@myclaw163/clawclaw-cli 0.6.56 → 0.6.58

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.
Files changed (50) hide show
  1. package/bin/clawclaw-cli.mjs +3 -3
  2. package/package.json +1 -1
  3. package/scripts/sync-bundled-skill.mjs +1 -1
  4. package/skills/clawclaw/SKILL.md +7 -3
  5. package/skills/clawclaw/references/GAME-MECHANICS.md +5 -5
  6. package/skills/clawclaw/references/STREAM.md +4 -3
  7. package/src/cli.ts +0 -43
  8. package/src/commands/config.ts +30 -30
  9. package/src/commands/game-start-plan.test.ts +10 -68
  10. package/src/commands/game.ts +318 -173
  11. package/src/commands/history.ts +1 -1
  12. package/src/commands/peek.ts +16 -9
  13. package/src/commands/setup/codex.ts +248 -248
  14. package/src/commands/setup/hermes.test.ts +96 -96
  15. package/src/commands/setup/hermes.ts +76 -76
  16. package/src/commands/setup/index.ts +13 -13
  17. package/src/commands/setup/openclaw.test.ts +114 -114
  18. package/src/commands/setup/openclaw.ts +147 -147
  19. package/src/commands/strategy.ts +29 -38
  20. package/src/commands/watch.test.ts +7 -11
  21. package/src/commands/watch.ts +77 -66
  22. package/src/lib/game-client.ts +3 -3
  23. package/src/lib/host-config-patcher.test.ts +130 -130
  24. package/src/lib/host-config-patcher.ts +151 -151
  25. package/src/lib/hub-reminder.ts +19 -19
  26. package/src/lib/strategy-export.test.ts +1 -1
  27. package/src/runtime/event-daemon.test.ts +81 -2
  28. package/src/runtime/event-daemon.ts +325 -287
  29. package/src/runtime/owner-control.ts +150 -0
  30. package/src/runtime/runtime-logger.ts +11 -3
  31. package/src/runtime/ws-client.test.ts +57 -0
  32. package/src/runtime/ws-client.ts +2 -2
  33. package/src/sdk/index.ts +4 -4
  34. package/src/strategies/game-utils.test.ts +27 -1
  35. package/src/strategies/game-utils.ts +27 -4
  36. package/src/strategies/goals/crab-octopus-reflexes.ts +4 -4
  37. package/src/strategies/goals/crab-sabotage-top.ts +1 -1
  38. package/src/strategies/goals/keep-away-goal.ts +10 -7
  39. package/src/strategies/goals/kill-frenzy-top.ts +5 -5
  40. package/src/strategies/goals/kill-target-goal.ts +2 -2
  41. package/src/strategies/goals/kill-target-top.ts +2 -2
  42. package/src/strategies/goals/lone-kill-core.ts +3 -3
  43. package/src/strategies/goals/lone-kill-task-top.ts +1 -1
  44. package/src/strategies/goals/task-kill-report-top.ts +3 -3
  45. package/src/strategies/goals/warrior-shrimp-top.ts +2 -2
  46. package/src/strategies/pathfind/escape-planner.ts +10 -3
  47. package/src/strategies/spawn.ts +16 -5
  48. package/src/strategies/strategy-loop.ts +9 -3
  49. package/src/runtime/daemon.ts +0 -100
  50. package/src/runtime/opening-mover.ts +0 -303
@@ -1,16 +1,17 @@
1
1
  import { Command } from 'commander';
2
2
  import { existsSync, readFileSync, openSync, readSync, closeSync, statSync } from 'fs';
3
- import { dirname, join } from 'path';
3
+ import { join } from 'path';
4
4
  import { AuthStore } from '../lib/auth.js';
5
5
  import { getProfileStateDir } from '../lib/init-command.js';
6
6
  import { setMeta } from '../lib/command-meta.js';
7
7
  import { EventStore, extractNewEvents } from '../pipeline/event-store.js';
8
8
  import { DEFAULT_MATCH_TIMEOUT_MS } from '../lib/match-state.js';
9
9
  import { hubReminder, readCachedGamesPlayed } from '../lib/hub-reminder.js';
10
+ import { sendOwnerControlRequest } from '../runtime/owner-control.js';
10
11
 
11
12
  export const POLL_INTERVAL_MS = 220;
12
13
  const HEARTBEAT_INTERVAL_MS = 60_000;
13
- const DAEMON_WAIT_MS = 30_000;
14
+ const RUNTIME_WAIT_MS = 30_000;
14
15
 
