@selvajs/cli 4.8.0-beta.8 → 4.8.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 +7 -12
- package/package.json +6 -3
- package/src/checks/boot.js +151 -0
- package/src/checks/config.js +189 -0
- package/src/checks/result.js +27 -0
- package/src/checks/runtime.js +372 -0
- package/src/cli.js +70 -2
- package/src/commands/create.js +71 -76
- package/src/commands/doctor.js +163 -332
- package/src/commands/init.js +13 -32
- package/src/commands/keys.js +2 -15
- package/src/commands/migrate.js +321 -115
- package/src/commands/pm2.js +15 -18
- package/src/deployment-package.js +254 -0
- package/src/env.js +197 -25
- package/src/node-range.js +66 -0
- package/src/paths.js +46 -3
- package/src/prompts.js +4 -16
- package/bin/create.js +0 -7
package/src/commands/pm2.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
// Thin wrappers around PM2 commands:
|
|
2
|
-
//
|
|
3
|
-
//
|
|
1
|
+
// Thin wrappers around PM2 commands: resolve to the deployment-local pm2, resync
|
|
2
|
+
// the daemon before state changes, and pass --update-env on restart so edits to
|
|
3
|
+
// .env actually take effect.
|
|
4
4
|
|
|
5
5
|
import { existsSync, readFileSync } from 'node:fs';
|
|
6
6
|
import { join } from 'node:path';
|
|
@@ -9,10 +9,9 @@ import * as p from '@clack/prompts';
|
|
|
9
9
|
import pc from 'picocolors';
|
|
10
10
|
import { requireDeploymentDir, resolveDeploymentDir } from '../paths.js';
|
|
11
11
|
|
|
12
|
-
const APP_NAME = 'selva-compute';
|
|
12
|
+
export const APP_NAME = 'selva-compute';
|
|
13
13
|
|
|
14
|
-
|
|
15
|
-
function pm2Bin(dir) {
|
|
14
|
+
export function pm2Bin(dir) {
|
|
16
15
|
const local = join(dir, 'node_modules', '.bin', process.platform === 'win32' ? 'pm2.cmd' : 'pm2');
|
|
17
16
|
if (!existsSync(local)) {
|
|
18
17
|
throw new Error(
|
|
@@ -25,11 +24,10 @@ function pm2Bin(dir) {
|
|
|
25
24
|
return local;
|
|
26
25
|
}
|
|
27
26
|
|
|
28
|
-
//
|
|
27
|
+
// Parses the two versions pm2 prints alongside its out-of-date warning:
|
|
29
28
|
// In memory PM2 version: 6.0.14
|
|
30
29
|
// Local PM2 version: 5.4.3
|
|
31
|
-
// Returns nulls when the shape
|
|
32
|
-
// which is the pre-existing behaviour.
|
|
30
|
+
// Returns nulls when the shape doesn't match, so callers fall back to resyncing.
|
|
33
31
|
export function parsePm2Skew(output) {
|
|
34
32
|
// eslint-disable-next-line no-control-regex -- pm2 colourises via chalk on a TTY
|
|
35
33
|
const clean = output.replace(/\[[0-9;]*m/g, '');
|
|
@@ -38,12 +36,10 @@ export function parsePm2Skew(output) {
|
|
|
38
36
|
return { daemon: daemon?.[1] ?? null, local: local?.[1] ?? null };
|
|
39
37
|
}
|
|
40
38
|
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
* the process table so selva-compute is never re-registered (issue #118).
|
|
46
|
-
*/
|
|
39
|
+
// True when the running daemon is newer than the deployment-local pm2 — i.e. a
|
|
40
|
+
// foreign global pm2 owns it. `pm2 update` can't fix that direction: it would
|
|
41
|
+
// downgrade the daemon and hand it a dump from a newer version, dropping the
|
|
42
|
+
// process table so selva-compute never gets re-registered.
|
|
47
43
|
export function daemonOutranksCli(daemon, local) {
|
|
48
44
|
if (!daemon || !local) return false;
|
|
49
45
|
const [a, b] = [daemon.split('.').map(Number), local.split('.').map(Number)];
|
|
@@ -54,7 +50,6 @@ export function daemonOutranksCli(daemon, local) {
|
|
|
54
50
|
return false;
|
|
55
51
|
}
|
|
56
52
|
|
|
57
|
-
// Check for daemon/CLI version mismatch; run `pm2 update` only when it can help.
|
|
58
53
|
function ensurePm2InSync(dir) {
|
|
59
54
|
const bin = pm2Bin(dir);
|
|
60
55
|
const probe = spawnSync(bin, ['ping'], {
|
|
@@ -92,8 +87,10 @@ function ensurePm2InSync(dir) {
|
|
|
92
87
|
}
|
|
93
88
|
}
|
|
94
89
|
|
|
95
|
-
|
|
96
|
-
|
|
90
|
+
// `bin` lets a caller pass an already-resolved pm2 path instead of resolving
|
|
91
|
+
// fresh from `dir` — needed when node_modules won't exist at call time (e.g.
|
|
92
|
+
// `selva migrate` wipes it mid-flight and passes the binary it stashed first).
|
|
93
|
+
export function runPm2(dir, args, { inherit = true, bin = pm2Bin(dir) } = {}) {
|
|
97
94
|
const result = spawnSync(bin, args, {
|
|
98
95
|
cwd: dir,
|
|
99
96
|
stdio: inherit ? 'inherit' : 'pipe',
|
|
@@ -0,0 +1,254 @@
|
|
|
1
|
+
// The package.json a deployment directory gets. `create` writes it at scaffold
|
|
2
|
+
// time and `migrate` rewrites an existing one onto it, so it lives here rather
|
|
3
|
+
// than in either command — two copies drifted once already, and the pm2 pin is
|
|
4
|
+
// exactly the field where a silent difference costs the most.
|
|
5
|
+
|
|
6
|
+
import { readFileSync } from 'node:fs';
|
|
7
|
+
import { execSync } from 'node:child_process';
|
|
8
|
+
import { join, dirname } from 'node:path';
|
|
9
|
+
import { fileURLToPath } from 'node:url';
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* pm2 is pinned exactly, not caret-ranged.
|
|
13
|
+
*
|
|
14
|
+
* pm2's CLI and its long-running daemon must be the same version. A range lets
|
|
15
|
+
* two deployments installed a week apart resolve differently, and the newer one
|
|
16
|
+
* silently adopts the daemon the older one started — the skew `ensurePm2InSync`
|
|
17
|
+
* has to detect and, in the daemon-is-newer direction, cannot repair (#118).
|
|
18
|
+
*
|
|
19
|
+
* Read from this package's own devDependencies rather than written here as a
|
|
20
|
+
* literal. Dependabot scans manifests, not source, so a literal is invisible to
|
|
21
|
+
* it — this pin sat on 5.4.3 while upstream reached 7.x, which is precisely the
|
|
22
|
+
* drift operators point at when they argue for distro packages instead.
|
|
23
|
+
* `checkPinIsExact` keeps the manifest entry from being loosened to a range.
|
|
24
|
+
*/
|
|
25
|
+
const PM2_VERSION = readPinnedPm2Version();
|
|
26
|
+
|
|
27
|
+
function readPinnedPm2Version() {
|
|
28
|
+
const manifest = JSON.parse(
|
|
29
|
+
readFileSync(join(dirname(fileURLToPath(import.meta.url)), '..', 'package.json'), 'utf8')
|
|
30
|
+
);
|
|
31
|
+
const pinned = manifest.devDependencies?.pm2;
|
|
32
|
+
if (!pinned || !/^\d+\.\d+\.\d+$/.test(pinned)) {
|
|
33
|
+
throw new Error(
|
|
34
|
+
`@selvajs/cli devDependencies.pm2 must be an exact version, got ${JSON.stringify(pinned)}. ` +
|
|
35
|
+
`Every deployment this CLI scaffolds inherits it, and a range there lets two ` +
|
|
36
|
+
`installs resolve different pm2s that then fight over one daemon.`
|
|
37
|
+
);
|
|
38
|
+
}
|
|
39
|
+
return pinned;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const SCRIPTS = {
|
|
43
|
+
start: 'selva start',
|
|
44
|
+
stop: 'selva stop',
|
|
45
|
+
restart: 'selva restart',
|
|
46
|
+
logs: 'selva logs',
|
|
47
|
+
doctor: 'selva doctor',
|
|
48
|
+
update: 'selva update'
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Packages a deployment carries. Providers are bundled into `@selvajs/selva`;
|
|
53
|
+
* the CLI is a dependency so `npm run start` resolves the `selva` bin locally
|
|
54
|
+
* instead of relying on a global install.
|
|
55
|
+
*
|
|
56
|
+
* The `latest` entries are what `create` resolves at scaffold time, never what
|
|
57
|
+
* gets written: `resolveSelvaPins` turns them into concrete versions. A stored
|
|
58
|
+
* `"latest"` re-resolves on every `npm install`, so the deployment follows the
|
|
59
|
+
* dist-tag forever and can change under the operator with no migrate at all.
|
|
60
|
+
*/
|
|
61
|
+
const DEPENDENCIES = {
|
|
62
|
+
'@selvajs/cli': 'latest',
|
|
63
|
+
'@selvajs/selva': 'latest',
|
|
64
|
+
pm2: PM2_VERSION
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
/** The `@selvajs/*` packages whose pin belongs to the operator, not the scaffold. */
|
|
68
|
+
export const SELVA_PACKAGES = ['@selvajs/cli', '@selvajs/selva'];
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* True for a pin that names no version — a dist-tag (`latest`, `beta`) or the
|
|
72
|
+
* empty-ish values npm also accepts (`*`, `""`). These are the pins that move
|
|
73
|
+
* on their own, which is what makes them unfit to store.
|
|
74
|
+
*/
|
|
75
|
+
export function isFloatingPin(pin) {
|
|
76
|
+
if (typeof pin !== 'string') return true;
|
|
77
|
+
const trimmed = pin.trim();
|
|
78
|
+
if (trimmed === '' || trimmed === '*' || trimmed === 'x') return true;
|
|
79
|
+
// A range still names versions; only a bare tag has no digits at all.
|
|
80
|
+
return !/\d/.test(trimmed);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* True for a pin on a prerelease line (`^4.8.0-beta.11`).
|
|
85
|
+
*
|
|
86
|
+
* These are preserved across a migration. An operator on a beta channel chose
|
|
87
|
+
* it, and npm's `latest` tag points at the newest *stable* release — so
|
|
88
|
+
* resolving their pin through `latest` is a downgrade that reads as an upgrade
|
|
89
|
+
* in the confirmation diff.
|
|
90
|
+
*/
|
|
91
|
+
export function isPrereleasePin(pin) {
|
|
92
|
+
return typeof pin === 'string' && /\d+\.\d+\.\d+-/.test(pin);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* npm `overrides` forced onto every deployment — security patches for
|
|
97
|
+
* transitive deps whose direct parent hasn't picked up the fix yet. Each entry
|
|
98
|
+
* is a temporary shim: remove it once the parent ships the fixed version on
|
|
99
|
+
* its own (the override then matches what would install anyway, so removal is
|
|
100
|
+
* cleanup, never urgent).
|
|
101
|
+
*
|
|
102
|
+
* js-yaml: pm2 <= 7.0.3 pins 4.3.0, which carries the quadratic-complexity
|
|
103
|
+
* DoS advisories (GHSA-5p4m-2wfm-xmqj and friends); the fix landed in 4.3.1.
|
|
104
|
+
* Drop when pm2's own js-yaml dependency reaches >= 4.3.1.
|
|
105
|
+
*/
|
|
106
|
+
export const OVERRIDES = {
|
|
107
|
+
'js-yaml': '^4.3.1'
|
|
108
|
+
};
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Legacy `@selvajs/*` packages that a deployment may still list. Each was
|
|
112
|
+
* either renamed or folded into `@selvajs/selva`; `migrate` drops them and
|
|
113
|
+
* `doctor` reports them as layout drift.
|
|
114
|
+
*/
|
|
115
|
+
export const LEGACY_DEPENDENCIES = {
|
|
116
|
+
'@selvajs/runtime': 'the old runtime package',
|
|
117
|
+
'@selvajs/create': 'the old CLI package',
|
|
118
|
+
'@selvajs/platform': 'now bundled into @selvajs/selva',
|
|
119
|
+
'@selvajs/local-provider': 'now bundled into @selvajs/selva',
|
|
120
|
+
'@selvajs/supabase-provider': 'now bundled into @selvajs/selva',
|
|
121
|
+
'@selvajs/header-auth-provider': 'now bundled into @selvajs/selva'
|
|
122
|
+
};
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Build the canonical deployment package.json.
|
|
126
|
+
*
|
|
127
|
+
* Dependencies are replaced wholesale rather than merged, so an operator's own
|
|
128
|
+
* additions are dropped. That is deliberate — a deployment directory is
|
|
129
|
+
* generated output, and anyone needing custom dependencies should depend on
|
|
130
|
+
* `@selvajs/selva` from their own project instead of editing this file.
|
|
131
|
+
*
|
|
132
|
+
* `engines` is the one exception, carried over when present: npm only enforces
|
|
133
|
+
* it under `engine-strict`, so an operator who pinned a Node floor did it
|
|
134
|
+
* deliberately, and dropping it removes a guard whose absence shows up only
|
|
135
|
+
* under real traffic (issue #176). Everything else a migration discards is
|
|
136
|
+
* listed by `diffPackageJson` before the operator confirms.
|
|
137
|
+
*
|
|
138
|
+
* `dependencies` is the second exception, for the same reason: a version pin is
|
|
139
|
+
* an operator choice too. `migrate` passes the resolved `@selvajs/*` pins in
|
|
140
|
+
* here; a fresh scaffold passes none and inherits the defaults.
|
|
141
|
+
*/
|
|
142
|
+
export function buildDeploymentPackageJson({ name, version = '0.1.0', engines, dependencies }) {
|
|
143
|
+
const pkg = {
|
|
144
|
+
name: sanitizePackageName(name),
|
|
145
|
+
version,
|
|
146
|
+
private: true,
|
|
147
|
+
type: 'module',
|
|
148
|
+
scripts: { ...SCRIPTS },
|
|
149
|
+
dependencies: { ...DEPENDENCIES, ...dependencies },
|
|
150
|
+
overrides: { ...OVERRIDES }
|
|
151
|
+
};
|
|
152
|
+
if (engines && typeof engines === 'object') pkg.engines = { ...engines };
|
|
153
|
+
return pkg;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* Ask npm what a dist-tag currently points at. Kept beside the pin logic it
|
|
158
|
+
* serves, and injected rather than called directly so tests never hit the
|
|
159
|
+
* network.
|
|
160
|
+
*/
|
|
161
|
+
export function npmDistTagVersion(name, tag) {
|
|
162
|
+
const out = execSync(`npm view ${name}@${tag} version`, {
|
|
163
|
+
encoding: 'utf8',
|
|
164
|
+
stdio: ['ignore', 'pipe', 'pipe']
|
|
165
|
+
});
|
|
166
|
+
const version = String(out).trim().split('\n').pop()?.trim();
|
|
167
|
+
if (!version || !/^\d+\.\d+\.\d+/.test(version)) {
|
|
168
|
+
throw new Error(`npm view returned no usable version for ${name}@${tag}`);
|
|
169
|
+
}
|
|
170
|
+
return version;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* Decide the `@selvajs/*` pins a migration should write.
|
|
175
|
+
*
|
|
176
|
+
* Two rules, both aimed at the same failure: a migration silently moving a
|
|
177
|
+
* deployment to a version the operator didn't ask for.
|
|
178
|
+
*
|
|
179
|
+
* - A prerelease pin is kept as-is. `latest` means newest stable, so
|
|
180
|
+
* resolving a beta pin through it walks the deployment backwards.
|
|
181
|
+
* - Anything else resolves to a concrete version via `resolveVersion`, so
|
|
182
|
+
* `"latest"` never reaches disk and the diff can show what it resolved to.
|
|
183
|
+
*
|
|
184
|
+
* `resolveVersion` hits the network. When it fails — offline, registry down, a
|
|
185
|
+
* package with no such tag — the existing pin is kept and the reason returned,
|
|
186
|
+
* so migrate can report it rather than inventing a version or aborting a
|
|
187
|
+
* migration whose other half (layout, overrides, env renames) is still valid.
|
|
188
|
+
*/
|
|
189
|
+
export function resolveSelvaPins(currentDeps, resolveVersion) {
|
|
190
|
+
const pins = {};
|
|
191
|
+
const notes = [];
|
|
192
|
+
|
|
193
|
+
for (const name of SELVA_PACKAGES) {
|
|
194
|
+
const current = currentDeps?.[name];
|
|
195
|
+
|
|
196
|
+
if (isPrereleasePin(current)) {
|
|
197
|
+
pins[name] = current;
|
|
198
|
+
notes.push({ name, kind: 'prerelease', pin: current });
|
|
199
|
+
continue;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
let resolved = null;
|
|
203
|
+
try {
|
|
204
|
+
resolved = resolveVersion(name, 'latest');
|
|
205
|
+
} catch (err) {
|
|
206
|
+
notes.push({ name, kind: 'unresolved', pin: current, reason: err?.message ?? String(err) });
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
if (resolved) {
|
|
210
|
+
pins[name] = `^${resolved}`;
|
|
211
|
+
continue;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
// Nothing resolvable and nothing worth keeping — fall back to the tag so
|
|
215
|
+
// the install still succeeds. Rare: offline *and* a floating pin.
|
|
216
|
+
if (isFloatingPin(current)) {
|
|
217
|
+
pins[name] = DEPENDENCIES[name];
|
|
218
|
+
if (!notes.some((n) => n.name === name)) {
|
|
219
|
+
notes.push({ name, kind: 'unresolved', pin: current, reason: 'no version returned' });
|
|
220
|
+
}
|
|
221
|
+
} else {
|
|
222
|
+
pins[name] = current;
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
return { pins, notes };
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/**
|
|
230
|
+
* Top-level keys the canonical package.json defines. Anything outside this set
|
|
231
|
+
* that an operator added is dropped by `migrate` — `diffPackageJson` reports
|
|
232
|
+
* them so the loss is declared rather than discovered later.
|
|
233
|
+
*/
|
|
234
|
+
export const CANONICAL_FIELDS = new Set([
|
|
235
|
+
'name',
|
|
236
|
+
'version',
|
|
237
|
+
'private',
|
|
238
|
+
'type',
|
|
239
|
+
'scripts',
|
|
240
|
+
'dependencies',
|
|
241
|
+
'overrides',
|
|
242
|
+
'engines'
|
|
243
|
+
]);
|
|
244
|
+
|
|
245
|
+
export function sanitizePackageName(name) {
|
|
246
|
+
return (
|
|
247
|
+
String(name ?? '')
|
|
248
|
+
.toLowerCase()
|
|
249
|
+
.replace(/[^a-z0-9._-]+/g, '-')
|
|
250
|
+
.replace(/^[-._]+|[-._]+$/g, '') || 'selva-deployment'
|
|
251
|
+
);
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
export { DEPENDENCIES, SCRIPTS, PM2_VERSION };
|
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)));
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
// Node version-range matching, used by `selva doctor` to check an installed
|
|
2
|
+
// runtime and by the scaffolder to refuse a too-old shell before writing files.
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Narrow `engines.node` range check — `>=X`, `^`, `~`, `||`, and bare/x-ranges.
|
|
6
|
+
* Returns null when unparseable so a misread never reports a false failure.
|
|
7
|
+
*
|
|
8
|
+
* Mirrors `satisfiesRange` in @selvajs/server/ops. Duplicated because the CLI is
|
|
9
|
+
* dependency-free by design (it scaffolds the deployment that installs the
|
|
10
|
+
* runtime, so it cannot import from it).
|
|
11
|
+
*/
|
|
12
|
+
export function satisfiesNodeRange(version, range) {
|
|
13
|
+
const core = /^v?(\d+)\.(\d+)\.(\d+)/.exec(String(version).trim());
|
|
14
|
+
if (!core) return null;
|
|
15
|
+
const v = [Number(core[1]), Number(core[2]), Number(core[3])];
|
|
16
|
+
|
|
17
|
+
let anyParsed = false;
|
|
18
|
+
for (const alt of String(range).split('||')) {
|
|
19
|
+
const clauses = alt.trim().split(/\s+/).filter(Boolean);
|
|
20
|
+
if (!clauses.length) continue;
|
|
21
|
+
|
|
22
|
+
let all = true;
|
|
23
|
+
let parsed = true;
|
|
24
|
+
for (const clause of clauses) {
|
|
25
|
+
const m = /^(>=|<=|>|<|\^|~|=)?\s*v?(\d+)(?:\.(\d+|x|\*))?(?:\.(\d+|x|\*))?/.exec(clause);
|
|
26
|
+
if (!m) {
|
|
27
|
+
parsed = false;
|
|
28
|
+
break;
|
|
29
|
+
}
|
|
30
|
+
const op = m[1] ?? '=';
|
|
31
|
+
const num = (s) => (s == null || s === 'x' || s === '*' ? 0 : Number(s));
|
|
32
|
+
const t = [Number(m[2]), num(m[3]), num(m[4])];
|
|
33
|
+
let cmp = 0;
|
|
34
|
+
for (let i = 0; i < 3; i++) {
|
|
35
|
+
if (v[i] > t[i]) {
|
|
36
|
+
cmp = 1;
|
|
37
|
+
break;
|
|
38
|
+
}
|
|
39
|
+
if (v[i] < t[i]) {
|
|
40
|
+
cmp = -1;
|
|
41
|
+
break;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
let ok;
|
|
45
|
+
if (op === '>=') ok = cmp >= 0;
|
|
46
|
+
else if (op === '>') ok = cmp > 0;
|
|
47
|
+
else if (op === '<=') ok = cmp <= 0;
|
|
48
|
+
else if (op === '<') ok = cmp < 0;
|
|
49
|
+
else if (op === '^') ok = cmp >= 0 && v[0] === t[0];
|
|
50
|
+
else if (op === '~') ok = cmp >= 0 && v[0] === t[0] && v[1] === t[1];
|
|
51
|
+
else
|
|
52
|
+
ok =
|
|
53
|
+
v[0] === t[0] &&
|
|
54
|
+
(m[3] == null || m[3] === 'x' || m[3] === '*' || v[1] === t[1]) &&
|
|
55
|
+
(m[4] == null || m[4] === 'x' || m[4] === '*' || v[2] === t[2]);
|
|
56
|
+
if (!ok) {
|
|
57
|
+
all = false;
|
|
58
|
+
break;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
if (!parsed) continue;
|
|
62
|
+
anyParsed = true;
|
|
63
|
+
if (all) return true;
|
|
64
|
+
}
|
|
65
|
+
return anyParsed ? false : null;
|
|
66
|
+
}
|