claude-nomad 0.17.0

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.
@@ -0,0 +1,279 @@
1
+ import { execFileSync } from 'node:child_process';
2
+ import { closeSync, existsSync, openSync, readSync } from 'node:fs';
3
+
4
+ import { cmdDoctor } from './commands.doctor.ts';
5
+ import { REPO_HOME } from './config.ts';
6
+ import { loadTopology } from './update.topology.ts';
7
+ import { die, gitOrFatal, gitStatusPorcelainZ, log, NomadFatal, warn } from './utils.ts';
8
+
9
+ /**
10
+ * Caller-supplied options for `cmdUpdate`. All flags optional; defaults are
11
+ * conservative (no dirty-tree override, prompt for fork push, mutate state).
12
+ */
13
+ export type CmdUpdateOpts = {
14
+ /** When true, run topology detection + pre-flight only; print would-be git
15
+ * commands without mutating the repo. Skips the trailing `cmdDoctor` call. */
16
+ dryRun?: boolean;
17
+ /** When true, proceed even when `gitStatusPorcelainZ(REPO_HOME)` is
18
+ * non-empty. Emits a WARN log line before continuing. */
19
+ force?: boolean;
20
+ /** Fork topology only: when true, push the post-merge HEAD to `origin/main`
21
+ * without prompting. When false/unset, the user is prompted y/N. */
22
+ pushOrigin?: boolean;
23
+ /** Test injection point for the interactive y/N prompt. Production code
24
+ * reads one line from `/dev/tty`; tests override this to return a
25
+ * deterministic answer without a real controlling terminal. */
26
+ prompt?: (question: string) => string;
27
+ };
28
+
29
+ /**
30
+ * Get the current Git branch name for the repository at REPO_HOME.
31
+ *
32
+ * Wraps the failure path so a corrupt or missing `.git` directory surfaces as
33
+ * ``✗ ...`` via the top-level dispatcher's `NomadFatal` catch
34
+ * rather than a raw `ExecException` stack trace.
35
+ *
36
+ * @returns The current branch name (trimmed).
37
+ * @throws NomadFatal when the git command fails; if the command produced stderr, that stderr is written to process.stderr before the exception is thrown.
38
+ */
39
+ function currentBranch(): string {
40
+ try {
41
+ return execFileSync('git', ['rev-parse', '--abbrev-ref', 'HEAD'], {
42
+ cwd: REPO_HOME,
43
+ stdio: ['ignore', 'pipe', 'pipe'],
44
+ })
45
+ .toString()
46
+ .trim();
47
+ } catch (err) {
48
+ const e = err as Error & { stderr?: Buffer };
49
+ if (e.stderr) process.stderr.write(e.stderr);
50
+ throw new NomadFatal('git rev-parse --abbrev-ref HEAD failed');
51
+ }
52
+ }
53
+
54
+ /**
55
+ * Default y/N prompt used when `opts.prompt` is not injected.
56
+ *
57
+ * Reads from `/dev/tty` byte-by-byte until newline so the call returns after
58
+ * the user presses Enter (cooked-mode TTY line buffering). The naive
59
+ * `readFileSync(0)` approach reads until EOF, which hangs interactive use
60
+ * until Ctrl-D. Opening `/dev/tty` directly also means the prompt still
61
+ * works when stdin is piped or redirected.
62
+ *
63
+ * @param question - Prompt text written to stdout before reading input.
64
+ * @returns The user's trimmed answer; `''` on any failure (no controlling TTY, read error), which `runFork` treats as "no" and skips the push.
65
+ */
66
+ function defaultPrompt(question: string): string {
67
+ process.stdout.write(question);
68
+ let fd: number;
69
+ try {
70
+ fd = openSync('/dev/tty', 'r');
71
+ } catch {
72
+ return '';
73
+ }
74
+ try {
75
+ const buf = Buffer.alloc(1);
76
+ let answer = '';
77
+ while (true) {
78
+ const n = readSync(fd, buf, 0, 1, null);
79
+ if (n === 0) break;
80
+ const ch = buf.toString('utf8', 0, 1);
81
+ if (ch === '\n' || ch === '\r') break;
82
+ answer += ch;
83
+ }
84
+ return answer.trim();
85
+ } catch {
86
+ return '';
87
+ } finally {
88
+ closeSync(fd);
89
+ }
90
+ }
91
+
92
+ /**
93
+ * Read and return the current `HEAD` commit SHA from the repository.
94
+ *
95
+ * Used to pin the pre-update commit so the post-update lockfile diff is
96
+ * exact regardless of whether the pull was a fast-forward, a no-op, or a
97
+ * merge. `HEAD@{1}` is unreliable here: a no-op `git pull --ff-only` does
98
+ * not always write a reflog entry, and a freshly cloned repo has no
99
+ * `HEAD@{1}` at all.
100
+ *
101
+ * @returns The `HEAD` commit SHA as a trimmed string.
102
+ * @throws NomadFatal if `git rev-parse HEAD` fails (stderr is written to stderr when present).
103
+ */
104
+ function headSha(): string {
105
+ try {
106
+ return execFileSync('git', ['rev-parse', 'HEAD'], {
107
+ cwd: REPO_HOME,
108
+ stdio: ['ignore', 'pipe', 'pipe'],
109
+ })
110
+ .toString()
111
+ .trim();
112
+ } catch (err) {
113
+ const e = err as Error & { stderr?: Buffer };
114
+ if (e.stderr) process.stderr.write(e.stderr);
115
+ throw new NomadFatal('git rev-parse HEAD failed');
116
+ }
117
+ }
118
+
119
+ /**
120
+ * List files changed between the given commit and the current HEAD.
121
+ *
122
+ * @param beforeSha - Commit SHA to compare against HEAD
123
+ * @returns An array of file paths changed between `beforeSha` and `HEAD`; an empty array if there are no changes
124
+ */
125
+ function changedFilesSince(beforeSha: string): string[] {
126
+ const out = execFileSync('git', ['diff', '--name-only', `${beforeSha}..HEAD`], {
127
+ cwd: REPO_HOME,
128
+ stdio: ['ignore', 'pipe', 'pipe'],
129
+ }).toString();
130
+ return out.split('\n').filter((line) => line !== '');
131
+ }
132
+
133
+ /**
134
+ * Run `npm install` in the repository only if `package-lock.json` changed since a given commit.
135
+ *
136
+ * If `package-lock.json` did not change between `beforeSha` and `HEAD`, logs
137
+ * a skip message; otherwise runs `npm install` with working directory set to
138
+ * `REPO_HOME`. Routing through `execFileSync` (no shell) keeps the call
139
+ * mockable in tests and prevents any chance of argv injection.
140
+ *
141
+ * @param beforeSha - Commit SHA to compare against `HEAD` when determining whether the lockfile changed
142
+ */
143
+ function reinstallIfNeeded(beforeSha: string): void {
144
+ const changed = changedFilesSince(beforeSha);
145
+ if (!changed.includes('package-lock.json')) {
146
+ log('skipping npm install (lockfile unchanged)');
147
+ return;
148
+ }
149
+ log('package-lock.json changed, running npm install');
150
+ execFileSync('npm', ['install'], { cwd: REPO_HOME, stdio: 'inherit' });
151
+ }
152
+
153
+ /**
154
+ * Perform a vanilla update by fast-forward pulling `origin/main`.
155
+ *
156
+ * Non-ff pulls (someone else pushed in the meantime) surface as `NomadFatal`
157
+ * via `gitOrFatal`. If `opts.dryRun` is true, logs the would-be pull command
158
+ * instead of executing it. Takes the full `CmdUpdateOpts` so the signature
159
+ * stays symmetric with `runFork` even though only `dryRun` is consulted
160
+ * today.
161
+ *
162
+ * @param opts - Update options; only `dryRun` is observed for this topology.
163
+ */
164
+ function runVanilla(opts: CmdUpdateOpts): void {
165
+ if (opts.dryRun === true) {
166
+ log('DRY-RUN: would run `git pull --ff-only origin main`');
167
+ return;
168
+ }
169
+ gitOrFatal(['pull', '--ff-only', 'origin', 'main'], 'git pull', REPO_HOME);
170
+ }
171
+
172
+ /**
173
+ * Perform a fork-style update by fetching from `upstream`, merging `upstream/main` into `main`, and optionally pushing the merge to `origin`.
174
+ *
175
+ * The prompt step is gated by `pushOrigin` (no prompt when explicit) and by
176
+ * `dryRun` (no prompt, no push when previewing). When `opts.dryRun === true`
177
+ * the function only logs the git actions it would perform and returns
178
+ * without running any commands. When `opts.pushOrigin === true` the function
179
+ * pushes to `origin/main` without prompting; otherwise it prompts (via
180
+ * `opts.prompt` if provided, or the default `/dev/tty` prompt) and only
181
+ * pushes when the answer is `y` or `yes` (case-insensitive). Non-affirmative
182
+ * answers skip the push and log a "run later" hint.
183
+ *
184
+ * @param opts - Update options; respected fields are:
185
+ * - `dryRun`: when true, log actions instead of executing them
186
+ * - `pushOrigin`: when true, push to `origin/main` without prompting
187
+ * - `prompt`: optional prompt function used for interactive confirmation
188
+ */
189
+ function runFork(opts: CmdUpdateOpts): void {
190
+ const promptFn = opts.prompt ?? defaultPrompt;
191
+ if (opts.dryRun === true) {
192
+ log('DRY-RUN: would run `git fetch upstream`');
193
+ log('DRY-RUN: would run `git merge upstream/main`');
194
+ if (opts.pushOrigin === true) {
195
+ log('DRY-RUN: would run `git push origin main`');
196
+ } else {
197
+ log('DRY-RUN: would prompt before pushing to origin/main');
198
+ }
199
+ return;
200
+ }
201
+ gitOrFatal(['fetch', 'upstream'], 'git fetch upstream', REPO_HOME);
202
+ gitOrFatal(['merge', 'upstream/main'], 'git merge upstream/main', REPO_HOME);
203
+ if (opts.pushOrigin === true) {
204
+ gitOrFatal(['push', 'origin', 'main'], 'git push origin main', REPO_HOME);
205
+ return;
206
+ }
207
+ const answer = promptFn('push merge to origin/main? [y/N] ').toLowerCase();
208
+ if (answer === 'y' || answer === 'yes') {
209
+ gitOrFatal(['push', 'origin', 'main'], 'git push origin main', REPO_HOME);
210
+ } else {
211
+ log('skipping push to origin (run `git push origin main` later)');
212
+ }
213
+ }
214
+
215
+ /**
216
+ * Perform a topology-aware repository update.
217
+ *
218
+ * Detects `vanilla` (`origin` -> public) vs `fork` (`upstream` -> public,
219
+ * `origin` -> private mirror) layouts, runs the right git invocation, runs
220
+ * `npm install` only when `package-lock.json` changed in the update, and
221
+ * ends with `cmdDoctor()` so the version-check PASS line confirms the
222
+ * upgrade landed.
223
+ *
224
+ * Pre-flight (each fatal unless overridden):
225
+ * 1. `REPO_HOME` exists.
226
+ * 2. Topology resolves to `vanilla` or `fork`.
227
+ * 3. `--push-origin` is fork-only (rejected on `vanilla`).
228
+ * 4. Current branch is `main`.
229
+ * 5. Working tree clean per `gitStatusPorcelainZ` (override with `force`).
230
+ *
231
+ * Fork-topology prompts read one line from `/dev/tty`; tests inject
232
+ * `opts.prompt` to bypass the TTY read.
233
+ *
234
+ * @param opts - Update options. `dryRun` runs pre-flight + logs the would-be git, install, and doctor actions without mutating the repo or invoking `cmdDoctor`. `force` proceeds past a dirty working tree. `pushOrigin` (fork topology only) skips the y/N prompt. `prompt` injects a synchronous answer function for tests.
235
+ */
236
+ export function cmdUpdate(opts: CmdUpdateOpts = {}): void {
237
+ if (!existsSync(REPO_HOME)) die(`repo not cloned at ${REPO_HOME}`);
238
+
239
+ const topology = loadTopology();
240
+ if (topology === 'unknown') {
241
+ die(
242
+ `could not detect upstream remote in ${REPO_HOME}. Run \`git fetch <remote>\` and \`git merge <remote>/main\` manually.`,
243
+ );
244
+ }
245
+
246
+ if (topology === 'vanilla' && opts.pushOrigin === true) {
247
+ die('`--push-origin` is only valid for fork topology');
248
+ }
249
+
250
+ const branch = currentBranch();
251
+ if (branch !== 'main') {
252
+ die(`current branch is \`${branch}\`, expected \`main\``);
253
+ }
254
+
255
+ const status = gitStatusPorcelainZ(REPO_HOME);
256
+ if (status.length > 0) {
257
+ if (opts.force !== true) {
258
+ die('working tree is not clean, use `--force` to override');
259
+ }
260
+ warn('working tree is not clean, proceeding because --force was passed');
261
+ }
262
+
263
+ log(`topology: ${topology}`);
264
+
265
+ if (opts.dryRun === true) {
266
+ if (topology === 'vanilla') runVanilla(opts);
267
+ else runFork(opts);
268
+ log('DRY-RUN: would run `npm install` only if `package-lock.json` changed');
269
+ log('DRY-RUN: would run `nomad doctor` to confirm the upgrade');
270
+ return;
271
+ }
272
+
273
+ const beforeSha = headSha();
274
+ if (topology === 'vanilla') runVanilla(opts);
275
+ else runFork(opts);
276
+
277
+ reinstallIfNeeded(beforeSha);
278
+ cmdDoctor();
279
+ }
package/src/config.ts ADDED
@@ -0,0 +1,149 @@
1
+ import { homedir, hostname } from 'node:os';
2
+ import { resolve } from 'node:path';
3
+
4
+ /**
5
+ * Resolved home directory. Uses Node's `os.homedir()` which reads `$HOME` on
6
+ * POSIX and falls back to `getpwuid_r()` when the env var is unset. Returns
7
+ * `""` only in pathological environments (no env, no uid mapping); callers
8
+ * should verify it is non-empty at CLI entry via `nomad.ts`. Centralizing the
9
+ * lookup here prevents the `process.env.HOME ?? ''` footgun where an unset
10
+ * `HOME` silently produced relative lockfile/backup paths.
11
+ */
12
+ export const HOME = homedir();
13
+
14
+ /** Absolute path to the user's Claude Code config directory (`~/.claude`). */
15
+ export const CLAUDE_HOME = resolve(HOME, '.claude');
16
+
17
+ /**
18
+ * Absolute path to the local checkout of the private sync repo. Reads
19
+ * `NOMAD_REPO` first, falls back to `~/claude-nomad`. A set-but-empty
20
+ * `NOMAD_REPO` (e.g. `export NOMAD_REPO=` in a dotfile that clobbers the
21
+ * variable) must also fall through to the default. `??` only triggers on
22
+ * null/undefined, so `||` is used here to fall through on empty strings too.
23
+ * Relative paths in `NOMAD_REPO` are resolved against the current working
24
+ * directory at first use (downstream `existsSync` / `cpSync` / git invocations
25
+ * accept either absolute or relative paths); we intentionally do NOT
26
+ * `resolve()` here so developers can point the override at relative checkouts.
27
+ */
28
+ // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing
29
+ export const REPO_HOME = process.env.NOMAD_REPO || resolve(HOME, 'claude-nomad');
30
+
31
+ /**
32
+ * Upstream GitHub repository slug for the release-version check in
33
+ * `nomad doctor`. Hardcoded for the same reason `REPO_HOME` is hardcoded:
34
+ * the deployed sync target is canonical for this CLI. Source of truth for
35
+ * the `GET /repos/<slug>/releases/latest` call in `reportVersionCheck`.
36
+ */
37
+ export const UPSTREAM_REPO_SLUG = 'funkadelic/claude-nomad';
38
+
39
+ /**
40
+ * Resolved host identity used to pick `hosts/<HOST>.json` and key entries in
41
+ * `path-map.json`. Reads `NOMAD_HOST` first, falls back to `hostname()`, then
42
+ * lowercases. A set-but-empty `NOMAD_HOST` (e.g. `export NOMAD_HOST=` in a
43
+ * dotfile that clobbers the variable) must also fall through to `hostname()`.
44
+ * `??` only triggers on null/undefined, so `||` is used here to fall through
45
+ * on empty strings too.
46
+ */
47
+ // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing
48
+ export const HOST = (process.env.NOMAD_HOST || hostname()).toLowerCase();
49
+
50
+ /** Names under `shared/` that are symlinked into `~/.claude/` on every pull. */
51
+ export const SHARED_LINKS = [
52
+ 'CLAUDE.md',
53
+ 'agents',
54
+ 'skills',
55
+ 'commands',
56
+ 'rules',
57
+ 'my-statusline.cjs',
58
+ ] as const;
59
+
60
+ /**
61
+ * Path segments that must never cross the sync boundary in either direction.
62
+ * Defense-in-depth pair with `PUSH_ALLOWED_STATIC`: even if the allow-list
63
+ * misses a path, anything containing one of these segments is hard-blocked.
64
+ */
65
+ export const NEVER_SYNC = new Set([
66
+ '.claude.json',
67
+ 'history.jsonl',
68
+ 'stats-cache.json',
69
+ 'todos',
70
+ 'shell-snapshots',
71
+ 'debug',
72
+ 'file-history',
73
+ 'plans',
74
+ 'session-env',
75
+ 'statsig',
76
+ 'telemetry',
77
+ 'ide',
78
+ ]);
79
+
80
+ /**
81
+ * Schema-drift baseline for `~/.claude/settings.json`. Top-level keys not in
82
+ * this set trigger a `nomad doctor` WARN line so we notice when Anthropic
83
+ * adds new settings before they silently round-trip through pull. Update on
84
+ * Anthropic settings.json schema changes.
85
+ */
86
+ export const KNOWN_SETTINGS_KEYS = new Set<string>([
87
+ '$schema',
88
+ 'agent',
89
+ 'agents',
90
+ 'agentPushNotifEnabled',
91
+ 'allowedHttpHookUrls',
92
+ 'apiKeyHelper',
93
+ 'apiKeyHelperTimeoutMs',
94
+ 'awsAuthRefresh',
95
+ 'awsCredentialExport',
96
+ 'awsLoginRefresh',
97
+ 'awsRegion',
98
+ 'awsRetryMode',
99
+ 'cleanupPeriodDays',
100
+ 'disableNonEssentialModelCalls',
101
+ 'enabledExperimentalFeatures',
102
+ 'enabledPlugins',
103
+ 'env',
104
+ 'forceLoginMethod',
105
+ 'forceLoginOrgUUID',
106
+ 'hooks',
107
+ 'includeCoAuthoredBy',
108
+ 'installMethod',
109
+ 'model',
110
+ 'outputStyle',
111
+ 'permissions',
112
+ 'pluginGroups',
113
+ 'pluginRepositoryEnabled',
114
+ 'pluginsLocalConfig',
115
+ 'proxy',
116
+ 'statsig',
117
+ 'statusLine',
118
+ 'subagents',
119
+ 'theme',
120
+ ]);
121
+
122
+ /**
123
+ * Static half of the push allow-list. Entries with trailing `/` are prefix
124
+ * matches; others are exact matches. The `hosts/` entry has special-case
125
+ * handling in `isAllowed` to limit it to `hosts/<name>.json` (single-level
126
+ * `.json` files only, no credentials). Data-driven
127
+ * `shared/projects/<logical>/` entries are added at runtime in
128
+ * `enforceAllowList`.
129
+ */
130
+ export const PUSH_ALLOWED_STATIC = [
131
+ 'shared/CLAUDE.md',
132
+ 'shared/my-statusline.cjs',
133
+ 'shared/settings.base.json',
134
+ 'shared/agents/',
135
+ 'shared/skills/',
136
+ 'shared/commands/',
137
+ 'shared/rules/',
138
+ 'shared/.gitignore',
139
+ 'hosts/',
140
+ 'path-map.json',
141
+ ] as const;
142
+
143
+ /**
144
+ * Shape of `path-map.json`. Each logical project name maps a hostname (matched
145
+ * against `HOST`) to the absolute path the project lives at on that host. Use
146
+ * the literal string `'TBD'` as a placeholder while a host has not yet cloned
147
+ * the project; `remapPull` / `remapPush` skip `'TBD'` entries.
148
+ */
149
+ export type PathMap = { projects: Record<string, Record<string, string>> };
package/src/diff.ts ADDED
@@ -0,0 +1,49 @@
1
+ import { existsSync } from 'node:fs';
2
+ import { join } from 'node:path';
3
+
4
+ import { HOME, REPO_HOME } from './config.ts';
5
+ import { computePreview } from './preview.ts';
6
+ import { emitSummary } from './summary.ts';
7
+ import { die, fail, freshBackupTs, NomadFatal } from './utils.ts';
8
+
9
+ /**
10
+ * `nomad diff` command. Offline-safe, read-only preview surface that runs
11
+ * the same `computePreview` orchestration as `pull --dry-run` but WITHOUT
12
+ * acquiring the pull/push lockfile and WITHOUT running `git pull --rebase`.
13
+ *
14
+ * Intent: answer "what would be applied from local repo state right now"
15
+ * (offline-safe). For "what will the next real pull do" (with the network
16
+ * round-trip), use `pull --dry-run` instead.
17
+ *
18
+ * Does NOT create the per-run backup directory under
19
+ * `~/.cache/claude-nomad/backup/<ts>/`: cmdDiff writes nothing, and an empty
20
+ * dir would pollute the cache. The `ts` value is still computed for log
21
+ * lines (so the preview output is consistent with `pull --dry-run`).
22
+ *
23
+ * Errors:
24
+ * - REPO_HOME missing surfaces as the canonical `repo not cloned at <path>`
25
+ * FATAL, matching cmdPull / cmdPush.
26
+ * - computePreview is tolerant of partial scaffold; cmdDiff inherits the
27
+ * same tolerance.
28
+ * - Any NomadFatal escapes into the local catch which writes the FATAL
29
+ * line to stderr and sets `process.exitCode = 1`. Non-NomadFatal errors
30
+ * rethrow.
31
+ */
32
+ export function cmdDiff(): void {
33
+ try {
34
+ if (!existsSync(REPO_HOME)) die(`repo not cloned at ${REPO_HOME}`);
35
+ const backupBase = join(HOME, '.cache', 'claude-nomad', 'backup');
36
+ const ts = freshBackupTs(backupBase);
37
+ // Preview log lines reference `ts` so output stays consistent with
38
+ // pull --dry-run; the backup root itself is intentionally NOT created.
39
+ const result = computePreview(ts);
40
+ emitSummary('diff', result.unmapped);
41
+ } catch (err) {
42
+ if (err instanceof NomadFatal) {
43
+ fail(err.message);
44
+ process.exitCode = 1;
45
+ return;
46
+ }
47
+ throw err;
48
+ }
49
+ }
@@ -0,0 +1,53 @@
1
+ import { copyFileSync, cpSync, existsSync, rmSync, statSync } from 'node:fs';
2
+ import { join } from 'node:path';
3
+
4
+ import { CLAUDE_HOME, HOST, REPO_HOME, SHARED_LINKS } from './config.ts';
5
+ import { die, log, readJson, writeJsonAtomic } from './utils.ts';
6
+
7
+ /**
8
+ * Overlay `~/.claude/` SHARED_LINKS onto the freshly-written scaffold under
9
+ * `REPO_HOME/shared/`. Regular files (`CLAUDE.md`, `my-statusline.cjs`) go
10
+ * through `copyFileSync` so the placeholder is overwritten; directories
11
+ * (`agents`, `skills`, `commands`, `rules`) drop their just-written
12
+ * `.gitkeep` marker first and then go through `cpSync` with `force: false`,
13
+ * so any unexpected pre-existing destination content (an out-of-band write
14
+ * between the preflight check and this step) surfaces as an error. Also
15
+ * translates `~/.claude/settings.json` (when present) into `hosts/<HOST>.json`
16
+ * via `writeJsonAtomic`. Does NOT modify `~/.claude/`; the caller emits the
17
+ * user-visible next-step + originals-not-removed log lines so the canonical
18
+ * phrasing stays co-located with `cmdInit` itself.
19
+ */
20
+ export function snapshotIntoShared(): void {
21
+ for (const name of SHARED_LINKS) {
22
+ const src = join(CLAUDE_HOME, name);
23
+ if (!existsSync(src)) continue;
24
+ const dst = join(REPO_HOME, 'shared', name);
25
+ if (statSync(src).isDirectory()) {
26
+ // Remove the .gitkeep first so cpSync starts against an empty dst.
27
+ // Force is false so existing files are not overwritten; errorOnExist
28
+ // is true because cpSync silently ignores destination collisions when
29
+ // it is omitted, defeating the intent of surfacing unexpected content
30
+ // (e.g. an out-of-band write between the preflight check and here).
31
+ const gk = join(dst, '.gitkeep');
32
+ if (existsSync(gk)) rmSync(gk);
33
+ cpSync(src, dst, { recursive: true, force: false, errorOnExist: true });
34
+ } else {
35
+ copyFileSync(src, dst);
36
+ }
37
+ log(`snapshotted shared/${name} from ${src}`);
38
+ }
39
+
40
+ const userSettings = join(CLAUDE_HOME, 'settings.json');
41
+ if (existsSync(userSettings)) {
42
+ // `return die(...)` keeps `parsed` definitely-assigned for the writeJsonAtomic call.
43
+ let parsed: Record<string, unknown>;
44
+ try {
45
+ parsed = readJson<Record<string, unknown>>(userSettings);
46
+ } catch (err) {
47
+ return die(`malformed ${userSettings}: ${(err as Error).message}`);
48
+ }
49
+ const hostFile = join(REPO_HOME, 'hosts', `${HOST}.json`);
50
+ writeJsonAtomic(hostFile, parsed);
51
+ log(`snapshotted hosts/${HOST}.json from ${userSettings}`);
52
+ }
53
+ }