diff --git a/completions/trsd.bash b/completions/trsd.bash index e156baa..fb93ec9 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\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\nhost uninstall\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 initialize\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\nhost uninstall\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 a07880b..4ae814c 100644 --- a/docs/command-reference.md +++ b/docs/command-reference.md @@ -487,6 +487,20 @@ Execution: `local.dev.verify`. Host operations. +### trsd host initialize + +Initialize the generic host foundation from an immutable catalog-bound profile. + +Operation: mutation. Result schema: `treeseed.host-initialization-result/v1`. +Execution: `local.host.initialize`. + +- `--server `: Control-plane server profile or URL. +- `--yes`: Confirm authorized automation. +- `--json`: Emit the stable JSON envelope. +- `--plan`: Return the exact proposed outcome without mutation. +- `--profile `: Catalog-bound host initialization profile. +- `--confirm`: Confirm installation of the reviewed profile plan. + ### trsd host status Status the selected resource. diff --git a/package-lock.json b/package-lock.json index 3943f93..6cf1d4d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@treeseed/cli", - "version": "0.13.0-rc.41", + "version": "0.13.0-rc.42", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@treeseed/cli", - "version": "0.13.0-rc.41", + "version": "0.13.0-rc.42", "bundleDependencies": [ "ink", "react", @@ -15,7 +15,7 @@ ], "license": "Apache-2.0", "dependencies": { - "@treeseed/sdk": "0.13.0-rc.64", + "@treeseed/sdk": "0.13.0-rc.65", "ink": "^7.1.1", "react": "^19.2.8", "string-width": "^8.2.2", @@ -492,9 +492,9 @@ } }, "node_modules/@treeseed/sdk": { - "version": "0.13.0-rc.64", - "resolved": "https://registry.npmjs.org/@treeseed/sdk/-/sdk-0.13.0-rc.64.tgz", - "integrity": "sha512-Z8jXhAzixvU1yztCC0InEjXFiSWXRzh50qDwB/wclWpcyEsmp1a2J/EkAdjbjqj3RCufCt5SnprRdEYktWKk9w==", + "version": "0.13.0-rc.65", + "resolved": "https://registry.npmjs.org/@treeseed/sdk/-/sdk-0.13.0-rc.65.tgz", + "integrity": "sha512-McRysmZzEXylsPKdIPxbO7Q3HnWKV0qnDrc54p87iUsIpiupe4YjPORTNyjcKG4YEyM1eQ6P72XkESnxgBxR+w==", "dependencies": { "@treeseed/treedx": "0.3.0-rc.4", "esbuild": "^0.28.0", diff --git a/package.json b/package.json index 6560379..e9cee3c 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@treeseed/cli", - "version": "0.13.0-rc.41", + "version": "0.13.0-rc.42", "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.64", + "@treeseed/sdk": "0.13.0-rc.65", "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 908074e..73dd364 100644 --- a/schemas/command-tree.json +++ b/schemas/command-tree.json @@ -1390,6 +1390,44 @@ "segment": "host", "description": "Host operations.", "children": [ + { + "nodeType": "leaf", + "segment": "initialize", + "description": "Initialize the generic host foundation from an immutable catalog-bound profile.", + "kind": "mutation", + "options": [ + { + "name": "--plan", + "description": "Return the exact proposed outcome without mutation.", + "type": "boolean" + }, + { + "name": "--profile", + "description": "Catalog-bound host initialization profile.", + "type": "string", + "required": true + }, + { + "name": "--confirm", + "description": "Confirm installation of the reviewed profile plan.", + "type": "boolean" + }, + { + "name": "--yes", + "description": "Confirm non-interactive execution after reviewing the plan.", + "type": "boolean" + } + ], + "authorization": { + "capability": "command.initialize", + "confirmation": "authority" + }, + "resultSchemaId": "treeseed.host-initialization-result/v1", + "execution": { + "kind": "local", + "handlerId": "local.host.initialize" + } + }, { "nodeType": "leaf", "segment": "status", diff --git a/src/cli/commands/host.ts b/src/cli/commands/host.ts index b757183..55913a1 100644 --- a/src/cli/commands/host.ts +++ b/src/cli/commands/host.ts @@ -3,7 +3,7 @@ import { invokeHostManager, invokeLocalHostManager } from '../support/host-clien import { storeHostEnrollment, type HostEnrollment } from '../support/host-custody.js'; import { defaultLocalControlPlaneServer, resolveControlPlaneServer } from '@treeseed/sdk/control-plane-client'; import { loadServerRegistry, loadServerSession } from '../support/server-custody.js'; -import { promptHidden } from '../support/prompts.js'; +import { promptHidden, promptText } from '../support/prompts.js'; const cloudflareSetupGuide = `Cloudflare R2 setup @@ -41,6 +41,32 @@ function activeTeam(invocation: ParsedInvocation, context: CommandContext) { async function input(invocation: ParsedInvocation, context: CommandContext) { if (invocation.command.execution.kind !== 'local') throw new Error('Host command is not locally bound.'); const { server: _server, json: _json, yes: _yes, ...options } = invocation.options; + if (invocation.command.name === 'host initialize') { + const profile = invocation.options.profile; + if (typeof profile !== 'string' || !/^[a-z][a-z0-9.-]{1,63}$/u.test(profile)) throw new Error('Host initialize requires a valid --profile identity.'); + if (invocation.options.plan === true) return { handlerId: invocation.command.execution.handlerId, arguments: [], options: { plan: true, profile } }; + if (invocation.options.confirm !== true || invocation.options.yes !== true) { + throw Object.assign(new Error('Host initialize execution requires both --confirm and --yes after reviewing the plan.'), { category: 'confirmation_required', code: 'confirmation_required' }); + } + const planCommand = { handlerId: invocation.command.execution.handlerId, arguments: [], options: { plan: true, profile } }; + const planned = await (context.hostInvoke ? context.hostInvoke(planCommand) : invokeLocalHostManager(planCommand)) as { inputs?: Array<{ name: string; required: boolean; sensitive: boolean; description: string }> }; + const values: Record = {}; + for (const descriptor of planned.inputs ?? []) { + if (!/^[a-z][A-Za-z0-9]{1,63}$/u.test(descriptor.name)) throw new Error('Host initialization plan contains an invalid input descriptor.'); + const question = `${descriptor.description}: `; + const value = descriptor.sensitive + ? String(context.promptSecret ? await context.promptSecret(question) : await promptHidden(question)).trim() + : await promptText(context, question); + if (!value && descriptor.required) throw new Error(`Required host initialization input ${descriptor.name} was not provided.`); + if (descriptor.name === 'controlPlaneUrl' && value) { + let url: URL; + try { url = new URL(value); } catch { throw new Error('Control-plane URL must be a valid HTTPS URL.'); } + if (url.protocol !== 'https:') throw new Error('Control-plane URL must use HTTPS.'); + } + if (value) values[descriptor.name] = value; + } + return { handlerId: invocation.command.execution.handlerId, arguments: [], options: { profile, confirm: true, payload: JSON.stringify({ profile, inputs: values }) } }; + } if (invocation.command.name === 'host uninstall' && invocation.options.plan !== true) { if (invocation.options.confirm !== true || invocation.options.yes !== true) { throw Object.assign(new Error('Host uninstall execution requires both --confirm and --yes after reviewing the plan.'), { @@ -129,7 +155,7 @@ async function input(invocation: ParsedInvocation, context: CommandContext) { } export function hostUsesProtectedLocalTransport(invocation: Pick) { - return invocation.command.name === 'host config adopt' || invocation.command.name === 'host bootstrap enroll' + return invocation.command.name === 'host initialize' || invocation.command.name === 'host config adopt' || invocation.command.name === 'host bootstrap enroll' || invocation.command.name === 'host reset' || invocation.command.name === 'host uninstall' || invocation.command.name.startsWith('host storage ') || invocation.command.name.startsWith('host security ') || invocation.command.name.startsWith('host sandbox ') || invocation.command.name.startsWith('host provider credentials '); @@ -149,6 +175,7 @@ export async function runHost(invocation: ParsedInvocation, context: CommandCont : hostUsesProtectedLocalTransport(invocation) ? invokeLocalHostManager(command) : invokeHostManager(command, typeof invocation.options.server === 'string' ? invocation.options.server : undefined, context.env); const progressLabel = context.outputFormat === 'human' && invocation.options.plan !== true ? ({ + 'host initialize': 'Initializing the selected TreeSeed host profile', 'host storage connect': 'Connecting Cloudflare R2. Provisioning storage, securing credentials, and reconciling the host', 'host storage reconcile': 'Reconciling Cloudflare R2 storage', 'host storage rotate': 'Rotating Cloudflare R2 storage credentials', diff --git a/tests/contract/package/thin-package.test.ts b/tests/contract/package/thin-package.test.ts index 6751c1f..dddcbd8 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.64', 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.65', 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/host/initialize.test.ts b/tests/unit/command-boundary/host/initialize.test.ts new file mode 100644 index 0000000..d47bdcd --- /dev/null +++ b/tests/unit/command-boundary/host/initialize.test.ts @@ -0,0 +1,42 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { runCommandLine } from '../../../../src/cli/runtime.ts'; +import { hostUsesProtectedLocalTransport } from '../../../../src/cli/commands/host.ts'; + +test('host initialize plan requests no runtime values', async () => { + const calls: any[] = []; let prompts = 0; + const exit = await runCommandLine(['host', 'initialize', '--profile', 'capacity-provider', '--plan', '--json'], { + interactiveUi: false, prompt: async () => { prompts += 1; return 'never'; }, promptSecret: async () => { prompts += 1; return 'never'; }, + hostInvoke: async (request) => { calls.push(request); return { profile: 'capacity-provider', inputs: [{ name: 'teamRegistrationCode', required: true, sensitive: true, description: 'Registration code' }] }; }, write() {}, + }); + assert.equal(exit, 0); assert.equal(prompts, 0); + assert.deepEqual(calls, [{ handlerId: 'local.host.initialize', arguments: [], options: { plan: true, profile: 'capacity-provider' } }]); + assert.equal(hostUsesProtectedLocalTransport({ command: { name: 'host initialize' } as any }), true); +}); + +test('host initialize prompts from the manager plan and keeps secrets out of argv and output', async () => { + const secret = 'registration-code-private'; const calls: any[] = []; const output: string[] = []; + const exit = await runCommandLine(['host', 'initialize', '--profile', 'capacity-provider', '--confirm', '--yes', '--json'], { + interactiveUi: false, prompt: async () => 'https://api.example.test', promptSecret: async () => secret, + hostInvoke: async (request) => { + calls.push(request); + if (request.options.plan === true) return { inputs: [ + { name: 'controlPlaneUrl', required: true, sensitive: false, description: 'Control plane URL' }, + { name: 'teamRegistrationCode', required: true, sensitive: true, description: 'Team registration code' }, + ] }; + return { state: 'pending-approval', profile: 'capacity-provider' }; + }, write: (value) => output.push(value), + }); + assert.equal(exit, 0); assert.equal(calls.length, 2); + assert.deepEqual(calls[0], { handlerId: 'local.host.initialize', arguments: [], options: { plan: true, profile: 'capacity-provider' } }); + assert.deepEqual(JSON.parse(calls[1].options.payload), { profile: 'capacity-provider', inputs: { controlPlaneUrl: 'https://api.example.test', teamRegistrationCode: secret } }); + assert.equal(JSON.stringify(output).includes(secret), false); +}); + +test('host initialize rejects incomplete execution confirmation before manager invocation', async () => { + for (const argv of [['host', 'initialize', '--profile', 'core', '--confirm', '--json'], ['host', 'initialize', '--profile', 'core', '--yes', '--json']]) { + let calls = 0; const output: string[] = []; + const exit = await runCommandLine(argv, { interactiveUi: false, hostInvoke: async () => { calls += 1; }, write: (value) => output.push(value) }); + assert.equal(exit, 1); assert.equal(calls, 0); assert.equal(JSON.parse(output[0]!).error.category, 'confirmation_required'); + } +});