@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 +5 -1
- package/dist/cli.js +27 -7
- package/package.json +1 -1
- package/web/app.js +12 -5
- package/web/embed.js +5 -3
- package/web/embed.test.js +6 -0
- package/web/view-url.js +15 -2
- package/web/view-url.test.js +26 -1
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
|
-
|
|
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 =
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
66
|
-
|
|
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
|
-
|
|
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 };
|
package/web/view-url.test.js
CHANGED
|
@@ -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"]);
|