@synap-core/cli 1.13.1-next.20261102.ec3d166 → 1.13.1-next.20261102.f2ce919

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.
@@ -40,7 +40,7 @@ import { bundledTemplatesVersion } from "../lib/bundle-version.js";
40
40
  import { HubNetworkError } from "../lib/hub-client.js";
41
41
  import { checksFromStandings, fetchPackageStandings, StandingsUnavailableError, } from "../lib/package-updates.js";
42
42
  import { podAdminOrigin } from "../lib/safe-open.js";
43
- import { parseDataChanges, dataChangesFromRefusal, consentNeedsPerson, dataChangeRefusalMessage, printDataChanges, parseAcceptList, decideConsent, consentRerunCommand, consentRefusalLines, lifecycleNotice, overlayNotice, overlayNeedsAttention, } from "../lib/template-lifecycle.js";
43
+ import { parseDataChanges, printDataChanges, parseAcceptList, decideConsent, consentRerunCommand, lifecycleNotice, overlayNotice, overlayNeedsAttention, } from "../lib/template-lifecycle.js";
44
44
  // Same longer budget launch uses — an apply writes profiles/views/entities.
45
45
  const APPLY_TIMEOUT_MS = 120_000;
46
46
  /** `--timeout <seconds>` → ms, falling back to `APPLY_TIMEOUT_MS` when unset/invalid. */
@@ -385,8 +385,8 @@ function printSeedOutcomes(dependencies, seedSummary) {
385
385
  log.hint(`${d.slug}: failed${d.seedOutcome.error ? ` (${d.seedOutcome.error})` : ""}`);
386
386
  }
387
387
  }
388
- // ── Apply-outcome honesty — shared by `marketInstall` + `applyOnePackage` ───
389
- // Both hit the same `/packages/apply` door and must render the SAME honest
388
+ // ── Apply-outcome honesty — `marketInstall`'s reading of `/packages/apply` ───
389
+ // Every install reads the same `/packages/apply` answer and must render the SAME honest
390
390
  // verdict from its response: a partial capability-seed failure, a version
391
391
  // stamp that silently didn't take, or a compose-overlay result are never a
392
392
  // plain "✓ success" — in the human text OR in `--json`.
@@ -439,8 +439,8 @@ export function podApplyState(res) {
439
439
  return null;
440
440
  }
441
441
  /**
442
- * The verdict of one `/packages/apply` response — the ONE reader both
443
- * `marketInstall` and `applyOnePackage` call. Layer 2's outcome comes from the
442
+ * The verdict of one `/packages/apply` response — the ONE reader
443
+ * `marketInstall` calls. Layer 2's outcome comes from the
444
444
  * pod's own state (`podApplyState`): an apply the pod says is unfinished is
445
445
  * never "updated", and never a stamp contradiction (the pod held the version
446
446
  * on purpose — redeploying fixes nothing).
@@ -459,12 +459,11 @@ export function classifyApplyResponse(entry, res, ctx) {
459
459
  return classifyApplyResult(entry, res.workspace, res.dependencies, res.seedSummary, ctx);
460
460
  }
461
461
  /**
462
- * Classify one `/packages/apply` response into the honest verdict both
463
- * `marketInstall` and `applyOnePackage` print (and both put in `--json`).
462
+ * Classify one `/packages/apply` response into the honest verdict
463
+ * `marketInstall` prints (and puts in `--json`).
464
464
  * `wasUnstamped` = the slug was already installed with no recorded version
465
465
  * BEFORE this call (only `marketInstall` can detect this today — see its
466
- * `fetchInstalledTemplates` call; `applyOnePackage` only ever re-applies
467
- * already-versioned slugs, so it always passes `false`).
466
+ * `fetchInstalledTemplates` call).
468
467
  */
