@tula-tech/cli 0.2.0 → 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.
package/README.md CHANGED
@@ -2,7 +2,8 @@
2
2
 
3
3
  The `tula` command line for [Tula Auth](../../README.md): `tula dev` (the local stack),
4
4
  `tula doctor` (check a deployment), `tula policy test` (try a password against a policy),
5
- `tula mcp` (the MCP server, [docs/mcp.md](../../docs/mcp.md)), and
5
+ `tula import` (move users, with their password hashes, from Clerk, Auth0, Firebase, Supabase or
6
+ better-auth), `tula mcp` (the MCP server, [docs/mcp.md](../../docs/mcp.md)), and
6
7
  `tula diff` / `tula apply`, which keep an environment's settings and OAuth providers in step
7
8
  with a `tula.config.ts`. Every command is in [docs/cli.md](../../docs/cli.md); settings as code
8
9
  in [docs/config.md](../../docs/config.md); the decisions in
@@ -45,4 +46,90 @@ https (or localhost; `--insecure-http` for a private network you trust).
45
46
  `tula` runs on **Bun** (`#!/usr/bin/env bun`): the first thing every command does is import
46
47
  the project's `tula.config.ts`, which Bun runs as it is.
47
48
 
49
+ ## Commands
50
+
51
+ | Command | What |
52
+ | --- | --- |
53
+ | `tula dev` | Start the local stack of a project `create-tula` made: Compose, migrations, seed, development keys. |
54
+ | `tula dev down` | Stop it; the data is kept. `--volumes` also deletes the database (asks first). |
55
+ | `tula doctor` | Check a deployment, each problem with its fix. `--strict` fails on a warning, `--json` is for a pipeline. |
56
+ | `tula policy test` | Which rules of an environment's password policy a password passes. |
57
+ | `tula mcp` | The MCP server an AI assistant reads and proposes through, on standard input and output. |
58
+ | `tula approve <id>` | Approve a change an assistant proposed, after showing what it does. |
59
+ | `tula import --from <provider> <file>` | Move users, with their password hashes, from Clerk, Auth0, Firebase, Supabase or better-auth. Prints a report; `--yes` imports. |
60
+ | `tula audit export` | Stream the audit log as NDJSON or CSV (`--format`, `--from`, `--to`, `--action`, `--organization`, `--resume`). |
61
+ | `tula diff`, `tula apply` | Settings as code: compare, then change an environment to match `tula.config.ts`. |
62
+
63
+ ```sh
64
+ tula dev # start, or continue; running it again changes nothing
65
+ tula dev --tunnel # the same stack at a public https address a phone can reach
66
+ tula doctor --strict
67
+ printf %s "$PW" | tula policy test --env prod --email maya@example.com
68
+ tula import --from supabase supabase-users.csv # a report; nothing is sent
69
+ tula import --from supabase supabase-users.csv --yes
70
+ tula audit export --from 2026-01-01 --to 2026-02-01 > january.ndjson
71
+ ```
72
+
73
+ Options shared by the commands that talk to an API: `--env <name>`, `--api-url <url>`,
74
+ `--insecure-http` and `--secret-key-file <path>`. `tula apply` also takes `--yes`,
75
+ `--prune`, `--allow-weaker`, `--allow-unknown` and `--allow-webhook-removal`; a run that
76
+ creates a webhook endpoint needs `--secrets-file`, `--show-secrets` or `--discard-secrets` to
77
+ say what happens to its signing secret. Exit codes differ per command and are listed with each
78
+ in [docs/cli.md](../../docs/cli.md).
79
+
80
+ ## Credentials are never arguments
81
+
82
+ A command line is kept in shell history and shows in process lists, so no option takes a
83
+ secret: `--secret-key`, `--admin-token` and `--password` do not exist.
84
+
85
+ | What | Where it comes from |
86
+ | --- | --- |
87
+ | API URL | `--api-url`, else `TULA_API_URL_<NAME>` (with `--env <name>`), else `TULA_API_URL`. |
88
+ | Secret key | `TULA_SECRET_KEY_<NAME>`, else `TULA_SECRET_KEY`, or `--secret-key-file <path>`. |
89
+ | Agent key (`tula mcp` only) | `TULA_AGENT_KEY_<NAME>`, else `TULA_AGENT_KEY`, or `--agent-key-file <path>`. |
90
+ | Instance admin token | `TULA_ADMIN_TOKEN`, or `--admin-token-file <path>` (`-` for a pipe). |
91
+
92
+ Bun loads `.env` and `.env.local` from the current directory, so in a project made by
93
+ [`create-tula`](../create-tula/README.md) the URL and keys `tula dev` wrote are found with
94
+ nothing exported.
95
+
96
+ ## Security notes
97
+
98
+ - The API URL must be https, or this machine; `--insecure-http` is for a private network you
99
+ trust.
100
+ - `tula policy test` evaluates the rules on your machine: the password is never sent, printed
101
+ or logged. `tula import` reads the provider's export locally, prints no hash, token or whole
102
+ address, and keeps its progress file at mode `0600` with no hash in it.
103
+ - `tula dev` writes keys to `.env.local` at mode `0600` and prints the secret key only with
104
+ `--show-keys`.
105
+ - `tula mcp` with an agent key does not read the secret key at all.
106
+ - The CLI never connects to your database; `tula doctor` asks the API.
107
+
108
+ ## Requirements
109
+
110
+ [Bun](https://bun.sh) 1.2 or later (`engines.bun`). The executable is `#!/usr/bin/env bun`,
111
+ and every command imports the project's `tula.config.ts` as it is. `tula dev` also needs
112
+ Docker with Compose.
113
+
114
+ ## Running it
115
+
116
+ | Where | How |
117
+ | --- | --- |
118
+ | A project, after `bun add -d @tula-tech/cli@latest` | `bunx tula <command>` |
119
+ | Anywhere else | `bunx --package @tula-tech/cli@latest tula <command>` |
120
+ | This repository | `bun run tula -- <command>` |
121
+
122
+ In CI, install the package as a dev dependency and call `tula diff --env prod` as a check
123
+ (exit 2 means the file and the environment disagree) before `tula apply --env prod --yes`.
124
+ Keep the secret key in the CI system's secret store and pass it as `TULA_SECRET_KEY_PROD`.
125
+
126
+ ## Learn more
127
+
128
+ - [docs/cli.md](../../docs/cli.md): every command, option and exit code.
129
+ - [docs/config.md](../../docs/config.md): `tula.config.ts`, `diff` and `apply`.
130
+ - [docs/mcp.md](../../docs/mcp.md): the MCP server and `tula approve`.
131
+ - [docs/quickstart.md](../../docs/quickstart.md): a project of your own.
132
+
133
+ ## Contributing
134
+
48
135
  A new command is one object of the `Command` shape added to `COMMANDS` in `src/index.ts`.
package/dist/bin.js CHANGED
@@ -2,7 +2,7 @@
2
2
  // @bun
3
3
  import {
4
4
  main2
5
- } from "./shared/chunk-4x7xmfqj.js";
5
+ } from "./shared/chunk-erqwg4v0.js";
6
6
 
