@shortlink-org/portolan 0.2.1 → 0.2.2

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 (107) hide show
  1. package/cli/init.mjs +14 -6
  2. package/cli/init.test.mjs +8 -1
  3. package/cli/portolan.mjs +56 -7
  4. package/cli/portolan.test.mjs +47 -0
  5. package/package.json +7 -6
  6. package/plugins/extract-django/README.md +1 -1
  7. package/plugins/extract-django/extract_test.py +1 -1
  8. package/plugins/extract-django/openapi.py +4 -14
  9. package/plugins/extract-django/openapi_test.py +4 -4
  10. package/plugins/extract-go/options.schema.json +5 -0
  11. package/plugins/extract-project/options.schema.json +21 -0
  12. package/plugins/extract-ts/README.md +1 -1
  13. package/plugins/extract-ts/clients.ts +1 -1
  14. package/plugins/extract-ts/extract.test.ts +2 -2
  15. package/plugins/extract-ts/graphql.test.ts +2 -2
  16. package/plugins/extract-ts/openapi.test.ts +6 -7
  17. package/plugins/extract-ts/openapi.ts +3 -10
  18. package/plugins/portolan-go.wasm +0 -0
  19. package/public/404-cat-v1.webp +0 -0
  20. package/public/cat-about-v1.webp +0 -0
  21. package/public/cat-clear-skies-v1.webp +0 -0
  22. package/public/cat-no-changes-v1.webp +0 -0
  23. package/public/cat-onboarding-v1.webp +0 -0
  24. package/public/cat-trial-success-v1.webp +0 -0
  25. package/schema/portolan.schema.json +61 -0
  26. package/scripts/catalog-sources.mjs +12 -1
  27. package/scripts/catalog-sources.test.mjs +35 -0
  28. package/scripts/gen-likec4.mjs +242 -85
  29. package/scripts/gen-likec4.test.mjs +168 -14
  30. package/scripts/gen.mjs +5 -3
  31. package/scripts/host-plugins/fetch-git.mjs +22 -12
  32. package/scripts/host-plugins/fetch-git.test.mjs +25 -5
  33. package/scripts/local-api.mjs +363 -28
  34. package/scripts/local-api.test.mjs +195 -1
  35. package/scripts/plugin-host.mjs +14 -3
  36. package/scripts/plugin-host.test.mjs +8 -0
  37. package/scripts/schema.mjs +7 -0
  38. package/src/app/CatalogApp.tsx +19 -4
  39. package/src/app/CommandPalette.tsx +7 -2
  40. package/src/app/toast.tsx +11 -7
  41. package/src/catalog.test.ts +33 -0
  42. package/src/catalog.ts +190 -8
  43. package/src/components/CatIllustration.tsx +30 -0
  44. package/src/components/CommitLink.tsx +154 -0
  45. package/src/components/EditorLink.tsx +15 -8
  46. package/src/components/GrpcMethodReference.tsx +93 -0
  47. package/src/components/Markdown.tsx +17 -0
  48. package/src/components/MethodRows.tsx +8 -1
  49. package/src/components/PageHeader.test.ts +24 -0
  50. package/src/components/PageHeader.tsx +34 -0
  51. package/src/components/ShapeRows.tsx +12 -0
  52. package/src/components/SourceDoc.tsx +27 -12
  53. package/src/components/SourcePreview.tsx +124 -36
  54. package/src/enrich.test.ts +209 -2
  55. package/src/enrich.ts +284 -15
  56. package/src/er/RedisSchema.tsx +86 -5
  57. package/src/flow/FlowTable.tsx +19 -4
  58. package/src/flow/StepDetail.tsx +461 -101
  59. package/src/flow/StepRail.tsx +36 -13
  60. package/src/flow/answers-response.test.ts +100 -0
  61. package/src/flow/answers.ts +11 -8
  62. package/src/flow/chapters.ts +2 -1
  63. package/src/flow/labels.ts +10 -0
  64. package/src/flow/mermaid.test.ts +87 -3
  65. package/src/flow/mermaid.ts +26 -14
  66. package/src/index.css +132 -0
  67. package/src/landing/LandingPage.tsx +12 -8
  68. package/src/lib/api.test.ts +4 -6
  69. package/src/lib/build-info.test.ts +29 -0
  70. package/src/lib/build-info.ts +21 -0
  71. package/src/lib/data-model.ts +14 -0
  72. package/src/lib/flow-tree.ts +1 -1
  73. package/src/lib/github-catalog.test.ts +51 -0
  74. package/src/lib/github-catalog.ts +69 -0
  75. package/src/lib/grpc-reference.test.ts +72 -0
  76. package/src/lib/grpc-reference.ts +117 -0
  77. package/src/lib/local-api.ts +17 -4
  78. package/src/lib/product.ts +7 -0
  79. package/src/lib/queries.ts +16 -1
  80. package/src/lib/readme-assets.test.ts +19 -0
  81. package/src/lib/readme-assets.ts +34 -0
  82. package/src/lib/setup-info.test.ts +7 -0
  83. package/src/lib/setup-info.ts +5 -0
  84. package/src/lib/source-link.test.ts +106 -18
  85. package/src/lib/source-link.ts +22 -6
  86. package/src/merge.test.ts +55 -0
  87. package/src/merge.ts +30 -0
  88. package/src/pages/AdrDetail.tsx +3 -2
  89. package/src/pages/AdrIndex.tsx +10 -7
  90. package/src/pages/AggregatePage.tsx +34 -4
  91. package/src/pages/BlockPage.tsx +39 -32
  92. package/src/pages/Changes.tsx +7 -9
  93. package/src/pages/ContextMap.tsx +11 -2
  94. package/src/pages/ContextPage.tsx +11 -4
  95. package/src/pages/FlowIndex.tsx +16 -7
  96. package/src/pages/GraphPage.tsx +21 -7
  97. package/src/pages/Language.tsx +9 -9
  98. package/src/pages/NotFound.tsx +58 -15
  99. package/src/pages/Overview.tsx +6 -0
  100. package/src/pages/Problems.tsx +10 -8
  101. package/src/pages/RegistryIndex.tsx +10 -8
  102. package/src/pages/ServicePage.tsx +1 -1
  103. package/src/pages/Settings.tsx +382 -44
  104. package/src/routes.test.ts +2 -0
  105. package/src/routes.ts +2 -1
  106. package/src/selection/DetailPanel.tsx +213 -113
  107. package/src/testing/setup.ts +14 -0
