@crossworks/app-build 0.232.10 → 0.232.12

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": "@crossworks/app-build",
3
- "version": "0.232.10",
3
+ "version": "0.232.12",
4
4
  "type": "module",
5
5
  "main": "./src/index.ts",
6
6
  "types": "./src/index.ts",
@@ -14,7 +14,7 @@
14
14
  "lucide-react": "^1.25.0",
15
15
  "react": "^19.2.7",
16
16
  "react-dom": "^19.2.7",
17
- "@mantle/share-ui": "npm:@crossworks/share-ui@0.232.10"
17
+ "@mantle/share-ui": "npm:@crossworks/share-ui@0.232.12"
18
18
  },
19
19
  "devDependencies": {
20
20
  "@types/node": "^22.20.1",
@@ -0,0 +1,94 @@
1
+ /**
2
+ * The manifest lookup must not depend on the working directory. app_build runs
3
+ * from BOTH server/web (Next) and server/mcp (the agent's MCP server); a bare
4
+ * cwd-relative path works in the first and resolves to nothing in the second,
5
+ * which took every agent-driven build down with ENOENT until it was fixed.
6
+ */
7
+ import { describe, expect, it, beforeEach, afterEach } from 'vitest';
8
+ import * as fs from 'node:fs';
9
+ import * as os from 'node:os';
10
+ import * as path from 'node:path';
11
+ import { loadRuntimeExports, manifestCandidates } from './runtime-exports';
12
+
13
+ const ENV = 'MANTLE_APP_RUNTIME_MANIFEST';
14
+ let saved: string | undefined;
15
+
16
+ beforeEach(() => {
17
+ saved = process.env[ENV];
18
+ });
19
+ afterEach(() => {
20
+ if (saved === undefined) delete process.env[ENV];
21
+ else process.env[ENV] = saved;
22
+ });
23
+
24
+ describe('manifestCandidates', () => {
25
+ it('includes a package-relative path that does not depend on cwd', () => {
26
+ delete process.env[ENV];
27
+ const [pkgRelative] = manifestCandidates();
28
+ expect(path.isAbsolute(pkgRelative!)).toBe(true);
29
+ expect(pkgRelative).toContain(path.join('server', 'web', 'public', 'app-runtime'));
30
+ });
31
+
32
+ it('puts an explicit override ahead of everything else', () => {
33
+ process.env[ENV] = '/tmp/somewhere/manifest.json';
34
+ expect(manifestCandidates()[0]).toBe('/tmp/somewhere/manifest.json');
35
+ });
36
+
37
+ it('still offers the cwd convention as a fallback', () => {
38
+ delete process.env[ENV];
39
+ expect(manifestCandidates()).toContain(path.resolve('public/app-runtime/manifest.json'));
40
+ });
41
+ });
42
+
43
+ describe('loadRuntimeExports', () => {
44
+ it('reads the exports map from the override path', async () => {
45
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'mantle-mf-'));
46
+ const file = path.join(dir, 'manifest.json');
47
+ fs.writeFileSync(file, JSON.stringify({ imports: {}, exports: { '@host': ['host'] } }));
48
+ process.env[ENV] = file;
49
+ try {
50
+ await expect(loadRuntimeExports()).resolves.toEqual({ '@host': ['host'] });
51
+ } finally {
52
+ fs.rmSync(dir, { recursive: true, force: true });
53
+ }
54
+ });
55
+
56
+ it('throws and names every path it tried when nothing is found', async () => {
57
+ // Candidates are injected: the real list always resolves inside this repo,
58
+ // so the not-found path cannot be reached through the environment alone.
59
+ const missing = [
60
+ path.join(os.tmpdir(), 'nope-a', 'manifest.json'),
61
+ path.join(os.tmpdir(), 'nope-b', 'manifest.json'),
62
+ ];
63
+ await expect(loadRuntimeExports(missing)).rejects.toThrow(/manifest not found[\s\S]*Tried:/);
64
+ for (const m of missing) {
65
+ await expect(loadRuntimeExports(missing)).rejects.toThrow(m);
66
+ }
67
+ });
68
+
69
+ it('does not fall through to another candidate when one is present but broken', async () => {
70
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'mantle-mf-'));
71
+ const broken = path.join(dir, 'manifest.json');
72
+ fs.writeFileSync(broken, JSON.stringify({ imports: {} }));
73
+ const good = path.join(dir, 'good.json');
74
+ fs.writeFileSync(good, JSON.stringify({ exports: { '@host': ['host'] } }));
75
+ try {
76
+ // A stale copy further down the list must never win over a broken one.
77
+ await expect(loadRuntimeExports([broken, good])).rejects.toThrow(/no 'exports' map/);
78
+ } finally {
79
+ fs.rmSync(dir, { recursive: true, force: true });
80
+ }
81
+ });
82
+
83
+ it('refuses a manifest that exists but carries no exports map', async () => {
84
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'mantle-mf-'));
85
+ const file = path.join(dir, 'manifest.json');
86
+ fs.writeFileSync(file, JSON.stringify({ imports: { react: '/x.js' } }));
87
+ process.env[ENV] = file;
88
+ try {
89
+ await expect(loadRuntimeExports()).rejects.toThrow(/no 'exports' map/);
90
+ } finally {
91
+ fs.rmSync(dir, { recursive: true, force: true });
92
+ }
93
+ });
94
+ });
@@ -7,38 +7,81 @@
7
7
  * names here) keeps one source of truth: the check can only ever describe the
