@odla-ai/cli 0.30.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/README.md CHANGED
@@ -511,12 +511,12 @@ credential set.
511
511
  and attempts to open it — including from CI, SSH, display-less, scripted,
512
512
  and agent-driven shells. Only `--no-open` suppresses the attempt. Browser
513
513
  launch is best-effort: an agent with browser control must open that exact URL
514
- immediately; otherwise it gives the URL to the human verbatim. The started
515
- handshake is persisted privately under `.odla/`, so a
516
- run killed before approval loses nothing: rerunning resumes the same code.
517
- Outside an interactive terminal the approval wait is capped (90 seconds by
518
- default; `--wait <seconds>` overrides), and a still-pending handshake exits
519
- with code 75 — approve at the URL, then re-run to collect the token.
514
+ immediately; otherwise it gives the URL to the human verbatim. The device
515
+ code stays only in the running process. Outside an interactive terminal the
516
+ approval wait is capped (90 seconds by default; `--wait <seconds>`
517
+ overrides), and a still-pending handshake exits with code 75. Rerunning
518
+ always requests and opens a new code; it never resumes an older request from
519
+ `.odla/`.
520
520
  2. Creates the platform app if needed. For a new id, this consumes the exact-id
521
521
  reservation approved in step 1; it is not ambient project-creation authority.
522
522
  3. Enables configured services in every configured environment. Calendar
@@ -569,6 +569,19 @@ The inventory contains only receipt, target, state, and timestamp metadata.
569
569
  Revocation uses the receipt's exact DB and o11y credential ids; no value is
570
570
  retrieved or rotated.
571
571
 
572
+ Verify a deployment that intentionally keeps db/o11y credentials only on the
573
+ Worker:
574
+
575
+ ```sh
576
+ npx @odla-ai/cli smoke --env dev --runtime
577
+ ```
578
+
579
+ Runtime smoke never reads `.odla/credentials.local.json`. It checks Registry
580
+ public config, the configured Worker link, anonymous integration probes, and
581
+ owner-visible calendar health. Direct schema and aggregate reads remain part
582
+ of ordinary local-credential smoke because a runtime credential is deliberately
583
+ not retrievable from the Worker.
584
+
572
585
  ### Application capability integrations
573
586
 
574
587
  An integration is an npm capability composed into an app, not a hosted
package/dist/bin.cjs CHANGED
@@ -305,10 +305,51 @@ var init_handshake_approval = __esm({
305
305
  }
306
306
  });
307
307
 
308
+ // src/handshake-state.ts
309
+ function handshakeFile(cfg) {
310
+ return (0, import_node_path2.join)((0, import_node_path2.dirname)(cfg.local.tokenFile), "handshake.local.json");
311
+ }
312
+ function clearPendingHandshake(path) {
313
+ (0, import_node_fs4.rmSync)(path, { force: true });
314
+ }
315
+ function minutesLeft(expiresAt) {
316
+ return Math.max(1, Math.round((expiresAt - Date.now()) / 6e4));
317
+ }
318
+ function approvalHint(pending) {
319
+ return `approve code ${pending.userCode} at ${pending.approvalUrl} (${minutesLeft(pending.expiresAt)}m left)`;
320
+ }
321
+ function approvalReminder(out, pending, periodMs = 3e4) {
322
+ const timer = setInterval(() => {
323
+ for (const line of reminderLines({
324
+ userCode: pending.userCode,
325
+ approvalUrl: pending.approvalUrl,
326
+ minutesLeft: minutesLeft(pending.expiresAt)
327
+ }))
328
+ out.log(line);
329
+ }, periodMs);
330
+ timer.unref?.();
331
+ return () => clearInterval(timer);
332
+ }
333
+ function handshakeWaitMs(waitSeconds, interactive = import_node_process3.default.stdout.isTTY === true) {
334
+ if (waitSeconds !== void 0) return waitSeconds * 1e3;
335
+ return interactive ? void 0 : 9e4;
336
+ }
337
+ var import_node_fs4, import_node_path2, import_node_process3;
338
+ var init_handshake_state = __esm({
339
+ "src/handshake-state.ts"() {
340
+ "use strict";
341
+ init_cjs_shims();
342
+ import_node_fs4 = require("fs");
343
+ import_node_path2 = require("path");
344
+ import_node_process3 = __toESM(require("process"), 1);
345
+ init_approval_prompt();
346
+ }
347
+ });
348
+
308
349
  // src/local.ts
