@shortlink-org/portolan 0.2.0 → 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.
- package/cli/init.mjs +14 -6
- package/cli/init.test.mjs +8 -1
- package/cli/portolan.mjs +56 -7
- package/cli/portolan.test.mjs +47 -0
- package/package.json +7 -6
- package/plugins/extract-django/README.md +1 -1
- package/plugins/extract-django/extract_test.py +1 -1
- package/plugins/extract-django/openapi.py +4 -14
- package/plugins/extract-django/openapi_test.py +4 -4
- package/plugins/extract-go/options.schema.json +5 -0
- package/plugins/extract-project/options.schema.json +21 -0
- package/plugins/extract-ts/README.md +1 -1
- package/plugins/extract-ts/clients.ts +1 -1
- package/plugins/extract-ts/extract.test.ts +2 -2
- package/plugins/extract-ts/graphql.test.ts +2 -2
- package/plugins/extract-ts/openapi.test.ts +6 -7
- package/plugins/extract-ts/openapi.ts +3 -10
- package/plugins/portolan-go.wasm +0 -0
- package/public/404-cat-v1.webp +0 -0
- package/public/cat-about-v1.webp +0 -0
- package/public/cat-clear-skies-v1.webp +0 -0
- package/public/cat-no-changes-v1.webp +0 -0
- package/public/cat-onboarding-v1.webp +0 -0
- package/public/cat-trial-success-v1.webp +0 -0
- package/schema/portolan.schema.json +61 -0
- package/scripts/catalog-sources.mjs +12 -1
- package/scripts/catalog-sources.test.mjs +35 -0
- package/scripts/gen-likec4.mjs +242 -85
- package/scripts/gen-likec4.test.mjs +168 -14
- package/scripts/gen.mjs +5 -3
- package/scripts/host-plugins/fetch-git.mjs +22 -12
- package/scripts/host-plugins/fetch-git.test.mjs +25 -5
- package/scripts/local-api.mjs +368 -34
- package/scripts/local-api.test.mjs +197 -2
- package/scripts/package-smoke.mjs +19 -2
- package/scripts/plugin-host.mjs +14 -3
- package/scripts/plugin-host.test.mjs +8 -0
- package/scripts/schema.mjs +7 -0
- package/src/app/CatalogApp.tsx +19 -4
- package/src/app/CommandPalette.tsx +7 -2
- package/src/app/toast.tsx +11 -7
- package/src/catalog.test.ts +33 -0
- package/src/catalog.ts +190 -8
- package/src/components/CatIllustration.tsx +30 -0
- package/src/components/CommitLink.tsx +154 -0
- package/src/components/EditorLink.tsx +15 -8
- package/src/components/GrpcMethodReference.tsx +93 -0
- package/src/components/Markdown.tsx +17 -0
- package/src/components/MethodRows.tsx +8 -1
- package/src/components/PageHeader.test.ts +24 -0
- package/src/components/PageHeader.tsx +34 -0
- package/src/components/ShapeRows.tsx +12 -0
- package/src/components/SourceDoc.tsx +27 -12
- package/src/components/SourcePreview.tsx +124 -36
- package/src/enrich.test.ts +209 -2
- package/src/enrich.ts +284 -15
- package/src/er/RedisSchema.tsx +86 -5
- package/src/flow/FlowTable.tsx +19 -4
- package/src/flow/StepDetail.tsx +461 -101
- package/src/flow/StepRail.tsx +36 -13
- package/src/flow/answers-response.test.ts +100 -0
- package/src/flow/answers.ts +11 -8
- package/src/flow/chapters.ts +2 -1
- package/src/flow/labels.ts +10 -0
- package/src/flow/mermaid.test.ts +87 -3
- package/src/flow/mermaid.ts +26 -14
- package/src/index.css +132 -0
- package/src/landing/LandingPage.tsx +12 -8
- package/src/lib/api.test.ts +4 -6
- package/src/lib/build-info.test.ts +29 -0
- package/src/lib/build-info.ts +21 -0
- package/src/lib/data-model.ts +14 -0
- package/src/lib/flow-tree.ts +1 -1
- package/src/lib/github-catalog.test.ts +51 -0
- package/src/lib/github-catalog.ts +69 -0
- package/src/lib/grpc-reference.test.ts +72 -0
- package/src/lib/grpc-reference.ts +117 -0
- package/src/lib/local-api.ts +17 -4
- package/src/lib/product.ts +7 -0
- package/src/lib/queries.ts +16 -1
- package/src/lib/readme-assets.test.ts +19 -0
- package/src/lib/readme-assets.ts +34 -0
- package/src/lib/setup-info.test.ts +7 -0
- package/src/lib/setup-info.ts +5 -0
- package/src/lib/source-link.test.ts +106 -18
- package/src/lib/source-link.ts +22 -6
- package/src/merge.test.ts +55 -0
- package/src/merge.ts +30 -0
- package/src/pages/AdrDetail.tsx +3 -2
- package/src/pages/AdrIndex.tsx +10 -7
- package/src/pages/AggregatePage.tsx +34 -4
- package/src/pages/BlockPage.tsx +39 -32
- package/src/pages/Changes.tsx +7 -9
- package/src/pages/ContextMap.tsx +11 -2
- package/src/pages/ContextPage.tsx +11 -4
- package/src/pages/FlowIndex.tsx +16 -7
- package/src/pages/GraphPage.tsx +21 -7
- package/src/pages/Language.tsx +9 -9
- package/src/pages/NotFound.tsx +58 -15
- package/src/pages/Overview.tsx +6 -0
- package/src/pages/Problems.tsx +10 -8
- package/src/pages/RegistryIndex.tsx +10 -8
- package/src/pages/ServicePage.tsx +1 -1
- package/src/pages/Settings.tsx +382 -44
- package/src/routes.test.ts +2 -0
- package/src/routes.ts +2 -1
- package/src/selection/DetailPanel.tsx +213 -113
- package/src/testing/setup.ts +14 -0
- package/vite.config.ts +5 -1
package/src/lib/api.test.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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
|
|
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");
|
package/src/lib/build-info.ts
CHANGED
|
@@ -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.
|
package/src/lib/data-model.ts
CHANGED
|
@@ -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
|
package/src/lib/flow-tree.ts
CHANGED
|
@@ -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
|
+
}
|
package/src/lib/local-api.ts
CHANGED
|
@@ -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`;
|
package/src/lib/queries.ts
CHANGED
|
@@ -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: [
|
package/src/lib/setup-info.ts
CHANGED
|
@@ -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 } : {}),
|