@odla-ai/cli 0.29.0 → 0.30.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -13,7 +13,7 @@ import {
13
13
  import process8 from "process";
14
14
 
15
15
  // src/token.ts
16
- import { collectToken, OdlaError, requestToken } from "@odla-ai/db";
16
+ import { OdlaError, requestToken } from "@odla-ai/db";
17
17
  import { createHash } from "crypto";
18
18
  import process5 from "process";
19
19
 
@@ -117,12 +117,40 @@ function handshakeUrl(platformUrl, userCode) {
117
117
 
118
118
  // src/handshake-state.ts
119
119
  import { rmSync } from "fs";
120
- import { dirname as dirname2, join } from "path";
120
+ import { dirname, join } from "path";
121
121
  import process4 from "process";
122
+ function handshakeFile(cfg) {
123
+ return join(dirname(cfg.local.tokenFile), "handshake.local.json");
124
+ }
125
+ function clearPendingHandshake(path) {
126
+ rmSync(path, { force: true });
127
+ }
128
+ function minutesLeft(expiresAt) {
129
+ return Math.max(1, Math.round((expiresAt - Date.now()) / 6e4));
130
+ }
131
+ function approvalHint(pending) {
132
+ return `approve code ${pending.userCode} at ${pending.approvalUrl} (${minutesLeft(pending.expiresAt)}m left)`;
133
+ }
134
+ function approvalReminder(out, pending, periodMs = 3e4) {
135
+ const timer = setInterval(() => {
136
+ for (const line of reminderLines({
137
+ userCode: pending.userCode,
138
+ approvalUrl: pending.approvalUrl,
139
+ minutesLeft: minutesLeft(pending.expiresAt)
140
+ }))
141
+ out.log(line);
142
+ }, periodMs);
143
+ timer.unref?.();
144
+ return () => clearInterval(timer);
145
+ }
146
+ function handshakeWaitMs(waitSeconds, interactive = process4.stdout.isTTY === true) {
147
+ if (waitSeconds !== void 0) return waitSeconds * 1e3;
148
+ return interactive ? void 0 : 9e4;
149
+ }
122
150
 
123
151
  // src/local.ts
124
152
  import { chmodSync, existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "fs";
125
- import { dirname, isAbsolute, relative, resolve } from "path";
153
+ import { dirname as dirname2, isAbsolute, relative, resolve } from "path";
126
154
  var GITIGNORE_LINES = [".odla/*.local.json", ".odla/dev-token.json", ".dev.vars"];
127
155
  function readJsonFile(path) {
128
156
  try {
@@ -190,7 +218,7 @@ function o11yDevVars(cfg) {
190
218
  function resolveWriteDevVarsTarget(cfg, requested) {
191
219
  if (!requested) return null;
192
220
  if (requested === true) return cfg.local.devVarsFile;
193
- return resolve(dirname(cfg.configPath), requested);
221
+ return resolve(dirname2(cfg.configPath), requested);
194
222
  }
195
223
  function writeDevVars(path, credentials, env, o11y) {
196
224
  const entry = credentials.envs[env];
@@ -230,7 +258,7 @@ function isManagedDevVar(line) {
230
258
  return !!match?.[1] && MANAGED_DEV_VARS.has(match[1]);
231
259
  }
232
260
  function writePrivateText(path, text2) {
233
- mkdirSync(dirname(path), { recursive: true });
261
+ mkdirSync(dirname2(path), { recursive: true });
234
262
  const temporary = `${path}.tmp-${process.pid}-${Date.now()}`;
235
263
  writeFileSync(temporary, text2, { mode: 384 });
236
264
  chmodSync(temporary, 384);
@@ -246,58 +274,6 @@ function displayPath(path, rootDir = process.cwd()) {
246
274
  return rel && !rel.startsWith("..") ? rel : path;
247
275
  }
248
276
 
249
- // src/handshake-state.ts
250
- function handshakeFile(cfg) {
251
- return join(dirname2(cfg.local.tokenFile), "handshake.local.json");
252
- }
253
- var RESUME_MARGIN_MS = 5e3;
254
- function readPendingHandshake(path, platform, email, requiredGrant) {
255
- const pending = readJsonFile(path);
256
- if (!pending || pending.platform !== platform || pending.email !== email) return null;
257
- if (typeof pending.userCode !== "string" || typeof pending.deviceCode !== "string") return null;
258
- if (typeof pending.expiresAt !== "number" || pending.expiresAt <= Date.now() + RESUME_MARGIN_MS) return null;
259
- if (requiredGrant && !grantCovers(pending, requiredGrant)) return null;
260
- return {
261
- interval: typeof pending.interval === "number" ? pending.interval : 3,
262
- ...pending,
263
- approvalUrl: handshakeUrl(platform, pending.userCode)
264
- };
265
- }
266
- function grantCovers(stored, required) {
267
- if (required.optionalProjectCapabilities.length === 0) return true;
268
- return required.projectIds.every((id) => stored.projectIds?.includes(id)) && required.optionalProjectCapabilities.every(
269
- (capability) => stored.optionalProjectCapabilities?.includes(capability)
270
- );
271
- }
272
- function writePendingHandshake(path, pending) {
273
- writePrivateJson(path, pending);
274
- }
275
- function clearPendingHandshake(path) {
276
- rmSync(path, { force: true });
277
- }
278
- function minutesLeft(expiresAt) {
279
- return Math.max(1, Math.round((expiresAt - Date.now()) / 6e4));
280
- }
281
- function approvalHint(pending) {
282
- return `approve code ${pending.userCode} at ${pending.approvalUrl} (${minutesLeft(pending.expiresAt)}m left)`;
283
- }
284
- function approvalReminder(out, pending, periodMs = 3e4) {
285
- const timer = setInterval(() => {
286
- for (const line of reminderLines({
287
- userCode: pending.userCode,
288
- approvalUrl: pending.approvalUrl,
289
- minutesLeft: minutesLeft(pending.expiresAt)
290
- }))
291
- out.log(line);
292
- }, periodMs);
293
- timer.unref?.();
294
- return () => clearInterval(timer);
295
- }
296
- function handshakeWaitMs(waitSeconds, interactive = process4.stdout.isTTY === true) {
297
- if (waitSeconds !== void 0) return waitSeconds * 1e3;
298
- return interactive ? void 0 : 9e4;
299
- }
300
-
301
277
  // src/token.ts
302
278
  async function getDeveloperToken(cfg, options, doFetch, out, grantRequest = {}) {
303
279
  const audience = platformAudience(cfg.platformUrl);
@@ -336,8 +312,8 @@ async function getDeveloperToken(cfg, options, doFetch, out, grantRequest = {})
336
312
  grantIntent
337
313
  };
338
314
  const waitMs = handshakeWaitMs(options.wait);
339
- if (grantRequest.freshLogin) clearPendingHandshake(ctx.pendingFile);
340
- const { token, expiresAt } = await resumePendingHandshake(ctx, waitMs) ?? await freshHandshake(ctx, waitMs);
315
+ clearPendingHandshake(ctx.pendingFile);
316
+ const { token, expiresAt } = await freshHandshake(ctx, waitMs);
341
317
  clearPendingHandshake(ctx.pendingFile);
342
318
  writePrivateJson(cfg.local.tokenFile, {
343
319
  platform: audience,
@@ -350,47 +326,6 @@ async function getDeveloperToken(cfg, options, doFetch, out, grantRequest = {})
350
326
  out.error(`auth: developer token cached (${displayPath(cfg.local.tokenFile, cfg.rootDir)})`);
351
327
  return token;
352
328
  }
353
- async function resumePendingHandshake(ctx, waitMs) {
354
- const pending = readPendingHandshake(
355
- ctx.pendingFile,
356
- ctx.audience,
357
- ctx.email,
358
- ctx.grantIntent
359
- );
360
- if (!pending) return null;
361
- ctx.out.error("");
362
- ctx.out.error(`auth: resuming pending handshake \u2014 ${approvalHint(pending)}`);
363
- await presentHandshakeApproval(ctx.out, {
364
- userCode: pending.userCode,
365
- approvalUrl: pending.approvalUrl,
366
- minutesLeft: Math.max(1, Math.floor((pending.expiresAt - Date.now()) / 6e4)),
367
- purpose: `sign this terminal in as ${ctx.email}`
368
- }, ctx.options);
369
- ctx.out.error("");
370
- const stopReminder = approvalReminder(ctx.out, pending);
371
- try {
372
- return await collectToken({
373
- endpoint: ctx.cfg.platformUrl,
374
- deviceCode: pending.deviceCode,
375
- expiresAt: pending.expiresAt,
376
- interval: pending.interval,
377
- waitMs,
378
- fetch: ctx.doFetch
379
- });
380
- } catch (err) {
381
- const code = err instanceof OdlaError ? err.code : void 0;
382
- if (code === "handshake_pending") throw stillPending(pending, ctx.email);
383
- if (code === "handshake_expired" || code === "handshake_timeout") {
384
- clearPendingHandshake(ctx.pendingFile);
385
- ctx.out.error("auth: pending handshake lapsed unapproved; starting a fresh one");
386
- return null;
387
- }
388
- if (code === "handshake_denied") clearPendingHandshake(ctx.pendingFile);
389
- throw err;
390
- } finally {
391
- stopReminder();
392
- }
393
- }
394
329
  async function freshHandshake(ctx, waitMs) {
395
330
  let started;
396
331
  let stopReminder;
@@ -418,7 +353,6 @@ async function freshHandshake(ctx, waitMs) {
418
353
  projectIds: ctx.grantIntent.projectIds,
419
354
  optionalProjectCapabilities: ctx.grantIntent.optionalProjectCapabilities
420
355
  };
421
- writePendingHandshake(ctx.pendingFile, started);
422
356
  await presentHandshakeApproval(ctx.out, {
423
357
  userCode,
424
358
  approvalUrl,
@@ -454,7 +388,7 @@ function projectAgentHandle(appId) {
454
388
  function stillPending(pending, email) {
455
389
  return new OdlaError(
456
390
  "handshake_pending",
457
- `handshake still pending \u2014 ask ${email} to ${approvalHint(pending)}, then re-run this command; it resumes the same handshake and collects the token`,
391
+ `handshake still pending \u2014 ask ${email} to ${approvalHint(pending)}; if this command exits, re-run it to request a new code`,
458
392
  { retryable: true }
459
393
  );
460
394
  }
@@ -3831,6 +3765,41 @@ async function wranglerLoggedIn(run, cwd) {
3831
3765
  return false;
3832
3766
  }
3833
3767
  }
3768
+ async function wranglerRuntimeTarget(run, opts) {
3769
+ const configPath = findWranglerConfig(opts.cwd);
3770
+ if (!configPath) throw new Error(`no wrangler config found in ${opts.cwd}`);
3771
+ const config = readWranglerConfig(configPath);
3772
+ if (!config) throw new Error("runtime credential delivery requires wrangler.json or wrangler.jsonc");
3773
+ const baseName = typeof config.name === "string" ? config.name : "";
3774
+ const envs = config.env && typeof config.env === "object" && !Array.isArray(config.env) ? config.env : {};
3775
+ const envConfig = opts.env && envs[opts.env] && typeof envs[opts.env] === "object" ? envs[opts.env] : {};
3776
+ const explicitName = typeof envConfig.name === "string" ? envConfig.name : "";
3777
+ const serviceEnvironments = config.legacy_env === false;
3778
+ if (serviceEnvironments && explicitName) {
3779
+ throw new Error("env.<name>.name is not allowed when wrangler legacy_env is false");
3780
+ }
3781
+ const scriptName = serviceEnvironments ? baseName : explicitName || (opts.env ? `${baseName}-${opts.env}` : baseName);
3782
+ if (!/^[a-z0-9][a-z0-9_-]{0,62}$/.test(scriptName)) {
3783
+ throw new Error("wrangler config must resolve an exact Worker name before credentials are issued");
3784
+ }
3785
+ const configuredAccount = typeof envConfig.account_id === "string" ? envConfig.account_id : typeof config.account_id === "string" ? config.account_id : "";
3786
+ const whoami = await run("npx", ["wrangler", "whoami"], { cwd: opts.cwd });
3787
+ if (whoami.code !== 0 || /not authenticated/i.test(`${whoami.stdout}${whoami.stderr}`)) {
3788
+ throw new Error(`wrangler is not logged in \u2014 run "wrangler login" (a browser step for the human)`);
3789
+ }
3790
+ const discovered = [...new Set(`${whoami.stdout}
3791
+ ${whoami.stderr}`.match(/\b[a-f0-9]{32}\b/gi)?.map((id) => id.toLowerCase()) ?? [])];
3792
+ const accountId = configuredAccount.toLowerCase() || (discovered.length === 1 ? discovered[0] : "");
3793
+ if (!/^[a-f0-9]{32}$/.test(accountId) || configuredAccount && discovered.length > 0 && !discovered.includes(accountId)) {
3794
+ throw new Error("Wrangler account is ambiguous or does not match account_id in the config");
3795
+ }
3796
+ return {
3797
+ provider: "cloudflare",
3798
+ accountId,
3799
+ scriptName,
3800
+ ...opts.env ? { environment: opts.env } : {}
3801
+ };
3802
+ }
3834
3803
  function wranglerPutSecret(run, opts) {
3835
3804
  const args = [
3836
3805
  "wrangler",
@@ -3842,6 +3811,16 @@ function wranglerPutSecret(run, opts) {
3842
3811
  ];
3843
3812
  return run("npx", args, { input: opts.value, cwd: opts.cwd });
3844
3813
  }
3814
+ function wranglerBulkSecrets(run, opts) {
3815
+ const args = [
3816
+ "wrangler",
3817
+ "secret",
3818
+ "bulk",
3819
+ ...opts.env ? ["--env", opts.env] : [],
3820
+ ...opts.configPath ? ["--config", opts.configPath] : []
3821
+ ];
3822
+ return run("npx", args, { input: JSON.stringify(opts.secrets), cwd: opts.cwd });
3823
+ }
3845
3824
 
3846
3825
  // src/doctor-checks.ts
3847
3826
  function lintRules(rules, entities, publicRead) {
@@ -4330,9 +4309,6 @@ var PROD_ENV_NAMES = /* @__PURE__ */ new Set(["prod", "production"]);
4330
4309
  async function secretsPush(options) {
4331
4310
  await secretsPushImpl(options, true);
4332
4311
  }
4333
- async function secretsPushAfterPreflight(options) {
4334
- await secretsPushImpl(options, false);
4335
- }
4336
4312
  async function secretsPushImpl(options, preflight) {
4337
4313
  const out = options.stdout ?? console;
4338
4314
  const cfg = await loadProjectConfig(options.configPath);
@@ -4739,28 +4715,31 @@ async function smoke(options) {
4739
4715
  const out = options.stdout ?? console;
4740
4716
  const cfg = await loadProjectConfig(options.configPath);
4741
4717
  const env = resolveEnv(cfg, options.env);
4742
- const credentials = readCredentials(cfg.local.credentialsFile);
4743
- if (!credentials) {
4718
+ const runtime = options.runtime === true;
4719
+ const credentials = runtime ? null : readCredentials(cfg.local.credentialsFile);
4720
+ if (!runtime && !credentials) {
4744
4721
  throw new Error(`local credentials missing: ${displayPath(cfg.local.credentialsFile, cfg.rootDir)}. Run "odla-ai provision --write-dev-vars".`);
4745
4722
  }
4746
- if (credentials.appId !== cfg.app.id) {
4723
+ if (credentials && credentials.appId !== cfg.app.id) {
4747
4724
  throw new Error(`local credentials are for app "${credentials.appId}", but config app is "${cfg.app.id}"`);
4748
4725
  }
4749
- const entry = credentials.envs[env];
4750
- if (!entry?.tenantId) {
4726
+ const entry = credentials?.envs[env];
4727
+ if (!runtime && !entry?.tenantId) {
4751
4728
  throw new Error(`local credentials have no tenant for env "${env}". Run "odla-ai provision --write-dev-vars".`);
4752
4729
  }
4753
4730
  const hasDb = cfg.services.includes("db");
4754
4731
  const hasO11y = cfg.services.includes("o11y");
4755
- if (hasDb && !entry.dbKey) {
4732
+ if (!runtime && hasDb && !entry?.dbKey) {
4756
4733
  throw new Error(`local credentials have no db key for env "${env}". Run "odla-ai provision --write-dev-vars".`);
4757
4734
  }
4758
- if (hasO11y && !entry.o11yToken) {
4735
+ if (!runtime && hasO11y && !entry?.o11yToken) {
4759
4736
  throw new Error(`local credentials have no o11y token for env "${env}". Run "odla-ai provision --write-dev-vars".`);
4760
4737
  }
4761
4738
  const doFetch = options.fetch ?? fetch;
4739
+ const tenantId = entry?.tenantId ?? resolveTenant(cfg, env).tenant;
4762
4740
  out.log(`smoke: ${cfg.app.id}/${env}`);
4763
- out.log(` tenant: ${entry.tenantId}`);
4741
+ out.log(` mode: ${runtime ? "runtime (Worker-held credentials)" : "local credentials"}`);
4742
+ out.log(` tenant: ${tenantId}`);
4764
4743
  const publicConfig = await getJson(doFetch, publicConfigUrl(cfg.platformUrl, cfg.app.id, env), void 0);
4765
4744
  out.log(` public-config: ok`);
4766
4745
  if (cfg.services.includes("ai") && cfg.ai?.provider) {
@@ -4774,7 +4753,7 @@ async function smoke(options) {
4774
4753
  if (mode !== "hosted") throw new Error(`ai mode mismatch: expected "hosted", public-config has "${String(mode ?? "none")}"`);
4775
4754
  out.log(" ai: hosted");
4776
4755
  }
4777
- if (hasO11y) out.log(` o11y: credentials present`);
4756
+ if (hasO11y) out.log(runtime ? ` o11y: Worker-held credential` : ` o11y: credentials present`);
4778
4757
  if (cfg.services.includes("calendar")) {
4779
4758
  const token = await getDeveloperToken(
4780
4759
  cfg,
@@ -4793,10 +4772,10 @@ async function smoke(options) {
4793
4772
  out.log(` calendar: ${status.status}, bookable (booking \u2192 ${status.bookingCalendarId ?? "primary"})`);
4794
4773
  }
4795
4774
  const database = await resolveDatabaseConfig(cfg);
4796
- if (hasDb) {
4775
+ if (hasDb && !runtime) {
4797
4776
  const expectedSchema = database.schema;
4798
4777
  const expectedEntities = serializedEntities(expectedSchema);
4799
- const liveSchemaPayload = await getJson(doFetch, `${cfg.dbEndpoint}/app/${encodeURIComponent(entry.tenantId)}/schema`, entry.dbKey);
4778
+ const liveSchemaPayload = await getJson(doFetch, `${cfg.dbEndpoint}/app/${encodeURIComponent(tenantId)}/schema`, entry.dbKey);
4800
4779
  const liveSchema = liveSchemaPayload.schema ?? liveSchemaPayload;
4801
4780
  const liveEntities = serializedEntities(liveSchema);
4802
4781
  if (expectedEntities.length) {
@@ -4806,7 +4785,7 @@ async function smoke(options) {
4806
4785
  out.log(` schema: ${liveEntities.length} entities`);
4807
4786
  const aggregateEntity = expectedEntities[0] ?? liveEntities[0];
4808
4787
  if (aggregateEntity) {
4809
- const aggregate = await postJson2(doFetch, `${cfg.dbEndpoint}/app/${encodeURIComponent(entry.tenantId)}/aggregate`, entry.dbKey, {
4788
+ const aggregate = await postJson2(doFetch, `${cfg.dbEndpoint}/app/${encodeURIComponent(tenantId)}/aggregate`, entry.dbKey, {
4810
4789
  ns: aggregateEntity,
4811
4790
  aggregate: { count: true }
4812
4791
  });
@@ -4815,9 +4794,20 @@ async function smoke(options) {
4815
4794
  } else {
4816
4795
  out.log(` aggregate: skipped (schema has no entities)`);
4817
4796
  }
4797
+ } else if (hasDb) {
4798
+ out.log(` db: Worker-held credential (direct schema/aggregate skipped)`);
4818
4799
  } else {
4819
4800
  out.log(` db: skipped (not enabled)`);
4820
4801
  }
4802
+ if (runtime) {
4803
+ const link = cfg.links?.[env];
4804
+ if (!link) throw new Error(`runtime smoke requires links.${env} in odla.config.mjs`);
4805
+ const res = await doFetch(link, { redirect: "manual" });
4806
+ if (res.status < 200 || res.status >= 500) {
4807
+ throw new Error(`runtime target ${new URL(link).origin} returned ${res.status}`);
4808
+ }
4809
+ out.log(` runtime target: ${res.status}`);
4810
+ }
4821
4811
  const probes = database.integrations.flatMap(
4822
4812
  (integration) => (integration.probes ?? []).map((probe) => ({ integration: integration.id, probe }))
4823
4813
  );
@@ -5020,10 +5010,11 @@ async function projectCommand(command, parsed, deps) {
5020
5010
  return true;
5021
5011
  }
5022
5012
  if (command === "smoke") {
5023
- assertArgs(parsed, ["config", "env", "token", "email", "open"], 1);
5013
+ assertArgs(parsed, ["config", "env", "runtime", "token", "email", "open"], 1);
5024
5014
  await smoke({
5025
5015
  configPath: stringOpt(parsed.options.config) ?? "odla.config.mjs",
5026
5016
  env: stringOpt(parsed.options.env),
5017
+ runtime: parsed.options.runtime === true,
5027
5018
  token: stringOpt(parsed.options.token),
5028
5019
  email: stringOpt(parsed.options.email),
5029
5020
  open: parsed.options.open === false ? false : parsed.options.open === true ? true : void 0,
@@ -8697,6 +8688,55 @@ function requireName(parsed) {
8697
8688
  return name;
8698
8689
  }
8699
8690
 
8691
+ // src/credential-command.ts
8692
+ async function responseError(response2) {
8693
+ return redactSecrets((await response2.text()).slice(0, 1e3));
8694
+ }
8695
+ async function credentialCommand(parsed, deps = {}) {
8696
+ const action2 = parsed.positionals[1] ?? "list";
8697
+ if (action2 !== "list" && action2 !== "revoke") {
8698
+ throw new Error(`unknown credentials action "${action2}". Try "odla-ai credentials list".`);
8699
+ }
8700
+ assertArgs(parsed, ["config", "env", "all", "json", "token", "email", "open"], action2 === "revoke" ? 3 : 2);
8701
+ const cfg = await loadProjectConfig(stringOpt(parsed.options.config) ?? "odla.config.mjs");
8702
+ const doFetch = deps.fetch ?? fetch;
8703
+ const out = deps.stdout ?? console;
8704
+ const token = await getDeveloperToken(cfg, {
8705
+ configPath: cfg.configPath,
8706
+ token: stringOpt(parsed.options.token),
8707
+ email: stringOpt(parsed.options.email),
8708
+ open: parsed.options.open === false ? false : parsed.options.open === true ? true : void 0,
8709
+ openApprovalUrl: deps.openUrl
8710
+ }, doFetch, out, { optionalProjectCapabilities: ["app.manage"] });
8711
+ const base = `${cfg.platformUrl}/registry/apps/${encodeURIComponent(cfg.app.id)}/runtime-credentials`;
8712
+ if (action2 === "revoke") {
8713
+ const id = parsed.positionals[2];
8714
+ if (!id) throw new Error("credentials revoke requires the exact receipt id from credentials list");
8715
+ const response3 = await doFetch(`${base}/${encodeURIComponent(id)}`, {
8716
+ method: "DELETE",
8717
+ headers: { authorization: `Bearer ${token}` }
8718
+ });
8719
+ if (!response3.ok) throw new Error(`runtime credential revoke failed (${response3.status}): ${await responseError(response3)}`);
8720
+ const body2 = await response3.json();
8721
+ return out.log(parsed.options.json === true ? JSON.stringify(body2, null, 2) : `revoked ${body2.receipt.id} (${body2.receipt.env} ${body2.receipt.target.scriptName})`);
8722
+ }
8723
+ const query = new URLSearchParams();
8724
+ const requestedEnv = stringOpt(parsed.options.env);
8725
+ if (requestedEnv) query.set("env", requestedEnv);
8726
+ const response2 = await doFetch(`${base}${query.size ? `?${query}` : ""}`, {
8727
+ headers: { authorization: `Bearer ${token}` }
8728
+ });
8729
+ if (!response2.ok) throw new Error(`runtime credential inventory failed (${response2.status}): ${await responseError(response2)}`);
8730
+ const body = await response2.json();
8731
+ const credentials = parsed.options.all === true ? body.credentials : body.credentials.filter((item) => item.state === "committed");
8732
+ if (parsed.options.json === true) return out.log(JSON.stringify({ credentials }, null, 2));
8733
+ if (!credentials.length) return out.log("no active runtime credential receipts");
8734
+ out.log("RECEIPT ENV TARGET CREATED");
8735
+ for (const item of credentials) {
8736
+ out.log(`${item.id} ${item.env} ${item.target.accountId ?? "local"}/${item.target.scriptName} ${new Date(item.createdAt).toISOString()}`);
8737
+ }
8738
+ }
8739
+
8700
8740
  // src/help-usage.ts
8701
8741
  var USAGE_SECTION = `
8702
8742
  Start here:
@@ -8815,7 +8855,9 @@ Usage:
8815
8855
  odla-ai security run [target] --ack-redacted-source [--env dev] [--profile odla] [--fail-on high]
8816
8856
  odla-ai security run [target] --self --ack-redacted-source
8817
8857
  odla-ai provision [--live] [--config odla.config.mjs] [--email <odla-account>] [--request-grant] [--wait <seconds>] [--dry-run] [--push-secrets] [--rotate-o11y-token] [--write-dev-vars[=path]] [--yes]
8818
- odla-ai smoke [--config odla.config.mjs] [--env dev] [--email <odla-account>] [--no-open]
8858
+ odla-ai credentials list [--config odla.config.mjs] [--env dev] [--all] [--json]
8859
+ odla-ai credentials revoke <receipt-id> [--config odla.config.mjs] [--json]
8860
+ odla-ai smoke [--config odla.config.mjs] [--env dev] [--runtime] [--email <odla-account>] [--no-open]
8819
8861
  odla-ai skill install [--dir <project>] [--agent <name>] [--global] [--force]
8820
8862
  odla-ai secrets push --env <env> [--config odla.config.mjs] [--dry-run] [--yes]
8821
8863
  odla-ai secrets set <name> --env <env> (--from-env <NAME>|--stdin) [--email <odla-account>] [--config odla.config.mjs] [--yes]
@@ -8917,7 +8959,7 @@ Commands:
8917
8959
  "provision --live --yes" initializes only the live instance of
8918
8960
  an existing sandbox app and enables every configured service;
8919
8961
  no edit to the dev-first envs list is required.
8920
- smoke Verify credentials, public-config, composed schema, db aggregate, and integration probes.
8962
+ smoke Verify local service access, or use --runtime for a Worker-held credential deployment.
8921
8963
  skill Same installer; --agent accepts all, claude, codex, cursor,
8922
8964
  copilot, gemini, or agents (repeatable or comma-separated).
8923
8965
  secrets Push configured db/o11y secrets into the Worker via wrangler
@@ -8937,9 +8979,13 @@ Safety:
8937
8979
  pinned versions of every external @odla-ai runtime module before importing
8938
8980
  the command graph. A stale workspace module blocks with its resolved path and
8939
8981
  tells the agent to update/rebase, npm ci, and rebuild.
8940
- Provision caches the approved developer token and service credentials under
8941
- .odla/ with mode 0600, and init adds those paths to .gitignore. Secret push
8942
- preflights Wrangler before any shown-once issuance or destructive rotation.
8982
+ Provision caches the approved developer token under .odla/ with mode 0600.
8983
+ Local-only development provisioning may also cache that developer's service
8984
+ credentials there. \`provision --push-secrets\` instead stages a fresh,
8985
+ independently revocable credential set through the approved handshake,
8986
+ transfers the complete set to the exact Worker with \`wrangler secret bulk\`
8987
+ over stdin, and never writes its plaintext under .odla. Secret push preflights
8988
+ Wrangler before shown-once issuance; it never rotates sibling credentials.
8943
8989
  Projectless PM, Discussions, o11y, runbook, and identity commands use
8944
8990
  --platform/--app/--env, ODLA_PLATFORM_URL/ODLA_APP_ID/ODLA_ENV, and
8945
8991
  ODLA_DEV_TOKEN. Save non-secret scope metadata with "context save", then
@@ -8953,12 +8999,11 @@ Safety:
8953
8999
  attempts to open it \u2014 including in CI, SSH, display-less, and agent-driven
8954
9000
  shells. Only --no-open suppresses the attempt. Browser launch is best-effort:
8955
9001
  agents with browser control must open that exact URL immediately; otherwise
8956
- they must give it to the human verbatim. A started handshake is persisted
8957
- under .odla/, so a command killed mid-wait loses nothing \u2014 rerunning resumes
8958
- the same code. Outside an interactive terminal the wait is capped (90s by
9002
+ they must give it to the human verbatim. A device code remains only in the
9003
+ running process. Outside an interactive terminal the wait is capped (90s by
8959
9004
  default, --wait <seconds> to change); a still-pending handshake then exits
8960
- with code 75: open the same URL (or relay it if browser control is unavailable),
8961
- wait for approval, and re-run to collect.
9005
+ with code 75. Rerunning always requests and opens a new code; older clients'
9006
+ persisted pending state is discarded.
8962
9007
  A fresh device handshake requires --email <odla-account> or ODLA_USER_EMAIL.
8963
9008
  The email is a non-secret identity hint: never provide a password or session
8964
9009
  token. It is the email shown by the signed-in odla account \u2014 never infer it
@@ -10863,7 +10908,7 @@ async function issueO11yToken(opts) {
10863
10908
  );
10864
10909
  if (res.status === 409 && !opts.rotateO11y) {
10865
10910
  throw new Error(
10866
- `o11y token already exists for env "${opts.env}", but its shown-once value is not in the local credentials file; run "odla-ai provision --rotate-o11y-token --push-secrets" to replace it explicitly`
10911
+ `o11y token already exists for env "${opts.env}", but its shown-once value is not in the local credentials file; run "odla-ai provision --push-secrets" to install a separate runtime credential without rotating siblings`
10867
10912
  );
10868
10913
  }
10869
10914
  if (!res.ok) throw new Error(`o11y token ${opts.rotateO11y ? "rotation" : "issue"} (${opts.env}) failed: ${res.status} ${await safeText7(res)}`);
@@ -10879,6 +10924,88 @@ async function safeText7(res) {
10879
10924
  }
10880
10925
  }
10881
10926
 
10927
+ // src/runtime-credentials.ts
10928
+ import { randomUUID as randomUUID3 } from "crypto";
10929
+ function runtimeUrl(cfg, suffix = "") {
10930
+ return `${cfg.platformUrl}/registry/apps/${encodeURIComponent(cfg.app.id)}/runtime-credentials${suffix}`;
10931
+ }
10932
+ async function safeError(response2) {
10933
+ const text2 = await response2.text();
10934
+ return redactSecrets(text2.slice(0, 1e3));
10935
+ }
10936
+ async function finish(doFetch, cfg, token, sessionId, method) {
10937
+ return doFetch(runtimeUrl(cfg, `/${encodeURIComponent(sessionId)}`), {
10938
+ method,
10939
+ headers: { authorization: `Bearer ${token}` }
10940
+ });
10941
+ }
10942
+ async function deliverRuntimeCredentials(cfg, options) {
10943
+ const doFetch = options.fetch ?? fetch;
10944
+ const run = options.runner ?? defaultRunner;
10945
+ const wranglerEnv = options.env === "prod" || options.env === "production" ? void 0 : options.env;
10946
+ const target = await wranglerRuntimeTarget(run, {
10947
+ cwd: cfg.rootDir,
10948
+ env: wranglerEnv
10949
+ });
10950
+ const issue = await doFetch(runtimeUrl(cfg), {
10951
+ method: "POST",
10952
+ headers: {
10953
+ authorization: `Bearer ${options.developerToken}`,
10954
+ "content-type": "application/json"
10955
+ },
10956
+ body: JSON.stringify({
10957
+ env: options.env,
10958
+ idempotencyKey: `wrangler:${randomUUID3()}`,
10959
+ target
10960
+ })
10961
+ });
10962
+ const body = await issue.json().catch(() => null);
10963
+ if (!issue.ok || typeof body?.sessionId !== "string" || !body.secrets || typeof body.secrets !== "object") {
10964
+ const detail = body?.error?.code ?? body?.error?.message ?? issue.status;
10965
+ throw new Error(`runtime credential staging failed: ${String(detail)}`);
10966
+ }
10967
+ const secrets = body.secrets;
10968
+ const values = {};
10969
+ for (const name of ["ODLA_API_KEY", "ODLA_O11Y_TOKEN"]) {
10970
+ const value2 = secrets[name];
10971
+ if (typeof value2 === "string" && value2) values[name] = value2;
10972
+ }
10973
+ if (Object.keys(values).length === 0) {
10974
+ await finish(doFetch, cfg, options.developerToken, body.sessionId, "DELETE");
10975
+ throw new Error("runtime credential staging returned no configured service credentials");
10976
+ }
10977
+ const pushed = await wranglerBulkSecrets(run, {
10978
+ secrets: values,
10979
+ env: wranglerEnv,
10980
+ cwd: cfg.rootDir
10981
+ });
10982
+ if (pushed.code !== 0) {
10983
+ await finish(doFetch, cfg, options.developerToken, body.sessionId, "DELETE");
10984
+ const detail = redactSecrets(`${pushed.stderr || pushed.stdout}`.trim());
10985
+ throw new Error(`wrangler secret bulk failed (exit ${pushed.code}): ${detail}`);
10986
+ }
10987
+ const committed = await finish(
10988
+ doFetch,
10989
+ cfg,
10990
+ options.developerToken,
10991
+ body.sessionId,
10992
+ "POST"
10993
+ );
10994
+ if (!committed.ok) {
10995
+ await finish(doFetch, cfg, options.developerToken, body.sessionId, "DELETE");
10996
+ throw new Error(`runtime credential receipt commit failed: ${await safeError(committed)}`);
10997
+ }
10998
+ options.stdout?.log(
10999
+ `${options.env}: installed ${Object.keys(values).join(" + ")} on ${target.accountId}/${target.scriptName}${target.environment ? ` (${target.environment})` : ""}; plaintext stayed in memory`
11000
+ );
11001
+ return {
11002
+ sessionId: body.sessionId,
11003
+ target,
11004
+ ...values.ODLA_API_KEY ? { dbKey: values.ODLA_API_KEY } : {},
11005
+ ...values.ODLA_O11Y_TOKEN ? { o11yToken: values.ODLA_O11Y_TOKEN } : {}
11006
+ };
11007
+ }
11008
+
10882
11009
  // src/provision-live.ts
10883
11010
  function liveProvisionConfig(cfg) {
10884
11011
  if (!cfg.envs.includes("dev")) {
@@ -10912,8 +11039,10 @@ async function provision(options) {
10912
11039
  if (options.rotateO11yToken && !hasO11y) {
10913
11040
  throw new Error("--rotate-o11y-token requires the o11y service in odla.config.mjs");
10914
11041
  }
10915
- if (options.pushSecrets && options.writeCredentials === false) {
10916
- throw new Error("--push-secrets cannot be combined with --no-write-credentials");
11042
+ if (options.pushSecrets && (options.rotateKeys || options.rotateO11yToken)) {
11043
+ throw new Error(
11044
+ "--push-secrets always issues an independent runtime credential set; do not combine it with destructive rotation flags"
11045
+ );
10917
11046
  }
10918
11047
  out.log(`odla-ai: ${plan.appName} (${plan.appId})`);
10919
11048
  out.log(` platform: ${plan.platformUrl}`);
@@ -10950,7 +11079,7 @@ async function provision(options) {
10950
11079
  out.log(` secrets: ${options.pushSecrets ? "push configured Worker secrets" : "local only"}`);
10951
11080
  return;
10952
11081
  }
10953
- let credentials = readCredentials(cfg.local.credentialsFile);
11082
+ let credentials = options.pushSecrets ? null : readCredentials(cfg.local.credentialsFile);
10954
11083
  if (credentials && credentials.appId !== cfg.app.id) {
10955
11084
  throw new Error(
10956
11085
  `credentials at ${displayPath(cfg.local.credentialsFile, cfg.rootDir)} are for "${credentials.appId}", not "${cfg.app.id}"`
@@ -10959,7 +11088,7 @@ async function provision(options) {
10959
11088
  const rotatesO11y = !!(options.rotateKeys || options.rotateO11yToken);
10960
11089
  const missingO11y = cfg.envs.some((env) => !credentials?.envs[env]?.o11yToken);
10961
11090
  const missingDb = cfg.envs.some((env) => !credentials?.envs[env]?.dbKey);
10962
- const losesShownOnceCredential = hasDb && (!!options.rotateKeys || missingDb) || hasO11y && (rotatesO11y || missingO11y);
11091
+ const losesShownOnceCredential = !options.pushSecrets && (hasDb && (!!options.rotateKeys || missingDb) || hasO11y && (rotatesO11y || missingO11y));
10963
11092
  if (options.writeCredentials === false && losesShownOnceCredential) {
10964
11093
  throw new Error("credential issuance/rotation requires the private credentials file; remove --no-write-credentials");
10965
11094
  }
@@ -11033,38 +11162,42 @@ async function provision(options) {
11033
11162
  await applyCalendarBookingPage(calendarCtx, calendarBookingPageUrl(cfg, env), out);
11034
11163
  }
11035
11164
  }
11165
+ let devVarsCredentials = credentials;
11036
11166
  for (const env of cfg.envs) {
11037
11167
  const tenantId = tenantIdFor5(cfg.app.id, env);
11038
- credentials = await provisionEnvCredentials({
11039
- cfg,
11040
- env,
11041
- developerToken: token,
11042
- credentials,
11043
- rotateDb: !!options.rotateKeys,
11044
- rotateO11y: rotatesO11y,
11045
- write: options.writeCredentials !== false,
11046
- fetch: doFetch,
11047
- stdout: out
11048
- });
11049
- const dbKey = credentials.envs[env]?.dbKey;
11168
+ let dbKey;
11050
11169
  if (options.pushSecrets) {
11051
- try {
11052
- await secretsPushAfterPreflight({
11053
- configPath: cfg.configPath,
11054
- env,
11055
- yes: options.yes,
11056
- runner: options.secretRunner,
11057
- stdout: out
11058
- });
11059
- } catch (error) {
11060
- const message2 = error instanceof Error ? error.message : String(error);
11061
- const consent = env === "prod" || env === "production" ? " --yes" : "";
11062
- throw new Error(
11063
- `${message2}
11064
- ${env}: credentials are already saved; retry "odla-ai secrets push --env ${env}${consent}" without issuing or rotating again`,
11065
- { cause: error }
11066
- );
11067
- }
11170
+ const delivered = await deliverRuntimeCredentials(cfg, {
11171
+ env,
11172
+ developerToken: token,
11173
+ fetch: doFetch,
11174
+ runner: options.secretRunner,
11175
+ stdout: out
11176
+ });
11177
+ dbKey = delivered.dbKey;
11178
+ devVarsCredentials = mergeCredential(devVarsCredentials, {
11179
+ appId: cfg.app.id,
11180
+ platformUrl: cfg.platformUrl,
11181
+ dbEndpoint: cfg.dbEndpoint,
11182
+ env,
11183
+ tenantId,
11184
+ ...delivered.dbKey ? { dbKey: delivered.dbKey } : {},
11185
+ ...delivered.o11yToken ? { o11yToken: delivered.o11yToken } : {}
11186
+ });
11187
+ } else {
11188
+ credentials = await provisionEnvCredentials({
11189
+ cfg,
11190
+ env,
11191
+ developerToken: token,
11192
+ credentials,
11193
+ rotateDb: !!options.rotateKeys,
11194
+ rotateO11y: rotatesO11y,
11195
+ write: options.writeCredentials !== false,
11196
+ fetch: doFetch,
11197
+ stdout: out
11198
+ });
11199
+ devVarsCredentials = credentials;
11200
+ dbKey = credentials.envs[env]?.dbKey;
11068
11201
  }
11069
11202
  if (schema && dbKey) {
11070
11203
  await postJson(doFetch, `${cfg.dbEndpoint}/app/${encodeURIComponent(tenantId)}/schema`, dbKey, { schema });
@@ -11088,13 +11221,13 @@ ${env}: credentials are already saved; retry "odla-ai secrets push --env ${env}$
11088
11221
  }
11089
11222
  }
11090
11223
  }
11091
- if (options.writeCredentials !== false && credentials) {
11224
+ if (!options.pushSecrets && options.writeCredentials !== false && credentials) {
11092
11225
  const ignored = cfg.local.gitignore && gitignoreEntry(cfg.rootDir, cfg.local.credentialsFile);
11093
11226
  out.log(`credentials: wrote ${displayPath(cfg.local.credentialsFile, cfg.rootDir)} (0600${ignored ? ", gitignored" : ""})`);
11094
11227
  }
11095
- if (devVarsTarget && credentials) {
11228
+ if (devVarsTarget && devVarsCredentials) {
11096
11229
  const env = cfg.envs.includes("dev") ? "dev" : cfg.envs[0] ?? "prod";
11097
- writeDevVars(devVarsTarget, credentials, env, o11yDevVars(cfg));
11230
+ writeDevVars(devVarsTarget, devVarsCredentials, env, o11yDevVars(cfg));
11098
11231
  out.log(`dev vars: wrote ${displayPath(devVarsTarget, cfg.rootDir)} for ${env}`);
11099
11232
  }
11100
11233
  if (cfg.services.includes("calendar")) {
@@ -11185,6 +11318,7 @@ var COMMAND_SURFACE = {
11185
11318
  code: { connect: {} },
11186
11319
  config: { diff: {}, plan: {}, apply: {} },
11187
11320
  context: { show: {}, list: {}, save: {}, remove: {} },
11321
+ credentials: { list: {}, revoke: {} },
11188
11322
  // `watch`, `read`, `reply`, and `resolve` take a topic id from there on.
11189
11323
  discuss: {
11190
11324
  groups: {},
@@ -12869,6 +13003,10 @@ async function runCli(argv = process.argv.slice(2), dependencies = {}) {
12869
13003
  await contextCommand(parsed, runtime);
12870
13004
  return;
12871
13005
  }
13006
+ if (command === "credentials") {
13007
+ await credentialCommand(parsed, runtime);
13008
+ return;
13009
+ }
12872
13010
  if (command === "runbook") {
12873
13011
  await runbookCommand(parsed, runtime);
12874
13012
  return;
@@ -13056,4 +13194,4 @@ export {
13056
13194
  isTerminalHostedSecurityStatus,
13057
13195
  runCli
13058
13196
  };
13059
- //# sourceMappingURL=chunk-YTVLTADT.js.map
13197
+ //# sourceMappingURL=chunk-7V7WVTDT.js.map