@ogygia/cli 0.11.5 → 0.13.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/ai/SKILL.md CHANGED
@@ -147,12 +147,12 @@ Each island is its own hydration root. Props cross by **value** (devalue), never
147
147
  ## Timing and nesting
148
148
 
149
149
  - `interaction` on an island: zero JS until first pointer/key/focus **inside** the region. The waking click is captured and replayed; typing survives; hover prefetches the chunk. Caveat: the replayed event is not a trusted gesture — `window.open`/clipboard/fullscreen will be blocked; detect with `hydratedBy() === 'interaction'` and render e.g. a real `<a target="_blank">`.
150
- - `interaction` on a server island = an **on-demand hole**: zero bytes until the visitor shows intent inside it — the first hover (`pointerover`, which bubbles through the boxless `display:contents` wrapper from a child), focus, touch, or key — then ONE fetch, **morphed** in so what the visitor already opened in the static fallback stays open (no click capture/replay — the fallback handles the gesture natively). Hover is the trigger, so the HTML is usually there before the visitor reaches a submenu. To have it there BEFORE the first hover, add `prefetch: 'idle'` (or `load` / `visible` / a media query — deferred-only, never `interaction`): the HTML is warmed into the frame store at that schedule and the hover swaps from it with no request. The shape for a mega menu: fallback = the menu's L1/L2 (today's SSR HTML, byte-identical), hole = the same menu with L3/L4 rendered on the server — no L4 in the initial HTML, no menu JS, no feed download in the browser.
150
+ - `interaction` on a server island = an **on-demand hole**: zero bytes until the visitor shows intent inside it — the first hover (`pointerover`, which bubbles through the boxless `display:contents` wrapper from a child), focus, touch, or key — then ONE fetch, **morphed** in so what the visitor already opened in the static fallback stays open (no click capture/replay — the fallback handles the gesture natively). Hover is the trigger, so the HTML is usually there before the visitor reaches a submenu. To have it there BEFORE the first hover, add `prefetch: 'idle'` (or `load` / `visible` / a media query — deferred-only, never `interaction`): the HTML is warmed into the frame store at that schedule and the hover swaps from it with no request. The shape for a nav menu: fallback = the menu's L1/L2 (today's SSR HTML, byte-identical), hole = the same menu with L3/L4 rendered on the server — no L4 in the initial HTML, no menu JS, no feed download in the browser.
151
151
  - Nesting — one rule, **closest marked parent decides**: island-in-island shares the parent's JS (one tree, child's schedule ignored, dev warns); lake-in-island stays static; island-in-lake wakes on its own; server-island-in-island renders inline (`deferred` ignored); an island inside a server island's `ogygiaFallback` is a full region that wakes on its own (the fallback belongs to the page, and a `keepFallback()`-kept fallback stays interactive).
152
152
  - **An island hydrates against its own server markup.** The element keeps the HTML it connected with; if another script edited the island while it slept (a design-system runtime stripping whitespace, an A/B tool, a translator) and Svelte's walk mismatches on wake, the runtime puts the server markup back and hydrates that (`data-og-healed`, dev warning) instead of re-rendering client-side; only a second failure falls back to Svelte's recovery (`data-og-recovered`). Foreign scripts should still leave `<ogygia-region>` subtrees alone — whatever they added is gone after a heal.
