@msn-control/liftoff 0.4.0 → 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.
package/dist/commands.js CHANGED
@@ -4,28 +4,38 @@ import { existsSync } from 'node:fs';
4
4
  import { cp, lstat, mkdir, mkdtemp, readFile, readdir, readlink, rm, stat } from 'node:fs/promises';
5
5
  import os from 'node:os';
6
6
  import path from 'node:path';
7
- import { formatCommandHelp, formatGeneralHelp, readBooleanFlag, readListFlag, readStringFlag } from './args.js';
7
+ import { getCommandHelp, getGeneralHelp, readBooleanFlag, readListFlag, readStringFlag } from './args.js';
8
8
  import { getCodingAgent, getFrameworkDefinition, getSpecWorkflow, listRegions, patterns, providers, searchRegions } from './catalogs.js';
9
9
  import { initializeFramework } from './framework-adapters.js';
10
10
  import { validateFrameworkInstallation } from './framework-validation.js';
11
11
  import { artifactPath, assertNewOrEmptyDirectory, deleteProjectFile, findProjectRoot, loadManifest, manifestDisplayPath, resolveProjectPath, validateGeneratedProject, writeArtifacts, writeProjectFile } from './file-system.js';
12
- import { confirmDependencyInstallation, confirmFileReplacements, confirmPlan, confirmToolInstallation, promptForInitOptions } from './interactive.js';
12
+ import { InteractivePrompter } from './interactive.js';
13
13
  import { applyMergePreflight, assertSafeInitTarget, authorizeMergePreflight, buildMergePreflight, captureTreeState, discoverGitRoot, resolveInitTargetFromDiscovery, validateStagedTree, withStagingArea, writeStagedArtifacts } from './init-filesystem.js';
14
14
  import { renderMigrationChecklist, renderMigrationProposal, renderMigrationTasks, seedMigrationGroups } from './migrate-plan.js';
15
- import { buildProjectPlan, formatProjectPlan, loadConfigOptions, mergeOptions, PlanValidationError } from './planner.js';
15
+ import { buildProjectPlan, loadConfigOptions, mergeOptions, PlanValidationError, projectPlanEntries } from './planner.js';
16
16
  import { buildDependencySetupPlan, runDependencySetup } from './project-dependencies.js';
17
17
  import { formatCommand, NodeCommandRunner } from './process-runner.js';
18
18
  import { scanDefaults, scanLegacyProject } from './scan.js';
19
19
  import { hasDrift, reconcileProject } from './reconcile.js';
20
20
  import { compareSemver } from './semver.js';
21
21
  import { buildArtifacts, buildManifest, partitionGeneratedArtifacts } from './templates.js';
22
- import { TerminalRenderer } from './terminal.js';
22
+ import { PresentationSession } from './terminal.js';
23
23
  import { liftoffVersion } from './version.js';
24
24
  import { blockingReadinessFailures, detectHostEnvironment, installRequirement, probeWorkstation, selectLiftoffRuntimeRequirements, selectWorkstationRequirements } from './workstation.js';
25
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 };
26
36
  try {
27
37
  if (parsed.command && readBooleanFlag(parsed.flags, 'help')) {
28
- context.stdout.write(formatCommandHelp(parsed.command));
38
+ renderCommandHelp(parsed.command, presentation);
29
39
  return 0;
30
40
  }
31
41
  switch (parsed.command) {
@@ -33,156 +43,191 @@ export async function runCommand(parsed, context) {
33
43
  case 'help':
34
44
  case '--help':
35
45
  if (parsed.positional[0]) {
36
- context.stdout.write(formatCommandHelp(parsed.positional[0]));
46
+ renderCommandHelp(parsed.positional[0], presentation);
37
47
  }
38
48
  else {
39
- printHelp(context.stdout);
49
+ renderGeneralHelp(presentation);
40
50
  }
41
51
  return 0;
42
52
  case 'version':
43
- context.stdout.write(`Liftoff ${liftoffVersion}\n`);
53
+ presentation.rawStdout(`Liftoff ${liftoffVersion}\n`);
44
54
  return 0;
45
55
  case 'init':
46
- return await initCommand(parsed, context);
56
+ return await initCommand(parsed, executionContext);
47
57
  case 'plan':
48
- return await planCommand(parsed, context);
58
+ return await planCommand(parsed, executionContext);
49
59
  case 'patterns':
50
- return patternsCommand(context);
60
+ return patternsCommand(executionContext);
51
61
  case 'providers':
52
- return providersCommand(context);
62
+ return providersCommand(executionContext);
53
63
  case 'regions':
54
- return regionsCommand(parsed, context);
64
+ return regionsCommand(parsed, executionContext);
55
65
  case 'validate':
56
- return await validateCommand(parsed, context);
66
+ return await validateCommand(parsed, executionContext);
57
67
  case 'update':
58
- return await updateCommand(parsed, context);
68
+ return await updateCommand(parsed, executionContext);
59
69
  case 'migrate':
60
- return await migrateCommand(parsed, context);
70
+ return await migrateCommand(parsed, executionContext);
61
71
  case 'doctor':
62
- return await doctorCommand(parsed, context);
72
+ return await doctorCommand(parsed, executionContext);
63
73
  case 'dev':
64
- return helperCommand(parsed, context, 'docker compose');
74
+ return helperCommand(parsed, executionContext, 'docker compose');
65
75
  case 'infra':
66
- return helperCommand(parsed, context, 'tofu');
76
+ return helperCommand(parsed, executionContext, 'tofu');
67
77
  default:
68
- context.stderr.write(`Unknown command: ${parsed.command}\n\n`);
69
- printHelp(context.stderr);
78
+ presentation.error(`Unknown command: ${parsed.command}`, 'Run `liftoff help` to list available commands.');
70
79
  return 1;
71
80
  }
72
81
  }
73
82
  catch (error) {
74
83
  if (error instanceof PlanValidationError) {
75
- 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);
76
85
  return 1;
77
86
  }
78
- context.stderr.write(`${error.message}\n`);
87
+ presentation.error(error instanceof Error ? error.message : String(error));
79
88
  return 1;
80
89
  }
