minutework 0.1.6 → 0.1.8

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 (40) hide show
  1. package/EXTERNAL_ALPHA.md +18 -1
  2. package/README.md +9 -2
  3. package/assets/claude-local/CLAUDE.md.template +22 -2
  4. package/assets/claude-local/skills/README.md +9 -0
  5. package/assets/claude-local/skills/app-pack-authoring.md +5 -1
  6. package/assets/claude-local/skills/content-structure-and-sections.md +15 -0
  7. package/assets/claude-local/skills/published-web-and-mw-core-site.md +17 -0
  8. package/assets/claude-local/skills/schema-engine.md +4 -0
  9. package/assets/claude-local/skills/secrets-runtime-bridge.md +3 -0
  10. package/assets/claude-local/skills/sidecar-generation.md +4 -0
  11. package/assets/templates/fastapi-sidecar/template.schema.json +2 -1
  12. package/assets/templates/next-tenant-app/.storybook/main.ts +1 -1
  13. package/assets/templates/next-tenant-app/README.md +20 -3
  14. package/assets/templates/next-tenant-app/next.config.mjs +33 -0
  15. package/assets/templates/next-tenant-app/package.json +3 -1
  16. package/assets/templates/next-tenant-app/public/.gitkeep +1 -0
  17. package/assets/templates/next-tenant-app/src/app/(cms)/[...path]/page.tsx +73 -0
  18. package/assets/templates/next-tenant-app/src/features/dashboard/components/tenant-dashboard.tsx +5 -3
  19. package/assets/templates/next-tenant-app/src/features/public-shell/components/section-renderer.test.ts +38 -0
  20. package/assets/templates/next-tenant-app/src/features/public-shell/components/section-renderer.tsx +145 -0
  21. package/assets/templates/next-tenant-app/src/lib/content/adapter.server.test.ts +2 -2
  22. package/assets/templates/next-tenant-app/src/lib/content/adapter.server.ts +47 -1
  23. package/assets/templates/next-tenant-app/src/lib/content/contracts.test.ts +138 -0
  24. package/assets/templates/next-tenant-app/src/lib/content/contracts.ts +59 -0
  25. package/assets/templates/next-tenant-app/src/lib/content/release-manifest.test.ts +70 -0
  26. package/assets/templates/next-tenant-app/src/lib/content/release-manifest.ts +106 -0
  27. package/assets/templates/next-tenant-app/template.json +2 -2
  28. package/assets/templates/next-tenant-app/template.schema.json +2 -1
  29. package/assets/templates/next-tenant-app/tools/template/with-public-site-fixture.mjs +130 -0
  30. package/dist/agent.d.ts +19 -0
  31. package/dist/agent.js +308 -0
  32. package/dist/agent.js.map +1 -0
  33. package/dist/developer-client.d.ts +59 -0
  34. package/dist/developer-client.js +35 -0
  35. package/dist/developer-client.js.map +1 -1
  36. package/dist/index.d.ts +2 -0
  37. package/dist/index.js +17 -0
  38. package/dist/index.js.map +1 -1
  39. package/package.json +1 -1
  40. package/assets/templates/next-tenant-app/next.config.ts +0 -18
@@ -1,9 +1,10 @@
1
1
  import "server-only";
2
2
 
3
3
  import { cache } from "react";
4
- import type { ZodType } from "zod";
4
+ import { z, type ZodType } from "zod";
5
5
 
