@selvajs/cli 4.8.0-beta.10 → 4.8.0-beta.12
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 +7 -12
- package/package.json +4 -1
- package/src/checks/boot.js +18 -16
- package/src/checks/config.js +101 -9
- package/src/checks/runtime.js +291 -0
- package/src/cli.js +1 -1
- package/src/commands/create.js +27 -22
- package/src/commands/doctor.js +91 -81
- package/src/commands/init.js +12 -13
- package/src/commands/migrate.js +142 -48
- package/src/commands/pm2.js +12 -21
- package/src/deployment-package.js +156 -4
- package/src/env.js +197 -25
- package/src/paths.js +2 -3
- package/src/prompts.js +4 -14
package/src/env.js
CHANGED
|
@@ -2,13 +2,10 @@
|
|
|
2
2
|
|
|
3
3
|
import { readFileSync, writeFileSync, existsSync } from 'node:fs';
|
|
4
4
|
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
* reports them and `selva migrate` rewrites them. Keep in sync with that list,
|
|
10
|
-
* and drop an entry when the server stops reading it.
|
|
11
|
-
*/
|
|
5
|
+
// Old env name → its replacement, for keys renamed without changing meaning.
|
|
6
|
+
// The server still reads each old name for one minor version and warns at
|
|
7
|
+
// boot; keep this in sync with the server's list and drop an entry once the
|
|
8
|
+
// server stops reading it.
|
|
12
9
|
export const RENAMED_ENV_VARS = {
|
|
13
10
|
COMPUTE_DEFINITION_BYTE_CACHE_MB: 'COMPUTE_DEFINITION_CACHE_MB',
|
|
14
11
|
COMPUTE_RESPONSE_CACHE_MB: 'COMPUTE_SOLVE_CACHE_MB',
|
|
@@ -16,16 +13,148 @@ export const RENAMED_ENV_VARS = {
|
|
|
16
13
|
MAX_SOLVE_DURATION_MS: 'COMPUTE_SOLVE_DEADLINE_MS'
|
|
17
14
|
};
|
|
18
15
|
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
* rewritten by renaming a key. `selva doctor` reports these; migrate leaves
|
|
22
|
-
* them alone rather than guessing.
|
|
23
|
-
*/
|
|
16
|
+
// Deprecated vars whose replacement changes the VALUE too, so a simple key
|
|
17
|
+
// rename can't migrate them — `selva migrate` leaves these alone rather than guessing.
|
|
24
18
|
export const REPLACED_ENV_VARS = {
|
|
25
19
|
SELVA_FLAG_COMPUTE_DEBUG_VERBOSE: 'SELVA_FLAG_COMPUTE_DEBUG=verbose'
|
|
26
20
|
};
|
|
27
21
|
|
|
28
|
-
//
|
|
22
|
+
// Header written above the settings in a stripped .env. The pointer matters:
|
|
23
|
+
// without it the operator has no way back to what a var means, and the reflex
|
|
24
|
+
// is to paste the old annotated template back in.
|
|
25
|
+
export const ENV_HEADER = [
|
|
26
|
+
'# Selva deployment configuration.',
|
|
27
|
+
'#',
|
|
28
|
+
'# Values only — every variable is documented in the runtime template at',
|
|
29
|
+
'# node_modules/@selvajs/selva/templates/.env.example, which is refreshed on',
|
|
30
|
+
'# every update. Do not paste that template in here: a copy stops tracking',
|
|
31
|
+
'# the release and starts describing variables the code no longer reads.'
|
|
32
|
+
].join('\n');
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Strip a deployment `.env` down to its live settings, dropping the shipped
|
|
36
|
+
* documentation block.
|
|
37
|
+
*
|
|
38
|
+
* A comment block copied onto a server is a snapshot with no update path —
|
|
39
|
+
* `migrate` rewrites keys but never prose, so a deployment keeps documenting
|
|
40
|
+
* the release it was installed at, describing vars the code has since renamed
|
|
41
|
+
* or retired. Deleting it is what makes the file navigable AND unable to lie.
|
|
42
|
+
*
|
|
43
|
+
* Operator comments are NOT prose to be discarded: a short `#` run directly
|
|
44
|
+
* above a setting (no blank line between) is that setting's note and rides
|
|
45
|
+
* along with it. Everything else goes.
|
|
46
|
+
*
|
|
47
|
+
* "Short" is doing real work here. The shipped template also writes prose
|
|
48
|
+
* immediately above a setting — its banner blocks run 40+ lines and end with a
|
|
49
|
+
* bare `#` — so proximity alone would keep the very documentation this exists
|
|
50
|
+
* to remove. A hand-written note is a line or two; a block longer than
|
|
51
|
+
* ATTACHED_NOTE_MAX_LINES, or one containing a `# ====` banner rule, is the
|
|
52
|
+
* template's and is dropped.
|
|
53
|
+
*
|
|
54
|
+
* Returns `{ text, removed }`, `removed` being the count of dropped comment
|
|
55
|
+
* lines. `removed === 0` means the file was already values-only.
|
|
56
|
+
*/
|
|
57
|
+
export function stripEnvComments(text, { header = ENV_HEADER } = {}) {
|
|
58
|
+
// Our own header is re-emitted below, so counting it as prose we removed
|
|
59
|
+
// would make every run report work it didn't do — and break idempotency,
|
|
60
|
+
// which is what lets doctor call a stripped file clean. Matched as a whole
|
|
61
|
+
// leading block, not line-by-line: the header contains a bare `#`, and
|
|
62
|
+
// excluding that by value would silently stop counting an operator's own
|
|
63
|
+
// `#` separators anywhere in the file.
|
|
64
|
+
let lines = text.split(/\r?\n/);
|
|
65
|
+
if (header) {
|
|
66
|
+
const headerLines = header.split('\n');
|
|
67
|
+
const leads = headerLines.every((h, i) => lines[i]?.trim() === h.trim());
|
|
68
|
+
if (leads) lines = lines.slice(headerLines.length);
|
|
69
|
+
}
|
|
70
|
+
const out = [];
|
|
71
|
+
let pending = [];
|
|
72
|
+
let removed = 0;
|
|
73
|
+
let sawSetting = false;
|
|
74
|
+
// Whether a blank line or dropped prose separated the last setting from the
|
|
75
|
+
// next one — the original file's own grouping, worth carrying over.
|
|
76
|
+
let groupBreak = false;
|
|
77
|
+
|
|
78
|
+
const flushDropped = () => {
|
|
79
|
+
const dropped = pending.filter((l) => l.trim().startsWith('#'));
|
|
80
|
+
removed += dropped.length;
|
|
81
|
+
// A discarded banner marked a section boundary in the original; keep that
|
|
82
|
+
// boundary as a blank line so the result still reads as grouped settings.
|
|
83
|
+
if (dropped.length > 0 && sawSetting) groupBreak = true;
|
|
84
|
+
pending = [];
|
|
85
|
+
};
|
|
86
|
+
|
|
87
|
+
for (const line of lines) {
|
|
88
|
+
const stripped = line.trim();
|
|
89
|
+
|
|
90
|
+
if (stripped.startsWith('#')) {
|
|
91
|
+
pending.push(line);
|
|
92
|
+
continue;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// A blank line ends whatever comment block was accumulating: those
|
|
96
|
+
// comments float free rather than annotating the next setting.
|
|
97
|
+
if (!stripped) {
|
|
98
|
+
if (sawSetting) groupBreak = true;
|
|
99
|
+
flushDropped();
|
|
100
|
+
continue;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
if (stripped.indexOf('=') === -1) {
|
|
104
|
+
flushDropped();
|
|
105
|
+
continue;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// Comments still pending sit directly above this setting. Keep them only
|
|
109
|
+
// if they read as a hand-written note rather than a template banner.
|
|
110
|
+
const note = isAttachedNote(pending);
|
|
111
|
+
// Blank line only where it earns one: before a kept note, or where the
|
|
112
|
+
// original had a break. Separating every key would just trade a wall of
|
|
113
|
+
// prose for a file twice as long as it needs to be.
|
|
114
|
+
if (sawSetting && (note || groupBreak)) out.push('');
|
|
115
|
+
if (note) {
|
|
116
|
+
out.push(...pending);
|
|
117
|
+
} else {
|
|
118
|
+
flushDropped();
|
|
119
|
+
}
|
|
120
|
+
pending = [];
|
|
121
|
+
groupBreak = false;
|
|
122
|
+
out.push(line);
|
|
123
|
+
sawSetting = true;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// Trailing comments annotate nothing.
|
|
127
|
+
flushDropped();
|
|
128
|
+
|
|
129
|
+
const body = out.join('\n');
|
|
130
|
+
return {
|
|
131
|
+
text: header ? `${header}\n\n${body}\n` : `${body}\n`,
|
|
132
|
+
removed
|
|
133
|
+
};
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// A hand-written note is a line or two. Longer than this and it is the shipped
|
|
137
|
+
// template's prose, which is what stripping exists to remove.
|
|
138
|
+
const ATTACHED_NOTE_MAX_LINES = 3;
|
|
139
|
+
|
|
140
|
+
// A `# ====` rule is the template's section-banner style; nothing hand-written
|
|
141
|
+
// above a single setting uses it.
|
|
142
|
+
const BANNER_RULE = /^#\s*[=-]{4,}/;
|
|
143
|
+
|
|
144
|
+
function isAttachedNote(pending) {
|
|
145
|
+
if (pending.length === 0 || pending.length > ATTACHED_NOTE_MAX_LINES) return false;
|
|
146
|
+
return !pending.some((l) => BANNER_RULE.test(l.trim()));
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* How many lines of the file are shipped documentation rather than config.
|
|
151
|
+
* Used by `selva doctor` to flag an annotated `.env` without rewriting it.
|
|
152
|
+
*/
|
|
153
|
+
export function countEnvCommentLines(text) {
|
|
154
|
+
const { removed } = stripEnvComments(text);
|
|
155
|
+
return removed;
|
|
156
|
+
}
|
|
157
|
+
|
|
29
158
|
export function parseEnv(text) {
|
|
30
159
|
const out = {};
|
|
31
160
|
for (const rawLine of text.split(/\r?\n/)) {
|
|
@@ -51,7 +180,6 @@ export function readEnvFile(path) {
|
|
|
51
180
|
return parseEnv(readFileSync(path, 'utf8'));
|
|
52
181
|
}
|
|
53
182
|
|
|
54
|
-
// Merge values into template: preserve structure, rewrite in-place, append new keys.
|
|
55
183
|
export function mergeEnv(template, values) {
|
|
56
184
|
const seen = new Set();
|
|
57
185
|
const lines = template.split(/\r?\n/);
|
|
@@ -92,27 +220,71 @@ export function mergeEnv(template, values) {
|
|
|
92
220
|
out.push(...appended);
|
|
93
221
|
}
|
|
94
222
|
|
|
95
|
-
// Ensure single trailing newline (shell append mishap guard).
|
|
96
223
|
while (out.length > 0 && out[out.length - 1] === '') out.pop();
|
|
97
224
|
return out.join('\n') + '\n';
|
|
98
225
|
}
|
|
99
226
|
|
|
100
|
-
|
|
101
|
-
|
|
227
|
+
/**
|
|
228
|
+
* Render a values-only `.env` — the scaffold's default shape.
|
|
229
|
+
*
|
|
230
|
+
* The annotated template is genuinely useful while you are deciding what to
|
|
231
|
+
* set, which is why the runtime still ships it; it stops being useful the
|
|
232
|
+
* moment it is written to a server and cannot be corrected there. So the
|
|
233
|
+
* template stays a reference and the deployment gets values, in the template's
|
|
234
|
+
* own key order (it groups related settings) with anything unknown appended.
|
|
235
|
+
*/
|
|
236
|
+
export function renderEnvValues(template, values, { header = ENV_HEADER } = {}) {
|
|
237
|
+
const ordered = [];
|
|
238
|
+
const seen = new Set();
|
|
239
|
+
|
|
240
|
+
// The template names a var three ways — live (`KEY=v`), commented-out
|
|
241
|
+
// (`# KEY=v`), and as a bare mention in prose (`# KEY single | multi`).
|
|
242
|
+
// All three signal where the var belongs, so the first token of any line is
|
|
243
|
+
// enough; anything that isn't a known key is filtered out below.
|
|
244
|
+
for (const rawLine of template.split(/\r?\n/)) {
|
|
245
|
+
const stripped = rawLine.trim().replace(/^#\s*/, '');
|
|
246
|
+
const key = stripped.split(/[\s=]/)[0]?.trim();
|
|
247
|
+
if (!key || !/^[A-Z][A-Z0-9_]{2,}$/.test(key)) continue;
|
|
248
|
+
if (seen.has(key)) continue;
|
|
249
|
+
if (!Object.prototype.hasOwnProperty.call(values, key)) continue;
|
|
250
|
+
seen.add(key);
|
|
251
|
+
ordered.push(key);
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
for (const key of Object.keys(values)) {
|
|
255
|
+
if (!seen.has(key)) ordered.push(key);
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
const lines = [];
|
|
259
|
+
for (const key of ordered) {
|
|
260
|
+
const value = values[key];
|
|
261
|
+
if (value === undefined || value === null || value === '') continue;
|
|
262
|
+
lines.push(`${key}=${quoteIfNeeded(value)}`);
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
return header ? `${header}\n\n${lines.join('\n')}\n` : `${lines.join('\n')}\n`;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
/**
|
|
269
|
+
* Writes the deployment `.env`. Values-only by default — pass
|
|
270
|
+
* `{ annotated: true }` to merge into the template's comments instead, which
|
|
271
|
+
* only an operator who explicitly wants the old shape should do.
|
|
272
|
+
*/
|
|
273
|
+
export function writeEnvFile(path, template, values, { annotated = false } = {}) {
|
|
274
|
+
const text = annotated ? mergeEnv(template, values) : renderEnvValues(template, values);
|
|
275
|
+
writeFileSync(path, text, 'utf8');
|
|
102
276
|
}
|
|
103
277
|
|
|
104
278
|
/**
|
|
105
|
-
* Rewrite deprecated keys to their current names, in place.
|
|
106
|
-
*
|
|
107
|
-
* spacing survive, so an operator's tuned file stays recognisably theirs.
|
|
279
|
+
* Rewrite deprecated keys to their current names, in place. Only the key is
|
|
280
|
+
* touched — value, comments, ordering, and spacing survive.
|
|
108
281
|
*
|
|
109
282
|
* A key already present under its new name is left alone and its old line is
|
|
110
|
-
* dropped: the server resolves
|
|
111
|
-
*
|
|
283
|
+
* dropped: the server resolves new-name-wins, so keeping both would preserve
|
|
284
|
+
* a line that does nothing.
|
|
112
285
|
*
|
|
113
|
-
* Returns `{ text, changes }
|
|
114
|
-
* | 'dropped']` per line acted on
|
|
115
|
-
* current and nothing should be written.
|
|
286
|
+
* Returns `{ text, changes }`, `changes` being `[oldName, newName, 'renamed'
|
|
287
|
+
* | 'dropped']` per line acted on. Empty means the file was already current.
|
|
116
288
|
*/
|
|
117
289
|
export function renameEnvKeys(text, renames) {
|
|
118
290
|
const present = new Set(Object.keys(parseEnv(text)));
|
package/src/paths.js
CHANGED
|
@@ -65,9 +65,8 @@ export function readEnvTemplate(dir) {
|
|
|
65
65
|
return '';
|
|
66
66
|
}
|
|
67
67
|
|
|
68
|
-
//
|
|
69
|
-
//
|
|
70
|
-
// CLI doesn't require it — `selva init` is allowed to fix a partial install.
|
|
68
|
+
// Either .env or ecosystem.config.cjs is enough — selva.config.js and
|
|
69
|
+
// node_modules are expected too, but init should still fix a partial install.
|
|
71
70
|
export function isDeploymentDir(dir) {
|
|
72
71
|
return existsSync(join(dir, '.env')) || existsSync(join(dir, 'ecosystem.config.cjs'));
|
|
73
72
|
}
|
package/src/prompts.js
CHANGED
|
@@ -18,9 +18,7 @@ export function collectConfigFromEnv(env = process.env) {
|
|
|
18
18
|
'local',
|
|
19
19
|
'SELVA_AUTH_PROVIDER'
|
|
20
20
|
);
|
|
21
|
-
//
|
|
22
|
-
// for all three" default. Header-auth has no data layer, so it falls
|
|
23
|
-
// through to local unless explicitly overridden.
|
|
21
|
+
// Header-auth has no data layer, so it falls through to local unless overridden.
|
|
24
22
|
const dataDefault = auth === 'header' ? 'local' : auth;
|
|
25
23
|
const data = pick(
|
|
26
24
|
env.SELVA_DATA_PROVIDER,
|
|
@@ -72,7 +70,6 @@ export function collectConfigFromEnv(env = process.env) {
|
|
|
72
70
|
values.HEADER_AUTH_DISPLAY_NAME_HEADER = env.HEADER_AUTH_DISPLAY_NAME_HEADER;
|
|
73
71
|
}
|
|
74
72
|
|
|
75
|
-
// ── Bootstrap admin ──────────────────────────────────────────────────
|
|
76
73
|
const adminRequired = auth === 'header' || tenancy === 'multi';
|
|
77
74
|
const adminEmail = env.BOOTSTRAP_INSTANCE_ADMIN_EMAIL || '';
|
|
78
75
|
if (adminRequired && !adminEmail) {
|
|
@@ -127,15 +124,11 @@ function requireEnv(env, name) {
|
|
|
127
124
|
return v;
|
|
128
125
|
}
|
|
129
126
|
|
|
130
|
-
// Run full interactive prompt sequence; return env vars. Caller decides merge vs fresh.
|
|
131
|
-
// `defaults` pre-populates prompts (cheap re-run).
|
|
132
127
|
export async function collectConfig({ defaults = {}, mode = 'create' } = {}) {
|
|
133
128
|
const isInit = mode === 'init';
|
|
134
129
|
|
|
135
130
|
p.intro(pc.bgCyan(pc.black(isInit ? ' selva init ' : ' Selva — new deployment ')));
|
|
136
131
|
|
|
137
|
-
// Brand prompts (SELVA_BRAND_NAME / COPYRIGHT_NAME / TAGLINE / DESCRIPTION)
|
|
138
|
-
|
|
139
132
|
const tenancy = await p.select({
|
|
140
133
|
message: 'Tenancy mode',
|
|
141
134
|
initialValue: defaults.SELVA_TENANCY ?? 'single',
|
|
@@ -169,7 +162,6 @@ export async function collectConfig({ defaults = {}, mode = 'create' } = {}) {
|
|
|
169
162
|
});
|
|
170
163
|
cancelOn(auth);
|
|
171
164
|
|
|
172
|
-
// Header-auth: auth-only; user must pick separate data/storage backend.
|
|
173
165
|
let data;
|
|
174
166
|
let storage;
|
|
175
167
|
if (auth === 'header') {
|
|
@@ -197,7 +189,6 @@ export async function collectConfig({ defaults = {}, mode = 'create' } = {}) {
|
|
|
197
189
|
cancelOn(storageChoice);
|
|
198
190
|
storage = storageChoice;
|
|
199
191
|
} else {
|
|
200
|
-
// Operators usually want same backend for all three; ask once, default the others.
|
|
201
192
|
const mixProviders = await p.confirm({
|
|
202
193
|
message: `Use ${pc.cyan(auth)} for data and storage too?`,
|
|
203
194
|
initialValue: pickSameProviderDefault(defaults, auth)
|
|
@@ -281,7 +272,6 @@ export async function collectConfig({ defaults = {}, mode = 'create' } = {}) {
|
|
|
281
272
|
}
|
|
282
273
|
|
|
283
274
|
if (auth === 'header') {
|
|
284
|
-
// HEADER_AUTH_DATA_DIR: ask only if data provider isn't local (fallback available).
|
|
285
275
|
if (data !== 'local') {
|
|
286
276
|
const dataDir = await p.text({
|
|
287
277
|
message: 'HEADER_AUTH_DATA_DIR — directory for header-allowlist.json',
|
|
@@ -330,7 +320,7 @@ export async function collectConfig({ defaults = {}, mode = 'create' } = {}) {
|
|
|
330
320
|
providerValues.HEADER_AUTH_DISPLAY_NAME_HEADER = stringValue(display);
|
|
331
321
|
}
|
|
332
322
|
|
|
333
|
-
//
|
|
323
|
+
// Not enforced — a Docker network can still route to the container on another host.
|
|
334
324
|
const bindLoopback = await p.confirm({
|
|
335
325
|
message: 'Bind the app to 127.0.0.1 only? (recommended for header-auth)',
|
|
336
326
|
initialValue: !defaults.HOST || defaults.HOST === '127.0.0.1' || defaults.HOST === 'localhost'
|
|
@@ -339,7 +329,6 @@ export async function collectConfig({ defaults = {}, mode = 'create' } = {}) {
|
|
|
339
329
|
providerValues.HOST = bindLoopback ? '127.0.0.1' : stringValue(defaults.HOST);
|
|
340
330
|
}
|
|
341
331
|
|
|
342
|
-
// Bootstrap admin (checked only while no admin exists; safe to leave set).
|
|
343
332
|
if (auth === 'header') {
|
|
344
333
|
p.note(
|
|
345
334
|
[
|
|
@@ -494,10 +483,11 @@ export async function collectConfig({ defaults = {}, mode = 'create' } = {}) {
|
|
|
494
483
|
return values;
|
|
495
484
|
}
|
|
496
485
|
|
|
497
|
-
// Init: default to "same provider" only if current .env already shows that.
|
|
498
486
|
function pickSameProviderDefault(defaults, auth) {
|
|
499
487
|
const data = defaults.SELVA_DATA_PROVIDER ?? auth;
|
|
500
488
|
const storage = defaults.SELVA_STORAGE_PROVIDER ?? auth;
|
|
489
|
+
// No existing defaults means a fresh `create`, not a re-prompted `init` — default to
|
|
490
|
+
// "same provider for all three" since there's nothing yet to disagree with auth.
|
|
501
491
|
if (!defaults.SELVA_DATA_PROVIDER && !defaults.SELVA_STORAGE_PROVIDER) return true;
|
|
502
492
|
return data === auth && storage === auth;
|
|
503
493
|
}
|