@odla-ai/cli 0.8.0 → 0.9.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
@@ -29,8 +29,8 @@ For a project you keep working in, install it as a dev dependency so the
29
29
  shorter `npx odla-ai` form works and the version is pinned by your lockfile:
30
30
 
31
31
  ```bash
32
- npm view @odla-ai/cli@0.8.0 version
33
- npm i -D --save-exact @odla-ai/cli@0.8.0
32
+ npm view @odla-ai/cli@0.9.0 version
33
+ npm i -D --save-exact @odla-ai/cli@0.9.0
34
34
  ```
35
35
 
36
36
  ## Prerequisites
@@ -59,6 +59,8 @@ npx odla-ai provision --dry-run
59
59
  npx odla-ai provision --write-dev-vars --push-secrets
60
60
  npx odla-ai smoke --env dev
61
61
  npx odla-ai secrets push --env dev
62
+ npx odla-ai secrets set clerk_webhook_secret --env dev --stdin
63
+ npx odla-ai secrets set-clerk-key --env dev --from-env CLERK_SECRET_KEY
62
64
  npx odla-ai security run . --env dev --ack-redacted-source
63
65
  npx odla-ai security github connect --env dev # infers owner/name from git origin
64
66
  npx odla-ai security plan --env dev
@@ -261,6 +263,22 @@ redacted plan without spawning anything. For the normal path, use
261
263
  `provision --write-dev-vars --push-secrets` so service enablement, credential
262
264
  issuance, local config, and deployed secret transfer stay one composition.
263
265
 
266
+ `secrets set <name> --env <env>` stores one named secret in that env's tenant
267
+ vault — the same write-only slot Studio's Secrets panel fills (for example
268
+ `clerk_webhook_secret` for synced auth). The value may come only from
269
+ `--from-env <NAME>` or `--stdin`, so a producer command pipes straight into the
270
+ vault without the secret ever appearing on argv, in output, or in an agent's
271
+ transcript; it is encrypted at rest and readable back only by that tenant's
272
+ app API key, never by the CLI, Studio, or the developer token. `$`-prefixed
273
+ names are platform-reserved. `secrets set-clerk-key --env <env>` stores the
274
+ app's Clerk secret key in the reserved `$clerk_secret` slot so the platform can
275
+ resolve the app's users (invites, member lookups); reserved secrets are never
276
+ readable by app keys. It refuses an `sk_test_` key for a prod-named env and
277
+ requires `--yes` to put an `sk_live_` key into a non-prod env, since live users
278
+ would sync into a non-prod tenant. Both commands authenticate with the
279
+ developer token (`$ODLA_DEV_TOKEN`, the cached token, or a fresh device
280
+ approval), and prod-named envs require `--yes`, matching `secrets push`.
281
+
264
282
  ## Who does what
265
283
 
266
284
  If a change is deterministic from `odla.config.mjs`, the CLI owns it: app and
