@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.
- package/bin/clawclaw-cli.mjs +3 -3
- package/package.json +1 -1
- package/scripts/sync-bundled-skill.mjs +1 -1
- package/skills/clawclaw/SKILL.md +7 -3
- package/skills/clawclaw/references/GAME-MECHANICS.md +5 -5
- package/skills/clawclaw/references/STREAM.md +4 -3
- package/src/cli.ts +0 -43
- package/src/commands/config.ts +30 -30
- package/src/commands/game-start-plan.test.ts +10 -68
- package/src/commands/game.ts +318 -173
- package/src/commands/history.ts +1 -1
- package/src/commands/peek.ts +16 -9
- package/src/commands/setup/codex.ts +248 -248
- package/src/commands/setup/hermes.test.ts +96 -96
- package/src/commands/setup/hermes.ts +76 -76
- package/src/commands/setup/index.ts +13 -13
- package/src/commands/setup/openclaw.test.ts +114 -114
- package/src/commands/setup/openclaw.ts +147 -147
- package/src/commands/strategy.ts +29 -38
- package/src/commands/watch.test.ts +7 -11
- package/src/commands/watch.ts +77 -66
- package/src/lib/game-client.ts +3 -3
- package/src/lib/host-config-patcher.test.ts +130 -130
- package/src/lib/host-config-patcher.ts +151 -151
- package/src/lib/hub-reminder.ts +19 -19
- package/src/lib/strategy-export.test.ts +1 -1
- package/src/runtime/event-daemon.test.ts +81 -2
- package/src/runtime/event-daemon.ts +325 -287
- package/src/runtime/owner-control.ts +150 -0
- package/src/runtime/runtime-logger.ts +11 -3
- package/src/runtime/ws-client.test.ts +57 -0
- package/src/runtime/ws-client.ts +2 -2
- package/src/sdk/index.ts +4 -4
- package/src/strategies/game-utils.test.ts +27 -1
- package/src/strategies/game-utils.ts +27 -4
- package/src/strategies/goals/crab-octopus-reflexes.ts +4 -4
- package/src/strategies/goals/crab-sabotage-top.ts +1 -1
- package/src/strategies/goals/keep-away-goal.ts +10 -7
- package/src/strategies/goals/kill-frenzy-top.ts +5 -5
- package/src/strategies/goals/kill-target-goal.ts +2 -2
- package/src/strategies/goals/kill-target-top.ts +2 -2
- package/src/strategies/goals/lone-kill-core.ts +3 -3
- package/src/strategies/goals/lone-kill-task-top.ts +1 -1
- package/src/strategies/goals/task-kill-report-top.ts +3 -3
- package/src/strategies/goals/warrior-shrimp-top.ts +2 -2
- package/src/strategies/pathfind/escape-planner.ts +10 -3
- package/src/strategies/spawn.ts +16 -5
- package/src/strategies/strategy-loop.ts +9 -3
- package/src/runtime/daemon.ts +0 -100
- package/src/runtime/opening-mover.ts +0 -303
package/src/commands/watch.ts
CHANGED
|
@@ -1,16 +1,17 @@
|
|
|
1
1
|
import { Command } from 'commander';
|
|
2
2
|
import { existsSync, readFileSync, openSync, readSync, closeSync, statSync } from 'fs';
|
|
3
|
-
import {
|
|
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
|
|
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
|
-
/**
|
|
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 ??
|
|
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
|
|
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
|
|
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
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
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
|
|
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 =
|
|
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:
|
|
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
|
|
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
|
|
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 ?? '
|
|
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
|
|
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 ──
|
package/src/lib/game-client.ts
CHANGED
|
@@ -48,7 +48,7 @@ export class GameClient {
|
|
|
48
48
|
|
|
49
49
|
/**
|
|
50
50
|
* Create GameClient from active auth profile.
|
|
51
|
-
* Used by both
|
|
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;
|
|
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;
|
|
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
|
+
});
|