@imdeadpool/guardex 7.0.43 → 7.1.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 (63) hide show
  1. package/README.md +26 -0
  2. package/package.json +2 -1
  3. package/skills/gx-act/SKILL.md +82 -0
  4. package/src/agents/inspect.js +17 -4
  5. package/src/agents/launch.js +10 -1
  6. package/src/agents/status.js +9 -6
  7. package/src/budget/index.js +2 -1
  8. package/src/cli/args.js +52 -2
  9. package/src/cli/commands/agents.js +364 -0
  10. package/src/cli/commands/bootstrap.js +92 -0
  11. package/src/cli/commands/branch.js +127 -0
  12. package/src/cli/commands/claude.js +674 -0
  13. package/src/cli/commands/doctor.js +268 -0
  14. package/src/cli/commands/finish.js +26 -0
  15. package/src/cli/commands/mcp.js +122 -0
  16. package/src/cli/commands/misc.js +304 -0
  17. package/src/cli/commands/pr.js +439 -0
  18. package/src/cli/commands/prompt.js +92 -0
  19. package/src/cli/commands/release.js +305 -0
  20. package/src/cli/commands/report.js +244 -0
  21. package/src/cli/commands/review.js +32 -0
  22. package/src/cli/commands/setup.js +242 -0
  23. package/src/cli/commands/status.js +338 -0
  24. package/src/cli/commands/watch.js +234 -0
  25. package/src/cli/main.js +68 -3726
  26. package/src/cli/shared/repo-env.js +161 -0
  27. package/src/cli/shared/sandbox.js +417 -0
  28. package/src/cli/shared/scaffolding.js +535 -0
  29. package/src/cli/shared/toolchain-shims.js +420 -0
  30. package/src/context.js +229 -11
  31. package/src/core/runtime.js +6 -1
  32. package/src/doctor/index.js +42 -13
  33. package/src/finish/index.js +147 -5
  34. package/src/finish/preflight.js +177 -0
  35. package/src/finish/review-gate.js +182 -0
  36. package/src/git/index.js +446 -4
  37. package/src/hooks/index.js +0 -64
  38. package/src/mcp/collect.js +370 -0
  39. package/src/mcp/server.js +157 -0
  40. package/src/output/index.js +67 -1
  41. package/src/pr-review.js +23 -0
  42. package/src/pr.js +381 -0
  43. package/src/sandbox/index.js +13 -2
  44. package/src/scaffold/agent-worktree-prep.js +213 -0
  45. package/src/scaffold/index.js +108 -10
  46. package/src/speckit/index.js +226 -0
  47. package/src/terminal/index.js +1 -76
  48. package/src/terminal/tmux.js +0 -1
  49. package/src/toolchain/index.js +20 -0
  50. package/templates/AGENTS.monorepo-apps.md +26 -0
  51. package/templates/AGENTS.multiagent-safety.md +61 -347
  52. package/templates/AGENTS.multiagent-safety.min.md +11 -0
  53. package/templates/codex/skills/gx-act/SKILL.md +82 -0
  54. package/templates/githooks/pre-commit +22 -19
  55. package/templates/scripts/agent-branch-finish.sh +8 -30
  56. package/templates/scripts/agent-branch-merge.sh +4 -1
  57. package/templates/scripts/agent-branch-start.sh +88 -3
  58. package/templates/scripts/agent-preflight.sh +31 -5
  59. package/templates/scripts/agent-worktree-prune.sh +1 -1
  60. package/templates/scripts/codex-agent.sh +0 -91
  61. package/src/agents/detect.js +0 -160
  62. package/src/cockpit/keybindings.js +0 -224
  63. package/src/cockpit/layout.js +0 -224
package/src/pr-review.js CHANGED
@@ -153,6 +153,12 @@ function runProviderReview(provider, diff, repoRoot, timeoutMs, runner = run) {
153
153
  if (result.status !== 0) {
154
154
  throw new Error(`${provider} review failed${result.stderr ? `\n${result.stderr.trim()}` : ''}`);
155
155
  }
156
+ // A compliant provider always emits the findings JSON object (empty array when
157
+ // nothing is wrong). Empty stdout means the review did NOT run — fail closed so
158
+ // a silent no-op is never mistaken for "clean" by the merge gate.
159
+ if (!(result.stdout || '').trim()) {
160
+ throw new Error(`${provider} review returned no output (review did not run)`);
161
+ }
156
162
  return normalizeFindings(result.stdout || '');
157
163
  }