15
16
  export class WatchNotReadyError extends Error {
16
17
  constructor(message: string) {
@@ -19,12 +20,12 @@ export class WatchNotReadyError extends Error {
19
20
  }
20
21
  }
21
22
 
22
- /** Wait for event-daemon to create feed.json (parallel launch with `game start`). */
23
+ /** Legacy helper for tests and explicit file-backed attaches. Owner paths use control socket snapshots. */
23
24
  export async function waitForFeed(
24
25
  feedPath: string,
25
26
  opts?: { signal?: AbortSignal; timeoutMs?: number; pollMs?: number },
26
27
  ): Promise<boolean> {
27
- const timeoutMs = opts?.timeoutMs ?? DAEMON_WAIT_MS;
28
+ const timeoutMs = opts?.timeoutMs ?? RUNTIME_WAIT_MS;
28
29
  const pollMs = opts?.pollMs ?? POLL_INTERVAL_MS;
29
30
  const deadline = Date.now() + timeoutMs;
30
31
  while (Date.now() < deadline) {
@@ -147,10 +148,10 @@ const ROUTING_RULES: RouteRule[] = [
147
148
  { reason: 'speech_your_turn', match: (t) => t.includes('speech_your_turn'), nextStep: 'It is YOUR turn to speak. Submit `ccl do -s "<draft>"` immediately. Server skips you after 45s.' },
148
149
  { reason: 'role_assigned', match: (t) => t.includes('role_assigned'), nextStep: 'Tell user your role, faction, win condition, and first plan.' },
149
150
  { reason: 'vote_cast', match: (t) => t.includes('vote_cast'), nextStep: 'A player just cast their vote. Read events[] to track who voted. No `ccl` action needed — cast your own vote immediately when vote phase starts.' },
150
- { reason: 'match_start', match: (t) => t.includes('match_start'), nextStep: 'Matchmaking queue entered. The daemon is live and the stream is attached. Chat with the user while waiting for allocation.' },
151
+ { reason: 'match_start', match: (t) => t.includes('match_start'), nextStep: 'Matchmaking queue entered. The game runtime is live and the stream is attached. Chat with the user while waiting for allocation.' },
151
152
  { reason: 'match_waiting', match: (t) => t.includes('match_waiting'), nextStep: 'Still in queue (see `events[].waited_secs`). Keep chatting with the user; no tactical action required.' },
152
153
  { reason: 'match_timeout', match: (t) => t.includes('match_timeout'), nextStep: `Cumulative wait reached ${Math.round(DEFAULT_MATCH_TIMEOUT_MS / 60_000)} min (see \`events[].waited_secs\`). The stream will exit — discuss with the user: launch a fresh \`ccl game start\` to retry, or call it a session.` },
153
- { reason: 'game_over', match: (t) => t.includes('game_over'), nextStep: 'Game ended. The stream process will exit. Run `ccl game start` for the next match a single launch covers everything from queue to game_over.' },
154
+ { reason: 'game_over', match: (t) => t.includes('game_over'), nextStep: 'Game ended. The current ccl game start process is exiting automatically. Review the result with the user; start another match only if the user asks.' },
154
155
  { reason: 'strategy_alert', match: (t) => t.includes('strategy_alert'), nextStep: 'Your automation just made a decisive autonomous move and is briefing you (read events[].message). Lock your story to it before you next speak or vote — e.g. if it reported a corpse that is likely your own kill, present yourself as the proactive reporter, steer suspicion onto the bystanders, and do NOT confess.' },
155
156
  ];
156
157
 
@@ -259,53 +260,36 @@ export function compactEventForMonitor(event: GameEvent): GameEvent {
259
260
  return event;
260
261
  }
261
262
 
262
- function readAutomationSummary(feedPath: string): Record<string, any> | undefined {
263
- try {
264
- const raw = JSON.parse(readFileSync(join(dirname(feedPath), 'auto.json'), 'utf8'));
265
- const strategy = typeof raw?.strategy === 'string' && raw.strategy.length > 0 ? raw.strategy : undefined;
266
- if (!strategy) return undefined;
267
- const pid = Number(raw?.pid);
268
- let running = false;
269
- if (Number.isFinite(pid) && pid > 0) {
270
- try {
271
- process.kill(pid, 0);
272
- running = true;
273
- } catch {
274
- running = false;
275
- }
276
- }
277
- return { strategy, running };
278
- } catch {
279
- return undefined;
280
- }
263
+ export function summarizeFeed(feed: any): any | null {
264
+ if (!feed || typeof feed !== 'object') return null;
265
+ const summary: any = {
266
+ phase: feed.phase,
267
+ you: {
268
+ name: feed.you?.name,
269
+ seat: feed.you?.seat,
270
+ role: feed.you?.role,
271
+ role_display: feed.you?.role_display,
272
+ faction: feed.you?.faction,
273
+ alive: feed.you?.alive,
274
+ x: feed.you?.x,
275
+ y: feed.you?.y,
276
+ currently_moving: feed.you?.currently_moving,
277
+ doing_task: feed.you?.doing_task,
278
+ kill_cooldown_secs: feed.you?.kill_cooldown_secs,
279
+ kills_remaining: feed.you?.kills_remaining,
280
+ },
281
+ game: feed.game,
282
+ urgent: feed.urgent,
283
+ meeting: feed.meeting,
284
+ };
285
+ if (feed.automation) summary.automation = feed.automation;
286
+ return summary;
281
287
  }
282
288
 
283
289
  export function readFeedSummary(feedPath: string): any | null {
284
290
  try {
285
291
  const feed = JSON.parse(readFileSync(feedPath, 'utf8'));
286
- const summary: any = {
287
- phase: feed.phase,
288
- you: {
289
- name: feed.you?.name,
290
- seat: feed.you?.seat,
291
- role: feed.you?.role,
292
- role_display: feed.you?.role_display,
293
- faction: feed.you?.faction,
294
- alive: feed.you?.alive,
295
- x: feed.you?.x,
296
- y: feed.you?.y,
297
- currently_moving: feed.you?.currently_moving,
298
- doing_task: feed.you?.doing_task,
299
- kill_cooldown_secs: feed.you?.kill_cooldown_secs,
300
- kills_remaining: feed.you?.kills_remaining,
301
- },
302
- game: feed.game,
303
- urgent: feed.urgent,
304
- meeting: feed.meeting,
305
- };
306
- const automation = readAutomationSummary(feedPath);
307
- if (automation) summary.automation = automation;
308
- return summary;
292
+ return summarizeFeed(feed);
309
293
  } catch {
310
294
  return null;
311
295
  }
@@ -351,15 +335,17 @@ export function compactSummaryForMonitor(summary: any | null, triggers: string[]
351
335
  export interface RunStreamingOptions {
352
336
  feedPath: string;
353
337
  sessionPath: string | null;
338
+ readSummary?: () => any | null;
354
339
  /** Optional dynamic resolver — called each poll so the stream can follow session rotation across games. */
355
340
  getSessionPath?: () => string | null;
356
341
  stdout: (line: string) => void;
357
342
  signal?: AbortSignal;
343
+ skipFeedWait?: boolean;
358
344
  pollIntervalMs?: number;
359
345
  /** Emit a `heartbeat` NDJSON line after this many ms of silence so consumers don't see the stream as hung. Default 30s. Pass 0 to disable. */
360
346
  heartbeatIntervalMs?: number;
361
347
  /** Max ms to wait for feed.json when launching alongside `game start`. Default 30s. */
362
- daemonWaitMs?: number;
348
+ runtimeWaitMs?: number;
363
349
  /**
364
350
  * Per-event-type delay overrides for the delay buffer (see DELAYABLE_EVENT_CONFIG).
365
351
  * Pass `{ kill: 0 }` to disable the kill delay in tests.
@@ -390,15 +376,17 @@ export interface RunStreamingOptions {
390
376
  }
391
377
 
392
378
  export async function runStreaming(opts: RunStreamingOptions): Promise<void> {
393
- const feedReady = await waitForFeed(opts.feedPath, {
394
- signal: opts.signal,
395
- timeoutMs: opts.daemonWaitMs ?? DAEMON_WAIT_MS,
396
- pollMs: opts.pollIntervalMs ?? POLL_INTERVAL_MS,
397
- });
398
- if (!feedReady) {
399
- throw new WatchNotReadyError(
400
- 'Event daemon not ready (no feed.json). Run `ccl game start` to start or recover the game stream.',
401
- );
379
+ if (!opts.skipFeedWait) {
380
+ const feedReady = await waitForFeed(opts.feedPath, {
381
+ signal: opts.signal,
382
+ timeoutMs: opts.runtimeWaitMs ?? RUNTIME_WAIT_MS,
383
+ pollMs: opts.pollIntervalMs ?? POLL_INTERVAL_MS,
384
+ });
385
+ if (!feedReady) {
386
+ throw new WatchNotReadyError(
387
+ 'Game runtime not ready (no feed.json). Run `ccl game start` to start or recover the game stream.',
388
+ );
389
+ }
402
390
  }
403
391
  const interval = opts.pollIntervalMs ?? POLL_INTERVAL_MS;
404
392
  const heartbeatInterval = opts.heartbeatIntervalMs ?? HEARTBEAT_INTERVAL_MS;
@@ -409,10 +397,12 @@ export async function runStreaming(opts: RunStreamingOptions): Promise<void> {
409
397
  lastEmitMs = Date.now();
410
398
  };
411
399
 
412
- // Initial state from feed.json (best-effort)
400
+ // Initial player name from owner snapshot or legacy feed file (best-effort).
413
401
  let youName: string | null = null;
414
402
  try {
415
- youName = JSON.parse(readFileSync(opts.feedPath, 'utf8'))?.you?.name ?? null;
403
+ youName = opts.readSummary?.()?.you?.name
404
+ ?? JSON.parse(readFileSync(opts.feedPath, 'utf8'))?.you?.name
405
+ ?? null;
416
406
  } catch {}
417
407
 
418
408
  // Tail state for the .jsonl
@@ -427,7 +417,7 @@ export async function runStreaming(opts: RunStreamingOptions): Promise<void> {
427
417
  fileIno = statSync(currentSessionPath).ino;
428
418
  }
429
419
 
430
- // Cross-game session rotation: daemon may create a new events.jsonl when a new
420
+ // Cross-game session rotation: the runtime may create a new events.jsonl when a new
431
421
  // game starts. If the caller provided a dynamic resolver, poll it and switch
432
422
  // tail state to the new file. Without this, the stream would silently follow the
433
423
  // previous game's file forever and look like a huge event delay.
@@ -492,10 +482,10 @@ export async function runStreaming(opts: RunStreamingOptions): Promise<void> {
492
482
  nextStep: string,
493
483
  events: GameEvent[] = [],
494
484
  ): any => {
495
- const rawSummary = readFeedSummary(opts.feedPath);
485
+ const rawSummary = opts.readSummary?.() ?? readFeedSummary(opts.feedPath);
496
486
  const summary = compactSummaryForMonitor(rawSummary, triggers);
497
487
  // Server pushes `meeting_briefing` without `speech_order` (that field only lives in
498
- // meeting state, which the daemon projects onto feed.json.meeting.speech_order).
488
+ // meeting state, which the runtime projects onto the owner snapshot).
499
489
  // Inject it onto the briefing event here so consumers can read it from events[]
500
490
  // directly. Guarded by caller match so a stale speech_order from a previous
501
491
  // meeting isn't ever attached.
@@ -641,7 +631,7 @@ export interface SnapshotOnceOptions {
641
631
 
642
632
  export function snapshotOnce(opts: SnapshotOnceOptions): void {
643
633
  if (!existsSync(opts.feedPath)) {
644
- throw new Error('ccl daemon is not running. Start it first with `ccl game start`.');
634
+ throw new Error('ccl game runtime is not running. Start it first with `ccl game start`.');
645
635
  }
646
636
  const summary = readFeedSummary(opts.feedPath);
647
637
  const warning = summary?.phase === 'meeting'
@@ -661,7 +651,7 @@ export function buildErrorLine(err: any, opts?: { notReadyNextStep?: string }):
661
651
  const notReady = err?.name === 'WatchNotReadyError' || /not ready|feed\.json/i.test(msg);
662
652
  const reason = notReady ? 'not_ready' : 'error';
663
653
  const nextStep = notReady
664
- ? (opts?.notReadyNextStep ?? 'Event daemon not running. Run `ccl game start` to begin a new session.')
654
+ ? (opts?.notReadyNextStep ?? 'Game runtime not running. Run `ccl game start` to begin a new session.')
665
655
  : msg;
666
656
  return JSON.stringify({
667
657
  exit_reason: [reason],
@@ -682,18 +672,37 @@ export function createWatchCommand(): Command {
682
672
  process.stdout.write(JSON.stringify({ exit_reason: ['not_logged_in'], error: 'Not logged in' }) + '\n');
683
673
  return;
684
674
  }
685
- const feedPath = join(getProfileStateDir(profile), 'feed.json');
675
+ const stateDir = getProfileStateDir(profile);
676
+ const feedPath = join(stateDir, 'feed.json');
686
677
  const sessionPath = EventStore.latestSessionPath();
687
678
  const ctrl = new AbortController();
688
679
  process.on('SIGINT', () => ctrl.abort());
689
680
  process.on('SIGTERM', () => ctrl.abort());
681
+ let summaryCache: any | null = null;
682
+ let summaryPoll: ReturnType<typeof setInterval> | null = null;
690
683
  try {
684
+ const snapshot = await sendOwnerControlRequest(stateDir, 'snapshot');
685
+ if (!snapshot?.ok) {
686
+ throw new WatchNotReadyError(
687
+ 'Game runtime not ready (no active ccl game start owner). Run `ccl game start` to start or recover the game stream.',
688
+ );
689
+ }
690
+ summaryCache = snapshot.summary ?? null;
691
+ summaryPoll = setInterval(() => {
692
+ void sendOwnerControlRequest(stateDir, 'snapshot')
693
+ .then((response) => {
694
+ if (response?.ok) summaryCache = response.summary ?? null;
695
+ })
696
+ .catch(() => {});
697
+ }, 1000);
691
698
  await runStreaming({
692
699
  feedPath,
693
700
  sessionPath,
694
701
  getSessionPath: () => EventStore.latestSessionPath(),
695
702
  stdout: (s) => process.stdout.write(s),
696
703
  signal: ctrl.signal,
704
+ skipFeedWait: true,
705
+ readSummary: () => summaryCache,
697
706
  hubReminder: hubReminder(readCachedGamesPlayed()),
698
707
  });
699
708
  } catch (err: any) {
@@ -701,6 +710,8 @@ export function createWatchCommand(): Command {
701
710
  notReadyNextStep: 'Run `ccl game start` to start or recover the game stream.',
702
711
  }));
703
712
  process.exit(1);
713
+ } finally {
714
+ if (summaryPoll) clearInterval(summaryPoll);
704
715
  }
705
716
  });
706
717
  // ── Adapter metadata: streaming mode blocks until game_over; cap at 30min for stuck-process safety ──
@@ -48,7 +48,7 @@ export class GameClient {
48
48
 
49
49
  /**
50
50
  * Create GameClient from active auth profile.
51
- * Used by both daemon (ws: true) and CLI commands (ws: false).
51
+ * Used by both runtime listeners (ws: true) and CLI commands (ws: false).
52
52
  */
53
53
  static fromAuth(opts?: { ws?: boolean; authStore?: AuthStore; rawWsLogPath?: string }): GameClient {
54
54
  const store = opts?.authStore ?? new AuthStore();
@@ -282,7 +282,7 @@ export class GameClient {
282
282
  await newWs.connect();
283
283
  return true;
284
284
  } catch {
285
- this.log.warn('WS_CONNECT', 'event WebSocket connection failed; daemon will retry');
285
+ this.log.warn('WS_CONNECT', 'event WebSocket connection failed; runtime will retry');
286
286
  newWs.disconnect();
287
287
  this.ws = null;
288
288
  this.markWsHandlersDetached();
@@ -344,7 +344,7 @@ export class GameClient {
344
344
  await newWs.connect();
345
345
  this.log.info('WS_RECONNECT', `connected to ${wsUrl}`);
346
346
  } catch {
347
- this.log.warn('WS_RECONNECT', 'event WebSocket reconnect failed; daemon will retry');
347
+ this.log.warn('WS_RECONNECT', 'event WebSocket reconnect failed; runtime will retry');
348
348
  newWs.disconnect();
349
349
  this.ws = null;
350
350
  this.markWsHandlersDetached();
@@ -1,130 +1,130 @@
1
- import { mkdtempSync, readFileSync, writeFileSync, existsSync, readdirSync } from 'fs';
2
- import { tmpdir } from 'os';
3
- import { dirname, join } from 'path';
4
- import { describe, expect, it } from 'vitest';
5
- import { runHostConfigSetup, unionArrayField, type HostConfigPatcher } from './host-config-patcher.js';
6
-
7
- function tmpConfig(initial: unknown): string {
8
- const dir = mkdtempSync(join(tmpdir(), 'host-cfg-'));
9
- const p = join(dir, 'config.json');
10
- writeFileSync(p, JSON.stringify(initial, null, 2));
11
- return p;
12
- }
13
-
14
- const dummyHost: HostConfigPatcher<{ value: string }> = {
15
- hostName: 'dummy',
16
- resolveConfigPath: () => '/tmp/never-used',
17
- computePatch: (current, opts) => {
18
- const cur = (current as Record<string, unknown> | null) ?? {};
19
- const before = (cur.values as string[] | undefined) ?? [];
20
- if (before.includes(opts.value)) return { next: cur, changes: [], warnings: [] };
21
- return {
22
- next: { ...cur, values: [...before, opts.value] },
23
- changes: [`add ${opts.value} to values`],
24
- warnings: [],
25
- };
26
- },
27
- };
28
-
29
- describe('runHostConfigSetup', () => {
30
- it('prints recommended patch in --print mode without touching disk', () => {
31
- const r = runHostConfigSetup(dummyHost, { value: 'x' }, { print: true });
32
- expect(r.exitCode).toBe(0);
33
- expect(r.output.join('\n')).toContain('"values"');
34
- });
35
-
36
- it('reports missing config file gracefully', () => {
37
- const r = runHostConfigSetup(dummyHost, { value: 'x' }, { configPath: '/no/such/file.json' });
38
- expect(r.exitCode).toBe(0);
39
- expect(r.output.some((l) => l.includes('not found'))).toBe(true);
40
- });
41
-
42
- it('reports malformed JSON with exit 1', () => {
43
- const dir = mkdtempSync(join(tmpdir(), 'host-cfg-bad-'));
44
- const p = join(dir, 'config.json');
45
- writeFileSync(p, '{ not: valid json');
46
- const r = runHostConfigSetup(dummyHost, { value: 'x' }, { configPath: p });
47
- expect(r.exitCode).toBe(1);
48
- expect(r.output.join('\n')).toMatch(/Failed to parse/);
49
- });
50
-
51
- it('returns exit 2 with diff in dry-run when changes pending', () => {
52
- const p = tmpConfig({ values: ['a'] });
53
- const r = runHostConfigSetup(dummyHost, { value: 'b' }, { configPath: p });
54
- expect(r.exitCode).toBe(2);
55
- const txt = r.output.join('\n');
56
- expect(txt).toMatch(/Pending changes/);
57
- expect(txt).toMatch(/Re-run with -y to apply/);
58
- // file unchanged
59
- expect(JSON.parse(readFileSync(p, 'utf-8'))).toEqual({ values: ['a'] });
60
- });
61
-
62
- it('returns exit 0 no-op when already up to date', () => {
63
- const p = tmpConfig({ values: ['a'] });
64
- const r = runHostConfigSetup(dummyHost, { value: 'a' }, { configPath: p });
65
- expect(r.exitCode).toBe(0);
66
- expect(r.output.join('\n')).toMatch(/already up to date/);
67
- });
68
-
69
- it('applies changes atomically with backup when -y', () => {
70
- const p = tmpConfig({ values: ['a'] });
71
- const r = runHostConfigSetup(dummyHost, { value: 'b' }, { configPath: p, yes: true });
72
- expect(r.exitCode).toBe(0);
73
- expect(JSON.parse(readFileSync(p, 'utf-8'))).toEqual({ values: ['a', 'b'] });
74
- const files = readdirSync(dirname(p));
75
- expect(files.some((f) => f.startsWith('config.json.bak.'))).toBe(true);
76
- });
77
-
78
- it('--no-backup skips backup file', () => {
79
- const p = tmpConfig({ values: ['a'] });
80
- const r = runHostConfigSetup(dummyHost, { value: 'b' }, { configPath: p, yes: true, backup: false });
81
- expect(r.exitCode).toBe(0);
82
- const files = readdirSync(dirname(p));
83
- expect(files.some((f) => f.startsWith('config.json.bak.'))).toBe(false);
84
- });
85
-
86
- it('does not leave .tmp file behind on success', () => {
87
- const p = tmpConfig({ values: ['a'] });
88
- runHostConfigSetup(dummyHost, { value: 'b' }, { configPath: p, yes: true, backup: false });
89
- const files = readdirSync(dirname(p));
90
- expect(files.some((f) => f.includes('.tmp.'))).toBe(false);
91
- });
92
-
93
- it('preserves CRLF line endings when original file uses CRLF', () => {
94
- const dir = mkdtempSync(join(tmpdir(), 'host-cfg-crlf-'));
95
- const p = join(dir, 'config.json');
96
- writeFileSync(p, '{\r\n "values": [\r\n "a"\r\n ]\r\n}\r\n');
97
- runHostConfigSetup(dummyHost, { value: 'b' }, { configPath: p, yes: true, backup: false });
98
- const after = readFileSync(p, 'utf-8');
99
- expect(after.includes('\r\n')).toBe(true);
100
- expect(after.replace(/\r\n/g, '<EOL>').includes('\n')).toBe(false);
101
- });
102
-
103
- it('preserves LF line endings when original file uses LF', () => {
104
- const dir = mkdtempSync(join(tmpdir(), 'host-cfg-lf-'));
105
- const p = join(dir, 'config.json');
106
- writeFileSync(p, '{\n "values": [\n "a"\n ]\n}\n');
107
- runHostConfigSetup(dummyHost, { value: 'b' }, { configPath: p, yes: true, backup: false });
108
- const after = readFileSync(p, 'utf-8');
109
- expect(after.includes('\r\n')).toBe(false);
110
- });
111
- });
112
-
113
- describe('unionArrayField', () => {
114
- it('creates new array when field missing', () => {
115
- expect(unionArrayField(undefined, 'x')).toEqual({ next: ['x'], added: true });
116
- });
117
-
118
- it('returns warning when existing value is not an array', () => {
119
- const r = unionArrayField('not-an-array', 'x');
120
- expect(r).toEqual({ warning: 'existing value is not an array, refusing to overwrite' });
121
- });
122
-
123
- it('returns added=false when entry already present', () => {
124
- expect(unionArrayField(['a', 'b'], 'a')).toEqual({ next: ['a', 'b'], added: false });
125
- });
126
-
127
- it('appends entry when missing from array', () => {
128
- expect(unionArrayField(['a'], 'b')).toEqual({ next: ['a', 'b'], added: true });
129
- });
130
- });
1
+ import { mkdtempSync, readFileSync, writeFileSync, existsSync, readdirSync } from 'fs';
2
+ import { tmpdir } from 'os';
3
+ import { dirname, join } from 'path';
4
+ import { describe, expect, it } from 'vitest';
5
+ import { runHostConfigSetup, unionArrayField, type HostConfigPatcher } from './host-config-patcher.js';
6
+
7
+ function tmpConfig(initial: unknown): string {
8
+ const dir = mkdtempSync(join(tmpdir(), 'host-cfg-'));
9
+ const p = join(dir, 'config.json');
10
+ writeFileSync(p, JSON.stringify(initial, null, 2));
11
+ return p;
12
+ }
13
+
14
+ const dummyHost: HostConfigPatcher<{ value: string }> = {
15
+ hostName: 'dummy',
16
+ resolveConfigPath: () => '/tmp/never-used',
17
+ computePatch: (current, opts) => {
18
+ const cur = (current as Record<string, unknown> | null) ?? {};
19
+ const before = (cur.values as string[] | undefined) ?? [];
20
+ if (before.includes(opts.value)) return { next: cur, changes: [], warnings: [] };
21
+ return {
22
+ next: { ...cur, values: [...before, opts.value] },
23
+ changes: [`add ${opts.value} to values`],
24
+ warnings: [],
25
+ };
26
+ },
27
+ };
28
+
29
+ describe('runHostConfigSetup', () => {
30
+ it('prints recommended patch in --print mode without touching disk', () => {
31
+ const r = runHostConfigSetup(dummyHost, { value: 'x' }, { print: true });
32
+ expect(r.exitCode).toBe(0);
33
+ expect(r.output.join('\n')).toContain('"values"');
34
+ });
35
+
36
+ it('reports missing config file gracefully', () => {
37
+ const r = runHostConfigSetup(dummyHost, { value: 'x' }, { configPath: '/no/such/file.json' });
38
+ expect(r.exitCode).toBe(0);
39
+ expect(r.output.some((l) => l.includes('not found'))).toBe(true);
40
+ });
41
+
42
+ it('reports malformed JSON with exit 1', () => {
43
+ const dir = mkdtempSync(join(tmpdir(), 'host-cfg-bad-'));
44
+ const p = join(dir, 'config.json');
45
+ writeFileSync(p, '{ not: valid json');
46
+ const r = runHostConfigSetup(dummyHost, { value: 'x' }, { configPath: p });
47
+ expect(r.exitCode).toBe(1);
48
+ expect(r.output.join('\n')).toMatch(/Failed to parse/);
49
+ });
50
+
51
+ it('returns exit 2 with diff in dry-run when changes pending', () => {
52
+ const p = tmpConfig({ values: ['a'] });
53
+ const r = runHostConfigSetup(dummyHost, { value: 'b' }, { configPath: p });
54
+ expect(r.exitCode).toBe(2);
55
+ const txt = r.output.join('\n');
56
+ expect(txt).toMatch(/Pending changes/);
57
+ expect(txt).toMatch(/Re-run with -y to apply/);
58
+ // file unchanged
59
+ expect(JSON.parse(readFileSync(p, 'utf-8'))).toEqual({ values: ['a'] });
60
+ });
61
+
62
+ it('returns exit 0 no-op when already up to date', () => {
63
+ const p = tmpConfig({ values: ['a'] });
64
+ const r = runHostConfigSetup(dummyHost, { value: 'a' }, { configPath: p });
65
+ expect(r.exitCode).toBe(0);
66
+ expect(r.output.join('\n')).toMatch(/already up to date/);
67
+ });
68
+
69
+ it('applies changes atomically with backup when -y', () => {
70
+ const p = tmpConfig({ values: ['a'] });
71
+ const r = runHostConfigSetup(dummyHost, { value: 'b' }, { configPath: p, yes: true });
72
+ expect(r.exitCode).toBe(0);
73
+ expect(JSON.parse(readFileSync(p, 'utf-8'))).toEqual({ values: ['a', 'b'] });
74
+ const files = readdirSync(dirname(p));
75
+ expect(files.some((f) => f.startsWith('config.json.bak.'))).toBe(true);
76
+ });
77
+
78
+ it('--no-backup skips backup file', () => {
79
+ const p = tmpConfig({ values: ['a'] });
80
+ const r = runHostConfigSetup(dummyHost, { value: 'b' }, { configPath: p, yes: true, backup: false });
81
+ expect(r.exitCode).toBe(0);
82
+ const files = readdirSync(dirname(p));
83
+ expect(files.some((f) => f.startsWith('config.json.bak.'))).toBe(false);
84
+ });
85
+
86
+ it('does not leave .tmp file behind on success', () => {
87
+ const p = tmpConfig({ values: ['a'] });
88
+ runHostConfigSetup(dummyHost, { value: 'b' }, { configPath: p, yes: true, backup: false });
89
+ const files = readdirSync(dirname(p));
90
+ expect(files.some((f) => f.includes('.tmp.'))).toBe(false);
91
+ });
92
+
93
+ it('preserves CRLF line endings when original file uses CRLF', () => {
94
+ const dir = mkdtempSync(join(tmpdir(), 'host-cfg-crlf-'));
95
+ const p = join(dir, 'config.json');
96
+ writeFileSync(p, '{\r\n "values": [\r\n "a"\r\n ]\r\n}\r\n');
97
+ runHostConfigSetup(dummyHost, { value: 'b' }, { configPath: p, yes: true, backup: false });
98
+ const after = readFileSync(p, 'utf-8');
99
+ expect(after.includes('\r\n')).toBe(true);
100
+ expect(after.replace(/\r\n/g, '<EOL>').includes('\n')).toBe(false);
101
+ });
102
+
103
+ it('preserves LF line endings when original file uses LF', () => {
104
+ const dir = mkdtempSync(join(tmpdir(), 'host-cfg-lf-'));
105
+ const p = join(dir, 'config.json');
106
+ writeFileSync(p, '{\n "values": [\n "a"\n ]\n}\n');
107
+ runHostConfigSetup(dummyHost, { value: 'b' }, { configPath: p, yes: true, backup: false });
108
+ const after = readFileSync(p, 'utf-8');
109
+ expect(after.includes('\r\n')).toBe(false);
110
+ });
111
+ });
112
+
113
+ describe('unionArrayField', () => {
114
+ it('creates new array when field missing', () => {
115
+ expect(unionArrayField(undefined, 'x')).toEqual({ next: ['x'], added: true });
116
+ });
117
+
118
+ it('returns warning when existing value is not an array', () => {
119
+ const r = unionArrayField('not-an-array', 'x');
120
+ expect(r).toEqual({ warning: 'existing value is not an array, refusing to overwrite' });
121
+ });
122
+
123
+ it('returns added=false when entry already present', () => {
124
+ expect(unionArrayField(['a', 'b'], 'a')).toEqual({ next: ['a', 'b'], added: false });
125
+ });
126
+
127
+ it('appends entry when missing from array', () => {
128
+ expect(unionArrayField(['a'], 'b')).toEqual({ next: ['a', 'b'], added: true });
129
+ });
130
+ });