infra-kit 0.1.94 → 0.1.97

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 (49) hide show
  1. package/.eslintcache +1 -1
  2. package/.turbo/turbo-eslint-check.log +1 -1
  3. package/.turbo/turbo-prettier-check.log +1 -1
  4. package/.turbo/turbo-test.log +7 -7
  5. package/.turbo/turbo-ts-check.log +1 -1
  6. package/dist/cli.js +37 -34
  7. package/dist/cli.js.map +4 -4
  8. package/dist/mcp.js +28 -25
  9. package/dist/mcp.js.map +4 -4
  10. package/package.json +3 -3
  11. package/src/commands/doctor/doctor.ts +119 -1
  12. package/src/commands/env-clear/env-clear.ts +1 -1
  13. package/src/commands/env-list/env-list.ts +1 -1
  14. package/src/commands/env-load/env-load.ts +1 -1
  15. package/src/commands/env-status/env-status.ts +1 -1
  16. package/src/commands/gh-merge-dev/gh-merge-dev.ts +1 -1
  17. package/src/commands/gh-release-deliver/gh-release-deliver.ts +1 -1
  18. package/src/commands/gh-release-deploy-all/gh-release-deploy-all.ts +1 -1
  19. package/src/commands/gh-release-deploy-selected/gh-release-deploy-selected.ts +1 -1
  20. package/src/commands/gh-release-list/gh-release-list.ts +1 -1
  21. package/src/commands/init/init.ts +3 -3
  22. package/src/commands/release-create/release-create.ts +1 -1
  23. package/src/commands/release-create-batch/release-create-batch.ts +1 -1
  24. package/src/commands/version/version.ts +1 -1
  25. package/src/commands/worktrees-add/index.ts +2 -1
  26. package/src/commands/worktrees-add/worktrees-add.ts +52 -11
  27. package/src/commands/worktrees-list/worktrees-list.ts +1 -1
  28. package/src/commands/worktrees-open/index.ts +1 -0
  29. package/src/commands/worktrees-open/worktrees-open.ts +197 -0
  30. package/src/commands/worktrees-remove/worktrees-remove.ts +50 -3
  31. package/src/commands/worktrees-sync/worktrees-sync.ts +69 -5
  32. package/src/entry/cli.ts +34 -5
  33. package/src/integrations/clickup/.gitkeep +0 -0
  34. package/src/integrations/cmux/index.ts +1 -0
  35. package/src/integrations/cmux/list-workspace-titles.ts +42 -0
  36. package/src/integrations/cursor/add-folders-to-workspace.ts +84 -0
  37. package/src/integrations/cursor/index.ts +4 -0
  38. package/src/integrations/cursor/reconcile-workspace-folders.ts +90 -0
  39. package/src/integrations/cursor/remove-folders-from-workspace.ts +93 -0
  40. package/src/integrations/cursor/resolve-workspace-path.ts +13 -0
  41. package/src/integrations/doppler/doppler-project.ts +2 -2
  42. package/src/lib/__tests__/infra-kit-config.test.ts +64 -14
  43. package/src/lib/git-utils/git-utils.ts +27 -8
  44. package/src/lib/infra-kit-config/index.ts +2 -0
  45. package/src/lib/infra-kit-config/infra-kit-config.ts +143 -0
  46. package/src/mcp/tools/index.ts +2 -0
  47. package/tsconfig.json +3 -2
  48. package/tsconfig.tsbuildinfo +1 -1
  49. package/src/lib/infra-kit-config.ts +0 -69
@@ -1,14 +1,16 @@
1
1
  import checkbox from '@inquirer/checkbox'
2
2
  import confirm from '@inquirer/confirm'
3
3
  import process from 'node:process'
4
- import { z } from 'zod'
4
+ import { z } from 'zod/v4'
5
5
  import { $ } from 'zx'
6
6
 
7
7
  import { buildCmuxWorkspaceTitle, closeCmuxWorkspaceByTitle } from 'src/integrations/cmux'
