orionfold-relay 0.21.0 → 0.22.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/cli.js CHANGED
@@ -1186,7 +1186,7 @@ var CURRENT_PLUGIN_API_VERSION, CAPABILITY_VALUES, ORIGIN_VALUES, PrimitivesBund
1186
1186
  var init_types = __esm({
1187
1187
  "src/lib/plugins/sdk/types.ts"() {
1188
1188
  "use strict";
1189
- CURRENT_PLUGIN_API_VERSION = "0.21";
1189
+ CURRENT_PLUGIN_API_VERSION = "0.22";
1190
1190
  CAPABILITY_VALUES = ["fs", "net", "child_process", "env"];
1191
1191
  ORIGIN_VALUES = ["ainative-internal", "third-party"];
1192
1192
  PrimitivesBundleManifestSchema = z.object({
@@ -1232,16 +1232,16 @@ function computeHash(content) {
1232
1232
  function safePreview(content) {
1233
1233
  return content.slice(0, MAX_PREVIEW_CHARS).trim();
1234
1234
  }
1235
- function safeStat(path23) {
1235
+ function safeStat(path24) {
1236
1236
  try {
1237
- return statSync2(path23);
1237
+ return statSync2(path24);
1238
1238
  } catch {
1239
1239
  return null;
1240
1240
  }
1241
1241
  }
1242
- function safeReadFile(path23) {
1242
+ function safeReadFile(path24) {
1243
1243
  try {
1244
- return readFileSync4(path23, "utf-8");
1244
+ return readFileSync4(path24, "utf-8");
1245
1245
  } catch {
1246
1246
  return null;
1247
1247
  }
@@ -3744,6 +3744,13 @@ var init_format = __esm({
3744
3744
  price: z3.string().min(1).optional(),
3745
3745
  /** Get-license CTA target on the locked card. */
3746
3746
  purchaseUrl: z3.url().optional(),
3747
+ /**
3748
+ * Per-version customer-voice recap, version → one line. The single source
3749
+ * for every renewal value-recap surface (`license status`, the 402 update
3750
+ * refusal, the /packs update card, the Website renewal email). Optional —
3751
+ * but paid packs should carry it, or their renewal case argues generically.
3752
+ */
3753
+ changelog: z3.record(z3.string(), z3.string().min(1)).optional(),
3747
3754
  /** Customer slugs seeded via ensureCustomer at install. */
3748
3755
  customers: z3.array(z3.string()).default([])
3749
3756
  }).strict();
@@ -11951,9 +11958,9 @@ function buildPermissionSummary(toolName, input) {
11951
11958
  }
11952
11959
  }
11953
11960
  if (toolName === "Read" || toolName === "Write" || toolName === "Edit" || toolName === "read" || toolName === "write" || toolName === "edit") {
11954
- const path23 = input.file_path ?? input.path;
11955
- if (typeof path23 === "string" && path23.trim().length > 0) {
11956
- return truncate(path23.trim());
11961
+ const path24 = input.file_path ?? input.path;
11962
+ if (typeof path24 === "string" && path24.trim().length > 0) {
11963
+ return truncate(path24.trim());
11957
11964
  }
11958
11965
  }
11959
11966
  if (toolName?.startsWith("mcp__")) {
@@ -11987,9 +11994,9 @@ function getPermissionDetailEntries(toolName, input) {
11987
11994
  }
11988
11995
  }
11989
11996
  if (toolName === "Read" || toolName === "Write" || toolName === "Edit" || toolName === "read" || toolName === "write" || toolName === "edit") {
11990
- const path23 = input.file_path ?? input.path;
11991
- if (typeof path23 === "string") {
11992
- return [{ label: "Path", value: path23 }];
11997
+ const path24 = input.file_path ?? input.path;
11998
+ if (typeof path24 === "string") {
11999
+ return [{ label: "Path", value: path24 }];
11993
12000
  }
11994
12001
  }
11995
12002
  return Object.entries(input).slice(0, 6).map(([key, value]) => ({
@@ -12891,7 +12898,7 @@ var init_registry6 = __esm({
12891
12898
  init_registry5();
12892
12899
  init_installer();
12893
12900
  init_schedule_spec();
12894
- SUPPORTED_API_VERSIONS = /* @__PURE__ */ new Set([CURRENT_PLUGIN_API_VERSION, "0.20"]);
12901
+ SUPPORTED_API_VERSIONS = /* @__PURE__ */ new Set([CURRENT_PLUGIN_API_VERSION, "0.21"]);
12895
12902
  pluginCache = null;
12896
12903
  lastLoadedPluginIds = /* @__PURE__ */ new Set();
12897
12904
  PluginTableSchema = z16.object({
@@ -12934,8 +12941,8 @@ function pluginTools(_ctx) {
12934
12941
  Promise.resolve().then(() => (init_ainative_paths(), ainative_paths_exports)),
12935
12942
  Promise.resolve().then(() => (init_types(), types_exports))
12936
12943
  ]);
12937
- const fs22 = await import("fs");
12938
- const path23 = await import("path");
12944
+ const fs23 = await import("fs");
12945
+ const path24 = await import("path");
12939
12946
  const yaml13 = await import("js-yaml");
12940
12947
  const kind5 = listPlugins2();
12941
12948
  const registrations = await listPluginMcpRegistrations2();
@@ -12952,13 +12959,13 @@ function pluginTools(_ctx) {
12952
12959
  let manifestHash;
12953
12960
  let capabilityAcceptStatus = "pending";
12954
12961
  try {
12955
- const pluginYamlPath = path23.join(
12962
+ const pluginYamlPath = path24.join(
12956
12963
  pluginsDir,
12957
12964
  pluginId,
12958
12965
  "plugin.yaml"
12959
12966
  );
12960
- if (fs22.existsSync(pluginYamlPath)) {
12961
- const content = fs22.readFileSync(pluginYamlPath, "utf-8");
12967
+ if (fs23.existsSync(pluginYamlPath)) {
12968
+ const content = fs23.readFileSync(pluginYamlPath, "utf-8");
12962
12969
  const rawManifest = yaml13.load(content);
12963
12970
  if (rawManifest !== null && typeof rawManifest === "object" && !Array.isArray(rawManifest)) {
12964
12971
  const record = rawManifest;
@@ -12971,7 +12978,7 @@ function pluginTools(_ctx) {
12971
12978
  try {
12972
12979
  manifestHash = deriveManifestHash2(content);
12973
12980
  const parsed = PluginManifestSchema2.safeParse(rawManifest);
12974
- const pluginRootDir = path23.join(pluginsDir, pluginId);
12981
+ const pluginRootDir = path24.join(pluginsDir, pluginId);
12975
12982
  const check = isCapabilityAccepted2(
12976
12983
  pluginId,
12977
12984
  manifestHash,
@@ -19387,13 +19394,13 @@ var init_codex_app_server_client = __esm({
19387
19394
  await syncPluginMcpToCodex2();
19388
19395
  } catch (err2) {
19389
19396
  try {
19390
- const fs22 = await import("fs");
19391
- const path23 = await import("path");
19397
+ const fs23 = await import("fs");
19398
+ const path24 = await import("path");
19392
19399
  const { getAinativeLogsDir: getAinativeLogsDir2 } = await Promise.resolve().then(() => (init_ainative_paths(), ainative_paths_exports));
19393
19400
  const dir = getAinativeLogsDir2();
19394
- fs22.mkdirSync(dir, { recursive: true });
19395
- fs22.appendFileSync(
19396
- path23.join(dir, "plugins.log"),
19401
+ fs23.mkdirSync(dir, { recursive: true });
19402
+ fs23.appendFileSync(
19403
+ path24.join(dir, "plugins.log"),
19397
19404
  `${(/* @__PURE__ */ new Date()).toISOString()} codex-sync-failed: ${err2 instanceof Error ? err2.message : String(err2)}
19398
19405
  `
19399
19406
  );
@@ -22985,14 +22992,14 @@ function resolvePostAction(action, row, itemVariable) {
22985
22992
  function substituteRowPath(template, row, itemVariable) {
22986
22993
  const escaped = itemVariable.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
22987
22994
  const pattern = new RegExp(`\\{\\{\\s*${escaped}\\.([\\w.]+)\\s*\\}\\}`, "g");
22988
- return template.replace(pattern, (_match, path23) => {
22989
- const value = readPath(row, path23);
22995
+ return template.replace(pattern, (_match, path24) => {
22996
+ const value = readPath(row, path24);
22990
22997
  if (value === void 0 || value === null) return "";
22991
22998
  return String(value);
22992
22999
  });
22993
23000
  }
22994
- function readPath(obj, path23) {
22995
- const parts = path23.split(".");
23001
+ function readPath(obj, path24) {
23002
+ const parts = path24.split(".");
22996
23003
  let current = obj;
22997
23004
  for (const part of parts) {
22998
23005
  if (current === null || current === void 0) return void 0;
@@ -23179,14 +23186,14 @@ ${resolvedTemplate}`);
23179
23186
  return parts.join("");
23180
23187
  }
23181
23188
  function resolveRowTemplate(template, context) {
23182
- return template.replace(/\{\{\s*([^}]+)\s*\}\}/g, (_match, path23) => {
23183
- const value = readContextPath(context, path23.trim());
23189
+ return template.replace(/\{\{\s*([^}]+)\s*\}\}/g, (_match, path24) => {
23190
+ const value = readContextPath(context, path24.trim());
23184
23191
  if (value === void 0 || value === null) return "";
23185
23192
  return typeof value === "string" ? value : JSON.stringify(value);
23186
23193
  });
23187
23194
  }
23188
- function readContextPath(value, path23) {
23189
- const parts = path23.split(".");
23195
+ function readContextPath(value, path24) {
23196
+ const parts = path24.split(".");
23190
23197
  let current = value;
23191
23198
  for (const part of parts) {
23192
23199
  if (current === null || current === void 0) return void 0;
@@ -25674,8 +25681,8 @@ import { execFileSync as execFileSync3 } from "child_process";
25674
25681
  import yaml12 from "js-yaml";
25675
25682
  import semver from "semver";
25676
25683
  function relayCoreVersion() {
25677
- if (semver.valid("0.21.0")) {
25678
- return "0.21.0";
25684
+ if (semver.valid("0.22.0")) {
25685
+ return "0.22.0";
25679
25686
  }
25680
25687
  try {
25681
25688
  const root = getAppRoot(import.meta.dirname, 3);
@@ -26014,6 +26021,68 @@ var init_install = __esm({
26014
26021
  }
26015
26022
  });
26016
26023
 
26024
+ // src/lib/licensing/recap.ts
26025
+ var recap_exports = {};
26026
+ __export(recap_exports, {
26027
+ changelogWindow: () => changelogWindow,
26028
+ entitledPackRecaps: () => entitledPackRecaps
26029
+ });
26030
+ import fs21 from "fs";
26031
+ import path22 from "path";
26032
+ import semver2 from "semver";
26033
+ function changelogWindow(changelog, fromExclusive, toInclusive) {
26034
+ if (!changelog || !toInclusive || !semver2.valid(toInclusive)) return [];
26035
+ const from = fromExclusive && semver2.valid(fromExclusive) ? fromExclusive : null;
26036
+ return Object.entries(changelog).filter(([version]) => semver2.valid(version)).filter(
26037
+ ([version]) => semver2.compare(version, toInclusive) <= 0 && (from === null || semver2.compare(version, from) > 0)
26038
+ ).sort(([a], [b]) => semver2.compare(a, b)).map(([version, note]) => ({ version, note }));
26039
+ }
26040
+ function entitledPackRecaps(entitlements, opts2 = {}) {
26041
+ try {
26042
+ const appsDir = opts2.appsDir ?? getAinativeAppsDir();
26043
+ const covered = new Set(entitlements);
26044
+ const out = [];
26045
+ for (const tpl of listPackTemplates({ templatesDir: opts2.templatesDir })) {
26046
+ if (tpl.error || !tpl.meta?.entitlement) continue;
26047
+ if (!covered.has(tpl.meta.entitlement)) continue;
26048
+ if (!fs21.existsSync(path22.join(appsDir, tpl.id))) continue;
26049
+ const avail = packUpdateAvailability(tpl.id, {
26050
+ appsDir,
26051
+ templatesDir: opts2.templatesDir
26052
+ });
26053
+ const state = readInstallState(appsDir, tpl.id);
26054
+ const changelog = tpl.meta.changelog;
26055
+ out.push({
26056
+ packId: tpl.id,
26057
+ packName: tpl.meta.name,
26058
+ installedVersion: avail.installedVersion,
26059
+ ...state?.installedAt ? { installedAt: state.installedAt } : {},
26060
+ availableVersion: avail.availableVersion,
26061
+ updateAvailable: avail.updateAvailable,
26062
+ ...avail.installedVersion && changelog?.[avail.installedVersion] ? { received: changelog[avail.installedVersion] } : {},
26063
+ pending: avail.updateAvailable ? changelogWindow(
26064
+ changelog,
26065
+ avail.installedVersion,
26066
+ avail.availableVersion
26067
+ ) : [],
26068
+ ...tpl.meta.purchaseUrl ? { purchaseUrl: tpl.meta.purchaseUrl } : {}
26069
+ });
26070
+ }
26071
+ return out;
26072
+ } catch {
26073
+ return [];
26074
+ }
26075
+ }
26076
+ var init_recap = __esm({
26077
+ "src/lib/licensing/recap.ts"() {
26078
+ "use strict";
26079
+ init_ainative_paths();
26080
+ init_catalog();
26081
+ init_update();
26082
+ init_install_state();
26083
+ }
26084
+ });
26085
+
26017
26086
  // src/lib/packs/update.ts
26018
26087
  var update_exports = {};
26019
26088
  __export(update_exports, {
@@ -26021,15 +26090,15 @@ __export(update_exports, {
26021
26090
  packUpdateAvailability: () => packUpdateAvailability,
26022
26091
  updatePack: () => updatePack
26023
26092
  });
26024
- import fs21 from "fs";
26025
- import path22 from "path";
26026
- import semver2 from "semver";
26093
+ import fs22 from "fs";
26094
+ import path23 from "path";
26095
+ import semver3 from "semver";
26027
26096
  function packUpdateAvailability(appId, opts2 = {}) {
26028
26097
  const appsDir = opts2.appsDir ?? getAinativeAppsDir();
26029
26098
  const installedVersion = readInstallState(appsDir, appId)?.packVersion ?? null;
26030
26099
  const template = findPackTemplate(appId, { templatesDir: opts2.templatesDir });
26031
26100
  const availableVersion = template?.meta?.version ?? null;
26032
- const updateAvailable = availableVersion !== null && semver2.valid(availableVersion) !== null && (installedVersion === null || semver2.valid(installedVersion) === null || semver2.compare(availableVersion, installedVersion) > 0);
26101
+ const updateAvailable = availableVersion !== null && semver3.valid(availableVersion) !== null && (installedVersion === null || semver3.valid(installedVersion) === null || semver3.compare(availableVersion, installedVersion) > 0);
26033
26102
  return { installedVersion, availableVersion, updateAvailable };
26034
26103
  }
26035
26104
  async function updatePack(id, options = {}) {
@@ -26057,13 +26126,13 @@ async function updatePack(id, options = {}) {
26057
26126
  );
26058
26127
  }
26059
26128
  const coreVersion = options.coreVersion ?? relayCoreVersion();
26060
- if (pack.meta.relayCore && !semver2.satisfies(coreVersion, pack.meta.relayCore)) {
26129
+ if (pack.meta.relayCore && !semver3.satisfies(coreVersion, pack.meta.relayCore)) {
26061
26130
  throw new PackValidationError(
26062
26131
  `Pack ${pack.meta.id}@${pack.meta.version} requires relay-core ${pack.meta.relayCore}, but this install is ${coreVersion}.`
26063
26132
  );
26064
26133
  }
26065
26134
  const newVersion = pack.meta.version;
26066
- if (previousVersion !== null && semver2.valid(previousVersion) && semver2.valid(newVersion) && semver2.compare(newVersion, previousVersion) <= 0) {
26135
+ if (previousVersion !== null && semver3.valid(previousVersion) && semver3.valid(newVersion) && semver3.compare(newVersion, previousVersion) <= 0) {
26067
26136
  return {
26068
26137
  packId: id,
26069
26138
  previousVersion,
@@ -26088,8 +26157,21 @@ async function updatePack(id, options = {}) {
26088
26157
  } catch (err2) {
26089
26158
  if (err2 instanceof PackLicenseError2) {
26090
26159
  const renew = pack.meta.purchaseUrl ? `renew at ${pack.meta.purchaseUrl}` : `redeem one with: relay license add <path-or-url to your .license.json>`;
26160
+ let withheld = "";
26161
+ try {
26162
+ const { changelogWindow: changelogWindow2 } = await Promise.resolve().then(() => (init_recap(), recap_exports));
26163
+ const pending = changelogWindow2(
26164
+ pack.meta.changelog,
26165
+ previousVersion,
26166
+ newVersion
26167
+ );
26168
+ if (pending.length > 0) {
26169
+ withheld = `This update includes: ` + pending.map((p) => `v${p.version} \u2014 ${p.note}`).join("; ") + ` `;
26170
+ }
26171
+ } catch {
26172
+ }
26091
26173
  throw new PackLicenseError2(
26092
- `Your installed ${id} keeps working \u2014 nothing is locked. Updating to v${newVersion} needs an active license: ${renew}. (${err2.message})`,
26174
+ `Your installed ${id} keeps working \u2014 nothing is locked. Updating to v${newVersion} needs an active license: ${renew}. ` + withheld + `(${err2.message})`,
26093
26175
  err2.reason
26094
26176
  );
26095
26177
  }
@@ -26097,7 +26179,7 @@ async function updatePack(id, options = {}) {
26097
26179
  }
26098
26180
  }
26099
26181
  const resolved = resolvePackLayer(pack);
26100
- const backupRoot = path22.join(
26182
+ const backupRoot = path23.join(
26101
26183
  appsDir,
26102
26184
  id,
26103
26185
  "backup",
@@ -26106,12 +26188,12 @@ async function updatePack(id, options = {}) {
26106
26188
  const backedUp = [];
26107
26189
  for (const file of resolved.files) {
26108
26190
  const dest = artifactDestPath(file.relPath, profilesDir, blueprintsDir);
26109
- if (!dest || !fs21.existsSync(dest)) continue;
26191
+ if (!dest || !fs22.existsSync(dest)) continue;
26110
26192
  const recorded = state?.files[file.relPath];
26111
26193
  if (recorded !== void 0 && hashFileSha256(dest) === recorded) continue;
26112
- const backupPath = path22.join(backupRoot, file.relPath);
26113
- fs21.mkdirSync(path22.dirname(backupPath), { recursive: true });
26114
- fs21.copyFileSync(dest, backupPath);
26194
+ const backupPath = path23.join(backupRoot, file.relPath);
26195
+ fs22.mkdirSync(path23.dirname(backupPath), { recursive: true });
26196
+ fs22.copyFileSync(dest, backupPath);
26115
26197
  backedUp.push(file.relPath);
26116
26198
  }
26117
26199
  const install = await installPack(packDir, {
@@ -26395,6 +26477,17 @@ async function runAdd2(source, io) {
26395
26477
  return 1;
26396
26478
  }
26397
26479
  }
26480
+ async function pendingRecaps(entitlements, io) {
26481
+ try {
26482
+ const { entitledPackRecaps: entitledPackRecaps2 } = await Promise.resolve().then(() => (init_recap(), recap_exports));
26483
+ return entitledPackRecaps2(entitlements, {
26484
+ appsDir: io.appsDir,
26485
+ templatesDir: io.templatesDir
26486
+ }).filter((r) => r.pending.length > 0);
26487
+ } catch {
26488
+ return [];
26489
+ }
26490
+ }
26398
26491
  async function runStatus(io) {
26399
26492
  try {
26400
26493
  const { listLicenses: listLicenses2 } = await Promise.resolve().then(() => (init_store(), store_exports));
@@ -26418,6 +26511,16 @@ async function runStatus(io) {
26418
26511
  io.log(
26419
26512
  ` Status: ${lic.valid ? "valid" : `invalid \u2014 ${lic.reason ?? "corrupt entry"}`}`
26420
26513
  );
26514
+ const recaps = await pendingRecaps(lic.entitlements, io);
26515
+ if (lic.valid && recaps.length > 0) {
26516
+ io.log(` Included in your term, waiting to install:`);
26517
+ for (const r of recaps) {
26518
+ for (const p of r.pending) {
26519
+ io.log(` ${r.packName} v${p.version} \u2014 ${p.note}`);
26520
+ }
26521
+ io.log(` \u2192 relay pack update ${r.packId}`);
26522
+ }
26523
+ }
26421
26524
  if (lic.valid && lic.expiresAt) {
26422
26525
  const daysLeft = Math.floor(
26423
26526
  (new Date(lic.expiresAt).getTime() - now.getTime()) / DAY_MS
@@ -26426,6 +26529,23 @@ async function runStatus(io) {
26426
26529
  io.log(
26427
26530
  ` \u26A0 Renewal: expires in ${daysLeft} day${daysLeft === 1 ? "" : "s"}. Your installed packs are yours forever; renewing keeps new premium packs and updates flowing.`
26428
26531
  );
26532
+ const latest = recaps.map((r) => ({ r, p: r.pending[r.pending.length - 1] })).filter((x) => x.p);
26533
+ for (const { r, p } of latest) {
26534
+ io.log(
26535
+ ` This license year delivered ${r.packName} v${p.version} \u2014 ${p.note}`
26536
+ );
26537
+ }
26538
+ }
26539
+ }
26540
+ if (!lic.valid && /expired/i.test(lic.reason ?? "") && recaps.length > 0) {
26541
+ io.log(
26542
+ ` Your installed packs keep working \u2014 nothing is locked. Renewing unlocks:`
26543
+ );
26544
+ for (const r of recaps) {
26545
+ for (const p of r.pending) {
26546
+ io.log(` ${r.packName} v${p.version} \u2014 ${p.note}`);
26547
+ }
26548
+ if (r.purchaseUrl) io.log(` \u2192 renew at ${r.purchaseUrl}`);
26429
26549
  }
26430
26550
  }
26431
26551
  io.log("");
@@ -26796,10 +26916,10 @@ import { existsSync as existsSync4, renameSync, cpSync, rmSync as rmSync2, readF
26796
26916
  import { join as join6 } from "path";
26797
26917
  import { homedir as homedir2 } from "os";
26798
26918
  import Database from "better-sqlite3";
26799
- function hasSqliteHeader(path23) {
26919
+ function hasSqliteHeader(path24) {
26800
26920
  const SQLITE_MAGIC = "SQLite format 3\0";
26801
26921
  try {
26802
- const header = readFileSync3(path23, { encoding: null });
26922
+ const header = readFileSync3(path24, { encoding: null });
26803
26923
  return header.length >= 16 && header.subarray(0, 16).toString("binary") === SQLITE_MAGIC;
26804
26924
  } catch {
26805
26925
  return false;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "orionfold-relay",
3
- "version": "0.21.0",
3
+ "version": "0.22.0",
4
4
  "description": "Orionfold Relay — a local-first, multi-agent orchestration runtime and builder scaffold for AI-native work.",
5
5
  "keywords": [
6
6
  "ai",
@@ -6,6 +6,7 @@ import { Boxes, Check, Lock, Package, TriangleAlert } from "lucide-react";
6
6
  import { listApps } from "@/lib/apps/registry";
7
7
  import { listPackTemplates, type PackTemplate } from "@/lib/packs/catalog";
8
8
  import { packUpdateAvailability } from "@/lib/packs/update";
9
+ import { changelogWindow } from "@/lib/licensing/recap";
9
10
  import { PackInstallButton } from "@/components/packs/pack-install-button";
10
11
  import { PackUpdateButton } from "@/components/packs/pack-update-button";
11
12
 
@@ -150,11 +151,24 @@ function InstalledActions({ template }: { template: PackTemplate }) {
150
151
  </Link>
151
152
  </div>
152
153
  {avail.updateAvailable && avail.availableVersion && (
153
- <PackUpdateButton
154
- packId={template.id}
155
- packName={template.meta!.name}
156
- newVersion={avail.availableVersion}
157
- />
154
+ <>
155
+ <PackUpdateButton
156
+ packId={template.id}
157
+ packName={template.meta!.name}
158
+ newVersion={avail.availableVersion}
159
+ />
160
+ {/* Value-recap one-liner: what the pending update contains, from the
161
+ pack's own changelog — same source as license status + the 402. */}
162
+ {changelogWindow(
163
+ template.meta!.changelog,
164
+ avail.installedVersion,
165
+ avail.availableVersion
166
+ ).map((p) => (
167
+ <p key={p.version} className="text-xs text-muted-foreground">
168
+ v{p.version} — {p.note}
169
+ </p>
170
+ ))}
171
+ </>
158
172
  )}
159
173
  </div>
160
174
  );
@@ -14,6 +14,9 @@ export interface LicenseCommandIo {
14
14
  dir?: string;
15
15
  /** Injected clock (tests). */
16
16
  now?: Date;
17
+ /** Override the recap surface's pack dirs (tests). */
18
+ appsDir?: string;
19
+ templatesDir?: string;
17
20
  log: (message: string) => void;
18
21
  error: (message: string) => void;
19
22
  }
@@ -115,6 +118,25 @@ async function runAdd(
115
118
  }
116
119
  }
117
120
 
121
+ /**
122
+ * Fail-open recap lookup — the value-recap is decoration on this surface,
123
+ * never a gate; any fault degrades to "no recap", not an error.
124
+ */
125
+ async function pendingRecaps(
126
+ entitlements: string[],
127
+ io: LicenseCommandIo
128
+ ): Promise<import("./recap").PackRecap[]> {
129
+ try {
130
+ const { entitledPackRecaps } = await import("./recap");
131
+ return entitledPackRecaps(entitlements, {
132
+ appsDir: io.appsDir,
133
+ templatesDir: io.templatesDir,
134
+ }).filter((r) => r.pending.length > 0);
135
+ } catch {
136
+ return [];
137
+ }
138
+ }
139
+
118
140
  async function runStatus(io: LicenseCommandIo): Promise<number> {
119
141
  try {
120
142
  const { listLicenses } = await import("./store");
@@ -141,6 +163,20 @@ async function runStatus(io: LicenseCommandIo): Promise<number> {
141
163
  ` Status: ${lic.valid ? "valid" : `invalid — ${lic.reason ?? "corrupt entry"}`}`
142
164
  );
143
165
 
166
+ const recaps = await pendingRecaps(lic.entitlements, io);
167
+
168
+ // Value recap (PLG-4a): entitled updates this license already paid for,
169
+ // sitting uninstalled. Explicit invocation ⇒ informational, not a nag.
170
+ if (lic.valid && recaps.length > 0) {
171
+ io.log(` Included in your term, waiting to install:`);
172
+ for (const r of recaps) {
173
+ for (const p of r.pending) {
174
+ io.log(` ${r.packName} v${p.version} — ${p.note}`);
175
+ }
176
+ io.log(` → relay pack update ${r.packId}`);
177
+ }
178
+ }
179
+
144
180
  // D4: expiry warns about FUTURE premium installs/updates only — it
145
181
  // never gates anything already installed, so this is a nudge, not a block.
146
182
  if (lic.valid && lic.expiresAt) {
@@ -153,6 +189,29 @@ async function runStatus(io: LicenseCommandIo): Promise<number> {
153
189
  `Your installed packs are yours forever; renewing keeps new premium ` +
154
190
  `packs and updates flowing.`
155
191
  );
192
+ // The generic promise gains this year's specific evidence.
193
+ const latest = recaps
194
+ .map((r) => ({ r, p: r.pending[r.pending.length - 1] }))
195
+ .filter((x) => x.p);
196
+ for (const { r, p } of latest) {
197
+ io.log(
198
+ ` This license year delivered ${r.packName} v${p.version} — ${p.note}`
199
+ );
200
+ }
201
+ }
202
+ }
203
+
204
+ // Expired license, renewal-voiced (same voice as the update gate): what
205
+ // renewal unlocks, never a threat to installed content.
206
+ if (!lic.valid && /expired/i.test(lic.reason ?? "") && recaps.length > 0) {
207
+ io.log(
208
+ ` Your installed packs keep working — nothing is locked. Renewing unlocks:`
209
+ );
210
+ for (const r of recaps) {
211
+ for (const p of r.pending) {
212
+ io.log(` ${r.packName} v${p.version} — ${p.note}`);
213
+ }
214
+ if (r.purchaseUrl) io.log(` → renew at ${r.purchaseUrl}`);
156
215
  }
157
216
  }
158
217
  io.log("");
@@ -0,0 +1,119 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import semver from "semver";
4
+ import { getAinativeAppsDir } from "@/lib/utils/ainative-paths";
5
+ import { listPackTemplates } from "@/lib/packs/catalog";
6
+ import { packUpdateAvailability } from "@/lib/packs/update";
7
+ import { readInstallState } from "@/lib/packs/install-state";
8
+
9
+ /**
10
+ * Renewal value-recap — the ONE source every renewal surface reads:
11
+ * `license status`, the 402 update refusal, the /packs update card, and the
12
+ * canonical copy relayed for the Website renewal email. Reuses
13
+ * `packUpdateAvailability` for the version comparison (D7: never a second
14
+ * comparison source) and the pack.yaml `changelog` map for the words.
15
+ *
16
+ * Everything here is FAIL-OPEN (cli-startup-robustness rule): a corrupt
17
+ * sidecar, a missing template, or a changelog-less pack degrades to silence —
18
+ * a recap must never crash or block `license status`.
19
+ *
20
+ * Consumers dynamically import this module (TDR-032: keep the CLI's static
21
+ * startup graph free of pack/licensing code).
22
+ */
23
+
24
+ export interface PendingValue {
25
+ version: string;
26
+ note: string;
27
+ }
28
+
29
+ export interface PackRecap {
30
+ packId: string;
31
+ packName: string;
32
+ /** From the install-state sidecar; null = unknown (pre-0.21 install). */
33
+ installedVersion: string | null;
34
+ /** Sidecar install timestamp, when known. */
35
+ installedAt?: string;
36
+ availableVersion: string | null;
37
+ updateAvailable: boolean;
38
+ /** Changelog line for the version already installed — value received. */
39
+ received?: string;
40
+ /** Changelog lines in (installed, available], ascending — value pending. */
41
+ pending: PendingValue[];
42
+ purchaseUrl?: string;
43
+ }
44
+
45
+ /**
46
+ * Changelog lines in the half-open version window (fromExclusive, toInclusive],
47
+ * ascending. Invalid semver keys are skipped, never thrown on. A null `from`
48
+ * means "everything up to and including `to`".
49
+ */
50
+ export function changelogWindow(
51
+ changelog: Record<string, string> | undefined,
52
+ fromExclusive: string | null,
53
+ toInclusive: string | null
54
+ ): PendingValue[] {
55
+ if (!changelog || !toInclusive || !semver.valid(toInclusive)) return [];
56
+ const from = fromExclusive && semver.valid(fromExclusive) ? fromExclusive : null;
57
+ return Object.entries(changelog)
58
+ .filter(([version]) => semver.valid(version))
59
+ .filter(
60
+ ([version]) =>
61
+ semver.compare(version, toInclusive) <= 0 &&
62
+ (from === null || semver.compare(version, from) > 0)
63
+ )
64
+ .sort(([a], [b]) => semver.compare(a, b))
65
+ .map(([version, note]) => ({ version, note }));
66
+ }
67
+
68
+ /**
69
+ * Recaps for every INSTALLED bundled pack whose entitlement is covered by
70
+ * `entitlements`. Uninstalled entitled packs are deliberately absent — that
71
+ * is install-nudge territory (D6), not renewal recap.
72
+ */
73
+ export function entitledPackRecaps(
74
+ entitlements: string[],
75
+ opts: { appsDir?: string; templatesDir?: string } = {}
76
+ ): PackRecap[] {
77
+ try {
78
+ const appsDir = opts.appsDir ?? getAinativeAppsDir();
79
+ const covered = new Set(entitlements);
80
+ const out: PackRecap[] = [];
81
+
82
+ for (const tpl of listPackTemplates({ templatesDir: opts.templatesDir })) {
83
+ if (tpl.error || !tpl.meta?.entitlement) continue;
84
+ if (!covered.has(tpl.meta.entitlement)) continue;
85
+ if (!fs.existsSync(path.join(appsDir, tpl.id))) continue;
86
+
87
+ const avail = packUpdateAvailability(tpl.id, {
88
+ appsDir,
89
+ templatesDir: opts.templatesDir,
90
+ });
91
+ const state = readInstallState(appsDir, tpl.id);
92
+ const changelog = tpl.meta.changelog;
93
+
94
+ out.push({
95
+ packId: tpl.id,
96
+ packName: tpl.meta.name,
97
+ installedVersion: avail.installedVersion,
98
+ ...(state?.installedAt ? { installedAt: state.installedAt } : {}),
99
+ availableVersion: avail.availableVersion,
100
+ updateAvailable: avail.updateAvailable,
101
+ ...(avail.installedVersion && changelog?.[avail.installedVersion]
102
+ ? { received: changelog[avail.installedVersion] }
103
+ : {}),
104
+ pending: avail.updateAvailable
105
+ ? changelogWindow(
106
+ changelog,
107
+ avail.installedVersion,
108
+ avail.availableVersion
109
+ )
110
+ : [],
111
+ ...(tpl.meta.purchaseUrl ? { purchaseUrl: tpl.meta.purchaseUrl } : {}),
112
+ });
113
+ }
114
+ return out;
115
+ } catch {
116
+ // Fail-open: a recap is decoration on the licensing surfaces, never a gate.
117
+ return [];
118
+ }
119
+ }
@@ -51,6 +51,13 @@ export const PackManifestSchema = z
51
51
  price: z.string().min(1).optional(),
52
52
  /** Get-license CTA target on the locked card. */
53
53
  purchaseUrl: z.url().optional(),
54
+ /**
55
+ * Per-version customer-voice recap, version → one line. The single source
56
+ * for every renewal value-recap surface (`license status`, the 402 update
57
+ * refusal, the /packs update card, the Website renewal email). Optional —
58
+ * but paid packs should carry it, or their renewal case argues generically.
59
+ */
60
+ changelog: z.record(z.string(), z.string().min(1)).optional(),
54
61
  /** Customer slugs seeded via ensureCustomer at install. */
55
62
  customers: z.array(z.string()).default([]),
56
63
  })
@@ -22,5 +22,18 @@ relayCore: ">=0.18.0"
22
22
  entitlement: product:orionfold-relay
23
23
  price: "$499/year"
24
24
  purchaseUrl: https://orionfold.com/relay/
25
- # Pro seeds no demo customers it operates your real ones.
25
+ # Per-version customer-voice recap the single source every renewal surface
26
+ # reads (license status, the update refusal, the /packs card, the renewal
27
+ # email). Add a line here with EVERY version bump; the template test suite
28
+ # requires an entry for the current version.
29
+ changelog:
30
+ "0.1.0": >-
31
+ Six chapters of agency operating system — the Finance Cockpit that closes
32
+ your month by itself, Intake Pipelines, the New-Business Machine,
33
+ client-safe Governance profiles, and the Commercial Real Estate renewal
34
+ engine.
35
+ "0.2.0": >-
36
+ The Nonprofit deep chapter — a grant pipeline that takes every opportunity
37
+ from fit-scored go/no-go through LOI, full application, and post-award
38
+ restricted-funds compliance with a reporting calendar.
26
39
  customers: []
@@ -179,9 +179,29 @@ export async function updatePack(
179
179
  const renew = pack.meta.purchaseUrl
180
180
  ? `renew at ${pack.meta.purchaseUrl}`
181
181
  : `redeem one with: relay license add <path-or-url to your .license.json>`;
182
+ // Value-recap voice: name what the withheld update contains, from
183
+ // the pack's own changelog (fail-open — silence if it has none).
184
+ let withheld = "";
185
+ try {
186
+ const { changelogWindow } = await import("@/lib/licensing/recap");
187
+ const pending = changelogWindow(
188
+ pack.meta.changelog,
189
+ previousVersion,
190
+ newVersion
191
+ );
192
+ if (pending.length > 0) {
193
+ withheld =
194
+ `This update includes: ` +
195
+ pending.map((p) => `v${p.version} — ${p.note}`).join("; ") +
196
+ ` `;
197
+ }
198
+ } catch {
199
+ // recap is decoration on the refusal, never a second failure.
200
+ }
182
201
  throw new PackLicenseError(
183
202
  `Your installed ${id} keeps working — nothing is locked. ` +
184
203
  `Updating to v${newVersion} needs an active license: ${renew}. ` +
204
+ withheld +
185
205
  `(${err.message})`,
186
206
  err.reason
187
207
  );
@@ -1,6 +1,6 @@
1
1
  id: echo-server
2
2
  version: 0.1.0
3
- apiVersion: "0.21"
3
+ apiVersion: "0.22"
4
4
  kind: chat-tools
5
5
  name: Echo Server
6
6
  description: |
@@ -1,6 +1,6 @@
1
1
  id: finance-pack
2
2
  version: 0.1.0
3
- apiVersion: "0.21"
3
+ apiVersion: "0.22"
4
4
  kind: primitives-bundle
5
5
  name: Finance Pack
6
6
  description: |
@@ -1,6 +1,6 @@
1
1
  id: reading-radar
2
2
  version: 0.1.0
3
- apiVersion: "0.21"
3
+ apiVersion: "0.22"
4
4
  kind: primitives-bundle
5
5
  name: Reading Radar
6
6
  description: |
@@ -53,7 +53,7 @@ import type { ScheduleSpec } from "@/lib/validators/schedule-spec";
53
53
  // unfixed from 0.15.0 through 0.16.0 — treat the window test's failure as
54
54
  // a release blocker, not noise). The 0.13→0.14 three-MINOR bridge is over;
55
55
  // this is the standard 2-MINOR window now.
56
- const SUPPORTED_API_VERSIONS = new Set([CURRENT_PLUGIN_API_VERSION, "0.20"]);
56
+ const SUPPORTED_API_VERSIONS = new Set([CURRENT_PLUGIN_API_VERSION, "0.21"]);
57
57
 
58
58
  /** Test-helper export so the window-enforcement test can read state. */
59
59
  export function isSupportedApiVersion(apiVersion: string): boolean {
@@ -6,7 +6,7 @@ import { z } from "zod";
6
6
  // (a hardcoded copy there once drifted to "0.14" — scaffolded plugins would
7
7
  // have been disabled on load the moment the window tightened). Bump on every
8
8
  // MINOR release; api-version-window.test.ts fails if this goes stale.
9
- export const CURRENT_PLUGIN_API_VERSION = "0.21";
9
+ export const CURRENT_PLUGIN_API_VERSION = "0.22";
10
10
 
11
11
  // Shared capability tuple — single source of truth used by Zod schema and
12
12
  // capability-check.ts hash derivation. Exported so consumers don't need a