@shortlink-org/portolan 0.2.1 → 0.2.3
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/README.md +7 -2
- package/cli/init.mjs +14 -6
- package/cli/init.test.mjs +8 -1
- package/cli/portolan.mjs +92 -13
- 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/builtin-plugins.mjs +3 -2
- package/scripts/catalog-sources.mjs +14 -2
- package/scripts/catalog-sources.test.mjs +45 -0
- package/scripts/delivery-presets.mjs +207 -24
- package/scripts/diff.mjs +5 -1
- 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 +330 -346
- package/scripts/local-api.test.mjs +245 -1
- package/scripts/local-discovery.mjs +378 -0
- package/scripts/manifest.mjs +42 -1
- package/scripts/manifest.test.mjs +24 -1
- package/scripts/plugin-host.mjs +14 -3
- package/scripts/plugin-host.test.mjs +8 -0
- package/scripts/run-builtin.mjs +4 -2
- package/scripts/schema.mjs +11 -0
- package/scripts/site-docs.mjs +2 -2
- package/src/app/CatalogApp.tsx +23 -8
- package/src/app/CommandPalette.tsx +7 -2
- package/src/app/Sidebar.tsx +13 -730
- package/src/app/SidebarFlowSections.tsx +251 -0
- package/src/app/SidebarFooter.tsx +160 -0
- package/src/app/SidebarTree.tsx +322 -0
- package/src/app/toast.tsx +11 -7
- package/src/catalog-index.ts +485 -0
- package/src/catalog-model.ts +1339 -0
- package/src/catalog-validation.ts +1570 -0
- package/src/catalog.test.ts +49 -0
- package/src/catalog.ts +6 -3124
- 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.tsx +23 -0
- package/src/components/PageHeader.tsx +34 -0
- package/src/components/ProblemRow.tsx +3 -0
- package/src/components/ShapeRows.tsx +12 -0
- package/src/components/SourceDoc.tsx +27 -12
- package/src/components/SourcePreview.tsx +125 -37
- 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 -17
- package/src/landing/LandingPage.tsx +16 -12
- package/src/lib/all-problems.ts +1 -1
- 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/derive.ts +1 -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 +36 -11
- package/src/lib/motion.test.ts +4 -2
- package/src/lib/motion.tsx +5 -4
- package/src/lib/product.ts +7 -0
- package/src/lib/proto-problems.test.ts +170 -3
- package/src/lib/proto-problems.ts +176 -4
- 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 +101 -0
- package/src/merge.ts +65 -1
- 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 +257 -44
- package/src/pages/settings/AboutSettings.tsx +129 -0
- package/src/pages/settings/DeliverySettings.tsx +71 -15
- package/src/routes.test.ts +2 -0
- package/src/routes.ts +2 -1
- package/src/selection/DetailPanel.tsx +213 -113
- package/src/selection/pages.test.ts +14 -1
- package/src/selection/pages.ts +9 -3
- package/src/testing/setup.ts +14 -0
- package/vite.config.ts +3 -1
|
@@ -1,9 +1,4 @@
|
|
|
1
|
-
import {
|
|
2
|
-
mkdirSync,
|
|
3
|
-
mkdtempSync,
|
|
4
|
-
readFileSync,
|
|
5
|
-
writeFileSync,
|
|
6
|
-
} from "node:fs";
|
|
1
|
+
import { mkdirSync, mkdtempSync, readFileSync, writeFileSync } from "node:fs";
|
|
7
2
|
import { tmpdir } from "node:os";
|
|
8
3
|
import { dirname, join } from "node:path";
|
|
9
4
|
import { fileURLToPath } from "node:url";
|
|
@@ -12,7 +7,12 @@ import { execFileSync } from "node:child_process";
|
|
|
12
7
|
import { describe, expect, it } from "vitest";
|
|
13
8
|
|
|
14
9
|
const generator = fileURLToPath(new URL("./gen-likec4.mjs", import.meta.url));
|
|
15
|
-
const likec4 = join(
|
|
10
|
+
const likec4 = join(
|
|
11
|
+
dirname(dirname(generator)),
|
|
12
|
+
"node_modules",
|
|
13
|
+
".bin",
|
|
14
|
+
"likec4",
|
|
15
|
+
);
|
|
16
16
|
|
|
17
17
|
/** Runs the generator over one catalog in a scratch tree, and validates what it wrote. */
|
|
18
18
|
function generate(catalog) {
|
|
@@ -35,12 +35,151 @@ function generate(catalog) {
|
|
|
35
35
|
execFileSync(process.execPath, [generator], { cwd: root });
|
|
36
36
|
execFileSync(likec4, ["validate", "likec4"], { cwd: root });
|
|
37
37
|
return {
|
|
38
|
+
spec: readFileSync(join(root, "likec4", "spec.c4"), "utf8"),
|
|
38
39
|
model: readFileSync(join(root, "likec4", "model.c4"), "utf8"),
|
|
39
40
|
views: readFileSync(join(root, "likec4", "views.c4"), "utf8"),
|
|
40
41
|
};
|
|
41
42
|
}
|
|
42
43
|
|
|
43
44
|
describe("the LikeC4 generator", () => {
|
|
45
|
+
it("emits valid fallback views for an empty catalog", () => {
|
|
46
|
+
const { model, views } = generate({ contexts: [], flows: [] });
|
|
47
|
+
expect(model).toContain("model {");
|
|
48
|
+
expect(views).toContain("view landscape");
|
|
49
|
+
expect(views).toContain("view landscape_default");
|
|
50
|
+
expect(views).toContain("view containers_default");
|
|
51
|
+
expect(views).toContain("include *");
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it("makes the implicit default profile include the complete catalog", () => {
|
|
55
|
+
const { views } = generate({
|
|
56
|
+
contexts: [
|
|
57
|
+
{
|
|
58
|
+
id: "platform",
|
|
59
|
+
slug: "platform",
|
|
60
|
+
name: "Platform",
|
|
61
|
+
summary: "",
|
|
62
|
+
services: [
|
|
63
|
+
{
|
|
64
|
+
id: "platform.api",
|
|
65
|
+
slug: "api",
|
|
66
|
+
name: "API",
|
|
67
|
+
repo: "example/platform",
|
|
68
|
+
path: "",
|
|
69
|
+
readme: "",
|
|
70
|
+
provides: [],
|
|
71
|
+
consumes: [],
|
|
72
|
+
aggregates: [],
|
|
73
|
+
},
|
|
74
|
+
],
|
|
75
|
+
},
|
|
76
|
+
],
|
|
77
|
+
flows: [],
|
|
78
|
+
});
|
|
79
|
+
expect(views).toContain("view landscape_default");
|
|
80
|
+
expect(views).toContain("include platform");
|
|
81
|
+
expect(views).toContain("view containers_default");
|
|
82
|
+
expect(views).toContain("include platform, platform.api");
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
it("draws synchronous requests solid and synthesized responses dashed", () => {
|
|
86
|
+
const service = (slug, provides = []) => ({
|
|
87
|
+
id: `demo.${slug}`,
|
|
88
|
+
slug,
|
|
89
|
+
name: slug,
|
|
90
|
+
repo: "example/demo",
|
|
91
|
+
path: slug,
|
|
92
|
+
readme: "",
|
|
93
|
+
provides,
|
|
94
|
+
consumes: [],
|
|
95
|
+
aggregates: [],
|
|
96
|
+
});
|
|
97
|
+
const { spec, views } = generate({
|
|
98
|
+
contexts: [
|
|
99
|
+
{
|
|
100
|
+
id: "demo",
|
|
101
|
+
slug: "demo",
|
|
102
|
+
name: "Demo",
|
|
103
|
+
summary: "",
|
|
104
|
+
services: [
|
|
105
|
+
service("api"),
|
|
106
|
+
service("book", [
|
|
107
|
+
{
|
|
108
|
+
id: "book.v1.Book",
|
|
109
|
+
source: "book.proto",
|
|
110
|
+
methods: [
|
|
111
|
+
{
|
|
112
|
+
name: "Get",
|
|
113
|
+
request: "GetRequest",
|
|
114
|
+
response: "GetResponse",
|
|
115
|
+
},
|
|
116
|
+
],
|
|
117
|
+
},
|
|
118
|
+
]),
|
|
119
|
+
],
|
|
120
|
+
},
|
|
121
|
+
],
|
|
122
|
+
flows: [
|
|
123
|
+
{
|
|
124
|
+
id: "flow.get-book",
|
|
125
|
+
slug: "get-book",
|
|
126
|
+
name: "Get book",
|
|
127
|
+
summary: "",
|
|
128
|
+
owner: "demo",
|
|
129
|
+
participants: [
|
|
130
|
+
{ id: "demo.api", kind: "service", context: "demo" },
|
|
131
|
+
{ id: "demo.book", kind: "service", context: "demo" },
|
|
132
|
+
],
|
|
133
|
+
steps: [
|
|
134
|
+
{
|
|
135
|
+
type: "step",
|
|
136
|
+
id: "request",
|
|
137
|
+
from: "demo.api",
|
|
138
|
+
to: "demo.book",
|
|
139
|
+
kind: "rpc",
|
|
140
|
+
ref: "book.v1.Book/Get",
|
|
141
|
+
label: "Get",
|
|
142
|
+
status: "declared",
|
|
143
|
+
},
|
|
144
|
+
{
|
|
145
|
+
type: "step",
|
|
146
|
+
id: "response",
|
|
147
|
+
from: "demo.book",
|
|
148
|
+
to: "demo.api",
|
|
149
|
+
kind: "response",
|
|
150
|
+
label: "GetResponse",
|
|
151
|
+
status: "declared",
|
|
152
|
+
replyTo: "request",
|
|
153
|
+
},
|
|
154
|
+
{
|
|
155
|
+
type: "step",
|
|
156
|
+
id: "failure",
|
|
157
|
+
from: "demo.book",
|
|
158
|
+
to: "demo.api",
|
|
159
|
+
kind: "response",
|
|
160
|
+
label: "500 · Error",
|
|
161
|
+
status: "declared",
|
|
162
|
+
replyTo: "request",
|
|
163
|
+
http: { status: 500, outcome: "error" },
|
|
164
|
+
},
|
|
165
|
+
],
|
|
166
|
+
},
|
|
167
|
+
],
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
expect(views).toContain(
|
|
171
|
+
"demo.api -> demo.book 'Get' {\n color declared line solid head normal",
|
|
172
|
+
);
|
|
173
|
+
expect(views).toContain(
|
|
174
|
+
"demo.book -> demo.api 'GetResponse' {\n color declared line dashed head normal",
|
|
175
|
+
);
|
|
176
|
+
expect(views).toContain(
|
|
177
|
+
"demo.book -> demo.api '500 · Error' {\n color response_error line dashed head normal",
|
|
178
|
+
);
|
|
179
|
+
expect(spec).toContain("color response_error #b7646b");
|
|
180
|
+
expect(views).not.toContain("Get → GetResponse");
|
|
181
|
+
});
|
|
182
|
+
|
|
44
183
|
it("treats dots in a root participant id as data, not containment", () => {
|
|
45
184
|
const { model, views } = generate({
|
|
46
185
|
contexts: [
|
|
@@ -152,7 +291,12 @@ describe("the LikeC4 generator", () => {
|
|
|
152
291
|
slug: "basket-checked-out",
|
|
153
292
|
name: "BasketCheckedOut",
|
|
154
293
|
versions: [
|
|
155
|
-
{
|
|
294
|
+
{
|
|
295
|
+
version: "v1",
|
|
296
|
+
doc: "",
|
|
297
|
+
source: "cart.go:1",
|
|
298
|
+
fields: [],
|
|
299
|
+
},
|
|
156
300
|
],
|
|
157
301
|
consumers: [{ service: "shop.oms", status: "declared" }],
|
|
158
302
|
},
|
|
@@ -231,18 +375,26 @@ describe("the LikeC4 generator", () => {
|
|
|
231
375
|
expect(model).toContain("technology 'postgres'");
|
|
232
376
|
// Every relation says what kind of fact it is; a call carries its protocol.
|
|
233
377
|
expect(model).toContain("shop.oms -[calls]-> shop.cart 'getBasket' 'HTTP'");
|
|
234
|
-
expect(model).toContain(
|
|
378
|
+
expect(model).toContain(
|
|
379
|
+
"shop.cart -[consumes]-> shop.oms 'BasketCheckedOut'",
|
|
380
|
+
);
|
|
235
381
|
// The hop through the broker, once per direction, with the step's status.
|
|
236
|
-
expect(model).toContain(
|
|
237
|
-
|
|
382
|
+
expect(model).toContain(
|
|
383
|
+
"shop.cart -[bus]-> bus 'BasketCheckedOut' {\n style { color verified",
|
|
384
|
+
);
|
|
385
|
+
expect(model).toContain(
|
|
386
|
+
"bus -[bus]-> shop.oms 'BasketCheckedOut' {\n style { color declared",
|
|
387
|
+
);
|
|
238
388
|
|
|
239
389
|
// The estate's containers: contexts opened, stores and the bus named.
|
|
240
|
-
expect(views).toMatch(
|
|
390
|
+
expect(views).toMatch(
|
|
391
|
+
/view containers \{[^}]*include shop, shop\.cart, shop\.oms, shop\.cart\.pg, bus\n/,
|
|
392
|
+
);
|
|
241
393
|
// One edge per pair, counted, with the protocol and the best status.
|
|
242
394
|
const pair =
|
|
243
395
|
"include shop.oms -> shop.cart with { title '2 calls' technology 'HTTP' color verified line solid }";
|
|
244
396
|
expect(views).toContain(`view containers {\n title 'Containers'`);
|
|
245
|
-
expect(views.split(pair)).toHaveLength(
|
|
397
|
+
expect(views.split(pair)).toHaveLength(4); // containers, containers_default, and ctx_shop
|
|
246
398
|
// With the bus on the picture the direct consumer arrow is not drawn twice.
|
|
247
399
|
expect(views).toContain(
|
|
248
400
|
"view ctx_shop of shop {\n" +
|
|
@@ -253,6 +405,8 @@ describe("the LikeC4 generator", () => {
|
|
|
253
405
|
" }",
|
|
254
406
|
);
|
|
255
407
|
// The neighbours view keeps every method as its own relation.
|
|
256
|
-
expect(views).toMatch(
|
|
408
|
+
expect(views).toMatch(
|
|
409
|
+
/view svc_shop_oms of shop\.oms \{\n title[^\n]*\n include \*, -> \*, \* ->\n \}/,
|
|
410
|
+
);
|
|
257
411
|
});
|
|
258
412
|
});
|
package/scripts/gen.mjs
CHANGED
|
@@ -485,21 +485,23 @@ function apply(files, out, key, checkOnly) {
|
|
|
485
485
|
for (const file of files) {
|
|
486
486
|
const target = safeJoin(out, file.name);
|
|
487
487
|
written.add(file.name);
|
|
488
|
+
const binary = file.encoding === "base64";
|
|
489
|
+
const wanted = binary ? Buffer.from(file.contents, "base64") : file.contents;
|
|
488
490
|
|
|
489
491
|
let current = null;
|
|
490
492
|
try {
|
|
491
|
-
current = readFileSync(target, "utf8");
|
|
493
|
+
current = readFileSync(target, binary ? undefined : "utf8");
|
|
492
494
|
} catch {
|
|
493
495
|
// Absent, which the comparison below reports as added.
|
|
494
496
|
}
|
|
495
497
|
|
|
496
|
-
if (current
|
|
498
|
+
if (binary ? Buffer.isBuffer(current) && current.equals(wanted) : current === wanted) continue;
|
|
497
499
|
|
|
498
500
|
changes.push({ kind: current === null ? "added" : "changed", path: join(out, file.name) });
|
|
499
501
|
if (checkOnly) continue;
|
|
500
502
|
|
|
501
503
|
try {
|
|
502
|
-
writeOutputFile(out, file.name,
|
|
504
|
+
writeOutputFile(out, file.name, wanted);
|
|
503
505
|
} catch (cause) {
|
|
504
506
|
fail(cause.message);
|
|
505
507
|
}
|
|
@@ -69,6 +69,7 @@ export function describe() {
|
|
|
69
69
|
*/
|
|
70
70
|
export function run(request, { env = process.env } = {}) {
|
|
71
71
|
const options = request.options ?? {};
|
|
72
|
+
const generatedAt = String(request.input?.generatedAt ?? "");
|
|
72
73
|
const repos = Array.isArray(options.repos) ? options.repos : [];
|
|
73
74
|
if (repos.length === 0) throw new Error("no repositories to fetch: name at least one in the manifest");
|
|
74
75
|
if (!options.cache) {
|
|
@@ -86,7 +87,7 @@ export function run(request, { env = process.env } = {}) {
|
|
|
86
87
|
const at = join(options.cache, ...dir.split("/"));
|
|
87
88
|
|
|
88
89
|
if (skip) {
|
|
89
|
-
emitCached(out, dir, at, want, "offline");
|
|
90
|
+
emitCached(out, dir, at, want, "offline", generatedAt);
|
|
90
91
|
continue;
|
|
91
92
|
}
|
|
92
93
|
|
|
@@ -97,14 +98,14 @@ export function run(request, { env = process.env } = {}) {
|
|
|
97
98
|
// Rule 2: the tree still holds a good copy, so the output is unchanged
|
|
98
99
|
// and `--check` stays clean.
|
|
99
100
|
try {
|
|
100
|
-
emitCached(out, dir, at, want, cause.message);
|
|
101
|
+
emitCached(out, dir, at, want, cause.message, generatedAt);
|
|
101
102
|
} catch (cacheCause) {
|
|
102
103
|
// Rule 3: nothing to fall back to.
|
|
103
104
|
throw new Error(`${want.repo} could not be fetched (${cause.message}) and there is no usable copy in the tree (${cacheCause.message})`);
|
|
104
105
|
}
|
|
105
106
|
continue;
|
|
106
107
|
}
|
|
107
|
-
emitFetched(out, dir, want, fetched.commit, fetched.files);
|
|
108
|
+
emitFetched(out, dir, want, fetched.commit, fetched.files, generatedAt);
|
|
108
109
|
}
|
|
109
110
|
|
|
110
111
|
return out.response();
|
|
@@ -164,8 +165,8 @@ export function webRepo(name) {
|
|
|
164
165
|
}
|
|
165
166
|
|
|
166
167
|
/** The fragment for one repository at one commit: a catalog with one pin. */
|
|
167
|
-
export function pin(repo, commit) {
|
|
168
|
-
return `${JSON.stringify({ contexts: [], defs: {}, flows: [], adrs: [], repos: [{ repo: webRepo(repo), commit }] }, null, 2)}\n`;
|
|
168
|
+
export function pin(repo, commit, generatedAt = "") {
|
|
169
|
+
return `${JSON.stringify({ ...(generatedAt ? { generatedAt } : {}), contexts: [], defs: {}, flows: [], adrs: [], repos: [{ repo: webRepo(repo), commit }] }, null, 2)}\n`;
|
|
169
170
|
}
|
|
170
171
|
|
|
171
172
|
/** The lock, written the way every generated file here is written. */
|
|
@@ -195,15 +196,15 @@ function live(url, want, out, env) {
|
|
|
195
196
|
return { commit, files };
|
|
196
197
|
}
|
|
197
198
|
|
|
198
|
-
function emitFetched(out, dir, want, commit, files) {
|
|
199
|
+
function emitFetched(out, dir, want, commit, files, generatedAt) {
|
|
199
200
|
const entry = { repo: want.repo, commit, paths: want.paths ?? [], files: [] };
|
|
200
201
|
for (const path of [...files.keys()].sort()) {
|
|
201
202
|
const contents = files.get(path);
|
|
202
|
-
out.file(posix.join(dir, path), contents
|
|
203
|
+
out.file(posix.join(dir, path), contents);
|
|
203
204
|
entry.files.push({ path, sha256: digestOf(contents), size: contents.length });
|
|
204
205
|
}
|
|
205
206
|
out.file(posix.join(dir, LOCK_NAME), encodeLock(entry));
|
|
206
|
-
out.file(posix.join(dir, PIN_NAME), pin(want.repo, commit));
|
|
207
|
+
out.file(posix.join(dir, PIN_NAME), pin(want.repo, commit, generatedAt));
|
|
207
208
|
}
|
|
208
209
|
|
|
209
210
|
/**
|
|
@@ -211,18 +212,18 @@ function emitFetched(out, dir, want, commit, files) {
|
|
|
211
212
|
* online run wrote, so `gen:check` sees no drift and CI can verify an estate
|
|
212
213
|
* whose services live in repositories it never clones.
|
|
213
214
|
*/
|
|
214
|
-
function emitCached(out, dir, at, want, why) {
|
|
215
|
+
function emitCached(out, dir, at, want, why, generatedAt = "") {
|
|
215
216
|
if (!want.commit) throw new Error(`${want.repo} is not pinned to a commit, so there is nothing to replay`);
|
|
216
217
|
const held = replay(at);
|
|
217
218
|
if (held.lock.commit !== want.commit) {
|
|
218
219
|
throw new Error(`${want.repo} holds commit ${held.lock.commit} but the manifest pins ${want.commit}; fetch it`);
|
|
219
220
|
}
|
|
220
|
-
for (const file of held.lock.files) out.file(posix.join(dir, file.path), held.files.get(file.path)
|
|
221
|
+
for (const file of held.lock.files) out.file(posix.join(dir, file.path), held.files.get(file.path));
|
|
221
222
|
out.file(posix.join(dir, LOCK_NAME), encodeLock(held.lock));
|
|
222
223
|
// From the LOCK's commit, not the manifest's. They are equal by the check
|
|
223
224
|
// above, and taking it from the copy is what keeps the fragment describing
|
|
224
225
|
// what is actually on disk rather than what was asked for.
|
|
225
|
-
out.file(posix.join(dir, PIN_NAME), pin(want.repo, held.lock.commit));
|
|
226
|
+
out.file(posix.join(dir, PIN_NAME), pin(want.repo, held.lock.commit, generatedAt));
|
|
226
227
|
out.warn(want.repo, `not fetched (${why}); the copy committed in this repository is used unchanged`);
|
|
227
228
|
}
|
|
228
229
|
|
|
@@ -366,7 +367,16 @@ class Builder {
|
|
|
366
367
|
warnings = [];
|
|
367
368
|
|
|
368
369
|
file(name, contents) {
|
|
369
|
-
|
|
370
|
+
if (!Buffer.isBuffer(contents)) {
|
|
371
|
+
this.files.push({ name, contents });
|
|
372
|
+
return;
|
|
373
|
+
}
|
|
374
|
+
const text = contents.toString("utf8");
|
|
375
|
+
this.files.push(
|
|
376
|
+
Buffer.from(text, "utf8").equals(contents)
|
|
377
|
+
? { name, contents: text }
|
|
378
|
+
: { name, contents: contents.toString("base64"), encoding: "base64" },
|
|
379
|
+
);
|
|
370
380
|
}
|
|
371
381
|
|
|
372
382
|
warn(ref, message) {
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { execFileSync } from "node:child_process";
|
|
2
|
-
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
3
3
|
import { tmpdir } from "node:os";
|
|
4
4
|
import { basename, dirname, join } from "node:path";
|
|
5
5
|
import { afterEach, describe, expect, it } from "vitest";
|
|
@@ -30,6 +30,7 @@ function repository() {
|
|
|
30
30
|
write("services/oms/internal/domain/order/order.go", "package order\n");
|
|
31
31
|
write("services/oms/README.md", "# OMS\n");
|
|
32
32
|
write("proto/shop/v1/orders.proto", 'syntax = "proto3";\n');
|
|
33
|
+
write("docs/example.png", Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0xff, 0x00]));
|
|
33
34
|
write("README.md", "# shop\n");
|
|
34
35
|
git(["add", "."]);
|
|
35
36
|
git(["commit", "--quiet", "-m", "the estate"]);
|
|
@@ -48,21 +49,22 @@ function cache() {
|
|
|
48
49
|
const online = { CI: "", [OFFLINE_ENV]: "" };
|
|
49
50
|
const offline = { CI: "", [OFFLINE_ENV]: "1" };
|
|
50
51
|
const options = (remote, cacheDir, paths) => ({ cache: cacheDir, repos: [{ repo: remote.url, commit: remote.commit, ...(paths ? { paths } : {}) }] });
|
|
51
|
-
const fetch = (opts, env) => run({ options: opts }, { env: { ...process.env, ...env } });
|
|
52
|
+
const fetch = (opts, env, input) => run({ options: opts, ...(input ? { input } : {}) }, { env: { ...process.env, ...env } });
|
|
52
53
|
const names = (response) => response.files.map((file) => file.name).sort();
|
|
53
54
|
const contentsOf = (response, name) => response.files.find((file) => file.name === name)?.contents ?? "";
|
|
54
55
|
// What the host does with a response.
|
|
55
56
|
const write = (dir, response) => {
|
|
56
57
|
for (const file of response.files) {
|
|
57
58
|
mkdirSync(dirname(join(dir, file.name)), { recursive: true });
|
|
58
|
-
writeFileSync(join(dir, file.name), file.contents);
|
|
59
|
+
writeFileSync(join(dir, file.name), file.encoding === "base64" ? Buffer.from(file.contents, "base64") : file.contents);
|
|
59
60
|
}
|
|
60
61
|
};
|
|
61
62
|
|
|
62
63
|
describe("fetch-git", () => {
|
|
63
64
|
it("writes the narrowed copy, a lock and a pin", () => {
|
|
64
65
|
const remote = repository();
|
|
65
|
-
const
|
|
66
|
+
const generatedAt = "2026-09-09T03:00:00.000Z";
|
|
67
|
+
const response = fetch(options(remote, cache(), ["services/oms", "proto"]), online, { generatedAt });
|
|
66
68
|
// The directory is owner/name, read off the URL; the paths inside are
|
|
67
69
|
// the repository's own, so an extractor can read the copy as a checkout.
|
|
68
70
|
const dir = remote.copyDir;
|
|
@@ -82,7 +84,7 @@ describe("fetch-git", () => {
|
|
|
82
84
|
|
|
83
85
|
// The fragment is the lock's commit said in the shape the catalog reads.
|
|
84
86
|
const fragment = contentsOf(response, `${dir}/git.repo.json`);
|
|
85
|
-
expect(JSON.parse(fragment)).toEqual({ contexts: [], defs: {}, flows: [], adrs: [], repos: [{ repo: remote.dir.slice(1), commit: remote.commit }] });
|
|
87
|
+
expect(JSON.parse(fragment)).toEqual({ generatedAt, contexts: [], defs: {}, flows: [], adrs: [], repos: [{ repo: remote.dir.slice(1), commit: remote.commit }] });
|
|
86
88
|
expect(fragment.endsWith("\n")).toBe(true);
|
|
87
89
|
});
|
|
88
90
|
|
|
@@ -103,6 +105,20 @@ describe("fetch-git", () => {
|
|
|
103
105
|
expect(inCi.warnings).toHaveLength(1);
|
|
104
106
|
});
|
|
105
107
|
|
|
108
|
+
it("preserves binary blobs online and from the offline cache", () => {
|
|
109
|
+
const remote = repository();
|
|
110
|
+
const cacheDir = cache();
|
|
111
|
+
const first = fetch(options(remote, cacheDir, ["docs/example.png"]), online);
|
|
112
|
+
const name = `${remote.copyDir}/docs/example.png`;
|
|
113
|
+
const image = first.files.find((file) => file.name === name);
|
|
114
|
+
expect(image?.encoding).toBe("base64");
|
|
115
|
+
write(cacheDir, first);
|
|
116
|
+
expect(readFileSync(join(cacheDir, name))).toEqual(readFileSync(join(remote.dir, "docs/example.png")));
|
|
117
|
+
|
|
118
|
+
const replayed = fetch(options(remote, cacheDir, ["docs/example.png"]), offline);
|
|
119
|
+
expect(replayed.files.find((file) => file.name === name)).toEqual(image);
|
|
120
|
+
});
|
|
121
|
+
|
|
106
122
|
it("names the edited file when a vendored copy no longer matches its lock", () => {
|
|
107
123
|
const remote = repository();
|
|
108
124
|
const cacheDir = cache();
|
|
@@ -202,6 +218,10 @@ describe("names", () => {
|
|
|
202
218
|
const fragment = pin("git@github.com:acme/shop.git", "c1d2e3f4a5b6c7d8e9f0a1b2c3d4e5f6a7b8c9d0");
|
|
203
219
|
expect(fragment).toBe(`${JSON.stringify({ contexts: [], defs: {}, flows: [], adrs: [], repos: [{ repo: "github.com/acme/shop", commit: "c1d2e3f4a5b6c7d8e9f0a1b2c3d4e5f6a7b8c9d0" }] }, null, 2)}\n`);
|
|
204
220
|
expect(pin("github.com/acme/shop", "c1d2e3f")).toBe(pin("github.com/acme/shop", "c1d2e3f"));
|
|
221
|
+
expect(JSON.parse(pin("github.com/acme/shop", "c1d2e3f", "2026-09-09T03:00:00.000Z"))).toMatchObject({
|
|
222
|
+
generatedAt: "2026-09-09T03:00:00.000Z",
|
|
223
|
+
repos: [{ repo: "github.com/acme/shop", commit: "c1d2e3f" }],
|
|
224
|
+
});
|
|
205
225
|
|
|
206
226
|
const lock = encodeLock({ repo: "github.com/acme/shop", commit: "abc", paths: ["services/oms", "proto"], files: [{ path: "b", sha256: "2", size: 1 }, { path: "a", sha256: "1", size: 1 }] });
|
|
207
227
|
expect(JSON.parse(lock)).toEqual({ repos: [{ repo: "github.com/acme/shop", commit: "abc", paths: ["proto", "services/oms"], files: [{ path: "a", sha256: "1", size: 1 }, { path: "b", sha256: "2", size: 1 }] }] });
|