@substrio/cli 0.1.0 → 0.1.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/README.md CHANGED
@@ -23,6 +23,7 @@ substrio deploy
23
23
 
24
24
  - `substrio init --url URL --tenant SLUG --yes` scaffolds a project (`substrio.toml`, no key stored in it).
25
25
  - `substrio login [--url URL] --key - [--tenant SLUG]` reads the key from stdin (never from argv, never in shell history) and saves a profile. Without `--url` the profile is the hosted platform, `https://mcp.substrio.com`. Set `--profile NAME` to save under a name other than the default.
26
+ - `substrio connect CODE|- [--url URL] [--profile NAME] [--show]` redeems a one-time connect code (`substrio_ae_...`) from the console's Agents page: the agent session is saved as profile `agent`, owner-only, and never printed (`--show` prints the secret alone on stdout, for `$(...)`).
26
27
  - `substrio status` reports server health and the config in use.
27
28
  - `substrio dev [--attach URL] [--server PATH] [--port N] [--data DIR] [--no-build] [--no-logs]` runs the app on a local server with hot reload. Data and keys stay under `.substrio/dev` (or `--data DIR`).
28
29
  - `substrio deploy [--dry-run]` deploys the declared collections, functions and rules.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@substrio/cli",
3
- "version": "0.1.0",
3
+ "version": "0.1.2",
4
4
  "description": "Substrio CLI: deploy functions, manage secrets, stream logs",
5
5
  "homepage": "https://github.com/illtronicevents-a11y/substrio#readme",
6
6
  "bugs": "https://github.com/illtronicevents-a11y/substrio/issues",
@@ -12,7 +12,7 @@
12
12
  "type": "module",
13
13
  "sideEffects": false,
14
14
  "bin": {
15
- "substrio": "./bin/substrio.mjs"
15
+ "substrio": "bin/substrio.mjs"
16
16
  },
17
17
  "exports": {
18
18
  "./test": "./src/testing/index.mjs",
@@ -26,7 +26,7 @@
26
26
  "src/"
27
27
  ],
28
28
  "dependencies": {
29
- "@substrio/sdk": "^0.1.0",
29
+ "@substrio/sdk": "^0.1.1",
30
30
  "esbuild": "^0.25.10"
31
31
  },
