@tendrilapp/cli 0.1.49 → 0.1.50

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/tendril.js CHANGED
@@ -841,6 +841,46 @@ import { createHash } from "node:crypto";
841
841
  import { existsSync } from "node:fs";
842
842
  import path from "node:path";
843
843
  import { z as z4 } from "zod";
844
+ function congruenceProjection(node, isRoot) {
845
+ return {
846
+ id: node.id,
847
+ name: node.name,
848
+ type: node.type,
849
+ ...isRoot ? {} : { ...node.x !== void 0 ? { x: node.x } : {}, ...node.y !== void 0 ? { y: node.y } : {} },
850
+ ...node.width !== void 0 ? { width: node.width } : {},
851
+ ...node.height !== void 0 ? { height: node.height } : {},
852
+ ...node.hidden === true ? { hidden: true } : {},
853
+ children: node.children.map((c) => congruenceProjection(c, false))
854
+ };
855
+ }
856
+ function metadataCongruent(recordedText, candidateDocument, parse2) {
857
+ const recorded = parse2(recordedText);
858
+ if (recorded.truncated === true) return { ok: false, divergence: "the recorded metadata parse is TRUNCATED \u2014 congruence cannot be established over a partial truth; re-record the metadata first" };
859
+ const candidate = parse2(synthesizeMetadataMarkup(candidateDocument));
860
+ const a = recorded.roots.map((r) => congruenceProjection(r, true));
861
+ const b = candidate.roots.map((r) => congruenceProjection(r, true));
862
+ const left = JSON.stringify(a);
863
+ const right = JSON.stringify(b);
864
+ if (left === right) return { ok: true };
865
+ const flat = (nodes, out) => {
866
+ for (const n of nodes) {
867
+ out.set(n.id, n);
868
+ flat(n.children, out);
869
+ }
870
+ return out;
871
+ };
872
+ const am = flat(a, /* @__PURE__ */ new Map());
873
+ const bm = flat(b, /* @__PURE__ */ new Map());
874
+ for (const [id, n] of am) {
875
+ const m = bm.get(id);
876
+ if (m === void 0) return { ok: false, divergence: `recorded node ${id} ("${n.name}") is no longer in the document` };
877
+ if (JSON.stringify({ ...n, children: [] }) !== JSON.stringify({ ...m, children: [] })) {
878
+ return { ok: false, divergence: `node ${id} ("${n.name}") changed since recording (name, box, type or visibility)` };
879
+ }
880
+ }
881
+ for (const id of bm.keys()) if (!am.has(id)) return { ok: false, divergence: `the document gained node ${id} the recording never contained` };
882
+ return { ok: false, divergence: "tree shape changed since recording" };
883
+ }
844
884
  function resolveRepEnvelopePath(setDir, slug, kind) {
845
885
  return resolveRepEnvelopePathIn(path.join(setDir, slug), kind);
846
886
  }
@@ -936,13 +976,14 @@ function deriveMetadataEnvelope(restNodesBytes, nodeId) {
936
976
  }
937
977
  };
938
978
  }
