@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.
Files changed (109) 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 +368 -34
  34. package/scripts/local-api.test.mjs +197 -2
  35. package/scripts/package-smoke.mjs +19 -2
  36. package/scripts/plugin-host.mjs +14 -3
  37. package/scripts/plugin-host.test.mjs +8 -0
  38. package/scripts/schema.mjs +7 -0
  39. package/src/app/CatalogApp.tsx +19 -4
  40. package/src/app/CommandPalette.tsx +7 -2
  41. package/src/app/toast.tsx +11 -7
  42. package/src/catalog.test.ts +33 -0
  43. package/src/catalog.ts +190 -8
  44. package/src/components/CatIllustration.tsx +30 -0
  45. package/src/components/CommitLink.tsx +154 -0
  46. package/src/components/EditorLink.tsx +15 -8
  47. package/src/components/GrpcMethodReference.tsx +93 -0
  48. package/src/components/Markdown.tsx +17 -0
  49. package/src/components/MethodRows.tsx +8 -1
  50. package/src/components/PageHeader.test.ts +24 -0
  51. package/src/components/PageHeader.tsx +34 -0
  52. package/src/components/ShapeRows.tsx +12 -0
  53. package/src/components/SourceDoc.tsx +27 -12
  54. package/src/components/SourcePreview.tsx +124 -36
  55. package/src/enrich.test.ts +209 -2
  56. package/src/enrich.ts +284 -15
  57. package/src/er/RedisSchema.tsx +86 -5
  58. package/src/flow/FlowTable.tsx +19 -4
  59. package/src/flow/StepDetail.tsx +461 -101
  60. package/src/flow/StepRail.tsx +36 -13
  61. package/src/flow/answers-response.test.ts +100 -0
  62. package/src/flow/answers.ts +11 -8
  63. package/src/flow/chapters.ts +2 -1
  64. package/src/flow/labels.ts +10 -0
  65. package/src/flow/mermaid.test.ts +87 -3
  66. package/src/flow/mermaid.ts +26 -14
  67. package/src/index.css +132 -0
  68. package/src/landing/LandingPage.tsx +12 -8
  69. package/src/lib/api.test.ts +4 -6
  70. package/src/lib/build-info.test.ts +29 -0
  71. package/src/lib/build-info.ts +21 -0
  72. package/src/lib/data-model.ts +14 -0
  73. package/src/lib/flow-tree.ts +1 -1
  74. package/src/lib/github-catalog.test.ts +51 -0
  75. package/src/lib/github-catalog.ts +69 -0
  76. package/src/lib/grpc-reference.test.ts +72 -0
  77. package/src/lib/grpc-reference.ts +117 -0
  78. package/src/lib/local-api.ts +17 -4
  79. package/src/lib/product.ts +7 -0
  80. package/src/lib/queries.ts +16 -1
  81. package/src/lib/readme-assets.test.ts +19 -0
  82. package/src/lib/readme-assets.ts +34 -0
  83. package/src/lib/setup-info.test.ts +7 -0
  84. package/src/lib/setup-info.ts +5 -0
  85. package/src/lib/source-link.test.ts +106 -18
  86. package/src/lib/source-link.ts +22 -6
  87. package/src/merge.test.ts +55 -0
  88. package/src/merge.ts +30 -0
  89. package/src/pages/AdrDetail.tsx +3 -2
  90. package/src/pages/AdrIndex.tsx +10 -7
  91. package/src/pages/AggregatePage.tsx +34 -4
  92. package/src/pages/BlockPage.tsx +39 -32
  93. package/src/pages/Changes.tsx +7 -9
  94. package/src/pages/ContextMap.tsx +11 -2
  95. package/src/pages/ContextPage.tsx +11 -4
  96. package/src/pages/FlowIndex.tsx +16 -7
  97. package/src/pages/GraphPage.tsx +21 -7
  98. package/src/pages/Language.tsx +9 -9
  99. package/src/pages/NotFound.tsx +58 -15
  100. package/src/pages/Overview.tsx +6 -0
  101. package/src/pages/Problems.tsx +10 -8
  102. package/src/pages/RegistryIndex.tsx +10 -8
  103. package/src/pages/ServicePage.tsx +1 -1
  104. package/src/pages/Settings.tsx +382 -44
  105. package/src/routes.test.ts +2 -0
  106. package/src/routes.ts +2 -1
  107. package/src/selection/DetailPanel.tsx +213 -113
  108. package/src/testing/setup.ts +14 -0
  109. package/vite.config.ts +5 -1
@@ -5,9 +5,10 @@ import { join } from "node:path";
5
5
  import { afterEach, describe, expect, it } from "vitest";