@@ -23,15 +23,13 @@ function auth(): Service {
23
23
 
24
24
  describe("interfaceDeclaring", () => {
25
25
  it("finds the interface a method belongs to", () => {
26
- expect(interfaceDeclaring(auth(), "registerUser")?.id).toBe(
27
- "auth.v1.Users",
28
- );
29
- expect(interfaceDeclaring(auth(), "login")?.id).toBe("auth.v1.Sessions");
26
+ expect(interfaceDeclaring(auth(), "registerUser")?.id).toBe("auth.v1");
27
+ expect(interfaceDeclaring(auth(), "login")?.id).toBe("auth.v1");
30
28
  expect(interfaceDeclaring(auth(), "nothing")).toBeUndefined();
31
29
  });
32
30
 
33
31
  it("writes a method the way the rest of the app writes one", () => {
34
- expect(methodId(auth(), "registerUser")).toBe("auth.v1.Users/registerUser");
32
+ expect(methodId(auth(), "registerUser")).toBe("auth.v1/registerUser");
35
33
  });
36
34
  });
37
35
 
@@ -92,7 +90,7 @@ describe("methods, now that a method is more than a name", () => {
92
90
  const declared = declaredMethods(auth());
93
91
 
94
92
  expect(declared.length).toBe(methodCount(auth()));
95
- expect(declared[0]?.provided.id).toBe("auth.v1.Users");
93
+ expect(declared[0]?.provided.id).toBe("auth.v1");
96
94
  expect(declared.map((d) => d.method.name)).toContain("login");
97
95
  });
98
96
 
@@ -3,6 +3,7 @@ import {
3
3
  buildHref,
4
4
  buildLabel,
5
5
  buildTitle,
6
+ repositoryCommitHref,
6
7
  type BuildInfo,
7
8
  } from "./build-info";
8
9
 
@@ -45,6 +46,34 @@ describe("buildHref", () => {
45
46
  });
46
47
  });
47
48
 
