@msn-control/liftoff 0.3.4 → 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 +55 -37
- 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 +110 -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 +599 -115
- 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/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 +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 +8 -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')) {
|
|
@@ -34,8 +42,8 @@ export async function runCommand(parsed, context) {
|
|
|
34
42
|
case 'version':
|
|
35
43
|
context.stdout.write(`Liftoff ${liftoffVersion}\n`);
|
|
36
44
|
return 0;
|
|
37
|
-
case '
|
|
38
|
-
return await
|
|
45
|
+
case 'init':
|
|
46
|
+
return await initCommand(parsed, context);
|
|
39
47
|
case 'plan':
|
|
40
48
|
return await planCommand(parsed, context);
|
|
41
49
|
case 'patterns':
|
|
@@ -71,25 +79,79 @@ export async function runCommand(parsed, context) {
|
|
|
71
79
|
return 1;
|
|
72
80
|
}
|
|
73
81
|
}
|
|
74
|
-
async function
|
|
75
|
-
const
|
|
76
|
-
const
|
|
77
|
-
|
|
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;
|
|
78
91
|
const plan = buildProjectPlan(options, { requireProjectName: true });
|
|
79
92
|
const confirmed = await confirmPlan(plan, options.yes);
|
|
80
93
|
if (!confirmed) {
|
|
81
|
-
context.stdout.write('
|
|
94
|
+
context.stdout.write('Initialization cancelled.\n');
|
|
82
95
|
return 0;
|
|
83
96
|
}
|
|
84
|
-
const
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
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);
|
|
88
135
|
if (issues.length > 0) {
|
|
89
|
-
context.stderr.write(`
|
|
136
|
+
context.stderr.write(`Initialized project validation failed:\n${issues.join('\n')}\n`);
|
|
90
137
|
return 1;
|
|
91
138
|
}
|
|
92
|
-
|
|
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)}`));
|
|
93
155
|
return 0;
|
|
94
156
|
}
|
|
95
157
|
async function planCommand(parsed, context) {
|
|
@@ -100,8 +162,151 @@ async function planCommand(parsed, context) {
|
|
|
100
162
|
for (const artifact of artifacts) {
|
|
101
163
|
context.stdout.write(`- ${artifact.logicalName}: ${artifact.pathParts.join('/')}\n`);
|
|
102
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
|
+
}
|
|
103
174
|
return 0;
|
|
104
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
|
+
}
|
|
105
310
|
function patternsCommand(context) {
|
|
106
311
|
context.stdout.write('Patterns:\n');
|
|
107
312
|
for (const pattern of patterns) {
|
|
@@ -143,29 +348,112 @@ async function validateCommand(parsed, context) {
|
|
|
143
348
|
return 0;
|
|
144
349
|
}
|
|
145
350
|
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
|
-
|
|
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;
|
|
154
395
|
try {
|
|
155
|
-
|
|
396
|
+
result = await operation();
|
|
156
397
|
}
|
|
157
|
-
catch {
|
|
158
|
-
|
|
159
|
-
|
|
398
|
+
catch (error) {
|
|
399
|
+
await assertUnchanged();
|
|
400
|
+
throw error;
|
|
160
401
|
}
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
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
|
+
}
|
|
164
423
|
}
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
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
|
+
};
|
|
168
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) {
|
|
169
457
|
const inventory = await scanLegacyProject(sourceRoot);
|
|
170
458
|
const { options: defaults, provenance } = scanDefaults(inventory);
|
|
171
459
|
context.stdout.write('Scan defaults (override in prompts or with flags):\n');
|
|
@@ -191,8 +479,8 @@ async function migrateCommand(parsed, context) {
|
|
|
191
479
|
if (flagOptions.projectType === 'genai' && flagOptions.apiStack === undefined) {
|
|
192
480
|
initial.apiStack = undefined;
|
|
193
481
|
}
|
|
194
|
-
const needsPrompts = !initial.yes &&
|
|
195
|
-
const options = needsPrompts ? await
|
|
482
|
+
const needsPrompts = !initial.yes && hasMissingInitInputs(initial);
|
|
483
|
+
const options = needsPrompts ? await promptForInitOptions(initial) : initial;
|
|
196
484
|
const plan = buildProjectPlan(options, { requireProjectName: true });
|
|
197
485
|
const confirmed = await confirmPlan(plan, options.yes);
|
|
198
486
|
if (!confirmed) {
|
|
@@ -204,45 +492,105 @@ async function migrateCommand(parsed, context) {
|
|
|
204
492
|
if (targetRoot === sourceRoot) {
|
|
205
493
|
targetRoot = path.resolve(parentDir, `${plan.safeProjectName}-liftoff`);
|
|
206
494
|
}
|
|
207
|
-
const
|
|
208
|
-
await
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
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) {
|
|
212
511
|
return 1;
|
|
213
512
|
}
|
|
214
|
-
const
|
|
215
|
-
await
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
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')}`);
|
|
223
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 });
|
|
224
541
|
});
|
|
225
|
-
const
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
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)';
|
|
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;
|
|
233
546
|
}
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
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));
|
|
237
561
|
}
|
|
238
|
-
context.stdout.write(`Created ${plan.projectName} at ${targetRoot}\n\n`);
|
|
239
562
|
context.stdout.write('Next steps:\n');
|
|
240
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`);
|
|
241
|
-
context.stdout.write(` 2. Execute the migration plan: ${
|
|
564
|
+
context.stdout.write(` 2. Execute the migration plan: ${migrationPlan.location}\n`);
|
|
242
565
|
context.stdout.write(' 3. Verify compliance: liftoff validate && liftoff doctor\n');
|
|
243
566
|
context.stdout.write(`The source project was not modified. Rolling back is deleting ${targetRoot}.\n`);
|
|
244
567
|
return 0;
|
|
245
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
|
+
}
|
|
246
594
|
function summarizeEntries(entries) {
|
|
247
595
|
const summary = { new: 0, missing: 0, upgrade: 0, conflict: 0, moved: 0, orphan: 0, refresh: 0, unchanged: 0 };
|
|
248
596
|
for (const entry of entries) {
|
|
@@ -343,6 +691,17 @@ async function updateCommand(parsed, context) {
|
|
|
343
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`);
|
|
344
692
|
return 1;
|
|
345
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
|
+
}
|
|
346
705
|
const render = buildArtifacts(plan);
|
|
347
706
|
const entries = await reconcileProject(manifest, render, projectRoot);
|
|
348
707
|
const summary = summarizeEntries(entries);
|
|
@@ -423,7 +782,16 @@ async function updateCommand(parsed, context) {
|
|
|
423
782
|
}
|
|
424
783
|
const oldByName = new Map(manifest.artifacts.map((artifact) => [artifact.logicalName, artifact]));
|
|
425
784
|
const skippedByName = new Map(skipped.map((entry) => [entry.logicalName, entry]));
|
|
426
|
-
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
|
+
}
|
|
427
795
|
nextManifest.artifacts = nextManifest.artifacts.flatMap((artifact) => {
|
|
428
796
|
// config is user-owned after create: carry the recorded entry forward untouched
|
|
429
797
|
if (artifact.logicalName === 'liftoff-config') {
|
|
@@ -470,13 +838,6 @@ async function updateCommand(parsed, context) {
|
|
|
470
838
|
context.stdout.write(`Updated: ${written.length} written, ${skipped.length} skipped, ${summary.orphan} orphan(s). Manifest recorded at ${liftoffVersion}.\n`);
|
|
471
839
|
return 0;
|
|
472
840
|
}
|
|
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
841
|
function versionedBinaryCheck(label, command, args, minimum, remedy) {
|
|
481
842
|
const result = spawnSync(command, args, { encoding: 'utf8' });
|
|
482
843
|
if (result.status !== 0) {
|
|
@@ -506,25 +867,121 @@ function pythonRuntime() {
|
|
|
506
867
|
];
|
|
507
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));
|
|
508
869
|
}
|
|
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' }];
|
|
870
|
+
function doctorCheckFromProbe(probe) {
|
|
871
|
+
const severity = probe.state === 'ready'
|
|
872
|
+
? 'ok'
|
|
873
|
+
: probe.requirement.severity === 'blocking'
|
|
874
|
+
? 'fail'
|
|
875
|
+
: 'warn';
|
|
518
876
|
return {
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
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 } : {})
|
|
526
884
|
};
|
|
527
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
|
+
: []
|
|
921
|
+
};
|
|
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
|
+
}
|
|
528
985
|
function stackProjectCheck(projectRoot, apiStack) {
|
|
529
986
|
let result;
|
|
530
987
|
switch (apiStack) {
|
|
@@ -567,13 +1024,13 @@ function binaryPresent(command) {
|
|
|
567
1024
|
}
|
|
568
1025
|
function azureCloudChecks() {
|
|
569
1026
|
if (!binaryPresent('az')) {
|
|
570
|
-
return [{ label: 'az', severity: '
|
|
1027
|
+
return [{ label: 'az', severity: 'warn', detail: 'Azure CLI not found', remedy: 'install the Azure CLI' }];
|
|
571
1028
|
}
|
|
572
1029
|
const auth = spawnSync('az', ['account', 'show', '-o', 'none', '--only-show-errors'], { encoding: 'utf8' });
|
|
573
1030
|
if (auth.status === 0) {
|
|
574
1031
|
return [{ label: 'azure auth', severity: 'ok', detail: 'authenticated' }];
|
|
575
1032
|
}
|
|
576
|
-
return [{ label: 'azure auth', severity: '
|
|
1033
|
+
return [{ label: 'azure auth', severity: 'warn', detail: 'not authenticated', remedy: 'run az login' }];
|
|
577
1034
|
}
|
|
578
1035
|
// ponytail: provider-keyed map so aws/gcp checks slot in when their adapters land
|
|
579
1036
|
const CLOUD_CHECKS = {
|
|
@@ -654,6 +1111,7 @@ async function projectLayer(projectRoot, manifest) {
|
|
|
654
1111
|
else {
|
|
655
1112
|
checks.push({ label: 'version', severity: 'ok', detail: `generated by ${manifest.liftoffVersion}, CLI ${liftoffVersion}` });
|
|
656
1113
|
}
|
|
1114
|
+
checks.push(...await frameworkDoctorChecks(projectRoot, manifest));
|
|
657
1115
|
checks.push(stackProjectCheck(projectRoot, manifest.project.apiStack));
|
|
658
1116
|
try {
|
|
659
1117
|
const config = await loadConfigOptions('liftoff.config.json', projectRoot);
|
|
@@ -752,9 +1210,14 @@ async function doctorCommand(parsed, context) {
|
|
|
752
1210
|
}
|
|
753
1211
|
}
|
|
754
1212
|
layers.push(await cliLayer());
|
|
755
|
-
const
|
|
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);
|
|
756
1219
|
layers.push(environment);
|
|
757
|
-
const dockerAvailable =
|
|
1220
|
+
const dockerAvailable = probes.some((probe) => probe.requirement.id === 'docker' && probe.state === 'ready');
|
|
758
1221
|
if (projectRoot) {
|
|
759
1222
|
if (manifestError) {
|
|
760
1223
|
layers.push({
|
|
@@ -839,35 +1302,35 @@ async function optionsFromParsedArgs(parsed, cwd, includeProjectName) {
|
|
|
839
1302
|
includeFrontend: readBooleanFlag(parsed.flags, 'frontend'),
|
|
840
1303
|
environments: readListFlag(parsed.flags, 'environments'),
|
|
841
1304
|
specWorkflow: readStringFlag(parsed.flags, 'spec'),
|
|
1305
|
+
agents: readListFlag(parsed.flags, 'agents'),
|
|
1306
|
+
defaultAgent: readStringFlag(parsed.flags, 'default-agent'),
|
|
842
1307
|
configPath,
|
|
843
|
-
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')
|
|
844
1312
|
};
|
|
845
1313
|
return mergeOptions(configOptions, flagOptions);
|
|
846
1314
|
}
|
|
847
|
-
function
|
|
1315
|
+
function hasMissingInitInputs(options) {
|
|
848
1316
|
const projectType = options.projectType ?? (options.pattern ? 'genai' : options.apiStack ? 'standard' : undefined);
|
|
849
1317
|
const missingTypeSpecific = projectType === 'genai' ? !options.pattern : projectType === 'standard' ? !options.apiStack : true;
|
|
850
|
-
|
|
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;
|
|
851
1329
|
}
|
|
852
1330
|
function printHelp(stream) {
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
stream.write(
|
|
856
|
-
stream.write(` --help Show general help\n\n`);
|
|
857
|
-
stream.write(`Commands:\n`);
|
|
858
|
-
stream.write(` create Generate a new project\n`);
|
|
859
|
-
stream.write(` plan Preview generated artifacts\n`);
|
|
860
|
-
stream.write(` patterns List GenAI patterns\n`);
|
|
861
|
-
stream.write(` providers List cloud providers\n`);
|
|
862
|
-
stream.write(` regions List or search provider regions\n`);
|
|
863
|
-
stream.write(` validate Validate a generated project manifest\n`);
|
|
864
|
-
stream.write(` update Reconcile a project with the current templates (check by default; --apply, --force)\n`);
|
|
865
|
-
stream.write(` migrate Adopt an existing project: fresh scaffold + staged copy + migration plan\n`);
|
|
866
|
-
stream.write(` doctor Check local readiness\n`);
|
|
867
|
-
stream.write(` dev Print Docker Compose helper commands\n`);
|
|
868
|
-
stream.write(` infra Print OpenTofu helper commands\n`);
|
|
869
|
-
stream.write(`\nProject types: GenAI (Python/FastAPI/PydanticAI) or standard API\n`);
|
|
870
|
-
stream.write(`Standard API stacks: ${apiStacks.map((stack) => stack.label).join(', ')}\n`);
|
|
1331
|
+
const renderer = new TerminalRenderer({ stream });
|
|
1332
|
+
renderer.write(renderer.banner('Project workstation and scaffold initializer'));
|
|
1333
|
+
stream.write(formatGeneralHelp(liftoffVersion));
|
|
871
1334
|
}
|
|
872
1335
|
export async function createFixtureProject(options) {
|
|
873
1336
|
const tempRoot = await mkdtemp(path.join(os.tmpdir(), 'liftoff-'));
|
|
@@ -877,6 +1340,27 @@ export async function createFixtureProject(options) {
|
|
|
877
1340
|
await assertNewOrEmptyDirectory(target);
|
|
878
1341
|
await rm(target, { recursive: true, force: true });
|
|
879
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
|
+
}
|
|
880
1364
|
return target;
|
|
881
1365
|
}
|
|
882
1366
|
//# sourceMappingURL=commands.js.map
|