package/dist/bin.cjs CHANGED
@@ -28,7 +28,7 @@ var getImportMetaUrl = () => typeof document === "undefined" ? new URL(`file:${_
28
28
  var importMetaUrl = /* @__PURE__ */ getImportMetaUrl();
29
29
 
30
30
  // src/admin-ai.ts
31
- var import_node_process4 = __toESM(require("process"), 1);
31
+ var import_node_process5 = __toESM(require("process"), 1);
32
32
 
33
33
  // src/token.ts
34
34
  var import_db = require("@odla-ai/db");
@@ -258,9 +258,32 @@ function platformAudience(value) {
258
258
  return url.origin;
259
259
  }
260
260
 
261
+ // src/secret-input.ts
262
+ var import_node_process3 = __toESM(require("process"), 1);
263
+ var MAX_BYTES = 64 * 1024;
264
+ async function secretInputValue(options, kind = "credential") {
265
+ if (options.fromEnv && options.stdin) throw new Error("choose exactly one of --from-env or --stdin");
266
+ let value;
267
+ if (options.fromEnv) value = import_node_process3.default.env[options.fromEnv];
268
+ else if (options.stdin) value = await (options.readStdin ?? (() => readSecretStream(kind)))();
269
+ else throw new Error(`${kind} input required: use --from-env <NAME> or --stdin; values are never accepted as arguments`);
270
+ value = value?.replace(/[\r\n]+$/, "");
271
+ if (!value) throw new Error(options.fromEnv ? `environment variable ${options.fromEnv} is empty or unset` : `stdin ${kind} is empty`);
272
+ if (new TextEncoder().encode(value).byteLength > MAX_BYTES) throw new Error(`${kind} exceeds 64 KiB`);
273
+ return value;
274
+ }
275
+ async function readSecretStream(kind, stream = import_node_process3.default.stdin) {
276
+ let value = "";
277
+ for await (const chunk of stream) {
278
+ value += String(chunk);
279
+ if (value.length > MAX_BYTES) throw new Error(`${kind} exceeds 64 KiB`);
280
+ }
281
+ return value;
282
+ }
283
+
261
284
  // src/admin-ai-auth.ts
262
285
  var import_node_fs2 = require("fs");
263
- var import_node_process3 = __toESM(require("process"), 1);
286
+ var import_node_process4 = __toESM(require("process"), 1);
264
287
  var import_db2 = require("@odla-ai/db");
265
288
  async function getScopedPlatformToken(options) {
266
289
  return resolveAdminPlatformToken(options);
@@ -268,7 +291,7 @@ async function getScopedPlatformToken(options) {
268
291
  async function resolveAdminPlatformToken(options) {
269
292
  const audience = platformAudience(options.platform);
270
293
  if (options.token) return options.token;
271
- const fromEnv = import_node_process3.default.env.ODLA_ADMIN_TOKEN;
294
+ const fromEnv = import_node_process4.default.env.ODLA_ADMIN_TOKEN;
272
295
  if (fromEnv) return audienceBoundEnvToken(fromEnv, audience);
273
296
  return scopedToken(
274
297
  audience,
@@ -280,7 +303,7 @@ async function resolveAdminPlatformToken(options) {
280
303
  }
281
304
  function audienceBoundEnvToken(token, platform) {
282
305
  const audience = platformAudience(platform);
283
- const declared = import_node_process3.default.env.ODLA_ADMIN_TOKEN_AUDIENCE;
306
+ const declared = import_node_process4.default.env.ODLA_ADMIN_TOKEN_AUDIENCE;
284
307
  if (declared) {
285
308
  if (platformAudience(declared) !== audience) throw new Error("ODLA_ADMIN_TOKEN_AUDIENCE does not match the configured platform");
286
309
  } else if (audience !== "https://odla.ai") {
@@ -290,7 +313,7 @@ function audienceBoundEnvToken(token, platform) {
290
313
  }
291
314
  async function scopedToken(platform, scope, options, doFetch, out) {
292
315
  const audience = platformAudience(platform);
293
- const tokenFile = options.tokenFile ?? `${import_node_process3.default.cwd()}/.odla/admin-token.local.json`;
316
+ const tokenFile = options.tokenFile ?? `${import_node_process4.default.cwd()}/.odla/admin-token.local.json`;
294
317
  const cache = readJsonFile(tokenFile);
295
318
  const cached = cache?.platform === audience ? cache.tokens?.[scope] : void 0;
296
319
  if (cached?.token && (cached.expiresAt ?? 0) > Date.now() + 6e4) {
@@ -305,13 +328,13 @@ async function scopedToken(platform, scope, options, doFetch, out) {
305
328
  onCode: async ({ userCode, expiresIn }) => {
306
329
  const approvalUrl = handshakeUrl(audience, userCode);
307
330
  out.log(`Approve scoped request ${userCode} at ${approvalUrl} within ${Math.floor(expiresIn / 60)}m.`);
308
- const shouldOpen = options.open === true || options.open !== false && !import_node_process3.default.env.CI && Boolean(import_node_process3.default.stdin.isTTY && import_node_process3.default.stdout.isTTY);
331
+ const shouldOpen = options.open === true || options.open !== false && !import_node_process4.default.env.CI && Boolean(import_node_process4.default.stdin.isTTY && import_node_process4.default.stdout.isTTY);
309
332
  if (shouldOpen) await (options.openApprovalUrl ?? openUrl)(approvalUrl).catch(() => void 0);
310
333
  }
311
334
  });
312
335
  const tokens = cache?.platform === audience ? { ...cache.tokens ?? {} } : {};
313
336
  tokens[scope] = { token, expiresAt };
314
- if ((0, import_node_fs2.existsSync)(`${import_node_process3.default.cwd()}/.git`)) ensureGitignore(import_node_process3.default.cwd(), [tokenFile]);
337
+ if ((0, import_node_fs2.existsSync)(`${import_node_process4.default.cwd()}/.git`)) ensureGitignore(import_node_process4.default.cwd(), [tokenFile]);
315
338
  writePrivateJson(tokenFile, { platform: audience, tokens });
316
339
  out.log(`auth: cached ${scope} grant (${tokenFile}; mode 0600)`);
317
340
  return token;
@@ -472,7 +495,7 @@ function isRecord2(value) {
472
495
 
473
496
  // src/admin-ai.ts
474
497
  async function adminAi(options) {
475
- const platform = platformAudience(options.platform ?? import_node_process4.default.env.ODLA_PLATFORM ?? "https://odla.ai");
498
+ const platform = platformAudience(options.platform ?? import_node_process5.default.env.ODLA_PLATFORM ?? "https://odla.ai");
476
499
  const doFetch = options.fetch ?? fetch;
477
500
  const out = options.stdout ?? console;
478
501
  const usageQuery = options.action === "usage" ? adminAiUsageQuery(options) : void 0;
@@ -511,7 +534,7 @@ async function adminAi(options) {
511
534
  }
512
535
  if (options.action === "credential-set") {
513
536
  const provider = requireProvider(options.credentialProvider);
514
- const value = await credentialValue(options);
537
+ const value = await secretInputValue(options);
515
538
  const res2 = await doFetch(`${platform}/registry/platform/ai-credentials/${encodeURIComponent(provider)}`, {
516
539
  method: "PUT",
517
540
  headers,
@@ -621,25 +644,6 @@ function requireProvider(value) {
621
644
  }
622
645
  return value;
623
646
  }
624
- async function credentialValue(options) {
625
- if (options.fromEnv && options.stdin) throw new Error("choose exactly one of --from-env or --stdin");
626
- let value;
627
- if (options.fromEnv) value = import_node_process4.default.env[options.fromEnv];
628
- else if (options.stdin) value = await (options.readStdin ?? readStdin)();
629
- else throw new Error("credential input required: use --from-env <NAME> or --stdin; values are never accepted as arguments");
630
- value = value?.replace(/[\r\n]+$/, "");
631
- if (!value) throw new Error(options.fromEnv ? `environment variable ${options.fromEnv} is empty or unset` : "stdin credential is empty");
632
- if (new TextEncoder().encode(value).byteLength > 64 * 1024) throw new Error("credential exceeds 64 KiB");
633
- return value;
634
- }
635
- async function readStdin() {
636
- let value = "";
637
- for await (const chunk of import_node_process4.default.stdin) {
638
- value += String(chunk);
639
- if (value.length > 64 * 1024) throw new Error("credential exceeds 64 KiB");
640
- }
641
- return value;
642
- }
643
647
  function policyArray(body) {
644
648
  if (isRecord3(body) && Array.isArray(body.policies)) return body.policies.filter(isRecord3);
645
649
  if (isRecord3(body) && isRecord3(body.policies)) return Object.values(body.policies).filter(isRecord3);
@@ -809,6 +813,7 @@ var CAPABILITIES = {
809
813
  "register the app and enable configured services per environment",
810
814
  "issue, persist, and explicitly rotate configured service credentials",
811
815
  "write local Worker values and push deployed Worker secrets through Wrangler stdin",
816
+ "store tenant-vault secrets (including the Clerk webhook secret and reserved Clerk secret key) write-only from stdin or an env var",
812
817
  "push db schema/rules and configure platform AI, auth, and deployment links",
813
818
  "validate config offline and smoke-test a provisioned db environment",
814
819
  "run app-attributed hosted security discovery and independent validation without provider keys",
@@ -1265,6 +1270,8 @@ Usage:
1265
1270
  odla-ai smoke [--config odla.config.mjs] [--env dev]
1266
1271
  odla-ai skill install [--dir <project>] [--agent <name>] [--global] [--force]
1267
1272
  odla-ai secrets push --env <env> [--config odla.config.mjs] [--dry-run] [--yes]
1273
+ odla-ai secrets set <name> --env <env> (--from-env <NAME>|--stdin) [--config odla.config.mjs] [--yes]
1274
+ odla-ai secrets set-clerk-key --env <env> (--from-env <NAME>|--stdin) [--config odla.config.mjs] [--yes]
1268
1275
  odla-ai version
1269
1276
 
1270
1277
  Commands:
@@ -1278,7 +1285,9 @@ Commands:
1278
1285
  smoke Verify local credentials, public-config, live schema, and db aggregate.
1279
1286
  skill Same installer; --agent accepts all, claude, codex, cursor,
1280
1287
  copilot, gemini, or agents (repeatable or comma-separated).
1281
- secrets Push configured db/o11y secrets into the Worker via wrangler stdin.
1288
+ secrets Push configured db/o11y secrets into the Worker via wrangler
1289
+ stdin; set stores a tenant-vault secret and set-clerk-key the
1290
+ reserved Clerk secret key, write-only from stdin or an env var.
1282
1291
  version Print the CLI version.
1283
1292
 
1284
1293
  Safety:
@@ -1436,7 +1445,7 @@ function relativeDisplay(path, rootDir) {
1436
1445
  // src/provision.ts
1437
1446
  var import_apps2 = require("@odla-ai/apps");
1438
1447
  var import_ai = require("@odla-ai/ai");
1439
- var import_node_process5 = __toESM(require("process"), 1);
1448
+ var import_node_process6 = __toESM(require("process"), 1);
1440
1449
 
1441
1450
  // src/provision-credentials.ts
1442
1451
  var import_apps = require("@odla-ai/apps");
@@ -1740,7 +1749,7 @@ ${env}: credentials are already saved; retry "odla-ai secrets push --env ${env}$
1740
1749
  out.log(`${env}: rules pushed (${Object.keys(rules).length} namespaces)`);
1741
1750
  }
1742
1751
  if (cfg.services.includes("ai") && cfg.ai?.provider && cfg.ai.keyEnv) {
1743
- const key = import_node_process5.default.env[cfg.ai.keyEnv];
1752
+ const key = import_node_process6.default.env[cfg.ai.keyEnv];
1744
1753
  if (key) {
1745
1754
  const secretName = cfg.ai.secretName ?? defaultSecretName(cfg.ai.provider);
1746
1755
  await (0, import_ai.putSecret)({ endpoint: cfg.dbEndpoint, token, fetch: doFetch }, tenantId, secretName, key);
@@ -1800,6 +1809,63 @@ async function safeText2(res) {
1800
1809
  }
1801
1810
  }
1802
1811
 
1812
+ // src/secrets-set.ts
1813
+ var import_ai2 = require("@odla-ai/ai");
1814
+ var import_apps3 = require("@odla-ai/apps");
1815
+ var PROD_ENV_NAMES2 = /* @__PURE__ */ new Set(["prod", "production"]);
1816
+ async function secretsSet(options) {
1817
+ const name = (options.name ?? "").trim();
1818
+ if (!name) throw new Error('secret name is required, e.g. "odla-ai secrets set clerk_webhook_secret --env dev --stdin"');
1819
+ if (name.startsWith("$")) {
1820
+ throw new Error('"$"-prefixed vault names are platform-reserved; for the Clerk secret key use "odla-ai secrets set-clerk-key"');
1821
+ }
1822
+ const { cfg, tenantId, value, doFetch, out } = await resolveVaultWrite(options);
1823
+ const token = await getDeveloperToken(cfg, options, doFetch, out);
1824
+ try {
1825
+ await (0, import_ai2.putSecret)({ endpoint: cfg.dbEndpoint, token, fetch: doFetch }, tenantId, name, value);
1826
+ } catch (err) {
1827
+ throw new Error(scrubValue(err instanceof Error ? err.message : String(err), value));
1828
+ }
1829
+ out.log(`${name} stored in the ${tenantId} vault (write-only; the value was never echoed and cannot be read back here)`);
1830
+ }
1831
+ async function secretsSetClerkKey(options) {
1832
+ const { cfg, tenantId, value, doFetch, out } = await resolveVaultWrite(options);
1833
+ if (!value.startsWith("sk_")) throw new Error("the Clerk secret key must start with sk_ (sk_test_\u2026 or sk_live_\u2026)");
1834
+ if (value.startsWith("sk_test_") && PROD_ENV_NAMES2.has(options.env)) {
1835
+ throw new Error(`refusing to store an sk_test_ Clerk key for "${options.env}" \u2014 use the production instance's sk_live_ key`);
1836
+ }
1837
+ if (value.startsWith("sk_live_") && !PROD_ENV_NAMES2.has(options.env) && !options.yes) {
1838
+ throw new Error(`refusing to store an sk_live_ Clerk key for "${options.env}" without --yes (live users would sync into a non-prod tenant)`);
1839
+ }
1840
+ const token = await getDeveloperToken(cfg, options, doFetch, out);
1841
+ const res = await doFetch(`${cfg.dbEndpoint}/admin/apps/${encodeURIComponent(tenantId)}/clerk-secret`, {
1842
+ method: "POST",
1843
+ headers: { authorization: `Bearer ${token}`, "content-type": "application/json" },
1844
+ body: JSON.stringify({ value })
1845
+ });
1846
+ if (!res.ok) {
1847
+ const text = scrubValue((await res.text().catch(() => "")).slice(0, 300), value);
1848
+ throw new Error(`store Clerk secret key failed (${res.status}): ${text || "request failed"}`);
1849
+ }
1850
+ out.log(`Clerk secret key stored for ${tenantId} ($clerk_secret, reserved + write-only; the value was never echoed)`);
1851
+ }
1852
+ function scrubValue(text, value) {
1853
+ return redactSecrets(text).split(value).join("[value redacted]");
1854
+ }
1855
+ async function resolveVaultWrite(options) {
1856
+ const out = options.stdout ?? console;
1857
+ const doFetch = options.fetch ?? fetch;
1858
+ const cfg = await loadProjectConfig(options.configPath);
1859
+ if (!cfg.envs.includes(options.env)) {
1860
+ throw new Error(`env "${options.env}" is not in config envs (${cfg.envs.join(", ")})`);
1861
+ }
1862
+ if (PROD_ENV_NAMES2.has(options.env) && !options.yes) {
1863
+ throw new Error(`refusing to store a secret for "${options.env}" without --yes`);
1864
+ }
1865
+ const value = await secretInputValue(options, "secret");
1866
+ return { cfg, tenantId: (0, import_apps3.tenantIdFor)(cfg.app.id, options.env), value, doFetch, out };
1867
+ }
1868
+
1803
1869
  // src/security-command-context.ts
