infra-kit 0.1.105 → 0.1.108
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/.omc/state/agent-replay-36d91435-50b4-442f-9f4e-3cc68d776236.jsonl +2 -0
- package/.omc/state/agent-replay-afc6290b-40d3-4bef-b3b6-14484c034ab9.jsonl +14 -0
- package/.omc/state/agent-replay-e947a3c6-989d-4a60-91dd-6b0ddd827b2d.jsonl +3 -0
- package/.omc/state/idle-notif-cooldown.json +1 -1
- package/.omc/state/sessions/36d91435-50b4-442f-9f4e-3cc68d776236/pre-tool-advisory-throttle.json +18 -0
- package/.omc/state/sessions/36d91435-50b4-442f-9f4e-3cc68d776236/subagent-tracking-state.json +17 -0
- package/.omc/state/sessions/afc6290b-40d3-4bef-b3b6-14484c034ab9/last-tool-error-state.json +7 -0
- package/.omc/state/sessions/afc6290b-40d3-4bef-b3b6-14484c034ab9/mission-state.json +89 -0
- package/.omc/state/sessions/afc6290b-40d3-4bef-b3b6-14484c034ab9/pre-tool-advisory-throttle.json +34 -0
- package/.omc/state/sessions/afc6290b-40d3-4bef-b3b6-14484c034ab9/ralph-state.json +13 -0
- package/.omc/state/sessions/afc6290b-40d3-4bef-b3b6-14484c034ab9/skill-active-state.json +15 -0
- package/.omc/state/sessions/afc6290b-40d3-4bef-b3b6-14484c034ab9/subagent-tracking-state.json +35 -0
- package/.omc/state/sessions/afc6290b-40d3-4bef-b3b6-14484c034ab9/ultrawork-state.json +11 -0
- package/.omc/state/sessions/e947a3c6-989d-4a60-91dd-6b0ddd827b2d/last-tool-error-state.json +7 -0
- package/.omc/state/sessions/e947a3c6-989d-4a60-91dd-6b0ddd827b2d/pre-tool-advisory-throttle.json +10 -0
- package/.omc/state/sessions/e947a3c6-989d-4a60-91dd-6b0ddd827b2d/subagent-tracking-state.json +26 -0
- package/.turbo/turbo-build.log +2 -2
- package/.turbo/turbo-test.log +43 -6
- package/dist/cli.js +61 -54
- package/dist/cli.js.map +4 -4
- package/dist/mcp.js +31 -31
- package/dist/mcp.js.map +4 -4
- package/package.json +1 -1
- package/src/commands/.omc/state/sessions/afc6290b-40d3-4bef-b3b6-14484c034ab9/pre-tool-advisory-throttle.json +14 -0
- package/src/commands/doctor/__tests__/agent-files.test.ts +110 -0
- package/src/commands/doctor/doctor.ts +66 -1
- package/src/commands/init/__tests__/agent-files.test.ts +147 -0
- package/src/commands/init/agent-files.ts +199 -0
- package/src/commands/init/index.ts +7 -0
- package/src/commands/init/init.ts +34 -25
- package/src/commands/worktrees-add/worktrees-add.ts +13 -1
- package/src/commands/worktrees-open/__tests__/open-cmux.test.ts +96 -0
- package/src/commands/worktrees-open/worktrees-open.ts +10 -3
- package/src/entry/cli.ts +1 -1
- package/src/integrations/cmux/__tests__/canonicalize-cmux-title.test.ts +56 -0
- package/src/integrations/cmux/__tests__/close-workspace-by-title.test.ts +63 -0
- package/src/integrations/cmux/__tests__/list-workspace-titles.test.ts +65 -0
- package/src/integrations/cmux/canonicalize-cmux-title.ts +31 -0
- package/src/integrations/cmux/close-workspace-by-title.ts +12 -4
- package/src/integrations/cmux/index.ts +1 -0
- package/src/integrations/cmux/list-workspace-titles.ts +11 -6
- package/src/integrations/cmux/open-workspace-with-layout.ts +1 -1
- package/src/integrations/gh/gh-release-prs/.omc/state/sessions/c6ed6186-1aac-48e0-aa2a-edc6da8e0410/pre-tool-advisory-throttle.json +10 -0
- package/src/integrations/gh/gh-release-prs/__tests__/gh-release-prs.test.ts +89 -6
- package/src/integrations/gh/gh-release-prs/gh-release-prs.ts +1 -1
- package/src/lib/managed-block/__tests__/managed-block.test.ts +121 -0
- package/src/lib/managed-block/index.ts +8 -0
- package/src/lib/managed-block/managed-block.ts +145 -0
- package/tsconfig.tsbuildinfo +1 -1
|
@@ -1,6 +1,8 @@
|
|
|
1
|
+
import { execFileSync } from 'node:child_process'
|
|
1
2
|
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
|
3
|
+
import { quote } from 'zx'
|
|
2
4
|
|
|
3
|
-
import { getReleasePRs, getReleasePRsWithInfo } from '../gh-release-prs'
|
|
5
|
+
import { createReleaseBranch, getReleasePRs, getReleasePRsWithInfo } from '../gh-release-prs'
|
|
4
6
|
|
|
5
7
|
interface FakePR {
|
|
6
8
|
number: number
|
|
@@ -12,17 +14,32 @@ interface FakePR {
|
|
|
12
14
|
}
|
|
13
15
|
|
|
14
16
|
const responses = vi.hoisted(() => {
|
|
15
|
-
return {
|
|
17
|
+
return {
|
|
18
|
+
release: [] as FakePR[],
|
|
19
|
+
hotfix: [] as FakePR[],
|
|
20
|
+
calls: [] as { strings: string[]; values: unknown[] }[],
|
|
21
|
+
}
|
|
16
22
|
})
|
|
17
23
|
|
|
18
24
|
// Mock zx's tagged-template `$`: the gh pr list call for `--base dev` returns
|
|
19
|
-
// the "release" set, `--base main` returns the "hotfix" set
|
|
20
|
-
//
|
|
21
|
-
|
|
25
|
+
// the "release" set, `--base main` returns the "hotfix" set, and `gh pr create`
|
|
26
|
+
// returns a fake PR URL. Every invocation is captured (template strings +
|
|
27
|
+
// interpolated values) so tests can reconstruct the exact command zx would run.
|
|
28
|
+
// Spreading `...actual` keeps zx's real `quote` available for that reconstruction.
|
|
29
|
+
vi.mock('zx', async (importOriginal) => {
|
|
30
|
+
const actual = await importOriginal<typeof import('zx')>()
|
|
31
|
+
|
|
22
32
|
return {
|
|
23
|
-
|
|
33
|
+
...actual,
|
|
34
|
+
$: vi.fn((strings: TemplateStringsArray, ...values: unknown[]) => {
|
|
24
35
|
const command = strings.join('')
|
|
25
36
|
|
|
37
|
+
responses.calls.push({ strings: [...strings], values })
|
|
38
|
+
|
|
39
|
+
if (command.includes('gh pr create')) {
|
|
40
|
+
return Promise.resolve({ stdout: 'https://github.com/acme/repo/pull/123' })
|
|
41
|
+
}
|
|
42
|
+
|
|
26
43
|
if (command.includes('--base main')) {
|
|
27
44
|
return Promise.resolve({ stdout: JSON.stringify(responses.hotfix) })
|
|
28
45
|
}
|
|
@@ -113,3 +130,69 @@ describe('getReleasePRsWithInfo (discovery + sort)', () => {
|
|
|
113
130
|
])
|
|
114
131
|
})
|
|
115
132
|
})
|
|
133
|
+
|
|
134
|
+
describe('createReleaseBranch (gh pr create title quoting)', () => {
|
|
135
|
+
beforeEach(() => {
|
|
136
|
+
responses.calls = []
|
|
137
|
+
})
|
|
138
|
+
|
|
139
|
+
// Rebuild the exact command zx would have run: interleave each template
|
|
140
|
+
// string with the zx-quoted form of its interpolated value. This reflects
|
|
141
|
+
// what the shell actually receives, including zx's ANSI-C `$'...'` escaping
|
|
142
|
+
// for values containing spaces.
|
|
143
|
+
const reconstruct = (call: { strings: string[]; values: unknown[] }): string => {
|
|
144
|
+
return call.strings
|
|
145
|
+
.map((part, i) => {
|
|
146
|
+
return i < call.values.length ? part + quote(String(call.values[i])) : part
|
|
147
|
+
})
|
|
148
|
+
.join('')
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
const findCreateCommand = (): string => {
|
|
152
|
+
const call = responses.calls.find((c) => {
|
|
153
|
+
return c.strings.join('').includes('gh pr create')
|
|
154
|
+
})
|
|
155
|
+
|
|
156
|
+
if (!call) throw new Error('gh pr create was not invoked')
|
|
157
|
+
|
|
158
|
+
return reconstruct(call)
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// Run the reconstructed command through a real shell with `gh pr create`
|
|
162
|
+
// replaced by a function that prints back the value the shell parsed for
|
|
163
|
+
// `--title`. This is the behavioral proof of what gh would receive.
|
|
164
|
+
const shellTitleArg = (command: string): string => {
|
|
165
|
+
const probe = command.replace(
|
|
166
|
+
'gh pr create',
|
|
167
|
+
'f(){ while [ "$#" -gt 0 ]; do if [ "$1" = "--title" ]; then printf %s "$2"; return 0; fi; shift; done; }; f',
|
|
168
|
+
)
|
|
169
|
+
|
|
170
|
+
// Absolute path (not PATH-resolved) keeps the lint's command-injection guard happy.
|
|
171
|
+
return execFileSync('/bin/sh', ['-c', probe]).toString()
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
it("delivers a spaced title to gh as the literal string, with no $'...' corruption", async () => {
|
|
175
|
+
const id = { kind: 'version', semver: { major: 1, minor: 89, patch: 1 }, raw: '1.89.1' } as const
|
|
176
|
+
|
|
177
|
+
await createReleaseBranch({ id, jiraVersionUrl: 'https://jira.example/v1.89.1', type: 'hotfix' })
|
|
178
|
+
|
|
179
|
+
const command = findCreateCommand()
|
|
180
|
+
|
|
181
|
+
// Behavioral: the shell parses --title to exactly the intended title.
|
|
182
|
+
expect(shellTitleArg(command)).toBe('Hotfix v1.89.1')
|
|
183
|
+
// The pre-fix bug wrapped zx's `$'...'` token in literal double quotes, so
|
|
184
|
+
// the shell passed the literal `$'Hotfix v1.89.1'` to gh. That marker must be absent.
|
|
185
|
+
expect(command).not.toContain('"$\'')
|
|
186
|
+
})
|
|
187
|
+
|
|
188
|
+
it('delivers a named release title to gh as the literal string', async () => {
|
|
189
|
+
const id = { kind: 'name', name: 'checkout-redesign', raw: 'checkout-redesign' } as const
|
|
190
|
+
|
|
191
|
+
await createReleaseBranch({ id, jiraVersionUrl: 'https://jira.example/checkout-redesign', type: 'regular' })
|
|
192
|
+
|
|
193
|
+
const command = findCreateCommand()
|
|
194
|
+
|
|
195
|
+
expect(shellTitleArg(command)).toBe('Release checkout-redesign')
|
|
196
|
+
expect(command).not.toContain('"$\'')
|
|
197
|
+
})
|
|
198
|
+
})
|
|
@@ -178,7 +178,7 @@ export const createReleaseBranch = async (
|
|
|
178
178
|
await $`git push origin ${branchName}`
|
|
179
179
|
|
|
180
180
|
// Create PR and capture URL
|
|
181
|
-
const prResult = await $`gh pr create --title
|
|
181
|
+
const prResult = await $`gh pr create --title ${prTitle} --body ${body} --base ${baseBranch} --head ${branchName}`
|
|
182
182
|
|
|
183
183
|
const prLink = prResult.stdout.trim()
|
|
184
184
|
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest'
|
|
2
|
+
|
|
3
|
+
import { extractVersion, hasManagedBlock, removeManagedBlock, upsertManagedBlock } from '../managed-block'
|
|
4
|
+
|
|
5
|
+
const START = '<!-- ik:begin -->'
|
|
6
|
+
const END = '<!-- ik:end -->'
|
|
7
|
+
|
|
8
|
+
describe('hasManagedBlock', () => {
|
|
9
|
+
it('detects a well-formed block', () => {
|
|
10
|
+
expect(hasManagedBlock(`x ${START} y ${END} z`, START, END)).toBe(true)
|
|
11
|
+
})
|
|
12
|
+
|
|
13
|
+
it('returns false when a marker is missing', () => {
|
|
14
|
+
expect(hasManagedBlock(`only ${START} here`, START, END)).toBe(false)
|
|
15
|
+
})
|
|
16
|
+
|
|
17
|
+
it('treats reversed markers (end before start) as absent', () => {
|
|
18
|
+
expect(hasManagedBlock(`${END} middle ${START}`, START, END)).toBe(false)
|
|
19
|
+
})
|
|
20
|
+
})
|
|
21
|
+
|
|
22
|
+
describe('removeManagedBlock', () => {
|
|
23
|
+
it('removes the block and preserves surrounding text', () => {
|
|
24
|
+
const content = `top\n${START}\nmid\n${END}\nbot`
|
|
25
|
+
|
|
26
|
+
expect(removeManagedBlock(content, START, END)).toBe('top\nbot')
|
|
27
|
+
})
|
|
28
|
+
|
|
29
|
+
it('returns null when no block is present', () => {
|
|
30
|
+
expect(removeManagedBlock('nothing here', START, END)).toBeNull()
|
|
31
|
+
})
|
|
32
|
+
|
|
33
|
+
it('returns null for reversed markers (guard)', () => {
|
|
34
|
+
expect(removeManagedBlock(`${END}\nx\n${START}`, START, END)).toBeNull()
|
|
35
|
+
})
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
describe('extractVersion', () => {
|
|
39
|
+
const PREFIX = '<!-- ik:version '
|
|
40
|
+
|
|
41
|
+
it('reads the version token after the prefix', () => {
|
|
42
|
+
expect(extractVersion('<!-- ik:version 0.1.105 -->', PREFIX)).toBe('0.1.105')
|
|
43
|
+
})
|
|
44
|
+
|
|
45
|
+
it('returns null when the prefix is absent', () => {
|
|
46
|
+
expect(extractVersion('no version here', PREFIX)).toBeNull()
|
|
47
|
+
})
|
|
48
|
+
})
|
|
49
|
+
|
|
50
|
+
describe('upsertManagedBlock', () => {
|
|
51
|
+
it('inserts into an empty file (replace-in-place default)', () => {
|
|
52
|
+
const result = upsertManagedBlock({ content: '', body: 'hello', startMarker: START, endMarker: END })
|
|
53
|
+
|
|
54
|
+
expect(result).toBe(`${START}\nhello\n${END}\n`)
|
|
55
|
+
})
|
|
56
|
+
|
|
57
|
+
it('inserts into an empty file (append-end)', () => {
|
|
58
|
+
const result = upsertManagedBlock({
|
|
59
|
+
content: '',
|
|
60
|
+
body: 'hello',
|
|
61
|
+
startMarker: START,
|
|
62
|
+
endMarker: END,
|
|
63
|
+
placement: 'append-end',
|
|
64
|
+
})
|
|
65
|
+
|
|
66
|
+
expect(result).toBe(`${START}\nhello\n${END}\n`)
|
|
67
|
+
})
|
|
68
|
+
|
|
69
|
+
it('replaces an existing block in place, keeping surrounding content verbatim', () => {
|
|
70
|
+
const content = `# top heading\n\n${START}\nold body\n${END}\n\n# bottom heading\n`
|
|
71
|
+
const result = upsertManagedBlock({ content, body: 'new body', startMarker: START, endMarker: END })
|
|
72
|
+
|
|
73
|
+
expect(result).toBe(`# top heading\n\n${START}\nnew body\n${END}\n\n# bottom heading\n`)
|
|
74
|
+
})
|
|
75
|
+
|
|
76
|
+
it('append-end relocates an existing mid-file block to end-of-file', () => {
|
|
77
|
+
const content = `intro\n\n${START}\nold\n${END}\n\nuser tail`
|
|
78
|
+
const result = upsertManagedBlock({
|
|
79
|
+
content,
|
|
80
|
+
body: 'fresh',
|
|
81
|
+
startMarker: START,
|
|
82
|
+
endMarker: END,
|
|
83
|
+
placement: 'append-end',
|
|
84
|
+
})
|
|
85
|
+
|
|
86
|
+
expect(result).toBe(`intro\nuser tail\n${START}\nfresh\n${END}\n`)
|
|
87
|
+
})
|
|
88
|
+
|
|
89
|
+
it('append-end lands the block at end-of-file when absent', () => {
|
|
90
|
+
const content = 'existing user content'
|
|
91
|
+
const result = upsertManagedBlock({
|
|
92
|
+
content,
|
|
93
|
+
body: 'b',
|
|
94
|
+
startMarker: START,
|
|
95
|
+
endMarker: END,
|
|
96
|
+
placement: 'append-end',
|
|
97
|
+
})
|
|
98
|
+
|
|
99
|
+
expect(result.endsWith(`${START}\nb\n${END}\n`)).toBe(true)
|
|
100
|
+
expect(result.startsWith('existing user content')).toBe(true)
|
|
101
|
+
})
|
|
102
|
+
|
|
103
|
+
it('is idempotent — re-running does not nest or duplicate blocks', () => {
|
|
104
|
+
const once = upsertManagedBlock({ content: 'pre\n', body: 'b', startMarker: START, endMarker: END })
|
|
105
|
+
const twice = upsertManagedBlock({ content: once, body: 'b', startMarker: START, endMarker: END })
|
|
106
|
+
|
|
107
|
+
expect(twice).toBe(once)
|
|
108
|
+
expect(twice.match(new RegExp(START, 'g'))?.length).toBe(1)
|
|
109
|
+
})
|
|
110
|
+
|
|
111
|
+
it('preserves content above and below across an update', () => {
|
|
112
|
+
const first = upsertManagedBlock({ content: 'ABOVE\n', body: 'v1', startMarker: START, endMarker: END })
|
|
113
|
+
const withTail = `${first}BELOW\n`
|
|
114
|
+
const updated = upsertManagedBlock({ content: withTail, body: 'v2', startMarker: START, endMarker: END })
|
|
115
|
+
|
|
116
|
+
expect(updated).toContain('ABOVE')
|
|
117
|
+
expect(updated).toContain('BELOW')
|
|
118
|
+
expect(updated).toContain('v2')
|
|
119
|
+
expect(updated).not.toContain('v1')
|
|
120
|
+
})
|
|
121
|
+
})
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Generic "managed block" helpers — a marker-delimited region a tool owns and
|
|
3
|
+
* rewrites idempotently while never touching content outside the markers.
|
|
4
|
+
*
|
|
5
|
+
* This is the same mechanism `infra-kit init` already uses for the `~/.zshrc`
|
|
6
|
+
* shell block (`# -- infra-kit:begin -- … # -- infra-kit:end --`), lifted here so
|
|
7
|
+
* it can be reused for the repo agent-instruction files (`AGENTS.md`,
|
|
8
|
+
* `CLAUDE.md` import region, `.cursor/rules`). It mirrors the design of OMC's
|
|
9
|
+
* `<!-- OMC:START --> … <!-- OMC:END -->` CLAUDE.md installer.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Where a freshly upserted block lands relative to existing content.
|
|
14
|
+
*
|
|
15
|
+
* - `replace-in-place`: if the block already exists, rewrite it where it sits
|
|
16
|
+
* (surrounding text untouched, verbatim); if absent, append it at end-of-file.
|
|
17
|
+
* - `append-end`: strip any existing block from wherever it is, then append the
|
|
18
|
+
* fresh block at end-of-file (i.e. always relocate to the end). This preserves
|
|
19
|
+
* the historical `~/.zshrc` behavior of `removeExistingBlock` + append.
|
|
20
|
+
*/
|
|
21
|
+
export type BlockPlacement = 'replace-in-place' | 'append-end'
|
|
22
|
+
|
|
23
|
+
export interface UpsertManagedBlockArgs {
|
|
24
|
+
/** Existing file content (`''` for a new file). */
|
|
25
|
+
content: string
|
|
26
|
+
/** Inner body to place between the markers (WITHOUT the markers). */
|
|
27
|
+
body: string
|
|
28
|
+
startMarker: string
|
|
29
|
+
endMarker: string
|
|
30
|
+
/** Defaults to `replace-in-place`. */
|
|
31
|
+
placement?: BlockPlacement
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Whether `content` contains a well-formed `start … end` block. A reversed pair
|
|
36
|
+
* (end before start) is treated as absent — the same guard `doctor.ts` applies
|
|
37
|
+
* to the zshrc block, ported here so corrupted markers never match.
|
|
38
|
+
*
|
|
39
|
+
* @example
|
|
40
|
+
* hasManagedBlock('a<!--s-->x<!--e-->b', '<!--s-->', '<!--e-->') // => true
|
|
41
|
+
* hasManagedBlock('<!--e--><!--s-->', '<!--s-->', '<!--e-->') // => false (reversed)
|
|
42
|
+
*/
|
|
43
|
+
export const hasManagedBlock = (content: string, startMarker: string, endMarker: string): boolean => {
|
|
44
|
+
const startIdx = content.indexOf(startMarker)
|
|
45
|
+
const endIdx = content.indexOf(endMarker)
|
|
46
|
+
|
|
47
|
+
return startIdx !== -1 && endIdx !== -1 && endIdx >= startIdx
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Remove the first complete `start … end` block, preserving surrounding text.
|
|
52
|
+
* Returns `null` when no well-formed block is present (no markers, or reversed),
|
|
53
|
+
* so callers can fall through to legacy-format handling — this matches the
|
|
54
|
+
* `string | null` contract of `init.ts`'s original `removeBetween`, with the
|
|
55
|
+
* added reversed-marker guard.
|
|
56
|
+
*
|
|
57
|
+
* @example
|
|
58
|
+
* removeManagedBlock('top\n<!--s-->mid<!--e-->\nbot', '<!--s-->', '<!--e-->')
|
|
59
|
+
* // => 'top\nbot'
|
|
60
|
+
*/
|
|
61
|
+
export const removeManagedBlock = (content: string, startMarker: string, endMarker: string): string | null => {
|
|
62
|
+
const startIdx = content.indexOf(startMarker)
|
|
63
|
+
const endIdx = content.indexOf(endMarker)
|
|
64
|
+
|
|
65
|
+
if (startIdx === -1 || endIdx === -1 || endIdx < startIdx) return null
|
|
66
|
+
|
|
67
|
+
// eslint-disable-next-line sonarjs/slow-regex
|
|
68
|
+
const before = content.slice(0, startIdx).replace(/\n+$/, '')
|
|
69
|
+
const after = content.slice(endIdx + endMarker.length).replace(/^\n+/, '')
|
|
70
|
+
|
|
71
|
+
return before + (after ? `\n${after}` : '')
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Read the version token that follows `versionPrefix` (e.g.
|
|
76
|
+
* `'<!-- infra-kit:version '`). Returns the token up to the next whitespace or
|
|
77
|
+
* `>`, or `null` if the prefix is absent. Mirrors OMC's `OMC:VERSION:` line.
|
|
78
|
+
*
|
|
79
|
+
* @example
|
|
80
|
+
* extractVersion('<!-- infra-kit:version 0.1.105 -->', '<!-- infra-kit:version ')
|
|
81
|
+
* // => '0.1.105'
|
|
82
|
+
*/
|
|
83
|
+
export const extractVersion = (content: string, versionPrefix: string): string | null => {
|
|
84
|
+
const idx = content.indexOf(versionPrefix)
|
|
85
|
+
|
|
86
|
+
if (idx === -1) return null
|
|
87
|
+
|
|
88
|
+
const rest = content.slice(idx + versionPrefix.length)
|
|
89
|
+
const match = rest.match(/^([^\s>]+)/)
|
|
90
|
+
|
|
91
|
+
return match ? match[1]! : null
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Compose a full block string: `start\n{body}\nend`. Kept identical in shape to
|
|
96
|
+
* the historical `buildShellBlock()` output so doctor's exact-match comparison
|
|
97
|
+
* stays valid.
|
|
98
|
+
*/
|
|
99
|
+
export const buildManagedBlock = (startMarker: string, body: string, endMarker: string): string => {
|
|
100
|
+
return `${startMarker}\n${body}\n${endMarker}`
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Insert or update a managed block in `content`, preserving everything outside
|
|
105
|
+
* the markers. Idempotent: re-running with the same body yields the same block
|
|
106
|
+
* and never nests duplicates.
|
|
107
|
+
*
|
|
108
|
+
* @example
|
|
109
|
+
* // fresh file
|
|
110
|
+
* upsertManagedBlock({ content: '', body: 'hi', startMarker: '<!--s-->', endMarker: '<!--e-->' })
|
|
111
|
+
* // => '<!--s-->\nhi\n<!--e-->\n'
|
|
112
|
+
*
|
|
113
|
+
* @example
|
|
114
|
+
* // existing block replaced in place, surrounding text kept
|
|
115
|
+
* upsertManagedBlock({
|
|
116
|
+
* content: 'top\n<!--s-->\nold\n<!--e-->\nbot',
|
|
117
|
+
* body: 'new', startMarker: '<!--s-->', endMarker: '<!--e-->',
|
|
118
|
+
* })
|
|
119
|
+
* // => 'top\n<!--s-->\nnew\n<!--e-->\nbot'
|
|
120
|
+
*/
|
|
121
|
+
export const upsertManagedBlock = ({
|
|
122
|
+
content,
|
|
123
|
+
body,
|
|
124
|
+
startMarker,
|
|
125
|
+
endMarker,
|
|
126
|
+
placement = 'replace-in-place',
|
|
127
|
+
}: UpsertManagedBlockArgs): string => {
|
|
128
|
+
const block = buildManagedBlock(startMarker, body, endMarker)
|
|
129
|
+
const present = hasManagedBlock(content, startMarker, endMarker)
|
|
130
|
+
|
|
131
|
+
if (placement === 'replace-in-place' && present) {
|
|
132
|
+
const startIdx = content.indexOf(startMarker)
|
|
133
|
+
const endIdx = content.indexOf(endMarker) + endMarker.length
|
|
134
|
+
|
|
135
|
+
return content.slice(0, startIdx) + block + content.slice(endIdx)
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// append-end, or replace-in-place with no existing block: drop any stale block
|
|
139
|
+
// then append the fresh one at end-of-file.
|
|
140
|
+
const stripped = present ? (removeManagedBlock(content, startMarker, endMarker) ?? content) : content
|
|
141
|
+
// eslint-disable-next-line sonarjs/slow-regex
|
|
142
|
+
const base = stripped.replace(/\n+$/, '')
|
|
143
|
+
|
|
144
|
+
return base.length > 0 ? `${base}\n${block}\n` : `${block}\n`
|
|
145
|
+
}
|