@msn-control/liftoff 0.9.0 → 0.9.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (42) hide show
  1. package/README.md +8 -5
  2. package/assets/locks/frontend/package-lock.json +51 -51
  3. package/assets/locks/frontend/package.json +1 -1
  4. package/assets/locks/node-backend/package-lock.json +353 -12
  5. package/assets/locks/node-backend/package.json +3 -3
  6. package/assets/supported-stack.json +9 -9
  7. package/dist/args.js +5 -5
  8. package/dist/args.js.map +1 -1
  9. package/dist/artifact-lifecycle.d.ts +4 -0
  10. package/dist/artifact-lifecycle.js +38 -0
  11. package/dist/artifact-lifecycle.js.map +1 -0
  12. package/dist/commands.js +327 -76
  13. package/dist/commands.js.map +1 -1
  14. package/dist/file-system.d.ts +1 -1
  15. package/dist/file-system.js +171 -29
  16. package/dist/file-system.js.map +1 -1
  17. package/dist/init-filesystem.js +2 -2
  18. package/dist/init-filesystem.js.map +1 -1
  19. package/dist/power-apps-templates.js +3 -1
  20. package/dist/power-apps-templates.js.map +1 -1
  21. package/dist/reconcile.js +4 -10
  22. package/dist/reconcile.js.map +1 -1
  23. package/dist/repository-governance.js +4 -0
  24. package/dist/repository-governance.js.map +1 -1
  25. package/dist/stable-release.js +1 -1
  26. package/dist/stable-release.js.map +1 -1
  27. package/dist/templates.d.ts +5 -1
  28. package/dist/templates.js +102 -56
  29. package/dist/templates.js.map +1 -1
  30. package/dist/types.d.ts +24 -4
  31. package/docs/cli-reference.md +39 -25
  32. package/docs/configuration-and-manifests.md +39 -24
  33. package/docs/existing-repositories.md +15 -15
  34. package/docs/getting-started.md +3 -2
  35. package/docs/project-structure.md +6 -1
  36. package/docs/repository-governance.md +2 -2
  37. package/docs/safety-and-consent.md +22 -17
  38. package/docs/spec-workflows-and-agents.md +5 -4
  39. package/docs/supported-stack.md +14 -17
  40. package/docs/troubleshooting.md +16 -8
  41. package/docs/workloads.md +9 -8
  42. package/package.json +1 -1
