@odla-ai/cli 0.30.0 → 0.30.2

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
@@ -147,7 +147,11 @@ Use `pm task ref <id>` (or the equivalent goal/decision/bug command) to print
147
147
  copy-ready structured markup for Discussion. The same markup may be pasted into
148
148
  `pm <entity> comment <id> --body "…"`; PM stores it as a structured reference,
149
149
  and `pm <entity> comments <id>` prints copy-ready markup instead of flattening
150
- the link back to a title.
150
+ the link back to a title. For ordinary chat and handoffs, `pm task link <id>`
151
+ prints a normal Markdown link to the record's durable Studio route. Successful
152
+ create and lifecycle commands print that title-first link directly. Lead with
153
+ the PM type and linked title, explain the state or next action, and keep the id
154
+ hidden in the URL; a bare id list is not a useful handoff.
151
155
 
152
156
  When diagnosing a deployed app, agents can request one parseable observability
153
157
  snapshot instead of scraping Studio or composing collector routes themselves:
@@ -511,12 +515,12 @@ credential set.
511
515
  and attempts to open it — including from CI, SSH, display-less, scripted,
512
516
  and agent-driven shells. Only `--no-open` suppresses the attempt. Browser
513
517
  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.
518
+ immediately; otherwise it gives the URL to the human verbatim. The device
519
+ code stays only in the running process. Outside an interactive terminal the
520
+ approval wait is capped (90 seconds by default; `--wait <seconds>`
521
+ overrides), and a still-pending handshake exits with code 75. Rerunning
522
+ always requests and opens a new code; it never resumes an older request from
523
+ `.odla/`.
520
524
  2. Creates the platform app if needed. For a new id, this consumes the exact-id
521
525
  reservation approved in step 1; it is not ambient project-creation authority.
522
526
  3. Enables configured services in every configured environment. Calendar
@@ -569,6 +573,19 @@ The inventory contains only receipt, target, state, and timestamp metadata.
569
573
  Revocation uses the receipt's exact DB and o11y credential ids; no value is
570
574
  retrieved or rotated.
571
575
 
576
+ Verify a deployment that intentionally keeps db/o11y credentials only on the
577
+ Worker:
578
+
579
+ ```sh
580
+ npx @odla-ai/cli smoke --env dev --runtime
581
+ ```
582
+
583
+ Runtime smoke never reads `.odla/credentials.local.json`. It checks Registry
584
+ public config, the configured Worker link, anonymous integration probes, and
585
+ owner-visible calendar health. Direct schema and aggregate reads remain part
586
+ of ordinary local-credential smoke because a runtime credential is deliberately
587
+ not retrievable from the Worker.
588
+
572
589
  ### Application capability integrations
573
590
 
574
591
  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,
@@ -9659,6 +9608,7 @@ Usage:
9659
9608
  odla-ai pm bug add --app <id> --title <t> (--description <text>|--body <text>) [--status <s>] [--severity <s>] [--goal <id>] [--assignee <id>] [--decision <id>] [--mutation-id <id>] [--json]
9660
9609
  odla-ai bug report --app <id> --title <t> (--description <text>|--body <text>) [--severity <s>] [--json]
9661
9610
  odla-ai pm <goal|task|decision|bug> get <id> [--json]
9611
+ odla-ai pm <goal|task|decision|bug> link <id> [--json]
9662
9612
  odla-ai pm <goal|task|decision|bug> ref <id> [--json]
9663
9613
  odla-ai pm goal set <id> [--title <t>|--status <s>|--proof <text>|--no-proof|--target <pct>|--no-target] [--mutation-id <id>] [--json]
9664
9614
  odla-ai pm task set <id> [--title <t>|--column <backlog|ready|doing|review|done>|--rank <n>|--goal <id>|--no-goal|--alignment-decision <id>|--no-alignment-decision|--execution <human|agent|either>|--assignee <id>|--no-assignee|--description <text>|--body <text>|--acceptance <text>|--no-acceptance|--due <epoch-ms>|--no-due|--expected-revision <n>] [--mutation-id <id>] [--json]
@@ -9727,7 +9677,7 @@ Usage:
9727
9677
  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
9678
  odla-ai credentials list [--config odla.config.mjs] [--env dev] [--all] [--json]
9729
9679
  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]
9680
+ odla-ai smoke [--config odla.config.mjs] [--env dev] [--runtime] [--email <odla-account>] [--no-open]
9731
9681
  odla-ai skill install [--dir <project>] [--agent <name>] [--global] [--force]
9732
9682
  odla-ai secrets push --env <env> [--config odla.config.mjs] [--dry-run] [--yes]
9733
9683
  odla-ai secrets set <name> --env <env> (--from-env <NAME>|--stdin) [--email <odla-account>] [--config odla.config.mjs] [--yes]
@@ -9831,7 +9781,7 @@ Commands:
9831
9781
  "provision --live --yes" initializes only the live instance of
9832
9782
  an existing sandbox app and enables every configured service;
9833
9783
  no edit to the dev-first envs list is required.
