@msn-control/liftoff 0.8.0 → 0.9.1
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 +11 -5
- package/assets/locks/frontend/package-lock.json +51 -51
- package/assets/locks/frontend/package.json +1 -1
- package/assets/locks/node-backend/package-lock.json +353 -12
- package/assets/locks/node-backend/package.json +3 -3
- package/assets/supported-stack.json +9 -9
- package/dist/args.js +7 -5
- package/dist/args.js.map +1 -1
- package/dist/artifact-lifecycle.d.ts +4 -0
- package/dist/artifact-lifecycle.js +38 -0
- package/dist/artifact-lifecycle.js.map +1 -0
- package/dist/commands.js +415 -77
- package/dist/commands.js.map +1 -1
- package/dist/file-system.d.ts +1 -1
- package/dist/file-system.js +171 -29
- package/dist/file-system.js.map +1 -1
- package/dist/framework-adapters.d.ts +1 -1
- package/dist/framework-adapters.js +9 -3
- package/dist/framework-adapters.js.map +1 -1
- package/dist/framework-validation.d.ts +1 -0
- package/dist/framework-validation.js +26 -0
- package/dist/framework-validation.js.map +1 -1
- package/dist/init-filesystem.js +2 -2
- package/dist/init-filesystem.js.map +1 -1
- package/dist/interactive.d.ts +13 -0
- package/dist/interactive.js +14 -0
- package/dist/interactive.js.map +1 -1
- package/dist/openspec-profile.d.ts +28 -0
- package/dist/openspec-profile.js +172 -0
- package/dist/openspec-profile.js.map +1 -0
- package/dist/planner.js +19 -1
- package/dist/planner.js.map +1 -1
- package/dist/power-apps-templates.js +15 -1
- package/dist/power-apps-templates.js.map +1 -1
- package/dist/reconcile.js +4 -10
- package/dist/reconcile.js.map +1 -1
- package/dist/repository-governance.js +4 -0
- package/dist/repository-governance.js.map +1 -1
- package/dist/templates.d.ts +5 -1
- package/dist/templates.js +126 -60
- package/dist/templates.js.map +1 -1
- package/dist/types.d.ts +27 -4
- package/docs/cli-reference.md +52 -25
- package/docs/configuration-and-manifests.md +39 -24
- package/docs/existing-repositories.md +38 -23
- package/docs/getting-started.md +17 -7
- package/docs/prerequisites.md +17 -0
- package/docs/project-structure.md +13 -1
- package/docs/repository-governance.md +2 -2
- package/docs/safety-and-consent.md +47 -30
- package/docs/spec-workflows-and-agents.md +47 -7
- package/docs/supported-stack.md +14 -17
- package/docs/troubleshooting.md +53 -8
- package/docs/workloads.md +9 -8
- package/package.json +1 -1
package/dist/commands.js
CHANGED
|
@@ -5,14 +5,15 @@ import { cp, lstat, mkdir, mkdtemp, readFile, readdir, readlink, rm, stat } from
|
|
|
5
5
|
import os from 'node:os';
|
|
6
6
|
import path from 'node:path';
|
|
7
7
|
import { getCommandHelp, getGeneralHelp, readBooleanFlag, readListFlag, readStringFlag } from './args.js';
|
|
8
|
-
import { getCodingAgent, getFrameworkDefinition, getSpecWorkflow, listRegions, patterns, providers, searchRegions } from './catalogs.js';
|
|
8
|
+
import { getCodingAgent, getEnvironment, getFrameworkDefinition, getSpecWorkflow, listRegions, patterns, providers, searchRegions } from './catalogs.js';
|
|
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,
|
|
12
|
+
import { artifactPath, applyProjectFileTransaction, assertNewOrEmptyDirectory, captureProjectFileSnapshot, findProjectRoot, loadManifest, manifestHadFilteredLegacyNonDurableOwnership, manifestDisplayPath, readProjectFile, resolveProjectPath, validateGeneratedProject, writeArtifacts, writeProjectFile } from './file-system.js';
|
|
13
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
|
+
import { buildOpenSpecProfileWriteCommands, configureOpenSpecProfile, inspectOpenSpecProfile, OPEN_SPEC_DELIVERY, OPEN_SPEC_PROFILE, OPEN_SPEC_WORKFLOW_IDS } from './openspec-profile.js';
|
|
16
17
|
import { buildProjectPlan, loadConfigOptions, mergeOptions, PlanValidationError, projectPlanEntries } from './planner.js';
|
|
17
18
|
import { buildDependencySetupPlan, dependencyResumeCommand, runDependencySetup, verifyPowerAppsPackageMetadata } from './project-dependencies.js';
|
|
18
19
|
import { formatCommand, NodeCommandRunner } from './process-runner.js';
|
|
@@ -158,12 +159,17 @@ async function initCommand(parsed, context) {
|
|
|
158
159
|
if (!readiness.ready) {
|
|
159
160
|
return 1;
|
|
160
161
|
}
|
|
162
|
+
const profileReadiness = await ensureOpenSpecProfileReady(plan, options, context, runner, presentation, prompter);
|
|
163
|
+
if (!profileReadiness.ready) {
|
|
164
|
+
return 1;
|
|
165
|
+
}
|
|
161
166
|
presentation.stage('Stage project files');
|
|
162
167
|
const staged = await withStagingArea(async (area) => {
|
|
163
168
|
const partition = partitionGeneratedArtifacts(buildArtifacts(plan));
|
|
164
|
-
await writeStagedArtifacts(area, partition.
|
|
169
|
+
await writeStagedArtifacts(area, partition.liftoff, 'liftoff');
|
|
165
170
|
presentation.stage('Initialize spec-driven framework', `${plan.specWorkflow.label} ${plan.framework.version}`);
|
|
166
171
|
await initializeFramework(area, plan, runner, {
|
|
172
|
+
env: context.env,
|
|
167
173
|
...presentation.childStreams(),
|
|
168
174
|
onCommand: (command) => presentation.command(command)
|
|
169
175
|
});
|
|
@@ -206,6 +212,15 @@ async function initCommand(parsed, context) {
|
|
|
206
212
|
`${plan.specWorkflow.label} ${plan.framework.version}`,
|
|
207
213
|
...plan.agents.map((agent) => `${agent.label}${plan.defaultAgent?.id === agent.id ? ' (default)' : ''}`),
|
|
208
214
|
...readiness.pluginProbes.map((probe) => `Code Apps plugin for ${probe.agent.label}: ${probe.state}`),
|
|
215
|
+
...(plan.specWorkflow.id === 'openspec'
|
|
216
|
+
? [
|
|
217
|
+
`OpenSpec global profile: ${profileReadiness.changed ? 'configured' : 'verified'}; ` +
|
|
218
|
+
`${OPEN_SPEC_WORKFLOW_IDS.length} workflows; skills and commands`,
|
|
219
|
+
...(plan.agents.some((agent) => agent.id === 'github-copilot')
|
|
220
|
+
? [`GitHub Copilot cloud agent: ${plan.copilotCloud ? 'enabled' : 'disabled'}`]
|
|
221
|
+
: [])
|
|
222
|
+
]
|
|
223
|
+
: []),
|
|
209
224
|
plan.governanceProfile.id === 'none'
|
|
210
225
|
? 'Repository governance: disabled'
|
|
211
226
|
: `Repository governance: ${plan.governanceProfile.label} policy ${plan.governanceProfile.policyVersion}; local handoff generated, live activation deferred`
|
|
@@ -240,7 +255,13 @@ async function planCommand(parsed, context) {
|
|
|
240
255
|
const plan = buildProjectPlan(options, { requireProjectName: false });
|
|
241
256
|
const artifacts = buildArtifacts(plan);
|
|
242
257
|
presentation.definitions('Project decisions', projectPlanEntries(plan));
|
|
243
|
-
presentation.table(`Artifacts (${artifacts.length})`, ['Artifact', 'Path'], artifacts.map((artifact) => [
|
|
258
|
+
presentation.table(`Artifacts (${artifacts.length})`, ['Artifact', 'Lifecycle', 'Path'], artifacts.map((artifact) => [
|
|
259
|
+
artifact.logicalName,
|
|
260
|
+
artifact.lifecycle === 'project'
|
|
261
|
+
? `${artifact.lifecycle} (${artifact.provisioningGroup})`
|
|
262
|
+
: artifact.lifecycle,
|
|
263
|
+
artifact.pathParts.join('/')
|
|
264
|
+
]));
|
|
244
265
|
const workstationRows = selectWorkstationRequirements(plan).map((requirement) => [
|
|
245
266
|
requirement.definition.label,
|
|
246
267
|
requirement.exactVersion
|
|
@@ -374,6 +395,58 @@ async function ensureWorkstationReady(plan, options, context, runner, presentati
|
|
|
374
395
|
];
|
|
375
396
|
return { ready: true, deferred, probes, pluginProbes };
|
|
376
397
|
}
|
|
398
|
+
async function ensureOpenSpecProfileReady(plan, options, context, runner, presentation, prompter, resumeInvocation = 'liftoff init') {
|
|
399
|
+
if (plan.specWorkflow.id !== 'openspec') {
|
|
400
|
+
return { ready: true, changed: false };
|
|
401
|
+
}
|
|
402
|
+
presentation.stage('Check OpenSpec global profile');
|
|
403
|
+
const inspection = await inspectOpenSpecProfile(plan.framework.executable, runner, {
|
|
404
|
+
cwd: context.cwd,
|
|
405
|
+
env: context.env
|
|
406
|
+
});
|
|
407
|
+
if (inspection.compatible) {
|
|
408
|
+
presentation.status('success', 'OpenSpec global profile', `${OPEN_SPEC_PROFILE}; ${OPEN_SPEC_DELIVERY}; ${OPEN_SPEC_WORKFLOW_IDS.length} workflows`);
|
|
409
|
+
return { ready: true, changed: false };
|
|
410
|
+
}
|
|
411
|
+
const commands = buildOpenSpecProfileWriteCommands(plan.framework.executable);
|
|
412
|
+
const authorized = options.configureOpenSpecProfile === true ||
|
|
413
|
+
(options.configureOpenSpecProfile === undefined &&
|
|
414
|
+
prompter !== undefined &&
|
|
415
|
+
await prompter.confirmOpenSpecProfileConfiguration({
|
|
416
|
+
observed: [
|
|
417
|
+
{ label: 'Profile', value: inspection.state.profile },
|
|
418
|
+
{ label: 'Delivery', value: inspection.state.delivery },
|
|
419
|
+
{
|
|
420
|
+
label: 'Workflows',
|
|
421
|
+
value: inspection.state.workflows.length > 0
|
|
422
|
+
? inspection.state.workflows.join(', ')
|
|
423
|
+
: '(none)'
|
|
424
|
+
}
|
|
425
|
+
],
|
|
426
|
+
required: [
|
|
427
|
+
{ label: 'Profile', value: OPEN_SPEC_PROFILE },
|
|
428
|
+
{ label: 'Delivery', value: OPEN_SPEC_DELIVERY },
|
|
429
|
+
{ label: 'Workflows', value: OPEN_SPEC_WORKFLOW_IDS.join(', ') }
|
|
430
|
+
],
|
|
431
|
+
differences: inspection.differences,
|
|
432
|
+
commands: commands.map((command) => formatCommand(command))
|
|
433
|
+
}));
|
|
434
|
+
if (!authorized) {
|
|
435
|
+
presentation.error('The global OpenSpec profile does not satisfy the Liftoff template contract.', `Run ${commands.map((command) => `\`${formatCommand(command)}\``).join(', then ')}, ` +
|
|
436
|
+
`then rerun \`${resumeInvocation}\`; or authorize those commands with ` +
|
|
437
|
+
'`--configure-openspec-profile`.');
|
|
438
|
+
return { ready: false, changed: false };
|
|
439
|
+
}
|
|
440
|
+
presentation.stage('Configure OpenSpec global profile');
|
|
441
|
+
await configureOpenSpecProfile(plan.framework.executable, runner, {
|
|
442
|
+
cwd: context.cwd,
|
|
443
|
+
env: context.env,
|
|
444
|
+
...presentation.childStreams(),
|
|
445
|
+
onCommand: (command) => presentation.command(formatCommand(command))
|
|
446
|
+
});
|
|
447
|
+
presentation.status('success', 'OpenSpec global profile', `${OPEN_SPEC_PROFILE}; ${OPEN_SPEC_DELIVERY}; ${OPEN_SPEC_WORKFLOW_IDS.length} workflows (configured)`);
|
|
448
|
+
return { ready: true, changed: true };
|
|
449
|
+
}
|
|
377
450
|
async function handleProjectDependencies(plan, projectRoot, options, probes, context, runner, presentation, prompter) {
|
|
378
451
|
const dependencyPlan = buildDependencySetupPlan(plan, projectRoot, probes);
|
|
379
452
|
let installDependencies = options.installDependencies === true;
|
|
@@ -391,7 +464,7 @@ async function handleProjectDependencies(plan, projectRoot, options, probes, con
|
|
|
391
464
|
if (plan.workload === 'power-apps-code-app') {
|
|
392
465
|
const issues = await verifyPowerAppsPackageMetadata(projectRoot);
|
|
393
466
|
if (issues.length > 0) {
|
|
394
|
-
presentation.error('Power Apps dependency installation blocked', `${issues.join(' ')}
|
|
467
|
+
presentation.error('Power Apps dependency installation blocked', `${issues.join(' ')} Repair the project-owned package.json and package-lock.json before installing dependencies.`);
|
|
395
468
|
return { success: false, deferred: [] };
|
|
396
469
|
}
|
|
397
470
|
}
|
|
@@ -550,12 +623,14 @@ function migrationPlanArtifacts(plan, inventory) {
|
|
|
550
623
|
{
|
|
551
624
|
logicalName: 'migration-proposal',
|
|
552
625
|
category: 'seed',
|
|
626
|
+
lifecycle: 'seed',
|
|
553
627
|
pathParts: ['openspec', 'changes', 'migrate-to-liftoff', 'proposal.md'],
|
|
554
628
|
content: renderMigrationProposal(plan, inventory)
|
|
555
629
|
},
|
|
556
630
|
{
|
|
557
631
|
logicalName: 'migration-tasks',
|
|
558
632
|
category: 'seed',
|
|
633
|
+
lifecycle: 'seed',
|
|
559
634
|
pathParts: ['openspec', 'changes', 'migrate-to-liftoff', 'tasks.md'],
|
|
560
635
|
content: renderMigrationTasks(groups)
|
|
561
636
|
}
|
|
@@ -567,6 +642,7 @@ function migrationPlanArtifacts(plan, inventory) {
|
|
|
567
642
|
artifacts: [{
|
|
568
643
|
logicalName: 'migration-checklist',
|
|
569
644
|
category: 'seed',
|
|
645
|
+
lifecycle: 'seed',
|
|
570
646
|
pathParts: ['MIGRATION.md'],
|
|
571
647
|
content: renderMigrationChecklist(plan, inventory, groups)
|
|
572
648
|
}],
|
|
@@ -654,13 +730,18 @@ async function executeMigration(parsed, context, sourceRoot) {
|
|
|
654
730
|
if (!readiness.ready) {
|
|
655
731
|
return 1;
|
|
656
732
|
}
|
|
733
|
+
const profileReadiness = await ensureOpenSpecProfileReady(plan, options, context, runner, presentation, prompter, `liftoff migrate ${JSON.stringify(sourceRoot)}`);
|
|
734
|
+
if (!profileReadiness.ready) {
|
|
735
|
+
return 1;
|
|
736
|
+
}
|
|
657
737
|
const migrationPlan = migrationPlanArtifacts(plan, inventory);
|
|
658
738
|
presentation.stage('Stage fresh migration project');
|
|
659
739
|
await withStagingArea(async (area) => {
|
|
660
740
|
const partition = partitionGeneratedArtifacts(buildArtifacts(plan));
|
|
661
|
-
await writeStagedArtifacts(area, partition.
|
|
741
|
+
await writeStagedArtifacts(area, partition.liftoff, 'liftoff');
|
|
662
742
|
presentation.stage('Initialize spec-driven framework', `${plan.specWorkflow.label} ${plan.framework.version}`);
|
|
663
743
|
await initializeFramework(area, plan, runner, {
|
|
744
|
+
env: context.env,
|
|
664
745
|
...presentation.childStreams(),
|
|
665
746
|
onCommand: (command) => presentation.command(command)
|
|
666
747
|
});
|
|
@@ -699,7 +780,16 @@ async function executeMigration(parsed, context, sourceRoot) {
|
|
|
699
780
|
}
|
|
700
781
|
presentation.bullets('Configured integrations', [
|
|
701
782
|
`${plan.specWorkflow.label} ${plan.framework.version}`,
|
|
702
|
-
...plan.agents.map((agent) => `${agent.label}${plan.defaultAgent?.id === agent.id ? ' (default)' : ''}`)
|
|
783
|
+
...plan.agents.map((agent) => `${agent.label}${plan.defaultAgent?.id === agent.id ? ' (default)' : ''}`),
|
|
784
|
+
...(plan.specWorkflow.id === 'openspec'
|
|
785
|
+
? [
|
|
786
|
+
`OpenSpec global profile: ${profileReadiness.changed ? 'configured' : 'verified'}; ` +
|
|
787
|
+
`${OPEN_SPEC_WORKFLOW_IDS.length} workflows; skills and commands`,
|
|
788
|
+
...(plan.agents.some((agent) => agent.id === 'github-copilot')
|
|
789
|
+
? [`GitHub Copilot cloud agent: ${plan.copilotCloud ? 'enabled' : 'disabled'}`]
|
|
790
|
+
: [])
|
|
791
|
+
]
|
|
792
|
+
: [])
|
|
703
793
|
]);
|
|
704
794
|
if (readiness.deferred.length > 0) {
|
|
705
795
|
presentation.bullets('Deferred advisory checks', readiness.deferred);
|
|
@@ -748,7 +838,7 @@ async function migrateCommand(parsed, context) {
|
|
|
748
838
|
return 1;
|
|
749
839
|
}
|
|
750
840
|
if (existsSync(path.join(sourceRoot, 'liftoff.manifest.json'))) {
|
|
751
|
-
context.presentation.error(`${sourceRoot} is already a Liftoff project.`, 'Use `liftoff update`
|
|
841
|
+
context.presentation.error(`${sourceRoot} is already a Liftoff project.`, 'Use `liftoff update` for managed-core maintenance; in-place project template migration is not automated.');
|
|
752
842
|
return 1;
|
|
753
843
|
}
|
|
754
844
|
context.presentation.identity('Migrate an existing application into a fresh Liftoff project');
|
|
@@ -804,6 +894,151 @@ function isDirtyGitWorktree(projectRoot) {
|
|
|
804
894
|
const result = spawnSync('git', ['status', '--porcelain'], { cwd: projectRoot, encoding: 'utf8' });
|
|
805
895
|
return result.status === 0 && result.stdout.trim().length > 0;
|
|
806
896
|
}
|
|
897
|
+
function requestedProvisioningGroups(manifest, plan) {
|
|
898
|
+
if (plan.workload === 'power-apps-code-app' ||
|
|
899
|
+
manifest.project.workload.kind === 'power-apps-code-app') {
|
|
900
|
+
return [];
|
|
901
|
+
}
|
|
902
|
+
const provisioned = new Set(manifest.projectArtifacts.map((artifact) => artifact.provisioningGroup));
|
|
903
|
+
const groups = [];
|
|
904
|
+
if (plan.includeFrontend &&
|
|
905
|
+
!manifest.project.workload.frontend &&
|
|
906
|
+
!provisioned.has('frontend')) {
|
|
907
|
+
groups.push('frontend');
|
|
908
|
+
}
|
|
909
|
+
const recordedEnvironments = new Set(manifest.project.workload.environments);
|
|
910
|
+
for (const environment of plan.environments) {
|
|
911
|
+
const group = `environment:${environment.id}`;
|
|
912
|
+
if (!recordedEnvironments.has(environment.id) &&
|
|
913
|
+
!provisioned.has(group)) {
|
|
914
|
+
groups.push(group);
|
|
915
|
+
}
|
|
916
|
+
}
|
|
917
|
+
return groups;
|
|
918
|
+
}
|
|
919
|
+
async function inspectProvisioningGroups(projectRoot, render, groups) {
|
|
920
|
+
const projectArtifacts = render.filter((artifact) => artifact.lifecycle === 'project');
|
|
921
|
+
const plans = [];
|
|
922
|
+
for (const group of groups) {
|
|
923
|
+
const artifacts = projectArtifacts.filter((artifact) => artifact.provisioningGroup === group);
|
|
924
|
+
if (artifacts.length === 0) {
|
|
925
|
+
throw new Error(`Provisioning group ${group} did not render any project artifacts.`);
|
|
926
|
+
}
|
|
927
|
+
const entries = await Promise.all(artifacts.map(async (artifact) => {
|
|
928
|
+
const disk = await readProjectFile(projectRoot, artifact.pathParts);
|
|
929
|
+
if (disk === undefined) {
|
|
930
|
+
return {
|
|
931
|
+
group,
|
|
932
|
+
status: 'create',
|
|
933
|
+
rendered: artifact,
|
|
934
|
+
reason: 'destination is absent'
|
|
935
|
+
};
|
|
936
|
+
}
|
|
937
|
+
if (disk.toString('utf8') === artifact.content) {
|
|
938
|
+
return {
|
|
939
|
+
group,
|
|
940
|
+
status: 'adopt',
|
|
941
|
+
rendered: artifact,
|
|
942
|
+
reason: 'destination already matches the selected component'
|
|
943
|
+
};
|
|
944
|
+
}
|
|
945
|
+
return {
|
|
946
|
+
group,
|
|
947
|
+
status: 'conflict',
|
|
948
|
+
rendered: artifact,
|
|
949
|
+
reason: 'destination contains project-owned bytes'
|
|
950
|
+
};
|
|
951
|
+
}));
|
|
952
|
+
plans.push({
|
|
953
|
+
group,
|
|
954
|
+
entries,
|
|
955
|
+
blocked: entries.some((entry) => entry.status === 'conflict')
|
|
956
|
+
});
|
|
957
|
+
}
|
|
958
|
+
return plans;
|
|
959
|
+
}
|
|
960
|
+
function provisioningJson(plans) {
|
|
961
|
+
return plans.map((plan) => ({
|
|
962
|
+
group: plan.group,
|
|
963
|
+
status: plan.blocked ? 'blocked' : 'ready',
|
|
964
|
+
entries: plan.entries.map((entry) => ({
|
|
965
|
+
status: entry.status,
|
|
966
|
+
path: manifestDisplayPath(entry.rendered.pathParts),
|
|
967
|
+
reason: entry.reason
|
|
968
|
+
}))
|
|
969
|
+
}));
|
|
970
|
+
}
|
|
971
|
+
function projectProvenanceFor(artifact) {
|
|
972
|
+
return {
|
|
973
|
+
logicalName: artifact.logicalName,
|
|
974
|
+
category: artifact.category,
|
|
975
|
+
pathParts: artifact.pathParts,
|
|
976
|
+
generatedBy: liftoffVersion,
|
|
977
|
+
generationHash: `sha256:${createHash('sha256').update(artifact.content, 'utf8').digest('hex')}`,
|
|
978
|
+
provisioningGroup: artifact.provisioningGroup
|
|
979
|
+
};
|
|
980
|
+
}
|
|
981
|
+
function appendProvisionedProjectArtifacts(existing, plans) {
|
|
982
|
+
const byName = new Map(existing.map((artifact) => [artifact.logicalName, artifact]));
|
|
983
|
+
for (const plan of plans) {
|
|
984
|
+
if (plan.blocked) {
|
|
985
|
+
continue;
|
|
986
|
+
}
|
|
987
|
+
for (const entry of plan.entries) {
|
|
988
|
+
if (!byName.has(entry.rendered.logicalName)) {
|
|
989
|
+
byName.set(entry.rendered.logicalName, projectProvenanceFor(entry.rendered));
|
|
990
|
+
}
|
|
991
|
+
}
|
|
992
|
+
}
|
|
993
|
+
return [...byName.values()];
|
|
994
|
+
}
|
|
995
|
+
function sameWorkloadIntent(left, right) {
|
|
996
|
+
if (left.kind !== right.kind) {
|
|
997
|
+
return false;
|
|
998
|
+
}
|
|
999
|
+
if (left.kind === 'power-apps-code-app' && right.kind === 'power-apps-code-app') {
|
|
1000
|
+
return left.codeAppsPlugin === right.codeAppsPlugin &&
|
|
1001
|
+
left.starter.repository === right.starter.repository &&
|
|
1002
|
+
left.starter.path === right.starter.path &&
|
|
1003
|
+
left.starter.commit === right.starter.commit;
|
|
1004
|
+
}
|
|
1005
|
+
if (left.kind === 'power-apps-code-app' || right.kind === 'power-apps-code-app') {
|
|
1006
|
+
return false;
|
|
1007
|
+
}
|
|
1008
|
+
return left.apiStack === right.apiStack &&
|
|
1009
|
+
left.cloud === right.cloud &&
|
|
1010
|
+
left.region === right.region &&
|
|
1011
|
+
left.frontend === right.frontend &&
|
|
1012
|
+
left.environments.length === right.environments.length &&
|
|
1013
|
+
left.environments.every((environment, index) => environment === right.environments[index]) &&
|
|
1014
|
+
(left.kind !== 'genai' || right.kind !== 'genai' || left.pattern === right.pattern);
|
|
1015
|
+
}
|
|
1016
|
+
function planWithBlockedProvisioning(plan, recordedWorkload, provisioningPlans) {
|
|
1017
|
+
if (plan.workload === 'power-apps-code-app' ||
|
|
1018
|
+
recordedWorkload.kind === 'power-apps-code-app') {
|
|
1019
|
+
return plan;
|
|
1020
|
+
}
|
|
1021
|
+
const blockedGroups = new Set(provisioningPlans
|
|
1022
|
+
.filter((group) => group.blocked)
|
|
1023
|
+
.map((group) => group.group));
|
|
1024
|
+
const hasBlockedEnvironment = [...blockedGroups].some((group) => group.startsWith('environment:'));
|
|
1025
|
+
const environments = hasBlockedEnvironment
|
|
1026
|
+
? recordedWorkload.environments.map((id) => {
|
|
1027
|
+
const environment = getEnvironment(id);
|
|
1028
|
+
if (!environment) {
|
|
1029
|
+
throw new Error(`Recorded environment ${id} is no longer supported.`);
|
|
1030
|
+
}
|
|
1031
|
+
return environment;
|
|
1032
|
+
})
|
|
1033
|
+
: plan.environments;
|
|
1034
|
+
return {
|
|
1035
|
+
...plan,
|
|
1036
|
+
includeFrontend: blockedGroups.has('frontend')
|
|
1037
|
+
? recordedWorkload.frontend
|
|
1038
|
+
: plan.includeFrontend,
|
|
1039
|
+
environments
|
|
1040
|
+
};
|
|
1041
|
+
}
|
|
807
1042
|
async function preflightUpdate(projectRoot, entries, force) {
|
|
808
1043
|
for (const entry of entries) {
|
|
809
1044
|
const writesDestination = entry.status === 'new' ||
|
|
@@ -828,10 +1063,8 @@ async function captureUpdateSnapshots(projectRoot, manifest, render, initialSnap
|
|
|
828
1063
|
const snapshots = new Map(initialSnapshots.map((snapshot) => [updateSnapshotKey(snapshot.pathParts), snapshot]));
|
|
829
1064
|
const seenKeys = new Set(snapshots.keys());
|
|
830
1065
|
const candidates = [
|
|
831
|
-
...manifest.
|
|
832
|
-
...render
|
|
833
|
-
.filter((artifact) => artifact.category !== 'seed' && artifact.category !== 'framework')
|
|
834
|
-
.map((artifact) => artifact.pathParts)
|
|
1066
|
+
...manifest.managedArtifacts.map((artifact) => artifact.pathParts),
|
|
1067
|
+
...render.map((artifact) => artifact.pathParts)
|
|
835
1068
|
];
|
|
836
1069
|
const uncaptured = [];
|
|
837
1070
|
for (const pathParts of candidates) {
|
|
@@ -846,10 +1079,11 @@ async function captureUpdateSnapshots(projectRoot, manifest, render, initialSnap
|
|
|
846
1079
|
}
|
|
847
1080
|
return [...snapshots.values()];
|
|
848
1081
|
}
|
|
849
|
-
function selectUpdatePreconditions(snapshots, entries, mutations) {
|
|
1082
|
+
function selectUpdatePreconditions(snapshots, entries, mutations, additionalPaths = []) {
|
|
850
1083
|
const requiredKeys = new Set([
|
|
851
1084
|
updateSnapshotKey(['liftoff.config.json']),
|
|
852
|
-
...mutations.map((mutation) => updateSnapshotKey(mutation.pathParts))
|
|
1085
|
+
...mutations.map((mutation) => updateSnapshotKey(mutation.pathParts)),
|
|
1086
|
+
...additionalPaths.map((pathParts) => updateSnapshotKey(pathParts))
|
|
853
1087
|
]);
|
|
854
1088
|
for (const entry of entries) {
|
|
855
1089
|
const adoptsExistingDestination = entry.status === 'moved' && entry.destinationMatches === true;
|
|
@@ -864,12 +1098,41 @@ function selectUpdatePreconditions(snapshots, entries, mutations) {
|
|
|
864
1098
|
}
|
|
865
1099
|
return snapshots.filter((snapshot) => requiredKeys.has(updateSnapshotKey(snapshot.pathParts)));
|
|
866
1100
|
}
|
|
1101
|
+
function assertAuthorizedUpdateMutations(mutations, entries, provisioningPlans) {
|
|
1102
|
+
const authorized = new Set([
|
|
1103
|
+
updateSnapshotKey(['liftoff.manifest.json'])
|
|
1104
|
+
]);
|
|
1105
|
+
for (const entry of entries) {
|
|
1106
|
+
if (entry.rendered && entry.rendered.lifecycle !== 'managed-core') {
|
|
1107
|
+
throw new Error(`Update reconciliation produced a non-core mutation candidate: ${entry.logicalName}`);
|
|
1108
|
+
}
|
|
1109
|
+
authorized.add(updateSnapshotKey(entry.pathParts));
|
|
1110
|
+
if (entry.previousPathParts) {
|
|
1111
|
+
authorized.add(updateSnapshotKey(entry.previousPathParts));
|
|
1112
|
+
}
|
|
1113
|
+
}
|
|
1114
|
+
for (const plan of provisioningPlans) {
|
|
1115
|
+
if (plan.blocked) {
|
|
1116
|
+
continue;
|
|
1117
|
+
}
|
|
1118
|
+
for (const entry of plan.entries) {
|
|
1119
|
+
if (entry.status === 'create') {
|
|
1120
|
+
authorized.add(updateSnapshotKey(entry.rendered.pathParts));
|
|
1121
|
+
}
|
|
1122
|
+
}
|
|
1123
|
+
}
|
|
1124
|
+
for (const mutation of mutations) {
|
|
1125
|
+
if (!authorized.has(updateSnapshotKey(mutation.pathParts))) {
|
|
1126
|
+
throw new Error(`Update mutation is outside managed-core or authorized provisioning scope: ${manifestDisplayPath(mutation.pathParts)}`);
|
|
1127
|
+
}
|
|
1128
|
+
}
|
|
1129
|
+
}
|
|
867
1130
|
async function updateCommand(parsed, context) {
|
|
868
1131
|
const { presentation } = context;
|
|
869
1132
|
const check = readBooleanFlag(parsed.flags, 'check') ?? false;
|
|
870
1133
|
const force = readBooleanFlag(parsed.flags, 'force') ?? false;
|
|
871
1134
|
const jsonMode = readBooleanFlag(parsed.flags, 'json') ?? false;
|
|
872
|
-
presentation.commandIdentity('update', 'Reconcile
|
|
1135
|
+
presentation.commandIdentity('update', 'Reconcile Liftoff-managed core files');
|
|
873
1136
|
if (force && check) {
|
|
874
1137
|
presentation.error('--force cannot be combined with --check.', 'Run `liftoff update --check` to inspect drift or `liftoff update --force` to overwrite conflicts.');
|
|
875
1138
|
return 1;
|
|
@@ -899,7 +1162,7 @@ async function updateCommand(parsed, context) {
|
|
|
899
1162
|
recordedWorkload.kind === 'power-apps-code-app';
|
|
900
1163
|
presentation.error(`Project type changes (${recordedWorkload.kind} -> ${plan.workload}) are not supported by update.`, involvesPowerApps
|
|
901
1164
|
? 'Initialize a fresh project for the new workload.'
|
|
902
|
-
: '
|
|
1165
|
+
: 'Initialize a fresh project and move production behavior through a reviewed project change.');
|
|
903
1166
|
return 1;
|
|
904
1167
|
}
|
|
905
1168
|
if (plan.workload === 'power-apps-code-app' && recordedWorkload.kind === 'power-apps-code-app') {
|
|
@@ -916,13 +1179,13 @@ async function updateCommand(parsed, context) {
|
|
|
916
1179
|
}
|
|
917
1180
|
else if (plan.workload !== 'power-apps-code-app' && recordedWorkload.kind !== 'power-apps-code-app') {
|
|
918
1181
|
if (plan.apiStack.id !== recordedWorkload.apiStack) {
|
|
919
|
-
presentation.error(`API stack changes (${recordedWorkload.apiStack} -> ${plan.apiStack.id}) are a migration, not an update.`, '
|
|
1182
|
+
presentation.error(`API stack changes (${recordedWorkload.apiStack} -> ${plan.apiStack.id}) are a migration, not an update.`, 'Initialize a fresh project and move production behavior through a reviewed project change.');
|
|
920
1183
|
return 1;
|
|
921
1184
|
}
|
|
922
1185
|
const recordedPattern = recordedWorkload.kind === 'genai' ? recordedWorkload.pattern : undefined;
|
|
923
1186
|
const desiredPattern = plan.workload === 'genai' ? plan.pattern.id : undefined;
|
|
924
1187
|
if (desiredPattern !== recordedPattern) {
|
|
925
|
-
presentation.error(`Pattern changes (${recordedPattern ?? 'none'} -> ${desiredPattern ?? 'none'}) are a migration, not an update.`, '
|
|
1188
|
+
presentation.error(`Pattern changes (${recordedPattern ?? 'none'} -> ${desiredPattern ?? 'none'}) are a migration, not an update.`, 'Use a separately reviewed project migration; the existing migrate command only adopts non-Liftoff sources into a fresh target.');
|
|
926
1189
|
return 1;
|
|
927
1190
|
}
|
|
928
1191
|
}
|
|
@@ -937,48 +1200,65 @@ async function updateCommand(parsed, context) {
|
|
|
937
1200
|
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.');
|
|
938
1201
|
return 1;
|
|
939
1202
|
}
|
|
940
|
-
|
|
1203
|
+
let desiredRenderPlan = manifest.framework.state === 'legacy'
|
|
941
1204
|
? { ...plan, agents: [], defaultAgent: undefined }
|
|
942
1205
|
: plan;
|
|
1206
|
+
if (desiredRenderPlan.workload === 'power-apps-code-app' &&
|
|
1207
|
+
recordedWorkload.kind === 'power-apps-code-app') {
|
|
1208
|
+
desiredRenderPlan = {
|
|
1209
|
+
...desiredRenderPlan,
|
|
1210
|
+
starter: recordedWorkload.starter
|
|
1211
|
+
};
|
|
1212
|
+
}
|
|
1213
|
+
const desiredRender = buildArtifacts(desiredRenderPlan);
|
|
1214
|
+
const provisioningPlans = await inspectProvisioningGroups(projectRoot, desiredRender, requestedProvisioningGroups(manifest, desiredRenderPlan));
|
|
1215
|
+
const renderPlan = planWithBlockedProvisioning(desiredRenderPlan, recordedWorkload, provisioningPlans);
|
|
943
1216
|
const render = buildArtifacts(renderPlan);
|
|
1217
|
+
const scopedRender = [
|
|
1218
|
+
...render.filter((artifact) => artifact.lifecycle === 'managed-core'),
|
|
1219
|
+
...desiredRender.filter((artifact) => artifact.lifecycle === 'project' &&
|
|
1220
|
+
provisioningPlans.some((group) => group.group === artifact.provisioningGroup))
|
|
1221
|
+
];
|
|
944
1222
|
const updateSnapshots = check
|
|
945
1223
|
? []
|
|
946
|
-
: await captureUpdateSnapshots(projectRoot, manifest,
|
|
1224
|
+
: await captureUpdateSnapshots(projectRoot, manifest, scopedRender, initialUpdateSnapshots);
|
|
947
1225
|
const entries = await reconcileProject(manifest, render, projectRoot);
|
|
948
1226
|
const summary = summarizeEntries(entries);
|
|
949
|
-
const
|
|
950
|
-
|
|
951
|
-
|
|
952
|
-
|
|
953
|
-
|
|
954
|
-
|
|
955
|
-
|
|
956
|
-
const
|
|
957
|
-
|
|
958
|
-
|
|
959
|
-
|
|
960
|
-
|
|
1227
|
+
const ownershipMigrationPending = manifest.artifactVersion !== 6 ||
|
|
1228
|
+
manifestHadFilteredLegacyNonDurableOwnership(manifest);
|
|
1229
|
+
const plannedManifest = buildManifest(renderPlan, render, {
|
|
1230
|
+
frameworkState: manifest.framework.state,
|
|
1231
|
+
projectArtifacts: manifest.projectArtifacts
|
|
1232
|
+
});
|
|
1233
|
+
const workloadIntentChanged = !sameWorkloadIntent(plannedManifest.project.workload, manifest.project.workload);
|
|
1234
|
+
const manifestRewritePending = ownershipMigrationPending ||
|
|
1235
|
+
workloadIntentChanged;
|
|
1236
|
+
const drift = hasDrift(entries) ||
|
|
1237
|
+
manifestRewritePending ||
|
|
1238
|
+
provisioningPlans.length > 0;
|
|
961
1239
|
const visible = entries.filter((entry) => entry.status !== 'unchanged' || entry.refreshHash);
|
|
962
1240
|
if (!drift) {
|
|
963
1241
|
if (jsonMode) {
|
|
964
1242
|
presentation.rawStdout(`${JSON.stringify(check ? {
|
|
965
|
-
schemaVersion:
|
|
1243
|
+
schemaVersion: 2,
|
|
966
1244
|
mode: 'check',
|
|
1245
|
+
scope: 'managed-core',
|
|
967
1246
|
cliVersion: liftoffVersion,
|
|
968
1247
|
projectVersion: manifest.liftoffVersion,
|
|
969
1248
|
entries: [],
|
|
970
|
-
|
|
971
|
-
|
|
1249
|
+
provisioning: [],
|
|
1250
|
+
ownershipMigrationPending,
|
|
972
1251
|
summary
|
|
973
1252
|
} : {
|
|
974
|
-
schemaVersion:
|
|
1253
|
+
schemaVersion: 2,
|
|
975
1254
|
mode: 'apply',
|
|
1255
|
+
scope: 'managed-core',
|
|
976
1256
|
cliVersion: liftoffVersion,
|
|
977
1257
|
projectVersion: manifest.liftoffVersion,
|
|
978
1258
|
written: [],
|
|
979
1259
|
skipped: [],
|
|
980
|
-
|
|
981
|
-
|
|
1260
|
+
provisioning: [],
|
|
1261
|
+
ownershipMigrationPending,
|
|
982
1262
|
summary
|
|
983
1263
|
}, null, 2)}\n`);
|
|
984
1264
|
return 0;
|
|
@@ -987,14 +1267,15 @@ async function updateCommand(parsed, context) {
|
|
|
987
1267
|
{ label: 'Liftoff CLI', value: liftoffVersion },
|
|
988
1268
|
{ label: 'Project generated by', value: manifest.liftoffVersion }
|
|
989
1269
|
]);
|
|
990
|
-
presentation.status('success', '
|
|
1270
|
+
presentation.status('success', 'Liftoff core is current', `${summary.unchanged} managed-core artifacts match; project files are not compared`);
|
|
991
1271
|
return 0;
|
|
992
1272
|
}
|
|
993
1273
|
if (check) {
|
|
994
1274
|
if (jsonMode) {
|
|
995
1275
|
presentation.rawStdout(`${JSON.stringify({
|
|
996
|
-
schemaVersion:
|
|
1276
|
+
schemaVersion: 2,
|
|
997
1277
|
mode: 'check',
|
|
1278
|
+
scope: 'managed-core',
|
|
998
1279
|
cliVersion: liftoffVersion,
|
|
999
1280
|
projectVersion: manifest.liftoffVersion,
|
|
1000
1281
|
entries: visible.map((entry) => ({
|
|
@@ -1004,8 +1285,8 @@ async function updateCommand(parsed, context) {
|
|
|
1004
1285
|
previousPath: entry.previousPathParts ? manifestDisplayPath(entry.previousPathParts) : undefined,
|
|
1005
1286
|
reason: entry.reason
|
|
1006
1287
|
})),
|
|
1007
|
-
|
|
1008
|
-
|
|
1288
|
+
provisioning: provisioningJson(provisioningPlans),
|
|
1289
|
+
ownershipMigrationPending,
|
|
1009
1290
|
summary
|
|
1010
1291
|
}, null, 2)}\n`);
|
|
1011
1292
|
return 2;
|
|
@@ -1016,19 +1297,21 @@ async function updateCommand(parsed, context) {
|
|
|
1016
1297
|
]);
|
|
1017
1298
|
if (visible.length > 0) {
|
|
1018
1299
|
if (presentation.stdout.layout === 'plain') {
|
|
1019
|
-
presentation.section('
|
|
1300
|
+
presentation.section('Liftoff core drift', visible.map((entry) => `${entryMarker(entry)} ${entryDisplay(entry)} ${entry.reason}`));
|
|
1020
1301
|
}
|
|
1021
1302
|
else {
|
|
1022
|
-
presentation.table('
|
|
1303
|
+
presentation.table('Liftoff core drift', ['Change', 'Artifact', 'Reason'], visible.map((entry) => [entryMarker(entry), entryDisplay(entry), entry.reason]));
|
|
1023
1304
|
}
|
|
1024
1305
|
}
|
|
1306
|
+
for (const group of provisioningPlans) {
|
|
1307
|
+
presentation.bullets(`Project component provisioning: ${group.group}`, group.entries.map((entry) => `${entry.status} ${manifestDisplayPath(entry.rendered.pathParts)} ${entry.reason}`));
|
|
1308
|
+
}
|
|
1025
1309
|
const manifestMaintenance = [
|
|
1026
|
-
|
|
1027
|
-
|
|
1028
|
-
? 'remove obsolete seed or framework entries from durable manifest ownership'
|
|
1310
|
+
ownershipMigrationPending
|
|
1311
|
+
? 'release legacy project artifacts from Liftoff update authority in manifest schema v6; no production file will be written'
|
|
1029
1312
|
: undefined,
|
|
1030
|
-
|
|
1031
|
-
?
|
|
1313
|
+
workloadIntentChanged
|
|
1314
|
+
? 'record the requested project configuration intent after safe provisioning'
|
|
1032
1315
|
: undefined
|
|
1033
1316
|
].filter((item) => item !== undefined);
|
|
1034
1317
|
if (manifestMaintenance.length > 0) {
|
|
@@ -1039,8 +1322,11 @@ async function updateCommand(parsed, context) {
|
|
|
1039
1322
|
summary.upgrade +
|
|
1040
1323
|
summary.moved +
|
|
1041
1324
|
summary.refresh +
|
|
1325
|
+
provisioningPlans.reduce((count, group) => count + (group.blocked
|
|
1326
|
+
? 0
|
|
1327
|
+
: group.entries.filter((entry) => entry.status === 'create').length), 0) +
|
|
1042
1328
|
(manifestRewritePending ? 1 : 0);
|
|
1043
|
-
presentation.status('warning', '
|
|
1329
|
+
presentation.status('warning', 'Liftoff core maintenance available', `${toWrite} to write, ${summary.conflict} core conflict(s), ${summary.orphan} core orphan(s), ${summary.unchanged} core unchanged`);
|
|
1044
1330
|
if (toWrite > 0) {
|
|
1045
1331
|
presentation.command('liftoff update');
|
|
1046
1332
|
}
|
|
@@ -1049,6 +1335,7 @@ async function updateCommand(parsed, context) {
|
|
|
1049
1335
|
}
|
|
1050
1336
|
return 2;
|
|
1051
1337
|
}
|
|
1338
|
+
const blockedProvisioning = provisioningPlans.filter((group) => group.blocked);
|
|
1052
1339
|
if (isDirtyGitWorktree(projectRoot)) {
|
|
1053
1340
|
const warning = 'The project worktree has uncommitted changes; consider committing before applying.';
|
|
1054
1341
|
if (jsonMode) {
|
|
@@ -1058,7 +1345,7 @@ async function updateCommand(parsed, context) {
|
|
|
1058
1345
|
presentation.warning(warning);
|
|
1059
1346
|
}
|
|
1060
1347
|
}
|
|
1061
|
-
presentation.stage('Apply safe
|
|
1348
|
+
presentation.stage('Apply safe Liftoff core changes', projectRoot);
|
|
1062
1349
|
await preflightUpdate(projectRoot, entries, force);
|
|
1063
1350
|
const written = [];
|
|
1064
1351
|
const skipped = [];
|
|
@@ -1111,12 +1398,32 @@ async function updateCommand(parsed, context) {
|
|
|
1111
1398
|
break;
|
|
1112
1399
|
}
|
|
1113
1400
|
}
|
|
1114
|
-
const
|
|
1401
|
+
const provisioned = [];
|
|
1402
|
+
for (const group of provisioningPlans) {
|
|
1403
|
+
if (group.blocked) {
|
|
1404
|
+
continue;
|
|
1405
|
+
}
|
|
1406
|
+
for (const entry of group.entries) {
|
|
1407
|
+
if (entry.status === 'create') {
|
|
1408
|
+
mutations.push({
|
|
1409
|
+
type: 'write',
|
|
1410
|
+
pathParts: entry.rendered.pathParts,
|
|
1411
|
+
content: entry.rendered.content
|
|
1412
|
+
});
|
|
1413
|
+
}
|
|
1414
|
+
provisioned.push(entry);
|
|
1415
|
+
}
|
|
1416
|
+
}
|
|
1417
|
+
const oldByName = new Map(manifest.managedArtifacts.map((artifact) => [artifact.logicalName, artifact]));
|
|
1115
1418
|
const skippedByName = new Map(skipped.map((entry) => [entry.logicalName, entry]));
|
|
1116
1419
|
const hasUnrecordedGovernanceConflict = skipped.some((entry) => entry.status === 'conflict' &&
|
|
1117
1420
|
entry.rendered?.category === 'governance' &&
|
|
1118
1421
|
!oldByName.has(entry.logicalName));
|
|
1119
|
-
const
|
|
1422
|
+
const nextProjectArtifacts = appendProvisionedProjectArtifacts(manifest.projectArtifacts, provisioningPlans);
|
|
1423
|
+
const nextManifest = buildManifest(renderPlan, render.filter((artifact) => artifact.logicalName !== 'manifest'), {
|
|
1424
|
+
frameworkState: manifest.framework.state,
|
|
1425
|
+
projectArtifacts: nextProjectArtifacts
|
|
1426
|
+
});
|
|
1120
1427
|
nextManifest.framework = manifest.framework;
|
|
1121
1428
|
nextManifest.project.specWorkflow = manifest.project.specWorkflow;
|
|
1122
1429
|
nextManifest.project.agents = manifest.project.agents;
|
|
@@ -1130,11 +1437,7 @@ async function updateCommand(parsed, context) {
|
|
|
1130
1437
|
nextManifest.governance.profile !== 'none') {
|
|
1131
1438
|
nextManifest.governance.state = 'handoff-partial';
|
|
1132
1439
|
}
|
|
1133
|
-
nextManifest.
|
|
1134
|
-
// config is user-owned after create: carry the recorded entry forward untouched
|
|
1135
|
-
if (artifact.logicalName === 'liftoff-config') {
|
|
1136
|
-
return [oldByName.get('liftoff-config') ?? artifact];
|
|
1137
|
-
}
|
|
1440
|
+
nextManifest.managedArtifacts = nextManifest.managedArtifacts.flatMap((artifact) => {
|
|
1138
1441
|
if (!skippedByName.has(artifact.logicalName)) {
|
|
1139
1442
|
return [artifact];
|
|
1140
1443
|
}
|
|
@@ -1151,7 +1454,7 @@ async function updateCommand(parsed, context) {
|
|
|
1151
1454
|
previous.category === 'governance') {
|
|
1152
1455
|
continue;
|
|
1153
1456
|
}
|
|
1154
|
-
nextManifest.
|
|
1457
|
+
nextManifest.managedArtifacts.push(previous);
|
|
1155
1458
|
}
|
|
1156
1459
|
}
|
|
1157
1460
|
mutations.push({
|
|
@@ -1159,34 +1462,52 @@ async function updateCommand(parsed, context) {
|
|
|
1159
1462
|
pathParts: ['liftoff.manifest.json'],
|
|
1160
1463
|
content: `${JSON.stringify(nextManifest, null, 2)}\n`
|
|
1161
1464
|
});
|
|
1465
|
+
assertAuthorizedUpdateMutations(mutations, entries, provisioningPlans);
|
|
1162
1466
|
await applyProjectFileTransaction(projectRoot, mutations, {
|
|
1163
|
-
preconditions: selectUpdatePreconditions(updateSnapshots, entries, mutations)
|
|
1467
|
+
preconditions: selectUpdatePreconditions(updateSnapshots, entries, mutations, provisioned.map((entry) => entry.rendered.pathParts))
|
|
1164
1468
|
});
|
|
1165
1469
|
if (jsonMode) {
|
|
1166
1470
|
presentation.rawStdout(`${JSON.stringify({
|
|
1167
|
-
schemaVersion:
|
|
1471
|
+
schemaVersion: 2,
|
|
1168
1472
|
mode: 'apply',
|
|
1473
|
+
scope: 'managed-core',
|
|
1474
|
+
status: blockedProvisioning.length > 0 ? 'partial' : 'applied',
|
|
1169
1475
|
cliVersion: liftoffVersion,
|
|
1170
1476
|
projectVersion: manifest.liftoffVersion,
|
|
1171
1477
|
written: written.map((entry) => manifestDisplayPath(entry.pathParts)),
|
|
1172
1478
|
skipped: skipped.map((entry) => ({ path: manifestDisplayPath(entry.pathParts), reason: entry.reason })),
|
|
1173
|
-
|
|
1174
|
-
|
|
1479
|
+
provisioning: provisioningJson(provisioningPlans),
|
|
1480
|
+
ownershipMigrationPending,
|
|
1175
1481
|
summary
|
|
1176
1482
|
}, null, 2)}\n`);
|
|
1177
1483
|
return 0;
|
|
1178
1484
|
}
|
|
1179
1485
|
if (written.length > 0) {
|
|
1180
|
-
presentation.bullets('Applied changes', written.map((entry) => `wrote ${entryDisplay(entry)}`));
|
|
1486
|
+
presentation.bullets('Applied Liftoff core changes', written.map((entry) => `wrote ${entryDisplay(entry)}`));
|
|
1487
|
+
}
|
|
1488
|
+
if (provisioned.length > 0) {
|
|
1489
|
+
presentation.bullets('Provisioned project components', provisioned.map((entry) => `${entry.status === 'create' ? 'wrote' : 'adopted'} ${manifestDisplayPath(entry.rendered.pathParts)}`));
|
|
1490
|
+
}
|
|
1491
|
+
if (blockedProvisioning.length > 0) {
|
|
1492
|
+
presentation.bullets('Blocked project component provisioning', blockedProvisioning.flatMap((group) => group.entries
|
|
1493
|
+
.filter((entry) => entry.status === 'conflict')
|
|
1494
|
+
.map((entry) => `${group.group}: preserved ${manifestDisplayPath(entry.rendered.pathParts)}; --force cannot overwrite project-owned bytes`)));
|
|
1181
1495
|
}
|
|
1182
1496
|
if (skipped.length > 0) {
|
|
1183
|
-
presentation.bullets('Skipped conflicts', skipped.map((entry) => `skipped ${entryDisplay(entry)} ${entry.reason}${force ? '' : ' (use --force to overwrite)'}`));
|
|
1497
|
+
presentation.bullets('Skipped Liftoff core conflicts', skipped.map((entry) => `skipped ${entryDisplay(entry)} ${entry.reason}${force ? '' : ' (use --force to overwrite)'}`));
|
|
1184
1498
|
}
|
|
1185
1499
|
const orphans = entries.filter((entry) => entry.status === 'orphan');
|
|
1186
1500
|
if (orphans.length > 0) {
|
|
1187
|
-
presentation.bullets('Orphaned artifacts', orphans.map((entry) => `orphan ${entryDisplay(entry)} ${entry.reason}`));
|
|
1501
|
+
presentation.bullets('Orphaned Liftoff core artifacts', orphans.map((entry) => `orphan ${entryDisplay(entry)} ${entry.reason}`));
|
|
1188
1502
|
}
|
|
1189
|
-
presentation.completion('Updated project',
|
|
1503
|
+
presentation.completion('Updated project', [
|
|
1504
|
+
`${written.length} core written`,
|
|
1505
|
+
`${provisioned.length} project provisioned`,
|
|
1506
|
+
...(blockedProvisioning.length > 0
|
|
1507
|
+
? [`${blockedProvisioning.length} provisioning group(s) blocked`]
|
|
1508
|
+
: []),
|
|
1509
|
+
`${skipped.length} core skipped`
|
|
1510
|
+
].join(', '), [{ label: 'Manifest version', value: liftoffVersion }], 'liftoff validate && liftoff doctor');
|
|
1190
1511
|
return 0;
|
|
1191
1512
|
}
|
|
1192
1513
|
function renderSelfUpgradeResult(value, presentation) {
|
|
@@ -1433,7 +1754,7 @@ function stackProjectCheck(projectRoot, apiStack) {
|
|
|
1433
1754
|
label: `${apiStack} project`,
|
|
1434
1755
|
severity: 'fail',
|
|
1435
1756
|
detail: `missing locked dependency metadata: ${missingMetadata}`,
|
|
1436
|
-
remedy: `restore ${missingMetadata}
|
|
1757
|
+
remedy: `restore or repair the project-owned ${missingMetadata}`
|
|
1437
1758
|
};
|
|
1438
1759
|
}
|
|
1439
1760
|
if (apiStack === 'python-fastapi') {
|
|
@@ -1446,7 +1767,7 @@ function stackProjectCheck(projectRoot, apiStack) {
|
|
|
1446
1767
|
label: `${apiStack} project`,
|
|
1447
1768
|
severity: 'fail',
|
|
1448
1769
|
detail: 'pyproject.toml and uv.lock do not identify the same project',
|
|
1449
|
-
remedy: '
|
|
1770
|
+
remedy: 'repair the project-owned backend/pyproject.toml and backend/uv.lock'
|
|
1450
1771
|
};
|
|
1451
1772
|
}
|
|
1452
1773
|
}
|
|
@@ -1498,7 +1819,7 @@ async function powerAppsProjectCheck(projectRoot) {
|
|
|
1498
1819
|
severity: 'fail',
|
|
1499
1820
|
state: 'unhealthy',
|
|
1500
1821
|
detail: issues[0],
|
|
1501
|
-
remedy: '
|
|
1822
|
+
remedy: 'repair the project-owned package.json and package-lock.json'
|
|
1502
1823
|
};
|
|
1503
1824
|
}
|
|
1504
1825
|
function runReadOnly(command, args, cwd) {
|
|
@@ -1579,11 +1900,15 @@ async function projectLayer(projectRoot, manifest) {
|
|
|
1579
1900
|
label: 'manifest',
|
|
1580
1901
|
severity: 'fail',
|
|
1581
1902
|
detail: `${issues.length} issue(s): ${issues[0]}${issues.length > 1 ? ' ...' : ''}`,
|
|
1582
|
-
remedy: 'restore
|
|
1903
|
+
remedy: 'restore the manifest or run liftoff update for managed-core repair'
|
|
1583
1904
|
});
|
|
1584
1905
|
}
|
|
1585
1906
|
else {
|
|
1586
|
-
checks.push({
|
|
1907
|
+
checks.push({
|
|
1908
|
+
label: 'manifest',
|
|
1909
|
+
severity: 'ok',
|
|
1910
|
+
detail: `valid, ${manifest.managedArtifacts.length} managed core and ${manifest.projectArtifacts.length} project provenance entries`
|
|
1911
|
+
});
|
|
1587
1912
|
}
|
|
1588
1913
|
if (compareSemver(manifest.liftoffVersion, liftoffVersion) > 0) {
|
|
1589
1914
|
checks.push({
|
|
@@ -1657,22 +1982,29 @@ async function projectLayer(projectRoot, manifest) {
|
|
|
1657
1982
|
const plan = buildProjectPlan(config, { requireProjectName: true });
|
|
1658
1983
|
const render = buildArtifacts(plan);
|
|
1659
1984
|
const entries = await reconcileProject(manifest, render, projectRoot);
|
|
1660
|
-
const
|
|
1985
|
+
const provisioningPlans = await inspectProvisioningGroups(projectRoot, render, requestedProvisioningGroups(manifest, plan));
|
|
1986
|
+
const driftCount = entries.filter((entry) => entry.status !== 'unchanged' || entry.refreshHash).length +
|
|
1987
|
+
provisioningPlans.length +
|
|
1988
|
+
(manifest.artifactVersion === 6 ? 0 : 1);
|
|
1661
1989
|
if (driftCount > 0) {
|
|
1662
1990
|
checks.push({
|
|
1663
|
-
label: '
|
|
1991
|
+
label: 'managed core',
|
|
1664
1992
|
severity: 'warn',
|
|
1665
|
-
detail: `${driftCount}
|
|
1993
|
+
detail: `${driftCount} core maintenance action(s) available`,
|
|
1666
1994
|
remedy: 'run liftoff update'
|
|
1667
1995
|
});
|
|
1668
1996
|
}
|
|
1669
1997
|
else {
|
|
1670
|
-
checks.push({
|
|
1998
|
+
checks.push({
|
|
1999
|
+
label: 'managed core',
|
|
2000
|
+
severity: 'ok',
|
|
2001
|
+
detail: 'Liftoff core is current; project templates are not compared'
|
|
2002
|
+
});
|
|
1671
2003
|
}
|
|
1672
2004
|
}
|
|
1673
2005
|
catch (error) {
|
|
1674
2006
|
checks.push({
|
|
1675
|
-
label: '
|
|
2007
|
+
label: 'managed core',
|
|
1676
2008
|
severity: 'fail',
|
|
1677
2009
|
detail: `liftoff.config.json could not be evaluated: ${error.message.split('\n')[0]}`,
|
|
1678
2010
|
remedy: 'repair liftoff.config.json'
|
|
@@ -1939,6 +2271,8 @@ async function optionsFromParsedArgs(parsed, cwd, includeProjectName) {
|
|
|
1939
2271
|
agents: readListFlag(parsed.flags, 'agents'),
|
|
1940
2272
|
defaultAgent: readStringFlag(parsed.flags, 'default-agent'),
|
|
1941
2273
|
codeAppsPlugin: readBooleanFlag(parsed.flags, 'code-apps-plugin'),
|
|
2274
|
+
copilotCloud: readBooleanFlag(parsed.flags, 'copilot-cloud'),
|
|
2275
|
+
configureOpenSpecProfile: readBooleanFlag(parsed.flags, 'configure-openspec-profile'),
|
|
1942
2276
|
governanceProfile: readStringFlag(parsed.flags, 'governance'),
|
|
1943
2277
|
configPath,
|
|
1944
2278
|
yes: readBooleanFlag(parsed.flags, 'yes') ?? false,
|
|
@@ -1962,6 +2296,9 @@ function hasMissingInitInputs(options) {
|
|
|
1962
2296
|
const missingDefaultAgent = options.specWorkflow === 'spec-kit' &&
|
|
1963
2297
|
(options.agents?.length ?? 0) > 1 &&
|
|
1964
2298
|
!options.defaultAgent;
|
|
2299
|
+
const missingCopilotCloud = options.specWorkflow === 'openspec' &&
|
|
2300
|
+
options.agents?.some((agent) => getCodingAgent(agent)?.id === 'github-copilot') === true &&
|
|
2301
|
+
options.copilotCloud === undefined;
|
|
1965
2302
|
return !options.projectName ||
|
|
1966
2303
|
missingTypeSpecific ||
|
|
1967
2304
|
projectType !== 'power-apps-code-app' && !options.cloud ||
|
|
@@ -1970,6 +2307,7 @@ function hasMissingInitInputs(options) {
|
|
|
1970
2307
|
!options.agents ||
|
|
1971
2308
|
!options.governanceProfile ||
|
|
1972
2309
|
missingDefaultAgent ||
|
|
2310
|
+
missingCopilotCloud ||
|
|
1973
2311
|
projectType !== 'power-apps-code-app' && !options.environments;
|
|
1974
2312
|
}
|
|
1975
2313
|
function renderGeneralHelp(presentation) {
|