@tula-tech/cli 0.0.0-stage → 0.1.1
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 +202 -0
- package/README.md +47 -2
- package/dist/bin.js +8 -0
- package/dist/index.d.ts +1898 -0
- package/dist/index.js +101 -0
- package/dist/shared/chunk-4qr9zsgj.js +4437 -0
- package/package.json +45 -4
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,1898 @@
|
|
|
1
|
+
import { Readable, Writable } from "node:stream";
|
|
2
|
+
import { AdminFetch } from "@tula-tech/admin";
|
|
3
|
+
/**
|
|
4
|
+
* A mistake in how the CLI was called: an unknown option, a missing value, a missing setting.
|
|
5
|
+
* Reported as `error: …` with exit code 1.
|
|
6
|
+
*
|
|
7
|
+
* @example
|
|
8
|
+
* ```ts
|
|
9
|
+
* throw new UsageError('Set TULA_API_URL, or pass --api-url.')
|
|
10
|
+
* ```
|
|
11
|
+
*/
|
|
12
|
+
declare class UsageError extends Error {
|
|
13
|
+
/** @param message - What was wrong, and what to do instead. */
|
|
14
|
+
constructor(message: string);
|
|
15
|
+
}
|
|
16
|
+
/**
|
|
17
|
+
* One option of a command.
|
|
18
|
+
*
|
|
19
|
+
* @example
|
|
20
|
+
* ```ts
|
|
21
|
+
* const yes: OptionSpec = { type: 'boolean', short: 'y', description: 'Do not ask.' }
|
|
22
|
+
* ```
|
|
23
|
+
*/
|
|
24
|
+
interface OptionSpec {
|
|
25
|
+
/** `boolean` for a flag, `string` for an option that takes a value. */
|
|
26
|
+
type: "boolean" | "string";
|
|
27
|
+
/** A one-letter alias, e.g. `y` for `-y`. */
|
|
28
|
+
short?: string;
|
|
29
|
+
/** The value's name in the help text, e.g. `<name>`. */
|
|
30
|
+
value?: string;
|
|
31
|
+
/** One line for the help text. */
|
|
32
|
+
description: string;
|
|
33
|
+
}
|
|
34
|
+
/**
|
|
35
|
+
* Parsed arguments.
|
|
36
|
+
*
|
|
37
|
+
* @example
|
|
38
|
+
* ```ts
|
|
39
|
+
* const { flags } = parseArgs(['--env', 'prod', '--yes'], options)
|
|
40
|
+
* flags.env // 'prod'
|
|
41
|
+
* ```
|
|
42
|
+
*/
|
|
43
|
+
interface ParsedArgs {
|
|
44
|
+
/** Options by name: `true` for a flag that was given, the value for an option. */
|
|
45
|
+
flags: Record<string, string | boolean | undefined>;
|
|
46
|
+
/** Everything that is not an option. */
|
|
47
|
+
positionals: string[];
|
|
48
|
+
}
|
|
49
|
+
/**
|
|
50
|
+
* Parse a command's arguments: `--name`, `--name value`, `--name=value`, `-y`, and `--` to end
|
|
51
|
+
* the options. No abbreviations and no grouped short flags, so what a script passes means one
|
|
52
|
+
* thing.
|
|
53
|
+
*
|
|
54
|
+
* An error names the option and never repeats a value: what follows an option may be a secret
|
|
55
|
+
* passed by mistake.
|
|
56
|
+
*
|
|
57
|
+
* @param argv - The arguments after the command's name.
|
|
58
|
+
* @param options - The options the command takes, by name.
|
|
59
|
+
* @returns The options that were given and the positional arguments.
|
|
60
|
+
* @throws UsageError for an unknown option, a missing value, or a value given to a flag.
|
|
61
|
+
*
|
|
62
|
+
* @example
|
|
63
|
+
* ```ts
|
|
64
|
+
* parseArgs(['--env=prod', '-y'], { env: { type: 'string', description: '' }, yes: { type: 'boolean', short: 'y', description: '' } })
|
|
65
|
+
* // { flags: { env: 'prod', yes: true }, positionals: [] }
|
|
66
|
+
* ```
|
|
67
|
+
*/
|
|
68
|
+
declare function parseArgs(argv: readonly string[], options: Record<string, OptionSpec>): ParsedArgs;
|
|
69
|
+
/**
|
|
70
|
+
* One finished child process.
|
|
71
|
+
*
|
|
72
|
+
* @example
|
|
73
|
+
* ```ts
|
|
74
|
+
* const { code, stdout } = await host.run(['docker', 'compose', 'version'], { timeoutMs: 10_000 })
|
|
75
|
+
* ```
|
|
76
|
+
*/
|
|
77
|
+
interface RunResult {
|
|
78
|
+
/** The exit code; `null` when the process was killed (a timeout, a signal). */
|
|
79
|
+
code: number | null;
|
|
80
|
+
/** What it wrote to standard output. Empty when the output was passed through. */
|
|
81
|
+
stdout: string;
|
|
82
|
+
/** What it wrote to standard error. Empty when the output was passed through. */
|
|
83
|
+
stderr: string;
|
|
84
|
+
/** Whether it was killed because it ran past its timeout. */
|
|
85
|
+
timedOut: boolean;
|
|
86
|
+
}
|
|
87
|
+
/**
|
|
88
|
+
* How a child process is run.
|
|
89
|
+
*
|
|
90
|
+
* @example
|
|
91
|
+
* ```ts
|
|
92
|
+
* const options: RunOptions = { timeoutMs: 60_000, env: { COMPOSE_PROJECT_NAME: 'shop' } }
|
|
93
|
+
* ```
|
|
94
|
+
*/
|
|
95
|
+
interface RunOptions {
|
|
96
|
+
/** The working directory. */
|
|
97
|
+
cwd?: string;
|
|
98
|
+
/** Variables added to the process's own environment. */
|
|
99
|
+
env?: Readonly<Record<string, string>>;
|
|
100
|
+
/** Kill the process after this long. Every spawn has one: a child that hangs must not hang the CLI. */
|
|
101
|
+
timeoutMs: number;
|
|
102
|
+
/**
|
|
103
|
+
* `capture` (the default) collects the output and shows none of it; `inherit` passes
|
|
104
|
+
* standard error through so a long step shows its progress. Standard output is always
|
|
105
|
+
* captured: a minted key is printed there.
|
|
106
|
+
*/
|
|
107
|
+
stderr?: "capture" | "inherit";
|
|
108
|
+
/**
|
|
109
|
+
* Run the command to its end whatever signal the CLI receives: it is not stopped by an
|
|
110
|
+
* interrupt, and is started outside the terminal's foreground group, so a Ctrl-C typed
|
|
111
|
+
* there does not reach it either. Its timeout still ends it. For the one command that
|
|
112
|
+
* must not be cut short by the interrupt it answers: closing a tunnel.
|
|
113
|
+
*/
|
|
114
|
+
shielded?: boolean;
|
|
115
|
+
}
|
|
116
|
+
/**
|
|
117
|
+
* Everything `tula dev` does outside the API: running `docker compose` and reading and writing
|
|
118
|
+
* the project's files. A test passes a fake, so no test needs Docker.
|
|
119
|
+
*
|
|
120
|
+
* @example
|
|
121
|
+
* ```ts
|
|
122
|
+
* const host: Host = createProcessHost()
|
|
123
|
+
* await host.run(['docker', 'compose', 'up', '-d'], { timeoutMs: 300_000 })
|
|
124
|
+
* ```
|
|
125
|
+
*/
|
|
126
|
+
interface Host {
|
|
127
|
+
/**
|
|
128
|
+
* Run a command to its end.
|
|
129
|
+
*
|
|
130
|
+
* @param command - The executable and its arguments. Never a shell line.
|
|
131
|
+
* @param options - Directory, environment, timeout.
|
|
132
|
+
* @returns The exit code and the captured output.
|
|
133
|
+
* @throws Error with `code: 'ENOENT'` when the executable does not exist.
|
|
134
|
+
*/
|
|
135
|
+
run(command: readonly string[], options: RunOptions): Promise<RunResult>;
|
|
136
|
+
/**
|
|
137
|
+
* Read a file as text.
|
|
138
|
+
*
|
|
139
|
+
* @param path - Absolute path.
|
|
140
|
+
* @returns The text, or `null` when the file does not exist.
|
|
141
|
+
* @throws UsageError when `path` is a symbolic link or anything else that is not a regular
|
|
142
|
+
* file (a named pipe, a directory): refused before it is opened, so a read never waits.
|
|
143
|
+
*/
|
|
144
|
+
readFile(path: string): Promise<string | null>;
|
|
145
|
+
/**
|
|
146
|
+
* Write a file, replacing it atomically, readable by its owner only.
|
|
147
|
+
*
|
|
148
|
+
* @param path - Absolute path.
|
|
149
|
+
* @param text - The contents.
|
|
150
|
+
* @throws UsageError when `path` is a symbolic link: nothing is written through one.
|
|
151
|
+
*/
|
|
152
|
+
writeSecretFile(path: string, text: string): Promise<void>;
|
|
153
|
+
/**
|
|
154
|
+
* Create a file that must not exist yet, readable by its owner only (mode 0600). Asking and
|
|
155
|
+
* taking the name are one step, so nothing that appears at the path meanwhile is replaced.
|
|
156
|
+
*
|
|
157
|
+
* @param path - Absolute path.
|
|
158
|
+
* @param text - The contents.
|
|
159
|
+
* @throws UsageError when anything is at `path`: a file (whatever it holds), a directory, a
|
|
160
|
+
* named pipe or a symbolic link. Nothing there is opened or changed.
|
|
161
|
+
*/
|
|
162
|
+
createSecretFile(path: string, text: string): Promise<void>;
|
|
163
|
+
/**
|
|
164
|
+
* Remove a file. A symbolic link at the path is removed itself, never what it points at.
|
|
165
|
+
*
|
|
166
|
+
* @param path - Absolute path.
|
|
167
|
+
* @returns Whether there was something to remove.
|
|
168
|
+
*/
|
|
169
|
+
removeFile(path: string): Promise<boolean>;
|
|
170
|
+
/**
|
|
171
|
+
* Make an existing file readable and writable by its owner only (mode 0600), leaving its
|
|
172
|
+
* contents alone.
|
|
173
|
+
*
|
|
174
|
+
* @param path - Absolute path.
|
|
175
|
+
* @returns Whether the file was open to anyone else and has been closed. `false` when it
|
|
176
|
+
* already was owner-only, does not exist, or the platform has no such modes.
|
|
177
|
+
* @throws UsageError when `path` is a symbolic link: no mode is changed through one.
|
|
178
|
+
*/
|
|
179
|
+
restrictFile(path: string): Promise<boolean>;
|
|
180
|
+
/**
|
|
181
|
+
* Wait.
|
|
182
|
+
*
|
|
183
|
+
* @param ms - How long.
|
|
184
|
+
*/
|
|
185
|
+
sleep(ms: number): Promise<void>;
|
|
186
|
+
/**
|
|
187
|
+
* Be told every time the CLI is asked to stop (`SIGINT`, `SIGTERM`, `SIGHUP`), for as long
|
|
188
|
+
* as the returned function has not been called. While something listens the signal does
|
|
189
|
+
* not end the process: the listener's owner ends the run itself, after what it must not
|
|
190
|
+
* leave behind (an open tunnel) is taken down. A signal that cannot be caught (a kill, a
|
|
191
|
+
* crash, a power loss) tells nobody.
|
|
192
|
+
*
|
|
193
|
+
* @param listener - Called once for every signal received.
|
|
194
|
+
* @returns Stop listening.
|
|
195
|
+
*/
|
|
196
|
+
onInterrupt(listener: () => void): () => void;
|
|
197
|
+
}
|
|
198
|
+
/**
|
|
199
|
+
* Where output goes: `process.stdout`, `process.stderr`, or a test's buffer.
|
|
200
|
+
*
|
|
201
|
+
* @example
|
|
202
|
+
* ```ts
|
|
203
|
+
* const lines: string[] = []
|
|
204
|
+
* const sink: Sink = { write: (text) => lines.push(text) }
|
|
205
|
+
* ```
|
|
206
|
+
*/
|
|
207
|
+
interface Sink {
|
|
208
|
+
/** Write text. */
|
|
209
|
+
write(text: string): unknown;
|
|
210
|
+
/** Whether the sink is a terminal. */
|
|
211
|
+
isTTY?: boolean;
|
|
212
|
+
}
|
|
213
|
+
/**
|
|
214
|
+
* Text styles. Each returns its text unchanged when colour is off.
|
|
215
|
+
*
|
|
216
|
+
* @example
|
|
217
|
+
* ```ts
|
|
218
|
+
* output.line(output.style.green('+ added'))
|
|
219
|
+
* ```
|
|
220
|
+
*/
|
|
221
|
+
interface Styles {
|
|
222
|
+
/** Something added. */
|
|
223
|
+
green(text: string): string;
|
|
224
|
+
/** Something removed, or an error. */
|
|
225
|
+
red(text: string): string;
|
|
226
|
+
/** Something changed, or a warning. */
|
|
227
|
+
yellow(text: string): string;
|
|
228
|
+
/** Secondary text. */
|
|
229
|
+
dim(text: string): string;
|
|
230
|
+
/** A heading. */
|
|
231
|
+
bold(text: string): string;
|
|
232
|
+
}
|
|
233
|
+
/**
|
|
234
|
+
* The one place the CLI writes from. Commands never touch `console` or `process.stdout`: they
|
|
235
|
+
* get an `Output`, so a test captures everything a run prints.
|
|
236
|
+
*
|
|
237
|
+
* @example
|
|
238
|
+
* ```ts
|
|
239
|
+
* const output = createOutput(process.stdout, process.stderr, process.env)
|
|
240
|
+
* output.line('No changes.')
|
|
241
|
+
* ```
|
|
242
|
+
*/
|
|
243
|
+
interface Output {
|
|
244
|
+
/** Write a line to standard output. */
|
|
245
|
+
line(text?: string): void;
|
|
246
|
+
/** Write a line to standard error. */
|
|
247
|
+
error(text: string): void;
|
|
248
|
+
/** Styles for standard output. */
|
|
249
|
+
readonly style: Styles;
|
|
250
|
+
/** Styles for standard error. */
|
|
251
|
+
readonly errorStyle: Styles;
|
|
252
|
+
/**
|
|
253
|
+
* Name a value that must never be printed (the secret key, a provider's secret). Every line
|
|
254
|
+
* written afterwards has it replaced by `[redacted]`. No code path prints one on purpose;
|
|
255
|
+
* this is the net under them (an error message that quotes a request, a future mistake).
|
|
256
|
+
*
|
|
257
|
+
* With `'errors'` the value is removed from standard error only. That is for a value that
|
|
258
|
+
* may be an ordinary word (the password `tula policy test` is given): results on standard
|
|
259
|
+
* output are fixed text that never includes it, and replacing the word there would garble
|
|
260
|
+
* them (`password.too_short` for the password `password`).
|
|
261
|
+
*/
|
|
262
|
+
redact(value: string, scope?: "all" | "errors"): void;
|
|
263
|
+
}
|
|
264
|
+
/**
|
|
265
|
+
* Whether to colour a stream: only a terminal, and never when `NO_COLOR` is set to anything
|
|
266
|
+
* but the empty string (https://no-color.org) or `TERM` is `dumb`.
|
|
267
|
+
*
|
|
268
|
+
* @param sink - The stream.
|
|
269
|
+
* @param env - The environment.
|
|
270
|
+
* @returns `true` to use ANSI colours.
|
|
271
|
+
*
|
|
272
|
+
* @example
|
|
273
|
+
* ```ts
|
|
274
|
+
* shouldUseColor(process.stdout, process.env)
|
|
275
|
+
* ```
|
|
276
|
+
*/
|
|
277
|
+
declare function shouldUseColor(sink: Sink, env: Readonly<Record<string, string | undefined>>): boolean;
|
|
278
|
+
/**
|
|
279
|
+
* Build the CLI's output.
|
|
280
|
+
*
|
|
281
|
+
* @param stdout - Where results go.
|
|
282
|
+
* @param stderr - Where errors and prompts go.
|
|
283
|
+
* @param env - The environment (`NO_COLOR`, `TERM`).
|
|
284
|
+
* @returns The output.
|
|
285
|
+
*
|
|
286
|
+
* @example
|
|
287
|
+
* ```ts
|
|
288
|
+
* const output = createOutput(process.stdout, process.stderr, process.env)
|
|
289
|
+
* ```
|
|
290
|
+
*/
|
|
291
|
+
declare function createOutput(stdout: Sink, stderr: Sink, env: Readonly<Record<string, string | undefined>>): Output;
|
|
292
|
+
/**
|
|
293
|
+
* A run's exit code: `0` success (and, for `tula diff`, "no changes"), `1` an error, `2` "there
|
|
294
|
+
* are changes to apply" (`tula diff`, so that CI can gate on it) and "the password would be
|
|
295
|
+
* refused" (`tula policy test`): an answer, not a failure of the command.
|
|
296
|
+
*
|
|
297
|
+
* @example
|
|
298
|
+
* ```ts
|
|
299
|
+
* process.exit(plan.changes ? EXIT.changes : EXIT.ok)
|
|
300
|
+
* ```
|
|
301
|
+
*/
|
|
302
|
+
declare const EXIT: {
|
|
303
|
+
readonly ok: 0;
|
|
304
|
+
readonly error: 1;
|
|
305
|
+
readonly changes: 2;
|
|
306
|
+
readonly refused: 2;
|
|
307
|
+
};
|
|
308
|
+
/**
|
|
309
|
+
* Everything a run touches outside itself. `main` fills it from the process; a test passes its
|
|
310
|
+
* own, which is how the real CLI entry runs in process against an API mounted in memory.
|
|
311
|
+
*
|
|
312
|
+
* @example
|
|
313
|
+
* ```ts
|
|
314
|
+
* const io: CliIo = { stdout, stderr, env: { TULA_API_URL, TULA_SECRET_KEY }, cwd, isTTY: false, fetch }
|
|
315
|
+
* const code = await runCli(['diff', '--env', 'dev'], io)
|
|
316
|
+
* ```
|
|
317
|
+
*/
|
|
318
|
+
interface CliIo {
|
|
319
|
+
/** Results. */
|
|
320
|
+
stdout: Sink;
|
|
321
|
+
/** Errors and prompts. */
|
|
322
|
+
stderr: Sink;
|
|
323
|
+
/** The environment: where the API URL, the secret key and provider secrets are read from. */
|
|
324
|
+
env: Readonly<Record<string, string | undefined>>;
|
|
325
|
+
/** The directory a relative `--config` is resolved against. */
|
|
326
|
+
cwd: string;
|
|
327
|
+
/**
|
|
328
|
+
* Whether a person can be asked a question: standard input and standard error are both a
|
|
329
|
+
* terminal (the question is written to standard error, so standard output may be a file).
|
|
330
|
+
*/
|
|
331
|
+
isTTY: boolean;
|
|
332
|
+
/**
|
|
333
|
+
* Whether standard input is a terminal. A secret key is never read from one
|
|
334
|
+
* (`--secret-key-file -`): it would be shown as it is typed.
|
|
335
|
+
*/
|
|
336
|
+
stdinIsTTY?: boolean;
|
|
337
|
+
/** The `fetch` the admin client uses. Defaults to the platform's. */
|
|
338
|
+
fetch?: AdminFetch;
|
|
339
|
+
/** Ask a question and read one line. Required only where a command prompts on a terminal. */
|
|
340
|
+
prompt?: (question: string) => Promise<string>;
|
|
341
|
+
/** Read all of standard input (`--secret-key-file -`). */
|
|
342
|
+
readStdin?: () => Promise<string>;
|
|
343
|
+
/** Read a file as text (`--secret-key-file <path>`). */
|
|
344
|
+
readFile?: (path: string) => Promise<string>;
|
|
345
|
+
/**
|
|
346
|
+
* Ask for a secret on the terminal without showing what is typed (`tula policy test`).
|
|
347
|
+
* Present only where standard input is a terminal.
|
|
348
|
+
*/
|
|
349
|
+
promptSecret?: (question: string) => Promise<string>;
|
|
350
|
+
/** This machine's clock (`tula doctor` compares it with the server's). Defaults to the system's. */
|
|
351
|
+
now?: () => Date;
|
|
352
|
+
/** How `tula dev` runs `docker compose` and touches the project's files. */
|
|
353
|
+
host?: Host;
|
|
354
|
+
/**
|
|
355
|
+
* The process's own standard input and output as streams, and its stop signals, for a
|
|
356
|
+
* command that serves a protocol on them (`tula mcp`). While it does, nothing else may be
|
|
357
|
+
* written to standard output: every other line goes to `stderr`.
|
|
358
|
+
*/
|
|
359
|
+
serve?: {
|
|
360
|
+
/** Standard input. */
|
|
361
|
+
input: Readable;
|
|
362
|
+
/** Standard output. */
|
|
363
|
+
output: Writable;
|
|
364
|
+
/** Call `stop` on SIGTERM or SIGINT. Returns how to stop listening. */
|
|
365
|
+
onTerminate?: (stop: () => void) => () => void;
|
|
366
|
+
};
|
|
367
|
+
}
|
|
368
|
+
/**
|
|
369
|
+
* What a command's `run` receives.
|
|
370
|
+
*
|
|
371
|
+
* @example
|
|
372
|
+
* ```ts
|
|
373
|
+
* async function run({ output, flags }: CommandContext): Promise<number> {
|
|
374
|
+
* output.line(`env: ${String(flags.env)}`)
|
|
375
|
+
* return EXIT.ok
|
|
376
|
+
* }
|
|
377
|
+
* ```
|
|
378
|
+
*/
|
|
379
|
+
interface CommandContext extends ParsedArgs {
|
|
380
|
+
/** The run's surroundings. */
|
|
381
|
+
io: CliIo;
|
|
382
|
+
/** Where to write. */
|
|
383
|
+
output: Output;
|
|
384
|
+
}
|
|
385
|
+
/**
|
|
386
|
+
* One command of the CLI. A new command (`tula dev`, `tula doctor`, …) is an object of this
|
|
387
|
+
* shape added to the list `runCli` is given; nothing else changes.
|
|
388
|
+
*
|
|
389
|
+
* @example
|
|
390
|
+
* ```ts
|
|
391
|
+
* const hello: Command = {
|
|
392
|
+
* name: 'hello',
|
|
393
|
+
* summary: 'Say hello.',
|
|
394
|
+
* usage: 'tula hello [--name <name>]',
|
|
395
|
+
* options: { name: { type: 'string', value: '<name>', description: 'Who to greet.' } },
|
|
396
|
+
* run: async ({ output, flags }) => {
|
|
397
|
+
* output.line(`Hello, ${String(flags.name ?? 'world')}.`)
|
|
398
|
+
* return EXIT.ok
|
|
399
|
+
* },
|
|
400
|
+
* }
|
|
401
|
+
* ```
|
|
402
|
+
*/
|
|
403
|
+
interface Command {
|
|
404
|
+
/** The word after `tula`. */
|
|
405
|
+
name: string;
|
|
406
|
+
/** One line for `tula --help`. */
|
|
407
|
+
summary: string;
|
|
408
|
+
/** The usage line for `tula <name> --help`. */
|
|
409
|
+
usage: string;
|
|
410
|
+
/** More help: what the command does, its exit codes. */
|
|
411
|
+
description?: string;
|
|
412
|
+
/**
|
|
413
|
+
* How many arguments that are not options the command takes (default: none), e.g. the `test`
|
|
414
|
+
* of `tula policy test`. The command checks what they are.
|
|
415
|
+
*/
|
|
416
|
+
maxPositionals?: number;
|
|
417
|
+
/** The options it takes, by name. `--help` is added to every command. */
|
|
418
|
+
options: Record<string, OptionSpec>;
|
|
419
|
+
/** Run the command and answer its exit code. */
|
|
420
|
+
run(context: CommandContext): Promise<number>;
|
|
421
|
+
}
|
|
422
|
+
/**
|
|
423
|
+
* Report a failure the way every command does, and answer the exit code for it.
|
|
424
|
+
*
|
|
425
|
+
* @param output - Where to write.
|
|
426
|
+
* @param error - What was thrown.
|
|
427
|
+
* @returns `EXIT.error`.
|
|
428
|
+
*
|
|
429
|
+
* @example
|
|
430
|
+
* ```ts
|
|
431
|
+
* try {
|
|
432
|
+
* await work()
|
|
433
|
+
* } catch (error) {
|
|
434
|
+
* return reportError(output, error)
|
|
435
|
+
* }
|
|
436
|
+
* ```
|
|
437
|
+
*/
|
|
438
|
+
declare function reportError(output: Output, error: unknown): number;
|
|
439
|
+
/**
|
|
440
|
+
* Run the CLI: pick the command, parse its options, run it, and turn whatever it throws into a
|
|
441
|
+
* message and an exit code. It never throws and never exits the process itself.
|
|
442
|
+
*
|
|
443
|
+
* @param argv - The arguments after `tula`.
|
|
444
|
+
* @param io - The run's surroundings.
|
|
445
|
+
* @param commands - The commands the CLI has.
|
|
446
|
+
* @returns The exit code.
|
|
447
|
+
*
|
|
448
|
+
* @example
|
|
449
|
+
* ```ts
|
|
450
|
+
* const code = await runCli(['diff', '--env', 'prod'], io, COMMANDS)
|
|
451
|
+
* ```
|
|
452
|
+
*/
|
|
453
|
+
declare function runCli(argv: readonly string[], io: CliIo, commands: readonly Command[]): Promise<number>;
|
|
454
|
+
/**
|
|
455
|
+
* `tula apply`: make an environment's settings, providers, webhook endpoints, hooks and native
|
|
456
|
+
* apps what the config file says.
|
|
457
|
+
*
|
|
458
|
+
* It prints the same plan as `tula diff`, asks before changing anything (`--yes` skips the
|
|
459
|
+
* question; without it a run that is not at a terminal refuses instead of hanging), and
|
|
460
|
+
* refuses two plans unless told otherwise: one that would reset settings this version does not
|
|
461
|
+
* know (`--allow-unknown`), and, under `--yes`, where nobody reads the warning, one that
|
|
462
|
+
* weakens security (`--allow-weaker`: the settings' weakenings, the hooks' and the native
|
|
463
|
+
* apps') or removes a
|
|
464
|
+
* webhook endpoint (`--allow-webhook-removal`). A plan that creates a webhook endpoint or a
|
|
465
|
+
* hook needs a word on its
|
|
466
|
+
* signing secret, which the server shows once (`--secrets-file`, `--show-secrets`,
|
|
467
|
+
* `--discard-secrets`); the secret is redacted from all output unless it was asked for. It
|
|
468
|
+
* replaces the settings with `If-Match` on the revision the plan was made against, so a change
|
|
469
|
+
* someone else made in between is never overwritten. If a write fails part-way, it says
|
|
470
|
+
* exactly what was applied and what was not; running it again finishes the job.
|
|
471
|
+
*
|
|
472
|
+
* @example
|
|
473
|
+
* ```sh
|
|
474
|
+
* TULA_API_URL=https://auth.example.com TULA_SECRET_KEY=… tula apply --env prod --yes
|
|
475
|
+
* ```
|
|
476
|
+
*/
|
|
477
|
+
declare const applyCommand: Command;
|
|
478
|
+
/**
|
|
479
|
+
* `tula approve <proposal id>`: a person's answer to a change an AI agent asked for
|
|
480
|
+
* ([ADR 0053](../../../../docs/adr/0053-proposals.md)).
|
|
481
|
+
*
|
|
482
|
+
* An agent holds an agent key, which reads and proposes and, a sign-in test's throwaway user
|
|
483
|
+
* aside (ADR 0058), can change nothing. Its proposal
|
|
484
|
+
* waits, for minutes, until a person approves it with a credential the agent never has: the
|
|
485
|
+
* environment's secret key, which this command reads from where every command reads it (the
|
|
486
|
+
* environment or `--secret-key-file`, never an argument). The command shows what the
|
|
487
|
+
* proposal would do and to whom, asks, and approves exactly what it showed: the approval
|
|
488
|
+
* repeats the operation and the target, and the server refuses it for anything else.
|
|
489
|
+
*
|
|
490
|
+
* It runs at a terminal or not at all: where standard input and standard error are not both
|
|
491
|
+
* a terminal it refuses before any request, and it has no option that answers the question
|
|
492
|
+
* (no `--yes`), so a program that can run commands cannot approve by running this one. A
|
|
493
|
+
* key on standard input (`--secret-key-file -`) is refused for the same reason: the answer
|
|
494
|
+
* is typed there.
|
|
495
|
+
*
|
|
496
|
+
* @example
|
|
497
|
+
* ```sh
|
|
498
|
+
* TULA_API_URL=https://auth.example.com TULA_SECRET_KEY=… tula approve 0199c2f4-7a10-7c3e-9b1a-5d2e8f4a6c01
|
|
499
|
+
* ```
|
|
500
|
+
*/
|
|
501
|
+
declare const approveCommand: Command;
|
|
502
|
+
/**
|
|
503
|
+
* `tula dev`: start the project's local stack with Docker Compose, migrate, seed, mint
|
|
504
|
+
* development keys into `.env.local` and print the URLs. `tula dev down` stops it.
|
|
505
|
+
* `--tunnel` also gives the stack a public https address (ADR 0049).
|
|
506
|
+
*
|
|
507
|
+
* @example
|
|
508
|
+
* ```sh
|
|
509
|
+
* tula dev # start, or continue: running it again changes nothing
|
|
510
|
+
* tula dev --tunnel # the same, reachable from a phone at a stable https address
|
|
511
|
+
* tula dev down # stop; --volumes also deletes the database
|
|
512
|
+
* ```
|
|
513
|
+
*/
|
|
514
|
+
declare const devCommand: Command;
|
|
515
|
+
/**
|
|
516
|
+
* `tula diff`: compare the config file with what the server has and print the plan. It writes
|
|
517
|
+
* nothing. Exit code `0` when there is nothing to apply, `2` when there is, `1` on an error,
|
|
518
|
+
* so a CI job can gate on it.
|
|
519
|
+
*
|
|
520
|
+
* @example
|
|
521
|
+
* ```sh
|
|
522
|
+
* tula diff --env prod || [ $? -eq 2 ] # 2 = changes pending
|
|
523
|
+
* ```
|
|
524
|
+
*/
|
|
525
|
+
declare const diffCommand: Command;
|
|
526
|
+
/**
|
|
527
|
+
* `tula doctor`: check what actually goes wrong in a deployment, each with its fix. The CLI
|
|
528
|
+
* checks what it can see from this machine (the API answers, the versions match, the clocks
|
|
529
|
+
* agree); the API checks itself (`GET /v1/instance/diagnostics`): the database and its
|
|
530
|
+
* migrations, the master key against the stored secrets, the mail relay, Redis, `PUBLIC_URL`
|
|
531
|
+
* and each enabled provider's redirect URI. The CLI never connects to the database.
|
|
532
|
+
*
|
|
533
|
+
* @example
|
|
534
|
+
* ```sh
|
|
535
|
+
* TULA_API_URL=https://auth.example.com TULA_ADMIN_TOKEN=… tula doctor --strict
|
|
536
|
+
* ```
|
|
537
|
+
*/
|
|
538
|
+
declare const doctorCommand: Command;
|
|
539
|
+
import { McpServer as McpServer_ymih5p } from "@modelcontextprotocol/server";
|
|
540
|
+
/**
|
|
541
|
+
* Build the MCP server `tula mcp` serves, from the same places every other command reads its
|
|
542
|
+
* target: the API URL from `--api-url`, `TULA_API_URL_<NAME>` or `TULA_API_URL`; the secret
|
|
543
|
+
* key from `--secret-key-file`, `TULA_SECRET_KEY_<NAME>` or `TULA_SECRET_KEY`; the instance
|
|
544
|
+
* admin token (for `run_doctor`'s server-side checks only) from `--admin-token-file` or
|
|
545
|
+
* `TULA_ADMIN_TOKEN`. Never from an argument, and never from a tool's input.
|
|
546
|
+
*
|
|
547
|
+
* **With an agent key the secret key is not read at all** (ADR 0053): `--agent-key-file`,
|
|
548
|
+
* `TULA_AGENT_KEY_<NAME>` or `TULA_AGENT_KEY`. The read tools then read with the agent key
|
|
549
|
+
* and the propose tools work; the process that an AI agent talks to holds no credential
|
|
550
|
+
* that can approve a proposal, or change anything beyond running a sign-in test where the
|
|
551
|
+
* environment allows one (ADR 0058). A secret key that is also set is left
|
|
552
|
+
* unread, and that is said on standard error. Without an agent key the propose tools answer
|
|
553
|
+
* "not configured": a secret key does not propose.
|
|
554
|
+
*
|
|
555
|
+
* Nothing is required: what is missing or refused (a plain-http URL that is not this machine)
|
|
556
|
+
* becomes the "not configured" answer of the tools that needed it, and is said once on
|
|
557
|
+
* standard error. The scaffold tools need none of it.
|
|
558
|
+
*
|
|
559
|
+
* This is where `@tula-tech/mcp` is loaded, on first use.
|
|
560
|
+
*
|
|
561
|
+
* @param context - The command's context.
|
|
562
|
+
* @returns The server, not yet connected.
|
|
563
|
+
*
|
|
564
|
+
* @example
|
|
565
|
+
* ```ts
|
|
566
|
+
* const server = await buildMcpServer({ flags: {}, positionals: [], io, output })
|
|
567
|
+
* await server.connect(transport)
|
|
568
|
+
* ```
|
|
569
|
+
*/
|
|
570
|
+
declare function buildMcpServer(context: CommandContext): Promise<McpServer_ymih5p>;
|
|
571
|
+
/**
|
|
572
|
+
* `tula mcp`: serve Tula's Model Context Protocol server on standard input and output, for an
|
|
573
|
+
* MCP client (Claude Code, Claude Desktop, an editor) to start as a child process. Read tools
|
|
574
|
+
* over an environment's users, sessions, audit entries, settings and deployment checks, a
|
|
575
|
+
* tool that drafts a settings change as an edit to the config file (for a person to apply
|
|
576
|
+
* with `tula apply`), scaffold tools that return files for the client to write, and, with an agent key, tools
|
|
577
|
+
* that propose a change for a person to approve with `tula approve`. One tool changes live
|
|
578
|
+
* data, for the seconds it runs and only where the environment allows it: `test_signin`
|
|
579
|
+
* signs a throwaway user up and in and deletes it (ADR 0058). No other tool does, and none
|
|
580
|
+
* returns a secret.
|
|
581
|
+
*
|
|
582
|
+
* @example
|
|
583
|
+
* ```sh
|
|
584
|
+
* TULA_API_URL=https://auth.example.com TULA_AGENT_KEY=… tula mcp
|
|
585
|
+
* ```
|
|
586
|
+
*/
|
|
587
|
+
declare const mcpCommand: Command;
|
|
588
|
+
/**
|
|
589
|
+
* `tula policy test`: show which rules of an environment's password policy a password passes.
|
|
590
|
+
*
|
|
591
|
+
* The policy is read from the API with the environment's secret key; the rules are evaluated
|
|
592
|
+
* on this machine with the same code the server and the SDKs use (`@tula-tech/contract`). The
|
|
593
|
+
* password is never sent anywhere, printed or logged. The breached-password check is the one
|
|
594
|
+
* rule that is not run: the server makes it when a password is set.
|
|
595
|
+
*
|
|
596
|
+
* @example
|
|
597
|
+
* ```sh
|
|
598
|
+
* tula policy test # asks for the password without showing it
|
|
599
|
+
* printf %s "$PW" | tula policy test --email maya@example.com
|
|
600
|
+
* ```
|
|
601
|
+
*/
|
|
602
|
+
declare const policyCommand: Command;
|
|
603
|
+
/**
|
|
604
|
+
* One thing the tunnel answers: a path of the public address, and the local URL a request
|
|
605
|
+
* for it is passed to. `/` is everything no other route names.
|
|
606
|
+
*
|
|
607
|
+
* @example
|
|
608
|
+
* ```ts
|
|
609
|
+
* const root: TunnelRoute = { path: '/', target: 'http://127.0.0.1:3003' }
|
|
610
|
+
* ```
|
|
611
|
+
*/
|
|
612
|
+
interface TunnelRoute {
|
|
613
|
+
/** The path on the public address: `/`, or one exact path. */
|
|
614
|
+
path: string;
|
|
615
|
+
/** Where it goes: `http://127.0.0.1:<port>`, with a path for an exact route. */
|
|
616
|
+
target: string;
|
|
617
|
+
}
|
|
618
|
+
/**
|
|
619
|
+
* What a tunnel tool says it serves on the tunnel's port.
|
|
620
|
+
*
|
|
621
|
+
* @example
|
|
622
|
+
* ```ts
|
|
623
|
+
* const nothing: TunnelStatus = { endpoints: [], taken: false }
|
|
624
|
+
* ```
|
|
625
|
+
*/
|
|
626
|
+
interface TunnelStatus {
|
|
627
|
+
/** Each public name with something on the port, and where each of its paths goes. */
|
|
628
|
+
endpoints: {
|
|
629
|
+
/** The name, lower case, without a port. */
|
|
630
|
+
host: string;
|
|
631
|
+
/** Whether the internet reaches it (not only the tool's private network). */
|
|
632
|
+
public: boolean;
|
|
633
|
+
/** Path to local URL; `null` for a path that is served some other way (a file, a text). */
|
|
634
|
+
routes: Record<string, string | null>;
|
|
635
|
+
}[];
|
|
636
|
+
/** The port is in use by something that is no web endpoint (a TCP forward, a session in the foreground). */
|
|
637
|
+
taken: boolean;
|
|
638
|
+
}
|
|
639
|
+
/**
|
|
640
|
+
* A tunnel service, as `tula dev --tunnel` needs it: how its tool is asked what it serves,
|
|
641
|
+
* told to serve a route and told to stop, and what to say when the tool is missing. One
|
|
642
|
+
* service is built (ADR 0049); another would be another value of this type.
|
|
643
|
+
*
|
|
644
|
+
* The tool's own login and configuration are the developer's: no command here carries a
|
|
645
|
+
* credential, reads one or names a file that holds one. **No command makes the tool rewrite
|
|
646
|
+
* the `Host` header**: the API's development-only routes answer a loopback `Host` only, and
|
|
647
|
+
* that is one of the things that keeps them off a tunnel.
|
|
648
|
+
*
|
|
649
|
+
* @example
|
|
650
|
+
* ```ts
|
|
651
|
+
* tailscaleFunnel.openCommand({ path: '/', target: 'http://127.0.0.1:3003' })
|
|
652
|
+
* // ['tailscale', 'funnel', '--bg', '--yes', '--https=443', 'http://127.0.0.1:3003']
|
|
653
|
+
* ```
|
|
654
|
+
*/
|
|
655
|
+
interface TunnelService {
|
|
656
|
+
/** The service's name, for messages. */
|
|
657
|
+
readonly name: string;
|
|
658
|
+
/** The executable. */
|
|
659
|
+
readonly binary: string;
|
|
660
|
+
/** What is said when the executable is missing: its name and how to install it. Fixed text. */
|
|
661
|
+
readonly missing: string;
|
|
662
|
+
/** What is said when the tool starts and reports no address in time. Fixed text. */
|
|
663
|
+
readonly noAddress: string;
|
|
664
|
+
/** How to see, by hand, what the tool serves. */
|
|
665
|
+
readonly inspect: string;
|
|
666
|
+
/** How to close the tunnel by hand. */
|
|
667
|
+
readonly closeByHand: string;
|
|
668
|
+
/** The argument vector that prints what the tool serves. */
|
|
669
|
+
statusCommand(): readonly string[];
|
|
670
|
+
/** The argument vector that serves one route and returns. */
|
|
671
|
+
openCommand(route: TunnelRoute): readonly string[];
|
|
672
|
+
/** The argument vector that stops serving everything on the tunnel's port. */
|
|
673
|
+
closeCommand(): readonly string[];
|
|
674
|
+
/**
|
|
675
|
+
* Read what {@link TunnelService.statusCommand} printed.
|
|
676
|
+
*
|
|
677
|
+
* @param stdout - The tool's standard output.
|
|
678
|
+
* @returns What is served on the tunnel's port, or `null` when the output cannot be read.
|
|
679
|
+
*/
|
|
680
|
+
readStatus(stdout: string): TunnelStatus | null;
|
|
681
|
+
}
|
|
682
|
+
/** The file `tula dev` writes the keys to: read by Bun, Vite and Next.js, and ignored by git. */
|
|
683
|
+
declare const DEV_ENV_FILE = ".env.local";
|
|
684
|
+
/**
|
|
685
|
+
* The variables in the block `tula dev` manages, or `null` when the file has no block.
|
|
686
|
+
*
|
|
687
|
+
* @param text - The file's contents.
|
|
688
|
+
* @returns The block's variables.
|
|
689
|
+
*
|
|
690
|
+
* @example
|
|
691
|
+
* ```ts
|
|
692
|
+
* parseDevBlock(await Bun.file('.env.local').text())?.TULA_API_URL
|
|
693
|
+
* ```
|
|
694
|
+
*/
|
|
695
|
+
declare function parseDevBlock(text: string): Record<string, string> | null;
|
|
696
|
+
/**
|
|
697
|
+
* A file's contents without the block `tula dev` manages. Every other line is kept as it is.
|
|
698
|
+
*
|
|
699
|
+
* @param text - The file's contents.
|
|
700
|
+
* @returns The contents without the block.
|
|
701
|
+
*
|
|
702
|
+
* @example
|
|
703
|
+
* ```ts
|
|
704
|
+
* withoutDevBlock('A=1\n# tula:dev:start\nB=2\n# tula:dev:end\n') // 'A=1\n'
|
|
705
|
+
* ```
|
|
706
|
+
*/
|
|
707
|
+
declare function withoutDevBlock(text: string): string;
|
|
708
|
+
/**
|
|
709
|
+
* A file's contents with the block `tula dev` manages set to `vars`: replaced where it is, or
|
|
710
|
+
* added at the end. Lines outside the block are never changed.
|
|
711
|
+
*
|
|
712
|
+
* @param text - The file's contents (`''` for a new file).
|
|
713
|
+
* @param vars - The block's variables.
|
|
714
|
+
* @returns The new contents.
|
|
715
|
+
*
|
|
716
|
+
* @example
|
|
717
|
+
* ```ts
|
|
718
|
+
* withDevBlock('MINE=1\n', { TULA_API_URL: 'http://localhost:3003' })
|
|
719
|
+
* ```
|
|
720
|
+
*/
|
|
721
|
+
declare function withDevBlock(text: string, vars: Readonly<Record<string, string>>): string;
|
|
722
|
+
/**
|
|
723
|
+
* What `tula dev` needs from its command: where it runs and how.
|
|
724
|
+
*
|
|
725
|
+
* @example
|
|
726
|
+
* ```ts
|
|
727
|
+
* await startDev({ io, output, host, projectName: 'shop', timeoutMs: 900_000, showKeys: false, rotateKeys: false })
|
|
728
|
+
* ```
|
|
729
|
+
*/
|
|
730
|
+
interface DevOptions {
|
|
731
|
+
/** The run's surroundings. */
|
|
732
|
+
io: CliIo;
|
|
733
|
+
/** Where to write. */
|
|
734
|
+
output: Output;
|
|
735
|
+
/** How Docker is run and files are touched. */
|
|
736
|
+
host: Host;
|
|
737
|
+
/** The Compose project name (`-p`); Compose's own default when left out. */
|
|
738
|
+
projectName?: string;
|
|
739
|
+
/** How long a long step (pulling, building, migrating, becoming ready) may take. */
|
|
740
|
+
timeoutMs: number;
|
|
741
|
+
/** Print the secret key. */
|
|
742
|
+
showKeys: boolean;
|
|
743
|
+
/** Mint new keys even when the file has some. */
|
|
744
|
+
rotateKeys: boolean;
|
|
745
|
+
/** Give the stack a public https address through a tunnel (ADR 0049). */
|
|
746
|
+
tunnel?: boolean;
|
|
747
|
+
/** The tunnel service. Default: Tailscale Funnel, the one that is built. */
|
|
748
|
+
tunnelService?: TunnelService;
|
|
749
|
+
}
|
|
750
|
+
/**
|
|
751
|
+
* `tula dev`: bring the project's local stack up and leave it ready to use.
|
|
752
|
+
*
|
|
753
|
+
* Starts the Compose services, runs the migrations and the seed through the commands the API
|
|
754
|
+
* image ships (the one place the CLI reaches the database, and only through them), waits for
|
|
755
|
+
* the API, mints a publishable and a secret development key unless `.env.local` already holds
|
|
756
|
+
* keys the stack accepts, writes them to its own block of `.env.local` (owner-readable only;
|
|
757
|
+
* lines outside the block are never changed, and a `TULA_SECRET_KEY` of the user's own is
|
|
758
|
+
* used as it is) and prints the URLs. Running it again changes nothing.
|
|
759
|
+
*
|
|
760
|
+
* With `tunnel` (ADR 0049) the API is started under a public https address: the tunnel is
|
|
761
|
+
* opened through `host` like every other command, the address goes into the block and into
|
|
762
|
+
* the one `docker compose up` that starts the API, and nothing else of the project is
|
|
763
|
+
* changed: no line outside the block, and no setting of the environment (the changes are
|
|
764
|
+
* printed for the developer to make). Without `tunnel`, a tunnel an earlier run opened is
|
|
765
|
+
* closed and the API goes back to its local address. A port that serves something else is
|
|
766
|
+
* refused before anything is started. The API's container is stopped and the tunnel's
|
|
767
|
+
* record (`TULA_TUNNEL_TARGET`) written before the tunnel is opened; a run that fails or
|
|
768
|
+
* receives an interrupt after that closes the tunnel again before it throws, and the
|
|
769
|
+
* record leaves the block only when the tunnel is closed. A process that is killed
|
|
770
|
+
* outright closes nothing: the record is what `tula dev down` then closes by.
|
|
771
|
+
*
|
|
772
|
+
* @param options - Where and how to run.
|
|
773
|
+
* @throws UsageError with the step that failed and what to do.
|
|
774
|
+
*
|
|
775
|
+
* @example
|
|
776
|
+
* ```ts
|
|
777
|
+
* await startDev({ io, output, host, timeoutMs: 900_000, showKeys: false, rotateKeys: false })
|
|
778
|
+
* ```
|
|
779
|
+
*/
|
|
780
|
+
declare function startDev(options: DevOptions): Promise<void>;
|
|
781
|
+
/**
|
|
782
|
+
* `tula dev down`: stop the project's stack. With `volumes` the database is deleted too, and
|
|
783
|
+
* the keys `tula dev` wrote (which that database no longer knows) are removed from
|
|
784
|
+
* `.env.local`; every other line of the file stays. A tunnel `tula dev --tunnel` opened is
|
|
785
|
+
* closed first, and its address is taken out of the block.
|
|
786
|
+
*
|
|
787
|
+
* @param options - Where and how to run, and whether to delete the data.
|
|
788
|
+
* @throws UsageError when Docker fails.
|
|
789
|
+
*
|
|
790
|
+
* @example
|
|
791
|
+
* ```ts
|
|
792
|
+
* await stopDev({ io, output, host, projectName: 'shop', volumes: false })
|
|
793
|
+
* ```
|
|
794
|
+
*/
|
|
795
|
+
declare function stopDev(options: Pick<DevOptions, "io" | "output" | "host" | "projectName" | "tunnelService"> & {
|
|
796
|
+
volumes: boolean;
|
|
797
|
+
}): Promise<void>;
|
|
798
|
+
import { AdminSchemas } from "@tula-tech/admin";
|
|
799
|
+
import { EnvironmentConfig, NativeAppConfig } from "@tula-tech/config";
|
|
800
|
+
import { EnvironmentSettings, EnvironmentSettingsInput, HookPoint, OAuthProvider, SettingsManagedBy } from "@tula-tech/contract";
|
|
801
|
+
/**
|
|
802
|
+
* The name `tula apply` records itself under as the manager of an environment's settings.
|
|
803
|
+
*
|
|
804
|
+
* @example
|
|
805
|
+
* ```ts
|
|
806
|
+
* headers['x-tula-managed-by'] = MANAGING_TOOL
|
|
807
|
+
* ```
|
|
808
|
+
*/
|
|
809
|
+
declare const MANAGING_TOOL = "tula-apply";
|
|
810
|
+
/**
|
|
811
|
+
* Lists in the settings document that are **sets**: their order means nothing to the server
|
|
812
|
+
* (an origin is allowed or it is not, a country is texted or it is not), so a reordering is
|
|
813
|
+
* not a change, and a change is shown as the entries added and removed. Every other list is
|
|
814
|
+
* compared in order.
|
|
815
|
+
*
|
|
816
|
+
* @example
|
|
817
|
+
* ```ts
|
|
818
|
+
* diffValues(current, desired, SET_PATHS)
|
|
819
|
+
* ```
|
|
820
|
+
*/
|
|
821
|
+
declare const SET_PATHS: readonly string[];
|
|
822
|
+
/**
|
|
823
|
+
* One difference between what a server has and what a config says.
|
|
824
|
+
*
|
|
825
|
+
* @example
|
|
826
|
+
* ```ts
|
|
827
|
+
* const change: Change = { path: 'app.name', kind: 'changed', before: 'Tula', after: 'Northline' }
|
|
828
|
+
* ```
|
|
829
|
+
*/
|
|
830
|
+
interface Change {
|
|
831
|
+
/** Where, dot-separated. */
|
|
832
|
+
path: string;
|
|
833
|
+
/** `added`: only the config has it. `removed`: only the server has it. `changed`: both do. */
|
|
834
|
+
kind: "added" | "changed" | "removed";
|
|
835
|
+
/** The server's value. */
|
|
836
|
+
before?: unknown;
|
|
837
|
+
/** The config's value. */
|
|
838
|
+
after?: unknown;
|
|
839
|
+
/** For a list that is a set: the entries the config adds. */
|
|
840
|
+
added?: unknown[];
|
|
841
|
+
/** For a list that is a set: the entries the config removes. */
|
|
842
|
+
removed?: unknown[];
|
|
843
|
+
}
|
|
844
|
+
/**
|
|
845
|
+
* The structural difference between two values, by path.
|
|
846
|
+
*
|
|
847
|
+
* Objects are compared key by key at any depth; a key only one side has is `added` or
|
|
848
|
+
* `removed` as a whole. A list on one of `setPaths` is compared as a set; any other list, and
|
|
849
|
+
* every scalar, by value. A key set to `undefined` counts as absent. JWT templates are a set
|
|
850
|
+
* by name and a template's claims a set by key (maps, so their order never counts); one claim
|
|
851
|
+
* is compared as a whole value. An email template is compared by its subject and its body,
|
|
852
|
+
* and a text message template by its text, also when only one side has it.
|
|
853
|
+
*
|
|
854
|
+
* @param before - What the server has.
|
|
855
|
+
* @param after - What the config says.
|
|
856
|
+
* @param setPaths - Paths of the lists that are sets.
|
|
857
|
+
* @returns The changes, in the order of the config's keys; empty when there are none.
|
|
858
|
+
*
|
|
859
|
+
* @example
|
|
860
|
+
* ```ts
|
|
861
|
+
* diffValues({ app: { name: 'Tula' } }, { app: { name: 'Northline' } })
|
|
862
|
+
* // [{ path: 'app.name', kind: 'changed', before: 'Tula', after: 'Northline' }]
|
|
863
|
+
* ```
|
|
864
|
+
*/
|
|
865
|
+
declare function diffValues(before: unknown, after: unknown, setPaths?: readonly string[]): Change[];
|
|
866
|
+
/**
|
|
867
|
+
* A provider as the admin API lists it.
|
|
868
|
+
*
|
|
869
|
+
* @example
|
|
870
|
+
* ```ts
|
|
871
|
+
* const { data } = await admin.call('listOAuthProviders')
|
|
872
|
+
* const providers: RemoteProvider[] = data.data
|
|
873
|
+
* ```
|
|
874
|
+
*/
|
|
875
|
+
type RemoteProvider = AdminSchemas["OAuthProviderSettings"];
|
|
876
|
+
/**
|
|
877
|
+
* What a server has for one environment: its settings, their revision and manager, and its
|
|
878
|
+
* providers.
|
|
879
|
+
*
|
|
880
|
+
* @example
|
|
881
|
+
* ```ts
|
|
882
|
+
* const state: RemoteState = { ...settingsResponse.data, providers: providersResponse.data.data }
|
|
883
|
+
* ```
|
|
884
|
+
*/
|
|
885
|
+
interface RemoteState {
|
|
886
|
+
/** The settings' revision: what the replace is conditional on. */
|
|
887
|
+
revision: number;
|
|
888
|
+
/** The settings document in force. */
|
|
889
|
+
settings: EnvironmentSettings;
|
|
890
|
+
/** Who manages the settings; `null` for nobody; `undefined` from a server that does not say. */
|
|
891
|
+
managedBy?: SettingsManagedBy | null;
|
|
892
|
+
/** Every provider, configured or not. */
|
|
893
|
+
providers: readonly RemoteProvider[];
|
|
894
|
+
/**
|
|
895
|
+
* How many accounts depend on a provider, for the providers the run was asked about: the
|
|
896
|
+
* ones the plan switches off or removes ({@link providersGoingOff}). `null` for one whose
|
|
897
|
+
* count could not be read; a provider that was not asked about is left out.
|
|
898
|
+
*/
|
|
899
|
+
dependents?: RemoteDependents;
|
|
900
|
+
/**
|
|
901
|
+
* The webhook endpoints, oldest first. Left out when the config does not manage webhooks:
|
|
902
|
+
* they are then not even read.
|
|
903
|
+
*/
|
|
904
|
+
webhooks?: readonly RemoteWebhook[];
|
|
905
|
+
/**
|
|
906
|
+
* The hooks, oldest first. Left out when the config does not manage hooks: they are then
|
|
907
|
+
* not even read.
|
|
908
|
+
*/
|
|
909
|
+
hooks?: readonly RemoteHook[];
|
|
910
|
+
/**
|
|
911
|
+
* The native apps, oldest first. Left out when the config does not manage native apps: they
|
|
912
|
+
* are then not even read.
|
|
913
|
+
*/
|
|
914
|
+
nativeApps?: readonly RemoteNativeApp[];
|
|
915
|
+
}
|
|
916
|
+
/**
|
|
917
|
+
* A native app as the admin API lists it.
|
|
918
|
+
*
|
|
919
|
+
* @example
|
|
920
|
+
* ```ts
|
|
921
|
+
* const { data } = await admin.call('listNativeApps')
|
|
922
|
+
* const apps: RemoteNativeApp[] = data.data
|
|
923
|
+
* ```
|
|
924
|
+
*/
|
|
925
|
+
type RemoteNativeApp = AdminSchemas["NativeApp"];
|
|
926
|
+
/**
|
|
927
|
+
* What a run does with one native app.
|
|
928
|
+
*
|
|
929
|
+
* @example
|
|
930
|
+
* ```ts
|
|
931
|
+
* const change: NativeAppChange = planNativeApps(remote.nativeApps, environment.nativeApps, {}).apps[0]
|
|
932
|
+
* ```
|
|
933
|
+
*/
|
|
934
|
+
interface NativeAppChange {
|
|
935
|
+
/** The platform: `ios`, `android`, or whatever a later server sent. */
|
|
936
|
+
platform: string;
|
|
937
|
+
/**
|
|
938
|
+
* The bundle id or the package name. With the platform it is what names an app: the file's
|
|
939
|
+
* for an entry of the file, the server's otherwise.
|
|
940
|
+
*/
|
|
941
|
+
identifier: string;
|
|
942
|
+
/** The server's id of the app. Absent for one to create. */
|
|
943
|
+
id?: string;
|
|
944
|
+
/**
|
|
945
|
+
* `create`, `update`, `delete`; `none` when it already is as the config says; `unmanaged`
|
|
946
|
+
* when the server has it and the config does not list it (left alone without `--prune`);
|
|
947
|
+
* `unknown` when its platform is one this version of the CLI does not know, which no file
|
|
948
|
+
* can name and no run touches, `--prune` or not.
|
|
949
|
+
*/
|
|
950
|
+
action: "create" | "update" | "delete" | "none" | "unmanaged" | "unknown";
|
|
951
|
+
/**
|
|
952
|
+
* The differences: `teamId` of an iOS app; `sha256CertFingerprints` of an Android app, as a
|
|
953
|
+
* set (`added`, `removed`); `appLinkPaths` of either, as a set, and only where the entry
|
|
954
|
+
* writes the key. For an app to create, what it is registered with (its link paths only
|
|
955
|
+
* when it has some).
|
|
956
|
+
*/
|
|
957
|
+
fields: Change[];
|
|
958
|
+
/**
|
|
959
|
+
* Where the change widens who the platforms will believe is the environment's app, as the
|
|
960
|
+
* contract's `nativeAppWeakenings` judges it: `nativeApps.<platform>/<identifier>` for a
|
|
961
|
+
* registration, and that with `.teamId`, `.sha256CertFingerprints` or `.appLinkPaths` for a
|
|
962
|
+
* changed team, a gained fingerprint or a gained link path. Part of the plan's `weakened`.
|
|
963
|
+
*/
|
|
964
|
+
weakened: string[];
|
|
965
|
+
/** The config's entry, for an app the run creates or updates: what the write sends. */
|
|
966
|
+
entry?: NativeAppConfig;
|
|
967
|
+
/**
|
|
968
|
+
* How many link paths the server has for this app that the file does not manage, because
|
|
969
|
+
* its entry leaves `appLinkPaths` out. Present only when that is more than none. They are
|
|
970
|
+
* kept, and are no difference of the plan: this is for the one line that says so.
|
|
971
|
+
*/
|
|
972
|
+
unmanagedLinkPaths?: number;
|
|
973
|
+
}
|
|
974
|
+
/**
|
|
975
|
+
* What a run does with an environment's native apps.
|
|
976
|
+
*
|
|
977
|
+
* @example
|
|
978
|
+
* ```ts
|
|
979
|
+
* if (plan.nativeApps.overLimit !== null) {
|
|
980
|
+
* // the environment would have more apps than it may
|
|
981
|
+
* }
|
|
982
|
+
* ```
|
|
983
|
+
*/
|
|
984
|
+
interface NativeAppPlan {
|
|
985
|
+
/** Whether the config has a `nativeApps` list at all. Without one nothing is read or changed. */
|
|
986
|
+
managed: boolean;
|
|
987
|
+
/** One entry per app of the file, in its order, then the server's other apps. */
|
|
988
|
+
apps: NativeAppChange[];
|
|
989
|
+
/**
|
|
990
|
+
* How many apps the environment would have once the plan is applied, when that is more than
|
|
991
|
+
* it may have (`MAX_NATIVE_APPS`); `null` otherwise.
|
|
992
|
+
*/
|
|
993
|
+
overLimit: number | null;
|
|
994
|
+
/**
|
|
995
|
+
* What the plan read of the server's apps ({@link nativeAppSnapshot}): `tula apply` reads
|
|
996
|
+
* them again before its first write to one and stops if this no longer matches.
|
|
997
|
+
*/
|
|
998
|
+
seen: string;
|
|
999
|
+
}
|
|
1000
|
+
/**
|
|
1001
|
+
* A hook as the admin API lists it. Never with its signing secret: no read returns one.
|
|
1002
|
+
*
|
|
1003
|
+
* @example
|
|
1004
|
+
* ```ts
|
|
1005
|
+
* const { data } = await admin.call('listHooks')
|
|
1006
|
+
* const hooks: RemoteHook[] = data.data
|
|
1007
|
+
* ```
|
|
1008
|
+
*/
|
|
1009
|
+
type RemoteHook = AdminSchemas["Hook"];
|
|
1010
|
+
/**
|
|
1011
|
+
* What a run does with one hook.
|
|
1012
|
+
*
|
|
1013
|
+
* @example
|
|
1014
|
+
* ```ts
|
|
1015
|
+
* const change: HookChange = planHooks(remote.hooks, environment.hooks, {}).hooks[0]
|
|
1016
|
+
* ```
|
|
1017
|
+
*/
|
|
1018
|
+
interface HookChange {
|
|
1019
|
+
/** The point, which is what names a hook: an environment has at most one per point. */
|
|
1020
|
+
point: string;
|
|
1021
|
+
/** The server's id of the hook. Absent for one to create. */
|
|
1022
|
+
id?: string;
|
|
1023
|
+
/** The hook's address: the file's for an entry of the file, the server's otherwise. */
|
|
1024
|
+
url: string;
|
|
1025
|
+
/**
|
|
1026
|
+
* `create`, `update`, `delete`; `none` when it already is as the config says; `unmanaged`
|
|
1027
|
+
* when the server has it and the config has no entry for its point (left alone without
|
|
1028
|
+
* `--prune`); `unknown` when its point is one this version of the CLI does not know, which
|
|
1029
|
+
* no file can name and no run touches, `--prune` or not.
|
|
1030
|
+
*/
|
|
1031
|
+
action: "create" | "update" | "delete" | "none" | "unmanaged" | "unknown";
|
|
1032
|
+
/** The differences in `url`, `enabled`, `deadlineMs` and `failureMode`, in that order. */
|
|
1033
|
+
fields: Change[];
|
|
1034
|
+
/**
|
|
1035
|
+
* Where the change lets through what the hook stops, as the contract's `hookWeakenings`
|
|
1036
|
+
* judges it: `hooks.<point>.failureMode`, `hooks.<point>.enabled`, or `hooks.<point>` for
|
|
1037
|
+
* the removal of a hook that is on. Part of the plan's `weakened`.
|
|
1038
|
+
*/
|
|
1039
|
+
weakened: string[];
|
|
1040
|
+
}
|
|
1041
|
+
/**
|
|
1042
|
+
* What a run does with an environment's hooks.
|
|
1043
|
+
*
|
|
1044
|
+
* @example
|
|
1045
|
+
* ```ts
|
|
1046
|
+
* if (plan.hooks.managed) {
|
|
1047
|
+
* // the file has a `hooks` key: the server's hooks were read
|
|
1048
|
+
* }
|
|
1049
|
+
* ```
|
|
1050
|
+
*/
|
|
1051
|
+
interface HookPlan {
|
|
1052
|
+
/** Whether the config has a `hooks` key at all. Without one nothing is read or changed. */
|
|
1053
|
+
managed: boolean;
|
|
1054
|
+
/** One entry per point the file or the server has a hook for, in the contract's order. */
|
|
1055
|
+
hooks: HookChange[];
|
|
1056
|
+
/**
|
|
1057
|
+
* What the plan read of the server's hooks ({@link hookSnapshot}): `tula apply` reads them
|
|
1058
|
+
* again before its first write to one and stops if this no longer matches.
|
|
1059
|
+
*/
|
|
1060
|
+
seen: string;
|
|
1061
|
+
}
|
|
1062
|
+
/**
|
|
1063
|
+
* A webhook endpoint as the admin API lists it. Never with its signing secret: no read
|
|
1064
|
+
* returns one.
|
|
1065
|
+
*
|
|
1066
|
+
* @example
|
|
1067
|
+
* ```ts
|
|
1068
|
+
* const { data } = await admin.call('listWebhookEndpoints')
|
|
1069
|
+
* const endpoints: RemoteWebhook[] = data.data
|
|
1070
|
+
* ```
|
|
1071
|
+
*/
|
|
1072
|
+
type RemoteWebhook = AdminSchemas["WebhookEndpoint"];
|
|
1073
|
+
/**
|
|
1074
|
+
* What a run does with one webhook endpoint.
|
|
1075
|
+
*
|
|
1076
|
+
* @example
|
|
1077
|
+
* ```ts
|
|
1078
|
+
* const change: WebhookChange = planWebhooks(remote.webhooks, environment.webhooks, {}).endpoints[0]
|
|
1079
|
+
* ```
|
|
1080
|
+
*/
|
|
1081
|
+
interface WebhookChange {
|
|
1082
|
+
/**
|
|
1083
|
+
* The endpoint's address, which is what identifies it: the file's for an entry of the file,
|
|
1084
|
+
* the server's for an endpoint only the server has. Compared exactly, as the server stores
|
|
1085
|
+
* and compares it.
|
|
1086
|
+
*/
|
|
1087
|
+
url: string;
|
|
1088
|
+
/** The server's id of the endpoint. Absent for one to create and for one that is `ambiguous`. */
|
|
1089
|
+
id?: string;
|
|
1090
|
+
/**
|
|
1091
|
+
* `create`, `update`, `delete`; `none` when it already is as the config says; `unmanaged`
|
|
1092
|
+
* when the server has it and the config's list does not (left alone without `--prune`);
|
|
1093
|
+
* `ambiguous` when the server has the address more than once, so the entry cannot be
|
|
1094
|
+
* matched to one endpoint and nothing is done to any of them.
|
|
1095
|
+
*/
|
|
1096
|
+
action: "create" | "update" | "delete" | "none" | "unmanaged" | "ambiguous";
|
|
1097
|
+
/**
|
|
1098
|
+
* The differences in `eventTypes` (a set: the types added and removed, sorted) and, only
|
|
1099
|
+
* when the file writes it, `enabled`.
|
|
1100
|
+
*/
|
|
1101
|
+
fields: Change[];
|
|
1102
|
+
/**
|
|
1103
|
+
* Set when the run switches on an endpoint the **server** switched off: the server's reason
|
|
1104
|
+
* (`failing`, `gone`), so that a pipeline does not quietly undo it on every run.
|
|
1105
|
+
*/
|
|
1106
|
+
reenables?: string;
|
|
1107
|
+
/** For `ambiguous`: the ids of the server's endpoints with this address. */
|
|
1108
|
+
duplicates?: string[];
|
|
1109
|
+
}
|
|
1110
|
+
/**
|
|
1111
|
+
* What a run does with an environment's webhook endpoints.
|
|
1112
|
+
*
|
|
1113
|
+
* @example
|
|
1114
|
+
* ```ts
|
|
1115
|
+
* if (plan.webhooks.overLimit !== null) {
|
|
1116
|
+
* // the run is refused before any write
|
|
1117
|
+
* }
|
|
1118
|
+
* ```
|
|
1119
|
+
*/
|
|
1120
|
+
interface WebhookPlan {
|
|
1121
|
+
/** Whether the config has a `webhooks` list at all. Without one nothing is read or changed. */
|
|
1122
|
+
managed: boolean;
|
|
1123
|
+
/** One entry per entry of the file, in its order, then the endpoints only the server has. */
|
|
1124
|
+
endpoints: WebhookChange[];
|
|
1125
|
+
/**
|
|
1126
|
+
* How many removals are made **before** the creations because the environment is too close
|
|
1127
|
+
* to its limit to hold the new endpoints beside the ones being removed. Otherwise `0`:
|
|
1128
|
+
* everything is created before anything is removed.
|
|
1129
|
+
*/
|
|
1130
|
+
removedFirst: number;
|
|
1131
|
+
/**
|
|
1132
|
+
* How many endpoints the environment would have after the run, when that is more than it
|
|
1133
|
+
* may have (`MAX_WEBHOOK_ENDPOINTS`); `null` when the plan fits.
|
|
1134
|
+
*/
|
|
1135
|
+
overLimit: number | null;
|
|
1136
|
+
/**
|
|
1137
|
+
* What the plan read of the server's endpoints ({@link webhookSnapshot}): `tula apply` reads
|
|
1138
|
+
* them again before its first webhook write and stops if this no longer matches.
|
|
1139
|
+
*/
|
|
1140
|
+
seen: string;
|
|
1141
|
+
}
|
|
1142
|
+
/**
|
|
1143
|
+
* The accounts that depend on a provider, as the server counted them
|
|
1144
|
+
* (`GET /v1/admin/oauth-providers/{provider}/dependents`, ADR 0059): how many of the
|
|
1145
|
+
* accounts it looked at can sign in only with that provider, and whether there were more
|
|
1146
|
+
* accounts than it looks at (the number is then a floor). Two values and nothing about who.
|
|
1147
|
+
*
|
|
1148
|
+
* @example
|
|
1149
|
+
* ```ts
|
|
1150
|
+
* const count: DependentCount = { accounts: 3, more: false }
|
|
1151
|
+
* ```
|
|
1152
|
+
*/
|
|
1153
|
+
interface DependentCount {
|
|
1154
|
+
/** How many were found among the accounts looked at. */
|
|
1155
|
+
accounts: number;
|
|
1156
|
+
/** Whether not every account was looked at: read `accounts` as "at least". */
|
|
1157
|
+
more: boolean;
|
|
1158
|
+
}
|
|
1159
|
+
/**
|
|
1160
|
+
* A count the server should have had and the run could not read: a request that failed or
|
|
1161
|
+
* got no answer, a refusal, an answer of another shape. Not known, never zero, and never
|
|
1162
|
+
* "not judged": the plan is flagged as if the server had not looked at every account,
|
|
1163
|
+
* because `tula apply --yes` must not get past a count for the reason that it failed.
|
|
1164
|
+
*
|
|
1165
|
+
* @example
|
|
1166
|
+
* ```ts
|
|
1167
|
+
* const dependents: RemoteDependents = { x: DEPENDENTS_UNREAD }
|
|
1168
|
+
* ```
|
|
1169
|
+
*/
|
|
1170
|
+
declare const DEPENDENTS_UNREAD = "unread";
|
|
1171
|
+
/**
|
|
1172
|
+
* What a run knows of one provider's dependent accounts: the count; {@link DEPENDENTS_UNREAD}
|
|
1173
|
+
* for one that could not be read; `null` for a server that has none to give (one from
|
|
1174
|
+
* before the route), the only case in which nothing is judged.
|
|
1175
|
+
*
|
|
1176
|
+
* @example
|
|
1177
|
+
* ```ts
|
|
1178
|
+
* const known: KnownDependents = { accounts: 3, more: false }
|
|
1179
|
+
* ```
|
|
1180
|
+
*/
|
|
1181
|
+
type KnownDependents = DependentCount | typeof DEPENDENTS_UNREAD | null;
|
|
1182
|
+
/**
|
|
1183
|
+
* What a run knows of the dependent accounts, by provider.
|
|
1184
|
+
*
|
|
1185
|
+
* @example
|
|
1186
|
+
* ```ts
|
|
1187
|
+
* const dependents: RemoteDependents = { x: { accounts: 3, more: false }, github: null }
|
|
1188
|
+
* ```
|
|
1189
|
+
*/
|
|
1190
|
+
type RemoteDependents = Partial<Record<OAuthProvider, KnownDependents>>;
|
|
1191
|
+
/**
|
|
1192
|
+
* Read a server's answer about a provider's dependent accounts. Only an answer of exactly
|
|
1193
|
+
* the expected shape is a count: the provider that was asked about, a whole number that is
|
|
1194
|
+
* not negative, a boolean. Anything else is "not read", never zero.
|
|
1195
|
+
*
|
|
1196
|
+
* @param provider - The provider that was asked about.
|
|
1197
|
+
* @param answer - What the server answered.
|
|
1198
|
+
* @returns The count, or `null` when the answer is not one.
|
|
1199
|
+
*
|
|
1200
|
+
* @example
|
|
1201
|
+
* ```ts
|
|
1202
|
+
* readDependentCount('x', { provider: 'x', accounts: 3, more: false }) // { accounts: 3, more: false }
|
|
1203
|
+
* readDependentCount('x', { provider: 'x', accounts: '3' }) // null
|
|
1204
|
+
* ```
|
|
1205
|
+
*/
|
|
1206
|
+
declare function readDependentCount(provider: OAuthProvider, answer: unknown): DependentCount | null;
|
|
1207
|
+
/**
|
|
1208
|
+
* What a run does with one provider.
|
|
1209
|
+
*
|
|
1210
|
+
* @example
|
|
1211
|
+
* ```ts
|
|
1212
|
+
* const change: ProviderChange = planProviders(remote.providers, environment.providers, {})[0]
|
|
1213
|
+
* ```
|
|
1214
|
+
*/
|
|
1215
|
+
interface ProviderChange {
|
|
1216
|
+
/** The provider. */
|
|
1217
|
+
provider: OAuthProvider;
|
|
1218
|
+
/**
|
|
1219
|
+
* `create`, `update`, `delete`; `none` when it already is as the config says; `unmanaged`
|
|
1220
|
+
* when the server has it and the config does not (left alone without `--prune`).
|
|
1221
|
+
*/
|
|
1222
|
+
action: "create" | "update" | "delete" | "none" | "unmanaged";
|
|
1223
|
+
/**
|
|
1224
|
+
* The differences in its fields that are not secret: `clientId`, `teamId`, `keyId`,
|
|
1225
|
+
* `tenant`, `additionalClientIds` (a set, with what is `added` and `removed`), `enabled`.
|
|
1226
|
+
*/
|
|
1227
|
+
fields: Change[];
|
|
1228
|
+
/**
|
|
1229
|
+
* What happens to its secret: `set` (written from the environment variable), `keep` (the
|
|
1230
|
+
* stored one stays) or `none` (there is none to speak of). Never the value.
|
|
1231
|
+
*/
|
|
1232
|
+
secret: "set" | "keep" | "none";
|
|
1233
|
+
/** The variable the secret is read from, when the config manages the provider. */
|
|
1234
|
+
secretEnv?: string;
|
|
1235
|
+
/** Whether sign-in offers it now. */
|
|
1236
|
+
enabledBefore: boolean;
|
|
1237
|
+
/** Whether sign-in offers it after the run. */
|
|
1238
|
+
enabledAfter: boolean;
|
|
1239
|
+
/**
|
|
1240
|
+
* The paths at which the run makes the provider accept more than it does: a client id
|
|
1241
|
+
* gained in Google's `additionalClientIds` (`providers.google.additionalClientIds`; the
|
|
1242
|
+
* field's name, never an id); and, where accounts depend on it (ADR 0059),
|
|
1243
|
+
* `providers.<provider>.enabled` for one the run switches off and `providers.<provider>`
|
|
1244
|
+
* for one it removes. Part of the plan's `weakened`.
|
|
1245
|
+
*/
|
|
1246
|
+
weakened: string[];
|
|
1247
|
+
/**
|
|
1248
|
+
* Only for a provider the run switches off or removes, and only when the run asked the
|
|
1249
|
+
* server: the accounts that depend on it; `'unread'` when the count could not be read
|
|
1250
|
+
* (the plan is then flagged); `null` when the server has no count to give (then nothing
|
|
1251
|
+
* is judged). The plan says which.
|
|
1252
|
+
*/
|
|
1253
|
+
dependents?: KnownDependents;
|
|
1254
|
+
}
|
|
1255
|
+
/** Options of a plan. */
|
|
1256
|
+
interface PlanOptions {
|
|
1257
|
+
/** Delete providers the server has and the config does not. */
|
|
1258
|
+
prune?: boolean;
|
|
1259
|
+
/** Send every managed provider's secret again, changed or not. */
|
|
1260
|
+
rotateSecrets?: boolean;
|
|
1261
|
+
}
|
|
1262
|
+
/**
|
|
1263
|
+
* Decide what to do with each provider.
|
|
1264
|
+
*
|
|
1265
|
+
* A secret cannot be compared: the API never returns one. So it is written when the provider
|
|
1266
|
+
* is created, when one of its identifying fields changes (a new client id comes with a new
|
|
1267
|
+
* secret) and when `rotateSecrets` asks; switching a provider on or off keeps the stored one,
|
|
1268
|
+
* and so does a change of Microsoft's `tenant` (which accounts may sign in: the app
|
|
1269
|
+
* registration, and so its secret, is the same) or of Google's `additionalClientIds`.
|
|
1270
|
+
* That is what makes a second run a no-op.
|
|
1271
|
+
*
|
|
1272
|
+
* Google's `additionalClientIds` (ADR 0045) are compared as a set, against none where the
|
|
1273
|
+
* file leaves them out or the server does not report the field; an id the file adds is the
|
|
1274
|
+
* entry's `weakened`.
|
|
1275
|
+
*
|
|
1276
|
+
* A provider the server has **on** that the run switches off or removes is a weakening where
|
|
1277
|
+
* accounts depend on it (ADR 0059: `providers.<provider>.enabled`, `providers.<provider>`).
|
|
1278
|
+
* The engine does not count: it is handed what the run read (`dependents`), judges
|
|
1279
|
+
* nothing for a provider whose count is missing or `null` (a server with none to give),
|
|
1280
|
+
* and flags one whose count could not be read.
|
|
1281
|
+
*
|
|
1282
|
+
* @param remote - The providers as the server lists them.
|
|
1283
|
+
* @param desired - The providers of the config's environment.
|
|
1284
|
+
* @param options - `prune`, `rotateSecrets`.
|
|
1285
|
+
* @param dependents - The dependent accounts the run counted, by provider.
|
|
1286
|
+
* @returns One entry per provider that the config or the server knows, by provider name.
|
|
1287
|
+
*
|
|
1288
|
+
* @example
|
|
1289
|
+
* ```ts
|
|
1290
|
+
* planProviders(remote.providers, environment.providers, { prune: true }, remote.dependents)
|
|
1291
|
+
* ```
|
|
1292
|
+
*/
|
|
1293
|
+
declare function planProviders(remote: readonly RemoteProvider[], desired: EnvironmentConfig["providers"], options: PlanOptions, dependents?: RemoteDependents): ProviderChange[];
|
|
1294
|
+
/**
|
|
1295
|
+
* The providers a run would take away from sign-in: on at the server, and switched off by
|
|
1296
|
+
* the file or removed (`--prune`). They are the only ones whose dependent accounts a run
|
|
1297
|
+
* asks the server to count, before the plan is made.
|
|
1298
|
+
*
|
|
1299
|
+
* @param remote - The providers as the server lists them.
|
|
1300
|
+
* @param desired - The providers of the config's environment.
|
|
1301
|
+
* @param options - `prune`.
|
|
1302
|
+
* @returns The providers, by name.
|
|
1303
|
+
*
|
|
1304
|
+
* @example
|
|
1305
|
+
* ```ts
|
|
1306
|
+
* providersGoingOff(remote.providers, environment.providers, { prune: true }) // ['x']
|
|
1307
|
+
* ```
|
|
1308
|
+
*/
|
|
1309
|
+
declare function providersGoingOff(remote: readonly RemoteProvider[], desired: EnvironmentConfig["providers"], options: Pick<PlanOptions, "prune">): OAuthProvider[];
|
|
1310
|
+
/**
|
|
1311
|
+
* Whether the server records this config as the manager of the settings, and why not.
|
|
1312
|
+
*
|
|
1313
|
+
* @example
|
|
1314
|
+
* ```ts
|
|
1315
|
+
* if (plan.marker.reason === 'drifted') {
|
|
1316
|
+
* warn('changed outside the config file')
|
|
1317
|
+
* }
|
|
1318
|
+
* ```
|
|
1319
|
+
*/
|
|
1320
|
+
interface MarkerPlan {
|
|
1321
|
+
/** Whether the server reports a manager at all (an older one does not). */
|
|
1322
|
+
supported: boolean;
|
|
1323
|
+
/** Whether a run would record this config as the manager. */
|
|
1324
|
+
pending: boolean;
|
|
1325
|
+
/**
|
|
1326
|
+
* Why: `unmanaged` (nobody is on record), `other-tool`, `other-config` (another version of
|
|
1327
|
+
* the file was applied), `drifted` (the settings were replaced around the file since), or
|
|
1328
|
+
* `none` (it is on record, or the server does not record one).
|
|
1329
|
+
*/
|
|
1330
|
+
reason: "unmanaged" | "other-tool" | "other-config" | "drifted" | "none";
|
|
1331
|
+
/** The manager on record. */
|
|
1332
|
+
current: SettingsManagedBy | null;
|
|
1333
|
+
/** This config's fingerprint. */
|
|
1334
|
+
configHash: string;
|
|
1335
|
+
}
|
|
1336
|
+
/**
|
|
1337
|
+
* Everything a run would do to one environment.
|
|
1338
|
+
*
|
|
1339
|
+
* @example
|
|
1340
|
+
* ```ts
|
|
1341
|
+
* const plan: Plan = buildPlan(remote, environment, { configHash })
|
|
1342
|
+
* process.exitCode = plan.changes ? 2 : 0
|
|
1343
|
+
* ```
|
|
1344
|
+
*/
|
|
1345
|
+
interface Plan {
|
|
1346
|
+
/** The settings revision the plan was made against. */
|
|
1347
|
+
revision: number;
|
|
1348
|
+
/** The differences in the settings document. */
|
|
1349
|
+
settings: Change[];
|
|
1350
|
+
/**
|
|
1351
|
+
* Paths where the run weakens security: the settings' (the contract's
|
|
1352
|
+
* `settingsWeakenings`), then the providers' (`oauthProviderWeakenings`, as
|
|
1353
|
+
* `providers.<provider>…`), then the hooks' (`hookWeakenings`, as `hooks.<point>…`), then the
|
|
1354
|
+
* native apps' (`nativeAppWeakenings`, as `nativeApps.<platform>/<identifier>…`).
|
|
1355
|
+
*/
|
|
1356
|
+
weakened: string[];
|
|
1357
|
+
/** Settings the file leaves out whose default is the deployment's: kept as the server has them. */
|
|
1358
|
+
kept: string[];
|
|
1359
|
+
/** Settings the server has that this version of the CLI does not know. */
|
|
1360
|
+
unknown: string[];
|
|
1361
|
+
/** What happens to each provider. */
|
|
1362
|
+
providers: ProviderChange[];
|
|
1363
|
+
/** What happens to each webhook endpoint, when the config manages them. */
|
|
1364
|
+
webhooks: WebhookPlan;
|
|
1365
|
+
/** What happens to each hook, when the config manages them. */
|
|
1366
|
+
hooks: HookPlan;
|
|
1367
|
+
/** What happens to each native app, when the config manages them. */
|
|
1368
|
+
nativeApps: NativeAppPlan;
|
|
1369
|
+
/** Whether the server records this config as the settings' manager. */
|
|
1370
|
+
marker: MarkerPlan;
|
|
1371
|
+
/** The document a replace would send. */
|
|
1372
|
+
body: EnvironmentSettingsInput;
|
|
1373
|
+
/** Whether a run would change anything. */
|
|
1374
|
+
changes: boolean;
|
|
1375
|
+
}
|
|
1376
|
+
/**
|
|
1377
|
+
* Compare a server's state with a config's environment.
|
|
1378
|
+
*
|
|
1379
|
+
* The file is the whole truth for what it can express: a setting it leaves out goes back to
|
|
1380
|
+
* its default. The exceptions are the two settings whose default is the **deployment's**
|
|
1381
|
+
* (`password`, `urls.allowedOrigins`), which only the server knows: left out, they are kept as
|
|
1382
|
+
* the server has them and listed in `kept`.
|
|
1383
|
+
*
|
|
1384
|
+
* @param remote - What the server has.
|
|
1385
|
+
* @param environment - The config's environment.
|
|
1386
|
+
* @param options - The config's fingerprint, `prune`, `rotateSecrets`.
|
|
1387
|
+
* @returns The plan.
|
|
1388
|
+
*
|
|
1389
|
+
* @example
|
|
1390
|
+
* ```ts
|
|
1391
|
+
* const plan = buildPlan(remote, selectEnvironment(config, 'prod'), { configHash })
|
|
1392
|
+
* ```
|
|
1393
|
+
*/
|
|
1394
|
+
declare function buildPlan(remote: RemoteState, environment: EnvironmentConfig, options: PlanOptions & {
|
|
1395
|
+
configHash: string;
|
|
1396
|
+
}): Plan;
|
|
1397
|
+
/**
|
|
1398
|
+
* One write of a run.
|
|
1399
|
+
*
|
|
1400
|
+
* @example
|
|
1401
|
+
* ```ts
|
|
1402
|
+
* for (const operation of orderOperations(plan)) {
|
|
1403
|
+
* await run(operation)
|
|
1404
|
+
* }
|
|
1405
|
+
* ```
|
|
1406
|
+
*/
|
|
1407
|
+
type Operation = {
|
|
1408
|
+
kind: "settings";
|
|
1409
|
+
} | {
|
|
1410
|
+
kind: "provider.set";
|
|
1411
|
+
provider: OAuthProvider;
|
|
1412
|
+
change: ProviderChange;
|
|
1413
|
+
} | {
|
|
1414
|
+
kind: "provider.delete";
|
|
1415
|
+
provider: OAuthProvider;
|
|
1416
|
+
change: ProviderChange;
|
|
1417
|
+
} | {
|
|
1418
|
+
kind: "webhook.create";
|
|
1419
|
+
url: string;
|
|
1420
|
+
change: WebhookChange;
|
|
1421
|
+
} | {
|
|
1422
|
+
kind: "webhook.update";
|
|
1423
|
+
url: string;
|
|
1424
|
+
id: string;
|
|
1425
|
+
change: WebhookChange;
|
|
1426
|
+
} | {
|
|
1427
|
+
kind: "webhook.delete";
|
|
1428
|
+
url: string;
|
|
1429
|
+
id: string;
|
|
1430
|
+
change: WebhookChange;
|
|
1431
|
+
} | {
|
|
1432
|
+
kind: "hook.create";
|
|
1433
|
+
point: HookPoint;
|
|
1434
|
+
change: HookChange;
|
|
1435
|
+
} | {
|
|
1436
|
+
kind: "hook.update";
|
|
1437
|
+
point: HookPoint;
|
|
1438
|
+
id: string;
|
|
1439
|
+
change: HookChange;
|
|
1440
|
+
} | {
|
|
1441
|
+
kind: "hook.delete";
|
|
1442
|
+
point: HookPoint;
|
|
1443
|
+
id: string;
|
|
1444
|
+
change: HookChange;
|
|
1445
|
+
} | {
|
|
1446
|
+
kind: "nativeApp.create";
|
|
1447
|
+
entry: NativeAppConfig;
|
|
1448
|
+
change: NativeAppChange;
|
|
1449
|
+
} | {
|
|
1450
|
+
kind: "nativeApp.update";
|
|
1451
|
+
id: string;
|
|
1452
|
+
entry: NativeAppConfig;
|
|
1453
|
+
change: NativeAppChange;
|
|
1454
|
+
} | {
|
|
1455
|
+
kind: "nativeApp.delete";
|
|
1456
|
+
id: string;
|
|
1457
|
+
change: NativeAppChange;
|
|
1458
|
+
};
|
|
1459
|
+
/**
|
|
1460
|
+
* The writes of a plan, in an order in which every intermediate state is one the server
|
|
1461
|
+
* accepts.
|
|
1462
|
+
*
|
|
1463
|
+
* The server refuses to leave an environment with no way to sign in: its settings need a
|
|
1464
|
+
* native method (password, email code, passkey) or an enabled OAuth provider. So:
|
|
1465
|
+
*
|
|
1466
|
+
* 1. writes that add a way in or change none: creating a provider, enabling one, new
|
|
1467
|
+
* credentials;
|
|
1468
|
+
* 2. writes that take one away: switching a provider off, then deleting providers.
|
|
1469
|
+
*
|
|
1470
|
+
* and the settings go **first** whenever the document they produce has a native method of its
|
|
1471
|
+
* own: nothing after them can then be refused for want of a way in, and a settings revision
|
|
1472
|
+
* that turned out stale stops the run before anything was written. Only when the file
|
|
1473
|
+
* switches every native method off do the settings wait for the providers they rely on.
|
|
1474
|
+
*
|
|
1475
|
+
* **Webhook endpoints come after the settings and every provider.** Nothing about
|
|
1476
|
+
* signing in depends on them, and a registration is the write most likely to be refused for
|
|
1477
|
+
* a reason outside the file (the server could not resolve the address, the environment is at
|
|
1478
|
+
* its limit): a failure there must find the settings and the providers already as the file
|
|
1479
|
+
* says, and a stale settings revision must stop the run before an endpoint is touched.
|
|
1480
|
+
* Among themselves: changes to existing endpoints, then new ones, then removals, so that an
|
|
1481
|
+
* address being replaced is never without an endpoint. The one exception is the limit
|
|
1482
|
+
* (`MAX_WEBHOOK_ENDPOINTS`): when the new endpoints do not fit beside the ones being removed,
|
|
1483
|
+
* exactly as many removals as it takes (`plan.webhooks.removedFirst`, oldest first) go before
|
|
1484
|
+
* the creations.
|
|
1485
|
+
*
|
|
1486
|
+
* **Hooks come after the webhook endpoints, and native apps last.** For hooks: an endpoint the same run registers is
|
|
1487
|
+
* then there to be told of what the run does to a hook (`hook.updated` with `weakened`),
|
|
1488
|
+
* since an event is owed only to endpoints registered before it happened. A hook's
|
|
1489
|
+
* registration can be refused for the same outside reasons as an endpoint's. Among
|
|
1490
|
+
* themselves, so that a run that stops half-way leaves the environment no laxer than it has
|
|
1491
|
+
* to: new hooks, then changes that loosen nothing, then changes that loosen one (switched
|
|
1492
|
+
* off, `allow`), then removals. Hooks at different points do not depend on each other and
|
|
1493
|
+
* each write is one request, so no intermediate state is laxer than both the start and the
|
|
1494
|
+
* end.
|
|
1495
|
+
*
|
|
1496
|
+
* Nothing else in a plan depends on a native app, and nothing about signing in on the web
|
|
1497
|
+
* does. Among themselves, for the same reason as the hooks' order and so that the limit
|
|
1498
|
+
* (`MAX_NATIVE_APPS`) is never met on the way to a state that fits: removals, then changes
|
|
1499
|
+
* that widen nothing (a fingerprint or a link path taken away), then changes that widen
|
|
1500
|
+
* (another team, a gained fingerprint, a gained link path), then registrations.
|
|
1501
|
+
*
|
|
1502
|
+
* @param plan - The plan.
|
|
1503
|
+
* @returns The writes, in order; empty when the plan changes nothing.
|
|
1504
|
+
*
|
|
1505
|
+
* @example
|
|
1506
|
+
* ```ts
|
|
1507
|
+
* orderOperations(plan).map((operation) => operation.kind) // ['settings', 'provider.set']
|
|
1508
|
+
* ```
|
|
1509
|
+
*/
|
|
1510
|
+
declare function orderOperations(plan: Plan): Operation[];
|
|
1511
|
+
import { InstanceClient } from "@tula-tech/admin";
|
|
1512
|
+
/**
|
|
1513
|
+
* How one check went.
|
|
1514
|
+
*
|
|
1515
|
+
* @example
|
|
1516
|
+
* ```ts
|
|
1517
|
+
* const status: CheckStatus = 'warn'
|
|
1518
|
+
* ```
|
|
1519
|
+
*/
|
|
1520
|
+
type CheckStatus = "ok" | "warn" | "fail" | "skipped";
|
|
1521
|
+
/**
|
|
1522
|
+
* One line of `tula doctor`'s report.
|
|
1523
|
+
*
|
|
1524
|
+
* @example
|
|
1525
|
+
* ```ts
|
|
1526
|
+
* const check: DoctorCheck = { id: 'api', source: 'cli', status: 'ok', summary: 'The API answers.' }
|
|
1527
|
+
* ```
|
|
1528
|
+
*/
|
|
1529
|
+
interface DoctorCheck {
|
|
1530
|
+
/** Stable identifier, e.g. `database`. */
|
|
1531
|
+
id: string;
|
|
1532
|
+
/** Where it ran: on this machine, or inside the API. */
|
|
1533
|
+
source: "cli" | "server";
|
|
1534
|
+
/** How it went. */
|
|
1535
|
+
status: CheckStatus;
|
|
1536
|
+
/** One sentence saying what was found. */
|
|
1537
|
+
summary: string;
|
|
1538
|
+
/** What to do about it. */
|
|
1539
|
+
fix?: string;
|
|
1540
|
+
/** Values the operator needs, e.g. redirect URIs. */
|
|
1541
|
+
values?: string[];
|
|
1542
|
+
}
|
|
1543
|
+
/**
|
|
1544
|
+
* What `tula doctor` found.
|
|
1545
|
+
*
|
|
1546
|
+
* @example
|
|
1547
|
+
* ```ts
|
|
1548
|
+
* const report = await examine({ apiUrl, instance, io })
|
|
1549
|
+
* process.exit(exitCode(report, false))
|
|
1550
|
+
* ```
|
|
1551
|
+
*/
|
|
1552
|
+
interface DoctorReport {
|
|
1553
|
+
/** The API that was examined. */
|
|
1554
|
+
apiUrl: string;
|
|
1555
|
+
/** The API's version, when it answered. */
|
|
1556
|
+
version?: string;
|
|
1557
|
+
/** The deployment tier, when the server-side checks ran. */
|
|
1558
|
+
environment?: string;
|
|
1559
|
+
/** Every check, the CLI's own first. */
|
|
1560
|
+
checks: DoctorCheck[];
|
|
1561
|
+
}
|
|
1562
|
+
/**
|
|
1563
|
+
* Text from a server, made safe for a terminal and bounded: no control characters (an escape
|
|
1564
|
+
* sequence could rewrite the screen or the window title), and nothing invisible that changes
|
|
1565
|
+
* how the rest reads (a right-to-left override would show `all good` for a failing check's
|
|
1566
|
+
* text; a zero-width space splits a word a reader would search for).
|
|
1567
|
+
*
|
|
1568
|
+
* @param value - What the server sent.
|
|
1569
|
+
* @param max - The longest text kept, in UTF-16 units; a character is never cut in half.
|
|
1570
|
+
* @returns Printable text.
|
|
1571
|
+
*
|
|
1572
|
+
* @example
|
|
1573
|
+
* ```ts
|
|
1574
|
+
* printable('ok\u001b[2J') // 'ok [2J'
|
|
1575
|
+
* printable('ok\u{202E}txt.exe') // 'oktxt.exe'
|
|
1576
|
+
* ```
|
|
1577
|
+
*/
|
|
1578
|
+
declare function printable(value: unknown, max?: number): string;
|
|
1579
|
+
/**
|
|
1580
|
+
* Examine a deployment: what this machine can see (the API answers, its version, the two
|
|
1581
|
+
* clocks, a loopback `PUBLIC_URL`) and what the API reports about itself through
|
|
1582
|
+
* `GET /v1/instance/diagnostics`.
|
|
1583
|
+
*
|
|
1584
|
+
* It never throws for something a check can say: an unreachable API is a failing check.
|
|
1585
|
+
*
|
|
1586
|
+
* @param input - The API's URL, the instance client (`null` without an admin token), the io,
|
|
1587
|
+
* and optionally a signal: aborting it ends the request in flight, and the run answers with
|
|
1588
|
+
* the failing check an unreachable API gets (`tula mcp` abandons a run that takes too long).
|
|
1589
|
+
* @returns The report.
|
|
1590
|
+
*
|
|
1591
|
+
* @example
|
|
1592
|
+
* ```ts
|
|
1593
|
+
* const report = await examine({ apiUrl: 'http://localhost:3003', instance, io })
|
|
1594
|
+
* ```
|
|
1595
|
+
*/
|
|
1596
|
+
declare function examine(input: {
|
|
1597
|
+
apiUrl: string;
|
|
1598
|
+
instance: InstanceClient | null;
|
|
1599
|
+
io: CliIo;
|
|
1600
|
+
signal?: AbortSignal;
|
|
1601
|
+
}): Promise<DoctorReport>;
|
|
1602
|
+
/**
|
|
1603
|
+
* The exit code of a report: `1` when a check failed, or warned under `--strict`.
|
|
1604
|
+
*
|
|
1605
|
+
* @param report - The report.
|
|
1606
|
+
* @param strict - Whether a warning fails too.
|
|
1607
|
+
* @returns `0` or `1`.
|
|
1608
|
+
*
|
|
1609
|
+
* @example
|
|
1610
|
+
* ```ts
|
|
1611
|
+
* exitCode(report, flags.strict === true)
|
|
1612
|
+
* ```
|
|
1613
|
+
*/
|
|
1614
|
+
declare function exitCode(report: DoctorReport, strict: boolean): number;
|
|
1615
|
+
/**
|
|
1616
|
+
* The report as data, for `--json`.
|
|
1617
|
+
*
|
|
1618
|
+
* @param report - The report.
|
|
1619
|
+
* @param strict - Whether a warning fails.
|
|
1620
|
+
* @returns A JSON-ready object.
|
|
1621
|
+
*
|
|
1622
|
+
* @example
|
|
1623
|
+
* ```ts
|
|
1624
|
+
* output.line(JSON.stringify(reportToJson(report, false), null, 2))
|
|
1625
|
+
* ```
|
|
1626
|
+
*/
|
|
1627
|
+
declare function reportToJson(report: DoctorReport, strict: boolean): {
|
|
1628
|
+
/** The API that was examined. */
|
|
1629
|
+
apiUrl: string;
|
|
1630
|
+
/** The API's version, when it answered. */
|
|
1631
|
+
version?: string;
|
|
1632
|
+
/** The deployment tier, when the server-side checks ran. */
|
|
1633
|
+
environment?: string;
|
|
1634
|
+
/** Every check, the CLI's own first. */
|
|
1635
|
+
checks: DoctorCheck[];
|
|
1636
|
+
ok: boolean;
|
|
1637
|
+
};
|
|
1638
|
+
/**
|
|
1639
|
+
* Print the report as a table, with a fix line under each check that is not ok.
|
|
1640
|
+
*
|
|
1641
|
+
* @param output - Where to write.
|
|
1642
|
+
* @param report - The report.
|
|
1643
|
+
*
|
|
1644
|
+
* @example
|
|
1645
|
+
* ```ts
|
|
1646
|
+
* renderReport(output, report)
|
|
1647
|
+
* ```
|
|
1648
|
+
*/
|
|
1649
|
+
declare function renderReport(output: Output, report: DoctorReport): void;
|
|
1650
|
+
/**
|
|
1651
|
+
* The warnings of a plan, as sentences.
|
|
1652
|
+
*
|
|
1653
|
+
* @param plan - The plan.
|
|
1654
|
+
* @returns One line per warning; empty when there are none.
|
|
1655
|
+
*
|
|
1656
|
+
* @example
|
|
1657
|
+
* ```ts
|
|
1658
|
+
* planWarnings(plan) // ['weakens security: mfa.policy']
|
|
1659
|
+
* ```
|
|
1660
|
+
*/
|
|
1661
|
+
declare function planWarnings(plan: Plan): string[];
|
|
1662
|
+
/**
|
|
1663
|
+
* What `tula apply` will not do to a plan without being told to: reset settings this version
|
|
1664
|
+
* does not know (`--allow-unknown`); when nobody is asked (`--yes`), weaken security
|
|
1665
|
+
* (`--allow-weaker`) or remove a webhook endpoint with its delivery log
|
|
1666
|
+
* (`--allow-webhook-removal`); and create a webhook endpoint or a hook without a word on what
|
|
1667
|
+
* becomes of its signing secret (`--secrets-file`, `--show-secrets` or `--discard-secrets`).
|
|
1668
|
+
*
|
|
1669
|
+
* @param plan - The plan.
|
|
1670
|
+
* @returns Which of them the plan needs.
|
|
1671
|
+
*
|
|
1672
|
+
* @example
|
|
1673
|
+
* ```ts
|
|
1674
|
+
* applyRequirements(plan)
|
|
1675
|
+
* // { allowUnknown: false, allowWeaker: true, allowWebhookRemoval: false,
|
|
1676
|
+
* // webhookSecrets: false, hookSecrets: false }
|
|
1677
|
+
* ```
|
|
1678
|
+
*/
|
|
1679
|
+
declare function applyRequirements(plan: Plan): {
|
|
1680
|
+
allowUnknown: boolean;
|
|
1681
|
+
allowWeaker: boolean;
|
|
1682
|
+
allowWebhookRemoval: boolean;
|
|
1683
|
+
webhookSecrets: boolean;
|
|
1684
|
+
hookSecrets: boolean;
|
|
1685
|
+
};
|
|
1686
|
+
/**
|
|
1687
|
+
* One line that says what a write does.
|
|
1688
|
+
*
|
|
1689
|
+
* @param operation - The write.
|
|
1690
|
+
* @returns E.g. `settings: replace`, `provider google: create`, `webhook https://…: remove`,
|
|
1691
|
+
* `hook before_sign_up: update`, `native app ios com.example.app: register`.
|
|
1692
|
+
*
|
|
1693
|
+
* @example
|
|
1694
|
+
* ```ts
|
|
1695
|
+
* describeOperation({ kind: 'settings' }) // 'settings: replace'
|
|
1696
|
+
* ```
|
|
1697
|
+
*/
|
|
1698
|
+
declare function describeOperation(operation: Operation): string;
|
|
1699
|
+
/**
|
|
1700
|
+
* Print a plan for a person: what would be added, changed and removed, by path, with warnings.
|
|
1701
|
+
* A provider's secret shows as "set from $NAME" or "kept", never as a value.
|
|
1702
|
+
*
|
|
1703
|
+
* @param output - Where to write.
|
|
1704
|
+
* @param target - The environment's name in the config and the API's URL.
|
|
1705
|
+
* @param plan - The plan.
|
|
1706
|
+
*
|
|
1707
|
+
* @example
|
|
1708
|
+
* ```ts
|
|
1709
|
+
* renderPlan(output, { environment: 'prod', apiUrl }, plan)
|
|
1710
|
+
* ```
|
|
1711
|
+
*/
|
|
1712
|
+
declare function renderPlan(output: Output, target: {
|
|
1713
|
+
environment: string;
|
|
1714
|
+
apiUrl: string;
|
|
1715
|
+
}, plan: Plan): void;
|
|
1716
|
+
/**
|
|
1717
|
+
* A plan for a machine: the same content as {@link renderPlan}, as plain data. A provider's
|
|
1718
|
+
* secret appears as `set` or `keep` and the name of its variable, never as a value; a webhook
|
|
1719
|
+
* endpoint's and a hook's secret do not appear at all (no read returns one).
|
|
1720
|
+
*
|
|
1721
|
+
* @param target - The environment's name in the config and the API's URL.
|
|
1722
|
+
* @param plan - The plan.
|
|
1723
|
+
* @returns A JSON-serialisable object.
|
|
1724
|
+
*
|
|
1725
|
+
* @example
|
|
1726
|
+
* ```ts
|
|
1727
|
+
* output.line(JSON.stringify(planToJson({ environment: 'prod', apiUrl }, plan), null, 2))
|
|
1728
|
+
* ```
|
|
1729
|
+
*/
|
|
1730
|
+
declare function planToJson(target: {
|
|
1731
|
+
environment: string;
|
|
1732
|
+
apiUrl: string;
|
|
1733
|
+
}, plan: Plan): Record<string, unknown>;
|
|
1734
|
+
import { AdminClient, InstanceClient as InstanceClient2 } from "@tula-tech/admin";
|
|
1735
|
+
import { EnvironmentConfig as EnvironmentConfig2 } from "@tula-tech/config";
|
|
1736
|
+
/**
|
|
1737
|
+
* The API a run talks to.
|
|
1738
|
+
*
|
|
1739
|
+
* @example
|
|
1740
|
+
* ```ts
|
|
1741
|
+
* const { admin, apiUrl } = await resolveTarget({ name: 'prod', environment, flags, io, output })
|
|
1742
|
+
* ```
|
|
1743
|
+
*/
|
|
1744
|
+
interface Target {
|
|
1745
|
+
/** The API's URL, for display. */
|
|
1746
|
+
apiUrl: string;
|
|
1747
|
+
/** The admin client, holding the secret key. */
|
|
1748
|
+
admin: AdminClient;
|
|
1749
|
+
}
|
|
1750
|
+
/**
|
|
1751
|
+
* Work out which API a run talks to and with which key.
|
|
1752
|
+
*
|
|
1753
|
+
* - **URL**: `--api-url`, else `TULA_API_URL_<NAME>`, else `TULA_API_URL`. It must be https,
|
|
1754
|
+
* or this machine (`localhost`, `*.localhost`, `127.0.0.1`, `[::1]`): over plain http the
|
|
1755
|
+
* secret key and provider secrets cross the network in clear text. `--insecure-http` allows
|
|
1756
|
+
* it for a private network you trust.
|
|
1757
|
+
* - **Secret key**: `--secret-key-file <path>` (`-` reads standard input, which must then be
|
|
1758
|
+
* a pipe: a terminal would show the key as it is typed), else `TULA_SECRET_KEY_<NAME>`, else
|
|
1759
|
+
* `TULA_SECRET_KEY`.
|
|
1760
|
+
*
|
|
1761
|
+
* `<NAME>` is the environment's name in the config, upper-cased, so one CI job can hold the
|
|
1762
|
+
* keys of several environments. Neither is ever read from the config file (it is committed),
|
|
1763
|
+
* and there is deliberately no option that takes the key itself: a command line is recorded
|
|
1764
|
+
* in shell history and visible in process lists. The key is registered with the output's
|
|
1765
|
+
* redaction before anything else can print.
|
|
1766
|
+
*
|
|
1767
|
+
* The secret key decides the environment. When the config's entry says which `kind` it is
|
|
1768
|
+
* for, a key of the other kind (`tula_sk_dev_…` for `production`) is refused here, before any
|
|
1769
|
+
* request.
|
|
1770
|
+
*
|
|
1771
|
+
* @param input - The environment's name and entry, the command's flags, and the run's io.
|
|
1772
|
+
* @returns The API's URL and an admin client.
|
|
1773
|
+
* @throws UsageError when the URL or the key is missing, the key is of the wrong kind, the key
|
|
1774
|
+
* would be typed at a terminal, or the URL is plain http for another machine.
|
|
1775
|
+
* @throws TulaAdminError when the key is not a secret key or the URL is not usable.
|
|
1776
|
+
*
|
|
1777
|
+
* @example
|
|
1778
|
+
* ```ts
|
|
1779
|
+
* const target = await resolveTarget({ name: 'prod', environment, flags, io, output })
|
|
1780
|
+
* ```
|
|
1781
|
+
*/
|
|
1782
|
+
declare function resolveTarget(input: {
|
|
1783
|
+
name: string;
|
|
1784
|
+
/** The config's entry; only its `kind` is read. A command without a config passes `{}`. */
|
|
1785
|
+
environment: Pick<EnvironmentConfig2, "kind">;
|
|
1786
|
+
flags: Record<string, string | boolean | undefined>;
|
|
1787
|
+
io: CliIo;
|
|
1788
|
+
output: Output;
|
|
1789
|
+
}): Promise<Target>;
|
|
1790
|
+
/**
|
|
1791
|
+
* The agent's client of a run of `tula mcp`: the environment's **agent key**
|
|
1792
|
+
* (`--agent-key-file`, else `TULA_AGENT_KEY_<NAME>`, else `TULA_AGENT_KEY`) for the API a
|
|
1793
|
+
* target would use. An agent key reads what a secret key reads and asks for changes as
|
|
1794
|
+
* proposals; it changes nothing and approves nothing (ADR 0053). There is deliberately no
|
|
1795
|
+
* option that takes the key itself, and it is registered with the output's redaction before
|
|
1796
|
+
* anything can print.
|
|
1797
|
+
*
|
|
1798
|
+
* @param input - The environment's name, the command's flags and the run's io and output.
|
|
1799
|
+
* @returns The API's URL and the client, or `null` when no agent key is configured here.
|
|
1800
|
+
* @throws UsageError when the key cannot be read, is not an agent key, the URL is missing,
|
|
1801
|
+
* or the URL is plain http for another machine.
|
|
1802
|
+
*
|
|
1803
|
+
* @example
|
|
1804
|
+
* ```ts
|
|
1805
|
+
* const agent = await resolveAgent({ name: 'default', flags, io, output })
|
|
1806
|
+
* ```
|
|
1807
|
+
*/
|
|
1808
|
+
declare function resolveAgent(input: {
|
|
1809
|
+
name: string;
|
|
1810
|
+
flags: Record<string, string | boolean | undefined>;
|
|
1811
|
+
io: CliIo;
|
|
1812
|
+
output: Output;
|
|
1813
|
+
}): Promise<Target | null>;
|
|
1814
|
+
/**
|
|
1815
|
+
* The API URL of a command that needs no config file: `--api-url`, else `TULA_API_URL`.
|
|
1816
|
+
*
|
|
1817
|
+
* @param flags - The command's flags.
|
|
1818
|
+
* @param io - The run's surroundings.
|
|
1819
|
+
* @returns The URL, without a trailing slash.
|
|
1820
|
+
* @throws UsageError when there is none, or it is not an http(s) URL.
|
|
1821
|
+
*
|
|
1822
|
+
* @example
|
|
1823
|
+
* ```ts
|
|
1824
|
+
* const apiUrl = resolveApiUrl(flags, io)
|
|
1825
|
+
* ```
|
|
1826
|
+
*/
|
|
1827
|
+
declare function resolveApiUrl(flags: Record<string, string | boolean | undefined>, io: CliIo): string;
|
|
1828
|
+
/**
|
|
1829
|
+
* Read a secret from a file or standard input: the two ways a credential reaches the CLI
|
|
1830
|
+
* without being on the command line.
|
|
1831
|
+
*
|
|
1832
|
+
* @param path - The option's value: a path, or `-` for standard input.
|
|
1833
|
+
* @param option - The option's name, for the messages.
|
|
1834
|
+
* @param io - The run's surroundings.
|
|
1835
|
+
* @returns The secret, trimmed.
|
|
1836
|
+
* @throws UsageError when it cannot be read, or would be typed at a terminal.
|
|
1837
|
+
*
|
|
1838
|
+
* @example
|
|
1839
|
+
* ```ts
|
|
1840
|
+
* const token = await readSecretFile(String(flags['admin-token-file']), '--admin-token-file', io)
|
|
1841
|
+
* ```
|
|
1842
|
+
*/
|
|
1843
|
+
declare function readSecretFile(path: string, option: string, io: CliIo): Promise<string>;
|
|
1844
|
+
/**
|
|
1845
|
+
* The instance client of a run: the deployment's admin token (`--admin-token-file`, else
|
|
1846
|
+
* `TULA_ADMIN_TOKEN`) for the API at `apiUrl`. There is deliberately no option that takes the
|
|
1847
|
+
* token itself. The token is registered with the output's redaction before anything can print.
|
|
1848
|
+
*
|
|
1849
|
+
* @param input - The API URL, the command's flags and the run's io and output.
|
|
1850
|
+
* @returns The client, or `null` when no token is configured here.
|
|
1851
|
+
* @throws UsageError when the token cannot be read, is not usable, or the URL is plain http
|
|
1852
|
+
* for another machine.
|
|
1853
|
+
*
|
|
1854
|
+
* @example
|
|
1855
|
+
* ```ts
|
|
1856
|
+
* const instance = await resolveInstance({ apiUrl, flags, io, output })
|
|
1857
|
+
* ```
|
|
1858
|
+
*/
|
|
1859
|
+
declare function resolveInstance(input: {
|
|
1860
|
+
apiUrl: string;
|
|
1861
|
+
flags: Record<string, string | boolean | undefined>;
|
|
1862
|
+
io: CliIo;
|
|
1863
|
+
output: Output;
|
|
1864
|
+
}): Promise<InstanceClient2 | null>;
|
|
1865
|
+
/**
|
|
1866
|
+
* The CLI's version: `tula --version`, and the `User-Agent` of its requests. A test holds it
|
|
1867
|
+
* equal to the package's `version`.
|
|
1868
|
+
*
|
|
1869
|
+
* @example
|
|
1870
|
+
* ```ts
|
|
1871
|
+
* `tula-cli/${VERSION}` // 'tula-cli/0.1.1'
|
|
1872
|
+
* ```
|
|
1873
|
+
*/
|
|
1874
|
+
declare const VERSION = "0.1.1";
|
|
1875
|
+
/**
|
|
1876
|
+
* The commands of `tula`. A new command is added here.
|
|
1877
|
+
*
|
|
1878
|
+
* @example
|
|
1879
|
+
* ```ts
|
|
1880
|
+
* await runCli(['diff'], io, COMMANDS)
|
|
1881
|
+
* ```
|
|
1882
|
+
*/
|
|
1883
|
+
declare const COMMANDS: readonly Command[];
|
|
1884
|
+
/**
|
|
1885
|
+
* Run `tula` with the process's own surroundings: its streams, environment and directory.
|
|
1886
|
+
*
|
|
1887
|
+
* @param argv - The arguments after `tula`.
|
|
1888
|
+
* @param overrides - Parts of the surroundings to replace (a test's streams, an in-process
|
|
1889
|
+
* `fetch`).
|
|
1890
|
+
* @returns The exit code. The caller exits with it.
|
|
1891
|
+
*
|
|
1892
|
+
* @example
|
|
1893
|
+
* ```ts
|
|
1894
|
+
* process.exit(await main(process.argv.slice(2)))
|
|
1895
|
+
* ```
|
|
1896
|
+
*/
|
|
1897
|
+
declare function main(argv: readonly string[], overrides?: Partial<CliIo>): Promise<number>;
|
|
1898
|
+
export { COMMANDS, Change, CheckStatus, CliIo, Command, CommandContext, DEPENDENTS_UNREAD, DEV_ENV_FILE, DependentCount, DevOptions, DoctorCheck, DoctorReport, EXIT, Host, KnownDependents, MANAGING_TOOL, MarkerPlan, Operation, OptionSpec, Output, ParsedArgs, Plan, PlanOptions, ProviderChange, RemoteDependents, RemoteProvider, RemoteState, RunOptions, RunResult, SET_PATHS, Sink, Styles, Target, UsageError, VERSION, applyCommand, applyRequirements, approveCommand, buildMcpServer, buildPlan, createOutput, describeOperation, devCommand, diffCommand, diffValues, doctorCommand, examine, exitCode, main, mcpCommand, orderOperations, parseArgs, parseDevBlock, planProviders, planToJson, planWarnings, policyCommand, printable, providersGoingOff, readDependentCount, readSecretFile, renderPlan, renderReport, reportError, reportToJson, resolveAgent, resolveApiUrl, resolveInstance, resolveTarget, runCli, shouldUseColor, startDev, stopDev, withDevBlock, withoutDevBlock };
|