Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions pumble-cli/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -172,6 +172,12 @@ async function main() {
.option('inspect', {
type: 'string',
description: 'NodeJS --inspect',
})
.option('debug', {
alias: 'd',
type: 'boolean',
default: false,
description: 'Enable debug logging (requests, responses, payloads)',
});
},
async (args) => {
Expand Down
38 changes: 27 additions & 11 deletions pumble-cli/src/services/AppSync.ts
Original file line number Diff line number Diff line change
Expand Up @@ -435,18 +435,34 @@ class AppSync {
}

private async updateApp(app: AddonManifest, manifest: AddonManifest) {
await cliPumbleApiClient
.updateApp(app.id, manifest)
.catch((e) => {
if (e instanceof AxiosError) {
logger.error(`Error updating app: ${e.response?.data.message}`);
} else {
console.log('ERROR', e);
try {
await cliPumbleApiClient.updateApp(app.id, manifest);
logger.success('App is updated');
} catch (e) {
if (e instanceof AxiosError) {
const status = e.response?.status;
const data = e.response?.data;
logger.error(`Error updating app (HTTP ${status}):`);
if (data?.message) {
logger.error(` Message: ${data.message}`);
}
})
.then(() => {
logger.success('App is updated');
});
if (data?.errors) {
logger.error(` Errors: ${JSON.stringify(data.errors)}`);
}
if (status === 500) {
logger.error(` This is a Pumble server error. The request may be valid but Pumble failed to process it.`);
logger.error(` Try again later or contact Pumble support.`);
}
if (process.env.DEBUG) {
logger.error(` Request URL: ${e.config?.url}`);
logger.error(` Request data: ${JSON.stringify(manifest, null, 2)}`);
}
} else if (e instanceof Error) {
logger.error(`Error updating app: ${e.message}`);
} else {
logger.error(`Unknown error updating app: ${e}`);
}
}
}

private userScopesChanged(oldApp: AddonManifest, newApp: AddonManifest): boolean {
Expand Down
1 change: 1 addition & 0 deletions pumble-cli/src/services/CommandsService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -124,6 +124,7 @@ class CommandsService {
host?: string;
port?: number;
inspect?: string;
debug?: boolean;
}) {
try {
await this.loadEnvironment(args.globalConfigFile);
Expand Down
249 changes: 249 additions & 0 deletions pumble-cli/src/services/ManifestValidator.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,249 @@
/**
* Manifest Validator
*
* Validates Pumble app manifest before sending to API to catch errors early
* and provide meaningful error messages instead of generic 500 errors.
*/

export interface ValidationResult {
valid: boolean;
errors: string[];
warnings: string[];
}

// Known supported fields in Pumble manifest
const SUPPORTED_ROOT_FIELDS = new Set([
'id',
'name',
'displayName',
'bot',
'botTitle',
'socketMode',
'scopes',
'eventSubscriptions',
'welcomeMessage',
'offlineMessage',
'slashCommands',
'shortcuts',
'blockInteraction',
'viewAction',
'dynamicMenus',
'redirectUrls',
'listingUrl',
'helpUrl',
// Internal fields (added by SDK)
'clientSecret',
'appKey',
'signingSecret',
]);

// Fields that are known to cause 500 errors
const UNSUPPORTED_FIELDS = new Set([
'listingIcon',
'avatar',
'icon',
'logo',
]);

// Valid bot scopes
const VALID_BOT_SCOPES = new Set([
'messages:read',
'messages:write',
'channels:read',
'channels:write',
'users:read',
'files:read',
'files:write',
'reactions:read',
'reactions:write',
]);

// Valid user scopes
const VALID_USER_SCOPES = new Set([
'messages:read',
'messages:write',
'channels:read',
'channels:write',
'users:read',
'files:read',
'files:write',
'reactions:read',
'reactions:write',
]);

// Valid shortcut types
const VALID_SHORTCUT_TYPES = new Set(['GLOBAL', 'ON_MESSAGE']);

// Valid event types
const VALID_EVENT_TYPES = new Set([
'NEW_MESSAGE',
'UPDATED_MESSAGE',
'DELETED_MESSAGE',
'REACTION_ADDED',
'REACTION_REMOVED',
'CHANNEL_CREATED',
'CHANNEL_UPDATED',
'CHANNEL_DELETED',
'USER_JOINED_CHANNEL',
'USER_LEFT_CHANNEL',
'APP_INSTALLED',
'APP_UNINSTALLED',
]);

/**
* Validates a Pumble app manifest
*/
export function validateManifest(manifest: Record<string, unknown>): ValidationResult {
const errors: string[] = [];
const warnings: string[] = [];

// Check for unsupported fields that cause 500 errors
for (const field of UNSUPPORTED_FIELDS) {
if (field in manifest) {
errors.push(`"${field}" is not supported by Pumble API and will cause an error. Remove this field.`);
}
}

// Unknown fields are errors (not warnings) - they can cause 500 errors
for (const key of Object.keys(manifest)) {
if (!SUPPORTED_ROOT_FIELDS.has(key) && !UNSUPPORTED_FIELDS.has(key)) {
errors.push(`Unknown field "${key}" is not supported by Pumble API. Remove this field.`);
}
}

// Required fields
if (!manifest.name) {
errors.push('"name" is required.');
} else if (typeof manifest.name !== 'string') {
errors.push('"name" must be a string.');
} else if (!/^[a-z0-9_]+$/.test(manifest.name as string)) {
errors.push('"name" must contain only lowercase letters, numbers, and underscores.');
}

if (!manifest.displayName) {
errors.push('"displayName" is required.');
} else if (typeof manifest.displayName !== 'string') {
errors.push('"displayName" must be a string.');
}

// Scopes validation
if (!manifest.scopes) {
errors.push('"scopes" is required.');
} else if (typeof manifest.scopes !== 'object' || manifest.scopes === null) {
errors.push('"scopes" must be an object.');
} else {
const scopes = manifest.scopes as Record<string, unknown>;

if (!Array.isArray(scopes.botScopes)) {
errors.push('"scopes.botScopes" must be an array.');
} else {
for (const scope of scopes.botScopes) {
if (!VALID_BOT_SCOPES.has(scope as string)) {
warnings.push(`Unknown bot scope "${scope}" - verify this is valid.`);
}
}
}

if (!Array.isArray(scopes.userScopes)) {
errors.push('"scopes.userScopes" must be an array.');
} else {
for (const scope of scopes.userScopes) {
if (!VALID_USER_SCOPES.has(scope as string)) {
warnings.push(`Unknown user scope "${scope}" - verify this is valid.`);
}
}
}
}

// Slash commands validation
if (manifest.slashCommands !== undefined) {
if (!Array.isArray(manifest.slashCommands)) {
errors.push('"slashCommands" must be an array.');
} else {
(manifest.slashCommands as any[]).forEach((cmd, i) => {
if (!cmd.command) {
errors.push(`slashCommands[${i}]: "command" is required.`);
} else if (!cmd.command.startsWith('/')) {
errors.push(`slashCommands[${i}]: "command" must start with "/".`);
}
if (!cmd.url) {
errors.push(`slashCommands[${i}]: "url" is required.`);
}
});
}
}

// Shortcuts validation
if (manifest.shortcuts !== undefined) {
if (!Array.isArray(manifest.shortcuts)) {
errors.push('"shortcuts" must be an array.');
} else {
(manifest.shortcuts as any[]).forEach((shortcut, i) => {
if (!shortcut.name) {
errors.push(`shortcuts[${i}]: "name" is required.`);
}
if (!shortcut.shortcutType) {
errors.push(`shortcuts[${i}]: "shortcutType" is required.`);
} else if (!VALID_SHORTCUT_TYPES.has(shortcut.shortcutType)) {
errors.push(`shortcuts[${i}]: "shortcutType" must be "GLOBAL" or "ON_MESSAGE".`);
}
if (!shortcut.url) {
errors.push(`shortcuts[${i}]: "url" is required.`);
}
});
}
}

// Event subscriptions validation
if (manifest.eventSubscriptions !== undefined) {
const evtSub = manifest.eventSubscriptions as Record<string, unknown>;

if (typeof evtSub !== 'object' || evtSub === null) {
errors.push('"eventSubscriptions" must be an object.');
} else {
if (evtSub.events !== undefined) {
if (!Array.isArray(evtSub.events)) {
errors.push('"eventSubscriptions.events" must be an array.');
} else {
(evtSub.events as any[]).forEach((evt, i) => {
const eventName = typeof evt === 'string' ? evt : evt?.name;
if (!eventName) {
errors.push(`eventSubscriptions.events[${i}]: event name is required.`);
} else if (!VALID_EVENT_TYPES.has(eventName)) {
warnings.push(`eventSubscriptions.events[${i}]: unknown event "${eventName}".`);
}
});
}
}
}
}

// Dynamic menus - warn if empty array (can cause issues)
if (manifest.dynamicMenus !== undefined) {
if (Array.isArray(manifest.dynamicMenus) && manifest.dynamicMenus.length === 0) {
warnings.push('"dynamicMenus" is an empty array - consider removing it.');
}
}

// Boolean fields
if (manifest.bot !== undefined && typeof manifest.bot !== 'boolean') {
errors.push('"bot" must be a boolean.');
}
if (manifest.socketMode !== undefined && typeof manifest.socketMode !== 'boolean') {
errors.push('"socketMode" must be a boolean.');
}

// String fields
const stringFields = ['botTitle', 'welcomeMessage', 'offlineMessage', 'listingUrl', 'helpUrl'];
for (const field of stringFields) {
if (manifest[field] !== undefined && typeof manifest[field] !== 'string') {
errors.push(`"${field}" must be a string.`);
}
}

return {
valid: errors.length === 0,
errors,
warnings,
};
}
Loading