@selvajs/cli 2.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2025 Selva FelixBrunold VektorNode
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,36 @@
1
+ # @selvajs/cli
2
+
3
+ CLI for white-label Selva deployments.
4
+
5
+ ## Bootstrap a new deployment
6
+
7
+ ```bash
8
+ npx @selvajs/cli my-deployment
9
+ ```
10
+
11
+ Interactive scaffolder. Prompts for provider, tenancy, flags, brand name, admin email. Generates `SELVA_HMAC_KEY` + `SELVA_AT_REST_KEY`. Writes `.env`, `selva.config.js`, `ecosystem.config.cjs`, `package.json`. Runs `npm install`.
12
+
13
+ ## Operate an existing deployment
14
+
15
+ After install, the package exposes a `selva` bin:
16
+
17
+ ```bash
18
+ selva init # reconfigure prompts; preserves existing secrets
19
+ selva doctor # validate env + providers + paths
20
+ selva start | stop | restart | logs
21
+ selva update # npm update @selvajs/selva + pm2 restart
22
+ selva keys rotate hmac # rotate SELVA_HMAC_KEY (logs everyone out)
23
+ selva keys rotate at-rest # rotate SELVA_AT_REST_KEY (compute API key needs re-entry)
24
+ ```
25
+
26
+ All operator commands run inside the deployment directory (the one that contains `.env` and `ecosystem.config.cjs`).
27
+
28
+ ## Idempotency rules
29
+
30
+ - `npx @selvajs/cli` refuses to overwrite a non-empty directory without `--force`.
31
+ - `selva init` reads the current `.env`, lets the user edit, and **never** regenerates `SELVA_HMAC_KEY` / `SELVA_AT_REST_KEY` if they're already set.
32
+ - A `.selva-version` marker is written so future CLI versions can migrate config schema cleanly.
33
+
34
+ ## Relationship to `@selvajs/selva`
35
+
36
+ The CLI generates a deployment directory whose `package.json` depends on `@selvajs/selva`. The runtime ships the prebuilt SvelteKit `node` build plus PM2 / config templates; the CLI's job is to fill in the template values and wire everything together.
package/bin/create.js ADDED
@@ -0,0 +1,7 @@
1
+ #!/usr/bin/env node
2
+ import { runCreate } from '../src/commands/create.js';
3
+
4
+ runCreate(process.argv.slice(2)).catch((err) => {
5
+ console.error(err instanceof Error ? err.message : err);
6
+ process.exit(1);
7
+ });
package/bin/selva.js ADDED
@@ -0,0 +1,7 @@
1
+ #!/usr/bin/env node
2
+ import { runSelva } from '../src/cli.js';
3
+
4
+ runSelva(process.argv.slice(2)).catch((err) => {
5
+ console.error(err instanceof Error ? err.message : err);
6
+ process.exit(1);
7
+ });
package/package.json ADDED
@@ -0,0 +1,30 @@
1
+ {
2
+ "name": "@selvajs/cli",
3
+ "version": "2.0.0",
4
+ "description": "Scaffold and operate a Selva white-label deployment. `npx @selvajs/cli <dir>` to bootstrap, `selva <cmd>` to manage.",
5
+ "license": "MIT",
6
+ "publishConfig": {
7
+ "access": "public"
8
+ },
9
+ "type": "module",
10
+ "bin": {
11
+ "create": "./bin/create.js",
12
+ "selva": "./bin/selva.js"
13
+ },
14
+ "files": [
15
+ "bin",
16
+ "src"
17
+ ],
18
+ "engines": {
19
+ "node": ">=20.6.0"
20
+ },
21
+ "dependencies": {
22
+ "@clack/prompts": "^0.11.0",
23
+ "picocolors": "^1.1.1"
24
+ },
25
+ "scripts": {
26
+ "start": "node ./bin/create.js",
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); }\""
29
+ }
30
+ }
package/src/cli.js ADDED
@@ -0,0 +1,78 @@
1
+ // `selva <command>` dispatcher.
2
+ //
3
+ // Kept deliberately flat — each command is a leaf module that gets imported
4
+ // only when its command runs. No global state, no shared parser; commands
5
+ // own their own argv handling.
6
+
7
+ import pc from 'picocolors';
8
+
9
+ const COMMANDS = {
10
+ init: () => import('./commands/init.js').then((m) => m.runInit),
11
+ doctor: () => import('./commands/doctor.js').then((m) => m.runDoctor),
12
+ start: () => import('./commands/pm2.js').then((m) => m.runStart),
13
+ stop: () => import('./commands/pm2.js').then((m) => m.runStop),
14
+ restart: () => import('./commands/pm2.js').then((m) => m.runRestart),
15
+ logs: () => import('./commands/pm2.js').then((m) => m.runLogs),
16
+ update: () => import('./commands/pm2.js').then((m) => m.runUpdate),
17
+ keys: () => import('./commands/keys.js').then((m) => keysDispatch(m))
18
+ };
19
+
20
+ function keysDispatch(m) {
21
+ return async (argv) => {
22
+ const sub = argv[0];
23
+ if (sub === 'rotate') return m.runKeysRotate(argv.slice(1));
24
+ console.error(`Usage: selva keys rotate <hmac|at-rest>`);
25
+ process.exit(1);
26
+ };
27
+ }
28
+
29
+ export async function runSelva(argv) {
30
+ const [command, ...rest] = argv;
31
+ if (!command || command === 'help' || command === '--help' || command === '-h') {
32
+ printHelp();
33
+ return;
34
+ }
35
+
36
+ if (command === '--version' || command === '-v') {
37
+ // We don't pin a version here — selva is installed transitively, so
38
+ // the deployment's package.json doesn't list us directly. Read our
39
+ // own package.json instead.
40
+ const { readFileSync } = await import('node:fs');
41
+ const { fileURLToPath } = await import('node:url');
42
+ const { dirname, join } = await import('node:path');
43
+ const here = dirname(fileURLToPath(import.meta.url));
44
+ const pkg = JSON.parse(readFileSync(join(here, '..', 'package.json'), 'utf8'));
45
+ console.log(pkg.version);
46
+ return;
47
+ }
48
+
49
+ const loader = COMMANDS[command];
50
+ if (!loader) {
51
+ console.error(`${pc.red('✗')} Unknown command: ${command}\n`);
52
+ printHelp();
53
+ process.exit(1);
54
+ }
55
+
56
+ const run = await loader();
57
+ await run(rest);
58
+ }
59
+
60
+ function printHelp() {
61
+ console.log(
62
+ [
63
+ pc.bold('selva') + ' — operate a Selva deployment',
64
+ '',
65
+ pc.bold('Commands:'),
66
+ ' init Reconfigure this deployment (prompts again)',
67
+ ' doctor Validate env, providers, and installed packages',
68
+ ' start pm2 start ecosystem.config.cjs',
69
+ ' stop pm2 stop selva-compute',
70
+ ' restart pm2 restart selva-compute --update-env',
71
+ ' logs pm2 logs selva-compute',
72
+ ' update npm update @selvajs/selva + restart',
73
+ ' keys rotate <hmac|at-rest> Rotate a secret in .env (destructive)',
74
+ '',
75
+ pc.dim('To scaffold a new deployment: ') + pc.cyan('npx @selvajs/cli <dir>')
76
+ ].join('\n')
77
+ );
78
+ }
@@ -0,0 +1,326 @@
1
+ // `npx @selvajs/cli <dir>` — scaffold a fresh deployment.
2
+ //
3
+ // What this writes into <dir>:
4
+ // .env merged from runtime's .env.example + prompt values
5
+ // selva.config.js copied from runtime's templates (operator can edit)
6
+ // ecosystem.config.cjs copied verbatim
7
+ // package.json depends on @selvajs/selva + providers
8
+ // .selva-version marker for future CLI migrations
9
+ // node_modules/ after `npm install`
10
+ //
11
+ // The runtime templates are the source of truth — we don't carry our own
12
+ // copies in @selvajs/cli. We install @selvajs/selva first, then copy
13
+ // from node_modules/@selvajs/selva/templates/.
14
+
15
+ import { writeFileSync, existsSync, mkdirSync, readFileSync, cpSync } from 'node:fs';
16
+ import { resolve, join, basename } from 'node:path';
17
+ import { spawn } from 'node:child_process';
18
+ import * as p from '@clack/prompts';
19
+ import pc from 'picocolors';
20
+ import { collectConfig, collectConfigFromEnv } from '../prompts.js';
21
+ import { generateKey } from '../secrets.js';
22
+ import { writeEnvFile } from '../env.js';
23
+ import { isEmptyOrMissing } from '../paths.js';
24
+
25
+ const CLI_VERSION = '0.1.0';
26
+
27
+ export async function runCreate(argv) {
28
+ const { dir: rawDir, force, skipInstall, yes } = parseArgs(argv);
29
+
30
+ const targetDir = resolve(rawDir);
31
+ if (!isEmptyOrMissing(targetDir) && !force) {
32
+ console.error(
33
+ `${pc.red('✗')} ${targetDir} already exists and isn't empty. ` +
34
+ `Pass --force to overwrite, or choose another directory.`
35
+ );
36
+ process.exit(1);
37
+ }
38
+ mkdirSync(targetDir, { recursive: true });
39
+
40
+ // 1. Collect config. --yes (or CI=1) takes everything from the
41
+ // environment instead of prompting — for Terraform startup scripts,
42
+ // Dockerfiles, and any other unattended bootstrap.
43
+ const nonInteractive = yes || envBool(process.env.CI);
44
+ const values = nonInteractive
45
+ ? collectConfigFromEnv(process.env)
46
+ : await collectConfig({ defaults: {}, mode: 'create' });
47
+
48
+ // 2. Always generate fresh secrets for a new install. They MUST be stable
49
+ // across restarts; the env-merge logic below writes them once and
50
+ // `selva init` later refuses to regenerate them.
51
+ values.SELVA_HMAC_KEY = generateKey();
52
+ values.SELVA_AT_REST_KEY = generateKey();
53
+
54
+ const deployName = basename(targetDir);
55
+
56
+ // 3. Write package.json before installing so npm has something to read.
57
+ const pkgJson = buildPackageJson(deployName, values);
58
+ writeFileSync(join(targetDir, 'package.json'), pkgJson + '\n', 'utf8');
59
+
60
+ // 4. Install. We need @selvajs/selva on disk to copy templates from it.
61
+ if (!skipInstall) {
62
+ await runNpmInstall(targetDir);
63
+ } else {
64
+ p.log.warn(
65
+ '--skip-install was passed: dependencies not installed. Run `npm install` manually before starting.'
66
+ );
67
+ }
68
+
69
+ // 5. Now copy templates from the installed runtime and fill them in.
70
+ const runtimeTemplates = join(targetDir, 'node_modules', '@selvajs', 'selva', 'templates');
71
+ if (skipInstall || !existsSync(runtimeTemplates)) {
72
+ p.log.warn(
73
+ `Couldn't read runtime templates from ${runtimeTemplates}. ` +
74
+ `Run \`npm install\`, then \`selva init\` to finish setup.`
75
+ );
76
+ writeFileSync(join(targetDir, '.selva-version'), CLI_VERSION + '\n', 'utf8');
77
+ p.outro(`Partial scaffold at ${pc.cyan(targetDir)}.`);
78
+ return;
79
+ }
80
+
81
+ const envTemplate = readFileSync(join(runtimeTemplates, '.env.example'), 'utf8');
82
+ writeEnvFile(join(targetDir, '.env'), envTemplate, values);
83
+
84
+ cpSync(join(runtimeTemplates, 'selva.config.example.js'), join(targetDir, 'selva.config.js'));
85
+ cpSync(join(runtimeTemplates, 'ecosystem.config.cjs'), join(targetDir, 'ecosystem.config.cjs'));
86
+
87
+ writeFileSync(join(targetDir, '.selva-version'), CLI_VERSION + '\n', 'utf8');
88
+ writeGitignore(targetDir);
89
+
90
+ // 6. Outro with next steps.
91
+ p.outro(
92
+ [
93
+ pc.green('Scaffolded ' + pc.cyan(targetDir)),
94
+ '',
95
+ pc.bold('Next steps:'),
96
+ ` cd ${rawDir}`,
97
+ ` npm run doctor # sanity-check the install`,
98
+ ` npm start # pm2 start ecosystem.config.cjs`,
99
+ '',
100
+ values.ORIGIN
101
+ ? `Then visit ${pc.cyan(values.ORIGIN)} (set up your reverse proxy first).`
102
+ : `Then visit ${pc.cyan('http://localhost:3000')}.`
103
+ ].join('\n')
104
+ );
105
+ }
106
+
107
+ // Run `npm install` with live progress and a real error report.
108
+ //
109
+ // The previous implementation used execSync + stdio:'pipe' which buffered
110
+ // everything in memory and discarded it on failure — operators saw "Command
111
+ // failed: npm install" with no clue what went wrong. We stream stdout/stderr
112
+ // into a ring buffer instead, and on failure dump the last lines so they can
113
+ // act on the actual error (sharp's libvips missing, registry timeout, etc.)
114
+ // without fishing through /home/user/.npm/_logs/.
115
+ //
116
+ // Cache-bust hint: if npm prints a placeDep for @selvajs/selva@0.10.2
117
+ // (which was published broken and unpublished), surface that so the operator
118
+ // knows to `npm cache clean --force`.
119
+ function runNpmInstall(cwd) {
120
+ return new Promise((resolveP, rejectP) => {
121
+ const s = p.spinner();
122
+ s.start('Installing dependencies (this can take a minute)');
123
+
124
+ // Ring buffer — keep the last 80 lines so we can show them on failure.
125
+ const tail = [];
126
+ const maxTail = 80;
127
+ const remember = (line) => {
128
+ tail.push(line);
129
+ if (tail.length > maxTail) tail.shift();
130
+ };
131
+
132
+ // Visible progress milestones. npm doesn't emit a clean progress
133
+ // stream; we cherry-pick recognizable transitions and pass them to
134
+ // the spinner so the operator sees movement.
135
+ const updateProgress = (line) => {
136
+ const trimmed = line.trim();
137
+ if (!trimmed) return;
138
+ // "added 412 packages" — final summary
139
+ if (/^added \d+ packages/.test(trimmed)) {
140
+ s.message('Finalizing installation');
141
+ return;
142
+ }
143
+ // "reify:foo: timing reifyNode..." — npm 8/9/10 progress lines.
144
+ // Pull the package name out and show it.
145
+ const reify = trimmed.match(/^reify:([^:]+):/);
146
+ if (reify) {
147
+ s.message(`Installing ${pc.cyan(reify[1])}`);
148
+ return;
149
+ }
150
+ // "npm WARN ..." / "npm error ..." — pass through prefixed.
151
+ if (/^npm (WARN|error|notice)/.test(trimmed)) {
152
+ // Don't change the spinner message for these — they're noisy.
153
+ return;
154
+ }
155
+ };
156
+
157
+ // Spawn npm with --loglevel=info so we get reify: progress lines.
158
+ // We DO NOT use stdio: 'inherit' because that would interleave with
159
+ // the spinner; we DO want a live read so we can update the message.
160
+ const child = spawn('npm', ['install', '--loglevel=info'], {
161
+ cwd,
162
+ stdio: ['ignore', 'pipe', 'pipe'],
163
+ shell: process.platform === 'win32'
164
+ });
165
+
166
+ let sawBrokenVersion = false;
167
+ const handleStream = (stream) => {
168
+ let buf = '';
169
+ stream.setEncoding('utf8');
170
+ stream.on('data', (chunk) => {
171
+ buf += chunk;
172
+ const lines = buf.split('\n');
173
+ buf = lines.pop() ?? '';
174
+ for (const line of lines) {
175
+ remember(line);
176
+ updateProgress(line);
177
+ if (line.includes('@selvajs/selva@0.10.2')) sawBrokenVersion = true;
178
+ }
179
+ });
180
+ stream.on('end', () => {
181
+ if (buf) {
182
+ remember(buf);
183
+ updateProgress(buf);
184
+ }
185
+ });
186
+ };
187
+ handleStream(child.stdout);
188
+ handleStream(child.stderr);
189
+
190
+ child.on('error', (err) => {
191
+ s.stop(pc.red('npm install could not start'));
192
+ rejectP(err);
193
+ });
194
+
195
+ child.on('close', (code) => {
196
+ if (code === 0) {
197
+ s.stop(pc.green('Dependencies installed'));
198
+ resolveP();
199
+ return;
200
+ }
201
+ s.stop(pc.red(`npm install failed (exit ${code})`));
202
+
203
+ // Show what npm actually said. Without this the operator has to
204
+ // dig through ~/.npm/_logs/*-debug-0.log to find a single line.
205
+ console.error('');
206
+ console.error(pc.dim('── last lines of npm output ──'));
207
+ for (const line of tail) console.error(line);
208
+ console.error(pc.dim('──────────────────────────────'));
209
+
210
+ if (sawBrokenVersion) {
211
+ console.error('');
212
+ console.error(
213
+ pc.yellow(
214
+ 'npm resolved @selvajs/selva@0.10.2 — that version is broken (unresolved\n' +
215
+ "workspace:* / catalog: specs) and has been unpublished. Your local npm\n" +
216
+ 'cache is stale. Clear it and retry:\n\n' +
217
+ ' npm cache clean --force\n' +
218
+ ' rm -rf node_modules package-lock.json\n' +
219
+ ' npm install --prefer-online'
220
+ )
221
+ );
222
+ }
223
+
224
+ rejectP(new Error(`npm install exited with code ${code}`));
225
+ });
226
+ });
227
+ }
228
+
229
+ function parseArgs(argv) {
230
+ let dir;
231
+ let force = false;
232
+ let skipInstall = false;
233
+ let yes = false;
234
+ for (const arg of argv) {
235
+ if (arg === '--force') force = true;
236
+ else if (arg === '--skip-install') skipInstall = true;
237
+ else if (arg === '--yes' || arg === '-y') yes = true;
238
+ else if (arg.startsWith('--')) {
239
+ throw new Error(`Unknown flag: ${arg}`);
240
+ } else if (!dir) {
241
+ dir = arg;
242
+ } else {
243
+ throw new Error(`Unexpected argument: ${arg}`);
244
+ }
245
+ }
246
+ if (!dir) {
247
+ throw new Error(
248
+ 'Usage: npx @selvajs/cli <directory> [--force] [--skip-install] [--yes]'
249
+ );
250
+ }
251
+ return { dir, force, skipInstall, yes };
252
+ }
253
+
254
+ function envBool(v) {
255
+ if (!v) return false;
256
+ return ['1', 'true', 'yes'].includes(String(v).toLowerCase());
257
+ }
258
+
259
+ // The deployment's package.json depends on @selvajs/selva (prebuilt
260
+ // SvelteKit app) plus whichever providers the operator picked. Providers are
261
+ // imported by selva.config.js — npm needs them resolvable from node_modules.
262
+ //
263
+ // We also list @selvajs/cli itself as a dep so the `selva` bin gets linked
264
+ // into node_modules/.bin/. Without this the operator's only way to run
265
+ // `selva doctor` / `selva start` is a global install of the CLI.
266
+ function buildPackageJson(name, values) {
267
+ const deps = {
268
+ '@selvajs/cli': 'latest',
269
+ '@selvajs/platform': 'latest',
270
+ '@selvajs/selva': 'latest',
271
+ // pm2 lives in the deployment's own node_modules so `selva start` can
272
+ // resolve it via node_modules/.bin/pm2 without a global install. The
273
+ // pm2.js wrapper already prefers the local binary (see pm2Bin()).
274
+ pm2: '^5.4.0'
275
+ };
276
+
277
+ const providers = new Set([
278
+ values.SELVA_AUTH_PROVIDER,
279
+ values.SELVA_DATA_PROVIDER,
280
+ values.SELVA_STORAGE_PROVIDER
281
+ ]);
282
+
283
+ if (providers.has('local')) deps['@selvajs/local-provider'] = 'latest';
284
+ if (providers.has('supabase')) deps['@selvajs/supabase-provider'] = 'latest';
285
+ if (providers.has('header')) deps['@selvajs/header-auth-provider'] = 'latest';
286
+
287
+ // Use `selva` (resolved via node_modules/.bin) in the npm scripts. Running
288
+ // them as `npm run start` / `npm run doctor` works without remembering the
289
+ // ./node_modules/.bin/ prefix.
290
+ const pkg = {
291
+ name: sanitizePackageName(name),
292
+ version: '0.1.0',
293
+ private: true,
294
+ type: 'module',
295
+ scripts: {
296
+ start: 'selva start',
297
+ stop: 'selva stop',
298
+ restart: 'selva restart',
299
+ logs: 'selva logs',
300
+ doctor: 'selva doctor',
301
+ update: 'selva update'
302
+ },
303
+ dependencies: deps
304
+ };
305
+ return JSON.stringify(pkg, null, 2);
306
+ }
307
+
308
+ function sanitizePackageName(name) {
309
+ // npm package names: lowercase, no spaces, limited punctuation.
310
+ return (
311
+ name
312
+ .toLowerCase()
313
+ .replace(/[^a-z0-9._-]+/g, '-')
314
+ .replace(/^[-._]+|[-._]+$/g, '') || 'selva-deployment'
315
+ );
316
+ }
317
+
318
+ function writeGitignore(dir) {
319
+ const path = join(dir, '.gitignore');
320
+ if (existsSync(path)) return;
321
+ writeFileSync(
322
+ path,
323
+ ['node_modules/', '.env', '.env.local', '.selva-data/', 'logs/', '*.log', ''].join('\n'),
324
+ 'utf8'
325
+ );
326
+ }