@msn-control/liftoff 0.4.0 → 0.5.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.
Files changed (123) hide show
  1. package/README.md +65 -224
  2. package/assets/power-apps-code-app/3438c352483e40982f6c5c0fc36fd71f8e7adbbb/UPSTREAM_LICENSE.txt +21 -0
  3. package/assets/power-apps-code-app/3438c352483e40982f6c5c0fc36fd71f8e7adbbb/catalog.json +520 -0
  4. package/assets/power-apps-code-app/3438c352483e40982f6c5c0fc36fd71f8e7adbbb/packaged/gitignore +27 -0
  5. package/assets/power-apps-code-app/3438c352483e40982f6c5c0fc36fd71f8e7adbbb/starter/README.md +25 -0
  6. package/assets/power-apps-code-app/3438c352483e40982f6c5c0fc36fd71f8e7adbbb/starter/components.json +22 -0
  7. package/assets/power-apps-code-app/3438c352483e40982f6c5c0fc36fd71f8e7adbbb/starter/eslint.config.js +23 -0
  8. package/assets/power-apps-code-app/3438c352483e40982f6c5c0fc36fd71f8e7adbbb/starter/index.html +13 -0
  9. package/assets/power-apps-code-app/3438c352483e40982f6c5c0fc36fd71f8e7adbbb/starter/package-lock.json +6597 -0
  10. package/assets/power-apps-code-app/3438c352483e40982f6c5c0fc36fd71f8e7adbbb/starter/package.json +60 -0
  11. package/assets/power-apps-code-app/3438c352483e40982f6c5c0fc36fd71f8e7adbbb/starter/public/power-apps.svg +55 -0
  12. package/assets/power-apps-code-app/3438c352483e40982f6c5c0fc36fd71f8e7adbbb/starter/src/App.tsx +17 -0
  13. package/assets/power-apps-code-app/3438c352483e40982f6c5c0fc36fd71f8e7adbbb/starter/src/assets/react.svg +1 -0
  14. package/assets/power-apps-code-app/3438c352483e40982f6c5c0fc36fd71f8e7adbbb/starter/src/components/mode-toggle.tsx +37 -0
  15. package/assets/power-apps-code-app/3438c352483e40982f6c5c0fc36fd71f8e7adbbb/starter/src/components/ui/badge.tsx +46 -0
  16. package/assets/power-apps-code-app/3438c352483e40982f6c5c0fc36fd71f8e7adbbb/starter/src/components/ui/button.tsx +60 -0
  17. package/assets/power-apps-code-app/3438c352483e40982f6c5c0fc36fd71f8e7adbbb/starter/src/components/ui/calendar.tsx +211 -0
  18. package/assets/power-apps-code-app/3438c352483e40982f6c5c0fc36fd71f8e7adbbb/starter/src/components/ui/card.tsx +92 -0
  19. package/assets/power-apps-code-app/3438c352483e40982f6c5c0fc36fd71f8e7adbbb/starter/src/components/ui/chart.tsx +355 -0
  20. package/assets/power-apps-code-app/3438c352483e40982f6c5c0fc36fd71f8e7adbbb/starter/src/components/ui/checkbox.tsx +32 -0
  21. package/assets/power-apps-code-app/3438c352483e40982f6c5c0fc36fd71f8e7adbbb/starter/src/components/ui/command.tsx +184 -0
  22. package/assets/power-apps-code-app/3438c352483e40982f6c5c0fc36fd71f8e7adbbb/starter/src/components/ui/dialog.tsx +141 -0
  23. package/assets/power-apps-code-app/3438c352483e40982f6c5c0fc36fd71f8e7adbbb/starter/src/components/ui/dropdown-menu.tsx +257 -0
  24. package/assets/power-apps-code-app/3438c352483e40982f6c5c0fc36fd71f8e7adbbb/starter/src/components/ui/input.tsx +21 -0
  25. package/assets/power-apps-code-app/3438c352483e40982f6c5c0fc36fd71f8e7adbbb/starter/src/components/ui/label.tsx +22 -0
  26. package/assets/power-apps-code-app/3438c352483e40982f6c5c0fc36fd71f8e7adbbb/starter/src/components/ui/popover.tsx +46 -0
  27. package/assets/power-apps-code-app/3438c352483e40982f6c5c0fc36fd71f8e7adbbb/starter/src/components/ui/progress.tsx +29 -0
  28. package/assets/power-apps-code-app/3438c352483e40982f6c5c0fc36fd71f8e7adbbb/starter/src/components/ui/select.tsx +185 -0
  29. package/assets/power-apps-code-app/3438c352483e40982f6c5c0fc36fd71f8e7adbbb/starter/src/components/ui/separator.tsx +26 -0
  30. package/assets/power-apps-code-app/3438c352483e40982f6c5c0fc36fd71f8e7adbbb/starter/src/components/ui/skeleton.tsx +13 -0
  31. package/assets/power-apps-code-app/3438c352483e40982f6c5c0fc36fd71f8e7adbbb/starter/src/components/ui/table.tsx +114 -0
  32. package/assets/power-apps-code-app/3438c352483e40982f6c5c0fc36fd71f8e7adbbb/starter/src/components/ui/tabs.tsx +66 -0
  33. package/assets/power-apps-code-app/3438c352483e40982f6c5c0fc36fd71f8e7adbbb/starter/src/components/ui/textarea.tsx +18 -0
  34. package/assets/power-apps-code-app/3438c352483e40982f6c5c0fc36fd71f8e7adbbb/starter/src/components/ui/tooltip.tsx +61 -0
  35. package/assets/power-apps-code-app/3438c352483e40982f6c5c0fc36fd71f8e7adbbb/starter/src/hooks/use-theme.ts +11 -0
  36. package/assets/power-apps-code-app/3438c352483e40982f6c5c0fc36fd71f8e7adbbb/starter/src/index.css +131 -0
  37. package/assets/power-apps-code-app/3438c352483e40982f6c5c0fc36fd71f8e7adbbb/starter/src/lib/utils.ts +6 -0
  38. package/assets/power-apps-code-app/3438c352483e40982f6c5c0fc36fd71f8e7adbbb/starter/src/main.tsx +10 -0
  39. package/assets/power-apps-code-app/3438c352483e40982f6c5c0fc36fd71f8e7adbbb/starter/src/pages/_layout.tsx +31 -0
  40. package/assets/power-apps-code-app/3438c352483e40982f6c5c0fc36fd71f8e7adbbb/starter/src/pages/home.tsx +59 -0
  41. package/assets/power-apps-code-app/3438c352483e40982f6c5c0fc36fd71f8e7adbbb/starter/src/pages/not-found.tsx +14 -0
  42. package/assets/power-apps-code-app/3438c352483e40982f6c5c0fc36fd71f8e7adbbb/starter/src/providers/query-provider.tsx +26 -0
  43. package/assets/power-apps-code-app/3438c352483e40982f6c5c0fc36fd71f8e7adbbb/starter/src/providers/sonner-provider.tsx +23 -0
  44. package/assets/power-apps-code-app/3438c352483e40982f6c5c0fc36fd71f8e7adbbb/starter/src/providers/theme-provider.tsx +64 -0
  45. package/assets/power-apps-code-app/3438c352483e40982f6c5c0fc36fd71f8e7adbbb/starter/src/router.tsx +24 -0
  46. package/assets/power-apps-code-app/3438c352483e40982f6c5c0fc36fd71f8e7adbbb/starter/tsconfig.app.json +34 -0
  47. package/assets/power-apps-code-app/3438c352483e40982f6c5c0fc36fd71f8e7adbbb/starter/tsconfig.json +13 -0
  48. package/assets/power-apps-code-app/3438c352483e40982f6c5c0fc36fd71f8e7adbbb/starter/tsconfig.node.json +26 -0
  49. package/assets/power-apps-code-app/3438c352483e40982f6c5c0fc36fd71f8e7adbbb/starter/vite.config.ts +19 -0
  50. package/assets/power-apps-code-app/README.md +27 -0
  51. package/dist/args.d.ts +33 -0
  52. package/dist/args.js +100 -38
  53. package/dist/args.js.map +1 -1
  54. package/dist/catalogs.d.ts +3 -1
  55. package/dist/catalogs.js +23 -0
  56. package/dist/catalogs.js.map +1 -1
  57. package/dist/cli.js +6 -1
  58. package/dist/cli.js.map +1 -1
  59. package/dist/code-apps-plugin.d.ts +13 -0
  60. package/dist/code-apps-plugin.js +69 -0
  61. package/dist/code-apps-plugin.js.map +1 -0
  62. package/dist/commands.d.ts +4 -0
  63. package/dist/commands.js +669 -321
  64. package/dist/commands.js.map +1 -1
  65. package/dist/file-system.d.ts +16 -1
  66. package/dist/file-system.js +320 -10
  67. package/dist/file-system.js.map +1 -1
  68. package/dist/framework-adapters.d.ts +5 -2
  69. package/dist/framework-adapters.js +5 -2
  70. package/dist/framework-adapters.js.map +1 -1
  71. package/dist/genai-templates.d.ts +6 -6
  72. package/dist/genai-templates.js +0 -3
  73. package/dist/genai-templates.js.map +1 -1
  74. package/dist/interactive.d.ts +80 -6
  75. package/dist/interactive.js +338 -142
  76. package/dist/interactive.js.map +1 -1
  77. package/dist/migrate-plan.d.ts +4 -4
  78. package/dist/migrate-plan.js +1 -1
  79. package/dist/migrate-plan.js.map +1 -1
  80. package/dist/planner.d.ts +5 -0
  81. package/dist/planner.js +197 -53
  82. package/dist/planner.js.map +1 -1
  83. package/dist/power-apps-assets.d.ts +30 -0
  84. package/dist/power-apps-assets.js +131 -0
  85. package/dist/power-apps-assets.js.map +1 -0
  86. package/dist/power-apps-templates.d.ts +3 -0
  87. package/dist/power-apps-templates.js +140 -0
  88. package/dist/power-apps-templates.js.map +1 -0
  89. package/dist/power-apps-validation.d.ts +1 -0
  90. package/dist/power-apps-validation.js +79 -0
  91. package/dist/power-apps-validation.js.map +1 -0
  92. package/dist/process-runner.js +9 -6
  93. package/dist/process-runner.js.map +1 -1
  94. package/dist/project-dependencies.d.ts +7 -4
  95. package/dist/project-dependencies.js +16 -4
  96. package/dist/project-dependencies.js.map +1 -1
  97. package/dist/published-verifier.js +1 -1
  98. package/dist/published-verifier.js.map +1 -1
  99. package/dist/standard-templates.d.ts +4 -4
  100. package/dist/standard-templates.js.map +1 -1
  101. package/dist/templates.d.ts +2 -2
  102. package/dist/templates.js +196 -50
  103. package/dist/templates.js.map +1 -1
  104. package/dist/terminal.d.ts +99 -3
  105. package/dist/terminal.js +482 -66
  106. package/dist/terminal.js.map +1 -1
  107. package/dist/types.d.ts +73 -15
  108. package/dist/workstation.d.ts +10 -5
  109. package/dist/workstation.js +25 -10
  110. package/dist/workstation.js.map +1 -1
  111. package/docs/assets/liftoff-terminal.svg +33 -0
  112. package/docs/azure-deployment.md +66 -0
  113. package/docs/cli-reference.md +128 -0
  114. package/docs/configuration-and-manifests.md +102 -0
  115. package/docs/existing-repositories.md +87 -0
  116. package/docs/getting-started.md +114 -0
  117. package/docs/prerequisites.md +99 -0
  118. package/docs/project-structure.md +119 -0
  119. package/docs/safety-and-consent.md +98 -0
  120. package/docs/spec-workflows-and-agents.md +92 -0
  121. package/docs/troubleshooting.md +133 -0
  122. package/docs/workloads.md +128 -0
  123. package/package.json +8 -2
