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