32
32
  "engines": {
package/src/apply.mjs CHANGED
@@ -22,7 +22,7 @@ import { OpError, callOperation } from './commands/op.mjs';
22
22
  import { loadConfig, parseTOML } from './config.mjs';
23
23
  import { COLLECTIONS_MEMBER, checkVersionCarriesCollections, versionCollectionSteps } from './collections.mjs';
24
24
  import { blobsByHash, collectApp, declaredFunction, declaredLedger, declaredPermissions, manifestOf, versionBody, withSiteVars } from './manifest.mjs';
25
- import { carrySettings, checkSettings, declaredPlans, declaredSettings } from './settings.mjs';
25
+ import { carrySettings, checkSettings, declaredDashboard, declaredPlans, declaredSettings } from './settings.mjs';
26
26
 
27
27
  /** What the server says of a source it checked (function.check): the severity of a diagnostic. */
28
28
  export const Severity = Object.freeze({
@@ -90,7 +90,7 @@ export function runBuild(steps, cwd, announce = () => {}) {
90
90
  * `functionCache` is forwarded to collectApp (substrio dev's Reloader keeps one
91
91
  * across applies; substrio push passes none, so every function is built fresh).
92
92
  * `resolveEsbuild` is forwarded to collectApp, then to every buildFunction call it makes.
93
- * Returns {app, settings, plans, collections, body, byHash}; throws a CliError.
93
+ * Returns {app, settings, plans, dashboard, collections, body, byHash}; throws a CliError.
94
94
  */
95
95
  export async function loadApp(config, manifest, cwd, message, functionCache, resolveEsbuild) {
96
96
  let app;
@@ -103,11 +103,13 @@ export async function loadApp(config, manifest, cwd, message, functionCache, res
103
103
  let plans;
104
104
  let permissions;
105
105
  let ledger;
106
+ let dashboard;
106
107
  try {
107
108
  settings = declaredSettings(config);
108
109
  plans = declaredPlans(config, cwd);
109
110
  permissions = declaredPermissions(config);
110
111
  ledger = declaredLedger(config);
112
+ dashboard = declaredDashboard(config, cwd);
111
113
  } catch (err) {
112
114
  throw new CliError(`substrio.toml: ${err.message}`);
113
115
  }
@@ -116,7 +118,10 @@ export async function loadApp(config, manifest, cwd, message, functionCache, res
116
118
  // goes live (append only), so a promote switches them with the code that uses them.
117
119
  if (permissions !== null) body.permissions = permissions;
118
120
  if (ledger !== null) body.ledger = ledger;
119
- return { app, settings, plans, collections: app.collections, body, byHash: blobsByHash(app) };
121
+ // APP-DASH: the dashboard rides the version too; the server checks it against the
122
+ // version's own routes (DASHBOARD_INVALID naming the path).
123
+ if (dashboard !== null) body.dashboard = dashboard;
124
+ return { app, settings, plans, dashboard, collections: app.collections, body, byHash: blobsByHash(app) };
120
125
  }
121
126
 
122
127
  /**
@@ -51,6 +51,13 @@ const FLOWS = [
51
51
  ],
52
52
  run: async ({ args }) => (await importCmd('login.mjs')).cmdLogin(args),
53
53
  },
54
+ {
55
+ names: ['connect'], section: 'START', needs: Needs.NOTHING,
56
+ usage: [
57
+ ['connect CODE|- [--url URL] [--profile NAME] [--default] [--show]', 'An agent redeems the one-time connect code (substrio_ae_...) from the console\'s Agents page: the session that acts as its owner is saved as profile NAME (default agent), owner-only, and the MCP setup is printed. The secret is never printed, except with --show: stdout then carries it alone, for $(...) in an MCP client\'s setup'],
58
+ ],
59
+ run: async ({ args }) => (await importCmd('connect.mjs')).cmdConnect(args),
60
+ },
54
61
  {
55
62
  names: ['whoami'], section: 'START', needs: Needs.CONFIG,
56
63
  usage: [['whoami', 'Tenant, server and profile in use, and where each came from']],
@@ -0,0 +1,107 @@
1
+ /**
2
+ * Substrio CLI: `substrio connect` (AGENT-CODE-CONNECT): redeem a one-time connect code from the console's
3
+ * Agents page ("Connect an agent", substrio_ae_...) for an agent session acting as its owner.
4
+ *
5
+ * Usage:
6
+ * substrio connect CODE [--url URL] [--profile NAME] [--default] [--show]
7
+ * substrio connect - ... The code read from stdin (kept out of argv and shell history)
8
+ *
9
+ * One request: POST URL/v1/_agent/exchange {code} (agent.enroll.exchange), the server
10
+ * https://mcp.substrio.com unless --url names another. The answer is a bearer session; its secret is
11
+ * saved as profile NAME (default "agent") in the owner-only profiles file, exactly as `substrio login`
12
+ * saves a key, and becomes the default profile only with --default (or when there is none). The code is
13
+ * spent by the exchange and never printed back. The secret is never printed either, except with --show:
14
+ * then stdout carries the secret alone on one line, for `$(...)` in an MCP client's setup command, and
15
+ * everything else goes to stderr.
16
+ */
17
+
18
+ import { CliError, ErrorCode, emit, errorText, fail, globals } from '../cli.mjs';
19
+ import { rcPath, writeProfile } from '../config.mjs';
20
+ import { DEFAULT_URL } from './login.mjs';
21
+ import { MCP_KEY_ENV, MCP_PATH } from './mcp-config.mjs';
22
+
23
+ /** The profile a connect saves when --profile names none. */
24
+ export const CONNECT_PROFILE = 'agent';
25
+ /** The platform route that redeems a connect code (agent.enroll.exchange). */
26
+ export const EXCHANGE_PATH = '/v1/_agent/exchange';
27
+
28
+ const USAGE = 'substrio connect CODE|- [--url URL] [--profile NAME] [--default] [--show]';
29
+
30
+ const C = { reset: '\x1b[0m', bold: '\x1b[1m', dim: '\x1b[2m', green: '\x1b[92m' };
31
+
32
+ /**
33
+ * The setup lines an MCP client needs for the session saved as `profile` on the server at `url` (pure):
34
+ * none holds the secret. `export` writes it owner-only as MCP_KEY_ENV; `header` is what the client sends;
35
+ * `claude` is Claude Code's one-step form, which redeems a NEW code straight into its config.
36
+ */
37
+ export function connectSetup(url, profile) {
38
+ const endpoint = `${url}${MCP_PATH}`;
39
+ const npx = url === DEFAULT_URL ? 'npx @substrio/cli connect' : `npx @substrio/cli connect --url ${url}`;
40
+ return {
41
+ endpoint,
42
+ export: `substrio --profile ${profile} key export --env .substrio.env`,
43
+ header: `Authorization: Bearer \${${MCP_KEY_ENV}}`,
44
+ claude: `claude mcp add --transport http substrio ${endpoint} --header "Authorization: Bearer $(${npx} CODE --show)"`,
45
+ };
46
+ }
47
+
48
+ // The whole of stdin, one trailing newline trimmed (never printed, never logged).
49
+ async function readStdin() {
50
+ const chunks = [];
51
+ for await (const chunk of process.stdin) chunks.push(chunk);
52
+ return Buffer.concat(chunks).toString('utf-8').trim();
53
+ }
54
+
55
+ export async function cmdConnect(args) {
56
+ let code = null;
57
+ let url = DEFAULT_URL;
58
+ let makeDefault = false;
59
+ let show = false;
60
+ for (let i = 0; i < args.length; i++) {
61
+ if (args[i] === '--url' && args[i + 1]) url = args[++i];
62
+ else if (args[i] === '--default') makeDefault = true;
63
+ else if (args[i] === '--show') show = true;
64
+ else if (code === null && !args[i].startsWith('--')) code = args[i];
65
+ else return fail(`unexpected argument "${args[i]}"`, USAGE);
66
+ }
67
+ if (code === null || code === '-') code = await readStdin();
68
+ if (!code) return fail('a connect code is required: the substrio_ae_... code from the console\'s Agents page', USAGE, ErrorCode.INVALID_ARGUMENT);
69
+ url = url.replace(/\/+$/, '');
70
+ const profile = globals.profile || CONNECT_PROFILE;
71
+
72
+ let response;
73
+ try {
74
+ response = await fetch(`${url}${EXCHANGE_PATH}`, {
75
+ method: 'POST',
76
+ headers: { 'Content-Type': 'application/json' },
77
+ body: JSON.stringify({ code }),
78
+ });
79
+ } catch (err) {
80
+ return fail(new CliError(`Could not reach ${url}: ${err.message}`, { code: ErrorCode.NETWORK_ERROR }), USAGE);
81
+ }
82
+ const data = await response.json().catch(() => null);
83
+ if (!response.ok || typeof data?.key !== 'string') {
84
+ return fail(CliError.fromResponse(`The code was not redeemed: ${errorText(data, response.status)}`, data, response.status),
85
+ 'Ask your owner for a new code on the console\'s Agents page');
86
+ }
87
+
88
+ writeProfile(profile, url, data.key, null, { makeDefault, checked: true });
89
+ if (show) process.stdout.write(`${data.key}\n`);
90
+ const setup = connectSetup(url, profile);
91
+ const summary = {
92
+ profile, url, path: rcPath(), session_id: data.id, kind: data.kind, account_id: data.account_id,
93
+ expires_at: data.expires_at, mcp_url: setup.endpoint,
94
+ };
95
+ const human = () => {
96
+ const until = Number.isFinite(data.expires_at) ? new Date(data.expires_at * 1000).toISOString() : 'its end';
97
+ const log = show ? console.error : console.log;
98
+ log(`${C.green}✓${C.reset} Connected: agent session ${data.id} acts as your owner in account ${data.account_id} until ${until} (${data.kind}).`);
99
+ log(` The secret is saved as profile ${C.bold}${profile}${C.reset} in ${rcPath()} (owner-only)${show ? '' : ' and is not shown'}.`);
100
+ log(`${C.dim} An MCP client: ${setup.export} writes ${MCP_KEY_ENV}, owner-only;`);
101
+ log(` the client sends "${setup.header}" to ${setup.endpoint}.`);
102
+ log(` Claude Code in one step, with a new code: ${setup.claude}${C.reset}`);
103
+ };
104
+ // --show keeps stdout to the secret alone: the summary goes to stderr as text, never as JSON on stdout.
105
+ if (show) human();
106
+ else emit(summary, human);
107
+ }
@@ -188,8 +188,10 @@ export async function cmdDeploy(api, config, args) {
188
188
  if (!existsSync(functionsDir) && !named) {
189
189
  console.log(`${C.yellow}⚠ No functions/ directory found. Skipping function deployment.${C.reset}`);
190
190
  } else {
191
- const files = existsSync(functionsDir) ? readdirSync(functionsDir).filter(f => f.endsWith('.js')) : [];
192
- const found = new Set(files.map(f => f.replace(/\.js$/, '')));
191
+ // WASM-P1: a function is functions/<name>.js, or functions/<name>.rs (its Rust source, sent as "rust":
192
+ // the server compiles it).
193
+ const files = existsSync(functionsDir) ? readdirSync(functionsDir).filter(f => FUNCTION_FILE.test(f)) : [];
194
+ const found = new Set(files.map(f => f.replace(FUNCTION_FILE, '')));
193
195
 
194
196
  if (named) {
195
197
  const known = new Set([...found, ...Object.keys(config.functions)]);
@@ -201,22 +203,25 @@ export async function cmdDeploy(api, config, args) {
201
203
  } else {
202
204
  const missing = Object.keys(config.functions).filter(name => !found.has(name));
203
205
  if (missing.length > 0) {
204
- fail(`substrio.toml declares ${missing.join(', ')} but ${config.functions_dir}/ has no ${missing.map(n => n + '.js').join(', ')}`,
206
+ fail(`substrio.toml declares ${missing.join(', ')} but ${config.functions_dir}/ has no ${missing.map(n => n + '.js or .rs').join(', ')}`,
205
207
  null, ErrorCode.FILE_NOT_FOUND);
206
208
  }
207
209
  }
208
210
 
209
- const toDeploy = named ? names : files.map(f => f.replace(/\.js$/, ''));
211
+ const toDeploy = named ? names : files.map(f => f.replace(FUNCTION_FILE, ''));
210
212
  if (toDeploy.length === 0) {
211
- console.log(`${C.dim} No .js files in ${config.functions_dir}/. Skipping.${C.reset}`);
213
+ console.log(`${C.dim} No .js or .rs files in ${config.functions_dir}/. Skipping.${C.reset}`);
212
214
  } else {
213
215
  console.log(`${C.bold}Functions${C.reset} ${C.dim}(${toDeploy.length} file${toDeploy.length > 1 ? 's' : ''})${C.reset}`);
214
216
 
215
217
  for (const name of toDeploy) {
216
218
  const filePath = join(functionsDir, `${name}.js`);
219
+ const rustPath = join(functionsDir, `${name}.rs`);
217
220
  let built;
218
221
  try {
219
- built = await buildFunction({ entry: filePath, projectDir: cwd });
222
+ built = existsSync(rustPath) && !existsSync(filePath)
223
+ ? rustSource(rustPath)
224
+ : await buildFunction({ entry: filePath, projectDir: cwd });
220
225
  } catch (err) {
221
226
  // Its exit code is exitCodeFor's, the same one a server refusal of this
222
227
  // function would give: run.failed keeps the first failure for that.
@@ -235,7 +240,7 @@ export async function cmdDeploy(api, config, args) {
235
240
  continue;
236
241
  }
237
242
 
238
- const step = await run.send('function.deploy', deployment(name, built.code, config.functions[name], cwd, publicDir));
243
+ const step = await run.send('function.deploy', deployment(name, built, config.functions[name], cwd, publicDir));
239
244
  report.functions.push({ name, size_bytes: built.bytes, inputs: built.inputs, ...step });
240
245
  if (step.ok) {
241
246
  const hash = step.result?.content_hash ? `, content_hash ${step.result.content_hash}` : '';
@@ -471,10 +476,21 @@ export async function sendDeclared(run, report, { key, operation, body, would, s
471
476
  // The deploy request for one function: its source plus the routes, entry point
472
477
  // and vars substrio.toml declares for it. Only what is declared is sent, so a
473
478
  // function substrio.toml says nothing about keeps what it had.
474
- function deployment(name, script, declared = {}, cwd, siteDir) {
479
+ function deployment(name, built, declared = {}, cwd, siteDir) {
475
480
  // VERSION-VALUES: a deploy makes no version, so site_vars are filled here from the site folder this
476
481
  // deploy publishes, as the server fills them from a version's own site.
477
- return { name, script, ...withSiteVars(declaredFunction(name, declared, cwd), siteDir) };
482
+ // WASM-P1: Rust goes as "rust" (its source: the server compiles it), a script as "script".
483
+ const source = built.rust !== undefined ? { rust: built.rust } : { script: built.code };
484
+ return { name, ...source, ...withSiteVars(declaredFunction(name, declared, cwd), siteDir) };
485
+ }
486
+
487
+ // WASM-P1: a function's file: <name>.js, or <name>.rs (Rust; docs/runtime/wasm-functions.md).
488
+ const FUNCTION_FILE = /\.(js|rs)$/;
489
+
490
+ // A .rs function as deploy sends it: its source as written, compiled by the server at deploy. Nothing is built here.
491
+ function rustSource(path) {
492
+ const rust = readFileSync(path, 'utf8');
493
+ return { rust, bytes: Buffer.byteLength(rust), inputs: [path], esbuildAvailable: true };
478
494
  }
479
495
 
480
496
  // Whether `step`'s failure of `built` looks like the server refusing an import it cannot resolve:
@@ -272,15 +272,20 @@ export const GROUPS = {
272
272
  live_view: {}, log_alert: {}, log_drain: {},
273
273
  // mcp.* runs by its words; config is the CLI's own word, for the client side of the same endpoint.
274
274
  mcp: { config: { flow: GroupFlow.MCP_CONFIG, valueFlags: ['--client', '--name', '--env'], note: 'a ready MCP client config for this profile\'s app (--client claude|cursor|generic); the key as an environment variable reference' } },
275
- // host.* and node_label.* (the operator's host manifest) run by their words; host remove and
276
- // node_label delete are gated by their own trailing word, as every other.
277
- host: {}, node_label: {},
275
+ // host.*, node_label.* and region.* (the operator's host manifest and its regions; region choices is an
276
+ // account's) run by their words; host remove, node_label delete and region remove are gated by their own
277
+ // trailing word, as every other.
278
+ host: {}, node_label: {}, region: {},
278
279
  // TEST-HOSTS: root_domain.* (the platform admin's roots) runs by its words: substrio root_domain add
279
280
  // domain=onstax.com. env.host.* runs through the env command (commands/env.mjs), beside its pull and push.
280
281
  root_domain: {},
281
282
  // BILLING, FEATURED-APPS, MCP-MODULES, PLATFORM-UPGRADES: their operations run by their words: substrio billing
282
283
  // cart create, substrio featured enable, substrio package add, substrio release list.
283
284
  billing: {}, featured: {}, package: {}, release: {},
285
+ // SUBSCRIPTIONS: substrio subscriptions mine, substrio subscriptions cancel id=7.
286
+ subscriptions: {},
287
+ // APP-DASH: substrio dashboard get id=7 slug=shop.
288
+ dashboard: {},
284
289
  mesh: {}, meta: {}, namespace: {}, oauth: {}, object: {}, observability: {},
285
290
  oidc: {}, plans: {}, queue: {}, rag: {}, rate_limit: {}, sql: {}, index: {},
286
291
  realtime: {}, role: {}, route: {}, search: {}, settings: {}, sync: {}, system: {}, thread: {}, user: {},
@@ -294,9 +299,9 @@ export const REGISTRY_GROUPS = Object.freeze({
294
299
  function: 'functions', graph: 'graph', host: 'host', index: 'index', job: 'job', kv: 'kv', ledger: 'ledger', live_view: 'live_view',
295
300
  log_alert: 'log_alert', log_drain: 'log_drain', mcp: 'mcp', mesh: 'mesh', meta: 'meta', namespace: 'namespace', node_label: 'node_label', oauth: 'oauth',
296
301
  root_domain: 'root_domain', env: 'env', apps: 'tenants', billing: 'billing', featured: 'featured', package: 'package',
297
- release: 'release',
302
+ release: 'release', subscriptions: 'subscriptions', dashboard: 'dashboard',
298
303
  object: 'object', observability: 'observability', oidc: 'oidc', permissions: 'permissions', plans: 'plans', queue: 'queue',
299
- platform: 'platform', query: 'data', rag: 'rag', rate_limit: 'rate_limit', realtime: 'realtime',
304
+ platform: 'platform', query: 'data', rag: 'rag', rate_limit: 'rate_limit', realtime: 'realtime', region: 'region',
300
305
  branch: 'branch', tag: 'tag', ref: 'ref', merge: 'merge',
301
306
  role: 'role', route: 'route', sandbox: 'sandbox', schedule: 'cron', schema: 'collections',
302
307
  search: 'search', secret: 'secrets', source: 'source', security: 'rules', settings: 'settings', site: 'site',
@@ -304,6 +309,12 @@ export const REGISTRY_GROUPS = Object.freeze({
304
309
  user: 'user', version: 'versions', view: 'views', webhook: 'webhooks',
305
310
  });
306
311
 
312
+ // The operations a flow runs by a word of its own, ahead of its registry words: tenants show SLUG (and get
313
+ // SLUG) is the operator's tenant.get, while tenants get with no slug is the app's own app.get by its words.
314
+ export const FLOW_OPERATIONS = Object.freeze({
315
+ tenants: Object.freeze({ show: 'tenant.get' }),
316
+ });
317
+
307
318
  /** The registry groups `command` runs (pure). */
308
319
  export function registryGroupsOf(command) {
309
320
  return Object.keys(REGISTRY_GROUPS).filter((group) => REGISTRY_GROUPS[group] === command);
@@ -144,7 +144,7 @@ export async function cmdPush(api, config, args) {
144
144
  // the manifest describes: the version's content (and so its hash) is the
145
145
  // same whichever branch it is committed on.
146
146
  if (branch) loaded.body.branch = branch;
147
- const { app, settings, plans, collections, body, byHash } = loaded;
147
+ const { app, settings, plans, dashboard, collections, body, byHash } = loaded;
148
148
  const total = [...byHash.values()].reduce((sum, f) => sum + f.size, 0);
149
149
  const siteFiles = app.site?.files.length ?? 0;
150
150
  console.log(` ${app.functions.length} function${app.functions.length === 1 ? '' : 's'}` +
@@ -156,6 +156,7 @@ export async function cmdPush(api, config, args) {
156
156
  ` ${C.dim}(${byHash.size} blobs, ${formatSize(total)})${C.reset}`);
157
157
  for (const s of settings) console.log(` ${s.label} [${s.table}] ${Object.keys(s.values).join(', ')}`);
158
158
  if (plans !== null) console.log(` Plans from ${config.plans_file} (${plans.plans?.length ?? 0})`);
159
+ if (dashboard !== null) console.log(` Dashboard from ${config.dashboard_file} (${dashboard.nav?.length ?? 0})`);
159
160
  if (app.site?.replaced.length) {
160
161
  const shown = app.site.replaced.slice(0, 5).join(', ');
161
162
  const more = app.site.replaced.length > 5 ? ` and ${app.site.replaced.length - 5} more` : '';
@@ -23,6 +23,7 @@
23
23
 
24
24
  import { ErrorCode, confirmDestructive, emit, emitIssuedKey, fail } from '../cli.mjs';
25
25
  import { cmdRegistry } from './groups-runtime.mjs';
26
+ import { FLOW_OPERATIONS } from './groups.mjs';
26
27
  import { OpError, callOperation, invokeOperation, loadOperations, runOperation } from './op.mjs';
27
28
  import { listProfiles, writeProfile } from '../config.mjs';
28
29
 
@@ -55,8 +56,11 @@ export async function cmdTenants(api, config, args) {
55
56
  case 'list':
56
57
  case 'ls':
57
58
  return tenantsList(api, args.slice(1));
58
- case 'show':
59
59
  case 'get':
60
+ // With no slug, the app's own record (app.get, its words); with one, the operator's read as show.
61
+ if (!args[1] || args[1].startsWith('-')) return cmdRegistry(api, 'tenants', args);
62
+ // falls through
63
+ case 'show':
60
64
  return tenantsShow(api, args.slice(1));
61
65
  case 'create':
62
66
  case 'new':
@@ -94,7 +98,7 @@ function printTenants(data) {
94
98
 
95
99
  async function tenantsShow(api, args) {
96
100
  if (!args[0] || args[0].startsWith('--')) fail(`Usage: ${USAGE}`);
97
- return runOperation(api, 'tenant.get', args.slice(1), { positional: [args[0]] });
101
+ return runOperation(api, FLOW_OPERATIONS.tenants.show, args.slice(1), { positional: [args[0]] });
98
102
  }
99
103
 
100
104
  // The account id of the profile's key (auth.whoami), when it is an account
package/src/config.mjs CHANGED
@@ -428,6 +428,8 @@ export function loadConfig(cwd = process.cwd()) {
428
428
  mcp_file: null,
429
429
  // JSON file of the tenant's plans, counters and feature flags, set by `substrio deploy`
430
430
  plans_file: null,
431
+ // APP-DASH: JSON file of the app's console dashboard (docs/dashboard.md), carried in the version `substrio push` makes
432
+ dashboard_file: null,
431
433
  // [apps.offer] and [apps.use]: the functions the app offers other apps and the ones it calls, set by
432
434
  // `substrio deploy` (apps.calls.set; substrio.apps.<app>.<entry>(...) in a function); [apps.query.<name>]
433
435
  // and [apps.use_query]: the reads it offers and the ones it makes (substrio.apps.<app>.query.<name>(...))
@@ -495,6 +497,7 @@ export function loadConfig(cwd = process.cwd()) {
495
497
  if (flat.collections_dir) config.collections_dir = flat.collections_dir;
496
498
  if (flat.mcp_file) config.mcp_file = flat.mcp_file;
497
499
  if (flat.plans_file) config.plans_file = flat.plans_file;
500
+ if (flat.dashboard_file) config.dashboard_file = flat.dashboard_file;
498
501
  if (flat.apps !== undefined) config.apps = flat.apps;
499
502
  if (flat.site_ignore !== undefined) config.site_ignore = flat.site_ignore;
500
503
  if (flat.build !== undefined) config.build = flat.build;
package/src/settings.mjs CHANGED
@@ -4,6 +4,7 @@
4
4
  * push, the app on promote, the older ones again on a rollback):
5
5
  *
6
6
  * plans_file = "plans.json" # version member "plans", as POST /v1/_plans takes
7
+ * dashboard_file = "dashboard.json" # version member "dashboard" (docs/dashboard.md)
7
8
  * [auth] # version member "auth", as PUT /v1/_auth/settings takes
8
9
  * email_function = "send_auth_mail"
9
10
  * reset_url = "https://app.example/reset?token={token}"
@@ -109,6 +110,30 @@ export function declaredPlans(config, cwd) {
109
110
  }
110
111
  }
111
112
 
113
+ /** The largest dashboard file the server takes (types::DashboardPolicy::FILE_MAX_BYTES). */
114
+ export const DASHBOARD_FILE_MAX_BYTES = 64 * 1024;
115
+
116
+ /**
117
+ * APP-DASH: the dashboard substrio.toml's dashboard_file names, parsed (the version
118
+ * member "dashboard"; docs/dashboard.md), or null when it names none. Throws when
119
+ * the file is missing, larger than the server takes, or not JSON. Its fields and
120
+ * routes are the server's to check (DASHBOARD_INVALID naming the path).
121
+ */
122
+ export function declaredDashboard(config, cwd) {
123
+ if (!config.dashboard_file) return null;
124
+ const path = resolve(cwd, config.dashboard_file);
125
+ if (!existsSync(path)) throw new Error(`dashboard_file ${config.dashboard_file} does not exist`);
126
+ const text = readFileSync(path, 'utf-8');
127
+ if (Buffer.byteLength(text) > DASHBOARD_FILE_MAX_BYTES) {
128
+ throw new Error(`dashboard_file ${config.dashboard_file} is larger than ${DASHBOARD_FILE_MAX_BYTES} bytes`);
129
+ }
130
+ try {
131
+ return JSON.parse(text);
132
+ } catch (err) {
133
+ throw new Error(`dashboard_file ${config.dashboard_file} is not JSON: ${err.message}`);
134
+ }
135
+ }
136
+
112
137
  /** Puts the declared settings and plans into a version body as its members. */
113
138
  export function carrySettings(body, declared, plans) {
114
139
  for (const setting of declared) body[setting.member] = setting.values;