@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.
Files changed (68) hide show
  1. package/README.md +66 -42
  2. package/assets/locks/frontend/package-lock.json +2217 -0
  3. package/assets/locks/frontend/package.json +19 -0
  4. package/assets/locks/node-backend/package-lock.json +4352 -0
  5. package/assets/locks/node-backend/package.json +32 -0
  6. package/dist/args.d.ts +9 -1
  7. package/dist/args.js +113 -33
  8. package/dist/args.js.map +1 -1
  9. package/dist/catalogs.d.ts +10 -1
  10. package/dist/catalogs.js +89 -0
  11. package/dist/catalogs.js.map +1 -1
  12. package/dist/cli.js +5 -0
  13. package/dist/cli.js.map +1 -1
  14. package/dist/commands.d.ts +5 -0
  15. package/dist/commands.js +633 -124
  16. package/dist/commands.js.map +1 -1
  17. package/dist/file-system.js +89 -5
  18. package/dist/file-system.js.map +1 -1
  19. package/dist/framework-adapters.d.ts +15 -0
  20. package/dist/framework-adapters.js +112 -0
  21. package/dist/framework-adapters.js.map +1 -0
  22. package/dist/framework-validation.d.ts +8 -0
  23. package/dist/framework-validation.js +83 -0
  24. package/dist/framework-validation.js.map +1 -0
  25. package/dist/init-filesystem.d.ts +102 -0
  26. package/dist/init-filesystem.js +762 -0
  27. package/dist/init-filesystem.js.map +1 -0
  28. package/dist/interactive.d.ts +5 -1
  29. package/dist/interactive.js +75 -4
  30. package/dist/interactive.js.map +1 -1
  31. package/dist/npm-template-assets.d.ts +3 -0
  32. package/dist/npm-template-assets.js +32 -0
  33. package/dist/npm-template-assets.js.map +1 -0
  34. package/dist/planner.js +62 -3
  35. package/dist/planner.js.map +1 -1
  36. package/dist/process-runner.d.ts +28 -0
  37. package/dist/process-runner.js +80 -0
  38. package/dist/process-runner.js.map +1 -0
  39. package/dist/project-dependencies.d.ts +28 -0
  40. package/dist/project-dependencies.js +163 -0
  41. package/dist/project-dependencies.js.map +1 -0
  42. package/dist/published-verifier.d.ts +39 -0
  43. package/dist/published-verifier.js +173 -0
  44. package/dist/published-verifier.js.map +1 -0
  45. package/dist/reconcile.js +4 -1
  46. package/dist/reconcile.js.map +1 -1
  47. package/dist/release-identity.d.ts +17 -0
  48. package/dist/release-identity.js +51 -0
  49. package/dist/release-identity.js.map +1 -0
  50. package/dist/runtime.d.ts +2 -0
  51. package/dist/runtime.js +9 -0
  52. package/dist/runtime.js.map +1 -0
  53. package/dist/standard-templates.js +3 -30
  54. package/dist/standard-templates.js.map +1 -1
  55. package/dist/templates.d.ts +9 -1
  56. package/dist/templates.js +110 -46
  57. package/dist/templates.js.map +1 -1
  58. package/dist/terminal.d.ts +32 -0
  59. package/dist/terminal.js +182 -0
  60. package/dist/terminal.js.map +1 -0
  61. package/dist/types.d.ts +38 -1
  62. package/dist/workstation-catalog.d.ts +20 -0
  63. package/dist/workstation-catalog.js +122 -0
  64. package/dist/workstation-catalog.js.map +1 -0
  65. package/dist/workstation.d.ts +76 -0
  66. package/dist/workstation.js +461 -0
  67. package/dist/workstation.js.map +1 -0
  68. 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, writeFile } from 'node:fs/promises';
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 { apiStacks, listRegions, patterns, providers, searchRegions } from './catalogs.js';
8
- import { artifactPath, assertNewOrEmptyDirectory, deleteProjectFile, findProjectRoot, loadManifest, manifestDisplayPath, resolveProjectPath, resolveTargetRoot, validateGeneratedProject, writeArtifacts, writeProjectFile } from './file-system.js';
9
- import { confirmPlan, promptForCreateOptions } from './interactive.js';
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 'create':
35
- return await createCommand(parsed, context);
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 createCommand(parsed, context) {
72
- const initial = await optionsFromParsedArgs(parsed, context.cwd, true);
73
- const needsPrompts = !initial.yes && hasMissingCreateInputs(initial);
74
- const options = needsPrompts ? await promptForCreateOptions(initial) : initial;
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('Generation cancelled.\n');
94
+ context.stdout.write('Initialization cancelled.\n');
79
95
  return 0;
80
96
  }
