@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.
- package/README.md +180 -0
- package/dist/branch.d.ts +14 -0
- package/dist/branch.js +27 -0
- package/dist/branches.d.ts +8 -0
- package/dist/branches.js +8 -0
- package/dist/ci.d.ts +25 -0
- package/dist/ci.js +37 -0
- package/dist/cli.d.ts +7 -0
- package/dist/cli.js +102 -0
- package/dist/commands/environment.d.ts +36 -0
- package/dist/commands/environment.js +454 -0
- package/dist/commands/init.d.ts +12 -0
- package/dist/commands/init.js +184 -0
- package/dist/commands/migration.d.ts +31 -0
- package/dist/commands/migration.js +111 -0
- package/dist/commands/setup.d.ts +9 -0
- package/dist/commands/setup.js +73 -0
- package/dist/config.d.ts +40 -0
- package/dist/config.js +69 -0
- package/dist/dotenv.d.ts +12 -0
- package/dist/dotenv.js +113 -0
- package/dist/errors.d.ts +13 -0
- package/dist/errors.js +18 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +3 -0
- package/dist/kontent.d.ts +8 -0
- package/dist/kontent.js +10 -0
- package/dist/migration.template +9 -0
- package/dist/migrations.d.ts +7 -0
- package/dist/migrations.js +1 -0
- package/dist/netlify.d.ts +49 -0
- package/dist/netlify.js +199 -0
- package/dist/project.d.ts +21 -0
- package/dist/project.js +57 -0
- package/dist/status.d.ts +8 -0
- package/dist/status.js +24 -0
- package/dist/test/handlers.d.ts +18 -0
- package/dist/test/handlers.js +102 -0
- package/dist/test/server.d.ts +1 -0
- package/dist/test/server.js +2 -0
- package/package.json +54 -0
|
@@ -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>;
|