@selvajs/cli 4.20.0-beta.3 → 4.20.0-beta.30
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/package.json +3 -3
- package/src/caddyfile.js +4 -4
- package/src/checks/config.js +79 -14
- package/src/checks/migrations.js +156 -0
- package/src/checks/runtime.js +35 -3
- package/src/cli.js +23 -7
- package/src/commands/config.js +196 -0
- package/src/commands/doctor.js +21 -83
- package/src/commands/init.js +15 -7
- package/src/commands/keys.js +112 -33
- package/src/commands/migrate.js +132 -37
- package/src/commands/pm2.js +129 -6
- package/src/commands/proxy.js +12 -0
- package/src/deployment-package.js +3 -1
- package/src/env-migration.js +155 -0
- package/src/env.js +80 -42
- package/src/generated/config.js +1186 -0
- package/src/prompts.js +9 -0
package/src/commands/doctor.js
CHANGED
|
@@ -8,7 +8,6 @@
|
|
|
8
8
|
import {
|
|
9
9
|
existsSync,
|
|
10
10
|
readFileSync,
|
|
11
|
-
writeFileSync,
|
|
12
11
|
copyFileSync,
|
|
13
12
|
readdirSync,
|
|
14
13
|
accessSync,
|
|
@@ -20,18 +19,27 @@ import { join, resolve, dirname } from 'node:path';
|
|
|
20
19
|
import { fileURLToPath } from 'node:url';
|
|
21
20
|
import * as p from '@clack/prompts';
|
|
22
21
|
import pc from 'picocolors';
|
|
23
|
-
import {
|
|
22
|
+
import {
|
|
23
|
+
readEnvFile,
|
|
24
|
+
parseEnv,
|
|
25
|
+
countEnvCommentLines,
|
|
26
|
+
stripEnvComments,
|
|
27
|
+
writeEnvText
|
|
28
|
+
} from '../env.js';
|
|
24
29
|
import { requireDeploymentDir, resolveDeploymentDir } from '../paths.js';
|
|
25
30
|
import { satisfiesNodeRange } from '../node-range.js';
|
|
26
31
|
import { green, yellow, red, fixable } from '../checks/result.js';
|
|
27
32
|
import { checkBootPersistence } from '../checks/boot.js';
|
|
28
33
|
import { checkRuntimeEnvironment } from '../checks/runtime.js';
|
|
34
|
+
import { checkSupabaseMigrations } from '../checks/migrations.js';
|
|
29
35
|
import {
|
|
30
36
|
checkBodySizeLimit,
|
|
31
37
|
checkClientAddress,
|
|
32
38
|
checkDeprecatedEnv,
|
|
33
39
|
checkEnvDocumentation,
|
|
34
40
|
checkHeaderNames,
|
|
41
|
+
checkHmacKey,
|
|
42
|
+
checkKeyRotation,
|
|
35
43
|
checkOrigin,
|
|
36
44
|
checkProviders,
|
|
37
45
|
checkSecret,
|
|
@@ -55,8 +63,9 @@ export async function runDoctor(argv = []) {
|
|
|
55
63
|
checks.push(checkLayoutDrift(dir));
|
|
56
64
|
checks.push(checkMigrationLeftovers(dir));
|
|
57
65
|
checks.push(checkAgedBackups(dir));
|
|
58
|
-
checks.push(
|
|
59
|
-
checks.push(checkSecret(env.SELVA_AT_REST_KEY, 'SELVA_AT_REST_KEY is a 32-byte
|
|
66
|
+
checks.push(checkHmacKey(env.SELVA_HMAC_KEY));
|
|
67
|
+
checks.push(checkSecret(env.SELVA_AT_REST_KEY, 'SELVA_AT_REST_KEY is a 32-byte key'));
|
|
68
|
+
checks.push(...checkKeyRotation(env));
|
|
60
69
|
|
|
61
70
|
const providers = resolveProviders(env);
|
|
62
71
|
checks.push(...checkProviders(providers));
|
|
@@ -69,7 +78,13 @@ export async function runDoctor(argv = []) {
|
|
|
69
78
|
|
|
70
79
|
if (used.has('supabase')) {
|
|
71
80
|
checks.push(checkSupabase(env));
|
|
72
|
-
checks.push(
|
|
81
|
+
checks.push(
|
|
82
|
+
checkSupabaseMigrations(
|
|
83
|
+
dir,
|
|
84
|
+
env,
|
|
85
|
+
readPackageVersion(join(dir, 'node_modules', '@selvajs', 'selva', 'package.json'))
|
|
86
|
+
)
|
|
87
|
+
);
|
|
73
88
|
}
|
|
74
89
|
|
|
75
90
|
if (providers.auth === 'header') {
|
|
@@ -214,83 +229,6 @@ export async function checkSupabase(env) {
|
|
|
214
229
|
}
|
|
215
230
|
}
|
|
216
231
|
|
|
217
|
-
// Compares the newest migration shipped by @selvajs/supabase-provider against
|
|
218
|
-
// what the database reports via selva.migration_head(). Red on skew — the app
|
|
219
|
-
// degrades /api/health to 503 at boot in the same state, so catching it here
|
|
220
|
-
// saves a confusing deploy.
|
|
221
|
-
async function checkSupabaseMigrations(dir, env) {
|
|
222
|
-
if (!env.SUPABASE_URL || !env.SUPABASE_SERVICE_ROLE_KEY) {
|
|
223
|
-
return yellow('Migration-head check skipped — Supabase env incomplete');
|
|
224
|
-
}
|
|
225
|
-
|
|
226
|
-
const migrationsDir = join(
|
|
227
|
-
dir,
|
|
228
|
-
'node_modules',
|
|
229
|
-
'@selvajs',
|
|
230
|
-
'supabase-provider',
|
|
231
|
-
'supabase',
|
|
232
|
-
'migrations'
|
|
233
|
-
);
|
|
234
|
-
let expected = null;
|
|
235
|
-
try {
|
|
236
|
-
expected =
|
|
237
|
-
readdirSync(migrationsDir)
|
|
238
|
-
.filter((f) => /^\d{14}_.+\.sql$/.test(f))
|
|
239
|
-
.map((f) => f.slice(0, 14))
|
|
240
|
-
.sort()
|
|
241
|
-
.pop() ?? null;
|
|
242
|
-
} catch {
|
|
243
|
-
// Provider package not installed in this deployment.
|
|
244
|
-
}
|
|
245
|
-
if (!expected) {
|
|
246
|
-
return red(
|
|
247
|
-
'@selvajs/supabase-provider is not installed, so the migration SQL is missing. ' +
|
|
248
|
-
'Run `selva migrate` to add it, then `npm install`.'
|
|
249
|
-
);
|
|
250
|
-
}
|
|
251
|
-
|
|
252
|
-
try {
|
|
253
|
-
const controller = new AbortController();
|
|
254
|
-
const timer = setTimeout(() => controller.abort(), 4000);
|
|
255
|
-
const res = await fetch(env.SUPABASE_URL + '/rest/v1/rpc/migration_head', {
|
|
256
|
-
method: 'POST',
|
|
257
|
-
headers: {
|
|
258
|
-
apikey: env.SUPABASE_SERVICE_ROLE_KEY,
|
|
259
|
-
Authorization: `Bearer ${env.SUPABASE_SERVICE_ROLE_KEY}`,
|
|
260
|
-
'Content-Type': 'application/json',
|
|
261
|
-
// The function lives in the selva schema the app is pinned to.
|
|
262
|
-
'Content-Profile': 'selva'
|
|
263
|
-
},
|
|
264
|
-
body: '{}',
|
|
265
|
-
signal: controller.signal
|
|
266
|
-
});
|
|
267
|
-
clearTimeout(timer);
|
|
268
|
-
if (res.status === 404) {
|
|
269
|
-
return red(
|
|
270
|
-
`Database is missing selva.migration_head() — migrations are pending (expected head ${expected}). Run: npx selva-supabase && npx supabase db push`
|
|
271
|
-
);
|
|
272
|
-
}
|
|
273
|
-
if (!res.ok) {
|
|
274
|
-
return yellow(`migration_head RPC responded ${res.status} — cannot verify schema head`);
|
|
275
|
-
}
|
|
276
|
-
const actual = String((await res.json()) ?? '');
|
|
277
|
-
if (!actual) {
|
|
278
|
-
return red(
|
|
279
|
-
`Database reports no applied migrations (expected head ${expected}). Run: npx selva-supabase && npx supabase db push`
|
|
280
|
-
);
|
|
281
|
-
}
|
|
282
|
-
// String comparison — relies on both heads being fixed-width, zero-padded timestamps.
|
|
283
|
-
if (actual < expected) {
|
|
284
|
-
return red(
|
|
285
|
-
`Database migration head ${actual} is behind the installed provider (${expected}). Run: npx selva-supabase && npx supabase db push`
|
|
286
|
-
);
|
|
287
|
-
}
|
|
288
|
-
return green(`Database migration head ${actual} matches the installed provider`);
|
|
289
|
-
} catch (err) {
|
|
290
|
-
return yellow(`Migration-head check skipped (${err.message ?? err})`);
|
|
291
|
-
}
|
|
292
|
-
}
|
|
293
|
-
|
|
294
232
|
function checkPackage(dir, name) {
|
|
295
233
|
const path = join(dir, 'node_modules', ...name.split('/'), 'package.json');
|
|
296
234
|
if (!existsSync(path)) return red(`${name} not installed (run npm install)`);
|
|
@@ -460,7 +398,7 @@ function checkEnvDocs(dir) {
|
|
|
460
398
|
if (drift) return red(`refused to strip .env — ${drift}`);
|
|
461
399
|
|
|
462
400
|
copyFileSync(envPath, envPath + '.bak');
|
|
463
|
-
|
|
401
|
+
writeEnvText(envPath, strippedText);
|
|
464
402
|
return green(`stripped ${removed} comment lines from .env (backup: .env.bak)`);
|
|
465
403
|
} catch (err) {
|
|
466
404
|
return red(`could not rewrite .env: ${err instanceof Error ? err.message : err}`);
|
package/src/commands/init.js
CHANGED
|
@@ -2,11 +2,12 @@
|
|
|
2
2
|
// the current .env as prompt defaults and never touches package.json or runs
|
|
3
3
|
// npm install.
|
|
4
4
|
|
|
5
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
5
6
|
import { join } from 'node:path';
|
|
6
7
|
import * as p from '@clack/prompts';
|
|
7
8
|
import pc from 'picocolors';
|
|
8
9
|
import { collectConfig } from '../prompts.js';
|
|
9
|
-
import {
|
|
10
|
+
import { parseEnv, seededTemplateDefaults, setEnvValues, writeEnvText } from '../env.js';
|
|
10
11
|
import { generateKey } from '../secrets.js';
|
|
11
12
|
import { readEnvTemplate, requireDeploymentDir, resolveDeploymentDir } from '../paths.js';
|
|
12
13
|
|
|
@@ -15,7 +16,8 @@ export async function runInit() {
|
|
|
15
16
|
requireDeploymentDir(dir);
|
|
16
17
|
|
|
17
18
|
const envPath = join(dir, '.env');
|
|
18
|
-
const
|
|
19
|
+
const text = existsSync(envPath) ? readFileSync(envPath, 'utf8') : '';
|
|
20
|
+
const current = parseEnv(text);
|
|
19
21
|
const values = await collectConfig({ defaults: current, mode: 'init' });
|
|
20
22
|
|
|
21
23
|
// Never regenerate an existing key: rotating SELVA_HMAC_KEY or
|
|
@@ -42,11 +44,17 @@ export async function runInit() {
|
|
|
42
44
|
p.log.warn('SELVA_AT_REST_KEY was missing — generated a fresh one.');
|
|
43
45
|
}
|
|
44
46
|
|
|
45
|
-
//
|
|
46
|
-
//
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
47
|
+
// Line-level: keys the prompts don't cover, and the operator's comments, stay as they are.
|
|
48
|
+
// A prompt answered blank means unset, as it did when the file was re-rendered.
|
|
49
|
+
const updates = Object.fromEntries(
|
|
50
|
+
Object.entries(values).map(([k, v]) => [k, v === '' ? undefined : v])
|
|
51
|
+
);
|
|
52
|
+
// A template default missing from .env (BODY_SIZE_LIMIT: unset, every upload
|
|
53
|
+
// 413s at adapter-node's 512 KB) is filled in; one the operator set is kept.
|
|
54
|
+
for (const [k, v] of Object.entries(seededTemplateDefaults(readEnvTemplate(dir)))) {
|
|
55
|
+
if (current[k] === undefined && !(k in updates)) updates[k] = v;
|
|
56
|
+
}
|
|
57
|
+
writeEnvText(envPath, setEnvValues(text, updates));
|
|
50
58
|
|
|
51
59
|
p.outro(
|
|
52
60
|
[
|
package/src/commands/keys.js
CHANGED
|
@@ -1,67 +1,146 @@
|
|
|
1
|
-
//
|
|
1
|
+
// `selva keys rotate <hmac|at-rest> [--finish | --leaked]`.
|
|
2
|
+
//
|
|
3
|
+
// A routine rotation keeps the old key as <KEY>_PREVIOUS, which the app still
|
|
4
|
+
// accepts, so nothing breaks. `--finish` drops the previous key once its
|
|
5
|
+
// overlap has passed. `--leaked` replaces the key and drops the old one at
|
|
6
|
+
// once: that is the destructive path, for a key that must stop working now.
|
|
2
7
|
|
|
8
|
+
import { existsSync, readFileSync, readdirSync } from 'node:fs';
|
|
3
9
|
import { join } from 'node:path';
|
|
4
10
|
import * as p from '@clack/prompts';
|
|
5
11
|
import pc from 'picocolors';
|
|
6
|
-
import {
|
|
12
|
+
import { parseEnv, setEnvValues, writeEnvText } from '../env.js';
|
|
13
|
+
import { resolveProviders } from '../checks/config.js';
|
|
7
14
|
import { generateKey } from '../secrets.js';
|
|
8
|
-
import {
|
|
15
|
+
import { PREVIOUS_KEY_DAYS } from '../generated/config.js';
|
|
16
|
+
import { requireDeploymentDir, resolveDeploymentDir } from '../paths.js';
|
|
9
17
|
|
|
10
18
|
const TARGETS = {
|
|
11
19
|
hmac: {
|
|
12
20
|
envVar: 'SELVA_HMAC_KEY',
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
21
|
+
leakedBlastRadius: hmacBlastRadius,
|
|
22
|
+
finishNote:
|
|
23
|
+
'The app stops accepting the previous key on its own at SELVA_HMAC_KEY_PREVIOUS_EXPIRES; ' +
|
|
24
|
+
'finishing ends it now. Either way this stops working: MCP clients registered before the ' +
|
|
25
|
+
'rotation (they register again), and share links, invites and API tokens stored under the ' +
|
|
26
|
+
'previous key before tokens were hashed without one. Sessions it signed last 8 hours at most.'
|
|
18
27
|
},
|
|
19
28
|
'at-rest': {
|
|
20
29
|
envVar: 'SELVA_AT_REST_KEY',
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
].join('\n')
|
|
30
|
+
leakedBlastRadius: atRestBlastRadius,
|
|
31
|
+
finishNote:
|
|
32
|
+
'The app re-seals stored API keys under the new key at boot, and stops accepting the previous ' +
|
|
33
|
+
'key at SELVA_AT_REST_KEY_PREVIOUS_EXPIRES. Check that /admin/system shows none left on the ' +
|
|
34
|
+
'previous key before finishing, or they become unreadable.'
|
|
27
35
|
}
|
|
28
36
|
};
|
|
29
37
|
|
|
38
|
+
/** What dropping the old SELVA_HMAC_KEY at once invalidates on this deployment's providers. */
|
|
39
|
+
export function hmacBlastRadius(env) {
|
|
40
|
+
const { auth } = resolveProviders(env);
|
|
41
|
+
const lines = [];
|
|
42
|
+
// Only the local provider signs sessions with it: Supabase sessions are
|
|
43
|
+
// Supabase JWTs, and under header auth the proxy owns the session.
|
|
44
|
+
if (auth === 'local') lines.push('log every signed-in user out');
|
|
45
|
+
lines.push(
|
|
46
|
+
'invalidate share links, invites and API tokens created before stored tokens were hashed without a key',
|
|
47
|
+
'disconnect every MCP client (each has to register and sign in again)',
|
|
48
|
+
'fail live solve-event streams of solves already running'
|
|
49
|
+
);
|
|
50
|
+
return lines;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function atRestBlastRadius() {
|
|
54
|
+
return [
|
|
55
|
+
'make the stored Rhino.Compute API keys unreadable',
|
|
56
|
+
'require re-entering each API key at /admin/compute'
|
|
57
|
+
];
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** The .env change for each mode, as `setEnvValues` updates. */
|
|
61
|
+
export function rotationUpdates(env, envVar, mode, fresh = generateKey(), now = Date.now()) {
|
|
62
|
+
const previousVar = `${envVar}_PREVIOUS`;
|
|
63
|
+
const expiresVar = `${previousVar}_EXPIRES`;
|
|
64
|
+
if (mode === 'finish') return { [previousVar]: undefined, [expiresVar]: undefined };
|
|
65
|
+
if (mode === 'leaked')
|
|
66
|
+
return { [envVar]: fresh, [previousVar]: undefined, [expiresVar]: undefined };
|
|
67
|
+
const expires = new Date(now + PREVIOUS_KEY_DAYS * 86_400_000).toISOString();
|
|
68
|
+
return { [envVar]: fresh, [previousVar]: env[envVar], [expiresVar]: expires };
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function backupsHolding(dir, value) {
|
|
72
|
+
if (!value) return [];
|
|
73
|
+
return readdirSync(dir)
|
|
74
|
+
.filter((f) => f.startsWith('.env.') && f.endsWith('.bak'))
|
|
75
|
+
.filter((f) => readFileSync(join(dir, f), 'utf8').includes(value));
|
|
76
|
+
}
|
|
77
|
+
|
|
30
78
|
export async function runKeysRotate(argv) {
|
|
31
79
|
const target = argv[0];
|
|
80
|
+
const mode = argv.includes('--finish')
|
|
81
|
+
? 'finish'
|
|
82
|
+
: argv.includes('--leaked')
|
|
83
|
+
? 'leaked'
|
|
84
|
+
: 'rotate';
|
|
32
85
|
if (!target || !(target in TARGETS)) {
|
|
33
|
-
console.error(
|
|
86
|
+
console.error('Usage: selva keys rotate <hmac|at-rest> [--finish | --leaked]');
|
|
34
87
|
process.exit(1);
|
|
35
88
|
}
|
|
36
89
|
|
|
37
90
|
const dir = resolveDeploymentDir();
|
|
38
91
|
requireDeploymentDir(dir);
|
|
39
92
|
|
|
40
|
-
const { envVar,
|
|
93
|
+
const { envVar, leakedBlastRadius, finishNote } = TARGETS[target];
|
|
94
|
+
const previousVar = `${envVar}_PREVIOUS`;
|
|
41
95
|
const envPath = join(dir, '.env');
|
|
42
|
-
const
|
|
96
|
+
const text = existsSync(envPath) ? readFileSync(envPath, 'utf8') : '';
|
|
97
|
+
const env = parseEnv(text);
|
|
43
98
|
|
|
44
|
-
p.intro(pc.bgYellow(pc.black(` rotate ${envVar} `)));
|
|
45
|
-
|
|
99
|
+
p.intro(pc.bgYellow(pc.black(` ${mode === 'rotate' ? 'rotate' : mode} ${envVar} `)));
|
|
100
|
+
|
|
101
|
+
if (mode === 'rotate') {
|
|
102
|
+
if (env[previousVar]) {
|
|
103
|
+
p.cancel(
|
|
104
|
+
`${previousVar} is still set from the last rotation. Run \`selva keys rotate ${target} --finish\` first.`
|
|
105
|
+
);
|
|
106
|
+
process.exit(1);
|
|
107
|
+
}
|
|
108
|
+
p.note(
|
|
109
|
+
`The current key moves to ${previousVar}, which the app keeps accepting for ${PREVIOUS_KEY_DAYS} days.\nNothing stops working now. After that it is refused on its own; \`selva keys rotate ${target} --finish\`\nremoves it from .env.`,
|
|
110
|
+
'Safe rotation'
|
|
111
|
+
);
|
|
112
|
+
} else if (mode === 'finish') {
|
|
113
|
+
if (!env[previousVar]) {
|
|
114
|
+
p.outro(pc.dim(`${previousVar} is not set: nothing to finish.`));
|
|
115
|
+
return;
|
|
116
|
+
}
|
|
117
|
+
p.note(finishNote, 'Before you finish');
|
|
118
|
+
} else {
|
|
119
|
+
p.note(
|
|
120
|
+
['This will:', ...leakedBlastRadius(env).map((l) => pc.red(` • ${l}`))].join('\n'),
|
|
121
|
+
'Blast radius'
|
|
122
|
+
);
|
|
123
|
+
}
|
|
46
124
|
|
|
47
|
-
const confirmed = await p.confirm({
|
|
48
|
-
message: `Rotate ${envVar}?`,
|
|
49
|
-
initialValue: false
|
|
50
|
-
});
|
|
125
|
+
const confirmed = await p.confirm({ message: 'Continue?', initialValue: mode === 'rotate' });
|
|
51
126
|
if (p.isCancel(confirmed) || !confirmed) {
|
|
52
127
|
p.cancel('Cancelled.');
|
|
53
128
|
return;
|
|
54
129
|
}
|
|
55
130
|
|
|
56
|
-
|
|
57
|
-
current[envVar] = fresh;
|
|
131
|
+
writeEnvText(envPath, setEnvValues(text, rotationUpdates(env, envVar, mode)));
|
|
58
132
|
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
133
|
+
const lines = [pc.green(mode === 'finish' ? `${previousVar} removed.` : `${envVar} rotated.`)];
|
|
134
|
+
if (mode !== 'rotate') {
|
|
135
|
+
const stale = backupsHolding(dir, mode === 'finish' ? env[previousVar] : env[envVar]);
|
|
136
|
+
if (stale.length > 0) {
|
|
137
|
+
lines.push(
|
|
138
|
+
pc.yellow(
|
|
139
|
+
`These backups still hold the old key: ${stale.join(', ')}. Delete them if it leaked.`
|
|
140
|
+
)
|
|
141
|
+
);
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
lines.push(pc.dim('Restart the app to apply: ') + pc.cyan('selva restart'));
|
|
145
|
+
p.outro(lines.join('\n'));
|
|
67
146
|
}
|
package/src/commands/migrate.js
CHANGED
|
@@ -2,13 +2,22 @@
|
|
|
2
2
|
// provider packages), removes stale selva.config.js, updates ecosystem.config.cjs.
|
|
3
3
|
// Idempotent; mirrors update's lifecycle (stop/mutate/start with rollback on failure).
|
|
4
4
|
|
|
5
|
-
import {
|
|
5
|
+
import {
|
|
6
|
+
existsSync,
|
|
7
|
+
readFileSync,
|
|
8
|
+
readdirSync,
|
|
9
|
+
writeFileSync,
|
|
10
|
+
copyFileSync,
|
|
11
|
+
rmSync,
|
|
12
|
+
renameSync
|
|
13
|
+
} from 'node:fs';
|
|
6
14
|
import { join } from 'node:path';
|
|
7
15
|
import { execSync } from 'node:child_process';
|
|
8
16
|
import * as p from '@clack/prompts';
|
|
9
17
|
import pc from 'picocolors';
|
|
10
18
|
import { requireDeploymentDir, resolveDeploymentDir } from '../paths.js';
|
|
11
|
-
import { renameEnvKeys, RENAMED_ENV_VARS } from '../env.js';
|
|
19
|
+
import { readEnvFile, renameEnvKeys, RENAMED_ENV_VARS, writeEnvText } from '../env.js';
|
|
20
|
+
import { planEnvMigration } from '../env-migration.js';
|
|
12
21
|
import {
|
|
13
22
|
buildDeploymentPackageJson,
|
|
14
23
|
CANONICAL_FIELDS,
|
|
@@ -100,7 +109,7 @@ export function classifyBackups(names, now = new Date(), maxAgeDays = BACKUP_AGE
|
|
|
100
109
|
// dependency tree, so it has to run against a real directory — but not against
|
|
101
110
|
// a real npm or pm2 daemon.
|
|
102
111
|
export async function runMigrate(
|
|
103
|
-
|
|
112
|
+
argv = [],
|
|
104
113
|
{
|
|
105
114
|
dir: injectedDir,
|
|
106
115
|
// --prefer-online: npm can otherwise resolve a cached packument that
|
|
@@ -114,7 +123,13 @@ export async function runMigrate(
|
|
|
114
123
|
const dir = injectedDir ?? resolveDeploymentDir();
|
|
115
124
|
requireDeploymentDir(dir);
|
|
116
125
|
|
|
117
|
-
|
|
126
|
+
const dryRun = argv.includes('--dry-run');
|
|
127
|
+
p.intro(pc.bgCyan(pc.black(dryRun ? ' selva migrate --dry-run ' : ' selva migrate ')));
|
|
128
|
+
|
|
129
|
+
if (argv.includes('--undo')) {
|
|
130
|
+
undoEnvMigration(dir);
|
|
131
|
+
return;
|
|
132
|
+
}
|
|
118
133
|
|
|
119
134
|
const pkgPath = join(dir, 'package.json');
|
|
120
135
|
if (!existsSync(pkgPath)) {
|
|
@@ -123,7 +138,7 @@ export async function runMigrate(
|
|
|
123
138
|
}
|
|
124
139
|
|
|
125
140
|
const before = JSON.parse(readFileSync(pkgPath, 'utf8'));
|
|
126
|
-
const deploymentEnv =
|
|
141
|
+
const deploymentEnv = readEnvFile(join(dir, '.env'));
|
|
127
142
|
const { pkg: target, notes: pinNotes } = buildTargetPackageJson(
|
|
128
143
|
before,
|
|
129
144
|
resolveVersion,
|
|
@@ -131,32 +146,76 @@ export async function runMigrate(
|
|
|
131
146
|
);
|
|
132
147
|
const pkgDiff = diffPackageJson(before, target);
|
|
133
148
|
|
|
134
|
-
// The server
|
|
135
|
-
//
|
|
136
|
-
// reverting to defaults later.
|
|
149
|
+
// The server stops reading old names in a later release, so rewriting now is
|
|
150
|
+
// what keeps a tuned value tuned instead of silently reverting to a default.
|
|
137
151
|
const envPath = join(dir, '.env');
|
|
138
152
|
const envBefore = existsSync(envPath) ? readFileSync(envPath, 'utf8') : null;
|
|
139
|
-
const
|
|
140
|
-
?
|
|
141
|
-
: { text: '', changes: [] };
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
153
|
+
const envPlan = envBefore
|
|
154
|
+
? planEnvMigration(envBefore)
|
|
155
|
+
: { text: '', changes: [], mismatches: [], blockers: [], fallbacks: [], unknown: [] };
|
|
156
|
+
const envChanged = envPlan.changes.length > 0;
|
|
157
|
+
|
|
158
|
+
// The rewrite would change what a setting means. Never write that.
|
|
159
|
+
if (envPlan.mismatches.length > 0) {
|
|
160
|
+
p.log.error('The .env rewrite would change these settings, so nothing was written:');
|
|
161
|
+
for (const m of envPlan.mismatches) {
|
|
162
|
+
console.log(` ${m.key}: ${JSON.stringify(m.was)} → ${JSON.stringify(m.is)}`);
|
|
163
|
+
}
|
|
164
|
+
p.outro(pc.red('Migration refused. Please report this: it is a bug in the migration.'));
|
|
165
|
+
process.exit(1);
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
// Values the old readers ignored that this release refuses to boot on.
|
|
169
|
+
if (envPlan.blockers.length > 0) {
|
|
170
|
+
p.log.error('This release would not start with these .env values:');
|
|
171
|
+
for (const b of envPlan.blockers) console.log(` ${b.key} ${b.problem}`);
|
|
172
|
+
p.outro(
|
|
173
|
+
pc.red(
|
|
174
|
+
'Fix them first (`selva config set KEY=VALUE`), then run migrate again. Nothing was written.'
|
|
175
|
+
)
|
|
149
176
|
);
|
|
177
|
+
process.exit(1);
|
|
150
178
|
}
|
|
179
|
+
for (const f of envPlan.fallbacks) {
|
|
180
|
+
p.log.warn(`${f.key} ${f.problem}: the app runs on its default instead.`);
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
const sideFileChanges = envPlan.changes.map(describeEnvChange);
|
|
151
184
|
|
|
152
185
|
if (pkgDiff.length === 0 && sideFileChanges.length === 0) {
|
|
186
|
+
reportUnknown(envPlan.unknown);
|
|
153
187
|
p.outro(pc.green('Already on the current layout — nothing to migrate.'));
|
|
154
188
|
return;
|
|
155
189
|
}
|
|
156
190
|
|
|
157
|
-
p.log.info('Changes to apply:');
|
|
191
|
+
p.log.info(dryRun ? 'Would apply (nothing is written in a dry run):' : 'Changes to apply:');
|
|
158
192
|
for (const line of pkgDiff) console.log(' ' + line);
|
|
159
193
|
for (const line of sideFileChanges) console.log(' ' + line);
|
|
194
|
+
reportUnknown(envPlan.unknown);
|
|
195
|
+
|
|
196
|
+
if (dryRun) {
|
|
197
|
+
p.outro(pc.green('Every setting keeps its meaning. Run without --dry-run to apply.'));
|
|
198
|
+
return;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
// Only .env changes: no reinstall, no restart of anyone's running app.
|
|
202
|
+
if (pkgDiff.length === 0) {
|
|
203
|
+
const go = await confirm('Rewrite .env (a backup is kept)?');
|
|
204
|
+
if (p.isCancel(go) || !go) {
|
|
205
|
+
p.cancel('Cancelled.');
|
|
206
|
+
return;
|
|
207
|
+
}
|
|
208
|
+
const bak = backupPathFor(envPath, backupStamp());
|
|
209
|
+
copyFileSync(envPath, bak);
|
|
210
|
+
writeEnvText(bak, readFileSync(bak, 'utf8'));
|
|
211
|
+
writeEnvText(envPath, envPlan.text);
|
|
212
|
+
p.outro(
|
|
213
|
+
pc.green('.env migrated. ') +
|
|
214
|
+
pc.dim(`Backup: ${bak}. Undo: selva migrate --undo. Apply: `) +
|
|
215
|
+
pc.cyan('selva restart')
|
|
216
|
+
);
|
|
217
|
+
return;
|
|
218
|
+
}
|
|
160
219
|
|
|
161
220
|
// The diff shows the pin that will be written; these explain why it is that
|
|
162
221
|
// pin. A preserved beta and a pin that couldn't be resolved both look like
|
|
@@ -203,9 +262,10 @@ export async function runMigrate(
|
|
|
203
262
|
copyFileSync(pkgPath, bakPath);
|
|
204
263
|
writeFileSync(pkgPath, JSON.stringify(target, null, 2) + '\n', 'utf8');
|
|
205
264
|
|
|
206
|
-
if (
|
|
265
|
+
if (envChanged) {
|
|
207
266
|
copyFileSync(envPath, envBak);
|
|
208
|
-
|
|
267
|
+
writeEnvText(envBak, readFileSync(envBak, 'utf8'));
|
|
268
|
+
writeEnvText(envPath, envPlan.text);
|
|
209
269
|
}
|
|
210
270
|
|
|
211
271
|
// A legacy lockfile pins the old package set across a major bump, so this needs
|
|
@@ -231,7 +291,7 @@ export async function runMigrate(
|
|
|
231
291
|
} catch (err) {
|
|
232
292
|
s.stop(pc.red('npm install failed — rolling back'));
|
|
233
293
|
copyFileSync(bakPath, pkgPath);
|
|
234
|
-
if (
|
|
294
|
+
if (envChanged && existsSync(envBak)) {
|
|
235
295
|
copyFileSync(envBak, envPath);
|
|
236
296
|
}
|
|
237
297
|
|
|
@@ -263,12 +323,12 @@ export async function runMigrate(
|
|
|
263
323
|
// here, once, instead of letting doctor nag forever. Deliberately NOT
|
|
264
324
|
// automated: `pm2 update` recycles the daemon, and doing that unattended
|
|
265
325
|
// inside a migration converts a mid-flight failure into an outage (see
|
|
266
|
-
// plans/
|
|
326
|
+
// plans/archive/host-prerequisites-and-pm2-audit.md).
|
|
267
327
|
const pm2Notice = buildPm2UpgradeNotice(before.dependencies?.pm2, target.dependencies.pm2);
|
|
268
328
|
if (pm2Notice) p.log.warn(pm2Notice);
|
|
269
329
|
|
|
270
330
|
const backupHints = [`package.json.${stamp}.bak`];
|
|
271
|
-
if (
|
|
331
|
+
if (envChanged) backupHints.push(`.env.${stamp}.bak`);
|
|
272
332
|
|
|
273
333
|
if (status === 0) {
|
|
274
334
|
p.outro(
|
|
@@ -305,19 +365,6 @@ function restartAfterRollback(dir, pm2 = runPm2) {
|
|
|
305
365
|
}
|
|
306
366
|
}
|
|
307
367
|
|
|
308
|
-
// Only the provider keys matter here, so this reads `KEY=value` lines rather
|
|
309
|
-
// than pulling in a dotenv parser: no quoting, expansion, or multi-line values
|
|
310
|
-
// to honour.
|
|
311
|
-
function readDeploymentEnv(envPath) {
|
|
312
|
-
if (!existsSync(envPath)) return {};
|
|
313
|
-
const env = {};
|
|
314
|
-
for (const line of readFileSync(envPath, 'utf8').split(/\r?\n/)) {
|
|
315
|
-
const match = /^\s*([A-Z0-9_]+)\s*=\s*(.*)$/.exec(line);
|
|
316
|
-
if (match) env[match[1]] = match[2].trim().replace(/^["']|["']$/g, '');
|
|
317
|
-
}
|
|
318
|
-
return env;
|
|
319
|
-
}
|
|
320
|
-
|
|
321
368
|
function buildTargetPackageJson(current, resolveVersion = npmDistTagVersion, env = {}) {
|
|
322
369
|
const supabase = needsSupabaseProvider(env);
|
|
323
370
|
const { pins, notes } = resolveSelvaPins(
|
|
@@ -475,3 +522,51 @@ export function buildPm2UpgradeNotice(beforeVersion, afterVersion) {
|
|
|
475
522
|
}
|
|
476
523
|
|
|
477
524
|
export { buildTargetPackageJson, diffPackageJson };
|
|
525
|
+
|
|
526
|
+
function describeEnvChange(c) {
|
|
527
|
+
const env = (text) => `${pc.yellow('~')} .env ${pc.dim(text)}`;
|
|
528
|
+
switch (c.action) {
|
|
529
|
+
case 'stripped':
|
|
530
|
+
return env('shipped template comments removed (your own notes above a setting are kept)');
|
|
531
|
+
case 'renamed':
|
|
532
|
+
return env(`${c.key} → ${c.to}`);
|
|
533
|
+
case 'dropped':
|
|
534
|
+
return `${pc.red('-')} .env ${pc.dim(`${c.key} (${c.reason})`)}`;
|
|
535
|
+
case 'replaced':
|
|
536
|
+
return env(`${c.key} → ${c.to}`);
|
|
537
|
+
case 'rewritten':
|
|
538
|
+
return env(`${c.key} written as ${c.to}, what it already meant`);
|
|
539
|
+
case 'removed':
|
|
540
|
+
return `${pc.red('-')} .env ${pc.dim(`${c.key} (${c.reason})`)}`;
|
|
541
|
+
default:
|
|
542
|
+
return env(c.key);
|
|
543
|
+
}
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
function reportUnknown(unknown) {
|
|
547
|
+
for (const u of unknown) {
|
|
548
|
+
p.log.warn(
|
|
549
|
+
`.env sets ${u.key}, which Selva does not read${u.suggestion ? ` (did you mean ${u.suggestion}?)` : ''}. Left as is.`
|
|
550
|
+
);
|
|
551
|
+
}
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
/** Restore the newest .env backup migrate (or config set) wrote. */
|
|
555
|
+
function undoEnvMigration(dir) {
|
|
556
|
+
const backups = readdirSync(dir)
|
|
557
|
+
.filter((f) => f.startsWith('.env.') && BACKUP_PATTERN.test(f))
|
|
558
|
+
.sort()
|
|
559
|
+
.reverse();
|
|
560
|
+
if (backups.length === 0) {
|
|
561
|
+
p.outro(pc.yellow('No .env backup to restore.'));
|
|
562
|
+
return;
|
|
563
|
+
}
|
|
564
|
+
const envPath = join(dir, '.env');
|
|
565
|
+
const current = existsSync(envPath) ? readFileSync(envPath, 'utf8') : '';
|
|
566
|
+
// Keep what is being replaced, so an undo can itself be undone.
|
|
567
|
+
if (current) writeEnvText(backupPathFor(envPath, backupStamp()), current);
|
|
568
|
+
writeEnvText(envPath, readFileSync(join(dir, backups[0]), 'utf8'));
|
|
569
|
+
p.outro(
|
|
570
|
+
pc.green(`Restored .env from ${backups[0]}. `) + pc.dim('Apply: ') + pc.cyan('selva restart')
|
|
571
|
+
);
|
|
572
|
+
}
|