8
+ import { removeFoldersFromCursorWorkspace, resolveCursorWorkspacePath } from 'src/integrations/cursor'
8
9
  import { getReleasePRsWithInfo } from 'src/integrations/gh'
9
10
  import { commandEcho } from 'src/lib/command-echo'
10
11
  import { WORKTREES_DIR_SUFFIX } from 'src/lib/constants'
11
12
  import { getCurrentWorktrees, getProjectRoot, getRepoName } from 'src/lib/git-utils'
13
+ import { getInfraKitConfig } from 'src/lib/infra-kit-config'
12
14
  import { logger } from 'src/lib/logger'
13
15
  import { detectReleaseType, formatBranchChoices, getJiraDescriptions } from 'src/lib/release-utils'
14
16
  import type { ReleaseType } from 'src/lib/release-utils'
@@ -114,8 +116,11 @@ export const worktreesRemove = async (options: WorktreeManagementArgs): Promise<
114
116
  branches: selectedReleaseBranches,
115
117
  worktreeDir,
116
118
  repoName,
119
+ allSelected,
117
120
  })
118
121
 
122
+ await syncCursorWorkspaceOnRemove({ removedWorktrees, worktreeDir, projectRoot })
123
+
119
124
  logResults(removedWorktrees)
120
125
 
121
126
  commandEcho.print()
@@ -144,13 +149,14 @@ interface RemoveWorktreesArgs {
144
149
  branches: string[]
145
150
  worktreeDir: string
146
151
  repoName: string
152
+ allSelected: boolean
147
153
  }
148
154
 
149
155
  /**
150
156
  * Remove worktrees for the specified branches and whole folder
151
157
  */