1804
1870
  var import_promises = require("readline/promises");
1805
1871
  async function hostedSecurityContext(parsed, dependencies) {
@@ -3130,8 +3196,26 @@ async function runCli(argv = process.argv.slice(2), dependencies = {}) {
3130
3196
  }
3131
3197
  if (command === "secrets") {
3132
3198
  const sub = parsed.positionals[1];
3199
+ if (sub === "set" || sub === "set-clerk-key") {
3200
+ assertArgs(parsed, ["config", "env", "from-env", "stdin", "token", "yes"], sub === "set" ? 3 : 2);
3201
+ const options = {
3202
+ configPath: stringOpt(parsed.options.config) ?? "odla.config.mjs",
3203
+ name: parsed.positionals[2],
3204
+ env: requiredString(parsed.options.env, "--env"),
3205
+ fromEnv: stringOpt(parsed.options["from-env"]),
3206
+ stdin: parsed.options.stdin === true,
3207
+ token: stringOpt(parsed.options.token),
3208
+ yes: parsed.options.yes === true,
3209
+ fetch: dependencies.fetch,
3210
+ stdout: dependencies.stdout
3211
+ };
3212
+ await (sub === "set" ? secretsSet(options) : secretsSetClerkKey(options));
3213
+ return;
3214
+ }
3133
3215
  if (sub !== "push") {
3134
- throw new Error(`unknown secrets subcommand "${sub ?? ""}". Try "odla-ai secrets push --env dev".`);
3216
+ throw new Error(
3217
+ `unknown secrets subcommand "${sub ?? ""}". Try "odla-ai secrets push --env dev", "odla-ai secrets set <name> --env dev --stdin", or "odla-ai secrets set-clerk-key --env dev --stdin".`
3218
+ );
3135
3219
  }
3136
3220
  assertArgs(parsed, ["config", "env", "dry-run", "yes"], 2);
3137
3221
  await secretsPush({