@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.
@@ -1,29 +1,34 @@
1
- // Scaffold a fresh deployment: write .env (merged from template + prompts),
2
- // ecosystem.config.cjs, package.json, and bootstrap node_modules. Runtime
3
- // templates are the source of truth; providers are env-driven.
1
+ // Scaffolds a fresh deployment: package.json, node_modules, .env (merged from
2
+ // template + prompts), and ecosystem.config.cjs.
4
3
 
5
4
  import { writeFileSync, existsSync, mkdirSync, readFileSync, cpSync } from 'node:fs';
6
- import { resolve, join, basename } from 'node:path';
5
+ import { resolve, join, basename, dirname } from 'node:path';
7
6
  import { spawn } from 'node:child_process';
8
7
  import * as p from '@clack/prompts';
9
8
  import pc from 'picocolors';
10
9
  import { collectConfig, collectConfigFromEnv } from '../prompts.js';
11
10
  import { generateKey } from '../secrets.js';
12
11
  import { writeEnvFile } from '../env.js';
13
- import { isEmptyOrMissing, requiredNodeRange } from '../paths.js';
14
- import { satisfiesNodeRange } from './doctor.js';
15
-
16
- const CLI_VERSION = '0.1.0';
12
+ import {
13
+ isEmptyOrMissing,
14
+ requiredNodeRange,
15
+ runtimeTemplatePath,
16
+ scaffoldVersion
17
+ } from '../paths.js';
18
+ import {
19
+ buildDeploymentPackageJson,
20
+ npmDistTagVersion,
21
+ resolveSelvaPins
22
+ } from '../deployment-package.js';
23
+ import { satisfiesNodeRange } from '../node-range.js';
17
24
 
