Skip to content

Commit bc0cfe5

Browse files
feat(core): legacy global file cleanup mechanism (T304)
T304 — Wave 1 of T299 v2026.4.11 epic. Implements ADR-036's "legacy global file cleanup" section. New cleanup-legacy.ts with detectAndRemoveLegacyGlobalFiles() — idempotent one-shot cleanup of workspace.db + workspace.db.bak-pre-rename + workspace.db-shm + workspace.db-shm-wal + nexus-pre-cleo.db.bak at $XDG_DATA_HOME/cleo/. - 6 unit tests covering all-present, no-op, partial, no-spurious-deletions, idempotent, error-capture - Wired into packages/cleo/src/cli/index.ts global init (non-blocking) - Exported via packages/core/src/internal.ts Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent b1323b7 commit bc0cfe5

File tree

4 files changed

+306
-0
lines changed

4 files changed

+306
-0
lines changed

packages/cleo/src/cli/index.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
import { readFileSync } from 'node:fs';
1010
import { dirname, join } from 'node:path';
1111
import { fileURLToPath } from 'node:url';
12+
import { detectAndRemoveLegacyGlobalFiles } from '@cleocode/core/internal';
1213
import { type CommandDef, defineCommand, runMain, showUsage } from 'citty';
1314
import { ShimCommand } from './commander-shim.js';
1415
import { resolveFieldContext, setFieldContext } from './field-context.js';
@@ -375,6 +376,15 @@ for (const shim of rootShim._subcommands) {
375376
}
376377
setFieldContext(fieldResolution);
377378

