@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
|
@@ -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(
|
|
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) {
|
package/scripts/plugin-host.mjs
CHANGED
|
@@ -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) => {
|
package/scripts/schema.mjs
CHANGED
|
@@ -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.",
|
package/src/app/CatalogApp.tsx
CHANGED
|
@@ -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({
|
|
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
|
|
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
|
|
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
|
|
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="
|
|
251
|
-
|
|
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
|
|
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>
|
package/src/catalog.test.ts
CHANGED
|
@@ -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);
|