18
25
  export async function runCreate(argv) {
19
26
  const { dir: rawDir, force, skipInstall, yes } = parseArgs(argv);
20
27
 
21
- // Scaffolding under too old a Node produces a deployment that installs
22
- // cleanly and passes its health check, then fails only under real traffic —
23
- // npm treats an engine mismatch as a warning unless engine-strict is set,
24
- // which no deployment sets (issue #176). Refuse at the one moment the
25
- // operator is still choosing their environment. The floor comes from this
26
- // CLI's own engines.node, so a bump can't leave a stale copy behind.
28
+ // Scaffolding under too old a Node installs and passes the health check,
29
+ // then fails only under real traffic — npm treats an engine mismatch as a
30
+ // warning, not an error, unless engine-strict is set, which no deployment
31
+ // sets. Refuse now, while the operator is still choosing their environment.
27
32
  const required = requiredNodeRange();
28
33
  if (required && satisfiesNodeRange(process.versions.node, required) === false) {
29
34
  console.error(
@@ -46,20 +51,29 @@ export async function runCreate(argv) {
46
51
  }
47
52
  mkdirSync(targetDir, { recursive: true });
48
53
 
49
- // --yes or CI=1 skips prompts (unattended bootstrap).
50
54
  const nonInteractive = yes || envBool(process.env.CI);
51
55
  const values = nonInteractive
52
56
  ? collectConfigFromEnv(process.env)
53
57
  : await collectConfig({ defaults: {}, mode: 'create' });
54
58
 
55
- // Generate fresh secrets (stable across restarts; init refuses to regen).
56
59
  values.SELVA_HMAC_KEY = generateKey();
57
60
  values.SELVA_AT_REST_KEY = generateKey();
58
61
 
59
62
  const deployName = basename(targetDir);
60
63
 
61
- // Write package.json, then install (need templates from @selvajs/selva).
62
- const pkgJson = buildPackageJson(deployName, values);
64
+ // Resolve the dist-tags to concrete versions before writing. A stored
65
+ // `"latest"` re-resolves on every later `npm install`, so the deployment
66
+ // would follow the tag instead of the version it was scaffolded against —
67
+ // and `selva doctor` reports a floating pin as drift.
68
+ const { pins } = resolveSelvaPins({}, npmDistTagVersion);
69
+
70
+ // package.json first: npm install needs it, and the runtime templates below
71
+ // only exist once @selvajs/selva is installed into node_modules.
72
+ const pkgJson = JSON.stringify(
73
+ buildDeploymentPackageJson({ name: deployName, dependencies: pins }),
74
+ null,
75
+ 2
76
+ );
63
77
  writeFileSync(join(targetDir, 'package.json'), pkgJson + '\n', 'utf8');
64
78
 
65
79
  if (!skipInstall) {
@@ -70,24 +84,23 @@ export async function runCreate(argv) {
70
84
  );
71
85
  }
72
86
 
73
- // Copy templates from installed runtime.
74
- const runtimeTemplates = join(targetDir, 'node_modules', '@selvajs', 'selva', 'templates');
75
- if (skipInstall || !existsSync(runtimeTemplates)) {
87
+ const envTemplatePath = runtimeTemplatePath(targetDir, '.env.example');
88
+ const ecosystemTemplatePath = runtimeTemplatePath(targetDir, 'ecosystem.config.cjs');
89
+ if (skipInstall || !existsSync(envTemplatePath)) {
76
90
  p.log.warn(
77
- `Couldn't read runtime templates from ${runtimeTemplates}. ` +
91
+ `Couldn't read runtime templates from ${dirname(envTemplatePath)}. ` +
78
92
  `Run \`npm install\`, then \`selva init\` to finish setup.`
79
93
  );
80
- writeFileSync(join(targetDir, '.selva-version'), CLI_VERSION + '\n', 'utf8');
94
+ writeFileSync(join(targetDir, '.selva-version'), scaffoldVersion() + '\n', 'utf8');
81
95
  p.outro(`Partial scaffold at ${pc.cyan(targetDir)}.`);
82
96
  return;
83
97
  }
84
98
 
85
- const envTemplate = readFileSync(join(runtimeTemplates, '.env.example'), 'utf8');
86
- writeEnvFile(join(targetDir, '.env'), envTemplate, values);
99
+ writeEnvFile(join(targetDir, '.env'), readFileSync(envTemplatePath, 'utf8'), values);
87
100
 
88
- cpSync(join(runtimeTemplates, 'ecosystem.config.cjs'), join(targetDir, 'ecosystem.config.cjs'));
101
+ cpSync(ecosystemTemplatePath, join(targetDir, 'ecosystem.config.cjs'));
89
102
 
90
- writeFileSync(join(targetDir, '.selva-version'), CLI_VERSION + '\n', 'utf8');
103
+ writeFileSync(join(targetDir, '.selva-version'), scaffoldVersion() + '\n', 'utf8');
91
104
  writeGitignore(targetDir);
92
105
 
93
106
  p.outro(
@@ -106,21 +119,17 @@ export async function runCreate(argv) {
106
119
  );
107
120
  }
108
121
 
109
- // Stream npm output with live progress; on failure, show tail for debugging.
110
- // Cache-bust hint: surface broken versions like @selvajs/selva@0.10.2.
111
122
  function runNpmInstall(cwd) {
112
123
  return new Promise((resolveP, rejectP) => {
113
124
  const s = p.spinner();
114
125
  s.start('Installing dependencies (this can take a minute)');
115
126
 
116
- // Keep last 80 lines for failure output.
117
127
  const tail = [];
118
128
  const remember = (line) => {
119
129
  tail.push(line);
120
130
  if (tail.length > 80) tail.shift();
121
131
  };
122
132
 
123
- // Extract progress milestones (reify: lines, package count).
124
133
  const updateProgress = (line) => {
125
134
  const trimmed = line.trim();
126
135
  if (!trimmed) return;
@@ -136,7 +145,7 @@ function runNpmInstall(cwd) {
136
145
  if (/^npm (WARN|error|notice)/.test(trimmed)) return;
137
146
  };
138
147
 
139
- // Spawn with --loglevel=info for reify: progress; pipe stdout/stderr for live updates.
148
+ // --loglevel=info is what makes npm print the reify: lines updateProgress matches on.
140
149
  const child = spawn('npm', ['install', '--loglevel=info'], {
141
150
  cwd,
142
151
  stdio: ['ignore', 'pipe', 'pipe'],
@@ -180,8 +189,8 @@ function runNpmInstall(cwd) {
180
189
  }
181
190
  s.stop(pc.red(`npm install failed (exit ${code})`));
182
191
 
183
- // Show what npm actually said. Without this the operator has to
184
- // dig through ~/.npm/_logs/*-debug-0.log to find a single line.
192
+ // Without this the operator has to dig through ~/.npm/_logs/*-debug-0.log
193
+ // to find out what actually went wrong.
185
194
  console.error('');
186
195
  console.error(pc.dim('── last lines of npm output ──'));
187
196
  for (const line of tail) console.error(line);
@@ -192,7 +201,7 @@ function runNpmInstall(cwd) {
192
201
  console.error(
193
202
  pc.yellow(
194
203
  'npm resolved @selvajs/selva@0.10.2 — that version is broken (unresolved\n' +
195
- "workspace:* / catalog: specs) and has been unpublished. Your local npm\n" +
204
+ 'workspace:* / catalog: specs) and has been unpublished. Your local npm\n' +
196
205
  'cache is stale. Clear it and retry:\n\n' +
197
206
  ' npm cache clean --force\n' +
198
207
  ' rm -rf node_modules package-lock.json\n' +
@@ -206,11 +215,37 @@ function runNpmInstall(cwd) {
206
215
  });
207
216
  }
208
217
 
218
+ // `npx @selvajs/cli` resolves to the `cli` bin — the scaffolder — while every
219
+ // operate command lives on the sibling `selva` bin. So `npx @selvajs/cli doctor
220
+ // --fix` lands here, where "doctor" is just a directory name and `--fix` an
221
+ // unknown flag. Name the bin split rather than letting the operator conclude
222
+ // their CLI is too old for a flag it has shipped for releases.
223
+ const OPERATE_COMMANDS = new Set([
224
+ 'doctor',
225
+ 'start',
226
+ 'stop',
227
+ 'restart',
228
+ 'logs',
229
+ 'update',
230
+ 'migrate',
231
+ 'keys',
232
+ 'init'
233
+ ]);
234
+
209
235
  function parseArgs(argv) {
210
236
  let dir;
211
237
  let force = false;
212
238
  let skipInstall = false;
213
239
  let yes = false;
240
+
241
+ if (OPERATE_COMMANDS.has(argv[0])) {
242
+ throw new Error(
243
+ `\`${argv[0]}\` is an operate command, but \`npx @selvajs/cli\` runs the scaffolder.\n` +
244
+ ` Run it through the deployment's own CLI instead:\n` +
245
+ ` npx selva ${argv.join(' ')}`
246
+ );
247
+ }
248
+
214
249
  for (const arg of argv) {
215
250
  if (arg === '--force') force = true;
216
251
  else if (arg === '--skip-install') skipInstall = true;
@@ -224,9 +259,7 @@ function parseArgs(argv) {
224
259
  }
225
260
  }
226
261
  if (!dir) {
227
- throw new Error(
228
- 'Usage: npx @selvajs/cli <directory> [--force] [--skip-install] [--yes]'
229
- );
262
+ throw new Error('Usage: npx @selvajs/cli <directory> [--force] [--skip-install] [--yes]');
230
263
  }
231
264
  return { dir, force, skipInstall, yes };
232
265
  }
@@ -236,44 +269,6 @@ function envBool(v) {
236
269
  return ['1', 'true', 'yes'].includes(String(v).toLowerCase());
237
270
  }
238
271
 
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
272
  function writeGitignore(dir) {
278
273
  const path = join(dir, '.gitignore');
279
274
  if (existsSync(path)) return;