@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 +1 -1
- package/package.json +1 -1
- package/src/caddyfile.js +89 -0
- package/src/checks/boot.js +73 -3
- package/src/checks/privileged.js +92 -0
- package/src/cli.js +11 -0
- package/src/commands/create.js +84 -38
- package/src/commands/doctor.js +3 -2
- package/src/commands/migrate.js +29 -52
- package/src/commands/proxy.js +254 -0
- package/src/deployment-package.js +56 -14
- package/src/env.js +3 -52
- package/src/paths.js +4 -21
- package/src/prompts.js +65 -38
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.
|
|
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
package/src/caddyfile.js
ADDED
|
@@ -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
|
+
}
|
package/src/checks/boot.js
CHANGED
|
@@ -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.'),
|
package/src/commands/create.js
CHANGED
|
@@ -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
|
|
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
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
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
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
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
|
});
|
package/src/commands/doctor.js
CHANGED
|
@@ -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
|
|
233
|
-
'@selvajs/supabase-provider
|
|
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
|
|
package/src/commands/migrate.js
CHANGED
|
@@ -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
|
|
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
|
-
|
|
343
|
-
|
|
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
|
-
/**
|
|
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
|
-
*
|
|
112
|
-
*
|
|
113
|
-
*
|
|
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({
|
|
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:
|
|
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
|
-
|
|
270
|
-
|
|
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 — `
|
|
57
|
-
* every value
|
|
58
|
-
* of an ENOENT from a command that was
|
|
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
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
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
|
-
|
|
479
|
-
|
|
480
|
-
|
|
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;
|