@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.
@@ -1,7 +1,7 @@
1
1
  #!/usr/bin/env node
2
2
  import {
3
3
  runCli
4
- } from "./chunk-PPSXHZJI.js";
4
+ } from "./chunk-7V7WVTDT.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-SZUWA6EF.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,
@@ -9038,7 +8987,7 @@ Usage:
9038
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]
9039
8988
  odla-ai credentials list [--config odla.config.mjs] [--env dev] [--all] [--json]
9040
8989
  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]
8990
+ odla-ai smoke [--config odla.config.mjs] [--env dev] [--runtime] [--email <odla-account>] [--no-open]
9042
8991
  odla-ai skill install [--dir <project>] [--agent <name>] [--global] [--force]
9043
8992
  odla-ai secrets push --env <env> [--config odla.config.mjs] [--dry-run] [--yes]
9044
8993
  odla-ai secrets set <name> --env <env> (--from-env <NAME>|--stdin) [--email <odla-account>] [--config odla.config.mjs] [--yes]
@@ -9140,7 +9089,7 @@ Commands:
9140
9089
  "provision --live --yes" initializes only the live instance of
9141
9090
  an existing sandbox app and enables every configured service;
9142
9091
  no edit to the dev-first envs list is required.
9143
- 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.
9144
9093
  skill Same installer; --agent accepts all, claude, codex, cursor,
9145
9094
  copilot, gemini, or agents (repeatable or comma-separated).
9146
9095
  secrets Push configured db/o11y secrets into the Worker via wrangler
@@ -9180,12 +9129,11 @@ Safety:
9180
9129
  attempts to open it \u2014 including in CI, SSH, display-less, and agent-driven
9181
9130
  shells. Only --no-open suppresses the attempt. Browser launch is best-effort:
9182
9131
  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
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
9186
9134
  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.
9135
+ with code 75. Rerunning always requests and opens a new code; older clients'
9136
+ persisted pending state is discarded.
9189
9137
  A fresh device handshake requires --email <odla-account> or ODLA_USER_EMAIL.
9190
9138
  The email is a non-secret identity hint: never provide a password or session
9191
9139
  token. It is the email shown by the signed-in odla account \u2014 never infer it