From 06ad8b75f6c5ae0afcb53f1e75d67618f1096de3 Mon Sep 17 00:00:00 2001 From: Sebastian Mertens Make Date: Tue, 26 May 2026 10:58:28 +0200 Subject: [PATCH 1/2] feat: add SDK app icon commands Add sdk-apps set-icon/get-icon commands using the Make Apps SDK icon endpoint documented by the VS Code Apps SDK. The commands validate PNG inputs, enforce 512x512 by default, support icon readback, and include tests. Tested: npm run build Tested: npm run lint Tested: npm test Tested: live set-icon/get-icon against two-captcha-812rjr --- src/icon-commands.ts | 169 +++++++++++++++++++++++++++++++++++++ src/index.ts | 2 + test/icon-commands.spec.ts | 61 +++++++++++++ 3 files changed, 232 insertions(+) create mode 100644 src/icon-commands.ts create mode 100644 test/icon-commands.spec.ts diff --git a/src/icon-commands.ts b/src/icon-commands.ts new file mode 100644 index 0000000..575b97c --- /dev/null +++ b/src/icon-commands.ts @@ -0,0 +1,169 @@ +import { Command } from 'commander'; +import { readFile, writeFile } from 'node:fs/promises'; +import { basename } from 'node:path'; +import { resolveAuth } from './auth.js'; +import { formatOutput, type OutputFormat } from './output.js'; + +type PngInfo = { + width: number; + height: number; +}; + +const PNG_SIGNATURE = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]); +const DEFAULT_SDK_VERSION = '2.5.0'; + +export function getOrCreateSdkAppsCommand(program: Command): Command { + const existing = program.commands.find(cmd => cmd.name() === 'sdk-apps'); + if (existing) return existing; + return program.command('sdk-apps').description('App definitions'); +} + +export function readPngInfo(buffer: Buffer): PngInfo { + if (buffer.length < 24 || !buffer.subarray(0, 8).equals(PNG_SIGNATURE)) { + throw new Error('Icon file must be a PNG image.'); + } + + const ihdrType = buffer.subarray(12, 16).toString('ascii'); + if (ihdrType !== 'IHDR') { + throw new Error('Icon file is not a valid PNG image: missing IHDR chunk.'); + } + + return { + width: buffer.readUInt32BE(16), + height: buffer.readUInt32BE(20), + }; +} + +export function validateMakeZone(zone: string): void { + if (!/^[A-Za-z0-9.-]+$/.test(zone) || zone.startsWith('.') || zone.includes('..') || !zone.endsWith('.make.com')) { + throw new Error(`Invalid Make zone: ${zone}. Expected a make.com zone hostname, e.g. eu1.make.com.`); + } +} + +function getGlobalOptions(cmd: Command): { apiKey?: string; zone?: string; output?: OutputFormat } { + return cmd.optsWithGlobals() as { apiKey?: string; zone?: string; output?: OutputFormat }; +} + +function iconEndpoint(zone: string, name: string, version: string): string { + validatePositiveInteger(version, 'version'); + return `https://${zone}/api/v2/sdk/apps/${encodeURIComponent(name)}/${encodeURIComponent(version)}/icon`; +} + +function iconReadbackEndpoint(zone: string, name: string, version: string, size: string): string { + validatePositiveInteger(size, 'size'); + return `${iconEndpoint(zone, name, version)}/${encodeURIComponent(size)}`; +} + +function validatePositiveInteger(value: string, name: string): void { + if (!/^\d+$/.test(value) || Number(value) <= 0) { + throw new Error(`${name} must be a positive integer.`); + } +} + +async function parseErrorResponse(response: Response): Promise { + const text = await response.text().catch(() => ''); + if (!text) return `${response.status} ${response.statusText}`; + + try { + const json = JSON.parse(text) as { message?: string; detail?: string }; + return json.message || json.detail || text; + } catch { + return text; + } +} + +export function registerIconCommands(program: Command): void { + const sdkApps = getOrCreateSdkAppsCommand(program); + + if (!sdkApps.commands.some(cmd => cmd.name() === 'set-icon')) { + sdkApps + .command('set-icon ') + .description('Upload a 512x512 PNG icon for a SDK app.') + .option('--allow-non-512', 'upload a PNG even if it is not 512x512') + .option('--sdk-version ', 'Apps SDK version header', DEFAULT_SDK_VERSION) + .action(async (name: string, version: string, file: string, options: { allowNon512?: boolean; sdkVersion: string }, cmd: Command) => { + const globalOptions = getGlobalOptions(cmd); + const { token, zone } = await resolveAuth({ apiKey: globalOptions.apiKey, zone: globalOptions.zone }); + validateMakeZone(zone); + + const icon = await readFile(file); + const pngInfo = readPngInfo(icon); + if (!options.allowNon512 && (pngInfo.width !== 512 || pngInfo.height !== 512)) { + throw new Error( + `Icon must be 512x512 PNG. Got ${pngInfo.width}x${pngInfo.height}. ` + + 'Resize it first or pass --allow-non-512 intentionally.', + ); + } + + const response = await fetch(iconEndpoint(zone, name, version), { + method: 'PUT', + headers: { + Authorization: `Token ${token}`, + 'Content-Type': 'image/png', + 'imt-apps-sdk-version': options.sdkVersion || DEFAULT_SDK_VERSION, + }, + body: icon, + }); + + if (!response.ok) { + throw new Error(`Icon upload failed: ${await parseErrorResponse(response)}`); + } + + const result = { + changed: true, + appName: name, + version: Number(version), + file: basename(file), + width: pngInfo.width, + height: pngInfo.height, + readbackUrl: iconReadbackEndpoint(zone, name, version, '512'), + }; + process.stdout.write(formatOutput(result, (globalOptions.output as OutputFormat) ?? 'json') + '\n'); + }); + } + + if (!sdkApps.commands.some(cmd => cmd.name() === 'get-icon')) { + sdkApps + .command('get-icon [output-file]') + .description('Download a SDK app icon.') + .option('--size ', 'icon size to download', '512') + .option('--sdk-version ', 'Apps SDK version header', DEFAULT_SDK_VERSION) + .action(async (name: string, version: string, outputFile: string | undefined, options: { size: string; sdkVersion: string }, cmd: Command) => { + const globalOptions = getGlobalOptions(cmd); + const { token, zone } = await resolveAuth({ apiKey: globalOptions.apiKey, zone: globalOptions.zone }); + validateMakeZone(zone); + + const response = await fetch(iconReadbackEndpoint(zone, name, version, options.size), { + headers: { + Authorization: `Token ${token}`, + 'imt-apps-sdk-version': options.sdkVersion || DEFAULT_SDK_VERSION, + }, + }); + + if (!response.ok) { + throw new Error(`Icon download failed: ${await parseErrorResponse(response)}`); + } + + const contentType = (response.headers.get('content-type') || '').toLowerCase(); + if (!contentType.includes('image/png')) { + throw new Error(`Icon download returned unexpected content type: ${contentType || 'unknown'}`); + } + + const icon = Buffer.from(await response.arrayBuffer()); + const pngInfo = readPngInfo(icon); + if (outputFile) { + await writeFile(outputFile, icon); + const result = { + appName: name, + version: Number(version), + file: outputFile, + width: pngInfo.width, + height: pngInfo.height, + }; + process.stdout.write(formatOutput(result, (globalOptions.output as OutputFormat) ?? 'json') + '\n'); + } else { + process.stdout.write(icon); + } + }); + } +} diff --git a/src/index.ts b/src/index.ts index 56c562d..8cd8ab4 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,6 +1,7 @@ import { Command, Option } from 'commander'; import { MakeTools } from '@makehq/sdk/tools'; import { buildCommands } from './commands.js'; +import { registerIconCommands } from './icon-commands.js'; import { registerLoginCommands } from './login.js'; declare const __VERSION__: string; @@ -16,6 +17,7 @@ program .addOption(new Option('--output ', 'Output format').choices(['json', 'compact', 'table']).default('json')); buildCommands(program, MakeTools); +registerIconCommands(program); registerLoginCommands(program); program.parseAsync(process.argv).catch(err => { diff --git a/test/icon-commands.spec.ts b/test/icon-commands.spec.ts new file mode 100644 index 0000000..aa5dc25 --- /dev/null +++ b/test/icon-commands.spec.ts @@ -0,0 +1,61 @@ +import { describe, expect, it } from '@jest/globals'; +import { Command, Option } from 'commander'; +import { registerIconCommands, readPngInfo, validateMakeZone } from '../src/icon-commands.js'; + +function minimalPng(width = 512, height = 512): Buffer { + const buffer = Buffer.alloc(24); + Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]).copy(buffer, 0); + buffer.writeUInt32BE(13, 8); + buffer.write('IHDR', 12, 'ascii'); + buffer.writeUInt32BE(width, 16); + buffer.writeUInt32BE(height, 20); + return buffer; +} + +describe('CLI: icon commands', () => { + it('parses PNG dimensions from the IHDR chunk', () => { + expect(readPngInfo(minimalPng(512, 512))).toEqual({ width: 512, height: 512 }); + expect(readPngInfo(minimalPng(64, 128))).toEqual({ width: 64, height: 128 }); + }); + + it('rejects non-PNG files', () => { + expect(() => readPngInfo(Buffer.from('not a png'))).toThrow('Icon file must be a PNG image'); + }); + + it('accepts Make zone hostnames and rejects unsafe zones', () => { + expect(() => validateMakeZone('eu1.make.com')).not.toThrow(); + expect(() => validateMakeZone('us1.make.com')).not.toThrow(); + expect(() => validateMakeZone('evil.example.com')).toThrow('Invalid Make zone'); + expect(() => validateMakeZone('eu1.make.com.evil.test')).toThrow('Invalid Make zone'); + expect(() => validateMakeZone('https://eu1.make.com')).toThrow('Invalid Make zone'); + }); + + it('exposes integer validation through get-icon readback command arguments', () => { + const program = new Command(); + program + .option('--api-key ') + .option('--zone ') + .addOption(new Option('--output ').choices(['json', 'compact', 'table']).default('json')); + program.command('sdk-apps').description('App definitions'); + registerIconCommands(program); + + const getIcon = program.commands.find(c => c.name() === 'sdk-apps')?.commands.find(c => c.name() === 'get-icon'); + expect(getIcon?.options.find(o => o.long === '--size')?.defaultValue).toBe('512'); + }); + + it('registers sdk-apps set-icon and get-icon commands on an existing category command', () => { + const program = new Command(); + program + .option('--api-key ') + .option('--zone ') + .addOption(new Option('--output ').choices(['json', 'compact', 'table']).default('json')); + program.command('sdk-apps').description('App definitions'); + + registerIconCommands(program); + + const sdkApps = program.commands.find(c => c.name() === 'sdk-apps'); + expect(sdkApps).toBeDefined(); + expect(sdkApps?.commands.find(c => c.name() === 'set-icon')).toBeDefined(); + expect(sdkApps?.commands.find(c => c.name() === 'get-icon')).toBeDefined(); + }); +}); From b5b49a31a1a748fc1162dbcae4607ac41b3904a9 Mon Sep 17 00:00:00 2001 From: Sebastian Mertens Make Date: Tue, 26 May 2026 12:44:20 +0200 Subject: [PATCH 2/2] feat: add SDK app and module visibility commands Add sdk-apps set-public/set-private and sdk-modules set-public/set-private commands using the Apps SDK visibility endpoints. Document the complete SDK app workflow for icons, module sections, and public visibility. --- README.md | 37 ++++++++ src/index.ts | 2 + src/visibility-commands.ts | 146 +++++++++++++++++++++++++++++++ test/visibility-commands.spec.ts | 86 ++++++++++++++++++ 4 files changed, 271 insertions(+) create mode 100644 src/visibility-commands.ts create mode 100644 test/visibility-commands.spec.ts diff --git a/README.md b/README.md index 796e396..b625919 100644 --- a/README.md +++ b/README.md @@ -134,6 +134,43 @@ make-cli scenarios create \ make-cli scenarios list --team-id=123 --output=table ``` +### Custom app icons and visibility + +The SDK app helpers include the app-icon upload/readback endpoints used by Make's Apps SDK: + +```bash +# Upload a 512x512 PNG app icon +make-cli sdk-apps set-icon my-app 1 ./assets/icon.png + +# Read the uploaded 512px icon back for verification +make-cli sdk-apps get-icon my-app 1 /tmp/my-app-icon.png +``` + +Use 512x512 PNG icons for SDK apps. The upload command writes the raw PNG to the SDK icon endpoint and the readback command downloads `/icon/512`, which is the verification route. + +App visibility and module visibility are separate. After uploading a complete app, publish both the app and every module that should be available publicly: + +```bash +# Mark the app version public +make-cli sdk-apps set-public my-app 1 + +# Mark each module public +make-cli sdk-modules set-public my-app 1 makeAnApiCall +make-cli sdk-modules set-public my-app 1 listItems + +# Roll back if needed +make-cli sdk-modules set-private my-app 1 listItems +make-cli sdk-apps set-private my-app 1 +``` + +For a complete production SDK app, upload and verify at least: + +1. app base section and docs +2. connection object and connection sections +3. all module objects and their `api`, `expect`, `interface`, and `samples` sections +4. a 512x512 PNG icon with `sdk-apps set-icon` and `sdk-apps get-icon` +5. public visibility for the app and each module with `set-public` + ### Resource IDs Resource-level actions (`get`, `update`, `delete`, and similar) accept the resource's own ID as a **positional argument**. The long-form flag is still available for scripting or when you prefer being explicit — both forms are equivalent: diff --git a/src/index.ts b/src/index.ts index 8cd8ab4..cf95f3b 100644 --- a/src/index.ts +++ b/src/index.ts @@ -2,6 +2,7 @@ import { Command, Option } from 'commander'; import { MakeTools } from '@makehq/sdk/tools'; import { buildCommands } from './commands.js'; import { registerIconCommands } from './icon-commands.js'; +import { registerVisibilityCommands } from './visibility-commands.js'; import { registerLoginCommands } from './login.js'; declare const __VERSION__: string; @@ -18,6 +19,7 @@ program buildCommands(program, MakeTools); registerIconCommands(program); +registerVisibilityCommands(program); registerLoginCommands(program); program.parseAsync(process.argv).catch(err => { diff --git a/src/visibility-commands.ts b/src/visibility-commands.ts new file mode 100644 index 0000000..cd96f41 --- /dev/null +++ b/src/visibility-commands.ts @@ -0,0 +1,146 @@ +import { Command } from 'commander'; +import { resolveAuth } from './auth.js'; +import { formatOutput, type OutputFormat } from './output.js'; +import { getOrCreateSdkAppsCommand, validateMakeZone } from './icon-commands.js'; + +type Visibility = 'public' | 'private'; + +type GlobalOptions = { + apiKey?: string; + zone?: string; + output?: OutputFormat; +}; + +export function visibilityToPublicFlag(visibility: Visibility): boolean { + return visibility === 'public'; +} + +function validatePositiveInteger(value: string, name: string): void { + if (!/^\d+$/.test(value) || Number(value) <= 0) { + throw new Error(`${name} must be a positive integer.`); + } +} + +export function appVisibilityEndpoint(zone: string, name: string, version: string, visibility: Visibility): string { + validateMakeZone(zone); + validatePositiveInteger(version, 'version'); + return `https://${zone}/api/v2/sdk/apps/${encodeURIComponent(name)}/${encodeURIComponent(version)}/${visibility}`; +} + +export function moduleVisibilityEndpoint(zone: string, appName: string, appVersion: string, moduleName: string, visibility: Visibility): string { + validateMakeZone(zone); + validatePositiveInteger(appVersion, 'app version'); + return `https://${zone}/api/v2/sdk/apps/${encodeURIComponent(appName)}/${encodeURIComponent(appVersion)}/modules/${encodeURIComponent(moduleName)}/${visibility}`; +} + +function getOrCreateSdkModulesCommand(program: Command): Command { + const existing = program.commands.find(cmd => cmd.name() === 'sdk-modules'); + if (existing) return existing; + return program.command('sdk-modules').description('App modules'); +} + +function getGlobalOptions(program: Command): GlobalOptions { + return program.opts() as GlobalOptions; +} + +async function parseErrorResponse(response: Response): Promise { + const text = await response.text().catch(() => ''); + if (!text) return `${response.status} ${response.statusText}`; + + try { + const json = JSON.parse(text) as { message?: string; detail?: string; code?: string }; + const code = json.code ? ` (${json.code})` : ''; + return `${json.message || json.detail || text}${code}`; + } catch { + return text; + } +} + +async function postVisibility(endpoint: string, token: string): Promise<{ changed?: boolean }> { + const response = await fetch(endpoint, { + method: 'POST', + headers: { + Authorization: `Token ${token}`, + 'Content-Type': 'text/plain', + }, + body: '', + }); + + if (!response.ok) { + throw new Error(`Visibility update failed: ${await parseErrorResponse(response)}`); + } + + const text = await response.text().catch(() => ''); + if (!text) return {}; + try { + return JSON.parse(text) as { changed?: boolean }; + } catch { + return {}; + } +} + +async function resolveVisibilityContext(program: Command): Promise<{ token: string; zone: string; output: OutputFormat }> { + const globalOptions = getGlobalOptions(program); + const { token, zone } = await resolveAuth({ apiKey: globalOptions.apiKey, zone: globalOptions.zone }); + validateMakeZone(zone); + return { + token, + zone, + output: (globalOptions.output as OutputFormat) ?? 'json', + }; +} + +function registerAppVisibilityCommand(program: Command, sdkApps: Command, visibility: Visibility): void { + const commandName = `set-${visibility}`; + if (sdkApps.commands.some(cmd => cmd.name() === commandName)) return; + + sdkApps + .command(`${commandName} `) + .description(`Mark a SDK app version as ${visibility}.`) + .action(async (name: string, version: string) => { + const { token, zone, output } = await resolveVisibilityContext(program); + const response = await postVisibility(appVisibilityEndpoint(zone, name, version, visibility), token); + const result = { + changed: response.changed ?? true, + scope: 'app', + appName: name, + version: Number(version), + visibility, + public: visibilityToPublicFlag(visibility), + }; + process.stdout.write(formatOutput(result, output) + '\n'); + }); +} + +function registerModuleVisibilityCommand(program: Command, sdkModules: Command, visibility: Visibility): void { + const commandName = `set-${visibility}`; + if (sdkModules.commands.some(cmd => cmd.name() === commandName)) return; + + sdkModules + .command(`${commandName} `) + .description(`Mark a SDK app module as ${visibility}.`) + .action(async (appName: string, appVersion: string, moduleName: string) => { + const { token, zone, output } = await resolveVisibilityContext(program); + const response = await postVisibility(moduleVisibilityEndpoint(zone, appName, appVersion, moduleName, visibility), token); + const result = { + changed: response.changed ?? true, + scope: 'module', + appName, + version: Number(appVersion), + moduleName, + visibility, + public: visibilityToPublicFlag(visibility), + }; + process.stdout.write(formatOutput(result, output) + '\n'); + }); +} + +export function registerVisibilityCommands(program: Command): void { + const sdkApps = getOrCreateSdkAppsCommand(program); + const sdkModules = getOrCreateSdkModulesCommand(program); + + registerAppVisibilityCommand(program, sdkApps, 'public'); + registerAppVisibilityCommand(program, sdkApps, 'private'); + registerModuleVisibilityCommand(program, sdkModules, 'public'); + registerModuleVisibilityCommand(program, sdkModules, 'private'); +} diff --git a/test/visibility-commands.spec.ts b/test/visibility-commands.spec.ts new file mode 100644 index 0000000..b6ab227 --- /dev/null +++ b/test/visibility-commands.spec.ts @@ -0,0 +1,86 @@ +import { afterEach, describe, expect, it, jest } from '@jest/globals'; +import { Command, Option } from 'commander'; +import { + appVisibilityEndpoint, + moduleVisibilityEndpoint, + registerVisibilityCommands, + visibilityToPublicFlag, +} from '../src/visibility-commands.js'; + +function testProgram(): Command { + const program = new Command(); + program + .exitOverride() + .option('--api-key ') + .option('--zone ') + .addOption(new Option('--output ').choices(['json', 'compact', 'table']).default('json')); + program.command('sdk-apps').description('App definitions'); + program.command('sdk-modules').description('App modules'); + return program; +} + +describe('CLI: visibility commands', () => { + afterEach(() => { + jest.restoreAllMocks(); + delete process.env.MAKE_API_KEY; + delete process.env.MAKE_ZONE; + }); + + it('builds SDK app visibility endpoints', () => { + expect(appVisibilityEndpoint('eu1.make.com', 'my-app', '1', 'public')).toBe('https://eu1.make.com/api/v2/sdk/apps/my-app/1/public'); + expect(appVisibilityEndpoint('eu1.make.com', 'my app', '2', 'private')).toBe('https://eu1.make.com/api/v2/sdk/apps/my%20app/2/private'); + }); + + it('builds SDK module visibility endpoints', () => { + expect(moduleVisibilityEndpoint('eu1.make.com', 'my-app', '1', 'listItems', 'public')).toBe( + 'https://eu1.make.com/api/v2/sdk/apps/my-app/1/modules/listItems/public', + ); + expect(moduleVisibilityEndpoint('eu1.make.com', 'my app', '2', 'Make an API Call', 'private')).toBe( + 'https://eu1.make.com/api/v2/sdk/apps/my%20app/2/modules/Make%20an%20API%20Call/private', + ); + }); + + it('maps visibility names to public booleans', () => { + expect(visibilityToPublicFlag('public')).toBe(true); + expect(visibilityToPublicFlag('private')).toBe(false); + }); + + it('registers app and module visibility commands', () => { + const program = testProgram(); + registerVisibilityCommands(program); + + const sdkApps = program.commands.find(c => c.name() === 'sdk-apps'); + const sdkModules = program.commands.find(c => c.name() === 'sdk-modules'); + + expect(sdkApps?.commands.find(c => c.name() === 'set-public')).toBeDefined(); + expect(sdkApps?.commands.find(c => c.name() === 'set-private')).toBeDefined(); + expect(sdkModules?.commands.find(c => c.name() === 'set-public')).toBeDefined(); + expect(sdkModules?.commands.find(c => c.name() === 'set-private')).toBeDefined(); + }); + + it('posts to the module public endpoint and prints structured output', async () => { + process.env.MAKE_API_KEY = 'test-token'; + process.env.MAKE_ZONE = 'eu1.make.com'; + + const fetchMock = jest.fn().mockResolvedValue( + new Response(JSON.stringify({ changed: true }), { status: 200, headers: { 'content-type': 'application/json' } }), + ); + jest.spyOn(globalThis, 'fetch').mockImplementation(fetchMock); + const stdout = jest.spyOn(process.stdout, 'write').mockImplementation(() => true); + + const program = testProgram(); + registerVisibilityCommands(program); + await program.parseAsync(['node', 'make-cli', 'sdk-modules', 'set-public', 'my-app', '1', 'listItems'], { from: 'node' }); + + expect(fetchMock).toHaveBeenCalledWith('https://eu1.make.com/api/v2/sdk/apps/my-app/1/modules/listItems/public', { + method: 'POST', + headers: { + Authorization: 'Token test-token', + 'Content-Type': 'text/plain', + }, + body: '', + }); + const output = stdout.mock.calls.map(call => String(call[0])).join(''); + expect(JSON.parse(output)).toMatchObject({ scope: 'module', appName: 'my-app', moduleName: 'listItems', public: true }); + }); +});