939
- var REST_SCREENSHOT_FILE, REST_NODES_FILE, REST_METADATA_FILE, RestNodesEnvelopeSchema, RestScreenshotEnvelopeSchema, RestMetadataEnvelopeSchema, TYPE_TO_TAG, num;
979
+ var REST_SCREENSHOT_FILE, REST_NODES_FILE, REST_METADATA_FILE, REST_INSTANCES_FILE, RestNodesEnvelopeSchema, RestScreenshotEnvelopeSchema, RestInstancesEnvelopeSchema, RestMetadataEnvelopeSchema, TYPE_TO_TAG, num;
940
980
  var init_rest_envelopes = __esm({
941
981
  "packages/figma/src/recording/rest-envelopes.ts"() {
942
982
  "use strict";
943
983
  REST_SCREENSHOT_FILE = "rest_screenshot.json";
944
984
  REST_NODES_FILE = "rest_nodes.json";
945
985
  REST_METADATA_FILE = "rest_metadata.json";
986
+ REST_INSTANCES_FILE = "rest_instances.json";
946
987
  RestNodesEnvelopeSchema = z4.object({
947
988
  url: z4.string().max(2048),
948
989
  params: z4.object({ ids: z4.string().max(4096) }).strict(),
@@ -960,6 +1001,23 @@ var init_rest_envelopes = __esm({
960
1001
  imageUrl: z4.string().max(2048),
961
1002
  content: z4.array(z4.object({ type: z4.literal("image"), data: z4.string() })).length(1)
962
1003
  }).strict();
1004
+ RestInstancesEnvelopeSchema = z4.object({
1005
+ url: z4.string().max(2048),
1006
+ params: z4.object({ ids: z4.string().max(4096) }).strict(),
1007
+ status: z4.number().int(),
1008
+ fileVersion: z4.string().max(128).optional(),
1009
+ fetchedAt: z4.string().max(64),
1010
+ /** "captured" file identity came from the manifest; "asserted"
1011
+ * came from an operator's --file flag at enrich time (disclosed
1012
+ * on every binding it produces). */
1013
+ fileIdentity: z4.enum(["captured", "asserted"]),
1014
+ verifiedAgainst: z4.object({
1015
+ metadataSha256: z4.string().length(64),
1016
+ screenshotSha256: z4.string().length(64).optional(),
1017
+ contextSha256: z4.string().length(64).optional()
1018
+ }).strict(),
1019
+ nodes: z4.record(z4.string(), z4.unknown())
1020
+ }).strict();
963
1021
  RestMetadataEnvelopeSchema = z4.object({
964
1022
  derived: z4.object({ from: z4.literal(REST_NODES_FILE), sha256: z4.string().length(64) }).strict(),
965
1023
  content: z4.array(z4.object({ type: z4.literal("text"), text: z4.string() })).length(1)
@@ -1178,6 +1236,7 @@ var init_envelope_content = __esm({
1178
1236
  import { existsSync as existsSync2, mkdirSync, readFileSync, writeFileSync } from "node:fs";
1179
1237
  import path2 from "node:path";
1180
1238
  import { z as z5 } from "zod";
1239
+ import { createHash as createHash2 } from "node:crypto";
1181
1240
  function manifestMains(manifest) {
1182
1241
  const roles = manifest.roles;
1183
1242
  return roles?.main ?? [];
@@ -1512,6 +1571,68 @@ function ingestRestArtifacts(setDir, slug, input) {
1512
1571
  `);
1513
1572
  return { overwrote };
1514
1573
  }
1574
+ function enrichRepBindings(setDir, slug, input) {
1575
+ const manifest = loadManifest(setDir);
1576
+ const rep = manifest.reps.find((r) => r.slug === slug);
1577
+ if (rep === void 0) return { ok: false, refusal: `unknown rep "${slug}" \u2014 not in the planned manifest` };
1578
+ if (repChannel(manifest, slug) === "rest") {
1579
+ return { ok: false, refusal: `${slug} records over REST \u2014 rest_nodes.json already carries its bindings; nothing to enrich` };
1580
+ }
1581
+ if (existsSync2(containedPath(setDir, slug, REST_NODES_FILE))) {
1582
+ return { ok: false, refusal: `${slug} holds BOTH channel families (invalid rep) \u2014 repair the recording before enriching` };
1583
+ }
1584
+ const metadataFile = containedPath(setDir, slug, "get_metadata.json");
1585
+ if (!existsSync2(metadataFile)) return { ok: false, refusal: `${slug} has no recorded metadata \u2014 congruence has nothing to verify against` };
1586
+ let recordedText;
1587
+ try {
1588
+ recordedText = envelopeTextContent(JSON.parse(readFileSync(metadataFile, "utf8")));
1589
+ } catch {
1590
+ return { ok: false, refusal: `${slug}'s recorded metadata is unreadable \u2014 re-record it before enriching` };
1591
+ }
1592
+ const doc = input.fetchedEntry?.document;
1593
+ if (doc === void 0) return { ok: false, refusal: `the fetch returned no document for ${slug} (${rep.nodeId})` };
1594
+ const congruent = metadataCongruent(recordedText, doc, parseMetadataForest);
1595
+ if (!congruent.ok) return { ok: false, refusal: `${slug} REFUSED \u2014 the document diverged from the recording: ${congruent.divergence}` };
1596
+ const sha = (name) => {
1597
+ const file = containedPath(setDir, slug, name);
1598
+ return existsSync2(file) ? createHash2("sha256").update(readFileSync(file)).digest("hex") : void 0;
1599
+ };
1600
+ const envelope = {
1601
+ url: input.url,
1602
+ params: { ids: rep.nodeId },
1603
+ status: input.status,
1604
+ ...input.fileVersion !== void 0 ? { fileVersion: input.fileVersion } : {},
1605
+ fetchedAt: input.fetchedAt,
1606
+ fileIdentity: input.fileIdentity,
1607
+ verifiedAgainst: {
1608
+ metadataSha256: sha("get_metadata.json"),
1609
+ ...sha("get_screenshot.json") !== void 0 ? { screenshotSha256: sha("get_screenshot.json") } : {},
1610
+ ...sha("get_design_context.json") !== void 0 ? { contextSha256: sha("get_design_context.json") } : {}
1611
+ },
1612
+ nodes: { [rep.nodeId]: input.fetchedEntry }
1613
+ };
1614
+ const parsed = RestInstancesEnvelopeSchema.safeParse(envelope);
1615
+ if (!parsed.success) return { ok: false, refusal: `${slug}/${REST_INSTANCES_FILE} was NOT written \u2014 ${parsed.error.issues[0]?.message ?? "invalid"}` };
1616
+ const bytes = `${JSON.stringify(envelope, null, 1)}
1617
+ `;
1618
+ if (input.accessToken !== "" && bytes.includes(input.accessToken)) {
1619
+ return { ok: false, refusal: `${slug} bindings were NOT written \u2014 the payload contains the live Figma credential, which must never enter a recording` };
1620
+ }
1621
+ writeFileSync(containedPath(setDir, slug, REST_INSTANCES_FILE), bytes);
1622
+ const count = (() => {
1623
+ let n = 0;
1624
+ const walk2 = (node) => {
1625
+ if (node.type === "INSTANCE") {
1626
+ n += 1;
1627
+ return;
1628
+ }
1629
+ if (Array.isArray(node.children)) for (const c of node.children) walk2(c);
1630
+ };
1631
+ walk2(doc);
1632
+ return n;
1633
+ })();
1634
+ return { ok: true, instances: count };
1635
+ }
1515
1636
  function envelopeTextContent(env) {
1516
1637
  const parts = env?.content ?? [];
1517
1638
  return parts.map((c) => c.text ?? "").filter((t) => t !== "").join("\n");
@@ -1549,6 +1670,7 @@ var init_session = __esm({
1549
1670
  init_envelope_content();
1550
1671
  init_plan();
1551
1672
  init_rest_envelopes();
1673
+ init_normalize();
1552
1674
  RECORD_TOOLS = ["get_design_context", "get_metadata", "get_screenshot", "get_variable_defs", "get_metadata_interior", "get_motion_context"];
1553
1675
  INTERIOR_TOOL = "get_metadata_interior";
1554
1676
  REQUIRED_TOOLS = ["get_design_context", "get_metadata", "get_screenshot"];
@@ -1732,7 +1854,7 @@ var init_visibility = __esm({
1732
1854
  });
1733
1855
 
1734
1856
  // packages/figma/src/recording/compose.ts
1735
- import { createHash as createHash2 } from "node:crypto";
1857
+ import { createHash as createHash3 } from "node:crypto";
1736
1858
  import { existsSync as existsSync4, readFileSync as readFileSync3, readdirSync, statSync } from "node:fs";
1737
1859
  import path4 from "node:path";
1738
1860
  import { z as z6 } from "zod";
@@ -1841,29 +1963,56 @@ function emissionTails(setDir, repSlug) {
1841
1963
  }
1842
1964
  return byHead;
1843
1965
  }
1844
- function restInstancePoses(setDir, repSlug, nodeId) {
1845
- const repDir = path4.join(setDir, repSlug);
1846
- if (!resolveRepEnvelopePathIn(repDir, "metadata").endsWith(REST_METADATA_FILE)) return /* @__PURE__ */ new Map();
1847
- const file = path4.join(repDir, REST_NODES_FILE);
1848
- if (!existsSync4(file)) return /* @__PURE__ */ new Map();
1849
- let doc;
1850
- try {
1851
- const parsed = JSON.parse(readFileSync3(file, "utf8"));
1852
- doc = parsed.nodes?.[nodeId]?.document;
1853
- } catch {
1854
- return /* @__PURE__ */ new Map();
1855
- }
1966
+ function bindingsFromEntry(entry) {
1856
1967
  const out = /* @__PURE__ */ new Map();
1968
+ const components = entry?.components ?? {};
1857
1969
  const walk2 = (n) => {
1858
1970
  if (n.type === "INSTANCE") {
1859
- if (typeof n.id === "string" && typeof n.componentId === "string") out.set(n.id, n.componentId);
1971
+ if (typeof n.id === "string" && typeof n.componentId === "string") {
1972
+ const setId = components[n.componentId]?.componentSetId;
1973
+ out.set(n.id, { componentId: n.componentId, ...typeof setId === "string" ? { componentSetId: setId } : {} });
1974
+ }
1860
1975
  return;
1861
1976
  }
1862
1977
  if (Array.isArray(n.children)) for (const c of n.children) walk2(c);
1863
1978
  };
1979
+ const doc = entry?.document;
1864
1980
  if (doc !== null && typeof doc === "object") walk2(doc);
1865
1981
  return out;
1866
1982
  }
1983
+ function restInstancePoses(setDir, repSlug, nodeId) {
1984
+ const repDir = path4.join(setDir, repSlug);
1985
+ const metadataPath = resolveRepEnvelopePathIn(repDir, "metadata");
1986
+ if (!existsSync4(metadataPath)) return NO_BINDINGS;
1987
+ const restNodes = path4.join(repDir, REST_NODES_FILE);
1988
+ if (metadataPath.endsWith(REST_METADATA_FILE)) {
1989
+ if (!existsSync4(restNodes)) return NO_BINDINGS;
1990
+ try {
1991
+ const parsed = JSON.parse(readFileSync3(restNodes, "utf8"));
1992
+ return { bindings: bindingsFromEntry(parsed.nodes?.[nodeId]), source: "captured" };
1993
+ } catch {
1994
+ return NO_BINDINGS;
1995
+ }
1996
+ }
1997
+ if (existsSync4(restNodes)) return NO_BINDINGS;
1998
+ const enriched = path4.join(repDir, REST_INSTANCES_FILE);
1999
+ if (!existsSync4(enriched)) return NO_BINDINGS;
2000
+ try {
2001
+ const parsed = JSON.parse(readFileSync3(enriched, "utf8"));
2002
+ const shaOfPath = (f) => existsSync4(f) ? createHash3("sha256").update(readFileSync3(f)).digest("hex") : void 0;
2003
+ const v = parsed.verifiedAgainst;
2004
+ const staleReasons = v?.metadataSha256 !== shaOfPath(metadataPath) || v?.screenshotSha256 !== void 0 && v.screenshotSha256 !== shaOfPath(resolveRepEnvelopePathIn(repDir, "screenshot")) || v?.contextSha256 !== void 0 && v.contextSha256 !== shaOfPath(path4.join(repDir, "get_design_context.json"));
2005
+ if (staleReasons) return { bindings: /* @__PURE__ */ new Map(), source: "enriched", stale: true, ...typeof parsed.fetchedAt === "string" ? { fetchedAt: parsed.fetchedAt } : {} };
2006
+ return {
2007
+ bindings: bindingsFromEntry(parsed.nodes?.[nodeId]),
2008
+ source: "enriched",
2009
+ ...typeof parsed.fetchedAt === "string" ? { fetchedAt: parsed.fetchedAt } : {},
2010
+ ...parsed.fileIdentity === "asserted" ? { identityAsserted: true } : {}
2011
+ };
2012
+ } catch {
2013
+ return NO_BINDINGS;
2014
+ }
2015
+ }
1867
2016
  function hostInstances(setDir, repSlug) {
1868
2017
  const metaFile = resolveRepEnvelopePathIn(path4.join(setDir, repSlug), "metadata");
1869
2018
  if (!existsSync4(metaFile)) return [];
@@ -1915,15 +2064,31 @@ function composeReport(index) {
1915
2064
  continue;
1916
2065
  }
1917
2066
  const tails = tailsByHead.get(inst.id) ?? /* @__PURE__ */ new Map();
1918
- const cid = cidByInstance.get(inst.id);
2067
+ const binding = cidByInstance.bindings.get(inst.id);
2068
+ const cid = binding?.componentId;
1919
2069
  const disclosures = [];
2070
+ if (cidByInstance.stale === true) {
2071
+ disclosures.push(
2072
+ `an ENRICHMENT for this pose predates a re-record of its evidence \u2014 its bindings are IGNORED until the bindings fetch is re-run`
2073
+ );
2074
+ }
1920
2075
  const refused = [];
2076
+ const staleRemediation = cidByInstance.stale === true ? { remediation: { kind: "re-bindings", hostSet: host.dir } } : {};
2077
+ let cidIdentityRefused;
1921
2078
  const idCands = index.filter((c) => {
1922
2079
  if (c.dir === host.dir || sameComponent(c, host)) return false;
1923
- if (cid !== void 0 && c.variantNodeIds.has(cid)) return true;
2080
+ if (cid !== void 0 && c.variantNodeIds.has(cid)) {
2081
+ if (binding?.componentSetId === void 0 || c.componentSetNode === void 0 || binding.componentSetId === c.componentSetNode) return true;
2082
+ cidIdentityRefused = c;
2083
+ }
1924
2084
  for (const t of tails.keys()) if (c.ownIds.has(t)) return true;
1925
2085
  return false;
1926
2086
  });
2087
+ if (cidIdentityRefused !== void 0) {
2088
+ disclosures.push(
2089
+ `binding identity REFUSED: Figma names component set ${binding?.componentSetId ?? "?"} for this instance while ${kitLabel(cidIdentityRefused)} captured set ${cidIdentityRefused.componentSetNode ?? "?"} \u2014 a lookalike variant id is not a pair`
2090
+ );
2091
+ }
1927
2092
  const survivors = [];
1928
2093
  for (const c of idCands) {
1929
2094
  if (host.figmaFile !== void 0 && c.figmaFile !== void 0 && c.figmaFile !== host.figmaFile) {
@@ -1969,18 +2134,23 @@ function composeReport(index) {
1969
2134
  disclosures: [
1970
2135
  ...disclosures,
1971
2136
  `NAME-ONLY proposal \u2014 identity unproven, name evidence never auto-joins: instance "${inst.name}" names ${nameProps.map(kitLabel).join(" / ")}. Name-only edges are NOT confirmable (there is nothing sound to confirm); record the partner so an id-backed join can form, or ignore`,
1972
- // The remediation is CONDITIONAL (review): componentId
1973
- // is a host-file-namespace id, so the promise holds
1974
- // only for same-file partners and only when the host
1975
- // rep was recorded over MCP (a REST-recorded rep with
1976
- // no binding genuinely has none to capture).
1977
- ...repIsMcpRecorded && host.figmaFile !== void 0 && nameProps.some((p) => p.figmaFile === host.figmaFile) ? [
1978
- `this host records over MCP, so Figma's instance\u2192component binding was never captured \u2014 re-recording the HOST over the REST channel makes this pair id-backed (a fresh recording, then a full host regeneration and re-verify)`
2137
+ // The claim is CONDITIONAL (review): componentId is a
2138
+ // host-file-namespace id, so the promise holds only
2139
+ // for same-file partners (uncaptured identities get
2140
+ // the attempt, disclosed) and only when the host rep
2141
+ // records over MCP (a REST-recorded rep with no
2142
+ // binding genuinely has none to capture).
2143
+ ...repIsMcpRecorded && !(host.figmaFile !== void 0 && nameProps.every((p) => p.figmaFile !== void 0 && p.figmaFile !== host.figmaFile)) ? [
2144
+ // Worded to stay clear of the free-forever
2145
+ // ratchet's \bfetch( pattern — prose, no call.
2146
+ `this host records over MCP, so Figma's instance\u2192component binding was never captured \u2014 one batched \`record bindings\` run makes an id-backed pairing possible without re-recording; re-recording the host over the REST channel is the heavier alternative`
1979
2147
  ] : []
1980
- ]
2148
+ ],
2149
+ ...repIsMcpRecorded && !(host.figmaFile !== void 0 && nameProps.every((p) => p.figmaFile !== void 0 && p.figmaFile !== host.figmaFile)) ? { remediation: { kind: "bindings", hostSet: host.dir } } : {},
2150
+ ...staleRemediation
1981
2151
  });
1982
2152
  } else if (disclosures.length > 0) {
1983
- edges.push({ hostSet: host.dir, hostRep: slug, instanceId: inst.id, instanceName: inst.name, kind: "external", partners: [], disclosures });
2153
+ edges.push({ hostSet: host.dir, hostRep: slug, instanceId: inst.id, instanceName: inst.name, kind: "external", partners: [], disclosures, ...staleRemediation });
1984
2154
  }
1985
2155
  continue;
1986
2156
  }
@@ -1999,10 +2169,14 @@ function composeReport(index) {
1999
2169
  const group = eligible[0];
2000
2170
  const ownedDepths = [];
2001
2171
  for (const [t, d] of tails) if (group.some((m) => m.ownIds.has(t))) ownedDepths.push(d);
2002
- const cidBacked = cid !== void 0 && group.some((m) => m.variantNodeIds.has(cid));
2172
+ const cidOwner = cid !== void 0 ? group.find((m) => m.variantNodeIds.has(cid)) : void 0;
2173
+ const cidBacked = cidOwner !== void 0;
2174
+ if (cidBacked && (binding?.componentSetId === void 0 || cidOwner.componentSetNode === void 0)) {
2175
+ disclosures.push(`binding identity PARTIALLY proven: the component-set cross-check had a missing side (${binding?.componentSetId === void 0 ? "no components map in the fetched entry" : "partner's componentSetNode not captured"})`);
2176
+ }
2003
2177
  const substitution = (ownedDepths.length > 0 || cidBacked) && ownedDepths.every((d) => d === 1);
2004
2178
  const poseVariants = /* @__PURE__ */ new Set();
2005
- if (cidBacked) poseVariants.add(cid);
2179
+ if (cidBacked && cid !== void 0) poseVariants.add(cid);
2006
2180
  for (const m of group) {
2007
2181
  for (const [t, d] of tails) {
2008
2182
  if (d !== 1 || !m.ownIds.has(t)) continue;
@@ -2059,7 +2233,12 @@ function composeReport(index) {
2059
2233
  },
2060
2234
  disclosures: [
2061
2235
  ...disclosures,
2062
- `substitution-grade (${[...ownedDepths.length > 0 ? ["every owned emission id at depth 1"] : [], ...cidBacked ? ["Figma's instance\u2192component binding (componentId), recorded verbatim over REST"] : []].join(" + ")}); pixel-neutrality is NOT asserted here \u2014 instance overrides are measured real, and a per-region check is still future work (whole-frame pixels remain the evidence for composed regions)`
2236
+ `substitution-grade (${[
2237
+ ...ownedDepths.length > 0 ? ["every owned emission id at depth 1"] : [],
2238
+ ...cidBacked ? [
2239
+ cidByInstance.source === "enriched" ? `Figma's instance\u2192component binding (componentId), ENRICHED${cidByInstance.fetchedAt !== void 0 ? ` ${cidByInstance.fetchedAt}` : ""} \u2014 congruence-verified against the recorded metadata, NOT captured with the pixels${cidByInstance.identityAsserted === true ? "; file identity asserted at enrich time, not captured at record time" : ""}` : "Figma's instance\u2192component binding (componentId), recorded verbatim over REST"
2240
+ ] : []
2241
+ ].join(" + ")}); pixel-neutrality is NOT asserted here \u2014 instance overrides are measured real, and a per-region check is still future work (whole-frame pixels remain the evidence for composed regions)`
2063
2242
  ]
2064
2243
  });
2065
2244
  }
@@ -2153,7 +2332,7 @@ function confirmedCompositionStatus(hostSet) {
2153
2332
  return { rows, ...malformedEntries.length > 0 ? { malformed: malformedEntries.join("; ") } : {} };
2154
2333
  }
2155
2334
  function createHashHex(bytes) {
2156
- return createHash2("sha256").update(bytes).digest("hex");
2335
+ return createHash3("sha256").update(bytes).digest("hex");
2157
2336
  }
2158
2337
  function liteIdentity(dir) {
2159
2338
  try {
@@ -2242,19 +2421,21 @@ function partnerOpportunities(partnerSet, roots) {
2242
2421
  });
2243
2422
  }
2244
2423
  if (byKind.proposal.length > 0) {
2424
+ const remediation = byKind.proposal.find((e) => e.remediation !== void 0)?.remediation;
2245
2425
  opportunities.push({
2246
2426
  hostSet,
2247
2427
  hostDisplayName: hostName,
2248
2428
  kind: "name-only",
2249
2429
  instances: byKind.proposal.length,
2250
2430
  hostReps: [...new Set(byKind.proposal.map((e) => e.hostRep))],
2251
- disclosures: [...new Set(byKind.proposal.flatMap((e) => e.disclosures))]
2431
+ disclosures: [...new Set(byKind.proposal.flatMap((e) => e.disclosures))],
2432
+ ...remediation !== void 0 ? { remediation } : {}
2252
2433
  });
2253
2434
  }
2254
2435
  }
2255
2436
  return { opportunities, hostsWithUnreadableDecisions: unreadable };
2256
2437
  }
2257
- var CompositionEntrySchema, norm, FOOTER, kitLabel, toPosixRel, fromStoredRel, pairKeyFor;
2438
+ var CompositionEntrySchema, norm, FOOTER, NO_BINDINGS, kitLabel, toPosixRel, fromStoredRel, pairKeyFor;
2258
2439
  var init_compose = __esm({
2259
2440
  "packages/figma/src/recording/compose.ts"() {
2260
2441
  "use strict";
@@ -2275,6 +2456,7 @@ var init_compose = __esm({
2275
2456
  });
2276
2457
  norm = (s) => s.toLowerCase().replace(/\s+/g, " ").trim();
2277
2458
  FOOTER = /Node ids have been added to the code as data attributes/i;
2459
+ NO_BINDINGS = { bindings: /* @__PURE__ */ new Map(), source: "none" };
2278
2460
  kitLabel = (e) => `${e.displayName} [${path4.basename(e.dir)}${e.figmaFile !== void 0 ? `, file ${e.figmaFile}` : ", file identity NOT captured"}]`;
2279
2461
  toPosixRel = (rel) => rel.split(path4.sep).join("/");
2280
2462
  fromStoredRel = (rel) => rel.replace(/\\/g, "/");
@@ -2402,8 +2584,8 @@ var init_src = __esm({
2402
2584
  function variableNameToPath(name) {
2403
2585
  return name.split("/").map(canonicalCssIdentPart).filter((part) => part.length > 0);
2404
2586
  }
2405
- function tokenPathToCssVar(path59) {
2406
- return `--${path59.join("-")}`;
2587
+ function tokenPathToCssVar(path60) {
2588
+ return `--${path60.join("-")}`;
2407
2589
  }
2408
2590
  function toDtcgToken(variable, defaultMode) {
2409
2591
  const modes = Object.keys(variable.valuesByMode);
@@ -2447,11 +2629,11 @@ function toDtcgToken(variable, defaultMode) {
2447
2629
  }
2448
2630
  function mapVariablesToDtcg(variables, defaultMode = "light") {
2449
2631
  const entries = variables.map((variable) => {
2450
- const path59 = variableNameToPath(variable.name);
2451
- if (path59.length === 0) {
2632
+ const path60 = variableNameToPath(variable.name);
2633
+ if (path60.length === 0) {
2452
2634
  throw new DtcgMappingError("empty-name", `Figma variable ${variable.id} has an empty name`);
2453
2635
  }
2454
- return { variable, path: path59 };
2636
+ return { variable, path: path60 };
2455
2637
  });
2456
2638
  const groupPrefixes = /* @__PURE__ */ new Set();
2457
2639
  for (const e of entries) {
@@ -2472,21 +2654,21 @@ function mapVariablesToDtcg(variables, defaultMode = "light") {
2472
2654
  }
2473
2655
  const tokens = {};
2474
2656
  const flat = [];
2475
- for (const { variable, path: path59 } of entries) {
2657
+ for (const { variable, path: path60 } of entries) {
2476
2658
  const token = toDtcgToken(variable, defaultMode);
2477
2659
  let group = tokens;
2478
- for (const segment of path59.slice(0, -1)) {
2660
+ for (const segment of path60.slice(0, -1)) {
2479
2661
  const existing = group[segment];
2480
2662
  group = existing ?? (group[segment] = {});
2481
2663
  }
2482
- const leaf = path59[path59.length - 1];
2664
+ const leaf = path60[path60.length - 1];
2483
2665
  if (group[leaf] !== void 0) {
2484
- throw new DtcgMappingError("duplicate-path", `Duplicate token path "${path59.join(".")}" (variable ${variable.id})`);
2666
+ throw new DtcgMappingError("duplicate-path", `Duplicate token path "${path60.join(".")}" (variable ${variable.id})`);
2485
2667
  }
2486
2668
  group[leaf] = token;
2487
2669
  flat.push({
2488
- path: path59.join("."),
2489
- cssVar: tokenPathToCssVar(path59),
2670
+ path: path60.join("."),
2671
+ cssVar: tokenPathToCssVar(path60),
2490
2672
  type: token.$type,
2491
2673
  value: token.$value
2492
2674
  });
@@ -2675,9 +2857,9 @@ function boundId(value) {
2675
2857
  return isObject(value) && typeof value["boundVariableId"] === "string" ? value["boundVariableId"] : void 0;
2676
2858
  }
2677
2859
  function resolveBinding(ctx, id) {
2678
- const path59 = ctx.pathById.get(id);
2679
- if (path59 === void 0) ctx.unresolved.add(id);
2680
- return path59;
2860
+ const path60 = ctx.pathById.get(id);
2861
+ if (path60 === void 0) ctx.unresolved.add(id);
2862
+ return path60;
2681
2863
  }
2682
2864
  function parseVariantProps(name) {
2683
2865
  if (!name.includes("=")) return void 0;
@@ -2712,8 +2894,8 @@ function walk(ctx, raw) {
2712
2894
  if (!isObject(paint) || paint["visible"] === false) continue;
2713
2895
  const id = boundId(paint);
2714
2896
  if (id !== void 0) {
2715
- const path59 = resolveBinding(ctx, id);
2716
- if (path59 !== void 0) tokens.add(path59);
2897
+ const path60 = resolveBinding(ctx, id);
2898
+ if (path60 !== void 0) tokens.add(path60);
2717
2899
  } else if (typeof paint["color"] === "string") {
2718
2900
  ctx.hardcoded.push({ node: name, property, value: paint["color"] });
2719
2901
  }
@@ -2721,8 +2903,8 @@ function walk(ctx, raw) {
2721
2903
  }
2722
2904
  const radiusId = boundId(raw["cornerRadius"]);
2723
2905
  if (radiusId !== void 0) {
2724
- const path59 = resolveBinding(ctx, radiusId);
2725
- if (path59 !== void 0) tokens.add(path59);
2906
+ const path60 = resolveBinding(ctx, radiusId);
2907
+ if (path60 !== void 0) tokens.add(path60);
2726
2908
  } else if (typeof raw["cornerRadius"] === "number" && raw["cornerRadius"] !== 0) {
2727
2909
  ctx.hardcoded.push({ node: name, property: "border-radius", value: `${raw["cornerRadius"]}px` });
2728
2910
  }
@@ -2732,10 +2914,10 @@ function walk(ctx, raw) {
2732
2914
  layout = { mode: layoutMode === "HORIZONTAL" ? "flex-row" : "flex-column" };
2733
2915
  const gapId = boundId(raw["itemSpacing"]);
2734
2916
  if (gapId !== void 0) {
2735
- const path59 = resolveBinding(ctx, gapId);
2736
- if (path59 !== void 0) {
2737
- layout.gap = path59;
2738
- tokens.add(path59);
2917
+ const path60 = resolveBinding(ctx, gapId);
2918
+ if (path60 !== void 0) {
2919
+ layout.gap = path60;
2920
+ tokens.add(path60);
2739
2921
  }
2740
2922
  } else if (typeof raw["itemSpacing"] === "number" && raw["itemSpacing"] !== 0) {
2741
2923
  ctx.hardcoded.push({ node: name, property: "gap", value: `${raw["itemSpacing"]}px` });
@@ -2744,10 +2926,10 @@ function walk(ctx, raw) {
2744
2926
  for (const field of PADDING_FIELDS) {
2745
2927
  const id = boundId(raw[field]);
2746
2928
  if (id !== void 0) {
2747
- const path59 = resolveBinding(ctx, id);
2748
- if (path59 !== void 0) {
2749
- paddingPaths.push(path59);
2750
- tokens.add(path59);
2929
+ const path60 = resolveBinding(ctx, id);
2930
+ if (path60 !== void 0) {
2931
+ paddingPaths.push(path60);
2932
+ tokens.add(path60);
2751
2933
  }
2752
2934
  } else if (typeof raw[field] === "number" && raw[field] !== 0) {
2753
2935
  ctx.hardcoded.push({ node: name, property: field, value: `${raw[field]}px` });
@@ -4926,7 +5108,7 @@ var init_font_discovery = __esm({
4926
5108
  });
4927
5109
 
4928
5110
  // packages/verify/src/font-resolve.ts
4929
- import { createHash as createHash3 } from "node:crypto";
5111
+ import { createHash as createHash4 } from "node:crypto";
4930
5112
  import { existsSync as existsSync10, mkdirSync as mkdirSync2, readFileSync as readFileSync7, writeFileSync as writeFileSync3 } from "node:fs";
4931
5113
  import os3 from "node:os";
4932
5114
  import path14 from "node:path";
@@ -4997,7 +5179,7 @@ async function resolveFonts(family, weights, cacheDir = DEFAULT_FONT_CACHE) {
4997
5179
  continue;
4998
5180
  }
4999
5181
  const bytes = new Uint8Array(await fileRes.arrayBuffer());
5000
- const sha256 = createHash3("sha256").update(bytes).digest("hex");
5182
+ const sha256 = createHash4("sha256").update(bytes).digest("hex");
5001
5183
  const file = path14.join(cacheDir, `${family.toLowerCase().replace(/\s+/g, "-")}-${weight}.woff2`);
5002
5184
  writeFileSync3(file, bytes);
5003
5185
  resolved.push({ family, weight, source: url, sha256, file, license: meta.license, ...meta.family !== null ? { servedFamily: meta.family } : {} });
@@ -5038,7 +5220,7 @@ ${shown}`
5038
5220
  storedExt = ".ttf";
5039
5221
  }
5040
5222
  mkdirSync2(cacheDir, { recursive: true });
5041
- const sha256 = createHash3("sha256").update(bytes).digest("hex");
5223
+ const sha256 = createHash4("sha256").update(bytes).digest("hex");
5042
5224
  const file = path14.join(cacheDir, `${family.toLowerCase().replace(/\s+/g, "-")}-${weight}${storedExt}`);
5043
5225
  writeFileSync3(file, bytes);
5044
5226
  const face = { family, weight, source: `${provenance}:${path14.basename(src)}`, sha256, file, license: "unknown" };
@@ -5118,7 +5300,7 @@ function verifiedFontFamilies(cacheDir = DEFAULT_FONT_CACHE) {
5118
5300
  if (typeof e.family !== "string" || typeof e.weight !== "number" || typeof e.file !== "string") continue;
5119
5301
  const file = path14.isAbsolute(e.file) && existsSync10(e.file) ? e.file : path14.resolve(cacheDir, path14.basename(e.file));
5120
5302
  if (!existsSync10(file)) continue;
5121
- if (createHash3("sha256").update(readFileSync7(file)).digest("hex") !== e.sha256) continue;
5303
+ if (createHash4("sha256").update(readFileSync7(file)).digest("hex") !== e.sha256) continue;
5122
5304
  const set = byFamily.get(e.family) ?? /* @__PURE__ */ new Set();
5123
5305
  set.add(e.weight);
5124
5306
  byFamily.set(e.family, set);
@@ -5201,7 +5383,7 @@ var init_font_resolve = __esm({
5201
5383
  });
5202
5384
 
5203
5385
  // packages/verify/src/font-faces.ts
5204
- import { createHash as createHash4 } from "node:crypto";
5386
+ import { createHash as createHash5 } from "node:crypto";
5205
5387
  import { existsSync as existsSync11, readFileSync as readFileSync8 } from "node:fs";
5206
5388
  import path15 from "node:path";
5207
5389
  function injectedGroups(manifestPath2) {
@@ -5212,7 +5394,7 @@ function injectedGroups(manifestPath2) {
5212
5394
  for (const f of claimed) {
5213
5395
  const file = resolveFile(f.file);
5214
5396
  if (!existsSync11(file)) continue;
5215
- if (createHash4("sha256").update(readFileSync8(file)).digest("hex") !== f.sha256) continue;
5397
+ if (createHash5("sha256").update(readFileSync8(file)).digest("hex") !== f.sha256) continue;
5216
5398
  const k = `${f.family}:${f.file}`;
5217
5399
  const e = byFile.get(k) ?? { family: f.family, weights: [], file };
5218
5400
  e.weights.push(f.weight);
@@ -6675,43 +6857,43 @@ function classifyBundleSurface(files, opts) {
6675
6857
  const excluded = [];
6676
6858
  const unknown = [];
6677
6859
  for (const raw of files) {
6678
- const path59 = raw.replace(/\\/g, "/").replace(/^\.\//, "");
6679
- const inEvidence = path59.startsWith(`${EVIDENCE_DIR}/`);
6680
- if (path59.startsWith("fonts/")) {
6681
- const fname = path59.slice("fonts/".length);
6860
+ const path60 = raw.replace(/\\/g, "/").replace(/^\.\//, "");
6861
+ const inEvidence = path60.startsWith(`${EVIDENCE_DIR}/`);
6862
+ if (path60.startsWith("fonts/")) {
6863
+ const fname = path60.slice("fonts/".length);
6682
6864
  if (!fname.includes("/") && (/\.(woff2?|ttf|otf)$/i.test(fname) || /^(NOTICE|LICENSE|LICENCE)[^/]*\.txt$/i.test(fname))) {
6683
- excluded.push({ path: path59, reason: "font payload \u2014 not published (fonts policy pending); faces are sha-pinned in component.json requiredFonts" });
6865
+ excluded.push({ path: path60, reason: "font payload \u2014 not published (fonts policy pending); faces are sha-pinned in component.json requiredFonts" });
6684
6866
  continue;
6685
6867
  }
6686
- unknown.push(path59);
6868
+ unknown.push(path60);
6687
6869
  continue;
6688
6870
  }
6689
- const name = inEvidence ? path59.slice(EVIDENCE_DIR.length + 1) : path59;
6871
+ const name = inEvidence ? path60.slice(EVIDENCE_DIR.length + 1) : path60;
6690
6872
  if (name.includes("/")) {
6691
- unknown.push(path59);
6873
+ unknown.push(path60);
6692
6874
  continue;
6693
6875
  }
6694
6876
  if (inEvidence) {
6695
- if (name === "verify-report.json") published.push({ path: path59, role: "verify-report" });
6696
- else if (name === "diff-legend.txt") published.push({ path: path59, role: "diff-legend" });
6697
- else if (name === "inspect.html") published.push({ path: path59, role: "inspect-sheet" });
6698
- else if (/-mount-failure\.png$/.test(name)) excluded.push({ path: path59, reason: "harness failure diagnostic (regenerated every verify run, never published)" });
6877
+ if (name === "verify-report.json") published.push({ path: path60, role: "verify-report" });
6878
+ else if (name === "diff-legend.txt") published.push({ path: path60, role: "diff-legend" });
6879
+ else if (name === "inspect.html") published.push({ path: path60, role: "inspect-sheet" });
6880
+ else if (/-mount-failure\.png$/.test(name)) excluded.push({ path: path60, reason: "harness failure diagnostic (regenerated every verify run, never published)" });
6699
6881
  else {
6700
6882
  const hit = EVIDENCE_PATTERNS.find((p) => p.re.test(name));
6701
- if (hit !== void 0) published.push({ path: path59, role: hit.role });
6702
- else unknown.push(path59);
6883
+ if (hit !== void 0) published.push({ path: path60, role: hit.role });
6884
+ else unknown.push(path60);
6703
6885
  }
6704
6886
  continue;
6705
6887
  }
6706
- if (name === opts.entry) published.push({ path: path59, role: "entry" });
6707
- else if (name === "styles.css") published.push({ path: path59, role: "styles" });
6708
- else if (name === "tokens.css") published.push({ path: path59, role: "tokens" });
6709
- else if (name === "fonts.css") published.push({ path: path59, role: "fonts" });
6710
- else if (name === "component.json") published.push({ path: path59, role: "manifest" });
6888
+ if (name === opts.entry) published.push({ path: path60, role: "entry" });
6889
+ else if (name === "styles.css") published.push({ path: path60, role: "styles" });
6890
+ else if (name === "tokens.css") published.push({ path: path60, role: "tokens" });
6891
+ else if (name === "fonts.css") published.push({ path: path60, role: "fonts" });
6892
+ else if (name === "component.json") published.push({ path: path60, role: "manifest" });
6711
6893
  else {
6712
6894
  const skip = EXCLUDED.find((e) => e.test(name));
6713
- if (skip !== void 0) excluded.push({ path: path59, reason: skip.reason });
6714
- else unknown.push(path59);
6895
+ if (skip !== void 0) excluded.push({ path: path60, reason: skip.reason });
6896
+ else unknown.push(path60);
6715
6897
  }
6716
6898
  }
6717
6899
  const roles = new Set(published.map((p) => p.role));
@@ -6721,8 +6903,8 @@ function missingInspectCrops(sheetText, publishedPaths) {
6721
6903
  const held = new Set(publishedPaths);
6722
6904
  const missing = /* @__PURE__ */ new Set();
6723
6905
  for (const [, name] of sheetText.matchAll(/src="\.\/([^"]+)"/g)) {
6724
- const path59 = `${EVIDENCE_DIR}/${name}`;
6725
- if (!held.has(path59)) missing.add(path59);
6906
+ const path60 = `${EVIDENCE_DIR}/${name}`;
6907
+ if (!held.has(path60)) missing.add(path60);
6726
6908
  }
6727
6909
  return [...missing].sort();
6728
6910
  }
@@ -6758,7 +6940,7 @@ var init_published_surface = __esm({
6758
6940
  });
6759
6941
 
6760
6942
  // packages/metadata/src/bundle-files.ts
6761
- import { createHash as createHash5 } from "node:crypto";
6943
+ import { createHash as createHash6 } from "node:crypto";
6762
6944
  import { readFileSync as readFileSync12, readdirSync as readdirSync6, statSync as statSync3 } from "node:fs";
6763
6945
  import path20 from "node:path";
6764
6946
  function bundleFiles(dir, prefix = "") {
@@ -6776,7 +6958,7 @@ function digestScoredFiles(bundleDir, entry) {
6776
6958
  const out = {};
6777
6959
  for (const file of surface.published) {
6778
6960
  if (!SCORED_FILE_ROLES.has(file.role)) continue;
6779
- out[file.path] = createHash5("sha256").update(readFileSync12(path20.join(bundleDir, file.path))).digest("hex");
6961
+ out[file.path] = createHash6("sha256").update(readFileSync12(path20.join(bundleDir, file.path))).digest("hex");
6780
6962
  }
6781
6963
  return out;
6782
6964
  }
@@ -6830,10 +7012,10 @@ function readScoredFiles(report) {
6830
7012
  const entries = Object.entries(value);
6831
7013
  if (entries.length === 0) return void 0;
6832
7014
  const out = {};
6833
- for (const [path59, digest] of entries) {
6834
- if (path59 === "" || path59.startsWith("/") || path59.includes("..")) return void 0;
7015
+ for (const [path60, digest] of entries) {
7016
+ if (path60 === "" || path60.startsWith("/") || path60.includes("..")) return void 0;
6835
7017
  if (!isSetHash(digest)) return void 0;
6836
- out[path59] = digest;
7018
+ out[path60] = digest;
6837
7019
  }
6838
7020
  return out;
6839
7021
  }
@@ -6841,11 +7023,11 @@ function compareScoredFiles(recorded, actual) {
6841
7023
  const missing = [];
6842
7024
  const unscored = [];
6843
7025
  const changed = [];
6844
- for (const [path59, digest] of Object.entries(recorded)) {
6845
- if (!(path59 in actual)) missing.push(path59);
6846
- else if (actual[path59] !== digest) changed.push(path59);
7026
+ for (const [path60, digest] of Object.entries(recorded)) {
7027
+ if (!(path60 in actual)) missing.push(path60);
7028
+ else if (actual[path60] !== digest) changed.push(path60);
6847
7029
  }
6848
- for (const path59 of Object.keys(actual)) if (!(path59 in recorded)) unscored.push(path59);
7030
+ for (const path60 of Object.keys(actual)) if (!(path60 in recorded)) unscored.push(path60);
6849
7031
  return { missing: missing.sort(), unscored: unscored.sort(), changed: changed.sort() };
6850
7032
  }
6851
7033
  function scoredRecordingSetHash(report) {
@@ -8690,7 +8872,7 @@ var init_src5 = __esm({
8690
8872
  // packages/cli/src/environment.ts
8691
8873
  import { existsSync as existsSync20, readFileSync as readFileSync17 } from "node:fs";
8692
8874
  import path26 from "node:path";
8693
- import { createHash as createHash6 } from "node:crypto";
8875
+ import { createHash as createHash7 } from "node:crypto";
8694
8876
  import { fileURLToPath as fileURLToPath5 } from "node:url";
8695
8877
  function cliVersion() {
8696
8878
  try {
@@ -8707,7 +8889,7 @@ function environmentStamp(taskFamilies) {
8707
8889
  const entries = JSON.parse(readFileSync17(manifestPath2, "utf8"));
8708
8890
  const scoped = taskFamilies === void 0 || taskFamilies === null ? entries : entries.filter((f) => taskFamilies.some((fam) => fam.toLowerCase() === f.family.toLowerCase()));
8709
8891
  const faces = scoped.map((f) => `${f.family}:${f.weight}:${f.sha256}`).sort();
8710
- fontsHash = faces.length === 0 ? null : createHash6("sha256").update(faces.join("\n")).digest("hex").slice(0, 16);
8892
+ fontsHash = faces.length === 0 ? null : createHash7("sha256").update(faces.join("\n")).digest("hex").slice(0, 16);
8711
8893
  } catch {
8712
8894
  fontsHash = null;
8713
8895
  }
@@ -8904,6 +9086,12 @@ var init_publish_client = __esm({
8904
9086
  pollApproval(input) {
8905
9087
  return this.json("GET", `/api/publish-approvals/${encodeURIComponent(input.approvalId)}`, null, true);
8906
9088
  }
9089
+ requestComposeApproval(input) {
9090
+ return this.json("POST", "/api/compose-approvals", input);
9091
+ }
9092
+ pollComposeApproval(input) {
9093
+ return this.json("GET", `/api/compose-approvals/${encodeURIComponent(input.approvalId)}`, null, true);
9094
+ }
8907
9095
  whoami() {
8908
9096
  return this.json("GET", "/api/whoami", null, true);
8909
9097
  }
@@ -11993,7 +12181,7 @@ var init_adapter = __esm({
11993
12181
  });
11994
12182
 
11995
12183
  // packages/generate/src/bundle-emit.ts
11996
- import { createHash as createHash7 } from "node:crypto";
12184
+ import { createHash as createHash8 } from "node:crypto";
11997
12185
  import { copyFileSync, existsSync as existsSync30, mkdirSync as mkdirSync9, readFileSync as readFileSync27, readdirSync as readdirSync11, rmSync as rmSync5, writeFileSync as writeFileSync13 } from "node:fs";
11998
12186
  import path37 from "node:path";
11999
12187
  function pinFromConfigs(configs) {
@@ -12104,7 +12292,7 @@ function fontsCssFor(faces, bundleDir, cacheDir = DEFAULT_FONT_CACHE, substitute
12104
12292
  `/* ${decl} */`
12105
12293
  );
12106
12294
  }
12107
- } else if (existsSync30(src) && createHash7("sha256").update(readFileSync27(src)).digest("hex") === face.sha256) {
12295
+ } else if (existsSync30(src) && createHash8("sha256").update(readFileSync27(src)).digest("hex") === face.sha256) {
12108
12296
  mkdirSync9(path37.join(bundleDir, "fonts"), { recursive: true });
12109
12297
  copyFileSync(src, path37.join(bundleDir, "fonts", path37.basename(face.file)));
12110
12298
  licenseTexts.set(terms.file, terms.text);
@@ -12193,7 +12381,7 @@ function recordingSetHash(setDir, configs) {
12193
12381
  relPaths,
12194
12382
  (p) => new Uint8Array(readFileSync27(path37.join(setDir, p))),
12195
12383
  (chunks) => {
12196
- const h = createHash7("sha256");
12384
+ const h = createHash8("sha256");
12197
12385
  for (const c of chunks) h.update(c);
12198
12386
  return h.digest("hex");
12199
12387
  }
@@ -12745,7 +12933,7 @@ DEALINGS IN THE FONT SOFTWARE.
12745
12933
  });
12746
12934
 
12747
12935
  // packages/generate/src/compose-pins.ts
12748
- import { createHash as createHash8 } from "node:crypto";
12936
+ import { createHash as createHash9 } from "node:crypto";
12749
12937
  import { existsSync as existsSync31, readFileSync as readFileSync28, readdirSync as readdirSync12, realpathSync as realpathSync3, statSync as statSync4 } from "node:fs";
12750
12938
  import path38 from "node:path";
12751
12939
  function bundleDirs(roots, depth = 4) {
@@ -12883,7 +13071,7 @@ function composedPins(hostSet, libraryRoots) {
12883
13071
  fileIssue = `${rel}: partner file ${name} exceeds the pin size cap (${bytes.byteLength} bytes)`;
12884
13072
  break;
12885
13073
  }
12886
- moduleFiles.push({ name, content: bytes.toString("utf8"), sha256: createHash8("sha256").update(bytes).digest("hex") });
13074
+ moduleFiles.push({ name, content: bytes.toString("utf8"), sha256: createHash9("sha256").update(bytes).digest("hex") });
12887
13075
  }
12888
13076
  if (fileIssue !== void 0) {
12889
13077
  failures.push(fileIssue);
@@ -12969,7 +13157,7 @@ function composedChecks(candidateDir, hostEntry, pins) {
12969
13157
  wrong.push(`${f.name} missing`);
12970
13158
  continue;
12971
13159
  }
12972
- const sha = createHash8("sha256").update(readFileSync28(target)).digest("hex");
13160
+ const sha = createHash9("sha256").update(readFileSync28(target)).digest("hex");
12973
13161
  if (sha !== f.sha256) wrong.push(`${f.name} differs from the pinned partner bytes`);
12974
13162
  }
12975
13163
  checks.push({
@@ -14474,14 +14662,14 @@ var init_figma_rest = __esm({
14474
14662
  });
14475
14663
 
14476
14664
  // packages/cli/src/run-presence.ts
14477
- import { createHash as createHash9 } from "node:crypto";
14665
+ import { createHash as createHash10 } from "node:crypto";
14478
14666
  import { existsSync as existsSync34, mkdirSync as mkdirSync11, readFileSync as readFileSync30, rmSync as rmSync6, writeFileSync as writeFileSync15 } from "node:fs";
14479
14667
  import path43 from "node:path";
14480
14668
  function presenceDir() {
14481
14669
  return path43.join(path43.dirname(sessionPath()), "runs");
14482
14670
  }
14483
14671
  function presenceFile(componentName) {
14484
- return path43.join(presenceDir(), `${createHash9("sha256").update(componentName).digest("hex").slice(0, 16)}.json`);
14672
+ return path43.join(presenceDir(), `${createHash10("sha256").update(componentName).digest("hex").slice(0, 16)}.json`);
14485
14673
  }
14486
14674
  function readCached(componentName) {
14487
14675
  const file = presenceFile(componentName);
@@ -14544,12 +14732,21 @@ var compose_exports = {};
14544
14732
  __export(compose_exports, {
14545
14733
  COMPOSE_DESCRIPTION: () => COMPOSE_DESCRIPTION,
14546
14734
  IMPLICIT_PARENT_SCAN_MAX_ENTRIES: () => IMPLICIT_PARENT_SCAN_MAX_ENTRIES,
14735
+ buildCompositionEntries: () => buildCompositionEntries,
14547
14736
  compositionPairsFor: () => compositionPairsFor,
14548
- runCompose: () => runCompose
14737
+ renderBindingsRemediation: () => renderBindingsRemediation,
14738
+ runCompose: () => runCompose,
14739
+ substitutionPairs: () => substitutionPairs,
14740
+ writeCompositionDecisions: () => writeCompositionDecisions
14549
14741
  });
14550
- import { createHash as createHash10 } from "node:crypto";
14742
+ import { createHash as createHash11 } from "node:crypto";
14551
14743
  import { existsSync as existsSync35, readFileSync as readFileSync31, readdirSync as readdirSync14 } from "node:fs";
14552
14744
  import path44 from "node:path";
14745
+ function renderBindingsRemediation(r) {
14746
+ if (r === void 0) return void 0;
14747
+ const hostQ = quoteArg(path44.resolve(r.hostSet));
14748
+ return r.kind === "re-bindings" ? `An earlier bindings enrichment predates a re-record \u2014 re-run ${tendrilCommand(`record bindings --set ${hostQ}`)} to restore the id evidence.` : `${tendrilCommand(`record bindings --set ${hostQ}`)} fetches Figma's instance\u2192component bindings for the HOST set (one or two batched REST calls, congruence-verified against the recording \u2014 no re-recording); then confirm the pairing and regenerate the host bundle.`;
14749
+ }
14553
14750
  function compositionPairsFor(hostSet, roots) {
14554
14751
  const parent = path44.dirname(hostSet);
14555
14752
  const explicitRoots = [...new Set(roots)];
@@ -14584,6 +14781,7 @@ function compositionPairsFor(hostSet, roots) {
14584
14781
  const row = proposalRows.get(key) ?? { displayName: e.partners[0]?.displayName ?? e.instanceName, partnerDirs: e.partners.map((p) => p.dir), instances: 0, disclosures: [] };
14585
14782
  row.instances += 1;
14586
14783
  for (const d of e.disclosures) if (!row.disclosures.includes(d)) row.disclosures.push(d);
14784
+ if (row.remediation === void 0 && e.remediation !== void 0) row.remediation = e.remediation;
14587
14785
  proposalRows.set(key, row);
14588
14786
  }
14589
14787
  return { open: pairs.filter((p) => !decidedKeys.has(p.key)), proposals: [...proposalRows.values()], standing, invalid, ...skippedParent !== void 0 ? { skippedParent } : {} };
@@ -14620,7 +14818,7 @@ function runCompose(flags) {
14620
14818
  const roots = flags.library !== void 0 && flags.library.length > 0 ? flags.library.map((d) => path44.resolve(base, d)) : [base];
14621
14819
  if (flags.set === void 0 && (flags.confirmCompositions === true || flags.decline !== void 0 && flags.decline.length > 0)) {
14622
14820
  fail(flags, ExitCode.InputValidation, {
14623
- error: "--confirm-compositions/--decline require --set <host> \u2014 a decision needs the host set it decides for",
14821
+ error: "a compose decision flag requires --set <host> \u2014 a decision needs the host set it decides for",
14624
14822
  code: "compose-decision-without-set",
14625
14823
  remediation: "Add --set <host recording-set dir>, or drop the decision flags to list opportunities."
14626
14824
  });
@@ -14675,6 +14873,7 @@ function runComposeConfirm(flags, hostSet, roots) {
14675
14873
  const index = buildComposeIndex(scanRoots);
14676
14874
  const edges = composeReport(index);
14677
14875
  const pairs = substitutionPairs(edges, hostSet);
14876
+ const nameOnly = edges.filter((e) => e.hostSet === hostSet && e.kind === "proposal");
14678
14877
  const { raw } = readManifestFile(hostSet);
14679
14878
  const rawStanding = Array.isArray(raw["compositions"]) ? raw["compositions"] : [];
14680
14879
  const standing = [];
@@ -14694,8 +14893,22 @@ function runComposeConfirm(flags, hostSet, roots) {
14694
14893
  const printProposal = () => {
14695
14894
  for (const s of standing) process.stdout.write(`standing ${s.status.toUpperCase()}: ${s.partner.displayName} [${s.partner.key}] (${s.instances.length} instance(s)) \u2014 asked once, not re-asked
14696
14895
  `);
14896
+ if (nameOnly.length > 0) {
14897
+ const byPartner = /* @__PURE__ */ new Map();
14898
+ for (const e of nameOnly) {
14899
+ const key = e.partners.map((p) => p.dir).sort().join("+");
14900
+ const row = byPartner.get(key) ?? { name: e.partners[0]?.displayName ?? e.instanceName, count: 0 };
14901
+ row.count += 1;
14902
+ if (row.remediation === void 0 && e.remediation !== void 0) row.remediation = e.remediation;
14903
+ byPartner.set(key, row);
14904
+ }
14905
+ for (const row of byPartner.values()) {
14906
+ process.stdout.write(`NAME-ONLY: instance names match recorded component ${row.name} (${row.count} instance(s)) \u2014 identity unproven, NOT confirmable. ${renderBindingsRemediation(row.remediation) ?? "Record id evidence to make it confirmable, or ignore."}
14907
+ `);
14908
+ }
14909
+ }
14697
14910
  if (open.length === 0) {
14698
- process.stdout.write(standing.length > 0 ? "no undecided composition pairs for this host\n" : "no id-backed composition pairs found for this host (name-only proposals, if any, are not confirmable \u2014 see compose --list)\n");
14911
+ process.stdout.write(standing.length > 0 ? "no undecided composition pairs for this host\n" : "no id-backed composition pairs found for this host (name-only proposals, if any, print above and are not confirmable)\n");
14699
14912
  return;
14700
14913
  }
14701
14914
  for (const p of open) {
@@ -14712,7 +14925,17 @@ PAIR ${p.displayName}${p.figmaFile !== void 0 ? ` (file ${p.figmaFile})` : " \u2
14712
14925
  };
14713
14926
  const deciding = flags.confirmCompositions === true || flags.decline !== void 0 && flags.decline.length > 0;
14714
14927
  if (open.length === 0 || !deciding) {
14715
- emitData(flags, { hostSet, openPairs: open, standing, note: "confirmations are human-only; declines persist; name-only proposals are never confirmable" }, printProposal);
14928
+ emitData(
14929
+ flags,
14930
+ {
14931
+ hostSet,
14932
+ openPairs: open,
14933
+ nameOnlyProposals: [...new Set(nameOnly.map((e) => e.partners[0]?.displayName ?? e.instanceName))].map((name) => ({ displayName: name, remediation: renderBindingsRemediation(nameOnly.find((e) => (e.partners[0]?.displayName ?? e.instanceName) === name)?.remediation) ?? null })),
14934
+ standing,
14935
+ note: "confirmations are human-only; declines persist; name-only proposals are never confirmable"
14936
+ },
14937
+ printProposal
14938
+ );
14716
14939
  } else if (!flags.json) {
14717
14940
  printProposal();
14718
14941
  }
@@ -14721,7 +14944,7 @@ PAIR ${p.displayName}${p.figmaFile !== void 0 ? ` (file ${p.figmaFile})` : " \u2
14721
14944
  fail(flags, ExitCode.ConfirmationRequired, {
14722
14945
  error: "composition pairs require a human decision (an honest stop, not a failure \u2014 exit 4 is the documented confirmation-required outcome)",
14723
14946
  code: "compositions-unconfirmed",
14724
- remediation: `To accept the pairing(s) above, run \`${tendrilCommand(`compose --set ${hostSet} --confirm-compositions`)}\` in your own terminal; to refuse one, add \`--decline <pair-key>\` using the pair-key printed above each pair. This decision is yours alone \u2014 agents cannot make it (the flag is refused without an interactive terminal), and nothing composes without it. Agents: relay this block to your operator verbatim.`
14947
+ remediation: `This decision is human-only, on either channel. CLICK PATH (agents lead with this): tendril_compose puts the pairing in your browser as an Approve card \u2014 one click decides it (sign in first with tendril_login if needed; both are one browser Approve). TERMINAL PATH: run \`${tendrilCommand(`compose --set ${hostSet} --confirm-compositions`)}\` yourself (no account needed); to refuse one permanently, add \`--decline <pair-key>\` using the pair-key printed above each pair. Agents cannot decide on either channel \u2014 relay this block to your operator.`
14725
14948
  });
14726
14949
  }
14727
14950
  if (process.stdin.isTTY !== true) {
@@ -14741,7 +14964,27 @@ PAIR ${p.displayName}${p.figmaFile !== void 0 ? ` (file ${p.figmaFile})` : " \u2
14741
14964
  });
14742
14965
  }
14743
14966
  }
14744
- const entries = open.map((p) => ({
14967
+ const entries = buildCompositionEntries(hostSet, open, declineKeys);
14968
+ const decided = flags.confirmCompositions === true ? entries : entries.filter((e) => declineKeys.has(e.partner.key));
14969
+ const written = writeCompositionDecisions(hostSet, decided);
14970
+ if (!written.ok) {
14971
+ fail(flags, ExitCode.General, {
14972
+ error: written.refusal,
14973
+ code: "compositions-write-raced",
14974
+ remediation: "Nothing was changed. Re-run the same command \u2014 the fresh derivation re-reads the manifest as it is now."
14975
+ });
14976
+ }
14977
+ warn(
14978
+ flags,
14979
+ "the host manifest changed \u2014 its bytes are hashed into bundle provenance, so bundles generated from this set before this decision now carry a stale set identity; regenerate to compose (`tendril engine brief` carries the pins)"
14980
+ );
14981
+ emitData(flags, { hostSet, openPairs: open, standing, written: written.written, note: "confirmations are human-only; declines persist; name-only proposals are never confirmable" }, () => {
14982
+ for (const e of written.written) process.stdout.write(`${e.status.toUpperCase()}: ${e.partner.displayName} [${e.partner.key}] (${e.instances.length} instance(s))
14983
+ `);
14984
+ });
14985
+ }
14986
+ function buildCompositionEntries(hostSet, pairs, declineKeys) {
14987
+ return pairs.map((p) => ({
14745
14988
  v: 1,
14746
14989
  partner: {
14747
14990
  key: p.key,
@@ -14750,33 +14993,29 @@ PAIR ${p.displayName}${p.figmaFile !== void 0 ? ` (file ${p.figmaFile})` : " \u2
14750
14993
  // The partner's manifest BYTES, pinned at decision time and
14751
14994
  // keyed by the same host-relative identity as the pair key —
14752
14995
  // any partner re-plan/roles/composition write flips it, which
14753
- // is the staleness signal later slices compare against. (The
14754
- // full recording-set hash join lands with pin authoring, where
14755
- // task configs exist.)
14996
+ // is the staleness signal later slices compare against.
14756
14997
  manifestSha256: Object.fromEntries(
14757
- p.partnerDirs.map((d) => [path44.relative(hostSet, d), createHash10("sha256").update(readFileSync31(path44.join(d, "recording-set.json"))).digest("hex")])
14998
+ p.partnerDirs.map((d) => [path44.relative(hostSet, d), createHash11("sha256").update(readFileSync31(path44.join(d, "recording-set.json"))).digest("hex")])
14758
14999
  )
14759
15000
  },
14760
- // Schema shape ONLY (review: the display-side poseDisplay field
14761
- // leaked into the provenance-hashed compositions extension —
14762
- // zod's strip on the next rewrite would re-serialize the same
14763
- // decision with different bytes, flipping the staleness signal
14764
- // for a no-op and embedding local dir basenames in a pinned
14765
- // human-decision record).
14766
15001
  instances: p.instances.map((i) => ({ hostRep: i.hostRep, instanceId: i.instanceId, poseVariantNodeId: i.poseVariantNodeId })),
14767
15002
  status: declineKeys.has(p.key) ? "declined" : "confirmed"
14768
15003
  }));
14769
- const decided = flags.confirmCompositions === true ? entries : entries.filter((e) => declineKeys.has(e.partner.key));
14770
- const merged = { ...raw, compositions: [...standing, ...decided] };
14771
- writeManifest(hostSet, merged);
14772
- warn(
14773
- flags,
14774
- "the host manifest changed \u2014 its bytes are hashed into bundle provenance, so bundles generated from this set before this decision now carry a stale set identity; regenerate to compose (`tendril engine brief` carries the pins)"
14775
- );
14776
- emitData(flags, { hostSet, openPairs: open, standing, written: decided, note: "confirmations are human-only; declines persist; name-only proposals are never confirmable" }, () => {
14777
- for (const e of decided) process.stdout.write(`${e.status.toUpperCase()}: ${e.partner.displayName} [${e.partner.key}] (${e.instances.length} instance(s))
14778
- `);
14779
- });
15004
+ }
15005
+ function writeCompositionDecisions(hostSet, decided) {
15006
+ const { raw } = readManifestFile(hostSet);
15007
+ const rawStanding = Array.isArray(raw["compositions"]) ? raw["compositions"] : [];
15008
+ const standing = [];
15009
+ for (let i = 0; i < rawStanding.length; i++) {
15010
+ const parsed = CompositionEntrySchema.safeParse(rawStanding[i]);
15011
+ if (!parsed.success) return { ok: false, refusal: `compositions[${i}] in the host manifest is not a valid v1 entry \u2014 a standing human decision could not be re-validated, so nothing was written` };
15012
+ standing.push(parsed.data);
15013
+ }
15014
+ const decidedKeys = new Set(standing.map((c) => fromStoredRel(c.partner.key)));
15015
+ const fresh = decided.filter((e) => !decidedKeys.has(fromStoredRel(e.partner.key)));
15016
+ const alreadyDecided = decided.filter((e) => decidedKeys.has(fromStoredRel(e.partner.key))).map((e) => e.partner.key);
15017
+ if (fresh.length > 0) writeManifest(hostSet, { ...raw, compositions: [...standing, ...fresh] });
15018
+ return { ok: true, written: fresh, alreadyDecided };
14780
15019
  }
14781
15020
  var COMPOSE_DESCRIPTION, NOTE, IMPLICIT_PARENT_SCAN_MAX_ENTRIES;
14782
15021
  var init_compose2 = __esm({
@@ -14826,6 +15065,7 @@ __export(record_exports, {
14826
15065
  nextPayload: () => nextPayload,
14827
15066
  recordsInteractionState: () => recordsInteractionState,
14828
15067
  runRecordAsset: () => runRecordAsset,
15068
+ runRecordBindings: () => runRecordBindings,
14829
15069
  runRecordFetch: () => runRecordFetch,
14830
15070
  runRecordFinish: () => runRecordFinish,
14831
15071
  runRecordIngest: () => runRecordIngest,
@@ -15389,6 +15629,119 @@ async function runRecordRestFetch(opts) {
15389
15629
  `);
15390
15630
  });
15391
15631
  }
15632
+ async function runRecordBindings(opts) {
15633
+ const setDir = path45.resolve(opts.setDir);
15634
+ const manifest = loadManifest(setDir);
15635
+ if (manifest.channel !== void 0) {
15636
+ fail(opts, ExitCode.InputValidation, {
15637
+ error: "this set records over REST \u2014 its REST reps already carry the bindings in rest_nodes.json (probe poses excepted, a named limitation of this slice)",
15638
+ code: "bindings-set-already-channeled",
15639
+ remediation: `Nothing to enrich. Pair discovery reads the channeled bindings directly \u2014 run ${tendrilCommand(`compose --set ${quoteArg(setDir)}`)}.`
15640
+ });
15641
+ }
15642
+ const fileKey = manifest.figmaFile ?? opts.file;
15643
+ if (fileKey === void 0) {
15644
+ fail(opts, ExitCode.InputValidation, {
15645
+ error: "the manifest carries no figmaFile key, and no --file was given",
15646
+ code: "bindings-no-file-identity",
15647
+ remediation: "Pass the design's file key (from its URL: figma.com/design/<KEY>/\u2026) as --file <KEY>. It is an OPERATOR ASSERTION recorded in the envelope, never backfilled into the manifest \u2014 node ids are file-scoped and every binding must still pass congruence against the recording, so a wrong key yields refusals, not wrong evidence."
15648
+ });
15649
+ }
15650
+ if (manifest.figmaFile !== void 0 && opts.file !== void 0 && opts.file !== manifest.figmaFile) {
15651
+ fail(opts, ExitCode.InputValidation, {
15652
+ error: `--file ${opts.file} contradicts the manifest's captured identity ${manifest.figmaFile}`,
15653
+ code: "bindings-file-mismatch",
15654
+ remediation: "Drop --file \u2014 the captured identity wins."
15655
+ });
15656
+ }
15657
+ const fileIdentity = manifest.figmaFile !== void 0 ? "captured" : "asserted";
15658
+ const client = opts.restClient !== void 0 ? { ok: true, value: opts.restClient } : await figmaRestClient();
15659
+ if (!client.ok) {
15660
+ fail(opts, ExitCode.General, { error: client.refusal, code: `figma-rest-${client.kind}`, remediation: client.kind === "no-credential" ? `Run ${tendrilCommand("figma-connect")} once, then re-run this.` : "Fix what is named above and re-run." });
15661
+ }
15662
+ const fetchedAt = (/* @__PURE__ */ new Date()).toISOString();
15663
+ const docs = /* @__PURE__ */ new Map();
15664
+ const refused = [];
15665
+ const versions = /* @__PURE__ */ new Set();
15666
+ let calls = 0;
15667
+ for (let i = 0; i < manifest.reps.length; i += REST_BATCH_SIZE) {
15668
+ const batch = manifest.reps.slice(i, i + REST_BATCH_SIZE);
15669
+ const got = await client.value.nodes(fileKey, batch.map((r) => r.nodeId));
15670
+ calls += 1;
15671
+ if (got.ok) {
15672
+ if (got.value.fileVersion !== void 0) versions.add(got.value.fileVersion);
15673
+ for (const rep of batch) docs.set(rep.slug, got.value.nodes[rep.nodeId]);
15674
+ continue;
15675
+ }
15676
+ if (!(got.kind === "transport" && got.refusal.includes("missing"))) failRest(opts, got);
15677
+ for (const rep of batch) {
15678
+ const one = await client.value.nodes(fileKey, [rep.nodeId]);
15679
+ calls += 1;
15680
+ if (one.ok) {
15681
+ if (one.value.fileVersion !== void 0) versions.add(one.value.fileVersion);
15682
+ docs.set(rep.slug, one.value.nodes[rep.nodeId]);
15683
+ } else if (one.kind === "transport" && one.refusal.includes("missing")) {
15684
+ refused.push({ slug: rep.slug, reason: `the recorded node ${rep.nodeId} no longer exists in the document (deleted or moved since recording)` });
15685
+ } else {
15686
+ failRest(opts, one);
15687
+ }
15688
+ }
15689
+ }
15690
+ if (versions.size > 1) {
15691
+ fail(opts, ExitCode.General, {
15692
+ error: `the Figma file changed mid-fetch (versions ${[...versions].join(" vs ")}) \u2014 the bindings would attest two different documents`,
15693
+ code: "bindings-file-version-drift",
15694
+ remediation: `Re-run ${tendrilCommand(`record bindings --set ${quoteArg(setDir)}`)} \u2014 the versions must agree.`
15695
+ });
15696
+ }
15697
+ const fileVersion = [...versions][0];
15698
+ let enriched = 0;
15699
+ let instances = 0;
15700
+ for (const rep of manifest.reps) {
15701
+ const entry = docs.get(rep.slug);
15702
+ if (entry === void 0) continue;
15703
+ const out = enrichRepBindings(setDir, rep.slug, {
15704
+ fetchedEntry: entry,
15705
+ url: "https://api.figma.com/v1/files/" + encodeURIComponent(fileKey) + "/nodes",
15706
+ status: 200,
15707
+ ...fileVersion !== void 0 ? { fileVersion } : {},
15708
+ fileIdentity,
15709
+ fetchedAt,
15710
+ accessToken: client.value.accessToken()
15711
+ });
15712
+ if (out.ok) {
15713
+ enriched += 1;
15714
+ instances += out.instances;
15715
+ } else {
15716
+ refused.push({ slug: rep.slug, reason: out.refusal });
15717
+ }
15718
+ }
15719
+ const discovery = (() => {
15720
+ try {
15721
+ const { open, proposals, skippedParent } = compositionPairsFor(setDir, [path45.dirname(setDir)]);
15722
+ const scanned = skippedParent !== void 0 ? `(parent ${skippedParent.dir} skipped: ${skippedParent.entries} entries \u2014 pass compose --library)` : path45.dirname(setDir);
15723
+ const note = open.length > 0 ? `${open.length} id-backed pair(s) now confirmable: a human runs ${tendrilCommand(`compose --set ${quoteArg(setDir)}`)} in their own terminal. Confirming writes the decision into this set's manifest \u2014 an EXISTING bundle of this host will then report set drift until it is regenerated (the regeneration is what composes the partner).` : proposals.length > 0 ? `name-only matches remain (no id-backed pair formed) \u2014 the partner set may record different variants than these bindings name, or sits outside the scanned root` : `no partner recording is visible in the scanned root \u2014 co-locate the partner set next to this one, or run ${tendrilCommand(`compose --set ${quoteArg(setDir)} --library <partner-workspace>`)}`;
15724
+ return { confirmable: open.length, nameOnly: proposals.length, scanned, note };
15725
+ } catch (err) {
15726
+ return { confirmable: 0, nameOnly: 0, scanned: "(discovery failed)", note: `pair discovery failed (${err instanceof Error ? err.message.split("\n")[0] : String(err)}) \u2014 whether partners are visible is UNKNOWN, not "no"` };
15727
+ }
15728
+ })();
15729
+ emitData(
15730
+ opts,
15731
+ { enriched, instances, refused, calls, ...fileVersion !== void 0 ? { fileVersion } : {}, fileIdentity, discovery },
15732
+ () => {
15733
+ process.stdout.write(`bindings: ${enriched}/${manifest.reps.length} rep(s) enriched (${instances} instance binding(s), ${calls} REST call(s)${fileVersion !== void 0 ? `, file version ${fileVersion}` : ""}${fileIdentity === "asserted" ? ", file identity ASSERTED via --file" : ""})
15734
+ `);
15735
+ for (const r of refused) process.stdout.write(`REFUSED ${r.slug}: ${r.reason}
15736
+ `);
15737
+ process.stdout.write(`COMPOSE scanned: ${discovery.scanned}
15738
+ `);
15739
+ process.stdout.write(`next: ${discovery.note}
15740
+ `);
15741
+ }
15742
+ );
15743
+ if (enriched === 0 && manifest.reps.length > 0) process.exitCode = ExitCode.General;
15744
+ }
15392
15745
  function failRest(opts, out) {
15393
15746
  fail(opts, ExitCode.General, {
15394
15747
  error: out.refusal,
@@ -15801,14 +16154,15 @@ function runRecordStatus(opts) {
15801
16154
  const composition = (() => {
15802
16155
  try {
15803
16156
  const setDir = path45.resolve(opts.setDir);
15804
- const { open, proposals, standing, invalid } = compositionPairsFor(setDir, [path45.dirname(setDir)]);
16157
+ const { open, proposals, standing, invalid } = compositionPairsFor(setDir, [opts.library !== void 0 ? path45.resolve(opts.library) : path45.dirname(setDir)]);
15805
16158
  return {
15806
16159
  openPairs: open.map((p) => ({ displayName: p.displayName, pairKey: p.key, instances: p.instances.length })),
15807
16160
  // Name-only proposals (field, 2026-08-26): the host-side
15808
16161
  // surface an MCP-recorded host embedding a later partner never
15809
16162
  // had — not confirmable, but never silent either; the
15810
- // disclosures carry the conditional re-record remediation.
15811
- nameOnly: proposals.map((p) => ({ displayName: p.displayName, instances: p.instances, disclosures: p.disclosures })),
16163
+ // remediation renders copy-paste-complete for this install
16164
+ // shape (structured, never substring-selected review).
16165
+ nameOnly: proposals.map((p) => ({ displayName: p.displayName, instances: p.instances, disclosures: p.disclosures, remediation: renderBindingsRemediation(p.remediation) ?? null })),
15812
16166
  confirmed: standing.filter((s) => s.status === "confirmed").length,
15813
16167
  declined: standing.filter((s) => s.status === "declined").length,
15814
16168
  invalid
@@ -15844,7 +16198,7 @@ function runRecordStatus(opts) {
15844
16198
  }
15845
16199
  if (!("unavailable" in composition) && composition.nameOnly.length > 0) {
15846
16200
  for (const p of composition.nameOnly) {
15847
- process.stdout.write(`COMPOSITION NAME-ONLY: instance name matches recorded component ${p.displayName} (${p.instances} instance(s)) \u2014 identity unproven, NOT confirmable. ${p.disclosures.find((d) => d.includes("re-record")) ?? "Record id evidence to make it confirmable, or ignore."}
16201
+ process.stdout.write(`COMPOSITION NAME-ONLY: instance name matches recorded component ${p.displayName} (${p.instances} instance(s)) \u2014 identity unproven, NOT confirmable. ${p.remediation ?? "Record id evidence to make it confirmable, or ignore."}
15848
16202
  `);
15849
16203
  }
15850
16204
  }
@@ -17570,7 +17924,7 @@ READY this component is verified \u2014 the run is not finished until it is
17570
17924
  `
17571
17925
  );
17572
17926
  } else {
17573
- process.stdout.write(`COMPOSE ${o.hostDisplayName} [${o.hostSet}]: name-only match (${o.instances} instance(s)) \u2014 identity unproven, not confirmable. ${o.disclosures.find((d) => d.includes("re-record")) ?? "Id evidence is needed for a pairing."}
17927
+ process.stdout.write(`COMPOSE ${o.hostDisplayName} [${o.hostSet}]: name-only match (${o.instances} instance(s)) \u2014 identity unproven, not confirmable. ${renderBindingsRemediation(o.remediation) ?? "Id evidence is needed for a pairing."}
17574
17928
  `);
17575
17929
  }
17576
17930
  }
@@ -17759,7 +18113,7 @@ function runEngineBrief(opts) {
17759
18113
  }
17760
18114
  if (proposals.length > 0) {
17761
18115
  disclosures.push(
17762
- `COMPOSITION NAME-ONLY: instance names match ${proposals.length} recorded component(s) (${proposals.map((p) => `${p.displayName}: ${p.instances} instance(s)`).join("; ")}) but identity is UNPROVEN \u2014 name evidence never auto-joins, so these regions are implemented locally. ${proposals.flatMap((p) => p.disclosures).find((d) => d.includes("re-record")) ?? "Record id evidence to make a pairing confirmable, or proceed self-contained."}`
18116
+ `COMPOSITION NAME-ONLY: instance names match ${proposals.length} recorded component(s) (${proposals.map((p) => `${p.displayName}: ${p.instances} instance(s)`).join("; ")}) but identity is UNPROVEN \u2014 name evidence never auto-joins, so these regions are implemented locally. ${renderBindingsRemediation(proposals.find((p) => p.remediation !== void 0)?.remediation) ?? "Record id evidence to make a pairing confirmable, or proceed self-contained."}`
17763
18117
  );
17764
18118
  }
17765
18119
  } catch (err) {
@@ -18391,7 +18745,7 @@ var init_codeconnect = __esm({
18391
18745
  });
18392
18746
 
18393
18747
  // packages/mcp/src/server.ts
18394
- import { createHash as createHash11 } from "node:crypto";
18748
+ import { createHash as createHash12 } from "node:crypto";
18395
18749
  import { existsSync as existsSync43, mkdtempSync as mkdtempSync3, readFileSync as readFileSync39, readdirSync as readdirSync17, writeFileSync as writeFileSync21 } from "node:fs";
18396
18750
  import os8 from "node:os";
18397
18751
  import path53 from "node:path";
@@ -18399,7 +18753,7 @@ import { fileURLToPath as fileURLToPath6 } from "node:url";
18399
18753
  import { z as z15 } from "zod";
18400
18754
  function sourceHash() {
18401
18755
  const dir = path53.dirname(fileURLToPath6(import.meta.url));
18402
- const h = createHash11("sha256");
18756
+ const h = createHash12("sha256");
18403
18757
  for (const f of readdirSync17(dir).filter((n) => n.endsWith(".ts")).sort()) {
18404
18758
  h.update(f);
18405
18759
  h.update(readFileSync39(path53.join(dir, f)));
@@ -18545,6 +18899,40 @@ var init_server = __esm({
18545
18899
  schema: z15.object({ setDir: str("recording set directory") }),
18546
18900
  argv: (i) => ["record", "rest-fetch", "--set", i["setDir"]]
18547
18901
  },
18902
+ {
18903
+ name: "tendril_record_bindings",
18904
+ description: "Fetch Figma's instance\u2192component bindings for an EXISTING MCP-recorded set \u2014 one or two batched REST calls, no re-recording, pixels/geometry/identity untouched. This is how components that are ALREADY recorded and generated become connectable: the bindings make cross-component pairing id-backed, so `tendril compose` can propose it for the human's confirm. Every binding is congruence-verified against the recorded metadata (a design that changed since recording refuses per pose, named). The CLI makes the requests itself with the user's connected Figma credential (tendril_figma_connect); you make NO Figma call. The result names the next step \u2014 including when no partner set is visible in the scanned root (co-locate the sets or pass compose --library). For sets recorded before file identity was captured, pass the design's file key as `file`.",
18905
+ schema: z15.object({ setDir: str("recording set directory"), file: optStr("the design's file key (figma.com/design/<KEY>/\u2026) \u2014 only for sets whose manifest lacks figmaFile; an operator assertion, congruence still gates every binding") }),
18906
+ argv: (i) => ["record", "bindings", "--set", i["setDir"], ...typeof i["file"] === "string" ? ["--file", i["file"]] : []]
18907
+ },
18908
+ {
18909
+ name: "tendril_compose",
18910
+ description: "Put ONE composition pairing in front of the user's BROWSER \u2014 phase one of the browser-approved connect (invariant-5 migration; the terminal `--confirm-compositions` path remains for humans at a TTY). Requires a portal session (tendril_login \u2014 one browser Approve). Returns the approve-page link: RELAY IT verbatim, name the account the result says to approve as, and never urge the decision \u2014 the card carries the engine's honest disclosures and the human reads them. Deny on the card is NOT NOW (this request only, never a permanent decline). You cannot decide this yourself on any channel: the portal accepts the decision only from the user's signed-in browser, and the terminal flag only from an interactive TTY. A host with several open pairings needs `pair` (one card = one decision). Then finish with tendril_compose_wait. Both recording sets must be visible in one scanned workspace \u2014 co-locate them or pass `library`.",
18911
+ schema: z15.object({
18912
+ setDir: str("the HOST recording set directory (the component that embeds the partner)"),
18913
+ pair: optStr("the pair-key to request when the host has several open pairings"),
18914
+ library: z15.array(z15.string()).optional().describe("workspace root(s) holding the partner recording set(s) \u2014 required when they live in another project root"),
18915
+ portal: optStr("portal origin override (defaults to the stored session's portal)")
18916
+ }),
18917
+ argv: (i) => [
18918
+ "compose",
18919
+ "--set",
18920
+ i["setDir"],
18921
+ "--approve-start",
18922
+ ...typeof i["pair"] === "string" ? ["--pair", i["pair"]] : [],
18923
+ ...Array.isArray(i["library"]) ? i["library"].flatMap((d) => ["--library", d]) : [],
18924
+ ...typeof i["portal"] === "string" ? ["--to", i["portal"]] : []
18925
+ ]
18926
+ },
18927
+ {
18928
+ name: "tendril_compose_wait",
18929
+ description: 'Phase two of the browser-approved connect. Waits in a BOUNDED window (~1 minute per call) for the user\'s decision on the card tendril_compose returned; on Approve it records the SAME manifest entry the terminal confirm writes \u2014 after re-deriving the pairing from the CURRENT recordings and refusing if anything changed underneath the click (the click is then not wrong; the project moved \u2014 run the connect again). While undecided, each call returns `status: "approval-pending"` \u2014 a heartbeat, not a failure: one short liveness line to the user, then call again; the request stays live ~30 minutes, and a decided card can take moments to land. NOT NOW, a lapse, and success each arrive as their own sentence \u2014 report the one you got. After success: regenerate the HOST bundle with the same library roots (brief \u2192 generate \u2192 score \u2192 verify), then republish \u2014 announce the republish in one line first. Compose waits follow the same heartbeat contract as publish waits: never urge, never call an in-flight wait stuck.',
18930
+ schema: z15.object({
18931
+ setDir: str("the same HOST set directory tendril_compose was called with"),
18932
+ portal: optStr("portal origin override (must match tendril_compose's)")
18933
+ }),
18934
+ argv: (i) => ["compose", "--set", i["setDir"], "--approve-wait", "--wait-window", "55", ...typeof i["portal"] === "string" ? ["--to", i["portal"]] : []]
18935
+ },
18548
18936
  {
18549
18937
  name: "tendril_record_fetch",
18550
18938
  description: "Download a Figma asset URL (from get_screenshot's image_url) straight to disk and ingest it as the rep's envelope \u2014 the fallback when only the screenshot piece needs (re-)recording; for a rep's standard three recordings PREFER tendril_record_ingest_rep. Never download the image yourself: the bytes must not pass through your context.",
@@ -19023,864 +19411,1321 @@ var init_permissions = __esm({
19023
19411
  }
19024
19412
  });
19025
19413
 
19026
- // packages/cli/src/commands/login.ts
19027
- var login_exports = {};
19028
- __export(login_exports, {
19029
- DEFAULT_PORTAL_ORIGIN: () => DEFAULT_PORTAL_ORIGIN,
19030
- runLogin: () => runLogin,
19031
- runLogout: () => runLogout
19414
+ // packages/cli/src/commands/publish.ts
19415
+ var publish_exports = {};
19416
+ __export(publish_exports, {
19417
+ approveWaitPhase: () => approveWaitPhase,
19418
+ resolveOrigin: () => resolveOrigin,
19419
+ runPublish: () => runPublish,
19420
+ spendPendingApproval: () => spendPendingApproval
19032
19421
  });
19033
- import { spawn } from "node:child_process";
19034
- import { existsSync as existsSync45, mkdirSync as mkdirSync14, readFileSync as readFileSync41, rmSync as rmSync8, writeFileSync as writeFileSync23 } from "node:fs";
19422
+ import { existsSync as existsSync45, readFileSync as readFileSync41, rmSync as rmSync8, writeFileSync as writeFileSync23 } from "node:fs";
19035
19423
  import path55 from "node:path";
19036
- import { isCancel as isCancel3, password as password2 } from "@clack/prompts";
19037
- async function runLogin(opts, deps) {
19038
- const origin = (opts.to ?? process.env["TENDRIL_PORTAL_URL"] ?? DEFAULT_PORTAL_ORIGIN).replace(/\/+$/, "");
19039
- if (!isSecureOrigin(origin)) {
19424
+ async function runPublish(opts) {
19425
+ if (opts.waitWindowSeconds !== void 0 && opts.approveWait !== true) {
19040
19426
  fail(opts, ExitCode.InputValidation, {
19041
- error: `${origin} is not https, so a session token sent there would travel in the clear`,
19042
- code: "portal-not-https",
19043
- remediation: "Use the https:// address of your portal. Only 127.0.0.1 and localhost are exempt, for local development."
19427
+ error: "--wait-window only bounds an --approve-wait poll",
19428
+ code: "wait-window-without-approve-wait",
19429
+ remediation: "Pass --approve-wait with it, or drop --wait-window."
19044
19430
  });
19045
19431
  }
19046
- if (opts.deviceStart === true) {
19047
- await deviceStartPhase(opts, origin, deps ?? realDeps());
19048
- return;
19432
+ if (opts.waitWindowSeconds !== void 0 && (!Number.isFinite(opts.waitWindowSeconds) || opts.waitWindowSeconds <= 0)) {
19433
+ fail(opts, ExitCode.InputValidation, {
19434
+ error: "--wait-window needs a positive number of seconds",
19435
+ code: "wait-window-invalid",
19436
+ remediation: "Pass e.g. --wait-window 55."
19437
+ });
19049
19438
  }
19050
- if (opts.deviceWait === true) {
19051
- await deviceWaitPhase(opts, deps ?? realDeps());
19052
- return;
19439
+ const bundleDir = path55.resolve(opts.bundleDir);
19440
+ const bundle = readBundle(opts, bundleDir);
19441
+ const report = bundle.report;
19442
+ if (typeof report["rulerExit"] !== "number" || !Number.isInteger(report["rulerExit"])) {
19443
+ fail(opts, ExitCode.InputValidation, {
19444
+ error: `${VERIFY_REPORT_FILENAME} does not state the ruler's exit code, and an absent exit code is not a passing one`,
19445
+ code: "report-has-no-exit-code",
19446
+ remediation: `Re-run \`${tendrilCommand(`verify ${opts.bundleDir}`)}\` with a current CLI \u2014 this report predates the field.`
19447
+ });
19053
19448
  }
19054
- if (process.stdin.isTTY === true && opts.paste !== true) {
19055
- await deviceLogin(opts, origin, deps ?? realDeps());
19056
- return;
19449
+ if (report["rulerExit"] !== 0) {
19450
+ const why = typeof report["rulerRefusal"] === "string" ? ` \u2014 ${report["rulerRefusal"]}` : "";
19451
+ fail(opts, ExitCode.VerificationFailed, {
19452
+ error: `the ruler refused this run (exit ${String(report["rulerExit"])}), so it has nothing to publish${why}`,
19453
+ code: "run-refused-by-ruler",
19454
+ remediation: `Fix what the report names, re-run \`${tendrilCommand(`verify ${opts.bundleDir}`)}\` until it passes, then publish. A declined verdict never becomes a page.`
19455
+ });
19057
19456
  }
19058
- await pasteLogin(opts, origin);
19059
- }
19060
- async function deviceLogin(opts, origin, deps) {
19061
- const started = await startHandshake(opts, origin, deps);
19062
- process.stderr.write(`
19063
- Open this link to approve the sign-in:
19064
-
19065
- ${started.verificationUrl}
19066
-
19067
- `);
19068
- process.stderr.write(` The page must show this code: ${started.userCode}
19069
- `);
19070
- process.stderr.write(` Approve it only if this terminal is yours.
19071
-
19072
- `);
19073
- deps.openBrowser(started.verificationUrl);
19074
- process.stderr.write(` Waiting for the browser (about ten minutes before this code lapses) `);
19075
- const outcome = await waitForDecision(opts, origin, deps, started, () => process.stderr.write("."));
19076
- process.stderr.write("\n");
19077
- settleDecision(opts, origin, outcome);
19078
- }
19079
- async function waitForDecision(opts, origin, deps, started, onTick) {
19080
- const interval = Math.max(1, started.intervalSeconds);
19081
- const attempts = Math.ceil(660 / interval);
19082
- for (let n = 0; n < attempts; n += 1) {
19083
- await deps.sleep(interval);
19084
- const state = await pollHandshake(opts, origin, deps, started.deviceCode);
19085
- if (state.status === "pending") {
19086
- onTick();
19087
- continue;
19457
+ const undisclosed = undisclosedTrustFacts(report);
19458
+ if (undisclosed.length > 0) {
19459
+ fail(opts, ExitCode.InputValidation, {
19460
+ error: `this report carries a disclosure a published page cannot yet show, and a verdict shown without it is worse than no page: ${undisclosed.map((f) => `${f.pointer} \u2014 ${f.consequence}`).join("; ")}`,
19461
+ code: "report-carries-an-unrenderable-disclosure",
19462
+ remediation: "Resolve what the disclosure names \u2014 re-record so the set matches the bundle's stamp, or resolve the substituted font families \u2014 then re-verify and publish the clean run."
19463
+ });
19464
+ }
19465
+ if (scoredRecordingSetHash(report) === void 0) {
19466
+ fail(opts, ExitCode.InputValidation, {
19467
+ error: "this report does not name the recording set these scores were measured against",
19468
+ code: "report-names-no-recording-set",
19469
+ remediation: `Re-run \`${tendrilCommand(`verify ${opts.bundleDir}`)}\` with a current CLI \u2014 the set identity rides the report.`
19470
+ });
19471
+ }
19472
+ const componentName = (opts.name ?? bundle.manifest.name).trim();
19473
+ const surface = classifyBundleSurface(bundle.files, { entry: bundle.manifest.entry });
19474
+ if (surface.unknown.length > 0) {
19475
+ fail(opts, ExitCode.InputValidation, {
19476
+ error: `this bundle carries files a portal does not recognise, and publishing guesses at nothing: ${surface.unknown.join(", ")}`,
19477
+ code: "bundle-carries-unrecognised-files",
19478
+ remediation: `Remove them from ${opts.bundleDir}, or re-emit the bundle. Publishing them would ship something nobody reviewed; dropping them silently would ship evidence with a hole in it.`
19479
+ });
19480
+ }
19481
+ const sheetEntry = surface.published.find((p) => p.role === "inspect-sheet");
19482
+ if (sheetEntry !== void 0) {
19483
+ const missingCrops = missingInspectCrops(
19484
+ readFileSync41(path55.join(bundleDir, sheetEntry.path), "utf8"),
19485
+ surface.published.map((p) => p.path)
19486
+ );
19487
+ if (missingCrops.length > 0) {
19488
+ fail(opts, ExitCode.InputValidation, {
19489
+ error: `this bundle's inspect sheet references images it does not carry, and half a sheet reads as a broken component: ${missingCrops.join(", ")}`,
19490
+ code: "inspect-sheet-without-its-crops",
19491
+ remediation: `Re-run \`${tendrilCommand(`inspect ${opts.bundleDir}`)}\` to rebuild the sheet and its crops together, or delete the sheet and publish without it.`
19492
+ });
19088
19493
  }
19089
- if (state.status === "approved") return state;
19090
- return { status: state.status };
19091
19494
  }
19092
- return { status: "gave-up" };
19093
- }
19094
- function settleDecision(opts, origin, outcome) {
19095
- switch (outcome.status) {
19096
- case "approved":
19097
- writeStoredSession({ origin, token: outcome.token });
19098
- emitData(opts, { origin, storedAt: sessionPath(), via: "device" }, () => {
19099
- process.stdout.write(`signed in to ${origin}
19495
+ if (surface.missingRequired.length > 0) {
19496
+ fail(opts, ExitCode.InputValidation, {
19497
+ error: `this bundle is missing what a publishable bundle cannot be without: ${surface.missingRequired.join(", ")}`,
19498
+ code: "bundle-missing-required-role",
19499
+ remediation: `Run \`${tendrilCommand(`verify ${opts.bundleDir}`)}\` \u2014 the evidence a page shows is written by that run. Required: ${REQUIRED_ROLES.join(", ")}.`
19500
+ });
19501
+ }
19502
+ const recorded = readScoredFiles(report);
19503
+ if (recorded === void 0) {
19504
+ fail(opts, ExitCode.InputValidation, {
19505
+ error: `${VERIFY_REPORT_FILENAME} does not record the files this run was scored beside, so nothing ties this bundle's bytes to its verdict`,
19506
+ code: "report-records-no-file-digests",
19507
+ remediation: `Re-run \`${tendrilCommand(`verify ${opts.bundleDir}`)}\` with a current CLI \u2014 this report predates the field.`
19508
+ });
19509
+ } else {
19510
+ const delta = compareScoredFiles(recorded, digestScoredFiles(bundleDir, bundle.manifest.entry));
19511
+ const disagreements = [
19512
+ ...delta.changed.map((p) => `${p} (bytes differ from the scored run)`),
19513
+ ...delta.missing.map((p) => `${p} (scored, no longer in the bundle)`),
19514
+ ...delta.unscored.map((p) => `${p} (in the bundle, never scored)`)
19515
+ ];
19516
+ if (disagreements.length > 0) {
19517
+ fail(opts, ExitCode.InputValidation, {
19518
+ error: `this bundle is not the one the ruler scored: ${disagreements.join("; ")}`,
19519
+ code: "bundle-disagrees-with-scored-files",
19520
+ remediation: `Re-run \`${tendrilCommand(`verify ${opts.bundleDir}`)}\` so the verdict describes these bytes. A page must never show code the ruler never saw.`
19521
+ });
19522
+ }
19523
+ }
19524
+ const figmaFile = bundle.manifest.provenance.recordingSet.figmaFile ?? null;
19525
+ if (opts.dryRun) {
19526
+ emitData(
19527
+ opts,
19528
+ {
19529
+ bundleDir,
19530
+ componentName,
19531
+ figmaFile,
19532
+ entry: bundle.manifest.entry,
19533
+ publishes: surface.published,
19534
+ excluded: surface.excluded,
19535
+ rulerVersion: report["environment"]?.["ruler"] ?? null,
19536
+ wouldPublish: true
19537
+ },
19538
+ () => {
19539
+ process.stdout.write(`${componentName} would publish ${String(surface.published.length)} files:
19100
19540
  `);
19101
- process.stdout.write(` token stored at ${sessionPath()} (readable only by you)
19541
+ for (const entry of surface.published) process.stdout.write(` ${entry.path} (${entry.role})
19102
19542
  `);
19543
+ for (const left of surface.excluded) process.stdout.write(` \u2014 leaving ${left.path} behind (${left.reason})
19544
+ `);
19545
+ }
19546
+ );
19547
+ return;
19548
+ }
19549
+ const origin = resolveOrigin(opts);
19550
+ const client = opts.client ?? httpClient(opts, origin);
19551
+ if (opts.approveWait === true) {
19552
+ const phase = await approveWaitPhase(opts, client, bundleDir, { componentName, figmaFile });
19553
+ if (phase === "yielded") return;
19554
+ }
19555
+ void reportRunPresence(componentName, "publishing");
19556
+ emitProgress(0, 1, "requesting the upload plan from the portal");
19557
+ let opened = await client.begin({
19558
+ componentName,
19559
+ figmaFile,
19560
+ entry: bundle.manifest.entry,
19561
+ files: bundle.files,
19562
+ report: bundle.reportText
19563
+ });
19564
+ if (!opened.ok && opened.needsConfirmation !== void 0 && opts.approveWait !== true) {
19565
+ const flow = await runApprovalFlow(opts, client, bundleDir, {
19566
+ componentName,
19567
+ figmaFile,
19568
+ begin: () => client.begin({ componentName, figmaFile, entry: bundle.manifest.entry, files: bundle.files, report: bundle.reportText })
19569
+ });
19570
+ if (flow === void 0) return;
19571
+ opened = flow;
19572
+ }
19573
+ if (!opened.ok) {
19574
+ if (opened.needsConfirmation !== void 0) {
19575
+ fail(opts, ExitCode.ConfirmationRequired, {
19576
+ error: opened.refusal,
19577
+ code: "first-publish-unconfirmed",
19578
+ remediation: "Approve it in your browser \u2014 run the publish again and open the link it prints."
19103
19579
  });
19104
- return;
19105
- case "denied":
19106
- fail(opts, ExitCode.General, {
19107
- error: "the sign-in was denied in the browser",
19108
- code: "login-denied",
19109
- remediation: `If that was not you saying no, run ${tendrilCommand("login")} again and approve the fresh code.`
19110
- });
19111
- break;
19112
- case "expired":
19113
- case "gave-up":
19114
- fail(opts, ExitCode.General, {
19115
- error: outcome.status === "expired" ? "the sign-in code lapsed before anyone approved it \u2014 they live ten minutes" : "gave up waiting for the browser approval",
19116
- code: "login-expired",
19117
- remediation: `Run ${tendrilCommand("login")} again for a fresh code.`
19580
+ }
19581
+ refuse(opts, opened, "publish-refused");
19582
+ }
19583
+ if (opts.approveWait === true) spendPendingApproval();
19584
+ const uploaded = [];
19585
+ for (const object of opened.value.plan.objects) {
19586
+ const file = path55.join(bundleDir, object.relPath);
19587
+ if (!existsSync45(file)) {
19588
+ fail(opts, ExitCode.InputValidation, {
19589
+ error: `the portal expects ${object.relPath}, and it is not in ${opts.bundleDir}`,
19590
+ code: "planned-file-missing",
19591
+ remediation: `Re-run \`${tendrilCommand(`verify ${opts.bundleDir}`)}\` and publish again \u2014 a publication is refused rather than going live with a hole in its evidence.`
19118
19592
  });
19119
- break;
19120
- default:
19593
+ }
19594
+ const sent = await client.upload({
19595
+ publicationId: opened.value.publicationId,
19596
+ relPath: object.relPath,
19597
+ bytes: new Uint8Array(readFileSync41(file))
19598
+ });
19599
+ if (!sent.ok) refuse(opts, sent, "upload-refused", true);
19600
+ uploaded.push({ relPath: object.relPath, deduplicated: sent.value.deduplicated });
19601
+ emitProgress(uploaded.length, opened.value.plan.objects.length, `uploading ${object.relPath}`);
19602
+ }
19603
+ emitProgress(uploaded.length, uploaded.length, "upload complete \u2014 committing the publication");
19604
+ const committed = await client.commit({ publicationId: opened.value.publicationId });
19605
+ if (committed.ok) {
19606
+ await endRunPresence(componentName);
19607
+ }
19608
+ if (!committed.ok) {
19609
+ if (committed.missing !== void 0 && committed.missing.length > 0) {
19121
19610
  fail(opts, ExitCode.General, {
19122
- error: "the portal no longer recognises this sign-in attempt",
19123
- code: "login-unknown",
19124
- remediation: `Run ${tendrilCommand("login")} again. If this repeats, the approval may be racing another terminal \u2014 approve only one at a time.`
19611
+ error: `this publication is missing objects the bundle declared, and a verdict beside missing evidence is worse than no page: ${committed.missing.join(", ")}`,
19612
+ code: "publication-incomplete",
19613
+ remediation: `Run \`${tendrilCommand(`publish ${opts.bundleDir}`)}\` again \u2014 it rejoins this same unfinished publication and re-sends only what is missing.`
19125
19614
  });
19615
+ }
19616
+ refuse(opts, committed, "commit-refused", true);
19126
19617
  }
19127
- }
19128
- function pendingLoginPath() {
19129
- return path55.join(path55.dirname(sessionPath()), "pending-login.json");
19130
- }
19131
- async function deviceStartPhase(opts, origin, deps) {
19132
- const started = await startHandshake(opts, origin, deps);
19133
- const file = pendingLoginPath();
19134
- mkdirSync14(path55.dirname(file), { recursive: true });
19135
- writeFileSync23(file, `${JSON.stringify({ origin, ...started }, null, 2)}
19136
- `, { mode: 384 });
19137
- deps.openBrowser(started.verificationUrl);
19138
19618
  emitData(
19139
19619
  opts,
19140
19620
  {
19141
- verificationUrl: started.verificationUrl,
19142
- userCode: started.userCode,
19143
- expiresAt: started.expiresAt,
19144
- origin,
19145
- next: "Show the user the link and the code \u2014 they approve in the browser, and must check the page shows this exact code. Then run login --device-wait (the tendril_login_wait tool) to finish."
19621
+ publicationId: committed.value.publicationId,
19622
+ url: committed.value.url,
19623
+ componentName,
19624
+ figmaFile: opened.value.figmaFile,
19625
+ rulerVersion: opened.value.rulerVersion,
19626
+ resumed: opened.value.resumed === true,
19627
+ files: uploaded
19146
19628
  },
19147
19629
  () => {
19148
- process.stdout.write(`open ${started.verificationUrl}
19630
+ const reused = uploaded.filter((u) => u.deduplicated).length;
19631
+ if (opened.value.resumed === true) process.stdout.write(`resumed the unfinished publish of ${componentName}
19149
19632
  `);
19150
- process.stdout.write(` the page must show: ${started.userCode}
19633
+ process.stdout.write(`published ${componentName} \u2014 ${String(uploaded.length)} files`);
19634
+ process.stdout.write(reused > 0 ? ` (${String(reused)} you already had)
19635
+ ` : "\n");
19636
+ process.stdout.write(` ${committed.value.url}
19151
19637
  `);
19152
- process.stdout.write(` then: ${tendrilCommand("login --device-wait")}
19638
+ process.stdout.write(` verdict as scored by ruler ${opened.value.rulerVersion}
19153
19639
  `);
19154
19640
  }
19155
19641
  );
19156
19642
  }
19157
- async function deviceWaitPhase(opts, deps) {
19158
- const file = pendingLoginPath();
19159
- let pending;
19160
- if (existsSync45(file)) {
19161
- try {
19162
- const parsed = JSON.parse(readFileSync41(file, "utf8"));
19163
- if (typeof parsed.origin === "string" && typeof parsed.deviceCode === "string" && typeof parsed.intervalSeconds === "number") {
19164
- pending = parsed;
19165
- }
19166
- } catch {
19167
- }
19643
+ function readBundle(opts, bundleDir) {
19644
+ const manifestPath2 = path55.join(bundleDir, "component.json");
19645
+ const reportPath = path55.join(bundleDir, EVIDENCE_DIR, VERIFY_REPORT_FILENAME);
19646
+ if (!existsSync45(manifestPath2)) {
19647
+ fail(opts, ExitCode.InputValidation, {
19648
+ error: `there is no component.json in ${opts.bundleDir}, so this is not a bundle`,
19649
+ code: "not-a-bundle",
19650
+ remediation: `Point publish at a directory a Tendril run produced. \`${tendrilCommand("generate --help")}\` shows how one is made.`
19651
+ });
19168
19652
  }
19169
- if (pending === void 0) {
19653
+ if (!existsSync45(reportPath)) {
19170
19654
  fail(opts, ExitCode.InputValidation, {
19171
- error: "there is no sign-in waiting to finish",
19172
- code: "no-pending-login",
19173
- remediation: `Start one first: ${tendrilCommand("login --device-start")} (the tendril_login tool).`
19655
+ error: `this bundle has never been verified \u2014 there is no ${EVIDENCE_DIR}/${VERIFY_REPORT_FILENAME}`,
19656
+ code: "bundle-not-verified",
19657
+ remediation: `Run \`${tendrilCommand(`verify ${opts.bundleDir}`)}\` first. Verification is free and needs no account; publishing without it would put a page up with no verdict on it.`
19174
19658
  });
19175
19659
  }
19176
- const done = () => rmSync8(file, { force: true });
19177
- const total = Math.ceil(660 / Math.max(1, pending.intervalSeconds));
19178
- let ticks = 0;
19179
- const outcome = await waitForDecision(opts, pending.origin, deps, pending, () => {
19180
- ticks += 1;
19181
- emitProgress(ticks, total, "waiting for the browser approval");
19182
- });
19183
- done();
19184
- settleDecision(opts, pending.origin, outcome);
19185
- }
19186
- async function startHandshake(opts, origin, deps) {
19187
- let response;
19660
+ const { manifest } = readBundleManifest(readFileSync41(manifestPath2, "utf8"));
19661
+ if (manifest === void 0) {
19662
+ fail(opts, ExitCode.InputValidation, {
19663
+ error: "component.json did not parse as a bundle manifest",
19664
+ code: "no-mount-contract",
19665
+ remediation: `Re-emit the bundle \u2014 \`${tendrilCommand(`verify ${opts.bundleDir}`)}\` rewrites component.json \u2014 then publish.`
19666
+ });
19667
+ }
19668
+ const reportText = readFileSync41(reportPath, "utf8");
19669
+ let report;
19188
19670
  try {
19189
- response = await deps.fetch(`${origin}/api/device`, { method: "POST", headers: { "content-type": "application/json" }, body: "{}" });
19671
+ report = JSON.parse(reportText);
19190
19672
  } catch (error) {
19191
- return unreachable(opts, origin, error.message);
19673
+ fail(opts, ExitCode.InputValidation, {
19674
+ error: `${EVIDENCE_DIR}/${VERIFY_REPORT_FILENAME} is not parseable JSON: ${error.message}`,
19675
+ code: "report-unparseable",
19676
+ remediation: `Re-run \`${tendrilCommand(`verify ${opts.bundleDir}`)}\` \u2014 that file is written by the ruler and should never be edited by hand.`
19677
+ });
19192
19678
  }
19193
- if (!response.ok) return unreachable(opts, origin, `the portal answered ${String(response.status)}`);
19194
- const body = await response.json();
19195
- if (typeof body.deviceCode !== "string" || typeof body.userCode !== "string" || typeof body.verificationUrl !== "string" || typeof body.intervalSeconds !== "number" || typeof body.expiresAt !== "string") {
19196
- return unreachable(opts, origin, "the portal's answer was not a device handshake");
19679
+ if (report === null || typeof report !== "object" || Array.isArray(report)) {
19680
+ fail(opts, ExitCode.InputValidation, {
19681
+ error: `${EVIDENCE_DIR}/${VERIFY_REPORT_FILENAME} parses, but a verify report is a JSON object and this is not one`,
19682
+ code: "report-not-an-object",
19683
+ remediation: `Re-run \`${tendrilCommand(`verify ${opts.bundleDir}`)}\`.`
19684
+ });
19197
19685
  }
19198
- return body;
19686
+ return { manifest, report, reportText, files: bundleFiles(bundleDir) };
19199
19687
  }
19200
- async function pollHandshake(opts, origin, deps, deviceCode) {
19201
- let response;
19202
- try {
19203
- response = await deps.fetch(`${origin}/api/device/token`, {
19204
- method: "POST",
19205
- headers: { "content-type": "application/json" },
19206
- body: JSON.stringify({ deviceCode })
19688
+ function resolveOrigin(opts) {
19689
+ const named = (opts.to ?? process.env["TENDRIL_PORTAL_URL"] ?? "").replace(/\/+$/, "");
19690
+ if (named !== "") return named;
19691
+ if ((process.env["TENDRIL_TOKEN"] ?? "") !== "") return "";
19692
+ return (readStoredSession()?.origin ?? "").replace(/\/+$/, "");
19693
+ }
19694
+ function httpClient(opts, origin) {
19695
+ if (origin === "") {
19696
+ fail(opts, ExitCode.InputValidation, {
19697
+ error: "no portal to publish to",
19698
+ code: "no-portal-configured",
19699
+ remediation: `Pass \`--to <url>\` or set TENDRIL_PORTAL_URL. \`${tendrilCommand(`publish ${opts.bundleDir} --dry-run`)}\` shows exactly what would be published without needing one.`
19207
19700
  });
19208
- } catch {
19209
- return { status: "pending" };
19210
19701
  }
19211
- if (!response.ok) return { status: "pending" };
19212
- const body = await response.json();
19213
- if (body.status === "approved" && typeof body.token === "string") return { status: "approved", token: body.token };
19214
- if (body.status === "denied" || body.status === "expired" || body.status === "unknown") return { status: body.status };
19215
- return { status: "pending" };
19702
+ if (!isSecureOrigin(origin)) {
19703
+ fail(opts, ExitCode.InputValidation, {
19704
+ error: `${origin} is not https, so a session token sent there would travel in the clear`,
19705
+ code: "portal-not-https",
19706
+ remediation: "Use the https:// address of your portal. Only 127.0.0.1 and localhost are exempt, for local development."
19707
+ });
19708
+ }
19709
+ const found = tokenFor(origin);
19710
+ if (!found.ok && found.reason === "origin-mismatch") {
19711
+ fail(opts, ExitCode.Auth, {
19712
+ error: `the session available here belongs to ${found.boundTo}, and this would publish to ${origin}`,
19713
+ code: "session-belongs-to-another-portal",
19714
+ remediation: found.from === "env" ? `TENDRIL_TOKEN is pinned to TENDRIL_PORTAL_URL (${found.boundTo}). Publish to that portal, or set both to ${origin} together \u2014 a token is a credential for one host.` : `Sign in to ${origin}. The stored session is for ${found.boundTo}, and sending it elsewhere would hand that host a live credential.`
19715
+ });
19716
+ }
19717
+ if (!found.ok) {
19718
+ fail(opts, ExitCode.Auth, {
19719
+ error: `no session for ${origin}`,
19720
+ code: "not-signed-in",
19721
+ remediation: `Run \`${tendrilCommand(`login --to ${origin}`)}\` and paste the token your portal operator gave you (or set TENDRIL_TOKEN and TENDRIL_PORTAL_URL together for this shell). Verification stays free and account-less; only publishing needs an account.`
19722
+ });
19723
+ }
19724
+ return new HttpPublishClient({ origin, token: found.token });
19216
19725
  }
19217
- function unreachable(opts, origin, detail) {
19218
- fail(opts, ExitCode.General, {
19219
- error: `could not start a sign-in with ${origin}: ${detail}`,
19220
- code: "portal-unreachable",
19221
- remediation: "Check your connection and try again. If your portal is self-hosted, confirm the --to URL."
19726
+ function refuse(opts, sent, code, rejoins = false) {
19727
+ const detail = sent.detail === void 0 || sent.detail.length === 0 ? "" : `: ${sent.detail.join(", ")}`;
19728
+ const retry = rejoins ? `run \`${tendrilCommand(`publish ${opts.bundleDir}`)}\` again \u2014 it rejoins this same unfinished publication rather than starting a second one` : `run \`${tendrilCommand(`publish ${opts.bundleDir}`)}\` again`;
19729
+ fail(opts, sent.status === 401 ? ExitCode.Auth : ExitCode.General, {
19730
+ error: `${sent.refusal}${detail}`,
19731
+ code,
19732
+ remediation: sent.status === 401 ? `That session is no longer valid. Run \`${tendrilCommand(`login --to ${resolveOrigin(opts)}`)}\` and paste a fresh token.` : sent.status >= 500 ? `The portal failed on its side. Quote the error id above to whoever runs it, then ${retry}.` : `Fix what is named above and ${retry}.`
19222
19733
  });
19223
19734
  }
19224
- function realDeps() {
19225
- return {
19226
- fetch: globalThis.fetch.bind(globalThis),
19227
- sleep: (seconds) => new Promise((resolve) => setTimeout(resolve, seconds * 1e3)),
19228
- openBrowser: (url) => {
19229
- const [cmd, args] = process.platform === "darwin" ? ["open", [url]] : process.platform === "win32" ? ["cmd", ["/c", "start", "", url]] : ["xdg-open", [url]];
19230
- try {
19231
- spawn(cmd, args, { stdio: "ignore", detached: true }).on("error", () => void 0).unref();
19232
- } catch {
19735
+ function pendingApprovalPath() {
19736
+ return path55.join(path55.dirname(sessionPath()), "pending-publish.json");
19737
+ }
19738
+ async function runApprovalFlow(opts, client, bundleDir, input) {
19739
+ const requested = await client.requestApproval({ componentName: input.componentName, figmaFile: input.figmaFile });
19740
+ if (!requested.ok) refuse(opts, requested, "approval-request-refused");
19741
+ const approval = requested.value;
19742
+ const who = await client.whoami?.();
19743
+ const asAccount = who?.ok === true && who.value.email !== "" ? ` signed in as ${who.value.email}` : "";
19744
+ if (opts.approveStart === true) {
19745
+ const pending = { ...approval, bundleDir, componentName: input.componentName, figmaFile: input.figmaFile, requestedAt: (/* @__PURE__ */ new Date()).toISOString() };
19746
+ writeFileSync23(pendingApprovalPath(), `${JSON.stringify(pending, null, 2)}
19747
+ `, { mode: 384 });
19748
+ await endRunPresence(input.componentName);
19749
+ emitData(
19750
+ opts,
19751
+ {
19752
+ status: "approval-pending",
19753
+ approveUrl: approval.approveUrl,
19754
+ componentName: input.componentName,
19755
+ ...asAccount === "" ? {} : { approveAsAccount: who?.ok === true ? who.value.email : "" },
19756
+ expiresAt: approval.expiresAt,
19757
+ next: `open the approve page in the browser${asAccount}, click Approve, then finish with --approve-wait`
19758
+ },
19759
+ () => {
19760
+ process.stdout.write(`Approval requested for ${JSON.stringify(input.componentName)}.
19761
+ `);
19762
+ process.stdout.write(`Approve it here${asAccount}: ${approval.approveUrl}
19763
+ `);
19764
+ process.stdout.write(`Then finish with: ${tendrilCommand(`publish ${opts.bundleDir} --approve-wait`)}
19765
+ `);
19766
+ }
19767
+ );
19768
+ return void 0;
19769
+ }
19770
+ process.stderr.write(`This component's FIRST publish needs your approval in the browser${asAccount}:
19771
+ ${approval.approveUrl}
19772
+ `);
19773
+ process.stderr.write(`Waiting for your decision (lapses at ${approval.expiresAt.slice(11, 16)} UTC)\u2026
19774
+ `);
19775
+ const decided = await waitForApproval(opts, client, approval);
19776
+ if (decided === "approved") return input.begin();
19777
+ await endRunPresence(input.componentName);
19778
+ failDecision(opts, decided === "window-elapsed" ? "expired" : decided);
19779
+ }
19780
+ async function approveWaitPhase(opts, client, bundleDir, subject) {
19781
+ const file = pendingApprovalPath();
19782
+ let pending;
19783
+ if (existsSync45(file)) {
19784
+ try {
19785
+ const parsed = JSON.parse(readFileSync41(file, "utf8"));
19786
+ if (typeof parsed.approvalId === "string" && typeof parsed.approveUrl === "string" && typeof parsed.expiresAt === "string") {
19787
+ pending = { pollSeconds: 3, bundleDir, ...parsed };
19233
19788
  }
19789
+ } catch {
19234
19790
  }
19235
- };
19236
- }
19237
- async function pasteLogin(opts, origin) {
19238
- const token = await readToken(opts);
19239
- if (token === void 0 || token.trim() === "") {
19791
+ }
19792
+ if (pending === void 0) {
19240
19793
  fail(opts, ExitCode.InputValidation, {
19241
- error: "no token was given",
19242
- code: "no-token",
19243
- remediation: `Paste the token when prompted, or pipe it: \`echo "<token>" | ${tendrilCommand(`login --to ${origin}`)}\`.`
19794
+ error: "there is no publish approval waiting to finish",
19795
+ code: "no-pending-approval",
19796
+ remediation: `Start one first: ${tendrilCommand(`publish ${opts.bundleDir} --approve-start`)} (the tendril_publish tool).`
19244
19797
  });
19245
19798
  }
19246
- const trimmed = token.trim();
19247
- if (!/^[A-Za-z0-9_-]{16,512}$/.test(trimmed)) {
19799
+ if (pending.componentName !== void 0 && pending.componentName !== subject.componentName) {
19248
19800
  fail(opts, ExitCode.InputValidation, {
19249
- error: "that does not look like a Tendril session token",
19250
- code: "malformed-token",
19251
- remediation: "Tokens are a single line of letters, digits, hyphens and underscores. Check for a stray space or a truncated paste."
19801
+ error: `the waiting approval is for ${JSON.stringify(pending.componentName)}, and this publish is ${JSON.stringify(subject.componentName)}`,
19802
+ code: "pending-approval-mismatch",
19803
+ remediation: `Finish that one first (${tendrilCommand(`publish ${pending.bundleDir} --approve-wait`)}), or start this one fresh with --approve-start \u2014 starting one replaces the waiting slot.`
19252
19804
  });
19253
19805
  }
19254
- writeStoredSession({ origin, token: trimmed });
19255
- emitData(opts, { origin, storedAt: sessionPath(), via: "paste" }, () => {
19256
- process.stdout.write(`signed in to ${origin}
19257
- `);
19258
- process.stdout.write(` token stored at ${sessionPath()} (readable only by you)
19806
+ const done = () => rmSync8(file, { force: true });
19807
+ const decided = await waitForApproval(opts, client, pending);
19808
+ if (decided === "window-elapsed") {
19809
+ const who = await client.whoami?.();
19810
+ const asAccount = who?.ok === true && who.value.email !== "" ? who.value.email : void 0;
19811
+ emitData(
19812
+ opts,
19813
+ {
19814
+ status: "approval-pending",
19815
+ approveUrl: pending.approveUrl,
19816
+ ...asAccount !== void 0 ? { approveAsAccount: asAccount } : {},
19817
+ expiresAt: pending.expiresAt,
19818
+ windowSeconds: opts.waitWindowSeconds ?? 0,
19819
+ ...pending.requestedAt !== void 0 ? { waitedTotalSeconds: Math.max(0, Math.round((Date.now() - Date.parse(pending.requestedAt)) / 1e3)) } : {},
19820
+ remainingSeconds: Math.max(0, Math.round((Date.parse(pending.expiresAt) - Date.now()) / 1e3)),
19821
+ next: "the approval request is STILL LIVE and undecided \u2014 tell the user in one short line you are still waiting for their Approve click (restate the approve link and the account to approve as, ONLY if they seem lost; never urge the decision), then run the wait again to keep waiting"
19822
+ },
19823
+ () => {
19824
+ process.stdout.write(`Still waiting for the browser approval (wait window elapsed; the request stays live until ${pending.expiresAt.slice(11, 16)} UTC): ${pending.approveUrl}
19259
19825
  `);
19260
- });
19826
+ }
19827
+ );
19828
+ return "yielded";
19829
+ }
19830
+ if (decided !== "approved") {
19831
+ done();
19832
+ failDecision(opts, decided);
19833
+ }
19834
+ return "proceed";
19261
19835
  }
19262
- async function readToken(opts) {
19263
- if (process.stdin.isTTY === true) {
19264
- const entered = await password2({ message: "Paste your Tendril portal token (it will not be shown)" });
19265
- if (isCancel3(entered)) {
19266
- fail(opts, ExitCode.General, { error: "cancelled", code: "cancelled", remediation: "Run it again when you have the token." });
19267
- }
19268
- return entered;
19836
+ function spendPendingApproval() {
19837
+ rmSync8(pendingApprovalPath(), { force: true });
19838
+ }
19839
+ async function waitForApproval(opts, client, approval) {
19840
+ const interval = Math.max(1, approval.pollSeconds) * 1e3;
19841
+ const capMs = opts.waitWindowSeconds !== void 0 ? Math.max(1, opts.waitWindowSeconds) * 1e3 : APPROVAL_WAIT_CAP_MS;
19842
+ const total = Math.ceil(capMs / interval);
19843
+ for (let tick = 1; tick <= total; tick += 1) {
19844
+ const polled = await client.pollApproval({ approvalId: approval.approvalId });
19845
+ if (!polled.ok) refuse(opts, polled, "approval-poll-refused");
19846
+ if (polled.value.status !== "pending") return polled.value.status;
19847
+ emitProgress(tick, total, "waiting for the browser approval");
19848
+ await new Promise((resolve) => setTimeout(resolve, interval));
19269
19849
  }
19270
- const chunks = [];
19271
- for await (const chunk of process.stdin) chunks.push(chunk);
19272
- return Buffer.concat(chunks).toString("utf8").split("\n")[0];
19850
+ return opts.waitWindowSeconds !== void 0 ? "window-elapsed" : "expired";
19273
19851
  }
19274
- function runLogout(opts) {
19275
- const stored = readStoredSession();
19276
- if (stored === void 0) {
19277
- emitData(opts, { signedOut: true, wasSignedIn: false }, () => process.stdout.write("you were not signed in\n"));
19278
- return;
19852
+ function failDecision(opts, decided) {
19853
+ if (decided === "denied") {
19854
+ fail(opts, ExitCode.ConfirmationRequired, {
19855
+ error: "the publish was DENIED in the browser \u2014 the human said no",
19856
+ code: "publish-approval-denied",
19857
+ remediation: "Nothing was published. If minds change, run the publish again \u2014 it makes a fresh request."
19858
+ });
19279
19859
  }
19280
- clearStoredSession();
19281
- emitData(opts, { signedOut: true, wasSignedIn: true, origin: stored.origin }, () => {
19282
- process.stdout.write(`this machine has forgotten its session for ${stored.origin}
19283
- `);
19284
- process.stdout.write(" the session itself is still valid \u2014 sign out everywhere from the portal to end it\n");
19860
+ fail(opts, ExitCode.ConfirmationRequired, {
19861
+ error: decided === "expired" ? "the approval request lapsed before anyone decided it" : "the approval request is gone \u2014 it lapsed and was cleaned up, or was already spent",
19862
+ code: "publish-approval-lapsed",
19863
+ remediation: "Run the publish again for a fresh request, and decide it within its 30-minute window. If the approve page showed nothing waiting, the browser is signed in to a DIFFERENT account than this CLI \u2014 the page lists only its own account's requests."
19285
19864
  });
19286
19865
  }
19287
- var DEFAULT_PORTAL_ORIGIN;
19288
- var init_login = __esm({
19289
- "packages/cli/src/commands/login.ts"() {
19866
+ var APPROVAL_WAIT_CAP_MS;
19867
+ var init_publish = __esm({
19868
+ "packages/cli/src/commands/publish.ts"() {
19290
19869
  "use strict";
19291
19870
  init_src3();
19871
+ init_src4();
19292
19872
  init_invocation();
19293
19873
  init_output();
19294
19874
  init_publish_client();
19295
- DEFAULT_PORTAL_ORIGIN = "https://app.trytendril.com";
19875
+ init_run_presence();
19876
+ APPROVAL_WAIT_CAP_MS = 31 * 6e4;
19296
19877
  }
19297
19878
  });
19298
19879
 
19299
- // packages/cli/src/commands/figma-connect.ts
19300
- var figma_connect_exports = {};
19301
- __export(figma_connect_exports, {
19302
- runFigmaConnect: () => runFigmaConnect
19880
+ // packages/cli/src/commands/compose-approve.ts
19881
+ var compose_approve_exports = {};
19882
+ __export(compose_approve_exports, {
19883
+ composeSubjectDigest: () => composeSubjectDigest,
19884
+ composeSubjectFor: () => composeSubjectFor,
19885
+ runComposeApprove: () => runComposeApprove,
19886
+ runComposeApproveStart: () => runComposeApproveStart,
19887
+ runComposeApproveWait: () => runComposeApproveWait
19303
19888
  });
19304
- import { existsSync as existsSync46, mkdirSync as mkdirSync15, readFileSync as readFileSync42, rmSync as rmSync9, writeFileSync as writeFileSync24 } from "node:fs";
19889
+ import { createHash as createHash13 } from "node:crypto";
19890
+ import { existsSync as existsSync46, mkdirSync as mkdirSync14, readFileSync as readFileSync42, rmSync as rmSync9, writeFileSync as writeFileSync24 } from "node:fs";
19305
19891
  import path56 from "node:path";
19306
- function pendingConnectPath() {
19307
- return path56.join(path56.dirname(sessionPath()), "pending-figma-connect.json");
19308
- }
19309
- function resolveOrigin(opts) {
19310
- const named = (opts.to ?? process.env["TENDRIL_PORTAL_URL"] ?? "").replace(/\/+$/, "");
19311
- if (named !== "") return named;
19312
- if ((process.env["TENDRIL_TOKEN"] ?? "") !== "") return "";
19313
- return (readStoredSession()?.origin ?? "").replace(/\/+$/, "");
19892
+ function composeSubjectDigest(subject) {
19893
+ const preimage = JSON.stringify([
19894
+ subject.hostComponent,
19895
+ subject.hostFigmaFile,
19896
+ subject.hostManifestSha256,
19897
+ subject.pairKey,
19898
+ subject.partnerManifestSha256,
19899
+ subject.instances.map((i) => [i.hostRep, i.instanceId, i.poseVariantNodeId]),
19900
+ subject.disclosures
19901
+ ]);
19902
+ return createHash13("sha256").update(preimage, "utf8").digest("hex");
19903
+ }
19904
+ function composeSubjectFor(hostSet, pair) {
19905
+ const manifest = loadManifest(hostSet);
19906
+ const canonical = {
19907
+ hostComponent: manifest.component,
19908
+ hostFigmaFile: manifest.figmaFile ?? "unidentified",
19909
+ hostManifestSha256: createHash13("sha256").update(readFileSync42(path56.join(hostSet, "recording-set.json"))).digest("hex"),
19910
+ pairKey: pair.key,
19911
+ partnerManifestSha256: pair.partnerDirs.map((d) => [fromStoredRel(path56.relative(hostSet, d)), createHash13("sha256").update(readFileSync42(path56.join(d, "recording-set.json"))).digest("hex")]).sort((a, b) => a[0] < b[0] ? -1 : 1),
19912
+ instances: [...pair.instances].map((i) => ({ hostRep: i.hostRep, instanceId: i.instanceId, poseVariantNodeId: i.poseVariantNodeId })).sort((a, b) => a.hostRep + a.instanceId < b.hostRep + b.instanceId ? -1 : 1),
19913
+ disclosures: [...pair.disclosures]
19914
+ };
19915
+ const slugs = [...new Set(canonical.instances.map((i) => i.hostRep))].sort();
19916
+ return {
19917
+ canonical,
19918
+ digest: composeSubjectDigest(canonical),
19919
+ display: {
19920
+ hostComponent: canonical.hostComponent,
19921
+ hostFigmaFile: canonical.hostFigmaFile,
19922
+ partnerDisplay: pair.displayName,
19923
+ poseSummary: truncateWithMarker(slugs.join(", "), 500),
19924
+ instanceCount: Math.min(canonical.instances.length, 500),
19925
+ disclosures: truncateWithMarker(canonical.disclosures.join("\n"), 4096)
19926
+ }
19927
+ };
19314
19928
  }
19315
- function bearerFor(opts, origin) {
19929
+ function composePortalClient(flags) {
19930
+ if (flags.client !== void 0) return flags.client;
19931
+ const origin = resolveOrigin({ to: flags.to });
19316
19932
  if (origin === "") {
19317
- fail(opts, ExitCode.InputValidation, {
19318
- error: "no portal to connect through",
19933
+ fail(flags, ExitCode.InputValidation, {
19934
+ error: "no portal to request the approval from",
19319
19935
  code: "no-portal-configured",
19320
- remediation: `Sign in first (${tendrilCommand("login")}) \u2014 the connect uses your portal account.`
19936
+ remediation: "Pass --to <url> or set TENDRIL_PORTAL_URL \u2014 or decide in your terminal instead: `--confirm-compositions` needs no account at all."
19937
+ });
19938
+ }
19939
+ if (!isSecureOrigin(origin)) {
19940
+ fail(flags, ExitCode.InputValidation, {
19941
+ error: `${origin} is not https, so a session token sent there would travel in the clear`,
19942
+ code: "portal-not-https",
19943
+ remediation: "Use the https:// address of your portal. Only 127.0.0.1 and localhost are exempt, for local development."
19321
19944
  });
19322
19945
  }
19323
19946
  const found = tokenFor(origin);
19947
+ if (!found.ok && found.reason === "origin-mismatch") {
19948
+ fail(flags, ExitCode.Auth, {
19949
+ error: `the session available here belongs to ${found.boundTo}, and this would ask ${origin}`,
19950
+ code: "session-belongs-to-another-portal",
19951
+ remediation: "Sign in to the portal this workspace uses, or decide in your terminal with --confirm-compositions."
19952
+ });
19953
+ }
19324
19954
  if (!found.ok) {
19325
- fail(opts, ExitCode.Auth, {
19955
+ fail(flags, ExitCode.Auth, {
19326
19956
  error: `no session for ${origin}`,
19327
19957
  code: "not-signed-in",
19328
- remediation: `Run ${tendrilCommand("login")} first \u2014 connecting Figma needs your signed-in portal account.`
19958
+ remediation: "Agents: run tendril_login \u2014 the user signs in with ONE browser Approve, then re-run this. Terminal humans can skip the account entirely and decide with `--confirm-compositions` (composition never requires an account; only the browser-approve convenience does)."
19329
19959
  });
19330
19960
  }
19331
- return found.token;
19961
+ return new HttpPublishClient({ origin, token: found.token });
19332
19962
  }
19333
- async function runFigmaConnect(opts) {
19334
- const send = opts.fetchImpl ?? globalThis.fetch;
19335
- const origin = resolveOrigin(opts);
19336
- if (opts.wait === true) {
19337
- await waitPhase(opts, send);
19338
- return;
19963
+ function pendingComposePath() {
19964
+ return path56.join(path56.dirname(sessionPath()), "pending-compose.json");
19965
+ }
19966
+ function openPairsFor(hostSet, roots) {
19967
+ const scanRoots = [.../* @__PURE__ */ new Set([...roots, path56.dirname(hostSet)])];
19968
+ const edges = composeReport(buildComposeIndex(scanRoots));
19969
+ const pairs = substitutionPairs(edges, hostSet);
19970
+ const { raw } = readManifestFile(hostSet);
19971
+ const rawStanding = Array.isArray(raw["compositions"]) ? raw["compositions"] : [];
19972
+ const decidedKeys = new Set(
19973
+ rawStanding.map((e) => CompositionEntrySchema.safeParse(e)).filter((p) => p.success).map((p) => fromStoredRel(p.data.partner.key))
19974
+ );
19975
+ return pairs.filter((p) => !decidedKeys.has(p.key));
19976
+ }
19977
+ async function runComposeApproveStart(flags, hostSet, roots) {
19978
+ if (!existsSync46(path56.join(hostSet, "recording-set.json"))) {
19979
+ fail(flags, ExitCode.InputValidation, { error: `no recording-set.json in ${hostSet}`, code: "no-recording-set", remediation: "Point --set at a recorded host set." });
19339
19980
  }
19340
- const token = bearerFor(opts, origin);
19341
- const started = await startConnect(opts, send, origin, token);
19342
- if (opts.start === true) {
19343
- const pending = { origin, ...started };
19344
- mkdirSync15(path56.dirname(pendingConnectPath()), { recursive: true });
19345
- writeFileSync24(pendingConnectPath(), `${JSON.stringify(pending, null, 2)}
19981
+ const open = openPairsFor(hostSet, roots);
19982
+ if (open.length === 0) {
19983
+ fail(flags, ExitCode.InputValidation, {
19984
+ error: "no undecided id-backed composition pairs for this host \u2014 nothing to put in front of the human",
19985
+ code: "compose-approve-nothing-open",
19986
+ remediation: `${tendrilCommand(`compose --set ${quoteArg(hostSet)}`)} shows what exists (name-only proposals carry their own remediation); ${tendrilCommand(`record bindings --set ${quoteArg(hostSet)}`)} makes MCP-recorded pairings id-backed.`
19987
+ });
19988
+ }
19989
+ let pair = open[0];
19990
+ if (open.length > 1) {
19991
+ if (flags.pair === void 0) {
19992
+ fail(flags, ExitCode.InputValidation, {
19993
+ error: `this host has ${open.length} open pairs and one card carries one decision \u2014 pick with --pair <key>: ${open.map((p) => p.key).join(", ")}`,
19994
+ code: "compose-approve-pick-a-pair",
19995
+ remediation: "Re-run with --pair <key> for the pairing to request; repeat for the others, one decision each."
19996
+ });
19997
+ }
19998
+ const picked = open.find((p) => p.key === flags.pair);
19999
+ if (picked === void 0) {
20000
+ fail(flags, ExitCode.InputValidation, { error: `--pair "${flags.pair}" names no open pair (open: ${open.map((p) => p.key).join(", ")})`, code: "unknown-composition-pair", remediation: "Use a pair-key from the list." });
20001
+ }
20002
+ pair = picked;
20003
+ } else if (flags.pair !== void 0 && flags.pair !== pair.key) {
20004
+ fail(flags, ExitCode.InputValidation, { error: `--pair "${flags.pair}" names no open pair (open: ${pair.key})`, code: "unknown-composition-pair", remediation: "Use the open pair-key, or drop --pair." });
20005
+ }
20006
+ const subject = composeSubjectFor(hostSet, pair);
20007
+ const client = composePortalClient(flags);
20008
+ const requested = await client.requestComposeApproval({ subjectDigest: subject.digest, ...subject.display });
20009
+ if (!requested.ok) {
20010
+ if (requested.status === 404) {
20011
+ fail(flags, ExitCode.General, {
20012
+ error: "this portal does not offer compose approvals yet",
20013
+ code: "compose-approvals-unavailable",
20014
+ remediation: `Decide in your terminal instead: ${tendrilCommand(`compose --set ${quoteArg(hostSet)} --confirm-compositions`)} (human-only, no account needed).`
20015
+ });
20016
+ }
20017
+ fail(flags, ExitCode.General, { error: requested.refusal, code: "compose-approval-request-refused", remediation: "Fix what is named above and re-run." });
20018
+ }
20019
+ const who = await client.whoami?.();
20020
+ const asAccount = who?.ok === true && who.value.email !== "" ? who.value.email : void 0;
20021
+ const pending = {
20022
+ approvalId: requested.value.approvalId,
20023
+ approveUrl: requested.value.approveUrl,
20024
+ expiresAt: requested.value.expiresAt,
20025
+ pollSeconds: requested.value.pollSeconds,
20026
+ hostSet,
20027
+ pairKey: pair.key,
20028
+ subjectDigest: subject.digest,
20029
+ canonical: subject.canonical,
20030
+ roots,
20031
+ requestedAt: (/* @__PURE__ */ new Date()).toISOString()
20032
+ };
20033
+ mkdirSync14(path56.dirname(pendingComposePath()), { recursive: true });
20034
+ writeFileSync24(pendingComposePath(), `${JSON.stringify(pending, null, 2)}
19346
20035
  `, { mode: 384 });
19347
- (opts.openBrowser ?? (() => {
19348
- }))(started.connectUrl);
19349
- emitData(
19350
- opts,
19351
- {
19352
- connectUrl: started.connectUrl,
19353
- expiresAt: started.expiresAt,
19354
- origin,
19355
- next: "Show the user the link \u2014 they sign in (same account) and click Allow on Figma's consent screen. Then run figma-connect --wait (the tendril_figma_connect_wait tool) to finish."
19356
- },
19357
- () => {
19358
- process.stdout.write(`open ${started.connectUrl}
19359
- `);
19360
- process.stdout.write(` then: ${tendrilCommand("figma-connect --wait")}
20036
+ emitData(
20037
+ flags,
20038
+ {
20039
+ status: "approval-pending",
20040
+ approveUrl: pending.approveUrl,
20041
+ ...asAccount !== void 0 ? { approveAsAccount: asAccount } : {},
20042
+ expiresAt: pending.expiresAt,
20043
+ pairKey: pair.key,
20044
+ hostComponent: subject.display.hostComponent,
20045
+ partnerDisplay: subject.display.partnerDisplay,
20046
+ next: "relay the approve link VERBATIM and name the account to approve as; the human clicks Approve (or Not now) in their browser \u2014 never urge the decision; then finish with the compose wait"
20047
+ },
20048
+ () => {
20049
+ process.stdout.write(`Connect ${subject.display.partnerDisplay} into ${subject.display.hostComponent}?
19361
20050
  `);
19362
- }
19363
- );
19364
- return;
19365
- }
19366
- process.stderr.write(`Connect Figma in your browser:
19367
- ${started.connectUrl}
20051
+ process.stdout.write(`Decide it here${asAccount !== void 0 ? ` signed in as ${asAccount}` : ""}: ${pending.approveUrl}
19368
20052
  `);
19369
- process.stderr.write(`Waiting for the Allow click (lapses at ${started.expiresAt.slice(11, 16)} UTC)\u2026
20053
+ process.stdout.write(`Then finish with: ${tendrilCommand(`compose --set ${quoteArg(hostSet)} --approve-wait`)}
19370
20054
  `);
19371
- const outcome = await pollToDecision(opts, send, origin, token, started);
19372
- settle2(opts, origin, outcome);
19373
- }
19374
- async function startConnect(opts, send, origin, token) {
19375
- let response;
19376
- try {
19377
- response = await send(`${origin}/api/figma-connect`, { method: "POST", headers: { authorization: `Bearer ${token}`, "content-type": "application/json" }, body: "{}" });
19378
- } catch (error) {
19379
- fail(opts, ExitCode.General, {
19380
- error: `could not reach ${origin}: ${error.message}`,
19381
- code: "portal-unreachable",
19382
- remediation: "Check the address and your connection, then try again."
19383
- });
19384
- }
19385
- const body = await response.json().catch(() => ({}));
19386
- if (!response.ok || typeof body.connectId !== "string" || typeof body.connectUrl !== "string" || typeof body.expiresAt !== "string") {
19387
- fail(opts, ExitCode.General, {
19388
- error: body.refusal ?? `the portal answered ${String(response.status)} to the connect start`,
19389
- code: "figma-connect-refused",
19390
- remediation: body.remediation ?? "Fix what is named above and run the connect again."
19391
- });
19392
- }
19393
- return { connectId: body.connectId, connectUrl: body.connectUrl, expiresAt: body.expiresAt, pollSeconds: typeof body.pollSeconds === "number" ? body.pollSeconds : 3 };
20055
+ }
20056
+ );
19394
20057
  }
19395
- async function waitPhase(opts, send) {
19396
- const file = pendingConnectPath();
20058
+ async function runComposeApproveWait(flags, hostSet) {
20059
+ const file = pendingComposePath();
19397
20060
  let pending;
19398
20061
  if (existsSync46(file)) {
19399
20062
  try {
19400
20063
  const parsed = JSON.parse(readFileSync42(file, "utf8"));
19401
- if (typeof parsed.origin === "string" && typeof parsed.connectId === "string") pending = parsed;
20064
+ if (typeof parsed.approvalId === "string" && typeof parsed.subjectDigest === "string" && typeof parsed.hostSet === "string" && typeof parsed.pairKey === "string") {
20065
+ pending = parsed;
20066
+ }
19402
20067
  } catch {
19403
20068
  }
19404
20069
  }
19405
20070
  if (pending === void 0) {
19406
- fail(opts, ExitCode.InputValidation, {
19407
- error: "there is no Figma connection waiting to finish",
19408
- code: "no-pending-figma-connect",
19409
- remediation: `Start one first: ${tendrilCommand("figma-connect --start")} (the tendril_figma_connect tool).`
20071
+ fail(flags, ExitCode.InputValidation, {
20072
+ error: "there is no compose approval waiting to finish",
20073
+ code: "no-pending-compose",
20074
+ remediation: `Start one first: ${tendrilCommand(`compose --set ${quoteArg(hostSet)} --approve-start`)} (the tendril_compose tool).`
20075
+ });
20076
+ }
20077
+ if (path56.resolve(pending.hostSet) !== path56.resolve(hostSet)) {
20078
+ fail(flags, ExitCode.InputValidation, {
20079
+ error: `the waiting approval is for ${pending.hostSet}, and this wait is for ${hostSet}`,
20080
+ code: "pending-compose-mismatch",
20081
+ remediation: `Finish that one first (${tendrilCommand(`compose --set ${quoteArg(pending.hostSet)} --approve-wait`)}), or start this one fresh with --approve-start \u2014 starting one replaces the waiting slot.`
19410
20082
  });
19411
20083
  }
19412
- const token = bearerFor(opts, pending.origin);
19413
- const outcome = await pollToDecision(opts, send, pending.origin, token, pending);
19414
- rmSync9(file, { force: true });
19415
- settle2(opts, pending.origin, outcome);
19416
- }
19417
- async function pollToDecision(opts, send, origin, token, started) {
19418
- const interval = Math.max(1, started.pollSeconds) * 1e3;
19419
- const total = Math.ceil(WAIT_CAP_SECONDS * 1e3 / interval);
20084
+ const rederive = () => {
20085
+ const open = openPairsFor(pending.hostSet, pending.roots ?? []);
20086
+ const pair = open.find((p) => p.key === pending.pairKey);
20087
+ if (pair === void 0) return { changed: "the approved pairing no longer derives from the current recordings (or was decided by another channel)" };
20088
+ const now = composeSubjectFor(pending.hostSet, pair);
20089
+ if (now.digest === pending.subjectDigest) return { pair, digest: now.digest };
20090
+ const then = pending.canonical;
20091
+ const changed = now.canonical.hostManifestSha256 !== then.hostManifestSha256 ? "the HOST recording changed since the request" : JSON.stringify(now.canonical.partnerManifestSha256) !== JSON.stringify(then.partnerManifestSha256) ? "the PARTNER recording changed since the request" : JSON.stringify(now.canonical.instances) !== JSON.stringify(then.instances) ? "the derived instances changed since the request" : "the derived disclosures changed since the request";
20092
+ return { changed };
20093
+ };
20094
+ const before = rederive();
20095
+ if ("changed" in before) {
20096
+ spendComposeSlot();
20097
+ fail(flags, ExitCode.General, {
20098
+ error: `this approval no longer matches the workspace: ${before.changed} \u2014 a stale approval must never write a decision the evidence does not support`,
20099
+ code: "compose-approval-stale",
20100
+ remediation: "Nothing was recorded, and the click was not wrong \u2014 the project moved under it. Run the connect again for a fresh card that matches what is on disk."
20101
+ });
20102
+ }
20103
+ const client = composePortalClient(flags);
20104
+ const interval = Math.max(1, pending.pollSeconds ?? 3) * 1e3;
20105
+ const capMs = flags.waitWindowSeconds !== void 0 ? Math.max(1, flags.waitWindowSeconds) * 1e3 : 31 * 6e4;
20106
+ const total = Math.ceil(capMs / interval);
20107
+ let decision = "window-elapsed";
20108
+ let approvedDigest;
19420
20109
  for (let tick = 1; tick <= total; tick += 1) {
19421
- let body = {};
19422
- try {
19423
- const response = await send(`${origin}/api/figma-connect/${encodeURIComponent(started.connectId)}`, {
19424
- headers: { authorization: `Bearer ${token}` }
19425
- });
19426
- if (response.ok) body = await response.json();
19427
- } catch {
20110
+ const polled = await client.pollComposeApproval({ approvalId: pending.approvalId });
20111
+ if (!polled.ok) {
20112
+ if (polled.status === 404) {
20113
+ spendComposeSlot();
20114
+ fail(flags, ExitCode.General, {
20115
+ error: "this portal does not offer compose approvals yet",
20116
+ code: "compose-approvals-unavailable",
20117
+ remediation: `Decide in your terminal instead: ${tendrilCommand(`compose --set ${quoteArg(hostSet)} --confirm-compositions`)}.`
20118
+ });
20119
+ }
20120
+ fail(flags, ExitCode.General, { error: polled.refusal, code: "compose-approval-poll-refused", remediation: polled.status === 401 ? "That session is no longer valid \u2014 sign in again (tendril_login), then re-run this." : "Fix what is named above and re-run." });
19428
20121
  }
19429
- if (body.status === "connected" && typeof body.accessToken === "string" && typeof body.refreshToken === "string") {
19430
- return {
19431
- status: "connected",
19432
- tokens: {
19433
- origin,
19434
- accessToken: body.accessToken,
19435
- refreshToken: body.refreshToken,
19436
- tokenExpiresAt: typeof body.tokenExpiresAt === "string" ? body.tokenExpiresAt : new Date(Date.now() + 80 * 24 * 60 * 6e4).toISOString()
19437
- }
19438
- };
20122
+ if (polled.value.status !== "pending") {
20123
+ decision = polled.value.status;
20124
+ approvedDigest = polled.value.subjectDigest;
20125
+ break;
19439
20126
  }
19440
- if (body.status === "failed") return { status: "failed", reason: body.reason ?? "declined" };
19441
- if (body.status === "expired") return { status: "expired" };
19442
- if (body.status === "unknown") return { status: "unknown" };
19443
- emitProgress(tick, total, "waiting for the Allow click in the browser");
20127
+ emitProgress(tick, total, "waiting for the browser decision");
19444
20128
  await new Promise((resolve) => setTimeout(resolve, interval));
19445
20129
  }
19446
- return { status: "expired" };
19447
- }
19448
- function settle2(opts, origin, outcome) {
19449
- if (outcome.status === "connected") {
19450
- writeFigmaTokens(outcome.tokens);
20130
+ if (decision === "window-elapsed") {
19451
20131
  emitData(
19452
- opts,
19453
- { status: "connected", origin, tokenExpiresAt: outcome.tokens.tokenExpiresAt, storedAt: figmaTokenPath() },
20132
+ flags,
20133
+ {
20134
+ status: "approval-pending",
20135
+ approveUrl: pending.approveUrl,
20136
+ expiresAt: pending.expiresAt,
20137
+ windowSeconds: flags.waitWindowSeconds ?? 0,
20138
+ waitedTotalSeconds: Math.max(0, Math.round((Date.now() - Date.parse(pending.requestedAt)) / 1e3)),
20139
+ remainingSeconds: Math.max(0, Math.round((Date.parse(pending.expiresAt) - Date.now()) / 1e3)),
20140
+ next: "the request is STILL LIVE and undecided \u2014 one short liveness line to the user, then run the wait again"
20141
+ },
19454
20142
  () => {
19455
- process.stdout.write(`Figma is connected \u2014 recording can use the REST channel now.
19456
- `);
19457
- process.stdout.write(` credential: ${figmaTokenPath()} (0600; renews itself through the portal)
20143
+ process.stdout.write(`Still waiting for the browser decision (window elapsed; live until ${pending.expiresAt.slice(11, 16)} UTC): ${pending.approveUrl}
19458
20144
  `);
19459
20145
  }
19460
20146
  );
19461
20147
  return;
19462
20148
  }
19463
- if (outcome.status === "failed") {
19464
- fail(opts, ExitCode.ConfirmationRequired, {
19465
- error: `the Figma connection was not granted: ${outcome.reason}`,
19466
- code: "figma-connect-declined",
19467
- remediation: "Nothing was stored. If minds change, run the connect again \u2014 it makes a fresh request."
20149
+ if (decision === "denied") {
20150
+ spendComposeSlot();
20151
+ fail(flags, ExitCode.ConfirmationRequired, {
20152
+ error: "the human said NOT NOW in the browser \u2014 nothing was recorded, and the pairing is NOT declined forever",
20153
+ code: "compose-not-now",
20154
+ remediation: "If minds change, run the connect again \u2014 it makes a fresh request and a fresh card."
19468
20155
  });
19469
20156
  }
19470
- fail(opts, ExitCode.ConfirmationRequired, {
19471
- error: outcome.status === "expired" ? "the Figma connection lapsed before the Allow click" : "the Figma connection is gone \u2014 it lapsed, or was collected by another terminal",
19472
- code: "figma-connect-lapsed",
19473
- remediation: "Run the connect again and finish it within its ten-minute window."
19474
- });
20157
+ if (decision === "expired" || decision === "unknown") {
20158
+ spendComposeSlot();
20159
+ fail(flags, ExitCode.ConfirmationRequired, {
20160
+ error: decision === "expired" ? "the connect request lapsed before anyone decided it" : "the connect request is gone \u2014 it lapsed and was purged",
20161
+ code: "compose-approval-lapsed",
20162
+ remediation: "Run the connect again for a fresh request, and decide it within its 30-minute window. If the connect page showed nothing waiting, the browser is signed in to a DIFFERENT account than this CLI \u2014 the page lists only its own account's requests."
20163
+ });
20164
+ }
20165
+ if (approvedDigest !== void 0 && approvedDigest !== pending.subjectDigest) {
20166
+ spendComposeSlot();
20167
+ fail(flags, ExitCode.General, {
20168
+ error: "the approved row's subject is not the one this wait carries \u2014 refusing to act on someone else's decision",
20169
+ code: "compose-approval-subject-mismatch",
20170
+ remediation: "Start the connect again \u2014 a fresh request binds a fresh subject."
20171
+ });
20172
+ }
20173
+ const after = rederive();
20174
+ if ("changed" in after) {
20175
+ spendComposeSlot();
20176
+ fail(flags, ExitCode.General, {
20177
+ error: `this approval no longer matches the workspace: ${after.changed} \u2014 a stale approval must never write a decision the evidence does not support`,
20178
+ code: "compose-approval-stale",
20179
+ remediation: "Nothing was recorded, and the click was not wrong \u2014 the project moved under it. Run the connect again for a fresh card that matches what is on disk."
20180
+ });
20181
+ }
20182
+ const entries = buildCompositionEntries(pending.hostSet, [after.pair], /* @__PURE__ */ new Set());
20183
+ const written = writeCompositionDecisions(pending.hostSet, entries);
20184
+ spendComposeSlot();
20185
+ if (!written.ok) {
20186
+ fail(flags, ExitCode.General, { error: written.refusal, code: "compositions-write-raced", remediation: "Nothing was changed. Run the connect again \u2014 the fresh derivation re-reads the manifest as it is now." });
20187
+ }
20188
+ emitData(
20189
+ flags,
20190
+ {
20191
+ status: written.written.length > 0 ? "confirmed" : "already-decided",
20192
+ hostSet: pending.hostSet,
20193
+ pairKey: pending.pairKey,
20194
+ written: written.written,
20195
+ alreadyDecided: written.alreadyDecided,
20196
+ next: `regenerate the HOST bundle with --library so the pairing composes (an agent run: brief \u2192 generate \u2192 score \u2192 verify), then republish \u2014 announce the republish in one line first`
20197
+ },
20198
+ () => {
20199
+ process.stdout.write(
20200
+ written.written.length > 0 ? `CONFIRMED: ${pending.pairKey} \u2014 the decision is recorded in the host manifest. Regenerate the host with --library to compose it.
20201
+ ` : `already decided: ${pending.pairKey} \u2014 a standing entry was in place (asked once); nothing changed.
20202
+ `
20203
+ );
20204
+ }
20205
+ );
19475
20206
  }
19476
- var WAIT_CAP_SECONDS;
19477
- var init_figma_connect = __esm({
19478
- "packages/cli/src/commands/figma-connect.ts"() {
20207
+ async function runComposeApprove(flags) {
20208
+ const base = process.env["INIT_CWD"] ?? process.cwd();
20209
+ const roots = flags.library !== void 0 && flags.library.length > 0 ? flags.library.map((d) => path56.resolve(base, d)) : [base];
20210
+ if (flags.set === void 0) {
20211
+ fail(flags, ExitCode.InputValidation, {
20212
+ error: "a compose decision flag requires --set <host> \u2014 a decision needs the host set it decides for",
20213
+ code: "compose-decision-without-set",
20214
+ remediation: "Add --set <host recording-set dir>."
20215
+ });
20216
+ }
20217
+ if (flags.waitWindowSeconds !== void 0 && flags.approveWait !== true) {
20218
+ fail(flags, ExitCode.InputValidation, {
20219
+ error: "--wait-window only bounds an --approve-wait poll",
20220
+ code: "wait-window-without-approve-wait",
20221
+ remediation: "Pass --approve-wait with it, or drop --wait-window."
20222
+ });
20223
+ }
20224
+ if (flags.waitWindowSeconds !== void 0 && (!Number.isFinite(flags.waitWindowSeconds) || flags.waitWindowSeconds <= 0)) {
20225
+ fail(flags, ExitCode.InputValidation, {
20226
+ error: "--wait-window needs a positive number of seconds",
20227
+ code: "wait-window-invalid",
20228
+ remediation: "Pass e.g. --wait-window 55."
20229
+ });
20230
+ }
20231
+ const hostSet = path56.resolve(base, flags.set);
20232
+ if (flags.approveStart === true) {
20233
+ await runComposeApproveStart(flags, hostSet, roots);
20234
+ return;
20235
+ }
20236
+ await runComposeApproveWait(flags, hostSet);
20237
+ }
20238
+ var truncateWithMarker, spendComposeSlot;
20239
+ var init_compose_approve = __esm({
20240
+ "packages/cli/src/commands/compose-approve.ts"() {
19479
20241
  "use strict";
19480
20242
  init_src3();
19481
- init_figma_token();
20243
+ init_src();
19482
20244
  init_invocation();
19483
20245
  init_output();
19484
20246
  init_publish_client();
19485
- WAIT_CAP_SECONDS = 660;
20247
+ init_publish();
20248
+ init_compose2();
20249
+ truncateWithMarker = (text, max) => text.length <= max ? text : `${text.slice(0, max - 14)}\u2026 [truncated]`;
20250
+ spendComposeSlot = () => rmSync9(pendingComposePath(), { force: true });
19486
20251
  }
19487
20252
  });
19488
20253
 
19489
- // packages/cli/src/commands/share.ts
19490
- var share_exports = {};
19491
- __export(share_exports, {
19492
- runShare: () => runShare
20254
+ // packages/cli/src/commands/login.ts
20255
+ var login_exports = {};
20256
+ __export(login_exports, {
20257
+ DEFAULT_PORTAL_ORIGIN: () => DEFAULT_PORTAL_ORIGIN,
20258
+ runLogin: () => runLogin,
20259
+ runLogout: () => runLogout
19493
20260
  });
19494
- async function runShare(opts) {
19495
- const origin = (opts.to ?? process.env["TENDRIL_PORTAL_URL"] ?? "").replace(/\/+$/, "");
19496
- if (origin === "") {
20261
+ import { spawn } from "node:child_process";
20262
+ import { existsSync as existsSync47, mkdirSync as mkdirSync15, readFileSync as readFileSync43, rmSync as rmSync10, writeFileSync as writeFileSync25 } from "node:fs";
20263
+ import path57 from "node:path";
20264
+ import { isCancel as isCancel3, password as password2 } from "@clack/prompts";
20265
+ async function runLogin(opts, deps) {
20266
+ const origin = (opts.to ?? process.env["TENDRIL_PORTAL_URL"] ?? DEFAULT_PORTAL_ORIGIN).replace(/\/+$/, "");
20267
+ if (!isSecureOrigin(origin)) {
19497
20268
  fail(opts, ExitCode.InputValidation, {
19498
- error: "no portal to share from",
19499
- code: "no-portal-configured",
19500
- remediation: `Pass \`--to <url>\` or set TENDRIL_PORTAL_URL.`
20269
+ error: `${origin} is not https, so a session token sent there would travel in the clear`,
20270
+ code: "portal-not-https",
20271
+ remediation: "Use the https:// address of your portal. Only 127.0.0.1 and localhost are exempt, for local development."
19501
20272
  });
19502
20273
  }
19503
- if (!isSecureOrigin(origin)) {
20274
+ if (opts.deviceStart === true) {
20275
+ await deviceStartPhase(opts, origin, deps ?? realDeps());
20276
+ return;
20277
+ }
20278
+ if (opts.deviceWait === true) {
20279
+ await deviceWaitPhase(opts, deps ?? realDeps());
20280
+ return;
20281
+ }
20282
+ if (process.stdin.isTTY === true && opts.paste !== true) {
20283
+ await deviceLogin(opts, origin, deps ?? realDeps());
20284
+ return;
20285
+ }
20286
+ await pasteLogin(opts, origin);
20287
+ }
20288
+ async function deviceLogin(opts, origin, deps) {
20289
+ const started = await startHandshake(opts, origin, deps);
20290
+ process.stderr.write(`
20291
+ Open this link to approve the sign-in:
20292
+
20293
+ ${started.verificationUrl}
20294
+
20295
+ `);
20296
+ process.stderr.write(` The page must show this code: ${started.userCode}
20297
+ `);
20298
+ process.stderr.write(` Approve it only if this terminal is yours.
20299
+
20300
+ `);
20301
+ deps.openBrowser(started.verificationUrl);
20302
+ process.stderr.write(` Waiting for the browser (about ten minutes before this code lapses) `);
20303
+ const outcome = await waitForDecision(opts, origin, deps, started, () => process.stderr.write("."));
20304
+ process.stderr.write("\n");
20305
+ settleDecision(opts, origin, outcome);
20306
+ }
20307
+ async function waitForDecision(opts, origin, deps, started, onTick) {
20308
+ const interval = Math.max(1, started.intervalSeconds);
20309
+ const attempts = Math.ceil(660 / interval);
20310
+ for (let n = 0; n < attempts; n += 1) {
20311
+ await deps.sleep(interval);
20312
+ const state = await pollHandshake(opts, origin, deps, started.deviceCode);
20313
+ if (state.status === "pending") {
20314
+ onTick();
20315
+ continue;
20316
+ }
20317
+ if (state.status === "approved") return state;
20318
+ return { status: state.status };
20319
+ }
20320
+ return { status: "gave-up" };
20321
+ }
20322
+ function settleDecision(opts, origin, outcome) {
20323
+ switch (outcome.status) {
20324
+ case "approved":
20325
+ writeStoredSession({ origin, token: outcome.token });
20326
+ emitData(opts, { origin, storedAt: sessionPath(), via: "device" }, () => {
20327
+ process.stdout.write(`signed in to ${origin}
20328
+ `);
20329
+ process.stdout.write(` token stored at ${sessionPath()} (readable only by you)
20330
+ `);
20331
+ });
20332
+ return;
20333
+ case "denied":
20334
+ fail(opts, ExitCode.General, {
20335
+ error: "the sign-in was denied in the browser",
20336
+ code: "login-denied",
20337
+ remediation: `If that was not you saying no, run ${tendrilCommand("login")} again and approve the fresh code.`
20338
+ });
20339
+ break;
20340
+ case "expired":
20341
+ case "gave-up":
20342
+ fail(opts, ExitCode.General, {
20343
+ error: outcome.status === "expired" ? "the sign-in code lapsed before anyone approved it \u2014 they live ten minutes" : "gave up waiting for the browser approval",
20344
+ code: "login-expired",
20345
+ remediation: `Run ${tendrilCommand("login")} again for a fresh code.`
20346
+ });
20347
+ break;
20348
+ default:
20349
+ fail(opts, ExitCode.General, {
20350
+ error: "the portal no longer recognises this sign-in attempt",
20351
+ code: "login-unknown",
20352
+ remediation: `Run ${tendrilCommand("login")} again. If this repeats, the approval may be racing another terminal \u2014 approve only one at a time.`
20353
+ });
20354
+ }
20355
+ }
20356
+ function pendingLoginPath() {
20357
+ return path57.join(path57.dirname(sessionPath()), "pending-login.json");
20358
+ }
20359
+ async function deviceStartPhase(opts, origin, deps) {
20360
+ const started = await startHandshake(opts, origin, deps);
20361
+ const file = pendingLoginPath();
20362
+ mkdirSync15(path57.dirname(file), { recursive: true });
20363
+ writeFileSync25(file, `${JSON.stringify({ origin, ...started }, null, 2)}
20364
+ `, { mode: 384 });
20365
+ deps.openBrowser(started.verificationUrl);
20366
+ emitData(
20367
+ opts,
20368
+ {
20369
+ verificationUrl: started.verificationUrl,
20370
+ userCode: started.userCode,
20371
+ expiresAt: started.expiresAt,
20372
+ origin,
20373
+ next: "Show the user the link and the code \u2014 they approve in the browser, and must check the page shows this exact code. Then run login --device-wait (the tendril_login_wait tool) to finish."
20374
+ },
20375
+ () => {
20376
+ process.stdout.write(`open ${started.verificationUrl}
20377
+ `);
20378
+ process.stdout.write(` the page must show: ${started.userCode}
20379
+ `);
20380
+ process.stdout.write(` then: ${tendrilCommand("login --device-wait")}
20381
+ `);
20382
+ }
20383
+ );
20384
+ }
20385
+ async function deviceWaitPhase(opts, deps) {
20386
+ const file = pendingLoginPath();
20387
+ let pending;
20388
+ if (existsSync47(file)) {
20389
+ try {
20390
+ const parsed = JSON.parse(readFileSync43(file, "utf8"));
20391
+ if (typeof parsed.origin === "string" && typeof parsed.deviceCode === "string" && typeof parsed.intervalSeconds === "number") {
20392
+ pending = parsed;
20393
+ }
20394
+ } catch {
20395
+ }
20396
+ }
20397
+ if (pending === void 0) {
19504
20398
  fail(opts, ExitCode.InputValidation, {
19505
- error: `${origin} is not https, so a session token sent there would travel in the clear`,
19506
- code: "portal-not-https",
19507
- remediation: "Use the https:// address of your portal. Only 127.0.0.1 and localhost are exempt, for local development."
20399
+ error: "there is no sign-in waiting to finish",
20400
+ code: "no-pending-login",
20401
+ remediation: `Start one first: ${tendrilCommand("login --device-start")} (the tendril_login tool).`
19508
20402
  });
19509
20403
  }
19510
- const found = tokenFor(origin);
19511
- if (!found.ok) {
19512
- fail(opts, ExitCode.Auth, {
19513
- error: found.reason === "origin-mismatch" ? `the session available here belongs to ${found.boundTo}, not ${origin}` : `no session for ${origin}`,
19514
- code: found.reason === "origin-mismatch" ? "session-belongs-to-another-portal" : "not-signed-in",
19515
- remediation: `Run \`${tendrilCommand(`login --to ${origin}`)}\`.`
20404
+ const done = () => rmSync10(file, { force: true });
20405
+ const total = Math.ceil(660 / Math.max(1, pending.intervalSeconds));
20406
+ let ticks = 0;
20407
+ const outcome = await waitForDecision(opts, pending.origin, deps, pending, () => {
20408
+ ticks += 1;
20409
+ emitProgress(ticks, total, "waiting for the browser approval");
20410
+ });
20411
+ done();
20412
+ settleDecision(opts, pending.origin, outcome);
20413
+ }
20414
+ async function startHandshake(opts, origin, deps) {
20415
+ let response;
20416
+ try {
20417
+ response = await deps.fetch(`${origin}/api/device`, { method: "POST", headers: { "content-type": "application/json" }, body: "{}" });
20418
+ } catch (error) {
20419
+ return unreachable(opts, origin, error.message);
20420
+ }
20421
+ if (!response.ok) return unreachable(opts, origin, `the portal answered ${String(response.status)}`);
20422
+ const body = await response.json();
20423
+ if (typeof body.deviceCode !== "string" || typeof body.userCode !== "string" || typeof body.verificationUrl !== "string" || typeof body.intervalSeconds !== "number" || typeof body.expiresAt !== "string") {
20424
+ return unreachable(opts, origin, "the portal's answer was not a device handshake");
20425
+ }
20426
+ return body;
20427
+ }
20428
+ async function pollHandshake(opts, origin, deps, deviceCode) {
20429
+ let response;
20430
+ try {
20431
+ response = await deps.fetch(`${origin}/api/device/token`, {
20432
+ method: "POST",
20433
+ headers: { "content-type": "application/json" },
20434
+ body: JSON.stringify({ deviceCode })
19516
20435
  });
20436
+ } catch {
20437
+ return { status: "pending" };
19517
20438
  }
19518
- const client = new HttpPublishClient({ origin, token: found.token });
19519
- if (opts.list === true) {
19520
- const listed = await client.listShareLinks({ publicationId: opts.publicationId });
19521
- if (!listed.ok) refuse(opts, listed);
19522
- emitData(opts, listed.value, () => {
19523
- if (listed.value.links.length === 0) {
19524
- process.stdout.write("no links have been issued for this publication\n");
19525
- return;
19526
- }
19527
- process.stdout.write(`${String(listed.value.links.length)} link(s) for this publication:
19528
- `);
19529
- for (const link of listed.value.links) {
19530
- const ended = link.revoked_at !== null ? ` REVOKED ${link.revoked_at}` : link.expires_at !== null ? ` expires ${link.expires_at}` : " open-ended";
19531
- process.stdout.write(` ${link.id}${ended}${link.recipient_email === null ? "" : ` \u2014 for ${link.recipient_email}`}
19532
- `);
20439
+ if (!response.ok) return { status: "pending" };
20440
+ const body = await response.json();
20441
+ if (body.status === "approved" && typeof body.token === "string") return { status: "approved", token: body.token };
20442
+ if (body.status === "denied" || body.status === "expired" || body.status === "unknown") return { status: body.status };
20443
+ return { status: "pending" };
20444
+ }
20445
+ function unreachable(opts, origin, detail) {
20446
+ fail(opts, ExitCode.General, {
20447
+ error: `could not start a sign-in with ${origin}: ${detail}`,
20448
+ code: "portal-unreachable",
20449
+ remediation: "Check your connection and try again. If your portal is self-hosted, confirm the --to URL."
20450
+ });
20451
+ }
20452
+ function realDeps() {
20453
+ return {
20454
+ fetch: globalThis.fetch.bind(globalThis),
20455
+ sleep: (seconds) => new Promise((resolve) => setTimeout(resolve, seconds * 1e3)),
20456
+ openBrowser: (url) => {
20457
+ const [cmd, args] = process.platform === "darwin" ? ["open", [url]] : process.platform === "win32" ? ["cmd", ["/c", "start", "", url]] : ["xdg-open", [url]];
20458
+ try {
20459
+ spawn(cmd, args, { stdio: "ignore", detached: true }).on("error", () => void 0).unref();
20460
+ } catch {
19533
20461
  }
19534
- process.stdout.write(" (the links themselves are not stored \u2014 only their digests, so a lost link is a new link)\n");
20462
+ }
20463
+ };
20464
+ }
20465
+ async function pasteLogin(opts, origin) {
20466
+ const token = await readToken(opts);
20467
+ if (token === void 0 || token.trim() === "") {
20468
+ fail(opts, ExitCode.InputValidation, {
20469
+ error: "no token was given",
20470
+ code: "no-token",
20471
+ remediation: `Paste the token when prompted, or pipe it: \`echo "<token>" | ${tendrilCommand(`login --to ${origin}`)}\`.`
19535
20472
  });
19536
- return;
19537
20473
  }
19538
- if (opts.revoke !== void 0) {
19539
- const revoked = await client.revokeShareLink({ publicationId: opts.publicationId, shareLinkId: opts.revoke });
19540
- if (!revoked.ok) refuse(opts, revoked);
19541
- emitData(opts, revoked.value, () => {
19542
- process.stdout.write(`revoked ${opts.revoke ?? ""}
19543
- `);
19544
- process.stdout.write(" anyone holding that link now gets told it was revoked, not that it never existed\n");
20474
+ const trimmed = token.trim();
20475
+ if (!/^[A-Za-z0-9_-]{16,512}$/.test(trimmed)) {
20476
+ fail(opts, ExitCode.InputValidation, {
20477
+ error: "that does not look like a Tendril session token",
20478
+ code: "malformed-token",
20479
+ remediation: "Tokens are a single line of letters, digits, hyphens and underscores. Check for a stray space or a truncated paste."
19545
20480
  });
19546
- return;
19547
20481
  }
19548
- const expiresAt = resolveExpiry(opts);
19549
- const issued = await client.issueShareLink({
19550
- publicationId: opts.publicationId,
19551
- expiresAt,
19552
- recipientEmail: opts.recipient ?? null
19553
- });
19554
- if (!issued.ok) refuse(opts, issued);
19555
- emitData(opts, issued.value, () => {
19556
- process.stdout.write(`${issued.value.url}
19557
-
20482
+ writeStoredSession({ origin, token: trimmed });
20483
+ emitData(opts, { origin, storedAt: sessionPath(), via: "paste" }, () => {
20484
+ process.stdout.write(`signed in to ${origin}
20485
+ `);
20486
+ process.stdout.write(` token stored at ${sessionPath()} (readable only by you)
19558
20487
  `);
19559
- process.stdout.write(" Anyone with this link can read this one publication and its evidence.\n");
19560
- process.stdout.write(" It is not tied to a person and needs no account.\n");
19561
- process.stdout.write(
19562
- issued.value.expiresAt === null ? " It does not expire. Revoke it with --revoke when you are done.\n" : ` It stops working at ${issued.value.expiresAt}.
19563
- `
19564
- );
19565
- process.stdout.write(" This is the only time the link is shown \u2014 only its digest is stored.\n");
19566
20488
  });
19567
20489
  }
19568
- function resolveExpiry(opts) {
19569
- if (opts.expires === void 0) return null;
19570
- const days = Number(opts.expires);
19571
- if (Number.isInteger(days) && days > 0) {
19572
- return new Date(Date.now() + days * 24 * 60 * 60 * 1e3).toISOString().replace(/\.\d{3}Z$/, (m) => m);
20490
+ async function readToken(opts) {
20491
+ if (process.stdin.isTTY === true) {
20492
+ const entered = await password2({ message: "Paste your Tendril portal token (it will not be shown)" });
20493
+ if (isCancel3(entered)) {
20494
+ fail(opts, ExitCode.General, { error: "cancelled", code: "cancelled", remediation: "Run it again when you have the token." });
20495
+ }
20496
+ return entered;
19573
20497
  }
19574
- if (/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/.test(opts.expires)) return opts.expires;
19575
- fail(opts, ExitCode.InputValidation, {
19576
- error: `--expires takes a number of days (e.g. 7) or an ISO-8601 UTC timestamp with milliseconds \u2014 got ${JSON.stringify(opts.expires)}`,
19577
- code: "bad-expiry",
19578
- remediation: "Try `--expires 7` for a week, or omit it for a link that does not expire."
19579
- });
20498
+ const chunks = [];
20499
+ for await (const chunk of process.stdin) chunks.push(chunk);
20500
+ return Buffer.concat(chunks).toString("utf8").split("\n")[0];
19580
20501
  }
19581
- function refuse(opts, sent) {
19582
- fail(opts, sent.status === 401 ? ExitCode.Auth : ExitCode.General, {
19583
- error: sent.refusal,
19584
- code: "share-refused",
19585
- remediation: sent.status === 404 ? "Check the publication id \u2014 only its owner can share it." : `Fix what is named above and run \`${tendrilCommand(`share ${opts.publicationId}`)}\` again.`
20502
+ function runLogout(opts) {
20503
+ const stored = readStoredSession();
20504
+ if (stored === void 0) {
20505
+ emitData(opts, { signedOut: true, wasSignedIn: false }, () => process.stdout.write("you were not signed in\n"));
20506
+ return;
20507
+ }
20508
+ clearStoredSession();
20509
+ emitData(opts, { signedOut: true, wasSignedIn: true, origin: stored.origin }, () => {
20510
+ process.stdout.write(`this machine has forgotten its session for ${stored.origin}
20511
+ `);
20512
+ process.stdout.write(" the session itself is still valid \u2014 sign out everywhere from the portal to end it\n");
19586
20513
  });
19587
20514
  }
19588
- var init_share = __esm({
19589
- "packages/cli/src/commands/share.ts"() {
20515
+ var DEFAULT_PORTAL_ORIGIN;
20516
+ var init_login = __esm({
20517
+ "packages/cli/src/commands/login.ts"() {
19590
20518
  "use strict";
19591
20519
  init_src3();
19592
20520
  init_invocation();
19593
20521
  init_output();
19594
20522
  init_publish_client();
20523
+ DEFAULT_PORTAL_ORIGIN = "https://app.trytendril.com";
19595
20524
  }
19596
20525
  });
19597
20526
 
19598
- // packages/cli/src/commands/publish.ts
19599
- var publish_exports = {};
19600
- __export(publish_exports, {
19601
- approveWaitPhase: () => approveWaitPhase,
19602
- resolveOrigin: () => resolveOrigin2,
19603
- runPublish: () => runPublish,
19604
- spendPendingApproval: () => spendPendingApproval
20527
+ // packages/cli/src/commands/figma-connect.ts
20528
+ var figma_connect_exports = {};
20529
+ __export(figma_connect_exports, {
20530
+ runFigmaConnect: () => runFigmaConnect
19605
20531
  });
19606
- import { existsSync as existsSync47, readFileSync as readFileSync43, rmSync as rmSync10, writeFileSync as writeFileSync25 } from "node:fs";
19607
- import path57 from "node:path";
19608
- async function runPublish(opts) {
19609
- if (opts.waitWindowSeconds !== void 0 && opts.approveWait !== true) {
19610
- fail(opts, ExitCode.InputValidation, {
19611
- error: "--wait-window only bounds an --approve-wait poll",
19612
- code: "wait-window-without-approve-wait",
19613
- remediation: "Pass --approve-wait with it, or drop --wait-window."
19614
- });
19615
- }
19616
- if (opts.waitWindowSeconds !== void 0 && (!Number.isFinite(opts.waitWindowSeconds) || opts.waitWindowSeconds <= 0)) {
19617
- fail(opts, ExitCode.InputValidation, {
19618
- error: "--wait-window needs a positive number of seconds",
19619
- code: "wait-window-invalid",
19620
- remediation: "Pass e.g. --wait-window 55."
19621
- });
19622
- }
19623
- const bundleDir = path57.resolve(opts.bundleDir);
19624
- const bundle = readBundle(opts, bundleDir);
19625
- const report = bundle.report;
19626
- if (typeof report["rulerExit"] !== "number" || !Number.isInteger(report["rulerExit"])) {
19627
- fail(opts, ExitCode.InputValidation, {
19628
- error: `${VERIFY_REPORT_FILENAME} does not state the ruler's exit code, and an absent exit code is not a passing one`,
19629
- code: "report-has-no-exit-code",
19630
- remediation: `Re-run \`${tendrilCommand(`verify ${opts.bundleDir}`)}\` with a current CLI \u2014 this report predates the field.`
19631
- });
19632
- }
19633
- if (report["rulerExit"] !== 0) {
19634
- const why = typeof report["rulerRefusal"] === "string" ? ` \u2014 ${report["rulerRefusal"]}` : "";
19635
- fail(opts, ExitCode.VerificationFailed, {
19636
- error: `the ruler refused this run (exit ${String(report["rulerExit"])}), so it has nothing to publish${why}`,
19637
- code: "run-refused-by-ruler",
19638
- remediation: `Fix what the report names, re-run \`${tendrilCommand(`verify ${opts.bundleDir}`)}\` until it passes, then publish. A declined verdict never becomes a page.`
19639
- });
19640
- }
19641
- const undisclosed = undisclosedTrustFacts(report);
19642
- if (undisclosed.length > 0) {
19643
- fail(opts, ExitCode.InputValidation, {
19644
- error: `this report carries a disclosure a published page cannot yet show, and a verdict shown without it is worse than no page: ${undisclosed.map((f) => `${f.pointer} \u2014 ${f.consequence}`).join("; ")}`,
19645
- code: "report-carries-an-unrenderable-disclosure",
19646
- remediation: "Resolve what the disclosure names \u2014 re-record so the set matches the bundle's stamp, or resolve the substituted font families \u2014 then re-verify and publish the clean run."
19647
- });
19648
- }
19649
- if (scoredRecordingSetHash(report) === void 0) {
19650
- fail(opts, ExitCode.InputValidation, {
19651
- error: "this report does not name the recording set these scores were measured against",
19652
- code: "report-names-no-recording-set",
19653
- remediation: `Re-run \`${tendrilCommand(`verify ${opts.bundleDir}`)}\` with a current CLI \u2014 the set identity rides the report.`
19654
- });
19655
- }
19656
- const componentName = (opts.name ?? bundle.manifest.name).trim();
19657
- const surface = classifyBundleSurface(bundle.files, { entry: bundle.manifest.entry });
19658
- if (surface.unknown.length > 0) {
19659
- fail(opts, ExitCode.InputValidation, {
19660
- error: `this bundle carries files a portal does not recognise, and publishing guesses at nothing: ${surface.unknown.join(", ")}`,
19661
- code: "bundle-carries-unrecognised-files",
19662
- remediation: `Remove them from ${opts.bundleDir}, or re-emit the bundle. Publishing them would ship something nobody reviewed; dropping them silently would ship evidence with a hole in it.`
19663
- });
19664
- }
19665
- const sheetEntry = surface.published.find((p) => p.role === "inspect-sheet");
19666
- if (sheetEntry !== void 0) {
19667
- const missingCrops = missingInspectCrops(
19668
- readFileSync43(path57.join(bundleDir, sheetEntry.path), "utf8"),
19669
- surface.published.map((p) => p.path)
19670
- );
19671
- if (missingCrops.length > 0) {
19672
- fail(opts, ExitCode.InputValidation, {
19673
- error: `this bundle's inspect sheet references images it does not carry, and half a sheet reads as a broken component: ${missingCrops.join(", ")}`,
19674
- code: "inspect-sheet-without-its-crops",
19675
- remediation: `Re-run \`${tendrilCommand(`inspect ${opts.bundleDir}`)}\` to rebuild the sheet and its crops together, or delete the sheet and publish without it.`
19676
- });
19677
- }
19678
- }
19679
- if (surface.missingRequired.length > 0) {
20532
+ import { existsSync as existsSync48, mkdirSync as mkdirSync16, readFileSync as readFileSync44, rmSync as rmSync11, writeFileSync as writeFileSync26 } from "node:fs";
20533
+ import path58 from "node:path";
20534
+ function pendingConnectPath() {
20535
+ return path58.join(path58.dirname(sessionPath()), "pending-figma-connect.json");
20536
+ }
20537
+ function resolveOrigin2(opts) {
20538
+ const named = (opts.to ?? process.env["TENDRIL_PORTAL_URL"] ?? "").replace(/\/+$/, "");
20539
+ if (named !== "") return named;
20540
+ if ((process.env["TENDRIL_TOKEN"] ?? "") !== "") return "";
20541
+ return (readStoredSession()?.origin ?? "").replace(/\/+$/, "");
20542
+ }
20543
+ function bearerFor(opts, origin) {
20544
+ if (origin === "") {
19680
20545
  fail(opts, ExitCode.InputValidation, {
19681
- error: `this bundle is missing what a publishable bundle cannot be without: ${surface.missingRequired.join(", ")}`,
19682
- code: "bundle-missing-required-role",
19683
- remediation: `Run \`${tendrilCommand(`verify ${opts.bundleDir}`)}\` \u2014 the evidence a page shows is written by that run. Required: ${REQUIRED_ROLES.join(", ")}.`
20546
+ error: "no portal to connect through",
20547
+ code: "no-portal-configured",
20548
+ remediation: `Sign in first (${tendrilCommand("login")}) \u2014 the connect uses your portal account.`
19684
20549
  });
19685
20550
  }
19686
- const recorded = readScoredFiles(report);
19687
- if (recorded === void 0) {
19688
- fail(opts, ExitCode.InputValidation, {
19689
- error: `${VERIFY_REPORT_FILENAME} does not record the files this run was scored beside, so nothing ties this bundle's bytes to its verdict`,
19690
- code: "report-records-no-file-digests",
19691
- remediation: `Re-run \`${tendrilCommand(`verify ${opts.bundleDir}`)}\` with a current CLI \u2014 this report predates the field.`
20551
+ const found = tokenFor(origin);
20552
+ if (!found.ok) {
20553
+ fail(opts, ExitCode.Auth, {
20554
+ error: `no session for ${origin}`,
20555
+ code: "not-signed-in",
20556
+ remediation: `Run ${tendrilCommand("login")} first \u2014 connecting Figma needs your signed-in portal account.`
19692
20557
  });
19693
- } else {
19694
- const delta = compareScoredFiles(recorded, digestScoredFiles(bundleDir, bundle.manifest.entry));
19695
- const disagreements = [
19696
- ...delta.changed.map((p) => `${p} (bytes differ from the scored run)`),
19697
- ...delta.missing.map((p) => `${p} (scored, no longer in the bundle)`),
19698
- ...delta.unscored.map((p) => `${p} (in the bundle, never scored)`)
19699
- ];
19700
- if (disagreements.length > 0) {
19701
- fail(opts, ExitCode.InputValidation, {
19702
- error: `this bundle is not the one the ruler scored: ${disagreements.join("; ")}`,
19703
- code: "bundle-disagrees-with-scored-files",
19704
- remediation: `Re-run \`${tendrilCommand(`verify ${opts.bundleDir}`)}\` so the verdict describes these bytes. A page must never show code the ruler never saw.`
19705
- });
19706
- }
19707
20558
  }
19708
- const figmaFile = bundle.manifest.provenance.recordingSet.figmaFile ?? null;
19709
- if (opts.dryRun) {
20559
+ return found.token;
20560
+ }
20561
+ async function runFigmaConnect(opts) {
20562
+ const send = opts.fetchImpl ?? globalThis.fetch;
20563
+ const origin = resolveOrigin2(opts);
20564
+ if (opts.wait === true) {
20565
+ await waitPhase(opts, send);
20566
+ return;
20567
+ }
20568
+ const token = bearerFor(opts, origin);
20569
+ const started = await startConnect(opts, send, origin, token);
20570
+ if (opts.start === true) {
20571
+ const pending = { origin, ...started };
20572
+ mkdirSync16(path58.dirname(pendingConnectPath()), { recursive: true });
20573
+ writeFileSync26(pendingConnectPath(), `${JSON.stringify(pending, null, 2)}
20574
+ `, { mode: 384 });
20575
+ (opts.openBrowser ?? (() => {
20576
+ }))(started.connectUrl);
19710
20577
  emitData(
19711
20578
  opts,
19712
20579
  {
19713
- bundleDir,
19714
- componentName,
19715
- figmaFile,
19716
- entry: bundle.manifest.entry,
19717
- publishes: surface.published,
19718
- excluded: surface.excluded,
19719
- rulerVersion: report["environment"]?.["ruler"] ?? null,
19720
- wouldPublish: true
20580
+ connectUrl: started.connectUrl,
20581
+ expiresAt: started.expiresAt,
20582
+ origin,
20583
+ next: "Show the user the link \u2014 they sign in (same account) and click Allow on Figma's consent screen. Then run figma-connect --wait (the tendril_figma_connect_wait tool) to finish."
19721
20584
  },
19722
20585
  () => {
19723
- process.stdout.write(`${componentName} would publish ${String(surface.published.length)} files:
19724
- `);
19725
- for (const entry of surface.published) process.stdout.write(` ${entry.path} (${entry.role})
20586
+ process.stdout.write(`open ${started.connectUrl}
19726
20587
  `);
19727
- for (const left of surface.excluded) process.stdout.write(` \u2014 leaving ${left.path} behind (${left.reason})
20588
+ process.stdout.write(` then: ${tendrilCommand("figma-connect --wait")}
19728
20589
  `);
19729
20590
  }
19730
20591
  );
19731
20592
  return;
19732
20593
  }
19733
- const origin = resolveOrigin2(opts);
19734
- const client = opts.client ?? httpClient(opts, origin);
19735
- if (opts.approveWait === true) {
19736
- const phase = await approveWaitPhase(opts, client, bundleDir, { componentName, figmaFile });
19737
- if (phase === "yielded") return;
20594
+ process.stderr.write(`Connect Figma in your browser:
20595
+ ${started.connectUrl}
20596
+ `);
20597
+ process.stderr.write(`Waiting for the Allow click (lapses at ${started.expiresAt.slice(11, 16)} UTC)\u2026
20598
+ `);
20599
+ const outcome = await pollToDecision(opts, send, origin, token, started);
20600
+ settle2(opts, origin, outcome);
20601
+ }
20602
+ async function startConnect(opts, send, origin, token) {
20603
+ let response;
20604
+ try {
20605
+ response = await send(`${origin}/api/figma-connect`, { method: "POST", headers: { authorization: `Bearer ${token}`, "content-type": "application/json" }, body: "{}" });
20606
+ } catch (error) {
20607
+ fail(opts, ExitCode.General, {
20608
+ error: `could not reach ${origin}: ${error.message}`,
20609
+ code: "portal-unreachable",
20610
+ remediation: "Check the address and your connection, then try again."
20611
+ });
19738
20612
  }
19739
- void reportRunPresence(componentName, "publishing");
19740
- emitProgress(0, 1, "requesting the upload plan from the portal");
19741
- let opened = await client.begin({
19742
- componentName,
19743
- figmaFile,
19744
- entry: bundle.manifest.entry,
19745
- files: bundle.files,
19746
- report: bundle.reportText
19747
- });
19748
- if (!opened.ok && opened.needsConfirmation !== void 0 && opts.approveWait !== true) {
19749
- const flow = await runApprovalFlow(opts, client, bundleDir, {
19750
- componentName,
19751
- figmaFile,
19752
- begin: () => client.begin({ componentName, figmaFile, entry: bundle.manifest.entry, files: bundle.files, report: bundle.reportText })
20613
+ const body = await response.json().catch(() => ({}));
20614
+ if (!response.ok || typeof body.connectId !== "string" || typeof body.connectUrl !== "string" || typeof body.expiresAt !== "string") {
20615
+ fail(opts, ExitCode.General, {
20616
+ error: body.refusal ?? `the portal answered ${String(response.status)} to the connect start`,
20617
+ code: "figma-connect-refused",
20618
+ remediation: body.remediation ?? "Fix what is named above and run the connect again."
19753
20619
  });
19754
- if (flow === void 0) return;
19755
- opened = flow;
19756
20620
  }
19757
- if (!opened.ok) {
19758
- if (opened.needsConfirmation !== void 0) {
19759
- fail(opts, ExitCode.ConfirmationRequired, {
19760
- error: opened.refusal,
19761
- code: "first-publish-unconfirmed",
19762
- remediation: "Approve it in your browser \u2014 run the publish again and open the link it prints."
19763
- });
20621
+ return { connectId: body.connectId, connectUrl: body.connectUrl, expiresAt: body.expiresAt, pollSeconds: typeof body.pollSeconds === "number" ? body.pollSeconds : 3 };
20622
+ }
20623
+ async function waitPhase(opts, send) {
20624
+ const file = pendingConnectPath();
20625
+ let pending;
20626
+ if (existsSync48(file)) {
20627
+ try {
20628
+ const parsed = JSON.parse(readFileSync44(file, "utf8"));
20629
+ if (typeof parsed.origin === "string" && typeof parsed.connectId === "string") pending = parsed;
20630
+ } catch {
19764
20631
  }
19765
- refuse2(opts, opened, "publish-refused");
19766
20632
  }
19767
- if (opts.approveWait === true) spendPendingApproval();
19768
- const uploaded = [];
19769
- for (const object of opened.value.plan.objects) {
19770
- const file = path57.join(bundleDir, object.relPath);
19771
- if (!existsSync47(file)) {
19772
- fail(opts, ExitCode.InputValidation, {
19773
- error: `the portal expects ${object.relPath}, and it is not in ${opts.bundleDir}`,
19774
- code: "planned-file-missing",
19775
- remediation: `Re-run \`${tendrilCommand(`verify ${opts.bundleDir}`)}\` and publish again \u2014 a publication is refused rather than going live with a hole in its evidence.`
19776
- });
19777
- }
19778
- const sent = await client.upload({
19779
- publicationId: opened.value.publicationId,
19780
- relPath: object.relPath,
19781
- bytes: new Uint8Array(readFileSync43(file))
20633
+ if (pending === void 0) {
20634
+ fail(opts, ExitCode.InputValidation, {
20635
+ error: "there is no Figma connection waiting to finish",
20636
+ code: "no-pending-figma-connect",
20637
+ remediation: `Start one first: ${tendrilCommand("figma-connect --start")} (the tendril_figma_connect tool).`
19782
20638
  });
19783
- if (!sent.ok) refuse2(opts, sent, "upload-refused", true);
19784
- uploaded.push({ relPath: object.relPath, deduplicated: sent.value.deduplicated });
19785
- emitProgress(uploaded.length, opened.value.plan.objects.length, `uploading ${object.relPath}`);
19786
- }
19787
- emitProgress(uploaded.length, uploaded.length, "upload complete \u2014 committing the publication");
19788
- const committed = await client.commit({ publicationId: opened.value.publicationId });
19789
- if (committed.ok) {
19790
- await endRunPresence(componentName);
19791
20639
  }
19792
- if (!committed.ok) {
19793
- if (committed.missing !== void 0 && committed.missing.length > 0) {
19794
- fail(opts, ExitCode.General, {
19795
- error: `this publication is missing objects the bundle declared, and a verdict beside missing evidence is worse than no page: ${committed.missing.join(", ")}`,
19796
- code: "publication-incomplete",
19797
- remediation: `Run \`${tendrilCommand(`publish ${opts.bundleDir}`)}\` again \u2014 it rejoins this same unfinished publication and re-sends only what is missing.`
20640
+ const token = bearerFor(opts, pending.origin);
20641
+ const outcome = await pollToDecision(opts, send, pending.origin, token, pending);
20642
+ rmSync11(file, { force: true });
20643
+ settle2(opts, pending.origin, outcome);
20644
+ }
20645
+ async function pollToDecision(opts, send, origin, token, started) {
20646
+ const interval = Math.max(1, started.pollSeconds) * 1e3;
20647
+ const total = Math.ceil(WAIT_CAP_SECONDS * 1e3 / interval);
20648
+ for (let tick = 1; tick <= total; tick += 1) {
20649
+ let body = {};
20650
+ try {
20651
+ const response = await send(`${origin}/api/figma-connect/${encodeURIComponent(started.connectId)}`, {
20652
+ headers: { authorization: `Bearer ${token}` }
19798
20653
  });
20654
+ if (response.ok) body = await response.json();
20655
+ } catch {
19799
20656
  }
19800
- refuse2(opts, committed, "commit-refused", true);
19801
- }
19802
- emitData(
19803
- opts,
19804
- {
19805
- publicationId: committed.value.publicationId,
19806
- url: committed.value.url,
19807
- componentName,
19808
- figmaFile: opened.value.figmaFile,
19809
- rulerVersion: opened.value.rulerVersion,
19810
- resumed: opened.value.resumed === true,
19811
- files: uploaded
19812
- },
19813
- () => {
19814
- const reused = uploaded.filter((u) => u.deduplicated).length;
19815
- if (opened.value.resumed === true) process.stdout.write(`resumed the unfinished publish of ${componentName}
19816
- `);
19817
- process.stdout.write(`published ${componentName} \u2014 ${String(uploaded.length)} files`);
19818
- process.stdout.write(reused > 0 ? ` (${String(reused)} you already had)
19819
- ` : "\n");
19820
- process.stdout.write(` ${committed.value.url}
19821
- `);
19822
- process.stdout.write(` verdict as scored by ruler ${opened.value.rulerVersion}
19823
- `);
20657
+ if (body.status === "connected" && typeof body.accessToken === "string" && typeof body.refreshToken === "string") {
20658
+ return {
20659
+ status: "connected",
20660
+ tokens: {
20661
+ origin,
20662
+ accessToken: body.accessToken,
20663
+ refreshToken: body.refreshToken,
20664
+ tokenExpiresAt: typeof body.tokenExpiresAt === "string" ? body.tokenExpiresAt : new Date(Date.now() + 80 * 24 * 60 * 6e4).toISOString()
20665
+ }
20666
+ };
19824
20667
  }
19825
- );
19826
- }
19827
- function readBundle(opts, bundleDir) {
19828
- const manifestPath2 = path57.join(bundleDir, "component.json");
19829
- const reportPath = path57.join(bundleDir, EVIDENCE_DIR, VERIFY_REPORT_FILENAME);
19830
- if (!existsSync47(manifestPath2)) {
19831
- fail(opts, ExitCode.InputValidation, {
19832
- error: `there is no component.json in ${opts.bundleDir}, so this is not a bundle`,
19833
- code: "not-a-bundle",
19834
- remediation: `Point publish at a directory a Tendril run produced. \`${tendrilCommand("generate --help")}\` shows how one is made.`
19835
- });
19836
- }
19837
- if (!existsSync47(reportPath)) {
19838
- fail(opts, ExitCode.InputValidation, {
19839
- error: `this bundle has never been verified \u2014 there is no ${EVIDENCE_DIR}/${VERIFY_REPORT_FILENAME}`,
19840
- code: "bundle-not-verified",
19841
- remediation: `Run \`${tendrilCommand(`verify ${opts.bundleDir}`)}\` first. Verification is free and needs no account; publishing without it would put a page up with no verdict on it.`
19842
- });
19843
- }
19844
- const { manifest } = readBundleManifest(readFileSync43(manifestPath2, "utf8"));
19845
- if (manifest === void 0) {
19846
- fail(opts, ExitCode.InputValidation, {
19847
- error: "component.json did not parse as a bundle manifest",
19848
- code: "no-mount-contract",
19849
- remediation: `Re-emit the bundle \u2014 \`${tendrilCommand(`verify ${opts.bundleDir}`)}\` rewrites component.json \u2014 then publish.`
19850
- });
20668
+ if (body.status === "failed") return { status: "failed", reason: body.reason ?? "declined" };
20669
+ if (body.status === "expired") return { status: "expired" };
20670
+ if (body.status === "unknown") return { status: "unknown" };
20671
+ emitProgress(tick, total, "waiting for the Allow click in the browser");
20672
+ await new Promise((resolve) => setTimeout(resolve, interval));
19851
20673
  }
19852
- const reportText = readFileSync43(reportPath, "utf8");
19853
- let report;
19854
- try {
19855
- report = JSON.parse(reportText);
19856
- } catch (error) {
19857
- fail(opts, ExitCode.InputValidation, {
19858
- error: `${EVIDENCE_DIR}/${VERIFY_REPORT_FILENAME} is not parseable JSON: ${error.message}`,
19859
- code: "report-unparseable",
19860
- remediation: `Re-run \`${tendrilCommand(`verify ${opts.bundleDir}`)}\` \u2014 that file is written by the ruler and should never be edited by hand.`
19861
- });
20674
+ return { status: "expired" };
20675
+ }
20676
+ function settle2(opts, origin, outcome) {
20677
+ if (outcome.status === "connected") {
20678
+ writeFigmaTokens(outcome.tokens);
20679
+ emitData(
20680
+ opts,
20681
+ { status: "connected", origin, tokenExpiresAt: outcome.tokens.tokenExpiresAt, storedAt: figmaTokenPath() },
20682
+ () => {
20683
+ process.stdout.write(`Figma is connected \u2014 recording can use the REST channel now.
20684
+ `);
20685
+ process.stdout.write(` credential: ${figmaTokenPath()} (0600; renews itself through the portal)
20686
+ `);
20687
+ }
20688
+ );
20689
+ return;
19862
20690
  }
19863
- if (report === null || typeof report !== "object" || Array.isArray(report)) {
19864
- fail(opts, ExitCode.InputValidation, {
19865
- error: `${EVIDENCE_DIR}/${VERIFY_REPORT_FILENAME} parses, but a verify report is a JSON object and this is not one`,
19866
- code: "report-not-an-object",
19867
- remediation: `Re-run \`${tendrilCommand(`verify ${opts.bundleDir}`)}\`.`
20691
+ if (outcome.status === "failed") {
20692
+ fail(opts, ExitCode.ConfirmationRequired, {
20693
+ error: `the Figma connection was not granted: ${outcome.reason}`,
20694
+ code: "figma-connect-declined",
20695
+ remediation: "Nothing was stored. If minds change, run the connect again \u2014 it makes a fresh request."
19868
20696
  });
19869
20697
  }
19870
- return { manifest, report, reportText, files: bundleFiles(bundleDir) };
19871
- }
19872
- function resolveOrigin2(opts) {
19873
- const named = (opts.to ?? process.env["TENDRIL_PORTAL_URL"] ?? "").replace(/\/+$/, "");
19874
- if (named !== "") return named;
19875
- if ((process.env["TENDRIL_TOKEN"] ?? "") !== "") return "";
19876
- return (readStoredSession()?.origin ?? "").replace(/\/+$/, "");
20698
+ fail(opts, ExitCode.ConfirmationRequired, {
20699
+ error: outcome.status === "expired" ? "the Figma connection lapsed before the Allow click" : "the Figma connection is gone \u2014 it lapsed, or was collected by another terminal",
20700
+ code: "figma-connect-lapsed",
20701
+ remediation: "Run the connect again and finish it within its ten-minute window."
20702
+ });
19877
20703
  }
19878
- function httpClient(opts, origin) {
20704
+ var WAIT_CAP_SECONDS;
20705
+ var init_figma_connect = __esm({
20706
+ "packages/cli/src/commands/figma-connect.ts"() {
20707
+ "use strict";
20708
+ init_src3();
20709
+ init_figma_token();
20710
+ init_invocation();
20711
+ init_output();
20712
+ init_publish_client();
20713
+ WAIT_CAP_SECONDS = 660;
20714
+ }
20715
+ });
20716
+
20717
+ // packages/cli/src/commands/share.ts
20718
+ var share_exports = {};
20719
+ __export(share_exports, {
20720
+ runShare: () => runShare
20721
+ });
20722
+ async function runShare(opts) {
20723
+ const origin = (opts.to ?? process.env["TENDRIL_PORTAL_URL"] ?? "").replace(/\/+$/, "");
19879
20724
  if (origin === "") {
19880
20725
  fail(opts, ExitCode.InputValidation, {
19881
- error: "no portal to publish to",
20726
+ error: "no portal to share from",
19882
20727
  code: "no-portal-configured",
19883
- remediation: `Pass \`--to <url>\` or set TENDRIL_PORTAL_URL. \`${tendrilCommand(`publish ${opts.bundleDir} --dry-run`)}\` shows exactly what would be published without needing one.`
20728
+ remediation: `Pass \`--to <url>\` or set TENDRIL_PORTAL_URL.`
19884
20729
  });
19885
20730
  }
19886
20731
  if (!isSecureOrigin(origin)) {
@@ -19891,173 +20736,90 @@ function httpClient(opts, origin) {
19891
20736
  });
19892
20737
  }
19893
20738
  const found = tokenFor(origin);
19894
- if (!found.ok && found.reason === "origin-mismatch") {
19895
- fail(opts, ExitCode.Auth, {
19896
- error: `the session available here belongs to ${found.boundTo}, and this would publish to ${origin}`,
19897
- code: "session-belongs-to-another-portal",
19898
- remediation: found.from === "env" ? `TENDRIL_TOKEN is pinned to TENDRIL_PORTAL_URL (${found.boundTo}). Publish to that portal, or set both to ${origin} together \u2014 a token is a credential for one host.` : `Sign in to ${origin}. The stored session is for ${found.boundTo}, and sending it elsewhere would hand that host a live credential.`
19899
- });
19900
- }
19901
20739
  if (!found.ok) {
19902
20740
  fail(opts, ExitCode.Auth, {
19903
- error: `no session for ${origin}`,
19904
- code: "not-signed-in",
19905
- remediation: `Run \`${tendrilCommand(`login --to ${origin}`)}\` and paste the token your portal operator gave you (or set TENDRIL_TOKEN and TENDRIL_PORTAL_URL together for this shell). Verification stays free and account-less; only publishing needs an account.`
20741
+ error: found.reason === "origin-mismatch" ? `the session available here belongs to ${found.boundTo}, not ${origin}` : `no session for ${origin}`,
20742
+ code: found.reason === "origin-mismatch" ? "session-belongs-to-another-portal" : "not-signed-in",
20743
+ remediation: `Run \`${tendrilCommand(`login --to ${origin}`)}\`.`
19906
20744
  });
19907
20745
  }
19908
- return new HttpPublishClient({ origin, token: found.token });
19909
- }
19910
- function refuse2(opts, sent, code, rejoins = false) {
19911
- const detail = sent.detail === void 0 || sent.detail.length === 0 ? "" : `: ${sent.detail.join(", ")}`;
19912
- const retry = rejoins ? `run \`${tendrilCommand(`publish ${opts.bundleDir}`)}\` again \u2014 it rejoins this same unfinished publication rather than starting a second one` : `run \`${tendrilCommand(`publish ${opts.bundleDir}`)}\` again`;
19913
- fail(opts, sent.status === 401 ? ExitCode.Auth : ExitCode.General, {
19914
- error: `${sent.refusal}${detail}`,
19915
- code,
19916
- remediation: sent.status === 401 ? `That session is no longer valid. Run \`${tendrilCommand(`login --to ${resolveOrigin2(opts)}`)}\` and paste a fresh token.` : sent.status >= 500 ? `The portal failed on its side. Quote the error id above to whoever runs it, then ${retry}.` : `Fix what is named above and ${retry}.`
19917
- });
19918
- }
19919
- function pendingApprovalPath() {
19920
- return path57.join(path57.dirname(sessionPath()), "pending-publish.json");
19921
- }
19922
- async function runApprovalFlow(opts, client, bundleDir, input) {
19923
- const requested = await client.requestApproval({ componentName: input.componentName, figmaFile: input.figmaFile });
19924
- if (!requested.ok) refuse2(opts, requested, "approval-request-refused");
19925
- const approval = requested.value;
19926
- const who = await client.whoami?.();
19927
- const asAccount = who?.ok === true && who.value.email !== "" ? ` signed in as ${who.value.email}` : "";
19928
- if (opts.approveStart === true) {
19929
- const pending = { ...approval, bundleDir, componentName: input.componentName, figmaFile: input.figmaFile, requestedAt: (/* @__PURE__ */ new Date()).toISOString() };
19930
- writeFileSync25(pendingApprovalPath(), `${JSON.stringify(pending, null, 2)}
19931
- `, { mode: 384 });
19932
- await endRunPresence(input.componentName);
19933
- emitData(
19934
- opts,
19935
- {
19936
- status: "approval-pending",
19937
- approveUrl: approval.approveUrl,
19938
- componentName: input.componentName,
19939
- ...asAccount === "" ? {} : { approveAsAccount: who?.ok === true ? who.value.email : "" },
19940
- expiresAt: approval.expiresAt,
19941
- next: `open the approve page in the browser${asAccount}, click Approve, then finish with --approve-wait`
19942
- },
19943
- () => {
19944
- process.stdout.write(`Approval requested for ${JSON.stringify(input.componentName)}.
19945
- `);
19946
- process.stdout.write(`Approve it here${asAccount}: ${approval.approveUrl}
19947
- `);
19948
- process.stdout.write(`Then finish with: ${tendrilCommand(`publish ${opts.bundleDir} --approve-wait`)}
19949
- `);
20746
+ const client = new HttpPublishClient({ origin, token: found.token });
20747
+ if (opts.list === true) {
20748
+ const listed = await client.listShareLinks({ publicationId: opts.publicationId });
20749
+ if (!listed.ok) refuse2(opts, listed);
20750
+ emitData(opts, listed.value, () => {
20751
+ if (listed.value.links.length === 0) {
20752
+ process.stdout.write("no links have been issued for this publication\n");
20753
+ return;
19950
20754
  }
19951
- );
19952
- return void 0;
19953
- }
19954
- process.stderr.write(`This component's FIRST publish needs your approval in the browser${asAccount}:
19955
- ${approval.approveUrl}
20755
+ process.stdout.write(`${String(listed.value.links.length)} link(s) for this publication:
19956
20756
  `);
19957
- process.stderr.write(`Waiting for your decision (lapses at ${approval.expiresAt.slice(11, 16)} UTC)\u2026
20757
+ for (const link of listed.value.links) {
20758
+ const ended = link.revoked_at !== null ? ` REVOKED ${link.revoked_at}` : link.expires_at !== null ? ` expires ${link.expires_at}` : " open-ended";
20759
+ process.stdout.write(` ${link.id}${ended}${link.recipient_email === null ? "" : ` \u2014 for ${link.recipient_email}`}
19958
20760
  `);
19959
- const decided = await waitForApproval(opts, client, approval);
19960
- if (decided === "approved") return input.begin();
19961
- await endRunPresence(input.componentName);
19962
- failDecision(opts, decided === "window-elapsed" ? "expired" : decided);
19963
- }
19964
- async function approveWaitPhase(opts, client, bundleDir, subject) {
19965
- const file = pendingApprovalPath();
19966
- let pending;
19967
- if (existsSync47(file)) {
19968
- try {
19969
- const parsed = JSON.parse(readFileSync43(file, "utf8"));
19970
- if (typeof parsed.approvalId === "string" && typeof parsed.approveUrl === "string" && typeof parsed.expiresAt === "string") {
19971
- pending = { pollSeconds: 3, bundleDir, ...parsed };
19972
20761
  }
19973
- } catch {
19974
- }
19975
- }
19976
- if (pending === void 0) {
19977
- fail(opts, ExitCode.InputValidation, {
19978
- error: "there is no publish approval waiting to finish",
19979
- code: "no-pending-approval",
19980
- remediation: `Start one first: ${tendrilCommand(`publish ${opts.bundleDir} --approve-start`)} (the tendril_publish tool).`
20762
+ process.stdout.write(" (the links themselves are not stored \u2014 only their digests, so a lost link is a new link)\n");
19981
20763
  });
20764
+ return;
19982
20765
  }
19983
- if (pending.componentName !== void 0 && pending.componentName !== subject.componentName) {
19984
- fail(opts, ExitCode.InputValidation, {
19985
- error: `the waiting approval is for ${JSON.stringify(pending.componentName)}, and this publish is ${JSON.stringify(subject.componentName)}`,
19986
- code: "pending-approval-mismatch",
19987
- remediation: `Finish that one first (${tendrilCommand(`publish ${pending.bundleDir} --approve-wait`)}), or start this one fresh with --approve-start \u2014 starting one replaces the waiting slot.`
20766
+ if (opts.revoke !== void 0) {
20767
+ const revoked = await client.revokeShareLink({ publicationId: opts.publicationId, shareLinkId: opts.revoke });
20768
+ if (!revoked.ok) refuse2(opts, revoked);
20769
+ emitData(opts, revoked.value, () => {
20770
+ process.stdout.write(`revoked ${opts.revoke ?? ""}
20771
+ `);
20772
+ process.stdout.write(" anyone holding that link now gets told it was revoked, not that it never existed\n");
19988
20773
  });
20774
+ return;
19989
20775
  }
19990
- const done = () => rmSync10(file, { force: true });
19991
- const decided = await waitForApproval(opts, client, pending);
19992
- if (decided === "window-elapsed") {
19993
- const who = await client.whoami?.();
19994
- const asAccount = who?.ok === true && who.value.email !== "" ? who.value.email : void 0;
19995
- emitData(
19996
- opts,
19997
- {
19998
- status: "approval-pending",
19999
- approveUrl: pending.approveUrl,
20000
- ...asAccount !== void 0 ? { approveAsAccount: asAccount } : {},
20001
- expiresAt: pending.expiresAt,
20002
- windowSeconds: opts.waitWindowSeconds ?? 0,
20003
- ...pending.requestedAt !== void 0 ? { waitedTotalSeconds: Math.max(0, Math.round((Date.now() - Date.parse(pending.requestedAt)) / 1e3)) } : {},
20004
- remainingSeconds: Math.max(0, Math.round((Date.parse(pending.expiresAt) - Date.now()) / 1e3)),
20005
- next: "the approval request is STILL LIVE and undecided \u2014 tell the user in one short line you are still waiting for their Approve click (restate the approve link and the account to approve as, ONLY if they seem lost; never urge the decision), then run the wait again to keep waiting"
20006
- },
20007
- () => {
20008
- process.stdout.write(`Still waiting for the browser approval (wait window elapsed; the request stays live until ${pending.expiresAt.slice(11, 16)} UTC): ${pending.approveUrl}
20776
+ const expiresAt = resolveExpiry(opts);
20777
+ const issued = await client.issueShareLink({
20778
+ publicationId: opts.publicationId,
20779
+ expiresAt,
20780
+ recipientEmail: opts.recipient ?? null
20781
+ });
20782
+ if (!issued.ok) refuse2(opts, issued);
20783
+ emitData(opts, issued.value, () => {
20784
+ process.stdout.write(`${issued.value.url}
20785
+
20009
20786
  `);
20010
- }
20787
+ process.stdout.write(" Anyone with this link can read this one publication and its evidence.\n");
20788
+ process.stdout.write(" It is not tied to a person and needs no account.\n");
20789
+ process.stdout.write(
20790
+ issued.value.expiresAt === null ? " It does not expire. Revoke it with --revoke when you are done.\n" : ` It stops working at ${issued.value.expiresAt}.
20791
+ `
20011
20792
  );
20012
- return "yielded";
20013
- }
20014
- if (decided !== "approved") {
20015
- done();
20016
- failDecision(opts, decided);
20017
- }
20018
- return "proceed";
20019
- }
20020
- function spendPendingApproval() {
20021
- rmSync10(pendingApprovalPath(), { force: true });
20793
+ process.stdout.write(" This is the only time the link is shown \u2014 only its digest is stored.\n");
20794
+ });
20022
20795
  }
20023
- async function waitForApproval(opts, client, approval) {
20024
- const interval = Math.max(1, approval.pollSeconds) * 1e3;
20025
- const capMs = opts.waitWindowSeconds !== void 0 ? Math.max(1, opts.waitWindowSeconds) * 1e3 : APPROVAL_WAIT_CAP_MS;
20026
- const total = Math.ceil(capMs / interval);
20027
- for (let tick = 1; tick <= total; tick += 1) {
20028
- const polled = await client.pollApproval({ approvalId: approval.approvalId });
20029
- if (!polled.ok) refuse2(opts, polled, "approval-poll-refused");
20030
- if (polled.value.status !== "pending") return polled.value.status;
20031
- emitProgress(tick, total, "waiting for the browser approval");
20032
- await new Promise((resolve) => setTimeout(resolve, interval));
20796
+ function resolveExpiry(opts) {
20797
+ if (opts.expires === void 0) return null;
20798
+ const days = Number(opts.expires);
20799
+ if (Number.isInteger(days) && days > 0) {
20800
+ return new Date(Date.now() + days * 24 * 60 * 60 * 1e3).toISOString().replace(/\.\d{3}Z$/, (m) => m);
20033
20801
  }
20034
- return opts.waitWindowSeconds !== void 0 ? "window-elapsed" : "expired";
20802
+ if (/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/.test(opts.expires)) return opts.expires;
20803
+ fail(opts, ExitCode.InputValidation, {
20804
+ error: `--expires takes a number of days (e.g. 7) or an ISO-8601 UTC timestamp with milliseconds \u2014 got ${JSON.stringify(opts.expires)}`,
20805
+ code: "bad-expiry",
20806
+ remediation: "Try `--expires 7` for a week, or omit it for a link that does not expire."
20807
+ });
20035
20808
  }
20036
- function failDecision(opts, decided) {
20037
- if (decided === "denied") {
20038
- fail(opts, ExitCode.ConfirmationRequired, {
20039
- error: "the publish was DENIED in the browser \u2014 the human said no",
20040
- code: "publish-approval-denied",
20041
- remediation: "Nothing was published. If minds change, run the publish again \u2014 it makes a fresh request."
20042
- });
20043
- }
20044
- fail(opts, ExitCode.ConfirmationRequired, {
20045
- error: decided === "expired" ? "the approval request lapsed before anyone decided it" : "the approval request is gone \u2014 it lapsed and was cleaned up, or was already spent",
20046
- code: "publish-approval-lapsed",
20047
- remediation: "Run the publish again for a fresh request, and decide it within its 30-minute window. If the approve page showed nothing waiting, the browser is signed in to a DIFFERENT account than this CLI \u2014 the page lists only its own account's requests."
20809
+ function refuse2(opts, sent) {
20810
+ fail(opts, sent.status === 401 ? ExitCode.Auth : ExitCode.General, {
20811
+ error: sent.refusal,
20812
+ code: "share-refused",
20813
+ remediation: sent.status === 404 ? "Check the publication id \u2014 only its owner can share it." : `Fix what is named above and run \`${tendrilCommand(`share ${opts.publicationId}`)}\` again.`
20048
20814
  });
20049
20815
  }
20050
- var APPROVAL_WAIT_CAP_MS;
20051
- var init_publish = __esm({
20052
- "packages/cli/src/commands/publish.ts"() {
20816
+ var init_share = __esm({
20817
+ "packages/cli/src/commands/share.ts"() {
20053
20818
  "use strict";
20054
20819
  init_src3();
20055
- init_src4();
20056
20820
  init_invocation();
20057
20821
  init_output();
20058
20822
  init_publish_client();
20059
- init_run_presence();
20060
- APPROVAL_WAIT_CAP_MS = 31 * 6e4;
20061
20823
  }
20062
20824
  });
20063
20825
 
@@ -20085,17 +20847,17 @@ __export(generate_recorded_exports, {
20085
20847
  runGenerateRecorded: () => runGenerateRecorded
20086
20848
  });
20087
20849
  import { confirm as confirm3, isCancel as isCancel4 } from "@clack/prompts";
20088
- import { existsSync as existsSync48, readFileSync as readFileSync44 } from "node:fs";
20089
- import path58 from "node:path";
20850
+ import { existsSync as existsSync49, readFileSync as readFileSync45 } from "node:fs";
20851
+ import path59 from "node:path";
20090
20852
  async function runGenerateRecorded(opts) {
20091
20853
  const callerCwd = process.env["INIT_CWD"] ?? process.cwd();
20092
- const outDirAbs = path58.resolve(callerCwd, opts.out);
20093
- const recordedAsPath = path58.resolve(callerCwd, opts.recorded);
20854
+ const outDirAbs = path59.resolve(callerCwd, opts.out);
20855
+ const recordedAsPath = path59.resolve(callerCwd, opts.recorded);
20094
20856
  let task;
20095
20857
  let taskName;
20096
20858
  let authoredApi;
20097
20859
  let composition;
20098
- const isSet = existsSync48(path58.join(recordedAsPath, "recording-set.json"));
20860
+ const isSet = existsSync49(path59.join(recordedAsPath, "recording-set.json"));
20099
20861
  const registry = TASKS[opts.recorded];
20100
20862
  if (registry !== void 0 && !isSet) {
20101
20863
  task = registry;
@@ -20104,7 +20866,7 @@ async function runGenerateRecorded(opts) {
20104
20866
  try {
20105
20867
  const authored = authorTaskFromSet(recordedAsPath);
20106
20868
  task = authored.task;
20107
- taskName = path58.basename(recordedAsPath);
20869
+ taskName = path59.basename(recordedAsPath);
20108
20870
  authoredApi = authored.api;
20109
20871
  const roles = RolesSchema.safeParse(loadManifest(recordedAsPath).roles);
20110
20872
  if (roles.success) composition = roles.data;
@@ -20138,7 +20900,7 @@ async function runGenerateRecorded(opts) {
20138
20900
  warn(opts, `font weights (advisory): the recording implies weights ${g.declared.join(", ")} for "${g.family}" \u2014 cache provides ${g.provided.join(", ")}; nearest-weight rendering may shift stroke density (certification stays family-gated; implied weights can leak across families)`);
20139
20901
  }
20140
20902
  const missing = task.configs.filter(
20141
- (c) => !repEnvelopeExists(task.set, c.rep, "screenshot") || !repEnvelopeExists(task.set, c.rep, "metadata") || !existsSync48(path58.join(task.set, c.rep, "get_design_context.json"))
20903
+ (c) => !repEnvelopeExists(task.set, c.rep, "screenshot") || !repEnvelopeExists(task.set, c.rep, "metadata") || !existsSync49(path59.join(task.set, c.rep, "get_design_context.json"))
20142
20904
  );
20143
20905
  if (missing.length > 0) {
20144
20906
  fail(opts, ExitCode.RecordingIncomplete, {
@@ -20208,8 +20970,8 @@ async function runGenerateRecorded(opts) {
20208
20970
  ` : `${line}
20209
20971
  `);
20210
20972
  if (opts.dryRun) {
20211
- emitData(opts, { dryRun: true, task: taskName, model: modelId, consent, wouldWrite: path58.join(outDirAbs, taskName) }, () => {
20212
- process.stdout.write(`dry-run: nothing sent, nothing written (would write ${path58.join(outDirAbs, taskName)})
20973
+ emitData(opts, { dryRun: true, task: taskName, model: modelId, consent, wouldWrite: path59.join(outDirAbs, taskName) }, () => {
20974
+ process.stdout.write(`dry-run: nothing sent, nothing written (would write ${path59.join(outDirAbs, taskName)})
20213
20975
  `);
20214
20976
  });
20215
20977
  return;
@@ -20232,10 +20994,10 @@ async function runGenerateRecorded(opts) {
20232
20994
  });
20233
20995
  }
20234
20996
  }
20235
- const bundleDir = path58.join(outDirAbs, taskName);
20236
- if (existsSync48(path58.join(bundleDir, "component.json"))) {
20997
+ const bundleDir = path59.join(outDirAbs, taskName);
20998
+ if (existsSync49(path59.join(bundleDir, "component.json"))) {
20237
20999
  try {
20238
- const prior = readBundleManifest(readFileSync44(path58.join(bundleDir, "component.json"), "utf8")).manifest;
21000
+ const prior = readBundleManifest(readFileSync45(path59.join(bundleDir, "component.json"), "utf8")).manifest;
20239
21001
  if (prior !== void 0 && prior.provenance.recordingSet.hash !== recordingSetHash(task.set, task.configs)) {
20240
21002
  fail(opts, ExitCode.InputValidation, {
20241
21003
  error: `${bundleDir} already holds a bundle bound to recording set "${prior.provenance.recordingSet.path}" \u2014 generating here against a different set would silently rewrite its verification identity`,
@@ -21310,6 +22072,12 @@ function buildProgram() {
21310
22072
  const { runRecordRestFetch: runRecordRestFetch2 } = await Promise.resolve().then(() => (init_record(), record_exports));
21311
22073
  await runRecordRestFetch2({ ...flags, setDir: local["set"] });
21312
22074
  });
22075
+ record.command("bindings").description("Fetch Figma's instance\u2192component bindings for an EXISTING MCP-recorded set (one or two batched REST calls, congruence-verified per pose against the recording) \u2014 makes cross-component pairing id-backed WITHOUT re-recording. Pixels, geometry and the set's identity are untouched.").requiredOption("--set <dir>", "recording set directory").option("--file <key>", "the design's file key (figma.com/design/<KEY>/\u2026) \u2014 an operator assertion for sets recorded before file identity was captured; congruence still gates every binding").action(async (_o, cmd) => {
22076
+ const flags = globalFlags(cmd.parent.parent);
22077
+ const local = cmd.opts();
22078
+ const { runRecordBindings: runRecordBindings2 } = await Promise.resolve().then(() => (init_record(), record_exports));
22079
+ await runRecordBindings2({ ...flags, setDir: local["set"], ...local["file"] !== void 0 ? { file: local["file"] } : {} });
22080
+ });
21313
22081
  record.command("next").requiredOption("--set <dir>", "recording set directory").action(async (_o, cmd) => {
21314
22082
  const flags = globalFlags(cmd.parent.parent);
21315
22083
  const { runRecordNext: runRecordNext2 } = await Promise.resolve().then(() => (init_record(), record_exports));
@@ -21354,10 +22122,11 @@ function buildProgram() {
21354
22122
  const { runRecordFinish: runRecordFinish2 } = await Promise.resolve().then(() => (init_record(), record_exports));
21355
22123
  runRecordFinish2({ ...flags, setDir: local["set"], confirmRoles: local["confirmRoles"], rolesFile: local["rolesFile"] });
21356
22124
  });
21357
- record.command("status").requiredOption("--set <dir>", "recording set directory").action(async (_o, cmd) => {
22125
+ record.command("status").requiredOption("--set <dir>", "recording set directory").option("--library <dir>", "workspace root to scan for composition partners (default: the set's parent directory, bounded) \u2014 a partner in another project root is invisible without this").action(async (_o, cmd) => {
21358
22126
  const flags = globalFlags(cmd.parent.parent);
22127
+ const local = cmd.opts();
21359
22128
  const { runRecordStatus: runRecordStatus2 } = await Promise.resolve().then(() => (init_record(), record_exports));
21360
- runRecordStatus2({ ...flags, setDir: cmd.opts()["set"] });
22129
+ runRecordStatus2({ ...flags, setDir: local["set"], ...local["library"] !== void 0 ? { library: local["library"] } : {} });
21361
22130
  });
21362
22131
  const fonts = program.command("fonts").description("Font resolution chain: hash-pinned cache, lock verification, requiredFonts manifest.");
21363
22132
  fonts.command("resolve").argument("[family]", 'font family name, e.g. "IBM Plex Sans" (omit when using --set)').option("--set <dir>", "recording set \u2014 resolve every family and weight the set itself declares (nothing is fetched until a recording needs it)").option("--weights <w...>", "weights to resolve", ["400", "500", "600"]).option("--cache <dir>", "cache directory (default: the per-user font cache)").action(async (family, _o, cmd) => {
@@ -21479,9 +22248,24 @@ function buildProgram() {
21479
22248
  ...local["tendrilPrefix"] !== void 0 ? { tendrilPrefix: local["tendrilPrefix"] } : {}
21480
22249
  });
21481
22250
  });
21482
- program.command("compose").description("Composition opportunities across a workspace's recording sets (ADR-013): --list discovers (read-only); --set persists a HUMAN's confirm/decline decisions into the host manifest. A confirmed pair composes at generation (module identity verified; rendered-mount stamping and region crops are the named next increment).").option("--list", "derive the index and report edges (default)").option("--library <dir...>", "workspace root(s) to scan for recording sets (default: current directory)").option("--set <dir>", "confirmation mode: propose this HOST set's id-backed pairs for a human decision").option("--confirm-compositions", "HUMAN-ONLY: confirm every open pair printed for --set (refused without an interactive terminal; agents surface the proposal instead)").option("--decline <pair-key...>", "HUMAN-ONLY: persistently decline the named pair(s) \u2014 asked once, never re-asked").action(async (_o, cmd) => {
22251
+ program.command("compose").description("Composition opportunities across a workspace's recording sets (ADR-013): --list discovers (read-only); --set persists a HUMAN's confirm/decline decisions into the host manifest. A confirmed pair composes at generation (module identity verified; rendered-mount stamping and region crops are the named next increment).").option("--list", "derive the index and report edges (default)").option("--library <dir...>", "workspace root(s) to scan for recording sets (default: current directory)").option("--set <dir>", "confirmation mode: propose this HOST set's id-backed pairs for a human decision").option("--confirm-compositions", "HUMAN-ONLY: confirm every open pair printed for --set (refused without an interactive terminal; agents surface the proposal instead)").option("--decline <pair-key...>", "HUMAN-ONLY: persistently decline the named pair(s) \u2014 asked once, never re-asked").option("--approve-start", "put ONE open pairing in front of the human's BROWSER (portal session required for this channel only) \u2014 prints the approve link, persists the pending state and exits; the tendril_compose tool's phase one").option("--approve-wait", "resume a pending browser decision: poll, and on Approve record the SAME entry the terminal confirm writes \u2014 phase two").option("--wait-window <seconds>", "with --approve-wait: return after this many undecided seconds (exit 0, status approval-pending, slot kept) \u2014 the MCP bridge's bounded-poll shape").option("--pair <key>", "with --approve-start on a host holding several open pairs: the ONE pairing to request (one card, one decision)").option("--to <url>", "portal origin override for the browser-approve channel").action(async (_o, cmd) => {
21483
22252
  const flags = globalFlags(cmd.parent);
21484
22253
  const local = cmd.opts();
22254
+ const waitWindow = local["waitWindow"] !== void 0 ? Number.parseFloat(local["waitWindow"]) : void 0;
22255
+ if (local["approveStart"] === true || local["approveWait"] === true) {
22256
+ const { runComposeApprove: runComposeApprove2 } = await Promise.resolve().then(() => (init_compose_approve(), compose_approve_exports));
22257
+ await runComposeApprove2({
22258
+ ...flags,
22259
+ ...local["library"] !== void 0 ? { library: local["library"] } : {},
22260
+ ...local["set"] !== void 0 ? { set: local["set"] } : {},
22261
+ ...local["approveStart"] !== void 0 ? { approveStart: local["approveStart"] } : {},
22262
+ ...local["approveWait"] !== void 0 ? { approveWait: local["approveWait"] } : {},
22263
+ ...waitWindow !== void 0 ? { waitWindowSeconds: waitWindow } : {},
22264
+ ...local["pair"] !== void 0 ? { pair: local["pair"] } : {},
22265
+ ...local["to"] !== void 0 ? { to: local["to"] } : {}
22266
+ });
22267
+ return;
22268
+ }
21485
22269
  const { runCompose: runCompose2 } = await Promise.resolve().then(() => (init_compose2(), compose_exports));
21486
22270
  runCompose2({
21487
22271
  ...flags,