@msn-control/liftoff 0.1.2 → 0.2.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 +12 -0
- package/dist/cli.js +0 -0
- package/dist/commands.d.ts +12 -0
- package/dist/commands.js +509 -26
- package/dist/commands.js.map +1 -1
- package/dist/file-system.d.ts +6 -1
- package/dist/file-system.js +48 -4
- package/dist/file-system.js.map +1 -1
- package/dist/migrate-plan.d.ts +10 -0
- package/dist/migrate-plan.js +85 -0
- package/dist/migrate-plan.js.map +1 -0
- package/dist/reconcile.d.ts +14 -0
- package/dist/reconcile.js +0 -0
- package/dist/reconcile.js.map +1 -0
- package/dist/scan.d.ts +21 -0
- package/dist/scan.js +140 -0
- package/dist/scan.js.map +1 -0
- package/dist/semver.d.ts +1 -0
- package/dist/semver.js +53 -0
- package/dist/semver.js.map +1 -0
- package/dist/templates.js +8 -2
- package/dist/templates.js.map +1 -1
- package/dist/types.d.ts +3 -1
- package/dist/version.d.ts +1 -0
- package/dist/version.js +8 -0
- package/dist/version.js.map +1 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -19,6 +19,18 @@ liftoff help
|
|
|
19
19
|
liftoff create
|
|
20
20
|
```
|
|
21
21
|
|
|
22
|
+
## Contract conventions
|
|
23
|
+
|
|
24
|
+
Generated projects contain persistent files that outlive any CLI release. The following rules are the compatibility contract, enforced by `tests/contract.test.ts` where possible:
|
|
25
|
+
|
|
26
|
+
- **Manifest schema**: `liftoff.manifest.json` uses `artifactVersion` 2 — the first supported schema version — recording the generating CLI version (`liftoffVersion`) and a `sha256:`-prefixed `contentHash` per artifact. Readers accept every supported version and reject others with a remedy; writers always write the latest version.
|
|
27
|
+
- **Append-only identifiers**: artifact `logicalName`s and catalog ids (patterns, providers, environments, spec workflows) are never renamed or removed, only added. The contract test snapshots the logical-name sets.
|
|
28
|
+
- **Deterministic rendering**: artifact content depends only on the project plan and the template code — no timestamps, randomness, or environment leakage. Verified by a double-render byte-equality test.
|
|
29
|
+
- **Reserved namespaces**: `.liftoff/` in generated projects is reserved for future CLI-managed state; no new CLI-managed root-level files beyond `liftoff.config.json` and `liftoff.manifest.json`. `liftoff.config.json` is written once at generation and never machine-written afterwards.
|
|
30
|
+
- **Portable paths**: machine-readable files store OS-neutral path-part arrays, never joined path strings.
|
|
31
|
+
- **Exit codes**: 0 = success or clean check, 1 = failure, 2 = a check mode found drift.
|
|
32
|
+
- **Machine output**: every `--json` output carries a top-level numeric `schemaVersion`.
|
|
33
|
+
|
|
22
34
|
## Development
|
|
23
35
|
|
|
24
36
|
Repository-local commands are for contributors working from a Mission Control checkout. Run package commands from the repository root:
|
package/dist/cli.js
CHANGED
|
File without changes
|
package/dist/commands.d.ts
CHANGED
|
@@ -5,4 +5,16 @@ export interface CommandContext {
|
|
|
5
5
|
stderr: NodeJS.WritableStream;
|
|
6
6
|
}
|
|
7
7
|
export declare function runCommand(parsed: ParsedArgs, context: CommandContext): Promise<number>;
|
|
8
|
+
interface DoctorCheck {
|
|
9
|
+
label: string;
|
|
10
|
+
severity: 'ok' | 'warn' | 'fail' | 'skipped';
|
|
11
|
+
detail: string;
|
|
12
|
+
remedy?: string;
|
|
13
|
+
}
|
|
14
|
+
interface DoctorLayer {
|
|
15
|
+
title: string;
|
|
16
|
+
checks: DoctorCheck[];
|
|
17
|
+
}
|
|
18
|
+
export declare function doctorExitCode(layers: DoctorLayer[]): number;
|
|
8
19
|
export declare function createFixtureProject(options: ProjectOptions): Promise<string>;
|
|
20
|
+
export {};
|
package/dist/commands.js
CHANGED
|
@@ -1,13 +1,19 @@
|
|
|
1
1
|
import { spawnSync } from 'node:child_process';
|
|
2
|
-
import {
|
|
2
|
+
import { existsSync } from 'node:fs';
|
|
3
|
+
import { cp, mkdir, mkdtemp, rm, stat, writeFile } from 'node:fs/promises';
|
|
3
4
|
import os from 'node:os';
|
|
4
5
|
import path from 'node:path';
|
|
5
6
|
import { readBooleanFlag, readListFlag, readStringFlag } from './args.js';
|
|
6
7
|
import { approvedStack, listRegions, patterns, providers, searchRegions } from './catalogs.js';
|
|
7
|
-
import { artifactPath, assertNewOrEmptyDirectory, resolveTargetRoot, validateGeneratedProject, writeArtifacts } from './file-system.js';
|
|
8
|
+
import { artifactPath, assertNewOrEmptyDirectory, deleteProjectFile, findProjectRoot, loadManifest, manifestDisplayPath, resolveTargetRoot, validateGeneratedProject, writeArtifacts, writeProjectFile } from './file-system.js';
|
|
8
9
|
import { confirmPlan, promptForCreateOptions } from './interactive.js';
|
|
10
|
+
import { renderMigrationChecklist, renderMigrationProposal, renderMigrationTasks, seedMigrationGroups } from './migrate-plan.js';
|
|
9
11
|
import { buildProjectPlan, formatProjectPlan, loadConfigOptions, mergeOptions, PlanValidationError } from './planner.js';
|
|
10
|
-
import {
|
|
12
|
+
import { scanDefaults, scanLegacyProject } from './scan.js';
|
|
13
|
+
import { hasDrift, reconcileProject } from './reconcile.js';
|
|
14
|
+
import { compareSemver } from './semver.js';
|
|
15
|
+
import { buildArtifacts, buildManifest } from './templates.js';
|
|
16
|
+
import { liftoffVersion } from './version.js';
|
|
11
17
|
export async function runCommand(parsed, context) {
|
|
12
18
|
try {
|
|
13
19
|
switch (parsed.command) {
|
|
@@ -28,8 +34,12 @@ export async function runCommand(parsed, context) {
|
|
|
28
34
|
return regionsCommand(parsed, context);
|
|
29
35
|
case 'validate':
|
|
30
36
|
return await validateCommand(parsed, context);
|
|
37
|
+
case 'update':
|
|
38
|
+
return await updateCommand(parsed, context);
|
|
39
|
+
case 'migrate':
|
|
40
|
+
return await migrateCommand(parsed, context);
|
|
31
41
|
case 'doctor':
|
|
32
|
-
return doctorCommand(parsed, context);
|
|
42
|
+
return await doctorCommand(parsed, context);
|
|
33
43
|
case 'dev':
|
|
34
44
|
return helperCommand(parsed, context, 'docker compose');
|
|
35
45
|
case 'infra':
|
|
@@ -108,8 +118,10 @@ function regionsCommand(parsed, context) {
|
|
|
108
118
|
return 0;
|
|
109
119
|
}
|
|
110
120
|
async function validateCommand(parsed, context) {
|
|
111
|
-
const
|
|
112
|
-
const projectRoot =
|
|
121
|
+
const explicit = parsed.positional[0] ?? readStringFlag(parsed.flags, 'project');
|
|
122
|
+
const projectRoot = explicit
|
|
123
|
+
? path.resolve(context.cwd, explicit)
|
|
124
|
+
: (await findProjectRoot(context.cwd)) ?? context.cwd;
|
|
113
125
|
const issues = await validateGeneratedProject(projectRoot);
|
|
114
126
|
if (issues.length > 0) {
|
|
115
127
|
context.stderr.write(`${issues.join('\n')}\n`);
|
|
@@ -118,35 +130,504 @@ async function validateCommand(parsed, context) {
|
|
|
118
130
|
context.stdout.write('Generated project manifest is valid.\n');
|
|
119
131
|
return 0;
|
|
120
132
|
}
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
+
const STAGING_EXCLUDES = new Set(['.git', 'node_modules', '.venv', 'venv', '__pycache__', 'dist', 'build', '.next']);
|
|
134
|
+
async function migrateCommand(parsed, context) {
|
|
135
|
+
const sourceArg = parsed.positional[0];
|
|
136
|
+
if (!sourceArg) {
|
|
137
|
+
context.stderr.write('Usage: liftoff migrate <path-to-existing-project>\n');
|
|
138
|
+
return 1;
|
|
139
|
+
}
|
|
140
|
+
const sourceRoot = path.resolve(context.cwd, sourceArg);
|
|
141
|
+
let sourceDetails;
|
|
142
|
+
try {
|
|
143
|
+
sourceDetails = await stat(sourceRoot);
|
|
144
|
+
}
|
|
145
|
+
catch {
|
|
146
|
+
context.stderr.write(`Source project not found: ${sourceRoot}\n`);
|
|
147
|
+
return 1;
|
|
148
|
+
}
|
|
149
|
+
if (!sourceDetails.isDirectory()) {
|
|
150
|
+
context.stderr.write(`Source path is not a directory: ${sourceRoot}\n`);
|
|
151
|
+
return 1;
|
|
152
|
+
}
|
|
153
|
+
if (existsSync(path.join(sourceRoot, 'liftoff.manifest.json'))) {
|
|
154
|
+
context.stderr.write(`${sourceRoot} is already a Liftoff project. Use liftoff update instead.\n`);
|
|
155
|
+
return 1;
|
|
156
|
+
}
|
|
157
|
+
const inventory = await scanLegacyProject(sourceRoot);
|
|
158
|
+
const { options: defaults, provenance } = scanDefaults(inventory);
|
|
159
|
+
context.stdout.write('Scan defaults (override in prompts or with flags):\n');
|
|
160
|
+
for (const item of provenance) {
|
|
161
|
+
context.stdout.write(` - ${item.field}: ${item.value} (detected: ${item.evidence})\n`);
|
|
162
|
+
}
|
|
163
|
+
context.stdout.write('\n');
|
|
164
|
+
const flagOptions = await optionsFromParsedArgs(parsed, context.cwd, false);
|
|
165
|
+
const initial = mergeOptions(defaults, flagOptions);
|
|
166
|
+
const needsPrompts = !initial.yes && hasMissingCreateInputs(initial);
|
|
167
|
+
const options = needsPrompts ? await promptForCreateOptions(initial) : initial;
|
|
168
|
+
const plan = buildProjectPlan(options, { requireProjectName: true });
|
|
169
|
+
const confirmed = await confirmPlan(plan, options.yes);
|
|
170
|
+
if (!confirmed) {
|
|
171
|
+
context.stdout.write('Migration cancelled.\n');
|
|
172
|
+
return 0;
|
|
173
|
+
}
|
|
174
|
+
const parentDir = path.dirname(sourceRoot);
|
|
175
|
+
let targetRoot = path.resolve(parentDir, plan.safeProjectName);
|
|
176
|
+
if (targetRoot === sourceRoot) {
|
|
177
|
+
targetRoot = path.resolve(parentDir, `${plan.safeProjectName}-liftoff`);
|
|
178
|
+
}
|
|
179
|
+
const artifacts = buildArtifacts(plan);
|
|
180
|
+
await writeArtifacts(targetRoot, artifacts);
|
|
181
|
+
const issues = await validateGeneratedProject(targetRoot);
|
|
182
|
+
if (issues.length > 0) {
|
|
183
|
+
context.stderr.write(`Generated scaffold validation failed:\n${issues.join('\n')}\n`);
|
|
184
|
+
return 1;
|
|
185
|
+
}
|
|
186
|
+
const stagingRoot = path.join(targetRoot, 'migration', 'legacy');
|
|
187
|
+
await cp(sourceRoot, stagingRoot, {
|
|
188
|
+
recursive: true,
|
|
189
|
+
filter: (source) => {
|
|
190
|
+
const relative = path.relative(sourceRoot, source);
|
|
191
|
+
if (!relative) {
|
|
192
|
+
return true;
|
|
193
|
+
}
|
|
194
|
+
return !relative.split(path.sep).some((part) => STAGING_EXCLUDES.has(part));
|
|
195
|
+
}
|
|
196
|
+
});
|
|
197
|
+
const groups = seedMigrationGroups(inventory);
|
|
198
|
+
let planLocation;
|
|
199
|
+
if (plan.specWorkflow.id === 'openspec') {
|
|
200
|
+
const changeDir = path.join(targetRoot, 'openspec', 'changes', 'migrate-to-liftoff');
|
|
201
|
+
await mkdir(changeDir, { recursive: true });
|
|
202
|
+
await writeFile(path.join(changeDir, 'proposal.md'), renderMigrationProposal(plan, inventory), 'utf8');
|
|
203
|
+
await writeFile(path.join(changeDir, 'tasks.md'), renderMigrationTasks(groups), 'utf8');
|
|
204
|
+
planLocation = 'openspec/changes/migrate-to-liftoff/ (run it with your agent workflow, e.g. /opsx:apply migrate-to-liftoff)';
|
|
205
|
+
}
|
|
206
|
+
else {
|
|
207
|
+
await writeFile(path.join(targetRoot, 'MIGRATION.md'), renderMigrationChecklist(plan, inventory, groups), 'utf8');
|
|
208
|
+
planLocation = 'MIGRATION.md';
|
|
209
|
+
}
|
|
210
|
+
context.stdout.write(`Created ${plan.projectName} at ${targetRoot}\n\n`);
|
|
211
|
+
context.stdout.write('Next steps:\n');
|
|
212
|
+
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`);
|
|
213
|
+
context.stdout.write(` 2. Execute the migration plan: ${planLocation}\n`);
|
|
214
|
+
context.stdout.write(' 3. Verify compliance: liftoff validate && liftoff doctor\n');
|
|
215
|
+
context.stdout.write(`The source project was not modified. Rolling back is deleting ${targetRoot}.\n`);
|
|
216
|
+
return 0;
|
|
217
|
+
}
|
|
218
|
+
function summarizeEntries(entries) {
|
|
219
|
+
const summary = { new: 0, missing: 0, upgrade: 0, conflict: 0, moved: 0, orphan: 0, refresh: 0, unchanged: 0 };
|
|
220
|
+
for (const entry of entries) {
|
|
221
|
+
if (entry.status === 'unchanged') {
|
|
222
|
+
if (entry.refreshHash) {
|
|
223
|
+
summary.refresh += 1;
|
|
224
|
+
}
|
|
225
|
+
else {
|
|
226
|
+
summary.unchanged += 1;
|
|
227
|
+
}
|
|
228
|
+
continue;
|
|
229
|
+
}
|
|
230
|
+
if (entry.status === 'moved' && !entry.cleanMove) {
|
|
231
|
+
summary.conflict += 1;
|
|
232
|
+
continue;
|
|
233
|
+
}
|
|
234
|
+
summary[entry.status] += 1;
|
|
235
|
+
}
|
|
236
|
+
return summary;
|
|
237
|
+
}
|
|
238
|
+
function entryMarker(entry) {
|
|
239
|
+
switch (entry.status) {
|
|
240
|
+
case 'new':
|
|
241
|
+
case 'missing':
|
|
242
|
+
return '+';
|
|
243
|
+
case 'upgrade':
|
|
244
|
+
return '~';
|
|
245
|
+
case 'conflict':
|
|
246
|
+
return '!';
|
|
247
|
+
case 'moved':
|
|
248
|
+
return entry.cleanMove ? '>' : '!';
|
|
249
|
+
case 'orphan':
|
|
250
|
+
return '-';
|
|
251
|
+
default:
|
|
252
|
+
return '~';
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
function entryDisplay(entry) {
|
|
256
|
+
if (entry.status === 'moved' && entry.previousPathParts) {
|
|
257
|
+
return `${manifestDisplayPath(entry.previousPathParts)} => ${manifestDisplayPath(entry.pathParts)}`;
|
|
258
|
+
}
|
|
259
|
+
return manifestDisplayPath(entry.pathParts);
|
|
260
|
+
}
|
|
261
|
+
function isDirtyGitWorktree(projectRoot) {
|
|
262
|
+
if (!existsSync(path.join(projectRoot, '.git'))) {
|
|
263
|
+
return false;
|
|
264
|
+
}
|
|
265
|
+
const result = spawnSync('git', ['status', '--porcelain'], { cwd: projectRoot, encoding: 'utf8' });
|
|
266
|
+
return result.status === 0 && result.stdout.trim().length > 0;
|
|
267
|
+
}
|
|
268
|
+
async function updateCommand(parsed, context) {
|
|
269
|
+
const apply = readBooleanFlag(parsed.flags, 'apply') ?? false;
|
|
270
|
+
const force = readBooleanFlag(parsed.flags, 'force') ?? false;
|
|
271
|
+
const jsonMode = readBooleanFlag(parsed.flags, 'json') ?? false;
|
|
272
|
+
if (force && !apply) {
|
|
273
|
+
context.stderr.write('--force requires --apply.\n');
|
|
274
|
+
return 1;
|
|
275
|
+
}
|
|
276
|
+
const explicit = parsed.positional[0] ?? readStringFlag(parsed.flags, 'project');
|
|
277
|
+
const projectRoot = explicit ? path.resolve(context.cwd, explicit) : await findProjectRoot(context.cwd);
|
|
278
|
+
if (!projectRoot) {
|
|
279
|
+
context.stderr.write(`No liftoff.manifest.json found in ${context.cwd} or any parent directory.\n`);
|
|
280
|
+
return 1;
|
|
281
|
+
}
|
|
282
|
+
const manifest = await loadManifest(projectRoot);
|
|
283
|
+
if (compareSemver(manifest.liftoffVersion, liftoffVersion) > 0) {
|
|
284
|
+
context.stderr.write(`This project was written by Liftoff ${manifest.liftoffVersion}, which is newer than this CLI (${liftoffVersion}). Upgrade the CLI first.\n`);
|
|
285
|
+
return 1;
|
|
286
|
+
}
|
|
287
|
+
const config = await loadConfigOptions('liftoff.config.json', projectRoot);
|
|
288
|
+
if (config.pattern && config.pattern !== manifest.project.pattern) {
|
|
289
|
+
context.stderr.write(`Pattern changes (${manifest.project.pattern} -> ${config.pattern}) are a migration, not an update. Run liftoff migrate instead.\n`);
|
|
290
|
+
return 1;
|
|
291
|
+
}
|
|
292
|
+
const plan = buildProjectPlan(config, { requireProjectName: true });
|
|
293
|
+
const render = buildArtifacts(plan);
|
|
294
|
+
const entries = await reconcileProject(manifest, render, projectRoot);
|
|
295
|
+
const summary = summarizeEntries(entries);
|
|
296
|
+
const drift = hasDrift(entries);
|
|
297
|
+
const visible = entries.filter((entry) => entry.status !== 'unchanged' || entry.refreshHash);
|
|
298
|
+
if (!apply) {
|
|
299
|
+
if (jsonMode) {
|
|
300
|
+
context.stdout.write(`${JSON.stringify({
|
|
301
|
+
schemaVersion: 1,
|
|
302
|
+
mode: 'check',
|
|
303
|
+
cliVersion: liftoffVersion,
|
|
304
|
+
projectVersion: manifest.liftoffVersion,
|
|
305
|
+
entries: visible.map((entry) => ({
|
|
306
|
+
logicalName: entry.logicalName,
|
|
307
|
+
status: entry.status,
|
|
308
|
+
path: manifestDisplayPath(entry.pathParts),
|
|
309
|
+
previousPath: entry.previousPathParts ? manifestDisplayPath(entry.previousPathParts) : undefined,
|
|
310
|
+
reason: entry.reason
|
|
311
|
+
})),
|
|
312
|
+
summary
|
|
313
|
+
}, null, 2)}\n`);
|
|
314
|
+
return drift ? 2 : 0;
|
|
315
|
+
}
|
|
316
|
+
context.stdout.write(`Liftoff ${liftoffVersion} - project generated by ${manifest.liftoffVersion}\n\n`);
|
|
317
|
+
if (!drift) {
|
|
318
|
+
context.stdout.write(`No drift: ${summary.unchanged} artifacts match the current templates and configuration.\n`);
|
|
319
|
+
return 0;
|
|
320
|
+
}
|
|
321
|
+
for (const entry of visible) {
|
|
322
|
+
context.stdout.write(` ${entryMarker(entry)} ${entryDisplay(entry)} ${entry.reason}\n`);
|
|
323
|
+
}
|
|
324
|
+
const toWrite = summary.new + summary.missing + summary.upgrade + summary.moved + summary.refresh;
|
|
325
|
+
context.stdout.write(`\n ${toWrite} to write, ${summary.conflict} conflict(s), ${summary.orphan} orphan(s), ${summary.unchanged} unchanged\n`);
|
|
326
|
+
context.stdout.write(' Run `liftoff update --apply` to apply the safe changes.\n');
|
|
327
|
+
return 2;
|
|
328
|
+
}
|
|
329
|
+
if (isDirtyGitWorktree(projectRoot)) {
|
|
330
|
+
context.stdout.write('Hint: the project worktree has uncommitted changes - consider committing before applying.\n');
|
|
331
|
+
}
|
|
332
|
+
const written = [];
|
|
333
|
+
const skipped = [];
|
|
334
|
+
for (const entry of entries) {
|
|
335
|
+
switch (entry.status) {
|
|
336
|
+
case 'new':
|
|
337
|
+
case 'missing':
|
|
338
|
+
case 'upgrade':
|
|
339
|
+
await writeProjectFile(projectRoot, entry.pathParts, entry.rendered.content);
|
|
340
|
+
written.push(entry);
|
|
341
|
+
break;
|
|
342
|
+
case 'moved':
|
|
343
|
+
if (entry.cleanMove || force) {
|
|
344
|
+
await writeProjectFile(projectRoot, entry.pathParts, entry.rendered.content);
|
|
345
|
+
await deleteProjectFile(projectRoot, entry.previousPathParts);
|
|
346
|
+
written.push(entry);
|
|
347
|
+
}
|
|
348
|
+
else {
|
|
349
|
+
skipped.push(entry);
|
|
350
|
+
}
|
|
351
|
+
break;
|
|
352
|
+
case 'conflict':
|
|
353
|
+
if (force) {
|
|
354
|
+
await writeProjectFile(projectRoot, entry.pathParts, entry.rendered.content);
|
|
355
|
+
written.push(entry);
|
|
356
|
+
}
|
|
357
|
+
else {
|
|
358
|
+
skipped.push(entry);
|
|
359
|
+
}
|
|
360
|
+
break;
|
|
361
|
+
default:
|
|
362
|
+
break;
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
const oldByName = new Map(manifest.artifacts.map((artifact) => [artifact.logicalName, artifact]));
|
|
366
|
+
const skippedByName = new Map(skipped.map((entry) => [entry.logicalName, entry]));
|
|
367
|
+
const nextManifest = buildManifest(plan, render.filter((artifact) => artifact.logicalName !== 'manifest'));
|
|
368
|
+
nextManifest.artifacts = nextManifest.artifacts.map((artifact) => {
|
|
369
|
+
// config is user-owned after create: carry the recorded entry forward untouched
|
|
370
|
+
if (artifact.logicalName === 'liftoff-config') {
|
|
371
|
+
return oldByName.get('liftoff-config') ?? artifact;
|
|
372
|
+
}
|
|
373
|
+
if (!skippedByName.has(artifact.logicalName)) {
|
|
374
|
+
return artifact;
|
|
375
|
+
}
|
|
376
|
+
const previous = oldByName.get(artifact.logicalName);
|
|
377
|
+
return { ...artifact, pathParts: previous.pathParts, contentHash: previous.contentHash };
|
|
378
|
+
});
|
|
379
|
+
for (const entry of entries) {
|
|
380
|
+
if (entry.status === 'orphan') {
|
|
381
|
+
nextManifest.artifacts.push(oldByName.get(entry.logicalName));
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
await writeProjectFile(projectRoot, ['liftoff.manifest.json'], `${JSON.stringify(nextManifest, null, 2)}\n`);
|
|
385
|
+
if (jsonMode) {
|
|
386
|
+
context.stdout.write(`${JSON.stringify({
|
|
387
|
+
schemaVersion: 1,
|
|
388
|
+
mode: 'apply',
|
|
389
|
+
cliVersion: liftoffVersion,
|
|
390
|
+
projectVersion: manifest.liftoffVersion,
|
|
391
|
+
written: written.map((entry) => manifestDisplayPath(entry.pathParts)),
|
|
392
|
+
skipped: skipped.map((entry) => ({ path: manifestDisplayPath(entry.pathParts), reason: entry.reason })),
|
|
393
|
+
summary
|
|
394
|
+
}, null, 2)}\n`);
|
|
395
|
+
return 0;
|
|
396
|
+
}
|
|
397
|
+
for (const entry of written) {
|
|
398
|
+
context.stdout.write(` wrote ${entryDisplay(entry)}\n`);
|
|
399
|
+
}
|
|
400
|
+
for (const entry of skipped) {
|
|
401
|
+
context.stdout.write(` skipped ${entryDisplay(entry)} ${entry.reason}${force ? '' : ' (use --apply --force to overwrite)'}\n`);
|
|
402
|
+
}
|
|
403
|
+
for (const entry of entries) {
|
|
404
|
+
if (entry.status === 'orphan') {
|
|
405
|
+
context.stdout.write(` orphan ${entryDisplay(entry)} ${entry.reason}\n`);
|
|
406
|
+
}
|
|
407
|
+
}
|
|
408
|
+
context.stdout.write(`Updated: ${written.length} written, ${skipped.length} skipped, ${summary.orphan} orphan(s). Manifest recorded at ${liftoffVersion}.\n`);
|
|
409
|
+
return 0;
|
|
410
|
+
}
|
|
411
|
+
function binaryCheck(command, args, remedy) {
|
|
412
|
+
const result = spawnSync(command, args, { encoding: 'utf8' });
|
|
413
|
+
if (result.status === 0) {
|
|
414
|
+
return { label: command, severity: 'ok', detail: (result.stdout || result.stderr).split('\n')[0].trim() };
|
|
415
|
+
}
|
|
416
|
+
return { label: command, severity: 'fail', detail: 'not found', remedy };
|
|
417
|
+
}
|
|
418
|
+
function environmentLayer() {
|
|
419
|
+
return {
|
|
420
|
+
title: 'Environment',
|
|
421
|
+
checks: [
|
|
422
|
+
binaryCheck('node', ['--version'], 'install Node.js 20 or newer'),
|
|
423
|
+
binaryCheck('python3', ['--version'], 'install Python 3'),
|
|
424
|
+
binaryCheck('docker', ['--version'], 'install Docker'),
|
|
425
|
+
binaryCheck('tofu', ['--version'], 'install OpenTofu')
|
|
426
|
+
]
|
|
427
|
+
};
|
|
428
|
+
}
|
|
429
|
+
function binaryPresent(command) {
|
|
430
|
+
const probe = process.platform === 'win32' ? 'where' : 'which';
|
|
431
|
+
return spawnSync(probe, [command], { encoding: 'utf8' }).status === 0;
|
|
432
|
+
}
|
|
433
|
+
function azureCloudChecks() {
|
|
434
|
+
if (!binaryPresent('az')) {
|
|
435
|
+
return [{ label: 'az', severity: 'fail', detail: 'Azure CLI not found', remedy: 'install the Azure CLI' }];
|
|
436
|
+
}
|
|
437
|
+
const auth = spawnSync('az', ['account', 'show', '-o', 'none', '--only-show-errors'], { encoding: 'utf8' });
|
|
438
|
+
if (auth.status === 0) {
|
|
439
|
+
return [{ label: 'azure auth', severity: 'ok', detail: 'authenticated' }];
|
|
440
|
+
}
|
|
441
|
+
return [{ label: 'azure auth', severity: 'fail', detail: 'not authenticated', remedy: 'run az login' }];
|
|
442
|
+
}
|
|
443
|
+
// ponytail: provider-keyed map so aws/gcp checks slot in when their adapters land
|
|
444
|
+
const CLOUD_CHECKS = {
|
|
445
|
+
azure: azureCloudChecks
|
|
446
|
+
};
|
|
447
|
+
function cloudLayer(cloud) {
|
|
448
|
+
const checks = CLOUD_CHECKS[cloud]
|
|
449
|
+
? CLOUD_CHECKS[cloud]()
|
|
450
|
+
: [{ label: cloud, severity: 'skipped', detail: `${cloud} provider checks are not available yet` }];
|
|
451
|
+
return { title: `Cloud - ${cloud}`, checks };
|
|
452
|
+
}
|
|
453
|
+
async function lookupLatestPublishedVersion() {
|
|
454
|
+
const registry = process.env.LIFTOFF_REGISTRY ?? 'https://registry.npmjs.org';
|
|
455
|
+
try {
|
|
456
|
+
const controller = new AbortController();
|
|
457
|
+
const timer = setTimeout(() => controller.abort(), 2000);
|
|
458
|
+
const response = await fetch(`${registry}/@msn-control%2fliftoff/latest`, { signal: controller.signal });
|
|
459
|
+
clearTimeout(timer);
|
|
460
|
+
if (!response.ok) {
|
|
461
|
+
return undefined;
|
|
462
|
+
}
|
|
463
|
+
const data = (await response.json());
|
|
464
|
+
return data.version;
|
|
465
|
+
}
|
|
466
|
+
catch {
|
|
467
|
+
return undefined; // offline or unreachable: doctor stays quiet about freshness
|
|
468
|
+
}
|
|
469
|
+
}
|
|
470
|
+
async function projectLayer(projectRoot, manifest) {
|
|
471
|
+
const checks = [];
|
|
472
|
+
const issues = await validateGeneratedProject(projectRoot);
|
|
473
|
+
if (issues.length > 0) {
|
|
474
|
+
checks.push({
|
|
475
|
+
label: 'manifest',
|
|
476
|
+
severity: 'fail',
|
|
477
|
+
detail: `${issues.length} issue(s): ${issues[0]}${issues.length > 1 ? ' ...' : ''}`,
|
|
478
|
+
remedy: 'restore missing artifacts or run liftoff update --apply'
|
|
479
|
+
});
|
|
480
|
+
}
|
|
481
|
+
else {
|
|
482
|
+
checks.push({ label: 'manifest', severity: 'ok', detail: `valid, ${manifest.artifacts.length} artifacts present` });
|
|
483
|
+
}
|
|
484
|
+
if (compareSemver(manifest.liftoffVersion, liftoffVersion) > 0) {
|
|
485
|
+
checks.push({
|
|
486
|
+
label: 'version',
|
|
487
|
+
severity: 'warn',
|
|
488
|
+
detail: `project written by Liftoff ${manifest.liftoffVersion}, CLI is ${liftoffVersion}`,
|
|
489
|
+
remedy: 'upgrade the CLI: npm install -g @msn-control/liftoff@latest'
|
|
490
|
+
});
|
|
491
|
+
}
|
|
492
|
+
else {
|
|
493
|
+
checks.push({ label: 'version', severity: 'ok', detail: `generated by ${manifest.liftoffVersion}, CLI ${liftoffVersion}` });
|
|
494
|
+
}
|
|
495
|
+
const latest = await lookupLatestPublishedVersion();
|
|
496
|
+
if (latest && compareSemver(latest, liftoffVersion) > 0) {
|
|
497
|
+
checks.push({
|
|
498
|
+
label: 'cli freshness',
|
|
499
|
+
severity: 'warn',
|
|
500
|
+
detail: `Liftoff ${latest} is published, this CLI is ${liftoffVersion}`,
|
|
501
|
+
remedy: 'npm install -g @msn-control/liftoff@latest'
|
|
502
|
+
});
|
|
503
|
+
}
|
|
504
|
+
try {
|
|
505
|
+
const config = await loadConfigOptions('liftoff.config.json', projectRoot);
|
|
506
|
+
const plan = buildProjectPlan(config, { requireProjectName: true });
|
|
507
|
+
const render = buildArtifacts(plan);
|
|
508
|
+
const entries = await reconcileProject(manifest, render, projectRoot);
|
|
509
|
+
const driftCount = entries.filter((entry) => entry.status !== 'unchanged' || entry.refreshHash).length;
|
|
510
|
+
if (driftCount > 0) {
|
|
511
|
+
checks.push({
|
|
512
|
+
label: 'scaffold drift',
|
|
513
|
+
severity: 'warn',
|
|
514
|
+
detail: `${driftCount} update(s) available`,
|
|
515
|
+
remedy: 'run liftoff update'
|
|
516
|
+
});
|
|
517
|
+
}
|
|
518
|
+
else {
|
|
519
|
+
checks.push({ label: 'scaffold drift', severity: 'ok', detail: 'project matches the current templates' });
|
|
520
|
+
}
|
|
521
|
+
}
|
|
522
|
+
catch (error) {
|
|
523
|
+
checks.push({
|
|
524
|
+
label: 'scaffold drift',
|
|
525
|
+
severity: 'fail',
|
|
526
|
+
detail: `liftoff.config.json could not be evaluated: ${error.message.split('\n')[0]}`,
|
|
527
|
+
remedy: 'repair liftoff.config.json'
|
|
528
|
+
});
|
|
529
|
+
}
|
|
530
|
+
return { title: 'Project', checks };
|
|
531
|
+
}
|
|
532
|
+
async function runtimeLayer(projectRoot, dockerAvailable) {
|
|
533
|
+
const checks = [];
|
|
534
|
+
if (existsSync(path.join(projectRoot, '.env.example'))) {
|
|
535
|
+
if (existsSync(path.join(projectRoot, '.env'))) {
|
|
536
|
+
checks.push({ label: '.env', severity: 'ok', detail: 'present' });
|
|
133
537
|
}
|
|
134
538
|
else {
|
|
135
|
-
|
|
136
|
-
context.stdout.write(`[missing] ${command}\n`);
|
|
539
|
+
checks.push({ label: '.env', severity: 'fail', detail: 'missing', remedy: 'copy .env.example to .env' });
|
|
137
540
|
}
|
|
138
541
|
}
|
|
139
|
-
|
|
140
|
-
|
|
542
|
+
else {
|
|
543
|
+
checks.push({ label: '.env', severity: 'skipped', detail: 'no .env.example in this project' });
|
|
544
|
+
}
|
|
545
|
+
if (!existsSync(path.join(projectRoot, 'docker-compose.yml'))) {
|
|
546
|
+
checks.push({ label: 'compose', severity: 'skipped', detail: 'no docker-compose.yml in this project' });
|
|
547
|
+
}
|
|
548
|
+
else if (!dockerAvailable) {
|
|
549
|
+
checks.push({ label: 'compose', severity: 'skipped', detail: 'docker is not installed, compose config not checked' });
|
|
550
|
+
}
|
|
551
|
+
else {
|
|
552
|
+
const result = spawnSync('docker', ['compose', 'config', '-q'], { cwd: projectRoot, encoding: 'utf8' });
|
|
141
553
|
if (result.status === 0) {
|
|
142
|
-
|
|
554
|
+
checks.push({ label: 'compose', severity: 'ok', detail: 'docker compose config is valid' });
|
|
143
555
|
}
|
|
144
556
|
else {
|
|
145
|
-
|
|
146
|
-
|
|
557
|
+
checks.push({
|
|
558
|
+
label: 'compose',
|
|
559
|
+
severity: 'fail',
|
|
560
|
+
detail: (result.stderr || 'docker compose config failed').split('\n')[0],
|
|
561
|
+
remedy: 'fix docker-compose.yml'
|
|
562
|
+
});
|
|
563
|
+
}
|
|
564
|
+
}
|
|
565
|
+
return { title: 'Runtime', checks };
|
|
566
|
+
}
|
|
567
|
+
const severityMarker = {
|
|
568
|
+
ok: '[ok]',
|
|
569
|
+
warn: '[warn]',
|
|
570
|
+
fail: '[fail]',
|
|
571
|
+
skipped: '[skip]'
|
|
572
|
+
};
|
|
573
|
+
function renderDoctorLayers(layers, stream) {
|
|
574
|
+
for (const layer of layers) {
|
|
575
|
+
stream.write(`${layer.title}\n`);
|
|
576
|
+
for (const check of layer.checks) {
|
|
577
|
+
const remedy = check.remedy ? ` - ${check.remedy}` : '';
|
|
578
|
+
stream.write(` ${severityMarker[check.severity].padEnd(6)} ${check.label}: ${check.detail}${remedy}\n`);
|
|
579
|
+
}
|
|
580
|
+
}
|
|
581
|
+
}
|
|
582
|
+
export function doctorExitCode(layers) {
|
|
583
|
+
return layers.some((layer) => layer.checks.some((check) => check.severity === 'fail')) ? 1 : 0;
|
|
584
|
+
}
|
|
585
|
+
async function doctorCommand(parsed, context) {
|
|
586
|
+
const jsonMode = readBooleanFlag(parsed.flags, 'json') ?? false;
|
|
587
|
+
const cloudOverride = readStringFlag(parsed.flags, 'cloud');
|
|
588
|
+
const layers = [];
|
|
589
|
+
const environment = environmentLayer();
|
|
590
|
+
layers.push(environment);
|
|
591
|
+
const dockerAvailable = environment.checks.some((check) => check.label === 'docker' && check.severity === 'ok');
|
|
592
|
+
const projectRoot = await findProjectRoot(context.cwd);
|
|
593
|
+
if (projectRoot) {
|
|
594
|
+
let manifest;
|
|
595
|
+
try {
|
|
596
|
+
manifest = await loadManifest(projectRoot);
|
|
597
|
+
}
|
|
598
|
+
catch (error) {
|
|
599
|
+
layers.push({
|
|
600
|
+
title: 'Project',
|
|
601
|
+
checks: [{ label: 'manifest', severity: 'fail', detail: error.message, remedy: 'regenerate the project or use a matching CLI version' }]
|
|
602
|
+
});
|
|
603
|
+
}
|
|
604
|
+
if (manifest) {
|
|
605
|
+
layers.push(await projectLayer(projectRoot, manifest));
|
|
606
|
+
layers.push(await runtimeLayer(projectRoot, dockerAvailable));
|
|
607
|
+
const cloud = cloudOverride ?? manifest.project.cloud;
|
|
608
|
+
const cloudChecks = cloudLayer(cloud);
|
|
609
|
+
const pattern = patterns.find((candidate) => candidate.id === manifest.project.pattern);
|
|
610
|
+
if (pattern?.worker && cloud === 'azure') {
|
|
611
|
+
cloudChecks.checks.push(binaryPresent('func')
|
|
612
|
+
? { label: 'functions tooling', severity: 'ok', detail: 'Azure Functions Core Tools installed' }
|
|
613
|
+
: { label: 'functions tooling', severity: 'warn', detail: 'Azure Functions Core Tools not found', remedy: 'npm install -g azure-functions-core-tools@4' });
|
|
614
|
+
}
|
|
615
|
+
layers.push(cloudChecks);
|
|
147
616
|
}
|
|
148
617
|
}
|
|
149
|
-
|
|
618
|
+
else if (cloudOverride) {
|
|
619
|
+
layers.push(cloudLayer(cloudOverride));
|
|
620
|
+
}
|
|
621
|
+
const failures = layers.reduce((count, layer) => count + layer.checks.filter((check) => check.severity === 'fail').length, 0);
|
|
622
|
+
const warnings = layers.reduce((count, layer) => count + layer.checks.filter((check) => check.severity === 'warn').length, 0);
|
|
623
|
+
if (jsonMode) {
|
|
624
|
+
context.stdout.write(`${JSON.stringify({ schemaVersion: 1, layers, summary: { failures, warnings } }, null, 2)}\n`);
|
|
625
|
+
}
|
|
626
|
+
else {
|
|
627
|
+
renderDoctorLayers(layers, context.stdout);
|
|
628
|
+
context.stdout.write(`${failures} failure(s), ${warnings} warning(s)\n`);
|
|
629
|
+
}
|
|
630
|
+
return doctorExitCode(layers);
|
|
150
631
|
}
|
|
151
632
|
function helperCommand(parsed, context, tool) {
|
|
152
633
|
const command = parsed.command === 'dev' ? buildDevCommand(parsed) : buildInfraCommand(parsed);
|
|
@@ -210,6 +691,8 @@ function printHelp(stream) {
|
|
|
210
691
|
stream.write(` providers List cloud providers\n`);
|
|
211
692
|
stream.write(` regions List or search provider regions\n`);
|
|
212
693
|
stream.write(` validate Validate a generated project manifest\n`);
|
|
694
|
+
stream.write(` update Reconcile a project with the current templates (check by default; --apply, --force)\n`);
|
|
695
|
+
stream.write(` migrate Adopt an existing project: fresh scaffold + staged copy + migration plan\n`);
|
|
213
696
|
stream.write(` doctor Check local readiness\n`);
|
|
214
697
|
stream.write(` dev Print Docker Compose helper commands\n`);
|
|
215
698
|
stream.write(` infra Print OpenTofu helper commands\n`);
|