cowork-cli 0.2.8 → 1.1.0

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.
@@ -1,37 +1,285 @@
1
1
  import fs from 'fs/promises';
2
2
  import path from 'path';
3
3
 
4
- const DEFAULT_IGNORES = ['.git', 'node_modules', 'dist', 'build', '.npm', '.DS_Store'];
4
+ // ── Constants ────────────────────────────────────────────────────────────────
5
+
6
+ const DEFAULT_IGNORES = [
7
+ // VCS
8
+ '.git', '.svn', '.hg',
9
+ // Dependencies & build output
10
+ 'node_modules', 'dist', 'build', '.npm',
11
+ // OS artifacts
12
+ '.DS_Store', 'Thumbs.db',
13
+ // Secrets — defense-in-depth even if .gitignore already covers them
14
+ '.env', '.env.*',
15
+ // Test / coverage artifacts
16
+ 'coverage', '__pycache__', '.cache',
17
+ // IDE metadata
18
+ '.vscode', '.idea',
19
+ ];
20
+
21
+ const MAX_GITIGNORE_SIZE = 64 * 1024; // 64 KB
22
+
23
+ // ── Cache ────────────────────────────────────────────────────────────────────
24
+
25
+ let _cachedPatterns = null;
26
+
27
+ // ── Internal: Glob-to-RegExp (zero dependencies) ────────────────────────────
5
28
 
6
29
  /**
7
- * Loads .gitignore patterns from the current working directory.
8
- * @returns {Promise<string[]>} Array of ignore patterns.
30
+ * Converts a gitignore-style glob pattern to a RegExp.
31
+ * Supports `*`, `**`, `?`, and bracket classes `[abc]`.
32
+ * @param {string} pattern
33
+ * @returns {RegExp}
9
34
  */
