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/README.md CHANGED
@@ -1,3 +1,13 @@
1
+ ```
2
+ ██
3
+ ██
4
+ ████████████
5
+ ██
6
+ ████████
7
+ ╲╲
8
+ ╲╲
9
+ ```
10
+
1
11
  # bet
2
12
 
3
13
  Keep your house in order. Explore and jump between local projects.
@@ -91,7 +101,7 @@ Then Tab-complete the slug argument after `bet go `, `bet path `, or `bet info `
91
101
  - **`bet update`**: Scan configured roots and rebuild the project index.
92
102
  - **First-time setup**: `bet update --roots "$HOME/code,$HOME/work"`
93
103
  - If you pass `--roots` when you already have roots in config, you will be warned and must confirm (or use `--force` when not in a TTY).
94
- - **Optional**: `--cron` installs an hourly `crontab` entry that runs `bet update` and logs output to your bet config directory
104
+ - **Optional**: `--cron [frequency]` install a `crontab` entry that runs `bet update` on a schedule. Use Nm (1–59), Nh (1–24), or Nd (1–31), e.g. `--cron 5m`, `--cron 1h` (default if omitted), `--cron 2d`. Use `--cron 0` or `--cron false` to remove the cron. Cron stdout/stderr are appended to `~/.config/bet/cron-update.log`; structured logs go to the main log file (see [Logging](#logging)).
95
105
  - **`bet list`**: List indexed projects (interactive by default).
96
106
  - **`--plain`**: non-interactive output
97
107
  - **`--json`**: machine-readable output
@@ -111,10 +121,20 @@ bet stores its data in:
111
121
 
112
122
  - **Config dir**: `~/.config/bet/` (or `$XDG_CONFIG_HOME/bet/`)
113
123
  - **Roots**: `config.json` — each root is `{ "path": "/absolute/path", "name": "display-name" }`. The name defaults to the top folder name and is used when listing/grouping projects.
124
+ - **slugParentFolders** (optional): In `config.json`, an array of folder names. When a discovered project path ends in one of these (e.g. `src` or `app`), the project slug is taken from the parent directory name instead. Default in code is `["src", "app"]` when the key is not set.
114
125
  - **Project index**: `projects.json`
115
126
 
116
127
  These are plain JSON files—easy to inspect, back up, or edit.
117
128
 
129
+ ### Logging
130
+
131
+ bet writes a structured log file for debugging, especially when `bet update` runs from cron:
132
+
133
+ - **macOS**: `~/Library/Logs/bet/bet.log`
134
+ - **Linux**: `~/.local/state/bet/bet.log` (or `$XDG_STATE_HOME/bet/bet.log`)
135
+
136
+ Each line is timestamped and includes a level (`DEBUG`, `INFO`, `WARN`, `ERROR`). Set `BET_LOG_LEVEL=debug` for verbose output when troubleshooting (e.g. in your cron environment). When run from cron, stdout/stderr are also captured in `~/.config/bet/cron-update.log`; the main log file is the structured, level-based log.
137
+
118
138
  ### Advanced filtering with `--json`
119
139
 
120
140
  You can combine `bet list --json` with [jq](https://stedolan.github.io/jq/) for powerful, scriptable project filtering. Here are some practical examples:
@@ -9,6 +9,7 @@ const SUBCOMMANDS = [
9
9
  "path",
10
10
  "shell",
11
11
  "completion",
12
+ "ignore",
12
13
  ];
13
14
  function zshScript() {
14
15
  const slugCommandsPattern = SLUG_COMMANDS.join("|");
@@ -24,6 +25,7 @@ _bet() {
24
25
  'path:Print project path'
25
26
  'shell:Print shell integration'
26
27
  'completion:Print shell completion script'
28
+ 'ignore:Manage ignored project paths'
27
29
  )
28
30
 
29
31
  if (( CURRENT == 2 )); then
@@ -0,0 +1,78 @@
1
+ import { readConfig, writeConfig } from "../lib/config.js";
2
+ import { normalizeAbsolute, isSubpath } from "../utils/paths.js";
3
+ function isPathUnderRoot(filePath, rootPath) {
4
+ return filePath === rootPath || isSubpath(filePath, rootPath);
5
+ }
6
+ function isPathUnderAnyRoot(filePath, rootPaths) {
7
+ return rootPaths.some((rootPath) => isPathUnderRoot(filePath, rootPath));
8
+ }
9
+ export function registerIgnore(program) {
10
+ const ignoreCmd = program
11
+ .command("ignore")
12
+ .description("Manage ignored project paths (excluded from index)");
13
+ ignoreCmd
14
+ .command("add [filepath]")
15
+ .description("Add a path to the ignore list (must be under a configured root)")
16
+ .option("--this", "Ignore the current folder")
17
+ .action(async (filepath, options) => {
18
+ const pathToAdd = options.this ? process.cwd() : filepath;
19
+ if (pathToAdd === undefined || pathToAdd === "") {
20
+ process.stderr.write("Error: Provide a path or use --this to ignore the current folder.\n");
21
+ process.exitCode = 1;
22
+ return;
23
+ }
24
+ const normalized = normalizeAbsolute(pathToAdd);
25
+ const config = await readConfig();
26
+ if (!config.roots.length) {
27
+ process.stderr.write("Error: No roots configured. Add roots first (e.g. bet update --roots /path/to/code).\n");
28
+ process.exitCode = 1;
29
+ return;
30
+ }
31
+ const rootPaths = config.roots.map((r) => r.path);
32
+ if (!isPathUnderAnyRoot(normalized, rootPaths)) {
33
+ process.stderr.write(`Error: Path must be under a configured root.\n Path: ${normalized}\n Roots: ${rootPaths.join(", ")}\n`);
34
+ process.exitCode = 1;
35
+ return;
36
+ }
37
+ const ignoredPaths = config.ignoredPaths ?? [];
38
+ if (ignoredPaths.includes(normalized)) {
39
+ process.stdout.write(`Already ignored: ${normalized}\n`);
40
+ return;
41
+ }
42
+ const nextIgnoredPaths = [...ignoredPaths, normalized];
43
+ await writeConfig({
44
+ ...config,
45
+ ignoredPaths: nextIgnoredPaths,
46
+ });
47
+ process.stdout.write(`Ignored: ${normalized}\n`);
48
+ });
49
+ ignoreCmd
50
+ .command("rm <filepath>")
51
+ .description("Remove a path from the ignore list")
52
+ .action(async (filepath) => {
53
+ const normalized = normalizeAbsolute(filepath);
54
+ const config = await readConfig();
55
+ const ignoredPaths = config.ignoredPaths ?? [];
56
+ const index = ignoredPaths.indexOf(normalized);
57
+ if (index === -1) {
58
+ process.stdout.write(`Not in ignore list: ${normalized}\n`);
59
+ return;
60
+ }
61
+ const nextIgnoredPaths = ignoredPaths.filter((_, i) => i !== index);
62
+ await writeConfig({
63
+ ...config,
64
+ ignoredPaths: nextIgnoredPaths.length > 0 ? nextIgnoredPaths : undefined,
65
+ });
66
+ process.stdout.write(`Removed from ignore list: ${normalized}\n`);
67
+ });
68
+ ignoreCmd
69
+ .command("list")
70
+ .description("List ignored paths")
71
+ .action(async () => {
72
+ const config = await readConfig();
73
+ const ignoredPaths = config.ignoredPaths ?? [];
74
+ for (const p of ignoredPaths) {
75
+ process.stdout.write(`${p}\n`);
76
+ }
77
+ });
78
+ }
@@ -2,10 +2,12 @@ import path from "node:path";
2
2
  import readline from "node:readline";
3
3
  import { readConfig, resolveRoots, writeConfig } from "../lib/config.js";
4
4
  import { normalizeAbsolute } from "../utils/paths.js";
5
- import { installHourlyUpdateCron } from "../lib/cron.js";
5
+ import { installUpdateCron, uninstallUpdateCron, parseCronSchedule, formatScheduleLabel } from "../lib/cron.js";
6
6
  import { scanRoots } from "../lib/scan.js";
7
7
  import { computeMetadata } from "../lib/metadata.js";
8
+ import { getEffectiveIgnores, isPathIgnored } from "../lib/ignore.js";
8
9
  import { isInsideGitRepo } from "../lib/git.js";
10
+ import { log } from "../lib/logger.js";
9
11
  function parseRoots(value) {
10
12
  if (!value)
11
13
  return undefined;
@@ -24,9 +26,11 @@ export function willOverrideRoots(providedRootConfigs, configRoots) {
24
26
  return !!(providedRootConfigs !== undefined &&
25
27
  configRoots.length > 0);
26
28
  }
27
- function projectSlug(pathName) {
29
+ const DEFAULT_SLUG_PARENT_FOLDERS = ["src", "app"];
30
+ export { DEFAULT_SLUG_PARENT_FOLDERS };
31
+ export function projectSlug(pathName, slugParentFolders) {
28
32
  const folderName = path.basename(pathName);
29
- if (folderName === "src" || folderName === "app") {
33
+ if (slugParentFolders.includes(folderName)) {
30
34
  return path.basename(path.dirname(pathName));
31
35
  }
32
36
  return folderName;
@@ -52,89 +56,144 @@ export function registerUpdate(program) {
52
56
  .description("Scan roots and update the project index")
53
57
  .option("--roots <paths>", "Comma-separated list of roots to scan")
54
58
  .option("--force", "Allow overriding configured roots when not in TTY")
55
- .option("--cron", "Install an hourly cron job to run bet update")
59
+ .option("--cron [frequency]", "Run update on a schedule: Nm/Nh/Nd e.g. 5m, 1h, 2d (default 1h), or 0/false to disable")
56
60
  .action(async (options) => {
57
- const config = await readConfig();
58
- const providedPaths = parseRoots(options.roots);
59
- const providedRootConfigs = providedPaths
60
- ? pathsToRootConfigs(providedPaths)
61
- : undefined;
62
- const configRoots = config.roots.length > 0 ? config.roots : undefined;
63
- const rootsToUse = providedRootConfigs ?? configRoots;
64
- if (!rootsToUse || rootsToUse.length === 0) {
65
- process.stderr.write("Error: No roots specified. Please provide roots using --roots option.\n" +
66
- "Example: bet update --roots /path/to/your/code\n");
67
- process.exitCode = 1;
68
- return;
69
- }
70
- const willOverride = willOverrideRoots(providedRootConfigs, config.roots);
71
- if (willOverride) {
72
- process.stderr.write("Warning: --roots will override your configured roots.\n" +
73
- " Configured: " +
74
- configRoots.map((r) => r.path).join(", ") +
75
- "\n Provided: " +
76
- providedRootConfigs.map((r) => r.path).join(", ") +
77
- "\n");
78
- if (!process.stdin.isTTY) {
79
- if (!options.force) {
80
- process.stderr.write("Error: Refusing to override without confirmation. Run interactively or use --force.\n");
81
- process.exitCode = 1;
82
- return;
83
- }
61
+ try {
62
+ const config = await readConfig();
63
+ const providedPaths = parseRoots(options.roots);
64
+ const providedRootConfigs = providedPaths
65
+ ? pathsToRootConfigs(providedPaths)
66
+ : undefined;
67
+ const configRoots = config.roots.length > 0 ? config.roots : undefined;
68
+ const rootsToUse = providedRootConfigs ?? configRoots;
69
+ if (!rootsToUse || rootsToUse.length === 0) {
70
+ log.error("update failed: no roots specified");
71
+ process.stderr.write("Error: No roots specified. Please provide roots using --roots option.\n" +
72
+ "Example: bet update --roots /path/to/your/code\n");
73
+ process.exitCode = 1;
74
+ return;
84
75
  }
85
- else {
86
- const confirmed = await promptYesNo("Continue?", true);
87
- if (!confirmed) {
88
- process.stderr.write("Aborted.\n");
89
- return;
76
+ const willOverride = willOverrideRoots(providedRootConfigs, config.roots);
77
+ if (willOverride) {
78
+ log.warn("--roots overrides configured roots", "configured:", configRoots.map((r) => r.path).join(", "), "provided:", providedRootConfigs.map((r) => r.path).join(", "));
79
+ process.stderr.write("Warning: --roots will override your configured roots.\n" +
80
+ " Configured: " +
81
+ configRoots.map((r) => r.path).join(", ") +
82
+ "\n Provided: " +
83
+ providedRootConfigs.map((r) => r.path).join(", ") +
84
+ "\n");
85
+ if (!process.stdin.isTTY) {
86
+ if (!options.force) {
87
+ log.error("update failed: refusing to override roots without confirmation (use --force when not in TTY)");
88
+ process.stderr.write("Error: Refusing to override without confirmation. Run interactively or use --force.\n");
89
+ process.exitCode = 1;
90
+ return;
91
+ }
92
+ }
93
+ else {
94
+ const confirmed = await promptYesNo("Continue?", true);
95
+ if (!confirmed) {
96
+ log.info("update aborted by user");
97
+ process.stderr.write("Aborted.\n");
98
+ return;
99
+ }
90
100
  }
91
101
  }
92
- }
93
- const rootsResolved = resolveRoots(rootsToUse);
94
- const rootPaths = rootsResolved.map((r) => r.path);
95
- const candidates = await scanRoots(rootPaths);
96
- const projects = {};
97
- for (const candidate of candidates) {
98
- const hasGit = await isInsideGitRepo(candidate.path);
99
- const auto = await computeMetadata(candidate.path, hasGit);
100
- const slug = projectSlug(candidate.path);
101
- const existing = config.projects[candidate.path];
102
- const rootConfig = rootsResolved.find((r) => r.path === candidate.root);
103
- const rootName = rootConfig?.name ?? path.basename(candidate.root);
104
- const project = {
105
- id: candidate.path,
106
- slug,
107
- name: slug,
108
- path: candidate.path,
109
- root: candidate.root,
110
- rootName,
111
- hasGit,
112
- hasReadme: candidate.hasReadme,
113
- auto,
114
- user: existing?.user,
102
+ const rootsResolved = resolveRoots(rootsToUse);
103
+ const rootPaths = rootsResolved.map((r) => r.path);
104
+ log.info("update started", "roots=" + rootPaths.join(", "));
105
+ log.debug("scanning roots", rootPaths.length, "root(s)");
106
+ const ignores = getEffectiveIgnores(config);
107
+ const candidates = await scanRoots(rootPaths, ignores);
108
+ const ignoredPaths = config.ignoredPaths ?? [];
109
+ const filteredCandidates = candidates.filter((c) => !isPathIgnored(c.path, ignoredPaths));
110
+ log.debug("found", filteredCandidates.length, "candidate(s) after ignoring paths");
111
+ const projects = {};
112
+ for (const candidate of filteredCandidates) {
113
+ const hasGit = await isInsideGitRepo(candidate.path);
114
+ const auto = await computeMetadata(candidate.path, hasGit, ignores);
115
+ const slug = projectSlug(candidate.path, config.slugParentFolders ?? DEFAULT_SLUG_PARENT_FOLDERS);
116
+ const existing = config.projects[candidate.path];
117
+ const rootConfig = rootsResolved.find((r) => r.path === candidate.root);
118
+ const rootName = rootConfig?.name ?? path.basename(candidate.root);
119
+ const project = {
120
+ id: candidate.path,
121
+ slug,
122
+ name: slug,
123
+ path: candidate.path,
124
+ root: candidate.root,
125
+ rootName,
126
+ hasGit,
127
+ hasReadme: candidate.hasReadme,
128
+ auto,
129
+ user: existing?.user,
130
+ };
131
+ projects[candidate.path] = project;
132
+ }
133
+ const nextConfig = {
134
+ version: config.version ?? 1,
135
+ roots: rootsResolved,
136
+ projects,
137
+ ...(config.ignores !== undefined && { ignores: config.ignores }),
138
+ ...(config.ignoredPaths !== undefined && { ignoredPaths: config.ignoredPaths }),
139
+ ...(config.slugParentFolders !== undefined && { slugParentFolders: config.slugParentFolders }),
115
140
  };
116
- projects[candidate.path] = project;
141
+ await writeConfig(nextConfig);
142
+ const projectCount = Object.keys(projects).length;
143
+ const rootCount = rootsResolved.length;
144
+ log.info("update completed", "projects=" + projectCount, "roots=" + rootCount);
145
+ process.stdout.write("Indexed " +
146
+ projectCount +
147
+ " projects from " +
148
+ rootCount +
149
+ " root(s).\n");
150
+ if (options.cron !== undefined && options.cron !== false) {
151
+ const entryScriptPath = path.isAbsolute(process.argv[1] ?? "")
152
+ ? process.argv[1]
153
+ : path.resolve(process.cwd(), process.argv[1] ?? "dist/index.js");
154
+ const cronOpt = options.cron;
155
+ if (cronOpt === true) {
156
+ const { wrapperPath, logPath } = await installUpdateCron({
157
+ nodePath: process.execPath,
158
+ entryScriptPath,
159
+ schedule: "1h",
160
+ });
161
+ process.stdout.write("Installed cron for bet update (every hour).\n");
162
+ process.stdout.write(` Wrapper script: ${wrapperPath}\n Log file: ${logPath}\n To view or edit crontab: crontab -l / crontab -e\n`);
163
+ }
164
+ else if (typeof cronOpt === "string") {
165
+ const normalized = cronOpt.trim().toLowerCase();
166
+ if (normalized === "0" || normalized === "false") {
167
+ await uninstallUpdateCron();
168
+ process.stdout.write("Removed cron for bet update.\n");
169
+ }
170
+ else {
171
+ try {
172
+ const parsed = parseCronSchedule(cronOpt);
173
+ const { wrapperPath, logPath } = await installUpdateCron({
174
+ nodePath: process.execPath,
175
+ entryScriptPath,
176
+ schedule: cronOpt,
177
+ });
178
+ const label = formatScheduleLabel(parsed);
179
+ process.stdout.write(`Installed cron for bet update (${label}).\n`);
180
+ process.stdout.write(` Wrapper script: ${wrapperPath}\n Log file: ${logPath}\n To view or edit crontab: crontab -l / crontab -e\n`);
181
+ }
182
+ catch (err) {
183
+ const message = err instanceof Error ? err.message : String(err);
184
+ log.error(err instanceof Error ? err : new Error(message));
185
+ process.stderr.write(`Error: ${message}\n`);
186
+ process.exitCode = 1;
187
+ }
188
+ }
189
+ }
190
+ }
117
191
  }
118
- const nextConfig = {
119
- version: config.version ?? 1,
120
- roots: rootsResolved,
121
- projects,
122
- };
123
- await writeConfig(nextConfig);
124
- process.stdout.write("Indexed " +
125
- Object.keys(projects).length +
126
- " projects from " +
127
- rootsResolved.length +
128
- " root(s).\n");
129
- if (options.cron) {
130
- const entryScriptPath = path.isAbsolute(process.argv[1] ?? "")
131
- ? process.argv[1]
132
- : path.resolve(process.cwd(), process.argv[1] ?? "dist/index.js");
133
- await installHourlyUpdateCron({
134
- nodePath: process.execPath,
135
- entryScriptPath,
136
- });
137
- process.stdout.write("Installed hourly cron job for bet update.\n");
192
+ catch (err) {
193
+ const message = err instanceof Error ? err.message : String(err);
194
+ log.error(err instanceof Error ? err : new Error(message));
195
+ process.stderr.write(`Error: ${message}\n`);
196
+ process.exitCode = 1;
138
197
  }
139
198
  });
140
199
  }
package/dist/index.js CHANGED
@@ -8,11 +8,12 @@ import { registerGo } from "./commands/go.js";
8
8
  import { registerPath } from "./commands/path.js";
9
9
  import { registerShell } from "./commands/shell.js";
10
10
  import { registerCompletion } from "./commands/completion.js";
11
+ import { registerIgnore } from "./commands/ignore.js";
11
12
  const program = new Command();
12
13
  program
13
14
  .name("bet")
14
15
  .description("Explore and jump between local projects.")
15
- .version("0.1.0");
16
+ .version("0.1.2");
16
17
  registerUpdate(program);
17
18
  registerList(program);
18
19
  registerSearch(program);
@@ -21,4 +22,5 @@ registerGo(program);
21
22
  registerPath(program);
22
23
  registerShell(program);
23
24
  registerCompletion(program);
25
+ registerIgnore(program);
24
26
  program.parseAsync(process.argv);
@@ -42,15 +42,39 @@ function normalizeRoots(parsedRoots) {
42
42
  }
43
43
  return result;
44
44
  }
45
+ function normalizeIgnores(parsed) {
46
+ if (!Array.isArray(parsed))
47
+ return undefined;
48
+ const list = parsed.filter((x) => typeof x === "string");
49
+ return list;
50
+ }
51
+ function normalizeSlugParentFolders(parsed) {
52
+ if (!Array.isArray(parsed))
53
+ return undefined;
54
+ const list = parsed.filter((x) => typeof x === "string" && x.trim() !== "").map((x) => x.trim());
55
+ return list.length === 0 ? undefined : list;
56
+ }
57
+ function normalizeIgnoredPaths(parsed) {
58
+ if (!Array.isArray(parsed))
59
+ return undefined;
60
+ const list = parsed.filter((x) => typeof x === "string").map((x) => normalizeAbsolute(x));
61
+ return list.length === 0 ? undefined : list;
62
+ }
45
63
  async function readAppConfig() {
46
64
  try {
47
65
  const raw = await fs.readFile(CONFIG_PATH, "utf8");
48
66
  const parsed = JSON.parse(raw);
49
67
  const roots = normalizeRoots(parsed.roots ?? []);
68
+ const ignores = normalizeIgnores(parsed.ignores);
69
+ const ignoredPaths = normalizeIgnoredPaths(parsed.ignoredPaths);
70
+ const slugParentFolders = normalizeSlugParentFolders(parsed.slugParentFolders);
50
71
  return {
51
72
  ...DEFAULT_APP_CONFIG,
52
73
  version: parsed.version ?? 1,
53
74
  roots,
75
+ ...(ignores !== undefined && { ignores }),
76
+ ...(ignoredPaths !== undefined && { ignoredPaths }),
77
+ ...(slugParentFolders !== undefined && { slugParentFolders }),
54
78
  };
55
79
  }
56
80
  catch (error) {
@@ -108,6 +132,9 @@ export async function writeConfig(config) {
108
132
  const appConfig = {
109
133
  version: config.version,
110
134
  roots: config.roots,
135
+ ...(config.ignores !== undefined && { ignores: config.ignores }),
136
+ ...(config.ignoredPaths !== undefined && { ignoredPaths: config.ignoredPaths }),
137
+ ...(config.slugParentFolders !== undefined && { slugParentFolders: config.slugParentFolders }),
111
138
  };
112
139
  const projectsConfig = {
113
140
  projects: config.projects,
package/dist/lib/cron.js CHANGED
@@ -2,16 +2,67 @@ import fs from "node:fs/promises";
2
2
  import path from "node:path";
3
3
  import { spawnSync } from "node:child_process";
4
4
  import { getConfigPath } from "./config.js";
5
- const CRON_MARKER = "# bet:update hourly";
5
+ const CRON_MARKER = "# bet:update";
6
6
  const WRAPPER_SCRIPT_NAME = "bet-update-cron.sh";
7
7
  const LOG_FILE_NAME = "cron-update.log";
8
+ const SCHEDULE_REGEX = /^(\d+)(m|h|d)$/i;
9
+ const MINUTES_MIN = 1;
10
+ const MINUTES_MAX = 59;
11
+ const HOURS_MIN = 1;
12
+ const HOURS_MAX = 24;
13
+ const DAYS_MIN = 1;
14
+ const DAYS_MAX = 31;
15
+ /**
16
+ * Parses a schedule string (e.g. "5m", "2h", "7d") and validates ranges.
17
+ * @throws Error if format is invalid or value is out of range (m: 1-59, h: 1-24, d: 1-31).
18
+ */
19
+ export function parseCronSchedule(schedule) {
20
+ const trimmed = schedule.trim().toLowerCase();
21
+ const match = trimmed.match(SCHEDULE_REGEX);
22
+ if (!match) {
23
+ throw new Error(`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.`);
24
+ }
25
+ const value = parseInt(match[1], 10);
26
+ const unit = match[2];
27
+ if (unit === "m" && (value < MINUTES_MIN || value > MINUTES_MAX)) {
28
+ throw new Error(`Invalid minutes: ${value}. Use 1-59 for Nm (e.g. 5m, 30m).`);
29
+ }
30
+ if (unit === "h" && (value < HOURS_MIN || value > HOURS_MAX)) {
31
+ throw new Error(`Invalid hours: ${value}. Use 1-24 for Nh (e.g. 1h, 12h). Use 24h for once per day.`);
32
+ }
33
+ if (unit === "d" && (value < DAYS_MIN || value > DAYS_MAX)) {
34
+ throw new Error(`Invalid days: ${value}. Use 1-31 for Nd (e.g. 1d, 7d).`);
35
+ }
36
+ return { value, unit };
37
+ }
38
+ /**
39
+ * Converts a parsed schedule to a 5-field cron expression (minute hour dom month dow).
40
+ */
41
+ export function scheduleToCronExpression(parsed) {
42
+ const { value, unit } = parsed;
43
+ if (unit === "m") {
44
+ return `*/${value} * * * *`;
45
+ }
46
+ if (unit === "h") {
47
+ if (value === 24) {
48
+ return "0 0 * * *";
49
+ }
50
+ return `0 */${value} * * *`;
51
+ }
52
+ // unit === "d"
53
+ return `0 0 */${value} * *`;
54
+ }
8
55
  /**
9
56
  * Writes a wrapper script and installs/updates a per-user crontab entry
10
- * so that `bet update` runs every hour. Idempotent: re-running replaces
11
- * the existing bet cron block.
57
+ * so that `bet update` runs at the given schedule. Idempotent: re-running
58
+ * replaces the existing bet cron block (single cron only).
59
+ * @throws Error if schedule is invalid (see parseCronSchedule).
60
+ * @returns Paths to the wrapper script and log file for user reference.
12
61
  */
13
- export async function installHourlyUpdateCron(options) {
14
- const { nodePath, entryScriptPath } = options;
62
+ export async function installUpdateCron(options) {
63
+ const { nodePath, entryScriptPath, schedule } = options;
64
+ const parsed = parseCronSchedule(schedule);
65
+ const cronExpression = scheduleToCronExpression(parsed);
15
66
  const configDir = path.dirname(getConfigPath());
16
67
  await fs.mkdir(configDir, { recursive: true });
17
68
  const wrapperPath = path.join(configDir, WRAPPER_SCRIPT_NAME);
@@ -22,7 +73,7 @@ export async function installHourlyUpdateCron(options) {
22
73
  ].join("\n");
23
74
  await fs.writeFile(wrapperPath, scriptBody + "\n", "utf8");
24
75
  await fs.chmod(wrapperPath, 0o755);
25
- const scheduleLine = `0 * * * * ${wrapperPath}`;
76
+ const scheduleLine = `${cronExpression} ${wrapperPath}`;
26
77
  const betBlock = [CRON_MARKER, scheduleLine].join("\n");
27
78
  const crontabL = spawnSync("crontab", ["-l"], {
28
79
  encoding: "utf8",
@@ -70,4 +121,62 @@ export async function installHourlyUpdateCron(options) {
70
121
  const err = crontabWrite.stderr || crontabWrite.stdout || "unknown";
71
122
  throw new Error(`crontab install failed: ${err}`);
72
123
  }
124
+ return { wrapperPath, logPath };
125
+ }
126
+ /**
127
+ * Removes the bet update cron entry from the user's crontab (if present).
128
+ */
129
+ export async function uninstallUpdateCron() {
130
+ const crontabL = spawnSync("crontab", ["-l"], {
131
+ encoding: "utf8",
132
+ stdio: ["ignore", "pipe", "pipe"],
133
+ });
134
+ let existingCrontab = "";
135
+ if (crontabL.status === 0 && crontabL.stdout) {
136
+ existingCrontab = crontabL.stdout;
137
+ }
138
+ if (crontabL.status !== 0 && crontabL.stderr && !crontabL.stderr.includes("no crontab")) {
139
+ throw new Error(`crontab -l failed: ${crontabL.stderr}`);
140
+ }
141
+ const lines = existingCrontab.split("\n");
142
+ const out = [];
143
+ let skipNext = false;
144
+ for (const line of lines) {
145
+ if (skipNext) {
146
+ skipNext = false;
147
+ continue;
148
+ }
149
+ if (line === CRON_MARKER) {
150
+ skipNext = true;
151
+ continue;
152
+ }
153
+ out.push(line);
154
+ }
155
+ const newCrontab = out.length > 0 ? out.join("\n").replace(/\n*$/, "") + "\n" : "";
156
+ if (newCrontab === "") {
157
+ spawnSync("crontab", ["-r"], { stdio: "pipe" });
158
+ return;
159
+ }
160
+ const crontabWrite = spawnSync("crontab", ["-"], {
161
+ encoding: "utf8",
162
+ input: newCrontab,
163
+ stdio: ["pipe", "pipe", "pipe"],
164
+ });
165
+ if (crontabWrite.status !== 0) {
166
+ const err = crontabWrite.stderr || crontabWrite.stdout || "unknown";
167
+ throw new Error(`crontab update failed: ${err}`);
168
+ }
169
+ }
170
+ /**
171
+ * Returns a human-readable label for a parsed schedule (e.g. "every 5 minutes").
172
+ */
173
+ export function formatScheduleLabel(parsed) {
174
+ const { value, unit } = parsed;
175
+ if (unit === "m") {
176
+ return value === 1 ? "every minute" : `every ${value} minutes`;
177
+ }
178
+ if (unit === "h") {
179
+ return value === 1 ? "every hour" : `every ${value} hours`;
180
+ }
181
+ return value === 1 ? "every day" : `every ${value} days`;
73
182
  }
@@ -1,3 +1,4 @@
1
+ import { isSubpath } from "../utils/paths.js";
1
2
  export const DEFAULT_IGNORES = [
2
3
  "**/node_modules/**",
3
4
  "**/.git/**",
@@ -9,3 +10,11 @@ export const DEFAULT_IGNORES = [
9
10
  "**/.venv/**",
10
11
  "**/venv/**",
11
12
  ];
13
+ export function getEffectiveIgnores(config) {
14
+ return config.ignores !== undefined ? config.ignores : DEFAULT_IGNORES;
15
+ }
16
+ export function isPathIgnored(projectPath, ignoredPaths) {
17
+ if (ignoredPaths.length === 0)
18
+ return false;
19
+ return ignoredPaths.some((ip) => projectPath === ip || isSubpath(projectPath, ip));
20
+ }
@@ -0,0 +1,22 @@
1
+ import path from "node:path";
2
+ import os from "node:os";
3
+ const LOG_FILE_NAME = "bet.log";
4
+ /**
5
+ * Returns the directory where bet writes its log file.
6
+ * - macOS: ~/Library/Logs/bet
7
+ * - Linux / others: $XDG_STATE_HOME/bet or ~/.local/state/bet
8
+ */
9
+ export function getLogDir() {
10
+ const homedir = os.homedir();
11
+ if (process.platform === "darwin") {
12
+ return path.join(homedir, "Library", "Logs", "bet");
13
+ }
14
+ const stateHome = process.env.XDG_STATE_HOME ?? path.join(homedir, ".local", "state");
15
+ return path.join(stateHome, "bet");
16
+ }
17
+ /**
18
+ * Returns the full path to the log file (e.g. getLogDir()/bet.log).
19
+ */
20
+ export function getLogFilePath() {
21
+ return path.join(getLogDir(), LOG_FILE_NAME);
22
+ }