@tokenoftrust/storefront-runner 1.3.4-rc.2 → 1.3.4-rc.4
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.
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Per-DEVELOPER "startup obstacle" — the FAILURE half of the local→hosted dev
|
|
3
|
+
* bridge (v2 §6). Where runtimeStore.ts holds the latest SUCCESSFUL heartbeat and
|
|
4
|
+
* activityStore.ts the file-save ring buffer, this holds the latest reason a
|
|
5
|
+
* developer's local `tot start` could NOT come up: a Node-version floor miss, a
|
|
6
|
+
* missing pnpm, a failed install/clone. The hosted cockpit reads it to turn the
|
|
7
|
+
* bridge strip into an actionable obstacle card ("This machine is running Node
|
|
8
|
+
* 20.17 — needs 22.12+; fix: …") instead of spinning forever on "watching…".
|
|
9
|
+
*
|
|
10
|
+
* WHY ITS OWN KEY (not folded into the runtime record): an obstacle is PRECISELY
|
|
11
|
+
* the state where no successful heartbeat exists, so it must never carry an
|
|
12
|
+
* `aliveAt` that liveness reads — mixing it into the runtime record would flicker
|
|
13
|
+
* the loop "live". It is its own latest-wins slot, cleared the moment a real
|
|
14
|
+
* heartbeat lands (writeRuntime → clearObstacle) and on a ?reset.
|
|
15
|
+
*
|
|
16
|
+
* SCOPED PER (tenant, developer email), exactly like the runtime record
|
|
17
|
+
* (commit 0d4bb7d): the obstacle is beaconed with the developer's OWN
|
|
18
|
+
* activity-ingest token — carried in the setup command's argv — so a machine that
|
|
19
|
+
* FAILS the Node gate (no session, no MCP, maybe no global `fetch`) can still
|
|
20
|
+
* report it. The viewer's own GET (their email) reads their own obstacle; a
|
|
21
|
+
* teammate's failure is theirs.
|
|
22
|
+
*
|
|
23
|
+
* REMEDIATION COPY LIVES HERE (server-side), keyed by `kind` — so the fix-it
|
|
24
|
+
* guidance changes without shipping a new CLI. The CLI reports only a stable,
|
|
25
|
+
* machine-readable {kind, have, need}; the human sentence is composed here.
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
/** The failure modes `tot start` can beacon. Stable machine-readable contract —
|
|
29
|
+
* the CLI emitter (a bounded follow-up) POSTs one of these; the human copy is
|
|
30
|
+
* composed server-side in describeObstacle(). */
|
|
31
|
+
export const OBSTACLE_KINDS = [
|
|
32
|
+
"node-too-old",
|
|
33
|
+
"pnpm-missing",
|
|
34
|
+
"install-failed",
|
|
35
|
+
"clone-failed",
|
|
36
|
+
] as const;
|
|
37
|
+
export type ObstacleKind = (typeof OBSTACLE_KINDS)[number];
|
|
38
|
+
|
|
39
|
+
/** The latest startup obstacle a developer's local loop reported. */
|
|
40
|
+
export interface DevObstacle {
|
|
41
|
+
kind: ObstacleKind;
|
|
42
|
+
/** The version the machine HAS, when the kind carries one (node-too-old). e.g. "20.17.0". */
|
|
43
|
+
have?: string;
|
|
44
|
+
/** The version the loop NEEDS, when the beacon knows it. e.g. "22.12.0". */
|
|
45
|
+
need?: string;
|
|
46
|
+
/** The `tot` CLI version that reported the obstacle, when carried. */
|
|
47
|
+
cliVersion?: string;
|
|
48
|
+
/** Epoch ms the obstacle was reported, stamped by the SERVER at ingest (never
|
|
49
|
+
* the client `ts` — skew-proof, matching the runtime record's aliveAt). */
|
|
50
|
+
reportedAt: number;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** The obstacle as the GET feed exposes it: the stored record PLUS the
|
|
54
|
+
* server-composed remediation copy the cockpit renders verbatim. */
|
|
55
|
+
export interface DevObstacleView extends DevObstacle {
|
|
56
|
+
/** Short label for the bridge kicker, e.g. "Node version too old". */
|
|
57
|
+
title: string;
|
|
58
|
+
/** One-line human explanation, with have/need interpolated. */
|
|
59
|
+
message: string;
|
|
60
|
+
/** The single shell command that fixes it, when there is one (rendered in mono). */
|
|
61
|
+
fixCommand?: string;
|
|
62
|
+
/** The instruction around the fix command (or the whole fix, when there is no command). */
|
|
63
|
+
fixNote: string;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const KEY_PREFIX = "dev-obstacle:";
|
|
67
|
+
/** Match the runtime record's TTL: an obstacle lingers long enough for the
|
|
68
|
+
* developer to see it after a failed start, then self-expires. A real heartbeat
|
|
69
|
+
* clears it well before this. */
|
|
70
|
+
const TTL_SECONDS = 3600;
|
|
71
|
+
|
|
72
|
+
/** A reported version ("20.17.0") is short; cap it for KV value-size hygiene. */
|
|
73
|
+
const MAX_VERSION_LEN = 32;
|
|
74
|
+
const MAX_CLI_LEN = 64;
|
|
75
|
+
|
|
76
|
+
/** Astro's engines floor (enforced by CLI 1.3.4-rc.2). Used ONLY as a fallback
|
|
77
|
+
* when a node-too-old beacon omits `need`; a beacon that carries `need` wins. */
|
|
78
|
+
const NODE_FLOOR = "22.12";
|
|
79
|
+
|
|
80
|
+
/** Per-developer record key: `dev-obstacle:<tenant>:<email>` (email lowercased
|
|
81
|
+
* for a stable key, mirroring runtimeStore). */
|
|
82
|
+
function key(appDomain: string, email: string | undefined): string {
|
|
83
|
+
return `${KEY_PREFIX}${appDomain}:${(email ?? "").toLowerCase()}`;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function clip(v: unknown, max: number): string | undefined {
|
|
87
|
+
return typeof v === "string" && v.length > 0 ? v.slice(0, max) : undefined;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function isKind(v: unknown): v is ObstacleKind {
|
|
91
|
+
return typeof v === "string" && (OBSTACLE_KINDS as readonly string[]).includes(v);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Normalize a raw obstacle beacon into a stored record, or null when the `kind`
|
|
96
|
+
* is unrecognized (the route rejects that as a 400 — an unknown kind has no
|
|
97
|
+
* remediation copy, so storing it would render a blank card). Pure + exported so
|
|
98
|
+
* the route's validation is unit-tested without KV. have/need/cliVersion are
|
|
99
|
+
* bounded strings; `reportedAt` is the SERVER clock, never the client's `ts`.
|
|
100
|
+
*/
|
|
101
|
+
export function shapeObstacle(
|
|
102
|
+
body: { kind?: unknown; have?: unknown; need?: unknown; cliVersion?: unknown },
|
|
103
|
+
reportedAt: number,
|
|
104
|
+
): DevObstacle | null {
|
|
105
|
+
if (!isKind(body.kind)) return null;
|
|
106
|
+
const rec: DevObstacle = { kind: body.kind, reportedAt };
|
|
107
|
+
const have = clip(body.have, MAX_VERSION_LEN);
|
|
108
|
+
const need = clip(body.need, MAX_VERSION_LEN);
|
|
109
|
+
const cliVersion = clip(body.cliVersion, MAX_CLI_LEN);
|
|
110
|
+
if (have) rec.have = have;
|
|
111
|
+
if (need) rec.need = need;
|
|
112
|
+
if (cliVersion) rec.cliVersion = cliVersion;
|
|
113
|
+
return rec;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/** Read a developer's latest obstacle (their tenant + email), or null when
|
|
117
|
+
* none/malformed/unknown-kind. Never throws. */
|
|
118
|
+
export async function readObstacle(
|
|
119
|
+
kv: KVNamespace,
|
|
120
|
+
appDomain: string,
|
|
121
|
+
email: string | undefined,
|
|
122
|
+
): Promise<DevObstacle | null> {
|
|
123
|
+
try {
|
|
124
|
+
const raw = await kv.get(key(appDomain, email));
|
|
125
|
+
if (!raw) return null;
|
|
126
|
+
const parsed = JSON.parse(raw);
|
|
127
|
+
if (
|
|
128
|
+
!parsed ||
|
|
129
|
+
typeof parsed !== "object" ||
|
|
130
|
+
typeof parsed.reportedAt !== "number" ||
|
|
131
|
+
!isKind(parsed.kind)
|
|
132
|
+
) {
|
|
133
|
+
return null;
|
|
134
|
+
}
|
|
135
|
+
return parsed as DevObstacle;
|
|
136
|
+
} catch {
|
|
137
|
+
return null;
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/** Overwrite a developer's obstacle with their latest beacon (latest-wins, TTL'd),
|
|
142
|
+
* scoped to (tenant, email). */
|
|
143
|
+
export async function writeObstacle(
|
|
144
|
+
kv: KVNamespace,
|
|
145
|
+
appDomain: string,
|
|
146
|
+
email: string | undefined,
|
|
147
|
+
rec: DevObstacle,
|
|
148
|
+
): Promise<void> {
|
|
149
|
+
await kv.put(key(appDomain, email), JSON.stringify(rec), { expirationTtl: TTL_SECONDS });
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/** Delete a developer's obstacle record — a live heartbeat supersedes it, and a
|
|
153
|
+
* ?reset clears it. Idempotent; never throws on an absent key. */
|
|
154
|
+
export async function clearObstacle(
|
|
155
|
+
kv: KVNamespace,
|
|
156
|
+
appDomain: string,
|
|
157
|
+
email: string | undefined,
|
|
158
|
+
): Promise<void> {
|
|
159
|
+
await kv.delete(key(appDomain, email));
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* Compose the human remediation copy for an obstacle, keyed by kind — SERVER-side
|
|
164
|
+
* so the fix-it guidance changes without a CLI release. Pure. The exhaustive
|
|
165
|
+
* switch means adding a new ObstacleKind is a compile error here until its copy
|
|
166
|
+
* exists (so no kind can ship without a fix message).
|
|
167
|
+
*/
|
|
168
|
+
export function describeObstacle(rec: DevObstacle): {
|
|
169
|
+
title: string;
|
|
170
|
+
message: string;
|
|
171
|
+
fixCommand?: string;
|
|
172
|
+
fixNote: string;
|
|
173
|
+
} {
|
|
174
|
+
switch (rec.kind) {
|
|
175
|
+
case "node-too-old": {
|
|
176
|
+
const need = rec.need || NODE_FLOOR;
|
|
177
|
+
const message = rec.have
|
|
178
|
+
? `This machine is running Node ${rec.have} — the dev loop needs ${need} or newer.`
|
|
179
|
+
: `The dev loop needs Node ${need} or newer, and this machine's version is too old.`;
|
|
180
|
+
return {
|
|
181
|
+
title: "Node version too old",
|
|
182
|
+
message,
|
|
183
|
+
fixCommand: "nvm install 24 && nvm use 24",
|
|
184
|
+
fixNote: "then re-run the setup command above. No nvm? Install Node 24 LTS from nodejs.org.",
|
|
185
|
+
};
|
|
186
|
+
}
|
|
187
|
+
case "pnpm-missing":
|
|
188
|
+
return {
|
|
189
|
+
title: "pnpm not found",
|
|
190
|
+
message: "The dev loop needs pnpm, which isn't installed on this machine.",
|
|
191
|
+
fixCommand: "npm install -g pnpm",
|
|
192
|
+
fixNote: "then re-run the setup command above.",
|
|
193
|
+
};
|
|
194
|
+
case "install-failed":
|
|
195
|
+
return {
|
|
196
|
+
title: "Install failed",
|
|
197
|
+
message: "Installing the dev runner didn't finish on this machine.",
|
|
198
|
+
fixNote:
|
|
199
|
+
"Check your terminal for the npm or network error, then re-run the setup command. If it keeps failing, send us that output.",
|
|
200
|
+
};
|
|
201
|
+
case "clone-failed":
|
|
202
|
+
return {
|
|
203
|
+
title: "Couldn't fetch your store",
|
|
204
|
+
message: "The dev loop couldn't download your store's starter files.",
|
|
205
|
+
fixNote: "Check your network and the terminal output, then re-run the setup command.",
|
|
206
|
+
};
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/** Attach the server-composed remediation copy to a stored obstacle — the
|
|
211
|
+
* developer-facing view the GET feed returns. Pure. */
|
|
212
|
+
export function toObstacleView(rec: DevObstacle): DevObstacleView {
|
|
213
|
+
return { ...rec, ...describeObstacle(rec) };
|
|
214
|
+
}
|
|
@@ -750,12 +750,42 @@ const hostedActivityScript = `
|
|
|
750
750
|
var bKicker = document.getElementById("bKicker");
|
|
751
751
|
var bHint = document.getElementById("bHint");
|
|
752
752
|
var bDiag = document.getElementById("bDiag");
|
|
753
|
+
var bTxt = document.getElementById("bTxt");
|
|
754
|
+
var bObstacle = document.getElementById("bObstacle");
|
|
755
|
+
var bObTitle = document.getElementById("bObTitle");
|
|
756
|
+
var bObMsg = document.getElementById("bObMsg");
|
|
757
|
+
var bObCmd = document.getElementById("bObCmd");
|
|
758
|
+
var bObNote = document.getElementById("bObNote");
|
|
753
759
|
var doneRunMeta = document.getElementById("doneRunMeta");
|
|
754
|
-
function updateBridge(rt) {
|
|
760
|
+
function updateBridge(rt, obstacle) {
|
|
755
761
|
if (!bridge) return;
|
|
756
|
-
|
|
757
|
-
var live = !!rt.live;
|
|
762
|
+
var live = !!(rt && rt.live);
|
|
758
763
|
if (live) sawLive = true;
|
|
764
|
+
// Obstacle lane (v2 §6): a failed \`tot start\` is the ONE reason the strip
|
|
765
|
+
// shows BEFORE the loop was ever live this page-view — the developer needs the
|
|
766
|
+
// fix, not a silent "watching…". The server returns \`obstacle\` only when there
|
|
767
|
+
// is no live heartbeat, so a live loop supersedes it. Fill from server copy.
|
|
768
|
+
if (obstacle && !live) {
|
|
769
|
+
bridge.hidden = false;
|
|
770
|
+
bridge.setAttribute("data-live", "obstacle");
|
|
771
|
+
if (bTxt) bTxt.hidden = true;
|
|
772
|
+
if (bDiag) bDiag.hidden = true;
|
|
773
|
+
if (bGo) bGo.hidden = true;
|
|
774
|
+
if (bHint) bHint.hidden = true;
|
|
775
|
+
if (bObstacle) bObstacle.hidden = false;
|
|
776
|
+
if (bObTitle) bObTitle.textContent = obstacle.title || "Setup hit a snag";
|
|
777
|
+
if (bObMsg) bObMsg.textContent = obstacle.message || "";
|
|
778
|
+
if (bObCmd) {
|
|
779
|
+
if (obstacle.fixCommand) { bObCmd.textContent = obstacle.fixCommand; bObCmd.hidden = false; }
|
|
780
|
+
else bObCmd.hidden = true;
|
|
781
|
+
}
|
|
782
|
+
if (bObNote) bObNote.textContent = obstacle.fixNote || "";
|
|
783
|
+
return;
|
|
784
|
+
}
|
|
785
|
+
// Back to the normal live/quiet strip: restore the pieces the obstacle state hid.
|
|
786
|
+
if (bObstacle) bObstacle.hidden = true;
|
|
787
|
+
if (bTxt) bTxt.hidden = false;
|
|
788
|
+
if (!rt || !rt.url) { bridge.hidden = true; return; }
|
|
759
789
|
// On FIRST arrival, a stale runtime record (offline, never seen live this
|
|
760
790
|
// page-view — e.g. left over from a prior run, still within the heartbeat TTL)
|
|
761
791
|
// must NOT show — let Step 1 "Get your store running" do the work. Only surface
|
|
@@ -1088,7 +1118,7 @@ const hostedActivityScript = `
|
|
|
1088
1118
|
var rt = d && d.runtime;
|
|
1089
1119
|
var isLive = !!(rt && rt.live);
|
|
1090
1120
|
liveNow = isLive; // drives Step 1 minimize + the Step-2 teaser visibility below
|
|
1091
|
-
updateBridge(rt); // same poll drives the bridge strip
|
|
1121
|
+
updateBridge(rt, d && d.obstacle); // same poll drives the bridge strip (+ obstacle lane)
|
|
1092
1122
|
render(events);
|
|
1093
1123
|
var last = events.length ? events[events.length - 1] : null;
|
|
1094
1124
|
var latest = last ? last.at : 0;
|
|
@@ -1344,6 +1374,25 @@ const hostedActivityScript = `
|
|
|
1344
1374
|
.b-go:hover { background: var(--accent-ink); transform: translateY(-1px); }
|
|
1345
1375
|
.b-hint { flex-basis: 100%; margin: 0; font-size: .8rem; color: var(--amber); }
|
|
1346
1376
|
.b-hint code { font-family: var(--mono); }
|
|
1377
|
+
/* Obstacle lane (v2 §6): a failed `tot start` — an ERROR, so it uses the
|
|
1378
|
+
existing --warn (red) token triple (NO new palette values), which also sets
|
|
1379
|
+
it apart from the amber "went quiet". Content is a stacked block, so the
|
|
1380
|
+
beacon aligns to the top rather than centering on a tall card. */
|
|
1381
|
+
.bridge[data-live="obstacle"] { align-items: flex-start; border-color: var(--warn-line); background: var(--warn-wash); }
|
|
1382
|
+
.bridge[data-live="obstacle"] .beacon { background: var(--warn); }
|
|
1383
|
+
.b-obstacle { flex: 1 1 100%; display: flex; flex-direction: column; gap: .3rem; min-width: 0; }
|
|
1384
|
+
.b-ob-title { color: var(--warn); }
|
|
1385
|
+
.b-ob-title::before { content: "\26A0 "; } /* ⚠ warning glyph */
|
|
1386
|
+
.b-ob-msg { margin: 0; font-size: .9rem; font-weight: 600; color: var(--ink); }
|
|
1387
|
+
.b-ob-fix { margin: 0; font-size: .82rem; color: var(--muted); line-height: 1.5; }
|
|
1388
|
+
.b-ob-lead { color: var(--warn); font-weight: 700; }
|
|
1389
|
+
.b-ob-cmd { font-family: var(--mono); font-size: .8rem; background: var(--bg-2); color: var(--ink); padding: .1rem .35rem; border-radius: var(--r-sm); }
|
|
1390
|
+
/* These three set their own `display`, which (author > UA) would beat the UA
|
|
1391
|
+
[hidden]{display:none} — so the script's `.hidden` toggles silently no-op on
|
|
1392
|
+
them. Re-assert [hidden] at higher specificity so hidden means hidden: the
|
|
1393
|
+
obstacle block and the live-strip text swap cleanly, AND the quiet-state CTA
|
|
1394
|
+
(b-go) actually hides. */
|
|
1395
|
+
.b-txt[hidden], .b-go[hidden], .b-obstacle[hidden] { display: none; }
|
|
1347
1396
|
@media (prefers-reduced-motion: reduce) { .bridge[data-live="1"], .beacon, .b-go { animation: none !important; transition: none; } }
|
|
1348
1397
|
@media (max-width: 34rem) { .b-diag { display: none; } }
|
|
1349
1398
|
|
|
@@ -1866,13 +1915,22 @@ const hostedActivityScript = `
|
|
|
1866
1915
|
{hostedActivity && (
|
|
1867
1916
|
<section class="bridge" id="bridge" data-live="1" hidden>
|
|
1868
1917
|
<span class="beacon" aria-hidden="true"></span>
|
|
1869
|
-
<div class="b-txt">
|
|
1918
|
+
<div class="b-txt" id="bTxt">
|
|
1870
1919
|
<span class="b-kicker" id="bKicker">Live on your machine</span>
|
|
1871
1920
|
<a class="b-url" id="bUrl" href="#" target="_blank" rel="noreferrer">http://localhost/</a>
|
|
1872
1921
|
</div>
|
|
1873
1922
|
<span class="b-diag" id="bDiag" hidden></span>
|
|
1874
1923
|
<a class="b-go" id="bGo" href="#" target="_blank" rel="noreferrer">Open your store ↗</a>
|
|
1875
1924
|
<p class="b-hint" id="bHint" hidden>Your store went quiet. Run <code>tot start</code> in your project to bring it back — Step 1 has the command.</p>
|
|
1925
|
+
{/* Obstacle lane (v2 §6): a failed `tot start` (wrong Node, missing pnpm,
|
|
1926
|
+
install/clone error) beacons why via the setup command's activity token;
|
|
1927
|
+
the strip becomes an actionable fix card. Text is set via textContent from
|
|
1928
|
+
the SERVER-composed remediation copy (obstacleStore) — never innerHTML. */}
|
|
1929
|
+
<div class="b-obstacle" id="bObstacle" hidden>
|
|
1930
|
+
<span class="b-kicker b-ob-title" id="bObTitle">Setup hit a snag</span>
|
|
1931
|
+
<p class="b-ob-msg" id="bObMsg"></p>
|
|
1932
|
+
<p class="b-ob-fix"><span class="b-ob-lead">Fix:</span> <code class="b-ob-cmd" id="bObCmd" hidden></code> <span id="bObNote"></span></p>
|
|
1933
|
+
</div>
|
|
1876
1934
|
</section>
|
|
1877
1935
|
)}
|
|
1878
1936
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@tokenoftrust/storefront-runner",
|
|
3
|
-
"version": "1.3.4-rc.
|
|
3
|
+
"version": "1.3.4-rc.4",
|
|
4
4
|
"license": "SEE LICENSE IN LICENSE",
|
|
5
5
|
"description": "World-shareable storefront runner: multi-tenant renderer on Astro/Cloudflare. No control plane.",
|
|
6
6
|
"packageManager": "pnpm@11.9.0",
|