dsh-plugin-git-commit-push 0.0.0-stage → 1.0.1

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/lib/git.js ADDED
@@ -0,0 +1,562 @@
1
+ /**
2
+ * The only place this plugin talks to git.
3
+ *
4
+ * Design rules that must not be relaxed:
5
+ *
6
+ * 1. FIXED ARGV UP FRONT. Every git invocation is `git -C <root> --no-pager
7
+ * -c color.ui=false <args...>` with `-c core.quotepath=false` so non-ASCII
8
+ * paths survive as UTF-8 instead of octal escapes. Arguments are passed as
9
+ * an argv array (never a shell string), so a path or branch name can never
10
+ * become a second command.
11
+ * 2. NO DESTRUCTIVE VERBS. `push --force`, `reset --hard`, `clean`,
12
+ * `checkout --`, `config` and `restore` are absent from this module by
13
+ * construction. If you need one, it does not belong in this plugin.
14
+ * 3. CAPTURED OUTPUT IS BOUNDED. Every read has a byte cap; a monorepo-sized
15
+ * diff cannot be pulled into the Host's heap or into the model's context.
16
+ * 4. THE USER'S GIT CONFIG IS NEVER WRITTEN. No `config` call exists here, and
17
+ * `GIT_OPTIONAL_LOCKS=0` keeps read commands from taking index locks.
18
+ *
19
+ * The pinned commit identity is supplied per command with `-c user.name=...`
20
+ * `-c user.email=...` ONLY when the config asks for it, so a machine without a
21
+ * global identity can still commit without this plugin mutating the user's
22
+ * global or repository configuration.
23
+ */
24
+ import { spawn } from 'node:child_process'
25
+ import { existsSync } from 'node:fs'
26
+ import { readdir } from 'node:fs/promises'
27
+ import { isAbsolute, join, resolve } from 'node:path'
28
+
29
+ /** A git invocation the plugin refused to run, or that failed. */
30
+ export class GitError extends Error {
31
+ constructor(message, code, command, stderr = '') {
32
+ super(message)
33
+ this.name = 'GitError'
34
+ this.code = code
35
+ this.command = command
36
+ this.stderr = stderr
37
+ }
38
+ }
39
+
40
+ /** Upper bound on captured stdout for any single read command. */
41
+ const READ_CAP = 1 << 20 // 1 MiB
42
+ /** Default budget for a local, non-network command. */
43
+ const LOCAL_TIMEOUT_MS = 30_000
44
+ /** Push/pull talk to a remote; give them room before giving up. */
45
+ const NETWORK_TIMEOUT_MS = 90_000
46
+
47
+ /**
48
+ * The platform's bit bucket.
49
+ *
50
+ * `GIT_CONFIG_GLOBAL`/`GIT_CONFIG_SYSTEM` are set to this to run git with the
51
+ * user's configuration OUT of the picture. Windows has no `/dev/null` — passing
52
+ * it there is not merely ignored, it makes git look for a file literally named
53
+ * `/dev/null` — so each platform gets its own spelling.
54
+ */
55
+ export const DEV_NULL = process.platform === 'win32' ? 'NUL' : '/dev/null'
56
+
57
+ /** Environment that pins a deterministic, user-config-free git. FOR TESTS. */
58
+ export function isolatedGitEnv() {
59
+ return {
60
+ ...process.env,
61
+ LC_ALL: 'C',
62
+ LANG: 'C',
63
+ GIT_OPTIONAL_LOCKS: '0',
64
+ GIT_TERMINAL_PROMPT: '0',
65
+ GIT_CONFIG_GLOBAL: DEV_NULL,
66
+ GIT_CONFIG_SYSTEM: DEV_NULL,
67
+ }
68
+ }
69
+
70
+ /**
71
+ * Environment for the plugin's own git calls.
72
+ *
73
+ * Deliberately NOT `isolatedGitEnv()`: the user's global configuration is left
74
+ * in place here, because that is where their credential helper, `pull.rebase`
75
+ * and `core.autocrlf` live. Blanking it would silently change how their own
76
+ * repositories behave — the opposite of this plugin's contract.
77
+ *
78
+ * `LC_ALL=C` is load-bearing: `classifyPushFailure` matches English stderr, and
79
+ * a localized git would report a non-fast-forward rejection as an unknown
80
+ * reason, silently disabling the rebase retry. `GIT_TERMINAL_PROMPT=0` turns a
81
+ * missing credential into a clean failure instead of a hung process waiting on
82
+ * a stdin we never opened.
83
+ */
84
+ export function pluginGitEnv() {
85
+ return {
86
+ ...process.env,
87
+ LC_ALL: 'C',
88
+ LANG: 'C',
89
+ GIT_OPTIONAL_LOCKS: '0',
90
+ GIT_TERMINAL_PROMPT: '0',
91
+ }
92
+ }
93
+
94
+ /**
95
+ * Run one git command and resolve with raw stdout.
96
+ *
97
+ * @param {string} root repository root (or any dir inside it)
98
+ * @param {readonly string[]} args git arguments, already split
99
+ * @param {{ timeoutMs?: number, identity?: { name?: string, email?: string } }} [options]
100
+ * @returns {Promise<string>} stdout
101
+ */
102
+ export function git(root, args, options = {}) {
103
+ const { timeoutMs = LOCAL_TIMEOUT_MS, identity } = options
104
+
105
+ const argv = ['-C', root, '--no-pager', '-c', 'color.ui=false', '-c', 'core.quotepath=false']
106
+ if (identity?.name !== undefined && identity.name !== '') argv.push('-c', `user.name=${identity.name}`)
107
+ if (identity?.email !== undefined && identity.email !== '') argv.push('-c', `user.email=${identity.email}`)
108
+ argv.push(...args)
109
+
110
+ return new Promise((resolvePromise, reject) => {
111
+ let child
112
+ try {
113
+ child = spawn('git', argv, {
114
+ stdio: ['ignore', 'pipe', 'pipe'],
115
+ windowsHide: true,
116
+ env: pluginGitEnv(),
117
+ })
118
+ } catch (error) {
119
+ reject(new GitError(`cannot run git: ${error.message}`, 'git-unavailable', args.join(' ')))
120
+ return
121
+ }
122
+
123
+ let stdout = ''
124
+ let stderr = ''
125
+ let settled = false
126
+
127
+ const timer = setTimeout(() => {
128
+ if (settled) return
129
+ settled = true
130
+ child.kill('SIGKILL')
131
+ reject(new GitError(`git ${args[0] ?? ''} timed out after ${timeoutMs}ms`, 'timeout', args.join(' '), stderr))
132
+ }, timeoutMs)
133
+
134
+ child.stdout.on('data', (chunk) => {
135
+ if (stdout.length < READ_CAP) stdout += chunk.toString('utf8')
136
+ })
137
+ child.stderr.on('data', (chunk) => {
138
+ if (stderr.length < 64 * 1024) stderr += chunk.toString('utf8')
139
+ })
140
+ child.on('error', (error) => {
141
+ if (settled) return
142
+ settled = true
143
+ clearTimeout(timer)
144
+ reject(new GitError(`cannot run git: ${error.message}`, 'git-unavailable', args.join(' '), stderr))
145
+ })
146
+ child.on('close', (code) => {
147
+ if (settled) return
148
+ settled = true
149
+ clearTimeout(timer)
150
+ if (code === 0) {
151
+ resolvePromise(stdout)
152
+ return
153
+ }
154
+ reject(new GitError(
155
+ stderr.trim() !== '' ? stderr.trim() : `git ${args[0] ?? ''} exited with ${String(code)}`,
156
+ 'git-failed',
157
+ args.join(' '),
158
+ stderr,
159
+ ))
160
+ })
161
+ })
162
+ }
163
+
164
+ /**
165
+ * Whether a usable `git` executable exists at all.
166
+ *
167
+ * `isRepo` deliberately swallows every error, which would otherwise report a
168
+ * machine with no git installed as "this project is not a Git repository" — a
169
+ * misleading answer that sends the user looking in the wrong place. This probe
170
+ * tells the two apart.
171
+ */
172
+ export async function gitAvailable() {
173
+ // A directory that certainly exists: `git --version` does not read it, but
174
+ // spawn still needs a valid cwd.
175
+ const root = process.platform === 'win32' ? (process.env.SystemRoot ?? process.cwd()) : '/'
176
+ try {
177
+ await git(root, ['--version'], { timeoutMs: 8_000 })
178
+ return true
179
+ } catch (error) {
180
+ return !(error instanceof GitError && error.code === 'git-unavailable')
181
+ }
182
+ }
183
+
184
+ /** Whether `dir` resolves to the top level of a git work tree. */
185
+ export async function isRepo(dir, signal) {
186
+ if (signal?.aborted) throw new GitError('aborted', 'aborted', 'rev-parse')
187
+ try {
188
+ const out = await git(dir, ['rev-parse', '--is-inside-work-tree'], { timeoutMs: 8_000 })
189
+ return out.trim() === 'true'
190
+ } catch {
191
+ return false
192
+ }
193
+ }
194
+
195
+ /** @returns {Promise<string>} the repository top level containing `dir` */
196
+ export function repoRoot(dir) {
197
+ return git(dir, ['rev-parse', '--show-toplevel'], { timeoutMs: 8_000 }).then(out => out.trim())
198
+ }
199
+
200
+ /**
201
+ * Immediate child directories that are themselves work-tree roots.
202
+ *
203
+ * A session whose working directory is a container (a home directory, or a
204
+ * folder of projects) must not be silently committed to. Instead we offer the
205
+ * candidates so the caller can name the repository it meant.
206
+ *
207
+ * @returns {Promise<string[]>} up to 8 candidate repository roots
208
+ */
209
+ export async function childRepoRoots(dir) {
210
+ let entries
211
+ try {
212
+ entries = await readdir(dir, { withFileTypes: true })
213
+ } catch {
214
+ return []
215
+ }
216
+ const roots = []
217
+ for (const entry of entries.sort((a, b) => a.name.localeCompare(b.name))) {
218
+ if (!entry.isDirectory() || entry.name.startsWith('.') || entry.name === 'node_modules') continue
219
+ if (roots.length >= 8) break
220
+ const candidate = join(dir, entry.name)
221
+ try {
222
+ const out = await git(candidate, ['rev-parse', '--show-toplevel'], { timeoutMs: 4_000 })
223
+ const root = out.trim()
224
+ if (root !== '' && !roots.includes(root)) roots.push(root)
225
+ } catch {
226
+ // Ordinary directory; keep looking.
227
+ }
228
+ }
229
+ return roots
230
+ }
231
+
232
+ /**
233
+ * Resolve the repository this call should operate on.
234
+ *
235
+ * @param {string} cwd the session working directory
236
+ * @param {string | undefined} requested an explicit override from the caller
237
+ * @returns {Promise<{ root: string } | { notRepo: true, candidates: string[] }>}
238
+ */
239
+ export async function resolveRepo(cwd, requested) {
240
+ const start = requested !== undefined && requested !== ''
241
+ ? (isAbsolute(requested) ? requested : resolve(cwd, requested))
242
+ : cwd
243
+ if (await isRepo(start)) return { root: await repoRoot(start) }
244
+ return { notRepo: true, candidates: await childRepoRoots(start) }
245
+ }
246
+
247
+ /** The current branch name, or 'HEAD' when detached. */
248
+ export async function currentBranch(root) {
249
+ return (await git(root, ['rev-parse', '--abbrev-ref', 'HEAD'])).trim()
250
+ }
251
+
252
+ /** Whether the current branch already has an upstream. */
253
+ export async function hasUpstream(root) {
254
+ try {
255
+ await git(root, ['rev-parse', '--abbrev-ref', '--symbolic-full-name', '@{upstream}'], { timeoutMs: 8_000 })
256
+ return true
257
+ } catch {
258
+ return false
259
+ }
260
+ }
261
+
262
+ /**
263
+ * Parse `status --porcelain=v1 -z`.
264
+ *
265
+ * Each record is `XY <path>`; a rename/copy record carries the ORIGIN path as
266
+ * the next NUL field, which must be consumed so it is not mistaken for an entry.
267
+ *
268
+ * @returns {{ xy: string, path: string, origPath?: string }[]}
269
+ */
270
+ export function parseStatusZ(raw) {
271
+ const tokens = raw.split('\u0000')
272
+ const entries = []
273
+ let index = 0
274
+ while (index < tokens.length) {
275
+ const token = tokens[index]
276
+ index += 1
277
+ if (token === undefined || token === '') continue
278
+ const xy = token.slice(0, 2)
279
+ const path = token.slice(3)
280
+ const entry = { xy, path }
281
+ // `-z` reverses the rename pair to `to\0from\0`, and git emits the extra
282
+ // source field whenever a rename_source exists — which includes a
283
+ // WORKTREE-side rename (XY ` R`), not only a staged one. Testing just
284
+ // `xy[0]` would leave `old\0` to be parsed as a bogus entry.
285
+ if ((xy[0] === 'R' || xy[0] === 'C' || xy[1] === 'R' || xy[1] === 'C')
286
+ && tokens[index] !== undefined && tokens[index] !== '') {
287
+ entry.origPath = tokens[index]
288
+ index += 1
289
+ }
290
+ entries.push(entry)
291
+ }
292
+ return entries
293
+ }
294
+
295
+ /** Working-tree status including untracked files, as individual entries. */
296
+ export async function status(root) {
297
+ const raw = await git(root, ['status', '--porcelain=v1', '-z', '--untracked-files=all'])
298
+ return parseStatusZ(raw)
299
+ }
300
+
301
+ /**
302
+ * Per-file line counts for the working tree, staged and unstaged together.
303
+ *
304
+ * `git diff --numstat -z` emits, per record:
305
+ *
306
+ * added TAB deleted TAB NUL preimage NUL postimage NUL
307
+ *
308
+ * i.e. the counts come FIRST and are followed by the NUL-framed path. For a
309
+ * rename the preimage (old path) precedes the postimage (new path). Every
310
+ * consumer keys this map by the CURRENT path — `survey` fills missing keys from
311
+ * the status entries and the card looks up `stats.get(entry.path)` — so the
312
+ * postimage is the key and the preimage is skipped. Getting this backwards
313
+ * silently loses the line counts of every renamed file.
314
+ *
315
+ * `HEAD` may not exist yet (a repository with no commits), in which case the
316
+ * numstat call is impossible and every entry is reported as new.
317
+ *
318
+ * @returns {Promise<Map<string, { added: number, deleted: number, binary: boolean }>>}
319
+ */
320
+ export async function numstat(root, cached) {
321
+ const args = ['diff', '--numstat', '-z']
322
+ if (cached) args.push('--cached')
323
+ args.push('HEAD')
324
+ let raw
325
+ try {
326
+ raw = await git(root, args)
327
+ } catch {
328
+ return new Map()
329
+ }
330
+ const stats = new Map()
331
+ const tokens = raw.split('\u0000')
332
+ let index = 0
333
+ while (index < tokens.length) {
334
+ const header = tokens[index]
335
+ index += 1
336
+ if (header === undefined || header === '') continue
337
+ const match = /^(\d+|-)\t(\d+|-)\t(.*)$/s.exec(header)
338
+ if (match === null) continue
339
+ let path = match[3]
340
+ if (path === '') {
341
+ // Rename: the next field is the preimage, the one after it is the postimage.
342
+ index += 1
343
+ path = tokens[index] ?? ''
344
+ index += 1
345
+ }
346
+ const added = match[1] === '-' ? 0 : Number(match[1])
347
+ const deleted = match[2] === '-' ? 0 : Number(match[2])
348
+ stats.set(path, { added, deleted, binary: match[1] === '-' || match[2] === '-' })
349
+ }
350
+ return stats
351
+ }
352
+
353
+ /**
354
+ * The raw `--unified=0` working-tree diff, or `''` when it cannot be read.
355
+ *
356
+ * Returns the DIFF TEXT, not a digest of it: the two consumers want different
357
+ * views of the same bytes (`declaredSymbols` looks at added lines,
358
+ * `removedDeclarationCount` at removed declaration lines), so sampling here
359
+ * would either lose information one of them needs or duplicate the parsing.
360
+ *
361
+ * `--no-textconv` matters: a repository with a textconv filter would otherwise
362
+ * run an external command during what is supposed to be a read-only survey.
363
+ *
364
+ * @returns {Promise<string>}
365
+ */
366
+ export async function rawDiff(root) {
367
+ try {
368
+ return await git(root, ['diff', '--unified=0', '--no-color', '--no-ext-diff', '--no-textconv', 'HEAD'], { timeoutMs: 20_000 })
369
+ } catch {
370
+ // No HEAD yet (a repository with no commits), or the read failed: an empty
371
+ // diff is the honest answer, and callers treat it as "no evidence".
372
+ return ''
373
+ }
374
+ }
375
+
376
+ /**
377
+ * The first `limit` added lines of the working-tree diff, trimmed.
378
+ *
379
+ * Diff *headers* are filtered out so `+++ b/path` can never be mistaken for an
380
+ * added line of content.
381
+ *
382
+ * @returns {Promise<string[]>}
383
+ */
384
+ export async function addedLineSample(root, limit = 60) {
385
+ const diff = await rawDiff(root)
386
+ const sample = []
387
+ for (const line of diff.split('\n')) {
388
+ if (!line.startsWith('+') || line.startsWith('+++')) continue
389
+ const text = line.slice(1).trim()
390
+ if (text === '' || text.startsWith('/*') || text.startsWith('*') || text.startsWith('//')) continue
391
+ sample.push(text)
392
+ if (sample.length >= limit) break
393
+ }
394
+ return sample
395
+ }
396
+
397
+ /** Recent commit subjects, newest first, used only to match the repo's tone. */
398
+ export async function recentSubjects(root, count = 8) {
399
+ try {
400
+ const raw = await git(root, ['log', '-n', String(count), '--pretty=format:%s'], { timeoutMs: 8_000 })
401
+ return raw.split('\n').filter(line => line !== '')
402
+ } catch {
403
+ return []
404
+ }
405
+ }
406
+
407
+ /** Resolve HEAD's short hash, or undefined in a repository with no commits. */
408
+ export async function headShort(root) {
409
+ try {
410
+ return (await git(root, ['rev-parse', '--short', 'HEAD'], { timeoutMs: 8_000 })).trim()
411
+ } catch {
412
+ return undefined
413
+ }
414
+ }
415
+
416
+ /** Stage everything (the working tree) exactly as the skill does. */
417
+ export function stageAll(root, signal) {
418
+ if (signal?.aborted) throw new GitError('aborted', 'aborted', 'add')
419
+ return git(root, ['add', '-A'])
420
+ }
421
+ /** Commit what is staged. `-m` is always passed as its own argv element. */
422
+ export async function commit(root, message, identity) {
423
+ return git(root, ['commit', '-m', message], { identity, timeoutMs: 60_000 })
424
+ }
425
+
426
+ /**
427
+ * Validate a user- or caller-supplied tag name.
428
+ *
429
+ * Rejection is deliberate rather than sanitizing: silently rewriting the name
430
+ * the user asked for would create a tag they did not choose. `git check-ref-format`
431
+ * is the authority.
432
+ *
433
+ * @returns {Promise<string | undefined>} an error message, or undefined when valid
434
+ */
435
+ export async function tagNameError(root, tag) {
436
+ if (typeof tag !== 'string' || tag.trim() === '') return 'tag name is empty'
437
+ if (tag.startsWith('-')) return 'tag name must not start with "-"'
438
+ if (/\s/.test(tag)) return 'tag name must not contain whitespace'
439
+ try {
440
+ await git(root, ['check-ref-format', `refs/tags/${tag}`], { timeoutMs: 8_000 })
441
+ return undefined
442
+ } catch {
443
+ return `"${tag}" is not a valid git tag name`
444
+ }
445
+ }
446
+
447
+ /** Whether a tag of this name already exists locally. */
448
+ export async function tagExists(root, tag) {
449
+ try {
450
+ const out = await git(root, ['tag', '--list', tag], { timeoutMs: 8_000 })
451
+ return out.trim() !== ''
452
+ } catch {
453
+ return false
454
+ }
455
+ }
456
+
457
+ /** Create a lightweight tag. */
458
+ export function createTag(root, tag) {
459
+ return git(root, ['tag', tag], { timeoutMs: 15_000 })
460
+ }
461
+
462
+ /**
463
+ * Push the current branch.
464
+ *
465
+ * With no upstream this uses `-u origin <branch>`; otherwise it is a plain
466
+ * `push`. `tag` pushes exactly that one tag alongside the branch, as a second
467
+ * command, so a tag rejection cannot be confused with a branch rejection.
468
+ *
469
+ * @returns {Promise<{ ok: true } | { ok: false, code: string, stderr: string, reason: string }>}
470
+ */
471
+ export async function push(root, branch, options = {}) {
472
+ const { tag, hasUpstream: upstream = false } = options
473
+ const args = ['push']
474
+ if (!upstream) args.push('-u', 'origin', branch)
475
+ if (typeof tag === 'string' && tag !== '') args.push(`refs/tags/${tag}`)
476
+ try {
477
+ await git(root, args, { timeoutMs: NETWORK_TIMEOUT_MS })
478
+ return { ok: true }
479
+ } catch (error) {
480
+ const stderr = typeof error.stderr === 'string' ? error.stderr : ''
481
+ return {
482
+ ok: false,
483
+ code: error.code ?? 'git-failed',
484
+ stderr,
485
+ reason: classifyPushFailure(stderr) ?? error.message,
486
+ }
487
+ }
488
+ }
489
+
490
+ /** Turn a push failure's stderr into one machine-branchable reason. */
491
+ export function classifyPushFailure(stderr) {
492
+ if (stderr === '') return undefined
493
+ if (/non-fast-forward|fetch first|\[rejected\]|failed to push some refs/i.test(stderr)) return 'non-fast-forward'
494
+ if (/Authentication failed|could not read Username|Permission denied|terminal prompts disabled|HTTP 40[13]|access denied/i.test(stderr)) return 'auth'
495
+ if (/Could not resolve host|unable to access|Connection (refused|timed out)|network/i.test(stderr)) return 'network'
496
+ if (/protected branch|pre-receive hook declined|GH00\d/i.test(stderr)) return 'remote-policy'
497
+ return 'rejected'
498
+ }
499
+
500
+ /**
501
+ * Whether a rebase is currently in progress.
502
+ *
503
+ * Used to honour the promise that this plugin hands the repository back exactly
504
+ * as it found it: `git rebase --abort` must never be aimed at a rebase the user
505
+ * was already in the middle of.
506
+ */
507
+ export async function rebaseInProgress(root) {
508
+ try {
509
+ const gitDir = (await git(root, ['rev-parse', '--git-path', 'rebase-merge'], { timeoutMs: 8_000 })).trim()
510
+ if (existsSync(gitDir)) return true
511
+ const applyDir = (await git(root, ['rev-parse', '--git-path', 'rebase-apply'], { timeoutMs: 8_000 })).trim()
512
+ return existsSync(applyDir)
513
+ } catch {
514
+ // Unknown: assume the worst and let the caller refuse to abort.
515
+ return true
516
+ }
517
+ }
518
+
519
+ /**
520
+ * Reconcile a rejected push, then retry it once.
521
+ *
522
+ * Only `pull --rebase` is permitted here. A rebase that cannot finish is
523
+ * aborted so the repository is handed back in the state it was found in — and
524
+ * the abort is only ever issued when THIS function's pull is what started the
525
+ * rebase, so a rebase the user already had running is never discarded.
526
+ *
527
+ * @returns {Promise<{ ok: boolean, reason?: string, stderr?: string, rebased?: boolean }>}
528
+ */
529
+ export async function pushAfterRebase(root, branch, options = {}) {
530
+ const first = await push(root, branch, options)
531
+ if (first.ok) return { ok: true, rebased: false }
532
+
533
+ if (first.reason !== 'non-fast-forward') {
534
+ return { ok: false, reason: first.reason, stderr: first.stderr }
535
+ }
536
+
537
+ // A pre-existing rebase means the pull cannot proceed cleanly anyway, and
538
+ // aborting it would destroy the user's own operation.
539
+ if (await rebaseInProgress(root)) {
540
+ return { ok: false, reason: 'rebase-in-progress', stderr: first.stderr }
541
+ }
542
+
543
+ try {
544
+ await git(root, ['pull', '--rebase', '--no-edit'], { timeoutMs: NETWORK_TIMEOUT_MS })
545
+ } catch (error) {
546
+ const stderr = typeof error.stderr === 'string' ? error.stderr : ''
547
+ const conflict = /CONFLICT|could not apply|Merge conflict/i.test(stderr) || /CONFLICT|Merge conflict/i.test(error.message)
548
+ // Hand the repository back clean — but only for the rebase we started.
549
+ if (await rebaseInProgress(root)) {
550
+ try {
551
+ await git(root, ['rebase', '--abort'], { timeoutMs: 20_000 })
552
+ } catch {
553
+ // The abort failed; the report below is the truth either way.
554
+ }
555
+ }
556
+ return { ok: false, reason: conflict ? 'rebase-conflict' : 'pull-failed', stderr }
557
+ }
558
+
559
+ const second = await push(root, branch, options)
560
+ if (second.ok) return { ok: true, rebased: true }
561
+ return { ok: false, reason: second.reason, stderr: second.stderr, rebased: true }
562
+ }
@@ -0,0 +1,118 @@
1
+ /**
2
+ * Profile manifest edit shared by the Windows and macOS/Linux installers.
3
+ *
4
+ * Editing a user's profile `package.json` is the one step that must not go
5
+ * wrong, and doing it in a shell is where portability bugs live (`/dev/null` vs
6
+ * `NUL`, `sed -i` flag differences, JSON escaping). The installers therefore
7
+ * delegate this single step here, so both platforms run the SAME reviewed code
8
+ * and a fix lands on both at once.
9
+ *
10
+ * Guarantees, in order of importance:
11
+ *
12
+ * 1. It edits the parsed object IN PLACE and re-serializes it, so any field
13
+ * this script does not know about survives untouched (key order included).
14
+ * 2. It writes with NO BOM and a trailing newline. A BOM makes the file
15
+ * unparseable by `JSON.parse`, which would break the profile's boot.
16
+ * 3. It is idempotent: the plugin's dependency and bundle entries are removed
17
+ * then re-added exactly once, so running it twice cannot stack duplicates.
18
+ * 4. It verifies its own output by re-parsing the written bytes before
19
+ * reporting success.
20
+ *
21
+ * Usage:
22
+ * node lib/profile-edit.mjs --profile-dir <dir> --package <name> --link <dir> [--remove]
23
+ */
24
+ import { readFileSync, writeFileSync } from 'node:fs'
25
+ import { join } from 'node:path'
26
+
27
+ /** Parse `--flag value` pairs without pulling in a dependency. */
28
+ function parseArgs(argv) {
29
+ const args = {}
30
+ for (let index = 0; index < argv.length; index += 1) {
31
+ const token = argv[index]
32
+ if (!token.startsWith('--')) continue
33
+ const key = token.slice(2)
34
+ const next = argv[index + 1]
35
+ if (next === undefined || next.startsWith('--')) {
36
+ args[key] = true
37
+ } else {
38
+ args[key] = next
39
+ index += 1
40
+ }
41
+ }
42
+ return args
43
+ }
44
+
45
+ const args = parseArgs(process.argv.slice(2))
46
+ const profileDir = typeof args['profile-dir'] === 'string' ? args['profile-dir'] : undefined
47
+ const packageName = typeof args.package === 'string' ? args.package : undefined
48
+ const linkTarget = typeof args.link === 'string' ? args.link : undefined
49
+ const remove = args.remove === true
50
+
51
+ if (profileDir === undefined || packageName === undefined || (!remove && linkTarget === undefined)) {
52
+ console.error('usage: node profile-edit.mjs --profile-dir <dir> --package <name> --link <dir> [--remove]')
53
+ process.exit(2)
54
+ }
55
+
56
+ const manifestPath = join(profileDir, 'package.json')
57
+
58
+ let manifest
59
+ try {
60
+ manifest = JSON.parse(readFileSync(manifestPath, 'utf8'))
61
+ } catch (error) {
62
+ console.error(`cannot read the profile manifest at ${manifestPath}: ${error.message}`)
63
+ process.exit(1)
64
+ }
65
+
66
+ // A BOM survives into the first key of the object under some parsers; drop it.
67
+ if (manifest !== null && typeof manifest === 'object' && Object.hasOwn(manifest, '\uFEFF')) {
68
+ delete manifest['\uFEFF']
69
+ }
70
+
71
+ if (manifest.dsh === null || typeof manifest.dsh !== 'object') {
72
+ console.error(`${manifestPath} has no dsh section; refusing to guess where the plugin belongs`)
73
+ process.exit(1)
74
+ }
75
+ if (manifest.dsh.profile === null || typeof manifest.dsh.profile !== 'object') {
76
+ console.error(`${manifestPath} has no dsh.profile section`)
77
+ process.exit(1)
78
+ }
79
+
80
+ // 1. dependencies: drop any previous entry for this package, then re-add once.
81
+ const dependencies = { ...(manifest.dependencies ?? {}) }
82
+ delete dependencies[packageName]
83
+ if (!remove) dependencies[packageName] = `link:${linkTarget}`
84
+ manifest.dependencies = dependencies
85
+
86
+ // 2. bundles: same idempotence, and always a real array (never a bare string,
87
+ // which is what a single-element array can collapse to in some editors).
88
+ const currentBundles = manifest.dsh.profile.bundles
89
+ const bundles = (Array.isArray(currentBundles) ? currentBundles : currentBundles === undefined ? [] : [currentBundles])
90
+ .filter(entry => typeof entry === 'string' && entry !== packageName)
91
+ if (!remove) bundles.push(packageName)
92
+ manifest.dsh.profile.bundles = bundles
93
+
94
+ // 3. Write without a BOM. `JSON.stringify(x, null, 2)` is what makes the diff
95
+ // readable for the user, who will review this file.
96
+ const text = `${JSON.stringify(manifest, null, 2)}\n`
97
+ writeFileSync(manifestPath, text, { encoding: 'utf8' })
98
+
99
+ // 4. Prove the bytes we just wrote are valid JSON and carry the intended state.
100
+ const verify = JSON.parse(readFileSync(manifestPath, 'utf8'))
101
+ if (verify.dependencies?.[packageName] !== undefined && remove) {
102
+ console.error('verification failed: the dependency is still present after removal')
103
+ process.exit(1)
104
+ }
105
+ if (!remove && verify.dependencies?.[packageName] !== `link:${linkTarget}`) {
106
+ console.error('verification failed: the dependency was not written as expected')
107
+ process.exit(1)
108
+ }
109
+ if (remove && verify.dsh.profile.bundles.includes(packageName)) {
110
+ console.error('verification failed: the bundle is still listed after removal')
111
+ process.exit(1)
112
+ }
113
+ if (!remove && !verify.dsh.profile.bundles.includes(packageName)) {
114
+ console.error('verification failed: the bundle was not listed')
115
+ process.exit(1)
116
+ }
117
+
118
+ console.log(`package.json updated (bundles: ${verify.dsh.profile.bundles.join(', ')})`)