@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/SKILL.md +36 -4
- package/dist/tendril-mcp.js +34 -0
- package/dist/tendril.js +1760 -976
- package/package.json +1 -1
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
|
|
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
|
|
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")
|
|
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
|
|
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))
|
|
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
|
|
1973
|
-
//
|
|
1974
|
-
//
|
|
1975
|
-
//
|
|
1976
|
-
//
|
|
1977
|
-
|
|
1978
|
-
|
|
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
|
|
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 (${[
|
|
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
|
|
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(
|
|
2406
|
-
return `--${
|
|
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
|
|
2451
|
-
if (
|
|
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:
|
|
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:
|
|
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
|
|
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 =
|
|
2664
|
+
const leaf = path60[path60.length - 1];
|
|
2483
2665
|
if (group[leaf] !== void 0) {
|
|
2484
|
-
throw new DtcgMappingError("duplicate-path", `Duplicate token path "${
|
|
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:
|
|
2489
|
-
cssVar: tokenPathToCssVar(
|
|
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
|
|
2679
|
-
if (
|
|
2680
|
-
return
|
|
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
|
|
2716
|
-
if (
|
|
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
|
|
2725
|
-
if (
|
|
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
|
|
2736
|
-
if (
|
|
2737
|
-
layout.gap =
|
|
2738
|
-
tokens.add(
|
|
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
|
|
2748
|
-
if (
|
|
2749
|
-
paddingPaths.push(
|
|
2750
|
-
tokens.add(
|
|
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
|
|
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 =
|
|
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 =
|
|
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 (
|
|
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
|
|
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 (
|
|
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
|
|
6679
|
-
const inEvidence =
|
|
6680
|
-
if (
|
|
6681
|
-
const fname =
|
|
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:
|
|
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(
|
|
6868
|
+
unknown.push(path60);
|
|
6687
6869
|
continue;
|
|
6688
6870
|
}
|
|
6689
|
-
const name = inEvidence ?
|
|
6871
|
+
const name = inEvidence ? path60.slice(EVIDENCE_DIR.length + 1) : path60;
|
|
6690
6872
|
if (name.includes("/")) {
|
|
6691
|
-
unknown.push(
|
|
6873
|
+
unknown.push(path60);
|
|
6692
6874
|
continue;
|
|
6693
6875
|
}
|
|
6694
6876
|
if (inEvidence) {
|
|
6695
|
-
if (name === "verify-report.json") published.push({ path:
|
|
6696
|
-
else if (name === "diff-legend.txt") published.push({ path:
|
|
6697
|
-
else if (name === "inspect.html") published.push({ path:
|
|
6698
|
-
else if (/-mount-failure\.png$/.test(name)) excluded.push({ path:
|
|
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:
|
|
6702
|
-
else unknown.push(
|
|
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:
|
|
6707
|
-
else if (name === "styles.css") published.push({ path:
|
|
6708
|
-
else if (name === "tokens.css") published.push({ path:
|
|
6709
|
-
else if (name === "fonts.css") published.push({ path:
|
|
6710
|
-
else if (name === "component.json") published.push({ path:
|
|
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:
|
|
6714
|
-
else unknown.push(
|
|
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
|
|
6725
|
-
if (!held.has(
|
|
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
|
|
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] =
|
|
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 [
|
|
6834
|
-
if (
|
|
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[
|
|
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 [
|
|
6845
|
-
if (!(
|
|
6846
|
-
else if (actual[
|
|
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
|
|
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
|
|
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 :
|
|
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
|
|
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) &&
|
|
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 =
|
|
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
|
|
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:
|
|
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 =
|
|
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
|
|
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(), `${
|
|
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
|
-
|
|
14737
|
+
renderBindingsRemediation: () => renderBindingsRemediation,
|
|
14738
|
+
runCompose: () => runCompose,
|
|
14739
|
+
substitutionPairs: () => substitutionPairs,
|
|
14740
|
+
writeCompositionDecisions: () => writeCompositionDecisions
|
|
14549
14741
|
});
|
|
14550
|
-
import { createHash as
|
|
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: "
|
|
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
|
|
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(
|
|
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: `
|
|
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 =
|
|
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.
|
|
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),
|
|
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
|
-
|
|
14770
|
-
|
|
14771
|
-
|
|
14772
|
-
|
|
14773
|
-
|
|
14774
|
-
|
|
14775
|
-
|
|
14776
|
-
|
|
14777
|
-
|
|
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
|
-
//
|
|
15811
|
-
|
|
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.
|
|
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.
|
|
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.
|
|
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
|
|
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 =
|
|
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/
|
|
19027
|
-
var
|
|
19028
|
-
__export(
|
|
19029
|
-
|
|
19030
|
-
|
|
19031
|
-
|
|
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 {
|
|
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
|
-
|
|
19037
|
-
|
|
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:
|
|
19042
|
-
code: "
|
|
19043
|
-
remediation: "
|
|
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.
|
|
19047
|
-
|
|
19048
|
-
|
|
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
|
-
|
|
19051
|
-
|
|
19052
|
-
|
|
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 (
|
|
19055
|
-
|
|
19056
|
-
|
|
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
|
-
|
|
19059
|
-
|
|
19060
|
-
|
|
19061
|
-
|
|
19062
|
-
|
|
19063
|
-
|
|
19064
|
-
|
|
19065
|
-
|
|
19066
|
-
|
|
19067
|
-
|
|
19068
|
-
|
|
19069
|
-
|
|
19070
|
-
|
|
19071
|
-
|
|
19072
|
-
|
|
19073
|
-
|
|
19074
|
-
|
|
19075
|
-
|
|
19076
|
-
|
|
19077
|
-
|
|
19078
|
-
|
|
19079
|
-
|
|
19080
|
-
|
|
19081
|
-
|
|
19082
|
-
|
|
19083
|
-
|
|
19084
|
-
const
|
|
19085
|
-
|
|
19086
|
-
|
|
19087
|
-
|
|
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
|
-
|
|
19093
|
-
|
|
19094
|
-
|
|
19095
|
-
|
|
19096
|
-
|
|
19097
|
-
|
|
19098
|
-
|
|
19099
|
-
|
|
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(`
|
|
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
|
-
|
|
19105
|
-
|
|
19106
|
-
|
|
19107
|
-
|
|
19108
|
-
|
|
19109
|
-
|
|
19110
|
-
|
|
19111
|
-
|
|
19112
|
-
|
|
19113
|
-
|
|
19114
|
-
|
|
19115
|
-
|
|
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
|
-
|
|
19120
|
-
|
|
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:
|
|
19123
|
-
code: "
|
|
19124
|
-
remediation: `Run
|
|
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
|
-
|
|
19142
|
-
|
|
19143
|
-
|
|
19144
|
-
|
|
19145
|
-
|
|
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
|
-
|
|
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(`
|
|
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(`
|
|
19638
|
+
process.stdout.write(` verdict as scored by ruler ${opened.value.rulerVersion}
|
|
19153
19639
|
`);
|
|
19154
19640
|
}
|
|
19155
19641
|
);
|
|
19156
19642
|
}
|
|
19157
|
-
|
|
19158
|
-
const
|
|
19159
|
-
|
|
19160
|
-
if (existsSync45(
|
|
19161
|
-
|
|
19162
|
-
|
|
19163
|
-
|
|
19164
|
-
|
|
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 (
|
|
19653
|
+
if (!existsSync45(reportPath)) {
|
|
19170
19654
|
fail(opts, ExitCode.InputValidation, {
|
|
19171
|
-
error:
|
|
19172
|
-
code: "
|
|
19173
|
-
remediation: `
|
|
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
|
|
19177
|
-
|
|
19178
|
-
|
|
19179
|
-
|
|
19180
|
-
|
|
19181
|
-
|
|
19182
|
-
|
|
19183
|
-
|
|
19184
|
-
|
|
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
|
-
|
|
19671
|
+
report = JSON.parse(reportText);
|
|
19190
19672
|
} catch (error) {
|
|
19191
|
-
|
|
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 (
|
|
19194
|
-
|
|
19195
|
-
|
|
19196
|
-
|
|
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
|
|
19686
|
+
return { manifest, report, reportText, files: bundleFiles(bundleDir) };
|
|
19199
19687
|
}
|
|
19200
|
-
|
|
19201
|
-
|
|
19202
|
-
|
|
19203
|
-
|
|
19204
|
-
|
|
19205
|
-
|
|
19206
|
-
|
|
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 (!
|
|
19212
|
-
|
|
19213
|
-
|
|
19214
|
-
|
|
19215
|
-
|
|
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
|
|
19218
|
-
|
|
19219
|
-
|
|
19220
|
-
|
|
19221
|
-
|
|
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
|
|
19225
|
-
return
|
|
19226
|
-
|
|
19227
|
-
|
|
19228
|
-
|
|
19229
|
-
|
|
19230
|
-
|
|
19231
|
-
|
|
19232
|
-
|
|
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
|
|
19242
|
-
code: "no-
|
|
19243
|
-
remediation: `
|
|
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
|
-
|
|
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:
|
|
19250
|
-
code: "
|
|
19251
|
-
remediation:
|
|
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
|
-
|
|
19255
|
-
|
|
19256
|
-
|
|
19257
|
-
|
|
19258
|
-
|
|
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
|
-
|
|
19263
|
-
|
|
19264
|
-
|
|
19265
|
-
|
|
19266
|
-
|
|
19267
|
-
|
|
19268
|
-
|
|
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
|
-
|
|
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
|
|
19275
|
-
|
|
19276
|
-
|
|
19277
|
-
|
|
19278
|
-
|
|
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
|
-
|
|
19281
|
-
|
|
19282
|
-
|
|
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
|
|
19288
|
-
var
|
|
19289
|
-
"packages/cli/src/commands/
|
|
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
|
-
|
|
19875
|
+
init_run_presence();
|
|
19876
|
+
APPROVAL_WAIT_CAP_MS = 31 * 6e4;
|
|
19296
19877
|
}
|
|
19297
19878
|
});
|
|
19298
19879
|
|
|
19299
|
-
// packages/cli/src/commands/
|
|
19300
|
-
var
|
|
19301
|
-
__export(
|
|
19302
|
-
|
|
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 {
|
|
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
|
|
19307
|
-
|
|
19308
|
-
|
|
19309
|
-
|
|
19310
|
-
|
|
19311
|
-
|
|
19312
|
-
|
|
19313
|
-
|
|
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
|
|
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(
|
|
19318
|
-
error: "no portal to
|
|
19933
|
+
fail(flags, ExitCode.InputValidation, {
|
|
19934
|
+
error: "no portal to request the approval from",
|
|
19319
19935
|
code: "no-portal-configured",
|
|
19320
|
-
remediation:
|
|
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(
|
|
19955
|
+
fail(flags, ExitCode.Auth, {
|
|
19326
19956
|
error: `no session for ${origin}`,
|
|
19327
19957
|
code: "not-signed-in",
|
|
19328
|
-
remediation:
|
|
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
|
-
|
|
19334
|
-
|
|
19335
|
-
|
|
19336
|
-
|
|
19337
|
-
|
|
19338
|
-
|
|
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
|
|
19341
|
-
|
|
19342
|
-
|
|
19343
|
-
|
|
19344
|
-
|
|
19345
|
-
|
|
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
|
-
|
|
19348
|
-
|
|
19349
|
-
|
|
19350
|
-
|
|
19351
|
-
|
|
19352
|
-
|
|
19353
|
-
|
|
19354
|
-
|
|
19355
|
-
|
|
19356
|
-
|
|
19357
|
-
()
|
|
19358
|
-
|
|
19359
|
-
|
|
19360
|
-
|
|
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
|
-
|
|
20053
|
+
process.stdout.write(`Then finish with: ${tendrilCommand(`compose --set ${quoteArg(hostSet)} --approve-wait`)}
|
|
19370
20054
|
`);
|
|
19371
|
-
|
|
19372
|
-
|
|
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
|
|
19396
|
-
const file =
|
|
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.
|
|
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(
|
|
19407
|
-
error: "there is no
|
|
19408
|
-
code: "no-pending-
|
|
19409
|
-
remediation: `Start one first: ${tendrilCommand(
|
|
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
|
|
19413
|
-
|
|
19414
|
-
|
|
19415
|
-
|
|
19416
|
-
|
|
19417
|
-
|
|
19418
|
-
|
|
19419
|
-
|
|
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
|
-
|
|
19422
|
-
|
|
19423
|
-
|
|
19424
|
-
|
|
19425
|
-
|
|
19426
|
-
|
|
19427
|
-
|
|
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 (
|
|
19430
|
-
|
|
19431
|
-
|
|
19432
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
19453
|
-
{
|
|
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(`
|
|
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 (
|
|
19464
|
-
|
|
19465
|
-
|
|
19466
|
-
|
|
19467
|
-
|
|
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
|
-
|
|
19471
|
-
|
|
19472
|
-
|
|
19473
|
-
|
|
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
|
-
|
|
19477
|
-
|
|
19478
|
-
|
|
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
|
-
|
|
20243
|
+
init_src();
|
|
19482
20244
|
init_invocation();
|
|
19483
20245
|
init_output();
|
|
19484
20246
|
init_publish_client();
|
|
19485
|
-
|
|
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/
|
|
19490
|
-
var
|
|
19491
|
-
__export(
|
|
19492
|
-
|
|
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
|
-
|
|
19495
|
-
|
|
19496
|
-
|
|
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:
|
|
19499
|
-
code: "
|
|
19500
|
-
remediation:
|
|
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 (
|
|
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:
|
|
19506
|
-
code: "
|
|
19507
|
-
remediation:
|
|
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
|
|
19511
|
-
|
|
19512
|
-
|
|
19513
|
-
|
|
19514
|
-
|
|
19515
|
-
|
|
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
|
-
|
|
19519
|
-
|
|
19520
|
-
|
|
19521
|
-
|
|
19522
|
-
|
|
19523
|
-
|
|
19524
|
-
|
|
19525
|
-
|
|
19526
|
-
|
|
19527
|
-
|
|
19528
|
-
|
|
19529
|
-
|
|
19530
|
-
|
|
19531
|
-
|
|
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
|
-
|
|
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
|
-
|
|
19539
|
-
|
|
19540
|
-
|
|
19541
|
-
|
|
19542
|
-
|
|
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
|
-
|
|
19549
|
-
|
|
19550
|
-
|
|
19551
|
-
|
|
19552
|
-
|
|
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
|
|
19569
|
-
if (
|
|
19570
|
-
|
|
19571
|
-
|
|
19572
|
-
|
|
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
|
-
|
|
19575
|
-
|
|
19576
|
-
|
|
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
|
|
19582
|
-
|
|
19583
|
-
|
|
19584
|
-
|
|
19585
|
-
|
|
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
|
|
19589
|
-
|
|
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/
|
|
19599
|
-
var
|
|
19600
|
-
__export(
|
|
19601
|
-
|
|
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
|
|
19607
|
-
import
|
|
19608
|
-
|
|
19609
|
-
|
|
19610
|
-
|
|
19611
|
-
|
|
19612
|
-
|
|
19613
|
-
|
|
19614
|
-
|
|
19615
|
-
|
|
19616
|
-
|
|
19617
|
-
|
|
19618
|
-
|
|
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:
|
|
19682
|
-
code: "
|
|
19683
|
-
remediation: `
|
|
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
|
|
19687
|
-
if (
|
|
19688
|
-
fail(opts, ExitCode.
|
|
19689
|
-
error:
|
|
19690
|
-
code: "
|
|
19691
|
-
remediation: `
|
|
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
|
-
|
|
19709
|
-
|
|
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
|
-
|
|
19714
|
-
|
|
19715
|
-
|
|
19716
|
-
|
|
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(
|
|
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
|
-
|
|
20588
|
+
process.stdout.write(` then: ${tendrilCommand("figma-connect --wait")}
|
|
19728
20589
|
`);
|
|
19729
20590
|
}
|
|
19730
20591
|
);
|
|
19731
20592
|
return;
|
|
19732
20593
|
}
|
|
19733
|
-
|
|
19734
|
-
|
|
19735
|
-
|
|
19736
|
-
|
|
19737
|
-
|
|
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
|
-
|
|
19740
|
-
|
|
19741
|
-
|
|
19742
|
-
|
|
19743
|
-
|
|
19744
|
-
|
|
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
|
-
|
|
19758
|
-
|
|
19759
|
-
|
|
19760
|
-
|
|
19761
|
-
|
|
19762
|
-
|
|
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 (
|
|
19768
|
-
|
|
19769
|
-
|
|
19770
|
-
|
|
19771
|
-
|
|
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
|
-
|
|
19793
|
-
|
|
19794
|
-
|
|
19795
|
-
|
|
19796
|
-
|
|
19797
|
-
|
|
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
|
-
|
|
19801
|
-
|
|
19802
|
-
|
|
19803
|
-
|
|
19804
|
-
|
|
19805
|
-
|
|
19806
|
-
|
|
19807
|
-
|
|
19808
|
-
|
|
19809
|
-
|
|
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
|
-
|
|
19828
|
-
|
|
19829
|
-
|
|
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
|
-
|
|
19853
|
-
|
|
19854
|
-
|
|
19855
|
-
|
|
19856
|
-
|
|
19857
|
-
|
|
19858
|
-
|
|
19859
|
-
|
|
19860
|
-
|
|
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 (
|
|
19864
|
-
fail(opts, ExitCode.
|
|
19865
|
-
error:
|
|
19866
|
-
code: "
|
|
19867
|
-
remediation:
|
|
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
|
-
|
|
19871
|
-
|
|
19872
|
-
|
|
19873
|
-
|
|
19874
|
-
|
|
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
|
-
|
|
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
|
|
20726
|
+
error: "no portal to share from",
|
|
19882
20727
|
code: "no-portal-configured",
|
|
19883
|
-
remediation: `Pass \`--to <url>\` or set TENDRIL_PORTAL_URL
|
|
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}`)}
|
|
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
|
-
|
|
19909
|
-
|
|
19910
|
-
|
|
19911
|
-
|
|
19912
|
-
|
|
19913
|
-
|
|
19914
|
-
|
|
19915
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 (
|
|
19984
|
-
|
|
19985
|
-
|
|
19986
|
-
|
|
19987
|
-
|
|
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
|
|
19991
|
-
const
|
|
19992
|
-
|
|
19993
|
-
|
|
19994
|
-
|
|
19995
|
-
|
|
19996
|
-
|
|
19997
|
-
|
|
19998
|
-
|
|
19999
|
-
|
|
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
|
-
|
|
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
|
-
|
|
20024
|
-
|
|
20025
|
-
const
|
|
20026
|
-
|
|
20027
|
-
|
|
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
|
-
|
|
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
|
|
20037
|
-
|
|
20038
|
-
|
|
20039
|
-
|
|
20040
|
-
|
|
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
|
|
20051
|
-
|
|
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
|
|
20089
|
-
import
|
|
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 =
|
|
20093
|
-
const recordedAsPath =
|
|
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 =
|
|
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 =
|
|
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") || !
|
|
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:
|
|
20212
|
-
process.stdout.write(`dry-run: nothing sent, nothing written (would write ${
|
|
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 =
|
|
20236
|
-
if (
|
|
20997
|
+
const bundleDir = path59.join(outDirAbs, taskName);
|
|
20998
|
+
if (existsSync49(path59.join(bundleDir, "component.json"))) {
|
|
20237
20999
|
try {
|
|
20238
|
-
const prior = readBundleManifest(
|
|
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:
|
|
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,
|