7
7
  // src/bin.ts
8
8
  process.exit(await main2(process.argv.slice(2)));
package/dist/index.d.ts CHANGED
@@ -57,7 +57,7 @@ interface ParsedArgs {
57
57
  * @param argv - The arguments after the command's name.
58
58
  * @param options - The options the command takes, by name.
59
59
  * @returns The options that were given and the positional arguments.
60
- * @throws UsageError for an unknown option, a missing value, or a value given to a flag.
60
+ * @throws UsageError for an unknown option, a missing value, a value given to a flag, or an option given twice.
61
61
  *
62
62
  * @example
63
63
  * ```ts
@@ -500,6 +500,16 @@ declare const applyCommand: Command;
500
500
  */
501
501
  declare const approveCommand: Command;
502
502
  /**
503
+ * `tula audit export`: the environment's audit log to standard output, oldest first, as
504
+ * NDJSON or CSV, streamed and resumable.
505
+ *
506
+ * @example
507
+ * ```sh
508
+ * tula audit --format csv --from 2026-01-01 > audit.csv
509
+ * ```
510
+ */
511
+ declare const auditCommand: Command;
512
+ /**
503
513
  * `tula dev`: start the project's local stack with Docker Compose, migrate, seed, mint
504
514
  * development keys into `.env.local` and print the URLs. `tula dev down` stops it.
505
515
  * `--tunnel` also gives the stack a public https address (ADR 0049).
@@ -536,6 +546,20 @@ declare const diffCommand: Command;
536
546
  * ```
537
547
  */
538
548
  declare const doctorCommand: Command;
549
+ /**
550
+ * `tula import`: move users from another provider. It reads the provider's own on this
551
+ * machine (no request to the old provider), prints a dry-run report, and with `--yes` imports
552
+ * the users through `POST /v1/admin/users/import` in batches, keeping a progress file so that an
553
+ * interrupted run continues where it stopped. A Clerk export's organizations and memberships go
554
+ * through the organization admin routes.
555
+ *
556
+ * @example
557
+ * ```sh
558
+ * tula import --from supabase users.csv # dry run: a report, nothing sent
559
+ * tula import --from supabase users.csv --yes # import; run again after an interruption
560
+ * ```
561
+ */
562
+ declare const importCommand: Command;
539
563
  import { McpServer as McpServer_ymih5p } from "@modelcontextprotocol/server";
540
564
  /**
541
565
  * Build the MCP server `tula mcp` serves, from the same places every other command reads its
@@ -712,6 +736,7 @@ declare function withoutDevBlock(text: string): string;
712
736
  * @param text - The file's contents (`''` for a new file).
713
737
  * @param vars - The block's variables.
714
738
  * @returns The new contents.
739
+ * @throws UsageError when a name is not `NAME_LIKE` or a value holds a control character.
715
740
  *
716
741
  * @example
717
742
  * ```ts
@@ -796,7 +821,7 @@ declare function stopDev(options: Pick<DevOptions, "io" | "output" | "host" | "p
796
821
  volumes: boolean;
797
822
  }): Promise<void>;
798
823
  import { AdminSchemas } from "@tula-tech/admin";
799
- import { EnvironmentConfig, NativeAppConfig } from "@tula-tech/config";
824
+ import { EnvironmentConfig, NativeAppConfig, OrganizationRoleConfig } from "@tula-tech/config";
800
825
  import { EnvironmentSettings, EnvironmentSettingsInput, HookPoint, OAuthProvider, SettingsManagedBy } from "@tula-tech/contract";
801
826
  /**
802
827
  * The name `tula apply` records itself under as the manager of an environment's settings.
@@ -912,6 +937,16 @@ interface RemoteState {
912
937
  * are then not even read.
913
938
  */
914
939
  nativeApps?: readonly RemoteNativeApp[];
940
+ /**
941
+ * The organization roles, built-in ones included. Left out when the config does not manage
942
+ * roles: they are then not even read.
943
+ */
944
+ organizationRoles?: readonly RemoteOrganizationRole[];
945
+ /**
946
+ * Set when the roles could not be read because organizations are off on the server: custom
947
+ * roles may exist that the plan does not know of.
948
+ */
949
+ organizationRolesUnread?: boolean;
915
950
  }
