@selvajs/cli 4.8.2 → 4.9.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/README.md CHANGED
@@ -24,7 +24,7 @@ selva keys rotate hmac # rotate SELVA_HMAC_KEY (logs everyone out)
24
24
  selva keys rotate at-rest # rotate SELVA_AT_REST_KEY (compute API key needs re-entry)
25
25
  ```
26
26
 
27
- `selva init` never regenerates `SELVA_HMAC_KEY` or `SELVA_AT_REST_KEY` once they're set — rotating those is `keys rotate`'s job, since it invalidates sessions or encrypted data. A `.selva-version` marker records which CLI version scaffolded the directory, so a later `selva migrate` knows what layout it's upgrading from.
27
+ `selva init` never regenerates `SELVA_HMAC_KEY` or `SELVA_AT_REST_KEY` once they're set — rotating those is `keys rotate`'s job, since it invalidates sessions or encrypted data.
28
28
 
29
29
  ## Relationship to `@selvajs/selva`
30
30
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@selvajs/cli",
3
- "version": "4.8.2",
3
+ "version": "4.9.0",
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",
@@ -0,0 +1,89 @@
1
+ // The Caddyfile a Selva deployment sits behind.
2
+ //
3
+ // This is the same config `infra/startup.sh.tpl` writes on the Terraform path —
4
+ // kept here as the single source so the two deployment routes can't drift.
5
+ // Anything changed in one has to be changed in the other, and a diff between
6
+ // them is a bug.
7
+ //
8
+ // Only the production (HTTPS + ACME) shape is emitted. The dev/plain-HTTP
9
+ // variant is deliberately absent: it exists in
10
+ // docs/self-hosting/deployment/Caddyfile.example for hand-editing, and a
11
+ // generator that can silently produce a no-TLS config is a footgun — Secure
12
+ // cookies are dropped over http://, so login appears to work and every
13
+ // subsequent request is anonymous.
14
+
15
+ /**
16
+ * Render a Caddyfile for `domain`, reverse-proxying to the local app port.
17
+ *
18
+ * `www.<domain>` gets a permanent redirect to the apex. Skipped when the domain
19
+ * is already a subdomain — `www.app.example.dev` is not a name anyone points at,
20
+ * and Caddy would try to provision a certificate for it on first boot and log a
21
+ * failure every retry.
22
+ */
23
+ export function renderCaddyfile({ domain, acmeEmail, port = 3000 }) {
24
+ const labels = domain.split('.').length;
25
+ const wantsWww = labels === 2;
26
+
27
+ const site = `${domain} {
28
+ encode gzip
29
+
30
+ # === HEADER AUTH SLOT ===
31
+ # Forward-auth providers go here. See packages/providers/header-auth/README.md.
32
+
33
+ reverse_proxy 127.0.0.1:${port}
34
+
35
+ header {
36
+ Strict-Transport-Security "max-age=31536000; includeSubDomains; preload"
37
+ X-Content-Type-Options "nosniff"
38
+ Referrer-Policy "strict-origin-when-cross-origin"
39
+ Permissions-Policy "geolocation=(), microphone=(), camera=()"
40
+ -Server
41
+ }
42
+
43
+ @static path /assets/*
44
+ header @static Cache-Control "public, max-age=31536000, immutable"
45
+ @api path /api/*
46
+ header @api Cache-Control "no-cache, no-store, must-revalidate"
47
+
48
+ request_body {
49
+ max_size 100mb
50
+ }
51
+
52
+ log {
53
+ output file /var/log/caddy/access.log
54
+ format json
55
+ }
56
+ }`;
57
+
58
+ const www = wantsWww
59
+ ? `
60
+
61
+ www.${domain} {
62
+ redir https://${domain}{uri} permanent
63
+ }`
64
+ : '';
65
+
66
+ return `{
67
+ email ${acmeEmail}
68
+ }
69
+
70
+ ${site}${www}
71
+ `;
72
+ }
73
+
74
+ /** A hostname Caddy can request a certificate for. */
75
+ export function isServableDomain(value) {
76
+ if (typeof value !== 'string') return false;
77
+ const domain = value.trim();
78
+ if (domain === '' || domain.length > 253) return false;
79
+ // Rejects bare hostnames, IPs, and anything with a scheme, port, or path —
80
+ // ACME issues against a resolvable FQDN and nothing else.
81
+ return /^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?(\.[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?)+$/i.test(
82
+ domain
83
+ );
84
+ }
85
+
86
+ /** The origin a given domain implies, for cross-checking `.env`. */
87
+ export function originFor(domain) {
88
+ return `https://${domain}`;
89
+ }
@@ -18,6 +18,7 @@ import { spawnSync } from 'node:child_process';
18
18
  import { posix as path } from 'node:path';
19
19
  import { homedir } from 'node:os';
20
20
  import { green, red, yellow, fixable } from './result.js';
21
+ import { escalationHint, runPrivileged } from './privileged.js';
21
22
 
22
23
  /** The real OS. Tests pass a stand-in with the same shape. */
23
24
  export const systemEnv = {
@@ -34,7 +35,8 @@ export const systemEnv = {
34
35
  return false;
35
36
  }
36
37
  },
37
- run: (cmd, args, opts) => spawnSync(cmd, args, { ...opts, encoding: 'utf8' })
38
+ run: (cmd, args, opts) => spawnSync(cmd, args, { ...opts, encoding: 'utf8' }),
39
+ runPrivileged: (cmd, args, opts) => runPrivileged(cmd, args, opts)
38
40
  };
39
41
 
40
42
  export function checkBootPersistence(dir, env = systemEnv) {
@@ -80,7 +82,12 @@ function checkSystemdUnit(dir, env) {
80
82
  yellow(
81
83
  'pm2 systemd boot unit not installed — the app will NOT restart after a ' +
82
84
  'reboot. Run `npx pm2 startup systemd -u $USER --hp $HOME` and paste the ' +
83
- 'printed command (point it at this deployment’s pm2).'
85
+ 'printed command (point it at this deployment’s pm2).',
86
+ user
87
+ ? fixable('install the pm2 systemd boot unit (needs sudo)', () =>
88
+ installBootUnit(dir, user, env)
89
+ )
90
+ : undefined
84
91
  )
85
92
  ];
86
93
  }
@@ -106,11 +113,74 @@ function checkSystemdUnit(dir, env) {
106
113
  `Reboots will resurrect via the wrong pm2 (version skew). Re-run startup ` +
107
114
  `with the local binary:\n ` +
108
115
  `sudo env PATH=$PATH:${path.join(dir, 'node_modules', '.bin')} ${localPm2} ` +
109
- `startup systemd -u $USER --hp $HOME`
116
+ `startup systemd -u $USER --hp $HOME`,
117
+ fixable('repoint the boot unit at this deployment’s pm2 (needs sudo)', () =>
118
+ installBootUnit(dir, user, env)
119
+ )
110
120
  )
111
121
  ];
