@msn-control/liftoff 0.3.4 → 0.4.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (65) hide show
  1. package/README.md +82 -32
  2. package/assets/locks/frontend/package-lock.json +2217 -0
  3. package/assets/locks/frontend/package.json +19 -0
  4. package/assets/locks/node-backend/package-lock.json +4352 -0
  5. package/assets/locks/node-backend/package.json +32 -0
  6. package/dist/args.d.ts +42 -1
  7. package/dist/args.js +170 -37
  8. package/dist/args.js.map +1 -1
  9. package/dist/catalogs.d.ts +10 -1
  10. package/dist/catalogs.js +89 -0
  11. package/dist/catalogs.js.map +1 -1
  12. package/dist/cli.js +11 -1
  13. package/dist/cli.js.map +1 -1
  14. package/dist/commands.d.ts +9 -0
  15. package/dist/commands.js +832 -227
  16. package/dist/commands.js.map +1 -1
  17. package/dist/file-system.js +89 -5
  18. package/dist/file-system.js.map +1 -1
  19. package/dist/framework-adapters.d.ts +18 -0
  20. package/dist/framework-adapters.js +115 -0
  21. package/dist/framework-adapters.js.map +1 -0
  22. package/dist/framework-validation.d.ts +8 -0
  23. package/dist/framework-validation.js +83 -0
  24. package/dist/framework-validation.js.map +1 -0
  25. package/dist/init-filesystem.d.ts +102 -0
  26. package/dist/init-filesystem.js +762 -0
  27. package/dist/init-filesystem.js.map +1 -0
  28. package/dist/interactive.d.ts +43 -2
  29. package/dist/interactive.js +202 -91
  30. package/dist/interactive.js.map +1 -1
  31. package/dist/npm-template-assets.d.ts +3 -0
  32. package/dist/npm-template-assets.js +32 -0
  33. package/dist/npm-template-assets.js.map +1 -0
  34. package/dist/planner.d.ts +5 -0
  35. package/dist/planner.js +87 -21
  36. package/dist/planner.js.map +1 -1
  37. package/dist/process-runner.d.ts +28 -0
  38. package/dist/process-runner.js +83 -0
  39. package/dist/process-runner.js.map +1 -0
  40. package/dist/project-dependencies.d.ts +30 -0
  41. package/dist/project-dependencies.js +164 -0
  42. package/dist/project-dependencies.js.map +1 -0
  43. package/dist/published-verifier.js +1 -1
  44. package/dist/published-verifier.js.map +1 -1
  45. package/dist/reconcile.js +4 -1
  46. package/dist/reconcile.js.map +1 -1
  47. package/dist/runtime.d.ts +2 -0
  48. package/dist/runtime.js +9 -0
  49. package/dist/runtime.js.map +1 -0
  50. package/dist/standard-templates.js +3 -30
  51. package/dist/standard-templates.js.map +1 -1
  52. package/dist/templates.d.ts +9 -1
  53. package/dist/templates.js +110 -46
  54. package/dist/templates.js.map +1 -1
  55. package/dist/terminal.d.ts +128 -0
  56. package/dist/terminal.js +598 -0
  57. package/dist/terminal.js.map +1 -0
  58. package/dist/types.d.ts +38 -1
  59. package/dist/workstation-catalog.d.ts +20 -0
  60. package/dist/workstation-catalog.js +122 -0
  61. package/dist/workstation-catalog.js.map +1 -0
  62. package/dist/workstation.d.ts +76 -0
  63. package/dist/workstation.js +461 -0
  64. package/dist/workstation.js.map +1 -0
  65. package/package.json +8 -2
package/dist/commands.js CHANGED
@@ -1,23 +1,41 @@
1
+ import { createHash } from 'node:crypto';
1
2
  import { spawnSync } from 'node:child_process';
2
3
  import { existsSync } from 'node:fs';
3
- import { cp, mkdir, mkdtemp, rm, stat, writeFile } from 'node:fs/promises';
4
+ import { cp, lstat, mkdir, mkdtemp, readFile, readdir, readlink, rm, stat } from 'node:fs/promises';
4
5
  import os from 'node:os';
5
6
  import path from 'node:path';
6
- import { formatCommandHelp, readBooleanFlag, readListFlag, readStringFlag } from './args.js';
7
- import { apiStacks, listRegions, patterns, providers, searchRegions } from './catalogs.js';
8
- import { artifactPath, assertNewOrEmptyDirectory, deleteProjectFile, findProjectRoot, loadManifest, manifestDisplayPath, resolveProjectPath, resolveTargetRoot, validateGeneratedProject, writeArtifacts, writeProjectFile } from './file-system.js';
9
- import { confirmPlan, promptForCreateOptions } from './interactive.js';
7
+ import { getCommandHelp, getGeneralHelp, readBooleanFlag, readListFlag, readStringFlag } from './args.js';
8
+ import { getCodingAgent, getFrameworkDefinition, getSpecWorkflow, listRegions, patterns, providers, searchRegions } from './catalogs.js';
9
+ import { initializeFramework } from './framework-adapters.js';
10
+ import { validateFrameworkInstallation } from './framework-validation.js';
11
+ import { artifactPath, assertNewOrEmptyDirectory, deleteProjectFile, findProjectRoot, loadManifest, manifestDisplayPath, resolveProjectPath, validateGeneratedProject, writeArtifacts, writeProjectFile } from './file-system.js';
12
+ import { InteractivePrompter } from './interactive.js';
13
+ import { applyMergePreflight, assertSafeInitTarget, authorizeMergePreflight, buildMergePreflight, captureTreeState, discoverGitRoot, resolveInitTargetFromDiscovery, validateStagedTree, withStagingArea, writeStagedArtifacts } from './init-filesystem.js';
10
14
  import { renderMigrationChecklist, renderMigrationProposal, renderMigrationTasks, seedMigrationGroups } from './migrate-plan.js';
11
- import { buildProjectPlan, formatProjectPlan, loadConfigOptions, mergeOptions, PlanValidationError } from './planner.js';
15
+ import { buildProjectPlan, loadConfigOptions, mergeOptions, PlanValidationError, projectPlanEntries } from './planner.js';
16
+ import { buildDependencySetupPlan, runDependencySetup } from './project-dependencies.js';
17
+ import { formatCommand, NodeCommandRunner } from './process-runner.js';
12
18
  import { scanDefaults, scanLegacyProject } from './scan.js';
13
19
  import { hasDrift, reconcileProject } from './reconcile.js';
14
20
  import { compareSemver } from './semver.js';
15
- import { buildArtifacts, buildManifest } from './templates.js';
21
+ import { buildArtifacts, buildManifest, partitionGeneratedArtifacts } from './templates.js';
22
+ import { PresentationSession } from './terminal.js';
16
23
  import { liftoffVersion } from './version.js';
