@selvajs/cli 4.8.0-beta.8 → 4.8.0-beta.9
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 +2 -2
- package/src/checks/boot.js +149 -0
- package/src/checks/config.js +97 -0
- package/src/checks/result.js +27 -0
- package/src/commands/create.js +20 -56
- package/src/commands/doctor.js +47 -263
- package/src/commands/init.js +2 -20
- package/src/commands/keys.js +2 -15
- package/src/commands/migrate.js +128 -91
- package/src/commands/pm2.js +10 -4
- package/src/deployment-package.js +102 -0
- package/src/node-range.js +66 -0
- package/src/paths.js +44 -0
- package/src/prompts.js +0 -2
- package/bin/create.js +0 -7
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@selvajs/cli",
|
|
3
|
-
"version": "4.8.0-beta.
|
|
3
|
+
"version": "4.8.0-beta.9",
|
|
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
|
"author": "VektorNode",
|
|
@@ -43,6 +43,6 @@
|
|
|
43
43
|
"scripts": {
|
|
44
44
|
"start": "node ./bin/cli.js",
|
|
45
45
|
"selva": "node ./bin/selva.js",
|
|
46
|
-
"test": "node --check ./bin/cli.js && node --check ./bin/selva.js && 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); }\" && node --test \"./src/__tests__/*.test.js\""
|
|
46
|
+
"test": "node --check ./bin/cli.js && node --check ./bin/selva.js && 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/node-range.js','./src/deployment-package.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); }\" && node --test \"./src/__tests__/*.test.js\""
|
|
47
47
|
}
|
|
48
48
|
}
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
// Will a reboot bring the app back?
|
|
2
|
+
//
|
|
3
|
+
// Three things have to line up: pm2 has a saved process list to resurrect, a
|
|
4
|
+
// systemd unit exists to do the resurrecting, and that unit points at THIS
|
|
5
|
+
// deployment's pm2 rather than a global one. Each fails silently — the
|
|
6
|
+
// deployment runs fine until the VM restarts, which is exactly when nobody is
|
|
7
|
+
// watching.
|
|
8
|
+
//
|
|
9
|
+
// Every OS interaction arrives through `env` so the whole matrix (unit missing,
|
|
10
|
+
// unit pointing elsewhere, unit unreadable, stray global pm2) is reachable in a
|
|
11
|
+
// test without a Linux box, a systemd unit, or root.
|
|
12
|
+
|
|
13
|
+
import { existsSync, readFileSync, accessSync, constants } from 'node:fs';
|
|
14
|
+
import { spawnSync } from 'node:child_process';
|
|
15
|
+
import { join, resolve, dirname, delimiter } from 'node:path';
|
|
16
|
+
import { homedir } from 'node:os';
|
|
17
|
+
import { green, red, yellow, fixable } from './result.js';
|
|
18
|
+
|
|
19
|
+
/** The real OS. Tests pass a stand-in with the same shape. */
|
|
20
|
+
export const systemEnv = {
|
|
21
|
+
platform: () => process.platform,
|
|
22
|
+
env: () => process.env,
|
|
23
|
+
homedir,
|
|
24
|
+
exists: existsSync,
|
|
25
|
+
readFile: (path) => readFileSync(path, 'utf8'),
|
|
26
|
+
isWritable: (path) => {
|
|
27
|
+
try {
|
|
28
|
+
accessSync(path, constants.W_OK);
|
|
29
|
+
return true;
|
|
30
|
+
} catch {
|
|
31
|
+
return false;
|
|
32
|
+
}
|
|
33
|
+
},
|
|
34
|
+
run: (cmd, args, opts) => spawnSync(cmd, args, { ...opts, encoding: 'utf8' })
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
export function checkBootPersistence(dir, env = systemEnv) {
|
|
38
|
+
// pm2's boot integration is Linux/systemd-specific. On macOS it's launchd
|
|
39
|
+
// (different unit path) and on Windows pm2 boot persistence isn't a thing —
|
|
40
|
+
// stay silent rather than emit misleading checks.
|
|
41
|
+
if (env.platform() !== 'linux') return [];
|
|
42
|
+
|
|
43
|
+
return [
|
|
44
|
+
checkSavedProcessList(dir, env),
|
|
45
|
+
...checkSystemdUnit(dir, env),
|
|
46
|
+
...checkStrayGlobalPm2(dir, env)
|
|
47
|
+
];
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// dump.pm2 is what `pm2 resurrect` replays. Without it the unit starts pm2 and
|
|
51
|
+
// pm2 starts nothing.
|
|
52
|
+
function checkSavedProcessList(dir, env) {
|
|
53
|
+
const pm2Home = env.env().PM2_HOME ?? join(env.homedir(), '.pm2');
|
|
54
|
+
if (env.exists(join(pm2Home, 'dump.pm2'))) {
|
|
55
|
+
return green('pm2 process list saved (dump.pm2 present)');
|
|
56
|
+
}
|
|
57
|
+
return yellow(
|
|
58
|
+
'pm2 process list not saved — run `npx pm2 save` so a reboot can ' +
|
|
59
|
+
'resurrect the app (nothing to restore without it)',
|
|
60
|
+
fixable('run `pm2 save` to persist the current process list', () => {
|
|
61
|
+
const bin = join(dir, 'node_modules', '.bin', 'pm2');
|
|
62
|
+
if (!env.exists(bin)) return red('pm2 not installed in this deployment');
|
|
63
|
+
const r = env.run(bin, ['save'], { cwd: dir });
|
|
64
|
+
return (r.status ?? 1) === 0
|
|
65
|
+
? green('pm2 process list saved')
|
|
66
|
+
: red(`pm2 save failed: ${(r.stderr || r.stdout || '').trim()}`);
|
|
67
|
+
})
|
|
68
|
+
);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function checkSystemdUnit(dir, env) {
|
|
72
|
+
const user = env.env().USER ?? env.env().LOGNAME;
|
|
73
|
+
const unitPath = user ? `/etc/systemd/system/pm2-${user}.service` : null;
|
|
74
|
+
|
|
75
|
+
if (!unitPath || !env.exists(unitPath)) {
|
|
76
|
+
return [
|
|
77
|
+
yellow(
|
|
78
|
+
'pm2 systemd boot unit not installed — the app will NOT restart after a ' +
|
|
79
|
+
'reboot. Run `npx pm2 startup systemd -u $USER --hp $HOME` and paste the ' +
|
|
80
|
+
'printed command (point it at this deployment’s pm2).'
|
|
81
|
+
)
|
|
82
|
+
];
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
let unit;
|
|
86
|
+
try {
|
|
87
|
+
unit = env.readFile(unitPath);
|
|
88
|
+
} catch {
|
|
89
|
+
return [yellow(`pm2 systemd unit present but unreadable (${unitPath})`)];
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const localPm2 = join(dir, 'node_modules', 'pm2', 'bin', 'pm2');
|
|
93
|
+
const execStart = /^ExecStart=(.+)$/m.exec(unit)?.[1] ?? '';
|
|
94
|
+
if (execStart.includes(localPm2)) {
|
|
95
|
+
return [green('pm2 systemd boot unit installed (uses deployment-local pm2)')];
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
return [
|
|
99
|
+
red(
|
|
100
|
+
`pm2 systemd boot unit points at a different pm2 than this deployment's.\n ` +
|
|
101
|
+
`ExecStart: ${execStart || '(not found)'}\n ` +
|
|
102
|
+
`expected: ${localPm2} resurrect\n ` +
|
|
103
|
+
`Reboots will resurrect via the wrong pm2 (version skew). Re-run startup ` +
|
|
104
|
+
`with the local binary:\n ` +
|
|
105
|
+
`sudo env PATH=$PATH:${join(dir, 'node_modules', '.bin')} ${localPm2} ` +
|
|
106
|
+
`startup systemd -u $USER --hp $HOME`
|
|
107
|
+
)
|
|
108
|
+
];
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// A pm2 outside the deployment is the root cause of daemon version skew.
|
|
112
|
+
function checkStrayGlobalPm2(dir, env) {
|
|
113
|
+
const globalPm2 = findGlobalPm2(dir, env);
|
|
114
|
+
if (!globalPm2) return [];
|
|
115
|
+
|
|
116
|
+
// Only offer the removal when we could actually perform it. A global
|
|
117
|
+
// install under /usr is root-owned; attempting it would half-fail and
|
|
118
|
+
// leave the operator worse informed than a printed instruction.
|
|
119
|
+
const writable = env.isWritable(dirname(globalPm2));
|
|
120
|
+
|
|
121
|
+
return [
|
|
122
|
+
yellow(
|
|
123
|
+
`a pm2 outside this deployment is on PATH (${globalPm2}) — it can fork a ` +
|
|
124
|
+
`mismatched daemon and trigger skew. Prefer \`npm run\` wrappers / \`npx pm2\` ` +
|
|
125
|
+
`from this directory; consider \`npm uninstall -g pm2\`.` +
|
|
126
|
+
(writable ? '' : `\n (${dirname(globalPm2)} is not writable — needs sudo)`),
|
|
127
|
+
writable
|
|
128
|
+
? fixable(`uninstall the global pm2 at ${globalPm2}`, () => {
|
|
129
|
+
const r = env.run('npm', ['uninstall', '-g', 'pm2'], {});
|
|
130
|
+
return (r.status ?? 1) === 0
|
|
131
|
+
? green('global pm2 uninstalled')
|
|
132
|
+
: red(`npm uninstall -g pm2 failed: ${(r.stderr || r.stdout || '').trim()}`);
|
|
133
|
+
})
|
|
134
|
+
: undefined
|
|
135
|
+
)
|
|
136
|
+
];
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// Scan PATH for a stray pm2 binary (read-only).
|
|
140
|
+
export function findGlobalPm2(dir, env = systemEnv) {
|
|
141
|
+
const localBin = resolve(dir, 'node_modules', '.bin');
|
|
142
|
+
const dirs = (env.env().PATH ?? '').split(delimiter).filter(Boolean);
|
|
143
|
+
for (const d of dirs) {
|
|
144
|
+
if (resolve(d) === localBin) continue;
|
|
145
|
+
const candidate = join(d, 'pm2');
|
|
146
|
+
if (env.exists(candidate)) return candidate;
|
|
147
|
+
}
|
|
148
|
+
return null;
|
|
149
|
+
}
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
// Checks that read only `.env` values — no filesystem, no network, no
|
|
2
|
+
// subprocesses. These decide whether a deployment is validly configured, so
|
|
3
|
+
// they run on every `selva doctor` regardless of which providers are in play.
|
|
4
|
+
|
|
5
|
+
import { RENAMED_ENV_VARS, REPLACED_ENV_VARS } from '../env.js';
|
|
6
|
+
import { green, red, yellow } from './result.js';
|
|
7
|
+
|
|
8
|
+
const HEX_64 = /^[0-9a-f]{64}$/i;
|
|
9
|
+
const PLACEHOLDER = 'replace-this-with-a-random-32-byte-hex-key';
|
|
10
|
+
|
|
11
|
+
const VALID_AUTH = ['local', 'supabase', 'header'];
|
|
12
|
+
// Header-auth is an auth provider only — it has no data or storage layer.
|
|
13
|
+
const VALID_DATA = ['local', 'supabase'];
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Guards SELVA_HMAC_KEY and SELVA_AT_REST_KEY. A placeholder or short key
|
|
17
|
+
* starts and serves traffic — the damage (forgeable sessions, weak at-rest
|
|
18
|
+
* encryption) is invisible until someone looks for it.
|
|
19
|
+
*/
|
|
20
|
+
export function checkSecret(value, label) {
|
|
21
|
+
if (!value) return red(`${label} — unset`);
|
|
22
|
+
if (value === PLACEHOLDER) return red(`${label} — still the placeholder`);
|
|
23
|
+
if (!HEX_64.test(value)) return red(`${label} — not 64 hex chars`);
|
|
24
|
+
return green(label);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* The three provider slots, lowercased and defaulted the same way the runtime
|
|
29
|
+
* defaults them. Returned alongside the checks because the caller decides which
|
|
30
|
+
* provider-specific checks to run from it.
|
|
31
|
+
*/
|
|
32
|
+
export function resolveProviders(env) {
|
|
33
|
+
return {
|
|
34
|
+
auth: (env.SELVA_AUTH_PROVIDER ?? 'local').toLowerCase(),
|
|
35
|
+
data: (env.SELVA_DATA_PROVIDER ?? 'local').toLowerCase(),
|
|
36
|
+
storage: (env.SELVA_STORAGE_PROVIDER ?? 'local').toLowerCase()
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function checkProviders(providers) {
|
|
41
|
+
const checks = [];
|
|
42
|
+
if (!VALID_AUTH.includes(providers.auth)) {
|
|
43
|
+
checks.push(red(`SELVA_AUTH_PROVIDER="${providers.auth}" — expected local|supabase|header`));
|
|
44
|
+
}
|
|
45
|
+
if (!VALID_DATA.includes(providers.data)) {
|
|
46
|
+
checks.push(red(`SELVA_DATA_PROVIDER="${providers.data}" — expected local|supabase`));
|
|
47
|
+
}
|
|
48
|
+
if (!VALID_DATA.includes(providers.storage)) {
|
|
49
|
+
checks.push(red(`SELVA_STORAGE_PROVIDER="${providers.storage}" — expected local|supabase`));
|
|
50
|
+
}
|
|
51
|
+
return checks;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export function checkTenancy(env) {
|
|
55
|
+
const tenancy = (env.SELVA_TENANCY ?? 'single').toLowerCase();
|
|
56
|
+
if (tenancy !== 'single' && tenancy !== 'multi') {
|
|
57
|
+
return red(`SELVA_TENANCY="${tenancy}" — expected single|multi`);
|
|
58
|
+
}
|
|
59
|
+
return green(`SELVA_TENANCY=${tenancy}`);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export function checkOrigin(env) {
|
|
63
|
+
if (!env.ORIGIN) return yellow('ORIGIN unset — required behind a reverse proxy');
|
|
64
|
+
try {
|
|
65
|
+
new URL(env.ORIGIN);
|
|
66
|
+
} catch {
|
|
67
|
+
return red(`ORIGIN="${env.ORIGIN}" is not a valid URL`);
|
|
68
|
+
}
|
|
69
|
+
return green(`ORIGIN=${env.ORIGIN}`);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Report env vars that were renamed or replaced.
|
|
74
|
+
*
|
|
75
|
+
* A rename is auto-fixable (`selva migrate` rewrites the key). A replacement
|
|
76
|
+
* encodes a value in the new name, so migrate leaves it alone rather than
|
|
77
|
+
* guessing — it is reported here and nowhere else.
|
|
78
|
+
*/
|
|
79
|
+
export function checkDeprecatedEnv(env) {
|
|
80
|
+
const checks = [];
|
|
81
|
+
|
|
82
|
+
for (const [oldName, newName] of Object.entries(RENAMED_ENV_VARS)) {
|
|
83
|
+
if (env[oldName] === undefined) continue;
|
|
84
|
+
checks.push(
|
|
85
|
+
env[newName] === undefined
|
|
86
|
+
? yellow(`${oldName} is deprecated — \`selva migrate\` renames it to ${newName}`)
|
|
87
|
+
: yellow(`${oldName} is deprecated and ignored — ${newName} is set and wins`)
|
|
88
|
+
);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
for (const [oldName, replacement] of Object.entries(REPLACED_ENV_VARS)) {
|
|
92
|
+
if (env[oldName] === undefined) continue;
|
|
93
|
+
checks.push(yellow(`${oldName} is deprecated — replace it with ${replacement}`));
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
return checks;
|
|
97
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
// The shape every `selva doctor` check returns, and the repair a check may
|
|
2
|
+
// carry. Kept apart from the checks themselves so a pure predicate can be
|
|
3
|
+
// tested without pulling in the filesystem and network ones.
|
|
4
|
+
|
|
5
|
+
import pc from 'picocolors';
|
|
6
|
+
|
|
7
|
+
export function green(text) {
|
|
8
|
+
return { severity: 'green', line: `${pc.green('✓')} ${text}` };
|
|
9
|
+
}
|
|
10
|
+
export function yellow(text, fix) {
|
|
11
|
+
return { severity: 'yellow', line: `${pc.yellow('!')} ${text}`, fix };
|
|
12
|
+
}
|
|
13
|
+
export function red(text, fix) {
|
|
14
|
+
return { severity: 'red', line: `${pc.red('✗')} ${text}`, fix };
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* A repair `--fix` may run. `label` is what the operator is asked to approve;
|
|
19
|
+
* `run()` performs it and returns a result line.
|
|
20
|
+
*
|
|
21
|
+
* Only attach one where the repair is unambiguous and reversible-ish. Anything
|
|
22
|
+
* needing root, or that restarts the runtime running this process, stays a
|
|
23
|
+
* printed instruction — a half-applied privileged fix is worse than none.
|
|
24
|
+
*/
|
|
25
|
+
export function fixable(label, run) {
|
|
26
|
+
return { label, run };
|
|
27
|
+
}
|
package/src/commands/create.js
CHANGED
|
@@ -3,17 +3,21 @@
|
|
|
3
3
|
// templates are the source of truth; providers are env-driven.
|
|
4
4
|
|
|
5
5
|
import { writeFileSync, existsSync, mkdirSync, readFileSync, cpSync } from 'node:fs';
|
|
6
|
-
import { resolve, join, basename } from 'node:path';
|
|
6
|
+
import { resolve, join, basename, dirname } from 'node:path';
|
|
7
7
|
import { spawn } from 'node:child_process';
|
|
8
8
|
import * as p from '@clack/prompts';
|
|
9
9
|
import pc from 'picocolors';
|
|
10
10
|
import { collectConfig, collectConfigFromEnv } from '../prompts.js';
|
|
11
11
|
import { generateKey } from '../secrets.js';
|
|
12
12
|
import { writeEnvFile } from '../env.js';
|
|
13
|
-
import {
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
13
|
+
import {
|
|
14
|
+
isEmptyOrMissing,
|
|
15
|
+
requiredNodeRange,
|
|
16
|
+
runtimeTemplatePath,
|
|
17
|
+
scaffoldVersion
|
|
18
|
+
} from '../paths.js';
|
|
19
|
+
import { buildDeploymentPackageJson } from '../deployment-package.js';
|
|
20
|
+
import { satisfiesNodeRange } from '../node-range.js';
|
|
17
21
|
|
|
18
22
|
export async function runCreate(argv) {
|
|
19
23
|
const { dir: rawDir, force, skipInstall, yes } = parseArgs(argv);
|
|
@@ -59,7 +63,7 @@ export async function runCreate(argv) {
|
|
|
59
63
|
const deployName = basename(targetDir);
|
|
60
64
|
|
|
61
65
|
// Write package.json, then install (need templates from @selvajs/selva).
|
|
62
|
-
const pkgJson =
|
|
66
|
+
const pkgJson = JSON.stringify(buildDeploymentPackageJson({ name: deployName }), null, 2);
|
|
63
67
|
writeFileSync(join(targetDir, 'package.json'), pkgJson + '\n', 'utf8');
|
|
64
68
|
|
|
65
69
|
if (!skipInstall) {
|
|
@@ -71,23 +75,23 @@ export async function runCreate(argv) {
|
|
|
71
75
|
}
|
|
72
76
|
|
|
73
77
|
// Copy templates from installed runtime.
|
|
74
|
-
const
|
|
75
|
-
|
|
78
|
+
const envTemplatePath = runtimeTemplatePath(targetDir, '.env.example');
|
|
79
|
+
const ecosystemTemplatePath = runtimeTemplatePath(targetDir, 'ecosystem.config.cjs');
|
|
80
|
+
if (skipInstall || !existsSync(envTemplatePath)) {
|
|
76
81
|
p.log.warn(
|
|
77
|
-
`Couldn't read runtime templates from ${
|
|
82
|
+
`Couldn't read runtime templates from ${dirname(envTemplatePath)}. ` +
|
|
78
83
|
`Run \`npm install\`, then \`selva init\` to finish setup.`
|
|
79
84
|
);
|
|
80
|
-
writeFileSync(join(targetDir, '.selva-version'),
|
|
85
|
+
writeFileSync(join(targetDir, '.selva-version'), scaffoldVersion() + '\n', 'utf8');
|
|
81
86
|
p.outro(`Partial scaffold at ${pc.cyan(targetDir)}.`);
|
|
82
87
|
return;
|
|
83
88
|
}
|
|
84
89
|
|
|
85
|
-
|
|
86
|
-
writeEnvFile(join(targetDir, '.env'), envTemplate, values);
|
|
90
|
+
writeEnvFile(join(targetDir, '.env'), readFileSync(envTemplatePath, 'utf8'), values);
|
|
87
91
|
|
|
88
|
-
cpSync(
|
|
92
|
+
cpSync(ecosystemTemplatePath, join(targetDir, 'ecosystem.config.cjs'));
|
|
89
93
|
|
|
90
|
-
writeFileSync(join(targetDir, '.selva-version'),
|
|
94
|
+
writeFileSync(join(targetDir, '.selva-version'), scaffoldVersion() + '\n', 'utf8');
|
|
91
95
|
writeGitignore(targetDir);
|
|
92
96
|
|
|
93
97
|
p.outro(
|
|
@@ -192,7 +196,7 @@ function runNpmInstall(cwd) {
|
|
|
192
196
|
console.error(
|
|
193
197
|
pc.yellow(
|
|
194
198
|
'npm resolved @selvajs/selva@0.10.2 — that version is broken (unresolved\n' +
|
|
195
|
-
|
|
199
|
+
'workspace:* / catalog: specs) and has been unpublished. Your local npm\n' +
|
|
196
200
|
'cache is stale. Clear it and retry:\n\n' +
|
|
197
201
|
' npm cache clean --force\n' +
|
|
198
202
|
' rm -rf node_modules package-lock.json\n' +
|
|
@@ -224,9 +228,7 @@ function parseArgs(argv) {
|
|
|
224
228
|
}
|
|
225
229
|
}
|
|
226
230
|
if (!dir) {
|
|
227
|
-
throw new Error(
|
|
228
|
-
'Usage: npx @selvajs/cli <directory> [--force] [--skip-install] [--yes]'
|
|
229
|
-
);
|
|
231
|
+
throw new Error('Usage: npx @selvajs/cli <directory> [--force] [--skip-install] [--yes]');
|
|
230
232
|
}
|
|
231
233
|
return { dir, force, skipInstall, yes };
|
|
232
234
|
}
|
|
@@ -236,44 +238,6 @@ function envBool(v) {
|
|
|
236
238
|
return ['1', 'true', 'yes'].includes(String(v).toLowerCase());
|
|
237
239
|
}
|
|
238
240
|
|
|
239
|
-
// Depends on @selvajs/selva (prebuilt) + @selvajs/cli (operator tool).
|
|
240
|
-
// @selvajs/cli links the `selva` bin; without it, only global CLI works.
|
|
241
|
-
function buildPackageJson(name /*, values */) {
|
|
242
|
-
const deps = {
|
|
243
|
-
'@selvajs/cli': 'latest',
|
|
244
|
-
'@selvajs/selva': 'latest',
|
|
245
|
-
// Deployment-local pm2 (pinned exact to prevent daemon version skew).
|
|
246
|
-
pm2: '5.4.3'
|
|
247
|
-
};
|
|
248
|
-
|
|
249
|
-
const pkg = {
|
|
250
|
-
name: sanitizePackageName(name),
|
|
251
|
-
version: '0.1.0',
|
|
252
|
-
private: true,
|
|
253
|
-
type: 'module',
|
|
254
|
-
scripts: {
|
|
255
|
-
start: 'selva start',
|
|
256
|
-
stop: 'selva stop',
|
|
257
|
-
restart: 'selva restart',
|
|
258
|
-
logs: 'selva logs',
|
|
259
|
-
doctor: 'selva doctor',
|
|
260
|
-
update: 'selva update'
|
|
261
|
-
},
|
|
262
|
-
dependencies: deps
|
|
263
|
-
};
|
|
264
|
-
return JSON.stringify(pkg, null, 2);
|
|
265
|
-
}
|
|
266
|
-
|
|
267
|
-
function sanitizePackageName(name) {
|
|
268
|
-
// npm package names: lowercase, no spaces, limited punctuation.
|
|
269
|
-
return (
|
|
270
|
-
name
|
|
271
|
-
.toLowerCase()
|
|
272
|
-
.replace(/[^a-z0-9._-]+/g, '-')
|
|
273
|
-
.replace(/^[-._]+|[-._]+$/g, '') || 'selva-deployment'
|
|
274
|
-
);
|
|
275
|
-
}
|
|
276
|
-
|
|
277
241
|
function writeGitignore(dir) {
|
|
278
242
|
const path = join(dir, '.gitignore');
|
|
279
243
|
if (existsSync(path)) return;
|