@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/README.md +32 -0
- package/dist/args.d.ts +33 -0
- package/dist/args.js +93 -37
- package/dist/args.js.map +1 -1
- package/dist/cli.js +6 -1
- package/dist/cli.js.map +1 -1
- package/dist/commands.d.ts +4 -0
- package/dist/commands.js +401 -280
- package/dist/commands.js.map +1 -1
- package/dist/framework-adapters.d.ts +5 -2
- package/dist/framework-adapters.js +5 -2
- package/dist/framework-adapters.js.map +1 -1
- package/dist/interactive.d.ts +43 -6
- package/dist/interactive.js +180 -140
- package/dist/interactive.js.map +1 -1
- package/dist/planner.d.ts +5 -0
- package/dist/planner.js +27 -20
- package/dist/planner.js.map +1 -1
- package/dist/process-runner.js +9 -6
- package/dist/process-runner.js.map +1 -1
- package/dist/project-dependencies.d.ts +6 -4
- package/dist/project-dependencies.js +4 -3
- package/dist/project-dependencies.js.map +1 -1
- package/dist/published-verifier.js +1 -1
- package/dist/published-verifier.js.map +1 -1
- package/dist/terminal.d.ts +99 -3
- package/dist/terminal.js +482 -66
- package/dist/terminal.js.map +1 -1
- package/package.json +1 -1
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 {
|
|
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 {
|
|
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,
|
|
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 {
|
|
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
|
-
|
|
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
|
-
|
|
46
|
+
renderCommandHelp(parsed.positional[0], presentation);
|
|
37
47
|
}
|
|
38
48
|
else {
|
|
39
|
-
|
|
49
|
+
renderGeneralHelp(presentation);
|
|
40
50
|
}
|
|
41
51
|
return 0;
|
|
42
52
|
case 'version':
|
|
43
|
-
|
|
53
|
+
presentation.rawStdout(`Liftoff ${liftoffVersion}\n`);
|
|
44
54
|
return 0;
|
|
45
55
|
case 'init':
|
|
46
|
-
return await initCommand(parsed,
|
|
56
|
+
return await initCommand(parsed, executionContext);
|
|
47
57
|
case 'plan':
|
|
48
|
-
return await planCommand(parsed,
|
|
58
|
+
return await planCommand(parsed, executionContext);
|
|
49
59
|
case 'patterns':
|
|
50
|
-
return patternsCommand(
|
|
60
|
+
return patternsCommand(executionContext);
|
|
51
61
|
case 'providers':
|
|
52
|
-
return providersCommand(
|
|
62
|
+
return providersCommand(executionContext);
|
|
53
63
|
case 'regions':
|
|
54
|
-
return regionsCommand(parsed,
|
|
64
|
+
return regionsCommand(parsed, executionContext);
|
|
55
65
|
case 'validate':
|
|
56
|
-
return await validateCommand(parsed,
|
|
66
|
+
return await validateCommand(parsed, executionContext);
|
|
57
67
|
case 'update':
|
|
58
|
-
return await updateCommand(parsed,
|
|
68
|
+
return await updateCommand(parsed, executionContext);
|
|
59
69
|
case 'migrate':
|
|
60
|
-
return await migrateCommand(parsed,
|
|
70
|
+
return await migrateCommand(parsed, executionContext);
|
|
61
71
|
case 'doctor':
|
|
62
|
-
return await doctorCommand(parsed,
|
|
72
|
+
return await doctorCommand(parsed, executionContext);
|
|
63
73
|
case 'dev':
|
|
64
|
-
return helperCommand(parsed,
|
|
74
|
+
return helperCommand(parsed, executionContext, 'docker compose');
|
|
65
75
|
case 'infra':
|
|
66
|
-
return helperCommand(parsed,
|
|
76
|
+
return helperCommand(parsed, executionContext, 'tofu');
|
|
67
77
|
default:
|
|
68
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
90
|
-
const
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
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
|
-
|
|
117
|
-
const
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
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
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
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
|
-
|
|
131
|
-
|
|
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
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
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
|
-
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
217
|
-
|
|
218
|
-
:
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
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:
|
|
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
|
-
|
|
299
|
+
presentation.status(kind, probe.requirement.definition.label, installation.detail);
|
|
251
300
|
if (installation.remedy) {
|
|
252
|
-
|
|
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
|
-
|
|
261
|
-
`\nRemedy: ${installInstruction(blocker)}\n`);
|
|
309
|
+
presentation.error(`${blocker.requirement.definition.label}: ${blocker.detail}`, installInstruction(blocker));
|
|
262
310
|
}
|
|
263
|
-
|
|
264
|
-
? `Open a new terminal if PATH changed, then rerun \`${resumeInvocation}\` with the same project options
|
|
265
|
-
: `Resume with \`${resumeInvocation} --install-tools\` plus the same project options after reviewing the commands
|
|
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,
|
|
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
|
-
|
|
294
|
-
|
|
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
|
-
|
|
298
|
-
|
|
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
|
-
|
|
352
|
+
presentation.warning(`Restored protected files: ${result.restoredMutations.join(', ')}`);
|
|
301
353
|
}
|
|
302
354
|
if (result.resumeCommand) {
|
|
303
|
-
|
|
355
|
+
presentation.command(result.resumeCommand);
|
|
304
356
|
}
|
|
305
357
|
return { success: false, deferred: [] };
|
|
306
358
|
}
|
|
307
|
-
|
|
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.
|
|
312
|
-
|
|
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.
|
|
319
|
-
|
|
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.
|
|
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
|
-
|
|
333
|
-
context.
|
|
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.
|
|
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.
|
|
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
|
-
|
|
460
|
-
|
|
461
|
-
|
|
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
|
|
483
|
-
const
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
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
|
-
|
|
507
|
-
|
|
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
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
const
|
|
516
|
-
await
|
|
517
|
-
await
|
|
518
|
-
|
|
519
|
-
|
|
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
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
const
|
|
527
|
-
if (
|
|
528
|
-
|
|
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
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
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
|
-
|
|
537
|
-
|
|
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
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
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
|
-
|
|
560
|
-
|
|
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.
|
|
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.
|
|
664
|
+
context.presentation.error(`Source project not found: ${sourceRoot}`);
|
|
582
665
|
return 1;
|
|
583
666
|
}
|
|
584
667
|
if (!sourceDetails.isDirectory()) {
|
|
585
|
-
context.
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
819
|
+
presentation.status('success', 'No drift', `${summary.unchanged} artifacts match the current templates and configuration`);
|
|
731
820
|
return 0;
|
|
732
821
|
}
|
|
733
|
-
|
|
734
|
-
|
|
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
|
-
|
|
738
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
828
|
-
|
|
920
|
+
if (written.length > 0) {
|
|
921
|
+
presentation.bullets('Applied changes', written.map((entry) => `wrote ${entryDisplay(entry)}`));
|
|
829
922
|
}
|
|
830
|
-
|
|
831
|
-
|
|
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
|
-
|
|
834
|
-
|
|
835
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
1180
|
-
|
|
1181
|
-
|
|
1182
|
-
|
|
1183
|
-
|
|
1184
|
-
|
|
1185
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
1343
|
+
context.presentation.rawStdout(`${JSON.stringify({ schemaVersion: 1, layers, summary: { failures, warnings } }, null, 2)}\n`);
|
|
1249
1344
|
}
|
|
1250
1345
|
else {
|
|
1251
|
-
renderDoctorLayers(layers, context.
|
|
1252
|
-
context.
|
|
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.
|
|
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
|
|
1331
|
-
const
|
|
1332
|
-
|
|
1333
|
-
|
|
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-'));
|