@shortlink-org/portolan 0.2.1 → 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 +363 -28
- package/scripts/local-api.test.mjs +195 -1
- 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
|
@@ -5,7 +5,7 @@ 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
11
|
const PACKAGE_VERSION = JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8")).version;
|
|
@@ -189,6 +189,18 @@ describe("local project setup", () => {
|
|
|
189
189
|
expect(discovery.detections.find((item) => item.plugin === "adr")?.selected).toBe(false);
|
|
190
190
|
});
|
|
191
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
|
+
|
|
192
204
|
it("finds component roots in a monorepo without reading dependency directories", () => {
|
|
193
205
|
const root = workspace();
|
|
194
206
|
mkdirSync(join(root, "services/orders"), { recursive: true });
|
|
@@ -209,6 +221,55 @@ describe("local project setup", () => {
|
|
|
209
221
|
]);
|
|
210
222
|
});
|
|
211
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
|
+
|
|
212
273
|
it("only offers the Go domain extractor when the layout contains an aggregate root", () => {
|
|
213
274
|
const root = workspace();
|
|
214
275
|
mkdirSync(join(root, "services/billing/internal/domain/invoice"), { recursive: true });
|
|
@@ -233,6 +294,14 @@ describe("local project setup", () => {
|
|
|
233
294
|
expect(discovery.detections.find((item) => item.plugin === "sql")?.evidence).toBe("2 repository packages");
|
|
234
295
|
});
|
|
235
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
|
+
|
|
236
305
|
it("offers the River extractor when the Go module uses River", () => {
|
|
237
306
|
const root = workspace();
|
|
238
307
|
writeFileSync(join(root, "services/billing/go.mod"), "module example.com/billing\nrequire github.com/riverqueue/river v0.26.0\n");
|
|
@@ -383,6 +452,131 @@ describe("local project setup", () => {
|
|
|
383
452
|
expect(() => writeProject(root, { ...request, id: "another" })).toThrow(/already exists/);
|
|
384
453
|
});
|
|
385
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
|
+
|
|
386
580
|
it("plans a pinned external repository through the built-in git fetcher", () => {
|
|
387
581
|
const root = workspace();
|
|
388
582
|
const repository = "https://github.com/acme/platform.git";
|
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);
|