9834
- smoke Verify credentials, public-config, composed schema, db aggregate, and integration probes.
9784
+ smoke Verify local service access, or use --runtime for a Worker-held credential deployment.
9835
9785
  skill Same installer; --agent accepts all, claude, codex, cursor,
9836
9786
  copilot, gemini, or agents (repeatable or comma-separated).
9837
9787
  secrets Push configured db/o11y secrets into the Worker via wrangler
@@ -9871,12 +9821,11 @@ Safety:
9871
9821
  attempts to open it \u2014 including in CI, SSH, display-less, and agent-driven
9872
9822
  shells. Only --no-open suppresses the attempt. Browser launch is best-effort:
9873
9823
  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
9824
+ they must give it to the human verbatim. A device code remains only in the
9825
+ running process. Outside an interactive terminal the wait is capped (90s by
9877
9826
  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.
9827
+ with code 75. Rerunning always requests and opens a new code; older clients'
9828
+ persisted pending state is discarded.
9880
9829
  A fresh device handshake requires --email <odla-account> or ODLA_USER_EMAIL.
9881
9830
  The email is a non-secret identity hint: never provide a password or session
9882
9831
  token. It is the email shown by the signed-in odla account \u2014 never infer it
@@ -10561,16 +10510,26 @@ function referenceMarkup(entity, record10) {
10561
10510
  const label = (record10.title?.trim() || `${entity} ${record10.id}`).replaceAll("]", ")");
10562
10511
  return `@[${label}](pm:${entity}/${record10.id})`;
10563
10512
  }
10513
+ function studioRecordUrl(ctx, entity, id) {
10514
+ return new URL(
10515
+ `/studio/pm/${STUDIO_SECTION[entity]}/${encodeURIComponent(id)}`,
10516
+ ctx.platformUrl
10517
+ ).href;
10518
+ }
10519
+ function studioRecordLink(ctx, entity, record10) {
10520
+ const label = (record10.title?.trim() || `${entity} ${record10.id}`).replaceAll("]", ")");
10521
+ return `[${label}](${studioRecordUrl(ctx, entity, record10.id)})`;
10522
+ }
10564
10523
  function printRecord(ctx, entity, record10) {
10565
10524
  ctx.out.log(
10566
- `${record10.id} [${statusCol(entity, record10)}] ${record10.appId} ${record10.title ?? ""}`
10525
+ `${record10.id} [${statusCol(entity, record10)}] ${record10.appId} ${studioRecordLink(ctx, entity, record10)}`
10567
10526
  );
10568
10527
  }
10569
10528
  function emit2(ctx, value2, human) {
10570
10529
  if (ctx.json) ctx.out.log(JSON.stringify(value2, null, 2));
10571
10530
  else human();
10572
10531
  }
10573
- var DONE, writeMutationId2, FIELD_MAP;
10532
+ var DONE, writeMutationId2, FIELD_MAP, STUDIO_SECTION;
10574
10533
  var init_pm_action_core = __esm({
10575
10534
  "src/pm-action-core.ts"() {
10576
10535
  "use strict";
@@ -10603,6 +10562,12 @@ var init_pm_action_core = __esm({
10603
10562
  execution: { key: "executionMode" },
10604
10563
  "expected-revision": { key: "expectedRevision", num: true }
10605
10564
  };
10565
+ STUDIO_SECTION = {
10566
+ goal: "goals",
10567
+ task: "board",
10568
+ decision: "decisions",
10569
+ bug: "bugs"
10570
+ };
10606
10571
  }
10607
10572
  });
10608
10573
 
@@ -10648,7 +10613,8 @@ async function pmAdd(ctx, entity, parsed) {
10648
10613
  input,
10649
10614
  mutationId: writeMutationId2(parsed)
10650
10615
  });
10651
- emit2(ctx, res, () => ctx.out.log(`created ${entity} ${res.id}`));
10616
+ const record10 = { id: res.id, appId, title: String(input.title) };
10617
+ emit2(ctx, res, () => ctx.out.log(`created ${entity}: ${studioRecordLink(ctx, entity, record10)}`));
10652
10618
  }
10653
10619
  async function pmGet(ctx, entity, id) {
10654
10620
  const { record: record10 } = await pmRequest(ctx, "GET", `/${entity}/${encodeURIComponent(id)}`);
@@ -10673,7 +10639,10 @@ async function pmSet(ctx, entity, id, parsed) {
10673
10639
  patch: patch2,
10674
10640
  mutationId: writeMutationId2(parsed)
10675
10641
  });
10676
- emit2(ctx, res, () => ctx.out.log(res.record ? `${res.record.id} [${statusCol(entity, res.record)}] ${res.record.appId} ${res.record.title ?? ""}` : `updated ${entity} ${id}`));
10642
+ emit2(ctx, res, () => {
10643
+ if (!res.record) return ctx.out.log(`updated ${entity} ${id}`);
10644
+ ctx.out.log(`${entity}: ${studioRecordLink(ctx, entity, res.record)} \u2192 ${statusCol(entity, res.record)}`);
10645
+ });
10677
10646
  }
