bet-cli 0.1.1 → 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.
@@ -77,12 +87,21 @@ After that:
77
87
  - `bet go <slug>` will `cd` your current shell into the project
78
88
  - `bet list` / `bet search` can also “select and jump” in interactive mode
79
89
 
90
+ **Project name autocompletion:** To complete project names (slugs) when using `bet go`, `bet path`, or `bet info`, add to your rc file:
91
+
92
+ ```sh
93
+ eval "$(bet completion zsh)" # zsh
94
+ eval "$(bet completion bash)" # bash
95
+ ```
96
+
97
+ Then Tab-complete the slug argument after `bet go `, `bet path `, or `bet info `.
98
+
80
99
  ## Core commands
81
100
 
82
101
  - **`bet update`**: Scan configured roots and rebuild the project index.
83
102
  - **First-time setup**: `bet update --roots "$HOME/code,$HOME/work"`
84
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).
85
- - **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)).
86
105
  - **`bet list`**: List indexed projects (interactive by default).
87
106
  - **`--plain`**: non-interactive output
88
107
  - **`--json`**: machine-readable output
@@ -94,6 +113,7 @@ After that:
94
113
  - **`--print`**: print selected path only (no shell `cd`)
95
114
  - **`--no-enter`**: do not run the project’s `onEnter` hook (if configured)
96
115
  - **`bet shell`**: Print the shell integration snippet (see above).
116
+ - **`bet completion [bash|zsh]`**: Print shell completion script for project name autocompletion (see above).
97
117
 
98
118
  ## Config & data files
99
119
 
@@ -101,10 +121,20 @@ bet stores its data in:
101
121
 
102
122
  - **Config dir**: `~/.config/bet/` (or `$XDG_CONFIG_HOME/bet/`)
103
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.
104
125
  - **Project index**: `projects.json`
105
126
 
106
127
  These are plain JSON files—easy to inspect, back up, or edit.
107
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
+
108
138
  ### Advanced filtering with `--json`
109
139
 
