diff --git a/packages/@n8n/cli/README.md b/packages/@n8n/cli/README.md index e9531087f26..ca267ed0188 100644 --- a/packages/@n8n/cli/README.md +++ b/packages/@n8n/cli/README.md @@ -61,7 +61,7 @@ n8n-cli --url=https://my-n8n.app.n8n.cloud --api-key=n8n_api_xxxxx workflow list | `user` | `list`, `get` | | `config` | `set-url`, `set-api-key`, `show` | | `promotion-provider` | `list`, `get`, `create`, `update`, `delete` | -| `promotion-connection` | `list`, `get`, `create`, `update`, `delete`, `set-config`, `delete-config`, `clone`, `disconnect`, `promote`, `apply`, `list-projects`, `add-project`, `remove-project` | +| `promotion-connection` | `list`, `get`, `create`, `update`, `delete`, `set-config`, `delete-config`, `clone`, `disconnect`, `list-changes`, `promote`, `promote-selection`, `apply`, `apply-continue`, `list-projects`, `add-project`, `remove-project` | | `source-control` | `pull` | | `package` | `export`, `import` _(beta)_ | | `skill` | `install` | diff --git a/packages/@n8n/cli/docs/commands/promotions.md b/packages/@n8n/cli/docs/commands/promotions.md index ca98454b434..ab60df02efe 100644 --- a/packages/@n8n/cli/docs/commands/promotions.md +++ b/packages/@n8n/cli/docs/commands/promotions.md @@ -235,11 +235,18 @@ n8n-cli promotion-connection list-changes proj-abc promote n8n-cli promotion-connection list-changes proj-abc apply ``` +| Flag | Description | +|------|-------------| +| `--search` | List only the workflows whose name matches this text. | +| `--sort` | Sort by `name`, `updatedAt`, or `status`. The default is `name`. | +| `--order` | Sort order: `asc` or `desc`. The default is `asc`. | + For `promote` the rows are what a promotion would send to the branch, and the API key needs `gitConnection:push`. For `apply` the rows are what applying the branch would change on this instance, and the key needs `gitConnection:pull`. -`commitSha` is the commit the rows were read from. Clone the direction first. -Use the `id` of each row to build a selective promote. +`commitSha` is the commit the rows were read from. Table output shows the rows +only. Use `--json` to get the rows and `commitSha` together. Clone the direction +first. Use the `id` of each row to build a selective promote. ## `promotion-connection promote-selection` @@ -269,10 +276,68 @@ package. This overwrites the instance to match the branch. ```bash n8n-cli promotion-connection apply conn-1 +n8n-cli promotion-connection apply conn-1 \ + --expected-config-id=cfg-2 --expected-branch=main --expected-commit-sha= ``` +| Flag | Description | +|------|-------------| +| `--expected-config-id` | ID of the apply configuration that you reviewed (`configs.apply.id`). | +| `--expected-branch` | Branch that you reviewed. | +| `--expected-commit-sha` | Full commit SHA that you reviewed (40 or 64 lowercase hex characters). | + +Without the `--expected-*` flags, the command applies the branch tip. With them, +the command applies only the source that you reviewed. Pass all three flags or +none. + +| Result | Exit code | What happened | +|--------|-----------|---------------| +| `applied` | `0` | The package was imported. JSON output includes `counts` and `warnings`. | +| `source-changed` | `3` | The configuration, branch, or commit is not the one you reviewed. Nothing was imported. Review the changes again. | +| `blocked` | `4` | Preflight found missing bindings, access requirements, or conflicts. Nothing was imported. The output shows the `apply-continue` command to run after you resolve them. | + +With `--json`, the output is the full result for each status. For `blocked`, the +`preflight` object lists each missing project, missing binding, access +requirement, and conflict. Existing variable values on this instance are kept. + Clone the `apply` direction first. This command works on the `instance` -connection only. +connection only. The API key needs `gitConnection:pull`. + +## `promotion-connection apply-continue` + +Continue an Apply that was `blocked`, after you resolve the missing bindings, access requirements, or conflicts. + +```bash +n8n-cli promotion-connection apply-continue conn-1 \ + --expected-config-id=cfg-2 --expected-branch=main --expected-commit-sha= +``` + +The three `--expected-*` flags are required. Use the `configId`, +`git.branchName`, and `git.commitSha` that the blocked Apply reported. The +command runs the preflight again. The results and exit codes are the same as +for `apply`: `blocked` again means that something still blocks, and +`source-changed` means that you must review again. The server keeps no session, +so run the command again when it exits `4`. + +## Apply a reviewed commit + +```bash +# 1. Review the changes. Take the commit SHA from the same response as the rows: +# a second request can read a newer commit. +n8n-cli promotion-connection list-changes proj-abc apply --json > reviewed-changes.json +jq '{commitSha, changes}' reviewed-changes.json + +# 2. Read the apply configuration ID and branch of the connection. +n8n-cli promotion-connection get conn-1 --jq '.configs.apply.id' +n8n-cli promotion-connection get conn-1 --jq '.configs.apply.settings.branchName' + +# 3. Apply that commit only. +n8n-cli promotion-connection apply conn-1 \ + --expected-config-id=cfg-2 --expected-branch=main --expected-commit-sha= + +# 4. On exit code 4, resolve the missing bindings, access requirements, or conflicts. +# Then run the apply-continue command that step 3 printed. +``` ## `promotion-connection list-projects` / `add-project` / `remove-project` diff --git a/packages/@n8n/cli/docs/guides/ai-agents.md b/packages/@n8n/cli/docs/guides/ai-agents.md index 2bcd00b4e0e..961e17f78ba 100644 --- a/packages/@n8n/cli/docs/guides/ai-agents.md +++ b/packages/@n8n/cli/docs/guides/ai-agents.md @@ -58,6 +58,8 @@ n8n-cli execution list --workflow=1234 --status=error --limit=5 --format=json | `0` | Success | | `1` | General error | | `2` | Authentication failure | +| `3` | Promotion Apply: the source changed since the review. Nothing was imported | +| `4` | Promotion Apply: preflight found missing bindings, access requirements, or conflicts. Nothing was imported | AI agents can branch on `$?` for error handling. diff --git a/packages/@n8n/cli/skills/n8n-cli/SKILL.md b/packages/@n8n/cli/skills/n8n-cli/SKILL.md index 9729e6e8ca4..0b9b1f6bb94 100644 --- a/packages/@n8n/cli/skills/n8n-cli/SKILL.md +++ b/packages/@n8n/cli/skills/n8n-cli/SKILL.md @@ -216,6 +216,26 @@ n8n-cli promotion-connection promote -m "Promote team projects" n8n-cli promotion-connection apply ``` +Apply a reviewed commit only: + +```bash +# Review the changes. Take the commit SHA from the same response as the rows: +# a second request can read a newer commit. +n8n-cli promotion-connection list-changes apply --sort=updatedAt --order=desc --json > reviewed-changes.json +jq '{commitSha, changes}' reviewed-changes.json +# The config ID and branch come from the connection. +n8n-cli promotion-connection get --jq '.configs.apply.id' +n8n-cli promotion-connection get --jq '.configs.apply.settings.branchName' + +n8n-cli promotion-connection apply \ + --expected-config-id= --expected-branch= --expected-commit-sha= +# Exit 3: the source changed since the review. Review again. +# Exit 4: preflight found missing bindings, access requirements, or conflicts. Resolve them, +# then run the apply-continue command that apply printed: +n8n-cli promotion-connection apply-continue \ + --expected-config-id= --expected-branch= --expected-commit-sha= +``` + Connection JSON for step 2: ```json @@ -274,6 +294,12 @@ Key points: - `createBranchOnPromotion` is always required in a promote configuration. - `promote` and `apply` work on the `instance` connection only, and need their direction cloned first. Cloning one direction does not make the other ready. +- `apply` and `apply-continue` import nothing on exit code `3` (`source-changed`) + or `4` (`blocked`). Check `$?`, or the `status` field in JSON output, before you + read `counts`. With `--json`, a `blocked` result lists what is missing under + `preflight`. +- The `--expected-*` flags go together: pass all three or none. The commit SHA + must be the full SHA. - API key scopes for this group are named `gitConnection:*`. `promote` also needs `variable:list` when the workflows reference variables. diff --git a/packages/@n8n/cli/src/__tests__/client.test.ts b/packages/@n8n/cli/src/__tests__/client.test.ts index 1727d6e2c46..f9cef602b2b 100644 --- a/packages/@n8n/cli/src/__tests__/client.test.ts +++ b/packages/@n8n/cli/src/__tests__/client.test.ts @@ -78,6 +78,69 @@ describe('N8nClient packages', () => { }); }); + const expectedSource = { + configId: 'cfg-2', + branchName: 'release', + commitSha: 'a'.repeat(40), + }; + + it.each([ + { + title: 'posts apply with no body when no expected source is given', + send: async () => await client.applyPackage('conn-1'), + path: 'apply', + body: undefined, + }, + { + title: 'posts apply with the expected source in the body', + send: async () => await client.applyPackage('conn-1', expectedSource), + path: 'apply', + body: { expectedSource }, + }, + { + title: 'posts continue to apply/continue with the expected source in the body', + send: async () => await client.continueApplyPackage('conn-1', expectedSource), + path: 'apply/continue', + body: { expectedSource }, + }, + ])('$title', async ({ send, path, body }) => { + fetchMock.mockResolvedValue(jsonResponse(200, { status: 'applied' })); + + await send(); + + expect(requestLines(fetchMock)).toEqual([ + `POST https://n8n.example.com/api/v1/promotions/connections/conn-1/${path}`, + ]); + const [, init] = fetchMock.mock.calls[0] as [string, RequestInit]; + expect(typeof init.body === 'string' ? JSON.parse(init.body) : init.body).toEqual(body); + }); + + describe('list changes', () => { + beforeEach(() => { + fetchMock.mockResolvedValue(jsonResponse(200, { commitSha: null, changes: [] })); + }); + + it('sends no query parameters when no options are given', async () => { + await client.listProjectPromotionChanges('proj-1', 'apply'); + + expect(requestLines(fetchMock)).toEqual([ + 'GET https://n8n.example.com/api/v1/promotions/projects/proj-1/changes/apply', + ]); + }); + + it('sends search, sort, and order as query parameters', async () => { + await client.listProjectPromotionChanges('proj-1', 'promote', { + search: 'checkout', + sort: 'updatedAt', + order: 'desc', + }); + + expect(requestLines(fetchMock)).toEqual([ + 'GET https://n8n.example.com/api/v1/promotions/projects/proj-1/changes/promote?search=checkout&sort=updatedAt&order=desc', + ]); + }); + }); + it('clones and disconnects one direction of a connection', async () => { const checkout = { connectionId: 'conn-1', diff --git a/packages/@n8n/cli/src/__tests__/promotion-command-wiring.test.ts b/packages/@n8n/cli/src/__tests__/promotion-command-wiring.test.ts index ab7c667bd02..a6b3e163f70 100644 --- a/packages/@n8n/cli/src/__tests__/promotion-command-wiring.test.ts +++ b/packages/@n8n/cli/src/__tests__/promotion-command-wiring.test.ts @@ -143,7 +143,7 @@ const CASES: WiringCase[] = [ commandClass: PromotionConnectionListChanges, args: { projectId: 'proj-1', direction: 'promote' }, method: 'listProjectPromotionChanges', - callArgs: ['proj-1', 'promote'], + callArgs: ['proj-1', 'promote', {}], }, { title: 'promotion-connection add-project', diff --git a/packages/@n8n/cli/src/__tests__/promotion-connection-list-changes.test.ts b/packages/@n8n/cli/src/__tests__/promotion-connection-list-changes.test.ts index 2daa4ddea49..6d25c5f6f37 100644 --- a/packages/@n8n/cli/src/__tests__/promotion-connection-list-changes.test.ts +++ b/packages/@n8n/cli/src/__tests__/promotion-connection-list-changes.test.ts @@ -8,6 +8,9 @@ interface ListChangesFlags { format?: string; json?: boolean; jq?: string; + search?: string; + sort?: string; + order?: string; } /** The command methods we stub to isolate behaviour from oclif/networking. */ @@ -51,7 +54,7 @@ function stubCommand(flags: ListChangesFlags) { } as unknown as N8nClient); const output = vi.spyOn(internals, 'output').mockImplementation(() => {}); - return { command, output }; + return { command, output, listProjectPromotionChanges }; } describe('promotion-connection list-changes command', () => { @@ -59,6 +62,22 @@ describe('promotion-connection list-changes command', () => { vi.restoreAllMocks(); }); + it('passes the search, sort, and order flags to the change preview', async () => { + const { command, listProjectPromotionChanges } = stubCommand({ + search: 'checkout', + sort: 'updatedAt', + order: 'desc', + }); + + await command.run(); + + expect(listProjectPromotionChanges).toHaveBeenCalledWith('proj-1', 'promote', { + search: 'checkout', + sort: 'updatedAt', + order: 'desc', + }); + }); + it('unwraps the changes array for table output so rows render individually', async () => { const { command, output } = stubCommand({ format: 'table' }); diff --git a/packages/@n8n/cli/src/__tests__/promotion-connection-operations.test.ts b/packages/@n8n/cli/src/__tests__/promotion-connection-operations.test.ts index 0cbb9df56e7..3476fc0f38b 100644 --- a/packages/@n8n/cli/src/__tests__/promotion-connection-operations.test.ts +++ b/packages/@n8n/cli/src/__tests__/promotion-connection-operations.test.ts @@ -3,6 +3,12 @@ import { describe, it, expect, vi, afterEach } from 'vitest'; import type { N8nClient } from '../client'; import PromotionConnectionApply from '../commands/promotion-connection/apply'; +import PromotionConnectionApplyContinue from '../commands/promotion-connection/apply-continue'; +import { + parseCommitSha, + parseNonEmpty, + toExpectedSource, +} from '../commands/promotion-connection/expected-source'; import PromotionConnectionPromote from '../commands/promotion-connection/promote'; import PromotionConnectionPromoteSelection from '../commands/promotion-connection/promote-selection'; @@ -13,6 +19,12 @@ interface OperationInternals { succeed: (message: string, ...rest: unknown[]) => void; } +interface ApplyInternals extends OperationInternals { + output: (data: unknown, flags: unknown) => void; + logToStderr: (message: string) => void; + exit: (code: number) => never; +} + const PROMOTE_RESULT = { connectionId: 'conn-1', configId: 'cfg-1', @@ -20,15 +32,38 @@ const PROMOTE_RESULT = { git: { commitSha: 'abc1234', branchName: 'main' }, }; -const APPLY_RESULT = { +const SHA = 'd'.repeat(40); +const IDENTITY = { connectionId: 'conn-1', configId: 'cfg-2', - counts: {}, - git: { commitSha: 'def5678', branchName: 'release' }, + git: { commitSha: SHA, branchName: 'release' }, }; +const APPLY_RESULT = { ...IDENTITY, status: 'applied', counts: {}, warnings: [] }; +const SOURCE_CHANGED_RESULT = { ...IDENTITY, status: 'source-changed' }; +const BLOCKED_RESULT = { + ...IDENTITY, + status: 'blocked', + preflight: { + missingProjects: [{ id: 'p-1', name: 'Sales' }], + missingBindings: [ + { kind: 'credential', sourceId: 'c-1' }, + { kind: 'credential', sourceId: 'c-2' }, + { kind: 'variable', name: 'API_URL' }, + ], + accessRequirements: [], + conflicts: [{ kind: 'variable', code: 'missing-definition' }], + warnings: [], + }, +}; +const PINNED_FLAGS = { + expectedConfigId: 'cfg-2', + expectedBranch: 'release', + expectedCommitSha: SHA, +}; +const EXPECTED_SOURCE = { configId: 'cfg-2', branchName: 'release', commitSha: SHA }; function stubCommand( - command: PromotionConnectionPromote | PromotionConnectionApply, + command: PromotionConnectionPromote | PromotionConnectionApply | PromotionConnectionApplyContinue, flags: Record, client: Partial, ) { @@ -42,6 +77,19 @@ function stubCommand( return succeed; } +function stubApplyCommand( + command: PromotionConnectionApply | PromotionConnectionApplyContinue, + flags: Record, + client: Partial, +) { + const succeed = stubCommand(command, flags, client); + const internals = command as unknown as ApplyInternals; + const output = vi.spyOn(internals, 'output').mockImplementation(() => {}); + const logToStderr = vi.spyOn(internals, 'logToStderr').mockImplementation(() => {}); + const exit = vi.spyOn(internals, 'exit').mockImplementation(() => undefined as never); + return { succeed, output, logToStderr, exit }; +} + describe('promotion-connection promote command', () => { afterEach(() => { vi.restoreAllMocks(); @@ -76,22 +124,177 @@ describe('promotion-connection apply command', () => { vi.restoreAllMocks(); }); - it('names the branch and commit it applied', async () => { + function runApply(result: unknown, flags: Record = { format: 'table' }) { const command = new PromotionConnectionApply([], {} as Config); - const applyPackage = vi.fn().mockResolvedValue(APPLY_RESULT); - const succeed = stubCommand(command, {}, { + const applyPackage = vi.fn().mockResolvedValue(result); + const stubs = stubApplyCommand(command, flags, { applyPackage, } as unknown as Partial); + return { run: async () => await command.run(), applyPackage, ...stubs }; + } + + it('sends the reviewed source built from the three expected flags', async () => { + const { run, applyPackage } = runApply(APPLY_RESULT, { format: 'table', ...PINNED_FLAGS }); + + await run(); + + expect(applyPackage).toHaveBeenCalledWith('conn-1', EXPECTED_SOURCE); + }); + + it('names the branch and commit it applied, and exits through succeed', async () => { + const { run, succeed, exit } = runApply(APPLY_RESULT); + + await run(); + + expect(succeed).toHaveBeenCalledWith( + `Applied release at commit ${SHA} to the instance.`, + expect.anything(), + APPLY_RESULT, + ); + expect(exit).not.toHaveBeenCalled(); + }); + + it('counts the warnings of a successful Apply in the Applied message', async () => { + const result = { ...APPLY_RESULT, warnings: [{}, {}] }; + const { run, succeed } = runApply(result); + + await run(); + + expect(succeed).toHaveBeenCalledWith( + `Applied release at commit ${SHA} to the instance with 2 warning(s).`, + expect.anything(), + result, + ); + }); + + it('exits 3 without reporting success when the source changed since the review', async () => { + const { run, succeed, logToStderr, exit } = runApply(SOURCE_CHANGED_RESULT); + + await run(); + + expect(succeed).not.toHaveBeenCalled(); + expect(logToStderr).toHaveBeenCalledWith(expect.stringContaining('Nothing was imported.')); + expect(logToStderr).toHaveBeenCalledWith( + expect.stringContaining(`config cfg-2, branch release, commit ${SHA}`), + ); + expect(exit).toHaveBeenCalledWith(3); + }); + + it('exits 4 with binding counts and a ready Continue command when bindings are missing', async () => { + const { run, succeed, logToStderr, exit } = runApply(BLOCKED_RESULT); + + await run(); + + expect(succeed).not.toHaveBeenCalled(); + const message = logToStderr.mock.calls[0][0]; + expect(message).toContain('Missing projects: 1'); + expect(message).toContain('Missing credentials: 2'); + expect(message).toContain('Missing variables: 1'); + expect(message).toContain('Access requirements: 0'); + expect(message).toContain('Conflicts: 1'); + expect(message).toContain( + `n8n-cli promotion-connection apply-continue conn-1 --expected-config-id=cfg-2 --expected-branch=release --expected-commit-sha=${SHA}`, + ); + expect(exit).toHaveBeenCalledWith(4); + }); + + it('quotes source values in the Continue command so it is safe to paste', async () => { + const { run, logToStderr } = runApply({ + ...BLOCKED_RESULT, + git: { commitSha: SHA, branchName: "release;$(id)'s" }, + }); + + await run(); + + expect(logToStderr.mock.calls[0][0]).toContain( + `--expected-branch='release;$(id)'\\''s' --expected-commit-sha=${SHA}`, + ); + }); + + it.each([ + { result: APPLY_RESULT, code: 0 }, + { result: BLOCKED_RESULT, code: 4 }, + ])( + 'sends the $result.status result through the jq-aware output in JSON mode', + async ({ result, code }) => { + const { run, succeed, output, logToStderr, exit } = runApply(result, { jq: '.status' }); + + await run(); + + expect(output).toHaveBeenCalledWith(result, expect.objectContaining({ jq: '.status' })); + expect(succeed).not.toHaveBeenCalled(); + expect(logToStderr).not.toHaveBeenCalled(); + expect(exit).toHaveBeenCalledWith(code); + }, + ); + + it('keeps the exit code when quiet', async () => { + const { run, logToStderr, exit } = runApply(SOURCE_CHANGED_RESULT, { + format: 'table', + quiet: true, + }); + + await run(); + + expect(logToStderr).not.toHaveBeenCalled(); + expect(exit).toHaveBeenCalledWith(3); + }); +}); + +describe('promotion-connection apply-continue command', () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('continues with the reviewed source and reports the result like Apply', async () => { + const command = new PromotionConnectionApplyContinue([], {} as Config); + const continueApplyPackage = vi.fn().mockResolvedValue(SOURCE_CHANGED_RESULT); + const { exit } = stubApplyCommand(command, { format: 'table', ...PINNED_FLAGS }, { + continueApplyPackage, + } as unknown as Partial); await command.run(); - expect(applyPackage).toHaveBeenCalledWith('conn-1'); - expect(succeed).toHaveBeenCalledWith( - 'Applied release at commit def5678 to the instance.', - expect.anything(), - APPLY_RESULT, + expect(continueApplyPackage).toHaveBeenCalledWith('conn-1', EXPECTED_SOURCE); + expect(exit).toHaveBeenCalledWith(3); + }); +}); + +describe('expected config ID and branch flags', () => { + it('reject a value that is empty after trimming', async () => { + await expect(parseNonEmpty('branch name')(' ')).rejects.toThrow( + 'Expected a non-empty branch name', ); }); + + it('keep a non-empty value unchanged', async () => { + await expect(parseNonEmpty('branch name')('release/1.0')).resolves.toBe('release/1.0'); + }); +}); + +describe('expected source from flags', () => { + it('pins nothing when no flag is given', () => { + expect(toExpectedSource({})).toBeUndefined(); + }); + + it('keeps an empty value so the request fails instead of applying the branch tip', () => { + expect( + toExpectedSource({ expectedConfigId: 'cfg-2', expectedBranch: '', expectedCommitSha: SHA }), + ).toEqual({ configId: 'cfg-2', branchName: '', commitSha: SHA }); + }); +}); + +describe('expected commit SHA flag', () => { + it.each(['a'.repeat(40), '0123456789abcdef'.repeat(4)])('accepts %s', async (sha) => { + await expect(parseCommitSha(sha)).resolves.toBe(sha); + }); + + it.each(['abc1234', 'A'.repeat(40), 'g'.repeat(40), 'a'.repeat(41)])( + 'rejects %s', + async (sha) => { + await expect(parseCommitSha(sha)).rejects.toThrow('full lowercase commit SHA'); + }, + ); }); describe('promotion-connection promote-selection command', () => { diff --git a/packages/@n8n/cli/src/base-command.ts b/packages/@n8n/cli/src/base-command.ts index 90035f09198..d8bcd61028a 100644 --- a/packages/@n8n/cli/src/base-command.ts +++ b/packages/@n8n/cli/src/base-command.ts @@ -6,9 +6,11 @@ import { resolveConnection } from './config'; import { formatOutput, applyJqFilter, type OutputFormat, type OutputOptions } from './output'; /** Exit codes following the RFC spec. */ -const EXIT_SUCCESS = 0; +export const EXIT_SUCCESS = 0; const EXIT_ERROR = 1; const EXIT_AUTH = 2; +export const EXIT_SOURCE_CHANGED = 3; +export const EXIT_BLOCKED = 4; export abstract class BaseCommand extends Command { static override baseFlags = { diff --git a/packages/@n8n/cli/src/client.ts b/packages/@n8n/cli/src/client.ts index 62f81e6daba..bc8cc84b598 100644 --- a/packages/@n8n/cli/src/client.ts +++ b/packages/@n8n/cli/src/client.ts @@ -108,14 +108,44 @@ export type PromotePackageResult = { git: PromotionGitResult; }; -/** Outcome of applying a package to the instance. */ -export type ApplyPackageResult = { +/** The reviewed source that Apply must still match. Any mismatch reports `source-changed`. */ +export interface PromotionExpectedSource { + configId: string; + branchName: string; + commitSha: string; +} + +/** References in a package that must be set up on this instance before Apply can import it. */ +export interface PromotionBindingPreflight { + missingProjects: Array>; + missingBindings: Array>; + accessRequirements: Array>; + conflicts: Array>; + warnings: Array>; +} + +type ApplyPackageIdentity = { connectionId: string; configId: string; - counts: ImportPackageCounts; git: PromotionGitResult; }; +/** Outcome of applying a package to the instance. Only `applied` imported anything. */ +export type ApplyPackageResult = + | (ApplyPackageIdentity & { + status: 'applied'; + counts: ImportPackageCounts; + warnings: Array>; + }) + | (ApplyPackageIdentity & { status: 'blocked'; preflight: PromotionBindingPreflight }) + | (ApplyPackageIdentity & { status: 'source-changed' }); + +export interface PromotionChangesQuery { + search?: string; + sort?: 'name' | 'updatedAt' | 'status'; + order?: 'asc' | 'desc'; +} + /** One workflow that differs between a project and its configured branch. */ export interface PromotableResourceSummary { id: string; @@ -391,13 +421,27 @@ export class N8nClient { return await this.post(`/promotions/connections/${id}/promote`, body); } - async applyPackage(id: string) { - return await this.post(`/promotions/connections/${id}/apply`); + async applyPackage(id: string, expectedSource?: PromotionExpectedSource) { + return await this.post( + `/promotions/connections/${id}/apply`, + expectedSource ? { expectedSource } : undefined, + ); } - async listProjectPromotionChanges(projectId: string, direction: PromotionDirection) { + async continueApplyPackage(id: string, expectedSource: PromotionExpectedSource) { + return await this.post(`/promotions/connections/${id}/apply/continue`, { + expectedSource, + }); + } + + async listProjectPromotionChanges( + projectId: string, + direction: PromotionDirection, + query: PromotionChangesQuery = {}, + ) { return await this.get( `/promotions/projects/${projectId}/changes/${direction}`, + { ...query }, ); } diff --git a/packages/@n8n/cli/src/commands/promotion-connection/apply-command.ts b/packages/@n8n/cli/src/commands/promotion-connection/apply-command.ts new file mode 100644 index 00000000000..5f49c0dac30 --- /dev/null +++ b/packages/@n8n/cli/src/commands/promotion-connection/apply-command.ts @@ -0,0 +1,82 @@ +import { BaseCommand, EXIT_BLOCKED, EXIT_SOURCE_CHANGED, EXIT_SUCCESS } from '../../base-command'; +import type { ApplyPackageResult, PromotionBindingPreflight } from '../../client'; + +type ReportFlags = { quiet?: boolean; format?: string; json?: boolean; jq?: string }; + +/** Shared result handling for Apply and Continue, which return the same statuses. */ +export abstract class PromotionApplyCommand extends BaseCommand { + protected reportApplyResult( + result: ApplyPackageResult, + flags: ReportFlags, + connectionId: string, + ): void { + // `succeed()` ignores --jq, so JSON output for every status goes through `output()`. + if (this.isJsonMode(flags)) { + this.output(result, flags); + this.exit(exitCodeFor(result)); + return; + } + + if (result.status === 'applied') { + const warnings = + result.warnings.length > 0 ? ` with ${result.warnings.length} warning(s)` : ''; + this.succeed( + `Applied ${result.git.branchName} at commit ${result.git.commitSha} to the instance${warnings}.`, + flags, + result, + ); + return; + } + + if (!flags.quiet) { + this.logToStderr( + result.status === 'blocked' + ? blockedMessage(result, result.preflight, connectionId) + : sourceChangedMessage(result), + ); + } + this.exit(exitCodeFor(result)); + } +} + +function exitCodeFor(result: ApplyPackageResult): number { + if (result.status === 'applied') return EXIT_SUCCESS; + return result.status === 'blocked' ? EXIT_BLOCKED : EXIT_SOURCE_CHANGED; +} + +function sourceChangedMessage(result: ApplyPackageResult): string { + return [ + 'The source changed since the review. Nothing was imported.', + `Current source: config ${result.configId}, branch ${result.git.branchName}, commit ${result.git.commitSha}.`, + 'Review the changes again, then apply the new commit.', + ].join('\n'); +} + +function blockedMessage( + result: ApplyPackageResult, + preflight: PromotionBindingPreflight, + connectionId: string, +): string { + const bindings = (kind: 'credential' | 'variable') => + preflight.missingBindings.filter((binding) => binding.kind === kind).length; + + return [ + 'Apply is blocked. Nothing was imported.', + ` Missing projects: ${preflight.missingProjects.length}`, + ` Missing credentials: ${bindings('credential')}`, + ` Missing variables: ${bindings('variable')}`, + ` Access requirements: ${preflight.accessRequirements.length}`, + ` Conflicts: ${preflight.conflicts.length}`, + 'Run with --json for the details. After you resolve them, run:', + ` n8n-cli promotion-connection apply-continue ${shellQuote(connectionId)}` + + ` --expected-config-id=${shellQuote(result.configId)}` + + ` --expected-branch=${shellQuote(result.git.branchName)}` + + ` --expected-commit-sha=${shellQuote(result.git.commitSha)}`, + ].join('\n'); +} + +/** Branch names can contain shell metacharacters such as `;` and `$(`, so the shell must not interpret them. */ +export function shellQuote(value: string): string { + if (/^[\w./@-]+$/.test(value)) return value; + return `'${value.replace(/'/g, "'\\''")}'`; +} diff --git a/packages/@n8n/cli/src/commands/promotion-connection/apply-continue.ts b/packages/@n8n/cli/src/commands/promotion-connection/apply-continue.ts new file mode 100644 index 00000000000..c21a8117596 --- /dev/null +++ b/packages/@n8n/cli/src/commands/promotion-connection/apply-continue.ts @@ -0,0 +1,32 @@ +import { Args } from '@oclif/core'; + +import { PromotionApplyCommand } from './apply-command'; +import { expectedSourceFlags, toExpectedSource } from './expected-source'; +import { BaseCommand } from '../../base-command'; + +export default class PromotionConnectionApplyContinue extends PromotionApplyCommand { + static override description = + 'Continue an Apply that was blocked on bindings, after they are set up. Pass the config ID, branch, and commit SHA that the blocked Apply reported. Exits 3 when the source changed since then and 4 when bindings are still missing; nothing is imported in either case.'; + + static override examples = [ + '<%= config.bin %> promotion-connection apply-continue conn-1 --expected-config-id=cfg-1 --expected-branch=main --expected-commit-sha=', + ]; + + static override args = { id: Args.string({ description: 'Connection ID', required: true }) }; + + static override flags = { + ...BaseCommand.baseFlags, + ...expectedSourceFlags({ required: true }), + }; + + async run() { + const { args, flags } = await this.parse(PromotionConnectionApplyContinue); + const expectedSource = toExpectedSource(flags); + // oclif enforces the required flags, so this only narrows the type. + if (!expectedSource) this.error('Pass all three --expected-* flags'); + await this.execute(async () => { + const result = await this.getClient(flags).continueApplyPackage(args.id, expectedSource); + this.reportApplyResult(result, flags, args.id); + }); + } +} diff --git a/packages/@n8n/cli/src/commands/promotion-connection/apply.ts b/packages/@n8n/cli/src/commands/promotion-connection/apply.ts index a0ff5ec2ffb..613169107c3 100644 --- a/packages/@n8n/cli/src/commands/promotion-connection/apply.ts +++ b/packages/@n8n/cli/src/commands/promotion-connection/apply.ts @@ -1,26 +1,30 @@ import { Args } from '@oclif/core'; +import { PromotionApplyCommand } from './apply-command'; +import { expectedSourceFlags, toExpectedSource } from './expected-source'; import { BaseCommand } from '../../base-command'; -export default class PromotionConnectionApply extends BaseCommand { +export default class PromotionConnectionApply extends PromotionApplyCommand { static override description = - 'Reset the local checkout to the configured branch tip and import the package, overwriting the instance to match. Requires the Apply direction to be cloned first.'; + 'Reset the local checkout to the configured branch tip and import the package, overwriting the instance to match. Pass the three --expected-* flags to apply only the reviewed commit. Exits 3 when the source changed since the review and 4 when bindings must be set up first; nothing is imported in either case. Requires the Apply direction to be cloned first.'; - static override examples = ['<%= config.bin %> promotion-connection apply conn-1']; + static override examples = [ + '<%= config.bin %> promotion-connection apply conn-1', + '<%= config.bin %> promotion-connection apply conn-1 --expected-config-id=cfg-1 --expected-branch=main --expected-commit-sha=', + ]; static override args = { id: Args.string({ description: 'Connection ID', required: true }) }; - static override flags = { ...BaseCommand.baseFlags }; + static override flags = { + ...BaseCommand.baseFlags, + ...expectedSourceFlags({ required: false }), + }; async run() { const { args, flags } = await this.parse(PromotionConnectionApply); await this.execute(async () => { - const result = await this.getClient(flags).applyPackage(args.id); - this.succeed( - `Applied ${result.git.branchName} at commit ${result.git.commitSha} to the instance.`, - flags, - result, - ); + const result = await this.getClient(flags).applyPackage(args.id, toExpectedSource(flags)); + this.reportApplyResult(result, flags, args.id); }); } } diff --git a/packages/@n8n/cli/src/commands/promotion-connection/expected-source.ts b/packages/@n8n/cli/src/commands/promotion-connection/expected-source.ts new file mode 100644 index 00000000000..c6fc6172f52 --- /dev/null +++ b/packages/@n8n/cli/src/commands/promotion-connection/expected-source.ts @@ -0,0 +1,72 @@ +import { Flags } from '@oclif/core'; + +import type { PromotionExpectedSource } from '../../client'; + +const COMMIT_SHA = /^[0-9a-f]{40}([0-9a-f]{24})?$/; + +/** The server compares the full SHA, so a short one could never match. */ +export async function parseCommitSha(input: string): Promise { + if (!COMMIT_SHA.test(input)) { + throw new Error( + `Expected a full lowercase commit SHA (40 or 64 hex characters), got "${input}"`, + ); + } + return input; +} + +/** An unset shell variable arrives as an empty value. Fail here, before any request is sent. */ +export function parseNonEmpty(label: string) { + return async (input: string): Promise => { + if (input.trim() === '') throw new Error(`Expected a non-empty ${label}`); + return input; + }; +} + +/** + * Flags that pin Apply to the reviewed source. `required: false` makes them + * optional as a group: pass all three or none. + */ +export function expectedSourceFlags({ required }: { required: boolean }) { + const group = ['expectedConfigId', 'expectedBranch', 'expectedCommitSha']; + const others = (name: string) => (required ? undefined : group.filter((flag) => flag !== name)); + + return { + expectedConfigId: Flags.string({ + description: 'Apply config ID of the reviewed source (configs.apply.id)', + aliases: ['expected-config-id'], + required, + dependsOn: others('expectedConfigId'), + parse: parseNonEmpty('config ID'), + }), + expectedBranch: Flags.string({ + description: 'Branch name of the reviewed source', + aliases: ['expected-branch'], + required, + dependsOn: others('expectedBranch'), + parse: parseNonEmpty('branch name'), + }), + expectedCommitSha: Flags.string({ + description: 'Full commit SHA of the reviewed source', + aliases: ['expected-commit-sha'], + required, + dependsOn: others('expectedCommitSha'), + parse: parseCommitSha, + }), + }; +} + +export function toExpectedSource(flags: { + expectedConfigId?: string; + expectedBranch?: string; + expectedCommitSha?: string; +}): PromotionExpectedSource | undefined { + const { expectedConfigId, expectedBranch, expectedCommitSha } = flags; + if ( + expectedConfigId === undefined || + expectedBranch === undefined || + expectedCommitSha === undefined + ) { + return undefined; + } + return { configId: expectedConfigId, branchName: expectedBranch, commitSha: expectedCommitSha }; +} diff --git a/packages/@n8n/cli/src/commands/promotion-connection/list-changes.ts b/packages/@n8n/cli/src/commands/promotion-connection/list-changes.ts index 4bbf0f0a48f..9cb7330523d 100644 --- a/packages/@n8n/cli/src/commands/promotion-connection/list-changes.ts +++ b/packages/@n8n/cli/src/commands/promotion-connection/list-changes.ts @@ -1,7 +1,15 @@ -import { Args } from '@oclif/core'; +import { Args, Flags } from '@oclif/core'; import { directionArg, toDirection } from './direction'; import { BaseCommand } from '../../base-command'; +import type { PromotionChangesQuery } from '../../client'; + +const SORT_FIELDS: ReadonlyArray> = [ + 'name', + 'updatedAt', + 'status', +]; +const SORT_ORDERS: ReadonlyArray> = ['asc', 'desc']; export default class PromotionConnectionListChanges extends BaseCommand { static override description = @@ -9,6 +17,7 @@ export default class PromotionConnectionListChanges extends BaseCommand { static override examples = [ '<%= config.bin %> promotion-connection list-changes proj-abc promote', + '<%= config.bin %> promotion-connection list-changes proj-abc apply --sort=updatedAt --order=desc', ]; static override args = { @@ -16,7 +25,18 @@ export default class PromotionConnectionListChanges extends BaseCommand { direction: directionArg, }; - static override flags = { ...BaseCommand.baseFlags }; + static override flags = { + ...BaseCommand.baseFlags, + search: Flags.string({ description: 'Only list workflows whose name matches this text' }), + sort: Flags.string({ + description: 'Field to sort by (server default: name)', + options: [...SORT_FIELDS], + }), + order: Flags.string({ + description: 'Sort order (server default: asc)', + options: [...SORT_ORDERS], + }), + }; async run() { const { args, flags } = await this.parse(PromotionConnectionListChanges); @@ -24,6 +44,11 @@ export default class PromotionConnectionListChanges extends BaseCommand { const result = await this.getClient(flags).listProjectPromotionChanges( args.projectId, toDirection(args.direction), + { + search: flags.search, + sort: SORT_FIELDS.find((field) => field === flags.sort), + order: SORT_ORDERS.find((order) => order === flags.order), + }, ); // JSON and jq consumers want the `commitSha` + `changes` wrapper; table // and id-only need the rows themselves, or the array serializes into one diff --git a/packages/@n8n/cli/src/index.ts b/packages/@n8n/cli/src/index.ts index aa4df1b4ae3..e99a4921e1b 100644 --- a/packages/@n8n/cli/src/index.ts +++ b/packages/@n8n/cli/src/index.ts @@ -36,6 +36,7 @@ import ProjectRemoveMember from './commands/project/remove-member'; import ProjectUpdate from './commands/project/update'; import PromotionConnectionAddProject from './commands/promotion-connection/add-project'; import PromotionConnectionApply from './commands/promotion-connection/apply'; +import PromotionConnectionApplyContinue from './commands/promotion-connection/apply-continue'; import PromotionConnectionClone from './commands/promotion-connection/clone'; import PromotionConnectionCreate from './commands/promotion-connection/create'; import PromotionConnectionDelete from './commands/promotion-connection/delete'; @@ -123,6 +124,7 @@ export const commands = { 'promotion-connection:promote': PromotionConnectionPromote, 'promotion-connection:promote-selection': PromotionConnectionPromoteSelection, 'promotion-connection:apply': PromotionConnectionApply, + 'promotion-connection:apply-continue': PromotionConnectionApplyContinue, 'credential:list': CredentialList, 'credential:get': CredentialGet,