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.
- package/.eslintcache +1 -1
- package/.turbo/turbo-eslint-check.log +1 -1
- package/.turbo/turbo-prettier-check.log +1 -1
- package/.turbo/turbo-test.log +7 -7
- package/.turbo/turbo-ts-check.log +1 -1
- package/dist/cli.js +37 -34
- package/dist/cli.js.map +4 -4
- package/dist/mcp.js +28 -25
- package/dist/mcp.js.map +4 -4
- package/package.json +3 -3
- package/src/commands/doctor/doctor.ts +119 -1
- package/src/commands/env-clear/env-clear.ts +1 -1
- package/src/commands/env-list/env-list.ts +1 -1
- package/src/commands/env-load/env-load.ts +1 -1
- package/src/commands/env-status/env-status.ts +1 -1
- package/src/commands/gh-merge-dev/gh-merge-dev.ts +1 -1
- package/src/commands/gh-release-deliver/gh-release-deliver.ts +1 -1
- package/src/commands/gh-release-deploy-all/gh-release-deploy-all.ts +1 -1
- package/src/commands/gh-release-deploy-selected/gh-release-deploy-selected.ts +1 -1
- package/src/commands/gh-release-list/gh-release-list.ts +1 -1
- package/src/commands/init/init.ts +3 -3
- package/src/commands/release-create/release-create.ts +1 -1
- package/src/commands/release-create-batch/release-create-batch.ts +1 -1
- package/src/commands/version/version.ts +1 -1
- package/src/commands/worktrees-add/index.ts +2 -1
- package/src/commands/worktrees-add/worktrees-add.ts +52 -11
- package/src/commands/worktrees-list/worktrees-list.ts +1 -1
- package/src/commands/worktrees-open/index.ts +1 -0
- package/src/commands/worktrees-open/worktrees-open.ts +197 -0
- package/src/commands/worktrees-remove/worktrees-remove.ts +50 -3
- package/src/commands/worktrees-sync/worktrees-sync.ts +69 -5
- package/src/entry/cli.ts +34 -5
- package/src/integrations/clickup/.gitkeep +0 -0
- package/src/integrations/cmux/index.ts +1 -0
- package/src/integrations/cmux/list-workspace-titles.ts +42 -0
- package/src/integrations/cursor/add-folders-to-workspace.ts +84 -0
- package/src/integrations/cursor/index.ts +4 -0
- package/src/integrations/cursor/reconcile-workspace-folders.ts +90 -0
- package/src/integrations/cursor/remove-folders-from-workspace.ts +93 -0
- package/src/integrations/cursor/resolve-workspace-path.ts +13 -0
- package/src/integrations/doppler/doppler-project.ts +2 -2
- package/src/lib/__tests__/infra-kit-config.test.ts +64 -14
- package/src/lib/git-utils/git-utils.ts +27 -8
- package/src/lib/infra-kit-config/index.ts +2 -0
- package/src/lib/infra-kit-config/infra-kit-config.ts +143 -0
- package/src/mcp/tools/index.ts +2 -0
- package/tsconfig.json +3 -2
- package/tsconfig.tsbuildinfo +1 -1
- 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
|
|
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 (
|
|
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', '
|
|
140
|
-
.option('--no-cursor', 'Skip Cursor
|
|
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 {
|
|
7
|
+
const { envManagement } = await getInfraKitConfig()
|
|
8
8
|
|
|
9
|
-
return
|
|
9
|
+
return envManagement.config.name
|
|
10
10
|
}
|