6
6
  import { rmSync } from "node:fs";
7
7
 
8
- import { classifyRepositoryFailure, diffGeneratedFiles, discoverProject, forgetRepositoryCredential, inspectionRoot, localApiPath, planProject, readLocalSource, resolveRepositoryCommit, storeRepositoryCredential, summarizeProjectTrial, writeProject } from "./local-api.mjs";
8
+ import { classifyRepositoryFailure, diffGeneratedFiles, discoverProject, externalProjectDefaults, forgetRepositoryCredential, inspectionRoot, localApiPath, manifestWithoutProject, manifestWithProject, planProject, readLocalSource, removeProject, resolveRepositoryCommit, starterManifestProject, storeRepositoryCredential, summarizeProjectTrial, undoProjectRemoval, writeProject } from "./local-api.mjs";
9
9
  import { installDeliveryPreset, planDeliveryPreset, providerFromRemote, publicDeliveryPreset } from "./delivery-presets.mjs";
10
10
 
11
+ const PACKAGE_VERSION = JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8")).version;
11
12
  const roots = [];
12
13
  afterEach(() => { for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true }); });
13
14
 
@@ -66,7 +67,7 @@ describe("local project setup", () => {
66
67
  const installed = installDeliveryPreset(root, { provider: "github", revision: preview.revision });
67
68
  expect(installed.written).toHaveLength(2);
68
69
  expect(installed.status).toBe("installed");
69
- expect(readFileSync(join(root, ".github/workflows/portolan-check.yml"), "utf8")).toContain("shortlink-org/portolan@0.2.0");
70
+ expect(readFileSync(join(root, ".github/workflows/portolan-check.yml"), "utf8")).toContain(`shortlink-org/portolan@${PACKAGE_VERSION}`);
70
71
  expect(readFileSync(join(root, ".github/workflows/portolan-pages.yml"), "utf8")).toContain("base: /${{ github.event.repository.name }}/");
71
72
  expect(installDeliveryPreset(root, { provider: "github", revision: installed.revision }).written).toEqual([]);
72
73
  });
@@ -188,6 +189,18 @@ describe("local project setup", () => {
188
189
  expect(discovery.detections.find((item) => item.plugin === "adr")?.selected).toBe(false);
189
190
  });
190
191
 
192
+ it("names external projects from the repository or selected component instead of the inspection cache", () => {
193
+ expect(externalProjectDefaults("https://github.com/batazor/microservice-template-ddd")).toEqual({
194
+ id: "microservice-template-ddd",
195
+ name: "Microservice Template Ddd",
196
+ group: "microservice-template-ddd",
197
+ component: "microservice-template-ddd",
198
+ context: "microservice-template-ddd",
199
+ service: "microservice-template-ddd",
200
+ });
201
+ expect(externalProjectDefaults("git@github.com:acme/platform.git", "services/payments").id).toBe("payments");
202
+ });
203
+
191
204
  it("finds component roots in a monorepo without reading dependency directories", () => {
192
205
  const root = workspace();
193
206
  mkdirSync(join(root, "services/orders"), { recursive: true });
@@ -208,6 +221,55 @@ describe("local project setup", () => {
208
221
  ]);
209
222
  });
210
223
 
