@msn-control/liftoff 0.3.3 → 0.4.0
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 +66 -42
- 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 +9 -1
- package/dist/args.js +113 -33
- 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 +5 -0
- package/dist/cli.js.map +1 -1
- package/dist/commands.d.ts +5 -0
- package/dist/commands.js +633 -124
- 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 +15 -0
- package/dist/framework-adapters.js +112 -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 +5 -1
- package/dist/interactive.js +75 -4
- 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.js +62 -3
- package/dist/planner.js.map +1 -1
- package/dist/process-runner.d.ts +28 -0
- package/dist/process-runner.js +80 -0
- package/dist/process-runner.js.map +1 -0
- package/dist/project-dependencies.d.ts +28 -0
- package/dist/project-dependencies.js +163 -0
- package/dist/project-dependencies.js.map +1 -0
- package/dist/published-verifier.d.ts +39 -0
- package/dist/published-verifier.js +173 -0
- package/dist/published-verifier.js.map +1 -0
- package/dist/reconcile.js +4 -1
- package/dist/reconcile.js.map +1 -1
- package/dist/release-identity.d.ts +17 -0
- package/dist/release-identity.js +51 -0
- package/dist/release-identity.js.map +1 -0
- 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 +32 -0
- package/dist/terminal.js +182 -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 +10 -2
package/dist/commands.js
CHANGED
|
@@ -1,19 +1,27 @@
|
|
|
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 { formatCommandHelp, readBooleanFlag, readListFlag, readStringFlag } from './args.js';
|
|
7
|
-
import {
|
|
8
|
-
import {
|
|
9
|
-
import {
|
|
7
|
+
import { formatCommandHelp, formatGeneralHelp, 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 { confirmDependencyInstallation, confirmFileReplacements, confirmPlan, confirmToolInstallation, promptForInitOptions } 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
15
|
import { buildProjectPlan, formatProjectPlan, loadConfigOptions, mergeOptions, PlanValidationError } 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 { TerminalRenderer } 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) {
|
|
18
26
|
try {
|
|
19
27
|
if (parsed.command && readBooleanFlag(parsed.flags, 'help')) {
|
|
@@ -31,8 +39,11 @@ export async function runCommand(parsed, context) {
|
|
|
31
39
|
printHelp(context.stdout);
|
|
32
40
|
}
|
|
33
41
|
return 0;
|
|
34
|
-
case '
|
|
35
|
-
|
|
42
|
+
case 'version':
|
|
43
|
+
context.stdout.write(`Liftoff ${liftoffVersion}\n`);
|
|
44
|
+
return 0;
|
|
45
|
+
case 'init':
|
|
46
|
+
return await initCommand(parsed, context);
|
|
36
47
|
case 'plan':
|
|
37
48
|
return await planCommand(parsed, context);
|
|
38
49
|
case 'patterns':
|
|
@@ -68,25 +79,79 @@ export async function runCommand(parsed, context) {
|
|
|
68
79
|
return 1;
|
|
69
80
|
}
|
|
70
81
|
}
|
|
71
|
-
async function
|
|
72
|
-
const
|
|
73
|
-
const
|
|
74
|
-
|
|
82
|
+
async function initCommand(parsed, context) {
|
|
83
|
+
const runner = context.runner ?? new NodeCommandRunner();
|
|
84
|
+
const git = await discoverGitRoot(context.cwd, runner);
|
|
85
|
+
let initial = await optionsFromParsedArgs(parsed, context.cwd, true);
|
|
86
|
+
if (!initial.projectName && git.exact && git.root) {
|
|
87
|
+
initial = { ...initial, projectName: path.basename(git.root) };
|
|
88
|
+
}
|
|
89
|
+
const needsPrompts = !initial.yes && hasMissingInitInputs(initial);
|
|
90
|
+
const options = needsPrompts ? await promptForInitOptions(initial) : initial;
|
|
75
91
|
const plan = buildProjectPlan(options, { requireProjectName: true });
|
|
76
92
|
const confirmed = await confirmPlan(plan, options.yes);
|
|
77
93
|
if (!confirmed) {
|
|
78
|
-
context.stdout.write('
|
|
94
|
+
context.stdout.write('Initialization cancelled.\n');
|
|
79
95
|
return 0;
|
|
80
96
|
}
|
|
81
|
-
const
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
97
|
+
const target = resolveInitTargetFromDiscovery(git, plan.safeProjectName);
|
|
98
|
+
await assertSafeInitTarget(target, target.mode === 'named-child' ? git.canonicalCwd : undefined);
|
|
99
|
+
const renderer = new TerminalRenderer({ stream: context.stdout });
|
|
100
|
+
renderer.write(renderer.banner('Initialize the project and prepare its workstation'));
|
|
101
|
+
const readiness = await ensureWorkstationReady(plan, options, context, runner, renderer);
|
|
102
|
+
if (!readiness.ready) {
|
|
103
|
+
return 1;
|
|
104
|
+
}
|
|
105
|
+
const staged = await withStagingArea(async (area) => {
|
|
106
|
+
const partition = partitionGeneratedArtifacts(buildArtifacts(plan));
|
|
107
|
+
await writeStagedArtifacts(area, partition.durable, 'liftoff');
|
|
108
|
+
await initializeFramework(area, plan, runner, { stdout: context.stdout, stderr: context.stderr });
|
|
109
|
+
await writeStagedArtifacts(area, partition.seed, 'seed');
|
|
110
|
+
await writeStagedArtifacts(area, [partition.manifest], 'liftoff');
|
|
111
|
+
await validateStagedTree(area);
|
|
112
|
+
const stagedIssues = await validateGeneratedProject(area.root);
|
|
113
|
+
if (stagedIssues.length > 0) {
|
|
114
|
+
throw new Error(`Staged project validation failed:\n${stagedIssues.join('\n')}`);
|
|
115
|
+
}
|
|
116
|
+
const preflight = await buildMergePreflight(area, target.root);
|
|
117
|
+
const interactive = options.yes !== true;
|
|
118
|
+
const authorized = await authorizeMergePreflight(preflight, options.force === true, interactive ? confirmFileReplacements : undefined);
|
|
119
|
+
if (!authorized) {
|
|
120
|
+
return {
|
|
121
|
+
status: interactive ? 'declined' : 'authorization-required'
|
|
122
|
+
};
|
|
123
|
+
}
|
|
124
|
+
return { status: 'applied', merge: await applyMergePreflight(authorized) };
|
|
125
|
+
});
|
|
126
|
+
if (staged.status === 'declined') {
|
|
127
|
+
context.stdout.write('Initialization cancelled; no destination files were changed.\n');
|
|
128
|
+
return 0;
|
|
129
|
+
}
|
|
130
|
+
if (staged.status === 'authorization-required') {
|
|
131
|
+
context.stderr.write('Existing regular-file conflicts require --force in non-interactive mode.\n');
|
|
132
|
+
return 1;
|
|
133
|
+
}
|
|
134
|
+
const issues = await validateGeneratedProject(target.root);
|
|
85
135
|
if (issues.length > 0) {
|
|
86
|
-
context.stderr.write(`
|
|
136
|
+
context.stderr.write(`Initialized project validation failed:\n${issues.join('\n')}\n`);
|
|
87
137
|
return 1;
|
|
88
138
|
}
|
|
89
|
-
|
|
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));
|
|
153
|
+
}
|
|
154
|
+
renderer.write(renderer.command(`liftoff validate ${JSON.stringify(target.root)}`));
|
|
90
155
|
return 0;
|
|
91
156
|
}
|
|
92
157
|
async function planCommand(parsed, context) {
|
|
@@ -97,8 +162,151 @@ async function planCommand(parsed, context) {
|
|
|
97
162
|
for (const artifact of artifacts) {
|
|
98
163
|
context.stdout.write(`- ${artifact.logicalName}: ${artifact.pathParts.join('/')}\n`);
|
|
99
164
|
}
|
|
165
|
+
context.stdout.write('\nWorkstation requirements:\n');
|
|
166
|
+
for (const requirement of selectWorkstationRequirements(plan)) {
|
|
167
|
+
const version = requirement.exactVersion
|
|
168
|
+
? `exactly ${requirement.exactVersion}`
|
|
169
|
+
: requirement.minimumVersion
|
|
170
|
+
? `${requirement.minimumVersion}+`
|
|
171
|
+
: 'available';
|
|
172
|
+
context.stdout.write(`- ${requirement.definition.label}: ${version} [${requirement.severity}]\n`);
|
|
173
|
+
}
|
|
100
174
|
return 0;
|
|
101
175
|
}
|
|
176
|
+
async function ensureWorkstationReady(plan, options, context, runner, renderer, resumeInvocation = 'liftoff init', commandCwd) {
|
|
177
|
+
const requirements = selectWorkstationRequirements(plan);
|
|
178
|
+
let probes = await probeWorkstation(requirements, runner, { cwd: commandCwd });
|
|
179
|
+
renderer.write(renderer.heading('Workstation readiness'));
|
|
180
|
+
renderer.write(renderer.table(['Requirement', 'Level', 'State', 'Detail'], probes.map((probe) => [
|
|
181
|
+
probe.requirement.definition.label,
|
|
182
|
+
probe.requirement.severity,
|
|
183
|
+
probe.state,
|
|
184
|
+
probe.detail
|
|
185
|
+
])));
|
|
186
|
+
const actionable = probes.filter((probe) => probe.state !== 'ready');
|
|
187
|
+
const host = await detectHostEnvironment();
|
|
188
|
+
const installInstruction = (probe) => {
|
|
189
|
+
const recipe = probe.requirement.definition.install[host.platform];
|
|
190
|
+
const automatic = recipe && (host.platform !== 'linux' || recipe.manager === 'npm' || recipe.manager === 'uv');
|
|
191
|
+
if (automatic) {
|
|
192
|
+
return formatCommand(recipe.command);
|
|
193
|
+
}
|
|
194
|
+
if (host.platform === 'linux') {
|
|
195
|
+
return probe.requirement.definition.linuxRemedies[host.linuxFamily];
|
|
196
|
+
}
|
|
197
|
+
return `Install ${probe.requirement.definition.label} manually, then retry.`;
|
|
198
|
+
};
|
|
199
|
+
const authorizedInstallations = new Set();
|
|
200
|
+
if (options.installTools === true) {
|
|
201
|
+
for (const probe of actionable) {
|
|
202
|
+
authorizedInstallations.add(probe.requirement.id);
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
if (options.installTools === undefined &&
|
|
206
|
+
options.yes !== true &&
|
|
207
|
+
actionable.length > 0) {
|
|
208
|
+
for (const probe of actionable) {
|
|
209
|
+
const recipe = probe.requirement.definition.install[host.platform];
|
|
210
|
+
const automatic = recipe && (host.platform !== 'linux' || recipe.manager === 'npm' || recipe.manager === 'uv');
|
|
211
|
+
const constraint = probe.requirement.exactVersion
|
|
212
|
+
? `required exactly ${probe.requirement.exactVersion}`
|
|
213
|
+
: probe.requirement.minimumVersion
|
|
214
|
+
? `required ${probe.requirement.minimumVersion} or newer`
|
|
215
|
+
: 'required to be available';
|
|
216
|
+
const action = automatic
|
|
217
|
+
? `Command: ${formatCommand(recipe.command)}`
|
|
218
|
+
: `Manual remedy: ${installInstruction(probe)}`;
|
|
219
|
+
const detail = [
|
|
220
|
+
`${probe.requirement.definition.label} [${probe.requirement.severity}]`,
|
|
221
|
+
`Purpose: ${probe.requirement.reasons.join('; ')}`,
|
|
222
|
+
`Requirement: ${constraint}`,
|
|
223
|
+
`Observed: ${probe.state} - ${probe.detail}`,
|
|
224
|
+
action
|
|
225
|
+
].map((line) => ` ${line}`).join('\n');
|
|
226
|
+
if (await confirmToolInstallation(detail)) {
|
|
227
|
+
authorizedInstallations.add(probe.requirement.id);
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
if (authorizedInstallations.size > 0) {
|
|
232
|
+
const updates = new Map();
|
|
233
|
+
for (const probe of actionable) {
|
|
234
|
+
if (!authorizedInstallations.has(probe.requirement.id)) {
|
|
235
|
+
continue;
|
|
236
|
+
}
|
|
237
|
+
const installation = await installRequirement(probe.requirement, probe, {
|
|
238
|
+
authorized: true,
|
|
239
|
+
host,
|
|
240
|
+
runner,
|
|
241
|
+
cwd: commandCwd,
|
|
242
|
+
streamOptions: { stdout: context.stdout, stderr: context.stderr }
|
|
243
|
+
});
|
|
244
|
+
updates.set(probe.requirement.id, installation.probe);
|
|
245
|
+
const kind = installation.state === 'installed'
|
|
246
|
+
? 'success'
|
|
247
|
+
: probe.requirement.severity === 'blocking'
|
|
248
|
+
? 'error'
|
|
249
|
+
: 'warning';
|
|
250
|
+
renderer.write(renderer.status(kind, probe.requirement.definition.label, installation.detail));
|
|
251
|
+
if (installation.remedy) {
|
|
252
|
+
renderer.write(renderer.command(installation.remedy));
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
probes = probes.map((probe) => updates.get(probe.requirement.id) ?? probe);
|
|
256
|
+
}
|
|
257
|
+
const blockers = blockingReadinessFailures(probes);
|
|
258
|
+
if (blockers.length > 0) {
|
|
259
|
+
for (const blocker of blockers) {
|
|
260
|
+
context.stderr.write(`${blocker.requirement.definition.label}: ${blocker.detail}` +
|
|
261
|
+
`\nRemedy: ${installInstruction(blocker)}\n`);
|
|
262
|
+
}
|
|
263
|
+
context.stderr.write(options.installTools
|
|
264
|
+
? `Open a new terminal if PATH changed, then rerun \`${resumeInvocation}\` with the same project options.\n`
|
|
265
|
+
: `Resume with \`${resumeInvocation} --install-tools\` plus the same project options after reviewing the commands.\n`);
|
|
266
|
+
return { ready: false, deferred: [], probes };
|
|
267
|
+
}
|
|
268
|
+
const deferred = [
|
|
269
|
+
...probes
|
|
270
|
+
.filter((probe) => probe.requirement.severity === 'advisory' && probe.state !== 'ready')
|
|
271
|
+
.map((probe) => `${probe.requirement.definition.label}: ${probe.detail} Remedy: ${installInstruction(probe)}`),
|
|
272
|
+
...probes.flatMap((probe) => probe.notices
|
|
273
|
+
.filter((notice) => notice.state !== 'ready')
|
|
274
|
+
.map((notice) => `${notice.label}: ${notice.detail}${notice.remedy ? ` Remedy: ${notice.remedy}` : ''}`))
|
|
275
|
+
];
|
|
276
|
+
return { ready: true, deferred, probes };
|
|
277
|
+
}
|
|
278
|
+
async function handleProjectDependencies(plan, projectRoot, options, probes, context, runner, renderer) {
|
|
279
|
+
const dependencyPlan = buildDependencySetupPlan(plan, projectRoot, probes);
|
|
280
|
+
let installDependencies = options.installDependencies === true;
|
|
281
|
+
if (options.installDependencies === undefined &&
|
|
282
|
+
options.yes !== true &&
|
|
283
|
+
dependencyPlan.commands.length > 0) {
|
|
284
|
+
installDependencies = await confirmDependencyInstallation(dependencyPlan.commands);
|
|
285
|
+
}
|
|
286
|
+
if (!installDependencies) {
|
|
287
|
+
return {
|
|
288
|
+
success: true,
|
|
289
|
+
deferred: dependencyPlan.commands.map((command) => `${command.label}: ${command.cwd} -> ${formatCommand(command.command)}`)
|
|
290
|
+
};
|
|
291
|
+
}
|
|
292
|
+
const result = await runDependencySetup(dependencyPlan, projectRoot, runner, {
|
|
293
|
+
stdout: context.stdout,
|
|
294
|
+
stderr: context.stderr
|
|
295
|
+
});
|
|
296
|
+
if (!result.success) {
|
|
297
|
+
renderer.write(renderer.status('error', 'Project dependencies failed', `${result.failed?.label ?? 'dependency command'}: ${result.detail ?? 'unknown failure'}`));
|
|
298
|
+
renderer.write(renderer.status('info', 'Scaffold preserved', projectRoot));
|
|
299
|
+
if (result.restoredMutations.length > 0) {
|
|
300
|
+
renderer.write(renderer.warning(`Restored protected files: ${result.restoredMutations.join(', ')}`));
|
|
301
|
+
}
|
|
302
|
+
if (result.resumeCommand) {
|
|
303
|
+
renderer.write(renderer.command(result.resumeCommand));
|
|
304
|
+
}
|
|
305
|
+
return { success: false, deferred: [] };
|
|
306
|
+
}
|
|
307
|
+
renderer.write(renderer.status('success', 'Project dependencies', `${result.completed.length} command${result.completed.length === 1 ? '' : 's'} completed`));
|
|
308
|
+
return { success: true, deferred: [] };
|
|
309
|
+
}
|
|
102
310
|
function patternsCommand(context) {
|
|
103
311
|
context.stdout.write('Patterns:\n');
|
|
104
312
|
for (const pattern of patterns) {
|
|
@@ -140,29 +348,112 @@ async function validateCommand(parsed, context) {
|
|
|
140
348
|
return 0;
|
|
141
349
|
}
|
|
142
350
|
const STAGING_EXCLUDES = new Set(['.git', 'node_modules', 'vendor', '.venv', 'venv', '__pycache__', 'dist', 'build', '.next']);
|
|
143
|
-
async function
|
|
144
|
-
const
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
351
|
+
async function snapshotMigrationSource(sourceRoot) {
|
|
352
|
+
const snapshot = new Map();
|
|
353
|
+
const visit = async (pathParts) => {
|
|
354
|
+
const current = path.join(sourceRoot, ...pathParts);
|
|
355
|
+
const entries = await readdir(current, { withFileTypes: true });
|
|
356
|
+
entries.sort((left, right) => left.name.localeCompare(right.name, 'en'));
|
|
357
|
+
for (const entry of entries) {
|
|
358
|
+
const childParts = [...pathParts, entry.name];
|
|
359
|
+
const relativePath = childParts.join('/');
|
|
360
|
+
const childPath = path.join(sourceRoot, ...childParts);
|
|
361
|
+
const details = await lstat(childPath);
|
|
362
|
+
if (details.isSymbolicLink()) {
|
|
363
|
+
snapshot.set(relativePath, `symlink:${await readlink(childPath)}`);
|
|
364
|
+
}
|
|
365
|
+
else if (details.isDirectory()) {
|
|
366
|
+
snapshot.set(relativePath, 'directory');
|
|
367
|
+
await visit(childParts);
|
|
368
|
+
}
|
|
369
|
+
else if (details.isFile()) {
|
|
370
|
+
const hash = createHash('sha256').update(await readFile(childPath)).digest('hex');
|
|
371
|
+
snapshot.set(relativePath, `file:${hash}`);
|
|
372
|
+
}
|
|
373
|
+
else {
|
|
374
|
+
snapshot.set(relativePath, 'other');
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
};
|
|
378
|
+
await visit([]);
|
|
379
|
+
return snapshot;
|
|
380
|
+
}
|
|
381
|
+
function sourceSnapshotChanges(before, after) {
|
|
382
|
+
return [...new Set([...before.keys(), ...after.keys()])]
|
|
383
|
+
.filter((key) => before.get(key) !== after.get(key))
|
|
384
|
+
.sort();
|
|
385
|
+
}
|
|
386
|
+
async function withUnchangedMigrationSource(sourceRoot, operation) {
|
|
387
|
+
const before = await snapshotMigrationSource(sourceRoot);
|
|
388
|
+
const assertUnchanged = async () => {
|
|
389
|
+
const changes = sourceSnapshotChanges(before, await snapshotMigrationSource(sourceRoot));
|
|
390
|
+
if (changes.length > 0) {
|
|
391
|
+
throw new Error(`Migration source changed unexpectedly; refusing to continue:\n${changes.map((entry) => `- ${entry}`).join('\n')}`);
|
|
392
|
+
}
|
|
393
|
+
};
|
|
394
|
+
let result;
|
|
151
395
|
try {
|
|
152
|
-
|
|
396
|
+
result = await operation();
|
|
153
397
|
}
|
|
154
|
-
catch {
|
|
155
|
-
|
|
156
|
-
|
|
398
|
+
catch (error) {
|
|
399
|
+
await assertUnchanged();
|
|
400
|
+
throw error;
|
|
157
401
|
}
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
402
|
+
await assertUnchanged();
|
|
403
|
+
return result;
|
|
404
|
+
}
|
|
405
|
+
async function stageMigrationSource(area, sourceRoot) {
|
|
406
|
+
const destination = path.join(area.root, 'migration', 'legacy');
|
|
407
|
+
await cp(sourceRoot, destination, {
|
|
408
|
+
recursive: true,
|
|
409
|
+
filter: (source) => {
|
|
410
|
+
const relative = path.relative(sourceRoot, source);
|
|
411
|
+
if (!relative) {
|
|
412
|
+
return true;
|
|
413
|
+
}
|
|
414
|
+
return !relative.split(path.sep).some((part) => STAGING_EXCLUDES.has(part));
|
|
415
|
+
}
|
|
416
|
+
});
|
|
417
|
+
for (const entry of (await captureTreeState(area.root)).values()) {
|
|
418
|
+
if (entry.pathParts[0] === 'migration' &&
|
|
419
|
+
entry.pathParts[1] === 'legacy' &&
|
|
420
|
+
entry.type !== 'directory') {
|
|
421
|
+
area.origins.set(entry.pathParts.join('/'), 'seed');
|
|
422
|
+
}
|
|
161
423
|
}
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
424
|
+
}
|
|
425
|
+
function migrationPlanArtifacts(plan, inventory) {
|
|
426
|
+
const groups = seedMigrationGroups(inventory, plan);
|
|
427
|
+
if (plan.specWorkflow.id === 'openspec') {
|
|
428
|
+
return {
|
|
429
|
+
artifacts: [
|
|
430
|
+
{
|
|
431
|
+
logicalName: 'migration-proposal',
|
|
432
|
+
category: 'seed',
|
|
433
|
+
pathParts: ['openspec', 'changes', 'migrate-to-liftoff', 'proposal.md'],
|
|
434
|
+
content: renderMigrationProposal(plan, inventory)
|
|
435
|
+
},
|
|
436
|
+
{
|
|
437
|
+
logicalName: 'migration-tasks',
|
|
438
|
+
category: 'seed',
|
|
439
|
+
pathParts: ['openspec', 'changes', 'migrate-to-liftoff', 'tasks.md'],
|
|
440
|
+
content: renderMigrationTasks(groups)
|
|
441
|
+
}
|
|
442
|
+
],
|
|
443
|
+
location: 'openspec/changes/migrate-to-liftoff/ (run it with your agent workflow, e.g. /opsx:apply migrate-to-liftoff)'
|
|
444
|
+
};
|
|
165
445
|
}
|
|
446
|
+
return {
|
|
447
|
+
artifacts: [{
|
|
448
|
+
logicalName: 'migration-checklist',
|
|
449
|
+
category: 'seed',
|
|
450
|
+
pathParts: ['MIGRATION.md'],
|
|
451
|
+
content: renderMigrationChecklist(plan, inventory, groups)
|
|
452
|
+
}],
|
|
453
|
+
location: 'MIGRATION.md'
|
|
454
|
+
};
|
|
455
|
+
}
|
|
456
|
+
async function executeMigration(parsed, context, sourceRoot) {
|
|
166
457
|
const inventory = await scanLegacyProject(sourceRoot);
|
|
167
458
|
const { options: defaults, provenance } = scanDefaults(inventory);
|
|
168
459
|
context.stdout.write('Scan defaults (override in prompts or with flags):\n');
|
|
@@ -188,8 +479,8 @@ async function migrateCommand(parsed, context) {
|
|
|
188
479
|
if (flagOptions.projectType === 'genai' && flagOptions.apiStack === undefined) {
|
|
189
480
|
initial.apiStack = undefined;
|
|
190
481
|
}
|
|
191
|
-
const needsPrompts = !initial.yes &&
|
|
192
|
-
const options = needsPrompts ? await
|
|
482
|
+
const needsPrompts = !initial.yes && hasMissingInitInputs(initial);
|
|
483
|
+
const options = needsPrompts ? await promptForInitOptions(initial) : initial;
|
|
193
484
|
const plan = buildProjectPlan(options, { requireProjectName: true });
|
|
194
485
|
const confirmed = await confirmPlan(plan, options.yes);
|
|
195
486
|
if (!confirmed) {
|
|
@@ -201,45 +492,105 @@ async function migrateCommand(parsed, context) {
|
|
|
201
492
|
if (targetRoot === sourceRoot) {
|
|
202
493
|
targetRoot = path.resolve(parentDir, `${plan.safeProjectName}-liftoff`);
|
|
203
494
|
}
|
|
204
|
-
const
|
|
205
|
-
await
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
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);
|
|
505
|
+
}
|
|
506
|
+
finally {
|
|
507
|
+
await rm(readinessRoot, { recursive: true, force: true });
|
|
508
|
+
}
|
|
509
|
+
})();
|
|
510
|
+
if (!readiness.ready) {
|
|
209
511
|
return 1;
|
|
210
512
|
}
|
|
211
|
-
const
|
|
212
|
-
await
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
513
|
+
const migrationPlan = migrationPlanArtifacts(plan, inventory);
|
|
514
|
+
await withStagingArea(async (area) => {
|
|
515
|
+
const partition = partitionGeneratedArtifacts(buildArtifacts(plan));
|
|
516
|
+
await writeStagedArtifacts(area, partition.durable, 'liftoff');
|
|
517
|
+
await initializeFramework(area, plan, runner, {
|
|
518
|
+
stdout: context.stdout,
|
|
519
|
+
stderr: context.stderr
|
|
520
|
+
});
|
|
521
|
+
await writeStagedArtifacts(area, partition.seed, 'seed');
|
|
522
|
+
await stageMigrationSource(area, sourceRoot);
|
|
523
|
+
await writeStagedArtifacts(area, migrationPlan.artifacts, 'seed');
|
|
524
|
+
await writeStagedArtifacts(area, [partition.manifest], 'liftoff');
|
|
525
|
+
await validateStagedTree(area);
|
|
526
|
+
const stagedIssues = await validateGeneratedProject(area.root);
|
|
527
|
+
if (stagedIssues.length > 0) {
|
|
528
|
+
throw new Error(`Staged migration project validation failed:\n${stagedIssues.join('\n')}`);
|
|
220
529
|
}
|
|
530
|
+
const preflight = await buildMergePreflight(area, targetRoot);
|
|
531
|
+
const existing = preflight.entries.filter((entry) => entry.destination.type !== 'missing');
|
|
532
|
+
if (existing.length > 0) {
|
|
533
|
+
throw new Error(`Migration target must remain new or empty; --force cannot replace existing content:\n` +
|
|
534
|
+
existing.map((entry) => `- ${entry.relativePath}`).join('\n'));
|
|
535
|
+
}
|
|
536
|
+
const authorized = await authorizeMergePreflight(preflight, false);
|
|
537
|
+
if (!authorized) {
|
|
538
|
+
throw new Error('Migration target authorization failed.');
|
|
539
|
+
}
|
|
540
|
+
await applyMergePreflight(authorized, { requireEmptyTarget: true });
|
|
221
541
|
});
|
|
222
|
-
const
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
await mkdir(changeDir, { recursive: true });
|
|
227
|
-
await writeFile(path.join(changeDir, 'proposal.md'), renderMigrationProposal(plan, inventory), 'utf8');
|
|
228
|
-
await writeFile(path.join(changeDir, 'tasks.md'), renderMigrationTasks(groups), 'utf8');
|
|
229
|
-
planLocation = 'openspec/changes/migrate-to-liftoff/ (run it with your agent workflow, e.g. /opsx:apply migrate-to-liftoff)';
|
|
542
|
+
const issues = await validateGeneratedProject(targetRoot);
|
|
543
|
+
if (issues.length > 0) {
|
|
544
|
+
context.stderr.write(`Migrated project validation failed:\n${issues.join('\n')}\n`);
|
|
545
|
+
return 1;
|
|
230
546
|
}
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
547
|
+
const dependencyPhase = await handleProjectDependencies(plan, targetRoot, options, readiness.probes, context, runner, renderer);
|
|
548
|
+
if (!dependencyPhase.success) {
|
|
549
|
+
return 1;
|
|
550
|
+
}
|
|
551
|
+
renderer.write(renderer.status('success', `Migrated ${plan.projectName}`, targetRoot));
|
|
552
|
+
renderer.write(renderer.panel('Configured integrations', [
|
|
553
|
+
`${plan.specWorkflow.label} ${plan.framework.version}`,
|
|
554
|
+
...plan.agents.map((agent) => `${agent.label}${plan.defaultAgent?.id === agent.id ? ' (default)' : ''}`)
|
|
555
|
+
]));
|
|
556
|
+
if (readiness.deferred.length > 0) {
|
|
557
|
+
renderer.write(renderer.panel('Deferred advisory checks', readiness.deferred));
|
|
558
|
+
}
|
|
559
|
+
if (dependencyPhase.deferred.length > 0) {
|
|
560
|
+
renderer.write(renderer.panel('Deferred project dependencies', dependencyPhase.deferred));
|
|
234
561
|
}
|
|
235
|
-
context.stdout.write(`Created ${plan.projectName} at ${targetRoot}\n\n`);
|
|
236
562
|
context.stdout.write('Next steps:\n');
|
|
237
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`);
|
|
238
|
-
context.stdout.write(` 2. Execute the migration plan: ${
|
|
564
|
+
context.stdout.write(` 2. Execute the migration plan: ${migrationPlan.location}\n`);
|
|
239
565
|
context.stdout.write(' 3. Verify compliance: liftoff validate && liftoff doctor\n');
|
|
240
566
|
context.stdout.write(`The source project was not modified. Rolling back is deleting ${targetRoot}.\n`);
|
|
241
567
|
return 0;
|
|
242
568
|
}
|
|
569
|
+
async function migrateCommand(parsed, context) {
|
|
570
|
+
const sourceArg = parsed.positional[0];
|
|
571
|
+
if (!sourceArg) {
|
|
572
|
+
context.stderr.write('Usage: liftoff migrate <path-to-existing-project>\n');
|
|
573
|
+
return 1;
|
|
574
|
+
}
|
|
575
|
+
const sourceRoot = path.resolve(context.cwd, sourceArg);
|
|
576
|
+
let sourceDetails;
|
|
577
|
+
try {
|
|
578
|
+
sourceDetails = await stat(sourceRoot);
|
|
579
|
+
}
|
|
580
|
+
catch {
|
|
581
|
+
context.stderr.write(`Source project not found: ${sourceRoot}\n`);
|
|
582
|
+
return 1;
|
|
583
|
+
}
|
|
584
|
+
if (!sourceDetails.isDirectory()) {
|
|
585
|
+
context.stderr.write(`Source path is not a directory: ${sourceRoot}\n`);
|
|
586
|
+
return 1;
|
|
587
|
+
}
|
|
588
|
+
if (existsSync(path.join(sourceRoot, 'liftoff.manifest.json'))) {
|
|
589
|
+
context.stderr.write(`${sourceRoot} is already a Liftoff project. Use liftoff update instead.\n`);
|
|
590
|
+
return 1;
|
|
591
|
+
}
|
|
592
|
+
return withUnchangedMigrationSource(sourceRoot, () => executeMigration(parsed, context, sourceRoot));
|
|
593
|
+
}
|
|
243
594
|
function summarizeEntries(entries) {
|
|
244
595
|
const summary = { new: 0, missing: 0, upgrade: 0, conflict: 0, moved: 0, orphan: 0, refresh: 0, unchanged: 0 };
|
|
245
596
|
for (const entry of entries) {
|
|
@@ -340,6 +691,17 @@ async function updateCommand(parsed, context) {
|
|
|
340
691
|
context.stderr.write(`Pattern changes (${manifest.project.pattern ?? 'none'} -> ${plan.pattern?.id ?? 'none'}) are a migration, not an update. Run liftoff migrate instead.\n`);
|
|
341
692
|
return 1;
|
|
342
693
|
}
|
|
694
|
+
if (plan.specWorkflow.id !== manifest.project.specWorkflow) {
|
|
695
|
+
context.stderr.write(`Spec workflow changes (${manifest.project.specWorkflow} -> ${plan.specWorkflow.id}) require official framework initialization and are not supported by liftoff update.\n`);
|
|
696
|
+
return 1;
|
|
697
|
+
}
|
|
698
|
+
const configuredAgents = plan.agents.map((agent) => agent.id);
|
|
699
|
+
if (manifest.framework.state === 'initialized' && (configuredAgents.length !== manifest.project.agents.length ||
|
|
700
|
+
configuredAgents.some((agent, index) => agent !== manifest.project.agents[index]) ||
|
|
701
|
+
plan.defaultAgent?.id !== manifest.project.defaultAgent)) {
|
|
702
|
+
context.stderr.write('AI agent or default-agent changes require official framework initialization and are not supported by liftoff update. Restore liftoff.config.json to the integrations recorded in liftoff.manifest.json.\n');
|
|
703
|
+
return 1;
|
|
704
|
+
}
|
|
343
705
|
const render = buildArtifacts(plan);
|
|
344
706
|
const entries = await reconcileProject(manifest, render, projectRoot);
|
|
345
707
|
const summary = summarizeEntries(entries);
|
|
@@ -420,7 +782,16 @@ async function updateCommand(parsed, context) {
|
|
|
420
782
|
}
|
|
421
783
|
const oldByName = new Map(manifest.artifacts.map((artifact) => [artifact.logicalName, artifact]));
|
|
422
784
|
const skippedByName = new Map(skipped.map((entry) => [entry.logicalName, entry]));
|
|
423
|
-
const nextManifest = buildManifest(plan, render.filter((artifact) => artifact.logicalName !== 'manifest'));
|
|
785
|
+
const nextManifest = buildManifest(plan, render.filter((artifact) => artifact.logicalName !== 'manifest'), { frameworkState: manifest.framework.state });
|
|
786
|
+
nextManifest.framework = manifest.framework;
|
|
787
|
+
nextManifest.project.specWorkflow = manifest.project.specWorkflow;
|
|
788
|
+
nextManifest.project.agents = manifest.project.agents;
|
|
789
|
+
if (manifest.project.defaultAgent) {
|
|
790
|
+
nextManifest.project.defaultAgent = manifest.project.defaultAgent;
|
|
791
|
+
}
|
|
792
|
+
else {
|
|
793
|
+
delete nextManifest.project.defaultAgent;
|
|
794
|
+
}
|
|
424
795
|
nextManifest.artifacts = nextManifest.artifacts.flatMap((artifact) => {
|
|
425
796
|
// config is user-owned after create: carry the recorded entry forward untouched
|
|
426
797
|
if (artifact.logicalName === 'liftoff-config') {
|
|
@@ -467,13 +838,6 @@ async function updateCommand(parsed, context) {
|
|
|
467
838
|
context.stdout.write(`Updated: ${written.length} written, ${skipped.length} skipped, ${summary.orphan} orphan(s). Manifest recorded at ${liftoffVersion}.\n`);
|
|
468
839
|
return 0;
|
|
469
840
|
}
|
|
470
|
-
function binaryCheck(command, args, remedy, cwd) {
|
|
471
|
-
const result = spawnSync(command, args, { cwd, encoding: 'utf8' });
|
|
472
|
-
if (result.status === 0) {
|
|
473
|
-
return { label: command, severity: 'ok', detail: (result.stdout || result.stderr).split('\n')[0].trim() };
|
|
474
|
-
}
|
|
475
|
-
return { label: command, severity: 'fail', detail: 'not found', remedy };
|
|
476
|
-
}
|
|
477
841
|
function versionedBinaryCheck(label, command, args, minimum, remedy) {
|
|
478
842
|
const result = spawnSync(command, args, { encoding: 'utf8' });
|
|
479
843
|
if (result.status !== 0) {
|
|
@@ -503,25 +867,121 @@ function pythonRuntime() {
|
|
|
503
867
|
];
|
|
504
868
|
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));
|
|
505
869
|
}
|
|
506
|
-
function
|
|
507
|
-
const
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
: [python
|
|
513
|
-
? versionedBinaryCheck('python', python.command, python.versionArgs, [3, 12], 'install Python 3.12 or newer')
|
|
514
|
-
: { label: 'python', severity: 'fail', detail: 'not found', remedy: 'install Python 3.12 or newer' }];
|
|
870
|
+
function doctorCheckFromProbe(probe) {
|
|
871
|
+
const severity = probe.state === 'ready'
|
|
872
|
+
? 'ok'
|
|
873
|
+
: probe.requirement.severity === 'blocking'
|
|
874
|
+
? 'fail'
|
|
875
|
+
: 'warn';
|
|
515
876
|
return {
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
877
|
+
id: probe.requirement.id,
|
|
878
|
+
label: probe.requirement.id,
|
|
879
|
+
severity,
|
|
880
|
+
state: probe.state,
|
|
881
|
+
requirementSeverity: probe.requirement.severity,
|
|
882
|
+
detail: probe.detail,
|
|
883
|
+
...(probe.remedy ? { remedy: probe.remedy } : {})
|
|
884
|
+
};
|
|
885
|
+
}
|
|
886
|
+
function noticeId(requirementId, label) {
|
|
887
|
+
const suffix = label.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '');
|
|
888
|
+
return `${requirementId}:${suffix}`;
|
|
889
|
+
}
|
|
890
|
+
function workstationLayer(probes) {
|
|
891
|
+
const checks = probes.flatMap((probe) => [
|
|
892
|
+
doctorCheckFromProbe(probe),
|
|
893
|
+
...probe.notices.map((notice) => ({
|
|
894
|
+
id: noticeId(probe.requirement.id, notice.label),
|
|
895
|
+
label: notice.label,
|
|
896
|
+
severity: notice.state === 'ready' ? 'ok' : 'warn',
|
|
897
|
+
state: notice.state,
|
|
898
|
+
requirementSeverity: 'advisory',
|
|
899
|
+
detail: notice.detail,
|
|
900
|
+
...(notice.remedy ? { remedy: notice.remedy } : {})
|
|
901
|
+
}))
|
|
902
|
+
]);
|
|
903
|
+
return { title: 'Environment', checks };
|
|
904
|
+
}
|
|
905
|
+
function workstationSelectionFromManifest(manifest) {
|
|
906
|
+
const framework = getFrameworkDefinition(manifest.project.specWorkflow);
|
|
907
|
+
return {
|
|
908
|
+
apiStack: { id: manifest.project.apiStack },
|
|
909
|
+
specWorkflow: { id: manifest.project.specWorkflow },
|
|
910
|
+
framework: { version: framework.version },
|
|
911
|
+
provider: { id: manifest.project.cloud },
|
|
912
|
+
agents: manifest.framework.state === 'initialized'
|
|
913
|
+
? manifest.project.agents.map((id) => {
|
|
914
|
+
const agent = getCodingAgent(id);
|
|
915
|
+
if (!agent) {
|
|
916
|
+
throw new Error(`Manifest references unknown coding agent ${id}.`);
|
|
917
|
+
}
|
|
918
|
+
return { id: agent.id, label: agent.label };
|
|
919
|
+
})
|
|
920
|
+
: []
|
|
523
921
|
};
|
|
524
922
|
}
|
|
923
|
+
async function frameworkDoctorChecks(projectRoot, manifest) {
|
|
924
|
+
if (manifest.framework.state === 'legacy') {
|
|
925
|
+
return [{
|
|
926
|
+
id: 'framework-legacy-state',
|
|
927
|
+
label: 'framework state',
|
|
928
|
+
severity: 'warn',
|
|
929
|
+
state: 'not-observable',
|
|
930
|
+
detail: `Legacy v${manifest.artifactVersion} manifest does not prove official ${manifest.framework.adapter} initialization or configured coding agents.`,
|
|
931
|
+
remedy: 'Reinitialize the framework explicitly before recording a v3 initialized framework contract.'
|
|
932
|
+
}];
|
|
933
|
+
}
|
|
934
|
+
const expected = getFrameworkDefinition(manifest.framework.adapter);
|
|
935
|
+
const frameworkLabel = getSpecWorkflow(manifest.framework.adapter)?.label ?? manifest.framework.adapter;
|
|
936
|
+
const contract = manifest.framework.contractVersion === expected.version
|
|
937
|
+
? {
|
|
938
|
+
id: 'framework-contract',
|
|
939
|
+
label: 'framework contract',
|
|
940
|
+
severity: 'ok',
|
|
941
|
+
state: 'ready',
|
|
942
|
+
detail: `${frameworkLabel} ${expected.version}`
|
|
943
|
+
}
|
|
944
|
+
: {
|
|
945
|
+
id: 'framework-contract',
|
|
946
|
+
label: 'framework contract',
|
|
947
|
+
severity: 'fail',
|
|
948
|
+
state: 'outdated',
|
|
949
|
+
detail: `Manifest records ${manifest.framework.contractVersion}; this Liftoff version requires ${expected.version}.`,
|
|
950
|
+
remedy: `Install ${frameworkLabel} ${expected.version} and reinitialize its integrations.`
|
|
951
|
+
};
|
|
952
|
+
const markerIssues = await validateFrameworkInstallation(projectRoot, {
|
|
953
|
+
workflow: manifest.framework.adapter,
|
|
954
|
+
agents: manifest.project.agents,
|
|
955
|
+
...(manifest.project.defaultAgent ? { defaultAgent: manifest.project.defaultAgent } : {})
|
|
956
|
+
});
|
|
957
|
+
const markers = markerIssues.length === 0
|
|
958
|
+
? {
|
|
959
|
+
id: 'framework-markers',
|
|
960
|
+
label: 'framework markers',
|
|
961
|
+
severity: 'ok',
|
|
962
|
+
state: 'ready',
|
|
963
|
+
detail: `${manifest.project.agents.length} selected integration${manifest.project.agents.length === 1 ? '' : 's'} verified`
|
|
964
|
+
}
|
|
965
|
+
: {
|
|
966
|
+
id: 'framework-markers',
|
|
967
|
+
label: 'framework markers',
|
|
968
|
+
severity: 'fail',
|
|
969
|
+
state: 'unhealthy',
|
|
970
|
+
detail: `${markerIssues.length} issue(s): ${markerIssues[0]}`,
|
|
971
|
+
remedy: `Run the official ${frameworkLabel} initializer for the selected integrations.`
|
|
972
|
+
};
|
|
973
|
+
return [
|
|
974
|
+
contract,
|
|
975
|
+
{
|
|
976
|
+
id: 'selected-agents',
|
|
977
|
+
label: 'selected agents',
|
|
978
|
+
severity: 'ok',
|
|
979
|
+
state: 'ready',
|
|
980
|
+
detail: manifest.project.agents.join(', ')
|
|
981
|
+
},
|
|
982
|
+
markers
|
|
983
|
+
];
|
|
984
|
+
}
|
|
525
985
|
function stackProjectCheck(projectRoot, apiStack) {
|
|
526
986
|
let result;
|
|
527
987
|
switch (apiStack) {
|
|
@@ -564,13 +1024,13 @@ function binaryPresent(command) {
|
|
|
564
1024
|
}
|
|
565
1025
|
function azureCloudChecks() {
|
|
566
1026
|
if (!binaryPresent('az')) {
|
|
567
|
-
return [{ label: 'az', severity: '
|
|
1027
|
+
return [{ label: 'az', severity: 'warn', detail: 'Azure CLI not found', remedy: 'install the Azure CLI' }];
|
|
568
1028
|
}
|
|
569
1029
|
const auth = spawnSync('az', ['account', 'show', '-o', 'none', '--only-show-errors'], { encoding: 'utf8' });
|
|
570
1030
|
if (auth.status === 0) {
|
|
571
1031
|
return [{ label: 'azure auth', severity: 'ok', detail: 'authenticated' }];
|
|
572
1032
|
}
|
|
573
|
-
return [{ label: 'azure auth', severity: '
|
|
1033
|
+
return [{ label: 'azure auth', severity: 'warn', detail: 'not authenticated', remedy: 'run az login' }];
|
|
574
1034
|
}
|
|
575
1035
|
// ponytail: provider-keyed map so aws/gcp checks slot in when their adapters land
|
|
576
1036
|
const CLOUD_CHECKS = {
|
|
@@ -584,11 +1044,10 @@ function cloudLayer(cloud) {
|
|
|
584
1044
|
}
|
|
585
1045
|
async function lookupLatestPublishedVersion() {
|
|
586
1046
|
const registry = process.env.LIFTOFF_REGISTRY ?? 'https://registry.npmjs.org';
|
|
1047
|
+
const controller = new AbortController();
|
|
1048
|
+
const timer = setTimeout(() => controller.abort(), 2000);
|
|
587
1049
|
try {
|
|
588
|
-
const controller = new AbortController();
|
|
589
|
-
const timer = setTimeout(() => controller.abort(), 2000);
|
|
590
1050
|
const response = await fetch(`${registry}/@msn-control%2fliftoff/latest`, { signal: controller.signal });
|
|
591
|
-
clearTimeout(timer);
|
|
592
1051
|
if (!response.ok) {
|
|
593
1052
|
return undefined;
|
|
594
1053
|
}
|
|
@@ -598,6 +1057,34 @@ async function lookupLatestPublishedVersion() {
|
|
|
598
1057
|
catch {
|
|
599
1058
|
return undefined; // offline or unreachable: doctor stays quiet about freshness
|
|
600
1059
|
}
|
|
1060
|
+
finally {
|
|
1061
|
+
clearTimeout(timer);
|
|
1062
|
+
}
|
|
1063
|
+
}
|
|
1064
|
+
async function cliLayer() {
|
|
1065
|
+
const checks = [
|
|
1066
|
+
{ label: 'version', severity: 'ok', detail: `Liftoff ${liftoffVersion}` }
|
|
1067
|
+
];
|
|
1068
|
+
const latest = await lookupLatestPublishedVersion();
|
|
1069
|
+
if (!latest) {
|
|
1070
|
+
return { title: 'CLI', checks };
|
|
1071
|
+
}
|
|
1072
|
+
if (compareSemver(latest, liftoffVersion) > 0) {
|
|
1073
|
+
checks.push({
|
|
1074
|
+
label: 'cli freshness',
|
|
1075
|
+
severity: 'warn',
|
|
1076
|
+
detail: `Liftoff ${latest} is published, this CLI is ${liftoffVersion}`,
|
|
1077
|
+
remedy: `install Liftoff ${latest} from an approved registry that exposes it; where direct public npm access is permitted: npm install -g @msn-control/liftoff@${latest} --registry=https://registry.npmjs.org`
|
|
1078
|
+
});
|
|
1079
|
+
}
|
|
1080
|
+
else {
|
|
1081
|
+
checks.push({
|
|
1082
|
+
label: 'cli freshness',
|
|
1083
|
+
severity: 'ok',
|
|
1084
|
+
detail: `running ${liftoffVersion}, latest stable ${latest}`
|
|
1085
|
+
});
|
|
1086
|
+
}
|
|
1087
|
+
return { title: 'CLI', checks };
|
|
601
1088
|
}
|
|
602
1089
|
async function projectLayer(projectRoot, manifest) {
|
|
603
1090
|
const checks = [];
|
|
@@ -624,16 +1111,8 @@ async function projectLayer(projectRoot, manifest) {
|
|
|
624
1111
|
else {
|
|
625
1112
|
checks.push({ label: 'version', severity: 'ok', detail: `generated by ${manifest.liftoffVersion}, CLI ${liftoffVersion}` });
|
|
626
1113
|
}
|
|
1114
|
+
checks.push(...await frameworkDoctorChecks(projectRoot, manifest));
|
|
627
1115
|
checks.push(stackProjectCheck(projectRoot, manifest.project.apiStack));
|
|
628
|
-
const latest = await lookupLatestPublishedVersion();
|
|
629
|
-
if (latest && compareSemver(latest, liftoffVersion) > 0) {
|
|
630
|
-
checks.push({
|
|
631
|
-
label: 'cli freshness',
|
|
632
|
-
severity: 'warn',
|
|
633
|
-
detail: `Liftoff ${latest} is published, this CLI is ${liftoffVersion}`,
|
|
634
|
-
remedy: 'npm install -g @msn-control/liftoff@latest'
|
|
635
|
-
});
|
|
636
|
-
}
|
|
637
1116
|
try {
|
|
638
1117
|
const config = await loadConfigOptions('liftoff.config.json', projectRoot);
|
|
639
1118
|
const plan = buildProjectPlan(config, { requireProjectName: true });
|
|
@@ -730,9 +1209,15 @@ async function doctorCommand(parsed, context) {
|
|
|
730
1209
|
manifestError = error;
|
|
731
1210
|
}
|
|
732
1211
|
}
|
|
733
|
-
|
|
1212
|
+
layers.push(await cliLayer());
|
|
1213
|
+
const runner = context.runner ?? new NodeCommandRunner();
|
|
1214
|
+
const requirements = manifest
|
|
1215
|
+
? selectWorkstationRequirements(workstationSelectionFromManifest(manifest), { includeFramework: manifest.framework.state === 'initialized' })
|
|
1216
|
+
: selectLiftoffRuntimeRequirements();
|
|
1217
|
+
const probes = await probeWorkstation(requirements, runner);
|
|
1218
|
+
const environment = workstationLayer(probes);
|
|
734
1219
|
layers.push(environment);
|
|
735
|
-
const dockerAvailable =
|
|
1220
|
+
const dockerAvailable = probes.some((probe) => probe.requirement.id === 'docker' && probe.state === 'ready');
|
|
736
1221
|
if (projectRoot) {
|
|
737
1222
|
if (manifestError) {
|
|
738
1223
|
layers.push({
|
|
@@ -817,32 +1302,35 @@ async function optionsFromParsedArgs(parsed, cwd, includeProjectName) {
|
|
|
817
1302
|
includeFrontend: readBooleanFlag(parsed.flags, 'frontend'),
|
|
818
1303
|
environments: readListFlag(parsed.flags, 'environments'),
|
|
819
1304
|
specWorkflow: readStringFlag(parsed.flags, 'spec'),
|
|
1305
|
+
agents: readListFlag(parsed.flags, 'agents'),
|
|
1306
|
+
defaultAgent: readStringFlag(parsed.flags, 'default-agent'),
|
|
820
1307
|
configPath,
|
|
821
|
-
yes: readBooleanFlag(parsed.flags, 'yes') ?? false
|
|
1308
|
+
yes: readBooleanFlag(parsed.flags, 'yes') ?? false,
|
|
1309
|
+
force: readBooleanFlag(parsed.flags, 'force'),
|
|
1310
|
+
installTools: readBooleanFlag(parsed.flags, 'install-tools'),
|
|
1311
|
+
installDependencies: readBooleanFlag(parsed.flags, 'install-dependencies')
|
|
822
1312
|
};
|
|
823
1313
|
return mergeOptions(configOptions, flagOptions);
|
|
824
1314
|
}
|
|
825
|
-
function
|
|
1315
|
+
function hasMissingInitInputs(options) {
|
|
826
1316
|
const projectType = options.projectType ?? (options.pattern ? 'genai' : options.apiStack ? 'standard' : undefined);
|
|
827
1317
|
const missingTypeSpecific = projectType === 'genai' ? !options.pattern : projectType === 'standard' ? !options.apiStack : true;
|
|
828
|
-
|
|
1318
|
+
const missingDefaultAgent = options.specWorkflow === 'spec-kit' &&
|
|
1319
|
+
(options.agents?.length ?? 0) > 1 &&
|
|
1320
|
+
!options.defaultAgent;
|
|
1321
|
+
return !options.projectName ||
|
|
1322
|
+
missingTypeSpecific ||
|
|
1323
|
+
!options.cloud ||
|
|
1324
|
+
options.includeFrontend === undefined ||
|
|
1325
|
+
!options.specWorkflow ||
|
|
1326
|
+
!options.agents ||
|
|
1327
|
+
missingDefaultAgent ||
|
|
1328
|
+
!options.environments;
|
|
829
1329
|
}
|
|
830
1330
|
function printHelp(stream) {
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
stream.write(
|
|
834
|
-
stream.write(` plan Preview generated artifacts\n`);
|
|
835
|
-
stream.write(` patterns List GenAI patterns\n`);
|
|
836
|
-
stream.write(` providers List cloud providers\n`);
|
|
837
|
-
stream.write(` regions List or search provider regions\n`);
|
|
838
|
-
stream.write(` validate Validate a generated project manifest\n`);
|
|
839
|
-
stream.write(` update Reconcile a project with the current templates (check by default; --apply, --force)\n`);
|
|
840
|
-
stream.write(` migrate Adopt an existing project: fresh scaffold + staged copy + migration plan\n`);
|
|
841
|
-
stream.write(` doctor Check local readiness\n`);
|
|
842
|
-
stream.write(` dev Print Docker Compose helper commands\n`);
|
|
843
|
-
stream.write(` infra Print OpenTofu helper commands\n`);
|
|
844
|
-
stream.write(`\nProject types: GenAI (Python/FastAPI/PydanticAI) or standard API\n`);
|
|
845
|
-
stream.write(`Standard API stacks: ${apiStacks.map((stack) => stack.label).join(', ')}\n`);
|
|
1331
|
+
const renderer = new TerminalRenderer({ stream });
|
|
1332
|
+
renderer.write(renderer.banner('Project workstation and scaffold initializer'));
|
|
1333
|
+
stream.write(formatGeneralHelp(liftoffVersion));
|
|
846
1334
|
}
|
|
847
1335
|
export async function createFixtureProject(options) {
|
|
848
1336
|
const tempRoot = await mkdtemp(path.join(os.tmpdir(), 'liftoff-'));
|
|
@@ -852,6 +1340,27 @@ export async function createFixtureProject(options) {
|
|
|
852
1340
|
await assertNewOrEmptyDirectory(target);
|
|
853
1341
|
await rm(target, { recursive: true, force: true });
|
|
854
1342
|
await writeArtifacts(target, buildArtifacts(plan));
|
|
1343
|
+
for (const marker of [
|
|
1344
|
+
...plan.framework.baseMarkers,
|
|
1345
|
+
...plan.agents.flatMap((agent) => plan.framework.agentMarkers[agent.id])
|
|
1346
|
+
]) {
|
|
1347
|
+
let content = 'fixture marker\n';
|
|
1348
|
+
if (marker.join('/') === '.specify/integration.json') {
|
|
1349
|
+
const installed = plan.agents.map((agent) => agent.integrationIds['spec-kit']);
|
|
1350
|
+
const defaultIntegration = plan.defaultAgent?.integrationIds['spec-kit'];
|
|
1351
|
+
content = `${JSON.stringify({
|
|
1352
|
+
integration_state_schema: 1,
|
|
1353
|
+
integration: defaultIntegration,
|
|
1354
|
+
default_integration: defaultIntegration,
|
|
1355
|
+
installed_integrations: installed,
|
|
1356
|
+
integration_settings: {}
|
|
1357
|
+
}, null, 2)}\n`;
|
|
1358
|
+
}
|
|
1359
|
+
else if (marker.join('/') === '.specify/init-options.json') {
|
|
1360
|
+
content = '{}\n';
|
|
1361
|
+
}
|
|
1362
|
+
await writeProjectFile(target, marker, content);
|
|
1363
|
+
}
|
|
855
1364
|
return target;
|
|
856
1365
|
}
|
|
857
1366
|
//# sourceMappingURL=commands.js.map
|