diff --git a/.github/actions/setup-cli/action.yml b/.github/actions/setup-cli/action.yml new file mode 100644 index 0000000..8326fc7 --- /dev/null +++ b/.github/actions/setup-cli/action.yml @@ -0,0 +1,27 @@ +name: Setup TreeSeed CLI +description: Install one exact published TreeSeed CLI release. +inputs: + version: + description: Exact published CLI semantic version. + required: true +runs: + using: composite + steps: + - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 + with: + node-version: 24.12.0 + registry-url: https://registry.npmjs.org + - name: Validate exact version + shell: bash + env: + TREESEED_CLI_VERSION: ${{ inputs.version }} + run: | + [[ "$TREESEED_CLI_VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+([+-][0-9A-Za-z.-]+)?$ ]] + - name: Install exact CLI release + shell: bash + env: + TREESEED_CLI_VERSION: ${{ inputs.version }} + run: npm install --global --ignore-scripts --no-audit --no-fund "@treeseed/cli@${TREESEED_CLI_VERSION}" + - name: Verify command surface + shell: bash + run: trsd help platform diff --git a/completions/trsd.bash b/completions/trsd.bash index 92c8d4a..97b33d5 100644 --- a/completions/trsd.bash +++ b/completions/trsd.bash @@ -1,6 +1,6 @@ # Generated from treeseed.command-tree/v1. _trsd_complete() { - local paths="inbox\nsend\ntopics list\ntopics show\ntopics subscribe\ntopics unsubscribe\ncapabilities list\ncapabilities show\nauth login\nauth logout\nauth status\nusers create\nteams list\nteams current\nteams use\nsecrets list\nsecrets status\nsecrets unlock\nsecrets lock\nsecrets rotate\ndev host activate\ndev host status\ndev host deactivate\ndev host guest image import\ndev session start\ndev session stop\ndev use\ndev rebuild\ndev restart\ndev status\ndev logs\ndev plan\ndev freeze\ndev verify\nhost status\nhost doctor\nhost plan\nhost apply\nhost reconcile\nhost events\nhost config show\nhost config plan\nhost config apply\nhost config adopt\nhost topology\nhost connections\nhost provider status\nhost provider credentials list\nhost provider credentials status\nhost provider credentials initialize\nhost storage status\nhost storage connect\nhost storage reconcile\nhost storage rotate\nhost storage reset\nhost security plan\nhost security initialize\nhost security status\nhost security verify\nhost security rotate\nhost security recovery verify\nhost sandbox status\nhost sandbox doctor\nhost fleet status\nhost update status\nhost update check\nhost update apply\nhost update channel\nhost update pause\nhost update resume\nhost component list\nhost component status\nhost component enable\nhost component disable\nhost aliases list\nhost recovery status\nhost recovery retry\nhost recovery restore\nhost bootstrap status\nhost bootstrap enroll\nhost reset\nagents list\nagents show\nagents validate\nagents diff\nagents diagnose\nagents classes list\nagents classes show\nagents bindings list\nagents bindings show\nagents bindings explain\nproviders list\nproviders show\nproviders status\nproviders diagnose\nproviders connect\nproviders disconnect\nproviders requests list\nproviders requests show\nproviders requests approve\nproviders requests reject\nproviders credentials status\nproviders credentials rotate\nproviders credentials revoke\nproviders offers show\nproviders offers validate\nproviders offers plan\nproviders offers apply\nseeds validate\nseeds plan\nseeds apply\nseeds show\nseeds verify\ncapacity status\ncapacity explain\ncapacity usage\ncapacity ledger\ncapacity audit\nplans list\nplans show\nplans explain\nplans diff\nworkdays profiles list\nworkdays profiles show\nworkdays profiles validate\nworkdays plan\nworkdays start\nworkdays list\nworkdays show\nworkdays watch\nworkdays pause\nworkdays resume\nworkdays stop\nworkdays cancel\nworkdays schedules list\nworkdays schedules show\nworkdays schedules plan\nworkdays schedules start\nworkdays schedules pause\nworkdays schedules resume\nworkdays schedules retire\nassignments list\nassignments show\nassignments explain\nassignments watch\nassignments retry\nassignments cancel\nassignments artifacts\nprojects treedx show\nprojects treedx bind\nprojects treedx status\nprojects treedx diagnose\nprojects treedx capabilities\nprojects treedx workspaces list\nprojects treedx workspaces show\nprojects treedx workspaces abandon\nai status\nai mode show\nai mode set\nai inference models\nai inference jobs\nai inference rollback\nai training libraries\nai training jobs\nai training runs\nai lab status\nai lab agents\nai lab libraries\nai qualify status\nai qualify run\nai qualify campaigns\nlibrary show\nlibrary status\nlibrary paths\nlibrary read\nlibrary search\nlibrary query\nlibrary context\nlibrary workspace create\nlibrary workspace show\nlibrary workspace read\nlibrary workspace diff\nlibrary workspace write\nlibrary workspace submit\nlibrary workspace abandon\nlibrary reviews list\nlibrary reviews decide\nlibrary reviews publish\nsave\nstage\nrelease\nstatus\ndiagnose" + local paths="inbox\nsend\ntopics list\ntopics show\ntopics subscribe\ntopics unsubscribe\ncapabilities list\ncapabilities show\nauth login\nauth logout\nauth status\nusers create\nteams list\nteams current\nteams use\nsecrets list\nsecrets status\nsecrets unlock\nsecrets lock\nsecrets rotate\nplatform verify\nplatform workset\nplatform project create\ndev host activate\ndev host status\ndev host deactivate\ndev host guest image import\ndev session start\ndev session stop\ndev use\ndev rebuild\ndev restart\ndev status\ndev logs\ndev plan\ndev freeze\ndev verify\nhost status\nhost doctor\nhost plan\nhost apply\nhost reconcile\nhost events\nhost config show\nhost config plan\nhost config apply\nhost config adopt\nhost topology\nhost connections\nhost provider status\nhost provider credentials list\nhost provider credentials status\nhost provider credentials initialize\nhost storage status\nhost storage connect\nhost storage reconcile\nhost storage rotate\nhost storage reset\nhost security plan\nhost security initialize\nhost security status\nhost security verify\nhost security rotate\nhost security recovery verify\nhost sandbox status\nhost sandbox doctor\nhost fleet status\nhost update status\nhost update check\nhost update apply\nhost update channel\nhost update pause\nhost update resume\nhost component list\nhost component status\nhost component enable\nhost component disable\nhost aliases list\nhost recovery status\nhost recovery retry\nhost recovery restore\nhost bootstrap status\nhost bootstrap enroll\nhost reset\nagents list\nagents show\nagents validate\nagents diff\nagents diagnose\nagents classes list\nagents classes show\nagents bindings list\nagents bindings show\nagents bindings explain\nproviders list\nproviders show\nproviders status\nproviders diagnose\nproviders connect\nproviders disconnect\nproviders requests list\nproviders requests show\nproviders requests approve\nproviders requests reject\nproviders credentials status\nproviders credentials rotate\nproviders credentials revoke\nproviders offers show\nproviders offers validate\nproviders offers plan\nproviders offers apply\nseeds validate\nseeds plan\nseeds apply\nseeds show\nseeds verify\ncapacity status\ncapacity explain\ncapacity usage\ncapacity ledger\ncapacity audit\nplans list\nplans show\nplans explain\nplans diff\nworkdays profiles list\nworkdays profiles show\nworkdays profiles validate\nworkdays plan\nworkdays start\nworkdays list\nworkdays show\nworkdays watch\nworkdays pause\nworkdays resume\nworkdays stop\nworkdays cancel\nworkdays schedules list\nworkdays schedules show\nworkdays schedules plan\nworkdays schedules start\nworkdays schedules pause\nworkdays schedules resume\nworkdays schedules retire\nassignments list\nassignments show\nassignments explain\nassignments watch\nassignments retry\nassignments cancel\nassignments artifacts\nprojects treedx show\nprojects treedx bind\nprojects treedx status\nprojects treedx diagnose\nprojects treedx capabilities\nprojects treedx workspaces list\nprojects treedx workspaces show\nprojects treedx workspaces abandon\nai status\nai mode show\nai mode set\nai inference models\nai inference jobs\nai inference rollback\nai training libraries\nai training jobs\nai training runs\nai lab status\nai lab agents\nai lab libraries\nai qualify status\nai qualify run\nai qualify campaigns\nlibrary show\nlibrary status\nlibrary paths\nlibrary read\nlibrary search\nlibrary query\nlibrary context\nlibrary workspace create\nlibrary workspace show\nlibrary workspace read\nlibrary workspace diff\nlibrary workspace write\nlibrary workspace submit\nlibrary workspace abandon\nlibrary reviews list\nlibrary reviews decide\nlibrary reviews publish\nsave\nstage\nrelease\nstatus\ndiagnose" COMPREPLY=( $(compgen -W "$paths" -- "${COMP_WORDS[*]:1}") ) } complete -F _trsd_complete trsd diff --git a/docs/command-reference.md b/docs/command-reference.md index 03e69a2..9357a2e 100644 --- a/docs/command-reference.md +++ b/docs/command-reference.md @@ -261,6 +261,52 @@ Execution: `local.secrets.rotate`. - `--json`: Emit the stable JSON envelope. - `--plan`: Return the exact proposed outcome without mutation. +## trsd platform + +Platform operations. + +### trsd platform verify [root] + +Verify a declarative Platform repository without package checkouts or a control-plane API. + +Operation: read. Result schema: `treeseed.platform-verification/v1`. +Execution: `local.platform.verify`. + +- `--json`: Emit the stable JSON envelope. +- `--profile `: Composable profile to verify. + +### trsd platform workset + +Plan or safely materialize exact primary source checkouts beneath packages/. + +Operation: mutation. Result schema: `treeseed.platform-workset-result/v1`. +Execution: `local.platform.workset`. + +- `--json`: Emit the stable JSON envelope. +- `--plan`: Return the exact proposed outcome without mutation. +- `--apply`: Apply the frozen workset plan. +- `--yes`: Confirm the planned checkout mutations. +- `--profile `: Composable source profile; repeat to union profiles. +- `--project `: Explicit project slug; repeat to select projects. +- `--exclude `: Project slug to exclude; repeat as needed. + +## trsd platform project + +Project operations. + +### trsd platform project create + +Plan or reconcile a project, repository, template, library binding, and live inventory without writing application source into Platform Git. + +Operation: mutation. Result schema: `treeseed.platform-project-create-result/v1`. +Execution: `local.platform.project.create`. + +- `--yes`: Confirm authorized automation. +- `--json`: Emit the stable JSON envelope. +- `--plan`: Return the exact proposed outcome without mutation. +- `--apply`: Apply the accepted creation plan. +- `--template `: Published template identity. + ## trsd dev Dev operations. diff --git a/package-lock.json b/package-lock.json index c52cc20..b8c9de9 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@treeseed/cli", - "version": "0.13.0-rc.38", + "version": "0.13.0-rc.39", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@treeseed/cli", - "version": "0.13.0-rc.38", + "version": "0.13.0-rc.39", "bundleDependencies": [ "ink", "react", @@ -15,7 +15,7 @@ ], "license": "Apache-2.0", "dependencies": { - "@treeseed/sdk": "0.13.0-rc.60", + "@treeseed/sdk": "0.13.0-rc.61", "ink": "^7.1.1", "react": "^19.2.8", "string-width": "^8.2.2", @@ -492,10 +492,9 @@ } }, "node_modules/@treeseed/sdk": { - "version": "0.13.0-rc.60", - "resolved": "https://registry.npmjs.org/@treeseed/sdk/-/sdk-0.13.0-rc.60.tgz", - "integrity": "sha512-3CpHJSPN2omjVJ+gVQwz1JXIUw9zN3Jh6KIgP3MHD7Y7w+Qh/UHQdRu6PnHH6Xpkk+FkMqNvaIwPATwswGKXrQ==", - "license": "Apache-2.0", + "version": "0.13.0-rc.61", + "resolved": "https://registry.npmjs.org/@treeseed/sdk/-/sdk-0.13.0-rc.61.tgz", + "integrity": "sha512-Op9t955kwz750YzSG7F458ZEBvGMgMITgj64k8WSAbys1r0XT37OVv98wzTaKRLgvbEOTDrgRUdBiKsLJsbtKw==", "dependencies": { "@treeseed/treedx": "0.3.0-rc.4", "esbuild": "^0.28.0", diff --git a/package.json b/package.json index b22b4c1..6bce6e6 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@treeseed/cli", - "version": "0.13.0-rc.38", + "version": "0.13.0-rc.39", "description": "Operator-facing Treeseed CLI package.", "license": "Apache-2.0", "repository": { @@ -50,7 +50,7 @@ "release:custody": "node --import tsx ./scripts/packages/release-custody.ts" }, "dependencies": { - "@treeseed/sdk": "0.13.0-rc.60", + "@treeseed/sdk": "0.13.0-rc.61", "ink": "^7.1.1", "react": "^19.2.8", "string-width": "^8.2.2", diff --git a/schemas/command-tree.json b/schemas/command-tree.json index 2569851..a7a4579 100644 --- a/schemas/command-tree.json +++ b/schemas/command-tree.json @@ -792,6 +792,152 @@ } ] }, + { + "nodeType": "branch", + "segment": "platform", + "description": "Platform operations.", + "children": [ + { + "nodeType": "leaf", + "segment": "verify", + "description": "Verify a declarative Platform repository without package checkouts or a control-plane API.", + "kind": "read", + "arguments": [ + { + "name": "root", + "description": "Platform root; defaults to the current directory.", + "required": false + } + ], + "options": [ + { + "name": "--profile", + "description": "Composable profile to verify.", + "type": "string[]" + }, + { + "name": "--json", + "description": "Emit the stable command-result envelope.", + "type": "boolean" + } + ], + "resultSchemaId": "treeseed.platform-verification/v1", + "execution": { + "kind": "local", + "handlerId": "local.platform.verify" + } + }, + { + "nodeType": "leaf", + "segment": "workset", + "description": "Plan or safely materialize exact primary source checkouts beneath packages/.", + "kind": "mutation", + "options": [ + { + "name": "--plan", + "description": "Return the exact proposed outcome without mutation.", + "type": "boolean" + }, + { + "name": "--apply", + "description": "Apply the frozen workset plan.", + "type": "boolean" + }, + { + "name": "--yes", + "description": "Confirm the planned checkout mutations.", + "type": "boolean" + }, + { + "name": "--profile", + "description": "Composable source profile; repeat to union profiles.", + "type": "string[]" + }, + { + "name": "--project", + "description": "Explicit project slug; repeat to select projects.", + "type": "string[]" + }, + { + "name": "--exclude", + "description": "Project slug to exclude; repeat as needed.", + "type": "string[]" + }, + { + "name": "--json", + "description": "Emit the stable command-result envelope.", + "type": "boolean" + } + ], + "authorization": { + "capability": "development.workset", + "confirmation": "never" + }, + "resultSchemaId": "treeseed.platform-workset-result/v1", + "execution": { + "kind": "local", + "handlerId": "local.platform.workset" + } + }, + { + "nodeType": "branch", + "segment": "project", + "description": "Project operations.", + "children": [ + { + "nodeType": "leaf", + "segment": "create", + "description": "Plan or reconcile a project, repository, template, library binding, and live inventory without writing application source into Platform Git.", + "kind": "mutation", + "arguments": [ + { + "name": "slug", + "description": "Portable project slug.", + "required": true + } + ], + "options": [ + { + "name": "--plan", + "description": "Return the exact proposed outcome without mutation.", + "type": "boolean" + }, + { + "name": "--apply", + "description": "Apply the accepted creation plan.", + "type": "boolean" + }, + { + "name": "--yes", + "description": "Confirm authority-bearing project creation.", + "type": "boolean" + }, + { + "name": "--template", + "description": "Published template identity.", + "type": "string", + "required": true + }, + { + "name": "--json", + "description": "Emit the stable command-result envelope.", + "type": "boolean" + } + ], + "authorization": { + "capability": "projects.create", + "confirmation": "authority" + }, + "resultSchemaId": "treeseed.platform-project-create-result/v1", + "execution": { + "kind": "local", + "handlerId": "local.platform.project.create" + } + } + ] + } + ] + }, { "nodeType": "branch", "segment": "dev", diff --git a/src/cli/commands/platform/index.ts b/src/cli/commands/platform/index.ts new file mode 100644 index 0000000..4959d30 --- /dev/null +++ b/src/cli/commands/platform/index.ts @@ -0,0 +1,53 @@ +import { resolve } from 'node:path'; +import { applyPlatformWorkset, loadPlatformInventory, loadPlatformProfiles, planPlatformWorkset, resolveProfileProjects, verifyPlatformRepository } from '@treeseed/sdk/platform'; +import type { CommandContext, ParsedInvocation } from '../../types.js'; + +const values = (value: string | string[] | boolean | undefined) => Array.isArray(value) ? value : typeof value === 'string' ? [value] : []; +const invalid = (code: string, message: string) => Object.assign(new Error(message), { category: 'invalid_input', code }); + +function rootFor(invocation: ParsedInvocation, context: CommandContext) { + return resolve(context.cwd, invocation.arguments[0] ?? '.'); +} + +function verify(invocation: ParsedInvocation, context: CommandContext) { + const root = rootFor(invocation, context); + const profiles = loadPlatformProfiles(root); + const selected = values(invocation.options.profile); + if (selected.length) resolveProfileProjects(profiles, selected); + return { ...verifyPlatformRepository(root), profiles: selected }; +} + +function workset(invocation: ParsedInvocation, context: CommandContext) { + const planning = invocation.options.plan === true; + const applying = invocation.options.apply === true; + if (planning === applying) throw invalid('workset_mode_required', 'Choose exactly one of --plan or --apply.'); + if (applying && invocation.options.yes !== true) throw Object.assign(new Error('Workset application requires --yes after reviewing the plan.'), { category: 'confirmation_required', code: 'confirmation_required' }); + const loaded = loadPlatformInventory(context.cwd); + const profiles = loadPlatformProfiles(context.cwd); + const plan = planPlatformWorkset({ root: loaded.root, inventoryPath: loaded.path, inventoryDigest: loaded.digest, inventory: loaded.inventory, profiles, selection: { + profiles: values(invocation.options.profile), + projects: values(invocation.options.project), + exclude: values(invocation.options.exclude), + } }); + if (planning) return plan; + return applyPlatformWorkset(plan); +} + +function projectCreate(invocation: ParsedInvocation) { + const slug = invocation.arguments[0]!; + const template = String(invocation.options.template ?? ''); + if (!template) throw invalid('template_required', '--template is required.'); + if (invocation.options.plan === true) return { + schemaVersion: 'treeseed.platform-project-create-preview/v1', slug, template, + steps: ['project', 'repository', 'template', 'library', 'inventory'], mutation: false, + blockers: ['control_plane_project_reconciliation_not_published'], + }; + throw Object.assign(new Error('Project creation apply remains fail-closed until its control-plane reconciliation operation is published.'), { category: 'policy_blocked', code: 'control_plane_project_reconciliation_not_published' }); +} + +export function runPlatform(invocation: ParsedInvocation, context: CommandContext) { + if (invocation.command.name === 'platform verify') return verify(invocation, context); + if (invocation.command.name === 'platform workset') return workset(invocation, context); + if (invocation.command.name === 'platform project create') return projectCreate(invocation); + throw invalid('platform_command_unknown', `Unsupported Platform command ${invocation.command.name}.`); +} diff --git a/src/cli/runtime.ts b/src/cli/runtime.ts index 511bff5..e0efa8e 100644 --- a/src/cli/runtime.ts +++ b/src/cli/runtime.ts @@ -11,6 +11,7 @@ import { runLibrary } from './commands/library.js'; import { runTeams } from './commands/teams.js'; import { runDevelopment } from './commands/development.js'; import { runInbox } from './commands/inbox.js'; +import { runPlatform } from './commands/platform/index.js'; import type { CommandContext, CommandFailure, ParsedInvocation, Writer } from './types.js'; import { promptText } from './support/prompts.js'; import { handoffProviderEnrollment } from './support/provider-enrollment.js'; @@ -70,6 +71,7 @@ async function execute(invocation: ParsedInvocation, context: CommandContext) { if (invocation.command.execution.kind === 'local' && invocation.command.path[0] === 'dev') return runDevelopment(invocation, context); if (invocation.command.execution.kind === 'local' && invocation.command.path[0] === 'library') return runLibrary(invocation, context); if (invocation.command.execution.kind === 'local' && invocation.command.path[0] === 'inbox') return runInbox(invocation, context); + if (invocation.command.execution.kind === 'local' && invocation.command.path[0] === 'platform') return runPlatform(invocation, context); return runOperator(invocation, context); } diff --git a/tests/contract/package/thin-package.test.ts b/tests/contract/package/thin-package.test.ts index 090f07e..0fdb847 100644 --- a/tests/contract/package/thin-package.test.ts +++ b/tests/contract/package/thin-package.test.ts @@ -9,7 +9,7 @@ test('package has one executable and only its declared CLI runtime dependencies' assert.equal(pkg.types, undefined); assert.equal(pkg.files.some((path: string) => path.startsWith('scripts/')), false); assert.equal(pkg.dependencies['@treeseed/agent'], undefined); - assert.deepEqual(pkg.dependencies, { '@treeseed/sdk': '0.13.0-rc.60', ink: '^7.1.1', react: '^19.2.8', 'string-width': '^8.2.2', yaml: '2.9.0' }); + assert.deepEqual(pkg.dependencies, { '@treeseed/sdk': '0.13.0-rc.61', ink: '^7.1.1', react: '^19.2.8', 'string-width': '^8.2.2', yaml: '2.9.0' }); }); test('built package contains executable runtime only', () => { diff --git a/tests/unit/command-boundary/platform/workset.test.ts b/tests/unit/command-boundary/platform/workset.test.ts new file mode 100644 index 0000000..dc34136 --- /dev/null +++ b/tests/unit/command-boundary/platform/workset.test.ts @@ -0,0 +1,49 @@ +import assert from 'node:assert/strict'; +import { execFileSync } from 'node:child_process'; +import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { resolve } from 'node:path'; +import test from 'node:test'; +import { runCommandLine } from '../../../../src/cli/runtime.ts'; + +const capture = () => { const output: string[] = []; return { output, write: (value: string) => output.push(value) }; }; + +test('platform verify works from a clean declarative clone without packages or an API', async () => { + const root = mkdtempSync(resolve(tmpdir(), 'platform-cli-')); + try { + execFileSync('git', ['init'], { cwd: root, stdio: 'ignore' }); + writeFileSync(resolve(root, 'README.md'), '# Portable Platform\n'); + writeFileSync(resolve(root, 'treeseed.site.yaml'), 'development:\n local:\n inventory: { source: seed, path: seeds/inventory.yaml }\n'); + execFileSync('git', ['add', '.'], { cwd: root, stdio: 'ignore' }); + const target = capture(); + assert.equal(await runCommandLine(['platform', 'verify', '--json'], { cwd: root, interactiveUi: false, write: target.write }), 0); + const envelope = JSON.parse(target.output[0]!); + assert.equal(envelope.result.ok, true); + assert.equal(envelope.result.profiles.length, 0); + } finally { rmSync(root, { recursive: true, force: true }); } +}); + +test('platform workset plans and applies an exact local staging checkout', async () => { + const root = mkdtempSync(resolve(tmpdir(), 'platform-cli-')); + const source = mkdtempSync(resolve(tmpdir(), 'platform-source-')); + try { + execFileSync('git', ['init', '-b', 'staging'], { cwd: source, stdio: 'ignore' }); + execFileSync('git', ['config', 'user.email', 'test@example.invalid'], { cwd: source }); + execFileSync('git', ['config', 'user.name', 'Test'], { cwd: source }); + writeFileSync(resolve(source, 'README.md'), '# SDK\n'); + execFileSync('git', ['add', '.'], { cwd: source }); + execFileSync('git', ['commit', '-m', 'initial'], { cwd: source, stdio: 'ignore' }); + mkdirSync(resolve(root, 'seeds')); + writeFileSync(resolve(root, 'treeseed.site.yaml'), 'development:\n local:\n inventory: { source: seed, path: seeds/inventory.yaml }\n'); + writeFileSync(resolve(root, 'seeds/inventory.yaml'), `schemaVersion: treeseed.seed-bundle/v3\nresources:\n projects:\n - { key: project:sdk, slug: sdk, primaryRepository: repository:sdk }\n repositories:\n - { key: repository:sdk, project: project:sdk, role: primary, gitUrl: ${source}, defaultBranch: main, repositoryPolicy: { stagingBranch: staging } }\n`); + const planned = capture(); + assert.equal(await runCommandLine(['platform', 'workset', '--plan', '--project', 'sdk', '--json'], { cwd: root, interactiveUi: false, write: planned.write }), 0); + assert.equal(JSON.parse(planned.output[0]!).result.entries[0].action, 'clone'); + const applied = capture(); + assert.equal(await runCommandLine(['platform', 'workset', '--apply', '--yes', '--project', 'sdk', '--json'], { cwd: root, interactiveUi: false, write: applied.write }), 0, applied.output.join('\n')); + assert.equal(JSON.parse(applied.output[0]!).result.entries[0].action, 'clone'); + const replay = capture(); + assert.equal(await runCommandLine(['platform', 'workset', '--plan', '--project', 'sdk', '--json'], { cwd: root, interactiveUi: false, write: replay.write }), 0); + assert.equal(JSON.parse(replay.output[0]!).result.entries[0].action, 'noop'); + } finally { rmSync(root, { recursive: true, force: true }); rmSync(source, { recursive: true, force: true }); } +});