@substrio/cli 0.0.0-stage → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +177 -0
- package/README.md +66 -2
- package/bin/substrio.mjs +91 -0
- package/package.json +44 -4
- package/src/api.mjs +107 -0
- package/src/apply.mjs +204 -0
- package/src/blobs.mjs +102 -0
- package/src/cli.mjs +405 -0
- package/src/collections.mjs +701 -0
- package/src/command-table.mjs +436 -0
- package/src/commands/bundle.mjs +369 -0
- package/src/commands/count.mjs +39 -0
- package/src/commands/deploy.mjs +488 -0
- package/src/commands/dev.mjs +246 -0
- package/src/commands/env.mjs +115 -0
- package/src/commands/files.mjs +172 -0
- package/src/commands/git.mjs +195 -0
- package/src/commands/groups-runtime.mjs +227 -0
- package/src/commands/groups.mjs +417 -0
- package/src/commands/history.mjs +374 -0
- package/src/commands/import.mjs +506 -0
- package/src/commands/init.mjs +175 -0
- package/src/commands/key.mjs +172 -0
- package/src/commands/login.mjs +167 -0
- package/src/commands/logs.mjs +285 -0
- package/src/commands/mcp-config.mjs +102 -0
- package/src/commands/op.mjs +265 -0
- package/src/commands/open.mjs +35 -0
- package/src/commands/push.mjs +199 -0
- package/src/commands/signup.mjs +164 -0
- package/src/commands/site.mjs +129 -0
- package/src/commands/skills.mjs +177 -0
- package/src/commands/status.mjs +78 -0
- package/src/commands/tenants.mjs +172 -0
- package/src/commands/test.mjs +167 -0
- package/src/commands/tools.mjs +63 -0
- package/src/commands/versions.mjs +293 -0
- package/src/config.mjs +569 -0
- package/src/documents.mjs +139 -0
- package/src/function-build.mjs +273 -0
- package/src/identity.mjs +248 -0
- package/src/jsonfile.mjs +22 -0
- package/src/live-move.mjs +267 -0
- package/src/local/args.mjs +58 -0
- package/src/local/reload.mjs +408 -0
- package/src/local/server.mjs +291 -0
- package/src/local/watch.mjs +175 -0
- package/src/manifest.mjs +591 -0
- package/src/ops.mjs +265 -0
- package/src/readers.mjs +416 -0
- package/src/sdk.mjs +26 -0
- package/src/settings.mjs +117 -0
- package/src/testing/app.mjs +507 -0
- package/src/testing/env.mjs +35 -0
- package/src/testing/index.mjs +11 -0
- package/src/testing/types.mjs +55 -0
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Substrio CLI: `substrio skills list | show NAME | install [NAME...] [--for
|
|
3
|
+
* claude|cursor|codex|agents-md|all] [--dir DIR]`.
|
|
4
|
+
*
|
|
5
|
+
* Reads the server's agent skills (documentation.skill.list/get: the same
|
|
6
|
+
* skills served at `substrio://skills/<name>` over MCP and `GET
|
|
7
|
+
* /v1/_docs/skills`) and, for install, writes them where each client reads
|
|
8
|
+
* its own skills/rules from.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
12
|
+
import { dirname, join } from 'node:path';
|
|
13
|
+
|
|
14
|
+
import { CliError, ErrorCode, emit, fail } from '../cli.mjs';
|
|
15
|
+
import { FlagKind, parseFlags } from '../local/args.mjs';
|
|
16
|
+
import { invokeOperation } from './op.mjs';
|
|
17
|
+
|
|
18
|
+
const C = { reset: '\x1b[0m', bold: '\x1b[1m', dim: '\x1b[2m', green: '\x1b[92m' };
|
|
19
|
+
|
|
20
|
+
const USAGE = 'substrio skills list | show NAME | install [NAME...] [--for claude|cursor|codex|agents-md|all] [--dir DIR]';
|
|
21
|
+
|
|
22
|
+
/** Where `skills install` can write a skill (pure data). */
|
|
23
|
+
export const SkillTarget = Object.freeze({
|
|
24
|
+
CLAUDE: 'claude', CURSOR: 'cursor', CODEX: 'codex', AGENTS_MD: 'agents-md', ALL: 'all',
|
|
25
|
+
});
|
|
26
|
+
const INSTALL_TARGETS = [SkillTarget.CLAUDE, SkillTarget.CURSOR, SkillTarget.CODEX, SkillTarget.AGENTS_MD];
|
|
27
|
+
|
|
28
|
+
export const AGENTS_MD_BEGIN = '<!-- substrio:skills:begin -->';
|
|
29
|
+
export const AGENTS_MD_END = '<!-- substrio:skills:end -->';
|
|
30
|
+
|
|
31
|
+
function showSkillList({ skills = [] }) {
|
|
32
|
+
if (!skills.length) { console.log(`${C.dim}No skills.${C.reset}`); return; }
|
|
33
|
+
for (const s of skills) console.log(`${C.bold}${s.name}${C.reset}${C.dim} - ${s.title}${C.reset}\n ${s.description}`);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** A skill's frontmatter ({name, description, ...}) and body, after the closing `---` (pure). Throws when there is none. */
|
|
37
|
+
export function splitFrontmatter(markdown) {
|
|
38
|
+
const m = /^---\n([\s\S]*?)\n---\n?([\s\S]*)$/.exec(markdown);
|
|
39
|
+
if (!m) throw new CliError('Skill markdown has no --- frontmatter', { code: ErrorCode.UNKNOWN });
|
|
40
|
+
const frontmatter = {};
|
|
41
|
+
for (const line of m[1].split('\n')) {
|
|
42
|
+
const i = line.indexOf(':');
|
|
43
|
+
if (i < 0) continue;
|
|
44
|
+
frontmatter[line.slice(0, i).trim()] = line.slice(i + 1).trim();
|
|
45
|
+
}
|
|
46
|
+
return { frontmatter, body: m[2] };
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** The .cursor/rules/substrio-<name>.mdc text for one skill (pure): its own description, alwaysApply false, then the body. */
|
|
50
|
+
export function cursorRuleOf(description, body) {
|
|
51
|
+
return `---\ndescription: ${description}\nalwaysApply: false\n---\n${body}`;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** One skill's AGENTS.md section (pure), without the markers (agentsMdBlockOf wraps the joined sections once). */
|
|
55
|
+
export function agentsMdSectionOf(name, markdown) {
|
|
56
|
+
return `## Skill: ${name}\n\n${markdown.trim()}\n`;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** The whole marked block `agentsMdOf` writes, every skill's section joined (pure). */
|
|
60
|
+
function agentsMdBlockOf(skills) {
|
|
61
|
+
return `${AGENTS_MD_BEGIN}\n${skills.map((s) => agentsMdSectionOf(s.name, s.markdown)).join('\n')}${AGENTS_MD_END}\n`;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* `path`'s text with the substrio skills block set to `block` (pure but for
|
|
66
|
+
* the read): replaces an existing begin/end section in place, keeping
|
|
67
|
+
* everything outside it; appends the block (after a blank line) when there
|
|
68
|
+
* is none yet.
|
|
69
|
+
*/
|
|
70
|
+
export function withAgentsMdBlock(existing, block) {
|
|
71
|
+
const start = existing.indexOf(AGENTS_MD_BEGIN);
|
|
72
|
+
const end = existing.indexOf(AGENTS_MD_END);
|
|
73
|
+
if (start >= 0 && end > start) {
|
|
74
|
+
return `${existing.slice(0, start)}${block}${existing.slice(end + AGENTS_MD_END.length).replace(/^\n/, '')}`;
|
|
75
|
+
}
|
|
76
|
+
return `${existing}${existing && !existing.endsWith('\n\n') ? (existing.endsWith('\n') ? '\n' : '\n\n') : ''}${block}`;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function writeFileEnsured(path, text) {
|
|
80
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
81
|
+
writeFileSync(path, text);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function installClaude(dir, name, markdown) {
|
|
85
|
+
const path = join(dir, '.claude', 'skills', name, 'SKILL.md');
|
|
86
|
+
writeFileEnsured(path, markdown);
|
|
87
|
+
return path;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function installCursor(dir, name, markdown) {
|
|
91
|
+
const { frontmatter, body } = splitFrontmatter(markdown);
|
|
92
|
+
const path = join(dir, '.cursor', 'rules', `substrio-${name}.mdc`);
|
|
93
|
+
writeFileEnsured(path, cursorRuleOf(frontmatter.description ?? '', body));
|
|
94
|
+
return path;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function installAgentsMd(dir, skills) {
|
|
98
|
+
const path = join(dir, 'AGENTS.md');
|
|
99
|
+
const existing = existsSync(path) ? readFileSync(path, 'utf8') : '';
|
|
100
|
+
writeFileEnsured(path, withAgentsMdBlock(existing, agentsMdBlockOf(skills)));
|
|
101
|
+
return path;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** The targets `--for` names, expanded and de-duplicated ("all" -> every target; codex and agents-md share one writer); pure. */
|
|
105
|
+
export function targetsOf(forFlag) {
|
|
106
|
+
if (!forFlag || forFlag === SkillTarget.ALL) return INSTALL_TARGETS;
|
|
107
|
+
const names = forFlag.split(',').map((s) => s.trim());
|
|
108
|
+
for (const name of names) {
|
|
109
|
+
if (!INSTALL_TARGETS.includes(name)) {
|
|
110
|
+
throw new CliError(`unknown --for target "${name}"`, { code: ErrorCode.INVALID_ARGUMENT, hint: `one of ${INSTALL_TARGETS.join(', ')}, all` });
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
return names;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
async function list(api) {
|
|
117
|
+
const data = await invokeOperation(api, 'documentation.skill.list', {});
|
|
118
|
+
emit(data, showSkillList);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
async function show(api, name) {
|
|
122
|
+
if (!name) fail('Usage: substrio skills show NAME');
|
|
123
|
+
const data = await invokeOperation(api, 'documentation.skill.get', { name });
|
|
124
|
+
emit(data, (d) => console.log(d.markdown));
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
const INSTALL_FLAGS = Object.freeze({ '--for': FlagKind.VALUE, '--dir': FlagKind.VALUE });
|
|
128
|
+
|
|
129
|
+
async function install(api, args) {
|
|
130
|
+
let flags;
|
|
131
|
+
let names;
|
|
132
|
+
try {
|
|
133
|
+
({ flags, positional: names } = parseFlags(args, INSTALL_FLAGS));
|
|
134
|
+
} catch (err) {
|
|
135
|
+
return fail(err);
|
|
136
|
+
}
|
|
137
|
+
let targets;
|
|
138
|
+
try {
|
|
139
|
+
targets = targetsOf(flags.get('--for') ?? null);
|
|
140
|
+
} catch (err) {
|
|
141
|
+
return fail(err);
|
|
142
|
+
}
|
|
143
|
+
const dir = flags.get('--dir') ?? process.cwd();
|
|
144
|
+
if (!names.length) {
|
|
145
|
+
const { skills } = await invokeOperation(api, 'documentation.skill.list', {});
|
|
146
|
+
names = skills.map((s) => s.name);
|
|
147
|
+
}
|
|
148
|
+
const fetched = [];
|
|
149
|
+
for (const name of names) {
|
|
150
|
+
fetched.push(await invokeOperation(api, 'documentation.skill.get', { name }));
|
|
151
|
+
}
|
|
152
|
+
const written = [];
|
|
153
|
+
for (const skill of fetched) {
|
|
154
|
+
if (targets.includes(SkillTarget.CLAUDE)) written.push(installClaude(dir, skill.name, skill.markdown));
|
|
155
|
+
if (targets.includes(SkillTarget.CURSOR)) written.push(installCursor(dir, skill.name, skill.markdown));
|
|
156
|
+
}
|
|
157
|
+
if (targets.includes(SkillTarget.CODEX) || targets.includes(SkillTarget.AGENTS_MD)) {
|
|
158
|
+
written.push(installAgentsMd(dir, fetched));
|
|
159
|
+
}
|
|
160
|
+
emit({ installed: fetched.map((s) => s.name), written }, () => {
|
|
161
|
+
for (const path of written) console.log(`${C.green}✓${C.reset} ${path}`);
|
|
162
|
+
});
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
export async function cmdSkills(api, args) {
|
|
166
|
+
switch (args[0]) {
|
|
167
|
+
case 'list':
|
|
168
|
+
case undefined:
|
|
169
|
+
return list(api);
|
|
170
|
+
case 'show':
|
|
171
|
+
return show(api, args[1]);
|
|
172
|
+
case 'install':
|
|
173
|
+
return install(api, args.slice(1));
|
|
174
|
+
default:
|
|
175
|
+
return fail(`Usage: ${USAGE}`);
|
|
176
|
+
}
|
|
177
|
+
}
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Substrio CLI: `substrio status` (server health and config) and `substrio whoami`
|
|
3
|
+
* (config and where each value came from). Both print JSON under --json.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { errorText } from '../api.mjs';
|
|
7
|
+
import { CliError, ErrorCode, emit, exitCodeFor } from '../cli.mjs';
|
|
8
|
+
import { callOperation, loadOperations } from './op.mjs';
|
|
9
|
+
import { getConfigSources, listProfiles } from '../config.mjs';
|
|
10
|
+
|
|
11
|
+
const C = {
|
|
12
|
+
reset: '\x1b[0m', bold: '\x1b[1m', dim: '\x1b[2m',
|
|
13
|
+
red: '\x1b[91m', green: '\x1b[92m', cyan: '\x1b[96m',
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
// The resolved config as data. No part of the key is shown: terminals and
|
|
17
|
+
// logs are not secret stores.
|
|
18
|
+
function configView(config) {
|
|
19
|
+
const sources = getConfigSources();
|
|
20
|
+
return {
|
|
21
|
+
url: config.url || null, url_source: sources.url,
|
|
22
|
+
tenant: config.tenant, tenant_source: sources.tenant,
|
|
23
|
+
key_set: Boolean(config.master_key), key_length: config.master_key ? config.master_key.length : 0,
|
|
24
|
+
key_source: sources.master_key,
|
|
25
|
+
profile: config.profile || null,
|
|
26
|
+
functions_dir: config.functions_dir, public_dir: config.public_dir,
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function printConfig(view) {
|
|
31
|
+
console.log(` URL: ${view.url || `${C.red}not set${C.reset}`} ${C.dim}(${view.url_source})${C.reset}`);
|
|
32
|
+
console.log(` Tenant: ${view.tenant} ${C.dim}(${view.tenant_source})${C.reset}`);
|
|
33
|
+
const keyState = view.key_set ? `set (${view.key_length} characters)` : `${C.red}not set${C.reset}`;
|
|
34
|
+
console.log(` Key: ${keyState} ${C.dim}(${view.key_source})${C.reset}`);
|
|
35
|
+
if (view.profile) console.log(` Profile: ${view.profile}`);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export async function cmdStatus(api, config) {
|
|
39
|
+
const server = { healthy: false, version: null, error: null };
|
|
40
|
+
// system.health; a failed call is the server's typed error, and the
|
|
41
|
+
// run's exit code is that error's (exitCodeFor).
|
|
42
|
+
let failure = null;
|
|
43
|
+
try {
|
|
44
|
+
const result = await callOperation(api, await loadOperations(api), 'system.health', undefined);
|
|
45
|
+
server.healthy = result.ok && result.data?.status === 'ok';
|
|
46
|
+
server.version = result.data?.version || null;
|
|
47
|
+
if (!result.ok) failure = CliError.fromResponse(errorText(result.data, result.status), result.data, result.status);
|
|
48
|
+
} catch (err) {
|
|
49
|
+
failure = err instanceof CliError ? err : new CliError(err.message, { code: ErrorCode.UNKNOWN });
|
|
50
|
+
}
|
|
51
|
+
if (failure) server.error = failure.toJSON();
|
|
52
|
+
emit({ server, config: configView(config) }, ({ server: s, config: view }) => {
|
|
53
|
+
console.log(`\n${C.bold}${C.cyan}Substrio Status${C.reset}\n`);
|
|
54
|
+
if (s.error) console.log(` ${C.red}●${C.reset} Server: ${C.red}${s.healthy ? 'healthy' : 'unhealthy or unreachable'}${C.reset} (${s.error.error})`);
|
|
55
|
+
else console.log(` ${s.healthy ? C.green : C.red}●${C.reset} Server: ${s.healthy ? `${C.green}healthy` : `${C.red}unhealthy`}${C.reset}`);
|
|
56
|
+
if (s.version) console.log(` ${C.dim}Version:${C.reset} ${s.version}`);
|
|
57
|
+
console.log(`\n ${C.bold}Configuration${C.reset}`);
|
|
58
|
+
printConfig(view);
|
|
59
|
+
console.log('');
|
|
60
|
+
});
|
|
61
|
+
// A script checks the server by the exit code: 0 only when it is healthy.
|
|
62
|
+
if (!server.healthy) process.exitCode = failure ? exitCodeFor(failure) : 1;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export function cmdWhoami(config) {
|
|
66
|
+
const view = { ...configView(config), profiles: listProfiles() };
|
|
67
|
+
emit(view, (v) => {
|
|
68
|
+
console.log(`\n${C.bold}${C.cyan}Substrio Whoami${C.reset}\n`);
|
|
69
|
+
printConfig(v);
|
|
70
|
+
console.log(` Functions: ${v.functions_dir}/`);
|
|
71
|
+
console.log(` Public: ${v.public_dir}/`);
|
|
72
|
+
if (v.profiles.length) {
|
|
73
|
+
console.log(`\n ${C.bold}Saved Profiles:${C.reset}`);
|
|
74
|
+
for (const p of v.profiles) console.log(` ${p.name}: ${p.url}${p.isDefault ? ` ${C.green}(default)${C.reset}` : ''}`);
|
|
75
|
+
}
|
|
76
|
+
console.log('');
|
|
77
|
+
});
|
|
78
|
+
}
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Substrio CLI: `substrio tenants` (alias `substrio apps`) command
|
|
3
|
+
*
|
|
4
|
+
* A tenant is an app: its own data, functions, users, site and domains.
|
|
5
|
+
* Listing tenants takes a platform-admin key (the profile in use must hold
|
|
6
|
+
* one). Creating one takes either that same master key (tenant.create, any
|
|
7
|
+
* slug, no owning account) or an account key (account.app.create: the app is
|
|
8
|
+
* owned by that account, the key a self-serve `substrio signup` saved); which one
|
|
9
|
+
* the profile in use holds is read with auth.whoami, falling back to the
|
|
10
|
+
* master-key path whenever whoami cannot say (no such route, or any other
|
|
11
|
+
* failure). The new tenant's own key is saved as a profile of its own, so
|
|
12
|
+
* its owner (a person, CI or an AI agent) deploys with it from then on.
|
|
13
|
+
*
|
|
14
|
+
* substrio tenants list List every tenant
|
|
15
|
+
* substrio tenants show SLUG One tenant's settings and domains
|
|
16
|
+
* substrio tenants create SLUG Create a tenant; its key is saved to
|
|
17
|
+
* [--save-profile NAME] the profile NAME (default: SLUG),
|
|
18
|
+
* [--print] never printed unless --print, which
|
|
19
|
+
* writes it alone on stdout (for CI).
|
|
20
|
+
* substrio tenants delete SLUG --yes Delete a tenant and all its data;
|
|
21
|
+
* --yes is required, there is no undo.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
import { ErrorCode, confirmDestructive, emit, emitIssuedKey, fail } from '../cli.mjs';
|
|
25
|
+
import { cmdRegistry } from './groups-runtime.mjs';
|
|
26
|
+
import { OpError, callOperation, invokeOperation, loadOperations, runOperation } from './op.mjs';
|
|
27
|
+
import { listProfiles, writeProfile } from '../config.mjs';
|
|
28
|
+
|
|
29
|
+
const C = {
|
|
30
|
+
reset: '\x1b[0m',
|
|
31
|
+
bold: '\x1b[1m',
|
|
32
|
+
dim: '\x1b[2m',
|
|
33
|
+
red: '\x1b[91m',
|
|
34
|
+
green: '\x1b[92m',
|
|
35
|
+
yellow: '\x1b[93m',
|
|
36
|
+
cyan: '\x1b[96m',
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
const USAGE =
|
|
40
|
+
'substrio tenants list | show SLUG | create SLUG [--save-profile NAME] [--print] | delete SLUG --yes';
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
function flag(args, name) {
|
|
44
|
+
const i = args.indexOf(name);
|
|
45
|
+
return i === -1 ? null : (args[i + 1] ?? null);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export async function cmdTenants(api, config, args) {
|
|
49
|
+
// list, create and delete are about the account's apps, not one tenant: an
|
|
50
|
+
// account key presented at a tenant it does not own (the 'default'
|
|
51
|
+
// fallback of a profile `substrio signup` saved) proves nothing, so the
|
|
52
|
+
// fallback is not sent; a named tenant still is.
|
|
53
|
+
if (!config.tenant_named && ['list', 'ls', 'create', 'new', 'delete', 'rm'].includes(args[0])) api.tenant = null;
|
|
54
|
+
switch (args[0]) {
|
|
55
|
+
case 'list':
|
|
56
|
+
case 'ls':
|
|
57
|
+
return tenantsList(api, args.slice(1));
|
|
58
|
+
case 'show':
|
|
59
|
+
case 'get':
|
|
60
|
+
return tenantsShow(api, args.slice(1));
|
|
61
|
+
case 'create':
|
|
62
|
+
case 'new':
|
|
63
|
+
return tenantsCreate(api, config, args.slice(1));
|
|
64
|
+
case 'delete':
|
|
65
|
+
case 'rm':
|
|
66
|
+
return tenantsDelete(api, args.slice(1));
|
|
67
|
+
default:
|
|
68
|
+
// Any other tenant.* or app.* operation by its words (substrio tenants usage).
|
|
69
|
+
if (args[0] && !args[0].startsWith('-')) return cmdRegistry(api, 'tenants', args);
|
|
70
|
+
fail(`Usage: ${USAGE}`);
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// tenant.list answers { data: [tenant...], count, tenant_domains,
|
|
75
|
+
// next_cursor } and account.app.list { apps: [tenant...], next_cursor },
|
|
76
|
+
// which --json prints whole; a tenant names itself by `subdomain` and lists
|
|
77
|
+
// the hosts it answers on (`hosts`, then `domains`).
|
|
78
|
+
function printTenants(data) {
|
|
79
|
+
const tenants = Array.isArray(data.data) ? data.data : Array.isArray(data.apps) ? data.apps : [];
|
|
80
|
+
if (tenants.length === 0) {
|
|
81
|
+
console.log(`${C.dim}No tenants.${C.reset}`);
|
|
82
|
+
return;
|
|
83
|
+
}
|
|
84
|
+
const slugOf = (t) => String(t.subdomain ?? t.slug ?? t.name ?? '');
|
|
85
|
+
const width = Math.max(4, ...tenants.map((t) => slugOf(t).length));
|
|
86
|
+
console.log(`\n ${'SLUG'.padEnd(width)} HOSTS`);
|
|
87
|
+
console.log(` ${'─'.repeat(width)} ${'─'.repeat(30)}`);
|
|
88
|
+
for (const t of tenants) {
|
|
89
|
+
const hosts = [...(t.hosts ?? []), ...(t.domains ?? [])].join(', ');
|
|
90
|
+
console.log(` ${slugOf(t).padEnd(width)} ${hosts}`);
|
|
91
|
+
}
|
|
92
|
+
console.log(`\n ${C.dim}${tenants.length} tenant(s)${C.reset}\n`);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
async function tenantsShow(api, args) {
|
|
96
|
+
if (!args[0] || args[0].startsWith('--')) fail(`Usage: ${USAGE}`);
|
|
97
|
+
return runOperation(api, 'tenant.get', args.slice(1), { positional: [args[0]] });
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// The account id of the profile's key (auth.whoami), when it is an account
|
|
101
|
+
// key; null for anything else, and for a server whoami cannot answer (no
|
|
102
|
+
// such route, or any other failure) -- tenant.create, the master-key path,
|
|
103
|
+
// stays the default.
|
|
104
|
+
async function accountKeyId(api, ops) {
|
|
105
|
+
try {
|
|
106
|
+
const result = await callOperation(api, ops, 'auth.whoami', undefined);
|
|
107
|
+
return result.ok && result.data?.key === 'account' && typeof result.data.account_id === 'number' ? result.data.account_id : null;
|
|
108
|
+
} catch (err) {
|
|
109
|
+
if (err instanceof OpError) return null;
|
|
110
|
+
throw err;
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
async function tenantsCreate(api, config, args) {
|
|
115
|
+
const slug = args[0];
|
|
116
|
+
if (!slug || slug.startsWith('--')) fail(`Usage: ${USAGE}`);
|
|
117
|
+
const profile = flag(args, '--save-profile') ?? slug;
|
|
118
|
+
const print = args.includes('--print');
|
|
119
|
+
// Never overwrite a saved key: that would lose the only copy of it.
|
|
120
|
+
if (listProfiles().some((p) => p.name === profile)) {
|
|
121
|
+
fail(
|
|
122
|
+
`A profile named "${profile}" already exists; pass --save-profile with another name.`
|
|
123
|
+
);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// auth.whoami says which key this profile holds: an account key (from
|
|
127
|
+
// `substrio signup`, no platform access) makes its app with account.app.create,
|
|
128
|
+
// owned by that account; anything else, or a server whoami cannot answer,
|
|
129
|
+
// keeps the master-key path, tenant.create.
|
|
130
|
+
const ops = await loadOperations(api);
|
|
131
|
+
const accountId = await accountKeyId(api, ops);
|
|
132
|
+
const data = accountId !== null
|
|
133
|
+
? await invokeOperation(api, 'account.app.create', { id: accountId, slug }, { ops })
|
|
134
|
+
: await invokeOperation(api, 'tenant.create', { slug }, { ops });
|
|
135
|
+
if (typeof data?.key !== 'string' || !data.key) {
|
|
136
|
+
fail(`Tenant "${slug}" was created but the server returned no key.`, null, ErrorCode.UNKNOWN);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
writeProfile(profile, config.url, data.key, data.slug);
|
|
140
|
+
emitIssuedKey(data, print);
|
|
141
|
+
console.error(
|
|
142
|
+
`${C.green}✓${C.reset} Tenant ${C.bold}${data.slug}${C.reset} created; its key is saved to profile ${C.bold}${profile}${C.reset}.`
|
|
143
|
+
);
|
|
144
|
+
console.error(
|
|
145
|
+
`${C.dim} In the app's substrio.toml: tenant = "${data.slug}", profile = "${profile}".${C.reset}`
|
|
146
|
+
);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// list and delete take the same branch create does: an account key (no
|
|
150
|
+
// platform access, so tenant.list and tenant.delete refuse it) reads and
|
|
151
|
+
// deletes its own apps through account.app.list and account.app.delete.
|
|
152
|
+
async function tenantsList(api, args) {
|
|
153
|
+
const ops = await loadOperations(api);
|
|
154
|
+
const accountId = await accountKeyId(api, ops);
|
|
155
|
+
return accountId !== null
|
|
156
|
+
? runOperation(api, 'account.app.list', args, { defaults: { id: accountId }, render: printTenants, ops })
|
|
157
|
+
: runOperation(api, 'tenant.list', args, { render: printTenants, ops });
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
async function tenantsDelete(api, args) {
|
|
161
|
+
const slug = args[0];
|
|
162
|
+
if (!slug || slug.startsWith('--')) fail(`Usage: ${USAGE}`);
|
|
163
|
+
await confirmDestructive(`Deleting "${slug}" removes all its data`, `substrio tenants delete ${slug} --yes`);
|
|
164
|
+
const ops = await loadOperations(api);
|
|
165
|
+
const accountId = await accountKeyId(api, ops);
|
|
166
|
+
const data = accountId !== null
|
|
167
|
+
? await invokeOperation(api, 'account.app.delete', { id: accountId, slug }, { ops })
|
|
168
|
+
: await invokeOperation(api, 'tenant.delete', { slug }, { ops });
|
|
169
|
+
emit(data, () => console.error(
|
|
170
|
+
`${C.green}✓${C.reset} Tenant ${C.bold}${slug}${C.reset} deleted.`
|
|
171
|
+
));
|
|
172
|
+
}
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Substrio CLI: `substrio test`, the tests of an app on a local server.
|
|
3
|
+
*
|
|
4
|
+
* substrio test [FILES...] [--server PATH] [--attach URL] [--no-build]
|
|
5
|
+
* [--filter NAME] [--timeout MS] [--keep]
|
|
6
|
+
*
|
|
7
|
+
* Starts one substrio_server of its own (or attaches to the one at --attach URL),
|
|
8
|
+
* and runs the test files with node's test runner (`node --test`): the files
|
|
9
|
+
* of the arguments, else every *.test.mjs under the app's test/ directory.
|
|
10
|
+
* Each test makes its own test tenant with startApp() of `@substrio/cli/test`,
|
|
11
|
+
* so nothing is shared between tests: no data, no stub, no clock. The server
|
|
12
|
+
* and everything it stored are removed at the end (--keep: the data
|
|
13
|
+
* directory stays). The exit code is the test runner's.
|
|
14
|
+
*
|
|
15
|
+
* The test files find the server through SUBSTRIO_TEST_URL, SUBSTRIO_TEST_APP_DIR and
|
|
16
|
+
* SUBSTRIO_TEST_KEY_FILE, the last a 0600 file holding the master key: it is never
|
|
17
|
+
* in a process environment.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import { spawn } from 'node:child_process';
|
|
21
|
+
import { mkdtempSync, readdirSync, rmSync, statSync, writeFileSync } from 'node:fs';
|
|
22
|
+
import { tmpdir } from 'node:os';
|
|
23
|
+
import { basename, join, resolve } from 'node:path';
|
|
24
|
+
|
|
25
|
+
import { readApp, runBuild } from '../apply.mjs';
|
|
26
|
+
import { CliError, ErrorCode, fail } from '../cli.mjs';
|
|
27
|
+
import { loadConfig } from '../config.mjs';
|
|
28
|
+
import { FlagKind, parseFlags, wholeNumber } from '../local/args.mjs';
|
|
29
|
+
import { LocalServer, resolveServerBinary } from '../local/server.mjs';
|
|
30
|
+
import { testEnvFor } from '../testing/env.mjs';
|
|
31
|
+
|
|
32
|
+
export const TEST_FLAGS = Object.freeze({
|
|
33
|
+
'--attach': FlagKind.VALUE,
|
|
34
|
+
'--server': FlagKind.VALUE,
|
|
35
|
+
'--no-build': FlagKind.SWITCH,
|
|
36
|
+
'--filter': FlagKind.VALUE,
|
|
37
|
+
'--timeout': FlagKind.VALUE,
|
|
38
|
+
'--keep': FlagKind.SWITCH,
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
/** The directory (under the app) the default test files are in. */
|
|
42
|
+
export const TEST_DIR = 'test';
|
|
43
|
+
|
|
44
|
+
/** The suffix of a test file's name. */
|
|
45
|
+
export const TEST_SUFFIX = '.test.mjs';
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* The test files to run: `given` (relative to `cwd`) when there are any, each
|
|
49
|
+
* required to exist, else every file of `cwd/test` named *.test.mjs, in path
|
|
50
|
+
* order. A CliError when there is none. Returns absolute paths.
|
|
51
|
+
*/
|
|
52
|
+
export function testFiles(cwd, given) {
|
|
53
|
+
if (given.length > 0) {
|
|
54
|
+
return given.map((file) => {
|
|
55
|
+
const path = resolve(cwd, file);
|
|
56
|
+
let stat;
|
|
57
|
+
try {
|
|
58
|
+
stat = statSync(path);
|
|
59
|
+
} catch {
|
|
60
|
+
throw new CliError(`No test file ${file}`);
|
|
61
|
+
}
|
|
62
|
+
if (!stat.isFile()) throw new CliError(`${file} is not a test file`);
|
|
63
|
+
return path;
|
|
64
|
+
});
|
|
65
|
+
}
|
|
66
|
+
const dir = join(cwd, TEST_DIR);
|
|
67
|
+
let names;
|
|
68
|
+
try {
|
|
69
|
+
names = readdirSync(dir, { recursive: true });
|
|
70
|
+
} catch {
|
|
71
|
+
throw new CliError(`No test files: the app has no ${TEST_DIR}/ directory (write ${TEST_DIR}/NAME${TEST_SUFFIX} or name the files)`);
|
|
72
|
+
}
|
|
73
|
+
const files = names.filter((name) => basename(name).endsWith(TEST_SUFFIX)).sort().map((name) => join(dir, name));
|
|
74
|
+
if (files.length === 0) throw new CliError(`No *${TEST_SUFFIX} files under ${TEST_DIR}/`);
|
|
75
|
+
return files;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** The arguments of the `node` that runs the tests (pure). */
|
|
79
|
+
export function nodeTestArgs({ files, filter = null, timeoutMs = null }) {
|
|
80
|
+
return [
|
|
81
|
+
'--test',
|
|
82
|
+
...(filter === null ? [] : [`--test-name-pattern=${filter}`]),
|
|
83
|
+
...(timeoutMs === null ? [] : [`--test-timeout=${timeoutMs}`]),
|
|
84
|
+
...files,
|
|
85
|
+
];
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** Runs node's test runner; resolves with its exit code (128 + signal number when a signal ended it). */
|
|
89
|
+
function runNode(args, env, cwd) {
|
|
90
|
+
return new Promise((resolveRun, reject) => {
|
|
91
|
+
const child = spawn(process.execPath, args, { cwd, env, stdio: 'inherit' });
|
|
92
|
+
const forward = (signal) => () => { child.kill(signal); };
|
|
93
|
+
const handlers = ['SIGINT', 'SIGTERM'].map((signal) => [signal, forward(signal)]);
|
|
94
|
+
for (const [signal, handler] of handlers) process.on(signal, handler);
|
|
95
|
+
const release = () => { for (const [signal, handler] of handlers) process.off(signal, handler); };
|
|
96
|
+
child.once('error', (err) => { release(); reject(err); });
|
|
97
|
+
child.once('exit', (code, signal) => {
|
|
98
|
+
release();
|
|
99
|
+
resolveRun(code ?? 128 + (signal === 'SIGINT' ? 2 : 15));
|
|
100
|
+
});
|
|
101
|
+
});
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
export async function cmdTest(args) {
|
|
105
|
+
const cwd = process.cwd();
|
|
106
|
+
let parsed;
|
|
107
|
+
let app;
|
|
108
|
+
let files;
|
|
109
|
+
let timeoutMs = null;
|
|
110
|
+
try {
|
|
111
|
+
parsed = parseFlags(args, TEST_FLAGS);
|
|
112
|
+
app = readApp(cwd);
|
|
113
|
+
files = testFiles(cwd, parsed.positional);
|
|
114
|
+
if (parsed.flags.has('--timeout')) timeoutMs = wholeNumber(parsed.flags, '--timeout', 1, 3_600_000, 0);
|
|
115
|
+
} catch (err) {
|
|
116
|
+
fail(err);
|
|
117
|
+
}
|
|
118
|
+
const { flags } = parsed;
|
|
119
|
+
|
|
120
|
+
if (!flags.has('--no-build') && app.manifest.build.length > 0) {
|
|
121
|
+
try {
|
|
122
|
+
runBuild(app.manifest.build, cwd, (step) => console.log(`== build (${step.dir}) ${step.run}`));
|
|
123
|
+
} catch (err) {
|
|
124
|
+
fail(err);
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// The master key goes to the tests through a file only this user can read.
|
|
129
|
+
const keyDir = mkdtempSync(join(tmpdir(), 'substrio-test-'));
|
|
130
|
+
const keyFile = join(keyDir, 'master.key');
|
|
131
|
+
let server;
|
|
132
|
+
try {
|
|
133
|
+
const attach = flags.get('--attach');
|
|
134
|
+
if (attach !== undefined) {
|
|
135
|
+
const config = loadConfig(cwd);
|
|
136
|
+
if (!config.master_key) throw new CliError('--attach needs a key: SUBSTRIO_MASTER_KEY or the profile', { code: ErrorCode.CREDENTIAL_REQUIRED });
|
|
137
|
+
server = LocalServer.attach({ url: attach, masterKey: config.master_key });
|
|
138
|
+
} else {
|
|
139
|
+
server = await LocalServer.spawn({ binary: resolveServerBinary(flags.get('--server')), port: 0, keep: flags.has('--keep') });
|
|
140
|
+
}
|
|
141
|
+
writeFileSync(keyFile, `${server.masterKey}\n`, { mode: 0o600 });
|
|
142
|
+
} catch (err) {
|
|
143
|
+
await server?.stop();
|
|
144
|
+
rmSync(keyDir, { recursive: true, force: true });
|
|
145
|
+
fail(err);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
let code = 1;
|
|
149
|
+
try {
|
|
150
|
+
const env = testEnvFor({ url: server.url, keyFile, appDir: cwd });
|
|
151
|
+
const ended = Promise.race([
|
|
152
|
+
runNode(nodeTestArgs({ files, filter: flags.get('--filter') ?? null, timeoutMs }), env, cwd),
|
|
153
|
+
server.exited.then((how) => {
|
|
154
|
+
if (how === null) return new Promise(() => {});
|
|
155
|
+
throw new CliError(`The server ended during the tests (${how.signal ?? `exit ${how.code}`})`);
|
|
156
|
+
}),
|
|
157
|
+
]);
|
|
158
|
+
code = await ended;
|
|
159
|
+
} catch (err) {
|
|
160
|
+
console.error(err.message);
|
|
161
|
+
} finally {
|
|
162
|
+
await server.stop();
|
|
163
|
+
rmSync(keyDir, { recursive: true, force: true });
|
|
164
|
+
}
|
|
165
|
+
if (flags.has('--keep') && server.dataDir) console.log(`The server's data is kept in ${server.dataDir}`);
|
|
166
|
+
process.exitCode = code;
|
|
167
|
+
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Substrio CLI: `substrio tools install` (tool.install, PUT /v1/tools/{name}/{sha256}).
|
|
3
|
+
*
|
|
4
|
+
* The image's bytes never sit whole in memory: the file is streamed once to
|
|
5
|
+
* hash it (sha256, which the route names by), then streamed again as the PUT
|
|
6
|
+
* body. Stdin ("-") cannot be streamed twice, so it is buffered to an
|
|
7
|
+
* owner-only temp file first (removed after); a given file is streamed from
|
|
8
|
+
* disk both times.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { createHash } from 'node:crypto';
|
|
12
|
+
import { createReadStream, createWriteStream, existsSync, statSync, unlinkSync } from 'node:fs';
|
|
13
|
+
import { tmpdir } from 'node:os';
|
|
14
|
+
import { join } from 'node:path';
|
|
15
|
+
import { Readable } from 'node:stream';
|
|
16
|
+
|
|
17
|
+
import { emit, fail } from '../cli.mjs';
|
|
18
|
+
import { invokeOperation } from './op.mjs';
|
|
19
|
+
|
|
20
|
+
const C = { reset: '\x1b[0m', bold: '\x1b[1m', green: '\x1b[92m' };
|
|
21
|
+
|
|
22
|
+
function sha256Of(path) {
|
|
23
|
+
return new Promise((resolve, reject) => {
|
|
24
|
+
const hash = createHash('sha256');
|
|
25
|
+
createReadStream(path)
|
|
26
|
+
.on('data', (chunk) => hash.update(chunk))
|
|
27
|
+
.on('end', () => resolve(hash.digest('hex')))
|
|
28
|
+
.on('error', reject);
|
|
29
|
+
});
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// Stdin buffered to an owner-only temp file: the hash is in the PUT's path,
|
|
33
|
+
// so it must be known before the body is sent, and stdin cannot be read twice.
|
|
34
|
+
function bufferStdin() {
|
|
35
|
+
const path = join(tmpdir(), `substrio-tool-${process.pid}-${Date.now()}`);
|
|
36
|
+
return new Promise((resolve, reject) => {
|
|
37
|
+
const out = createWriteStream(path, { mode: 0o600 });
|
|
38
|
+
process.stdin.pipe(out);
|
|
39
|
+
out.on('finish', () => resolve(path));
|
|
40
|
+
out.on('error', reject);
|
|
41
|
+
process.stdin.on('error', reject);
|
|
42
|
+
});
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export async function cmdToolInstall(api, resolved) {
|
|
46
|
+
const [name, source] = resolved.positional;
|
|
47
|
+
if (!name || !source) fail('Usage: substrio tools install NAME FILE|-', 'FILE is a path to the image, or - for stdin');
|
|
48
|
+
const fromStdin = source === '-';
|
|
49
|
+
if (!fromStdin && !existsSync(source)) fail(`File not found: ${source}`);
|
|
50
|
+
const path = fromStdin ? await bufferStdin() : source;
|
|
51
|
+
try {
|
|
52
|
+
const sha256 = await sha256Of(path);
|
|
53
|
+
const size = statSync(path).size;
|
|
54
|
+
const data = await invokeOperation(api, 'tool.install', { name, sha256 }, {
|
|
55
|
+
method: 'PUT',
|
|
56
|
+
body: Readable.toWeb(createReadStream(path)),
|
|
57
|
+
headers: { 'Content-Type': 'application/octet-stream', 'Content-Length': String(size) },
|
|
58
|
+
});
|
|
59
|
+
emit(data, () => console.log(`${C.green}✓${C.reset} Installed ${C.bold}${name}${C.reset} (${sha256.slice(0, 16)})`));
|
|
60
|
+
} finally {
|
|
61
|
+
if (fromStdin) unlinkSync(path);
|
|
62
|
+
}
|
|
63
|
+
}
|