@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.
Files changed (56) hide show
  1. package/LICENSE +177 -0
  2. package/README.md +66 -2
  3. package/bin/substrio.mjs +91 -0
  4. package/package.json +44 -4
  5. package/src/api.mjs +107 -0
  6. package/src/apply.mjs +204 -0
  7. package/src/blobs.mjs +102 -0
  8. package/src/cli.mjs +405 -0
  9. package/src/collections.mjs +701 -0
  10. package/src/command-table.mjs +436 -0
  11. package/src/commands/bundle.mjs +369 -0
  12. package/src/commands/count.mjs +39 -0
  13. package/src/commands/deploy.mjs +488 -0
  14. package/src/commands/dev.mjs +246 -0
  15. package/src/commands/env.mjs +115 -0
  16. package/src/commands/files.mjs +172 -0
  17. package/src/commands/git.mjs +195 -0
  18. package/src/commands/groups-runtime.mjs +227 -0
  19. package/src/commands/groups.mjs +417 -0
  20. package/src/commands/history.mjs +374 -0
  21. package/src/commands/import.mjs +506 -0
  22. package/src/commands/init.mjs +175 -0
  23. package/src/commands/key.mjs +172 -0
  24. package/src/commands/login.mjs +167 -0
  25. package/src/commands/logs.mjs +285 -0
  26. package/src/commands/mcp-config.mjs +102 -0
  27. package/src/commands/op.mjs +265 -0
  28. package/src/commands/open.mjs +35 -0
  29. package/src/commands/push.mjs +199 -0
  30. package/src/commands/signup.mjs +164 -0
  31. package/src/commands/site.mjs +129 -0
  32. package/src/commands/skills.mjs +177 -0
  33. package/src/commands/status.mjs +78 -0
  34. package/src/commands/tenants.mjs +172 -0
  35. package/src/commands/test.mjs +167 -0
  36. package/src/commands/tools.mjs +63 -0
  37. package/src/commands/versions.mjs +293 -0
  38. package/src/config.mjs +569 -0
  39. package/src/documents.mjs +139 -0
  40. package/src/function-build.mjs +273 -0
  41. package/src/identity.mjs +248 -0
  42. package/src/jsonfile.mjs +22 -0
  43. package/src/live-move.mjs +267 -0
  44. package/src/local/args.mjs +58 -0
  45. package/src/local/reload.mjs +408 -0
  46. package/src/local/server.mjs +291 -0
  47. package/src/local/watch.mjs +175 -0
  48. package/src/manifest.mjs +591 -0
  49. package/src/ops.mjs +265 -0
  50. package/src/readers.mjs +416 -0
  51. package/src/sdk.mjs +26 -0
  52. package/src/settings.mjs +117 -0
  53. package/src/testing/app.mjs +507 -0
  54. package/src/testing/env.mjs +35 -0
  55. package/src/testing/index.mjs +11 -0
  56. package/src/testing/types.mjs +55 -0
@@ -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
+ }