916
951
  /**
917
952
  * A native app as the admin API lists it.
@@ -998,6 +1033,82 @@ interface NativeAppPlan {
998
1033
  seen: string;
999
1034
  }
1000
1035
  /**
1036
+ * A custom organization role as the admin API lists it (the built-in roles are listed too and
1037
+ * are never planned: `builtIn`).
1038
+ *
1039
+ * @example
1040
+ * ```ts
1041
+ * const { data } = await admin.call('listOrganizationRoles')
1042
+ * const roles: RemoteOrganizationRole[] = data.data
1043
+ * ```
1044
+ */
1045
+ type RemoteOrganizationRole = AdminSchemas["OrganizationRole"];
1046
+ /**
1047
+ * What a run does with one custom organization role.
1048
+ *
1049
+ * @example
1050
+ * ```ts
1051
+ * const change: OrganizationRoleChange = planOrganizationRoles(remote.organizationRoles, environment.organizationRoles, {}).roles[0]
1052
+ * ```
1053
+ */
1054
+ interface OrganizationRoleChange {
1055
+ /** The role's key: its identity, the file's for an entry of the file, the server's otherwise. */
1056
+ key: string;
1057
+ /** The server's id of the role. Absent for one to create. */
1058
+ id?: string;
1059
+ /**
1060
+ * The role's version as the plan read it: the `If-Match` of the update or delete that
1061
+ * follows, so that a change made after the plan is refused by the server (412) and never
1062
+ * written over. Absent for a role to create.
1063
+ */
1064
+ version?: number;
1065
+ /**
1066
+ * `create`, `update`, `delete`; `none` when it already is as the config says; `unmanaged`
1067
+ * when the server has it and the config does not list it (left alone without `--prune`).
1068
+ */
1069
+ action: "create" | "update" | "delete" | "none" | "unmanaged";
1070
+ /**
1071
+ * The differences: `name`, and `permissions` as a set (`added`, `removed`). For a role to
1072
+ * create, what it is defined with.
1073
+ */
1074
+ fields: Change[];
1075
+ /**
1076
+ * `organizationRoles.<key>.permissions` when the change gives members of the role a
1077
+ * permission it did not have, as the contract's `organizationRoleWeakenings` judges it.
1078
+ * Part of the plan's `weakened`. A new role is no weakening: nobody holds it.
1079
+ */
1080
+ weakened: string[];
1081
+ /** The config's entry, for a role the run creates or updates: what the write sends. */
1082
+ entry?: OrganizationRoleConfig;
1083
+ }
1084
+ /**
1085
+ * What a run does with an environment's custom organization roles.
1086
+ *
1087
+ * @example
1088
+ * ```ts
1089
+ * if (plan.organizationRoles.overLimit !== null) {
1090
+ * // the environment would have more custom roles than it may
1091
+ * }
1092
+ * ```
1093
+ */
1094
+ interface OrganizationRolePlan {
1095
+ /** Whether the config has an `organizationRoles` list at all. Without one nothing is read or changed. */
1096
+ managed: boolean;
1097
+ /** One entry per role of the file, in its order, then the server's other custom roles. */
1098
+ roles: OrganizationRoleChange[];
1099
+ /**
1100
+ * How many custom roles the environment would have once the plan is applied, when that is
1101
+ * more than it may have (`MAX_CUSTOM_ROLES`); `null` otherwise.
1102
+ */
1103
+ overLimit: number | null;
1104
+ /**
1105
+ * The server's roles could not be read (organizations are off there), so every listed role
1106
+ * is planned as a definition. A role that exists makes that definition fail with
1107
+ * `organization.role_exists`, writing nothing over it; `tula diff` again then shows it.
1108
+ */
1109
+ unread: boolean;
1110
+ }
1111
+ /**
1001
1112
  * A hook as the admin API lists it. Never with its signing secret: no read returns one.
1002
1113
  *
1003
1114
  * @example
@@ -1351,7 +1462,8 @@ interface Plan {
1351
1462
  * Paths where the run weakens security: the settings' (the contract's
1352
1463
  * `settingsWeakenings`), then the providers' (`oauthProviderWeakenings`, as
1353
1464
  * `providers.<provider>…`), then the hooks' (`hookWeakenings`, as `hooks.<point>…`), then the
1354
- * native apps' (`nativeAppWeakenings`, as `nativeApps.<platform>/<identifier>…`).
1465
+ * native apps' (`nativeAppWeakenings`, as `nativeApps.<platform>/<identifier>…`), then the
1466
+ * organization roles' (`organizationRoleWeakenings`, as `organizationRoles.<key>.permissions`).
1355
1467
  */
