@substrio/cli 0.0.0-stage → 0.1.1

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.
Files changed (56) hide show
  1. package/LICENSE +177 -0
  2. package/README.md +66 -2
  3. package/bin/substrio.mjs +91 -0
  4. package/package.json +44 -4
  5. package/src/api.mjs +107 -0
  6. package/src/apply.mjs +204 -0
  7. package/src/blobs.mjs +102 -0
  8. package/src/cli.mjs +405 -0
  9. package/src/collections.mjs +701 -0
  10. package/src/command-table.mjs +436 -0
  11. package/src/commands/bundle.mjs +369 -0
  12. package/src/commands/count.mjs +39 -0
  13. package/src/commands/deploy.mjs +488 -0
  14. package/src/commands/dev.mjs +246 -0
  15. package/src/commands/env.mjs +115 -0
  16. package/src/commands/files.mjs +172 -0
  17. package/src/commands/git.mjs +195 -0
  18. package/src/commands/groups-runtime.mjs +227 -0
  19. package/src/commands/groups.mjs +417 -0
  20. package/src/commands/history.mjs +374 -0
  21. package/src/commands/import.mjs +506 -0
  22. package/src/commands/init.mjs +175 -0
  23. package/src/commands/key.mjs +172 -0
  24. package/src/commands/login.mjs +167 -0
  25. package/src/commands/logs.mjs +285 -0
  26. package/src/commands/mcp-config.mjs +102 -0
  27. package/src/commands/op.mjs +265 -0
  28. package/src/commands/open.mjs +35 -0
  29. package/src/commands/push.mjs +199 -0
  30. package/src/commands/signup.mjs +164 -0
  31. package/src/commands/site.mjs +129 -0
  32. package/src/commands/skills.mjs +177 -0
  33. package/src/commands/status.mjs +78 -0
  34. package/src/commands/tenants.mjs +172 -0
  35. package/src/commands/test.mjs +167 -0
  36. package/src/commands/tools.mjs +63 -0
  37. package/src/commands/versions.mjs +293 -0
  38. package/src/config.mjs +569 -0
  39. package/src/documents.mjs +139 -0
  40. package/src/function-build.mjs +273 -0
  41. package/src/identity.mjs +248 -0
  42. package/src/jsonfile.mjs +22 -0
  43. package/src/live-move.mjs +267 -0
  44. package/src/local/args.mjs +58 -0
  45. package/src/local/reload.mjs +408 -0
  46. package/src/local/server.mjs +291 -0
  47. package/src/local/watch.mjs +175 -0
  48. package/src/manifest.mjs +591 -0
  49. package/src/ops.mjs +265 -0
  50. package/src/readers.mjs +416 -0
  51. package/src/sdk.mjs +26 -0
  52. package/src/settings.mjs +117 -0
  53. package/src/testing/app.mjs +507 -0
  54. package/src/testing/env.mjs +35 -0
  55. package/src/testing/index.mjs +11 -0
  56. package/src/testing/types.mjs +55 -0