379+
// One-shot idempotent cleanup of legacy global-tier files (T304 / ADR-036).
380+
// Runs non-blocking on every invocation; errors are swallowed so that stale
381+
// files never prevent normal command execution.
382+
try {
383+
detectAndRemoveLegacyGlobalFiles();
384+
} catch {
385+
// Non-fatal: legacy cleanup must never break the CLI startup path.
386+
}
387+
378388
// Handle -V as alias for --version (citty handles --version but not -V)
379389
// Must come after format context is set so output respects --json/--human
380390
if (argv[0] === '-V') {

packages/core/src/internal.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -411,6 +411,8 @@ export type { CreateStickyParams, ListStickiesParams, StickyNote } from './stick
411411
// Store
412412
export { createBackup, listBackups, restoreFromBackup } from './store/backup.js';
413413
export { getBrainDb, getBrainNativeDb } from './store/brain-sqlite.js';
414+
export type { LegacyCleanupResult } from './store/cleanup-legacy.js';
415+
export { detectAndRemoveLegacyGlobalFiles } from './store/cleanup-legacy.js';
414416
export {
415417
gitCheckpoint,
416418
gitCheckpointStatus,
Lines changed: 174 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,174 @@
1+
/**
2+
* Unit tests for detectAndRemoveLegacyGlobalFiles().
3+
*
4+
* All filesystem interactions occur inside a fresh tmp directory per test.
5+
* The `cleoHomeOverride` parameter is used instead of mocking `getCleoHome()`
6+
* to keep tests hermetic and avoid global module state side-effects.
7+
*
8+
* @task T304
9+
* @epic T299
10+
*/
11+
12+
import { existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
13+
import { tmpdir } from 'node:os';
14+
import { join } from 'node:path';
15+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
16+
import { detectAndRemoveLegacyGlobalFiles } from '../cleanup-legacy.js';
17+
18+
// ---------------------------------------------------------------------------
19+
// Logger mock — prevents pino from trying to open real log files during tests
20+
// ---------------------------------------------------------------------------
21+
22+
vi.mock('../../logger.js', () => ({
23+
getLogger: () => ({
24+
info: vi.fn(),
25+
warn: vi.fn(),
26+
error: vi.fn(),
27+
debug: vi.fn(),
28+
}),
29+
}));
30+
31+
// ---------------------------------------------------------------------------
32+
// Helpers
33+
// ---------------------------------------------------------------------------
34+
35+
const LEGACY_NAMES = [
36+
'workspace.db',
37+
'workspace.db.bak-pre-rename',
38+
'workspace.db-shm',
39+
'workspace.db-shm-wal',
40+
'nexus-pre-cleo.db.bak',
41+
] as const;
42+
43+
const LIVE_NAMES = ['nexus.db', 'signaldock.db', 'machine-key', 'config.json'] as const;
44+
45+
function createTmpHome(): string {
46+
return mkdtempSync(join(tmpdir(), 'cleo-cleanup-test-'));
47+
}
48+
49+
function touchFile(dir: string, name: string): string {
50+
const fullPath = join(dir, name);
51+
writeFileSync(fullPath, 'placeholder');
52+
return fullPath;
53+
}
54+
55+
// ---------------------------------------------------------------------------
56+
// Tests
57+
// ---------------------------------------------------------------------------
58+
59+
describe('detectAndRemoveLegacyGlobalFiles', () => {
60+
let tmpHome: string;
61+
62+
beforeEach(() => {
63+
tmpHome = createTmpHome();
64+
});
65+
66+
afterEach(() => {
67+
rmSync(tmpHome, { recursive: true, force: true });
68+
});
69+
70+
it('deletes all legacy files when all are present', () => {
71+
// Create every legacy file
72+
for (const name of LEGACY_NAMES) {
73+
touchFile(tmpHome, name);
74+
}
75+
76+
const result = detectAndRemoveLegacyGlobalFiles(tmpHome);
77+
78+
expect(result.errors).toHaveLength(0);
79+
expect(result.removed).toHaveLength(LEGACY_NAMES.length);
80+
81+
for (const name of LEGACY_NAMES) {
82+
expect(result.removed).toContain(name);
83+
expect(existsSync(join(tmpHome, name))).toBe(false);
84+
}
85+
});
86+
87+
it('is a no-op when no legacy files are present', () => {
88+
// Empty directory — no legacy files
89+
const result = detectAndRemoveLegacyGlobalFiles(tmpHome);
90+
91+
expect(result.removed).toHaveLength(0);
92+
expect(result.errors).toHaveLength(0);
93+
});
94+
95+
it('deletes only the files that exist when a partial set is present', () => {
96+
const presentFiles = ['workspace.db', 'nexus-pre-cleo.db.bak'] as const;
97+
const absentFiles = LEGACY_NAMES.filter(
98+
(n) => !(presentFiles as readonly string[]).includes(n),
99+
);
100+
101+
for (const name of presentFiles) {
102+
touchFile(tmpHome, name);
103+
}
104+
105+
const result = detectAndRemoveLegacyGlobalFiles(tmpHome);
106+
107+
expect(result.errors).toHaveLength(0);
108+
expect(result.removed).toHaveLength(presentFiles.length);
109+
110+
for (const name of presentFiles) {
111+
expect(result.removed).toContain(name);
112+
expect(existsSync(join(tmpHome, name))).toBe(false);
113+
}
114+
115+
for (const name of absentFiles) {
116+
expect(result.removed).not.toContain(name);
117+
}
118+
});
119+
120+
it('does not touch live DB files or other non-legacy files', () => {
121+
// Create all legacy files AND all live files
122+
for (const name of LEGACY_NAMES) {
123+
touchFile(tmpHome, name);
124+
}
125+
for (const name of LIVE_NAMES) {
126+
touchFile(tmpHome, name);
127+
}
128+
// Create an extra unrelated file
129+
touchFile(tmpHome, 'some-other.txt');
130+
131+
detectAndRemoveLegacyGlobalFiles(tmpHome);
132+
133+
// Live files must still exist
134+
for (const name of LIVE_NAMES) {
135+
expect(existsSync(join(tmpHome, name))).toBe(true);
136+
}
137+
// Extra file must still exist
138+
expect(existsSync(join(tmpHome, 'some-other.txt'))).toBe(true);
139+
});
140+
141+
it('is idempotent: second call returns empty removed array', () => {
142+
for (const name of LEGACY_NAMES) {
143+
touchFile(tmpHome, name);
144+
}
145+
146+
const first = detectAndRemoveLegacyGlobalFiles(tmpHome);
147+
expect(first.removed).toHaveLength(LEGACY_NAMES.length);
148+
expect(first.errors).toHaveLength(0);
149+
150+
// Second call — files are already gone
151+
const second = detectAndRemoveLegacyGlobalFiles(tmpHome);
152+
expect(second.removed).toHaveLength(0);
153+
expect(second.errors).toHaveLength(0);
154+
});
155+
156+
it('captures errors for files that cannot be deleted and continues', () => {
157+
// Create a directory with the legacy name to simulate an undeletable entry
158+
// (fs.unlinkSync on a directory throws EISDIR)
159+
const problemName = 'workspace.db';
160+
mkdirSync(join(tmpHome, problemName));
161+
// Also create a normal deletable legacy file
162+
touchFile(tmpHome, 'nexus-pre-cleo.db.bak');
163+
164+
const result = detectAndRemoveLegacyGlobalFiles(tmpHome);
165+
166+
// The directory-disguised file should appear in errors
167+
const errorFiles = result.errors.map((e) => e.file);
168+
expect(errorFiles).toContain(problemName);
169+
170+
// The normal file should still be removed
171+
expect(result.removed).toContain('nexus-pre-cleo.db.bak');
172+
expect(existsSync(join(tmpHome, 'nexus-pre-cleo.db.bak'))).toBe(false);
173+
});
174+
});
Lines changed: 120 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,120 @@
1+
/**
2+
* Idempotent one-shot cleanup of legacy global-tier files.
3+
*
4+
* Detects and removes stale files left at the global CLEO home directory
5+
* (`getCleoHome()`) by pre-v2026.4.11 naming migrations. Safe to call on
6+
* repeat invocations — existence-checks guard every deletion.
7+
*
8+
* Files targeted (see ADR-036 §Decision/Global-Tier table):
9+
* - workspace.db (pre-nexus naming relic)
10+
* - workspace.db.bak-pre-rename (safety copy from a long-landed rename)
11+
* - workspace.db-shm (SQLite shared-memory sidecar of workspace.db)
12+
* - workspace.db-shm-wal (SQLite WAL sidecar of workspace.db)
13+
* - nexus-pre-cleo.db.bak (pre-CLEO backup of nexus — migration complete)
14+
*
15+
* Live files (nexus.db, signaldock.db, machine-key, config.json, etc.) are
16+
* NEVER touched. The function only acts on the explicit LEGACY_FILES list.
17+
*
18+
* @task T304
19+
* @epic T299
20+
* @adr ADR-036
21+
* @why v2026.4.10 left workspace.db and pre-cleo backups at global tier;
22+
* ADR-036 mandates their deletion to eliminate diagnostic confusion and
23+
* false impressions of active legacy databases.
24+
*/
25+
26+
import fs from 'node:fs';
27+
import path from 'node:path';
28+
import { getLogger } from '../logger.js';
29+
import { getCleoHome } from '../paths.js';
30+
31+
// ---------------------------------------------------------------------------
32+
// Constants
33+
// ---------------------------------------------------------------------------
34+
35+
/**
36+
* Exhaustive list of legacy filenames that MUST be removed from the global
37+
* CLEO home directory. No other files are touched.
38+
*
39+
* @task T304
40+
*/
41+
const LEGACY_FILES: readonly string[] = [
42+
'workspace.db',
43+
'workspace.db.bak-pre-rename',
44+
'workspace.db-shm',
45+
'workspace.db-shm-wal',
46+
'nexus-pre-cleo.db.bak',
47+
] as const;
48+
49+
// ---------------------------------------------------------------------------
50+
// Types
51+
// ---------------------------------------------------------------------------
52+
53+
/** Result returned by {@link detectAndRemoveLegacyGlobalFiles}. */
54+
export interface LegacyCleanupResult {
55+
/** Filenames (basename only) that were successfully deleted. */
56+
removed: string[];
57+
/** Files that could not be deleted, with error messages. */
58+
errors: Array<{ file: string; error: string }>;
59+
}
60+
61+
// ---------------------------------------------------------------------------
62+
// Implementation
63+
// ---------------------------------------------------------------------------
64+
65+
/**
66+
* Detect and remove legacy global-tier files from the CLEO home directory.
67+
*
68+
* Idempotent: safe to call when some or all files are already absent. Each
69+
* file is individually existence-checked before attempting deletion. Failures
70+
* on individual files are captured in `errors` rather than thrown, so the
71+
* caller receives a complete picture of what was (and was not) cleaned up.
72+
*
73+
* Logs each successful deletion at `info` level and each failure at `warn`
74+
* level via the shared pino logger.
75+
*
76+
* @param cleoHomeOverride - Optional directory override for tests. When
77+
* omitted the canonical `getCleoHome()` path is used. Prefer passing this
78+
* parameter in test harnesses rather than mutating `CLEO_HOME` environment
79+
* variables, as it avoids global state contamination between test runs.
80+
* @returns A {@link LegacyCleanupResult} describing what was removed and any
81+
* errors encountered.
82+
*
83+
* @example
84+
* ```typescript
85+
* // Production usage (runs against real global home)
86+
* const result = detectAndRemoveLegacyGlobalFiles();
87+
* if (result.removed.length > 0) {
88+
* // Legacy files were cleaned up
89+
* }
90+
*
91+
* // Test usage (runs against tmp directory)
92+
* const result = detectAndRemoveLegacyGlobalFiles('/tmp/fake-cleo-home');
93+
* ```
94+
*
95+
* @task T304
96+
* @epic T299
97+
*/
98+
export function detectAndRemoveLegacyGlobalFiles(cleoHomeOverride?: string): LegacyCleanupResult {
99+
const log = getLogger('cleanup-legacy');
100+
const cleoHome = cleoHomeOverride ?? getCleoHome();
101+
const removed: string[] = [];
102+
const errors: Array<{ file: string; error: string }> = [];
103+
104+
for (const fileName of LEGACY_FILES) {
105+
const fullPath = path.join(cleoHome, fileName);
106+
try {
107+
if (fs.existsSync(fullPath)) {
108+
fs.unlinkSync(fullPath);
109+
removed.push(fileName);
110+
log.info({ file: fullPath }, 'Removed legacy global file');
111+
}
112+
} catch (err) {
113+
const message = err instanceof Error ? err.message : String(err);
114+
errors.push({ file: fileName, error: message });
115+
log.warn({ file: fullPath, error: message }, 'Failed to remove legacy global file');
116+
}
117+
}
118+
119+
return { removed, errors };
120+
}

0 commit comments

Comments
 (0)