@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.
@@ -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 };