6
6
  import type {
7
+ ContentStructurePage,
7
8
  MarketingPage,
8
9
  MarketingPageKey,
9
10
  PublicContentAdapter,
@@ -14,6 +15,7 @@ import type {
14
15
  SiteConfig,
15
16
  } from "@/lib/content/contracts";
16
17
  import {
18
+ contentStructurePageSchema,
17
19
  marketingPageResultSchema,
18
20
  publicEntryResultSchema,
19
21
  publicEntrySummaryListSchema,
@@ -110,6 +112,26 @@ export class ValidatedPublicContentAdapter implements PublicContentAdapter {
110
112
  await this.adapter.getEntry(kind, slugParts),
111
113
  );
112
114
  }
115
+
116
+ async getPageByPath(path: string): Promise<ContentStructurePage | null> {
117
+ if (!this.adapter.getPageByPath) return null;
118
+ return validateAdapterResult(
119
+ this.adapterLabel,
120
+ `CMS page for path "${path}"`,
121
+ contentStructurePageSchema.nullable(),
122
+ await this.adapter.getPageByPath(path),
123
+ );
124
+ }
125
+
126
+ async listPages(status?: string): Promise<ContentStructurePage[]> {
127
+ if (!this.adapter.listPages) return [];
128
+ return validateAdapterResult(
129
+ this.adapterLabel,
130
+ "CMS page list",
131
+ z.array(contentStructurePageSchema),
132
+ await this.adapter.listPages(status),
133
+ );
134
+ }
113
135
  }
114
136
 
115
137
  export async function fetchPublicSiteSnapshotEnvelope(input: {
@@ -197,6 +219,22 @@ export class MinuteWorkPublicSiteContentAdapter implements PublicContentAdapter
197
219
  ) ?? null
198
220
  );
199
221
  }
222
+
223
+ async getPageByPath(path: string): Promise<ContentStructurePage | null> {
224
+ const snapshot = await this.loadSnapshot();
225
+ const pages = snapshot.pages ?? [];
226
+ const normalizedPath = path.replace(/\/+$/, "") || "/";
227
+ return pages.find((p) => p.path === normalizedPath) ?? null;
228
+ }
229
+
230
+ async listPages(status?: string): Promise<ContentStructurePage[]> {
231
+ const snapshot = await this.loadSnapshot();
232
+ const pages = snapshot.pages ?? [];
233
+ if (status) {
234
+ return pages.filter((p) => p.status === status);
235
+ }
236
+ return pages;
237
+ }
200
238
  }
201
239
 