112
122
  }
113
123
 
124
+ /**
125
+ * Install (or overwrite) the pm2 systemd unit so it resurrects via this
126
+ * deployment's pm2.
127
+ *
128
+ * pm2's own `startup` subcommand doesn't write the unit when run unprivileged —
129
+ * it prints a `sudo env PATH=… pm2 startup …` line for the operator to paste.
130
+ * Rather than parse that line, this runs the same command through `sudo`
131
+ * directly: PATH carries the deployment's `.bin` so systemd's generated
132
+ * ExecStart resolves to the local pm2 rather than whichever one is on root's
133
+ * PATH — the exact skew the wrong-unit branch above exists to catch.
134
+ *
135
+ * A unit without a saved process list resurrects nothing, so `pm2 save` runs
136
+ * after. Reporting it separately keeps a save failure from reading as a failed
137
+ * unit install.
138
+ */
139
+ function installBootUnit(dir, user, env) {
140
+ const localPm2 = path.join(dir, 'node_modules', 'pm2', 'bin', 'pm2');
141
+ if (!env.exists(localPm2)) {
142
+ return red('pm2 not installed in this deployment — run `npm install` first');
143
+ }
144
+
145
+ const home = env.env().HOME ?? env.homedir();
146
+ const binDir = path.join(dir, 'node_modules', '.bin');
147
+ const result = env.runPrivileged(
148
+ 'env',
149
+ [
150
+ `PATH=${env.env().PATH ?? ''}:${binDir}`,
151
+ localPm2,
152
+ 'startup',
153
+ 'systemd',
154
+ '-u',
155
+ user,
156
+ '--hp',
157
+ home
158
+ ],
159
+ { cwd: dir }
160
+ );
161
+
162
+ if (result.blocked) {
163
+ return yellow(
164
+ `could not escalate — ${escalationHint(result.reason)}. Run this yourself:\n ` +
165
+ result.command
166
+ );
167
+ }
168
+ if (!result.ok) {
169
+ return red(
170
+ `pm2 startup failed (exit ${result.status}). Run it manually:\n ` + result.command
171
+ );
172
+ }
173
+
174
+ const saved = env.run(path.join(binDir, 'pm2'), ['save'], { cwd: dir });
175
+ if ((saved.status ?? 1) !== 0) {
176
+ return yellow(
177
+ 'boot unit installed, but `pm2 save` failed — a reboot would resurrect an ' +
178
+ 'empty process list. Run `npx pm2 save` once the app is started.'
179
+ );
180
+ }
181
+ return green('pm2 systemd boot unit installed and process list saved');
182
+ }
183
+
114
184
  // A pm2 outside the deployment is the root cause of daemon version skew.