10
- export async function getIgnorePatterns() {
11
- const ignoreList = [...DEFAULT_IGNORES];
35
+ function globToRegex(pattern) {
36
+ let i = 0;
37
+ let re = '';
38
+
39
+ while (i < pattern.length) {
40
+ const c = pattern[i];
41
+
42
+ if (c === '*') {
43
+ if (pattern[i + 1] === '*') {
44
+ // `**` — match everything including path separators
45
+ re += '.*';
46
+ i += 2;
47
+ if (pattern[i] === '/') i++; // consume optional trailing `/`
48
+ continue;
49
+ }
50
+ re += '[^/]*'; // `*` — any non-separator chars
51
+ } else if (c === '?') {
52
+ re += '[^/]'; // `?` — single non-separator char
53
+ } else if (c === '[') {
54
+ // Bracket expression — locate the closing `]`
55
+ let j = i + 1;
56
+ if (j < pattern.length && pattern[j] === '!') j++;
57
+ if (j < pattern.length && pattern[j] === ']') j++;
58
+ while (j < pattern.length && pattern[j] !== ']') j++;
59
+
60
+ if (j >= pattern.length) {
61
+ re += '\\['; // no closing bracket → literal `[`
62
+ } else {
63
+ let cls = pattern.slice(i + 1, j).replace(/\\/g, '\\\\');
64
+ if (cls[0] === '!') cls = '^' + cls.slice(1);
65
+ re += `[${cls}]`;
66
+ i = j; // advance past `]`
67
+ }
68
+ } else if ('.+^${}()|\\'.includes(c)) {
69
+ re += '\\' + c; // escape regex meta chars
70
+ } else {
71
+ re += c;
72
+ }
73
+ i++;
74
+ }
75
+
76
+ return new RegExp(`^${re}$`);
77
+ }
78
+
79
+ // ── Internal: .gitignore Parser ──────────────────────────────────────────────
80
+
81
+ /**
82
+ * Parses raw `.gitignore` content into structured pattern objects.
83
+ * Handles: comments, blank lines, negation (`!`), directory-only (`/`),
84
+ * Windows `\r` line endings, and glob detection.
85
+ * @param {string} content
86
+ * @returns {Object[]}
87
+ */
88
+ function parseGitignoreContent(content) {
89
+ const patterns = [];
90
+
91
+ for (const raw of content.split('\n')) {
92
+ let line = raw.replace(/\r$/, '').trim(); // strip Windows CR
93
+ if (!line || line.startsWith('#')) continue; // skip blanks & comments
94
+
95
+ // Negation
96
+ let negated = false;
97
+ if (line.startsWith('!')) {
98
+ negated = true;
99
+ line = line.slice(1).trim();
100
+ if (!line) continue;
101
+ }
102
+
103
+ // Directory-only marker
104
+ const dirOnly = line.endsWith('/');
105
+ if (dirOnly) line = line.slice(0, -1);
106
+
107
+ const hasGlob = /[*?[\]]/.test(line);
108
+ const hasSlash = line.includes('/');
109
+
110
+ patterns.push({
111
+ pattern: line,
112
+ negated,
113
+ dirOnly,
114
+ hasGlob,
115
+ hasSlash,
116
+ regex: hasGlob ? globToRegex(line) : null,
117
+ });
118
+ }
119
+
120
+ return patterns;
121
+ }
122
+
123
+ /**
124
+ * Builds structured pattern objects from a plain-string array
125
+ * (used for the hard-coded DEFAULT_IGNORES list).
126
+ * @param {string[]} list
127
+ * @returns {Object[]}
128
+ */
129
+ function buildPatterns(list) {
130
+ return list.map(raw => {
131
+ const dirOnly = raw.endsWith('/');
132
+ const cleaned = dirOnly ? raw.slice(0, -1) : raw;
133
+ const hasGlob = /[*?[\]]/.test(cleaned);
134
+ return {
135
+ pattern: cleaned,
136
+ negated: false,
137
+ dirOnly,
138
+ hasGlob,
139
+ hasSlash: cleaned.includes('/'),
140
+ regex: hasGlob ? globToRegex(cleaned) : null,
141
+ };
142
+ });
143
+ }
144
+
145
+ // ── Internal: .gitignore Loader ──────────────────────────────────────────────
146
+
147
+ /**
148
+ * Reads and parses the `.gitignore` file inside a directory.
149
+ * Returns an empty array if the file is missing, unreadable, or oversized.
150
+ * @param {string} dirPath Absolute directory path.
151
+ * @returns {Promise<Object[]>}
152
+ */
153
+ async function loadGitignoreFromDir(dirPath) {
12
154
  try {
13
- const gitignorePath = path.join(process.cwd(), '.gitignore');
155
+ const gitignorePath = path.join(dirPath, '.gitignore');
156
+ const stats = await fs.stat(gitignorePath);
157
+ if (stats.size > MAX_GITIGNORE_SIZE) return [];
158
+
14
159
  const content = await fs.readFile(gitignorePath, 'utf8');
15
- const lines = content.split('\n')
16
- .map(l => l.trim())
17
- .filter(l => l && !l.startsWith('#'));
18
- ignoreList.push(...lines);
19
- } catch (e) {
20
- // Ignore if not found or unreadable
160
+ return parseGitignoreContent(content);
161
+ } catch {
162
+ return [];
21
163
  }
22
- return ignoreList;
23
164
  }
24
165
 
166
+ // ── Public API ───────────────────────────────────────────────────────────────
167
+
25
168
  /**
26
- * Checks if a file or directory should be ignored.
27
- * @param {string} name Item name.
28
- * @param {string[]} ignoreList List of patterns.
29
- * @returns {boolean}
169
+ * Loads ignore patterns from `DEFAULT_IGNORES` + the root `.gitignore`.
170
+ * Results are cached for the lifetime of the process.
171
+ * @returns {Promise<Object[]>} Structured pattern objects.
30
172
  */
31
- export function shouldIgnore(name, ignoreList) {
32
- for (const ignore of ignoreList) {
33
- const pattern = ignore.endsWith('/') ? ignore.slice(0, -1) : ignore;
34
- if (name === pattern) return true;
173
+ export async function getIgnorePatterns() {
174
+ if (_cachedPatterns) return _cachedPatterns;
175
+
176
+ const defaults = buildPatterns(DEFAULT_IGNORES);
177
+ const gitignore = await loadGitignoreFromDir(process.cwd());
178
+
179
+ _cachedPatterns = [...defaults, ...gitignore];
180
+ return _cachedPatterns;
181
+ }
182
+
183
+ /**
184
+ * Loads additional `.gitignore` patterns from a nested directory and
185
+ * merges them **after** the parent list so they can override via negation.
186
+ * @param {string} dirPath Absolute path of the directory to inspect.
187
+ * @param {Object[]} parentList Patterns inherited from the parent scope.
188
+ * @returns {Promise<Object[]>} Merged pattern list (parent + nested).
189
+ */
190
+ export async function loadNestedIgnores(dirPath, parentList) {
191
+ const nested = await loadGitignoreFromDir(dirPath);
192
+ if (nested.length === 0) return parentList;
193
+ return [...parentList, ...nested];
194
+ }
195
+
196
+ /**
197
+ * Invalidates the cached ignore patterns.
198
+ */
199
+ export function clearIgnoreCache() {
200
+ _cachedPatterns = null;
201
+ }
202
+
203
+ /**
204
+ * Checks if a name should be ignored.
205
+ *
206
+ * Pattern evaluation order matters — patterns are processed sequentially
207
+ * and the **last matching pattern wins**. Negation patterns (`!`) can
208
+ * therefore un-ignore a previously ignored name.
209
+ *
210
+ * @param {string} name Item basename.
211
+ * @param {Object[]} ignoreList Structured pattern objects.
212
+ * @param {Object} [options]
213
+ * @param {boolean} [options.isDirectory] Whether the item is a directory.
214
+ * When omitted, directory-only patterns match regardless
215
+ * (backward-compatible with existing callers).
216
+ * @returns {boolean} `true` if the item should be skipped.
217
+ */
218
+ export function shouldIgnore(name, ignoreList, options = {}) {
219
+ const { isDirectory } = options;
220
+ let ignored = false;
221
+
222
+ for (const entry of ignoreList) {
223
+ // Path-containing patterns (e.g. `docs/internal`) require full
224
+ // relative-path matching which callers don't supply — skip them.
225
+ if (entry.hasSlash) continue;
226
+
227
+ // Directory-only patterns (`build/`) don't apply to files.
228
+ // When `isDirectory` is undefined the caller didn't say, so we match
229
+ // to preserve backward compat with callers that only pass basenames.
230
+ if (entry.dirOnly && isDirectory === false) continue;
231
+
232
+ const matches = entry.hasGlob && entry.regex
233
+ ? entry.regex.test(name)
234
+ : name === entry.pattern;
235
+
236
+ if (matches) {
237
+ ignored = !entry.negated;
238
+ }
239
+ }
240
+
241
+ return ignored;
242
+ }
243
+
244
+ /**
245
+ * Resolves a path and verifies it stays within `process.cwd()`.
246
+ * Prevents directory-traversal attacks (e.g. `../../etc/passwd`).
247
+ *
248
+ * @param {string} inputPath The user- or model-supplied path.
249
+ * @returns {string} Resolved absolute path guaranteed to be inside the project.
250
+ * @throws {Error} If the resolved path escapes the project root.
251
+ */
252
+ export function safePath(inputPath) {
253
+ const root = process.cwd();
254
+ const resolved = path.resolve(root, inputPath);
255
+ if (resolved !== root && !resolved.startsWith(root + path.sep)) {
256
+ throw new Error(`Access denied: '${inputPath}' resolves outside the project directory.`);
35
257
  }
36
- return false;
258
+ return resolved;
259
+ }
260
+
261
+ /**
262
+ * Decides whether a directory entry is safe to traverse / read.
263
+ *
264
+ * Rejects:
265
+ * 1. Symbolic links (could escape the project sandbox).
266
+ * 2. Names matched by the ignore list.
267
+ * 3. Paths that resolve outside `process.cwd()`.
268
+ *
269
+ * @param {import('fs').Dirent} dirent Directory entry from `readdir`.
270
+ * @param {string} parentPath Absolute path of the parent dir.
271
+ * @param {Object[]} ignoreList Structured pattern objects.
272
+ * @returns {boolean} `true` when the entry is safe to process.
273
+ */
274
+ export function isSafeEntry(dirent, parentPath, ignoreList) {
275
+ if (dirent.isSymbolicLink()) return false;
276
+
277
+ const isDir = dirent.isDirectory();
278
+ if (shouldIgnore(dirent.name, ignoreList, { isDirectory: isDir })) return false;
279
+
280
+ const resolved = path.resolve(parentPath, dirent.name);
281
+ const root = process.cwd();
282
+ if (resolved !== root && !resolved.startsWith(root + path.sep)) return false;
283
+
284
+ return true;
37
285
  }
@@ -9,12 +9,15 @@ let config;
9
9
  try {
10
10
  config = JSON.parse(fs.readFileSync(configPath, 'utf8'));
11
11
  } catch (e) {
12
- // Fallback config if file is missing or invalid
13
12
  config = {
14
13
  accents: {
15
- orangex: "#D97757",
16
- greyx: "#808080",
17
- resetx: "#FFFFFF"
14
+ main: '#7BA5DA',
15
+ tool: '#F2CF6E',
16
+ data: '#C2C6C5',
17
+ success: '#7AC391',
18
+ error: '#E07070',
19
+ dim: '#606060',
20
+ header: '#A37ACC',
18
21
  }
19
22
  };
20
23
  }
@@ -26,20 +29,28 @@ function hexToAnsi(hex) {
26
29
  return `\x1b[38;2;${r};${g};${b}m`;
27
30
  }
28
31
 
32
+ const reset = '\x1b[0m';
33
+
29
34
  const colors = {
30
- main: hexToAnsi(config.accents.orangex),
31
- secondary: hexToAnsi(config.accents.greyx),
32
- normal: hexToAnsi(config.accents.resetx),
33
- reset: '\x1b[0m'
35
+ main: hexToAnsi(config.accents.main),
36
+ tool: hexToAnsi(config.accents.tool),
37
+ data: hexToAnsi(config.accents.data),
38
+ success: hexToAnsi(config.accents.success),
39
+ error: hexToAnsi(config.accents.error),
40
+ dim: hexToAnsi(config.accents.dim),
41
+ header: hexToAnsi(config.accents.header),
34
42
  };
35
43
 
36
- export const formatMain = (text) => `${colors.main}${text}${colors.reset}`;
37
- export const formatSecondary = (text) => `${colors.secondary}${text}${colors.reset}`;
38
- export const formatNormal = (text) => `${colors.normal}${text}${colors.reset}`;
44
+ export const formatMain = (text) => `${colors.main}${text}${reset}`;
45
+ export const formatSecondary = (text) => `${colors.tool}${text}${reset}`;
46
+ export const formatNormal = (text) => `${colors.data}${text}${reset}`;
47
+ export const formatError = (text) => `${colors.error}${text}${reset}`;
48
+ export const formatDim = (text) => `${colors.dim}${text}${reset}`;
49
+ export const formatHeader = (text) => `${colors.header}${text}${reset}`;
39
50
 
40
51
  export const logger = {
41
- main: (msg) => console.log(formatMain(msg)),
52
+ main: (msg) => console.log(formatMain(msg)),
42
53
  secondary: (msg) => console.log(formatSecondary(msg)),
43
- normal: (msg) => console.log(formatNormal(msg)),
44
- error: (msg) => console.error(formatMain(msg))
54
+ normal: (msg) => console.log(formatNormal(msg)),
55
+ error: (msg) => console.error(formatError(msg)),
45
56
  };