@hs-x/cli 0.1.3 → 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (145) hide show
  1. package/README.md +97 -986
  2. package/dist/account-store.d.ts +45 -22
  3. package/dist/account-store.d.ts.map +1 -1
  4. package/dist/account-store.js +73 -16
  5. package/dist/account-store.js.map +1 -1
  6. package/dist/cli/index.d.ts.map +1 -1
  7. package/dist/cli/index.js +177 -16
  8. package/dist/cli/index.js.map +1 -1
  9. package/dist/cli/kit.d.ts +1 -1
  10. package/dist/cli/kit.d.ts.map +1 -1
  11. package/dist/cli/kit.js.map +1 -1
  12. package/dist/commands/account.d.ts +6 -6
  13. package/dist/commands/account.d.ts.map +1 -1
  14. package/dist/commands/account.js +25 -23
  15. package/dist/commands/account.js.map +1 -1
  16. package/dist/commands/api.d.ts +1 -1
  17. package/dist/commands/completion.d.ts.map +1 -1
  18. package/dist/commands/completion.js +6 -4
  19. package/dist/commands/completion.js.map +1 -1
  20. package/dist/commands/connect.d.ts +16 -0
  21. package/dist/commands/connect.d.ts.map +1 -1
  22. package/dist/commands/connect.js +143 -17
  23. package/dist/commands/connect.js.map +1 -1
  24. package/dist/commands/control-plane-read.d.ts +3 -3
  25. package/dist/commands/control-plane-read.d.ts.map +1 -1
  26. package/dist/commands/control-plane-read.js +21 -33
  27. package/dist/commands/control-plane-read.js.map +1 -1
  28. package/dist/commands/deploy-promote.d.ts +1 -1
  29. package/dist/commands/deploy-promote.d.ts.map +1 -1
  30. package/dist/commands/deploy-promote.js +3 -3
  31. package/dist/commands/deploy-promote.js.map +1 -1
  32. package/dist/commands/deploy.d.ts.map +1 -1
  33. package/dist/commands/deploy.js +114 -7
  34. package/dist/commands/deploy.js.map +1 -1
  35. package/dist/commands/dev.d.ts +5 -0
  36. package/dist/commands/dev.d.ts.map +1 -1
  37. package/dist/commands/dev.js +233 -2
  38. package/dist/commands/dev.js.map +1 -1
  39. package/dist/commands/doctor.d.ts +1 -1
  40. package/dist/commands/doctor.js +2 -2
  41. package/dist/commands/doctor.js.map +1 -1
  42. package/dist/commands/flags.d.ts +1 -1
  43. package/dist/commands/flags.d.ts.map +1 -1
  44. package/dist/commands/flags.js +59 -35
  45. package/dist/commands/flags.js.map +1 -1
  46. package/dist/commands/help-command.d.ts +15 -1
  47. package/dist/commands/help-command.d.ts.map +1 -1
  48. package/dist/commands/help-command.js +316 -31
  49. package/dist/commands/help-command.js.map +1 -1
  50. package/dist/commands/init.d.ts +11 -8
  51. package/dist/commands/init.d.ts.map +1 -1
  52. package/dist/commands/init.js +435 -246
  53. package/dist/commands/init.js.map +1 -1
  54. package/dist/commands/link.d.ts +1 -26
  55. package/dist/commands/link.d.ts.map +1 -1
  56. package/dist/commands/link.js +11 -236
  57. package/dist/commands/link.js.map +1 -1
  58. package/dist/commands/login.d.ts +4 -12
  59. package/dist/commands/login.d.ts.map +1 -1
  60. package/dist/commands/login.js +196 -390
  61. package/dist/commands/login.js.map +1 -1
  62. package/dist/commands/migrate.d.ts +12 -7
  63. package/dist/commands/migrate.d.ts.map +1 -1
  64. package/dist/commands/migrate.js +311 -205
  65. package/dist/commands/migrate.js.map +1 -1
  66. package/dist/commands/rollback.d.ts +1 -20
  67. package/dist/commands/rollback.d.ts.map +1 -1
  68. package/dist/commands/rollback.js +6 -275
  69. package/dist/commands/rollback.js.map +1 -1
  70. package/dist/commands/status.d.ts +1 -1
  71. package/dist/commands/status.js +1 -1
  72. package/dist/commands/status.js.map +1 -1
  73. package/dist/commands/unlink.d.ts +1 -21
  74. package/dist/commands/unlink.d.ts.map +1 -1
  75. package/dist/commands/unlink.js +12 -94
  76. package/dist/commands/unlink.js.map +1 -1
  77. package/dist/commands/update.js +7 -7
  78. package/dist/commands/update.js.map +1 -1
  79. package/dist/commands/validate.d.ts +2 -2
  80. package/dist/commands/validate.d.ts.map +1 -1
  81. package/dist/commands/validate.js +1 -1
  82. package/dist/commands/validate.js.map +1 -1
  83. package/dist/config.d.ts +1 -0
  84. package/dist/config.d.ts.map +1 -1
  85. package/dist/config.js +7 -0
  86. package/dist/config.js.map +1 -1
  87. package/dist/constants.d.ts +1 -1
  88. package/dist/constants.d.ts.map +1 -1
  89. package/dist/constants.js +5 -4
  90. package/dist/constants.js.map +1 -1
  91. package/dist/control-plane-fetch.d.ts.map +1 -1
  92. package/dist/control-plane-fetch.js +2 -2
  93. package/dist/control-plane-fetch.js.map +1 -1
  94. package/dist/control-plane-loader.d.ts +1 -1
  95. package/dist/control-plane-loader.js +1 -1
  96. package/dist/dev/invoke.d.ts +44 -0
  97. package/dist/dev/invoke.d.ts.map +1 -0
  98. package/dist/dev/invoke.js +155 -0
  99. package/dist/dev/invoke.js.map +1 -0
  100. package/dist/errors-registry.d.ts.map +1 -1
  101. package/dist/errors-registry.js +86 -1
  102. package/dist/errors-registry.js.map +1 -1
  103. package/dist/help.d.ts +10 -0
  104. package/dist/help.d.ts.map +1 -1
  105. package/dist/help.js +212 -5
  106. package/dist/help.js.map +1 -1
  107. package/dist/init/templates.d.ts +11 -3
  108. package/dist/init/templates.d.ts.map +1 -1
  109. package/dist/init/templates.js +115 -27
  110. package/dist/init/templates.js.map +1 -1
  111. package/dist/project-binding.d.ts +17 -0
  112. package/dist/project-binding.d.ts.map +1 -0
  113. package/dist/project-binding.js +30 -0
  114. package/dist/project-binding.js.map +1 -0
  115. package/dist/prompt.d.ts +21 -0
  116. package/dist/prompt.d.ts.map +1 -1
  117. package/dist/prompt.js +186 -50
  118. package/dist/prompt.js.map +1 -1
  119. package/dist/reporter/human.d.ts +5 -0
  120. package/dist/reporter/human.d.ts.map +1 -1
  121. package/dist/reporter/human.js +48 -18
  122. package/dist/reporter/human.js.map +1 -1
  123. package/dist/reporter/json.d.ts +6 -0
  124. package/dist/reporter/json.d.ts.map +1 -1
  125. package/dist/reporter/json.js +16 -0
  126. package/dist/reporter/json.js.map +1 -1
  127. package/dist/reporter/style.d.ts +2 -1
  128. package/dist/reporter/style.d.ts.map +1 -1
  129. package/dist/reporter/style.js +14 -3
  130. package/dist/reporter/style.js.map +1 -1
  131. package/dist/reporter/types.d.ts +9 -0
  132. package/dist/reporter/types.d.ts.map +1 -1
  133. package/dist/reporter/types.js.map +1 -1
  134. package/dist/services/account-store.d.ts +9 -4
  135. package/dist/services/account-store.d.ts.map +1 -1
  136. package/dist/services/account-store.js +46 -22
  137. package/dist/services/account-store.js.map +1 -1
  138. package/dist/services/control-plane.js +3 -2
  139. package/dist/services/control-plane.js.map +1 -1
  140. package/dist/services/live.d.ts +1 -1
  141. package/dist/services/reporter.d.ts +3 -0
  142. package/dist/services/reporter.d.ts.map +1 -1
  143. package/dist/services/reporter.js +2 -0
  144. package/dist/services/reporter.js.map +1 -1
  145. package/package.json +21 -8