49
+ describe("repositoryCommitHref", () => {
50
+ it("links a GitLab commit through the repository page", () => {
51
+ expect(repositoryCommitHref("abc123", CI)).toBe(
52
+ "https://gitlab.com/acme/portolan/-/commit/abc123",
53
+ );
54
+ });
55
+
56
+ it("links a GitHub commit through the repository page", () => {
57
+ expect(
58
+ repositoryCommitHref("abc123", {
59
+ ...CI,
60
+ repoUrl: "https://github.com/acme/portolan/",
61
+ forge: "github",
62
+ }),
63
+ ).toBe("https://github.com/acme/portolan/commit/abc123");
64
+ });
65
+
66
+ it("does not guess a route for an unknown forge", () => {
67
+ expect(
68
+ repositoryCommitHref("abc123", {
69
+ ...CI,
70
+ repoUrl: "https://code.example.com/acme/portolan",
71
+ forge: undefined,
72
+ }),
73
+ ).toBeNull();
74
+ });
75
+ });
76
+
48
77
  describe("buildLabel", () => {
49
78
  it("reads as the commit, not the build number, even on CI", () => {
50
79
  expect(buildLabel(CI)).toBe("4f1c9ae");
@@ -50,6 +50,27 @@ export function buildHref(info: BuildInfo = buildInfo): string | null {
50
50
  return info.commitUrl || info.buildUrl || null;
51
51
  }
52
52
 
53
+ /** An immutable commit in the repository this bundle was built from. */
54
+ export function repositoryCommitHref(
55
+ commit: string,
56
+ info: BuildInfo = buildInfo,
57
+ ): string | null {
58
+ if (!commit || !info.repoUrl) return null;
59
+
60
+ const repo = info.repoUrl.replace(/\/$/, "");
61
+ const forge =
62
+ info.forge ??
63
+ (/gitlab/i.test(repo)
64
+ ? "gitlab"
65
+ : /github/i.test(repo)
66
+ ? "github"
67
+ : null);
68
+ if (!forge) return null;
69
+
70
+ const route = forge === "gitlab" ? "/-/commit/" : "/commit/";
71
+ return `${repo}${route}${encodeURIComponent(commit)}`;
72
+ }
73
+
53
74
  /**
54
75
  * What the stamp reads: the commit, because that is the thing you can look up
55
76
  * in the repo. The build number is CI bookkeeping and lives in the tooltip.
@@ -15,6 +15,7 @@ import type {
15
15
  Column,
16
16
  ColumnOwner,
17
17
  Field,
18
+ RedisKeyspace,
18
19
  Service,
19
20
  Store,
20
21
  Table,
@@ -128,6 +129,19 @@ export interface PersistedTable {
128
129
  store: Store;
129
130
  }
130
131
 
132
+ export interface PersistedRedisKeyspace {
133
+ keyspace: RedisKeyspace;
134
+ store: Store;
135
+ }
136
+
137
+ /** Redis key families whose serialized value is an aggregate snapshot. */
138
+ export function redisKeyspacesPersisting(
139
+ index: CatalogIndex,
140
+ aggregateId: string,
141
+ ): PersistedRedisKeyspace[] {
142
+ return index.keyspacesByAggregate.get(aggregateId) ?? [];
143
+ }
144
+
131
145
  /**
132
146
  * The tables that hold an aggregate — those naming it in `persists`, plus the
133
147
  * ones naming one of its blocks. A child table pointing at a value object of
@@ -42,7 +42,7 @@ export function flowHealth(flow: Flow): FlowHealth {
42
42
  const steps = walkSteps(flow.steps);
43
43
  const statuses: Status[] = steps.map((s) => s.status);
44
44
  if (statuses.some((s) => s === "unresolved")) return "unresolved";
45
- const hops = steps.filter((s) => s.kind !== "call");
45
+ const hops = steps.filter((s) => s.kind !== "call" && s.kind !== "response");
46
46
  if (hops.length > 0 && hops.every((s) => s.status === "verified"))
47
47
  return "verified";
48
48
  return "declared";
@@ -6,6 +6,7 @@ import {
6
6
  listForgeBranches,
7
7
  listForgeRefs,
8
8
  listGitHubBranches,
9
+ loadForgeCommit,
9
10
  loadForgeCatalog,
10
11
  loadGitHubCatalog,
11
12
  } from "./github-catalog";
@@ -140,6 +141,56 @@ describe("listGitHubBranches", () => {
140
141
  });
141
142
  });
142
143
 
144
+ describe("loadForgeCommit", () => {
145
+ it("normalizes GitHub commit metadata for the hover card", async () => {
146
+ const fetch = vi.fn().mockResolvedValue(new Response(JSON.stringify({
147
+ sha: SHA,
148
+ html_url: `${REPO.webUrl}/commit/${SHA}`,
149
+ commit: {
150
+ message: "Explain the decision\n\nKeep the useful detail.",
151
+ author: { name: "Ada", date: "2026-09-08T10:00:00Z" },
152
+ },
153
+ author: { login: "ada", avatar_url: "https://avatars.example/ada" },
154
+ stats: { additions: 12, deletions: 3 },
155
+ }), { status: 200 }));
156
+ vi.stubGlobal("fetch", fetch);
157
+
158
+ await expect(loadForgeCommit(REPO, SHA, { token: "secret" })).resolves.toEqual({
159
+ sha: SHA,
160
+ title: "Explain the decision",
161
+ body: "Keep the useful detail.",
162
+ author: "ada",
163
+ authoredAt: "2026-09-08T10:00:00Z",
164
+ avatarUrl: "https://avatars.example/ada",
165
+ additions: 12,
166
+ deletions: 3,
167
+ webUrl: `${REPO.webUrl}/commit/${SHA}`,
168
+ });
169
+ expect(new Headers(fetch.mock.calls[0]?.[1]?.headers).get("authorization")).toBe("Bearer secret");
170
+ });
171
+
172
+ it("normalizes GitLab commit metadata for the same card", async () => {
173
+ vi.stubGlobal("fetch", vi.fn().mockResolvedValue(new Response(JSON.stringify({
174
+ id: SHA,
175
+ title: "Explain the decision",
176
+ message: "Explain the decision\n\nKeep the useful detail.",
177
+ author_name: "Ada",
178
+ authored_date: "2026-09-08T10:00:00Z",
179
+ web_url: `${GITLAB_REPO.webUrl}/-/commit/${SHA}`,
180
+ stats: { additions: 12, deletions: 3 },
181
+ }), { status: 200 })));
182
+
183
+ await expect(loadForgeCommit(GITLAB_REPO, SHA)).resolves.toMatchObject({
184
+ sha: SHA,
185
+ title: "Explain the decision",
186
+ body: "Keep the useful detail.",
187
+ author: "Ada",
188
+ additions: 12,
189
+ deletions: 3,
190
+ });
191
+ });
192
+ });
193
+
143
194
  describe("loadGitHubCatalog", () => {
144
195
  it("discovers manifest sources at an immutable SHA and validates the merge", async () => {
145
196
  const source = {
@@ -33,6 +33,18 @@ export type ForgeRepo = GitHubRepo | GitLabRepo;
33
33
 
34
34
  export type ForgeAccess = { token?: string };
35
35
 
36
+ export type ForgeCommit = {
37
+ sha: string;
38
+ title: string;
39
+ body: string;
40
+ author: string;
41
+ authoredAt: string;
42
+ avatarUrl: string;
43
+ additions: number | null;
44
+ deletions: number | null;
45
+ webUrl: string;
46
+ };
47
+
36
48
  /**
37
49
  * A name the forge resolves to a commit: a branch head as it is now, or a
38
50
  * tag. Both are what a reader compares against; the kind is kept because a
@@ -263,6 +275,63 @@ function gitlabError(response: Response): Error {
263
275
  return new Error(`GitLab API request failed (${response.status}).`);
264
276
  }
265
277
 
278
+ /** Commit metadata for a hover card, normalized across GitHub and GitLab. */
279
+ export async function loadForgeCommit(
280
+ repo: ForgeRepo,
281
+ sha: string,
282
+ access: ForgeAccess = {},
283
+ ): Promise<ForgeCommit> {
284
+ if (repo.provider === "github") {
285
+ const found = await githubJson<{
286
+ sha: string;
287
+ html_url: string;
288
+ commit: {
289
+ message: string;
290
+ author: { name: string; date: string } | null;
291
+ };
292
+ author: { login: string; avatar_url: string } | null;
293
+ stats?: { additions: number; deletions: number };
294
+ }>(apiUrl(repo, `/commits/${encodeURIComponent(sha)}`), access.token);
295
+ const [title = found.sha.slice(0, 7), ...body] = found.commit.message.split("\n");
296
+ return {
297
+ sha: found.sha,
298
+ title,
299
+ body: body.join("\n").trim(),
300
+ author: found.author?.login || found.commit.author?.name || "unknown",
301
+ authoredAt: found.commit.author?.date || "",
302
+ avatarUrl: found.author?.avatar_url || "",
303
+ additions: found.stats?.additions ?? null,
304
+ deletions: found.stats?.deletions ?? null,
305
+ webUrl: found.html_url,
306
+ };
307
+ }
308
+
309
+ const found = await gitlabJson<{
310
+ id: string;
311
+ title: string;
312
+ message: string;
313
+ author_name: string;
314
+ authored_date: string;
315
+ web_url: string;
316
+ stats?: { additions: number; deletions: number };
317
+ }>(
318
+ gitlabApiUrl(repo, `/repository/commits/${encodeURIComponent(sha)}`),
319
+ access.token,
320
+ );
321
+ const body = found.message.slice(found.title.length).trim();
322
+ return {
323
+ sha: found.id,
324
+ title: found.title,
325
+ body,
326
+ author: found.author_name || "unknown",
327
+ authoredAt: found.authored_date || "",
328
+ avatarUrl: "",
329
+ additions: found.stats?.additions ?? null,
330
+ deletions: found.stats?.deletions ?? null,
331
+ webUrl: found.web_url,
332
+ };
333
+ }
334
+
266
335
  async function fetchText(
267
336
  url: string,
268
337
  label: string,
@@ -0,0 +1,72 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import type { RpcMethod, RpcService } from "../catalog";
3
+ import { grpcRequestJson, grpcurlCommand } from "./grpc-reference";
4
+
5
+ const method: RpcMethod = {
6
+ name: "Create",
7
+ request: "CreateRequest",
8
+ response: "CreateResponse",
9
+ };
10
+
11
+ const provided: RpcService = {
12
+ id: "shop.v1.Orders",
13
+ source: "proto/shop/v1/orders.proto:10",
14
+ methods: [method],
15
+ enums: [
16
+ {
17
+ name: "Priority",
18
+ values: [
19
+ { name: "PRIORITY_UNSPECIFIED", number: 0 },
20
+ { name: "PRIORITY_HIGH", number: 1 },
21
+ ],
22
+ },
23
+ ],
24
+ messages: [
25
+ {
26
+ name: "CreateRequest",
27
+ fields: [
28
+ { name: "name", type: "string", doc: "" },
29
+ { name: "quantity", type: "int64", doc: "" },
30
+ { name: "expedited", type: "bool", doc: "" },
31
+ { name: "priority", type: "Priority", doc: "" },
32
+ { name: "lines", type: "[]Line", doc: "" },
33
+ { name: "when", type: "Timestamp", doc: "" },
34
+ { name: "money", type: "Money", ref: "Money", doc: "" },
35
+ ],
36
+ },
37
+ { name: "CreateResponse", fields: [] },
38
+ ],
39
+ };
40
+
41
+ describe("gRPC reference examples", () => {
42
+ it("builds a ProtoJSON request from scalars, enums, lists and shared defs", () => {
43
+ expect(JSON.parse(grpcRequestJson(provided, method, {
44
+ Money: {
45
+ fields: [
46
+ { name: "currency", type: "string", doc: "" },
47
+ { name: "minor", type: "int64", doc: "" },
48
+ ],
49
+ },
50
+ }))).toEqual({
51
+ name: "",
52
+ quantity: "0",
53
+ expedited: false,
54
+ priority: "PRIORITY_UNSPECIFIED",
55
+ lines: [],
56
+ when: "1970-01-01T00:00:00Z",
57
+ money: { currency: "", minor: "0" },
58
+ });
59
+ });
60
+
61
+ it("emits a ready-to-edit grpcurl command", () => {
62
+ const command = grpcurlCommand(provided, method, {});
63
+ expect(command).toContain("grpcurl -plaintext");
64
+ expect(command).toContain("localhost:50051");
65
+ expect(command).toContain("shop.v1.Orders/Create");
66
+ expect(command).toContain(`"quantity": "0"`);
67
+ });
68
+
69
+ it("falls back to an empty body when no request shape was extracted", () => {
70
+ expect(grpcRequestJson(provided, { name: "Ping" }, {})).toBe("{}");
71
+ });
72
+ });
@@ -0,0 +1,117 @@
1
+ // Small, executable examples for a gRPC method.
2
+ //
3
+ // The protobuf extractor already leaves the catalog with enough information
4
+ // to sketch a request. Keeping that derivation here means the reference UI
5
+ // does not need to understand scalar mappings, recursive messages or shell
6
+ // quoting, and it gives the generated command a focused test surface.
7
+
8
+ import type { Field, RpcMethod, RpcService, TypeDef } from "../catalog";
9
+ import { parseType } from "./shape";
10
+
11
+ const NUMBER = new Set([
12
+ "double",
13
+ "float",
14
+ "int32",
15
+ "sint32",
16
+ "sfixed32",
17
+ "uint32",
18
+ "fixed32",
19
+ ]);
20
+ const LONG = new Set([
21
+ "int64",
22
+ "sint64",
23
+ "sfixed64",
24
+ "uint64",
25
+ "fixed64",
26
+ ]);
27
+
28
+ function shortName(name: string): string {
29
+ return name.split(".").at(-1) ?? name;
30
+ }
31
+
32
+ function fieldsFor(
33
+ provided: RpcService,
34
+ name: string,
35
+ ref: string | undefined,
36
+ defs: Record<string, TypeDef>,
37
+ ): Field[] | null {
38
+ if (ref && defs[ref]) return defs[ref].fields;
39
+ const bare = shortName(name);
40
+ return (
41
+ provided.messages?.find(
42
+ (message) => message.name === name || message.name === bare,
43
+ )?.fields ?? null
44
+ );
45
+ }
46
+
47
+ function valueFor(
48
+ provided: RpcService,
49
+ field: Field,
50
+ defs: Record<string, TypeDef>,
51
+ visiting: ReadonlySet<string>,
52
+ ): unknown {
53
+ const parsed = parseType(field.type);
54
+ if (parsed.cardinality === "many") return [];
55
+ if (parsed.cardinality === "map") return {};
56
+
57
+ const type = shortName(parsed.base);
58
+ if (type === "string" || type === "bytes") return "";
59
+ if (type === "bool") return false;
60
+ if (NUMBER.has(type)) return 0;
61
+ // ProtoJSON writes 64-bit integers as decimal strings so their precision is
62
+ // not lost in JavaScript.
63
+ if (LONG.has(type)) return "0";
64
+ if (type === "Timestamp") return "1970-01-01T00:00:00Z";
65
+ if (type === "Duration") return "0s";
66
+ if (type === "FieldMask") return "";
67
+ if (type === "Struct") return {};
68
+ if (type === "Value") return null;
69
+ if (type === "ListValue") return [];
70
+ if (type === "Empty") return {};
71
+ if (type === "Any") return { "@type": "" };
72
+
73
+ const set = provided.enums?.find(
74
+ (candidate) => candidate.name === parsed.base || candidate.name === type,
75
+ );
76
+ if (set) return set.values[0]?.name ?? "";
77
+
78
+ const nested = fieldsFor(provided, parsed.base, field.ref, defs);
79
+ if (!nested || visiting.has(parsed.base)) return {};
80
+
81
+ const next = new Set(visiting).add(parsed.base);
82
+ return Object.fromEntries(
83
+ nested.map((child) => [child.name, valueFor(provided, child, defs, next)]),
84
+ );
85
+ }
86
+
87
+ /** A valid ProtoJSON-shaped starting point for a method request. */
88
+ export function grpcRequestJson(
89
+ provided: RpcService,
90
+ method: RpcMethod,
91
+ defs: Record<string, TypeDef>,
92
+ ): string {
93
+ if (!method.request) return "{}";
94
+ const fields = fieldsFor(provided, method.request, method.requestRef, defs);
95
+ if (!fields) return "{}";
96
+
97
+ const visiting = new Set([method.request]);
98
+ const value = Object.fromEntries(
99
+ fields.map((field) => [field.name, valueFor(provided, field, defs, visiting)]),
100
+ );
101
+ return JSON.stringify(value, null, 2);
102
+ }
103
+
104
+ /** A copyable local-development invocation. The endpoint is deliberately visible. */
105
+ export function grpcurlCommand(
106
+ provided: RpcService,
107
+ method: RpcMethod,
108
+ defs: Record<string, TypeDef>,
109
+ ): string {
110
+ const body = grpcRequestJson(provided, method, defs).replace(/'/g, `'"'"'`);
111
+ return [
112
+ "grpcurl -plaintext \\",
113
+ ` -d '${body}' \\`,
114
+ " localhost:50051 \\",
115
+ ` ${provided.id}/${method.name}`,
116
+ ].join("\n");
117
+ }
@@ -1,4 +1,4 @@
1
- import type { SetupInfo, SetupPhase, SetupRunStepStatus } from "./setup-info";
1
+ import type { SetupInfo, SetupPhase, SetupProject, SetupRunStepStatus } from "./setup-info";
2
2
 
3
3
  const ROOT = `${import.meta.env.BASE_URL}__portolan`;
4
4
  const LOCAL_HEADER = { "Content-Type": "application/json", "X-Portolan-Local": "1" };
@@ -18,6 +18,7 @@ export interface Discovery {
18
18
  truncated: boolean;
19
19
  components: Array<{ path: string; name: string; markers: string[]; technologies: string[] }>;
20
20
  componentsTruncated: boolean;
21
+ deployables: Array<{ slug: string; name: string; path: string; kind: "service"; confidence: "high" | "medium"; evidence: string[] }>;
21
22
  defaults: { id: string; name: string; group: string; component: string; context: string; service: string };
22
23
  detections: Detection[];
23
24
  }
@@ -35,13 +36,17 @@ export interface ProjectDraft {
35
36
  component: string;
36
37
  context?: string;
37
38
  service?: string;
39
+ contextName?: string;
40
+ contextSummary?: string;
41
+ classification?: "core" | "supporting" | "generic";
42
+ replaceStarter?: boolean;
38
43
  groupKind?: "bounded-context" | "system" | "product" | "team" | "namespace";
39
44
  componentKind?: "service" | "application" | "webapp" | "worker" | "job" | "function" | "cli" | "library" | "data-pipeline";
40
45
  plugins: string[];
41
46
  }
42
47
 
43
48
  export interface ProjectPlan {
44
- project: { id: string; name: string; root: string; group?: string; component?: string; groupKind?: string; componentKind?: string; context?: string; service?: string; repository?: string };
49
+ project: { id: string; name: string; root: string; group?: string; component?: string; components?: string[]; groupKind?: string; componentKind?: string; context?: string; service?: string; repository?: string };
45
50
  plugins: string[];
46
51
  steps: Array<{ plugin: string; in: string; out: string; options: Record<string, unknown> }>;
47
52
  source: string;
@@ -196,15 +201,23 @@ export async function previewProject(draft: ProjectDraft): Promise<ProjectPlan>
196
201
  return json("/projects/preview", { method: "POST", headers: LOCAL_HEADER, body: JSON.stringify(draft) });
197
202
  }
198
203
 
199
- export async function addProject(draft: ProjectDraft): Promise<ProjectPlan & { setup: SetupInfo }> {
204
+ export async function addProject(draft: ProjectDraft): Promise<ProjectPlan & { undoToken: string; setup: SetupInfo }> {
200
205
  return json("/projects", { method: "POST", headers: LOCAL_HEADER, body: JSON.stringify(draft) });
201
206
  }
202
207
 
208
+ export async function removeProject(id: string): Promise<{ project: SetupProject; removedOutputs: string[]; undoToken: string; setup: SetupInfo }> {
209
+ return json(`/projects/${encodeURIComponent(id)}/remove`, { method: "POST", headers: LOCAL_HEADER, body: "{}" });
210
+ }
211
+
212
+ export async function undoProjectRemoval(undoToken: string): Promise<{ restored: true; setup: SetupInfo }> {
213
+ return json("/projects/removals/undo", { method: "POST", headers: LOCAL_HEADER, body: JSON.stringify({ undoToken }) });
214
+ }
215
+
203
216
  export async function startProjectTrial(draft: ProjectDraft): Promise<{ runId: string; mode: "project-preview"; plan: ProjectPlan }> {
204
217
  return json("/projects/trials", { method: "POST", headers: LOCAL_HEADER, body: JSON.stringify(draft) });
205
218
  }
206
219
 
207
- export async function applyProjectTrial(runId: string, generate: boolean): Promise<ProjectPlan & { setup: SetupInfo; run: { runId: string; mode: "write" } | null }> {
220
+ export async function applyProjectTrial(runId: string, generate: boolean): Promise<ProjectPlan & { undoToken: string; setup: SetupInfo; run: { runId: string; mode: "write" } | null }> {
208
221
  return json(`/projects/trials/${encodeURIComponent(runId)}/apply`, { method: "POST", headers: LOCAL_HEADER, body: JSON.stringify({ generate }) });
209
222
  }
210
223
 
@@ -0,0 +1,7 @@
1
+ /** Canonical public pages for Portolan itself, not the catalog being viewed. */
2
+ export const PRODUCT_REPOSITORY =
3
+ "https://github.com/shortlink-org/portolan";
4
+
5
+ export const PRODUCT_README = `${PRODUCT_REPOSITORY}#readme`;
6
+ export const PRODUCT_ISSUES = `${PRODUCT_REPOSITORY}/issues`;
7
+ export const PRODUCT_LICENSE = `${PRODUCT_REPOSITORY}/blob/main/LICENSE`;
@@ -16,7 +16,7 @@ import type { Catalog } from "../catalog";
16
16
  import { diffCatalogs } from "./catalog-diff";
17
17
  import type { Change } from "./catalog-diff";
18
18
  import { findRef } from "./forge-refs";
19
- import { listForgeRefs, loadForgeCatalog } from "./github-catalog";
19
+ import { listForgeRefs, loadForgeCatalog, loadForgeCommit } from "./github-catalog";
20
20
  import type { ForgeRef, ForgeRepo } from "./github-catalog";
21
21
  import { localStatus } from "./local-api";
22
22
  import { loadSourceCode } from "./source-code";
@@ -30,6 +30,8 @@ export const forgeKeys = {
30
30
  ["forge", "refs", repo?.provider ?? "", repo?.webUrl ?? "", token] as const,
31
31
  comparison: (repo: ForgeRepo | null, token: string, baseSha: string, headSha: string) =>
32
32
  ["forge", "comparison", repo?.provider ?? "", repo?.webUrl ?? "", baseSha, headSha, token] as const,
33
+ commit: (repo: ForgeRepo | null, token: string, sha: string) =>
34
+ ["forge", "commit", repo?.provider ?? "", repo?.webUrl ?? "", sha, token] as const,
33
35
  };
34
36
 
35
37
  export const sourceKeys = {
@@ -56,6 +58,19 @@ export function forgeRefsQuery(repo: ForgeRepo | null, token: string) {
56
58
  });
57
59
  }
58
60
 
61
+ /** One immutable commit, fetched only when its hover card is open. */
62
+ export function forgeCommitQuery(repo: ForgeRepo | null, token: string, sha: string) {
63
+ return queryOptions({
64
+ queryKey: forgeKeys.commit(repo, token, sha),
65
+ queryFn: () => {
66
+ if (!repo) throw new Error("No forge repository is configured.");
67
+ return loadForgeCommit(repo, sha, { token });
68
+ },
69
+ enabled: repo !== null && Boolean(sha),
70
+ staleTime: Infinity,
71
+ });
72
+ }
73
+
59
74
  /**
60
75
  * One side of a comparison: the name a reader typed, the commit it resolves
61
76
  * to, and the catalog itself when the side is the one this build was made
@@ -0,0 +1,19 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { readmeAssetHref } from "./readme-assets";
3
+
4
+ describe("readmeAssetHref", () => {
5
+ it("mounts README-relative files below the configured site base", () => {
6
+ expect(readmeAssetHref("vendor/repos/acme/shop", "./docs/example.png", "/catalog/")).toBe(
7
+ "/catalog/portolan-assets/vendor/repos/acme/shop/docs/example.png",
8
+ );
9
+ expect(readmeAssetHref("services/api", "docs/file.json#example", "/")).toBe(
10
+ "/portolan-assets/services/api/docs/file.json#example",
11
+ );
12
+ });
13
+
14
+ it("leaves remote, root-relative and escaping references alone", () => {
15
+ expect(readmeAssetHref("services/api", "https://example.com/image.png")).toBeNull();
16
+ expect(readmeAssetHref("services/api", "/logo.svg")).toBeNull();
17
+ expect(readmeAssetHref("services/api", "../secret.png")).toBeNull();
18
+ });
19
+ });
@@ -0,0 +1,34 @@
1
+ /** Turn a README-relative file into the public asset staged beside the app. */
2
+ export function readmeAssetHref(
3
+ root: string,
4
+ target: string,
5
+ base = "/",
6
+ ): string | null {
7
+ const value = target.trim();
8
+ if (
9
+ !value ||
10
+ value.startsWith("#") ||
11
+ value.startsWith("/") ||
12
+ /^[a-zA-Z][a-zA-Z0-9+.-]*:/.test(value)
13
+ ) return null;
14
+
15
+ const suffixAt = value.search(/[?#]/);
16
+ const pathname = suffixAt < 0 ? value : value.slice(0, suffixAt);
17
+ const suffix = suffixAt < 0 ? "" : value.slice(suffixAt);
18
+ const rootParts = root.replaceAll("\\", "/").split("/").filter(Boolean);
19
+ const parts = [...rootParts];
20
+ for (const part of pathname.replaceAll("\\", "/").split("/")) {
21
+ if (!part || part === ".") continue;
22
+ if (part === "..") {
23
+ if (parts.length === rootParts.length) return null;
24
+ parts.pop();
25
+ continue;
26
+ }
27
+ try { parts.push(decodeURIComponent(part)); }
28
+ catch { return null; }
29
+ }
30
+ if (parts.length === rootParts.length) return null;
31
+
32
+ const prefix = base.endsWith("/") ? base : `${base}/`;
33
+ return `${prefix}portolan-assets/${parts.map((part) => encodeURIComponent(part)).join("/")}${suffix}`;
34
+ }
@@ -91,6 +91,13 @@ describe("publicSetupFrom", () => {
91
91
  });
92
92
  });
93
93
 
94
+ it("publishes the deployable components of one repository", () => {
95
+ const setup = publicSetupFrom({
96
+ projects: [{ id: "platform", name: "Platform", root: "platform", group: "platform", components: ["api", "billing", "book", "user"] }],
97
+ });
98
+ expect(setup.projects[0]?.components).toEqual(["api", "billing", "book", "user"]);
99
+ });
100
+
94
101
  it("claims the sandbox only for a plugin that declares a module", () => {
95
102
  const setup = publicSetupFrom({
96
103
  plugins: [
@@ -17,6 +17,7 @@ export interface SetupProject {
17
17
  service?: string;
18
18
  group?: string;
19
19
  component?: string;
20
+ components?: string[];
20
21
  groupKind?: string;
21
22
  componentKind?: string;
22
23
  repository?: string;
@@ -99,6 +100,7 @@ interface ManifestProject {
99
100
  service?: unknown;
100
101
  group?: unknown;
101
102
  component?: unknown;
103
+ components?: unknown;
102
104
  groupKind?: unknown;
103
105
  componentKind?: unknown;
104
106
  repository?: unknown;
@@ -289,6 +291,9 @@ function projectFrom(value: unknown): SetupProject | null {
289
291
  ...(typeof item.service === "string" ? { service: item.service } : {}),
290
292
  ...(typeof item.group === "string" ? { group: item.group } : {}),
291
293
  ...(typeof item.component === "string" ? { component: item.component } : {}),
294
+ ...(array(item.components).every((component) => typeof component === "string") && array(item.components).length > 0
295
+ ? { components: array(item.components) as string[] }
296
+ : {}),
292
297
  ...(typeof item.groupKind === "string" ? { groupKind: item.groupKind } : {}),
293
298
  ...(typeof item.componentKind === "string" ? { componentKind: item.componentKind } : {}),
294
299
  ...(repository ? { repository } : {}),