typeclaw 0.37.3 → 0.37.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/src/cli/init.ts CHANGED
@@ -39,6 +39,7 @@ import {
39
39
  hasExistingOAuthCredentials,
40
40
  isDirectoryNonEmpty,
41
41
  isHatched,
42
+ isInitialized,
42
43
  readExistingProviderApiKey,
43
44
  runInit,
44
45
  type GithubInitCredentials,
@@ -139,18 +140,22 @@ export const init = defineCommand({
139
140
  if (existingAgent !== null && existingAgent !== cwd) {
140
141
  console.error(
141
142
  errorLine(
142
- `Refusing to init: a TypeClaw agent already exists at ${existingAgent}. Nested agents are not supported.`,
143
+ `Refusing to init: a TypeClaw agent already exists at ${existingAgent}. Nested agents are not supported. Run init from a directory that is not inside an existing agent.`,
143
144
  ),
144
145
  )
145
146
  process.exit(1)
146
147
  }
147
148
 
148
149
  if (await isHatched(cwd)) {
149
- console.error(errorLine(`TypeClaw has already hatched in ${cwd}.`))
150
+ console.error(
151
+ errorLine(
152
+ `TypeClaw has already hatched in ${cwd}. Use \`typeclaw tui\` to attach or \`typeclaw start\` to run it; init in a different directory to create another agent.`,
153
+ ),
154
+ )
150
155
  process.exit(1)
151
156
  }
152
157
 
153
- if (isDirectoryNonEmpty(cwd)) {
158
+ if (shouldConfirmNonEmptyDirectory(cwd)) {
154
159
  const proceed = await confirm({
155
160
  message: `You're at ${cwd}. The directory is not empty. Do you want to proceed?`,
156
161
  initialValue: false,
@@ -192,7 +197,7 @@ export const init = defineCommand({
192
197
  [
193
198
  'OAuth credentials were saved to `secrets.json` before you aborted.',
194
199
  'Re-run `typeclaw init` here to pick up where you left off (the credentials',
195
- 'will be reused), or delete `secrets.json` if you want a clean restart.',
200
+ 'will be reused), or run `typeclaw init --reset` to start fresh.',
196
201
  ].join('\n'),
197
202
  'Saved OAuth credentials',
198
203
  )
@@ -288,6 +293,14 @@ export const init = defineCommand({
288
293
  })
289
294
  } catch (error) {
290
295
  console.error(errorLine(error instanceof Error ? error.message : String(error)))
296
+ note(
297
+ [
298
+ 'Your answers are saved.',
299
+ 'Re-run `typeclaw init` here to resume, or `typeclaw init --reset` to start fresh.',
300
+ 'Run `typeclaw doctor` to diagnose host/Docker issues.',
301
+ ].join('\n'),
302
+ 'init failed',
303
+ )
291
304
  process.exit(1)
292
305
  }
293
306
 
@@ -296,6 +309,19 @@ export const init = defineCommand({
296
309
  process.exit(1)
297
310
  }
298
311
 
312
+ if (!hatchingOk) {
313
+ note(
314
+ [
315
+ 'The container was built but the agent did not come up.',
316
+ 'Check logs: `typeclaw logs`',
317
+ 'Diagnose: `typeclaw doctor`',
318
+ 'Retry once fixed: `typeclaw start` (your setup is saved).',
319
+ ].join('\n'),
320
+ 'Hatching failed',
321
+ )
322
+ process.exit(1)
323
+ }
324
+
299
325
  if (githubCredentials?.tunnelProvider === 'none') {
300
326
  log.warn(
301
327
  'Webhook delivery is disabled until you add a `tunnels[]` entry or set `channels.github.webhookUrl` manually.',
@@ -335,6 +361,10 @@ export const init = defineCommand({
335
361
  },
336
362
  })
337
363
 
364
+ export function shouldConfirmNonEmptyDirectory(cwd: string): boolean {
365
+ return isDirectoryNonEmpty(cwd) && !isInitialized(cwd)
366
+ }
367
+
338
368
  interface WizardState {
339
369
  catalog?: { options: ModelOption[]; source: 'models.dev' | 'curated'; warning?: string }
340
370
  vendorId?: KnownProviderVendorId
@@ -1750,20 +1780,24 @@ function reportProgress(
1750
1780
  s.stop(event.result.ok ? 'Logged in.' : `OAuth login failed: ${event.result.reason}`)
1751
1781
  break
1752
1782
  case 'install':
1753
- s.stop(event.result.ok ? 'Dependencies installed.' : `Skipped bun install: ${event.result.reason}`)
1783
+ if (event.result.ok) {
1784
+ s.stop('Dependencies installed.')
1785
+ } else {
1786
+ s.error(`Dependency install failed: ${event.result.reason}`)
1787
+ }
1754
1788
  break
1755
1789
  case 'dockerfile':
1756
1790
  if (event.result.ok) {
1757
1791
  s.stop(event.result.devMode ? 'Dockerfile written (dev mode).' : 'Dockerfile written.')
1758
1792
  } else {
1759
- s.stop(`Skipped Dockerfile: ${event.result.reason}`)
1793
+ s.error(`Dockerfile generation failed: ${event.result.reason}`)
1760
1794
  }
1761
1795
  break
1762
1796
  case 'git':
1763
1797
  if (event.result.ok) {
1764
1798
  s.stop(event.result.skipped ? 'Git repository already exists.' : 'Git repository initialized.')
1765
1799
  } else {
1766
- s.stop(`Skipped git init: ${event.result.reason}`)
1800
+ s.error(`git init failed — continuing without a repo: ${event.result.reason}`)
1767
1801
  }
1768
1802
  break
1769
1803
  }
package/src/cli/qr.ts CHANGED
@@ -6,6 +6,8 @@ import { promisify } from 'node:util'
6
6
 
7
7
  import QRCode from 'qrcode'
8
8
 
9
+ import { isMacOS, isWindows } from '@/shared'
10
+
9
11
  const execFileAsync = promisify(execFile)
10
12
 
11
13
  // The upstream LINE SDK's QR login hands back a raw auth URL
@@ -108,12 +110,11 @@ async function writeQRHtmlFile(
108
110
  }
109
111
 
110
112
  async function openInBrowser(filePath: string): Promise<void> {
111
- const platform = process.platform
112
- if (platform === 'darwin') {
113
+ if (isMacOS()) {
113
114
  await execFileAsync('open', [filePath])
114
115
  return
115
116
  }
116
- if (platform === 'win32') {
117
+ if (isWindows()) {
117
118
  await execFileAsync('cmd', ['/c', 'start', '', filePath])
118
119
  return
119
120
  }
package/src/cli/ui.ts CHANGED
@@ -14,14 +14,18 @@ type ClackInput = Pick<NodeJS.ReadStream, 'isTTY' | 'setRawMode' | 'resume'>
14
14
  // Bun's readline keypress wiring only transitions stdin into flowing raw mode
15
15
  // reliably once the stream has already been resumed; on a never-resumed stdin
16
16
  // the picker renders but arrow keys echo as raw `^[[B` and never advance it.
17
- // Local terminals dodge this because stdin was already flowing. So before every
18
- // picker: clear any stale raw mode for a clean baseline, then resume the stream.
19
- // Never pause() here a previously-paused process.stdin does not reliably
20
- // re-flow under Bun, which is the same failure this resume() is fixing.
17
+ // Local terminals dodge this because stdin was already flowing. Worse, after a
18
+ // pi-tui viewer (ProcessTerminal.stop() calls process.stdin.pause()), a plain
19
+ // resume() does NOT re-flow stdin under Bun, so the next picker is dead over
20
+ // SSH. Toggling raw mode on->off forces the TTY read back into flowing mode;
21
+ // the trailing resume() + non-raw state is the baseline clack expects.
22
+ // Never pause() here — a paused process.stdin does not reliably re-flow.
21
23
  export function prepareStdinForClack(input: ClackInput = process.stdin): void {
22
24
  if (!input.isTTY) return
25
+ input.resume()
23
26
  if (typeof input.setRawMode === 'function') {
24
27
  try {
28
+ input.setRawMode(true)
25
29
  input.setRawMode(false)
26
30
  } catch {
27
31
  /* terminal already torn down */
@@ -15,11 +15,12 @@ import {
15
15
  resolveHostPort,
16
16
  type DockerExec,
17
17
  } from '@/container'
18
- import { isDaemonReachable, send } from '@/hostd'
18
+ import { homeRoot, isDaemonReachable, send } from '@/hostd'
19
19
  import { resolveBaseImageVersion } from '@/init/cli-version'
20
20
  import { buildDockerfile, DOCKERFILE } from '@/init/dockerfile'
21
21
  import { detectMissingDeps } from '@/init/ensure-deps'
22
22
  import { buildGitignore, GITIGNORE_FILE } from '@/init/gitignore'
23
+ import { detectWsl, isWindows, isWindowsDriveMount, type WslInfo } from '@/shared'
23
24
 
24
25
  import { buildChannelChecks } from './channel-checks'
25
26
  import type { DoctorCheck } from './types'
@@ -37,8 +38,11 @@ export function buildStaticChecks(opts: { dockerExec?: DockerExec } = {}): Docto
37
38
  agentFolderGitRepo(),
38
39
  configValid(),
39
40
  hostdHomeWritable(),
41
+ wslDriveMount(),
42
+ windowsSecretPerms(),
40
43
  hostdReachable(),
41
44
  hostdRegistration(),
45
+ windowsBindMount(),
42
46
  containerState(dockerExec),
43
47
  containerHostPort(),
44
48
  ...buildChannelChecks(),
@@ -237,6 +241,142 @@ function hostdHomeWritable(): DoctorCheck {
237
241
  }
238
242
  }
239
243
 
244
+ export type WslDriveMountDeps = {
245
+ detect: () => WslInfo
246
+ isWindowsDriveMount: (path: string) => boolean
247
+ typeclawHome: () => string
248
+ }
249
+
250
+ // Under WSL, files on a Windows-drive mount (/mnt/c/...) don't enforce Unix
251
+ // permissions, so the 0600 chmod that protects secrets.json and the encryption
252
+ // keys is silently ignored — they become readable by every local user. Warn
253
+ // when either the agent folder or ~/.typeclaw lives on such a mount.
254
+ export function wslDriveMount(deps: Partial<WslDriveMountDeps> = {}): DoctorCheck {
255
+ const detect = deps.detect ?? detectWsl
256
+ const onWindowsDrive = deps.isWindowsDriveMount ?? isWindowsDriveMount
257
+ const typeclawHome = deps.typeclawHome ?? homeRoot
258
+
259
+ return {
260
+ name: 'hostd.wsl-drive-mount',
261
+ category: 'hostd',
262
+ description: 'agent state is not on a Windows-drive mount under WSL',
263
+ async run(ctx) {
264
+ if (!detect().isWsl) return { status: 'ok', message: 'not running under WSL' }
265
+
266
+ const offenders: string[] = []
267
+ if (ctx.hasAgentFolder && onWindowsDrive(ctx.cwd)) offenders.push(`agent folder: ${ctx.cwd}`)
268
+ const home = typeclawHome()
269
+ if (onWindowsDrive(home)) offenders.push(`hostd home: ${home}`)
270
+
271
+ if (offenders.length === 0) {
272
+ return { status: 'ok', message: 'agent state is on the Linux filesystem' }
273
+ }
274
+
275
+ return {
276
+ status: 'warning',
277
+ message: 'agent state is on a Windows-drive mount; file permissions are not enforced',
278
+ details: [
279
+ ...offenders,
280
+ 'chmod is a no-op on /mnt/<drive> (DrvFs/9p), so secrets.json and encryption keys become world-readable.',
281
+ ],
282
+ fix: {
283
+ description:
284
+ 'Move the agent folder to the WSL Linux filesystem (e.g. ~/my-agent) and, if needed, set TYPECLAW_HOME to a Linux path.',
285
+ },
286
+ }
287
+ },
288
+ }
289
+ }
290
+
291
+ export type WindowsSecretPermsDeps = {
292
+ isWindows: () => boolean
293
+ typeclawHome: () => string
294
+ }
295
+
296
+ // On native Windows the 0600/0700 modes typeclaw sets on secrets.json and the
297
+ // encryption keys are no-ops — NTFS uses ACLs, not Unix modes — so their
298
+ // confidentiality rests on the inherited %USERPROFILE% ACLs rather than the
299
+ // hardening typeclaw enforces on POSIX. Surface that as a warning.
300
+ export function windowsSecretPerms(deps: Partial<WindowsSecretPermsDeps> = {}): DoctorCheck {
301
+ const onWindows = deps.isWindows ?? isWindows
302
+ const typeclawHome = deps.typeclawHome ?? homeRoot
303
+
304
+ return {
305
+ name: 'hostd.windows-secret-perms',
306
+ category: 'hostd',
307
+ description: 'secrets rely on enforced file permissions (native Windows)',
308
+ async run(ctx) {
309
+ if (!onWindows()) return { status: 'ok', message: 'not running on native Windows' }
310
+
311
+ const details = [`hostd home: ${typeclawHome()}`]
312
+ if (ctx.hasAgentFolder) details.push(`agent folder: ${ctx.cwd}`)
313
+ details.push(
314
+ 'NTFS ignores the 0600/0700 chmod typeclaw applies to secrets.json and encryption keys; their confidentiality relies on the inherited %USERPROFILE% ACLs instead.',
315
+ )
316
+
317
+ return {
318
+ status: 'warning',
319
+ message: 'native Windows does not enforce the file modes that protect agent secrets',
320
+ details,
321
+ fix: {
322
+ description:
323
+ 'Keep the agent folder and ~/.typeclaw under your user profile, where default ACLs restrict access to your account; avoid a shared or everyone-readable location.',
324
+ },
325
+ }
326
+ },
327
+ }
328
+ }
329
+
330
+ export type WindowsBindMountDeps = {
331
+ isWindows: () => boolean
332
+ }
333
+
334
+ // Docker Desktop bind-mounts the agent folder into its Linux VM, and a few host
335
+ // locations don't survive that translation: UNC/network paths (\\server\share)
336
+ // aren't shareable, OneDrive-virtualized folders fail on placeholder files, and
337
+ // paths near the legacy MAX_PATH (260) limit break mid-build. Flag them so
338
+ // `typeclaw start` fails loudly here instead of cryptically at mount time.
339
+ export function windowsBindMount(deps: Partial<WindowsBindMountDeps> = {}): DoctorCheck {
340
+ const onWindows = deps.isWindows ?? isWindows
341
+
342
+ return {
343
+ name: 'container.windows-bind-mount',
344
+ category: 'container',
345
+ description: 'agent folder is bind-mountable by Docker Desktop (native Windows)',
346
+ applies: (ctx) => ctx.hasAgentFolder,
347
+ async run(ctx) {
348
+ if (!onWindows()) return { status: 'ok', message: 'not running on native Windows' }
349
+
350
+ const issues = detectWindowsBindMountIssues(ctx.cwd)
351
+ if (issues.length === 0) return { status: 'ok', message: 'agent folder path is bind-mountable' }
352
+
353
+ return {
354
+ status: 'warning',
355
+ message: 'agent folder may not bind-mount cleanly under Docker Desktop',
356
+ details: issues,
357
+ fix: {
358
+ description:
359
+ 'Use a local, short, non-OneDrive path under your user profile (e.g. C:\\agents\\my-agent), then re-run typeclaw start.',
360
+ },
361
+ }
362
+ },
363
+ }
364
+ }
365
+
366
+ export function detectWindowsBindMountIssues(path: string): string[] {
367
+ const issues: string[] = []
368
+ if (path.startsWith('\\\\')) {
369
+ issues.push(`UNC/network path is not shareable with Docker Desktop: ${path}`)
370
+ }
371
+ if (path.split(/[\\/]/).some((seg) => /^onedrive(?: -.*)?$/i.test(seg))) {
372
+ issues.push(`path is under OneDrive, where virtualized files can break bind mounts: ${path}`)
373
+ }
374
+ if (path.length > 260) {
375
+ issues.push(`path length ${path.length} exceeds the legacy Windows MAX_PATH (260) limit`)
376
+ }
377
+ return issues
378
+ }
379
+
240
380
  function hostdReachable(): DoctorCheck {
241
381
  return {
242
382
  name: 'hostd.reachable',
@@ -362,9 +502,12 @@ function loadConfigStrictForTemplate(
362
502
  return { dockerfile: cfg.docker.file, gitignore: cfg.git.ignore }
363
503
  }
364
504
 
505
+ // Normalizes CRLF to LF: the managed templates are emitted with `\n`, but a
506
+ // checkout under Git for Windows (core.autocrlf=true) rewrites them to `\r\n`,
507
+ // which would make the byte-exact template comparison report a false divergence.
365
508
  async function safeRead(path: string): Promise<string | null> {
366
509
  try {
367
- return await readFile(path, 'utf8')
510
+ return (await readFile(path, 'utf8')).replace(/\r\n/g, '\n')
368
511
  } catch {
369
512
  return null
370
513
  }
@@ -1,4 +1,5 @@
1
1
  import { getBun } from '@/container/shared'
2
+ import { isMacOS, isWindows } from '@/shared'
2
3
 
3
4
  export type TailscaleExecResult = { exitCode: number; stdout: string; stderr: string }
4
5
  export type TailscaleExec = (args: string[]) => Promise<TailscaleExecResult>
@@ -33,6 +34,7 @@ type TailscaleStatus = {
33
34
  }
34
35
 
35
36
  const MACOS_APP_CLI = '/Applications/Tailscale.app/Contents/MacOS/Tailscale'
37
+ const WINDOWS_CLI = 'C:\\Program Files\\Tailscale\\tailscale.exe'
36
38
 
37
39
  export function createTailscaleServeManager(opts: TailscaleServeManagerOptions): TailscaleServeManager {
38
40
  const exec = opts.exec ?? defaultTailscaleExec
@@ -129,8 +131,17 @@ async function checkRunning(exec: TailscaleExec): Promise<{ ok: true } | { ok: f
129
131
  return { ok: true }
130
132
  }
131
133
 
134
+ // `tailscale` on PATH is tried first everywhere; the platform-specific absolute
135
+ // path is a fallback for GUI installs that leave the CLI off PATH (the macOS app
136
+ // bundle, the Windows default install dir).
137
+ export function tailscaleCandidates(platform: NodeJS.Platform = process.platform): string[] {
138
+ if (isMacOS(platform)) return ['tailscale', MACOS_APP_CLI]
139
+ if (isWindows(platform)) return ['tailscale', WINDOWS_CLI]
140
+ return ['tailscale']
141
+ }
142
+
132
143
  export const defaultTailscaleExec: TailscaleExec = async (args) => {
133
- const candidates = process.platform === 'darwin' ? ['tailscale', MACOS_APP_CLI] : ['tailscale']
144
+ const candidates = tailscaleCandidates()
134
145
  let lastError = 'tailscale command not found'
135
146
 
136
147
  for (const candidate of candidates) {
package/src/init/index.ts CHANGED
@@ -20,7 +20,14 @@ import {
20
20
  type KnownProviderId,
21
21
  type ModelRef,
22
22
  } from '@/config/providers'
23
- import { checkDockerAvailable, type DockerAvailability, type DockerExec, start } from '@/container'
23
+ import {
24
+ checkDockerAvailable,
25
+ type DockerAvailability,
26
+ type DockerExec,
27
+ start,
28
+ type StartResult,
29
+ stop,
30
+ } from '@/container'
24
31
  import { commitSystemFile } from '@/git/system-commit'
25
32
  import { createSecretsStoreForAgent, type Channels, type Secret, SecretsBackend } from '@/secrets'
26
33
  import { hostLocaleIsCjk } from '@/shared/host-locale'
@@ -376,10 +383,12 @@ export async function runInit({
376
383
  emit({ step: 'install', phase: 'start' })
377
384
  const install = await installRunner(cwd)
378
385
  emit({ step: 'install', phase: 'done', result: install })
386
+ if (!install.ok) throw new Error(`Dependency install failed: ${install.reason}`)
379
387
 
380
388
  emit({ step: 'dockerfile', phase: 'start' })
381
389
  const docker = await writeDockerAssets(cwd)
382
390
  emit({ step: 'dockerfile', phase: 'done', result: docker })
391
+ if (!docker.ok) throw new Error(`Dockerfile generation failed: ${docker.reason}`)
383
392
 
384
393
  emit({ step: 'git', phase: 'start' })
385
394
  const git = await initGitRepo(cwd)
@@ -417,6 +426,7 @@ export async function defaultRunHatching({
417
426
  tui: tuiFactory = createTui,
418
427
  waitForAgent: waitForAgentFn = waitForAgent,
419
428
  runClaim = defaultRunClaim,
429
+ stopContainer = stop,
420
430
  }: {
421
431
  cwd: string
422
432
  port: number
@@ -426,14 +436,17 @@ export async function defaultRunHatching({
426
436
  tui?: typeof createTui
427
437
  waitForAgent?: typeof waitForAgent
428
438
  runClaim?: ClaimRunner
439
+ stopContainer?: typeof stop
429
440
  }): Promise<HatchingResult> {
441
+ let launch: Extract<StartResult, { ok: true }> | null = null
430
442
  try {
431
- const launch = await startContainer({
443
+ const startResult = await startContainer({
432
444
  cwd,
433
445
  preferredHostPort: port,
434
446
  ...(cliEntry !== undefined ? { cliEntry } : {}),
435
447
  })
436
- if (!launch.ok) return { ok: false, reason: launch.reason }
448
+ if (!startResult.ok) return { ok: false, reason: startResult.reason }
449
+ launch = startResult
437
450
 
438
451
  // start() may have allocated a different host port (the preferred one was
439
452
  // bound). Use the actually-published port for the TUI handshake instead of
@@ -455,6 +468,9 @@ export async function defaultRunHatching({
455
468
  await tui.run()
456
469
  return { ok: true }
457
470
  } catch (error) {
471
+ if (launch !== null && !launch.alreadyRunning) {
472
+ await stopContainer({ cwd }).catch(() => {})
473
+ }
458
474
  return { ok: false, reason: error instanceof Error ? error.message : String(error) }
459
475
  }
460
476
  }
@@ -709,7 +725,7 @@ export async function initGitRepo(cwd: string): Promise<GitInitResult> {
709
725
  const bun = (globalThis as { Bun?: { spawn: typeof Bun.spawn } }).Bun
710
726
  if (!bun) return { ok: false, reason: 'bun runtime not available' }
711
727
 
712
- if (existsSync(join(cwd, '.git'))) return { ok: true, skipped: true }
728
+ const hasGit = existsSync(join(cwd, '.git'))
713
729
 
714
730
  // Author the initial commit as TypeClaw itself. The agent is still unnamed
715
731
  // (IDENTITY.md is empty and hatching hasn't run), so the agent identity will
@@ -724,10 +740,21 @@ export async function initGitRepo(cwd: string): Promise<GitInitResult> {
724
740
  }
725
741
 
726
742
  try {
727
- const init = bun.spawn({ cmd: ['git', 'init', '-b', 'main'], cwd, env, stdout: 'pipe', stderr: 'pipe' })
728
- if ((await init.exited) !== 0) {
729
- const stderr = await new Response(init.stderr).text()
730
- return { ok: false, reason: `git init failed: ${stderr.trim() || 'no stderr'}` }
743
+ if (hasGit) {
744
+ const head = bun.spawn({
745
+ cmd: ['git', 'rev-parse', '--verify', 'HEAD'],
746
+ cwd,
747
+ env,
748
+ stdout: 'pipe',
749
+ stderr: 'pipe',
750
+ })
751
+ if ((await head.exited) === 0) return { ok: true, skipped: true }
752
+ } else {
753
+ const init = bun.spawn({ cmd: ['git', 'init', '-b', 'main'], cwd, env, stdout: 'pipe', stderr: 'pipe' })
754
+ if ((await init.exited) !== 0) {
755
+ const stderr = await new Response(init.stderr).text()
756
+ return { ok: false, reason: `git init failed: ${stderr.trim() || 'no stderr'}` }
757
+ }
731
758
  }
732
759
 
733
760
  const add = bun.spawn({ cmd: ['git', 'add', '.'], cwd, env, stdout: 'pipe', stderr: 'pipe' })
@@ -1,5 +1,7 @@
1
1
  export type InstallResult = { ok: true } | { ok: false; reason: string }
2
2
 
3
+ const INSTALL_TIMEOUT_MS = 300_000
4
+
3
5
  export type InstallRunnerOptions = {
4
6
  // Append `--force` to the bun install argv to bypass the cache for
5
7
  // `file:` / `link:` deps. Bun treats name+version of a `file:` dep as a
@@ -7,6 +9,8 @@ export type InstallRunnerOptions = {
7
9
  // locally-linked typeclaw never propagate into <agent>/node_modules until
8
10
  // either the typeclaw version is bumped or the install is forced.
9
11
  force?: boolean
12
+ timeoutMs?: number
13
+ spawn?: typeof Bun.spawn
10
14
  }
11
15
 
12
16
  // Signature for the function `runInit` uses to materialize the agent folder's
@@ -19,31 +23,29 @@ export async function runBunInstall(cwd: string, opts?: InstallRunnerOptions): P
19
23
  const bun = (globalThis as { Bun?: { spawn: typeof Bun.spawn } }).Bun
20
24
  if (!bun) return { ok: false, reason: 'bun runtime not available' }
21
25
  try {
22
- const proc = bun.spawn({
23
- // `--linker=hoisted` sidesteps a deadlock in Bun 1.3.x's isolated linker
24
- // (the default since 1.3.0). When any single package fetch fails — 401,
25
- // SHA-512 mismatch, transient registry 5xx, the kind of flake that's
26
- // routine on GitHub Actions shared-IP runners the isolated linker
27
- // hangs the process indefinitely instead of erroring out
28
- // (oven-sh/bun#26341, oven-sh/bun#29646). `bun install` runs here over
29
- // ~500 transitive packages with no lockfile, so the odds of triggering
30
- // the bug are non-trivial. Hoisted is the fallback strategy bun shipped
31
- // before 1.3 slightly slower for huge monorepos, indistinguishable
32
- // for an agent folder, and not affected by the bug.
33
- //
34
- // `--force` is conditional: it bypasses the package cache so file:/link:
35
- // deps re-copy their current on-disk source into node_modules. Bun's
36
- // file-dep cache is keyed on name+version, so without --force, edits to
37
- // a `file:..` typeclaw never reach the container after the first install.
26
+ // `--linker=hoisted` sidesteps a deadlock in Bun 1.3.x's isolated linker
27
+ // (the default since 1.3.0). When any single package fetch fails — 401,
28
+ // SHA-512 mismatch, transient registry 5xx, the kind of flake that's
29
+ // routine on GitHub Actions shared-IP runners the isolated linker
30
+ // hangs the process indefinitely instead of erroring out
31
+ // (oven-sh/bun#26341, oven-sh/bun#29646). `bun install` runs here over
32
+ // ~500 transitive packages with no lockfile, so the odds of triggering
33
+ // the bug are non-trivial. Hoisted is the fallback strategy bun shipped
34
+ // before 1.3 slightly slower for huge monorepos, indistinguishable
35
+ // for an agent folder, and not affected by the bug.
36
+ //
37
+ // `--force` is conditional: it bypasses the package cache so file:/link:
38
+ // deps re-copy their current on-disk source into node_modules. Bun's
39
+ // file-dep cache is keyed on name+version, so without --force, edits to
40
+ // a `file:..` typeclaw never reach the container after the first install.
41
+ return await runTimedBunProcess({
38
42
  cmd: opts?.force ? ['bun', 'install', '--linker=hoisted', '--force'] : ['bun', 'install', '--linker=hoisted'],
39
43
  cwd,
40
- stdout: 'pipe',
41
- stderr: 'pipe',
44
+ timeoutMs: opts?.timeoutMs ?? INSTALL_TIMEOUT_MS,
45
+ spawn: opts?.spawn ?? bun.spawn,
46
+ timeoutReason: (seconds) => `bun install timed out after ${seconds}s`,
47
+ failureReason: (code, stderr) => `bun install exited with code ${code}: ${stderr.trim() || 'no stderr'}`,
42
48
  })
43
- const code = await proc.exited
44
- if (code === 0) return { ok: true }
45
- const stderr = await new Response(proc.stderr).text()
46
- return { ok: false, reason: `bun install exited with code ${code}: ${stderr.trim() || 'no stderr'}` }
47
49
  } catch (error) {
48
50
  return { ok: false, reason: error instanceof Error ? error.message : String(error) }
49
51
  }
@@ -55,30 +57,62 @@ export async function runBunInstall(cwd: string, opts?: InstallRunnerOptions): P
55
57
  // install` would no-op when the existing lockfile entry already satisfies
56
58
  // an in-range spec — which is the exact regression auto-upgrade exists to
57
59
  // prevent).
58
- export type UpdateRunner = (cwd: string, pkg: string) => Promise<InstallResult>
60
+ export type UpdateRunnerOptions = Pick<InstallRunnerOptions, 'timeoutMs' | 'spawn'>
59
61
 
60
- export async function runBunUpdate(cwd: string, pkg: string): Promise<InstallResult> {
62
+ export type UpdateRunner = (cwd: string, pkg: string, opts?: UpdateRunnerOptions) => Promise<InstallResult>
63
+
64
+ export async function runBunUpdate(cwd: string, pkg: string, opts?: UpdateRunnerOptions): Promise<InstallResult> {
61
65
  const bun = (globalThis as { Bun?: { spawn: typeof Bun.spawn } }).Bun
62
66
  if (!bun) return { ok: false, reason: 'bun runtime not available' }
63
67
  try {
64
- const proc = bun.spawn({
65
- // `bun update <pkg> --latest` re-resolves <pkg> against the registry,
66
- // capped by the spec in package.json. For a caret/tilde range this
67
- // pulls the highest in-range version (the case `bun install` won't
68
- // upgrade because the lockfile already satisfies the spec). For an
69
- // exact pin it's effectively a force re-fetch of that exact version.
70
- // `--linker=hoisted` for the same Bun 1.3.x deadlock reason as
71
- // runBunInstall above.
68
+ // `bun update <pkg> --latest` re-resolves <pkg> against the registry,
69
+ // capped by the spec in package.json. For a caret/tilde range this
70
+ // pulls the highest in-range version (the case `bun install` won't
71
+ // upgrade because the lockfile already satisfies the spec). For an
72
+ // exact pin it's effectively a force re-fetch of that exact version.
73
+ // `--linker=hoisted` for the same Bun 1.3.x deadlock reason as
74
+ // runBunInstall above.
75
+ return await runTimedBunProcess({
72
76
  cmd: ['bun', 'update', pkg, '--latest', '--linker=hoisted'],
73
77
  cwd,
74
- stdout: 'pipe',
75
- stderr: 'pipe',
78
+ timeoutMs: opts?.timeoutMs ?? INSTALL_TIMEOUT_MS,
79
+ spawn: opts?.spawn ?? bun.spawn,
80
+ timeoutReason: (seconds) => `bun update ${pkg} timed out after ${seconds}s`,
81
+ failureReason: (code, stderr) => `bun update ${pkg} exited with code ${code}: ${stderr.trim() || 'no stderr'}`,
76
82
  })
83
+ } catch (error) {
84
+ return { ok: false, reason: error instanceof Error ? error.message : String(error) }
85
+ }
86
+ }
87
+
88
+ async function runTimedBunProcess({
89
+ cmd,
90
+ cwd,
91
+ timeoutMs,
92
+ spawn,
93
+ timeoutReason,
94
+ failureReason,
95
+ }: {
96
+ cmd: string[]
97
+ cwd: string
98
+ timeoutMs: number
99
+ spawn: typeof Bun.spawn
100
+ timeoutReason: (seconds: number) => string
101
+ failureReason: (code: number, stderr: string) => string
102
+ }): Promise<InstallResult> {
103
+ const proc = spawn({ cmd, cwd, stdout: 'pipe', stderr: 'pipe' })
104
+ let timedOut = false
105
+ const timeout = setTimeout(() => {
106
+ timedOut = true
107
+ proc.kill('SIGKILL')
108
+ }, timeoutMs)
109
+ try {
77
110
  const code = await proc.exited
111
+ if (timedOut) return { ok: false, reason: timeoutReason(timeoutMs / 1000) }
78
112
  if (code === 0) return { ok: true }
79
113
  const stderr = await new Response(proc.stderr).text()
80
- return { ok: false, reason: `bun update ${pkg} exited with code ${code}: ${stderr.trim() || 'no stderr'}` }
81
- } catch (error) {
82
- return { ok: false, reason: error instanceof Error ? error.message : String(error) }
114
+ return { ok: false, reason: failureReason(code, stderr) }
115
+ } finally {
116
+ clearTimeout(timeout)
83
117
  }
84
118
  }