@selvajs/cli 2.0.0 → 2.0.1
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 +4 -4
- package/src/cli.js +2 -0
- package/src/commands/doctor.js +25 -1
- package/src/commands/migrate.js +243 -0
- /package/bin/{create.js → cli.js} +0 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@selvajs/cli",
|
|
3
|
-
"version": "2.0.
|
|
3
|
+
"version": "2.0.1",
|
|
4
4
|
"description": "Scaffold and operate a Selva white-label deployment. `npx @selvajs/cli <dir>` to bootstrap, `selva <cmd>` to manage.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"publishConfig": {
|
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
},
|
|
9
9
|
"type": "module",
|
|
10
10
|
"bin": {
|
|
11
|
-
"
|
|
11
|
+
"cli": "./bin/cli.js",
|
|
12
12
|
"selva": "./bin/selva.js"
|
|
13
13
|
},
|
|
14
14
|
"files": [
|
|
@@ -23,8 +23,8 @@
|
|
|
23
23
|
"picocolors": "^1.1.1"
|
|
24
24
|
},
|
|
25
25
|
"scripts": {
|
|
26
|
-
"start": "node ./bin/
|
|
26
|
+
"start": "node ./bin/cli.js",
|
|
27
27
|
"selva": "node ./bin/selva.js",
|
|
28
|
-
"test": "node --input-type=module -e \"for (const m of ['./src/cli.js','./src/prompts.js','./src/env.js','./src/paths.js','./src/secrets.js','./src/commands/create.js','./src/commands/init.js','./src/commands/doctor.js','./src/commands/pm2.js','./src/commands/keys.js']) { await import(m); }\""
|
|
28
|
+
"test": "node --input-type=module -e \"for (const m of ['./src/cli.js','./src/prompts.js','./src/env.js','./src/paths.js','./src/secrets.js','./src/commands/create.js','./src/commands/init.js','./src/commands/doctor.js','./src/commands/pm2.js','./src/commands/migrate.js','./src/commands/keys.js']) { await import(m); }\""
|
|
29
29
|
}
|
|
30
30
|
}
|
package/src/cli.js
CHANGED
|
@@ -14,6 +14,7 @@ const COMMANDS = {
|
|
|
14
14
|
restart: () => import('./commands/pm2.js').then((m) => m.runRestart),
|
|
15
15
|
logs: () => import('./commands/pm2.js').then((m) => m.runLogs),
|
|
16
16
|
update: () => import('./commands/pm2.js').then((m) => m.runUpdate),
|
|
17
|
+
migrate: () => import('./commands/migrate.js').then((m) => m.runMigrate),
|
|
17
18
|
keys: () => import('./commands/keys.js').then((m) => keysDispatch(m))
|
|
18
19
|
};
|
|
19
20
|
|
|
@@ -70,6 +71,7 @@ function printHelp() {
|
|
|
70
71
|
' restart pm2 restart selva-compute --update-env',
|
|
71
72
|
' logs pm2 logs selva-compute',
|
|
72
73
|
' update npm update @selvajs/selva + restart',
|
|
74
|
+
' migrate Bring package.json onto the current layout',
|
|
73
75
|
' keys rotate <hmac|at-rest> Rotate a secret in .env (destructive)',
|
|
74
76
|
'',
|
|
75
77
|
pc.dim('To scaffold a new deployment: ') + pc.cyan('npx @selvajs/cli <dir>')
|
package/src/commands/doctor.js
CHANGED
|
@@ -10,12 +10,13 @@
|
|
|
10
10
|
//
|
|
11
11
|
// Exits 0 (green) or 1 (any red); yellow checks don't fail the run.
|
|
12
12
|
|
|
13
|
-
import { existsSync, accessSync, constants, statSync } from 'node:fs';
|
|
13
|
+
import { existsSync, readFileSync, accessSync, constants, statSync } from 'node:fs';
|
|
14
14
|
import { join, resolve } from 'node:path';
|
|
15
15
|
import * as p from '@clack/prompts';
|
|
16
16
|
import pc from 'picocolors';
|
|
17
17
|
import { readEnvFile } from '../env.js';
|
|
18
18
|
import { requireDeploymentDir, resolveDeploymentDir } from '../paths.js';
|
|
19
|
+
import { detectDrift } from './migrate.js';
|
|
19
20
|
|
|
20
21
|
const HEX_64 = /^[0-9a-f]{64}$/i;
|
|
21
22
|
const PLACEHOLDER = 'replace-this-with-a-random-32-byte-hex-key';
|
|
@@ -34,6 +35,12 @@ export async function runDoctor() {
|
|
|
34
35
|
checks.push(checkFile(join(dir, 'selva.config.js'), 'selva.config.js present'));
|
|
35
36
|
checks.push(checkFile(join(dir, 'ecosystem.config.cjs'), 'ecosystem.config.cjs present'));
|
|
36
37
|
|
|
38
|
+
// ── Layout drift ───────────────────────────────────────────────────
|
|
39
|
+
// Catches deployments still on the @selvajs/runtime layout (or other
|
|
40
|
+
// historical states) and points the operator at `selva migrate` instead
|
|
41
|
+
// of just letting the missing-package checks below scream.
|
|
42
|
+
checks.push(checkLayoutDrift(dir));
|
|
43
|
+
|
|
37
44
|
// ── Secrets ────────────────────────────────────────────────────────
|
|
38
45
|
checks.push(checkSecret(env.SELVA_HMAC_KEY, 'SELVA_HMAC_KEY is a 32-byte hex string'));
|
|
39
46
|
checks.push(checkSecret(env.SELVA_AT_REST_KEY, 'SELVA_AT_REST_KEY is a 32-byte hex string'));
|
|
@@ -192,6 +199,23 @@ function checkPackage(dir, name) {
|
|
|
192
199
|
return green(`${name} installed`);
|
|
193
200
|
}
|
|
194
201
|
|
|
202
|
+
function checkLayoutDrift(dir) {
|
|
203
|
+
const pkgPath = join(dir, 'package.json');
|
|
204
|
+
if (!existsSync(pkgPath)) return yellow('package.json missing — cannot check layout');
|
|
205
|
+
let pkg;
|
|
206
|
+
try {
|
|
207
|
+
pkg = JSON.parse(readFileSync(pkgPath, 'utf8'));
|
|
208
|
+
} catch {
|
|
209
|
+
return red('package.json is not valid JSON');
|
|
210
|
+
}
|
|
211
|
+
const reasons = detectDrift(pkg);
|
|
212
|
+
if (reasons.length === 0) return green('package.json layout is current');
|
|
213
|
+
return red(
|
|
214
|
+
`package.json layout is outdated — run \`selva migrate\`:\n ` +
|
|
215
|
+
reasons.map((r) => '· ' + r).join('\n ')
|
|
216
|
+
);
|
|
217
|
+
}
|
|
218
|
+
|
|
195
219
|
// Header-auth-specific sanity checks. None of these catch the truly dangerous
|
|
196
220
|
// misconfigurations (header spoofing, missing proxy auth) — those are
|
|
197
221
|
// runtime invariants we can't verify from here. We DO check the things we can:
|
|
@@ -0,0 +1,243 @@
|
|
|
1
|
+
// `selva migrate` — bring an existing deployment's package.json onto the
|
|
2
|
+
// current layout.
|
|
3
|
+
//
|
|
4
|
+
// Two historical migrations exist for Selva deployments:
|
|
5
|
+
// 1. `@selvajs/create` → `@selvajs/cli` (CLI bootstrap; can't be automated
|
|
6
|
+
// since the operator has no `selva` binary yet — they run two npm
|
|
7
|
+
// commands by hand from the Hotfix doc).
|
|
8
|
+
// 2. `@selvajs/runtime` → `@selvajs/selva` (runtime bundling: UI, schemas,
|
|
9
|
+
// ui-kit and the providers' workspace deps are now built into
|
|
10
|
+
// @selvajs/selva). This is what `selva migrate` automates.
|
|
11
|
+
//
|
|
12
|
+
// Future package-layout shifts go here too — the command should remain
|
|
13
|
+
// idempotent. On an already-current deployment it prints "nothing to
|
|
14
|
+
// migrate" and exits 0.
|
|
15
|
+
//
|
|
16
|
+
// Mirrors `selva update`'s lifecycle: stop pm2, mutate node_modules, start
|
|
17
|
+
// pm2 again with --update-env. Rollback restores package.json.bak on
|
|
18
|
+
// npm-install failure so the operator isn't left with a broken deployment.
|
|
19
|
+
|
|
20
|
+
import { existsSync, readFileSync, writeFileSync, copyFileSync, rmSync } from 'node:fs';
|
|
21
|
+
import { join } from 'node:path';
|
|
22
|
+
import { spawnSync, execSync } from 'node:child_process';
|
|
23
|
+
import * as p from '@clack/prompts';
|
|
24
|
+
import pc from 'picocolors';
|
|
25
|
+
import { readEnvFile } from '../env.js';
|
|
26
|
+
import { requireDeploymentDir, resolveDeploymentDir } from '../paths.js';
|
|
27
|
+
|
|
28
|
+
const APP_NAME = 'selva-compute';
|
|
29
|
+
|
|
30
|
+
// Packages we own. Everything in this set is rewritten wholesale by migrate;
|
|
31
|
+
// anything outside it (operator's own deps) gets dropped, which is the
|
|
32
|
+
// explicit design choice — operators with custom deps should fork the
|
|
33
|
+
// template rather than hand-patch the deployment package.json.
|
|
34
|
+
const SELVA_DEPS = new Set([
|
|
35
|
+
'@selvajs/cli',
|
|
36
|
+
'@selvajs/selva',
|
|
37
|
+
'@selvajs/runtime', // legacy — removed during migrate
|
|
38
|
+
'@selvajs/platform',
|
|
39
|
+
'@selvajs/local-provider',
|
|
40
|
+
'@selvajs/supabase-provider',
|
|
41
|
+
'@selvajs/header-auth-provider',
|
|
42
|
+
'@selvajs/create' // legacy CLI — removed during migrate
|
|
43
|
+
]);
|
|
44
|
+
|
|
45
|
+
const CANONICAL_SCRIPTS = {
|
|
46
|
+
start: 'selva start',
|
|
47
|
+
stop: 'selva stop',
|
|
48
|
+
restart: 'selva restart',
|
|
49
|
+
logs: 'selva logs',
|
|
50
|
+
doctor: 'selva doctor',
|
|
51
|
+
update: 'selva update'
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
export async function runMigrate() {
|
|
55
|
+
const dir = resolveDeploymentDir();
|
|
56
|
+
requireDeploymentDir(dir);
|
|
57
|
+
|
|
58
|
+
p.intro(pc.bgCyan(pc.black(' selva migrate ')));
|
|
59
|
+
|
|
60
|
+
const pkgPath = join(dir, 'package.json');
|
|
61
|
+
if (!existsSync(pkgPath)) {
|
|
62
|
+
p.outro(pc.red('No package.json in this directory — nothing to migrate.'));
|
|
63
|
+
process.exit(1);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const before = JSON.parse(readFileSync(pkgPath, 'utf8'));
|
|
67
|
+
const env = readEnvFile(join(dir, '.env'));
|
|
68
|
+
|
|
69
|
+
const target = buildTargetPackageJson(before, env);
|
|
70
|
+
const diff = diffPackageJson(before, target);
|
|
71
|
+
|
|
72
|
+
if (diff.length === 0) {
|
|
73
|
+
p.outro(pc.green('Already on the current layout — nothing to migrate.'));
|
|
74
|
+
return;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
p.log.info('Changes to apply:');
|
|
78
|
+
for (const line of diff) console.log(' ' + line);
|
|
79
|
+
|
|
80
|
+
const confirmed = await p.confirm({
|
|
81
|
+
message: 'Apply these changes, reinstall, and restart?',
|
|
82
|
+
initialValue: true
|
|
83
|
+
});
|
|
84
|
+
if (p.isCancel(confirmed) || !confirmed) {
|
|
85
|
+
p.cancel('Cancelled.');
|
|
86
|
+
return;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// Stop pm2 before mutating node_modules. Same reasoning as `selva update`:
|
|
90
|
+
// SvelteKit's node adapter lazy-imports chunks, so swapping the build dir
|
|
91
|
+
// under a live process causes ERR_MODULE_NOT_FOUND on in-flight requests.
|
|
92
|
+
const stopStatus = runPm2(dir, ['stop', APP_NAME], { inherit: false });
|
|
93
|
+
if (stopStatus !== 0) {
|
|
94
|
+
p.log.warn('pm2 stop did not succeed — selva-compute may not be running. Continuing.');
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// Write package.json.bak before touching anything. Restored if npm install
|
|
98
|
+
// fails so the operator can re-run with their previous state intact.
|
|
99
|
+
const bakPath = pkgPath + '.bak';
|
|
100
|
+
copyFileSync(pkgPath, bakPath);
|
|
101
|
+
writeFileSync(pkgPath, JSON.stringify(target, null, 2) + '\n', 'utf8');
|
|
102
|
+
|
|
103
|
+
// Nuke node_modules + lockfile. A simple `npm install` won't always
|
|
104
|
+
// resolve correctly when major version ranges change (e.g. runtime 0.10
|
|
105
|
+
// → selva 2.0) — the lockfile pins old transitive deps that no longer
|
|
106
|
+
// belong. Clean install is the only reliable path.
|
|
107
|
+
rmSync(join(dir, 'node_modules'), { recursive: true, force: true });
|
|
108
|
+
rmSync(join(dir, 'package-lock.json'), { force: true });
|
|
109
|
+
|
|
110
|
+
const s = p.spinner();
|
|
111
|
+
s.start('Installing new dependencies (this can take a minute)');
|
|
112
|
+
try {
|
|
113
|
+
// --prefer-online to bypass the stale-packument trap documented in
|
|
114
|
+
// docs/Hotfix-CLI-Runtime.md.
|
|
115
|
+
execSync('npm install --prefer-online', {
|
|
116
|
+
cwd: dir,
|
|
117
|
+
stdio: 'pipe'
|
|
118
|
+
});
|
|
119
|
+
s.stop('Dependencies installed');
|
|
120
|
+
} catch (err) {
|
|
121
|
+
s.stop(pc.red('npm install failed — rolling back package.json'));
|
|
122
|
+
copyFileSync(bakPath, pkgPath);
|
|
123
|
+
// Try to bring the old process back up so we don't leave the operator
|
|
124
|
+
// with downtime. If node_modules was wiped this won't help, but at
|
|
125
|
+
// least package.json matches what's on disk.
|
|
126
|
+
runPm2(dir, ['start', APP_NAME, '--update-env'], { inherit: false });
|
|
127
|
+
p.outro(pc.red(`Migration aborted: ${err.message ?? err}`));
|
|
128
|
+
process.exit(1);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
const status = runPm2(dir, ['start', APP_NAME, '--update-env'], { inherit: false });
|
|
132
|
+
if (status === 0) {
|
|
133
|
+
p.outro(
|
|
134
|
+
[
|
|
135
|
+
pc.green('Migration complete.'),
|
|
136
|
+
pc.dim('Old package.json saved as ') + pc.cyan('package.json.bak'),
|
|
137
|
+
pc.dim('Run ') + pc.cyan('selva doctor') + pc.dim(' to verify.')
|
|
138
|
+
].join('\n')
|
|
139
|
+
);
|
|
140
|
+
} else {
|
|
141
|
+
p.outro(pc.yellow(`Migration applied but pm2 start failed — check \`pm2 logs ${APP_NAME}\`.`));
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// Compute what package.json should look like given the current contents and
|
|
146
|
+
// the operator's .env (which tells us which providers are in use).
|
|
147
|
+
//
|
|
148
|
+
// Wholesale-replace semantics: any @selvajs/* or pm2 entry not in our
|
|
149
|
+
// canonical set is dropped. Non-selva deps the operator added are also
|
|
150
|
+
// dropped — that's the design choice we made up-front.
|
|
151
|
+
function buildTargetPackageJson(current, env) {
|
|
152
|
+
const providers = new Set(
|
|
153
|
+
[
|
|
154
|
+
env.SELVA_AUTH_PROVIDER,
|
|
155
|
+
env.SELVA_DATA_PROVIDER,
|
|
156
|
+
env.SELVA_STORAGE_PROVIDER
|
|
157
|
+
].filter(Boolean).map((v) => v.toLowerCase())
|
|
158
|
+
);
|
|
159
|
+
|
|
160
|
+
const deps = {
|
|
161
|
+
'@selvajs/cli': 'latest',
|
|
162
|
+
'@selvajs/selva': 'latest',
|
|
163
|
+
'@selvajs/platform': 'latest',
|
|
164
|
+
pm2: '^5.4.0'
|
|
165
|
+
};
|
|
166
|
+
if (providers.has('local')) deps['@selvajs/local-provider'] = 'latest';
|
|
167
|
+
if (providers.has('supabase')) deps['@selvajs/supabase-provider'] = 'latest';
|
|
168
|
+
if (providers.has('header')) deps['@selvajs/header-auth-provider'] = 'latest';
|
|
169
|
+
|
|
170
|
+
return {
|
|
171
|
+
name: current.name ?? 'selva-deployment',
|
|
172
|
+
version: current.version ?? '0.1.0',
|
|
173
|
+
private: true,
|
|
174
|
+
type: 'module',
|
|
175
|
+
scripts: { ...CANONICAL_SCRIPTS },
|
|
176
|
+
dependencies: deps
|
|
177
|
+
};
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
// Produce human-readable diff lines for the confirmation prompt. Keep it
|
|
181
|
+
// short — the operator just needs to see what's moving, not a unified diff.
|
|
182
|
+
function diffPackageJson(before, after) {
|
|
183
|
+
const lines = [];
|
|
184
|
+
|
|
185
|
+
const beforeDeps = before.dependencies ?? {};
|
|
186
|
+
const afterDeps = after.dependencies ?? {};
|
|
187
|
+
|
|
188
|
+
const allNames = new Set([...Object.keys(beforeDeps), ...Object.keys(afterDeps)]);
|
|
189
|
+
for (const name of [...allNames].sort()) {
|
|
190
|
+
const a = beforeDeps[name];
|
|
191
|
+
const b = afterDeps[name];
|
|
192
|
+
if (a && !b) lines.push(`${pc.red('-')} ${name} ${pc.dim(a)}`);
|
|
193
|
+
else if (!a && b) lines.push(`${pc.green('+')} ${name} ${pc.dim(b)}`);
|
|
194
|
+
else if (a !== b) lines.push(`${pc.yellow('~')} ${name} ${pc.dim(a + ' → ' + b)}`);
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
const beforeScripts = before.scripts ?? {};
|
|
198
|
+
const afterScripts = after.scripts ?? {};
|
|
199
|
+
const allScripts = new Set([...Object.keys(beforeScripts), ...Object.keys(afterScripts)]);
|
|
200
|
+
for (const name of [...allScripts].sort()) {
|
|
201
|
+
const a = beforeScripts[name];
|
|
202
|
+
const b = afterScripts[name];
|
|
203
|
+
if (a !== b) {
|
|
204
|
+
if (a && !b) lines.push(`${pc.red('-')} script.${name} ${pc.dim(a)}`);
|
|
205
|
+
else if (!a && b) lines.push(`${pc.green('+')} script.${name} ${pc.dim(b)}`);
|
|
206
|
+
else lines.push(`${pc.yellow('~')} script.${name} ${pc.dim(a + ' → ' + b)}`);
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
return lines;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
// Local copy of pm2.js's runner. Importing it would create a circular
|
|
214
|
+
// dependency via the `pm2.js` exports; pm2 invocation is small enough to
|
|
215
|
+
// duplicate.
|
|
216
|
+
function runPm2(dir, args, { inherit = true } = {}) {
|
|
217
|
+
const local = join(dir, 'node_modules', '.bin', process.platform === 'win32' ? 'pm2.cmd' : 'pm2');
|
|
218
|
+
const bin = existsSync(local) ? local : 'pm2';
|
|
219
|
+
const result = spawnSync(bin, args, {
|
|
220
|
+
cwd: dir,
|
|
221
|
+
stdio: inherit ? 'inherit' : 'pipe',
|
|
222
|
+
shell: process.platform === 'win32'
|
|
223
|
+
});
|
|
224
|
+
if (result.error) return 1;
|
|
225
|
+
return result.status ?? 0;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
// Exported for use by `selva doctor` so it can warn about layout drift
|
|
229
|
+
// without duplicating the detection logic.
|
|
230
|
+
export function detectDrift(pkgJson) {
|
|
231
|
+
const deps = pkgJson?.dependencies ?? {};
|
|
232
|
+
const reasons = [];
|
|
233
|
+
if (deps['@selvajs/runtime']) reasons.push('@selvajs/runtime is the old runtime package');
|
|
234
|
+
if (deps['@selvajs/create']) reasons.push('@selvajs/create is the old CLI package');
|
|
235
|
+
if (!deps['@selvajs/selva']) reasons.push('@selvajs/selva is missing');
|
|
236
|
+
if (!deps['@selvajs/cli']) reasons.push('@selvajs/cli is missing');
|
|
237
|
+
if (!deps['pm2']) reasons.push('pm2 is not in dependencies');
|
|
238
|
+
return reasons;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
// Re-exported so tests/imports can verify what migrate would write without
|
|
242
|
+
// actually running it.
|
|
243
|
+
export { buildTargetPackageJson, SELVA_DEPS };
|
|
File without changes
|