@selvajs/cli 4.8.1 → 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.1",
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,14 +9,10 @@ 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,
15
+ needsSupabaseProvider,
20
16
  npmDistTagVersion,
21
17
  resolveSelvaPins
22
18
  } from '../deployment-package.js';
@@ -65,12 +61,17 @@ export async function runCreate(argv) {
65
61
  // `"latest"` re-resolves on every later `npm install`, so the deployment
66
62
  // would follow the tag instead of the version it was scaffolded against —
67
63
  // and `selva doctor` reports a floating pin as drift.
68
- const { pins } = resolveSelvaPins({}, npmDistTagVersion);
64
+ const supabase = needsSupabaseProvider(values);
65
+ const { pins } = resolveSelvaPins(
66
+ {},
67
+ npmDistTagVersion,
68
+ supabase ? ['@selvajs/supabase-provider'] : []
69
+ );
69
70
 
70
71
  // package.json first: npm install needs it, and the runtime templates below
71
72
  // only exist once @selvajs/selva is installed into node_modules.
72
73
  const pkgJson = JSON.stringify(
73
- buildDeploymentPackageJson({ name: deployName, dependencies: pins }),
74
+ buildDeploymentPackageJson({ name: deployName, dependencies: pins, supabase }),
74
75
  null,
75
76
  2
76
77
  );
@@ -91,7 +92,6 @@ export async function runCreate(argv) {
91
92
  `Couldn't read runtime templates from ${dirname(envTemplatePath)}. ` +
92
93
  `Run \`npm install\`, then \`selva init\` to finish setup.`
93
94
  );
94
- writeFileSync(join(targetDir, '.selva-version'), scaffoldVersion() + '\n', 'utf8');
95
95
  p.outro(`Partial scaffold at ${pc.cyan(targetDir)}.`);
96
96
  return;
97
97
  }
@@ -100,23 +100,71 @@ export async function runCreate(argv) {
100
100
 
101
101
  cpSync(ecosystemTemplatePath, join(targetDir, 'ecosystem.config.cjs'));
102
102
 
103
- writeFileSync(join(targetDir, '.selva-version'), scaffoldVersion() + '\n', 'utf8');
104
103
  writeGitignore(targetDir);
105
104
 
106
- p.outro(
107
- [
108
- pc.green('Scaffolded ' + pc.cyan(targetDir)),
109
- '',
110
- pc.bold('Next steps:'),
111
- ` cd ${rawDir}`,
112
- ` npm run doctor # sanity-check the install`,
113
- ` npm start # pm2 start ecosystem.config.cjs`,
114
- '',
115
- values.ORIGIN
116
- ? `Then visit ${pc.cyan(values.ORIGIN)} (set up your reverse proxy first).`
117
- : `Then visit ${pc.cyan('http://localhost:3000')}.`
118
- ].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')}`
119
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.')
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
+ }
120
168
  }
121
169
 
122
170
  function runNpmInstall(cwd) {
@@ -152,7 +200,6 @@ function runNpmInstall(cwd) {
152
200
  shell: process.platform === 'win32'
153
201
  });
154
202
 
155
- let sawBrokenVersion = false;
156
203
  const handleStream = (stream) => {
157
204
  let buf = '';
158
205
  stream.setEncoding('utf8');
@@ -163,7 +210,6 @@ function runNpmInstall(cwd) {
163
210
  for (const line of lines) {
164
211
  remember(line);
165
212
  updateProgress(line);
166
- if (line.includes('@selvajs/selva@0.10.2')) sawBrokenVersion = true;
167
213
  }
168
214
  });
169
215
  stream.on('end', () => {
@@ -196,19 +242,19 @@ function runNpmInstall(cwd) {
196
242
  for (const line of tail) console.error(line);
197
243
  console.error(pc.dim('──────────────────────────────'));
198
244
 
199
- if (sawBrokenVersion) {
200
- console.error('');
201
- console.error(
202
- pc.yellow(
203
- 'npm resolved @selvajs/selva@0.10.2 — that version is broken (unresolved\n' +
204
- 'workspace:* / catalog: specs) and has been unpublished. Your local npm\n' +
205
- 'cache is stale. Clear it and retry:\n\n' +
206
- ' npm cache clean --force\n' +
207
- ' rm -rf node_modules package-lock.json\n' +
208
- ' npm install --prefer-online'
209
- )
210
- );
211
- }
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
+ );
212
258
 
213
259
  rejectP(new Error(`npm install exited with code ${code}`));
214
260
  });
