create-agent-rig 0.4.0 → 0.6.0

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 (118) hide show
  1. package/CHANGELOG.md +316 -11
  2. package/README.md +58 -19
  3. package/package.json +2 -1
  4. package/packages/cli/dist/commands/create.js +8 -3
  5. package/packages/cli/dist/commands/init.js +85 -34
  6. package/packages/cli/dist/commands/upgrade.js +112 -30
  7. package/packages/cli/dist/index.js +81 -19
  8. package/packages/cli/dist/lib/copy-tree.js +35 -6
  9. package/packages/cli/dist/lib/init-settings.js +12 -0
  10. package/packages/cli/dist/lib/install-set.js +6 -8
  11. package/packages/cli/dist/lib/manifest.js +31 -9
  12. package/packages/cli/dist/lib/safe-path.js +30 -0
  13. package/scripts/prepare.mjs +1 -1
  14. package/templates/agent-os/init/AGENTS.md +199 -0
  15. package/templates/agent-os/init/CLAUDE.md +69 -9
  16. package/templates/agent-os/stack/aws-cdk/.agents/skills/post-deploy-verify/SKILL.md +105 -0
  17. package/templates/agent-os/stack/aws-cdk/.agents/skills/ro-debug/SKILL.md +117 -0
  18. package/templates/agent-os/stack/aws-cdk/.claude/agents/cdk-diff-reviewer.md +41 -3
  19. package/templates/agent-os/stack/aws-cdk/.claude/rules/aws-cdk.md +1 -1
  20. package/templates/agent-os/stack/aws-cdk/.claude/skills/post-deploy-verify/SKILL.md +41 -0
  21. package/templates/agent-os/stack/aws-cdk/.codex/agents/cdk-diff-reviewer.toml +4 -0
  22. package/templates/agent-os/stack/node-ts/.claude/rules/node-ts.md +29 -0
  23. package/templates/agent-os/universal/.agents/skills/check-premises/SKILL.md +236 -0
  24. package/templates/agent-os/universal/.agents/skills/loop/SKILL.md +958 -0
  25. package/templates/agent-os/universal/.agents/skills/new-invariant/SKILL.md +102 -0
  26. package/templates/agent-os/universal/.agents/skills/new-invariant/guard-invariant.example.mjs +78 -0
  27. package/templates/agent-os/universal/.agents/skills/new-invariant/guard-invariant.example.test.mjs +89 -0
  28. package/templates/agent-os/universal/.agents/skills/pr-ship/SKILL.md +396 -0
  29. package/templates/agent-os/universal/.agents/skills/worktree-task/SKILL.md +73 -0
  30. package/templates/agent-os/universal/.claude/agents/code-reviewer.md +40 -1
  31. package/templates/agent-os/universal/.claude/agents/prose-reviewer.md +71 -1
  32. package/templates/agent-os/universal/.claude/agents/security-scanner.md +40 -0
  33. package/templates/agent-os/universal/.claude/hooks/gate-stop-dod.mjs +301 -26
  34. package/templates/agent-os/universal/.claude/hooks/guard-bash.mjs +43 -5
  35. package/templates/agent-os/universal/.claude/hooks/guard-core-purity.mjs +25 -11
  36. package/templates/agent-os/universal/.claude/hooks/guard-rulebook.mjs +127 -0
  37. package/templates/agent-os/universal/.claude/hooks/guard-secret-file.mjs +180 -0
  38. package/templates/agent-os/universal/.claude/hooks/guard-web-boundary.mjs +32 -13
  39. package/templates/agent-os/universal/.claude/hooks/inject-rules.mjs +175 -7
  40. package/templates/agent-os/universal/.claude/hooks/lib/edit-input.mjs +503 -0
  41. package/templates/agent-os/universal/.claude/rules/autonomy.md +69 -1
  42. package/templates/agent-os/universal/.claude/rules/invariants.md +104 -19
  43. package/templates/agent-os/universal/.claude/rules/workflow.md +55 -5
  44. package/templates/agent-os/universal/.claude/scripts/decision-router.mjs +1260 -0
  45. package/templates/agent-os/universal/.claude/scripts/detect-missed-gate.mjs +65 -2
  46. package/templates/agent-os/universal/.claude/scripts/doctor.mjs +351 -0
  47. package/templates/agent-os/universal/.claude/scripts/git-env.mjs +49 -0
  48. package/templates/agent-os/universal/.claude/scripts/lib/gate-coverage.mjs +306 -0
  49. package/templates/agent-os/universal/.claude/scripts/lib/revalidation-points.mjs +28 -0
  50. package/templates/agent-os/universal/.claude/scripts/lib/secrets.mjs +490 -0
  51. package/templates/agent-os/universal/.claude/scripts/lib/verdict.mjs +462 -0
  52. package/templates/agent-os/universal/.claude/scripts/preflight.mjs +33 -17
  53. package/templates/agent-os/universal/.claude/scripts/queue/as-of.mjs +51 -0
  54. package/templates/agent-os/universal/.claude/scripts/queue/checkout.mjs +149 -0
  55. package/templates/agent-os/universal/.claude/scripts/queue/core.mjs +809 -28
  56. package/templates/agent-os/universal/.claude/scripts/queue/gate-rounds.mjs +160 -0
  57. package/templates/agent-os/universal/.claude/scripts/queue/github-issues.mjs +95 -16
  58. package/templates/agent-os/universal/.claude/scripts/queue/index.mjs +543 -15
  59. package/templates/agent-os/universal/.claude/scripts/queue/jira.mjs +457 -47
  60. package/templates/agent-os/universal/.claude/scripts/queue/plan-md.mjs +289 -25
  61. package/templates/agent-os/universal/.claude/scripts/queue/state.mjs +197 -0
  62. package/templates/agent-os/universal/.claude/scripts/revalidate.mjs +316 -0
  63. package/templates/agent-os/universal/.claude/scripts/revalidation-report.mjs +180 -0
  64. package/templates/agent-os/universal/.claude/scripts/run-journal.mjs +435 -0
  65. package/templates/agent-os/universal/.claude/scripts/run-state.mjs +539 -0
  66. package/templates/agent-os/universal/.claude/scripts/stop-flag.mjs +15 -8
  67. package/templates/agent-os/universal/.claude/scripts/unattended-flag.mjs +239 -0
  68. package/templates/agent-os/universal/.claude/scripts/verdict.mjs +198 -0
  69. package/templates/agent-os/universal/.claude/settings.json +11 -2
  70. package/templates/agent-os/universal/.claude/skills/check-premises/SKILL.md +118 -7
  71. package/templates/agent-os/universal/.claude/skills/loop/SKILL.md +667 -38
  72. package/templates/agent-os/universal/.claude/skills/pr-ship/SKILL.md +359 -26
  73. package/templates/agent-os/universal/.codex/agents/code-reviewer.toml +4 -0
  74. package/templates/agent-os/universal/.codex/agents/prose-reviewer.toml +4 -0
  75. package/templates/agent-os/universal/.codex/agents/security-scanner.toml +4 -0
  76. package/templates/agent-os/universal/.codex/agents/test-writer.toml +4 -0
  77. package/templates/agent-os/universal/.codex/hooks.json +70 -0
  78. package/templates/agent-os/universal/AGENTS.md +166 -0
  79. package/templates/agent-os/universal/CLAUDE.md +49 -14
  80. package/templates/agent-os/universal/PLAN.md +7 -40
  81. package/templates/agent-os/universal/docs/decisions/closing-a-task.md +98 -0
  82. package/templates/agent-os/universal/docs/decisions/codex-adapter.md +108 -0
  83. package/templates/agent-os/universal/docs/decisions/fail-open-guards.md +43 -0
  84. package/templates/agent-os/universal/docs/decisions/gate-coverage.md +83 -0
  85. package/templates/agent-os/universal/docs/decisions/review-lanes.md +72 -0
  86. package/templates/agent-os/universal/docs/decisions/run-directory.md +44 -0
  87. package/templates/agent-os/universal/docs/decisions/spacing-rations-mechanisms.md +125 -0
  88. package/templates/agent-os/universal/docs/decisions/stop-conditions-in-a-file.md +46 -0
  89. package/templates/agent-os/universal/docs/decisions/two-empty-endings.md +74 -0
  90. package/templates/agent-os/universal/journal/README.md +101 -0
  91. package/templates/agent-os/universal/layers.json +45 -2
  92. package/templates/hash-history.json +310 -49
  93. package/templates/release-ledger.json +9 -0
  94. package/templates/skeleton/aws-serverless/.github/workflows/deploy.yml +34 -1
  95. package/templates/skeleton/aws-serverless/README.md +91 -9
  96. package/templates/skeleton/aws-serverless/apps/web/src/lib/api.ts +9 -2
  97. package/templates/skeleton/aws-serverless/gitignore +37 -0
  98. package/templates/skeleton/aws-serverless/infra/bin/app.ts +73 -13
  99. package/templates/skeleton/aws-serverless/infra/lib/app-stack.ts +136 -4
  100. package/templates/skeleton/aws-serverless/infra/lib/web-stack.ts +14 -1
  101. package/templates/skeleton/aws-serverless/infra/test/allowed-origins.test.ts +301 -0
  102. package/templates/skeleton/aws-serverless/infra/test/app-composition.test.ts +137 -0
  103. package/templates/skeleton/aws-serverless/infra/test/app-stack.test.ts +36 -20
  104. package/templates/skeleton/aws-serverless/infra/test/web-stack.test.ts +15 -12
  105. package/templates/skeleton/aws-serverless/services/api/src/handlers/create-note.ts +33 -6
  106. package/templates/skeleton/aws-serverless/services/api/src/handlers/list-notes.ts +18 -5
  107. package/templates/skeleton/aws-serverless/services/api/src/usecases/create-note.ts +11 -0
  108. package/templates/skeleton/aws-serverless/services/api/test/create-note.handler.test.ts +120 -1
  109. package/templates/skeleton/aws-serverless/services/api/test/list-notes.test.ts +72 -1
  110. package/templates/skeleton/node-service/README.md +11 -1
  111. package/templates/skeleton/node-service/gitignore +34 -0
  112. package/templates/skeleton/node-service/packages/db/src/note-store.ts +47 -10
  113. package/templates/skeleton/node-service/packages/db/test/note-store.test.ts +20 -0
  114. package/templates/skeleton/node-service/services/api/src/main.ts +2 -9
  115. package/templates/skeleton/node-service/services/api/src/server.ts +93 -10
  116. package/templates/skeleton/node-service/services/api/src/static-dir.ts +20 -0
  117. package/templates/skeleton/node-service/services/api/test/server.test.ts +98 -13
  118. package/templates/skeleton/node-service/services/api/test/static-dir.test.ts +28 -0
