@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,4437 @@
1
+ // @bun
2
+ // src/commands/apply.ts
3
+ import { basename, resolve } from "path";
4
+ import { ifMatch, isTulaAdminError as isTulaAdminError5 } from "@tula-tech/admin";
5
+ import { providerSecret as providerSecret2, resolveSecret } from "@tula-tech/config";
6
+ import { MAX_WEBHOOK_URL_LENGTH as MAX_WEBHOOK_URL_LENGTH2 } from "@tula-tech/contract";
7
+ import { CONFIG_HASH_HEADER, CONFIG_MANAGED_BY_HEADER } from "@tula-tech/contract/headers";
8
+
9
+ // src/args.ts
10
+ class UsageError2 extends Error {
11
+ constructor(message) {
12
+ super(message);
13
+ this.name = "UsageError";
14
+ }
15
+ }
16
+ var KEY_ADVICE = "Set TULA_SECRET_KEY, or pass --secret-key-file <path> (- for standard input).";
17
+ var SECRET_FLAGS = new Map([
18
+ ["secret-key", KEY_ADVICE],
19
+ ["key", KEY_ADVICE],
20
+ ["secret", KEY_ADVICE],
21
+ ["token", KEY_ADVICE],
22
+ [
23
+ "admin-token",
24
+ "Set TULA_ADMIN_TOKEN, or pass --admin-token-file <path> (- for standard input)."
25
+ ],
26
+ [
27
+ "password",
28
+ 'Run `tula policy test` and type it at the prompt, or pipe it in: `printf %s "$PW" | tula policy test`.'
29
+ ]
30
+ ]);
31
+ function parseArgs2(argv, options) {
32
+ const flags = {};
33
+ const positionals = [];
34
+ const byShort = new Map(Object.entries(options).flatMap(([name, spec]) => spec.short ? [[spec.short, name]] : []));
35
+ for (let index = 0;index < argv.length; index += 1) {
36
+ const argument = argv[index];
37
+ if (argument === "--") {
38
+ positionals.push(...argv.slice(index + 1));
39
+ break;
40
+ }
41
+ if (!argument.startsWith("-") || argument === "-") {
42
+ positionals.push(argument);
43
+ continue;
44
+ }
45
+ const long = argument.startsWith("--");
46
+ const equals = long ? argument.indexOf("=") : -1;
47
+ const given = long ? argument.slice(2, equals === -1 ? undefined : equals) : argument.slice(1);
48
+ const name = long ? given : byShort.get(given);
49
+ const spec = name !== undefined && Object.hasOwn(options, name) ? options[name] : undefined;
50
+ if (name === undefined || !spec) {
51
+ const advice = SECRET_FLAGS.get(given);
52
+ if (advice !== undefined) {
53
+ throw new UsageError2(`There is no --${given} option: a secret on the command line ends up in shell history ` + `and process lists. ${advice}`);
54
+ }
55
+ throw new UsageError2(`Unknown option ${long ? "--" : "-"}${given.slice(0, 40)}.`);
56
+ }
57
+ if (spec.type === "boolean") {
58
+ if (equals !== -1) {
59
+ throw new UsageError2(`--${name} takes no value.`);
60
+ }
61
+ flags[name] = true;
62
+ continue;
63
+ }
64
+ const value = equals === -1 ? argv[index + 1] : argument.slice(equals + 1);
65
+ if (value === undefined || equals === -1 && value.startsWith("--")) {
66
+ throw new UsageError2(`--${name} needs a value.`);
67
+ }
68
+ flags[name] = value;
69
+ if (equals === -1) {
70
+ index += 1;
71
+ }
72
+ }
73
+ return { flags, positionals };
74
+ }
75
+
76
+ // src/diff.ts
77
+ import { providerSecret } from "@tula-tech/config";
78
+ import {
79
+ HOOK_FIELDS,
80
+ HOOK_POINTS,
81
+ hasEnabledSignInMethod,
82
+ hookWeakenings,
83
+ isEmailTemplateKind,
84
+ isSmsTemplateKind,
85
+ MAX_NATIVE_APPS,
86
+ MAX_WEBHOOK_ENDPOINTS,
87
+ NATIVE_APP_PLATFORMS,
88
+ nativeAppIdentifier,
89
+ nativeAppWeakenings,
90
+ normalizeAppLinkPaths,
91
+ normalizeCertFingerprints,
92
+ OAUTH_PROVIDERS,
93
+ oauthProviderWeakenings,
94
+ parseStoredEnvironmentSettings,
95
+ settingsWeakenings
96
+ } from "@tula-tech/contract";
97
+ var MANAGING_TOOL2 = "tula-apply";
98
+ var EMAIL_TEMPLATES_PATH = "emails.templates";
99
+ var SMS_TEMPLATES_PATH = "sms.templates";
100
+ function isTemplatePath(path) {
101
+ return path.startsWith(`${EMAIL_TEMPLATES_PATH}.`) || path.startsWith(`${SMS_TEMPLATES_PATH}.`);
102
+ }
103
+ var SET_PATHS2 = [
104
+ "urls.allowedOrigins",
105
+ "urls.allowedRedirectUrls",
106
+ "sms.allowedCountries"
107
+ ];
108
+ var NAMED_ENTRY_PATHS = [
109
+ "sessions.profiles",
110
+ "sessions.jwtTemplates",
111
+ EMAIL_TEMPLATES_PATH,
112
+ SMS_TEMPLATES_PATH
113
+ ];
114
+ var WHOLE_VALUE = /^sessions\.jwtTemplates\.[^.]+\.claims\.[^.]+$/;
115
+ var DEPLOYMENT_DEFAULTS = ["password", "urls.allowedOrigins"];
116
+ var TEMPLATE_PATH = /^(?:emails|sms)\.templates\.[^.]+$/;
117
+ function isPlainObject(value) {
118
+ return typeof value === "object" && value !== null && !Array.isArray(value);
119
+ }
120
+ function same(a, b) {
121
+ return JSON.stringify(a) === JSON.stringify(b);
122
+ }
123
+ function diffSets(path, before, after) {
124
+ const key = (value) => JSON.stringify(value);
125
+ const had = new Set(before.map(key));
126
+ const has = new Set(after.map(key));
127
+ const added = after.filter((value, index) => !had.has(key(value)) && after.findIndex((other) => key(other) === key(value)) === index);
128
+ const removed = before.filter((value, index) => !has.has(key(value)) && before.findIndex((other) => key(other) === key(value)) === index);
129
+ return added.length === 0 && removed.length === 0 ? [] : [{ path, kind: "changed", before, after, added, removed }];
130
+ }
131
+ function diffAt(path, before, after, setPaths) {
132
+ if (before === undefined && after === undefined) {
133
+ return [];
134
+ }
135
+ if (TEMPLATE_PATH.test(path) && isPlainObject(before ?? after)) {
136
+ const [had, has] = [before ?? {}, after ?? {}];
137
+ if (isPlainObject(had) && isPlainObject(has)) {
138
+ const keys = [...new Set([...Object.keys(has), ...Object.keys(had)])];
139
+ return keys.flatMap((key) => diffAt(`${path}.${key}`, had[key], has[key], setPaths));
140
+ }
141
+ }
142
+ if (before === undefined) {
143
+ return [{ path, kind: "added", after }];
144
+ }
145
+ if (after === undefined) {
146
+ return [{ path, kind: "removed", before }];
147
+ }
148
+ if (isPlainObject(before) && isPlainObject(after) && !WHOLE_VALUE.test(path)) {
149
+ const keys = [...new Set([...Object.keys(after), ...Object.keys(before)])];
150
+ return keys.flatMap((key) => diffAt(path === "" ? key : `${path}.${key}`, before[key], after[key], setPaths));
151
+ }
152
+ if (Array.isArray(before) && Array.isArray(after) && setPaths.includes(path)) {
153
+ return diffSets(path, before, after);
154
+ }
155
+ return same(before, after) ? [] : [{ path, kind: "changed", before, after }];
156
+ }
157
+ function diffValues2(before, after, setPaths = SET_PATHS2) {
158
+ return diffAt("", before, after, setPaths);
159
+ }
160
+ function nativeAppSnapshot(apps) {
161
+ return JSON.stringify(apps.map((app) => {
162
+ const { id, platform } = app;
163
+ const loose = app;
164
+ const fingerprints = app.sha256CertFingerprints;
165
+ const paths = app.appLinkPaths;
166
+ return [
167
+ id,
168
+ platform,
169
+ loose.bundleId ?? loose.packageName ?? null,
170
+ loose.teamId ?? null,
171
+ Array.isArray(fingerprints) ? [...fingerprints].map(String).sort() : null,
172
+ Array.isArray(paths) ? [...paths].map(String).sort() : []
173
+ ];
174
+ }).sort((a, b) => String(a[0]).localeCompare(String(b[0]))));
175
+ }
176
+ function nativeAppKey(platform, identifier) {
177
+ return `${platform}/${identifier}`;
178
+ }
179
+ function identityOf(app) {
180
+ if (app.platform === "ios") {
181
+ return {
182
+ platform: "ios",
183
+ teamId: app.teamId,
184
+ bundleId: app.bundleId,
185
+ appLinkPaths: remotePaths(app)
186
+ };
187
+ }
188
+ if (app.platform === "android") {
189
+ return {
190
+ platform: "android",
191
+ packageName: app.packageName,
192
+ sha256CertFingerprints: app.sha256CertFingerprints,
193
+ appLinkPaths: remotePaths(app)
194
+ };
195
+ }
196
+ return null;
197
+ }
198
+ function remotePaths(app) {
199
+ const paths = app.appLinkPaths;
200
+ return Array.isArray(paths) ? paths.map(String) : [];
201
+ }
202
+ function appLinkPathFields(current, entry) {
203
+ if (entry.appLinkPaths === undefined) {
204
+ return [];
205
+ }
206
+ const after = normalizeAppLinkPaths(entry.appLinkPaths);
207
+ if (!current) {
208
+ return after.length === 0 ? [] : [{ path: "appLinkPaths", kind: "added", after }];
209
+ }
210
+ const before = [...new Set(current.appLinkPaths ?? [])].sort();
211
+ const added = after.filter((path) => !before.includes(path));
212
+ const removed = before.filter((path) => !after.includes(path));
213
+ return added.length === 0 && removed.length === 0 ? [] : [{ path: "appLinkPaths", kind: "changed", before, after, added, removed }];
214
+ }
215
+ function unmanagedLinkPaths(current, entry) {
216
+ if (entry.appLinkPaths !== undefined || current?.platform !== entry.platform) {
217
+ return 0;
218
+ }
219
+ return new Set(current.appLinkPaths ?? []).size;
220
+ }
221
+ function nativeAppFields(current, entry) {
222
+ const sameApp = current?.platform === entry.platform ? current : null;
223
+ return [...identityFields(current, entry), ...appLinkPathFields(sameApp, entry)];
224
+ }
225
+ function identityFields(current, entry) {
226
+ if (entry.platform === "ios") {
227
+ if (current?.platform !== "ios") {
228
+ return [{ path: "teamId", kind: "added", after: entry.teamId }];
229
+ }
230
+ return current.teamId === entry.teamId ? [] : [{ path: "teamId", kind: "changed", before: current.teamId, after: entry.teamId }];
231
+ }
232
+ const after = normalizeCertFingerprints(entry.sha256CertFingerprints);
233
+ if (current?.platform !== "android") {
234
+ return [{ path: "sha256CertFingerprints", kind: "added", after }];
235
+ }
236
+ const before = normalizeCertFingerprints(current.sha256CertFingerprints);
237
+ const added = after.filter((fingerprint) => !before.includes(fingerprint));
238
+ const removed = before.filter((fingerprint) => !after.includes(fingerprint));
239
+ return added.length === 0 && removed.length === 0 ? [] : [{ path: "sha256CertFingerprints", kind: "changed", before, after, added, removed }];
240
+ }
241
+ function planNativeApps(remote, desired, options) {
242
+ if (desired === undefined) {
243
+ return { managed: false, apps: [], overLimit: null, seen: nativeAppSnapshot([]) };
244
+ }
245
+ const existing = remote ?? [];
246
+ const known = new Set(NATIVE_APP_PLATFORMS);
247
+ const byKey = new Map;
248
+ for (const app of existing) {
249
+ const identity = identityOf(app);
250
+ if (identity) {
251
+ byKey.set(nativeAppKey(identity.platform, nativeAppIdentifier(identity)), app);
252
+ }
253
+ }
254
+ const listed = new Set;
255
+ const apps = desired.map((entry) => {
256
+ const identifier = nativeAppIdentifier(entry);
257
+ const key = nativeAppKey(entry.platform, identifier);
258
+ listed.add(key);
259
+ const current = byKey.get(key);
260
+ const was = current ? identityOf(current) : null;
261
+ const fields = nativeAppFields(was, entry);
262
+ const weakened = nativeAppWeakenings(was, entry).map((field) => field === "app" ? `nativeApps.${key}` : `nativeApps.${key}.${field}`);
263
+ if (!current) {
264
+ return { platform: entry.platform, identifier, action: "create", fields, weakened, entry };
265
+ }
266
+ const unmanaged = unmanagedLinkPaths(was, entry);
267
+ return {
268
+ platform: entry.platform,
269
+ identifier,
270
+ id: current.id,
271
+ action: fields.length > 0 ? "update" : "none",
272
+ fields,
273
+ weakened,
274
+ entry,
275
+ ...unmanaged > 0 && { unmanagedLinkPaths: unmanaged }
276
+ };
277
+ });
278
+ for (const app of existing) {
279
+ const identity = identityOf(app);
280
+ if (!identity || !known.has(app.platform)) {
281
+ const loose = app;
282
+ apps.push({
283
+ platform: String(loose.platform),
284
+ identifier: String(loose.bundleId ?? loose.packageName ?? ""),
285
+ id: app.id,
286
+ action: "unknown",
287
+ fields: [],
288
+ weakened: []
289
+ });
290
+ continue;
291
+ }
292
+ const identifier = nativeAppIdentifier(identity);
293
+ if (!listed.has(nativeAppKey(identity.platform, identifier))) {
294
+ apps.push({
295
+ platform: identity.platform,
296
+ identifier,
297
+ id: app.id,
298
+ action: options.prune === true ? "delete" : "unmanaged",
299
+ fields: [],
300
+ weakened: []
301
+ });
302
+ }
303
+ }
304
+ const count = (action) => apps.filter((app) => app.action === action).length;
305
+ const after = existing.length + count("create") - count("delete");
306
+ return {
307
+ managed: true,
308
+ apps,
309
+ overLimit: count("create") > 0 && after > MAX_NATIVE_APPS ? after : null,
310
+ seen: nativeAppSnapshot(existing)
311
+ };
312
+ }
313
+ function hookSnapshot(hooks) {
314
+ return JSON.stringify(hooks.map((hook) => [
315
+ hook.id,
316
+ hook.point,
317
+ hook.url,
318
+ hook.enabled,
319
+ hook.deadlineMs,
320
+ hook.failureMode
321
+ ]).sort((a, b) => String(a[0]).localeCompare(String(b[0]))));
322
+ }
323
+ function strengthOf(hook) {
324
+ return { enabled: hook.enabled, failureMode: hook.failureMode === "allow" ? "allow" : "deny" };
325
+ }
326
+ function planHooks(remote, desired, options) {
327
+ if (desired === undefined) {
328
+ return { managed: false, hooks: [], seen: hookSnapshot([]) };
329
+ }
330
+ const existing = remote ?? [];
331
+ const known = new Set(HOOK_POINTS);
332
+ const hooks = [];
333
+ for (const point of HOOK_POINTS) {
334
+ const current = existing.find((hook) => hook.point === point);
335
+ const entry = Object.hasOwn(desired, point) ? desired[point] : undefined;
336
+ if (!entry) {
337
+ if (current) {
338
+ const removed = options.prune === true;
339
+ hooks.push({
340
+ point,
341
+ id: current.id,
342
+ url: current.url,
343
+ action: removed ? "delete" : "unmanaged",
344
+ fields: [],
345
+ weakened: removed && hookWeakenings(strengthOf(current), null).length > 0 ? [`hooks.${point}`] : []
346
+ });
347
+ }
348
+ continue;
349
+ }
350
+ const weakened = hookWeakenings(current ? strengthOf(current) : null, entry).map((field) => `hooks.${point}.${field}`);
351
+ if (!current) {
352
+ hooks.push({
353
+ point,
354
+ url: entry.url,
355
+ action: "create",
356
+ fields: HOOK_FIELDS.map((path) => ({ path, kind: "added", after: entry[path] })),
357
+ weakened
358
+ });
359
+ continue;
360
+ }
361
+ const fields = HOOK_FIELDS.flatMap((path) => current[path] === entry[path] ? [] : [{ path, kind: "changed", before: current[path], after: entry[path] }]);
362
+ hooks.push({
363
+ point,
364
+ id: current.id,
365
+ url: entry.url,
366
+ action: fields.length > 0 ? "update" : "none",
367
+ fields,
368
+ weakened
369
+ });
370
+ }
371
+ for (const hook of existing) {
372
+ if (!known.has(hook.point)) {
373
+ hooks.push({
374
+ point: hook.point,
375
+ id: hook.id,
376
+ url: hook.url,
377
+ action: "unknown",
378
+ fields: [],
379
+ weakened: []
380
+ });
381
+ }
382
+ }
383
+ return { managed: true, hooks, seen: hookSnapshot(existing) };
384
+ }
385
+ function webhookSnapshot(endpoints) {
386
+ return JSON.stringify(endpoints.map((endpoint) => [
387
+ endpoint.id,
388
+ endpoint.url,
389
+ [...new Set(endpoint.eventTypes)].sort(),
390
+ endpoint.enabled,
391
+ endpoint.disabledReason
392
+ ]).sort((a, b) => String(a[0]).localeCompare(String(b[0]))));
393
+ }
394
+ function planEndpoint(desired, matches) {
395
+ const { url } = desired;
396
+ const types = [...desired.eventTypes].sort();
397
+ if (matches.length > 1) {
398
+ return { url, action: "ambiguous", fields: [], duplicates: matches.map((match) => match.id) };
399
+ }
400
+ const current = matches[0];
401
+ if (!current) {
402
+ const fields = [{ path: "eventTypes", kind: "added", after: types }];
403
+ if (desired.enabled !== undefined) {
404
+ fields.push({ path: "enabled", kind: "added", after: desired.enabled });
405
+ }
406
+ return { url, action: "create", fields };
407
+ }
408
+ const fields = diffSets("eventTypes", [...new Set(current.eventTypes)].sort(), types).map((change) => ({
409
+ ...change,
410
+ added: [...change.added ?? []].sort(),
411
+ removed: [...change.removed ?? []].sort()
412
+ }));
413
+ const switched = desired.enabled !== undefined && desired.enabled !== current.enabled;
414
+ if (switched) {
415
+ fields.push({
416
+ path: "enabled",
417
+ kind: "changed",
418
+ before: current.enabled,
419
+ after: desired.enabled
420
+ });
421
+ }
422
+ const reenables = switched && desired.enabled === true && typeof current.disabledReason === "string" ? current.disabledReason : undefined;
423
+ return {
424
+ url,
425
+ id: current.id,
426
+ action: fields.length > 0 ? "update" : "none",
427
+ fields,
428
+ ...reenables !== undefined && { reenables }
429
+ };
430
+ }
431
+ function planWebhooks(remote, desired, options) {
432
+ if (desired === undefined) {
433
+ return {
434
+ managed: false,
435
+ endpoints: [],
436
+ removedFirst: 0,
437
+ overLimit: null,
438
+ seen: webhookSnapshot([])
439
+ };
440
+ }
441
+ const existing = remote ?? [];
442
+ const listed = new Set(desired.map((endpoint) => endpoint.url));
443
+ const endpoints = [
444
+ ...desired.map((endpoint) => planEndpoint(endpoint, existing.filter((candidate) => candidate.url === endpoint.url))),
445
+ ...existing.filter((endpoint) => !listed.has(endpoint.url)).map((endpoint) => ({
446
+ url: endpoint.url,
447
+ id: endpoint.id,
448
+ action: options.prune ? "delete" : "unmanaged",
449
+ fields: []
450
+ }))
451
+ ];
452
+ const count = (action) => endpoints.filter((endpoint) => endpoint.action === action).length;
453
+ const creates = count("create");
454
+ const after = existing.length + creates - count("delete");
455
+ const overLimit = after > MAX_WEBHOOK_ENDPOINTS ? after : null;
456
+ return {
457
+ managed: true,
458
+ endpoints,
459
+ removedFirst: overLimit === null ? Math.max(0, existing.length + creates - MAX_WEBHOOK_ENDPOINTS) : 0,
460
+ overLimit,
461
+ seen: webhookSnapshot(existing)
462
+ };
463
+ }
464
+ var DEPENDENTS_UNREAD2 = "unread";
465
+ function readDependentCount2(provider, answer) {
466
+ if (typeof answer !== "object" || answer === null) {
467
+ return null;
468
+ }
469
+ const { provider: named, accounts, more } = answer;
470
+ if (named !== provider || typeof accounts !== "number" || !Number.isSafeInteger(accounts) || accounts < 0 || typeof more !== "boolean") {
471
+ return null;
472
+ }
473
+ return { accounts, more };
474
+ }
475
+ var KEEPS_SECRET = new Set(["enabled", "tenant", "additionalClientIds"]);
476
+ var CLIENT_IDS = "additionalClientIds";
477
+ var UNREAD_AS_A_COUNT = { accounts: 0, more: true };
478
+ function countToJudge(known) {
479
+ return known === DEPENDENTS_UNREAD2 ? UNREAD_AS_A_COUNT : known ?? undefined;
480
+ }
481
+ function providerWeakenings(provider, before, fields) {
482
+ const clientIds = fields?.[CLIENT_IDS];
483
+ const dependents = countToJudge(before?.dependents);
484
+ const weakened = oauthProviderWeakenings(before && {
485
+ additionalClientIds: before.additionalClientIds,
486
+ enabled: before.enabled,
487
+ ...dependents && { dependents }
488
+ }, fields && {
489
+ ...Array.isArray(clientIds) && { additionalClientIds: clientIds },
490
+ enabled: fields.enabled === true
491
+ });
492
+ return weakened.map((field) => fields === null ? `providers.${provider}` : `providers.${provider}.${field}`);
493
+ }
494
+ function providerFields(provider, providers) {
495
+ if (provider === "apple") {
496
+ const apple = providers.apple;
497
+ return apple && {
498
+ clientId: apple.clientId,
499
+ teamId: apple.teamId,
500
+ keyId: apple.keyId,
501
+ enabled: apple.enabled
502
+ };
503
+ }
504
+ if (provider === "microsoft") {
505
+ const microsoft = providers.microsoft;
506
+ return microsoft && {
507
+ clientId: microsoft.clientId,
508
+ tenant: microsoft.tenant,
509
+ enabled: microsoft.enabled
510
+ };
511
+ }
512
+ if (provider === "google") {
513
+ const google = providers.google;
514
+ return google && {
515
+ clientId: google.clientId,
516
+ [CLIENT_IDS]: google.additionalClientIds ?? [],
517
+ enabled: google.enabled
518
+ };
519
+ }
520
+ const client = providers[provider];
521
+ return client && { clientId: client.clientId, enabled: client.enabled };
522
+ }
523
+ function clientIdsOf(current) {
524
+ const listed = current?.additionalClientIds;
525
+ return Array.isArray(listed) ? listed.filter((id) => typeof id === "string").sort() : [];
526
+ }
527
+ function planProviders2(remote, desired, options, dependents = {}) {
528
+ const countOf = (provider, goesOff) => goesOff && Object.hasOwn(dependents, provider) ? { dependents: dependents[provider] } : {};
529
+ const plans = [];
530
+ for (const provider of [...OAUTH_PROVIDERS].sort()) {
531
+ const current = remote.find((entry) => entry.provider === provider);
532
+ const configured = current?.configured === true;
533
+ const enabledBefore = configured && current.enabled;
534
+ const fields = providerFields(provider, desired);
535
+ if (!fields) {
536
+ if (configured) {
537
+ plans.push({
538
+ provider,
539
+ action: options.prune ? "delete" : "unmanaged",
540
+ fields: [],
541
+ secret: "none",
542
+ enabledBefore,
543
+ enabledAfter: options.prune ? false : enabledBefore,
544
+ weakened: options.prune ? providerWeakenings(provider, {
545
+ additionalClientIds: clientIdsOf(current),
546
+ enabled: enabledBefore,
547
+ ...countOf(provider, enabledBefore)
548
+ }, null) : [],
549
+ ...countOf(provider, options.prune === true && enabledBefore)
550
+ });
551
+ }
552
+ continue;
553
+ }
554
+ const secretEnv = providerSecret(desired, provider)?.$env;
555
+ const enabledAfter = fields.enabled === true;
556
+ if (!configured) {
557
+ plans.push({
558
+ provider,
559
+ action: "create",
560
+ fields: Object.entries(fields).filter(([path, after]) => path !== CLIENT_IDS || after.length > 0).map(([path, after]) => ({ path, kind: "added", after })),
561
+ secret: "set",
562
+ secretEnv,
563
+ enabledBefore,
564
+ enabledAfter,
565
+ weakened: providerWeakenings(provider, null, fields)
566
+ });
567
+ continue;
568
+ }
569
+ const changes = Object.entries(fields).flatMap(([path, after]) => {
570
+ if (path === CLIENT_IDS) {
571
+ return diffSets(path, clientIdsOf(current), after);
572
+ }
573
+ const before = current[path];
574
+ return same(before, after) ? [] : [{ path, kind: "changed", before, after }];
575
+ });
576
+ const identityChanged = changes.some((change) => !KEEPS_SECRET.has(change.path));
577
+ const secret = identityChanged || options.rotateSecrets ? "set" : "keep";
578
+ plans.push({
579
+ provider,
580
+ action: changes.length > 0 || secret === "set" ? "update" : "none",
581
+ fields: changes,
582
+ secret,
583
+ secretEnv,
584
+ enabledBefore,
585
+ enabledAfter,
586
+ weakened: providerWeakenings(provider, {
587
+ additionalClientIds: clientIdsOf(current),
588
+ enabled: enabledBefore,
589
+ ...countOf(provider, enabledBefore && !enabledAfter)
590
+ }, fields),
591
+ ...countOf(provider, enabledBefore && !enabledAfter)
592
+ });
593
+ }
594
+ return plans;
595
+ }
596
+ function providersGoingOff2(remote, desired, options) {
597
+ return planProviders2(remote, desired, options).filter((change) => change.enabledBefore && !change.enabledAfter).map((change) => change.provider);
598
+ }
599
+ function planMarker(remote, configHash) {
600
+ const current = remote.managedBy;
601
+ if (current === undefined) {
602
+ return { supported: false, pending: false, reason: "none", current: null, configHash };
603
+ }
604
+ const reason = current === null ? "unmanaged" : current.tool !== MANAGING_TOOL2 ? "other-tool" : current.configHash !== configHash ? "other-config" : current.drifted ? "drifted" : "none";
605
+ return { supported: true, pending: reason !== "none", reason, current, configHash };
606
+ }
607
+ function unknownTemplateKind(path) {
608
+ for (const [prefix, known] of [
609
+ [EMAIL_TEMPLATES_PATH, isEmailTemplateKind],
610
+ [SMS_TEMPLATES_PATH, isSmsTemplateKind]
611
+ ]) {
612
+ if (path.startsWith(`${prefix}.`)) {
613
+ const [kind = ""] = path.slice(prefix.length + 1).split(".");
614
+ return !known(kind);
615
+ }
616
+ }
617
+ return false;
618
+ }
619
+ function weakenings(before, after) {
620
+ try {
621
+ return settingsWeakenings(parseStoredEnvironmentSettings(before), parseStoredEnvironmentSettings(after));
622
+ } catch {
623
+ return [];
624
+ }
625
+ }
626
+ function buildPlan2(remote, environment, options) {
627
+ const kept = [];
628
+ const body = structuredClone(environment.settings);
629
+ if (body.password === undefined) {
630
+ body.password = structuredClone(remote.settings.password);
631
+ kept.push(DEPLOYMENT_DEFAULTS[0]);
632
+ }
633
+ if (body.urls.allowedOrigins === undefined) {
634
+ body.urls.allowedOrigins = [...remote.settings.urls.allowedOrigins];
635
+ kept.push(DEPLOYMENT_DEFAULTS[1]);
636
+ }
637
+ const settings = diffValues2(remote.settings, body);
638
+ const unknown = settings.filter((change) => change.kind === "removed" && (!NAMED_ENTRY_PATHS.some((prefix) => change.path.startsWith(`${prefix}.`)) || unknownTemplateKind(change.path))).map((change) => change.path);
639
+ const providers = planProviders2(remote.providers, environment.providers, options, remote.dependents);
640
+ const webhooks = planWebhooks(remote.webhooks, environment.webhooks, options);
641
+ const hooks = planHooks(remote.hooks, environment.hooks, options);
642
+ const nativeApps = planNativeApps(remote.nativeApps, environment.nativeApps, options);
643
+ const marker = planMarker(remote, options.configHash);
644
+ return {
645
+ revision: remote.revision,
646
+ settings,
647
+ weakened: [
648
+ ...weakenings(remote.settings, body),
649
+ ...providers.flatMap((provider) => provider.weakened),
650
+ ...hooks.hooks.flatMap((hook) => hook.weakened),
651
+ ...nativeApps.apps.flatMap((app) => app.weakened)
652
+ ],
653
+ kept,
654
+ unknown,
655
+ providers,
656
+ webhooks,
657
+ hooks,
658
+ nativeApps,
659
+ marker,
660
+ body,
661
+ changes: settings.length > 0 || marker.pending || providers.some((provider) => ["create", "update", "delete"].includes(provider.action)) || webhooks.endpoints.some((endpoint) => ["create", "update", "delete", "ambiguous"].includes(endpoint.action)) || hooks.hooks.some((hook) => ["create", "update", "delete"].includes(hook.action)) || nativeApps.apps.some((app) => ["create", "update", "delete"].includes(app.action))
662
+ };
663
+ }
664
+ function orderOperations2(plan) {
665
+ const settings = plan.settings.length > 0 || plan.marker.pending ? [{ kind: "settings" }] : [];
666
+ const writes = plan.providers.filter((change) => change.action === "create" || change.action === "update");
667
+ const takesAway = (change) => change.enabledBefore && !change.enabledAfter;
668
+ const set = (change) => ({
669
+ kind: "provider.set",
670
+ provider: change.provider,
671
+ change
672
+ });
673
+ const adding = writes.filter((change) => !takesAway(change)).map(set);
674
+ const removing = writes.filter(takesAway).map(set);
675
+ const deleting = plan.providers.filter((change) => change.action === "delete").map((change) => ({ kind: "provider.delete", provider: change.provider, change }));
676
+ const signIn = hasEnabledSignInMethod(plan.body) ? [...settings, ...adding, ...removing, ...deleting] : [...adding, ...settings, ...removing, ...deleting];
677
+ return [
678
+ ...signIn,
679
+ ...orderWebhooks(plan.webhooks),
680
+ ...orderHooks(plan.hooks),
681
+ ...orderNativeApps(plan.nativeApps)
682
+ ];
683
+ }
684
+ function orderNativeApps(nativeApps) {
685
+ const removals = [];
686
+ const narrowing = [];
687
+ const widening = [];
688
+ const creates = [];
689
+ for (const change of nativeApps.apps) {
690
+ const { id, entry } = change;
691
+ if (change.action === "create" && entry) {
692
+ creates.push({ kind: "nativeApp.create", entry, change });
693
+ } else if (id !== undefined && change.action === "update" && entry) {
694
+ const bucket = change.weakened.length > 0 ? widening : narrowing;
695
+ bucket.push({ kind: "nativeApp.update", id, entry, change });
696
+ } else if (id !== undefined && change.action === "delete") {
697
+ removals.push({ kind: "nativeApp.delete", id, change });
698
+ }
699
+ }
700
+ return [...removals, ...narrowing, ...widening, ...creates];
701
+ }
702
+ function isHookPoint(point) {
703
+ return HOOK_POINTS.includes(point);
704
+ }
705
+ function orderHooks(hooks) {
706
+ const creates = [];
707
+ const tightening = [];
708
+ const loosening = [];
709
+ const removals = [];
710
+ for (const change of hooks.hooks) {
711
+ const { point, id } = change;
712
+ if (!isHookPoint(point)) {
713
+ continue;
714
+ }
715
+ if (change.action === "create") {
716
+ creates.push({ kind: "hook.create", point, change });
717
+ } else if (id !== undefined && change.action === "update") {
718
+ const bucket = change.weakened.length > 0 ? loosening : tightening;
719
+ bucket.push({ kind: "hook.update", point, id, change });
720
+ } else if (id !== undefined && change.action === "delete") {
721
+ removals.push({ kind: "hook.delete", point, id, change });
722
+ }
723
+ }
724
+ return [...creates, ...tightening, ...loosening, ...removals];
725
+ }
726
+ function orderWebhooks(webhooks) {
727
+ const updates = [];
728
+ const creates = [];
729
+ const removals = [];
730
+ for (const change of webhooks.endpoints) {
731
+ if (change.action === "create") {
732
+ creates.push({ kind: "webhook.create", url: change.url, change });
733
+ } else if (change.id !== undefined && change.action === "update") {
734
+ updates.push({ kind: "webhook.update", url: change.url, id: change.id, change });
735
+ } else if (change.id !== undefined && change.action === "delete") {
736
+ removals.push({ kind: "webhook.delete", url: change.url, id: change.id, change });
737
+ }
738
+ }
739
+ return [
740
+ ...updates,
741
+ ...removals.slice(0, webhooks.removedFirst),
742
+ ...creates,
743
+ ...removals.slice(webhooks.removedFirst)
744
+ ];
745
+ }
746
+
747
+ // src/doctor.ts
748
+ import { isTulaAdminError } from "@tula-tech/admin";
749
+
750
+ // src/version.ts
751
+ var VERSION2 = "0.1.1";
752
+
753
+ // src/doctor.ts
754
+ var SKEW_WARN_MS = 5000;
755
+ var SKEW_FAIL_MS = 30000;
756
+ var STATUS_TIMEOUT_MS = 1e4;
757
+ var STATUSES = new Set(["ok", "warn", "fail", "skipped"]);
758
+ var CONTROLS = /[\u{0}-\u{1F}\u{7F}-\u{9F}\p{Zl}\p{Zp}]/gu;
759
+ var INVISIBLE = /[\p{Cf}\p{Co}\p{Cn}\p{Cs}]/gu;
760
+ function printable2(value, max = 600) {
761
+ const text = typeof value === "string" ? value : "";
762
+ return text.replace(CONTROLS, " ").replace(INVISIBLE, "").slice(0, max).replace(INVISIBLE, "");
763
+ }
764
+ function isRecord(value) {
765
+ return typeof value === "object" && value !== null && !Array.isArray(value);
766
+ }
767
+ function serverChecks(data) {
768
+ const checks = isRecord(data) && Array.isArray(data.checks) ? data.checks : [];
769
+ return checks.filter(isRecord).map((check) => ({
770
+ id: printable2(check.id, 60) || "unknown",
771
+ source: "server",
772
+ status: typeof check.status === "string" && STATUSES.has(check.status) ? check.status : "fail",
773
+ summary: printable2(check.summary),
774
+ ...typeof check.fix === "string" ? { fix: printable2(check.fix) } : {},
775
+ ...Array.isArray(check.values) ? { values: check.values.slice(0, 50).map((value) => printable2(value, 300)) } : {}
776
+ }));
777
+ }
778
+ async function status(io, url, signal) {
779
+ const send = io.fetch ?? ((target, init) => fetch(target, init));
780
+ try {
781
+ const response = await send(`${url}/v1/status`, {
782
+ method: "GET",
783
+ headers: { accept: "application/json", "user-agent": `tula-cli/${VERSION2}` },
784
+ redirect: "manual",
785
+ signal: signal ? AbortSignal.any([signal, AbortSignal.timeout(STATUS_TIMEOUT_MS)]) : AbortSignal.timeout(STATUS_TIMEOUT_MS)
786
+ });
787
+ const body = await response.json().catch(() => null);
788
+ const version = isRecord(body) && typeof body.version === "string" ? body.version : undefined;
789
+ return { status: response.status, version: version && printable2(version, 40) };
790
+ } catch {
791
+ return null;
792
+ }
793
+ }
794
+ function parseUrl(text) {
795
+ try {
796
+ return new URL(text);
797
+ } catch {
798
+ return null;
799
+ }
800
+ }
801
+ function isLoopback(url) {
802
+ try {
803
+ const host = new URL(url).hostname.toLowerCase();
804
+ return host === "localhost" || host.endsWith(".localhost") || host === "127.0.0.1" || host === "[::1]";
805
+ } catch {
806
+ return false;
807
+ }
808
+ }
809
+ function refusedCheck(error) {
810
+ const base = { id: "server_checks", source: "cli" };
811
+ if (isTulaAdminError(error)) {
812
+ if (error.status === 404) {
813
+ return {
814
+ ...base,
815
+ status: "warn",
816
+ summary: "The server has no instance admin token, so its own checks (database, migrations, master key, mail, Redis) did not run.",
817
+ fix: "Set TULA_ADMIN_TOKEN in the API\u2019s environment (`openssl rand -hex 32`), restart it, and set the same value here."
818
+ };
819
+ }
820
+ if (error.status === 401) {
821
+ return {
822
+ ...base,
823
+ status: "fail",
824
+ summary: "The server refused the admin token.",
825
+ fix: "Set TULA_ADMIN_TOKEN here to the value in the API\u2019s environment (or pass --admin-token-file). After changing it on the server, restart the API."
826
+ };
827
+ }
828
+ if (error.code === "service.unavailable") {
829
+ return {
830
+ ...base,
831
+ status: "fail",
832
+ summary: "The API refused the request because the store behind its rate limits (Redis) does not answer.",
833
+ fix: "Check REDIS_URL in the API\u2019s environment and that Redis is running and reachable from the API. Until it answers, sign-in is refused too."
834
+ };
835
+ }
836
+ if (error.code === "rate_limited") {
837
+ return {
838
+ ...base,
839
+ status: "fail",
840
+ summary: "The API is rate limiting this machine\u2019s diagnostics requests.",
841
+ fix: "Wait a minute and run `tula doctor` again."
842
+ };
843
+ }
844
+ return {
845
+ ...base,
846
+ status: "fail",
847
+ summary: `The server-side checks could not be read (${printable2(error.code, 60)}${error.status > 0 ? `, HTTP ${error.status}` : ""}).`,
848
+ fix: "Check that TULA_API_URL is the Tula API itself (not a page in front of it) and that the CLI and the server are the same version."
849
+ };
850
+ }
851
+ return {
852
+ ...base,
853
+ status: "fail",
854
+ summary: "The server-side checks could not be read.",
855
+ fix: "Run `tula doctor` again; if it persists, look at the API\u2019s logs."
856
+ };
857
+ }
858
+ async function examine2(input) {
859
+ const { apiUrl, instance, io, signal } = input;
860
+ const now = io.now ?? (() => new Date);
861
+ const report = { apiUrl, checks: [] };
862
+ const answer = await status(io, apiUrl, signal);
863
+ if (answer?.status !== 200) {
864
+ report.checks.push({
865
+ id: "api",
866
+ source: "cli",
867
+ status: "fail",
868
+ summary: answer ? `The API URL answers with HTTP ${answer.status} instead of the API\u2019s status.` : "The API cannot be reached from this machine.",
869
+ fix: "Check TULA_API_URL (or --api-url): it is the address of the Tula API, with no path. Check that the API is running (`tula dev`, or `docker compose ps`) and, for https, that its certificate is valid."
870
+ });
871
+ return report;
872
+ }
873
+ report.version = answer.version;
874
+ report.checks.push({
875
+ id: "api",
876
+ source: "cli",
877
+ status: "ok",
878
+ summary: `The API answers${apiUrl.startsWith("https:") ? " over TLS" : ""}.`
879
+ });
880
+ report.checks.push(answer.version === VERSION2 ? {
881
+ id: "version",
882
+ source: "cli",
883
+ status: "ok",
884
+ summary: `The CLI and the API are the same version (${VERSION2}).`
885
+ } : {
886
+ id: "version",
887
+ source: "cli",
888
+ status: "warn",
889
+ summary: `The CLI is version ${VERSION2} and the API is ${answer.version ?? "unknown"}.`,
890
+ fix: "Use the CLI of the API\u2019s version: a different one may not know every setting or check."
891
+ });
892
+ if (!instance) {
893
+ report.checks.push({
894
+ id: "server_checks",
895
+ source: "cli",
896
+ status: "warn",
897
+ summary: "No admin token here, so the server\u2019s own checks (database, migrations, master key, mail, Redis) did not run.",
898
+ fix: "Set TULA_ADMIN_TOKEN to the value in the API\u2019s environment, or pass --admin-token-file <path>."
899
+ });
900
+ return report;
901
+ }
902
+ const before = now().getTime();
903
+ let data;
904
+ try {
905
+ data = (await instance.call("getInstanceDiagnostics", { signal })).data;
906
+ } catch (error) {
907
+ report.checks.push(refusedCheck(error));
908
+ return report;
909
+ }
910
+ const after = now().getTime();
911
+ const body = isRecord(data) ? data : {};
912
+ report.environment = printable2(body.environment, 20) || undefined;
913
+ const server = serverChecks(data);
914
+ const serverTime = typeof body.time === "string" ? Date.parse(body.time) : Number.NaN;
915
+ if (!Number.isNaN(serverTime)) {
916
+ const skewMs = Math.abs(serverTime - (before + after) / 2);
917
+ report.checks.push(skewMs < SKEW_WARN_MS ? {
918
+ id: "local_clock",
919
+ source: "cli",
920
+ status: "ok",
921
+ summary: "This machine\u2019s clock and the API\u2019s agree."
922
+ } : {
923
+ id: "local_clock",
924
+ source: "cli",
925
+ status: skewMs >= SKEW_FAIL_MS ? "fail" : "warn",
926
+ summary: `This machine\u2019s clock and the API\u2019s differ by about ${Math.round(skewMs / 1000)} seconds.`,
927
+ fix: "Synchronize the clocks (NTP). A server that verifies Tula\u2019s tokens on a clock this far off refuses valid ones or accepts expired ones."
928
+ });
929
+ }
930
+ const skipped = server.find((check) => check.id === "public_url" && check.status === "skipped");
931
+ const named = typeof body.publicUrl === "string" ? parseUrl(body.publicUrl) : null;
932
+ if (skipped && named && isLoopback(named.href)) {
933
+ const configured = parseUrl(apiUrl);
934
+ if (configured && named.origin === configured.origin && named.username === "" && named.password === "") {
935
+ const reached = apiUrl.replace(/\/+$/, "") === named.origin ? answer : await status(io, named.origin, signal);
936
+ Object.assign(skipped, reached?.status === 200 ? {
937
+ source: "cli",
938
+ status: "ok",
939
+ summary: "PUBLIC_URL is a loopback address and reaches the API from this machine."
940
+ } : {
941
+ source: "cli",
942
+ status: "warn",
943
+ summary: "PUBLIC_URL is a loopback address that does not reach the API from this machine.",
944
+ fix: "Set PUBLIC_URL in the API\u2019s environment to the address clients use to reach it (for local development, the port the API is published on), and restart it."
945
+ });
946
+ } else {
947
+ Object.assign(skipped, {
948
+ source: "cli",
949
+ status: "skipped",
950
+ summary: "PUBLIC_URL is a loopback address other than the API URL given here, so it was not requested: `tula doctor` asks only the address you give it. If clients reach the API there, run `tula doctor --api-url <PUBLIC_URL>`; if not, set PUBLIC_URL to the address they use."
951
+ });
952
+ }
953
+ }
954
+ report.checks.push(...server);
955
+ return report;
956
+ }
957
+ function exitCode2(report, strict) {
958
+ const failing = (check) => check.status === "fail" || strict && check.status === "warn";
959
+ return report.checks.some(failing) ? 1 : 0;
960
+ }
961
+ function reportToJson2(report, strict) {
962
+ return { ...report, ok: exitCode2(report, strict) === 0 };
963
+ }
964
+ function renderReport2(output, report) {
965
+ const { style } = output;
966
+ const labels = {
967
+ ok: style.green("ok "),
968
+ warn: style.yellow("warn "),
969
+ fail: style.red("FAIL "),
970
+ skipped: style.dim("skipped")
971
+ };
972
+ const about = [report.environment, report.version && `version ${report.version}`].filter(Boolean).join(", ");
973
+ output.line(style.bold(`tula doctor: ${report.apiUrl}${about ? ` (${about})` : ""}`));
974
+ output.line();
975
+ const width = Math.max(...report.checks.map((check) => check.id.length));
976
+ const indent = " ".repeat(2 + 7 + 2 + width + 2);
977
+ for (const check of report.checks) {
978
+ output.line(` ${labels[check.status]} ${check.id.padEnd(width)} ${check.summary}`);
979
+ for (const value of check.values ?? []) {
980
+ output.line(`${indent}- ${value}`);
981
+ }
982
+ if (check.fix !== undefined && check.status !== "ok") {
983
+ output.line(`${indent}fix: ${check.fix}`);
984
+ }
985
+ }
986
+ output.line();
987
+ const count = (status) => report.checks.filter((check) => check.status === status).length;
988
+ const warnings = count("warn");
989
+ output.line(`${count("ok")} ok, ${count("fail")} failed, ${warnings} warning${warnings === 1 ? "" : "s"}, ${count("skipped")} skipped.`);
990
+ }
991
+
992
+ // src/framework.ts
993
+ import { isTulaAdminError as isTulaAdminError2 } from "@tula-tech/admin";
994
+ import { isConfigError } from "@tula-tech/config";
995
+
996
+ // src/output.ts
997
+ function shouldUseColor2(sink, env) {
998
+ const noColor = env.NO_COLOR;
999
+ return sink.isTTY === true && (noColor === undefined || noColor === "") && env.TERM !== "dumb";
1000
+ }
1001
+ function styles(color) {
1002
+ const wrap = (open, close) => (text) => color ? `\x1B[${open}m${text}\x1B[${close}m` : text;
1003
+ return {
1004
+ green: wrap(32, 39),
1005
+ red: wrap(31, 39),
1006
+ yellow: wrap(33, 39),
1007
+ dim: wrap(2, 22),
1008
+ bold: wrap(1, 22)
1009
+ };
1010
+ }
1011
+ var MIN_REDACTED_LENGTH = 6;
1012
+ function createOutput2(stdout, stderr, env) {
1013
+ const secrets = new Set;
1014
+ const errorSecrets = new Set;
1015
+ const clean = (text, sets) => {
1016
+ let cleaned = text;
1017
+ for (const set of sets) {
1018
+ for (const secret of set) {
1019
+ cleaned = cleaned.split(secret).join("[redacted]");
1020
+ }
1021
+ }
1022
+ return cleaned;
1023
+ };
1024
+ return {
1025
+ line: (text = "") => {
1026
+ stdout.write(`${clean(text, [secrets])}
1027
+ `);
1028
+ },
1029
+ error: (text) => {
1030
+ stderr.write(`${clean(text, [secrets, errorSecrets])}
1031
+ `);
1032
+ },
1033
+ style: styles(shouldUseColor2(stdout, env)),
1034
+ errorStyle: styles(shouldUseColor2(stderr, env)),
1035
+ redact: (value, scope = "all") => {
1036
+ const trimmed = value.trim();
1037
+ if (scope === "errors") {
1038
+ if (trimmed.length >= MIN_REDACTED_LENGTH) {
1039
+ errorSecrets.add(trimmed);
1040
+ }
1041
+ return;
1042
+ }
1043
+ if (trimmed.length >= MIN_REDACTED_LENGTH) {
1044
+ secrets.add(trimmed);
1045
+ for (const line of trimmed.split(`
1046
+ `)) {
1047
+ if (line.trim().length >= MIN_REDACTED_LENGTH * 4) {
1048
+ secrets.add(line.trim());
1049
+ }
1050
+ }
1051
+ }
1052
+ }
1053
+ };
1054
+ }
1055
+
1056
+ // src/framework.ts
1057
+ var EXIT2 = { ok: 0, error: 1, changes: 2, refused: 2 };
1058
+ function optionLines(options) {
1059
+ const all = {
1060
+ ...options,
1061
+ help: { type: "boolean", short: "h", description: "Show this help." }
1062
+ };
1063
+ const rows = Object.entries(all).map(([name, spec]) => [
1064
+ `${spec.short ? `-${spec.short}, ` : " "}--${name}${spec.value ? ` ${spec.value}` : ""}`,
1065
+ spec.description
1066
+ ]);
1067
+ const width = Math.max(...rows.map(([left]) => left.length));
1068
+ return rows.map(([left, right]) => ` ${left.padEnd(width)} ${right}`);
1069
+ }
1070
+ function commandHelp(command) {
1071
+ return [
1072
+ `Usage: ${command.usage}`,
1073
+ "",
1074
+ command.description ?? command.summary,
1075
+ "",
1076
+ "Options:",
1077
+ ...optionLines(command.options)
1078
+ ];
1079
+ }
1080
+ function generalHelp(commands) {
1081
+ const width = Math.max(...commands.map((command) => command.name.length));
1082
+ return [
1083
+ "Usage: tula <command> [options]",
1084
+ "",
1085
+ "Commands:",
1086
+ ...commands.map((command) => ` ${command.name.padEnd(width)} ${command.summary}`),
1087
+ "",
1088
+ "Run `tula <command> --help` for a command\u2019s options."
1089
+ ];
1090
+ }
1091
+ function reportAdminError(output, error) {
1092
+ const { errorStyle } = output;
1093
+ const where = error.status > 0 ? ` (${error.code}, HTTP ${error.status})` : ` (${error.code})`;
1094
+ output.error(`${errorStyle.red("error:")} ${error.message}${where}`);
1095
+ for (const problem of error.errors) {
1096
+ output.error(` ${problem.field}: ${problem.message}`);
1097
+ }
1098
+ if (error.retryAfterMs !== undefined) {
1099
+ output.error(` Try again in ${Math.ceil(error.retryAfterMs / 1000)}s.`);
1100
+ }
1101
+ }
1102
+ function reportError2(output, error) {
1103
+ const { errorStyle } = output;
1104
+ if (isTulaAdminError2(error)) {
1105
+ reportAdminError(output, error);
1106
+ } else if (isConfigError(error) || error instanceof UsageError2) {
1107
+ output.error(`${errorStyle.red("error:")} ${error.message}`);
1108
+ } else {
1109
+ const text = error instanceof Error ? `${error.name}: ${error.message}` : "an unknown error";
1110
+ output.error(`${errorStyle.red("error:")} unexpected failure (${text})`);
1111
+ }
1112
+ return EXIT2.error;
1113
+ }
1114
+ async function runCli2(argv, io, commands) {
1115
+ const output = createOutput2(io.stdout, io.stderr, io.env);
1116
+ const [name, ...rest] = argv;
1117
+ if (name === undefined || name === "--help" || name === "-h" || name === "help") {
1118
+ for (const line of generalHelp(commands)) {
1119
+ output.line(line);
1120
+ }
1121
+ return name === undefined ? EXIT2.error : EXIT2.ok;
1122
+ }
1123
+ if (name === "--version" || name === "-v") {
1124
+ output.line(VERSION2);
1125
+ return EXIT2.ok;
1126
+ }
1127
+ const command = commands.find((candidate) => candidate.name === name);
1128
+ if (!command) {
1129
+ const shown = name.startsWith("-") ? "an option" : `"${name.slice(0, 40)}"`;
1130
+ output.error(`${output.errorStyle.red("error:")} ${shown} is not a tula command.`);
1131
+ for (const line of generalHelp(commands)) {
1132
+ output.error(line);
1133
+ }
1134
+ return EXIT2.error;
1135
+ }
1136
+ try {
1137
+ const parsed = parseArgs2(rest, {
1138
+ ...command.options,
1139
+ help: { type: "boolean", short: "h", description: "Show this help." }
1140
+ });
1141
+ if (parsed.flags.help) {
1142
+ for (const line of commandHelp(command)) {
1143
+ output.line(line);
1144
+ }
1145
+ return EXIT2.ok;
1146
+ }
1147
+ const allowed = command.maxPositionals ?? 0;
1148
+ if (parsed.positionals.length > allowed) {
1149
+ throw new UsageError2(allowed === 0 ? `tula ${command.name} takes no arguments, only options.` : `tula ${command.name} takes at most ${allowed} argument${allowed === 1 ? "" : "s"}. Usage: ${command.usage}`);
1150
+ }
1151
+ return await command.run({ ...parsed, io, output });
1152
+ } catch (error) {
1153
+ return reportError2(output, error);
1154
+ }
1155
+ }
1156
+
1157
+ // src/render.ts
1158
+ import {
1159
+ MAX_EMAIL_BODY_LENGTH,
1160
+ MAX_NATIVE_APPS as MAX_NATIVE_APPS2,
1161
+ MAX_WEBHOOK_ENDPOINTS as MAX_WEBHOOK_ENDPOINTS2,
1162
+ MAX_WEBHOOK_URL_LENGTH
1163
+ } from "@tula-tech/contract";
1164
+ var MAX_VALUE_LENGTH = 100;
1165
+ function show(value) {
1166
+ const text = JSON.stringify(value) ?? "undefined";
1167
+ return text.length > MAX_VALUE_LENGTH ? `${text.slice(0, MAX_VALUE_LENGTH - 1)}\u2026` : text;
1168
+ }
1169
+ function shown(change, value) {
1170
+ return typeof value === "string" && isTemplatePath(change.path) ? show(printable2(value, MAX_EMAIL_BODY_LENGTH)) : show(value);
1171
+ }
1172
+ function settingLine(output, change) {
1173
+ const { style } = output;
1174
+ if (change.kind === "added") {
1175
+ return style.green(` + ${change.path}: ${shown(change, change.after)}`);
1176
+ }
1177
+ if (change.kind === "removed") {
1178
+ const note = isTemplatePath(change.path) ? " (the built-in copy is sent)" : "";
1179
+ return style.red(` - ${change.path}: ${shown(change, change.before)}${note}`);
1180
+ }
1181
+ if (change.added || change.removed) {
1182
+ const entries = [
1183
+ ...(change.added ?? []).map((entry) => `+${show(entry)}`),
1184
+ ...(change.removed ?? []).map((entry) => `-${show(entry)}`)
1185
+ ];
1186
+ return style.yellow(` ~ ${change.path}: ${entries.join(" ")}`);
1187
+ }
1188
+ return style.yellow(` ~ ${change.path}: ${shown(change, change.before)} \u2192 ${shown(change, change.after)}`);
1189
+ }
1190
+ function secretNote(change) {
1191
+ if (change.secret === "set") {
1192
+ return `secret set from $${change.secretEnv ?? "?"}`;
1193
+ }
1194
+ return change.secret === "keep" ? "stored secret kept" : "";
1195
+ }
1196
+ function clean(entry) {
1197
+ return typeof entry === "string" ? printable2(entry, 128) : entry;
1198
+ }
1199
+ function providerLine(output, change) {
1200
+ const { style } = output;
1201
+ const fields = change.fields.map((field) => {
1202
+ if (field.added || field.removed) {
1203
+ const entries = [
1204
+ ...(field.added ?? []).map((entry) => `+${show(clean(entry))}`),
1205
+ ...(field.removed ?? []).map((entry) => `-${show(clean(entry))}`)
1206
+ ];
1207
+ return `${field.path} ${entries.join(" ")}`;
1208
+ }
1209
+ return field.kind === "changed" ? `${field.path} ${show(field.before)} \u2192 ${show(field.after)}` : `${field.path} ${show(field.after)}`;
1210
+ });
1211
+ const details = [...fields, secretNote(change)].filter((part) => part !== "").join(", ");
1212
+ const suffix = details === "" ? "" : ` (${details})`;
1213
+ switch (change.action) {
1214
+ case "create":
1215
+ return style.green(` + ${change.provider}: create${suffix}`);
1216
+ case "update":
1217
+ return style.yellow(` ~ ${change.provider}: update${suffix}`);
1218
+ case "delete":
1219
+ return style.red(` - ${change.provider}: delete`);
1220
+ case "unmanaged":
1221
+ return style.dim(` = ${change.provider}: unmanaged (on the server, not in the file; --prune deletes it)`);
1222
+ default:
1223
+ return style.dim(` = ${change.provider}: unchanged`);
1224
+ }
1225
+ }
1226
+ function address(url) {
1227
+ return printable2(url, MAX_WEBHOOK_URL_LENGTH);
1228
+ }
1229
+ var plural = (count, one, many) => count === 1 ? one : many;
1230
+ function removedEndpoints(count) {
1231
+ return `${count} webhook ${plural(count, "endpoint with its", "endpoints with their")} pending deliveries and ${plural(count, "its delivery log", "their delivery logs")}`;
1232
+ }
1233
+ function webhookFields(change) {
1234
+ return change.fields.map((field) => {
1235
+ if (field.path === "eventTypes") {
1236
+ const entries = field.kind === "added" ? field.after.map((entry) => show(entry)) : [
1237
+ ...(field.added ?? []).map((entry) => `+${show(entry)}`),
1238
+ ...(field.removed ?? []).map((entry) => `-${show(entry)}`)
1239
+ ];
1240
+ return `eventTypes ${entries.join(" ")}`;
1241
+ }
1242
+ if (field.kind === "added") {
1243
+ return `${field.path} ${show(field.after)}`;
1244
+ }
1245
+ const why = change.reenables === undefined ? "" : ` (the server had switched it off: ${printable2(change.reenables, 60)})`;
1246
+ return `${field.path} ${show(field.before)} \u2192 ${show(field.after)}${why}`;
1247
+ });
1248
+ }
1249
+ function webhookLine(output, change) {
1250
+ const { style } = output;
1251
+ const url = address(change.url);
1252
+ const fields = webhookFields(change).join(", ");
1253
+ switch (change.action) {
1254
+ case "create":
1255
+ return style.green(` + ${url}: create (${fields}; a signing secret is made, shown once)`);
1256
+ case "update":
1257
+ return style.yellow(` ~ ${url}: update (${fields})`);
1258
+ case "delete":
1259
+ return style.red(` - ${url}: remove, with its pending deliveries and its delivery log`);
1260
+ case "unmanaged":
1261
+ return style.dim(` = ${url}: unmanaged (on the server, not in the file; --prune removes it, with its pending deliveries and its delivery log)`);
1262
+ case "ambiguous":
1263
+ return style.red(` ! ${url}: cannot be matched (the server has ${change.duplicates?.length ?? 0} endpoints with this address)`);
1264
+ default:
1265
+ return style.dim(` = ${url}: unchanged`);
1266
+ }
1267
+ }
1268
+ function pointName(point) {
1269
+ return printable2(point, 60);
1270
+ }
1271
+ function hookFields(change) {
1272
+ return change.fields.map((field) => {
1273
+ const value = (entry) => field.path === "url" ? address(String(entry)) : show(entry);
1274
+ return field.kind === "added" ? `${field.path} ${value(field.after)}` : `${field.path} ${value(field.before)} \u2192 ${value(field.after)}`;
1275
+ });
1276
+ }
1277
+ function hookLine(output, change) {
1278
+ const { style } = output;
1279
+ const point = pointName(change.point);
1280
+ const fields = hookFields(change).join(", ");
1281
+ switch (change.action) {
1282
+ case "create":
1283
+ return style.green(` + ${point}: create (${fields}; a signing secret is made, shown once)`);
1284
+ case "update":
1285
+ return style.yellow(` ~ ${point}: update (${fields})`);
1286
+ case "delete":
1287
+ return style.red(` - ${point}: remove (${address(change.url)}), with its signing secret`);
1288
+ case "unmanaged":
1289
+ return style.dim(` = ${point}: unmanaged (on the server, not in the file; --prune removes it)`);
1290
+ case "unknown":
1291
+ return style.dim(` = ${point}: a point this version of tula does not know (left alone, also with --prune)`);
1292
+ default:
1293
+ return style.dim(` = ${point}: unchanged`);
1294
+ }
1295
+ }
1296
+ function appName(change) {
1297
+ return `${printable2(change.platform, 20)} ${printable2(change.identifier, 255)}`;
1298
+ }
1299
+ function appValue(value) {
1300
+ return printable2(String(value), 95);
1301
+ }
1302
+ function nativeAppFields2(change) {
1303
+ return change.fields.map((field) => {
1304
+ if (field.path === "sha256CertFingerprints" || field.path === "appLinkPaths") {
1305
+ const entries = field.kind === "added" ? field.after.map(appValue) : [
1306
+ ...(field.added ?? []).map((entry) => `+${appValue(entry)}`),
1307
+ ...(field.removed ?? []).map((entry) => `-${appValue(entry)}`)
1308
+ ];
1309
+ return `${field.path} ${entries.join(" ")}`;
1310
+ }
1311
+ return field.kind === "added" ? `${field.path} ${appValue(field.after)}` : `${field.path} ${appValue(field.before)} \u2192 ${appValue(field.after)}`;
1312
+ });
1313
+ }
1314
+ function unmanagedLinkPathsLine(change) {
1315
+ const count = change.unmanagedLinkPaths ?? 0;
1316
+ if (count === 0) {
1317
+ return null;
1318
+ }
1319
+ const paths = count === 1 ? "1 link path" : `${count} link paths`;
1320
+ return ` ${paths} on the server, not managed by the file (kept; write appLinkPaths to manage them, [] to remove them)`;
1321
+ }
1322
+ function nativeAppLine(output, change) {
1323
+ const { style } = output;
1324
+ const name = appName(change);
1325
+ const fields = nativeAppFields2(change).join(", ");
1326
+ switch (change.action) {
1327
+ case "create":
1328
+ return style.green(` + ${name}: register (${fields})`);
1329
+ case "update":
1330
+ return style.yellow(` ~ ${name}: update (${fields})`);
1331
+ case "delete":
1332
+ return style.red(` - ${name}: remove (the served files stop naming it)`);
1333
+ case "unmanaged":
1334
+ return style.dim(` = ${name}: unmanaged (on the server, not in the file; --prune removes it)`);
1335
+ case "unknown":
1336
+ return style.dim(` = ${name}: a platform this version of tula does not know (left alone, also with --prune)`);
1337
+ default:
1338
+ return style.dim(` = ${name}: unchanged`);
1339
+ }
1340
+ }
1341
+ function hookCounts(plan) {
1342
+ const count = (action) => plan.hooks.hooks.filter((hook) => hook.action === action).length;
1343
+ return { created: count("create"), removed: count("delete") };
1344
+ }
1345
+ function webhookCounts(plan) {
1346
+ const count = (action) => plan.webhooks.endpoints.filter((endpoint) => endpoint.action === action).length;
1347
+ return { created: count("create"), removed: count("delete") };
1348
+ }
1349
+ function planBlockers(plan) {
1350
+ const blockers = [];
1351
+ for (const change of plan.webhooks.endpoints) {
1352
+ if (change.action === "ambiguous") {
1353
+ const ids = (change.duplicates ?? []).map((id) => printable2(id, 40));
1354
+ blockers.push(`The server has ${ids.length} webhook endpoints with the address ${address(change.url)} ` + `(ids ${ids.join(", ")}): tula cannot tell which one the file means and changes none ` + "of them. Remove all but one by hand (DELETE /v1/admin/webhook-endpoints/<id>), then " + "run again.");
1355
+ }
1356
+ }
1357
+ if (plan.webhooks.overLimit !== null) {
1358
+ blockers.push(`The environment would have ${plan.webhooks.overLimit} webhook endpoints and may have ${MAX_WEBHOOK_ENDPOINTS2}.`);
1359
+ }
1360
+ const apps = plan.nativeApps?.overLimit ?? null;
1361
+ if (apps !== null) {
1362
+ blockers.push(`The environment would have ${apps} native apps and may have ${MAX_NATIVE_APPS2}.`);
1363
+ }
1364
+ return blockers;
1365
+ }
1366
+ var OVER_LIMIT_ADVICE = "List fewer in the file, or remove the ones it does not list (--prune).";
1367
+ var MARKER_REASONS = {
1368
+ unmanaged: "the server does not record a config file as the source of these settings yet",
1369
+ "other-tool": "another tool is on record as managing these settings",
1370
+ "other-config": "another version of the config file was applied last",
1371
+ drifted: "the settings were changed outside the config file since the last apply",
1372
+ none: ""
1373
+ };
1374
+ function deletedAuditAge(plan) {
1375
+ const retentionDays = plan.body.audit?.retentionDays;
1376
+ return plan.weakened.includes("audit.retentionDays") && typeof retentionDays === "number" ? retentionDays : null;
1377
+ }
1378
+ function switchesOffTextedSecondStep(plan) {
1379
+ return plan.settings.some((change) => change.path === "mfa.smsCode.enabled" && change.before === true && change.after !== true);
1380
+ }
1381
+ var TEXTED_SECOND_STEP_LOCKED = "those whose only second step is a texted code cannot sign in until it is back or an administrator resets their two-step verification";
1382
+ var MAX_COUNTRIES_NAMED = 20;
1383
+ function countriesTakenOut(plan) {
1384
+ const change = plan.settings.find((entry) => entry.path === "sms.allowedCountries");
1385
+ const removed = change?.removed ?? [];
1386
+ return removed.filter((entry) => typeof entry === "string").map((entry) => printable2(entry, 8)).sort();
1387
+ }
1388
+ function dependentAccountLines(plan) {
1389
+ return plan.providers.flatMap((change) => {
1390
+ if (change.dependents === undefined) {
1391
+ return [];
1392
+ }
1393
+ const outcome = change.action === "delete" ? "until it is configured again" : "while it is off";
1394
+ if (change.dependents === null) {
1395
+ return [
1396
+ `the accounts that can sign in only with ${change.provider} could not be counted (this server has no count): if there are any they are locked out ${outcome}, and this plan was not judged for them`
1397
+ ];
1398
+ }
1399
+ if (change.dependents === DEPENDENTS_UNREAD2) {
1400
+ return [
1401
+ `the accounts that can sign in only with ${change.provider} could not be counted (the count could not be read): if there are any they are locked out ${outcome}, and this plan is taken to weaken security`
1402
+ ];
1403
+ }
1404
+ const { accounts, more } = change.dependents;
1405
+ if (accounts === 0 && !more) {
1406
+ return [];
1407
+ }
1408
+ if (accounts === 0) {
1409
+ return [
1410
+ `none of the accounts the server read can sign in only with ${change.provider}, and it did not read them all: the rest were not looked at, so how many there are is not known`
1411
+ ];
1412
+ }
1413
+ const count = `${more ? "at least " : ""}${accounts}`;
1414
+ const one = accounts === 1 && !more;
1415
+ return [
1416
+ `${count} ${accounts === 1 ? "account" : "accounts"} can sign in only with ${change.provider}: ${one ? "it is" : "they are"} locked out ${outcome}`
1417
+ ];
1418
+ });
1419
+ }
1420
+ function switchesTextMessagesOff(plan) {
1421
+ return plan.settings.some((change) => change.path === "sms.enabled" && change.before === true && change.after !== true);
1422
+ }
1423
+ function planWarnings2(plan) {
1424
+ const warnings = [];
1425
+ if (plan.weakened.length > 0) {
1426
+ warnings.push(`weakens security: ${plan.weakened.join(", ")} (\`tula apply --yes\` needs --allow-weaker)`);
1427
+ }
1428
+ warnings.push(...dependentAccountLines(plan));
1429
+ const doomed = deletedAuditAge(plan);
1430
+ if (doomed !== null) {
1431
+ warnings.push(`deletes audit entries older than ${doomed} days, for good, starting with the next retention run (every ten minutes; a large backlog takes several)`);
1432
+ }
1433
+ if (switchesOffTextedSecondStep(plan)) {
1434
+ warnings.push("switches off the texted code as the second step (mfa.smsCode: off in the file, or left out of it): users whose only second step is a texted code cannot sign in until it is on again or an administrator resets their two-step verification");
1435
+ }
1436
+ if (switchesTextMessagesOff(plan)) {
1437
+ warnings.push(`switches text messages off (sms.enabled: off in the file, or left out of it): no user can receive a code any more, and ${TEXTED_SECOND_STEP_LOCKED}`);
1438
+ }
1439
+ const countries = countriesTakenOut(plan);
1440
+ if (countries.length > 0) {
1441
+ const named = countries.slice(0, MAX_COUNTRIES_NAMED).join(", ");
1442
+ const more = countries.length - MAX_COUNTRIES_NAMED;
1443
+ warnings.push(`stops text messages to ${named}${more > 0 ? ` and ${more} more` : ""} (taken out of sms.allowedCountries): users whose number is there can no longer receive a code, unless a country that stays shares its prefix, and ${TEXTED_SECOND_STEP_LOCKED}`);
1444
+ }
1445
+ const { created, removed } = webhookCounts(plan);
1446
+ if (removed > 0) {
1447
+ warnings.push(`removes ${removedEndpoints(removed)}, for good (\`tula apply --yes\` needs --allow-webhook-removal)`);
1448
+ }
1449
+ if (created > 0) {
1450
+ warnings.push(`creates ${created} webhook ${plural(created, "endpoint: its signing secret is", "endpoints: each signing secret is")} shown once, to the run that creates it (\`tula apply\` needs --secrets-file <path>, --show-secrets or --discard-secrets)`);
1451
+ }
1452
+ const hooks = hookCounts(plan).created;
1453
+ if (hooks > 0) {
1454
+ warnings.push(`creates ${hooks} ${plural(hooks, "hook: its signing secret is", "hooks: each signing secret is")} shown once, to the run that creates it (\`tula apply\` needs --secrets-file <path>, --show-secrets or --discard-secrets)`);
1455
+ }
1456
+ for (const change of plan.webhooks.endpoints) {
1457
+ if (change.reenables !== undefined) {
1458
+ warnings.push(`switches on a webhook endpoint the server switched off (${address(change.url)}: ${printable2(change.reenables, 60)}); if it still fails the server switches it off again`);
1459
+ }
1460
+ }
1461
+ const first = plan.webhooks.removedFirst;
1462
+ if (first > 0) {
1463
+ warnings.push(`the environment is at its limit of ${MAX_WEBHOOK_ENDPOINTS2} webhook endpoints: ${first} of the removals ${plural(first, "is", "are")} made before the new ${plural(created, "endpoint is", "endpoints are")} created, to make room`);
1464
+ }
1465
+ if (plan.marker.reason === "other-tool") {
1466
+ warnings.push(MARKER_REASONS["other-tool"]);
1467
+ }
1468
+ if (plan.marker.current?.drifted) {
1469
+ warnings.push(MARKER_REASONS.drifted);
1470
+ }
1471
+ if (plan.unknown.length > 0) {
1472
+ warnings.push(`the server has settings this version of tula does not know (${plan.unknown.join(", ")}); ` + "applying resets them to their defaults, so `tula apply` refuses without " + "--allow-unknown. Upgrade tula first.");
1473
+ }
1474
+ return warnings;
1475
+ }
1476
+ function applyRequirements2(plan) {
1477
+ const { created, removed } = webhookCounts(plan);
1478
+ return {
1479
+ allowUnknown: plan.unknown.length > 0,
1480
+ allowWeaker: plan.weakened.length > 0,
1481
+ allowWebhookRemoval: removed > 0,
1482
+ webhookSecrets: created > 0,
1483
+ hookSecrets: hookCounts(plan).created > 0
1484
+ };
1485
+ }
1486
+ function removedWebhooks(plan) {
1487
+ const { removed } = webhookCounts(plan);
1488
+ return removed > 0 ? removedEndpoints(removed) : null;
1489
+ }
1490
+ function describeOperation2(operation) {
1491
+ if (operation.kind === "settings") {
1492
+ return "settings: replace";
1493
+ }
1494
+ if (operation.kind === "hook.create" || operation.kind === "hook.update" || operation.kind === "hook.delete") {
1495
+ const did = { "hook.create": "create", "hook.update": "update", "hook.delete": "remove" };
1496
+ return `hook ${operation.point}: ${did[operation.kind]}`;
1497
+ }
1498
+ if (operation.kind === "nativeApp.create" || operation.kind === "nativeApp.update" || operation.kind === "nativeApp.delete") {
1499
+ const did = {
1500
+ "nativeApp.create": "register",
1501
+ "nativeApp.update": "update",
1502
+ "nativeApp.delete": "remove"
1503
+ };
1504
+ return `native app ${appName(operation.change)}: ${did[operation.kind]}`;
1505
+ }
1506
+ if (operation.kind === "provider.delete") {
1507
+ return `provider ${operation.provider}: delete`;
1508
+ }
1509
+ if (operation.kind === "provider.set") {
1510
+ return `provider ${operation.provider}: ${operation.change.action}`;
1511
+ }
1512
+ const action = {
1513
+ "webhook.create": "create",
1514
+ "webhook.update": "update",
1515
+ "webhook.delete": "remove"
1516
+ };
1517
+ return `webhook ${address(operation.url)}: ${action[operation.kind]}`;
1518
+ }
1519
+ function renderPlan2(output, target, plan) {
1520
+ const { style } = output;
1521
+ output.line(style.bold(`Environment "${target.environment}" at ${target.apiUrl}`) + style.dim(` (settings revision ${plan.revision})`));
1522
+ output.line();
1523
+ output.line(style.bold("Settings"));
1524
+ if (plan.settings.length === 0) {
1525
+ output.line(style.dim(" no changes"));
1526
+ }
1527
+ for (const change of plan.settings) {
1528
+ output.line(settingLine(output, change));
1529
+ }
1530
+ if (plan.marker.pending) {
1531
+ output.line(style.yellow(` ~ managed-by record: ${MARKER_REASONS[plan.marker.reason]}`));
1532
+ }
1533
+ if (plan.kept.length > 0) {
1534
+ output.line(style.dim(` kept as on the server (not in the file): ${plan.kept.join(", ")}`));
1535
+ }
1536
+ if (plan.providers.length > 0) {
1537
+ output.line();
1538
+ output.line(style.bold("Providers"));
1539
+ for (const change of plan.providers) {
1540
+ output.line(providerLine(output, change));
1541
+ }
1542
+ }
1543
+ if (plan.webhooks.managed) {
1544
+ output.line();
1545
+ output.line(style.bold("Webhooks"));
1546
+ if (plan.webhooks.endpoints.length === 0) {
1547
+ output.line(style.dim(" none in the file, none on the server"));
1548
+ }
1549
+ for (const change of plan.webhooks.endpoints) {
1550
+ output.line(webhookLine(output, change));
1551
+ }
1552
+ const actions = new Set(plan.webhooks.endpoints.map((endpoint) => endpoint.action));
1553
+ if (actions.has("create") && (actions.has("delete") || actions.has("unmanaged"))) {
1554
+ output.line(style.dim(" an endpoint is its address: a changed address is a new endpoint with a new signing secret; the old one stays until it is removed (--prune), and its pending deliveries and its delivery log go with it"));
1555
+ }
1556
+ }
1557
+ if (plan.hooks.managed) {
1558
+ output.line();
1559
+ output.line(style.bold("Hooks"));
1560
+ if (plan.hooks.hooks.length === 0) {
1561
+ output.line(style.dim(" none in the file, none on the server"));
1562
+ }
1563
+ for (const change of plan.hooks.hooks) {
1564
+ output.line(hookLine(output, change));
1565
+ }
1566
+ }
1567
+ if (plan.nativeApps.managed) {
1568
+ output.line();
1569
+ output.line(style.bold("Native apps"));
1570
+ if (plan.nativeApps.apps.length === 0) {
1571
+ output.line(style.dim(" none in the file, none on the server"));
1572
+ }
1573
+ for (const change of plan.nativeApps.apps) {
1574
+ output.line(nativeAppLine(output, change));
1575
+ const note = unmanagedLinkPathsLine(change);
1576
+ if (note !== null) {
1577
+ output.line(style.dim(note));
1578
+ }
1579
+ }
1580
+ }
1581
+ const warnings = planWarnings2(plan);
1582
+ if (warnings.length > 0) {
1583
+ output.line();
1584
+ for (const warning of warnings) {
1585
+ output.line(style.yellow(` ! ${warning}`));
1586
+ }
1587
+ }
1588
+ output.line();
1589
+ }
1590
+ function planToJson2(target, plan) {
1591
+ return {
1592
+ environment: target.environment,
1593
+ apiUrl: target.apiUrl,
1594
+ revision: plan.revision,
1595
+ changes: plan.changes,
1596
+ settings: plan.settings,
1597
+ weakened: plan.weakened,
1598
+ kept: plan.kept,
1599
+ unknown: plan.unknown,
1600
+ providers: plan.providers,
1601
+ webhooks: {
1602
+ managed: plan.webhooks.managed,
1603
+ endpoints: plan.webhooks.endpoints,
1604
+ removedFirst: plan.webhooks.removedFirst,
1605
+ overLimit: plan.webhooks.overLimit
1606
+ },
1607
+ hooks: { managed: plan.hooks.managed, hooks: plan.hooks.hooks },
1608
+ nativeApps: {
1609
+ managed: plan.nativeApps.managed,
1610
+ apps: plan.nativeApps.apps.map(({ entry: _entry, ...change }) => change),
1611
+ overLimit: plan.nativeApps.overLimit
1612
+ },
1613
+ blockers: planBlockers(plan),
1614
+ managedBy: {
1615
+ supported: plan.marker.supported,
1616
+ pending: plan.marker.pending,
1617
+ reason: plan.marker.reason,
1618
+ current: plan.marker.current,
1619
+ configHash: plan.marker.configHash
1620
+ },
1621
+ warnings: planWarnings2(plan),
1622
+ applyRequires: applyRequirements2(plan)
1623
+ };
1624
+ }
1625
+
1626
+ // src/commands/shared.ts
1627
+ import { isTulaAdminError as isTulaAdminError4 } from "@tula-tech/admin";
1628
+ import {
1629
+ DEFAULT_CONFIG_FILE,
1630
+ hashEnvironmentConfig,
1631
+ loadConfig,
1632
+ selectEnvironment
1633
+ } from "@tula-tech/config";
1634
+
1635
+ // src/target.ts
1636
+ import {
1637
+ createAdminClient,
1638
+ createAgentClient,
1639
+ createInstanceClient,
1640
+ isTulaAdminError as isTulaAdminError3
1641
+ } from "@tula-tech/admin";
1642
+ import { secretKeyMatchesKind } from "@tula-tech/config";
1643
+ function variableSuffix(name) {
1644
+ return name.toUpperCase().replace(/[^A-Z0-9]/g, "_");
1645
+ }
1646
+ function own(env, name) {
1647
+ const value = Object.hasOwn(env, name) ? env[name] : undefined;
1648
+ return value === undefined || value.trim() === "" ? undefined : value.trim();
1649
+ }
1650
+ async function resolveTarget2(input) {
1651
+ const { name, environment, flags, io, output } = input;
1652
+ const suffix = variableSuffix(name);
1653
+ const keyFile = typeof flags["secret-key-file"] === "string" ? flags["secret-key-file"] : undefined;
1654
+ let secretKey;
1655
+ if (keyFile !== undefined) {
1656
+ if (keyFile === "-" && io.stdinIsTTY) {
1657
+ throw new UsageError2("--secret-key-file - reads the key from standard input, and standard input is a " + "terminal: the key would be shown as you type it. Pipe it in instead " + "(e.g. `your-secret-store read tula | tula diff --secret-key-file -`), or use a file.");
1658
+ }
1659
+ const read = keyFile === "-" ? io.readStdin : io.readFile;
1660
+ if (!read) {
1661
+ throw new UsageError2("--secret-key-file cannot be read here.");
1662
+ }
1663
+ secretKey = (await read(keyFile).catch(() => {
1664
+ throw new UsageError2(keyFile === "-" ? "Could not read the secret key from standard input." : "Could not read the file given as --secret-key-file.");
1665
+ })).trim();
1666
+ } else {
1667
+ secretKey = own(io.env, `TULA_SECRET_KEY_${suffix}`) ?? own(io.env, "TULA_SECRET_KEY");
1668
+ }
1669
+ if (!secretKey) {
1670
+ throw new UsageError2(`No secret key: set TULA_SECRET_KEY (or TULA_SECRET_KEY_${suffix}), or pass --secret-key-file <path>.`);
1671
+ }
1672
+ output.redact(secretKey);
1673
+ const apiUrl = (typeof flags["api-url"] === "string" ? flags["api-url"] : undefined) ?? own(io.env, `TULA_API_URL_${suffix}`) ?? own(io.env, "TULA_API_URL");
1674
+ if (!apiUrl) {
1675
+ throw new UsageError2(`No API URL: set TULA_API_URL (or TULA_API_URL_${suffix}), or pass --api-url <url>.`);
1676
+ }
1677
+ let admin;
1678
+ try {
1679
+ admin = createAdminClient({
1680
+ baseUrl: apiUrl,
1681
+ secretKey,
1682
+ fetch: io.fetch,
1683
+ userAgent: `tula-cli/${VERSION2}`,
1684
+ allowInsecureHttp: flags["insecure-http"] === true
1685
+ });
1686
+ } catch (error) {
1687
+ if (isTulaAdminError3(error) && error.code === "client.invalid_url" && /^http:/i.test(apiUrl) && flags["insecure-http"] !== true) {
1688
+ throw new UsageError2("The API URL is plain http and is not this machine: the secret key and provider " + "secrets would cross the network in clear text (client.invalid_url). Use an https " + "URL, or pass --insecure-http for a private network you trust. Nothing was sent.");
1689
+ }
1690
+ throw error;
1691
+ }
1692
+ if (!secretKeyMatchesKind(environment.kind, secretKey)) {
1693
+ throw new UsageError2(`The config says "${name}" is a ${environment.kind} environment, and the secret key is not ` + `a ${environment.kind} key. Nothing was read or changed.`);
1694
+ }
1695
+ return { apiUrl: apiUrl.replace(/\/+$/, ""), admin };
1696
+ }
1697
+ async function resolveAgent2(input) {
1698
+ const { name, flags, io, output } = input;
1699
+ const suffix = variableSuffix(name);
1700
+ const file = typeof flags["agent-key-file"] === "string" ? flags["agent-key-file"] : undefined;
1701
+ const agentKey = file !== undefined ? await readSecretFile2(file, "--agent-key-file", io) : own(io.env, `TULA_AGENT_KEY_${suffix}`) ?? own(io.env, "TULA_AGENT_KEY");
1702
+ if (!agentKey) {
1703
+ return null;
1704
+ }
1705
+ output.redact(agentKey);
1706
+ const apiUrl = (typeof flags["api-url"] === "string" ? flags["api-url"] : undefined) ?? own(io.env, `TULA_API_URL_${suffix}`) ?? own(io.env, "TULA_API_URL");
1707
+ if (!apiUrl) {
1708
+ throw new UsageError2(`No API URL: set TULA_API_URL (or TULA_API_URL_${suffix}), or pass --api-url <url>.`);
1709
+ }
1710
+ try {
1711
+ return {
1712
+ apiUrl: apiUrl.replace(/\/+$/, ""),
1713
+ admin: createAgentClient({
1714
+ baseUrl: apiUrl,
1715
+ agentKey,
1716
+ fetch: io.fetch,
1717
+ userAgent: `tula-cli/${VERSION2}`,
1718
+ allowInsecureHttp: flags["insecure-http"] === true
1719
+ })
1720
+ };
1721
+ } catch (error) {
1722
+ if (isTulaAdminError3(error) && error.code === "client.invalid_url") {
1723
+ throw new UsageError2(PLAIN_HTTP);
1724
+ }
1725
+ if (isTulaAdminError3(error) && error.code === "client.invalid_agent_key") {
1726
+ throw new UsageError2("The agent key is not usable: it is an agent key (tula_ak_\u2026), made in the dashboard or " + "with the admin API. A secret key is never given to an agent.");
1727
+ }
1728
+ throw error;
1729
+ }
1730
+ }
1731
+ var PLAIN_HTTP = "The API URL is plain http and is not this machine: the credential would cross the network " + "in clear text (client.invalid_url). Use an https URL, or pass --insecure-http for a " + "private network you trust. Nothing was sent.";
1732
+ function resolveApiUrl2(flags, io) {
1733
+ const apiUrl = (typeof flags["api-url"] === "string" ? flags["api-url"] : undefined) ?? own(io.env, "TULA_API_URL");
1734
+ if (!apiUrl) {
1735
+ throw new UsageError2("No API URL: set TULA_API_URL, or pass --api-url <url>.");
1736
+ }
1737
+ let url;
1738
+ try {
1739
+ url = new URL(apiUrl);
1740
+ } catch {
1741
+ throw new UsageError2("The API URL is not a URL. Set TULA_API_URL to e.g. https://auth.example.com.");
1742
+ }
1743
+ if (url.protocol !== "https:" && url.protocol !== "http:" || url.username || url.password) {
1744
+ throw new UsageError2("The API URL must be an http(s) URL without credentials in it.");
1745
+ }
1746
+ return `${url.origin}${url.pathname.replace(/\/+$/, "")}`;
1747
+ }
1748
+ async function readSecretFile2(path, option, io) {
1749
+ if (path === "-" && io.stdinIsTTY) {
1750
+ throw new UsageError2(`${option} - reads from standard input, and standard input is a terminal: the value ` + "would be shown as you type it. Pipe it in instead, or use a file.");
1751
+ }
1752
+ const read = path === "-" ? io.readStdin : io.readFile;
1753
+ if (!read) {
1754
+ throw new UsageError2(`${option} cannot be read here.`);
1755
+ }
1756
+ const text = await read(path).catch(() => {
1757
+ throw new UsageError2(path === "-" ? `Could not read ${option} from standard input.` : `Could not read the file given as ${option}.`);
1758
+ });
1759
+ return text.trim();
1760
+ }
1761
+ async function resolveInstance2(input) {
1762
+ const { apiUrl, flags, io, output } = input;
1763
+ const file = typeof flags["admin-token-file"] === "string" ? flags["admin-token-file"] : undefined;
1764
+ const token = file !== undefined ? await readSecretFile2(file, "--admin-token-file", io) : own(io.env, "TULA_ADMIN_TOKEN");
1765
+ if (!token) {
1766
+ return null;
1767
+ }
1768
+ output.redact(token);
1769
+ try {
1770
+ return createInstanceClient({
1771
+ baseUrl: apiUrl,
1772
+ adminToken: token,
1773
+ fetch: io.fetch,
1774
+ userAgent: `tula-cli/${VERSION2}`,
1775
+ allowInsecureHttp: flags["insecure-http"] === true
1776
+ });
1777
+ } catch (error) {
1778
+ if (isTulaAdminError3(error) && error.code === "client.invalid_url") {
1779
+ throw new UsageError2(PLAIN_HTTP);
1780
+ }
1781
+ if (isTulaAdminError3(error) && error.code === "client.invalid_token") {
1782
+ throw new UsageError2("The admin token is not usable: it is the server\u2019s TULA_ADMIN_TOKEN (at least 32 characters), not an API key.");
1783
+ }
1784
+ throw error;
1785
+ }
1786
+ }
1787
+
1788
+ // src/commands/shared.ts
1789
+ var PLAN_OPTIONS = {
1790
+ config: {
1791
+ type: "string",
1792
+ short: "c",
1793
+ value: "<path>",
1794
+ description: `The config file. Default: ${DEFAULT_CONFIG_FILE} in the current directory.`
1795
+ },
1796
+ env: {
1797
+ type: "string",
1798
+ short: "e",
1799
+ value: "<name>",
1800
+ description: "The environment in the config. May be left out when the file has one."
1801
+ },
1802
+ "api-url": {
1803
+ type: "string",
1804
+ value: "<url>",
1805
+ description: "The Tula API. Default: TULA_API_URL_<NAME>, then TULA_API_URL."
1806
+ },
1807
+ "insecure-http": {
1808
+ type: "boolean",
1809
+ description: "Allow a plain http API URL that is not localhost: the secret key then crosses the network in clear text. For a private network you trust."
1810
+ },
1811
+ "secret-key-file": {
1812
+ type: "string",
1813
+ value: "<path>",
1814
+ description: "Read the secret key from a file (- for standard input, piped; apply then needs --yes). Default: TULA_SECRET_KEY_<NAME>, then TULA_SECRET_KEY."
1815
+ },
1816
+ prune: {
1817
+ type: "boolean",
1818
+ description: "Delete providers, and remove webhook endpoints, hooks and native apps, that the server has and the file does not list (default: leave them). A file with no webhooks list keeps every endpoint, one with no hooks key every hook, and one with no nativeApps list every app."
1819
+ },
1820
+ "rotate-secrets": {
1821
+ type: "boolean",
1822
+ description: "Send every managed provider\u2019s secret again, changed or not."
1823
+ },
1824
+ json: { type: "boolean", description: "Print the plan as JSON." }
1825
+ };
1826
+ async function readRemote(admin, options) {
1827
+ const settings = await admin.call("getEnvironmentSettings");
1828
+ const providers = await admin.call("listOAuthProviders");
1829
+ const webhooks = options.webhooks ? (await admin.call("listWebhookEndpoints")).data.data : undefined;
1830
+ const hooks = options.hooks ? (await admin.call("listHooks")).data.data : undefined;
1831
+ const nativeApps = options.nativeApps ? (await admin.call("listNativeApps")).data.data : undefined;
1832
+ const managedBy = settings.data.managedBy;
1833
+ return {
1834
+ revision: settings.data.revision,
1835
+ settings: settings.data.settings,
1836
+ managedBy,
1837
+ providers: providers.data.data,
1838
+ ...webhooks !== undefined && { webhooks },
1839
+ ...hooks !== undefined && { hooks },
1840
+ ...nativeApps !== undefined && { nativeApps }
1841
+ };
1842
+ }
1843
+ function isUnknownRoute(error) {
1844
+ return error.status === 404 && error.code === "resource.not_found";
1845
+ }
1846
+ async function readDependents(admin, providers) {
1847
+ const dependents = {};
1848
+ for (const provider of providers) {
1849
+ try {
1850
+ const answer = await admin.call("getOAuthProviderDependents", { params: { provider } });
1851
+ dependents[provider] = readDependentCount2(provider, answer.data) ?? DEPENDENTS_UNREAD2;
1852
+ } catch (error) {
1853
+ if (!isTulaAdminError4(error)) {
1854
+ throw error;
1855
+ }
1856
+ dependents[provider] = isUnknownRoute(error) ? null : DEPENDENTS_UNREAD2;
1857
+ }
1858
+ }
1859
+ return dependents;
1860
+ }
1861
+ async function prepare(context) {
1862
+ const { flags, io, output } = context;
1863
+ const path = typeof flags.config === "string" ? flags.config : DEFAULT_CONFIG_FILE;
1864
+ const { config } = await loadConfig(path, io.cwd);
1865
+ const requested = typeof flags.env === "string" ? flags.env : undefined;
1866
+ const environment = selectEnvironment(config, requested);
1867
+ const name = requested ?? Object.keys(config.environments)[0];
1868
+ const target = await resolveTarget2({ name, environment, flags, io, output });
1869
+ const remote = await readRemote(target.admin, {
1870
+ webhooks: environment.webhooks !== undefined,
1871
+ hooks: environment.hooks !== undefined,
1872
+ nativeApps: environment.nativeApps !== undefined
1873
+ });
1874
+ const prune = flags.prune === true;
1875
+ const dependents = await readDependents(target.admin, providersGoingOff2(remote.providers, environment.providers, { prune }));
1876
+ const plan = buildPlan2({ ...remote, dependents }, environment, {
1877
+ configHash: await hashEnvironmentConfig(environment),
1878
+ prune,
1879
+ rotateSecrets: flags["rotate-secrets"] === true
1880
+ });
1881
+ return { name, environment, target, plan };
1882
+ }
1883
+
1884
+ // src/commands/apply.ts
1885
+ var APPLY_OPTIONS = {
1886
+ ...PLAN_OPTIONS,
1887
+ yes: {
1888
+ type: "boolean",
1889
+ short: "y",
1890
+ description: "Apply without asking. Required when not at a terminal (CI)."
1891
+ },
1892
+ "allow-unknown": {
1893
+ type: "boolean",
1894
+ description: "Apply although the server has settings this version of tula does not know: they are reset to their defaults."
1895
+ },
1896
+ "allow-weaker": {
1897
+ type: "boolean",
1898
+ description: "With --yes: apply although the plan weakens security (it is listed in the plan)."
1899
+ },
1900
+ "allow-webhook-removal": {
1901
+ type: "boolean",
1902
+ description: "With --yes: apply although the plan removes a webhook endpoint (--prune), with its pending deliveries and its delivery log."
1903
+ },
1904
+ "secrets-file": {
1905
+ type: "string",
1906
+ value: "<path>",
1907
+ description: "Write the signing secret of each webhook endpoint and each hook the run creates to this new file (JSON, mode 0600). The server shows a secret only once."
1908
+ },
1909
+ "show-secrets": {
1910
+ type: "boolean",
1911
+ description: "Print the signing secret of each webhook endpoint and each hook the run creates."
1912
+ },
1913
+ "discard-secrets": {
1914
+ type: "boolean",
1915
+ description: "Create webhook endpoints and hooks without keeping their signing secrets (rotate an endpoint\u2019s later to get one; a hook has to be removed and added again)."
1916
+ },
1917
+ "expect-revision": {
1918
+ type: "string",
1919
+ value: "<n>",
1920
+ description: "Apply only if the settings are still at this revision (the one `tula diff` printed)."
1921
+ }
1922
+ };
1923
+ function warningBeforeQuestion(plan) {
1924
+ const removed = removedWebhooks(plan);
1925
+ const removes = removed === null ? "" : `This REMOVES ${removed}, for good. `;
1926
+ const paths = plan.weakened.join(", ");
1927
+ const doomed = deletedAuditAge(plan);
1928
+ if (doomed === null) {
1929
+ return `${paths ? `This WEAKENS security (${paths}). ` : ""}${removes}`;
1930
+ }
1931
+ const deletes = `DELETES audit entries older than ${doomed} days, for good`;
1932
+ return plan.weakened.length === 1 ? `This ${deletes} (${paths}). ${removes}` : `This WEAKENS security (${paths}) and ${deletes}. ${removes}`;
1933
+ }
1934
+ function byKind(created) {
1935
+ return {
1936
+ webhooks: created.filter((entry) => entry.hook === undefined),
1937
+ hooks: created.filter((entry) => entry.hook !== undefined)
1938
+ };
1939
+ }
1940
+ function secretChoice(context) {
1941
+ const { flags, io } = context;
1942
+ const path = flags["secrets-file"];
1943
+ const choice = {
1944
+ file: typeof path === "string" ? resolve(io.cwd, path) : undefined,
1945
+ show: flags["show-secrets"] === true,
1946
+ discard: flags["discard-secrets"] === true
1947
+ };
1948
+ if (choice.discard && (choice.show || choice.file !== undefined)) {
1949
+ throw new UsageError2("--discard-secrets cannot be combined with --secrets-file or --show-secrets: keep the signing secrets or do not.");
1950
+ }
1951
+ if (path === "-" || path === "") {
1952
+ throw new UsageError2("--secrets-file takes the path of a new file.");
1953
+ }
1954
+ return choice;
1955
+ }
1956
+ var NO_SECRETS = `[]
1957
+ `;
1958
+ async function secretsHost(context, file) {
1959
+ const host = context.io.host;
1960
+ if (!host) {
1961
+ throw new UsageError2("--secrets-file needs a file system, which this run does not have.");
1962
+ }
1963
+ let present;
1964
+ try {
1965
+ present = await host.readFile(file);
1966
+ } catch (error) {
1967
+ if (error instanceof UsageError2) {
1968
+ throw new UsageError2(`--secrets-file: ${error.message} Nothing was changed.`);
1969
+ }
1970
+ throw error;
1971
+ }
1972
+ if (present !== null) {
1973
+ throw new UsageError2(`--secrets-file: ${basename(file)} already exists. It may hold the signing secrets of an earlier run, which are shown only once: name a file that does not exist. Nothing was changed.`);
1974
+ }
1975
+ return host;
1976
+ }
1977
+ var LATER = {
1978
+ webhook: "To get a secret later, rotate it: for the 24 hours of the overlap deliveries are then " + "also signed with the first secret, which nobody holds. That is harmless.",
1979
+ hook: "A hook\u2019s secret cannot be rotated: to get one later, remove the hook and add it again " + "(its receiver cannot verify a question until then)."
1980
+ };
1981
+ var NEEDS_SECRET_CHOICE = (endpoints, hooks) => {
1982
+ const created = endpoints + hooks;
1983
+ const what = [
1984
+ endpoints > 0 ? `${endpoints} webhook ${endpoints === 1 ? "endpoint" : "endpoints"}` : "",
1985
+ hooks > 0 ? `${hooks} ${hooks === 1 ? "hook" : "hooks"}` : ""
1986
+ ].filter((part) => part !== "").join(" and ");
1987
+ const later = [endpoints > 0 ? LATER.webhook : "", hooks > 0 ? LATER.hook : ""].filter((part) => part !== "").join(" ");
1988
+ return `This plan creates ${what}, and the server shows ` + `${created === 1 ? "its signing secret" : "each signing secret"} only once, in its answer to this run. ` + `Nothing was changed. Say what to do with ${created === 1 ? "it" : "them"}:
1989
+ ` + ` --secrets-file <path> write to a new file only you can read (mode 0600)
1990
+ ` + ` --show-secrets print on standard output
1991
+ ` + ` --discard-secrets keep nothing. ${later}`;
1992
+ };
1993
+
1994
+ class StaleWebhooks extends Error {
1995
+ }
1996
+
1997
+ class UnreadWebhooks extends Error {
1998
+ reason;
1999
+ constructor(reason) {
2000
+ super("the webhook endpoints could not be read again");
2001
+ this.reason = reason;
2002
+ }
2003
+ }
2004
+ var UNREAD_WEBHOOKS = "The webhook endpoints could not be read again before the first write to them, so nothing " + "was written to them.";
2005
+ var STALE_WEBHOOKS = "The webhook endpoints were changed by someone else after this plan was made. Nothing was " + "written to them. Run `tula diff` again and review the new plan.";
2006
+
2007
+ class StaleHooks extends Error {
2008
+ }
2009
+
2010
+ class UnreadHooks extends Error {
2011
+ reason;
2012
+ constructor(reason) {
2013
+ super("the hooks could not be read again");
2014
+ this.reason = reason;
2015
+ }
2016
+ }
2017
+ var UNREAD_HOOKS = "The hooks could not be read again before the first write to them, so nothing was written " + "to them.";
2018
+ var STALE_HOOKS = "The hooks were changed by someone else after this plan was made. Nothing was written to " + "them. Run `tula diff` again and review the new plan.";
2019
+
2020
+ class StaleNativeApps extends Error {
2021
+ constructor() {
2022
+ super("the native apps changed after the plan was made");
2023
+ }
2024
+ }
2025
+
2026
+ class UnreadNativeApps extends Error {
2027
+ reason;
2028
+ constructor(reason) {
2029
+ super("the native apps could not be read again");
2030
+ this.reason = reason;
2031
+ }
2032
+ }
2033
+ var UNREAD_NATIVE_APPS = "The native apps could not be read again before the first write to them, so nothing was " + "written to them.";
2034
+ var STALE_NATIVE_APPS = "The native apps were changed by someone else after this plan was made. Nothing was written " + "to them. Run `tula diff` again and review the new plan.";
2035
+ async function runNativeAppOperation(admin, operation) {
2036
+ if (operation.kind === "nativeApp.delete") {
2037
+ await admin.call("deleteNativeApp", { params: { id: operation.id } });
2038
+ return;
2039
+ }
2040
+ const { entry } = operation;
2041
+ if (operation.kind === "nativeApp.create") {
2042
+ await admin.call("createNativeApp", { body: entry });
2043
+ return;
2044
+ }
2045
+ const differs = (path) => operation.change.fields.some((field) => field.path === path);
2046
+ await admin.call("updateNativeApp", {
2047
+ params: { id: operation.id },
2048
+ body: {
2049
+ ...entry.platform === "ios" && differs("teamId") && { teamId: entry.teamId },
2050
+ ...entry.platform === "android" && differs("sha256CertFingerprints") && {
2051
+ sha256CertFingerprints: entry.sha256CertFingerprints
2052
+ },
2053
+ ...differs("appLinkPaths") && entry.appLinkPaths !== undefined && { appLinkPaths: entry.appLinkPaths }
2054
+ }
2055
+ });
2056
+ }
2057
+ async function runHookOperation(admin, environment, operation) {
2058
+ if (operation.kind === "hook.delete") {
2059
+ await admin.call("deleteHook", { params: { id: operation.id } });
2060
+ return;
2061
+ }
2062
+ const desired = environment.hooks?.[operation.point];
2063
+ if (!desired) {
2064
+ throw new Error("a hook operation has no entry in the config");
2065
+ }
2066
+ if (operation.kind === "hook.update") {
2067
+ const changed = (path) => operation.change.fields.some((field) => field.path === path);
2068
+ await admin.call("updateHook", {
2069
+ params: { id: operation.id },
2070
+ body: {
2071
+ ...changed("url") && { url: desired.url },
2072
+ ...changed("enabled") && { enabled: desired.enabled },
2073
+ ...changed("deadlineMs") && { deadlineMs: desired.deadlineMs },
2074
+ ...changed("failureMode") && { failureMode: desired.failureMode }
2075
+ }
2076
+ });
2077
+ return;
2078
+ }
2079
+ const answer = await admin.call("createHook", {
2080
+ body: {
2081
+ point: operation.point,
2082
+ url: desired.url,
2083
+ enabled: desired.enabled,
2084
+ deadlineMs: desired.deadlineMs,
2085
+ failureMode: desired.failureMode
2086
+ }
2087
+ });
2088
+ return { hook: operation.point, id: answer.data.id, url: desired.url, secret: answer.data.secret };
2089
+ }
2090
+ function resolveSecrets(context, environment, operations) {
2091
+ const secrets = new Map;
2092
+ for (const operation of operations) {
2093
+ if (operation.kind === "provider.set" && operation.change.secret === "set") {
2094
+ const ref = providerSecret2(environment.providers, operation.provider);
2095
+ if (ref) {
2096
+ const value = resolveSecret(ref, context.io.env);
2097
+ context.output.redact(value);
2098
+ secrets.set(operation.provider, value);
2099
+ }
2100
+ }
2101
+ }
2102
+ return secrets;
2103
+ }
2104
+ function webhookBody(environment, operation) {
2105
+ const desired = environment.webhooks?.find((endpoint) => endpoint.url === operation.url);
2106
+ if (!desired) {
2107
+ throw new Error("a webhook operation has no entry in the config");
2108
+ }
2109
+ const changed = (path) => operation.change.fields.some((field) => field.path === path);
2110
+ return {
2111
+ desired,
2112
+ update: {
2113
+ ...changed("eventTypes") && { eventTypes: desired.eventTypes },
2114
+ ...changed("enabled") && { enabled: desired.enabled }
2115
+ }
2116
+ };
2117
+ }
2118
+ async function runWebhookOperation(admin, environment, operation) {
2119
+ if (operation.kind === "webhook.delete") {
2120
+ await admin.call("deleteWebhookEndpoint", { params: { id: operation.id } });
2121
+ return;
2122
+ }
2123
+ const { desired, update } = webhookBody(environment, operation);
2124
+ if (operation.kind === "webhook.update") {
2125
+ await admin.call("updateWebhookEndpoint", { params: { id: operation.id }, body: update });
2126
+ return;
2127
+ }
2128
+ const answer = await admin.call("createWebhookEndpoint", {
2129
+ body: {
2130
+ url: desired.url,
2131
+ eventTypes: desired.eventTypes,
2132
+ ...desired.enabled !== undefined && { enabled: desired.enabled }
2133
+ }
2134
+ });
2135
+ return { id: answer.data.id, url: desired.url, secret: answer.data.secret };
2136
+ }
2137
+ async function runOperation(admin, plan, environment, secrets, operation) {
2138
+ if (operation.kind === "settings") {
2139
+ const answer = await admin.call("replaceEnvironmentSettings", {
2140
+ headers: {
2141
+ "If-Match": ifMatch(plan.revision),
2142
+ ...plan.marker.supported && {
2143
+ [CONFIG_MANAGED_BY_HEADER]: MANAGING_TOOL2,
2144
+ [CONFIG_HASH_HEADER]: plan.marker.configHash
2145
+ }
2146
+ },
2147
+ body: plan.body
2148
+ });
2149
+ return answer.data.revision;
2150
+ }
2151
+ if (operation.kind === "provider.delete") {
2152
+ await admin.call("deleteOAuthProvider", { params: { provider: operation.provider } });
2153
+ return;
2154
+ }
2155
+ const secret = secrets.get(operation.provider);
2156
+ if (operation.provider === "apple") {
2157
+ const apple = environment.providers.apple;
2158
+ if (apple) {
2159
+ await admin.call("updateOAuthProvider", {
2160
+ params: { provider: "apple" },
2161
+ body: {
2162
+ clientId: apple.clientId,
2163
+ teamId: apple.teamId,
2164
+ keyId: apple.keyId,
2165
+ enabled: apple.enabled,
2166
+ ...secret !== undefined && { privateKey: secret }
2167
+ }
2168
+ });
2169
+ }
2170
+ return;
2171
+ }
2172
+ if (operation.provider === "microsoft") {
2173
+ const microsoft = environment.providers.microsoft;
2174
+ if (microsoft) {
2175
+ await admin.call("updateOAuthProvider", {
2176
+ params: { provider: "microsoft" },
2177
+ body: {
2178
+ clientId: microsoft.clientId,
2179
+ tenant: microsoft.tenant,
2180
+ enabled: microsoft.enabled,
2181
+ ...secret !== undefined && { clientSecret: secret }
2182
+ }
2183
+ });
2184
+ }
2185
+ return;
2186
+ }
2187
+ const client = environment.providers[operation.provider];
2188
+ if (client) {
2189
+ await admin.call("updateOAuthProvider", {
2190
+ params: { provider: operation.provider },
2191
+ body: {
2192
+ clientId: client.clientId,
2193
+ enabled: client.enabled,
2194
+ ..."additionalClientIds" in client && client.additionalClientIds !== undefined && {
2195
+ additionalClientIds: client.additionalClientIds
2196
+ },
2197
+ ...secret !== undefined && { clientSecret: secret }
2198
+ }
2199
+ });
2200
+ }
2201
+ return;
2202
+ }
2203
+ function expectedRevision(flag) {
2204
+ if (flag === undefined) {
2205
+ return;
2206
+ }
2207
+ if (typeof flag !== "string" || !/^(0|[1-9][0-9]{0,8})$/.test(flag)) {
2208
+ throw new UsageError2("--expect-revision takes a revision number, e.g. --expect-revision 7.");
2209
+ }
2210
+ return Number(flag);
2211
+ }
2212
+ var STALE = "The settings were changed by someone else after this plan was made. Nothing was written to " + "the settings. Run `tula diff` again and review the new plan.";
2213
+ var applyCommand2 = {
2214
+ name: "apply",
2215
+ summary: "Make the environment what tula.config.ts says.",
2216
+ usage: "tula apply [--env <name>] [--config <path>] [--yes] [--prune] [--rotate-secrets] [--expect-revision <n>] [--allow-unknown] [--allow-weaker] [--allow-webhook-removal] [--secrets-file <path> | --show-secrets | --discard-secrets] [--json]",
2217
+ description: "Prints the plan, asks for confirmation, then replaces the environment\u2019s settings (only if " + "nobody changed them since the plan was made) and creates, updates or deletes OAuth " + "providers and, when the file lists them, webhook endpoints, hooks and native apps. Provider secrets " + "are read from the environment variables the config names. The signing secret of a new " + "webhook endpoint or a new hook is made by the server and shown once: the run needs " + `--secrets-file, --show-secrets or --discard-secrets.
2218
+
2219
+ ` + "It refuses a plan that would reset settings this version of tula does not know (unless " + "--allow-unknown), and with --yes a plan that weakens security (unless --allow-weaker) or " + `removes a webhook endpoint (unless --allow-webhook-removal).
2220
+
2221
+ ` + "Exit codes: 0 applied (or nothing to do), 1 an error or a declined confirmation.",
2222
+ options: APPLY_OPTIONS,
2223
+ run: async (context) => {
2224
+ const { flags, io, output } = context;
2225
+ const json = flags.json === true;
2226
+ const expected = expectedRevision(flags["expect-revision"]);
2227
+ if (flags["secret-key-file"] === "-" && flags.yes !== true) {
2228
+ throw new UsageError2("The secret key is read from standard input, so standard input cannot also answer the " + "confirmation: pass --yes as well. Nothing was read or changed.");
2229
+ }
2230
+ const choice = secretChoice(context);
2231
+ const { name, environment, target, plan } = await prepare(context);
2232
+ const header = { environment: name, apiUrl: target.apiUrl };
2233
+ if (!json) {
2234
+ renderPlan2(output, header, plan);
2235
+ }
2236
+ const operations = orderOperations2(plan);
2237
+ const created = [];
2238
+ const notKept = [];
2239
+ const report = (applied, failed, revision) => {
2240
+ if (json) {
2241
+ const notApplied = operations.slice(applied.length + (failed ? 1 : 0));
2242
+ output.line(JSON.stringify({
2243
+ ...planToJson2(header, plan),
2244
+ applied: applied.map(describeOperation2),
2245
+ failed: failed ? describeOperation2(failed) : null,
2246
+ notApplied: notApplied.map(describeOperation2),
2247
+ revisionAfter: revision,
2248
+ ...choice.file !== undefined && created.length > 0 && { secretsFile: choice.file },
2249
+ ...notKept.length > 0 && { secretsNotKept: notKept },
2250
+ ...choice.show && {
2251
+ webhookSecrets: byKind(created).webhooks,
2252
+ hookSecrets: byKind(created).hooks
2253
+ }
2254
+ }, null, 2));
2255
+ }
2256
+ };
2257
+ if (expected !== undefined && expected !== plan.revision) {
2258
+ output.error(`${output.errorStyle.red("error:")} The settings are at revision ${plan.revision}, not ` + `${expected}: they were changed after that plan was made. Nothing was changed. Run ` + "`tula diff` again and review the new plan.");
2259
+ return EXIT2.error;
2260
+ }
2261
+ const blockers = planBlockers(plan);
2262
+ for (const blocker of blockers) {
2263
+ const advice = blocker.includes("may have") ? ` ${OVER_LIMIT_ADVICE}` : "";
2264
+ output.error(`${output.errorStyle.red("error:")} ${blocker} Nothing was changed.${advice}`);
2265
+ }
2266
+ if (blockers.length > 0) {
2267
+ return EXIT2.error;
2268
+ }
2269
+ if (operations.length === 0) {
2270
+ if (json) {
2271
+ report([], undefined, plan.revision);
2272
+ } else {
2273
+ output.line("No changes: the environment is as the config says.");
2274
+ }
2275
+ return EXIT2.ok;
2276
+ }
2277
+ if (plan.unknown.length > 0 && flags["allow-unknown"] !== true) {
2278
+ output.error(`${output.errorStyle.red("error:")} The server has settings this version of tula does ` + `not know (${plan.unknown.join(", ")}); applying would reset them to their defaults. ` + "Nothing was changed. Upgrade tula, or pass --allow-unknown to reset them.");
2279
+ return EXIT2.error;
2280
+ }
2281
+ if (flags.yes === true && plan.weakened.length > 0 && flags["allow-weaker"] !== true) {
2282
+ output.error(`${output.errorStyle.red("error:")} This plan weakens security ` + `(${plan.weakened.join(", ")}), and with --yes nobody is asked. Nothing was changed. ` + "Pass --allow-weaker with --yes to apply it.");
2283
+ return EXIT2.error;
2284
+ }
2285
+ const removed = removedWebhooks(plan);
2286
+ if (flags.yes === true && removed !== null && flags["allow-webhook-removal"] !== true) {
2287
+ output.error(`${output.errorStyle.red("error:")} This plan removes ${removed}, for good, and with ` + "--yes nobody is asked. Nothing was changed. Pass --allow-webhook-removal with --yes " + "to apply it.");
2288
+ return EXIT2.error;
2289
+ }
2290
+ const endpoints = webhookCounts(plan).created;
2291
+ const hooks = hookCounts(plan).created;
2292
+ const creating = endpoints + hooks;
2293
+ if (creating > 0 && !choice.show && !choice.discard && choice.file === undefined) {
2294
+ output.error(`${output.errorStyle.red("error:")} ${NEEDS_SECRET_CHOICE(endpoints, hooks)}`);
2295
+ return EXIT2.error;
2296
+ }
2297
+ const secretsFile = creating > 0 && choice.file !== undefined ? { path: choice.file, host: await secretsHost(context, choice.file) } : undefined;
2298
+ const secrets = resolveSecrets(context, environment, operations);
2299
+ if (flags.yes !== true) {
2300
+ if (!io.isTTY || !io.prompt) {
2301
+ throw new UsageError2("Not at a terminal, so there is nobody to confirm: pass --yes to apply without asking. Nothing was changed.");
2302
+ }
2303
+ const answer = await io.prompt(`${warningBeforeQuestion(plan)}Apply these changes to "${name}" at ${target.apiUrl}? Type yes to continue: `);
2304
+ if (answer.trim().toLowerCase() !== "yes") {
2305
+ output.error("Cancelled. Nothing was changed.");
2306
+ return EXIT2.error;
2307
+ }
2308
+ }
2309
+ if (operations[0]?.kind !== "settings" && operations.some((op) => op.kind === "settings")) {
2310
+ const now = await target.admin.call("getEnvironmentSettings");
2311
+ if (now.data.revision !== plan.revision) {
2312
+ output.error(`${output.errorStyle.red("error:")} ${STALE}`);
2313
+ return EXIT2.error;
2314
+ }
2315
+ }
2316
+ let inFile = NO_SECRETS;
2317
+ let written = 0;
2318
+ const keep = async (file) => {
2319
+ if (await file.host.readFile(file.path) !== inFile) {
2320
+ throw new Error("the secrets file is no longer the one this run created");
2321
+ }
2322
+ const next = `${JSON.stringify(created, null, 2)}
2323
+ `;
2324
+ await file.host.writeSecretFile(file.path, next);
2325
+ inFile = next;
2326
+ written = created.length;
2327
+ };
2328
+ if (secretsFile) {
2329
+ try {
2330
+ await secretsFile.host.createSecretFile(secretsFile.path, NO_SECRETS);
2331
+ } catch (error) {
2332
+ const why = error instanceof UsageError2 ? error.message : "It could not be created.";
2333
+ output.error(`${output.errorStyle.red("error:")} --secrets-file: ${why} Nothing was changed.`);
2334
+ return EXIT2.error;
2335
+ }
2336
+ }
2337
+ const release = async () => {
2338
+ if (!secretsFile || written > 0) {
2339
+ return;
2340
+ }
2341
+ try {
2342
+ if (await secretsFile.host.readFile(secretsFile.path) === NO_SECRETS) {
2343
+ await secretsFile.host.removeFile(secretsFile.path);
2344
+ }
2345
+ } catch {}
2346
+ };
2347
+ const secretsNote = (say) => {
2348
+ const signing = (count) => `${count} signing ${count === 1 ? "secret" : "secrets"}`;
2349
+ if (secretsFile && written > 0) {
2350
+ say(`Wrote ${signing(written)} to ${secretsFile.path} (mode 0600). It is not shown again: give it to the receiver, then delete the file.`);
2351
+ } else if (choice.discard && created.length > 0) {
2352
+ const { webhooks, hooks } = byKind(created);
2353
+ const were = (count) => count === 1 ? "was" : "were";
2354
+ if (webhooks.length > 0) {
2355
+ say(`${signing(webhooks.length)} ${were(webhooks.length)} not kept (--discard-secrets). To get one, rotate it: POST /v1/admin/webhook-endpoints/<id>/secret/rotate.`);
2356
+ }
2357
+ if (hooks.length > 0) {
2358
+ say(`${signing(hooks.length)} of ${hooks.length === 1 ? "a hook" : "hooks"} ${were(hooks.length)} not kept (--discard-secrets). A hook\u2019s secret cannot be rotated: to get one, remove the hook and apply again.`);
2359
+ }
2360
+ }
2361
+ };
2362
+ const applied = [];
2363
+ let revision = plan.revision;
2364
+ let webhooksChecked = false;
2365
+ let hooksChecked = false;
2366
+ let nativeAppsChecked = false;
2367
+ const stop = async (failed) => {
2368
+ const notApplied = operations.slice(applied.length);
2369
+ if (applied.length > 0) {
2370
+ output.error("Applied before the failure:");
2371
+ for (const done of applied) {
2372
+ output.error(` ${describeOperation2(done)}`);
2373
+ }
2374
+ }
2375
+ if (notApplied.length > 0) {
2376
+ output.error("Not applied:");
2377
+ for (const pending of notApplied) {
2378
+ output.error(` ${describeOperation2(pending)}`);
2379
+ }
2380
+ }
2381
+ if (applied.length > 0 && notApplied.length > 0) {
2382
+ output.error("Run `tula apply` again to finish: it starts from what the server has now.");
2383
+ }
2384
+ secretsNote((text) => output.error(text));
2385
+ await release();
2386
+ report(applied, failed, revision);
2387
+ return EXIT2.error;
2388
+ };
2389
+ for (const operation of operations) {
2390
+ let unkept;
2391
+ const take = async (made) => {
2392
+ if (!choice.show) {
2393
+ output.redact(made.secret);
2394
+ }
2395
+ created.push(made);
2396
+ if (secretsFile) {
2397
+ await keep(secretsFile).catch(() => {
2398
+ unkept = made;
2399
+ });
2400
+ }
2401
+ };
2402
+ try {
2403
+ if (operation.kind === "hook.create" || operation.kind === "hook.update" || operation.kind === "hook.delete") {
2404
+ if (!hooksChecked) {
2405
+ const now = await target.admin.call("listHooks").catch((reason) => {
2406
+ throw new UnreadHooks(reason);
2407
+ });
2408
+ if (hookSnapshot(now.data.data) !== plan.hooks.seen) {
2409
+ throw new StaleHooks;
2410
+ }
2411
+ hooksChecked = true;
2412
+ }
2413
+ const made = await runHookOperation(target.admin, environment, operation);
2414
+ if (made) {
2415
+ await take(made);
2416
+ }
2417
+ } else if (operation.kind === "nativeApp.create" || operation.kind === "nativeApp.update" || operation.kind === "nativeApp.delete") {
2418
+ if (!nativeAppsChecked) {
2419
+ const now = await target.admin.call("listNativeApps").catch((reason) => {
2420
+ throw new UnreadNativeApps(reason);
2421
+ });
2422
+ if (nativeAppSnapshot(now.data.data) !== plan.nativeApps.seen) {
2423
+ throw new StaleNativeApps;
2424
+ }
2425
+ nativeAppsChecked = true;
2426
+ }
2427
+ await runNativeAppOperation(target.admin, operation);
2428
+ } else if (operation.kind === "webhook.create" || operation.kind === "webhook.update" || operation.kind === "webhook.delete") {
2429
+ if (!webhooksChecked) {
2430
+ const now = await target.admin.call("listWebhookEndpoints").catch((reason) => {
2431
+ throw new UnreadWebhooks(reason);
2432
+ });
2433
+ if (webhookSnapshot(now.data.data) !== plan.webhooks.seen) {
2434
+ throw new StaleWebhooks;
2435
+ }
2436
+ webhooksChecked = true;
2437
+ }
2438
+ const made = await runWebhookOperation(target.admin, environment, operation);
2439
+ if (made) {
2440
+ await take(made);
2441
+ }
2442
+ } else {
2443
+ revision = await runOperation(target.admin, plan, environment, secrets, operation) ?? revision;
2444
+ }
2445
+ } catch (error) {
2446
+ const stale = operation.kind === "settings" && isTulaAdminError5(error) && error.code === "precondition.failed";
2447
+ if (stale) {
2448
+ output.error(`${output.errorStyle.red("error:")} ${STALE}`);
2449
+ } else if (error instanceof StaleWebhooks) {
2450
+ output.error(`${output.errorStyle.red("error:")} ${STALE_WEBHOOKS}`);
2451
+ } else if (error instanceof UnreadWebhooks) {
2452
+ output.error(`${output.errorStyle.red("error:")} ${UNREAD_WEBHOOKS}`);
2453
+ reportError2(output, error.reason);
2454
+ } else if (error instanceof StaleHooks) {
2455
+ output.error(`${output.errorStyle.red("error:")} ${STALE_HOOKS}`);
2456
+ } else if (error instanceof UnreadHooks) {
2457
+ output.error(`${output.errorStyle.red("error:")} ${UNREAD_HOOKS}`);
2458
+ reportError2(output, error.reason);
2459
+ } else if (error instanceof StaleNativeApps) {
2460
+ output.error(`${output.errorStyle.red("error:")} ${STALE_NATIVE_APPS}`);
2461
+ } else if (error instanceof UnreadNativeApps) {
2462
+ output.error(`${output.errorStyle.red("error:")} ${UNREAD_NATIVE_APPS}`);
2463
+ reportError2(output, error.reason);
2464
+ } else {
2465
+ output.error(`Failed: ${describeOperation2(operation)}`);
2466
+ reportError2(output, error);
2467
+ const reason = isTulaAdminError5(error) ? error.params.reason : undefined;
2468
+ const judged = operation.kind === "webhook.create" || operation.kind === "hook.create" || operation.kind === "hook.update";
2469
+ if (judged && typeof reason === "string") {
2470
+ output.error(` reason: ${printable2(reason, 60)}`);
2471
+ }
2472
+ }
2473
+ return stop(operation);
2474
+ }
2475
+ applied.push(operation);
2476
+ if (!json) {
2477
+ output.line(output.style.green(` done ${describeOperation2(operation)}`));
2478
+ const creates = operation.kind === "webhook.create" || operation.kind === "hook.create";
2479
+ const made = creates ? created.at(-1) : undefined;
2480
+ if (made && choice.show) {
2481
+ output.line(` signing secret, shown this once: ${made.secret}`);
2482
+ }
2483
+ }
2484
+ if (unkept !== undefined && secretsFile) {
2485
+ const lost = unkept;
2486
+ const what = lost.hook === undefined ? `The webhook endpoint ${printable2(lost.url, MAX_WEBHOOK_URL_LENGTH2)}` : `The hook ${lost.hook}`;
2487
+ const later = lost.hook === undefined ? `To get one, rotate it: POST /v1/admin/webhook-endpoints/${printable2(lost.id, 40)}/secret/rotate.` : "A hook\u2019s secret cannot be rotated: to get one, remove the hook and apply again.";
2488
+ notKept.push({
2489
+ ...lost.hook !== undefined && { hook: lost.hook },
2490
+ id: lost.id,
2491
+ url: lost.url
2492
+ });
2493
+ output.error(`${output.errorStyle.red("error:")} ${what} was created, but its signing secret could not be written to ${secretsFile.path}: it was not kept. ${later}`);
2494
+ return stop(undefined);
2495
+ }
2496
+ }
2497
+ if (json) {
2498
+ report(applied, undefined, revision);
2499
+ } else {
2500
+ output.line();
2501
+ output.line(`Applied ${applied.length} ${applied.length === 1 ? "change" : "changes"}. ` + `Settings are at revision ${revision}.`);
2502
+ secretsNote((text) => output.line(text));
2503
+ }
2504
+ await release();
2505
+ return EXIT2.ok;
2506
+ }
2507
+ };
2508
+
2509
+ // src/commands/approve.ts
2510
+ import { isTulaAdminError as isTulaAdminError6 } from "@tula-tech/admin";
2511
+ import { OAUTH_PROVIDERS as OAUTH_PROVIDERS2 } from "@tula-tech/contract";
2512
+ var PROPOSAL_ID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
2513
+ var CONSEQUENCES = {
2514
+ ban_user: "The user is banned at once: every session of theirs ends and they cannot sign in.",
2515
+ unban_user: "The user\u2019s ban is lifted at once: they can sign in again. Sessions that were ended stay ended.",
2516
+ end_session: "That one session is ended at once: whoever holds it is signed out and has to sign in again.",
2517
+ disable_oauth_provider: "Sign-in with that provider is switched off at once: nobody signs in or signs up with it until it is switched on again. Its credentials are kept."
2518
+ };
2519
+ var TARGET_WORDS = {
2520
+ user: "user",
2521
+ session: "session",
2522
+ oauth_provider: "provider"
2523
+ };
2524
+ function targetWord(proposal) {
2525
+ const type = String(proposal.target.type);
2526
+ return Object.hasOwn(TARGET_WORDS, type) ? TARGET_WORDS[type] : printable2(type, 40);
2527
+ }
2528
+ var REFUSALS = {
2529
+ expired: "The proposal has expired. Nothing was changed. The agent can ask again.",
2530
+ already_decided: "The proposal was already approved: a proposal is approved once. Nothing was changed by this run.",
2531
+ mismatch: "The proposal is not the one that was shown: its operation or its target differs. Nothing was changed.",
2532
+ agent_key_revoked: "The agent key that asked for this was revoked: what a revoked key asked for is never carried out. Nothing was changed.",
2533
+ production_environment: "This is a production environment, and its settings do not (or no longer) accept proposals from an agent (agents.proposalsInProduction). Nothing was changed, and the proposal is not spent: it can still be approved before it expires if the setting is switched on."
2534
+ };
2535
+ var EXPIRY_WORDS = {
2536
+ time: "expired (it was not approved in time)",
2537
+ agent_key_revoked: "expired (the agent key that asked for it was revoked)"
2538
+ };
2539
+ function failureWords(proposal) {
2540
+ const spent = "The proposal is spent: it cannot be approved again.";
2541
+ const reason = proposal.failureReason === null ? "" : String(proposal.failureReason);
2542
+ if (reason === "incomplete" && proposal.operation === "ban_user") {
2543
+ const id = printable2(proposal.target.id, 64);
2544
+ return "Approved, and only partly carried out. The user is banned, and ending their sessions failed: " + "a session of theirs may still be live until its access token runs out, and is refused at its next refresh. " + `End them now: DELETE /v1/admin/users/${id}/sessions with your secret key, or the user\u2019s page in the dashboard. ${spent}`;
2545
+ }
2546
+ if (reason === "incomplete") {
2547
+ return `Approved, and only partly carried out: the change was made and a later step failed. Check the target. ${spent}`;
2548
+ }
2549
+ const what = targetWord(proposal);
2550
+ if (reason === "target_not_found") {
2551
+ return `Approved, but nothing was changed: the ${what} no longer exists. ${spent}`;
2552
+ }
2553
+ if (reason === "last_sign_in_method") {
2554
+ return "Approved, but nothing was changed: switching this provider off would leave the environment " + `with no way to sign in, which the server refuses for an administrator too. The provider is still on. ${spent}`;
2555
+ }
2556
+ return `Approved, but the operation failed on the server (${reason === "" ? "unknown" : printable2(reason, 40)}). ` + `Check the ${what} as it is now, and have the agent ask again if the change is still wanted. ${spent}`;
2557
+ }
2558
+ var STATUS_WORDS = {
2559
+ pending: "waiting for approval",
2560
+ expired: "expired",
2561
+ approved: "approved, its operation not finished",
2562
+ executed: "approved and carried out",
2563
+ failed: "approved, and its operation did not end as carried out"
2564
+ };
2565
+ function statusWords(proposal) {
2566
+ const status = String(proposal.status);
2567
+ if (status === "expired") {
2568
+ const reason = proposal.expiryReason === null ? "time" : String(proposal.expiryReason);
2569
+ return Object.hasOwn(EXPIRY_WORDS, reason) ? EXPIRY_WORDS[reason] : "expired";
2570
+ }
2571
+ if (status === "failed" && proposal.failureReason === "incomplete") {
2572
+ return "approved, and only partly carried out";
2573
+ }
2574
+ return Object.hasOwn(STATUS_WORDS, status) ? STATUS_WORDS[status] : printable2(status, 40);
2575
+ }
2576
+ function timeLeft(expiresAt, now) {
2577
+ const ms = Date.parse(expiresAt) - now.getTime();
2578
+ if (!Number.isFinite(ms) || ms <= 0) {
2579
+ return "now";
2580
+ }
2581
+ const seconds = Math.ceil(ms / 1000);
2582
+ return seconds >= 60 ? `in about ${Math.round(seconds / 60)} min` : `in ${seconds} s`;
2583
+ }
2584
+ async function describeProvider(admin, id) {
2585
+ const { data } = await admin.call("listOAuthProviders", {});
2586
+ const found = data.data.find((one) => one.provider === id);
2587
+ const name = printable2(id, 64);
2588
+ if (!found?.configured) {
2589
+ return { text: `provider ${name} (not configured now)`, providerOn: false };
2590
+ }
2591
+ return {
2592
+ text: `provider ${name} (${found.enabled ? "on now" : "already off"})`,
2593
+ providerOn: found.enabled === true
2594
+ };
2595
+ }
2596
+ var NOT_COUNTED = {
2597
+ unread: "the count could not be read",
2598
+ none: "this server has no count"
2599
+ };
2600
+ function dependentsWords(known) {
2601
+ if (known === null || known === DEPENDENTS_UNREAD2) {
2602
+ const why = known === null ? NOT_COUNTED.none : NOT_COUNTED.unread;
2603
+ return `The accounts that can sign in only with this provider could not be counted (${why}): if there are any they are locked out while it is off.`;
2604
+ }
2605
+ const { accounts, more } = known;
2606
+ const when = "Counted just now; the server counts again when it switches the provider off.";
2607
+ if (accounts === 0 && !more) {
2608
+ return `No account was found that can sign in only with this provider. ${when}`;
2609
+ }
2610
+ if (accounts === 0) {
2611
+ return `No account that can sign in only with this provider was found among the accounts the server read, and it did not read them all: the rest were not looked at, so how many there are is not known. ${when}`;
2612
+ }
2613
+ const one = accounts === 1 && !more;
2614
+ const count = `${more ? "At least " : ""}${accounts} ${accounts === 1 ? "account" : "accounts"}`;
2615
+ return `${count} can sign in only with this provider: ${one ? "it is" : "they are"} locked out while it is off. ${when}`;
2616
+ }
2617
+ async function dependentsOf(admin, proposal, about) {
2618
+ if (proposal.operation !== "disable_oauth_provider" || !about.providerOn) {
2619
+ return null;
2620
+ }
2621
+ const provider = OAUTH_PROVIDERS2.find((one) => one === proposal.target.id);
2622
+ if (provider === undefined) {
2623
+ return dependentsWords(DEPENDENTS_UNREAD2);
2624
+ }
2625
+ let known;
2626
+ try {
2627
+ const read = (await readDependents(admin, [provider]))[provider];
2628
+ known = read === undefined ? DEPENDENTS_UNREAD2 : read;
2629
+ } catch {
2630
+ known = DEPENDENTS_UNREAD2;
2631
+ }
2632
+ return dependentsWords(known);
2633
+ }
2634
+ function userLabel(user) {
2635
+ const email = printable2(user.email ?? "", 320);
2636
+ const name = printable2([user.firstName, user.lastName].filter(Boolean).join(" "), 200);
2637
+ return [email || "no email address", name].filter(Boolean).join(", ");
2638
+ }
2639
+ async function describeSessionText(admin, sessionId) {
2640
+ const id = printable2(sessionId, 64);
2641
+ let session;
2642
+ try {
2643
+ session = await readSession(admin, sessionId);
2644
+ } catch (error) {
2645
+ if (isTulaAdminError6(error) && error.code === "resource.not_found") {
2646
+ return `session ${id} (no such session now)`;
2647
+ }
2648
+ return `session ${id} (could not be read)`;
2649
+ }
2650
+ let whose;
2651
+ try {
2652
+ const { data } = await admin.call("getUser", { params: { userId: session.userId } });
2653
+ whose = userLabel(data);
2654
+ } catch (error) {
2655
+ whose = isTulaAdminError6(error) && error.code === "resource.not_found" ? "no such user now" : "the user could not be read";
2656
+ }
2657
+ const owner = printable2(session.userId, 64);
2658
+ const client = printable2(session.client, 40);
2659
+ const since = printable2(session.createdAt, 40);
2660
+ const state = session.active === true ? "live now" : "already ended";
2661
+ return `session ${id} (of user ${owner}: ${whose}; ${client} client, signed in ${since}; ${state})`;
2662
+ }
2663
+ async function readSession(admin, sessionId) {
2664
+ const { data } = await admin.call("getSession", { params: { sessionId } });
2665
+ return data;
2666
+ }
2667
+ async function describeTarget(admin, proposal) {
2668
+ const id = printable2(proposal.target.id, 64);
2669
+ if (proposal.target.type === "oauth_provider") {
2670
+ return describeProvider(admin, proposal.target.id);
2671
+ }
2672
+ const said = (text) => ({ text, providerOn: false });
2673
+ if (proposal.target.type === "session") {
2674
+ return said(await describeSessionText(admin, proposal.target.id));
2675
+ }
2676
+ if (proposal.target.type !== "user") {
2677
+ return said(`${targetWord(proposal)} ${id}`);
2678
+ }
2679
+ try {
2680
+ const { data } = await admin.call("getUser", { params: { userId: proposal.target.id } });
2681
+ return said(`user ${id} (${userLabel(data)}${data.bannedAt ? "; already banned" : ""})`);
2682
+ } catch (error) {
2683
+ if (isTulaAdminError6(error) && error.code === "resource.not_found") {
2684
+ return said(`user ${id} (no such user now)`);
2685
+ }
2686
+ throw error;
2687
+ }
2688
+ }
2689
+ var ALREADY_SO = {
2690
+ unban_user: "the user was not banned",
2691
+ end_session: "the session had already ended",
2692
+ disable_oauth_provider: "the provider was already off"
2693
+ };
2694
+ function render(line, proposal, about, now) {
2695
+ const status = String(proposal.status);
2696
+ line(`Proposal ${printable2(proposal.id, 64)}`);
2697
+ line(` Asked for by an agent (agent key ${printable2(proposal.proposedBy.id, 64)})`);
2698
+ line(` Operation ${printable2(proposal.operation, 40)}`);
2699
+ line(` About ${about}`);
2700
+ line(` What it does ${printable2(proposal.summary, 400)}`);
2701
+ line(` Status ${statusWords(proposal)}`);
2702
+ if (status === "pending") {
2703
+ line(` Expires ${timeLeft(proposal.expiresAt, now)} (${printable2(proposal.expiresAt, 40)})`);
2704
+ }
2705
+ }
2706
+ var NOT_A_TERMINAL = "tula approve asks its question at a terminal and takes the answer typed there: standard " + "input and standard error are not both a terminal, so there is nobody to ask. There is no " + "option that answers for you. Nothing was read or changed.";
2707
+ function toJson(proposal) {
2708
+ return {
2709
+ id: printable2(proposal.id, 64),
2710
+ operation: printable2(proposal.operation, 40),
2711
+ target: {
2712
+ type: printable2(proposal.target.type, 40),
2713
+ id: printable2(proposal.target.id, 64)
2714
+ },
2715
+ summary: printable2(proposal.summary, 400),
2716
+ status: printable2(proposal.status, 40),
2717
+ proposedBy: { type: "agent", id: printable2(proposal.proposedBy.id, 64) },
2718
+ createdAt: printable2(proposal.createdAt, 40),
2719
+ expiresAt: printable2(proposal.expiresAt, 40),
2720
+ approvedBy: proposal.approvedBy ? {
2721
+ type: printable2(proposal.approvedBy.type, 40),
2722
+ id: proposal.approvedBy.id === null ? null : printable2(proposal.approvedBy.id, 64)
2723
+ } : null,
2724
+ ...typeof proposal.changed === "boolean" ? { changed: proposal.changed } : {},
2725
+ approvedAt: proposal.approvedAt === null ? null : printable2(proposal.approvedAt, 40),
2726
+ failureReason: proposal.failureReason === null ? null : printable2(proposal.failureReason, 40),
2727
+ expiryReason: proposal.expiryReason === null ? null : printable2(proposal.expiryReason, 40)
2728
+ };
2729
+ }
2730
+ async function run(context) {
2731
+ const { flags, io, output, positionals } = context;
2732
+ const id = positionals[0];
2733
+ if (id === undefined) {
2734
+ throw new UsageError2("Which proposal? Usage: tula approve <proposal id>");
2735
+ }
2736
+ if (!PROPOSAL_ID.test(id)) {
2737
+ throw new UsageError2("That is not a proposal id (a UUID, as the agent reported it). Nothing was read or changed.");
2738
+ }
2739
+ if (flags["secret-key-file"] === "-") {
2740
+ throw new UsageError2("--secret-key-file - takes the key from standard input, which is where the question of " + "tula approve is answered: put the key in a file or in the environment. Nothing was " + "read or changed.");
2741
+ }
2742
+ const ask = io.isTTY === true ? io.prompt : undefined;
2743
+ if (ask === undefined) {
2744
+ throw new UsageError2(NOT_A_TERMINAL);
2745
+ }
2746
+ const name = typeof flags.env === "string" ? flags.env : "default";
2747
+ const target = await resolveTarget2({ name, environment: {}, flags, io, output }).catch((error) => {
2748
+ if (isTulaAdminError6(error) && error.code === "client.agent_key") {
2749
+ throw new UsageError2("That is an agent key (tula_ak_\u2026). A proposal is approved by a person, with a secret " + "key (tula_sk_\u2026): an agent key cannot approve. Nothing was read or changed.");
2750
+ }
2751
+ throw error;
2752
+ });
2753
+ const now = io.now?.() ?? new Date;
2754
+ const json = flags.json === true;
2755
+ const say = json ? (text) => output.error(text) : (text) => output.line(text);
2756
+ const { data: proposal } = await target.admin.call("getProposal", { params: { id } });
2757
+ const described = await describeTarget(target.admin, proposal);
2758
+ const about = described.text;
2759
+ render(say, proposal, about, now);
2760
+ say("");
2761
+ const consequence = Object.hasOwn(CONSEQUENCES, proposal.operation) ? CONSEQUENCES[proposal.operation] : undefined;
2762
+ if (consequence === undefined) {
2763
+ output.error(`${output.errorStyle.red("error:")} This version of tula does not know the operation of ` + "this proposal, and does not approve what it cannot describe. Nothing was changed.");
2764
+ return EXIT2.error;
2765
+ }
2766
+ if (proposal.status !== "pending") {
2767
+ if (json) {
2768
+ output.line(JSON.stringify(toJson(proposal), null, 2));
2769
+ }
2770
+ output.error(`${output.errorStyle.red("error:")} This proposal is ${statusWords(proposal)}: there is nothing to approve. Nothing was changed by this run.`);
2771
+ return EXIT2.error;
2772
+ }
2773
+ const dependents = await dependentsOf(target.admin, proposal, described);
2774
+ const asked = dependents === null ? consequence : `${consequence} ${dependents}`;
2775
+ const answer = await ask(`${asked} Approve ${printable2(proposal.operation, 40)} for ${about} at ${target.apiUrl}? Type yes to continue: `);
2776
+ if (answer.trim().toLowerCase() !== "yes") {
2777
+ output.error("Cancelled. Nothing was changed.");
2778
+ return EXIT2.error;
2779
+ }
2780
+ let decided;
2781
+ try {
2782
+ const answer = await target.admin.call("approveProposal", {
2783
+ params: { id },
2784
+ body: { operation: proposal.operation, target: proposal.target }
2785
+ });
2786
+ decided = answer.data;
2787
+ } catch (error) {
2788
+ if (isTulaAdminError6(error) && error.code === "resource.conflict") {
2789
+ const reason = typeof error.params.reason === "string" ? error.params.reason : "";
2790
+ const why = Object.hasOwn(REFUSALS, reason) ? REFUSALS[reason] : undefined;
2791
+ if (why !== undefined) {
2792
+ output.error(`${output.errorStyle.red("error:")} ${why}`);
2793
+ return EXIT2.error;
2794
+ }
2795
+ }
2796
+ throw error;
2797
+ }
2798
+ if (json) {
2799
+ output.line(JSON.stringify(toJson(decided), null, 2));
2800
+ }
2801
+ if (decided.status === "executed") {
2802
+ if (decided.changed === false) {
2803
+ const already = Object.hasOwn(ALREADY_SO, decided.operation) ? ALREADY_SO[decided.operation] : "it was already so";
2804
+ say(`Approved and carried out; ${already}, so nothing changed. The proposal is spent.`);
2805
+ return EXIT2.ok;
2806
+ }
2807
+ say(`Approved and carried out. ${consequence}`);
2808
+ return EXIT2.ok;
2809
+ }
2810
+ if (decided.status !== "failed") {
2811
+ output.error(`${output.errorStyle.red("error:")} Approved, and the server could not record how the operation ended. Check the ${targetWord(decided)} as it is now. The proposal is spent: it cannot be approved again.`);
2812
+ return EXIT2.error;
2813
+ }
2814
+ output.error(`${output.errorStyle.red("error:")} ${failureWords(decided)}`);
2815
+ return EXIT2.error;
2816
+ }
2817
+ var approveCommand2 = {
2818
+ name: "approve",
2819
+ summary: "Approve a change an AI agent proposed, after showing what it does.",
2820
+ usage: "tula approve <proposal id> [--env <name>] [--api-url <url>] [--secret-key-file <path>] [--json]",
2821
+ description: "Shows a proposal an agent made through `tula mcp` (what it would do, to whom, and when it " + "expires), asks, then approves it on a typed yes: the operation is carried out at once, " + "and recorded with the agent as its actor and you as the one who approved. A proposal is " + `approved once, and only before it expires.
2822
+
2823
+ ` + "It needs a terminal: the question is asked there and the answer typed, and no option " + "answers it (there is no --yes). Away from a terminal, in a script or in CI, it refuses " + `before it reads or changes anything.
2824
+
2825
+ ` + "The secret key decides the environment and is what makes the approval yours: " + "--secret-key-file, TULA_SECRET_KEY_<NAME> or TULA_SECRET_KEY, never the command line. An " + `agent key cannot approve. Keep the secret key out of the environment the agent runs in.
2826
+
2827
+ ` + "Exit codes: 0 approved and carried out, 1 anything else (declined, expired, already " + `approved, not carried out, an error).
2828
+
2829
+ ` + "What an agent can propose: banning a user, lifting a ban, ending one session, switching " + "one OAuth provider off. A production environment accepts proposals only where its " + "settings say so (agents.proposalsInProduction, off by default).",
2830
+ maxPositionals: 1,
2831
+ options: {
2832
+ env: {
2833
+ type: "string",
2834
+ short: "e",
2835
+ value: "<name>",
2836
+ description: "Read TULA_SECRET_KEY_<NAME> and TULA_API_URL_<NAME> first."
2837
+ },
2838
+ "api-url": {
2839
+ type: "string",
2840
+ value: "<url>",
2841
+ description: "The Tula API. Default: TULA_API_URL_<NAME>, then TULA_API_URL."
2842
+ },
2843
+ "insecure-http": {
2844
+ type: "boolean",
2845
+ description: "Allow a plain http API URL that is not localhost: the secret key then crosses the network in clear text."
2846
+ },
2847
+ "secret-key-file": {
2848
+ type: "string",
2849
+ value: "<path>",
2850
+ description: "Read the secret key from a file (never - for standard input: the answer is typed there). Default: TULA_SECRET_KEY_<NAME>, then TULA_SECRET_KEY."
2851
+ },
2852
+ json: {
2853
+ type: "boolean",
2854
+ description: "Print the proposal as it stands after the run as JSON. The question is still asked, and the proposal shown first, on standard error."
2855
+ }
2856
+ },
2857
+ run
2858
+ };
2859
+
2860
+ // src/dev.ts
2861
+ import { createAdminClient as createAdminClient2, isTulaAdminError as isTulaAdminError7 } from "@tula-tech/admin";
2862
+ import { environmentIssuer } from "@tula-tech/contract/issuer";
2863
+
2864
+ // src/tunnel.ts
2865
+ var FUNNEL_PORT = "443";
2866
+ var TAILNET_HOST = /^(?=.{1,253}$)[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?(?:\.[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?)*\.ts\.net$/;
2867
+ function isRecord2(value) {
2868
+ return typeof value === "object" && value !== null && !Array.isArray(value);
2869
+ }
2870
+ function hostOnFunnelPort(key) {
2871
+ const at = key.lastIndexOf(":");
2872
+ return at > 0 && key.slice(at + 1) === FUNNEL_PORT ? key.slice(0, at).toLowerCase() : null;
2873
+ }
2874
+ function usesFunnelPort(config) {
2875
+ const tcp = isRecord2(config.TCP) ? config.TCP : {};
2876
+ const web = isRecord2(config.Web) ? config.Web : {};
2877
+ return Object.hasOwn(tcp, FUNNEL_PORT) || Object.keys(web).some((key) => hostOnFunnelPort(key) !== null);
2878
+ }
2879
+ var tailscaleFunnel = {
2880
+ name: "Tailscale Funnel",
2881
+ binary: "tailscale",
2882
+ missing: "`tailscale` was not found. `tula dev --tunnel` opens the tunnel with Tailscale Funnel: install Tailscale (https://tailscale.com/download), sign this machine in to your tailnet (`tailscale up`), and run this again. Nothing was started, and .env.local was not changed.",
2883
+ noAddress: "Tailscale did not report a public address for the tunnel in time. Funnel may not be switched on for this machine yet: run `tailscale funnel 443` once by hand (a tailnet needs HTTPS certificates, MagicDNS and the funnel attribute for it), stop it with Ctrl-C, and run this again.",
2884
+ inspect: "tailscale funnel status",
2885
+ closeByHand: `tailscale funnel --https=${FUNNEL_PORT} off`,
2886
+ statusCommand: () => ["tailscale", "funnel", "status", "--json"],
2887
+ openCommand: (route) => [
2888
+ "tailscale",
2889
+ "funnel",
2890
+ "--bg",
2891
+ "--yes",
2892
+ `--https=${FUNNEL_PORT}`,
2893
+ ...route.path === "/" ? [] : [`--set-path=${route.path}`],
2894
+ route.target
2895
+ ],
2896
+ closeCommand: () => ["tailscale", "funnel", "--yes", `--https=${FUNNEL_PORT}`, "off"],
2897
+ readStatus(stdout) {
2898
+ let config;
2899
+ try {
2900
+ config = JSON.parse(stdout.trim() === "" ? "{}" : stdout);
2901
+ } catch {
2902
+ return null;
2903
+ }
2904
+ if (!isRecord2(config)) {
2905
+ return null;
2906
+ }
2907
+ const web = isRecord2(config.Web) ? config.Web : {};
2908
+ const funnel = isRecord2(config.AllowFunnel) ? config.AllowFunnel : {};
2909
+ const endpoints = [];
2910
+ let unreadable = false;
2911
+ for (const key of Object.keys(web)) {
2912
+ const host = hostOnFunnelPort(key);
2913
+ const entry = web[key];
2914
+ const handlers = isRecord2(entry) && isRecord2(entry.Handlers) ? entry.Handlers : {};
2915
+ if (host === null || Object.keys(handlers).length === 0) {
2916
+ continue;
2917
+ }
2918
+ if (!TAILNET_HOST.test(host)) {
2919
+ unreadable = true;
2920
+ continue;
2921
+ }
2922
+ const routes = {};
2923
+ for (const path of Object.keys(handlers)) {
2924
+ const handler = handlers[path];
2925
+ routes[path] = isRecord2(handler) && typeof handler.Proxy === "string" ? handler.Proxy : null;
2926
+ }
2927
+ endpoints.push({ host, public: funnel[key] === true, routes });
2928
+ }
2929
+ const tcp = isRecord2(config.TCP) ? config.TCP : {};
2930
+ const foreground = isRecord2(config.Foreground) ? Object.values(config.Foreground) : [];
2931
+ return {
2932
+ endpoints,
2933
+ taken: unreadable || Object.hasOwn(tcp, FUNNEL_PORT) && endpoints.length === 0 || foreground.some((session) => isRecord2(session) && usesFunnelPort(session))
2934
+ };
2935
+ }
2936
+ };
2937
+ var STATUS_MS = 30000;
2938
+ var CHANGE_MS = 90000;
2939
+ var ADDRESS_TRIES = 15;
2940
+ var sameTarget = (a, b) => a.replace(/\/+$/, "") === b.replace(/\/+$/, "");
2941
+ function judgeTunnel(status, routes) {
2942
+ if (status.taken || status.endpoints.length > 1) {
2943
+ return { kind: "foreign" };
2944
+ }
2945
+ const [endpoint] = status.endpoints;
2946
+ if (!endpoint) {
2947
+ return { kind: "absent" };
2948
+ }
2949
+ for (const [path, target] of Object.entries(endpoint.routes)) {
2950
+ const wanted = routes.find((route) => route.path === path);
2951
+ if (!wanted || target === null || !sameTarget(wanted.target, target)) {
2952
+ return { kind: "foreign" };
2953
+ }
2954
+ }
2955
+ return {
2956
+ kind: "ours",
2957
+ host: endpoint.host,
2958
+ public: endpoint.public,
2959
+ missing: routes.filter((route) => !Object.hasOwn(endpoint.routes, route.path))
2960
+ };
2961
+ }
2962
+ function said(result) {
2963
+ return (result.stderr || result.stdout).trim().split(`
2964
+ `).slice(-6).map((line) => ` ${printable2(line, 300)}`).join(`
2965
+ `);
2966
+ }
2967
+ async function run2(context, command, timeoutMs) {
2968
+ try {
2969
+ return await context.host.run(command, {
2970
+ cwd: context.cwd,
2971
+ timeoutMs,
2972
+ ...context.shielded && { shielded: true }
2973
+ });
2974
+ } catch (error) {
2975
+ if (error.code === "ENOENT") {
2976
+ throw new UsageError2(context.service.missing);
2977
+ }
2978
+ throw error;
2979
+ }
2980
+ }
2981
+ async function tunnelStatus(context) {
2982
+ const { service } = context;
2983
+ const result = await run2(context, service.statusCommand(), STATUS_MS);
2984
+ if (result.timedOut) {
2985
+ throw new UsageError2(`\`${service.inspect}\` did not answer in ${STATUS_MS / 1000}s. Is ${service.binary} running on this machine?`);
2986
+ }
2987
+ if (result.code !== 0) {
2988
+ throw new UsageError2(`\`${service.inspect}\` failed (exit ${String(result.code)}). Is this machine signed in to ${service.name}?
2989
+ ${said(result)}`);
2990
+ }
2991
+ const status = service.readStatus(result.stdout);
2992
+ if (!status) {
2993
+ throw new UsageError2(`\`${service.inspect}\` printed something \`tula dev\` cannot read. Nothing was changed.`);
2994
+ }
2995
+ return status;
2996
+ }
2997
+ async function closeTunnel(context, routes) {
2998
+ try {
2999
+ const before = judgeTunnel(await tunnelStatus(context), routes);
3000
+ if (before.kind !== "ours") {
3001
+ return before.kind === "absent" ? "gone" : "foreign";
3002
+ }
3003
+ await run2(context, context.service.closeCommand(), CHANGE_MS);
3004
+ const after = judgeTunnel(await tunnelStatus(context), routes);
3005
+ return after.kind === "absent" ? "closed" : "failed";
3006
+ } catch (error) {
3007
+ if (error.code === "ABORT") {
3008
+ throw error;
3009
+ }
3010
+ return "failed";
3011
+ }
3012
+ }
3013
+ async function openTunnel(context, routes, beforeOpening) {
3014
+ const { service } = context;
3015
+ const before = judgeTunnel(await tunnelStatus(context), routes);
3016
+ if (before.kind === "foreign") {
3017
+ throw new UsageError2(portInUse(service));
3018
+ }
3019
+ if (before.kind === "ours" && before.public && before.missing.length === 0) {
3020
+ return { host: before.host, opened: false };
3021
+ }
3022
+ const missing = before.kind === "ours" ? before.missing : [...routes];
3023
+ const toOpen = missing.length > 0 ? missing : routes.slice(0, 1);
3024
+ await beforeOpening?.();
3025
+ for (const route of toOpen) {
3026
+ const result = await run2(context, service.openCommand(route), CHANGE_MS);
3027
+ if (result.timedOut) {
3028
+ throw new UsageError2(service.noAddress);
3029
+ }
3030
+ if (result.code !== 0) {
3031
+ throw new UsageError2(`${service.name} refused to open the tunnel (exit ${String(result.code)}). It said:
3032
+ ${said(result)}`);
3033
+ }
3034
+ }
3035
+ for (let attempt = 0;attempt < ADDRESS_TRIES; attempt += 1) {
3036
+ const now = judgeTunnel(await tunnelStatus(context), routes);
3037
+ if (now.kind === "ours" && now.public && now.missing.length === 0) {
3038
+ return { host: now.host, opened: true };
3039
+ }
3040
+ await context.host.sleep(1000);
3041
+ context.halt?.();
3042
+ }
3043
+ throw new UsageError2(service.noAddress);
3044
+ }
3045
+ function portInUse(service) {
3046
+ return `${service.name} already serves something else on this machine's port ${FUNNEL_PORT}, which \`tula dev --tunnel\` would use. It was left alone. \`${service.inspect}\` shows it; \`${service.closeByHand}\` removes it.`;
3047
+ }
3048
+
3049
+ // src/dev.ts
3050
+ var DEV_ENV_FILE2 = ".env.local";
3051
+ var BLOCK_START = "# tula:dev:start";
3052
+ var BLOCK_END = "# tula:dev:end";
3053
+ var BLOCK_NOTE = "# Written by `tula dev`: the local stack\u2019s URL and keys. Lines outside this block are yours.";
3054
+ var API_CONTAINER_PORT = "3003";
3055
+ var MAILPIT_UI_PORT = "8025";
3056
+ var QUICK_MS = 30000;
3057
+ var PUBLIC_URL_VARIABLE = "TULA_PUBLIC_URL";
3058
+ var TUNNEL_VARIABLES = [
3059
+ PUBLIC_URL_VARIABLE,
3060
+ "TULA_ISSUER",
3061
+ "TULA_TUNNEL_TARGET"
3062
+ ];
3063
+ var PROBE_PUBLIC_URL = "https://tula-dev-tunnel-probe.invalid";
3064
+ var COMPOSE_LINE = `PUBLIC_URL: \${${PUBLIC_URL_VARIABLE}:-http://localhost:\${API_PORT:-3003}}`;
3065
+ var TUNNEL_TARGET = /^http:\/\/127\.0\.0\.1:([0-9]{1,5})$/;
3066
+ var ASSOCIATION_FILES = ["apple-app-site-association", "assetlinks.json"];
3067
+ var UUID = /[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/i;
3068
+ var KEY = {
3069
+ publishable: /^tula_pk_[a-z]+_[A-Za-z0-9_-]{16,}$/,
3070
+ secret: /^tula_sk_[a-z]+_[A-Za-z0-9_-]{16,}$/
3071
+ };
3072
+ function findBlock(text) {
3073
+ const start = /^# tula:dev:start[ \t\r]*$/m.exec(text);
3074
+ if (!start) {
3075
+ return null;
3076
+ }
3077
+ const from = start.index + BLOCK_START.length;
3078
+ const end = /^# tula:dev:end[ \t]*$/m.exec(text.slice(from));
3079
+ if (!end) {
3080
+ return null;
3081
+ }
3082
+ return { start: start.index, end: from + end.index + BLOCK_END.length };
3083
+ }
3084
+ function parseDevBlock2(text) {
3085
+ const block = findBlock(text);
3086
+ if (!block) {
3087
+ return null;
3088
+ }
3089
+ const vars = {};
3090
+ for (const line of text.slice(block.start, block.end).split(`
3091
+ `)) {
3092
+ const match = /^([A-Z][A-Z0-9_]*)=(.*)$/.exec(line.trim());
3093
+ if (match) {
3094
+ vars[match[1]] = match[2];
3095
+ }
3096
+ }
3097
+ return vars;
3098
+ }
3099
+ function withoutDevBlock2(text) {
3100
+ const found = findBlock(text);
3101
+ if (!found) {
3102
+ return text;
3103
+ }
3104
+ const after = text.slice(found.end).replace(/^\r?\n/, "");
3105
+ return `${text.slice(0, found.start)}${after}`;
3106
+ }
3107
+ function withDevBlock2(text, vars) {
3108
+ const block = [
3109
+ BLOCK_START,
3110
+ BLOCK_NOTE,
3111
+ ...Object.entries(vars).map(([name, value]) => `${name}=${value}`),
3112
+ BLOCK_END
3113
+ ].join(`
3114
+ `);
3115
+ const found = findBlock(text);
3116
+ if (found) {
3117
+ return `${text.slice(0, found.start)}${block}${text.slice(found.end)}`;
3118
+ }
3119
+ const separator = text === "" || text.endsWith(`
3120
+ `) ? "" : `
3121
+ `;
3122
+ return `${text}${separator}${block}
3123
+ `;
3124
+ }
3125
+ function userValue(text, name) {
3126
+ for (const line of withoutDevBlock2(text).split(`
3127
+ `)) {
3128
+ const match = /^(?:export\s+)?([A-Z][A-Z0-9_]*)=(.*)$/.exec(line.trim());
3129
+ if (match && match[1] === name && match[2].trim() !== "") {
3130
+ return match[2].trim().replace(/^(['"])(.*)\1$/, "$2");
3131
+ }
3132
+ }
3133
+ return;
3134
+ }
3135
+ function tunnelContext(options) {
3136
+ return {
3137
+ host: options.host,
3138
+ cwd: options.io.cwd,
3139
+ service: options.tunnelService ?? tailscaleFunnel
3140
+ };
3141
+ }
3142
+ function tunnelRoutes(target, environmentId) {
3143
+ return [
3144
+ { path: "/", target },
3145
+ ...ASSOCIATION_FILES.map((file) => ({
3146
+ path: `/.well-known/${file}`,
3147
+ target: `${target}/v1/environments/${environmentId}/.well-known/${file}`
3148
+ }))
3149
+ ];
3150
+ }
3151
+ function recordedTunnel(block) {
3152
+ const target = block.TULA_TUNNEL_TARGET ?? "";
3153
+ const environmentId = UUID.exec(block.TULA_ENVIRONMENT_ID ?? "")?.[0];
3154
+ if (!TUNNEL_TARGET.test(target) || !environmentId) {
3155
+ return null;
3156
+ }
3157
+ return tunnelRoutes(target, environmentId);
3158
+ }
3159
+ function servesAnother(status, target, block, previous) {
3160
+ const named = status.endpoints.flatMap((endpoint) => Object.values(endpoint.routes)).map((route) => UUID.exec(route ?? "")?.[0]);
3161
+ const environments = [UUID.exec(block.TULA_ENVIRONMENT_ID ?? "")?.[0], ...named, NO_ENVIRONMENT];
3162
+ const candidates = [
3163
+ ...previous ? [previous] : [],
3164
+ ...environments.flatMap((id) => id ? [tunnelRoutes(target, id)] : [])
3165
+ ];
3166
+ return candidates.every((routes) => judgeTunnel(status, routes).kind === "foreign");
3167
+ }
3168
+ var NO_ENVIRONMENT = "00000000-0000-0000-0000-000000000000";
3169
+ async function closeRecorded(options, routes) {
3170
+ const { output } = options;
3171
+ const context = tunnelContext(options);
3172
+ const outcome = await closeTunnel(context, routes);
3173
+ const warning = output.errorStyle.yellow("warning:");
3174
+ if (outcome === "closed") {
3175
+ output.line(`${output.style.dim("\u203A")} The tunnel was closed: the public address no longer reaches this machine.`);
3176
+ } else if (outcome === "foreign") {
3177
+ output.error(`${warning} ${context.service.name} now serves something \`tula dev\` did not set up where its tunnel was. It was left alone: \`${context.service.inspect}\` shows it. The tunnel's record stays in ${DEV_ENV_FILE2} until a run finds the port free.`);
3178
+ } else if (outcome === "failed") {
3179
+ output.error(`${warning} the tunnel \`tula dev --tunnel\` opened could not be closed. Close it yourself: \`${context.service.closeByHand}\`. Its record stays in ${DEV_ENV_FILE2}, so \`tula dev down\` tries again.`);
3180
+ }
3181
+ return outcome;
3182
+ }
3183
+ function composeValue(environment, name) {
3184
+ const value = typeof environment === "object" && environment !== null ? environment[name] : undefined;
3185
+ return typeof value === "string" || typeof value === "number" || typeof value === "boolean" ? String(value).trim() : "";
3186
+ }
3187
+ function compose(options, ...args) {
3188
+ return ["docker", "compose", ...options.projectName ? ["-p", options.projectName] : [], ...args];
3189
+ }
3190
+ function tail(result, lines = 12) {
3191
+ const clean = (text) => text.replace(/[\u0000-\u0008\u000b-\u001f\u007f-\u009f]/g, "");
3192
+ return clean(result.stderr || result.stdout).trim().split(`
3193
+ `).slice(-lines).map((line) => ` ${line}`).join(`
3194
+ `);
3195
+ }
3196
+ async function step(options, what, command, run) {
3197
+ let result;
3198
+ try {
3199
+ result = await options.host.run(command, {
3200
+ cwd: options.io.cwd,
3201
+ timeoutMs: run.timeoutMs,
3202
+ ...run.env && { env: run.env }
3203
+ });
3204
+ } catch (error) {
3205
+ if (error.code === "ENOENT") {
3206
+ throw new UsageError2("Docker was not found. `tula dev` runs the local stack with Docker Compose: install Docker (https://docs.docker.com/get-docker/) and run it again.");
3207
+ }
3208
+ if (error.code === "ABORT") {
3209
+ throw new UsageError2("Interrupted. The stack may be partly started: `tula dev` continues it, `tula dev down` stops it.");
3210
+ }
3211
+ throw error;
3212
+ }
3213
+ if (result.timedOut) {
3214
+ throw new UsageError2(`${what} timed out after ${Math.round(run.timeoutMs / 1000)}s. Run it again (pulled images are kept), or raise --timeout.`);
3215
+ }
3216
+ if (result.code !== 0) {
3217
+ const said = `${result.stderr}
3218
+ ${result.stdout}`;
3219
+ if (/cannot connect to the docker daemon|docker daemon is not running|error during connect/i.test(said)) {
3220
+ throw new UsageError2("Docker is not running. Start Docker Desktop (or the Docker daemon) and run `tula dev` again.");
3221
+ }
3222
+ if (/no configuration file provided|no such file or directory.*compose|can't find a suitable configuration file/i.test(said)) {
3223
+ throw new UsageError2("There is no Compose file here. Run `tula dev` in a project made by `create-tula` (or in a directory with a compose.yaml that has `api` and `migrate` services).");
3224
+ }
3225
+ throw new UsageError2(run.quiet ? `${what} failed (exit ${String(result.code)}).` : `${what} failed (exit ${String(result.code)}):
3226
+ ${tail(result)}`);
3227
+ }
3228
+ return result;
3229
+ }
3230
+ async function tunnelTarget(options) {
3231
+ const result = await step(options, "Reading the Compose file", compose(options, "config", "--format", "json"), { timeoutMs: QUICK_MS, quiet: true, env: { [PUBLIC_URL_VARIABLE]: PROBE_PUBLIC_URL } });
3232
+ const untouched = "Nothing was started, and .env.local was not changed.";
3233
+ let api;
3234
+ try {
3235
+ api = JSON.parse(result.stdout).services?.api;
3236
+ } catch {
3237
+ api = undefined;
3238
+ }
3239
+ if (typeof api !== "object" || api === null) {
3240
+ throw new UsageError2(`The resolved Compose file could not be read (\`docker compose config --format json\`). \`tula dev --tunnel\` needs it to see how the API is started. ${untouched}`);
3241
+ }
3242
+ const { environment, ports } = api;
3243
+ if (composeValue(environment, "PUBLIC_URL") !== PROBE_PUBLIC_URL) {
3244
+ throw new UsageError2(`The Compose file here gives the API a fixed PUBLIC_URL, so it cannot be started under a public address, and \`tula dev\` does not edit the file. In the \`api\` service, change that line to:
3245
+
3246
+ ${COMPOSE_LINE}
3247
+
3248
+ and run this again. ${untouched}`);
3249
+ }
3250
+ const on = [];
3251
+ if (/^(true|1|yes)$/i.test(composeValue(environment, "OAUTH_MOCK_PROVIDER"))) {
3252
+ on.push("the mock OAuth provider (OAUTH_MOCK_PROVIDER)");
3253
+ }
3254
+ if (composeValue(environment, "SMS_PROVIDER").toLowerCase() === "dev") {
3255
+ on.push("the development SMS inbox (SMS_PROVIDER=dev)");
3256
+ }
3257
+ if (on.length > 0) {
3258
+ throw new UsageError2(`This stack has ${on.join(" and ")} switched on. The API refuses to start with that under a public address: it hands a sign-in, or every texted code, to whoever asks, and is for this machine only. \`tula dev\` does not change your lines. Switch it off in .env (or the Compose file) and run this again, or run \`tula dev\` without --tunnel to keep it. ${untouched}`);
3259
+ }
3260
+ const published = (Array.isArray(ports) ? ports : []).filter((port) => typeof port === "object" && port !== null).find((port) => String(port.target) === API_CONTAINER_PORT)?.published;
3261
+ const port = /^[0-9]{1,5}$/.exec(String(published ?? ""))?.[0];
3262
+ if (!port || Number(port) < 1 || Number(port) > 65535) {
3263
+ throw new UsageError2(`The \`api\` service does not publish its port 3003 on a fixed port of this machine, which is where a tunnel sends requests. Publish it in the Compose file (e.g. \`127.0.0.1:3003:3003\`). ${untouched}`);
3264
+ }
3265
+ return `http://127.0.0.1:${port}`;
3266
+ }
3267
+ async function publishedUrl(options, service, port) {
3268
+ const result = await options.host.run(compose(options, "port", service, port), { cwd: options.io.cwd, timeoutMs: QUICK_MS }).catch(() => null);
3269
+ const match = /:(\d{1,5})\s*$/.exec(result?.code === 0 ? result.stdout.trim() : "");
3270
+ return match ? `http://localhost:${match[1]}` : null;
3271
+ }
3272
+ async function waitUntilReady(options, apiUrl, halt) {
3273
+ const send = options.io.fetch ?? ((url, init) => fetch(url, init));
3274
+ const attempts = Math.max(1, Math.ceil(options.timeoutMs / 1000));
3275
+ for (let attempt = 0;attempt < attempts; attempt += 1) {
3276
+ halt();
3277
+ const ready = await send(`${apiUrl}/v1/ready`, { signal: AbortSignal.timeout(5000) }).then((response) => response.status === 200, () => false);
3278
+ if (ready) {
3279
+ return;
3280
+ }
3281
+ await options.host.sleep(1000);
3282
+ }
3283
+ throw new UsageError2(`The API did not become ready at ${apiUrl}. Look at what it says: \`${compose(options, "logs", "api").join(" ")}\`, then \`tula doctor\`.`);
3284
+ }
3285
+ async function mint(options, environmentId, kind) {
3286
+ const result = await step(options, `Minting the ${kind} key`, compose(options, "exec", "-T", "api", "bun", "run", "src/scripts/create-api-key.ts", "--environment", environmentId, "--kind", kind, "--name", "tula dev"), { timeoutMs: 60000, quiet: true });
3287
+ const key = result.stdout.trim().split(`
3288
+ `).at(-1)?.trim() ?? "";
3289
+ if (!KEY[kind].test(key)) {
3290
+ throw new UsageError2(`Minting the ${kind} key did not answer with a key.`);
3291
+ }
3292
+ if (kind === "secret" && !options.showKeys) {
3293
+ options.output.redact(key);
3294
+ }
3295
+ return key;
3296
+ }
3297
+ async function accepts(options, apiUrl, secretKey) {
3298
+ try {
3299
+ const admin = createAdminClient2({
3300
+ baseUrl: apiUrl,
3301
+ secretKey,
3302
+ fetch: options.io.fetch,
3303
+ userAgent: `tula-cli/${VERSION2}`
3304
+ });
3305
+ await admin.call("getEnvironmentSettings");
3306
+ return true;
3307
+ } catch (error) {
3308
+ if (isTulaAdminError7(error) && (error.code === "auth.invalid_key" || error.code === "client.invalid_key")) {
3309
+ return false;
3310
+ }
3311
+ throw error;
3312
+ }
3313
+ }
3314
+ async function startDev2(options) {
3315
+ const { io, output, host } = options;
3316
+ const say = (text) => output.line(`${output.style.dim("\u203A")} ${text}`);
3317
+ await step(options, "Checking Docker Compose", ["docker", "compose", "version"], {
3318
+ timeoutMs: QUICK_MS
3319
+ });
3320
+ const services = (await step(options, "Reading the Compose file", compose(options, "config", "--services"), {
3321
+ timeoutMs: QUICK_MS
3322
+ })).stdout.split(`
3323
+ `).map((line) => line.trim());
3324
+ for (const service of ["api", "migrate"]) {
3325
+ if (!services.includes(service)) {
3326
+ throw new UsageError2(`The Compose file here has no \`${service}\` service. \`tula dev\` runs a project made by \`create-tula\`: a Compose file with \`api\` and \`migrate\` services built from the Tula API image.`);
3327
+ }
3328
+ }
3329
+ const path = `${io.cwd.replace(/\/+$/, "")}/${DEV_ENV_FILE2}`;
3330
+ const raw = await host.readFile(path);
3331
+ const existing = raw ?? "";
3332
+ const block = parseDevBlock2(existing) ?? {};
3333
+ const previous = recordedTunnel(block);
3334
+ const tunnel = tunnelContext(options);
3335
+ let target;
3336
+ if (options.tunnel) {
3337
+ say("A tunnel gives this stack a public address. Under one the API refuses to start with the mock OAuth provider (OAUTH_MOCK_PROVIDER) or the development SMS inbox (SMS_PROVIDER=dev): both are for this machine only.");
3338
+ target = await tunnelTarget(options);
3339
+ const status = await tunnelStatus(tunnel);
3340
+ if (servesAnother(status, target, block, previous)) {
3341
+ throw new UsageError2(`${portInUse(tunnel.service)} Nothing was started.`);
3342
+ }
3343
+ }
3344
+ say("Starting the database and running migrations (the first run pulls images)\u2026");
3345
+ await step(options, "Running the migrations", compose(options, "run", "--rm", "migrate"), {
3346
+ timeoutMs: options.timeoutMs
3347
+ });
3348
+ say("Seeding the default project and its environments\u2026");
3349
+ const seed = await step(options, "Seeding", compose(options, "run", "--rm", "--no-deps", "api", "bun", "run", "../../packages/db/src/scripts/seed.ts"), { timeoutMs: options.timeoutMs });
3350
+ const environmentId = UUID.exec(/development environment\s+(\S+)/.exec(seed.stdout)?.[1] ?? "")?.[0];
3351
+ if (!environmentId) {
3352
+ throw new UsageError2("The seed did not report a development environment.");
3353
+ }
3354
+ if (target !== undefined) {
3355
+ await serveThroughTunnel(options, { path, raw, block, previous, target, environmentId });
3356
+ return;
3357
+ }
3358
+ const outcome = previous ? await closeRecorded(options, previous) : "gone";
3359
+ const still = outcome === "failed" || outcome === "foreign";
3360
+ await serve(options, {
3361
+ path,
3362
+ existing,
3363
+ block,
3364
+ environmentId,
3365
+ tunnel: null,
3366
+ ...still && block.TULA_TUNNEL_TARGET && { record: block.TULA_TUNNEL_TARGET },
3367
+ halt: () => {}
3368
+ });
3369
+ }
3370
+ var STOP_MS = 300000;
3371
+ async function serveThroughTunnel(options, run) {
3372
+ const { host, output } = options;
3373
+ const { path, raw, block, previous, target, environmentId } = run;
3374
+ const existing = raw ?? "";
3375
+ const tunnel = tunnelContext(options);
3376
+ const { service } = tunnel;
3377
+ const routes = tunnelRoutes(target, environmentId);
3378
+ const same = previous !== null && JSON.stringify(previous) === JSON.stringify(routes);
3379
+ let signals = 0;
3380
+ let closing = false;
3381
+ const release = host.onInterrupt(() => {
3382
+ signals += 1;
3383
+ if (closing) {
3384
+ output.error("The tunnel is being closed. That is not cut short: one moment.");
3385
+ }
3386
+ });
3387
+ const halt = () => {
3388
+ if (signals > 0) {
3389
+ throw Object.assign(new Error("interrupted"), { code: "ABORT" });
3390
+ }
3391
+ };
3392
+ let pending = null;
3393
+ const record = async (vars) => {
3394
+ const text = withDevBlock2(existing, {
3395
+ ...vars,
3396
+ TULA_ENVIRONMENT_ID: environmentId,
3397
+ TULA_TUNNEL_TARGET: target
3398
+ });
3399
+ if (text !== existing) {
3400
+ await host.writeSecretFile(path, text);
3401
+ pending = text;
3402
+ }
3403
+ };
3404
+ let staleClosed = false;
3405
+ let stopped = false;
3406
+ let started = false;
3407
+ let begun = false;
3408
+ let stopping = false;
3409
+ let adopted = "no";
3410
+ try {
3411
+ if (previous && !same) {
3412
+ const outcome = await closeRecorded(options, previous);
3413
+ if (outcome === "failed" || outcome === "foreign") {
3414
+ throw new UsageError2("The tunnel an earlier run opened, for another port or environment, is still recorded in .env.local and was not closed, so no new one was opened. The API was not touched.");
3415
+ }
3416
+ staleClosed = true;
3417
+ }
3418
+ halt();
3419
+ output.line(`${output.style.dim("\u203A")} Opening the tunnel (${service.name})\u2026`);
3420
+ const answer = await openTunnel({ ...tunnel, halt }, routes, async () => {
3421
+ halt();
3422
+ stopping = true;
3423
+ await step(options, "Stopping the API", compose(options, "stop", "api"), {
3424
+ timeoutMs: STOP_MS
3425
+ });
3426
+ stopped = true;
3427
+ halt();
3428
+ await record(same ? block : withoutTunnelVariables(block));
3429
+ begun = true;
3430
+ halt();
3431
+ });
3432
+ if (!begun) {
3433
+ adopted = "unrecorded";
3434
+ await record(same ? block : withoutTunnelVariables(block));
3435
+ adopted = "recorded";
3436
+ }
3437
+ halt();
3438
+ const now = pending ?? existing;
3439
+ await serve(options, {
3440
+ path,
3441
+ existing: now,
3442
+ block: parseDevBlock2(now) ?? {},
3443
+ environmentId,
3444
+ tunnel: { publicUrl: `https://${answer.host}`, target },
3445
+ halt,
3446
+ started: () => {
3447
+ started = true;
3448
+ }
3449
+ });
3450
+ } catch (error) {
3451
+ closing = true;
3452
+ const interrupted = signals > 0 || error.code === "ABORT";
3453
+ const outcome = begun ? await closeTunnel({ ...tunnel, shielded: true }, routes).catch(() => "failed") : null;
3454
+ const closed = outcome === "closed" || outcome === "gone";
3455
+ let dropped = false;
3456
+ if (begun ? closed : staleClosed) {
3457
+ dropped = await dropRecord(host, path, { raw, pending }).catch(() => false);
3458
+ }
3459
+ if (begun) {
3460
+ output.error(closed ? `The tunnel this run opened was closed again${dropped ? ", and its record was taken out of .env.local" : ". Its record could not be taken out of .env.local: `tula dev down` does that"}.` : outcome === "foreign" ? `${service.name} now serves something else where this run was opening its tunnel. It was left alone (\`${service.inspect}\` shows it), and the tunnel's record stays in .env.local until a run finds the port free.` : `The tunnel this run opened could not be closed, and may still reach this machine. Its record stays in .env.local: \`tula dev down\` tries again, and \`${service.closeByHand}\` closes it by hand.`);
3461
+ } else if (adopted === "recorded") {
3462
+ output.error("The tunnel was open before this run and was left open; it is recorded in .env.local. `tula dev down` closes it.");
3463
+ } else if (adopted === "unrecorded") {
3464
+ output.error(`The tunnel was open before this run and was left open. It could not be recorded in .env.local, so \`tula dev down\` does not know of it: \`${service.closeByHand}\` closes it by hand.`);
3465
+ }
3466
+ if (stopped && !started) {
3467
+ output.error("The API was stopped before the tunnel was opened and was not started again: `tula dev` starts it.");
3468
+ } else if (stopping && !stopped) {
3469
+ output.error("The API may have been stopped (the stop did not end as expected): `tula dev` starts it.");
3470
+ }
3471
+ if (interrupted && !(error instanceof UsageError2)) {
3472
+ throw new UsageError2("Interrupted. `tula dev` continues where this stopped, `tula dev down` stops the stack.");
3473
+ }
3474
+ throw error;
3475
+ } finally {
3476
+ release();
3477
+ }
3478
+ }
3479
+ function withoutTunnelVariables(block) {
3480
+ return Object.fromEntries(Object.entries(block).filter(([name]) => !TUNNEL_VARIABLES.includes(name)));
3481
+ }
3482
+ function withoutTunnelRecord(text) {
3483
+ const block = parseDevBlock2(text);
3484
+ if (!block) {
3485
+ return text;
3486
+ }
3487
+ const kept = withoutTunnelVariables(block);
3488
+ if (Object.keys(kept).length === Object.keys(block).length) {
3489
+ return text;
3490
+ }
3491
+ return Object.keys(kept).every((name) => name === "TULA_ENVIRONMENT_ID") ? withoutDevBlock2(text) : withDevBlock2(text, kept);
3492
+ }
3493
+ async function dropRecord(host, path, was) {
3494
+ const now = await host.readFile(path);
3495
+ if (now === null) {
3496
+ return true;
3497
+ }
3498
+ const next = withoutTunnelRecord(now === was.pending ? was.raw ?? "" : now);
3499
+ if (next === "" && was.raw === null) {
3500
+ await host.removeFile(path);
3501
+ } else if (next !== now) {
3502
+ await host.writeSecretFile(path, next);
3503
+ }
3504
+ return true;
3505
+ }
3506
+ async function serve(options, state) {
3507
+ const { io, output, host } = options;
3508
+ const { path, existing, block, environmentId, tunnel, halt } = state;
3509
+ const say = (text) => output.line(`${output.style.dim("\u203A")} ${text}`);
3510
+ halt();
3511
+ say("Starting the API\u2026");
3512
+ await step(options, "Starting the API", compose(options, "up", "-d", "api"), {
3513
+ timeoutMs: options.timeoutMs,
3514
+ ...tunnel && { env: { [PUBLIC_URL_VARIABLE]: tunnel.publicUrl } }
3515
+ });
3516
+ state.started?.();
3517
+ const apiUrl = await publishedUrl(options, "api", API_CONTAINER_PORT);
3518
+ if (!apiUrl) {
3519
+ throw new UsageError2("The `api` service does not publish its port 3003, so its URL is unknown. Publish it in the Compose file (e.g. `127.0.0.1:3003:3003`).");
3520
+ }
3521
+ if (tunnel && new URL(apiUrl).port !== new URL(tunnel.target).port) {
3522
+ throw new UsageError2("The API is published on another port than the Compose file said, so the tunnel would not reach it.");
3523
+ }
3524
+ await waitUntilReady(options, apiUrl, halt);
3525
+ halt();
3526
+ const own = {
3527
+ secret: userValue(existing, "TULA_SECRET_KEY"),
3528
+ publishable: userValue(existing, "TULA_PUBLISHABLE_KEY")
3529
+ };
3530
+ for (const value of [own.secret, options.showKeys ? undefined : block.TULA_SECRET_KEY]) {
3531
+ if (value) {
3532
+ output.redact(value);
3533
+ }
3534
+ }
3535
+ let secretKey = options.rotateKeys ? undefined : block.TULA_SECRET_KEY;
3536
+ let publishableKey = options.rotateKeys ? undefined : block.TULA_PUBLISHABLE_KEY;
3537
+ let minted = false;
3538
+ if (own.secret) {
3539
+ if (!await accepts(options, apiUrl, own.secret)) {
3540
+ output.error(`${output.errorStyle.yellow("warning:")} the TULA_SECRET_KEY you set in ${DEV_ENV_FILE2} is not accepted by this stack. \`tula dev\` does not change your lines: remove that line to have a key minted.`);
3541
+ }
3542
+ secretKey = undefined;
3543
+ } else if (secretKey && publishableKey) {
3544
+ if (!await accepts(options, apiUrl, secretKey)) {
3545
+ throw new UsageError2(`The keys in ${DEV_ENV_FILE2} are not accepted by this stack (was its database wiped?). Nothing was changed. Run \`tula dev --rotate-keys\` to mint new ones.`);
3546
+ }
3547
+ } else {
3548
+ say("Minting development keys\u2026");
3549
+ publishableKey = await mint(options, environmentId, "publishable");
3550
+ secretKey = await mint(options, environmentId, "secret");
3551
+ minted = true;
3552
+ }
3553
+ if (!publishableKey && !own.publishable) {
3554
+ publishableKey = await mint(options, environmentId, "publishable");
3555
+ minted = true;
3556
+ }
3557
+ const vars = { TULA_API_URL: apiUrl, TULA_ENVIRONMENT_ID: environmentId };
3558
+ if (publishableKey) {
3559
+ vars.TULA_PUBLISHABLE_KEY = publishableKey;
3560
+ }
3561
+ if (secretKey) {
3562
+ vars.TULA_SECRET_KEY = secretKey;
3563
+ }
3564
+ vars.VITE_TULA_API_URL = apiUrl;
3565
+ const shownKey = publishableKey ?? own.publishable;
3566
+ if (shownKey) {
3567
+ vars.VITE_TULA_PUBLISHABLE_KEY = shownKey;
3568
+ vars.NEXT_PUBLIC_TULA_PUBLISHABLE_KEY = shownKey;
3569
+ }
3570
+ if (tunnel) {
3571
+ vars[PUBLIC_URL_VARIABLE] = tunnel.publicUrl;
3572
+ vars.TULA_ISSUER = environmentIssuer(tunnel.publicUrl, environmentId);
3573
+ vars.TULA_TUNNEL_TARGET = tunnel.target;
3574
+ } else if (state.record) {
3575
+ vars.TULA_TUNNEL_TARGET = state.record;
3576
+ }
3577
+ halt();
3578
+ const next = withDevBlock2(existing, vars);
3579
+ if (next !== existing) {
3580
+ await host.writeSecretFile(path, next);
3581
+ } else if (await host.restrictFile(path)) {
3582
+ output.error(`${output.errorStyle.yellow("warning:")} ${DEV_ENV_FILE2} holds a secret key and was readable by other users of this machine. It is now readable by you only (mode 0600).`);
3583
+ }
3584
+ const ignored = await host.run(["git", "check-ignore", "-q", DEV_ENV_FILE2], { cwd: io.cwd, timeoutMs: QUICK_MS }).then((result) => result.code, () => null);
3585
+ if (ignored === 1) {
3586
+ output.error(`${output.errorStyle.yellow("warning:")} ${DEV_ENV_FILE2} holds a secret key and is not ignored by git. Add it to .gitignore before you commit.`);
3587
+ }
3588
+ const mailUrl = await publishedUrl(options, "mailpit", MAILPIT_UI_PORT);
3589
+ halt();
3590
+ const { style } = output;
3591
+ output.line();
3592
+ output.line(style.bold("Tula is running."));
3593
+ output.line(` API ${apiUrl}`);
3594
+ if (tunnel) {
3595
+ output.line(` Public address ${tunnel.publicUrl}`);
3596
+ }
3597
+ output.line(` API reference ${apiUrl}/v1/docs`);
3598
+ if (mailUrl) {
3599
+ output.line(` Mail (Mailpit) ${mailUrl}`);
3600
+ }
3601
+ output.line(` Environment ${environmentId} (development)`);
3602
+ if (shownKey) {
3603
+ output.line(` Publishable key ${shownKey}`);
3604
+ }
3605
+ output.line(secretKey && options.showKeys ? ` Secret key ${secretKey}` : ` Secret key in ${DEV_ENV_FILE2}${own.secret ? " (your own line)" : ""}; --show-keys prints it`);
3606
+ output.line();
3607
+ output.line(minted ? `Keys were minted and written to ${DEV_ENV_FILE2} (readable by you only).` : `Keys in ${DEV_ENV_FILE2} were reused: nothing was minted.`);
3608
+ if (tunnel) {
3609
+ const name = new URL(tunnel.publicUrl).host;
3610
+ const { service } = tunnelContext(options);
3611
+ output.line();
3612
+ output.line(style.bold(`The tunnel is open (${service.name}).`));
3613
+ output.line(` ${tunnel.publicUrl} reaches this machine's API, and so does anyone who has that address: the sign-in routes, the dashboard's sign-in and the API reference included. \`tula dev\` without --tunnel, or \`tula dev down\`, closes it.`);
3614
+ output.line(` The address is in ${DEV_ENV_FILE2} as ${PUBLIC_URL_VARIABLE}, and the issuer of this stack's tokens as TULA_ISSUER. TULA_API_URL is still the local address.`);
3615
+ output.line(" The mock OAuth provider and the development SMS inbox are off under a public address, and their routes answer a local request only.");
3616
+ output.line();
3617
+ output.line("No setting of the environment was changed. For passkeys and a native app at this address, put this in tula.config.ts and run `tula apply`:");
3618
+ output.line(` passkeys: { rpId: '${name}' },`);
3619
+ output.line(` urls: { allowedOrigins: ['${tunnel.publicUrl}' /* and the origins you already allow */] },`);
3620
+ output.line(` Register the app under \`nativeApps\`; its associated domain is ${name}. /.well-known/apple-app-site-association and /.well-known/assetlinks.json at that address answer with this environment's files.`);
3621
+ output.line(" A redirect URL or an app link at this address goes in `urls.allowedRedirectUrls`, written out in full.");
3622
+ output.line();
3623
+ output.line("Not checked: that the address answers from the internet, or from a phone. Open its /v1/status in the phone\u2019s browser to see.");
3624
+ }
3625
+ output.line("Next: `tula doctor` checks the stack, `tula apply` applies tula.config.ts, `tula dev down` stops it.");
3626
+ }
3627
+ async function stopDev2(options) {
3628
+ const { io, output, host } = options;
3629
+ const path = `${io.cwd.replace(/\/+$/, "")}/${DEV_ENV_FILE2}`;
3630
+ const existing = await host.readFile(path);
3631
+ const block = existing === null ? null : parseDevBlock2(existing);
3632
+ const tunnel = block ? recordedTunnel(block) : null;
3633
+ const outcome = tunnel ? await closeRecorded(options, tunnel) : "gone";
3634
+ const still = block !== null && (outcome === "failed" || outcome === "foreign");
3635
+ await step(options, "Stopping the stack", compose(options, "down", ...options.volumes ? ["--volumes"] : []), { timeoutMs: STOP_MS });
3636
+ if (options.volumes) {
3637
+ if (existing !== null && block !== null) {
3638
+ await host.writeSecretFile(path, still ? withDevBlock2(existing, {
3639
+ TULA_ENVIRONMENT_ID: block.TULA_ENVIRONMENT_ID ?? "",
3640
+ TULA_TUNNEL_TARGET: block.TULA_TUNNEL_TARGET ?? ""
3641
+ }) : withoutDevBlock2(existing));
3642
+ }
3643
+ output.line(`Stopped, and the data is deleted. The keys \`tula dev\` wrote were removed from ${DEV_ENV_FILE2}.`);
3644
+ } else {
3645
+ if (existing !== null && !still) {
3646
+ const next = withoutTunnelRecord(existing);
3647
+ if (next !== existing) {
3648
+ await host.writeSecretFile(path, next);
3649
+ }
3650
+ }
3651
+ output.line("Stopped. The data is kept: `tula dev` starts it again.");
3652
+ }
3653
+ }
3654
+
3655
+ // src/commands/dev.ts
3656
+ var USAGE = `tula dev [--tunnel] [--project-name <name>] [--show-keys] [--rotate-keys] [--timeout <seconds>]
3657
+ ` + " tula dev down [--volumes [--yes]] [--project-name <name>]";
3658
+ var DEFAULT_TIMEOUT_SECONDS = 900;
3659
+ var devCommand2 = {
3660
+ name: "dev",
3661
+ summary: "Start the local stack: Compose, migrations, seed, development keys.",
3662
+ usage: USAGE,
3663
+ description: "Runs in a project made by create-tula (a directory with a Compose file that has `api` " + "and `migrate` services). Starts the services, runs the migrations and the seed with the " + "commands the API image ships, mints a publishable and a secret development key, writes " + "them to its own block of .env.local (your own lines are never changed) and prints the " + `URLs. A second run reuses the keys. The secret key is printed only with --show-keys.
3664
+
3665
+ ` + "`tula dev down` stops the stack and keeps the data; `--volumes` deletes the database " + `after a confirmation (--yes without a terminal).
3666
+
3667
+ ` + "`--tunnel` gives the stack a public https address a phone can reach, through Tailscale " + "Funnel (the machine\u2019s own name under ts.net, the same on every run). The Tailscale client " + "and its sign-in are your own setup: tula passes it no credential and reads none. The " + "address goes into the block of .env.local and the API is started under it; no setting " + "of the environment is changed, the changes to make are printed. Under a public address " + "the mock OAuth provider and the development SMS inbox cannot be on: with either, " + "--tunnel stops before anything is started. `tula dev` without --tunnel, and `tula dev " + `down\`, close the tunnel.
3668
+
3669
+ ` + "The Compose project name comes from --project-name, else COMPOSE_PROJECT_NAME, else " + `Compose\u2019s default (the directory\u2019s name). Ports come from the Compose file and its .env.
3670
+
3671
+ ` + "Exit codes: 0 done, 1 an error.",
3672
+ maxPositionals: 1,
3673
+ options: {
3674
+ "project-name": {
3675
+ type: "string",
3676
+ short: "p",
3677
+ value: "<name>",
3678
+ description: "The Compose project name. Default: COMPOSE_PROJECT_NAME, then the directory."
3679
+ },
3680
+ tunnel: {
3681
+ type: "boolean",
3682
+ description: "Open a public https address for the stack (Tailscale Funnel)."
3683
+ },
3684
+ "show-keys": { type: "boolean", description: "Print the secret key too." },
3685
+ "rotate-keys": {
3686
+ type: "boolean",
3687
+ description: "Mint new keys and replace the ones tula dev wrote."
3688
+ },
3689
+ timeout: {
3690
+ type: "string",
3691
+ value: "<seconds>",
3692
+ description: `How long one step may take. Default: ${DEFAULT_TIMEOUT_SECONDS}.`
3693
+ },
3694
+ volumes: { type: "boolean", description: "With `down`: delete the database too." },
3695
+ yes: { type: "boolean", short: "y", description: "With `down --volumes`: do not ask." }
3696
+ },
3697
+ run: async ({ flags, positionals, io, output }) => {
3698
+ const [subcommand] = positionals;
3699
+ if (subcommand !== undefined && subcommand !== "down") {
3700
+ throw new UsageError2(`Usage: ${USAGE}`);
3701
+ }
3702
+ const { host } = io;
3703
+ if (!host) {
3704
+ throw new UsageError2("tula dev cannot run processes here.");
3705
+ }
3706
+ const projectName = typeof flags["project-name"] === "string" ? flags["project-name"] : undefined;
3707
+ if (projectName !== undefined && !/^[a-z0-9][a-z0-9_-]*$/.test(projectName)) {
3708
+ throw new UsageError2("--project-name must be lowercase letters, digits, dashes and underscores, starting with a letter or digit.");
3709
+ }
3710
+ if (subcommand === "down" && flags.tunnel === true) {
3711
+ throw new UsageError2("--tunnel belongs to `tula dev`: `tula dev down` closes the tunnel.");
3712
+ }
3713
+ if (subcommand === "down") {
3714
+ const volumes = flags.volumes === true;
3715
+ if (volumes && flags.yes !== true) {
3716
+ if (!io.isTTY || !io.prompt) {
3717
+ throw new UsageError2("`tula dev down --volumes` deletes the database. Pass --yes to do that without a terminal to confirm on.");
3718
+ }
3719
+ const answer = await io.prompt("Delete this project\u2019s database and every user in it? [y/N] ");
3720
+ if (!/^y(es)?$/i.test(answer.trim())) {
3721
+ output.error("Nothing was stopped or deleted.");
3722
+ return EXIT2.error;
3723
+ }
3724
+ }
3725
+ await stopDev2({ io, output, host, projectName, volumes });
3726
+ return EXIT2.ok;
3727
+ }
3728
+ if (flags.volumes === true || flags.yes === true) {
3729
+ throw new UsageError2("--volumes and --yes belong to `tula dev down`.");
3730
+ }
3731
+ const seconds = flags.timeout === undefined ? DEFAULT_TIMEOUT_SECONDS : Number(flags.timeout);
3732
+ if (!Number.isInteger(seconds) || seconds < 1 || seconds > 7200) {
3733
+ throw new UsageError2("--timeout is a number of seconds, from 1 to 7200.");
3734
+ }
3735
+ await startDev2({
3736
+ io,
3737
+ output,
3738
+ host,
3739
+ projectName,
3740
+ timeoutMs: seconds * 1000,
3741
+ showKeys: flags["show-keys"] === true,
3742
+ rotateKeys: flags["rotate-keys"] === true,
3743
+ tunnel: flags.tunnel === true
3744
+ });
3745
+ return EXIT2.ok;
3746
+ }
3747
+ };
3748
+
3749
+ // src/commands/diff.ts
3750
+ var diffCommand2 = {
3751
+ name: "diff",
3752
+ summary: "Show what `tula apply` would change. Writes nothing.",
3753
+ usage: "tula diff [--env <name>] [--config <path>] [--prune] [--rotate-secrets] [--json]",
3754
+ description: "Compares tula.config.ts with the environment\u2019s settings, OAuth providers and (when the " + "file lists them) webhook endpoints and hooks, and prints the plan: what would be added, changed " + `and removed, by path. Secrets are never shown.
3755
+
3756
+ ` + "Exit codes: 0 no changes, 2 changes pending, 1 an error. A plan that cannot be " + "applied (a webhook address the server has twice, too many endpoints) is an error.",
3757
+ options: PLAN_OPTIONS,
3758
+ run: async (context) => {
3759
+ const { name, target, plan } = await prepare(context);
3760
+ const header = { environment: name, apiUrl: target.apiUrl };
3761
+ if (context.flags.json) {
3762
+ context.output.line(JSON.stringify(planToJson2(header, plan), null, 2));
3763
+ } else {
3764
+ renderPlan2(context.output, header, plan);
3765
+ context.output.line(plan.changes ? "Changes pending. Run `tula apply` to make them." : "No changes: the environment is as the config says.");
3766
+ const needs = applyRequirements2(plan);
3767
+ if (needs.allowUnknown) {
3768
+ context.output.line("`tula apply` refuses this plan without --allow-unknown: it would reset settings this version does not know.");
3769
+ }
3770
+ if (needs.allowWeaker) {
3771
+ context.output.line("`tula apply --yes` refuses this plan without --allow-weaker: it weakens security.");
3772
+ }
3773
+ if (needs.allowWebhookRemoval) {
3774
+ context.output.line("`tula apply --yes` refuses this plan without --allow-webhook-removal: it removes a webhook endpoint and its delivery log.");
3775
+ }
3776
+ }
3777
+ const blockers = planBlockers(plan);
3778
+ for (const blocker of blockers) {
3779
+ const advice = plan.webhooks.overLimit !== null && blocker.includes("may have") ? ` ${OVER_LIMIT_ADVICE}` : "";
3780
+ context.output.error(`${context.output.errorStyle.red("error:")} ${blocker}${advice}`);
3781
+ }
3782
+ if (blockers.length > 0) {
3783
+ return EXIT2.error;
3784
+ }
3785
+ return plan.changes ? EXIT2.changes : EXIT2.ok;
3786
+ }
3787
+ };
3788
+
3789
+ // src/commands/doctor.ts
3790
+ var doctorCommand2 = {
3791
+ name: "doctor",
3792
+ summary: "Check a deployment: database, migrations, master key, mail, Redis, URLs.",
3793
+ usage: "tula doctor [--api-url <url>] [--admin-token-file <path>] [--strict] [--json]",
3794
+ description: "Checks what actually goes wrong in a deployment and prints the fix under each check that " + "is not ok. The API URL comes from --api-url or TULA_API_URL; the instance admin token " + "(the server\u2019s TULA_ADMIN_TOKEN) from TULA_ADMIN_TOKEN or --admin-token-file, never from " + `the command line. Without a token only the checks this machine can make are run.
3795
+
3796
+ ` + "Exit codes: 0 nothing failed, 1 a check failed (or warned, with --strict) or an error.",
3797
+ options: {
3798
+ "api-url": {
3799
+ type: "string",
3800
+ value: "<url>",
3801
+ description: "The Tula API. Default: TULA_API_URL."
3802
+ },
3803
+ "admin-token-file": {
3804
+ type: "string",
3805
+ value: "<path>",
3806
+ description: "Read the instance admin token from a file (- for standard input, piped). Default: TULA_ADMIN_TOKEN."
3807
+ },
3808
+ "insecure-http": {
3809
+ type: "boolean",
3810
+ description: "Allow a plain http API URL that is not localhost: the admin token then crosses the network in clear text."
3811
+ },
3812
+ strict: { type: "boolean", description: "Exit 1 on a warning too." },
3813
+ json: { type: "boolean", description: "Print the report as JSON." }
3814
+ },
3815
+ run: async ({ flags, io, output }) => {
3816
+ const apiUrl = resolveApiUrl2(flags, io);
3817
+ const instance = await resolveInstance2({ apiUrl, flags, io, output });
3818
+ const report = await examine2({ apiUrl, instance, io });
3819
+ const strict = flags.strict === true;
3820
+ if (flags.json) {
3821
+ output.line(JSON.stringify(reportToJson2(report, strict), null, 2));
3822
+ } else {
3823
+ renderReport2(output, report);
3824
+ }
3825
+ return exitCode2(report, strict);
3826
+ }
3827
+ };
3828
+
3829
+ // src/commands/mcp.ts
3830
+ import { isTulaAdminError as isTulaAdminError8 } from "@tula-tech/admin";
3831
+ function suffix(name) {
3832
+ return name.toUpperCase().replace(/[^A-Z0-9]/g, "_");
3833
+ }
3834
+ function reason(error) {
3835
+ if (error instanceof UsageError2) {
3836
+ return error.message.replace(/ \(or TULA_[A-Z_]+_DEFAULT\)/g, "");
3837
+ }
3838
+ if (isTulaAdminError8(error)) {
3839
+ return `The configured credential or API URL is not usable (${error.code}).`;
3840
+ }
3841
+ throw error;
3842
+ }
3843
+ async function buildMcpServer2(context) {
3844
+ const { flags, io } = context;
3845
+ for (const option of ["secret-key-file", "admin-token-file", "agent-key-file"]) {
3846
+ if (flags[option] === "-") {
3847
+ throw new UsageError2(`--${option} - would read from standard input, which carries the protocol. Use a file or the environment.`);
3848
+ }
3849
+ }
3850
+ const { createTulaMcpServer, TOOL_NAMES } = await import("@tula-tech/mcp");
3851
+ const secrets = [];
3852
+ const output = {
3853
+ ...context.output,
3854
+ redact: (value, scope) => {
3855
+ secrets.push(value);
3856
+ context.output.redact(value, scope);
3857
+ }
3858
+ };
3859
+ const name = typeof flags.env === "string" ? flags.env : "default";
3860
+ const unavailable = {};
3861
+ let agentConfigured = false;
3862
+ const agent = await resolveAgent2({ name, flags, io, output }).then((target) => {
3863
+ agentConfigured = target !== null;
3864
+ return target?.admin ?? null;
3865
+ }, (error) => {
3866
+ agentConfigured = true;
3867
+ unavailable.agent = reason(error);
3868
+ unavailable.admin = unavailable.agent;
3869
+ return null;
3870
+ });
3871
+ const admin = agentConfigured ? null : await resolveTarget2({ name, environment: {}, flags, io, output }).then((target) => target.admin, (error) => {
3872
+ unavailable.admin = reason(error);
3873
+ return null;
3874
+ });
3875
+ const secretKeyLeftUnread = agentConfigured && (flags["secret-key-file"] !== undefined || Boolean(io.env[`TULA_SECRET_KEY_${suffix(name)}`]?.trim()) || Boolean(io.env.TULA_SECRET_KEY?.trim()));
3876
+ let doctor = null;
3877
+ try {
3878
+ const named = io.env[`TULA_API_URL_${suffix(name)}`]?.trim();
3879
+ const apiUrl = resolveApiUrl2(flags["api-url"] === undefined && named ? { ...flags, "api-url": named } : flags, io);
3880
+ const instance = await resolveInstance2({ apiUrl, flags, io, output });
3881
+ doctor = ({ signal }) => examine2({ apiUrl, instance, io, signal });
3882
+ } catch (error) {
3883
+ unavailable.doctor = reason(error);
3884
+ }
3885
+ context.output.error(`tula mcp: ${TOOL_NAMES.length} tools; one changes live data while it runs (test_signin, where the environment allows it). Read tools: ${agent ? "configured (agent key)" : admin ? "configured" : "not configured"}. Propose tools: ${agent ? "configured" : "not configured"}. Doctor: ${doctor ? "configured" : "not configured"}.`);
3886
+ if (secretKeyLeftUnread) {
3887
+ context.output.error("tula mcp: an agent key is configured, so the secret key was not read. Keep the secret key out of the environment an agent runs in: it is what approves a proposal.");
3888
+ }
3889
+ if (agentConfigured && doctor && io.env.TULA_ADMIN_TOKEN?.trim()) {
3890
+ context.output.error("tula mcp: the instance admin token is set beside an agent key. No tool can use it for anything but the deployment checks, but it is a credential of a person: prefer to " + "leave it out of an agent\u2019s environment.");
3891
+ }
3892
+ for (const why of new Set(Object.values(unavailable))) {
3893
+ context.output.error(`tula mcp: ${why}`);
3894
+ }
3895
+ return createTulaMcpServer({
3896
+ admin,
3897
+ agent,
3898
+ doctor,
3899
+ cwd: io.cwd,
3900
+ secrets,
3901
+ log: (line) => context.output.error(line),
3902
+ unavailable,
3903
+ version: VERSION2
3904
+ });
3905
+ }
3906
+ var mcpCommand2 = {
3907
+ name: "mcp",
3908
+ summary: "Serve the MCP server (it reads, and proposes) on standard input and output.",
3909
+ usage: "tula mcp [--env <name>] [--api-url <url>] [--agent-key-file <path>] [--secret-key-file <path>] [--admin-token-file <path>]",
3910
+ description: "Serves Tula\u2019s Model Context Protocol server over standard input and output, for an MCP " + "client to start. Read tools: users, sessions, audit entries, settings, OAuth providers " + "and the doctor\u2019s checks. One draft tool: a change to the settings or the providers, " + "returned as an edit to tula.config.ts for a person to review and apply with `tula apply`; it writes no file and calls no route that writes. Scaffold tools: the provider, a protected route and a sign-in page, returned as files for the client to write. Propose tools (with an agent key): ask for a change, which a person approves with `tula approve <proposal id>` and their own secret key. One test tool, test_signin: the server signs a throwaway user up and in " + "through the environment\u2019s real flow and deletes it, and the tool reports each step. It " + "runs only where the environment\u2019s setting agents.testSignIn allows it (on by default " + "in a development environment, off in production); while it runs the environment\u2019s " + `hooks are asked and its events are delivered. No other tool changes live data, and no tool returns a secret.
3911
+
3912
+ ` + "For an AI agent, give it an agent key (tula_ak_\u2026): --agent-key-file, " + `TULA_AGENT_KEY_<NAME> or TULA_AGENT_KEY. It reads what a secret key reads, proposes, and runs a sign-in test where the environment allows one; it changes nothing else. With it the secret key is not read at all.
3913
+
3914
+ The API URL comes from --api-url, TULA_API_URL_<NAME> or TULA_API_URL; the secret key from --secret-key-file, TULA_SECRET_KEY_<NAME> or TULA_SECRET_KEY; the instance admin token (run_doctor only) from --admin-token-file or TULA_ADMIN_TOKEN. None is required: without a key the read tools answer "not configured" and the scaffold tools still work. Standard output carries only the protocol; everything else is written to standard error.`,
3915
+ options: {
3916
+ env: {
3917
+ type: "string",
3918
+ short: "e",
3919
+ value: "<name>",
3920
+ description: "Read TULA_API_URL_<NAME> and TULA_SECRET_KEY_<NAME> first."
3921
+ },
3922
+ "api-url": {
3923
+ type: "string",
3924
+ value: "<url>",
3925
+ description: "The Tula API. Default: TULA_API_URL_<NAME>, then TULA_API_URL."
3926
+ },
3927
+ "agent-key-file": {
3928
+ type: "string",
3929
+ value: "<path>",
3930
+ description: "Read the agent key from a file. Default: TULA_AGENT_KEY_<NAME>, then TULA_AGENT_KEY. With one, the secret key is not read."
3931
+ },
3932
+ "secret-key-file": {
3933
+ type: "string",
3934
+ value: "<path>",
3935
+ description: "Read the secret key from a file. Default: TULA_SECRET_KEY_<NAME>, then TULA_SECRET_KEY."
3936
+ },
3937
+ "admin-token-file": {
3938
+ type: "string",
3939
+ value: "<path>",
3940
+ description: "Read the instance admin token from a file. Default: TULA_ADMIN_TOKEN."
3941
+ },
3942
+ "insecure-http": {
3943
+ type: "boolean",
3944
+ description: "Allow a plain http API URL that is not localhost: the credentials then cross the network in clear text."
3945
+ }
3946
+ },
3947
+ run: async (context) => {
3948
+ const { serve } = context.io;
3949
+ if (!serve) {
3950
+ throw new UsageError2("tula mcp needs the process\u2019s standard input and output.");
3951
+ }
3952
+ const server = await buildMcpServer2(context);
3953
+ const { serveOverStdio } = await import("@tula-tech/mcp");
3954
+ await serveOverStdio(server, serve);
3955
+ return EXIT2.ok;
3956
+ }
3957
+ };
3958
+
3959
+ // src/commands/policy.ts
3960
+ import {
3961
+ evaluatePassword
3962
+ } from "@tula-tech/contract/password-rules";
3963
+ var USAGE2 = "tula policy test [--env <name>] [--email <address>] [--name <full name>] [--json]";
3964
+ function describeRule(check) {
3965
+ const params = check.params ?? {};
3966
+ switch (check.rule) {
3967
+ case "min_length":
3968
+ return `at least ${String(params.min)} characters`;
3969
+ case "max_length":
3970
+ return `at most ${String(params.max)} characters`;
3971
+ case "lowercase":
3972
+ return "a lowercase letter";
3973
+ case "uppercase":
3974
+ return "an uppercase letter";
3975
+ case "number":
3976
+ return "a digit";
3977
+ case "special":
3978
+ return "a special character";
3979
+ case "character_classes":
3980
+ return `at least ${String(params.min)} of: lowercase, uppercase, digit, special character`;
3981
+ case "user_info":
3982
+ return "does not contain the user\u2019s name or email";
3983
+ case "common":
3984
+ return "not a commonly used password";
3985
+ case "repeated_characters":
3986
+ return `no character more than ${String(params.max)} times in a row`;
3987
+ case "sequence":
3988
+ return "no run of consecutive characters (abcd, 4321)";
3989
+ default:
3990
+ return check.code;
3991
+ }
3992
+ }
3993
+ async function readPassword(context) {
3994
+ const { io, output, positionals } = context;
3995
+ const argument = positionals[1];
3996
+ if (argument !== undefined) {
3997
+ output.redact(argument, "errors");
3998
+ output.error(`${output.errorStyle.yellow("warning:")} a password on the command line is recorded in shell history and visible in process lists. Leave it out to be asked for it, or pipe it in.`);
3999
+ return argument;
4000
+ }
4001
+ if (io.stdinIsTTY) {
4002
+ if (!io.promptSecret) {
4003
+ throw new UsageError2('The password cannot be asked for without showing it here. Pipe it in on standard input: `printf %s "$PW" | tula policy test`.');
4004
+ }
4005
+ return io.promptSecret("Password to test (not shown): ");
4006
+ }
4007
+ if (!io.readStdin) {
4008
+ throw new UsageError2("The password cannot be read from standard input here.");
4009
+ }
4010
+ return (await io.readStdin()).replace(/\r?\n$/, "");
4011
+ }
4012
+ var policyCommand2 = {
4013
+ name: "policy",
4014
+ summary: "Test a password against an environment\u2019s password policy.",
4015
+ usage: USAGE2,
4016
+ description: "Shows which rules of the environment\u2019s password policy a password passes. The password " + "is asked for without being shown, or read from standard input when it is piped. It is " + "checked on this machine and never sent anywhere; the breached-password check, which only " + "the server makes, is reported as not run. A password given as an argument works too, with " + `a warning: a command line is recorded in shell history.
4017
+
4018
+ ` + "The secret key decides the environment (TULA_SECRET_KEY, or TULA_SECRET_KEY_<NAME> with " + `--env <name>).
4019
+
4020
+ ` + "Exit codes: 0 the password would be accepted, 2 it would be refused, 1 an error.",
4021
+ maxPositionals: 2,
4022
+ options: {
4023
+ env: {
4024
+ type: "string",
4025
+ short: "e",
4026
+ value: "<name>",
4027
+ description: "Read TULA_SECRET_KEY_<NAME> and TULA_API_URL_<NAME> first."
4028
+ },
4029
+ "api-url": {
4030
+ type: "string",
4031
+ value: "<url>",
4032
+ description: "The Tula API. Default: TULA_API_URL_<NAME>, then TULA_API_URL."
4033
+ },
4034
+ "insecure-http": {
4035
+ type: "boolean",
4036
+ description: "Allow a plain http API URL that is not localhost."
4037
+ },
4038
+ "secret-key-file": {
4039
+ type: "string",
4040
+ value: "<path>",
4041
+ description: "Read the secret key from a file. Default: TULA_SECRET_KEY_<NAME>, then TULA_SECRET_KEY."
4042
+ },
4043
+ email: {
4044
+ type: "string",
4045
+ value: "<address>",
4046
+ description: 'The user\u2019s email, for the "does not contain your name or email" rule.'
4047
+ },
4048
+ name: {
4049
+ type: "string",
4050
+ value: "<full name>",
4051
+ description: "The user\u2019s name, for the same rule."
4052
+ },
4053
+ json: { type: "boolean", description: "Print the result as JSON." }
4054
+ },
4055
+ run: async (context) => {
4056
+ const { flags, io, output, positionals } = context;
4057
+ if (positionals[0] !== "test") {
4058
+ throw new UsageError2(`Usage: ${USAGE2}`);
4059
+ }
4060
+ if (flags["secret-key-file"] === "-") {
4061
+ throw new UsageError2("--secret-key-file - would read the key from standard input, which is where the password comes from. Use a file or TULA_SECRET_KEY.");
4062
+ }
4063
+ const name = typeof flags.env === "string" ? flags.env : "default";
4064
+ const target = await resolveTarget2({ name, environment: {}, flags, io, output });
4065
+ const password = await readPassword(context);
4066
+ output.redact(password, "errors");
4067
+ if (password === "") {
4068
+ throw new UsageError2("No password was given.");
4069
+ }
4070
+ const { data } = await target.admin.call("getEnvironmentSettings");
4071
+ const policy = data.settings.password;
4072
+ if (!policy) {
4073
+ throw new UsageError2("The API did not answer with a password policy.");
4074
+ }
4075
+ const [firstName, ...rest] = typeof flags.name === "string" ? flags.name.trim().split(/\s+/) : [];
4076
+ const userInfo = {
4077
+ email: typeof flags.email === "string" ? flags.email : undefined,
4078
+ firstName,
4079
+ lastName: rest.length > 0 ? rest.join(" ") : undefined
4080
+ };
4081
+ const hasUserInfo = userInfo.email !== undefined || firstName !== undefined;
4082
+ const evaluation = evaluatePassword(policy, password, userInfo);
4083
+ const failed = evaluation.checks.filter((check) => !check.passed);
4084
+ const breach = { policy: policy.breachCheck, ran: false };
4085
+ if (flags.json) {
4086
+ output.line(JSON.stringify({
4087
+ apiUrl: target.apiUrl,
4088
+ preset: policy.preset,
4089
+ accepted: evaluation.ok,
4090
+ rules: evaluation.checks.map((check) => ({
4091
+ rule: check.rule,
4092
+ code: check.code,
4093
+ passed: check.passed,
4094
+ description: describeRule(check)
4095
+ })),
4096
+ breachCheck: breach,
4097
+ userInfoGiven: hasUserInfo
4098
+ }, null, 2));
4099
+ return evaluation.ok ? EXIT2.ok : EXIT2.refused;
4100
+ }
4101
+ const { style } = output;
4102
+ output.line(style.bold(`Password policy at ${target.apiUrl} (preset: ${policy.preset})`));
4103
+ output.line();
4104
+ const width = Math.max(...evaluation.checks.map((check) => check.rule.length), "breach".length);
4105
+ for (const check of evaluation.checks) {
4106
+ const label = check.passed ? style.green("pass ") : style.red("FAIL ");
4107
+ const note = check.rule === "user_info" && !hasUserInfo ? " (nothing to compare with: pass --email or --name)" : "";
4108
+ output.line(` ${label} ${check.rule.padEnd(width)} ${describeRule(check)}${note}`);
4109
+ }
4110
+ output.line(` ${style.dim("not run")} ${"breach".padEnd(width)} ${policy.breachCheck === "off" ? "the breached-password check is off in this environment" : `the breached-password check (policy: ${policy.breachCheck}) is made by the server when a password is set, not here`}`);
4111
+ output.line();
4112
+ output.line(evaluation.ok ? "This password would be accepted by the rules above." : `This password would be refused: it breaks ${failed.length} rule${failed.length === 1 ? "" : "s"}.`);
4113
+ output.line("It was checked on this machine and was not sent anywhere.");
4114
+ return evaluation.ok ? EXIT2.ok : EXIT2.refused;
4115
+ }
4116
+ };
4117
+
4118
+ // src/process-io.ts
4119
+ import { readFile, stat } from "fs/promises";
4120
+ import { isAbsolute, resolve as resolve2 } from "path";
4121
+ import { createInterface } from "readline/promises";
4122
+
4123
+ // src/process-host.ts
4124
+ import { constants } from "fs";
4125
+ import { lstat, open, rename, rm, unlink } from "fs/promises";
4126
+ import { basename as basename2 } from "path";
4127
+ function linkRefusal(path) {
4128
+ return new UsageError2(`${basename2(path)} is a symbolic link. \`tula\` does not write a secret or change a mode through a link: replace it with a regular file.`);
4129
+ }
4130
+ function kindRefusal(path) {
4131
+ return new UsageError2(`${basename2(path)} is not a regular file. \`tula\` keeps its keys in a regular file only: move it away and run the command again.`);
4132
+ }
4133
+ async function refuseIrregular(path) {
4134
+ try {
4135
+ const found = await lstat(path);
4136
+ if (found.isSymbolicLink()) {
4137
+ throw linkRefusal(path);
4138
+ }
4139
+ if (!found.isFile()) {
4140
+ throw kindRefusal(path);
4141
+ }
4142
+ return true;
4143
+ } catch (error) {
4144
+ if (error.code === "ENOENT") {
4145
+ return false;
4146
+ }
4147
+ throw error;
4148
+ }
4149
+ }
4150
+ function existsRefusal(path) {
4151
+ return new UsageError2(`${basename2(path)} already exists. \`tula\` writes these secrets to a new file only and never replaces one: name a file that does not exist.`);
4152
+ }
4153
+ async function openRegular(path) {
4154
+ if (!await refuseIrregular(path)) {
4155
+ return null;
4156
+ }
4157
+ let file;
4158
+ try {
4159
+ file = await open(path, constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0) | (constants.O_NONBLOCK ?? 0));
4160
+ } catch (error) {
4161
+ const code = error.code;
4162
+ if (code === "ENOENT") {
4163
+ return null;
4164
+ }
4165
+ throw code === "ELOOP" ? linkRefusal(path) : error;
4166
+ }
4167
+ if (!(await file.stat()).isFile()) {
4168
+ await file.close();
4169
+ throw kindRefusal(path);
4170
+ }
4171
+ return file;
4172
+ }
4173
+ function unguessable() {
4174
+ const bytes = crypto.getRandomValues(new Uint8Array(16));
4175
+ return Array.from(bytes, (byte) => byte.toString(16).padStart(2, "0")).join("");
4176
+ }
4177
+ function createProcessHost(env) {
4178
+ const stoppable = new Set;
4179
+ return {
4180
+ async run(command, options) {
4181
+ const child = Bun.spawn([...command], {
4182
+ cwd: options.cwd,
4183
+ env: { ...env, ...options.env },
4184
+ stdin: "ignore",
4185
+ stdout: "pipe",
4186
+ stderr: options.stderr === "inherit" ? "inherit" : "pipe",
4187
+ ...options.shielded && { detached: true }
4188
+ });
4189
+ let timedOut = false;
4190
+ let interrupted = false;
4191
+ const timer = setTimeout(() => {
4192
+ timedOut = true;
4193
+ child.kill("SIGKILL");
4194
+ }, options.timeoutMs);
4195
+ const onInterrupt = () => {
4196
+ interrupted = true;
4197
+ child.kill("SIGINT");
4198
+ };
4199
+ if (!options.shielded) {
4200
+ process.once("SIGINT", onInterrupt);
4201
+ stoppable.add(onInterrupt);
4202
+ }
4203
+ try {
4204
+ const [stdout, stderr] = await Promise.all([
4205
+ new Response(child.stdout).text(),
4206
+ child.stderr ? new Response(child.stderr).text() : "",
4207
+ child.exited
4208
+ ]);
4209
+ if (interrupted) {
4210
+ throw Object.assign(new Error("interrupted"), { code: "ABORT" });
4211
+ }
4212
+ return { code: timedOut ? null : child.exitCode, stdout, stderr, timedOut };
4213
+ } finally {
4214
+ clearTimeout(timer);
4215
+ process.off("SIGINT", onInterrupt);
4216
+ stoppable.delete(onInterrupt);
4217
+ }
4218
+ },
4219
+ onInterrupt(listener) {
4220
+ const interrupt = () => listener();
4221
+ const terminate = () => {
4222
+ for (const stop of [...stoppable]) {
4223
+ stop();
4224
+ }
4225
+ listener();
4226
+ };
4227
+ process.on("SIGINT", interrupt);
4228
+ process.on("SIGTERM", terminate);
4229
+ process.on("SIGHUP", terminate);
4230
+ return () => {
4231
+ process.off("SIGINT", interrupt);
4232
+ process.off("SIGTERM", terminate);
4233
+ process.off("SIGHUP", terminate);
4234
+ };
4235
+ },
4236
+ async readFile(path) {
4237
+ const file = await openRegular(path);
4238
+ if (!file) {
4239
+ return null;
4240
+ }
4241
+ try {
4242
+ return await file.readFile("utf8");
4243
+ } finally {
4244
+ await file.close();
4245
+ }
4246
+ },
4247
+ async createSecretFile(path, text) {
4248
+ let file;
4249
+ try {
4250
+ file = await open(path, "wx", 384);
4251
+ } catch (error) {
4252
+ if (error.code === "EEXIST") {
4253
+ throw existsRefusal(path);
4254
+ }
4255
+ throw error;
4256
+ }
4257
+ try {
4258
+ await file.writeFile(text);
4259
+ await file.chmod(384);
4260
+ } finally {
4261
+ await file.close();
4262
+ }
4263
+ },
4264
+ async removeFile(path) {
4265
+ try {
4266
+ await unlink(path);
4267
+ return true;
4268
+ } catch (error) {
4269
+ if (error.code === "ENOENT") {
4270
+ return false;
4271
+ }
4272
+ throw error;
4273
+ }
4274
+ },
4275
+ async writeSecretFile(path, text) {
4276
+ await refuseIrregular(path);
4277
+ const temporary = `${path}.${unguessable()}.tmp`;
4278
+ try {
4279
+ const file = await open(temporary, "wx", 384);
4280
+ try {
4281
+ await file.writeFile(text);
4282
+ await file.chmod(384);
4283
+ } finally {
4284
+ await file.close();
4285
+ }
4286
+ await rename(temporary, path);
4287
+ } catch (error) {
4288
+ await rm(temporary, { force: true }).catch(() => {
4289
+ return;
4290
+ });
4291
+ throw error;
4292
+ }
4293
+ },
4294
+ async restrictFile(path) {
4295
+ const file = await openRegular(path);
4296
+ if (!file) {
4297
+ return false;
4298
+ }
4299
+ try {
4300
+ if (((await file.stat()).mode & 63) === 0) {
4301
+ return false;
4302
+ }
4303
+ await file.chmod(384);
4304
+ return ((await file.stat()).mode & 63) === 0;
4305
+ } finally {
4306
+ await file.close();
4307
+ }
4308
+ },
4309
+ sleep: (ms) => new Promise((resolve) => setTimeout(resolve, ms))
4310
+ };
4311
+ }
4312
+
4313
+ // src/process-io.ts
4314
+ function readHidden(parts, question) {
4315
+ const { stdin, stderr } = parts;
4316
+ return new Promise((resolve, reject) => {
4317
+ let value = "";
4318
+ const finish = (settle) => {
4319
+ stdin.off("data", onData);
4320
+ stdin.setRawMode?.(false);
4321
+ stdin.pause();
4322
+ stderr.write(`
4323
+ `);
4324
+ settle();
4325
+ };
4326
+ function onData(chunk) {
4327
+ for (const character of String(chunk)) {
4328
+ if (character === "\r" || character === `
4329
+ ` || character === "\x04") {
4330
+ finish(() => resolve(value));
4331
+ return;
4332
+ }
4333
+ if (character === "\x03") {
4334
+ finish(() => reject(new UsageError2("Cancelled.")));
4335
+ return;
4336
+ }
4337
+ if (character === "\x7F" || character === "\b") {
4338
+ value = [...value].slice(0, -1).join("");
4339
+ } else if (character >= " ") {
4340
+ value += character;
4341
+ }
4342
+ }
4343
+ }
4344
+ stderr.write(question);
4345
+ stdin.setRawMode?.(true);
4346
+ stdin.resume();
4347
+ stdin.on("data", onData);
4348
+ });
4349
+ }
4350
+ function createProcessIo(parts) {
4351
+ let warned = false;
4352
+ async function checkMode(path) {
4353
+ if (warned || parts.platform === "win32") {
4354
+ return;
4355
+ }
4356
+ const mode = await parts.stat(path).then((stats) => stats.mode, () => {
4357
+ return;
4358
+ });
4359
+ if (mode !== undefined && (mode & 63) !== 0) {
4360
+ warned = true;
4361
+ parts.stderr.write(`warning: the file given as --secret-key-file is readable by other users (mode ${(mode & 511).toString(8).padStart(4, "0")}). Restrict it: chmod 600 <file>.
4362
+ `);
4363
+ }
4364
+ }
4365
+ return {
4366
+ stdout: parts.stdout,
4367
+ stderr: parts.stderr,
4368
+ env: parts.env,
4369
+ cwd: parts.cwd,
4370
+ isTTY: parts.stdin.isTTY === true && parts.stderr.isTTY === true,
4371
+ stdinIsTTY: parts.stdin.isTTY === true,
4372
+ prompt: async (question) => {
4373
+ const reader = createInterface({ input: parts.stdin, output: parts.stderr });
4374
+ try {
4375
+ return await reader.question(question);
4376
+ } finally {
4377
+ reader.close();
4378
+ }
4379
+ },
4380
+ now: () => new Date,
4381
+ host: createProcessHost(parts.env),
4382
+ serve: {
4383
+ input: parts.stdin,
4384
+ output: parts.stdout,
4385
+ ...parts.onTerminate ? { onTerminate: parts.onTerminate } : {}
4386
+ },
4387
+ ...parts.stdin.isTTY === true && typeof parts.stdin.setRawMode === "function" ? { promptSecret: (question) => readHidden(parts, question) } : {},
4388
+ readStdin: async () => {
4389
+ let text = "";
4390
+ for await (const chunk of parts.stdin) {
4391
+ text += String(chunk);
4392
+ }
4393
+ return text;
4394
+ },
4395
+ readFile: async (path) => {
4396
+ const absolute = isAbsolute(path) ? path : resolve2(parts.cwd, path);
4397
+ await checkMode(absolute);
4398
+ return parts.readFile(absolute);
4399
+ }
4400
+ };
4401
+ }
4402
+ function processIo() {
4403
+ return createProcessIo({
4404
+ stdin: process.stdin,
4405
+ stdout: process.stdout,
4406
+ stderr: process.stderr,
4407
+ env: process.env,
4408
+ cwd: process.cwd(),
4409
+ platform: process.platform,
4410
+ readFile: (path) => readFile(path, "utf8"),
4411
+ stat: (path) => stat(path),
4412
+ onTerminate: (stop) => {
4413
+ process.once("SIGTERM", stop);
4414
+ process.once("SIGINT", stop);
4415
+ return () => {
4416
+ process.off("SIGTERM", stop);
4417
+ process.off("SIGINT", stop);
4418
+ };
4419
+ }
4420
+ });
4421
+ }
4422
+
4423
+ // src/index.ts
4424
+ var COMMANDS2 = [
4425
+ diffCommand2,
4426
+ applyCommand2,
4427
+ doctorCommand2,
4428
+ policyCommand2,
4429
+ devCommand2,
4430
+ mcpCommand2,
4431
+ approveCommand2
4432
+ ];
4433
+ async function main2(argv, overrides = {}) {
4434
+ return runCli2(argv, { ...processIo(), ...overrides }, COMMANDS2);
4435
+ }
4436
+
4437
+ export { UsageError2, parseArgs2, MANAGING_TOOL2, SET_PATHS2, diffValues2, DEPENDENTS_UNREAD2, readDependentCount2, planProviders2, providersGoingOff2, buildPlan2, orderOperations2, VERSION2, printable2, examine2, exitCode2, reportToJson2, renderReport2, shouldUseColor2, createOutput2, EXIT2, reportError2, runCli2, planWarnings2, applyRequirements2, describeOperation2, renderPlan2, planToJson2, resolveTarget2, resolveAgent2, resolveApiUrl2, readSecretFile2, resolveInstance2, applyCommand2, approveCommand2, DEV_ENV_FILE2, parseDevBlock2, withoutDevBlock2, withDevBlock2, startDev2, stopDev2, devCommand2, diffCommand2, doctorCommand2, buildMcpServer2, mcpCommand2, policyCommand2, COMMANDS2, main2 };