@shortlink-org/portolan 0.2.2 → 0.2.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.
Files changed (84) hide show
  1. package/README.md +7 -2
  2. package/cli/portolan.mjs +36 -6
  3. package/package.json +4 -4
  4. package/plugins/README.md +5 -0
  5. package/plugins/extract-python-kafka/README.md +6 -0
  6. package/plugins/extract-python-kafka/extract.py +2 -2
  7. package/plugins/extract-python-kafka/extract_test.py +17 -1
  8. package/plugins/portolan-go.wasm +0 -0
  9. package/plugins/pyplugin/catalog.py +12 -1
  10. package/plugins/pyplugin/kafka.py +74 -3
  11. package/scripts/builtin-plugins.mjs +3 -2
  12. package/scripts/catalog-sources.mjs +2 -1
  13. package/scripts/catalog-sources.test.mjs +11 -1
  14. package/scripts/delivery-presets.mjs +207 -24
  15. package/scripts/diff.mjs +5 -1
  16. package/scripts/gen-likec4.mjs +78 -20
  17. package/scripts/gen-likec4.test.mjs +25 -2
  18. package/scripts/local-api.mjs +26 -377
  19. package/scripts/local-api.test.mjs +50 -0
  20. package/scripts/local-discovery.mjs +378 -0
  21. package/scripts/manifest.mjs +42 -1
  22. package/scripts/manifest.test.mjs +24 -1
  23. package/scripts/run-builtin.mjs +4 -2
  24. package/scripts/schema.mjs +4 -0
  25. package/scripts/site-docs.mjs +2 -2
  26. package/src/app/Breadcrumbs.test.ts +4 -0
  27. package/src/app/Breadcrumbs.tsx +1 -0
  28. package/src/app/CatalogApp.tsx +4 -4
  29. package/src/app/Sidebar.tsx +13 -730
  30. package/src/app/SidebarFlowSections.tsx +251 -0
  31. package/src/app/SidebarFooter.tsx +160 -0
  32. package/src/app/SidebarTree.tsx +322 -0
  33. package/src/catalog-index.ts +485 -0
  34. package/src/catalog-model.ts +1360 -0
  35. package/src/catalog-stores.test.ts +17 -0
  36. package/src/catalog-validation.ts +1611 -0
  37. package/src/catalog.test.ts +29 -2
  38. package/src/catalog.ts +6 -3306
  39. package/src/components/ChannelRows.messagepack.test.tsx +28 -0
  40. package/src/components/ChannelRows.test.tsx +54 -0
  41. package/src/components/ChannelRows.tsx +57 -10
  42. package/src/components/LifecycleDiagram.tsx +28 -12
  43. package/src/components/{PageHeader.test.ts → PageHeader.test.tsx} +9 -10
  44. package/src/components/ProblemRow.tsx +7 -0
  45. package/src/components/SourcePreview.tsx +1 -1
  46. package/src/components/WhatLinksHere.tsx +6 -4
  47. package/src/enrich.test.ts +4 -5
  48. package/src/flow/StepDetail.tsx +98 -54
  49. package/src/flow/answers.test.ts +18 -1
  50. package/src/flow/answers.ts +37 -8
  51. package/src/index.css +0 -17
  52. package/src/landing/LandingPage.tsx +4 -4
  53. package/src/lib/all-problems.ts +1 -1
  54. package/src/lib/backlinks.test.ts +16 -1
  55. package/src/lib/backlinks.ts +20 -0
  56. package/src/lib/catalog-diff.test.ts +18 -0
  57. package/src/lib/catalog-diff.ts +19 -1
  58. package/src/lib/derive.ts +2 -0
  59. package/src/lib/kafka-ui.test.ts +87 -0
  60. package/src/lib/kafka-ui.ts +105 -0
  61. package/src/lib/local-api.ts +19 -7
  62. package/src/lib/motion.test.ts +4 -2
  63. package/src/lib/motion.tsx +5 -4
  64. package/src/lib/proto-problems.test.ts +170 -3
  65. package/src/lib/proto-problems.ts +176 -4
  66. package/src/lib/wire-problems.test.ts +21 -0
  67. package/src/lib/wire-problems.ts +62 -1
  68. package/src/likec4/FlowView.tsx +2 -6
  69. package/src/likec4/flow-edges.test.ts +64 -1
  70. package/src/likec4/flow-edges.ts +43 -7
  71. package/src/likec4/view-index.ts +8 -2
  72. package/src/merge.test.ts +69 -0
  73. package/src/merge.ts +52 -2
  74. package/src/pages/CatalogFailure.tsx +2 -2
  75. package/src/pages/Settings.tsx +12 -129
  76. package/src/pages/settings/AboutSettings.tsx +129 -0
  77. package/src/pages/settings/DeliverySettings.tsx +71 -15
  78. package/src/pages/settings/IntegrationsSettings.tsx +117 -0
  79. package/src/routes.test.ts +2 -0
  80. package/src/routes.ts +2 -1
  81. package/src/selection/DetailPanel.tsx +46 -1
  82. package/src/selection/pages.test.ts +14 -1
  83. package/src/selection/pages.ts +9 -3
  84. package/vite.config.ts +3 -1