package/dist/commands.js CHANGED
@@ -5,11 +5,11 @@ 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, manifestHadFilteredLegacySeedOwnership, manifestDisplayPath, resolveProjectPath, validateGeneratedProject, writeArtifacts, writeProjectFile } from './file-system.js';
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';
@@ -166,7 +166,7 @@ async function initCommand(parsed, context) {
166
166
  presentation.stage('Stage project files');
167
167
  const staged = await withStagingArea(async (area) => {
168
168
  const partition = partitionGeneratedArtifacts(buildArtifacts(plan));
169
- await writeStagedArtifacts(area, partition.durable, 'liftoff');
169
+ await writeStagedArtifacts(area, partition.liftoff, 'liftoff');
170
170
  presentation.stage('Initialize spec-driven framework', `${plan.specWorkflow.label} ${plan.framework.version}`);
171
171
  await initializeFramework(area, plan, runner, {
172
172
  env: context.env,
@@ -255,7 +255,13 @@ async function planCommand(parsed, context) {
255
255
  const plan = buildProjectPlan(options, { requireProjectName: false });
256
256
  const artifacts = buildArtifacts(plan);
257
257
  presentation.definitions('Project decisions', projectPlanEntries(plan));
258
- presentation.table(`Artifacts (${artifacts.length})`, ['Artifact', 'Path'], artifacts.map((artifact) => [artifact.logicalName, artifact.pathParts.join('/')]));
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
+ ]));
259
265
  const workstationRows = selectWorkstationRequirements(plan).map((requirement) => [
260
266
  requirement.definition.label,
261
267
  requirement.exactVersion
@@ -458,7 +464,7 @@ async function handleProjectDependencies(plan, projectRoot, options, probes, con
458
464
  if (plan.workload === 'power-apps-code-app') {
459
465
  const issues = await verifyPowerAppsPackageMetadata(projectRoot);
460
466
  if (issues.length > 0) {
461
- presentation.error('Power Apps dependency installation blocked', `${issues.join(' ')} Restore package.json and package-lock.json or run \`liftoff update\`.`);
467
+ presentation.error('Power Apps dependency installation blocked', `${issues.join(' ')} Repair the project-owned package.json and package-lock.json before installing dependencies.`);
462
468
  return { success: false, deferred: [] };
463
469
  }
464
470
  }
@@ -617,12 +623,14 @@ function migrationPlanArtifacts(plan, inventory) {
617
623
  {
618
624
  logicalName: 'migration-proposal',
619
625
  category: 'seed',
626
+ lifecycle: 'seed',
620
627
  pathParts: ['openspec', 'changes', 'migrate-to-liftoff', 'proposal.md'],
621
628
  content: renderMigrationProposal(plan, inventory)
622
629
  },
623
630
  {
624
631
  logicalName: 'migration-tasks',
625
632
  category: 'seed',
633
+ lifecycle: 'seed',
626
634
  pathParts: ['openspec', 'changes', 'migrate-to-liftoff', 'tasks.md'],
627
635
  content: renderMigrationTasks(groups)
628
636
  }
@@ -634,6 +642,7 @@ function migrationPlanArtifacts(plan, inventory) {
634
642
  artifacts: [{
635
643
  logicalName: 'migration-checklist',
636
644
  category: 'seed',
645
+ lifecycle: 'seed',
637
646
  pathParts: ['MIGRATION.md'],
638
647
  content: renderMigrationChecklist(plan, inventory, groups)
639
648
  }],
@@ -729,7 +738,7 @@ async function executeMigration(parsed, context, sourceRoot) {
729
738
  presentation.stage('Stage fresh migration project');
730
739
  await withStagingArea(async (area) => {
731
740
  const partition = partitionGeneratedArtifacts(buildArtifacts(plan));
732
- await writeStagedArtifacts(area, partition.durable, 'liftoff');
741
+ await writeStagedArtifacts(area, partition.liftoff, 'liftoff');
733
742
  presentation.stage('Initialize spec-driven framework', `${plan.specWorkflow.label} ${plan.framework.version}`);
734
743
  await initializeFramework(area, plan, runner, {
735
744
  env: context.env,
@@ -829,7 +838,7 @@ async function migrateCommand(parsed, context) {
829
838
  return 1;
830
839
  }
831
840
  if (existsSync(path.join(sourceRoot, 'liftoff.manifest.json'))) {
832
- context.presentation.error(`${sourceRoot} is already a Liftoff project.`, 'Use `liftoff update` instead.');
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.');
833
842
  return 1;
834
843
  }
835
844
  context.presentation.identity('Migrate an existing application into a fresh Liftoff project');
@@ -885,6 +894,151 @@ function isDirtyGitWorktree(projectRoot) {
885
894
  const result = spawnSync('git', ['status', '--porcelain'], { cwd: projectRoot, encoding: 'utf8' });
886
895
  return result.status === 0 && result.stdout.trim().length > 0;
887
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
+ }
888
1042
  async function preflightUpdate(projectRoot, entries, force) {
889
1043
  for (const entry of entries) {
890
1044
  const writesDestination = entry.status === 'new' ||
@@ -909,10 +1063,8 @@ async function captureUpdateSnapshots(projectRoot, manifest, render, initialSnap
909
1063
  const snapshots = new Map(initialSnapshots.map((snapshot) => [updateSnapshotKey(snapshot.pathParts), snapshot]));
910
1064
  const seenKeys = new Set(snapshots.keys());
911
1065
  const candidates = [
912
- ...manifest.artifacts.map((artifact) => artifact.pathParts),
913
- ...render
914
- .filter((artifact) => artifact.category !== 'seed' && artifact.category !== 'framework')
915
- .map((artifact) => artifact.pathParts)
1066
+ ...manifest.managedArtifacts.map((artifact) => artifact.pathParts),
1067
+ ...render.map((artifact) => artifact.pathParts)
916
1068
  ];
917
1069
  const uncaptured = [];
918
1070
  for (const pathParts of candidates) {
@@ -927,10 +1079,11 @@ async function captureUpdateSnapshots(projectRoot, manifest, render, initialSnap
927
1079
  }
928
1080
  return [...snapshots.values()];
929
1081
  }
930
- function selectUpdatePreconditions(snapshots, entries, mutations) {
1082
+ function selectUpdatePreconditions(snapshots, entries, mutations, additionalPaths = []) {
931
1083
  const requiredKeys = new Set([
932
1084
  updateSnapshotKey(['liftoff.config.json']),
933
- ...mutations.map((mutation) => updateSnapshotKey(mutation.pathParts))
1085
+ ...mutations.map((mutation) => updateSnapshotKey(mutation.pathParts)),
1086
+ ...additionalPaths.map((pathParts) => updateSnapshotKey(pathParts))
934
1087
  ]);
935
1088
  for (const entry of entries) {
936
1089
  const adoptsExistingDestination = entry.status === 'moved' && entry.destinationMatches === true;
@@ -945,12 +1098,41 @@ function selectUpdatePreconditions(snapshots, entries, mutations) {
945
1098
  }
946
1099
  return snapshots.filter((snapshot) => requiredKeys.has(updateSnapshotKey(snapshot.pathParts)));
947
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
+ }
948
1130
  async function updateCommand(parsed, context) {
949
1131
  const { presentation } = context;
950
1132
  const check = readBooleanFlag(parsed.flags, 'check') ?? false;
951
1133
  const force = readBooleanFlag(parsed.flags, 'force') ?? false;
952
1134
  const jsonMode = readBooleanFlag(parsed.flags, 'json') ?? false;
953
- presentation.commandIdentity('update', 'Reconcile the project with current Liftoff templates');
1135
+ presentation.commandIdentity('update', 'Reconcile Liftoff-managed core files');
954
1136
  if (force && check) {
955
1137
  presentation.error('--force cannot be combined with --check.', 'Run `liftoff update --check` to inspect drift or `liftoff update --force` to overwrite conflicts.');
956
1138
  return 1;
@@ -980,7 +1162,7 @@ async function updateCommand(parsed, context) {
980
1162
  recordedWorkload.kind === 'power-apps-code-app';
981
1163
  presentation.error(`Project type changes (${recordedWorkload.kind} -> ${plan.workload}) are not supported by update.`, involvesPowerApps
982
1164
  ? 'Initialize a fresh project for the new workload.'
983
- : 'Run `liftoff migrate` instead.');
1165
+ : 'Initialize a fresh project and move production behavior through a reviewed project change.');
984
1166
  return 1;
985
1167
  }
986
1168
  if (plan.workload === 'power-apps-code-app' && recordedWorkload.kind === 'power-apps-code-app') {
@@ -997,13 +1179,13 @@ async function updateCommand(parsed, context) {
997
1179
  }
998
1180
  else if (plan.workload !== 'power-apps-code-app' && recordedWorkload.kind !== 'power-apps-code-app') {
999
1181
  if (plan.apiStack.id !== recordedWorkload.apiStack) {
1000
- presentation.error(`API stack changes (${recordedWorkload.apiStack} -> ${plan.apiStack.id}) are a migration, not an update.`, 'Run `liftoff migrate` instead.');
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.');
1001
1183
  return 1;
1002
1184
  }
1003
1185
  const recordedPattern = recordedWorkload.kind === 'genai' ? recordedWorkload.pattern : undefined;
1004
1186
  const desiredPattern = plan.workload === 'genai' ? plan.pattern.id : undefined;
1005
1187
  if (desiredPattern !== recordedPattern) {
1006
- presentation.error(`Pattern changes (${recordedPattern ?? 'none'} -> ${desiredPattern ?? 'none'}) are a migration, not an update.`, 'Run `liftoff migrate` instead.');
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.');
1007
1189
  return 1;
1008
1190
  }
1009
1191
  }
@@ -1018,48 +1200,65 @@ async function updateCommand(parsed, context) {
1018
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.');
1019
1201
  return 1;
1020
1202
  }
1021
- const renderPlan = manifest.framework.state === 'legacy'
1203
+ let desiredRenderPlan = manifest.framework.state === 'legacy'
1022
1204
  ? { ...plan, agents: [], defaultAgent: undefined }
1023
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);
1024
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
+ ];
1025
1222
  const updateSnapshots = check
1026
1223
  ? []
1027
- : await captureUpdateSnapshots(projectRoot, manifest, render, initialUpdateSnapshots);
1224
+ : await captureUpdateSnapshots(projectRoot, manifest, scopedRender, initialUpdateSnapshots);
1028
1225
  const entries = await reconcileProject(manifest, render, projectRoot);
1029
1226
  const summary = summarizeEntries(entries);
1030
- const manifestUpgradePending = manifest.artifactVersion !== 5;
1031
- const starterUpgradePending = plan.workload === 'power-apps-code-app' &&
1032
- recordedWorkload.kind === 'power-apps-code-app' &&
1033
- recordedWorkload.starter.commit !== plan.starter.commit;
1034
- const nonDurableNames = new Set(render
1035
- .filter((artifact) => artifact.category === 'seed' || artifact.category === 'framework')
1036
- .map((artifact) => artifact.logicalName));
1037
- const manifestOwnershipCleanupPending = manifest.artifacts.some((artifact) => nonDurableNames.has(artifact.logicalName)) || manifestHadFilteredLegacySeedOwnership(manifest);
1038
- const manifestRewritePending = manifestUpgradePending ||
1039
- manifestOwnershipCleanupPending ||
1040
- starterUpgradePending;
1041
- const drift = hasDrift(entries) || manifestRewritePending;
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;
1042
1239
  const visible = entries.filter((entry) => entry.status !== 'unchanged' || entry.refreshHash);
1043
1240
  if (!drift) {
1044
1241
  if (jsonMode) {
1045
1242
  presentation.rawStdout(`${JSON.stringify(check ? {
1046
- schemaVersion: 1,
1243
+ schemaVersion: 2,
1047
1244
  mode: 'check',
1245
+ scope: 'managed-core',
1048
1246
  cliVersion: liftoffVersion,
1049
1247
  projectVersion: manifest.liftoffVersion,
1050
1248
  entries: [],
1051
- manifestUpgradePending,
1052
- manifestOwnershipCleanupPending,
1249
+ provisioning: [],
1250
+ ownershipMigrationPending,
1053
1251
  summary
1054
1252
  } : {
1055
- schemaVersion: 1,
1253
+ schemaVersion: 2,
1056
1254
  mode: 'apply',
1255
+ scope: 'managed-core',
1057
1256
  cliVersion: liftoffVersion,
1058
1257
  projectVersion: manifest.liftoffVersion,
1059
1258
  written: [],
1060
1259
  skipped: [],
1061
- manifestUpgradePending,
1062
- manifestOwnershipCleanupPending,
1260
+ provisioning: [],
1261
+ ownershipMigrationPending,
1063
1262
  summary
1064
1263
  }, null, 2)}\n`);
1065
1264
  return 0;
@@ -1068,14 +1267,15 @@ async function updateCommand(parsed, context) {
1068
1267
  { label: 'Liftoff CLI', value: liftoffVersion },
1069
1268
  { label: 'Project generated by', value: manifest.liftoffVersion }
1070
1269
  ]);
1071
- presentation.status('success', 'No drift', `${summary.unchanged} artifacts match the current templates and configuration`);
1270
+ presentation.status('success', 'Liftoff core is current', `${summary.unchanged} managed-core artifacts match; project files are not compared`);
1072
1271
  return 0;
1073
1272
  }
1074
1273
  if (check) {
1075
1274
  if (jsonMode) {
1076
1275
  presentation.rawStdout(`${JSON.stringify({
1077
- schemaVersion: 1,
1276
+ schemaVersion: 2,
1078
1277
  mode: 'check',
1278
+ scope: 'managed-core',
1079
1279
  cliVersion: liftoffVersion,
1080
1280
  projectVersion: manifest.liftoffVersion,
1081
1281
  entries: visible.map((entry) => ({
@@ -1085,8 +1285,8 @@ async function updateCommand(parsed, context) {
1085
1285
  previousPath: entry.previousPathParts ? manifestDisplayPath(entry.previousPathParts) : undefined,
1086
1286
  reason: entry.reason
1087
1287
  })),
1088
- manifestUpgradePending,
1089
- manifestOwnershipCleanupPending,
1288
+ provisioning: provisioningJson(provisioningPlans),
1289
+ ownershipMigrationPending,
1090
1290
  summary
1091
1291
  }, null, 2)}\n`);
1092
1292
  return 2;
@@ -1097,19 +1297,21 @@ async function updateCommand(parsed, context) {
1097
1297
  ]);
1098
1298
  if (visible.length > 0) {
1099
1299
  if (presentation.stdout.layout === 'plain') {
1100
- presentation.section('Template drift', visible.map((entry) => `${entryMarker(entry)} ${entryDisplay(entry)} ${entry.reason}`));
1300
+ presentation.section('Liftoff core drift', visible.map((entry) => `${entryMarker(entry)} ${entryDisplay(entry)} ${entry.reason}`));
1101
1301
  }
1102
1302
  else {
1103
- presentation.table('Template drift', ['Change', 'Artifact', 'Reason'], visible.map((entry) => [entryMarker(entry), entryDisplay(entry), entry.reason]));
1303
+ presentation.table('Liftoff core drift', ['Change', 'Artifact', 'Reason'], visible.map((entry) => [entryMarker(entry), entryDisplay(entry), entry.reason]));
1104
1304
  }
1105
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
+ }
1106
1309
  const manifestMaintenance = [
1107
- manifestUpgradePending ? 'upgrade liftoff.manifest.json to schema v5' : undefined,
1108
- manifestOwnershipCleanupPending
1109
- ? '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'
1110
1312
  : undefined,
1111
- starterUpgradePending
1112
- ? `record the current packaged Power Apps starter ${plan.starter.commit}`
1313
+ workloadIntentChanged
1314
+ ? 'record the requested project configuration intent after safe provisioning'
1113
1315
  : undefined
1114
1316
  ].filter((item) => item !== undefined);
1115
1317
  if (manifestMaintenance.length > 0) {
@@ -1120,8 +1322,11 @@ async function updateCommand(parsed, context) {
1120
1322
  summary.upgrade +
1121
1323
  summary.moved +
1122
1324
  summary.refresh +
1325
+ provisioningPlans.reduce((count, group) => count + (group.blocked
1326
+ ? 0
1327
+ : group.entries.filter((entry) => entry.status === 'create').length), 0) +
1123
1328
  (manifestRewritePending ? 1 : 0);
1124
- presentation.status('warning', 'Drift detected', `${toWrite} to write, ${summary.conflict} conflict(s), ${summary.orphan} orphan(s), ${summary.unchanged} unchanged`);
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`);
1125
1330
  if (toWrite > 0) {
1126
1331
  presentation.command('liftoff update');
1127
1332
  }
@@ -1130,6 +1335,7 @@ async function updateCommand(parsed, context) {
1130
1335
  }
1131
1336
  return 2;
1132
1337
  }
1338
+ const blockedProvisioning = provisioningPlans.filter((group) => group.blocked);
1133
1339
  if (isDirtyGitWorktree(projectRoot)) {
1134
1340
  const warning = 'The project worktree has uncommitted changes; consider committing before applying.';
1135
1341
  if (jsonMode) {
@@ -1139,7 +1345,7 @@ async function updateCommand(parsed, context) {
1139
1345
  presentation.warning(warning);
1140
1346
  }
1141
1347
  }
1142
- presentation.stage('Apply safe template changes', projectRoot);
1348
+ presentation.stage('Apply safe Liftoff core changes', projectRoot);
1143
1349
  await preflightUpdate(projectRoot, entries, force);
1144
1350
  const written = [];
1145
1351
  const skipped = [];
@@ -1192,12 +1398,32 @@ async function updateCommand(parsed, context) {
1192
1398
  break;
1193
1399
  }
1194
1400
  }
1195
- const oldByName = new Map(manifest.artifacts.map((artifact) => [artifact.logicalName, artifact]));
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]));
1196
1418
  const skippedByName = new Map(skipped.map((entry) => [entry.logicalName, entry]));
1197
1419
  const hasUnrecordedGovernanceConflict = skipped.some((entry) => entry.status === 'conflict' &&
1198
1420
  entry.rendered?.category === 'governance' &&
1199
1421
  !oldByName.has(entry.logicalName));
1200
- const nextManifest = buildManifest(plan, render.filter((artifact) => artifact.logicalName !== 'manifest'), { frameworkState: manifest.framework.state });
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
+ });
1201
1427
  nextManifest.framework = manifest.framework;
1202
1428
  nextManifest.project.specWorkflow = manifest.project.specWorkflow;
1203
1429
  nextManifest.project.agents = manifest.project.agents;
@@ -1211,11 +1437,7 @@ async function updateCommand(parsed, context) {
1211
1437
  nextManifest.governance.profile !== 'none') {
1212
1438
  nextManifest.governance.state = 'handoff-partial';
1213
1439
  }
1214
- nextManifest.artifacts = nextManifest.artifacts.flatMap((artifact) => {
1215
- // config is user-owned after create: carry the recorded entry forward untouched
1216
- if (artifact.logicalName === 'liftoff-config') {
1217
- return [oldByName.get('liftoff-config') ?? artifact];
1218
- }
1440
+ nextManifest.managedArtifacts = nextManifest.managedArtifacts.flatMap((artifact) => {
1219
1441
  if (!skippedByName.has(artifact.logicalName)) {
1220
1442
  return [artifact];
1221
1443
  }
@@ -1232,7 +1454,7 @@ async function updateCommand(parsed, context) {
1232
1454
  previous.category === 'governance') {
1233
1455
  continue;
1234
1456
  }
1235
- nextManifest.artifacts.push(previous);
1457
+ nextManifest.managedArtifacts.push(previous);
1236
1458
  }
1237
1459
  }
1238
1460
  mutations.push({
@@ -1240,34 +1462,52 @@ async function updateCommand(parsed, context) {
1240
1462
  pathParts: ['liftoff.manifest.json'],
1241
1463
  content: `${JSON.stringify(nextManifest, null, 2)}\n`
1242
1464
  });
1465
+ assertAuthorizedUpdateMutations(mutations, entries, provisioningPlans);
1243
1466
  await applyProjectFileTransaction(projectRoot, mutations, {
1244
- preconditions: selectUpdatePreconditions(updateSnapshots, entries, mutations)
1467
+ preconditions: selectUpdatePreconditions(updateSnapshots, entries, mutations, provisioned.map((entry) => entry.rendered.pathParts))
1245
1468
  });
1246
1469
  if (jsonMode) {
1247
1470
  presentation.rawStdout(`${JSON.stringify({
1248
- schemaVersion: 1,
1471
+ schemaVersion: 2,
1249
1472
  mode: 'apply',
1473
+ scope: 'managed-core',
1474
+ status: blockedProvisioning.length > 0 ? 'partial' : 'applied',
1250
1475
  cliVersion: liftoffVersion,
1251
1476
  projectVersion: manifest.liftoffVersion,
1252
1477
  written: written.map((entry) => manifestDisplayPath(entry.pathParts)),
1253
1478
  skipped: skipped.map((entry) => ({ path: manifestDisplayPath(entry.pathParts), reason: entry.reason })),
1254
- manifestUpgradePending,
1255
- manifestOwnershipCleanupPending,
1479
+ provisioning: provisioningJson(provisioningPlans),
1480
+ ownershipMigrationPending,
1256
1481
  summary
1257
1482
  }, null, 2)}\n`);
1258
1483
  return 0;
1259
1484
  }
1260
1485
  if (written.length > 0) {
1261
- 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`)));
1262
1495
  }
1263
1496
  if (skipped.length > 0) {
1264
- 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)'}`));
1265
1498
  }
1266
1499
  const orphans = entries.filter((entry) => entry.status === 'orphan');
1267
1500
  if (orphans.length > 0) {
1268
- 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}`));
1269
1502
  }
1270
- presentation.completion('Updated project', `${written.length} written, ${skipped.length} skipped, ${summary.orphan} orphan(s)`, [{ label: 'Manifest version', value: liftoffVersion }], 'liftoff validate && liftoff doctor');
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');
1271
1511
  return 0;
1272
1512
  }
1273
1513
  function renderSelfUpgradeResult(value, presentation) {
@@ -1514,7 +1754,7 @@ function stackProjectCheck(projectRoot, apiStack) {
1514
1754
  label: `${apiStack} project`,
1515
1755
  severity: 'fail',
1516
1756
  detail: `missing locked dependency metadata: ${missingMetadata}`,
1517
- remedy: `restore ${missingMetadata} or run liftoff update`
1757
+ remedy: `restore or repair the project-owned ${missingMetadata}`
1518
1758
  };
1519
1759
  }
1520
1760
  if (apiStack === 'python-fastapi') {
@@ -1527,7 +1767,7 @@ function stackProjectCheck(projectRoot, apiStack) {
1527
1767
  label: `${apiStack} project`,
1528
1768
  severity: 'fail',
1529
1769
  detail: 'pyproject.toml and uv.lock do not identify the same project',
1530
- remedy: 'restore backend/pyproject.toml and backend/uv.lock or run liftoff update'
1770
+ remedy: 'repair the project-owned backend/pyproject.toml and backend/uv.lock'
1531
1771
  };
1532
1772
  }
1533
1773
  }
@@ -1579,7 +1819,7 @@ async function powerAppsProjectCheck(projectRoot) {
1579
1819
  severity: 'fail',
1580
1820
  state: 'unhealthy',
1581
1821
  detail: issues[0],
1582
- remedy: 'restore package.json and package-lock.json or run liftoff update'
1822
+ remedy: 'repair the project-owned package.json and package-lock.json'
1583
1823
  };
1584
1824
  }
1585
1825
  function runReadOnly(command, args, cwd) {
@@ -1660,11 +1900,15 @@ async function projectLayer(projectRoot, manifest) {
1660
1900
  label: 'manifest',
1661
1901
  severity: 'fail',
1662
1902
  detail: `${issues.length} issue(s): ${issues[0]}${issues.length > 1 ? ' ...' : ''}`,
1663
- remedy: 'restore missing artifacts or run liftoff update'
1903
+ remedy: 'restore the manifest or run liftoff update for managed-core repair'
1664
1904
  });
1665
1905
  }
1666
1906
  else {
1667
- checks.push({ label: 'manifest', severity: 'ok', detail: `valid, ${manifest.artifacts.length} artifacts present` });
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
+ });
1668
1912
  }
1669
1913
  if (compareSemver(manifest.liftoffVersion, liftoffVersion) > 0) {
1670
1914
  checks.push({
@@ -1738,22 +1982,29 @@ async function projectLayer(projectRoot, manifest) {
1738
1982
  const plan = buildProjectPlan(config, { requireProjectName: true });
1739
1983
  const render = buildArtifacts(plan);
1740
1984
  const entries = await reconcileProject(manifest, render, projectRoot);
1741
- const driftCount = entries.filter((entry) => entry.status !== 'unchanged' || entry.refreshHash).length;
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);
1742
1989
  if (driftCount > 0) {
1743
1990
  checks.push({
1744
- label: 'scaffold drift',
1991
+ label: 'managed core',
1745
1992
  severity: 'warn',
1746
- detail: `${driftCount} update(s) available`,
1993
+ detail: `${driftCount} core maintenance action(s) available`,
1747
1994
  remedy: 'run liftoff update'
1748
1995
  });
1749
1996
  }
1750
1997
  else {
1751
- checks.push({ label: 'scaffold drift', severity: 'ok', detail: 'project matches the current templates' });
1998
+ checks.push({
1999
+ label: 'managed core',
2000
+ severity: 'ok',
2001
+ detail: 'Liftoff core is current; project templates are not compared'
2002
+ });
1752
2003
  }
1753
2004
  }
1754
2005
  catch (error) {
1755
2006
  checks.push({
1756
- label: 'scaffold drift',
2007
+ label: 'managed core',
1757
2008
  severity: 'fail',
1758
2009
  detail: `liftoff.config.json could not be evaluated: ${error.message.split('\n')[0]}`,
1759
2010
  remedy: 'repair liftoff.config.json'