@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.
@@ -2,42 +2,46 @@
2
2
  // provider packages), remove stale selva.config.js, update ecosystem.config.cjs.
3
3
  // Idempotent; mirrors update's lifecycle (stop/mutate/start with rollback on failure).
4
4
 
5
- import { existsSync, readFileSync, writeFileSync, copyFileSync, rmSync } from 'node:fs';
5
+ import { existsSync, readFileSync, writeFileSync, copyFileSync, rmSync, renameSync } from 'node:fs';
6
6
  import { join } from 'node:path';
7
- import { spawnSync, execSync } from 'node:child_process';
7
+ import { execSync } from 'node:child_process';
8
8
  import * as p from '@clack/prompts';
9
9
  import pc from 'picocolors';
10
10
  import { requireDeploymentDir, resolveDeploymentDir } from '../paths.js';
11
11
  import { renameEnvKeys, RENAMED_ENV_VARS } from '../env.js';
12
-
13
- const APP_NAME = 'selva-compute';
14
-
15
- // Packages we own. Everything in this set is rewritten wholesale by migrate;
16
- // anything outside it (operator's own deps) gets dropped, which is the
17
- // explicit design choice — operators with custom deps should fork the
18
- // template rather than hand-patch the deployment package.json.
19
- const SELVA_DEPS = new Set([
20
- '@selvajs/cli',
21
- '@selvajs/selva',
22
- '@selvajs/runtime', // legacy — removed during migrate
23
- '@selvajs/platform', // legacy — bundled into @selvajs/selva
24
- '@selvajs/local-provider', // legacy — bundled into @selvajs/selva
25
- '@selvajs/supabase-provider', // legacy — bundled into @selvajs/selva
26
- '@selvajs/header-auth-provider', // legacy — bundled into @selvajs/selva
27
- '@selvajs/create' // legacy CLI — removed during migrate
28
- ]);
29
-
30
- const CANONICAL_SCRIPTS = {
31
- start: 'selva start',
32
- stop: 'selva stop',
33
- restart: 'selva restart',
34
- logs: 'selva logs',
35
- doctor: 'selva doctor',
36
- update: 'selva update'
37
- };
38
-
39
- export async function runMigrate() {
40
- const dir = resolveDeploymentDir();
12
+ import {
13
+ buildDeploymentPackageJson,
14
+ CANONICAL_FIELDS,
15
+ DEPENDENCIES,
16
+ LEGACY_DEPENDENCIES
17
+ } from '../deployment-package.js';
18
+ import { APP_NAME, runPm2 } from './pm2.js';
19
+
20
+ /**
21
+ * Where `migrate` parks the old node_modules while npm installs the new one.
22
+ * Left behind only if the process is killed mid-migration — `detectDrift`
23
+ * reports it, because a stray copy of the dependency tree is both confusing and
24
+ * large enough to matter on a small VM.
25
+ */
26
+ export const NODE_MODULES_STASH = 'node_modules.selva-migrate-bak';
27
+
28
+ /**
29
+ * @param {object} [deps] Seams for tests. The rollback path destroys and
30
+ * restores a real dependency tree, so it has to be exercised against a real
31
+ * directory — but not against a real npm or a real pm2 daemon.
32
+ */
33
+ export async function runMigrate(
34
+ _argv,
35
+ {
36
+ dir: injectedDir,
37
+ // --prefer-online bypasses the stale-packument trap: npm can otherwise
38
+ // resolve a cached packument that predates the release being migrated to.
39
+ install = (cwd) => execSync('npm install --prefer-online', { cwd, stdio: 'pipe' }),
40
+ confirm = (message) => p.confirm({ message, initialValue: true }),
41
+ pm2 = runPm2
42
+ } = {}
43
+ ) {
44
+ const dir = injectedDir ?? resolveDeploymentDir();
41
45
  requireDeploymentDir(dir);
42
46
 
43
47
  p.intro(pc.bgCyan(pc.black(' selva migrate ')));
@@ -96,19 +100,22 @@ export async function runMigrate() {
96
100
  for (const line of pkgDiff) console.log(' ' + line);
97
101
  for (const line of sideFileChanges) console.log(' ' + line);
98
102
 
99
- const confirmed = await p.confirm({
100
- message: 'Apply these changes, reinstall, and restart?',
101
- initialValue: true
102
- });
103
+ const confirmed = await confirm('Apply these changes, reinstall, and restart?');
103
104
  if (p.isCancel(confirmed) || !confirmed) {
104
105
  p.cancel('Cancelled.');
105
106
  return;
106
107
  }
107
108
 
108
109
  // Stop pm2 before npm rewrites (SvelteKit lazy-loads chunks; see update command).
109
- const stopStatus = runPm2(dir, ['stop', APP_NAME], { inherit: false });
110
- if (stopStatus !== 0) {
111
- p.log.warn('pm2 stop did not succeed — selva-compute may not be running. Continuing.');
110
+ // A legacy deployment may predate the local-pm2 layout, so a missing binary
111
+ // is not fatal here — there is nothing running for us to stop.
112
+ try {
113
+ const stopStatus = pm2(dir, ['stop', APP_NAME], { inherit: false });
114
+ if (stopStatus !== 0) {
115
+ p.log.warn('pm2 stop did not succeed — selva-compute may not be running. Continuing.');
116
+ }
117
+ } catch {
118
+ p.log.warn('No deployment-local pm2 to stop — continuing.');
112
119
  }
113
120
 
114
121
  // Back up for rollback on npm-install failure.
@@ -136,22 +143,28 @@ export async function runMigrate() {
136
143
  writeFileSync(envPath, envRename.text, 'utf8');
137
144
  }
138
145
 
139
- // Clean install required (major version changes break legacy lockfile).
140
- rmSync(join(dir, 'node_modules'), { recursive: true, force: true });
141
- rmSync(join(dir, 'package-lock.json'), { force: true });
146
+ // A clean install is required — a legacy lockfile pins the old package set
147
+ // across a major bump. But node_modules is also where pm2 lives, and the
148
+ // rollback below has to restart the app, so deleting it outright leaves a
149
+ // failed migration with no way back up. Rename instead: atomic, keeps the
150
+ // .bin symlinks intact, and restorable until the install succeeds.
151
+ const nodeModules = join(dir, 'node_modules');
152
+ const nodeModulesBak = join(dir, NODE_MODULES_STASH);
153
+ const lockPath = join(dir, 'package-lock.json');
154
+
155
+ rmSync(nodeModulesBak, { recursive: true, force: true });
156
+ if (existsSync(nodeModules)) renameSync(nodeModules, nodeModulesBak);
157
+ const lockBak = existsSync(lockPath) ? readFileSync(lockPath, 'utf8') : null;
158
+ rmSync(lockPath, { force: true });
142
159
 
143
160
  const s = p.spinner();
144
161
  s.start('Installing new dependencies (this can take a minute)');
145
162
  try {
146
- // --prefer-online to bypass the stale-packument trap documented in
147
- // docs/Hotfix-CLI-Runtime.md.
148
- execSync('npm install --prefer-online', {
149
- cwd: dir,
150
- stdio: 'pipe'
151
- });
163
+ install(dir);
152
164
  s.stop('Dependencies installed');
165
+ rmSync(nodeModulesBak, { recursive: true, force: true });
153
166
  } catch (err) {
154
- s.stop(pc.red('npm install failed — rolling back package.json'));
167
+ s.stop(pc.red('npm install failed — rolling back'));
155
168
  copyFileSync(bakPath, pkgPath);
156
169
  if (hasStaleConfig && existsSync(configPath + '.bak')) {
157
170
  copyFileSync(configPath + '.bak', configPath);
@@ -162,13 +175,25 @@ export async function runMigrate() {
162
175
  if (envRename.changes.length > 0 && existsSync(envPath + '.bak')) {
163
176
  copyFileSync(envPath + '.bak', envPath);
164
177
  }
165
- // Try to bring the process back up (best-effort; at least package.json is consistent).
166
- runPm2(dir, ['start', APP_NAME, '--update-env'], { inherit: false });
178
+
179
+ // Restore the dependency tree the app was running on. A half-installed
180
+ // node_modules from the failed attempt is worse than the old one.
181
+ rmSync(nodeModules, { recursive: true, force: true });
182
+ if (existsSync(nodeModulesBak)) renameSync(nodeModulesBak, nodeModules);
183
+ if (lockBak !== null) writeFileSync(lockPath, lockBak, 'utf8');
184
+
185
+ restartAfterRollback(dir, pm2);
167
186
  p.outro(pc.red(`Migration aborted: ${err.message ?? err}`));
168
187
  process.exit(1);
169
188
  }
170
189
 
171
- const status = runPm2(dir, ['start', APP_NAME, '--update-env'], { inherit: false });
190
+ let status;
191
+ try {
192
+ status = pm2(dir, ['start', APP_NAME, '--update-env'], { inherit: false });
193
+ } catch (err) {
194
+ status = 1;
195
+ p.log.error(`Could not invoke pm2: ${err instanceof Error ? err.message : err}`);
196
+ }
172
197
  const backupHints = ['package.json.bak'];
173
198
  if (hasStaleConfig) backupHints.push('selva.config.js.bak');
174
199
  if (ecoHasStaleRuntime) backupHints.push('ecosystem.config.cjs.bak');
@@ -187,23 +212,39 @@ export async function runMigrate() {
187
212
  }
188
213
  }
189
214
 
190
- // Build target package.json with canonical dependencies (wholesale replace).
191
- // Drops non-canonical @selvajs/* and operator's own deps (by design).
192
- function buildTargetPackageJson(current) {
193
- const deps = {
194
- '@selvajs/cli': 'latest',
195
- '@selvajs/selva': 'latest',
196
- pm2: '^5.4.0'
197
- };
215
+ /**
216
+ * Bring the app back up after a failed migration.
217
+ *
218
+ * Never throws: the migration is already aborting, and an exception here would
219
+ * replace the rollback's diagnosis with a stack trace. A failure to restart is
220
+ * the operator's problem to act on, so it must be stated, not swallowed — the
221
+ * old code returned a bare 1 and printed nothing.
222
+ */
223
+ function restartAfterRollback(dir, pm2 = runPm2) {
224
+ try {
225
+ const status = pm2(dir, ['start', APP_NAME, '--update-env'], { inherit: false });
226
+ if (status === 0) {
227
+ p.log.success(`Rolled back and restarted ${APP_NAME}.`);
228
+ return;
229
+ }
230
+ p.log.error(
231
+ `Rolled back, but \`pm2 start\` exited ${status}. The app may be down — ` +
232
+ `check \`pm2 logs ${APP_NAME}\` and start it with \`selva start\`.`
233
+ );
234
+ } catch (err) {
235
+ p.log.error(
236
+ `Rolled back, but pm2 could not be invoked (${err instanceof Error ? err.message : err}). ` +
237
+ `The app is likely down — run \`npm install\` then \`selva start\`.`
238
+ );
239
+ }
240
+ }
198
241
 
199
- return {
242
+ function buildTargetPackageJson(current) {
243
+ return buildDeploymentPackageJson({
200
244
  name: current.name ?? 'selva-deployment',
201
245
  version: current.version ?? '0.1.0',
202
- private: true,
203
- type: 'module',
204
- scripts: { ...CANONICAL_SCRIPTS },
205
- dependencies: deps
206
- };
246
+ engines: current.engines
247
+ });
207
248
  }
208
249
 
209
250
  // Format package.json diff for confirmation prompt (concise, not full diff).
@@ -235,39 +276,36 @@ function diffPackageJson(before, after) {
235
276
  }
236
277
  }
237
278
 
279
+ // Anything outside the canonical set is discarded by the rewrite.
280
+ // devDependencies and description used to vanish without appearing here, so
281
+ // the operator confirmed a change they were never shown.
282
+ for (const key of Object.keys(before).sort()) {
283
+ if (CANONICAL_FIELDS.has(key)) continue;
284
+ lines.push(`${pc.red('-')} ${key} ${pc.dim(summarize(before[key]))}`);
285
+ }
286
+
238
287
  return lines;
239
288
  }
240
289
 
241
- // Local pm2 runner (avoid circular dep; duplication is small).
242
- function runPm2(dir, args, { inherit = true } = {}) {
243
- const local = join(dir, 'node_modules', '.bin', process.platform === 'win32' ? 'pm2.cmd' : 'pm2');
244
- const bin = existsSync(local) ? local : 'pm2';
245
- const result = spawnSync(bin, args, {
246
- cwd: dir,
247
- stdio: inherit ? 'inherit' : 'pipe',
248
- shell: process.platform === 'win32'
249
- });
250
- if (result.error) return 1;
251
- return result.status ?? 0;
290
+ // One-line preview of a dropped field, enough to recognise what is being lost.
291
+ function summarize(value) {
292
+ if (value === null || typeof value !== 'object') return String(value);
293
+ const keys = Object.keys(value);
294
+ if (Array.isArray(value)) return `[${keys.length} item${keys.length === 1 ? '' : 's'}]`;
295
+ return keys.length ? `{${keys.join(', ')}}` : '{}';
252
296
  }
253
297
 
254
298
  // Exported for `selva doctor` to check layout drift without duplication.
255
299
  export function detectDrift(pkgJson, dir) {
256
300
  const deps = pkgJson?.dependencies ?? {};
257
301
  const reasons = [];
258
- if (deps['@selvajs/runtime']) reasons.push('@selvajs/runtime is the old runtime package');
259
- if (deps['@selvajs/create']) reasons.push('@selvajs/create is the old CLI package');
260
- if (deps['@selvajs/platform'])
261
- reasons.push('@selvajs/platform is now bundled into @selvajs/selva');
262
- if (deps['@selvajs/local-provider'])
263
- reasons.push('@selvajs/local-provider is now bundled into @selvajs/selva');
264
- if (deps['@selvajs/supabase-provider'])
265
- reasons.push('@selvajs/supabase-provider is now bundled into @selvajs/selva');
266
- if (deps['@selvajs/header-auth-provider'])
267
- reasons.push('@selvajs/header-auth-provider is now bundled into @selvajs/selva');
268
- if (!deps['@selvajs/selva']) reasons.push('@selvajs/selva is missing');
269
- if (!deps['@selvajs/cli']) reasons.push('@selvajs/cli is missing');
270
- if (!deps['pm2']) reasons.push('pm2 is not in dependencies');
302
+
303
+ for (const [name, why] of Object.entries(LEGACY_DEPENDENCIES)) {
304
+ if (deps[name]) reasons.push(`${name} is ${why}`);
305
+ }
306
+ for (const name of Object.keys(DEPENDENCIES)) {
307
+ if (!deps[name]) reasons.push(`${name} is missing`);
308
+ }
271
309
 
272
310
  if (dir) {
273
311
  const configPath = join(dir, 'selva.config.js');
@@ -290,5 +328,4 @@ export function detectDrift(pkgJson, dir) {
290
328
  return reasons;
291
329
  }
292
330
 
293
- // Re-exported for tests to verify without running migrate.
294
- export { buildTargetPackageJson, SELVA_DEPS };
331
+ export { buildTargetPackageJson, diffPackageJson };
@@ -9,10 +9,10 @@ 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
14
  // Resolve to deployment-local pm2 (no global fallback; prevents version skew).
15
- function pm2Bin(dir) {
15
+ export function pm2Bin(dir) {
16
16
  const local = join(dir, 'node_modules', '.bin', process.platform === 'win32' ? 'pm2.cmd' : 'pm2');
17
17
  if (!existsSync(local)) {
18
18
  throw new Error(
@@ -92,8 +92,14 @@ function ensurePm2InSync(dir) {
92
92
  }
93
93
  }
94
94
 
95
- function runPm2(dir, args, { inherit = true } = {}) {
96
- const bin = pm2Bin(dir);
95
+ /**
96
+ * Run the deployment-local pm2.
97
+ *
98
+ * `bin` overrides where pm2 is resolved from — `selva migrate` wipes
99
+ * node_modules mid-flight and passes the binary it stashed beforehand, because
100
+ * resolving from the deployment at that moment would find nothing.
101
+ */
102
+ export function runPm2(dir, args, { inherit = true, bin = pm2Bin(dir) } = {}) {
97
103
  const result = spawnSync(bin, args, {
98
104
  cwd: dir,
99
105
  stdio: inherit ? 'inherit' : 'pipe',
@@ -0,0 +1,102 @@
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
+ /**
7
+ * pm2 is pinned exactly, not caret-ranged.
8
+ *
9
+ * pm2's CLI and its long-running daemon must be the same version. A range lets
10
+ * two deployments installed a week apart resolve differently, and the newer one
11
+ * silently adopts the daemon the older one started — the skew `ensurePm2InSync`
12
+ * has to detect and, in the daemon-is-newer direction, cannot repair (#118).
13
+ */
14
+ const PM2_VERSION = '5.4.3';
15
+
16
+ const SCRIPTS = {
17
+ start: 'selva start',
18
+ stop: 'selva stop',
19
+ restart: 'selva restart',
20
+ logs: 'selva logs',
21
+ doctor: 'selva doctor',
22
+ update: 'selva update'
23
+ };
24
+
25
+ /**
26
+ * Packages a deployment carries. Providers are bundled into `@selvajs/selva`;
27
+ * the CLI is a dependency so `npm run start` resolves the `selva` bin locally
28
+ * instead of relying on a global install.
29
+ */
30
+ const DEPENDENCIES = {
31
+ '@selvajs/cli': 'latest',
32
+ '@selvajs/selva': 'latest',
33
+ pm2: PM2_VERSION
34
+ };
35
+
36
+ /**
37
+ * Legacy `@selvajs/*` packages that a deployment may still list. Each was
38
+ * either renamed or folded into `@selvajs/selva`; `migrate` drops them and
39
+ * `doctor` reports them as layout drift.
40
+ */
41
+ export const LEGACY_DEPENDENCIES = {
42
+ '@selvajs/runtime': 'the old runtime package',
43
+ '@selvajs/create': 'the old CLI package',
44
+ '@selvajs/platform': 'now bundled into @selvajs/selva',
45
+ '@selvajs/local-provider': 'now bundled into @selvajs/selva',
46
+ '@selvajs/supabase-provider': 'now bundled into @selvajs/selva',
47
+ '@selvajs/header-auth-provider': 'now bundled into @selvajs/selva'
48
+ };
49
+
50
+ /**
51
+ * Build the canonical deployment package.json.
52
+ *
53
+ * Dependencies are replaced wholesale rather than merged, so an operator's own
54
+ * additions are dropped. That is deliberate — a deployment directory is
55
+ * generated output, and anyone needing custom dependencies should depend on
56
+ * `@selvajs/selva` from their own project instead of editing this file.
57
+ *
58
+ * `engines` is the one exception, carried over when present: npm only enforces
59
+ * it under `engine-strict`, so an operator who pinned a Node floor did it
60
+ * deliberately, and dropping it removes a guard whose absence shows up only
61
+ * under real traffic (issue #176). Everything else a migration discards is
62
+ * listed by `diffPackageJson` before the operator confirms.
63
+ */
64
+ export function buildDeploymentPackageJson({ name, version = '0.1.0', engines }) {
65
+ const pkg = {
66
+ name: sanitizePackageName(name),
67
+ version,
68
+ private: true,
69
+ type: 'module',
70
+ scripts: { ...SCRIPTS },
71
+ dependencies: { ...DEPENDENCIES }
72
+ };
73
+ if (engines && typeof engines === 'object') pkg.engines = { ...engines };
74
+ return pkg;
75
+ }
76
+
77
+ /**
78
+ * Top-level keys the canonical package.json defines. Anything outside this set
79
+ * that an operator added is dropped by `migrate` — `diffPackageJson` reports
80
+ * them so the loss is declared rather than discovered later.
81
+ */
82
+ export const CANONICAL_FIELDS = new Set([
83
+ 'name',
84
+ 'version',
85
+ 'private',
86
+ 'type',
87
+ 'scripts',
88
+ 'dependencies',
89
+ 'engines'
90
+ ]);
91
+
92
+ export function sanitizePackageName(name) {
93
+ // npm package names: lowercase, no spaces, limited punctuation.
94
+ return (
95
+ String(name ?? '')
96
+ .toLowerCase()
97
+ .replace(/[^a-z0-9._-]+/g, '-')
98
+ .replace(/^[-._]+|[-._]+$/g, '') || 'selva-deployment'
99
+ );
100
+ }
101
+
102
+ export { DEPENDENCIES, SCRIPTS, PM2_VERSION };
@@ -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
+ }
package/src/paths.js CHANGED
@@ -21,6 +21,50 @@ export function requiredNodeRange() {
21
21
  }
22
22
  }
23
23
 
24
+ /**
25
+ * This CLI's own version, written into `.selva-version` so a later `selva
26
+ * migrate` can tell which scaffold layout it is looking at.
27
+ *
28
+ * Read from package.json for the same reason as `requiredNodeRange`: a literal
29
+ * in code stops tracking the release the moment someone forgets to bump it, and
30
+ * the marker's whole job is to be accurate about which CLI wrote the directory.
31
+ */
32
+ export function scaffoldVersion() {
33
+ try {
34
+ const here = dirname(fileURLToPath(import.meta.url));
35
+ const pkg = JSON.parse(readFileSync(join(here, '..', 'package.json'), 'utf8'));
36
+ return typeof pkg.version === 'string' ? pkg.version : '0.0.0';
37
+ } catch {
38
+ return '0.0.0';
39
+ }
40
+ }
41
+
42
+ /**
43
+ * A scaffolding template shipped inside the installed runtime. The CLI cannot
44
+ * import from `@selvajs/selva` — it is what installs it — so it reads the files
45
+ * out of node_modules after install.
46
+ */
47
+ export function runtimeTemplatePath(dir, name) {
48
+ return join(dir, 'node_modules', '@selvajs', 'selva', 'templates', name);
49
+ }
50
+
51
+ /**
52
+ * The `.env` template to merge operator values into: the runtime's `.env.example`
53
+ * when installed, otherwise the deployment's own `.env` so its comments and
54
+ * ordering survive a rewrite.
55
+ *
56
+ * Returns `''` when neither exists rather than throwing — `mergeEnv` appends
57
+ * every value to an empty template, so the operator gets a usable file instead
58
+ * of an ENOENT from a command that was only asked to change one key.
59
+ */
60
+ export function readEnvTemplate(dir) {
61
+ const template = runtimeTemplatePath(dir, '.env.example');
62
+ if (existsSync(template)) return readFileSync(template, 'utf8');
63
+ const env = join(dir, '.env');
64
+ if (existsSync(env)) return readFileSync(env, 'utf8');
65
+ return '';
66
+ }
67
+
24
68
  // A deployment directory must contain an .env and an ecosystem.config.cjs.
25
69
  // Anything else (selva.config.js, node_modules) is "should be there" but the
26
70
  // CLI doesn't require it — `selva init` is allowed to fix a partial install.
package/src/prompts.js CHANGED
@@ -527,5 +527,3 @@ function headerAuthSecurityWarning() {
527
527
  pc.dim('See packages/providers/header-auth/README.md for the full deployment checklist.')
528
528
  ].join('\n');
529
529
  }
530
-
531
- export { p as prompts, pc as colors };
package/bin/create.js DELETED
@@ -1,7 +0,0 @@
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
- });