24
+ import { blockingReadinessFailures, detectHostEnvironment, installRequirement, probeWorkstation, selectLiftoffRuntimeRequirements, selectWorkstationRequirements } from './workstation.js';
17
25
  export async function runCommand(parsed, context) {
26
+ const helpRequested = parsed.command !== undefined && readBooleanFlag(parsed.flags, 'help') === true;
27
+ const jsonMode = !helpRequested && (parsed.command === 'doctor' || parsed.command === 'update') &&
28
+ readBooleanFlag(parsed.flags, 'json') === true;
29
+ const presentation = new PresentationSession({
30
+ stdout: context.stdout,
31
+ stderr: context.stderr,
32
+ ...context.terminal,
33
+ json: jsonMode
34
+ });
35
+ const executionContext = { ...context, presentation };
18
36
  try {
19
37
  if (parsed.command && readBooleanFlag(parsed.flags, 'help')) {
20
- context.stdout.write(formatCommandHelp(parsed.command));
38
+ renderCommandHelp(parsed.command, presentation);
21
39
  return 0;
22
40
  }
23
41
  switch (parsed.command) {
@@ -25,154 +43,479 @@ export async function runCommand(parsed, context) {
25
43
  case 'help':
26
44
  case '--help':
27
45
  if (parsed.positional[0]) {
28
- context.stdout.write(formatCommandHelp(parsed.positional[0]));
46
+ renderCommandHelp(parsed.positional[0], presentation);
29
47
  }
30
48
  else {
31
- printHelp(context.stdout);
49
+ renderGeneralHelp(presentation);
32
50
  }
33
51
  return 0;
34
52
  case 'version':
35
- context.stdout.write(`Liftoff ${liftoffVersion}\n`);
53
+ presentation.rawStdout(`Liftoff ${liftoffVersion}\n`);
36
54
  return 0;
37
- case 'create':
38
- return await createCommand(parsed, context);
55
+ case 'init':
56
+ return await initCommand(parsed, executionContext);
39
57
  case 'plan':
40
- return await planCommand(parsed, context);
58
+ return await planCommand(parsed, executionContext);
41
59
  case 'patterns':
42
- return patternsCommand(context);
60
+ return patternsCommand(executionContext);
43
61
  case 'providers':
44
- return providersCommand(context);
62
+ return providersCommand(executionContext);
45
63
  case 'regions':
46
- return regionsCommand(parsed, context);
64
+ return regionsCommand(parsed, executionContext);
47
65
  case 'validate':
48
- return await validateCommand(parsed, context);
66
+ return await validateCommand(parsed, executionContext);
49
67
  case 'update':
50
- return await updateCommand(parsed, context);
68
+ return await updateCommand(parsed, executionContext);
51
69
  case 'migrate':
52
- return await migrateCommand(parsed, context);
70
+ return await migrateCommand(parsed, executionContext);
53
71
  case 'doctor':
54
- return await doctorCommand(parsed, context);
72
+ return await doctorCommand(parsed, executionContext);
55
73
  case 'dev':
56
- return helperCommand(parsed, context, 'docker compose');
74
+ return helperCommand(parsed, executionContext, 'docker compose');
57
75
  case 'infra':
58
- return helperCommand(parsed, context, 'tofu');
76
+ return helperCommand(parsed, executionContext, 'tofu');
59
77
  default:
60
- context.stderr.write(`Unknown command: ${parsed.command}\n\n`);
61
- printHelp(context.stderr);
78
+ presentation.error(`Unknown command: ${parsed.command}`, 'Run `liftoff help` to list available commands.');
62
79
  return 1;
63
80
  }
64
81
  }
65
82
  catch (error) {
66
83
  if (error instanceof PlanValidationError) {
67
- context.stderr.write(`${error.issues.join('\n')}\n`);
84
+ presentation.error(error.issues.join('\n'), parsed.command ? `Run \`liftoff ${parsed.command} --help\` to review accepted values.` : undefined);
68
85
  return 1;
69
86
  }
70
- context.stderr.write(`${error.message}\n`);
87
+ presentation.error(error instanceof Error ? error.message : String(error));
71
88
  return 1;
72
89
  }
73
90
  }
74
- async function createCommand(parsed, context) {
75
- const initial = await optionsFromParsedArgs(parsed, context.cwd, true);
76
- const needsPrompts = !initial.yes && hasMissingCreateInputs(initial);
77
- const options = needsPrompts ? await promptForCreateOptions(initial) : initial;
78
- const plan = buildProjectPlan(options, { requireProjectName: true });
79
- const confirmed = await confirmPlan(plan, options.yes);
80
- if (!confirmed) {
81
- context.stdout.write('Generation cancelled.\n');
91
+ async function initCommand(parsed, context) {
92
+ const { presentation } = context;
93
+ presentation.identity('Initialize the project and prepare its workstation');
94
+ const runner = context.runner ?? new NodeCommandRunner();
95
+ presentation.stage('Discover project context');
96
+ const git = await discoverGitRoot(context.cwd, runner);
97
+ let initial = await optionsFromParsedArgs(parsed, context.cwd, true);
98
+ if (!initial.projectName && git.exact && git.root) {
99
+ initial = { ...initial, projectName: path.basename(git.root) };
100
+ }
101
+ const interactive = initial.yes !== true;
102
+ const prompter = interactive
103
+ ? new InteractivePrompter({
104
+ input: context.stdin,
105
+ output: context.stdout,
106
+ presentation
107
+ })
108
+ : undefined;
109
+ try {
110
+ const needsPrompts = interactive && hasMissingInitInputs(initial);
111
+ if (needsPrompts) {
112
+ presentation.stage('Configure project');
113
+ }
114
+ const options = needsPrompts ? await prompter.promptForInitOptions(initial) : initial;
115
+ const plan = buildProjectPlan(options, { requireProjectName: true });
116
+ presentation.stage('Review resolved plan');
117
+ const confirmed = options.yes === true
118
+ ? (presentation.definitions('Resolved project plan', projectPlanEntries(plan)), true)
119
+ : await prompter.confirmPlan(plan);
120
+ if (!confirmed) {
121
+ presentation.cancellation('Initialization stopped; no destination files were changed.');
122
+ return 0;
123
+ }
124
+ presentation.stage('Resolve destination');
125
+ const target = resolveInitTargetFromDiscovery(git, plan.safeProjectName);
126
+ await assertSafeInitTarget(target, target.mode === 'named-child' ? git.canonicalCwd : undefined);
127
+ presentation.stage('Check workstation readiness');
128
+ const readiness = await ensureWorkstationReady(plan, options, context, runner, presentation, prompter);
129
+ if (!readiness.ready) {
130
+ return 1;
131
+ }
132
+ presentation.stage('Stage project files');
133
+ const staged = await withStagingArea(async (area) => {
134
+ const partition = partitionGeneratedArtifacts(buildArtifacts(plan));
135
+ await writeStagedArtifacts(area, partition.durable, 'liftoff');
136
+ presentation.stage('Initialize spec-driven framework', `${plan.specWorkflow.label} ${plan.framework.version}`);
137
+ await initializeFramework(area, plan, runner, {
138
+ ...presentation.childStreams(),
139
+ onCommand: (command) => presentation.command(command)
140
+ });
141
+ await writeStagedArtifacts(area, partition.seed, 'seed');
142
+ await writeStagedArtifacts(area, [partition.manifest], 'liftoff');
143
+ presentation.stage('Validate staged project');
144
+ await validateStagedTree(area);
145
+ const stagedIssues = await validateGeneratedProject(area.root);
146
+ if (stagedIssues.length > 0) {
147
+ throw new Error(`Staged project validation failed:\n${stagedIssues.join('\n')}`);
148
+ }
149
+ const preflight = await buildMergePreflight(area, target.root);
150
+ const authorized = await authorizeMergePreflight(preflight, options.force === true, interactive ? (paths) => prompter.confirmFileReplacements(paths) : undefined);
151
+ if (!authorized) {
152
+ return {
153
+ status: interactive ? 'declined' : 'authorization-required'
154
+ };
155
+ }
156
+ presentation.stage('Merge staged project', target.root);
157
+ return { status: 'applied', merge: await applyMergePreflight(authorized) };
158
+ });
159
+ if (staged.status === 'declined') {
160
+ presentation.cancellation('No destination files were changed.');
161
+ return 0;
162
+ }
163
+ if (staged.status === 'authorization-required') {
164
+ presentation.error('Existing regular-file conflicts require --force in non-interactive mode.', 'Review the listed conflicts, then rerun with `--force` only if every replacement is intended.');
165
+ return 1;
166
+ }
167
+ const issues = await validateGeneratedProject(target.root);
168
+ if (issues.length > 0) {
169
+ presentation.error(`Initialized project validation failed:\n${issues.join('\n')}`);
170
+ return 1;
171
+ }
172
+ const dependencyPhase = await handleProjectDependencies(plan, target.root, options, readiness.probes, context, runner, presentation, prompter);
173
+ if (!dependencyPhase.success) {
174
+ return 1;
175
+ }
176
+ presentation.bullets('Configured integrations', [
177
+ `${plan.specWorkflow.label} ${plan.framework.version}`,
178
+ ...plan.agents.map((agent) => `${agent.label}${plan.defaultAgent?.id === agent.id ? ' (default)' : ''}`)
179
+ ]);
180
+ if (readiness.deferred.length > 0) {
181
+ presentation.bullets('Deferred advisory checks', readiness.deferred);
182
+ }
183
+ if (dependencyPhase.deferred.length > 0) {
184
+ presentation.bullets('Deferred project dependencies', dependencyPhase.deferred);
185
+ }
186
+ presentation.completion(`Initialized ${plan.projectName}`, target.root, [
187
+ { label: 'Target', value: target.root },
188
+ { label: 'Spec workflow', value: plan.specWorkflow.label },
189
+ { label: 'Coding agents', value: plan.agents.map((agent) => agent.label).join(', ') }
190
+ ], `liftoff validate ${JSON.stringify(target.root)}`);
82
191
  return 0;
83
192
  }
84
- const targetRoot = resolveTargetRoot(context.cwd, plan.safeProjectName);
85
- const artifacts = buildArtifacts(plan);
86
- await writeArtifacts(targetRoot, artifacts);
87
- const issues = await validateGeneratedProject(targetRoot);
88
- if (issues.length > 0) {
89
- context.stderr.write(`Generated project validation failed:\n${issues.join('\n')}\n`);
90
- return 1;
193
+ finally {
194
+ prompter?.close();
91
195
  }
92
- context.stdout.write(`Created ${plan.projectName} at ${targetRoot}\n`);
93
- return 0;
94
196
  }
95
197
  async function planCommand(parsed, context) {
198
+ const { presentation } = context;
199
+ presentation.identity('Preview project decisions, artifacts, and workstation requirements');
96
200
  const options = await optionsFromParsedArgs(parsed, context.cwd, false);
97
201
  const plan = buildProjectPlan(options, { requireProjectName: false });
98
202
  const artifacts = buildArtifacts(plan);
99
- context.stdout.write(`${formatProjectPlan(plan)}\n\nArtifacts (${artifacts.length}):\n`);
100
- for (const artifact of artifacts) {
101
- context.stdout.write(`- ${artifact.logicalName}: ${artifact.pathParts.join('/')}\n`);
203
+ presentation.definitions('Project decisions', projectPlanEntries(plan));
204
+ presentation.table(`Artifacts (${artifacts.length})`, ['Artifact', 'Path'], artifacts.map((artifact) => [artifact.logicalName, artifact.pathParts.join('/')]));
205
+ const workstationRows = selectWorkstationRequirements(plan).map((requirement) => [
206
+ requirement.definition.label,
207
+ requirement.exactVersion
208
+ ? `exactly ${requirement.exactVersion}`
209
+ : requirement.minimumVersion
210
+ ? `${requirement.minimumVersion}+`
211
+ : 'available',
212
+ requirement.severity
213
+ ]);
214
+ if (presentation.stdout.layout === 'plain') {
215
+ presentation.section('Workstation requirements', workstationRows.map(([label, version, severity]) => `${label}: ${version} [${severity}]`));
216
+ }
217
+ else {
218
+ presentation.table('Workstation requirements', ['Requirement', 'Version', 'Level'], workstationRows);
102
219
  }
103
220
  return 0;
104
221
  }
105
- function patternsCommand(context) {
106
- context.stdout.write('Patterns:\n');
107
- for (const pattern of patterns) {
108
- context.stdout.write(`- ${pattern.id}: ${pattern.label} [${pattern.scaffoldStatus}]\n`);
222
+ async function ensureWorkstationReady(plan, options, context, runner, presentation, prompter, resumeInvocation = 'liftoff init', commandCwd) {
223
+ const requirements = selectWorkstationRequirements(plan);
224
+ let probes = await probeWorkstation(requirements, runner, { cwd: commandCwd });
225
+ presentation.table('Workstation readiness', ['Requirement', 'Level', 'State', 'Detail'], probes.map((probe) => [
226
+ probe.requirement.definition.label,
227
+ probe.requirement.severity,
228
+ probe.state,
229
+ probe.detail
230
+ ]));
231
+ const actionable = probes.filter((probe) => probe.state !== 'ready');
232
+ const host = await detectHostEnvironment();
233
+ const installInstruction = (probe) => {
234
+ const recipe = probe.requirement.definition.install[host.platform];
235
+ const automatic = recipe && (host.platform !== 'linux' || recipe.manager === 'npm' || recipe.manager === 'uv');
236
+ if (automatic) {
237
+ return formatCommand(recipe.command);
238
+ }
239
+ if (host.platform === 'linux') {
240
+ return probe.requirement.definition.linuxRemedies[host.linuxFamily];
241
+ }
242
+ return `Install ${probe.requirement.definition.label} manually, then retry.`;
243
+ };
244
+ const authorizedInstallations = new Set();
245
+ if (options.installTools === true) {
246
+ for (const probe of actionable) {
247
+ authorizedInstallations.add(probe.requirement.id);
248
+ }
249
+ }
250
+ if (options.installTools === undefined &&
251
+ options.yes !== true &&
252
+ actionable.length > 0) {
253
+ for (const probe of actionable) {
254
+ const recipe = probe.requirement.definition.install[host.platform];
255
+ const automatic = recipe && (host.platform !== 'linux' || recipe.manager === 'npm' || recipe.manager === 'uv');
256
+ const constraint = probe.requirement.exactVersion
257
+ ? `required exactly ${probe.requirement.exactVersion}`
258
+ : probe.requirement.minimumVersion
259
+ ? `required ${probe.requirement.minimumVersion} or newer`
260
+ : 'required to be available';
261
+ if (await prompter.confirmToolInstallation({
262
+ label: probe.requirement.definition.label,
263
+ severity: probe.requirement.severity,
264
+ purpose: probe.requirement.reasons.join('; '),
265
+ requirement: constraint,
266
+ observed: `${probe.state} - ${probe.detail}`,
267
+ ...(automatic
268
+ ? { command: formatCommand(recipe.command) }
269
+ : { remedy: installInstruction(probe) })
270
+ })) {
271
+ authorizedInstallations.add(probe.requirement.id);
272
+ }
273
+ }
274
+ }
275
+ if (authorizedInstallations.size > 0) {
276
+ const updates = new Map();
277
+ for (const probe of actionable) {
278
+ if (!authorizedInstallations.has(probe.requirement.id)) {
279
+ continue;
280
+ }
281
+ presentation.stage(`Install ${probe.requirement.definition.label}`, `${probe.state} - ${probe.detail}`);
282
+ const recipe = probe.requirement.definition.install[host.platform];
283
+ if (recipe && (host.platform !== 'linux' || recipe.manager === 'npm' || recipe.manager === 'uv')) {
284
+ presentation.command(formatCommand(recipe.command));
285
+ }
286
+ const installation = await installRequirement(probe.requirement, probe, {
287
+ authorized: true,
288
+ host,
289
+ runner,
290
+ cwd: commandCwd,
291
+ streamOptions: presentation.childStreams()
292
+ });
293
+ updates.set(probe.requirement.id, installation.probe);
294
+ const kind = installation.state === 'installed'
295
+ ? 'success'
296
+ : probe.requirement.severity === 'blocking'
297
+ ? 'error'
298
+ : 'warning';
299
+ presentation.status(kind, probe.requirement.definition.label, installation.detail);
300
+ if (installation.remedy) {
301
+ presentation.command(installation.remedy);
302
+ }
303
+ }
304
+ probes = probes.map((probe) => updates.get(probe.requirement.id) ?? probe);
305
+ }
306
+ const blockers = blockingReadinessFailures(probes);
307
+ if (blockers.length > 0) {
308
+ for (const blocker of blockers) {
309
+ presentation.error(`${blocker.requirement.definition.label}: ${blocker.detail}`, installInstruction(blocker));
310
+ }
311
+ presentation.error('Workstation readiness is incomplete.', options.installTools
312
+ ? `Open a new terminal if PATH changed, then rerun \`${resumeInvocation}\` with the same project options.`
313
+ : `Resume with \`${resumeInvocation} --install-tools\` plus the same project options after reviewing the commands.`);
314
+ return { ready: false, deferred: [], probes };
315
+ }
316
+ const deferred = [
317
+ ...probes
318
+ .filter((probe) => probe.requirement.severity === 'advisory' && probe.state !== 'ready')
319
+ .map((probe) => `${probe.requirement.definition.label}: ${probe.detail} Remedy: ${installInstruction(probe)}`),
320
+ ...probes.flatMap((probe) => probe.notices
321
+ .filter((notice) => notice.state !== 'ready')
322
+ .map((notice) => `${notice.label}: ${notice.detail}${notice.remedy ? ` Remedy: ${notice.remedy}` : ''}`))
323
+ ];
324
+ return { ready: true, deferred, probes };
325
+ }
326
+ async function handleProjectDependencies(plan, projectRoot, options, probes, context, runner, presentation, prompter) {
327
+ const dependencyPlan = buildDependencySetupPlan(plan, projectRoot, probes);
328
+ let installDependencies = options.installDependencies === true;
329
+ if (options.installDependencies === undefined &&
330
+ options.yes !== true &&
331
+ dependencyPlan.commands.length > 0) {
332
+ installDependencies = await prompter.confirmDependencyInstallation(dependencyPlan.commands);
333
+ }
334
+ if (!installDependencies) {
335
+ return {
336
+ success: true,
337
+ deferred: dependencyPlan.commands.map((command) => `${command.label}: ${command.cwd} -> ${formatCommand(command.command)}`)
338
+ };
339
+ }
340
+ presentation.stage('Install project dependencies');
341
+ const result = await runDependencySetup(dependencyPlan, projectRoot, runner, {
342
+ ...presentation.childStreams(),
343
+ onCommand: (command) => {
344
+ presentation.status('pending', command.label, command.cwd);
345
+ presentation.command(formatCommand(command.command));
346
+ }
347
+ });
348
+ if (!result.success) {
349
+ presentation.error('Project dependencies failed', `${result.failed?.label ?? 'dependency command'}: ${result.detail ?? 'unknown failure'}`);
350
+ presentation.status('info', 'Scaffold preserved', projectRoot);
351
+ if (result.restoredMutations.length > 0) {
352
+ presentation.warning(`Restored protected files: ${result.restoredMutations.join(', ')}`);
353
+ }
354
+ if (result.resumeCommand) {
355
+ presentation.command(result.resumeCommand);
356
+ }
357
+ return { success: false, deferred: [] };
109
358
  }
359
+ presentation.status('success', 'Project dependencies', `${result.completed.length} command${result.completed.length === 1 ? '' : 's'} completed`);
360
+ return { success: true, deferred: [] };
361
+ }
362
+ function patternsCommand(context) {
363
+ context.presentation.commandIdentity('patterns', 'Available GenAI application patterns');
364
+ context.presentation.table('Patterns', ['Identifier', 'Pattern', 'Scaffold'], patterns.map((pattern) => [pattern.id, pattern.label, pattern.scaffoldStatus]));
110
365
  return 0;
111
366
  }
112
367
  function providersCommand(context) {
113
- context.stdout.write('Providers:\n');
114
- for (const provider of providers) {
115
- context.stdout.write(`- ${provider.id}: ${provider.label} [${provider.status}]\n`);
116
- }
368
+ context.presentation.commandIdentity('providers', 'Cloud provider availability');
369
+ context.presentation.table('Providers', ['Identifier', 'Provider', 'Availability'], providers.map((provider) => [provider.id, provider.label, provider.status]));
117
370
  return 0;
118
371
  }
119
372
  function regionsCommand(parsed, context) {
120
373
  const cloud = readStringFlag(parsed.flags, 'cloud') ?? 'azure';
374
+ context.presentation.commandIdentity('regions', 'Cloud deployment regions');
121
375
  if (cloud !== 'azure') {
122
- context.stderr.write(`${cloud} regions are not available until the provider adapter is implemented.\n`);
376
+ context.presentation.error(`${cloud} regions are not available until the provider adapter is implemented.`, 'Run `liftoff providers` to review currently available providers.');
123
377
  return 1;
124
378
  }
125
379
  const query = parsed.positional[0] ?? readStringFlag(parsed.flags, 'region');
126
380
  const regions = parsed.subcommand === 'search' && query ? searchRegions('azure', query) : listRegions('azure');
127
- for (const region of regions) {
128
- context.stdout.write(`${region.slug}\t${region.displayName}\t${region.geography}\n`);
381
+ if (regions.length === 0) {
382
+ context.presentation.warning(`No Azure regions matched ${JSON.stringify(query ?? '')}.`);
383
+ return 0;
129
384
  }
385
+ context.presentation.table(query ? `Azure region matches for ${JSON.stringify(query)}` : 'Azure regions', ['Identifier', 'Region', 'Geography'], regions.map((region) => [region.slug, region.displayName, region.geography]));
130
386
  return 0;
131
387
  }
132
388
  async function validateCommand(parsed, context) {
389
+ context.presentation.commandIdentity('validate', 'Validate a generated Liftoff project');
133
390
  const explicit = parsed.positional[0] ?? readStringFlag(parsed.flags, 'project');
134
391
  const projectRoot = explicit
135
392
  ? path.resolve(context.cwd, explicit)
136
393
  : (await findProjectRoot(context.cwd)) ?? context.cwd;
137
394
  const issues = await validateGeneratedProject(projectRoot);
138
395
  if (issues.length > 0) {
139
- context.stderr.write(`${issues.join('\n')}\n`);
396
+ context.presentation.error(issues.join('\n'), 'Restore invalid generated files or the manifest from version control, then rerun validation.');
140
397
  return 1;
141
398
  }
142
- context.stdout.write('Generated project manifest is valid.\n');
399
+ context.presentation.status('success', 'Generated project manifest is valid', projectRoot);
143
400
  return 0;
144
401
  }
145
402
  const STAGING_EXCLUDES = new Set(['.git', 'node_modules', 'vendor', '.venv', 'venv', '__pycache__', 'dist', 'build', '.next']);
146
- async function migrateCommand(parsed, context) {
147
- const sourceArg = parsed.positional[0];
148
- if (!sourceArg) {
149
- context.stderr.write('Usage: liftoff migrate <path-to-existing-project>\n');
150
- return 1;
151
- }
152
- const sourceRoot = path.resolve(context.cwd, sourceArg);
153
- let sourceDetails;
403
+ async function snapshotMigrationSource(sourceRoot) {
404
+ const snapshot = new Map();
405
+ const visit = async (pathParts) => {
406
+ const current = path.join(sourceRoot, ...pathParts);
407
+ const entries = await readdir(current, { withFileTypes: true });
408
+ entries.sort((left, right) => left.name.localeCompare(right.name, 'en'));
409
+ for (const entry of entries) {
410
+ const childParts = [...pathParts, entry.name];
411
+ const relativePath = childParts.join('/');
412
+ const childPath = path.join(sourceRoot, ...childParts);
413
+ const details = await lstat(childPath);
414
+ if (details.isSymbolicLink()) {
415
+ snapshot.set(relativePath, `symlink:${await readlink(childPath)}`);
416
+ }
417
+ else if (details.isDirectory()) {
418
+ snapshot.set(relativePath, 'directory');
419
+ await visit(childParts);
420
+ }
421
+ else if (details.isFile()) {
422
+ const hash = createHash('sha256').update(await readFile(childPath)).digest('hex');
423
+ snapshot.set(relativePath, `file:${hash}`);
424
+ }
425
+ else {
426
+ snapshot.set(relativePath, 'other');
427
+ }
428
+ }
429
+ };
430
+ await visit([]);
431
+ return snapshot;
432
+ }
433
+ function sourceSnapshotChanges(before, after) {
434
+ return [...new Set([...before.keys(), ...after.keys()])]
435
+ .filter((key) => before.get(key) !== after.get(key))
436
+ .sort();
437
+ }
438
+ async function withUnchangedMigrationSource(sourceRoot, operation) {
439
+ const before = await snapshotMigrationSource(sourceRoot);
440
+ const assertUnchanged = async () => {
441
+ const changes = sourceSnapshotChanges(before, await snapshotMigrationSource(sourceRoot));
442
+ if (changes.length > 0) {
443
+ throw new Error(`Migration source changed unexpectedly; refusing to continue:\n${changes.map((entry) => `- ${entry}`).join('\n')}`);
444
+ }
445
+ };
446
+ let result;
154
447
  try {
155
- sourceDetails = await stat(sourceRoot);
448
+ result = await operation();
156
449
  }
157
- catch {
158
- context.stderr.write(`Source project not found: ${sourceRoot}\n`);
159
- return 1;
450
+ catch (error) {
451
+ await assertUnchanged();
452
+ throw error;
160
453
  }
161
- if (!sourceDetails.isDirectory()) {
162
- context.stderr.write(`Source path is not a directory: ${sourceRoot}\n`);
163
- return 1;
454
+ await assertUnchanged();
455
+ return result;
456
+ }
457
+ async function stageMigrationSource(area, sourceRoot) {
458
+ const destination = path.join(area.root, 'migration', 'legacy');
459
+ await cp(sourceRoot, destination, {
460
+ recursive: true,
461
+ filter: (source) => {
462
+ const relative = path.relative(sourceRoot, source);
463
+ if (!relative) {
464
+ return true;
465
+ }
466
+ return !relative.split(path.sep).some((part) => STAGING_EXCLUDES.has(part));
467
+ }
468
+ });
469
+ for (const entry of (await captureTreeState(area.root)).values()) {
470
+ if (entry.pathParts[0] === 'migration' &&
471
+ entry.pathParts[1] === 'legacy' &&
472
+ entry.type !== 'directory') {
473
+ area.origins.set(entry.pathParts.join('/'), 'seed');
474
+ }
164
475
  }
165
- if (existsSync(path.join(sourceRoot, 'liftoff.manifest.json'))) {
166
- context.stderr.write(`${sourceRoot} is already a Liftoff project. Use liftoff update instead.\n`);
167
- return 1;
476
+ }
477
+ function migrationPlanArtifacts(plan, inventory) {
478
+ const groups = seedMigrationGroups(inventory, plan);
479
+ if (plan.specWorkflow.id === 'openspec') {
480
+ return {
481
+ artifacts: [
482
+ {
483
+ logicalName: 'migration-proposal',
484
+ category: 'seed',
485
+ pathParts: ['openspec', 'changes', 'migrate-to-liftoff', 'proposal.md'],
486
+ content: renderMigrationProposal(plan, inventory)
487
+ },
488
+ {
489
+ logicalName: 'migration-tasks',
490
+ category: 'seed',
491
+ pathParts: ['openspec', 'changes', 'migrate-to-liftoff', 'tasks.md'],
492
+ content: renderMigrationTasks(groups)
493
+ }
494
+ ],
495
+ location: 'openspec/changes/migrate-to-liftoff/ (run it with your agent workflow, e.g. /opsx:apply migrate-to-liftoff)'
496
+ };
168
497
  }
498
+ return {
499
+ artifacts: [{
500
+ logicalName: 'migration-checklist',
501
+ category: 'seed',
502
+ pathParts: ['MIGRATION.md'],
503
+ content: renderMigrationChecklist(plan, inventory, groups)
504
+ }],
505
+ location: 'MIGRATION.md'
506
+ };
507
+ }
508
+ async function executeMigration(parsed, context, sourceRoot) {
509
+ const { presentation } = context;
510
+ presentation.stage('Scan legacy project', sourceRoot);
169
511
  const inventory = await scanLegacyProject(sourceRoot);
170
512
  const { options: defaults, provenance } = scanDefaults(inventory);
171
- context.stdout.write('Scan defaults (override in prompts or with flags):\n');
172
- for (const item of provenance) {
173
- context.stdout.write(` - ${item.field}: ${item.value} (detected: ${item.evidence})\n`);
513
+ if (presentation.stdout.layout === 'plain') {
514
+ presentation.section('Scan defaults (override in prompts or with flags)', provenance.map((item) => `${item.field}: ${String(item.value)} (detected: ${item.evidence})`));
515
+ }
516
+ else {
517
+ presentation.table('Scan defaults (override in prompts or with flags)', ['Decision', 'Detected value', 'Evidence'], provenance.map((item) => [item.field, String(item.value), item.evidence]));
174
518
  }
175
- context.stdout.write('\n');
176
519
  const flagOptions = await optionsFromParsedArgs(parsed, context.cwd, false);
177
520
  const initial = mergeOptions(defaults, flagOptions);
178
521
  if (flagOptions.pattern && flagOptions.projectType === undefined) {
@@ -191,57 +534,146 @@ async function migrateCommand(parsed, context) {
191
534
  if (flagOptions.projectType === 'genai' && flagOptions.apiStack === undefined) {
192
535
  initial.apiStack = undefined;
193
536
  }
194
- const needsPrompts = !initial.yes && hasMissingCreateInputs(initial);
195
- const options = needsPrompts ? await promptForCreateOptions(initial) : initial;
196
- const plan = buildProjectPlan(options, { requireProjectName: true });
197
- const confirmed = await confirmPlan(plan, options.yes);
198
- if (!confirmed) {
199
- context.stdout.write('Migration cancelled.\n');
537
+ const interactive = initial.yes !== true;
538
+ const prompter = interactive
539
+ ? new InteractivePrompter({
540
+ input: context.stdin,
541
+ output: context.stdout,
542
+ presentation
543
+ })
544
+ : undefined;
545
+ try {
546
+ const needsPrompts = interactive && hasMissingInitInputs(initial);
547
+ if (needsPrompts) {
548
+ presentation.stage('Configure migrated project');
549
+ }
550
+ const options = needsPrompts ? await prompter.promptForInitOptions(initial) : initial;
551
+ const plan = buildProjectPlan(options, { requireProjectName: true });
552
+ presentation.stage('Review migration plan');
553
+ const confirmed = options.yes === true
554
+ ? (presentation.definitions('Resolved migration plan', projectPlanEntries(plan)), true)
555
+ : await prompter.confirmPlan(plan, undefined, 'Migrate project?');
556
+ if (!confirmed) {
557
+ presentation.cancellation('Migration stopped; the source and destination were not modified.');
558
+ return 0;
559
+ }
560
+ presentation.stage('Resolve fresh migration target');
561
+ const parentDir = path.dirname(sourceRoot);
562
+ let targetRoot = path.resolve(parentDir, plan.safeProjectName);
563
+ if (targetRoot === sourceRoot) {
564
+ targetRoot = path.resolve(parentDir, `${plan.safeProjectName}-liftoff`);
565
+ }
566
+ const target = { root: targetRoot, mode: 'named-child' };
567
+ await assertSafeInitTarget(target, parentDir);
568
+ await assertNewOrEmptyDirectory(targetRoot);
569
+ const runner = context.runner ?? new NodeCommandRunner();
570
+ presentation.stage('Check workstation readiness');
571
+ const readinessRoot = await mkdtemp(path.join(os.tmpdir(), 'liftoff-migrate-readiness-'));
572
+ const readiness = await (async () => {
573
+ try {
574
+ return await ensureWorkstationReady(plan, options, context, runner, presentation, prompter, `liftoff migrate ${JSON.stringify(sourceRoot)}`, readinessRoot);
575
+ }
576
+ finally {
577
+ await rm(readinessRoot, { recursive: true, force: true });
578
+ }
579
+ })();
580
+ if (!readiness.ready) {
581
+ return 1;
582
+ }
583
+ const migrationPlan = migrationPlanArtifacts(plan, inventory);
584
+ presentation.stage('Stage fresh migration project');
585
+ await withStagingArea(async (area) => {
586
+ const partition = partitionGeneratedArtifacts(buildArtifacts(plan));
587
+ await writeStagedArtifacts(area, partition.durable, 'liftoff');
588
+ presentation.stage('Initialize spec-driven framework', `${plan.specWorkflow.label} ${plan.framework.version}`);
589
+ await initializeFramework(area, plan, runner, {
590
+ ...presentation.childStreams(),
591
+ onCommand: (command) => presentation.command(command)
592
+ });
593
+ await writeStagedArtifacts(area, partition.seed, 'seed');
594
+ presentation.stage('Copy filtered legacy source', sourceRoot);
595
+ await stageMigrationSource(area, sourceRoot);
596
+ await writeStagedArtifacts(area, migrationPlan.artifacts, 'seed');
597
+ await writeStagedArtifacts(area, [partition.manifest], 'liftoff');
598
+ presentation.stage('Validate staged migration');
599
+ await validateStagedTree(area);
600
+ const stagedIssues = await validateGeneratedProject(area.root);
601
+ if (stagedIssues.length > 0) {
602
+ throw new Error(`Staged migration project validation failed:\n${stagedIssues.join('\n')}`);
603
+ }
604
+ const preflight = await buildMergePreflight(area, targetRoot);
605
+ const existing = preflight.entries.filter((entry) => entry.destination.type !== 'missing');
606
+ if (existing.length > 0) {
607
+ throw new Error(`Migration target must remain new or empty; --force cannot replace existing content:\n` +
608
+ existing.map((entry) => `- ${entry.relativePath}`).join('\n'));
609
+ }
610
+ const authorized = await authorizeMergePreflight(preflight, false);
611
+ if (!authorized) {
612
+ throw new Error('Migration target authorization failed.');
613
+ }
614
+ presentation.stage('Merge fresh migration target', targetRoot);
615
+ await applyMergePreflight(authorized, { requireEmptyTarget: true });
616
+ });
617
+ const issues = await validateGeneratedProject(targetRoot);
618
+ if (issues.length > 0) {
619
+ presentation.error(`Migrated project validation failed:\n${issues.join('\n')}`);
620
+ return 1;
621
+ }
622
+ const dependencyPhase = await handleProjectDependencies(plan, targetRoot, options, readiness.probes, context, runner, presentation, prompter);
623
+ if (!dependencyPhase.success) {
624
+ return 1;
625
+ }
626
+ presentation.bullets('Configured integrations', [
627
+ `${plan.specWorkflow.label} ${plan.framework.version}`,
628
+ ...plan.agents.map((agent) => `${agent.label}${plan.defaultAgent?.id === agent.id ? ' (default)' : ''}`)
629
+ ]);
630
+ if (readiness.deferred.length > 0) {
631
+ presentation.bullets('Deferred advisory checks', readiness.deferred);
632
+ }
633
+ if (dependencyPhase.deferred.length > 0) {
634
+ presentation.bullets('Deferred project dependencies', dependencyPhase.deferred);
635
+ }
636
+ presentation.bullets('Next steps', [
637
+ `Optional - preserve history: copy the .git directory from ${sourceRoot} into ${targetRoot}, then commit the migration on top (git rename detection preserves file history).`,
638
+ `Execute the migration plan: ${migrationPlan.location}`,
639
+ 'Verify compliance: liftoff validate && liftoff doctor'
640
+ ]);
641
+ presentation.completion(`Migrated ${plan.projectName}`, targetRoot, [
642
+ { label: 'Target', value: targetRoot },
643
+ { label: 'Source', value: `${sourceRoot} (not modified)` },
644
+ { label: 'Rollback', value: `Delete ${targetRoot}` }
645
+ ], 'liftoff validate && liftoff doctor');
200
646
  return 0;
201
647
  }
202
- const parentDir = path.dirname(sourceRoot);
203
- let targetRoot = path.resolve(parentDir, plan.safeProjectName);
204
- if (targetRoot === sourceRoot) {
205
- targetRoot = path.resolve(parentDir, `${plan.safeProjectName}-liftoff`);
648
+ finally {
649
+ prompter?.close();
206
650
  }
207
- const artifacts = buildArtifacts(plan);
208
- await writeArtifacts(targetRoot, artifacts);
209
- const issues = await validateGeneratedProject(targetRoot);
210
- if (issues.length > 0) {
211
- context.stderr.write(`Generated scaffold validation failed:\n${issues.join('\n')}\n`);
651
+ }
652
+ async function migrateCommand(parsed, context) {
653
+ const sourceArg = parsed.positional[0];
654
+ if (!sourceArg) {
655
+ context.presentation.error('Usage: liftoff migrate <path-to-existing-project>', 'Run `liftoff migrate --help` for accepted migration options.');
212
656
  return 1;
213
657
  }
214
- const stagingRoot = path.join(targetRoot, 'migration', 'legacy');
215
- await cp(sourceRoot, stagingRoot, {
216
- recursive: true,
217
- filter: (source) => {
218
- const relative = path.relative(sourceRoot, source);
219
- if (!relative) {
220
- return true;
221
- }
222
- return !relative.split(path.sep).some((part) => STAGING_EXCLUDES.has(part));
223
- }
224
- });
225
- const groups = seedMigrationGroups(inventory, plan);
226
- let planLocation;
227
- if (plan.specWorkflow.id === 'openspec') {
228
- const changeDir = path.join(targetRoot, 'openspec', 'changes', 'migrate-to-liftoff');
229
- await mkdir(changeDir, { recursive: true });
230
- await writeFile(path.join(changeDir, 'proposal.md'), renderMigrationProposal(plan, inventory), 'utf8');
231
- await writeFile(path.join(changeDir, 'tasks.md'), renderMigrationTasks(groups), 'utf8');
232
- planLocation = 'openspec/changes/migrate-to-liftoff/ (run it with your agent workflow, e.g. /opsx:apply migrate-to-liftoff)';
658
+ const sourceRoot = path.resolve(context.cwd, sourceArg);
659
+ let sourceDetails;
660
+ try {
661
+ sourceDetails = await stat(sourceRoot);
233
662
  }
234
- else {
235
- await writeFile(path.join(targetRoot, 'MIGRATION.md'), renderMigrationChecklist(plan, inventory, groups), 'utf8');
236
- planLocation = 'MIGRATION.md';
237
- }
238
- context.stdout.write(`Created ${plan.projectName} at ${targetRoot}\n\n`);
239
- context.stdout.write('Next steps:\n');
240
- context.stdout.write(` 1. Optional - preserve history: copy the .git directory from ${sourceRoot} into ${targetRoot}, then commit the migration on top (git rename detection preserves file history).\n`);
241
- context.stdout.write(` 2. Execute the migration plan: ${planLocation}\n`);
242
- context.stdout.write(' 3. Verify compliance: liftoff validate && liftoff doctor\n');
243
- context.stdout.write(`The source project was not modified. Rolling back is deleting ${targetRoot}.\n`);
244
- return 0;
663
+ catch {
664
+ context.presentation.error(`Source project not found: ${sourceRoot}`);
665
+ return 1;
666
+ }
667
+ if (!sourceDetails.isDirectory()) {
668
+ context.presentation.error(`Source path is not a directory: ${sourceRoot}`);
669
+ return 1;
670
+ }
671
+ if (existsSync(path.join(sourceRoot, 'liftoff.manifest.json'))) {
672
+ context.presentation.error(`${sourceRoot} is already a Liftoff project.`, 'Use `liftoff update` instead.');
673
+ return 1;
674
+ }
675
+ context.presentation.identity('Migrate an existing application into a fresh Liftoff project');
676
+ return withUnchangedMigrationSource(sourceRoot, () => executeMigration(parsed, context, sourceRoot));
245
677
  }
246
678
  function summarizeEntries(entries) {
247
679
  const summary = { new: 0, missing: 0, upgrade: 0, conflict: 0, moved: 0, orphan: 0, refresh: 0, unchanged: 0 };
@@ -311,36 +743,49 @@ async function preflightUpdate(projectRoot, entries, force) {
311
743
  await resolveProjectPath(projectRoot, ['liftoff.manifest.json']);
312
744
  }
313
745
  async function updateCommand(parsed, context) {
746
+ const { presentation } = context;
314
747
  const apply = readBooleanFlag(parsed.flags, 'apply') ?? false;
315
748
  const force = readBooleanFlag(parsed.flags, 'force') ?? false;
316
749
  const jsonMode = readBooleanFlag(parsed.flags, 'json') ?? false;
750
+ presentation.commandIdentity('update', 'Reconcile the project with current Liftoff templates');
317
751
  if (force && !apply) {
318
- context.stderr.write('--force requires --apply.\n');
752
+ presentation.error('--force requires --apply.', 'Run `liftoff update --apply --force` only after reviewing the reported conflicts.');
319
753
  return 1;
320
754
  }
321
755
  const explicit = parsed.positional[0] ?? readStringFlag(parsed.flags, 'project');
322
756
  const projectRoot = explicit ? path.resolve(context.cwd, explicit) : await findProjectRoot(context.cwd);
323
757
  if (!projectRoot) {
324
- context.stderr.write(`No liftoff.manifest.json found in ${context.cwd} or any parent directory.\n`);
758
+ presentation.error(`No liftoff.manifest.json found in ${context.cwd} or any parent directory.`, 'Run this command inside a Liftoff project or provide its path explicitly.');
325
759
  return 1;
326
760
  }
327
761
  const manifest = await loadManifest(projectRoot);
328
762
  if (compareSemver(manifest.liftoffVersion, liftoffVersion) > 0) {
329
- context.stderr.write(`This project was written by Liftoff ${manifest.liftoffVersion}, which is newer than this CLI (${liftoffVersion}). Upgrade the CLI first.\n`);
763
+ presentation.error(`This project was written by Liftoff ${manifest.liftoffVersion}, which is newer than this CLI (${liftoffVersion}).`, 'Upgrade the CLI first.');
330
764
  return 1;
331
765
  }
332
766
  const config = await loadConfigOptions('liftoff.config.json', projectRoot);
333
767
  const plan = buildProjectPlan(config, { requireProjectName: true });
334
768
  if (plan.projectType.id !== manifest.project.projectType) {
335
- context.stderr.write(`Project type changes (${manifest.project.projectType} -> ${plan.projectType.id}) are a migration, not an update. Run liftoff migrate instead.\n`);
769
+ presentation.error(`Project type changes (${manifest.project.projectType} -> ${plan.projectType.id}) are a migration, not an update.`, 'Run `liftoff migrate` instead.');
336
770
  return 1;
337
771
  }
338
772
  if (plan.apiStack.id !== manifest.project.apiStack) {
339
- context.stderr.write(`API stack changes (${manifest.project.apiStack} -> ${plan.apiStack.id}) are a migration, not an update. Run liftoff migrate instead.\n`);
773
+ presentation.error(`API stack changes (${manifest.project.apiStack} -> ${plan.apiStack.id}) are a migration, not an update.`, 'Run `liftoff migrate` instead.');
340
774
  return 1;
341
775
  }
342
776
  if (plan.pattern?.id !== manifest.project.pattern) {
343
- context.stderr.write(`Pattern changes (${manifest.project.pattern ?? 'none'} -> ${plan.pattern?.id ?? 'none'}) are a migration, not an update. Run liftoff migrate instead.\n`);
777
+ presentation.error(`Pattern changes (${manifest.project.pattern ?? 'none'} -> ${plan.pattern?.id ?? 'none'}) are a migration, not an update.`, 'Run `liftoff migrate` instead.');
778
+ return 1;
779
+ }
780
+ if (plan.specWorkflow.id !== manifest.project.specWorkflow) {
781
+ presentation.error(`Spec workflow changes (${manifest.project.specWorkflow} -> ${plan.specWorkflow.id}) require official framework initialization and are not supported by liftoff update.`, 'Restore the workflow recorded in liftoff.manifest.json or migrate into a fresh project.');
782
+ return 1;
783
+ }
784
+ const configuredAgents = plan.agents.map((agent) => agent.id);
785
+ if (manifest.framework.state === 'initialized' && (configuredAgents.length !== manifest.project.agents.length ||
786
+ configuredAgents.some((agent, index) => agent !== manifest.project.agents[index]) ||
787
+ plan.defaultAgent?.id !== manifest.project.defaultAgent)) {
788
+ presentation.error('AI agent or default-agent changes require official framework initialization and are not supported by liftoff update.', 'Restore liftoff.config.json to the integrations recorded in liftoff.manifest.json.');
344
789
  return 1;
345
790
  }
346
791
  const render = buildArtifacts(plan);
@@ -350,7 +795,7 @@ async function updateCommand(parsed, context) {
350
795
  const visible = entries.filter((entry) => entry.status !== 'unchanged' || entry.refreshHash);
351
796
  if (!apply) {
352
797
  if (jsonMode) {
353
- context.stdout.write(`${JSON.stringify({
798
+ presentation.rawStdout(`${JSON.stringify({
354
799
  schemaVersion: 1,
355
800
  mode: 'check',
356
801
  cliVersion: liftoffVersion,
@@ -366,22 +811,29 @@ async function updateCommand(parsed, context) {
366
811
  }, null, 2)}\n`);
367
812
  return drift ? 2 : 0;
368
813
  }
369
- context.stdout.write(`Liftoff ${liftoffVersion} - project generated by ${manifest.liftoffVersion}\n\n`);
814
+ presentation.definitions('Project versions', [
815
+ { label: 'Liftoff CLI', value: liftoffVersion },
816
+ { label: 'Project generated by', value: manifest.liftoffVersion }
817
+ ]);
370
818
  if (!drift) {
371
- context.stdout.write(`No drift: ${summary.unchanged} artifacts match the current templates and configuration.\n`);
819
+ presentation.status('success', 'No drift', `${summary.unchanged} artifacts match the current templates and configuration`);
372
820
  return 0;
373
821
  }
374
- for (const entry of visible) {
375
- context.stdout.write(` ${entryMarker(entry)} ${entryDisplay(entry)} ${entry.reason}\n`);
822
+ if (presentation.stdout.layout === 'plain') {
823
+ presentation.section('Template drift', visible.map((entry) => `${entryMarker(entry)} ${entryDisplay(entry)} ${entry.reason}`));
824
+ }
825
+ else {
826
+ presentation.table('Template drift', ['Change', 'Artifact', 'Reason'], visible.map((entry) => [entryMarker(entry), entryDisplay(entry), entry.reason]));
376
827
  }
377
828
  const toWrite = summary.new + summary.missing + summary.upgrade + summary.moved + summary.refresh;
378
- context.stdout.write(`\n ${toWrite} to write, ${summary.conflict} conflict(s), ${summary.orphan} orphan(s), ${summary.unchanged} unchanged\n`);
379
- context.stdout.write(' Run `liftoff update --apply` to apply the safe changes.\n');
829
+ presentation.status('warning', 'Drift detected', `${toWrite} to write, ${summary.conflict} conflict(s), ${summary.orphan} orphan(s), ${summary.unchanged} unchanged`);
830
+ presentation.command('liftoff update --apply');
380
831
  return 2;
381
832
  }
382
833
  if (isDirtyGitWorktree(projectRoot)) {
383
- context.stdout.write('Hint: the project worktree has uncommitted changes - consider committing before applying.\n');
834
+ presentation.warning('The project worktree has uncommitted changes; consider committing before applying.');
384
835
  }
836
+ presentation.stage('Apply safe template changes', projectRoot);
385
837
  await preflightUpdate(projectRoot, entries, force);
386
838
  const written = [];
387
839
  const skipped = [];
@@ -423,7 +875,16 @@ async function updateCommand(parsed, context) {
423
875
  }
424
876
  const oldByName = new Map(manifest.artifacts.map((artifact) => [artifact.logicalName, artifact]));
425
877
  const skippedByName = new Map(skipped.map((entry) => [entry.logicalName, entry]));
426
- const nextManifest = buildManifest(plan, render.filter((artifact) => artifact.logicalName !== 'manifest'));
878
+ const nextManifest = buildManifest(plan, render.filter((artifact) => artifact.logicalName !== 'manifest'), { frameworkState: manifest.framework.state });
879
+ nextManifest.framework = manifest.framework;
880
+ nextManifest.project.specWorkflow = manifest.project.specWorkflow;
881
+ nextManifest.project.agents = manifest.project.agents;
882
+ if (manifest.project.defaultAgent) {
883
+ nextManifest.project.defaultAgent = manifest.project.defaultAgent;
884
+ }
885
+ else {
886
+ delete nextManifest.project.defaultAgent;
887
+ }
427
888
  nextManifest.artifacts = nextManifest.artifacts.flatMap((artifact) => {
428
889
  // config is user-owned after create: carry the recorded entry forward untouched
429
890
  if (artifact.logicalName === 'liftoff-config') {
@@ -445,7 +906,7 @@ async function updateCommand(parsed, context) {
445
906
  }
446
907
  await writeProjectFile(projectRoot, ['liftoff.manifest.json'], `${JSON.stringify(nextManifest, null, 2)}\n`);
447
908
  if (jsonMode) {
448
- context.stdout.write(`${JSON.stringify({
909
+ presentation.rawStdout(`${JSON.stringify({
449
910
  schemaVersion: 1,
450
911
  mode: 'apply',
451
912
  cliVersion: liftoffVersion,
@@ -456,27 +917,19 @@ async function updateCommand(parsed, context) {
456
917
  }, null, 2)}\n`);
457
918
  return 0;
458
919
  }
459
- for (const entry of written) {
460
- context.stdout.write(` wrote ${entryDisplay(entry)}\n`);
920
+ if (written.length > 0) {
921
+ presentation.bullets('Applied changes', written.map((entry) => `wrote ${entryDisplay(entry)}`));
461
922
  }
462
- for (const entry of skipped) {
463
- context.stdout.write(` skipped ${entryDisplay(entry)} ${entry.reason}${force ? '' : ' (use --apply --force to overwrite)'}\n`);
923
+ if (skipped.length > 0) {
924
+ presentation.bullets('Skipped conflicts', skipped.map((entry) => `skipped ${entryDisplay(entry)} ${entry.reason}${force ? '' : ' (use --apply --force to overwrite)'}`));
464
925
  }
465
- for (const entry of entries) {
466
- if (entry.status === 'orphan') {
467
- context.stdout.write(` orphan ${entryDisplay(entry)} ${entry.reason}\n`);
468
- }
926
+ const orphans = entries.filter((entry) => entry.status === 'orphan');
927
+ if (orphans.length > 0) {
928
+ presentation.bullets('Orphaned artifacts', orphans.map((entry) => `orphan ${entryDisplay(entry)} ${entry.reason}`));
469
929
  }
470
- context.stdout.write(`Updated: ${written.length} written, ${skipped.length} skipped, ${summary.orphan} orphan(s). Manifest recorded at ${liftoffVersion}.\n`);
930
+ presentation.completion('Updated project', `${written.length} written, ${skipped.length} skipped, ${summary.orphan} orphan(s)`, [{ label: 'Manifest version', value: liftoffVersion }], 'liftoff validate && liftoff doctor');
471
931
  return 0;
472
932
  }
473
- function binaryCheck(command, args, remedy, cwd) {
474
- const result = spawnSync(command, args, { cwd, encoding: 'utf8' });
475
- if (result.status === 0) {
476
- return { label: command, severity: 'ok', detail: (result.stdout || result.stderr).split('\n')[0].trim() };
477
- }
478
- return { label: command, severity: 'fail', detail: 'not found', remedy };
479
- }
480
933
  function versionedBinaryCheck(label, command, args, minimum, remedy) {
481
934
  const result = spawnSync(command, args, { encoding: 'utf8' });
482
935
  if (result.status !== 0) {
@@ -506,25 +959,121 @@ function pythonRuntime() {
506
959
  ];
507
960
  return candidates.find((candidate) => versionedBinaryCheck('python', candidate.command, candidate.versionArgs, [3, 12], 'install Python 3.12 or newer').severity === 'ok') ?? candidates.find((candidate) => binaryPresent(candidate.command));
508
961
  }
509
- function environmentLayer(apiStack) {
510
- const python = pythonRuntime();
511
- const runtimeChecks = apiStack === 'go-huma'
512
- ? [versionedBinaryCheck('go', 'go', ['version'], [1, 23], 'install Go 1.23 or newer')]
513
- : apiStack === 'node-fastify'
514
- ? []
515
- : [python
516
- ? versionedBinaryCheck('python', python.command, python.versionArgs, [3, 12], 'install Python 3.12 or newer')
517
- : { label: 'python', severity: 'fail', detail: 'not found', remedy: 'install Python 3.12 or newer' }];
962
+ function doctorCheckFromProbe(probe) {
963
+ const severity = probe.state === 'ready'
964
+ ? 'ok'
965
+ : probe.requirement.severity === 'blocking'
966
+ ? 'fail'
967
+ : 'warn';
518
968
  return {
519
- title: 'Environment',
520
- checks: [
521
- versionedBinaryCheck('node', 'node', ['--version'], [20, 0], 'install Node.js 20 or newer'),
522
- ...runtimeChecks,
523
- binaryCheck('docker', ['--version'], 'install Docker'),
524
- binaryCheck('tofu', ['--version'], 'install OpenTofu')
525
- ]
969
+ id: probe.requirement.id,
970
+ label: probe.requirement.id,
971
+ severity,
972
+ state: probe.state,
973
+ requirementSeverity: probe.requirement.severity,
974
+ detail: probe.detail,
975
+ ...(probe.remedy ? { remedy: probe.remedy } : {})
526
976
  };
527
977
  }
978
+ function noticeId(requirementId, label) {
979
+ const suffix = label.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '');
980
+ return `${requirementId}:${suffix}`;
981
+ }
982
+ function workstationLayer(probes) {
983
+ const checks = probes.flatMap((probe) => [
984
+ doctorCheckFromProbe(probe),
985
+ ...probe.notices.map((notice) => ({
986
+ id: noticeId(probe.requirement.id, notice.label),
987
+ label: notice.label,
988
+ severity: notice.state === 'ready' ? 'ok' : 'warn',
989
+ state: notice.state,
990
+ requirementSeverity: 'advisory',
991
+ detail: notice.detail,
992
+ ...(notice.remedy ? { remedy: notice.remedy } : {})
993
+ }))
994
+ ]);
995
+ return { title: 'Environment', checks };
996
+ }
997
+ function workstationSelectionFromManifest(manifest) {
998
+ const framework = getFrameworkDefinition(manifest.project.specWorkflow);
999
+ return {
1000
+ apiStack: { id: manifest.project.apiStack },
1001
+ specWorkflow: { id: manifest.project.specWorkflow },
1002
+ framework: { version: framework.version },
1003
+ provider: { id: manifest.project.cloud },
1004
+ agents: manifest.framework.state === 'initialized'
1005
+ ? manifest.project.agents.map((id) => {
1006
+ const agent = getCodingAgent(id);
1007
+ if (!agent) {
1008
+ throw new Error(`Manifest references unknown coding agent ${id}.`);
1009
+ }
1010
+ return { id: agent.id, label: agent.label };
1011
+ })
1012
+ : []
1013
+ };
1014
+ }
1015
+ async function frameworkDoctorChecks(projectRoot, manifest) {
1016
+ if (manifest.framework.state === 'legacy') {
1017
+ return [{
1018
+ id: 'framework-legacy-state',
1019
+ label: 'framework state',
1020
+ severity: 'warn',
1021
+ state: 'not-observable',
1022
+ detail: `Legacy v${manifest.artifactVersion} manifest does not prove official ${manifest.framework.adapter} initialization or configured coding agents.`,
1023
+ remedy: 'Reinitialize the framework explicitly before recording a v3 initialized framework contract.'
1024
+ }];
1025
+ }
1026
+ const expected = getFrameworkDefinition(manifest.framework.adapter);
1027
+ const frameworkLabel = getSpecWorkflow(manifest.framework.adapter)?.label ?? manifest.framework.adapter;
1028
+ const contract = manifest.framework.contractVersion === expected.version
1029
+ ? {
1030
+ id: 'framework-contract',
1031
+ label: 'framework contract',
1032
+ severity: 'ok',
1033
+ state: 'ready',
1034
+ detail: `${frameworkLabel} ${expected.version}`
1035
+ }
1036
+ : {
1037
+ id: 'framework-contract',
1038
+ label: 'framework contract',
1039
+ severity: 'fail',
1040
+ state: 'outdated',
1041
+ detail: `Manifest records ${manifest.framework.contractVersion}; this Liftoff version requires ${expected.version}.`,
1042
+ remedy: `Install ${frameworkLabel} ${expected.version} and reinitialize its integrations.`
1043
+ };
1044
+ const markerIssues = await validateFrameworkInstallation(projectRoot, {
1045
+ workflow: manifest.framework.adapter,
1046
+ agents: manifest.project.agents,
1047
+ ...(manifest.project.defaultAgent ? { defaultAgent: manifest.project.defaultAgent } : {})
1048
+ });
1049
+ const markers = markerIssues.length === 0
1050
+ ? {
1051
+ id: 'framework-markers',
1052
+ label: 'framework markers',
1053
+ severity: 'ok',
1054
+ state: 'ready',
1055
+ detail: `${manifest.project.agents.length} selected integration${manifest.project.agents.length === 1 ? '' : 's'} verified`
1056
+ }
1057
+ : {
1058
+ id: 'framework-markers',
1059
+ label: 'framework markers',
1060
+ severity: 'fail',
1061
+ state: 'unhealthy',
1062
+ detail: `${markerIssues.length} issue(s): ${markerIssues[0]}`,
1063
+ remedy: `Run the official ${frameworkLabel} initializer for the selected integrations.`
1064
+ };
1065
+ return [
1066
+ contract,
1067
+ {
1068
+ id: 'selected-agents',
1069
+ label: 'selected agents',
1070
+ severity: 'ok',
1071
+ state: 'ready',
1072
+ detail: manifest.project.agents.join(', ')
1073
+ },
1074
+ markers
1075
+ ];
1076
+ }
528
1077
  function stackProjectCheck(projectRoot, apiStack) {
529
1078
  let result;
530
1079
  switch (apiStack) {
@@ -565,23 +1114,23 @@ function binaryPresent(command) {
565
1114
  const probe = process.platform === 'win32' ? 'where' : 'which';
566
1115
  return spawnSync(probe, [command], { encoding: 'utf8' }).status === 0;
567
1116
  }
568
- function azureCloudChecks() {
569
- if (!binaryPresent('az')) {
570
- return [{ label: 'az', severity: 'fail', detail: 'Azure CLI not found', remedy: 'install the Azure CLI' }];
1117
+ async function azureCloudChecks(runner) {
1118
+ const auth = await runner.run({ executable: 'az', args: ['account', 'show', '-o', 'none', '--only-show-errors'] }, { timeoutMs: 15_000 });
1119
+ if (auth.errorCode === 'ENOENT') {
1120
+ return [{ label: 'az', severity: 'warn', detail: 'Azure CLI not found', remedy: 'install the Azure CLI' }];
571
1121
  }
572
- const auth = spawnSync('az', ['account', 'show', '-o', 'none', '--only-show-errors'], { encoding: 'utf8' });
573
1122
  if (auth.status === 0) {
574
1123
  return [{ label: 'azure auth', severity: 'ok', detail: 'authenticated' }];
575
1124
  }
576
- return [{ label: 'azure auth', severity: 'fail', detail: 'not authenticated', remedy: 'run az login' }];
1125
+ return [{ label: 'azure auth', severity: 'warn', detail: 'not authenticated', remedy: 'run az login' }];
577
1126
  }
578
1127
  // ponytail: provider-keyed map so aws/gcp checks slot in when their adapters land
579
1128
  const CLOUD_CHECKS = {
580
1129
  azure: azureCloudChecks
581
1130
  };
582
- function cloudLayer(cloud) {
1131
+ async function cloudLayer(cloud, runner) {
583
1132
  const checks = CLOUD_CHECKS[cloud]
584
- ? CLOUD_CHECKS[cloud]()
1133
+ ? await CLOUD_CHECKS[cloud](runner)
585
1134
  : [{ label: cloud, severity: 'skipped', detail: `${cloud} provider checks are not available yet` }];
586
1135
  return { title: `Cloud - ${cloud}`, checks };
587
1136
  }
@@ -654,6 +1203,7 @@ async function projectLayer(projectRoot, manifest) {
654
1203
  else {
655
1204
  checks.push({ label: 'version', severity: 'ok', detail: `generated by ${manifest.liftoffVersion}, CLI ${liftoffVersion}` });
656
1205
  }
1206
+ checks.push(...await frameworkDoctorChecks(projectRoot, manifest));
657
1207
  checks.push(stackProjectCheck(projectRoot, manifest.project.apiStack));
658
1208
  try {
659
1209
  const config = await loadConfigOptions('liftoff.config.json', projectRoot);
@@ -683,7 +1233,7 @@ async function projectLayer(projectRoot, manifest) {
683
1233
  }
684
1234
  return { title: 'Project', checks };
685
1235
  }
686
- async function runtimeLayer(projectRoot, dockerAvailable) {
1236
+ async function runtimeLayer(projectRoot, dockerAvailable, runner) {
687
1237
  const checks = [];
688
1238
  if (existsSync(path.join(projectRoot, '.env.example'))) {
689
1239
  if (existsSync(path.join(projectRoot, '.env'))) {
@@ -703,7 +1253,7 @@ async function runtimeLayer(projectRoot, dockerAvailable) {
703
1253
  checks.push({ label: 'compose', severity: 'skipped', detail: 'docker is not installed, compose config not checked' });
704
1254
  }
705
1255
  else {
706
- const result = spawnSync('docker', ['compose', 'config', '-q'], { cwd: projectRoot, encoding: 'utf8' });
1256
+ const result = await runner.run({ executable: 'docker', args: ['compose', 'config', '-q'] }, { cwd: projectRoot, timeoutMs: 15_000 });
707
1257
  if (result.status === 0) {
708
1258
  checks.push({ label: 'compose', severity: 'ok', detail: 'docker compose config is valid' });
709
1259
  }
@@ -718,19 +1268,21 @@ async function runtimeLayer(projectRoot, dockerAvailable) {
718
1268
  }
719
1269
  return { title: 'Runtime', checks };
720
1270
  }
721
- const severityMarker = {
722
- ok: '[ok]',
723
- warn: '[warn]',
724
- fail: '[fail]',
725
- skipped: '[skip]'
726
- };
727
- function renderDoctorLayers(layers, stream) {
1271
+ function renderDoctorLayers(layers, presentation) {
1272
+ const statusKind = {
1273
+ ok: 'success',
1274
+ warn: 'warning',
1275
+ fail: 'error',
1276
+ skipped: 'pending'
1277
+ };
728
1278
  for (const layer of layers) {
729
- stream.write(`${layer.title}\n`);
730
- for (const check of layer.checks) {
1279
+ presentation.section(layer.title, layer.checks.flatMap((check) => {
731
1280
  const remedy = check.remedy ? ` - ${check.remedy}` : '';
732
- stream.write(` ${severityMarker[check.severity].padEnd(6)} ${check.label}: ${check.detail}${remedy}\n`);
733
- }
1281
+ return presentation.stdout
1282
+ .status(statusKind[check.severity], check.label, `${check.detail}${remedy}`)
1283
+ .trimEnd()
1284
+ .split('\n');
1285
+ }));
734
1286
  }
735
1287
  }
736
1288
  export function doctorExitCode(layers) {
@@ -740,6 +1292,7 @@ async function doctorCommand(parsed, context) {
740
1292
  const jsonMode = readBooleanFlag(parsed.flags, 'json') ?? false;
741
1293
  const cloudOverride = readStringFlag(parsed.flags, 'cloud');
742
1294
  const layers = [];
1295
+ context.presentation.commandIdentity('doctor', 'Inspect CLI, workstation, project, runtime, and cloud readiness');
743
1296
  const projectRoot = await findProjectRoot(context.cwd);
744
1297
  let manifest;
745
1298
  let manifestError;
@@ -752,9 +1305,14 @@ async function doctorCommand(parsed, context) {
752
1305
  }
753
1306
  }
754
1307
  layers.push(await cliLayer());
755
- const environment = environmentLayer(manifest?.project.apiStack);
1308
+ const runner = context.runner ?? new NodeCommandRunner();
1309
+ const requirements = manifest
1310
+ ? selectWorkstationRequirements(workstationSelectionFromManifest(manifest), { includeFramework: manifest.framework.state === 'initialized' })
1311
+ : selectLiftoffRuntimeRequirements();
1312
+ const probes = await probeWorkstation(requirements, runner);
1313
+ const environment = workstationLayer(probes);
756
1314
  layers.push(environment);
757
- const dockerAvailable = environment.checks.some((check) => check.label === 'docker' && check.severity === 'ok');
1315
+ const dockerAvailable = probes.some((probe) => probe.requirement.id === 'docker' && probe.state === 'ready');
758
1316
  if (projectRoot) {
759
1317
  if (manifestError) {
760
1318
  layers.push({
@@ -764,9 +1322,9 @@ async function doctorCommand(parsed, context) {
764
1322
  }
765
1323
  if (manifest) {
766
1324
  layers.push(await projectLayer(projectRoot, manifest));
767
- layers.push(await runtimeLayer(projectRoot, dockerAvailable));
1325
+ layers.push(await runtimeLayer(projectRoot, dockerAvailable, runner));
768
1326
  const cloud = cloudOverride ?? manifest.project.cloud;
769
- const cloudChecks = cloudLayer(cloud);
1327
+ const cloudChecks = await cloudLayer(cloud, runner);
770
1328
  const pattern = patterns.find((candidate) => candidate.id === manifest.project.pattern);
771
1329
  if (pattern?.worker && cloud === 'azure') {
772
1330
  cloudChecks.checks.push(binaryPresent('func')
@@ -777,22 +1335,24 @@ async function doctorCommand(parsed, context) {
777
1335
  }
778
1336
  }
779
1337
  else if (cloudOverride) {
780
- layers.push(cloudLayer(cloudOverride));
1338
+ layers.push(await cloudLayer(cloudOverride, runner));
781
1339
  }
782
1340
  const failures = layers.reduce((count, layer) => count + layer.checks.filter((check) => check.severity === 'fail').length, 0);
783
1341
  const warnings = layers.reduce((count, layer) => count + layer.checks.filter((check) => check.severity === 'warn').length, 0);
784
1342
  if (jsonMode) {
785
- context.stdout.write(`${JSON.stringify({ schemaVersion: 1, layers, summary: { failures, warnings } }, null, 2)}\n`);
1343
+ context.presentation.rawStdout(`${JSON.stringify({ schemaVersion: 1, layers, summary: { failures, warnings } }, null, 2)}\n`);
786
1344
  }
787
1345
  else {
788
- renderDoctorLayers(layers, context.stdout);
789
- context.stdout.write(`${failures} failure(s), ${warnings} warning(s)\n`);
1346
+ renderDoctorLayers(layers, context.presentation);
1347
+ context.presentation.status(failures > 0 ? 'error' : warnings > 0 ? 'warning' : 'success', 'Doctor summary', `${failures} failure(s), ${warnings} warning(s)`);
790
1348
  }
791
1349
  return doctorExitCode(layers);
792
1350
  }
793
1351
  function helperCommand(parsed, context, tool) {
794
1352
  const command = parsed.command === 'dev' ? buildDevCommand(parsed) : buildInfraCommand(parsed);
795
- context.stdout.write(`${tool} helper command:\n${command}\n`);
1353
+ context.presentation.commandIdentity(parsed.command ?? tool, `${tool} helper command`);
1354
+ context.presentation.section(`${tool} helper command`, []);
1355
+ context.presentation.command(command);
796
1356
  return 0;
797
1357
  }
798
1358
  function buildDevCommand(parsed) {
@@ -839,35 +1399,59 @@ async function optionsFromParsedArgs(parsed, cwd, includeProjectName) {
839
1399
  includeFrontend: readBooleanFlag(parsed.flags, 'frontend'),
840
1400
  environments: readListFlag(parsed.flags, 'environments'),
841
1401
  specWorkflow: readStringFlag(parsed.flags, 'spec'),
1402
+ agents: readListFlag(parsed.flags, 'agents'),
1403
+ defaultAgent: readStringFlag(parsed.flags, 'default-agent'),
842
1404
  configPath,
843
- yes: readBooleanFlag(parsed.flags, 'yes') ?? false
1405
+ yes: readBooleanFlag(parsed.flags, 'yes') ?? false,
1406
+ force: readBooleanFlag(parsed.flags, 'force'),
1407
+ installTools: readBooleanFlag(parsed.flags, 'install-tools'),
1408
+ installDependencies: readBooleanFlag(parsed.flags, 'install-dependencies')
844
1409
  };
845
1410
  return mergeOptions(configOptions, flagOptions);
846
1411
  }
847
- function hasMissingCreateInputs(options) {
1412
+ function hasMissingInitInputs(options) {
848
1413
  const projectType = options.projectType ?? (options.pattern ? 'genai' : options.apiStack ? 'standard' : undefined);
849
1414
  const missingTypeSpecific = projectType === 'genai' ? !options.pattern : projectType === 'standard' ? !options.apiStack : true;
850
- return !options.projectName || missingTypeSpecific || !options.cloud || options.includeFrontend === undefined || !options.specWorkflow || !options.environments;
851
- }
852
- function printHelp(stream) {
853
- stream.write(`Mission Control Liftoff ${liftoffVersion}\n\n`);
854
- stream.write(`Global options:\n`);
855
- stream.write(` --version Show the installed Liftoff version\n`);
856
- stream.write(` --help Show general help\n\n`);
857
- stream.write(`Commands:\n`);
858
- stream.write(` create Generate a new project\n`);
859
- stream.write(` plan Preview generated artifacts\n`);
860
- stream.write(` patterns List GenAI patterns\n`);
861
- stream.write(` providers List cloud providers\n`);
862
- stream.write(` regions List or search provider regions\n`);
863
- stream.write(` validate Validate a generated project manifest\n`);
864
- stream.write(` update Reconcile a project with the current templates (check by default; --apply, --force)\n`);
865
- stream.write(` migrate Adopt an existing project: fresh scaffold + staged copy + migration plan\n`);
866
- stream.write(` doctor Check local readiness\n`);
867
- stream.write(` dev Print Docker Compose helper commands\n`);
868
- stream.write(` infra Print OpenTofu helper commands\n`);
869
- stream.write(`\nProject types: GenAI (Python/FastAPI/PydanticAI) or standard API\n`);
870
- stream.write(`Standard API stacks: ${apiStacks.map((stack) => stack.label).join(', ')}\n`);
1415
+ const missingDefaultAgent = options.specWorkflow === 'spec-kit' &&
1416
+ (options.agents?.length ?? 0) > 1 &&
1417
+ !options.defaultAgent;
1418
+ return !options.projectName ||
1419
+ missingTypeSpecific ||
1420
+ !options.cloud ||
1421
+ options.includeFrontend === undefined ||
1422
+ !options.specWorkflow ||
1423
+ !options.agents ||
1424
+ missingDefaultAgent ||
1425
+ !options.environments;
1426
+ }
1427
+ function renderGeneralHelp(presentation) {
1428
+ const help = getGeneralHelp(liftoffVersion);
1429
+ presentation.identity(`${help.title} - ${help.subtitle}`);
1430
+ presentation.section('Usage', [help.usage]);
1431
+ presentation.table('Global options', ['Option', 'Description'], help.globalOptions.map((option) => [option.syntax, option.description]));
1432
+ for (const group of help.commandGroups) {
1433
+ presentation.table(group.title, ['Command', 'Description'], group.entries.map((entry) => [entry.syntax, entry.description]));
1434
+ }
1435
+ presentation.status('info', 'Tip', help.hint);
1436
+ }
1437
+ function renderCommandHelp(command, presentation) {
1438
+ const help = getCommandHelp(command);
1439
+ presentation.commandIdentity(help.command, help.description);
1440
+ presentation.section('Usage', [
1441
+ presentation.stdout.layout === 'plain' ? `Usage: ${help.usage}` : help.usage
1442
+ ]);
1443
+ if (help.arguments.length > 0) {
1444
+ presentation.table('Arguments', ['Argument', 'Description'], help.arguments.map((argument) => [argument.syntax, argument.description]));
1445
+ }
1446
+ if (help.subcommands.length > 0) {
1447
+ presentation.bullets('Subcommands', help.subcommands);
1448
+ }
1449
+ for (const group of help.optionGroups) {
1450
+ presentation.table(group.title, ['Option', 'Description'], group.entries.map((entry) => [
1451
+ entry.syntax,
1452
+ `${entry.description}${entry.defaultValue ? ` (default: ${entry.defaultValue})` : ''}`
1453
+ ]));
1454
+ }
871
1455
  }
872
1456
  export async function createFixtureProject(options) {
873
1457
  const tempRoot = await mkdtemp(path.join(os.tmpdir(), 'liftoff-'));
@@ -877,6 +1461,27 @@ export async function createFixtureProject(options) {
877
1461
  await assertNewOrEmptyDirectory(target);
878
1462
  await rm(target, { recursive: true, force: true });
879
1463
  await writeArtifacts(target, buildArtifacts(plan));
1464
+ for (const marker of [
1465
+ ...plan.framework.baseMarkers,
1466
+ ...plan.agents.flatMap((agent) => plan.framework.agentMarkers[agent.id])
1467
+ ]) {
1468
+ let content = 'fixture marker\n';
1469
+ if (marker.join('/') === '.specify/integration.json') {
1470
+ const installed = plan.agents.map((agent) => agent.integrationIds['spec-kit']);
1471
+ const defaultIntegration = plan.defaultAgent?.integrationIds['spec-kit'];
1472
+ content = `${JSON.stringify({
1473
+ integration_state_schema: 1,
1474
+ integration: defaultIntegration,
1475
+ default_integration: defaultIntegration,
1476
+ installed_integrations: installed,
1477
+ integration_settings: {}
1478
+ }, null, 2)}\n`;
1479
+ }
1480
+ else if (marker.join('/') === '.specify/init-options.json') {
1481
+ content = '{}\n';
1482
+ }
1483
+ await writeProjectFile(target, marker, content);
1484
+ }
880
1485
  return target;
881
1486
  }
882
1487
  //# sourceMappingURL=commands.js.map