202
240
  const defaultPublicContentAdapter = new ValidatedPublicContentAdapter(
@@ -230,3 +268,11 @@ export const listEntries = cache(async (kind: PublicContentKind) =>
230
268
  export const getEntry = cache(async (kind: PublicContentKind, slugParts: string[]) =>
231
269
  getPublicContentAdapter().getEntry(kind, slugParts),
232
270
  );
271
+
272
+ export const getPageByPath = cache(async (path: string) =>
273
+ getPublicContentAdapter().getPageByPath(path),
274
+ );
275
+
276
+ export const listPages = cache(async (status?: string) =>
277
+ getPublicContentAdapter().listPages(status),
278
+ );
@@ -0,0 +1,138 @@
1
+ import { describe, expect, it } from "vitest";
2
+
3
+ import {
4
+ contentSectionSchema,
5
+ contentStructurePageSchema,
6
+ publicContentSnapshotSchema,
7
+ } from "./contracts";
8
+
9
+ describe("content_structure contracts", () => {
10
+ it("validates a page with arbitrary section types", () => {
11
+ const page = {
12
+ page_key: "landing",
13
+ path: "/landing",
14
+ title: "Landing Page",
15
+ status: "published",
16
+ sort_order: 0,
17
+ content_structure: {
18
+ sections: [
19
+ {
20
+ section_key: "hero",
21
+ section_type: "hero_banner",
22
+ sort_order: 0,
23
+ data: { heading: "Welcome", subheading: "To our site" },
24
+ },
25
+ {
26
+ section_key: "features",
27
+ section_type: "custom_feature_grid",
28
+ sort_order: 1,
29
+ data: {
30
+ columns: 3,
31
+ items: [
32
+ { title: "Fast", icon: "bolt" },
33
+ { title: "Secure", icon: "lock" },
34
+ ],
35
+ },
36
+ },
37
+ {
38
+ section_key: "tenant_custom",
39
+ section_type: "my_custom_widget",
40
+ sort_order: 2,
41
+ data: { arbitrary: "tenant-defined", nested: { deep: true } },
42
+ },
43
+ ],
44
+ },
45
+ seo_metadata: {
46
+ title: "Landing Page",
47
+ description: "A landing page with arbitrary sections",
48
+ },
49
+ };
50
+
51
+ const result = contentStructurePageSchema.safeParse(page);
52
+ expect(result.success).toBe(true);
53
+ if (result.success) {
54
+ expect(result.data.content_structure.sections).toHaveLength(3);
55
+ expect(result.data.content_structure.sections[2]!.section_type).toBe(
56
+ "my_custom_widget",
57
+ );
58
+ }
59
+ });
60
+
61
+ it("rejects a section without section_key", () => {
62
+ const section = {
63
+ section_type: "text",
64
+ data: { heading: "Heading" },
65
+ };
66
+ const result = contentSectionSchema.safeParse(section);
67
+ expect(result.success).toBe(false);
68
+ });
69
+
70
+ it("allows empty sections array", () => {
71
+ const page = {
72
+ page_key: "empty",
73
+ path: "/empty",
74
+ title: "Empty Page",
75
+ status: "draft",
76
+ content_structure: { sections: [] },
77
+ };
78
+ const result = contentStructurePageSchema.safeParse(page);
79
+ expect(result.success).toBe(true);
80
+ });
81
+
82
+ it("snapshot schema accepts optional pages array", () => {
83
+ const snapshot = {
84
+ site: {
85
+ siteName: "Test",
86
+ siteDescription: "Test site",
87
+ organizationName: "Org",
88
+ footerBlurb: "Footer",
89
+ primaryCta: { label: "CTA", href: "/" },
90
+ secondaryCta: { label: "CTA2", href: "/docs" },
91
+ primaryNavigation: [{ label: "Home", href: "/" }],
92
+ collections: {
93
+ docs: { eyebrow: "Docs", title: "Docs", description: "Documentation" },
94
+ blog: { eyebrow: "Blog", title: "Blog", description: "Updates" },
95
+ },
96
+ },
97
+ marketingPages: [],
98
+ docs: [],
99
+ blog: [],
100
+ pages: [
101
+ {
102
+ page_key: "about",
103
+ path: "/about",
104
+ title: "About",
105
+ status: "published",
106
+ content_structure: { sections: [] },
107
+ },
108
+ ],
109
+ };
110
+
111
+ const result = publicContentSnapshotSchema.safeParse(snapshot);
112
+ expect(result.success).toBe(true);
113
+ });
114
+
115
+ it("snapshot schema works without pages field (backward compat)", () => {
116
+ const snapshot = {
117
+ site: {
118
+ siteName: "Test",
119
+ siteDescription: "Test site",
120
+ organizationName: "Org",
121
+ footerBlurb: "Footer",
122
+ primaryCta: { label: "CTA", href: "/" },
123
+ secondaryCta: { label: "CTA2", href: "/docs" },
124
+ primaryNavigation: [{ label: "Home", href: "/" }],
125
+ collections: {
126
+ docs: { eyebrow: "Docs", title: "Docs", description: "Documentation" },
127
+ blog: { eyebrow: "Blog", title: "Blog", description: "Updates" },
128
+ },
129
+ },
130
+ marketingPages: [],
131
+ docs: [],
132
+ blog: [],
133
+ };
134
+
135
+ const result = publicContentSnapshotSchema.safeParse(snapshot);
136
+ expect(result.success).toBe(true);
137
+ });
138
+ });
@@ -93,11 +93,48 @@ export type DocsEntry = DocsEntrySummary & PublicEntryBody;
93
93
  export type BlogEntry = BlogEntrySummary & PublicEntryBody;
94
94
  export type PublicEntry = DocsEntry | BlogEntry;
95
95
 
96
+ /**
97
+ * A single section/block within a CMS page's content_structure.
98
+ *
99
+ * Section types are extensible strings — not a closed enum. This allows
100
+ * tenant-specific or extension-pack sections without modifying the baseline.
101
+ */
102
+ export type ContentSection = {
103
+ section_key: string;
104
+ section_type: string;
105
+ sort_order?: number;
106
+ data: Record<string, unknown>;
107
+ };
108
+
109
+ /**
110
+ * A CMS page backed by the mw.core.site runtime baseline.
111
+ *
112
+ * content_structure is an open, ordered section/block graph — not a fixed
113
+ * template. The section renderer maps section_type to React components.
114
+ */
115
+ export type ContentStructurePage = {
116
+ page_key: string;
117
+ path: string;
118
+ title: string;
119
+ status: string;
120
+ sort_order?: number;
121
+ content_structure: {
122
+ sections: ContentSection[];
123
+ };
124
+ seo_metadata?: SeoMetadata;
125
+ og_metadata?: Record<string, string>;
126
+ visibility?: string;
127
+ };
128
+
96
129
  export interface PublicContentAdapter {
97
130
  getSiteConfig(): Promise<SiteConfig>;
98
131
  getMarketingPage(pageKey: MarketingPageKey): Promise<MarketingPage | null>;
99
132
  listEntries(kind: PublicContentKind): Promise<PublicEntrySummary[]>;
100
133
  getEntry(kind: PublicContentKind, slugParts: string[]): Promise<PublicEntry | null>;
134
+ /** Retrieve a CMS page by its URL path. Returns null if not found. */
135
+ getPageByPath?(path: string): Promise<ContentStructurePage | null>;
136
+ /** List all CMS pages, optionally filtered by status. */
137
+ listPages?(status?: string): Promise<ContentStructurePage[]>;
101
138
  }
102
139
 
103
140
  export const publicLinkSchema = z.object({
@@ -234,11 +271,33 @@ export const blogEntrySchema = blogEntrySummarySchema.extend(
234
271
 
235
272
  export const publicEntrySchema = z.union([docsEntrySchema, blogEntrySchema]);
236
273
 
274
+ export const contentSectionSchema = z.object({
275
+ section_key: z.string().min(1),
276
+ section_type: z.string().min(1),
277
+ sort_order: z.number().optional(),
278
+ data: z.record(z.unknown()),
279
+ });
280
+
281
+ export const contentStructurePageSchema = z.object({
282
+ page_key: z.string().min(1),
283
+ path: z.string().min(1),
284
+ title: z.string().min(1),
285
+ status: z.string().min(1),
286
+ sort_order: z.number().optional(),
287
+ content_structure: z.object({
288
+ sections: z.array(contentSectionSchema),
289
+ }),
290
+ seo_metadata: seoMetadataSchema.optional(),
291
+ og_metadata: z.record(z.string()).optional(),
292
+ visibility: z.string().optional(),
293
+ });
294
+
237
295
  export const publicContentSnapshotSchema = z.object({
238
296
  site: siteConfigSchema,
239
297
  marketingPages: marketingPagesSchema,
240
298
  docs: z.array(docsEntrySchema),
241
299
  blog: z.array(blogEntrySchema),
300
+ pages: z.array(contentStructurePageSchema).optional(),
242
301
  });
243
302
 
244
303
  export const publicSiteSnapshotEnvironmentSchema = z.enum(["preview", "live"]);
@@ -0,0 +1,70 @@
1
+ import { describe, expect, it } from "vitest";
2
+
3
+ import { buildReleaseManifest, releaseManifestSchema } from "./release-manifest";
4
+
5
+ describe("release-manifest", () => {
6
+ it("builds a valid manifest from composition input", () => {
7
+ const manifest = buildReleaseManifest({
8
+ appPackId: "mw.core.site",
9
+ snapshotSchemaVersion: 1,
10
+ releaseClass: "static_export",
11
+ config: { site_title: "Test Site" },
12
+ pages: [
13
+ {
14
+ page_key: "home",
15
+ path: "/",
16
+ title: "Home",
17
+ sections: [{ section_key: "hero", section_type: "hero", data: {} }],
18
+ },
19
+ {
20
+ page_key: "about",
21
+ path: "/about",
22
+ title: "About",
23
+ sections: [],
24
+ },
25
+ ],
26
+ navigation: [
27
+ { nav_key: "header", slot: "header", items: [{ label: "Home" }] },
28
+ ],
29
+ forms: [
30
+ { form_key: "contact", schema: { fields: [{ name: "email" }] } },
31
+ ],
32
+ contentDigest: "sha256-abc123",
33
+ });
34
+
35
+ const parsed = releaseManifestSchema.safeParse(manifest);
36
+ expect(parsed.success).toBe(true);
37
+ expect(manifest.manifest_version).toBe(1);
38
+ expect(manifest.composition.config_updated).toBe(true);
39
+ expect(manifest.composition.pages).toHaveLength(2);
40
+ expect(manifest.composition.pages[0]!.section_count).toBe(1);
41
+ expect(manifest.composition.pages[1]!.section_count).toBe(0);
42
+ expect(manifest.composition.navigation[0]!.item_count).toBe(1);
43
+ expect(manifest.composition.forms[0]!.field_count).toBe(1);
44
+ });
45
+
46
+ it("handles null config", () => {
47
+ const manifest = buildReleaseManifest({
48
+ appPackId: "mw.core.site",
49
+ snapshotSchemaVersion: 1,
50
+ releaseClass: "ssr_container",
51
+ config: null,
52
+ pages: [],
53
+ navigation: [],
54
+ forms: [],
55
+ contentDigest: "sha256-empty",
56
+ });
57
+
58
+ expect(manifest.composition.config_updated).toBe(false);
59
+ });
60
+
61
+ it("validates against the schema", () => {
62
+ const invalid = {
63
+ manifest_version: 2, // wrong version
64
+ app_pack_id: "",
65
+ };
66
+
67
+ const parsed = releaseManifestSchema.safeParse(invalid);
68
+ expect(parsed.success).toBe(false);
69
+ });
70
+ });
@@ -0,0 +1,106 @@
1
+ import { z } from "zod";
2
+
3
+ import { contentStructurePageSchema, siteConfigSchema } from "./contracts";
4
+
5
+ /**
6
+ * Release manifest contract for Builder output.
7
+ *
8
+ * When Builder composes a site, it emits a manifest that describes what
9
+ * was generated. This manifest is inspectable, diffable, and can be
10
+ * used to verify that releases match expectations.
11
+ *
12
+ * The manifest is NOT the release artifact itself — it describes the
13
+ * composition that produced the artifact.
14
+ */
15
+
16
+ export const releaseManifestSchema = z.object({
17
+ /** Manifest format version. */
18
+ manifest_version: z.literal(1),
19
+
20
+ /** The app pack ID that was composed. */
21
+ app_pack_id: z.string().min(1),
22
+
23
+ /** Snapshot schema version for compatibility gating. */
24
+ snapshot_schema_version: z.number().int().positive(),
25
+
26
+ /** Release class from the hosted vocabulary. */
27
+ release_class: z.enum(["static_export", "ssr_container", "runtime_local_sidecar"]),
28
+
29
+ /** Summary of what was composed. */
30
+ composition: z.object({
31
+ config_updated: z.boolean(),
32
+ pages: z.array(
33
+ z.object({
34
+ page_key: z.string().min(1),
35
+ path: z.string().min(1),
36
+ title: z.string().min(1),
37
+ section_count: z.number().int().min(0),
38
+ }),
39
+ ),
40
+ navigation: z.array(
41
+ z.object({
42
+ nav_key: z.string().min(1),
43
+ slot: z.string().min(1),
44
+ item_count: z.number().int().min(0),
45
+ }),
46
+ ),
47
+ forms: z.array(
48
+ z.object({
49
+ form_key: z.string().min(1),
50
+ field_count: z.number().int().min(0),
51
+ }),
52
+ ),
53
+ }),
54
+
55
+ /** Digest of the composed content for integrity verification. */
56
+ content_digest: z.string().min(1),
57
+
58
+ /** Timestamp of the manifest generation. */
59
+ generated_at: z.string().min(1),
60
+ });
61
+
62
+ export type ReleaseManifest = z.infer<typeof releaseManifestSchema>;
63
+
64
+ /**
65
+ * Build a release manifest from a Builder composition result.
66
+ *
67
+ * This is called by Builder after compose_site_from_builder_payload
68
+ * to produce an inspectable record of what was generated.
69
+ */
70
+ export function buildReleaseManifest(input: {
71
+ appPackId: string;
72
+ snapshotSchemaVersion: number;
73
+ releaseClass: "static_export" | "ssr_container" | "runtime_local_sidecar";
74
+ config: Record<string, unknown> | null;
75
+ pages: { page_key: string; path: string; title: string; sections: unknown[] }[];
76
+ navigation: { nav_key: string; slot: string; items: unknown[] }[];
77
+ forms: { form_key: string; schema?: { fields?: unknown[] } }[];
78
+ contentDigest: string;
79
+ }): ReleaseManifest {
80
+ return {
81
+ manifest_version: 1,
82
+ app_pack_id: input.appPackId,
83
+ snapshot_schema_version: input.snapshotSchemaVersion,
84
+ release_class: input.releaseClass,
85
+ composition: {
86
+ config_updated: input.config !== null,
87
+ pages: input.pages.map((p) => ({
88
+ page_key: p.page_key,
89
+ path: p.path,
90
+ title: p.title,
91
+ section_count: p.sections?.length ?? 0,
92
+ })),
93
+ navigation: input.navigation.map((n) => ({
94
+ nav_key: n.nav_key,
95
+ slot: n.slot,
96
+ item_count: n.items?.length ?? 0,
97
+ })),
98
+ forms: input.forms.map((f) => ({
99
+ form_key: f.form_key,
100
+ field_count: f.schema?.fields?.length ?? 0,
101
+ })),
102
+ },
103
+ content_digest: input.contentDigest,
104
+ generated_at: new Date().toISOString(),
105
+ };
106
+ }
@@ -1,9 +1,9 @@
1
1
  {
2
2
  "template_id": "next-tenant-app",
3
- "template_kind": "sidecar_nextjs_private",
3
+ "template_kind": "combined_web",
4
4
  "template_profile": "platform_session_bff",
5
5
  "template_bundle_ref": "runtime/builder/templates/next-tenant-app",
6
- "template_version": "0.2.0",
6
+ "template_version": "0.3.0",
7
7
  "materialize": {
8
8
  "destination": "app"
9
9
  },
@@ -24,7 +24,8 @@
24
24
  "type": "string",
25
25
  "enum": [
26
26
  "sidecar_nextjs_private",
27
- "sidecar_fastapi_internal"
27
+ "sidecar_fastapi_internal",
28
+ "combined_web"
28
29
  ]
29
30
  },
30
31
  "template_profile": {
@@ -0,0 +1,130 @@
1
+ import { spawn } from "node:child_process";
2
+ import { createServer } from "node:http";
3
+
4
+ const [command, ...args] = process.argv.slice(2);
5
+ const storybookHeapMb = process.env.MW_STORYBOOK_NODE_HEAP_MB ?? "768";
6
+
7
+ if (!command) {
8
+ console.error("Usage: node tools/template/with-public-site-fixture.mjs <command> [args...]");
9
+ process.exit(1);
10
+ }
11
+
12
+ const emptySnapshot = {
13
+ site: {
14
+ siteName: "MinuteWork Combined Starter",
15
+ siteDescription: "Validation fixture snapshot for the combined starter.",
16
+ organizationName: "MinuteWork",
17
+ footerBlurb: "Validation fixture content.",
18
+ primaryCta: {
19
+ label: "Sign In",
20
+ href: "/login",
21
+ },
22
+ secondaryCta: {
23
+ label: "Docs",
24
+ href: "/docs",
25
+ },
26
+ primaryNavigation: [
27
+ { label: "Home", href: "/" },
28
+ { label: "Pricing", href: "/pricing" },
29
+ { label: "Docs", href: "/docs" },
30
+ { label: "Blog", href: "/blog" },
31
+ ],
32
+ collections: {
33
+ docs: {
34
+ eyebrow: "Docs",
35
+ title: "Documentation",
36
+ description: "Validation fixture docs collection.",
37
+ },
38
+ blog: {
39
+ eyebrow: "Blog",
40
+ title: "Updates",
41
+ description: "Validation fixture blog collection.",
42
+ },
43
+ },
44
+ },
45
+ marketingPages: [],
46
+ docs: [],
47
+ blog: [],
48
+ };
49
+
50
+ const server = createServer((request, response) => {
51
+ const requestUrl = new URL(request.url ?? "/", "http://127.0.0.1");
52
+ if (
53
+ request.method !== "GET" ||
54
+ requestUrl.pathname !== "/api/v1/developer/public-site/snapshots/main-site/"
55
+ ) {
56
+ response.writeHead(404, { "content-type": "application/json" });
57
+ response.end(JSON.stringify({ detail: "Not found" }));
58
+ return;
59
+ }
60
+
61
+ const environment = requestUrl.searchParams.get("environment") ?? "preview";
62
+ const sourceBoundary = environment === "live" ? "published_live" : "runtime_preview";
63
+
64
+ response.writeHead(200, { "content-type": "application/json" });
65
+ response.end(
66
+ JSON.stringify({
67
+ environment,
68
+ source_boundary: sourceBoundary,
69
+ snapshot: emptySnapshot,
70
+ }),
71
+ );
72
+ });
73
+
74
+ try {
75
+ const baseUrl = await new Promise((resolve, reject) => {
76
+ server.once("error", reject);
77
+ server.listen(0, "127.0.0.1", () => {
78
+ const address = server.address();
79
+ if (!address || typeof address === "string") {
80
+ reject(new Error("Unable to resolve validation fixture server address."));
81
+ return;
82
+ }
83
+ resolve(`http://127.0.0.1:${address.port}`);
84
+ });
85
+ });
86
+
87
+ const exitCode = await new Promise((resolve, reject) => {
88
+ const childEnv = {
89
+ ...process.env,
90
+ MW_CONTENT_API_TOKEN: "validate-content-token",
91
+ MW_PLATFORM_BASE_URL: baseUrl,
92
+ MW_PUBLIC_BASE_URL: "https://public.example.com",
93
+ MW_PUBLIC_SITE_ENV: "preview",
94
+ MW_PUBLIC_SITE_PROPERTY_KEY: "main-site",
95
+ };
96
+ if (
97
+ command === "storybook" &&
98
+ !/\bmax-old-space-size=/.test(childEnv.NODE_OPTIONS ?? "")
99
+ ) {
100
+ childEnv.NODE_OPTIONS = [childEnv.NODE_OPTIONS, `--max-old-space-size=${storybookHeapMb}`]
101
+ .filter(Boolean)
102
+ .join(" ");
103
+ }
104
+ const child = spawn(command, args, {
105
+ env: childEnv,
106
+ stdio: "inherit",
107
+ });
108
+
109
+ child.once("error", reject);
110
+ child.once("exit", (code, signal) => {
111
+ if (signal) {
112
+ reject(new Error(`Validation fixture command exited via signal ${signal}.`));
113
+ return;
114
+ }
115
+ resolve(code ?? 1);
116
+ });
117
+ });
118
+
119
+ process.exitCode = exitCode;
120
+ } finally {
121
+ await new Promise((resolve, reject) => {
122
+ server.close((error) => {
123
+ if (error) {
124
+ reject(error);
125
+ return;
126
+ }
127
+ resolve(undefined);
128
+ });
129
+ });
130
+ }
@@ -0,0 +1,19 @@
1
+ import type { CliIo } from "./index.js";
2
+ import type { CliStatePaths, PlatformName } from "./paths.js";
3
+ export type AgentDependencies = {
4
+ closeInput?: () => void;
5
+ fetchImpl?: typeof fetch;
6
+ heartbeatIntervalMs?: number;
7
+ inputLines?: AsyncIterable<string>;
8
+ pollIntervalMs?: number;
9
+ sleepImpl?: (ms: number) => Promise<void>;
10
+ };
11
+ export declare function runAgentCommand(options: {
12
+ args: string[];
13
+ cwd: string;
14
+ dependencies?: AgentDependencies;
15
+ env: NodeJS.ProcessEnv;
16
+ io: CliIo;
17
+ paths: CliStatePaths;
18
+ platform: PlatformName;
19
+ }): Promise<number>;