From b10c7c5b043396135de6a696b167d90eaeb5b8a6 Mon Sep 17 00:00:00 2001 From: "heecheol.park" Date: Sat, 20 Jun 2026 22:42:16 +0900 Subject: [PATCH 1/2] feat: add --json output to mutation commands Extend the global --json flag to all write commands so their results (new page id/url, deletion confirmations, purge counts) are machine-readable for scripting and jq, completing --json coverage across the CLI. - create / create-child / update / move / delete / copy-tree - comment / comment-delete / property-delete - attachment-upload / attachment-delete / version-delete / versions-purge - destructive commands require --yes in --json mode (no interactive prompt) - progress and confirmation chatter is suppressed so stdout stays valid JSON Closes #203 --- README.md | 31 ++++----- bin/commands/attachments.js | 39 +++++++++--- bin/commands/comment.js | 24 ++++++- bin/commands/properties.js | 12 +++- bin/commands/versions.js | 50 ++++++++++++--- bin/confluence.js | 122 +++++++++++++++++++++++++++++++----- tests/json-output.test.js | 46 ++++++++++++++ 7 files changed, 274 insertions(+), 50 deletions(-) diff --git a/README.md b/README.md index a657377..169b849 100644 --- a/README.md +++ b/README.md @@ -343,15 +343,16 @@ For **read-only** usage, select at minimum: `read:confluence-content.all`, `read ### JSON output (for scripting / jq) -Pass the global `--json` flag to make any read command emit raw JSON to stdout, so you can pipe it to tools like `jq`: +Pass the global `--json` flag to make a command emit raw JSON to stdout, so you can pipe it to tools like `jq`: ```bash confluence search "release notes" --json | jq '.results[].title' confluence spaces --json | jq -r '.spaces[].key' confluence info 123456789 --json | jq '.version' +confluence create "Notes" ENG --content "hi" --json | jq -r '.id' # capture the new page id ``` -`--json` works on `info`, `search`, `spaces`, `find`, `children`, `versions`, `comments`, `attachments`, `property-list`, `property-get`, and `property-set`. Passing `--json` to any other command is rejected with an error rather than silently ignored. Human-readable messages and warnings go to stderr, so stdout stays valid JSON. +`--json` works on read commands (`info`, `search`, `spaces`, `find`, `children`, `versions`, `comments`, `attachments`, `property-list`/`-get`/`-set`) and write commands (`create`, `create-child`, `update`, `move`, `delete`, `copy-tree`, `comment`, `comment-delete`, `property-delete`, `attachment-upload`/`-delete`, `version-delete`, `versions-purge`). For destructive commands, `--json` requires `--yes` — it will not prompt interactively. Passing `--json` to a command that doesn't support it (e.g. `init`, `convert`, `export`, `edit`) is rejected with an error rather than silently ignored. Human-readable messages and warnings go to stderr, so stdout stays valid JSON. > **Deprecation:** the per-command `--format json` form is deprecated in favor of the global `--json` flag. It still works but prints a warning to stderr and will be removed in a future major version. @@ -792,26 +793,26 @@ confluence stats | `spaces` | List available spaces | `--json`, `--limit `, `--all` | | `find ` | Find a page by its title | `--space <spaceKey>`, `--json` | | `children <pageId>` | List child pages of a page | `--recursive`, `--max-depth <number>`, `--format <list\|tree>`, `--json`, `--show-url`, `--show-id` | -| `create <title> <spaceKey>` | Create a new page or folder | `--content <string>`, `--file <path>`, `--format <auto\|storage\|html\|markdown>`, `--type <page\|folder>` | -| `create-child <title> <parentId>` | Create a child page or folder | `--content <string>`, `--file <path>`, `--format <auto\|storage\|html\|markdown>`, `--type <page\|folder>` | -| `copy-tree <sourcePageId> <targetParentId> [newTitle]` | Copy page tree with all children | `--max-depth <number>`, `--exclude <patterns>`, `--delay-ms <ms>`, `--copy-suffix <text>`, `--dry-run`, `--fail-on-error`, `--quiet` | -| `update <pageId>` | Update a page's title or content | `--title <string>`, `--content <string>`, `--file <path>`, `--format <auto\|storage\|html\|markdown>` | -| `move <pageId_or_url> <newParentId_or_url>` | Move a page to a new parent location | `--title <string>` | -| `delete <pageId_or_url>` | Delete a page by ID or URL | `--yes` | +| `create <title> <spaceKey>` | Create a new page or folder | `--content <string>`, `--file <path>`, `--format <auto\|storage\|html\|markdown>`, `--type <page\|folder>`, `--json` | +| `create-child <title> <parentId>` | Create a child page or folder | `--content <string>`, `--file <path>`, `--format <auto\|storage\|html\|markdown>`, `--type <page\|folder>`, `--json` | +| `copy-tree <sourcePageId> <targetParentId> [newTitle]` | Copy page tree with all children | `--max-depth <number>`, `--exclude <patterns>`, `--delay-ms <ms>`, `--copy-suffix <text>`, `--dry-run`, `--fail-on-error`, `--quiet`, `--json` | +| `update <pageId>` | Update a page's title or content | `--title <string>`, `--content <string>`, `--file <path>`, `--format <auto\|storage\|html\|markdown>`, `--json` | +| `move <pageId_or_url> <newParentId_or_url>` | Move a page to a new parent location | `--title <string>`, `--json` | +| `delete <pageId_or_url>` | Delete a page by ID or URL | `--yes`, `--json` | | `versions <pageId_or_url>` | List historical versions of a page | `--json` | -| `version-delete <pageId_or_url> <versionNumber>` | Delete a single non-current version of a page | `--yes` | -| `versions-purge <pageId_or_url>` | Delete every non-current historical version of a page | `--yes`, `--throttle <seconds>` | +| `version-delete <pageId_or_url> <versionNumber>` | Delete a single non-current version of a page | `--yes`, `--json` | +| `versions-purge <pageId_or_url>` | Delete every non-current historical version of a page | `--yes`, `--throttle <seconds>`, `--json` | | `edit <pageId>` | Export page content for editing | `--output <file>` | | `attachments <pageId_or_url>` | List or download attachments for a page | `--json`, `--limit <number>`, `--pattern <glob>`, `--download`, `--dest <directory>` | -| `attachment-upload <pageId_or_url>` | Upload attachments to a page | `--file <path>`, `--comment <text>`, `--replace`, `--minor-edit` | -| `attachment-delete <pageId_or_url> <attachmentId>` | Delete an attachment from a page | `--yes` | +| `attachment-upload <pageId_or_url>` | Upload attachments to a page | `--file <path>`, `--comment <text>`, `--replace`, `--minor-edit`, `--json` | +| `attachment-delete <pageId_or_url> <attachmentId>` | Delete an attachment from a page | `--yes`, `--json` | | `comments <pageId_or_url>` | List comments for a page | `--format <text\|markdown>`, `--json`, `--limit <number>`, `--start <number>`, `--location <inline\|footer\|resolved>`, `--depth <root\|all>`, `--all` | -| `comment <pageId_or_url>` | Create a comment on a page | `--content <string>`, `--file <path>`, `--format <auto\|storage\|html\|markdown>`, `--parent <commentId>`, `--location <inline\|footer>`, `--inline-selection <text>`, `--inline-original-selection <text>`, `--inline-marker-ref <ref>`, `--inline-properties <json>` | -| `comment-delete <commentId>` | Delete a comment by ID | `--yes` | +| `comment <pageId_or_url>` | Create a comment on a page | `--content <string>`, `--file <path>`, `--format <auto\|storage\|html\|markdown>`, `--parent <commentId>`, `--location <inline\|footer>`, `--inline-selection <text>`, `--inline-original-selection <text>`, `--inline-marker-ref <ref>`, `--inline-properties <json>`, `--json` | +| `comment-delete <commentId>` | Delete a comment by ID | `--yes`, `--json` | | `property-list <pageId_or_url>` | List all content properties for a page | `--json`, `--limit <number>`, `--start <number>`, `--all` | | `property-get <pageId_or_url> <key>` | Get a content property by key | `--json` | | `property-set <pageId_or_url> <key>` | Set a content property (create or update) | `--value <json>`, `--file <path>`, `--json` | -| `property-delete <pageId_or_url> <key>` | Delete a content property by key | `--yes` | +| `property-delete <pageId_or_url> <key>` | Delete a content property by key | `--yes`, `--json` | | `export <pageId_or_url>` | Export a page to a directory with its attachments | `--format <html\|text\|markdown>`, `--dest <directory>`, `--file <filename>`, `--attachments-dir <name>`, `--pattern <glob>`, `--referenced-only`, `--skip-attachments` | | `profile list` | List all configuration profiles | | | `profile use <name>` | Set the active configuration profile | | diff --git a/bin/commands/attachments.js b/bin/commands/attachments.js index 69f0183..f7d9003 100644 --- a/bin/commands/attachments.js +++ b/bin/commands/attachments.js @@ -128,7 +128,8 @@ function registerAttachmentCommands(program, { withClient }) { .option('--comment <comment>', 'Comment for the attachment(s)') .option('--replace', 'Replace an existing attachment with the same filename') .option('--minor-edit', 'Mark the upload as a minor edit') - .action(withClient('attachment_upload', async ({ client, analytics }, pageId, options) => { + .action(withClient('attachment_upload', async ({ client, analytics, wantsJson, emitJson }, pageId, options) => { + const jsonMode = wantsJson(); const files = Array.isArray(options.file) ? options.file.filter(Boolean) : []; if (files.length === 0) { throw new Error('At least one --file option is required.'); @@ -145,7 +146,7 @@ function registerAttachmentCommands(program, { withClient }) { } }); - let uploaded = 0; + const uploadedItems = []; for (const file of resolvedFiles) { const result = await client.uploadAttachment(pageId, file.resolved, { comment: options.comment, @@ -153,15 +154,25 @@ function registerAttachmentCommands(program, { withClient }) { minorEdit: options.minorEdit === true ? true : undefined, }); const attachment = result.results[0]; - if (attachment) { - console.log(`⬆️ ${chalk.green(attachment.title)} (ID: ${attachment.id}, Version: ${attachment.version})`); - } else { - console.log(`⬆️ ${chalk.green(path.basename(file.resolved))}`); + uploadedItems.push(attachment + ? { id: attachment.id, title: attachment.title, version: attachment.version } + : { title: path.basename(file.resolved) }); + if (!jsonMode) { + if (attachment) { + console.log(`⬆️ ${chalk.green(attachment.title)} (ID: ${attachment.id}, Version: ${attachment.version})`); + } else { + console.log(`⬆️ ${chalk.green(path.basename(file.resolved))}`); + } } - uploaded += 1; } - console.log(chalk.green(`Uploaded ${uploaded} attachment${uploaded === 1 ? '' : 's'} to page ${pageId}`)); + if (jsonMode) { + emitJson({ pageId, uploaded: uploadedItems }); + analytics.track('attachment_upload', true); + return; + } + + console.log(chalk.green(`Uploaded ${uploadedItems.length} attachment${uploadedItems.length === 1 ? '' : 's'} to page ${pageId}`)); analytics.track('attachment_upload', true); }, { writable: true })); @@ -169,8 +180,12 @@ function registerAttachmentCommands(program, { withClient }) { .command('attachment-delete <pageId> <attachmentId>') .description('Delete an attachment by ID from a page') .option('-y, --yes', 'Skip confirmation prompt') - .action(withClient('attachment_delete', async ({ client, analytics }, pageId, attachmentId, options) => { + .action(withClient('attachment_delete', async ({ client, analytics, wantsJson, emitJson }, pageId, attachmentId, options) => { + const jsonMode = wantsJson(); if (!options.yes) { + if (jsonMode) { + throw new Error('Refusing to delete without confirmation in --json mode. Pass --yes to proceed.'); + } const { confirmed } = await inquirer.prompt([ { type: 'confirm', @@ -189,6 +204,12 @@ function registerAttachmentCommands(program, { withClient }) { const result = await client.deleteAttachment(pageId, attachmentId); + if (jsonMode) { + emitJson({ id: result.id, pageId: result.pageId, deleted: true }); + analytics.track('attachment_delete', true); + return; + } + console.log(chalk.green('✅ Attachment deleted successfully!')); console.log(`ID: ${chalk.blue(result.id)}`); console.log(`Page ID: ${chalk.blue(result.pageId)}`); diff --git a/bin/commands/comment.js b/bin/commands/comment.js index 328d0f0..027d23b 100644 --- a/bin/commands/comment.js +++ b/bin/commands/comment.js @@ -208,7 +208,7 @@ function registerCommentCommands(program, { withClient }) { .option('--inline-original-selection <text>', 'Original inline selection text') .option('--inline-marker-ref <ref>', 'Inline marker reference (optional)') .option('--inline-properties <json>', 'Inline properties JSON (advanced)') - .action(withClient('comment_create', async ({ client, analytics }, pageId, options) => { + .action(withClient('comment_create', async ({ client, analytics, wantsJson, emitJson }, pageId, options) => { let content = ''; if (options.file) { @@ -277,6 +277,16 @@ function registerCommentCommands(program, { withClient }) { inlineProperties: location === 'inline' ? inlineProperties : null, }); + if (wantsJson()) { + emitJson({ + id: result.id, + pageId: result.container?.id ?? null, + url: result._links?.webui ? client.toAbsoluteUrl(result._links.webui) : null, + }); + analytics.track('comment_create', true); + return; + } + console.log(chalk.green('✅ Comment created successfully!')); console.log(`ID: ${chalk.blue(result.id)}`); if (result.container?.id) { @@ -315,8 +325,12 @@ function registerCommentCommands(program, { withClient }) { .command('comment-delete <commentId>') .description('Delete a comment by ID') .option('-y, --yes', 'Skip confirmation prompt') - .action(withClient('comment_delete', async ({ client, analytics }, commentId, options) => { + .action(withClient('comment_delete', async ({ client, analytics, wantsJson, emitJson }, commentId, options) => { + const jsonMode = wantsJson(); if (!options.yes) { + if (jsonMode) { + throw new Error('Refusing to delete without confirmation in --json mode. Pass --yes to proceed.'); + } const { confirmed } = await inquirer.prompt([ { type: 'confirm', @@ -335,6 +349,12 @@ function registerCommentCommands(program, { withClient }) { const result = await client.deleteComment(commentId); + if (jsonMode) { + emitJson({ id: result.id, deleted: true }); + analytics.track('comment_delete', true); + return; + } + console.log(chalk.green('✅ Comment deleted successfully!')); console.log(`ID: ${chalk.blue(result.id)}`); analytics.track('comment_delete', true); diff --git a/bin/commands/properties.js b/bin/commands/properties.js index 80f034d..f6c7606 100644 --- a/bin/commands/properties.js +++ b/bin/commands/properties.js @@ -138,8 +138,12 @@ function registerPropertyCommands(program, { withClient }) { .command('property-delete <pageId> <key>') .description('Delete a content property by key') .option('-y, --yes', 'Skip confirmation prompt') - .action(withClient('property_delete', async ({ client, analytics }, pageId, key, options) => { + .action(withClient('property_delete', async ({ client, analytics, wantsJson, emitJson }, pageId, key, options) => { + const jsonMode = wantsJson(); if (!options.yes) { + if (jsonMode) { + throw new Error('Refusing to delete without confirmation in --json mode. Pass --yes to proceed.'); + } const { confirmed } = await inquirer.prompt([ { type: 'confirm', @@ -158,6 +162,12 @@ function registerPropertyCommands(program, { withClient }) { const result = await client.deleteProperty(pageId, key); + if (jsonMode) { + emitJson({ key: result.key, pageId: result.pageId, deleted: true }); + analytics.track('property_delete', true); + return; + } + console.log(chalk.green('✅ Property deleted successfully!')); console.log(`${chalk.green('Key:')} ${chalk.blue(result.key)}`); console.log(`${chalk.green('Page ID:')} ${chalk.blue(result.pageId)}`); diff --git a/bin/commands/versions.js b/bin/commands/versions.js index 8201e5a..0fa3cb0 100644 --- a/bin/commands/versions.js +++ b/bin/commands/versions.js @@ -32,11 +32,15 @@ function registerVersionCommands(program, { withClient }) { .command('version-delete <pageId> <versionNumber>') .description('Delete a single historical version of a page (cannot delete the current version)') .option('-y, --yes', 'Skip confirmation prompt') - .action(withClient('version_delete', async ({ client, analytics }, pageId, versionNumber, options) => { + .action(withClient('version_delete', async ({ client, analytics, wantsJson, emitJson }, pageId, versionNumber, options) => { + const jsonMode = wantsJson(); const resolvedId = String(await client.extractPageId(pageId)); const n = Number(versionNumber); if (!options.yes) { + if (jsonMode) { + throw new Error('Refusing to delete without confirmation in --json mode. Pass --yes to proceed.'); + } const { confirmed } = await inquirer.prompt([{ type: 'confirm', name: 'confirmed', @@ -51,6 +55,13 @@ function registerVersionCommands(program, { withClient }) { } const result = await client.deleteVersion(resolvedId, n); + + if (jsonMode) { + emitJson({ id: result.id, versionNumber: result.versionNumber, viaExperimental: !!result.viaExperimental, deleted: true }); + analytics.track('version_delete', true); + return; + } + const note = result.viaExperimental ? chalk.yellow(' (via experimental endpoint)') : ''; console.log(chalk.green(`✅ Deleted v${result.versionNumber} of page ${result.id}${note}`)); analytics.track('version_delete', true); @@ -61,11 +72,17 @@ function registerVersionCommands(program, { withClient }) { .description('Delete every non-current historical version of a page (keeps only current)') .option('-y, --yes', 'Skip confirmation prompt') .option('--throttle <seconds>', 'Sleep between version-delete calls', '0') - .action(withClient('versions_purge', async ({ client, analytics }, pageId, options) => { + .action(withClient('versions_purge', async ({ client, analytics, wantsJson, emitJson }, pageId, options) => { + const jsonMode = wantsJson(); const resolvedId = String(await client.extractPageId(pageId)); const versions = await client.listVersions(resolvedId); if (versions.length === 0) { + if (jsonMode) { + emitJson({ id: resolvedId, deleted: 0, failed: 0, kept: null }); + analytics.track('versions_purge', true); + return; + } console.log(chalk.yellow(`No versions returned for page ${resolvedId}.`)); analytics.track('versions_purge', true); return; @@ -73,12 +90,20 @@ function registerVersionCommands(program, { withClient }) { const max = Math.max(...versions.map(v => v.number)); const historicalCount = versions.filter(v => v.number !== max).length; if (historicalCount === 0) { + if (jsonMode) { + emitJson({ id: resolvedId, deleted: 0, failed: 0, kept: max }); + analytics.track('versions_purge', true); + return; + } console.log(chalk.yellow(`Only current version v${max} exists for page ${resolvedId}; nothing to purge.`)); analytics.track('versions_purge', true); return; } if (!options.yes) { + if (jsonMode) { + throw new Error('Refusing to purge versions without confirmation in --json mode. Pass --yes to proceed.'); + } const { confirmed } = await inquirer.prompt([{ type: 'confirm', name: 'confirmed', @@ -95,11 +120,13 @@ function registerVersionCommands(program, { withClient }) { const throttleMs = Math.max(0, parseFloat(options.throttle || '0')) * 1000; const result = await client.purgeNonCurrentVersions(resolvedId, { onProgress: async (event) => { - if (event.kind === 'deleted') { - const note = event.viaExperimental ? chalk.yellow(' (experimental)') : ''; - console.log(chalk.green(` ✓ deleted v${event.versionNumber}${note}`)); - } else if (event.kind === 'failed') { - console.log(chalk.red(` ✗ v${event.versionNumber}: ${event.message}`)); + if (!jsonMode) { + if (event.kind === 'deleted') { + const note = event.viaExperimental ? chalk.yellow(' (experimental)') : ''; + console.log(chalk.green(` ✓ deleted v${event.versionNumber}${note}`)); + } else if (event.kind === 'failed') { + console.log(chalk.red(` ✗ v${event.versionNumber}: ${event.message}`)); + } } if (throttleMs > 0) { await new Promise(r => setTimeout(r, throttleMs)); @@ -107,6 +134,15 @@ function registerVersionCommands(program, { withClient }) { }, }); + if (jsonMode) { + emitJson({ id: result.id, deleted: result.deleted, failed: result.failed, kept: result.kept }); + analytics.track('versions_purge', result.failed === 0); + if (result.failed > 0) { + process.exitCode = 1; + } + return; + } + console.log(''); console.log(chalk.green(`✅ Purge complete for page ${result.id}: ` + `${result.deleted} deleted, ${result.failed} failed, kept v${result.kept}.`)); diff --git a/bin/confluence.js b/bin/confluence.js index fa83da5..a44ac00 100755 --- a/bin/confluence.js +++ b/bin/confluence.js @@ -129,10 +129,16 @@ function wantsJson(options) { // Commands that honor the global --json flag. Passing --json to anything else is // a usage error — fail loud instead of silently emitting human-readable output. const JSON_COMMANDS = new Set([ + // read / query 'info', 'search', 'spaces', 'find', 'children', 'versions', 'comments', 'attachments', 'property-list', 'property-get', 'property-set', 'api', // already emits raw JSON + // mutations + 'create', 'create-child', 'update', 'move', 'delete', 'copy-tree', + 'comment', 'comment-delete', 'property-delete', + 'attachment-upload', 'attachment-delete', + 'version-delete', 'versions-purge', ]); program.hook('preAction', (thisCommand, actionCommand) => { @@ -317,7 +323,7 @@ program .option('-c, --content <content>', 'Page content as string') .option('--format <format>', 'Content format (auto, storage, html, markdown)', 'storage') .option('--type <type>', 'Content type (page, folder)', 'page') - .action(withClient('create', async ({ client, analytics }, title, spaceKey, options) => { + .action(withClient('create', async ({ client, analytics, wantsJson, emitJson }, title, spaceKey, options) => { assertNonEmpty(title, 'title'); assertNonEmpty(spaceKey, 'spaceKey'); assertValidType(options.type); @@ -338,6 +344,18 @@ program const result = await client.createPage(title, spaceKey, content, options.format, options.type); + if (wantsJson()) { + emitJson({ + id: result.id, + title: result.title, + type: options.type, + spaceKey: result.space.key, + url: client.buildUrl(`${client.webUrlPrefix}${result._links.webui}`), + }); + analytics.track('create', true); + return; + } + const label = options.type === 'folder' ? 'Folder' : 'Page'; console.log(chalk.green(`✅ ${label} created successfully!`)); console.log(`Title: ${chalk.blue(result.title)}`); @@ -356,7 +374,7 @@ program .option('-c, --content <content>', 'Page content as string') .option('--format <format>', 'Content format (auto, storage, html, markdown)', 'storage') .option('--type <type>', 'Content type (page, folder)', 'page') - .action(withClient('create_child', async ({ client, analytics }, title, parentId, options) => { + .action(withClient('create_child', async ({ client, analytics, wantsJson, emitJson }, title, parentId, options) => { assertNonEmpty(title, 'title'); assertNonEmpty(parentId, 'parentId'); assertValidType(options.type); @@ -381,6 +399,19 @@ program const result = await client.createChildPage(title, spaceKey, parentId, content, options.format, options.type); + if (wantsJson()) { + emitJson({ + id: result.id, + title: result.title, + type: options.type, + parentId, + spaceKey: result.space.key, + url: client.buildUrl(`${client.webUrlPrefix}${result._links.webui}`), + }); + analytics.track('create_child', true); + return; + } + const label = options.type === 'folder' ? 'Folder' : 'Child page'; console.log(chalk.green(`✅ ${label} created successfully!`)); console.log(`Title: ${chalk.blue(result.title)}`); @@ -400,7 +431,7 @@ program .option('-f, --file <file>', 'Read content from file') .option('-c, --content <content>', 'Page content as string') .option('--format <format>', 'Content format (auto, storage, html, markdown)', 'storage') - .action(withClient('update', async ({ client, analytics }, pageId, options) => { + .action(withClient('update', async ({ client, analytics, wantsJson, emitJson }, pageId, options) => { // Check if at least one option is provided if (!options.title && !options.file && !options.content) { throw new Error('At least one of --title, --file, or --content must be provided.'); @@ -423,6 +454,17 @@ program const result = await client.updatePage(pageId, options.title, content, options.format); + if (wantsJson()) { + emitJson({ + id: result.id, + title: result.title, + version: result.version.number, + url: client.buildUrl(`${client.webUrlPrefix}${result._links.webui}`), + }); + analytics.track('update', true); + return; + } + console.log(chalk.green('✅ Page updated successfully!')); console.log(`Title: ${chalk.blue(result.title)}`); console.log(`ID: ${chalk.blue(result.id)}`); @@ -437,9 +479,21 @@ program .command('move <pageId_or_url> <newParentId_or_url>') .description('Move a page to a new parent location (within same space)') .option('-t, --title <title>', 'New page title (optional)') - .action(withClient('move', async ({ client, analytics }, pageId, newParentId, options) => { + .action(withClient('move', async ({ client, analytics, wantsJson, emitJson }, pageId, newParentId, options) => { const result = await client.movePage(pageId, newParentId, options.title); + if (wantsJson()) { + emitJson({ + id: result.id, + title: result.title, + newParentId, + version: result.version.number, + url: client.buildUrl(`${client.webUrlPrefix}${result._links.webui}`), + }); + analytics.track('move', true); + return; + } + console.log(chalk.green('✅ Page moved successfully!')); console.log(`Title: ${chalk.blue(result.title)}`); console.log(`ID: ${chalk.blue(result.id)}`); @@ -455,10 +509,14 @@ program .command('delete <pageIdOrUrl>') .description('Delete a Confluence page by ID or URL') .option('-y, --yes', 'Skip confirmation prompt') - .action(withClient('delete', async ({ client, analytics }, pageIdOrUrl, options) => { + .action(withClient('delete', async ({ client, analytics, wantsJson, emitJson }, pageIdOrUrl, options) => { + const jsonMode = wantsJson(); const pageInfo = await client.getPageInfo(pageIdOrUrl); if (!options.yes) { + if (jsonMode) { + throw new Error('Refusing to delete without confirmation in --json mode. Pass --yes to proceed.'); + } const spaceLabel = pageInfo.space?.key ? ` (${pageInfo.space.key})` : ''; const { confirmed } = await inquirer.prompt([ { @@ -478,6 +536,12 @@ program const result = await client.deletePage(pageInfo.id); + if (jsonMode) { + emitJson({ id: result.id, title: pageInfo.title, deleted: true }); + analytics.track('delete', true); + return; + } + console.log(chalk.green('✅ Page deleted successfully!')); console.log(`Title: ${chalk.blue(pageInfo.title)}`); console.log(`ID: ${chalk.blue(result.id)}`); @@ -557,7 +621,8 @@ program .option('-n, --dry-run', 'Preview operations without creating pages') .option('--fail-on-error', 'Exit with non-zero code if any page fails') .option('-q, --quiet', 'Suppress progress output') - .action(withClient('copy_tree', async ({ client, analytics }, sourcePageId, targetParentId, newTitle, options) => { + .action(withClient('copy_tree', async ({ client, analytics, wantsJson, emitJson }, sourcePageId, targetParentId, newTitle, options) => { + const jsonMode = wantsJson(); // Parse numeric flags with safe fallbacks const parsedDepth = parseInt(options.maxDepth, 10); const maxDepth = Number.isNaN(parsedDepth) ? 10 : parsedDepth; @@ -565,20 +630,22 @@ program const delayMs = Number.isNaN(parsedDelay) ? 100 : parsedDelay; const copySuffix = options.copySuffix ?? ' (Copy)'; - console.log(chalk.blue('🚀 Starting page tree copy...')); - console.log(`Source: ${sourcePageId}`); - console.log(`Target parent: ${targetParentId}`); - if (newTitle) console.log(`New root title: ${newTitle}`); - console.log(`Max depth: ${maxDepth}`); - console.log(`Delay: ${delayMs} ms`); - if (copySuffix) console.log(`Root suffix: ${copySuffix}`); - console.log(''); + if (!jsonMode) { + console.log(chalk.blue('🚀 Starting page tree copy...')); + console.log(`Source: ${sourcePageId}`); + console.log(`Target parent: ${targetParentId}`); + if (newTitle) console.log(`New root title: ${newTitle}`); + console.log(`Max depth: ${maxDepth}`); + console.log(`Delay: ${delayMs} ms`); + if (copySuffix) console.log(`Root suffix: ${copySuffix}`); + console.log(''); + } // Parse exclude patterns let excludePatterns = []; if (options.exclude) { excludePatterns = options.exclude.split(',').map(p => p.trim()).filter(Boolean); - if (excludePatterns.length > 0) { + if (excludePatterns.length > 0 && !jsonMode) { console.log(chalk.yellow(`Exclude patterns: ${excludePatterns.join(', ')}`)); } } @@ -594,6 +661,11 @@ program const rootTitle = newTitle || `${info.title}${copySuffix}`; const descendants = await client.getAllDescendantPages(sourcePageId, maxDepth); const filtered = descendants.filter(p => !client.shouldExcludePage(p.title, excludePatterns)); + if (jsonMode) { + emitJson({ dryRun: true, rootTitle, targetParentId, childCount: filtered.length }); + analytics.track('copy_tree_dry_run', true); + return; + } console.log(chalk.yellow('Dry run: no changes will be made.')); console.log(`Would create root: ${chalk.blue(rootTitle)} (under parent ${targetParentId})`); console.log(`Would create ${filtered.length} child page(s)`); @@ -627,13 +699,31 @@ program { maxDepth, excludePatterns, - onProgress: options.quiet ? null : onProgress, + onProgress: options.quiet || jsonMode ? null : onProgress, quiet: options.quiet, delayMs, copySuffix } ); + if (jsonMode) { + emitJson({ + rootPage: { + id: result.rootPage.id, + title: result.rootPage.title, + url: client.buildUrl(`${client.webUrlPrefix}${result.rootPage._links.webui}`), + }, + totalCopied: result.totalCopied, + failures: result.failures || [], + }); + if (options.failOnError && result.failures?.length) { + analytics.track('copy_tree', false); + process.exit(1); + } + analytics.track('copy_tree', true); + return; + } + console.log(''); console.log(chalk.green('✅ Page tree copy completed')); console.log(`Root page: ${chalk.blue(result.rootPage.title)} (ID: ${result.rootPage.id})`); diff --git a/tests/json-output.test.js b/tests/json-output.test.js index 25f4bed..a3d0655 100644 --- a/tests/json-output.test.js +++ b/tests/json-output.test.js @@ -157,4 +157,50 @@ describe('Global --json output flag', () => { const output = JSON.parse(logSpy.mock.calls[0][0]); expect(output).toMatchObject({ id: '999', title: 'Found' }); }); + + test('create --json emits the created page as JSON', async () => { + const { program } = await loadCli({ + createPage: jest.fn(async () => ({ + id: '500', title: 'New Page', + space: { key: 'ENG', name: 'Engineering' }, + _links: { webui: '/pages/500' }, + })), + }); + const logSpy = jest.spyOn(console, 'log').mockImplementation(() => {}); + + await runCli(program, ['create', 'New Page', 'ENG', '--content', 'hi', '--json']); + + const output = JSON.parse(logSpy.mock.calls[0][0]); + expect(output).toMatchObject({ id: '500', title: 'New Page', type: 'page', spaceKey: 'ENG' }); + }); + + test('delete --yes --json emits a deletion confirmation as JSON', async () => { + const { program } = await loadCli({ + getPageInfo: jest.fn(async () => ({ id: '600', title: 'Doomed', space: { key: 'ENG' } })), + deletePage: jest.fn(async () => ({ id: '600' })), + }); + const logSpy = jest.spyOn(console, 'log').mockImplementation(() => {}); + + await runCli(program, ['delete', '600', '--yes', '--json']); + + const output = JSON.parse(logSpy.mock.calls[0][0]); + expect(output).toEqual({ id: '600', title: 'Doomed', deleted: true }); + }); + + test('delete --json without --yes refuses instead of prompting', async () => { + const deletePage = jest.fn(); + const { program } = await loadCli({ + getPageInfo: jest.fn(async () => ({ id: '600', title: 'Doomed' })), + deletePage, + }); + const errSpy = jest.spyOn(console, 'error').mockImplementation(() => {}); + jest.spyOn(process, 'exit').mockImplementation(() => { + throw new Error('process.exit called'); + }); + + await expect(runCli(program, ['delete', '600', '--json'])).rejects.toThrow('process.exit called'); + expect(deletePage).not.toHaveBeenCalled(); + const msg = errSpy.mock.calls.map(c => `${c[0]} ${c[1] ?? ''}`).find(m => /Refusing to delete/.test(m)); + expect(msg).toBeDefined(); + }); }); From 0cb95f2d7f8417762c7c749bdaf2b5e580af27a4 Mon Sep 17 00:00:00 2001 From: "heecheol.park" <heecheol.park@linecorp.com> Date: Sun, 21 Jun 2026 16:10:19 +0900 Subject: [PATCH 2/2] fix: use exitCode for copy-tree --json failure to avoid truncating piped output process.exit() can terminate before a piped stdout write flushes, truncating the JSON failure payload from `copy-tree --json --fail-on-error`. Set process.exitCode and return so the event loop drains stdout first. --- bin/confluence.js | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/bin/confluence.js b/bin/confluence.js index a44ac00..39b05cc 100755 --- a/bin/confluence.js +++ b/bin/confluence.js @@ -718,7 +718,10 @@ program }); if (options.failOnError && result.failures?.length) { analytics.track('copy_tree', false); - process.exit(1); + // Use exitCode (not process.exit) so the JSON write to a pipe flushes + // before the process exits — process.exit() can truncate piped stdout. + process.exitCode = 1; + return; } analytics.track('copy_tree', true); return;