115
185
  function checkStrayGlobalPm2(dir, env) {
116
186
  const globalPm2 = findGlobalPm2(dir, env);
@@ -0,0 +1,92 @@
1
+ // Running a command as root from a tool the operator invoked as themselves.
2
+ //
3
+ // Every privileged repair in the CLI funnels through here so there is exactly
4
+ // one place that decides whether escalation is possible, and exactly one shape
5
+ // for "we could not, here is the line to run yourself". A fixer that shells out
6
+ // to sudo on its own would each need to re-derive that fallback, and the ones
7
+ // that got it wrong would strand an operator mid-repair with no printed command.
8
+ //
9
+ // The contract: never escalate without the caller having already confirmed, and
10
+ // never escalate when nobody is at the terminal to answer a password prompt.
11
+ // `sudo -n` (non-interactive) is the probe for the second — it exits non-zero
12
+ // rather than blocking when a password would be needed.
13
+
14
+ import { spawnSync } from 'node:child_process';
15
+
16
+ /** The real OS. Tests pass a stand-in with the same shape. */
17
+ export const systemPrivileged = {
18
+ platform: () => process.platform,
19
+ isTTY: () => Boolean(process.stdin.isTTY && process.stdout.isTTY),
20
+ geteuid: () => (typeof process.geteuid === 'function' ? process.geteuid() : null),
21
+ run: (cmd, args, opts) => spawnSync(cmd, args, { ...opts, encoding: 'utf8' })
22
+ };
23
+
24
+ /**
25
+ * How a privileged command can be run here, if at all.
26
+ *
27
+ * - `root` — already uid 0; run it directly, no sudo needed.
28
+ * - `sudo` — sudo exists and a TTY is attached to answer for it.
29
+ * - `blocked` — no way to escalate. `reason` says which, for the printed hint.
30
+ */
31
+ export function escalationMode(env = systemPrivileged) {
32
+ if (env.platform() !== 'linux') return { mode: 'blocked', reason: 'not-linux' };
33
+ if (env.geteuid() === 0) return { mode: 'root' };
34
+
35
+ const probe = env.run('sudo', ['-n', 'true'], {});
36
+ // ENOENT surfaces as a null status with an error — sudo isn't installed.
37
+ if (probe.error) return { mode: 'blocked', reason: 'no-sudo' };
38
+
39
+ // A passwordless sudo (NOPASSWD, or a live credential cache) works even with
40
+ // no TTY, so it's checked before the TTY requirement rather than after.
41
+ if ((probe.status ?? 1) === 0) return { mode: 'sudo' };
42
+
43
+ if (!env.isTTY()) return { mode: 'blocked', reason: 'no-tty' };
44
+ return { mode: 'sudo' };
45
+ }
46
+
47
+ /** The reason text a blocked escalation reports, for a printed fallback. */
48
+ export function escalationHint(reason) {
49
+ switch (reason) {
50
+ case 'not-linux':
51
+ return 'this step is Linux-only';
52
+ case 'no-sudo':
53
+ return 'sudo is not available on this host';
54
+ case 'no-tty':
55
+ return 'sudo needs a password and nothing is attached to answer it';
56
+ default:
57
+ return 'privileged commands cannot be run from here';
58
+ }
59
+ }
60
+
61
+ /**
62
+ * Run `cmd args` with root privileges.
63
+ *
64
+ * Returns `{ ok, status, stdout, stderr, command }` — `command` is the exact
65
+ * line that was run (or would have been), so a caller can print it verbatim
66
+ * when escalation is blocked rather than reconstructing it.
67
+ *
68
+ * Callers MUST have confirmed with the operator first. This does not prompt.
69
+ */
70
+ export function runPrivileged(cmd, args, { env = systemPrivileged, cwd } = {}) {
71
+ const { mode, reason } = escalationMode(env);
72
+ const command = mode === 'root' ? [cmd, ...args].join(' ') : ['sudo', cmd, ...args].join(' ');
73
+
74
+ if (mode === 'blocked') {
75
+ return { ok: false, blocked: true, reason, command, stdout: '', stderr: '' };
76
+ }
77
+
78
+ const r =
79
+ mode === 'root'
80
+ ? env.run(cmd, args, { cwd, stdio: 'inherit' })
81
+ : // stdio inherit so sudo's own password prompt reaches the terminal.
82
+ env.run('sudo', [cmd, ...args], { cwd, stdio: 'inherit' });
83
+
84
+ return {
85
+ ok: (r.status ?? 1) === 0,
86
+ blocked: false,
87
+ status: r.status ?? 1,
88
+ stdout: r.stdout ?? '',
89
+ stderr: r.stderr ?? '',
90
+ command
91
+ };
92
+ }
package/src/cli.js CHANGED
@@ -15,6 +15,7 @@ const COMMANDS = {
15
15
  logs: () => import('./commands/pm2.js').then((m) => m.runLogs),
16
16
  update: () => import('./commands/pm2.js').then((m) => m.runUpdate),
17
17
  migrate: () => import('./commands/migrate.js').then((m) => m.runMigrate),
18
+ 'setup-proxy': () => import('./commands/proxy.js').then((m) => m.runSetupProxy),
18
19
  keys: () => import('./commands/keys.js').then((m) => keysDispatch(m))
19
20
  };
20
21
 
@@ -52,6 +53,15 @@ const USAGE = {
52
53
  blurb:
53
54
  'Bring package.json and the deployment layout onto the current release.\nRewrites keys, never comments.'
54
55
  },
56
+ 'setup-proxy': {
57
+ usage: 'selva setup-proxy [--domain <fqdn>] [--acme-email <addr>]',
58
+ blurb:
59
+ "Put Caddy in front of the app with a Let's Encrypt certificate. Installs\nCaddy if missing, writes /etc/caddy/Caddyfile, validates, and reloads.\nNeeds sudo; prints the config to apply by hand when it can't escalate.",
60
+ flags: [
61
+ ['--domain', 'Domain to serve. Prompts (defaulting to ORIGIN) when omitted.'],
62
+ ['--acme-email', "Address Let's Encrypt sends expiry notices to."]
63
+ ]
64
+ },
55
65
  keys: {
56
66
  usage: 'selva keys rotate <hmac|at-rest>',
57
67
  blurb:
@@ -129,6 +139,7 @@ function printHelp() {
129
139
  ' logs pm2 logs selva-compute',
130
140
  ' update npm update @selvajs/cli + @selvajs/selva, then restart',
131
141
  ' migrate Bring package.json onto the current layout',
142
+ ' setup-proxy Install + configure Caddy with TLS for this deployment',
132
143
  ' keys rotate <hmac|at-rest> Rotate a secret in .env (destructive)',
133
144
  '',
134
145
  pc.dim('`npx selva <command> --help` explains one command.'),
@@ -9,12 +9,7 @@ import pc from 'picocolors';
9
9
  import { collectConfig, collectConfigFromEnv } from '../prompts.js';
10
10
  import { generateKey } from '../secrets.js';
11
11
  import { writeEnvFile } from '../env.js';
12
- import {
13
- isEmptyOrMissing,
14
- requiredNodeRange,
15
- runtimeTemplatePath,
16
- scaffoldVersion
17
- } from '../paths.js';
12
+ import { isEmptyOrMissing, requiredNodeRange, runtimeTemplatePath } from '../paths.js';
18
13
  import {
19
14
  buildDeploymentPackageJson,
20
15
  needsSupabaseProvider,
@@ -97,7 +92,6 @@ export async function runCreate(argv) {
97
92
  `Couldn't read runtime templates from ${dirname(envTemplatePath)}. ` +
98
93
  `Run \`npm install\`, then \`selva init\` to finish setup.`
99
94
  );
100
- writeFileSync(join(targetDir, '.selva-version'), scaffoldVersion() + '\n', 'utf8');
101
95
  p.outro(`Partial scaffold at ${pc.cyan(targetDir)}.`);
102
96
  return;
103
97
  }
@@ -106,23 +100,71 @@ export async function runCreate(argv) {
106
100
 
107
101
  cpSync(ecosystemTemplatePath, join(targetDir, 'ecosystem.config.cjs'));
108
102
 
109
- writeFileSync(join(targetDir, '.selva-version'), scaffoldVersion() + '\n', 'utf8');
110
103
  writeGitignore(targetDir);
111
104
 
112
- p.outro(
113
- [
114
- pc.green('Scaffolded ' + pc.cyan(targetDir)),
115
- '',
116
- pc.bold('Next steps:'),
117
- ` cd ${rawDir}`,
118
- ` npm run doctor # sanity-check the install`,
119
- ` npm start # pm2 start ecosystem.config.cjs`,
120
- '',
121
- values.ORIGIN
122
- ? `Then visit ${pc.cyan(values.ORIGIN)} (set up your reverse proxy first).`
123
- : `Then visit ${pc.cyan('http://localhost:3000')}.`
124
- ].join('\n')
105
+ p.outro(nextSteps({ rawDir, values, supabase }).join('\n'));
106
+ }
107
+
108
+ /**
109
+ * The remaining work, in the order it has to happen.
110
+ *
111
+ * Ordered by dependency, not by importance: the schema has to exist before the
112
+ * app can serve a request, and the proxy has to terminate TLS before a browser
113
+ * will keep the session cookie. A list that mentioned them in any other order
114
+ * would have the operator debugging a 503 or a login loop that the next step
115
+ * was about to fix.
116
+ */
117
+ function nextSteps({ rawDir, values, supabase }) {
118
+ const lines = [
119
+ pc.green('Scaffolded ' + pc.cyan(targetDirLabel(rawDir))),
120
+ '',
121
+ pc.bold('Next steps:')
122
+ ];
123
+ let n = 1;
124
+
125
+ lines.push(` ${n++}. cd ${rawDir}`);
126
+
127
+ if (supabase) {
128
+ lines.push(
129
+ ` ${n++}. Apply the database schema (once per Supabase project):`,
130
+ pc.dim(' npx selva-supabase # copy migrations into ./supabase'),
131
+ pc.dim(' npx supabase link --project-ref <ref>'),
132
+ pc.dim(' npx supabase db push')
133
+ );
134
+ }
135
+
136
+ lines.push(
137
+ ` ${n++}. npm run doctor${pc.dim(' # verify config, schema, and boot persistence')}`
138
+ );
139
+ lines.push(` ${n++}. npm start${pc.dim(' # start under pm2')}`);
140
+
141
+ if (values.ORIGIN?.startsWith('https://')) {
142
+ lines.push(
143
+ ` ${n++}. npx selva setup-proxy${pc.dim(' # Caddy + TLS for ' + hostOf(values.ORIGIN))}`
144
+ );
145
+ }
146
+
147
+ lines.push(
148
+ '',
149
+ values.ORIGIN
150
+ ? `Then visit ${pc.cyan(values.ORIGIN)} and open ${pc.cyan('/setup')} to claim admin.`
151
+ : `Then visit ${pc.cyan('http://localhost:3000')} and open ${pc.cyan('/setup')} to claim admin.`,
152
+ '',
153
+ pc.dim('`npm run doctor --fix` repairs what it can, including pm2 boot persistence.')
125
154
  );
155
+ return lines;
156
+ }
157
+
158
+ function targetDirLabel(rawDir) {
159
+ return resolve(rawDir);
160
+ }
161
+
162
+ function hostOf(origin) {
163
+ try {
164
+ return new URL(origin).host;
165
+ } catch {
166
+ return origin;
167
+ }
126
168
  }
127
169
 
128
170
  function runNpmInstall(cwd) {
@@ -158,7 +200,6 @@ function runNpmInstall(cwd) {
158
200
  shell: process.platform === 'win32'
159
201
  });
160
202
 
161
- let sawBrokenVersion = false;
162
203
  const handleStream = (stream) => {
163
204
  let buf = '';
164
205
  stream.setEncoding('utf8');
@@ -169,7 +210,6 @@ function runNpmInstall(cwd) {
169
210
  for (const line of lines) {
170
211
  remember(line);
171
212
  updateProgress(line);
172
- if (line.includes('@selvajs/selva@0.10.2')) sawBrokenVersion = true;
173
213
  }
174
214
  });
175
215
  stream.on('end', () => {
@@ -202,19 +242,19 @@ function runNpmInstall(cwd) {
202
242
  for (const line of tail) console.error(line);
203
243
  console.error(pc.dim('──────────────────────────────'));
204
244
 
205
- if (sawBrokenVersion) {
206
- console.error('');
207
- console.error(
208
- pc.yellow(
209
- 'npm resolved @selvajs/selva@0.10.2 — that version is broken (unresolved\n' +
210
- 'workspace:* / catalog: specs) and has been unpublished. Your local npm\n' +
211
- 'cache is stale. Clear it and retry:\n\n' +
212
- ' npm cache clean --force\n' +
213
- ' rm -rf node_modules package-lock.json\n' +
214
- ' npm install --prefer-online'
215
- )
216
- );
217
- }
245
+ // A stale npm cache resolves versions that no longer exist on the
246
+ // registry, and the error it produces names the package rather than the
247
+ // cache — so this is worth suggesting on any install failure.
248
+ console.error('');
249
+ console.error(
250
+ pc.yellow(
251
+ 'If the output above names a version that cannot be resolved, the local\n' +
252
+ 'npm cache is stale. Clear it and retry:\n\n' +
253
+ ' npm cache clean --force\n' +
254
+ ' rm -rf node_modules package-lock.json\n' +
255
+ ' npm install --prefer-online'
256
+ )
257
+ );
218
258
 
219
259
  rejectP(new Error(`npm install exited with code ${code}`));
220
260
  });
@@ -131,13 +131,6 @@ export async function runMigrate(
131
131
  );
132
132
  const pkgDiff = diffPackageJson(before, target);
133
133
 
134
- const configPath = join(dir, 'selva.config.js');
135
- const hasStaleConfig = existsSync(configPath);
136
-
137
- const ecoPath = join(dir, 'ecosystem.config.cjs');
138
- const ecoHasStaleRuntime =
139
- existsSync(ecoPath) && readFileSync(ecoPath, 'utf8').includes('@selvajs/runtime');
140
-
141
134
  // The server drops its read-old-name shim after one minor version, so
142
135
  // rewriting now is what keeps a tuned value tuned instead of silently
143
136
  // reverting to defaults later.
@@ -148,14 +141,6 @@ export async function runMigrate(
148
141
  : { text: '', changes: [] };
149
142
 
150
143
  const sideFileChanges = [];
151
- if (hasStaleConfig)
152
- sideFileChanges.push(
153
- `${pc.red('-')} selva.config.js ${pc.dim('(no longer needed; providers are env-driven)')}`
154
- );
155
- if (ecoHasStaleRuntime)
156
- sideFileChanges.push(
157
- `${pc.yellow('~')} ecosystem.config.cjs ${pc.dim('(rewrite: @selvajs/runtime → @selvajs/selva)')}`
158
- );
159
144
  for (const [oldName, newName, action] of envRename.changes) {
160
145
  sideFileChanges.push(
161
146
  action === 'dropped'
@@ -213,28 +198,11 @@ export async function runMigrate(
213
198
  // later migration cannot overwrite this one's (#184).
214
199
  const stamp = backupStamp();
215
200
  const bakPath = backupPathFor(pkgPath, stamp);
216
- const configBak = backupPathFor(configPath, stamp);
217
- const ecoBak = backupPathFor(ecoPath, stamp);
218
201
  const envBak = backupPathFor(envPath, stamp);
219
202
 
220
203
  copyFileSync(pkgPath, bakPath);
221
204
  writeFileSync(pkgPath, JSON.stringify(target, null, 2) + '\n', 'utf8');
222
205
 
223
- if (hasStaleConfig) {
224
- copyFileSync(configPath, configBak);
225
- rmSync(configPath, { force: true });
226
- }
227
-
228
- if (ecoHasStaleRuntime) {
229
- copyFileSync(ecoPath, ecoBak);
230
- // Text substitution, not regeneration, so any operator customizations to the file survive.
231
- const ecoContent = readFileSync(ecoPath, 'utf8').replace(
232
- /@selvajs\/runtime/g,
233
- '@selvajs/selva'
234
- );
235
- writeFileSync(ecoPath, ecoContent, 'utf8');
236
- }
237
-
238
206
  if (envRename.changes.length > 0) {
239
207
  copyFileSync(envPath, envBak);
240
208
  writeFileSync(envPath, envRename.text, 'utf8');
@@ -263,12 +231,6 @@ export async function runMigrate(
263
231
  } catch (err) {
264
232
  s.stop(pc.red('npm install failed — rolling back'));
265
233
  copyFileSync(bakPath, pkgPath);
266
- if (hasStaleConfig && existsSync(configBak)) {
267
- copyFileSync(configBak, configPath);
268
- }
269
- if (ecoHasStaleRuntime && existsSync(ecoBak)) {
270
- copyFileSync(ecoBak, ecoPath);
271
- }
272
234
  if (envRename.changes.length > 0 && existsSync(envBak)) {
273
235
  copyFileSync(envBak, envPath);
274
236
  }
@@ -306,8 +268,6 @@ export async function runMigrate(
306
268
  if (pm2Notice) p.log.warn(pm2Notice);
307
269
 
308
270
  const backupHints = [`package.json.${stamp}.bak`];
309
- if (hasStaleConfig) backupHints.push(`selva.config.js.${stamp}.bak`);
310
- if (ecoHasStaleRuntime) backupHints.push(`ecosystem.config.cjs.${stamp}.bak`);
311
271
  if (envRename.changes.length > 0) backupHints.push(`.env.${stamp}.bak`);
312
272
 
313
273
  if (status === 0) {
@@ -480,14 +440,6 @@ export function detectDrift(pkgJson, dir) {
480
440
  }
481
441
 
482
442
  if (dir) {
483
- const configPath = join(dir, 'selva.config.js');
484
- if (existsSync(configPath)) {
485
- reasons.push('selva.config.js is no longer needed (providers are env-driven)');
486
- }
487
- const ecoPath = join(dir, 'ecosystem.config.cjs');
488
- if (existsSync(ecoPath) && readFileSync(ecoPath, 'utf8').includes('@selvajs/runtime')) {
489
- reasons.push('ecosystem.config.cjs still references @selvajs/runtime');
490
- }
491
443
  const envPath = join(dir, '.env');
492
444
  if (existsSync(envPath)) {
493
445
  const { changes } = renameEnvKeys(readFileSync(envPath, 'utf8'), RENAMED_ENV_VARS);
@@ -0,0 +1,254 @@
1
+ // `selva setup-proxy` — put Caddy in front of the app with a real certificate.
2
+ //
3
+ // This is the step that separates "pm2 says the app is up" from "someone can
4
+ // log in". The app binds 127.0.0.1 only, so without a proxy nothing reaches it;
5
+ // and without TLS the session cookie is dropped by the browser, which looks
6
+ // exactly like a login that silently fails.
7
+ //
8
+ // Every privileged action is confirmed once, up front, and routed through
9
+ // runPrivileged so a host without sudo gets the exact commands printed instead
10
+ // of a half-applied config.
11
+
12
+ import { existsSync, readFileSync, writeFileSync, mkdtempSync } from 'node:fs';
13
+ import { join } from 'node:path';
14
+ import { tmpdir } from 'node:os';
15
+ import { spawnSync } from 'node:child_process';
16
+ import * as p from '@clack/prompts';
17
+ import pc from 'picocolors';
18
+
19
+ import { renderCaddyfile, isServableDomain, originFor } from '../caddyfile.js';
20
+ import { escalationHint, escalationMode, runPrivileged } from '../checks/privileged.js';
21
+ import { readEnvFile } from '../env.js';
22
+
23
+ const CADDYFILE_PATH = '/etc/caddy/Caddyfile';
24
+
25
+ export async function runSetupProxy(argv = []) {
26
+ const dir = process.cwd();
27
+ p.intro(pc.bgCyan(pc.black(' selva setup-proxy ')));
28
+
29
+ if (process.platform !== 'linux') {
30
+ p.outro(
31
+ pc.yellow(
32
+ `Caddy setup is automated for Linux/systemd only. On ${process.platform}, ` +
33
+ `copy docs/self-hosting/deployment/Caddyfile.example by hand.`
34
+ )
35
+ );
36
+ return;
37
+ }
38
+
39
+ const env = existsSync(join(dir, '.env')) ? readEnvFile(join(dir, '.env')) : {};
40
+ const domain = await resolveDomain(argv, env);
41
+ if (domain === null) return;
42
+
43
+ const acmeEmail = await resolveAcmeEmail(argv, env, domain);
44
+ if (acmeEmail === null) return;
45
+
46
+ // ORIGIN drives cookie and redirect behaviour. A proxy serving a domain the
47
+ // app doesn't know about produces logins that succeed then bounce, so this
48
+ // is worth stopping for rather than warning about after the fact.
49
+ const expectedOrigin = originFor(domain);
50
+ if (env.ORIGIN && env.ORIGIN !== expectedOrigin) {
51
+ p.log.warn(
52
+ `.env has ORIGIN=${env.ORIGIN}, but this proxy will serve ${expectedOrigin}.\n` +
53
+ `Sessions break when they disagree — update ORIGIN with \`selva init\` ` +
54
+ `(then \`selva restart\`) if the proxy domain is the right one.`
55
+ );
56
+ }
57
+
58
+ const caddyfile = renderCaddyfile({ domain, acmeEmail });
59
+ const { mode, reason } = escalationMode();
60
+ if (mode === 'blocked') {
61
+ printManualPath(caddyfile, reason);
62
+ p.outro(pc.yellow('Nothing was changed.'));
63
+ return;
64
+ }
65
+
66
+ const existing = readExistingCaddyfile();
67
+ if (existing && existing.trim() !== '' && !existing.includes('reverse_proxy 127.0.0.1')) {
68
+ // Someone else's config. Overwriting it could take an unrelated site
69
+ // offline, and this command has no way to merge two Caddyfiles.
70
+ p.log.warn(`${CADDYFILE_PATH} already exists and doesn't look like a Selva config.`);
71
+ const overwrite = await p.confirm({
72
+ message: `Overwrite ${CADDYFILE_PATH}? The current file is backed up first.`,
73
+ initialValue: false
74
+ });
75
+ if (p.isCancel(overwrite) || !overwrite) {
76
+ printManualPath(caddyfile, null);
77
+ p.outro(pc.yellow('Nothing was changed.'));
78
+ return;
79
+ }
80
+ }
81
+
82
+ const proceed = await p.confirm({
83
+ message: `Write ${CADDYFILE_PATH} for ${domain} and reload Caddy? (needs sudo)`,
84
+ initialValue: true
85
+ });
86
+ if (p.isCancel(proceed) || !proceed) {
87
+ p.outro(pc.yellow('Nothing was changed.'));
88
+ return;
89
+ }
90
+
91
+ if (!ensureCaddyInstalled()) {
92
+ p.outro(pc.red('Caddy is not installed and could not be installed automatically.'));
93
+ return;
94
+ }
95
+
96
+ const s = p.spinner();
97
+ s.start('Writing Caddyfile');
98
+ const staged = stageCaddyfile(caddyfile);
99
+ if (existing) runPrivileged('cp', [CADDYFILE_PATH, `${CADDYFILE_PATH}.bak`]);
100
+ const installed = runPrivileged('cp', [staged, CADDYFILE_PATH]);
101
+ if (!installed.ok) {
102
+ s.stop('Could not write the Caddyfile');
103
+ p.log.error(installed.blocked ? escalationHint(installed.reason) : installed.stderr);
104
+ return;
105
+ }
106
+ s.stop('Caddyfile written');
107
+
108
+ // Validate before reload: a bad config leaves the running Caddy untouched,
109
+ // so catching it here means the site never goes down.
110
+ const valid = runPrivileged('caddy', ['validate', '--config', CADDYFILE_PATH]);
111
+ if (!valid.ok) {
112
+ p.log.error('caddy validate rejected the generated config — reverting.');
113
+ if (existing) runPrivileged('cp', [`${CADDYFILE_PATH}.bak`, CADDYFILE_PATH]);
114
+ p.outro(pc.red('Proxy not configured.'));
115
+ return;
116
+ }
117
+
118
+ // The Debian package leaves /var/log/caddy root-owned while the service runs
119
+ // as `caddy`; without this the service fails to start on the log directive.
120
+ runPrivileged('mkdir', ['-p', '/var/log/caddy']);
121
+ runPrivileged('chown', ['-R', 'caddy:caddy', '/var/log/caddy']);
122
+
123
+ runPrivileged('systemctl', ['enable', 'caddy']);
124
+ const restarted = runPrivileged('systemctl', ['restart', 'caddy']);
125
+ if (!restarted.ok) {
126
+ p.log.error('caddy failed to restart — check `sudo journalctl -u caddy -n 50`.');
127
+ p.outro(pc.red('Proxy configured but not running.'));
128
+ return;
129
+ }
130
+
131
+ p.outro(
132
+ [
133
+ pc.green(`Caddy is serving ${domain}.`),
134
+ pc.dim('A certificate is requested on first request; it needs ') +
135
+ pc.cyan(`${domain} → this host`) +
136
+ pc.dim(' in DNS, and ports 80 + 443 open.'),
137
+ pc.dim('Check it with: ') + pc.cyan(`curl -sI ${expectedOrigin}/api/health`)
138
+ ].join('\n')
139
+ );
140
+ }
141
+
142
+ async function resolveDomain(argv, env) {
143
+ const flag = readFlag(argv, '--domain');
144
+ if (flag) {
145
+ if (isServableDomain(flag)) return flag;
146
+ p.log.error(`--domain ${flag} is not a valid domain name.`);
147
+ return null;
148
+ }
149
+
150
+ // ORIGIN is the domain the operator already committed to at scaffold time.
151
+ const fromOrigin = env.ORIGIN?.replace(/^https?:\/\//, '').replace(/\/.*$/, '');
152
+ const answer = await p.text({
153
+ message: 'Domain to serve (must already point at this host)',
154
+ placeholder: 'app.example.dev',
155
+ initialValue: fromOrigin && isServableDomain(fromOrigin) ? fromOrigin : '',
156
+ validate: (v) =>
157
+ isServableDomain(v) ? undefined : 'Enter a domain like app.example.dev (no scheme, no port).'
158
+ });
159
+ if (p.isCancel(answer)) {
160
+ p.outro(pc.yellow('Cancelled.'));
161
+ return null;
162
+ }
163
+ return answer.trim();
164
+ }
165
+
166
+ async function resolveAcmeEmail(argv, env, domain) {
167
+ const flag = readFlag(argv, '--acme-email');
168
+ if (flag) return flag;
169
+
170
+ const answer = await p.text({
171
+ message: "Email for Let's Encrypt (expiry notices)",
172
+ placeholder: `admin@${domain}`,
173
+ initialValue: env.BOOTSTRAP_INSTANCE_ADMIN_EMAIL ?? '',
174
+ validate: (v) => (v.includes('@') ? undefined : 'Enter an email address.')
175
+ });
176
+ if (p.isCancel(answer)) {
177
+ p.outro(pc.yellow('Cancelled.'));
178
+ return null;
179
+ }
180
+ return answer.trim() || `admin@${domain}`;
181
+ }
182
+
183
+ function readFlag(argv, name) {
184
+ const i = argv.indexOf(name);
185
+ if (i !== -1 && argv[i + 1] && !argv[i + 1].startsWith('--')) return argv[i + 1].trim();
186
+ const inline = argv.find((a) => a.startsWith(`${name}=`));
187
+ return inline ? inline.slice(name.length + 1).trim() : null;
188
+ }
189
+
190
+ function readExistingCaddyfile() {
191
+ try {
192
+ return readFileSync(CADDYFILE_PATH, 'utf8');
193
+ } catch {
194
+ // Unreadable is the same as absent here: either way there's nothing to
195
+ // preserve, and the write is privileged regardless.
196
+ return null;
197
+ }
198
+ }
199
+
200
+ // Written to a temp file first because the shell redirect that would otherwise
201
+ // place it (`echo … | sudo tee`) needs a shell, and passing config text through
202
+ // one is how quoting bugs corrupt a file that then fails to validate.
203
+ function stageCaddyfile(text) {
204
+ const staged = join(mkdtempSync(join(tmpdir(), 'selva-caddy-')), 'Caddyfile');
205
+ writeFileSync(staged, text, 'utf8');
206
+ return staged;
207
+ }
208
+
209
+ function ensureCaddyInstalled() {
210
+ if (spawnSync('caddy', ['version'], { encoding: 'utf8' }).status === 0) return true;
211
+
212
+ p.log.info('Caddy is not installed. Installing from the official apt repository.');
213
+ const steps = [
214
+ [
215
+ 'apt-get',
216
+ ['install', '-y', 'debian-keyring', 'debian-archive-keyring', 'apt-transport-https', 'curl']
217
+ ],
218
+ ['apt-get', ['update']],
219
+ ['apt-get', ['install', '-y', 'caddy']]
220
+ ];
221
+ // The keyring + source-list dance needs a shell pipeline, so it runs as one
222
+ // privileged `sh -c` rather than as spawn args.
223
+ const repo =
224
+ "curl -1sLf 'https://dl.cloudsmith.io/public/caddy/stable/gpg.key' | gpg --dearmor -o /usr/share/keyrings/caddy-stable-archive-keyring.gpg && " +
225
+ "curl -1sLf 'https://dl.cloudsmith.io/public/caddy/stable/debian.deb.txt' > /etc/apt/sources.list.d/caddy-stable.list";
226
+
227
+ const s = p.spinner();
228
+ s.start('Installing Caddy');
229
+ if (!runPrivileged(steps[0][0], steps[0][1]).ok) {
230
+ s.stop('Could not install Caddy prerequisites');
231
+ return false;
232
+ }
233
+ if (!runPrivileged('sh', ['-c', repo]).ok) {
234
+ s.stop('Could not add the Caddy apt repository');
235
+ return false;
236
+ }
237
+ for (const [cmd, args] of steps.slice(1)) {
238
+ if (!runPrivileged(cmd, args).ok) {
239
+ s.stop(`\`${cmd} ${args.join(' ')}\` failed`);
240
+ return false;
241
+ }
242
+ }
243
+ s.stop('Caddy installed');
244
+ return true;
245
+ }
246
+
247
+ function printManualPath(caddyfile, reason) {
248
+ if (reason) p.log.warn(`Cannot escalate — ${escalationHint(reason)}.`);
249
+ p.log.info(
250
+ `Write this to ${CADDYFILE_PATH} yourself, then ` +
251
+ `\`sudo caddy validate --config ${CADDYFILE_PATH} && sudo systemctl restart caddy\`:`
252
+ );
253
+ console.log('\n' + caddyfile);
254
+ }
@@ -136,16 +136,16 @@ export const OVERRIDES = {
136
136
  };
137
137
 
138
138
  /**
139
- * Legacy `@selvajs/*` packages that a deployment may still list. Each was
140
- * either renamed or folded into `@selvajs/selva`; `migrate` drops them and
141
- * `doctor` reports them as layout drift.
139
+ * Packages folded into `@selvajs/selva` that a deployment may still list.
140
+ * `migrate` drops them and `doctor` reports them as layout drift.
141
+ *
142
+ * Only ones still on the registry are listed: an unpublished package fails
143
+ * `npm install` outright, so a deployment carrying one never reaches a state
144
+ * where this could report it — npm's own error is the diagnosis there.
142
145
  */
143
146
  export const LEGACY_DEPENDENCIES = {
144
- '@selvajs/runtime': 'the old runtime package',
145
- '@selvajs/create': 'the old CLI package',
146
147
  '@selvajs/platform': 'now bundled into @selvajs/selva',
147
- '@selvajs/local-provider': 'now bundled into @selvajs/selva',
148
- '@selvajs/header-auth-provider': 'now bundled into @selvajs/selva'
148
+ '@selvajs/local-provider': 'now bundled into @selvajs/selva'
149
149
  };
150
150
 
151
151
  /**
package/src/env.js CHANGED
@@ -180,50 +180,6 @@ export function readEnvFile(path) {
180
180
  return parseEnv(readFileSync(path, 'utf8'));
181
181
  }
182
182
 
183
- export function mergeEnv(template, values) {
184
- const seen = new Set();
185
- const lines = template.split(/\r?\n/);
186
- const out = [];
187
-
188
- for (const line of lines) {
189
- const stripped = line.trim();
190
- if (!stripped || stripped.startsWith('#')) {
191
- out.push(line);
192
- continue;
193
- }
194
- const eq = stripped.indexOf('=');
195
- if (eq === -1) {
196
- out.push(line);
197
- continue;
198
- }
199
- const key = stripped.slice(0, eq).trim();
200
- if (Object.prototype.hasOwnProperty.call(values, key)) {
201
- out.push(`${key}=${quoteIfNeeded(values[key])}`);
202
- seen.add(key);
203
- } else {
204
- out.push(line);
205
- }
206
- }
207
-
208
- const appended = [];
209
- for (const [key, value] of Object.entries(values)) {
210
- if (seen.has(key)) continue;
211
- if (value === undefined || value === null || value === '') continue;
212
- appended.push(`${key}=${quoteIfNeeded(value)}`);
213
- }
214
-
215
- if (appended.length > 0) {
216
- if (out.length > 0 && out[out.length - 1].trim() !== '') out.push('');
217
- out.push('# ============================================================================');
218
- out.push('# Additional values written by the Selva CLI');
219
- out.push('# ============================================================================');
220
- out.push(...appended);
221
- }
222
-
223
- while (out.length > 0 && out[out.length - 1] === '') out.pop();
224
- return out.join('\n') + '\n';
225
- }
226
-
227
183
  /**
228
184
  * Render a values-only `.env` — the scaffold's default shape.
229
185
  *
@@ -265,14 +221,9 @@ export function renderEnvValues(template, values, { header = ENV_HEADER } = {})
265
221
  return header ? `${header}\n\n${lines.join('\n')}\n` : `${lines.join('\n')}\n`;
266
222
  }
267
223
 
268
- /**
269
- * Writes the deployment `.env`. Values-only by default — pass
270
- * `{ annotated: true }` to merge into the template's comments instead, which
271
- * only an operator who explicitly wants the old shape should do.
272
- */
273
- export function writeEnvFile(path, template, values, { annotated = false } = {}) {
274
- const text = annotated ? mergeEnv(template, values) : renderEnvValues(template, values);
275
- writeFileSync(path, text, 'utf8');
224
+ /** Writes the deployment `.env` — values only, in the template's key order. */
225
+ export function writeEnvFile(path, template, values) {
226
+ writeFileSync(path, renderEnvValues(template, values), 'utf8');
276
227
  }
277
228
 
278
229
  /**
package/src/paths.js CHANGED
@@ -21,24 +21,6 @@ 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
24
  /**
43
25
  * A scaffolding template shipped inside the installed runtime. The CLI cannot
44
26
  * import from `@selvajs/selva` — it is what installs it — so it reads the files
@@ -53,9 +35,10 @@ export function runtimeTemplatePath(dir, name) {
53
35
  * when installed, otherwise the deployment's own `.env` so its comments and
54
36
  * ordering survive a rewrite.
55
37
  *
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.
38
+ * Returns `''` when neither exists rather than throwing — `renderEnvValues`
39
+ * writes every value it was given even with no template to order them by, so
40
+ * the operator gets a usable file instead of an ENOENT from a command that was
41
+ * only asked to change one key.
59
42
  */
60
43
  export function readEnvTemplate(dir) {
61
44
  const template = runtimeTemplatePath(dir, '.env.example');
package/src/prompts.js CHANGED
@@ -48,7 +48,9 @@ const FLAG_NAMES = FLAG_OPTIONS.map((o) => o.value);
48
48
  * `single`, where one org makes both settings describe the same set.
49
49
  */
50
50
  export function promptableFlags(tenancy) {
51
- return FLAG_OPTIONS.filter((o) => o.promptable !== false && (tenancy === 'multi' || !o.multiOnly));
51
+ return FLAG_OPTIONS.filter(
52
+ (o) => o.promptable !== false && (tenancy === 'multi' || !o.multiOnly)
53
+ );
52
54
  }
53
55
 
54
56
  function envBool(v) {