8
8
  * runtime that is actually deployed alongside it.
9
9
  *
10
- * Path convention matches server/web/lib/app-frame.ts, which reads the same
11
- * file for the import map: relative to cwd, which is server/web in dev and in
12
- * the image. Cached for the process — the file only changes on deploy.
10
+ * Resolution deliberately does NOT trust the working directory. The manifest is
11
+ * generated into server/web/public, and server/web's cwd is what
12
+ * `server/web/lib/app-frame.ts` relies on — but app_build also runs from the
13
+ * MCP server, whose cwd is server/mcp, and a bare relative path there resolves
14
+ * to nothing. That cost a live build outage: every agent-driven app_build
15
+ * failed with ENOENT until the lookup stopped assuming one caller. So the
16
+ * package locates the manifest relative to ITSELF first (the repo and the image
17
+ * share the same layout), falls back to the cwd convention for anything that
18
+ * relocates it, and takes an explicit env override ahead of both.
13
19
  *
14
- * A missing or malformed manifest THROWS. It must never degrade to "no
15
- * exports known", because an empty map would flag every import as invalid, and
16
- * an optional map would silently stop guarding.
20
+ * A missing or malformed manifest THROWS, naming every path it tried. It must
21
+ * never degrade to "no exports known": an empty map would flag every import as
22
+ * invalid, and an optional one would silently stop guarding.
17
23
  */
18
24
  import { readFile } from 'node:fs/promises';
25
+ import * as path from 'node:path';
26
+ import { fileURLToPath } from 'node:url';
19
27
 
20
- const MANIFEST_PATH = 'public/app-runtime/manifest.json';
28
+ const HERE = path.dirname(fileURLToPath(import.meta.url));
21
29
 
22
- let cached: Promise<Record<string, string[]>> | null = null;
30
+ /** In order of trust: explicit override, package-relative, cwd convention. */
31
+ export function manifestCandidates(): string[] {
32
+ const override = process.env.MANTLE_APP_RUNTIME_MANIFEST;
33
+ return [
34
+ ...(override ? [override] : []),
35
+ // packages/app-build/src → <root>/server/web/public/app-runtime/manifest.json
36
+ path.resolve(HERE, '../../../server/web/public/app-runtime/manifest.json'),
37
+ path.resolve('public/app-runtime/manifest.json'),
38
+ ];
39
+ }
40
+
41
+ /** Keyed by the candidate list rather than a single flag: the paths are stable
42
+ * in production (so this is still one read per process), and a changed
43
+ * override naturally re-resolves instead of serving another location's
44
+ * answer. */
45
+ const cache = new Map<string, Promise<Record<string, string[]>>>();
23
46
 
24
47
  export function loadRuntimeExports(
25
- manifestPath: string = MANIFEST_PATH,
48
+ /** Injectable for tests only — production always uses the real candidates.
49
+ * A test cannot isolate the not-found path otherwise, because the
50
+ * package-relative candidate resolves inside the repo itself. */
51
+ candidates: string[] = manifestCandidates(),
26
52
  ): Promise<Record<string, string[]>> {
53
+ const key = candidates.join('\u0000');
54
+ let cached = cache.get(key);
27
55
  if (!cached) {
28
- cached = readFile(manifestPath, 'utf8')
29
- .then((raw) => {
56
+ cached = (async () => {
57
+ const tried: string[] = [];
58
+ for (const candidate of candidates) {
59
+ tried.push(candidate);
60
+ let raw: string;
61
+ try {
62
+ raw = await readFile(candidate, 'utf8');
63
+ } catch {
64
+ continue; // not here; try the next location
65
+ }
66
+ // Present but unusable is a hard error — never fall through to another
67
+ // candidate, or a stale copy elsewhere could silently win.
30
68
  const m = JSON.parse(raw) as { exports?: Record<string, string[]> };
31
69
  if (!m.exports || Object.keys(m.exports).length === 0) {
32
70
  throw new Error(
33
- `${manifestPath} has no 'exports' map — regenerate it with the app-runtime build`,
71
+ `${candidate} has no 'exports' map — regenerate it with the app-runtime build`,
34
72
  );
35
73
  }
36
74
  return m.exports;
37
- })
38
- .catch((e: unknown) => {
39
- cached = null; // let the next call retry rather than pinning the failure
40
- throw e;
41
- });
75
+ }
76
+ throw new Error(
77
+ `app-runtime manifest not found. Tried:\n ${tried.join('\n ')}\n` +
78
+ 'Generate it with the app-runtime build, or set MANTLE_APP_RUNTIME_MANIFEST.',
79
+ );
80
+ })().catch((e: unknown) => {
81
+ cache.delete(key); // let the next call retry rather than pinning the failure
82
+ throw e;
83
+ });
84
+ cache.set(key, cached);
42
85
  }
43
86
  return cached;
44
87
  }