claude-nomad 0.26.2 → 0.27.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.
package/src/remap.ts CHANGED
@@ -49,22 +49,31 @@ export function copyDirJsonlOnly(src: string, dst: string): void {
49
49
  /**
50
50
  * Pull: copy from repo's logical project names into local path-encoded dirs.
51
51
  *
52
- * Returns `{ unmapped: N }` where `N` counts path-map entries skipped for
53
- * this host (either `'TBD'` placeholder or no entry for `HOST`). The count
54
- * is consumed by `computePreview` and the future summary line.
52
+ * Returns `{ unmapped, pulled, wouldPull }`. `unmapped` counts path-map entries
53
+ * skipped for this host (`'TBD'` placeholder or no entry for `HOST`); `pulled`
54
+ * holds logical names copied (wet), `wouldPull` those that would copy under
55
+ * `dryRun`. The arrays let cmdPull render a grouped tree; the wet path no longer
56
+ * logs per-project `pulled X -> Y` / `skip ...` inline. The dry-run path KEEPS
57
+ * its `would overwrite:` line because `computePreview` renders those as the
58
+ * Projects section of `nomad diff` and the dry-run pull preview. The degenerate
59
+ * early-return `log(...)` (not a per-project skip) is preserved.
55
60
  *
56
- * `opts.dryRun` (default `false`): when `true`, log `would overwrite:` lines
57
- * instead of calling `backupBeforeWrite` + `copyDir`. The unmapped count is
58
- * computed identically in both modes.
61
+ * @param ts - backup timestamp namespace.
62
+ * @param opts.dryRun - when `true`, collect `wouldPull` and log would-overwrite.
59
63
  */
60
- export function remapPull(ts: string, opts: { dryRun?: boolean } = {}): { unmapped: number } {
64
+ export function remapPull(
65
+ ts: string,
66
+ opts: { dryRun?: boolean } = {},
67
+ ): { unmapped: number; pulled: string[]; wouldPull: string[] } {
61
68
  const dryRun = opts.dryRun === true;
62
69
  let unmapped = 0;
70
+ const pulled: string[] = [];
71
+ const wouldPull: string[] = [];
63
72
  const mapPath = join(REPO_HOME, 'path-map.json');
64
73
  const repoProjects = join(REPO_HOME, 'shared', 'projects');
65
74
  if (!existsSync(mapPath) || !existsSync(repoProjects)) {
66
75
  log('no path-map or repo projects dir; skipping session remap');
67
- return { unmapped: 0 };
76
+ return { unmapped: 0, pulled, wouldPull };
68
77
  }
69
78
 
70
79
  const map = readJson<PathMap>(mapPath);
@@ -73,29 +82,28 @@ export function remapPull(ts: string, opts: { dryRun?: boolean } = {}): { unmapp
73
82
 
74
83
  for (const [logical, hosts] of Object.entries(map.projects)) {
75
84
  const localPath = hosts[HOST];
76
- if (localPath === 'TBD') {
85
+ if (!localPath || localPath === 'TBD') {
77
86
  unmapped++;
78
- log(`skip ${logical}: placeholder path for ${HOST}`);
79
- continue;
80
- }
81
- if (!localPath) {
82
- unmapped++;
83
- log(`skip ${logical}: no path for ${HOST}`);
84
87
  continue;
85
88
  }
86
89
  const src = join(repoProjects, logical);
87
90
  if (!existsSync(src)) continue;
88
91
  const dst = join(localProjects, encodePath(localPath));
89
92
  if (dryRun) {
93
+ // KEEP this would-overwrite log: computePreview (backing both `nomad
94
+ // diff` and the dry-run pull preview) renders these lines as its
95
+ // Projects section, so removing them regresses that output. The grouped
96
+ // tree is built only on the WET path, which consumes `pulled` instead.
97
+ wouldPull.push(logical);
90
98
  log(`would overwrite: ${dst} (from ${src})`);
91
99
  continue;
92
100
  }
93
101
  // Snapshot prior encoded-path-dir state BEFORE copyDir overwrites it.
94
102
  backupBeforeWrite(dst, ts);
95
103
  copyDir(src, dst);
96
- log(`pulled ${logical} -> ${encodePath(localPath)}`);
104
+ pulled.push(logical);
97
105
  }
98
- return { unmapped };
106
+ return { unmapped, pulled, wouldPull };
99
107
  }
100
108
 
101
109
  /**
@@ -156,20 +164,32 @@ function buildReverseMap(map: PathMap): Map<string, string> {
156
164
  * refuse the push before any `shared/projects/` content is written. Detection
157
165
  * runs during the reverse-map build, so it fires under `dryRun` too.
158
166
  *
159
- * `opts.dryRun` (default `false`): when `true`, log `would push:` lines
160
- * instead of calling `backupRepoWrite` + `copyDir`. Collision detection
161
- * runs identically in both modes.
167
+ * `opts.dryRun` (default `false`): when `true`, collect `wouldPush` without
168
+ * calling `backupRepoWrite` + `copyDir`. Collision detection runs identically
169
+ * in both modes.
170
+ *
171
+ * Returns `pushed` (logical names actually copied, wet mode) and `wouldPush`
172
+ * (logical names under `dryRun`) alongside the counts so cmdPush can render a
173
+ * grouped tree. This function no longer logs per-project `pushed X -> Y` /
174
+ * `skip ... not in path-map` / `would push:` lines inline; the
175
+ * `copyDirJsonlOnly` extension-skip log is a separate file-filter concern and
176
+ * is preserved, as are the degenerate early-return `log(...)` lines.
177
+ *
178
+ * @param ts - backup timestamp namespace.
179
+ * @param opts.dryRun - when `true`, collect `wouldPush` without mutating.
162
180
  */
163
181
  export function remapPush(
164
182
  ts: string,
165
183
  opts: { dryRun?: boolean } = {},
166
- ): { unmapped: number; collisions: number } {
184
+ ): { unmapped: number; collisions: number; pushed: string[]; wouldPush: string[] } {
167
185
  const dryRun = opts.dryRun === true;
168
186
  let unmapped = 0;
187
+ const pushed: string[] = [];
188
+ const wouldPush: string[] = [];
169
189
  const mapPath = join(REPO_HOME, 'path-map.json');
170
190
  if (!existsSync(mapPath)) {
171
191
  log('no path-map.json; skipping session export');
172
- return { unmapped: 0, collisions: 0 };
192
+ return { unmapped: 0, collisions: 0, pushed, wouldPush };
173
193
  }
174
194
 
175
195
  const map = readJson<PathMap>(mapPath);
@@ -177,7 +197,7 @@ export function remapPush(
177
197
  const repoProjects = join(REPO_HOME, 'shared', 'projects');
178
198
 
179
199
  const reverse = buildReverseMap(map);
180
- if (!existsSync(localProjects)) return { unmapped, collisions: 0 };
200
+ if (!existsSync(localProjects)) return { unmapped, collisions: 0, pushed, wouldPush };
181
201
  // Create the repo destination only after collision detection passes and we
182
202
  // know there is something to push, so a failing or no-op push is fully
183
203
  // side-effect-free (no empty shared/projects/ left behind).
@@ -187,12 +207,11 @@ export function remapPush(
187
207
  const logical = reverse.get(dir);
188
208
  if (!logical) {
189
209
  unmapped++;
190
- log(`skip ${dir}: not in path-map for ${HOST}`);
191
210
  continue;
192
211
  }
193
212
  const repoDst = join(repoProjects, logical);
194
213
  if (dryRun) {
195
- log(`would push: ${dir} -> ${logical}`);
214
+ wouldPush.push(logical);
196
215
  continue;
197
216
  }
198
217
  // Snapshot repo-side destination before copyDir clobbers it. Git
@@ -201,7 +220,7 @@ export function remapPush(
201
220
  // path. Symmetric with remapPull's backupBeforeWrite on the local dst.
202
221
  backupRepoWrite(repoDst, ts, REPO_HOME);
203
222
  copyDirJsonlOnly(join(localProjects, dir), repoDst);
204
- log(`pushed ${dir} -> ${logical}`);
223
+ pushed.push(logical);
205
224
  }
206
- return { unmapped, collisions: 0 };
225
+ return { unmapped, collisions: 0, pushed, wouldPush };
207
226
  }
package/src/summary.ts CHANGED
@@ -1,51 +1,99 @@
1
+ import { green, okGlyph, warnGlyph, yellow } from './color.ts';
1
2
  import { ok, warn } from './utils.ts';
2
3
 
3
4
  /**
4
- * Emit the single end-of-run summary line shared by cmdPull, cmdPush, and
5
- * cmdDiff. Canonical phrasing:
5
+ * Pure phrasing core for the end-of-run summary line shared by cmdPull,
6
+ * cmdPush, and cmdDiff. Returns the message `text` (without any status glyph)
7
+ * plus a `clean` flag so callers can pick the right glyph/stream. Canonical
8
+ * phrasing:
6
9
  * - `summary: clean` when nothing was unmapped (and, for push, no
7
- * collisions or extras skipped). Always printed so users see a consistent
8
- * terminator and can spot when behavior changes.
10
+ * collisions or extras skipped).
9
11
  * - `summary: <N> unmapped on pull (run nomad doctor to list)`
10
12
  * - `summary: <N> unmapped on pull, <X> extras skipped (run nomad doctor to list)`
11
13
  * - `summary: <N> unmapped on diff (run nomad doctor to list)`
12
14
  * - `summary: <N> unmapped on push, <M> collisions (run nomad doctor to list)`
13
15
  * - `summary: <N> unmapped on push, <M> collisions, <X> extras skipped (run nomad doctor to list)`
14
16
  *
15
- * Clean outcomes go through `ok()` (green `✓` glyph, stdout) and unmapped /
16
- * collision / extras-skipped outcomes go through `warn()` (yellow `⚠︎` glyph,
17
- * stderr). The status glyph carries the success/warn semantics; users see e.g.
18
- * `✓ summary: clean` or `⚠︎ summary: 3 unmapped on pull (...)`. Note: clean
19
- * still goes to stdout so it survives backgrounded shell-rc invocations
20
- * like `nomad pull 2>/dev/null &`. `collisions` is meaningful only for
21
- * `'push'`; for `'pull'` / `'diff'` it is ignored and defaults to 0.
22
- * `extrasSkipped` counts dirnames that the per-project whitelist
23
- * (`SUPPORTED_EXTRAS`) declined to sync; surfaces from `remapExtrasPush`
24
- * and `remapExtrasPull`. The fourth positional parameter defaults to 0 so
25
- * legacy three-arg call sites continue to work unchanged (D-03 additive
26
- * contract). This module is the SINGLE source of truth for the phrasing,
27
- * eliminating drift risk across the three callers by construction.
17
+ * `collisions` is meaningful only for `'push'`; for `'pull'` / `'diff'` it is
18
+ * ignored and defaults to 0. `extrasSkipped` counts dirnames that the
19
+ * per-project whitelist (`SUPPORTED_EXTRAS`) declined to sync. This function is
20
+ * the SINGLE source of truth for the phrasing, so `emitSummary` (standalone
21
+ * line) and `summaryRow` (tree row) cannot drift apart.
22
+ *
23
+ * @param verb - the originating command.
24
+ * @param unmapped - count of path-map entries skipped for this host.
25
+ * @param collisions - push-only collision count (ignored for pull/diff).
26
+ * @param extrasSkipped - count of extras dirnames the whitelist declined.
27
+ * @returns `{ text, clean }` where `clean` is true on the no-warning outcome.
28
28
  */
29
- export function emitSummary(
29
+ export function summaryText(
30
30
  verb: 'pull' | 'push' | 'diff',
31
31
  unmapped: number,
32
32
  collisions = 0,
33
33
  extrasSkipped = 0,
34
- ): void {
34
+ ): { text: string; clean: boolean } {
35
+ const extras = extrasSkipped > 0 ? `, ${extrasSkipped} extras skipped` : '';
35
36
  if (verb === 'push') {
36
37
  if (unmapped === 0 && collisions === 0 && extrasSkipped === 0) {
37
- ok('summary: clean');
38
- return;
38
+ return { text: 'summary: clean', clean: true };
39
39
  }
40
40
  const base = `summary: ${unmapped} unmapped on push, ${collisions} collisions`;
41
- const extras = extrasSkipped > 0 ? `, ${extrasSkipped} extras skipped` : '';
42
- warn(`${base}${extras} (run nomad doctor to list)`);
43
- return;
41
+ return { text: `${base}${extras} (run nomad doctor to list)`, clean: false };
44
42
  }
45
43
  if (unmapped === 0 && extrasSkipped === 0) {
46
- ok('summary: clean');
44
+ return { text: 'summary: clean', clean: true };
45
+ }
46
+ return {
47
+ text: `summary: ${unmapped} unmapped on ${verb}${extras} (run nomad doctor to list)`,
48
+ clean: false,
49
+ };
50
+ }
51
+
52
+ /**
53
+ * Build the fully-rendered Summary-section row (status glyph embedded) for the
54
+ * grouped push/pull tree. Delegates phrasing to `summaryText` so the row text
55
+ * matches `emitSummary` byte-for-byte. A clean outcome renders
56
+ * `${green(okGlyph)} <text>`; any warning outcome renders
57
+ * `${yellow(warnGlyph)} <text>`.
58
+ *
59
+ * @param verb - the originating command.
60
+ * @param unmapped - count of path-map entries skipped for this host.
61
+ * @param collisions - push-only collision count (ignored for pull/diff).
62
+ * @param extrasSkipped - count of extras dirnames the whitelist declined.
63
+ * @returns the rendered row string for the Summary section.
64
+ */
65
+ export function summaryRow(
66
+ verb: 'pull' | 'push' | 'diff',
67
+ unmapped: number,
68
+ collisions = 0,
69
+ extrasSkipped = 0,
70
+ ): string {
71
+ const { text, clean } = summaryText(verb, unmapped, collisions, extrasSkipped);
72
+ return clean ? `${green(okGlyph)} ${text}` : `${yellow(warnGlyph)} ${text}`;
73
+ }
74
+
75
+ /**
76
+ * Emit the single end-of-run summary line shared by cmdPull, cmdPush, and
77
+ * cmdDiff. Delegates phrasing to `summaryText` so the wording cannot drift from
78
+ * `summaryRow`. Clean outcomes go through `ok()` (green `✓` glyph, stdout) and
79
+ * unmapped / collision / extras-skipped outcomes go through `warn()` (yellow
80
+ * `⚠︎` glyph, stderr). The status glyph carries the success/warn semantics;
81
+ * users see e.g. `✓ summary: clean` or `⚠︎ summary: 3 unmapped on pull (...)`.
82
+ * Clean still goes to stdout so it survives backgrounded shell-rc invocations
83
+ * like `nomad pull 2>/dev/null &`. The fourth positional parameter defaults to
84
+ * 0 so legacy three-arg call sites continue to work unchanged (D-03 additive
85
+ * contract). `cmdDiff` still calls this for its standalone summary line.
86
+ */
87
+ export function emitSummary(
88
+ verb: 'pull' | 'push' | 'diff',
89
+ unmapped: number,
90
+ collisions = 0,
91
+ extrasSkipped = 0,
92
+ ): void {
93
+ const { text, clean } = summaryText(verb, unmapped, collisions, extrasSkipped);
94
+ if (clean) {
95
+ ok(text);
47
96
  return;
48
97
  }
49
- const extras = extrasSkipped > 0 ? `, ${extrasSkipped} extras skipped` : '';
50
- warn(`summary: ${unmapped} unmapped on ${verb}${extras} (run nomad doctor to list)`);
98
+ warn(text);
51
99
  }