153
153
  - **A lake on a csr=true (Kit-hydrated) page is adopted, not hydrated.** Kit's pass keeps the lake's SSR element as opaque DOM (its component ships no client JS — not even in Kit's bundle), and everything authored INSIDE the lake stays in ogygia's world: islands and server islands there emit real regions and wake on the runtime. This is how a site header (static markup + `render: 'deferred'` personal holes + a `wake: 'interaction'` search) stays zero-JS-for-anonymous on csr=true pages too: mark the header import `with { wake: 'none' }` in the layout. Caveat: a lake first mounted during a Kit client-side navigation has no server HTML and renders empty (dev warns) — keep chrome lakes in a layout that persists, or serve the route csr=false. A hole rendered OUTSIDE a lake on a csr=true page is Kit-hydrated around; if Kit gives up hydrating the document (`Failed to hydrate`: a component threw) and mounts it fresh, the hole is rendered again with no address — the runtime restores it from the document's holes record (by identity, `data-og-hole`), so it still fetches; a hole rendered only in the browser has no record and keeps its fallback.
154
154
  - Dedupe: same component + same strategy/options = ONE wrapper module and ONE client chunk, however many instances (each instance still gets its own region + props payload).
155
- - A hole's timing is `fetch`, never `wake`: `with { render: 'deferred', fetch: 'visible' }`. Any `wake` on a `render: 'deferred'` import fails the build, naming the `fetch` to write; a `fetch` without `render: 'deferred'` fails too.
155
+ - A hole's timing is `fetch`, never `wake`: `with { render: 'deferred', fetch: 'visible' }`. A `wake` on a `render: 'deferred'` import is deprecated: it still times the hole, with a build warning naming the `fetch` to write (both at once fails); a `fetch` without `render: 'deferred'` fails too.
156
156
 
157
157
  ## csr=false lifecycle rules
158
158
 
@@ -317,7 +317,7 @@ CLI (`@ogygia/cli`; `npx ogygia …` runs it): `npx ogygia init` · `site init`
317
317
  - **Programmatic routes** take the same `config` in table grammar: `page(C, { config: { postrender: false } })`, `layout(name, C, { config })`, `routes(table, { config })` — merged table → layouts → page. The table registers a matcher at construction; the handle asks it for any request Kit's file router didn't claim (`route.id` null under `sequence(postrender.handle(), ogygia.handle(), app.handle)`, or a catch-all `+server.ts`). Endpoint routes and unclaimed paths follow the vite-config default. The router's default `cache-control: no-store` document stamp yields to the verdict on eligible requests (stored → `public, s-maxage`; refused → `private, no-store`; an explicit page header always wins).
318
318
  - Invalidation (any server code, e.g. a CMS webhook endpoint; reaches the store/edges `postrender.handle()` was given): `postrender.invalidate('/fr/fr/solar/')` (exact URL), `postrender.invalidateWhere({ prefix: '/fr/fr/' })` (subtree = locale/section nuke), `postrender.invalidate(fn, [args])` (declared sources).
319
319
  - Declared sources (the reverse index): `export const loadDoc = import.meta.og.source(async (…) => …)` — top-level `export const` only (macro laws). Every page whose render calls it files a receipt; `invalidate(loadDoc, [args])` evicts exactly the consuming pages, at origin AND every edge. `{ key: (…args) => string }` canonicalizes exotic args.
320
- - Stores: `memoryStore` (default) · `valkey(client)` · `upstash({ url, token })` · `cloudflareKv(binding)`. Edges: `akamai(cfg)` · `cloudfront(cfg)` · `cloudflare(cfg)`. Fan-out is allSettled inside ogygia — one edge down never fails a publish or a request.
320
+ - Stores: `new MemoryStore()` (default) · `new ValkeyStore(client)` · `new UpstashStore({ url, token })` · `new CloudflareKvStore(binding)`. Edges: `new AkamaiEdge(cfg)` · `new CloudFrontEdge(cfg)` · `new CloudflareEdge(cfg)`. Fan-out is allSettled inside ogygia — one edge down never fails a publish or a request.
321
321
  - The vary law is the cookie-less CDN key, formalized: DEFAULT-valued reads still store (that render IS the canonical), and once minted everyone gets the canonical copy. Truly per-visitor content belongs in a deferred hole; A/B'd pages auto-disqualify on any flag read.
322
322
  - Eligible responses are stamped `cache-control: public, s-maxage=<ttl>` plus each edge's tags (Akamai/Cloudflare prefix tags), so CDNs follow proven per-page headers instead of blanket rules. csr=true pages store the same — Kit hydrates the stored copy.
323
323
  - **Self-freshening header on a stored page** (SSR'd, no deferred fallback): mark the component `with { render: 'live', wake: 'load' }` INSIDE a small island host — its HTML bakes into the stored bytes, and on served-from-store documents (the handle stamps a doc marker) it revalidates on first mount: a fresh server render with the visitor's cookies, swapped in. Anonymous traffic mints the canonical (default-valued reads store). A live region at page top level OUTSIDE any island bakes once and never revalidates — always give it an island host (dev warns).
package/dist/mcp.js CHANGED
@@ -1,5 +1,7 @@
1
+ import { line } from "./messages.js";
1
2
  import { createInterface } from "node:readline";
2
3
  import { createRequire } from "node:module";
4
+ import { STALE, flights, scope } from "@ogygia/machine";
3
5
  import { gzipSync } from "node:zlib";
4
6
  import { readFileSync, readdirSync } from "node:fs";
5
7
  import path from "node:path";
@@ -22,15 +24,18 @@ import { CLIENT_BINDING_STUB, collect_flag_sites, flags_manifest, islandVirtualI
22
24
  * @packageDocumentation
23
25
  */
24
26
  let profiler_reading;
25
- async function load_profiler_reading() {
26
- if (profiler_reading !== void 0) return profiler_reading;
27
- try {
27
+ /** The reading's load, kept once it lands; a failed one (the plugin not installed yet) is not kept,
28
+ * so the next call loads it, with no restart of this long-lived server (#295). */
29
+ const reading_load = flights({
30
+ name: "profiler report reading",
31
+ run: async () => {
28
32
  const from_app = createRequire(path.join(process.cwd(), "package.json"));
29
- profiler_reading = await import(pathToFileURL(from_app.resolve("@ogygia/devtools/report")).href);
30
- } catch {
31
- profiler_reading = null;
33
+ return profiler_reading = await import(pathToFileURL(from_app.resolve("@ogygia/devtools/report")).href);
32
34
  }
33
- return profiler_reading;
35
+ });
36
+ async function load_profiler_reading() {
37
+ const got = await reading_load.get().catch(() => null);
38
+ return got === STALE ? null : got;
34
39
  }
35
40
  const TRAILING_SLASH_RE = /\/$/;
36
41
  const HTTP_URL_RE = /^https?:\/\//i;
@@ -243,7 +248,7 @@ const TOOLS = [
243
248
  },
244
249
  {
245
250
  name: "ogygia_profile",
246
- description: "Profile the SERVER-SIDE render of a route — runs the whole thing itself, nothing to download. ASK THE USER which URL to profile before calling (they prefer to name the host): a full URL like `http://localhost:5173/fr/fr` (local) or `https://app.com/fr/fr` (remote). Pass it as `url`. A bare path is accepted too, but then the tool just asks you for the URL (and lists any local server it can see). Records N renders and returns a digest: verdict (compute- vs io-bound), render p50, CPU findings, time-by-category, hottest functions + components (Svelte names each after its file), network, heap. Self-heals a stuck session (auto /reset + retry). Requires the profiler turned on: `devtools({ profiler: true })` from `@ogygia/devtools/vite` in the app’s vite.config (the tool tells you how if missing). Prod build = real numbers; dev is indicative (the digest says which). For a report you already downloaded, use ogygia_profile_open.",
251
+ description: "Profile the SERVER-SIDE render of a route — runs the whole thing itself, nothing to download. ASK THE USER which URL to profile before calling (they prefer to name the host): a full URL like `http://localhost:5173/fr/fr` (local) or `https://app.com/fr/fr` (remote). Pass it as `url`. A bare path is accepted too, but then the tool just asks you for the URL (and lists any local server it can see). Records N renders and returns a digest: verdict (compute- vs io-bound), render p50, CPU findings, time-by-category, hottest functions + components (Svelte names each after its file), network, heap. Clears a stale session (auto /reset + retry); leaves a live recording alone and says the profiler is busy. Requires the profiler turned on: `devtools({ profiler: true })` from `@ogygia/devtools/vite` in the app’s vite.config (the tool tells you how if missing). Prod build = real numbers; dev is indicative (the digest says which). For a report you already downloaded, use ogygia_profile_open.",
247
252
  inputSchema: {
248
253
  type: "object",
249
254
  properties: {
@@ -359,7 +364,7 @@ function tool_compile(args) {
359
364
  try {
360
365
  out = compile(source, filename, true, csr);
361
366
  } catch (e) {
362
- return fail(`[ogygia] transform error:\n${e instanceof Error ? e.message : String(e)}`);
367
+ return fail(line("cli_transform_failed", { error: e instanceof Error ? e.message : String(e) }));
363
368
  }
364
369
  const map = out.islands.length ? out.islands.map((i) => `- ${i.component} (as ${i.local}) — ${i.strategy}${i.id ? ` · id ${i.id.slice(0, 8)}` : ""}`).join("\n") : "(no marked components — the whole file is free server HTML)";
365
370
  const structured = JSON.stringify({
@@ -380,7 +385,7 @@ function tool_islands(args) {
380
385
  try {
381
386
  out = compile(source, "App.svelte", true, false);
382
387
  } catch (e) {
383
- return fail(`[ogygia] transform error:\n${e instanceof Error ? e.message : String(e)}`);
388
+ return fail(line("cli_transform_failed", { error: e instanceof Error ? e.message : String(e) }));
384
389
  }
385
390
  const structured = out.islands.map((i) => ({
386
391
  component: i.component,
@@ -415,7 +420,7 @@ function tool_explain(args) {
415
420
  try {
416
421
  out = compile(source, "App.svelte", true, false);
417
422
  } catch (e) {
418
- return fail(`[ogygia] transform error:\n${e instanceof Error ? e.message : String(e)}`);
423
+ return fail(line("cli_transform_failed", { error: e instanceof Error ? e.message : String(e) }));
419
424
  }
420
425
  if (!out.islands.length) return text("This component marks nothing, so it ships as pure server HTML — zero JavaScript, no islands.");
421
426
  const lines = out.islands.map((i) => {
@@ -815,14 +820,15 @@ const LOCAL_PORTS = [
815
820
  /** fetch with a hard deadline — a hung server can't wedge the tool. */
816
821
  async function fetch_timeout(url, opts, ms) {
817
822
  const ac = new AbortController();
818
- const t = setTimeout(() => ac.abort(), ms);
823
+ const deadline = scope();
824
+ deadline.timeout(() => ac.abort(), ms);
819
825
  try {
820
826
  return await fetch(url, {
821
827
  ...opts,
822
828
  signal: ac.signal
823
829
  });
824
830
  } finally {
825
- clearTimeout(t);
831
+ deadline.close();
826
832
  }
827
833
  }
828
834
  /** Probe localhost ports in parallel for a MOUNTED profiler; returns hits in priority order. */
@@ -842,6 +848,17 @@ async function find_local_profiler(base, key) {
842
848
  return (await Promise.all(probes)).filter((o) => !!o);
843
849
  }
844
850
  const NOT_MOUNTED = (base, where) => `The profiler is not mounted at ${base} on ${where}. Install @ogygia/devtools and turn it on in vite.config.ts:\n\n import { devtools } from '@ogygia/devtools/vite';\n plugins: [ogygia(), devtools({ profiler: true /* or { secret, path, … } */ }), sveltekit()]\n\nogygia.handle() mounts it for you — no hooks wiring needed. Restart the server, then retry.`;
851
+ /** The holder a 409 names: null when it names none (an older profiler, or a body that is not its). */
852
+ async function busy_holder(res) {
853
+ try {
854
+ return (await res.json()).holder ?? null;
855
+ } catch {
856
+ return null;
857
+ }
858
+ }
859
+ function busy_message(origin, holder) {
860
+ return `The profiler on ${origin} is busy: ${holder ? `a ${holder.kind === "window" ? "recording window" : holder.kind === "background" ? "background capture" : "page profile"} started ${Math.round(holder.age_ms / 1e3)}s ago` : "another recording"} holds it, and it may be someone else's, so ogygia_profile leaves it alone. Wait for it to end and retry (the dashboard's Reset takes it over, if it is yours and stuck).`;
861
+ }
845
862
  async function tool_profile(args) {
846
863
  const raw = String(args.url ?? "").trim();
847
864
  if (!raw) return fail("`url` is required — a route path like `/fr/fr` (profiles your local running server), or a full URL like `https://app.com/fr/fr` (profiles that host).");
@@ -895,19 +912,23 @@ async function tool_profile(args) {
895
912
  const aborted = e instanceof Error && e.name === "AbortError";
896
913
  return fail(aborted ? `Recording ${route} on ${origin} took over 90s and was aborted. Try fewer \`runs\`, or if this host times out (serverless), download the .ogp from ${origin}${profiler_base} and open it with ogygia_profile_open.` : `Recording failed: ${e instanceof Error ? e.message : String(e)}`);
897
914
  }
898
- if (res.status === 409) try {
899
- await fetch_timeout(new URL(origin + profiler_base + "/reset"), {
900
- redirect: "manual",
901
- headers: key ? { "x-profiler-key": key } : {}
902
- }, 5e3);
903
- res = await record_once();
904
- } catch {}
915
+ if (res.status === 409) {
916
+ const holder = await busy_holder(res);
917
+ if (holder?.stale !== true) return fail(busy_message(origin, holder));
918
+ try {
919
+ await fetch_timeout(new URL(origin + profiler_base + "/reset"), {
920
+ redirect: "manual",
921
+ headers: key ? { "x-profiler-key": key } : {}
922
+ }, 5e3);
923
+ res = await record_once();
924
+ } catch {}
925
+ if (res.status === 409) return fail(busy_message(origin, await busy_holder(res)));
926
+ }
905
927
  if ((res.headers.get("content-type") ?? "").includes("application/json")) {
906
928
  await load_profiler_reading();
907
929
  return text(render_profile(origin, await res.json()));
908
930
  }
909
931
  const body = (await res.text()).replace(HTML_TAG_G, " ").replace(WS_G, " ").trim().slice(0, 300);
910
- if (res.status === 409) return fail(`A profile is already running on ${origin} and /reset didn’t clear it. It auto-heals after ~2 min — wait and retry.`);
911
932
  if (res.status === 401 || res.status === 403) return fail(`The profiler on ${origin} is locked. Pass its secret as \`key\` (ask the user for it).`);
912
933
  return fail(`profiler did not return JSON (HTTP ${res.status}). ${body || "(check the profiler key / route path)"}`);
913
934
  }
@@ -1141,6 +1162,7 @@ async function tool_fragment(args) {
1141
1162
  }
1142
1163
  return text(`# ogygia fragment probe — ${origin}\n\n- routes endpoint (\`/og/fragment/page\`): ${posture}\n\n## Widgets (\`__catalog\`)\n${widgets_block}`);
1143
1164
  }
1165
+ /** Run one tool, as the server does for a `tools/call`. @internal Exported for tests. */
1144
1166
  async function dispatch_tool(name, args) {
1145
1167
  switch (name) {
1146
1168
  case "ogygia_compile": return tool_compile(args);
@@ -1242,4 +1264,4 @@ async function runMcp() {
1242
1264
  }
1243
1265
  }
1244
1266
  }
1245
- export { render_profile, runMcp };
1267
+ export { dispatch_tool, load_profiler_reading, render_profile, runMcp };
@@ -0,0 +1,75 @@
1
+ import { define_messages, prose } from "@ogygia/core/internal/messages";
2
+ // src/messages.ts
3
+ /**
4
+ * EVERY MESSAGE THE CLI PRINTS AS AN OGYGIA MESSAGE, in one place: its code, its wording, what to do
5
+ * about it (core's `@ogygia/core/internal/messages` says how the system works).
6
+ *
7
+ * fail(line('cli_transform_failed', { error }));
8
+ *
9
+ * The CLI runs in Node, never in a page, so the wording always prints, then the code's page. (The
10
+ * commands' own usage lines and `✗` failures are terminal output, not messages.)
11
+ */
12
+ /** The errors: `throw new OgygiaError(code, args)`, or `line(code, args)` where an error is answered
13
+ * as text (an MCP tool's result). */
14
+ const CLI_ERRORS = {
15
+ cli_transform_failed: {
16
+ text: (a) => prose`
17
+ transform error:
18
+
19
+ ${a.error}
20
+ `,
21
+ details: () => prose`
22
+ An MCP tool (\`ogygia_compile\`, \`ogygia_islands\`, \`ogygia_explain\`) ran ogygia's
23
+ transform on the component it was given, and the transform threw. \`error\` is the
24
+ transform's own message, which usually names the rule the component breaks and links its
25
+ page.
26
+ `
27
+ },
28
+ cli_server_port_busy: {
29
+ text: (a) => prose`
30
+ port ${a.port} (${a.url}) is still held by another process after ${a.ms} ms: the server
31
+ was not started.
32
+ `,
33
+ details: () => prose`
34
+ \`ogygia bisect\` starts each commit's server (\`--start\`) on the port of \`--url\`, and
35
+ waits for that port to be free first: a server still on it would answer the readiness
36
+ check, and bisect would measure that server instead of the commit's. Its own servers are
37
+ gone before the next one starts, so the process on the port is someone else's (a server
38
+ left running in another terminal, an earlier run): stop it, or point \`--url\` at a free
39
+ port.
40
+ `
41
+ },
42
+ cli_server_exited_early: {
43
+ text: (a) => prose`
44
+ the server (${a.cmd}) exited before it answered (${a.how}). Its last output:
45
+
46
+ ${a.output || "(none)"}
47
+ `,
48
+ details: () => prose`
49
+ \`ogygia bisect\` started the commit's server with \`--start\` and it exited before
50
+ \`--url\` answered: a server that cannot take its port (EADDRINUSE), a build output that
51
+ is not there, or a crash on boot. Its output says which. The commit is skipped.
52
+ `
53
+ },
54
+ cli_server_not_ready: {
55
+ text: (a) => prose`
56
+ the server did not answer at ${a.url} within ${a.ms} ms, and was stopped. Its last output:
57
+
58
+ ${a.output || "(none)"}
59
+ `,
60
+ details: () => prose`
61
+ \`ogygia bisect\` polls \`--url\` until the commit's server answers (any status under 500),
62
+ for up to \`--ready\` ms. A server that takes longer to boot needs a larger \`--ready\`; one
63
+ that listens on another port needs \`--url\` to name that port. The commit is skipped.
64
+ `
65
+ }
66
+ };
67
+ /** The warnings: `warn(code, args, ...rest)`. */
68
+ const CLI_WARNINGS = {};
69
+ const own = define_messages(CLI_ERRORS, CLI_WARNINGS);
70
+ /** `throw new OgygiaError(code, args)`; `warn(code, args, ...rest)` logs; `line(code, args)` is the
71
+ * line as a string. */
72
+ const OgygiaError = own.OgygiaError;
73
+ const warn = own.warn;
74
+ const line = own.line;
75
+ export { CLI_ERRORS, CLI_WARNINGS, OgygiaError, line, warn };
package/dist/perf/cli.js CHANGED
@@ -1,11 +1,15 @@
1
+ import { OgygiaError } from "../messages.js";
1
2
  import { measure } from "./measure.js";
2
3
  import { verdict } from "./compare.js";
3
4
  import { markdown, summary } from "./report.js";
4
5
  import { bisect } from "./bisect.js";
6
+ import { machine } from "@ogygia/machine";
5
7
  import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
6
8
  import path from "node:path";
7
9
  import { execSync, spawn } from "node:child_process";
10
+ import { connect } from "node:net";
8
11
  import { tmpdir } from "node:os";
12
+ import { setTimeout } from "node:timers/promises";
9
13
  // src/perf/cli.ts
10
14
  /**
11
15
  * `ogygia perf` and `ogygia bisect` — the runners around the pure modules. Loaded by the CLI on
@@ -92,29 +96,145 @@ function sh(cmd, cwd, timeout_ms) {
92
96
  }
93
97
  });
94
98
  }
95
- /** Start a server command, wait until `url` answers (or `ready_ms`), return a stopper. */
96
- async function serve(cmd, cwd, url, ready_ms) {
99
+ const server_machine = machine()({
100
+ name: "bisect server",
101
+ initial: "spawning",
102
+ steps: {
103
+ spawning: [
104
+ "ready",
105
+ "stopping",
106
+ "exited_early"
107
+ ],
108
+ ready: ["stopping", "exited_early"],
109
+ stopping: ["exited"],
110
+ exited_early: ["exited"],
111
+ exited: []
112
+ }
113
+ });
114
+ /** How long a stopped server has to exit on SIGTERM before its group gets SIGKILL. */
115
+ const STOP_GRACE_MS = 1e4;
116
+ /** `ms` later, or at once when `signal` aborts. Its timer holds the process open, as a wait the
117
+ * command itself awaits must (a scope's timer does not). */
118
+ const sleep = (ms, signal) => setTimeout(ms, void 0, { signal }).catch((error) => {
119
+ if (!signal?.aborted) throw error;
120
+ });
121
+ /** Something accepts a connection at `host:port` (an HTTP server or not: either would get in the
122
+ * way of the readiness check). */
123
+ function port_held(host, port) {
124
+ return new Promise((resolve) => {
125
+ const socket = connect({
126
+ host,
127
+ port
128
+ });
129
+ socket.setTimeout(1e3, () => (socket.destroy(), resolve(false)));
130
+ socket.once("connect", () => (socket.destroy(), resolve(true)));
131
+ socket.once("error", () => resolve(false));
132
+ });
133
+ }
134
+ /** The process group still has a process in it. */
135
+ function group_alive(pid) {
136
+ try {
137
+ process.kill(-pid, 0);
138
+ return true;
139
+ } catch (error) {
140
+ return error.code === "EPERM";
141
+ }
142
+ }
143
+ /**
144
+ * Start a server command and wait until `url` answers (within `ready_ms`, the wait for a free
145
+ * port included). Returns its stop, which resolves once the server's whole process group has
146
+ * exited (SIGTERM, then SIGKILL after `grace_ms`). Throws when the port stays held, when the server
147
+ * exits before it answers (with its output), or when it never answers (stopped first).
148
+ */
149
+ async function serve(cmd, cwd, url, ready_ms, grace_ms = STOP_GRACE_MS) {
150
+ const deadline = Date.now() + ready_ms;
151
+ const at = new URL(url);
152
+ const host = at.hostname.replace(/^\[(.*)\]$/, "$1");
153
+ const port = Number(at.port) || (at.protocol === "https:" ? 443 : 80);
154
+ while (await port_held(host, port)) {
155
+ if (Date.now() >= deadline) throw new OgygiaError("cli_server_port_busy", {
156
+ url,
157
+ port,
158
+ ms: ready_ms
159
+ });
160
+ await sleep(200);
161
+ }
162
+ const m = server_machine.create();
163
+ let output = "";
164
+ const keep = (chunk) => {
165
+ output = (output + String(chunk)).slice(-65536);
166
+ };
167
+ const tail = () => output.split("\n").slice(-40).join("\n").trim();
97
168
  const child = spawn(cmd, {
98
169
  cwd,
99
170
  shell: true,
100
- stdio: "ignore",
171
+ stdio: [
172
+ "ignore",
173
+ "pipe",
174
+ "pipe"
175
+ ],
101
176
  detached: true,
102
177
  env: { ...process.env }
103
178
  });
104
- const stop = () => {
179
+ child.stdout?.on("data", keep);
180
+ child.stderr?.on("data", keep);
181
+ const closed = new Promise((r) => child.once("close", () => r()));
182
+ const pid = child.pid;
183
+ const signal_group = (sig) => {
105
184
  try {
106
- if (child.pid) process.kill(-child.pid, "SIGTERM");
185
+ if (pid !== void 0) process.kill(-pid, sig);
107
186
  } catch {}
108
187
  };
109
- const until = Date.now() + ready_ms;
110
- while (Date.now() < until) {
111
- try {
112
- if ((await fetch(url, { redirect: "manual" })).status < 500) return stop;
113
- } catch {}
114
- await new Promise((r) => setTimeout(r, 500));
188
+ let exit_seen = false;
189
+ const on_exit = async (how) => {
190
+ if (exit_seen) return;
191
+ exit_seen = true;
192
+ if (m.move("spawning", "exited_early", { how }) || m.move("ready", "exited_early", { how })) signal_group("SIGKILL");
193
+ while (pid !== void 0 && group_alive(pid)) await sleep(25);
194
+ await Promise.race([closed, sleep(1e3)]);
195
+ if (!m.move("stopping", "exited")) m.move("exited_early", "exited");
196
+ };
197
+ child.once("exit", (code, sig) => void on_exit(sig ? `signal ${sig}` : `code ${code}`));
198
+ child.once("error", (error) => {
199
+ keep(error.message);
200
+ on_exit(error.message);
201
+ });
202
+ const stop = async () => {
203
+ if (m.move("spawning", "stopping") || m.move("ready", "stopping")) {
204
+ signal_group("SIGTERM");
205
+ m.scope.timeout(() => signal_group("SIGKILL"), grace_ms);
206
+ }
207
+ await m.wait("exited");
208
+ };
209
+ if (await m.scope.task(async (signal) => {
210
+ while (Date.now() < deadline && !signal.aborted) {
211
+ try {
212
+ const res = await fetch(url, {
213
+ redirect: "manual",
214
+ signal: AbortSignal.any([signal, AbortSignal.timeout(2e3)])
215
+ });
216
+ await res.body?.cancel();
217
+ if (res.status < 500) return true;
218
+ } catch {}
219
+ await sleep(200, signal);
220
+ }
221
+ return false;
222
+ }) === true && m.move("spawning", "ready")) return stop;
223
+ const now = m.now;
224
+ if (now.kind === "exited_early") {
225
+ await m.wait("exited");
226
+ throw new OgygiaError("cli_server_exited_early", {
227
+ cmd,
228
+ how: now.how,
229
+ output: tail()
230
+ });
115
231
  }
116
- stop();
117
- throw new Error(`server did not answer at ${url} within ${ready_ms} ms`);
232
+ await stop();
233
+ throw new OgygiaError("cli_server_not_ready", {
234
+ url,
235
+ ms: ready_ms,
236
+ output: tail()
237
+ });
118
238
  }
119
239
  async function runBisect(argv) {
120
240
  const good = arg(argv, "--good");
@@ -143,7 +263,6 @@ async function runBisect(argv) {
143
263
  }
144
264
  process.stdout.write(`ogygia bisect · ${commits.length} commits between ${good.slice(0, 7)} and ${bad.slice(0, 7)} · page ${page}\n`);
145
265
  const worktree = mkdtempSync(path.join(tmpdir(), "ogygia-bisect-"));
146
- const running = { stop: null };
147
266
  const measure_sha = async (sha) => {
148
267
  try {
149
268
  const dir = path.join(worktree, sha.slice(0, 12));
@@ -154,7 +273,7 @@ async function runBisect(argv) {
154
273
  process.stdout.write(` ${sha.slice(0, 7)}: build failed — skipped\n`);
155
274
  return null;
156
275
  }
157
- running.stop = await serve(start, dir, url, ready_ms);
276
+ const stop = await serve(start, dir, url, ready_ms);
158
277
  try {
159
278
  return (await measure({
160
279
  url,
@@ -163,8 +282,7 @@ async function runBisect(argv) {
163
282
  key
164
283
  })).pages[0].render_p50;
165
284
  } finally {
166
- running.stop();
167
- running.stop = null;
285
+ await stop();
168
286
  }
169
287
  } catch (e) {
170
288
  process.stdout.write(` ${sha.slice(0, 7)}: ${e instanceof Error ? e.message : String(e)} — skipped\n`);
@@ -211,11 +329,10 @@ async function runBisect(argv) {
211
329
  process.stdout.write(`\nfirst bad commit: ${result.culprit.slice(0, 7)} — ${subject}\n${result.last_good ? `last good: ${result.last_good.slice(0, 7)}\n` : ""}bar: ${result.bar.toFixed(1)} ms · ${result.steps.length} steps${result.skipped.length ? ` · skipped ${result.skipped.length}` : ""}\n`);
212
330
  return 0;
213
331
  } finally {
214
- running.stop?.();
215
332
  rmSync(worktree, {
216
333
  recursive: true,
217
334
  force: true
218
335
  });
219
336
  }
220
337
  }
221
- export { number_arg, runBisect, runPerf };
338
+ export { number_arg, runBisect, runPerf, serve };
@@ -1,4 +1,12 @@
1
+ import { scope } from "@ogygia/machine";
1
2
  // src/perf/measure.ts
3
+ /**
4
+ * MEASURE — a page list rendered under the profiler on a running server (a local preview, a
5
+ * deployed one), folded into a small snapshot a CI step can keep and diff. Nothing new is
6
+ * instrumented: this is the `/__profiler/page?format=json` report the UI reads, reduced to the
7
+ * numbers a budget can be set on. Pure apart from the fetch it is handed, so a test drives it with
8
+ * a fake and a runner with the real one.
9
+ */
2
10
  const r1 = (n) => Math.round(n * 10) / 10;
3
11
  const median = (xs) => xs.length ? [...xs].sort((a, b) => a - b)[Math.floor(xs.length / 2)] : 0;
4
12
  /** One report → one page's snapshot. */
@@ -62,7 +70,8 @@ async function measure(opts) {
62
70
  if (!page.startsWith("/")) throw new Error(`page must be a path on the site: ${page}`);
63
71
  const url = `${origin}${base}/page?p=${encodeURIComponent(page)}&runs=${runs}&format=json`;
64
72
  const ctrl = new AbortController();
65
- const timer = setTimeout(() => ctrl.abort(), opts.timeout_ms ?? 12e4);
73
+ const deadline = scope();
74
+ deadline.timeout(() => ctrl.abort(), opts.timeout_ms ?? 12e4);
66
75
  let res;
67
76
  try {
68
77
  res = await f(url, {
@@ -76,7 +85,7 @@ async function measure(opts) {
76
85
  } catch (e) {
77
86
  throw new Error(`${page}: the profiler did not answer (${e instanceof Error ? e.message : String(e)})`);
78
87
  } finally {
79
- clearTimeout(timer);
88
+ deadline.close();
80
89
  }
81
90
  if (res.status === 409) throw new Error(`${page}: the profiler is busy on that server (another recording is running)`);
82
91
  if (!res.ok) throw new Error(`${page}: the profiler answered ${res.status}${res.status === 401 || res.status === 302 || res.status === 303 ? " — is the key right (x-profiler-key)?" : ""}`);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ogygia/cli",
3
- "version": "0.11.5",
3
+ "version": "0.13.0",
4
4
  "description": "The ogygia command line: init and site scaffolds, the AI skill, the MCP server, perf budgets and bisect, federation keys and catalogs",
5
5
  "keywords": [
6
6
  "cli",
@@ -27,15 +27,22 @@
27
27
  "type": "module",
28
28
  "exports": {
29
29
  "./bin": "./dist/cli.mjs",
30
+ "./internal/messages": {
31
+ "types": "./src/messages.ts",
32
+ "default": "./dist/messages.js"
33
+ },
30
34
  "./package.json": "./package.json"
31
35
  },
32
36
  "publishConfig": {
33
37
  "access": "public"
34
38
  },
39
+ "dependencies": {
40
+ "@ogygia/machine": "^0.13.0"
41
+ },
35
42
  "devDependencies": {
36
- "@ogygia/core": "0.11.5",
37
- "@ogygia/devtools": "0.11.4",
38
- "@ogygia/tooling": "0.0.0",
43
+ "@ogygia/config": "0.0.0",
44
+ "@ogygia/core": "0.13.0",
45
+ "@ogygia/devtools": "0.13.0",
39
46
  "@sveltejs/sv-utils": "^0.3.3",
40
47
  "@types/node": "^26.1.2",
41
48
  "@typescript/native": "npm:typescript@^7.0.2",
@@ -45,7 +52,7 @@
45
52
  "vitest": "^4.1.12"
46
53
  },
47
54
  "peerDependencies": {
48
- "@ogygia/core": "^0.11.5",
55
+ "@ogygia/core": "^0.13.0",
49
56
  "playwright": "^1.62.0",
50
57
  "svelte": "^5.56.8"
51
58
  },