@msn-control/liftoff 0.7.0 → 0.8.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 +10 -1
- package/assets/governance/single-maintainer-gitflow/policy.md +705 -0
- package/assets/locks/frontend/package-lock.json +1012 -1694
- package/assets/locks/frontend/package.json +5 -6
- package/assets/locks/go-backend/go.mod +17 -0
- package/assets/locks/go-backend/go.sum +159 -0
- package/assets/locks/node-backend/package-lock.json +1473 -2320
- package/assets/locks/node-backend/package.json +8 -7
- package/assets/locks/opentofu-azure/.terraform.lock.hcl +39 -0
- package/assets/locks/opentofu-azure/versions.tf +10 -0
- package/assets/locks/python-genai/function-requirements.txt +1734 -0
- package/assets/locks/python-genai/liftoff_template_python_genai.egg-info/PKG-INFO +25 -0
- package/assets/locks/python-genai/liftoff_template_python_genai.egg-info/SOURCES.txt +6 -0
- package/assets/locks/python-genai/liftoff_template_python_genai.egg-info/dependency_links.txt +1 -0
- package/assets/locks/python-genai/liftoff_template_python_genai.egg-info/requires.txt +23 -0
- package/assets/locks/python-genai/liftoff_template_python_genai.egg-info/top_level.txt +1 -0
- package/assets/locks/python-genai/pyproject.toml +42 -0
- package/assets/locks/python-genai/uv.lock +2536 -0
- package/assets/locks/python-standard/pyproject.toml +36 -0
- package/assets/locks/python-standard/uv.lock +1780 -0
- package/assets/power-apps-code-app/3438c352483e40982f6c5c0fc36fd71f8e7adbbb/catalog.json +4 -4
- package/assets/power-apps-code-app/3438c352483e40982f6c5c0fc36fd71f8e7adbbb/starter/package-lock.json +667 -1108
- package/assets/supported-stack.json +711 -0
- package/dist/args.js +12 -0
- package/dist/args.js.map +1 -1
- package/dist/catalogs.d.ts +3 -1
- package/dist/catalogs.js +29 -7
- package/dist/catalogs.js.map +1 -1
- package/dist/cli.js +2 -1
- package/dist/cli.js.map +1 -1
- package/dist/commands.d.ts +6 -0
- package/dist/commands.js +243 -40
- package/dist/commands.js.map +1 -1
- package/dist/container-validation.d.ts +2 -0
- package/dist/container-validation.js +26 -0
- package/dist/container-validation.js.map +1 -0
- package/dist/file-system.js +130 -5
- package/dist/file-system.js.map +1 -1
- package/dist/framework-adapters.js +1 -0
- package/dist/framework-adapters.js.map +1 -1
- package/dist/go-template-assets.d.ts +2 -0
- package/dist/go-template-assets.js +14 -0
- package/dist/go-template-assets.js.map +1 -0
- package/dist/init-filesystem.js +5 -4
- package/dist/init-filesystem.js.map +1 -1
- package/dist/interactive.js +11 -7
- package/dist/interactive.js.map +1 -1
- package/dist/opentofu-template-assets.d.ts +2 -0
- package/dist/opentofu-template-assets.js +10 -0
- package/dist/opentofu-template-assets.js.map +1 -0
- package/dist/package-identity.d.ts +8 -0
- package/dist/package-identity.js +16 -0
- package/dist/package-identity.js.map +1 -0
- package/dist/planner.js +42 -3
- package/dist/planner.js.map +1 -1
- package/dist/power-apps-templates.js +6 -0
- package/dist/power-apps-templates.js.map +1 -1
- package/dist/project-dependencies.d.ts +1 -1
- package/dist/project-dependencies.js +15 -34
- package/dist/project-dependencies.js.map +1 -1
- package/dist/published-verifier.js +11 -1
- package/dist/published-verifier.js.map +1 -1
- package/dist/python-template-assets.d.ts +4 -0
- package/dist/python-template-assets.js +34 -0
- package/dist/python-template-assets.js.map +1 -0
- package/dist/repository-governance.d.ts +17 -0
- package/dist/repository-governance.js +507 -0
- package/dist/repository-governance.js.map +1 -0
- package/dist/runtime.d.ts +1 -1
- package/dist/runtime.js +3 -1
- package/dist/runtime.js.map +1 -1
- package/dist/self-upgrade.d.ts +77 -0
- package/dist/self-upgrade.js +499 -0
- package/dist/self-upgrade.js.map +1 -0
- package/dist/stable-release.d.ts +18 -0
- package/dist/stable-release.js +66 -0
- package/dist/stable-release.js.map +1 -0
- package/dist/standard-templates.js +28 -97
- package/dist/standard-templates.js.map +1 -1
- package/dist/supported-stack.d.ts +115 -0
- package/dist/supported-stack.js +439 -0
- package/dist/supported-stack.js.map +1 -0
- package/dist/telemetry/contract.d.ts +1 -1
- package/dist/telemetry/contract.js +1 -0
- package/dist/telemetry/contract.js.map +1 -1
- package/dist/templates.js +214 -123
- package/dist/templates.js.map +1 -1
- package/dist/types.d.ts +18 -1
- package/dist/workstation-catalog.js +15 -12
- package/dist/workstation-catalog.js.map +1 -1
- package/dist/workstation.js +16 -6
- package/dist/workstation.js.map +1 -1
- package/docs/cli-reference.md +59 -2
- package/docs/configuration-and-manifests.md +13 -5
- package/docs/existing-repositories.md +21 -0
- package/docs/getting-started.md +23 -7
- package/docs/prerequisites.md +30 -4
- package/docs/project-structure.md +20 -1
- package/docs/repository-governance.md +91 -0
- package/docs/safety-and-consent.md +25 -0
- package/docs/spec-workflows-and-agents.md +19 -6
- package/docs/supported-stack.md +89 -0
- package/docs/telemetry.md +6 -0
- package/docs/troubleshooting.md +78 -0
- package/docs/workloads.md +14 -5
- package/package.json +14 -6
package/dist/commands.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { createHash } from 'node:crypto';
|
|
2
2
|
import { spawnSync } from 'node:child_process';
|
|
3
|
-
import { existsSync } from 'node:fs';
|
|
3
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
4
4
|
import { cp, lstat, mkdir, mkdtemp, readFile, readdir, readlink, rm, stat } from 'node:fs/promises';
|
|
5
5
|
import os from 'node:os';
|
|
6
6
|
import path from 'node:path';
|
|
@@ -19,6 +19,10 @@ import { formatCommand, NodeCommandRunner } from './process-runner.js';
|
|
|
19
19
|
import { scanDefaults, scanLegacyProject } from './scan.js';
|
|
20
20
|
import { hasDrift, reconcileProject } from './reconcile.js';
|
|
21
21
|
import { compareSemver } from './semver.js';
|
|
22
|
+
import { checkConfiguredRegistryTarget, runSelfUpgrade, selfUpgradeExitCode, selfUpgradeRemedy, selfUpgradeSummary } from './self-upgrade.js';
|
|
23
|
+
import { canonicalManualInstallCommand, canonicalNpmRegistry, } from './package-identity.js';
|
|
24
|
+
import { lookupStableRelease } from './stable-release.js';
|
|
25
|
+
import { supportedStack } from './supported-stack.js';
|
|
22
26
|
import { buildArtifacts, buildManifest, partitionGeneratedArtifacts } from './templates.js';
|
|
23
27
|
import { PresentationSession } from './terminal.js';
|
|
24
28
|
import { liftoffVersion } from './version.js';
|
|
@@ -27,6 +31,7 @@ export async function runCommand(parsed, context) {
|
|
|
27
31
|
const helpRequested = parsed.command !== undefined && readBooleanFlag(parsed.flags, 'help') === true;
|
|
28
32
|
const jsonMode = !helpRequested && (parsed.command === 'doctor' ||
|
|
29
33
|
parsed.command === 'update' ||
|
|
34
|
+
parsed.command === 'upgrade' ||
|
|
30
35
|
parsed.command === 'validate') &&
|
|
31
36
|
readBooleanFlag(parsed.flags, 'json') === true;
|
|
32
37
|
const presentation = new PresentationSession({
|
|
@@ -69,6 +74,8 @@ export async function runCommand(parsed, context) {
|
|
|
69
74
|
return await validateCommand(parsed, executionContext);
|
|
70
75
|
case 'update':
|
|
71
76
|
return await updateCommand(parsed, executionContext);
|
|
77
|
+
case 'upgrade':
|
|
78
|
+
return await upgradeCommand(parsed, executionContext);
|
|
72
79
|
case 'migrate':
|
|
73
80
|
return await migrateCommand(parsed, executionContext);
|
|
74
81
|
case 'doctor':
|
|
@@ -198,7 +205,10 @@ async function initCommand(parsed, context) {
|
|
|
198
205
|
presentation.bullets('Configured integrations', [
|
|
199
206
|
`${plan.specWorkflow.label} ${plan.framework.version}`,
|
|
200
207
|
...plan.agents.map((agent) => `${agent.label}${plan.defaultAgent?.id === agent.id ? ' (default)' : ''}`),
|
|
201
|
-
...readiness.pluginProbes.map((probe) => `Code Apps plugin for ${probe.agent.label}: ${probe.state}`)
|
|
208
|
+
...readiness.pluginProbes.map((probe) => `Code Apps plugin for ${probe.agent.label}: ${probe.state}`),
|
|
209
|
+
plan.governanceProfile.id === 'none'
|
|
210
|
+
? 'Repository governance: disabled'
|
|
211
|
+
: `Repository governance: ${plan.governanceProfile.label} policy ${plan.governanceProfile.policyVersion}; local handoff generated, live activation deferred`
|
|
202
212
|
]);
|
|
203
213
|
if (readiness.deferred.length > 0) {
|
|
204
214
|
presentation.bullets('Deferred advisory checks', readiness.deferred);
|
|
@@ -209,7 +219,13 @@ async function initCommand(parsed, context) {
|
|
|
209
219
|
presentation.completion(`Initialized ${plan.projectName}`, target.root, [
|
|
210
220
|
{ label: 'Target', value: target.root },
|
|
211
221
|
{ label: 'Spec workflow', value: plan.specWorkflow.label },
|
|
212
|
-
{ label: 'Coding agents', value: plan.agents.map((agent) => agent.label).join(', ') }
|
|
222
|
+
{ label: 'Coding agents', value: plan.agents.map((agent) => agent.label).join(', ') },
|
|
223
|
+
{
|
|
224
|
+
label: 'Repository governance',
|
|
225
|
+
value: plan.governanceProfile.id === 'none'
|
|
226
|
+
? 'Disabled'
|
|
227
|
+
: 'Handoff generated; commit and push before read-only Phase 0'
|
|
228
|
+
}
|
|
213
229
|
], `liftoff validate ${JSON.stringify(target.root)}`);
|
|
214
230
|
return 0;
|
|
215
231
|
}
|
|
@@ -888,10 +904,13 @@ async function updateCommand(parsed, context) {
|
|
|
888
904
|
}
|
|
889
905
|
if (plan.workload === 'power-apps-code-app' && recordedWorkload.kind === 'power-apps-code-app') {
|
|
890
906
|
const recordedStarter = recordedWorkload.starter;
|
|
891
|
-
|
|
892
|
-
|
|
893
|
-
plan.starter.
|
|
894
|
-
|
|
907
|
+
const starterBaseline = supportedStack.upstreams['power-apps-code-app'];
|
|
908
|
+
if (plan.starter.repository !== starterBaseline.repository ||
|
|
909
|
+
plan.starter.path !== starterBaseline.path ||
|
|
910
|
+
recordedStarter.repository !== starterBaseline.repository ||
|
|
911
|
+
recordedStarter.path !== starterBaseline.path ||
|
|
912
|
+
!starterBaseline.compatibleSourceCommits.includes(recordedStarter.commit)) {
|
|
913
|
+
presentation.error('The Power Apps starter repository, path, or commit is not represented by this Liftoff release catalog.', 'Restore the recorded source identity or use a Liftoff version that catalogs the transition.');
|
|
895
914
|
return 1;
|
|
896
915
|
}
|
|
897
916
|
}
|
|
@@ -918,18 +937,26 @@ async function updateCommand(parsed, context) {
|
|
|
918
937
|
presentation.error('AI agent or default-agent changes require official framework initialization and are not supported by liftoff update.', 'Restore liftoff.config.json to the integrations recorded in liftoff.manifest.json.');
|
|
919
938
|
return 1;
|
|
920
939
|
}
|
|
921
|
-
const
|
|
940
|
+
const renderPlan = manifest.framework.state === 'legacy'
|
|
941
|
+
? { ...plan, agents: [], defaultAgent: undefined }
|
|
942
|
+
: plan;
|
|
943
|
+
const render = buildArtifacts(renderPlan);
|
|
922
944
|
const updateSnapshots = check
|
|
923
945
|
? []
|
|
924
946
|
: await captureUpdateSnapshots(projectRoot, manifest, render, initialUpdateSnapshots);
|
|
925
947
|
const entries = await reconcileProject(manifest, render, projectRoot);
|
|
926
948
|
const summary = summarizeEntries(entries);
|
|
927
|
-
const manifestUpgradePending = manifest.artifactVersion !==
|
|
949
|
+
const manifestUpgradePending = manifest.artifactVersion !== 5;
|
|
950
|
+
const starterUpgradePending = plan.workload === 'power-apps-code-app' &&
|
|
951
|
+
recordedWorkload.kind === 'power-apps-code-app' &&
|
|
952
|
+
recordedWorkload.starter.commit !== plan.starter.commit;
|
|
928
953
|
const nonDurableNames = new Set(render
|
|
929
954
|
.filter((artifact) => artifact.category === 'seed' || artifact.category === 'framework')
|
|
930
955
|
.map((artifact) => artifact.logicalName));
|
|
931
956
|
const manifestOwnershipCleanupPending = manifest.artifacts.some((artifact) => nonDurableNames.has(artifact.logicalName)) || manifestHadFilteredLegacySeedOwnership(manifest);
|
|
932
|
-
const manifestRewritePending = manifestUpgradePending ||
|
|
957
|
+
const manifestRewritePending = manifestUpgradePending ||
|
|
958
|
+
manifestOwnershipCleanupPending ||
|
|
959
|
+
starterUpgradePending;
|
|
933
960
|
const drift = hasDrift(entries) || manifestRewritePending;
|
|
934
961
|
const visible = entries.filter((entry) => entry.status !== 'unchanged' || entry.refreshHash);
|
|
935
962
|
if (!drift) {
|
|
@@ -996,9 +1023,12 @@ async function updateCommand(parsed, context) {
|
|
|
996
1023
|
}
|
|
997
1024
|
}
|
|
998
1025
|
const manifestMaintenance = [
|
|
999
|
-
manifestUpgradePending ? 'upgrade liftoff.manifest.json to schema
|
|
1026
|
+
manifestUpgradePending ? 'upgrade liftoff.manifest.json to schema v5' : undefined,
|
|
1000
1027
|
manifestOwnershipCleanupPending
|
|
1001
1028
|
? 'remove obsolete seed or framework entries from durable manifest ownership'
|
|
1029
|
+
: undefined,
|
|
1030
|
+
starterUpgradePending
|
|
1031
|
+
? `record the current packaged Power Apps starter ${plan.starter.commit}`
|
|
1002
1032
|
: undefined
|
|
1003
1033
|
].filter((item) => item !== undefined);
|
|
1004
1034
|
if (manifestMaintenance.length > 0) {
|
|
@@ -1083,6 +1113,9 @@ async function updateCommand(parsed, context) {
|
|
|
1083
1113
|
}
|
|
1084
1114
|
const oldByName = new Map(manifest.artifacts.map((artifact) => [artifact.logicalName, artifact]));
|
|
1085
1115
|
const skippedByName = new Map(skipped.map((entry) => [entry.logicalName, entry]));
|
|
1116
|
+
const hasUnrecordedGovernanceConflict = skipped.some((entry) => entry.status === 'conflict' &&
|
|
1117
|
+
entry.rendered?.category === 'governance' &&
|
|
1118
|
+
!oldByName.has(entry.logicalName));
|
|
1086
1119
|
const nextManifest = buildManifest(plan, render.filter((artifact) => artifact.logicalName !== 'manifest'), { frameworkState: manifest.framework.state });
|
|
1087
1120
|
nextManifest.framework = manifest.framework;
|
|
1088
1121
|
nextManifest.project.specWorkflow = manifest.project.specWorkflow;
|
|
@@ -1093,6 +1126,10 @@ async function updateCommand(parsed, context) {
|
|
|
1093
1126
|
else {
|
|
1094
1127
|
delete nextManifest.project.defaultAgent;
|
|
1095
1128
|
}
|
|
1129
|
+
if (hasUnrecordedGovernanceConflict &&
|
|
1130
|
+
nextManifest.governance.profile !== 'none') {
|
|
1131
|
+
nextManifest.governance.state = 'handoff-partial';
|
|
1132
|
+
}
|
|
1096
1133
|
nextManifest.artifacts = nextManifest.artifacts.flatMap((artifact) => {
|
|
1097
1134
|
// config is user-owned after create: carry the recorded entry forward untouched
|
|
1098
1135
|
if (artifact.logicalName === 'liftoff-config') {
|
|
@@ -1109,7 +1146,12 @@ async function updateCommand(parsed, context) {
|
|
|
1109
1146
|
});
|
|
1110
1147
|
for (const entry of entries) {
|
|
1111
1148
|
if (entry.status === 'orphan') {
|
|
1112
|
-
|
|
1149
|
+
const previous = oldByName.get(entry.logicalName);
|
|
1150
|
+
if (plan.governanceProfile.id === 'none' &&
|
|
1151
|
+
previous.category === 'governance') {
|
|
1152
|
+
continue;
|
|
1153
|
+
}
|
|
1154
|
+
nextManifest.artifacts.push(previous);
|
|
1113
1155
|
}
|
|
1114
1156
|
}
|
|
1115
1157
|
mutations.push({
|
|
@@ -1147,6 +1189,85 @@ async function updateCommand(parsed, context) {
|
|
|
1147
1189
|
presentation.completion('Updated project', `${written.length} written, ${skipped.length} skipped, ${summary.orphan} orphan(s)`, [{ label: 'Manifest version', value: liftoffVersion }], 'liftoff validate && liftoff doctor');
|
|
1148
1190
|
return 0;
|
|
1149
1191
|
}
|
|
1192
|
+
function renderSelfUpgradeResult(value, presentation) {
|
|
1193
|
+
const details = [
|
|
1194
|
+
{ label: 'Current CLI', value: value.currentVersion },
|
|
1195
|
+
...('targetVersion' in value && value.targetVersion
|
|
1196
|
+
? [{ label: 'Canonical target', value: value.targetVersion }]
|
|
1197
|
+
: []),
|
|
1198
|
+
...('registryKind' in value && value.registryKind
|
|
1199
|
+
? [{ label: 'Delivery registry', value: value.registryKind }]
|
|
1200
|
+
: [])
|
|
1201
|
+
];
|
|
1202
|
+
presentation.definitions('CLI upgrade result', details);
|
|
1203
|
+
switch (value.status) {
|
|
1204
|
+
case 'current':
|
|
1205
|
+
presentation.status('success', 'CLI is current', selfUpgradeSummary(value));
|
|
1206
|
+
return;
|
|
1207
|
+
case 'update-available':
|
|
1208
|
+
presentation.status('warning', 'CLI update available', selfUpgradeSummary(value));
|
|
1209
|
+
presentation.command('liftoff upgrade');
|
|
1210
|
+
return;
|
|
1211
|
+
case 'upgraded':
|
|
1212
|
+
presentation.completion('Liftoff CLI upgraded', selfUpgradeSummary(value), [{ label: 'Installed version', value: value.targetVersion }], 'liftoff update --check');
|
|
1213
|
+
return;
|
|
1214
|
+
case 'blocked':
|
|
1215
|
+
case 'failed': {
|
|
1216
|
+
presentation.status(value.status === 'blocked' ? 'warning' : 'error', value.status === 'blocked' ? 'CLI upgrade blocked' : 'CLI upgrade failed', selfUpgradeSummary(value));
|
|
1217
|
+
const remedy = selfUpgradeRemedy(value);
|
|
1218
|
+
if (remedy) {
|
|
1219
|
+
presentation.remedy(remedy);
|
|
1220
|
+
}
|
|
1221
|
+
return;
|
|
1222
|
+
}
|
|
1223
|
+
default: {
|
|
1224
|
+
const exhaustive = value;
|
|
1225
|
+
throw new Error(`Unhandled self-upgrade result: ${String(exhaustive)}`);
|
|
1226
|
+
}
|
|
1227
|
+
}
|
|
1228
|
+
}
|
|
1229
|
+
async function upgradeCommand(parsed, context) {
|
|
1230
|
+
const mode = readBooleanFlag(parsed.flags, 'check') === true ? 'check' : 'apply';
|
|
1231
|
+
const json = readBooleanFlag(parsed.flags, 'json') === true;
|
|
1232
|
+
if (!json) {
|
|
1233
|
+
context.presentation.commandIdentity('upgrade', 'Replace the supported global Liftoff CLI installation');
|
|
1234
|
+
}
|
|
1235
|
+
const execute = context.selfUpgrade ?? ((request) => runSelfUpgrade(request, {
|
|
1236
|
+
environment: context.env ?? process.env
|
|
1237
|
+
}));
|
|
1238
|
+
let value;
|
|
1239
|
+
try {
|
|
1240
|
+
value = await execute({
|
|
1241
|
+
mode,
|
|
1242
|
+
currentVersion: liftoffVersion,
|
|
1243
|
+
stdout: context.stdout,
|
|
1244
|
+
stderr: context.stderr,
|
|
1245
|
+
json,
|
|
1246
|
+
...(!json
|
|
1247
|
+
? {
|
|
1248
|
+
onStage: (stage, detail) => context.presentation.stage(stage, detail),
|
|
1249
|
+
onInstallCommand: (command) => context.presentation.command(formatCommand(command))
|
|
1250
|
+
}
|
|
1251
|
+
: {})
|
|
1252
|
+
});
|
|
1253
|
+
}
|
|
1254
|
+
catch {
|
|
1255
|
+
value = {
|
|
1256
|
+
schemaVersion: 1,
|
|
1257
|
+
mode,
|
|
1258
|
+
status: 'failed',
|
|
1259
|
+
currentVersion: liftoffVersion,
|
|
1260
|
+
reasonCode: 'verification_failed'
|
|
1261
|
+
};
|
|
1262
|
+
}
|
|
1263
|
+
if (json) {
|
|
1264
|
+
context.presentation.rawStdout(`${JSON.stringify(value, null, 2)}\n`);
|
|
1265
|
+
}
|
|
1266
|
+
else {
|
|
1267
|
+
renderSelfUpgradeResult(value, context.presentation);
|
|
1268
|
+
}
|
|
1269
|
+
return selfUpgradeExitCode(value);
|
|
1270
|
+
}
|
|
1150
1271
|
function versionedBinaryCheck(label, command, args, minimum, remedy) {
|
|
1151
1272
|
const result = spawnSync(command, args, { encoding: 'utf8' });
|
|
1152
1273
|
if (result.status !== 0) {
|
|
@@ -1164,6 +1285,9 @@ function versionedBinaryCheck(label, command, args, minimum, remedy) {
|
|
|
1164
1285
|
return { label, severity: 'ok', detail: output };
|
|
1165
1286
|
}
|
|
1166
1287
|
function pythonRuntime() {
|
|
1288
|
+
const [major, minor] = (supportedStack.runtimes.python.minimumVersion ??
|
|
1289
|
+
supportedStack.runtimes.python.version).split('.');
|
|
1290
|
+
const minimum = [Number(major), Number(minor)];
|
|
1167
1291
|
const candidates = process.platform === 'win32'
|
|
1168
1292
|
? [
|
|
1169
1293
|
{ command: 'py', versionArgs: ['-3', '--version'], commandArgs: ['-3'] },
|
|
@@ -1174,7 +1298,7 @@ function pythonRuntime() {
|
|
|
1174
1298
|
{ command: 'python3', versionArgs: ['--version'], commandArgs: [] },
|
|
1175
1299
|
{ command: 'python', versionArgs: ['--version'], commandArgs: [] }
|
|
1176
1300
|
];
|
|
1177
|
-
return candidates.find((candidate) => versionedBinaryCheck('python', candidate.command, candidate.versionArgs,
|
|
1301
|
+
return candidates.find((candidate) => versionedBinaryCheck('python', candidate.command, candidate.versionArgs, minimum, `install Python ${major}.${minor} or newer`).severity === 'ok') ?? candidates.find((candidate) => binaryPresent(candidate.command));
|
|
1178
1302
|
}
|
|
1179
1303
|
function doctorCheckFromProbe(probe) {
|
|
1180
1304
|
const severity = probe.state === 'ready'
|
|
@@ -1298,6 +1422,34 @@ async function frameworkDoctorChecks(projectRoot, manifest) {
|
|
|
1298
1422
|
];
|
|
1299
1423
|
}
|
|
1300
1424
|
function stackProjectCheck(projectRoot, apiStack) {
|
|
1425
|
+
const requiredMetadata = {
|
|
1426
|
+
'python-fastapi': ['backend/pyproject.toml', 'backend/uv.lock'],
|
|
1427
|
+
'node-fastify': ['backend/package.json', 'backend/package-lock.json'],
|
|
1428
|
+
'go-huma': ['backend/go.mod', 'backend/go.sum']
|
|
1429
|
+
};
|
|
1430
|
+
const missingMetadata = requiredMetadata[apiStack].find((relativePath) => !existsSync(path.join(projectRoot, ...relativePath.split('/'))));
|
|
1431
|
+
if (missingMetadata) {
|
|
1432
|
+
return {
|
|
1433
|
+
label: `${apiStack} project`,
|
|
1434
|
+
severity: 'fail',
|
|
1435
|
+
detail: `missing locked dependency metadata: ${missingMetadata}`,
|
|
1436
|
+
remedy: `restore ${missingMetadata} or run liftoff update`
|
|
1437
|
+
};
|
|
1438
|
+
}
|
|
1439
|
+
if (apiStack === 'python-fastapi') {
|
|
1440
|
+
const pyproject = readFileSync(path.join(projectRoot, 'backend', 'pyproject.toml'), 'utf8');
|
|
1441
|
+
const lock = readFileSync(path.join(projectRoot, 'backend', 'uv.lock'), 'utf8');
|
|
1442
|
+
const projectName = pyproject.match(/^\s*name\s*=\s*"([^"]+)"/m)?.[1];
|
|
1443
|
+
const lockedProjectName = lock.match(/\[\[package\]\]\s+name\s*=\s*"([^"]+)"\s+version\s*=\s*"[^"]+"\s+source\s*=\s*\{\s*editable\s*=\s*"\."\s*\}/m)?.[1];
|
|
1444
|
+
if (!projectName || lockedProjectName !== projectName) {
|
|
1445
|
+
return {
|
|
1446
|
+
label: `${apiStack} project`,
|
|
1447
|
+
severity: 'fail',
|
|
1448
|
+
detail: 'pyproject.toml and uv.lock do not identify the same project',
|
|
1449
|
+
remedy: 'restore backend/pyproject.toml and backend/uv.lock or run liftoff update'
|
|
1450
|
+
};
|
|
1451
|
+
}
|
|
1452
|
+
}
|
|
1301
1453
|
let result;
|
|
1302
1454
|
switch (apiStack) {
|
|
1303
1455
|
case 'python-fastapi':
|
|
@@ -1377,39 +1529,37 @@ async function cloudLayer(cloud, runner) {
|
|
|
1377
1529
|
: [{ label: cloud, severity: 'skipped', detail: `${cloud} provider checks are not available yet` }];
|
|
1378
1530
|
return { title: `Cloud - ${cloud}`, checks };
|
|
1379
1531
|
}
|
|
1380
|
-
async function
|
|
1381
|
-
const registry = process.env.LIFTOFF_REGISTRY ?? 'https://registry.npmjs.org';
|
|
1382
|
-
const controller = new AbortController();
|
|
1383
|
-
const timer = setTimeout(() => controller.abort(), 2000);
|
|
1384
|
-
try {
|
|
1385
|
-
const response = await fetch(`${registry}/@msn-control%2fliftoff/latest`, { signal: controller.signal });
|
|
1386
|
-
if (!response.ok) {
|
|
1387
|
-
return undefined;
|
|
1388
|
-
}
|
|
1389
|
-
const data = (await response.json());
|
|
1390
|
-
return data.version;
|
|
1391
|
-
}
|
|
1392
|
-
catch {
|
|
1393
|
-
return undefined; // offline or unreachable: doctor stays quiet about freshness
|
|
1394
|
-
}
|
|
1395
|
-
finally {
|
|
1396
|
-
clearTimeout(timer);
|
|
1397
|
-
}
|
|
1398
|
-
}
|
|
1399
|
-
async function cliLayer() {
|
|
1532
|
+
async function cliLayer(releaseLookup, configuredRegistryLookup) {
|
|
1400
1533
|
const checks = [
|
|
1401
1534
|
{ label: 'version', severity: 'ok', detail: `Liftoff ${liftoffVersion}` }
|
|
1402
1535
|
];
|
|
1403
|
-
|
|
1404
|
-
|
|
1536
|
+
let latest;
|
|
1537
|
+
try {
|
|
1538
|
+
latest = (await releaseLookup()).version;
|
|
1539
|
+
}
|
|
1540
|
+
catch {
|
|
1405
1541
|
return { title: 'CLI', checks };
|
|
1406
1542
|
}
|
|
1407
1543
|
if (compareSemver(latest, liftoffVersion) > 0) {
|
|
1544
|
+
let registryState = {
|
|
1545
|
+
status: 'unavailable'
|
|
1546
|
+
};
|
|
1547
|
+
try {
|
|
1548
|
+
registryState = await configuredRegistryLookup(latest);
|
|
1549
|
+
}
|
|
1550
|
+
catch {
|
|
1551
|
+
// Canonical freshness remains useful when local npm registry inspection fails.
|
|
1552
|
+
}
|
|
1553
|
+
const manual = canonicalManualInstallCommand(latest);
|
|
1408
1554
|
checks.push({
|
|
1409
1555
|
label: 'cli freshness',
|
|
1410
1556
|
severity: 'warn',
|
|
1411
|
-
detail:
|
|
1412
|
-
|
|
1557
|
+
detail: registryState.status === 'stale'
|
|
1558
|
+
? `Liftoff ${latest} is published, but the configured npm registry does not expose it`
|
|
1559
|
+
: `Liftoff ${latest} is published, this CLI is ${liftoffVersion}`,
|
|
1560
|
+
remedy: registryState.status === 'stale'
|
|
1561
|
+
? 'ask the managed registry owner to synchronize the canonical target, then run liftoff upgrade --check'
|
|
1562
|
+
: `run liftoff upgrade --check, then liftoff upgrade; manual fallback where canonical npm is permitted: ${manual}`
|
|
1413
1563
|
});
|
|
1414
1564
|
}
|
|
1415
1565
|
else {
|
|
@@ -1440,12 +1590,53 @@ async function projectLayer(projectRoot, manifest) {
|
|
|
1440
1590
|
label: 'version',
|
|
1441
1591
|
severity: 'warn',
|
|
1442
1592
|
detail: `project written by Liftoff ${manifest.liftoffVersion}, CLI is ${liftoffVersion}`,
|
|
1443
|
-
remedy:
|
|
1593
|
+
remedy: `run liftoff upgrade --check, then liftoff upgrade; manual fallback: ${canonicalManualInstallCommand(manifest.liftoffVersion)}`
|
|
1444
1594
|
});
|
|
1445
1595
|
}
|
|
1446
1596
|
else {
|
|
1447
1597
|
checks.push({ label: 'version', severity: 'ok', detail: `generated by ${manifest.liftoffVersion}, CLI ${liftoffVersion}` });
|
|
1448
1598
|
}
|
|
1599
|
+
if (manifest.governance.profile === 'unspecified') {
|
|
1600
|
+
checks.push({
|
|
1601
|
+
id: 'repository-governance',
|
|
1602
|
+
label: 'repository governance',
|
|
1603
|
+
severity: 'warn',
|
|
1604
|
+
state: 'not-observable',
|
|
1605
|
+
detail: 'manifest predates local governance handoff state',
|
|
1606
|
+
remedy: 'run liftoff update --check to inspect default handoff adoption'
|
|
1607
|
+
});
|
|
1608
|
+
}
|
|
1609
|
+
else if (manifest.governance.profile === 'none') {
|
|
1610
|
+
checks.push({
|
|
1611
|
+
id: 'repository-governance',
|
|
1612
|
+
label: 'repository governance',
|
|
1613
|
+
severity: 'ok',
|
|
1614
|
+
state: 'disabled',
|
|
1615
|
+
detail: 'disabled; no local handoff or live-enforcement claim'
|
|
1616
|
+
});
|
|
1617
|
+
}
|
|
1618
|
+
else {
|
|
1619
|
+
const governanceIssue = issues.find((issue) => /governance|repository-governance/i.test(issue));
|
|
1620
|
+
const partialHandoff = manifest.governance.state === 'handoff-partial';
|
|
1621
|
+
const severity = governanceIssue ? 'fail' : partialHandoff ? 'warn' : 'ok';
|
|
1622
|
+
const detail = governanceIssue ??
|
|
1623
|
+
(partialHandoff
|
|
1624
|
+
? `local ${manifest.governance.profile} policy ${manifest.governance.policyVersion} handoff is incomplete; one or more exact destinations remain outside Liftoff ownership`
|
|
1625
|
+
: `local ${manifest.governance.profile} policy ${manifest.governance.policyVersion} handoff is intact; live enforcement is not inferred`);
|
|
1626
|
+
const remedy = governanceIssue
|
|
1627
|
+
? 'inspect with liftoff update --check; run liftoff update for safe repairs or use --force only after reviewing exact conflicts; neither activates GitHub governance'
|
|
1628
|
+
: partialHandoff
|
|
1629
|
+
? 'inspect liftoff update --check; use liftoff update --force only after reviewing each governance conflict'
|
|
1630
|
+
: undefined;
|
|
1631
|
+
checks.push({
|
|
1632
|
+
id: 'repository-governance',
|
|
1633
|
+
label: 'repository governance',
|
|
1634
|
+
severity,
|
|
1635
|
+
state: governanceIssue ? 'unhealthy' : manifest.governance.state,
|
|
1636
|
+
detail,
|
|
1637
|
+
...(remedy ? { remedy } : {})
|
|
1638
|
+
});
|
|
1639
|
+
}
|
|
1449
1640
|
checks.push(...await frameworkDoctorChecks(projectRoot, manifest));
|
|
1450
1641
|
if (manifest.project.workload.kind === 'power-apps-code-app') {
|
|
1451
1642
|
const starter = manifest.project.workload.starter;
|
|
@@ -1603,6 +1794,7 @@ async function doctorCommand(parsed, context) {
|
|
|
1603
1794
|
const cloudOverride = readStringFlag(parsed.flags, 'cloud');
|
|
1604
1795
|
const layers = [];
|
|
1605
1796
|
context.presentation.commandIdentity('doctor', 'Inspect CLI, workstation, project, runtime, and cloud readiness');
|
|
1797
|
+
const runner = context.runner ?? new NodeCommandRunner();
|
|
1606
1798
|
const projectRoot = await findProjectRoot(context.cwd);
|
|
1607
1799
|
let manifest;
|
|
1608
1800
|
let manifestError;
|
|
@@ -1614,8 +1806,17 @@ async function doctorCommand(parsed, context) {
|
|
|
1614
1806
|
manifestError = error;
|
|
1615
1807
|
}
|
|
1616
1808
|
}
|
|
1617
|
-
|
|
1618
|
-
|
|
1809
|
+
const releaseLookup = context.stableReleaseLookup ?? (() => lookupStableRelease({
|
|
1810
|
+
registry: context.env?.LIFTOFF_REGISTRY ??
|
|
1811
|
+
process.env.LIFTOFF_REGISTRY ??
|
|
1812
|
+
canonicalNpmRegistry
|
|
1813
|
+
}));
|
|
1814
|
+
const configuredRegistryLookup = context.configuredRegistryTargetLookup ??
|
|
1815
|
+
((targetVersion) => checkConfiguredRegistryTarget(targetVersion, {
|
|
1816
|
+
runner,
|
|
1817
|
+
environment: context.env ?? process.env
|
|
1818
|
+
}));
|
|
1819
|
+
layers.push(await cliLayer(releaseLookup, configuredRegistryLookup));
|
|
1619
1820
|
const requirements = manifest
|
|
1620
1821
|
? selectWorkstationRequirements(workstationSelectionFromManifest(manifest), { includeFramework: manifest.framework.state === 'initialized' })
|
|
1621
1822
|
: selectLiftoffRuntimeRequirements();
|
|
@@ -1738,6 +1939,7 @@ async function optionsFromParsedArgs(parsed, cwd, includeProjectName) {
|
|
|
1738
1939
|
agents: readListFlag(parsed.flags, 'agents'),
|
|
1739
1940
|
defaultAgent: readStringFlag(parsed.flags, 'default-agent'),
|
|
1740
1941
|
codeAppsPlugin: readBooleanFlag(parsed.flags, 'code-apps-plugin'),
|
|
1942
|
+
governanceProfile: readStringFlag(parsed.flags, 'governance'),
|
|
1741
1943
|
configPath,
|
|
1742
1944
|
yes: readBooleanFlag(parsed.flags, 'yes') ?? false,
|
|
1743
1945
|
force: readBooleanFlag(parsed.flags, 'force'),
|
|
@@ -1766,6 +1968,7 @@ function hasMissingInitInputs(options) {
|
|
|
1766
1968
|
projectType !== 'power-apps-code-app' && options.includeFrontend === undefined ||
|
|
1767
1969
|
!options.specWorkflow ||
|
|
1768
1970
|
!options.agents ||
|
|
1971
|
+
!options.governanceProfile ||
|
|
1769
1972
|
missingDefaultAgent ||
|
|
1770
1973
|
projectType !== 'power-apps-code-app' && !options.environments;
|
|
1771
1974
|
}
|