@tula-tech/cli 0.1.1 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -65,6 +65,9 @@ function parseArgs2(argv, options) {
65
65
  if (value === undefined || equals === -1 && value.startsWith("--")) {
66
66
  throw new UsageError2(`--${name} needs a value.`);
67
67
  }
68
+ if (Object.hasOwn(flags, name)) {
69
+ throw new UsageError2(`--${name} was given more than once.`);
70
+ }
68
71
  flags[name] = value;
69
72
  if (equals === -1) {
70
73
  index += 1;
@@ -74,7 +77,9 @@ function parseArgs2(argv, options) {
74
77
  }
75
78
 
76
79
  // src/diff.ts
77
- import { providerSecret } from "@tula-tech/config";
80
+ import {
81
+ providerSecret
82
+ } from "@tula-tech/config";
78
83
  import {
79
84
  HOOK_FIELDS,
80
85
  HOOK_POINTS,
@@ -82,6 +87,7 @@ import {
82
87
  hookWeakenings,
83
88
  isEmailTemplateKind,
84
89
  isSmsTemplateKind,
90
+ MAX_CUSTOM_ROLES,
85
91
  MAX_NATIVE_APPS,
86
92
  MAX_WEBHOOK_ENDPOINTS,
87
93
  NATIVE_APP_PLATFORMS,
@@ -89,8 +95,10 @@ import {
89
95
  nativeAppWeakenings,
90
96
  normalizeAppLinkPaths,
91
97
  normalizeCertFingerprints,
98
+ normalizePermissions,
92
99
  OAUTH_PROVIDERS,
93
100
  oauthProviderWeakenings,
101
+ organizationRoleWeakenings,
94
102
  parseStoredEnvironmentSettings,
95
103
  settingsWeakenings
96
104
  } from "@tula-tech/contract";
@@ -310,6 +318,72 @@ function planNativeApps(remote, desired, options) {
310
318
  seen: nativeAppSnapshot(existing)
311
319
  };
312
320
  }
321
+ function organizationRoleFields(current, entry) {
322
+ const after = normalizePermissions(entry.permissions);
323
+ if (!current) {
324
+ return [
325
+ { path: "name", kind: "added", after: entry.name },
326
+ { path: "permissions", kind: "added", after }
327
+ ];
328
+ }
329
+ const fields = [];
330
+ if (current.name !== entry.name) {
331
+ fields.push({ path: "name", kind: "changed", before: current.name, after: entry.name });
332
+ }
333
+ const before = normalizePermissions(current.permissions);
334
+ const added = after.filter((key) => !before.includes(key));
335
+ const removed = before.filter((key) => !after.includes(key));
336
+ if (added.length > 0 || removed.length > 0) {
337
+ fields.push({ path: "permissions", kind: "changed", before, after, added, removed });
338
+ }
339
+ return fields;
340
+ }
341
+ function planOrganizationRoles(remote, desired, options, unread = false) {
342
+ if (desired === undefined) {
343
+ return { managed: false, roles: [], overLimit: null, unread: false };
344
+ }
345
+ const existing = (remote ?? []).filter((role) => !role.builtIn);
346
+ const byKey = new Map(existing.map((role) => [role.key, role]));
347
+ const listed = new Set;
348
+ const roles = desired.map((entry) => {
349
+ listed.add(entry.key);
350
+ const current = byKey.get(entry.key);
351
+ const fields = organizationRoleFields(current, entry);
352
+ if (!current) {
353
+ return { key: entry.key, action: "create", fields, weakened: [], entry };
354
+ }
355
+ const weakened = organizationRoleWeakenings(current.permissions, normalizePermissions(entry.permissions)).map((field) => `organizationRoles.${entry.key}.${field}`);
356
+ return {
357
+ key: entry.key,
358
+ id: current.id,
359
+ version: current.version,
360
+ action: fields.length > 0 ? "update" : "none",
361
+ fields,
362
+ weakened,
363
+ entry
364
+ };
365
+ });
366
+ for (const role of existing) {
367
+ if (!listed.has(role.key)) {
368
+ roles.push({
369
+ key: role.key,
370
+ id: role.id,
371
+ version: role.version,
372
+ action: options.prune === true ? "delete" : "unmanaged",
373
+ fields: [],
374
+ weakened: []
375
+ });
376
+ }
377
+ }
378
+ const count = (action) => roles.filter((role) => role.action === action).length;
379
+ const after = existing.length + count("create") - count("delete");
380
+ return {
381
+ managed: true,
382
+ roles,
383
+ overLimit: count("create") > 0 && after > MAX_CUSTOM_ROLES ? after : null,
384
+ unread
385
+ };
386
+ }
313
387
  function hookSnapshot(hooks) {
314
388
  return JSON.stringify(hooks.map((hook) => [
315
389
  hook.id,
@@ -640,6 +714,7 @@ function buildPlan2(remote, environment, options) {
640
714
  const webhooks = planWebhooks(remote.webhooks, environment.webhooks, options);
641
715
  const hooks = planHooks(remote.hooks, environment.hooks, options);
642
716
  const nativeApps = planNativeApps(remote.nativeApps, environment.nativeApps, options);
717
+ const organizationRoles = planOrganizationRoles(remote.organizationRoles, environment.organizationRoles, options, remote.organizationRolesUnread === true);
643
718
  const marker = planMarker(remote, options.configHash);
644
719
  return {
645
720
  revision: remote.revision,
@@ -648,7 +723,8 @@ function buildPlan2(remote, environment, options) {
648
723
  ...weakenings(remote.settings, body),
649
724
  ...providers.flatMap((provider) => provider.weakened),
650
725
  ...hooks.hooks.flatMap((hook) => hook.weakened),
651
- ...nativeApps.apps.flatMap((app) => app.weakened)
726
+ ...nativeApps.apps.flatMap((app) => app.weakened),
727
+ ...organizationRoles.roles.flatMap((role) => role.weakened)
652
728
  ],
653
729
  kept,
654
730
  unknown,
@@ -656,9 +732,10 @@ function buildPlan2(remote, environment, options) {
656
732
  webhooks,
657
733
  hooks,
658
734
  nativeApps,
735
+ organizationRoles,
659
736
  marker,
660
737
  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))
738
+ 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)) || organizationRoles.roles.some((role) => ["create", "update", "delete"].includes(role.action))
662
739
  };
663
740
  }
664
741
  function orderOperations2(plan) {
@@ -678,9 +755,28 @@ function orderOperations2(plan) {
678
755
  ...signIn,
679
756
  ...orderWebhooks(plan.webhooks),
680
757
  ...orderHooks(plan.hooks),
681
- ...orderNativeApps(plan.nativeApps)
758
+ ...orderNativeApps(plan.nativeApps),
759
+ ...orderOrganizationRoles(plan.organizationRoles)
682
760
  ];
683
761
  }
762
+ function orderOrganizationRoles(roles) {
763
+ const removals = [];
764
+ const narrowing = [];
765
+ const widening = [];
766
+ const creates = [];
767
+ for (const change of roles.roles) {
768
+ const { key, version, entry } = change;
769
+ if (change.action === "create" && entry) {
770
+ creates.push({ kind: "organizationRole.create", key, entry, change });
771
+ } else if (version !== undefined && change.action === "update" && entry) {
772
+ const bucket = change.weakened.length > 0 ? widening : narrowing;
773
+ bucket.push({ kind: "organizationRole.update", key, version, entry, change });
774
+ } else if (version !== undefined && change.action === "delete") {
775
+ removals.push({ kind: "organizationRole.delete", key, version, change });
776
+ }
777
+ }
778
+ return [...removals, ...narrowing, ...widening, ...creates];
779
+ }
684
780
  function orderNativeApps(nativeApps) {
685
781
  const removals = [];
686
782
  const narrowing = [];
@@ -748,7 +844,7 @@ function orderWebhooks(webhooks) {
748
844
  import { isTulaAdminError } from "@tula-tech/admin";
749
845
 
750
846
  // src/version.ts
751
- var VERSION2 = "0.1.1";
847
+ var VERSION2 = "0.3.0";
752
848
 
753
849
  // src/doctor.ts
754
850
  var SKEW_WARN_MS = 5000;
@@ -1156,6 +1252,7 @@ async function runCli2(argv, io, commands) {
1156
1252
 
1157
1253
  // src/render.ts
1158
1254
  import {
1255
+ MAX_CUSTOM_ROLES as MAX_CUSTOM_ROLES2,
1159
1256
  MAX_EMAIL_BODY_LENGTH,
1160
1257
  MAX_NATIVE_APPS as MAX_NATIVE_APPS2,
1161
1258
  MAX_WEBHOOK_ENDPOINTS as MAX_WEBHOOK_ENDPOINTS2,
@@ -1163,7 +1260,7 @@ import {
1163
1260
  } from "@tula-tech/contract";
1164
1261
  var MAX_VALUE_LENGTH = 100;
1165
1262
  function show(value) {
1166
- const text = JSON.stringify(value) ?? "undefined";
1263
+ const text = printable2(JSON.stringify(value) ?? "undefined", Number.MAX_SAFE_INTEGER);
1167
1264
  return text.length > MAX_VALUE_LENGTH ? `${text.slice(0, MAX_VALUE_LENGTH - 1)}\u2026` : text;
1168
1265
  }
1169
1266
  function shown(change, value) {
@@ -1319,6 +1416,38 @@ function unmanagedLinkPathsLine(change) {
1319
1416
  const paths = count === 1 ? "1 link path" : `${count} link paths`;
1320
1417
  return ` ${paths} on the server, not managed by the file (kept; write appLinkPaths to manage them, [] to remove them)`;
1321
1418
  }
1419
+ function roleText(value) {
1420
+ return printable2(String(value), 100);
1421
+ }
1422
+ function organizationRoleFields2(change) {
1423
+ return change.fields.map((field) => {
1424
+ if (field.path === "permissions") {
1425
+ const entries = field.kind === "added" ? field.after.map(roleText) : [
1426
+ ...(field.added ?? []).map((entry) => `+${roleText(entry)}`),
1427
+ ...(field.removed ?? []).map((entry) => `-${roleText(entry)}`)
1428
+ ];
1429
+ return entries.length > 0 ? `permissions ${entries.join(" ")}` : "permissions none";
1430
+ }
1431
+ return field.kind === "added" ? `name ${show(roleText(field.after))}` : `name ${show(roleText(field.before))} \u2192 ${show(roleText(field.after))}`;
1432
+ });
1433
+ }
1434
+ function organizationRoleLine(output, change) {
1435
+ const { style } = output;
1436
+ const name = roleText(change.key);
1437
+ const fields = organizationRoleFields2(change).join(", ");
1438
+ switch (change.action) {
1439
+ case "create":
1440
+ return style.green(` + ${name}: define (${fields})`);
1441
+ case "update":
1442
+ return style.yellow(` ~ ${name}: update (${fields})`);
1443
+ case "delete":
1444
+ return style.red(` - ${name}: remove (refused by the server while a member holds the role)`);
1445
+ case "unmanaged":
1446
+ return style.dim(` = ${name}: unmanaged (on the server, not in the file; --prune removes it)`);
1447
+ default:
1448
+ return style.dim(` = ${name}: unchanged`);
1449
+ }
1450
+ }
1322
1451
  function nativeAppLine(output, change) {
1323
1452
  const { style } = output;
1324
1453
  const name = appName(change);
@@ -1361,6 +1490,10 @@ function planBlockers(plan) {
1361
1490
  if (apps !== null) {
1362
1491
  blockers.push(`The environment would have ${apps} native apps and may have ${MAX_NATIVE_APPS2}.`);
1363
1492
  }
1493
+ const roles = plan.organizationRoles?.overLimit ?? null;
1494
+ if (roles !== null) {
1495
+ blockers.push(`The environment would have ${roles} custom organization roles and may have ${MAX_CUSTOM_ROLES2}.`);
1496
+ }
1364
1497
  return blockers;
1365
1498
  }
1366
1499
  var OVER_LIMIT_ADVICE = "List fewer in the file, or remove the ones it does not list (--prune).";
@@ -1503,6 +1636,14 @@ function describeOperation2(operation) {
1503
1636
  };
1504
1637
  return `native app ${appName(operation.change)}: ${did[operation.kind]}`;
1505
1638
  }
1639
+ if (operation.kind === "organizationRole.create" || operation.kind === "organizationRole.update" || operation.kind === "organizationRole.delete") {
1640
+ const did = {
1641
+ "organizationRole.create": "define",
1642
+ "organizationRole.update": "update",
1643
+ "organizationRole.delete": "remove"
1644
+ };
1645
+ return `organization role ${roleText(operation.key)}: ${did[operation.kind]}`;
1646
+ }
1506
1647
  if (operation.kind === "provider.delete") {
1507
1648
  return `provider ${operation.provider}: delete`;
1508
1649
  }
@@ -1578,6 +1719,19 @@ function renderPlan2(output, target, plan) {
1578
1719
  }
1579
1720
  }
1580
1721
  }
1722
+ if (plan.organizationRoles.managed) {
1723
+ output.line();
1724
+ output.line(style.bold("Organization roles"));
1725
+ if (plan.organizationRoles.roles.length === 0) {
1726
+ output.line(style.dim(" none in the file, none on the server"));
1727
+ }
1728
+ if (plan.organizationRoles.unread) {
1729
+ output.line(style.dim(" organizations are off on the server, so its custom roles were not read: a role it already has fails its definition (nothing is written over it); run tula diff again once organizations are on"));
1730
+ }
1731
+ for (const change of plan.organizationRoles.roles) {
1732
+ output.line(organizationRoleLine(output, change));
1733
+ }
1734
+ }
1581
1735
  const warnings = planWarnings2(plan);
1582
1736
  if (warnings.length > 0) {
1583
1737
  output.line();
@@ -1610,6 +1764,12 @@ function planToJson2(target, plan) {
1610
1764
  apps: plan.nativeApps.apps.map(({ entry: _entry, ...change }) => change),
1611
1765
  overLimit: plan.nativeApps.overLimit
1612
1766
  },
1767
+ organizationRoles: {
1768
+ managed: plan.organizationRoles.managed,
1769
+ roles: plan.organizationRoles.roles.map(({ entry: _entry, ...change }) => change),
1770
+ overLimit: plan.organizationRoles.overLimit,
1771
+ unread: plan.organizationRoles.unread
1772
+ },
1613
1773
  blockers: planBlockers(plan),
1614
1774
  managedBy: {
1615
1775
  supported: plan.marker.supported,
@@ -1640,6 +1800,79 @@ import {
1640
1800
  isTulaAdminError as isTulaAdminError3
1641
1801
  } from "@tula-tech/admin";
1642
1802
  import { secretKeyMatchesKind } from "@tula-tech/config";
1803
+
1804
+ // src/import/progress.ts
1805
+ import { createHash } from "crypto";
1806
+
1807
+ // src/import/types.ts
1808
+ var IMPORT_PROVIDERS = ["clerk", "auth0", "firebase", "supabase", "better-auth"];
1809
+
1810
+ // src/import/progress.ts
1811
+ function newProgress(provider, fingerprint, target) {
1812
+ return {
1813
+ version: 1,
1814
+ provider,
1815
+ fingerprint,
1816
+ target,
1817
+ doneThrough: 0,
1818
+ batches: 0,
1819
+ counts: { created: 0, skipped: 0, refused: 0 },
1820
+ refused: [],
1821
+ userIds: {},
1822
+ organizations: { cursor: 0, created: 0, existing: 0, failed: [], ids: {} },
1823
+ memberships: { cursor: 0, added: 0, existing: 0, failed: [] }
1824
+ };
1825
+ }
1826
+ function fingerprintOf(files) {
1827
+ const hash = createHash("sha256");
1828
+ for (const [name, text] of files) {
1829
+ hash.update(`${name}
1830
+ ${Buffer.byteLength(text)}
1831
+ `);
1832
+ hash.update(text);
1833
+ }
1834
+ return hash.digest("hex");
1835
+ }
1836
+ function targetOf(apiUrl, secretKey) {
1837
+ return createHash("sha256").update(`tula-import-target
1838
+ ${new URL(apiUrl).origin}
1839
+ ${secretKey}`).digest("hex").slice(0, 16);
1840
+ }
1841
+ var CORRUPT = "The progress file is not one this version of `tula import` wrote. Nothing was changed: move it away, or pass --fresh to start over.";
1842
+ function count(value) {
1843
+ return typeof value === "number" && Number.isSafeInteger(value) && value >= 0;
1844
+ }
1845
+ function items(value) {
1846
+ return Array.isArray(value) && value.every((entry) => typeof entry === "object" && entry !== null && count(entry.row) && typeof entry.code === "string" && entry.code.length <= 80);
1847
+ }
1848
+ function pairs(value) {
1849
+ return typeof value === "object" && value !== null && !Array.isArray(value) && Object.values(value).every((entry) => typeof entry === "string");
1850
+ }
1851
+ function readProgress(source) {
1852
+ let value;
1853
+ try {
1854
+ value = JSON.parse(source);
1855
+ } catch {
1856
+ throw new UsageError2(CORRUPT);
1857
+ }
1858
+ if (typeof value !== "object" || value === null) {
1859
+ throw new UsageError2(CORRUPT);
1860
+ }
1861
+ const found = value;
1862
+ const counts = found.counts;
1863
+ const orgs = found.organizations;
1864
+ const members = found.memberships;
1865
+ if (found.version !== 1 || !IMPORT_PROVIDERS.includes(found.provider) || typeof found.fingerprint !== "string" || typeof found.target !== "string" || !count(found.doneThrough) || !count(found.batches) || !counts || !count(counts.created) || !count(counts.skipped) || !count(counts.refused) || !items(found.refused) || !pairs(found.userIds) || !orgs || !count(orgs.cursor) || !count(orgs.created) || !count(orgs.existing) || !items(orgs.failed) || !pairs(orgs.ids) || !members || !count(members.cursor) || !count(members.added) || !count(members.existing) || !items(members.failed)) {
1866
+ throw new UsageError2(CORRUPT);
1867
+ }
1868
+ return found;
1869
+ }
1870
+ function serializeProgress(progress) {
1871
+ return `${JSON.stringify(progress, null, 2)}
1872
+ `;
1873
+ }
1874
+
1875
+ // src/target.ts
1643
1876
  function variableSuffix(name) {
1644
1877
  return name.toUpperCase().replace(/[^A-Z0-9]/g, "_");
1645
1878
  }
@@ -1692,7 +1925,13 @@ async function resolveTarget2(input) {
1692
1925
  if (!secretKeyMatchesKind(environment.kind, secretKey)) {
1693
1926
  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
1927
  }
1695
- return { apiUrl: apiUrl.replace(/\/+$/, ""), admin };
1928
+ const segment = /^tula_sk_([a-z]+)_/.exec(secretKey)?.[1];
1929
+ return {
1930
+ apiUrl: apiUrl.replace(/\/+$/, ""),
1931
+ admin,
1932
+ fingerprint: targetOf(apiUrl, secretKey),
1933
+ keyLabel: segment === undefined ? "secret key" : `secret key, ${segment}`
1934
+ };
1696
1935
  }
1697
1936
  async function resolveAgent2(input) {
1698
1937
  const { name, flags, io, output } = input;
@@ -1710,6 +1949,8 @@ async function resolveAgent2(input) {
1710
1949
  try {
1711
1950
  return {
1712
1951
  apiUrl: apiUrl.replace(/\/+$/, ""),
1952
+ fingerprint: targetOf(apiUrl, agentKey),
1953
+ keyLabel: "agent key",
1713
1954
  admin: createAgentClient({
1714
1955
  baseUrl: apiUrl,
1715
1956
  agentKey,
@@ -1815,7 +2056,7 @@ var PLAN_OPTIONS = {
1815
2056
  },
1816
2057
  prune: {
1817
2058
  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."
2059
+ description: "Delete providers, and remove webhook endpoints, hooks, native apps and custom organization roles, 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, one with no nativeApps list every app, and one with no organizationRoles list every role."
1819
2060
  },
1820
2061
  "rotate-secrets": {
1821
2062
  type: "boolean",
@@ -1829,6 +2070,14 @@ async function readRemote(admin, options) {
1829
2070
  const webhooks = options.webhooks ? (await admin.call("listWebhookEndpoints")).data.data : undefined;
1830
2071
  const hooks = options.hooks ? (await admin.call("listHooks")).data.data : undefined;
1831
2072
  const nativeApps = options.nativeApps ? (await admin.call("listNativeApps")).data.data : undefined;
2073
+ const unread = { roles: false };
2074
+ const organizationRoles = options.organizationRoles ? await admin.call("listOrganizationRoles").then((answer) => answer.data.data).catch((error) => {
2075
+ if (isTulaAdminError4(error) && error.code === "organization.disabled") {
2076
+ unread.roles = true;
2077
+ return [];
2078
+ }
2079
+ throw error;
2080
+ }) : undefined;
1832
2081
  const managedBy = settings.data.managedBy;
1833
2082
  return {
1834
2083
  revision: settings.data.revision,
@@ -1837,7 +2086,9 @@ async function readRemote(admin, options) {
1837
2086
  providers: providers.data.data,
1838
2087
  ...webhooks !== undefined && { webhooks },
1839
2088
  ...hooks !== undefined && { hooks },
1840
- ...nativeApps !== undefined && { nativeApps }
2089
+ ...nativeApps !== undefined && { nativeApps },
2090
+ ...organizationRoles !== undefined && { organizationRoles },
2091
+ ...unread.roles && { organizationRolesUnread: true }
1841
2092
  };
1842
2093
  }
1843
2094
  function isUnknownRoute(error) {
@@ -1869,7 +2120,8 @@ async function prepare(context) {
1869
2120
  const remote = await readRemote(target.admin, {
1870
2121
  webhooks: environment.webhooks !== undefined,
1871
2122
  hooks: environment.hooks !== undefined,
1872
- nativeApps: environment.nativeApps !== undefined
2123
+ nativeApps: environment.nativeApps !== undefined,
2124
+ organizationRoles: environment.organizationRoles !== undefined
1873
2125
  });
1874
2126
  const prune = flags.prune === true;
1875
2127
  const dependents = await readDependents(target.admin, providersGoingOff2(remote.providers, environment.providers, { prune }));
@@ -2054,6 +2306,43 @@ async function runNativeAppOperation(admin, operation) {
2054
2306
  }
2055
2307
  });
2056
2308
  }
2309
+ var STALE_ORGANIZATION_ROLE = "An organization role was changed by someone else after this plan was made. Nothing was " + "written to it. Run `tula diff` again and review the new plan.";
2310
+
2311
+ class StaleOrganizationRole extends Error {
2312
+ constructor() {
2313
+ super("an organization role changed after the plan was made");
2314
+ }
2315
+ }
2316
+ async function runOrganizationRoleOperation(admin, operation) {
2317
+ if (operation.kind === "organizationRole.create") {
2318
+ const { entry } = operation;
2319
+ await admin.call("createOrganizationRole", {
2320
+ body: { key: entry.key, name: entry.name, permissions: entry.permissions }
2321
+ });
2322
+ return;
2323
+ }
2324
+ const headers = { "If-Match": ifMatch(operation.version) };
2325
+ try {
2326
+ if (operation.kind === "organizationRole.delete") {
2327
+ await admin.call("deleteOrganizationRole", { params: { key: operation.key }, headers });
2328
+ return;
2329
+ }
2330
+ const differs = (path) => operation.change.fields.some((field) => field.path === path);
2331
+ await admin.call("updateOrganizationRole", {
2332
+ params: { key: operation.key },
2333
+ headers,
2334
+ body: {
2335
+ ...differs("name") && { name: operation.entry.name },
2336
+ ...differs("permissions") && { permissions: operation.entry.permissions }
2337
+ }
2338
+ });
2339
+ } catch (error) {
2340
+ if (isTulaAdminError5(error) && error.code === "precondition.failed") {
2341
+ throw new StaleOrganizationRole;
2342
+ }
2343
+ throw error;
2344
+ }
2345
+ }
2057
2346
  async function runHookOperation(admin, environment, operation) {
2058
2347
  if (operation.kind === "hook.delete") {
2059
2348
  await admin.call("deleteHook", { params: { id: operation.id } });
@@ -2425,6 +2714,8 @@ var applyCommand2 = {
2425
2714
  nativeAppsChecked = true;
2426
2715
  }
2427
2716
  await runNativeAppOperation(target.admin, operation);
2717
+ } else if (operation.kind === "organizationRole.create" || operation.kind === "organizationRole.update" || operation.kind === "organizationRole.delete") {
2718
+ await runOrganizationRoleOperation(target.admin, operation);
2428
2719
  } else if (operation.kind === "webhook.create" || operation.kind === "webhook.update" || operation.kind === "webhook.delete") {
2429
2720
  if (!webhooksChecked) {
2430
2721
  const now = await target.admin.call("listWebhookEndpoints").catch((reason) => {
@@ -2456,6 +2747,8 @@ var applyCommand2 = {
2456
2747
  } else if (error instanceof UnreadHooks) {
2457
2748
  output.error(`${output.errorStyle.red("error:")} ${UNREAD_HOOKS}`);
2458
2749
  reportError2(output, error.reason);
2750
+ } else if (error instanceof StaleOrganizationRole) {
2751
+ output.error(`${output.errorStyle.red("error:")} ${STALE_ORGANIZATION_ROLE}`);
2459
2752
  } else if (error instanceof StaleNativeApps) {
2460
2753
  output.error(`${output.errorStyle.red("error:")} ${STALE_NATIVE_APPS}`);
2461
2754
  } else if (error instanceof UnreadNativeApps) {
@@ -2558,6 +2851,7 @@ function failureWords(proposal) {
2558
2851
  var STATUS_WORDS = {
2559
2852
  pending: "waiting for approval",
2560
2853
  expired: "expired",
2854
+ rejected: "rejected by a person",
2561
2855
  approved: "approved, its operation not finished",
2562
2856
  executed: "approved and carried out",
2563
2857
  failed: "approved, and its operation did not end as carried out"
@@ -2697,7 +2991,7 @@ function render(line, proposal, about, now) {
2697
2991
  line(` Asked for by an agent (agent key ${printable2(proposal.proposedBy.id, 64)})`);
2698
2992
  line(` Operation ${printable2(proposal.operation, 40)}`);
2699
2993
  line(` About ${about}`);
2700
- line(` What it does ${printable2(proposal.summary, 400)}`);
2994
+ line(` Agent says ${printable2(proposal.summary, 400)}`);
2701
2995
  line(` Status ${statusWords(proposal)}`);
2702
2996
  if (status === "pending") {
2703
2997
  line(` Expires ${timeLeft(proposal.expiresAt, now)} (${printable2(proposal.expiresAt, 40)})`);
@@ -2772,7 +3066,7 @@ async function run(context) {
2772
3066
  }
2773
3067
  const dependents = await dependentsOf(target.admin, proposal, described);
2774
3068
  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: `);
3069
+ const answer = await ask(`${asked} Approve ${printable2(proposal.operation, 40)} for ${about} at ${target.apiUrl} (${target.keyLabel})? Type yes to continue: `);
2776
3070
  if (answer.trim().toLowerCase() !== "yes") {
2777
3071
  output.error("Cancelled. Nothing was changed.");
2778
3072
  return EXIT2.error;
@@ -2857,8 +3151,166 @@ var approveCommand2 = {
2857
3151
  run
2858
3152
  };
2859
3153
 
3154
+ // src/commands/audit.ts
3155
+ import { isTulaAdminError as isTulaAdminError7 } from "@tula-tech/admin";
3156
+ import {
3157
+ AUDIT_EXPORT_FORMATS,
3158
+ auditExportCursor
3159
+ } from "@tula-tech/contract";
3160
+ function instant(flag, value) {
3161
+ if (value === undefined) {
3162
+ return;
3163
+ }
3164
+ const ms = Date.parse(String(value));
3165
+ if (!Number.isFinite(ms)) {
3166
+ throw new UsageError2(`--${flag} is not a time. Use ISO 8601, e.g. 2026-01-31T00:00:00Z.`);
3167
+ }
3168
+ return new Date(ms).toISOString();
3169
+ }
3170
+ function takeRecords(buffer, format) {
3171
+ const records = [];
3172
+ let start = 0;
3173
+ let quotes = 0;
3174
+ for (let i = 0;i < buffer.length; i += 1) {
3175
+ const char = buffer[i];
3176
+ if (char === '"' && format === "csv") {
3177
+ quotes += 1;
3178
+ } else if (char === `
3179
+ ` && (format === "ndjson" || quotes % 2 === 0)) {
3180
+ records.push(buffer.slice(start, i).replace(/\r$/, ""));
3181
+ start = i + 1;
3182
+ }
3183
+ }
3184
+ return { records, rest: buffer.slice(start) };
3185
+ }
3186
+ function cursorOf(record, format) {
3187
+ try {
3188
+ if (format === "ndjson") {
3189
+ const row = JSON.parse(record);
3190
+ return auditExportCursor(row);
3191
+ }
3192
+ const [id, occurredAt] = record.split(",");
3193
+ return id && occurredAt && id !== "id" ? auditExportCursor({ id, occurredAt }) : null;
3194
+ } catch {
3195
+ return null;
3196
+ }
3197
+ }
3198
+ function requestOf(flags) {
3199
+ const format = typeof flags.format === "string" ? flags.format : "ndjson";
3200
+ if (!AUDIT_EXPORT_FORMATS.includes(format)) {
3201
+ throw new UsageError2(`--format is one of ${AUDIT_EXPORT_FORMATS.join(", ")}.`);
3202
+ }
3203
+ return {
3204
+ format,
3205
+ from: instant("from", flags.from),
3206
+ to: instant("to", flags.to),
3207
+ after: typeof flags.resume === "string" ? flags.resume : undefined,
3208
+ action: typeof flags.action === "string" ? flags.action : undefined,
3209
+ organizationId: typeof flags.organization === "string" ? flags.organization : undefined
3210
+ };
3211
+ }
3212
+ async function drain(response, format, output) {
3213
+ let rows = 0;
3214
+ let last = null;
3215
+ let failed = false;
3216
+ const emit = (records) => {
3217
+ for (const record of records) {
3218
+ output.line(record);
3219
+ const cursor = cursorOf(record, format);
3220
+ if (cursor !== null) {
3221
+ rows += 1;
3222
+ last = cursor;
3223
+ }
3224
+ }
3225
+ };
3226
+ let buffer = "";
3227
+ try {
3228
+ const decoder = new TextDecoder;
3229
+ for await (const chunk of response.body) {
3230
+ const taken = takeRecords(buffer + decoder.decode(chunk, { stream: true }), format);
3231
+ buffer = taken.rest;
3232
+ emit(taken.records);
3233
+ }
3234
+ emit(takeRecords(`${buffer}
3235
+ `, format).records.filter(Boolean));
3236
+ } catch {
3237
+ failed = true;
3238
+ }
3239
+ return { rows, last, failed };
3240
+ }
3241
+ async function run2(context) {
3242
+ const { flags, io, output, positionals } = context;
3243
+ if (positionals[0] !== "export") {
3244
+ throw new UsageError2("Usage: tula audit export [--format ndjson|csv] [--from <time>] [--to <time>]");
3245
+ }
3246
+ const body = requestOf(flags);
3247
+ const name = typeof flags.env === "string" ? flags.env : "default";
3248
+ const target = await resolveTarget2({ name, environment: {}, flags, io, output }).catch((error) => {
3249
+ if (isTulaAdminError7(error) && error.code === "client.agent_key") {
3250
+ throw new UsageError2("That is an agent key (tula_ak_\u2026). An audit export needs a secret key (tula_sk_\u2026).");
3251
+ }
3252
+ throw error;
3253
+ });
3254
+ let response;
3255
+ try {
3256
+ response = await target.admin.stream("exportAuditLogs", { body });
3257
+ } catch (error) {
3258
+ if (isTulaAdminError7(error)) {
3259
+ output.error(`${output.errorStyle.red("error:")} ${printable2(error.code, 80)}`);
3260
+ return EXIT2.error;
3261
+ }
3262
+ throw error;
3263
+ }
3264
+ const { rows, last, failed } = await drain(response, body.format, output);
3265
+ output.error(`${rows} ${rows === 1 ? "entry" : "entries"} exported.`);
3266
+ if (failed) {
3267
+ output.error(`${output.errorStyle.red("error:")} The stream ended early.`);
3268
+ }
3269
+ if (last !== null && (failed || rows >= 1e5)) {
3270
+ output.error(`Continue with: tula audit export --resume ${last}`);
3271
+ }
3272
+ return failed ? EXIT2.error : EXIT2.ok;
3273
+ }
3274
+ var auditCommand2 = {
3275
+ name: "audit",
3276
+ summary: "Export the audit log as NDJSON or CSV.",
3277
+ usage: "tula audit export [--format ndjson|csv] [--from <time>] [--to <time>] [--action <type>] [--organization <id>] [--resume <cursor>] [--env <name>]",
3278
+ description: "Streams the environment\u2019s audit entries to standard output, oldest first. One request " + "writes at most 100000 entries; when a run stops early or reaches that limit, standard " + "error names the cursor to pass to --resume, which continues with no gap and no repeat. " + "The export is itself recorded in the audit log. Needs a secret key (not an agent key).",
3279
+ maxPositionals: 1,
3280
+ options: {
3281
+ format: {
3282
+ type: "string",
3283
+ value: "<ndjson|csv>",
3284
+ description: "Output format. Default ndjson."
3285
+ },
3286
+ from: { type: "string", value: "<time>", description: "Entries at or after this time." },
3287
+ to: { type: "string", value: "<time>", description: "Entries before this time." },
3288
+ action: { type: "string", value: "<type>", description: "Only this action." },
3289
+ organization: {
3290
+ type: "string",
3291
+ value: "<id>",
3292
+ description: "Only entries about this organization."
3293
+ },
3294
+ resume: { type: "string", value: "<cursor>", description: "Continue after this cursor." },
3295
+ env: {
3296
+ type: "string",
3297
+ short: "e",
3298
+ value: "<name>",
3299
+ description: "Read TULA_SECRET_KEY_<NAME> first."
3300
+ },
3301
+ "api-url": { type: "string", value: "<url>", description: "The Tula API." },
3302
+ "insecure-http": { type: "boolean", description: "Allow plain http that is not localhost." },
3303
+ "secret-key-file": {
3304
+ type: "string",
3305
+ value: "<path>",
3306
+ description: "Read the secret key from a file."
3307
+ }
3308
+ },
3309
+ run: run2
3310
+ };
3311
+
2860
3312
  // src/dev.ts
2861
- import { createAdminClient as createAdminClient2, isTulaAdminError as isTulaAdminError7 } from "@tula-tech/admin";
3313
+ import { createAdminClient as createAdminClient2, isTulaAdminError as isTulaAdminError8 } from "@tula-tech/admin";
2862
3314
  import { environmentIssuer } from "@tula-tech/contract/issuer";
2863
3315
 
2864
3316
  // src/tunnel.ts
@@ -2964,7 +3416,7 @@ function said(result) {
2964
3416
  `).slice(-6).map((line) => ` ${printable2(line, 300)}`).join(`
2965
3417
  `);
2966
3418
  }
2967
- async function run2(context, command, timeoutMs) {
3419
+ async function run3(context, command, timeoutMs) {
2968
3420
  try {
2969
3421
  return await context.host.run(command, {
2970
3422
  cwd: context.cwd,
@@ -2980,7 +3432,7 @@ async function run2(context, command, timeoutMs) {
2980
3432
  }
2981
3433
  async function tunnelStatus(context) {
2982
3434
  const { service } = context;
2983
- const result = await run2(context, service.statusCommand(), STATUS_MS);
3435
+ const result = await run3(context, service.statusCommand(), STATUS_MS);
2984
3436
  if (result.timedOut) {
2985
3437
  throw new UsageError2(`\`${service.inspect}\` did not answer in ${STATUS_MS / 1000}s. Is ${service.binary} running on this machine?`);
2986
3438
  }
@@ -3000,7 +3452,7 @@ async function closeTunnel(context, routes) {
3000
3452
  if (before.kind !== "ours") {
3001
3453
  return before.kind === "absent" ? "gone" : "foreign";
3002
3454
  }
3003
- await run2(context, context.service.closeCommand(), CHANGE_MS);
3455
+ await run3(context, context.service.closeCommand(), CHANGE_MS);
3004
3456
  const after = judgeTunnel(await tunnelStatus(context), routes);
3005
3457
  return after.kind === "absent" ? "closed" : "failed";
3006
3458
  } catch (error) {
@@ -3023,7 +3475,7 @@ async function openTunnel(context, routes, beforeOpening) {
3023
3475
  const toOpen = missing.length > 0 ? missing : routes.slice(0, 1);
3024
3476
  await beforeOpening?.();
3025
3477
  for (const route of toOpen) {
3026
- const result = await run2(context, service.openCommand(route), CHANGE_MS);
3478
+ const result = await run3(context, service.openCommand(route), CHANGE_MS);
3027
3479
  if (result.timedOut) {
3028
3480
  throw new UsageError2(service.noAddress);
3029
3481
  }
@@ -3104,7 +3556,18 @@ function withoutDevBlock2(text) {
3104
3556
  const after = text.slice(found.end).replace(/^\r?\n/, "");
3105
3557
  return `${text.slice(0, found.start)}${after}`;
3106
3558
  }
3559
+ function hasControlCharacter(text) {
3560
+ return [...text].some((char) => {
3561
+ const code = char.charCodeAt(0);
3562
+ return code < 32 || code === 127;
3563
+ });
3564
+ }
3107
3565
  function withDevBlock2(text, vars) {
3566
+ for (const [name, value] of Object.entries(vars)) {
3567
+ if (!/^[A-Z][A-Z0-9_]*$/.test(name) || hasControlCharacter(value)) {
3568
+ throw new UsageError2(`The dev block cannot hold ${name}: a name must be NAME_LIKE and a value has no newline or control character.`);
3569
+ }
3570
+ }
3108
3571
  const block = [
3109
3572
  BLOCK_START,
3110
3573
  BLOCK_NOTE,
@@ -3265,7 +3728,10 @@ and run this again. ${untouched}`);
3265
3728
  return `http://127.0.0.1:${port}`;
3266
3729
  }
3267
3730
  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);
3731
+ const result = await options.host.run(compose(options, "port", "--", service, port), {
3732
+ cwd: options.io.cwd,
3733
+ timeoutMs: QUICK_MS
3734
+ }).catch(() => null);
3269
3735
  const match = /:(\d{1,5})\s*$/.exec(result?.code === 0 ? result.stdout.trim() : "");
3270
3736
  return match ? `http://localhost:${match[1]}` : null;
3271
3737
  }
@@ -3305,7 +3771,7 @@ async function accepts(options, apiUrl, secretKey) {
3305
3771
  await admin.call("getEnvironmentSettings");
3306
3772
  return true;
3307
3773
  } catch (error) {
3308
- if (isTulaAdminError7(error) && (error.code === "auth.invalid_key" || error.code === "client.invalid_key")) {
3774
+ if (isTulaAdminError8(error) && (error.code === "auth.invalid_key" || error.code === "client.invalid_key")) {
3309
3775
  return false;
3310
3776
  }
3311
3777
  throw error;
@@ -3826,75 +4292,1728 @@ var doctorCommand2 = {
3826
4292
  }
3827
4293
  };
3828
4294
 
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;
4295
+ // src/commands/import.ts
4296
+ import { createHash as createHash2 } from "crypto";
4297
+ import { resolve as resolve2 } from "path";
4298
+ import { MAX_IMPORT_ROWS as MAX_IMPORT_ROWS2 } from "@tula-tech/contract";
4299
+
4300
+ // src/import/formats.ts
4301
+ var UNREADABLE = "is not a JSON, NDJSON or CSV export this command reads";
4302
+ function isRecord3(value) {
4303
+ return typeof value === "object" && value !== null && !Array.isArray(value);
3842
4304
  }
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.`);
4305
+ function parseCsv(text) {
4306
+ const rows = [];
4307
+ let row = [];
4308
+ let field = "";
4309
+ let quoted = false;
4310
+ let started = false;
4311
+ const source = text.charCodeAt(0) === 65279 ? text.slice(1) : text;
4312
+ for (let index = 0;index < source.length; index += 1) {
4313
+ const char = source[index];
4314
+ if (quoted) {
4315
+ if (char === '"') {
4316
+ if (source[index + 1] === '"') {
4317
+ field += '"';
4318
+ index += 1;
4319
+ } else {
4320
+ quoted = false;
4321
+ }
4322
+ } else {
4323
+ field += char;
4324
+ }
4325
+ continue;
3848
4326
  }
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);
4327
+ if (char === '"' && field === "") {
4328
+ quoted = true;
4329
+ started = true;
4330
+ } else if (char === ",") {
4331
+ row.push(field);
4332
+ field = "";
4333
+ started = true;
4334
+ } else if (char === `
4335
+ ` || char === "\r") {
4336
+ if (char === "\r" && source[index + 1] === `
4337
+ `) {
4338
+ index += 1;
4339
+ }
4340
+ if (started || field !== "") {
4341
+ row.push(field);
4342
+ rows.push(row);
4343
+ }
4344
+ row = [];
4345
+ field = "";
4346
+ started = false;
4347
+ } else {
4348
+ field += char;
3857
4349
  }
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
4350
  }
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.");
4351
+ if (quoted) {
4352
+ throw new UsageError2("A quoted field in the CSV file is never closed.");
3888
4353
  }
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.");
4354
+ if (started || field !== "") {
4355
+ row.push(field);
4356
+ rows.push(row);
3891
4357
  }
3892
- for (const why of new Set(Object.values(unavailable))) {
3893
- context.output.error(`tula mcp: ${why}`);
4358
+ return rows;
4359
+ }
4360
+ function readRecords(text, options) {
4361
+ const body = (text.charCodeAt(0) === 65279 ? text.slice(1) : text).trim();
4362
+ if (body === "") {
4363
+ return [];
3894
4364
  }
3895
- return createTulaMcpServer({
3896
- admin,
3897
- agent,
4365
+ if (body.startsWith("[") || body.startsWith("{")) {
4366
+ let whole;
4367
+ try {
4368
+ whole = JSON.parse(body);
4369
+ } catch {
4370
+ whole = undefined;
4371
+ }
4372
+ if (Array.isArray(whole)) {
4373
+ return whole.map((entry) => isRecord3(entry) ? entry : null);
4374
+ }
4375
+ if (isRecord3(whole)) {
4376
+ for (const key of options.containers) {
4377
+ const inside = whole[key];
4378
+ if (Array.isArray(inside)) {
4379
+ return inside.map((entry) => isRecord3(entry) ? entry : null);
4380
+ }
4381
+ }
4382
+ if (!body.includes(`
4383
+ `)) {
4384
+ return [whole];
4385
+ }
4386
+ }
4387
+ if (body.startsWith("{")) {
4388
+ return body.split(/\r?\n/).filter((line) => line.trim() !== "").map((line) => {
4389
+ try {
4390
+ const parsed = JSON.parse(line);
4391
+ return isRecord3(parsed) ? parsed : null;
4392
+ } catch {
4393
+ return null;
4394
+ }
4395
+ });
4396
+ }
4397
+ throw new UsageError2(`${options.label} ${UNREADABLE}.`);
4398
+ }
4399
+ if (!options.csv) {
4400
+ throw new UsageError2(`${options.label} ${UNREADABLE}.`);
4401
+ }
4402
+ const [header, ...lines] = parseCsv(body);
4403
+ if (!header) {
4404
+ return [];
4405
+ }
4406
+ const names = header.map((name) => name.trim());
4407
+ return lines.map((fields) => {
4408
+ const record = {};
4409
+ names.forEach((name, position) => {
4410
+ if (name !== "") {
4411
+ record[name] = fields[position] ?? "";
4412
+ }
4413
+ });
4414
+ return record;
4415
+ });
4416
+ }
4417
+ function pick(record, ...keys) {
4418
+ for (const key of keys) {
4419
+ if (Object.hasOwn(record, key)) {
4420
+ const value = record[key];
4421
+ if (value !== null && value !== undefined && value !== "") {
4422
+ return value;
4423
+ }
4424
+ }
4425
+ }
4426
+ return;
4427
+ }
4428
+ function text(value) {
4429
+ if (typeof value === "number" && Number.isFinite(value)) {
4430
+ return String(value);
4431
+ }
4432
+ if (typeof value !== "string") {
4433
+ return;
4434
+ }
4435
+ const trimmed = value.trim();
4436
+ return trimmed === "" ? undefined : trimmed;
4437
+ }
4438
+ function truthy(value) {
4439
+ if (typeof value === "boolean") {
4440
+ return value;
4441
+ }
4442
+ if (typeof value === "number") {
4443
+ return value === 1;
4444
+ }
4445
+ return typeof value === "string" && /^(?:true|t|1|yes|y|on|enabled)$/i.test(value.trim());
4446
+ }
4447
+ function objectOf(value) {
4448
+ if (isRecord3(value)) {
4449
+ return value;
4450
+ }
4451
+ if (typeof value === "string" && value.trim().startsWith("{")) {
4452
+ try {
4453
+ const parsed = JSON.parse(value);
4454
+ return isRecord3(parsed) ? parsed : undefined;
4455
+ } catch {
4456
+ return;
4457
+ }
4458
+ }
4459
+ return;
4460
+ }
4461
+ function maskEmail(email) {
4462
+ if (email === null || email.trim() === "") {
4463
+ return "(no address)";
4464
+ }
4465
+ const trimmed = email.trim();
4466
+ const at = trimmed.lastIndexOf("@");
4467
+ if (at < 1) {
4468
+ return "***";
4469
+ }
4470
+ const first = Array.from(trimmed)[0] ?? "";
4471
+ const domain = trimmed.slice(at + 1);
4472
+ const dot = domain.lastIndexOf(".");
4473
+ const label = dot === -1 ? "" : domain.slice(dot).replace(/[^\w.-]/g, "");
4474
+ return `${first}***@***${label}`;
4475
+ }
4476
+ function comparable(email) {
4477
+ return email.trim().toLowerCase();
4478
+ }
4479
+ function usableEmail(email) {
4480
+ const value = text(email);
4481
+ if (value === undefined || value.length > 320 || /\s/.test(value)) {
4482
+ return;
4483
+ }
4484
+ const at = value.indexOf("@");
4485
+ return at > 0 && at === value.lastIndexOf("@") && at < value.length - 1 ? value : undefined;
4486
+ }
4487
+ var NO_EMAIL = {
4488
+ code: "email.invalid",
4489
+ reason: "The row has no usable email address."
4490
+ };
4491
+ var UNREADABLE_ROW = {
4492
+ code: "source.row_unreadable",
4493
+ reason: "The record is not a JSON object, so nothing could be read from it."
4494
+ };
4495
+ var DISABLED_USER = {
4496
+ code: "source.user_disabled",
4497
+ reason: "The user is disabled, blocked or banned at the provider, and importing would make the account usable again."
4498
+ };
4499
+ function storedSize(value) {
4500
+ const json = JSON.stringify(value);
4501
+ let extra = 0;
4502
+ for (const char of json) {
4503
+ if (char === ":" || char === ",") {
4504
+ extra += 1;
4505
+ }
4506
+ }
4507
+ return Buffer.byteLength(json) + extra;
4508
+ }
4509
+ var METADATA_LIMIT = 4000;
4510
+ function fitMetadata(value) {
4511
+ const kept = Object.fromEntries(Object.entries(value).filter(([, entry]) => entry !== undefined && entry !== null));
4512
+ if (Object.keys(kept).length === 0) {
4513
+ return;
4514
+ }
4515
+ return storedSize(kept) > METADATA_LIMIT ? "too_large" : kept;
4516
+ }
4517
+ function standardBase64(value) {
4518
+ return value.trim().replaceAll("-", "+").replaceAll("_", "/");
4519
+ }
4520
+ function readHash(hash) {
4521
+ const value = hash.trim();
4522
+ if (/^\$2[aby]\$/.test(value)) {
4523
+ return { kind: "password", password: { algorithm: "bcrypt", hash: value } };
4524
+ }
4525
+ if (value.startsWith("$argon2id$")) {
4526
+ return { kind: "password", password: { algorithm: "argon2id", hash: value } };
4527
+ }
4528
+ if (value.startsWith("$argon2i$")) {
4529
+ return { kind: "password", password: { algorithm: "argon2i", hash: value } };
4530
+ }
4531
+ if (value.startsWith("pbkdf2_sha256$") || value.startsWith("$pbkdf2-sha256$")) {
4532
+ return { kind: "password", password: { algorithm: "pbkdf2_sha256", hash: value } };
4533
+ }
4534
+ if (/^[0-9a-f]{16,64}:[0-9a-f]{64,256}$/i.test(value) || value.startsWith("$scrypt$")) {
4535
+ return {
4536
+ kind: "unsupported",
4537
+ refusal: {
4538
+ code: "import.unsupported_algorithm",
4539
+ reason: "The password is hashed with scrypt, which the server does not read yet (only bcrypt, argon2, PBKDF2-SHA256 and Firebase scrypt)."
4540
+ }
4541
+ };
4542
+ }
4543
+ return {
4544
+ kind: "unsupported",
4545
+ refusal: {
4546
+ code: "import.unsupported_algorithm",
4547
+ reason: "The password hash is in a format the server does not read (it reads bcrypt, argon2id, argon2i, PBKDF2-SHA256 and Firebase scrypt)."
4548
+ }
4549
+ };
4550
+ }
4551
+ function refused(row, email, refusal, sourceId) {
4552
+ return { row, sourceId, email, user: null, refusal };
4553
+ }
4554
+ function accepted(row, email, user, sourceId) {
4555
+ return { row, sourceId, email, user, refusal: null };
4556
+ }
4557
+ function addCountNotes(notes, counts) {
4558
+ if (counts.droppedMetadata) {
4559
+ notes.push(`${counts.droppedMetadata} user(s) had metadata over the 4 KiB limit; it was left out and the users are imported without it.`);
4560
+ }
4561
+ if (counts.passwordless) {
4562
+ notes.push(`${counts.passwordless} user(s) are imported without a password because their hash cannot be carried over (--allow-passwordless).`);
4563
+ }
4564
+ }
4565
+
4566
+ // src/import/auth0.ts
4567
+ function oidOf(record) {
4568
+ const id = record._id;
4569
+ if (isRecord3(id)) {
4570
+ return text(id.$oid);
4571
+ }
4572
+ return text(id);
4573
+ }
4574
+ function indexHashes(source) {
4575
+ const index = {
4576
+ byId: new Map,
4577
+ byEmail: new Map,
4578
+ unused: new Set,
4579
+ unreadable: 0
4580
+ };
4581
+ if (source === undefined) {
4582
+ return index;
4583
+ }
4584
+ const records = readRecords(source, {
4585
+ containers: ["users", "data"],
4586
+ csv: false,
4587
+ label: "The password hash file"
4588
+ });
4589
+ records.forEach((record, position) => {
4590
+ const hash = record === null ? undefined : text(pick(record, "passwordHash", "password_hash"));
4591
+ if (record === null || hash === undefined) {
4592
+ index.unreadable += 1;
4593
+ return;
4594
+ }
4595
+ const key = String(position);
4596
+ index.unused.add(key);
4597
+ const oid = oidOf(record);
4598
+ if (oid !== undefined) {
4599
+ index.byId.set(`auth0|${oid}`, `${key}
4600
+ ${hash}`);
4601
+ }
4602
+ const email = usableEmail(record.email)?.toLowerCase();
4603
+ if (email !== undefined) {
4604
+ index.byEmail.set(email, [...index.byEmail.get(email) ?? [], `${key}
4605
+ ${hash}`]);
4606
+ }
4607
+ });
4608
+ return index;
4609
+ }
4610
+ function hashFor(index, userId, email) {
4611
+ const found = (userId === undefined ? undefined : index.byId.get(userId)) ?? (index.byEmail.get(email.toLowerCase())?.length === 1 ? index.byEmail.get(email.toLowerCase())?.[0] : undefined);
4612
+ if (found === undefined) {
4613
+ return;
4614
+ }
4615
+ const [key, ...rest] = found.split(`
4616
+ `);
4617
+ index.unused.delete(key);
4618
+ return rest.join(`
4619
+ `);
4620
+ }
4621
+ var UNSUPPORTED = {
4622
+ code: "import.unsupported_algorithm",
4623
+ reason: "The password hash is in a format the server does not read. Auth0 password hashes are bcrypt; custom_password_hash is read for bcrypt and argon2 only."
4624
+ };
4625
+ function customHash(value) {
4626
+ if (!isRecord3(value)) {
4627
+ return;
4628
+ }
4629
+ const algorithm = text(value.algorithm)?.toLowerCase();
4630
+ const hash = isRecord3(value.hash) ? text(value.hash.value) : undefined;
4631
+ if (hash === undefined) {
4632
+ return { refusal: UNSUPPORTED };
4633
+ }
4634
+ if (algorithm === "bcrypt" || algorithm === "argon2") {
4635
+ const read = readHash(hash);
4636
+ return read.kind === "password" ? { password: read.password } : { refusal: read.refusal };
4637
+ }
4638
+ return { refusal: UNSUPPORTED };
4639
+ }
4640
+ function parseAuth0(input) {
4641
+ const notes = [];
4642
+ const counts = { droppedMetadata: 0, passwordless: 0 };
4643
+ const hashes = indexHashes(input.files["password-hashes"]);
4644
+ const records = readRecords(input.text, {
4645
+ containers: ["users", "data"],
4646
+ csv: false,
4647
+ label: "The Auth0 export"
4648
+ });
4649
+ let databaseUsers = 0;
4650
+ let withHash = 0;
4651
+ const rows = records.map((record, position) => {
4652
+ const row = position + 1;
4653
+ if (record === null) {
4654
+ return refused(row, null, UNREADABLE_ROW);
4655
+ }
4656
+ const sourceId = text(pick(record, "user_id", "id"));
4657
+ const email = usableEmail(record.email);
4658
+ if (email === undefined) {
4659
+ return refused(row, null, NO_EMAIL, sourceId);
4660
+ }
4661
+ if (truthy(record.blocked)) {
4662
+ return refused(row, email, DISABLED_USER, sourceId);
4663
+ }
4664
+ const user = { email, emailVerified: truthy(record.email_verified) };
4665
+ const name = text(record.name) ?? [text(record.given_name), text(record.family_name)].filter((part) => part !== undefined).join(" ");
4666
+ if (name !== undefined && name !== "") {
4667
+ user.name = name;
4668
+ }
4669
+ if (sourceId !== undefined) {
4670
+ user.externalId = sourceId;
4671
+ }
4672
+ const meta = fitMetadata({
4673
+ userMetadata: objectOf(record.user_metadata),
4674
+ appMetadata: objectOf(record.app_metadata),
4675
+ username: text(record.username)
4676
+ });
4677
+ if (meta === "too_large") {
4678
+ counts.droppedMetadata += 1;
4679
+ } else if (meta !== undefined) {
4680
+ user.metadata = { auth0: meta };
4681
+ }
4682
+ if (sourceId?.startsWith("auth0|")) {
4683
+ databaseUsers += 1;
4684
+ }
4685
+ const direct = text(pick(record, "passwordHash", "password_hash")) ?? hashFor(hashes, sourceId, email);
4686
+ const found = direct !== undefined ? (() => {
4687
+ const read = readHash(direct);
4688
+ return read.kind === "password" ? { password: read.password } : { refusal: read.refusal };
4689
+ })() : customHash(record.custom_password_hash);
4690
+ if (found !== undefined) {
4691
+ if ("password" in found) {
4692
+ user.password = found.password;
4693
+ withHash += 1;
4694
+ } else {
4695
+ if (!input.allowPasswordless) {
4696
+ return refused(row, email, found.refusal, sourceId);
4697
+ }
4698
+ counts.passwordless += 1;
4699
+ }
4700
+ }
4701
+ return accepted(row, email, user, sourceId);
4702
+ });
4703
+ if (input.files["password-hashes"] === undefined && databaseUsers > 0) {
4704
+ notes.push(`${databaseUsers} user(s) are database-connection users, and Auth0 does not put password hashes in the user export. Without --password-hashes they are imported without a password and reset it. See the Auth0 guide for how to ask Auth0 support for the hashes.`);
4705
+ } else if (input.files["password-hashes"] !== undefined) {
4706
+ if (hashes.unused.size > 0) {
4707
+ notes.push(`${hashes.unused.size} entr${hashes.unused.size === 1 ? "y" : "ies"} of the password hash file matched no user of the export.`);
4708
+ }
4709
+ if (hashes.unreadable > 0) {
4710
+ notes.push(`${hashes.unreadable} line(s) of the password hash file had no readable passwordHash.`);
4711
+ }
4712
+ if (withHash === 0) {
4713
+ notes.push("No password hash was matched to a user: check that the two files are from the same tenant.");
4714
+ }
4715
+ }
4716
+ addCountNotes(notes, counts);
4717
+ return { rows, organizations: [], memberships: [], notes };
4718
+ }
4719
+
4720
+ // src/import/better-auth.ts
4721
+ function parseBetterAuth(input) {
4722
+ const notes = [];
4723
+ const counts = { droppedMetadata: 0, passwordless: 0 };
4724
+ const accounts = input.files.accounts;
4725
+ const passwords = new Map;
4726
+ if (accounts !== undefined) {
4727
+ for (const record of readRecords(accounts, {
4728
+ containers: ["account", "accounts", "data"],
4729
+ csv: true,
4730
+ label: "The accounts file"
4731
+ })) {
4732
+ const provider = record === null ? undefined : text(pick(record, "providerId", "provider_id"));
4733
+ const owner = record === null ? undefined : text(pick(record, "userId", "user_id"));
4734
+ const hash = record === null ? undefined : text(record.password);
4735
+ if (provider === "credential" && owner !== undefined && hash !== undefined) {
4736
+ passwords.set(owner, hash);
4737
+ }
4738
+ }
4739
+ } else {
4740
+ notes.push("No --accounts file was given, so no password is carried over: every user is imported without one and signs in another way or resets it.");
4741
+ }
4742
+ const records = readRecords(input.text, {
4743
+ containers: ["user", "users", "data"],
4744
+ csv: true,
4745
+ label: "The better-auth user file"
4746
+ });
4747
+ const rows = records.map((record, position) => {
4748
+ const row = position + 1;
4749
+ if (record === null) {
4750
+ return refused(row, null, UNREADABLE_ROW);
4751
+ }
4752
+ const sourceId = text(pick(record, "id"));
4753
+ const email = usableEmail(record.email);
4754
+ if (email === undefined) {
4755
+ return refused(row, null, NO_EMAIL, sourceId);
4756
+ }
4757
+ if (truthy(record.banned)) {
4758
+ return refused(row, email, DISABLED_USER, sourceId);
4759
+ }
4760
+ const user = {
4761
+ email,
4762
+ emailVerified: truthy(pick(record, "emailVerified", "email_verified"))
4763
+ };
4764
+ const name = text(record.name);
4765
+ if (name !== undefined) {
4766
+ user.name = name;
4767
+ }
4768
+ if (sourceId !== undefined) {
4769
+ user.externalId = sourceId;
4770
+ }
4771
+ const meta = fitMetadata({
4772
+ username: text(record.username),
4773
+ role: text(record.role)
4774
+ });
4775
+ if (meta === "too_large") {
4776
+ counts.droppedMetadata += 1;
4777
+ } else if (meta !== undefined) {
4778
+ user.metadata = { betterAuth: meta };
4779
+ }
4780
+ const hash = sourceId === undefined ? undefined : passwords.get(sourceId);
4781
+ if (hash !== undefined) {
4782
+ const read = readHash(hash);
4783
+ if (read.kind === "password") {
4784
+ user.password = read.password;
4785
+ } else if (input.allowPasswordless) {
4786
+ counts.passwordless += 1;
4787
+ } else {
4788
+ return refused(row, email, read.refusal, sourceId);
4789
+ }
4790
+ }
4791
+ return accepted(row, email, user, sourceId);
4792
+ });
4793
+ addCountNotes(notes, counts);
4794
+ return { rows, organizations: [], memberships: [], notes };
4795
+ }
4796
+
4797
+ // src/import/clerk.ts
4798
+ import { CreateOrganizationRequestSchema } from "@tula-tech/contract";
4799
+ var HASHERS = {
4800
+ bcrypt: "bcrypt",
4801
+ argon2id: "argon2id",
4802
+ argon2i: "argon2i",
4803
+ pbkdf2_sha256: "pbkdf2_sha256",
4804
+ pbkdf2_sha256_django: "pbkdf2_sha256"
4805
+ };
4806
+ var FIREBASE_HASHER = {
4807
+ code: "import.unsupported_algorithm",
4808
+ reason: "The password is hashed with Firebase scrypt, whose project parameters a Clerk export does not hold. Import from the Firebase export with `--from firebase`, or use --allow-passwordless."
4809
+ };
4810
+ var UNKNOWN_HASHER = {
4811
+ code: "import.unsupported_algorithm",
4812
+ reason: "The password was hashed with an algorithm the server does not read (it reads bcrypt, argon2id, argon2i and PBKDF2-SHA256). Clerk's md5, sha256, scrypt, phpass and LDAP hashers are not supported."
4813
+ };
4814
+ function addressList(value) {
4815
+ if (Array.isArray(value)) {
4816
+ return value.flatMap((entry) => addressList(entry));
4817
+ }
4818
+ const single = text(value);
4819
+ return single === undefined ? [] : single.split(/[,;]/).map((part) => part.trim().toLowerCase()).filter((part) => part !== "");
4820
+ }
4821
+ function emailOf(record) {
4822
+ const listed = pick(record, "email_addresses", "emailAddresses");
4823
+ if (Array.isArray(listed)) {
4824
+ const primaryId = text(pick(record, "primary_email_address_id", "primaryEmailAddressId"));
4825
+ const entries = listed.filter(isRecord3);
4826
+ const chosen = entries.find((entry) => entry.id === primaryId) ?? entries[0];
4827
+ if (chosen) {
4828
+ const status = isRecord3(chosen.verification) ? chosen.verification.status : undefined;
4829
+ return {
4830
+ email: usableEmail(pick(chosen, "email_address", "emailAddress", "email")),
4831
+ verified: status === "verified"
4832
+ };
4833
+ }
4834
+ }
4835
+ const email = usableEmail(pick(record, "primary_email_address", "primaryEmailAddress", "email", "email_address"));
4836
+ const verified = addressList(pick(record, "verified_email_addresses", "verifiedEmailAddresses"));
4837
+ return { email, verified: email !== undefined && verified.includes(email.toLowerCase()) };
4838
+ }
4839
+ function passwordOf(record) {
4840
+ const digest = text(pick(record, "password_digest", "passwordDigest"));
4841
+ if (digest === undefined) {
4842
+ return;
4843
+ }
4844
+ const hasher = text(pick(record, "password_hasher", "passwordHasher"))?.toLowerCase();
4845
+ if (hasher === undefined) {
4846
+ const read = readHash(digest);
4847
+ return read.kind === "password" ? { password: read.password } : { refusal: read.refusal };
4848
+ }
4849
+ if (hasher === "scrypt_firebase") {
4850
+ return { refusal: FIREBASE_HASHER };
4851
+ }
4852
+ const algorithm = Object.hasOwn(HASHERS, hasher) ? HASHERS[hasher] : undefined;
4853
+ return algorithm === undefined ? { refusal: UNKNOWN_HASHER } : { password: { algorithm, hash: digest } };
4854
+ }
4855
+ function metadataOf(record) {
4856
+ const keep = {};
4857
+ const username = text(pick(record, "username"));
4858
+ if (username !== undefined) {
4859
+ keep.username = username;
4860
+ }
4861
+ for (const [key, spellings] of [
4862
+ ["publicMetadata", ["public_metadata", "publicMetadata"]],
4863
+ ["privateMetadata", ["private_metadata", "privateMetadata"]],
4864
+ ["unsafeMetadata", ["unsafe_metadata", "unsafeMetadata"]]
4865
+ ]) {
4866
+ const value = objectOf(pick(record, ...spellings));
4867
+ if (value !== undefined && Object.keys(value).length > 0) {
4868
+ keep[key] = value;
4869
+ }
4870
+ }
4871
+ return keep;
4872
+ }
4873
+ function parseClerk(input) {
4874
+ const notes = [];
4875
+ const counts = { droppedMetadata: 0, passwordless: 0 };
4876
+ const records = readRecords(input.text, {
4877
+ containers: ["data", "users"],
4878
+ csv: true,
4879
+ label: "The Clerk export"
4880
+ });
4881
+ let withTotp = 0;
4882
+ const rows = records.map((record, position) => {
4883
+ const row = position + 1;
4884
+ if (record === null) {
4885
+ return refused(row, null, UNREADABLE_ROW);
4886
+ }
4887
+ const sourceId = text(pick(record, "id", "user_id", "userId"));
4888
+ const { email, verified } = emailOf(record);
4889
+ if (email === undefined) {
4890
+ return refused(row, null, NO_EMAIL, sourceId);
4891
+ }
4892
+ if (truthy(pick(record, "banned", "locked"))) {
4893
+ return refused(row, email, DISABLED_USER, sourceId);
4894
+ }
4895
+ if (text(pick(record, "totp_secret", "totpSecret")) !== undefined) {
4896
+ withTotp += 1;
4897
+ }
4898
+ const user = { email, emailVerified: verified };
4899
+ const name = [
4900
+ text(pick(record, "first_name", "firstName")),
4901
+ text(pick(record, "last_name", "lastName"))
4902
+ ].filter((part) => part !== undefined).join(" ");
4903
+ if (name !== "") {
4904
+ user.name = name;
4905
+ }
4906
+ if (sourceId !== undefined) {
4907
+ user.externalId = sourceId;
4908
+ }
4909
+ const meta = fitMetadata(metadataOf(record));
4910
+ if (meta === "too_large") {
4911
+ counts.droppedMetadata += 1;
4912
+ } else if (meta !== undefined) {
4913
+ user.metadata = { clerk: meta };
4914
+ }
4915
+ const password = passwordOf(record);
4916
+ if (password !== undefined) {
4917
+ if ("password" in password) {
4918
+ user.password = password.password;
4919
+ } else {
4920
+ if (!input.allowPasswordless) {
4921
+ return refused(row, email, password.refusal, sourceId);
4922
+ }
4923
+ counts.passwordless += 1;
4924
+ }
4925
+ }
4926
+ return accepted(row, email, user, sourceId);
4927
+ });
4928
+ if (withTotp > 0) {
4929
+ notes.push(`${withTotp} user(s) had an authenticator app at Clerk. Two-step verification is not carried over: they enrol again in Tula.`);
4930
+ }
4931
+ addCountNotes(notes, counts);
4932
+ const { organizations, memberships } = parseOrganizations(input, notes);
4933
+ return { rows, organizations, memberships, notes };
4934
+ }
4935
+ function slugOf(value) {
4936
+ return value.normalize("NFKD").replace(/[\u0300-\u036F]/g, "").toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 48).replace(/-+$/g, "");
4937
+ }
4938
+ function roleOf(value) {
4939
+ if (value === undefined) {
4940
+ return;
4941
+ }
4942
+ const key = value.toLowerCase().replace(/^org:/, "").replace(/[^a-z0-9_]+/g, "_");
4943
+ return key === "basic_member" ? "member" : key;
4944
+ }
4945
+ function listOf(source, label) {
4946
+ return source === undefined ? [] : readRecords(source, { containers: ["data"], csv: false, label });
4947
+ }
4948
+ function parseOrganizations(input, notes) {
4949
+ const organizations = listOf(input.files.organizations, "The organizations file").map((record, position) => {
4950
+ const row = position + 1;
4951
+ if (record === null) {
4952
+ return { row, sourceId: "", name: null, slug: null, refusal: UNREADABLE_ROW };
4953
+ }
4954
+ const sourceId = text(pick(record, "id", "organization_id")) ?? "";
4955
+ const name = text(pick(record, "name"));
4956
+ const slug = slugOf(text(pick(record, "slug")) ?? name ?? "");
4957
+ const checked = CreateOrganizationRequestSchema.safeParse({ name, slug });
4958
+ if (sourceId === "" || !checked.success) {
4959
+ return {
4960
+ row,
4961
+ sourceId,
4962
+ name: null,
4963
+ slug: null,
4964
+ refusal: {
4965
+ code: "source.organization_invalid",
4966
+ reason: "The organization has no id, or no name and slug the API accepts."
4967
+ }
4968
+ };
4969
+ }
4970
+ return { row, sourceId, name: checked.data.name, slug: checked.data.slug, refusal: null };
4971
+ });
4972
+ const memberships = listOf(input.files.memberships, "The memberships file").map((record, position) => {
4973
+ const row = position + 1;
4974
+ const invalid = (organizationId, userId) => ({
4975
+ row,
4976
+ organizationId,
4977
+ userId,
4978
+ role: null,
4979
+ refusal: {
4980
+ code: "source.membership_invalid",
4981
+ reason: "The membership has no organization, no user or no role."
4982
+ }
4983
+ });
4984
+ if (record === null) {
4985
+ return invalid("", "");
4986
+ }
4987
+ const organization = isRecord3(record.organization) ? record.organization : undefined;
4988
+ const data = isRecord3(record.public_user_data) ? record.public_user_data : undefined;
4989
+ const organizationId = text(pick(record, "organization_id", "organizationId")) ?? text(organization?.id) ?? "";
4990
+ const userId = text(pick(record, "user_id", "userId")) ?? text(data?.user_id) ?? text(data?.userId) ?? "";
4991
+ const role = roleOf(text(pick(record, "role", "role_name")));
4992
+ if (organizationId === "" || userId === "" || role === undefined || !/^[a-z][a-z0-9_]{0,31}$/.test(role)) {
4993
+ return invalid(organizationId, userId);
4994
+ }
4995
+ return { row, organizationId, userId, role, refusal: null };
4996
+ });
4997
+ if (memberships.length > 0 && organizations.length === 0) {
4998
+ notes.push("A memberships file was given without an organizations file; no organization is created.");
4999
+ }
5000
+ return { organizations, memberships };
5001
+ }
5002
+
5003
+ // src/import/firebase.ts
5004
+ function parseHashConfig(source) {
5005
+ const values = new Map;
5006
+ const put = (key, value) => {
5007
+ const name = key.replace(/[_-]/g, "").toLowerCase();
5008
+ if (values.has(name)) {
5009
+ throw new UsageError2(`The Firebase hash config file names ${name} twice. Keep one value and run the command again.`);
5010
+ }
5011
+ values.set(name, value);
5012
+ };
5013
+ const trimmed = source.trim();
5014
+ const json = trimmed.startsWith("{") ? objectOf(trimmed) : undefined;
5015
+ if (json !== undefined) {
5016
+ for (const [key, value] of Object.entries(json)) {
5017
+ if (typeof value === "string" || typeof value === "number") {
5018
+ put(key, String(value));
5019
+ }
5020
+ }
5021
+ } else {
5022
+ for (const match of trimmed.matchAll(/([A-Za-z_][\w-]*)\s*[:=]\s*"?([^\s",}]+)"?/g)) {
5023
+ put(match[1], match[2]);
5024
+ }
5025
+ }
5026
+ const signerKey = values.get("base64signerkey") ?? values.get("signerkey");
5027
+ if (signerKey === undefined || signerKey === "") {
5028
+ throw new UsageError2("The Firebase hash config file has no base64_signer_key. It is the file or console text with base64_signer_key, base64_salt_separator, rounds and mem_cost.");
5029
+ }
5030
+ const number = (name) => {
5031
+ const value = values.get(name);
5032
+ return value === undefined || !/^\d{1,3}$/.test(value) ? undefined : Number(value);
5033
+ };
5034
+ return {
5035
+ signerKey: standardBase64(signerKey),
5036
+ saltSeparator: standardBase64(values.get("base64saltseparator") ?? values.get("saltseparator") ?? ""),
5037
+ rounds: number("rounds"),
5038
+ memCost: number("memcost")
5039
+ };
5040
+ }
5041
+ var NO_SALT = {
5042
+ code: "import.hash_malformed",
5043
+ reason: "The user has a passwordHash but no salt, so the hash cannot be checked later."
5044
+ };
5045
+ var NO_CONFIG = {
5046
+ code: "import.hash_malformed",
5047
+ reason: "The user has a password hash and no Firebase hash parameters were given (--firebase-hash-config)."
5048
+ };
5049
+ function parseFirebase(input) {
5050
+ const notes = [];
5051
+ const counts = { droppedMetadata: 0, passwordless: 0 };
5052
+ const records = readRecords(input.text, {
5053
+ containers: ["users"],
5054
+ csv: false,
5055
+ label: "The Firebase export"
5056
+ });
5057
+ const needsConfig = records.filter((record) => record !== null && text(record.passwordHash) !== undefined);
5058
+ if (needsConfig.length > 0 && input.firebase === undefined && !input.allowPasswordless) {
5059
+ throw new UsageError2(`${needsConfig.length} user(s) of the export have a password hash, and Firebase's hash parameters are needed to carry them: pass --firebase-hash-config <file> (Authentication, Users, the menu, Password hash parameters), or --allow-passwordless to import them without a password.`);
5060
+ }
5061
+ const rows = records.map((record, position) => {
5062
+ const row = position + 1;
5063
+ if (record === null) {
5064
+ return refused(row, null, UNREADABLE_ROW);
5065
+ }
5066
+ const sourceId = text(pick(record, "localId", "uid"));
5067
+ const email = usableEmail(record.email);
5068
+ if (email === undefined) {
5069
+ return refused(row, null, NO_EMAIL, sourceId);
5070
+ }
5071
+ if (truthy(record.disabled)) {
5072
+ return refused(row, email, DISABLED_USER, sourceId);
5073
+ }
5074
+ const user = { email, emailVerified: truthy(record.emailVerified) };
5075
+ const name = text(record.displayName);
5076
+ if (name !== undefined) {
5077
+ user.name = name;
5078
+ }
5079
+ if (sourceId !== undefined) {
5080
+ user.externalId = sourceId;
5081
+ }
5082
+ const meta = fitMetadata({ customAttributes: objectOf(record.customAttributes) });
5083
+ if (meta === "too_large") {
5084
+ counts.droppedMetadata += 1;
5085
+ } else if (meta !== undefined) {
5086
+ user.metadata = { firebase: meta };
5087
+ }
5088
+ const hash = text(record.passwordHash);
5089
+ if (hash !== undefined) {
5090
+ const salt = text(record.salt);
5091
+ const missing = input.firebase === undefined ? NO_CONFIG : salt === undefined ? NO_SALT : null;
5092
+ if (missing === null && salt !== undefined) {
5093
+ user.password = {
5094
+ algorithm: "firebase_scrypt",
5095
+ hash: standardBase64(hash),
5096
+ params: { salt: standardBase64(salt) }
5097
+ };
5098
+ } else if (input.allowPasswordless) {
5099
+ counts.passwordless += 1;
5100
+ } else {
5101
+ return refused(row, email, missing ?? NO_SALT, sourceId);
5102
+ }
5103
+ }
5104
+ return accepted(row, email, user, sourceId);
5105
+ });
5106
+ addCountNotes(notes, counts);
5107
+ const usesScrypt = rows.some((entry) => entry.user?.password?.algorithm === "firebase_scrypt");
5108
+ const firebaseScrypt = usesScrypt && input.firebase !== undefined ? input.firebase : undefined;
5109
+ return { rows, firebaseScrypt, organizations: [], memberships: [], notes };
5110
+ }
5111
+
5112
+ // src/import/report.ts
5113
+ var SERVER_REASONS = {
5114
+ "email.invalid": "The server does not accept the address.",
5115
+ "import.unsupported_algorithm": "The server does not read this password hash algorithm.",
5116
+ "import.hash_malformed": "The password hash is not a well-formed hash of its algorithm.",
5117
+ "import.hash_parameters_too_weak": "The password hash has parameters below the server\u2019s minimum (see docs/user-import.md).",
5118
+ "import.hash_parameters_too_costly": "The password hash has parameters above the server\u2019s maximum (see docs/user-import.md).",
5119
+ "validation.failed": "The row\u2019s name, metadata or external id cannot be stored.",
5120
+ "source.organization_missing": "The membership\u2019s organization was not created.",
5121
+ "source.member_user_missing": "The membership\u2019s user is not in the environment.",
5122
+ "organization.role_unknown": "The role does not exist in the environment.",
5123
+ "organization.slug_taken": "The slug is taken and no organization with it could be found."
5124
+ };
5125
+ function reasonOf(code) {
5126
+ return Object.hasOwn(SERVER_REASONS, code) ? SERVER_REASONS[code] : "See the code.";
5127
+ }
5128
+ function summarize(parsed) {
5129
+ const algorithms = {};
5130
+ const seen = new Set;
5131
+ const summary = {
5132
+ total: parsed.rows.length,
5133
+ ready: 0,
5134
+ algorithms,
5135
+ withoutPassword: 0,
5136
+ repeated: 0,
5137
+ refused: [],
5138
+ organizations: parsed.organizations.length,
5139
+ memberships: parsed.memberships.length
5140
+ };
5141
+ for (const entry of parsed.rows) {
5142
+ if (entry.user === null || entry.refusal !== null) {
5143
+ if (entry.refusal) {
5144
+ summary.refused.push({
5145
+ kind: "user",
5146
+ row: entry.row,
5147
+ code: entry.refusal.code,
5148
+ email: maskEmail(entry.email),
5149
+ reason: entry.refusal.reason
5150
+ });
5151
+ }
5152
+ continue;
5153
+ }
5154
+ summary.ready += 1;
5155
+ const algorithm = entry.user.password?.algorithm;
5156
+ if (algorithm === undefined) {
5157
+ summary.withoutPassword += 1;
5158
+ } else {
5159
+ algorithms[algorithm] = (algorithms[algorithm] ?? 0) + 1;
5160
+ }
5161
+ const key = comparable(entry.user.email);
5162
+ if (seen.has(key)) {
5163
+ summary.repeated += 1;
5164
+ }
5165
+ seen.add(key);
5166
+ }
5167
+ for (const entry of parsed.organizations) {
5168
+ if (entry.refusal) {
5169
+ summary.refused.push({
5170
+ kind: "organization",
5171
+ row: entry.row,
5172
+ code: entry.refusal.code,
5173
+ reason: entry.refusal.reason
5174
+ });
5175
+ }
5176
+ }
5177
+ for (const entry of parsed.memberships) {
5178
+ if (entry.refusal) {
5179
+ summary.refused.push({
5180
+ kind: "membership",
5181
+ row: entry.row,
5182
+ code: entry.refusal.code,
5183
+ reason: entry.refusal.reason
5184
+ });
5185
+ }
5186
+ }
5187
+ return summary;
5188
+ }
5189
+ function serverRefusals(parsed, progress) {
5190
+ const emails = new Map(parsed.rows.map((entry) => [entry.row, entry.email]));
5191
+ const lines = progress.refused.map((item) => ({
5192
+ kind: "user",
5193
+ row: item.row,
5194
+ code: item.code,
5195
+ email: maskEmail(emails.get(item.row) ?? null),
5196
+ reason: reasonOf(item.code)
5197
+ }));
5198
+ for (const item of progress.organizations.failed) {
5199
+ lines.push({
5200
+ kind: "organization",
5201
+ row: item.row,
5202
+ code: item.code,
5203
+ reason: reasonOf(item.code)
5204
+ });
5205
+ }
5206
+ for (const item of progress.memberships.failed) {
5207
+ lines.push({ kind: "membership", row: item.row, code: item.code, reason: reasonOf(item.code) });
5208
+ }
5209
+ return lines;
5210
+ }
5211
+ function plural2(count, one, many = `${one}s`) {
5212
+ return `${count} ${count === 1 ? one : many}`;
5213
+ }
5214
+ function refusedLines(output, lines) {
5215
+ if (lines.length === 0) {
5216
+ return;
5217
+ }
5218
+ output.line();
5219
+ output.line(output.style.bold(`Refused (${lines.length}):`));
5220
+ for (const line of lines) {
5221
+ const who = line.email === undefined ? "" : ` ${line.email}`;
5222
+ output.line(` ${line.kind} ${line.row} ${line.code}${who} ${line.reason}`);
5223
+ }
5224
+ }
5225
+ function renderDryRun(output, provider, parsed, summary) {
5226
+ output.line(output.style.bold(`Dry run: ${provider} export. Nothing was sent.`));
5227
+ output.line();
5228
+ output.line(`Users in the file: ${summary.total}`);
5229
+ output.line(` would be imported: ${summary.ready}`);
5230
+ for (const [algorithm, count] of Object.entries(summary.algorithms).sort()) {
5231
+ output.line(` with ${algorithm} password: ${count}`);
5232
+ }
5233
+ output.line(` without a password: ${summary.withoutPassword}`);
5234
+ if (summary.repeated > 0) {
5235
+ output.line(` repeating an address: ${summary.repeated} (the server skips them: the first row wins)`);
5236
+ }
5237
+ output.line(` refused here: ${summary.refused.filter((line) => line.kind === "user").length}`);
5238
+ if (summary.organizations > 0 || summary.memberships > 0) {
5239
+ output.line(`Organizations: ${summary.organizations}`);
5240
+ output.line(`Memberships: ${summary.memberships}`);
5241
+ }
5242
+ for (const note of parsed.notes) {
5243
+ output.line();
5244
+ output.line(`Note: ${note}`);
5245
+ }
5246
+ refusedLines(output, summary.refused);
5247
+ output.line();
5248
+ output.line(summary.ready === 0 && summary.organizations === 0 ? "Nothing would be imported." : "Run the same command with --yes to import. It is safe to run again: users that exist are skipped.");
5249
+ }
5250
+ function renderResult(output, provider, parsed, summary, progress) {
5251
+ const server = serverRefusals(parsed, progress);
5252
+ const all = [...summary.refused, ...server];
5253
+ output.line(output.style.bold(`Imported the ${provider} export.`));
5254
+ output.line();
5255
+ output.line(`Users created: ${progress.counts.created}`);
5256
+ output.line(`Users skipped: ${progress.counts.skipped} (their address was already a user)`);
5257
+ output.line(`Users refused: ${summary.refused.filter((line) => line.kind === "user").length + progress.counts.refused}`);
5258
+ if (summary.organizations > 0 || summary.memberships > 0) {
5259
+ const orgs = progress.organizations;
5260
+ const members = progress.memberships;
5261
+ output.line(`Organizations: ${orgs.created} created, ${orgs.existing} already there, ${orgs.failed.length} failed`);
5262
+ output.line(`Memberships: ${members.added} added, ${members.existing} already members, ${members.failed.length} failed`);
5263
+ }
5264
+ for (const note of parsed.notes) {
5265
+ output.line();
5266
+ output.line(`Note: ${note}`);
5267
+ }
5268
+ refusedLines(output, all);
5269
+ output.line();
5270
+ output.line(all.length === 0 ? "Done." : `Done, with ${plural2(all.length, "refused item")}. Fix the export and run the same command again to retry: what exists is skipped.`);
5271
+ return all.length;
5272
+ }
5273
+ function reportToJson(provider, parsed, summary, progress) {
5274
+ return {
5275
+ provider,
5276
+ dryRun: progress === undefined,
5277
+ users: {
5278
+ total: summary.total,
5279
+ ready: summary.ready,
5280
+ withoutPassword: summary.withoutPassword,
5281
+ algorithms: summary.algorithms,
5282
+ repeatedAddresses: summary.repeated
5283
+ },
5284
+ organizations: summary.organizations,
5285
+ memberships: summary.memberships,
5286
+ notes: parsed.notes,
5287
+ refused: [...summary.refused, ...progress ? serverRefusals(parsed, progress) : []],
5288
+ ...progress ? {
5289
+ result: {
5290
+ created: progress.counts.created,
5291
+ skipped: progress.counts.skipped,
5292
+ refusedByServer: progress.counts.refused,
5293
+ organizations: {
5294
+ created: progress.organizations.created,
5295
+ existing: progress.organizations.existing,
5296
+ failed: progress.organizations.failed.length
5297
+ },
5298
+ memberships: {
5299
+ added: progress.memberships.added,
5300
+ existing: progress.memberships.existing,
5301
+ failed: progress.memberships.failed.length
5302
+ }
5303
+ }
5304
+ } : {}
5305
+ };
5306
+ }
5307
+
5308
+ // src/import/run.ts
5309
+ import { isTulaAdminError as isTulaAdminError9 } from "@tula-tech/admin";
5310
+ import { MAX_IMPORT_ROWS } from "@tula-tech/contract";
5311
+ var BATCH_BYTE_LIMIT = 4000000;
5312
+ var MAX_ATTEMPTS = 6;
5313
+ var BACKOFF_MS = 2000;
5314
+ function say(context, text) {
5315
+ context.output.error(text);
5316
+ }
5317
+ function isRateLimited(error) {
5318
+ return error.status === 429 || error.code === "rate_limited";
5319
+ }
5320
+ function isTransient(error) {
5321
+ return error.status === 0 || error.status >= 500;
5322
+ }
5323
+ async function withRetry(context, what, call) {
5324
+ for (let attempt = 1;; attempt += 1) {
5325
+ try {
5326
+ return await call();
5327
+ } catch (error) {
5328
+ if (!isTulaAdminError9(error) || attempt >= MAX_ATTEMPTS) {
5329
+ throw error;
5330
+ }
5331
+ let wait;
5332
+ if (isRateLimited(error)) {
5333
+ wait = Math.min(error.retryAfterMs ?? BACKOFF_MS * attempt, context.maxWaitMs);
5334
+ say(context, `The rate limit was reached; waiting ${Math.ceil(wait / 1000)}s before ${what}.`);
5335
+ } else if (isTransient(error)) {
5336
+ wait = BACKOFF_MS * attempt;
5337
+ say(context, `The server did not answer (${error.status === 0 ? "no response" : `HTTP ${error.status}`}); trying ${what} again in ${Math.ceil(wait / 1000)}s.`);
5338
+ } else {
5339
+ throw error;
5340
+ }
5341
+ await context.host.sleep(Math.max(0, wait));
5342
+ }
5343
+ }
5344
+ }
5345
+ async function save(context, progress) {
5346
+ await context.host.writeSecretFile(context.progressPath, serializeProgress(progress));
5347
+ }
5348
+ function setOwn(record, key, value) {
5349
+ Object.defineProperty(record, key, {
5350
+ value,
5351
+ enumerable: true,
5352
+ writable: true,
5353
+ configurable: true
5354
+ });
5355
+ }
5356
+ function codeOf(error) {
5357
+ return /^[a-z][a-z0-9_]*(?:\.[a-z][a-z0-9_]*)*$/.test(error.code) && error.code.length <= 80 ? error.code : "unknown";
5358
+ }
5359
+ function nextBatch(rows, after, batchSize, extraBytes) {
5360
+ const batch = [];
5361
+ let bytes = extraBytes + 32;
5362
+ for (const entry of rows) {
5363
+ if (entry.row <= after || entry.user === null) {
5364
+ continue;
5365
+ }
5366
+ const size = Buffer.byteLength(JSON.stringify(entry.user)) + 1;
5367
+ if (batch.length > 0 && (batch.length >= batchSize || bytes + size > BATCH_BYTE_LIMIT)) {
5368
+ break;
5369
+ }
5370
+ batch.push(entry);
5371
+ bytes += size;
5372
+ }
5373
+ return batch;
5374
+ }
5375
+ function importable(parsed) {
5376
+ return parsed.rows.filter((entry) => entry.user !== null).length;
5377
+ }
5378
+ async function importUsers(context, parsed, progress) {
5379
+ const total = importable(parsed);
5380
+ const batchSize = Math.min(context.batchSize, MAX_IMPORT_ROWS);
5381
+ const firebaseBytes = parsed.firebaseScrypt ? Buffer.byteLength(JSON.stringify(parsed.firebaseScrypt)) : 0;
5382
+ const keepIds = parsed.memberships.length > 0;
5383
+ for (;; ) {
5384
+ const batch = nextBatch(parsed.rows, progress.doneThrough, batchSize, firebaseBytes);
5385
+ if (batch.length === 0) {
5386
+ return;
5387
+ }
5388
+ const users = batch.map((entry) => entry.user);
5389
+ const needsFirebase = parsed.firebaseScrypt !== undefined && users.some((user) => user.password?.algorithm === "firebase_scrypt");
5390
+ let retried = false;
5391
+ const { data } = await withRetry(context, "importing", async () => {
5392
+ try {
5393
+ return await context.admin.call("importUsers", {
5394
+ body: needsFirebase ? { users, firebaseScrypt: parsed.firebaseScrypt } : { users }
5395
+ });
5396
+ } catch (error) {
5397
+ if (isTulaAdminError9(error) && isTransient(error)) {
5398
+ retried = true;
5399
+ }
5400
+ throw error;
5401
+ }
5402
+ });
5403
+ if (data.results.length !== batch.length) {
5404
+ throw new UsageError2("The server answered a batch with a different number of results than rows sent. Nothing more was sent; the progress file is unchanged.");
5405
+ }
5406
+ for (const result of data.results) {
5407
+ const entry = batch[result.index];
5408
+ if (entry === undefined) {
5409
+ continue;
5410
+ }
5411
+ if (result.status === "created") {
5412
+ progress.counts.created += 1;
5413
+ if (keepIds && entry.sourceId !== undefined) {
5414
+ setOwn(progress.userIds, entry.sourceId, result.id);
5415
+ }
5416
+ } else if (result.status === "skipped") {
5417
+ if (keepIds && entry.sourceId !== undefined && result.id !== undefined) {
5418
+ setOwn(progress.userIds, entry.sourceId, result.id);
5419
+ }
5420
+ if (retried && result.sameExternalId === true) {
5421
+ progress.counts.created += 1;
5422
+ } else {
5423
+ progress.counts.skipped += 1;
5424
+ }
5425
+ } else {
5426
+ progress.counts.refused += 1;
5427
+ progress.refused.push({
5428
+ row: entry.row,
5429
+ code: /^[a-z][a-z0-9_.]{0,79}$/.test(result.code) ? result.code : "unknown"
5430
+ });
5431
+ }
5432
+ }
5433
+ progress.doneThrough = batch[batch.length - 1].row;
5434
+ progress.batches += 1;
5435
+ await save(context, progress);
5436
+ const done = progress.counts.created + progress.counts.skipped + progress.counts.refused;
5437
+ say(context, `Imported ${done} of ${total} users (batch ${progress.batches}).`);
5438
+ }
5439
+ }
5440
+ async function findUser(context, cache, email) {
5441
+ const key = comparable(email);
5442
+ const cached = cache.get(key);
5443
+ if (cached !== undefined) {
5444
+ return cached;
5445
+ }
5446
+ const { data } = await withRetry(context, "looking a user up", () => context.admin.call("listUsers", { query: { q: email, size: 20 } }));
5447
+ const match = data.data.find((user) => user.email !== null && comparable(user.email) === key);
5448
+ cache.set(key, match?.id ?? null);
5449
+ return match?.id ?? null;
5450
+ }
5451
+ async function findOrganization(context, slug) {
5452
+ for (let page = 1;; page += 1) {
5453
+ const { data } = await withRetry(context, "looking an organization up", () => context.admin.call("listOrganizations", { query: { page, size: 100 } }));
5454
+ const match = data.data.find((organization) => organization.slug === slug);
5455
+ if (match) {
5456
+ return match.id;
5457
+ }
5458
+ if (page >= data.meta.totalPages) {
5459
+ return null;
5460
+ }
5461
+ }
5462
+ }
5463
+ var DISABLED = "Organizations are switched off in this environment (organizations.enabled). Switch them on and run the same command again: it continues from the progress file.";
5464
+ async function importOrganizations(context, parsed, progress) {
5465
+ const organizations = progress.organizations;
5466
+ while (organizations.cursor < parsed.organizations.length) {
5467
+ const entry = parsed.organizations[organizations.cursor];
5468
+ if (entry.refusal === null && entry.name !== null && entry.slug !== null) {
5469
+ try {
5470
+ const { data } = await withRetry(context, "creating an organization", () => context.admin.call("createOrganization", {
5471
+ body: { name: entry.name, slug: entry.slug }
5472
+ }));
5473
+ setOwn(organizations.ids, entry.sourceId, data.id);
5474
+ organizations.created += 1;
5475
+ } catch (error) {
5476
+ if (!isTulaAdminError9(error)) {
5477
+ throw error;
5478
+ }
5479
+ if (error.code === "organization.disabled") {
5480
+ throw new UsageError2(DISABLED);
5481
+ }
5482
+ if (error.code === "organization.slug_taken") {
5483
+ const id = await findOrganization(context, entry.slug);
5484
+ if (id === null) {
5485
+ organizations.failed.push({ row: entry.row, code: "organization.slug_taken" });
5486
+ } else {
5487
+ setOwn(organizations.ids, entry.sourceId, id);
5488
+ organizations.existing += 1;
5489
+ }
5490
+ } else if (isRateLimited(error) || isTransient(error)) {
5491
+ throw error;
5492
+ } else {
5493
+ organizations.failed.push({ row: entry.row, code: codeOf(error) });
5494
+ }
5495
+ }
5496
+ }
5497
+ organizations.cursor += 1;
5498
+ await save(context, progress);
5499
+ }
5500
+ const emails = new Map;
5501
+ for (const entry of parsed.rows) {
5502
+ if (entry.sourceId !== undefined && entry.email !== null) {
5503
+ emails.set(entry.sourceId, entry.email);
5504
+ }
5505
+ }
5506
+ const cache = new Map;
5507
+ const members = progress.memberships;
5508
+ while (members.cursor < parsed.memberships.length) {
5509
+ const entry = parsed.memberships[members.cursor];
5510
+ if (entry.refusal === null && entry.role !== null) {
5511
+ const organizationId = Object.hasOwn(organizations.ids, entry.organizationId) ? organizations.ids[entry.organizationId] : undefined;
5512
+ const email = emails.get(entry.userId);
5513
+ const userId = Object.hasOwn(progress.userIds, entry.userId) ? progress.userIds[entry.userId] : email === undefined ? null : await findUser(context, cache, email);
5514
+ if (organizationId === undefined) {
5515
+ members.failed.push({ row: entry.row, code: "source.organization_missing" });
5516
+ } else if (userId === null || userId === undefined) {
5517
+ members.failed.push({ row: entry.row, code: "source.member_user_missing" });
5518
+ } else {
5519
+ try {
5520
+ await withRetry(context, "adding a member", () => context.admin.call("addOrganizationMember", {
5521
+ params: { id: organizationId },
5522
+ body: { userId, role: entry.role }
5523
+ }));
5524
+ members.added += 1;
5525
+ } catch (error) {
5526
+ if (!isTulaAdminError9(error)) {
5527
+ throw error;
5528
+ }
5529
+ if (error.code === "organization.disabled") {
5530
+ throw new UsageError2(DISABLED);
5531
+ }
5532
+ if (error.code === "organization.member_exists") {
5533
+ members.existing += 1;
5534
+ } else if (isRateLimited(error) || isTransient(error)) {
5535
+ throw error;
5536
+ } else {
5537
+ members.failed.push({ row: entry.row, code: codeOf(error) });
5538
+ }
5539
+ }
5540
+ }
5541
+ }
5542
+ members.cursor += 1;
5543
+ await save(context, progress);
5544
+ }
5545
+ }
5546
+
5547
+ // src/import/supabase.ts
5548
+ var DELETED = {
5549
+ code: "source.user_deleted",
5550
+ reason: "The user is soft-deleted at Supabase (`deleted_at` is set)."
5551
+ };
5552
+ var ANONYMOUS = {
5553
+ code: "email.invalid",
5554
+ reason: "The row is an anonymous user and has no email address."
5555
+ };
5556
+ function stillAhead(value, now) {
5557
+ const iso = text(value);
5558
+ if (iso === undefined) {
5559
+ return false;
5560
+ }
5561
+ const at = Date.parse(iso);
5562
+ return Number.isNaN(at) ? true : at > now;
5563
+ }
5564
+ function parseSupabase(input, now = Date.now()) {
5565
+ const notes = [];
5566
+ const counts = { droppedMetadata: 0, passwordless: 0 };
5567
+ const records = readRecords(input.text, {
5568
+ containers: ["users", "data"],
5569
+ csv: true,
5570
+ label: "The Supabase export"
5571
+ });
5572
+ const rows = records.map((record, position) => {
5573
+ const row = position + 1;
5574
+ if (record === null) {
5575
+ return refused(row, null, UNREADABLE_ROW);
5576
+ }
5577
+ const sourceId = text(pick(record, "id"));
5578
+ const email = usableEmail(record.email);
5579
+ if (pick(record, "deleted_at") !== undefined) {
5580
+ return refused(row, email ?? null, DELETED, sourceId);
5581
+ }
5582
+ if (email === undefined) {
5583
+ return refused(row, null, truthy(record.is_anonymous) ? ANONYMOUS : NO_EMAIL, sourceId);
5584
+ }
5585
+ if (stillAhead(record.banned_until, now)) {
5586
+ return refused(row, email, DISABLED_USER, sourceId);
5587
+ }
5588
+ const metaUser = objectOf(record.raw_user_meta_data);
5589
+ const user = {
5590
+ email,
5591
+ emailVerified: pick(record, "email_confirmed_at", "confirmed_at") !== undefined
5592
+ };
5593
+ const nameValue = text(metaUser?.full_name) ?? text(metaUser?.name);
5594
+ if (nameValue !== undefined) {
5595
+ user.name = nameValue;
5596
+ }
5597
+ if (sourceId !== undefined) {
5598
+ user.externalId = sourceId;
5599
+ }
5600
+ const meta = fitMetadata({
5601
+ userMetadata: metaUser,
5602
+ appMetadata: objectOf(record.raw_app_meta_data)
5603
+ });
5604
+ if (meta === "too_large") {
5605
+ counts.droppedMetadata += 1;
5606
+ } else if (meta !== undefined) {
5607
+ user.metadata = { supabase: meta };
5608
+ }
5609
+ const hash = text(record.encrypted_password);
5610
+ if (hash !== undefined) {
5611
+ const read = readHash(hash);
5612
+ if (read.kind === "password") {
5613
+ user.password = read.password;
5614
+ } else if (input.allowPasswordless) {
5615
+ counts.passwordless += 1;
5616
+ } else {
5617
+ return refused(row, email, read.refusal, sourceId);
5618
+ }
5619
+ }
5620
+ return accepted(row, email, user, sourceId);
5621
+ });
5622
+ addCountNotes(notes, counts);
5623
+ return { rows, organizations: [], memberships: [], notes };
5624
+ }
5625
+
5626
+ // src/commands/import.ts
5627
+ var USAGE2 = `tula import --from <provider> <export file> [--yes] [--progress-file <path>] [--fresh] [--allow-passwordless] [--no-trust-verified]
5628
+ ` + ` [--password-hashes <file>] [--accounts <file>] [--organizations <file>] [--memberships <file>]
5629
+ ` + ` [--firebase-hash-config <file>] [--firebase-rounds <n>] [--firebase-mem-cost <n>]
5630
+ ` + " [--batch-size <n>] [--max-wait <seconds>] [--env <name>] [--api-url <url>] [--secret-key-file <path>] [--json]";
5631
+ var PARSERS = {
5632
+ clerk: parseClerk,
5633
+ auth0: parseAuth0,
5634
+ firebase: parseFirebase,
5635
+ supabase: (input) => parseSupabase(input),
5636
+ "better-auth": parseBetterAuth
5637
+ };
5638
+ var EXTRA_FILES = {
5639
+ clerk: ["organizations", "memberships"],
5640
+ auth0: ["password-hashes"],
5641
+ firebase: [],
5642
+ supabase: [],
5643
+ "better-auth": ["accounts"]
5644
+ };
5645
+ var ALL_EXTRA = ["organizations", "memberships", "password-hashes", "accounts"];
5646
+ var DEFAULT_MAX_WAIT_SECONDS = 120;
5647
+ function whole(flags, name, min, max) {
5648
+ const value = flags[name];
5649
+ if (typeof value !== "string") {
5650
+ return;
5651
+ }
5652
+ const number = /^\d{1,6}$/.test(value) ? Number(value) : Number.NaN;
5653
+ if (!(number >= min && number <= max)) {
5654
+ throw new UsageError2(`--${name} takes a whole number from ${min} to ${max}.`);
5655
+ }
5656
+ return number;
5657
+ }
5658
+ async function readInput(context, path, option) {
5659
+ const host = context.io.host;
5660
+ if (!host) {
5661
+ throw new UsageError2("This run cannot read files.");
5662
+ }
5663
+ const found = await host.readFile(resolve2(context.io.cwd, path));
5664
+ if (found === null) {
5665
+ throw new UsageError2(`The file given as ${option} does not exist.`);
5666
+ }
5667
+ return found;
5668
+ }
5669
+ async function firebaseParameters(context) {
5670
+ const { flags, io, output } = context;
5671
+ const file = typeof flags["firebase-hash-config"] === "string" ? flags["firebase-hash-config"] : undefined;
5672
+ const rounds = whole(flags, "firebase-rounds", 1, 1000);
5673
+ const memCost = whole(flags, "firebase-mem-cost", 1, 1000);
5674
+ const fromEnv = io.env.TULA_FIREBASE_SIGNER_KEY?.trim();
5675
+ if (file === undefined && !fromEnv) {
5676
+ return;
5677
+ }
5678
+ const config = file !== undefined ? parseHashConfig(await readInput(context, file, "--firebase-hash-config")) : {
5679
+ signerKey: standardBase64(fromEnv ?? ""),
5680
+ saltSeparator: standardBase64(io.env.TULA_FIREBASE_SALT_SEPARATOR ?? ""),
5681
+ rounds: undefined,
5682
+ memCost: undefined
5683
+ };
5684
+ output.redact(config.signerKey);
5685
+ if (fromEnv) {
5686
+ output.redact(fromEnv);
5687
+ }
5688
+ output.redact(config.saltSeparator);
5689
+ const rawSeparator = io.env.TULA_FIREBASE_SALT_SEPARATOR?.trim();
5690
+ if (rawSeparator) {
5691
+ output.redact(rawSeparator);
5692
+ }
5693
+ const finalRounds = rounds ?? config.rounds;
5694
+ const finalMemCost = memCost ?? config.memCost;
5695
+ if (finalRounds === undefined || finalMemCost === undefined) {
5696
+ throw new UsageError2("Firebase\u2019s rounds and mem_cost are needed: put them in the hash config file, or pass --firebase-rounds and --firebase-mem-cost.");
5697
+ }
5698
+ return {
5699
+ signerKey: config.signerKey,
5700
+ saltSeparator: config.saltSeparator,
5701
+ rounds: finalRounds,
5702
+ memCost: finalMemCost
5703
+ };
5704
+ }
5705
+ function checkOptions(provider, flags) {
5706
+ for (const name of ALL_EXTRA) {
5707
+ if (flags[name] !== undefined && !EXTRA_FILES[provider].includes(name)) {
5708
+ throw new UsageError2(`--${name} is not used with --from ${provider}.`);
5709
+ }
5710
+ }
5711
+ if (provider !== "firebase") {
5712
+ for (const name of ["firebase-hash-config", "firebase-rounds", "firebase-mem-cost"]) {
5713
+ if (flags[name] !== undefined) {
5714
+ throw new UsageError2(`--${name} is only used with --from firebase.`);
5715
+ }
5716
+ }
5717
+ }
5718
+ }
5719
+ async function load(context, provider, file) {
5720
+ const { flags } = context;
5721
+ const text = await readInput(context, file, "the export file");
5722
+ const files = {};
5723
+ for (const name of EXTRA_FILES[provider]) {
5724
+ const path = flags[name];
5725
+ if (typeof path === "string") {
5726
+ files[name] = await readInput(context, path, `--${name}`);
5727
+ }
5728
+ }
5729
+ const firebase = provider === "firebase" ? await firebaseParameters(context) : undefined;
5730
+ const parsed = PARSERS[provider]({
5731
+ text,
5732
+ files,
5733
+ allowPasswordless: flags["allow-passwordless"] === true,
5734
+ firebase
5735
+ });
5736
+ if (flags["no-trust-verified"] === true) {
5737
+ for (const entry of parsed.rows) {
5738
+ if (entry.user !== null) {
5739
+ entry.user.emailVerified = false;
5740
+ }
5741
+ }
5742
+ }
5743
+ const fingerprint = fingerprintOf([
5744
+ ["export", text],
5745
+ ...Object.entries(files).sort(([left], [right]) => left.localeCompare(right)),
5746
+ [
5747
+ "options",
5748
+ JSON.stringify({
5749
+ passwordless: flags["allow-passwordless"] === true,
5750
+ unverified: flags["no-trust-verified"] === true,
5751
+ firebase: firebase === undefined ? null : createHash2("sha256").update(JSON.stringify(firebase)).digest("hex")
5752
+ })
5753
+ ]
5754
+ ]);
5755
+ return { parsed, fingerprint };
5756
+ }
5757
+ async function loadProgress(context, path, fresh) {
5758
+ const { host } = context.io;
5759
+ const existing = await host?.readFile(path);
5760
+ if (existing === null || existing === undefined || context.flags.fresh === true) {
5761
+ return fresh;
5762
+ }
5763
+ const found = readProgress(existing);
5764
+ if (found.provider !== fresh.provider || found.fingerprint !== fresh.fingerprint) {
5765
+ throw new UsageError2("The progress file belongs to a different export. Nothing was sent. Use the matching export, name another file with --progress-file, or pass --fresh to start over (users that exist are skipped).");
5766
+ }
5767
+ if (found.target !== fresh.target) {
5768
+ throw new UsageError2("The progress file was made for a different API or secret key. Nothing was sent. Use the same ones, name another file with --progress-file, or pass --fresh to start over (users that exist are skipped).");
5769
+ }
5770
+ return found;
5771
+ }
5772
+ var importCommand2 = {
5773
+ name: "import",
5774
+ summary: "Import users from Clerk, Auth0, Firebase, Supabase or better-auth.",
5775
+ usage: USAGE2,
5776
+ description: "Reads the provider\u2019s export file on this machine, with no request to the old provider, " + "and prints a report of what would be imported and what is refused (row number, code, and a " + "masked address). With --yes it sends the users in batches of up to 500 to " + "POST /v1/admin/users/import with the secret key from TULA_SECRET_KEY or " + `--secret-key-file, waiting out the rate limit.
5777
+
5778
+ ` + "A progress file (default: <export file>.tula-import-progress.json) holds row numbers, " + "counts, codes and ids, never a password hash or an address; run the same command again " + "after an interruption and it continues. A user whose address exists is skipped, so " + "running it again is safe. The progress file is bound to the export and to the API and " + `key; --fresh ignores it.
5779
+
5780
+ ` + "Providers: clerk (CSV or JSON, with --organizations and --memberships), auth0 (NDJSON or " + "JSON, with --password-hashes), firebase (auth:export JSON, with --firebase-hash-config), " + "supabase (auth.users CSV or JSON) and better-auth (user table, with --accounts). " + "A password hash the server cannot read refuses its row, or with --allow-passwordless " + `imports the user without a password.
5781
+
5782
+ ` + "Firebase\u2019s signer key is never taken on the command line: it is in the hash config file, " + `or in TULA_FIREBASE_SIGNER_KEY (and TULA_FIREBASE_SALT_SEPARATOR).
5783
+
5784
+ ` + "Exit codes: 0 done and nothing refused, 2 something was refused (a report, not a failure), " + "1 an error (the progress file keeps what was done).",
5785
+ maxPositionals: 1,
5786
+ options: {
5787
+ from: {
5788
+ type: "string",
5789
+ value: "<provider>",
5790
+ description: `The provider: ${IMPORT_PROVIDERS.join(", ")}.`
5791
+ },
5792
+ yes: {
5793
+ type: "boolean",
5794
+ short: "y",
5795
+ description: "Import. Without it only the report is printed."
5796
+ },
5797
+ "progress-file": {
5798
+ type: "string",
5799
+ value: "<path>",
5800
+ description: "Where the progress is kept. Default: <export file>.tula-import-progress.json."
5801
+ },
5802
+ fresh: { type: "boolean", description: "Ignore an existing progress file and start over." },
5803
+ "allow-passwordless": {
5804
+ type: "boolean",
5805
+ description: "Import a user whose password hash cannot be carried over without a password, instead of refusing the row."
5806
+ },
5807
+ "no-trust-verified": {
5808
+ type: "boolean",
5809
+ description: "Import every address as unverified, whatever the export says. For an export from a source you do not control."
5810
+ },
5811
+ "password-hashes": {
5812
+ type: "string",
5813
+ value: "<file>",
5814
+ description: "Auth0: the password hash export from Auth0 support (NDJSON)."
5815
+ },
5816
+ accounts: {
5817
+ type: "string",
5818
+ value: "<file>",
5819
+ description: "better-auth: the account table, which holds the password hashes."
5820
+ },
5821
+ organizations: {
5822
+ type: "string",
5823
+ value: "<file>",
5824
+ description: "Clerk: the organizations (JSON from the Backend API)."
5825
+ },
5826
+ memberships: {
5827
+ type: "string",
5828
+ value: "<file>",
5829
+ description: "Clerk: the organization memberships (JSON from the Backend API)."
5830
+ },
5831
+ "firebase-hash-config": {
5832
+ type: "string",
5833
+ value: "<file>",
5834
+ description: "Firebase: the project\u2019s password hash parameters (base64_signer_key, base64_salt_separator, rounds, mem_cost)."
5835
+ },
5836
+ "firebase-rounds": {
5837
+ type: "string",
5838
+ value: "<n>",
5839
+ description: "Firebase: rounds, when the config file lacks it."
5840
+ },
5841
+ "firebase-mem-cost": {
5842
+ type: "string",
5843
+ value: "<n>",
5844
+ description: "Firebase: mem_cost, when the config file lacks it."
5845
+ },
5846
+ "batch-size": {
5847
+ type: "string",
5848
+ value: "<n>",
5849
+ description: `Users per request, at most ${MAX_IMPORT_ROWS2} (the default).`
5850
+ },
5851
+ "max-wait": {
5852
+ type: "string",
5853
+ value: "<seconds>",
5854
+ description: `The longest a Retry-After is waited. Default: ${DEFAULT_MAX_WAIT_SECONDS}.`
5855
+ },
5856
+ env: {
5857
+ type: "string",
5858
+ short: "e",
5859
+ value: "<name>",
5860
+ description: "Names the TULA_API_URL_<NAME> and TULA_SECRET_KEY_<NAME> variables read."
5861
+ },
5862
+ "api-url": {
5863
+ type: "string",
5864
+ value: "<url>",
5865
+ description: "The Tula API. Default: TULA_API_URL_<NAME>, then TULA_API_URL."
5866
+ },
5867
+ "insecure-http": {
5868
+ type: "boolean",
5869
+ description: "Allow a plain http API URL that is not localhost: the secret key then crosses the network in clear text."
5870
+ },
5871
+ "secret-key-file": {
5872
+ type: "string",
5873
+ value: "<path>",
5874
+ description: "Read the secret key from a file (- for standard input, piped)."
5875
+ },
5876
+ json: { type: "boolean", description: "Print the report as JSON." }
5877
+ },
5878
+ run: async (context) => {
5879
+ const { flags, positionals, io, output } = context;
5880
+ const provider = flags.from;
5881
+ if (typeof provider !== "string" || !IMPORT_PROVIDERS.includes(provider)) {
5882
+ throw new UsageError2(`--from names the provider: one of ${IMPORT_PROVIDERS.join(", ")}. Usage: ${USAGE2}`);
5883
+ }
5884
+ const source = provider;
5885
+ const file = positionals[0];
5886
+ if (file === undefined) {
5887
+ throw new UsageError2(`Name the export file. Usage: ${USAGE2}`);
5888
+ }
5889
+ const batchSize = whole(flags, "batch-size", 1, MAX_IMPORT_ROWS2) ?? MAX_IMPORT_ROWS2;
5890
+ const maxWait = whole(flags, "max-wait", 0, 3600) ?? DEFAULT_MAX_WAIT_SECONDS;
5891
+ checkOptions(source, flags);
5892
+ const { parsed, fingerprint } = await load(context, source, file);
5893
+ const summary = summarize(parsed);
5894
+ if (flags.yes !== true) {
5895
+ if (flags.json) {
5896
+ output.line(JSON.stringify(reportToJson(source, parsed, summary), null, 2));
5897
+ } else {
5898
+ renderDryRun(output, source, parsed, summary);
5899
+ }
5900
+ return summary.refused.length > 0 ? EXIT2.refused : EXIT2.ok;
5901
+ }
5902
+ const target = await resolveTarget2({
5903
+ name: typeof flags.env === "string" ? flags.env : "default",
5904
+ environment: {},
5905
+ flags,
5906
+ io,
5907
+ output
5908
+ });
5909
+ const host = io.host;
5910
+ if (!host) {
5911
+ throw new UsageError2("This run cannot write files.");
5912
+ }
5913
+ const progressPath = resolve2(io.cwd, typeof flags["progress-file"] === "string" ? flags["progress-file"] : `${file}.tula-import-progress.json`);
5914
+ const progress = await loadProgress(context, progressPath, newProgress(source, fingerprint, target.fingerprint));
5915
+ if (progress.batches > 0 || progress.organizations.cursor > 0) {
5916
+ output.error(`Continuing from the progress file (after user row ${progress.doneThrough}).`);
5917
+ }
5918
+ const run = {
5919
+ admin: target.admin,
5920
+ host,
5921
+ output,
5922
+ progressPath,
5923
+ batchSize,
5924
+ maxWaitMs: maxWait * 1000
5925
+ };
5926
+ try {
5927
+ await importUsers(run, parsed, progress);
5928
+ await importOrganizations(run, parsed, progress);
5929
+ } catch (error) {
5930
+ const code = reportError2(output, error);
5931
+ output.error(`Stopped. The progress file keeps what was done (${progress.counts.created + progress.counts.skipped + progress.counts.refused} users answered); run the same command again to continue.`);
5932
+ return code;
5933
+ }
5934
+ if (flags.json) {
5935
+ output.line(JSON.stringify(reportToJson(source, parsed, summary, progress), null, 2));
5936
+ return summary.refused.length + progress.counts.refused > 0 || progress.organizations.failed.length + progress.memberships.failed.length > 0 ? EXIT2.refused : EXIT2.ok;
5937
+ }
5938
+ return renderResult(output, source, parsed, summary, progress) > 0 ? EXIT2.refused : EXIT2.ok;
5939
+ }
5940
+ };
5941
+
5942
+ // src/commands/mcp.ts
5943
+ import { isTulaAdminError as isTulaAdminError10 } from "@tula-tech/admin";
5944
+ function suffix(name) {
5945
+ return name.toUpperCase().replace(/[^A-Z0-9]/g, "_");
5946
+ }
5947
+ function reason(error) {
5948
+ if (error instanceof UsageError2) {
5949
+ return error.message.replace(/ \(or TULA_[A-Z_]+_DEFAULT\)/g, "");
5950
+ }
5951
+ if (isTulaAdminError10(error)) {
5952
+ return `The configured credential or API URL is not usable (${error.code}).`;
5953
+ }
5954
+ throw error;
5955
+ }
5956
+ async function buildMcpServer2(context) {
5957
+ const { flags, io } = context;
5958
+ for (const option of ["secret-key-file", "admin-token-file", "agent-key-file"]) {
5959
+ if (flags[option] === "-") {
5960
+ throw new UsageError2(`--${option} - would read from standard input, which carries the protocol. Use a file or the environment.`);
5961
+ }
5962
+ }
5963
+ const { createTulaMcpServer, TOOL_NAMES } = await import("@tula-tech/mcp");
5964
+ const secrets = [];
5965
+ const output = {
5966
+ ...context.output,
5967
+ redact: (value, scope) => {
5968
+ secrets.push(value);
5969
+ context.output.redact(value, scope);
5970
+ }
5971
+ };
5972
+ const name = typeof flags.env === "string" ? flags.env : "default";
5973
+ const unavailable = {};
5974
+ let agentConfigured = false;
5975
+ const agent = await resolveAgent2({ name, flags, io, output }).then((target) => {
5976
+ agentConfigured = target !== null;
5977
+ return target?.admin ?? null;
5978
+ }, (error) => {
5979
+ agentConfigured = true;
5980
+ unavailable.agent = reason(error);
5981
+ unavailable.admin = unavailable.agent;
5982
+ return null;
5983
+ });
5984
+ const admin = agentConfigured ? null : await resolveTarget2({ name, environment: {}, flags, io, output }).then((target) => target.admin, (error) => {
5985
+ unavailable.admin = reason(error);
5986
+ return null;
5987
+ });
5988
+ const secretKeyLeftUnread = agentConfigured && (flags["secret-key-file"] !== undefined || Boolean(io.env[`TULA_SECRET_KEY_${suffix(name)}`]?.trim()) || Boolean(io.env.TULA_SECRET_KEY?.trim()));
5989
+ const agentIgnoredAdminToken = agentConfigured && flags["admin-token-file"] === undefined && Boolean(io.env.TULA_ADMIN_TOKEN?.trim());
5990
+ let doctor = null;
5991
+ try {
5992
+ const named = io.env[`TULA_API_URL_${suffix(name)}`]?.trim();
5993
+ const apiUrl = resolveApiUrl2(flags["api-url"] === undefined && named ? { ...flags, "api-url": named } : flags, io);
5994
+ const instance = await resolveInstance2({
5995
+ apiUrl,
5996
+ flags,
5997
+ io: agentIgnoredAdminToken ? { ...io, env: { ...io.env, TULA_ADMIN_TOKEN: undefined } } : io,
5998
+ output
5999
+ });
6000
+ doctor = ({ signal }) => examine2({ apiUrl, instance, io, signal });
6001
+ } catch (error) {
6002
+ unavailable.doctor = reason(error);
6003
+ }
6004
+ 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"}.`);
6005
+ if (secretKeyLeftUnread) {
6006
+ 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.");
6007
+ }
6008
+ if (agentIgnoredAdminToken) {
6009
+ context.output.error("tula mcp: an agent key is configured, so the admin token was ignored (it is a credential of a person). Pass --admin-token-file to enable run_doctor.");
6010
+ }
6011
+ for (const why of new Set(Object.values(unavailable))) {
6012
+ context.output.error(`tula mcp: ${why}`);
6013
+ }
6014
+ return createTulaMcpServer({
6015
+ admin,
6016
+ agent,
3898
6017
  doctor,
3899
6018
  cwd: io.cwd,
3900
6019
  secrets,
@@ -3911,7 +6030,7 @@ var mcpCommand2 = {
3911
6030
 
3912
6031
  ` + "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
6032
 
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.`,
6033
+ 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 (with an agent key, only --admin-token-file). 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
6034
  options: {
3916
6035
  env: {
3917
6036
  type: "string",
@@ -3960,7 +6079,7 @@ The API URL comes from --api-url, TULA_API_URL_<NAME> or TULA_API_URL; the secre
3960
6079
  import {
3961
6080
  evaluatePassword
3962
6081
  } from "@tula-tech/contract/password-rules";
3963
- var USAGE2 = "tula policy test [--env <name>] [--email <address>] [--name <full name>] [--json]";
6082
+ var USAGE3 = "tula policy test [--env <name>] [--email <address>] [--name <full name>] [--json]";
3964
6083
  function describeRule(check) {
3965
6084
  const params = check.params ?? {};
3966
6085
  switch (check.rule) {
@@ -4012,7 +6131,7 @@ async function readPassword(context) {
4012
6131
  var policyCommand2 = {
4013
6132
  name: "policy",
4014
6133
  summary: "Test a password against an environment\u2019s password policy.",
4015
- usage: USAGE2,
6134
+ usage: USAGE3,
4016
6135
  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
6136
 
4018
6137
  ` + "The secret key decides the environment (TULA_SECRET_KEY, or TULA_SECRET_KEY_<NAME> with " + `--env <name>).
@@ -4055,7 +6174,7 @@ var policyCommand2 = {
4055
6174
  run: async (context) => {
4056
6175
  const { flags, io, output, positionals } = context;
4057
6176
  if (positionals[0] !== "test") {
4058
- throw new UsageError2(`Usage: ${USAGE2}`);
6177
+ throw new UsageError2(`Usage: ${USAGE3}`);
4059
6178
  }
4060
6179
  if (flags["secret-key-file"] === "-") {
4061
6180
  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.");
@@ -4117,13 +6236,14 @@ var policyCommand2 = {
4117
6236
 
4118
6237
  // src/process-io.ts
4119
6238
  import { readFile, stat } from "fs/promises";
4120
- import { isAbsolute, resolve as resolve2 } from "path";
6239
+ import { isAbsolute, resolve as resolve3 } from "path";
4121
6240
  import { createInterface } from "readline/promises";
4122
6241
 
4123
6242
  // src/process-host.ts
4124
6243
  import { constants } from "fs";
4125
6244
  import { lstat, open, rename, rm, unlink } from "fs/promises";
4126
6245
  import { basename as basename2 } from "path";
6246
+ var MAX_INPUT_BYTES = 1024 * 1024;
4127
6247
  function linkRefusal(path) {
4128
6248
  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
6249
  }
@@ -4239,6 +6359,9 @@ function createProcessHost(env) {
4239
6359
  return null;
4240
6360
  }
4241
6361
  try {
6362
+ if ((await file.stat()).size > MAX_INPUT_BYTES) {
6363
+ throw new Error(`${path} is larger than ${MAX_INPUT_BYTES} bytes.`);
6364
+ }
4242
6365
  return await file.readFile("utf8");
4243
6366
  } finally {
4244
6367
  await file.close();
@@ -4348,17 +6471,21 @@ function readHidden(parts, question) {
4348
6471
  });
4349
6472
  }
4350
6473
  function createProcessIo(parts) {
4351
- let warned = false;
4352
- async function checkMode(path) {
4353
- if (warned || parts.platform === "win32") {
6474
+ const warned = new Set;
6475
+ async function checkFile(path) {
6476
+ const stats = await parts.stat(path).then((found) => found, () => {
4354
6477
  return;
6478
+ });
6479
+ if (stats?.size !== undefined && stats.size > MAX_INPUT_BYTES) {
6480
+ throw new UsageError2(`The file is larger than ${MAX_INPUT_BYTES} bytes.`);
4355
6481
  }
4356
- const mode = await parts.stat(path).then((stats) => stats.mode, () => {
6482
+ if (warned.has(path) || parts.platform === "win32") {
4357
6483
  return;
4358
- });
6484
+ }
6485
+ const mode = stats?.mode;
4359
6486
  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>.
6487
+ warned.add(path);
6488
+ parts.stderr.write(`warning: ${printable2(path, 200)} is readable by other users (mode ${(mode & 511).toString(8).padStart(4, "0")}). Restrict it: chmod 600 <file>.
4362
6489
  `);
4363
6490
  }
4364
6491
  }
@@ -4389,12 +6516,15 @@ function createProcessIo(parts) {
4389
6516
  let text = "";
4390
6517
  for await (const chunk of parts.stdin) {
4391
6518
  text += String(chunk);
6519
+ if (text.length > MAX_INPUT_BYTES) {
6520
+ throw new UsageError2(`Standard input is larger than ${MAX_INPUT_BYTES} bytes.`);
6521
+ }
4392
6522
  }
4393
6523
  return text;
4394
6524
  },
4395
6525
  readFile: async (path) => {
4396
- const absolute = isAbsolute(path) ? path : resolve2(parts.cwd, path);
4397
- await checkMode(absolute);
6526
+ const absolute = isAbsolute(path) ? path : resolve3(parts.cwd, path);
6527
+ await checkFile(absolute);
4398
6528
  return parts.readFile(absolute);
4399
6529
  }
4400
6530
  };
@@ -4428,10 +6558,12 @@ var COMMANDS2 = [
4428
6558
  policyCommand2,
4429
6559
  devCommand2,
4430
6560
  mcpCommand2,
4431
- approveCommand2
6561
+ approveCommand2,
6562
+ importCommand2,
6563
+ auditCommand2
4432
6564
  ];
4433
6565
  async function main2(argv, overrides = {}) {
4434
6566
  return runCli2(argv, { ...processIo(), ...overrides }, COMMANDS2);
4435
6567
  }
4436
6568
 
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 };
6569
+ 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, auditCommand2, DEV_ENV_FILE2, parseDevBlock2, withoutDevBlock2, withDevBlock2, startDev2, stopDev2, devCommand2, diffCommand2, doctorCommand2, importCommand2, buildMcpServer2, mcpCommand2, policyCommand2, COMMANDS2, main2 };