@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,175 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Substrio CLI: `substrio init` command
|
|
3
|
+
*
|
|
4
|
+
* Scaffolds a new Substrio project with:
|
|
5
|
+
* substrio.toml : project config
|
|
6
|
+
* functions/ : cloud functions directory
|
|
7
|
+
* functions/hello.js: sample function
|
|
8
|
+
* public/ : static assets directory
|
|
9
|
+
* package.json : esbuild as a devDependency, for a function that imports an npm package
|
|
10
|
+
* .gitignore : gets `.substrio/` (the local dev server's data and keys), once
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { appendFileSync, writeFileSync, mkdirSync, existsSync, readFileSync } from 'node:fs';
|
|
14
|
+
import { join } from 'node:path';
|
|
15
|
+
import { ErrorCode, Prompter, fail, globals } from '../cli.mjs';
|
|
16
|
+
import { DEFAULT_FUNCTIONS_DIR, DEFAULT_PUBLIC_DIR, PROJECT_STATE_DIR } from '../config.mjs';
|
|
17
|
+
|
|
18
|
+
const C = {
|
|
19
|
+
reset: '\x1b[0m', bold: '\x1b[1m', dim: '\x1b[2m',
|
|
20
|
+
red: '\x1b[91m', green: '\x1b[92m', cyan: '\x1b[96m', yellow: '\x1b[93m',
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
/** The .gitignore line that keeps the project's local substrio state (the dev server's data and keys) out of git. */
|
|
25
|
+
export const STATE_IGNORE_LINE = `${PROJECT_STATE_DIR}/`;
|
|
26
|
+
const STATE_IGNORE_FORMS = new Set([STATE_IGNORE_LINE, PROJECT_STATE_DIR]);
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Makes sure `dir`'s .gitignore lists the state directory, creating the file
|
|
30
|
+
* or appending to it, and only once: a line already there (with or without
|
|
31
|
+
* its trailing slash) is left as it is. Returns whether the file was changed.
|
|
32
|
+
*/
|
|
33
|
+
export function ensureGitignored(dir) {
|
|
34
|
+
const path = join(dir, '.gitignore');
|
|
35
|
+
const text = existsSync(path) ? readFileSync(path, 'utf8') : '';
|
|
36
|
+
if (text.split('\n').some((line) => STATE_IGNORE_FORMS.has(line.trim()))) return false;
|
|
37
|
+
appendFileSync(path, `${text === '' || text.endsWith('\n') ? '' : '\n'}${STATE_IGNORE_LINE}\n`);
|
|
38
|
+
return true;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* The substrio.toml `substrio init` writes. Every key sits in [project], where loadConfig
|
|
43
|
+
* reads them (config.mjs); a [paths] table is not read.
|
|
44
|
+
*/
|
|
45
|
+
export function initToml({ url, tenant, profile }) {
|
|
46
|
+
return `# Substrio Project Configuration
|
|
47
|
+
# The key is never kept here: it lives in the profile below (~/.substrio/config.toml,
|
|
48
|
+
# written by "substrio login"), or in SUBSTRIO_MASTER_KEY.
|
|
49
|
+
|
|
50
|
+
[project]
|
|
51
|
+
url = "${url}"
|
|
52
|
+
tenant = "${tenant}"
|
|
53
|
+
profile = "${profile}"
|
|
54
|
+
functions_dir = "${DEFAULT_FUNCTIONS_DIR}"
|
|
55
|
+
public_dir = "${DEFAULT_PUBLIC_DIR}"
|
|
56
|
+
`;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** The AGENTS.md `substrio init` writes for a project with none yet: where an agent reads this project's own docs and skills. */
|
|
60
|
+
export function agentsMdTemplate({ url }) {
|
|
61
|
+
return `# Agents
|
|
62
|
+
|
|
63
|
+
This project runs on substrio. An agent working in this repo should read its
|
|
64
|
+
docs and skills from the server before guessing:
|
|
65
|
+
|
|
66
|
+
- \`GET ${url}/llms.txt\` - an index of every doc page and skill.
|
|
67
|
+
- \`substrio skills install\` - writes the server's skills where this
|
|
68
|
+
agent reads its own from (Claude, Cursor, Codex, or this file).
|
|
69
|
+
`;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* The esbuild version `substrio init` writes into a new project's package.json: the one this CLI's own
|
|
74
|
+
* repo depends on, so a function that bundles locally and on the server bundles with the same
|
|
75
|
+
* esbuild. An existing project without it gets the typed error of function-build.mjs's buildFunction
|
|
76
|
+
* instead (no esbuild, no network call made for it).
|
|
77
|
+
*/
|
|
78
|
+
export const ESBUILD_VERSION = '^0.25.10';
|
|
79
|
+
|
|
80
|
+
/** The package.json `substrio init` writes, as an object (JSON.stringify it to write it). */
|
|
81
|
+
export function packageJsonTemplate({ name }) {
|
|
82
|
+
return { name, private: true, devDependencies: { esbuild: ESBUILD_VERSION } };
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* substrio init [--url URL] [--tenant SLUG] [--profile NAME] [--yes]
|
|
87
|
+
*
|
|
88
|
+
* substrio.toml never holds a key (it is meant to be committed): the key lives in
|
|
89
|
+
* a profile ("substrio login"), which substrio.toml names. Values not given as flags
|
|
90
|
+
* are asked on a terminal, on one readline; with no terminal, the defaults
|
|
91
|
+
* are used. An existing substrio.toml is replaced only with --yes or a yes
|
|
92
|
+
* answer; declining exits non-zero.
|
|
93
|
+
*/
|
|
94
|
+
export async function cmdInit(args = []) {
|
|
95
|
+
const cwd = process.cwd();
|
|
96
|
+
let url = null;
|
|
97
|
+
for (let i = 0; i < args.length; i++) {
|
|
98
|
+
if (args[i] === '--url' && args[i + 1]) url = args[++i];
|
|
99
|
+
else fail(`unexpected argument "${args[i]}"`, 'substrio init --help');
|
|
100
|
+
}
|
|
101
|
+
const prompter = new Prompter();
|
|
102
|
+
let tenant = globals.tenant;
|
|
103
|
+
let profile = globals.profile;
|
|
104
|
+
try {
|
|
105
|
+
if (existsSync(join(cwd, 'substrio.toml')) && !globals.yes) {
|
|
106
|
+
const overwrite = await prompter.ask(`${C.yellow}substrio.toml already exists.${C.reset} Overwrite? (yes/no)`, '', '--yes');
|
|
107
|
+
if (!/^y(es)?$/i.test(overwrite)) fail('Aborted; substrio.toml is unchanged.', null, ErrorCode.UNKNOWN);
|
|
108
|
+
}
|
|
109
|
+
if (prompter.interactive) console.log(`\n${C.bold}${C.cyan}Substrio Project Setup${C.reset}\n`);
|
|
110
|
+
url = url || await prompter.ask('Server URL', 'http://localhost:9000');
|
|
111
|
+
tenant = tenant || await prompter.ask('Tenant/project name', 'default');
|
|
112
|
+
profile = profile || await prompter.ask('Profile holding the key (substrio login saves it)', 'default');
|
|
113
|
+
} catch (err) {
|
|
114
|
+
fail(err, 'substrio init --url URL --tenant SLUG --profile NAME --yes');
|
|
115
|
+
} finally {
|
|
116
|
+
prompter.close();
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
const tomlContent = initToml({ url, tenant, profile });
|
|
120
|
+
|
|
121
|
+
// Write files
|
|
122
|
+
writeFileSync(join(cwd, 'substrio.toml'), tomlContent);
|
|
123
|
+
console.log(` ${C.green}✓${C.reset} Created ${C.bold}substrio.toml${C.reset}`);
|
|
124
|
+
|
|
125
|
+
// Create directories
|
|
126
|
+
mkdirSync(join(cwd, DEFAULT_FUNCTIONS_DIR), { recursive: true });
|
|
127
|
+
console.log(` ${C.green}✓${C.reset} Created ${C.bold}functions/${C.reset}`);
|
|
128
|
+
|
|
129
|
+
mkdirSync(join(cwd, DEFAULT_PUBLIC_DIR), { recursive: true });
|
|
130
|
+
console.log(` ${C.green}✓${C.reset} Created ${C.bold}public/${C.reset}`);
|
|
131
|
+
|
|
132
|
+
if (ensureGitignored(cwd)) console.log(` ${C.green}✓${C.reset} Added ${C.bold}${STATE_IGNORE_LINE}${C.reset} to ${C.bold}.gitignore${C.reset} ${C.dim}(the dev server's data and keys)${C.reset}`);
|
|
133
|
+
|
|
134
|
+
// Sample function
|
|
135
|
+
const sampleFn = `/**
|
|
136
|
+
* Hello World: Sample Substrio Cloud Function
|
|
137
|
+
*
|
|
138
|
+
* Deploy with: substrio deploy
|
|
139
|
+
* Invoke with: substrio invoke hello --data '{"name": "world"}'
|
|
140
|
+
*/
|
|
141
|
+
(function(event) {
|
|
142
|
+
return JSON.stringify({
|
|
143
|
+
message: "Hello from Substrio! 🚀",
|
|
144
|
+
timestamp: Date.now(),
|
|
145
|
+
input: event.body
|
|
146
|
+
});
|
|
147
|
+
})
|
|
148
|
+
`;
|
|
149
|
+
|
|
150
|
+
if (!existsSync(join(cwd, 'functions', 'hello.js'))) {
|
|
151
|
+
writeFileSync(join(cwd, 'functions', 'hello.js'), sampleFn);
|
|
152
|
+
console.log(` ${C.green}✓${C.reset} Created ${C.bold}functions/hello.js${C.reset} ${C.dim}(sample function)${C.reset}`);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// esbuild bundles a function's npm imports (packages/substrio-cli/src/function-build.mjs); a
|
|
156
|
+
// function importing only the runtime's builtins needs it at neither install nor deploy time.
|
|
157
|
+
if (!existsSync(join(cwd, 'package.json'))) {
|
|
158
|
+
writeFileSync(join(cwd, 'package.json'), JSON.stringify(packageJsonTemplate({ name: tenant }), null, 2) + '\n');
|
|
159
|
+
console.log(` ${C.green}✓${C.reset} Created ${C.bold}package.json${C.reset} ${C.dim}(esbuild, to bundle a function's npm imports)${C.reset}`);
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
if (!existsSync(join(cwd, 'AGENTS.md'))) {
|
|
163
|
+
writeFileSync(join(cwd, 'AGENTS.md'), agentsMdTemplate({ url }));
|
|
164
|
+
console.log(` ${C.green}✓${C.reset} Created ${C.bold}AGENTS.md${C.reset} ${C.dim}(points an agent at /llms.txt and substrio skills install)${C.reset}`);
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
console.log(`
|
|
168
|
+
${C.bold}${C.green}✓ Project initialized!${C.reset}
|
|
169
|
+
|
|
170
|
+
${C.bold}Next steps:${C.reset}
|
|
171
|
+
${C.cyan}1.${C.reset} Edit ${C.bold}functions/hello.js${C.reset} or add more functions
|
|
172
|
+
${C.cyan}2.${C.reset} Run ${C.green}substrio deploy${C.reset} to push to your server
|
|
173
|
+
${C.cyan}3.${C.reset} Test with: ${C.green}substrio invoke hello --data '{"name": "world"}'${C.reset}
|
|
174
|
+
`);
|
|
175
|
+
}
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Substrio CLI: `substrio key` command
|
|
3
|
+
*
|
|
4
|
+
* The tenant's own key, the credential its owner deploys with. The server
|
|
5
|
+
* keeps only a digest of it: the key is shown once, when it is created or
|
|
6
|
+
* rotated, and no call returns it again.
|
|
7
|
+
* substrio key rotate Replace the tenant's key and save the new one
|
|
8
|
+
* in the current profile (never printed). The
|
|
9
|
+
* old key stops working at once.
|
|
10
|
+
* substrio key rotate --overlap 1h
|
|
11
|
+
* The old key keeps working for that long beside
|
|
12
|
+
* the new one (s, m, h or d; at most 7d), so a
|
|
13
|
+
* deploy can switch over.
|
|
14
|
+
* substrio key rotate --expires-in 90d
|
|
15
|
+
* The new key stops working after that long
|
|
16
|
+
* (a request with it then answers KEY_EXPIRED).
|
|
17
|
+
* substrio key rotate --print Also write the new key to stdout, alone on
|
|
18
|
+
* one line, for handing it on (e.g. to CI).
|
|
19
|
+
* substrio key list The tenant's keys: ids, created, expires and
|
|
20
|
+
* state (never a key).
|
|
21
|
+
* substrio key revoke ID End the previous key's overlap now.
|
|
22
|
+
* substrio key export --env FILE [--var NAME] [--force]
|
|
23
|
+
* Write NAME=<the key in use> (default SUBSTRIO_KEY) to FILE,
|
|
24
|
+
* owner-only (0600), for an MCP client config or a
|
|
25
|
+
* process that reads its key from the environment
|
|
26
|
+
* (".mcp.json" names ${SUBSTRIO_KEY}). Never printed. An
|
|
27
|
+
* existing FILE is refused unless --force replaces it.
|
|
28
|
+
*/
|
|
29
|
+
|
|
30
|
+
import { existsSync } from 'node:fs';
|
|
31
|
+
|
|
32
|
+
import { CliError, ErrorCode, emit, emitIssuedKey, fail } from '../cli.mjs';
|
|
33
|
+
import { setProfileKey, writePrivateFile } from '../config.mjs';
|
|
34
|
+
import { FlagKind, parseFlags } from '../local/args.mjs';
|
|
35
|
+
import { splitPositional } from '../ops.mjs';
|
|
36
|
+
import { invokeOperation, runOperation } from './op.mjs';
|
|
37
|
+
|
|
38
|
+
const C = {
|
|
39
|
+
reset: '\x1b[0m', bold: '\x1b[1m', dim: '\x1b[2m',
|
|
40
|
+
red: '\x1b[91m', green: '\x1b[92m', yellow: '\x1b[93m',
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
const USAGE = 'substrio key rotate [--overlap 1h] [--expires-in 90d] [--print] | list | revoke ID | export --env FILE [--var NAME] [--force]';
|
|
44
|
+
/** The variable `key export` writes unless --var names another. */
|
|
45
|
+
export const DEFAULT_KEY_VARIABLE = 'SUBSTRIO_KEY';
|
|
46
|
+
|
|
47
|
+
const isLetterOrUnderscore = (c) => (c >= 'A' && c <= 'Z') || (c >= 'a' && c <= 'z') || c === '_';
|
|
48
|
+
const isDigit = (c) => c >= '0' && c <= '9';
|
|
49
|
+
|
|
50
|
+
/** Whether `name` can be an environment variable's name: a letter or _ first, then letters, digits and _ (pure). */
|
|
51
|
+
export const isVariableName = (name) => name.length > 0 && isLetterOrUnderscore(name[0]) && [...name].every((c) => isLetterOrUnderscore(c) || isDigit(c));
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* The key in use, written as one `NAME=KEY` line to `file`, owner-only (pure but for the file): refused when
|
|
55
|
+
* `file` exists unless `force`. Returns {file, variable}; the key is in no return value, message or output.
|
|
56
|
+
*/
|
|
57
|
+
export function exportKey(key, { file, variable = DEFAULT_KEY_VARIABLE, force = false }) {
|
|
58
|
+
if (!isVariableName(variable)) throw new CliError(`--var ${variable} is not a variable name (letters, digits and _, not starting with a digit)`, { code: ErrorCode.INVALID_ARGUMENT });
|
|
59
|
+
if (typeof key !== 'string' || key === '') {
|
|
60
|
+
throw new CliError('No key to export: none is saved in the profile in use', { code: ErrorCode.CREDENTIAL_REQUIRED, hint: 'Run "substrio login" first' });
|
|
61
|
+
}
|
|
62
|
+
if (!force && existsSync(file)) throw new CliError(`${file} exists; --force replaces it`, { code: ErrorCode.INVALID_ARGUMENT });
|
|
63
|
+
try {
|
|
64
|
+
writePrivateFile(file, `${variable}=${key}\n`, { exclusive: !force });
|
|
65
|
+
} catch (err) {
|
|
66
|
+
if (err.code === 'EEXIST') throw new CliError(`${file} exists; --force replaces it`, { code: ErrorCode.INVALID_ARGUMENT });
|
|
67
|
+
throw new CliError(`Could not write ${file}: ${err.message}`, { code: ErrorCode.UNKNOWN });
|
|
68
|
+
}
|
|
69
|
+
return { file, variable };
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const SECONDS = { s: 1, m: 60, h: 3600, d: 86400 };
|
|
73
|
+
|
|
74
|
+
// The value after `name`, or null when the flag is absent. A flag given with
|
|
75
|
+
// no value (last, or followed by another flag) is an error, never absent.
|
|
76
|
+
function flag(args, name) {
|
|
77
|
+
const at = args.indexOf(name);
|
|
78
|
+
if (at === -1) return null;
|
|
79
|
+
const value = args[at + 1];
|
|
80
|
+
if (value === undefined || value.startsWith('--')) fail(`${name} needs a value`);
|
|
81
|
+
return value;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// "90s", "15m", "2h", "7d", or a bare count of `bare` units, in seconds.
|
|
85
|
+
function duration(text, flagName, bare) {
|
|
86
|
+
const match = /^(\d+)([smhd]?)$/.exec(text ?? '');
|
|
87
|
+
if (!match) fail(`${flagName} takes a duration like 90s, 15m, 2h or 7d`);
|
|
88
|
+
return Number(match[1]) * SECONDS[match[2] || bare];
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function whenText(seconds) {
|
|
92
|
+
return seconds == null ? 'never' : new Date(seconds * 1000).toISOString();
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function showKeys({ slug, keys = [] }) {
|
|
96
|
+
console.log(`\n${C.bold}Keys of ${slug}${C.reset} ${C.dim}(stored as digests)${C.reset}\n`);
|
|
97
|
+
for (const key of keys) {
|
|
98
|
+
const created = key.created == null ? 'unknown' : whenText(key.created);
|
|
99
|
+
console.log(` ${C.bold}${key.id}${C.reset} ${key.state.padEnd(8)} created ${created} expires ${whenText(key.expires)}`);
|
|
100
|
+
}
|
|
101
|
+
console.log('');
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
const EXPORT_FLAGS = Object.freeze({ '--env': FlagKind.VALUE, '--var': FlagKind.VALUE, '--force': FlagKind.SWITCH });
|
|
105
|
+
|
|
106
|
+
// `key export`: the flags parsed, the key written; says where it went, never the key.
|
|
107
|
+
function exportCommand(config, args) {
|
|
108
|
+
let done;
|
|
109
|
+
try {
|
|
110
|
+
const { flags, positional } = parseFlags(args, EXPORT_FLAGS);
|
|
111
|
+
if (positional.length > 0) throw new CliError(`unexpected argument "${positional[0]}"`, { code: ErrorCode.INVALID_ARGUMENT });
|
|
112
|
+
if (!flags.has('--env')) throw new CliError('key export needs --env FILE', { code: ErrorCode.INVALID_ARGUMENT, hint: USAGE });
|
|
113
|
+
done = exportKey(config.master_key, { file: flags.get('--env'), variable: flags.get('--var') ?? DEFAULT_KEY_VARIABLE, force: flags.has('--force') });
|
|
114
|
+
} catch (err) {
|
|
115
|
+
fail(err);
|
|
116
|
+
}
|
|
117
|
+
emit({ ...done, profile: config.profile || null }, () => {
|
|
118
|
+
console.log(`${C.green}✓${C.reset} The key${config.profile ? ` of profile ${C.bold}${config.profile}${C.reset}` : ''} written to ${done.file} as ${C.bold}${done.variable}${C.reset} (owner-only); it is shown nowhere.`);
|
|
119
|
+
});
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
export async function cmdKey(api, config, args) {
|
|
123
|
+
switch (args[0]) {
|
|
124
|
+
case 'export':
|
|
125
|
+
return exportCommand(config, args.slice(1));
|
|
126
|
+
case 'rotate':
|
|
127
|
+
return rotate(api, config, args.slice(1));
|
|
128
|
+
case 'list':
|
|
129
|
+
case 'ls':
|
|
130
|
+
return runOperation(api, 'tenant.key.list', args.slice(1), { render: showKeys });
|
|
131
|
+
case 'revoke': {
|
|
132
|
+
// The key id is a bare word: it fills the route's {id} like any
|
|
133
|
+
// other operation alias (splitPositional, then runOperation).
|
|
134
|
+
const { positional, rest } = splitPositional(args.slice(1));
|
|
135
|
+
if (positional.length !== 1) fail(`Usage: substrio key revoke ID`);
|
|
136
|
+
return runOperation(api, 'tenant.key.revoke', rest, { positional });
|
|
137
|
+
}
|
|
138
|
+
default:
|
|
139
|
+
return fail(`Usage: ${USAGE}`);
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
async function rotate(api, config, args) {
|
|
144
|
+
const print = args.includes('--print');
|
|
145
|
+
// A key from SUBSTRIO_MASTER_KEY or substrio.toml has nowhere to be saved.
|
|
146
|
+
const saved = config.profile && !process.env.SUBSTRIO_MASTER_KEY;
|
|
147
|
+
if (!saved && !print) {
|
|
148
|
+
fail('The key does not come from a saved profile, so the new one could not be kept.',
|
|
149
|
+
'Run "substrio login" first, or pass --print to receive the new key.');
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
const input = {};
|
|
153
|
+
const overlap = flag(args, '--overlap');
|
|
154
|
+
if (overlap !== null) input.overlap_seconds = duration(overlap, '--overlap', 's');
|
|
155
|
+
const expiresIn = flag(args, '--expires-in');
|
|
156
|
+
if (expiresIn !== null) {
|
|
157
|
+
// Whole days: 90d, or a bare 90.
|
|
158
|
+
const days = duration(expiresIn, '--expires-in', 'd') / SECONDS.d;
|
|
159
|
+
if (!Number.isInteger(days)) fail('--expires-in takes whole days, like 90d');
|
|
160
|
+
input.expires_in_days = days;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
const data = await invokeOperation(api, 'tenant.key.rotate', input);
|
|
164
|
+
if (typeof data?.key !== 'string' || !data.key) fail('The key was rotated but the server returned no new key.', null, ErrorCode.UNKNOWN);
|
|
165
|
+
if (saved) setProfileKey(config.profile, data.key);
|
|
166
|
+
emitIssuedKey(data, print);
|
|
167
|
+
const where = saved ? `saved to profile ${C.bold}${config.profile}${C.reset}` : 'printed above';
|
|
168
|
+
const old = data.previous
|
|
169
|
+
? `the old key works until ${whenText(data.previous.expires)}.`
|
|
170
|
+
: 'the old key no longer works.';
|
|
171
|
+
console.error(`${C.green}✓${C.reset} New key ${C.bold}${data.key_id}${C.reset} for ${C.bold}${data.slug}${C.reset} ${where}; ${old}`);
|
|
172
|
+
}
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Substrio CLI: login command
|
|
3
|
+
*
|
|
4
|
+
* Usage:
|
|
5
|
+
* substrio login Interactive login (a terminal)
|
|
6
|
+
* substrio login [--url X] --key - [--tenant T] [--profile N] [--default]
|
|
7
|
+
* The key read from stdin
|
|
8
|
+
* substrio login --offline --url X --tenant T --key - [--profile N] [--default]
|
|
9
|
+
* Saves the profile with no round trip (--tenant required)
|
|
10
|
+
* substrio login --list List saved profiles
|
|
11
|
+
* substrio login --remove NAME Remove a profile
|
|
12
|
+
*
|
|
13
|
+
* The profile keeps the URL, the key and the tenant the key belongs to
|
|
14
|
+
* (--tenant, the global flag; else the server's auth.whoami for the key).
|
|
15
|
+
* --profile (the global flag) names the profile saved. Without --profile the
|
|
16
|
+
* profile becomes the default, so later commands need neither --profile nor
|
|
17
|
+
* --tenant. With --profile it never changes the default profile (it is shared
|
|
18
|
+
* by every project) unless --default is passed or no default exists yet. With
|
|
19
|
+
* no terminal, nothing is asked: a missing key is an error. With no --url the profile is the hosted platform,
|
|
20
|
+
* DEFAULT_URL, so an outside developer's first login needs only the key.
|
|
21
|
+
*
|
|
22
|
+
* --offline skips the round trip entirely (the server need not be up yet): no
|
|
23
|
+
* auth.whoami lookup, so --tenant is required; the profile is saved marked
|
|
24
|
+
* `checked: false` (config.mjs writeProfile) until a login without --offline
|
|
25
|
+
* saves it again.
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
import { CliError, ErrorCode, Prompter, emit, fail, globals } from '../cli.mjs';
|
|
29
|
+
import { listProfiles, loadRC, rcPath, removeProfile, writeProfile } from '../config.mjs';
|
|
30
|
+
import { SubstrioAPI, errorText } from '../api.mjs';
|
|
31
|
+
import { OpError, callOperation, loadOperations } from './op.mjs';
|
|
32
|
+
|
|
33
|
+
// The hosted platform: the server a login names when no --url is given.
|
|
34
|
+
export const DEFAULT_URL = 'https://mcp.substrio.com';
|
|
35
|
+
|
|
36
|
+
const C = {
|
|
37
|
+
reset: '\x1b[0m', bold: '\x1b[1m', dim: '\x1b[2m',
|
|
38
|
+
red: '\x1b[91m', green: '\x1b[92m', yellow: '\x1b[93m', cyan: '\x1b[96m',
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
export async function cmdLogin(args) {
|
|
43
|
+
// --list
|
|
44
|
+
if (args.includes('--list')) {
|
|
45
|
+
const profiles = listProfiles();
|
|
46
|
+
if (globals.json) return emit(profiles);
|
|
47
|
+
if (profiles.length === 0) {
|
|
48
|
+
console.log(`${C.dim}No profiles saved. Run "substrio login" to add one.${C.reset}`);
|
|
49
|
+
return;
|
|
50
|
+
}
|
|
51
|
+
console.log(`\n${C.bold}Saved Profiles${C.reset}\n`);
|
|
52
|
+
const nameW = Math.max(10, ...profiles.map(p => p.name.length));
|
|
53
|
+
console.log(` ${'NAME'.padEnd(nameW)} URL`);
|
|
54
|
+
console.log(` ${'─'.repeat(nameW)} ${'─'.repeat(30)}`);
|
|
55
|
+
for (const p of profiles) {
|
|
56
|
+
const marker = p.isDefault ? ` ${C.green}(default)${C.reset}` : '';
|
|
57
|
+
console.log(` ${p.name.padEnd(nameW)} ${p.url}${marker}`);
|
|
58
|
+
}
|
|
59
|
+
console.log('');
|
|
60
|
+
return;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// --remove NAME
|
|
64
|
+
const rmIdx = args.indexOf('--remove');
|
|
65
|
+
if (rmIdx !== -1) {
|
|
66
|
+
const name = args[rmIdx + 1];
|
|
67
|
+
if (!name) {
|
|
68
|
+
console.error(`${C.red}✗ Profile name required.${C.reset}`);
|
|
69
|
+
process.exit(1);
|
|
70
|
+
}
|
|
71
|
+
if (removeProfile(name)) {
|
|
72
|
+
console.log(`${C.green}✓${C.reset} Profile ${C.bold}${name}${C.reset} removed.`);
|
|
73
|
+
} else {
|
|
74
|
+
console.error(`${C.red}✗ Profile "${name}" not found.${C.reset}`);
|
|
75
|
+
process.exit(1);
|
|
76
|
+
}
|
|
77
|
+
return;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// --url X --key Y (or - for stdin); --tenant and --profile are global.
|
|
81
|
+
let url = null, key = null, makeDefault = globals.profile === null, offline = false;
|
|
82
|
+
let tenant = globals.tenant, profileName = globals.profile;
|
|
83
|
+
for (let i = 0; i < args.length; i++) {
|
|
84
|
+
if (args[i] === '--url' && args[i + 1]) url = args[++i];
|
|
85
|
+
else if (args[i] === '--key' && args[i + 1]) key = args[++i];
|
|
86
|
+
else if (args[i] === '--default') makeDefault = true;
|
|
87
|
+
else if (args[i] === '--offline') offline = true;
|
|
88
|
+
else fail(`unexpected argument "${args[i]}"`, 'substrio login --help');
|
|
89
|
+
}
|
|
90
|
+
if (offline && !tenant) {
|
|
91
|
+
return fail('--offline needs --tenant: with no server reachable the CLI cannot look the tenant up for the key',
|
|
92
|
+
'substrio login --offline --url URL --tenant SLUG --key - < keyfile', ErrorCode.INVALID_ARGUMENT);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// --key - reads the key from stdin, keeping it out of argv and history.
|
|
96
|
+
if (key === '-') {
|
|
97
|
+
const chunks = [];
|
|
98
|
+
for await (const chunk of process.stdin) chunks.push(chunk);
|
|
99
|
+
key = Buffer.concat(chunks).toString('utf-8').trim();
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
const prompter = new Prompter();
|
|
103
|
+
try {
|
|
104
|
+
if (!url || !key) {
|
|
105
|
+
if (prompter.interactive) console.log(`\n${C.bold}${C.cyan}Substrio Login${C.reset}\n`);
|
|
106
|
+
url = url || await prompter.ask(' Server URL', DEFAULT_URL, '--url');
|
|
107
|
+
key = key || await prompter.ask(' Key (your tenant\'s key, given when it was created or by "substrio key rotate")', '', '--key -');
|
|
108
|
+
}
|
|
109
|
+
url = url.replace(/\/+$/, '');
|
|
110
|
+
if (offline) {
|
|
111
|
+
// No round trip: the tenant is required (checked above), nothing to look up.
|
|
112
|
+
} else {
|
|
113
|
+
const server = await connect(url, key);
|
|
114
|
+
// The tenant: --tenant, else the one the server says the key belongs
|
|
115
|
+
// to, else asked (default "default").
|
|
116
|
+
tenant = tenant || await keyTenant(server) || await prompter.ask(' Tenant the key belongs to', 'default');
|
|
117
|
+
}
|
|
118
|
+
profileName = profileName || await prompter.ask(' Profile name', 'default');
|
|
119
|
+
} catch (err) {
|
|
120
|
+
fail(err, offline ? 'substrio login --offline --url URL --tenant SLUG --key - < keyfile' : 'substrio login --url URL [--tenant SLUG] --key - < keyfile');
|
|
121
|
+
} finally {
|
|
122
|
+
prompter.close();
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// The profile keeps the tenant. It becomes the default when no --profile
|
|
126
|
+
// named it (later commands need neither --profile nor --tenant) or
|
|
127
|
+
// --default says so; writeProfile also makes it the default when there is
|
|
128
|
+
// none. A named profile otherwise leaves the shared default alone.
|
|
129
|
+
writeProfile(profileName, url, key, tenant, { makeDefault, checked: !offline });
|
|
130
|
+
const defaultProfile = loadRC().default_profile;
|
|
131
|
+
const isDefault = defaultProfile === profileName;
|
|
132
|
+
emit({ profile: profileName, url, tenant, default: isDefault, default_profile: defaultProfile, path: rcPath(), ...(offline ? { checked: false } : {}) }, () => {
|
|
133
|
+
console.log(`${C.green}✓${C.reset} Profile ${C.bold}${profileName}${C.reset} (tenant ${C.bold}${tenant}${C.reset}) saved to ${rcPath()}` +
|
|
134
|
+
`${isDefault ? ' as the default' : `; the default profile stays ${C.bold}${defaultProfile}${C.reset} (--default makes this one the default)`}`);
|
|
135
|
+
if (offline) console.log(`${C.dim} Saved without checking the server (--offline); "substrio whoami" shows whether the key works once the server is up.${C.reset}`);
|
|
136
|
+
console.log(`${C.dim} "substrio whoami" shows it; "substrio login --list" lists every profile.${C.reset}`);
|
|
137
|
+
});
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// The server at `url`, read with `key` and no tenant: its catalog, after
|
|
141
|
+
// system.health says it is up. Throws the server's typed error otherwise.
|
|
142
|
+
async function connect(url, key) {
|
|
143
|
+
const api = new SubstrioAPI({ url, master_key: key, tenant: null });
|
|
144
|
+
const ops = await loadOperations(api);
|
|
145
|
+
const health = await callOperation(api, ops, 'system.health', undefined);
|
|
146
|
+
if (!health.ok) {
|
|
147
|
+
throw CliError.fromResponse(`Server responded with ${health.status}: ${errorText(health.data, health.status)}. Check the URL.`,
|
|
148
|
+
health.data, health.status);
|
|
149
|
+
}
|
|
150
|
+
// The server tells its version to a holder of the master key only (a tenant or account key gets the status alone).
|
|
151
|
+
const version = health.data?.version;
|
|
152
|
+
console.log(`${C.green}✓${C.reset} Connected to server${typeof version === 'string' && version !== '' ? ` (${version})` : ''}`);
|
|
153
|
+
return { api, ops };
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
// The tenant the server says the key belongs to (auth.whoami), or null (an
|
|
157
|
+
// account or system key, or a server without whoami).
|
|
158
|
+
async function keyTenant({ api, ops }) {
|
|
159
|
+
try {
|
|
160
|
+
const result = await callOperation(api, ops, 'auth.whoami', undefined);
|
|
161
|
+
const tenant = result.ok ? result.data?.tenant : null;
|
|
162
|
+
return typeof tenant === 'string' && tenant ? tenant : null;
|
|
163
|
+
} catch (err) {
|
|
164
|
+
if (err instanceof OpError) return null;
|
|
165
|
+
throw err;
|
|
166
|
+
}
|
|
167
|
+
}
|