@@ -1,174 +1,15 @@
1
- import { mkdir, readFile, stat, writeFile } from 'node:fs/promises';
2
- import { basename, dirname, join, resolve } from 'node:path';
3
1
  import * as Args from '@effect/cli/Args';
4
2
  import * as Command from '@effect/cli/Command';
5
3
  import * as Options from '@effect/cli/Options';
6
4
  import { FileSystem, Path } from '@effect/platform';
7
- import { generateLegacyCardProject } from '@hs-x/codegen';
8
- import { classifyMigrationInput, validateProject } from '@hs-x/validator';
5
+ import { generateLegacyCardProject, generateMigratedHsxProject, } from '@hs-x/codegen';
6
+ import { classifyMigrationInput, detectMigrationSource, planCutover, validateProject } from '@hs-x/validator';
9
7
  import { Effect, Option } from 'effect';
10
8
  import { cliError, exitWith } from '../cli-error.js';
11
9
  import { accountIdOption, controlPlaneUrlOption, cwdOption, jsonOption, runHandler, userIdOption, } from '../cli/kit.js';
12
- import { createDeployHubSpotDeveloperClient } from '../hubspot-developer-client.js';
13
- import { emitHelp } from '../help.js';
14
- import { createReporter } from '../reporter/index.js';
15
10
  import { Cwd } from '../services/cwd.js';
16
11
  import { HubSpotDevClient } from '../services/hubspot-dev-client.js';
17
12
  import { Reporter } from '../services/reporter.js';