1356
1468
  weakened: string[];
1357
1469
  /** Settings the file leaves out whose default is the deployment's: kept as the server has them. */
@@ -1366,6 +1478,8 @@ interface Plan {
1366
1478
  hooks: HookPlan;
1367
1479
  /** What happens to each native app, when the config manages them. */
1368
1480
  nativeApps: NativeAppPlan;
1481
+ /** What happens to each custom organization role, when the config manages them. */
1482
+ organizationRoles: OrganizationRolePlan;
1369
1483
  /** Whether the server records this config as the settings' manager. */
1370
1484
  marker: MarkerPlan;
1371
1485
  /** The document a replace would send. */
@@ -1455,6 +1569,22 @@ type Operation = {
1455
1569
  kind: "nativeApp.delete";
1456
1570
  id: string;
1457
1571
  change: NativeAppChange;
1572
+ } | {
1573
+ kind: "organizationRole.create";
1574
+ key: string;
1575
+ entry: OrganizationRoleConfig;
1576
+ change: OrganizationRoleChange;
1577
+ } | {
1578
+ kind: "organizationRole.update";
1579
+ key: string;
1580
+ version: number;
1581
+ entry: OrganizationRoleConfig;
1582
+ change: OrganizationRoleChange;
1583
+ } | {
1584
+ kind: "organizationRole.delete";
1585
+ key: string;
1586
+ version: number;
1587
+ change: OrganizationRoleChange;
1458
1588
  };
1459
1589
  /**
1460
1590
  * The writes of a plan, in an order in which every intermediate state is one the server
@@ -1499,6 +1629,13 @@ type Operation = {
1499
1629
  * that widen nothing (a fingerprint or a link path taken away), then changes that widen
1500
1630
  * (another team, a gained fingerprint, a gained link path), then registrations.
1501
1631
  *
1632
+ * **Custom organization roles come after the native apps.** The settings that switch
1633
+ * organizations on are written first, and a role's write is refused while they are off. Among
1634
+ * themselves, as the native apps' and for the same reasons: removals, then changes that give
1635
+ * a role nothing (a permission taken away, a rename), then changes that give it a permission,
1636
+ * then definitions, so that the limit (`MAX_CUSTOM_ROLES`) is never met on the way to a state
1637
+ * that fits and a run that stops has widened as little as it could.
1638
+ *
1502
1639
  * @param plan - The plan.
1503
1640
  * @returns The writes, in order; empty when the plan changes nothing.
1504
1641
  *
@@ -1688,7 +1825,8 @@ declare function applyRequirements(plan: Plan): {
1688
1825
  *
1689
1826
  * @param operation - The write.
1690
1827
  * @returns E.g. `settings: replace`, `provider google: create`, `webhook https://…: remove`,
1691
- * `hook before_sign_up: update`, `native app ios com.example.app: register`.
1828
+ * `hook before_sign_up: update`, `native app ios com.example.app: register`,
1829
+ * `organization role billing_manager: define`.
1692
1830
  *
1693
1831
  * @example
1694
1832
  * ```ts
@@ -1746,6 +1884,13 @@ interface Target {
1746
1884
  apiUrl: string;
1747
1885
  /** The admin client, holding the secret key. */
1748
1886
  admin: AdminClient;
1887
+ /**
1888
+ * Tells this API and key from another without holding either: 16 hex characters of a SHA-256
1889
+ * of the origin and the key. A progress file is bound to it (`tula import`).
1890
+ */
1891
+ fingerprint: string;
1892
+ /** The key's kind and environment as it names itself (`secret key, dev`), for a prompt. */
1893
+ keyLabel: string;
1749
1894
  }