@@ -2,7 +2,7 @@
2
2
  // configured) serving the built web bundle. No second runtime for the
3
3
  // frontend — the same process serves the static export.
4
4
  import { createReadStream } from 'node:fs';
5
- import { stat } from 'node:fs/promises';
5
+ import { realpath, stat } from 'node:fs/promises';
6
6
  import { createServer, type Server } from 'node:http';
7
7
  import path from 'node:path';
8
8
  import type { CreateNoteDeps } from './usecases/create-note.js';
@@ -18,6 +18,14 @@ export interface ServerOptions {
18
18
  staticDir?: string;
19
19
  }
20
20
 
21
+ /**
22
+ * Largest request body `POST /notes` will buffer — that route and no other:
23
+ * the cap is applied where the body is read, so a second route that reads one
24
+ * brings its own. A note is a title and some tags; anything past this is a
25
+ * mistake or an attack, and either way the process must not hold it in memory.
26
+ */
27
+ const MAX_BODY_BYTES = 1024 * 1024;
28
+
21
29
  const CONTENT_TYPES: Record<string, string> = {
22
30
  '.html': 'text/html; charset=utf-8',
23
31
  '.css': 'text/css',
@@ -39,11 +47,34 @@ export function makeServer(deps: ServerDeps, options: ServerOptions = {}): Serve
39
47
  response.end(result.body);
40
48
  };
