@hs-x/cli 0.2.7 → 0.3.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +202 -0
- package/README.md +25 -8
- package/dist/audit-interactive.d.ts +29 -0
- package/dist/audit-interactive.d.ts.map +1 -0
- package/dist/audit-interactive.js +156 -0
- package/dist/audit-interactive.js.map +1 -0
- package/dist/bin/hs-x.js.map +1 -1
- package/dist/checkpoint-interactive.d.ts +24 -0
- package/dist/checkpoint-interactive.d.ts.map +1 -0
- package/dist/checkpoint-interactive.js +65 -0
- package/dist/checkpoint-interactive.js.map +1 -0
- package/dist/cli/index.d.ts.map +1 -1
- package/dist/cli/index.js +266 -39
- package/dist/cli/index.js.map +1 -1
- package/dist/cli/kit.d.ts +1 -1
- package/dist/cli/kit.d.ts.map +1 -1
- package/dist/cli/kit.js +19 -3
- package/dist/cli/kit.js.map +1 -1
- package/dist/cloudflare-auth.d.ts +8 -0
- package/dist/cloudflare-auth.d.ts.map +1 -1
- package/dist/cloudflare-auth.js +12 -2
- package/dist/cloudflare-auth.js.map +1 -1
- package/dist/cloudflare-oauth-store.d.ts +9 -1
- package/dist/cloudflare-oauth-store.d.ts.map +1 -1
- package/dist/cloudflare-oauth-store.js +23 -4
- package/dist/cloudflare-oauth-store.js.map +1 -1
- package/dist/cloudflare-oauth.d.ts +1 -0
- package/dist/cloudflare-oauth.d.ts.map +1 -1
- package/dist/cloudflare-oauth.js +6 -2
- package/dist/cloudflare-oauth.js.map +1 -1
- package/dist/commands/account.d.ts +28 -13
- package/dist/commands/account.d.ts.map +1 -1
- package/dist/commands/account.js +219 -69
- package/dist/commands/account.js.map +1 -1
- package/dist/commands/api.d.ts.map +1 -1
- package/dist/commands/api.js +7 -11
- package/dist/commands/api.js.map +1 -1
- package/dist/commands/completion.d.ts.map +1 -1
- package/dist/commands/completion.js +13 -7
- package/dist/commands/completion.js.map +1 -1
- package/dist/commands/connect.d.ts.map +1 -1
- package/dist/commands/connect.js +239 -125
- package/dist/commands/connect.js.map +1 -1
- package/dist/commands/control-plane-read.d.ts +71 -10
- package/dist/commands/control-plane-read.d.ts.map +1 -1
- package/dist/commands/control-plane-read.js +1035 -58
- package/dist/commands/control-plane-read.js.map +1 -1
- package/dist/commands/deploy-promote.d.ts +25 -3
- package/dist/commands/deploy-promote.d.ts.map +1 -1
- package/dist/commands/deploy-promote.js +314 -38
- package/dist/commands/deploy-promote.js.map +1 -1
- package/dist/commands/deploy.d.ts +69 -4
- package/dist/commands/deploy.d.ts.map +1 -1
- package/dist/commands/deploy.js +1512 -276
- package/dist/commands/deploy.js.map +1 -1
- package/dist/commands/dev.d.ts +5 -1
- package/dist/commands/dev.d.ts.map +1 -1
- package/dist/commands/dev.js +502 -107
- package/dist/commands/dev.js.map +1 -1
- package/dist/commands/doctor.d.ts +14 -0
- package/dist/commands/doctor.d.ts.map +1 -1
- package/dist/commands/doctor.js +131 -20
- package/dist/commands/doctor.js.map +1 -1
- package/dist/commands/flags.d.ts +108 -6
- package/dist/commands/flags.d.ts.map +1 -1
- package/dist/commands/flags.js +996 -75
- package/dist/commands/flags.js.map +1 -1
- package/dist/commands/help-command.d.ts +14 -0
- package/dist/commands/help-command.d.ts.map +1 -1
- package/dist/commands/help-command.js +144 -44
- package/dist/commands/help-command.js.map +1 -1
- package/dist/commands/history.js +14 -10
- package/dist/commands/history.js.map +1 -1
- package/dist/commands/init.d.ts +4 -2
- package/dist/commands/init.d.ts.map +1 -1
- package/dist/commands/init.js +98 -17
- package/dist/commands/init.js.map +1 -1
- package/dist/commands/link.d.ts +2 -3
- package/dist/commands/link.d.ts.map +1 -1
- package/dist/commands/link.js +201 -101
- package/dist/commands/link.js.map +1 -1
- package/dist/commands/login.d.ts.map +1 -1
- package/dist/commands/login.js +22 -5
- package/dist/commands/login.js.map +1 -1
- package/dist/commands/migrate.d.ts +37 -17
- package/dist/commands/migrate.d.ts.map +1 -1
- package/dist/commands/migrate.js +500 -73
- package/dist/commands/migrate.js.map +1 -1
- package/dist/commands/rollback.d.ts +42 -2
- package/dist/commands/rollback.d.ts.map +1 -1
- package/dist/commands/rollback.js +422 -58
- package/dist/commands/rollback.js.map +1 -1
- package/dist/commands/secrets.d.ts +76 -3
- package/dist/commands/secrets.d.ts.map +1 -1
- package/dist/commands/secrets.js +328 -41
- package/dist/commands/secrets.js.map +1 -1
- package/dist/commands/status.d.ts +13 -2
- package/dist/commands/status.d.ts.map +1 -1
- package/dist/commands/status.js +154 -17
- package/dist/commands/status.js.map +1 -1
- package/dist/commands/unlink.d.ts +2 -2
- package/dist/commands/unlink.d.ts.map +1 -1
- package/dist/commands/unlink.js +2 -2
- package/dist/commands/unlink.js.map +1 -1
- package/dist/commands/update.d.ts.map +1 -1
- package/dist/commands/update.js +4 -3
- package/dist/commands/update.js.map +1 -1
- package/dist/commands/validate.d.ts +12 -3
- package/dist/commands/validate.d.ts.map +1 -1
- package/dist/commands/validate.js +70 -8
- package/dist/commands/validate.js.map +1 -1
- package/dist/constants.d.ts +1 -1
- package/dist/constants.js +1 -1
- package/dist/control-plane-fetch.d.ts.map +1 -1
- package/dist/control-plane-fetch.js +5 -4
- package/dist/control-plane-fetch.js.map +1 -1
- package/dist/deploy-revision-capture.d.ts +48 -0
- package/dist/deploy-revision-capture.d.ts.map +1 -0
- package/dist/deploy-revision-capture.js +125 -0
- package/dist/deploy-revision-capture.js.map +1 -0
- package/dist/dev/event-bus.d.ts +5 -2
- package/dist/dev/event-bus.d.ts.map +1 -1
- package/dist/dev/event-bus.js +4 -1
- package/dist/dev/event-bus.js.map +1 -1
- package/dist/dev/hubspot-logs-poller.d.ts +35 -0
- package/dist/dev/hubspot-logs-poller.d.ts.map +1 -0
- package/dist/dev/hubspot-logs-poller.js +94 -0
- package/dist/dev/hubspot-logs-poller.js.map +1 -0
- package/dist/dev/invoke.d.ts +9 -1
- package/dist/dev/invoke.d.ts.map +1 -1
- package/dist/dev/invoke.js +69 -20
- package/dist/dev/invoke.js.map +1 -1
- package/dist/dev/keyboard.d.ts +31 -0
- package/dist/dev/keyboard.d.ts.map +1 -0
- package/dist/dev/keyboard.js +143 -0
- package/dist/dev/keyboard.js.map +1 -0
- package/dist/dev/log-server.d.ts +7 -0
- package/dist/dev/log-server.d.ts.map +1 -1
- package/dist/dev/log-server.js +68 -31
- package/dist/dev/log-server.js.map +1 -1
- package/dist/dev/migrated-card.d.ts +17 -0
- package/dist/dev/migrated-card.d.ts.map +1 -0
- package/dist/dev/migrated-card.js +114 -0
- package/dist/dev/migrated-card.js.map +1 -0
- package/dist/dev/stream-filter.d.ts +40 -0
- package/dist/dev/stream-filter.d.ts.map +1 -0
- package/dist/dev/stream-filter.js +93 -0
- package/dist/dev/stream-filter.js.map +1 -0
- package/dist/dev/stream-renderer.d.ts +34 -0
- package/dist/dev/stream-renderer.d.ts.map +1 -1
- package/dist/dev/stream-renderer.js +222 -8
- package/dist/dev/stream-renderer.js.map +1 -1
- package/dist/errors-registry.d.ts.map +1 -1
- package/dist/errors-registry.js +280 -36
- package/dist/errors-registry.js.map +1 -1
- package/dist/errors.d.ts.map +1 -1
- package/dist/errors.js.map +1 -1
- package/dist/help.d.ts.map +1 -1
- package/dist/help.js +8 -0
- package/dist/help.js.map +1 -1
- package/dist/hubspot-app-logs.d.ts +76 -0
- package/dist/hubspot-app-logs.d.ts.map +1 -0
- package/dist/hubspot-app-logs.js +323 -0
- package/dist/hubspot-app-logs.js.map +1 -0
- package/dist/hubspot-auth.d.ts +8 -2
- package/dist/hubspot-auth.d.ts.map +1 -1
- package/dist/hubspot-auth.js +9 -9
- package/dist/hubspot-auth.js.map +1 -1
- package/dist/hubspot-developer-client.js +119 -11
- package/dist/hubspot-developer-client.js.map +1 -1
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -0
- package/dist/index.js.map +1 -1
- package/dist/init/templates.d.ts +11 -1
- package/dist/init/templates.d.ts.map +1 -1
- package/dist/init/templates.js +228 -15
- package/dist/init/templates.js.map +1 -1
- package/dist/link-migration-journal.d.ts +131 -0
- package/dist/link-migration-journal.d.ts.map +1 -0
- package/dist/link-migration-journal.js +395 -0
- package/dist/link-migration-journal.js.map +1 -0
- package/dist/link-preflight.d.ts +78 -0
- package/dist/link-preflight.d.ts.map +1 -0
- package/dist/link-preflight.js +231 -0
- package/dist/link-preflight.js.map +1 -0
- package/dist/local-project-picker.d.ts +34 -0
- package/dist/local-project-picker.d.ts.map +1 -0
- package/dist/local-project-picker.js +121 -0
- package/dist/local-project-picker.js.map +1 -0
- package/dist/logs-interactive.d.ts +37 -0
- package/dist/logs-interactive.d.ts.map +1 -0
- package/dist/logs-interactive.js +110 -0
- package/dist/logs-interactive.js.map +1 -0
- package/dist/output-context.d.ts +13 -0
- package/dist/output-context.d.ts.map +1 -0
- package/dist/output-context.js +46 -0
- package/dist/output-context.js.map +1 -0
- package/dist/project-context.d.ts +55 -0
- package/dist/project-context.d.ts.map +1 -0
- package/dist/project-context.js +115 -0
- package/dist/project-context.js.map +1 -0
- package/dist/project-picker.d.ts +65 -0
- package/dist/project-picker.d.ts.map +1 -0
- package/dist/project-picker.js +227 -0
- package/dist/project-picker.js.map +1 -0
- package/dist/prompt.d.ts +8 -0
- package/dist/prompt.d.ts.map +1 -1
- package/dist/prompt.js +81 -13
- package/dist/prompt.js.map +1 -1
- package/dist/reporter/human.d.ts +10 -2
- package/dist/reporter/human.d.ts.map +1 -1
- package/dist/reporter/human.js +82 -14
- package/dist/reporter/human.js.map +1 -1
- package/dist/reporter/index.d.ts.map +1 -1
- package/dist/reporter/index.js +7 -5
- package/dist/reporter/index.js.map +1 -1
- package/dist/reporter/json.d.ts +2 -1
- package/dist/reporter/json.d.ts.map +1 -1
- package/dist/reporter/json.js +10 -7
- package/dist/reporter/json.js.map +1 -1
- package/dist/reporter/style.d.ts +19 -0
- package/dist/reporter/style.d.ts.map +1 -1
- package/dist/reporter/style.js +126 -6
- package/dist/reporter/style.js.map +1 -1
- package/dist/reporter/types.d.ts +34 -4
- package/dist/reporter/types.d.ts.map +1 -1
- package/dist/reporter/types.js.map +1 -1
- package/dist/services/account-store.d.ts +3 -1
- package/dist/services/account-store.d.ts.map +1 -1
- package/dist/services/account-store.js +2 -1
- package/dist/services/account-store.js.map +1 -1
- package/dist/services/app-paths.d.ts.map +1 -1
- package/dist/services/app-paths.js +3 -1
- package/dist/services/app-paths.js.map +1 -1
- package/dist/services/cloudflare-auth.d.ts +1 -1
- package/dist/services/cloudflare-auth.d.ts.map +1 -1
- package/dist/services/cloudflare-auth.js +2 -2
- package/dist/services/cloudflare-auth.js.map +1 -1
- package/dist/services/cloudflare-kv.d.ts.map +1 -1
- package/dist/services/cloudflare-kv.js.map +1 -1
- package/dist/services/command-history.d.ts.map +1 -1
- package/dist/services/command-history.js.map +1 -1
- package/dist/services/config.d.ts.map +1 -1
- package/dist/services/config.js +1 -1
- package/dist/services/config.js.map +1 -1
- package/dist/services/control-plane.d.ts.map +1 -1
- package/dist/services/control-plane.js +3 -3
- package/dist/services/control-plane.js.map +1 -1
- package/dist/services/env-loader.d.ts.map +1 -1
- package/dist/services/env-loader.js +1 -3
- package/dist/services/env-loader.js.map +1 -1
- package/dist/services/http.d.ts.map +1 -1
- package/dist/services/http.js.map +1 -1
- package/dist/services/hubspot-dev-client.d.ts.map +1 -1
- package/dist/services/hubspot-dev-client.js +30 -0
- package/dist/services/hubspot-dev-client.js.map +1 -1
- package/dist/services/live.d.ts +2 -2
- package/dist/services/live.d.ts.map +1 -1
- package/dist/services/live.js +1 -1
- package/dist/services/live.js.map +1 -1
- package/dist/services/machine-id.d.ts.map +1 -1
- package/dist/services/machine-id.js.map +1 -1
- package/dist/services/reporter.d.ts +8 -5
- package/dist/services/reporter.d.ts.map +1 -1
- package/dist/services/reporter.js +6 -4
- package/dist/services/reporter.js.map +1 -1
- package/dist/services/tenant-state.d.ts.map +1 -1
- package/dist/services/tenant-state.js.map +1 -1
- package/dist/state-store.d.ts +4 -0
- package/dist/state-store.d.ts.map +1 -1
- package/dist/state-store.js +15 -0
- package/dist/state-store.js.map +1 -1
- package/dist/status-drilldown.d.ts +24 -0
- package/dist/status-drilldown.d.ts.map +1 -0
- package/dist/status-drilldown.js +50 -0
- package/dist/status-drilldown.js.map +1 -0
- package/dist/tenant-state.d.ts.map +1 -1
- package/dist/tenant-state.js +3 -2
- package/dist/tenant-state.js.map +1 -1
- package/dist/token-key-custody.d.ts +91 -0
- package/dist/token-key-custody.d.ts.map +1 -0
- package/dist/token-key-custody.js +375 -0
- package/dist/token-key-custody.js.map +1 -0
- package/package.json +14 -15
package/dist/commands/migrate.js
CHANGED
|
@@ -3,11 +3,12 @@ import * as Command from '@effect/cli/Command';
|
|
|
3
3
|
import * as Options from '@effect/cli/Options';
|
|
4
4
|
import { FileSystem, Path } from '@effect/platform';
|
|
5
5
|
import { generateLegacyCardProject, generateMigratedHsxProject, } from '@hs-x/codegen';
|
|
6
|
-
import { classifyMigrationInput, detectMigrationSource, planCutover, validateProject } from '@hs-x/validator';
|
|
6
|
+
import { classifyMigrationInput, detectMigrationSource, planCutover, validateProject, } from '@hs-x/validator';
|
|
7
7
|
import { Effect, Option } from 'effect';
|
|
8
8
|
import { cliError, exitWith } from '../cli-error.js';
|
|
9
9
|
import { accountIdOption, controlPlaneUrlOption, cwdOption, jsonOption, runHandler, userIdOption, } from '../cli/kit.js';
|
|
10
|
-
import {
|
|
10
|
+
import { discoverLocalProjects } from '../local-project-picker.js';
|
|
11
|
+
import { BACK, isInteractive, promptConfirm, promptSelect, promptText, } from '../prompt.js';
|
|
11
12
|
import { Cwd } from '../services/cwd.js';
|
|
12
13
|
import { HubSpotDevClient } from '../services/hubspot-dev-client.js';
|
|
13
14
|
import { Reporter } from '../services/reporter.js';
|
|
@@ -48,21 +49,44 @@ function renderMigrationReport(data) {
|
|
|
48
49
|
return `${lines.join('\n')}\n`;
|
|
49
50
|
}
|
|
50
51
|
function migrationNextSteps(result) {
|
|
52
|
+
const hasNonCardComponents = result.detected.some((kind) => kind !== 'classic-crm-card' && kind !== 'project-card');
|
|
53
|
+
const generateCommand = hasNonCardComponents
|
|
54
|
+
? '`hs-x migrate run <path> --out <dir>`'
|
|
55
|
+
: '`hs-x migrate cards <path> --project <dir>`';
|
|
51
56
|
if (result.manualWork.length > 0) {
|
|
52
57
|
return [
|
|
53
|
-
|
|
58
|
+
`Resolve human-review findings before running ${generateCommand}.`,
|
|
54
59
|
result.cloudflareRequired
|
|
55
60
|
? 'Plan Cloudflare runtime requirements before deploy.'
|
|
56
61
|
: 'Confirm HubSpot-only replacement card targets before deploy.',
|
|
57
62
|
];
|
|
58
63
|
}
|
|
59
64
|
return [
|
|
60
|
-
|
|
65
|
+
`Run ${generateCommand} to generate the replacement project.`,
|
|
61
66
|
result.cloudflareRequired
|
|
62
67
|
? 'Run `hs-x connect cloudflare` before deploy.'
|
|
63
68
|
: 'Use `hs-x deploy --hubspot-only` once HubSpot upload is wired.',
|
|
64
69
|
];
|
|
65
70
|
}
|
|
71
|
+
function countMatches(contents, pattern) {
|
|
72
|
+
return [...contents.matchAll(pattern)].length;
|
|
73
|
+
}
|
|
74
|
+
function migrationOutstandingWork(files) {
|
|
75
|
+
let todoMarkers = 0;
|
|
76
|
+
let frontendServerlessCalls = 0;
|
|
77
|
+
const hasCompatibilityShim = files['src/app/cards/_hsx-migration-run-serverless.ts'] !== undefined;
|
|
78
|
+
for (const [file, contents] of Object.entries(files)) {
|
|
79
|
+
todoMarkers += countMatches(contents, /TODO\(migration\)/g);
|
|
80
|
+
if (!hasCompatibilityShim && file.startsWith('src/app/cards/') && /\.(?:t|j)sx?$/.test(file)) {
|
|
81
|
+
frontendServerlessCalls += countMatches(contents, /\brunServerlessFunction\s*\(/g);
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
return {
|
|
85
|
+
todoMarkers,
|
|
86
|
+
frontendServerlessCalls,
|
|
87
|
+
total: todoMarkers + frontendServerlessCalls,
|
|
88
|
+
};
|
|
89
|
+
}
|
|
66
90
|
function isRecord(value) {
|
|
67
91
|
return typeof value === 'object' && value !== null;
|
|
68
92
|
}
|
|
@@ -73,6 +97,15 @@ Migrate a legacy HubSpot app (2023.2 / 2025.1 / 2025.2) to a 2026.03 HS-X
|
|
|
73
97
|
project. The generated project is a NET-NEW dupe for testing in \`hs-x dev\`;
|
|
74
98
|
your original app keeps running untouched until cutover.
|
|
75
99
|
|
|
100
|
+
Guided mode:
|
|
101
|
+
hs-x migrate In a terminal, choose a local project, live app, or cutover plan
|
|
102
|
+
|
|
103
|
+
The guided journey discovers hsproject.json and migration.json sources beneath
|
|
104
|
+
a workspace, inspects the selected source, previews its output, and asks for
|
|
105
|
+
confirmation (default No) before writing. It generates a test dupe only: it
|
|
106
|
+
never deploys or executes cutover. Press Esc to go back or Ctrl+C to cancel.
|
|
107
|
+
For CI, JSON, JSON-stream, or redirected input, use an explicit command below.
|
|
108
|
+
|
|
76
109
|
Commands:
|
|
77
110
|
hs-x migrate run [path] [--out <dir>] [--json] Detect, classify, and generate the dupe (the one command)
|
|
78
111
|
hs-x migrate pull [--app-id <id>] [--json] Pull a live app's definition into migration.json (no --app-id: pick from a list)
|
|
@@ -85,6 +118,69 @@ Commands:
|
|
|
85
118
|
Run hs-x migrate <subcommand> --help for details on each step.
|
|
86
119
|
`;
|
|
87
120
|
}
|
|
121
|
+
const liveMigratePromptDeps = {
|
|
122
|
+
isInteractive,
|
|
123
|
+
select: (options) => promptSelect({ ...options, allowBack: true }),
|
|
124
|
+
text: (options) => promptText({ ...options, allowBack: true }),
|
|
125
|
+
confirm: (options) => promptConfirm({ ...options, allowBack: true }),
|
|
126
|
+
};
|
|
127
|
+
/** Prompt-only seam: the root journey can be tested without filesystem or network effects. */
|
|
128
|
+
export async function selectMigrateJourney(deps = liveMigratePromptDeps) {
|
|
129
|
+
const selected = await deps.select({
|
|
130
|
+
message: 'Where are you migrating from?',
|
|
131
|
+
options: [
|
|
132
|
+
{
|
|
133
|
+
value: 'local',
|
|
134
|
+
label: 'Local legacy project',
|
|
135
|
+
description: 'Inspect source on disk and generate a test dupe',
|
|
136
|
+
},
|
|
137
|
+
{
|
|
138
|
+
value: 'live',
|
|
139
|
+
label: 'Live HubSpot app',
|
|
140
|
+
description: 'Choose an app, pull migration.json, then generate a test dupe',
|
|
141
|
+
},
|
|
142
|
+
{
|
|
143
|
+
value: 'cutover',
|
|
144
|
+
label: 'Plan cutover',
|
|
145
|
+
description: 'Plan the in-place cutover, or --execute the one-way migration',
|
|
146
|
+
},
|
|
147
|
+
],
|
|
148
|
+
});
|
|
149
|
+
return selected === BACK ? undefined : selected;
|
|
150
|
+
}
|
|
151
|
+
export async function confirmMigrateWrite(message, deps = liveMigratePromptDeps) {
|
|
152
|
+
const confirmed = await deps.confirm({ message, default: false, allowBack: true });
|
|
153
|
+
if (confirmed === BACK)
|
|
154
|
+
return 'back';
|
|
155
|
+
return confirmed === true ? 'proceed' : 'cancel';
|
|
156
|
+
}
|
|
157
|
+
/**
|
|
158
|
+
* Discover legacy migration sources beneath a workspace. `null` means no
|
|
159
|
+
* marker was found (callers retain their existing `.` fallback); undefined is
|
|
160
|
+
* prompt cancellation, and BACK returns to the preceding guided menu.
|
|
161
|
+
*/
|
|
162
|
+
export async function selectLocalMigrationSource(root, deps = liveMigratePromptDeps) {
|
|
163
|
+
const projects = await discoverLocalProjects({
|
|
164
|
+
root,
|
|
165
|
+
markers: ['hsproject.json', 'migration.json'],
|
|
166
|
+
});
|
|
167
|
+
const direct = projects.find((project) => project.relativePath === '.');
|
|
168
|
+
if (direct)
|
|
169
|
+
return '.';
|
|
170
|
+
if (projects.length === 0)
|
|
171
|
+
return null;
|
|
172
|
+
if (projects.length === 1)
|
|
173
|
+
return projects[0]?.relativePath ?? null;
|
|
174
|
+
return await deps.select({
|
|
175
|
+
message: 'Which local legacy project?',
|
|
176
|
+
options: projects.map((project) => ({
|
|
177
|
+
value: project.relativePath,
|
|
178
|
+
label: project.name,
|
|
179
|
+
description: `${project.relativePath} · ${project.marker}`,
|
|
180
|
+
})),
|
|
181
|
+
allowBack: true,
|
|
182
|
+
});
|
|
183
|
+
}
|
|
88
184
|
export function migrateSubcommandHelpText(sub) {
|
|
89
185
|
switch (sub) {
|
|
90
186
|
case 'run':
|
|
@@ -100,7 +196,8 @@ are listed with doc links; they gate CUTOVER, not the dupe — test it in
|
|
|
100
196
|
\`hs-x dev\` first. Re-run safely after resolving findings.
|
|
101
197
|
|
|
102
198
|
Arguments:
|
|
103
|
-
path Legacy source
|
|
199
|
+
path Legacy source. When omitted, terminals discover
|
|
200
|
+
workspace sources; scripts use the current directory.
|
|
104
201
|
|
|
105
202
|
Flags:
|
|
106
203
|
--out <dir> Output directory (default: migrated-app)
|
|
@@ -123,26 +220,35 @@ HSX_HUBSPOT_DEVELOPER_ACCOUNT_ID).
|
|
|
123
220
|
Flags:
|
|
124
221
|
--app-id <id> HubSpot app id (omit to pick interactively)
|
|
125
222
|
--app-name <name> Override the app name recorded in migration.json
|
|
126
|
-
--out <file> Output file (default: migration.json)
|
|
223
|
+
--out <file> Output file (default: migration.json; existing files are never overwritten)
|
|
127
224
|
--cwd <dir> Resolve paths relative to <dir>
|
|
128
225
|
--json Emit JSON result (requires --app-id)
|
|
129
226
|
`;
|
|
130
227
|
case 'cutover':
|
|
131
228
|
return `hs-x migrate cutover [path]
|
|
132
229
|
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
230
|
+
Plan or perform the in-place cutover of the ORIGINAL app to 2026.03 —
|
|
231
|
+
keeping its app id, installs, OAuth grants, and listing — once the dupe has
|
|
232
|
+
proven the migration in \`hs-x dev\`.
|
|
136
233
|
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
marketplace listing) that gates execution.
|
|
140
|
-
|
|
234
|
+
Bare, it prints the DRY-RUN plan: the ordered steps, the one-way door (no
|
|
235
|
+
downgrade from 2026.03), and every open decision (external receivers,
|
|
236
|
+
endpoint contracts, marketplace listing) that gates execution.
|
|
237
|
+
|
|
238
|
+
With --execute it performs the cutover, once no open decisions remain. It is
|
|
239
|
+
a ONE-WAY change, so it asks you to type the project name to confirm (or pass
|
|
240
|
+
--yes in automation; a non-TTY run without --yes is refused). 2025.2 sources
|
|
241
|
+
take the config-only path (platformVersion bump + in-place re-upload, rolled
|
|
242
|
+
back if the build fails); 2023.2/2025.1 sources run HubSpot's interactive
|
|
243
|
+
\`hs project migrate\`, which needs a terminal to answer its feature prompts.
|
|
141
244
|
|
|
142
245
|
Arguments:
|
|
143
|
-
path The ORIGINAL legacy source
|
|
246
|
+
path The ORIGINAL legacy source. When omitted, terminals
|
|
247
|
+
discover workspace sources; scripts use the current directory.
|
|
144
248
|
|
|
145
249
|
Flags:
|
|
250
|
+
--execute Perform the cutover (default: dry-run plan only)
|
|
251
|
+
--yes Skip the type-to-confirm prompt (automation)
|
|
146
252
|
--json Emit the plan as JSON
|
|
147
253
|
--cwd <dir> Resolve paths relative to <dir>
|
|
148
254
|
`;
|
|
@@ -154,8 +260,8 @@ automatically, which need human review, and whether the replacement app needs
|
|
|
154
260
|
the Cloudflare runtime.
|
|
155
261
|
|
|
156
262
|
Arguments:
|
|
157
|
-
path
|
|
158
|
-
|
|
263
|
+
path A project directory (hsproject.json tree), a
|
|
264
|
+
migration.json file, or a directory containing one
|
|
159
265
|
|
|
160
266
|
Flags:
|
|
161
267
|
--cwd <dir> Resolve <path> relative to <dir>
|
|
@@ -170,8 +276,8 @@ Summarize migration readiness: detected features, automatable vs human-review
|
|
|
170
276
|
finding counts, runtime requirements, and suggested next steps.
|
|
171
277
|
|
|
172
278
|
Arguments:
|
|
173
|
-
path
|
|
174
|
-
|
|
279
|
+
path A project directory (hsproject.json tree), a
|
|
280
|
+
migration.json file, or a directory containing one
|
|
175
281
|
|
|
176
282
|
Flags:
|
|
177
283
|
--cwd <dir> Resolve <path> relative to <dir>
|
|
@@ -185,8 +291,8 @@ input, then validate the generated project. Fails (exit 1) if the input still
|
|
|
185
291
|
has human-review findings — resolve those first (see hs-x migrate inspect).
|
|
186
292
|
|
|
187
293
|
Arguments:
|
|
188
|
-
path
|
|
189
|
-
|
|
294
|
+
path A project directory (hsproject.json tree), a
|
|
295
|
+
migration.json file, or a directory containing one
|
|
190
296
|
|
|
191
297
|
Flags:
|
|
192
298
|
--project <dir> Output directory (default: migrated-app)
|
|
@@ -238,7 +344,9 @@ function readMigrationInputEff(path) {
|
|
|
238
344
|
return Effect.gen(function* () {
|
|
239
345
|
const fs = yield* FileSystem.FileSystem;
|
|
240
346
|
const pathSvc = yield* Path.Path;
|
|
241
|
-
const info = yield* fs
|
|
347
|
+
const info = yield* fs
|
|
348
|
+
.stat(path)
|
|
349
|
+
.pipe(Effect.mapError((cause) => cliError('HSX_E_INTERNAL', `Could not read ${path}: ${String(cause)}`)));
|
|
242
350
|
if (info.type === 'Directory') {
|
|
243
351
|
const fixture = pathSvc.join(path, 'migration.json');
|
|
244
352
|
const hasFixture = yield* fs.exists(fixture).pipe(Effect.orElseSucceed(() => false));
|
|
@@ -258,7 +366,9 @@ function readMigrationInputEff(path) {
|
|
|
258
366
|
}
|
|
259
367
|
}
|
|
260
368
|
const file = info.type === 'Directory' ? pathSvc.join(path, 'migration.json') : path;
|
|
261
|
-
const contents = yield* fs
|
|
369
|
+
const contents = yield* fs
|
|
370
|
+
.readFileString(file)
|
|
371
|
+
.pipe(Effect.mapError((cause) => cliError('HSX_E_INTERNAL', `Could not read ${file}: ${String(cause)}`)));
|
|
262
372
|
return yield* Effect.try({
|
|
263
373
|
try: () => JSON.parse(contents),
|
|
264
374
|
catch: (cause) => cliError('HSX_E_INPUT_BAD_JSON', `Invalid JSON in ${file}: ${String(cause)}`),
|
|
@@ -272,9 +382,30 @@ function writeProjectFilesEff(root, files) {
|
|
|
272
382
|
const pathSvc = yield* Path.Path;
|
|
273
383
|
for (const [file, contents] of Object.entries(files)) {
|
|
274
384
|
const target = pathSvc.join(root, file);
|
|
275
|
-
yield* fs
|
|
276
|
-
|
|
385
|
+
yield* fs
|
|
386
|
+
.makeDirectory(pathSvc.dirname(target), { recursive: true })
|
|
387
|
+
.pipe(Effect.mapError((cause) => cliError('HSX_E_INTERNAL', `Could not create directory for ${target}: ${String(cause)}`)));
|
|
388
|
+
yield* fs
|
|
389
|
+
.writeFileString(target, contents, { flag: 'wx' })
|
|
390
|
+
.pipe(Effect.mapError((cause) => cliError('HSX_E_INTERNAL', `Could not write ${target}: ${String(cause)}`)));
|
|
391
|
+
}
|
|
392
|
+
});
|
|
393
|
+
}
|
|
394
|
+
function resolveMigrationCommandPath(input) {
|
|
395
|
+
return Effect.gen(function* () {
|
|
396
|
+
if (Option.isSome(input.path))
|
|
397
|
+
return input.path.value;
|
|
398
|
+
const cwd = yield* Cwd;
|
|
399
|
+
const reporter = yield* Reporter;
|
|
400
|
+
const root = Option.getOrElse(input.cwd, () => cwd);
|
|
401
|
+
if (input.json || reporter.mode === 'ndjson' || !isInteractive())
|
|
402
|
+
return '.';
|
|
403
|
+
const selected = yield* Effect.promise(() => selectLocalMigrationSource(root));
|
|
404
|
+
if (selected === undefined || selected === BACK) {
|
|
405
|
+
yield* reporter.info('Cancelled.');
|
|
406
|
+
return yield* Effect.fail(exitWith(130));
|
|
277
407
|
}
|
|
408
|
+
return selected ?? '.';
|
|
278
409
|
});
|
|
279
410
|
}
|
|
280
411
|
function migrateInspectBody(opts) {
|
|
@@ -435,12 +566,14 @@ function migrateRunBody(opts) {
|
|
|
435
566
|
const outputRoot = pathSvc.resolve(root, Option.getOrUndefined(opts.out) ?? 'migrated-app');
|
|
436
567
|
yield* writeProjectFilesEff(outputRoot, generated.files);
|
|
437
568
|
const validation = yield* Effect.promise(() => validateProject({ root: outputRoot }));
|
|
569
|
+
const outstandingWork = migrationOutstandingWork(generated.files);
|
|
438
570
|
const response = {
|
|
439
571
|
ok: validation.ok,
|
|
440
572
|
command: 'migrate run',
|
|
441
573
|
outputRoot,
|
|
442
574
|
createdFiles: Object.keys(generated.files).sort(),
|
|
443
575
|
inspect: result,
|
|
576
|
+
outstandingWork,
|
|
444
577
|
validation,
|
|
445
578
|
};
|
|
446
579
|
if (opts.json) {
|
|
@@ -450,9 +583,16 @@ function migrateRunBody(opts) {
|
|
|
450
583
|
yield* reporter.header(pathSvc.basename(outputRoot));
|
|
451
584
|
const genStep = yield* reporter.step('Generated migrated HS-X project (the dupe)');
|
|
452
585
|
yield* genStep.ok(`${response.createdFiles.length} files ${outputRoot}`);
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
586
|
+
const scaffoldedStubs = result.automatable.filter((finding) => finding.code === 'serverless.app-function.portable');
|
|
587
|
+
const portedAutomatically = result.automatable.filter((finding) => finding.code !== 'serverless.app-function.portable');
|
|
588
|
+
if (portedAutomatically.length > 0) {
|
|
589
|
+
yield* reporter.info('Ported automatically:');
|
|
590
|
+
for (const finding of portedAutomatically)
|
|
591
|
+
yield* reporter.info(` - ${finding.message}`);
|
|
592
|
+
}
|
|
593
|
+
if (scaffoldedStubs.length > 0) {
|
|
594
|
+
yield* reporter.info('Scaffolded (stub — port body):');
|
|
595
|
+
for (const finding of scaffoldedStubs)
|
|
456
596
|
yield* reporter.info(` - ${finding.message}`);
|
|
457
597
|
}
|
|
458
598
|
if (result.manualWork.length > 0) {
|
|
@@ -471,11 +611,22 @@ function migrateRunBody(opts) {
|
|
|
471
611
|
yield* reporter.error(d.code ?? 'diagnostic', d.message ?? JSON.stringify(d));
|
|
472
612
|
}
|
|
473
613
|
}
|
|
614
|
+
if (outstandingWork.total > 0) {
|
|
615
|
+
yield* reporter.info('');
|
|
616
|
+
yield* reporter.info(`Outstanding migration work: ${outstandingWork.todoMarkers} TODO marker(s), ${outstandingWork.frontendServerlessCalls} frontend serverless call(s) (${outstandingWork.total} total).`);
|
|
617
|
+
yield* reporter.info('The dupe is scaffolded for testing; it is not production-ready yet.');
|
|
618
|
+
}
|
|
474
619
|
yield* reporter.info('');
|
|
475
620
|
yield* reporter.info('Next steps:');
|
|
476
621
|
yield* reporter.info(` cd ${pathSvc.relative(root, outputRoot) || '.'} && bun install`);
|
|
622
|
+
if (opts.guided) {
|
|
623
|
+
yield* reporter.info(' hs-x check # validate the generated project again after edits');
|
|
624
|
+
}
|
|
477
625
|
yield* reporter.info(' hs-x dev # test every migrated capability locally');
|
|
478
626
|
yield* reporter.info(' hs-x deploy # upload the dupe as a NET-NEW app when ready');
|
|
627
|
+
if (opts.guided && opts.originalSource) {
|
|
628
|
+
yield* reporter.info(` hs-x migrate cutover ${opts.originalSource} # review the dry-run cutover plan`);
|
|
629
|
+
}
|
|
479
630
|
yield* reporter.done(validation.ok ? 'Migrated' : 'Failed', validation.ok ? 0 : 1);
|
|
480
631
|
}
|
|
481
632
|
if (!validation.ok) {
|
|
@@ -570,7 +721,7 @@ function swapCardsArgv(opts) {
|
|
|
570
721
|
argv.push('--user-id', opts.userId.value);
|
|
571
722
|
return argv;
|
|
572
723
|
}
|
|
573
|
-
const migrateRunPathArg = Args.text({ name: 'path' }).pipe(Args.
|
|
724
|
+
const migrateRunPathArg = Args.text({ name: 'path' }).pipe(Args.optional);
|
|
574
725
|
const migrateOutOption = Options.text('out').pipe(Options.optional);
|
|
575
726
|
/**
|
|
576
727
|
* `hs-x migrate cutover [path]` — DRY-RUN ONLY today (locked decision #2:
|
|
@@ -579,43 +730,134 @@ const migrateOutOption = Options.text('out').pipe(Options.optional);
|
|
|
579
730
|
* in place, plus the open decisions gating execution.
|
|
580
731
|
*/
|
|
581
732
|
/**
|
|
582
|
-
*
|
|
583
|
-
*
|
|
584
|
-
*
|
|
585
|
-
*
|
|
586
|
-
*
|
|
587
|
-
*
|
|
733
|
+
* Not every open decision blocks an in-place cutover. Cutover re-uploads the
|
|
734
|
+
* SAME source under a new platform version (2025.2), or runs HubSpot's own
|
|
735
|
+
* `hs project migrate` (2023.2/2025.1), so "keep forwarding?" / "confirm this
|
|
736
|
+
* surface" findings are advisory — surfaced, then covered by the type-to-confirm.
|
|
737
|
+
* Only findings that mean the source is missing something required (a URL,
|
|
738
|
+
* entrypoint, or label) or carries an unsupported/empty component genuinely
|
|
739
|
+
* block: those can't be resolved by proceeding.
|
|
740
|
+
*/
|
|
741
|
+
function isBlockingCutoverDecision(code) {
|
|
742
|
+
return (code === 'migration.input.unsupported' ||
|
|
743
|
+
code.startsWith('component.unsupported.') ||
|
|
744
|
+
code.endsWith('.missing') ||
|
|
745
|
+
code.endsWith('.empty'));
|
|
746
|
+
}
|
|
747
|
+
/**
|
|
748
|
+
* `confirmCutover` — the human checkpoint. Cutover is a one-way, in-place
|
|
749
|
+
* change to the ORIGINAL app, so execution requires the operator to type the
|
|
750
|
+
* project name back (or pass --yes in automation). It never runs unattended by
|
|
751
|
+
* default: a non-TTY run without --yes is refused rather than assumed.
|
|
752
|
+
*/
|
|
753
|
+
function confirmCutover(input) {
|
|
754
|
+
const { reporter, projectName, yes } = input;
|
|
755
|
+
return Effect.gen(function* () {
|
|
756
|
+
if (yes) {
|
|
757
|
+
yield* reporter.info(`--yes: skipping the type-to-confirm for "${projectName}".`);
|
|
758
|
+
return;
|
|
759
|
+
}
|
|
760
|
+
if (!isInteractive()) {
|
|
761
|
+
return yield* Effect.fail(cliError('HSX_E_CUTOVER_CONFIRM_REQUIRED', 'Cutover is a one-way, in-place change to the original app. Re-run in an interactive terminal to confirm, or pass --yes to skip the prompt in automation.'));
|
|
762
|
+
}
|
|
763
|
+
yield* reporter.info(`ONE-WAY: "${projectName}" cannot downgrade from 2026.03 after cutover.`);
|
|
764
|
+
const answer = yield* Effect.tryPromise({
|
|
765
|
+
try: () => promptText({ message: 'Type the project name to confirm cutover' }),
|
|
766
|
+
catch: () => cliError('HSX_E_INTERNAL', 'Could not read confirmation input.'),
|
|
767
|
+
});
|
|
768
|
+
if (answer === undefined || answer.trim() !== projectName) {
|
|
769
|
+
return yield* Effect.fail(cliError('HSX_E_CUTOVER_CONFIRM_MISMATCH', `Confirmation "${answer?.trim() ?? ''}" did not match the project name "${projectName}". Cutover aborted; nothing changed.`));
|
|
770
|
+
}
|
|
771
|
+
});
|
|
772
|
+
}
|
|
773
|
+
/**
|
|
774
|
+
* Cutover EXECUTE: migrates the ORIGINAL app in place to 2026.03, keeping its
|
|
775
|
+
* app id, installs, OAuth grants, and listing — a one-way door. Gated by (1) no
|
|
776
|
+
* open decisions in the plan, (2) an explicit human confirmation (type the
|
|
777
|
+
* project name, or --yes), and (3) a supported source era:
|
|
778
|
+
* • 2025.2 — config-only: bump platformVersion → 2026.03 and re-upload in
|
|
779
|
+
* place (rolled back if the build fails).
|
|
780
|
+
* • 2023.2 / 2025.1 — HubSpot's own interactive `hs project migrate` (needs a
|
|
781
|
+
* real terminal to answer its feature-confirmation prompts).
|
|
782
|
+
* Non-project legacy apps (`hs app migrate`) are out of scope for cutover.
|
|
588
783
|
*/
|
|
589
|
-
const CUTOVER_REHEARSAL_DEVELOPER_ACCOUNT = '46993937';
|
|
590
784
|
function executeCutover(input) {
|
|
591
785
|
const { opts, sourcePath, reporter } = input;
|
|
592
786
|
return Effect.gen(function* () {
|
|
593
787
|
const fs = yield* FileSystem.FileSystem;
|
|
594
788
|
const developerAccountId = process.env.HSX_HUBSPOT_DEVELOPER_ACCOUNT_ID ?? '';
|
|
595
|
-
if (developerAccountId !== CUTOVER_REHEARSAL_DEVELOPER_ACCOUNT) {
|
|
596
|
-
return yield* Effect.fail(cliError('HSX_E_CUTOVER_EXECUTE_GATED', `Cutover execution is authorized only for throwaway apps on developer account ${CUTOVER_REHEARSAL_DEVELOPER_ACCOUNT} (got ${developerAccountId || 'none'}). Real-app cutover needs an explicit per-app human go.`));
|
|
597
|
-
}
|
|
598
789
|
const detected = yield* Effect.tryPromise({
|
|
599
790
|
try: () => detectMigrationSource(sourcePath),
|
|
600
791
|
catch: (cause) => cliError('HSX_E_INTERNAL', `Could not inspect ${sourcePath}: ${String(cause)}`),
|
|
601
792
|
});
|
|
602
793
|
const classification = classifyMigrationInput(detected.input);
|
|
603
794
|
const plan = planCutover(detected, classification);
|
|
604
|
-
|
|
605
|
-
|
|
795
|
+
const blockingDecisions = plan.openDecisions.filter((decision) => isBlockingCutoverDecision(decision.code));
|
|
796
|
+
const advisoryDecisions = plan.openDecisions.filter((decision) => !isBlockingCutoverDecision(decision.code));
|
|
797
|
+
if (blockingDecisions.length > 0) {
|
|
798
|
+
return yield* Effect.fail(cliError('HSX_E_CUTOVER_EXECUTE_GATED', `${blockingDecisions.length} blocking decision(s) prevent cutover: ${blockingDecisions
|
|
606
799
|
.map((decision) => decision.code)
|
|
607
|
-
.join(', ')}.
|
|
800
|
+
.join(', ')}. The source is missing something cutover needs (a URL, entrypoint, or label) or carries an unsupported component — fix it in the source, re-run the dry-run, then execute.`));
|
|
608
801
|
}
|
|
609
|
-
|
|
610
|
-
|
|
802
|
+
// Advisory findings don't block an in-place cutover; surface them so the
|
|
803
|
+
// operator sees exactly what they're acknowledging at the confirm prompt.
|
|
804
|
+
for (const decision of advisoryDecisions) {
|
|
805
|
+
yield* reporter.info(`advisory [${decision.code}]: ${decision.message}`);
|
|
611
806
|
}
|
|
612
|
-
//
|
|
613
|
-
// the original tree (same project name => same app id), watch the build.
|
|
807
|
+
// Cutover execute operates on the ORIGINAL project tree.
|
|
614
808
|
const projectConfigPath = `${sourcePath}/hsproject.json`;
|
|
615
809
|
const rawProject = yield* fs
|
|
616
810
|
.readFileString(projectConfigPath)
|
|
617
|
-
.pipe(Effect.mapError(() => cliError('
|
|
811
|
+
.pipe(Effect.mapError(() => cliError('HSX_E_CUTOVER_EXECUTE_GATED', `Cutover execute needs the original project tree (hsproject.json) at ${sourcePath}; a migration.json input alone is not enough. Point at the ORIGINAL legacy app's source.`)));
|
|
618
812
|
const projectJson = JSON.parse(rawProject);
|
|
813
|
+
const projectName = typeof projectJson.name === 'string' && projectJson.name.length > 0
|
|
814
|
+
? projectJson.name
|
|
815
|
+
: 'migrated-app';
|
|
816
|
+
const yes = opts.yes ?? false;
|
|
817
|
+
// 2023.2 / 2025.1 → HubSpot's own interactive `hs project migrate`. It
|
|
818
|
+
// prompts to confirm the features it migrates, so it needs a real terminal;
|
|
819
|
+
// HS-X gates entry, then hands the wheel to HubSpot's CLI (inherited stdio).
|
|
820
|
+
if (plan.sourceVersion === '2023.2' || plan.sourceVersion === '2025.1') {
|
|
821
|
+
if (!isInteractive()) {
|
|
822
|
+
return yield* Effect.fail(cliError('HSX_E_CUTOVER_CONFIRM_REQUIRED', `Cutover for a ${plan.sourceVersion} project runs HubSpot's interactive \`hs project migrate\`, which prompts to confirm the features it migrates. Run it in an interactive terminal — --yes skips HS-X's own confirmation but cannot answer HubSpot's prompts.`));
|
|
823
|
+
}
|
|
824
|
+
yield* confirmCutover({ reporter, projectName, yes });
|
|
825
|
+
yield* reporter.info("Running HubSpot's project migration in place (hs project migrate → 2026.03). Answer its feature prompts to complete the one-way migration…");
|
|
826
|
+
const migrateExit = yield* Effect.tryPromise({
|
|
827
|
+
try: async () => {
|
|
828
|
+
const proc = Bun.spawn({
|
|
829
|
+
cmd: [
|
|
830
|
+
'hs',
|
|
831
|
+
'project',
|
|
832
|
+
'migrate',
|
|
833
|
+
'--platform-version',
|
|
834
|
+
'2026.03',
|
|
835
|
+
...(developerAccountId.length > 0 ? ['--account', developerAccountId] : []),
|
|
836
|
+
],
|
|
837
|
+
cwd: sourcePath,
|
|
838
|
+
env: { ...process.env },
|
|
839
|
+
stdin: 'inherit',
|
|
840
|
+
stdout: 'inherit',
|
|
841
|
+
stderr: 'inherit',
|
|
842
|
+
});
|
|
843
|
+
return await proc.exited;
|
|
844
|
+
},
|
|
845
|
+
catch: (cause) => cliError('HSX_E_CUTOVER_EXECUTE_FAILED', `Could not run \`hs project migrate\` (${String(cause)}). Cutover drives HubSpot's own CLI — install it (npm i -g @hubspot/cli) and authenticate (hs accounts auth).`),
|
|
846
|
+
});
|
|
847
|
+
if (migrateExit !== 0) {
|
|
848
|
+
return yield* Effect.fail(cliError('HSX_E_CUTOVER_EXECUTE_FAILED', `\`hs project migrate\` exited ${migrateExit}; the original app was not migrated (a failed or cancelled migration leaves your project as HubSpot found it).`));
|
|
849
|
+
}
|
|
850
|
+
yield* reporter.info(`Original ${plan.sourceVersion} app migrated to 2026.03 — app id, installs, and OAuth grants preserved.`);
|
|
851
|
+
yield* reporter.info('Note: serverless env vars declared via serverless.json `environment` are not migrated automatically — redefine them as secrets with `hs secret add`.');
|
|
852
|
+
yield* reporter.info('Next: verify capabilities, then deprecate/delete the dupe.');
|
|
853
|
+
return yield* reporter.done('Cutover executed');
|
|
854
|
+
}
|
|
855
|
+
if (plan.sourceVersion !== '2025.2') {
|
|
856
|
+
return yield* Effect.fail(cliError('HSX_E_CUTOVER_EXECUTE_GATED', `Cutover execute covers project-based 2023.2, 2025.1, and 2025.2 sources; this source resolves to "${plan.sourceVersion}". A non-project legacy app migrates with HubSpot's own interactive \`hs app migrate\`, which cutover does not wrap.`));
|
|
857
|
+
}
|
|
858
|
+
// 2025.2 config-only execute: bump platformVersion IN PLACE, re-upload
|
|
859
|
+
// the original tree (same project name => same app id), watch the build.
|
|
860
|
+
yield* confirmCutover({ reporter, projectName, yes });
|
|
619
861
|
const previousVersion = projectJson.platformVersion;
|
|
620
862
|
projectJson.platformVersion = '2026.03';
|
|
621
863
|
yield* fs
|
|
@@ -646,8 +888,7 @@ function executeCutover(input) {
|
|
|
646
888
|
catch: (cause) => cliError('HSX_E_CUTOVER_EXECUTE_FAILED', `Could not run \`hs project upload\` (${String(cause)}). Cutover drives HubSpot's own CLI — install it (npm i -g @hubspot/cli) and authenticate (hs accounts auth).`),
|
|
647
889
|
});
|
|
648
890
|
const uploadOutput = `${result.stdout}\n${result.stderr}`;
|
|
649
|
-
const buildLine = /Build #(\d+) succeeded/.exec(uploadOutput) ??
|
|
650
|
-
/Built [\w-]+ #(\d+)/.exec(uploadOutput);
|
|
891
|
+
const buildLine = /Build #(\d+) succeeded/.exec(uploadOutput) ?? /Built [\w-]+ #(\d+)/.exec(uploadOutput);
|
|
651
892
|
if (result.exitCode !== 0 || !buildLine) {
|
|
652
893
|
// Roll the version bump back so a failed execute leaves the tree as found.
|
|
653
894
|
projectJson.platformVersion = previousVersion;
|
|
@@ -693,31 +934,29 @@ function migrateCutoverBody(opts) {
|
|
|
693
934
|
continue;
|
|
694
935
|
yield* reporter.info(`${phase.toUpperCase()}:`);
|
|
695
936
|
for (const step of phaseSteps) {
|
|
696
|
-
|
|
937
|
+
const tag = step.gatedBy
|
|
938
|
+
? ` [${isBlockingCutoverDecision(step.gatedBy) ? 'blocks' : 'confirm'}: ${step.gatedBy}]`
|
|
939
|
+
: '';
|
|
940
|
+
yield* reporter.info(` - ${step.title}${tag}`);
|
|
697
941
|
yield* reporter.info(` ${step.detail}`);
|
|
698
942
|
}
|
|
699
943
|
yield* reporter.info('');
|
|
700
944
|
}
|
|
701
945
|
if (plan.openDecisions.length > 0) {
|
|
702
|
-
|
|
946
|
+
const blockingCount = plan.openDecisions.filter((decision) => isBlockingCutoverDecision(decision.code)).length;
|
|
947
|
+
yield* reporter.info(blockingCount > 0
|
|
948
|
+
? `${blockingCount} of ${plan.openDecisions.length} decision(s) BLOCK execution (missing/unsupported) — fix them in the source, then re-run with --execute. The rest are advisory confirmations.`
|
|
949
|
+
: `${plan.openDecisions.length} advisory decision(s) to confirm; none block. Re-run with --execute to perform the one-way, in-place cutover — you confirm by typing the project name (or pass --yes in automation).`);
|
|
703
950
|
}
|
|
704
951
|
else {
|
|
705
|
-
yield* reporter.info('No open decisions.
|
|
952
|
+
yield* reporter.info('No open decisions. Re-run with --execute to perform the one-way, in-place cutover — you confirm by typing the project name (or pass --yes in automation).');
|
|
706
953
|
}
|
|
707
954
|
yield* reporter.done('Planned');
|
|
708
955
|
});
|
|
709
956
|
}
|
|
710
957
|
const migrateExecuteOption = Options.boolean('execute').pipe(Options.withDefault(false));
|
|
711
|
-
const
|
|
712
|
-
|
|
713
|
-
* `hs-x migrate pull --app-id <id>` — live-app pull (locked decision #4,
|
|
714
|
-
* live half): fetch a UI-configured legacy app's definition via HubSpot
|
|
715
|
-
* APIs (PAK auth) and write the migration.json input the classifier and
|
|
716
|
-
* generator already consume. Endpoints grounded 2026-06-10:
|
|
717
|
-
* cards GET /crm/extensions/cards-dev/2026-03/{appId}; webhooks
|
|
718
|
-
* GET /webhooks/v3/{appId}/settings + /subscriptions; workflow actions
|
|
719
|
-
* GET /automation/v4/actions/{appId}.
|
|
720
|
-
*/
|
|
958
|
+
const migrateYesOption = Options.boolean('yes').pipe(Options.withDefault(false));
|
|
959
|
+
const migrateCutoverPathArg = Args.text({ name: 'path' }).pipe(Args.optional);
|
|
721
960
|
function migratePullBody(opts) {
|
|
722
961
|
return Effect.gen(function* () {
|
|
723
962
|
const cwd = yield* Cwd;
|
|
@@ -725,6 +964,11 @@ function migratePullBody(opts) {
|
|
|
725
964
|
const fs = yield* FileSystem.FileSystem;
|
|
726
965
|
const reporter = yield* Reporter;
|
|
727
966
|
const root = Option.getOrElse(opts.cwd, () => cwd);
|
|
967
|
+
const outPath = pathSvc.resolve(root, opts.out);
|
|
968
|
+
const outputExists = yield* fs.exists(outPath).pipe(Effect.orElseSucceed(() => false));
|
|
969
|
+
if (outputExists) {
|
|
970
|
+
return yield* Effect.fail(cliError('HSX_E_INPUT_BAD_FIELD', `${outPath} already exists; refusing to overwrite migration input. Choose another file with --out <file> or move the existing file.`));
|
|
971
|
+
}
|
|
728
972
|
const personalAccessKey = process.env.HSX_HUBSPOT_PAK;
|
|
729
973
|
const portalId = process.env.HSX_HUBSPOT_DEVELOPER_ACCOUNT_ID;
|
|
730
974
|
if (!personalAccessKey || !portalId) {
|
|
@@ -820,10 +1064,11 @@ function migratePullBody(opts) {
|
|
|
820
1064
|
},
|
|
821
1065
|
catch: (cause) => cliError('HSX_E_INTERNAL', `Live pull failed: ${cause instanceof Error ? cause.message : String(cause)}`),
|
|
822
1066
|
});
|
|
1067
|
+
const appName = Option.getOrElse(opts.appName, () => pickedAppName ?? `app-${appId}`);
|
|
823
1068
|
const input = {
|
|
824
1069
|
legacyApp: {
|
|
825
1070
|
appId,
|
|
826
|
-
name:
|
|
1071
|
+
name: appName,
|
|
827
1072
|
portalId,
|
|
828
1073
|
},
|
|
829
1074
|
};
|
|
@@ -831,8 +1076,8 @@ function migratePullBody(opts) {
|
|
|
831
1076
|
const cardResults = isRecord(pulled.cards) && Array.isArray(pulled.cards.results)
|
|
832
1077
|
? pulled.cards.results.filter(isRecord)
|
|
833
1078
|
: [];
|
|
834
|
-
|
|
835
|
-
|
|
1079
|
+
const card = cardResults[0];
|
|
1080
|
+
if (card) {
|
|
836
1081
|
const title = isRecord(card.title) ? '' : String(card.title ?? '');
|
|
837
1082
|
const fetchData = isRecord(card.fetch) ? card.fetch : {};
|
|
838
1083
|
input.legacyCard = {
|
|
@@ -885,21 +1130,24 @@ function migratePullBody(opts) {
|
|
|
885
1130
|
}));
|
|
886
1131
|
detected.push(`workflow actions ×${actionResults.length}`);
|
|
887
1132
|
}
|
|
888
|
-
const outPath = pathSvc.resolve(root, opts.out);
|
|
889
1133
|
yield* fs
|
|
890
|
-
.writeFileString(outPath, `${JSON.stringify(input, null, 2)}\n
|
|
891
|
-
.pipe(Effect.mapError(() => cliError('HSX_E_INTERNAL', `Could not
|
|
1134
|
+
.writeFileString(outPath, `${JSON.stringify(input, null, 2)}\n`, { flag: 'wx' })
|
|
1135
|
+
.pipe(Effect.mapError(() => cliError('HSX_E_INTERNAL', `Could not create ${outPath}; it may already exist. Choose another file with --out <file>.`)));
|
|
1136
|
+
const result = { appId, appName, outPath };
|
|
892
1137
|
if (opts.json) {
|
|
893
1138
|
yield* reporter.raw(`${JSON.stringify({ ok: true, command: 'migrate pull', appId, out: outPath, detected }, null, 2)}\n`);
|
|
894
|
-
return;
|
|
1139
|
+
return result;
|
|
895
1140
|
}
|
|
896
1141
|
yield* reporter.header(`pulled app ${appId}`);
|
|
897
1142
|
for (const warning of pulled.warnings) {
|
|
898
1143
|
yield* reporter.info(`note: ${warning}`);
|
|
899
1144
|
}
|
|
900
|
-
yield* reporter.info(detected.length > 0
|
|
1145
|
+
yield* reporter.info(detected.length > 0
|
|
1146
|
+
? `Detected: ${detected.join(', ')}`
|
|
1147
|
+
: 'Nothing portable detected on this app.');
|
|
901
1148
|
yield* reporter.info(`Wrote ${outPath} — next: hs-x migrate run ${pathSvc.relative(cwd, outPath) || opts.out}`);
|
|
902
1149
|
yield* reporter.done('Pulled');
|
|
1150
|
+
return result;
|
|
903
1151
|
});
|
|
904
1152
|
}
|
|
905
1153
|
/**
|
|
@@ -944,6 +1192,173 @@ async function fetchMigratableApps(accessToken, portalId) {
|
|
|
944
1192
|
}
|
|
945
1193
|
return out;
|
|
946
1194
|
}
|
|
1195
|
+
function migrateCancelled(reporter) {
|
|
1196
|
+
return Effect.gen(function* () {
|
|
1197
|
+
yield* reporter.info('Cancelled.');
|
|
1198
|
+
return yield* Effect.fail(exitWith(130));
|
|
1199
|
+
});
|
|
1200
|
+
}
|
|
1201
|
+
function renderGuidedPreview(input, sourcePath, outputPath) {
|
|
1202
|
+
const classification = classifyMigrationInput(input);
|
|
1203
|
+
let files = 0;
|
|
1204
|
+
try {
|
|
1205
|
+
files = Object.keys(generateMigratedHsxProject(input).files).length;
|
|
1206
|
+
}
|
|
1207
|
+
catch {
|
|
1208
|
+
// The existing run body owns the detailed generation failure. The preview
|
|
1209
|
+
// still gives the user the classification before asking for consent.
|
|
1210
|
+
}
|
|
1211
|
+
return `${renderMigrationInspect(classification).trimEnd()}\nPreview: generate ${files || 'no'} file${files === 1 ? '' : 's'} from ${sourcePath} into ${outputPath}.`;
|
|
1212
|
+
}
|
|
1213
|
+
function migrateRootBody(opts, deps = liveMigratePromptDeps) {
|
|
1214
|
+
return Effect.gen(function* () {
|
|
1215
|
+
const cwd = yield* Cwd;
|
|
1216
|
+
const pathSvc = yield* Path.Path;
|
|
1217
|
+
const fs = yield* FileSystem.FileSystem;
|
|
1218
|
+
const reporter = yield* Reporter;
|
|
1219
|
+
if (opts.json || reporter.mode === 'ndjson' || !deps.isInteractive()) {
|
|
1220
|
+
return yield* Effect.fail(cliError('HSX_E_INPUT_MISSING_VALUE', 'Bare `hs-x migrate` is a terminal guide. For automation use `hs-x migrate run <path> --out <dir>`, `hs-x migrate pull --app-id <id> --out <file>`, or `hs-x migrate cutover <path>`.'));
|
|
1221
|
+
}
|
|
1222
|
+
const root = Option.getOrElse(opts.cwd, () => cwd);
|
|
1223
|
+
yield* reporter.banner;
|
|
1224
|
+
yield* reporter.header('guided migration');
|
|
1225
|
+
for (;;) {
|
|
1226
|
+
const journey = yield* Effect.promise(() => selectMigrateJourney(deps));
|
|
1227
|
+
if (!journey)
|
|
1228
|
+
return yield* migrateCancelled(reporter);
|
|
1229
|
+
if (journey === 'cutover') {
|
|
1230
|
+
const suggestedSource = yield* Effect.promise(() => selectLocalMigrationSource(root, deps));
|
|
1231
|
+
if (suggestedSource === BACK)
|
|
1232
|
+
continue;
|
|
1233
|
+
if (suggestedSource === undefined)
|
|
1234
|
+
return yield* migrateCancelled(reporter);
|
|
1235
|
+
const source = yield* Effect.promise(() => deps.text({
|
|
1236
|
+
message: 'Original legacy source',
|
|
1237
|
+
default: suggestedSource ?? '.',
|
|
1238
|
+
allowBack: true,
|
|
1239
|
+
}));
|
|
1240
|
+
if (source === BACK)
|
|
1241
|
+
continue;
|
|
1242
|
+
if (source === undefined)
|
|
1243
|
+
return yield* migrateCancelled(reporter);
|
|
1244
|
+
return yield* migrateCutoverBody({
|
|
1245
|
+
path: source,
|
|
1246
|
+
cwd: Option.some(root),
|
|
1247
|
+
json: false,
|
|
1248
|
+
execute: false,
|
|
1249
|
+
yes: false,
|
|
1250
|
+
});
|
|
1251
|
+
}
|
|
1252
|
+
if (journey === 'live') {
|
|
1253
|
+
let pullOut = 'migration.json';
|
|
1254
|
+
for (;;) {
|
|
1255
|
+
const answer = yield* Effect.promise(() => deps.text({
|
|
1256
|
+
message: 'Save the pulled migration input as',
|
|
1257
|
+
default: pullOut,
|
|
1258
|
+
allowBack: true,
|
|
1259
|
+
}));
|
|
1260
|
+
if (answer === BACK)
|
|
1261
|
+
break;
|
|
1262
|
+
if (answer === undefined)
|
|
1263
|
+
return yield* migrateCancelled(reporter);
|
|
1264
|
+
pullOut = answer;
|
|
1265
|
+
const pullPath = pathSvc.resolve(root, pullOut);
|
|
1266
|
+
if (yield* fs.exists(pullPath).pipe(Effect.orElseSucceed(() => false))) {
|
|
1267
|
+
yield* reporter.warn('HSX_E_INPUT_BAD_FIELD', `${pullPath} already exists. Choose another file; HS-X never overwrites migration input.`);
|
|
1268
|
+
continue;
|
|
1269
|
+
}
|
|
1270
|
+
yield* reporter.info(`Preview: choose an eligible app from HubSpot and write its portable definition to ${pullPath}.`);
|
|
1271
|
+
const pullConfirmed = yield* Effect.promise(() => confirmMigrateWrite('Pull the live app and write this migration input?', deps));
|
|
1272
|
+
if (pullConfirmed === 'back')
|
|
1273
|
+
continue;
|
|
1274
|
+
if (pullConfirmed !== 'proceed')
|
|
1275
|
+
return yield* migrateCancelled(reporter);
|
|
1276
|
+
const pulled = yield* migratePullBody({
|
|
1277
|
+
appId: Option.none(),
|
|
1278
|
+
appName: Option.none(),
|
|
1279
|
+
out: pullOut,
|
|
1280
|
+
cwd: Option.some(root),
|
|
1281
|
+
json: false,
|
|
1282
|
+
});
|
|
1283
|
+
const input = yield* readMigrationInputEff(pulled.outPath);
|
|
1284
|
+
let output = 'migrated-app';
|
|
1285
|
+
for (;;) {
|
|
1286
|
+
const outputAnswer = yield* Effect.promise(() => deps.text({
|
|
1287
|
+
message: 'Generate the test dupe in',
|
|
1288
|
+
default: output,
|
|
1289
|
+
allowBack: true,
|
|
1290
|
+
}));
|
|
1291
|
+
if (outputAnswer === BACK)
|
|
1292
|
+
continue;
|
|
1293
|
+
if (outputAnswer === undefined)
|
|
1294
|
+
return yield* migrateCancelled(reporter);
|
|
1295
|
+
output = outputAnswer;
|
|
1296
|
+
const outputPath = pathSvc.resolve(root, output);
|
|
1297
|
+
yield* reporter.block(renderGuidedPreview(input, pulled.outPath, outputPath));
|
|
1298
|
+
const generateConfirmed = yield* Effect.promise(() => confirmMigrateWrite('Generate and validate this test dupe?', deps));
|
|
1299
|
+
if (generateConfirmed === 'back')
|
|
1300
|
+
continue;
|
|
1301
|
+
if (generateConfirmed !== 'proceed')
|
|
1302
|
+
return yield* migrateCancelled(reporter);
|
|
1303
|
+
return yield* migrateRunBody({
|
|
1304
|
+
path: pulled.outPath,
|
|
1305
|
+
out: Option.some(output),
|
|
1306
|
+
cwd: Option.some(root),
|
|
1307
|
+
json: false,
|
|
1308
|
+
guided: true,
|
|
1309
|
+
originalSource: pulled.outPath,
|
|
1310
|
+
});
|
|
1311
|
+
}
|
|
1312
|
+
}
|
|
1313
|
+
continue;
|
|
1314
|
+
}
|
|
1315
|
+
const suggestedSource = yield* Effect.promise(() => selectLocalMigrationSource(root, deps));
|
|
1316
|
+
if (suggestedSource === BACK)
|
|
1317
|
+
continue;
|
|
1318
|
+
if (suggestedSource === undefined)
|
|
1319
|
+
return yield* migrateCancelled(reporter);
|
|
1320
|
+
let source = suggestedSource ?? '.';
|
|
1321
|
+
for (;;) {
|
|
1322
|
+
const sourceAnswer = yield* Effect.promise(() => deps.text({ message: 'Local legacy source', default: source, allowBack: true }));
|
|
1323
|
+
if (sourceAnswer === BACK)
|
|
1324
|
+
break;
|
|
1325
|
+
if (sourceAnswer === undefined)
|
|
1326
|
+
return yield* migrateCancelled(reporter);
|
|
1327
|
+
source = sourceAnswer;
|
|
1328
|
+
const sourcePath = pathSvc.resolve(root, source);
|
|
1329
|
+
const input = yield* readMigrationInputEff(sourcePath);
|
|
1330
|
+
let output = 'migrated-app';
|
|
1331
|
+
for (;;) {
|
|
1332
|
+
const outputAnswer = yield* Effect.promise(() => deps.text({
|
|
1333
|
+
message: 'Generate the test dupe in',
|
|
1334
|
+
default: output,
|
|
1335
|
+
allowBack: true,
|
|
1336
|
+
}));
|
|
1337
|
+
if (outputAnswer === BACK)
|
|
1338
|
+
break;
|
|
1339
|
+
if (outputAnswer === undefined)
|
|
1340
|
+
return yield* migrateCancelled(reporter);
|
|
1341
|
+
output = outputAnswer;
|
|
1342
|
+
const outputPath = pathSvc.resolve(root, output);
|
|
1343
|
+
yield* reporter.block(renderGuidedPreview(input, sourcePath, outputPath));
|
|
1344
|
+
const confirmed = yield* Effect.promise(() => confirmMigrateWrite('Generate and validate this test dupe?', deps));
|
|
1345
|
+
if (confirmed === 'back')
|
|
1346
|
+
continue;
|
|
1347
|
+
if (confirmed !== 'proceed')
|
|
1348
|
+
return yield* migrateCancelled(reporter);
|
|
1349
|
+
return yield* migrateRunBody({
|
|
1350
|
+
path: source,
|
|
1351
|
+
out: Option.some(output),
|
|
1352
|
+
cwd: Option.some(root),
|
|
1353
|
+
json: false,
|
|
1354
|
+
guided: true,
|
|
1355
|
+
originalSource: source,
|
|
1356
|
+
});
|
|
1357
|
+
}
|
|
1358
|
+
}
|
|
1359
|
+
}
|
|
1360
|
+
});
|
|
1361
|
+
}
|
|
947
1362
|
const migratePullAppIdOption = Options.text('app-id').pipe(Options.optional);
|
|
948
1363
|
const migratePullNameOption = Options.text('app-name').pipe(Options.optional);
|
|
949
1364
|
const migratePullOutOption = Options.text('out').pipe(Options.withDefault('migration.json'));
|
|
@@ -953,10 +1368,22 @@ const migratePullCmd = Command.make('pull', {
|
|
|
953
1368
|
out: migratePullOutOption,
|
|
954
1369
|
cwd: cwdOption,
|
|
955
1370
|
json: jsonOption,
|
|
956
|
-
}, (opts) => runHandler('migrate', { json: opts.json }, migratePullBody(opts)));
|
|
957
|
-
const migrateCutoverCmd = Command.make('cutover', {
|
|
958
|
-
|
|
959
|
-
|
|
1371
|
+
}, (opts) => runHandler('migrate', { json: opts.json }, Effect.asVoid(migratePullBody(opts))));
|
|
1372
|
+
const migrateCutoverCmd = Command.make('cutover', {
|
|
1373
|
+
path: migrateCutoverPathArg,
|
|
1374
|
+
cwd: cwdOption,
|
|
1375
|
+
json: jsonOption,
|
|
1376
|
+
execute: migrateExecuteOption,
|
|
1377
|
+
yes: migrateYesOption,
|
|
1378
|
+
}, (opts) => runHandler('migrate', { json: opts.json }, Effect.gen(function* () {
|
|
1379
|
+
const path = yield* resolveMigrationCommandPath(opts);
|
|
1380
|
+
yield* migrateCutoverBody({ ...opts, path });
|
|
1381
|
+
})));
|
|
1382
|
+
const migrateRunCmd = Command.make('run', { path: migrateRunPathArg, out: migrateOutOption, cwd: cwdOption, json: jsonOption }, (opts) => runHandler('migrate', { json: opts.json }, Effect.gen(function* () {
|
|
1383
|
+
const path = yield* resolveMigrationCommandPath(opts);
|
|
1384
|
+
yield* migrateRunBody({ ...opts, path });
|
|
1385
|
+
})));
|
|
1386
|
+
export const migrateCmd = Command.make('migrate', { cwd: cwdOption, json: jsonOption }, (opts) => runHandler('migrate', { json: opts.json }, migrateRootBody(opts))).pipe(Command.withSubcommands([
|
|
960
1387
|
migrateRunCmd,
|
|
961
1388
|
migratePullCmd,
|
|
962
1389
|
migrateCutoverCmd,
|