@selvajs/cli 2.0.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/LICENSE +21 -0
- package/README.md +36 -0
- package/bin/create.js +7 -0
- package/bin/selva.js +7 -0
- package/package.json +30 -0
- package/src/cli.js +78 -0
- package/src/commands/create.js +326 -0
- package/src/commands/doctor.js +268 -0
- package/src/commands/init.js +76 -0
- package/src/commands/keys.js +93 -0
- package/src/commands/pm2.js +172 -0
- package/src/env.js +93 -0
- package/src/paths.js +32 -0
- package/src/prompts.js +575 -0
- package/src/secrets.js +7 -0
package/src/env.js
ADDED
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
// Minimal .env parser / serializer.
|
|
2
|
+
//
|
|
3
|
+
// We don't pull in dotenv: the runtime already loads .env via node --env-file,
|
|
4
|
+
// and the CLI's needs are simpler than dotenv supports (no shell expansion, no
|
|
5
|
+
// multiline values). Keeping it in-tree means one less dep to vet.
|
|
6
|
+
|
|
7
|
+
import { readFileSync, writeFileSync, existsSync } from 'node:fs';
|
|
8
|
+
|
|
9
|
+
// Parse a .env file into { key: value }. Preserves nothing else.
|
|
10
|
+
export function parseEnv(text) {
|
|
11
|
+
const out = {};
|
|
12
|
+
for (const rawLine of text.split(/\r?\n/)) {
|
|
13
|
+
const line = rawLine.trim();
|
|
14
|
+
if (!line || line.startsWith('#')) continue;
|
|
15
|
+
const eq = line.indexOf('=');
|
|
16
|
+
if (eq === -1) continue;
|
|
17
|
+
const key = line.slice(0, eq).trim();
|
|
18
|
+
let value = line.slice(eq + 1).trim();
|
|
19
|
+
if (
|
|
20
|
+
(value.startsWith('"') && value.endsWith('"')) ||
|
|
21
|
+
(value.startsWith("'") && value.endsWith("'"))
|
|
22
|
+
) {
|
|
23
|
+
value = value.slice(1, -1);
|
|
24
|
+
}
|
|
25
|
+
out[key] = value;
|
|
26
|
+
}
|
|
27
|
+
return out;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function readEnvFile(path) {
|
|
31
|
+
if (!existsSync(path)) return {};
|
|
32
|
+
return parseEnv(readFileSync(path, 'utf8'));
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// Re-serialize a .env file. Comments + section structure from `template` are
|
|
36
|
+
// preserved; lines whose key appears in `values` get rewritten in-place. Keys
|
|
37
|
+
// in `values` but absent from the template are appended at the end.
|
|
38
|
+
//
|
|
39
|
+
// Lines beginning with `# KEY=...` (commented examples) are treated as
|
|
40
|
+
// suggestions and left alone; the actual value (if any) goes uncommented.
|
|
41
|
+
export function mergeEnv(template, values) {
|
|
42
|
+
const seen = new Set();
|
|
43
|
+
const lines = template.split(/\r?\n/);
|
|
44
|
+
const out = [];
|
|
45
|
+
|
|
46
|
+
for (const line of lines) {
|
|
47
|
+
const stripped = line.trim();
|
|
48
|
+
if (!stripped || stripped.startsWith('#')) {
|
|
49
|
+
out.push(line);
|
|
50
|
+
continue;
|
|
51
|
+
}
|
|
52
|
+
const eq = stripped.indexOf('=');
|
|
53
|
+
if (eq === -1) {
|
|
54
|
+
out.push(line);
|
|
55
|
+
continue;
|
|
56
|
+
}
|
|
57
|
+
const key = stripped.slice(0, eq).trim();
|
|
58
|
+
if (Object.prototype.hasOwnProperty.call(values, key)) {
|
|
59
|
+
out.push(`${key}=${quoteIfNeeded(values[key])}`);
|
|
60
|
+
seen.add(key);
|
|
61
|
+
} else {
|
|
62
|
+
out.push(line);
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const appended = [];
|
|
67
|
+
for (const [key, value] of Object.entries(values)) {
|
|
68
|
+
if (seen.has(key)) continue;
|
|
69
|
+
if (value === undefined || value === null || value === '') continue;
|
|
70
|
+
appended.push(`${key}=${quoteIfNeeded(value)}`);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
if (appended.length > 0) {
|
|
74
|
+
if (out.length > 0 && out[out.length - 1].trim() !== '') out.push('');
|
|
75
|
+
out.push('# ============================================================================');
|
|
76
|
+
out.push('# Additional values written by the Selva CLI');
|
|
77
|
+
out.push('# ============================================================================');
|
|
78
|
+
out.push(...appended);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
return out.join('\n');
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export function writeEnvFile(path, template, values) {
|
|
85
|
+
writeFileSync(path, mergeEnv(template, values), 'utf8');
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function quoteIfNeeded(value) {
|
|
89
|
+
const s = String(value);
|
|
90
|
+
if (s === '') return '""';
|
|
91
|
+
if (/[\s"'#=]/.test(s)) return JSON.stringify(s);
|
|
92
|
+
return s;
|
|
93
|
+
}
|
package/src/paths.js
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { existsSync, readdirSync } from 'node:fs';
|
|
2
|
+
import { resolve, join } from 'node:path';
|
|
3
|
+
|
|
4
|
+
// A deployment directory must contain an .env and an ecosystem.config.cjs.
|
|
5
|
+
// Anything else (selva.config.js, node_modules) is "should be there" but the
|
|
6
|
+
// CLI doesn't require it — `selva init` is allowed to fix a partial install.
|
|
7
|
+
export function isDeploymentDir(dir) {
|
|
8
|
+
return existsSync(join(dir, '.env')) || existsSync(join(dir, 'ecosystem.config.cjs'));
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function requireDeploymentDir(dir) {
|
|
12
|
+
if (!isDeploymentDir(dir)) {
|
|
13
|
+
throw new Error(
|
|
14
|
+
`Not a Selva deployment directory: ${dir}\n` +
|
|
15
|
+
`Expected to find .env or ecosystem.config.cjs here. ` +
|
|
16
|
+
`Run \`npx @selvajs/cli <dir>\` first, or cd into an existing deployment.`
|
|
17
|
+
);
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function resolveDeploymentDir(cwd = process.cwd()) {
|
|
22
|
+
return resolve(cwd);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function isEmptyOrMissing(dir) {
|
|
26
|
+
if (!existsSync(dir)) return true;
|
|
27
|
+
try {
|
|
28
|
+
return readdirSync(dir).length === 0;
|
|
29
|
+
} catch {
|
|
30
|
+
return false;
|
|
31
|
+
}
|
|
32
|
+
}
|
package/src/prompts.js
ADDED
|
@@ -0,0 +1,575 @@
|
|
|
1
|
+
// Shared prompt flow used by both `create` (fresh scaffold) and
|
|
2
|
+
// `selva init` (reconfigure existing install). The two callers differ only in
|
|
3
|
+
// what defaults are passed in and what gets written afterwards.
|
|
4
|
+
|
|
5
|
+
import * as p from '@clack/prompts';
|
|
6
|
+
import pc from 'picocolors';
|
|
7
|
+
|
|
8
|
+
const TRUTHY = new Set(['true', '1', 'yes']);
|
|
9
|
+
|
|
10
|
+
function envBool(v) {
|
|
11
|
+
if (v === undefined) return false;
|
|
12
|
+
return TRUTHY.has(String(v).toLowerCase());
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
// Non-interactive sibling of `collectConfig`. Reads everything from `env`
|
|
16
|
+
// (defaults to process.env) so unattended bootstraps — Terraform startup
|
|
17
|
+
// scripts, CI, Docker entrypoints — never touch a prompt. Same output shape
|
|
18
|
+
// as `collectConfig` so the caller code in create.js doesn't branch further.
|
|
19
|
+
//
|
|
20
|
+
// Validation mirrors collectConfig's prompt-time checks. Anything missing or
|
|
21
|
+
// malformed throws with the offending var name so the boot log makes the
|
|
22
|
+
// fix obvious. We deliberately do NOT fall back to a "safe default" for
|
|
23
|
+
// security-relevant fields (BOOTSTRAP_INSTANCE_ADMIN_EMAIL for header-auth /
|
|
24
|
+
// multi-tenant, SUPABASE_SERVICE_ROLE_KEY when supabase is selected) — a
|
|
25
|
+
// silently-misconfigured deploy is worse than a loud failure.
|
|
26
|
+
export function collectConfigFromEnv(env = process.env) {
|
|
27
|
+
const tenancy = pick(env.SELVA_TENANCY, ['single', 'multi'], 'single', 'SELVA_TENANCY');
|
|
28
|
+
const auth = pick(
|
|
29
|
+
env.SELVA_AUTH_PROVIDER,
|
|
30
|
+
['local', 'supabase', 'header'],
|
|
31
|
+
'local',
|
|
32
|
+
'SELVA_AUTH_PROVIDER'
|
|
33
|
+
);
|
|
34
|
+
// Default data/storage to auth — matches the prompt's "use same provider
|
|
35
|
+
// for all three" default. Header-auth has no data layer, so it falls
|
|
36
|
+
// through to local unless explicitly overridden.
|
|
37
|
+
const dataDefault = auth === 'header' ? 'local' : auth;
|
|
38
|
+
const data = pick(
|
|
39
|
+
env.SELVA_DATA_PROVIDER,
|
|
40
|
+
['local', 'supabase'],
|
|
41
|
+
dataDefault,
|
|
42
|
+
'SELVA_DATA_PROVIDER'
|
|
43
|
+
);
|
|
44
|
+
const storage = pick(
|
|
45
|
+
env.SELVA_STORAGE_PROVIDER,
|
|
46
|
+
['local', 'supabase'],
|
|
47
|
+
dataDefault,
|
|
48
|
+
'SELVA_STORAGE_PROVIDER'
|
|
49
|
+
);
|
|
50
|
+
|
|
51
|
+
const values = {
|
|
52
|
+
SELVA_TENANCY: tenancy,
|
|
53
|
+
SELVA_AUTH_PROVIDER: auth,
|
|
54
|
+
SELVA_DATA_PROVIDER: data,
|
|
55
|
+
SELVA_STORAGE_PROVIDER: storage
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
// ── Provider-specific config ──────────────────────────────────────────
|
|
59
|
+
if (auth === 'local' || data === 'local' || storage === 'local') {
|
|
60
|
+
values.DATA_PATH = env.DATA_PATH || './.selva-data';
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
if (auth === 'supabase' || data === 'supabase' || storage === 'supabase') {
|
|
64
|
+
const url = requireEnv(env, 'SUPABASE_URL');
|
|
65
|
+
try {
|
|
66
|
+
new URL(url);
|
|
67
|
+
} catch {
|
|
68
|
+
throw new Error('SUPABASE_URL must be a valid URL.');
|
|
69
|
+
}
|
|
70
|
+
values.SUPABASE_URL = url;
|
|
71
|
+
values.SUPABASE_ANON_KEY = requireEnv(env, 'SUPABASE_ANON_KEY');
|
|
72
|
+
values.SUPABASE_SERVICE_ROLE_KEY = requireEnv(env, 'SUPABASE_SERVICE_ROLE_KEY');
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
if (auth === 'header') {
|
|
76
|
+
// Same loopback default as the prompt — header-auth without network
|
|
77
|
+
// isolation is the documented worst-case footgun.
|
|
78
|
+
values.HOST = env.HOST || '127.0.0.1';
|
|
79
|
+
if (data !== 'local') {
|
|
80
|
+
values.HEADER_AUTH_DATA_DIR = requireEnv(env, 'HEADER_AUTH_DATA_DIR');
|
|
81
|
+
} else if (env.HEADER_AUTH_DATA_DIR) {
|
|
82
|
+
values.HEADER_AUTH_DATA_DIR = env.HEADER_AUTH_DATA_DIR;
|
|
83
|
+
}
|
|
84
|
+
if (env.HEADER_AUTH_UPN_HEADER) values.HEADER_AUTH_UPN_HEADER = env.HEADER_AUTH_UPN_HEADER;
|
|
85
|
+
if (env.HEADER_AUTH_EMAIL_HEADER)
|
|
86
|
+
values.HEADER_AUTH_EMAIL_HEADER = env.HEADER_AUTH_EMAIL_HEADER;
|
|
87
|
+
if (env.HEADER_AUTH_DISPLAY_NAME_HEADER)
|
|
88
|
+
values.HEADER_AUTH_DISPLAY_NAME_HEADER = env.HEADER_AUTH_DISPLAY_NAME_HEADER;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// ── Bootstrap admin ──────────────────────────────────────────────────
|
|
92
|
+
const adminRequired = auth === 'header' || tenancy === 'multi';
|
|
93
|
+
const adminEmail = env.BOOTSTRAP_INSTANCE_ADMIN_EMAIL || '';
|
|
94
|
+
if (adminRequired && !adminEmail) {
|
|
95
|
+
throw new Error(
|
|
96
|
+
`BOOTSTRAP_INSTANCE_ADMIN_EMAIL is required for ${auth === 'header' ? 'header-auth' : 'multi-tenant'} deployments.`
|
|
97
|
+
);
|
|
98
|
+
}
|
|
99
|
+
if (adminEmail && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(adminEmail)) {
|
|
100
|
+
throw new Error('BOOTSTRAP_INSTANCE_ADMIN_EMAIL is not a valid email.');
|
|
101
|
+
}
|
|
102
|
+
values.BOOTSTRAP_INSTANCE_ADMIN_EMAIL = adminEmail;
|
|
103
|
+
|
|
104
|
+
// ── Reverse proxy ────────────────────────────────────────────────────
|
|
105
|
+
const origin = env.ORIGIN || '';
|
|
106
|
+
if (origin) {
|
|
107
|
+
try {
|
|
108
|
+
new URL(origin);
|
|
109
|
+
} catch {
|
|
110
|
+
throw new Error('ORIGIN must be a valid URL.');
|
|
111
|
+
}
|
|
112
|
+
if (origin.endsWith('/')) {
|
|
113
|
+
throw new Error('ORIGIN must not have a trailing slash.');
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
values.ORIGIN = origin;
|
|
117
|
+
|
|
118
|
+
// ── Feature flags ────────────────────────────────────────────────────
|
|
119
|
+
// Pass-through: any SELVA_FLAG_* var set on the environment is written
|
|
120
|
+
// verbatim. Unset flags get an empty string so the .env file still has
|
|
121
|
+
// a row for them (operator can flip later without editing structure).
|
|
122
|
+
const flagNames = [
|
|
123
|
+
'ALLOW_ORG_CREATION',
|
|
124
|
+
'ALLOW_CROSS_ORG_PUBLIC',
|
|
125
|
+
'ALLOW_ORG_COMPUTE_OVERRIDE',
|
|
126
|
+
'ENABLE_SHARING'
|
|
127
|
+
];
|
|
128
|
+
for (const f of flagNames) {
|
|
129
|
+
const key = `SELVA_FLAG_${f}`;
|
|
130
|
+
values[key] = envBool(env[key]) ? 'true' : '';
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
return values;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function pick(value, allowed, fallback, name) {
|
|
137
|
+
if (!value) return fallback;
|
|
138
|
+
if (!allowed.includes(value)) {
|
|
139
|
+
throw new Error(`${name} must be one of: ${allowed.join(', ')} (got "${value}").`);
|
|
140
|
+
}
|
|
141
|
+
return value;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function requireEnv(env, name) {
|
|
145
|
+
const v = env[name];
|
|
146
|
+
if (!v) throw new Error(`${name} is required.`);
|
|
147
|
+
return v;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// Runs the full interactive prompt sequence and returns a flat object of
|
|
151
|
+
// env-var-name → value (string). The caller decides whether to merge with an
|
|
152
|
+
// existing .env or write fresh.
|
|
153
|
+
//
|
|
154
|
+
// `defaults` is an existing env map (from .env, or {}). Anything present there
|
|
155
|
+
// pre-populates the prompt so re-running is cheap.
|
|
156
|
+
export async function collectConfig({ defaults = {}, mode = 'create' } = {}) {
|
|
157
|
+
const isInit = mode === 'init';
|
|
158
|
+
|
|
159
|
+
p.intro(pc.bgCyan(pc.black(isInit ? ' selva init ' : ' Selva — new deployment ')));
|
|
160
|
+
|
|
161
|
+
// Brand prompts (SELVA_BRAND_NAME / COPYRIGHT_NAME / TAGLINE / DESCRIPTION)
|
|
162
|
+
// are skipped for now — the runtime falls back to "Selva" defaults when
|
|
163
|
+
// these env vars are absent. To re-enable, add a brand prompt block here
|
|
164
|
+
// and write the values into `values` below.
|
|
165
|
+
|
|
166
|
+
// ── Tenancy ─────────────────────────────────────────────────────────
|
|
167
|
+
const tenancy = await p.select({
|
|
168
|
+
message: 'Tenancy mode',
|
|
169
|
+
initialValue: defaults.SELVA_TENANCY ?? 'single',
|
|
170
|
+
options: [
|
|
171
|
+
{
|
|
172
|
+
value: 'single',
|
|
173
|
+
label: 'single',
|
|
174
|
+
hint: 'one org per deployment (white-label)'
|
|
175
|
+
},
|
|
176
|
+
{
|
|
177
|
+
value: 'multi',
|
|
178
|
+
label: 'multi',
|
|
179
|
+
hint: 'orgs are first-class (SaaS-style)'
|
|
180
|
+
}
|
|
181
|
+
]
|
|
182
|
+
});
|
|
183
|
+
cancelOn(tenancy);
|
|
184
|
+
|
|
185
|
+
// ── Auth provider ───────────────────────────────────────────────────
|
|
186
|
+
const auth = await p.select({
|
|
187
|
+
message: 'Auth backend',
|
|
188
|
+
initialValue: defaults.SELVA_AUTH_PROVIDER ?? 'local',
|
|
189
|
+
options: [
|
|
190
|
+
{ value: 'local', label: 'local', hint: 'filesystem + HMAC sessions' },
|
|
191
|
+
{ value: 'supabase', label: 'supabase', hint: 'managed auth + Postgres + storage' },
|
|
192
|
+
{
|
|
193
|
+
value: 'header',
|
|
194
|
+
label: 'header',
|
|
195
|
+
hint: 'forward-auth via reverse proxy (Entra, oauth2-proxy, …)'
|
|
196
|
+
}
|
|
197
|
+
]
|
|
198
|
+
});
|
|
199
|
+
cancelOn(auth);
|
|
200
|
+
|
|
201
|
+
// Header-auth is auth-only — it has no data/storage to share. The user
|
|
202
|
+
// MUST pick a separate backend for those. Local is the sensible default
|
|
203
|
+
// pairing (same filesystem as the allowlist).
|
|
204
|
+
let data;
|
|
205
|
+
let storage;
|
|
206
|
+
if (auth === 'header') {
|
|
207
|
+
p.note(headerAuthSecurityWarning(), pc.yellow('⚠ Read before deploying'));
|
|
208
|
+
|
|
209
|
+
const dataChoice = await p.select({
|
|
210
|
+
message: 'Data backend (header-auth has none — pair with local or supabase)',
|
|
211
|
+
initialValue: defaults.SELVA_DATA_PROVIDER ?? 'local',
|
|
212
|
+
options: [
|
|
213
|
+
{ value: 'local', label: 'local', hint: 'filesystem JSON' },
|
|
214
|
+
{ value: 'supabase', label: 'supabase', hint: 'Postgres' }
|
|
215
|
+
]
|
|
216
|
+
});
|
|
217
|
+
cancelOn(dataChoice);
|
|
218
|
+
data = dataChoice;
|
|
219
|
+
|
|
220
|
+
const storageChoice = await p.select({
|
|
221
|
+
message: 'Storage backend',
|
|
222
|
+
initialValue: defaults.SELVA_STORAGE_PROVIDER ?? data,
|
|
223
|
+
options: [
|
|
224
|
+
{ value: 'local', label: 'local', hint: 'filesystem' },
|
|
225
|
+
{ value: 'supabase', label: 'supabase', hint: 'Supabase Storage' }
|
|
226
|
+
]
|
|
227
|
+
});
|
|
228
|
+
cancelOn(storageChoice);
|
|
229
|
+
storage = storageChoice;
|
|
230
|
+
} else {
|
|
231
|
+
// In practice operators almost always want data + storage on the same
|
|
232
|
+
// backend as auth. Ask once, default the others; let advanced users
|
|
233
|
+
// override.
|
|
234
|
+
const mixProviders = await p.confirm({
|
|
235
|
+
message: `Use ${pc.cyan(auth)} for data and storage too?`,
|
|
236
|
+
initialValue: pickSameProviderDefault(defaults, auth)
|
|
237
|
+
});
|
|
238
|
+
cancelOn(mixProviders);
|
|
239
|
+
|
|
240
|
+
data = auth;
|
|
241
|
+
storage = auth;
|
|
242
|
+
if (!mixProviders) {
|
|
243
|
+
const dataChoice = await p.select({
|
|
244
|
+
message: 'Data backend',
|
|
245
|
+
initialValue: defaults.SELVA_DATA_PROVIDER ?? auth,
|
|
246
|
+
options: [
|
|
247
|
+
{ value: 'local', label: 'local' },
|
|
248
|
+
{ value: 'supabase', label: 'supabase' }
|
|
249
|
+
]
|
|
250
|
+
});
|
|
251
|
+
cancelOn(dataChoice);
|
|
252
|
+
data = dataChoice;
|
|
253
|
+
|
|
254
|
+
const storageChoice = await p.select({
|
|
255
|
+
message: 'Storage backend',
|
|
256
|
+
initialValue: defaults.SELVA_STORAGE_PROVIDER ?? auth,
|
|
257
|
+
options: [
|
|
258
|
+
{ value: 'local', label: 'local' },
|
|
259
|
+
{ value: 'supabase', label: 'supabase' }
|
|
260
|
+
]
|
|
261
|
+
});
|
|
262
|
+
cancelOn(storageChoice);
|
|
263
|
+
storage = storageChoice;
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
// ── Provider-specific config ───────────────────────────────────────
|
|
268
|
+
const providerValues = {};
|
|
269
|
+
if (auth === 'local' || data === 'local' || storage === 'local') {
|
|
270
|
+
const dataPath = await p.text({
|
|
271
|
+
message: 'DATA_PATH — directory for users/orgs JSON + uploaded .gh files',
|
|
272
|
+
placeholder: './.selva-data',
|
|
273
|
+
initialValue: defaults.DATA_PATH ?? './.selva-data'
|
|
274
|
+
});
|
|
275
|
+
cancelOn(dataPath);
|
|
276
|
+
providerValues.DATA_PATH = String(dataPath);
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
if (auth === 'supabase' || data === 'supabase' || storage === 'supabase') {
|
|
280
|
+
const supabaseUrl = await p.text({
|
|
281
|
+
message: 'SUPABASE_URL',
|
|
282
|
+
placeholder: 'https://<project-ref>.supabase.co',
|
|
283
|
+
initialValue: defaults.SUPABASE_URL ?? '',
|
|
284
|
+
validate: (v) => {
|
|
285
|
+
if (!v) return 'Required for the Supabase provider.';
|
|
286
|
+
try {
|
|
287
|
+
new URL(v);
|
|
288
|
+
} catch {
|
|
289
|
+
return 'Must be a valid URL.';
|
|
290
|
+
}
|
|
291
|
+
return undefined;
|
|
292
|
+
}
|
|
293
|
+
});
|
|
294
|
+
cancelOn(supabaseUrl);
|
|
295
|
+
|
|
296
|
+
const supabaseAnon = await p.text({
|
|
297
|
+
message: 'SUPABASE_ANON_KEY (publishable)',
|
|
298
|
+
placeholder: 'sb_publishable_…',
|
|
299
|
+
initialValue: defaults.SUPABASE_ANON_KEY ?? '',
|
|
300
|
+
validate: (v) => (v ? undefined : 'Required.')
|
|
301
|
+
});
|
|
302
|
+
cancelOn(supabaseAnon);
|
|
303
|
+
|
|
304
|
+
const supabaseService = await p.password({
|
|
305
|
+
message: 'SUPABASE_SERVICE_ROLE_KEY (secret, server-only)',
|
|
306
|
+
validate: (v) => (v || defaults.SUPABASE_SERVICE_ROLE_KEY ? undefined : 'Required.')
|
|
307
|
+
});
|
|
308
|
+
cancelOn(supabaseService);
|
|
309
|
+
|
|
310
|
+
providerValues.SUPABASE_URL = String(supabaseUrl);
|
|
311
|
+
providerValues.SUPABASE_ANON_KEY = String(supabaseAnon);
|
|
312
|
+
providerValues.SUPABASE_SERVICE_ROLE_KEY = String(
|
|
313
|
+
supabaseService || defaults.SUPABASE_SERVICE_ROLE_KEY || ''
|
|
314
|
+
);
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
if (auth === 'header') {
|
|
318
|
+
// HEADER_AUTH_DATA_DIR is where header-allowlist.json lives. When the
|
|
319
|
+
// data provider is local, DATA_PATH is the natural home and we let
|
|
320
|
+
// the provider fall back to it. Only ask explicitly if data isn't
|
|
321
|
+
// local — otherwise there's no obvious default.
|
|
322
|
+
if (data !== 'local') {
|
|
323
|
+
const dataDir = await p.text({
|
|
324
|
+
message: 'HEADER_AUTH_DATA_DIR — directory for header-allowlist.json',
|
|
325
|
+
placeholder: './.selva-data',
|
|
326
|
+
initialValue: defaults.HEADER_AUTH_DATA_DIR ?? './.selva-data',
|
|
327
|
+
validate: (v) => (v ? undefined : 'Required when data provider is not local.')
|
|
328
|
+
});
|
|
329
|
+
cancelOn(dataDir);
|
|
330
|
+
providerValues.HEADER_AUTH_DATA_DIR = String(dataDir);
|
|
331
|
+
} else if (defaults.HEADER_AUTH_DATA_DIR) {
|
|
332
|
+
// Preserve an explicit override if the operator set one previously.
|
|
333
|
+
providerValues.HEADER_AUTH_DATA_DIR = defaults.HEADER_AUTH_DATA_DIR;
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
const customizeHeaders = await p.confirm({
|
|
337
|
+
message: 'Customize the trusted header names?',
|
|
338
|
+
initialValue: Boolean(
|
|
339
|
+
defaults.HEADER_AUTH_UPN_HEADER ||
|
|
340
|
+
defaults.HEADER_AUTH_EMAIL_HEADER ||
|
|
341
|
+
defaults.HEADER_AUTH_DISPLAY_NAME_HEADER
|
|
342
|
+
)
|
|
343
|
+
});
|
|
344
|
+
cancelOn(customizeHeaders);
|
|
345
|
+
|
|
346
|
+
if (customizeHeaders) {
|
|
347
|
+
const upn = await p.text({
|
|
348
|
+
message: 'HEADER_AUTH_UPN_HEADER',
|
|
349
|
+
placeholder: 'SELVA-UserPrincipalName',
|
|
350
|
+
initialValue: defaults.HEADER_AUTH_UPN_HEADER ?? ''
|
|
351
|
+
});
|
|
352
|
+
cancelOn(upn);
|
|
353
|
+
const email = await p.text({
|
|
354
|
+
message: 'HEADER_AUTH_EMAIL_HEADER',
|
|
355
|
+
placeholder: 'SELVA-Email',
|
|
356
|
+
initialValue: defaults.HEADER_AUTH_EMAIL_HEADER ?? ''
|
|
357
|
+
});
|
|
358
|
+
cancelOn(email);
|
|
359
|
+
const display = await p.text({
|
|
360
|
+
message: 'HEADER_AUTH_DISPLAY_NAME_HEADER',
|
|
361
|
+
placeholder: 'SELVA-DisplayName',
|
|
362
|
+
initialValue: defaults.HEADER_AUTH_DISPLAY_NAME_HEADER ?? ''
|
|
363
|
+
});
|
|
364
|
+
cancelOn(display);
|
|
365
|
+
|
|
366
|
+
providerValues.HEADER_AUTH_UPN_HEADER = stringValue(upn);
|
|
367
|
+
providerValues.HEADER_AUTH_EMAIL_HEADER = stringValue(email);
|
|
368
|
+
providerValues.HEADER_AUTH_DISPLAY_NAME_HEADER = stringValue(display);
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
// Header-auth deployments MUST bind to loopback. We don't force it
|
|
372
|
+
// (the operator might run inside a Docker network where the proxy
|
|
373
|
+
// reaches the container by service name), but we set HOST=127.0.0.1
|
|
374
|
+
// as the default and let them override.
|
|
375
|
+
const bindLoopback = await p.confirm({
|
|
376
|
+
message: 'Bind the app to 127.0.0.1 only? (recommended for header-auth)',
|
|
377
|
+
initialValue: !defaults.HOST || defaults.HOST === '127.0.0.1' || defaults.HOST === 'localhost'
|
|
378
|
+
});
|
|
379
|
+
cancelOn(bindLoopback);
|
|
380
|
+
providerValues.HOST = bindLoopback ? '127.0.0.1' : stringValue(defaults.HOST);
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
// ── Bootstrap admin ────────────────────────────────────────────────
|
|
384
|
+
// Two roles in one env var: (1) gate the "first-signup-becomes-admin"
|
|
385
|
+
// path to a specific email — required for multi-tenant so a random
|
|
386
|
+
// signup doesn't get Selva staff perms; (2) break-glass recovery if
|
|
387
|
+
// admin is lost to a backup restore / manual DB edit. The check ONLY
|
|
388
|
+
// runs while no admin exists yet, so leaving it set permanently is
|
|
389
|
+
// safe. Phrase the prompt differently per tenancy/auth-provider because
|
|
390
|
+
// the security implications differ.
|
|
391
|
+
if (auth === 'header') {
|
|
392
|
+
p.note(
|
|
393
|
+
[
|
|
394
|
+
'Header-auth has no /setup form. The first proxy-authenticated visit whose',
|
|
395
|
+
'email matches this var becomes ' +
|
|
396
|
+
pc.bold('instance admin') +
|
|
397
|
+
' automatically. Set it now so you',
|
|
398
|
+
'can claim admin on first visit — otherwise you will need to hand-edit JSON',
|
|
399
|
+
'files on the server to bootstrap.',
|
|
400
|
+
'',
|
|
401
|
+
pc.dim('Only consulted while no admin exists yet — safe to leave permanently set.')
|
|
402
|
+
].join('\n'),
|
|
403
|
+
'Bootstrap admin (required for header-auth)'
|
|
404
|
+
);
|
|
405
|
+
} else if (tenancy === 'multi') {
|
|
406
|
+
p.note(
|
|
407
|
+
[
|
|
408
|
+
'On a public multi-tenant instance, the FIRST user to sign in becomes',
|
|
409
|
+
pc.bold('Selva staff') + ' (every platform permission) unless you lock the bootstrap',
|
|
410
|
+
'to a specific email. Strongly recommended to set this — leave blank only',
|
|
411
|
+
'if you know what you are doing.',
|
|
412
|
+
'',
|
|
413
|
+
pc.dim('Also doubles as the recovery email if admin is ever lost.')
|
|
414
|
+
].join('\n'),
|
|
415
|
+
'Bootstrap admin'
|
|
416
|
+
);
|
|
417
|
+
} else {
|
|
418
|
+
p.note(
|
|
419
|
+
[
|
|
420
|
+
'OPTIONAL — leave blank to let the first user who completes /setup become',
|
|
421
|
+
'admin. Set it if you want only a specific email to be eligible (useful when',
|
|
422
|
+
'the URL is shared before setup, or as a recovery handle if admin is ever lost).',
|
|
423
|
+
'',
|
|
424
|
+
pc.dim('Only consulted while no admin exists yet — safe to leave permanently set.')
|
|
425
|
+
].join('\n'),
|
|
426
|
+
'Bootstrap admin (optional)'
|
|
427
|
+
);
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
const adminRequired = auth === 'header' || tenancy === 'multi';
|
|
431
|
+
const adminEmail = await p.text({
|
|
432
|
+
message: adminRequired
|
|
433
|
+
? 'Email allowed to claim admin on first signup'
|
|
434
|
+
: 'Email allowed to claim admin (leave blank to skip)',
|
|
435
|
+
placeholder: adminRequired ? 'you@your-org.com' : '(press Enter to skip)',
|
|
436
|
+
initialValue: defaults.BOOTSTRAP_INSTANCE_ADMIN_EMAIL ?? '',
|
|
437
|
+
validate: (v) => {
|
|
438
|
+
if (!v) {
|
|
439
|
+
// Blank is allowed in single-tenant non-header. Header-auth and
|
|
440
|
+
// multi-tenant both need the email — without it there is no
|
|
441
|
+
// supported path to first-admin (header-auth) or the first
|
|
442
|
+
// random signup gets staff perms (multi-tenant).
|
|
443
|
+
return adminRequired ? 'Required for this configuration.' : undefined;
|
|
444
|
+
}
|
|
445
|
+
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(v)) return 'Not a valid email.';
|
|
446
|
+
return undefined;
|
|
447
|
+
}
|
|
448
|
+
});
|
|
449
|
+
cancelOn(adminEmail);
|
|
450
|
+
|
|
451
|
+
// ── Reverse proxy ──────────────────────────────────────────────────
|
|
452
|
+
const behindProxy = await p.confirm({
|
|
453
|
+
message: 'Behind a reverse proxy (Caddy, nginx, etc.)?',
|
|
454
|
+
initialValue: Boolean(defaults.ORIGIN)
|
|
455
|
+
});
|
|
456
|
+
cancelOn(behindProxy);
|
|
457
|
+
|
|
458
|
+
let origin = '';
|
|
459
|
+
if (behindProxy) {
|
|
460
|
+
const value = await p.text({
|
|
461
|
+
message: 'Public-facing origin (no trailing slash) — sets ORIGIN',
|
|
462
|
+
placeholder: 'https://your-domain.com',
|
|
463
|
+
initialValue: defaults.ORIGIN ?? '',
|
|
464
|
+
validate: (v) => {
|
|
465
|
+
if (!v) return 'Required when behind a proxy.';
|
|
466
|
+
try {
|
|
467
|
+
new URL(v);
|
|
468
|
+
} catch {
|
|
469
|
+
return 'Must be a valid URL.';
|
|
470
|
+
}
|
|
471
|
+
if (v.endsWith('/')) return 'Drop the trailing slash.';
|
|
472
|
+
return undefined;
|
|
473
|
+
}
|
|
474
|
+
});
|
|
475
|
+
cancelOn(value);
|
|
476
|
+
origin = String(value);
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
// ── Platform flags ─────────────────────────────────────────────────
|
|
480
|
+
const flagOptions = [
|
|
481
|
+
{
|
|
482
|
+
value: 'ALLOW_ORG_CREATION',
|
|
483
|
+
label: 'Allow non-admin users to create their own orgs',
|
|
484
|
+
hint: 'multi-tenant self-service'
|
|
485
|
+
},
|
|
486
|
+
{
|
|
487
|
+
value: 'ALLOW_CROSS_ORG_PUBLIC',
|
|
488
|
+
label: 'Public projects visible across all orgs',
|
|
489
|
+
hint: 'instance-wide discovery'
|
|
490
|
+
},
|
|
491
|
+
{
|
|
492
|
+
value: 'ALLOW_ORG_COMPUTE_OVERRIDE',
|
|
493
|
+
label: 'Orgs can configure their own Rhino.Compute server',
|
|
494
|
+
hint: 'BYO compute'
|
|
495
|
+
},
|
|
496
|
+
{
|
|
497
|
+
value: 'ENABLE_SHARING',
|
|
498
|
+
label: 'Per-definition share links (anonymous external access)',
|
|
499
|
+
hint: 'tokenized URLs'
|
|
500
|
+
}
|
|
501
|
+
];
|
|
502
|
+
|
|
503
|
+
const flagDefaults = flagOptions
|
|
504
|
+
.map((o) => o.value)
|
|
505
|
+
.filter((v) => envBool(defaults[`SELVA_FLAG_${v}`]));
|
|
506
|
+
|
|
507
|
+
const flags = await p.multiselect({
|
|
508
|
+
message: 'Platform feature flags (space to toggle, enter to confirm)',
|
|
509
|
+
options: flagOptions,
|
|
510
|
+
initialValues: flagDefaults,
|
|
511
|
+
required: false
|
|
512
|
+
});
|
|
513
|
+
cancelOn(flags);
|
|
514
|
+
|
|
515
|
+
// ── Done ───────────────────────────────────────────────────────────
|
|
516
|
+
const values = {
|
|
517
|
+
SELVA_TENANCY: tenancy,
|
|
518
|
+
SELVA_AUTH_PROVIDER: auth,
|
|
519
|
+
SELVA_DATA_PROVIDER: data,
|
|
520
|
+
SELVA_STORAGE_PROVIDER: storage,
|
|
521
|
+
BOOTSTRAP_INSTANCE_ADMIN_EMAIL: stringValue(adminEmail),
|
|
522
|
+
ORIGIN: origin,
|
|
523
|
+
...providerValues
|
|
524
|
+
};
|
|
525
|
+
|
|
526
|
+
for (const opt of flagOptions) {
|
|
527
|
+
const enabled = Array.isArray(flags) && flags.includes(opt.value);
|
|
528
|
+
values[`SELVA_FLAG_${opt.value}`] = enabled ? 'true' : '';
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
return values;
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
// `selva init` should default to "yes, same provider for all three" only when
|
|
535
|
+
// the current .env already reflects that. If the operator deliberately split
|
|
536
|
+
// auth and data, don't re-merge them on reconfigure.
|
|
537
|
+
function pickSameProviderDefault(defaults, auth) {
|
|
538
|
+
const data = defaults.SELVA_DATA_PROVIDER ?? auth;
|
|
539
|
+
const storage = defaults.SELVA_STORAGE_PROVIDER ?? auth;
|
|
540
|
+
if (!defaults.SELVA_DATA_PROVIDER && !defaults.SELVA_STORAGE_PROVIDER) return true;
|
|
541
|
+
return data === auth && storage === auth;
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
function cancelOn(v) {
|
|
545
|
+
// @clack/prompts returns Symbol(clack:cancel) when the user hits Ctrl+C.
|
|
546
|
+
// p.isCancel() is the official API for detecting it.
|
|
547
|
+
if (p.isCancel(v)) {
|
|
548
|
+
p.cancel('Cancelled.');
|
|
549
|
+
process.exit(0);
|
|
550
|
+
}
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
function stringValue(v) {
|
|
554
|
+
if (v === undefined || v === null) return '';
|
|
555
|
+
return String(v);
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
// Shown once when the operator picks header-auth. The provider's README is
|
|
559
|
+
// emphatic that the deployment IS the security boundary — none of these
|
|
560
|
+
// invariants can be enforced at runtime, so we surface them up front.
|
|
561
|
+
function headerAuthSecurityWarning() {
|
|
562
|
+
return [
|
|
563
|
+
'Header-auth trusts identity headers from the upstream proxy. Anyone who',
|
|
564
|
+
'reaches the app process directly can spoof them. You MUST ensure:',
|
|
565
|
+
'',
|
|
566
|
+
` ${pc.bold('1.')} Network isolation — bind to 127.0.0.1 (or use a Unix socket / firewall).`,
|
|
567
|
+
` ${pc.bold('2.')} Proxy-side auth — the proxy authenticates every request against the IdP.`,
|
|
568
|
+
` ${pc.bold('3.')} Header scrubbing — the proxy strips inbound SELVA-* headers before adding`,
|
|
569
|
+
' its own, otherwise a browser can spoof them alongside the real ones.',
|
|
570
|
+
'',
|
|
571
|
+
pc.dim('See packages/providers/header-auth/README.md for the full deployment checklist.')
|
|
572
|
+
].join('\n');
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
export { p as prompts, pc as colors };
|