18
- export async function migrateCommand({ argv, cwd, json, }) {
19
- const subcommand = argv[1] ?? 'help';
20
- if (subcommand !== 'inspect' &&
21
- subcommand !== 'cards' &&
22
- subcommand !== 'report' &&
23
- subcommand !== 'swap-cards') {
24
- emitHelp(migrateHelpText(), json);
25
- return { exitCode: subcommand === 'help' ? 0 : 1 };
26
- }
27
- if (subcommand === 'swap-cards') {
28
- return await migrateSwapCardsCommand({ argv, json });
29
- }
30
- const target = argv.find((arg, index) => index > 1 && !arg.startsWith('-'));
31
- if (!target) {
32
- throw new Error(`Usage: hs-x migrate ${subcommand} <path> [--json]`);
33
- }
34
- const input = await readMigrationInput(resolve(cwd, target));
35
- const result = classifyMigrationInput(input);
36
- if (subcommand === 'report') {
37
- const response = {
38
- ok: true,
39
- command: 'migrate report',
40
- summary: {
41
- detected: result.detected,
42
- automatable: result.automatable.length,
43
- humanReview: result.manualWork.length,
44
- cloudflareRequired: result.cloudflareRequired,
45
- canGenerateCards: result.manualWork.length === 0,
46
- },
47
- inspect: result,
48
- nextSteps: migrationNextSteps(result),
49
- };
50
- if (json) {
51
- process.stdout.write(`${JSON.stringify(response, null, 2)}\n`);
52
- }
53
- else {
54
- process.stdout.write(renderMigrationReport(response));
55
- }
56
- return { exitCode: 0 };
57
- }
58
- if (subcommand === 'inspect') {
59
- const response = { ok: true, command: 'migrate inspect', ...result };
60
- if (json) {
61
- process.stdout.write(`${JSON.stringify(response, null, 2)}\n`);
62
- }
63
- else {
64
- process.stdout.write(renderMigrationInspect(response));
65
- }
66
- return { exitCode: result.manualWork.length === 0 ? 0 : 1 };
67
- }
68
- if (result.manualWork.length > 0 || !isLegacyCardMigrationInput(input)) {
69
- const response = { ok: false, command: 'migrate cards', ...result };
70
- if (json) {
71
- process.stdout.write(`${JSON.stringify(response, null, 2)}\n`);
72
- }
73
- else {
74
- process.stdout.write(renderMigrationInspect(response));
75
- }
76
- return { exitCode: 1 };
77
- }
78
- const outputRoot = resolve(cwd, resolveFlag(argv, '--project') ?? 'migrated-app');
79
- const generated = generateLegacyCardProject(input);
80
- await writeProjectFiles(outputRoot, generated.files);
81
- const validation = await validateProject({ root: outputRoot });
82
- const response = {
83
- ok: validation.ok,
84
- command: 'migrate cards',
85
- outputRoot,
86
- createdFiles: Object.keys(generated.files).sort(),
87
- inspect: result,
88
- validation,
89
- };
90
- if (json) {
91
- process.stdout.write(`${JSON.stringify(response, null, 2)}\n`);
92
- }
93
- else {
94
- const reporter = createReporter({ command: 'migrate cards', argv });
95
- reporter.header(basename(outputRoot));
96
- reporter
97
- .step('Generated migrated App Card project')
98
- .ok(`${response.createdFiles.length} files ${outputRoot}`);
99
- for (const file of response.createdFiles)
100
- reporter.info(file);
101
- if (validation.ok) {
102
- reporter.step('Validation').ok();
103
- }
104
- else {
105
- reporter.step('Validation').fail(`${validation.diagnostics.length} issue(s)`);
106
- for (const d of validation.diagnostics) {
107
- const code = d.code ?? 'diagnostic';
108
- const msg = d.message ?? JSON.stringify(d);
109
- reporter.error(code, msg);
110
- }
111
- }
112
- reporter.done(validation.ok ? 'Migrated' : 'Failed', validation.ok ? 0 : 1);
113
- }
114
- return { exitCode: validation.ok ? 0 : 1 };
115
- }
116
- async function migrateSwapCardsCommand({ argv, json, }) {
117
- const appId = readRequiredNumberFlag(argv, '--app-id');
118
- const legacyCrmCardId = resolveFlag(argv, '--legacy-card-id');
119
- const replacementAppCardId = resolveFlag(argv, '--replacement-card-id');
120
- if (!legacyCrmCardId) {
121
- throw new Error('--legacy-card-id is required.');
122
- }
123
- if (!replacementAppCardId) {
124
- throw new Error('--replacement-card-id is required.');
125
- }
126
- const local = argv.includes('--local-hubspot');
127
- const client = await createDeployHubSpotDeveloperClient({
128
- argv,
129
- local,
130
- allowedOperations: ['hubspot-migration-read', 'hubspot-card-view-swap'],
131
- });
132
- const apps = await client.migrations.listApps();
133
- const helpDeskAppCardId = resolveFlag(argv, '--helpdesk-card-id');
134
- const swapRequest = helpDeskAppCardId === undefined
135
- ? { appId, legacyCrmCardId, replacementAppCardId }
136
- : { appId, legacyCrmCardId, replacementAppCardId, helpDeskAppCardId };
137
- const swap = await client.migrations.migrateCardViews(swapRequest);
138
- const response = {
139
- ok: true,
140
- command: 'migrate swap-cards',
141
- appId,
142
- legacyCrmCardId,
143
- replacementAppCardId,
144
- ...(helpDeskAppCardId === undefined ? {} : { helpDeskAppCardId }),
145
- migrationApps: apps,
146
- swap,
147
- };
148
- if (json) {
149
- process.stdout.write(`${JSON.stringify(response, null, 2)}\n`);
150
- }
151
- else {
152
- const reporter = createReporter({ command: 'migrate swap-cards', argv });
153
- reporter.header(legacyCrmCardId);
154
- reporter.step('Migrated legacy CRM card').ok(`-> App Card ${replacementAppCardId}`);
155
- reporter.done('Swapped');
156
- }
157
- return { exitCode: 0 };
158
- }
159
- async function readMigrationInput(path) {
160
- const stats = await stat(path);
161
- const file = stats.isDirectory() ? join(path, 'migration.json') : path;
162
- const contents = await readFile(file, 'utf8');
163
- return JSON.parse(contents);
164
- }
165
- async function writeProjectFiles(root, files) {
166
- for (const [file, contents] of Object.entries(files)) {
167
- const path = join(root, file);
168
- await mkdir(dirname(path), { recursive: true });
169
- await writeFile(path, contents, { flag: 'wx' });
170
- }
171
- }
172
13
  function isLegacyCardMigrationInput(value) {
173
14
  if (!isRecord(value) || !isRecord(value.legacyApp) || !isRecord(value.legacyCard)) {
174
15
  return false;
@@ -221,50 +62,140 @@ function migrationNextSteps(result) {
221
62
  : 'Use `hs-x deploy --hubspot-only` once HubSpot upload is wired.',
222
63
  ];
223
64
  }
224
- function readRequiredNumberFlag(argv, flag) {
225
- const value = resolveFlag(argv, flag);
226
- if (!value) {
227
- throw new Error(`${flag} is required.`);
228
- }
229
- return readPositiveInteger(value, flag);
230
- }
231
- function readPositiveInteger(value, label) {
232
- const parsed = Number.parseInt(value, 10);
233
- if (!Number.isInteger(parsed) || parsed <= 0) {
234
- throw new Error(`${label} must be a positive integer.`);
235
- }
236
- return parsed;
237
- }
238
- function isFlagValue(value) {
239
- return value !== undefined && value.length > 0 && !value.startsWith('-');
240
- }
241
- function resolveFlag(argv, flag) {
242
- const index = argv.indexOf(flag);
243
- if (index !== -1) {
244
- const next = argv[index + 1];
245
- if (isFlagValue(next))
246
- return next;
247
- }
248
- const prefix = `${flag}=`;
249
- const found = argv.find((arg) => arg.startsWith(prefix));
250
- if (!found)
251
- return undefined;
252
- const value = found.slice(prefix.length);
253
- return value.length > 0 ? value : undefined;
254
- }
255
65
  function isRecord(value) {
256
66
  return typeof value === 'object' && value !== null;
257
67
  }
258
68
  export function migrateHelpText() {
259
69
  return `hs-x migrate
260
70
 
71
+ Migrate a legacy HubSpot app (2023.2 / 2025.1 / 2025.2) to a 2026.03 HS-X
72
+ project. The generated project is a NET-NEW dupe for testing in \`hs-x dev\`;
73
+ your original app keeps running untouched until cutover.
74
+
261
75
  Commands:
262
- hs-x migrate inspect <path> [--json] Classify a legacy migration input
263
- hs-x migrate report <path> [--json] Summarize migration readiness
76
+ hs-x migrate run [path] [--out <dir>] [--json] Detect, classify, and generate the dupe (the one command)
77
+ hs-x migrate cutover [path] [--json] Dry-run plan: what cutover changes on the ORIGINAL app
78
+ hs-x migrate inspect <path> [--json] Classify a legacy migration input
79
+ hs-x migrate report <path> [--json] Summarize migration readiness
264
80
  hs-x migrate cards <path> [--project <dir>] [--json]
265
81
  hs-x migrate swap-cards --app-id <id> --legacy-card-id <id> --replacement-card-id <id> [--helpdesk-card-id <id>] [--local-hubspot] [--json]
82
+
83
+ Run hs-x migrate <subcommand> --help for details on each step.
266
84
  `;
267
85
  }
86
+ export function migrateSubcommandHelpText(sub) {
87
+ switch (sub) {
88
+ case 'run':
89
+ return `hs-x migrate run [path]
90
+
91
+ The one-command migration: detect the legacy source (a project tree with
92
+ hsproject.json, or a migration.json input), classify every component, and
93
+ generate a NET-NEW 2026.03 HS-X project (the dupe) from everything portable —
94
+ app cards, serverless functions, webhook subscriptions, workflow actions.
95
+
96
+ Pieces needing a human decision (external receivers, endpoint URL contracts)
97
+ are listed with doc links; they gate CUTOVER, not the dupe — test it in
98
+ \`hs-x dev\` first. Re-run safely after resolving findings.
99
+
100
+ Arguments:
101
+ path Legacy source (default: current directory)
102
+
103
+ Flags:
104
+ --out <dir> Output directory (default: migrated-app)
105
+ --cwd <dir> Resolve paths relative to <dir>
106
+ --json Emit JSON result
107
+
108
+ Exits 1 only when nothing portable could be generated or validation fails.
109
+ `;
110
+ case 'cutover':
111
+ return `hs-x migrate cutover [path]
112
+
113
+ Print the DRY-RUN cutover plan: the ordered steps that migrate the ORIGINAL
114
+ app in place to 2026.03 — keeping its app id, installs, OAuth grants, and
115
+ listing — once the dupe has proven the migration in \`hs-x dev\`.
116
+
117
+ The plan is honest about the one-way door (no downgrade from 2026.03) and
118
+ lists every open decision (external receivers, endpoint contracts,
119
+ marketplace listing) that gates execution. Execution itself never runs
120
+ without an explicit human go; --execute is refused in this build.
121
+
122
+ Arguments:
123
+ path The ORIGINAL legacy source (default: current directory)
124
+
125
+ Flags:
126
+ --json Emit the plan as JSON
127
+ --cwd <dir> Resolve paths relative to <dir>
128
+ `;
129
+ case 'inspect':
130
+ return `hs-x migrate inspect <path>
131
+
132
+ Classify a legacy migration input: which findings HS-X can migrate
133
+ automatically, which need human review, and whether the replacement app needs
134
+ the Cloudflare runtime.
135
+
136
+ Arguments:
137
+ path Migration input JSON file, or a directory
138
+ containing migration.json
139
+
140
+ Flags:
141
+ --cwd <dir> Resolve <path> relative to <dir>
142
+ --json Emit JSON result
143
+
144
+ Exits 1 when human-review findings remain (so CI can gate on it).
145
+ `;
146
+ case 'report':
147
+ return `hs-x migrate report <path>
148
+
149
+ Summarize migration readiness: detected features, automatable vs human-review
150
+ finding counts, runtime requirements, and suggested next steps.
151
+
152
+ Arguments:
153
+ path Migration input JSON file, or a directory
154
+ containing migration.json
155
+
156
+ Flags:
157
+ --cwd <dir> Resolve <path> relative to <dir>
158
+ --json Emit JSON result
159
+ `;
160
+ case 'cards':
161
+ return `hs-x migrate cards <path>
162
+
163
+ Generate a replacement App Card project from a legacy CRM card migration
164
+ input, then validate the generated project. Fails (exit 1) if the input still
165
+ has human-review findings — resolve those first (see hs-x migrate inspect).
166
+
167
+ Arguments:
168
+ path Migration input JSON file, or a directory
169
+ containing migration.json
170
+
171
+ Flags:
172
+ --project <dir> Output directory (default: migrated-app)
173
+ --cwd <dir> Resolve paths relative to <dir>
174
+ --json Emit JSON result
175
+ `;
176
+ case 'swap-cards':
177
+ return `hs-x migrate swap-cards
178
+
179
+ Swap a live legacy CRM card for its replacement App Card via the HubSpot
180
+ developer API.
181
+
182
+ Flags:
183
+ --app-id <id> HubSpot app id (required)
184
+ --legacy-card-id <id> Legacy CRM card to retire (required)
185
+ --replacement-card-id <id> App Card that replaces it (required)
186
+ --helpdesk-card-id <id> Optional help-desk App Card variant
187
+ --local-hubspot Use the local HubSpot mock
188
+ --json Emit JSON result
189
+
190
+ Advanced:
191
+ --control-plane-url <u> Override control plane (HSX_CONTROL_PLANE_URL)
192
+ --account-id <id> HS-X account for lease/credential discovery
193
+ --user-id <id> Attribution for control-plane records
194
+ `;
195
+ default:
196
+ return undefined;
197
+ }
198
+ }
268
199
  // ─── Native @effect/cli command (Phase 4) ───────────────────────────────────
269
200
  // Subcommands inspect/report/cards (project analysis; fs lifted onto
270
201
  // `@effect/platform` FileSystem/Path) + swap-cards (HubSpot developer client via
@@ -288,6 +219,24 @@ function readMigrationInputEff(path) {
288
219
  const fs = yield* FileSystem.FileSystem;
289
220
  const pathSvc = yield* Path.Path;
290
221
  const info = yield* fs.stat(path).pipe(Effect.mapError((cause) => cliError('HSX_E_INTERNAL', `Could not read ${path}: ${String(cause)}`)));
222
+ if (info.type === 'Directory') {
223
+ const fixture = pathSvc.join(path, 'migration.json');
224
+ const hasFixture = yield* fs.exists(fixture).pipe(Effect.orElseSucceed(() => false));
225
+ if (!hasFixture) {
226
+ // Locked decision #4 (local-source half): a legacy PROJECT tree —
227
+ // hsproject.json names the platform version; component configs
228
+ // (serverless.json, webhooks/workflow-action hsmeta) assemble into
229
+ // the classifier input via detectMigrationSource.
230
+ const detected = yield* Effect.tryPromise({
231
+ try: () => detectMigrationSource(path),
232
+ catch: (cause) => cliError('HSX_E_INTERNAL', `Could not inspect ${path}: ${String(cause)}`),
233
+ });
234
+ if (detected.entry === 'unknown') {
235
+ return yield* Effect.fail(cliError('HSX_E_INPUT_BAD_FIELD', `${path} has no migration.json and no hsproject.json — nothing to migrate. Point at a legacy project root or a migration input file.`));
236
+ }
237
+ return detected.input;
238
+ }
239
+ }
291
240
  const file = info.type === 'Directory' ? pathSvc.join(path, 'migration.json') : path;
292
241
  const contents = yield* fs.readFileString(file).pipe(Effect.mapError((cause) => cliError('HSX_E_INTERNAL', `Could not read ${file}: ${String(cause)}`)));
293
242
  return yield* Effect.try({
@@ -308,22 +257,20 @@ function writeProjectFilesEff(root, files) {
308
257
  }
309
258
  });
310
259
  }
311
- function writeStdout(text) {
312
- return Effect.sync(() => process.stdout.write(text));
313
- }
314
260
  function migrateInspectBody(opts) {
315
261
  return Effect.gen(function* () {
316
262
  const cwd = yield* Cwd;
317
263
  const pathSvc = yield* Path.Path;
264
+ const reporter = yield* Reporter;
318
265
  const root = Option.getOrElse(opts.cwd, () => cwd);
319
266
  const input = yield* readMigrationInputEff(pathSvc.resolve(root, opts.path));
320
267
  const result = classifyMigrationInput(input);
321
268
  const response = { ok: true, command: 'migrate inspect', ...result };
322
269
  if (opts.json) {
323
- yield* writeStdout(`${JSON.stringify(response, null, 2)}\n`);
270
+ yield* reporter.raw(`${JSON.stringify(response, null, 2)}\n`);
324
271
  }
325
272
  else {
326
- yield* writeStdout(renderMigrationInspect(response));
273
+ yield* reporter.raw(renderMigrationInspect(response));
327
274
  }
328
275
  if (result.manualWork.length > 0) {
329
276
  return yield* Effect.fail(exitWith(1));
@@ -334,6 +281,7 @@ function migrateReportBody(opts) {
334
281
  return Effect.gen(function* () {
335
282
  const cwd = yield* Cwd;
336
283
  const pathSvc = yield* Path.Path;
284
+ const reporter = yield* Reporter;
337
285
  const root = Option.getOrElse(opts.cwd, () => cwd);
338
286
  const input = yield* readMigrationInputEff(pathSvc.resolve(root, opts.path));
339
287
  const result = classifyMigrationInput(input);
@@ -351,10 +299,10 @@ function migrateReportBody(opts) {
351
299
  nextSteps: migrationNextSteps(result),
352
300
  };
353
301
  if (opts.json) {
354
- yield* writeStdout(`${JSON.stringify(response, null, 2)}\n`);
302
+ yield* reporter.raw(`${JSON.stringify(response, null, 2)}\n`);
355
303
  }
356
304
  else {
357
- yield* writeStdout(renderMigrationReport(response));
305
+ yield* reporter.raw(renderMigrationReport(response));
358
306
  }
359
307
  });
360
308
  }
@@ -369,10 +317,10 @@ function migrateCardsBody(opts) {
369
317
  if (result.manualWork.length > 0 || !isLegacyCardMigrationInput(input)) {
370
318
  const response = { ok: false, command: 'migrate cards', ...result };
371
319
  if (opts.json) {
372
- yield* writeStdout(`${JSON.stringify(response, null, 2)}\n`);
320
+ yield* reporter.raw(`${JSON.stringify(response, null, 2)}\n`);
373
321
  }
374
322
  else {
375
- yield* writeStdout(renderMigrationInspect(response));
323
+ yield* reporter.raw(renderMigrationInspect(response));
376
324
  }
377
325
  return yield* Effect.fail(exitWith(1));
378
326
  }
@@ -389,7 +337,7 @@ function migrateCardsBody(opts) {
389
337
  validation,
390
338
  };
391
339
  if (opts.json) {
392
- yield* writeStdout(`${JSON.stringify(response, null, 2)}\n`);
340
+ yield* reporter.raw(`${JSON.stringify(response, null, 2)}\n`);
393
341
  }
394
342
  else {
395
343
  yield* reporter.header(pathSvc.basename(outputRoot));
@@ -417,6 +365,104 @@ function migrateCardsBody(opts) {
417
365
  }
418
366
  });
419
367
  }
368
+ /**
369
+ * The one-command flow (launch headline): detect → classify → generate the
370
+ * dupe project from every PORTABLE piece → report skipped pieces and
371
+ * pre-cutover decisions → guided next step into `hs-x dev`. Decision-type
372
+ * findings (forwarding/url-contract) never block dupe generation — the dupe
373
+ * is a testbed (locked decision #2); they gate CUTOVER, which is a separate
374
+ * command. Exit 1 only when nothing portable could be generated.
375
+ */
376
+ function migrateRunBody(opts) {
377
+ return Effect.gen(function* () {
378
+ const cwd = yield* Cwd;
379
+ const pathSvc = yield* Path.Path;
380
+ const reporter = yield* Reporter;
381
+ const root = Option.getOrElse(opts.cwd, () => cwd);
382
+ const sourcePath = pathSvc.resolve(root, opts.path);
383
+ const input = yield* readMigrationInputEff(sourcePath);
384
+ const result = classifyMigrationInput(input);
385
+ let generated;
386
+ let generationError;
387
+ try {
388
+ generated = generateMigratedHsxProject(input);
389
+ }
390
+ catch (error) {
391
+ generationError = error instanceof Error ? error.message : String(error);
392
+ }
393
+ const hasCapabilities = generated !== undefined &&
394
+ Object.keys(generated.files).some((file) => file.startsWith('src/workers/') || file.includes('/cards/'));
395
+ if (!generated || !hasCapabilities) {
396
+ const response = {
397
+ ok: false,
398
+ command: 'migrate run',
399
+ ...result,
400
+ ...(generationError ? { error: generationError } : {}),
401
+ };
402
+ if (opts.json) {
403
+ yield* reporter.raw(`${JSON.stringify(response, null, 2)}\n`);
404
+ }
405
+ else {
406
+ yield* reporter.raw(renderMigrationInspect(response));
407
+ yield* reporter.error('HSX_E_MIGRATE_NOTHING_PORTABLE', generationError ?? 'No portable components detected in this source.', {
408
+ hint: 'Resolve the findings above, then re-run `hs-x migrate run`.',
409
+ docs_url: 'https://hs-x.dev/docs/guides/migrate',
410
+ });
411
+ yield* reporter.done(undefined, 1);
412
+ }
413
+ return yield* Effect.fail(exitWith(1));
414
+ }
415
+ const outputRoot = pathSvc.resolve(root, Option.getOrUndefined(opts.out) ?? 'migrated-app');
416
+ yield* writeProjectFilesEff(outputRoot, generated.files);
417
+ const validation = yield* Effect.promise(() => validateProject({ root: outputRoot }));
418
+ const response = {
419
+ ok: validation.ok,
420
+ command: 'migrate run',
421
+ outputRoot,
422
+ createdFiles: Object.keys(generated.files).sort(),
423
+ inspect: result,
424
+ validation,
425
+ };
426
+ if (opts.json) {
427
+ yield* reporter.raw(`${JSON.stringify(response, null, 2)}\n`);
428
+ }
429
+ else {
430
+ yield* reporter.header(pathSvc.basename(outputRoot));
431
+ const genStep = yield* reporter.step('Generated migrated HS-X project (the dupe)');
432
+ yield* genStep.ok(`${response.createdFiles.length} files ${outputRoot}`);
433
+ if (result.automatable.length > 0) {
434
+ yield* reporter.info(`Ported automatically:`);
435
+ for (const finding of result.automatable)
436
+ yield* reporter.info(` - ${finding.message}`);
437
+ }
438
+ if (result.manualWork.length > 0) {
439
+ yield* reporter.info('Decisions before cutover (the dupe is a testbed; these do not block it):');
440
+ for (const finding of result.manualWork) {
441
+ yield* reporter.info(` - [${finding.code}] ${finding.message} docs: https://hs-x.dev/docs/guides/migrate#${finding.code.replace(/\./g, '-')}`);
442
+ }
443
+ }
444
+ const vStep = yield* reporter.step('Validation');
445
+ if (validation.ok) {
446
+ yield* vStep.ok();
447
+ }
448
+ else {
449
+ yield* vStep.fail(`${validation.diagnostics.length} issue(s)`);
450
+ for (const d of validation.diagnostics) {
451
+ yield* reporter.error(d.code ?? 'diagnostic', d.message ?? JSON.stringify(d));
452
+ }
453
+ }
454
+ yield* reporter.info('');
455
+ yield* reporter.info('Next steps:');
456
+ yield* reporter.info(` cd ${pathSvc.relative(root, outputRoot) || '.'} && bun install`);
457
+ yield* reporter.info(' hs-x dev # test every migrated capability locally');
458
+ yield* reporter.info(' hs-x deploy # upload the dupe as a NET-NEW app when ready');
459
+ yield* reporter.done(validation.ok ? 'Migrated' : 'Failed', validation.ok ? 0 : 1);
460
+ }
461
+ if (!validation.ok) {
462
+ return yield* Effect.fail(exitWith(1));
463
+ }
464
+ });
465
+ }
420
466
  function migrateSwapCardsBody(opts, argv) {
421
467
  return Effect.gen(function* () {
422
468
  const reporter = yield* Reporter;
@@ -451,7 +497,7 @@ function migrateSwapCardsBody(opts, argv) {
451
497
  swap,
452
498
  };
453
499
  if (opts.json) {
454
- yield* writeStdout(`${JSON.stringify(response, null, 2)}\n`);
500
+ yield* reporter.raw(`${JSON.stringify(response, null, 2)}\n`);
455
501
  }
456
502
  else {
457
503
  yield* reporter.header(legacyCrmCardId);
@@ -504,7 +550,67 @@ function swapCardsArgv(opts) {
504
550
  argv.push('--user-id', opts.userId.value);
505
551
  return argv;
506
552
  }
553
+ const migrateRunPathArg = Args.text({ name: 'path' }).pipe(Args.withDefault('.'));
554
+ const migrateOutOption = Options.text('out').pipe(Options.optional);
555
+ /**
556
+ * `hs-x migrate cutover [path]` — DRY-RUN ONLY today (locked decision #2:
557
+ * cutover never executes against a real app without explicit human go).
558
+ * Prints the ordered plan of what cutover will change on the ORIGINAL app,
559
+ * in place, plus the open decisions gating execution.
560
+ */
561
+ function migrateCutoverBody(opts) {
562
+ return Effect.gen(function* () {
563
+ const cwd = yield* Cwd;
564
+ const pathSvc = yield* Path.Path;
565
+ const reporter = yield* Reporter;
566
+ const root = Option.getOrElse(opts.cwd, () => cwd);
567
+ const sourcePath = pathSvc.resolve(root, opts.path);
568
+ if (opts.execute) {
569
+ return yield* Effect.fail(cliError('HSX_E_CUTOVER_EXECUTE_GATED', 'Cutover execution against a real app requires an explicit human go and is not enabled in this build.'));
570
+ }
571
+ const detected = yield* Effect.tryPromise({
572
+ try: () => detectMigrationSource(sourcePath),
573
+ catch: (cause) => cliError('HSX_E_INTERNAL', `Could not inspect ${sourcePath}: ${String(cause)}`),
574
+ });
575
+ if (detected.entry === 'unknown') {
576
+ return yield* Effect.fail(cliError('HSX_E_INPUT_BAD_FIELD', `${sourcePath} has no migration.json and no hsproject.json — nothing to plan. Point at the ORIGINAL legacy app's source.`));
577
+ }
578
+ const classification = classifyMigrationInput(detected.input);
579
+ const plan = planCutover(detected, classification);
580
+ if (opts.json) {
581
+ yield* reporter.raw(`${JSON.stringify({ ok: true, command: 'migrate cutover', mode: 'dry-run', ...plan }, null, 2)}\n`);
582
+ return;
583
+ }
584
+ yield* reporter.header(`cutover plan (dry-run) — ${plan.sourceVersion} source`);
585
+ yield* reporter.info('ONE-WAY: the original app cannot downgrade from 2026.03 after cutover.');
586
+ yield* reporter.info('');
587
+ for (const phase of ['preflight', 'in-place', 'post']) {
588
+ const phaseSteps = plan.steps.filter((step) => step.phase === phase);
589
+ if (phaseSteps.length === 0)
590
+ continue;
591
+ yield* reporter.info(`${phase.toUpperCase()}:`);
592
+ for (const step of phaseSteps) {
593
+ yield* reporter.info(` - ${step.title}${step.gatedBy ? ` [gated: ${step.gatedBy}]` : ''}`);
594
+ yield* reporter.info(` ${step.detail}`);
595
+ }
596
+ yield* reporter.info('');
597
+ }
598
+ if (plan.openDecisions.length > 0) {
599
+ yield* reporter.info(`${plan.openDecisions.length} open decision(s) gate execution — resolve them, re-run this plan, then request execution.`);
600
+ }
601
+ else {
602
+ yield* reporter.info('No open decisions. Execution still requires an explicit human go (see LAUNCH-PLAN NEEDS HUMAN).');
603
+ }
604
+ yield* reporter.done('Planned');
605
+ });
606
+ }
607
+ const migrateExecuteOption = Options.boolean('execute').pipe(Options.withDefault(false));
608
+ const migrateCutoverPathArg = Args.text({ name: 'path' }).pipe(Args.withDefault('.'));
609
+ const migrateCutoverCmd = Command.make('cutover', { path: migrateCutoverPathArg, cwd: cwdOption, json: jsonOption, execute: migrateExecuteOption }, (opts) => runHandler('migrate', { json: opts.json }, migrateCutoverBody(opts)));
610
+ const migrateRunCmd = Command.make('run', { path: migrateRunPathArg, out: migrateOutOption, cwd: cwdOption, json: jsonOption }, (opts) => runHandler('migrate', { json: opts.json }, migrateRunBody(opts)));
507
611
  export const migrateCmd = Command.make('migrate').pipe(Command.withSubcommands([
612
+ migrateRunCmd,
613
+ migrateCutoverCmd,
508
614
  migrateInspectCmd,
509
615
  migrateReportCmd,
510
616
  migrateCardsCmd,