@getjack/jack 0.1.7 → 0.1.9

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/src/lib/hooks.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  import { existsSync } from "node:fs";
2
2
  import { join } from "node:path";
3
3
  import type { HookAction } from "../templates/types";
4
+ import { applyJsonWrite } from "./json-edit";
4
5
  import { getSavedSecrets } from "./secrets";
5
6
  import { restoreTty } from "./tty";
6
7
 
@@ -17,6 +18,7 @@ export interface HookOutput {
17
18
  error(message: string): void;
18
19
  success(message: string): void;
19
20
  box(title: string, lines: string[]): void;
21
+ celebrate?(title: string, lines: string[]): void;
20
22
  }
21
23
 
22
24
  export interface HookOptions {
@@ -32,6 +34,20 @@ const noopOutput: HookOutput = {
32
34
  box() {},
33
35
  };
34
36
 
37
+ /**
38
+ * Hook schema quick reference (interactive behavior + fields)
39
+ *
40
+ * - message: { text } -> prints info
41
+ * - box: { title, lines } -> prints boxed text
42
+ * - url: { url, label?, open?, prompt? } -> prints link; optional open prompt
43
+ * - clipboard: { text, message? } -> copy to clipboard (prints in non-interactive)
44
+ * - pause: { message? } -> waits for enter (skipped in non-interactive)
45
+ * - require: { source, key, message?, setupUrl? } -> validates secret/env
46
+ * - shell: { command, cwd?, message? } -> runs shell command
47
+ * - prompt: { message, validate?, required?, successMessage?, writeJson? } -> input + optional JSON update
48
+ * - writeJson: { path, set, successMessage? } -> JSON update (runs in non-interactive)
49
+ */
50
+
35
51
  /**
36
52
  * Prompt user with numbered options (Claude Code style)
37
53
  * Returns the selected option index (0-based) or -1 if cancelled
@@ -121,6 +137,26 @@ function substituteVars(str: string, context: HookContext): string {
121
137
  .replace(/\{\{name\}\}/g, context.projectName ?? "");
122
138
  }
123
139
 
140
+ function resolveHookPath(filePath: string, context: HookContext): string {
141
+ if (filePath.startsWith("/")) {
142
+ return filePath;
143
+ }
144
+ if (!context.projectDir) {
145
+ return filePath;
146
+ }
147
+ return join(context.projectDir, filePath);
148
+ }
149
+
150
+ function isAccountAssociation(value: unknown): value is { header: string; payload: string; signature: string } {
151
+ if (!value || typeof value !== "object") return false;
152
+ const obj = value as { header?: unknown; payload?: unknown; signature?: unknown };
153
+ return (
154
+ typeof obj.header === "string" &&
155
+ typeof obj.payload === "string" &&
156
+ typeof obj.signature === "string"
157
+ );
158
+ }
159
+
124
160
  /**
125
161
  * Open a URL in the default browser
126
162
  */
@@ -209,153 +245,255 @@ async function checkEnvExists(env: string, projectDir?: string): Promise<boolean
209
245
  * Execute a single hook action
210
246
  * Returns true if should continue, false if should abort
211
247
  */
212
- async function executeAction(
213
- action: HookAction,
248
+ type ActionHandler<T extends HookAction["action"]> = (
249
+ action: Extract<HookAction, { action: T }>,
214
250
  context: HookContext,
215
- options?: HookOptions,
216
- ): Promise<boolean> {
217
- const interactive = options?.interactive !== false;
218
- const ui = options?.output ?? noopOutput;
219
-
220
- switch (action.action) {
221
- case "message": {
222
- ui.info(substituteVars(action.text, context));
251
+ options: HookOptions,
252
+ ) => Promise<boolean>;
253
+
254
+ const actionHandlers: {
255
+ [T in HookAction["action"]]: ActionHandler<T>;
256
+ } = {
257
+ message: async (action, context, options) => {
258
+ const ui = options.output ?? noopOutput;
259
+ ui.info(substituteVars(action.text, context));
260
+ return true;
261
+ },
262
+ box: async (action, context, options) => {
263
+ const ui = options.output ?? noopOutput;
264
+ const title = substituteVars(action.title, context);
265
+ const lines = action.lines.map((line) => substituteVars(line, context));
266
+ ui.box(title, lines);
267
+ return true;
268
+ },
269
+ url: async (action, context, options) => {
270
+ const ui = options.output ?? noopOutput;
271
+ const interactive = options.interactive !== false;
272
+ const url = substituteVars(action.url, context);
273
+ const label = action.label ?? "Link";
274
+ if (!interactive) {
275
+ ui.info(`${label}: ${url}`);
223
276
  return true;
224
277
  }
278
+ console.error("");
279
+ console.error(` ${label}: \x1b[36m${url}\x1b[0m`);
225
280
 
226
- case "box": {
227
- const title = substituteVars(action.title, context);
228
- const lines = action.lines.map((line) => substituteVars(line, context));
229
- ui.box(title, lines);
281
+ if (action.open) {
282
+ ui.info(`Opening: ${url}`);
283
+ await openBrowser(url);
230
284
  return true;
231
285
  }
232
286
 
233
- case "url": {
234
- const url = substituteVars(action.url, context);
235
- const label = action.label ?? "Link";
236
- if (!interactive) {
237
- ui.info(`${label}: ${url}`);
238
- return true;
239
- }
287
+ if (action.prompt !== false) {
240
288
  console.error("");
241
- console.error(` ${label}: \x1b[36m${url}\x1b[0m`);
242
-
243
- if (action.open) {
244
- ui.info(`Opening: ${url}`);
289
+ const choice = await promptSelect(["Open in browser", "Skip"]);
290
+ if (choice === 0) {
245
291
  await openBrowser(url);
246
- return true;
247
- }
248
-
249
- if (action.prompt !== false) {
250
- console.error("");
251
- const choice = await promptSelect(["Open in browser", "Skip"]);
252
- if (choice === 0) {
253
- await openBrowser(url);
254
- ui.success("Opened in browser");
255
- }
292
+ ui.success("Opened in browser");
256
293
  }
294
+ }
295
+ return true;
296
+ },
297
+ clipboard: async (action, context, options) => {
298
+ const ui = options.output ?? noopOutput;
299
+ const interactive = options.interactive !== false;
300
+ const text = substituteVars(action.text, context);
301
+ if (!interactive) {
302
+ ui.info(text);
257
303
  return true;
258
304
  }
305
+ const success = await copyToClipboard(text);
306
+ if (success) {
307
+ const message = action.message ?? "Copied to clipboard";
308
+ ui.success(message);
309
+ } else {
310
+ ui.warn("Could not copy to clipboard");
311
+ }
312
+ return true;
313
+ },
314
+ pause: async (action, _context, options) => {
315
+ const interactive = options.interactive !== false;
316
+ if (!interactive) {
317
+ return true;
318
+ }
319
+ await waitForEnter(action.message);
320
+ return true;
321
+ },
322
+ require: async (action, context, options) => {
323
+ const ui = options.output ?? noopOutput;
324
+ const interactive = options.interactive !== false;
325
+ if (action.source === "secret") {
326
+ const result = await checkSecretExists(action.key, context.projectDir);
327
+ if (!result.exists) {
328
+ const message = action.message ?? `Missing required secret: ${action.key}`;
329
+ ui.error(message);
330
+ ui.info(`Run: jack secrets add ${action.key}`);
259
331
 
260
- case "require": {
261
- if (action.source === "secret") {
262
- const result = await checkSecretExists(action.key, context.projectDir);
263
- if (!result.exists) {
264
- const message = action.message ?? `Missing required secret: ${action.key}`;
265
- ui.error(message);
266
- ui.info(`Run: jack secrets add ${action.key}`);
267
-
268
- if (action.setupUrl) {
269
- if (interactive) {
270
- console.error("");
271
- const choice = await promptSelect(["Open setup page", "Skip"]);
272
- if (choice === 0) {
273
- await openBrowser(action.setupUrl);
274
- }
275
- } else {
276
- ui.info(`Setup: ${action.setupUrl}`);
332
+ if (action.setupUrl) {
333
+ if (interactive) {
334
+ console.error("");
335
+ const choice = await promptSelect(["Open setup page", "Skip"]);
336
+ if (choice === 0) {
337
+ await openBrowser(action.setupUrl);
277
338
  }
339
+ } else {
340
+ ui.info(`Setup: ${action.setupUrl}`);
278
341
  }
279
- return false;
280
- }
281
- return true;
282
- }
283
-
284
- const exists = await checkEnvExists(action.key, context.projectDir);
285
- if (!exists) {
286
- const message = action.message ?? `Missing required env var: ${action.key}`;
287
- ui.error(message);
288
- if (action.setupUrl) {
289
- ui.info(`Setup: ${action.setupUrl}`);
290
342
  }
291
343
  return false;
292
344
  }
293
345
  return true;
294
346
  }
295
347
 
296
- case "clipboard": {
297
- const text = substituteVars(action.text, context);
298
- if (!interactive) {
299
- ui.info(text);
300
- return true;
301
- }
302
- const success = await copyToClipboard(text);
303
- if (success) {
304
- const message = action.message ?? "Copied to clipboard";
305
- ui.success(message);
306
- } else {
307
- ui.warn("Could not copy to clipboard");
348
+ const exists = await checkEnvExists(action.key, context.projectDir);
349
+ if (!exists) {
350
+ const message = action.message ?? `Missing required env var: ${action.key}`;
351
+ ui.error(message);
352
+ if (action.setupUrl) {
353
+ ui.info(`Setup: ${action.setupUrl}`);
308
354
  }
355
+ return false;
356
+ }
357
+ return true;
358
+ },
359
+ prompt: async (action, context, options) => {
360
+ const ui = options.output ?? noopOutput;
361
+ const interactive = options.interactive !== false;
362
+ if (!interactive) {
309
363
  return true;
310
364
  }
311
365
 
312
- case "pause": {
313
- if (!interactive) {
366
+ const { input } = await import("@inquirer/prompts");
367
+
368
+ let rawValue = "";
369
+ try {
370
+ rawValue = await input({ message: action.message });
371
+ } catch (err) {
372
+ if (err instanceof Error && err.name === "ExitPromptError") {
314
373
  return true;
315
374
  }
316
- await waitForEnter(action.message);
375
+ throw err;
376
+ }
377
+
378
+ if (!rawValue.trim()) {
317
379
  return true;
318
380
  }
319
381
 
320
- case "shell": {
321
- const command = substituteVars(action.command, context);
322
- if (action.message) {
323
- ui.info(action.message);
382
+ let parsedInput: unknown = rawValue;
383
+ if (action.validate === "json" || action.validate === "accountAssociation") {
384
+ try {
385
+ parsedInput = JSON.parse(rawValue);
386
+ } catch {
387
+ ui.error("Invalid JSON input");
388
+ return action.required ? false : true;
389
+ }
390
+ }
391
+
392
+ if (action.validate === "accountAssociation" && !isAccountAssociation(parsedInput)) {
393
+ ui.error("Invalid accountAssociation JSON (expected header, payload, signature)");
394
+ return action.required ? false : true;
395
+ }
396
+
397
+ if (action.writeJson) {
398
+ const targetPath = resolveHookPath(action.writeJson.path, context);
399
+ const ok = await applyJsonWrite(
400
+ targetPath,
401
+ action.writeJson.set,
402
+ (value) => substituteVars(value, context),
403
+ parsedInput,
404
+ );
405
+ if (!ok) {
406
+ ui.error(`Invalid JSON file: ${targetPath}`);
407
+ return action.required ? false : true;
324
408
  }
325
- const cwd = action.cwd === "project" ? context.projectDir : undefined;
326
- // Resume stdin in case previous prompts paused it
327
- if (interactive) {
328
- process.stdin.resume();
409
+ if (action.successMessage) {
410
+ ui.success(substituteVars(action.successMessage, context));
329
411
  }
330
- const proc = Bun.spawn(["sh", "-c", command], {
331
- cwd,
332
- stdin: interactive ? "inherit" : "ignore",
333
- stdout: "inherit",
334
- stderr: "inherit",
335
- });
336
- await proc.exited;
337
- return proc.exitCode === 0;
338
412
  }
339
413
 
340
- default:
341
- return true;
342
- }
414
+ return true;
415
+ },
416
+ writeJson: async (action, context, options) => {
417
+ const ui = options.output ?? noopOutput;
418
+ const targetPath = resolveHookPath(action.path, context);
419
+ const ok = await applyJsonWrite(
420
+ targetPath,
421
+ action.set,
422
+ (value) => substituteVars(value, context),
423
+ );
424
+ if (!ok) {
425
+ ui.error(`Invalid JSON file: ${targetPath}`);
426
+ return false;
427
+ }
428
+ if (action.successMessage) {
429
+ ui.success(substituteVars(action.successMessage, context));
430
+ }
431
+ return true;
432
+ },
433
+ shell: async (action, context, options) => {
434
+ const ui = options.output ?? noopOutput;
435
+ const interactive = options.interactive !== false;
436
+ const command = substituteVars(action.command, context);
437
+ if (action.message) {
438
+ ui.info(action.message);
439
+ }
440
+ const cwd = action.cwd === "project" ? context.projectDir : undefined;
441
+ // Resume stdin in case previous prompts paused it
442
+ if (interactive) {
443
+ process.stdin.resume();
444
+ }
445
+ const proc = Bun.spawn(["sh", "-c", command], {
446
+ cwd,
447
+ stdin: interactive ? "inherit" : "ignore",
448
+ stdout: "inherit",
449
+ stderr: "inherit",
450
+ });
451
+ await proc.exited;
452
+ return proc.exitCode === 0;
453
+ },
454
+ };
455
+
456
+ async function executeAction(
457
+ action: HookAction,
458
+ context: HookContext,
459
+ options?: HookOptions,
460
+ ): Promise<boolean> {
461
+ const handler = actionHandlers[action.action] as (
462
+ action: HookAction,
463
+ context: HookContext,
464
+ options: HookOptions,
465
+ ) => Promise<boolean>;
466
+ return handler(action, context, options ?? {});
467
+ }
468
+
469
+ export interface HookResult {
470
+ success: boolean;
471
+ hadInteractiveActions: boolean;
343
472
  }
344
473
 
345
474
  /**
346
475
  * Run a list of hook actions
347
- * Returns true if all succeeded, false if any failed (for preDeploy checks)
476
+ * Returns success status and whether any interactive actions were executed
348
477
  */
349
478
  export async function runHook(
350
479
  actions: HookAction[],
351
480
  context: HookContext,
352
481
  options?: HookOptions,
353
- ): Promise<boolean> {
482
+ ): Promise<HookResult> {
483
+ const interactive = options?.interactive !== false;
484
+ // Track if we had any interactive actions (prompt, pause) that ran
485
+ const interactiveActionTypes = ["prompt", "pause"];
486
+ let hadInteractiveActions = false;
487
+
354
488
  for (const action of actions) {
489
+ // Check if this is an interactive action that will actually run
490
+ if (interactive && interactiveActionTypes.includes(action.action)) {
491
+ hadInteractiveActions = true;
492
+ }
355
493
  const shouldContinue = await executeAction(action, context, options);
356
494
  if (!shouldContinue) {
357
- return false;
495
+ return { success: false, hadInteractiveActions };
358
496
  }
359
497
  }
360
- return true;
498
+ return { success: true, hadInteractiveActions };
361
499
  }
@@ -0,0 +1,56 @@
1
+ import { existsSync } from "node:fs";
2
+
3
+ export function setJsonPath(
4
+ target: Record<string, unknown>,
5
+ path: string,
6
+ value: unknown,
7
+ ): void {
8
+ const keys = path.split(".").filter(Boolean);
9
+ let current: Record<string, unknown> = target;
10
+
11
+ for (let i = 0; i < keys.length - 1; i++) {
12
+ const key = keys[i];
13
+ if (key === undefined) continue;
14
+ const next = current[key];
15
+ if (!next || typeof next !== "object" || Array.isArray(next)) {
16
+ current[key] = {};
17
+ }
18
+ current = current[key] as Record<string, unknown>;
19
+ }
20
+
21
+ const lastKey = keys[keys.length - 1];
22
+ if (lastKey) {
23
+ current[lastKey] = value;
24
+ }
25
+ }
26
+
27
+ export async function applyJsonWrite(
28
+ targetPath: string,
29
+ updates: Record<string, string | { from: "input" }>,
30
+ substitute: (value: string) => string,
31
+ inputValue?: unknown,
32
+ ): Promise<boolean> {
33
+ let jsonData: Record<string, unknown> = {};
34
+
35
+ if (existsSync(targetPath)) {
36
+ try {
37
+ const content = await Bun.file(targetPath).text();
38
+ if (content.trim()) {
39
+ jsonData = JSON.parse(content) as Record<string, unknown>;
40
+ }
41
+ } catch {
42
+ return false;
43
+ }
44
+ }
45
+
46
+ for (const [path, value] of Object.entries(updates)) {
47
+ if (typeof value === "string") {
48
+ setJsonPath(jsonData, path, substitute(value));
49
+ } else if (value?.from === "input") {
50
+ setJsonPath(jsonData, path, inputValue);
51
+ }
52
+ }
53
+
54
+ await Bun.write(targetPath, `${JSON.stringify(jsonData, null, 2)}\n`);
55
+ return true;
56
+ }
@@ -3,6 +3,7 @@ import { mkdir } from "node:fs/promises";
3
3
  import { homedir } from "node:os";
4
4
  import { platform } from "node:os";
5
5
  import { dirname, join } from "node:path";
6
+ import { CONFIG_DIR } from "./config.ts";
6
7
 
7
8
  /**
8
9
  * MCP server configuration structure
@@ -44,7 +45,7 @@ export const APP_MCP_CONFIGS: Record<string, AppMcpConfig> = {
44
45
  /**
45
46
  * Jack MCP configuration storage path
46
47
  */
47
- const JACK_MCP_CONFIG_DIR = join(homedir(), ".config", "jack", "mcp");
48
+ const JACK_MCP_CONFIG_DIR = join(CONFIG_DIR, "mcp");
48
49
  const JACK_MCP_CONFIG_PATH = join(JACK_MCP_CONFIG_DIR, "config.json");
49
50
 
50
51
  /**
package/src/lib/output.ts CHANGED
@@ -26,13 +26,34 @@ export const output = {
26
26
  warn,
27
27
  item,
28
28
  box,
29
+ celebrate,
29
30
  };
30
31
 
31
32
  /**
32
33
  * Create a spinner for long-running operations
33
34
  */
34
35
  export function spinner(text: string) {
35
- return yoctoSpinner({ text }).start();
36
+ const spin = yoctoSpinner({ text }).start();
37
+
38
+ return {
39
+ success(message: string) {
40
+ spin.stop();
41
+ success(message);
42
+ },
43
+ error(message: string) {
44
+ spin.stop();
45
+ error(message);
46
+ },
47
+ stop() {
48
+ spin.stop();
49
+ },
50
+ get text() {
51
+ return spin.text;
52
+ },
53
+ set text(value: string | undefined) {
54
+ spin.text = value ?? "";
55
+ },
56
+ };
36
57
  }
37
58
 
38
59
  /**
@@ -74,25 +95,75 @@ export function item(message: string): void {
74
95
  console.error(` ${message}`);
75
96
  }
76
97
 
98
+ // Random neon purple for cyberpunk styling
99
+ function getRandomPurple(): string {
100
+ const purples = [177, 165, 141, 129];
101
+ const colorCode = purples[Math.floor(Math.random() * purples.length)];
102
+ return `\x1b[38;5;${colorCode}m`;
103
+ }
104
+
77
105
  /**
78
- * Print a boxed message for important info
106
+ * Print a boxed message for important info (cyberpunk style)
79
107
  */
80
108
  export function box(title: string, lines: string[]): void {
81
109
  const maxLen = Math.max(title.length, ...lines.map((l) => l.length));
82
- const width = maxLen + 4;
83
- const border = isColorEnabled ? "\x1b[90m" : "";
110
+ const innerWidth = maxLen + 4;
111
+
112
+ const purple = isColorEnabled ? getRandomPurple() : "";
113
+ const bold = isColorEnabled ? "\x1b[1m" : "";
114
+ const reset = isColorEnabled ? "\x1b[0m" : "";
115
+
116
+ const bar = "═".repeat(innerWidth);
117
+ const fill = "▓".repeat(innerWidth);
118
+ const gradient = "░".repeat(innerWidth);
119
+
120
+ const pad = (text: string) => ` ${text.padEnd(maxLen)} `;
121
+
122
+ console.error("");
123
+ console.error(` ${purple}╔${bar}╗${reset}`);
124
+ console.error(` ${purple}║${fill}║${reset}`);
125
+ console.error(` ${purple}║${pad(bold + title + reset + purple)}║${reset}`);
126
+ console.error(` ${purple}║${"─".repeat(innerWidth)}║${reset}`);
127
+ for (const line of lines) {
128
+ console.error(` ${purple}║${pad(line)}║${reset}`);
129
+ }
130
+ console.error(` ${purple}║${gradient}║${reset}`);
131
+ console.error(` ${purple}╚${bar}╝${reset}`);
132
+ console.error("");
133
+ }
134
+
135
+ /**
136
+ * Print a celebration box (for final success after setup)
137
+ */
138
+ export function celebrate(title: string, lines: string[]): void {
139
+ const maxLen = Math.max(title.length, ...lines.map((l) => l.length));
140
+ const innerWidth = maxLen + 4;
141
+
142
+ const purple = isColorEnabled ? getRandomPurple() : "";
143
+ const bold = isColorEnabled ? "\x1b[1m" : "";
84
144
  const reset = isColorEnabled ? "\x1b[0m" : "";
85
- const titleColor = isColorEnabled ? "\x1b[1m" : "";
145
+
146
+ const bar = "═".repeat(innerWidth);
147
+ const fill = "▓".repeat(innerWidth);
148
+ const gradient = "░".repeat(innerWidth);
149
+ const space = " ".repeat(innerWidth);
150
+
151
+ const center = (text: string) => {
152
+ const left = Math.floor((innerWidth - text.length) / 2);
153
+ return " ".repeat(left) + text + " ".repeat(innerWidth - text.length - left);
154
+ };
86
155
 
87
156
  console.error("");
88
- console.error(`${border}┌${"─".repeat(width)}┐${reset}`);
89
- console.error(
90
- `${border}│${reset} ${titleColor}${title.padEnd(maxLen)}${reset} ${border}│${reset}`,
91
- );
92
- console.error(`${border}├${"─".repeat(width)}┤${reset}`);
157
+ console.error(` ${purple}╔${bar}╗${reset}`);
158
+ console.error(` ${purple}║${fill}║${reset}`);
159
+ console.error(` ${purple}║${space}║${reset}`);
160
+ console.error(` ${purple}║${center(bold + title + reset + purple)}║${reset}`);
161
+ console.error(` ${purple}║${space}║${reset}`);
93
162
  for (const line of lines) {
94
- console.error(`${border}│${reset} ${line.padEnd(maxLen)} ${border}│${reset}`);
163
+ console.error(` ${purple}║${center(line)}║${reset}`);
95
164
  }
96
- console.error(`${border}└${"─".repeat(width)}┘${reset}`);
165
+ console.error(` ${purple}║${space}║${reset}`);
166
+ console.error(` ${purple}║${gradient}║${reset}`);
167
+ console.error(` ${purple}╚${bar}╝${reset}`);
97
168
  console.error("");
98
169
  }
@@ -187,7 +187,7 @@ describe("paths-index", () => {
187
187
  const index = await readPathsIndex();
188
188
 
189
189
  // Should store absolute path
190
- expect(index.paths.proj_rel[0].startsWith("/")).toBe(true);
190
+ expect(index.paths.proj_rel?.[0]?.startsWith("/")).toBe(true);
191
191
  } finally {
192
192
  process.chdir(originalCwd);
193
193
  }
@@ -351,7 +351,7 @@ describe("paths-index", () => {
351
351
  const discovered = await scanAndRegisterProjects(testDir);
352
352
 
353
353
  expect(discovered).toHaveLength(1);
354
- expect(discovered[0].projectId).toBe("proj_linked");
354
+ expect(discovered[0]!.projectId).toBe("proj_linked");
355
355
  });
356
356
 
357
357
  it("respects maxDepth", async () => {
@@ -366,7 +366,7 @@ describe("paths-index", () => {
366
366
  const discovered = await scanAndRegisterProjects(testDir, 2);
367
367
 
368
368
  expect(discovered).toHaveLength(1);
369
- expect(discovered[0].projectId).toBe("proj_shallow");
369
+ expect(discovered[0]!.projectId).toBe("proj_shallow");
370
370
  });
371
371
 
372
372
  it("skips node_modules", async () => {
@@ -379,7 +379,7 @@ describe("paths-index", () => {
379
379
  const discovered = await scanAndRegisterProjects(testDir);
380
380
 
381
381
  expect(discovered).toHaveLength(1);
382
- expect(discovered[0].projectId).toBe("proj_valid");
382
+ expect(discovered[0]!.projectId).toBe("proj_valid");
383
383
  });
384
384
 
385
385
  it("skips .git directory", async () => {
@@ -392,7 +392,7 @@ describe("paths-index", () => {
392
392
  const discovered = await scanAndRegisterProjects(testDir);
393
393
 
394
394
  expect(discovered).toHaveLength(1);
395
- expect(discovered[0].projectId).toBe("proj_valid");
395
+ expect(discovered[0]!.projectId).toBe("proj_valid");
396
396
  });
397
397
 
398
398
  it("does not recurse into linked projects", async () => {
@@ -407,7 +407,7 @@ describe("paths-index", () => {
407
407
 
408
408
  // Should only find parent, not nested child
409
409
  expect(discovered).toHaveLength(1);
410
- expect(discovered[0].projectId).toBe("proj_parent");
410
+ expect(discovered[0]!.projectId).toBe("proj_parent");
411
411
  });
412
412
 
413
413
  it("registers discovered projects in index", async () => {
@@ -517,7 +517,7 @@ describe("paths-index", () => {
517
517
 
518
518
  const discovered = await scanAndRegisterProjects(testDir);
519
519
  expect(discovered).toHaveLength(1);
520
- expect(discovered[0].projectId).toBe("proj_spaces");
520
+ expect(discovered[0]!.projectId).toBe("proj_spaces");
521
521
  });
522
522
 
523
523
  it("handles permission errors gracefully", async () => {