110
140
  You can combine `bet list --json` with [jq](https://stedolan.github.io/jq/) for powerful, scriptable project filtering. Here are some practical examples:
@@ -0,0 +1,110 @@
1
+ import { getProjectSlugs } from "../lib/completion.js";
2
+ const SLUG_COMMANDS = ["go", "path", "info"];
3
+ const SUBCOMMANDS = [
4
+ "update",
5
+ "list",
6
+ "search",
7
+ "info",
8
+ "go",
9
+ "path",
10
+ "shell",
11
+ "completion",
12
+ "ignore",
13
+ ];
14
+ function zshScript() {
15
+ const slugCommandsPattern = SLUG_COMMANDS.join("|");
16
+ return `#compdef bet
17
+ _bet() {
18
+ local -a subcommands
19
+ subcommands=(
20
+ 'update:Scan roots and rebuild project index'
21
+ 'list:List projects'
22
+ 'search:Fuzzy-search projects'
23
+ 'info:Show project details'
24
+ 'go:Jump to a project'
25
+ 'path:Print project path'
26
+ 'shell:Print shell integration'
27
+ 'completion:Print shell completion script'
28
+ 'ignore:Manage ignored project paths'
29
+ )
30
+
31
+ if (( CURRENT == 2 )); then
32
+ _describe 'bet commands' subcommands
33
+ return
34
+ fi
35
+
36
+ if [[ "${slugCommandsPattern}" == *"\$words[2]"* ]]; then
37
+ if (( CURRENT == 3 )); then
38
+ local -a slugs
39
+ slugs=(\$(command bet completion --list 2>/dev/null))
40
+ _describe 'project' slugs
41
+ fi
42
+ return
43
+ fi
44
+
45
+ _default
46
+ }
47
+ compdef _bet bet
48
+ `;
49
+ }
50
+ function bashScript() {
51
+ const subcommandsList = SUBCOMMANDS.join(" ");
52
+ const cw = "COMP_WORDS";
53
+ const cc = "COMP_CWORD";
54
+ const dollar = "$";
55
+ return `_bet_completions() {
56
+ local cur="${dollar}{${cw}[${cc}]}"
57
+
58
+ if (( ${cc} == 1 )); then
59
+ COMPREPLY=(\$(compgen -W "${subcommandsList}" -- "${dollar}cur"))
60
+ return
61
+ fi
62
+
63
+ local cmd="${dollar}{${cw}[1]}"
64
+ if [[ "${dollar}cmd" == "go" || "${dollar}cmd" == "path" || "${dollar}cmd" == "info" ]]; then
65
+ if (( ${cc} == 2 )); then
66
+ local slugs
67
+ slugs=\$(command bet completion --list 2>/dev/null)
68
+ COMPREPLY=(\$(compgen -W "${dollar}slugs" -- "${dollar}cur"))
69
+ fi
70
+ fi
71
+ }
72
+ complete -F _bet_completions bet
73
+ `;
74
+ }
75
+ export function registerCompletion(program) {
76
+ program
77
+ .command("completion [shell]")
78
+ .description("Print shell completion script for project name autocompletion")
79
+ .option("--list", "Print project slugs only (for use by completion script)")
80
+ .option("--prefix <prefix>", "Filter slugs by prefix")
81
+ .action(async (shell, options) => {
82
+ if (options.list) {
83
+ let slugs = await getProjectSlugs();
84
+ if (options.prefix?.length) {
85
+ const p = options.prefix.toLowerCase();
86
+ slugs = slugs.filter((s) => s.toLowerCase().startsWith(p));
87
+ }
88
+ for (const slug of slugs) {
89
+ process.stdout.write(`${slug}\n`);
90
+ }
91
+ return;
92
+ }
93
+ const sh = shell?.toLowerCase() ?? "";
94
+ if (sh === "zsh") {
95
+ process.stdout.write(zshScript());
96
+ return;
97
+ }
98
+ if (sh === "bash") {
99
+ process.stdout.write(bashScript());
100
+ return;
101
+ }
102
+ if (shell !== undefined) {
103
+ process.stderr.write(`Unknown shell "${shell}". Use bash or zsh.\n`);
104
+ process.exitCode = 1;
105
+ return;
106
+ }
107
+ process.stderr.write("Usage: bet completion [bash|zsh]\n eval \"$(bet completion zsh)\"\n");
108
+ process.exitCode = 1;
109
+ });
110
+ }
@@ -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
@@ -7,11 +7,13 @@ import { registerInfo } from "./commands/info.js";
7
7
  import { registerGo } from "./commands/go.js";
8
8
  import { registerPath } from "./commands/path.js";
9
9
  import { registerShell } from "./commands/shell.js";
10
+ import { registerCompletion } from "./commands/completion.js";
11
+ import { registerIgnore } from "./commands/ignore.js";
10
12
  const program = new Command();
11
13
  program
12
14
  .name("bet")
13
15
  .description("Explore and jump between local projects.")
14
- .version("0.1.0");
16
+ .version("0.1.2");
15
17
  registerUpdate(program);
16
18
  registerList(program);
17
19
  registerSearch(program);
@@ -19,4 +21,6 @@ registerInfo(program);
19
21
  registerGo(program);
20
22
  registerPath(program);
21
23
  registerShell(program);
24
+ registerCompletion(program);
25
+ registerIgnore(program);
22
26
  program.parseAsync(process.argv);
@@ -0,0 +1,16 @@
1
+ import { readConfig } from "./config.js";
2
+ import { listProjects } from "./projects.js";
3
+ /**
4
+ * Returns project slugs for shell completion. On missing config or error,
5
+ * returns an empty array so the completion path can exit 0 with no output.
6
+ */
7
+ export async function getProjectSlugs() {
8
+ try {
9
+ const config = await readConfig();
10
+ const projects = listProjects(config);
11
+ return projects.map((p) => p.slug);
12
+ }
13
+ catch {
14
+ return [];
15
+ }
16
+ }
@@ -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,