@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.
Files changed (135) hide show
  1. package/README.md +7 -2
  2. package/cli/init.mjs +14 -6
  3. package/cli/init.test.mjs +8 -1
  4. package/cli/portolan.mjs +92 -13
  5. package/cli/portolan.test.mjs +47 -0
  6. package/package.json +7 -6
  7. package/plugins/extract-django/README.md +1 -1
  8. package/plugins/extract-django/extract_test.py +1 -1
  9. package/plugins/extract-django/openapi.py +4 -14
  10. package/plugins/extract-django/openapi_test.py +4 -4
  11. package/plugins/extract-go/options.schema.json +5 -0
  12. package/plugins/extract-project/options.schema.json +21 -0
  13. package/plugins/extract-ts/README.md +1 -1
  14. package/plugins/extract-ts/clients.ts +1 -1
  15. package/plugins/extract-ts/extract.test.ts +2 -2
  16. package/plugins/extract-ts/graphql.test.ts +2 -2
  17. package/plugins/extract-ts/openapi.test.ts +6 -7
  18. package/plugins/extract-ts/openapi.ts +3 -10
  19. package/plugins/portolan-go.wasm +0 -0
  20. package/public/404-cat-v1.webp +0 -0
  21. package/public/cat-about-v1.webp +0 -0
  22. package/public/cat-clear-skies-v1.webp +0 -0
  23. package/public/cat-no-changes-v1.webp +0 -0
  24. package/public/cat-onboarding-v1.webp +0 -0
  25. package/public/cat-trial-success-v1.webp +0 -0
  26. package/schema/portolan.schema.json +61 -0
  27. package/scripts/builtin-plugins.mjs +3 -2
  28. package/scripts/catalog-sources.mjs +14 -2
  29. package/scripts/catalog-sources.test.mjs +45 -0
  30. package/scripts/delivery-presets.mjs +207 -24
  31. package/scripts/diff.mjs +5 -1
  32. package/scripts/gen-likec4.mjs +242 -85
  33. package/scripts/gen-likec4.test.mjs +168 -14
  34. package/scripts/gen.mjs +5 -3
  35. package/scripts/host-plugins/fetch-git.mjs +22 -12
  36. package/scripts/host-plugins/fetch-git.test.mjs +25 -5
  37. package/scripts/local-api.mjs +330 -346
  38. package/scripts/local-api.test.mjs +245 -1
  39. package/scripts/local-discovery.mjs +378 -0
  40. package/scripts/manifest.mjs +42 -1
  41. package/scripts/manifest.test.mjs +24 -1
  42. package/scripts/plugin-host.mjs +14 -3
  43. package/scripts/plugin-host.test.mjs +8 -0
  44. package/scripts/run-builtin.mjs +4 -2
  45. package/scripts/schema.mjs +11 -0
  46. package/scripts/site-docs.mjs +2 -2
  47. package/src/app/CatalogApp.tsx +23 -8
  48. package/src/app/CommandPalette.tsx +7 -2
  49. package/src/app/Sidebar.tsx +13 -730
  50. package/src/app/SidebarFlowSections.tsx +251 -0
  51. package/src/app/SidebarFooter.tsx +160 -0
  52. package/src/app/SidebarTree.tsx +322 -0
  53. package/src/app/toast.tsx +11 -7
  54. package/src/catalog-index.ts +485 -0
  55. package/src/catalog-model.ts +1339 -0
  56. package/src/catalog-validation.ts +1570 -0
  57. package/src/catalog.test.ts +49 -0
  58. package/src/catalog.ts +6 -3124
  59. package/src/components/CatIllustration.tsx +30 -0
  60. package/src/components/CommitLink.tsx +154 -0
  61. package/src/components/EditorLink.tsx +15 -8
  62. package/src/components/GrpcMethodReference.tsx +93 -0
  63. package/src/components/Markdown.tsx +17 -0
  64. package/src/components/MethodRows.tsx +8 -1
  65. package/src/components/PageHeader.test.tsx +23 -0
  66. package/src/components/PageHeader.tsx +34 -0
  67. package/src/components/ProblemRow.tsx +3 -0
  68. package/src/components/ShapeRows.tsx +12 -0
  69. package/src/components/SourceDoc.tsx +27 -12
  70. package/src/components/SourcePreview.tsx +125 -37
  71. package/src/enrich.test.ts +209 -2
  72. package/src/enrich.ts +284 -15
  73. package/src/er/RedisSchema.tsx +86 -5
  74. package/src/flow/FlowTable.tsx +19 -4
  75. package/src/flow/StepDetail.tsx +461 -101
  76. package/src/flow/StepRail.tsx +36 -13
  77. package/src/flow/answers-response.test.ts +100 -0
  78. package/src/flow/answers.ts +11 -8
  79. package/src/flow/chapters.ts +2 -1
  80. package/src/flow/labels.ts +10 -0
  81. package/src/flow/mermaid.test.ts +87 -3
  82. package/src/flow/mermaid.ts +26 -14
  83. package/src/index.css +132 -17
  84. package/src/landing/LandingPage.tsx +16 -12
  85. package/src/lib/all-problems.ts +1 -1
  86. package/src/lib/api.test.ts +4 -6
  87. package/src/lib/build-info.test.ts +29 -0
  88. package/src/lib/build-info.ts +21 -0
  89. package/src/lib/data-model.ts +14 -0
  90. package/src/lib/derive.ts +1 -0
  91. package/src/lib/flow-tree.ts +1 -1
  92. package/src/lib/github-catalog.test.ts +51 -0
  93. package/src/lib/github-catalog.ts +69 -0
  94. package/src/lib/grpc-reference.test.ts +72 -0
  95. package/src/lib/grpc-reference.ts +117 -0
  96. package/src/lib/local-api.ts +36 -11
  97. package/src/lib/motion.test.ts +4 -2
  98. package/src/lib/motion.tsx +5 -4
  99. package/src/lib/product.ts +7 -0
  100. package/src/lib/proto-problems.test.ts +170 -3
  101. package/src/lib/proto-problems.ts +176 -4
  102. package/src/lib/queries.ts +16 -1
  103. package/src/lib/readme-assets.test.ts +19 -0
  104. package/src/lib/readme-assets.ts +34 -0
  105. package/src/lib/setup-info.test.ts +7 -0
  106. package/src/lib/setup-info.ts +5 -0
  107. package/src/lib/source-link.test.ts +106 -18
  108. package/src/lib/source-link.ts +22 -6
  109. package/src/merge.test.ts +101 -0
  110. package/src/merge.ts +65 -1
  111. package/src/pages/AdrDetail.tsx +3 -2
  112. package/src/pages/AdrIndex.tsx +10 -7
  113. package/src/pages/AggregatePage.tsx +34 -4
  114. package/src/pages/BlockPage.tsx +39 -32
  115. package/src/pages/Changes.tsx +7 -9
  116. package/src/pages/ContextMap.tsx +11 -2
  117. package/src/pages/ContextPage.tsx +11 -4
  118. package/src/pages/FlowIndex.tsx +16 -7
  119. package/src/pages/GraphPage.tsx +21 -7
  120. package/src/pages/Language.tsx +9 -9
  121. package/src/pages/NotFound.tsx +58 -15
  122. package/src/pages/Overview.tsx +6 -0
  123. package/src/pages/Problems.tsx +10 -8
  124. package/src/pages/RegistryIndex.tsx +10 -8
  125. package/src/pages/ServicePage.tsx +1 -1
  126. package/src/pages/Settings.tsx +257 -44
  127. package/src/pages/settings/AboutSettings.tsx +129 -0
  128. package/src/pages/settings/DeliverySettings.tsx +71 -15
  129. package/src/routes.test.ts +2 -0
  130. package/src/routes.ts +2 -1
  131. package/src/selection/DetailPanel.tsx +213 -113
  132. package/src/selection/pages.test.ts +14 -1
  133. package/src/selection/pages.ts +9 -3
  134. package/src/testing/setup.ts +14 -0
  135. 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(dirname(dirname(generator)), "node_modules", ".bin", "likec4");
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
- { version: "v1", doc: "", source: "cart.go:1", fields: [] },
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("shop.cart -[consumes]-> shop.oms 'BasketCheckedOut'");
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("shop.cart -[bus]-> bus 'BasketCheckedOut' {\n style { color verified");
237
- expect(model).toContain("bus -[bus]-> shop.oms 'BasketCheckedOut' {\n style { color declared");
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(/view containers \{[^}]*include shop, shop\.cart, shop\.oms, shop\.cart\.pg, bus\n/);
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(3); // containers, and ctx_shop
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(/view svc_shop_oms of shop\.oms \{\n title[^\n]*\n include \*, -> \*, \* ->\n \}/);
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 === file.contents) continue;
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, file.contents);
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.toString("utf8"));
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).toString("utf8"));
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
- this.files.push({ name, contents });
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 response = fetch(options(remote, cache(), ["services/oms", "proto"]), online);
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 }] }] });