@selvajs/cli 4.8.0 → 4.8.2

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@selvajs/cli",
3
- "version": "4.8.0",
3
+ "version": "4.8.2",
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",
@@ -17,6 +17,7 @@ import {
17
17
  } from '../paths.js';
18
18
  import {
19
19
  buildDeploymentPackageJson,
20
+ needsSupabaseProvider,
20
21
  npmDistTagVersion,
21
22
  resolveSelvaPins
22
23
  } from '../deployment-package.js';
@@ -65,12 +66,17 @@ export async function runCreate(argv) {
65
66
  // `"latest"` re-resolves on every later `npm install`, so the deployment
66
67
  // would follow the tag instead of the version it was scaffolded against —
67
68
  // and `selva doctor` reports a floating pin as drift.
68
- const { pins } = resolveSelvaPins({}, npmDistTagVersion);
69
+ const supabase = needsSupabaseProvider(values);
70
+ const { pins } = resolveSelvaPins(
71
+ {},
72
+ npmDistTagVersion,
73
+ supabase ? ['@selvajs/supabase-provider'] : []
74
+ );
69
75
 
70
76
  // package.json first: npm install needs it, and the runtime templates below
71
77
  // only exist once @selvajs/selva is installed into node_modules.
72
78
  const pkgJson = JSON.stringify(
73
- buildDeploymentPackageJson({ name: deployName, dependencies: pins }),
79
+ buildDeploymentPackageJson({ name: deployName, dependencies: pins, supabase }),
74
80
  null,
75
81
  2
76
82
  );
@@ -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,7 +123,12 @@ 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
134
  const configPath = join(dir, 'selva.config.js');
@@ -339,13 +345,32 @@ function restartAfterRollback(dir, pm2 = runPm2) {
339
345
  }
340
346
  }
341
347
 
342
- function buildTargetPackageJson(current, resolveVersion = npmDistTagVersion) {
343
- const { pins, notes } = resolveSelvaPins(current.dependencies, resolveVersion);
348
+ // Only the provider keys matter here, so this reads `KEY=value` lines rather
349
+ // than pulling in a dotenv parser: no quoting, expansion, or multi-line values
350
+ // to honour.
351
+ function readDeploymentEnv(envPath) {
352
+ if (!existsSync(envPath)) return {};
353
+ const env = {};
354
+ for (const line of readFileSync(envPath, 'utf8').split(/\r?\n/)) {
355
+ const match = /^\s*([A-Z0-9_]+)\s*=\s*(.*)$/.exec(line);
356
+ if (match) env[match[1]] = match[2].trim().replace(/^["']|["']$/g, '');
357
+ }
358
+ return env;
359
+ }
360
+
361
+ function buildTargetPackageJson(current, resolveVersion = npmDistTagVersion, env = {}) {
362
+ const supabase = needsSupabaseProvider(env);
363
+ const { pins, notes } = resolveSelvaPins(
364
+ current.dependencies,
365
+ resolveVersion,
366
+ supabase ? ['@selvajs/supabase-provider'] : []
367
+ );
344
368
  const pkg = buildDeploymentPackageJson({
345
369
  name: current.name ?? 'selva-deployment',
346
370
  version: current.version ?? '0.1.0',
347
371
  engines: current.engines,
348
- dependencies: pins
372
+ dependencies: pins,
373
+ supabase
349
374
  });
350
375
  return { pkg, notes };
351
376
  }
@@ -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
  /**
@@ -117,7 +145,6 @@ export const LEGACY_DEPENDENCIES = {
117
145
  '@selvajs/create': 'the old CLI package',
118
146
  '@selvajs/platform': 'now bundled into @selvajs/selva',
119
147
  '@selvajs/local-provider': 'now bundled into @selvajs/selva',
120
- '@selvajs/supabase-provider': 'now bundled into @selvajs/selva',
121
148
  '@selvajs/header-auth-provider': 'now bundled into @selvajs/selva'
122
149
  };
123
150
 
@@ -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/prompts.js CHANGED
@@ -5,6 +5,52 @@ 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((o) => o.promptable !== false && (tenancy === 'multi' || !o.multiOnly));
52
+ }
53
+
8
54
  function envBool(v) {
9
55
  if (v === undefined) return false;
10
56
  return TRUTHY.has(String(v).toLowerCase());
@@ -95,14 +141,10 @@ export function collectConfigFromEnv(env = process.env) {
95
141
  }
96
142
  values.ORIGIN = origin;
97
143
 
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) {
144
+ // An explicitly-set flag is preserved verbatim even when tenancy makes it
145
+ // inert: rewriting it to '' would hide the operator's misconfiguration
146
+ // rather than fix it, and the server already reads every key as opt-in.
147
+ for (const f of FLAG_NAMES) {
106
148
  const key = `SELVA_FLAG_${f}`;
107
149
  values[key] = envBool(env[key]) ? 'true' : '';
108
150
  }
@@ -425,33 +467,7 @@ export async function collectConfig({ defaults = {}, mode = 'create' } = {}) {
425
467
  }
426
468
  }
427
469
 
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
- ];
470
+ const flagOptions = promptableFlags(tenancy);
455
471
 
456
472
  const flagDefaults = flagOptions
457
473
  .map((o) => o.value)
@@ -475,9 +491,18 @@ export async function collectConfig({ defaults = {}, mode = 'create' } = {}) {
475
491
  ...providerValues
476
492
  };
477
493
 
478
- for (const opt of flagOptions) {
479
- const enabled = Array.isArray(flags) && flags.includes(opt.value);
480
- values[`SELVA_FLAG_${opt.value}`] = enabled ? 'true' : '';
494
+ // Every key is written, including the ones the prompt hid — a missing key
495
+ // reads as "unset" downstream, which is indistinguishable from "removed".
496
+ // A hidden flag keeps whatever the existing .env had; only offered flags
497
+ // are decided by the multiselect, so `init` can't silently clear one.
498
+ const offered = new Set(flagOptions.map((o) => o.value));
499
+ for (const name of FLAG_NAMES) {
500
+ const key = `SELVA_FLAG_${name}`;
501
+ if (!offered.has(name)) {
502
+ values[key] = envBool(defaults[key]) ? 'true' : '';
503
+ continue;
504
+ }
505
+ values[key] = Array.isArray(flags) && flags.includes(name) ? 'true' : '';
481
506
  }
482
507
 
483
508
  return values;