@sonarsource/marketing-cli 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,454 @@
1
+ import { confirm, select, spinner } from '@clack/prompts';
2
+ import { join } from 'node:path';
3
+ import { detectBranch } from '../branch.js';
4
+ import { PROTECTED_BRANCHES } from '../branches.js';
5
+ import { isCI, resolveManagementKey } from '../ci.js';
6
+ import { loadConfig, updateConfig } from '../config.js';
7
+ import { assertNotCancelled, CliError } from '../errors.js';
8
+ import { createKontentClient } from '../kontent.js';
9
+ import { fetchBranchVars, resolveNetlifyToken } from '../netlify.js';
10
+ import { loadStatusJson, writeStatusJson } from '../status.js';
11
+ import { migrationRun } from './migration.js';
12
+ import { setup } from './setup.js';
13
+ // ---------------------------------------------------------------------------
14
+ // Helpers
15
+ // ---------------------------------------------------------------------------
16
+ /**
17
+ * Slugify a branch name for use as the Kontent clone name.
18
+ * Non-alphanumeric characters (except `-`) → `-`, consecutive `-` collapsed,
19
+ * leading/trailing `-` trimmed.
20
+ */
21
+ export function slugifyBranch(branch) {
22
+ return branch
23
+ .replace(/[^a-zA-Z0-9-]/g, '-')
24
+ .replace(/-{2,}/g, '-')
25
+ .replace(/^-+/, '')
26
+ .replace(/-+$/, '');
27
+ }
28
+ /**
29
+ * Source resolution — deterministic from the `longLivedBranches` config tree.
30
+ *
31
+ * 1. If branch is a child of a key → parent's `environmentId`
32
+ * 2. If branch is a key (not a child of another key) → production
33
+ * 3. Not in config at all → production
34
+ */
35
+ export function resolveSourceEnvironment(branch, config) {
36
+ const llb = config.kontent.longLivedBranches;
37
+ for (const [parentBranch, entry] of Object.entries(llb)) {
38
+ if (entry.children.includes(branch)) {
39
+ if (!entry.environmentId) {
40
+ throw new CliError(`Parent branch "${parentBranch}" has no environment. ` +
41
+ `Run \`marketing environment create\` on "${parentBranch}" first.`);
42
+ }
43
+ return {
44
+ environmentId: entry.environmentId,
45
+ isProduction: false,
46
+ sourceBranch: parentBranch,
47
+ };
48
+ }
49
+ }
50
+ return {
51
+ environmentId: config.kontent.productionEnvironmentId,
52
+ isProduction: true,
53
+ };
54
+ }
55
+ /**
56
+ * Resolve the source environment for `environment create`.
57
+ *
58
+ * 1. `--source <branch>` → look up in longLivedBranches, hard-error if
59
+ * not found or no active environmentId.
60
+ * 2. No `--source`, ≥1 long-lived branch with active env → interactive
61
+ * selector (production is always an option).
62
+ * 3. No `--source`, no active long-lived branches → production.
63
+ */
64
+ async function resolveSource(branch, config, opts) {
65
+ const llb = config.kontent.longLivedBranches;
66
+ if (opts.source) {
67
+ if (opts.source === branch) {
68
+ throw new CliError(`Cannot use "${branch}" as its own source environment.`);
69
+ }
70
+ const entry = llb[opts.source];
71
+ if (!entry) {
72
+ throw new CliError(`Branch "${opts.source}" is not declared in longLivedBranches. ` +
73
+ `Available long-lived branches: ${Object.keys(llb).join(', ') || '(none)'}.`);
74
+ }
75
+ if (!entry.environmentId) {
76
+ throw new CliError(`Branch "${opts.source}" has no active environment. ` +
77
+ `Run \`marketing environment create\` on "${opts.source}" first.`);
78
+ }
79
+ return { environmentId: entry.environmentId, isProduction: false, sourceBranch: opts.source };
80
+ }
81
+ // Collect long-lived branches with active environments (excluding the current branch)
82
+ const activeLLBs = Object.entries(llb).filter(([name, entry]) => name !== branch && Boolean(entry.environmentId));
83
+ if (activeLLBs.length > 0 && !opts.yes) {
84
+ const PRODUCTION_VALUE = '__production__';
85
+ const selected = assertNotCancelled(await select({
86
+ message: 'Select source environment:',
87
+ options: [
88
+ { value: PRODUCTION_VALUE, label: 'Production' },
89
+ ...activeLLBs.map(([name]) => ({ value: name, label: name })),
90
+ ],
91
+ }));
92
+ if (selected !== PRODUCTION_VALUE) {
93
+ return {
94
+ environmentId: llb[selected].environmentId,
95
+ isProduction: false,
96
+ sourceBranch: selected,
97
+ };
98
+ }
99
+ // Explicit production choice — bypass tree lookup
100
+ return {
101
+ environmentId: config.kontent.productionEnvironmentId,
102
+ isProduction: true,
103
+ };
104
+ }
105
+ return resolveSourceEnvironment(branch, config);
106
+ }
107
+ /**
108
+ * Human-readable label for a resolved source — `"production"` or the
109
+ * source branch name. Falls back to the raw environment ID when neither
110
+ * is available (shouldn't happen in practice).
111
+ */
112
+ function sourceLabel(source) {
113
+ if (source.isProduction)
114
+ return 'production';
115
+ return source.sourceBranch ?? source.environmentId;
116
+ }
117
+ function requireBranch(opts) {
118
+ const branch = detectBranch(opts);
119
+ if (!branch) {
120
+ throw new CliError('Could not detect the current branch. Pass --branch <name>.');
121
+ }
122
+ return branch;
123
+ }
124
+ function guardProtectedBranch(branch) {
125
+ if (PROTECTED_BRANCHES.includes(branch)) {
126
+ throw new CliError(`Refusing to operate on protected branch "${branch}". ` + `Switch to a feature branch.`);
127
+ }
128
+ }
129
+ function requireTTY(yes) {
130
+ if (!yes && !process.stdin.isTTY) {
131
+ throw new CliError('No interactive terminal detected. Pass --yes to skip confirmation, ' +
132
+ 'or run in an interactive shell.');
133
+ }
134
+ }
135
+ // ---------------------------------------------------------------------------
136
+ // environment create
137
+ // ---------------------------------------------------------------------------
138
+ export async function environmentCreate(opts) {
139
+ const cwd = process.cwd();
140
+ const config = loadConfig(cwd);
141
+ const branch = requireBranch(opts);
142
+ guardProtectedBranch(branch);
143
+ requireTTY(opts.yes);
144
+ const token = resolveNetlifyToken();
145
+ const previewSiteId = config.netlify.previewSiteId;
146
+ // --- Resolve management key ---
147
+ const ci = isCI(opts);
148
+ const envFilePath = join(cwd, '.env.development');
149
+ const apiKey = resolveManagementKey({
150
+ ci,
151
+ netlifyValue: undefined,
152
+ envFilePath,
153
+ managementKeyName: config.envVars.managementKey,
154
+ });
155
+ // --- Fetch branch vars (single GET, reused throughout) ---
156
+ const branchVars = await fetchBranchVars(token, previewSiteId);
157
+ // --- Check for existing branch env var ---
158
+ const hasExisting = branchVars.has(config.envVars.environmentId, branch);
159
+ if (hasExisting) {
160
+ if (opts.yes) {
161
+ console.log(`Branch "${branch}" already has an environment — keeping existing (--yes).`);
162
+ return;
163
+ }
164
+ const action = assertNotCancelled(await confirm({
165
+ message: `Branch "${branch}" already has a branch-scoped environment on Netlify. Keep it?`,
166
+ }));
167
+ if (action === true) {
168
+ console.log('Keeping existing environment.');
169
+ return;
170
+ }
171
+ // --- Block if stack branch has children with active environments ---
172
+ guardActiveChildren(branch, 'recreate', config, branchVars);
173
+ // Recreate: tear down old environment
174
+ console.log('Recreating — removing old environment...');
175
+ // Resolve old clone ID before deleting the Netlify var (delete drops it from cache)
176
+ const oldResolved = branchVars.resolve(config.envVars.environmentId, branch);
177
+ const oldCloneEnvId = oldResolved?.isBranchSpecific ? oldResolved.value : undefined;
178
+ // Delete old Netlify branch var
179
+ await branchVars.delete(config.envVars.environmentId, branch);
180
+ // Delete old Kontent environment
181
+ if (oldCloneEnvId) {
182
+ try {
183
+ const oldClient = createKontentClient(oldCloneEnvId, apiKey);
184
+ await oldClient.deleteEnvironment().toPromise();
185
+ console.log(`Deleted old Kontent environment (${oldCloneEnvId}).`);
186
+ }
187
+ catch (err) {
188
+ const msg = err instanceof Error ? err.message : String(err);
189
+ console.warn(`Warning: could not delete old Kontent environment (${oldCloneEnvId}): ${msg}`);
190
+ console.warn('Continuing with recreation — the old environment may need manual cleanup.');
191
+ }
192
+ }
193
+ // Clean up status.json for the old clone
194
+ if (oldCloneEnvId) {
195
+ const status = loadStatusJson(cwd);
196
+ delete status[oldCloneEnvId];
197
+ writeStatusJson(cwd, status);
198
+ }
199
+ // Remove config entry if applicable
200
+ const llb = config.kontent.longLivedBranches;
201
+ if (llb[branch]?.environmentId) {
202
+ delete llb[branch].environmentId;
203
+ updateConfig(config, cwd);
204
+ }
205
+ }
206
+ // --- Resolve source (--source flag or selector or default) ---
207
+ const source = await resolveSource(branch, config, opts);
208
+ // --- Pre-validate credentials on source env ---
209
+ console.log('Validating credentials on source environment...');
210
+ const sourceClient = createKontentClient(source.environmentId, apiKey);
211
+ await sourceClient.environmentInformation().toPromise();
212
+ console.log('Credentials valid.');
213
+ // --- Determine if this is a long-lived branch ---
214
+ const llb = config.kontent.longLivedBranches;
215
+ const isLongLived = opts.stack || llb[branch] !== undefined;
216
+ if (opts.stack && llb[branch] === undefined) {
217
+ llb[branch] = { children: [] };
218
+ }
219
+ // --- Clone the environment ---
220
+ const cloneName = slugifyBranch(branch);
221
+ console.log(`Cloning "${sourceLabel(source)}" (${source.environmentId}) as "${cloneName}"...`);
222
+ const cloneResponse = await sourceClient
223
+ .cloneEnvironment()
224
+ .withData({
225
+ name: cloneName,
226
+ roles_to_activate: config.kontent.roles,
227
+ })
228
+ .toPromise();
229
+ const cloneEnvId = cloneResponse.data.id;
230
+ console.log(`Clone initiated: ${cloneEnvId}`);
231
+ // --- Poll until cloning completes ---
232
+ const s = spinner();
233
+ s.start('Waiting for clone to complete...');
234
+ const cloneClient = createKontentClient(cloneEnvId, apiKey);
235
+ const startTime = Date.now();
236
+ let lastLogTime = startTime;
237
+ let consecutiveFailures = 0;
238
+ while (true) {
239
+ await delay(2000);
240
+ let stateResponse;
241
+ try {
242
+ stateResponse = await cloneClient.getEnvironmentCloningState().toPromise();
243
+ consecutiveFailures = 0;
244
+ }
245
+ catch (err) {
246
+ if (isAuthError(err)) {
247
+ throw err;
248
+ }
249
+ consecutiveFailures++;
250
+ const backoff = Math.min(2000 * 2 ** (consecutiveFailures - 1), 30_000);
251
+ s.message(`Poll error (${consecutiveFailures} consecutive), ` + `retrying in ${backoff / 1000}s...`);
252
+ await delay(backoff);
253
+ continue;
254
+ }
255
+ const state = stateResponse.data.cloningInfo.cloningState;
256
+ if (state === 'done') {
257
+ break;
258
+ }
259
+ if (state === 'failed') {
260
+ s.stop('Clone failed.');
261
+ throw new CliError(`Cloning failed for environment "${cloneEnvId}". Check the Kontent dashboard.`);
262
+ }
263
+ const now = Date.now();
264
+ if (now - lastLogTime >= 30_000) {
265
+ const elapsed = Math.round((now - startTime) / 1000);
266
+ s.message(`Still cloning... (${elapsed}s elapsed)`);
267
+ lastLogTime = now;
268
+ }
269
+ }
270
+ s.stop('Clone complete.');
271
+ // --- Push env ID to Netlify branch context ---
272
+ await branchVars.set(config.envVars.environmentId, branch, cloneEnvId);
273
+ console.log(`Set branch env var on Netlify preview site.`);
274
+ // --- Seed status.json ---
275
+ const status = loadStatusJson(cwd);
276
+ const sourceStatus = status[source.environmentId];
277
+ if (Array.isArray(sourceStatus)) {
278
+ status[cloneEnvId] = [...sourceStatus];
279
+ }
280
+ else {
281
+ status[cloneEnvId] = [];
282
+ }
283
+ writeStatusJson(cwd, status);
284
+ console.log('Seeded Migrations/status.json.');
285
+ // --- Update config: long-lived branch state + children ---
286
+ // Clean up stale children entries (e.g. branch was previously under a
287
+ // different parent, or user explicitly chose production)
288
+ let configDirty = removeBranchFromAllChildren(llb, branch);
289
+ if (isLongLived) {
290
+ llb[branch].environmentId = cloneEnvId;
291
+ configDirty = true;
292
+ }
293
+ if (source.sourceBranch) {
294
+ llb[source.sourceBranch].children.push(branch);
295
+ configDirty = true;
296
+ }
297
+ if (configDirty) {
298
+ updateConfig(config, cwd);
299
+ console.log('Updated marketing.config.json.');
300
+ }
301
+ // --- Re-run setup ---
302
+ await setup({ branch: opts.branch, ci: opts.ci });
303
+ console.log('Re-ran setup.');
304
+ // --- Run migrations ---
305
+ await migrationRun({ branch: opts.branch, ci: opts.ci, yes: true });
306
+ console.log('Migrations complete.');
307
+ console.log(`\nEnvironment "${branch}" (${cloneEnvId}) is ready.`);
308
+ }
309
+ // ---------------------------------------------------------------------------
310
+ // environment finish
311
+ // ---------------------------------------------------------------------------
312
+ export async function environmentFinish(opts) {
313
+ const cwd = process.cwd();
314
+ const config = loadConfig(cwd);
315
+ const branch = requireBranch(opts);
316
+ guardProtectedBranch(branch);
317
+ requireTTY(opts.yes);
318
+ const token = resolveNetlifyToken();
319
+ const previewSiteId = config.netlify.previewSiteId;
320
+ // --- Fetch branch vars (single GET, reused throughout) ---
321
+ const branchVars = await fetchBranchVars(token, previewSiteId);
322
+ // --- Resolve clone env ID from cache ---
323
+ const resolved = branchVars.resolve(config.envVars.environmentId, branch);
324
+ if (!resolved?.isBranchSpecific) {
325
+ throw new CliError(`No branch-scoped environment found for "${branch}". ` +
326
+ `Run \`marketing environment create\` first.`);
327
+ }
328
+ const cloneEnvId = resolved.value;
329
+ // --- Resolve source ---
330
+ const source = resolveSourceEnvironment(branch, config);
331
+ const llb = config.kontent.longLivedBranches;
332
+ const isLongLived = llb[branch] !== undefined;
333
+ // --- Block if long-lived and children have active envs ---
334
+ if (isLongLived) {
335
+ guardActiveChildren(branch, 'finish', config, branchVars);
336
+ }
337
+ // --- Confirmation ---
338
+ if (!opts.yes) {
339
+ const confirmed = assertNotCancelled(await confirm({
340
+ message: `Finish environment for branch "${branch}"?\n` +
341
+ ` • Migrate source "${sourceLabel(source)}"\n` +
342
+ ` • Delete clone "${branch}"\n` +
343
+ ` • Remove Netlify branch var`,
344
+ }));
345
+ if (confirmed !== true) {
346
+ throw new CliError('Aborted by user.');
347
+ }
348
+ }
349
+ // --- Resolve management key ---
350
+ const ci = isCI(opts);
351
+ const envFilePath = join(cwd, '.env.development');
352
+ const apiKey = resolveManagementKey({
353
+ ci,
354
+ netlifyValue: undefined,
355
+ envFilePath,
356
+ managementKeyName: config.envVars.managementKey,
357
+ });
358
+ // --- Migrate source environment ---
359
+ console.log(`Running migrations on "${sourceLabel(source)}" (${source.environmentId})...`);
360
+ await migrationRun({
361
+ environmentId: source.environmentId,
362
+ allowProduction: source.isProduction,
363
+ yes: true,
364
+ branch: opts.branch,
365
+ ci: opts.ci,
366
+ });
367
+ console.log('Source migrations complete.');
368
+ // --- Delete clone ---
369
+ console.log(`Deleting clone "${branch}" (${cloneEnvId})...`);
370
+ const cloneClient = createKontentClient(cloneEnvId, apiKey);
371
+ await cloneClient.deleteEnvironment().toPromise();
372
+ console.log('Clone deleted.');
373
+ // --- Delete Netlify branch var ---
374
+ await branchVars.delete(config.envVars.environmentId, branch);
375
+ console.log('Removed Netlify branch env var.');
376
+ // --- Clean up status.json ---
377
+ const status = loadStatusJson(cwd);
378
+ delete status[cloneEnvId];
379
+ writeStatusJson(cwd, status);
380
+ console.log('Cleaned up Migrations/status.json.');
381
+ // --- Auto-remove branch from parent's children ---
382
+ let configDirty = removeBranchFromAllChildren(llb, branch);
383
+ // --- Clean up config for long-lived branches ---
384
+ if (isLongLived) {
385
+ delete llb[branch];
386
+ configDirty = true;
387
+ }
388
+ if (configDirty) {
389
+ updateConfig(config, cwd);
390
+ console.log('Updated marketing.config.json.');
391
+ }
392
+ // --- Re-run setup ---
393
+ await setup({ branch: opts.branch, ci: opts.ci });
394
+ console.log('Re-ran setup.');
395
+ console.log(`\nEnvironment finished for branch "${branch}".`);
396
+ }
397
+ // ---------------------------------------------------------------------------
398
+ // Utilities
399
+ // ---------------------------------------------------------------------------
400
+ /**
401
+ * Remove `branch` from every parent's `children` array in the long-lived
402
+ * branches config. Returns `true` if any entry was modified.
403
+ */
404
+ /**
405
+ * Throw if `branch` is a long-lived branch whose children still have active
406
+ * Netlify environments. Used by both `create` (recreate path) and `finish`.
407
+ */
408
+ function guardActiveChildren(branch, verb, config, branchVars) {
409
+ const entry = config.kontent.longLivedBranches[branch];
410
+ if (!entry?.children.length)
411
+ return;
412
+ const activeChildren = [];
413
+ for (const child of entry.children) {
414
+ if (branchVars.has(config.envVars.environmentId, child)) {
415
+ activeChildren.push(child);
416
+ }
417
+ }
418
+ if (activeChildren.length > 0) {
419
+ throw new CliError(`Cannot ${verb} long-lived branch "${branch}" \u2014 ` +
420
+ `the following children still have active environments:\n` +
421
+ activeChildren.map((c) => ` \u2022 ${c}`).join('\n') +
422
+ `\nFinish those branches first.`);
423
+ }
424
+ }
425
+ function removeBranchFromAllChildren(llb, branch) {
426
+ let modified = false;
427
+ for (const [, entry] of Object.entries(llb)) {
428
+ const idx = entry.children.indexOf(branch);
429
+ if (idx !== -1) {
430
+ entry.children.splice(idx, 1);
431
+ modified = true;
432
+ }
433
+ }
434
+ return modified;
435
+ }
436
+ function delay(ms) {
437
+ return new Promise((resolve) => setTimeout(resolve, ms));
438
+ }
439
+ const AUTH_STATUS_CODES = new Set([401, 403]);
440
+ /**
441
+ * Detect authentication / authorisation errors that should never be retried.
442
+ * Checks for a numeric `status` property or a nested `response.status`.
443
+ */
444
+ function isAuthError(err) {
445
+ if (typeof err !== 'object' || err === null)
446
+ return false;
447
+ const status = err.status;
448
+ if (typeof status === 'number' && AUTH_STATUS_CODES.has(status))
449
+ return true;
450
+ const response = err.response;
451
+ if (typeof response?.status === 'number' && AUTH_STATUS_CODES.has(response.status))
452
+ return true;
453
+ return false;
454
+ }
@@ -0,0 +1,12 @@
1
+ export interface InitOptions {
2
+ dryRun: boolean;
3
+ force: boolean;
4
+ }
5
+ /**
6
+ * `marketing init` — one-shot migration from legacy CLI to config-driven model.
7
+ *
8
+ * Reads an existing legacy `status.json`, generates `marketing.config.json`,
9
+ * and rewrites `status.json` to the official format (per-environment migration
10
+ * arrays only, with `info`/`roles`/`netlify` stripped).
11
+ */
12
+ export declare function init(opts: InitOptions): Promise<void>;
@@ -0,0 +1,184 @@
1
+ import { access, mkdir, readFile, unlink, writeFile } from 'node:fs/promises';
2
+ import { join } from 'node:path';
3
+ import { z } from 'zod/v4';
4
+ import { MIGRATIONS_FOLDER } from '../branches.js';
5
+ import { configSchema } from '../config.js';
6
+ import { CliError } from '../errors.js';
7
+ import { MIGRATIONS_PACKAGE_JSON } from '../project.js';
8
+ const STATUS_FILENAME = 'status.json';
9
+ const CONFIG_FILENAME = 'marketing.config.json';
10
+ const TSCONFIG_MIGRATIONS_FILENAME = 'tsconfig.migrations.json';
11
+ /** Canonical tsconfig for compiling Kontent content-model migrations. */
12
+ const TSCONFIG_MIGRATIONS = {
13
+ compilerOptions: {
14
+ target: 'ES2022',
15
+ module: 'Node16',
16
+ moduleResolution: 'Node16',
17
+ lib: ['ES2022', 'dom'],
18
+ rootDir: './Migrations',
19
+ outDir: './Migrations',
20
+ strict: true,
21
+ noUnusedLocals: true,
22
+ noUnusedParameters: true,
23
+ noImplicitReturns: true,
24
+ noFallthroughCasesInSwitch: true,
25
+ noUncheckedIndexedAccess: true,
26
+ forceConsistentCasingInFileNames: true,
27
+ esModuleInterop: true,
28
+ resolveJsonModule: true,
29
+ skipLibCheck: true,
30
+ declaration: false,
31
+ sourceMap: false,
32
+ incremental: false,
33
+ },
34
+ include: ['./Migrations'],
35
+ exclude: ['node_modules', 'Migrations/**/*.js'],
36
+ };
37
+ /**
38
+ * Zod schema for the legacy status.json shape.
39
+ *
40
+ * Validates the known keys strictly, using `z.looseObject()` so the dynamic
41
+ * UUID-keyed migration arrays survive without validation.
42
+ */
43
+ const legacyStatusSchema = z.looseObject({
44
+ info: z.looseObject({
45
+ PRODUCTION_PROJECT: z.string(),
46
+ }),
47
+ roles: z.looseObject({
48
+ PRODUCTION_PROJECT: z.array(z.string()),
49
+ }),
50
+ netlify: z.object({
51
+ PREVIEW: z.string(),
52
+ PRODUCTION: z.string(),
53
+ }),
54
+ });
55
+ /** Standard Vite variable names scaffolded for every migration. */
56
+ const STANDARD_ENV_VARS = {
57
+ environmentId: 'VITE_KONTENT_ENVIRONMENT_ID',
58
+ secureKey: 'KONTENT_SECURE_KEY',
59
+ previewKey: 'KONTENT_PREVIEW_KEY',
60
+ homepage: 'VITE_KONTENT_HOMEPAGE_CODENAME',
61
+ previewMode: 'VITE_KONTENT_PREVIEW_MODE',
62
+ managementKey: 'KONTENT_MANAGEMENT_KEY',
63
+ };
64
+ /**
65
+ * `marketing init` — one-shot migration from legacy CLI to config-driven model.
66
+ *
67
+ * Reads an existing legacy `status.json`, generates `marketing.config.json`,
68
+ * and rewrites `status.json` to the official format (per-environment migration
69
+ * arrays only, with `info`/`roles`/`netlify` stripped).
70
+ */
71
+ export async function init(opts) {
72
+ const cwd = process.cwd();
73
+ const legacyStatusPath = join(cwd, STATUS_FILENAME);
74
+ const statusPath = join(cwd, MIGRATIONS_FOLDER, STATUS_FILENAME);
75
+ const configPath = join(cwd, CONFIG_FILENAME);
76
+ const tsconfigPath = join(cwd, TSCONFIG_MIGRATIONS_FILENAME);
77
+ // 1. Read status.json
78
+ let raw;
79
+ try {
80
+ raw = await readFile(legacyStatusPath, 'utf8');
81
+ }
82
+ catch (err) {
83
+ if (err.code === 'ENOENT') {
84
+ throw new CliError(`Could not read ${STATUS_FILENAME} in ${cwd}. ` +
85
+ `init is a migration tool — a legacy status.json must exist.`);
86
+ }
87
+ throw new CliError(`Could not read ${STATUS_FILENAME}: ${err.message}`);
88
+ }
89
+ let json;
90
+ try {
91
+ json = JSON.parse(raw);
92
+ }
93
+ catch {
94
+ throw new CliError(`${STATUS_FILENAME} contains invalid JSON.`);
95
+ }
96
+ // 2. Check if already official (no legacy keys)
97
+ if (isOfficialFormat(json)) {
98
+ throw new CliError(`${STATUS_FILENAME} is already in official format (no info/roles/netlify keys). Nothing to migrate.`);
99
+ }
100
+ // 3. Validate legacy schema
101
+ const result = legacyStatusSchema.safeParse(json);
102
+ if (!result.success) {
103
+ const issues = z.prettifyError(result.error);
104
+ throw new CliError(`Invalid legacy ${STATUS_FILENAME}:\n${issues}`);
105
+ }
106
+ // 4. Check if marketing.config.json or tsconfig.migrations.json already exist
107
+ const configExists = await access(configPath).then(() => true, () => false);
108
+ if (configExists && !opts.force) {
109
+ throw new CliError(`${CONFIG_FILENAME} already exists. Use --force to overwrite.`);
110
+ }
111
+ const tsconfigExists = await access(tsconfigPath).then(() => true, () => false);
112
+ if (tsconfigExists && !opts.force) {
113
+ throw new CliError(`${TSCONFIG_MIGRATIONS_FILENAME} already exists. Use --force to overwrite.`);
114
+ }
115
+ // 5. Derive config from legacy status
116
+ const legacy = result.data;
117
+ const config = deriveConfig(legacy);
118
+ // 5b. Validate derived config against configSchema
119
+ const configResult = configSchema.safeParse(config);
120
+ if (!configResult.success) {
121
+ const issues = z.prettifyError(configResult.error);
122
+ throw new CliError(`Cannot migrate: legacy ${STATUS_FILENAME} has empty or invalid values for fields that ${CONFIG_FILENAME} requires. ` +
123
+ `Fix ${STATUS_FILENAME} and re-run init.\n${issues}`);
124
+ }
125
+ // 6. Build official status (strip info/roles/netlify)
126
+ const officialStatus = stripLegacyKeys(json);
127
+ // 7. Write or dry-run
128
+ if (opts.dryRun) {
129
+ console.log(`[dry-run] Would write ${CONFIG_FILENAME}:`);
130
+ console.log(JSON.stringify(config, null, 2));
131
+ console.log('');
132
+ console.log(`[dry-run] Would write ${MIGRATIONS_FOLDER}/${STATUS_FILENAME}:`);
133
+ console.log(JSON.stringify(officialStatus, null, 2));
134
+ console.log('');
135
+ console.log(`[dry-run] Would write ${TSCONFIG_MIGRATIONS_FILENAME}:`);
136
+ console.log(JSON.stringify(TSCONFIG_MIGRATIONS, null, 2));
137
+ console.log('');
138
+ console.log(`[dry-run] Would write ${MIGRATIONS_FOLDER}/package.json:`);
139
+ console.log(JSON.stringify(MIGRATIONS_PACKAGE_JSON, null, 2));
140
+ console.log('');
141
+ console.log(`[dry-run] Would delete ./${STATUS_FILENAME}`);
142
+ return;
143
+ }
144
+ await writeFile(configPath, JSON.stringify(config, null, 2) + '\n');
145
+ console.log(`Wrote ${CONFIG_FILENAME}`);
146
+ await mkdir(join(cwd, MIGRATIONS_FOLDER), { recursive: true });
147
+ await writeFile(statusPath, JSON.stringify(officialStatus, null, 2) + '\n');
148
+ console.log(`Wrote ${MIGRATIONS_FOLDER}/${STATUS_FILENAME}`);
149
+ await writeFile(tsconfigPath, JSON.stringify(TSCONFIG_MIGRATIONS, null, 2) + '\n');
150
+ console.log(`Wrote ${TSCONFIG_MIGRATIONS_FILENAME}`);
151
+ const migrationsPkgPath = join(cwd, MIGRATIONS_FOLDER, 'package.json');
152
+ const existingPkg = await readFile(migrationsPkgPath, 'utf8').then((raw) => JSON.parse(raw), () => ({}));
153
+ await writeFile(migrationsPkgPath, JSON.stringify({ ...existingPkg, ...MIGRATIONS_PACKAGE_JSON }, null, 2) + '\n');
154
+ console.log(`Wrote ${MIGRATIONS_FOLDER}/package.json`);
155
+ await unlink(legacyStatusPath);
156
+ console.log(`Deleted ./${STATUS_FILENAME}`);
157
+ console.log('envVars scaffolded with standard Vite names — verify they match your project.');
158
+ console.log('Review changes: git diff');
159
+ }
160
+ function isOfficialFormat(json) {
161
+ if (typeof json !== 'object' || json === null || Array.isArray(json))
162
+ return false;
163
+ const obj = json;
164
+ return !('info' in obj) && !('roles' in obj) && !('netlify' in obj);
165
+ }
166
+ function deriveConfig(legacy) {
167
+ return {
168
+ kontent: {
169
+ productionEnvironmentName: 'PRODUCTION_PROJECT',
170
+ productionEnvironmentId: legacy.info.PRODUCTION_PROJECT,
171
+ roles: legacy.roles.PRODUCTION_PROJECT,
172
+ longLivedBranches: {},
173
+ },
174
+ netlify: {
175
+ previewSiteId: legacy.netlify.PREVIEW,
176
+ productionSiteId: legacy.netlify.PRODUCTION,
177
+ },
178
+ envVars: { ...STANDARD_ENV_VARS },
179
+ };
180
+ }
181
+ function stripLegacyKeys(obj) {
182
+ const { info: _, roles: _r, netlify: _n, ...rest } = obj;
183
+ return rest;
184
+ }
@@ -0,0 +1,31 @@
1
+ /**
2
+ * `marketing migration create <name>` — scaffold a new timestamp-ordered
3
+ * TypeScript migration file.
4
+ */
5
+ export declare function migrationCreate(name: string, folder?: string): Promise<void>;
6
+ export interface MigrationRunOptions {
7
+ branch?: string;
8
+ ci?: boolean;
9
+ yes?: boolean;
10
+ /** Internal — never CLI-exposed. Bypasses production guards and prompt. */
11
+ allowProduction?: boolean;
12
+ /**
13
+ * Internal — never CLI-exposed. Override the environment ID instead of
14
+ * reading it from `.env.development`. Used by `environment finish` to
15
+ * target the source environment while `.env.development` still points at
16
+ * the clone.
17
+ */
18
+ environmentId?: string;
19
+ }
20
+ /**
21
+ * `marketing migration run` — compile and execute all pending migrations
22
+ * against the environment specified in `.env.development`.
23
+ *
24
+ * Two-layer production guard:
25
+ * 1. Refuses on protected branches (`master`/`main`/`develop`)
26
+ * 2. Refuses when the resolved environment ID matches production
27
+ *
28
+ * Both guards are bypassed by `allowProduction: true` (used internally by
29
+ * `environment finish`).
30
+ */
31
+ export declare function migrationRun(opts: MigrationRunOptions): Promise<void>;