@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.
package/dist/index.cjs CHANGED
@@ -203,17 +203,45 @@ function handshakeUrl(platformUrl, userCode) {
203
203
  }
204
204
 
205
205
  // src/handshake-state.ts
206
- var import_node_fs2 = require("fs");
207
- var import_node_path2 = require("path");
206
+ var import_node_fs = require("fs");
207
+ var import_node_path = require("path");
208
208
  var import_node_process3 = __toESM(require("process"), 1);
209
+ function handshakeFile(cfg) {
210
+ return (0, import_node_path.join)((0, import_node_path.dirname)(cfg.local.tokenFile), "handshake.local.json");
211
+ }
212
+ function clearPendingHandshake(path) {
213
+ (0, import_node_fs.rmSync)(path, { force: true });
214
+ }
215
+ function minutesLeft(expiresAt) {
216
+ return Math.max(1, Math.round((expiresAt - Date.now()) / 6e4));
217
+ }
218
+ function approvalHint(pending) {
219
+ return `approve code ${pending.userCode} at ${pending.approvalUrl} (${minutesLeft(pending.expiresAt)}m left)`;
220
+ }
221
+ function approvalReminder(out, pending, periodMs = 3e4) {
222
+ const timer = setInterval(() => {
223
+ for (const line of reminderLines({
224
+ userCode: pending.userCode,
225
+ approvalUrl: pending.approvalUrl,
226
+ minutesLeft: minutesLeft(pending.expiresAt)
227
+ }))
228
+ out.log(line);
229
+ }, periodMs);
230
+ timer.unref?.();
231
+ return () => clearInterval(timer);
232
+ }
233
+ function handshakeWaitMs(waitSeconds, interactive = import_node_process3.default.stdout.isTTY === true) {
234
+ if (waitSeconds !== void 0) return waitSeconds * 1e3;
235
+ return interactive ? void 0 : 9e4;
236
+ }
209
237
 
210
238
  // src/local.ts
211
- var import_node_fs = require("fs");
212
- var import_node_path = require("path");
239
+ var import_node_fs2 = require("fs");
240
+ var import_node_path2 = require("path");
213
241
  var GITIGNORE_LINES = [".odla/*.local.json", ".odla/dev-token.json", ".dev.vars"];
