@amsterdamdatalabs/adl-agent-control-linux-x64 0.1.10 → 0.1.12

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.
@@ -1,848 +0,0 @@
1
- /**
2
- * git-maintain — keep local repos fresh and tidy.
3
- *
4
- * For each repo under $GIT_MAINTAIN_ROOT whose directory name matches
5
- * $GIT_MAINTAIN_GLOB (and Git recognizes it as a worktree):
6
- * 1. fetch origin --prune (drop stale remote-tracking refs)
7
- * 2. fast-forward the local baseline branch (integration, else main/master)
8
- * WITHOUT disturbing the working tree or current checkout; a checked-out
9
- * baseline is fast-forwarded only when clean (`merge --ff-only`)
10
- * 3. prune old local branches, keeping:
11
- * - protected: integration / main / master / develop
12
- * - current HEAD (never deleted)
13
- * - the $KEEP most-recently-committed non-protected branches
14
- * Deletion is SAFE-ONLY:
15
- * - upstream "[gone]" (remote branch deleted after merge) -> git branch -D
16
- * - true ancestor of baseline (--merged) -> git branch -d
17
- * - anything else -> kept + logged
18
- * It NEVER force-deletes a branch that cannot be confirmed merged.
19
- *
20
- * Env:
21
- * GIT_MAINTAIN_ROOT (default: $HOME/Documents/Repos)
22
- * GIT_MAINTAIN_GLOB (default: adl-*)
23
- * GIT_MAINTAIN_KEEP (default: 3)
24
- * GIT_MAINTAIN_DRY (1 = report only: fetches, but never moves a local
25
- * ref, fast-forwards a checkout, or deletes a branch)
26
- *
27
- * CLI:
28
- * --repo <path> (repeatable) an explicit, closed list of repos, as
29
- * an alternative to the GIT_MAINTAIN_ROOT/GLOB
30
- * recursive scan above -- see `parseArgv`'s own
31
- * header comment for why this exists (the fleet's
32
- * Linux VPS checkouts live at a fixed, root-owned
33
- * path with no home-relative root to scan). When any
34
- * `--repo` is given, the scan is skipped entirely.
35
- * --arm-only used together with --repo. Runs ONLY `armHooks` for
36
- * the declared repos -- no fetch, no fast-forward,
37
- * no branch prune, no bundle-refresh installer, no
38
- * network at all.
39
- *
40
- * ARM-ONLY, why it exists (coordinator decision, 2026-09-30): the Linux
41
- * VPSes' `git_maintain_repos` names their OWN fleet checkout
42
- * (`/opt/adl-azdo`), and running the FULL job there -- fetch, fast-forward,
43
- * prune, bundle-refresh -- turned out to be new, unapproved, and risky
44
- * behaviour once actually reasoned through, not merely a self-checkout
45
- * ordering hazard (see `main`'s own placement comment in bin/reconcile for
46
- * that separate, narrower ordering concern):
47
- * - the fast-forward step (2 above) would silently auto-deploy whatever
48
- * `origin/integration` (or main) has advanced to, as root, onto a
49
- * production checkout -- `bin/sync-and-reconcile` never pulls in
50
- * checkout source mode today, so this would have been genuinely NEW
51
- * auto-deploy behaviour, not a restoration of an existing one;
52
- * - `fleet-cutover.sh` re-reads `fleet-retire-list.json` from that same
53
- * checkout AFTER reconcile runs (fleet-cutover.sh:1129-1135, :254) --
54
- * mutating the checkout mid-cutover is exactly the class of hazard this
55
- * job's own "run last" placement in bin/reconcile was trying to avoid,
56
- * generalized to a tool this job doesn't control;
57
- * - branches get deleted (`git branch -D`) as root on a checkout other
58
- * tooling depends on being stable;
59
- * - the fetch step still runs in dry/plan/audit mode (see GIT_MAINTAIN_DRY
60
- * above) even though nothing else does, and its own failure path exits
61
- * 0, not 1 -- a network hiccup would be silently absorbed rather than
62
- * reported, for a step this use case has no reason to run at all.
63
- * The actual goal was only ever arming `core.hooksPath` -- the ONE thing
64
- * `.git/config` never getting the repo's own committed value is impossible
65
- * to route around any other way (unlike the fetch/prune/refresh above, all
66
- * of which land safely through this job's macOS LaunchAgent path or
67
- * through the ordinary bundle-refresh `refreshRepo` already runs
68
- * elsewhere). `--arm-only` narrows the Linux invocation to exactly that.
69
- *
70
- * Logs go to stdout; on macOS the LaunchAgent (packages/fleet/manifest.json
71
- * mac.common.launch_agents.git-maintain) captures stdout+stderr in
72
- * ~/Library/Logs/adl/git-maintain.log. On Linux there is no LaunchAgent
73
- * equivalent -- see `parseArgv` -- `bin/reconcile` instead runs this file
74
- * directly (via `bun`, not a compiled binary) on its own schedule, in
75
- * `--arm-only` mode, whenever a device declares `git_maintain_repos` in the
76
- * manifest.
77
- *
78
- * Reconcile ALSO compiles this file into the dedicated, ad-hoc-signed binary
79
- * /usr/local/libexec/adl/git-maintain for the macOS LaunchAgent path (see
80
- * packages/fleet/src/launch-agents.ts). Under launchd that binary is the
81
- * responsible process for macOS privacy protection (TCC), so the owner grants
82
- * Full Disk Access to exactly that binary; the `git` processes it spawns are
83
- * attributed to it. A denied listing is logged as `TCC denied` with the exact
84
- * remedy, and reconcile surfaces that last log line. None of that TCC
85
- * machinery applies to the Linux `--repo` path above, which runs as `root`.
86
- */
87
-
88
- import { spawnSync } from 'node:child_process'
89
- import { lstatSync, readFileSync, readdirSync, realpathSync, statSync, type Dirent } from 'node:fs'
90
- import { homedir } from 'node:os'
91
- import { basename, isAbsolute, join, relative, resolve } from 'node:path'
92
- // F6 (independent review, 2026-09-29): this job's `git fetch` runs from a
93
- // LaunchAgent with no TTY and no ADL_AGENT_ID_AGENT, so once bin/git-ado's
94
- // fail-closed rule (STATUS.md 12p decision 1) is deployed, every Azure
95
- // DevOps fetch was refused. The one shared resolver every other Azure
96
- // DevOps surface in this repo uses, never re-implemented here.
97
- import { NO_AGENT_IDENTITY_MESSAGE, resolveAgentIdentity } from '../../../../azdo/src/lib/agent-identity.ts'
98
-
99
- export const TCC_DENIED_MARKER = 'TCC denied'
100
-
101
- /**
102
- * Matches packages/fleet/src/launch-agents.ts's DEFAULT_LIBEXEC_DIR -- a
103
- * literal duplicate rather than an import, deliberately: launch-agents.ts
104
- * already imports TCC_DENIED_MARKER FROM this file, so importing the
105
- * constant back from there would make the two files circular.
106
- */
107
- const LIBEXEC_DIR = '/usr/local/libexec/adl'
108
-
109
- /**
110
- * The file whose presence marks a repo adl hook-managed. It carries several
111
- * owners' tables (`version`/`hooks`/`rules`/`repo` = adl hook, `extensions` =
112
- * agent-skills, `controls` = repo-controls) plus hand-written
113
- * blocks, so an installer that rewrites it wholesale silently deletes another
114
- * owner's section.
115
- *
116
- * The invariant is ADDITIVE-ONLY, not byte-identical. Measured 2026-09-15
117
- * against the real binaries (then named `adl-repo-controls install --repo X
118
- * --force`; see the adl-m3-finish item-1/2/3 rename below): the run
119
- * appends `[controls]`, `[controls.compliance]` and
120
- * `[controls.compliance.trees]` while leaving a pre-existing `[extensions]` table
121
- * and comments intact. Writing its own table IS an installer's job, so a
122
- * byte-identical assertion would fail on every legitimate run. What must never
123
- * happen is LOSING a table or comment that was there before — that is the
124
- * shredder case, and it is what this job refuses to continue past.
125
- */
126
- export const MARKER = 'adl-config.toml'
127
-
128
- /**
129
- * Installers that own repo-local surfaces, with the argv each actually takes.
130
- *
131
- * adl-m3-finish items 1-3 (2026-09-27): neither installer is a standalone
132
- * global command any more. `adl repo controls` folds repo-controls into the
133
- * one `adl` binary (docs/architecture/cli-design.md §6, STATUS.md item 10) -- `adl` is
134
- * itself fleet-installed by the `bun-compile` manager, so it is guaranteed
135
- * on PATH by the time this job runs. agent-skills has no `adl` fold-in yet
136
- * (a separate, not-yet-done item), so it is invoked directly from its
137
- * `repo-dir`-deployed copy (packages/fleet/manifest.json's "agent-skills"
138
- * tool, `bin/reconcile`'s `write_one_repo_dir`) via `bun`, the same way
139
- * every other bundled `.mjs`/`.ts` entry point in this job already is.
140
- *
141
- * The bundle NAME must come before `--repo`
142
- * (`install <name> --repo <path> --host claude,codex,cursor,kimi`); called without it, it exits 1 with
143
- * "install --repo requires a plugin name". `adl-repo` is the baseline bundle
144
- * its own `list --json` advertises. OpenCode is deliberately absent: its hook
145
- * carrier is a VPS-owned global artifact, not a direct repo-local host.
146
- *
147
- * Each installer's `repoArgs` carries only the flags that installer's own
148
- * `parse()` actually accepts (checked against the real CLIs, not stubs):
149
- * agent-skills' `adl-extensions.mjs install` accepts `--force` (a no-op kept
150
- * for compatibility — its own lock-owned content is always repaired on a
151
- * plain install); repo-controls' `install` (packages/repo-controls/src/
152
- * cli.ts) recognizes only `--dry-run` and exits 1 on an unknown flag, so it
153
- * gets none. `--force` used to be appended to every installer's argv
154
- * unconditionally, which made every real (non-stubbed) run of the
155
- * repo-controls installer fail with "unknown option for install: --force" --
156
- * install is already idempotent there, so nothing is lost by not forcing it.
157
- */
158
- export const INSTALLERS: readonly {
159
- readonly bin: string
160
- readonly args: readonly string[]
161
- readonly repoArgs?: readonly string[]
162
- }[] = [
163
- {
164
- bin: 'bun',
165
- args: [join(LIBEXEC_DIR, 'agent-skills', 'scripts', 'adl-extensions.mjs'), 'install', 'adl-repo'],
166
- repoArgs: ['--host', 'claude,codex,cursor,kimi', '--force'],
167
- },
168
- { bin: 'adl', args: ['repo', 'controls', 'install'] },
169
- ]
170
-
171
- /** Table headers and comment lines — the structure a refresh must never remove. */
172
- export function markerEntries(text: string): string[] {
173
- return text
174
- .split('\n')
175
- .map((line) => line.trim())
176
- .filter((line) => line.startsWith('[') || line.startsWith('#'))
177
- }
178
-
179
- const PROTECTED = new Set(['integration', 'main', 'master', 'develop'])
180
-
181
- function timestamp(date = new Date()): string {
182
- const pad = (value: number) => String(value).padStart(2, '0')
183
- const offset = -date.getTimezoneOffset()
184
- const sign = offset >= 0 ? '+' : '-'
185
- const abs = Math.abs(offset)
186
- return (
187
- `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}` +
188
- `T${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(date.getSeconds())}` +
189
- `${sign}${pad(Math.floor(abs / 60))}${pad(abs % 60)}`
190
- )
191
- }
192
-
193
- function log(message: string): void {
194
- process.stdout.write(`[${timestamp()}] ${message}\n`)
195
- }
196
-
197
- /**
198
- * Lines explaining why ROOT could not be listed. EPERM from readdir is what
199
- * macOS privacy protection returns (plain permission bits give EACCES), so it
200
- * names the exact binary the owner must grant Full Disk Access.
201
- *
202
- * ENOTDIR is a misconfiguration -- the path is the right name but the wrong
203
- * kind (e.g. a file where a directory was expected). The message is the
204
- * scanner's own ("not a directory"), not a readdir-failure wrapper.
205
- */
206
- export function listFailureLines(root: string, error: unknown, executable: string): readonly string[] {
207
- const message = error instanceof Error ? error.message : String(error)
208
- const code = (error as { code?: unknown } | null)?.code
209
- if (code === 'ENOTDIR') return [`${root}: ${message}`]
210
- const lines = [`${root}: cannot list: ${message}`]
211
- if (code === 'EPERM') {
212
- lines.push(
213
- `${root}: ${TCC_DENIED_MARKER}: macOS privacy protection blocked ${executable}; ` +
214
- `one-time fix: System Settings › Privacy & Security › Full Disk Access › + › add ${executable}`,
215
- )
216
- }
217
- return lines
218
- }
219
-
220
- export interface RepositoryScope {
221
- readonly root: string
222
- readonly glob: string
223
- readonly recursive: true
224
- }
225
-
226
- const INSTALLED_MANIFEST = '/var/lib/adl/applied-manifest.json'
227
-
228
- /** Read the installed LaunchAgent scope; its root is always relative to the user's home. */
229
- export function readRepositoryScope(manifestPath: string, home: string, platform: 'mac' | 'linux'): RepositoryScope {
230
- const manifest = JSON.parse(readFileSync(manifestPath, 'utf8')) as Record<string, unknown>
231
- const section = manifest[platform] as Record<string, unknown> | undefined
232
- const common = section?.common as Record<string, unknown> | undefined
233
- const agents = common?.launch_agents as Record<string, unknown> | undefined
234
- const job = agents?.['git-maintain'] as Record<string, unknown> | undefined
235
- const scope = job?.repo_scope as Record<string, unknown> | undefined
236
- if (
237
- typeof scope?.root !== 'string' ||
238
- scope.root.length === 0 ||
239
- isAbsolute(scope.root) ||
240
- scope.root.split(/[\\/]/).includes('..') ||
241
- typeof scope.glob !== 'string' ||
242
- scope.glob.length === 0 ||
243
- scope.recursive !== true
244
- ) {
245
- throw new Error(`${manifestPath}: mac.common.launch_agents.git-maintain.repo_scope must declare a home-relative root, glob, and recursive: true`)
246
- }
247
- const root = resolve(home, scope.root)
248
- const fromHome = relative(resolve(home), root)
249
- if (fromHome === '..' || fromHome.startsWith(`..${process.platform === 'win32' ? '\\\\' : '/'}`) || isAbsolute(fromHome)) {
250
- throw new Error(`${manifestPath}: git-maintain repo_scope.root escapes the user's home`)
251
- }
252
- return { root, glob: scope.glob, recursive: true }
253
- }
254
-
255
- /**
256
- * The fleet root is `~/Documents`; managed checkouts also live one level down
257
- * in `~/Documents/Repos`. Both are in scope. A non-directory entry with this
258
- * name at the top of the root is a misconfiguration -- silent skip would mask
259
- * it (status 0, no maintenance performed).
260
- */
261
- const TOP_LEVEL_COLLECTION = 'Repos'
262
-
263
- /** Recursively locate real Git roots, including worktrees, without following symlinks. */
264
- export function discoverGitRepositories(
265
- root: string,
266
- scope: Pick<RepositoryScope, 'glob' | 'recursive'>,
267
- onError: (path: string, error: unknown) => void = () => {},
268
- ): string[] {
269
- const matcher = globToRegExp(scope.glob)
270
- const repositories: string[] = []
271
- const visit = (directory: string): void => {
272
- let entries: Dirent[]
273
- try {
274
- entries = readdirSync(directory, { withFileTypes: true })
275
- } catch (error) {
276
- onError(directory, error)
277
- return
278
- }
279
- if (directory === root) {
280
- const collection = entries.find((entry) => entry.name === TOP_LEVEL_COLLECTION)
281
- if (collection !== undefined && !collection.isDirectory()) {
282
- onError(
283
- join(directory, TOP_LEVEL_COLLECTION),
284
- Object.assign(new Error('not a directory'), { code: 'ENOTDIR' }),
285
- )
286
- return
287
- }
288
- }
289
- try {
290
- lstatSync(join(directory, '.git'))
291
- const topLevel = spawnSync('git', ['-C', directory, 'rev-parse', '--show-toplevel'], { encoding: 'utf8' })
292
- if (topLevel.error !== undefined) throw topLevel.error
293
- if (topLevel.status === 0 && realpathSync(topLevel.stdout.trim()) === realpathSync(directory) && matcher.test(basename(directory))) {
294
- repositories.push(directory)
295
- }
296
- } catch (error) {
297
- if ((error as { code?: unknown } | null)?.code !== 'ENOENT') onError(join(directory, '.git'), error)
298
- }
299
- if (scope.recursive) {
300
- for (const entry of entries) {
301
- if (entry.isDirectory() && entry.name !== '.git') visit(join(directory, entry.name))
302
- }
303
- }
304
- }
305
- visit(root)
306
- return repositories.sort()
307
- }
308
-
309
- interface GitResult {
310
- readonly ok: boolean
311
- readonly stdout: string
312
- /**
313
- * Captured so a failure can report what git SAID.
314
- *
315
- * This was discarded, which is why the fetch failure below could only guess
316
- * at a cause -- the information never reached the call site. `.donor` then
317
- * failed every run for hours under a logged `(offline?)` that nobody had
318
- * checked, while the real message on an https Azure DevOps remote is
319
- * `could not read Username`: an authentication problem, not a network one.
320
- */
321
- readonly stderr: string
322
- }
323
-
324
- /**
325
- * F6: the resolved agent identity's env overlay for every `git` child this
326
- * job spawns, set once in `main()` before the repo loop and merged into
327
- * EVERY call (not only `fetch`) -- harmless for local-only commands, and
328
- * simpler than threading it through every one of this file's ~30 call
329
- * sites. Empty until `main()` resolves an identity; a repo with no
330
- * Azure DevOps remote is unaffected either way.
331
- */
332
- let adoIdentityEnv: NodeJS.ProcessEnv = {}
333
-
334
- function git(repo: string, ...args: string[]): GitResult {
335
- const result = spawnSync('git', ['-C', repo, ...args], { encoding: 'utf8', env: { ...process.env, ...adoIdentityEnv } })
336
- if (result.error !== undefined) throw result.error
337
- return { ok: result.status === 0, stdout: result.stdout, stderr: result.stderr ?? '' }
338
- }
339
-
340
- /** Matches bin/git's own Azure DevOps HTTPS remote check (F6/F2). */
341
- const ADO_REMOTE_PATTERN = /^https:\/\/[^/]*dev\.azure\.com\//
342
-
343
- /** Cheap, local-only (no network): reads the configured `origin` URL. */
344
- function originIsAzureDevOps(repo: string): boolean {
345
- const url = git(repo, 'remote', 'get-url', 'origin')
346
- return url.ok && ADO_REMOTE_PATTERN.test(url.stdout.trim())
347
- }
348
-
349
- function lines(output: string): string[] {
350
- return output.split('\n').filter((line) => line.length > 0)
351
- }
352
-
353
- /** find -name semantics for the simple glob characters `*`, `?` and `[...]`. */
354
- function globToRegExp(glob: string): RegExp {
355
- let pattern = ''
356
- for (let index = 0; index < glob.length; index++) {
357
- const char = glob[index]!
358
- if (char === '*') pattern += '.*'
359
- else if (char === '?') pattern += '.'
360
- else if (char === '[') {
361
- const close = glob.indexOf(']', index + 1)
362
- if (close === -1) pattern += '\\['
363
- else {
364
- const body = glob.slice(index + 1, close).replace(/^!/, '^').replaceAll('\\', '\\\\')
365
- pattern += `[${body}]`
366
- index = close
367
- }
368
- } else pattern += char.replace(/[.+^${}()|\\/\]]/g, '\\$&')
369
- }
370
- return new RegExp(`^${pattern}$`, 's')
371
- }
372
-
373
- function isDirectory(path: string): boolean {
374
- try {
375
- return statSync(path).isDirectory()
376
- } catch {
377
- return false
378
- }
379
- }
380
-
381
- function baselineFor(repo: string): string {
382
- for (const branch of ['integration', 'main', 'master']) {
383
- if (git(repo, 'show-ref', '--verify', '--quiet', `refs/remotes/origin/${branch}`).ok) return branch
384
- }
385
- return ''
386
- }
387
-
388
- function maintainRepo(repo: string, keep: number, dry: boolean): void {
389
- const name = basename(repo)
390
- if (!git(repo, 'rev-parse', '--git-dir').ok) return
391
-
392
- const head = git(repo, 'symbolic-ref', '--short', '-q', 'HEAD')
393
- const current = head.ok ? head.stdout.trim() : '(detached)'
394
-
395
- // 1. fetch + prune
396
- //
397
- // Report what git SAID, never a guess at what it meant. This logged
398
- // `fetch failed (offline?)` and discarded the stderr, so `.donor` failed
399
- // every run for hours while the log asserted a cause nobody had checked --
400
- // and on an https Azure DevOps remote the real message is
401
- // `could not read Username`, which is an authentication problem and not a
402
- // network one. A wrong cause in a log is worse than no cause: it stops the
403
- // next person looking.
404
- const fetched = git(repo, 'fetch', 'origin', '--prune', '--quiet')
405
- if (!fetched.ok) {
406
- const reason = fetched.stderr.trim().split('\n').pop() ?? 'no stderr from git'
407
- log(`${name}: fetch failed, skipping repo: ${reason}`)
408
- return
409
- }
410
-
411
- const base = baselineFor(repo)
412
-
413
- // 2. fast-forward baseline without touching the working tree
414
- if (base !== '') {
415
- const local = `refs/heads/${base}`
416
- const remote = `refs/remotes/origin/${base}`
417
- const behind =
418
- git(repo, 'show-ref', '--verify', '--quiet', local).ok &&
419
- git(repo, 'rev-parse', local).stdout.trim() !== git(repo, 'rev-parse', remote).stdout.trim() &&
420
- git(repo, 'merge-base', '--is-ancestor', local, remote).ok
421
- if (current === base) {
422
- if (git(repo, 'status', '--porcelain').stdout.trim() !== '') {
423
- log(`${name}: ${base} checked out but dirty — skip FF`)
424
- } else if (behind && dry) {
425
- log(`${name}: [dry] would fast-forward ${base} (checked out)`)
426
- } else if (behind) {
427
- if (git(repo, 'merge', '--ff-only', `origin/${base}`, '--quiet').ok) {
428
- log(`${name}: ${base} fast-forwarded (checked out)`)
429
- }
430
- }
431
- } else if (behind) {
432
- // update the local ref directly; FF-safe, no working-tree change
433
- if (dry) log(`${name}: [dry] would fast-forward ${base} (background)`)
434
- else if (git(repo, 'fetch', 'origin', `${base}:${base}`, '--quiet').ok) {
435
- log(`${name}: ${base} ref fast-forwarded (background)`)
436
- }
437
- }
438
- }
439
-
440
- // 3. guarded prune
441
- // Keep-set: top-KEEP most-recent non-protected, non-current branches.
442
- const keptRecent = new Set(
443
- lines(git(repo, 'for-each-ref', '--sort=-committerdate', 'refs/heads/', '--format=%(refname:short)').stdout)
444
- .filter((branch) => !PROTECTED.has(branch) && branch !== current)
445
- .slice(0, keep),
446
- )
447
- const merged =
448
- base === ''
449
- ? new Set<string>()
450
- : new Set(lines(git(repo, 'branch', '--merged', `origin/${base}`, '--format=%(refname:short)').stdout))
451
-
452
- for (const line of lines(git(repo, 'for-each-ref', 'refs/heads/', '--format=%(refname:short)|%(upstream:track)').stdout)) {
453
- const separator = line.indexOf('|')
454
- const branch = line.slice(0, separator)
455
- const track = line.slice(separator + 1)
456
- if (PROTECTED.has(branch) || branch === current || keptRecent.has(branch)) continue
457
-
458
- if (track.includes('[gone]')) {
459
- // remote branch deleted after merge -> proven safe
460
- if (dry) log(`${name}: [dry] would delete (upstream gone) ${branch}`)
461
- else if (git(repo, 'branch', '-D', branch).ok) log(`${name}: deleted (upstream gone) ${branch}`)
462
- else log(`${name}: FAILED delete ${branch}`)
463
- } else if (base !== '' && merged.has(branch)) {
464
- if (dry) log(`${name}: [dry] would delete (merged into ${base}) ${branch}`)
465
- else if (git(repo, 'branch', '-d', branch).ok) log(`${name}: deleted (merged into ${base}) ${branch}`)
466
- else log(`${name}: kept (${branch}: -d refused)`)
467
- } else {
468
- log(`${name}: kept (unconfirmed merged) ${branch}`)
469
- }
470
- }
471
- }
472
-
473
- function isFile(path: string): boolean {
474
- try {
475
- return statSync(path).isFile()
476
- } catch {
477
- return false
478
- }
479
- }
480
-
481
- /**
482
- * Re-install the repo-local surfaces into `repo`, if it carries the marker.
483
- * Returns false if anything went wrong — a missing installer, a failing one,
484
- * or a disturbed marker. A missing installer is a LOUD failure, never a quiet
485
- * skip: "marker found, installer absent, nothing happened, no error" is
486
- * exactly the presence-is-not-armedness shape this refresh exists to remove.
487
- *
488
- * Never commits and never stages: it runs the installers and nothing else.
489
- */
490
- function refreshRepo(repo: string, dry: boolean): boolean {
491
- const name = basename(repo)
492
- const marker = join(repo, MARKER)
493
- if (!isFile(marker)) return true
494
-
495
- const argvFor = (installer: (typeof INSTALLERS)[number]) => [
496
- ...installer.args,
497
- '--repo',
498
- repo,
499
- ...(installer.repoArgs ?? []),
500
- ]
501
-
502
- if (dry) {
503
- for (const installer of INSTALLERS) {
504
- log(`${name}: [dry] would refresh via ${installer.bin} ${argvFor(installer).join(' ')}`)
505
- }
506
- return true
507
- }
508
-
509
- const beforeText = readFileSync(marker, 'utf8')
510
- const mtimeBefore = statSync(marker).mtimeMs
511
- const before = markerEntries(beforeText)
512
- let ok = true
513
-
514
- for (const installer of INSTALLERS) {
515
- const argv = argvFor(installer)
516
- const result = spawnSync(installer.bin, argv, { encoding: 'utf8' })
517
- const code = (result.error as { code?: unknown } | undefined)?.code
518
- if (code === 'ENOENT') {
519
- log(`${name}: ${installer.bin} not found on PATH — refresh incomplete, repo left stale`)
520
- ok = false
521
- } else if (result.error !== undefined) {
522
- log(`${name}: ${installer.bin} failed to run: ${result.error.message}`)
523
- ok = false
524
- } else if (result.status !== 0) {
525
- log(`${name}: ${installer.bin} ${argv.join(' ')} exited ${result.status}: ${result.stderr.trim()}`)
526
- ok = false
527
- } else {
528
- log(`${name}: refreshed via ${installer.bin}`)
529
- }
530
- }
531
-
532
- const afterText = readFileSync(marker, 'utf8')
533
- const after = markerEntries(afterText)
534
- const lost = before.filter((entry) => !after.includes(entry))
535
-
536
- if (lost.length > 0) {
537
- log(`${name}: ${MARKER} LOST ${lost.join(', ')} at ${repo} — another owner's section was destroyed; refusing to call this a clean refresh`)
538
- ok = false
539
- } else if (afterText === beforeText && statSync(marker).mtimeMs === mtimeBefore) {
540
- // The steady state, and what every run after a repo's first must look like.
541
- log(`${name}: ${MARKER} untouched`)
542
- } else {
543
- // An installer creating its own missing table: legitimate exactly once per
544
- // repo. Seen on a repo that already had every table, it means an installer
545
- // stopped being idempotent — so it is logged, loudly, rather than ignored.
546
- log(`${name}: ${MARKER} gained ${after.filter((entry) => !before.includes(entry)).join(', ') || '(no new table)'} at ${repo} — expected only on a repo's first install`)
547
- }
548
-
549
- return ok
550
- }
551
-
552
- /**
553
- * The repository-relative hooks directory repo-controls installs into every
554
- * enrolled repo (packages/repo-controls/src/constants.ts's own HOOKS_PATH) --
555
- * a literal duplicate here, deliberately, the same as LIBEXEC_DIR above:
556
- * this job needs only the one string, and it never changes independent of
557
- * the layout `adl repo controls install` itself writes.
558
- */
559
- const HOOKS_DIR = '.workflows/hooks'
560
-
561
- /**
562
- * Arms `core.hooksPath` for a repo carrying the MARKER, if the repo also
563
- * carries a committed `.workflows/hooks` directory.
564
- *
565
- * `core.hooksPath` lives in `.git/config`, which never travels with a clone
566
- * -- a repo cloned onto a fresh machine keeps its committed hook stubs but
567
- * stays unarmed (every commit silently skips every gate) until someone runs
568
- * `adl repo controls install` there by hand. `refreshRepo` above already
569
- * runs that installer on this job's own schedule, but only on a repo that
570
- * ALREADY has the marker's tables in place; this closes the narrower gap of
571
- * the git-local setting itself, directly, on the same schedule, without
572
- * depending on that installer being present or succeeding.
573
- *
574
- * Scope is exactly the marker AND a committed hooks directory -- never a
575
- * repo missing either. Correction (coordinator, 2026-09-30): a repo in that
576
- * scope is ENROLLED, and an enrolled repo has no valid reason for
577
- * `core.hooksPath` to point anywhere else -- it means the repo has NO gates
578
- * (every commit silently skips every one of them), not a deliberate owner
579
- * choice worth preserving. So a wrong value is restored, exactly like
580
- * `packages/repo-controls/src/install.ts`'s own unconditional restore
581
- * (which `refreshRepo` above already runs as part of this job's bundle
582
- * refresh) -- this aligns with that standard instead of deviating from it.
583
- *
584
- * Returns false only when arming was attempted and the write itself failed
585
- * -- a repo skipped by scope or already armed returns true; none of those
586
- * is a fleet failure.
587
- */
588
- function armHooks(repo: string, dry: boolean): boolean {
589
- const name = basename(repo)
590
- if (!isFile(join(repo, MARKER))) return true
591
- if (!isDirectory(join(repo, HOOKS_DIR))) return true
592
-
593
- const current = git(repo, 'config', '--local', '--get', 'core.hooksPath')
594
- const value = current.ok ? current.stdout.trim() : ''
595
-
596
- if (value === HOOKS_DIR) {
597
- log(`${name}: hooksPath already armed (${HOOKS_DIR})`)
598
- return true
599
- }
600
- const wasUnset = value === ''
601
- const suffix = wasUnset ? '' : ` (was ${value})`
602
- if (dry) {
603
- log(`${name}: [dry] would ${wasUnset ? 'arm' : 're-arm'} core.hooksPath=${HOOKS_DIR}${suffix}`)
604
- return true
605
- }
606
- if (git(repo, 'config', '--local', 'core.hooksPath', HOOKS_DIR).ok) {
607
- log(`${name}: ${wasUnset ? 'armed' : 're-armed'} core.hooksPath=${HOOKS_DIR}${suffix}`)
608
- return true
609
- }
610
- log(`${name}: FAILED to arm core.hooksPath`)
611
- return false
612
- }
613
-
614
- export interface ParsedArgv {
615
- readonly repos: string[]
616
- readonly armOnly: boolean
617
- }
618
-
619
- /**
620
- * Strict argv parser. Recognizes exactly two tokens:
621
- *
622
- * --repo <path> (repeatable) -- an explicit, closed list of
623
- * repositories, as an alternative to the recursive
624
- * root+glob scan `discoverGitRepositories` performs. This
625
- * is how `bin/reconcile` reaches the fleet's own Linux VPS
626
- * checkouts (see `main`'s header comment for why): those
627
- * live at a fixed path outside any device's home (e.g.
628
- * `/opt/adl-azdo`, root-owned), so there is no
629
- * home-relative root to recursively scan in the first
630
- * place -- and a recursive scan from a shared parent like
631
- * `/opt` would also walk into unrelated, explicitly
632
- * off-limits directories (Azure Pipelines agent installs).
633
- * An explicit path list structurally cannot do that: it
634
- * only ever touches exactly what it is given.
635
- *
636
- * --arm-only REQUIRES at least one --repo (enforced here, not just
637
- * documented: --arm-only alone is a usage error) -- see
638
- * `main`'s ARM-ONLY comment for the full decision. Runs
639
- * ONLY `armHooks` for the declared repos -- no fetch, no
640
- * fast-forward, no branch prune, no bundle-refresh
641
- * installer, no network at all.
642
- *
643
- * Both are CLI flags, not environment variables: `bin/reconcile` computes
644
- * this data from the manifest itself and passes it as argv, the same
645
- * convention already used for every other bun-invoked step in that script
646
- * (`--platform <p> --mode <m>`, never a bespoke env var for data reconcile
647
- * already has).
648
- *
649
- * ANY other token -- an unrecognized flag, `--repo` with no following
650
- * value, `--repo=/path` (the `=` form is not supported; write `--repo
651
- * /path`), or a near-miss like `--rep` -- is a loud, fatal usage error.
652
- * This must never happen: silently ignoring or misreading an argument here
653
- * and falling through to the recursive scope-resolution path below would
654
- * silently WIDEN what this run touches, far past whatever was actually
655
- * declared.
656
- */
657
- export function parseArgv(argv: readonly string[]): ParsedArgv {
658
- const repos: string[] = []
659
- let armOnly = false
660
- for (let index = 0; index < argv.length; index++) {
661
- const token = argv[index]!
662
- if (token === '--repo') {
663
- const value = argv[index + 1]
664
- if (value === undefined) throw new Error('--repo requires a path argument')
665
- repos.push(value)
666
- index++
667
- } else if (token === '--arm-only') {
668
- armOnly = true
669
- } else {
670
- throw new Error(`unknown argument: ${token}`)
671
- }
672
- }
673
- if (armOnly && repos.length === 0) throw new Error('--arm-only requires at least one --repo')
674
- return { repos, armOnly }
675
- }
676
-
677
- /**
678
- * Path names, or resolves to (via realpath, so a symlink cannot launder
679
- * it), something naming an Azure Pipelines agent install -- case
680
- * insensitive, matching bin/validate-manifest's own schema-layer guard on
681
- * `git_maintain_repos` (independent review, 2026-09-30: that guard alone
682
- * only ever sees the manifest's literal string; this is the same check
683
- * enforced again at the point of actual use, so a hand-typed `--repo`
684
- * argument or a symlinked path bypassing the manifest entirely is refused
685
- * too). This estate's own agent directories/services are never named here,
686
- * on purpose: this function exists so this job can refuse them, not so it
687
- * can reference them as a path it might otherwise touch.
688
- */
689
- const FORBIDDEN_PATH_PATTERN = /azagent|vsts\.agent/i
690
-
691
- function isForbiddenAzurePipelinesPath(path: string): boolean {
692
- if (FORBIDDEN_PATH_PATTERN.test(path)) return true
693
- try {
694
- return FORBIDDEN_PATH_PATTERN.test(realpathSync(path))
695
- } catch {
696
- return false
697
- }
698
- }
699
-
700
- /** Same top-level-repo check `discoverGitRepositories` applies while walking -- standalone, for a caller that already has an exact candidate path and skips the walk entirely. */
701
- function isGitToplevel(directory: string): boolean {
702
- try {
703
- lstatSync(join(directory, '.git'))
704
- } catch {
705
- return false
706
- }
707
- const topLevel = spawnSync('git', ['-C', directory, 'rev-parse', '--show-toplevel'], { encoding: 'utf8' })
708
- if (topLevel.error !== undefined || topLevel.status !== 0) return false
709
- try {
710
- return realpathSync(topLevel.stdout.trim()) === realpathSync(directory)
711
- } catch {
712
- return false
713
- }
714
- }
715
-
716
- export function main(env: NodeJS.ProcessEnv, executable: string, manifestPath = INSTALLED_MANIFEST, argv: readonly string[] = []): number {
717
- adoIdentityEnv = {}
718
- let parsedArgv: ParsedArgv
719
- try {
720
- parsedArgv = parseArgv(argv)
721
- } catch (error) {
722
- // Exit 2 (usage error), never 1 (runtime failure) -- and returned here,
723
- // before any scope is resolved, so a bad argument can never fall
724
- // through to the recursive scan below by accident.
725
- log(`usage error: ${error instanceof Error ? error.message : String(error)}`)
726
- return 2
727
- }
728
- const { repos: explicitRepos, armOnly } = parsedArgv
729
- let scope: RepositoryScope | undefined
730
- if (explicitRepos.length === 0) {
731
- if (env.GIT_MAINTAIN_ROOT !== undefined || env.GIT_MAINTAIN_GLOB !== undefined) {
732
- // Optional test overrides keep fixtures out of the installed host scope.
733
- scope = {
734
- root: env.GIT_MAINTAIN_ROOT ?? join(homedir(), 'Documents'),
735
- glob: env.GIT_MAINTAIN_GLOB ?? '*',
736
- recursive: true,
737
- }
738
- } else {
739
- try {
740
- const platform = process.platform === 'darwin' ? 'mac' : 'linux'
741
- scope = readRepositoryScope(manifestPath, homedir(), platform)
742
- } catch (error) {
743
- log(`cannot read installed git-maintain scope from ${manifestPath}: ${error instanceof Error ? error.message : String(error)}`)
744
- return 1
745
- }
746
- }
747
- }
748
- const keepRaw = env.GIT_MAINTAIN_KEEP || '3'
749
- const dryRaw = env.GIT_MAINTAIN_DRY || '0'
750
- const keep = Number(keepRaw)
751
- if (!Number.isInteger(keep) || keep < 0) {
752
- log(`GIT_MAINTAIN_KEEP must be a non-negative integer: ${keepRaw}`)
753
- return 1
754
- }
755
- const dry = dryRaw === '1'
756
-
757
- // F6: resolve the Azure DevOps agent identity ONCE, before touching any
758
- // repo, the same way every other Azure DevOps surface does
759
- // (`resolveAgentIdentity`: env, else the machine default-agent file,
760
- // never a human fallback). A real resolution error (e.g. an invalid name
761
- // in the default-agent file) is fatal, not a per-repo skip -- it means
762
- // the machine's own identity configuration is broken. Resolving to
763
- // "nothing configured" is not fatal by itself: only Azure DevOps-remoted
764
- // repos need it, and those are skipped loudly below, never silently
765
- // fetched with no identity (AGENTS.md: "no silent fallback or degrade").
766
- //
767
- // ARM-ONLY (coordinator decision, 2026-09-30): skipped entirely in
768
- // --arm-only mode, which never fetches and so never needs an Azure
769
- // DevOps identity for anything -- resolving one anyway would make this
770
- // mode's success depend on machine identity configuration it has no use
771
- // for at all.
772
- let agentForAdo: string | undefined
773
- if (!armOnly) {
774
- try {
775
- agentForAdo = resolveAgentIdentity(env).agent
776
- } catch (error) {
777
- const message = error instanceof Error ? error.message : String(error)
778
- if (message !== NO_AGENT_IDENTITY_MESSAGE) {
779
- log(`cannot resolve an Azure DevOps agent identity: ${message}`)
780
- return 1
781
- }
782
- log(
783
- 'no Azure DevOps agent identity resolved (no ADL_AGENT_ID_AGENT, no default-agent file): ' +
784
- 'Azure DevOps-remoted repos will be skipped this run, not silently fetched with no identity',
785
- )
786
- }
787
- if (agentForAdo !== undefined) adoIdentityEnv = { ADL_AGENT_ID_AGENT: agentForAdo }
788
- }
789
-
790
- let discoveryFailed = false
791
- let candidates: string[]
792
- if (explicitRepos.length > 0) {
793
- log(`=== git-maintain start (repos=${explicitRepos.length} declared${armOnly ? ', arm-only' : ''} keep=${keepRaw} dry=${dryRaw}) ===`)
794
- candidates = []
795
- for (const repo of explicitRepos.slice().sort()) {
796
- if (isForbiddenAzurePipelinesPath(repo)) {
797
- log(`${repo}: refusing an Azure Pipelines agent path (declared via --repo)`)
798
- discoveryFailed = true
799
- } else if (isGitToplevel(repo)) {
800
- candidates.push(repo)
801
- } else {
802
- log(`${repo}: not a git repository (declared via --repo)`)
803
- discoveryFailed = true
804
- }
805
- }
806
- } else {
807
- const { root, glob } = scope as RepositoryScope
808
- log(`=== git-maintain start (root=${root} glob=${glob} keep=${keepRaw} dry=${dryRaw}) ===`)
809
- if (!isDirectory(root)) {
810
- log(`${root}: missing (not a directory)`)
811
- return 1
812
- }
813
- candidates = discoverGitRepositories(root, scope as RepositoryScope, (path, error) => {
814
- for (const line of listFailureLines(path, error, executable)) log(line)
815
- discoveryFailed = true
816
- })
817
- }
818
-
819
- let refreshed = !discoveryFailed
820
- for (const repo of candidates) {
821
- const name = basename(repo)
822
- // Local-only (no fetch, no Azure DevOps identity needed), so it runs for
823
- // every candidate before the identity gate below -- a repo skipped for
824
- // having no resolvable ADO identity must not also stay unarmed for a
825
- // reason that has nothing to do with that.
826
- if (!armHooks(repo, dry)) refreshed = false
827
- // ARM-ONLY: nothing below this line ever runs. No fetch, no
828
- // fast-forward, no branch prune (maintainRepo), no bundle-refresh
829
- // installer (refreshRepo) -- and therefore no Azure DevOps identity
830
- // check either, since that check exists only to gate the fetch
831
- // maintainRepo is about to skip anyway.
832
- if (armOnly) continue
833
- if (agentForAdo === undefined && originIsAzureDevOps(repo)) {
834
- log(`${name}: skipping — Azure DevOps remote but no agent identity resolved (see the start-of-run message above)`)
835
- refreshed = false
836
- continue
837
- }
838
- maintainRepo(repo, keep, dry)
839
- if (!refreshRepo(repo, dry)) refreshed = false
840
- }
841
-
842
- log('=== git-maintain done ===')
843
- return refreshed ? 0 : 1
844
- }
845
-
846
- if (import.meta.main) {
847
- process.exitCode = main(process.env, process.execPath, INSTALLED_MANIFEST, process.argv.slice(2))
848
- }