package/dist/commands.js CHANGED
@@ -4,28 +4,41 @@ import { existsSync } 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';
7
- import { formatCommandHelp, formatGeneralHelp, readBooleanFlag, readListFlag, readStringFlag } from './args.js';
7
+ import { getCommandHelp, getGeneralHelp, readBooleanFlag, readListFlag, readStringFlag } from './args.js';
8
8
  import { getCodingAgent, getFrameworkDefinition, getSpecWorkflow, listRegions, patterns, providers, searchRegions } from './catalogs.js';
9
+ import { probeCodeAppsPlugin } from './code-apps-plugin.js';
9
10
  import { initializeFramework } from './framework-adapters.js';
10
11
  import { validateFrameworkInstallation } from './framework-validation.js';
11
- import { artifactPath, assertNewOrEmptyDirectory, deleteProjectFile, findProjectRoot, loadManifest, manifestDisplayPath, resolveProjectPath, validateGeneratedProject, writeArtifacts, writeProjectFile } from './file-system.js';
12
- import { confirmDependencyInstallation, confirmFileReplacements, confirmPlan, confirmToolInstallation, promptForInitOptions } from './interactive.js';
12
+ import { artifactPath, applyProjectFileTransaction, assertNewOrEmptyDirectory, findProjectRoot, loadManifest, manifestDisplayPath, resolveProjectPath, validateGeneratedProject, writeArtifacts, writeProjectFile } from './file-system.js';
13
+ import { InteractiveCancelledError, InteractivePrompter } from './interactive.js';
13
14
  import { applyMergePreflight, assertSafeInitTarget, authorizeMergePreflight, buildMergePreflight, captureTreeState, discoverGitRoot, resolveInitTargetFromDiscovery, validateStagedTree, withStagingArea, writeStagedArtifacts } from './init-filesystem.js';
14
15
  import { renderMigrationChecklist, renderMigrationProposal, renderMigrationTasks, seedMigrationGroups } from './migrate-plan.js';
15
- import { buildProjectPlan, formatProjectPlan, loadConfigOptions, mergeOptions, PlanValidationError } from './planner.js';
16
- import { buildDependencySetupPlan, runDependencySetup } from './project-dependencies.js';
16
+ import { buildProjectPlan, loadConfigOptions, mergeOptions, PlanValidationError, projectPlanEntries } from './planner.js';
17
+ import { buildDependencySetupPlan, dependencyResumeCommand, runDependencySetup, verifyPowerAppsPackageMetadata } from './project-dependencies.js';
17
18
  import { formatCommand, NodeCommandRunner } from './process-runner.js';
18
19
  import { scanDefaults, scanLegacyProject } from './scan.js';
19
20
  import { hasDrift, reconcileProject } from './reconcile.js';
20
21
  import { compareSemver } from './semver.js';
21
22
  import { buildArtifacts, buildManifest, partitionGeneratedArtifacts } from './templates.js';
22
- import { TerminalRenderer } from './terminal.js';
23
+ import { PresentationSession } from './terminal.js';
23
24
  import { liftoffVersion } from './version.js';
24
25
  import { blockingReadinessFailures, detectHostEnvironment, installRequirement, probeWorkstation, selectLiftoffRuntimeRequirements, selectWorkstationRequirements } from './workstation.js';