@@ -1,6 +1,6 @@
1
1
  import { useCallback, useEffect, useState } from "react";
2
+ import { Switch } from "@headlessui/react";
2
3
  import {
3
- Check,
4
4
  ChevronDown,
5
5
  CircleAlert,
6
6
  FolderGit2,
@@ -14,6 +14,7 @@ import {
14
14
  previewDeliveryPreset,
15
15
  } from "../../lib/local-api";
16
16
  import type {
17
+ DeliveryFeatureId,
17
18
  DeliveryPreset,
18
19
  DeliveryProvider,
19
20
  } from "../../lib/local-api";
@@ -24,6 +25,7 @@ const STATUS_CLASS: Record<
24
25
  > = {
25
26
  added: "status-verified",
26
27
  changed: "status-declared",
28
+ removed: "status-unresolved",
27
29
  unchanged: "status-verified",
28
30
  conflict: "status-unresolved",
29
31
  };
@@ -35,12 +37,12 @@ export function DeliverySettings({ local }: { local: boolean }) {
35
37
  const [error, setError] = useState("");
36
38
  const say = useToastStore((state) => state.say);
37
39
 
38
- const load = useCallback(async (provider?: DeliveryProvider) => {
40
+ const load = useCallback(async (provider?: DeliveryProvider, features?: DeliveryFeatureId[]) => {
39
41
  if (!local) return;
40
42
  setLoading(true);
41
43
  setError("");
42
44
  try {
43
- setPlan(await previewDeliveryPreset(provider));
45
+ setPlan(await previewDeliveryPreset(provider, features));
44
46
  } catch (cause) {
45
47
  setError(cause instanceof Error ? cause.message : String(cause));
46
48
  } finally {
@@ -60,6 +62,7 @@ export function DeliverySettings({ local }: { local: boolean }) {
60
62
  const installed = await installDeliveryPreset(
61
63
  plan.provider,
62
64
  plan.revision,
65
+ plan.features.filter((feature) => feature.selected).map((feature) => feature.id),
63
66
  );
64
67
  setPlan(installed);
65
68
  say(
@@ -76,6 +79,23 @@ export function DeliverySettings({ local }: { local: boolean }) {
76
79
  }
77
80
  }
78
81
 
82
+ function selectFeature(id: DeliveryFeatureId) {
83
+ if (!plan) return;
84
+ const feature = plan.features.find((item) => item.id === id);
85
+ if (!feature?.available) return;
86
+ const selected = new Set(
87
+ plan.features.filter((item) => item.selected).map((item) => item.id),
88
+ );
89
+ if (selected.has(id)) {
90
+ selected.delete(id);
91
+ if (id === "diff") selected.delete("sarif");
92
+ } else {
93
+ selected.add(id);
94
+ for (const required of feature.requires) selected.add(required);
95
+ }
96
+ void load(plan.provider, [...selected]);
97
+ }
98
+
79
99
  if (!local) {
80
100
  return (
81
101
  <div className="empty">
@@ -96,8 +116,8 @@ export function DeliverySettings({ local }: { local: boolean }) {
96
116
  <FolderGit2 size={16} className="text-accent" /> Delivery automation
97
117
  </div>
98
118
  <p className="mt-1 max-w-prose text-muted">
99
- Install review checks and static catalog publishing directly into
100
- this repository.
119
+ Choose the automation this repository needs, preview the generated
120
+ jobs, then install them together.
101
121
  </p>
102
122
  </div>
103
123
  <div
@@ -111,7 +131,12 @@ export function DeliverySettings({ local }: { local: boolean }) {
111
131
  type="button"
112
132
  disabled={loading || installing}
113
133
  aria-pressed={plan?.provider === provider}
114
- onClick={() => void load(provider)}
134
+ onClick={() => void load(
135
+ provider,
136
+ plan?.features
137
+ .filter((feature) => feature.selected && (feature.id !== "sarif" || provider === "github"))
138
+ .map((feature) => feature.id),
139
+ )}
115
140
  className={`capitalize ${plan?.provider === provider ? "is-on" : ""}`}
116
141
  >
117
142
  {provider === "github" ? "GitHub" : "GitLab"}
@@ -157,15 +182,46 @@ export function DeliverySettings({ local }: { local: boolean }) {
157
182
  )}
158
183
  </div>
159
184
 
160
- <ul className="mt-4 grid gap-2 text-muted sm:grid-cols-2">
185
+ <div className="mt-4 grid gap-2 sm:grid-cols-2">
161
186
  {plan.features.map((feature) => (
162
- <li key={feature} className="flex items-center gap-2">
163
- <Check size={14} className="shrink-0 text-verified" /> {feature}
164
- </li>
187
+ <div
188
+ key={feature.id}
189
+ className={`flex items-start gap-3 rounded-control border px-3 py-3 transition-colors ${
190
+ feature.available
191
+ ? feature.selected
192
+ ? "border-accent bg-surface"
193
+ : "border-line hover:bg-surface"
194
+ : "border-line opacity-60"
195
+ }`}
196
+ >
197
+ <span className="min-w-0 flex-1">
198
+ <span className="block font-medium text-ink">{feature.label}</span>
199
+ <span className="mt-0.5 block text-muted">{feature.description}</span>
200
+ {!feature.available ? (
201
+ <span className="mt-1 block text-faint">Available for GitHub only.</span>
202
+ ) : feature.requires.length > 0 ? (
203
+ <span className="mt-1 block text-faint">
204
+ Enables {feature.requires.map((required) => plan.features.find((item) => item.id === required)?.label).join(", ")}.
205
+ </span>
206
+ ) : null}
207
+ </span>
208
+ <Switch
209
+ checked={feature.selected}
210
+ disabled={!feature.available || loading || installing}
211
+ onChange={() => selectFeature(feature.id)}
212
+ aria-label={feature.label}
213
+ className="group mt-0.5 inline-flex h-5 w-9 shrink-0 cursor-pointer items-center rounded-full border border-line-strong bg-surface transition-colors outline-none data-checked:border-accent data-checked:bg-accent focus-visible:ring-2 focus-visible:ring-accent/40 disabled:cursor-not-allowed"
214
+ >
215
+ <span
216
+ aria-hidden
217
+ className="size-3.5 translate-x-0.5 rounded-full bg-muted shadow-xs transition-transform group-data-checked:translate-x-[18px] group-data-checked:bg-canvas"
218
+ />
219
+ </Switch>
220
+ </div>
165
221
  ))}
166
- </ul>
222
+ </div>
167
223
 
168
- <div className="mt-4 divide-y divide-line overflow-hidden rounded-control border border-line">
224
+ {plan.files.length > 0 ? <div className="mt-4 divide-y divide-line overflow-hidden rounded-control border border-line">
169
225
  {plan.files.map((file) => (
170
226
  <details
171
227
  key={file.path}
@@ -194,7 +250,7 @@ export function DeliverySettings({ local }: { local: boolean }) {
194
250
  </div>
195
251
  </details>
196
252
  ))}
197
- </div>
253
+ </div> : <div className="empty mt-4">No delivery jobs selected.</div>}
198
254
 
199
255
  {error ? <p className="mt-3 text-unresolved">{error}</p> : null}
200
256
  <div className="mt-4 flex flex-wrap items-center justify-between gap-3">
@@ -204,12 +260,12 @@ export function DeliverySettings({ local }: { local: boolean }) {
204
260
  </p>
205
261
  {plan.status === "installed" ? (
206
262
  <span className="flex items-center gap-1.5 text-verified">
207
- <ShieldCheck size={15} /> Preset installed
263
+ <ShieldCheck size={15} /> Selection installed
208
264
  </span>
209
265
  ) : (
210
266
  <button
211
267
  type="button"
212
- className="btn-accent"
268
+ className="product-primary"
213
269
  disabled={installing || conflicts.length > 0}
214
270
  onClick={() => void install()}
215
271
  >
@@ -0,0 +1,117 @@
1
+ import { ExternalLink, Save, Trash2 } from "lucide-react";
2
+ import { useState } from "react";
3
+ import { SectionTitle } from "../../components/PageHeader";
4
+ import { TechIcon } from "../../components/TechIcon";
5
+ import { normalizeKafkaUiUrl, useKafkaUi } from "../../lib/kafka-ui";
6
+ import { techGlyph } from "../../lib/tech";
7
+
8
+ const FIELD =
9
+ "mono w-full rounded-control border border-line bg-canvas px-3 py-2 text-ink outline-none focus:border-accent";
10
+
11
+ function KafkaUiCard() {
12
+ const configured = useKafkaUi((state) => state.url);
13
+ const setUrl = useKafkaUi((state) => state.setUrl);
14
+ const [draft, setDraft] = useState(configured);
15
+ const normalized = normalizeKafkaUiUrl(draft);
16
+ const valid = normalized !== null;
17
+ const dirty = valid && normalized !== configured;
18
+ const glyph = techGlyph("Kafka");
19
+
20
+ const save = () => {
21
+ if (normalized === null) return;
22
+ setUrl(normalized);
23
+ setDraft(normalized);
24
+ };
25
+
26
+ const remove = () => {
27
+ setUrl("");
28
+ setDraft("");
29
+ };
30
+
31
+ return (
32
+ <section className="rounded-card border border-line bg-canvas p-card shadow-xs">
33
+ <div className="flex items-start gap-3">
34
+ <span className="flex size-9 shrink-0 items-center justify-center rounded-control border border-line bg-surface text-ink">
35
+ {glyph ? <TechIcon glyph={glyph} size={18} /> : null}
36
+ </span>
37
+ <div className="min-w-0 flex-1">
38
+ <div className="flex flex-wrap items-center gap-2">
39
+ <h2 className="font-semibold text-ink">Kafka UI</h2>
40
+ <span className={`chip ${configured ? "status-verified" : "text-muted"}`}>
41
+ {configured ? "configured" : "not configured"}
42
+ </span>
43
+ </div>
44
+ <p className="mt-1 text-muted">
45
+ Opens catalogued Kafka topics in your Kafbat Kafka UI installation.
46
+ </p>
47
+ </div>
48
+ </div>
49
+
50
+ <label className="mt-4 block">
51
+ <span className="label mb-1.5 block">Kafka UI cluster URL</span>
52
+ <input
53
+ type="url"
54
+ value={draft}
55
+ onChange={(event) => setDraft(event.target.value)}
56
+ onKeyDown={(event) => {
57
+ if (event.key === "Enter" && dirty) save();
58
+ }}
59
+ placeholder="https://kafka.example/ui/clusters/production"
60
+ spellCheck={false}
61
+ autoComplete="off"
62
+ aria-invalid={!valid}
63
+ className={`${FIELD} ${valid ? "" : "border-unresolved"}`}
64
+ />
65
+ </label>
66
+ <p className={`mono mt-2 ${valid ? "text-muted" : "text-unresolved"}`}>
67
+ {valid
68
+ ? "Use a cluster or topics URL to open the exact topic. A plain installation URL opens Kafka UI itself."
69
+ : "Enter an http:// or https:// URL."}
70
+ </p>
71
+
72
+ <div className="mt-4 flex flex-wrap items-center gap-2 border-t border-line pt-4">
73
+ <button
74
+ type="button"
75
+ onClick={save}
76
+ disabled={!dirty}
77
+ className="product-primary"
78
+ >
79
+ <Save size={14} aria-hidden />
80
+ Save integration
81
+ </button>
82
+ {configured ? (
83
+ <>
84
+ <a
85
+ href={configured}
86
+ target="_blank"
87
+ rel="noreferrer"
88
+ className="tbtn px-3 py-1.5 text-ink"
89
+ >
90
+ Open Kafka UI
91
+ <ExternalLink size={14} aria-hidden />
92
+ </a>
93
+ <button
94
+ type="button"
95
+ onClick={remove}
96
+ className="tbtn ml-auto px-3 py-1.5 text-unresolved"
97
+ >
98
+ <Trash2 size={14} aria-hidden />
99
+ Remove
100
+ </button>
101
+ </>
102
+ ) : null}
103
+ </div>
104
+ </section>
105
+ );
106
+ }
107
+
108
+ export function IntegrationsSettings() {
109
+ return (
110
+ <section>
111
+ <SectionTitle right="stored in this browser">Integrations</SectionTitle>
112
+ <div className="grid gap-grid lg:grid-cols-2">
113
+ <KafkaUiCard />
114
+ </div>
115
+ </section>
116
+ );
117
+ }
@@ -169,12 +169,14 @@ describe("routes", () => {
169
169
  expect(paths.settingsProjects()).toBe("/settings/projects");
170
170
  expect(paths.settingsPipeline()).toBe("/settings/pipeline");
171
171
  expect(paths.settingsDelivery()).toBe("/settings/delivery");
172
+ expect(paths.settingsIntegrations()).toBe("/settings/integrations");
172
173
  expect(paths.settingsPreferences()).toBe("/settings/preferences");
173
174
  expect(paths.settingsAbout()).toBe("/settings/about");
174
175
  for (const path of [
175
176
  paths.settingsProjects(),
176
177
  paths.settingsPipeline(),
177
178
  paths.settingsDelivery(),
179
+ paths.settingsIntegrations(),
178
180
  paths.settingsPreferences(),
179
181
  paths.settingsAbout(),
180
182
  ]) expect(isRoutable(path)).toBe(true);
package/src/routes.ts CHANGED
@@ -45,6 +45,7 @@ export const paths = {
45
45
  settingsProjects: () => "/settings/projects",
46
46
  settingsPipeline: () => "/settings/pipeline",
47
47
  settingsDelivery: () => "/settings/delivery",
48
+ settingsIntegrations: () => "/settings/integrations",
48
49
  settingsPreferences: () => "/settings/preferences",
49
50
  settingsAbout: () => "/settings/about",
50
51
  external: (slug: string) => `/externals/${slug}`,
@@ -375,7 +376,7 @@ const ROUTES: RegExp[] = [
375
376
  /^\/language$/,
376
377
  /^\/problems$/,
377
378
  /^\/changes$/,
378
- /^\/settings(?:\/(?:projects|pipeline|delivery|preferences|about))?$/,
379
+ /^\/settings(?:\/(?:projects|pipeline|delivery|integrations|preferences|about))?$/,
379
380
  /^\/externals\/[^/]+$/,
380
381
  /^\/map$/,
381
382
  /^\/adrs\/[^/]+$/,
@@ -34,7 +34,7 @@ import { stepLabel } from "../flow/labels";
34
34
  import { KIND_LABEL } from "../lib/kinds";
35
35
  import { eventScope, resolveShape } from "../lib/shape";
36
36
  import { stepsInto } from "../lib/backlinks";
37
- import { treeHref } from "../lib/source-link";
37
+ import { sourceLocation, treeHref } from "../lib/source-link";
38
38
  import { walkSteps } from "../catalog";
39
39
  import { methodCount } from "../lib/api";
40
40
  import {
@@ -49,6 +49,7 @@ import { upstreamOf } from "../er/lineage";
49
49
  import type { LineageMaps } from "../er/lineage";
50
50
  import { ctxStyle } from "../lib/context-color";
51
51
  import { Ident } from "../components/Ident";
52
+ import { SourcePreviewLink } from "../components/SourcePreview";
52
53
  import { StatusChip } from "../components/primitives";
53
54
  import { StepDetailBody } from "../flow/StepDetail";
54
55
  import {
@@ -420,6 +421,50 @@ function TableBody({
420
421
  </>
421
422
  ) : null}
422
423
 
424
+ {(table.accesses ?? []).length > 0 ? (
425
+ <>
426
+ <Label>Reads / writes</Label>
427
+ {(table.accesses ?? []).map((access, accessIndex) => {
428
+ const location = access.source
429
+ ? sourceLocation(
430
+ access.source,
431
+ index.serviceById.get(store.owner),
432
+ allRepos(catalog),
433
+ )
434
+ : null;
435
+ const tone =
436
+ access.operation === "read"
437
+ ? "text-accent"
438
+ : access.operation === "write"
439
+ ? "text-verified"
440
+ : "text-unresolved";
441
+ return (
442
+ <div
443
+ key={`${access.operation}:${access.method}:${access.source}:${accessIndex}`}
444
+ className="grid grid-cols-[auto_minmax(0,1fr)] gap-x-2 gap-y-0.5 py-1"
445
+ >
446
+ <span className={`chip self-start uppercase ${tone}`}>
447
+ {access.operation}
448
+ </span>
449
+ <div className="min-w-0">
450
+ <div className="mono break-all text-ink">
451
+ {access.method ?? "SQL client call"}
452
+ </div>
453
+ {access.source ? (
454
+ <SourcePreviewLink
455
+ location={location}
456
+ className="mono block break-all text-muted hover:text-accent"
457
+ >
458
+ {access.source}
459
+ </SourcePreviewLink>
460
+ ) : null}
461
+ </div>
462
+ </div>
463
+ );
464
+ })}
465
+ </>
466
+ ) : null}
467
+
423
468
  <Label>Referenced by</Label>
424
469
  {into.length === 0 ? (
425
470
  <div className="mono text-muted">nothing points at this table</div>
@@ -1,5 +1,5 @@
1
1
  import { describe, expect, it } from "vitest";
2
- import { pageContains, selectionPath } from "./pages";
2
+ import { pageContains, selectionPath, selectsInPlace } from "./pages";
3
3
  import { selectionFor } from "./model";
4
4
 
5
5
  const dispatched = selectionFor("delivery.core.shipment.ShipmentDispatched");
@@ -44,6 +44,19 @@ describe("selectionPath", () => {
44
44
  });
45
45
  });
46
46
 
47
+ describe("selectsInPlace", () => {
48
+ const flow = "/flows/cart-checkout";
49
+ const shop = selectionFor("shop");
50
+
51
+ it("selects a flow context on the flow the first time", () => {
52
+ expect(selectsInPlace(flow, shop, null)).toBe(true);
53
+ });
54
+
55
+ it("lets a repeated selection follow its link to the context page", () => {
56
+ expect(selectsInPlace(flow, shop, shop)).toBe(false);
57
+ });
58
+ });
59
+
47
60
  // Navigation keeps a selection only when the page it lands on can point at it.
48
61
  // These are the cases that decide whether the palette moves the reader.
49
62
  describe("pageContains across pages", () => {
@@ -9,7 +9,7 @@ import { walkSteps } from "../catalog";
9
9
  import { catalog, index } from "../data";
10
10
  import { paths } from "../routes";
11
11
  import type { Selection } from "./model";
12
- import { parseFlowStepId, resolveSelection } from "./model";
12
+ import { parseFlowStepId, resolveSelection, sameSelection } from "./model";
13
13
 
14
14
  /** The page that owns a selection, or null when it has no page of its own. */
15
15
  export function selectionPath(selection: Selection): string | null {
@@ -257,12 +257,18 @@ export function pageContains(pathname: string, selection: Selection): boolean {
257
257
  * and only for something that flow actually draws. That is the one place where
258
258
  * leaving is the wrong answer — picking an event there is a question about
259
259
  * *this* sequence ("where does it appear?"), and the flow page answers it by
260
- * lighting the matching steps.
260
+ * lighting the matching steps. Clicking the highlighted selection again is an
261
+ * explicit request to follow the tree link to its own page.
261
262
  */
262
263
  export function selectsInPlace(
263
264
  pathname: string,
264
265
  selection: Selection,
266
+ current: Selection | null,
265
267
  ): boolean {
266
268
  const route = parseRoute(pathname);
267
- return route.kind === "flow" && pageContains(pathname, selection);
269
+ return (
270
+ !sameSelection(current, selection) &&
271
+ route.kind === "flow" &&
272
+ pageContains(pathname, selection)
273
+ );
268
274
  }
package/vite.config.ts CHANGED
@@ -7,6 +7,8 @@ import react from "@vitejs/plugin-react";
7
7
  import tailwindcss from "@tailwindcss/vite";
8
8
  import { nodePolyfills } from "vite-plugin-node-polyfills";
9
9
  import { publicSetupFrom } from "./src/lib/setup-info.ts";
10
+ // @ts-expect-error plain JavaScript module intentionally has no browser types
11
+ import { readManifest } from "./scripts/manifest.mjs";
10
12
  // The local control plane is a Node-only Vite plugin kept as plain ESM so its
11
13
  // pure discovery functions can also be tested directly.
12
14
  // @ts-expect-error plain JavaScript module intentionally has no browser types
@@ -121,7 +123,7 @@ try {
121
123
  // A clean checkout has no run to report yet. Settings says so explicitly.
122
124
  }
123
125
  const setupInfo = publicSetupFrom(
124
- JSON.parse(manifestText) as unknown,
126
+ readManifest(resolve(workspace, "portolan.json")) as unknown,
125
127
  buildReport,
126
128
  createHash("sha256").update(manifestText).digest("hex"),
127
129
  );