41
49
  const notFound = () => respond({ statusCode: 404, body: JSON.stringify({ error: 'not found' }) });
50
+ const badRequest = () =>
51
+ respond({ statusCode: 400, body: JSON.stringify({ error: 'bad request' }) });
42
52
 
43
53
  if (request.method === 'POST' && request.url === '/notes') {
44
54
  const chunks: Buffer[] = [];
45
- request.on('data', (chunk: Buffer) => chunks.push(chunk));
55
+ let size = 0;
56
+ let refused = false;
57
+ // A socket that dies mid-upload emits 'error'; unhandled, it takes the
58
+ // process with it.
59
+ request.on('error', () => {
60
+ refused = true;
61
+ });
62
+ request.on('data', (chunk: Buffer) => {
63
+ if (refused) return;
64
+ size += chunk.length;
65
+ if (size > MAX_BODY_BYTES) {
66
+ refused = true;
67
+ chunks.length = 0;
68
+ respond({ statusCode: 413, body: JSON.stringify({ error: 'payload too large' }) });
69
+ // Keep draining rather than destroying the socket: a client that is
70
+ // still uploading has to stay connected to read the refusal.
71
+ request.resume();
72
+ return;
73
+ }
74
+ chunks.push(chunk);
75
+ });
46
76
  request.on('end', () => {
77
+ if (refused) return;
47
78
  void createNote(Buffer.concat(chunks).toString('utf8')).then(respond);
48
79
  });
49
80
  return;
@@ -55,7 +86,21 @@ export function makeServer(deps: ServerDeps, options: ServerOptions = {}): Serve
55
86
  }
