@msn-control/liftoff 0.6.2 → 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 +15 -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 +26 -1
- 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 +364 -172
- 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.d.ts +1 -0
- package/dist/file-system.js +141 -7
- 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.d.ts +0 -5
- package/dist/interactive.js +11 -73
- 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 +216 -125
- 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 +99 -22
- package/docs/configuration-and-manifests.md +14 -7
- package/docs/existing-repositories.md +27 -6
- 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 +42 -17
- package/docs/spec-workflows-and-agents.md +22 -8
- package/docs/supported-stack.md +89 -0
- package/docs/telemetry.md +6 -0
- package/docs/troubleshooting.md +84 -8
- package/docs/workloads.md +18 -5
- package/package.json +14 -6
- package/dist/update-impact.d.ts +0 -23
- package/dist/update-impact.js +0 -127
- package/dist/update-impact.js.map +0 -1
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';
|
|
@@ -9,8 +9,8 @@ import { getCodingAgent, getFrameworkDefinition, getSpecWorkflow, listRegions, p
|
|
|
9
9
|
import { probeCodeAppsPlugin } from './code-apps-plugin.js';
|
|
10
10
|
import { initializeFramework } from './framework-adapters.js';
|
|
11
11
|
import { validateFrameworkInstallation } from './framework-validation.js';
|
|
12
|
-
import { artifactPath, applyProjectFileTransaction, assertNewOrEmptyDirectory, captureProjectFileSnapshot, findProjectRoot, loadManifest, manifestDisplayPath, resolveProjectPath, validateGeneratedProject, writeArtifacts, writeProjectFile } from './file-system.js';
|
|
13
|
-
import { InteractiveCancelledError, InteractivePrompter
|
|
12
|
+
import { artifactPath, applyProjectFileTransaction, assertNewOrEmptyDirectory, captureProjectFileSnapshot, findProjectRoot, loadManifest, manifestHadFilteredLegacySeedOwnership, manifestDisplayPath, resolveProjectPath, validateGeneratedProject, writeArtifacts, writeProjectFile } from './file-system.js';
|
|
13
|
+
import { InteractiveCancelledError, InteractivePrompter } from './interactive.js';
|
|
14
14
|
import { applyMergePreflight, assertSafeInitTarget, authorizeMergePreflight, buildMergePreflight, captureTreeState, discoverGitRoot, resolveInitTargetFromDiscovery, validateStagedTree, withStagingArea, writeStagedArtifacts } from './init-filesystem.js';
|
|
15
15
|
import { renderMigrationChecklist, renderMigrationProposal, renderMigrationTasks, seedMigrationGroups } from './migrate-plan.js';
|
|
16
16
|
import { buildProjectPlan, loadConfigOptions, mergeOptions, PlanValidationError, projectPlanEntries } from './planner.js';
|
|
@@ -19,15 +19,19 @@ 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
|
-
import { buildUpdateImpact } from './update-impact.js';
|
|
25
28
|
import { liftoffVersion } from './version.js';
|
|
26
29
|
import { blockingReadinessFailures, detectHostEnvironment, installRequirement, probeWorkstation, selectLiftoffRuntimeRequirements, selectWorkstationRequirements } from './workstation.js';
|
|
27
30
|
export async function runCommand(parsed, context) {
|
|
28
31
|
const helpRequested = parsed.command !== undefined && readBooleanFlag(parsed.flags, 'help') === true;
|
|
29
32
|
const jsonMode = !helpRequested && (parsed.command === 'doctor' ||
|
|
30
33
|
parsed.command === 'update' ||
|
|
34
|
+
parsed.command === 'upgrade' ||
|
|
31
35
|
parsed.command === 'validate') &&
|
|
32
36
|
readBooleanFlag(parsed.flags, 'json') === true;
|
|
33
37
|
const presentation = new PresentationSession({
|
|
@@ -70,6 +74,8 @@ export async function runCommand(parsed, context) {
|
|
|
70
74
|
return await validateCommand(parsed, executionContext);
|
|
71
75
|
case 'update':
|
|
72
76
|
return await updateCommand(parsed, executionContext);
|
|
77
|
+
case 'upgrade':
|
|
78
|
+
return await upgradeCommand(parsed, executionContext);
|
|
73
79
|
case 'migrate':
|
|
74
80
|
return await migrateCommand(parsed, executionContext);
|
|
75
81
|
case 'doctor':
|
|
@@ -199,7 +205,10 @@ async function initCommand(parsed, context) {
|
|
|
199
205
|
presentation.bullets('Configured integrations', [
|
|
200
206
|
`${plan.specWorkflow.label} ${plan.framework.version}`,
|
|
201
207
|
...plan.agents.map((agent) => `${agent.label}${plan.defaultAgent?.id === agent.id ? ' (default)' : ''}`),
|
|
202
|
-
...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`
|
|
203
212
|
]);
|
|
204
213
|
if (readiness.deferred.length > 0) {
|
|
205
214
|
presentation.bullets('Deferred advisory checks', readiness.deferred);
|
|
@@ -210,7 +219,13 @@ async function initCommand(parsed, context) {
|
|
|
210
219
|
presentation.completion(`Initialized ${plan.projectName}`, target.root, [
|
|
211
220
|
{ label: 'Target', value: target.root },
|
|
212
221
|
{ label: 'Spec workflow', value: plan.specWorkflow.label },
|
|
213
|
-
{ 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
|
+
}
|
|
214
229
|
], `liftoff validate ${JSON.stringify(target.root)}`);
|
|
215
230
|
return 0;
|
|
216
231
|
}
|
|
@@ -376,7 +391,7 @@ async function handleProjectDependencies(plan, projectRoot, options, probes, con
|
|
|
376
391
|
if (plan.workload === 'power-apps-code-app') {
|
|
377
392
|
const issues = await verifyPowerAppsPackageMetadata(projectRoot);
|
|
378
393
|
if (issues.length > 0) {
|
|
379
|
-
presentation.error('Power Apps dependency installation blocked', `${issues.join(' ')} Restore package.json and package-lock.json or run \`liftoff update
|
|
394
|
+
presentation.error('Power Apps dependency installation blocked', `${issues.join(' ')} Restore package.json and package-lock.json or run \`liftoff update\`.`);
|
|
380
395
|
return { success: false, deferred: [] };
|
|
381
396
|
}
|
|
382
397
|
}
|
|
@@ -809,71 +824,54 @@ async function preflightUpdate(projectRoot, entries, force) {
|
|
|
809
824
|
function updateSnapshotKey(pathParts) {
|
|
810
825
|
return pathParts.join('\0');
|
|
811
826
|
}
|
|
812
|
-
function
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
const snapshots = new Map(inputSnapshots.map((snapshot) => [updateSnapshotKey(snapshot.pathParts), snapshot]));
|
|
828
|
-
for (const entry of entries) {
|
|
829
|
-
const actionable = entry.status !== 'orphan' &&
|
|
830
|
-
(entry.status !== 'unchanged' || entry.refreshHash === true);
|
|
831
|
-
if (!actionable) {
|
|
832
|
-
continue;
|
|
833
|
-
}
|
|
834
|
-
for (const pathParts of [entry.pathParts, entry.previousPathParts]) {
|
|
835
|
-
if (!pathParts) {
|
|
836
|
-
continue;
|
|
837
|
-
}
|
|
838
|
-
const key = updateSnapshotKey(pathParts);
|
|
839
|
-
if (!snapshots.has(key)) {
|
|
840
|
-
snapshots.set(key, await captureProjectFileSnapshot(projectRoot, pathParts));
|
|
841
|
-
}
|
|
827
|
+
async function captureUpdateSnapshots(projectRoot, manifest, render, initialSnapshots) {
|
|
828
|
+
const snapshots = new Map(initialSnapshots.map((snapshot) => [updateSnapshotKey(snapshot.pathParts), snapshot]));
|
|
829
|
+
const seenKeys = new Set(snapshots.keys());
|
|
830
|
+
const candidates = [
|
|
831
|
+
...manifest.artifacts.map((artifact) => artifact.pathParts),
|
|
832
|
+
...render
|
|
833
|
+
.filter((artifact) => artifact.category !== 'seed' && artifact.category !== 'framework')
|
|
834
|
+
.map((artifact) => artifact.pathParts)
|
|
835
|
+
];
|
|
836
|
+
const uncaptured = [];
|
|
837
|
+
for (const pathParts of candidates) {
|
|
838
|
+
const key = updateSnapshotKey(pathParts);
|
|
839
|
+
if (!seenKeys.has(key)) {
|
|
840
|
+
seenKeys.add(key);
|
|
841
|
+
uncaptured.push(pathParts);
|
|
842
842
|
}
|
|
843
843
|
}
|
|
844
|
-
|
|
844
|
+
for (const snapshot of await Promise.all(uncaptured.map((pathParts) => captureProjectFileSnapshot(projectRoot, pathParts)))) {
|
|
845
|
+
snapshots.set(updateSnapshotKey(snapshot.pathParts), snapshot);
|
|
846
|
+
}
|
|
847
|
+
return [...snapshots.values()];
|
|
845
848
|
}
|
|
846
|
-
function
|
|
847
|
-
const
|
|
849
|
+
function selectUpdatePreconditions(snapshots, entries, mutations) {
|
|
850
|
+
const requiredKeys = new Set([
|
|
848
851
|
updateSnapshotKey(['liftoff.config.json']),
|
|
849
|
-
updateSnapshotKey(
|
|
852
|
+
...mutations.map((mutation) => updateSnapshotKey(mutation.pathParts))
|
|
850
853
|
]);
|
|
851
854
|
for (const entry of entries) {
|
|
852
|
-
const
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
entry.status === 'unchanged' && entry.refreshHash === true ||
|
|
856
|
-
entry.status === 'moved' && (entry.cleanMove === true || force) ||
|
|
857
|
-
entry.status === 'conflict' && force;
|
|
858
|
-
if (!authorized) {
|
|
855
|
+
const adoptsExistingDestination = entry.status === 'moved' && entry.destinationMatches === true;
|
|
856
|
+
if (!(entry.status === 'unchanged' && entry.refreshHash) &&
|
|
857
|
+
!adoptsExistingDestination) {
|
|
859
858
|
continue;
|
|
860
859
|
}
|
|
861
|
-
|
|
860
|
+
requiredKeys.add(updateSnapshotKey(entry.pathParts));
|
|
862
861
|
if (entry.previousPathParts) {
|
|
863
|
-
|
|
862
|
+
requiredKeys.add(updateSnapshotKey(entry.previousPathParts));
|
|
864
863
|
}
|
|
865
864
|
}
|
|
866
|
-
return
|
|
865
|
+
return snapshots.filter((snapshot) => requiredKeys.has(updateSnapshotKey(snapshot.pathParts)));
|
|
867
866
|
}
|
|
868
867
|
async function updateCommand(parsed, context) {
|
|
869
868
|
const { presentation } = context;
|
|
870
|
-
|
|
871
|
-
|
|
869
|
+
const check = readBooleanFlag(parsed.flags, 'check') ?? false;
|
|
870
|
+
const force = readBooleanFlag(parsed.flags, 'force') ?? false;
|
|
872
871
|
const jsonMode = readBooleanFlag(parsed.flags, 'json') ?? false;
|
|
873
|
-
let dirtyWorktreeWarningShown = false;
|
|
874
872
|
presentation.commandIdentity('update', 'Reconcile the project with current Liftoff templates');
|
|
875
|
-
if (force &&
|
|
876
|
-
presentation.error('--force
|
|
873
|
+
if (force && check) {
|
|
874
|
+
presentation.error('--force cannot be combined with --check.', 'Run `liftoff update --check` to inspect drift or `liftoff update --force` to overwrite conflicts.');
|
|
877
875
|
return 1;
|
|
878
876
|
}
|
|
879
877
|
const explicit = parsed.positional[0] ?? readStringFlag(parsed.flags, 'project');
|
|
@@ -882,15 +880,12 @@ async function updateCommand(parsed, context) {
|
|
|
882
880
|
presentation.error(`No liftoff.manifest.json found in ${context.cwd} or any parent directory.`, 'Run this command inside a Liftoff project or provide its path explicitly.');
|
|
883
881
|
return 1;
|
|
884
882
|
}
|
|
885
|
-
const
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
const interactiveInputSnapshots = interactiveReview
|
|
889
|
-
? await Promise.all([
|
|
883
|
+
const initialUpdateSnapshots = check
|
|
884
|
+
? []
|
|
885
|
+
: await Promise.all([
|
|
890
886
|
captureProjectFileSnapshot(projectRoot, ['liftoff.manifest.json']),
|
|
891
887
|
captureProjectFileSnapshot(projectRoot, ['liftoff.config.json'])
|
|
892
|
-
])
|
|
893
|
-
: [];
|
|
888
|
+
]);
|
|
894
889
|
const manifest = await loadManifest(projectRoot);
|
|
895
890
|
if (compareSemver(manifest.liftoffVersion, liftoffVersion) > 0) {
|
|
896
891
|
presentation.error(`This project was written by Liftoff ${manifest.liftoffVersion}, which is newer than this CLI (${liftoffVersion}).`, 'Upgrade the CLI first.');
|
|
@@ -909,10 +904,13 @@ async function updateCommand(parsed, context) {
|
|
|
909
904
|
}
|
|
910
905
|
if (plan.workload === 'power-apps-code-app' && recordedWorkload.kind === 'power-apps-code-app') {
|
|
911
906
|
const recordedStarter = recordedWorkload.starter;
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
plan.starter.
|
|
915
|
-
|
|
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.');
|
|
916
914
|
return 1;
|
|
917
915
|
}
|
|
918
916
|
}
|
|
@@ -939,14 +937,60 @@ async function updateCommand(parsed, context) {
|
|
|
939
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.');
|
|
940
938
|
return 1;
|
|
941
939
|
}
|
|
942
|
-
const
|
|
940
|
+
const renderPlan = manifest.framework.state === 'legacy'
|
|
941
|
+
? { ...plan, agents: [], defaultAgent: undefined }
|
|
942
|
+
: plan;
|
|
943
|
+
const render = buildArtifacts(renderPlan);
|
|
944
|
+
const updateSnapshots = check
|
|
945
|
+
? []
|
|
946
|
+
: await captureUpdateSnapshots(projectRoot, manifest, render, initialUpdateSnapshots);
|
|
943
947
|
const entries = await reconcileProject(manifest, render, projectRoot);
|
|
944
948
|
const summary = summarizeEntries(entries);
|
|
945
|
-
const
|
|
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;
|
|
953
|
+
const nonDurableNames = new Set(render
|
|
954
|
+
.filter((artifact) => artifact.category === 'seed' || artifact.category === 'framework')
|
|
955
|
+
.map((artifact) => artifact.logicalName));
|
|
956
|
+
const manifestOwnershipCleanupPending = manifest.artifacts.some((artifact) => nonDurableNames.has(artifact.logicalName)) || manifestHadFilteredLegacySeedOwnership(manifest);
|
|
957
|
+
const manifestRewritePending = manifestUpgradePending ||
|
|
958
|
+
manifestOwnershipCleanupPending ||
|
|
959
|
+
starterUpgradePending;
|
|
960
|
+
const drift = hasDrift(entries) || manifestRewritePending;
|
|
946
961
|
const visible = entries.filter((entry) => entry.status !== 'unchanged' || entry.refreshHash);
|
|
947
|
-
|
|
948
|
-
|
|
949
|
-
|
|
962
|
+
if (!drift) {
|
|
963
|
+
if (jsonMode) {
|
|
964
|
+
presentation.rawStdout(`${JSON.stringify(check ? {
|
|
965
|
+
schemaVersion: 1,
|
|
966
|
+
mode: 'check',
|
|
967
|
+
cliVersion: liftoffVersion,
|
|
968
|
+
projectVersion: manifest.liftoffVersion,
|
|
969
|
+
entries: [],
|
|
970
|
+
manifestUpgradePending,
|
|
971
|
+
manifestOwnershipCleanupPending,
|
|
972
|
+
summary
|
|
973
|
+
} : {
|
|
974
|
+
schemaVersion: 1,
|
|
975
|
+
mode: 'apply',
|
|
976
|
+
cliVersion: liftoffVersion,
|
|
977
|
+
projectVersion: manifest.liftoffVersion,
|
|
978
|
+
written: [],
|
|
979
|
+
skipped: [],
|
|
980
|
+
manifestUpgradePending,
|
|
981
|
+
manifestOwnershipCleanupPending,
|
|
982
|
+
summary
|
|
983
|
+
}, null, 2)}\n`);
|
|
984
|
+
return 0;
|
|
985
|
+
}
|
|
986
|
+
presentation.definitions('Project versions', [
|
|
987
|
+
{ label: 'Liftoff CLI', value: liftoffVersion },
|
|
988
|
+
{ label: 'Project generated by', value: manifest.liftoffVersion }
|
|
989
|
+
]);
|
|
990
|
+
presentation.status('success', 'No drift', `${summary.unchanged} artifacts match the current templates and configuration`);
|
|
991
|
+
return 0;
|
|
992
|
+
}
|
|
993
|
+
if (check) {
|
|
950
994
|
if (jsonMode) {
|
|
951
995
|
presentation.rawStdout(`${JSON.stringify({
|
|
952
996
|
schemaVersion: 1,
|
|
@@ -960,80 +1004,59 @@ async function updateCommand(parsed, context) {
|
|
|
960
1004
|
previousPath: entry.previousPathParts ? manifestDisplayPath(entry.previousPathParts) : undefined,
|
|
961
1005
|
reason: entry.reason
|
|
962
1006
|
})),
|
|
1007
|
+
manifestUpgradePending,
|
|
1008
|
+
manifestOwnershipCleanupPending,
|
|
963
1009
|
summary
|
|
964
1010
|
}, null, 2)}\n`);
|
|
965
|
-
return
|
|
1011
|
+
return 2;
|
|
966
1012
|
}
|
|
967
1013
|
presentation.definitions('Project versions', [
|
|
968
1014
|
{ label: 'Liftoff CLI', value: liftoffVersion },
|
|
969
1015
|
{ label: 'Project generated by', value: manifest.liftoffVersion }
|
|
970
1016
|
]);
|
|
971
|
-
if (
|
|
972
|
-
presentation.
|
|
973
|
-
|
|
974
|
-
}
|
|
975
|
-
if (presentation.stdout.layout === 'plain') {
|
|
976
|
-
presentation.section('Template drift', visible.map((entry) => `${entryMarker(entry)} ${entryDisplay(entry)} ${entry.reason}`));
|
|
977
|
-
}
|
|
978
|
-
else {
|
|
979
|
-
presentation.table('Template drift', ['Change', 'Artifact', 'Reason'], visible.map((entry) => [entryMarker(entry), entryDisplay(entry), entry.reason]));
|
|
980
|
-
}
|
|
981
|
-
const toWrite = summary.new + summary.missing + summary.upgrade + summary.moved + summary.refresh;
|
|
982
|
-
presentation.status('warning', 'Drift detected', `${toWrite} to write, ${summary.conflict} conflict(s), ${summary.orphan} orphan(s), ${summary.unchanged} unchanged`);
|
|
983
|
-
if (!impact.hasActionableChanges ||
|
|
984
|
-
!interactiveReview) {
|
|
985
|
-
if (impact.hasActionableChanges) {
|
|
986
|
-
presentation.command('liftoff update --apply');
|
|
987
|
-
}
|
|
988
|
-
return 2;
|
|
989
|
-
}
|
|
990
|
-
if (isDirtyGitWorktree(projectRoot)) {
|
|
991
|
-
presentation.warning('The project worktree has uncommitted changes; commit or back up local work before accepting an update.');
|
|
992
|
-
dirtyWorktreeWarningShown = true;
|
|
993
|
-
}
|
|
994
|
-
interactiveSnapshots = await captureInteractiveUpdateSnapshots(projectRoot, entries, interactiveInputSnapshots);
|
|
995
|
-
const reviewedEntries = await reconcileProject(manifest, render, projectRoot);
|
|
996
|
-
if (reconciliationSignature(reviewedEntries) !== reconciliationSignature(entries)) {
|
|
997
|
-
presentation.error('The project changed while Liftoff prepared the update.', 'Run `liftoff update` again to review the current state.');
|
|
998
|
-
return 1;
|
|
999
|
-
}
|
|
1000
|
-
const prompter = new InteractivePrompter({
|
|
1001
|
-
input: context.stdin,
|
|
1002
|
-
output: context.stdout,
|
|
1003
|
-
presentation,
|
|
1004
|
-
cwd: projectRoot
|
|
1005
|
-
});
|
|
1006
|
-
try {
|
|
1007
|
-
prompter.presentUpdateImpact(impact);
|
|
1008
|
-
if (impact.hasSafeActions) {
|
|
1009
|
-
const safeAccepted = await prompter.confirmSafeUpdate(impact.safeActionCount);
|
|
1010
|
-
if (!safeAccepted) {
|
|
1011
|
-
presentation.cancellation('Update declined; no project files were changed.');
|
|
1012
|
-
return 2;
|
|
1013
|
-
}
|
|
1017
|
+
if (visible.length > 0) {
|
|
1018
|
+
if (presentation.stdout.layout === 'plain') {
|
|
1019
|
+
presentation.section('Template drift', visible.map((entry) => `${entryMarker(entry)} ${entryDisplay(entry)} ${entry.reason}`));
|
|
1014
1020
|
}
|
|
1015
|
-
|
|
1016
|
-
|
|
1017
|
-
if (!impact.hasSafeActions && !force) {
|
|
1018
|
-
presentation.cancellation('Update declined; no project files were changed.');
|
|
1019
|
-
return 2;
|
|
1020
|
-
}
|
|
1021
|
+
else {
|
|
1022
|
+
presentation.table('Template drift', ['Change', 'Artifact', 'Reason'], visible.map((entry) => [entryMarker(entry), entryDisplay(entry), entry.reason]));
|
|
1021
1023
|
}
|
|
1022
|
-
apply = true;
|
|
1023
1024
|
}
|
|
1024
|
-
|
|
1025
|
-
|
|
1026
|
-
|
|
1027
|
-
|
|
1028
|
-
|
|
1029
|
-
|
|
1025
|
+
const manifestMaintenance = [
|
|
1026
|
+
manifestUpgradePending ? 'upgrade liftoff.manifest.json to schema v5' : undefined,
|
|
1027
|
+
manifestOwnershipCleanupPending
|
|
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}`
|
|
1032
|
+
: undefined
|
|
1033
|
+
].filter((item) => item !== undefined);
|
|
1034
|
+
if (manifestMaintenance.length > 0) {
|
|
1035
|
+
presentation.bullets('Manifest maintenance', manifestMaintenance);
|
|
1036
|
+
}
|
|
1037
|
+
const toWrite = summary.new +
|
|
1038
|
+
summary.missing +
|
|
1039
|
+
summary.upgrade +
|
|
1040
|
+
summary.moved +
|
|
1041
|
+
summary.refresh +
|
|
1042
|
+
(manifestRewritePending ? 1 : 0);
|
|
1043
|
+
presentation.status('warning', 'Drift detected', `${toWrite} to write, ${summary.conflict} conflict(s), ${summary.orphan} orphan(s), ${summary.unchanged} unchanged`);
|
|
1044
|
+
if (toWrite > 0) {
|
|
1045
|
+
presentation.command('liftoff update');
|
|
1030
1046
|
}
|
|
1031
|
-
|
|
1032
|
-
|
|
1047
|
+
if (summary.conflict > 0) {
|
|
1048
|
+
presentation.command('liftoff update --force');
|
|
1033
1049
|
}
|
|
1050
|
+
return 2;
|
|
1034
1051
|
}
|
|
1035
|
-
if (
|
|
1036
|
-
|
|
1052
|
+
if (isDirtyGitWorktree(projectRoot)) {
|
|
1053
|
+
const warning = 'The project worktree has uncommitted changes; consider committing before applying.';
|
|
1054
|
+
if (jsonMode) {
|
|
1055
|
+
presentation.rawStderr(`Warning: ${warning}\n`);
|
|
1056
|
+
}
|
|
1057
|
+
else {
|
|
1058
|
+
presentation.warning(warning);
|
|
1059
|
+
}
|
|
1037
1060
|
}
|
|
1038
1061
|
presentation.stage('Apply safe template changes', projectRoot);
|
|
1039
1062
|
await preflightUpdate(projectRoot, entries, force);
|
|
@@ -1090,6 +1113,9 @@ async function updateCommand(parsed, context) {
|
|
|
1090
1113
|
}
|
|
1091
1114
|
const oldByName = new Map(manifest.artifacts.map((artifact) => [artifact.logicalName, artifact]));
|
|
1092
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));
|
|
1093
1119
|
const nextManifest = buildManifest(plan, render.filter((artifact) => artifact.logicalName !== 'manifest'), { frameworkState: manifest.framework.state });
|
|
1094
1120
|
nextManifest.framework = manifest.framework;
|
|
1095
1121
|
nextManifest.project.specWorkflow = manifest.project.specWorkflow;
|
|
@@ -1100,6 +1126,10 @@ async function updateCommand(parsed, context) {
|
|
|
1100
1126
|
else {
|
|
1101
1127
|
delete nextManifest.project.defaultAgent;
|
|
1102
1128
|
}
|
|
1129
|
+
if (hasUnrecordedGovernanceConflict &&
|
|
1130
|
+
nextManifest.governance.profile !== 'none') {
|
|
1131
|
+
nextManifest.governance.state = 'handoff-partial';
|
|
1132
|
+
}
|
|
1103
1133
|
nextManifest.artifacts = nextManifest.artifacts.flatMap((artifact) => {
|
|
1104
1134
|
// config is user-owned after create: carry the recorded entry forward untouched
|
|
1105
1135
|
if (artifact.logicalName === 'liftoff-config') {
|
|
@@ -1116,7 +1146,12 @@ async function updateCommand(parsed, context) {
|
|
|
1116
1146
|
});
|
|
1117
1147
|
for (const entry of entries) {
|
|
1118
1148
|
if (entry.status === 'orphan') {
|
|
1119
|
-
|
|
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);
|
|
1120
1155
|
}
|
|
1121
1156
|
}
|
|
1122
1157
|
mutations.push({
|
|
@@ -1124,14 +1159,8 @@ async function updateCommand(parsed, context) {
|
|
|
1124
1159
|
pathParts: ['liftoff.manifest.json'],
|
|
1125
1160
|
content: `${JSON.stringify(nextManifest, null, 2)}\n`
|
|
1126
1161
|
});
|
|
1127
|
-
const authorizedSnapshotKeys = interactiveSnapshots
|
|
1128
|
-
? authorizedInteractiveSnapshotKeys(entries, force)
|
|
1129
|
-
: undefined;
|
|
1130
|
-
const transactionPreconditions = interactiveSnapshots && authorizedSnapshotKeys
|
|
1131
|
-
? [...interactiveSnapshots.values()].filter((snapshot) => authorizedSnapshotKeys.has(updateSnapshotKey(snapshot.pathParts)))
|
|
1132
|
-
: undefined;
|
|
1133
1162
|
await applyProjectFileTransaction(projectRoot, mutations, {
|
|
1134
|
-
|
|
1163
|
+
preconditions: selectUpdatePreconditions(updateSnapshots, entries, mutations)
|
|
1135
1164
|
});
|
|
1136
1165
|
if (jsonMode) {
|
|
1137
1166
|
presentation.rawStdout(`${JSON.stringify({
|
|
@@ -1141,6 +1170,8 @@ async function updateCommand(parsed, context) {
|
|
|
1141
1170
|
projectVersion: manifest.liftoffVersion,
|
|
1142
1171
|
written: written.map((entry) => manifestDisplayPath(entry.pathParts)),
|
|
1143
1172
|
skipped: skipped.map((entry) => ({ path: manifestDisplayPath(entry.pathParts), reason: entry.reason })),
|
|
1173
|
+
manifestUpgradePending,
|
|
1174
|
+
manifestOwnershipCleanupPending,
|
|
1144
1175
|
summary
|
|
1145
1176
|
}, null, 2)}\n`);
|
|
1146
1177
|
return 0;
|
|
@@ -1149,7 +1180,7 @@ async function updateCommand(parsed, context) {
|
|
|
1149
1180
|
presentation.bullets('Applied changes', written.map((entry) => `wrote ${entryDisplay(entry)}`));
|
|
1150
1181
|
}
|
|
1151
1182
|
if (skipped.length > 0) {
|
|
1152
|
-
presentation.bullets('Skipped conflicts', skipped.map((entry) => `skipped ${entryDisplay(entry)} ${entry.reason}${force ? '' : ' (use --
|
|
1183
|
+
presentation.bullets('Skipped conflicts', skipped.map((entry) => `skipped ${entryDisplay(entry)} ${entry.reason}${force ? '' : ' (use --force to overwrite)'}`));
|
|
1153
1184
|
}
|
|
1154
1185
|
const orphans = entries.filter((entry) => entry.status === 'orphan');
|
|
1155
1186
|
if (orphans.length > 0) {
|
|
@@ -1158,6 +1189,85 @@ async function updateCommand(parsed, context) {
|
|
|
1158
1189
|
presentation.completion('Updated project', `${written.length} written, ${skipped.length} skipped, ${summary.orphan} orphan(s)`, [{ label: 'Manifest version', value: liftoffVersion }], 'liftoff validate && liftoff doctor');
|
|
1159
1190
|
return 0;
|
|
1160
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
|
+
}
|
|
1161
1271
|
function versionedBinaryCheck(label, command, args, minimum, remedy) {
|
|
1162
1272
|
const result = spawnSync(command, args, { encoding: 'utf8' });
|
|
1163
1273
|
if (result.status !== 0) {
|
|
@@ -1175,6 +1285,9 @@ function versionedBinaryCheck(label, command, args, minimum, remedy) {
|
|
|
1175
1285
|
return { label, severity: 'ok', detail: output };
|
|
1176
1286
|
}
|
|
1177
1287
|
function pythonRuntime() {
|
|
1288
|
+
const [major, minor] = (supportedStack.runtimes.python.minimumVersion ??
|
|
1289
|
+
supportedStack.runtimes.python.version).split('.');
|
|
1290
|
+
const minimum = [Number(major), Number(minor)];
|
|
1178
1291
|
const candidates = process.platform === 'win32'
|
|
1179
1292
|
? [
|
|
1180
1293
|
{ command: 'py', versionArgs: ['-3', '--version'], commandArgs: ['-3'] },
|
|
@@ -1185,7 +1298,7 @@ function pythonRuntime() {
|
|
|
1185
1298
|
{ command: 'python3', versionArgs: ['--version'], commandArgs: [] },
|
|
1186
1299
|
{ command: 'python', versionArgs: ['--version'], commandArgs: [] }
|
|
1187
1300
|
];
|
|
1188
|
-
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));
|
|
1189
1302
|
}
|
|
1190
1303
|
function doctorCheckFromProbe(probe) {
|
|
1191
1304
|
const severity = probe.state === 'ready'
|
|
@@ -1309,6 +1422,34 @@ async function frameworkDoctorChecks(projectRoot, manifest) {
|
|
|
1309
1422
|
];
|
|
1310
1423
|
}
|
|
1311
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
|
+
}
|
|
1312
1453
|
let result;
|
|
1313
1454
|
switch (apiStack) {
|
|
1314
1455
|
case 'python-fastapi':
|
|
@@ -1357,7 +1498,7 @@ async function powerAppsProjectCheck(projectRoot) {
|
|
|
1357
1498
|
severity: 'fail',
|
|
1358
1499
|
state: 'unhealthy',
|
|
1359
1500
|
detail: issues[0],
|
|
1360
|
-
remedy: 'restore package.json and package-lock.json or run liftoff update
|
|
1501
|
+
remedy: 'restore package.json and package-lock.json or run liftoff update'
|
|
1361
1502
|
};
|
|
1362
1503
|
}
|
|
1363
1504
|
function runReadOnly(command, args, cwd) {
|
|
@@ -1388,39 +1529,37 @@ async function cloudLayer(cloud, runner) {
|
|
|
1388
1529
|
: [{ label: cloud, severity: 'skipped', detail: `${cloud} provider checks are not available yet` }];
|
|
1389
1530
|
return { title: `Cloud - ${cloud}`, checks };
|
|
1390
1531
|
}
|
|
1391
|
-
async function
|
|
1392
|
-
const registry = process.env.LIFTOFF_REGISTRY ?? 'https://registry.npmjs.org';
|
|
1393
|
-
const controller = new AbortController();
|
|
1394
|
-
const timer = setTimeout(() => controller.abort(), 2000);
|
|
1395
|
-
try {
|
|
1396
|
-
const response = await fetch(`${registry}/@msn-control%2fliftoff/latest`, { signal: controller.signal });
|
|
1397
|
-
if (!response.ok) {
|
|
1398
|
-
return undefined;
|
|
1399
|
-
}
|
|
1400
|
-
const data = (await response.json());
|
|
1401
|
-
return data.version;
|
|
1402
|
-
}
|
|
1403
|
-
catch {
|
|
1404
|
-
return undefined; // offline or unreachable: doctor stays quiet about freshness
|
|
1405
|
-
}
|
|
1406
|
-
finally {
|
|
1407
|
-
clearTimeout(timer);
|
|
1408
|
-
}
|
|
1409
|
-
}
|
|
1410
|
-
async function cliLayer() {
|
|
1532
|
+
async function cliLayer(releaseLookup, configuredRegistryLookup) {
|
|
1411
1533
|
const checks = [
|
|
1412
1534
|
{ label: 'version', severity: 'ok', detail: `Liftoff ${liftoffVersion}` }
|
|
1413
1535
|
];
|
|
1414
|
-
|
|
1415
|
-
|
|
1536
|
+
let latest;
|
|
1537
|
+
try {
|
|
1538
|
+
latest = (await releaseLookup()).version;
|
|
1539
|
+
}
|
|
1540
|
+
catch {
|
|
1416
1541
|
return { title: 'CLI', checks };
|
|
1417
1542
|
}
|
|
1418
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);
|
|
1419
1554
|
checks.push({
|
|
1420
1555
|
label: 'cli freshness',
|
|
1421
1556
|
severity: 'warn',
|
|
1422
|
-
detail:
|
|
1423
|
-
|
|
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}`
|
|
1424
1563
|
});
|
|
1425
1564
|
}
|
|
1426
1565
|
else {
|
|
@@ -1440,7 +1579,7 @@ async function projectLayer(projectRoot, manifest) {
|
|
|
1440
1579
|
label: 'manifest',
|
|
1441
1580
|
severity: 'fail',
|
|
1442
1581
|
detail: `${issues.length} issue(s): ${issues[0]}${issues.length > 1 ? ' ...' : ''}`,
|
|
1443
|
-
remedy: 'restore missing artifacts or run liftoff update
|
|
1582
|
+
remedy: 'restore missing artifacts or run liftoff update'
|
|
1444
1583
|
});
|
|
1445
1584
|
}
|
|
1446
1585
|
else {
|
|
@@ -1451,12 +1590,53 @@ async function projectLayer(projectRoot, manifest) {
|
|
|
1451
1590
|
label: 'version',
|
|
1452
1591
|
severity: 'warn',
|
|
1453
1592
|
detail: `project written by Liftoff ${manifest.liftoffVersion}, CLI is ${liftoffVersion}`,
|
|
1454
|
-
remedy:
|
|
1593
|
+
remedy: `run liftoff upgrade --check, then liftoff upgrade; manual fallback: ${canonicalManualInstallCommand(manifest.liftoffVersion)}`
|
|
1455
1594
|
});
|
|
1456
1595
|
}
|
|
1457
1596
|
else {
|
|
1458
1597
|
checks.push({ label: 'version', severity: 'ok', detail: `generated by ${manifest.liftoffVersion}, CLI ${liftoffVersion}` });
|
|
1459
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
|
+
}
|
|
1460
1640
|
checks.push(...await frameworkDoctorChecks(projectRoot, manifest));
|
|
1461
1641
|
if (manifest.project.workload.kind === 'power-apps-code-app') {
|
|
1462
1642
|
const starter = manifest.project.workload.starter;
|
|
@@ -1614,6 +1794,7 @@ async function doctorCommand(parsed, context) {
|
|
|
1614
1794
|
const cloudOverride = readStringFlag(parsed.flags, 'cloud');
|
|
1615
1795
|
const layers = [];
|
|
1616
1796
|
context.presentation.commandIdentity('doctor', 'Inspect CLI, workstation, project, runtime, and cloud readiness');
|
|
1797
|
+
const runner = context.runner ?? new NodeCommandRunner();
|
|
1617
1798
|
const projectRoot = await findProjectRoot(context.cwd);
|
|
1618
1799
|
let manifest;
|
|
1619
1800
|
let manifestError;
|
|
@@ -1625,8 +1806,17 @@ async function doctorCommand(parsed, context) {
|
|
|
1625
1806
|
manifestError = error;
|
|
1626
1807
|
}
|
|
1627
1808
|
}
|
|
1628
|
-
|
|
1629
|
-
|
|
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));
|
|
1630
1820
|
const requirements = manifest
|
|
1631
1821
|
? selectWorkstationRequirements(workstationSelectionFromManifest(manifest), { includeFramework: manifest.framework.state === 'initialized' })
|
|
1632
1822
|
: selectLiftoffRuntimeRequirements();
|
|
@@ -1749,6 +1939,7 @@ async function optionsFromParsedArgs(parsed, cwd, includeProjectName) {
|
|
|
1749
1939
|
agents: readListFlag(parsed.flags, 'agents'),
|
|
1750
1940
|
defaultAgent: readStringFlag(parsed.flags, 'default-agent'),
|
|
1751
1941
|
codeAppsPlugin: readBooleanFlag(parsed.flags, 'code-apps-plugin'),
|
|
1942
|
+
governanceProfile: readStringFlag(parsed.flags, 'governance'),
|
|
1752
1943
|
configPath,
|
|
1753
1944
|
yes: readBooleanFlag(parsed.flags, 'yes') ?? false,
|
|
1754
1945
|
force: readBooleanFlag(parsed.flags, 'force'),
|
|
@@ -1777,6 +1968,7 @@ function hasMissingInitInputs(options) {
|
|
|
1777
1968
|
projectType !== 'power-apps-code-app' && options.includeFrontend === undefined ||
|
|
1778
1969
|
!options.specWorkflow ||
|
|
1779
1970
|
!options.agents ||
|
|
1971
|
+
!options.governanceProfile ||
|
|
1780
1972
|
missingDefaultAgent ||
|
|
1781
1973
|
projectType !== 'power-apps-code-app' && !options.environments;
|
|
1782
1974
|
}
|