158
164
 
@@ -218,6 +224,22 @@ function runPrReview(options, deps = {}) {
218
224
  return { posted: true, artifactPath: '', findings };
219
225
  }
220
226
 
227
+ /**
228
+ * Decide whether a set of review findings clears the merge gate. Blocks on any
229
+ * finding whose severity is in `blockSeverities` (high + critical by default;
230
+ * low/medium are advisory). Pure + synchronous so it is trivially testable.
231
+ *
232
+ * Fail-closed semantics live in the CALLER: an absent/empty `findings` here is
233
+ * treated as clean, so the caller must independently treat a review that did
234
+ * NOT run (provider threw / timed out) as a block, never as clean.
235
+ */
236
+ function evaluateReviewGate(findings, { blockSeverities = ['high', 'critical'] } = {}) {
237
+ const block = new Set(blockSeverities.map((s) => String(s).toLowerCase()));
238
+ const list = Array.isArray(findings) ? findings : [];
239
+ const blocking = list.filter((f) => f && block.has(String(f.severity).toLowerCase()));
240
+ return { clean: blocking.length === 0, blocking };
241
+ }
242
+
221
243
  function printPrReviewResult(result) {
222
244
  if (result.posted) {
223
245
  console.log(`${TOOL_PREFIX} Posted PR review with ${result.findings.length} finding(s).`);
@@ -237,5 +259,6 @@ module.exports = {
237
259
  normalizeFindings,
238
260
  renderMarkdownReview,
239
261
  runPrReview,
262
+ evaluateReviewGate,
240
263
  printPrReviewResult,
241
264
  };
package/src/pr.js ADDED
@@ -0,0 +1,381 @@
1
+ // PR-from-worktree helpers.
2
+ //
3
+ // Centralizes the operations that the agent + worktree flow needs to drive a
4
+ // pull request: detecting the PR for the current branch, opening one
5
+ // idempotently, pushing + syncing, enabling auto-merge, and polling CI / merge
6
+ // state. The agent-branch-finish.sh script historically owned most of this;
7
+ // these helpers expose the same primitives to the JS CLI so `gx pr ...` can be
8
+ // driven without invoking the shell flow.
9
+
10
+ const { GH_BIN } = require('./context');
11
+ const { run } = require('./core/runtime');
12
+ const {
13
+ resolveRepoRoot,
14
+ currentBranchName,
15
+ hasOriginRemote,
16
+ detectDefaultBaseBranch,
17
+ } = require('./git');
18
+
19
+ const DEFAULT_POLL_INTERVAL_MS = 5_000;
20
+ const DEFAULT_POLL_TIMEOUT_MS = 10 * 60 * 1000;
21
+
22
+ class PrError extends Error {
23
+ constructor(message, { code = 'pr-error', cause = null } = {}) {
24
+ super(message);
25
+ this.name = 'PrError';
26
+ this.code = code;
27
+ if (cause) this.cause = cause;
28
+ }
29
+ }
30
+
31
+ function ghAvailable() {
32
+ const result = run(GH_BIN, ['--version'], { allowFailure: true });
33
+ return result.status === 0;
34
+ }
35
+
36
+ function ghAuthStatus() {
37
+ const result = run(GH_BIN, ['auth', 'status'], { allowFailure: true });
38
+ return {
39
+ authenticated: result.status === 0,
40
+ output: ((result.stdout || '') + (result.stderr || '')).trim(),
41
+ };
42
+ }
43
+
44
+ function ghJson(repoRoot, args) {
45
+ const result = run(GH_BIN, args, { cwd: repoRoot, allowFailure: true });
46
+ if (result.status !== 0) {
47
+ return { ok: false, error: (result.stderr || result.stdout || '').trim(), data: null };
48
+ }
49
+ const raw = (result.stdout || '').trim();
50
+ if (!raw) return { ok: true, data: null, error: null };
51
+ try {
52
+ return { ok: true, data: JSON.parse(raw), error: null };
53
+ } catch (error) {
54
+ return { ok: false, data: null, error: `gh JSON parse: ${error.message}` };
55
+ }
56
+ }
57
+
58
+ /**
59
+ * Resolve the PR for `branch` if any exists on the origin remote.
60
+ * Returns null when no PR is open (or any are merged / closed only).
61
+ */
62
+ function findOpenPrForBranch(repoRoot, branch) {
63
+ const result = ghJson(repoRoot, [
64
+ 'pr', 'list',
65
+ '--head', branch,
66
+ '--state', 'open',
67
+ '--json', 'number,url,state,isDraft,mergeable,mergeStateStatus,reviewDecision,headRefName,baseRefName,title,statusCheckRollup',
68
+ '--limit', '5',
69
+ ]);
70
+ if (!result.ok) {
71
+ throw new PrError(`gh pr list failed: ${result.error}`, { code: 'gh-list-failed' });
72
+ }
73
+ const items = Array.isArray(result.data) ? result.data : [];
74
+ if (items.length === 0) return null;
75
+ return items[0];
76
+ }
77
+
78
+ /**
79
+ * Resolve the most recent PR for `branch` regardless of state. Useful when
80
+ * the agent has already merged a previous PR for this branch.
81
+ */
82
+ function findLatestPrForBranch(repoRoot, branch) {
83
+ const result = ghJson(repoRoot, [
84
+ 'pr', 'list',
85
+ '--head', branch,
86
+ '--state', 'all',
87
+ '--json', 'number,url,state,isDraft,mergedAt,headRefName,baseRefName,title',
88
+ '--limit', '5',
89
+ ]);
90
+ if (!result.ok) {
91
+ throw new PrError(`gh pr list failed: ${result.error}`, { code: 'gh-list-failed' });
92
+ }
93
+ const items = Array.isArray(result.data) ? result.data : [];
94
+ if (items.length === 0) return null;
95
+ return items[0];
96
+ }
97
+
98
+ /**
99
+ * List ALL open PRs for the repo on origin in a single gh call, so callers can
100
+ * correlate many branches to their PRs without one gh round-trip per branch.
101
+ * Best-effort: never throws. Returns `{ prs, error }` so callers can tell a
102
+ * genuine "no open PRs" (`error: null`) from a failed lookup (gh missing /
103
+ * unauthenticated / offline → `error` set, `prs: []`).
104
+ * NB: `limit` (default 100) bounds correlation COVERAGE, not just payload size —
105
+ * a repo with more than `limit` open PRs may leave some lanes showing pr:null.
106
+ */
107
+ function listOpenPrsForRepo(repoRoot, { limit = 100 } = {}) {
108
+ const result = ghJson(repoRoot, [
109
+ 'pr', 'list',
110
+ '--state', 'open',
111
+ '--json', 'number,url,state,isDraft,mergeable,mergeStateStatus,reviewDecision,headRefName,baseRefName,title',
112
+ '--limit', String(limit),
113
+ ]);
114
+ if (!result.ok) return { prs: [], error: result.error || 'gh pr list failed' };
115
+ if (!Array.isArray(result.data)) return { prs: [], error: null };
116
+ return { prs: result.data, error: null };
117
+ }
118
+
119
+ function pushBranch(repoRoot, branch, { setUpstream = true } = {}) {
120
+ const args = ['push'];
121
+ if (setUpstream) args.push('-u');
122
+ args.push('origin', branch);
123
+ const result = run('git', args, { cwd: repoRoot, allowFailure: true });
124
+ return {
125
+ ok: result.status === 0,
126
+ output: ((result.stdout || '') + (result.stderr || '')).trim(),
127
+ };
128
+ }
129
+
130
+ function defaultPrTitleFromCommit(repoRoot, branch) {
131
+ const result = run('git', ['log', '-1', '--pretty=%s', branch], {
132
+ cwd: repoRoot, allowFailure: true,
133
+ });
134
+ if (result.status !== 0) return branch;
135
+ const subject = (result.stdout || '').trim();
136
+ return subject || branch;
137
+ }
138
+
139
+ function defaultPrBodyFromCommits(repoRoot, branch, baseBranch) {
140
+ const result = run('git', [
141
+ 'log',
142
+ `${baseBranch}..${branch}`,
143
+ '--pretty=format:- %s',
144
+ ], { cwd: repoRoot, allowFailure: true });
145
+ const list = (result.stdout || '').trim();
146
+ return [
147
+ '## Summary',
148
+ list || '- (no commits between base and branch yet)',
149
+ '',
150
+ '## Test plan',
151
+ '- [ ] verified locally',
152
+ ].join('\n');
153
+ }
154
+
155
+ /**
156
+ * Create (or fetch existing) PR for the current worktree branch. Idempotent:
157
+ * if an open PR is already associated with the branch, returns it without
158
+ * creating a duplicate.
159
+ *
160
+ * @param {object} options
161
+ * @param {string} options.repoRoot
162
+ * @param {string} options.branch
163
+ * @param {string} [options.base]
164
+ * @param {string} [options.title]
165
+ * @param {string} [options.body]
166
+ * @param {boolean} [options.draft]
167
+ * @param {boolean} [options.push]
168
+ */
169
+ function openPullRequest(options) {
170
+ const repoRoot = options.repoRoot;
171
+ const branch = options.branch;
172
+ if (!repoRoot) throw new PrError('repoRoot required', { code: 'bad-arg' });
173
+ if (!branch) throw new PrError('branch required', { code: 'bad-arg' });
174
+
175
+ if (!ghAvailable()) {
176
+ throw new PrError('gh CLI not installed. Install from https://cli.github.com/', {
177
+ code: 'gh-missing',
178
+ });
179
+ }
180
+ const auth = ghAuthStatus();
181
+ if (!auth.authenticated) {
182
+ throw new PrError(
183
+ `GitHub CLI is not authenticated. Run 'gh auth login' or set GITHUB_TOKEN.\n${auth.output}`,
184
+ { code: 'gh-unauthenticated' },
185
+ );
186
+ }
187
+ if (!hasOriginRemote(repoRoot)) {
188
+ throw new PrError('No `origin` remote configured for this repo', {
189
+ code: 'no-origin',
190
+ });
191
+ }
192
+
193
+ // Try to find an existing open PR first.
194
+ const existing = findOpenPrForBranch(repoRoot, branch);
195
+ if (existing) {
196
+ return { created: false, pr: existing };
197
+ }
198
+
199
+ if (options.push !== false) {
200
+ const push = pushBranch(repoRoot, branch);
201
+ if (!push.ok) {
202
+ throw new PrError(`git push failed:\n${push.output}`, { code: 'push-failed' });
203
+ }
204
+ }
205
+
206
+ const base = options.base || detectDefaultBaseBranch(repoRoot);
207
+ const title = options.title || defaultPrTitleFromCommit(repoRoot, branch);
208
+ const body = options.body || defaultPrBodyFromCommits(repoRoot, branch, base);
209
+
210
+ const args = [
211
+ 'pr', 'create',
212
+ '--base', base,
213
+ '--head', branch,
214
+ '--title', title,
215
+ '--body', body,
216
+ ];
217
+ if (options.draft !== false) args.push('--draft');
218
+
219
+ const result = run(GH_BIN, args, { cwd: repoRoot, allowFailure: true });
220
+ if (result.status !== 0) {
221
+ throw new PrError(
222
+ `gh pr create failed:\n${(result.stderr || result.stdout || '').trim()}`,
223
+ { code: 'gh-create-failed' },
224
+ );
225
+ }
226
+
227
+ const created = findOpenPrForBranch(repoRoot, branch);
228
+ if (!created) {
229
+ throw new PrError('PR appears to have been created but could not be located on origin', {
230
+ code: 'pr-lookup-failed',
231
+ });
232
+ }
233
+ return { created: true, pr: created };
234
+ }
235
+
236
+ /**
237
+ * Fetch the live status of the PR for `branch`. Returns null when no open PR
238
+ * exists. Status checks are summarized.
239
+ */
240
+ function getPullRequestStatus(repoRoot, branch) {
241
+ const pr = findOpenPrForBranch(repoRoot, branch);
242
+ if (!pr) return null;
243
+
244
+ const checks = Array.isArray(pr.statusCheckRollup) ? pr.statusCheckRollup : [];
245
+ const summary = checks.reduce(
246
+ (acc, check) => {
247
+ const state = String(check?.conclusion || check?.status || '').toUpperCase();
248
+ if (state === 'SUCCESS') acc.success += 1;
249
+ else if (state === 'FAILURE' || state === 'ERROR' || state === 'TIMED_OUT') acc.failed += 1;
250
+ else if (state === 'PENDING' || state === 'IN_PROGRESS' || state === 'QUEUED') acc.pending += 1;
251
+ else if (state === 'CANCELLED') acc.cancelled += 1;
252
+ else acc.other += 1;
253
+ return acc;
254
+ },
255
+ { success: 0, failed: 0, pending: 0, cancelled: 0, other: 0, total: checks.length },
256
+ );
257
+
258
+ return {
259
+ number: pr.number,
260
+ url: pr.url,
261
+ state: pr.state,
262
+ isDraft: pr.isDraft,
263
+ mergeable: pr.mergeable,
264
+ mergeStateStatus: pr.mergeStateStatus,
265
+ reviewDecision: pr.reviewDecision,
266
+ title: pr.title,
267
+ head: pr.headRefName,
268
+ base: pr.baseRefName,
269
+ checks: summary,
270
+ };
271
+ }
272
+
273
+ function enableAutoMerge(repoRoot, prNumber, { strategy = 'squash' } = {}) {
274
+ const flag = strategy === 'merge'
275
+ ? '--merge'
276
+ : strategy === 'rebase' ? '--rebase' : '--squash';
277
+ const result = run(GH_BIN, [
278
+ 'pr', 'merge', String(prNumber),
279
+ '--auto', flag, '--delete-branch',
280
+ ], { cwd: repoRoot, allowFailure: true });
281
+ return {
282
+ ok: result.status === 0,
283
+ output: ((result.stdout || '') + (result.stderr || '')).trim(),
284
+ };
285
+ }
286
+
287
+ function markPullRequestReady(repoRoot, prNumber) {
288
+ const result = run(GH_BIN, ['pr', 'ready', String(prNumber)], {
289
+ cwd: repoRoot, allowFailure: true,
290
+ });
291
+ return {
292
+ ok: result.status === 0,
293
+ output: ((result.stdout || '') + (result.stderr || '')).trim(),
294
+ };
295
+ }
296
+
297
+ /**
298
+ * Watch a PR's CI + merge state until it merges, fails, or times out.
299
+ *
300
+ * Returns a structured result; does not throw on non-success states. Used by
301
+ * `gx pr watch`. `onEvent` is called for every poll tick with the current
302
+ * status snapshot, so callers can render progress.
303
+ */
304
+ async function watchPullRequest(options) {
305
+ const {
306
+ repoRoot,
307
+ branch,
308
+ intervalMs = DEFAULT_POLL_INTERVAL_MS,
309
+ timeoutMs = DEFAULT_POLL_TIMEOUT_MS,
310
+ onEvent = () => {},
311
+ } = options;
312
+
313
+ const deadline = Date.now() + timeoutMs;
314
+
315
+ // eslint-disable-next-line no-constant-condition
316
+ while (true) {
317
+ // Check open PRs first.
318
+ const open = findOpenPrForBranch(repoRoot, branch);
319
+ if (!open) {
320
+ const latest = findLatestPrForBranch(repoRoot, branch);
321
+ if (latest && latest.state === 'MERGED') {
322
+ onEvent({ phase: 'merged', pr: latest });
323
+ return { status: 'merged', pr: latest };
324
+ }
325
+ if (latest && latest.state === 'CLOSED') {
326
+ onEvent({ phase: 'closed', pr: latest });
327
+ return { status: 'closed', pr: latest };
328
+ }
329
+ onEvent({ phase: 'no-pr', pr: null });
330
+ return { status: 'no-pr', pr: null };
331
+ }
332
+
333
+ const snapshot = getPullRequestStatus(repoRoot, branch);
334
+ onEvent({ phase: 'polling', pr: snapshot });
335
+
336
+ if (snapshot && snapshot.checks.failed > 0) {
337
+ return { status: 'checks-failed', pr: snapshot };
338
+ }
339
+ if (
340
+ snapshot
341
+ && !snapshot.isDraft
342
+ && snapshot.mergeable === 'MERGEABLE'
343
+ && snapshot.checks.pending === 0
344
+ && snapshot.checks.failed === 0
345
+ && snapshot.checks.total > 0
346
+ ) {
347
+ // CI is green and PR is non-draft + mergeable; the merge itself will be
348
+ // driven by auto-merge or a separate `gh pr merge` invocation.
349
+ onEvent({ phase: 'ready', pr: snapshot });
350
+ }
351
+
352
+ if (Date.now() >= deadline) {
353
+ return { status: 'timeout', pr: snapshot };
354
+ }
355
+ await new Promise((resolve) => setTimeout(resolve, intervalMs));
356
+ }
357
+ }
358
+
359
+ function resolveRepoAndBranch(target) {
360
+ const repoRoot = resolveRepoRoot(target || process.cwd());
361
+ const branch = currentBranchName(repoRoot);
362
+ return { repoRoot, branch };
363
+ }
364
+
365
+ module.exports = {
366
+ PrError,
367
+ ghAvailable,
368
+ ghAuthStatus,
369
+ findOpenPrForBranch,
370
+ findLatestPrForBranch,
371
+ listOpenPrsForRepo,
372
+ pushBranch,
373
+ openPullRequest,
374
+ getPullRequestStatus,
375
+ enableAutoMerge,
376
+ markPullRequestReady,
377
+ watchPullRequest,
378
+ resolveRepoAndBranch,
379
+ defaultPrTitleFromCommit,
380
+ defaultPrBodyFromCommits,
381
+ };
@@ -13,6 +13,14 @@ const {
13
13
  gitRefExists,
14
14
  ensureRepoBranch,
15
15
  } = require('../git');
16
+ const { prepareAgentWorktree } = require('../scaffold/agent-worktree-prep');
17
+
18
+ function formatWorktreePrepOps(operations) {
19
+ if (!operations || operations.length === 0) return '';
20
+ return operations
21
+ .map((op) => `[agent-branch-start] worktree-prep ${op.status} ${op.file}${op.note ? ' — ' + op.note : ''}`)
22
+ .join('\n') + '\n';
23
+ }
16
24
 
17
25
  function hasGuardexBootstrapFiles(repoRoot) {
18
26
  const required = [
@@ -195,6 +203,7 @@ function startProtectedBaseSandboxFallback(blocked, sandboxSuffix) {
195
203
  }
196
204
  }
197
205
 
206
+ const prepOps = prepareAgentWorktree(blocked.repoRoot, selectedWorktreePath);
198
207
  return {
199
208
  metadata: {
200
209
  branch: selectedBranch,
@@ -202,7 +211,8 @@ function startProtectedBaseSandboxFallback(blocked, sandboxSuffix) {
202
211
  },
203
212
  stdout:
204
213
  `[agent-branch-start] Created branch: ${selectedBranch}\n` +
205
- `[agent-branch-start] Worktree: ${selectedWorktreePath}\n`,
214
+ `[agent-branch-start] Worktree: ${selectedWorktreePath}\n` +
215
+ formatWorktreePrepOps(prepOps),
206
216
  stderr: addResult.stderr || '',
207
217
  };
208
218
  }
@@ -246,9 +256,10 @@ function startProtectedBaseSandbox(blocked, { taskName, sandboxSuffix }) {
246
256
  return startProtectedBaseSandboxFallback(blocked, sandboxSuffix);
247
257
  }
248
258
 
259
+ const prepOps = prepareAgentWorktree(blocked.repoRoot, worktreePath);
249
260
  return {
250
261
  metadata,
251
- stdout: startResult.stdout || '',
262
+ stdout: (startResult.stdout || '') + formatWorktreePrepOps(prepOps),
252
263
  stderr: startResult.stderr || '',
253
264
  };
254
265
  }
@@ -0,0 +1,213 @@
1
+ 'use strict';
2
+
3
+ // Prepares a freshly-created agent worktree for monorepos that have `apps/*`
4
+ // packages. Two jobs:
5
+ //
6
+ // 1. Symlink the root's `apps/<pkg>/.env` (and friends) into the worktree
7
+ // so backend / storefront / etc. can boot with the same secrets without
8
+ // asking the user to copy gitignored env files manually.
9
+ //
10
+ // 2. Pick a free port per app and write it into the worktree's
11
+ // `apps/<pkg>/.env.local` (which both Vite and Medusa's loadEnv read with
12
+ // higher precedence than `.env`). This stops agent dev servers from
13
+ // colliding with whatever's running in the root worktree on the default
14
+ // port.
15
+ //
16
+ // Both jobs are best-effort: if `apps/` doesn't exist, or there are no env
17
+ // files / no package.json in a subfolder, we silently skip — non-monorepo
18
+ // repos see no change.
19
+
20
+ const fs = require('fs');
21
+ const path = require('path');
22
+ const { spawnSync } = require('child_process');
23
+
24
+ const ENV_FILE_CANDIDATES = [
25
+ '.env',
26
+ '.env.local',
27
+ '.env.development',
28
+ '.env.development.local',
29
+ '.env.production',
30
+ '.env.production.local',
31
+ ];
32
+
33
+ // Port pool by detected app role. Storefronts get the Vite/Next range,
34
+ // backends get the Medusa range, everything else gets a generic mid-range.
35
+ const PORT_POOLS = {
36
+ storefront: 5174,
37
+ backend: 9101,
38
+ default: 8100,
39
+ };
40
+
41
+ function detectAppPackages(repoRoot) {
42
+ const appsRoot = path.join(repoRoot, 'apps');
43
+ let stat;
44
+ try {
45
+ stat = fs.statSync(appsRoot);
46
+ } catch {
47
+ return [];
48
+ }
49
+ if (!stat.isDirectory()) return [];
50
+ let entries;
51
+ try {
52
+ entries = fs.readdirSync(appsRoot, { withFileTypes: true });
53
+ } catch {
54
+ return [];
55
+ }
56
+ return entries
57
+ .filter((e) => e.isDirectory())
58
+ .map((e) => e.name)
59
+ .filter((name) => fs.existsSync(path.join(appsRoot, name, 'package.json')));
60
+ }
61
+
62
+ function inferAppRole(appName) {
63
+ const n = appName.toLowerCase();
64
+ if (n.includes('storefront') || n.includes('frontend') || n.includes('web')) {
65
+ return 'storefront';
66
+ }
67
+ if (n.includes('backend') || n.includes('api') || n.includes('server')) {
68
+ return 'backend';
69
+ }
70
+ return 'default';
71
+ }
72
+
73
+ function isPortFree(port) {
74
+ // Use `lsof` if available — it's on macOS and most Linux distros. Fall
75
+ // back to assuming free when lsof isn't installed (e.g. minimal Alpine
76
+ // CI image); the dev server will fail loudly if it isn't.
77
+ const probe = spawnSync('lsof', ['-iTCP:' + port, '-sTCP:LISTEN', '-t'], {
78
+ stdio: ['ignore', 'pipe', 'pipe'],
79
+ timeout: 2000,
80
+ });
81
+ if (probe.error) return true;
82
+ const out = (probe.stdout && probe.stdout.toString().trim()) || '';
83
+ return out === '';
84
+ }
85
+
86
+ function pickFreePort(start) {
87
+ for (let p = start; p < start + 200; p++) {
88
+ if (isPortFree(p)) return p;
89
+ }
90
+ return null;
91
+ }
92
+
93
+ function symlinkAppEnvFiles(repoRoot, worktreePath, appName) {
94
+ const operations = [];
95
+ const rootAppDir = path.join(repoRoot, 'apps', appName);
96
+ const wtAppDir = path.join(worktreePath, 'apps', appName);
97
+ if (!fs.existsSync(wtAppDir)) {
98
+ return operations;
99
+ }
100
+ for (const candidate of ENV_FILE_CANDIDATES) {
101
+ const rootEnv = path.join(rootAppDir, candidate);
102
+ const wtEnv = path.join(wtAppDir, candidate);
103
+ if (!fs.existsSync(rootEnv)) continue;
104
+ // Don't overwrite an existing file/symlink in the worktree.
105
+ let alreadyExists = false;
106
+ try {
107
+ fs.lstatSync(wtEnv);
108
+ alreadyExists = true;
109
+ } catch (err) {
110
+ if (err.code !== 'ENOENT') throw err;
111
+ }
112
+ if (alreadyExists) {
113
+ operations.push({
114
+ status: 'unchanged',
115
+ file: `apps/${appName}/${candidate}`,
116
+ note: 'already present in worktree',
117
+ });
118
+ continue;
119
+ }
120
+ try {
121
+ fs.symlinkSync(rootEnv, wtEnv);
122
+ operations.push({
123
+ status: 'linked',
124
+ file: `apps/${appName}/${candidate}`,
125
+ note: `→ ${path.relative(worktreePath, rootEnv)}`,
126
+ });
127
+ } catch (err) {
128
+ operations.push({
129
+ status: 'failed',
130
+ file: `apps/${appName}/${candidate}`,
131
+ note: `symlink failed: ${err.message}`,
132
+ });
133
+ }
134
+ }
135
+ return operations;
136
+ }
137
+
138
+ function assignAgentPort(repoRoot, worktreePath, appName, takenPorts) {
139
+ const wtAppDir = path.join(worktreePath, 'apps', appName);
140
+ if (!fs.existsSync(wtAppDir)) {
141
+ return { status: 'skipped', file: `apps/${appName}`, note: 'no app dir in worktree' };
142
+ }
143
+ const role = inferAppRole(appName);
144
+ const base = PORT_POOLS[role] || PORT_POOLS.default;
145
+ let port = pickFreePort(base);
146
+ // Bump past anything we've already assigned this run.
147
+ while (port !== null && takenPorts.has(port)) {
148
+ port = pickFreePort(port + 1);
149
+ }
150
+ if (port === null) {
151
+ return {
152
+ status: 'failed',
153
+ file: `apps/${appName}/.env.local`,
154
+ note: 'no free port found in pool',
155
+ };
156
+ }
157
+ takenPorts.add(port);
158
+
159
+ const envLocalPath = path.join(wtAppDir, '.env.local');
160
+ let existing = '';
161
+ try {
162
+ existing = fs.readFileSync(envLocalPath, 'utf8');
163
+ } catch (err) {
164
+ if (err.code !== 'ENOENT') throw err;
165
+ }
166
+
167
+ // If a .env.local already exists, replace the PORT= line if present,
168
+ // otherwise append. Keep everything else the user might have added.
169
+ const portLine = `PORT=${port}`;
170
+ let next;
171
+ if (/^PORT=/m.test(existing)) {
172
+ next = existing.replace(/^PORT=.*$/m, portLine);
173
+ } else {
174
+ const sep = existing.length === 0 || existing.endsWith('\n') ? '' : '\n';
175
+ next = `${existing}${sep}${portLine}\n`;
176
+ // Header on fresh files so the user knows what wrote this.
177
+ if (existing.length === 0) {
178
+ next = `# Written by gitguardex on worktree creation — agent dev server port.\n${portLine}\n`;
179
+ }
180
+ }
181
+ fs.writeFileSync(envLocalPath, next, 'utf8');
182
+ return {
183
+ status: 'wrote',
184
+ file: `apps/${appName}/.env.local`,
185
+ note: `PORT=${port} (${role} pool)`,
186
+ };
187
+ }
188
+
189
+ function prepareAgentWorktree(repoRoot, worktreePath) {
190
+ if (!repoRoot || !worktreePath) return [];
191
+ if (repoRoot === worktreePath) return [];
192
+ if (!fs.existsSync(worktreePath)) return [];
193
+ const apps = detectAppPackages(repoRoot);
194
+ if (apps.length === 0) return [];
195
+
196
+ const operations = [];
197
+ const takenPorts = new Set();
198
+ for (const appName of apps) {
199
+ operations.push(...symlinkAppEnvFiles(repoRoot, worktreePath, appName));
200
+ operations.push(assignAgentPort(repoRoot, worktreePath, appName, takenPorts));
201
+ }
202
+ return operations;
203
+ }
204
+
205
+ module.exports = {
206
+ detectAppPackages,
207
+ inferAppRole,
208
+ isPortFree,
209
+ pickFreePort,
210
+ symlinkAppEnvFiles,
211
+ assignAgentPort,
212
+ prepareAgentWorktree,
213
+ };