@mandujs/core 0.54.2 → 0.54.4

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mandujs/core",
3
- "version": "0.54.2",
3
+ "version": "0.54.4",
4
4
  "description": "Mandu Framework Core - Spec, Generator, Guard, Runtime",
5
5
  "type": "module",
6
6
  "main": "./src/index.ts",
@@ -8,6 +8,7 @@
8
8
  "exports": {
9
9
  ".": "./src/index.ts",
10
10
  "./a11y": "./src/a11y/index.ts",
11
+ "./agent": "./src/agent/index.ts",
11
12
  "./auth": "./src/auth/index.ts",
12
13
  "./auth/login": "./src/auth/login.ts",
13
14
  "./auth/password": "./src/auth/password.ts",
@@ -110,9 +111,11 @@
110
111
  "./components/Image": "./src/components/Image.tsx"
111
112
  },
112
113
  "files": [
113
- "src/**/*"
114
+ "src/**/*",
115
+ "scripts/postinstall-lock.ts"
114
116
  ],
115
117
  "scripts": {
118
+ "postinstall": "bun ./scripts/postinstall-lock.ts",
116
119
  "test": "bun test tests/streaming-ssr && bun test tests/hydration tests/typing src",
117
120
  "test:hydration": "bun test tests/hydration",
118
121
  "test:streaming": "bun test tests/streaming-ssr",
@@ -0,0 +1,153 @@
1
+ /**
2
+ * Refresh an existing Guard lock after package-manager updates.
3
+ *
4
+ * Lifecycle scripts run from inside the installed package, so projectRoot
5
+ * defaults to INIT_CWD when Bun provides it. This helper is intentionally
6
+ * best-effort: installs must not fail because a project has a stale, invalid,
7
+ * or temporarily unreadable Mandu config.
8
+ */
9
+
10
+ import path from "node:path";
11
+ import {
12
+ validateConfig,
13
+ type ValidatedManduConfig,
14
+ } from "../src/config/validate.js";
15
+ import { CONFIG_FILES } from "../src/config/mandu.js";
16
+ import {
17
+ generateLockfile,
18
+ readLockfile,
19
+ readMcpConfig,
20
+ writeLockfile,
21
+ LOCKFILE_PATH,
22
+ } from "../src/lockfile/index.js";
23
+
24
+ export type PostinstallLockAction =
25
+ | "updated"
26
+ | "skipped-disabled"
27
+ | "skipped-no-project-config"
28
+ | "skipped-no-lockfile"
29
+ | "skipped-invalid-lockfile"
30
+ | "skipped-invalid-config"
31
+ | "skipped-invalid-mcp-config"
32
+ | "skipped-write-failed";
33
+
34
+ export interface PostinstallLockResult {
35
+ action: PostinstallLockAction;
36
+ projectRoot: string;
37
+ hash?: string;
38
+ error?: string;
39
+ }
40
+
41
+ export interface PostinstallLockOptions {
42
+ projectRoot?: string;
43
+ env?: NodeJS.ProcessEnv;
44
+ verbose?: boolean;
45
+ log?: (message: string) => void;
46
+ warn?: (message: string) => void;
47
+ }
48
+
49
+ export async function refreshGuardLockAfterInstall(
50
+ options: PostinstallLockOptions = {},
51
+ ): Promise<PostinstallLockResult> {
52
+ const env = options.env ?? process.env;
53
+ const projectRoot = path.resolve(
54
+ options.projectRoot ?? env.INIT_CWD ?? process.cwd(),
55
+ );
56
+ const verbose = options.verbose ?? env.MANDU_POSTINSTALL_VERBOSE === "1";
57
+ const log = options.log ?? console.log;
58
+ const warn = options.warn ?? console.warn;
59
+
60
+ const report = (result: PostinstallLockResult): PostinstallLockResult => {
61
+ if (verbose) {
62
+ if (result.action === "updated") {
63
+ log(`[Mandu] refreshed ${LOCKFILE_PATH} (${result.hash})`);
64
+ } else if (result.error) {
65
+ warn(`[Mandu] ${result.action}: ${result.error}`);
66
+ } else {
67
+ log(`[Mandu] ${result.action}`);
68
+ }
69
+ }
70
+ return result;
71
+ };
72
+
73
+ if (env.MANDU_POSTINSTALL_LOCK === "0") {
74
+ return report({ action: "skipped-disabled", projectRoot });
75
+ }
76
+
77
+ if (!(await hasProjectConfig(projectRoot))) {
78
+ return report({ action: "skipped-no-project-config", projectRoot });
79
+ }
80
+
81
+ let existingLockfile: Awaited<ReturnType<typeof readLockfile>>;
82
+ try {
83
+ existingLockfile = await readLockfile(projectRoot);
84
+ } catch (error) {
85
+ return report({
86
+ action: "skipped-invalid-lockfile",
87
+ projectRoot,
88
+ error: stringifyError(error),
89
+ });
90
+ }
91
+
92
+ if (!existingLockfile) {
93
+ return report({ action: "skipped-no-lockfile", projectRoot });
94
+ }
95
+
96
+ const validation = await validateConfig(projectRoot);
97
+ if (!validation.valid || !validation.config) {
98
+ return report({
99
+ action: "skipped-invalid-config",
100
+ projectRoot,
101
+ error:
102
+ validation.errors?.map((entry) => entry.message).join("; ") ??
103
+ "Mandu config validation failed",
104
+ });
105
+ }
106
+
107
+ let mcpConfig: Record<string, unknown> | null;
108
+ try {
109
+ mcpConfig = await readMcpConfig(projectRoot);
110
+ } catch (error) {
111
+ return report({
112
+ action: "skipped-invalid-mcp-config",
113
+ projectRoot,
114
+ error: stringifyError(error),
115
+ });
116
+ }
117
+
118
+ try {
119
+ const lockfile = generateLockfile(
120
+ validation.config as ValidatedManduConfig,
121
+ {
122
+ includeSnapshot: existingLockfile.snapshot !== undefined,
123
+ includeMcpServerHashes: true,
124
+ },
125
+ mcpConfig,
126
+ );
127
+ await writeLockfile(projectRoot, lockfile);
128
+ return report({ action: "updated", projectRoot, hash: lockfile.configHash });
129
+ } catch (error) {
130
+ return report({
131
+ action: "skipped-write-failed",
132
+ projectRoot,
133
+ error: stringifyError(error),
134
+ });
135
+ }
136
+ }
137
+
138
+ async function hasProjectConfig(projectRoot: string): Promise<boolean> {
139
+ for (const fileName of CONFIG_FILES) {
140
+ if (await Bun.file(path.join(projectRoot, fileName)).exists()) {
141
+ return true;
142
+ }
143
+ }
144
+ return false;
145
+ }
146
+
147
+ function stringifyError(error: unknown): string {
148
+ return error instanceof Error ? error.message : String(error);
149
+ }
150
+
151
+ if (import.meta.main) {
152
+ await refreshGuardLockAfterInstall();
153
+ }
@@ -0,0 +1,237 @@
1
+ import { afterEach, beforeEach, describe, expect, it } from "bun:test";
2
+ import fs from "fs/promises";
3
+ import os from "os";
4
+ import path from "path";
5
+ import {
6
+ agentManifestPath,
7
+ buildAgentContext,
8
+ readAgentManifest,
9
+ writeAgentManifest,
10
+ } from "../context";
11
+ import {
12
+ agentPlanPath,
13
+ buildAgentApplyReport,
14
+ buildAgentPlan,
15
+ writeAgentApplyReport,
16
+ writeAgentPlan,
17
+ } from "../plan";
18
+ import {
19
+ agentRepairReportPath,
20
+ buildAgentRepairReport,
21
+ writeAgentRepairReport,
22
+ } from "../repair";
23
+ import { buildAgentSyncReport } from "../sync";
24
+ import {
25
+ agentVerifyReportPath,
26
+ buildAgentVerifyReport,
27
+ writeAgentVerifyReport,
28
+ } from "../verify";
29
+
30
+ async function writeFile(root: string, rel: string, content: string): Promise<void> {
31
+ const abs = path.join(root, rel);
32
+ await fs.mkdir(path.dirname(abs), { recursive: true });
33
+ await fs.writeFile(abs, content, "utf8");
34
+ }
35
+
36
+ describe("agent context", () => {
37
+ let root: string;
38
+
39
+ beforeEach(async () => {
40
+ root = await fs.mkdtemp(path.join(os.tmpdir(), "mandu-agent-context-"));
41
+ await writeFile(root, "package.json", JSON.stringify({
42
+ name: "agent-app",
43
+ version: "1.2.3",
44
+ packageManager: "bun@1.3.14",
45
+ }));
46
+ await writeFile(root, ".mandu/routes.manifest.json", JSON.stringify({
47
+ version: 1,
48
+ routes: [
49
+ {
50
+ id: "home",
51
+ pattern: "/",
52
+ kind: "page",
53
+ module: "app/page.tsx",
54
+ componentModule: "app/page.tsx",
55
+ layoutChain: [],
56
+ },
57
+ {
58
+ id: "api-ping",
59
+ pattern: "/api/ping",
60
+ kind: "api",
61
+ module: "app/api/ping/route.ts",
62
+ methods: ["GET"],
63
+ contractModule: "spec/contracts/ping.contract.ts",
64
+ },
65
+ ],
66
+ }));
67
+ await writeFile(root, "app/dashboard/counter.partial.tsx", "export default function Counter() { return null; }");
68
+ await writeFile(root, "app/dashboard/chart.island.tsx", "export default function Chart() { return null; }");
69
+ await writeFile(root, "spec/slots/dashboard.slot.ts", "export default {};");
70
+ await writeFile(root, "spec/contracts/ping.contract.ts", "export default {};");
71
+ await writeFile(root, ".env.example", "DATABASE_URL=\n");
72
+ });
73
+
74
+ afterEach(async () => {
75
+ await fs.rm(root, { recursive: true, force: true });
76
+ });
77
+
78
+ it("builds a single agent-facing project map", async () => {
79
+ const context = await buildAgentContext(root, {
80
+ includeDiagnose: false,
81
+ includeGit: false,
82
+ });
83
+
84
+ expect(context.framework).toBe("mandu");
85
+ expect(context.project.name).toBe("agent-app");
86
+ expect(context.project.packageManager).toBe("bun@1.3.14");
87
+ expect(context.routeSource).toBe("manifest");
88
+ expect(context.routes).toHaveLength(2);
89
+ expect(context.pages.map((r) => r.id)).toEqual(["home"]);
90
+ expect(context.apis.map((r) => r.id)).toEqual(["api-ping"]);
91
+ expect(context.apis[0]?.hasContractModule).toBe(true);
92
+ expect(context.partials.map((p) => p.path)).toEqual(["app/dashboard/counter.partial.tsx"]);
93
+ expect(context.islands.map((p) => p.path)).toEqual(["app/dashboard/chart.island.tsx"]);
94
+ expect(context.slots.map((p) => p.path)).toEqual(["spec/slots/dashboard.slot.ts"]);
95
+ expect(context.contracts.map((p) => p.path)).toEqual(["spec/contracts/ping.contract.ts"]);
96
+ expect(context.env[0]).toMatchObject({ path: ".env.example", kind: "template", redacted: true });
97
+ expect(context.commands.context).toBe("mandu agent context --json");
98
+ expect(context.agentWorkflow.canonical).toEqual(["context", "plan", "apply", "verify", "repair"]);
99
+ });
100
+
101
+ it("writes and reads .mandu/agent-manifest.json", async () => {
102
+ const context = await buildAgentContext(root, {
103
+ includeDiagnose: false,
104
+ includeGit: false,
105
+ });
106
+ const result = await writeAgentManifest(root, context);
107
+
108
+ expect(result.path).toBe(agentManifestPath(root));
109
+ const manifest = await readAgentManifest(root);
110
+ expect(manifest?.schemaVersion).toBe(1);
111
+ expect(manifest?.project.name).toBe("agent-app");
112
+ expect(manifest?.agentWorkflow.canonical).toEqual(["context", "plan", "apply", "verify", "repair"]);
113
+ });
114
+
115
+ it("builds a deterministic agent plan from intent", () => {
116
+ const plan = buildAgentPlan({
117
+ intent: "add dashboard page with API contract and partial island",
118
+ });
119
+
120
+ expect(plan.framework).toBe("mandu");
121
+ expect(plan.intent).toBe("add dashboard page with API contract and partial island");
122
+ expect(plan.domains).toContain("route");
123
+ expect(plan.domains).toContain("api");
124
+ expect(plan.domains).toContain("contract");
125
+ expect(plan.domains).toContain("hydration");
126
+ expect(plan.filesToCreate).toContain("app/dashboard/page.tsx");
127
+ expect(plan.filesToCreate).toContain("app/api/<name>/route.ts");
128
+ expect(plan.mcpTools).toContain("mandu.agent.verify");
129
+ expect(plan.mcpTools).toContain("mandu.contract.validate");
130
+ expect(plan.verification.map((cmd) => cmd.command)).toContain("bun run typecheck");
131
+ });
132
+
133
+ it("writes a plan and previews apply actions without mutating files", async () => {
134
+ const plan = buildAgentPlan({
135
+ intent: "add dashboard page with API contract and partial island",
136
+ });
137
+ const written = await writeAgentPlan(root, plan);
138
+ expect(written.path).toBe(agentPlanPath(root));
139
+
140
+ const report = await buildAgentApplyReport(root);
141
+ expect(report.ok).toBe(true);
142
+ expect(report.dryRun).toBe(true);
143
+ expect(report.intent).toBe(plan.intent);
144
+ expect(report.actions.some((action) => action.kind === "mcp_tool")).toBe(true);
145
+ expect(report.actions.some((action) => action.kind === "manual_edit")).toBe(true);
146
+ expect(report.actions.every((action) => action.applied === false)).toBe(true);
147
+
148
+ const result = await writeAgentApplyReport(root, report);
149
+ expect(result.path).toBe(path.join(root, ".mandu", "agent-apply.json"));
150
+ expect(JSON.parse(await fs.readFile(result.path, "utf8")).intent).toBe(plan.intent);
151
+ });
152
+
153
+ it("syncs agent workflow artifacts for Codex, Claude, and Gemini", async () => {
154
+ const report = await buildAgentSyncReport(root, { target: "all" });
155
+
156
+ expect(report.ok).toBe(true);
157
+ expect(report.profile).toBe("agent-core");
158
+ expect(report.workflow).toEqual(["context", "plan", "apply", "verify", "repair"]);
159
+ expect(report.files.map((file) => file.path)).toContain(".mandu/agent-sync/codex/AGENTS.md");
160
+ expect(report.files.map((file) => file.path)).toContain(".mandu/agent-sync/claude/CLAUDE.md");
161
+ expect(report.files.map((file) => file.path)).toContain(".mandu/agent-sync/gemini/GEMINI.md");
162
+ const codex = await fs.readFile(path.join(root, ".mandu", "agent-sync", "codex", "AGENTS.md"), "utf8");
163
+ expect(codex).toContain("context -> plan -> apply -> verify -> repair");
164
+ expect(codex).toContain("MANDU_MCP_PROFILE=agent-core");
165
+ });
166
+
167
+ it("builds and writes the agent verify report", async () => {
168
+ const report = await buildAgentVerifyReport(root, {
169
+ includeDiagnose: false,
170
+ includeGit: false,
171
+ includeGuard: false,
172
+ includeContract: false,
173
+ });
174
+
175
+ expect(report.framework).toBe("mandu");
176
+ expect(report.ok).toBe(true);
177
+ expect(report.checks.map((check) => check.id)).toEqual(["manifest"]);
178
+ expect(report.suggestedCommands.map((cmd) => cmd.command)).toContain("bun run typecheck");
179
+ expect(report.nextRepairInput).toBe(".mandu/agent-verify.json");
180
+
181
+ const result = await writeAgentVerifyReport(root, report);
182
+ expect(result.path).toBe(agentVerifyReportPath(root));
183
+ const parsed = JSON.parse(await fs.readFile(result.path, "utf8"));
184
+ expect(parsed.project.name).toBe("agent-app");
185
+ });
186
+
187
+ it("turns a verify report into repair actions", async () => {
188
+ await writeAgentVerifyReport(root, {
189
+ schemaVersion: 1,
190
+ framework: "mandu",
191
+ generatedAt: new Date().toISOString(),
192
+ project: {
193
+ name: "agent-app",
194
+ version: "1.2.3",
195
+ root,
196
+ packageManager: "bun",
197
+ configFile: null,
198
+ },
199
+ changedFiles: [],
200
+ gitAvailable: false,
201
+ notes: [],
202
+ ok: false,
203
+ checks: [],
204
+ diagnostics: [
205
+ {
206
+ code: "MANDU_VERIFY_MANIFEST_UNAVAILABLE",
207
+ severity: "warning",
208
+ title: "Routes manifest unavailable",
209
+ cause: "missing",
210
+ suggestedFix: {
211
+ type: "run_command",
212
+ command: "mandu build",
213
+ description: "Build the project.",
214
+ },
215
+ repairable: true,
216
+ source: "test",
217
+ },
218
+ ],
219
+ suggestedCommands: [],
220
+ nextRepairInput: ".mandu/agent-verify.json",
221
+ });
222
+
223
+ const report = await buildAgentRepairReport(root);
224
+ expect(report.status).toBe("ready");
225
+ expect(report.actions).toHaveLength(1);
226
+ expect(report.actions[0]).toMatchObject({
227
+ diagnosticCode: "MANDU_VERIFY_MANIFEST_UNAVAILABLE",
228
+ kind: "run_command",
229
+ command: "mandu build",
230
+ safeToApply: false,
231
+ });
232
+
233
+ const result = await writeAgentRepairReport(root, report);
234
+ expect(result.path).toBe(agentRepairReportPath(root));
235
+ expect(JSON.parse(await fs.readFile(result.path, "utf8")).status).toBe("ready");
236
+ });
237
+ });