81
- const targetRoot = resolveTargetRoot(context.cwd, plan.safeProjectName);
82
- const artifacts = buildArtifacts(plan);
83
- await writeArtifacts(targetRoot, artifacts);
84
- const issues = await validateGeneratedProject(targetRoot);
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(`Generated project validation failed:\n${issues.join('\n')}\n`);
136
+ context.stderr.write(`Initialized project validation failed:\n${issues.join('\n')}\n`);
87
137
  return 1;
88
138
  }
89
- context.stdout.write(`Created ${plan.projectName} at ${targetRoot}\n`);
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 migrateCommand(parsed, context) {
144
- const sourceArg = parsed.positional[0];
145
- if (!sourceArg) {
146
- context.stderr.write('Usage: liftoff migrate <path-to-existing-project>\n');
147
- return 1;
148
- }
149
- const sourceRoot = path.resolve(context.cwd, sourceArg);
150
- let sourceDetails;
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
- sourceDetails = await stat(sourceRoot);
396
+ result = await operation();
153
397
  }
154
- catch {
155
- context.stderr.write(`Source project not found: ${sourceRoot}\n`);
156
- return 1;
398
+ catch (error) {
399
+ await assertUnchanged();
400
+ throw error;
157
401
  }
158
- if (!sourceDetails.isDirectory()) {
159
- context.stderr.write(`Source path is not a directory: ${sourceRoot}\n`);
160
- return 1;
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
- if (existsSync(path.join(sourceRoot, 'liftoff.manifest.json'))) {
163
- context.stderr.write(`${sourceRoot} is already a Liftoff project. Use liftoff update instead.\n`);
164
- return 1;
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 && hasMissingCreateInputs(initial);
192
- const options = needsPrompts ? await promptForCreateOptions(initial) : initial;
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 artifacts = buildArtifacts(plan);
205
- await writeArtifacts(targetRoot, artifacts);
206
- const issues = await validateGeneratedProject(targetRoot);
207
- if (issues.length > 0) {
208
- context.stderr.write(`Generated scaffold validation failed:\n${issues.join('\n')}\n`);
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 stagingRoot = path.join(targetRoot, 'migration', 'legacy');
212
- await cp(sourceRoot, stagingRoot, {
213
- recursive: true,
214
- filter: (source) => {
215
- const relative = path.relative(sourceRoot, source);
216
- if (!relative) {
217
- return true;
218
- }
219
- return !relative.split(path.sep).some((part) => STAGING_EXCLUDES.has(part));
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 groups = seedMigrationGroups(inventory, plan);
223
- let planLocation;
224
- if (plan.specWorkflow.id === 'openspec') {
225
- const changeDir = path.join(targetRoot, 'openspec', 'changes', 'migrate-to-liftoff');
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
- else {
232
- await writeFile(path.join(targetRoot, 'MIGRATION.md'), renderMigrationChecklist(plan, inventory, groups), 'utf8');
233
- planLocation = 'MIGRATION.md';
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: ${planLocation}\n`);
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 environmentLayer(apiStack) {
507
- const python = pythonRuntime();
508
- const runtimeChecks = apiStack === 'go-huma'
509
- ? [versionedBinaryCheck('go', 'go', ['version'], [1, 23], 'install Go 1.23 or newer')]
510
- : apiStack === 'node-fastify'
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
- title: 'Environment',
517
- checks: [
518
- versionedBinaryCheck('node', 'node', ['--version'], [20, 0], 'install Node.js 20 or newer'),
519
- ...runtimeChecks,
520
- binaryCheck('docker', ['--version'], 'install Docker'),
521
- binaryCheck('tofu', ['--version'], 'install OpenTofu')
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: 'fail', detail: 'Azure CLI not found', remedy: 'install the Azure CLI' }];
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: 'fail', detail: 'not authenticated', remedy: 'run az login' }];
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
- const environment = environmentLayer(manifest?.project.apiStack);
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 = environment.checks.some((check) => check.label === 'docker' && check.severity === 'ok');
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 hasMissingCreateInputs(options) {
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
- return !options.projectName || missingTypeSpecific || !options.cloud || options.includeFrontend === undefined || !options.specWorkflow || !options.environments;
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
- stream.write(`Mission Control Liftoff\n\n`);
832
- stream.write(`Commands:\n`);
833
- stream.write(` create Generate a new project\n`);
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