@datumos/datum-cli 0.0.0-stage → 0.588.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.
@@ -0,0 +1,243 @@
1
+ /**
2
+ * Derives the `datum` command tree from the OpenAPI spec — the CLI has NO
3
+ * hand-written command definitions, so it updates itself with every API
4
+ * change (the spec is the maintained contract, CLAUDE.md "API = kontrakt").
5
+ *
6
+ * Mapping rules (deterministic; commandTree.test.ts proves uniqueness
7
+ * against the real spec):
8
+ * - command tokens = static path segments after `/api`
9
+ * (`/api/cases/{id}/links` → `cases links`);
10
+ * - path params become positional arguments in path order;
11
+ * - the verb comes from the method + path shape:
12
+ * GET → `get` when the path ends with a param, else `list`;
13
+ * POST → the trailing action segment when there is one after the
14
+ * resource (`/api/cases/{id}/escalate` → `escalate`), else `create`;
15
+ * PATCH → `update`, PUT → `set`, DELETE → `delete`;
16
+ * - query params and top-level JSON body properties become `--kebab-case`
17
+ * flags (typed from their schema);
18
+ * - `x-cli-name` names a command the rules would give a taken name
19
+ * (X_CLI_NAME below); `x-cli: hidden` keeps an operation out;
20
+ * `x-cli-flag` on a body property names its flag (X_CLI_FLAG).
21
+ */
22
+ /** OpenAPI extension on a BODY property that names its CLI flag instead of
23
+ * the kebab-cased wire name (#2905) — for a field whose derived flag would
24
+ * shadow a global one (RESERVED_GLOBAL_FLAGS): the page state's `data`
25
+ * becomes `--doc`. The wire name is unchanged. */
26
+ export const X_CLI_FLAG = "x-cli-flag";
27
+ const METHODS = ["get", "post", "put", "patch", "delete"];
28
+ /**
29
+ * Global CLI flag names — derived command flags must never collide with
30
+ * these (commandTree.test.ts checks the real spec; a colliding new API
31
+ * field should get a different wire name).
32
+ */
33
+ export const RESERVED_GLOBAL_FLAGS = new Set([
34
+ "base-url",
35
+ "api-key",
36
+ "actor",
37
+ "data",
38
+ "fields",
39
+ "curl",
40
+ "raw",
41
+ "help",
42
+ "version",
43
+ ]);
44
+ export function kebabCase(name) {
45
+ return name
46
+ .replace(/([a-z0-9])([A-Z])/g, "$1-$2")
47
+ .replace(/[_\s]+/g, "-")
48
+ .toLowerCase();
49
+ }
50
+ function schemaType(schema) {
51
+ const t = schema?.type;
52
+ if (t === "number" ||
53
+ t === "integer" ||
54
+ t === "boolean" ||
55
+ t === "array" ||
56
+ t === "object") {
57
+ return t;
58
+ }
59
+ return "string";
60
+ }
61
+ function deriveVerb(method, segments) {
62
+ const tokens = segments.filter((s) => !s.startsWith("{"));
63
+ const endsWithParam = segments[segments.length - 1]?.startsWith("{") ?? false;
64
+ switch (method) {
65
+ case "get":
66
+ return { verb: endsWithParam ? "get" : "list", tokens };
67
+ case "post":
68
+ // A trailing static segment AFTER the resource is the action itself
69
+ // (`…/{id}/escalate` → `escalate`, `…/sync` → `sync`).
70
+ if (!endsWithParam && tokens.length > 1) {
71
+ return { verb: tokens[tokens.length - 1], tokens: tokens.slice(0, -1) };
72
+ }
73
+ return { verb: "create", tokens };
74
+ case "patch":
75
+ return { verb: "update", tokens };
76
+ case "put":
77
+ return { verb: "set", tokens };
78
+ case "delete":
79
+ return { verb: "delete", tokens };
80
+ default:
81
+ return { verb: method, tokens };
82
+ }
83
+ }
84
+ function buildCommand(method, path, op) {
85
+ // Segments after the leading "/api".
86
+ const segments = path.split("/").filter(Boolean).slice(1);
87
+ const derived = deriveVerb(method, segments);
88
+ const { verb, tokens } = cliNameOverride(method, path, op, derived.tokens) ?? derived;
89
+ const positionals = [];
90
+ const queryFlags = [];
91
+ for (const param of op.parameters ?? []) {
92
+ if (param.in === "path") {
93
+ positionals.push({
94
+ name: param.name,
95
+ description: param.description ?? "",
96
+ });
97
+ }
98
+ else if (param.in === "query") {
99
+ queryFlags.push({
100
+ flag: kebabCase(param.name),
101
+ name: param.name,
102
+ source: "query",
103
+ required: param.required ?? false,
104
+ type: schemaType(param.schema),
105
+ ...(param.schema?.enum
106
+ ? { enum: param.schema.enum.map(String) }
107
+ : {}),
108
+ description: param.description ?? "",
109
+ });
110
+ }
111
+ }
112
+ const bodySchema = op.requestBody?.content?.["application/json"]?.schema;
113
+ const bodyFlags = [];
114
+ const requiredProps = new Set(bodySchema?.required ?? []);
115
+ for (const [name, schema] of Object.entries(bodySchema?.properties ?? {})) {
116
+ bodyFlags.push({
117
+ // `x-cli-flag` renames a body field whose derived flag would shadow a
118
+ // global one (the page state's `data` vs `--data`, #2905); the wire
119
+ // name stays.
120
+ flag: schema[X_CLI_FLAG] ?? kebabCase(name),
121
+ name,
122
+ source: "body",
123
+ required: requiredProps.has(name),
124
+ type: schemaType(schema),
125
+ ...(schema.enum ? { enum: schema.enum.map(String) } : {}),
126
+ description: schema.description ?? "",
127
+ });
128
+ }
129
+ return {
130
+ name: [...tokens, verb].join(" "),
131
+ tokens,
132
+ verb,
133
+ method: method.toUpperCase(),
134
+ path,
135
+ summary: op.summary ?? "",
136
+ description: op.description ?? "",
137
+ tags: op.tags ?? [],
138
+ positionals,
139
+ queryFlags,
140
+ bodyFlags,
141
+ hasBody: Boolean(op.requestBody),
142
+ bodyRequired: op.requestBody?.required ?? false,
143
+ bodyDescription: bodySchema?.description ?? "",
144
+ bodyOpen: bodySchema?.additionalProperties !== false && Boolean(bodySchema),
145
+ responses: Object.entries(op.responses ?? {}).map(([status, r]) => ({ status, description: r.description ?? "" })),
146
+ examples: cliExamples(method, path, op, [...tokens, verb].join(" ")),
147
+ };
148
+ }
149
+ /** OpenAPI extension an operation sets to stay OUT of the CLI tree: a
150
+ * JSON-RPC endpoint (`/api/mcp`) that a flag-based command could not drive
151
+ * and whose name would shadow a hand-written built-in (`datum mcp serve`).
152
+ * The endpoint stays documented in the spec; only the CLI skips it. */
153
+ export const X_CLI_HIDDEN = "hidden";
154
+ /** OpenAPI extension that NAMES an operation's command instead of the
155
+ * derivation (#1562) — for the one case the rules cannot tell apart: a
156
+ * static segment where another path has a parameter
157
+ * (`GET /api/flows/runs` and `GET /api/flows/{id}/runs` both derive to
158
+ * `flows runs list`). The value is the whole command, space-separated —
159
+ * group tokens first, the verb last (`"flows runs all"`); it keeps the
160
+ * derived group (first token), so the command stays under `datum <group>`.
161
+ * Anything else is a spec bug and throws: the CLI must never guess. */
162
+ export const X_CLI_NAME = "x-cli-name";
163
+ const CLI_WORD = /^[a-z0-9][a-z0-9-]*$/;
164
+ function cliNameOverride(method, path, op, derivedTokens) {
165
+ const raw = op[X_CLI_NAME];
166
+ if (raw === undefined)
167
+ return null;
168
+ const where = `${method.toUpperCase()} ${path}: ${X_CLI_NAME}`;
169
+ if (typeof raw !== "string")
170
+ throw new Error(`${where} must be a string`);
171
+ const words = raw.trim().split(/\s+/).filter(Boolean);
172
+ if (words.length < 2) {
173
+ throw new Error(`${where} "${raw}" needs a group and a verb ("flows runs all")`);
174
+ }
175
+ const bad = words.find((w) => !CLI_WORD.test(w));
176
+ if (bad !== undefined) {
177
+ throw new Error(`${where} "${raw}": "${bad}" is not a lowercase command word`);
178
+ }
179
+ if (derivedTokens[0] !== undefined && words[0] !== derivedTokens[0]) {
180
+ throw new Error(`${where} "${raw}" must stay in the "${derivedTokens[0]}" group`);
181
+ }
182
+ return { verb: words[words.length - 1], tokens: words.slice(0, -1) };
183
+ }
184
+ /** OpenAPI extension listing hand-picked invocations of an operation's
185
+ * command (#1562) — what `--help` and cli-reference.md show under
186
+ * *Examples* when a command's useful calls are not its required flags
187
+ * (`datum flows runs all --attention`). Each entry is a whole command line
188
+ * that starts with `datum <the command's name>`; anything else is a spec
189
+ * bug and throws, like a malformed `x-cli-name`. */
190
+ export const X_CLI_EXAMPLES = "x-cli-examples";
191
+ function cliExamples(method, path, op, name) {
192
+ const raw = op[X_CLI_EXAMPLES];
193
+ if (raw === undefined)
194
+ return [];
195
+ const where = `${method.toUpperCase()} ${path}: ${X_CLI_EXAMPLES}`;
196
+ if (!Array.isArray(raw) || raw.length === 0) {
197
+ throw new Error(`${where} must be a non-empty list of command lines`);
198
+ }
199
+ return raw.map((example) => {
200
+ if (typeof example !== "string")
201
+ throw new Error(`${where} entries must be strings`);
202
+ const line = example.trim().replace(/\s+/g, " ");
203
+ if (line !== `datum ${name}` && !line.startsWith(`datum ${name} `)) {
204
+ throw new Error(`${where} "${example}" must start with "datum ${name}"`);
205
+ }
206
+ return line;
207
+ });
208
+ }
209
+ /** All CLI commands derived from the spec, sorted by name. */
210
+ export function buildCommandTree(spec) {
211
+ const commands = [];
212
+ for (const [path, operations] of Object.entries(spec.paths)) {
213
+ for (const method of METHODS) {
214
+ const op = operations[method];
215
+ if (op && op["x-cli"] !== X_CLI_HIDDEN) {
216
+ commands.push(buildCommand(method, path, op));
217
+ }
218
+ }
219
+ }
220
+ return commands.sort((a, b) => a.name.localeCompare(b.name));
221
+ }
222
+ /**
223
+ * Matches argv words against the tree: the longest command whose
224
+ * `name` tokens are a prefix of the given words wins. Returns the command
225
+ * and the remaining words (positionals/flags).
226
+ */
227
+ export function matchCommand(commands, words) {
228
+ let best = null;
229
+ for (const command of commands) {
230
+ const parts = command.name.split(" ");
231
+ if (parts.length > words.length)
232
+ continue;
233
+ if (parts.every((p, i) => p === words[i])) {
234
+ if (!best || parts.length > best.length) {
235
+ best = { command, length: parts.length };
236
+ }
237
+ }
238
+ }
239
+ return best
240
+ ? { command: best.command, rest: words.slice(best.length) }
241
+ : null;
242
+ }
243
+ //# sourceMappingURL=commandTree.js.map
@@ -0,0 +1,54 @@
1
+ import { spawnSync } from "node:child_process";
2
+ /**
3
+ * The CLI's credential process (#2719) — the `credential_process` pattern
4
+ * of the AWS CLI (#111): `DATUM_CREDENTIAL_PROCESS` names a command whose
5
+ * stdout is `{ "token": "datum_…", "expiresAt": "<ISO>" }`. The CLI runs it
6
+ * once per invocation and uses the token as its API key; nothing is
7
+ * written to the CLI's own files. On a personal agent's machine the
8
+ * runtime points it at the run plane's /datum-token (the person's own
9
+ * 15-minute token), so no long-lived secret lies on the disk.
10
+ *
11
+ * Precedence: `--api-key` > DATUM_CREDENTIAL_PROCESS > DATUM_API_KEY >
12
+ * the stored sign-in (docs/cli.md "Auth").
13
+ */
14
+ export const CREDENTIAL_PROCESS_ENV = "DATUM_CREDENTIAL_PROCESS";
15
+ /** How long the command may take (it makes one HTTPS call). */
16
+ export const CREDENTIAL_PROCESS_TIMEOUT_MS = 30_000;
17
+ export class CredentialProcessError extends Error {
18
+ }
19
+ const defaultRunner = (command) => {
20
+ // The value is the run's own configuration (the runtime writes it, or the
21
+ // person sets it); it runs under a shell like the AWS CLI's.
22
+ const out = spawnSync(command, {
23
+ shell: true,
24
+ encoding: "utf8",
25
+ timeout: CREDENTIAL_PROCESS_TIMEOUT_MS,
26
+ stdio: ["ignore", "pipe", "pipe"],
27
+ });
28
+ return { status: out.status, stdout: out.stdout ?? "", stderr: out.stderr ?? "", ...(out.error ? { error: out.error } : {}) };
29
+ };
30
+ /** Runs the credential process and reads its answer. Throws
31
+ * CredentialProcessError with a message for the person — never the
32
+ * process's stdout (it may carry the token). */
33
+ export function readCredentialProcess(command, run = defaultRunner) {
34
+ const res = run(command);
35
+ if (res.error)
36
+ throw new CredentialProcessError(`${CREDENTIAL_PROCESS_ENV} could not run: ${res.error.message}`);
37
+ if (res.status !== 0) {
38
+ const why = res.stderr.trim().split("\n").slice(-1)[0] ?? "";
39
+ throw new CredentialProcessError(`${CREDENTIAL_PROCESS_ENV} exited ${res.status ?? "?"}${why ? ` — ${why.slice(0, 300)}` : ""}`);
40
+ }
41
+ let parsed;
42
+ try {
43
+ parsed = JSON.parse(res.stdout);
44
+ }
45
+ catch {
46
+ throw new CredentialProcessError(`${CREDENTIAL_PROCESS_ENV} did not print JSON { token, expiresAt }`);
47
+ }
48
+ const p = (parsed && typeof parsed === "object" ? parsed : {});
49
+ const token = typeof p.token === "string" ? p.token.trim() : "";
50
+ if (!token)
51
+ throw new CredentialProcessError(`${CREDENTIAL_PROCESS_ENV} printed no token`);
52
+ return { token, expiresAt: typeof p.expiresAt === "string" ? p.expiresAt : "" };
53
+ }
54
+ //# sourceMappingURL=credentialProcess.js.map
@@ -0,0 +1,93 @@
1
+ import { chmodSync, existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
2
+ import { homedir } from "node:os";
3
+ import path from "node:path";
4
+ /**
5
+ * Where `datum login` keeps what it obtained (docs/developer-access.md):
6
+ * ONE JSON file, mode 0600 in a 0700 directory — the same posture `gh`
7
+ * takes on Linux. No keychain: the CLI is dependency-free and headless
8
+ * (ssh, containers) by design; the refresh token is the sensitive part and
9
+ * it lives only here, never in env or in a shell history.
10
+ *
11
+ * Location: `$DATUM_CONFIG_DIR/credentials.json`, else
12
+ * `$XDG_CONFIG_HOME/datum/credentials.json`, else `~/.config/datum/…`.
13
+ * Keyed by the ORG origin the login ran against (`https://datum.example`);
14
+ * space subdomains reuse the org's tokens — the token is the same
15
+ * person on the same app registration whichever space host answers.
16
+ */
17
+ export const CREDENTIALS_FILE = "credentials.json";
18
+ export const CREDENTIALS_VERSION = 1;
19
+ const EMPTY = { version: CREDENTIALS_VERSION, logins: {} };
20
+ export function credentialsDir(env = process.env) {
21
+ if (env.DATUM_CONFIG_DIR?.trim())
22
+ return env.DATUM_CONFIG_DIR.trim();
23
+ const xdg = env.XDG_CONFIG_HOME?.trim();
24
+ return path.join(xdg || path.join(homedir(), ".config"), "datum");
25
+ }
26
+ export function credentialsPath(env = process.env) {
27
+ return path.join(credentialsDir(env), CREDENTIALS_FILE);
28
+ }
29
+ /** The credentials key for a base URL: scheme + host + port, no path. */
30
+ export function originOf(baseUrl) {
31
+ return new URL(baseUrl).origin;
32
+ }
33
+ export class CredentialStore {
34
+ file;
35
+ constructor(file = credentialsPath()) {
36
+ this.file = file;
37
+ }
38
+ get path() {
39
+ return this.file;
40
+ }
41
+ read() {
42
+ if (!existsSync(this.file))
43
+ return { ...EMPTY, logins: {} };
44
+ try {
45
+ const parsed = JSON.parse(readFileSync(this.file, "utf8"));
46
+ if (parsed.version !== CREDENTIALS_VERSION || typeof parsed.logins !== "object" || !parsed.logins) {
47
+ return { ...EMPTY, logins: {} };
48
+ }
49
+ return {
50
+ version: CREDENTIALS_VERSION,
51
+ ...(parsed.defaultBaseUrl ? { defaultBaseUrl: parsed.defaultBaseUrl } : {}),
52
+ logins: parsed.logins,
53
+ };
54
+ }
55
+ catch {
56
+ // A corrupt file must not brick the CLI — the next login rewrites it.
57
+ return { ...EMPTY, logins: {} };
58
+ }
59
+ }
60
+ write(file) {
61
+ const dir = path.dirname(this.file);
62
+ mkdirSync(dir, { recursive: true, mode: 0o700 });
63
+ const tmp = `${this.file}.${process.pid}.tmp`;
64
+ writeFileSync(tmp, `${JSON.stringify(file, null, 2)}\n`, { mode: 0o600 });
65
+ chmodSync(tmp, 0o600);
66
+ renameSync(tmp, this.file);
67
+ }
68
+ get(baseUrl) {
69
+ return this.read().logins[originOf(baseUrl)] ?? null;
70
+ }
71
+ set(login) {
72
+ const file = this.read();
73
+ const origin = originOf(login.baseUrl);
74
+ file.logins[origin] = { ...login, baseUrl: origin };
75
+ file.defaultBaseUrl = origin;
76
+ this.write(file);
77
+ }
78
+ /** Forgets one login; returns whether there was one. */
79
+ delete(baseUrl) {
80
+ const file = this.read();
81
+ const origin = originOf(baseUrl);
82
+ const had = origin in file.logins;
83
+ delete file.logins[origin];
84
+ if (file.defaultBaseUrl === origin)
85
+ delete file.defaultBaseUrl;
86
+ this.write(file);
87
+ return had;
88
+ }
89
+ defaultBaseUrl() {
90
+ return this.read().defaultBaseUrl ?? null;
91
+ }
92
+ }
93
+ //# sourceMappingURL=credentials.js.map