152
158
  const removeWorktrees = async (args: RemoveWorktreesArgs): Promise<string[]> => {
153
- const { branches, worktreeDir, repoName } = args
159
+ const { branches, worktreeDir, repoName, allSelected } = args
154
160
 
155
161
  const results = await Promise.allSettled(
156
162
  branches.map(async (branch) => {
@@ -178,7 +184,7 @@ const removeWorktrees = async (args: RemoveWorktreesArgs): Promise<string[]> =>
178
184
  }
179
185
  }
180
186
 
181
- if (removed.length === branches.length) {
187
+ if (allSelected && removed.length === branches.length) {
182
188
  await $`git worktree prune`
183
189
  await $`rm -rf ${worktreeDir}`
184
190
 
@@ -189,6 +195,47 @@ const removeWorktrees = async (args: RemoveWorktreesArgs): Promise<string[]> =>
189
195
  return removed
190
196
  }
191
197
 
198
+ interface SyncCursorWorkspaceOnRemoveArgs {
199
+ removedWorktrees: string[]
200
+ worktreeDir: string
201
+ projectRoot: string
202
+ }
203
+
204
+ /**
205
+ * Strip removed worktrees from the configured Cursor workspace's `folders` array.
206
+ * No-op if Cursor isn't configured, mode isn't "workspace", or no worktrees were removed.
207
+ */
208
+ const syncCursorWorkspaceOnRemove = async (args: SyncCursorWorkspaceOnRemoveArgs): Promise<void> => {
209
+ const { removedWorktrees, worktreeDir, projectRoot } = args
210
+
211
+ if (removedWorktrees.length === 0) {
212
+ return
213
+ }
214
+
215
+ const config = await getInfraKitConfig()
216
+ const cursorConfig = config.ide?.provider === 'cursor' ? config.ide.config : undefined
217
+
218
+ if (!cursorConfig || cursorConfig.mode !== 'workspace' || !cursorConfig.workspaceConfigPath) {
219
+ return
220
+ }
221
+
222
+ const workspacePath = resolveCursorWorkspacePath(cursorConfig.workspaceConfigPath, projectRoot)
223
+
224
+ const folderPaths = removedWorktrees.map((branch) => {
225
+ return `${worktreeDir}/${branch}`
226
+ })
227
+
228
+ try {
229
+ const { removed } = await removeFoldersFromCursorWorkspace({ workspacePath, folderPaths })
230
+
231
+ if (removed.length > 0) {
232
+ logger.info(`✅ Removed ${removed.length} folder(s) from ${workspacePath}`)
233
+ }
234
+ } catch (error) {
235
+ logger.warn({ error }, `⚠️ Failed to update Cursor workspace at ${workspacePath}`)
236
+ }
237
+ }
238
+
192
239
  /**
193
240
  * Log the results of worktree management
194
241
  */
@@ -1,12 +1,15 @@
1
1
  import confirm from '@inquirer/confirm'
2
2
  import process from 'node:process'
3
- import { z } from 'zod'
3
+ import { z } from 'zod/v4'
4
4
  import { $ } from 'zx'
5
5
 
6
+ import { buildCmuxWorkspaceTitle, closeCmuxWorkspaceByTitle } from 'src/integrations/cmux'
7
+ import { removeFoldersFromCursorWorkspace, resolveCursorWorkspacePath } from 'src/integrations/cursor'
6
8
  import { getReleasePRs } from 'src/integrations/gh'
7
9
  import { commandEcho } from 'src/lib/command-echo'
8
10
  import { WORKTREES_DIR_SUFFIX } from 'src/lib/constants'
9
- import { getCurrentWorktrees, getProjectRoot } from 'src/lib/git-utils'
11
+ import { getCurrentWorktrees, getProjectRoot, getRepoName } from 'src/lib/git-utils'
12
+ import { getInfraKitConfig } from 'src/lib/infra-kit-config'
10
13
  import { logger } from 'src/lib/logger'
11
14
  import type { RequiredConfirmedOptionArg, ToolsExecutionResult } from 'src/types'
12
15
 
@@ -59,7 +62,15 @@ export const worktreesSync = async (options: WorktreeSyncArgs): Promise<ToolsExe
59
62
  currentWorktrees,
60
63
  })
61
64
 
62
- const removedWorktrees = await removeWorktrees(branchesToRemove, worktreeDir)
65
+ const repoName = await getRepoName()
66
+
67
+ const removedWorktrees = await removeWorktrees({
68
+ branches: branchesToRemove,
69
+ worktreeDir,
70
+ repoName,
71
+ })
72
+
73
+ await syncCursorWorkspaceOnRemove({ removedWorktrees, worktreeDir, projectRoot })
63
74
 
64
75
  logResults(removedWorktrees)
65
76
 
@@ -107,16 +118,28 @@ const categorizeWorktrees = (args: CategorizeWorktreesArgs): { branchesToRemove:
107
118
  return { branchesToRemove }
108
119
  }
109
120
 
121
+ interface RemoveWorktreesArgs {
122
+ branches: string[]
123
+ worktreeDir: string
124
+ repoName: string
125
+ }
126
+
110
127
  /**
111
- * Remove worktrees for the specified branches
128
+ * Remove worktrees for the specified branches and close their cmux workspaces
112
129
  */
113
- const removeWorktrees = async (branches: string[], worktreeDir: string): Promise<string[]> => {
130
+ const removeWorktrees = async (args: RemoveWorktreesArgs): Promise<string[]> => {
131
+ const { branches, worktreeDir, repoName } = args
132
+
114
133
  const removed: string[] = []
115
134
 
116
135
  for (const branch of branches) {
117
136
  try {
118
137
  const worktreePath = `${worktreeDir}/${branch}`
119
138
 
139
+ const title = buildCmuxWorkspaceTitle({ repoName, branch })
140
+
141
+ await closeCmuxWorkspaceByTitle(title)
142
+
120
143
  await $`git worktree remove ${worktreePath}`
121
144
  removed.push(branch)
122
145
  } catch (error) {
@@ -127,6 +150,47 @@ const removeWorktrees = async (branches: string[], worktreeDir: string): Promise
127
150
  return removed
128
151
  }
129
152
 
153
+ interface SyncCursorWorkspaceOnRemoveArgs {
154
+ removedWorktrees: string[]
155
+ worktreeDir: string
156
+ projectRoot: string
157
+ }
158
+
159
+ /**
160
+ * Strip removed worktrees from the configured Cursor workspace's `folders` array.
161
+ * No-op if Cursor isn't configured, mode isn't "workspace", or no worktrees were removed.
162
+ */
163
+ const syncCursorWorkspaceOnRemove = async (args: SyncCursorWorkspaceOnRemoveArgs): Promise<void> => {
164
+ const { removedWorktrees, worktreeDir, projectRoot } = args
165
+
166
+ if (removedWorktrees.length === 0) {
167
+ return
168
+ }
169
+
170
+ const config = await getInfraKitConfig()
171
+ const cursorConfig = config.ide?.provider === 'cursor' ? config.ide.config : undefined
172
+
173
+ if (!cursorConfig || cursorConfig.mode !== 'workspace' || !cursorConfig.workspaceConfigPath) {
174
+ return
175
+ }
176
+
177
+ const workspacePath = resolveCursorWorkspacePath(cursorConfig.workspaceConfigPath, projectRoot)
178
+
179
+ const folderPaths = removedWorktrees.map((branch) => {
180
+ return `${worktreeDir}/${branch}`
181
+ })
182
+
183
+ try {
184
+ const { removed: removedEntries } = await removeFoldersFromCursorWorkspace({ workspacePath, folderPaths })
185
+
186
+ if (removedEntries.length > 0) {
187
+ logger.info(`✅ Removed ${removedEntries.length} folder(s) from ${workspacePath}`)
188
+ }
189
+ } catch (error) {
190
+ logger.warn({ error }, `⚠️ Failed to update Cursor workspace at ${workspacePath}`)
191
+ }
192
+ }
193
+
130
194
  /**
131
195
  * Log the results of worktree management
132
196
  */
package/src/entry/cli.ts CHANGED
@@ -16,14 +16,36 @@ import { init } from 'src/commands/init'
16
16
  import { releaseCreate } from 'src/commands/release-create'
17
17
  import { releaseCreateBatch } from 'src/commands/release-create-batch'
18
18
  import { version } from 'src/commands/version'
19
- import { worktreesAdd } from 'src/commands/worktrees-add'
19
+ import { CURSOR_MODES, worktreesAdd } from 'src/commands/worktrees-add'
20
+ import type { CursorMode } from 'src/commands/worktrees-add'
20
21
  import { worktreesList } from 'src/commands/worktrees-list'
22
+ import { worktreesOpen } from 'src/commands/worktrees-open'
21
23
  import { worktreesRemove } from 'src/commands/worktrees-remove'
22
24
  import { worktreesSync } from 'src/commands/worktrees-sync'
23
25
  import { logger } from 'src/lib/logger'
24
26
 
25
27
  const program = new Command()
26
28
 
29
+ const normalizeCursorMode = (value: unknown): CursorMode | undefined => {
30
+ if (typeof value === 'undefined') {
31
+ return undefined
32
+ }
33
+
34
+ if (value === true) {
35
+ return 'workspace'
36
+ }
37
+
38
+ if (value === false) {
39
+ return 'none'
40
+ }
41
+
42
+ if (typeof value === 'string' && (CURSOR_MODES as readonly string[]).includes(value)) {
43
+ return value as CursorMode
44
+ }
45
+
46
+ throw new Error(`Invalid --cursor value "${String(value)}". Expected one of: ${CURSOR_MODES.join(', ')}.`)
47
+ }
48
+
27
49
  const runProgram = async (argv?: string[]): Promise<void> => {
28
50
  try {
29
51
  if (argv) {
@@ -136,8 +158,8 @@ program
136
158
  .option('-y, --yes', 'Skip confirmation prompt')
137
159
  .option('-a, --all', 'Select all active release branches')
138
160
  .option('-v, --versions <versions>', 'Specify versions by comma, e.g. 1.2.5, 1.2.6')
139
- .option('-c, --cursor', 'Open created worktrees in Cursor')
140
- .option('--no-cursor', 'Skip Cursor prompt')
161
+ .option('-c, --cursor [mode]', 'Cursor mode for created worktrees: workspace (default) | windows | none')
162
+ .option('--no-cursor', 'Skip Cursor (alias for --cursor none)')
141
163
  .option('-g, --github-desktop', 'Open created worktrees in GitHub Desktop')
142
164
  .option('--no-github-desktop', 'Skip GitHub Desktop prompt')
143
165
  .option('-m, --cmux', 'Open created worktrees in cmux (3-pane layout)')
@@ -147,7 +169,7 @@ program
147
169
  confirmedCommand: options.yes,
148
170
  all: options.all,
149
171
  versions: options.versions,
150
- cursor: options.cursor,
172
+ cursor: normalizeCursorMode(options.cursor),
151
173
  githubDesktop: options.githubDesktop,
152
174
  cmux: options.cmux,
153
175
  })
@@ -170,6 +192,13 @@ program
170
192
  await worktreesRemove({ confirmedCommand: options.yes, all: options.all, versions: options.versions })
171
193
  })
172
194
 
195
+ program
196
+ .command('worktrees-open')
197
+ .description('Open Cursor + cmux for existing release worktrees (cold-start restore)')
198
+ .action(async () => {
199
+ await worktreesOpen()
200
+ })
201
+
173
202
  program
174
203
  .command('doctor')
175
204
  .description('Check installation and authentication status of gh and doppler CLIs')
@@ -230,7 +259,7 @@ if (process.argv.length <= 2) {
230
259
  'release-deploy-selected',
231
260
  'release-deliver',
232
261
  ]
233
- const worktreeCommands = ['worktrees-add', 'worktrees-list', 'worktrees-remove', 'worktrees-sync']
262
+ const worktreeCommands = ['worktrees-add', 'worktrees-list', 'worktrees-open', 'worktrees-remove', 'worktrees-sync']
234
263
  const envCommands = ['doctor', 'init', 'version', 'env-status', 'env-list', 'env-load', 'env-clear']
235
264
 
236
265
  const commandMap = new Map(
File without changes
@@ -1,3 +1,4 @@
1
1
  export { closeCmuxWorkspaceByTitle } from './close-workspace-by-title'
2
+ export { listCmuxWorkspaceTitles } from './list-workspace-titles'
2
3
  export { openCmuxWorkspaceWithLayout } from './open-workspace-with-layout'
3
4
  export { buildCmuxWorkspaceTitle } from './workspace-title'
@@ -0,0 +1,42 @@
1
+ import { $ } from 'zx'
2
+
3
+ import { logger } from 'src/lib/logger'
4
+
5
+ /**
6
+ * Returns the set of titles for all currently-open cmux workspaces.
7
+ * Returns an empty set if cmux isn't running, the call fails, or the
8
+ * output can't be parsed — callers should treat "empty" as "unknown,
9
+ * proceed as if nothing is open".
10
+ *
11
+ * Each line of `cmux list-workspaces` looks like:
12
+ * " workspace:8 hulyo-monorepo v1.48.0"
13
+ * "* workspace:6 obsidian-workspace [selected]"
14
+ */
15
+ export const listCmuxWorkspaceTitles = async (): Promise<Set<string>> => {
16
+ try {
17
+ const output = (await $`cmux list-workspaces`.quiet()).stdout
18
+
19
+ const titles = new Set<string>()
20
+
21
+ for (const rawLine of output.split('\n')) {
22
+ // eslint-disable-next-line sonarjs/slow-regex, regexp/no-super-linear-backtracking
23
+ const match = rawLine.match(/^[* ]\s*workspace:\d+\s+(.+?)(?:\s+\[selected\])?\s*$/)
24
+
25
+ if (!match) {
26
+ continue
27
+ }
28
+
29
+ const title = match[1]?.trim()
30
+
31
+ if (title) {
32
+ titles.add(title)
33
+ }
34
+ }
35
+
36
+ return titles
37
+ } catch (error) {
38
+ logger.debug({ error }, 'cmux: skipped listing workspace titles')
39
+
40
+ return new Set()
41
+ }
42
+ }
@@ -0,0 +1,84 @@
1
+ import fs from 'node:fs/promises'
2
+ import path from 'node:path'
3
+
4
+ interface AddFoldersToCursorWorkspaceArgs {
5
+ workspacePath: string
6
+ folderPaths: string[]
7
+ }
8
+
9
+ interface AddFoldersToCursorWorkspaceResult {
10
+ added: string[]
11
+ skipped: string[]
12
+ }
13
+
14
+ interface WorkspaceFolderEntry {
15
+ path: string
16
+ name?: string
17
+ }
18
+
19
+ interface WorkspaceFile {
20
+ folders?: WorkspaceFolderEntry[]
21
+ [key: string]: unknown
22
+ }
23
+
24
+ /**
25
+ * Adds folders to a Cursor (`.code-workspace`) file's `folders` array, skipping
26
+ * entries that already point to the same absolute path. Folder paths are written
27
+ * as relative to the workspace file's directory to match Cursor's default style.
28
+ */
29
+ export const addFoldersToCursorWorkspace = async (
30
+ args: AddFoldersToCursorWorkspaceArgs,
31
+ ): Promise<AddFoldersToCursorWorkspaceResult> => {
32
+ const { workspacePath, folderPaths } = args
33
+
34
+ const workspaceDir = path.dirname(workspacePath)
35
+
36
+ let raw: string
37
+
38
+ try {
39
+ raw = await fs.readFile(workspacePath, 'utf-8')
40
+ } catch (error) {
41
+ throw new Error(`Cursor workspace file not found at ${workspacePath}: ${(error as Error).message}`)
42
+ }
43
+
44
+ let parsed: WorkspaceFile
45
+
46
+ try {
47
+ parsed = JSON.parse(raw) as WorkspaceFile
48
+ } catch (error) {
49
+ throw new Error(
50
+ `Failed to parse ${workspacePath} as JSON. Comments (JSONC) are not supported. ${(error as Error).message}`,
51
+ )
52
+ }
53
+
54
+ const existingFolders = parsed.folders ?? []
55
+ const existingAbsolutePaths = new Set(
56
+ existingFolders.map((entry) => {
57
+ return path.resolve(workspaceDir, entry.path)
58
+ }),
59
+ )
60
+
61
+ const added: string[] = []
62
+ const skipped: string[] = []
63
+
64
+ for (const folderPath of folderPaths) {
65
+ const absolutePath = path.resolve(folderPath)
66
+
67
+ if (existingAbsolutePaths.has(absolutePath)) {
68
+ skipped.push(folderPath)
69
+ continue
70
+ }
71
+
72
+ const relativePath = path.relative(workspaceDir, absolutePath)
73
+
74
+ existingFolders.push({ path: relativePath })
75
+ existingAbsolutePaths.add(absolutePath)
76
+ added.push(folderPath)
77
+ }
78
+
79
+ parsed.folders = existingFolders
80
+
81
+ await fs.writeFile(workspacePath, `${JSON.stringify(parsed, null, 2)}\n`, 'utf-8')
82
+
83
+ return { added, skipped }
84
+ }
@@ -0,0 +1,4 @@
1
+ export { addFoldersToCursorWorkspace } from './add-folders-to-workspace'
2
+ export { reconcileCursorWorkspaceFolders } from './reconcile-workspace-folders'
3
+ export { removeFoldersFromCursorWorkspace } from './remove-folders-from-workspace'
4
+ export { resolveCursorWorkspacePath } from './resolve-workspace-path'
@@ -0,0 +1,90 @@
1
+ import fs from 'node:fs/promises'
2
+ import path from 'node:path'
3
+
4
+ import { addFoldersToCursorWorkspace } from './add-folders-to-workspace'
5
+ import { removeFoldersFromCursorWorkspace } from './remove-folders-from-workspace'
6
+
7
+ interface ReconcileCursorWorkspaceFoldersArgs {
8
+ workspacePath: string
9
+ worktreeDir: string
10
+ currentBranches: string[]
11
+ }
12
+
13
+ interface ReconcileCursorWorkspaceFoldersResult {
14
+ added: string[]
15
+ removed: string[]
16
+ }
17
+
18
+ interface WorkspaceFolderEntry {
19
+ path: string
20
+ name?: string
21
+ }
22
+
23
+ interface WorkspaceFile {
24
+ folders?: WorkspaceFolderEntry[]
25
+ [key: string]: unknown
26
+ }
27
+
28
+ /**
29
+ * Reconciles the configured Cursor workspace's `folders` array against the
30
+ * actual set of release worktrees on disk:
31
+ * - Adds any worktree folders that aren't already listed.
32
+ * - Removes entries whose absolute path lives under `${worktreeDir}/release/`
33
+ * but no longer corresponds to a current branch (drift from manual
34
+ * `git worktree remove`, deleted branches, etc.).
35
+ *
36
+ * Non-worktree folder entries are left untouched.
37
+ */
38
+ export const reconcileCursorWorkspaceFolders = async (
39
+ args: ReconcileCursorWorkspaceFoldersArgs,
40
+ ): Promise<ReconcileCursorWorkspaceFoldersResult> => {
41
+ const { workspacePath, worktreeDir, currentBranches } = args
42
+
43
+ const workspaceDir = path.dirname(workspacePath)
44
+ const releaseRoot = path.resolve(`${worktreeDir}/release`)
45
+
46
+ const raw = await fs.readFile(workspacePath, 'utf-8')
47
+ const parsed = JSON.parse(raw) as WorkspaceFile
48
+
49
+ const existingFolders = parsed.folders ?? []
50
+
51
+ const desiredAbsolutePaths = new Set(
52
+ currentBranches.map((branch) => {
53
+ return path.resolve(`${worktreeDir}/${branch}`)
54
+ }),
55
+ )
56
+
57
+ const danglingFolderPaths: string[] = []
58
+
59
+ for (const entry of existingFolders) {
60
+ const entryAbsolutePath = path.resolve(workspaceDir, entry.path)
61
+
62
+ const isReleaseShaped = entryAbsolutePath === releaseRoot || entryAbsolutePath.startsWith(`${releaseRoot}/`)
63
+
64
+ if (isReleaseShaped && !desiredAbsolutePaths.has(entryAbsolutePath)) {
65
+ danglingFolderPaths.push(entryAbsolutePath)
66
+ }
67
+ }
68
+
69
+ let removed: string[] = []
70
+
71
+ if (danglingFolderPaths.length > 0) {
72
+ const result = await removeFoldersFromCursorWorkspace({
73
+ workspacePath,
74
+ folderPaths: danglingFolderPaths,
75
+ })
76
+
77
+ removed = result.removed
78
+ }
79
+
80
+ const desiredFolderPaths = currentBranches.map((branch) => {
81
+ return `${worktreeDir}/${branch}`
82
+ })
83
+
84
+ const { added } =
85
+ desiredFolderPaths.length > 0
86
+ ? await addFoldersToCursorWorkspace({ workspacePath, folderPaths: desiredFolderPaths })
87
+ : { added: [] as string[] }
88
+
89
+ return { added, removed }
90
+ }
@@ -0,0 +1,93 @@
1
+ import fs from 'node:fs/promises'
2
+ import path from 'node:path'
3
+
4
+ interface RemoveFoldersFromCursorWorkspaceArgs {
5
+ workspacePath: string
6
+ folderPaths: string[]
7
+ }
8
+
9
+ interface RemoveFoldersFromCursorWorkspaceResult {
10
+ removed: string[]
11
+ notFound: string[]
12
+ }
13
+
14
+ interface WorkspaceFolderEntry {
15
+ path: string
16
+ name?: string
17
+ }
18
+
19
+ interface WorkspaceFile {
20
+ folders?: WorkspaceFolderEntry[]
21
+ [key: string]: unknown
22
+ }
23
+
24
+ /**
25
+ * Removes folders from a Cursor (`.code-workspace`) file's `folders` array. Entries
26
+ * are matched by resolved absolute path, so relative and absolute entries pointing
27
+ * at the same folder are both removed.
28
+ */
29
+ export const removeFoldersFromCursorWorkspace = async (
30
+ args: RemoveFoldersFromCursorWorkspaceArgs,
31
+ ): Promise<RemoveFoldersFromCursorWorkspaceResult> => {
32
+ const { workspacePath, folderPaths } = args
33
+
34
+ const workspaceDir = path.dirname(workspacePath)
35
+
36
+ let raw: string
37
+
38
+ try {
39
+ raw = await fs.readFile(workspacePath, 'utf-8')
40
+ } catch (error) {
41
+ throw new Error(`Cursor workspace file not found at ${workspacePath}: ${(error as Error).message}`)
42
+ }
43
+
44
+ let parsed: WorkspaceFile
45
+
46
+ try {
47
+ parsed = JSON.parse(raw) as WorkspaceFile
48
+ } catch (error) {
49
+ throw new Error(
50
+ `Failed to parse ${workspacePath} as JSON. Comments (JSONC) are not supported. ${(error as Error).message}`,
51
+ )
52
+ }
53
+
54
+ const existingFolders = parsed.folders ?? []
55
+ const targetAbsolutePaths = new Set(
56
+ folderPaths.map((folderPath) => {
57
+ return path.resolve(folderPath)
58
+ }),
59
+ )
60
+
61
+ const removedAbsolutePaths = new Set<string>()
62
+
63
+ const filteredFolders = existingFolders.filter((entry) => {
64
+ const entryAbsolutePath = path.resolve(workspaceDir, entry.path)
65
+
66
+ if (targetAbsolutePaths.has(entryAbsolutePath)) {
67
+ removedAbsolutePaths.add(entryAbsolutePath)
68
+
69
+ return false
70
+ }
71
+
72
+ return true
73
+ })
74
+
75
+ parsed.folders = filteredFolders
76
+
77
+ await fs.writeFile(workspacePath, `${JSON.stringify(parsed, null, 2)}\n`, 'utf-8')
78
+
79
+ const removed: string[] = []
80
+ const notFound: string[] = []
81
+
82
+ for (const folderPath of folderPaths) {
83
+ const absolutePath = path.resolve(folderPath)
84
+
85
+ if (removedAbsolutePaths.has(absolutePath)) {
86
+ removed.push(folderPath)
87
+ } else {
88
+ notFound.push(folderPath)
89
+ }
90
+ }
91
+
92
+ return { removed, notFound }
93
+ }
@@ -0,0 +1,13 @@
1
+ import path from 'node:path'
2
+
3
+ /**
4
+ * Resolves the configured Cursor workspace path against the project root.
5
+ * Absolute paths are returned unchanged.
6
+ */
7
+ export const resolveCursorWorkspacePath = (configValue: string, projectRoot: string): string => {
8
+ if (path.isAbsolute(configValue)) {
9
+ return configValue
10
+ }
11
+
12
+ return path.resolve(projectRoot, configValue)
13
+ }
@@ -4,7 +4,7 @@ import { getInfraKitConfig } from 'src/lib/infra-kit-config'
4
4
  * Resolve Doppler project name from infra-kit.yml at the project root
5
5
  */
6
6
  export const getDopplerProject = async (): Promise<string> => {
7
- const { dopplerProjectName } = await getInfraKitConfig()
7
+ const { envManagement } = await getInfraKitConfig()
8
8
 
9
- return dopplerProjectName
9
+ return envManagement.config.name
10
10
  }