224
+ it("splits corroborated Go cmd entrypoints into deployable services", () => {
225
+ const root = workspace();
226
+ const platform = join(root, "services/platform");
227
+ mkdirSync(platform, { recursive: true });
228
+ writeFileSync(join(platform, "go.mod"), "module example.com/platform\nrequire github.com/go-redis/redis v6.15.9+incompatible\n");
229
+ for (const service of ["api", "billing", "book", "user"]) {
230
+ mkdirSync(join(platform, "cmd", service), { recursive: true });
231
+ mkdirSync(join(platform, "ops/dockerfile"), { recursive: true });
232
+ writeFileSync(join(platform, "cmd", service, `${service}.go`), "package main\nfunc main() {}\n");
233
+ writeFileSync(join(platform, "ops/dockerfile", `${service}.Dockerfile`), `FROM golang\nRUN go build -o app ./cmd/${service}\n`);
234
+ }
235
+ for (const service of ["billing", "book", "user"]) {
236
+ mkdirSync(join(platform, "internal", service, "infrastructure/rpc"), { recursive: true });
237
+ writeFileSync(join(platform, "internal", service, "infrastructure/rpc", `${service}.proto`), `syntax = "proto3"; package ${service}; service ${service}RPC {}\n`);
238
+ mkdirSync(join(platform, "internal", service, "domain"), { recursive: true });
239
+ writeFileSync(join(platform, "internal", service, "domain", `${service}.go`), `package domain\ntype ${service[0].toUpperCase() + service.slice(1)} struct{}\n`);
240
+ }
241
+ mkdirSync(join(platform, "internal/db/redis"), { recursive: true });
242
+ mkdirSync(join(platform, "internal/di"), { recursive: true });
243
+ writeFileSync(join(platform, "internal/db/redis/redis.go"), "package redisdb\nimport redis \"github.com/go-redis/redis\"\nfunc Open() { _ = redis.NewClient(nil) }\n");
244
+ writeFileSync(join(platform, "internal/di/book.go"), "package di\nimport _ \"example.com/platform/internal/db\"\n");
245
+
246
+ const discovery = discoverProject(root, "services/platform");
247
+ expect(discovery.deployables.map(({ slug, confidence }) => [slug, confidence])).toEqual([
248
+ ["api", "high"], ["billing", "high"], ["book", "high"], ["user", "high"],
249
+ ]);
250
+
251
+ const manifest = JSON.parse(readFileSync(join(root, "portolan.json"), "utf8"));
252
+ const plan = planProject(root, manifest, {
253
+ root: "services/platform", id: "platform", name: "Platform", group: "platform", component: "platform", componentKind: "application", plugins: ["project", "go-domain", "proto", "redis"],
254
+ });
255
+ expect(plan.project).toMatchObject({ group: "platform", components: ["api", "billing", "book", "user"] });
256
+ expect(plan.project).not.toHaveProperty("component");
257
+ expect(plan.steps[0].options.components.map((component) => component.slug)).toEqual(["api", "billing", "book", "user"]);
258
+ expect(plan.steps.filter((step) => step.plugin === "proto").map((step) => [step.options.service, step.options.out])).toEqual([
259
+ ["billing", "proto-billing.json"], ["book", "proto-book.json"], ["user", "proto-user.json"],
260
+ ]);
261
+ expect(plan.steps.filter((step) => step.plugin === "redis").map((step) => [step.options.service, step.options.out])).toEqual([
262
+ ["book", "redis-book.json"],
263
+ ]);
264
+ expect(plan.steps.filter((step) => step.plugin === "go-domain").map((step) => [step.options.service, step.options.scope, step.options.store, step.options.peers, step.options.out])).toEqual([
265
+ ["api", "api", undefined, { billing: "platform.billing", book: "platform.book", user: "platform.user" }, "go-domain-api.json"],
266
+ ["billing", "billing", undefined, { billing: "platform.billing", book: "platform.book", user: "platform.user" }, "go-domain-billing.json"],
267
+ ["book", "book", "redis", { billing: "platform.billing", book: "platform.book", user: "platform.user" }, "go-domain-book.json"],
268
+ ["user", "user", undefined, { billing: "platform.billing", book: "platform.book", user: "platform.user" }, "go-domain-user.json"],
269
+ ]);
270
+ expect(plan.steps[0].options.groupKind).toBe("system");
271
+ });
272
+
211
273
  it("only offers the Go domain extractor when the layout contains an aggregate root", () => {
212
274
  const root = workspace();
213
275
  mkdirSync(join(root, "services/billing/internal/domain/invoice"), { recursive: true });
@@ -232,6 +294,14 @@ describe("local project setup", () => {
232
294
  expect(discovery.detections.find((item) => item.plugin === "sql")?.evidence).toBe("2 repository packages");
233
295
  });
234
296
 
297
+ it("accepts a feature domain package named domain", () => {
298
+ const root = workspace();
299
+ mkdirSync(join(root, "services/billing/internal/invoice/domain"), { recursive: true });
300
+ writeFileSync(join(root, "services/billing/internal/invoice/domain/invoice.go"), "package domain\n\ntype Invoice struct{}\n");
301
+ const discovery = discoverProject(root, "services/billing");
302
+ expect(discovery.detections.find((item) => item.plugin === "go-domain")?.evidence).toBe("internal/invoice/domain/invoice.go");
303
+ });
304
+
235
305
  it("offers the River extractor when the Go module uses River", () => {
236
306
  const root = workspace();
237
307
  writeFileSync(join(root, "services/billing/go.mod"), "module example.com/billing\nrequire github.com/riverqueue/river v0.26.0\n");
@@ -382,6 +452,131 @@ describe("local project setup", () => {
382
452
  expect(() => writeProject(root, { ...request, id: "another" })).toThrow(/already exists/);
383
453
  });
384
454
 
455
+ it("removes a project's complete catalog slice without touching its neighbour", () => {
456
+ const manifest = {
457
+ sources: ["portolan/*.json", "services/billing/portolan/*.json"],
458
+ defaultCatalog: "portolan",
459
+ catalogs: [
460
+ { id: "portolan", title: "Portolan", sources: ["portolan/*.json"], contexts: ["portolan"], projects: ["portolan"] },
461
+ { id: "billing", title: "Billing", sources: ["services/billing/portolan/*.json"], contexts: ["finance"], projects: ["billing"] },
462
+ ],
463
+ projects: [
464
+ { id: "portolan", name: "Portolan", root: ".", group: "portolan", component: "portolan" },
465
+ { id: "billing", name: "Billing", root: "services/billing", group: "finance", component: "billing" },
466
+ ],
467
+ extract: [
468
+ { plugin: "project", in: ".", out: "portolan", options: {} },
469
+ { plugin: "commands", in: "scripts", out: "portolan", options: {} },
470
+ { plugin: "project", in: "services/billing", out: "services/billing/portolan", options: {} },
471
+ ],
472
+ verify: [
473
+ { plugin: "otel", in: ".", out: "portolan", options: {} },
474
+ { plugin: "otel", in: "services/billing", out: "services/billing/portolan", options: {} },
475
+ ],
476
+ generate: [
477
+ { plugin: "markdown", catalog: "portolan", out: "docs", options: {} },
478
+ { plugin: "markdown", catalog: "billing", out: "docs/billing", options: {} },
479
+ ],
480
+ };
481
+ const result = manifestWithoutProject(manifest, "portolan");
482
+ expect(result.manifest.projects.map((project) => project.id)).toEqual(["billing"]);
483
+ expect(result.manifest.sources).toEqual(["services/billing/portolan/*.json"]);
484
+ expect(result.manifest.extract.map((step) => step.out)).toEqual(["services/billing/portolan"]);
485
+ expect(result.manifest.verify.map((step) => step.out)).toEqual(["services/billing/portolan"]);
486
+ expect(result.manifest.catalogs.map((catalog) => catalog.id)).toEqual(["billing"]);
487
+ expect(result.manifest.defaultCatalog).toBe("billing");
488
+ expect(result.manifest.generate.map((step) => step.catalog)).toEqual(["billing"]);
489
+ expect(result.removedOutputs).toEqual(["docs"]);
490
+ });
491
+
492
+ it("keeps a valid empty-workspace source and replaces it when a project is added", () => {
493
+ const removed = manifestWithoutProject({
494
+ sources: ["portolan/*.json"],
495
+ projects: [{ id: "portolan", name: "Portolan", root: ".", group: "portolan", component: "portolan" }],
496
+ extract: [{ plugin: "project", in: ".", out: "portolan", options: {} }],
497
+ generate: [{ plugin: "markdown", out: "docs", options: {} }],
498
+ }, "portolan");
499
+ expect(removed.manifest).toMatchObject({ sources: ["portolan/*.json"], projects: [], extract: [] });
500
+ expect(removed.removedOutputs).toEqual(["docs"]);
501
+
502
+ const added = planProject(workspace(), { sources: ["portolan/*.json"], projects: [], extract: [] }, {
503
+ root: "services/billing", id: "aviaadmin", name: "Avia Admin", group: "avia", groupKind: "bounded-context", component: "aviaadmin", plugins: ["project"],
504
+ });
505
+ expect(added.project).toMatchObject({ id: "aviaadmin", group: "avia", groupKind: "bounded-context" });
506
+ expect(added.steps[0].options).toMatchObject({ group: "avia", groupKind: "bounded-context" });
507
+ const manifest = { sources: ["portolan/*.json"], projects: [], extract: [] };
508
+ expect(manifestWithProject(manifest, added).sources).toEqual(["services/billing/portolan/*.json"]);
509
+ });
510
+
511
+ it("does not duplicate an existing external fetch when a project is re-added", () => {
512
+ const fetch = { repo: "https://github.com/acme/orders", commit: "a".repeat(40), paths: [] };
513
+ const manifest = {
514
+ sources: ["vendor/repos/*/*/git.repo.json"], projects: [],
515
+ extract: [{ plugin: "git", in: "vendor", out: "vendor/repos", options: { cache: "vendor/repos", repos: [fetch] } }],
516
+ };
517
+ const plan = {
518
+ project: { id: "orders", root: "vendor/repos/acme/orders", group: "orders", component: "orders" },
519
+ steps: [{ plugin: "project", in: "vendor/repos/acme/orders", out: "vendor/repos/acme/orders/portolan", options: {} }],
520
+ source: "vendor/repos/**/portolan/*.json", fetch,
521
+ };
522
+ expect(manifestWithProject(manifest, plan).extract[0].options.repos).toEqual([fetch]);
523
+ });
524
+
525
+ it("adds the project slice to the default catalog profile", () => {
526
+ const root = workspace();
527
+ const manifest = {
528
+ sources: ["portolan/*.json"],
529
+ defaultCatalog: "company",
530
+ catalogs: [{ id: "company", title: "Company", sources: ["portolan/*.json"], contexts: ["platform"], projects: ["portolan"] }],
531
+ projects: [{ id: "portolan", root: ".", group: "platform", component: "portolan" }],
532
+ extract: [],
533
+ };
534
+ const added = planProject(root, manifest, {
535
+ root: "services/billing", id: "billing", name: "Billing", group: "finance", component: "billing", plugins: ["project"],
536
+ });
537
+ expect(manifestWithProject(manifest, added).catalogs[0]).toMatchObject({
538
+ sources: ["portolan/*.json", "services/billing/portolan/*.json"],
539
+ contexts: ["platform", "finance"],
540
+ projects: ["portolan", "billing"],
541
+ });
542
+ });
543
+
544
+ it("replaces a starter atomically after project discovery succeeds", () => {
545
+ const root = workspace();
546
+ const manifest = JSON.parse(readFileSync(join(root, "portolan.json"), "utf8"));
547
+ Object.assign(manifest, {
548
+ sources: ["portolan/*.json"],
549
+ projects: [{ id: "starter", name: "Starter", root: ".", group: "starter", component: "starter" }],
550
+ extract: [{ plugin: "project", in: ".", out: "portolan", options: { group: "starter", component: "starter" } }],
551
+ });
552
+ writeFileSync(join(root, "portolan.json"), `${JSON.stringify(manifest, null, 2)}\n`);
553
+ expect(starterManifestProject(manifest)?.id).toBe("starter");
554
+ const result = writeProject(root, {
555
+ root: "services/billing", id: "aviaadmin", name: "Avia Admin", group: "avia", contextName: "Aviation", contextSummary: "Flight administration.", classification: "core", groupKind: "bounded-context", component: "aviaadmin", componentKind: "service", replaceStarter: true, plugins: ["project"],
556
+ });
557
+ const written = JSON.parse(readFileSync(join(root, "portolan.json"), "utf8"));
558
+ expect(result.replacedProject?.id).toBe("starter");
559
+ expect(written.projects.map((project) => project.id)).toEqual(["aviaadmin"]);
560
+ expect(starterManifestProject(written)).toBeNull();
561
+ expect(written.sources).toEqual(["services/billing/portolan/*.json"]);
562
+ expect(written.extract).toHaveLength(1);
563
+ expect(written.extract[0].options).toMatchObject({ group: "avia", groupName: "Aviation", groupSummary: "Flight administration.", classification: "core", groupKind: "bounded-context", componentKind: "service" });
564
+ });
565
+
566
+ it("undoes a project removal only while the manifest is unchanged", () => {
567
+ const root = workspace();
568
+ writeProject(root, { root: "services/billing", id: "billing", name: "Billing", group: "finance", component: "billing", plugins: ["project"] });
569
+ mkdirSync(join(root, "services/billing/portolan"), { recursive: true });
570
+ writeFileSync(join(root, "services/billing/portolan/project.json"), "generated slice\n");
571
+ const removed = removeProject(root, "billing");
572
+ expect(JSON.parse(readFileSync(join(root, "portolan.json"), "utf8")).projects).toEqual([]);
573
+ expect(existsSync(join(root, "services/billing/portolan/project.json"))).toBe(false);
574
+ expect(undoProjectRemoval(root, removed.undoToken)).toEqual({ restored: true });
575
+ expect(JSON.parse(readFileSync(join(root, "portolan.json"), "utf8")).projects[0].id).toBe("billing");
576
+ expect(readFileSync(join(root, "services/billing/portolan/project.json"), "utf8")).toBe("generated slice\n");
577
+ expect(() => undoProjectRemoval(root, removed.undoToken)).toThrow(/can no longer be undone/);
578
+ });
579
+
385
580
  it("plans a pinned external repository through the built-in git fetcher", () => {
386
581
  const root = workspace();
387
582
  const repository = "https://github.com/acme/platform.git";
@@ -2,16 +2,32 @@
2
2
  // This catches accidental cwd coupling before the same package reaches npm.
3
3
 
4
4
  import { execFileSync } from "node:child_process";
5
- import { existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
5
+ import { existsSync, mkdirSync, mkdtempSync, renameSync, rmSync, symlinkSync, writeFileSync } from "node:fs";
6
6
  import { tmpdir } from "node:os";
7
7
  import { resolve } from "node:path";
8
8
  import { fileURLToPath } from "node:url";
9
9
 
10
10
  const root = resolve(fileURLToPath(new URL("..", import.meta.url)));
11
- const cli = resolve(root, "cli/portolan.mjs");
12
11
  const fixture = mkdtempSync(resolve(tmpdir(), "portolan-package-smoke-"));
12
+ const install = mkdtempSync(resolve(tmpdir(), "portolan-package-install-"));
13
13
 
14
14
  try {
15
+ // Exercise the tarball from a real node_modules path. Node permits native
16
+ // TypeScript stripping in a checkout but rejects it below node_modules, so
17
+ // invoking the source-tree CLI did not catch broken published entry graphs.
18
+ const tarball = execFileSync("npm", ["pack", "--ignore-scripts", "--pack-destination", install], {
19
+ cwd: root,
20
+ encoding: "utf8",
21
+ stdio: ["ignore", "pipe", "inherit"],
22
+ }).trim().split("\n").at(-1);
23
+ const scope = resolve(install, "node_modules/@shortlink-org");
24
+ mkdirSync(scope, { recursive: true });
25
+ execFileSync("tar", ["-xzf", resolve(install, tarball), "-C", scope]);
26
+ const installed = resolve(scope, "portolan");
27
+ renameSync(resolve(scope, "package"), installed);
28
+ symlinkSync(resolve(root, "node_modules"), resolve(installed, "node_modules"), process.platform === "win32" ? "junction" : "dir");
29
+ const cli = resolve(installed, "cli/portolan.mjs");
30
+
15
31
  writeFileSync(resolve(fixture, "package.json"), '{"name":"package-smoke","version":"1.0.0"}\n');
16
32
  writeFileSync(resolve(fixture, "README.md"), "# Package smoke\n");
17
33
  // A Go domain layout: init should notice it and wire the extractor without being asked.
@@ -34,6 +50,7 @@ try {
34
50
  console.log("package smoke: init, generate, check, and build passed outside the repository");
35
51
  } finally {
36
52
  rmSync(fixture, { recursive: true, force: true });
53
+ rmSync(install, { recursive: true, force: true });
37
54
  }
38
55
 
39
56
  function run(command, args) {
@@ -41,7 +41,7 @@ const HOST_LIMITS = Object.freeze({
41
41
  * gets no filesystem at all, which is what a generator and a describe
42
42
  * request should get. A process plugin is unaffected: it already has the
43
43
  * whole machine.
44
- * @returns {Promise<{files: {name: string, contents: string}[], describe?: object}>}
44
+ * @returns {Promise<{files: {name: string, contents: string, encoding?: "base64"}[], describe?: object}>}
45
45
  */
46
46
  export async function runPlugin(plugin, request, requestedLimits = {}, access = {}) {
47
47
  const limits = lowerLimits(requestedLimits);
@@ -158,7 +158,7 @@ export function validateResponse(name, response) {
158
158
  throw new Error(`plugin ${name}: files[${index}] is not an object`);
159
159
  }
160
160
  const keys = Object.keys(file);
161
- if (keys.some((key) => !["name", "contents"].includes(key))) {
161
+ if (keys.some((key) => !["name", "contents", "encoding"].includes(key))) {
162
162
  throw new Error(`plugin ${name}: files[${index}] has unsupported properties`);
163
163
  }
164
164
  if (typeof file.name !== "string" || !safeFileName(file.name)) {
@@ -167,17 +167,28 @@ export function validateResponse(name, response) {
167
167
  if (typeof file.contents !== "string") {
168
168
  throw new Error(`plugin ${name}: files[${index}].contents is not a string`);
169
169
  }
170
+ if (file.encoding !== undefined && file.encoding !== "base64") {
171
+ throw new Error(`plugin ${name}: files[${index}].encoding is not supported`);
172
+ }
173
+ if (file.encoding === "base64" && !validBase64(file.contents)) {
174
+ throw new Error(`plugin ${name}: files[${index}].contents is not valid base64`);
175
+ }
170
176
  if (seen.has(file.name)) {
171
177
  throw new Error(`plugin ${name}: duplicate output file ${file.name}`);
172
178
  }
173
179
  seen.add(file.name);
174
- return { name: file.name, contents: file.contents };
180
+ return { name: file.name, contents: file.contents, ...(file.encoding ? { encoding: file.encoding } : {}) };
175
181
  });
176
182
 
177
183
  if (response.describe !== undefined) validateDescriptor(name, response.describe);
178
184
  return { files, ...(response.describe === undefined ? {} : { describe: response.describe }) };
179
185
  }
180
186
 
187
+ function validBase64(value) {
188
+ return value.length % 4 === 0
189
+ && /^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/.test(value);
190
+ }
191
+
181
192
  function safeFileName(name) {
182
193
  if (!name || name.includes("\0") || isAbsolute(name) || /^[A-Za-z]:[\\/]/.test(name)) return false;
183
194
  const parts = name.split(/[\\/]/);
@@ -13,6 +13,14 @@ describe("plugin response validation", () => {
13
13
  });
14
14
  });
15
15
 
16
+ it("accepts canonical base64 files and rejects malformed binary output", () => {
17
+ expect(validateResponse("fixture", { files: [{ name: "image.png", contents: "iVBORw==", encoding: "base64" }] })).toEqual({
18
+ files: [{ name: "image.png", contents: "iVBORw==", encoding: "base64" }],
19
+ });
20
+ expect(() => validateResponse("fixture", { files: [{ name: "image.png", contents: "not base64", encoding: "base64" }] })).toThrow("not valid base64");
21
+ expect(() => validateResponse("fixture", { files: [{ name: "image.png", contents: "x", encoding: "binary" }] })).toThrow("encoding is not supported");
22
+ });
23
+
16
24
  it.each(["../secret", "/tmp/result", "C:\\tmp\\result", "a/../../secret", "./result"])(
17
25
  "rejects unsafe output name %s",
18
26
  (name) => {
@@ -207,6 +207,13 @@ function compose() {
207
207
  minLength: 1,
208
208
  description: "Component slug inside the group.",
209
209
  },
210
+ components: {
211
+ type: "array",
212
+ minItems: 2,
213
+ uniqueItems: true,
214
+ items: { type: "string", minLength: 1 },
215
+ description: "Deployable component slugs discovered inside one repository-backed project.",
216
+ },
210
217
  groupKind: {
211
218
  enum: ["bounded-context", "system", "product", "team", "namespace"],
212
219
  description: "Semantic role of the project's top-level group.",
@@ -98,7 +98,13 @@ function ChatLoading() {
98
98
  * way out the router already says the next address, and the page leaving
99
99
  * must keep drawing the one it was.
100
100
  */
101
- function AppRoutes({ location }: { location: Location }) {
101
+ function AppRoutes({
102
+ location,
103
+ onOpenSearch,
104
+ }: {
105
+ location: Location;
106
+ onOpenSearch: () => void;
107
+ }) {
102
108
  return (
103
109
  <Routes location={location}>
104
110
  <Route path="/" element={<Overview />} />
@@ -200,7 +206,10 @@ function AppRoutes({ location }: { location: Location }) {
200
206
  }
201
207
  />
202
208
  <Route path="/index.html" element={<Navigate to="/" replace />} />
203
- <Route path="*" element={<NotFoundPage />} />
209
+ <Route
210
+ path="*"
211
+ element={<NotFoundPage onOpenSearch={onOpenSearch} />}
212
+ />
204
213
  </Routes>
205
214
  );
206
215
  }
@@ -354,7 +363,10 @@ function Shell() {
354
363
  {...page}
355
364
  className="min-h-0 flex-1 overflow-hidden"
356
365
  >
357
- <AppRoutes location={location} />
366
+ <AppRoutes
367
+ location={location}
368
+ onOpenSearch={() => setPalette(true)}
369
+ />
358
370
  </m.main>
359
371
  </AnimatePresence>
360
372
  <SidebarDrawer />
@@ -392,7 +404,10 @@ function Shell() {
392
404
  {...page}
393
405
  className="h-full overflow-hidden"
394
406
  >
395
- <AppRoutes location={location} />
407
+ <AppRoutes
408
+ location={location}
409
+ onOpenSearch={() => setPalette(true)}
410
+ />
396
411
  </m.main>
397
412
  </AnimatePresence>
398
413
  </Panel>
@@ -31,6 +31,7 @@ import { pageContains, selectionPath } from "../selection/pages";
31
31
  import { selectionHash } from "../selection/hash";
32
32
  import { selectionFor } from "../selection/model";
33
33
  import { useSelectionStore } from "../selection/store";
34
+ import { CatIllustration } from "../components/CatIllustration";
34
35
 
35
36
  const ITEMS = paletteItems(catalog);
36
37
 
@@ -247,8 +248,12 @@ export function CommandPalette({
247
248
  </div>
248
249
 
249
250
  {shown === 0 ? (
250
- <div className="glow mono px-4 py-8 text-center text-muted">
251
- no match
251
+ <div className="cat-search-empty glow">
252
+ <CatIllustration scene="search" className="cat-search-illustration" />
253
+ <div className="mono">
254
+ <div className="text-ink">no match</div>
255
+ <div className="mt-1 text-faint">try another name or a kind prefix</div>
256
+ </div>
252
257
  </div>
253
258
  ) : (
254
259
  /* `static` because the list is the palette: there is no closed
package/src/app/toast.tsx CHANGED
@@ -11,17 +11,19 @@ import { AnimatePresence, m, rise } from "../lib/motion";
11
11
 
12
12
  interface ToastState {
13
13
  message: string | null;
14
+ action: { label: string; run: () => void } | null;
14
15
  /** Bumped per call, so the same message twice restarts the timer. */
15
16
  nonce: number;
16
- say: (message: string) => void;
17
+ say: (message: string, action?: { label: string; run: () => void }) => void;
17
18
  clear: () => void;
18
19
  }
19
20
 
20
21
  export const useToastStore = create<ToastState>()((set) => ({
21
22
  message: null,
23
+ action: null,
22
24
  nonce: 0,
23
- say: (message) => set((s) => ({ message, nonce: s.nonce + 1 })),
24
- clear: () => set({ message: null }),
25
+ say: (message, action) => set((s) => ({ message, action: action ?? null, nonce: s.nonce + 1 })),
26
+ clear: () => set({ message: null, action: null }),
25
27
  }));
26
28
 
27
29
  const SHOWN_MS = 4000;
@@ -29,13 +31,14 @@ const SHOWN_MS = 4000;
29
31
  export function Toaster() {
30
32
  const message = useToastStore((s) => s.message);
31
33
  const nonce = useToastStore((s) => s.nonce);
34
+ const action = useToastStore((s) => s.action);
32
35
  const clear = useToastStore((s) => s.clear);
33
36
 
34
37
  useEffect(() => {
35
38
  if (!message) return;
36
- const timer = setTimeout(clear, SHOWN_MS);
39
+ const timer = setTimeout(clear, action ? 10_000 : SHOWN_MS);
37
40
  return () => clearTimeout(timer);
38
- }, [message, nonce, clear]);
41
+ }, [message, nonce, action, clear]);
39
42
 
40
43
  // It rises in and sinks out. A second message while one is up swaps the
41
44
  // text in place: the box is the same box, only its line changed.
@@ -49,9 +52,10 @@ export function Toaster() {
49
52
  /* Bottom RIGHT, not left: the foot of the sidebar is where Problems
50
53
  and the build line are pinned, and a message that covers them for
51
54
  four seconds hides the one row that promised never to leave. */
52
- className="mono pointer-events-none fixed right-4 bottom-4 z-50 max-w-80 rounded-control border px-3 py-2 shadow-md border-line bg-canvas text-ink"
55
+ className="mono fixed right-4 bottom-4 z-50 flex max-w-96 items-center gap-3 rounded-control border px-3 py-2 shadow-md border-line bg-canvas text-ink"
53
56
  >
54
- {message}
57
+ <span>{message}</span>
58
+ {action ? <button type="button" className="tbtn shrink-0 px-2 py-1 text-accent" onClick={() => { action.run(); clear(); }}>{action.label}</button> : null}
55
59
  </m.div>
56
60
  )}
57
61
  </AnimatePresence>
@@ -290,6 +290,39 @@ describe("validateCatalog: flow frames", () => {
290
290
  expect(() => validateCatalog(bad)).toThrow(/job handoff with no message/);
291
291
  });
292
292
 
293
+ it("validates that a response reverses the rpc request it names", () => {
294
+ const good = clone();
295
+ const flow = good.flows[0]!;
296
+ const request = walkSteps(flow.steps).find((step) => step.kind === "rpc")!;
297
+ flow.steps.push({
298
+ type: "step",
299
+ id: "response-test",
300
+ from: request.to,
301
+ to: request.from,
302
+ kind: "response",
303
+ label: "Response",
304
+ status: request.status,
305
+ replyTo: request.id,
306
+ });
307
+ expect(() => validateCatalog(good)).not.toThrow();
308
+
309
+ const bad = clone();
310
+ const badRequest = walkSteps(bad.flows[0]!.steps).find(
311
+ (step) => step.kind === "rpc",
312
+ )!;
313
+ bad.flows[0]!.steps.push({
314
+ type: "step",
315
+ id: "response-test",
316
+ from: badRequest.to,
317
+ to: badRequest.from,
318
+ kind: "response",
319
+ label: "Response",
320
+ status: "declared",
321
+ replyTo: "missing-request",
322
+ });
323
+ expect(() => validateCatalog(bad)).toThrow(/not an rpc request/);
324
+ });
325
+
293
326
  it("rejects an alt with a single branch", () => {
294
327
  const { bad, node } = alt();
295
328
  node.branches = node.branches.slice(0, 1);