@odla-ai/cli 0.27.11 → 0.27.13

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.
@@ -292,23 +292,30 @@ function handshakeWaitMs(waitSeconds, interactive = process4.stdout.isTTY === tr
292
292
 
293
293
  // src/token.ts
294
294
  async function getDeveloperToken(cfg, options, doFetch, out, grantRequest = {}) {
295
- if (options.token) return options.token;
296
295
  const audience = platformAudience(cfg.platformUrl);
297
- if (process5.env.ODLA_DEV_TOKEN) {
298
- const declared = process5.env.ODLA_DEV_TOKEN_AUDIENCE;
299
- if (declared) {
300
- if (platformAudience(declared) !== audience) throw new Error("ODLA_DEV_TOKEN_AUDIENCE does not match the configured platform");
301
- } else if (audience !== "https://odla.ai") {
302
- throw new Error("ODLA_DEV_TOKEN_AUDIENCE is required for a non-default platform");
303
- }
304
- return process5.env.ODLA_DEV_TOKEN;
305
- }
306
296
  const optionalProjectCapabilities = grantRequest.optionalProjectCapabilities ?? [];
307
297
  const grantIntent = { projectIds: [cfg.app.id], optionalProjectCapabilities };
308
298
  const cached = readJsonFile(cfg.local.tokenFile);
309
- if (cached?.token && cached.platform === audience && (cached.expiresAt ?? 0) > Date.now() + 6e4 && cachedGrantCovers(cached, grantIntent)) {
310
- out.error(`auth: using cached developer token (${displayPath(cfg.local.tokenFile, cfg.rootDir)})`);
311
- return cached.token;
299
+ if (!grantRequest.forceReview) {
300
+ if (options.token) return options.token;
301
+ if (process5.env.ODLA_DEV_TOKEN) {
302
+ const declared = process5.env.ODLA_DEV_TOKEN_AUDIENCE;
303
+ if (declared) {
304
+ if (platformAudience(declared) !== audience) throw new Error("ODLA_DEV_TOKEN_AUDIENCE does not match the configured platform");
305
+ } else if (audience !== "https://odla.ai") {
306
+ throw new Error("ODLA_DEV_TOKEN_AUDIENCE is required for a non-default platform");
307
+ }
308
+ return process5.env.ODLA_DEV_TOKEN;
309
+ }
310
+ if (cached?.token && cached.platform === audience && (cached.expiresAt ?? 0) > Date.now() + 6e4 && cachedGrantCovers(cached, grantIntent)) {
311
+ out.error(`auth: using cached developer token (${displayPath(cfg.local.tokenFile, cfg.rootDir)})`);
312
+ return cached.token;
313
+ }
314
+ } else {
315
+ if (options.token) {
316
+ throw new Error("--request-grant cannot be combined with --token; remove --token so the approved replacement credential can be collected and cached");
317
+ }
318
+ out.error(`auth: requesting fresh owner review for app.manage on exact project "${cfg.app.id}"`);
312
319
  }
313
320
  const ctx = {
314
321
  cfg,
@@ -1372,12 +1379,6 @@ async function pollCalendarConnection(ctx, attemptId) {
1372
1379
  ctx.env
1373
1380
  );
1374
1381
  }
1375
- async function requestCalendarDisconnect(ctx) {
1376
- return parseCalendarStatus(
1377
- await calendarJson(ctx, "/disconnect", { method: "POST", body: JSON.stringify({ purge: false }) }),
1378
- ctx.env
1379
- );
1380
- }
1381
1382
  function parseCalendarStatus(raw, env) {
1382
1383
  const outer = wrapped(raw, "calendar");
1383
1384
  const value2 = record(outer.attempt) ?? record(outer.status) ?? outer;
@@ -1528,6 +1529,16 @@ async function waitForCalendarPoll(milliseconds, signal) {
1528
1529
  });
1529
1530
  }
1530
1531
 
1532
+ // src/human-session.ts
1533
+ async function requireStudioHuman(configPath, action2, destination = "app", env) {
1534
+ const cfg = await loadProjectConfig(configPath);
1535
+ const appEnv = env && cfg.envs.includes(env) ? env : cfg.envs.includes("dev") ? "dev" : cfg.envs[0] ?? "prod";
1536
+ const path = destination === "app" ? `/studio/apps/${encodeURIComponent(cfg.app.id)}/${encodeURIComponent(appEnv)}/settings/app` : `/studio/apps/${encodeURIComponent(cfg.app.id)}/${encodeURIComponent(appEnv)}/${destination}`;
1537
+ throw new Error(
1538
+ `human_session_required: ${action2} requires the signed-in owner in Studio. A CLI device token is an agent credential, so another approval or retry cannot work. ${new URL(path, cfg.platformUrl).href}`
1539
+ );
1540
+ }
1541
+
1531
1542
  // src/calendar.ts
1532
1543
  async function calendarStatus(options) {
1533
1544
  const { ctx, out } = await lifecycleContext(options);
@@ -1561,10 +1572,7 @@ async function applyCalendarBookingPage(ctx, bookingPageUrl, out) {
1561
1572
  }
1562
1573
  async function calendarDisconnect(options) {
1563
1574
  if (!options.yes) throw new Error("calendar disconnect requires --yes");
1564
- const { ctx, out } = await lifecycleContext(options);
1565
- const status = await requestCalendarDisconnect(ctx);
1566
- out.log(`${ctx.env}: calendar disconnected; no calendar data was stored`);
1567
- return status;
1575
+ return requireStudioHuman(options.configPath, "calendar disconnect", "calendar", options.env);
1568
1576
  }
1569
1577
  async function ensureCalendarConnected(ctx, options) {
1570
1578
  const out = options.stdout ?? console;
@@ -1809,11 +1817,11 @@ async function assertTenantAdminAccess(doFetch, cfg, env, token) {
1809
1817
  }
1810
1818
  if (code === "provision_approval_required") {
1811
1819
  throw new Error(
1812
- `${env}: the agent credential does not carry the owner-reviewed app.manage grant required to provision "${cfg.app.id}" (tenant ${tenantId}). The human owner id on the token is accountability, not agent authority. Discard the cached or supplied token and run this command with the current CLI to approve one fresh exact-project provisioning handshake`
1820
+ `${env}: the agent credential does not carry the owner-reviewed app.manage grant required to provision "${cfg.app.id}" (tenant ${tenantId}). The human owner id on the token is accountability, not agent authority. Run "odla-ai provision --request-grant --email <odla-account>" to open one fresh exact-project owner review; do not change app ownership unless the human account itself is not an owner`
1813
1821
  );
1814
1822
  }
1815
1823
  throw new Error(
1816
- `${env}: this credential lacks live app.manage authority for "${cfg.app.id}" (tenant ${tenantId}) \u2014 nothing was minted or written; re-run provision with a fresh owner-approved provision handshake. If the human account is not an owner, an existing owner must add it in signed-in Studio; an agent token cannot repair ownership`
1824
+ `${env}: this credential lacks live app.manage authority for "${cfg.app.id}" (tenant ${tenantId}) \u2014 nothing was minted or written; run "odla-ai provision --request-grant --email <odla-account>" to open a fresh owner review. If the human account is not an owner, an existing owner must add it in signed-in Studio; an agent token cannot repair ownership`
1817
1825
  );
1818
1826
  }
1819
1827
  throw new Error(`${env}: tenant access preflight (${tenantId}) failed: ${res.status} ${await safeText4(res)}`);
@@ -3345,7 +3353,9 @@ async function secretsSetClerkKey(options) {
3345
3353
  if (value2.startsWith("sk_live_") && !PROD_ENV_NAMES2.has(options.env) && !options.yes) {
3346
3354
  throw new Error(`refusing to store an sk_live_ Clerk key for "${options.env}" without --yes (live users would sync into a non-prod tenant)`);
3347
3355
  }
3348
- const token = await getDeveloperToken(cfg, options, doFetch, out);
3356
+ const token = await getDeveloperToken(cfg, options, doFetch, out, {
3357
+ optionalProjectCapabilities: ["app.manage"]
3358
+ });
3349
3359
  const res = await doFetch(`${cfg.dbEndpoint}/admin/apps/${encodeURIComponent(tenantId)}/clerk-secret`, {
3350
3360
  method: "POST",
3351
3361
  headers: { authorization: `Bearer ${token}`, "content-type": "application/json" },
@@ -7238,7 +7248,7 @@ function record6(value2) {
7238
7248
  }
7239
7249
 
7240
7250
  // src/provision.ts
7241
- import { createAppsClient as createAppsClient3, orderAppServices as orderAppServices3, tenantIdFor as tenantIdFor4 } from "@odla-ai/apps";
7251
+ import { AppsError as AppsError2, createAppsClient as createAppsClient3, orderAppServices as orderAppServices3, tenantIdFor as tenantIdFor4 } from "@odla-ai/apps";
7242
7252
  import { putSecret as putSecret2 } from "@odla-ai/ai";
7243
7253
  import process9 from "process";
7244
7254
 
@@ -7459,14 +7469,25 @@ async function provision(options) {
7459
7469
  }
7460
7470
  const doFetch = options.fetch ?? fetch;
7461
7471
  const token = await getDeveloperToken(cfg, options, doFetch, out, {
7462
- optionalProjectCapabilities: ["app.manage"]
7472
+ optionalProjectCapabilities: ["app.manage"],
7473
+ forceReview: options.requestGrant
7463
7474
  });
7464
7475
  const apps = createAppsClient3({ endpoint: cfg.platformUrl, token, fetcher: { fetch: doFetch } });
7465
7476
  const existing = await apps.resolveApp(cfg.app.id);
7466
7477
  if (existing) {
7467
7478
  out.log(`app: ${cfg.app.id} already exists`);
7468
7479
  } else {
7469
- await apps.createApp({ name: cfg.app.name, appId: cfg.app.id });
7480
+ try {
7481
+ await apps.createApp({ name: cfg.app.name, appId: cfg.app.id });
7482
+ } catch (error) {
7483
+ if (error instanceof AppsError2 && error.status === 403) {
7484
+ throw new Error(
7485
+ `app "${cfg.app.id}" does not exist, and this authenticated agent credential has no owner-reviewed app.manage bootstrap grant for that exact id. Run "odla-ai provision --request-grant --email <odla-account>" to open the review URL and continue; developer ownership alone is not agent authority`,
7486
+ { cause: error }
7487
+ );
7488
+ }
7489
+ throw error;
7490
+ }
7470
7491
  out.log(`app: created ${cfg.app.id}`);
7471
7492
  }
7472
7493
  for (const env of cfg.envs) {
@@ -7817,17 +7838,17 @@ async function runHostedSecurity(options) {
7817
7838
  allowNetwork: false
7818
7839
  }
7819
7840
  });
7820
- const report5 = await harness.run(snapshot, { runId: hosted.run.runId, signal: options.signal });
7821
- await writeSecurityArtifacts(output, report5);
7822
- const reportDigest = await securityFingerprint(report5);
7841
+ const report4 = await harness.run(snapshot, { runId: hosted.run.runId, signal: options.signal });
7842
+ await writeSecurityArtifacts(output, report4);
7843
+ const reportDigest = await securityFingerprint(report4);
7823
7844
  await hosted.complete({
7824
7845
  reportDigest,
7825
- coverageStatus: report5.coverageStatus,
7826
- confirmed: report5.metrics.confirmed,
7827
- candidates: report5.metrics.candidates
7846
+ coverageStatus: report4.coverageStatus,
7847
+ confirmed: report4.metrics.confirmed,
7848
+ candidates: report4.metrics.candidates
7828
7849
  }, { signal: options.signal });
7829
- printSummary(options.stdout ?? console, appId, env, hosted.run, report5, output);
7830
- return Object.freeze({ report: report5, run: hosted.run, output });
7850
+ printSummary(options.stdout ?? console, appId, env, hosted.run, report4, output);
7851
+ return Object.freeze({ report: report4, run: hosted.run, output });
7831
7852
  }
7832
7853
  function selectEnv(requested, declared, configPath, rootDir) {
7833
7854
  const env = requested ?? (declared.includes("dev") ? "dev" : declared[0]);
@@ -7853,14 +7874,14 @@ function profileFor(name, maxHuntTasks) {
7853
7874
  if (!Number.isSafeInteger(maxHuntTasks) || maxHuntTasks < 1) throw new Error("maxHuntTasks must be a positive integer");
7854
7875
  return { ...profile, maxHuntTasks };
7855
7876
  }
7856
- function printSummary(out, appId, env, run, report5, output) {
7857
- const complete = report5.coverage.filter((cell) => cell.state === "complete").length;
7877
+ function printSummary(out, appId, env, run, report4, output) {
7878
+ const complete = report4.coverage.filter((cell) => cell.state === "complete").length;
7858
7879
  out.log(`security: ${appId}/${env} run=${run.runId} profile=${run.profileVersion}`);
7859
7880
  out.log(` discovery: ${run.discovery.identity.provider}/${run.discovery.identity.model}`);
7860
7881
  out.log(` validation: ${run.validation.identity.provider}/${run.validation.identity.model}`);
7861
- out.log(` coverage: ${report5.coverageStatus} ${complete}/${report5.coverage.length} blocked=${report5.metrics.blockedCells} shallow=${report5.metrics.shallowCells} unscheduled=${report5.metrics.unscheduledCells} budget_exhausted=${report5.metrics.budgetExhaustedCells}`);
7862
- if (report5.callBudget) out.log(` calls: discovery=${formatBudget(report5.callBudget.discovery)} validation=${formatBudget(report5.callBudget.validation)}`);
7863
- out.log(` findings: confirmed=${report5.metrics.confirmed} needs_reproduction=${report5.metrics.needsReproduction} candidates=${report5.metrics.candidates}`);
7882
+ out.log(` coverage: ${report4.coverageStatus} ${complete}/${report4.coverage.length} blocked=${report4.metrics.blockedCells} shallow=${report4.metrics.shallowCells} unscheduled=${report4.metrics.unscheduledCells} budget_exhausted=${report4.metrics.budgetExhaustedCells}`);
7883
+ if (report4.callBudget) out.log(` calls: discovery=${formatBudget(report4.callBudget.discovery)} validation=${formatBudget(report4.callBudget.validation)}`);
7884
+ out.log(` findings: confirmed=${report4.metrics.confirmed} needs_reproduction=${report4.metrics.needsReproduction} candidates=${report4.metrics.candidates}`);
7864
7885
  out.log(` report: ${resolve9(output, "REPORT.md")}`);
7865
7886
  }
7866
7887
  function formatBudget(usage) {
@@ -8425,7 +8446,7 @@ async function agentCommand(parsed, deps = {}) {
8425
8446
  if (action2 !== "jobs" && action2 !== "retry") {
8426
8447
  throw new Error(`unknown agent action "${action2 ?? ""}". Try "odla-ai agent jobs --json".`);
8427
8448
  }
8428
- assertArgs(parsed, ["config", "env", "state", "limit", "json", "token", "email"], action2 === "jobs" ? 2 : 3);
8449
+ assertArgs(parsed, ["config", "env", "state", "limit", "json", "token"], action2 === "jobs" ? 2 : 3);
8429
8450
  if (action2 === "retry" && (parsed.options.state !== void 0 || parsed.options.limit !== void 0)) {
8430
8451
  throw new Error('--state and --limit are supported only by "agent jobs"');
8431
8452
  }
@@ -8433,17 +8454,17 @@ async function agentCommand(parsed, deps = {}) {
8433
8454
  const { env, tenant } = resolveTenant(cfg, stringOpt(parsed.options.env));
8434
8455
  const doFetch = deps.fetch ?? fetch;
8435
8456
  const out = deps.stdout ?? console;
8436
- const credential2 = await getDeveloperToken(
8437
- cfg,
8438
- {
8439
- configPath: cfg.configPath,
8440
- token: stringOpt(parsed.options.token),
8441
- email: stringOpt(parsed.options.email),
8442
- open: false
8443
- },
8444
- doFetch,
8445
- out
8446
- );
8457
+ const credential2 = stringOpt(parsed.options.token) ?? readCredentials(cfg.local.credentialsFile)?.envs[env]?.dbKey;
8458
+ if (!credential2) {
8459
+ throw new Error(
8460
+ `no ${env} app credential found; run \`odla-ai provision --write-dev-vars --yes\` or pass --token <ODLA_API_KEY>`
8461
+ );
8462
+ }
8463
+ if (credential2.startsWith("odla_dev_")) {
8464
+ throw new Error(
8465
+ "agent job administration requires an app credential (ODLA_API_KEY / odla_sk_\u2026), not a developer device token"
8466
+ );
8467
+ }
8447
8468
  const base = `${cfg.dbEndpoint}/app/${encodeURIComponent(tenant)}/admin/agent-jobs`;
8448
8469
  const headers = { authorization: `Bearer ${credential2}` };
8449
8470
  if (action2 === "retry") {
@@ -8500,53 +8521,14 @@ function errorMessage(body) {
8500
8521
  }
8501
8522
 
8502
8523
  // src/app-export.ts
8503
- import { createWriteStream } from "fs";
8504
- import { Readable } from "stream";
8505
- import { pipeline } from "stream/promises";
8506
8524
  async function appExport(options) {
8507
- const cfg = await loadProjectConfig(options.configPath);
8508
- const out = options.stdout ?? console;
8509
- const doFetch = options.fetch ?? fetch;
8510
- const { tenant } = resolveTenant(cfg, options.env);
8511
- const token = await getDeveloperToken(
8512
- cfg,
8513
- { configPath: cfg.configPath, token: options.token, email: options.email, open: false },
8514
- doFetch,
8515
- out
8516
- );
8517
- const auth = { authorization: `Bearer ${token}` };
8518
- const base = `${cfg.dbEndpoint}/admin/apps/${encodeURIComponent(tenant)}`;
8519
- if (options.fresh) {
8520
- const res = await doFetch(`${base}/export`, { method: "POST", headers: auth });
8521
- const body = await res.json().catch(() => ({}));
8522
- if (!res.ok) throw new Error(`export failed${body.error?.code ? ` (${body.error.code})` : ""}: ${body.error?.message ?? res.status}`);
8523
- }
8524
- const list = await doFetch(`${base}/backups`, { headers: auth });
8525
- if (!list.ok) throw new Error(`couldn't list backups (${list.status})`);
8526
- const { backups } = await list.json();
8527
- const newest = backups[0];
8528
- if (!newest) {
8529
- throw new Error(
8530
- `${tenant} has no backups yet \u2014 run \`odla-ai app export --fresh\` to take one now (nightly snapshots appear after the first day with writes)`
8531
- );
8532
- }
8533
- const download = await doFetch(`${base}/backups/${newest.id}/download`, { headers: auth });
8534
- if (!download.ok || !download.body) throw new Error(`download failed (${download.status})`);
8535
- const file = options.out ?? `${tenant}-${new Date(newest.created_at).toISOString().slice(0, 10)}-tx${newest.max_tx}.jsonl.gz`;
8536
- await pipeline(Readable.fromWeb(download.body), createWriteStream(file));
8537
- if (options.json) out.log(JSON.stringify({ file, backup: newest }, null, 2));
8538
- else {
8539
- out.log(`${tenant}: wrote ${file} (${newest.bytes} bytes, ${newest.kind} snapshot at tx ${newest.max_tx})`);
8540
- out.log(`sha256 ${download.headers.get("x-odla-sha256") ?? newest.sha256}`);
8541
- }
8542
- return { file, backup: newest };
8525
+ return requireStudioHuman(options.configPath, "database export", "database", options.env);
8543
8526
  }
8544
8527
 
8545
8528
  // src/app-import.ts
8546
8529
  import { readFileSync as readFileSync9 } from "fs";
8547
8530
  import {
8548
8531
  buildImportOps,
8549
- importMutationId,
8550
8532
  parseImport,
8551
8533
  planImportChunks
8552
8534
  } from "@odla-ai/db/import";
@@ -8565,7 +8547,6 @@ async function appImport(options) {
8565
8547
  const cfg = await loadProjectConfig(options.configPath);
8566
8548
  const out = options.stdout ?? console;
8567
8549
  const say = options.json ? (line) => out.error(line) : (line) => out.log(line);
8568
- const doFetch = options.fetch ?? fetch;
8569
8550
  const { tenant } = resolveTenant(cfg, options.env);
8570
8551
  const text2 = options.file === "-" ? (options.readStdin ?? (() => readFileSync9(0, "utf8")))() : readFileSync9(options.file, "utf8");
8571
8552
  const { format, sources } = parseImport(text2, options.ns);
@@ -8593,100 +8574,18 @@ ${detail}${more}`);
8593
8574
  if (options.json) out.log(JSON.stringify(result, null, 2));
8594
8575
  return result;
8595
8576
  }
8596
- const token = await getDeveloperToken(
8597
- cfg,
8598
- { configPath: cfg.configPath, token: options.token, email: options.email, open: false },
8599
- doFetch,
8600
- out
8601
- );
8602
- const runId = crypto.randomUUID();
8603
- const url = `${cfg.dbEndpoint}/admin/apps/${encodeURIComponent(tenant)}/transact`;
8604
- for (const chunk of chunks) {
8605
- const res = await doFetch(url, {
8606
- method: "POST",
8607
- headers: { authorization: `Bearer ${token}`, "content-type": "application/json" },
8608
- body: JSON.stringify({ mutationId: importMutationId(runId, chunk.index), ops: chunk.ops })
8609
- });
8610
- const body = await res.json().catch(() => ({}));
8611
- if (!res.ok) {
8612
- const code = body.error?.code ? ` (${body.error.code})` : "";
8613
- throw new Error(
8614
- `chunk ${chunk.index + 1} of ${chunks.length} failed${code}: ${body.error?.message ?? res.status}. ${result.committed} row(s) already committed; fix the input and re-run to import the rest.`
8615
- );
8616
- }
8617
- result.committed += chunk.ops.length;
8618
- if (typeof body.txId === "number") result.txIds.push(body.txId);
8619
- if (body.duplicate) result.duplicate++;
8620
- }
8621
- if (options.json) out.log(JSON.stringify(result, null, 2));
8622
- else {
8623
- const dup = result.duplicate > 0 ? ` (${result.duplicate} chunk(s) were already applied)` : "";
8624
- out.log(`${tenant}: upserted ${result.committed} row(s) in ${chunks.length} transaction(s)${dup}`);
8625
- }
8626
- return result;
8577
+ return requireStudioHuman(options.configPath, "database import", "database", options.env);
8627
8578
  }
8628
8579
 
8629
8580
  // src/app-owners.ts
8630
- var sink = (options) => options.stdout ?? console;
8631
- async function ownersRequest(method, suffix, options, body) {
8632
- const cfg = await loadProjectConfig(options.configPath);
8633
- const doFetch = options.fetch ?? fetch;
8634
- const token = await getDeveloperToken(
8635
- cfg,
8636
- { configPath: cfg.configPath, token: options.token, email: options.email, open: false },
8637
- doFetch,
8638
- sink(options)
8639
- );
8640
- const res = await doFetch(`${cfg.platformUrl}/registry/apps/${encodeURIComponent(cfg.app.id)}/owners${suffix}`, {
8641
- method,
8642
- headers: { authorization: `Bearer ${token}`, "content-type": "application/json" },
8643
- body: body === void 0 ? void 0 : JSON.stringify(body)
8644
- });
8645
- const data = await res.json().catch(() => ({}));
8646
- if (!res.ok) {
8647
- throw new Error(
8648
- `owners ${method} failed${data.error?.code ? ` (${data.error.code})` : ""}: ` + (data.error?.message ?? `registry returned ${res.status}`)
8649
- );
8650
- }
8651
- return data.owners ?? [];
8652
- }
8653
- function report(options, owners, headline) {
8654
- const out = sink(options);
8655
- if (options.json === true) {
8656
- out.log(JSON.stringify(owners, null, 2));
8657
- return;
8658
- }
8659
- if (headline) out.log(headline);
8660
- out.log(`owners (${owners.length}):`);
8661
- for (const o of owners) {
8662
- const name = o.email?.trim() || "Unnamed member";
8663
- out.log(
8664
- ` ${o.primary ? "\u2605" : "\xB7"} ${name} [${o.ownerId}]${o.primary ? " (primary)" : ""}`
8665
- );
8666
- }
8667
- }
8668
8581
  async function ownersList(options) {
8669
- report(options, await ownersRequest("GET", "", options));
8582
+ await requireStudioHuman(options.configPath, "listing app owners", "app");
8670
8583
  }
8671
8584
  async function ownersAdd(email, options) {
8672
- const owners = await ownersRequest("POST", "", options, { email });
8673
- report(
8674
- options,
8675
- owners,
8676
- `added ${email} as a co-owner \u2014 they share full access. They can now run \`odla-ai provision\` to mint their own credentials (no secret sharing).`
8677
- );
8585
+ await requireStudioHuman(options.configPath, `adding ${email} as an app owner`, "app");
8678
8586
  }
8679
8587
  async function ownersRemove(target, options) {
8680
- let ownerId = target;
8681
- if (target.includes("@")) {
8682
- const owners2 = await ownersRequest("GET", "", options);
8683
- const match = owners2.find((o) => o.email?.toLowerCase() === target.toLowerCase());
8684
- if (!match) throw new Error(`no co-owner with email ${target}`);
8685
- if (match.primary) throw new Error("can't remove the primary owner");
8686
- ownerId = match.ownerId;
8687
- }
8688
- const owners = await ownersRequest("DELETE", `/${encodeURIComponent(ownerId)}`, options);
8689
- report(options, owners, `removed ${target}`);
8588
+ await requireStudioHuman(options.configPath, `removing ${target} as an app owner`, "app");
8690
8589
  }
8691
8590
  async function appOwnersCommand(parsed, dependencies = {}) {
8692
8591
  const sub = parsed.positionals[2] ?? "list";
@@ -8718,35 +8617,9 @@ async function appOwnersCommand(parsed, dependencies = {}) {
8718
8617
 
8719
8618
  // src/app-rename.ts
8720
8619
  async function appRename(name, options) {
8721
- const out = options.stdout ?? console;
8722
8620
  const trimmed = name.trim();
8723
8621
  if (!trimmed) throw new Error('"app rename" needs a name \u2014 try `odla-ai app rename "Acme Storefront"`.');
8724
- const cfg = await loadProjectConfig(options.configPath);
8725
- const doFetch = options.fetch ?? fetch;
8726
- const token = await getDeveloperToken(
8727
- cfg,
8728
- { configPath: cfg.configPath, token: options.token, email: options.email, open: false },
8729
- doFetch,
8730
- out
8731
- );
8732
- const res = await doFetch(`${cfg.platformUrl}/registry/apps/${encodeURIComponent(cfg.app.id)}/name`, {
8733
- method: "PUT",
8734
- headers: { authorization: `Bearer ${token}`, "content-type": "application/json" },
8735
- body: JSON.stringify({ name: trimmed })
8736
- });
8737
- const data = await res.json().catch(() => ({}));
8738
- if (!res.ok || !data.app) {
8739
- throw new Error(
8740
- `rename failed${data.error?.code ? ` (${data.error.code})` : ""}: ` + (data.error?.message ?? `registry returned ${res.status}`)
8741
- );
8742
- }
8743
- if (options.json === true) {
8744
- out.log(JSON.stringify(data.app, null, 2));
8745
- return;
8746
- }
8747
- out.log(`renamed ${data.app.appId} \u2192 "${data.app.name}"`);
8748
- out.log("The app id is unchanged, so credentials, tenants, and URLs keep working.");
8749
- out.log(`Update the "name" in ${cfg.configPath} to match.`);
8622
+ await requireStudioHuman(options.configPath, `renaming the app to "${trimmed}"`, "app");
8750
8623
  }
8751
8624
  async function appRenameCommand(parsed, dependencies = {}) {
8752
8625
  assertArgs(parsed, ["config", "token", "email", "json"], parsed.positionals.length);
@@ -8762,172 +8635,23 @@ async function appRenameCommand(parsed, dependencies = {}) {
8762
8635
  }
8763
8636
 
8764
8637
  // src/app-transfer.ts
8765
- function endpointsFor(verb, tenants) {
8766
- const up = { source: tenants.sandbox, target: tenants.live, from: "dev" };
8767
- const down = { source: tenants.live, target: tenants.sandbox, from: "prod" };
8768
- if (verb === "refresh-sandbox") return { ...down, mode: "refresh" };
8769
- return { ...up, mode: "cutover" };
8770
- }
8771
- async function api(cfg, token, path, init, doFetch) {
8772
- const res = await doFetch(`${cfg.dbEndpoint}${path}`, {
8773
- ...init,
8774
- headers: { authorization: `Bearer ${token}`, "content-type": "application/json", ...init?.headers }
8775
- });
8776
- return { status: res.status, body: await res.json().catch(() => ({})) };
8777
- }
8778
- function describe(side) {
8779
- if (!side.exists) return "not provisioned";
8780
- if (side.maxTx === 0) return "empty \u2014 never written";
8781
- const ns = side.namespaces.length === 1 ? "1 namespace" : `${side.namespaces.length} namespaces`;
8782
- const identity = side.identityRows > 0 ? `, ${side.identityRows} identity row(s)` : "";
8783
- return `${ns} \xB7 ${side.triples} value(s) \xB7 tx ${side.maxTx}${identity}`;
8784
- }
8785
- function printPlan2(out, verb, pre, opts) {
8786
- const arrow = verb === "refresh-sandbox" ? "live \u2192 sandbox" : "sandbox \u2192 live";
8787
- out.log(`${verb} (${arrow})`);
8788
- out.log(` from ${pre.source.tenant} ${describe(pre.source)}`);
8789
- out.log(` to ${pre.target.tenant} ${describe(pre.target)}`);
8790
- if (verb === "promote") {
8791
- out.log(" moves schema, rules and gates only \u2014 no rows are copied or removed");
8792
- } else {
8793
- out.log(` ${pre.target.tenant} is REPLACED by ${pre.source.tenant}`);
8794
- out.log(` stays put: ${pre.staysPut.join(", ")}`);
8795
- const identity = opts.includeIdentity ? "INCLUDED (--include-identity)" : `left behind: ${pre.excluded.join(", ")}`;
8796
- out.log(` identity: ${identity}`);
8797
- out.log(` files: ${opts.includeFiles ? "copied (--include-files)" : "not copied"}`);
8798
- }
8799
- for (const blocker of pre.blockers) out.log(` \u2716 ${blocker.code}: ${blocker.message}`);
8800
- }
8801
8638
  async function appTransfer(options) {
8802
8639
  const cfg = await loadProjectConfig(options.configPath);
8803
- const out = options.stdout ?? console;
8804
- const say = options.json ? (line) => out.error(line) : (line) => out.log(line);
8805
- const doFetch = options.fetch ?? fetch;
8806
- const tenants = bothTenants(cfg);
8807
- const route2 = endpointsFor(options.verb, tenants);
8808
- const token = await getDeveloperToken(
8809
- cfg,
8810
- { configPath: cfg.configPath, token: options.token, email: options.email, open: false },
8811
- doFetch,
8812
- out
8813
- );
8814
- const pre = await api(
8815
- cfg,
8816
- token,
8817
- `/admin/apps/${encodeURIComponent(route2.target)}/copy-preflight?from=${route2.from}`,
8818
- void 0,
8819
- doFetch
8820
- );
8821
- if (pre.status !== 200) {
8822
- throw new Error(`pre-flight failed${pre.body.error?.code ? ` (${pre.body.error.code})` : ""}: ${pre.body.error?.message ?? pre.status}`);
8823
- }
8824
- const plan = pre.body;
8825
- printPlan2({ log: say }, options.verb, plan, options);
8826
- if (options.verb === "go-live" && !plan.targetEmpty) {
8827
- throw new Error(
8828
- `${route2.target} already has data \u2014 go-live has already happened. Use \`odla-ai app promote --yes\` to push schema and rules, or \`odla-ai app refresh-sandbox --yes\` to pull live back down.`
8829
- );
8830
- }
8831
- if (plan.blockers.length > 0) throw new Error(`cannot ${options.verb}: ${plan.blockers.map((b) => b.message).join("; ")}`);
8832
- if (options.dryRun || options.yes !== true) {
8833
- say(`nothing written (${options.dryRun ? "--dry-run" : "no --yes"})`);
8834
- if (options.json) out.log(JSON.stringify(plan, null, 2));
8835
- return { ok: true, plan };
8836
- }
8837
- if (options.verb === "promote") {
8838
- const res2 = await api(
8839
- cfg,
8840
- token,
8841
- `/admin/apps/${encodeURIComponent(route2.target)}/promote-definitions`,
8842
- { method: "POST", body: JSON.stringify({ from: "dev" }) },
8843
- doFetch
8844
- );
8845
- if (res2.status !== 200) throw new Error(`promote failed${res2.body.error?.code ? ` (${res2.body.error.code})` : ""}: ${res2.body.error?.message ?? res2.status}`);
8846
- if (options.json) out.log(JSON.stringify(res2.body, null, 2));
8847
- else say(`${route2.target}: promoted ${(res2.body.applied ?? []).join(", ")}`);
8848
- return { ok: true, plan, result: res2.body };
8849
- }
8850
- const res = await api(
8851
- cfg,
8852
- token,
8853
- `/admin/apps/${encodeURIComponent(route2.target)}/copy-db`,
8854
- {
8855
- method: "POST",
8856
- body: JSON.stringify({
8857
- from: route2.from,
8858
- mode: route2.mode,
8859
- ...options.includeIdentity ? { includeUsers: true } : {},
8860
- ...options.includeFiles ? { includeFiles: true } : {}
8861
- })
8862
- },
8863
- doFetch
8864
- );
8865
- if (res.status !== 200) throw new Error(`${options.verb} failed${res.body.error?.code ? ` (${res.body.error.code})` : ""}: ${res.body.error?.message ?? res.status}`);
8866
- say(`${route2.target}: replaced from ${route2.source} (tx ${res.body.destination?.maxTx}, epoch ${res.body.destination?.epoch}) \u2014 connected clients resync automatically`);
8867
- if (options.includeFiles) await copyFiles(cfg, token, route2, doFetch, out);
8868
- if (options.json) out.log(JSON.stringify(res.body, null, 2));
8869
- return { ok: true, plan, result: res.body };
8870
- }
8871
- async function copyFiles(cfg, token, route2, doFetch, out) {
8872
- for (let attempt = 1; attempt <= 20; attempt++) {
8873
- const res = await api(
8874
- cfg,
8875
- token,
8876
- `/admin/apps/${encodeURIComponent(route2.target)}/copy-files`,
8877
- { method: "POST", body: JSON.stringify({ from: route2.from }) },
8878
- doFetch
8879
- );
8880
- if (res.status === 200) {
8881
- out.log(`${route2.target}: files copied`);
8882
- return;
8883
- }
8884
- if (!res.body.error?.retry) {
8885
- throw new Error(`file copy failed${res.body.error?.code ? ` (${res.body.error.code})` : ""}: ${res.body.error?.message ?? res.status}`);
8886
- }
8887
- out.log(` files: bounded at attempt ${attempt}, resuming\u2026`);
8888
- }
8889
- throw new Error("file copy did not finish within 20 rounds \u2014 re-run to continue where it left off");
8640
+ bothTenants(cfg);
8641
+ return requireStudioHuman(options.configPath, `app ${options.verb}`, "database");
8890
8642
  }
8891
8643
 
8892
8644
  // src/app-lifecycle.ts
8893
- async function lifecycleCall(action2, options) {
8894
- const cfg = await loadProjectConfig(options.configPath);
8895
- const out = options.stdout ?? console;
8896
- const doFetch = options.fetch ?? fetch;
8897
- const token = await getDeveloperToken(
8898
- cfg,
8899
- { configPath: cfg.configPath, token: options.token, email: options.email, open: false },
8900
- doFetch,
8901
- out
8902
- );
8903
- const res = await doFetch(`${cfg.platformUrl}/registry/apps/${encodeURIComponent(cfg.app.id)}/${action2}`, {
8904
- method: "POST",
8905
- headers: { authorization: `Bearer ${token}`, "content-type": "application/json" }
8906
- });
8907
- const body = await res.json().catch(() => ({}));
8908
- if (!res.ok || !body.ok) {
8909
- throw new Error(`${action2} failed${body.error?.code ? ` (${body.error.code})` : ""}: ${body.error?.message ?? `registry returned ${res.status}`}`);
8910
- }
8911
- return body;
8912
- }
8913
8645
  async function appArchive(options) {
8914
8646
  if (options.yes !== true) {
8915
8647
  throw new Error(
8916
8648
  "app archive suspends EVERY environment: API keys stop working and all services refuse requests until restored. All data is retained. Pass --yes to proceed."
8917
8649
  );
8918
8650
  }
8919
- const out = options.stdout ?? console;
8920
- const body = await lifecycleCall("archive", options);
8921
- if (options.json) out.log(JSON.stringify(body, null, 2));
8922
- else if (body.operation?.state === "noop") out.log(`${body.app?.appId}: already archived`);
8923
- else out.log(`${body.app?.appId}: archived \u2014 data retained; run \`odla-ai app restore\` (or use Studio) to bring it back`);
8651
+ await requireStudioHuman(options.configPath, "app archive", "app");
8924
8652
  }
8925
8653
  async function appRestore(options) {
8926
- const out = options.stdout ?? console;
8927
- const body = await lifecycleCall("restore", options);
8928
- if (options.json) out.log(JSON.stringify(body, null, 2));
8929
- else if (body.operation?.state === "noop") out.log(`${body.app?.appId}: already active`);
8930
- else out.log(`${body.app?.appId}: restored \u2014 every service's data plane is live again`);
8654
+ await requireStudioHuman(options.configPath, "app restore", "app");
8931
8655
  }
8932
8656
  async function appCommand(parsed, dependencies = {}) {
8933
8657
  const sub = parsed.positionals[1];
@@ -9563,18 +9287,17 @@ Usage:
9563
9287
  odla-ai calendar status [--env dev] [--email <odla-account>] [--json]
9564
9288
  odla-ai calendar calendars [--env dev] [--email <odla-account>] [--json]
9565
9289
  odla-ai calendar connect [--env dev] [--email <odla-account>] [--no-open] [--yes]
9566
- odla-ai calendar disconnect [--env dev] [--email <odla-account>] --yes
9567
- odla-ai app archive [--config odla.config.mjs] [--email <odla-account>] [--json] --yes
9568
- odla-ai app restore [--config odla.config.mjs] [--email <odla-account>] [--json]
9569
- odla-ai app export [--env dev] [--fresh] [--out <file>] [--email <odla-account>] [--json]
9290
+ odla-ai calendar disconnect [--env dev] --yes [continue in Studio; human session required]
9291
+ odla-ai app archive [--config odla.config.mjs] --yes [continue in Studio; human session required]
9292
+ odla-ai app restore [--config odla.config.mjs] [continue in Studio; human session required]
9293
+ odla-ai app export [--env dev] [continue in Studio; human session required]
9570
9294
  odla-ai app import <file|-> [--env dev] [--ns <namespace>] [--id-field <f>|--key <attr>|--generate-ids] [--dry-run] [--json] --yes
9571
- odla-ai app refresh-sandbox [--include-identity] [--include-files] [--dry-run] [--json] --yes
9572
- odla-ai app go-live [--include-identity] [--include-files] [--dry-run] [--json] --yes
9573
- odla-ai app promote [--dry-run] [--json] --yes
9574
- odla-ai app rename <name> [--config odla.config.mjs] [--email <odla-account>] [--json]
9575
- odla-ai app owners list [--config odla.config.mjs] [--email <odla-account>] [--json]
9576
- odla-ai app owners add <email> [--email <odla-account>] [--json]
9577
- odla-ai app owners remove <email> [--email <odla-account>] [--json]
9295
+ [dry-run is local; writes continue in Studio]
9296
+ odla-ai app refresh-sandbox [continue in Studio; human session required]
9297
+ odla-ai app go-live [continue in Studio; human session required]
9298
+ odla-ai app promote [continue in Studio; human session required]
9299
+ odla-ai app rename <name> [continue in Studio; human session required]
9300
+ odla-ai app owners <list|add|remove> [...] [continue in Studio; human session required]
9578
9301
  odla-ai brand design unpack <bundle.html|-> [--out <dir>] [--json]
9579
9302
  odla-ai pm goal list [--app <id>] [--status <s>] [--q <text>] [--limit <n>] [--offset <n>] [--json]
9580
9303
  odla-ai pm task list [--app <id>] [--column <backlog|ready|doing|review|done>] [--goal <id>] [--assignee <id>] [--q <text>] [--limit <n>] [--offset <n>] [--json]
@@ -9609,8 +9332,8 @@ Usage:
9609
9332
  odla-ai discuss resolve <topic> [--reopen] [--mutation-id <id>]
9610
9333
  odla-ai discuss who --q <text> [--app <id>] [--kinds user,pm:task] [--json]
9611
9334
  odla-ai discuss watch [<topic>] [--cursor <cursor>] [--by <authorId>] [--self <authorId>] [--interval <s>] [--timeout <s>] [--json|--jsonl]
9612
- odla-ai agent jobs [--env dev] [--state pending|running|succeeded|dead_letter] [--limit 50] [--email <email>] [--json]
9613
- odla-ai agent retry <job-id> [--env dev] [--email <email>] [--json]
9335
+ odla-ai agent jobs [--env dev] [--state pending|running|succeeded|dead_letter] [--limit 50] [--token <ODLA_API_KEY>] [--json]
9336
+ odla-ai agent retry <job-id> [--env dev] [--token <ODLA_API_KEY>] [--json]
9614
9337
  odla-ai context show [--context <name>] [--platform https://odla.ai] [--app <id>] [--env prod] [--json]
9615
9338
  odla-ai context list [--json]
9616
9339
  odla-ai context save <name> [--platform <url>] [--app <id>] [--env <name>] [--json]
@@ -9646,8 +9369,8 @@ Usage:
9646
9369
  odla-ai admin ai credential set <provider> [--context <name>] (--from-env <NAME>|--stdin)
9647
9370
  odla-ai admin ai usage [--context <name>] [--app-id <id>] [--env <env>] [--run-id <id>] [--limit <1-500>] [--json]
9648
9371
  odla-ai admin ai audit [--context <name>] [--limit <1-200>] [--json]
9649
- odla-ai security github connect [--repo owner/name] [--env dev] [--email <odla-account>] [--no-open]
9650
- odla-ai security github disconnect --source <id> [--env dev] [--yes]
9372
+ odla-ai security github connect [--repo owner/name] [--env dev] [continue in Studio; human session required]
9373
+ odla-ai security github disconnect --source <id> [--env dev] [continue in Studio; human session required]
9651
9374
  odla-ai security plan [--env dev] [--json]
9652
9375
  odla-ai security sources [--env dev] [--json]
9653
9376
  odla-ai security run --source <id> --plan-digest <sha256:...> --ack-redacted-source [--ref <branch|tag|sha>] [--env dev] [--no-follow]
@@ -9655,7 +9378,7 @@ Usage:
9655
9378
  odla-ai security report <job-id> [--json]
9656
9379
  odla-ai security run [target] --ack-redacted-source [--env dev] [--profile odla] [--fail-on high]
9657
9380
  odla-ai security run [target] --self --ack-redacted-source
9658
- odla-ai provision [--config odla.config.mjs] [--email <odla-account>] [--wait <seconds>] [--dry-run] [--push-secrets] [--rotate-o11y-token] [--write-dev-vars[=path]] [--yes]
9381
+ odla-ai provision [--config odla.config.mjs] [--email <odla-account>] [--request-grant] [--wait <seconds>] [--dry-run] [--push-secrets] [--rotate-o11y-token] [--write-dev-vars[=path]] [--yes]
9659
9382
  odla-ai smoke [--config odla.config.mjs] [--env dev] [--email <odla-account>] [--no-open]
9660
9383
  odla-ai skill install [--dir <project>] [--agent <name>] [--global] [--force]
9661
9384
  odla-ai secrets push --env <env> [--config odla.config.mjs] [--dry-run] [--yes]
@@ -9790,6 +9513,10 @@ Safety:
9790
9513
  The email is a non-secret identity hint: never provide a password or session
9791
9514
  token. The matching account must already exist, be signed in, explicitly
9792
9515
  review the exact code, and finish any current request before claiming another.
9516
+ If provision reports that the current agent principal has no live app.manage
9517
+ grant, run it once with --request-grant. That flag ignores ODLA_DEV_TOKEN and
9518
+ the local cache, prints and opens a fresh exact-project owner-review URL, then
9519
+ continues provisioning with the approved replacement credential.
9793
9520
  Run Code from a GitHub checkout already connected to an app in Studio; an
9794
9521
  odla.config.mjs may select the app explicitly but is not required. Code host
9795
9522
  approval and credential hashes live in odla-ai/db. The host
@@ -10158,7 +9885,7 @@ async function discussWatch(ctx, topicId, parsed) {
10158
9885
  throw new WatchRemoteError(cursor, error);
10159
9886
  }
10160
9887
  if (deadline !== void 0 && now() >= deadline) {
10161
- const result = report2(ctx, parsed, { found: false, cursor: cursor ?? "" });
9888
+ const result = report(ctx, parsed, { found: false, cursor: cursor ?? "" });
10162
9889
  throw new WatchTimeoutError(result.cursor);
10163
9890
  }
10164
9891
  const base = Math.min(intervalMs, 1e3);
@@ -10199,7 +9926,7 @@ async function discussWatch(ctx, topicId, parsed) {
10199
9926
  });
10200
9927
  const posts = topicId ? matching.filter((event) => event.type === "message").map((event) => event.payload) : void 0;
10201
9928
  const topics = topicId ? void 0 : matching.filter((event) => event.type === "activity").map((event) => event.payload);
10202
- return report2(ctx, parsed, {
9929
+ return report(ctx, parsed, {
10203
9930
  found: true,
10204
9931
  cursor,
10205
9932
  events: matching,
@@ -10226,13 +9953,13 @@ async function discussWatch(ctx, topicId, parsed) {
10226
9953
  }
10227
9954
  if (page2.hasMore) continue;
10228
9955
  if (deadline !== void 0 && now() >= deadline) {
10229
- return report2(ctx, parsed, { found: false, cursor });
9956
+ return report(ctx, parsed, { found: false, cursor });
10230
9957
  }
10231
9958
  const wait2 = deadline === void 0 ? intervalMs : Math.min(intervalMs, Math.max(0, deadline - now()));
10232
9959
  await sleep(wait2);
10233
9960
  }
10234
9961
  }
10235
- function report2(ctx, parsed, result) {
9962
+ function report(ctx, parsed, result) {
10236
9963
  if (ctx.json) {
10237
9964
  ctx.out.log(JSON.stringify(result, null, 2));
10238
9965
  } else if (parsed.options.jsonl !== true && result.found) {
@@ -10733,7 +10460,7 @@ function eventLabel(event) {
10733
10460
  const body = event.payload.message?.body?.replace(/\s+/g, " ").trim();
10734
10461
  return body || event.payload.entityId;
10735
10462
  }
10736
- function report3(ctx, parsed, result) {
10463
+ function report2(ctx, parsed, result) {
10737
10464
  if (ctx.json) ctx.out.log(JSON.stringify(result, null, 2));
10738
10465
  else if (parsed.options.jsonl !== true && result.found) {
10739
10466
  for (const event of result.events ?? []) {
@@ -10793,7 +10520,7 @@ async function pmWatch(ctx, parsed) {
10793
10520
  });
10794
10521
  if (consecutiveFailures >= MAX_CONSECUTIVE_FAILURES2) throw error;
10795
10522
  if (deadline !== void 0 && now() >= deadline) {
10796
- return report3(ctx, parsed, { found: false, cursor: cursor ?? "" });
10523
+ return report2(ctx, parsed, { found: false, cursor: cursor ?? "" });
10797
10524
  }
10798
10525
  const backoff = Math.min(
10799
10526
  MAX_BACKOFF_MS2,
@@ -10834,7 +10561,7 @@ async function pmWatch(ctx, parsed) {
10834
10561
  cursor,
10835
10562
  serverTime: current.serverTime
10836
10563
  });
10837
- return report3(ctx, parsed, { found: true, cursor, events: matching });
10564
+ return report2(ctx, parsed, { found: true, cursor, events: matching });
10838
10565
  }
10839
10566
  if (current.events.length > 0) {
10840
10567
  jsonl2(ctx, parsed, {
@@ -10853,7 +10580,7 @@ async function pmWatch(ctx, parsed) {
10853
10580
  }
10854
10581
  if (current.hasMore) continue;
10855
10582
  if (deadline !== void 0 && now() >= deadline) {
10856
- return report3(ctx, parsed, { found: false, cursor });
10583
+ return report2(ctx, parsed, { found: false, cursor });
10857
10584
  }
10858
10585
  await sleep(
10859
10586
  deadline === void 0 ? intervalMs : Math.min(intervalMs, Math.max(0, deadline - now()))
@@ -12044,7 +11771,7 @@ async function assessImpact(ctx, surfaces, all, limit) {
12044
11771
  return out;
12045
11772
  }
12046
11773
  var editHint = (slug, appId) => `odla-ai runbook edit ${slug}${appId === PLATFORM_SCOPE ? "" : ` --app ${appId}`} --note "<what changed>"`;
12047
- function report4(ctx, impacts) {
11774
+ function report3(ctx, impacts) {
12048
11775
  const covered = impacts.filter((i) => i.runbooks.length);
12049
11776
  ctx.out.log(
12050
11777
  `${impacts.length} changed surface${impacts.length === 1 ? "" : "s"}; ${covered.length} covered by a runbook. Reread each one and fix any step this change made wrong.`
@@ -12082,7 +11809,7 @@ async function runbookImpact(ctx, options, deps = {}) {
12082
11809
  }
12083
11810
  const impacts = await assessImpact(ctx, surfaces, options.all, options.limit ?? 4);
12084
11811
  if (ctx.json) return ctx.out.log(JSON.stringify({ base: options.base, impacts }, null, 2));
12085
- report4(ctx, impacts);
11812
+ report3(ctx, impacts);
12086
11813
  }
12087
11814
 
12088
11815
  // src/runbook-lint.ts
@@ -12582,7 +12309,6 @@ async function runbookCommand(parsed, deps = {}) {
12582
12309
  }
12583
12310
 
12584
12311
  // src/security-command-context.ts
12585
- import { createInterface } from "readline/promises";
12586
12312
  async function hostedSecurityContext(parsed, dependencies) {
12587
12313
  const configPath = stringOpt(parsed.options.config) ?? "odla.config.mjs";
12588
12314
  const cfg = await loadProjectConfig(configPath);
@@ -12601,21 +12327,11 @@ async function hostedSecurityContext(parsed, dependencies) {
12601
12327
  cfg,
12602
12328
  { configPath, email: stringOpt(parsed.options.email), open, openApprovalUrl: dependencies.openUrl },
12603
12329
  doFetch,
12604
- stdout
12330
+ stdout,
12331
+ { optionalProjectCapabilities: ["app.manage"] }
12605
12332
  );
12606
12333
  return { platform, token, appId: cfg.app.id, env, fetch: doFetch, stdout };
12607
12334
  }
12608
- async function interactiveConfirmation(message2, dependencies) {
12609
- if (dependencies.confirm) return dependencies.confirm(message2);
12610
- if (!process.stdin.isTTY || !process.stdout.isTTY) return false;
12611
- const prompt = createInterface({ input: process.stdin, output: process.stdout });
12612
- try {
12613
- const answer = await prompt.question(`${message2} [y/N] `);
12614
- return /^y(?:es)?$/i.test(answer.trim());
12615
- } finally {
12616
- prompt.close();
12617
- }
12618
- }
12619
12335
  function requiredSecurityPositional(parsed, index, label) {
12620
12336
  const value2 = parsed.positionals[index];
12621
12337
  if (!value2) throw new Error(`${label} is required`);
@@ -12681,31 +12397,31 @@ function printHostedJob(out, job, platform, appId) {
12681
12397
  url.searchParams.set("job", job.jobId);
12682
12398
  out.log(` Studio: ${url.toString()}`);
12683
12399
  }
12684
- function printHostedReport(out, report5) {
12685
- out.log(`security report ${report5.jobId}: ${report5.repository}@${report5.revision}`);
12686
- out.log(` coverage: ${report5.coverageStatus} cells=${report5.metrics.coverageCells} shallow=${report5.metrics.shallowCells} blocked=${report5.metrics.blockedCells} unscheduled=${report5.metrics.unscheduledCells} budget_exhausted=${report5.metrics.budgetExhaustedCells}`);
12687
- out.log(` findings: confirmed=${report5.metrics.confirmed} needs_reproduction=${report5.metrics.needsReproduction} candidates=${report5.metrics.candidates} rejected=${report5.metrics.rejected}`);
12688
- out.log(` discovery: ${report5.provenance.discovery?.provider ?? "unknown"}/${report5.provenance.discovery?.model ?? "unknown"}`);
12689
- out.log(` validation: ${report5.provenance.validation?.provider ?? "unknown"}/${report5.provenance.validation?.model ?? "unknown"} independent=${String(report5.provenance.independentValidation)}`);
12690
- for (const finding of report5.findings) {
12400
+ function printHostedReport(out, report4) {
12401
+ out.log(`security report ${report4.jobId}: ${report4.repository}@${report4.revision}`);
12402
+ out.log(` coverage: ${report4.coverageStatus} cells=${report4.metrics.coverageCells} shallow=${report4.metrics.shallowCells} blocked=${report4.metrics.blockedCells} unscheduled=${report4.metrics.unscheduledCells} budget_exhausted=${report4.metrics.budgetExhaustedCells}`);
12403
+ out.log(` findings: confirmed=${report4.metrics.confirmed} needs_reproduction=${report4.metrics.needsReproduction} candidates=${report4.metrics.candidates} rejected=${report4.metrics.rejected}`);
12404
+ out.log(` discovery: ${report4.provenance.discovery?.provider ?? "unknown"}/${report4.provenance.discovery?.model ?? "unknown"}`);
12405
+ out.log(` validation: ${report4.provenance.validation?.provider ?? "unknown"}/${report4.provenance.validation?.model ?? "unknown"} independent=${String(report4.provenance.independentValidation)}`);
12406
+ for (const finding of report4.findings) {
12691
12407
  const location = finding.locations[0];
12692
12408
  out.log(` [${finding.severity}] ${finding.title}${location ? ` (${location.path}:${location.line})` : ""} \xB7 ${finding.disposition}`);
12693
12409
  }
12694
- for (const limitation of report5.limitations) out.log(` limitation: ${limitation}`);
12410
+ for (const limitation of report4.limitations) out.log(` limitation: ${limitation}`);
12695
12411
  }
12696
- function enforceHostedReportGate(report5, parsed, out, emitSuccess) {
12412
+ function enforceHostedReportGate(report4, parsed, out, emitSuccess) {
12697
12413
  const failOn = hostedSeverity(stringOpt(parsed.options["fail-on"]) ?? "high", "--fail-on");
12698
12414
  const candidateValue = parsed.options["fail-on-candidates"];
12699
12415
  const failOnCandidates = candidateValue === false ? void 0 : hostedSeverity(stringOpt(candidateValue) ?? "critical", "--fail-on-candidates");
12700
12416
  const atOrAbove = (severity, threshold) => HOSTED_SEVERITIES.indexOf(severity) >= HOSTED_SEVERITIES.indexOf(threshold);
12701
- const confirmed = report5.findings.filter((finding) => finding.disposition === "confirmed" && atOrAbove(finding.severity, failOn));
12702
- const leads = failOnCandidates ? report5.findings.filter((finding) => finding.disposition !== "confirmed" && atOrAbove(finding.severity, failOnCandidates)) : [];
12703
- const incomplete = report5.coverageStatus !== "complete" && parsed.options["allow-incomplete"] !== true;
12417
+ const confirmed = report4.findings.filter((finding) => finding.disposition === "confirmed" && atOrAbove(finding.severity, failOn));
12418
+ const leads = failOnCandidates ? report4.findings.filter((finding) => finding.disposition !== "confirmed" && atOrAbove(finding.severity, failOnCandidates)) : [];
12419
+ const incomplete = report4.coverageStatus !== "complete" && parsed.options["allow-incomplete"] !== true;
12704
12420
  if (confirmed.length || leads.length || incomplete) {
12705
- throw new Error(`hosted security gate failed: ${confirmed.length} confirmed >= ${failOn}; ${leads.length} leads >= ${failOnCandidates ?? "disabled"}${incomplete ? `; coverage ${report5.coverageStatus}` : ""}`);
12421
+ throw new Error(`hosted security gate failed: ${confirmed.length} confirmed >= ${failOn}; ${leads.length} leads >= ${failOnCandidates ?? "disabled"}${incomplete ? `; coverage ${report4.coverageStatus}` : ""}`);
12706
12422
  }
12707
12423
  if (emitSuccess) {
12708
- out.log(`security gate passed: 0 confirmed >= ${failOn}; 0 leads >= ${failOnCandidates ?? "disabled"}; coverage ${report5.coverageStatus}. This is not proof that the application is secure.`);
12424
+ out.log(`security gate passed: 0 confirmed >= ${failOn}; 0 leads >= ${failOnCandidates ?? "disabled"}; coverage ${report4.coverageStatus}. This is not proof that the application is secure.`);
12709
12425
  }
12710
12426
  }
12711
12427
  function printHostedSecurityPlanRoute(out, label, route2) {
@@ -12809,13 +12525,13 @@ async function runSourceSecurityCommand(parsed, dependencies, sourceId) {
12809
12525
  }
12810
12526
  throw new Error(`hosted security job ${result.jobId} ended ${result.status}${result.errorCode ? `: ${result.errorCode}` : ""}`);
12811
12527
  }
12812
- const report5 = await getHostedSecurityReport({ ...context, jobId: result.jobId });
12528
+ const report4 = await getHostedSecurityReport({ ...context, jobId: result.jobId });
12813
12529
  if (parsed.options.json === true) {
12814
- context.stdout.log(JSON.stringify({ plan, intent: preview.intent, job: result, report: report5 }, null, 2));
12530
+ context.stdout.log(JSON.stringify({ plan, intent: preview.intent, job: result, report: report4 }, null, 2));
12815
12531
  } else {
12816
- printHostedReport(context.stdout, report5);
12532
+ printHostedReport(context.stdout, report4);
12817
12533
  }
12818
- enforceHostedReportGate(report5, parsed, context.stdout, parsed.options.json !== true);
12534
+ enforceHostedReportGate(report4, parsed, context.stdout, parsed.options.json !== true);
12819
12535
  }
12820
12536
  async function runLocalSecurityCommand(parsed, dependencies) {
12821
12537
  if (parsed.options.source === true) {
@@ -12876,19 +12592,20 @@ async function runLocalSecurityCommand(parsed, dependencies) {
12876
12592
  cfg,
12877
12593
  { configPath, email: stringOpt(parsed.options.email), open, openApprovalUrl: dependencies.openUrl },
12878
12594
  doFetch,
12879
- out
12595
+ out,
12596
+ { optionalProjectCapabilities: ["app.manage"] }
12880
12597
  );
12881
12598
  }
12882
12599
  });
12883
12600
  enforceLocalGate(result.report, parsed);
12884
12601
  }
12885
- function enforceLocalGate(report5, parsed) {
12602
+ function enforceLocalGate(report4, parsed) {
12886
12603
  const failOn = severityOpt(stringOpt(parsed.options["fail-on"]) ?? "high", "--fail-on");
12887
12604
  const candidateValue = parsed.options["fail-on-candidates"];
12888
12605
  const failOnCandidates = candidateValue === false ? void 0 : severityOpt(stringOpt(candidateValue) ?? "critical", "--fail-on-candidates");
12889
- const confirmed = findingsAtOrAbove(report5, failOn);
12890
- const leads = failOnCandidates ? findingsAtOrAbove(report5, failOnCandidates, true).filter((finding) => finding.disposition !== "confirmed") : [];
12891
- const incomplete = report5.coverageStatus === "incomplete" && parsed.options["allow-incomplete"] !== true;
12606
+ const confirmed = findingsAtOrAbove(report4, failOn);
12607
+ const leads = failOnCandidates ? findingsAtOrAbove(report4, failOnCandidates, true).filter((finding) => finding.disposition !== "confirmed") : [];
12608
+ const incomplete = report4.coverageStatus === "incomplete" && parsed.options["allow-incomplete"] !== true;
12892
12609
  if (confirmed.length || leads.length || incomplete) {
12893
12610
  throw new Error(`hosted security gate failed: ${confirmed.length} confirmed >= ${failOn}; ${leads.length} leads >= ${failOnCandidates ?? "disabled"}${incomplete ? "; coverage incomplete" : ""}`);
12894
12611
  }
@@ -12927,9 +12644,9 @@ async function securityCommand(parsed, dependencies) {
12927
12644
  assertArgs(parsed, ["config", "env", "platform", "email", "open", "json"], 3);
12928
12645
  const jobId = requiredSecurityPositional(parsed, 2, "job id");
12929
12646
  const context = await hostedSecurityContext(parsed, dependencies);
12930
- const report5 = await getHostedSecurityReport({ ...context, jobId });
12931
- if (parsed.options.json === true) context.stdout.log(JSON.stringify(report5, null, 2));
12932
- else printHostedReport(context.stdout, report5);
12647
+ const report4 = await getHostedSecurityReport({ ...context, jobId });
12648
+ if (parsed.options.json === true) context.stdout.log(JSON.stringify(report4, null, 2));
12649
+ else printHostedReport(context.stdout, report4);
12933
12650
  return;
12934
12651
  }
12935
12652
  if (sub !== "run") {
@@ -12943,35 +12660,24 @@ async function githubSecurityCommand(parsed, dependencies) {
12943
12660
  const action2 = parsed.positionals[2];
12944
12661
  if (action2 === "disconnect") {
12945
12662
  assertArgs(parsed, ["config", "env", "platform", "source", "email", "open", "yes"], 3);
12946
- const context2 = await hostedSecurityContext(parsed, dependencies);
12947
12663
  const sourceId = requiredString(parsed.options.source, "--source");
12948
- const confirmed = parsed.options.yes === true || await interactiveConfirmation(
12949
- `Disconnect GitHub security source ${sourceId} from ${context2.appId}/${context2.env}?`,
12950
- dependencies
12664
+ return requireStudioHuman(
12665
+ stringOpt(parsed.options.config) ?? "odla.config.mjs",
12666
+ `disconnecting GitHub security source ${sourceId}`,
12667
+ "security",
12668
+ stringOpt(parsed.options.env)
12951
12669
  );
12952
- if (!confirmed) {
12953
- throw new Error("GitHub source disconnect cancelled; pass --yes in a non-interactive shell");
12954
- }
12955
- await disconnectGitHubSecuritySource({ ...context2, sourceId });
12956
- context2.stdout.log(`github: disconnected ${sourceId} from ${context2.appId}/${context2.env}`);
12957
- return;
12958
12670
  }
12959
12671
  if (action2 !== "connect") {
12960
12672
  throw new Error('unknown security github command. Try "odla-ai security github connect".');
12961
12673
  }
12962
12674
  assertArgs(parsed, ["config", "env", "platform", "repo", "email", "open"], 3);
12963
- const context = await hostedSecurityContext(parsed, dependencies);
12964
- const repository = stringOpt(parsed.options.repo) ?? await inferGitHubRepository(process.cwd(), dependencies.readGitOrigin).catch(() => void 0);
12965
- const connection = await connectGitHubSecuritySource({
12966
- ...context,
12967
- ...repository === void 0 ? {} : { repository },
12968
- open: parsed.options.open !== false,
12969
- openInstallUrl: dependencies.openUrl ?? openUrl,
12970
- wait: dependencies.pollWait,
12971
- stdout: context.stdout
12972
- });
12973
- context.stdout.log(`github: connected ${connection.repository ?? repository ?? "app repository"} (${connection.sourceId ?? "source pending"})`);
12974
- context.stdout.log("github: odla.ai stores the installation; no PAT or GitHub token is written locally");
12675
+ await requireStudioHuman(
12676
+ stringOpt(parsed.options.config) ?? "odla.config.mjs",
12677
+ "connecting the GitHub security repository",
12678
+ "security",
12679
+ stringOpt(parsed.options.env)
12680
+ );
12975
12681
  }
12976
12682
  async function listSecuritySources(parsed, dependencies) {
12977
12683
  assertArgs(parsed, ["config", "env", "platform", "email", "open", "json"], 2);
@@ -13112,6 +12818,7 @@ async function provisionCommand(parsed, dependencies) {
13112
12818
  "write-credentials",
13113
12819
  "write-dev-vars",
13114
12820
  "token",
12821
+ "request-grant",
13115
12822
  "email",
13116
12823
  "open",
13117
12824
  "wait",
@@ -13127,6 +12834,7 @@ async function provisionCommand(parsed, dependencies) {
13127
12834
  writeCredentials: parsed.options["write-credentials"] !== false,
13128
12835
  writeDevVars: typeof writeDevVars2 === "string" ? writeDevVars2 : writeDevVars2 === true,
13129
12836
  token: stringOpt(parsed.options.token),
12837
+ requestGrant: parsed.options["request-grant"] === true,
13130
12838
  email: stringOpt(parsed.options.email),
13131
12839
  open: parsed.options.open === false ? false : parsed.options.open === true ? true : void 0,
13132
12840
  wait: numberOpt(parsed.options.wait, "--wait"),
@@ -13222,4 +12930,4 @@ export {
13222
12930
  exitCodeFor,
13223
12931
  runCli
13224
12932
  };
13225
- //# sourceMappingURL=chunk-Y7WFLSTN.js.map
12933
+ //# sourceMappingURL=chunk-3YSCRXPF.js.map