56
87
 
57
88
  if (request.method === 'GET' && options.staticDir) {
58
- void serveStatic(options.staticDir, request.url ?? '/', response, notFound);
89
+ // The rejection handler is the point: without it a throw in here is an
90
+ // unhandled rejection, which ends the process on one malformed request.
91
+ void serveStatic(options.staticDir, request.url ?? '/', response, notFound, badRequest).catch(
92
+ (error: unknown) => {
93
+ // Say nothing to the client beyond the code, but never swallow it —
94
+ // a static path that fails silently is a blind spot in every
95
+ // generated project.
96
+ deps.log.error('static serve failed', { url: request.url, error: String(error) });
97
+ if (response.headersSent) {
98
+ response.destroy();
99
+ return;
100
+ }
101
+ respond({ statusCode: 500, body: JSON.stringify({ error: 'internal error' }) });
102
+ },
103
+ );
59
104
  return;
60
105
  }
61
106
 
@@ -68,22 +113,60 @@ async function serveStatic(
68
113
  rawUrl: string,
69
114
  response: import('node:http').ServerResponse,
70
115
  notFound: () => void,
116
+ badRequest: () => void,
71
117
  ): Promise<void> {
72
- const pathname = decodeURIComponent(new URL(rawUrl, 'http://local').pathname);
118
+ let pathname: string;
119
+ try {
120
+ pathname = decodeURIComponent(new URL(rawUrl, 'http://local').pathname);
121
+ } catch {
122
+ // A malformed escape (`GET /%`) is the client's error, not ours.
123
+ return badRequest();
124
+ }
73
125
  const relative = pathname.endsWith('/') ? `${pathname}index.html` : pathname;
74
- const resolved = path.normalize(path.join(staticDir, relative));
126
+ // Resolve the root first: a configured dir with a trailing separator would
127
+ // otherwise build a `//` prefix that no resolved path can start with, and
128
+ // every request would 404.
129
+ const root = path.resolve(staticDir);
130
+ const resolved = path.resolve(root, `.${path.sep}${relative}`);
75
131
  // Path-traversal guard: whatever the URL said, we never leave staticDir.
76
- if (!resolved.startsWith(path.normalize(staticDir) + path.sep)) {
132
+ // The string test is only half of it — it says where the path points, not
133
+ // what the filesystem will open, and stat/open both follow symlinks.
134
+ if (!within(root, resolved)) {
77
135
  return notFound();
78
136
  }
137
+ let file: string;
79
138
  try {
80
- const stats = await stat(resolved);
139
+ // …so ask the filesystem too, and test the answer the same way. A missing
140
+ // file throws here, which is the 404 we would give it anyway.
141
+ file = await realpath(resolved);
142
+ if (!within(await realpath(root), file)) return notFound();
143
+ const stats = await stat(file);
81
144
  if (!stats.isFile()) return notFound();
82
145
  } catch {
83
146
  return notFound();
84
147
  }
85
- response.writeHead(200, {
86
- 'content-type': CONTENT_TYPES[path.extname(resolved)] ?? 'application/octet-stream',
148
+ // Open before the headers go out: a file that passes stat can still fail to
149
+ // open (EACCES, or it is gone by now), and after writeHead(200) there is no
150
+ // way left to say so — which is one GET away from an unhandled stream error.
151
+ const stream = createReadStream(file);
152
+ await new Promise<void>((resolve, reject) => {
153
+ stream.once('error', reject);
154
+ stream.once('open', () => {
155
+ stream.removeListener('error', reject);
156
+ response.writeHead(200, {
157
+ 'content-type': CONTENT_TYPES[path.extname(file)] ?? 'application/octet-stream',
158
+ });
159
+ // Past the headers the only honest answer to a read failure is to cut
160
+ // the response short, so the client sees a truncated body rather than a
161
+ // complete one.
162
+ stream.once('error', () => response.destroy());
163
+ stream.pipe(response);
164
+ resolve();
165
+ });
87
166
  });
88
- createReadStream(resolved).pipe(response);
167
+ }
168
+
169
+ /** Whether `candidate` is `root` itself or sits underneath it. */
170
+ function within(root: string, candidate: string): boolean {
171
+ return candidate === root || candidate.startsWith(root + path.sep);
89
172
  }
@@ -0,0 +1,20 @@
1
+ // Where the built web bundle lives, resolved relative to the composition root.
2
+ // A pure function rather than an inline expression in main.ts: main.ts is wiring
3
+ // that tests never import, and this resolution has a bug worth pinning —
4
+ // `new URL(url).pathname` leaves percent-escapes in place (a checkout under
5
+ // "/Users/a b/" resolves to "/Users/a%20b/") and prefixes a slash on Windows.
6
+ import path from 'node:path';
7
+ import { fileURLToPath } from 'node:url';
8
+
9
+ /** `apps/web/out`, three levels up from a module in `services/api/src`. */
10
+ export function defaultStaticDirFor(moduleUrl: string): string {
11
+ return path.resolve(
12
+ path.dirname(fileURLToPath(moduleUrl)),
13
+ '..',
14
+ '..',
15
+ '..',
16
+ 'apps',
17
+ 'web',
18
+ 'out',
19
+ );
20
+ }
@@ -1,6 +1,6 @@
1
1
  // Integration: the real HTTP server over a real socket, with real file
2
2
  // storage and a real spool directory — the whole request path at once.
3
- import { mkdir, mkdtemp, readdir, rm, writeFile } from 'node:fs/promises';
3
+ import { chmod, mkdir, mkdtemp, readdir, rm, symlink, writeFile } from 'node:fs/promises';
4
4
  import { tmpdir } from 'node:os';
5
5
  import type { AddressInfo } from 'node:net';
6
6
  import path from 'node:path';
@@ -75,8 +75,43 @@ describe('the service end to end', () => {
75
75
  const { notes } = (await response.json()) as { notes: Array<{ title: string }> };
76
76
  expect(notes).toHaveLength(2);
77
77
  });
78
+
79
+ it('refuses a request body larger than the cap with 413', async () => {
80
+ // Well-formed JSON on purpose: what is rejected is the size, not the shape.
81
+ // The server must answer before it has buffered the whole upload, and must
82
+ // stay readable long enough for the client to see that answer.
83
+ const body = JSON.stringify({ title: 'x'.repeat(1024 * 1024 + 1024) });
84
+ const response = await fetch(`${baseUrl}/notes`, {
85
+ method: 'POST',
86
+ headers: { 'content-type': 'application/json' },
87
+ body,
88
+ });
89
+ expect(response.status).toBe(413);
90
+ });
78
91
  });
79
92
 
93
+ /** A listening server serving `staticDir`, plus its base URL. */
94
+ async function startStaticServer(staticDir: string): Promise<{ server: Server; base: string }> {
95
+ const server = makeServer(
96
+ {
97
+ notes: new JsonFileNoteStore(path.join(dir, 'data', 'notes.json')),
98
+ events: new SpoolEventPublisher(path.join(dir, 'queue'), () => 'm'),
99
+ newId: () => 'id',
100
+ now: () => '2024-01-01T00:00:00.000Z',
101
+ log: createLogger({}, () => {}),
102
+ },
103
+ { staticDir },
104
+ );
105
+ await new Promise<void>((resolve) => server.listen(0, resolve));
106
+ return { server, base: `http://127.0.0.1:${(server.address() as AddressInfo).port}` };
107
+ }
108
+
109
+ // Windows has no POSIX mode bits, and root reads a 0o000 file regardless — in
110
+ // both cases the "unreadable file" this test needs cannot be built at all.
111
+ const cannotMakeFileUnreadable = process.platform === 'win32' || process.getuid?.() === 0;
112
+ // Creating a symlink on Windows needs a privilege the test runner may not hold.
113
+ const cannotSymlink = process.platform === 'win32';
114
+
80
115
  describe('static serving (the built web bundle)', () => {
81
116
  let staticServer: Server;
82
117
  let staticBase: string;
@@ -84,18 +119,9 @@ describe('static serving (the built web bundle)', () => {
84
119
  beforeEach(async () => {
85
120
  await mkdir(path.join(dir, 'static'), { recursive: true });
86
121
  await writeFile(path.join(dir, 'static', 'index.html'), '<h1>web shell</h1>');
87
- staticServer = makeServer(
88
- {
89
- notes: new JsonFileNoteStore(path.join(dir, 'data', 'notes.json')),
90
- events: new SpoolEventPublisher(path.join(dir, 'queue'), () => 'm'),
91
- newId: () => 'id',
92
- now: () => '2024-01-01T00:00:00.000Z',
93
- log: createLogger({}, () => {}),
94
- },
95
- { staticDir: path.join(dir, 'static') },
96
- );
97
- await new Promise<void>((resolve) => staticServer.listen(0, resolve));
98
- staticBase = `http://127.0.0.1:${(staticServer.address() as AddressInfo).port}`;
122
+ ({ server: staticServer, base: staticBase } = await startStaticServer(
123
+ path.join(dir, 'static'),
124
+ ));
99
125
  });
100
126
 
101
127
  afterEach(async () => {
@@ -120,4 +146,63 @@ describe('static serving (the built web bundle)', () => {
120
146
  expect(response.status).toBe(200);
121
147
  expect(response.headers.get('content-type')).toContain('application/json');
122
148
  });
149
+
150
+ it('answers 400 to an undecodable URL and keeps serving afterwards', async () => {
151
+ // The signal is a leash, not a relaxation: a server that dies on this URL
152
+ // never answers at all, and the run should say so in seconds.
153
+ const response = await fetch(staticBase + '/%', { signal: AbortSignal.timeout(5000) });
154
+ expect(response.status).toBe(400);
155
+
156
+ const afterwards = await fetch(staticBase + '/');
157
+ expect(afterwards.status).toBe(200);
158
+ expect(await afterwards.text()).toContain('web shell');
159
+ });
160
+
161
+ it.skipIf(cannotMakeFileUnreadable)(
162
+ 'answers a file it cannot open and keeps serving afterwards',
163
+ async () => {
164
+ // A file that passes stat() but fails open() (EACCES here; in production
165
+ // the stat/open race gives ENOENT). Before the headers are held back
166
+ // until the stream opens, that error reached no handler at all and the
167
+ // process paid for one unreadable file.
168
+ const locked = path.join(dir, 'static', 'locked.txt');
169
+ await writeFile(locked, 'unreadable');
170
+ await chmod(locked, 0o000);
171
+ try {
172
+ const response = await fetch(staticBase + '/locked.txt', {
173
+ signal: AbortSignal.timeout(5000),
174
+ });
175
+ expect(response.status).toBe(500);
176
+
177
+ const afterwards = await fetch(staticBase + '/');
178
+ expect(afterwards.status).toBe(200);
179
+ expect(await afterwards.text()).toContain('web shell');
180
+ } finally {
181
+ // Restore before afterEach removes the temp dir.
182
+ await chmod(locked, 0o600);
183
+ }
184
+ },
185
+ );
186
+
187
+ it.skipIf(cannotSymlink)('refuses a symlink that escapes the static dir', async () => {
188
+ // A lexical guard inspects only the resolved string, while stat and the
189
+ // read stream follow the link wherever it points — which is why the guard
190
+ // asks realpath as well.
191
+ await writeFile(path.join(dir, 'secret-target.txt'), 'nope');
192
+ await symlink(path.join(dir, 'secret-target.txt'), path.join(dir, 'static', 'escape.txt'));
193
+
194
+ const response = await fetch(staticBase + '/escape.txt');
195
+ expect(response.status).toBe(404);
196
+ });
197
+
198
+ it('serves the bundle when the configured static dir has a trailing separator', async () => {
199
+ const { server, base } = await startStaticServer(path.join(dir, 'static') + path.sep);
200
+ try {
201
+ const response = await fetch(base + '/');
202
+ expect(response.status).toBe(200);
203
+ expect(await response.text()).toContain('web shell');
204
+ } finally {
205
+ await new Promise((resolve) => server.close(resolve));
206
+ }
207
+ });
123
208
  });
@@ -0,0 +1,28 @@
1
+ // The composition root resolves the built web bundle relative to itself; the
2
+ // resolution is a pure function so it can be tested without importing main.ts.
3
+ //
4
+ // What is NOT pinned here: the Windows half of the same defect — the old
5
+ // `new URL(url).pathname` yielded `/C:/…`. Both expressions are identical on a
6
+ // POSIX runner, so observing the difference would mean passing a path flavour
7
+ // into `defaultStaticDirFor`, i.e. a parameter that exists only for the test.
8
+ // The decision was to leave that hook out; percent-decoding below is the same
9
+ // bug's POSIX-visible half, and it fails against the old expression.
10
+ import path from 'node:path';
11
+ import { describe, expect, it } from 'vitest';
12
+ import { defaultStaticDirFor } from '../src/static-dir.js';
13
+
14
+ describe('resolving the default static dir from the module url', () => {
15
+ it('decodes percent-escapes in the path (a directory may contain a space)', () => {
16
+ const resolved = defaultStaticDirFor('file:///Users/a%20b/proj/services/api/src/main.ts');
17
+
18
+ expect(resolved).toContain('a b');
19
+ expect(resolved).not.toContain('%20');
20
+ });
21
+
22
+ it('points three levels up from services/api/src at apps/web/out', () => {
23
+ const resolved = defaultStaticDirFor('file:///Users/a%20b/proj/services/api/src/main.ts');
24
+
25
+ expect(resolved.endsWith(path.join('a b', 'proj', 'apps', 'web', 'out'))).toBe(true);
26
+ expect(path.isAbsolute(resolved)).toBe(true);
27
+ });
28
+ });