81
90
  }
82
91
  async function initCommand(parsed, context) {
92
+ const { presentation } = context;
93
+ presentation.identity('Initialize the project and prepare its workstation');
83
94
  const runner = context.runner ?? new NodeCommandRunner();
95
+ presentation.stage('Discover project context');
84
96
  const git = await discoverGitRoot(context.cwd, runner);
85
97
  let initial = await optionsFromParsedArgs(parsed, context.cwd, true);
86
98
  if (!initial.projectName && git.exact && git.root) {
87
99
  initial = { ...initial, projectName: path.basename(git.root) };
88
100
  }
89
- const needsPrompts = !initial.yes && hasMissingInitInputs(initial);
90
- const options = needsPrompts ? await promptForInitOptions(initial) : initial;
91
- const plan = buildProjectPlan(options, { requireProjectName: true });
92
- const confirmed = await confirmPlan(plan, options.yes);
93
- if (!confirmed) {
94
- context.stdout.write('Initialization cancelled.\n');
95
- return 0;
96
- }
97
- const target = resolveInitTargetFromDiscovery(git, plan.safeProjectName);
98
- await assertSafeInitTarget(target, target.mode === 'named-child' ? git.canonicalCwd : undefined);
99
- const renderer = new TerminalRenderer({ stream: context.stdout });
100
- renderer.write(renderer.banner('Initialize the project and prepare its workstation'));
101
- const readiness = await ensureWorkstationReady(plan, options, context, runner, renderer);
102
- if (!readiness.ready) {
103
- return 1;
104
- }
105
- const staged = await withStagingArea(async (area) => {
106
- const partition = partitionGeneratedArtifacts(buildArtifacts(plan));
107
- await writeStagedArtifacts(area, partition.durable, 'liftoff');
108
- await initializeFramework(area, plan, runner, { stdout: context.stdout, stderr: context.stderr });
109
- await writeStagedArtifacts(area, partition.seed, 'seed');
110
- await writeStagedArtifacts(area, [partition.manifest], 'liftoff');
111
- await validateStagedTree(area);
112
- const stagedIssues = await validateGeneratedProject(area.root);
113
- if (stagedIssues.length > 0) {
114
- throw new Error(`Staged project validation failed:\n${stagedIssues.join('\n')}`);
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;
115
123
  }
116
- const preflight = await buildMergePreflight(area, target.root);
117
- const interactive = options.yes !== true;
118
- const authorized = await authorizeMergePreflight(preflight, options.force === true, interactive ? confirmFileReplacements : undefined);
119
- if (!authorized) {
120
- return {
121
- status: interactive ? 'declined' : 'authorization-required'
122
- };
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;
123
131
  }
124
- return { status: 'applied', merge: await applyMergePreflight(authorized) };
125
- });
126
- if (staged.status === 'declined') {
127
- context.stdout.write('Initialization cancelled; no destination files were changed.\n');
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)}`);
128
191
  return 0;
129
192
  }
130
- if (staged.status === 'authorization-required') {
131
- context.stderr.write('Existing regular-file conflicts require --force in non-interactive mode.\n');
132
- return 1;
133
- }
134
- const issues = await validateGeneratedProject(target.root);
135
- if (issues.length > 0) {
136
- context.stderr.write(`Initialized project validation failed:\n${issues.join('\n')}\n`);
137
- return 1;
138
- }
139
- const dependencyPhase = await handleProjectDependencies(plan, target.root, options, readiness.probes, context, runner, renderer);
140
- if (!dependencyPhase.success) {
141
- return 1;
142
- }
143
- renderer.write(renderer.status('success', `Initialized ${plan.projectName}`, target.root));
144
- renderer.write(renderer.panel('Configured integrations', [
145
- `${plan.specWorkflow.label} ${plan.framework.version}`,
146
- ...plan.agents.map((agent) => `${agent.label}${plan.defaultAgent?.id === agent.id ? ' (default)' : ''}`)
147
- ]));
148
- if (readiness.deferred.length > 0) {
149
- renderer.write(renderer.panel('Deferred advisory checks', readiness.deferred));
150
- }
151
- if (dependencyPhase.deferred.length > 0) {
152
- renderer.write(renderer.panel('Deferred project dependencies', dependencyPhase.deferred));
193
+ finally {
194
+ prompter?.close();
153
195
  }
154
- renderer.write(renderer.command(`liftoff validate ${JSON.stringify(target.root)}`));
155
- return 0;
156
196
  }
157
197
  async function planCommand(parsed, context) {
198
+ const { presentation } = context;
199
+ presentation.identity('Preview project decisions, artifacts, and workstation requirements');
158
200
  const options = await optionsFromParsedArgs(parsed, context.cwd, false);
159
201
  const plan = buildProjectPlan(options, { requireProjectName: false });
160
202
  const artifacts = buildArtifacts(plan);
161
- context.stdout.write(`${formatProjectPlan(plan)}\n\nArtifacts (${artifacts.length}):\n`);
162
- for (const artifact of artifacts) {
163
- context.stdout.write(`- ${artifact.logicalName}: ${artifact.pathParts.join('/')}\n`);
164
- }
165
- context.stdout.write('\nWorkstation requirements:\n');
166
- for (const requirement of selectWorkstationRequirements(plan)) {
167
- const version = requirement.exactVersion
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
168
208
  ? `exactly ${requirement.exactVersion}`
169
209
  : requirement.minimumVersion
170
210
  ? `${requirement.minimumVersion}+`
171
- : 'available';
172
- context.stdout.write(`- ${requirement.definition.label}: ${version} [${requirement.severity}]\n`);
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);
173
219
  }
174
220
  return 0;
175
221
  }
176
- async function ensureWorkstationReady(plan, options, context, runner, renderer, resumeInvocation = 'liftoff init', commandCwd) {
222
+ async function ensureWorkstationReady(plan, options, context, runner, presentation, prompter, resumeInvocation = 'liftoff init', commandCwd) {
177
223
  const requirements = selectWorkstationRequirements(plan);
178
224
  let probes = await probeWorkstation(requirements, runner, { cwd: commandCwd });
179
- renderer.write(renderer.heading('Workstation readiness'));
180
- renderer.write(renderer.table(['Requirement', 'Level', 'State', 'Detail'], probes.map((probe) => [
225
+ presentation.table('Workstation readiness', ['Requirement', 'Level', 'State', 'Detail'], probes.map((probe) => [
181
226
  probe.requirement.definition.label,
182
227
  probe.requirement.severity,
183
228
  probe.state,
184
229
  probe.detail
185
- ])));
230
+ ]));
186
231
  const actionable = probes.filter((probe) => probe.state !== 'ready');
187
232
  const host = await detectHostEnvironment();
188
233
  const installInstruction = (probe) => {
@@ -213,17 +258,16 @@ async function ensureWorkstationReady(plan, options, context, runner, renderer,
213
258
  : probe.requirement.minimumVersion
214
259
  ? `required ${probe.requirement.minimumVersion} or newer`
215
260
  : 'required to be available';
216
- const action = automatic
217
- ? `Command: ${formatCommand(recipe.command)}`
218
- : `Manual remedy: ${installInstruction(probe)}`;
219
- const detail = [
220
- `${probe.requirement.definition.label} [${probe.requirement.severity}]`,
221
- `Purpose: ${probe.requirement.reasons.join('; ')}`,
222
- `Requirement: ${constraint}`,
223
- `Observed: ${probe.state} - ${probe.detail}`,
224
- action
225
- ].map((line) => ` ${line}`).join('\n');
226
- if (await confirmToolInstallation(detail)) {
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
+ })) {
227
271
  authorizedInstallations.add(probe.requirement.id);
228
272
  }
229
273
  }
@@ -234,12 +278,17 @@ async function ensureWorkstationReady(plan, options, context, runner, renderer,
234
278
  if (!authorizedInstallations.has(probe.requirement.id)) {
235
279
  continue;
236
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
+ }
237
286
  const installation = await installRequirement(probe.requirement, probe, {
238
287
  authorized: true,
239
288
  host,
240
289
  runner,
241
290
  cwd: commandCwd,
242
- streamOptions: { stdout: context.stdout, stderr: context.stderr }
291
+ streamOptions: presentation.childStreams()
243
292
  });
244
293
  updates.set(probe.requirement.id, installation.probe);
245
294
  const kind = installation.state === 'installed'
@@ -247,9 +296,9 @@ async function ensureWorkstationReady(plan, options, context, runner, renderer,
247
296
  : probe.requirement.severity === 'blocking'
248
297
  ? 'error'
249
298
  : 'warning';
250
- renderer.write(renderer.status(kind, probe.requirement.definition.label, installation.detail));
299
+ presentation.status(kind, probe.requirement.definition.label, installation.detail);
251
300
  if (installation.remedy) {
252
- renderer.write(renderer.command(installation.remedy));
301
+ presentation.command(installation.remedy);
253
302
  }
254
303
  }
255
304
  probes = probes.map((probe) => updates.get(probe.requirement.id) ?? probe);
@@ -257,12 +306,11 @@ async function ensureWorkstationReady(plan, options, context, runner, renderer,
257
306
  const blockers = blockingReadinessFailures(probes);
258
307
  if (blockers.length > 0) {
259
308
  for (const blocker of blockers) {
260
- context.stderr.write(`${blocker.requirement.definition.label}: ${blocker.detail}` +
261
- `\nRemedy: ${installInstruction(blocker)}\n`);
309
+ presentation.error(`${blocker.requirement.definition.label}: ${blocker.detail}`, installInstruction(blocker));
262
310
  }
263
- context.stderr.write(options.installTools
264
- ? `Open a new terminal if PATH changed, then rerun \`${resumeInvocation}\` with the same project options.\n`
265
- : `Resume with \`${resumeInvocation} --install-tools\` plus the same project options after reviewing the commands.\n`);
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.`);
266
314
  return { ready: false, deferred: [], probes };
267
315
  }
268
316
  const deferred = [
@@ -275,13 +323,13 @@ async function ensureWorkstationReady(plan, options, context, runner, renderer,
275
323
  ];
276
324
  return { ready: true, deferred, probes };
277
325
  }
278
- async function handleProjectDependencies(plan, projectRoot, options, probes, context, runner, renderer) {
326
+ async function handleProjectDependencies(plan, projectRoot, options, probes, context, runner, presentation, prompter) {
279
327
  const dependencyPlan = buildDependencySetupPlan(plan, projectRoot, probes);
280
328
  let installDependencies = options.installDependencies === true;
281
329
  if (options.installDependencies === undefined &&
282
330
  options.yes !== true &&
283
331
  dependencyPlan.commands.length > 0) {
284
- installDependencies = await confirmDependencyInstallation(dependencyPlan.commands);
332
+ installDependencies = await prompter.confirmDependencyInstallation(dependencyPlan.commands);
285
333
  }
286
334
  if (!installDependencies) {
287
335
  return {
@@ -289,62 +337,66 @@ async function handleProjectDependencies(plan, projectRoot, options, probes, con
289
337
  deferred: dependencyPlan.commands.map((command) => `${command.label}: ${command.cwd} -> ${formatCommand(command.command)}`)
290
338
  };
291
339
  }
340
+ presentation.stage('Install project dependencies');
292
341
  const result = await runDependencySetup(dependencyPlan, projectRoot, runner, {
293
- stdout: context.stdout,
294
- stderr: context.stderr
342
+ ...presentation.childStreams(),
343
+ onCommand: (command) => {
344
+ presentation.status('pending', command.label, command.cwd);
345
+ presentation.command(formatCommand(command.command));
346
+ }
295
347
  });
296
348
  if (!result.success) {
297
- renderer.write(renderer.status('error', 'Project dependencies failed', `${result.failed?.label ?? 'dependency command'}: ${result.detail ?? 'unknown failure'}`));
298
- renderer.write(renderer.status('info', 'Scaffold preserved', projectRoot));
349
+ presentation.error('Project dependencies failed', `${result.failed?.label ?? 'dependency command'}: ${result.detail ?? 'unknown failure'}`);
350
+ presentation.status('info', 'Scaffold preserved', projectRoot);
299
351
  if (result.restoredMutations.length > 0) {
300
- renderer.write(renderer.warning(`Restored protected files: ${result.restoredMutations.join(', ')}`));
352
+ presentation.warning(`Restored protected files: ${result.restoredMutations.join(', ')}`);
301
353
  }
302
354
  if (result.resumeCommand) {
303
- renderer.write(renderer.command(result.resumeCommand));
355
+ presentation.command(result.resumeCommand);
304
356
  }
305
357
  return { success: false, deferred: [] };
306
358
  }
307
- renderer.write(renderer.status('success', 'Project dependencies', `${result.completed.length} command${result.completed.length === 1 ? '' : 's'} completed`));
359
+ presentation.status('success', 'Project dependencies', `${result.completed.length} command${result.completed.length === 1 ? '' : 's'} completed`);
308
360
  return { success: true, deferred: [] };
309
361
  }
310
362
  function patternsCommand(context) {
311
- context.stdout.write('Patterns:\n');
312
- for (const pattern of patterns) {
313
- context.stdout.write(`- ${pattern.id}: ${pattern.label} [${pattern.scaffoldStatus}]\n`);
314
- }
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]));
315
365
  return 0;
316
366
  }
317
367
  function providersCommand(context) {
318
- context.stdout.write('Providers:\n');
319
- for (const provider of providers) {
320
- context.stdout.write(`- ${provider.id}: ${provider.label} [${provider.status}]\n`);
321
- }
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]));
322
370
  return 0;
323
371
  }
324
372
  function regionsCommand(parsed, context) {
325
373
  const cloud = readStringFlag(parsed.flags, 'cloud') ?? 'azure';
374
+ context.presentation.commandIdentity('regions', 'Cloud deployment regions');
326
375
  if (cloud !== 'azure') {
327
- 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.');
328
377
  return 1;
329
378
  }
330
379
  const query = parsed.positional[0] ?? readStringFlag(parsed.flags, 'region');
331
380
  const regions = parsed.subcommand === 'search' && query ? searchRegions('azure', query) : listRegions('azure');
332
- for (const region of regions) {
333
- 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;
334
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]));
335
386
  return 0;
336
387
  }
337
388
  async function validateCommand(parsed, context) {
389
+ context.presentation.commandIdentity('validate', 'Validate a generated Liftoff project');
338
390
  const explicit = parsed.positional[0] ?? readStringFlag(parsed.flags, 'project');
339
391
  const projectRoot = explicit
340
392
  ? path.resolve(context.cwd, explicit)
341
393
  : (await findProjectRoot(context.cwd)) ?? context.cwd;
342
394
  const issues = await validateGeneratedProject(projectRoot);
343
395
  if (issues.length > 0) {
344
- 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.');
345
397
  return 1;
346
398
  }
347
- context.stdout.write('Generated project manifest is valid.\n');
399
+ context.presentation.status('success', 'Generated project manifest is valid', projectRoot);
348
400
  return 0;
349
401
  }
350
402
  const STAGING_EXCLUDES = new Set(['.git', 'node_modules', 'vendor', '.venv', 'venv', '__pycache__', 'dist', 'build', '.next']);
@@ -454,13 +506,16 @@ function migrationPlanArtifacts(plan, inventory) {
454
506
  };
455
507
  }
456
508
  async function executeMigration(parsed, context, sourceRoot) {
509
+ const { presentation } = context;
510
+ presentation.stage('Scan legacy project', sourceRoot);
457
511
  const inventory = await scanLegacyProject(sourceRoot);
458
512
  const { options: defaults, provenance } = scanDefaults(inventory);
459
- context.stdout.write('Scan defaults (override in prompts or with flags):\n');
460
- for (const item of provenance) {
461
- 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]));
462
518
  }
463
- context.stdout.write('\n');
464
519
  const flagOptions = await optionsFromParsedArgs(parsed, context.cwd, false);
465
520
  const initial = mergeOptions(defaults, flagOptions);
466
521
  if (flagOptions.pattern && flagOptions.projectType === undefined) {
@@ -479,97 +534,125 @@ async function executeMigration(parsed, context, sourceRoot) {
479
534
  if (flagOptions.projectType === 'genai' && flagOptions.apiStack === undefined) {
480
535
  initial.apiStack = undefined;
481
536
  }
482
- const needsPrompts = !initial.yes && hasMissingInitInputs(initial);
483
- const options = needsPrompts ? await promptForInitOptions(initial) : initial;
484
- const plan = buildProjectPlan(options, { requireProjectName: true });
485
- const confirmed = await confirmPlan(plan, options.yes);
486
- if (!confirmed) {
487
- context.stdout.write('Migration cancelled.\n');
488
- return 0;
489
- }
490
- const parentDir = path.dirname(sourceRoot);
491
- let targetRoot = path.resolve(parentDir, plan.safeProjectName);
492
- if (targetRoot === sourceRoot) {
493
- targetRoot = path.resolve(parentDir, `${plan.safeProjectName}-liftoff`);
494
- }
495
- const target = { root: targetRoot, mode: 'named-child' };
496
- await assertSafeInitTarget(target, parentDir);
497
- await assertNewOrEmptyDirectory(targetRoot);
498
- const runner = context.runner ?? new NodeCommandRunner();
499
- const renderer = new TerminalRenderer({ stream: context.stdout });
500
- renderer.write(renderer.banner('Migrate into a fresh Liftoff project'));
501
- const readinessRoot = await mkdtemp(path.join(os.tmpdir(), 'liftoff-migrate-readiness-'));
502
- const readiness = await (async () => {
503
- try {
504
- return await ensureWorkstationReady(plan, options, context, runner, renderer, `liftoff migrate ${JSON.stringify(sourceRoot)}`, readinessRoot);
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');
505
549
  }
506
- finally {
507
- await rm(readinessRoot, { recursive: true, force: true });
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;
508
559
  }
509
- })();
510
- if (!readiness.ready) {
511
- return 1;
512
- }
513
- const migrationPlan = migrationPlanArtifacts(plan, inventory);
514
- await withStagingArea(async (area) => {
515
- const partition = partitionGeneratedArtifacts(buildArtifacts(plan));
516
- await writeStagedArtifacts(area, partition.durable, 'liftoff');
517
- await initializeFramework(area, plan, runner, {
518
- stdout: context.stdout,
519
- stderr: context.stderr
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 });
520
616
  });
521
- await writeStagedArtifacts(area, partition.seed, 'seed');
522
- await stageMigrationSource(area, sourceRoot);
523
- await writeStagedArtifacts(area, migrationPlan.artifacts, 'seed');
524
- await writeStagedArtifacts(area, [partition.manifest], 'liftoff');
525
- await validateStagedTree(area);
526
- const stagedIssues = await validateGeneratedProject(area.root);
527
- if (stagedIssues.length > 0) {
528
- throw new Error(`Staged migration project validation failed:\n${stagedIssues.join('\n')}`);
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;
529
625
  }
530
- const preflight = await buildMergePreflight(area, targetRoot);
531
- const existing = preflight.entries.filter((entry) => entry.destination.type !== 'missing');
532
- if (existing.length > 0) {
533
- throw new Error(`Migration target must remain new or empty; --force cannot replace existing content:\n` +
534
- existing.map((entry) => `- ${entry.relativePath}`).join('\n'));
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);
535
632
  }
536
- const authorized = await authorizeMergePreflight(preflight, false);
537
- if (!authorized) {
538
- throw new Error('Migration target authorization failed.');
633
+ if (dependencyPhase.deferred.length > 0) {
634
+ presentation.bullets('Deferred project dependencies', dependencyPhase.deferred);
539
635
  }
540
- await applyMergePreflight(authorized, { requireEmptyTarget: true });
541
- });
542
- const issues = await validateGeneratedProject(targetRoot);
543
- if (issues.length > 0) {
544
- context.stderr.write(`Migrated project validation failed:\n${issues.join('\n')}\n`);
545
- return 1;
546
- }
547
- const dependencyPhase = await handleProjectDependencies(plan, targetRoot, options, readiness.probes, context, runner, renderer);
548
- if (!dependencyPhase.success) {
549
- return 1;
550
- }
551
- renderer.write(renderer.status('success', `Migrated ${plan.projectName}`, targetRoot));
552
- renderer.write(renderer.panel('Configured integrations', [
553
- `${plan.specWorkflow.label} ${plan.framework.version}`,
554
- ...plan.agents.map((agent) => `${agent.label}${plan.defaultAgent?.id === agent.id ? ' (default)' : ''}`)
555
- ]));
556
- if (readiness.deferred.length > 0) {
557
- renderer.write(renderer.panel('Deferred advisory checks', readiness.deferred));
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');
646
+ return 0;
558
647
  }
559
- if (dependencyPhase.deferred.length > 0) {
560
- renderer.write(renderer.panel('Deferred project dependencies', dependencyPhase.deferred));
648
+ finally {
649
+ prompter?.close();
561
650
  }
562
- context.stdout.write('Next steps:\n');
563
- 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`);
564
- context.stdout.write(` 2. Execute the migration plan: ${migrationPlan.location}\n`);
565
- context.stdout.write(' 3. Verify compliance: liftoff validate && liftoff doctor\n');
566
- context.stdout.write(`The source project was not modified. Rolling back is deleting ${targetRoot}.\n`);
567
- return 0;
568
651
  }
