@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,195 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Substrio CLI: the app's history as a git repository, and back.
|
|
3
|
+
*
|
|
4
|
+
* substrio git export DIR the whole history as commits in DIR
|
|
5
|
+
* substrio git import DIR DIR's commits as the app's history
|
|
6
|
+
*
|
|
7
|
+
* Both are one stream between two processes, with nothing held in memory on
|
|
8
|
+
* this side: `substrio_server --git-export <app>` writes a git fast-import
|
|
9
|
+
* stream (api/version_git.h) that `git fast-import` reads in DIR, and `git
|
|
10
|
+
* fast-export --all` writes the stream `substrio_server --git-import <app>`
|
|
11
|
+
* reads. The server's own stream is the one owner of the translation; this
|
|
12
|
+
* file names the two processes and joins their pipe.
|
|
13
|
+
*
|
|
14
|
+
* It is the one command of this CLI that is not an HTTP call: the history
|
|
15
|
+
* crosses with no port, no key and no request, which is also its limit - the
|
|
16
|
+
* app's database file has to be on this machine, named by --data (the
|
|
17
|
+
* directory `substrio dev` keeps, .substrio/dev, by default). A remote app is
|
|
18
|
+
* exported by running the same command where its server runs.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
import { spawn } from 'node:child_process';
|
|
22
|
+
import { existsSync, mkdirSync, readdirSync } from 'node:fs';
|
|
23
|
+
import { join, resolve } from 'node:path';
|
|
24
|
+
|
|
25
|
+
import { CliError, ErrorCode, emit, fail } from '../cli.mjs';
|
|
26
|
+
import { devDataDir } from '../config.mjs';
|
|
27
|
+
import { FlagKind, parseFlags } from '../local/args.mjs';
|
|
28
|
+
import { resolveServerBinary } from '../local/server.mjs';
|
|
29
|
+
|
|
30
|
+
const C = { reset: '\x1b[0m', bold: '\x1b[1m', dim: '\x1b[2m', green: '\x1b[92m' };
|
|
31
|
+
|
|
32
|
+
const GIT_FLAGS = {
|
|
33
|
+
'--data': FlagKind.VALUE, // the server's data directory (default: .substrio/dev)
|
|
34
|
+
'--server': FlagKind.VALUE, // the substrio_server binary (default: PATH, then the checkout's build)
|
|
35
|
+
'--owner': FlagKind.VALUE, // the account the objects are billed to
|
|
36
|
+
'--author': FlagKind.VALUE, // the person a commit by nobody this app knows is attributed to, on import
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
/** The file an app keeps its refs and its history in, under the server's data directory. */
|
|
40
|
+
export const appDatabase = (dataDir, slug) => join(dataDir, slug, 'substrio.db');
|
|
41
|
+
|
|
42
|
+
// A whole number flag, or `fallback`; a value that is not one is a usage error.
|
|
43
|
+
function number(flags, name, fallback) {
|
|
44
|
+
const text = flags.get(name);
|
|
45
|
+
if (text === undefined) return fallback;
|
|
46
|
+
if (!/^\d+$/.test(text)) throw new CliError(`${name} is an id: whole digits`, { code: ErrorCode.INVALID_ARGUMENT });
|
|
47
|
+
return Number(text);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Where the app's history lives: the data directory, the slug and the
|
|
52
|
+
* database file, all three checked before a process is started. Throws a
|
|
53
|
+
* typed CliError naming what is missing.
|
|
54
|
+
*/
|
|
55
|
+
export function historyLocation(config, flags, cwd = process.cwd()) {
|
|
56
|
+
const slug = config.tenant;
|
|
57
|
+
if (!slug) {
|
|
58
|
+
throw new CliError('No app: substrio git needs a tenant', {
|
|
59
|
+
code: ErrorCode.INVALID_ARGUMENT,
|
|
60
|
+
hint: 'Give --tenant SLUG, or run it in a project whose substrio.toml names one',
|
|
61
|
+
});
|
|
62
|
+
}
|
|
63
|
+
const dataDir = resolve(flags.get('--data') ?? devDataDir(cwd));
|
|
64
|
+
const database = appDatabase(dataDir, slug);
|
|
65
|
+
if (!existsSync(database)) {
|
|
66
|
+
throw new CliError(`${slug} keeps no database at ${database}`, {
|
|
67
|
+
code: ErrorCode.FILE_NOT_FOUND,
|
|
68
|
+
hint: flags.has('--data')
|
|
69
|
+
? 'Give --data DIR of the server that serves this app; it has to be a directory on this machine'
|
|
70
|
+
: `Give --data DIR (the default is this project's ${devDataDir(cwd)}, which "substrio dev" makes)`,
|
|
71
|
+
});
|
|
72
|
+
}
|
|
73
|
+
return { slug, dataDir, database };
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// A child process as a promise: its exit code, with its stderr relayed to
|
|
77
|
+
// ours as it comes (the server writes its one-line summary there).
|
|
78
|
+
function ended(child, what) {
|
|
79
|
+
return new Promise((done, broken) => {
|
|
80
|
+
child.on('error', (err) => broken(new CliError(`${what} could not be run: ${err.message}`, { code: ErrorCode.UNKNOWN })));
|
|
81
|
+
child.on('close', (code, signal) => done(signal ? `${what} was stopped by ${signal}` : code === 0 ? null : `${what} exited ${code}`));
|
|
82
|
+
});
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// `git` with these arguments in `dir`, its output relayed; the failure text,
|
|
86
|
+
// or null.
|
|
87
|
+
function git(args, dir, stdio = 'inherit') {
|
|
88
|
+
return ended(spawn('git', args, { cwd: dir, stdio }), `git ${args[0]}`);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** The arguments a stream command runs substrio_server with (pure, so a test reads them). */
|
|
92
|
+
export function serverArgs({ dataDir, database, slug, owner = 0, author = null }, direction) {
|
|
93
|
+
const args = ['--data', dataDir, `--git-${direction}`, slug, '--git-db', database, '--git-owner', String(owner)];
|
|
94
|
+
if (author !== null) args.push('--git-author', String(author));
|
|
95
|
+
return args;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export async function cmdGit(config, args = []) {
|
|
99
|
+
const word = args[0];
|
|
100
|
+
if (word === 'export') return gitExport(config, args.slice(1));
|
|
101
|
+
if (word === 'import') return gitImport(config, args.slice(1));
|
|
102
|
+
return fail('substrio git export DIR | substrio git import DIR',
|
|
103
|
+
'export writes the app\'s history into a git repository; import reads one back');
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// The repository a stream is written into: an existing one is used as it is,
|
|
107
|
+
// an empty (or missing) directory is made one. A directory holding something
|
|
108
|
+
// else is refused, because a fast-import would write refs into nothing.
|
|
109
|
+
function repositoryFor(dir) {
|
|
110
|
+
if (existsSync(join(dir, '.git'))) return { made: false };
|
|
111
|
+
if (existsSync(dir) && readdirSync(dir).length > 0) {
|
|
112
|
+
throw new CliError(`${dir} is not a git repository and is not empty`, {
|
|
113
|
+
code: ErrorCode.INVALID_ARGUMENT,
|
|
114
|
+
hint: 'Name an empty directory, or one that is already a repository',
|
|
115
|
+
});
|
|
116
|
+
}
|
|
117
|
+
mkdirSync(dir, { recursive: true });
|
|
118
|
+
return { made: true };
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
async function gitExport(config, args) {
|
|
122
|
+
let where;
|
|
123
|
+
let dir;
|
|
124
|
+
let repository;
|
|
125
|
+
let run;
|
|
126
|
+
try {
|
|
127
|
+
const { flags, positional } = parseFlags(args, GIT_FLAGS);
|
|
128
|
+
if (!positional[0]) {
|
|
129
|
+
throw new CliError('substrio git export DIR names the directory the history is written into',
|
|
130
|
+
{ code: ErrorCode.INVALID_ARGUMENT });
|
|
131
|
+
}
|
|
132
|
+
dir = resolve(positional[0]);
|
|
133
|
+
where = historyLocation(config, flags);
|
|
134
|
+
repository = repositoryFor(dir);
|
|
135
|
+
run = { binary: resolveServerBinary(flags.get('--server')), owner: number(flags, '--owner', 0) };
|
|
136
|
+
} catch (err) {
|
|
137
|
+
return fail(err);
|
|
138
|
+
}
|
|
139
|
+
if (repository.made) {
|
|
140
|
+
const failed = await git(['init', '--quiet', '.'], dir);
|
|
141
|
+
if (failed) return fail(failed, 'git has to be on PATH for substrio git export');
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// One pipe, two processes: the server writes the stream, git reads it. The
|
|
145
|
+
// bytes never enter this process.
|
|
146
|
+
const server = spawn(run.binary, serverArgs({ ...where, owner: run.owner }, 'export'),
|
|
147
|
+
{ stdio: ['ignore', 'pipe', 'inherit'] });
|
|
148
|
+
const importer = spawn('git', ['fast-import', '--quiet'], { cwd: dir, stdio: ['pipe', 'inherit', 'inherit'] });
|
|
149
|
+
server.stdout.pipe(importer.stdin);
|
|
150
|
+
const [wrote, read] = await Promise.all([ended(server, 'substrio_server --git-export'), ended(importer, 'git fast-import')]);
|
|
151
|
+
if (wrote || read) return fail(wrote ?? read, 'nothing of the app changed; the repository may hold a partial import');
|
|
152
|
+
|
|
153
|
+
// A repository this command made holds the history and no files yet; one
|
|
154
|
+
// that was already here keeps its working tree exactly as it was.
|
|
155
|
+
if (repository.made) await git(['checkout', '--quiet', 'main'], dir, ['ignore', 'inherit', 'ignore']);
|
|
156
|
+
emit({ exported: where.slug, directory: dir, repository: repository.made ? 'created' : 'existing' }, () => {
|
|
157
|
+
console.log(`${C.green}✓${C.reset} ${C.bold}${where.slug}${C.reset}'s history is in ${dir}` +
|
|
158
|
+
`${repository.made ? '' : ` ${C.dim}(its refs; the working tree was left alone)${C.reset}`}`);
|
|
159
|
+
console.log(` ${C.dim}git -C ${dir} log --graph --all${C.reset}`);
|
|
160
|
+
});
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
async function gitImport(config, args) {
|
|
164
|
+
let where;
|
|
165
|
+
let dir;
|
|
166
|
+
let run;
|
|
167
|
+
try {
|
|
168
|
+
const { flags, positional } = parseFlags(args, GIT_FLAGS);
|
|
169
|
+
if (!positional[0]) {
|
|
170
|
+
throw new CliError('substrio git import DIR names the git repository the history is read from',
|
|
171
|
+
{ code: ErrorCode.INVALID_ARGUMENT });
|
|
172
|
+
}
|
|
173
|
+
dir = resolve(positional[0]);
|
|
174
|
+
if (!existsSync(join(dir, '.git'))) {
|
|
175
|
+
throw new CliError(`${dir} is not a git repository`, { code: ErrorCode.FILE_NOT_FOUND });
|
|
176
|
+
}
|
|
177
|
+
where = historyLocation(config, flags);
|
|
178
|
+
run = {
|
|
179
|
+
binary: resolveServerBinary(flags.get('--server')),
|
|
180
|
+
owner: number(flags, '--owner', 0), author: number(flags, '--author', 0),
|
|
181
|
+
};
|
|
182
|
+
} catch (err) {
|
|
183
|
+
return fail(err);
|
|
184
|
+
}
|
|
185
|
+
const exporter = spawn('git', ['fast-export', '--all'], { cwd: dir, stdio: ['ignore', 'pipe', 'inherit'] });
|
|
186
|
+
const server = spawn(run.binary, serverArgs({ ...where, owner: run.owner, author: run.author }, 'import'),
|
|
187
|
+
{ stdio: ['pipe', 'inherit', 'inherit'] });
|
|
188
|
+
exporter.stdout.pipe(server.stdin);
|
|
189
|
+
const [wrote, read] = await Promise.all([ended(exporter, 'git fast-export'), ended(server, 'substrio_server --git-import')]);
|
|
190
|
+
if (wrote || read) return fail(read ?? wrote, 'the app took only the commits the stream carried before the refusal');
|
|
191
|
+
emit({ imported: where.slug, directory: dir }, () => {
|
|
192
|
+
console.log(`${C.green}✓${C.reset} ${dir} is now ${C.bold}${where.slug}${C.reset}'s history ${C.dim}(the counts above are the server's)${C.reset}`);
|
|
193
|
+
console.log(` ${C.dim}substrio branch, substrio log${C.reset}`);
|
|
194
|
+
});
|
|
195
|
+
}
|
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Substrio CLI: group commands, run. The table itself (GROUPS, REGISTRY_GROUPS,
|
|
3
|
+
* resolveGroup, entryOf, isDestructiveOperation...) is pure data in
|
|
4
|
+
* ./groups.mjs, loaded unconditionally so --version and --help stay light;
|
|
5
|
+
* this module is the one owner of running a group command (a server call, a
|
|
6
|
+
* confirmation, a file write), loaded only once a group command runs.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
10
|
+
import { confirmDestructive, fail } from '../cli.mjs';
|
|
11
|
+
import { writePrivateFile } from '../config.mjs';
|
|
12
|
+
import { splitPositional } from '../ops.mjs';
|
|
13
|
+
import {
|
|
14
|
+
GroupFlow, groupOf, isDestructiveOperation, registryGroupsOf, registryOperation, resolveGroup,
|
|
15
|
+
} from './groups.mjs';
|
|
16
|
+
import { loadOperations, runOperation } from './op.mjs';
|
|
17
|
+
|
|
18
|
+
const C = {
|
|
19
|
+
reset: '\x1b[0m', bold: '\x1b[1m', green: '\x1b[92m',
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
/** The operation `substrio command ...args` names in its registry groups, from the server's catalog. */
|
|
23
|
+
export async function findRegistryOperation(api, command, args) {
|
|
24
|
+
const ops = await loadOperations(api);
|
|
25
|
+
const found = registryOperation(new Set(ops.map((op) => op.name)), registryGroupsOf(command), args);
|
|
26
|
+
if (!found) {
|
|
27
|
+
const groups = registryGroupsOf(command);
|
|
28
|
+
throw new Error(`Unknown ${command} command: ${args.filter((a) => typeof a === 'string' && !a.startsWith('-') && !a.includes('=')).join(' ') || '(none)'}. ` +
|
|
29
|
+
`Its operations: ${groups.map((g) => `substrio op list ${g}.`).join(', ') || 'substrio op list'}`);
|
|
30
|
+
}
|
|
31
|
+
return { ...found, ops };
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** Runs the registry operation `substrio command ...args` names (group words and flows alike). */
|
|
35
|
+
export async function cmdRegistry(api, command, args) {
|
|
36
|
+
let found;
|
|
37
|
+
try {
|
|
38
|
+
found = await findRegistryOperation(api, command, args);
|
|
39
|
+
} catch (err) {
|
|
40
|
+
return fail(err, `substrio ${command} --help`);
|
|
41
|
+
}
|
|
42
|
+
// An operation with both a write and a delete route is destructive only
|
|
43
|
+
// by its DELETE route, not by its own name:
|
|
44
|
+
// an explicit --method/-X DELETE on the pass-through path is destructive too.
|
|
45
|
+
const methodAt = Math.max(found.rest.indexOf('--method'), found.rest.indexOf('-X'));
|
|
46
|
+
const destructiveMethod = methodAt >= 0 && found.rest[methodAt + 1]?.toUpperCase() === 'DELETE';
|
|
47
|
+
if (isDestructiveOperation(found.operation) || destructiveMethod) {
|
|
48
|
+
await confirmDestructive(`substrio ${command} ${args.join(' ')} runs ${found.operation}, which cannot be undone`,
|
|
49
|
+
`substrio ${command} ${args.join(' ')} --yes`);
|
|
50
|
+
}
|
|
51
|
+
const { positional, rest } = splitPositional(found.rest);
|
|
52
|
+
return runOperation(api, found.operation, rest, { positional, ops: found.ops });
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// Takes `--out FILE` out of `args`; returns the file, or null.
|
|
56
|
+
export function takeOut(args) {
|
|
57
|
+
const at = args.indexOf('--out');
|
|
58
|
+
if (at < 0) return null;
|
|
59
|
+
if (at + 1 >= args.length) throw new Error('--out needs a file');
|
|
60
|
+
const [, file] = args.splice(at, 2);
|
|
61
|
+
return file;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// A new account key goes to `file`, owner-only and never over an existing
|
|
65
|
+
// file; what is printed says where it went, never the key.
|
|
66
|
+
export function saveKey(file) {
|
|
67
|
+
return (data) => {
|
|
68
|
+
const { key, ...shown } = data;
|
|
69
|
+
if (typeof key === 'string') {
|
|
70
|
+
writePrivateFile(file, `${key}\n`, { exclusive: true });
|
|
71
|
+
console.error(`${C.green}✓${C.reset} Key written to ${file} (owner-only); it is shown nowhere else.`);
|
|
72
|
+
}
|
|
73
|
+
return shown;
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// Takes `flag` and its value out of `args`; undefined when `flag` is absent.
|
|
78
|
+
function takeFlag(args, flag) {
|
|
79
|
+
const at = args.indexOf(flag);
|
|
80
|
+
if (at < 0) return undefined;
|
|
81
|
+
if (at + 1 >= args.length) throw new Error(`${flag} needs a value`);
|
|
82
|
+
const [, value] = args.splice(at, 2);
|
|
83
|
+
return value;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const SECRET_SET_USAGE = 'substrio secrets set KEY (the value from stdin, or -, --from-file PATH, or --from-env NAME)';
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* secrets set KEY: the value is never a plain argument (argv, history and
|
|
90
|
+
* process lists would show it) -- stdin (the default, and what "-" or a
|
|
91
|
+
* non-TTY stdin means), --from-file PATH or --from-env NAME, one of the
|
|
92
|
+
* three. Never logged, printed or put in an error.
|
|
93
|
+
*/
|
|
94
|
+
async function cmdSecretSet(api, resolved) {
|
|
95
|
+
const [key, ...restPositional] = resolved.positional;
|
|
96
|
+
if (!key) fail(`Usage: ${SECRET_SET_USAGE}`);
|
|
97
|
+
const rest = [...resolved.rest];
|
|
98
|
+
let fromFile, fromEnv;
|
|
99
|
+
try {
|
|
100
|
+
fromFile = takeFlag(rest, '--from-file');
|
|
101
|
+
fromEnv = takeFlag(rest, '--from-env');
|
|
102
|
+
} catch (err) {
|
|
103
|
+
fail(err);
|
|
104
|
+
}
|
|
105
|
+
// A word of any shape after KEY (plain text, or one that looks like a
|
|
106
|
+
// flag or a key=value field, which rest rather than positional carries)
|
|
107
|
+
// is the value given as an argument: refused before it is ever read.
|
|
108
|
+
const explicitStdin = restPositional.length === 1 && restPositional[0] === '-';
|
|
109
|
+
if ((!explicitStdin && restPositional.length > 0) || rest.length > 0) {
|
|
110
|
+
fail('secrets set does not take the value as an argument: it would show in argv, history and process lists',
|
|
111
|
+
`substrio secrets set ${key} < file (stdin), --from-file PATH, or --from-env NAME`);
|
|
112
|
+
}
|
|
113
|
+
if ([fromFile !== undefined, fromEnv !== undefined, explicitStdin].filter(Boolean).length > 1) {
|
|
114
|
+
fail('secrets set: give the value one way only: stdin, --from-file PATH or --from-env NAME');
|
|
115
|
+
}
|
|
116
|
+
let value;
|
|
117
|
+
if (fromFile !== undefined) value = readFileSync(fromFile, 'utf8').replace(/\r?\n$/, '');
|
|
118
|
+
else if (fromEnv !== undefined) {
|
|
119
|
+
value = process.env[fromEnv];
|
|
120
|
+
if (value === undefined) fail(`secrets set: ${fromEnv} is not set`);
|
|
121
|
+
} else if (explicitStdin || !process.stdin.isTTY) value = readFileSync(0, 'utf8').replace(/\r?\n$/, '');
|
|
122
|
+
else fail(`Usage: ${SECRET_SET_USAGE}`, 'the value is never a plain argument');
|
|
123
|
+
return runOperation(api, 'secret.write', [`key==${key}`, `value==${value}`], { method: 'PUT' });
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// permissions grant/revoke USER_ID NAME ...: the names are a variable number
|
|
127
|
+
// of trailing bare words, so user.permissions.set's own array field (grant
|
|
128
|
+
// or revoke) is built here, not through resolveGroup's trailing splice
|
|
129
|
+
// (which only ever takes a fixed field count).
|
|
130
|
+
async function cmdUserPermissionsEdit(api, resolved, field) {
|
|
131
|
+
const [id, ...names] = resolved.positional;
|
|
132
|
+
if (!id || names.length === 0) fail(`Usage: substrio permissions ${field} USER_ID NAME ...`);
|
|
133
|
+
return runOperation(api, 'user.permissions.set', [...resolved.rest, `id==${id}`, `${field}:=${JSON.stringify(names)}`]);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// memberships grant/revoke/show's --in COLLECTION:ID: the organization
|
|
137
|
+
// (user.memberships.get/set's collection and id fields), taken out of `rest`
|
|
138
|
+
// in place; undefined when --in was not given.
|
|
139
|
+
function takeMembershipIn(rest) {
|
|
140
|
+
const value = takeFlag(rest, '--in');
|
|
141
|
+
if (value === undefined) return undefined;
|
|
142
|
+
const at = value.indexOf(':');
|
|
143
|
+
if (at < 0) fail(`--in needs collection:id, not ${value}`);
|
|
144
|
+
return { collection: value.slice(0, at), id: value.slice(at + 1) };
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// memberships grant/revoke USER_ID NAME ... --in COLLECTION:ID: the names
|
|
148
|
+
// are a variable number of trailing bare words, so user.memberships.set's
|
|
149
|
+
// own array field (grant or revoke) is built here, as
|
|
150
|
+
// cmdUserPermissionsEdit builds user.permissions.set's. user_id travels
|
|
151
|
+
// unquoted (=, not ==): the schema needs it a JSON integer, not a string.
|
|
152
|
+
async function cmdUserMembershipsEdit(api, resolved, field) {
|
|
153
|
+
const [userId, ...names] = resolved.positional;
|
|
154
|
+
const rest = [...resolved.rest];
|
|
155
|
+
const membership = takeMembershipIn(rest);
|
|
156
|
+
if (!userId || names.length === 0 || !membership) {
|
|
157
|
+
fail(`Usage: substrio memberships ${field} USER_ID NAME ... --in COLLECTION:ID`);
|
|
158
|
+
}
|
|
159
|
+
return runOperation(api, 'user.memberships.set', [
|
|
160
|
+
...rest, `user_id=${userId}`, `collection==${membership.collection}`, `id==${membership.id}`,
|
|
161
|
+
`${field}:=${JSON.stringify(names)}`,
|
|
162
|
+
]);
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// memberships show USER_ID [--in COLLECTION:ID]: user.memberships.get is a
|
|
166
|
+
// GET, so these fields travel in the query string (buildRequest's own rule
|
|
167
|
+
// for GET), not the body; --in is optional (every organization held, else
|
|
168
|
+
// just the one named).
|
|
169
|
+
async function cmdUserMembershipsShow(api, resolved) {
|
|
170
|
+
const [userId] = resolved.positional;
|
|
171
|
+
const rest = [...resolved.rest];
|
|
172
|
+
const membership = takeMembershipIn(rest);
|
|
173
|
+
if (!userId) fail('Usage: substrio memberships show USER_ID [--in COLLECTION:ID]');
|
|
174
|
+
const fields = [...rest, `user_id=${userId}`];
|
|
175
|
+
if (membership) fields.push(`collection==${membership.collection}`, `id==${membership.id}`);
|
|
176
|
+
return runOperation(api, 'user.memberships.get', fields);
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
// Each flow's runner, loaded when its words run.
|
|
180
|
+
const FLOWS = {
|
|
181
|
+
[GroupFlow.IMPORT]: async (api, resolved) => (await import('./import.mjs')).cmdImport(api, resolved),
|
|
182
|
+
[GroupFlow.COUNT]: async (api, resolved) => (await import('./count.mjs')).cmdCount(api, resolved),
|
|
183
|
+
[GroupFlow.MCP_CONFIG]: async (api, resolved, context) => (await import('./mcp-config.mjs')).cmdMcpConfig(api, resolved, context),
|
|
184
|
+
[GroupFlow.TOOL_INSTALL]: async (api, resolved) => (await import('./tools.mjs')).cmdToolInstall(api, resolved),
|
|
185
|
+
[GroupFlow.SECRET_SET]: async (api, resolved) => cmdSecretSet(api, resolved),
|
|
186
|
+
[GroupFlow.USER_PERMISSIONS_GRANT]: async (api, resolved) => cmdUserPermissionsEdit(api, resolved, 'grant'),
|
|
187
|
+
[GroupFlow.USER_PERMISSIONS_REVOKE]: async (api, resolved) => cmdUserPermissionsEdit(api, resolved, 'revoke'),
|
|
188
|
+
[GroupFlow.USER_MEMBERSHIPS_GRANT]: async (api, resolved) => cmdUserMembershipsEdit(api, resolved, 'grant'),
|
|
189
|
+
[GroupFlow.USER_MEMBERSHIPS_REVOKE]: async (api, resolved) => cmdUserMembershipsEdit(api, resolved, 'revoke'),
|
|
190
|
+
[GroupFlow.USER_MEMBERSHIPS_SHOW]: async (api, resolved) => cmdUserMembershipsShow(api, resolved),
|
|
191
|
+
};
|
|
192
|
+
|
|
193
|
+
// `context` is what the command's entry gave beyond the API client: {config}.
|
|
194
|
+
export async function cmdGroup(api, command, args, context = {}) {
|
|
195
|
+
const group = groupOf(command);
|
|
196
|
+
let resolved;
|
|
197
|
+
let out = null;
|
|
198
|
+
const rest = [...args];
|
|
199
|
+
try {
|
|
200
|
+
out = takeOut(rest);
|
|
201
|
+
resolved = resolveGroup(group, rest);
|
|
202
|
+
if (!resolved.operation && out) throw new Error('--out is for accounts keys create');
|
|
203
|
+
if (resolved.operation === 'account.key.create' && !out) {
|
|
204
|
+
throw new Error('keys create needs --out FILE: the new key is written there, never printed');
|
|
205
|
+
}
|
|
206
|
+
if (out && existsSync(out)) {
|
|
207
|
+
throw new Error(`${out} exists; --out names a new file for the key`);
|
|
208
|
+
}
|
|
209
|
+
} catch (err) {
|
|
210
|
+
fail(err, `substrio ${group} --help lists its commands; any other operation: substrio op list`);
|
|
211
|
+
}
|
|
212
|
+
if (resolved.destructive) {
|
|
213
|
+
await confirmDestructive(`substrio ${command} ${args.join(' ')} runs ${resolved.operation}, which cannot be undone`,
|
|
214
|
+
`substrio ${command} ${args.join(' ')} --yes`);
|
|
215
|
+
}
|
|
216
|
+
if (resolved.flow !== null) return FLOWS[resolved.flow](api, resolved, context);
|
|
217
|
+
if (!resolved.operation) return cmdRegistry(api, group, rest);
|
|
218
|
+
// A "-" value is read from stdin, keeping it out of argv and history.
|
|
219
|
+
if (resolved.stdinField) {
|
|
220
|
+
resolved.rest.push(`${resolved.stdinField}==${readFileSync(0, 'utf8').replace(/\r?\n$/, '')}`);
|
|
221
|
+
}
|
|
222
|
+
const present = resolved.operation === 'account.key.create' ? saveKey(out) : null;
|
|
223
|
+
return runOperation(api, resolved.operation, resolved.rest, {
|
|
224
|
+
positional: resolved.positional, present, render: resolved.render, method: resolved.method,
|
|
225
|
+
defaults: resolved.defaults, body: resolved.body,
|
|
226
|
+
});
|
|
227
|
+
}
|