SLOPSHOPPER

worktree-ready

Makes a new git worktree of a Laravel app ready to use with no hands: clones vendor, node_modules and built assets with copy-on-write, links it with Valet or…

newguardtoaststatusprocesstimer
A shopper browsing a rack in a slop shop
README

worktree-ready

Make a new git worktree of a Laravel app ready to use with no hands, and tidy it away when you are done.

worktree-ready is a Claude Code mod. When Claude enters a worktree of a Laravel app, it brings the worktree up to the same state as your main checkout, links it as its own .test site over HTTPS, and shows you the URL. When the worktree is exited, it unlinks the site and removes the folder if the work is finished.

Requirements

  • A Claude Code build that loads plugin modules (mods).
  • macOS on APFS. Dependencies are cloned with cp -c, a copy-on-write clone, so the worktree must be on the same volume as the main checkout (worktrees under .claude/worktrees/ always are).
  • Laravel Valet or Herd. Without one the worktree is still set up, it just is not linked.
  • composer, and your JS package manager, on the PATH.

What happens when a worktree is entered

The mod only acts in a linked worktree that has an artisan file. Each step is skipped when it has nothing to do.

  1. Clone vendor, node_modules and public/build from the main checkout with copy-on-write. This takes seconds and shares disk blocks until a file changes. public/hot and bootstrap/cache are never cloned.
  2. Copy .env from the main checkout, if the worktree has none. The worktree shares main's database. A sqlite database is shared by pointing DB_DATABASE at main's file.
  3. Install only what changed. composer install runs when composer.lock differs from main's, and the JS install runs when its lockfile differs. The package manager is picked from the lockfile (bun.lock, pnpm-lock.yaml, yarn.lock or package-lock.json).
  4. Build assets only when needed. The build script runs when the worktree's resources/, package.json or Vite, Tailwind, PostCSS or TypeScript config differ from main's working tree, or when main has no public/build.
  5. Link and secure. The site is linked as <project>-<worktree> (for example acme-blue-heron.test), secured, and checked against links. The word worktree is never part of the name. A name that already points at a different folder is left alone and reported. APP_URL in the worktree's .env is set to the new URL, and VITE_DEV_SERVER_KEY and VITE_DEV_SERVER_CERT to the site's certificate, so the Vite dev server runs on https at that host. Without them, laravel-vite-plugin looks for a certificate named after the folder, finds none, and serves plain http assets that an https page blocks.

Claude does not get control back until this finishes, as with a SessionStart hook, so its first command in the worktree already has what it needs. Progress shows under the prompt.

A worktree that a session starts in (claude --worktree) is set up the same way.

What happens when it is exited

Whether the exit keeps or removes the worktree, the site is unsecured and unlinked, and the mod checks that it is gone from links.

If the exit kept the worktree, the mod then removes the folder and its branch when there is nothing to lose: no uncommitted changes, and the branch has commits of its own that are merged into the default branch. A worktree you entered and left without committing is kept. Anything else is kept, and you are told why once. The same check runs for leftovers at the start of the next session, along with the links of worktrees whose folder no longer exists. Only worktrees under the project's .claude/worktrees/ that the mod set up are ever removed.

Options

OptionDefaultWhat it does
Site toolautovalet, herd or auto. Auto uses whichever is installed and does nothing when both are, with a toast asking you to choose.

Limits

  • A worktree whose branch was squash-merged is reported as not merged, because its commits are not in the default branch's history. It stays until you remove it.
  • A session that ends without exiting the worktree leaves it marked active, so it is kept.
  • Herd's links output is assumed to match Valet's table. It has not been tried.
  • The whole flow was tested against a faked machine. It has not run against a real Herd.

Development

The pure helpers (link names, .env edits, the links table, lockfile and asset checks) are in hooks/lib.ts, and the enter and exit flows in hooks/register.ts. Both are covered by tests that fake the file system, git, composer, the package manager and Valet:

