@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.
@@ -1,7 +1,7 @@
1
1
  #!/usr/bin/env node
2
2
  import {
3
3
  runCli
4
- } from "./chunk-PPSXHZJI.js";
4
+ } from "./chunk-3HFNKDZV.js";
5
5
  import {
6
6
  exitCodeFor
7
7
  } from "./chunk-UKLSRQ5J.js";
@@ -9,4 +9,4 @@ export {
9
9
  exitCodeFor,
10
10
  runCli
11
11
  };
12
- //# sourceMappingURL=cli-CKBUVTJM.js.map
12
+ //# sourceMappingURL=cli-BGSX5Z4C.js.map
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
  }
@@ -4911,28 +4845,31 @@ async function smoke(options) {
4911
4845
  const out = options.stdout ?? console;
4912
4846
  const cfg = await loadProjectConfig(options.configPath);
4913
4847
  const env = resolveEnv(cfg, options.env);
4914
- const credentials = readCredentials(cfg.local.credentialsFile);
4915
- if (!credentials) {
4848
+ const runtime = options.runtime === true;
4849
+ const credentials = runtime ? null : readCredentials(cfg.local.credentialsFile);
4850
+ if (!runtime && !credentials) {
4916
4851
  throw new Error(`local credentials missing: ${displayPath(cfg.local.credentialsFile, cfg.rootDir)}. Run "odla-ai provision --write-dev-vars".`);
4917
4852
  }
4918
- if (credentials.appId !== cfg.app.id) {
4853
+ if (credentials && credentials.appId !== cfg.app.id) {
4919
4854
  throw new Error(`local credentials are for app "${credentials.appId}", but config app is "${cfg.app.id}"`);
4920
4855
  }
4921
- const entry = credentials.envs[env];
4922
- if (!entry?.tenantId) {
4856
+ const entry = credentials?.envs[env];
4857
+ if (!runtime && !entry?.tenantId) {
4923
4858
  throw new Error(`local credentials have no tenant for env "${env}". Run "odla-ai provision --write-dev-vars".`);
4924
4859
  }
4925
4860
  const hasDb = cfg.services.includes("db");
4926
4861
  const hasO11y = cfg.services.includes("o11y");
4927
- if (hasDb && !entry.dbKey) {
4862
+ if (!runtime && hasDb && !entry?.dbKey) {
4928
4863
  throw new Error(`local credentials have no db key for env "${env}". Run "odla-ai provision --write-dev-vars".`);
4929
4864
  }
4930
- if (hasO11y && !entry.o11yToken) {
4865
+ if (!runtime && hasO11y && !entry?.o11yToken) {
4931
4866
  throw new Error(`local credentials have no o11y token for env "${env}". Run "odla-ai provision --write-dev-vars".`);
4932
4867
  }
4933
4868
  const doFetch = options.fetch ?? fetch;
4869
+ const tenantId = entry?.tenantId ?? resolveTenant(cfg, env).tenant;
4934
4870
  out.log(`smoke: ${cfg.app.id}/${env}`);
4935
- out.log(` tenant: ${entry.tenantId}`);
4871
+ out.log(` mode: ${runtime ? "runtime (Worker-held credentials)" : "local credentials"}`);
4872
+ out.log(` tenant: ${tenantId}`);
4936
4873
  const publicConfig = await getJson(doFetch, publicConfigUrl(cfg.platformUrl, cfg.app.id, env), void 0);
4937
4874
  out.log(` public-config: ok`);
4938
4875
  if (cfg.services.includes("ai") && cfg.ai?.provider) {
@@ -4946,7 +4883,7 @@ async function smoke(options) {
4946
4883
  if (mode !== "hosted") throw new Error(`ai mode mismatch: expected "hosted", public-config has "${String(mode ?? "none")}"`);
4947
4884
  out.log(" ai: hosted");
4948
4885
  }
4949
- if (hasO11y) out.log(` o11y: credentials present`);
4886
+ if (hasO11y) out.log(runtime ? ` o11y: Worker-held credential` : ` o11y: credentials present`);
4950
4887
  if (cfg.services.includes("calendar")) {
4951
4888
  const token = await getDeveloperToken(
4952
4889
  cfg,
@@ -4965,10 +4902,10 @@ async function smoke(options) {
4965
4902
  out.log(` calendar: ${status.status}, bookable (booking \u2192 ${status.bookingCalendarId ?? "primary"})`);
4966
4903
  }
4967
4904
  const database = await resolveDatabaseConfig(cfg);
4968
- if (hasDb) {
4905
+ if (hasDb && !runtime) {
4969
4906
  const expectedSchema = database.schema;
4970
4907
  const expectedEntities = serializedEntities(expectedSchema);
4971
- 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);
4972
4909
  const liveSchema = liveSchemaPayload.schema ?? liveSchemaPayload;
4973
4910
  const liveEntities = serializedEntities(liveSchema);
4974
4911
  if (expectedEntities.length) {
@@ -4978,7 +4915,7 @@ async function smoke(options) {
4978
4915
  out.log(` schema: ${liveEntities.length} entities`);
4979
4916
  const aggregateEntity = expectedEntities[0] ?? liveEntities[0];
4980
4917
  if (aggregateEntity) {
4981
- 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, {
4982
4919
  ns: aggregateEntity,
4983
4920
  aggregate: { count: true }
4984
4921
  });
@@ -4987,9 +4924,20 @@ async function smoke(options) {
4987
4924
  } else {
4988
4925
  out.log(` aggregate: skipped (schema has no entities)`);
4989
4926
  }
4927
+ } else if (hasDb) {
4928
+ out.log(` db: Worker-held credential (direct schema/aggregate skipped)`);
4990
4929
  } else {
4991
4930
  out.log(` db: skipped (not enabled)`);
4992
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
+ }
4993
4941
  const probes = database.integrations.flatMap(
4994
4942
  (integration) => (integration.probes ?? []).map((probe) => ({ integration: integration.id, probe }))
4995
4943
  );
@@ -5192,10 +5140,11 @@ async function projectCommand(command, parsed, deps) {
5192
5140
  return true;
5193
5141
  }
5194
5142
  if (command === "smoke") {
5195
- assertArgs(parsed, ["config", "env", "token", "email", "open"], 1);
5143
+ assertArgs(parsed, ["config", "env", "runtime", "token", "email", "open"], 1);
5196
5144
  await smoke({
5197
5145
  configPath: stringOpt(parsed.options.config) ?? "odla.config.mjs",
5198
5146
  env: stringOpt(parsed.options.env),
5147
+ runtime: parsed.options.runtime === true,
5199
5148
  token: stringOpt(parsed.options.token),
5200
5149
  email: stringOpt(parsed.options.email),
5201
5150
  open: parsed.options.open === false ? false : parsed.options.open === true ? true : void 0,
@@ -8970,6 +8919,7 @@ Usage:
8970
8919
  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]
8971
8920
  odla-ai bug report --app <id> --title <t> (--description <text>|--body <text>) [--severity <s>] [--json]
8972
8921
  odla-ai pm <goal|task|decision|bug> get <id> [--json]
8922
+ odla-ai pm <goal|task|decision|bug> link <id> [--json]
8973
8923
  odla-ai pm <goal|task|decision|bug> ref <id> [--json]
8974
8924
  odla-ai pm goal set <id> [--title <t>|--status <s>|--proof <text>|--no-proof|--target <pct>|--no-target] [--mutation-id <id>] [--json]
8975
8925
  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]
@@ -9038,7 +8988,7 @@ Usage:
9038
8988
  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]
9039
8989
  odla-ai credentials list [--config odla.config.mjs] [--env dev] [--all] [--json]
9040
8990
  odla-ai credentials revoke <receipt-id> [--config odla.config.mjs] [--json]
9041
- odla-ai smoke [--config odla.config.mjs] [--env dev] [--email <odla-account>] [--no-open]
8991
+ odla-ai smoke [--config odla.config.mjs] [--env dev] [--runtime] [--email <odla-account>] [--no-open]
9042
8992
  odla-ai skill install [--dir <project>] [--agent <name>] [--global] [--force]
9043
8993
  odla-ai secrets push --env <env> [--config odla.config.mjs] [--dry-run] [--yes]
9044
8994
  odla-ai secrets set <name> --env <env> (--from-env <NAME>|--stdin) [--email <odla-account>] [--config odla.config.mjs] [--yes]
@@ -9140,7 +9090,7 @@ Commands:
9140
9090
  "provision --live --yes" initializes only the live instance of
9141
9091
  an existing sandbox app and enables every configured service;
9142
9092
  no edit to the dev-first envs list is required.
9143
- smoke Verify credentials, public-config, composed schema, db aggregate, and integration probes.
9093
+ smoke Verify local service access, or use --runtime for a Worker-held credential deployment.
9144
9094
  skill Same installer; --agent accepts all, claude, codex, cursor,
9145
9095
  copilot, gemini, or agents (repeatable or comma-separated).
9146
9096
  secrets Push configured db/o11y secrets into the Worker via wrangler
@@ -9180,12 +9130,11 @@ Safety:
9180
9130
  attempts to open it \u2014 including in CI, SSH, display-less, and agent-driven
9181
9131
  shells. Only --no-open suppresses the attempt. Browser launch is best-effort:
9182
9132
  agents with browser control must open that exact URL immediately; otherwise
9183
- they must give it to the human verbatim. A started handshake is persisted
9184
- under .odla/, so a command killed mid-wait loses nothing \u2014 rerunning resumes
9185
- the same code. Outside an interactive terminal the wait is capped (90s by
9133
+ they must give it to the human verbatim. A device code remains only in the
9134
+ running process. Outside an interactive terminal the wait is capped (90s by
9186
9135
  default, --wait <seconds> to change); a still-pending handshake then exits
9187
- with code 75: open the same URL (or relay it if browser control is unavailable),
9188
- wait for approval, and re-run to collect.
9136
+ with code 75. Rerunning always requests and opens a new code; older clients'
9137
+ persisted pending state is discarded.
9189
9138
  A fresh device handshake requires --email <odla-account> or ODLA_USER_EMAIL.
9190
9139
  The email is a non-secret identity hint: never provide a password or session
9191
9140
  token. It is the email shown by the signed-in odla account \u2014 never infer it
@@ -9836,9 +9785,25 @@ function referenceMarkup(entity, record10) {
9836
9785
  const label = (record10.title?.trim() || `${entity} ${record10.id}`).replaceAll("]", ")");
9837
9786
  return `@[${label}](pm:${entity}/${record10.id})`;
9838
9787
  }
9788
+ var STUDIO_SECTION = {
9789
+ goal: "goals",
9790
+ task: "board",
9791
+ decision: "decisions",
9792
+ bug: "bugs"
9793
+ };
9794
+ function studioRecordUrl(ctx, entity, id) {
9795
+ return new URL(
9796
+ `/studio/pm/${STUDIO_SECTION[entity]}/${encodeURIComponent(id)}`,
9797
+ ctx.platformUrl
9798
+ ).href;
9799
+ }
9800
+ function studioRecordLink(ctx, entity, record10) {
9801
+ const label = (record10.title?.trim() || `${entity} ${record10.id}`).replaceAll("]", ")");
9802
+ return `[${label}](${studioRecordUrl(ctx, entity, record10.id)})`;
9803
+ }
9839
9804
  function printRecord(ctx, entity, record10) {
9840
9805
  ctx.out.log(
9841
- `${record10.id} [${statusCol(entity, record10)}] ${record10.appId} ${record10.title ?? ""}`
9806
+ `${record10.id} [${statusCol(entity, record10)}] ${record10.appId} ${studioRecordLink(ctx, entity, record10)}`
9842
9807
  );
9843
9808
  }
9844
9809
  function emit2(ctx, value2, human) {
@@ -9888,7 +9853,8 @@ async function pmAdd(ctx, entity, parsed) {
9888
9853
  input,
9889
9854
  mutationId: writeMutationId2(parsed)
9890
9855
  });
9891
- emit2(ctx, res, () => ctx.out.log(`created ${entity} ${res.id}`));
9856
+ const record10 = { id: res.id, appId, title: String(input.title) };
9857
+ emit2(ctx, res, () => ctx.out.log(`created ${entity}: ${studioRecordLink(ctx, entity, record10)}`));
9892
9858
  }
9893
9859
  async function pmGet(ctx, entity, id) {
9894
9860
  const { record: record10 } = await pmRequest(ctx, "GET", `/${entity}/${encodeURIComponent(id)}`);
@@ -9913,7 +9879,10 @@ async function pmSet(ctx, entity, id, parsed) {
9913
9879
  patch: patch2,
9914
9880
  mutationId: writeMutationId2(parsed)
9915
9881
  });
9916
- emit2(ctx, res, () => ctx.out.log(res.record ? `${res.record.id} [${statusCol(entity, res.record)}] ${res.record.appId} ${res.record.title ?? ""}` : `updated ${entity} ${id}`));
9882
+ emit2(ctx, res, () => {
9883
+ if (!res.record) return ctx.out.log(`updated ${entity} ${id}`);
9884
+ ctx.out.log(`${entity}: ${studioRecordLink(ctx, entity, res.record)} \u2192 ${statusCol(entity, res.record)}`);
9885
+ });
9917
9886
  }
9918
9887
  async function pmDone(ctx, entity, id, parsed) {
9919
9888
  const decisionId = stringOpt(parsed.options.decision);
@@ -9923,7 +9892,11 @@ async function pmDone(ctx, entity, id, parsed) {
9923
9892
  patch: patch2,
9924
9893
  mutationId: writeMutationId2(parsed)
9925
9894
  });
9926
- emit2(ctx, res, () => ctx.out.log(`${entity} ${id} \u2192 done`));
9895
+ emit2(ctx, res, () => {
9896
+ const label = res.record ? studioRecordLink(ctx, entity, res.record) : id;
9897
+ const state2 = res.record ? statusCol(entity, res.record) : "done";
9898
+ ctx.out.log(`${entity}: ${label} \u2192 ${state2}`);
9899
+ });
9927
9900
  }
9928
9901
  async function pmTaskLifecycle(ctx, id, action2, parsed) {
9929
9902
  const rawRevision = stringOpt(parsed.options["expected-revision"]);
@@ -9952,7 +9925,8 @@ async function pmTaskLifecycle(ctx, id, action2, parsed) {
9952
9925
  );
9953
9926
  emit2(ctx, res, () => {
9954
9927
  const state2 = res.record ? statusCol("task", res.record) : action2;
9955
- ctx.out.log(`task ${id} \u2192 ${state2}`);
9928
+ const label = res.record ? studioRecordLink(ctx, "task", res.record) : id;
9929
+ ctx.out.log(`task: ${label} \u2192 ${state2}`);
9956
9930
  });
9957
9931
  }
9958
9932
  async function allRecords(ctx, entity, appId) {
@@ -10049,6 +10023,20 @@ async function pmRemove(ctx, entity, id) {
10049
10023
  ctx.out.log(`deleted ${entity} ${id}`);
10050
10024
  }
10051
10025
 
10026
+ // src/pm-links.ts
10027
+ async function pmLink(ctx, entity, id) {
10028
+ const { record: record10 } = await pmRequest(
10029
+ ctx,
10030
+ "GET",
10031
+ `/${entity}/${encodeURIComponent(id)}`
10032
+ );
10033
+ const url = studioRecordUrl(ctx, entity, record10.id);
10034
+ const markdown = studioRecordLink(ctx, entity, record10);
10035
+ emit2(ctx, { kind: entity, id: record10.id, label: record10.title ?? "", url, markdown }, () => {
10036
+ ctx.out.log(markdown);
10037
+ });
10038
+ }
10039
+
10052
10040
  // src/pm-comments.ts
10053
10041
  async function pmComment(ctx, entity, id, parsed) {
10054
10042
  const body = stringOpt(parsed.options.body);
@@ -10296,6 +10284,7 @@ var ACTION_OPTIONS = {
10296
10284
  ready: ["goal", "alignment-decision", "execution", "description", "desc", "body", "acceptance", "expected-revision", "mutation-id"],
10297
10285
  claim: ["expected-revision", "mutation-id"],
10298
10286
  release: ["expected-revision", "mutation-id"],
10287
+ link: [],
10299
10288
  ref: []
10300
10289
  };
10301
10290
  var ENTITY_OPTIONS = {
@@ -10400,7 +10389,7 @@ async function pmCommand(parsed, deps = {}) {
10400
10389
  if (!entity) throw new Error(`unknown pm entity "${word}". Try "odla-ai pm bug list" (goal|task|decision|bug).`);
10401
10390
  const requestedAction = parsed.positionals[2] ?? "list";
10402
10391
  const action2 = canonicalAction(requestedAction);
10403
- if (!action2) throw new Error(`unknown pm action "${requestedAction}". Try list|add|get|set|done|comment|comments|rm.`);
10392
+ if (!action2) throw new Error(`unknown pm action "${requestedAction}". Try list|add|get|set|done|link|ref|comment|comments|rm.`);
10404
10393
  assertArgs(parsed, allowedOptions(entity, action2), 4);
10405
10394
  if ((action2 === "ready" || action2 === "claim" || action2 === "release") && entity !== "task") {
10406
10395
  throw new Error(`pm ${action2} is only valid for tasks`);
@@ -10424,6 +10413,8 @@ async function pmCommand(parsed, deps = {}) {
10424
10413
  return pmComments(ctx, entity, requireId2(id, action2));
10425
10414
  case "rm":
10426
10415
  return pmRemove(ctx, entity, requireId2(id, action2));
10416
+ case "link":
10417
+ return pmLink(ctx, entity, requireId2(id, action2));
10427
10418
  case "ref":
10428
10419
  return pmReference(ctx, entity, requireId2(id, action2));
10429
10420
  case "ready":