@@ -229,8 +229,9 @@ async function checkSupabaseMigrations(dir, env) {
229
229
  // Provider package not installed in this deployment.
230
230
  }
231
231
  if (!expected) {
232
- return yellow(
233
- '@selvajs/supabase-provider migrations not found — skipping migration-head check'
232
+ return red(
233
+ '@selvajs/supabase-provider is not installed, so the migration SQL is missing. ' +
234
+ 'Run `selva migrate` to add it, then `npm install`.'
234
235
  );
235
236
  }
236
237
 
@@ -15,6 +15,7 @@ import {
15
15
  DEPENDENCIES,
16
16
  isFloatingPin,
17
17
  LEGACY_DEPENDENCIES,
18
+ needsSupabaseProvider,
18
19
  npmDistTagVersion,
19
20
  OVERRIDES,
20
21
  resolveSelvaPins,
@@ -122,16 +123,14 @@ export async function runMigrate(
122
123
  }
123
124
 
124
125
  const before = JSON.parse(readFileSync(pkgPath, 'utf8'));
125
- const { pkg: target, notes: pinNotes } = buildTargetPackageJson(before, resolveVersion);
126
+ const deploymentEnv = readDeploymentEnv(join(dir, '.env'));
127
+ const { pkg: target, notes: pinNotes } = buildTargetPackageJson(
128
+ before,
129
+ resolveVersion,
130
+ deploymentEnv
131
+ );
126
132
  const pkgDiff = diffPackageJson(before, target);
127
133
 
128
- const configPath = join(dir, 'selva.config.js');
129
- const hasStaleConfig = existsSync(configPath);
130
-
131
- const ecoPath = join(dir, 'ecosystem.config.cjs');
132
- const ecoHasStaleRuntime =
133
- existsSync(ecoPath) && readFileSync(ecoPath, 'utf8').includes('@selvajs/runtime');
134
-
135
134
  // The server drops its read-old-name shim after one minor version, so
136
135
  // rewriting now is what keeps a tuned value tuned instead of silently
137
136
  // reverting to defaults later.
@@ -142,14 +141,6 @@ export async function runMigrate(
142
141
  : { text: '', changes: [] };
143
142
 
144
143
  const sideFileChanges = [];
145
- if (hasStaleConfig)
146
- sideFileChanges.push(
147
- `${pc.red('-')} selva.config.js ${pc.dim('(no longer needed; providers are env-driven)')}`
148
- );
149
- if (ecoHasStaleRuntime)
150
- sideFileChanges.push(
151
- `${pc.yellow('~')} ecosystem.config.cjs ${pc.dim('(rewrite: @selvajs/runtime → @selvajs/selva)')}`
152
- );
153
144
  for (const [oldName, newName, action] of envRename.changes) {
154
145
  sideFileChanges.push(
155
146
  action === 'dropped'
@@ -207,28 +198,11 @@ export async function runMigrate(
207
198
  // later migration cannot overwrite this one's (#184).
208
199
  const stamp = backupStamp();
209
200
  const bakPath = backupPathFor(pkgPath, stamp);
210
- const configBak = backupPathFor(configPath, stamp);
211
- const ecoBak = backupPathFor(ecoPath, stamp);
212
201
  const envBak = backupPathFor(envPath, stamp);
213
202
 
214
203
  copyFileSync(pkgPath, bakPath);
215
204
  writeFileSync(pkgPath, JSON.stringify(target, null, 2) + '\n', 'utf8');
216
205
 
217
- if (hasStaleConfig) {
218
- copyFileSync(configPath, configBak);
219
- rmSync(configPath, { force: true });
220
- }
221
-
222
- if (ecoHasStaleRuntime) {
223
- copyFileSync(ecoPath, ecoBak);
224
- // Text substitution, not regeneration, so any operator customizations to the file survive.
225
- const ecoContent = readFileSync(ecoPath, 'utf8').replace(
226
- /@selvajs\/runtime/g,
227
- '@selvajs/selva'
228
- );
229
- writeFileSync(ecoPath, ecoContent, 'utf8');
230
- }
231
-
232
206
  if (envRename.changes.length > 0) {
233
207
  copyFileSync(envPath, envBak);
234
208
  writeFileSync(envPath, envRename.text, 'utf8');
@@ -257,12 +231,6 @@ export async function runMigrate(
257
231
  } catch (err) {
258
232
  s.stop(pc.red('npm install failed — rolling back'));
259
233
  copyFileSync(bakPath, pkgPath);
260
- if (hasStaleConfig && existsSync(configBak)) {
261
- copyFileSync(configBak, configPath);
262
- }
263
- if (ecoHasStaleRuntime && existsSync(ecoBak)) {
264
- copyFileSync(ecoBak, ecoPath);
265
- }
266
234
  if (envRename.changes.length > 0 && existsSync(envBak)) {
267
235
  copyFileSync(envBak, envPath);
268
236
  }
@@ -300,8 +268,6 @@ export async function runMigrate(
300
268
  if (pm2Notice) p.log.warn(pm2Notice);
301
269
 
302
270
  const backupHints = [`package.json.${stamp}.bak`];
303
- if (hasStaleConfig) backupHints.push(`selva.config.js.${stamp}.bak`);
304
- if (ecoHasStaleRuntime) backupHints.push(`ecosystem.config.cjs.${stamp}.bak`);
305
271
  if (envRename.changes.length > 0) backupHints.push(`.env.${stamp}.bak`);
306
272
 
307
273
  if (status === 0) {
@@ -339,13 +305,32 @@ function restartAfterRollback(dir, pm2 = runPm2) {
339
305
  }
340
306
  }
341
307
 
342
- function buildTargetPackageJson(current, resolveVersion = npmDistTagVersion) {
343
- const { pins, notes } = resolveSelvaPins(current.dependencies, resolveVersion);
308
+ // Only the provider keys matter here, so this reads `KEY=value` lines rather
309
+ // than pulling in a dotenv parser: no quoting, expansion, or multi-line values
310
+ // to honour.
311
+ function readDeploymentEnv(envPath) {
312
+ if (!existsSync(envPath)) return {};
313
+ const env = {};
314
+ for (const line of readFileSync(envPath, 'utf8').split(/\r?\n/)) {
315
+ const match = /^\s*([A-Z0-9_]+)\s*=\s*(.*)$/.exec(line);
316
+ if (match) env[match[1]] = match[2].trim().replace(/^["']|["']$/g, '');
317
+ }
318
+ return env;
319
+ }
320
+
321
+ function buildTargetPackageJson(current, resolveVersion = npmDistTagVersion, env = {}) {
322
+ const supabase = needsSupabaseProvider(env);
323
+ const { pins, notes } = resolveSelvaPins(
324
+ current.dependencies,
325
+ resolveVersion,
326
+ supabase ? ['@selvajs/supabase-provider'] : []
327
+ );
344
328
  const pkg = buildDeploymentPackageJson({
345
329
  name: current.name ?? 'selva-deployment',
346
330
  version: current.version ?? '0.1.0',
347
331
  engines: current.engines,
348
- dependencies: pins
332
+ dependencies: pins,
333
+ supabase
349
334
  });
350
335
  return { pkg, notes };
351
336
  }
@@ -455,14 +440,6 @@ export function detectDrift(pkgJson, dir) {
455
440
  }
456
441
 
457
442
  if (dir) {
458
- const configPath = join(dir, 'selva.config.js');
459
- if (existsSync(configPath)) {
460
- reasons.push('selva.config.js is no longer needed (providers are env-driven)');
461
- }
462
- const ecoPath = join(dir, 'ecosystem.config.cjs');
463
- if (existsSync(ecoPath) && readFileSync(ecoPath, 'utf8').includes('@selvajs/runtime')) {
464
- reasons.push('ecosystem.config.cjs still references @selvajs/runtime');
465
- }
466
443
  const envPath = join(dir, '.env');
467
444
  if (existsSync(envPath)) {
468
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
+ }
@@ -64,7 +64,35 @@ const DEPENDENCIES = {
64
64
  pm2: PM2_VERSION
65
65
  };
66
66
 
67
- /** The `@selvajs/*` packages whose pin belongs to the operator, not the scaffold. */
67
+ /**
68
+ * Installed on top of `DEPENDENCIES` when a Supabase provider is selected.
69
+ *
70
+ * The provider's *code* is bundled into `@selvajs/selva`, so the app runs
71
+ * without this. Its migration SQL is not: `@selvajs/selva` publishes only
72
+ * `build` + `templates`, while the SQL and the `selva-supabase` bin ship in
73
+ * this package's own tarball. Without it, a deployment has no way to reach the
74
+ * schema it needs — `npx selva-supabase sync-migrations` resolves nothing and
75
+ * `doctor`'s migration-head check finds no directory to compare against.
76
+ */
77
+ export const SUPABASE_DEPENDENCIES = {
78
+ '@selvajs/supabase-provider': 'latest'
79
+ };
80
+
81
+ /** True when any provider slot is set to supabase. */
82
+ export function needsSupabaseProvider(env) {
83
+ return ['SELVA_AUTH_PROVIDER', 'SELVA_DATA_PROVIDER', 'SELVA_STORAGE_PROVIDER'].some(
84
+ (key) => env?.[key] === 'supabase'
85
+ );
86
+ }
87
+
88
+ /**
89
+ * The `@selvajs/*` packages whose pin belongs to the operator, not the scaffold.
90
+ *
91
+ * `@selvajs/supabase-provider` is deliberately absent: it's conditional, and
92
+ * `resolveSelvaPins` runs before anything knows which providers a deployment
93
+ * uses. Its pin is resolved alongside these only when it's actually installed
94
+ * (see `resolveSelvaPins`' `extra` argument).
95
+ */
68
96
  export const SELVA_PACKAGES = ['@selvajs/cli', '@selvajs/selva'];
69
97
 
70
98
  /**
@@ -108,17 +136,16 @@ export const OVERRIDES = {
108
136
  };
109
137
 
110
138
  /**
111
- * Legacy `@selvajs/*` packages that a deployment may still list. Each was
112
- * either renamed or folded into `@selvajs/selva`; `migrate` drops them and
113
- * `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.
114
145
  */
115
146
  export const LEGACY_DEPENDENCIES = {
116
- '@selvajs/runtime': 'the old runtime package',
117
- '@selvajs/create': 'the old CLI package',
118
147
  '@selvajs/platform': 'now bundled into @selvajs/selva',
119
- '@selvajs/local-provider': 'now bundled into @selvajs/selva',
120
- '@selvajs/supabase-provider': 'now bundled into @selvajs/selva',
121
- '@selvajs/header-auth-provider': 'now bundled into @selvajs/selva'
148
+ '@selvajs/local-provider': 'now bundled into @selvajs/selva'
122
149
  };
123
150
 
124
151
  /**
@@ -139,14 +166,29 @@ export const LEGACY_DEPENDENCIES = {
139
166
  * an operator choice too. `migrate` passes the resolved `@selvajs/*` pins in
140
167
  * here; a fresh scaffold passes none and inherits the defaults.
141
168
  */
142
- export function buildDeploymentPackageJson({ name, version = '0.1.0', engines, dependencies }) {
169
+ export function buildDeploymentPackageJson({
170
+ name,
171
+ version = '0.1.0',
172
+ engines,
173
+ dependencies,
174
+ supabase = false
175
+ }) {
176
+ const base = supabase ? { ...DEPENDENCIES, ...SUPABASE_DEPENDENCIES } : { ...DEPENDENCIES };
177
+ // A caller-supplied pin wins, but only for a package the deployment actually
178
+ // carries: an operator's stale supabase pin must not reinstate the dependency
179
+ // on a deployment that has since moved off supabase.
180
+ const resolved = { ...base };
181
+ for (const [pkgName, pin] of Object.entries(dependencies ?? {})) {
182
+ if (pkgName in base) resolved[pkgName] = pin;
183
+ }
184
+
143
185
  const pkg = {
144
186
  name: sanitizePackageName(name),
145
187
  version,
146
188
  private: true,
147
189
  type: 'module',
148
190
  scripts: { ...SCRIPTS },
149
- dependencies: { ...DEPENDENCIES, ...dependencies },
191
+ dependencies: resolved,
150
192
  overrides: { ...OVERRIDES }
151
193
  };
152
194
  if (engines && typeof engines === 'object') pkg.engines = { ...engines };
@@ -186,11 +228,11 @@ export function npmDistTagVersion(name, tag) {
186
228
  * so migrate can report it rather than inventing a version or aborting a
187
229
  * migration whose other half (layout, overrides, env renames) is still valid.
188
230
  */
189
- export function resolveSelvaPins(currentDeps, resolveVersion) {
231
+ export function resolveSelvaPins(currentDeps, resolveVersion, extra = []) {
190
232
  const pins = {};
191
233
  const notes = [];
192
234
 
193
- for (const name of SELVA_PACKAGES) {
235
+ for (const name of [...SELVA_PACKAGES, ...extra]) {
194
236
  const current = currentDeps?.[name];
195
237
 
196
238
  if (isPrereleasePin(current)) {
@@ -214,7 +256,7 @@ export function resolveSelvaPins(currentDeps, resolveVersion) {
214
256
  // Nothing resolvable and nothing worth keeping — fall back to the tag so
215
257
  // the install still succeeds. Rare: offline *and* a floating pin.
216
258
  if (isFloatingPin(current)) {
217
- pins[name] = DEPENDENCIES[name];
259
+ pins[name] = DEPENDENCIES[name] ?? SUPABASE_DEPENDENCIES[name];
218
260
  if (!notes.some((n) => n.name === name)) {
219
261
  notes.push({ name, kind: 'unresolved', pin: current, reason: 'no version returned' });
220
262
  }
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
@@ -5,6 +5,54 @@ import pc from 'picocolors';
5
5
 
6
6
  const TRUTHY = new Set(['true', '1', 'yes']);
7
7
 
8
+ // Mirrors FLAG_KEYS in @selvajs/server's create-selva-providers.ts. Every key
9
+ // here is written to .env; `promptable: false` only hides it from the wizard.
10
+ const FLAG_OPTIONS = [
11
+ {
12
+ value: 'ALLOW_ORG_CREATION',
13
+ label: 'Allow non-admin users to create their own orgs',
14
+ hint: 'multi-tenant self-service',
15
+ multiOnly: true,
16
+ // No route consults this flag yet — self-service org creation has not
17
+ // shipped. Offering it would promise behaviour nothing implements.
18
+ promptable: false
19
+ },
20
+ {
21
+ value: 'ALLOW_CROSS_ORG_PUBLIC',
22
+ label: 'Public projects visible across all orgs',
23
+ hint: 'instance-wide discovery',
24
+ multiOnly: true
25
+ },
26
+ {
27
+ value: 'ALLOW_ORG_COMPUTE_OVERRIDE',
28
+ label: 'Orgs can configure their own Rhino.Compute server',
29
+ hint: 'BYO compute',
30
+ multiOnly: true
31
+ },
32
+ {
33
+ value: 'ENABLE_PLATFORM_PROJECTS',
34
+ label: 'Platform projects (admin-owned, granted to orgs/users)',
35
+ hint: 'cross-org sharing without membership'
36
+ },
37
+ {
38
+ value: 'ENABLE_SHARING',
39
+ label: 'Per-definition share links (anonymous external access)',
40
+ hint: 'tokenized URLs'
41
+ }
42
+ ];
43
+
44
+ const FLAG_NAMES = FLAG_OPTIONS.map((o) => o.value);
45
+
46
+ /**
47
+ * Flags worth offering for a given tenancy. Cross-org flags are hidden under
48
+ * `single`, where one org makes both settings describe the same set.
49
+ */
50
+ export function promptableFlags(tenancy) {
51
+ return FLAG_OPTIONS.filter(
52
+ (o) => o.promptable !== false && (tenancy === 'multi' || !o.multiOnly)
53
+ );
54
+ }
55
+
8
56
  function envBool(v) {
9
57
  if (v === undefined) return false;
10
58
  return TRUTHY.has(String(v).toLowerCase());
@@ -95,14 +143,10 @@ export function collectConfigFromEnv(env = process.env) {
95
143
  }
96
144
  values.ORIGIN = origin;
97
145
 
98
- const flagNames = [
99
- 'ALLOW_ORG_CREATION',
100
- 'ALLOW_CROSS_ORG_PUBLIC',
101
- 'ALLOW_ORG_COMPUTE_OVERRIDE',
102
- 'ENABLE_PLATFORM_PROJECTS',
103
- 'ENABLE_SHARING'
104
- ];
105
- for (const f of flagNames) {
146
+ // An explicitly-set flag is preserved verbatim even when tenancy makes it
147
+ // inert: rewriting it to '' would hide the operator's misconfiguration
148
+ // rather than fix it, and the server already reads every key as opt-in.
149
+ for (const f of FLAG_NAMES) {
106
150
  const key = `SELVA_FLAG_${f}`;
107
151
  values[key] = envBool(env[key]) ? 'true' : '';
108
152
  }
@@ -425,33 +469,7 @@ export async function collectConfig({ defaults = {}, mode = 'create' } = {}) {
425
469
  }
426
470
  }
427
471
 
428
- const flagOptions = [
429
- {
430
- value: 'ALLOW_ORG_CREATION',
431
- label: 'Allow non-admin users to create their own orgs',
432
- hint: 'multi-tenant self-service'
433
- },
434
- {
435
- value: 'ALLOW_CROSS_ORG_PUBLIC',
436
- label: 'Public projects visible across all orgs',
437
- hint: 'instance-wide discovery'
438
- },
439
- {
440
- value: 'ALLOW_ORG_COMPUTE_OVERRIDE',
441
- label: 'Orgs can configure their own Rhino.Compute server',
442
- hint: 'BYO compute'
443
- },
444
- {
445
- value: 'ENABLE_PLATFORM_PROJECTS',
446
- label: 'Platform projects (admin-owned, granted to orgs/users)',
447
- hint: 'cross-org sharing without membership'
448
- },
449
- {
450
- value: 'ENABLE_SHARING',
451
- label: 'Per-definition share links (anonymous external access)',
452
- hint: 'tokenized URLs'
453
- }
454
- ];
472
+ const flagOptions = promptableFlags(tenancy);
455
473
 
456
474
  const flagDefaults = flagOptions
457
475
  .map((o) => o.value)
@@ -475,9 +493,18 @@ export async function collectConfig({ defaults = {}, mode = 'create' } = {}) {
475
493
  ...providerValues
476
494
  };
477
495
 
478
- for (const opt of flagOptions) {
479
- const enabled = Array.isArray(flags) && flags.includes(opt.value);
480
- values[`SELVA_FLAG_${opt.value}`] = enabled ? 'true' : '';
496
+ // Every key is written, including the ones the prompt hid — a missing key
497
+ // reads as "unset" downstream, which is indistinguishable from "removed".
498
+ // A hidden flag keeps whatever the existing .env had; only offered flags
499
+ // are decided by the multiselect, so `init` can't silently clear one.
500
+ const offered = new Set(flagOptions.map((o) => o.value));
501
+ for (const name of FLAG_NAMES) {
502
+ const key = `SELVA_FLAG_${name}`;
503
+ if (!offered.has(name)) {
504
+ values[key] = envBool(defaults[key]) ? 'true' : '';
505
+ continue;
506
+ }
507
+ values[key] = Array.isArray(flags) && flags.includes(name) ? 'true' : '';
481
508
  }
482
509
 
483
510
  return values;