@substrio/cli 0.1.1 → 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 +1 -0
- package/package.json +2 -2
- package/src/apply.mjs +8 -3
- package/src/command-table.mjs +7 -0
- package/src/commands/connect.mjs +107 -0
- package/src/commands/deploy.mjs +25 -9
- package/src/commands/groups.mjs +16 -5
- package/src/commands/push.mjs +2 -1
- package/src/commands/tenants.mjs +6 -2
- package/src/config.mjs +3 -0
- package/src/settings.mjs +25 -0
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.
|
|
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",
|
|
@@ -26,7 +26,7 @@
|
|
|
26
26
|
"src/"
|
|
27
27
|
],
|
|
28
28
|
"dependencies": {
|
|
29
|
-
"@substrio/sdk": "^0.1.
|
|
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
|
-
|
|
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
|
/**
|
package/src/command-table.mjs
CHANGED
|
@@ -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
|
+
}
|
package/src/commands/deploy.mjs
CHANGED
|
@@ -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
|
-
|
|
192
|
-
|
|
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(
|
|
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 =
|
|
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
|
|
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,
|
|
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
|
-
|
|
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:
|
package/src/commands/groups.mjs
CHANGED
|
@@ -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
|
|
276
|
-
// node_label delete are gated by their own
|
|
277
|
-
|
|
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);
|
package/src/commands/push.mjs
CHANGED
|
@@ -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` : '';
|
package/src/commands/tenants.mjs
CHANGED
|
@@ -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,
|
|
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;
|