469
468
  export function classifyApplyResult(entry, ws, dependencies, seedSummary, ctx) {
470
469
  // A post-workspace LAYER failure counts the same as a seed failure: the
@@ -519,7 +518,7 @@ export function deferredPlacementLines(res) {
519
518
  export function heldVersionLine(res) {
520
519
  return typeof res.templateApplyWarning === "string" ? res.templateApplyWarning : null;
521
520
  }
522
- /** Render a `classifyApplyResult` verdict — the ONE honest-text door for both `marketInstall` and `applyOnePackage`. */
521
+ /** Render a `classifyApplyResult` verdict — the ONE honest-text door of `marketInstall`. */
523
522
  export function printApplyVerdict(entry, verdict) {
524
523
  switch (verdict.status) {
525
524
  case "installed":
@@ -1263,24 +1262,49 @@ function stampVersion(pkg, slug, version) {
1263
1262
  return { ...pkg, _meta: { ...meta, slug, ...(version ? { version } : {}) } };
1264
1263
  }
1265
1264
  /**
1266
- * The slugs with an update that `synap market update <slug>` can apply (a
1267
- * kind with a CLI updater). Next-step suggestions use it, so they never offer
1268
- * a command that would only refuse.
1265
+ * The slugs with an update that `synap market update <slug>` can apply. Every
1266
+ * installed kind (space, capability, view, skill, automation) updates through
1267
+ * the pod's ONE update door (`POST /package-updates/{slug}/apply`), so every
1268
+ * slug with an update is offered; what only the person can do is said by the
1269
+ * pod at apply time (`NEEDS_PERSON`), never guessed here. Next-step
1270
+ * suggestions use it.
1269
1271
  */
1270
1272
  export function cliUpdatableSlugs(checks) {
1271
- return checks.filter((c) => c.updateAvailable && updateDoorFor(c.kind) !== null).map((c) => c.slug);
1273
+ return checks.filter((c) => c.updateAvailable).map((c) => c.slug);
1272
1274
  }
1273
- export function updateDoorFor(kind) {
1274
- switch (kind ?? "workspace") {
1275
- case "workspace":
1276
- return "packages-apply";
1277
- case "view":
1278
- case "skill":
1279
- case "automation":
1280
- return "configs-reconcile";
1281
- default:
1282
- return null;
1275
+ /** The status of an update left for the person to apply in pod admin (not a failure). */
1276
+ export const APPLY_IN_POD_ADMIN = "apply-in-pod-admin";
1277
+ /**
1278
+ * Where the person finishes ONE package's update: pod admin's review page for
1279
+ * it (`/marketplace/updates/<slug>`), when the pod-admin origin derives from
1280
+ * the pod's; else the pod's own link (`reviewUrl`, pod-relative
1281
+ * `/open/update/<slug>`, which the pod sends on to pod admin); else the
1282
+ * Updates tab's words.
1283
+ */
1284
+ export function packageReviewLink(podUrl, slug, reviewUrl) {
1285
+ const admin = podAdminOrigin(podUrl);
1286
+ if (admin)
1287
+ return `${admin}/marketplace/updates/${encodeURIComponent(slug)}`;
1288
+ if (typeof reviewUrl === "string" && reviewUrl.startsWith("/"))
1289
+ return `${podUrl.replace(/\/+$/, "")}${reviewUrl}`;
1290
+ if (typeof reviewUrl === "string" && /^https?:\/\//.test(reviewUrl))
1291
+ return reviewUrl;
1292
+ return undefined;
1293
+ }
1294
+ /** The one row for an update only the person can finish: on pod admin, not a failure. */
1295
+ function applyInPodAdminResult(c, podUrl, json, pod = {}) {
1296
+ const link = podUrl ? packageReviewLink(podUrl, c.slug, pod.reviewUrl) : undefined;
1297
+ if (!json) {
1298
+ log.warn(`${c.slug} is a ${c.kind ?? "workspace"} — to apply in pod admin${pod.why ? `: ${pod.why}` : "."} Nothing was written.`);
1299
+ log.hint(link ? `Apply it yourself: ${link}` : podUrl ? marketUpdatesDoor(podUrl) : "Apply it yourself from pod admin → Marketplace → Updates.");
1283
1300
  }
1301
+ return {
1302
+ slug: c.slug,
1303
+ outcome: APPLY_IN_POD_ADMIN,
1304
+ status: APPLY_IN_POD_ADMIN,
1305
+ warnings: [],
1306
+ ...(link ? { reviewUrl: link } : {}),
1307
+ };
1284
1308
  }
1285
1309
  /**
1286
1310
  * Per-installed-slug drift check. SERVER-FIRST: when the pod runs the
@@ -1345,124 +1369,111 @@ export function dedupeBySlug(checks) {
1345
1369
  const seen = new Set();
1346
1370
  return checks.filter((c) => (seen.has(c.slug) ? false : (seen.add(c.slug), true)));
1347
1371
  }
1372
+ /** The pod's typed refusals of an update (`services/package-updates/refusals.ts`). */
1373
+ const NEEDS_THE_PERSON = new Set(["NEEDS_PERSON", "CHOICES_NEEDED"]);
1348
1374
  /**
1349
- * Apply one package via the SAME door `marketInstall` uses
1350
- * (`POST /packages/apply`) — for an already-installed slug this re-runs the
1351
- * pod's idempotent-create path, which reconciles when the pod build supports
1352
- * it (see the `applyOnePackage` caveat below for what "created" means here).
1375
+ * Apply ONE installed package's update through the pod's ONE update door,
1376
+ * `POST /package-updates/{slug}/apply`, whatever its kind (space, capability,
1377
+ * view, skill, automation) and whoever holds the key. The pod applies the
1378
+ * rule (backend `services/package-updates/`): a person's apply lands; an
1379
+ * agent key's is filed for the person's approval (`proposed`) and lands when
1380
+ * they approve it; what only the person can do (agree to changes to existing
1381
+ * records, choose for a field both sides changed, change a view) is
1382
+ * `NEEDS_PERSON` / `CHOICES_NEEDED` with that package's review page. An
1383
+ * older pod that refuses an agent here (403, no `reasonCode`) keeps today's
1384
+ * line: to apply in pod admin.
1353
1385
  */
1354
- export async function applyOnePackage(entry, cat, cfg, opts, applyTimeoutMs = APPLY_TIMEOUT_MS, extra = {}) {
1355
- if (cat.lockedSlugs.has(entry.slug)) {
1356
- const tier = cat.tierBySlug.get(entry.slug)?.requiredTier;
1386
+ export async function applyUpdateThroughDoor(c, cfg, opts, applyTimeoutMs = APPLY_TIMEOUT_MS, acceptDataChanges = []) {
1387
+ if (!c.latestVersion) {
1388
+ const error = "the pod named no catalog version to update to";
1357
1389
  if (!opts.json)
1358
- log.warn(`"${entry.name}" requires the ${tier ?? "a higher"} tier — skipping.`);
1359
- return { slug: entry.slug, outcome: "locked", status: "locked", warnings: [] };
1360
- }
1361
- const cpToken = getStoredToken()?.token;
1362
- // Same honest payload as `marketInstall` (see `resolveInstallPayload`).
1363
- const payload = extra.payload !== undefined ? extra.payload : await resolveInstallPayload(entry.slug, cat, cpToken);
1364
- if (!payload) {
1365
- if (!opts.json) {
1366
- log.error(`Couldn't fetch "${entry.name}"'s definition from the control plane.`);
1367
- if (!cat.loggedIn)
1368
- log.hint("It may be private — try: synap login");
1369
- }
1370
- return { slug: entry.slug, outcome: "fetch-failed", status: "fetch-failed", warnings: [] };
1390
+ log.error(`${c.slug} not updated — ${error}.`);
1391
+ return { slug: c.slug, outcome: "failed", status: "failed", warnings: [], error };
1371
1392
  }
1372
- const pkg = extra.acceptDataChanges && extra.acceptDataChanges.length > 0
1373
- ? { ...payload.pkg, acceptDataChanges: extra.acceptDataChanges }
1374
- : payload.pkg;
1375
- const s = opts.json ? null : ora(`Updating ${entry.name}…`).start();
1393
+ const body = {
1394
+ expectedVersion: c.latestVersion,
1395
+ choices: {},
1396
+ ...(acceptDataChanges.length > 0 ? { acceptDataChanges } : {}),
1397
+ };
1398
+ const s = opts.json ? null : ora(`Updating ${c.slug}…`).start();
1376
1399
  try {
1377
- const res = (await hubPost("/packages/apply", pkg, cfg, applyTimeoutMs));
1400
+ const res = (await hubPost(`/package-updates/${encodeURIComponent(c.slug)}/apply`, body, cfg, applyTimeoutMs));
1378
1401
  s?.stop();
1379
- // A key that may not apply a workspace package on its own (a computer
1380
- // key's grant leaves out `workspace.update`) files the update for the
1381
- // person's approval: 202 `{status:"proposed", proposalId, reviewUrl}`.
1382
- // Waiting, never failed — it applies when the person approves it.
1383
- if (writeGovernance(res) === "proposed") {
1384
- const proposalId = res.proposalId ? String(res.proposalId) : undefined;
1385
- const reviewUrl = approvalLink(cfg.podUrl, proposalId, res.reviewUrl);
1402
+ if (res?.status === "proposed") {
1403
+ const proposalIds = Array.isArray(res.proposalIds) ? res.proposalIds.map(String) : [];
1404
+ const links = proposalIds.map((id) => approvalLink(cfg.podUrl, id, undefined) ?? `${cfg.podUrl.replace(/\/+$/, "")}/open/${encodeURIComponent(id)}`);
1386
1405
  if (!opts.json) {
1387
- log.info(`${entry.name} — update filed for your approval (not applied yet).`);
1388
- if (reviewUrl)
1389
- log.hint(`Approve it: ${reviewUrl}`);
1390
- else if (proposalId)
1391
- log.hint(`Approve it: synap open proposal ${proposalId}`);
1406
+ log.info(`${c.slug} — update filed for your approval (not applied yet).`);
1407
+ for (const link of links)
1408
+ log.hint(`Approve it: ${link}`);
1392
1409
  }
1393
1410
  return {
1394
- slug: entry.slug,
1411
+ slug: c.slug,
1395
1412
  outcome: "proposed",
1396
1413
  status: "proposed",
1397
1414
  warnings: [],
1398
- ...(proposalId ? { proposalId } : {}),
1399
- ...(reviewUrl ? { reviewUrl } : {}),
1415
+ ...(proposalIds[0] ? { proposalId: proposalIds[0], proposalIds } : {}),
1416
+ ...(links[0] ? { reviewUrl: links[0] } : {}),
1400
1417
  };
1401
1418
  }
1402
- const ws = res.workspace;
1403
- const dependencies = res.dependencies;
1404
- const seedSummary = res.seedSummary;
1405
- // `wasUnstamped: false` — `applyOnePackage` is only ever reached (from
1406
- // `marketUpdate`) for a slug that already had `updateAvailable: true`,
1407
- // which by construction means it already carried a recorded version. The
1408
- // stamp-contradiction check is therefore a no-op here by design; it's
1409
- // `marketInstall`'s job to catch it on a first-time/unstamped install.
1410
- const verdict = classifyApplyResponse(entry, res, {
1411
- remoteVersionSent: payload.catalogVersion,
1412
- wasUnstamped: false,
1413
- });
1414
- if (!opts.json) {
1415
- printApplyVerdict(entry, verdict);
1416
- printSeedOutcomes(dependencies, seedSummary);
1417
- printLayerOutcomes(ws?.layers);
1418
- for (const line of deferredPlacementLines(res))
1419
- log.info(line);
1419
+ if (res?.status === "applied") {
1420
+ const warning = typeof res.warning === "string" && res.warning ? res.warning : undefined;
1421
+ const receipt = res.receipt;
1422
+ // A field update whose every field stayed yours changed nothing.
1423
+ const unchanged = !!receipt && Array.isArray(receipt.changes) && receipt.changes.length === 0;
1424
+ const status = warning ? "updated-with-failures" : unchanged ? "unchanged" : "updated";
1425
+ if (!opts.json) {
1426
+ if (warning)
1427
+ log.warn(`${c.slug} — updated, not finished: ${warning}`);
1428
+ else
1429
+ log.success(`${c.slug} — ${status}`);
1430
+ }
1431
+ return { slug: c.slug, outcome: status, status, warnings: warning ? [warning] : [] };
1420
1432
  }
1421
- return {
1422
- slug: entry.slug,
1423
- outcome: ws?.outcome ?? ws?.status ?? "unknown",
1424
- status: verdict.status,
1425
- warnings: verdict.warnings,
1426
- dependencies,
1427
- seedSummary,
1428
- };
1433
+ const error = "the pod answered with a result this CLI can't read — not assuming it updated";
1434
+ if (!opts.json)
1435
+ log.error(`${c.slug} — ${error}`);
1436
+ return { slug: c.slug, outcome: "failed", status: "failed", warnings: [], error };
1429
1437
  }
1430
1438
  catch (e) {
1431
1439
  s?.stop();
1432
- // The pod refused: data changes are owed that the person has not agreed to.
1433
- // Nothing was written. Show the list and the exact command that agrees.
1434
- const owed = dataChangesFromRefusal(e);
1435
- if (owed) {
1440
+ const refusal = e instanceof HubError ? (e.body ?? {}) : {};
1441
+ const reasonCode = typeof refusal.reasonCode === "string" ? refusal.reasonCode : undefined;
1442
+ const said = e instanceof HubError ? (e.detail ?? e.message) : e instanceof Error ? e.message : String(e);
1443
+ if (reasonCode && NEEDS_THE_PERSON.has(reasonCode)) {
1444
+ return applyInPodAdminResult(c, cfg.podUrl, opts.json, { why: said, reviewUrl: refusal.reviewUrl });
1445
+ }
1446
+ // An older pod refuses an agent's space or capability update here: today's line.
1447
+ if (e instanceof HubError && e.status === 403 && !reasonCode && /\bagent\b/i.test(said)) {
1448
+ return applyInPodAdminResult(c, cfg.podUrl, opts.json);
1449
+ }
1450
+ if (reasonCode === "INVALID_CATALOG_DEFINITION") {
1451
+ const fieldErrors = (refusal.fieldErrors ?? undefined);
1436
1452
  if (!opts.json) {
1437
- const said = consentRefusalLines(entry.name, entry.slug, owed, consentNeedsPerson(e));
1438
- log.warn(said.warn);
1439
- printDataChanges(entry.slug, owed);
1440
- for (const hint of said.hints)
1441
- log.hint(hint);
1453
+ log.error(`${c.slug} not updated — ${said}`);
1454
+ for (const [field, messages] of Object.entries(fieldErrors ?? {}))
1455
+ log.dim(` ${field || "(the definition)"}: ${messages.join("; ")}`);
1442
1456
  }
1443
1457
  return {
1444
- slug: entry.slug,
1445
- outcome: "needs-consent",
1446
- status: "needs-consent",
1458
+ slug: c.slug,
1459
+ outcome: "failed",
1460
+ status: "failed",
1447
1461
  warnings: [],
1448
- pendingDataChanges: owed,
1462
+ error: said,
1463
+ ...(fieldErrors ? { fieldErrors } : {}),
1449
1464
  };
1450
1465
  }
1451
1466
  const timedOut = e instanceof HubNetworkError && e.code === "TIMEOUT";
1452
- const refusal = dataChangeRefusalMessage(e);
1453
1467
  if (!opts.json) {
1454
- log.error(`${entry.name} update failed`);
1455
- if (refusal)
1456
- log.warn(refusal);
1457
- else
1458
- renderHubError(e, { personDoor: marketUpdatesDoor(cfg.podUrl) });
1468
+ log.error(`${c.slug} update failed`);
1469
+ renderHubError(e, { personDoor: marketUpdatesDoor(cfg.podUrl) });
1459
1470
  }
1460
1471
  return {
1461
- slug: entry.slug,
1472
+ slug: c.slug,
1462
1473
  outcome: timedOut ? "timed-out" : "failed",
1463
1474
  status: timedOut ? "timed-out" : "failed",
1464
1475
  warnings: [],
1465
- error: e instanceof Error ? e.message : String(e),
1476
+ error: said,
1466
1477
  };
1467
1478
  }
1468
1479
  }
@@ -1506,54 +1517,6 @@ async function previewDataChanges(entry, cat, cfg) {
1506
1517
  return { payload, error: `the pod's preview failed: ${status}` };
1507
1518
  }
1508
1519
  }
1509
- /** One slug's outcome out of the shared pod-wide config reconcile. */
1510
- async function reconcileOutcome(c, report, opts) {
1511
- let r;
1512
- try {
1513
- r = await report;
1514
- }
1515
- catch (e) {
1516
- if (!opts.json) {
1517
- log.error(`${c.slug} update failed`);
1518
- renderHubError(e);
1519
- }
1520
- return { slug: c.slug, outcome: "failed", status: "failed", warnings: [], error: e instanceof Error ? e.message : String(e) };
1521
- }
1522
- // A report this CLI cannot read is a FAILED read — never "up to date".
1523
- const lists = [r?.updated, r?.ownerOwnedSkipped, r?.skipped, r?.conflicts];
1524
- if (!lists.some(Array.isArray) || lists.some((l) => l !== undefined && !Array.isArray(l))) {
1525
- const error = "the pod's reconcile answered with a report this CLI can't read — not assuming it updated";
1526
- if (!opts.json)
1527
- log.error(`${c.slug} — ${error}`);
1528
- return { slug: c.slug, outcome: "failed", status: "failed", warnings: [], error };
1529
- }
1530
- const mine = (rows) => (rows ?? []).filter((e) => e.packageSlug === c.slug);
1531
- const conflicts = mine(r.conflicts);
1532
- const owned = mine(r.ownerOwnedSkipped);
1533
- const updated = mine(r.updated);
1534
- const warnings = owned.map((e) => `${e.name}: kept your edits — ${e.reason}`);
1535
- if (conflicts.length > 0) {
1536
- const error = conflicts.map((e) => `${e.name}: ${e.reason}`).join("; ");
1537
- if (!opts.json)
1538
- log.error(`${c.slug} update failed — ${error}`);
1539
- return { slug: c.slug, outcome: "failed", status: updated.length > 0 ? "updated-with-failures" : "failed", warnings, error };
1540
- }
1541
- if (!opts.json)
1542
- for (const w of warnings)
1543
- log.warn(`${c.slug} — ${w}`);
1544
- if (updated.length > 0 || owned.length > 0) {
1545
- // Every field it could advance did; a field you edited stays yours (warned above).
1546
- const status = updated.length > 0 ? "updated" : "unchanged";
1547
- if (!opts.json)
1548
- log.success(`${c.slug} — ${status}`);
1549
- return { slug: c.slug, outcome: status, status, warnings };
1550
- }
1551
- // Skipped (cache miss, unresolved definition…) or not reported at all: NOT updated — never "current".
1552
- const error = mine(r.skipped).map((e) => `${e.name}: ${e.reason}`).join("; ") || "the pod's reconcile did not report this package";
1553
- if (!opts.json)
1554
- log.warn(`${c.slug} was not updated — ${error}`);
1555
- return { slug: c.slug, outcome: "not-updated", status: "not-updated", warnings, error };
1556
- }
1557
1520
  export async function marketUpdate(slugsArg, opts) {
1558
1521
  const applyTimeoutMs = parseApplyTimeoutMs(opts.timeout);
1559
1522
  const cat = await buildMarketCatalog();
@@ -1666,8 +1629,9 @@ export async function marketUpdate(slugsArg, opts) {
1666
1629
  process.exit(1);
1667
1630
  }
1668
1631
  for (const c of withUpdates) {
1669
- // The data-change preview is the workspace door's (`/packages/preflight`).
1670
- if (updateDoorFor(c.kind) !== "packages-apply")
1632
+ // Only a space's update changes existing records: the preview of its
1633
+ // data changes is the template door's (`/packages/preflight`).
1634
+ if ((c.kind ?? "workspace") !== "workspace")
1671
1635
  continue;
1672
1636
  const entry = entryBySlug.get(c.slug);
1673
1637
  if (entry)
@@ -1707,6 +1671,8 @@ export async function marketUpdate(slugsArg, opts) {
1707
1671
  }
1708
1672
  log.heading(" Installed packages");
1709
1673
  log.blank();
1674
+ // The slug column fits the longest slug (capped), with at least 2 spaces after it.
1675
+ const slugW = Math.min(Math.max(28, ...checks.map((c) => c.slug.length + 2)), 46);
1710
1676
  for (const c of checks) {
1711
1677
  // ONE label door. This was a hand-rolled second copy that diverged from
1712
1678
  // `installedStatusLabel` in two ways that both LIED: it had no cache-cold
@@ -1717,7 +1683,7 @@ export async function marketUpdate(slugsArg, opts) {
1717
1683
  const label = retiredSlugs.has(c.slug)
1718
1684
  ? chalk.yellow("retired") + chalk.dim(` · synap templates retire ${c.slug}`)
1719
1685
  : installedStatusLabel(c, entryBySlug.get(c.slug)?.isPrivate ?? false, cat.loggedIn);
1720
- console.log(" " + chalk.cyan(c.slug.padEnd(28)) + label);
1686
+ console.log(" " + chalk.cyan(clip(c.slug, slugW - 2).padEnd(slugW)) + label);
1721
1687
  }
1722
1688
  log.blank();
1723
1689
  if (noVersionInfo.length > 0) {
@@ -1764,10 +1730,9 @@ export async function marketUpdate(slugsArg, opts) {
1764
1730
  return;
1765
1731
  }
1766
1732
  if (!shouldApply) {
1767
- // Next steps offer only what this command can run: a capability update
1768
- // (no CLI updater) is the person's act in pod admin, named once below.
1733
+ // Every update applies through the pod's one update door, so every one
1734
+ // is offered; what only the person can do is said when it is applied.
1769
1735
  const steps = FLOW.afterMarketUpdateCheck(cliUpdatableSlugs(withUpdates));
1770
- const personApplies = withUpdates.filter((c) => updateDoorFor(c.kind) === null).map((c) => c.slug);
1771
1736
  if (opts.json) {
1772
1737
  console.log(JSON.stringify({
1773
1738
  checks,
@@ -1775,9 +1740,6 @@ export async function marketUpdate(slugsArg, opts) {
1775
1740
  unknownSlugs,
1776
1741
  applied: [],
1777
1742
  nextSteps: steps,
1778
- ...(personApplies.length > 0
1779
- ? { appliedInPodAdmin: { slugs: personApplies, where: marketUpdatesDoor(cfg.podUrl) } }
1780
- : {}),
1781
1743
  dataChanges: dataChangesJson,
1782
1744
  templateLifecycle: lifecycleJson,
1783
1745
  }, null, 2));
@@ -1787,44 +1749,12 @@ export async function marketUpdate(slugsArg, opts) {
1787
1749
  if ([...previews.values()].some((pv) => (pv.changes?.length ?? 0) > 0)) {
1788
1750
  log.hint("Updates with data changes ask before they run. To agree up front: --accept-data-changes <opKey,…> (or --yes for all listed)");
1789
1751
  }
1790
- if (personApplies.length > 0) {
1791
- log.hint(`${personApplies.join(", ")}: not updated by this command. ${marketUpdatesDoor(cfg.podUrl)}`);
1792
- }
1793
1752
  renderNextSteps(steps);
1794
1753
  return;
1795
1754
  }
1796
1755
  const acceptList = parseAcceptList(opts.acceptDataChanges);
1797
1756
  const results = [];
1798
- // One pod-wide reconcile serves every view/skill/automation update — run it
1799
- // at most once, lazily, and read each slug's outcome from its report.
1800
- let configsReport;
1801
1757
  for (const c of withUpdates) {
1802
- const door = updateDoorFor(c.kind);
1803
- if (door === null) {
1804
- if (!opts.json) {
1805
- // A capability update is a person's act on pod admin (the pod refuses
1806
- // an agent key there); any other kind here has no updater at all.
1807
- log.warn(c.kind === "capability"
1808
- ? `${c.slug} is a capability — it was not updated here. Nothing was written. ${marketUpdatesDoor(cfg.podUrl)}`
1809
- : `${c.slug} is a ${c.kind} — the pod has no per-package update for this kind, so it was not updated. Nothing was written.`);
1810
- }
1811
- results.push({ slug: c.slug, outcome: "no-updater", status: "no-updater", warnings: [] });
1812
- continue;
1813
- }
1814
- if (door === "configs-reconcile") {
1815
- configsReport ??= hubPost("/configs/reconcile", {}, cfg, applyTimeoutMs);
1816
- results.push(await reconcileOutcome(c, configsReport, { json: opts.json }));
1817
- continue;
1818
- }
1819
- const entry = entryBySlug.get(c.slug);
1820
- if (!entry) {
1821
- // Absent from the catalog THIS CLI read — not proof it left the catalog
1822
- // (that is the pod's `unlisted`, shown above, and only after a complete sync).
1823
- if (!opts.json)
1824
- log.warn(`Couldn't find "${c.slug}" in the catalog read just now — skipping.`);
1825
- results.push({ slug: c.slug, outcome: "not-in-catalog", status: "not-in-catalog", warnings: [] });
1826
- continue;
1827
- }
1828
1758
  const pv = previews.get(c.slug);
1829
1759
  const pending = pv?.changes ?? [];
1830
1760
  let accepted = [];
@@ -1849,17 +1779,14 @@ export async function marketUpdate(slugsArg, opts) {
1849
1779
  }
1850
1780
  if (missing.length > 0) {
1851
1781
  if (!opts.json) {
1852
- log.warn(`${entry.name} not updated: you have not agreed to its data changes. Nothing was written.`);
1782
+ log.warn(`${c.slug} not updated: you have not agreed to its data changes. Nothing was written.`);
1853
1783
  log.hint(`To agree and update: ${consentRerunCommand(c.slug, missing)}`);
1854
1784
  }
1855
1785
  results.push({ slug: c.slug, outcome: "needs-consent", status: "needs-consent", warnings: [], pendingDataChanges: missing });
1856
1786
  continue;
1857
1787
  }
1858
1788
  }
1859
- results.push(await applyOnePackage(entry, cat, cfg, { json: opts.json }, applyTimeoutMs, {
1860
- payload: pv?.payload,
1861
- acceptDataChanges: accepted,
1862
- }));
1789
+ results.push(await applyUpdateThroughDoor(c, cfg, { json: opts.json }, applyTimeoutMs, accepted));
1863
1790
  }
1864
1791
  if (results.some((r) => r.status === "needs-consent"))
1865
1792
  process.exitCode = 1;
@@ -1883,8 +1810,8 @@ export async function marketUpdate(slugsArg, opts) {
1883
1810
  renderNextSteps(steps);
1884
1811
  }
1885
1812
  /**
1886
- * One-line rollup over a batch of `applyOnePackage` outcomes — computed from
1887
- * the SAME per-item `status` (`classifyApplyResult`'s honest verdict) the
1813
+ * One-line rollup over a batch of update outcomes (`applyUpdateThroughDoor`) — computed from
1814
+ * the SAME per-item `status` the
1888
1815
  * loop already collects, not a separate tally. `"installed"`/`"updated"`/
1889
1816
  * `"composed"`/`"legacy-unknown"` count as a clean update, `"unchanged"` as
1890
1817
  * already-current, the `*-with-failures` and `"stamp-contradiction"` statuses
@@ -1904,6 +1831,7 @@ export function summarizeApplyResults(results) {
1904
1831
  let failed = 0;
1905
1832
  let proposed = 0;
1906
1833
  let needsConsent = 0;
1834
+ let toApplyInPodAdmin = 0;
1907
1835
  for (const r of results) {
1908
1836
  switch (r.status) {
1909
1837
  case "installed":
@@ -1938,6 +1866,10 @@ export function summarizeApplyResults(results) {
1938
1866
  case "needs-consent":
1939
1867
  needsConsent++;
1940
1868
  break;
1869
+ // The person's act (pod admin → Marketplace → Updates): nothing failed.
1870
+ case APPLY_IN_POD_ADMIN:
1871
+ toApplyInPodAdmin++;
1872
+ break;
1941
1873
  default:
1942
1874
  failed++;
1943
1875
  }
@@ -1949,6 +1881,8 @@ export function summarizeApplyResults(results) {
1949
1881
  // every surface — never "completed with failures" (review2 UX §1 #2).
1950
1882
  if (withFailures > 0)
1951
1883
  parts.push(`${withFailures} ${templateStepPhrase("unfinished")}`);
1884
+ if (toApplyInPodAdmin > 0)
1885
+ parts.push(`${toApplyInPodAdmin} to apply in pod admin`);
1952
1886
  if (failed > 0)
1953
1887
  parts.push(`${failed} failed`);
1954
1888
  if (timedOut > 0)
@@ -1958,8 +1892,8 @@ export function summarizeApplyResults(results) {
1958
1892
  const clean = withFailures === 0 && failed === 0 && timedOut === 0 && needsConsent === 0;
1959
1893
  // Proposed isn't a failure (never ⚠) but isn't live yet (not a bare ✓) —
1960
1894
  // a distinct "→ waiting for your approval" marker keeps the summary honest.
1961
- const marker = !clean ? "⚠" : proposed > 0 ? "→" : "✓";
1962
- const color = !clean ? chalk.yellow : proposed > 0 ? chalk.cyan : chalk.green;
1895
+ const marker = !clean ? "⚠" : proposed > 0 || toApplyInPodAdmin > 0 ? "→" : "✓";
1896
+ const color = !clean ? chalk.yellow : proposed > 0 || toApplyInPodAdmin > 0 ? chalk.cyan : chalk.green;
1963
1897
  return {
1964
1898
  updated,
1965
1899
  unchanged,
@@ -1968,6 +1902,7 @@ export function summarizeApplyResults(results) {
1968
1902
  timedOut,
1969
1903
  proposed,
1970
1904
  needsConsent,
1905
+ toApplyInPodAdmin,
1971
1906
  line: color(`${marker} ${parts.join(" · ")}`),
1972
1907
  };
1973
1908
  }