@intentius/behold 0.21.0 → 0.22.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -340,7 +340,11 @@ any theme name the picker lists:
340
340
 
341
341
  `gates=<env>` sets the env the gate strip reads, apart from the graph's, so a
342
342
  host can open behold on the source graph and still show the gates of the env
343
- it watches.
343
+ it watches. A host whose env changes posts `{type: "behold:view", gates: "<env>"}`
344
+ instead of reloading the frame: behold checks the name as it checks the URL's,
345
+ reads that env's gates at once, and writes `gates=` back into the URL so a
346
+ reload keeps it. `gates: null` (or `""`) hands the strip back to the graph's
347
+ env.
344
348
 
345
349
  behold binds 127.0.0.1 and answers only loopback names, since it runs writes
346
350
  with the credentials of whoever started it. A page from another site can't
package/dist/cli.js CHANGED
@@ -6600,14 +6600,30 @@ function forMember(dir, opts) {
6600
6600
  }
6601
6601
  var memberSource = (dir, opts) => memberIr(dir, forMember(dir, opts), memberViaFor(dir));
6602
6602
  var memberLive = (dir, opts, fresh) => overlayIr(dir, forMember(dir, opts), memberViaFor(dir), fresh);
6603
+ async function memberSourceOrBox(dir, opts) {
6604
+ try {
6605
+ return joinCarvedSources(await memberSource(dir, opts), (await carveStatesFor(dir)).values(), dir).ir;
6606
+ } catch (err) {
6607
+ const box = declaredMemberBox(dir, err);
6608
+ if (!box) throw err;
6609
+ return box;
6610
+ }
6611
+ }
6612
+ function declaredMemberBox(dir, err) {
6613
+ const m = servedWorkspace() ? workspaceMemberOf(dir) : void 0;
6614
+ if (!m) return void 0;
6615
+ const said4 = (e) => {
6616
+ const msg = e instanceof Error ? e.message : String(e);
6617
+ return (msg.match(/exited \d+:\s*([\s\S]*)/)?.[1] ?? msg).trim().slice(0, 600);
6618
+ };
6619
+ const reason = err instanceof WorkspaceMemberError ? err.reason : { code: "command-failed", message: said4(err) };
6620
+ return unreadableMemberIr({ ...m, reason: { ...reason, message: reason.message.replace(/\u001b\[[0-9;]*m/g, "") } });
6621
+ }
6603
6622
  async function composeEstate(projectDirs, opts = {}) {
6604
6623
  const names = estateMemberNames(projectDirs);
6605
6624
  const stacks = await mapPool(projectDirs, estateReadPool(projectDirs.length), async (dir, i) => ({
6606
6625
  name: names[i],
6607
- // chant#2040: the manifests a member holds name the files its carved
6608
- // entities were emitted into — joined here, where the member's root is
6609
- // known, onto a COPY (the member IR is the #312 cache's own object).
6610
- ir: joinCarvedSources(await memberSource(dir, opts), (await carveStatesFor(dir)).values(), dir).ir
6626
+ ir: await memberSourceOrBox(dir, opts)
6611
6627
  }));
6612
6628
  return composeStacks([...stacks, ...unreadableStacks(projectDirs)]);
6613
6629
  }
@@ -6615,8 +6631,8 @@ async function composeEstatePending(projectDirs, opts = {}) {
6615
6631
  const names = estateMemberNames(projectDirs);
6616
6632
  const { env: _env, live: _live, overlay: _overlay, ...srcOpts } = opts;
6617
6633
  const stacks = await mapPool(projectDirs, estateReadPool(projectDirs.length), async (dir, i) => {
6618
- const ir = joinCarvedSources(await memberSource(dir, srcOpts), (await carveStatesFor(dir)).values(), dir).ir;
6619
- for (const n of ir.nodes) n.attrs = { ...n.attrs, _pendingRead: true };
6634
+ const ir = await memberSourceOrBox(dir, srcOpts);
6635
+ for (const n of ir.nodes) if (n.kind !== "UnreadableMember") n.attrs = { ...n.attrs, _pendingRead: true };
6620
6636
  return { name: names[i], ir };
6621
6637
  });
6622
6638
  return composeStacks([...stacks, ...unreadableStacks(projectDirs)]);
@@ -6757,7 +6773,11 @@ async function composeEstateOverlay(projectDirs, opts, classify, observe = {}) {
6757
6773
  observe.onMember?.({ name, dir, ir: src, unobserved: reason });
6758
6774
  unobserved.push({ name, reason });
6759
6775
  } catch (err2) {
6760
- dropped.push({ name, reason: firstLine2(err2) });
6776
+ const box = declaredMemberBox(dir, err2);
6777
+ if (box) {
6778
+ stacks[i] = { name, ir: box };
6779
+ observe.onMember?.({ name, dir, ir: box });
6780
+ } else dropped.push({ name, reason: firstLine2(err2) });
6761
6781
  }
6762
6782
  }
6763
6783
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@intentius/behold",
3
- "version": "0.21.0",
3
+ "version": "0.22.0",
4
4
  "license": "Apache-2.0",
5
5
  "type": "module",
6
6
  "description": "behold — a live control plane on chant. See your whole estate (every substrate in one graph), coloured by drift; act through delegated, gated Ops.",
package/web/app.js CHANGED
@@ -1284,7 +1284,7 @@ function wire(ir) {
1284
1284
  // member (#475) is the estate member the view is on: a host opens behold with
1285
1285
  // ?member=, and picking a card or a member box moves it. It changes no fetch;
1286
1286
  // the graph is the whole estate, framed on that member's box.
1287
- const view = { member: null, env: null, detail: 2, components: true, logical: false, runtime: false, ops: false, tier: null, target: null, stack: null, radial: false, collapse: false, compareTo: null };
1287
+ const view = { member: null, env: null, gates: null, detail: 2, components: true, logical: false, runtime: false, ops: false, tier: null, target: null, stack: null, radial: false, collapse: false, compareTo: null };
1288
1288
 
1289
1289
  // #182: `components` is the boot default, but a project that declares no
1290
1290
  // components renders it as ZERO nodes — the first screen was a blank graph
@@ -2173,6 +2173,7 @@ function applyWantedView(want) {
2173
2173
  }
2174
2174
  if (apply.lens) colourMode = apply.lens; // not written to localStorage: the link's pick, not yours
2175
2175
  if ("radial" in apply) view.radial = apply.radial;
2176
+ if ("gates" in apply) view.gates = apply.gates;
2176
2177
  if (want.member === null) {
2177
2178
  view.member = null;
2178
2179
  selectedNodeId = null;
@@ -2195,7 +2196,12 @@ window.addEventListener("message", (e) => {
2195
2196
  const want = viewFromMessage(e, embedOpts.host);
2196
2197
  if (!want) return;
2197
2198
  const lensBefore = colourMode;
2198
- if (applyWantedView(want)) {
2199
+ const gatesBefore = view.gates;
2200
+ const moved = applyWantedView(want);
2201
+ // #477: the host's env for the gate strip moved. Read it now, not on the
2202
+ // next `changed`; renderStatusbar below writes it into the URL.
2203
+ if (view.gates !== gatesBefore) loadWorkspaceGates({ force: true });
2204
+ if (moved) {
2199
2205
  resetDialCaches();
2200
2206
  renderStatusbar();
2201
2207
  load();
@@ -2228,10 +2234,10 @@ function embedSelected(member, node) {
2228
2234
  let wsGates = { env: undefined, answer: null, seq: 0 };
2229
2235
  // ?gates=<env> (#477): the env the gate strip reads, apart from the graph's.
2230
2236
  // A host that opens behold on the source graph still wants the gates of the
2231
- // env its block watches.
2232
- const gatesEnvParam = new URLSearchParams(location.search).get("gates") || "";
2237
+ // env its block watches. It is a view key (view-url.js), so the link sets it,
2238
+ // a host's `behold:view` moves it, and the URL keeps it.
2233
2239
  function gatesEnv() {
2234
- return gatesEnvParam || view.env || "";
2240
+ return view.gates || view.env || "";
2235
2241
  }
2236
2242
  function loadWorkspaceGates({ force = false } = {}) {
2237
2243
  if (staticMode) return;
@@ -2429,6 +2435,7 @@ function syncViewUrl() {
2429
2435
  lens: colourMode,
2430
2436
  node: place.node || selectedNodeId,
2431
2437
  radial: view.radial && !view.components && !view.logical && !view.ops,
2438
+ gates: view.gates,
2432
2439
  };
2433
2440
  const defaults = { env: (projectInfo && projectInfo.currentEnv) || null, lens: savedColourMode };
2434
2441
  const next = location.pathname + writeViewQuery(location.search, state, defaults) + location.hash;
package/web/embed.js CHANGED
@@ -61,9 +61,11 @@ export function viewFromMessage(event, host) {
61
61
  if (typeof v === "boolean") want.radial = v;
62
62
  continue;
63
63
  }
64
- if (k === "env") {
65
- if (v === null || v === "") want.env = null;
66
- else if (typeof v === "string") want.env = v;
64
+ if (k === "env" || k === "gates") {
65
+ // env: null or "" is the source graph. gates: null or "" hands the
66
+ // gate strip back to the graph's env.
67
+ if (v === null || v === "") want[k] = null;
68
+ else if (typeof v === "string") want[k] = v;
67
69
  continue;
68
70
  }
69
71
  if (k === "member" && v === null) {
package/web/embed.test.js CHANGED
@@ -37,6 +37,12 @@ describe("viewFromMessage (#476)", () => {
37
37
  it("reads member null as the whole estate and env empty as the source", () => {
38
38
  expect(viewFromMessage({ origin: host, data: { type: "behold:view", member: null, env: "" } }, host)).toEqual({ member: null, env: null });
39
39
  });
40
+ it("takes gates, the gate strip's env, and null or empty as following the graph's env (#477)", () => {
41
+ expect(viewFromMessage({ origin: host, data: { type: "behold:view", gates: "prod" } }, host)).toEqual({ gates: "prod" });
42
+ expect(viewFromMessage({ origin: host, data: { type: "behold:view", gates: "" } }, host)).toEqual({ gates: null });
43
+ expect(viewFromMessage({ origin: host, data: { type: "behold:view", gates: null } }, host)).toEqual({ gates: null });
44
+ expect(viewFromMessage({ origin: host, data: { type: "behold:view", gates: 3 } }, host)).toEqual({});
45
+ });
40
46
  it("ignores another origin, another type, and any message when there is no host", () => {
41
47
  expect(viewFromMessage({ origin: "http://evil", data: { type: "behold:view", member: "x" } }, host)).toBe(null);
42
48
  expect(viewFromMessage({ origin: host, data: { type: "other" } }, host)).toBe(null);
package/web/view-url.js CHANGED
@@ -8,8 +8,15 @@
8
8
  // it takes the same parameters against its captured lens matrix.
9
9
 
10
10
  /** The view's keys, in the order they are written back. Anything else in the
11
- * query (embed, theme, host, #476) is left where it is. */
12
- export const VIEW_KEYS = ["member", "zoom", "env", "tier", "lens", "node", "radial"];
11
+ * query (embed, theme, host, #476) is left where it is. `gates` is the env the
12
+ * gate strip reads when it isn't the graph's (#477); it is the view's because
13
+ * a host can move it with `behold:view`, and a reload has to keep that. */
14
+ export const VIEW_KEYS = ["member", "zoom", "env", "tier", "lens", "node", "radial", "gates"];
15
+
16
+ /** An env name as the server takes one (src/workspace-gates.ts isEnvName). */
17
+ export function isEnvName(env) {
18
+ return typeof env === "string" && /^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(env);
19
+ }
13
20
 
14
21
  /** The colour lenses (web/behaviour-scale.js COLOUR_MODES). `lens` in the URL
15
22
  * is the colour the graph is painted in. */
@@ -63,6 +70,7 @@ export function writeViewQuery(search, state, defaults = {}) {
63
70
  if (s.lens && s.lens !== lensDefault) q.set("lens", s.lens);
64
71
  if (s.node) q.set("node", s.node);
65
72
  if (s.radial) q.set("radial", "1");
73
+ if (s.gates) q.set("gates", s.gates);
66
74
  const out = q.toString();
67
75
  return out ? `?${out}` : "";
68
76
  }
@@ -99,6 +107,11 @@ export function settleView(want, offer) {
99
107
  if (typeof want.radial === "boolean") apply.radial = want.radial;
100
108
  else refused.push({ key: "radial", value: want.radial, reason: "radial is 1 or 0" });
101
109
  }
110
+ if ("gates" in want) {
111
+ // null: the gate strip follows the graph's env again.
112
+ if (want.gates === null || isEnvName(want.gates)) apply.gates = want.gates;
113
+ else refused.push({ key: "gates", value: want.gates, reason: "an env name is letters, digits, '.', '_' and '-'" });
114
+ }
102
115
  if (want.member) apply.member = want.member;
103
116
  if (want.node) apply.node = want.node;
104
117
  return { apply, refused };
@@ -1,5 +1,5 @@
1
1
  import { describe, expect, it } from "vitest";
2
- import { readViewQuery, writeViewQuery, settleView, settlePlace, refusalLine } from "./view-url.js";
2
+ import { readViewQuery, writeViewQuery, settleView, settlePlace, refusalLine, isEnvName } from "./view-url.js";
3
3
 
4
4
  describe("readViewQuery (#475)", () => {
5
5
  it("reads the view keys and nothing else", () => {
@@ -61,6 +61,31 @@ describe("settleView (#475)", () => {
61
61
  });
62
62
  });
63
63
 
64
+ describe("gates, the gate strip's env (#477)", () => {
65
+ it("reads gates from the query, and an empty one as no override", () => {
66
+ expect(readViewQuery("?gates=prod&env=")).toEqual({ env: null, gates: "prod" });
67
+ expect(readViewQuery("?gates=")).toEqual({});
68
+ });
69
+ it("owns gates on write-back: written when set, dropped when cleared", () => {
70
+ expect(writeViewQuery("?embed=1&gates=old", { zoom: "resources", gates: "prod" })).toBe("?embed=1&zoom=resources&gates=prod");
71
+ expect(writeViewQuery("?embed=1&gates=old", { zoom: "resources", gates: null })).toBe("?embed=1&zoom=resources");
72
+ });
73
+ it("round-trips through readViewQuery", () => {
74
+ expect(readViewQuery(writeViewQuery("", { gates: "staging" }))).toEqual({ gates: "staging" });
75
+ });
76
+ it("takes an env name or null, and refuses anything chant would read as a flag", () => {
77
+ expect(settleView({ gates: "prod" }, {}).apply).toEqual({ gates: "prod" });
78
+ expect(settleView({ gates: null }, {}).apply).toEqual({ gates: null });
79
+ const { apply, refused } = settleView({ gates: "--sign" }, {});
80
+ expect(apply).toEqual({});
81
+ expect(refused).toEqual([{ key: "gates", value: "--sign", reason: "an env name is letters, digits, '.', '_' and '-'" }]);
82
+ });
83
+ it("matches the server's env-name check", () => {
84
+ for (const ok of ["local", "prod-eu", "a.b_c", "0"]) expect(isEnvName(ok)).toBe(true);
85
+ for (const bad of ["", "-x", ".x", "a b", "a/b", null]) expect(isEnvName(bad)).toBe(false);
86
+ });
87
+ });
88
+
64
89
  describe("settlePlace (#475)", () => {
65
90
  const members = new Set(["delivery", "app"]);
66
91
  const nodes = new Set(["delivery/appService", "app/x"]);