569
652
  async function migrateCommand(parsed, context) {
570
653
  const sourceArg = parsed.positional[0];
571
654
  if (!sourceArg) {
572
- context.stderr.write('Usage: liftoff migrate <path-to-existing-project>\n');
655
+ context.presentation.error('Usage: liftoff migrate <path-to-existing-project>', 'Run `liftoff migrate --help` for accepted migration options.');
573
656
  return 1;
574
657
  }
575
658
  const sourceRoot = path.resolve(context.cwd, sourceArg);
@@ -578,17 +661,18 @@ async function migrateCommand(parsed, context) {
578
661
  sourceDetails = await stat(sourceRoot);
579
662
  }
580
663
  catch {
581
- context.stderr.write(`Source project not found: ${sourceRoot}\n`);
664
+ context.presentation.error(`Source project not found: ${sourceRoot}`);
582
665
  return 1;
583
666
  }
584
667
  if (!sourceDetails.isDirectory()) {
585
- context.stderr.write(`Source path is not a directory: ${sourceRoot}\n`);
668
+ context.presentation.error(`Source path is not a directory: ${sourceRoot}`);
586
669
  return 1;
587
670
  }
588
671
  if (existsSync(path.join(sourceRoot, 'liftoff.manifest.json'))) {
589
- context.stderr.write(`${sourceRoot} is already a Liftoff project. Use liftoff update instead.\n`);
672
+ context.presentation.error(`${sourceRoot} is already a Liftoff project.`, 'Use `liftoff update` instead.');
590
673
  return 1;
591
674
  }
675
+ context.presentation.identity('Migrate an existing application into a fresh Liftoff project');
592
676
  return withUnchangedMigrationSource(sourceRoot, () => executeMigration(parsed, context, sourceRoot));
593
677
  }