25
26
  export async function runCommand(parsed, context) {
27
+ const helpRequested = parsed.command !== undefined && readBooleanFlag(parsed.flags, 'help') === true;
28
+ const jsonMode = !helpRequested && (parsed.command === 'doctor' ||
29
+ parsed.command === 'update' ||
30
+ parsed.command === 'validate') &&
31
+ readBooleanFlag(parsed.flags, 'json') === true;
32
+ const presentation = new PresentationSession({
33
+ stdout: context.stdout,
34
+ stderr: context.stderr,
35
+ ...context.terminal,
36
+ json: jsonMode
37
+ });
38
+ const executionContext = { ...context, presentation };
26
39
  try {
27
40
  if (parsed.command && readBooleanFlag(parsed.flags, 'help')) {
28
- context.stdout.write(formatCommandHelp(parsed.command));
41
+ renderCommandHelp(parsed.command, presentation);
29
42
  return 0;
30
43
  }
31
44
  switch (parsed.command) {
@@ -33,156 +46,220 @@ export async function runCommand(parsed, context) {
33
46
  case 'help':
34
47
  case '--help':
35
48
  if (parsed.positional[0]) {
36
- context.stdout.write(formatCommandHelp(parsed.positional[0]));
49
+ renderCommandHelp(parsed.positional[0], presentation);
37
50
  }
38
51
  else {
39
- printHelp(context.stdout);
52
+ renderGeneralHelp(presentation);
40
53
  }
41
54
  return 0;
42
55
  case 'version':
43
- context.stdout.write(`Liftoff ${liftoffVersion}\n`);
56
+ presentation.rawStdout(`Liftoff ${liftoffVersion}\n`);
44
57
  return 0;
45
58
  case 'init':
46
- return await initCommand(parsed, context);
59
+ return await initCommand(parsed, executionContext);
47
60
  case 'plan':
48
- return await planCommand(parsed, context);
61
+ return await planCommand(parsed, executionContext);
49
62
  case 'patterns':
50
- return patternsCommand(context);
63
+ return patternsCommand(executionContext);
51
64
  case 'providers':
52
- return providersCommand(context);
65
+ return providersCommand(executionContext);
53
66
  case 'regions':
54
- return regionsCommand(parsed, context);
67
+ return regionsCommand(parsed, executionContext);
55
68
  case 'validate':
56
- return await validateCommand(parsed, context);
69
+ return await validateCommand(parsed, executionContext);
57
70
  case 'update':
58
- return await updateCommand(parsed, context);
71
+ return await updateCommand(parsed, executionContext);
59
72
  case 'migrate':
60
- return await migrateCommand(parsed, context);
73
+ return await migrateCommand(parsed, executionContext);
61
74
  case 'doctor':
62
- return await doctorCommand(parsed, context);
75
+ return await doctorCommand(parsed, executionContext);
63
76
  case 'dev':
64
- return helperCommand(parsed, context, 'docker compose');
77
+ return helperCommand(parsed, executionContext, 'docker compose');
65
78
  case 'infra':
66
- return helperCommand(parsed, context, 'tofu');
79
+ return helperCommand(parsed, executionContext, 'tofu');
67
80
  default:
68
- context.stderr.write(`Unknown command: ${parsed.command}\n\n`);
69
- printHelp(context.stderr);
81
+ presentation.error(`Unknown command: ${parsed.command}`, 'Run `liftoff help` to list available commands.');
70
82
  return 1;
71
83
  }
72
84
  }
73
85
  catch (error) {
86
+ if (error instanceof InteractiveCancelledError) {
87
+ presentation.cancellation('Interactive operation stopped.');
88
+ return 0;
89
+ }
74
90
  if (error instanceof PlanValidationError) {
75
- context.stderr.write(`${error.issues.join('\n')}\n`);
91
+ presentation.error(error.issues.join('\n'), parsed.command ? `Run \`liftoff ${parsed.command} --help\` to review accepted values.` : undefined);
76
92
  return 1;
77
93
  }
78
- context.stderr.write(`${error.message}\n`);
94
+ presentation.error(error instanceof Error ? error.message : String(error));
79
95
  return 1;
80
96
  }
81
97
  }
82
98
  async function initCommand(parsed, context) {
99
+ const { presentation } = context;
100
+ presentation.identity('Initialize the project and prepare its workstation');
83
101
  const runner = context.runner ?? new NodeCommandRunner();
102
+ presentation.stage('Discover project context');
84
103
  const git = await discoverGitRoot(context.cwd, runner);
85
104
  let initial = await optionsFromParsedArgs(parsed, context.cwd, true);
86
105
  if (!initial.projectName && git.exact && git.root) {
87
106
  initial = { ...initial, projectName: path.basename(git.root) };
88
107
  }
89
- const needsPrompts = !initial.yes && hasMissingInitInputs(initial);
90
- const options = needsPrompts ? await promptForInitOptions(initial) : initial;
91
- const plan = buildProjectPlan(options, { requireProjectName: true });
92
- const confirmed = await confirmPlan(plan, options.yes);
93
- if (!confirmed) {
94
- context.stdout.write('Initialization cancelled.\n');
95
- return 0;
96
- }
97
- const target = resolveInitTargetFromDiscovery(git, plan.safeProjectName);
98
- await assertSafeInitTarget(target, target.mode === 'named-child' ? git.canonicalCwd : undefined);
99
- const renderer = new TerminalRenderer({ stream: context.stdout });
100
- renderer.write(renderer.banner('Initialize the project and prepare its workstation'));
101
- const readiness = await ensureWorkstationReady(plan, options, context, runner, renderer);
102
- if (!readiness.ready) {
103
- return 1;
104
- }
105
- const staged = await withStagingArea(async (area) => {
106
- const partition = partitionGeneratedArtifacts(buildArtifacts(plan));
107
- await writeStagedArtifacts(area, partition.durable, 'liftoff');
108
- await initializeFramework(area, plan, runner, { stdout: context.stdout, stderr: context.stderr });
109
- await writeStagedArtifacts(area, partition.seed, 'seed');
110
- await writeStagedArtifacts(area, [partition.manifest], 'liftoff');
111
- await validateStagedTree(area);
112
- const stagedIssues = await validateGeneratedProject(area.root);
113
- if (stagedIssues.length > 0) {
114
- throw new Error(`Staged project validation failed:\n${stagedIssues.join('\n')}`);
115
- }
116
- const preflight = await buildMergePreflight(area, target.root);
117
- const interactive = options.yes !== true;
118
- const authorized = await authorizeMergePreflight(preflight, options.force === true, interactive ? confirmFileReplacements : undefined);
119
- if (!authorized) {
120
- return {
121
- status: interactive ? 'declined' : 'authorization-required'
122
- };
123
- }
124
- return { status: 'applied', merge: await applyMergePreflight(authorized) };
125
- });
126
- if (staged.status === 'declined') {
127
- context.stdout.write('Initialization cancelled; no destination files were changed.\n');
108
+ const interactive = initial.yes !== true;
109
+ const prompter = interactive
110
+ ? new InteractivePrompter({
111
+ input: context.stdin,
112
+ output: context.stdout,
113
+ presentation,
114
+ cwd: context.cwd,
115
+ configuredRoot: git.exact ? git.root : undefined,
116
+ runner
117
+ })
118
+ : undefined;
119
+ try {
120
+ const needsPrompts = interactive && hasMissingInitInputs(initial);
121
+ if (needsPrompts) {
122
+ presentation.stage('Configure project');
123
+ }
124
+ let options;
125
+ let plan;
126
+ let confirmed;
127
+ try {
128
+ options = needsPrompts ? await prompter.promptForInitOptions(initial) : initial;
129
+ plan = buildProjectPlan(options, { requireProjectName: true });
130
+ presentation.stage('Review resolved plan');
131
+ confirmed = options.yes === true
132
+ ? (presentation.definitions('Resolved project plan', projectPlanEntries(plan)), true)
133
+ : await prompter.confirmPlan(plan);
134
+ }
135
+ catch (error) {
136
+ if (error instanceof InteractiveCancelledError) {
137
+ presentation.cancellation('Initialization stopped; no destination files were changed.');
138
+ return 0;
139
+ }
140
+ throw error;
141
+ }
142
+ if (!confirmed) {
143
+ presentation.cancellation('Initialization stopped; no destination files were changed.');
144
+ return 0;
145
+ }
146
+ presentation.stage('Resolve destination');
147
+ const target = resolveInitTargetFromDiscovery(git, plan.safeProjectName);
148
+ await assertSafeInitTarget(target, target.mode === 'named-child' ? git.canonicalCwd : undefined);
149
+ presentation.stage('Check workstation readiness');
150
+ const readiness = await ensureWorkstationReady(plan, options, context, runner, presentation, prompter);
151
+ if (!readiness.ready) {
152
+ return 1;
153
+ }
154
+ presentation.stage('Stage project files');
155
+ const staged = await withStagingArea(async (area) => {
156
+ const partition = partitionGeneratedArtifacts(buildArtifacts(plan));
157
+ await writeStagedArtifacts(area, partition.durable, 'liftoff');
158
+ presentation.stage('Initialize spec-driven framework', `${plan.specWorkflow.label} ${plan.framework.version}`);
159
+ await initializeFramework(area, plan, runner, {
160
+ ...presentation.childStreams(),
161
+ onCommand: (command) => presentation.command(command)
162
+ });
163
+ await writeStagedArtifacts(area, partition.seed, 'seed');
164
+ await writeStagedArtifacts(area, [partition.manifest], 'liftoff');
165
+ presentation.stage('Validate staged project');
166
+ await validateStagedTree(area);
167
+ const stagedIssues = await validateGeneratedProject(area.root);
168
+ if (stagedIssues.length > 0) {
169
+ throw new Error(`Staged project validation failed:\n${stagedIssues.join('\n')}`);
170
+ }
171
+ const preflight = await buildMergePreflight(area, target.root);
172
+ const authorized = await authorizeMergePreflight(preflight, options.force === true, interactive ? (paths) => prompter.confirmFileReplacements(paths) : undefined);
173
+ if (!authorized) {
174
+ return {
175
+ status: interactive ? 'declined' : 'authorization-required'
176
+ };
177
+ }
178
+ presentation.stage('Merge staged project', target.root);
179
+ return { status: 'applied', merge: await applyMergePreflight(authorized) };
180
+ });
181
+ if (staged.status === 'declined') {
182
+ presentation.cancellation('No destination files were changed.');
183
+ return 0;
184
+ }
185
+ if (staged.status === 'authorization-required') {
186
+ presentation.error('Existing regular-file conflicts require --force in non-interactive mode.', 'Review the listed conflicts, then rerun with `--force` only if every replacement is intended.');
187
+ return 1;
188
+ }
189
+ const issues = await validateGeneratedProject(target.root);
190
+ if (issues.length > 0) {
191
+ presentation.error(`Initialized project validation failed:\n${issues.join('\n')}`);
192
+ return 1;
193
+ }
194
+ const dependencyPhase = await handleProjectDependencies(plan, target.root, options, readiness.probes, context, runner, presentation, prompter);
195
+ if (!dependencyPhase.success) {
196
+ return 1;
197
+ }
198
+ presentation.bullets('Configured integrations', [
199
+ `${plan.specWorkflow.label} ${plan.framework.version}`,
200
+ ...plan.agents.map((agent) => `${agent.label}${plan.defaultAgent?.id === agent.id ? ' (default)' : ''}`),
201
+ ...readiness.pluginProbes.map((probe) => `Code Apps plugin for ${probe.agent.label}: ${probe.state}`)
202
+ ]);
203
+ if (readiness.deferred.length > 0) {
204
+ presentation.bullets('Deferred advisory checks', readiness.deferred);
205
+ }
206
+ if (dependencyPhase.deferred.length > 0) {
207
+ presentation.bullets('Deferred project dependencies', dependencyPhase.deferred);
208
+ }
209
+ presentation.completion(`Initialized ${plan.projectName}`, target.root, [
210
+ { label: 'Target', value: target.root },
211
+ { label: 'Spec workflow', value: plan.specWorkflow.label },
212
+ { label: 'Coding agents', value: plan.agents.map((agent) => agent.label).join(', ') }
213
+ ], `liftoff validate ${JSON.stringify(target.root)}`);
128
214
  return 0;
129
215
  }
130
- if (staged.status === 'authorization-required') {
131
- context.stderr.write('Existing regular-file conflicts require --force in non-interactive mode.\n');
132
- return 1;
133
- }
134
- const issues = await validateGeneratedProject(target.root);
135
- if (issues.length > 0) {
136
- context.stderr.write(`Initialized project validation failed:\n${issues.join('\n')}\n`);
137
- return 1;
138
- }
139
- const dependencyPhase = await handleProjectDependencies(plan, target.root, options, readiness.probes, context, runner, renderer);
140
- if (!dependencyPhase.success) {
141
- return 1;
142
- }
143
- renderer.write(renderer.status('success', `Initialized ${plan.projectName}`, target.root));
144
- renderer.write(renderer.panel('Configured integrations', [
145
- `${plan.specWorkflow.label} ${plan.framework.version}`,
146
- ...plan.agents.map((agent) => `${agent.label}${plan.defaultAgent?.id === agent.id ? ' (default)' : ''}`)
147
- ]));
148
- if (readiness.deferred.length > 0) {
149
- renderer.write(renderer.panel('Deferred advisory checks', readiness.deferred));
150
- }
151
- if (dependencyPhase.deferred.length > 0) {
152
- renderer.write(renderer.panel('Deferred project dependencies', dependencyPhase.deferred));
216
+ finally {
217
+ prompter?.close();
153
218
  }
154
- renderer.write(renderer.command(`liftoff validate ${JSON.stringify(target.root)}`));
155
- return 0;
156
219
  }
157
220
  async function planCommand(parsed, context) {
221
+ const { presentation } = context;
222
+ presentation.identity('Preview project decisions, artifacts, and workstation requirements');
158
223
  const options = await optionsFromParsedArgs(parsed, context.cwd, false);
159
224
  const plan = buildProjectPlan(options, { requireProjectName: false });
160
225
  const artifacts = buildArtifacts(plan);
161
- context.stdout.write(`${formatProjectPlan(plan)}\n\nArtifacts (${artifacts.length}):\n`);
162
- for (const artifact of artifacts) {
163
- context.stdout.write(`- ${artifact.logicalName}: ${artifact.pathParts.join('/')}\n`);
164
- }
165
- context.stdout.write('\nWorkstation requirements:\n');
166
- for (const requirement of selectWorkstationRequirements(plan)) {
167
- const version = requirement.exactVersion
226
+ presentation.definitions('Project decisions', projectPlanEntries(plan));
227
+ presentation.table(`Artifacts (${artifacts.length})`, ['Artifact', 'Path'], artifacts.map((artifact) => [artifact.logicalName, artifact.pathParts.join('/')]));
228
+ const workstationRows = selectWorkstationRequirements(plan).map((requirement) => [
229
+ requirement.definition.label,
230
+ requirement.exactVersion
168
231
  ? `exactly ${requirement.exactVersion}`
169
232
  : requirement.minimumVersion
170
233
  ? `${requirement.minimumVersion}+`
171
- : 'available';
172
- context.stdout.write(`- ${requirement.definition.label}: ${version} [${requirement.severity}]\n`);
234
+ : 'available',
235
+ requirement.severity
236
+ ]);
237
+ if (presentation.stdout.layout === 'plain') {
238
+ presentation.section('Workstation requirements', workstationRows.map(([label, version, severity]) => `${label}: ${version} [${severity}]`));
239
+ }
240
+ else {
241
+ presentation.table('Workstation requirements', ['Requirement', 'Version', 'Level'], workstationRows);
173
242
  }
174
243
  return 0;
175
244
  }
176
- async function ensureWorkstationReady(plan, options, context, runner, renderer, resumeInvocation = 'liftoff init', commandCwd) {
245
+ async function ensureWorkstationReady(plan, options, context, runner, presentation, prompter, resumeInvocation = 'liftoff init', commandCwd) {
177
246
  const requirements = selectWorkstationRequirements(plan);
178
- let probes = await probeWorkstation(requirements, runner, { cwd: commandCwd });
179
- renderer.write(renderer.heading('Workstation readiness'));
180
- renderer.write(renderer.table(['Requirement', 'Level', 'State', 'Detail'], probes.map((probe) => [
247
+ const [initialProbes, pluginProbes] = await Promise.all([
248
+ probeWorkstation(requirements, runner, { cwd: commandCwd }),
249
+ plan.workload === 'power-apps-code-app' && plan.codeAppsPlugin
250
+ ? probeCodeAppsPlugin(plan.agents, runner, commandCwd)
251
+ : Promise.resolve([])
252
+ ]);
253
+ let probes = initialProbes;
254
+ presentation.table('Workstation readiness', ['Requirement', 'Level', 'State', 'Detail'], probes.map((probe) => [
181
255
  probe.requirement.definition.label,
182
256
  probe.requirement.severity,
183
257
  probe.state,
184
258
  probe.detail
185
- ])));
259
+ ]));
260
+ if (pluginProbes.length > 0) {
261
+ presentation.table('Optional Code Apps plugin', ['Agent', 'State', 'Detail'], pluginProbes.map((probe) => [probe.agent.label, probe.state, probe.detail]));
262
+ }
186
263
  const actionable = probes.filter((probe) => probe.state !== 'ready');
187
264
  const host = await detectHostEnvironment();
188
265
  const installInstruction = (probe) => {
@@ -213,17 +290,16 @@ async function ensureWorkstationReady(plan, options, context, runner, renderer,
213
290
  : probe.requirement.minimumVersion
214
291
  ? `required ${probe.requirement.minimumVersion} or newer`
215
292
  : 'required to be available';
216
- const action = automatic
217
- ? `Command: ${formatCommand(recipe.command)}`
218
- : `Manual remedy: ${installInstruction(probe)}`;
219
- const detail = [
220
- `${probe.requirement.definition.label} [${probe.requirement.severity}]`,
221
- `Purpose: ${probe.requirement.reasons.join('; ')}`,
222
- `Requirement: ${constraint}`,
223
- `Observed: ${probe.state} - ${probe.detail}`,
224
- action
225
- ].map((line) => ` ${line}`).join('\n');
226
- if (await confirmToolInstallation(detail)) {
293
+ if (await prompter.confirmToolInstallation({
294
+ label: probe.requirement.definition.label,
295
+ severity: probe.requirement.severity,
296
+ purpose: probe.requirement.reasons.join('; '),
297
+ requirement: constraint,
298
+ observed: `${probe.state} - ${probe.detail}`,
299
+ ...(automatic
300
+ ? { command: formatCommand(recipe.command) }
301
+ : { remedy: installInstruction(probe) })
302
+ })) {
227
303
  authorizedInstallations.add(probe.requirement.id);
228
304
  }
229
305
  }
@@ -234,12 +310,17 @@ async function ensureWorkstationReady(plan, options, context, runner, renderer,
234
310
  if (!authorizedInstallations.has(probe.requirement.id)) {
235
311
  continue;
236
312
  }
313
+ presentation.stage(`Install ${probe.requirement.definition.label}`, `${probe.state} - ${probe.detail}`);
314
+ const recipe = probe.requirement.definition.install[host.platform];
315
+ if (recipe && (host.platform !== 'linux' || recipe.manager === 'npm' || recipe.manager === 'uv')) {
316
+ presentation.command(formatCommand(recipe.command));
317
+ }
237
318
  const installation = await installRequirement(probe.requirement, probe, {
238
319
  authorized: true,
239
320
  host,
240
321
  runner,
241
322
  cwd: commandCwd,
242
- streamOptions: { stdout: context.stdout, stderr: context.stderr }
323
+ streamOptions: presentation.childStreams()
243
324
  });
244
325
  updates.set(probe.requirement.id, installation.probe);
245
326
  const kind = installation.state === 'installed'
@@ -247,9 +328,9 @@ async function ensureWorkstationReady(plan, options, context, runner, renderer,
247
328
  : probe.requirement.severity === 'blocking'
248
329
  ? 'error'
249
330
  : 'warning';
250
- renderer.write(renderer.status(kind, probe.requirement.definition.label, installation.detail));
331
+ presentation.status(kind, probe.requirement.definition.label, installation.detail);
251
332
  if (installation.remedy) {
252
- renderer.write(renderer.command(installation.remedy));
333
+ presentation.command(installation.remedy);
253
334
  }
254
335
  }
255
336
  probes = probes.map((probe) => updates.get(probe.requirement.id) ?? probe);
@@ -257,13 +338,12 @@ async function ensureWorkstationReady(plan, options, context, runner, renderer,
257
338
  const blockers = blockingReadinessFailures(probes);
258
339
  if (blockers.length > 0) {
259
340
  for (const blocker of blockers) {
260
- context.stderr.write(`${blocker.requirement.definition.label}: ${blocker.detail}` +
261
- `\nRemedy: ${installInstruction(blocker)}\n`);
341
+ presentation.error(`${blocker.requirement.definition.label}: ${blocker.detail}`, installInstruction(blocker));
262
342
  }
263
- context.stderr.write(options.installTools
264
- ? `Open a new terminal if PATH changed, then rerun \`${resumeInvocation}\` with the same project options.\n`
265
- : `Resume with \`${resumeInvocation} --install-tools\` plus the same project options after reviewing the commands.\n`);
266
- return { ready: false, deferred: [], probes };
343
+ presentation.error('Workstation readiness is incomplete.', options.installTools
344
+ ? `Open a new terminal if PATH changed, then rerun \`${resumeInvocation}\` with the same project options.`
345
+ : `Resume with \`${resumeInvocation} --install-tools\` plus the same project options after reviewing the commands.`);
346
+ return { ready: false, deferred: [], probes, pluginProbes };
267
347
  }
268
348
  const deferred = [
269
349
  ...probes
@@ -271,80 +351,104 @@ async function ensureWorkstationReady(plan, options, context, runner, renderer,
271
351
  .map((probe) => `${probe.requirement.definition.label}: ${probe.detail} Remedy: ${installInstruction(probe)}`),
272
352
  ...probes.flatMap((probe) => probe.notices
273
353
  .filter((notice) => notice.state !== 'ready')
274
- .map((notice) => `${notice.label}: ${notice.detail}${notice.remedy ? ` Remedy: ${notice.remedy}` : ''}`))
354
+ .map((notice) => `${notice.label}: ${notice.detail}${notice.remedy ? ` Remedy: ${notice.remedy}` : ''}`)),
355
+ ...pluginProbes
356
+ .filter((probe) => probe.state !== 'ready')
357
+ .map((probe) => `${probe.agent.label} Code Apps plugin: ${probe.detail}${probe.remedy ? ` Remedy: ${probe.remedy}` : ''}`)
275
358
  ];
276
- return { ready: true, deferred, probes };
359
+ return { ready: true, deferred, probes, pluginProbes };
277
360
  }
278
- async function handleProjectDependencies(plan, projectRoot, options, probes, context, runner, renderer) {
361
+ async function handleProjectDependencies(plan, projectRoot, options, probes, context, runner, presentation, prompter) {
279
362
  const dependencyPlan = buildDependencySetupPlan(plan, projectRoot, probes);
280
363
  let installDependencies = options.installDependencies === true;
281
364
  if (options.installDependencies === undefined &&
282
365
  options.yes !== true &&
283
366
  dependencyPlan.commands.length > 0) {
284
- installDependencies = await confirmDependencyInstallation(dependencyPlan.commands);
367
+ installDependencies = await prompter.confirmDependencyInstallation(dependencyPlan.commands);
285
368
  }
286
369
  if (!installDependencies) {
287
370
  return {
288
371
  success: true,
289
- deferred: dependencyPlan.commands.map((command) => `${command.label}: ${command.cwd} -> ${formatCommand(command.command)}`)
372
+ deferred: dependencyPlan.commands.map((command) => `${command.label}: ${dependencyResumeCommand(command)}`)
290
373
  };
291
374
  }
375
+ if (plan.workload === 'power-apps-code-app') {
376
+ const issues = await verifyPowerAppsPackageMetadata(projectRoot);
377
+ if (issues.length > 0) {
378
+ presentation.error('Power Apps dependency installation blocked', `${issues.join(' ')} Restore package.json and package-lock.json or run \`liftoff update --apply\`.`);
379
+ return { success: false, deferred: [] };
380
+ }
381
+ }
382
+ presentation.stage('Install project dependencies');
292
383
  const result = await runDependencySetup(dependencyPlan, projectRoot, runner, {
293
- stdout: context.stdout,
294
- stderr: context.stderr
384
+ ...presentation.childStreams(),
385
+ onCommand: (command) => {
386
+ presentation.status('pending', command.label, command.cwd);
387
+ presentation.command(formatCommand(command.command));
388
+ }
295
389
  });
296
390
  if (!result.success) {
297
- renderer.write(renderer.status('error', 'Project dependencies failed', `${result.failed?.label ?? 'dependency command'}: ${result.detail ?? 'unknown failure'}`));
298
- renderer.write(renderer.status('info', 'Scaffold preserved', projectRoot));
391
+ presentation.error('Project dependencies failed', `${result.failed?.label ?? 'dependency command'}: ${result.detail ?? 'unknown failure'}`);
392
+ presentation.status('info', 'Scaffold preserved', projectRoot);
299
393
  if (result.restoredMutations.length > 0) {
300
- renderer.write(renderer.warning(`Restored protected files: ${result.restoredMutations.join(', ')}`));
394
+ presentation.warning(`Restored protected files: ${result.restoredMutations.join(', ')}`);
301
395
  }
302
396
  if (result.resumeCommand) {
303
- renderer.write(renderer.command(result.resumeCommand));
397
+ presentation.command(result.resumeCommand);
304
398
  }
305
399
  return { success: false, deferred: [] };
306
400
  }
307
- renderer.write(renderer.status('success', 'Project dependencies', `${result.completed.length} command${result.completed.length === 1 ? '' : 's'} completed`));
401
+ presentation.status('success', 'Project dependencies', `${result.completed.length} command${result.completed.length === 1 ? '' : 's'} completed`);
308
402
  return { success: true, deferred: [] };
309
403
  }
310
404
  function patternsCommand(context) {
311
- context.stdout.write('Patterns:\n');
312
- for (const pattern of patterns) {
313
- context.stdout.write(`- ${pattern.id}: ${pattern.label} [${pattern.scaffoldStatus}]\n`);
314
- }
405
+ context.presentation.commandIdentity('patterns', 'Available GenAI application patterns');
406
+ context.presentation.table('Patterns', ['Identifier', 'Pattern', 'Scaffold'], patterns.map((pattern) => [pattern.id, pattern.label, pattern.scaffoldStatus]));
315
407
  return 0;
316
408
  }
317
409
  function providersCommand(context) {
318
- context.stdout.write('Providers:\n');
319
- for (const provider of providers) {
320
- context.stdout.write(`- ${provider.id}: ${provider.label} [${provider.status}]\n`);
321
- }
410
+ context.presentation.commandIdentity('providers', 'Cloud provider availability');
411
+ context.presentation.table('Providers', ['Identifier', 'Provider', 'Availability'], providers.map((provider) => [provider.id, provider.label, provider.status]));
322
412
  return 0;
323
413
  }
324
414
  function regionsCommand(parsed, context) {
325
415
  const cloud = readStringFlag(parsed.flags, 'cloud') ?? 'azure';
416
+ context.presentation.commandIdentity('regions', 'Cloud deployment regions');
326
417
  if (cloud !== 'azure') {
327
- context.stderr.write(`${cloud} regions are not available until the provider adapter is implemented.\n`);
418
+ context.presentation.error(`${cloud} regions are not available until the provider adapter is implemented.`, 'Run `liftoff providers` to review currently available providers.');
328
419
  return 1;
329
420
  }
330
421
  const query = parsed.positional[0] ?? readStringFlag(parsed.flags, 'region');
331
422
  const regions = parsed.subcommand === 'search' && query ? searchRegions('azure', query) : listRegions('azure');
332
- for (const region of regions) {
333
- context.stdout.write(`${region.slug}\t${region.displayName}\t${region.geography}\n`);
423
+ if (regions.length === 0) {
424
+ context.presentation.warning(`No Azure regions matched ${JSON.stringify(query ?? '')}.`);
425
+ return 0;
334
426
  }
427
+ context.presentation.table(query ? `Azure region matches for ${JSON.stringify(query)}` : 'Azure regions', ['Identifier', 'Region', 'Geography'], regions.map((region) => [region.slug, region.displayName, region.geography]));
335
428
  return 0;
336
429
  }
337
430
  async function validateCommand(parsed, context) {
431
+ context.presentation.commandIdentity('validate', 'Validate a generated Liftoff project');
432
+ const jsonMode = readBooleanFlag(parsed.flags, 'json') ?? false;
338
433
  const explicit = parsed.positional[0] ?? readStringFlag(parsed.flags, 'project');
339
434
  const projectRoot = explicit
340
435
  ? path.resolve(context.cwd, explicit)
341
436
  : (await findProjectRoot(context.cwd)) ?? context.cwd;
342
437
  const issues = await validateGeneratedProject(projectRoot);
438
+ if (jsonMode) {
439
+ context.presentation.rawStdout(`${JSON.stringify({
440
+ schemaVersion: 1,
441
+ projectRoot,
442
+ valid: issues.length === 0,
443
+ issues
444
+ }, null, 2)}\n`);
445
+ return issues.length === 0 ? 0 : 1;
446
+ }
343
447
  if (issues.length > 0) {
344
- context.stderr.write(`${issues.join('\n')}\n`);
448
+ context.presentation.error(issues.join('\n'), 'Restore invalid generated files or the manifest from version control, then rerun validation.');
345
449
  return 1;
346
450
  }
347
- context.stdout.write('Generated project manifest is valid.\n');
451
+ context.presentation.status('success', 'Generated project manifest is valid', projectRoot);
348
452
  return 0;
349
453
  }
350
454
  const STAGING_EXCLUDES = new Set(['.git', 'node_modules', 'vendor', '.venv', 'venv', '__pycache__', 'dist', 'build', '.next']);
@@ -454,13 +558,16 @@ function migrationPlanArtifacts(plan, inventory) {
454
558
  };
455
559
  }
456
560
  async function executeMigration(parsed, context, sourceRoot) {
561
+ const { presentation } = context;
562
+ presentation.stage('Scan legacy project', sourceRoot);
457
563
  const inventory = await scanLegacyProject(sourceRoot);
458
564
  const { options: defaults, provenance } = scanDefaults(inventory);
459
- context.stdout.write('Scan defaults (override in prompts or with flags):\n');
460
- for (const item of provenance) {
461
- context.stdout.write(` - ${item.field}: ${item.value} (detected: ${item.evidence})\n`);
565
+ if (presentation.stdout.layout === 'plain') {
566
+ presentation.section('Scan defaults (override in prompts or with flags)', provenance.map((item) => `${item.field}: ${String(item.value)} (detected: ${item.evidence})`));
567
+ }
568
+ else {
569
+ presentation.table('Scan defaults (override in prompts or with flags)', ['Decision', 'Detected value', 'Evidence'], provenance.map((item) => [item.field, String(item.value), item.evidence]));
462
570
  }
463
- context.stdout.write('\n');
464
571
  const flagOptions = await optionsFromParsedArgs(parsed, context.cwd, false);
465
572
  const initial = mergeOptions(defaults, flagOptions);
466
573
  if (flagOptions.pattern && flagOptions.projectType === undefined) {
@@ -479,97 +586,136 @@ async function executeMigration(parsed, context, sourceRoot) {
479
586
  if (flagOptions.projectType === 'genai' && flagOptions.apiStack === undefined) {
480
587
  initial.apiStack = undefined;
481
588
  }
482
- const needsPrompts = !initial.yes && hasMissingInitInputs(initial);
483
- const options = needsPrompts ? await promptForInitOptions(initial) : initial;
484
- const plan = buildProjectPlan(options, { requireProjectName: true });
485
- const confirmed = await confirmPlan(plan, options.yes);
486
- if (!confirmed) {
487
- context.stdout.write('Migration cancelled.\n');
488
- return 0;
489
- }
490
- const parentDir = path.dirname(sourceRoot);
491
- let targetRoot = path.resolve(parentDir, plan.safeProjectName);
492
- if (targetRoot === sourceRoot) {
493
- targetRoot = path.resolve(parentDir, `${plan.safeProjectName}-liftoff`);
494
- }
495
- const target = { root: targetRoot, mode: 'named-child' };
496
- await assertSafeInitTarget(target, parentDir);
497
- await assertNewOrEmptyDirectory(targetRoot);
498
589
  const runner = context.runner ?? new NodeCommandRunner();
499
- const renderer = new TerminalRenderer({ stream: context.stdout });
500
- renderer.write(renderer.banner('Migrate into a fresh Liftoff project'));
501
- const readinessRoot = await mkdtemp(path.join(os.tmpdir(), 'liftoff-migrate-readiness-'));
502
- const readiness = await (async () => {
503
- try {
504
- return await ensureWorkstationReady(plan, options, context, runner, renderer, `liftoff migrate ${JSON.stringify(sourceRoot)}`, readinessRoot);
590
+ const interactive = initial.yes !== true;
591
+ const prompter = interactive
592
+ ? new InteractivePrompter({
593
+ input: context.stdin,
594
+ output: context.stdout,
595
+ presentation,
596
+ cwd: context.cwd,
597
+ runner
598
+ })
599
+ : undefined;
600
+ try {
601
+ const needsPrompts = interactive && hasMissingInitInputs(initial);
602
+ if (needsPrompts) {
603
+ presentation.stage('Configure migrated project');
505
604
  }
506
- finally {
507
- await rm(readinessRoot, { recursive: true, force: true });
605
+ const options = needsPrompts ? await prompter.promptForInitOptions(initial) : initial;
606
+ const plan = buildProjectPlan(options, { requireProjectName: true });
607
+ if (plan.workload === 'power-apps-code-app') {
608
+ presentation.error('Power Apps code app migration is not supported.', 'Initialize a fresh Power Apps code app, then move application changes intentionally.');
609
+ return 1;
508
610
  }
509
- })();
510
- if (!readiness.ready) {
511
- return 1;
512
- }
513
- const migrationPlan = migrationPlanArtifacts(plan, inventory);
514
- await withStagingArea(async (area) => {
515
- const partition = partitionGeneratedArtifacts(buildArtifacts(plan));
516
- await writeStagedArtifacts(area, partition.durable, 'liftoff');
517
- await initializeFramework(area, plan, runner, {
518
- stdout: context.stdout,
519
- stderr: context.stderr
611
+ presentation.stage('Review migration plan');
612
+ const confirmed = options.yes === true
613
+ ? (presentation.definitions('Resolved migration plan', projectPlanEntries(plan)), true)
614
+ : await prompter.confirmPlan(plan, undefined, 'Migrate project?');
615
+ if (!confirmed) {
616
+ presentation.cancellation('Migration stopped; the source and destination were not modified.');
617
+ return 0;
618
+ }
619
+ presentation.stage('Resolve fresh migration target');
620
+ const parentDir = path.dirname(sourceRoot);
621
+ let targetRoot = path.resolve(parentDir, plan.safeProjectName);
622
+ if (targetRoot === sourceRoot) {
623
+ targetRoot = path.resolve(parentDir, `${plan.safeProjectName}-liftoff`);
624
+ }
625
+ const target = { root: targetRoot, mode: 'named-child' };
626
+ await assertSafeInitTarget(target, parentDir);
627
+ await assertNewOrEmptyDirectory(targetRoot);
628
+ presentation.stage('Check workstation readiness');
629
+ const readinessRoot = await mkdtemp(path.join(os.tmpdir(), 'liftoff-migrate-readiness-'));
630
+ const readiness = await (async () => {
631
+ try {
632
+ return await ensureWorkstationReady(plan, options, context, runner, presentation, prompter, `liftoff migrate ${JSON.stringify(sourceRoot)}`, readinessRoot);
633
+ }
634
+ finally {
635
+ await rm(readinessRoot, { recursive: true, force: true });
636
+ }
637
+ })();
638
+ if (!readiness.ready) {
639
+ return 1;
640
+ }
641
+ const migrationPlan = migrationPlanArtifacts(plan, inventory);
642
+ presentation.stage('Stage fresh migration project');
643
+ await withStagingArea(async (area) => {
644
+ const partition = partitionGeneratedArtifacts(buildArtifacts(plan));
645
+ await writeStagedArtifacts(area, partition.durable, 'liftoff');
646
+ presentation.stage('Initialize spec-driven framework', `${plan.specWorkflow.label} ${plan.framework.version}`);
647
+ await initializeFramework(area, plan, runner, {
648
+ ...presentation.childStreams(),
649
+ onCommand: (command) => presentation.command(command)
650
+ });
651
+ await writeStagedArtifacts(area, partition.seed, 'seed');
652
+ presentation.stage('Copy filtered legacy source', sourceRoot);
653
+ await stageMigrationSource(area, sourceRoot);
654
+ await writeStagedArtifacts(area, migrationPlan.artifacts, 'seed');
655
+ await writeStagedArtifacts(area, [partition.manifest], 'liftoff');
656
+ presentation.stage('Validate staged migration');
657
+ await validateStagedTree(area);
658
+ const stagedIssues = await validateGeneratedProject(area.root);
659
+ if (stagedIssues.length > 0) {
660
+ throw new Error(`Staged migration project validation failed:\n${stagedIssues.join('\n')}`);
661
+ }
662
+ const preflight = await buildMergePreflight(area, targetRoot);
663
+ const existing = preflight.entries.filter((entry) => entry.destination.type !== 'missing');
664
+ if (existing.length > 0) {
665
+ throw new Error(`Migration target must remain new or empty; --force cannot replace existing content:\n` +
666
+ existing.map((entry) => `- ${entry.relativePath}`).join('\n'));
667
+ }
668
+ const authorized = await authorizeMergePreflight(preflight, false);
669
+ if (!authorized) {
670
+ throw new Error('Migration target authorization failed.');
671
+ }
672
+ presentation.stage('Merge fresh migration target', targetRoot);
673
+ await applyMergePreflight(authorized, { requireEmptyTarget: true });
520
674
  });
521
- await writeStagedArtifacts(area, partition.seed, 'seed');
522
- await stageMigrationSource(area, sourceRoot);
523
- await writeStagedArtifacts(area, migrationPlan.artifacts, 'seed');
524
- await writeStagedArtifacts(area, [partition.manifest], 'liftoff');
525
- await validateStagedTree(area);
526
- const stagedIssues = await validateGeneratedProject(area.root);
527
- if (stagedIssues.length > 0) {
528
- throw new Error(`Staged migration project validation failed:\n${stagedIssues.join('\n')}`);
529
- }
530
- const preflight = await buildMergePreflight(area, targetRoot);
531
- const existing = preflight.entries.filter((entry) => entry.destination.type !== 'missing');
532
- if (existing.length > 0) {
533
- throw new Error(`Migration target must remain new or empty; --force cannot replace existing content:\n` +
534
- existing.map((entry) => `- ${entry.relativePath}`).join('\n'));
535
- }
536
- const authorized = await authorizeMergePreflight(preflight, false);
537
- if (!authorized) {
538
- throw new Error('Migration target authorization failed.');
539
- }
540
- await applyMergePreflight(authorized, { requireEmptyTarget: true });
541
- });
542
- const issues = await validateGeneratedProject(targetRoot);
543
- if (issues.length > 0) {
544
- context.stderr.write(`Migrated project validation failed:\n${issues.join('\n')}\n`);
545
- return 1;
546
- }
547
- const dependencyPhase = await handleProjectDependencies(plan, targetRoot, options, readiness.probes, context, runner, renderer);
548
- if (!dependencyPhase.success) {
549
- return 1;
550
- }
551
- renderer.write(renderer.status('success', `Migrated ${plan.projectName}`, targetRoot));
552
- renderer.write(renderer.panel('Configured integrations', [
553
- `${plan.specWorkflow.label} ${plan.framework.version}`,
554
- ...plan.agents.map((agent) => `${agent.label}${plan.defaultAgent?.id === agent.id ? ' (default)' : ''}`)
555
- ]));
556
- if (readiness.deferred.length > 0) {
557
- renderer.write(renderer.panel('Deferred advisory checks', readiness.deferred));
675
+ const issues = await validateGeneratedProject(targetRoot);
676
+ if (issues.length > 0) {
677
+ presentation.error(`Migrated project validation failed:\n${issues.join('\n')}`);
678
+ return 1;
679
+ }
680
+ const dependencyPhase = await handleProjectDependencies(plan, targetRoot, options, readiness.probes, context, runner, presentation, prompter);
681
+ if (!dependencyPhase.success) {
682
+ return 1;
683
+ }
684
+ presentation.bullets('Configured integrations', [
685
+ `${plan.specWorkflow.label} ${plan.framework.version}`,
686
+ ...plan.agents.map((agent) => `${agent.label}${plan.defaultAgent?.id === agent.id ? ' (default)' : ''}`)
687
+ ]);
688
+ if (readiness.deferred.length > 0) {
689
+ presentation.bullets('Deferred advisory checks', readiness.deferred);
690
+ }
691
+ if (dependencyPhase.deferred.length > 0) {
692
+ presentation.bullets('Deferred project dependencies', dependencyPhase.deferred);
693
+ }
694
+ presentation.bullets('Next steps', [
695
+ `Optional - preserve history: copy the .git directory from ${sourceRoot} into ${targetRoot}, then commit the migration on top (git rename detection preserves file history).`,
696
+ `Execute the migration plan: ${migrationPlan.location}`,
697
+ 'Verify compliance: liftoff validate && liftoff doctor'
698
+ ]);
699
+ presentation.completion(`Migrated ${plan.projectName}`, targetRoot, [
700
+ { label: 'Target', value: targetRoot },
701
+ { label: 'Source', value: `${sourceRoot} (not modified)` },
702
+ { label: 'Rollback', value: `Delete ${targetRoot}` }
703
+ ], 'liftoff validate && liftoff doctor');
704
+ return 0;
558
705
  }
559
- if (dependencyPhase.deferred.length > 0) {
560
- renderer.write(renderer.panel('Deferred project dependencies', dependencyPhase.deferred));
706
+ finally {
707
+ prompter?.close();
561
708
  }
562
- context.stdout.write('Next steps:\n');
563
- context.stdout.write(` 1. Optional - preserve history: copy the .git directory from ${sourceRoot} into ${targetRoot}, then commit the migration on top (git rename detection preserves file history).\n`);
564
- context.stdout.write(` 2. Execute the migration plan: ${migrationPlan.location}\n`);
565
- context.stdout.write(' 3. Verify compliance: liftoff validate && liftoff doctor\n');
566
- context.stdout.write(`The source project was not modified. Rolling back is deleting ${targetRoot}.\n`);
567
- return 0;
568
709
  }
569
710
  async function migrateCommand(parsed, context) {
570
711
  const sourceArg = parsed.positional[0];
571
712
  if (!sourceArg) {
572
- context.stderr.write('Usage: liftoff migrate <path-to-existing-project>\n');
713
+ context.presentation.error('Usage: liftoff migrate <path-to-existing-project>', 'Run `liftoff migrate --help` for accepted migration options.');
714
+ return 1;
715
+ }
716
+ const migrationOptions = await optionsFromParsedArgs(parsed, context.cwd, false);
717
+ if (migrationOptions.projectType === 'power-apps-code-app') {
718
+ context.presentation.error('Power Apps code app migration is not supported.', 'Initialize a fresh Power Apps code app with `liftoff init --type power-apps-code-app`, then move application changes intentionally.');
573
719
  return 1;
574
720
  }
575
721
  const sourceRoot = path.resolve(context.cwd, sourceArg);
@@ -578,17 +724,18 @@ async function migrateCommand(parsed, context) {
578
724
  sourceDetails = await stat(sourceRoot);
579
725
  }
580
726
  catch {
581
- context.stderr.write(`Source project not found: ${sourceRoot}\n`);
727
+ context.presentation.error(`Source project not found: ${sourceRoot}`);
582
728
  return 1;
583
729
  }
584
730
  if (!sourceDetails.isDirectory()) {
585
- context.stderr.write(`Source path is not a directory: ${sourceRoot}\n`);
731
+ context.presentation.error(`Source path is not a directory: ${sourceRoot}`);
586
732
  return 1;
587
733
  }
588
734
  if (existsSync(path.join(sourceRoot, 'liftoff.manifest.json'))) {
589
- context.stderr.write(`${sourceRoot} is already a Liftoff project. Use liftoff update instead.\n`);
735
+ context.presentation.error(`${sourceRoot} is already a Liftoff project.`, 'Use `liftoff update` instead.');
590
736
  return 1;
591
737
  }
738
+ context.presentation.identity('Migrate an existing application into a fresh Liftoff project');
592
739
  return withUnchangedMigrationSource(sourceRoot, () => executeMigration(parsed, context, sourceRoot));
593
740
  }
594
741
  function summarizeEntries(entries) {
@@ -659,47 +806,67 @@ async function preflightUpdate(projectRoot, entries, force) {
659
806
  await resolveProjectPath(projectRoot, ['liftoff.manifest.json']);
660
807
  }
661
808
  async function updateCommand(parsed, context) {
809
+ const { presentation } = context;
662
810
  const apply = readBooleanFlag(parsed.flags, 'apply') ?? false;
663
811
  const force = readBooleanFlag(parsed.flags, 'force') ?? false;
664
812
  const jsonMode = readBooleanFlag(parsed.flags, 'json') ?? false;
813
+ presentation.commandIdentity('update', 'Reconcile the project with current Liftoff templates');
665
814
  if (force && !apply) {
666
- context.stderr.write('--force requires --apply.\n');
815
+ presentation.error('--force requires --apply.', 'Run `liftoff update --apply --force` only after reviewing the reported conflicts.');
667
816
  return 1;
668
817
  }
669
818
  const explicit = parsed.positional[0] ?? readStringFlag(parsed.flags, 'project');
670
819
  const projectRoot = explicit ? path.resolve(context.cwd, explicit) : await findProjectRoot(context.cwd);
671
820
  if (!projectRoot) {
672
- context.stderr.write(`No liftoff.manifest.json found in ${context.cwd} or any parent directory.\n`);
821
+ 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.');
673
822
  return 1;
674
823
  }
675
824
  const manifest = await loadManifest(projectRoot);
676
825
  if (compareSemver(manifest.liftoffVersion, liftoffVersion) > 0) {
677
- context.stderr.write(`This project was written by Liftoff ${manifest.liftoffVersion}, which is newer than this CLI (${liftoffVersion}). Upgrade the CLI first.\n`);
826
+ presentation.error(`This project was written by Liftoff ${manifest.liftoffVersion}, which is newer than this CLI (${liftoffVersion}).`, 'Upgrade the CLI first.');
678
827
  return 1;
679
828
  }
680
829
  const config = await loadConfigOptions('liftoff.config.json', projectRoot);
681
830
  const plan = buildProjectPlan(config, { requireProjectName: true });
682
- if (plan.projectType.id !== manifest.project.projectType) {
683
- context.stderr.write(`Project type changes (${manifest.project.projectType} -> ${plan.projectType.id}) are a migration, not an update. Run liftoff migrate instead.\n`);
831
+ const recordedWorkload = manifest.project.workload;
832
+ if (plan.workload !== recordedWorkload.kind) {
833
+ const involvesPowerApps = plan.workload === 'power-apps-code-app' ||
834
+ recordedWorkload.kind === 'power-apps-code-app';
835
+ presentation.error(`Project type changes (${recordedWorkload.kind} -> ${plan.workload}) are not supported by update.`, involvesPowerApps
836
+ ? 'Initialize a fresh project for the new workload.'
837
+ : 'Run `liftoff migrate` instead.');
684
838
  return 1;
685
839
  }
686
- if (plan.apiStack.id !== manifest.project.apiStack) {
687
- context.stderr.write(`API stack changes (${manifest.project.apiStack} -> ${plan.apiStack.id}) are a migration, not an update. Run liftoff migrate instead.\n`);
688
- return 1;
840
+ if (plan.workload === 'power-apps-code-app' && recordedWorkload.kind === 'power-apps-code-app') {
841
+ const recordedStarter = recordedWorkload.starter;
842
+ if (plan.starter.repository !== recordedStarter.repository ||
843
+ plan.starter.path !== recordedStarter.path ||
844
+ plan.starter.commit !== recordedStarter.commit) {
845
+ presentation.error('The Power Apps starter repository, path, or commit differs from the recorded immutable source.', 'Restore the recorded source identity or initialize a fresh project.');
846
+ return 1;
847
+ }
689
848
  }
690
- if (plan.pattern?.id !== manifest.project.pattern) {
691
- context.stderr.write(`Pattern changes (${manifest.project.pattern ?? 'none'} -> ${plan.pattern?.id ?? 'none'}) are a migration, not an update. Run liftoff migrate instead.\n`);
692
- return 1;
849
+ else if (plan.workload !== 'power-apps-code-app' && recordedWorkload.kind !== 'power-apps-code-app') {
850
+ if (plan.apiStack.id !== recordedWorkload.apiStack) {
851
+ presentation.error(`API stack changes (${recordedWorkload.apiStack} -> ${plan.apiStack.id}) are a migration, not an update.`, 'Run `liftoff migrate` instead.');
852
+ return 1;
853
+ }
854
+ const recordedPattern = recordedWorkload.kind === 'genai' ? recordedWorkload.pattern : undefined;
855
+ const desiredPattern = plan.workload === 'genai' ? plan.pattern.id : undefined;
856
+ if (desiredPattern !== recordedPattern) {
857
+ presentation.error(`Pattern changes (${recordedPattern ?? 'none'} -> ${desiredPattern ?? 'none'}) are a migration, not an update.`, 'Run `liftoff migrate` instead.');
858
+ return 1;
859
+ }
693
860
  }
694
861
  if (plan.specWorkflow.id !== manifest.project.specWorkflow) {
695
- context.stderr.write(`Spec workflow changes (${manifest.project.specWorkflow} -> ${plan.specWorkflow.id}) require official framework initialization and are not supported by liftoff update.\n`);
862
+ presentation.error(`Spec workflow changes (${manifest.project.specWorkflow} -> ${plan.specWorkflow.id}) require official framework initialization and are not supported by liftoff update.`, 'Restore the workflow recorded in liftoff.manifest.json or migrate into a fresh project.');
696
863
  return 1;
697
864
  }
698
865
  const configuredAgents = plan.agents.map((agent) => agent.id);
699
866
  if (manifest.framework.state === 'initialized' && (configuredAgents.length !== manifest.project.agents.length ||
700
867
  configuredAgents.some((agent, index) => agent !== manifest.project.agents[index]) ||
701
868
  plan.defaultAgent?.id !== manifest.project.defaultAgent)) {
702
- context.stderr.write('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.\n');
869
+ 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.');
703
870
  return 1;
704
871
  }
705
872
  const render = buildArtifacts(plan);
@@ -709,7 +876,7 @@ async function updateCommand(parsed, context) {
709
876
  const visible = entries.filter((entry) => entry.status !== 'unchanged' || entry.refreshHash);
710
877
  if (!apply) {
711
878
  if (jsonMode) {
712
- context.stdout.write(`${JSON.stringify({
879
+ presentation.rawStdout(`${JSON.stringify({
713
880
  schemaVersion: 1,
714
881
  mode: 'check',
715
882
  cliVersion: liftoffVersion,
@@ -725,39 +892,55 @@ async function updateCommand(parsed, context) {
725
892
  }, null, 2)}\n`);
726
893
  return drift ? 2 : 0;
727
894
  }
728
- context.stdout.write(`Liftoff ${liftoffVersion} - project generated by ${manifest.liftoffVersion}\n\n`);
895
+ presentation.definitions('Project versions', [
896
+ { label: 'Liftoff CLI', value: liftoffVersion },
897
+ { label: 'Project generated by', value: manifest.liftoffVersion }
898
+ ]);
729
899
  if (!drift) {
730
- context.stdout.write(`No drift: ${summary.unchanged} artifacts match the current templates and configuration.\n`);
900
+ presentation.status('success', 'No drift', `${summary.unchanged} artifacts match the current templates and configuration`);
731
901
  return 0;
732
902
  }
733
- for (const entry of visible) {
734
- context.stdout.write(` ${entryMarker(entry)} ${entryDisplay(entry)} ${entry.reason}\n`);
903
+ if (presentation.stdout.layout === 'plain') {
904
+ presentation.section('Template drift', visible.map((entry) => `${entryMarker(entry)} ${entryDisplay(entry)} ${entry.reason}`));
905
+ }
906
+ else {
907
+ presentation.table('Template drift', ['Change', 'Artifact', 'Reason'], visible.map((entry) => [entryMarker(entry), entryDisplay(entry), entry.reason]));
735
908
  }
736
909
  const toWrite = summary.new + summary.missing + summary.upgrade + summary.moved + summary.refresh;
737
- context.stdout.write(`\n ${toWrite} to write, ${summary.conflict} conflict(s), ${summary.orphan} orphan(s), ${summary.unchanged} unchanged\n`);
738
- context.stdout.write(' Run `liftoff update --apply` to apply the safe changes.\n');
910
+ presentation.status('warning', 'Drift detected', `${toWrite} to write, ${summary.conflict} conflict(s), ${summary.orphan} orphan(s), ${summary.unchanged} unchanged`);
911
+ presentation.command('liftoff update --apply');
739
912
  return 2;
740
913
  }
741
914
  if (isDirtyGitWorktree(projectRoot)) {
742
- context.stdout.write('Hint: the project worktree has uncommitted changes - consider committing before applying.\n');
915
+ presentation.warning('The project worktree has uncommitted changes; consider committing before applying.');
743
916
  }
917
+ presentation.stage('Apply safe template changes', projectRoot);
744
918
  await preflightUpdate(projectRoot, entries, force);
745
919
  const written = [];
746
920
  const skipped = [];
921
+ const mutations = [];
747
922
  for (const entry of entries) {
748
923
  switch (entry.status) {
749
924
  case 'new':
750
925
  case 'missing':
751
926
  case 'upgrade':
752
- await writeProjectFile(projectRoot, entry.pathParts, entry.rendered.content);
927
+ mutations.push({
928
+ type: 'write',
929
+ pathParts: entry.pathParts,
930
+ content: entry.rendered.content
931
+ });
753
932
  written.push(entry);
754
933
  break;
755
934
  case 'moved':
756
935
  if (entry.cleanMove || force) {
757
936
  if (!entry.destinationMatches) {
758
- await writeProjectFile(projectRoot, entry.pathParts, entry.rendered.content);
937
+ mutations.push({
938
+ type: 'write',
939
+ pathParts: entry.pathParts,
940
+ content: entry.rendered.content
941
+ });
759
942
  }
760
- await deleteProjectFile(projectRoot, entry.previousPathParts);
943
+ mutations.push({ type: 'delete', pathParts: entry.previousPathParts });
761
944
  written.push(entry);
762
945
  }
763
946
  else {
@@ -766,9 +949,13 @@ async function updateCommand(parsed, context) {
766
949
  break;
767
950
  case 'conflict':
768
951
  if (force) {
769
- await writeProjectFile(projectRoot, entry.pathParts, entry.rendered.content);
952
+ mutations.push({
953
+ type: 'write',
954
+ pathParts: entry.pathParts,
955
+ content: entry.rendered.content
956
+ });
770
957
  if (entry.previousPathParts) {
771
- await deleteProjectFile(projectRoot, entry.previousPathParts);
958
+ mutations.push({ type: 'delete', pathParts: entry.previousPathParts });
772
959
  }
773
960
  written.push(entry);
774
961
  }
@@ -811,9 +998,14 @@ async function updateCommand(parsed, context) {
811
998
  nextManifest.artifacts.push(oldByName.get(entry.logicalName));
812
999
  }
813
1000
  }
814
- await writeProjectFile(projectRoot, ['liftoff.manifest.json'], `${JSON.stringify(nextManifest, null, 2)}\n`);
1001
+ mutations.push({
1002
+ type: 'write',
1003
+ pathParts: ['liftoff.manifest.json'],
1004
+ content: `${JSON.stringify(nextManifest, null, 2)}\n`
1005
+ });
1006
+ await applyProjectFileTransaction(projectRoot, mutations);
815
1007
  if (jsonMode) {
816
- context.stdout.write(`${JSON.stringify({
1008
+ presentation.rawStdout(`${JSON.stringify({
817
1009
  schemaVersion: 1,
818
1010
  mode: 'apply',
819
1011
  cliVersion: liftoffVersion,
@@ -824,18 +1016,17 @@ async function updateCommand(parsed, context) {
824
1016
  }, null, 2)}\n`);
825
1017
  return 0;
826
1018
  }
827
- for (const entry of written) {
828
- context.stdout.write(` wrote ${entryDisplay(entry)}\n`);
1019
+ if (written.length > 0) {
1020
+ presentation.bullets('Applied changes', written.map((entry) => `wrote ${entryDisplay(entry)}`));
829
1021
  }
830
- for (const entry of skipped) {
831
- context.stdout.write(` skipped ${entryDisplay(entry)} ${entry.reason}${force ? '' : ' (use --apply --force to overwrite)'}\n`);
1022
+ if (skipped.length > 0) {
1023
+ presentation.bullets('Skipped conflicts', skipped.map((entry) => `skipped ${entryDisplay(entry)} ${entry.reason}${force ? '' : ' (use --apply --force to overwrite)'}`));
832
1024
  }
833
- for (const entry of entries) {
834
- if (entry.status === 'orphan') {
835
- context.stdout.write(` orphan ${entryDisplay(entry)} ${entry.reason}\n`);
836
- }
1025
+ const orphans = entries.filter((entry) => entry.status === 'orphan');
1026
+ if (orphans.length > 0) {
1027
+ presentation.bullets('Orphaned artifacts', orphans.map((entry) => `orphan ${entryDisplay(entry)} ${entry.reason}`));
837
1028
  }
838
- context.stdout.write(`Updated: ${written.length} written, ${skipped.length} skipped, ${summary.orphan} orphan(s). Manifest recorded at ${liftoffVersion}.\n`);
1029
+ presentation.completion('Updated project', `${written.length} written, ${skipped.length} skipped, ${summary.orphan} orphan(s)`, [{ label: 'Manifest version', value: liftoffVersion }], 'liftoff validate && liftoff doctor');
839
1030
  return 0;
840
1031
  }
841
1032
  function versionedBinaryCheck(label, command, args, minimum, remedy) {
@@ -904,11 +1095,17 @@ function workstationLayer(probes) {
904
1095
  }
905
1096
  function workstationSelectionFromManifest(manifest) {
906
1097
  const framework = getFrameworkDefinition(manifest.project.specWorkflow);
1098
+ const workload = manifest.project.workload;
907
1099
  return {
908
- apiStack: { id: manifest.project.apiStack },
1100
+ workload: workload.kind === 'power-apps-code-app'
1101
+ ? { kind: 'power-apps-code-app' }
1102
+ : {
1103
+ kind: workload.kind,
1104
+ apiStack: { id: workload.apiStack },
1105
+ provider: { id: workload.cloud }
1106
+ },
909
1107
  specWorkflow: { id: manifest.project.specWorkflow },
910
1108
  framework: { version: framework.version },
911
- provider: { id: manifest.project.cloud },
912
1109
  agents: manifest.framework.state === 'initialized'
913
1110
  ? manifest.project.agents.map((id) => {
914
1111
  const agent = getCodingAgent(id);
@@ -1014,6 +1211,26 @@ function stackProjectCheck(projectRoot, apiStack) {
1014
1211
  remedy: `repair the generated ${apiStack} backend configuration`
1015
1212
  };
1016
1213
  }
1214
+ async function powerAppsProjectCheck(projectRoot) {
1215
+ const issues = await verifyPowerAppsPackageMetadata(projectRoot);
1216
+ if (issues.length === 0) {
1217
+ return {
1218
+ id: 'power-apps-project',
1219
+ label: 'Power Apps project',
1220
+ severity: 'ok',
1221
+ state: 'ready',
1222
+ detail: 'SDK, Vite plugin, and deterministic lockfile metadata are valid'
1223
+ };
1224
+ }
1225
+ return {
1226
+ id: 'power-apps-project',
1227
+ label: 'Power Apps project',
1228
+ severity: 'fail',
1229
+ state: 'unhealthy',
1230
+ detail: issues[0],
1231
+ remedy: 'restore package.json and package-lock.json or run liftoff update --apply'
1232
+ };
1233
+ }
1017
1234
  function runReadOnly(command, args, cwd) {
1018
1235
  const result = spawnSync(command, args, { cwd, encoding: 'utf8' });
1019
1236
  return { status: result.status, stdout: result.stdout, stderr: result.stderr };
@@ -1022,11 +1239,11 @@ function binaryPresent(command) {
1022
1239
  const probe = process.platform === 'win32' ? 'where' : 'which';
1023
1240
  return spawnSync(probe, [command], { encoding: 'utf8' }).status === 0;
1024
1241
  }
1025
- function azureCloudChecks() {
1026
- if (!binaryPresent('az')) {
1242
+ async function azureCloudChecks(runner) {
1243
+ const auth = await runner.run({ executable: 'az', args: ['account', 'show', '-o', 'none', '--only-show-errors'] }, { timeoutMs: 15_000 });
1244
+ if (auth.errorCode === 'ENOENT') {
1027
1245
  return [{ label: 'az', severity: 'warn', detail: 'Azure CLI not found', remedy: 'install the Azure CLI' }];
1028
1246
  }
1029
- const auth = spawnSync('az', ['account', 'show', '-o', 'none', '--only-show-errors'], { encoding: 'utf8' });
1030
1247
  if (auth.status === 0) {
1031
1248
  return [{ label: 'azure auth', severity: 'ok', detail: 'authenticated' }];
1032
1249
  }
@@ -1036,9 +1253,9 @@ function azureCloudChecks() {
1036
1253
  const CLOUD_CHECKS = {
1037
1254
  azure: azureCloudChecks
1038
1255
  };
1039
- function cloudLayer(cloud) {
1256
+ async function cloudLayer(cloud, runner) {
1040
1257
  const checks = CLOUD_CHECKS[cloud]
1041
- ? CLOUD_CHECKS[cloud]()
1258
+ ? await CLOUD_CHECKS[cloud](runner)
1042
1259
  : [{ label: cloud, severity: 'skipped', detail: `${cloud} provider checks are not available yet` }];
1043
1260
  return { title: `Cloud - ${cloud}`, checks };
1044
1261
  }
@@ -1112,7 +1329,20 @@ async function projectLayer(projectRoot, manifest) {
1112
1329
  checks.push({ label: 'version', severity: 'ok', detail: `generated by ${manifest.liftoffVersion}, CLI ${liftoffVersion}` });
1113
1330
  }
1114
1331
  checks.push(...await frameworkDoctorChecks(projectRoot, manifest));
1115
- checks.push(stackProjectCheck(projectRoot, manifest.project.apiStack));
1332
+ if (manifest.project.workload.kind === 'power-apps-code-app') {
1333
+ const starter = manifest.project.workload.starter;
1334
+ checks.push({
1335
+ id: 'power-apps-starter',
1336
+ label: 'Power Apps starter',
1337
+ severity: 'ok',
1338
+ state: 'ready',
1339
+ detail: `${starter.repository}/${starter.path} @ ${starter.commit}`
1340
+ });
1341
+ checks.push(await powerAppsProjectCheck(projectRoot));
1342
+ }
1343
+ else {
1344
+ checks.push(stackProjectCheck(projectRoot, manifest.project.workload.apiStack));
1345
+ }
1116
1346
  try {
1117
1347
  const config = await loadConfigOptions('liftoff.config.json', projectRoot);
1118
1348
  const plan = buildProjectPlan(config, { requireProjectName: true });
@@ -1141,8 +1371,40 @@ async function projectLayer(projectRoot, manifest) {
1141
1371
  }
1142
1372
  return { title: 'Project', checks };
1143
1373
  }
1144
- async function runtimeLayer(projectRoot, dockerAvailable) {
1374
+ async function runtimeLayer(projectRoot, dockerAvailable, runner, manifest) {
1145
1375
  const checks = [];
1376
+ if (manifest.project.workload.kind === 'power-apps-code-app') {
1377
+ if (!existsSync(path.join(projectRoot, 'node_modules'))) {
1378
+ checks.push({
1379
+ id: 'power-apps-cli',
1380
+ label: 'Power Apps CLI',
1381
+ severity: 'skipped',
1382
+ state: 'not-observable',
1383
+ detail: 'project dependencies are not installed',
1384
+ remedy: 'run npm ci, then rerun liftoff doctor'
1385
+ });
1386
+ return { title: 'Runtime', checks };
1387
+ }
1388
+ const executable = process.platform === 'win32' ? 'npx.cmd' : 'npx';
1389
+ const result = await runner.run({ executable, args: ['--no-install', 'power-apps', '--version'] }, { cwd: projectRoot, timeoutMs: 15_000 });
1390
+ checks.push(result.status === 0
1391
+ ? {
1392
+ id: 'power-apps-cli',
1393
+ label: 'Power Apps CLI',
1394
+ severity: 'ok',
1395
+ state: 'ready',
1396
+ detail: result.stdout.trim() || 'local CLI is available'
1397
+ }
1398
+ : {
1399
+ id: 'power-apps-cli',
1400
+ label: 'Power Apps CLI',
1401
+ severity: 'fail',
1402
+ state: 'unhealthy',
1403
+ detail: (result.stderr || 'local CLI probe failed').trim().split('\n')[0],
1404
+ remedy: 'run npm ci and verify @microsoft/power-apps before binding the environment'
1405
+ });
1406
+ return { title: 'Runtime', checks };
1407
+ }
1146
1408
  if (existsSync(path.join(projectRoot, '.env.example'))) {
1147
1409
  if (existsSync(path.join(projectRoot, '.env'))) {
1148
1410
  checks.push({ label: '.env', severity: 'ok', detail: 'present' });
@@ -1161,7 +1423,7 @@ async function runtimeLayer(projectRoot, dockerAvailable) {
1161
1423
  checks.push({ label: 'compose', severity: 'skipped', detail: 'docker is not installed, compose config not checked' });
1162
1424
  }
1163
1425
  else {
1164
- const result = spawnSync('docker', ['compose', 'config', '-q'], { cwd: projectRoot, encoding: 'utf8' });
1426
+ const result = await runner.run({ executable: 'docker', args: ['compose', 'config', '-q'] }, { cwd: projectRoot, timeoutMs: 15_000 });
1165
1427
  if (result.status === 0) {
1166
1428
  checks.push({ label: 'compose', severity: 'ok', detail: 'docker compose config is valid' });
1167
1429
  }
@@ -1176,19 +1438,43 @@ async function runtimeLayer(projectRoot, dockerAvailable) {
1176
1438
  }
1177
1439
  return { title: 'Runtime', checks };
1178
1440
  }
1179
- const severityMarker = {
1180
- ok: '[ok]',
1181
- warn: '[warn]',
1182
- fail: '[fail]',
1183
- skipped: '[skip]'
1184
- };
1185
- function renderDoctorLayers(layers, stream) {
1441
+ async function codeAppsPluginLayer(projectRoot, manifest, runner) {
1442
+ const agents = manifest.project.agents.map((id) => {
1443
+ const agent = getCodingAgent(id);
1444
+ if (!agent) {
1445
+ throw new Error(`Manifest references unknown coding agent ${id}.`);
1446
+ }
1447
+ return agent;
1448
+ });
1449
+ const probes = await probeCodeAppsPlugin(agents, runner, projectRoot);
1450
+ return {
1451
+ title: 'Optional Code Apps plugin',
1452
+ checks: probes.map((probe) => ({
1453
+ id: probe.id,
1454
+ label: probe.agent.label,
1455
+ severity: probe.state === 'ready' ? 'ok' : 'warn',
1456
+ state: probe.state,
1457
+ requirementSeverity: 'advisory',
1458
+ detail: probe.detail,
1459
+ ...(probe.remedy ? { remedy: probe.remedy } : {})
1460
+ }))
1461
+ };
1462
+ }
1463
+ function renderDoctorLayers(layers, presentation) {
1464
+ const statusKind = {
1465
+ ok: 'success',
1466
+ warn: 'warning',
1467
+ fail: 'error',
1468
+ skipped: 'pending'
1469
+ };
1186
1470
  for (const layer of layers) {
1187
- stream.write(`${layer.title}\n`);
1188
- for (const check of layer.checks) {
1471
+ presentation.section(layer.title, layer.checks.flatMap((check) => {
1189
1472
  const remedy = check.remedy ? ` - ${check.remedy}` : '';
1190
- stream.write(` ${severityMarker[check.severity].padEnd(6)} ${check.label}: ${check.detail}${remedy}\n`);
1191
- }
1473
+ return presentation.stdout
1474
+ .status(statusKind[check.severity], check.label, `${check.detail}${remedy}`)
1475
+ .trimEnd()
1476
+ .split('\n');
1477
+ }));
1192
1478
  }
1193
1479
  }
1194
1480
  export function doctorExitCode(layers) {
@@ -1198,6 +1484,7 @@ async function doctorCommand(parsed, context) {
1198
1484
  const jsonMode = readBooleanFlag(parsed.flags, 'json') ?? false;
1199
1485
  const cloudOverride = readStringFlag(parsed.flags, 'cloud');
1200
1486
  const layers = [];
1487
+ context.presentation.commandIdentity('doctor', 'Inspect CLI, workstation, project, runtime, and cloud readiness');
1201
1488
  const projectRoot = await findProjectRoot(context.cwd);
1202
1489
  let manifest;
1203
1490
  let manifestError;
@@ -1227,35 +1514,64 @@ async function doctorCommand(parsed, context) {
1227
1514
  }
1228
1515
  if (manifest) {
1229
1516
  layers.push(await projectLayer(projectRoot, manifest));
1230
- layers.push(await runtimeLayer(projectRoot, dockerAvailable));
1231
- const cloud = cloudOverride ?? manifest.project.cloud;
1232
- const cloudChecks = cloudLayer(cloud);
1233
- const pattern = patterns.find((candidate) => candidate.id === manifest.project.pattern);
1234
- if (pattern?.worker && cloud === 'azure') {
1235
- cloudChecks.checks.push(binaryPresent('func')
1236
- ? { label: 'functions tooling', severity: 'ok', detail: 'Azure Functions Core Tools installed' }
1237
- : { label: 'functions tooling', severity: 'warn', detail: 'Azure Functions Core Tools not found', remedy: 'npm install -g azure-functions-core-tools@4' });
1517
+ layers.push(await runtimeLayer(projectRoot, dockerAvailable, runner, manifest));
1518
+ if (manifest.project.workload.kind === 'power-apps-code-app') {
1519
+ if (manifest.project.workload.codeAppsPlugin) {
1520
+ layers.push(await codeAppsPluginLayer(projectRoot, manifest, runner));
1521
+ }
1522
+ }
1523
+ else {
1524
+ const workload = manifest.project.workload;
1525
+ const cloud = cloudOverride ?? workload.cloud;
1526
+ const cloudChecks = await cloudLayer(cloud, runner);
1527
+ const pattern = workload.kind === 'genai'
1528
+ ? patterns.find((candidate) => candidate.id === workload.pattern)
1529
+ : undefined;
1530
+ if (pattern?.worker && cloud === 'azure') {
1531
+ cloudChecks.checks.push(binaryPresent('func')
1532
+ ? { label: 'functions tooling', severity: 'ok', detail: 'Azure Functions Core Tools installed' }
1533
+ : { label: 'functions tooling', severity: 'warn', detail: 'Azure Functions Core Tools not found', remedy: 'npm install -g azure-functions-core-tools@4' });
1534
+ }
1535
+ layers.push(cloudChecks);
1238
1536
  }
1239
- layers.push(cloudChecks);
1240
1537
  }
1241
1538
  }
1242
1539
  else if (cloudOverride) {
1243
- layers.push(cloudLayer(cloudOverride));
1540
+ layers.push(await cloudLayer(cloudOverride, runner));
1244
1541
  }
1245
1542
  const failures = layers.reduce((count, layer) => count + layer.checks.filter((check) => check.severity === 'fail').length, 0);
1246
1543
  const warnings = layers.reduce((count, layer) => count + layer.checks.filter((check) => check.severity === 'warn').length, 0);
1247
1544
  if (jsonMode) {
1248
- context.stdout.write(`${JSON.stringify({ schemaVersion: 1, layers, summary: { failures, warnings } }, null, 2)}\n`);
1545
+ context.presentation.rawStdout(`${JSON.stringify({ schemaVersion: 1, layers, summary: { failures, warnings } }, null, 2)}\n`);
1249
1546
  }
1250
1547
  else {
1251
- renderDoctorLayers(layers, context.stdout);
1252
- context.stdout.write(`${failures} failure(s), ${warnings} warning(s)\n`);
1548
+ renderDoctorLayers(layers, context.presentation);
1549
+ context.presentation.status(failures > 0 ? 'error' : warnings > 0 ? 'warning' : 'success', 'Doctor summary', `${failures} failure(s), ${warnings} warning(s)`);
1253
1550
  }
1254
1551
  return doctorExitCode(layers);
1255
1552
  }
1256
- function helperCommand(parsed, context, tool) {
1553
+ async function helperCommand(parsed, context, tool) {
1554
+ const projectRoot = await findProjectRoot(context.cwd);
1555
+ if (projectRoot) {
1556
+ const manifest = await loadManifest(projectRoot);
1557
+ if (manifest.project.workload.kind === 'power-apps-code-app') {
1558
+ if (parsed.command === 'dev') {
1559
+ context.presentation.commandIdentity('dev', 'Power Apps local development');
1560
+ context.presentation.section('Dependency prerequisite', ['Install the root lockfile before starting or after dependency changes.']);
1561
+ context.presentation.command('npm ci');
1562
+ context.presentation.section('Development server', []);
1563
+ context.presentation.command('npm run dev');
1564
+ return 0;
1565
+ }
1566
+ context.presentation.commandIdentity('infra', 'Power Apps hosting responsibility');
1567
+ context.presentation.status('info', 'Not applicable', 'Power Apps code apps are hosted by Power Platform; Liftoff-managed OpenTofu infrastructure is not generated.');
1568
+ return 0;
1569
+ }
1570
+ }
1257
1571
  const command = parsed.command === 'dev' ? buildDevCommand(parsed) : buildInfraCommand(parsed);
1258
- context.stdout.write(`${tool} helper command:\n${command}\n`);
1572
+ context.presentation.commandIdentity(parsed.command ?? tool, `${tool} helper command`);
1573
+ context.presentation.section(`${tool} helper command`, []);
1574
+ context.presentation.command(command);
1259
1575
  return 0;
1260
1576
  }
1261
1577
  function buildDevCommand(parsed) {
@@ -1292,9 +1608,8 @@ async function optionsFromParsedArgs(parsed, cwd, includeProjectName) {
1292
1608
  const configOptions = configPath ? await loadConfigOptions(configPath, cwd) : {};
1293
1609
  const flagOptions = {
1294
1610
  projectName: includeProjectName ? parsed.positional[0] ?? readStringFlag(parsed.flags, 'project') : readStringFlag(parsed.flags, 'project'),
1295
- projectType: readBooleanFlag(parsed.flags, 'genai') === undefined
1296
- ? undefined
1297
- : readBooleanFlag(parsed.flags, 'genai') ? 'genai' : 'standard',
1611
+ projectType: readStringFlag(parsed.flags, 'type'),
1612
+ genai: readBooleanFlag(parsed.flags, 'genai'),
1298
1613
  apiStack: readStringFlag(parsed.flags, 'api'),
1299
1614
  pattern: readStringFlag(parsed.flags, 'pattern'),
1300
1615
  cloud: readStringFlag(parsed.flags, 'cloud'),
@@ -1304,6 +1619,7 @@ async function optionsFromParsedArgs(parsed, cwd, includeProjectName) {
1304
1619
  specWorkflow: readStringFlag(parsed.flags, 'spec'),
1305
1620
  agents: readListFlag(parsed.flags, 'agents'),
1306
1621
  defaultAgent: readStringFlag(parsed.flags, 'default-agent'),
1622
+ codeAppsPlugin: readBooleanFlag(parsed.flags, 'code-apps-plugin'),
1307
1623
  configPath,
1308
1624
  yes: readBooleanFlag(parsed.flags, 'yes') ?? false,
1309
1625
  force: readBooleanFlag(parsed.flags, 'force'),
@@ -1313,24 +1629,56 @@ async function optionsFromParsedArgs(parsed, cwd, includeProjectName) {
1313
1629
  return mergeOptions(configOptions, flagOptions);
1314
1630
  }
1315
1631
  function hasMissingInitInputs(options) {
1316
- const projectType = options.projectType ?? (options.pattern ? 'genai' : options.apiStack ? 'standard' : undefined);
1317
- const missingTypeSpecific = projectType === 'genai' ? !options.pattern : projectType === 'standard' ? !options.apiStack : true;
1632
+ const projectType = options.projectType ??
1633
+ (options.genai === undefined ? undefined : options.genai ? 'genai' : 'standard') ??
1634
+ (options.pattern ? 'genai' : options.apiStack ? 'standard' : undefined);
1635
+ const missingTypeSpecific = projectType === 'genai'
1636
+ ? !options.pattern
1637
+ : projectType === 'standard'
1638
+ ? !options.apiStack
1639
+ : projectType === 'power-apps-code-app'
1640
+ ? options.codeAppsPlugin === undefined
1641
+ : true;
1318
1642
  const missingDefaultAgent = options.specWorkflow === 'spec-kit' &&
1319
1643
  (options.agents?.length ?? 0) > 1 &&
1320
1644
  !options.defaultAgent;
1321
1645
  return !options.projectName ||
1322
1646
  missingTypeSpecific ||
1323
- !options.cloud ||
1324
- options.includeFrontend === undefined ||
1647
+ projectType !== 'power-apps-code-app' && !options.cloud ||
1648
+ projectType !== 'power-apps-code-app' && options.includeFrontend === undefined ||
1325
1649
  !options.specWorkflow ||
1326
1650
  !options.agents ||
1327
1651
  missingDefaultAgent ||
1328
- !options.environments;
1652
+ projectType !== 'power-apps-code-app' && !options.environments;
1329
1653
  }
1330
- function printHelp(stream) {
1331
- const renderer = new TerminalRenderer({ stream });
1332
- renderer.write(renderer.banner('Project workstation and scaffold initializer'));
1333
- stream.write(formatGeneralHelp(liftoffVersion));
1654
+ function renderGeneralHelp(presentation) {
1655
+ const help = getGeneralHelp(liftoffVersion);
1656
+ presentation.identity(`${help.title} - ${help.subtitle}`);
1657
+ presentation.section('Usage', [help.usage]);
1658
+ presentation.table('Global options', ['Option', 'Description'], help.globalOptions.map((option) => [option.syntax, option.description]));
1659
+ for (const group of help.commandGroups) {
1660
+ presentation.table(group.title, ['Command', 'Description'], group.entries.map((entry) => [entry.syntax, entry.description]));
1661
+ }
1662
+ presentation.status('info', 'Tip', help.hint);
1663
+ }
1664
+ function renderCommandHelp(command, presentation) {
1665
+ const help = getCommandHelp(command);
1666
+ presentation.commandIdentity(help.command, help.description);
1667
+ presentation.section('Usage', [
1668
+ presentation.stdout.layout === 'plain' ? `Usage: ${help.usage}` : help.usage
1669
+ ]);
1670
+ if (help.arguments.length > 0) {
1671
+ presentation.table('Arguments', ['Argument', 'Description'], help.arguments.map((argument) => [argument.syntax, argument.description]));
1672
+ }
1673
+ if (help.subcommands.length > 0) {
1674
+ presentation.bullets('Subcommands', help.subcommands);
1675
+ }
1676
+ for (const group of help.optionGroups) {
1677
+ presentation.table(group.title, ['Option', 'Description'], group.entries.map((entry) => [
1678
+ entry.syntax,
1679
+ `${entry.description}${entry.defaultValue ? ` (default: ${entry.defaultValue})` : ''}`
1680
+ ]));
1681
+ }
1334
1682
  }
1335
1683
  export async function createFixtureProject(options) {
1336
1684
  const tempRoot = await mkdtemp(path.join(os.tmpdir(), 'liftoff-'));