214
242
  function readJsonFile(path) {
215
243
  try {
216
- return JSON.parse((0, import_node_fs.readFileSync)(path, "utf8"));
244
+ return JSON.parse((0, import_node_fs2.readFileSync)(path, "utf8"));
217
245
  } catch {
218
246
  return null;
219
247
  }
@@ -223,10 +251,10 @@ function writePrivateJson(path, value2) {
223
251
  `);
224
252
  }
225
253
  function readCredentials(path) {
226
- if (!(0, import_node_fs.existsSync)(path)) return null;
254
+ if (!(0, import_node_fs2.existsSync)(path)) return null;
227
255
  let value2;
228
256
  try {
229
- value2 = JSON.parse((0, import_node_fs.readFileSync)(path, "utf8"));
257
+ value2 = JSON.parse((0, import_node_fs2.readFileSync)(path, "utf8"));
230
258
  } catch {
231
259
  throw new Error(`credentials file ${path} is not valid JSON; fix or remove it before provisioning`);
232
260
  }
@@ -256,14 +284,14 @@ function mergeCredential(current, update) {
256
284
  return next;
257
285
  }
258
286
  function ensureGitignore(rootDir, localPaths = []) {
259
- const path = (0, import_node_path.resolve)(rootDir, ".gitignore");
260
- const existing = (0, import_node_fs.existsSync)(path) ? (0, import_node_fs.readFileSync)(path, "utf8") : "";
287
+ const path = (0, import_node_path2.resolve)(rootDir, ".gitignore");
288
+ const existing = (0, import_node_fs2.existsSync)(path) ? (0, import_node_fs2.readFileSync)(path, "utf8") : "";
261
289
  const configured = localPaths.map((localPath) => gitignoreEntry(rootDir, localPath)).filter((line) => !!line);
262
290
  const wanted = [.../* @__PURE__ */ new Set([...GITIGNORE_LINES, ...configured])];
263
291
  const missing = wanted.filter((line) => !existing.split(/\r?\n/).includes(line));
264
292
  if (missing.length === 0) return;
265
293
  const prefix = existing && !existing.endsWith("\n") ? "\n" : "";
266
- (0, import_node_fs.writeFileSync)(path, `${existing}${prefix}${missing.join("\n")}
294
+ (0, import_node_fs2.writeFileSync)(path, `${existing}${prefix}${missing.join("\n")}
267
295
  `);
268
296
  }
269
297
  function o11yDevVars(cfg) {
@@ -277,7 +305,7 @@ function o11yDevVars(cfg) {
277
305
  function resolveWriteDevVarsTarget(cfg, requested) {
278
306
  if (!requested) return null;
279
307
  if (requested === true) return cfg.local.devVarsFile;
280
- return (0, import_node_path.resolve)((0, import_node_path.dirname)(cfg.configPath), requested);
308
+ return (0, import_node_path2.resolve)((0, import_node_path2.dirname)(cfg.configPath), requested);
281
309
  }
282
310
  function writeDevVars(path, credentials, env, o11y) {
283
311
  const entry = credentials.envs[env];
@@ -291,7 +319,7 @@ function writeDevVars(path, credentials, env, o11y) {
291
319
  if (o11y.version) lines.push(`ODLA_O11Y_VERSION="${o11y.version}"`);
292
320
  if (entry.o11yToken) lines.push(`ODLA_O11Y_TOKEN="${entry.o11yToken}"`);
293
321
  }
294
- const existing = (0, import_node_fs.existsSync)(path) ? (0, import_node_fs.readFileSync)(path, "utf8") : "";
322
+ const existing = (0, import_node_fs2.existsSync)(path) ? (0, import_node_fs2.readFileSync)(path, "utf8") : "";
295
323
  const retained = existing.split(/\r?\n/).filter((line) => !isManagedDevVar(line));
296
324
  while (retained.at(-1) === "") retained.pop();
297
325
  const prefix = retained.length ? `${retained.join("\n")}
@@ -317,74 +345,22 @@ function isManagedDevVar(line) {
317
345
  return !!match?.[1] && MANAGED_DEV_VARS.has(match[1]);
318
346
  }
319
347
  function writePrivateText(path, text2) {
320
- (0, import_node_fs.mkdirSync)((0, import_node_path.dirname)(path), { recursive: true });
348
+ (0, import_node_fs2.mkdirSync)((0, import_node_path2.dirname)(path), { recursive: true });
321
349
  const temporary = `${path}.tmp-${process.pid}-${Date.now()}`;
322
- (0, import_node_fs.writeFileSync)(temporary, text2, { mode: 384 });
323
- (0, import_node_fs.chmodSync)(temporary, 384);
324
- (0, import_node_fs.renameSync)(temporary, path);
350
+ (0, import_node_fs2.writeFileSync)(temporary, text2, { mode: 384 });
351
+ (0, import_node_fs2.chmodSync)(temporary, 384);
352
+ (0, import_node_fs2.renameSync)(temporary, path);
325
353
  }
326
354
  function gitignoreEntry(rootDir, path) {
327
- const rel = (0, import_node_path.relative)((0, import_node_path.resolve)(rootDir), (0, import_node_path.resolve)(path));
328
- if (!rel || rel === ".." || rel.startsWith(`..${process.platform === "win32" ? "\\" : "/"}`) || (0, import_node_path.isAbsolute)(rel)) return null;
355
+ const rel = (0, import_node_path2.relative)((0, import_node_path2.resolve)(rootDir), (0, import_node_path2.resolve)(path));
356
+ if (!rel || rel === ".." || rel.startsWith(`..${process.platform === "win32" ? "\\" : "/"}`) || (0, import_node_path2.isAbsolute)(rel)) return null;
329
357
  return rel.replaceAll("\\", "/");
330
358
  }
331
359
  function displayPath(path, rootDir = process.cwd()) {
332
- const rel = (0, import_node_path.relative)(rootDir, path);
360
+ const rel = (0, import_node_path2.relative)(rootDir, path);
333
361
  return rel && !rel.startsWith("..") ? rel : path;
334
362
  }
335
363
 
336
- // src/handshake-state.ts
337
- function handshakeFile(cfg) {
338
- return (0, import_node_path2.join)((0, import_node_path2.dirname)(cfg.local.tokenFile), "handshake.local.json");
339
- }
340
- var RESUME_MARGIN_MS = 5e3;
341
- function readPendingHandshake(path, platform, email, requiredGrant) {
342
- const pending = readJsonFile(path);
343
- if (!pending || pending.platform !== platform || pending.email !== email) return null;
344
- if (typeof pending.userCode !== "string" || typeof pending.deviceCode !== "string") return null;
345
- if (typeof pending.expiresAt !== "number" || pending.expiresAt <= Date.now() + RESUME_MARGIN_MS) return null;
346
- if (requiredGrant && !grantCovers(pending, requiredGrant)) return null;
347
- return {
348
- interval: typeof pending.interval === "number" ? pending.interval : 3,
349
- ...pending,
350
- approvalUrl: handshakeUrl(platform, pending.userCode)
351
- };
352
- }
353
- function grantCovers(stored, required) {
354
- if (required.optionalProjectCapabilities.length === 0) return true;
355
- return required.projectIds.every((id) => stored.projectIds?.includes(id)) && required.optionalProjectCapabilities.every(
356
- (capability) => stored.optionalProjectCapabilities?.includes(capability)
357
- );
358
- }
359
- function writePendingHandshake(path, pending) {
360
- writePrivateJson(path, pending);
361
- }
362
- function clearPendingHandshake(path) {
363
- (0, import_node_fs2.rmSync)(path, { force: true });
364
- }
365
- function minutesLeft(expiresAt) {
366
- return Math.max(1, Math.round((expiresAt - Date.now()) / 6e4));
367
- }
368
- function approvalHint(pending) {
369
- return `approve code ${pending.userCode} at ${pending.approvalUrl} (${minutesLeft(pending.expiresAt)}m left)`;
370
- }
371
- function approvalReminder(out, pending, periodMs = 3e4) {
372
- const timer = setInterval(() => {
373
- for (const line of reminderLines({
374
- userCode: pending.userCode,
375
- approvalUrl: pending.approvalUrl,
376
- minutesLeft: minutesLeft(pending.expiresAt)
377
- }))
378
- out.log(line);
379
- }, periodMs);
380
- timer.unref?.();
381
- return () => clearInterval(timer);
382
- }
383
- function handshakeWaitMs(waitSeconds, interactive = import_node_process3.default.stdout.isTTY === true) {
384
- if (waitSeconds !== void 0) return waitSeconds * 1e3;
385
- return interactive ? void 0 : 9e4;
386
- }
387
-
388
364
  // src/token.ts
389
365
  async function getDeveloperToken(cfg, options, doFetch, out, grantRequest = {}) {
390
366
  const audience = platformAudience(cfg.platformUrl);
@@ -423,8 +399,8 @@ async function getDeveloperToken(cfg, options, doFetch, out, grantRequest = {})
423
399
  grantIntent
424
400
  };
425
401
  const waitMs = handshakeWaitMs(options.wait);
426
- if (grantRequest.freshLogin) clearPendingHandshake(ctx.pendingFile);
427
- const { token, expiresAt } = await resumePendingHandshake(ctx, waitMs) ?? await freshHandshake(ctx, waitMs);
402
+ clearPendingHandshake(ctx.pendingFile);
403
+ const { token, expiresAt } = await freshHandshake(ctx, waitMs);
428
404
  clearPendingHandshake(ctx.pendingFile);
429
405
  writePrivateJson(cfg.local.tokenFile, {
430
406
  platform: audience,
@@ -437,47 +413,6 @@ async function getDeveloperToken(cfg, options, doFetch, out, grantRequest = {})
437
413
  out.error(`auth: developer token cached (${displayPath(cfg.local.tokenFile, cfg.rootDir)})`);
438
414
  return token;
439
415
  }
440
- async function resumePendingHandshake(ctx, waitMs) {
441
- const pending = readPendingHandshake(
442
- ctx.pendingFile,
443
- ctx.audience,
444
- ctx.email,
445
- ctx.grantIntent
446
- );
447
- if (!pending) return null;
448
- ctx.out.error("");
449
- ctx.out.error(`auth: resuming pending handshake \u2014 ${approvalHint(pending)}`);
450
- await presentHandshakeApproval(ctx.out, {
451
- userCode: pending.userCode,
452
- approvalUrl: pending.approvalUrl,
453
- minutesLeft: Math.max(1, Math.floor((pending.expiresAt - Date.now()) / 6e4)),
454
- purpose: `sign this terminal in as ${ctx.email}`
455
- }, ctx.options);
456
- ctx.out.error("");
457
- const stopReminder = approvalReminder(ctx.out, pending);
458
- try {
459
- return await (0, import_db.collectToken)({
460
- endpoint: ctx.cfg.platformUrl,
461
- deviceCode: pending.deviceCode,
462
- expiresAt: pending.expiresAt,
463
- interval: pending.interval,
464
- waitMs,
465
- fetch: ctx.doFetch
466
- });
467
- } catch (err) {
468
- const code = err instanceof import_db.OdlaError ? err.code : void 0;
469
- if (code === "handshake_pending") throw stillPending(pending, ctx.email);
470
- if (code === "handshake_expired" || code === "handshake_timeout") {
471
- clearPendingHandshake(ctx.pendingFile);
472
- ctx.out.error("auth: pending handshake lapsed unapproved; starting a fresh one");
473
- return null;
474
- }
475
- if (code === "handshake_denied") clearPendingHandshake(ctx.pendingFile);
476
- throw err;
477
- } finally {
478
- stopReminder();
479
- }
480
- }
481
416
  async function freshHandshake(ctx, waitMs) {
482
417
  let started;
483
418
  let stopReminder;
@@ -505,7 +440,6 @@ async function freshHandshake(ctx, waitMs) {
505
440
  projectIds: ctx.grantIntent.projectIds,
506
441
  optionalProjectCapabilities: ctx.grantIntent.optionalProjectCapabilities
507
442
  };
508
- writePendingHandshake(ctx.pendingFile, started);
509
443
  await presentHandshakeApproval(ctx.out, {
510
444
  userCode,
511
445
  approvalUrl,
@@ -541,7 +475,7 @@ function projectAgentHandle(appId) {
541
475
  function stillPending(pending, email) {
542
476
  return new import_db.OdlaError(
543
477
  "handshake_pending",
544
- `handshake still pending \u2014 ask ${email} to ${approvalHint(pending)}, then re-run this command; it resumes the same handshake and collects the token`,
478
+ `handshake still pending \u2014 ask ${email} to ${approvalHint(pending)}; if this command exits, re-run it to request a new code`,
545
479
  { retryable: true }
546
480
  );
547
481
  }
@@ -3961,6 +3895,41 @@ async function wranglerLoggedIn(run, cwd) {
3961
3895
  return false;
3962
3896
  }
3963
3897
  }
3898
+ async function wranglerRuntimeTarget(run, opts) {
3899
+ const configPath = findWranglerConfig(opts.cwd);
3900
+ if (!configPath) throw new Error(`no wrangler config found in ${opts.cwd}`);
3901
+ const config = readWranglerConfig(configPath);
3902
+ if (!config) throw new Error("runtime credential delivery requires wrangler.json or wrangler.jsonc");
3903
+ const baseName = typeof config.name === "string" ? config.name : "";
3904
+ const envs = config.env && typeof config.env === "object" && !Array.isArray(config.env) ? config.env : {};
3905
+ const envConfig = opts.env && envs[opts.env] && typeof envs[opts.env] === "object" ? envs[opts.env] : {};
3906
+ const explicitName = typeof envConfig.name === "string" ? envConfig.name : "";
3907
+ const serviceEnvironments = config.legacy_env === false;
3908
+ if (serviceEnvironments && explicitName) {
3909
+ throw new Error("env.<name>.name is not allowed when wrangler legacy_env is false");
3910
+ }
3911
+ const scriptName = serviceEnvironments ? baseName : explicitName || (opts.env ? `${baseName}-${opts.env}` : baseName);
3912
+ if (!/^[a-z0-9][a-z0-9_-]{0,62}$/.test(scriptName)) {
3913
+ throw new Error("wrangler config must resolve an exact Worker name before credentials are issued");
3914
+ }
3915
+ const configuredAccount = typeof envConfig.account_id === "string" ? envConfig.account_id : typeof config.account_id === "string" ? config.account_id : "";
3916
+ const whoami = await run("npx", ["wrangler", "whoami"], { cwd: opts.cwd });
3917
+ if (whoami.code !== 0 || /not authenticated/i.test(`${whoami.stdout}${whoami.stderr}`)) {
3918
+ throw new Error(`wrangler is not logged in \u2014 run "wrangler login" (a browser step for the human)`);
3919
+ }
3920
+ const discovered = [...new Set(`${whoami.stdout}
3921
+ ${whoami.stderr}`.match(/\b[a-f0-9]{32}\b/gi)?.map((id) => id.toLowerCase()) ?? [])];
3922
+ const accountId = configuredAccount.toLowerCase() || (discovered.length === 1 ? discovered[0] : "");
3923
+ if (!/^[a-f0-9]{32}$/.test(accountId) || configuredAccount && discovered.length > 0 && !discovered.includes(accountId)) {
3924
+ throw new Error("Wrangler account is ambiguous or does not match account_id in the config");
3925
+ }
3926
+ return {
3927
+ provider: "cloudflare",
3928
+ accountId,
3929
+ scriptName,
3930
+ ...opts.env ? { environment: opts.env } : {}
3931
+ };
3932
+ }
3964
3933
  function wranglerPutSecret(run, opts) {
3965
3934
  const args = [
3966
3935
  "wrangler",
@@ -3972,6 +3941,16 @@ function wranglerPutSecret(run, opts) {
3972
3941
  ];
3973
3942
  return run("npx", args, { input: opts.value, cwd: opts.cwd });
3974
3943
  }
3944
+ function wranglerBulkSecrets(run, opts) {
3945
+ const args = [
3946
+ "wrangler",
3947
+ "secret",
3948
+ "bulk",
3949
+ ...opts.env ? ["--env", opts.env] : [],
3950
+ ...opts.configPath ? ["--config", opts.configPath] : []
3951
+ ];
3952
+ return run("npx", args, { input: JSON.stringify(opts.secrets), cwd: opts.cwd });
3953
+ }
3975
3954
 
3976
3955
  // src/doctor-checks.ts
3977
3956
  function lintRules(rules, entities, publicRead) {
@@ -4460,9 +4439,6 @@ var PROD_ENV_NAMES = /* @__PURE__ */ new Set(["prod", "production"]);
4460
4439
  async function secretsPush(options) {
4461
4440
  await secretsPushImpl(options, true);
4462
4441
  }
4463
- async function secretsPushAfterPreflight(options) {
4464
- await secretsPushImpl(options, false);
4465
- }
4466
4442
  async function secretsPushImpl(options, preflight) {
4467
4443
  const out = options.stdout ?? console;
4468
4444
  const cfg = await loadProjectConfig(options.configPath);
@@ -4869,28 +4845,31 @@ async function smoke(options) {
4869
4845
  const out = options.stdout ?? console;
4870
4846
  const cfg = await loadProjectConfig(options.configPath);
4871
4847
  const env = resolveEnv(cfg, options.env);
4872
- const credentials = readCredentials(cfg.local.credentialsFile);
4873
- if (!credentials) {
4848
+ const runtime = options.runtime === true;
4849
+ const credentials = runtime ? null : readCredentials(cfg.local.credentialsFile);
4850
+ if (!runtime && !credentials) {
4874
4851
  throw new Error(`local credentials missing: ${displayPath(cfg.local.credentialsFile, cfg.rootDir)}. Run "odla-ai provision --write-dev-vars".`);
4875
4852
  }
4876
- if (credentials.appId !== cfg.app.id) {
4853
+ if (credentials && credentials.appId !== cfg.app.id) {
4877
4854
  throw new Error(`local credentials are for app "${credentials.appId}", but config app is "${cfg.app.id}"`);
4878
4855
  }
4879
- const entry = credentials.envs[env];
4880
- if (!entry?.tenantId) {
4856
+ const entry = credentials?.envs[env];
4857
+ if (!runtime && !entry?.tenantId) {
4881
4858
  throw new Error(`local credentials have no tenant for env "${env}". Run "odla-ai provision --write-dev-vars".`);
4882
4859
  }
4883
4860
  const hasDb = cfg.services.includes("db");
4884
4861
  const hasO11y = cfg.services.includes("o11y");
4885
- if (hasDb && !entry.dbKey) {
4862
+ if (!runtime && hasDb && !entry?.dbKey) {
4886
4863
  throw new Error(`local credentials have no db key for env "${env}". Run "odla-ai provision --write-dev-vars".`);
4887
4864
  }
4888
- if (hasO11y && !entry.o11yToken) {
4865
+ if (!runtime && hasO11y && !entry?.o11yToken) {
4889
4866
  throw new Error(`local credentials have no o11y token for env "${env}". Run "odla-ai provision --write-dev-vars".`);
4890
4867
  }
4891
4868
  const doFetch = options.fetch ?? fetch;
4869
+ const tenantId = entry?.tenantId ?? resolveTenant(cfg, env).tenant;
4892
4870
  out.log(`smoke: ${cfg.app.id}/${env}`);
4893
- out.log(` tenant: ${entry.tenantId}`);
4871
+ out.log(` mode: ${runtime ? "runtime (Worker-held credentials)" : "local credentials"}`);
4872
+ out.log(` tenant: ${tenantId}`);
4894
4873
  const publicConfig = await getJson(doFetch, publicConfigUrl(cfg.platformUrl, cfg.app.id, env), void 0);
4895
4874
  out.log(` public-config: ok`);
4896
4875
  if (cfg.services.includes("ai") && cfg.ai?.provider) {
@@ -4904,7 +4883,7 @@ async function smoke(options) {
4904
4883
  if (mode !== "hosted") throw new Error(`ai mode mismatch: expected "hosted", public-config has "${String(mode ?? "none")}"`);
4905
4884
  out.log(" ai: hosted");
4906
4885
  }
4907
- if (hasO11y) out.log(` o11y: credentials present`);
4886
+ if (hasO11y) out.log(runtime ? ` o11y: Worker-held credential` : ` o11y: credentials present`);
4908
4887
  if (cfg.services.includes("calendar")) {
4909
4888
  const token = await getDeveloperToken(
4910
4889
  cfg,
@@ -4923,10 +4902,10 @@ async function smoke(options) {
4923
4902
  out.log(` calendar: ${status.status}, bookable (booking \u2192 ${status.bookingCalendarId ?? "primary"})`);
4924
4903
  }
4925
4904
  const database = await resolveDatabaseConfig(cfg);
4926
- if (hasDb) {
4905
+ if (hasDb && !runtime) {
4927
4906
  const expectedSchema = database.schema;
4928
4907
  const expectedEntities = serializedEntities(expectedSchema);
4929
- const liveSchemaPayload = await getJson(doFetch, `${cfg.dbEndpoint}/app/${encodeURIComponent(entry.tenantId)}/schema`, entry.dbKey);
4908
+ const liveSchemaPayload = await getJson(doFetch, `${cfg.dbEndpoint}/app/${encodeURIComponent(tenantId)}/schema`, entry.dbKey);
4930
4909
  const liveSchema = liveSchemaPayload.schema ?? liveSchemaPayload;
4931
4910
  const liveEntities = serializedEntities(liveSchema);
4932
4911
  if (expectedEntities.length) {
@@ -4936,7 +4915,7 @@ async function smoke(options) {
4936
4915
  out.log(` schema: ${liveEntities.length} entities`);
4937
4916
  const aggregateEntity = expectedEntities[0] ?? liveEntities[0];
4938
4917
  if (aggregateEntity) {
4939
- const aggregate = await postJson2(doFetch, `${cfg.dbEndpoint}/app/${encodeURIComponent(entry.tenantId)}/aggregate`, entry.dbKey, {
4918
+ const aggregate = await postJson2(doFetch, `${cfg.dbEndpoint}/app/${encodeURIComponent(tenantId)}/aggregate`, entry.dbKey, {
4940
4919
  ns: aggregateEntity,
4941
4920
  aggregate: { count: true }
4942
4921
  });
@@ -4945,9 +4924,20 @@ async function smoke(options) {
4945
4924
  } else {
4946
4925
  out.log(` aggregate: skipped (schema has no entities)`);
4947
4926
  }
4927
+ } else if (hasDb) {
4928
+ out.log(` db: Worker-held credential (direct schema/aggregate skipped)`);
4948
4929
  } else {
4949
4930
  out.log(` db: skipped (not enabled)`);
4950
4931
  }
4932
+ if (runtime) {
4933
+ const link = cfg.links?.[env];
4934
+ if (!link) throw new Error(`runtime smoke requires links.${env} in odla.config.mjs`);
4935
+ const res = await doFetch(link, { redirect: "manual" });
4936
+ if (res.status < 200 || res.status >= 500) {
4937
+ throw new Error(`runtime target ${new URL(link).origin} returned ${res.status}`);
4938
+ }
4939
+ out.log(` runtime target: ${res.status}`);
4940
+ }
4951
4941
  const probes = database.integrations.flatMap(
4952
4942
  (integration) => (integration.probes ?? []).map((probe) => ({ integration: integration.id, probe }))
4953
4943
  );
@@ -5150,10 +5140,11 @@ async function projectCommand(command, parsed, deps) {
5150
5140
  return true;
5151
5141
  }
5152
5142
  if (command === "smoke") {
5153
- assertArgs(parsed, ["config", "env", "token", "email", "open"], 1);
5143
+ assertArgs(parsed, ["config", "env", "runtime", "token", "email", "open"], 1);
5154
5144
  await smoke({
5155
5145
  configPath: stringOpt(parsed.options.config) ?? "odla.config.mjs",
5156
5146
  env: stringOpt(parsed.options.env),
5147
+ runtime: parsed.options.runtime === true,
5157
5148
  token: stringOpt(parsed.options.token),
5158
5149
  email: stringOpt(parsed.options.email),
5159
5150
  open: parsed.options.open === false ? false : parsed.options.open === true ? true : void 0,
@@ -8827,6 +8818,55 @@ function requireName(parsed) {
8827
8818
  return name;
8828
8819
  }
8829
8820
 
8821
+ // src/credential-command.ts
8822
+ async function responseError(response2) {
8823
+ return redactSecrets((await response2.text()).slice(0, 1e3));
8824
+ }
8825
+ async function credentialCommand(parsed, deps = {}) {
8826
+ const action2 = parsed.positionals[1] ?? "list";
8827
+ if (action2 !== "list" && action2 !== "revoke") {
8828
+ throw new Error(`unknown credentials action "${action2}". Try "odla-ai credentials list".`);
8829
+ }
8830
+ assertArgs(parsed, ["config", "env", "all", "json", "token", "email", "open"], action2 === "revoke" ? 3 : 2);
8831
+ const cfg = await loadProjectConfig(stringOpt(parsed.options.config) ?? "odla.config.mjs");
8832
+ const doFetch = deps.fetch ?? fetch;
8833
+ const out = deps.stdout ?? console;
8834
+ const token = await getDeveloperToken(cfg, {
8835
+ configPath: cfg.configPath,
8836
+ token: stringOpt(parsed.options.token),
8837
+ email: stringOpt(parsed.options.email),
8838
+ open: parsed.options.open === false ? false : parsed.options.open === true ? true : void 0,
8839
+ openApprovalUrl: deps.openUrl
8840
+ }, doFetch, out, { optionalProjectCapabilities: ["app.manage"] });
8841
+ const base = `${cfg.platformUrl}/registry/apps/${encodeURIComponent(cfg.app.id)}/runtime-credentials`;
8842
+ if (action2 === "revoke") {
8843
+ const id = parsed.positionals[2];
8844
+ if (!id) throw new Error("credentials revoke requires the exact receipt id from credentials list");
8845
+ const response3 = await doFetch(`${base}/${encodeURIComponent(id)}`, {
8846
+ method: "DELETE",
8847
+ headers: { authorization: `Bearer ${token}` }
8848
+ });
8849
+ if (!response3.ok) throw new Error(`runtime credential revoke failed (${response3.status}): ${await responseError(response3)}`);
8850
+ const body2 = await response3.json();
8851
+ return out.log(parsed.options.json === true ? JSON.stringify(body2, null, 2) : `revoked ${body2.receipt.id} (${body2.receipt.env} ${body2.receipt.target.scriptName})`);
8852
+ }
8853
+ const query = new URLSearchParams();
8854
+ const requestedEnv = stringOpt(parsed.options.env);
8855
+ if (requestedEnv) query.set("env", requestedEnv);
8856
+ const response2 = await doFetch(`${base}${query.size ? `?${query}` : ""}`, {
8857
+ headers: { authorization: `Bearer ${token}` }
8858
+ });
8859
+ if (!response2.ok) throw new Error(`runtime credential inventory failed (${response2.status}): ${await responseError(response2)}`);
8860
+ const body = await response2.json();
8861
+ const credentials = parsed.options.all === true ? body.credentials : body.credentials.filter((item) => item.state === "committed");
8862
+ if (parsed.options.json === true) return out.log(JSON.stringify({ credentials }, null, 2));
8863
+ if (!credentials.length) return out.log("no active runtime credential receipts");
8864
+ out.log("RECEIPT ENV TARGET CREATED");
8865
+ for (const item of credentials) {
8866
+ out.log(`${item.id} ${item.env} ${item.target.accountId ?? "local"}/${item.target.scriptName} ${new Date(item.createdAt).toISOString()}`);
8867
+ }
8868
+ }
8869
+
8830
8870
  // src/help-usage.ts
8831
8871
  var USAGE_SECTION = `
8832
8872
  Start here:
@@ -8945,7 +8985,9 @@ Usage:
8945
8985
  odla-ai security run [target] --ack-redacted-source [--env dev] [--profile odla] [--fail-on high]
8946
8986
  odla-ai security run [target] --self --ack-redacted-source
8947
8987
  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]
8948
- odla-ai smoke [--config odla.config.mjs] [--env dev] [--email <odla-account>] [--no-open]
8988
+ odla-ai credentials list [--config odla.config.mjs] [--env dev] [--all] [--json]
8989
+ odla-ai credentials revoke <receipt-id> [--config odla.config.mjs] [--json]
8990
+ odla-ai smoke [--config odla.config.mjs] [--env dev] [--runtime] [--email <odla-account>] [--no-open]
8949
8991
  odla-ai skill install [--dir <project>] [--agent <name>] [--global] [--force]
8950
8992
  odla-ai secrets push --env <env> [--config odla.config.mjs] [--dry-run] [--yes]
8951
8993
  odla-ai secrets set <name> --env <env> (--from-env <NAME>|--stdin) [--email <odla-account>] [--config odla.config.mjs] [--yes]
@@ -9047,7 +9089,7 @@ Commands:
9047
9089
  "provision --live --yes" initializes only the live instance of
9048
9090
  an existing sandbox app and enables every configured service;
9049
9091
  no edit to the dev-first envs list is required.
9050
- smoke Verify credentials, public-config, composed schema, db aggregate, and integration probes.
9092
+ smoke Verify local service access, or use --runtime for a Worker-held credential deployment.
9051
9093
  skill Same installer; --agent accepts all, claude, codex, cursor,
9052
9094
  copilot, gemini, or agents (repeatable or comma-separated).
9053
9095
  secrets Push configured db/o11y secrets into the Worker via wrangler
@@ -9067,9 +9109,13 @@ Safety:
9067
9109
  pinned versions of every external @odla-ai runtime module before importing
9068
9110
  the command graph. A stale workspace module blocks with its resolved path and
9069
9111
  tells the agent to update/rebase, npm ci, and rebuild.
9070
- Provision caches the approved developer token and service credentials under
9071
- .odla/ with mode 0600, and init adds those paths to .gitignore. Secret push
9072
- preflights Wrangler before any shown-once issuance or destructive rotation.
9112
+ Provision caches the approved developer token under .odla/ with mode 0600.
9113
+ Local-only development provisioning may also cache that developer's service
9114
+ credentials there. \`provision --push-secrets\` instead stages a fresh,
9115
+ independently revocable credential set through the approved handshake,
9116
+ transfers the complete set to the exact Worker with \`wrangler secret bulk\`
9117
+ over stdin, and never writes its plaintext under .odla. Secret push preflights
9118
+ Wrangler before shown-once issuance; it never rotates sibling credentials.
9073
9119
  Projectless PM, Discussions, o11y, runbook, and identity commands use
9074
9120
  --platform/--app/--env, ODLA_PLATFORM_URL/ODLA_APP_ID/ODLA_ENV, and
9075
9121
  ODLA_DEV_TOKEN. Save non-secret scope metadata with "context save", then
@@ -9083,12 +9129,11 @@ Safety:
9083
9129
  attempts to open it \u2014 including in CI, SSH, display-less, and agent-driven
9084
9130
  shells. Only --no-open suppresses the attempt. Browser launch is best-effort:
9085
9131
  agents with browser control must open that exact URL immediately; otherwise
9086
- they must give it to the human verbatim. A started handshake is persisted
9087
- under .odla/, so a command killed mid-wait loses nothing \u2014 rerunning resumes
9088
- the same code. Outside an interactive terminal the wait is capped (90s by
9132
+ they must give it to the human verbatim. A device code remains only in the
9133
+ running process. Outside an interactive terminal the wait is capped (90s by
9089
9134
  default, --wait <seconds> to change); a still-pending handshake then exits
9090
- with code 75: open the same URL (or relay it if browser control is unavailable),
9091
- wait for approval, and re-run to collect.
9135
+ with code 75. Rerunning always requests and opens a new code; older clients'
9136
+ persisted pending state is discarded.
9092
9137
  A fresh device handshake requires --email <odla-account> or ODLA_USER_EMAIL.
9093
9138
  The email is a non-secret identity hint: never provide a password or session
9094
9139
  token. It is the email shown by the signed-in odla account \u2014 never infer it
@@ -10993,7 +11038,7 @@ async function issueO11yToken(opts) {
10993
11038
  );
10994
11039
  if (res.status === 409 && !opts.rotateO11y) {
10995
11040
  throw new Error(
10996
- `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`
11041
+ `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`
10997
11042
  );
10998
11043
  }
10999
11044
  if (!res.ok) throw new Error(`o11y token ${opts.rotateO11y ? "rotation" : "issue"} (${opts.env}) failed: ${res.status} ${await safeText7(res)}`);
@@ -11009,6 +11054,88 @@ async function safeText7(res) {
11009
11054
  }
11010
11055
  }
11011
11056
 
11057
+ // src/runtime-credentials.ts
11058
+ var import_node_crypto5 = require("crypto");
11059
+ function runtimeUrl(cfg, suffix = "") {
11060
+ return `${cfg.platformUrl}/registry/apps/${encodeURIComponent(cfg.app.id)}/runtime-credentials${suffix}`;
11061
+ }
11062
+ async function safeError(response2) {
11063
+ const text2 = await response2.text();
11064
+ return redactSecrets(text2.slice(0, 1e3));
11065
+ }
11066
+ async function finish(doFetch, cfg, token, sessionId, method) {
11067
+ return doFetch(runtimeUrl(cfg, `/${encodeURIComponent(sessionId)}`), {
11068
+ method,
11069
+ headers: { authorization: `Bearer ${token}` }
11070
+ });
11071
+ }
11072
+ async function deliverRuntimeCredentials(cfg, options) {
11073
+ const doFetch = options.fetch ?? fetch;
11074
+ const run = options.runner ?? defaultRunner;
11075
+ const wranglerEnv = options.env === "prod" || options.env === "production" ? void 0 : options.env;
11076
+ const target = await wranglerRuntimeTarget(run, {
11077
+ cwd: cfg.rootDir,
11078
+ env: wranglerEnv
11079
+ });
11080
+ const issue = await doFetch(runtimeUrl(cfg), {
11081
+ method: "POST",
11082
+ headers: {
11083
+ authorization: `Bearer ${options.developerToken}`,
11084
+ "content-type": "application/json"
11085
+ },
11086
+ body: JSON.stringify({
11087
+ env: options.env,
11088
+ idempotencyKey: `wrangler:${(0, import_node_crypto5.randomUUID)()}`,
11089
+ target
11090
+ })
11091
+ });
11092
+ const body = await issue.json().catch(() => null);
11093
+ if (!issue.ok || typeof body?.sessionId !== "string" || !body.secrets || typeof body.secrets !== "object") {
11094
+ const detail = body?.error?.code ?? body?.error?.message ?? issue.status;
11095
+ throw new Error(`runtime credential staging failed: ${String(detail)}`);
11096
+ }
11097
+ const secrets = body.secrets;
11098
+ const values = {};
11099
+ for (const name of ["ODLA_API_KEY", "ODLA_O11Y_TOKEN"]) {
11100
+ const value2 = secrets[name];
11101
+ if (typeof value2 === "string" && value2) values[name] = value2;
11102
+ }
11103
+ if (Object.keys(values).length === 0) {
11104
+ await finish(doFetch, cfg, options.developerToken, body.sessionId, "DELETE");
11105
+ throw new Error("runtime credential staging returned no configured service credentials");
11106
+ }
11107
+ const pushed = await wranglerBulkSecrets(run, {
11108
+ secrets: values,
11109
+ env: wranglerEnv,
11110
+ cwd: cfg.rootDir
11111
+ });
11112
+ if (pushed.code !== 0) {
11113
+ await finish(doFetch, cfg, options.developerToken, body.sessionId, "DELETE");
11114
+ const detail = redactSecrets(`${pushed.stderr || pushed.stdout}`.trim());
11115
+ throw new Error(`wrangler secret bulk failed (exit ${pushed.code}): ${detail}`);
11116
+ }
11117
+ const committed = await finish(
11118
+ doFetch,
11119
+ cfg,
11120
+ options.developerToken,
11121
+ body.sessionId,
11122
+ "POST"
11123
+ );
11124
+ if (!committed.ok) {
11125
+ await finish(doFetch, cfg, options.developerToken, body.sessionId, "DELETE");
11126
+ throw new Error(`runtime credential receipt commit failed: ${await safeError(committed)}`);
11127
+ }
11128
+ options.stdout?.log(
11129
+ `${options.env}: installed ${Object.keys(values).join(" + ")} on ${target.accountId}/${target.scriptName}${target.environment ? ` (${target.environment})` : ""}; plaintext stayed in memory`
11130
+ );
11131
+ return {
11132
+ sessionId: body.sessionId,
11133
+ target,
11134
+ ...values.ODLA_API_KEY ? { dbKey: values.ODLA_API_KEY } : {},
11135
+ ...values.ODLA_O11Y_TOKEN ? { o11yToken: values.ODLA_O11Y_TOKEN } : {}
11136
+ };
11137
+ }
11138
+
11012
11139
  // src/provision-live.ts
11013
11140
  function liveProvisionConfig(cfg) {
11014
11141
  if (!cfg.envs.includes("dev")) {
@@ -11042,8 +11169,10 @@ async function provision(options) {
11042
11169
  if (options.rotateO11yToken && !hasO11y) {
11043
11170
  throw new Error("--rotate-o11y-token requires the o11y service in odla.config.mjs");
11044
11171
  }
11045
- if (options.pushSecrets && options.writeCredentials === false) {
11046
- throw new Error("--push-secrets cannot be combined with --no-write-credentials");
11172
+ if (options.pushSecrets && (options.rotateKeys || options.rotateO11yToken)) {
11173
+ throw new Error(
11174
+ "--push-secrets always issues an independent runtime credential set; do not combine it with destructive rotation flags"
11175
+ );
11047
11176
  }
11048
11177
  out.log(`odla-ai: ${plan.appName} (${plan.appId})`);
11049
11178
  out.log(` platform: ${plan.platformUrl}`);
@@ -11080,7 +11209,7 @@ async function provision(options) {
11080
11209
  out.log(` secrets: ${options.pushSecrets ? "push configured Worker secrets" : "local only"}`);
11081
11210
  return;
11082
11211
  }
11083
- let credentials = readCredentials(cfg.local.credentialsFile);
11212
+ let credentials = options.pushSecrets ? null : readCredentials(cfg.local.credentialsFile);
11084
11213
  if (credentials && credentials.appId !== cfg.app.id) {
11085
11214
  throw new Error(
11086
11215
  `credentials at ${displayPath(cfg.local.credentialsFile, cfg.rootDir)} are for "${credentials.appId}", not "${cfg.app.id}"`
@@ -11089,7 +11218,7 @@ async function provision(options) {
11089
11218
  const rotatesO11y = !!(options.rotateKeys || options.rotateO11yToken);
11090
11219
  const missingO11y = cfg.envs.some((env) => !credentials?.envs[env]?.o11yToken);
11091
11220
  const missingDb = cfg.envs.some((env) => !credentials?.envs[env]?.dbKey);
11092
- const losesShownOnceCredential = hasDb && (!!options.rotateKeys || missingDb) || hasO11y && (rotatesO11y || missingO11y);
11221
+ const losesShownOnceCredential = !options.pushSecrets && (hasDb && (!!options.rotateKeys || missingDb) || hasO11y && (rotatesO11y || missingO11y));
11093
11222
  if (options.writeCredentials === false && losesShownOnceCredential) {
11094
11223
  throw new Error("credential issuance/rotation requires the private credentials file; remove --no-write-credentials");
11095
11224
  }
@@ -11163,38 +11292,42 @@ async function provision(options) {
11163
11292
  await applyCalendarBookingPage(calendarCtx, calendarBookingPageUrl(cfg, env), out);
11164
11293
  }
11165
11294
  }
11295
+ let devVarsCredentials = credentials;
11166
11296
  for (const env of cfg.envs) {
11167
11297
  const tenantId = (0, import_apps12.tenantIdFor)(cfg.app.id, env);
11168
- credentials = await provisionEnvCredentials({
11169
- cfg,
11170
- env,
11171
- developerToken: token,
11172
- credentials,
11173
- rotateDb: !!options.rotateKeys,
11174
- rotateO11y: rotatesO11y,
11175
- write: options.writeCredentials !== false,
11176
- fetch: doFetch,
11177
- stdout: out
11178
- });
11179
- const dbKey = credentials.envs[env]?.dbKey;
11298
+ let dbKey;
11180
11299
  if (options.pushSecrets) {
11181
- try {
11182
- await secretsPushAfterPreflight({
11183
- configPath: cfg.configPath,
11184
- env,
11185
- yes: options.yes,
11186
- runner: options.secretRunner,
11187
- stdout: out
11188
- });
11189
- } catch (error) {
11190
- const message2 = error instanceof Error ? error.message : String(error);
11191
- const consent = env === "prod" || env === "production" ? " --yes" : "";
11192
- throw new Error(
11193
- `${message2}
11194
- ${env}: credentials are already saved; retry "odla-ai secrets push --env ${env}${consent}" without issuing or rotating again`,
11195
- { cause: error }
11196
- );
11197
- }
11300
+ const delivered = await deliverRuntimeCredentials(cfg, {
11301
+ env,
11302
+ developerToken: token,
11303
+ fetch: doFetch,
11304
+ runner: options.secretRunner,
11305
+ stdout: out
11306
+ });
11307
+ dbKey = delivered.dbKey;
11308
+ devVarsCredentials = mergeCredential(devVarsCredentials, {
11309
+ appId: cfg.app.id,
11310
+ platformUrl: cfg.platformUrl,
11311
+ dbEndpoint: cfg.dbEndpoint,
11312
+ env,
11313
+ tenantId,
11314
+ ...delivered.dbKey ? { dbKey: delivered.dbKey } : {},
11315
+ ...delivered.o11yToken ? { o11yToken: delivered.o11yToken } : {}
11316
+ });
11317
+ } else {
11318
+ credentials = await provisionEnvCredentials({
11319
+ cfg,
11320
+ env,
11321
+ developerToken: token,
11322
+ credentials,
11323
+ rotateDb: !!options.rotateKeys,
11324
+ rotateO11y: rotatesO11y,
11325
+ write: options.writeCredentials !== false,
11326
+ fetch: doFetch,
11327
+ stdout: out
11328
+ });
11329
+ devVarsCredentials = credentials;
11330
+ dbKey = credentials.envs[env]?.dbKey;
11198
11331
  }
11199
11332
  if (schema && dbKey) {
11200
11333
  await postJson(doFetch, `${cfg.dbEndpoint}/app/${encodeURIComponent(tenantId)}/schema`, dbKey, { schema });
@@ -11218,13 +11351,13 @@ ${env}: credentials are already saved; retry "odla-ai secrets push --env ${env}$
11218
11351
  }
11219
11352
  }
11220
11353
  }
11221
- if (options.writeCredentials !== false && credentials) {
11354
+ if (!options.pushSecrets && options.writeCredentials !== false && credentials) {
11222
11355
  const ignored = cfg.local.gitignore && gitignoreEntry(cfg.rootDir, cfg.local.credentialsFile);
11223
11356
  out.log(`credentials: wrote ${displayPath(cfg.local.credentialsFile, cfg.rootDir)} (0600${ignored ? ", gitignored" : ""})`);
11224
11357
  }
11225
- if (devVarsTarget && credentials) {
11358
+ if (devVarsTarget && devVarsCredentials) {
11226
11359
  const env = cfg.envs.includes("dev") ? "dev" : cfg.envs[0] ?? "prod";
11227
- writeDevVars(devVarsTarget, credentials, env, o11yDevVars(cfg));
11360
+ writeDevVars(devVarsTarget, devVarsCredentials, env, o11yDevVars(cfg));
11228
11361
  out.log(`dev vars: wrote ${displayPath(devVarsTarget, cfg.rootDir)} for ${env}`);
11229
11362
  }
11230
11363
  if (cfg.services.includes("calendar")) {
@@ -11315,6 +11448,7 @@ var COMMAND_SURFACE = {
11315
11448
  code: { connect: {} },
11316
11449
  config: { diff: {}, plan: {}, apply: {} },
11317
11450
  context: { show: {}, list: {}, save: {}, remove: {} },
11451
+ credentials: { list: {}, revoke: {} },
11318
11452
  // `watch`, `read`, `reply`, and `resolve` take a topic id from there on.
11319
11453
  discuss: {
11320
11454
  groups: {},
@@ -13052,6 +13186,10 @@ async function runCli(argv = process.argv.slice(2), dependencies = {}) {
13052
13186
  await contextCommand(parsed, runtime);
13053
13187
  return;
13054
13188
  }
13189
+ if (command === "credentials") {
13190
+ await credentialCommand(parsed, runtime);
13191
+ return;
13192
+ }
13055
13193
  if (command === "runbook") {
13056
13194
  await runbookCommand(parsed, runtime);
13057
13195
  return;