claude plugin validate plugins/worktree-ready
claude plugin test plugins/worktree-ready

License

MIT

Source 2 files
hooks/register.ts 475 lines
1import type { EngineInterface, Register } from 'claude-code'
2
3import { ASSET_INPUTS, CLONES, JS_LOCK_FILES, TOOLS, basename, certBase, chooseTool, jsPlan, linkName, parseLinks, parseWorktree, setEnv, shareSqlite, underWorktrees } from './lib'
4import type { Link, Tool, WorktreeInfo, WorktreeRecord } from './lib'
5
6const PLUGIN = 'worktree-ready'
7const PREFIX = 'wt:'
8const LONG_MS = 600_000
9const SITE_MS = 120_000
10const STALE_LOCK_MS = 1_800_000
11const NOT_LINKED = { url: null, linked: false }
12
13let configured = 'auto'
14
15type Outcome = { exitCode: number; stdout: string; stderr: string }
16
17async function run($: EngineInterface, argv: string[], cwd?: string, timeoutMs?: number): Promise<Outcome> {
18  try {
19    return await $.process.run(argv, { ...(cwd ? { cwd } : {}), ...(timeoutMs ? { timeoutMs } : {}) })
20  } catch (error) {
21    return { exitCode: 127, stdout: '', stderr: String(error) }
22  }
23}
24
25const passed = (outcome: Outcome): boolean => outcome.exitCode === 0
26
27const deniedOrFailed = (result: unknown): boolean => {
28  const shape = result as { deny?: unknown; isError?: unknown } | undefined
29
30  return shape?.deny !== undefined || shape?.isError === true
31}
32
33const outcomeOf = (result: unknown): { worktreePath?: string; action?: string } | undefined => {
34  const inner = (result as { result?: unknown } | undefined)?.result
35
36  return typeof inner === 'object' && inner !== null ? (inner as { worktreePath?: string; action?: string }) : undefined
37}
38
39function must(outcome: Outcome, what: string) {
40  if (!passed(outcome)) {
41    throw new Error(`${what}: ${outcome.stderr.trim().slice(0, 160) || `exit ${outcome.exitCode}`}`)
42  }
43}
44
45const say = ($: EngineInterface, text?: string) => $.ui.status(text)
46
47const toast = ($: EngineInterface, text: string, timeoutMs = 6000) => $.ui.toast(text, { timeoutMs })
48
49const has = ($: EngineInterface, root: string, rel: string) => $.fs.exists(`${root}/${rel}`)
50
51const recordKey = (path: string) => `${PREFIX}${path}`
52
53async function readRecord($: EngineInterface, path: string): Promise<WorktreeRecord | null> {
54  return ((await $.store.get(recordKey(path))) as WorktreeRecord | undefined) ?? null
55}
56
57async function saveRecord($: EngineInterface, record: WorktreeRecord) {
58  await $.store.set(recordKey(record.path), record)
59}
60
61async function worktreeAt($: EngineInterface, dir: string): Promise<WorktreeInfo | null> {
62  const rev = await run($, ['git', 'rev-parse', '--path-format=absolute', '--show-toplevel', '--git-dir', '--git-common-dir'], dir)
63
64  return passed(rev) ? parseWorktree(rev.stdout) : null
65}
66
67async function findTool($: EngineInterface, notify: boolean): Promise<Tool | null> {
68  const installed: Tool[] = []
69
70  for (const tool of TOOLS) {
71    if (passed(await run($, ['sh', '-c', `command -v ${tool}`]))) {
72      installed.push(tool)
73    }
74  }
75
76  const { tool, reason } = chooseTool(configured, installed)
77
78  if (notify && reason === 'both') {
79    toast($, 'Valet and Herd are both installed: set the "Site tool" option to choose one', 10_000)
80  }
81
82  if (notify && reason === 'missing') {
83    toast($, `${configured} is not installed, so the site is not linked`, 10_000)
84  }
85
86  return tool
87}
88
89async function listLinks($: EngineInterface, tool: Tool): Promise<Link[] | null> {
90  const listed = await run($, [tool, 'links'])
91
92  return passed(listed) ? parseLinks(listed.stdout) : null
93}
94
95async function copyEnv($: EngineInterface, { main, top }: WorktreeInfo) {
96  if ((await has($, top, '.env')) || !(await has($, main, '.env'))) {
97    return
98  }
99
100  await $.fs.write(`${top}/.env`, String(await $.fs.read(`${main}/.env`)))
101}
102
103async function editEnv($: EngineInterface, top: string, change: (text: string) => string) {
104  if (!(await has($, top, '.env'))) {
105    return
106  }
107
108  const text = String(await $.fs.read(`${top}/.env`))
109  const next = change(text)
110
111  if (next !== text) {
112    await $.fs.write(`${top}/.env`, next)
113  }
114}
115
116async function shareDatabase($: EngineInterface, { main, top }: WorktreeInfo) {
117  const database = `${main}/database/database.sqlite`
118
119  if (await $.fs.exists(database)) {
120    await editEnv($, top, text => shareSqlite(text, database))
121  }
122}
123
124const setAppUrl = ($: EngineInterface, top: string, url: string) => editEnv($, top, text => setEnv(text, 'APP_URL', url))
125
126async function useSiteCert($: EngineInterface, top: string, tool: Tool, url: string) {
127  const home = (await run($, ['printenv', 'HOME'])).stdout.trim()
128  const base = certBase(tool, home, new URL(url).hostname)
129
130  if (!home || !(await $.fs.exists(`${base}.key`)) || !(await $.fs.exists(`${base}.crt`))) {
131    return
132  }
133
134  await editEnv($, top, text => setEnv(setEnv(text, 'VITE_DEV_SERVER_KEY', `${base}.key`), 'VITE_DEV_SERVER_CERT', `${base}.crt`))
135}
136
137async function lockDiffers($: EngineInterface, { main, top }: WorktreeInfo, file: string): Promise<boolean> {
138  return !passed(await run($, ['cmp', '-s', `${main}/${file}`, `${top}/${file}`]))
139}
140
141async function hasBuildScript($: EngineInterface, top: string): Promise<boolean> {
142  if (!(await has($, top, 'package.json'))) {
143    return false
144  }
145
146  try {
147    const scripts = (JSON.parse(String(await $.fs.read(`${top}/package.json`))) as { scripts?: Record<string, unknown> }).scripts
148
149    return typeof scripts?.build === 'string'
150  } catch {
151    return false
152  }
153}
154
155async function needsBuild($: EngineInterface, { main, top }: WorktreeInfo): Promise<boolean> {
156  if (!(await has($, top, 'public/build'))) {
157    return true
158  }
159
160  const head = await run($, ['git', 'rev-parse', 'HEAD'], top)
161
162  if (!passed(head)) {
163    return true
164  }
165
166  // Main's working tree is what its public/build was built from, so that is what the worktree's commit is compared with.
167  return !passed(await run($, ['git', '-C', main, 'diff', '--quiet', head.stdout.trim(), '--', ...ASSET_INPUTS]))
168}
169
170async function link($: EngineInterface, tool: Tool, name: string, top: string): Promise<{ url: string | null; linked: boolean }> {
171  const clash = (await listLinks($, tool))?.find(site => site.name === name)
172
173  if (clash && clash.path !== top) {
174    toast($, `${name} already points at ${clash.path}, so it was not linked`, 10_000)
175
176    return NOT_LINKED
177  }
178
179  if (!clash && !passed(await run($, [tool, 'link', name], top, SITE_MS))) {
180    toast($, `${tool} link ${name} failed`, 10_000)
181
182    return NOT_LINKED
183  }
184
185  await run($, [tool, 'secure', name], top, SITE_MS)
186
187  const site = (await listLinks($, tool))?.find(entry => entry.name === name && entry.path === top)
188
189  if (!site?.secure) {
190    toast($, `${name} is linked but not secure`, 10_000)
191  }
192
193  return { url: site?.url ?? null, linked: true }
194}
195
196async function setUp($: EngineInterface, info: WorktreeInfo): Promise<string | null> {
197  const { top, main } = info
198  const earlier = await readRecord($, top)
199
200  say($, 'cloning dependencies')
201
202  for (const dir of CLONES) {
203    if ((await has($, main, dir)) && !(await has($, top, dir))) {
204      await run($, ['cp', '-c', '-R', `${main}/${dir}`, `${top}/${dir}`], undefined, LONG_MS)
205    }
206  }
207
208  await copyEnv($, info)
209  await shareDatabase($, info)
210
211  if ((await has($, top, 'composer.lock')) && ((await lockDiffers($, info, 'composer.lock')) || !(await has($, top, 'vendor')))) {
212    say($, 'composer install')
213    must(await run($, ['composer', 'install', '--no-interaction'], top, LONG_MS), 'composer install failed')
214  }
215
216  if ((await has($, main, 'public/storage')) && !(await has($, top, 'public/storage'))) {
217    await run($, ['php', 'artisan', 'storage:link'], top)
218  }
219
220  const locks: string[] = []
221
222  for (const file of JS_LOCK_FILES) {
223    if (await has($, top, file)) {
224      locks.push(file)
225    }
226  }
227
228  const plan = jsPlan(locks, await hasBuildScript($, top))
229
230  if (plan.install && plan.lock && ((await lockDiffers($, info, plan.lock)) || !(await has($, top, 'node_modules')))) {
231    say($, `${plan.install[0]} install`)
232    must(await run($, plan.install, top, LONG_MS), `${plan.install[0]} install failed`)
233  }
234
235  if (plan.build && (await needsBuild($, info))) {
236    say($, 'building assets')
237    must(await run($, plan.build, top, LONG_MS), 'asset build failed')
238  }
239
240  const project = basename(main)
241  const name = linkName(project, basename(top), top)
242  const tool = await findTool($, true)
243  const site = tool ? await link($, tool, name, top) : NOT_LINKED
244
245  if (site.url) {
246    await setAppUrl($, top, site.url)
247  }
248
249  if (site.url && tool) {
250    await useSiteCert($, top, tool, site.url)
251  }
252
253  const branch = (await run($, ['git', 'rev-parse', '--abbrev-ref', 'HEAD'], top)).stdout.trim()
254  const head = (await run($, ['git', 'rev-parse', 'HEAD'], top)).stdout.trim()
255
256  await saveRecord($, { path: top, main, project, branch, base: earlier?.base ?? head, tool, link: site.linked ? name : null, state: 'active', notified: false })
257  toast($, site.url ? `Worktree ready at ${site.url}` : 'Worktree ready', 15_000)
258
259  if (site.url) {
260    $.ui.log(`${basename(top)} is served at ${site.url}`)
261  }
262
263  return site.url
264}
265
266async function claimLock($: EngineInterface, lock: string): Promise<boolean> {
267  if (passed(await run($, ['mkdir', lock]))) {
268    return true
269  }
270
271  const made = Number((await run($, ['stat', '-f', '%m', lock])).stdout.trim())
272
273  if (!(made > 0) || (await $.clock.now()) - made * 1000 < STALE_LOCK_MS) {
274    return false
275  }
276
277  await run($, ['rmdir', lock])
278
279  return passed(await run($, ['mkdir', lock]))
280}
281
282async function provision($: EngineInterface, dir: string): Promise<string | null> {
283  const info = await worktreeAt($, dir)
284
285  if (!info || !(await has($, info.top, 'artisan'))) {
286    return null
287  }
288
289  const lock = `${info.gitDir}/${PLUGIN}.lock`
290
291  if (!(await claimLock($, lock))) {
292    toast($, `another setup is running at ${lock}`, 10_000)
293
294    return null
295  }
296
297  try {
298    return await setUp($, info)
299  } catch (error) {
300    toast($, String(error instanceof Error ? error.message : error), 12_000)
301
302    return null
303  } finally {
304    say($)
305    await run($, ['rmdir', lock])
306  }
307}
308
309async function dropSite($: EngineInterface, tool: Tool, name: string) {
310  await run($, [tool, 'unsecure', name], undefined, SITE_MS)
311  await run($, [tool, 'unlink', name], undefined, SITE_MS)
312}
313
314async function unlink($: EngineInterface, record: WorktreeRecord): Promise<WorktreeRecord> {
315  if (!record.link || !record.tool) {
316    return record
317  }
318
319  await dropSite($, record.tool, record.link)
320
321  if ((await listLinks($, record.tool))?.some(site => site.name === record.link)) {
322    toast($, `${record.link} is still linked after unlink`, 10_000)
323
324    return record
325  }
326
327  return { ...record, link: null }
328}
329
330async function isMerged($: EngineInterface, record: WorktreeRecord): Promise<boolean> {
331  if (!record.branch || record.branch === 'HEAD') {
332    return false
333  }
334
335  const head = await run($, ['git', 'rev-parse', 'HEAD'], record.path)
336
337  if (!passed(head) || (record.base !== undefined && head.stdout.trim() === record.base)) {
338    return false
339  }
340
341  const origin = await run($, ['git', '-C', record.main, 'symbolic-ref', '--short', 'refs/remotes/origin/HEAD'])
342
343  for (const ref of [...(passed(origin) ? [origin.stdout.trim()] : []), 'main', 'master']) {
344    if (passed(await run($, ['git', '-C', record.main, 'merge-base', '--is-ancestor', record.branch, ref]))) {
345      return true
346    }
347  }
348
349  return false
350}
351
352async function tryRemove($: EngineInterface, record: WorktreeRecord) {
353  const forget = () => $.store.delete(recordKey(record.path))
354
355  if (!underWorktrees(record.main, record.path)) {
356    await forget()
357
358    return
359  }
360
361  if (!(await $.fs.exists(record.path))) {
362    await forget()
363
364    return
365  }
366
367  const status = await run($, ['git', 'status', '--porcelain'], record.path)
368  const dirty = !passed(status) || status.stdout.trim() !== ''
369
370  if (dirty || !(await isMerged($, record))) {
371    if (!record.notified) {
372      toast($, `${basename(record.path)} was kept: ${dirty ? 'it has uncommitted changes' : 'its work is not merged into the default branch'}`, 10_000)
373      await saveRecord($, { ...record, notified: true })
374    }
375
376    return
377  }
378
379  if (!passed(await run($, ['git', '-C', record.main, 'worktree', 'remove', record.path]))) {
380    toast($, `${basename(record.path)} could not be removed`, 10_000)
381
382    return
383  }
384
385  const branchDropped = passed(await run($, ['git', '-C', record.main, 'branch', '-d', record.branch]))
386
387  await forget()
388  toast($, branchDropped ? `Removed the finished worktree ${basename(record.path)}` : `Removed ${basename(record.path)}, but its branch ${record.branch} was kept`, 10_000)
389}
390
391async function retire($: EngineInterface, top: string, removedByTool: boolean) {
392  const record = await readRecord($, top)
393
394  if (!record) {
395    return
396  }
397
398  // Unlinking goes by site name, so it works after the tool has deleted the directory.
399  const unlinked = await unlink($, record)
400
401  if (removedByTool) {
402    await $.store.delete(recordKey(top))
403
404    return
405  }
406
407  const exited: WorktreeRecord = { ...unlinked, state: 'exited' }
408
409  await saveRecord($, exited)
410  await tryRemove($, exited)
411}
412
413async function sweep($: EngineInterface) {
414  const main = (await $.session.repo().catch(() => null))?.root
415  const here = await $.session.cwd()
416
417  if (!main) {
418    return
419  }
420
421  for (const key of (await $.store.keys()).filter(key => key.startsWith(PREFIX))) {
422    const record = (await $.store.get(key)) as WorktreeRecord | undefined
423
424    if (!record || record.main !== main || record.path === here) {
425      continue
426    }
427
428    if (record.state === 'exited' || !(await $.fs.exists(record.path))) {
429      await tryRemove($, await unlink($, record))
430    }
431  }
432}
433
434export const register: Register = (on, options) => {
435  configured = String(options.tool ?? 'auto')
436
437  on('session.start', async ($, e, next) => {
438    await provision($, await $.session.cwd()).catch(() => undefined)
439
440    $.clock.after(0, () => {
441      void sweep($).catch(() => undefined)
442    })
443
444    return next(e)
445  })
446
447  on('tool.call', { tool: 'EnterWorktree' }, async ($, e, next) => {
448    const result = await next(e)
449    const entered = outcomeOf(result)?.worktreePath
450
451    if (!entered || deniedOrFailed(result)) {
452      return result
453    }
454
455    const url = await provision($, entered).catch(() => null)
456
457    if (!url) {
458      return result
459    }
460
461    return { ...result, context: [...((result as { context?: readonly string[] }).context ?? []), `This worktree is served at ${url}. Tell the user this URL.`] }
462  })
463
464  on('tool.call', { tool: 'ExitWorktree' }, async ($, e, next) => {
465    const result = await next(e)
466    const left = outcomeOf(result)
467
468    if (left?.worktreePath) {
469      await retire($, left.worktreePath, (left.action ?? (e as { action?: string }).action) === 'remove').catch(error => toast($, String(error), 10_000))
470    }
471
472    return result
473  })
474}
475
hooks/lib.ts 135 lines
1export type Tool = 'valet' | 'herd'
2
3export type Link = { name: string; secure: boolean; url: string; path: string }
4
5export type WorktreeInfo = { top: string; gitDir: string; main: string }
6
7export type WorktreeRecord = {
8  path: string
9  main: string
10  project: string
11  branch: string
12  base?: string
13  tool: Tool | null
14  link: string | null
15  state: 'active' | 'exited'
16  notified: boolean
17}
18
19export type JsPlan = { lock: string | null; install: string[] | null; build: string[] | null }
20
21export const TOOLS: Tool[] = ['valet', 'herd']
22
23// Never add public/hot or bootstrap/cache: they carry main's dev-server URL and its cached config.
24export const CLONES = ['vendor', 'node_modules', 'public/build']
25
26const ANSI = /\u001b\[[0-9;]*m/g
27
28const JS_LOCKS: [string, string[], string[]][] = [
29  ['bun.lock', ['bun', 'install', '--frozen-lockfile'], ['bun', 'run', 'build']],
30  ['bun.lockb', ['bun', 'install', '--frozen-lockfile'], ['bun', 'run', 'build']],
31  ['pnpm-lock.yaml', ['pnpm', 'install', '--frozen-lockfile'], ['pnpm', 'run', 'build']],
32  ['yarn.lock', ['yarn', 'install', '--frozen-lockfile'], ['yarn', 'run', 'build']],
33  ['package-lock.json', ['npm', 'ci'], ['npm', 'run', 'build']],
34]
35
36export const JS_LOCK_FILES = JS_LOCKS.map(([lock]) => lock)
37
38export const ASSET_INPUTS = ['resources', 'package.json', ...JS_LOCK_FILES, 'vite.config.*', 'tailwind.config.*', 'postcss.config.*', 'tsconfig.json']
39
40const slug = (text: string): string =>
41  text
42    .toLowerCase()
43    .replace(/[^a-z0-9]+/g, '-')
44    .replace(/^-+|-+$/g, '')
45
46const parts = (text: string): string[] => slug(text).replaceAll('worktree', '').split('-').filter(Boolean)
47
48const hash = (text: string): string => {
49  let value = 5381
50
51  for (const char of text) {
52    value = ((value * 33) ^ char.charCodeAt(0)) >>> 0
53  }
54
55  return value.toString(36).slice(0, 6)
56}
57
58export const linkName = (project: string, worktree: string, path: string): string => {
59  const own = parts(worktree)
60  const name = [...parts(project), ...(own.length > 0 ? own : [hash(path)])].join('-')
61
62  return name.slice(0, 60).replace(/-+$/, '')
63}
64
65export const parseLinks = (stdout: string): Link[] =>
66  stdout
67    .replace(ANSI, '')
68    .split('\n')
69    .filter(line => line.trim().startsWith('|'))
70    .map(line => line.split('|').slice(1, -1).map(cell => cell.trim()))
71    .filter(cells => cells.length >= 4 && cells[0] !== 'Site')
72    .map(([name = '', ssl = '', url = '', path = '']) => ({ name, secure: ssl !== '', url, path }))
73
74export const parseWorktree = (revParse: string): WorktreeInfo | null => {
75  const [top, gitDir, commonDir] = revParse.split('\n').map(line => line.trim())
76
77  if (!top || !gitDir || !commonDir || gitDir === commonDir) {
78    return null
79  }
80
81  return { top, gitDir, main: commonDir.replace(/\/\.git\/?$/, '') }
82}
83
84export const basename = (path: string): string => path.split('/').filter(Boolean).pop() ?? path
85
86export const underWorktrees = (main: string, path: string): boolean => path.startsWith(`${main}/.claude/worktrees/`)
87
88export const chooseTool = (
89  configured: string,
90  installed: Tool[],
91): { tool: Tool | null; reason: 'none' | 'both' | 'missing' | null } => {
92  const wanted = configured.trim().toLowerCase()
93
94  if (wanted === 'valet' || wanted === 'herd') {
95    return installed.includes(wanted) ? { tool: wanted, reason: null } : { tool: null, reason: 'missing' }
96  }
97
98  if (installed.length === 1) {
99    return { tool: installed[0] ?? null, reason: null }
100  }
101
102  return { tool: null, reason: installed.length === 0 ? 'none' : 'both' }
103}
104
105export const jsPlan = (files: string[], hasBuild: boolean): JsPlan => {
106  const found = JS_LOCKS.find(([lock]) => files.includes(lock))
107
108  if (!found) {
109    return { lock: null, install: null, build: null }
110  }
111
112  return { lock: found[0], install: found[1], build: hasBuild ? found[2] : null }
113}
114
115export const certBase = (tool: Tool, home: string, host: string): string =>
116  tool === 'herd' ? `${home}/Library/Application Support/Herd/config/valet/Certificates/${host}` : `${home}/.config/valet/Certificates/${host}`
117
118export const setEnv = (text: string, key: string, value: string): string => {
119  const line = `${key}=${/\s/.test(value) ? `"${value}"` : value}`
120  const pattern = new RegExp(`^${key}=.*$`, 'm')
121
122  if (pattern.test(text)) {
123    return text.replace(pattern, () => line)
124  }
125
126  return `${text}${text === '' || text.endsWith('\n') ? '' : '\n'}${line}\n`
127}
128
129export const shareSqlite = (text: string, mainDatabase: string): string => {
130  const sqlite = /^DB_CONNECTION=["']?sqlite/m.test(text)
131  const absolute = /^DB_DATABASE=["']?\//m.test(text)
132
133  return sqlite && !absolute ? setEnv(text, 'DB_DATABASE', mainDatabase) : text
134}
135