594
678
  function summarizeEntries(entries) {
@@ -659,47 +743,49 @@ async function preflightUpdate(projectRoot, entries, force) {
659
743
  await resolveProjectPath(projectRoot, ['liftoff.manifest.json']);
660
744
  }
661
745
  async function updateCommand(parsed, context) {
746
+ const { presentation } = context;
662
747
  const apply = readBooleanFlag(parsed.flags, 'apply') ?? false;
663
748
  const force = readBooleanFlag(parsed.flags, 'force') ?? false;
664
749
  const jsonMode = readBooleanFlag(parsed.flags, 'json') ?? false;
750
+ presentation.commandIdentity('update', 'Reconcile the project with current Liftoff templates');
665
751
  if (force && !apply) {
666
- context.stderr.write('--force requires --apply.\n');
752
+ presentation.error('--force requires --apply.', 'Run `liftoff update --apply --force` only after reviewing the reported conflicts.');
667
753
  return 1;
668
754
  }
669
755
  const explicit = parsed.positional[0] ?? readStringFlag(parsed.flags, 'project');
670
756
  const projectRoot = explicit ? path.resolve(context.cwd, explicit) : await findProjectRoot(context.cwd);
671
757
  if (!projectRoot) {
672
- 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.');
673
759
  return 1;
674
760
  }
675
761
  const manifest = await loadManifest(projectRoot);
676
762
  if (compareSemver(manifest.liftoffVersion, liftoffVersion) > 0) {
677
- 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.');
678
764
  return 1;
679
765
  }
680
766
  const config = await loadConfigOptions('liftoff.config.json', projectRoot);
681
767
  const plan = buildProjectPlan(config, { requireProjectName: true });
682
768
  if (plan.projectType.id !== manifest.project.projectType) {
683
- 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.');
684
770
  return 1;
685
771
  }
686
772
  if (plan.apiStack.id !== manifest.project.apiStack) {
687
- 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.');
688
774
  return 1;
689
775
  }
690
776
  if (plan.pattern?.id !== manifest.project.pattern) {
691
- 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.');
692
778
  return 1;
693
779
  }
694
780
  if (plan.specWorkflow.id !== manifest.project.specWorkflow) {
695
- context.stderr.write(`Spec workflow changes (${manifest.project.specWorkflow} -> ${plan.specWorkflow.id}) require official framework initialization and are not supported by liftoff update.\n`);
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.');
696
782
  return 1;
697
783
  }
698
784
  const configuredAgents = plan.agents.map((agent) => agent.id);
699
785
  if (manifest.framework.state === 'initialized' && (configuredAgents.length !== manifest.project.agents.length ||
700
786
  configuredAgents.some((agent, index) => agent !== manifest.project.agents[index]) ||
701
787
  plan.defaultAgent?.id !== manifest.project.defaultAgent)) {
702
- context.stderr.write('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.\n');
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.');
703
789
  return 1;
704
790
  }
705
791
  const render = buildArtifacts(plan);
@@ -709,7 +795,7 @@ async function updateCommand(parsed, context) {
709
795
  const visible = entries.filter((entry) => entry.status !== 'unchanged' || entry.refreshHash);
710
796
  if (!apply) {
711
797
  if (jsonMode) {
712
- context.stdout.write(`${JSON.stringify({
798
+ presentation.rawStdout(`${JSON.stringify({
713
799
  schemaVersion: 1,
714
800
  mode: 'check',
715
801
  cliVersion: liftoffVersion,
@@ -725,22 +811,29 @@ async function updateCommand(parsed, context) {
725
811
  }, null, 2)}\n`);
726
812
  return drift ? 2 : 0;
727
813
  }
728
- 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
+ ]);
729
818
  if (!drift) {
730
- 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`);
731
820
  return 0;
732
821
  }
733
- for (const entry of visible) {
734
- 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]));
735
827
  }
736
828
  const toWrite = summary.new + summary.missing + summary.upgrade + summary.moved + summary.refresh;
737
- context.stdout.write(`\n ${toWrite} to write, ${summary.conflict} conflict(s), ${summary.orphan} orphan(s), ${summary.unchanged} unchanged\n`);
738
- 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');
739
831
  return 2;
740
832
  }
741
833
  if (isDirtyGitWorktree(projectRoot)) {
742
- 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.');
743
835
  }
836
+ presentation.stage('Apply safe template changes', projectRoot);
744
837
  await preflightUpdate(projectRoot, entries, force);
745
838
  const written = [];
746
839
  const skipped = [];
@@ -813,7 +906,7 @@ async function updateCommand(parsed, context) {
813
906
  }
814
907
  await writeProjectFile(projectRoot, ['liftoff.manifest.json'], `${JSON.stringify(nextManifest, null, 2)}\n`);
815
908
  if (jsonMode) {
816
- context.stdout.write(`${JSON.stringify({
909
+ presentation.rawStdout(`${JSON.stringify({
817
910
  schemaVersion: 1,
818
911
  mode: 'apply',
819
912
  cliVersion: liftoffVersion,
@@ -824,18 +917,17 @@ async function updateCommand(parsed, context) {
824
917
  }, null, 2)}\n`);
825
918
  return 0;
826
919
  }
827
- for (const entry of written) {
828
- context.stdout.write(` wrote ${entryDisplay(entry)}\n`);
920
+ if (written.length > 0) {
921
+ presentation.bullets('Applied changes', written.map((entry) => `wrote ${entryDisplay(entry)}`));
829
922
  }
830
- for (const entry of skipped) {
831
- 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)'}`));
832
925
  }
833
- for (const entry of entries) {
834
- if (entry.status === 'orphan') {
835
- context.stdout.write(` orphan ${entryDisplay(entry)} ${entry.reason}\n`);
836
- }
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}`));
837
929
  }
838
- 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');
839
931
  return 0;
840
932
  }
841
933
  function versionedBinaryCheck(label, command, args, minimum, remedy) {
@@ -1022,11 +1114,11 @@ function binaryPresent(command) {
1022
1114
  const probe = process.platform === 'win32' ? 'where' : 'which';
1023
1115
  return spawnSync(probe, [command], { encoding: 'utf8' }).status === 0;
1024
1116
  }
1025
- function azureCloudChecks() {
1026
- if (!binaryPresent('az')) {
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') {
1027
1120
  return [{ label: 'az', severity: 'warn', detail: 'Azure CLI not found', remedy: 'install the Azure CLI' }];
1028
1121
  }
1029
- const auth = spawnSync('az', ['account', 'show', '-o', 'none', '--only-show-errors'], { encoding: 'utf8' });
1030
1122
  if (auth.status === 0) {
1031
1123
  return [{ label: 'azure auth', severity: 'ok', detail: 'authenticated' }];
1032
1124
  }
@@ -1036,9 +1128,9 @@ function azureCloudChecks() {
1036
1128
  const CLOUD_CHECKS = {
1037
1129
  azure: azureCloudChecks
1038
1130
  };
1039
- function cloudLayer(cloud) {
1131
+ async function cloudLayer(cloud, runner) {
1040
1132
  const checks = CLOUD_CHECKS[cloud]
1041
- ? CLOUD_CHECKS[cloud]()
1133
+ ? await CLOUD_CHECKS[cloud](runner)
1042
1134
  : [{ label: cloud, severity: 'skipped', detail: `${cloud} provider checks are not available yet` }];
1043
1135
  return { title: `Cloud - ${cloud}`, checks };
1044
1136
  }
@@ -1141,7 +1233,7 @@ async function projectLayer(projectRoot, manifest) {
1141
1233
  }
1142
1234
  return { title: 'Project', checks };
1143
1235
  }
1144
- async function runtimeLayer(projectRoot, dockerAvailable) {
1236
+ async function runtimeLayer(projectRoot, dockerAvailable, runner) {
1145
1237
  const checks = [];
1146
1238
  if (existsSync(path.join(projectRoot, '.env.example'))) {
1147
1239
  if (existsSync(path.join(projectRoot, '.env'))) {
@@ -1161,7 +1253,7 @@ async function runtimeLayer(projectRoot, dockerAvailable) {
1161
1253
  checks.push({ label: 'compose', severity: 'skipped', detail: 'docker is not installed, compose config not checked' });
1162
1254
  }
1163
1255
  else {
1164
- 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 });
1165
1257
  if (result.status === 0) {
1166
1258
  checks.push({ label: 'compose', severity: 'ok', detail: 'docker compose config is valid' });
1167
1259
  }
@@ -1176,19 +1268,21 @@ async function runtimeLayer(projectRoot, dockerAvailable) {
1176
1268
  }
1177
1269
  return { title: 'Runtime', checks };
1178
1270
  }
1179
- const severityMarker = {
1180
- ok: '[ok]',
1181
- warn: '[warn]',
1182
- fail: '[fail]',
1183
- skipped: '[skip]'
1184
- };
1185
- 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
+ };
1186
1278
  for (const layer of layers) {
1187
- stream.write(`${layer.title}\n`);
1188
- for (const check of layer.checks) {
1279
+ presentation.section(layer.title, layer.checks.flatMap((check) => {
1189
1280
  const remedy = check.remedy ? ` - ${check.remedy}` : '';
1190
- stream.write(` ${severityMarker[check.severity].padEnd(6)} ${check.label}: ${check.detail}${remedy}\n`);
1191
- }
1281
+ return presentation.stdout
1282
+ .status(statusKind[check.severity], check.label, `${check.detail}${remedy}`)
1283
+ .trimEnd()
1284
+ .split('\n');
1285
+ }));
1192
1286
  }
1193
1287
  }
1194
1288
  export function doctorExitCode(layers) {
@@ -1198,6 +1292,7 @@ async function doctorCommand(parsed, context) {
1198
1292
  const jsonMode = readBooleanFlag(parsed.flags, 'json') ?? false;
1199
1293
  const cloudOverride = readStringFlag(parsed.flags, 'cloud');
1200
1294
  const layers = [];
1295
+ context.presentation.commandIdentity('doctor', 'Inspect CLI, workstation, project, runtime, and cloud readiness');
1201
1296
  const projectRoot = await findProjectRoot(context.cwd);
1202
1297
  let manifest;
1203
1298
  let manifestError;
@@ -1227,9 +1322,9 @@ async function doctorCommand(parsed, context) {
1227
1322
  }
1228
1323
  if (manifest) {
1229
1324
  layers.push(await projectLayer(projectRoot, manifest));
1230
- layers.push(await runtimeLayer(projectRoot, dockerAvailable));
1325
+ layers.push(await runtimeLayer(projectRoot, dockerAvailable, runner));
1231
1326
  const cloud = cloudOverride ?? manifest.project.cloud;
1232
- const cloudChecks = cloudLayer(cloud);
1327
+ const cloudChecks = await cloudLayer(cloud, runner);
1233
1328
  const pattern = patterns.find((candidate) => candidate.id === manifest.project.pattern);
1234
1329
  if (pattern?.worker && cloud === 'azure') {
1235
1330
  cloudChecks.checks.push(binaryPresent('func')
@@ -1240,22 +1335,24 @@ async function doctorCommand(parsed, context) {
1240
1335
  }
1241
1336
  }
1242
1337
  else if (cloudOverride) {
1243
- layers.push(cloudLayer(cloudOverride));
1338
+ layers.push(await cloudLayer(cloudOverride, runner));
1244
1339
  }
1245
1340
  const failures = layers.reduce((count, layer) => count + layer.checks.filter((check) => check.severity === 'fail').length, 0);
1246
1341
  const warnings = layers.reduce((count, layer) => count + layer.checks.filter((check) => check.severity === 'warn').length, 0);
1247
1342
  if (jsonMode) {
1248
- 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`);
1249
1344
  }
1250
1345
  else {
1251
- renderDoctorLayers(layers, context.stdout);
1252
- 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)`);
1253
1348
  }
1254
1349
  return doctorExitCode(layers);
1255
1350
  }
1256
1351
  function helperCommand(parsed, context, tool) {
1257
1352
  const command = parsed.command === 'dev' ? buildDevCommand(parsed) : buildInfraCommand(parsed);
1258
- 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);
1259
1356
  return 0;
1260
1357
  }
1261
1358
  function buildDevCommand(parsed) {
@@ -1327,10 +1424,34 @@ function hasMissingInitInputs(options) {
1327
1424
  missingDefaultAgent ||
1328
1425
  !options.environments;
1329
1426
  }
1330
- function printHelp(stream) {
1331
- const renderer = new TerminalRenderer({ stream });
1332
- renderer.write(renderer.banner('Project workstation and scaffold initializer'));
1333
- stream.write(formatGeneralHelp(liftoffVersion));
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
+ }
1334
1455
  }
1335
1456
  export async function createFixtureProject(options) {
1336
1457
  const tempRoot = await mkdtemp(path.join(os.tmpdir(), 'liftoff-'));