10678
10647
  async function pmDone(ctx, entity, id, parsed) {
10679
10648
  const decisionId = stringOpt(parsed.options.decision);
@@ -10683,7 +10652,11 @@ async function pmDone(ctx, entity, id, parsed) {
10683
10652
  patch: patch2,
10684
10653
  mutationId: writeMutationId2(parsed)
10685
10654
  });
10686
- emit2(ctx, res, () => ctx.out.log(`${entity} ${id} \u2192 done`));
10655
+ emit2(ctx, res, () => {
10656
+ const label = res.record ? studioRecordLink(ctx, entity, res.record) : id;
10657
+ const state2 = res.record ? statusCol(entity, res.record) : "done";
10658
+ ctx.out.log(`${entity}: ${label} \u2192 ${state2}`);
10659
+ });
10687
10660
  }
10688
10661
  async function pmTaskLifecycle(ctx, id, action2, parsed) {
10689
10662
  const rawRevision = stringOpt(parsed.options["expected-revision"]);
@@ -10712,7 +10685,8 @@ async function pmTaskLifecycle(ctx, id, action2, parsed) {
10712
10685
  );
10713
10686
  emit2(ctx, res, () => {
10714
10687
  const state2 = res.record ? statusCol("task", res.record) : action2;
10715
- ctx.out.log(`task ${id} \u2192 ${state2}`);
10688
+ const label = res.record ? studioRecordLink(ctx, "task", res.record) : id;
10689
+ ctx.out.log(`task: ${label} \u2192 ${state2}`);
10716
10690
  });
10717
10691
  }
10718
10692
  async function allRecords(ctx, entity, appId) {
@@ -10817,6 +10791,27 @@ var init_pm_actions = __esm({
10817
10791
  }
10818
10792
  });
10819
10793
 
10794
+ // src/pm-links.ts
10795
+ async function pmLink(ctx, entity, id) {
10796
+ const { record: record10 } = await pmRequest(
10797
+ ctx,
10798
+ "GET",
10799
+ `/${entity}/${encodeURIComponent(id)}`
10800
+ );
10801
+ const url = studioRecordUrl(ctx, entity, record10.id);
10802
+ const markdown = studioRecordLink(ctx, entity, record10);
10803
+ emit2(ctx, { kind: entity, id: record10.id, label: record10.title ?? "", url, markdown }, () => {
10804
+ ctx.out.log(markdown);
10805
+ });
10806
+ }
10807
+ var init_pm_links = __esm({
10808
+ "src/pm-links.ts"() {
10809
+ "use strict";
10810
+ init_cjs_shims();
10811
+ init_pm_action_core();
10812
+ }
10813
+ });
10814
+
10820
10815
  // src/pm-comments.ts
10821
10816
  async function pmComment(ctx, entity, id, parsed) {
10822
10817
  const body = stringOpt(parsed.options.body);
@@ -11143,7 +11138,7 @@ async function pmCommand(parsed, deps = {}) {
11143
11138
  if (!entity) throw new Error(`unknown pm entity "${word}". Try "odla-ai pm bug list" (goal|task|decision|bug).`);
11144
11139
  const requestedAction = parsed.positionals[2] ?? "list";
11145
11140
  const action2 = canonicalAction(requestedAction);
11146
- if (!action2) throw new Error(`unknown pm action "${requestedAction}". Try list|add|get|set|done|comment|comments|rm.`);
11141
+ if (!action2) throw new Error(`unknown pm action "${requestedAction}". Try list|add|get|set|done|link|ref|comment|comments|rm.`);
11147
11142
  assertArgs(parsed, allowedOptions(entity, action2), 4);
11148
11143
  if ((action2 === "ready" || action2 === "claim" || action2 === "release") && entity !== "task") {
11149
11144
  throw new Error(`pm ${action2} is only valid for tasks`);
@@ -11167,6 +11162,8 @@ async function pmCommand(parsed, deps = {}) {
11167
11162
  return pmComments(ctx, entity, requireId2(id, action2));
11168
11163
  case "rm":
11169
11164
  return pmRemove(ctx, entity, requireId2(id, action2));
11165
+ case "link":
11166
+ return pmLink(ctx, entity, requireId2(id, action2));
11170
11167
  case "ref":
11171
11168
  return pmReference(ctx, entity, requireId2(id, action2));
11172
11169
  case "ready":
@@ -11183,6 +11180,7 @@ var init_pm_command = __esm({
11183
11180
  init_argv();
11184
11181
  init_operator_context();
11185
11182
  init_pm_actions();
11183
+ init_pm_links();
11186
11184
  init_pm_comments();
11187
11185
  init_token();
11188
11186
  init_pm_watch();
@@ -11207,6 +11205,7 @@ var init_pm_command = __esm({
11207
11205
  ready: ["goal", "alignment-decision", "execution", "description", "desc", "body", "acceptance", "expected-revision", "mutation-id"],
11208
11206
  claim: ["expected-revision", "mutation-id"],
11209
11207
  release: ["expected-revision", "mutation-id"],
11208
+ link: [],
11210
11209
  ref: []
11211
11210
  };
11212
11211
  ENTITY_OPTIONS = {