1750
1895
  /**
1751
1896
  * Work out which API a run talks to and with which key.
@@ -1868,10 +2013,10 @@ declare function resolveInstance(input: {
1868
2013
  *
1869
2014
  * @example
1870
2015
  * ```ts
1871
- * `tula-cli/${VERSION}` // 'tula-cli/0.2.0'
2016
+ * `tula-cli/${VERSION}` // 'tula-cli/0.3.0'
1872
2017
  * ```
1873
2018
  */
1874
- declare const VERSION = "0.2.0";
2019
+ declare const VERSION = "0.3.0";
1875
2020
  /**
1876
2021
  * The commands of `tula`. A new command is added here.
1877
2022
  *
@@ -1895,4 +2040,4 @@ declare const COMMANDS: readonly Command[];
1895
2040
  * ```
1896
2041
  */
1897
2042
  declare function main(argv: readonly string[], overrides?: Partial<CliIo>): Promise<number>;
1898
- export { COMMANDS, Change, CheckStatus, CliIo, Command, CommandContext, DEPENDENTS_UNREAD, DEV_ENV_FILE, DependentCount, DevOptions, DoctorCheck, DoctorReport, EXIT, Host, KnownDependents, MANAGING_TOOL, MarkerPlan, Operation, OptionSpec, Output, ParsedArgs, Plan, PlanOptions, ProviderChange, RemoteDependents, RemoteProvider, RemoteState, RunOptions, RunResult, SET_PATHS, Sink, Styles, Target, UsageError, VERSION, applyCommand, applyRequirements, approveCommand, buildMcpServer, buildPlan, createOutput, describeOperation, devCommand, diffCommand, diffValues, doctorCommand, examine, exitCode, main, mcpCommand, orderOperations, parseArgs, parseDevBlock, planProviders, planToJson, planWarnings, policyCommand, printable, providersGoingOff, readDependentCount, readSecretFile, renderPlan, renderReport, reportError, reportToJson, resolveAgent, resolveApiUrl, resolveInstance, resolveTarget, runCli, shouldUseColor, startDev, stopDev, withDevBlock, withoutDevBlock };
2043
+ export { COMMANDS, Change, CheckStatus, CliIo, Command, CommandContext, DEPENDENTS_UNREAD, DEV_ENV_FILE, DependentCount, DevOptions, DoctorCheck, DoctorReport, EXIT, Host, KnownDependents, MANAGING_TOOL, MarkerPlan, Operation, OptionSpec, Output, ParsedArgs, Plan, PlanOptions, ProviderChange, RemoteDependents, RemoteProvider, RemoteState, RunOptions, RunResult, SET_PATHS, Sink, Styles, Target, UsageError, VERSION, applyCommand, applyRequirements, approveCommand, auditCommand, buildMcpServer, buildPlan, createOutput, describeOperation, devCommand, diffCommand, diffValues, doctorCommand, examine, exitCode, importCommand, main, mcpCommand, orderOperations, parseArgs, parseDevBlock, planProviders, planToJson, planWarnings, policyCommand, printable, providersGoingOff, readDependentCount, readSecretFile, renderPlan, renderReport, reportError, reportToJson, resolveAgent, resolveApiUrl, resolveInstance, resolveTarget, runCli, shouldUseColor, startDev, stopDev, withDevBlock, withoutDevBlock };
package/dist/index.js CHANGED
@@ -34,6 +34,7 @@ import {
34
34
  resolveInstance2,
35
35
  applyCommand2,
36
36
  approveCommand2,
37
+ auditCommand2,
37
38
  DEV_ENV_FILE2,
38
39
  parseDevBlock2,
39
40
  withoutDevBlock2,
@@ -43,12 +44,13 @@ import {
43
44
  devCommand2,
44
45
  diffCommand2,
45
46
  doctorCommand2,
47
+ importCommand2,
46
48
  buildMcpServer2,
47
49
  mcpCommand2,
48
50
  policyCommand2,
49
51
  COMMANDS2,
50
52
  main2
51
- } from "./shared/chunk-4x7xmfqj.js";
53
+ } from "./shared/chunk-erqwg4v0.js";
52
54
  export {
53
55
  COMMANDS2 as COMMANDS,
54
56
  DEPENDENTS_UNREAD2 as DEPENDENTS_UNREAD,
@@ -61,6 +63,7 @@ export {
61
63
  applyCommand2 as applyCommand,
62
64
  applyRequirements2 as applyRequirements,
63
65
  approveCommand2 as approveCommand,
66
+ auditCommand2 as auditCommand,
64
67
  buildMcpServer2 as buildMcpServer,
65
68
  buildPlan2 as buildPlan,
66
69
  createOutput2 as createOutput,
@@ -71,6 +74,7 @@ export {
71
74
  doctorCommand2 as doctorCommand,
72
75
  examine2 as examine,
73
76
  exitCode2 as exitCode,
77
+ importCommand2 as importCommand,
74
78
  main2 as main,
75
79
  mcpCommand2 as mcpCommand,
76
80
  orderOperations2 as orderOperations,