bet-cli 0.1.2 → 0.1.3

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/lib/cron.ts CHANGED
@@ -3,26 +3,101 @@ import path from "node:path";
3
3
  import { spawnSync } from "node:child_process";
4
4
  import { getConfigPath } from "./config.js";
5
5
 
6
- const CRON_MARKER = "# bet:update hourly";
6
+ const CRON_MARKER = "# bet:update";
7
7
  const WRAPPER_SCRIPT_NAME = "bet-update-cron.sh";
8
8
  const LOG_FILE_NAME = "cron-update.log";
9
9
 
10
- export type InstallHourlyUpdateCronOptions = {
10
+ const SCHEDULE_REGEX = /^(\d+)(m|h|d)$/i;
11
+
12
+ export type CronScheduleUnit = "m" | "h" | "d";
13
+
14
+ export type ParsedCronSchedule = {
15
+ value: number;
16
+ unit: CronScheduleUnit;
17
+ };
18
+
19
+ const MINUTES_MIN = 1;
20
+ const MINUTES_MAX = 59;
21
+ const HOURS_MIN = 1;
22
+ const HOURS_MAX = 24;
23
+ const DAYS_MIN = 1;
24
+ const DAYS_MAX = 31;
25
+
26
+ /**
27
+ * Parses a schedule string (e.g. "5m", "2h", "7d") and validates ranges.
28
+ * @throws Error if format is invalid or value is out of range (m: 1-59, h: 1-24, d: 1-31).
29
+ */
30
+ export function parseCronSchedule(schedule: string): ParsedCronSchedule {
31
+ const trimmed = schedule.trim().toLowerCase();
32
+ const match = trimmed.match(SCHEDULE_REGEX);
33
+ if (!match) {
34
+ throw new Error(
35
+ `Invalid cron schedule "${schedule}". Use Nm (1-59), Nh (1-24), or Nd (1-31). Examples: 5m, 1h, 7d. Use 0 or false to disable.`,
36
+ );
37
+ }
38
+ const value = parseInt(match[1], 10);
39
+ const unit = match[2] as CronScheduleUnit;
40
+
41
+ if (unit === "m" && (value < MINUTES_MIN || value > MINUTES_MAX)) {
42
+ throw new Error(
43
+ `Invalid minutes: ${value}. Use 1-59 for Nm (e.g. 5m, 30m).`,
44
+ );
45
+ }
46
+ if (unit === "h" && (value < HOURS_MIN || value > HOURS_MAX)) {
47
+ throw new Error(
48
+ `Invalid hours: ${value}. Use 1-24 for Nh (e.g. 1h, 12h). Use 24h for once per day.`,
49
+ );
50
+ }
51
+ if (unit === "d" && (value < DAYS_MIN || value > DAYS_MAX)) {
52
+ throw new Error(
53
+ `Invalid days: ${value}. Use 1-31 for Nd (e.g. 1d, 7d).`,
54
+ );
55
+ }
56
+
57
+ return { value, unit };
58
+ }
59
+
60
+ /**
61
+ * Converts a parsed schedule to a 5-field cron expression (minute hour dom month dow).
62
+ */
63
+ export function scheduleToCronExpression(parsed: ParsedCronSchedule): string {
64
+ const { value, unit } = parsed;
65
+ if (unit === "m") {
66
+ return `*/${value} * * * *`;
67
+ }
68
+ if (unit === "h") {
69
+ if (value === 24) {
70
+ return "0 0 * * *";
71
+ }
72
+ return `0 */${value} * * *`;
73
+ }
74
+ // unit === "d"
75
+ return `0 0 */${value} * *`;
76
+ }
77
+
78
+ export type InstallUpdateCronOptions = {
11
79
  /** Absolute path to the Node binary (e.g. process.execPath). */
12
80
  nodePath: string;
13
81
  /** Absolute path to the bet CLI entry script (e.g. dist/index.js). */
14
82
  entryScriptPath: string;
83
+ /** Schedule string: Nm (1-59), Nh (1-24), Nd (1-31). Example: "5m", "1h", "7d". */
84
+ schedule: string;
15
85
  };
16
86
 
17
87
  /**
18
88
  * Writes a wrapper script and installs/updates a per-user crontab entry
19
- * so that `bet update` runs every hour. Idempotent: re-running replaces
20
- * the existing bet cron block.
89
+ * so that `bet update` runs at the given schedule. Idempotent: re-running
90
+ * replaces the existing bet cron block (single cron only).
91
+ * @throws Error if schedule is invalid (see parseCronSchedule).
92
+ * @returns Paths to the wrapper script and log file for user reference.
21
93
  */
22
- export async function installHourlyUpdateCron(
23
- options: InstallHourlyUpdateCronOptions,
24
- ): Promise<void> {
25
- const { nodePath, entryScriptPath } = options;
94
+ export async function installUpdateCron(
95
+ options: InstallUpdateCronOptions,
96
+ ): Promise<{ wrapperPath: string; logPath: string }> {
97
+ const { nodePath, entryScriptPath, schedule } = options;
98
+ const parsed = parseCronSchedule(schedule);
99
+ const cronExpression = scheduleToCronExpression(parsed);
100
+
26
101
  const configDir = path.dirname(getConfigPath());
27
102
  await fs.mkdir(configDir, { recursive: true });
28
103
 
@@ -37,7 +112,7 @@ export async function installHourlyUpdateCron(
37
112
  await fs.writeFile(wrapperPath, scriptBody + "\n", "utf8");
38
113
  await fs.chmod(wrapperPath, 0o755);
39
114
 
40
- const scheduleLine = `0 * * * * ${wrapperPath}`;
115
+ const scheduleLine = `${cronExpression} ${wrapperPath}`;
41
116
  const betBlock = [CRON_MARKER, scheduleLine].join("\n");
42
117
 
43
118
  const crontabL = spawnSync("crontab", ["-l"], {
@@ -93,4 +168,72 @@ export async function installHourlyUpdateCron(
93
168
  const err = crontabWrite.stderr || crontabWrite.stdout || "unknown";
94
169
  throw new Error(`crontab install failed: ${err}`);
95
170
  }
171
+
172
+ return { wrapperPath, logPath };
173
+ }
174
+
175
+ /**
176
+ * Removes the bet update cron entry from the user's crontab (if present).
177
+ */
178
+ export async function uninstallUpdateCron(): Promise<void> {
179
+ const crontabL = spawnSync("crontab", ["-l"], {
180
+ encoding: "utf8",
181
+ stdio: ["ignore", "pipe", "pipe"],
182
+ });
183
+
184
+ let existingCrontab = "";
185
+ if (crontabL.status === 0 && crontabL.stdout) {
186
+ existingCrontab = crontabL.stdout;
187
+ }
188
+ if (crontabL.status !== 0 && crontabL.stderr && !crontabL.stderr.includes("no crontab")) {
189
+ throw new Error(`crontab -l failed: ${crontabL.stderr}`);
190
+ }
191
+
192
+ const lines = existingCrontab.split("\n");
193
+ const out: string[] = [];
194
+ let skipNext = false;
195
+
196
+ for (const line of lines) {
197
+ if (skipNext) {
198
+ skipNext = false;
199
+ continue;
200
+ }
201
+ if (line === CRON_MARKER) {
202
+ skipNext = true;
203
+ continue;
204
+ }
205
+ out.push(line);
206
+ }
207
+
208
+ const newCrontab = out.length > 0 ? out.join("\n").replace(/\n*$/, "") + "\n" : "";
209
+
210
+ if (newCrontab === "") {
211
+ spawnSync("crontab", ["-r"], { stdio: "pipe" });
212
+ return;
213
+ }
214
+
215
+ const crontabWrite = spawnSync("crontab", ["-"], {
216
+ encoding: "utf8",
217
+ input: newCrontab,
218
+ stdio: ["pipe", "pipe", "pipe"],
219
+ });
220
+
221
+ if (crontabWrite.status !== 0) {
222
+ const err = crontabWrite.stderr || crontabWrite.stdout || "unknown";
223
+ throw new Error(`crontab update failed: ${err}`);
224
+ }
225
+ }
226
+
227
+ /**
228
+ * Returns a human-readable label for a parsed schedule (e.g. "every 5 minutes").
229
+ */
230
+ export function formatScheduleLabel(parsed: ParsedCronSchedule): string {
231
+ const { value, unit } = parsed;
232
+ if (unit === "m") {
233
+ return value === 1 ? "every minute" : `every ${value} minutes`;
234
+ }
235
+ if (unit === "h") {
236
+ return value === 1 ? "every hour" : `every ${value} hours`;
237
+ }
238
+ return value === 1 ? "every day" : `every ${value} days`;
96
239
  }
package/src/lib/ignore.ts CHANGED
@@ -1,3 +1,5 @@
1
+ import { isSubpath } from "../utils/paths.js";
2
+
1
3
  export const DEFAULT_IGNORES = [
2
4
  "**/node_modules/**",
3
5
  "**/.git/**",
@@ -9,3 +11,14 @@ export const DEFAULT_IGNORES = [
9
11
  "**/.venv/**",
10
12
  "**/venv/**",
11
13
  ];
14
+
15
+ export function getEffectiveIgnores(config: { ignores?: string[] }): string[] {
16
+ return config.ignores !== undefined ? config.ignores : DEFAULT_IGNORES;
17
+ }
18
+
19
+ export function isPathIgnored(projectPath: string, ignoredPaths: string[]): boolean {
20
+ if (ignoredPaths.length === 0) return false;
21
+ return ignoredPaths.some(
22
+ (ip) => projectPath === ip || isSubpath(projectPath, ip),
23
+ );
24
+ }
@@ -0,0 +1,26 @@
1
+ import path from "node:path";
2
+ import os from "node:os";
3
+
4
+ const LOG_FILE_NAME = "bet.log";
5
+
6
+ /**
7
+ * Returns the directory where bet writes its log file.
8
+ * - macOS: ~/Library/Logs/bet
9
+ * - Linux / others: $XDG_STATE_HOME/bet or ~/.local/state/bet
10
+ */
11
+ export function getLogDir(): string {
12
+ const homedir = os.homedir();
13
+ if (process.platform === "darwin") {
14
+ return path.join(homedir, "Library", "Logs", "bet");
15
+ }
16
+ const stateHome =
17
+ process.env.XDG_STATE_HOME ?? path.join(homedir, ".local", "state");
18
+ return path.join(stateHome, "bet");
19
+ }
20
+
21
+ /**
22
+ * Returns the full path to the log file (e.g. getLogDir()/bet.log).
23
+ */
24
+ export function getLogFilePath(): string {
25
+ return path.join(getLogDir(), LOG_FILE_NAME);
26
+ }
@@ -0,0 +1,92 @@
1
+ import fs from "node:fs";
2
+ import { getLogDir, getLogFilePath } from "./log-dir.js";
3
+
4
+ const LEVELS = ["debug", "info", "warn", "error"] as const;
5
+ type LogLevel = (typeof LEVELS)[number];
6
+
7
+ const LEVEL_ORDER: Record<LogLevel, number> = {
8
+ debug: 0,
9
+ info: 1,
10
+ warn: 2,
11
+ error: 3,
12
+ };
13
+
14
+ function parseLogLevel(): LogLevel {
15
+ const raw = process.env.BET_LOG_LEVEL?.trim().toLowerCase();
16
+ if (raw && LEVELS.includes(raw as LogLevel)) {
17
+ return raw as LogLevel;
18
+ }
19
+ return "info";
20
+ }
21
+
22
+ let minLevelOrder: number = LEVEL_ORDER[parseLogLevel()];
23
+ let fileStream: fs.WriteStream | null = null;
24
+
25
+ function ensureStream(): fs.WriteStream {
26
+ if (fileStream != null) {
27
+ return fileStream;
28
+ }
29
+ const logDir = getLogDir();
30
+ fs.mkdirSync(logDir, { recursive: true });
31
+ const logPath = getLogFilePath();
32
+ fileStream = fs.createWriteStream(logPath, { flags: "a" });
33
+ return fileStream;
34
+ }
35
+
36
+ function formatLine(level: LogLevel, message: string, stack?: string): string {
37
+ const ts = new Date().toISOString();
38
+ let line = `${ts} ${level.toUpperCase()} ${message}`;
39
+ if (stack) {
40
+ line += "\n" + stack;
41
+ }
42
+ return line + "\n";
43
+ }
44
+
45
+ function shouldLog(level: LogLevel): boolean {
46
+ return LEVEL_ORDER[level] >= minLevelOrder;
47
+ }
48
+
49
+ function write(level: LogLevel, message: string, stack?: string): void {
50
+ if (!shouldLog(level)) {
51
+ return;
52
+ }
53
+ const line = formatLine(level, message, stack);
54
+ try {
55
+ ensureStream().write(line);
56
+ } catch {
57
+ // Avoid throwing from logger; best-effort only
58
+ }
59
+ if (process.stderr.isTTY) {
60
+ process.stderr.write(line);
61
+ }
62
+ }
63
+
64
+ export const log = {
65
+ debug(msg: string, ...args: unknown[]): void {
66
+ const message = args.length > 0 ? `${msg} ${args.map(String).join(" ")}` : msg;
67
+ write("debug", message);
68
+ },
69
+
70
+ info(msg: string, ...args: unknown[]): void {
71
+ const message = args.length > 0 ? `${msg} ${args.map(String).join(" ")}` : msg;
72
+ write("info", message);
73
+ },
74
+
75
+ warn(msg: string, ...args: unknown[]): void {
76
+ const message = args.length > 0 ? `${msg} ${args.map(String).join(" ")}` : msg;
77
+ write("warn", message);
78
+ },
79
+
80
+ error(errOrMsg: Error | string, ...args: unknown[]): void {
81
+ if (errOrMsg instanceof Error) {
82
+ const message = errOrMsg.message;
83
+ const stack = errOrMsg.stack;
84
+ write("error", message, stack);
85
+ return;
86
+ }
87
+ const message = args.length > 0
88
+ ? `${String(errOrMsg)} ${args.map(String).join(" ")}`
89
+ : String(errOrMsg);
90
+ write("error", message);
91
+ },
92
+ };
@@ -1,10 +1,9 @@
1
1
  import fg from 'fast-glob';
2
- import { DEFAULT_IGNORES } from './ignore.js';
3
2
  import { ProjectAutoMetadata } from './types.js';
4
3
  import { getDirtyStatus, getFirstCommitDate } from './git.js';
5
4
  import { readReadmeDescription } from './readme.js';
6
5
 
7
- export async function computeMetadata(projectPath: string, hasGit: boolean): Promise<ProjectAutoMetadata> {
6
+ export async function computeMetadata(projectPath: string, hasGit: boolean, ignores: string[]): Promise<ProjectAutoMetadata> {
8
7
  const nowIso = new Date().toISOString();
9
8
 
10
9
  const entries = await fg('**/*', {
@@ -12,7 +11,7 @@ export async function computeMetadata(projectPath: string, hasGit: boolean): Pro
12
11
  dot: true,
13
12
  onlyFiles: true,
14
13
  followSymbolicLinks: false,
15
- ignore: DEFAULT_IGNORES,
14
+ ignore: ignores,
16
15
  stats: true,
17
16
  });
18
17
 
package/src/lib/scan.ts CHANGED
@@ -1,16 +1,10 @@
1
1
  import path from "node:path";
2
2
  import fg from "fast-glob";
3
3
  import { ProjectCandidate } from "./types.js";
4
- import { DEFAULT_IGNORES } from "./ignore.js";
5
4
  import { isSubpath } from "../utils/paths.js";
6
5
  import { isInsideGitRepo } from "./git.js";
7
6
 
8
- const README_PATTERNS = [
9
- "**/README.md",
10
- "**/readme.md",
11
- "**/Readme.md",
12
- "**/README.MD",
13
- ];
7
+ const README_PATTERNS = ["**/README.md", "**/readme.txt"];
14
8
 
15
9
  function resolveProjectRoot(matchPath: string): string {
16
10
  const container = path.dirname(matchPath);
@@ -49,12 +43,12 @@ function addCandidate(
49
43
  map.set(projectPath, next);
50
44
  }
51
45
 
52
- export async function scanRoots(roots: string[]): Promise<ProjectCandidate[]> {
46
+ export async function scanRoots(roots: string[], ignores: string[]): Promise<ProjectCandidate[]> {
53
47
  const candidates = new Map<string, ProjectCandidate>();
54
48
 
55
49
  for (const root of roots) {
56
50
  // Exclude .git/** from ignores when scanning for .git directories
57
- const gitIgnores = DEFAULT_IGNORES.filter(
51
+ const gitIgnores = ignores.filter(
58
52
  (pattern) => pattern !== "**/.git/**",
59
53
  );
60
54
  const gitMatches = await fg("**/.git", {
@@ -78,7 +72,8 @@ export async function scanRoots(roots: string[]): Promise<ProjectCandidate[]> {
78
72
  dot: true,
79
73
  onlyFiles: true,
80
74
  followSymbolicLinks: false,
81
- ignore: DEFAULT_IGNORES,
75
+ ignore: ignores,
76
+ caseSensitiveMatch: false,
82
77
  });
83
78
 
84
79
  for (const match of readmeMatches) {
package/src/lib/types.ts CHANGED
@@ -33,6 +33,9 @@ export type RootConfig = {
33
33
  export type AppConfig = {
34
34
  version: number;
35
35
  roots: RootConfig[];
36
+ ignores?: string[];
37
+ ignoredPaths?: string[];
38
+ slugParentFolders?: string[];
36
39
  };
37
40
 
38
41
  export type ProjectsConfig = {
@@ -42,6 +45,9 @@ export type ProjectsConfig = {
42
45
  export type Config = {
43
46
  version: number;
44
47
  roots: RootConfig[];
48
+ ignores?: string[];
49
+ ignoredPaths?: string[];
50
+ slugParentFolders?: string[];
45
51
  projects: Record<string, Project>;
46
52
  };
47
53
 
@@ -1,7 +1,8 @@
1
1
  import { describe, it, expect, vi, beforeEach } from "vitest";
2
2
  import path from "node:path";
3
3
  import fs from "node:fs/promises";
4
- import { readConfig, resolveRoots, getConfigPath, getProjectsPath } from "../src/lib/config.js";
4
+ import { readConfig, resolveRoots, getConfigPath, getProjectsPath, writeConfig } from "../src/lib/config.js";
5
+ import { getEffectiveIgnores, DEFAULT_IGNORES } from "../src/lib/ignore.js";
5
6
  import type { RootConfig } from "../src/lib/types.js";
6
7
 
7
8
  vi.mock("node:fs/promises", () => ({
@@ -88,6 +89,146 @@ describe("config", () => {
88
89
  expect(project.rootName).toBe("my-code");
89
90
  expect((project as { group?: string }).group).toBeUndefined();
90
91
  });
92
+
93
+ it("returns ignores when set in config", async () => {
94
+ const configPath = getConfigPath();
95
+ const projectsPath = getProjectsPath();
96
+ vi.mocked(fs.readFile).mockImplementation((p: string) => {
97
+ if (p === configPath) {
98
+ return Promise.resolve(
99
+ JSON.stringify({
100
+ version: 1,
101
+ roots: [],
102
+ ignores: ["**/foo/**", "**/bar"],
103
+ }),
104
+ );
105
+ }
106
+ if (p === projectsPath) {
107
+ return Promise.resolve(JSON.stringify({ projects: {} }));
108
+ }
109
+ return Promise.reject(new Error("ENOENT"));
110
+ });
111
+
112
+ const config = await readConfig();
113
+
114
+ expect(config.ignores).toEqual(["**/foo/**", "**/bar"]);
115
+ });
116
+
117
+ it("leaves ignores undefined when not in config", async () => {
118
+ const configPath = getConfigPath();
119
+ const projectsPath = getProjectsPath();
120
+ vi.mocked(fs.readFile).mockImplementation((p: string) => {
121
+ if (p === configPath) {
122
+ return Promise.resolve(JSON.stringify({ version: 1, roots: [] }));
123
+ }
124
+ if (p === projectsPath) {
125
+ return Promise.resolve(JSON.stringify({ projects: {} }));
126
+ }
127
+ return Promise.reject(new Error("ENOENT"));
128
+ });
129
+
130
+ const config = await readConfig();
131
+
132
+ expect(config.ignores).toBeUndefined();
133
+ });
134
+
135
+ it("returns slugParentFolders when set in config", async () => {
136
+ const configPath = getConfigPath();
137
+ const projectsPath = getProjectsPath();
138
+ vi.mocked(fs.readFile).mockImplementation((p: string) => {
139
+ if (p === configPath) {
140
+ return Promise.resolve(
141
+ JSON.stringify({
142
+ version: 1,
143
+ roots: [],
144
+ slugParentFolders: ["src", "app", "packages"],
145
+ }),
146
+ );
147
+ }
148
+ if (p === projectsPath) {
149
+ return Promise.resolve(JSON.stringify({ projects: {} }));
150
+ }
151
+ return Promise.reject(new Error("ENOENT"));
152
+ });
153
+
154
+ const config = await readConfig();
155
+
156
+ expect(config.slugParentFolders).toEqual(["src", "app", "packages"]);
157
+ });
158
+
159
+ it("leaves slugParentFolders undefined when not in config", async () => {
160
+ const configPath = getConfigPath();
161
+ const projectsPath = getProjectsPath();
162
+ vi.mocked(fs.readFile).mockImplementation((p: string) => {
163
+ if (p === configPath) {
164
+ return Promise.resolve(JSON.stringify({ version: 1, roots: [] }));
165
+ }
166
+ if (p === projectsPath) {
167
+ return Promise.resolve(JSON.stringify({ projects: {} }));
168
+ }
169
+ return Promise.reject(new Error("ENOENT"));
170
+ });
171
+
172
+ const config = await readConfig();
173
+
174
+ expect(config.slugParentFolders).toBeUndefined();
175
+ });
176
+
177
+ it("reads and normalizes ignoredPaths from config", async () => {
178
+ const configPath = getConfigPath();
179
+ const projectsPath = getProjectsPath();
180
+ const resolvedPath = path.resolve("/code/some-project");
181
+ vi.mocked(fs.readFile).mockImplementation((p: string) => {
182
+ if (p === configPath) {
183
+ return Promise.resolve(
184
+ JSON.stringify({
185
+ version: 1,
186
+ roots: [],
187
+ ignoredPaths: ["/code/some-project", "~/other"],
188
+ }),
189
+ );
190
+ }
191
+ if (p === projectsPath) {
192
+ return Promise.resolve(JSON.stringify({ projects: {} }));
193
+ }
194
+ return Promise.reject(new Error("ENOENT"));
195
+ });
196
+
197
+ const config = await readConfig();
198
+
199
+ expect(config.ignoredPaths).toBeDefined();
200
+ expect(config.ignoredPaths).toHaveLength(2);
201
+ expect(config.ignoredPaths![0]).toBe(resolvedPath);
202
+ expect(config.ignoredPaths![1]).toBe(path.resolve(process.env.HOME || "", "other"));
203
+ });
204
+
205
+ it("leaves ignoredPaths undefined when not in config", async () => {
206
+ const configPath = getConfigPath();
207
+ const projectsPath = getProjectsPath();
208
+ vi.mocked(fs.readFile).mockImplementation((p: string) => {
209
+ if (p === configPath) {
210
+ return Promise.resolve(JSON.stringify({ version: 1, roots: [] }));
211
+ }
212
+ if (p === projectsPath) {
213
+ return Promise.resolve(JSON.stringify({ projects: {} }));
214
+ }
215
+ return Promise.reject(new Error("ENOENT"));
216
+ });
217
+
218
+ const config = await readConfig();
219
+
220
+ expect(config.ignoredPaths).toBeUndefined();
221
+ });
222
+ });
223
+
224
+ describe("getEffectiveIgnores", () => {
225
+ it("returns DEFAULT_IGNORES when config has no ignores", () => {
226
+ expect(getEffectiveIgnores({})).toEqual(DEFAULT_IGNORES);
227
+ });
228
+
229
+ it("returns only user ignores when config.ignores is set (no merge)", () => {
230
+ expect(getEffectiveIgnores({ ignores: ["x"] })).toEqual(["x"]);
231
+ });
91
232
  });
92
233
 
93
234
  describe("resolveRoots", () => {
@@ -103,4 +244,37 @@ describe("config", () => {
103
244
  expect(result[1]).toEqual({ path: path.resolve("/c"), name: "c" });
104
245
  });
105
246
  });
247
+
248
+ describe("writeConfig", () => {
249
+ it("writes ignoredPaths to app config when present", async () => {
250
+ const configPath = getConfigPath();
251
+ const projectsPath = getProjectsPath();
252
+ vi.mocked(fs.readFile).mockImplementation((p: string) => {
253
+ if (p === configPath) {
254
+ return Promise.resolve(JSON.stringify({ version: 1, roots: [] }));
255
+ }
256
+ if (p === projectsPath) {
257
+ return Promise.resolve(JSON.stringify({ projects: {} }));
258
+ }
259
+ return Promise.reject(new Error("ENOENT"));
260
+ });
261
+ const config = await readConfig();
262
+ const configWithIgnores = {
263
+ ...config,
264
+ ignoredPaths: [path.resolve("/code/foo"), path.resolve("/code/bar")],
265
+ };
266
+
267
+ await writeConfig(configWithIgnores);
268
+
269
+ expect(fs.writeFile).toHaveBeenCalledWith(
270
+ configPath,
271
+ expect.stringContaining('"ignoredPaths"'),
272
+ "utf8",
273
+ );
274
+ const written = JSON.parse(
275
+ String(vi.mocked(fs.writeFile).mock.calls.find((c) => c[0] === configPath)![1]),
276
+ );
277
+ expect(written.ignoredPaths).toEqual([path.resolve("/code/foo"), path.resolve("/code/bar")]);
278
+ });
279
+ });
106
280
  });