309
350
  function readJsonFile(path) {
310
351
  try {
311
- return JSON.parse((0, import_node_fs4.readFileSync)(path, "utf8"));
352
+ return JSON.parse((0, import_node_fs5.readFileSync)(path, "utf8"));
312
353
  } catch {
313
354
  return null;
314
355
  }
@@ -318,10 +359,10 @@ function writePrivateJson(path, value2) {
318
359
  `);
319
360
  }
320
361
  function readCredentials(path) {
321
- if (!(0, import_node_fs4.existsSync)(path)) return null;
362
+ if (!(0, import_node_fs5.existsSync)(path)) return null;
322
363
  let value2;
323
364
  try {
324
- value2 = JSON.parse((0, import_node_fs4.readFileSync)(path, "utf8"));
365
+ value2 = JSON.parse((0, import_node_fs5.readFileSync)(path, "utf8"));
325
366
  } catch {
326
367
  throw new Error(`credentials file ${path} is not valid JSON; fix or remove it before provisioning`);
327
368
  }
@@ -351,14 +392,14 @@ function mergeCredential(current, update) {
351
392
  return next;
352
393
  }
353
394
  function ensureGitignore(rootDir, localPaths = []) {
354
- const path = (0, import_node_path2.resolve)(rootDir, ".gitignore");
355
- const existing = (0, import_node_fs4.existsSync)(path) ? (0, import_node_fs4.readFileSync)(path, "utf8") : "";
395
+ const path = (0, import_node_path3.resolve)(rootDir, ".gitignore");
396
+ const existing = (0, import_node_fs5.existsSync)(path) ? (0, import_node_fs5.readFileSync)(path, "utf8") : "";
356
397
  const configured = localPaths.map((localPath) => gitignoreEntry(rootDir, localPath)).filter((line) => !!line);
357
398
  const wanted = [.../* @__PURE__ */ new Set([...GITIGNORE_LINES, ...configured])];
358
399
  const missing = wanted.filter((line) => !existing.split(/\r?\n/).includes(line));
359
400
  if (missing.length === 0) return;
360
401
  const prefix = existing && !existing.endsWith("\n") ? "\n" : "";
361
- (0, import_node_fs4.writeFileSync)(path, `${existing}${prefix}${missing.join("\n")}
402
+ (0, import_node_fs5.writeFileSync)(path, `${existing}${prefix}${missing.join("\n")}
362
403
  `);
363
404
  }
364
405
  function o11yDevVars(cfg) {
@@ -372,7 +413,7 @@ function o11yDevVars(cfg) {
372
413
  function resolveWriteDevVarsTarget(cfg, requested) {
373
414
  if (!requested) return null;
374
415
  if (requested === true) return cfg.local.devVarsFile;
375
- return (0, import_node_path2.resolve)((0, import_node_path2.dirname)(cfg.configPath), requested);
416
+ return (0, import_node_path3.resolve)((0, import_node_path3.dirname)(cfg.configPath), requested);
376
417
  }
377
418
  function writeDevVars(path, credentials, env, o11y) {
378
419
  const entry = credentials.envs[env];
@@ -386,7 +427,7 @@ function writeDevVars(path, credentials, env, o11y) {
386
427
  if (o11y.version) lines.push(`ODLA_O11Y_VERSION="${o11y.version}"`);
387
428
  if (entry.o11yToken) lines.push(`ODLA_O11Y_TOKEN="${entry.o11yToken}"`);
388
429
  }
389
- const existing = (0, import_node_fs4.existsSync)(path) ? (0, import_node_fs4.readFileSync)(path, "utf8") : "";
430
+ const existing = (0, import_node_fs5.existsSync)(path) ? (0, import_node_fs5.readFileSync)(path, "utf8") : "";
390
431
  const retained = existing.split(/\r?\n/).filter((line) => !isManagedDevVar(line));
391
432
  while (retained.at(-1) === "") retained.pop();
392
433
  const prefix = retained.length ? `${retained.join("\n")}
@@ -400,28 +441,28 @@ function isManagedDevVar(line) {
400
441
  return !!match?.[1] && MANAGED_DEV_VARS.has(match[1]);
401
442
  }
402
443
  function writePrivateText(path, text2) {
403
- (0, import_node_fs4.mkdirSync)((0, import_node_path2.dirname)(path), { recursive: true });
444
+ (0, import_node_fs5.mkdirSync)((0, import_node_path3.dirname)(path), { recursive: true });
404
445
  const temporary = `${path}.tmp-${process.pid}-${Date.now()}`;
405
- (0, import_node_fs4.writeFileSync)(temporary, text2, { mode: 384 });
406
- (0, import_node_fs4.chmodSync)(temporary, 384);
407
- (0, import_node_fs4.renameSync)(temporary, path);
446
+ (0, import_node_fs5.writeFileSync)(temporary, text2, { mode: 384 });
447
+ (0, import_node_fs5.chmodSync)(temporary, 384);
448
+ (0, import_node_fs5.renameSync)(temporary, path);
408
449
  }
409
450
  function gitignoreEntry(rootDir, path) {
410
- const rel = (0, import_node_path2.relative)((0, import_node_path2.resolve)(rootDir), (0, import_node_path2.resolve)(path));
411
- if (!rel || rel === ".." || rel.startsWith(`..${process.platform === "win32" ? "\\" : "/"}`) || (0, import_node_path2.isAbsolute)(rel)) return null;
451
+ const rel = (0, import_node_path3.relative)((0, import_node_path3.resolve)(rootDir), (0, import_node_path3.resolve)(path));
452
+ if (!rel || rel === ".." || rel.startsWith(`..${process.platform === "win32" ? "\\" : "/"}`) || (0, import_node_path3.isAbsolute)(rel)) return null;
412
453
  return rel.replaceAll("\\", "/");
413
454
  }
414
455
  function displayPath(path, rootDir = process.cwd()) {
415
- const rel = (0, import_node_path2.relative)(rootDir, path);
456
+ const rel = (0, import_node_path3.relative)(rootDir, path);
416
457
  return rel && !rel.startsWith("..") ? rel : path;
417
458
  }
418
- var import_node_fs4, import_node_path2, GITIGNORE_LINES, MANAGED_DEV_VARS;
459
+ var import_node_fs5, import_node_path3, GITIGNORE_LINES, MANAGED_DEV_VARS;
419
460
  var init_local = __esm({
420
461
  "src/local.ts"() {
421
462
  "use strict";
422
463
  init_cjs_shims();
423
- import_node_fs4 = require("fs");
424
- import_node_path2 = require("path");
464
+ import_node_fs5 = require("fs");
465
+ import_node_path3 = require("path");
425
466
  GITIGNORE_LINES = [".odla/*.local.json", ".odla/dev-token.json", ".dev.vars"];
426
467
  MANAGED_DEV_VARS = /* @__PURE__ */ new Set([
427
468
  "ODLA_PLATFORM",
@@ -438,71 +479,6 @@ var init_local = __esm({
438
479
  }
439
480
  });
440
481
 
441
- // src/handshake-state.ts
442
- function handshakeFile(cfg) {
443
- return (0, import_node_path3.join)((0, import_node_path3.dirname)(cfg.local.tokenFile), "handshake.local.json");
444
- }
445
- function readPendingHandshake(path, platform, email, requiredGrant) {
446
- const pending = readJsonFile(path);
447
- if (!pending || pending.platform !== platform || pending.email !== email) return null;
448
- if (typeof pending.userCode !== "string" || typeof pending.deviceCode !== "string") return null;
449
- if (typeof pending.expiresAt !== "number" || pending.expiresAt <= Date.now() + RESUME_MARGIN_MS) return null;
450
- if (requiredGrant && !grantCovers(pending, requiredGrant)) return null;
451
- return {
452
- interval: typeof pending.interval === "number" ? pending.interval : 3,
453
- ...pending,
454
- approvalUrl: handshakeUrl(platform, pending.userCode)
455
- };
456
- }
457
- function grantCovers(stored, required) {
458
- if (required.optionalProjectCapabilities.length === 0) return true;
459
- return required.projectIds.every((id) => stored.projectIds?.includes(id)) && required.optionalProjectCapabilities.every(
460
- (capability) => stored.optionalProjectCapabilities?.includes(capability)
461
- );
462
- }
463
- function writePendingHandshake(path, pending) {
464
- writePrivateJson(path, pending);
465
- }
466
- function clearPendingHandshake(path) {
467
- (0, import_node_fs5.rmSync)(path, { force: true });
468
- }
469
- function minutesLeft(expiresAt) {
470
- return Math.max(1, Math.round((expiresAt - Date.now()) / 6e4));
471
- }
472
- function approvalHint(pending) {
473
- return `approve code ${pending.userCode} at ${pending.approvalUrl} (${minutesLeft(pending.expiresAt)}m left)`;
474
- }
475
- function approvalReminder(out, pending, periodMs = 3e4) {
476
- const timer = setInterval(() => {
477
- for (const line of reminderLines({
478
- userCode: pending.userCode,
479
- approvalUrl: pending.approvalUrl,
480
- minutesLeft: minutesLeft(pending.expiresAt)
481
- }))
482
- out.log(line);
483
- }, periodMs);
484
- timer.unref?.();
485
- return () => clearInterval(timer);
486
- }
487
- function handshakeWaitMs(waitSeconds, interactive = import_node_process3.default.stdout.isTTY === true) {
488
- if (waitSeconds !== void 0) return waitSeconds * 1e3;
489
- return interactive ? void 0 : 9e4;
490
- }
491
- var import_node_fs5, import_node_path3, import_node_process3, RESUME_MARGIN_MS;
492
- var init_handshake_state = __esm({
493
- "src/handshake-state.ts"() {
494
- "use strict";
495
- init_cjs_shims();
496
- import_node_fs5 = require("fs");
497
- import_node_path3 = require("path");
498
- import_node_process3 = __toESM(require("process"), 1);
499
- init_local();
500
- init_approval_prompt();
501
- init_handshake_approval();
502
- RESUME_MARGIN_MS = 5e3;
503
- }
504
- });
505
-
506
482
  // src/token.ts
507
483
  async function getDeveloperToken(cfg, options, doFetch, out, grantRequest = {}) {
508
484
  const audience = platformAudience(cfg.platformUrl);
@@ -541,8 +517,8 @@ async function getDeveloperToken(cfg, options, doFetch, out, grantRequest = {})
541
517
  grantIntent
542
518
  };
543
519
  const waitMs = handshakeWaitMs(options.wait);
544
- if (grantRequest.freshLogin) clearPendingHandshake(ctx.pendingFile);
545
- const { token, expiresAt } = await resumePendingHandshake(ctx, waitMs) ?? await freshHandshake(ctx, waitMs);
520
+ clearPendingHandshake(ctx.pendingFile);
521
+ const { token, expiresAt } = await freshHandshake(ctx, waitMs);
546
522
  clearPendingHandshake(ctx.pendingFile);
547
523
  writePrivateJson(cfg.local.tokenFile, {
548
524
  platform: audience,
@@ -555,47 +531,6 @@ async function getDeveloperToken(cfg, options, doFetch, out, grantRequest = {})
555
531
  out.error(`auth: developer token cached (${displayPath(cfg.local.tokenFile, cfg.rootDir)})`);
556
532
  return token;
557
533
  }
558
- async function resumePendingHandshake(ctx, waitMs) {
559
- const pending = readPendingHandshake(
560
- ctx.pendingFile,
561
- ctx.audience,
562
- ctx.email,
563
- ctx.grantIntent
564
- );
565
- if (!pending) return null;
566
- ctx.out.error("");
567
- ctx.out.error(`auth: resuming pending handshake \u2014 ${approvalHint(pending)}`);
568
- await presentHandshakeApproval(ctx.out, {
569
- userCode: pending.userCode,
570
- approvalUrl: pending.approvalUrl,
571
- minutesLeft: Math.max(1, Math.floor((pending.expiresAt - Date.now()) / 6e4)),
572
- purpose: `sign this terminal in as ${ctx.email}`
573
- }, ctx.options);
574
- ctx.out.error("");
575
- const stopReminder = approvalReminder(ctx.out, pending);
576
- try {
577
- return await (0, import_db.collectToken)({
578
- endpoint: ctx.cfg.platformUrl,
579
- deviceCode: pending.deviceCode,
580
- expiresAt: pending.expiresAt,
581
- interval: pending.interval,
582
- waitMs,
583
- fetch: ctx.doFetch
584
- });
585
- } catch (err) {
586
- const code = err instanceof import_db.OdlaError ? err.code : void 0;
587
- if (code === "handshake_pending") throw stillPending(pending, ctx.email);
588
- if (code === "handshake_expired" || code === "handshake_timeout") {
589
- clearPendingHandshake(ctx.pendingFile);
590
- ctx.out.error("auth: pending handshake lapsed unapproved; starting a fresh one");
591
- return null;
592
- }
593
- if (code === "handshake_denied") clearPendingHandshake(ctx.pendingFile);
594
- throw err;
595
- } finally {
596
- stopReminder();
597
- }
598
- }
599
534
  async function freshHandshake(ctx, waitMs) {
600
535
  let started;
601
536
  let stopReminder;
@@ -623,7 +558,6 @@ async function freshHandshake(ctx, waitMs) {
623
558
  projectIds: ctx.grantIntent.projectIds,
624
559
  optionalProjectCapabilities: ctx.grantIntent.optionalProjectCapabilities
625
560
  };
626
- writePendingHandshake(ctx.pendingFile, started);
627
561
  await presentHandshakeApproval(ctx.out, {
628
562
  userCode,
629
563
  approvalUrl,
@@ -659,7 +593,7 @@ function projectAgentHandle(appId) {
659
593
  function stillPending(pending, email) {
660
594
  return new import_db.OdlaError(
661
595
  "handshake_pending",
662
- `handshake still pending \u2014 ask ${email} to ${approvalHint(pending)}, then re-run this command; it resumes the same handshake and collects the token`,
596
+ `handshake still pending \u2014 ask ${email} to ${approvalHint(pending)}; if this command exits, re-run it to request a new code`,
663
597
  { retryable: true }
664
598
  );
665
599
  }
@@ -5436,28 +5370,31 @@ async function smoke(options) {
5436
5370
  const out = options.stdout ?? console;
5437
5371
  const cfg = await loadProjectConfig(options.configPath);
5438
5372
  const env = resolveEnv(cfg, options.env);
5439
- const credentials = readCredentials(cfg.local.credentialsFile);
5440
- if (!credentials) {
5373
+ const runtime = options.runtime === true;
5374
+ const credentials = runtime ? null : readCredentials(cfg.local.credentialsFile);
5375
+ if (!runtime && !credentials) {
5441
5376
  throw new Error(`local credentials missing: ${displayPath(cfg.local.credentialsFile, cfg.rootDir)}. Run "odla-ai provision --write-dev-vars".`);
5442
5377
  }
5443
- if (credentials.appId !== cfg.app.id) {
5378
+ if (credentials && credentials.appId !== cfg.app.id) {
5444
5379
  throw new Error(`local credentials are for app "${credentials.appId}", but config app is "${cfg.app.id}"`);
5445
5380
  }
5446
- const entry = credentials.envs[env];
5447
- if (!entry?.tenantId) {
5381
+ const entry = credentials?.envs[env];
5382
+ if (!runtime && !entry?.tenantId) {
5448
5383
  throw new Error(`local credentials have no tenant for env "${env}". Run "odla-ai provision --write-dev-vars".`);
5449
5384
  }
5450
5385
  const hasDb = cfg.services.includes("db");
5451
5386
  const hasO11y = cfg.services.includes("o11y");
5452
- if (hasDb && !entry.dbKey) {
5387
+ if (!runtime && hasDb && !entry?.dbKey) {
5453
5388
  throw new Error(`local credentials have no db key for env "${env}". Run "odla-ai provision --write-dev-vars".`);
5454
5389
  }
5455
- if (hasO11y && !entry.o11yToken) {
5390
+ if (!runtime && hasO11y && !entry?.o11yToken) {
5456
5391
  throw new Error(`local credentials have no o11y token for env "${env}". Run "odla-ai provision --write-dev-vars".`);
5457
5392
  }
5458
5393
  const doFetch = options.fetch ?? fetch;
5394
+ const tenantId = entry?.tenantId ?? resolveTenant(cfg, env).tenant;
5459
5395
  out.log(`smoke: ${cfg.app.id}/${env}`);
5460
- out.log(` tenant: ${entry.tenantId}`);
5396
+ out.log(` mode: ${runtime ? "runtime (Worker-held credentials)" : "local credentials"}`);
5397
+ out.log(` tenant: ${tenantId}`);
5461
5398
  const publicConfig = await getJson(doFetch, publicConfigUrl(cfg.platformUrl, cfg.app.id, env), void 0);
5462
5399
  out.log(` public-config: ok`);
5463
5400
  if (cfg.services.includes("ai") && cfg.ai?.provider) {
@@ -5471,7 +5408,7 @@ async function smoke(options) {
5471
5408
  if (mode !== "hosted") throw new Error(`ai mode mismatch: expected "hosted", public-config has "${String(mode ?? "none")}"`);
5472
5409
  out.log(" ai: hosted");
5473
5410
  }
5474
- if (hasO11y) out.log(` o11y: credentials present`);
5411
+ if (hasO11y) out.log(runtime ? ` o11y: Worker-held credential` : ` o11y: credentials present`);
5475
5412
  if (cfg.services.includes("calendar")) {
5476
5413
  const token = await getDeveloperToken(
5477
5414
  cfg,
@@ -5490,10 +5427,10 @@ async function smoke(options) {
5490
5427
  out.log(` calendar: ${status.status}, bookable (booking \u2192 ${status.bookingCalendarId ?? "primary"})`);
5491
5428
  }
5492
5429
  const database = await resolveDatabaseConfig(cfg);
5493
- if (hasDb) {
5430
+ if (hasDb && !runtime) {
5494
5431
  const expectedSchema = database.schema;
5495
5432
  const expectedEntities = serializedEntities(expectedSchema);
5496
- const liveSchemaPayload = await getJson(doFetch, `${cfg.dbEndpoint}/app/${encodeURIComponent(entry.tenantId)}/schema`, entry.dbKey);
5433
+ const liveSchemaPayload = await getJson(doFetch, `${cfg.dbEndpoint}/app/${encodeURIComponent(tenantId)}/schema`, entry.dbKey);
5497
5434
  const liveSchema = liveSchemaPayload.schema ?? liveSchemaPayload;
5498
5435
  const liveEntities = serializedEntities(liveSchema);
5499
5436
  if (expectedEntities.length) {
@@ -5503,7 +5440,7 @@ async function smoke(options) {
5503
5440
  out.log(` schema: ${liveEntities.length} entities`);
5504
5441
  const aggregateEntity = expectedEntities[0] ?? liveEntities[0];
5505
5442
  if (aggregateEntity) {
5506
- const aggregate = await postJson2(doFetch, `${cfg.dbEndpoint}/app/${encodeURIComponent(entry.tenantId)}/aggregate`, entry.dbKey, {
5443
+ const aggregate = await postJson2(doFetch, `${cfg.dbEndpoint}/app/${encodeURIComponent(tenantId)}/aggregate`, entry.dbKey, {
5507
5444
  ns: aggregateEntity,
5508
5445
  aggregate: { count: true }
5509
5446
  });
@@ -5512,9 +5449,20 @@ async function smoke(options) {
5512
5449
  } else {
5513
5450
  out.log(` aggregate: skipped (schema has no entities)`);
5514
5451
  }
5452
+ } else if (hasDb) {
5453
+ out.log(` db: Worker-held credential (direct schema/aggregate skipped)`);
5515
5454
  } else {
5516
5455
  out.log(` db: skipped (not enabled)`);
5517
5456
  }
5457
+ if (runtime) {
5458
+ const link = cfg.links?.[env];
5459
+ if (!link) throw new Error(`runtime smoke requires links.${env} in odla.config.mjs`);
5460
+ const res = await doFetch(link, { redirect: "manual" });
5461
+ if (res.status < 200 || res.status >= 500) {
5462
+ throw new Error(`runtime target ${new URL(link).origin} returned ${res.status}`);
5463
+ }
5464
+ out.log(` runtime target: ${res.status}`);
5465
+ }
5518
5466
  const probes = database.integrations.flatMap(
5519
5467
  (integration) => (integration.probes ?? []).map((probe) => ({ integration: integration.id, probe }))
5520
5468
  );
@@ -5729,10 +5677,11 @@ async function projectCommand(command, parsed, deps) {
5729
5677
  return true;
5730
5678
  }
5731
5679
  if (command === "smoke") {
5732
- assertArgs(parsed, ["config", "env", "token", "email", "open"], 1);
5680
+ assertArgs(parsed, ["config", "env", "runtime", "token", "email", "open"], 1);
5733
5681
  await smoke({
5734
5682
  configPath: stringOpt(parsed.options.config) ?? "odla.config.mjs",
5735
5683
  env: stringOpt(parsed.options.env),
5684
+ runtime: parsed.options.runtime === true,
5736
5685
  token: stringOpt(parsed.options.token),
5737
5686
  email: stringOpt(parsed.options.email),
5738
5687
  open: parsed.options.open === false ? false : parsed.options.open === true ? true : void 0,
@@ -9727,7 +9676,7 @@ Usage:
9727
9676
  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]
9728
9677
  odla-ai credentials list [--config odla.config.mjs] [--env dev] [--all] [--json]
9729
9678
  odla-ai credentials revoke <receipt-id> [--config odla.config.mjs] [--json]
9730
- odla-ai smoke [--config odla.config.mjs] [--env dev] [--email <odla-account>] [--no-open]
9679
+ odla-ai smoke [--config odla.config.mjs] [--env dev] [--runtime] [--email <odla-account>] [--no-open]
9731
9680
  odla-ai skill install [--dir <project>] [--agent <name>] [--global] [--force]
9732
9681
  odla-ai secrets push --env <env> [--config odla.config.mjs] [--dry-run] [--yes]
9733
9682
  odla-ai secrets set <name> --env <env> (--from-env <NAME>|--stdin) [--email <odla-account>] [--config odla.config.mjs] [--yes]
@@ -9831,7 +9780,7 @@ Commands:
9831
9780
  "provision --live --yes" initializes only the live instance of
9832
9781
  an existing sandbox app and enables every configured service;
9833
9782
  no edit to the dev-first envs list is required.
9834
- smoke Verify credentials, public-config, composed schema, db aggregate, and integration probes.
9783
+ smoke Verify local service access, or use --runtime for a Worker-held credential deployment.
9835
9784
  skill Same installer; --agent accepts all, claude, codex, cursor,
9836
9785
  copilot, gemini, or agents (repeatable or comma-separated).
9837
9786
  secrets Push configured db/o11y secrets into the Worker via wrangler
@@ -9871,12 +9820,11 @@ Safety:
9871
9820
  attempts to open it \u2014 including in CI, SSH, display-less, and agent-driven
9872
9821
  shells. Only --no-open suppresses the attempt. Browser launch is best-effort:
9873
9822
  agents with browser control must open that exact URL immediately; otherwise
9874
- they must give it to the human verbatim. A started handshake is persisted
9875
- under .odla/, so a command killed mid-wait loses nothing \u2014 rerunning resumes
9876
- the same code. Outside an interactive terminal the wait is capped (90s by
9823
+ they must give it to the human verbatim. A device code remains only in the
9824
+ running process. Outside an interactive terminal the wait is capped (90s by
9877
9825
  default, --wait <seconds> to change); a still-pending handshake then exits
9878
- with code 75: open the same URL (or relay it if browser control is unavailable),
9879
- wait for approval, and re-run to collect.
9826
+ with code 75. Rerunning always requests and opens a new code; older clients'
9827
+ persisted pending state is discarded.
9880
9828
  A fresh device handshake requires --email <odla-account> or ODLA_USER_EMAIL.
9881
9829
  The email is a non-secret identity hint: never provide a password or session
9882
9830
  token. It is the email shown by the signed-in odla account \u2014 never infer it