package/src/config.mjs ADDED
@@ -0,0 +1,569 @@
1
+ /**
2
+ * Substrio CLI: Configuration loader with multi-profile support
3
+ *
4
+ * Resolution order:
5
+ * 1. Environment variables (SUBSTRIO_URL, SUBSTRIO_TENANT, SUBSTRIO_MASTER_KEY)
6
+ * 2. substrio.toml in current directory (project config)
7
+ * 3. SUBSTRIO_PROFILE, else substrio.toml profile → [profiles.<name>]
8
+ * 4. the profiles file's default_profile → [profiles.<default>]
9
+ * 5. the profiles file's flat keys
10
+ *
11
+ * The profiles file is ~/.substrio/config.toml (SUBSTRIO_CONFIG_DIR moves the
12
+ * directory): the directory is 0700 and the file 0600, whatever the umask,
13
+ * and it is replaced atomically. A ~/.substriorc of an older CLI is moved there
14
+ * on first use.
15
+ */
16
+
17
+ import {
18
+ chmodSync, closeSync, existsSync, fchmodSync, fsyncSync, mkdirSync, openSync, readFileSync, renameSync, rmSync,
19
+ writeFileSync,
20
+ } from 'node:fs';
21
+ import { randomBytes } from 'node:crypto';
22
+ import { dirname, join } from 'node:path';
23
+ import { homedir } from 'node:os';
24
+
25
+ import { SETTINGS_TABLES } from './settings.mjs';
26
+
27
+ // =============================================================================
28
+ // Minimal TOML Parser (handles key=value, [sections], [a.b] dotted, [[arrays
29
+ // of tables]], strings, bools, numbers, and arrays and inline tables of those,
30
+ // which may span lines)
31
+ // =============================================================================
32
+
33
+ // One scalar: a quoted string (basic strings keep \" \\ \n \t escapes), a
34
+ // bool or an integer; anything else is kept as its text.
35
+ function parseScalar(text) {
36
+ const value = text.trim();
37
+ if (value.length >= 2 && value.startsWith('"') && value.endsWith('"')) {
38
+ return value.slice(1, -1).replace(/\\(["\\nt])/g, (_, c) =>
39
+ c === 'n' ? '\n' : c === 't' ? '\t' : c);
40
+ }
41
+ if (value.length >= 2 && value.startsWith("'") && value.endsWith("'")) {
42
+ return value.slice(1, -1);
43
+ }
44
+ if (value === 'true') return true;
45
+ if (value === 'false') return false;
46
+ if (/^-?\d+$/.test(value)) return parseInt(value, 10);
47
+ return value;
48
+ }
49
+
50
+ // Skips whitespace, line breaks and # comments from text[i].
51
+ function skipBlank(text, i) {
52
+ while (i < text.length) {
53
+ const ch = text[i];
54
+ if (ch === '#') { while (i < text.length && text[i] !== '\n') i++; }
55
+ else if (ch === ' ' || ch === '\t' || ch === '\r' || ch === '\n') i++;
56
+ else break;
57
+ }
58
+ return i;
59
+ }
60
+
61
+ // The index just past the string that opens at text[i].
62
+ function stringEnd(text, i) {
63
+ const quote = text[i];
64
+ for (let j = i + 1; j < text.length; j++) {
65
+ if (text[j] === '\\' && quote === '"') j++;
66
+ else if (text[j] === quote) return j + 1;
67
+ }
68
+ throw new Error(`unclosed string: ${text.slice(i, i + 40)}`);
69
+ }
70
+
71
+ // One value at text[i]: a string, an array, an inline table or a bare scalar
72
+ // (which ends at a comma, a closing bracket or brace, a comment or a line
73
+ // break). Returns [value, the index after it].
74
+ function parseValue(text, i) {
75
+ i = skipBlank(text, i);
76
+ const ch = text[i];
77
+ if (ch === '"' || ch === "'") {
78
+ const end = stringEnd(text, i);
79
+ return [parseScalar(text.slice(i, end)), end];
80
+ }
81
+ if (ch === '[' || ch === '{') {
82
+ const close = ch === '[' ? ']' : '}';
83
+ const out = ch === '[' ? [] : {};
84
+ i = skipBlank(text, i + 1);
85
+ while (text[i] !== close) {
86
+ if (i >= text.length) throw new Error(`unclosed ${ch === '[' ? 'array' : 'inline table'}`);
87
+ if (ch === '[') {
88
+ const [value, next] = parseValue(text, i);
89
+ out.push(value);
90
+ i = next;
91
+ } else {
92
+ const eq = text.indexOf('=', i);
93
+ if (eq < 0) throw new Error(`inline table entry without "=": ${text.slice(i, i + 40)}`);
94
+ const key = parseScalar(text.slice(i, eq));
95
+ const [value, next] = parseValue(text, eq + 1);
96
+ out[key] = value;
97
+ i = next;
98
+ }
99
+ i = skipBlank(text, i);
100
+ if (text[i] === ',') i = skipBlank(text, i + 1);
101
+ else if (text[i] !== close) throw new Error(`expected "," or "${close}" at: ${text.slice(i, i + 40)}`);
102
+ }
103
+ return [out, i + 1];
104
+ }
105
+ let end = i;
106
+ while (end < text.length && !',]}#\n'.includes(text[end])) end++;
107
+ return [parseScalar(text.slice(i, end)), end];
108
+ }
109
+
110
+ // Whether an array or inline table value is still open at the end of text:
111
+ // brackets and braces outside strings and comments are counted.
112
+ function valueOpen(text) {
113
+ let depth = 0;
114
+ let quote = null;
115
+ for (let i = 0; i < text.length; i++) {
116
+ const ch = text[i];
117
+ if (quote) {
118
+ if (ch === '\\' && quote === '"') i++;
119
+ else if (ch === quote) quote = null;
120
+ } else if (ch === '"' || ch === "'") quote = ch;
121
+ else if (ch === '#') { while (i < text.length && text[i] !== '\n') i++; }
122
+ else if (ch === '[' || ch === '{') depth++;
123
+ else if ((ch === ']' || ch === '}') && --depth === 0) return false;
124
+ }
125
+ return depth > 0;
126
+ }
127
+
128
+ // The table a dotted header names, made as needed. A name that holds an array
129
+ // of tables stands for its last table, as in TOML.
130
+ function tableAt(root, parts) {
131
+ let obj = root;
132
+ for (const part of parts) {
133
+ if (Array.isArray(obj[part])) obj = obj[part][obj[part].length - 1];
134
+ else obj = obj[part] = obj[part] || {};
135
+ }
136
+ return obj;
137
+ }
138
+
139
+ export function parseTOML(content) {
140
+ const result = {};
141
+ let current = result;
142
+ const lines = content.split('\n');
143
+
144
+ for (let n = 0; n < lines.length; n++) {
145
+ const line = lines[n].trim();
146
+ if (!line || line.startsWith('#')) continue;
147
+
148
+ // Array of tables: [[cron]] or [[build.steps]] appends a new table.
149
+ const arrayMatch = line.match(/^\[\[([^\]]+)\]\]$/);
150
+ if (arrayMatch) {
151
+ const parts = arrayMatch[1].trim().split('.').map((p) => p.trim());
152
+ const parent = tableAt(result, parts.slice(0, -1));
153
+ const last = parts[parts.length - 1];
154
+ if (parent[last] === undefined) parent[last] = [];
155
+ if (!Array.isArray(parent[last])) throw new Error(`[[${arrayMatch[1]}]] names a table that is not an array`);
156
+ current = {};
157
+ parent[last].push(current);
158
+ continue;
159
+ }
160
+
161
+ // Section header: [section] or [section.subsection]
162
+ const sectionMatch = line.match(/^\[([^\]]+)\]$/);
163
+ if (sectionMatch) {
164
+ current = tableAt(result, sectionMatch[1].trim().split('.').map((p) => p.trim()));
165
+ continue;
166
+ }
167
+
168
+ // Key = value
169
+ const kvMatch = line.match(/^([^=]+?)\s*=\s*(.+)$/);
170
+ if (kvMatch) {
171
+ const key = kvMatch[1].trim();
172
+ let value = kvMatch[2].trim();
173
+
174
+ if (value.startsWith('[') || value.startsWith('{')) {
175
+ let text = value;
176
+ while (valueOpen(text) && n + 1 < lines.length) text += '\n' + lines[++n];
177
+ if (valueOpen(text)) {
178
+ throw new Error(`unclosed ${value.startsWith('[') ? 'array' : 'inline table'} for "${key}"`);
179
+ }
180
+ value = parseValue(text, 0)[0];
181
+ } else if (value.startsWith('"') || value.startsWith("'")) {
182
+ // A quoted string; a comment after it is dropped.
183
+ value = parseValue(value, 0)[0];
184
+ } else {
185
+ value = parseScalar(value);
186
+ }
187
+
188
+ current[key] = value;
189
+ }
190
+ }
191
+
192
+ return result;
193
+ }
194
+
195
+ // =============================================================================
196
+ // TOML Writer (simple key=value with sections)
197
+ // =============================================================================
198
+
199
+ function toTOML(obj, prefix = '') {
200
+ const lines = [];
201
+ const sections = [];
202
+
203
+ for (const [key, val] of Object.entries(obj)) {
204
+ if (val && typeof val === 'object' && !Array.isArray(val)) {
205
+ sections.push([key, val]);
206
+ } else {
207
+ const quoted = typeof val === 'string' ? `"${val}"` : String(val);
208
+ lines.push(`${key} = ${quoted}`);
209
+ }
210
+ }
211
+
212
+ for (const [key, val] of sections) {
213
+ const sectionKey = prefix ? `${prefix}.${key}` : key;
214
+ lines.push('');
215
+ lines.push(`[${sectionKey}]`);
216
+ for (const [k, v] of Object.entries(val)) {
217
+ if (v && typeof v === 'object' && !Array.isArray(v)) {
218
+ // Nested section (e.g., profiles.local)
219
+ const nestedKey = `${sectionKey}.${k}`;
220
+ lines.push('');
221
+ lines.push(`[${nestedKey}]`);
222
+ for (const [nk, nv] of Object.entries(v)) {
223
+ const q = typeof nv === 'string' ? `"${nv}"` : String(nv);
224
+ lines.push(`${nk} = ${q}`);
225
+ }
226
+ } else {
227
+ const q = typeof v === 'string' ? `"${v}"` : String(v);
228
+ lines.push(`${k} = ${q}`);
229
+ }
230
+ }
231
+ }
232
+
233
+ return lines.join('\n') + '\n';
234
+ }
235
+
236
+ // =============================================================================
237
+ // RC File Helpers
238
+ // =============================================================================
239
+
240
+ /** The directory that holds the profiles file (SUBSTRIO_CONFIG_DIR, else ~/.substrio). */
241
+ export function configDir() {
242
+ return process.env.SUBSTRIO_CONFIG_DIR || join(homedir(), '.substrio');
243
+ }
244
+
245
+ /** The profiles file: saved URLs, keys and tenants. */
246
+ export function rcPath() {
247
+ return join(configDir(), 'config.toml');
248
+ }
249
+
250
+ /** The directory in a project root where substrio keeps what is local to this checkout (never committed). */
251
+ export const PROJECT_STATE_DIR = '.substrio';
252
+
253
+ /** Where the loader looks for a project's functions and static files when substrio.toml names none. */
254
+ export const DEFAULT_FUNCTIONS_DIR = 'functions';
255
+ export const DEFAULT_PUBLIC_DIR = 'public';
256
+
257
+ /** The data directory of `substrio dev` in the project at `root`: its database, its keys, its log. */
258
+ export function devDataDir(root) {
259
+ return join(root, PROJECT_STATE_DIR, 'dev');
260
+ }
261
+
262
+ const LEGACY_RC = () => join(homedir(), '.substriorc');
263
+
264
+ /**
265
+ * Writes `text` to `path` readable by its owner only (0600 whatever the
266
+ * umask). Replaces the file atomically (a temporary file renamed over it), or
267
+ * with `exclusive` creates it and refuses an existing one.
268
+ */
269
+ export function writePrivateFile(path, text, { exclusive = false } = {}) {
270
+ const target = exclusive ? path : `${path}.${randomBytes(6).toString('hex')}.tmp`;
271
+ const fd = openSync(target, 'wx', 0o600);
272
+ try {
273
+ fchmodSync(fd, 0o600);
274
+ writeFileSync(fd, text);
275
+ fsyncSync(fd);
276
+ } catch (err) {
277
+ closeSync(fd);
278
+ rmSync(target, { force: true });
279
+ throw err;
280
+ }
281
+ closeSync(fd);
282
+ if (!exclusive) renameSync(target, path);
283
+ }
284
+
285
+ // The config directory, owner-only (0700); created when missing.
286
+ function privateDir() {
287
+ const dir = configDir();
288
+ mkdirSync(dir, { recursive: true, mode: 0o700 });
289
+ chmodSync(dir, 0o700);
290
+ return dir;
291
+ }
292
+
293
+ export function loadRC() {
294
+ const path = rcPath();
295
+ if (!existsSync(path) && existsSync(LEGACY_RC())) {
296
+ privateDir();
297
+ renameSync(LEGACY_RC(), path);
298
+ chmodSync(path, 0o600);
299
+ }
300
+ if (!existsSync(path)) return {};
301
+ try {
302
+ return parseTOML(readFileSync(path, 'utf-8'));
303
+ } catch { return {}; }
304
+ }
305
+
306
+ // The file holds keys: readable by its owner only, in an owner-only directory.
307
+ export function saveRC(rc) {
308
+ privateDir();
309
+ writePrivateFile(rcPath(), toTOML(rc));
310
+ }
311
+
312
+ // Saves profile `name`; it becomes the default when there is none, or with
313
+ // `makeDefault` (substrio login).
314
+ // `defaultIfNone` false leaves the default as it is even when there is none (the dev profile of `substrio dev`).
315
+ export function writeProfile(name, url, masterKey, tenant = null, { makeDefault = false, defaultIfNone = true, checked = true } = {}) {
316
+ const rc = loadRC();
317
+ if (!rc.profiles) rc.profiles = {};
318
+ rc.profiles[name] = tenant ? { url, master_key: masterKey, tenant } : { url, master_key: masterKey };
319
+ // A profile saved with no round trip (login --offline) says so; any later
320
+ // save that did reach the server writes it without the mark.
321
+ if (!checked) rc.profiles[name].checked = false;
322
+ if (makeDefault || (defaultIfNone && !rc.default_profile)) rc.default_profile = name;
323
+ saveRC(rc);
324
+ }
325
+
326
+ // Replaces the key of a saved profile (after `substrio key rotate`), keeping its
327
+ // URL. Returns false when there is no such profile.
328
+ /** The tenant a `substrio dev` without a tenant in substrio.toml uses. */
329
+ export const DEFAULT_DEV_TENANT = 'dev';
330
+ /** The end of the name of the profile `substrio dev` saves for its project's tenant. */
331
+ export const DEV_PROFILE_SUFFIX = '-dev';
332
+
333
+ /** The app's tenant under `substrio dev` (pure): the config's own tenant, else DEFAULT_DEV_TENANT. */
334
+ export const devTenantSlug = (config) => (config.tenant && config.tenant !== 'default' ? config.tenant : DEFAULT_DEV_TENANT);
335
+
336
+ /** The name of the dev profile of tenant `slug` (pure). */
337
+ export const devProfileName = (slug) => `${slug}${DEV_PROFILE_SUFFIX}`;
338
+
339
+ /**
340
+ * Saves (or updates, at each start: the port is new) the profile of the dev server of tenant `slug`: its
341
+ * URL and the tenant's dev key. It is never made the default. Returns the profile's name.
342
+ */
343
+ export function saveDevProfile(slug, url, key) {
344
+ const name = devProfileName(slug);
345
+ writeProfile(name, url, key, slug, { defaultIfNone: false });
346
+ return name;
347
+ }
348
+
349
+ /**
350
+ * What `substrio --dev` selects (the connection variables, as loadConfig reads them) for the project at `cwd`:
351
+ * the saved dev profile of its tenant, {SUBSTRIO_PROFILE, SUBSTRIO_URL, SUBSTRIO_TENANT, SUBSTRIO_MASTER_KEY}. Throws an Error
352
+ * naming the profile when `substrio dev` has not saved it yet.
353
+ */
354
+ export function devConnection(cwd) {
355
+ const slug = devTenantSlug(loadConfig(cwd));
356
+ const name = devProfileName(slug);
357
+ const profile = readProfile(name);
358
+ if (profile === null || !profile.url || !profile.master_key) {
359
+ throw new Error(`there is no dev profile "${name}": run "substrio dev" in this project first (it saves one)`);
360
+ }
361
+ return { SUBSTRIO_PROFILE: name, SUBSTRIO_URL: profile.url, SUBSTRIO_TENANT: slug, SUBSTRIO_MASTER_KEY: profile.master_key };
362
+ }
363
+
364
+ export function setProfileKey(name, masterKey) {
365
+ const rc = loadRC();
366
+ if (!rc.profiles?.[name]) return false;
367
+ rc.profiles[name].master_key = masterKey;
368
+ saveRC(rc);
369
+ return true;
370
+ }
371
+
372
+ // One saved profile, {url, master_key} with the URL's trailing slash dropped,
373
+ // or null when there is no such profile.
374
+ export function readProfile(name) {
375
+ const profile = loadRC().profiles?.[name];
376
+ if (!profile) return null;
377
+ return { url: (profile.url || '').replace(/\/+$/, ''), master_key: profile.master_key || '' };
378
+ }
379
+
380
+ export function removeProfile(name) {
381
+ const rc = loadRC();
382
+ if (rc.profiles && rc.profiles[name]) {
383
+ delete rc.profiles[name];
384
+ if (rc.default_profile === name) {
385
+ const remaining = Object.keys(rc.profiles || {});
386
+ rc.default_profile = remaining[0] || '';
387
+ }
388
+ saveRC(rc);
389
+ return true;
390
+ }
391
+ return false;
392
+ }
393
+
394
+ export function listProfiles() {
395
+ const rc = loadRC();
396
+ const profiles = rc.profiles || {};
397
+ const defaultName = rc.default_profile || '';
398
+ return Object.entries(profiles).map(([name, cfg]) => ({
399
+ name,
400
+ url: cfg.url || '',
401
+ isDefault: name === defaultName,
402
+ }));
403
+ }
404
+
405
+ // =============================================================================
406
+ // Config Loader
407
+ // =============================================================================
408
+
409
+ export function loadConfig(cwd = process.cwd()) {
410
+ const config = {
411
+ url: null,
412
+ tenant: 'default',
413
+ // true when a profile, substrio.toml, SUBSTRIO_TENANT or --tenant named
414
+ // the tenant; false for the 'default' fallback, which a command over
415
+ // the account's apps (substrio tenants) leaves off the wire.
416
+ tenant_named: false,
417
+ master_key: null,
418
+ functions_dir: DEFAULT_FUNCTIONS_DIR,
419
+ public_dir: DEFAULT_PUBLIC_DIR,
420
+ profile: null,
421
+ // [functions.<name>] tables: routes, main, [functions.<name>.vars]
422
+ functions: {},
423
+ // JSON file of the tenant's security rules, applied by `substrio deploy`
424
+ rules_file: null,
425
+ // Directory of the collections the app declares, one JSON file each (src/collections.mjs)
426
+ collections_dir: null,
427
+ // JSON file of the tenant's MCP tools (docs/mcp.md), set by `substrio deploy`
428
+ mcp_file: null,
429
+ // JSON file of the tenant's plans, counters and feature flags, set by `substrio deploy`
430
+ plans_file: null,
431
+ // [apps.offer] and [apps.use]: the functions the app offers other apps and the ones it calls, set by
432
+ // `substrio deploy` (apps.calls.set; substrio.apps.<app>.<entry>(...) in a function); [apps.query.<name>]
433
+ // and [apps.use_query]: the reads it offers and the ones it makes (substrio.apps.<app>.query.<name>(...))
434
+ apps: null,
435
+ // What `substrio push` builds and ships (src/manifest.mjs reads these):
436
+ // [build] steps, [site] parts and ignore, [[cron]], [[triggers]] and [queues.<name>].
437
+ build: null,
438
+ site: null,
439
+ cron: [],
440
+ triggers: [],
441
+ // [queues.<name>] tables: the durable queues the version declares (src/manifest.mjs)
442
+ queues: null,
443
+ // [roles.NAME] tables: the roles `substrio deploy` and `substrio dev` apply (src/identity.mjs)
444
+ roles: null,
445
+ // [permissions] table: the tenant's permission catalog `substrio deploy` sets (src/manifest.mjs's declaredPermissions)
446
+ permissions: null,
447
+ // [ledger] table: the tenant's ledger catalog `substrio deploy` declares (src/manifest.mjs's declaredLedger)
448
+ ledger: null,
449
+ // Settings tables a pushed version carries, such as [auth] (src/settings.mjs)
450
+ ...Object.fromEntries(SETTINGS_TABLES.map(({ table }) => [table, null])),
451
+ };
452
+
453
+ const rc = loadRC();
454
+
455
+ // 1. Load substrio.toml (project config)
456
+ let toml = {};
457
+ const tomlPath = join(cwd, 'substrio.toml');
458
+ if (existsSync(tomlPath)) {
459
+ try {
460
+ toml = parseTOML(readFileSync(tomlPath, 'utf-8'));
461
+ } catch (err) {
462
+ console.error(`⚠ Error reading substrio.toml: ${err.message}`);
463
+ }
464
+ }
465
+ const flat = toml.project ? { ...toml, ...toml.project } : toml;
466
+
467
+ // 2. Resolve profile: SUBSTRIO_PROFILE, then substrio.toml, then the profiles file's default
468
+ const profileName = process.env.SUBSTRIO_PROFILE || flat.profile || rc.default_profile || null;
469
+ let profileCfg = null;
470
+ if (profileName && rc.profiles && rc.profiles[profileName]) {
471
+ profileCfg = rc.profiles[profileName];
472
+ config.profile = profileName;
473
+ }
474
+
475
+ // 3. Apply layers: profile < substrio.toml < env vars (backward compat: flat rc)
476
+ // Layer 0: the profiles file's flat keys
477
+ if (rc.url) config.url = rc.url;
478
+ if (rc.master_key) config.master_key = rc.master_key;
479
+
480
+ // Layer 1: profile values
481
+ if (profileCfg) {
482
+ if (profileCfg.url) config.url = profileCfg.url;
483
+ if (profileCfg.master_key) config.master_key = profileCfg.master_key;
484
+ if (profileCfg.tenant) config.tenant = profileCfg.tenant;
485
+ }
486
+
487
+ // Layer 2: substrio.toml direct values
488
+ if (flat.url) config.url = flat.url;
489
+ if (flat.tenant) config.tenant = flat.tenant;
490
+ if (flat.master_key) config.master_key = flat.master_key;
491
+ if (flat.functions_dir) config.functions_dir = flat.functions_dir;
492
+ if (flat.public_dir) config.public_dir = flat.public_dir;
493
+ if (flat.functions && typeof flat.functions === 'object') config.functions = flat.functions;
494
+ if (flat.rules_file) config.rules_file = flat.rules_file;
495
+ if (flat.collections_dir) config.collections_dir = flat.collections_dir;
496
+ if (flat.mcp_file) config.mcp_file = flat.mcp_file;
497
+ if (flat.plans_file) config.plans_file = flat.plans_file;
498
+ if (flat.apps !== undefined) config.apps = flat.apps;
499
+ if (flat.site_ignore !== undefined) config.site_ignore = flat.site_ignore;
500
+ if (flat.build !== undefined) config.build = flat.build;
501
+ if (flat.site !== undefined) config.site = flat.site;
502
+ if (flat.cron !== undefined) config.cron = flat.cron;
503
+ if (flat.triggers !== undefined) config.triggers = flat.triggers;
504
+ if (flat.queues !== undefined) config.queues = flat.queues;
505
+ if (flat.roles !== undefined) config.roles = flat.roles;
506
+ if (flat.permissions !== undefined) config.permissions = flat.permissions;
507
+ if (flat.ledger !== undefined) config.ledger = flat.ledger;
508
+ for (const { table } of SETTINGS_TABLES) if (flat[table] !== undefined) config[table] = flat[table];
509
+
510
+ // Layer 3: environment variables override everything
511
+ if (process.env.SUBSTRIO_URL) config.url = process.env.SUBSTRIO_URL;
512
+ if (process.env.SUBSTRIO_TENANT) config.tenant = process.env.SUBSTRIO_TENANT;
513
+ config.tenant_named = Boolean(profileCfg?.tenant || flat.tenant || process.env.SUBSTRIO_TENANT);
514
+ if (process.env.SUBSTRIO_MASTER_KEY) config.master_key = process.env.SUBSTRIO_MASTER_KEY;
515
+ if (process.env.SUBSTRIO_FUNCTIONS_DIR) config.functions_dir = process.env.SUBSTRIO_FUNCTIONS_DIR;
516
+ if (process.env.SUBSTRIO_PUBLIC_DIR) config.public_dir = process.env.SUBSTRIO_PUBLIC_DIR;
517
+
518
+ // Normalize URL (strip trailing slash)
519
+ if (config.url) config.url = config.url.replace(/\/+$/, '');
520
+
521
+ return config;
522
+ }
523
+
524
+ /**
525
+ * Validate that required config fields are present.
526
+ * Returns null if valid, or an error message string.
527
+ */
528
+ export function validateConfig(config, requireAuth = true) {
529
+ if (!config.url) {
530
+ return 'Server URL not configured. Run "substrio login" or set url in substrio.toml / SUBSTRIO_URL env var.';
531
+ }
532
+ if (requireAuth && !config.master_key) {
533
+ return 'Master key not configured. Run "substrio login" or set master_key in substrio.toml / SUBSTRIO_MASTER_KEY env var.';
534
+ }
535
+ return null;
536
+ }
537
+
538
+ /**
539
+ * Returns a human-readable summary of resolved config sources.
540
+ */
541
+ export function getConfigSources(cwd = process.cwd()) {
542
+ const sources = {};
543
+ const rc = loadRC();
544
+ const tomlPath = join(cwd, 'substrio.toml');
545
+ const hasToml = existsSync(tomlPath);
546
+ let toml = {};
547
+ if (hasToml) {
548
+ try { toml = parseTOML(readFileSync(tomlPath, 'utf-8')); } catch { /* an unreadable substrio.toml adds nothing to the summary */ }
549
+ }
550
+ const flat = toml.project ? { ...toml, ...toml.project } : toml;
551
+ const profileName = process.env.SUBSTRIO_PROFILE || flat.profile || rc.default_profile || null;
552
+
553
+ // Determine source for each key
554
+ for (const key of ['url', 'tenant', 'master_key']) {
555
+ const envKey = `SUBSTRIO_${key.toUpperCase()}`;
556
+ if (process.env[envKey]) {
557
+ sources[key] = `env ${envKey}`;
558
+ } else if (flat[key]) {
559
+ sources[key] = 'substrio.toml';
560
+ } else if (profileName && rc.profiles?.[profileName]?.[key]) {
561
+ sources[key] = `profile "${profileName}"`;
562
+ } else if (rc[key]) {
563
+ sources[key] = rcPath();
564
+ } else {
565
+ sources[key] = 'default';
566
+ }
567
+ }
568
+ return sources;
569
+ }