@karmaniverous/jeeves-watcher 0.19.1 → 0.21.0-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.
- package/LICENSE +28 -0
- package/README.md +37 -27
- package/config.schema.json +553 -199
- package/dist/cli/jeeves-watcher/index-CGVLEt3K.js +20913 -0
- package/dist/cli/jeeves-watcher/index.js +1193 -1169
- package/dist/index.d.ts +1624 -609
- package/dist/index.js +19662 -7549
- package/package.json +40 -28
- package/dist/cli/jeeves-watcher/index-C_bf9c9m.js +0 -8867
|
@@ -1,32 +1,41 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
import { substituteEnvVars,
|
|
3
|
-
import {
|
|
2
|
+
import { substituteEnvVars, getPackageVersion, createServiceCli } from '@karmaniverous/jeeves';
|
|
3
|
+
import { resolve, dirname, join } from 'node:path';
|
|
4
4
|
import { fileURLToPath } from 'node:url';
|
|
5
|
-
import { jeevesWatcherConfigSchema, LOGGING_DEFAULTS, API_DEFAULTS, EMBEDDING_DEFAULTS, CONFIG_WATCH_DEFAULTS, WATCH_DEFAULTS, ROOT_DEFAULTS,
|
|
5
|
+
import { isPlainObject as isPlainObject$1, findReservedPropertyNames, sourceConfigSchema, jeevesWatcherConfigSchema, LOGGING_DEFAULTS, API_DEFAULTS, EMBEDDING_DEFAULTS, CONFIG_WATCH_DEFAULTS, WATCH_DEFAULTS, ROOT_DEFAULTS, WATCHER_CATALOG, API_KEY_ENV_VAR, VCS_EXCLUDE_CONFIRM_THRESHOLD, reindexScopeSchema, WATCHER_SKILL, WATCHER_PROMPT_RULES, INIT_CONFIG_TEMPLATE, DEFAULT_PORT as DEFAULT_PORT$1, PLUGIN_PACKAGE, SERVICE_PACKAGE, COMPONENT_NAME } from '@karmaniverous/jeeves-watcher-core';
|
|
6
6
|
import { packageDirectorySync } from 'package-directory';
|
|
7
|
-
import { readFileSync, existsSync, mkdirSync, renameSync
|
|
7
|
+
import { readFileSync, existsSync, mkdirSync, renameSync } from 'node:fs';
|
|
8
8
|
import { cosmiconfig } from 'cosmiconfig';
|
|
9
|
-
import { ZodError } from 'zod';
|
|
10
|
-
import {
|
|
11
|
-
import { parallel } from 'radash';
|
|
12
|
-
import ignore from 'ignore';
|
|
13
|
-
import picomatch from 'picomatch';
|
|
14
|
-
import { execFile } from 'node:child_process';
|
|
15
|
-
import { promisify } from 'node:util';
|
|
9
|
+
import { ZodError, z } from 'zod';
|
|
10
|
+
import { createCatalogClient, CatalogClientError } from '@karmaniverous/jeeves/catalog';
|
|
16
11
|
|
|
17
12
|
/**
|
|
18
|
-
* @module
|
|
19
|
-
*
|
|
13
|
+
* @module util/threadpool
|
|
14
|
+
* The libuv threadpool size the service runs with (D170). Pure: edits the given environment object.
|
|
20
15
|
*/
|
|
16
|
+
/** `UV_THREADPOOL_SIZE` the service uses unless the environment sets one. */
|
|
17
|
+
const SERVICE_THREADPOOL_SIZE = '16';
|
|
21
18
|
/**
|
|
22
|
-
*
|
|
19
|
+
* Default `UV_THREADPOOL_SIZE` to 16, keeping a value already set. libuv
|
|
20
|
+
* reads it when the threadpool first starts, so this must run before any
|
|
21
|
+
* threadpool work: the CLI entry imports it first.
|
|
23
22
|
*
|
|
24
|
-
* @param
|
|
25
|
-
* @returns True for plain objects.
|
|
23
|
+
* @param env - The environment to edit (`process.env` in the entry).
|
|
26
24
|
*/
|
|
27
|
-
function
|
|
28
|
-
|
|
25
|
+
function ensureThreadpoolSize(env) {
|
|
26
|
+
env.UV_THREADPOOL_SIZE ??= SERVICE_THREADPOOL_SIZE;
|
|
29
27
|
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* @module cli/jeeves-watcher/threadpool
|
|
31
|
+
* Side effect only: sets `UV_THREADPOOL_SIZE` (D170) before anything else in the CLI loads. Imported first by the entry point.
|
|
32
|
+
*/
|
|
33
|
+
ensureThreadpoolSize(process.env);
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* @module api/handlers/configMerge
|
|
37
|
+
* Shared config merge utilities.
|
|
38
|
+
*/
|
|
30
39
|
/**
|
|
31
40
|
* Deep-merge a config patch into a base config.
|
|
32
41
|
*
|
|
@@ -48,7 +57,7 @@ function deepMergeConfig(base, patch) {
|
|
|
48
57
|
? current
|
|
49
58
|
: undefined, value);
|
|
50
59
|
}
|
|
51
|
-
else if (isPlainObject(current) && isPlainObject(value)) {
|
|
60
|
+
else if (isPlainObject$1(current) && isPlainObject$1(value)) {
|
|
52
61
|
result[key] = deepMergeConfig(current, value);
|
|
53
62
|
}
|
|
54
63
|
else {
|
|
@@ -83,6 +92,401 @@ function mergeInferenceRules(existing, incoming) {
|
|
|
83
92
|
return merged;
|
|
84
93
|
}
|
|
85
94
|
|
|
95
|
+
/**
|
|
96
|
+
* @module rules/coerceType
|
|
97
|
+
* Coerces values to target JSON Schema types.
|
|
98
|
+
*/
|
|
99
|
+
/**
|
|
100
|
+
* Coerce a value to a target type based on JSON Schema type.
|
|
101
|
+
*
|
|
102
|
+
* @param value - The value to coerce (typically a string from template interpolation).
|
|
103
|
+
* @param type - The target JSON Schema type.
|
|
104
|
+
* @returns The coerced value, or undefined if coercion fails.
|
|
105
|
+
*/
|
|
106
|
+
function coerceType(value, type) {
|
|
107
|
+
// Null and undefined always coerce to undefined
|
|
108
|
+
if (value === null || value === undefined) {
|
|
109
|
+
return undefined;
|
|
110
|
+
}
|
|
111
|
+
// Empty strings are only valid for the 'string' type
|
|
112
|
+
if (value === '' && type !== 'string') {
|
|
113
|
+
return undefined;
|
|
114
|
+
}
|
|
115
|
+
switch (type) {
|
|
116
|
+
case 'string': {
|
|
117
|
+
if (typeof value === 'string')
|
|
118
|
+
return value;
|
|
119
|
+
if (typeof value === 'number' || typeof value === 'boolean') {
|
|
120
|
+
return String(value);
|
|
121
|
+
}
|
|
122
|
+
if (typeof value === 'object') {
|
|
123
|
+
// Stringify objects and arrays
|
|
124
|
+
return JSON.stringify(value);
|
|
125
|
+
}
|
|
126
|
+
// Unsupported types (symbol, function, etc.) - undefined
|
|
127
|
+
return undefined;
|
|
128
|
+
}
|
|
129
|
+
case 'integer': {
|
|
130
|
+
if (typeof value === 'string') {
|
|
131
|
+
// For strings, check that parsing yields an integer that matches the trimmed input
|
|
132
|
+
const trimmed = value.trim();
|
|
133
|
+
const num = parseInt(trimmed, 10);
|
|
134
|
+
if (Number.isInteger(num) && num.toString() === trimmed) {
|
|
135
|
+
return num;
|
|
136
|
+
}
|
|
137
|
+
return undefined;
|
|
138
|
+
}
|
|
139
|
+
if (typeof value === 'number') {
|
|
140
|
+
return Number.isInteger(value) ? value : undefined;
|
|
141
|
+
}
|
|
142
|
+
return undefined;
|
|
143
|
+
}
|
|
144
|
+
case 'number': {
|
|
145
|
+
const num = typeof value === 'string' ? parseFloat(value) : Number(value);
|
|
146
|
+
return Number.isFinite(num) ? num : undefined;
|
|
147
|
+
}
|
|
148
|
+
case 'boolean': {
|
|
149
|
+
if (typeof value === 'boolean')
|
|
150
|
+
return value;
|
|
151
|
+
if (typeof value === 'string') {
|
|
152
|
+
const lower = value.toLowerCase();
|
|
153
|
+
if (lower === 'true')
|
|
154
|
+
return true;
|
|
155
|
+
if (lower === 'false')
|
|
156
|
+
return false;
|
|
157
|
+
}
|
|
158
|
+
return undefined;
|
|
159
|
+
}
|
|
160
|
+
case 'array': {
|
|
161
|
+
if (Array.isArray(value))
|
|
162
|
+
return value;
|
|
163
|
+
if (typeof value === 'string') {
|
|
164
|
+
try {
|
|
165
|
+
const parsed = JSON.parse(value);
|
|
166
|
+
if (Array.isArray(parsed))
|
|
167
|
+
return parsed;
|
|
168
|
+
}
|
|
169
|
+
catch {
|
|
170
|
+
// Invalid JSON
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
return undefined;
|
|
174
|
+
}
|
|
175
|
+
case 'object': {
|
|
176
|
+
if (typeof value === 'string') {
|
|
177
|
+
try {
|
|
178
|
+
const parsed = JSON.parse(value);
|
|
179
|
+
if (typeof parsed === 'object' &&
|
|
180
|
+
parsed !== null &&
|
|
181
|
+
!Array.isArray(parsed)) {
|
|
182
|
+
return parsed;
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
catch {
|
|
186
|
+
// Invalid JSON
|
|
187
|
+
}
|
|
188
|
+
return undefined;
|
|
189
|
+
}
|
|
190
|
+
// Check for plain objects (not array) - null already filtered at top
|
|
191
|
+
if (typeof value === 'object' && !Array.isArray(value)) {
|
|
192
|
+
return value;
|
|
193
|
+
}
|
|
194
|
+
return undefined;
|
|
195
|
+
}
|
|
196
|
+
default:
|
|
197
|
+
return value;
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/**
|
|
202
|
+
* @module rules/templates
|
|
203
|
+
* Resolves Handlebars template expressions in rule `set` objects against file attributes.
|
|
204
|
+
*/
|
|
205
|
+
/**
|
|
206
|
+
* Resolve a Handlebars template expression against file attributes.
|
|
207
|
+
*
|
|
208
|
+
* @param value - The value to resolve (strings are compiled as Handlebars templates).
|
|
209
|
+
* @param attributes - The file or source attributes for variable lookup.
|
|
210
|
+
* @param hbs - Handlebars instance with registered helpers.
|
|
211
|
+
* @returns The resolved value.
|
|
212
|
+
*/
|
|
213
|
+
function resolveTemplateVars(value, attributes, hbs) {
|
|
214
|
+
if (typeof value !== 'string')
|
|
215
|
+
return value;
|
|
216
|
+
return hbs.compile(value, { noEscape: true })(attributes);
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/**
|
|
220
|
+
* @module rules/schemaMerge
|
|
221
|
+
* Merges schema references into a resolved property schema with set extraction and type coercion.
|
|
222
|
+
*/
|
|
223
|
+
/**
|
|
224
|
+
* Resolve a schema entry (inline object or file path) to a schema object.
|
|
225
|
+
*
|
|
226
|
+
* @param entry - Schema entry (inline object or file path).
|
|
227
|
+
* @param configDir - Config directory for resolving file paths.
|
|
228
|
+
* @returns The resolved schema object.
|
|
229
|
+
*/
|
|
230
|
+
function resolveSchemaEntry(entry, configDir) {
|
|
231
|
+
if (typeof entry === 'string') {
|
|
232
|
+
// File path - load and parse JSON
|
|
233
|
+
const resolvedPath = configDir ? resolve(configDir, entry) : entry;
|
|
234
|
+
const raw = readFileSync(resolvedPath, 'utf-8');
|
|
235
|
+
return JSON.parse(raw);
|
|
236
|
+
}
|
|
237
|
+
return entry;
|
|
238
|
+
}
|
|
239
|
+
/**
|
|
240
|
+
* Merge an array of schema references into a single resolved schema.
|
|
241
|
+
* Later entries override earlier ones at the property level.
|
|
242
|
+
*
|
|
243
|
+
* @param refs - Array of schema references (named strings or inline objects).
|
|
244
|
+
* @param options - Options for schema merging.
|
|
245
|
+
* @returns The merged resolved schema.
|
|
246
|
+
*/
|
|
247
|
+
function mergeSchemas(refs, options = {}) {
|
|
248
|
+
const { globalSchemas = {}, configDir } = options;
|
|
249
|
+
const merged = { properties: {} };
|
|
250
|
+
for (const ref of refs) {
|
|
251
|
+
let schema;
|
|
252
|
+
if (typeof ref === 'string') {
|
|
253
|
+
// Named reference - resolve from global schemas
|
|
254
|
+
const globalEntry = globalSchemas[ref];
|
|
255
|
+
if (!globalEntry) {
|
|
256
|
+
throw new Error(`Schema reference "${ref}" not found in global schemas`);
|
|
257
|
+
}
|
|
258
|
+
schema = resolveSchemaEntry(globalEntry, configDir);
|
|
259
|
+
}
|
|
260
|
+
else {
|
|
261
|
+
// Inline object
|
|
262
|
+
schema = ref;
|
|
263
|
+
}
|
|
264
|
+
// Merge properties at the property level
|
|
265
|
+
if (schema.properties) {
|
|
266
|
+
for (const [propName, propDef] of Object.entries(schema.properties)) {
|
|
267
|
+
const existing = merged.properties[propName];
|
|
268
|
+
if (existing) {
|
|
269
|
+
const incoming = propDef;
|
|
270
|
+
// For array-typed properties, concatenate set values instead of replacing
|
|
271
|
+
if (existing.type === 'array' &&
|
|
272
|
+
Array.isArray(existing.set) &&
|
|
273
|
+
incoming.set !== undefined) {
|
|
274
|
+
const existingSet = existing.set;
|
|
275
|
+
const incomingSet = Array.isArray(incoming.set)
|
|
276
|
+
? incoming.set
|
|
277
|
+
: [incoming.set];
|
|
278
|
+
merged.properties[propName] = {
|
|
279
|
+
...existing,
|
|
280
|
+
...propDef,
|
|
281
|
+
set: existingSet.concat(incomingSet),
|
|
282
|
+
};
|
|
283
|
+
}
|
|
284
|
+
else {
|
|
285
|
+
merged.properties[propName] = {
|
|
286
|
+
...existing,
|
|
287
|
+
...propDef,
|
|
288
|
+
};
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
else {
|
|
292
|
+
merged.properties[propName] = propDef;
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
return merged;
|
|
298
|
+
}
|
|
299
|
+
/**
|
|
300
|
+
* Resolve set templates against file attributes and coerce to declared types.
|
|
301
|
+
*
|
|
302
|
+
* @param schema - Resolved schema with type information.
|
|
303
|
+
* @param attributes - File attributes for template variable resolution.
|
|
304
|
+
* @returns Resolved and type-coerced metadata.
|
|
305
|
+
*/
|
|
306
|
+
function resolveAndCoerce(schema, attributes, hbs) {
|
|
307
|
+
const result = {};
|
|
308
|
+
for (const [propName, propDef] of Object.entries(schema.properties)) {
|
|
309
|
+
const setTemplate = propDef.set;
|
|
310
|
+
if (setTemplate === undefined)
|
|
311
|
+
continue;
|
|
312
|
+
// For array-typed properties with array set values, resolve each element
|
|
313
|
+
if (Array.isArray(setTemplate)) {
|
|
314
|
+
const resolved = setTemplate.map((item) => resolveTemplateVars(item, attributes, hbs));
|
|
315
|
+
result[propName] = resolved;
|
|
316
|
+
continue;
|
|
317
|
+
}
|
|
318
|
+
// Resolve template
|
|
319
|
+
const rawValue = resolveTemplateVars(setTemplate, attributes, hbs);
|
|
320
|
+
// Coerce to declared type - returns undefined on failure
|
|
321
|
+
const coerced = coerceType(rawValue, propDef.type);
|
|
322
|
+
// Only include if coercion succeeded
|
|
323
|
+
if (coerced !== undefined) {
|
|
324
|
+
result[propName] = coerced;
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
return result;
|
|
328
|
+
}
|
|
329
|
+
/**
|
|
330
|
+
* Validate that all properties in a schema have a declared type.
|
|
331
|
+
* Throws if any property lacks a type.
|
|
332
|
+
*
|
|
333
|
+
* @param schema - Resolved schema to validate.
|
|
334
|
+
* @param ruleName - Name of the rule (for error messages).
|
|
335
|
+
*/
|
|
336
|
+
function validateSchemaCompleteness(schema, ruleName) {
|
|
337
|
+
for (const [propName, propDef] of Object.entries(schema.properties)) {
|
|
338
|
+
if (!propDef.type) {
|
|
339
|
+
throw new Error(`Property "${propName}" in rule "${ruleName}" has no declared type. Every property must have a type.`);
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
/** Types that can produce trackable facet values. */
|
|
344
|
+
const FACETABLE_TYPES = new Set([
|
|
345
|
+
'string',
|
|
346
|
+
'number',
|
|
347
|
+
'boolean',
|
|
348
|
+
'integer',
|
|
349
|
+
'array',
|
|
350
|
+
]);
|
|
351
|
+
/**
|
|
352
|
+
* Validate that uiHint and enum are only applied to facetable property types.
|
|
353
|
+
* Throws if a non-facetable type (e.g. object) declares uiHint or enum.
|
|
354
|
+
*
|
|
355
|
+
* @param schema - Resolved schema to validate.
|
|
356
|
+
* @param ruleName - Name of the rule (for error messages).
|
|
357
|
+
*/
|
|
358
|
+
function validateFacetTypes(schema, ruleName) {
|
|
359
|
+
for (const [propName, propDef] of Object.entries(schema.properties)) {
|
|
360
|
+
if (!propDef.type)
|
|
361
|
+
continue;
|
|
362
|
+
if (!FACETABLE_TYPES.has(propDef.type) &&
|
|
363
|
+
(propDef.uiHint !== undefined || propDef.enum !== undefined)) {
|
|
364
|
+
throw new Error(`Property "${propName}" in rule "${ruleName}" has type "${propDef.type}" with uiHint/enum. Facet hints are only valid on string, number, boolean, integer, or array types.`);
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
/**
|
|
370
|
+
* @module rules/reservedFieldWarnings
|
|
371
|
+
* Detects inference-rule schema properties named like reserved system payload fields and reports them as non-fatal config warnings (emitted by loadConfig and `/config/validate`). Reads file-based schemas via mergeSchemas.
|
|
372
|
+
*/
|
|
373
|
+
/**
|
|
374
|
+
* Find rule schema properties that use a reserved system field name.
|
|
375
|
+
*
|
|
376
|
+
* Rules whose schemas cannot be resolved are skipped: schema resolution
|
|
377
|
+
* failures are reported as errors by validation, not as warnings here.
|
|
378
|
+
*
|
|
379
|
+
* @param config - Config (or candidate config) with rules and global schemas.
|
|
380
|
+
* @param configDir - Directory used to resolve file-based schema paths.
|
|
381
|
+
* @returns One warning per offending rule property.
|
|
382
|
+
*/
|
|
383
|
+
function findReservedFieldWarnings(config, configDir) {
|
|
384
|
+
const warnings = [];
|
|
385
|
+
for (const rule of config.inferenceRules ?? []) {
|
|
386
|
+
if (typeof rule === 'string' || !rule.schema?.length)
|
|
387
|
+
continue;
|
|
388
|
+
let properties;
|
|
389
|
+
try {
|
|
390
|
+
properties = mergeSchemas(rule.schema, {
|
|
391
|
+
globalSchemas: config.schemas,
|
|
392
|
+
configDir,
|
|
393
|
+
}).properties;
|
|
394
|
+
}
|
|
395
|
+
catch {
|
|
396
|
+
continue;
|
|
397
|
+
}
|
|
398
|
+
for (const name of findReservedPropertyNames(properties)) {
|
|
399
|
+
warnings.push({
|
|
400
|
+
path: `inferenceRules[${rule.name}].schema.${name}`,
|
|
401
|
+
message: `Property "${name}" is a reserved system payload field; the system value overwrites rule metadata on upsert. Rename the property.`,
|
|
402
|
+
});
|
|
403
|
+
}
|
|
404
|
+
}
|
|
405
|
+
return warnings;
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
/**
|
|
409
|
+
* @module config/assertResolvedSecrets
|
|
410
|
+
* Fail-fast check that secret-bearing config values (api.apiKey, sources.*.url and headers) contain no unresolved `${VAR}` after env substitution, and that api.apiKey is not empty. Pure; throws.
|
|
411
|
+
*/
|
|
412
|
+
const UNRESOLVED_PATTERN = /\$\{[^}]*\}/;
|
|
413
|
+
/**
|
|
414
|
+
* Collect config paths whose secret-bearing value still contains an
|
|
415
|
+
* unresolved `${…}` reference. Values themselves are never returned.
|
|
416
|
+
*
|
|
417
|
+
* @param config - Config after environment-variable substitution.
|
|
418
|
+
* @returns Dotted paths of offending values.
|
|
419
|
+
*/
|
|
420
|
+
function findUnresolvedSecrets(config) {
|
|
421
|
+
const paths = [];
|
|
422
|
+
const check = (path, value) => {
|
|
423
|
+
if (value !== undefined && UNRESOLVED_PATTERN.test(value))
|
|
424
|
+
paths.push(path);
|
|
425
|
+
};
|
|
426
|
+
check('api.apiKey', config.api?.apiKey);
|
|
427
|
+
for (const [name, source] of Object.entries(config.sources ?? {})) {
|
|
428
|
+
check(`sources.${name}.url`, source.url);
|
|
429
|
+
for (const [header, value] of Object.entries(source.headers ?? {})) {
|
|
430
|
+
check(`sources.${name}.headers.${header}`, value);
|
|
431
|
+
}
|
|
432
|
+
}
|
|
433
|
+
return paths;
|
|
434
|
+
}
|
|
435
|
+
/**
|
|
436
|
+
* Throw when any secret-bearing value still contains an unresolved `${…}`,
|
|
437
|
+
* or when `api.apiKey` is empty after substitution (e.g. its variable is set
|
|
438
|
+
* to an empty string). Errors name config paths only, never values.
|
|
439
|
+
*
|
|
440
|
+
* @param config - Config after environment-variable substitution.
|
|
441
|
+
* @throws Error listing the offending config paths.
|
|
442
|
+
*/
|
|
443
|
+
function assertResolvedSecrets(config) {
|
|
444
|
+
const paths = findUnresolvedSecrets(config);
|
|
445
|
+
if (paths.length > 0) {
|
|
446
|
+
throw new Error(`Unresolved environment variable reference in: ${paths.join(', ')}. Set the referenced environment variables before starting jeeves-watcher.`);
|
|
447
|
+
}
|
|
448
|
+
if (config.api?.apiKey === '') {
|
|
449
|
+
throw new Error('api.apiKey is empty after environment variable substitution. Set a non-empty key, or remove api.apiKey to disable authentication.');
|
|
450
|
+
}
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
/**
|
|
454
|
+
* @module config/sourceKeyWarnings
|
|
455
|
+
* Finds unknown keys under `sources.<name>` in a raw (unparsed) config, e.g. a misspelt or unsupported `batchUrl`, which the schema would silently drop. Pure.
|
|
456
|
+
*/
|
|
457
|
+
/** Keys a source entry may carry. */
|
|
458
|
+
const KNOWN_SOURCE_KEYS = new Set(Object.keys(sourceConfigSchema.shape));
|
|
459
|
+
/**
|
|
460
|
+
* Find keys under `sources.<name>` that the source schema does not define.
|
|
461
|
+
* Validation strips them, so without this warning a setting such as
|
|
462
|
+
* `batchUrl` would be ignored without notice.
|
|
463
|
+
*
|
|
464
|
+
* @param rawConfig - The config as written (before schema parsing).
|
|
465
|
+
* @returns One warning per unknown key, in config order.
|
|
466
|
+
*/
|
|
467
|
+
function findUnknownSourceKeys(rawConfig) {
|
|
468
|
+
if (!isPlainObject$1(rawConfig) || !isPlainObject$1(rawConfig.sources))
|
|
469
|
+
return [];
|
|
470
|
+
const warnings = [];
|
|
471
|
+
for (const [name, source] of Object.entries(rawConfig.sources)) {
|
|
472
|
+
if (!isPlainObject$1(source))
|
|
473
|
+
continue;
|
|
474
|
+
for (const key of Object.keys(source)) {
|
|
475
|
+
if (KNOWN_SOURCE_KEYS.has(key))
|
|
476
|
+
continue;
|
|
477
|
+
warnings.push({
|
|
478
|
+
path: `sources.${name}.${key}`,
|
|
479
|
+
message: `Unknown source setting "${key}" is ignored (supported: ${[...KNOWN_SOURCE_KEYS].join(', ')}).`,
|
|
480
|
+
});
|
|
481
|
+
}
|
|
482
|
+
}
|
|
483
|
+
return warnings;
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
/**
|
|
487
|
+
* @module config/loadConfig
|
|
488
|
+
* Loads the watcher config via cosmiconfig: schema validation, rule/map file resolution, defaults, env substitution and secret checks; reports non-fatal warnings (unknown source keys, reserved rule field names) through onWarning. Reads files.
|
|
489
|
+
*/
|
|
86
490
|
const MODULE_NAME = 'jeeves-watcher';
|
|
87
491
|
/**
|
|
88
492
|
* Merge sensible defaults into a loaded configuration.
|
|
@@ -105,10 +509,11 @@ function applyDefaults(raw) {
|
|
|
105
509
|
* Load the jeeves-watcher configuration.
|
|
106
510
|
*
|
|
107
511
|
* @param configPath - Optional explicit path to a config file.
|
|
512
|
+
* @param options - Optional warning sink.
|
|
108
513
|
* @returns The loaded configuration.
|
|
109
514
|
* @throws If no configuration is found or validation fails.
|
|
110
515
|
*/
|
|
111
|
-
async function loadConfig(configPath) {
|
|
516
|
+
async function loadConfig(configPath, options = {}) {
|
|
112
517
|
const explorer = cosmiconfig(MODULE_NAME);
|
|
113
518
|
const result = configPath
|
|
114
519
|
? await explorer.load(configPath)
|
|
@@ -145,7 +550,15 @@ async function loadConfig(configPath) {
|
|
|
145
550
|
}
|
|
146
551
|
}
|
|
147
552
|
const withDefaults = applyDefaults(validated);
|
|
148
|
-
|
|
553
|
+
const substituted = substituteEnvVars(withDefaults);
|
|
554
|
+
assertResolvedSecrets(substituted);
|
|
555
|
+
const warnings = [
|
|
556
|
+
...findUnknownSourceKeys(result.config),
|
|
557
|
+
...findReservedFieldWarnings(substituted, configDir),
|
|
558
|
+
];
|
|
559
|
+
for (const warning of warnings)
|
|
560
|
+
options.onWarning?.(warning);
|
|
561
|
+
return substituted;
|
|
149
562
|
}
|
|
150
563
|
catch (error) {
|
|
151
564
|
if (error instanceof ZodError) {
|
|
@@ -209,8 +622,102 @@ function migrateConfigPath(configDir) {
|
|
|
209
622
|
|
|
210
623
|
/**
|
|
211
624
|
* @module app/shutdown
|
|
212
|
-
*
|
|
625
|
+
* Shutdown orchestration: closes the API server first (no new notifies), stops producers, drains and stops queues with timeouts, and closes stores only when no abandoned run is still in flight. Installs SIGINT/SIGTERM handlers.
|
|
626
|
+
*/
|
|
627
|
+
/**
|
|
628
|
+
* Wait for a promise, giving up after `timeoutMs`.
|
|
629
|
+
*
|
|
630
|
+
* @param promise - The work to wait for (its rejection counts as settled).
|
|
631
|
+
* @param timeoutMs - Maximum wait in milliseconds.
|
|
632
|
+
* @returns `true` when settled in time, `false` on timeout.
|
|
633
|
+
*/
|
|
634
|
+
async function settlesWithin(promise, timeoutMs) {
|
|
635
|
+
let timer;
|
|
636
|
+
const settled = await Promise.race([
|
|
637
|
+
promise.then(() => true, () => true),
|
|
638
|
+
new Promise((resolve) => {
|
|
639
|
+
timer = setTimeout(() => {
|
|
640
|
+
resolve(false);
|
|
641
|
+
}, timeoutMs);
|
|
642
|
+
}),
|
|
643
|
+
]);
|
|
644
|
+
clearTimeout(timer);
|
|
645
|
+
return settled;
|
|
646
|
+
}
|
|
647
|
+
/**
|
|
648
|
+
* Wait for a step, warning when it times out.
|
|
649
|
+
*
|
|
650
|
+
* @returns `true` when the step settled in time.
|
|
651
|
+
*/
|
|
652
|
+
async function step(label, work, timeoutMs, logger) {
|
|
653
|
+
const done = await settlesWithin(work, timeoutMs);
|
|
654
|
+
if (!done) {
|
|
655
|
+
logger?.warn({ timeoutMs, step: label }, `${label} timeout hit, forcing shutdown`);
|
|
656
|
+
}
|
|
657
|
+
return done;
|
|
658
|
+
}
|
|
659
|
+
/**
|
|
660
|
+
* Drain a queue, then stop it (whatever the drain did); each step bounded.
|
|
661
|
+
*
|
|
662
|
+
* @returns `true` when the stop finished, i.e. no run is still in flight.
|
|
663
|
+
*/
|
|
664
|
+
async function drainThenStop(label, queue, timeoutMs, logger) {
|
|
665
|
+
await step(`${label} drain`, queue.drain(), timeoutMs, logger);
|
|
666
|
+
return step(`${label} stop`, queue.stop(), timeoutMs, logger);
|
|
667
|
+
}
|
|
668
|
+
/**
|
|
669
|
+
* Drain and stop the file queue, then the source notify queue. Stopping
|
|
670
|
+
* drops work the drain did not reach, refuses new work and waits for
|
|
671
|
+
* in-flight runs; each step is bounded by `timeoutMs`.
|
|
672
|
+
*
|
|
673
|
+
* @param queues - The queues (absent ones are skipped).
|
|
674
|
+
* @param timeoutMs - Per-step timeout in milliseconds.
|
|
675
|
+
* @param logger - Logger for timeout warnings.
|
|
676
|
+
* @returns `true` when every stop finished, i.e. no run is still in flight.
|
|
677
|
+
*/
|
|
678
|
+
async function drainQueues(queues, timeoutMs, logger) {
|
|
679
|
+
let idle = true;
|
|
680
|
+
if (queues.queue) {
|
|
681
|
+
idle = await drainThenStop('Queue', queues.queue, timeoutMs, logger);
|
|
682
|
+
}
|
|
683
|
+
if (queues.sourceQueue) {
|
|
684
|
+
const { sourceQueue } = queues;
|
|
685
|
+
const stopped = await drainThenStop('Source queue', sourceQueue, timeoutMs, logger);
|
|
686
|
+
idle = stopped && idle;
|
|
687
|
+
}
|
|
688
|
+
return idle;
|
|
689
|
+
}
|
|
690
|
+
/**
|
|
691
|
+
* Stop every component in a safe order: close the API server (bounded), stop
|
|
692
|
+
* producers, drain and stop the queues (bounded), flush the values index, and
|
|
693
|
+
* close stores. When a queue step or a producer's bounded stop timed out, runs may still be in flight, so
|
|
694
|
+
* stores are left open (the process exits) rather than closed under them.
|
|
695
|
+
*
|
|
696
|
+
* @param components - The components.
|
|
697
|
+
* @param timeoutMs - Per-step timeout in milliseconds.
|
|
698
|
+
* @param logger - Logger for warnings.
|
|
213
699
|
*/
|
|
700
|
+
async function shutdownComponents(components, timeoutMs, logger) {
|
|
701
|
+
const { server, producers = [], valuesManager, stores = [] } = components;
|
|
702
|
+
if (server)
|
|
703
|
+
await step('API server close', server.close(), timeoutMs, logger);
|
|
704
|
+
let producersIdle = true;
|
|
705
|
+
for (const producer of producers) {
|
|
706
|
+
if ((await producer?.stop()) === false)
|
|
707
|
+
producersIdle = false;
|
|
708
|
+
}
|
|
709
|
+
const queuesIdle = await drainQueues(components, timeoutMs, logger);
|
|
710
|
+
const idle = producersIdle && queuesIdle;
|
|
711
|
+
valuesManager?.stopAutoFlush();
|
|
712
|
+
valuesManager?.flush();
|
|
713
|
+
if (idle) {
|
|
714
|
+
for (const store of stores)
|
|
715
|
+
store?.close();
|
|
716
|
+
}
|
|
717
|
+
else {
|
|
718
|
+
logger?.warn('Runs still in flight after shutdown timeout; stores left open');
|
|
719
|
+
}
|
|
720
|
+
}
|
|
214
721
|
/**
|
|
215
722
|
* Install process signal handlers.
|
|
216
723
|
*
|
|
@@ -262,10 +769,16 @@ async function startFromConfig(configPath, descriptor) {
|
|
|
262
769
|
resolvedPath = undefined;
|
|
263
770
|
}
|
|
264
771
|
}
|
|
265
|
-
|
|
772
|
+
// The logger is built from the config, so load-time warnings go to the
|
|
773
|
+
// console like the migration warnings above.
|
|
774
|
+
const config = await loadConfig(resolvedPath, {
|
|
775
|
+
onWarning: (warning) => {
|
|
776
|
+
console.warn(`[jeeves-watcher] ${warning.path}: ${warning.message}`);
|
|
777
|
+
},
|
|
778
|
+
});
|
|
266
779
|
// Dynamic import breaks the circular dependency between this module
|
|
267
780
|
// and app/index.ts (which defines JeevesWatcher and re-exports startFromConfig).
|
|
268
|
-
const { JeevesWatcher } = await import('./index-
|
|
781
|
+
const { JeevesWatcher } = await import('./index-CGVLEt3K.js');
|
|
269
782
|
const app = new JeevesWatcher(config, resolvedPath, descriptor);
|
|
270
783
|
installShutdownHandlers(() => app.stop());
|
|
271
784
|
await app.start();
|
|
@@ -273,1183 +786,748 @@ async function startFromConfig(configPath, descriptor) {
|
|
|
273
786
|
}
|
|
274
787
|
|
|
275
788
|
/**
|
|
276
|
-
* @module
|
|
277
|
-
*
|
|
789
|
+
* @module WatcherError
|
|
790
|
+
* The single error class thrown by the SDK: client-side validation, timeouts, network failures, non-2xx responses and unparseable responses. No I/O.
|
|
278
791
|
*/
|
|
279
792
|
/**
|
|
280
|
-
*
|
|
281
|
-
*
|
|
793
|
+
* The one error type thrown by every SDK call. The SDK never retries; inspect
|
|
794
|
+
* `status` and `code` to decide whether to.
|
|
282
795
|
*/
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
796
|
+
class WatcherError extends Error {
|
|
797
|
+
/** Machine-readable code: a {@link WatcherClientErrorCode} or a server code. */
|
|
798
|
+
code;
|
|
799
|
+
/** HTTP status, when a response was received. */
|
|
800
|
+
status;
|
|
801
|
+
/** Structured details (Zod issues, server `details`, or the raw body). */
|
|
802
|
+
details;
|
|
803
|
+
/**
|
|
804
|
+
* Delay the server asked for before a retry (`Retry-After` on a `503` or
|
|
805
|
+
* `429`), in milliseconds, clamped to 0..3,600,000 (D78). The SDK never
|
|
806
|
+
* waits or retries itself.
|
|
807
|
+
*/
|
|
808
|
+
retryAfterMs;
|
|
809
|
+
/**
|
|
810
|
+
* Create a watcher error.
|
|
811
|
+
*
|
|
812
|
+
* @param init - Code, message and optional status, details, retry delay and cause.
|
|
813
|
+
*/
|
|
814
|
+
constructor(init) {
|
|
815
|
+
super(init.message, init.cause === undefined ? {} : { cause: init.cause });
|
|
816
|
+
this.name = 'WatcherError';
|
|
817
|
+
this.code = init.code;
|
|
818
|
+
this.status = init.status;
|
|
819
|
+
this.details = init.details;
|
|
820
|
+
this.retryAfterMs = init.retryAfterMs;
|
|
295
821
|
}
|
|
296
|
-
return undefined;
|
|
297
822
|
}
|
|
298
823
|
/**
|
|
299
|
-
*
|
|
300
|
-
*
|
|
824
|
+
* Type guard for {@link WatcherError}.
|
|
825
|
+
*
|
|
826
|
+
* @param value - Any value (typically a caught error).
|
|
827
|
+
* @returns `true` when `value` is a {@link WatcherError}.
|
|
301
828
|
*/
|
|
302
|
-
function
|
|
303
|
-
|
|
304
|
-
try {
|
|
305
|
-
return statSync(absPath).isDirectory() ? absPath : dirname(absPath);
|
|
306
|
-
}
|
|
307
|
-
catch {
|
|
308
|
-
// ignore
|
|
309
|
-
}
|
|
310
|
-
// If this is a glob, fall back to the non-glob prefix.
|
|
311
|
-
const globMatch = /[*?[{]/.exec(watchPath);
|
|
312
|
-
if (!globMatch)
|
|
313
|
-
return undefined;
|
|
314
|
-
const prefix = watchPath.slice(0, globMatch.index);
|
|
315
|
-
const trimmed = prefix.trim();
|
|
316
|
-
const baseDir = trimmed.length === 0
|
|
317
|
-
? '.'
|
|
318
|
-
: trimmed.endsWith('/') || trimmed.endsWith('\\')
|
|
319
|
-
? trimmed
|
|
320
|
-
: dirname(trimmed);
|
|
321
|
-
const resolved = resolve(baseDir);
|
|
322
|
-
if (!existsSync(resolved))
|
|
323
|
-
return undefined;
|
|
324
|
-
return resolved;
|
|
829
|
+
function isWatcherError(value) {
|
|
830
|
+
return value instanceof WatcherError;
|
|
325
831
|
}
|
|
832
|
+
|
|
326
833
|
/**
|
|
327
|
-
*
|
|
328
|
-
*
|
|
834
|
+
* @module documents
|
|
835
|
+
* The `documents` method group over the catalog's `notifyDocuments` (POST /documents, 202 or ?wait=true) and `deleteDocuments` (POST /documents/delete) entries (D60). Network I/O via the client methods it wraps.
|
|
329
836
|
*/
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
if (statSync(fullPath).isDirectory()) {
|
|
349
|
-
results.push(...findGitignoreFiles(fullPath));
|
|
837
|
+
/**
|
|
838
|
+
* Build the `documents` method group.
|
|
839
|
+
*
|
|
840
|
+
* @param notifyDocuments - Client method of the `notifyDocuments` entry.
|
|
841
|
+
* @param deleteDocuments - Client method of the `deleteDocuments` entry.
|
|
842
|
+
* @returns The {@link DocumentsApi}.
|
|
843
|
+
*/
|
|
844
|
+
function createDocumentsApi(notifyDocuments, deleteDocuments) {
|
|
845
|
+
return {
|
|
846
|
+
notify: (async (documents, options) => {
|
|
847
|
+
const wait = options?.wait === true;
|
|
848
|
+
const body = await notifyDocuments(wait ? { documents, wait: 'true' } : { documents });
|
|
849
|
+
if (wait !== 'results' in body) {
|
|
850
|
+
throw new WatcherError({
|
|
851
|
+
code: 'invalid_response',
|
|
852
|
+
message: `notifyDocuments answered without ${wait ? 'per-document results' : 'accepted references'}`,
|
|
853
|
+
details: body,
|
|
854
|
+
});
|
|
350
855
|
}
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
}
|
|
356
|
-
return results;
|
|
856
|
+
return body;
|
|
857
|
+
}),
|
|
858
|
+
delete: (request) => deleteDocuments(request),
|
|
859
|
+
};
|
|
357
860
|
}
|
|
861
|
+
|
|
358
862
|
/**
|
|
359
|
-
*
|
|
863
|
+
* @module options
|
|
864
|
+
* Client options for createWatcherClient and their validation/normalisation (base URL, API key, timeout, fetch injection). Pure; throws WatcherError invalid_options.
|
|
360
865
|
*/
|
|
361
|
-
function parseGitignore(gitignorePath) {
|
|
362
|
-
const content = readFileSync(gitignorePath, 'utf8');
|
|
363
|
-
return ignore().add(content);
|
|
364
|
-
}
|
|
365
866
|
/**
|
|
366
|
-
*
|
|
867
|
+
* Default request timeout in milliseconds. Shorter than the server's
|
|
868
|
+
* longest waits (`RootGitLock` 30 s, document lock 10 s): raise `timeoutMs`
|
|
869
|
+
* for state-changing VCS calls and `documents.notify(..., { wait: true })`.
|
|
367
870
|
*/
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
}
|
|
871
|
+
const DEFAULT_TIMEOUT_MS = 10_000;
|
|
872
|
+
const invalid = (message) => new WatcherError({ code: 'invalid_options', message });
|
|
371
873
|
/**
|
|
372
|
-
*
|
|
373
|
-
*
|
|
374
|
-
* Eliminates the repeated inline pattern:
|
|
375
|
-
* ```ts
|
|
376
|
-
* gitignoreFilter ? (filePath: string) => gitignoreFilter!.isIgnored(filePath) : undefined
|
|
377
|
-
* ```
|
|
874
|
+
* Adapt a {@link FetchLike} to the `fetch` signature jeeves's catalog client
|
|
875
|
+
* takes. The client always passes a URL string and an init object.
|
|
378
876
|
*
|
|
379
|
-
* @param
|
|
380
|
-
* @returns A
|
|
877
|
+
* @param fetchImpl - The caller's fetch.
|
|
878
|
+
* @returns A `typeof fetch` that calls `fetchImpl`.
|
|
381
879
|
*/
|
|
382
|
-
function
|
|
383
|
-
return
|
|
880
|
+
function adaptFetch(fetchImpl) {
|
|
881
|
+
return (input, init) => fetchImpl(typeof input === 'string'
|
|
882
|
+
? input
|
|
883
|
+
: input instanceof URL
|
|
884
|
+
? input.href
|
|
885
|
+
: input.url, init ?? {});
|
|
384
886
|
}
|
|
385
887
|
/**
|
|
386
|
-
*
|
|
387
|
-
*
|
|
888
|
+
* Validate and normalise client options.
|
|
889
|
+
*
|
|
890
|
+
* @param options - Caller options.
|
|
891
|
+
* @returns Resolved options.
|
|
892
|
+
* @throws {@link WatcherError} `invalid_options` on a bad base URL (not http(s), or carrying a query string or fragment), empty API key, non-positive timeout or missing `fetch`.
|
|
388
893
|
*/
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
*
|
|
394
|
-
* @param watchPaths - Absolute paths being watched (directories or globs resolved to roots).
|
|
395
|
-
*/
|
|
396
|
-
constructor(watchPaths) {
|
|
397
|
-
this.scan(watchPaths);
|
|
894
|
+
function resolveClientOptions(options) {
|
|
895
|
+
const { baseUrl, apiKey, timeoutMs = DEFAULT_TIMEOUT_MS } = options;
|
|
896
|
+
if (!URL.canParse(baseUrl) || !/^https?:$/.test(new URL(baseUrl).protocol)) {
|
|
897
|
+
throw invalid(`baseUrl must be an http(s) URL, got "${baseUrl}"`);
|
|
398
898
|
}
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
this.repos.clear();
|
|
404
|
-
const scannedDirs = new Set();
|
|
405
|
-
for (const watchPath of watchPaths) {
|
|
406
|
-
const scanDir = watchPathToScanDir(watchPath);
|
|
407
|
-
if (!scanDir)
|
|
408
|
-
continue;
|
|
409
|
-
if (scannedDirs.has(scanDir))
|
|
410
|
-
continue;
|
|
411
|
-
scannedDirs.add(scanDir);
|
|
412
|
-
const repoRoot = findRepoRoot(scanDir);
|
|
413
|
-
if (!repoRoot) {
|
|
414
|
-
// No git repo found. Check for a standalone .gitignore at the scan dir.
|
|
415
|
-
// This handles directories like OpenClaw workspaces that have .gitignore
|
|
416
|
-
// but no .git directory.
|
|
417
|
-
const standaloneGitignore = join(scanDir, '.gitignore');
|
|
418
|
-
if (existsSync(standaloneGitignore) && !this.repos.has(scanDir)) {
|
|
419
|
-
const entry = {
|
|
420
|
-
dir: scanDir,
|
|
421
|
-
ig: parseGitignore(standaloneGitignore),
|
|
422
|
-
};
|
|
423
|
-
this.repos.set(scanDir, { root: scanDir, entries: [entry] });
|
|
424
|
-
}
|
|
425
|
-
continue;
|
|
426
|
-
}
|
|
427
|
-
if (this.repos.has(repoRoot))
|
|
428
|
-
continue;
|
|
429
|
-
const gitignoreFiles = findGitignoreFiles(repoRoot);
|
|
430
|
-
const entries = gitignoreFiles.map((gf) => ({
|
|
431
|
-
dir: dirname(gf),
|
|
432
|
-
ig: parseGitignore(gf),
|
|
433
|
-
}));
|
|
434
|
-
// Sort deepest-first so nested `.gitignore` files are checked first
|
|
435
|
-
entries.sort((a, b) => b.dir.length - a.dir.length);
|
|
436
|
-
this.repos.set(repoRoot, { root: repoRoot, entries });
|
|
437
|
-
}
|
|
899
|
+
// Entry paths are appended to baseUrl, so a query or fragment would
|
|
900
|
+
// swallow them. Check the raw string: URL drops an empty `?` or `#`.
|
|
901
|
+
if (/[?#]/.test(baseUrl)) {
|
|
902
|
+
throw invalid(`baseUrl must not contain a query string or fragment, got "${baseUrl}"`);
|
|
438
903
|
}
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
*
|
|
442
|
-
* @param filePath - Absolute file path to check.
|
|
443
|
-
* @returns `true` if the file should be ignored.
|
|
444
|
-
*/
|
|
445
|
-
isIgnored(filePath) {
|
|
446
|
-
const absPath = resolve(filePath);
|
|
447
|
-
for (const [, repo] of this.repos) {
|
|
448
|
-
// Check if file is within this repo
|
|
449
|
-
const relToRepo = relative(repo.root, absPath);
|
|
450
|
-
// On Windows, path.relative() across drives (e.g. D:\ → J:\) produces
|
|
451
|
-
// an absolute path with a drive letter instead of a relative one. The
|
|
452
|
-
// `ignore` library rejects these with a RangeError. Skip repos on
|
|
453
|
-
// different drives to avoid cross-drive gitignore mismatches.
|
|
454
|
-
if (relToRepo.startsWith('..') ||
|
|
455
|
-
relToRepo.startsWith(resolve('/')) ||
|
|
456
|
-
/^[a-zA-Z]:/.test(relToRepo)) {
|
|
457
|
-
continue;
|
|
458
|
-
}
|
|
459
|
-
// Check each `.gitignore` entry (deepest-first)
|
|
460
|
-
for (const entry of repo.entries) {
|
|
461
|
-
const relToEntry = relative(entry.dir, absPath);
|
|
462
|
-
if (relToEntry.startsWith('..') || /^[a-zA-Z]:/.test(relToEntry))
|
|
463
|
-
continue;
|
|
464
|
-
const normalized = toForwardSlash(relToEntry);
|
|
465
|
-
if (entry.ig.ignores(normalized)) {
|
|
466
|
-
return true;
|
|
467
|
-
}
|
|
468
|
-
}
|
|
469
|
-
}
|
|
470
|
-
return false;
|
|
904
|
+
if (apiKey?.length === 0) {
|
|
905
|
+
throw invalid('apiKey must be a non-empty string when set');
|
|
471
906
|
}
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
invalidate(gitignorePath) {
|
|
479
|
-
const absPath = resolve(gitignorePath);
|
|
480
|
-
const gitignoreDir = dirname(absPath);
|
|
481
|
-
for (const [, repo] of this.repos) {
|
|
482
|
-
const relToRepo = relative(repo.root, gitignoreDir);
|
|
483
|
-
if (relToRepo.startsWith('..'))
|
|
484
|
-
continue;
|
|
485
|
-
// Remove old entry for this directory
|
|
486
|
-
repo.entries = repo.entries.filter((e) => e.dir !== gitignoreDir);
|
|
487
|
-
// Re-parse if file still exists
|
|
488
|
-
if (existsSync(absPath)) {
|
|
489
|
-
repo.entries.push({ dir: gitignoreDir, ig: parseGitignore(absPath) });
|
|
490
|
-
// Re-sort deepest-first
|
|
491
|
-
repo.entries.sort((a, b) => b.dir.length - a.dir.length);
|
|
492
|
-
}
|
|
493
|
-
return;
|
|
494
|
-
}
|
|
495
|
-
// If not in any known repo, check if it's in a repo we haven't scanned
|
|
496
|
-
const repoRoot = findRepoRoot(gitignoreDir);
|
|
497
|
-
if (repoRoot && existsSync(absPath)) {
|
|
498
|
-
const entries = [
|
|
499
|
-
{ dir: gitignoreDir, ig: parseGitignore(absPath) },
|
|
500
|
-
];
|
|
501
|
-
if (this.repos.has(repoRoot)) {
|
|
502
|
-
const repo = this.repos.get(repoRoot);
|
|
503
|
-
repo.entries.push(entries[0]);
|
|
504
|
-
repo.entries.sort((a, b) => b.dir.length - a.dir.length);
|
|
505
|
-
}
|
|
506
|
-
else {
|
|
507
|
-
this.repos.set(repoRoot, { root: repoRoot, entries });
|
|
508
|
-
}
|
|
509
|
-
}
|
|
907
|
+
if (!Number.isFinite(timeoutMs) || timeoutMs <= 0) {
|
|
908
|
+
throw invalid('timeoutMs must be a positive number');
|
|
909
|
+
}
|
|
910
|
+
const fetchImpl = options.fetch ?? globalThis.fetch;
|
|
911
|
+
if (fetchImpl === undefined) {
|
|
912
|
+
throw invalid('No fetch implementation available; pass options.fetch');
|
|
510
913
|
}
|
|
914
|
+
return {
|
|
915
|
+
baseUrl: baseUrl.replace(/\/+$/, ''),
|
|
916
|
+
apiKey,
|
|
917
|
+
timeoutMs,
|
|
918
|
+
fetch: adaptFetch(fetchImpl),
|
|
919
|
+
};
|
|
511
920
|
}
|
|
512
921
|
|
|
513
922
|
/**
|
|
514
|
-
* @module
|
|
515
|
-
*
|
|
923
|
+
* @module toWatcherError
|
|
924
|
+
* The one mapping from jeeves's `CatalogClientError` to {@link WatcherError}: the server body's `code` wins; client-side failures get the SDK's own codes; `retryAfterMs` is clamped. Pure.
|
|
516
925
|
*/
|
|
926
|
+
/** Largest `retryAfterMs` the SDK reports: one hour (D78). */
|
|
927
|
+
const MAX_RETRY_AFTER_MS = 3_600_000;
|
|
517
928
|
/**
|
|
518
|
-
*
|
|
929
|
+
* Clamp a server-requested delay to 0..{@link MAX_RETRY_AFTER_MS}.
|
|
519
930
|
*
|
|
520
|
-
*
|
|
521
|
-
*
|
|
522
|
-
* @param path - The path string to normalize.
|
|
523
|
-
* @returns The path with forward slashes.
|
|
931
|
+
* @param ms - jeeves's `CatalogClientError.retryAfterMs` (uncapped).
|
|
932
|
+
* @returns The clamped delay, or `undefined` when the server sent none.
|
|
524
933
|
*/
|
|
525
|
-
function
|
|
526
|
-
return
|
|
934
|
+
function clampRetryAfter(ms) {
|
|
935
|
+
return ms === undefined
|
|
936
|
+
? undefined
|
|
937
|
+
: Math.min(Math.max(ms, 0), MAX_RETRY_AFTER_MS);
|
|
527
938
|
}
|
|
528
|
-
|
|
939
|
+
const stringField = (body, key) => {
|
|
940
|
+
const value = body?.[key];
|
|
941
|
+
return typeof value === 'string' && value.length > 0 ? value : undefined;
|
|
942
|
+
};
|
|
943
|
+
/** Longest message kept from a body without a `code` (a proxy's error page, say); the whole body stays in `details`. */
|
|
944
|
+
const MAX_UNCODED_MESSAGE_LENGTH = 200;
|
|
529
945
|
/**
|
|
530
|
-
*
|
|
531
|
-
*
|
|
946
|
+
* Code, message and details of a non-2xx response body.
|
|
947
|
+
*
|
|
948
|
+
* @param status - HTTP status.
|
|
949
|
+
* @param body - jeeves's parsed error body (`{ error, ... }`).
|
|
950
|
+
* @returns The mapped parts.
|
|
532
951
|
*/
|
|
952
|
+
function serverError(status, body) {
|
|
953
|
+
const error = stringField(body, 'error');
|
|
954
|
+
const extra = stringField(body, 'message');
|
|
955
|
+
const message = error === undefined
|
|
956
|
+
? (extra ?? `HTTP ${String(status)}`)
|
|
957
|
+
: extra !== undefined && extra !== error
|
|
958
|
+
? `${error}: ${extra}`
|
|
959
|
+
: error;
|
|
960
|
+
const code = stringField(body, 'code');
|
|
961
|
+
return {
|
|
962
|
+
code: code ?? `http_${String(status)}`,
|
|
963
|
+
message: code === undefined && message.length > MAX_UNCODED_MESSAGE_LENGTH
|
|
964
|
+
? `${message.slice(0, MAX_UNCODED_MESSAGE_LENGTH)}...`
|
|
965
|
+
: message,
|
|
966
|
+
details: body !== undefined && 'details' in body ? body.details : body,
|
|
967
|
+
};
|
|
968
|
+
}
|
|
533
969
|
/**
|
|
534
|
-
*
|
|
535
|
-
* A path is watched if it matches at least one `paths` glob
|
|
536
|
-
* AND does not match any `ignored` glob. Mirrors chokidar's
|
|
537
|
-
* inclusion/exclusion logic.
|
|
970
|
+
* The SDK code of a failure that produced no response.
|
|
538
971
|
*
|
|
539
|
-
* @param
|
|
540
|
-
* @
|
|
541
|
-
* @param ignoredPaths - Glob patterns that exclude paths (optional).
|
|
542
|
-
* @returns `true` if the file is within watched scope.
|
|
543
|
-
*/
|
|
544
|
-
function isPathWatched(filePath, watchPaths, ignoredPaths) {
|
|
545
|
-
const normalised = normalizeSlashes(filePath);
|
|
546
|
-
const opts = { dot: true, nocase: process.platform === 'win32' };
|
|
547
|
-
const isIncluded = picomatch(watchPaths, opts);
|
|
548
|
-
if (!isIncluded(normalised))
|
|
549
|
-
return false;
|
|
550
|
-
if (ignoredPaths && ignoredPaths.length > 0) {
|
|
551
|
-
const isExcluded = picomatch(ignoredPaths, opts);
|
|
552
|
-
if (isExcluded(normalised))
|
|
553
|
-
return false;
|
|
554
|
-
}
|
|
555
|
-
return true;
|
|
556
|
-
}
|
|
557
|
-
|
|
558
|
-
/**
|
|
559
|
-
* @module util/normalizeError
|
|
972
|
+
* @param cause - The underlying error.
|
|
973
|
+
* @returns `timeout`, `invalid_response` (an unparseable 2xx body) or `network_error`.
|
|
560
974
|
*
|
|
561
|
-
*
|
|
975
|
+
* @remarks
|
|
976
|
+
* Assumes jeeves's client wraps a `SyntaxError` only from parsing a 2xx
|
|
977
|
+
* body; a caller's own `fetch` throwing one before any response would also
|
|
978
|
+
* read as `invalid_response`.
|
|
562
979
|
*/
|
|
980
|
+
function noResponseCode(cause) {
|
|
981
|
+
if (cause instanceof Error && cause.name === 'TimeoutError')
|
|
982
|
+
return 'timeout';
|
|
983
|
+
if (cause instanceof SyntaxError)
|
|
984
|
+
return 'invalid_response';
|
|
985
|
+
return 'network_error';
|
|
986
|
+
}
|
|
563
987
|
/**
|
|
564
|
-
* Convert
|
|
565
|
-
* Pino's built-in `err` serializer requires an Error instance to extract message/stack.
|
|
988
|
+
* Convert a failed catalog call to a {@link WatcherError}.
|
|
566
989
|
*
|
|
567
|
-
* @param error -
|
|
568
|
-
* @
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
if (
|
|
574
|
-
return new
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
990
|
+
* @param error - What the catalog client threw.
|
|
991
|
+
* @param operation - Catalog entry name (used in messages).
|
|
992
|
+
* @param timeoutMs - The client's timeout (used in the timeout message).
|
|
993
|
+
* @returns The error to throw.
|
|
994
|
+
*/
|
|
995
|
+
function toWatcherError(error, operation, timeoutMs) {
|
|
996
|
+
if (!(error instanceof CatalogClientError)) {
|
|
997
|
+
return new WatcherError({
|
|
998
|
+
code: 'network_error',
|
|
999
|
+
message: `${operation} request failed: ${String(error)}`,
|
|
1000
|
+
cause: error,
|
|
1001
|
+
});
|
|
1002
|
+
}
|
|
1003
|
+
const retryAfterMs = clampRetryAfter(error.retryAfterMs);
|
|
1004
|
+
const common = {
|
|
1005
|
+
cause: error,
|
|
1006
|
+
...(retryAfterMs === undefined ? {} : { retryAfterMs }),
|
|
1007
|
+
};
|
|
1008
|
+
if (error.status >= 300) {
|
|
1009
|
+
return new WatcherError({
|
|
1010
|
+
...serverError(error.status, error.body),
|
|
1011
|
+
status: error.status,
|
|
1012
|
+
...common,
|
|
1013
|
+
});
|
|
1014
|
+
}
|
|
1015
|
+
if (error.issues !== undefined) {
|
|
1016
|
+
const responded = error.status > 0;
|
|
1017
|
+
return new WatcherError({
|
|
1018
|
+
code: responded ? 'invalid_response' : 'invalid_request',
|
|
1019
|
+
message: responded
|
|
1020
|
+
? `${operation} response did not match the expected shape`
|
|
1021
|
+
: error.message,
|
|
1022
|
+
details: error.issues,
|
|
1023
|
+
...(responded ? { status: error.status } : {}),
|
|
1024
|
+
...common,
|
|
1025
|
+
});
|
|
1026
|
+
}
|
|
1027
|
+
const code = noResponseCode(error.cause);
|
|
1028
|
+
const message = code === 'timeout'
|
|
1029
|
+
? `${operation} timed out after ${String(timeoutMs)} ms`
|
|
1030
|
+
: code === 'invalid_response'
|
|
1031
|
+
? `${operation} returned a response that is not JSON`
|
|
1032
|
+
: `${operation} request failed: ${error.cause instanceof Error ? error.cause.message : String(error.cause)}`;
|
|
1033
|
+
return new WatcherError({ code, message, ...common });
|
|
581
1034
|
}
|
|
582
1035
|
|
|
583
1036
|
/**
|
|
584
|
-
* @module
|
|
585
|
-
*
|
|
1037
|
+
* @module createWatcherClient
|
|
1038
|
+
* Typed watcher API client built on jeeves's catalog client: one method per `WATCHER_CATALOG` entry, named by the entry, plus the `documents` group; every failure is a {@link WatcherError}. Network I/O via fetch.
|
|
586
1039
|
*/
|
|
587
1040
|
/**
|
|
588
|
-
*
|
|
1041
|
+
* Create a watcher API client.
|
|
589
1042
|
*
|
|
590
|
-
* @
|
|
591
|
-
*
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
onAbort();
|
|
613
|
-
return;
|
|
614
|
-
}
|
|
615
|
-
signal.addEventListener('abort', onAbort, { once: true });
|
|
616
|
-
}
|
|
1043
|
+
* @example
|
|
1044
|
+
* ```ts
|
|
1045
|
+
* const watcher = createWatcherClient({
|
|
1046
|
+
* baseUrl: 'http://watcher.internal.example:1936',
|
|
1047
|
+
* apiKey: process.env.WATCHER_API_KEY,
|
|
1048
|
+
* });
|
|
1049
|
+
* await watcher.documents.notify([{ source: 'app://chat/thread/123', rule: 'chat-thread' }]);
|
|
1050
|
+
* ```
|
|
1051
|
+
*
|
|
1052
|
+
* @param options - Base URL, optional API key, timeout (default 10000 ms) and `fetch`.
|
|
1053
|
+
* @returns A {@link WatcherClient}.
|
|
1054
|
+
* @throws {@link WatcherError} `invalid_options` when the options are invalid.
|
|
1055
|
+
*/
|
|
1056
|
+
function createWatcherClient(options) {
|
|
1057
|
+
const resolved = resolveClientOptions(options);
|
|
1058
|
+
const catalogClient = createCatalogClient(WATCHER_CATALOG, {
|
|
1059
|
+
baseUrl: resolved.baseUrl,
|
|
1060
|
+
...(resolved.apiKey === undefined
|
|
1061
|
+
? {}
|
|
1062
|
+
: { auth: { bearer: resolved.apiKey } }),
|
|
1063
|
+
timeoutMs: resolved.timeoutMs,
|
|
1064
|
+
fetch: resolved.fetch,
|
|
617
1065
|
});
|
|
1066
|
+
const methods = Object.fromEntries(WATCHER_CATALOG.entries.map(({ name }) => {
|
|
1067
|
+
const call = catalogClient[name];
|
|
1068
|
+
const method = (input) => call(input).catch((error) => {
|
|
1069
|
+
throw toWatcherError(error, name, resolved.timeoutMs);
|
|
1070
|
+
});
|
|
1071
|
+
return [name, method];
|
|
1072
|
+
}));
|
|
1073
|
+
const { notifyDocuments, deleteDocuments, ...rest } = methods;
|
|
1074
|
+
return {
|
|
1075
|
+
...rest,
|
|
1076
|
+
documents: createDocumentsApi(notifyDocuments, deleteDocuments),
|
|
1077
|
+
};
|
|
618
1078
|
}
|
|
619
1079
|
|
|
620
1080
|
/**
|
|
621
|
-
* @module
|
|
622
|
-
*
|
|
623
|
-
*/
|
|
624
|
-
function computeDelayMs(attempt, baseDelayMs, maxDelayMs, jitter = 0) {
|
|
625
|
-
const exp = Math.max(0, attempt - 1);
|
|
626
|
-
const raw = Math.min(maxDelayMs, baseDelayMs * 2 ** exp);
|
|
627
|
-
const factor = jitter > 0 ? 1 + Math.random() * jitter : 1;
|
|
628
|
-
return Math.round(raw * factor);
|
|
629
|
-
}
|
|
630
|
-
/**
|
|
631
|
-
* Retry an async operation using exponential backoff.
|
|
1081
|
+
* @module cli/jeeves-watcher/cliClient
|
|
1082
|
+
* The SDK client every API-calling CLI command uses (D37): `http://<host>:<port>`, the resolved bearer key, and a 60 s timeout (D119). Also the key resolution and the key hint printed after a 401. No I/O until a method is called.
|
|
632
1083
|
*
|
|
633
|
-
* @
|
|
634
|
-
* @
|
|
635
|
-
*
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
const attempts = Math.max(1, options.attempts);
|
|
639
|
-
const shouldRetry = options.shouldRetry ?? (() => true);
|
|
640
|
-
let lastError;
|
|
641
|
-
for (let attempt = 1; attempt <= attempts; attempt++) {
|
|
642
|
-
try {
|
|
643
|
-
return await fn(attempt);
|
|
644
|
-
}
|
|
645
|
-
catch (error) {
|
|
646
|
-
lastError = error;
|
|
647
|
-
const isLast = attempt >= attempts;
|
|
648
|
-
if (isLast || !shouldRetry(error))
|
|
649
|
-
break;
|
|
650
|
-
const delayMs = computeDelayMs(attempt, options.baseDelayMs, options.maxDelayMs, options.jitter);
|
|
651
|
-
options.onRetry?.({ attempt, attempts, delayMs, error });
|
|
652
|
-
await sleep(delayMs, options.signal);
|
|
653
|
-
}
|
|
654
|
-
}
|
|
655
|
-
throw lastError;
|
|
656
|
-
}
|
|
657
|
-
|
|
658
|
-
/**
|
|
659
|
-
* @module vcs/gitExec
|
|
660
|
-
* Shared git execution utilities: the pinned argv prefix, timeouts, error
|
|
661
|
-
* field extraction and classification, and path comparison helpers.
|
|
1084
|
+
* @remarks
|
|
1085
|
+
* Key resolution ({@link resolveApiKey}): the `--api-key` flag, then the
|
|
1086
|
+
* `JEEVES_WATCHER_API_KEY` environment variable (core `API_KEY_ENV_VAR`). The
|
|
1087
|
+
* API commands do not load the watcher config file, so `api.apiKey` from
|
|
1088
|
+
* config is not consulted.
|
|
662
1089
|
*/
|
|
663
|
-
/** Promisified execFile for git commands. */
|
|
664
|
-
const execFileAsync = promisify(execFile);
|
|
665
1090
|
/**
|
|
666
|
-
*
|
|
667
|
-
*
|
|
668
|
-
*
|
|
669
|
-
* legacy MAX_PATH limit (260 chars) without requiring a machine-wide
|
|
670
|
-
* `core.longpaths` git config or registry change. See #249.
|
|
1091
|
+
* Request timeout of every CLI command, in milliseconds (D119): longer than
|
|
1092
|
+
* any server-side wait (`RootGitLock` 30 s, document lock 10 s), so a busy
|
|
1093
|
+
* server's `503` reaches the user instead of a timeout.
|
|
671
1094
|
*/
|
|
672
|
-
const
|
|
1095
|
+
const CLI_TIMEOUT_MS = 60_000;
|
|
1096
|
+
/** Where the CLI reads its API key from (named in the 401 hint). */
|
|
1097
|
+
const CLI_KEY_SOURCES = `--api-key or the ${API_KEY_ENV_VAR} env var`;
|
|
1098
|
+
/** Treat missing and blank values as unset. */
|
|
1099
|
+
function nonBlank(value) {
|
|
1100
|
+
return value !== undefined && value.trim() !== '' ? value : undefined;
|
|
1101
|
+
}
|
|
673
1102
|
/**
|
|
674
|
-
*
|
|
1103
|
+
* Resolve the API key for a CLI invocation. Pure.
|
|
675
1104
|
*
|
|
676
|
-
* @param
|
|
677
|
-
* @
|
|
1105
|
+
* @param flag - Value of the `--api-key` option, if given.
|
|
1106
|
+
* @param env - Environment to read `API_KEY_ENV_VAR` from.
|
|
1107
|
+
* @returns The first non-blank of `flag` and the env var, or `undefined`.
|
|
678
1108
|
*/
|
|
679
|
-
function
|
|
680
|
-
return [
|
|
681
|
-
}
|
|
682
|
-
/** Standard timeout (ms) for most git operations. */
|
|
683
|
-
const GIT_TIMEOUT_STANDARD = 30_000;
|
|
684
|
-
/** Extended timeout (ms) for cherry-pick operations. */
|
|
685
|
-
const GIT_TIMEOUT_CHERRY_PICK = 120_000;
|
|
686
|
-
/** Timeout (ms) for push operations. */
|
|
687
|
-
const GIT_TIMEOUT_PUSH = 60_000;
|
|
688
|
-
/**
|
|
689
|
-
* Extract string fields from a child-process error.
|
|
690
|
-
* Node's execFile wraps failures in an Error with `stderr`, `stdout`, and
|
|
691
|
-
* `code` properties that aren't part of the Error type.
|
|
692
|
-
*/
|
|
693
|
-
function getExecErrorFields(error) {
|
|
694
|
-
if (!(error instanceof Error)) {
|
|
695
|
-
return { message: String(error), stderr: '', stdout: '' };
|
|
696
|
-
}
|
|
697
|
-
const rec = error;
|
|
698
|
-
const stderr = typeof rec['stderr'] === 'string' ? rec['stderr'] : '';
|
|
699
|
-
const stdout = typeof rec['stdout'] === 'string' ? rec['stdout'] : '';
|
|
700
|
-
return { message: error.message || '', stderr, stdout };
|
|
1109
|
+
function resolveApiKey(flag, env = process.env) {
|
|
1110
|
+
return nonBlank(flag) ?? nonBlank(env[API_KEY_ENV_VAR]);
|
|
701
1111
|
}
|
|
702
1112
|
/**
|
|
703
|
-
*
|
|
704
|
-
* execFile rejects with the child's numeric exit status in `code`; fs calls
|
|
705
|
-
* reject with a string errno code (e.g. `'ENOENT'`).
|
|
1113
|
+
* The hint printed after a `401`. Never contains the key. Pure.
|
|
706
1114
|
*
|
|
707
|
-
* @param
|
|
708
|
-
* @returns
|
|
1115
|
+
* @param keySent - Whether the request carried a bearer key.
|
|
1116
|
+
* @returns Guidance on supplying (or checking) the key.
|
|
709
1117
|
*/
|
|
710
|
-
function
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
const { code } = error;
|
|
715
|
-
return typeof code === 'string' || typeof code === 'number'
|
|
716
|
-
? code
|
|
717
|
-
: undefined;
|
|
1118
|
+
function unauthorizedHint(keySent) {
|
|
1119
|
+
return keySent
|
|
1120
|
+
? `The watcher API rejected the API key. Check ${CLI_KEY_SOURCES} against the service's api.apiKey.`
|
|
1121
|
+
: `The watcher API requires an API key. Provide it via ${CLI_KEY_SOURCES}.`;
|
|
718
1122
|
}
|
|
719
1123
|
/**
|
|
720
|
-
*
|
|
721
|
-
*
|
|
722
|
-
* Requires the literal `index.lock` in the message or stderr: a bare
|
|
723
|
-
* `EEXIST` (any file that already exists) is not lock contention and must
|
|
724
|
-
* not be retried as if it were. Only Error instances qualify.
|
|
1124
|
+
* The SDK client for a command invocation.
|
|
725
1125
|
*
|
|
726
|
-
* @param
|
|
727
|
-
* @returns
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
1126
|
+
* @param opts - Parsed command options.
|
|
1127
|
+
* @returns A client for `http://<host>:<port>` with the resolved key and
|
|
1128
|
+
* {@link CLI_TIMEOUT_MS}.
|
|
1129
|
+
*/
|
|
1130
|
+
function cliClient(opts) {
|
|
1131
|
+
const apiKey = resolveApiKey(opts.apiKey);
|
|
1132
|
+
return createWatcherClient({
|
|
1133
|
+
baseUrl: `http://${opts.host}:${opts.port}`,
|
|
1134
|
+
...(apiKey === undefined ? {} : { apiKey }),
|
|
1135
|
+
timeoutMs: CLI_TIMEOUT_MS,
|
|
1136
|
+
});
|
|
734
1137
|
}
|
|
1138
|
+
|
|
735
1139
|
/**
|
|
736
|
-
*
|
|
737
|
-
*
|
|
738
|
-
*
|
|
739
|
-
* @param p - The path string to normalize.
|
|
740
|
-
* @param platform - The platform to check against (default: `process.platform`).
|
|
741
|
-
* @returns The normalized path.
|
|
1140
|
+
* @module cli/jeeves-watcher/apiOptions
|
|
1141
|
+
* Commander plumbing shared by the API-calling CLI commands: --port/--host/--api-key, running a command against the SDK client, error reporting (`<code>: <message>` on stderr, exit code 1) and output (pretty JSON or raw text on stdout).
|
|
742
1142
|
*/
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
}
|
|
1143
|
+
const DEFAULT_PORT = String(API_DEFAULTS.port);
|
|
1144
|
+
const DEFAULT_HOST = API_DEFAULTS.host;
|
|
746
1145
|
/**
|
|
747
|
-
*
|
|
748
|
-
*
|
|
1146
|
+
* Add the standard `--port`, `--host` and `--api-key` options to a command.
|
|
1147
|
+
* The key has no default, so it never appears in `--help` output.
|
|
749
1148
|
*
|
|
750
|
-
* @param
|
|
751
|
-
* @
|
|
752
|
-
* @returns The comparison key.
|
|
1149
|
+
* @param cmd - The command.
|
|
1150
|
+
* @returns The command with the options added.
|
|
753
1151
|
*/
|
|
754
|
-
function
|
|
755
|
-
return
|
|
1152
|
+
function withApiOptions(cmd) {
|
|
1153
|
+
return cmd
|
|
1154
|
+
.option('-p, --port <port>', 'API port', DEFAULT_PORT)
|
|
1155
|
+
.option('-H, --host <host>', 'API host', DEFAULT_HOST)
|
|
1156
|
+
.option('--api-key <key>', `API bearer key (defaults to the ${API_KEY_ENV_VAR} environment variable)`);
|
|
756
1157
|
}
|
|
757
1158
|
/**
|
|
758
|
-
*
|
|
759
|
-
*
|
|
1159
|
+
* Describe a failure as `<code>: <message>`. A 401 adds the key hint; a
|
|
1160
|
+
* timeout of a state-changing command adds that the operation may still
|
|
1161
|
+
* complete on the server, and how to check (D119).
|
|
760
1162
|
*
|
|
761
|
-
* @param
|
|
762
|
-
* @param
|
|
763
|
-
* @param
|
|
764
|
-
* @returns The
|
|
765
|
-
*/
|
|
766
|
-
function
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
1163
|
+
* @param err - What the command threw.
|
|
1164
|
+
* @param apiKey - `--api-key` value, to tell "no key" from "wrong key".
|
|
1165
|
+
* @param checkAfterTimeout - How to check a timed-out state change.
|
|
1166
|
+
* @returns The text to print (one or more lines).
|
|
1167
|
+
*/
|
|
1168
|
+
function describeError(err, apiKey, checkAfterTimeout) {
|
|
1169
|
+
if (!isWatcherError(err)) {
|
|
1170
|
+
return err instanceof Error ? err.message : String(err);
|
|
1171
|
+
}
|
|
1172
|
+
const lines = [`${err.code}: ${err.message}`];
|
|
1173
|
+
if (err.status === 401) {
|
|
1174
|
+
lines.push(unauthorizedHint(resolveApiKey(apiKey) !== undefined));
|
|
1175
|
+
}
|
|
1176
|
+
if (err.code === 'timeout' && checkAfterTimeout !== undefined) {
|
|
1177
|
+
lines.push(`The operation may still complete on the server; check with ${checkAfterTimeout}.`);
|
|
774
1178
|
}
|
|
775
|
-
return
|
|
1179
|
+
return lines.join('\n');
|
|
776
1180
|
}
|
|
777
|
-
|
|
778
1181
|
/**
|
|
779
|
-
*
|
|
1182
|
+
* Run a command against the SDK client: any failure is printed with
|
|
1183
|
+
* {@link describeError} and sets exit code 1.
|
|
780
1184
|
*
|
|
781
|
-
*
|
|
782
|
-
*
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
if (globIdx === -1)
|
|
788
|
-
return resolve(pattern);
|
|
789
|
-
const prefix = normalised.slice(0, globIdx);
|
|
790
|
-
// If prefix ends mid-segment, dirname to get a real directory
|
|
791
|
-
const base = prefix.endsWith('/') ? prefix.slice(0, -1) : dirname(prefix);
|
|
792
|
-
return resolve(base);
|
|
793
|
-
}
|
|
794
|
-
async function* walk(dir) {
|
|
795
|
-
let entries;
|
|
1185
|
+
* @param opts - The command's connection options.
|
|
1186
|
+
* @param fn - The command body.
|
|
1187
|
+
* @param checkAfterTimeout - For state-changing commands: how to check
|
|
1188
|
+
* whether a timed-out call completed.
|
|
1189
|
+
*/
|
|
1190
|
+
async function runApiCommand(opts, fn, checkAfterTimeout) {
|
|
796
1191
|
try {
|
|
797
|
-
|
|
798
|
-
entries = dirents.map((d) => ({
|
|
799
|
-
name: d.name,
|
|
800
|
-
isDirectory: d.isDirectory(),
|
|
801
|
-
}));
|
|
1192
|
+
await fn(cliClient(opts));
|
|
802
1193
|
}
|
|
803
|
-
catch {
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
for (const entry of entries) {
|
|
807
|
-
const full = resolve(dir, entry.name);
|
|
808
|
-
if (entry.isDirectory) {
|
|
809
|
-
yield* walk(full);
|
|
810
|
-
}
|
|
811
|
-
else {
|
|
812
|
-
// ensure it's a file
|
|
813
|
-
try {
|
|
814
|
-
const st = await stat(full);
|
|
815
|
-
if (st.isFile())
|
|
816
|
-
yield full;
|
|
817
|
-
}
|
|
818
|
-
catch {
|
|
819
|
-
// ignore
|
|
820
|
-
}
|
|
821
|
-
}
|
|
822
|
-
}
|
|
823
|
-
}
|
|
824
|
-
/**
|
|
825
|
-
* Walk filesystem trees rooted at the base directories of `patterns`,
|
|
826
|
-
* applying ignore globs, a primary match filter, an optional extra filter,
|
|
827
|
-
* and gitignore filtering. Returns a deduplicated array of matching files.
|
|
828
|
-
*/
|
|
829
|
-
async function walkAndFilter(params) {
|
|
830
|
-
const { patterns, ignored, isGitignored, extraFilter } = params;
|
|
831
|
-
const normPatterns = patterns.map((p) => normalizeSlashes(p));
|
|
832
|
-
const normIgnored = ignored.map((p) => normalizeSlashes(p));
|
|
833
|
-
const match = picomatch(normPatterns, { dot: true, nocase: true });
|
|
834
|
-
const ignore = normIgnored.length
|
|
835
|
-
? picomatch(normIgnored, { dot: true, nocase: true })
|
|
836
|
-
: () => false;
|
|
837
|
-
const bases = Array.from(new Set(patterns.map(globBase)));
|
|
838
|
-
const seen = new Set();
|
|
839
|
-
for (const base of bases) {
|
|
840
|
-
for await (const file of walk(base)) {
|
|
841
|
-
const rel = normalizeSlashes(file);
|
|
842
|
-
if (ignore(rel))
|
|
843
|
-
continue;
|
|
844
|
-
if (!match(rel))
|
|
845
|
-
continue;
|
|
846
|
-
if (extraFilter && !extraFilter(rel))
|
|
847
|
-
continue;
|
|
848
|
-
if (isGitignored?.(file))
|
|
849
|
-
continue;
|
|
850
|
-
seen.add(file);
|
|
851
|
-
}
|
|
1194
|
+
catch (err) {
|
|
1195
|
+
console.error(describeError(err, opts.apiKey, checkAfterTimeout));
|
|
1196
|
+
process.exitCode = 1;
|
|
852
1197
|
}
|
|
853
|
-
return Array.from(seen);
|
|
854
1198
|
}
|
|
855
1199
|
/**
|
|
856
|
-
*
|
|
1200
|
+
* Print a JSON response to stdout, pretty-printed.
|
|
857
1201
|
*
|
|
858
|
-
* @param
|
|
859
|
-
* @param ignored - Glob patterns to exclude (optional).
|
|
860
|
-
* @param isGitignored - Optional callback to check gitignore status per file.
|
|
1202
|
+
* @param data - The response.
|
|
861
1203
|
*/
|
|
862
|
-
|
|
863
|
-
|
|
1204
|
+
function printJson(data) {
|
|
1205
|
+
console.log(JSON.stringify(data, null, 2));
|
|
864
1206
|
}
|
|
865
1207
|
/**
|
|
866
|
-
*
|
|
867
|
-
* ignored globs, gitignore, and an additional caller-provided glob intersection filter.
|
|
1208
|
+
* Print a text response to stdout as received.
|
|
868
1209
|
*
|
|
869
|
-
*
|
|
870
|
-
* this function always walks from watch root base directories and applies the caller's
|
|
871
|
-
* globs as an additional intersection filter within the watched universe.
|
|
872
|
-
*
|
|
873
|
-
* @param watchPatterns - The configured watch path globs (defines the watched universe).
|
|
874
|
-
* @param ignored - Glob patterns to exclude.
|
|
875
|
-
* @param callerGlobs - Additional globs to intersect with. Only files matching both
|
|
876
|
-
* the watch patterns AND the caller globs are returned.
|
|
877
|
-
* @param isGitignored - Optional callback to check gitignore status per file.
|
|
878
|
-
*/
|
|
879
|
-
async function listFilesFromWatchRoots(watchPatterns, ignored, callerGlobs, isGitignored) {
|
|
880
|
-
const normCaller = callerGlobs.map((p) => normalizeSlashes(p));
|
|
881
|
-
const matchCaller = picomatch(normCaller, { dot: true, nocase: true });
|
|
882
|
-
return walkAndFilter({
|
|
883
|
-
patterns: watchPatterns,
|
|
884
|
-
ignored,
|
|
885
|
-
isGitignored,
|
|
886
|
-
extraFilter: (normalizedPath) => matchCaller(normalizedPath),
|
|
887
|
-
});
|
|
888
|
-
}
|
|
889
|
-
/**
|
|
890
|
-
* Get the base directories of the configured watch paths.
|
|
1210
|
+
* @param text - The response.
|
|
891
1211
|
*/
|
|
892
|
-
function
|
|
893
|
-
|
|
1212
|
+
function printText(text) {
|
|
1213
|
+
process.stdout.write(text);
|
|
894
1214
|
}
|
|
895
1215
|
|
|
896
1216
|
/**
|
|
897
|
-
* @module
|
|
1217
|
+
* @module cli/jeeves-watcher/configCommands
|
|
1218
|
+
* Re-wires core's `config` and `config apply` CLI commands through the SDK client (`config`, `configApply`), adding `--api-key` so they send the bearer key. Reads patches from a file or stdin.
|
|
898
1219
|
*
|
|
899
|
-
*
|
|
1220
|
+
* @remarks
|
|
1221
|
+
* Core's `createServiceCli` registers both commands with an HTTP helper that
|
|
1222
|
+
* cannot attach headers. This module keeps their names, arguments, options and
|
|
1223
|
+
* output, adds `--api-key` and replaces only the action handlers, which call
|
|
1224
|
+
* the SDK with the CLI's 60 s timeout. Failures print core's prefix, then
|
|
1225
|
+
* `<code>: <message>`. When the commands are absent (e.g. a bare test
|
|
1226
|
+
* program) it does nothing.
|
|
900
1227
|
*/
|
|
901
|
-
/**
|
|
902
|
-
const
|
|
903
|
-
/**
|
|
904
|
-
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
|
|
910
|
-
|
|
911
|
-
|
|
912
|
-
|
|
913
|
-
* @param preComputedFiles - Optional pre-computed file list (skips listFilesFromGlobs).
|
|
914
|
-
* @returns The number of files processed.
|
|
915
|
-
*/
|
|
916
|
-
async function processAllFiles(watchPaths, ignoredPaths, processor, method, concurrency = DEFAULT_REINDEX_CONCURRENCY, callbacks, isGitignored, preComputedFiles) {
|
|
917
|
-
const files = preComputedFiles ??
|
|
918
|
-
(await listFilesFromGlobs(watchPaths, ignoredPaths, isGitignored));
|
|
919
|
-
callbacks?.onTotal?.(files.length);
|
|
920
|
-
await parallel(concurrency, files, async (file) => {
|
|
921
|
-
await processor[method](file);
|
|
922
|
-
callbacks?.onFileProcessed?.();
|
|
923
|
-
});
|
|
924
|
-
return files.length;
|
|
1228
|
+
/** Host core's config commands always address. */
|
|
1229
|
+
const CONFIG_HOST = API_DEFAULTS.host;
|
|
1230
|
+
/** Parsed options of the re-wired commands; Zod is the source of truth. */
|
|
1231
|
+
const configOptsSchema = z.object({
|
|
1232
|
+
port: z.string(),
|
|
1233
|
+
apiKey: z.string().optional(),
|
|
1234
|
+
file: z.string().optional(),
|
|
1235
|
+
replace: z.boolean().optional(),
|
|
1236
|
+
});
|
|
1237
|
+
/** Find a direct subcommand by name. */
|
|
1238
|
+
function findCommand(parent, name) {
|
|
1239
|
+
return parent.commands.find((cmd) => cmd.name() === name);
|
|
925
1240
|
}
|
|
926
|
-
|
|
927
|
-
|
|
928
|
-
|
|
929
|
-
* Shared reindex execution logic used by both the reindex handler and the triggerReindex lambda.
|
|
930
|
-
*/
|
|
931
|
-
/** Ordered list of valid reindex scopes for validation. */
|
|
932
|
-
const VALID_REINDEX_SCOPES = [
|
|
933
|
-
'issues',
|
|
934
|
-
'full',
|
|
935
|
-
'rules',
|
|
936
|
-
'path',
|
|
937
|
-
'prune',
|
|
938
|
-
];
|
|
939
|
-
/**
|
|
940
|
-
* Valid scopes for config-watch auto-trigger.
|
|
941
|
-
* `prune` is excluded - auto-pruning on config change is dangerous.
|
|
942
|
-
*/
|
|
943
|
-
const CONFIG_WATCH_VALID_SCOPES = [
|
|
944
|
-
'issues',
|
|
945
|
-
'full',
|
|
946
|
-
'rules',
|
|
947
|
-
];
|
|
948
|
-
/** Fire reindex callback with exponential backoff retry. */
|
|
949
|
-
async function fireCallback(url, payload, logger) {
|
|
950
|
-
await retry(async () => {
|
|
951
|
-
const response = await fetch(url, {
|
|
952
|
-
method: 'POST',
|
|
953
|
-
headers: { 'Content-Type': 'application/json' },
|
|
954
|
-
body: JSON.stringify(payload),
|
|
955
|
-
});
|
|
956
|
-
if (!response.ok) {
|
|
957
|
-
throw new Error(`Non-OK response: ${String(response.status)}`);
|
|
958
|
-
}
|
|
959
|
-
}, {
|
|
960
|
-
attempts: 3,
|
|
961
|
-
baseDelayMs: 1000,
|
|
962
|
-
maxDelayMs: 4000,
|
|
963
|
-
onRetry: ({ attempt, error }) => {
|
|
964
|
-
logger.warn({ attempt, err: normalizeError(error), url }, 'Reindex callback failed; will retry');
|
|
965
|
-
},
|
|
966
|
-
});
|
|
1241
|
+
/** Add `--api-key` to a command (no default, so it never shows in help). */
|
|
1242
|
+
function addApiKeyOption(cmd) {
|
|
1243
|
+
cmd.option('--api-key <key>', `API bearer key (defaults to the ${API_KEY_ENV_VAR} environment variable)`);
|
|
967
1244
|
}
|
|
968
|
-
/**
|
|
969
|
-
|
|
970
|
-
|
|
971
|
-
|
|
972
|
-
*/
|
|
973
|
-
function groupByRoot(filePaths, watchPaths) {
|
|
974
|
-
const byRoot = {};
|
|
975
|
-
for (const fp of filePaths) {
|
|
976
|
-
const normalised = normalizeSlashes(fp);
|
|
977
|
-
let matched = false;
|
|
978
|
-
for (const root of watchPaths) {
|
|
979
|
-
const rootNorm = normalizeSlashes(root).replace(/\/?\*\*.*$/, '');
|
|
980
|
-
if (normalised.startsWith(rootNorm)) {
|
|
981
|
-
byRoot[rootNorm] = (byRoot[rootNorm] ?? 0) + 1;
|
|
982
|
-
matched = true;
|
|
983
|
-
break;
|
|
984
|
-
}
|
|
985
|
-
}
|
|
986
|
-
if (!matched) {
|
|
987
|
-
byRoot['(unmatched)'] = (byRoot['(unmatched)'] ?? 0) + 1;
|
|
988
|
-
}
|
|
989
|
-
}
|
|
990
|
-
return byRoot;
|
|
1245
|
+
/** Print an error with core's prefix and set exit code 1. */
|
|
1246
|
+
function reportFailure(prefix, err, apiKey) {
|
|
1247
|
+
console.error(`${prefix}: ${describeError(err, apiKey)}`);
|
|
1248
|
+
process.exitCode = 1;
|
|
991
1249
|
}
|
|
992
|
-
/**
|
|
993
|
-
|
|
994
|
-
|
|
995
|
-
|
|
996
|
-
|
|
997
|
-
|
|
998
|
-
|
|
999
|
-
|
|
1000
|
-
|
|
1001
|
-
*/
|
|
1002
|
-
async function scrollPageWithRetry(vectorStore, cursor, logger) {
|
|
1003
|
-
let lastError;
|
|
1004
|
-
for (let attempt = 1; attempt <= SCROLL_RETRY_ATTEMPTS; attempt++) {
|
|
1250
|
+
/**
|
|
1251
|
+
* Read a config patch from `file`, else from stdin (empty input = `{}`).
|
|
1252
|
+
*
|
|
1253
|
+
* @throws With a one-line message when the file cannot be read, the input
|
|
1254
|
+
* is not JSON, or it is not a JSON object.
|
|
1255
|
+
*/
|
|
1256
|
+
async function readPatch(file) {
|
|
1257
|
+
let input;
|
|
1258
|
+
if (file) {
|
|
1005
1259
|
try {
|
|
1006
|
-
|
|
1007
|
-
return { points: page.points, nextCursor: page.nextCursor };
|
|
1260
|
+
input = readFileSync(file, 'utf-8');
|
|
1008
1261
|
}
|
|
1009
|
-
catch (
|
|
1010
|
-
|
|
1011
|
-
|
|
1012
|
-
const delay = SCROLL_RETRY_BASE_DELAY_MS * Math.pow(2, attempt - 1) +
|
|
1013
|
-
Math.random() * 500;
|
|
1014
|
-
logger.warn({ attempt, err: normalizeError(error) }, `Prune scroll page failed; retrying in ${String(Math.round(delay))}ms`);
|
|
1015
|
-
await new Promise((resolve) => setTimeout(resolve, delay));
|
|
1016
|
-
}
|
|
1262
|
+
catch (err) {
|
|
1263
|
+
const { code } = err;
|
|
1264
|
+
throw new Error(`cannot read patch file ${file}${typeof code === 'string' ? ` (${code})` : ''}`, { cause: err });
|
|
1017
1265
|
}
|
|
1018
1266
|
}
|
|
1019
|
-
|
|
1020
|
-
|
|
1021
|
-
|
|
1022
|
-
|
|
1023
|
-
|
|
1024
|
-
* Compute the plan for a prune operation using a filter-first approach.
|
|
1025
|
-
*
|
|
1026
|
-
* 1. Enumerate all watched files from the filesystem → `watchedFiles` set.
|
|
1027
|
-
* 2. Scroll Qdrant points. For each point:
|
|
1028
|
-
* - No `file_path` → orphan.
|
|
1029
|
-
* - `file_path` not in `watchedFiles` → orphan (out of watch scope).
|
|
1030
|
-
* - `file_path` in `watchedFiles` → check disk existence via `fs.access()`.
|
|
1031
|
-
* If missing from disk → orphan (deleted file still in index).
|
|
1032
|
-
* 3. Batch `fs.access()` checks with bounded concurrency.
|
|
1033
|
-
*/
|
|
1034
|
-
async function computePrunePlan(deps) {
|
|
1035
|
-
const { config, vectorStore, gitignoreFilter, logger } = deps;
|
|
1036
|
-
if (!vectorStore) {
|
|
1037
|
-
throw new Error('vectorStore is required for prune scope');
|
|
1267
|
+
else {
|
|
1268
|
+
const chunks = [];
|
|
1269
|
+
for await (const chunk of process.stdin)
|
|
1270
|
+
chunks.push(chunk);
|
|
1271
|
+
input = Buffer.concat(chunks).toString('utf-8');
|
|
1038
1272
|
}
|
|
1039
|
-
|
|
1040
|
-
|
|
1041
|
-
|
|
1042
|
-
const watchedFiles = new Set(watchedFileList.map((f) => normalizePathCase(normalizeSlashes(f))));
|
|
1043
|
-
logger.info({ watchedFileCount: watchedFiles.size }, 'Prune: enumerated watched files from filesystem');
|
|
1044
|
-
// Step 2: Scroll Qdrant and classify points
|
|
1045
|
-
const seenFiles = new Set();
|
|
1046
|
-
const orphanedFiles = new Set();
|
|
1047
|
-
const diskCheckFiles = new Set();
|
|
1048
|
-
const orphanedIds = [];
|
|
1049
|
-
// Map from file_path → point IDs, for points needing disk-existence check
|
|
1050
|
-
const diskCheckPointIds = new Map();
|
|
1051
|
-
let totalPoints = 0;
|
|
1052
|
-
let incomplete = false;
|
|
1053
|
-
let cursor;
|
|
1273
|
+
if (!input.trim())
|
|
1274
|
+
return {};
|
|
1275
|
+
let patch;
|
|
1054
1276
|
try {
|
|
1055
|
-
|
|
1056
|
-
while (hasMore) {
|
|
1057
|
-
const page = await scrollPageWithRetry(vectorStore, cursor, logger);
|
|
1058
|
-
for (const point of page.points) {
|
|
1059
|
-
totalPoints++;
|
|
1060
|
-
const filePath = point.payload.file_path;
|
|
1061
|
-
if (!filePath) {
|
|
1062
|
-
orphanedIds.push(point.id);
|
|
1063
|
-
continue;
|
|
1064
|
-
}
|
|
1065
|
-
const normalized = normalizePathCase(normalizeSlashes(filePath));
|
|
1066
|
-
if (seenFiles.has(normalized)) {
|
|
1067
|
-
if (orphanedFiles.has(normalized)) {
|
|
1068
|
-
orphanedIds.push(point.id);
|
|
1069
|
-
}
|
|
1070
|
-
else if (diskCheckFiles.has(normalized)) {
|
|
1071
|
-
// Already queued for disk check — track its ID
|
|
1072
|
-
const ids = diskCheckPointIds.get(normalized);
|
|
1073
|
-
if (ids)
|
|
1074
|
-
ids.push(point.id);
|
|
1075
|
-
}
|
|
1076
|
-
continue;
|
|
1077
|
-
}
|
|
1078
|
-
seenFiles.add(normalized);
|
|
1079
|
-
if (!watchedFiles.has(normalized)) {
|
|
1080
|
-
// Not in watch scope → definitely orphaned
|
|
1081
|
-
orphanedFiles.add(normalized);
|
|
1082
|
-
orphanedIds.push(point.id);
|
|
1083
|
-
}
|
|
1084
|
-
else {
|
|
1085
|
-
// In watch scope → needs disk-existence check
|
|
1086
|
-
diskCheckFiles.add(normalized);
|
|
1087
|
-
diskCheckPointIds.set(normalized, [point.id]);
|
|
1088
|
-
}
|
|
1089
|
-
}
|
|
1090
|
-
// Progress logging every 100K points
|
|
1091
|
-
if (totalPoints > 0 && totalPoints % 100000 === 0) {
|
|
1092
|
-
logger.info({ totalPoints, orphanedCount: orphanedIds.length }, 'Prune scroll progress');
|
|
1093
|
-
}
|
|
1094
|
-
if (!page.nextCursor) {
|
|
1095
|
-
hasMore = false;
|
|
1096
|
-
}
|
|
1097
|
-
else {
|
|
1098
|
-
cursor = page.nextCursor;
|
|
1099
|
-
}
|
|
1100
|
-
}
|
|
1277
|
+
patch = JSON.parse(input);
|
|
1101
1278
|
}
|
|
1102
|
-
catch (
|
|
1103
|
-
|
|
1104
|
-
incomplete = true;
|
|
1279
|
+
catch (err) {
|
|
1280
|
+
throw new Error(`patch is not valid JSON (${err instanceof Error ? err.message : String(err)})`, { cause: err });
|
|
1105
1281
|
}
|
|
1106
|
-
|
|
1107
|
-
|
|
1108
|
-
|
|
1109
|
-
try {
|
|
1110
|
-
await access(filePath);
|
|
1111
|
-
}
|
|
1112
|
-
catch {
|
|
1113
|
-
// File doesn't exist on disk → orphaned
|
|
1114
|
-
orphanedFiles.add(filePath);
|
|
1115
|
-
const pointIds = diskCheckPointIds.get(filePath);
|
|
1116
|
-
if (pointIds) {
|
|
1117
|
-
orphanedIds.push(...pointIds);
|
|
1118
|
-
}
|
|
1119
|
-
}
|
|
1120
|
-
});
|
|
1121
|
-
const orphanedFileList = [...orphanedFiles];
|
|
1122
|
-
const byRoot = groupByRoot(orphanedFileList, extractWatchPathStrings(config.watch.paths));
|
|
1123
|
-
return {
|
|
1124
|
-
plan: {
|
|
1125
|
-
total: totalPoints,
|
|
1126
|
-
toProcess: 0,
|
|
1127
|
-
toDelete: orphanedIds.length,
|
|
1128
|
-
byRoot,
|
|
1129
|
-
...(incomplete ? { incomplete: true } : {}),
|
|
1130
|
-
},
|
|
1131
|
-
orphanedIds,
|
|
1132
|
-
};
|
|
1282
|
+
if (!isPlainObject$1(patch))
|
|
1283
|
+
throw new Error('patch must be a JSON object');
|
|
1284
|
+
return patch;
|
|
1133
1285
|
}
|
|
1134
1286
|
/**
|
|
1135
|
-
*
|
|
1287
|
+
* Make core's `config` and `config apply` commands send the bearer key.
|
|
1136
1288
|
*
|
|
1137
|
-
* @param
|
|
1138
|
-
|
|
1139
|
-
|
|
1140
|
-
|
|
1141
|
-
|
|
1142
|
-
|
|
1143
|
-
|
|
1144
|
-
|
|
1145
|
-
|
|
1146
|
-
|
|
1147
|
-
throw new Error(`Invalid reindex scope: "${scope}". Valid scopes: ${VALID_REINDEX_SCOPES.join(', ')}`);
|
|
1148
|
-
}
|
|
1149
|
-
const pathArray = path
|
|
1150
|
-
? Array.isArray(path)
|
|
1151
|
-
? path
|
|
1152
|
-
: [path]
|
|
1153
|
-
: undefined;
|
|
1154
|
-
if (scope === 'path' && (!pathArray || pathArray.length === 0)) {
|
|
1155
|
-
throw new Error('The "path" parameter is required when scope is "path".');
|
|
1156
|
-
}
|
|
1157
|
-
if (scope === 'prune' && !deps.vectorStore) {
|
|
1158
|
-
throw new Error('vectorStore is required for prune scope.');
|
|
1159
|
-
}
|
|
1160
|
-
// Compute plan before starting async work
|
|
1161
|
-
let plan;
|
|
1162
|
-
if (scope === 'prune') {
|
|
1163
|
-
deps.queue?.pause();
|
|
1164
|
-
await deps.queue?.waitForIdleWorkers();
|
|
1165
|
-
let pruneResult;
|
|
1289
|
+
* @param program - The service CLI program, after core registered its commands.
|
|
1290
|
+
*/
|
|
1291
|
+
function authenticateConfigCommands(program) {
|
|
1292
|
+
const configCmd = findCommand(program, 'config');
|
|
1293
|
+
if (!configCmd)
|
|
1294
|
+
return;
|
|
1295
|
+
addApiKeyOption(configCmd);
|
|
1296
|
+
configCmd.action(async () => {
|
|
1297
|
+
const opts = configOptsSchema.parse(configCmd.opts());
|
|
1298
|
+
const jsonpath = configCmd.args[0];
|
|
1166
1299
|
try {
|
|
1167
|
-
|
|
1300
|
+
const client = cliClient({ ...opts, host: CONFIG_HOST });
|
|
1301
|
+
printJson(await client.config(jsonpath ? { path: jsonpath } : {}));
|
|
1168
1302
|
}
|
|
1169
1303
|
catch (err) {
|
|
1170
|
-
|
|
1171
|
-
throw err;
|
|
1304
|
+
reportFailure('Config query failed', err, opts.apiKey);
|
|
1172
1305
|
}
|
|
1173
|
-
|
|
1174
|
-
|
|
1175
|
-
|
|
1176
|
-
|
|
1306
|
+
});
|
|
1307
|
+
const applyCmd = findCommand(configCmd, 'apply');
|
|
1308
|
+
if (!applyCmd)
|
|
1309
|
+
return;
|
|
1310
|
+
addApiKeyOption(applyCmd);
|
|
1311
|
+
applyCmd.action(async () => {
|
|
1312
|
+
const opts = configOptsSchema.parse(applyCmd.opts());
|
|
1313
|
+
try {
|
|
1314
|
+
const patch = await readPatch(opts.file);
|
|
1315
|
+
const client = cliClient({ ...opts, host: CONFIG_HOST });
|
|
1316
|
+
printJson(await client.configApply({
|
|
1317
|
+
patch,
|
|
1318
|
+
...(opts.replace ? { replace: true } : {}),
|
|
1319
|
+
}));
|
|
1177
1320
|
}
|
|
1178
|
-
|
|
1179
|
-
|
|
1180
|
-
deps.queue?.resume();
|
|
1181
|
-
logger.error({ totalPoints: plan.total, pointsToDelete: plan.toDelete }, 'Aborting live prune — scroll was incomplete; partial deletes are unsafe');
|
|
1182
|
-
return { filesProcessed: 0, durationMs: 0, errors: 1, plan };
|
|
1321
|
+
catch (err) {
|
|
1322
|
+
reportFailure('Config apply failed', err, opts.apiKey);
|
|
1183
1323
|
}
|
|
1184
|
-
|
|
1185
|
-
|
|
1186
|
-
|
|
1187
|
-
|
|
1188
|
-
|
|
1189
|
-
|
|
1190
|
-
|
|
1191
|
-
|
|
1192
|
-
|
|
1193
|
-
|
|
1194
|
-
|
|
1195
|
-
|
|
1196
|
-
|
|
1197
|
-
|
|
1198
|
-
|
|
1199
|
-
|
|
1200
|
-
|
|
1201
|
-
|
|
1202
|
-
|
|
1203
|
-
|
|
1204
|
-
|
|
1205
|
-
}, logger);
|
|
1206
|
-
}
|
|
1207
|
-
return {
|
|
1208
|
-
filesProcessed: 0,
|
|
1209
|
-
durationMs,
|
|
1210
|
-
errors: 0,
|
|
1211
|
-
plan,
|
|
1212
|
-
};
|
|
1324
|
+
});
|
|
1325
|
+
}
|
|
1326
|
+
|
|
1327
|
+
/**
|
|
1328
|
+
* @module cli/jeeves-watcher/helperFormat
|
|
1329
|
+
* Pure formatting for the `helpers` CLI command: renders JsonMap and Handlebars helper namespaces from GET /config query results as text.
|
|
1330
|
+
*/
|
|
1331
|
+
/**
|
|
1332
|
+
* Format a helpers section for display. Pure.
|
|
1333
|
+
*
|
|
1334
|
+
* @param title - Section heading.
|
|
1335
|
+
* @param data - Helper namespaces keyed by name.
|
|
1336
|
+
* @returns The rendered section, or `''` when there are no helpers.
|
|
1337
|
+
*/
|
|
1338
|
+
function formatHelperSection(title, data) {
|
|
1339
|
+
if (!data || Object.keys(data).length === 0)
|
|
1340
|
+
return '';
|
|
1341
|
+
const lines = [`${title}:`];
|
|
1342
|
+
for (const [namespace, entry] of Object.entries(data)) {
|
|
1343
|
+
if (entry.description) {
|
|
1344
|
+
lines.push(` [${namespace}] ${entry.description}`);
|
|
1213
1345
|
}
|
|
1214
|
-
|
|
1215
|
-
const
|
|
1216
|
-
|
|
1217
|
-
|
|
1218
|
-
if (config.reindex?.callbackUrl) {
|
|
1219
|
-
await fireCallback(config.reindex.callbackUrl, {
|
|
1220
|
-
scope,
|
|
1221
|
-
filesProcessed: 0,
|
|
1222
|
-
durationMs,
|
|
1223
|
-
errors: [normalizeError(error).message],
|
|
1224
|
-
}, logger);
|
|
1346
|
+
if (entry.exports) {
|
|
1347
|
+
for (const [name, desc] of Object.entries(entry.exports)) {
|
|
1348
|
+
const descPart = desc ? ` - ${desc}` : '';
|
|
1349
|
+
lines.push(` ${name.padEnd(40)}${descPart}`);
|
|
1225
1350
|
}
|
|
1226
|
-
return { filesProcessed: 0, durationMs, errors: 1, plan };
|
|
1227
1351
|
}
|
|
1228
|
-
|
|
1229
|
-
|
|
1230
|
-
}
|
|
1231
|
-
}
|
|
1232
|
-
// Non-prune scopes: compute plan from file lists
|
|
1233
|
-
let fileList;
|
|
1234
|
-
if (scope === 'issues' && deps.issuesManager) {
|
|
1235
|
-
const issues = deps.issuesManager.getAll();
|
|
1236
|
-
fileList = Object.keys(issues);
|
|
1237
|
-
}
|
|
1238
|
-
else if (scope === 'path' && pathArray) {
|
|
1239
|
-
// Path plan computed inline in the execution block below
|
|
1240
|
-
fileList = undefined;
|
|
1241
|
-
}
|
|
1242
|
-
else if (scope === 'rules' && pathArray && pathArray.length > 0) {
|
|
1243
|
-
// Rules scope with path filter: list from watch roots intersected with caller globs
|
|
1244
|
-
fileList = await listFilesFromWatchRoots(extractWatchPathStrings(config.watch.paths), config.watch.ignored, pathArray, isGitignored);
|
|
1245
|
-
}
|
|
1246
|
-
else {
|
|
1247
|
-
// rules or full: list files from globs
|
|
1248
|
-
fileList = await listFilesFromGlobs(extractWatchPathStrings(config.watch.paths), config.watch.ignored, isGitignored);
|
|
1249
|
-
}
|
|
1250
|
-
if (fileList !== undefined) {
|
|
1251
|
-
plan = {
|
|
1252
|
-
total: fileList.length,
|
|
1253
|
-
toProcess: fileList.length,
|
|
1254
|
-
toDelete: 0,
|
|
1255
|
-
byRoot: groupByRoot(fileList, extractWatchPathStrings(config.watch.paths)),
|
|
1256
|
-
};
|
|
1257
|
-
}
|
|
1258
|
-
if (dryRun) {
|
|
1259
|
-
// For path scope without pre-computed plan, compute it now
|
|
1260
|
-
if (plan === undefined && scope === 'path' && pathArray) {
|
|
1261
|
-
const pathFiles = await collectPathFiles(pathArray, config, deps.gitignoreFilter);
|
|
1262
|
-
plan = {
|
|
1263
|
-
total: pathFiles.length,
|
|
1264
|
-
toProcess: pathFiles.length,
|
|
1265
|
-
toDelete: 0,
|
|
1266
|
-
byRoot: groupByRoot(pathFiles, extractWatchPathStrings(config.watch.paths)),
|
|
1267
|
-
};
|
|
1352
|
+
else {
|
|
1353
|
+
lines.push(` (no exports introspected)`);
|
|
1268
1354
|
}
|
|
1269
|
-
return { filesProcessed: 0, durationMs: 0, errors: 0, plan };
|
|
1270
1355
|
}
|
|
1271
|
-
|
|
1272
|
-
|
|
1273
|
-
|
|
1274
|
-
|
|
1275
|
-
|
|
1276
|
-
|
|
1277
|
-
|
|
1278
|
-
|
|
1279
|
-
|
|
1280
|
-
|
|
1281
|
-
|
|
1282
|
-
|
|
1283
|
-
|
|
1284
|
-
|
|
1285
|
-
|
|
1286
|
-
|
|
1287
|
-
|
|
1288
|
-
|
|
1289
|
-
|
|
1290
|
-
|
|
1291
|
-
|
|
1292
|
-
|
|
1293
|
-
|
|
1294
|
-
|
|
1295
|
-
|
|
1296
|
-
}, isGitignored, fileList);
|
|
1297
|
-
}
|
|
1298
|
-
else if (scope === 'path' && pathArray) {
|
|
1299
|
-
const uniqueFiles = await collectPathFiles(pathArray, config, deps.gitignoreFilter);
|
|
1300
|
-
plan = {
|
|
1301
|
-
total: uniqueFiles.length,
|
|
1302
|
-
toProcess: uniqueFiles.length,
|
|
1303
|
-
toDelete: 0,
|
|
1304
|
-
byRoot: groupByRoot(uniqueFiles, extractWatchPathStrings(config.watch.paths)),
|
|
1305
|
-
};
|
|
1306
|
-
reindexTracker?.setTotal(uniqueFiles.length);
|
|
1307
|
-
await parallel(concurrency, uniqueFiles, async (filePath) => {
|
|
1308
|
-
await processor.processFile(filePath);
|
|
1309
|
-
reindexTracker?.incrementProcessed();
|
|
1310
|
-
filesProcessed++;
|
|
1311
|
-
});
|
|
1356
|
+
return lines.join('\n');
|
|
1357
|
+
}
|
|
1358
|
+
|
|
1359
|
+
/**
|
|
1360
|
+
* @module cli/jeeves-watcher/metadataArgs
|
|
1361
|
+
* Parses the `enrich` command's --json and --key options into a metadata object; reports validation errors on stderr and sets the exit code.
|
|
1362
|
+
*/
|
|
1363
|
+
/** Whether a parsed JSON value is a plain object (not null, an array or a primitive). */
|
|
1364
|
+
function isPlainObject(value) {
|
|
1365
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
1366
|
+
}
|
|
1367
|
+
/**
|
|
1368
|
+
* Parse `--json` and `--key` options into a metadata object.
|
|
1369
|
+
* `--json` must be a JSON object; null, arrays and primitives are rejected.
|
|
1370
|
+
*
|
|
1371
|
+
* @param json - Metadata as a JSON string, if given.
|
|
1372
|
+
* @param keys - `key=value` pairs (applied after `json`).
|
|
1373
|
+
* @returns The metadata, or `null` on validation failure (already reported).
|
|
1374
|
+
*/
|
|
1375
|
+
function parseMetadataArgs(json, keys) {
|
|
1376
|
+
let metadata = {};
|
|
1377
|
+
if (json) {
|
|
1378
|
+
let parsed;
|
|
1379
|
+
try {
|
|
1380
|
+
parsed = JSON.parse(json);
|
|
1312
1381
|
}
|
|
1313
|
-
|
|
1314
|
-
|
|
1315
|
-
|
|
1316
|
-
|
|
1317
|
-
onFileProcessed: () => reindexTracker?.incrementProcessed(),
|
|
1318
|
-
}, isGitignored, fileList);
|
|
1382
|
+
catch {
|
|
1383
|
+
console.error('Invalid JSON:', json);
|
|
1384
|
+
process.exitCode = 1;
|
|
1385
|
+
return null;
|
|
1319
1386
|
}
|
|
1320
|
-
|
|
1321
|
-
|
|
1322
|
-
|
|
1323
|
-
|
|
1324
|
-
await fireCallback(config.reindex.callbackUrl, { scope, filesProcessed, durationMs, errors: [] }, logger);
|
|
1387
|
+
if (!isPlainObject(parsed)) {
|
|
1388
|
+
console.error('JSON metadata must be an object:', json);
|
|
1389
|
+
process.exitCode = 1;
|
|
1390
|
+
return null;
|
|
1325
1391
|
}
|
|
1326
|
-
|
|
1392
|
+
metadata = parsed;
|
|
1327
1393
|
}
|
|
1328
|
-
|
|
1329
|
-
|
|
1330
|
-
|
|
1331
|
-
|
|
1332
|
-
|
|
1333
|
-
|
|
1334
|
-
|
|
1335
|
-
|
|
1336
|
-
|
|
1337
|
-
durationMs,
|
|
1338
|
-
errors: [normalizeError(error).message],
|
|
1339
|
-
}, logger);
|
|
1394
|
+
if (Array.isArray(keys) && keys.length > 0) {
|
|
1395
|
+
for (const pair of keys) {
|
|
1396
|
+
const eqIndex = pair.indexOf('=');
|
|
1397
|
+
if (eqIndex === -1) {
|
|
1398
|
+
console.error(`Invalid key-value pair: ${pair}`);
|
|
1399
|
+
process.exitCode = 1;
|
|
1400
|
+
return null;
|
|
1401
|
+
}
|
|
1402
|
+
metadata[pair.slice(0, eqIndex)] = pair.slice(eqIndex + 1);
|
|
1340
1403
|
}
|
|
1341
|
-
return { filesProcessed: 0, durationMs, errors, plan };
|
|
1342
1404
|
}
|
|
1343
|
-
|
|
1344
|
-
|
|
1345
|
-
|
|
1346
|
-
|
|
1347
|
-
* Iterates over each target path, resolves its file list, and returns a
|
|
1348
|
-
* deduplicated array. This eliminates the duplicate iteration + `new Set()`
|
|
1349
|
-
* that previously appeared in both the dryRun plan and execution branches.
|
|
1350
|
-
*/
|
|
1351
|
-
async function collectPathFiles(pathArray, config, gitignoreFilter) {
|
|
1352
|
-
const allFiles = [];
|
|
1353
|
-
for (const p of pathArray) {
|
|
1354
|
-
const files = await getPathFileList(p, config, gitignoreFilter);
|
|
1355
|
-
allFiles.push(...files);
|
|
1405
|
+
if (Object.keys(metadata).length === 0) {
|
|
1406
|
+
console.error('No metadata provided. Use --key or --json.');
|
|
1407
|
+
process.exitCode = 1;
|
|
1408
|
+
return null;
|
|
1356
1409
|
}
|
|
1357
|
-
return
|
|
1410
|
+
return metadata;
|
|
1358
1411
|
}
|
|
1412
|
+
|
|
1359
1413
|
/**
|
|
1360
|
-
*
|
|
1414
|
+
* @module cli/jeeves-watcher/vcsCommands
|
|
1415
|
+
* The `vcs` command group (#213): status, history, show, diff, revert, check and exclude, each one SDK call (`getVcsStatus`, `listVcsHistory`, `showVcsFile`, `diffVcs`, `revertVcs`, `checkVcsExclusion`, `excludeVcsPath`). JSON replies print as pretty JSON; `show` and `diff` print the raw text.
|
|
1361
1416
|
*/
|
|
1362
|
-
|
|
1363
|
-
|
|
1364
|
-
|
|
1365
|
-
|
|
1366
|
-
|
|
1367
|
-
|
|
1368
|
-
|
|
1369
|
-
|
|
1370
|
-
|
|
1371
|
-
|
|
1372
|
-
|
|
1373
|
-
|
|
1374
|
-
|
|
1375
|
-
|
|
1376
|
-
|
|
1377
|
-
|
|
1378
|
-
|
|
1379
|
-
|
|
1417
|
+
/** How to check whether a timed-out exclusion completed (D119). */
|
|
1418
|
+
const CHECK_EXCLUDE = '`jeeves-watcher vcs check <path>` or `vcs status`';
|
|
1419
|
+
/** How to check whether a timed-out revert completed (D119). */
|
|
1420
|
+
const CHECK_REVERT = '`jeeves-watcher vcs history <glob>`';
|
|
1421
|
+
/**
|
|
1422
|
+
* Register the `vcs` command group.
|
|
1423
|
+
*
|
|
1424
|
+
* @param program - The Commander program.
|
|
1425
|
+
*/
|
|
1426
|
+
function registerVcsCommands(program) {
|
|
1427
|
+
const vcs = program
|
|
1428
|
+
.command('vcs')
|
|
1429
|
+
.description('Version history of the watched files (/vcs/*)');
|
|
1430
|
+
withApiOptions(vcs
|
|
1431
|
+
.command('status')
|
|
1432
|
+
.description('VCS state of every root (GET /vcs/status)')).action((opts) => runApiCommand(opts, async (client) => {
|
|
1433
|
+
printJson(await client.getVcsStatus());
|
|
1434
|
+
}));
|
|
1435
|
+
withApiOptions(vcs
|
|
1436
|
+
.command('history')
|
|
1437
|
+
.description('Change history of a path or glob (GET /vcs/history)')
|
|
1438
|
+
.argument('<glob>', 'Path or glob inside a VCS root')
|
|
1439
|
+
.option('--since <date>', 'Only commits after this date')
|
|
1440
|
+
.option('--until <date>', 'Only commits before this date')
|
|
1441
|
+
.option('--limit <n>', 'Most commits to return (server default 20)')).action((glob, opts) => runApiCommand(opts, async (client) => {
|
|
1442
|
+
printJson(await client.listVcsHistory({
|
|
1443
|
+
glob,
|
|
1444
|
+
since: opts.since,
|
|
1445
|
+
until: opts.until,
|
|
1446
|
+
limit: opts.limit,
|
|
1447
|
+
}));
|
|
1448
|
+
}));
|
|
1449
|
+
withApiOptions(vcs
|
|
1450
|
+
.command('show')
|
|
1451
|
+
.description('File content at a commit (GET /vcs/show)')
|
|
1452
|
+
.argument('<path>', 'File path inside a VCS root')
|
|
1453
|
+
.argument('<commit>', 'Commit to read the file from')).action((path, commit, opts) => runApiCommand(opts, async (client) => {
|
|
1454
|
+
printText(await client.showVcsFile({ path, commit }));
|
|
1455
|
+
}));
|
|
1456
|
+
withApiOptions(vcs
|
|
1457
|
+
.command('diff')
|
|
1458
|
+
.description('Unified diff from a commit (GET /vcs/diff)')
|
|
1459
|
+
.argument('<glob>', 'Path or glob inside a VCS root')
|
|
1460
|
+
.argument('<commit>', 'Commit to diff from')
|
|
1461
|
+
.option('--to <commit>', 'Commit to diff to (default: the working tree)')).action((glob, commit, opts) => runApiCommand(opts, async (client) => {
|
|
1462
|
+
printText(await client.diffVcs({ glob, commit, commitEnd: opts.to }));
|
|
1463
|
+
}));
|
|
1464
|
+
withApiOptions(vcs
|
|
1465
|
+
.command('revert')
|
|
1466
|
+
.description('Restore files from a commit, as a new commit (POST /vcs/revert)')
|
|
1467
|
+
.argument('<glob>', 'Path or glob inside a VCS root')
|
|
1468
|
+
.argument('<commit>', 'Commit to restore from')
|
|
1469
|
+
.option('--existing-only', 'Restore only files that exist now')).action((glob, commit, opts) => runApiCommand(opts, async (client) => {
|
|
1470
|
+
printJson(await client.revertVcs({
|
|
1471
|
+
glob,
|
|
1472
|
+
commit,
|
|
1473
|
+
...(opts.existingOnly ? { existingOnly: true } : {}),
|
|
1474
|
+
}));
|
|
1475
|
+
}, CHECK_REVERT));
|
|
1476
|
+
withApiOptions(vcs
|
|
1477
|
+
.command('check')
|
|
1478
|
+
.description('Whether git ignores a path, and the rule, file and line (GET /vcs/check-exclusion)')
|
|
1479
|
+
.argument('<path>', 'Path inside a VCS root')).action((path, opts) => runApiCommand(opts, async (client) => {
|
|
1480
|
+
printJson(await client.checkVcsExclusion({ path }));
|
|
1481
|
+
}));
|
|
1482
|
+
withApiOptions(vcs
|
|
1483
|
+
.command('exclude')
|
|
1484
|
+
.description('Exclude a gitignore pattern from version history (it stays in search) and untrack the files it matches, or remove the exclusion (POST /vcs/exclude)')
|
|
1485
|
+
.argument('<glob>', 'One gitignore pattern (escape brackets to name a file: Report \\[draft\\].docx)')
|
|
1486
|
+
.option('--remove', 'Remove the exclusion instead of adding it')
|
|
1487
|
+
.option('--root <root>', 'VCS root the glob is relative to')
|
|
1488
|
+
.option('--confirm', `Allow untracking more than ${String(VCS_EXCLUDE_CONFIRM_THRESHOLD)} files`)).action((glob, opts) => runApiCommand(opts, async (client) => {
|
|
1489
|
+
printJson(await client.excludeVcsPath({
|
|
1490
|
+
glob,
|
|
1491
|
+
root: opts.root,
|
|
1492
|
+
...(opts.remove ? { remove: true } : {}),
|
|
1493
|
+
...(opts.confirm ? { confirm: true } : {}),
|
|
1494
|
+
}));
|
|
1495
|
+
}, CHECK_EXCLUDE));
|
|
1380
1496
|
}
|
|
1381
1497
|
|
|
1382
1498
|
/**
|
|
1383
1499
|
* @module cli/jeeves-watcher/customCommands
|
|
1384
1500
|
* Domain-specific CLI commands registered via the descriptor's customCliCommands.
|
|
1385
|
-
*
|
|
1501
|
+
* Every API call is one SDK client call (D37) with a 60 s timeout (`cliClient`), sending the bearer key when one is resolved.
|
|
1386
1502
|
*/
|
|
1387
|
-
const DEFAULT_PORT = String(DEFAULT_PORTS.watcher);
|
|
1388
|
-
const DEFAULT_HOST = '127.0.0.1';
|
|
1389
|
-
/** Build the API base URL from host and port options. */
|
|
1390
|
-
function baseUrl(opts) {
|
|
1391
|
-
return `http://${opts.host}:${opts.port}`;
|
|
1392
|
-
}
|
|
1393
|
-
/** Add standard --port and --host options to a command. */
|
|
1394
|
-
function withApiOptions(cmd) {
|
|
1395
|
-
return cmd
|
|
1396
|
-
.option('-p, --port <port>', 'API port', DEFAULT_PORT)
|
|
1397
|
-
.option('-H, --host <host>', 'API host', DEFAULT_HOST);
|
|
1398
|
-
}
|
|
1399
|
-
/** Wrap an async action with standard error handling. */
|
|
1400
|
-
function handleErrors(fn) {
|
|
1401
|
-
return async () => {
|
|
1402
|
-
try {
|
|
1403
|
-
await fn();
|
|
1404
|
-
}
|
|
1405
|
-
catch (err) {
|
|
1406
|
-
console.error(err instanceof Error ? err.message : String(err));
|
|
1407
|
-
process.exitCode = 1;
|
|
1408
|
-
}
|
|
1409
|
-
};
|
|
1410
|
-
}
|
|
1411
|
-
/** Print JSON response to stdout. */
|
|
1412
|
-
function printJson(data) {
|
|
1413
|
-
console.log(JSON.stringify(data, null, 2));
|
|
1414
|
-
}
|
|
1415
1503
|
/**
|
|
1416
1504
|
* Register all domain-specific commands on the given Commander program.
|
|
1417
1505
|
*
|
|
1418
1506
|
* @param program - The Commander program from createServiceCli.
|
|
1419
1507
|
*/
|
|
1420
1508
|
function registerCustomCommands(program) {
|
|
1509
|
+
// Core's `config` / `config apply` call the API too: give them the key.
|
|
1510
|
+
authenticateConfigCommands(program);
|
|
1421
1511
|
// --- search ---
|
|
1422
1512
|
withApiOptions(program
|
|
1423
1513
|
.command('search')
|
|
1424
1514
|
.description('Search the vector store (POST /search)')
|
|
1425
1515
|
.argument('<query>', 'Search query')
|
|
1426
|
-
.option('-l, --limit <limit>', 'Max results', '10')).action(
|
|
1427
|
-
await
|
|
1428
|
-
|
|
1429
|
-
query,
|
|
1430
|
-
limit: Number(opts.limit),
|
|
1431
|
-
});
|
|
1432
|
-
printJson(result);
|
|
1433
|
-
})();
|
|
1434
|
-
});
|
|
1516
|
+
.option('-l, --limit <limit>', 'Max results', '10')).action((query, opts) => runApiCommand(opts, async (client) => {
|
|
1517
|
+
printJson(await client.search({ query, limit: Number(opts.limit) }));
|
|
1518
|
+
}));
|
|
1435
1519
|
// --- enrich ---
|
|
1436
1520
|
withApiOptions(program
|
|
1437
1521
|
.command('enrich')
|
|
1438
1522
|
.description('Enrich document metadata (POST /metadata)')
|
|
1439
1523
|
.argument('<path>', 'File path to enrich')
|
|
1440
1524
|
.option('-k, --key <key=value...>', 'Metadata key-value pairs (repeatable)', [])
|
|
1441
|
-
.option('-j, --json <json>', 'Metadata as JSON string (e.g., \'{"key":"value"}\')')).action(
|
|
1442
|
-
|
|
1443
|
-
|
|
1444
|
-
|
|
1445
|
-
|
|
1446
|
-
|
|
1447
|
-
path,
|
|
1448
|
-
metadata,
|
|
1449
|
-
});
|
|
1450
|
-
printJson(result);
|
|
1451
|
-
})();
|
|
1452
|
-
});
|
|
1525
|
+
.option('-j, --json <json>', 'Metadata as JSON string (e.g., \'{"key":"value"}\')')).action((path, opts) => runApiCommand(opts, async (client) => {
|
|
1526
|
+
const metadata = parseMetadataArgs(opts.json, opts.key);
|
|
1527
|
+
if (!metadata)
|
|
1528
|
+
return;
|
|
1529
|
+
printJson(await client.setMetadata({ path, metadata }));
|
|
1530
|
+
}));
|
|
1453
1531
|
// --- scan ---
|
|
1454
1532
|
withApiOptions(program
|
|
1455
1533
|
.command('scan')
|
|
@@ -1458,138 +1536,80 @@ function registerCustomCommands(program) {
|
|
|
1458
1536
|
.option('-l, --limit <limit>', 'Max results', '100')
|
|
1459
1537
|
.option('-c, --cursor <cursor>', 'Cursor from previous response')
|
|
1460
1538
|
.option('--fields <fields>', 'Fields to return (comma-separated)')
|
|
1461
|
-
.option('--count-only', 'Return count only')).action(async (
|
|
1462
|
-
|
|
1463
|
-
|
|
1464
|
-
|
|
1465
|
-
|
|
1466
|
-
|
|
1467
|
-
|
|
1468
|
-
|
|
1469
|
-
|
|
1470
|
-
|
|
1471
|
-
|
|
1472
|
-
|
|
1473
|
-
|
|
1474
|
-
|
|
1475
|
-
|
|
1539
|
+
.option('--count-only', 'Return count only')).action((opts) => runApiCommand(opts, async (client) => {
|
|
1540
|
+
const filter = JSON.parse(opts.filter);
|
|
1541
|
+
printJson(await client.scan({
|
|
1542
|
+
filter,
|
|
1543
|
+
limit: Number(opts.limit),
|
|
1544
|
+
cursor: opts.cursor,
|
|
1545
|
+
fields: opts.fields
|
|
1546
|
+
? opts.fields.split(',').map((f) => f.trim())
|
|
1547
|
+
: undefined,
|
|
1548
|
+
countOnly: opts.countOnly,
|
|
1549
|
+
}));
|
|
1550
|
+
}));
|
|
1551
|
+
// --- walk ---
|
|
1552
|
+
withApiOptions(program
|
|
1553
|
+
.command('walk')
|
|
1554
|
+
.description('List watched files matching globs (POST /walk)')
|
|
1555
|
+
.argument('<globs...>', 'Glob patterns to intersect with watch paths')).action((globs, opts) => runApiCommand(opts, async (client) => {
|
|
1556
|
+
printJson(await client.walk({ globs }));
|
|
1557
|
+
}));
|
|
1476
1558
|
// --- reindex ---
|
|
1477
1559
|
withApiOptions(program
|
|
1478
1560
|
.command('reindex')
|
|
1479
1561
|
.description('Trigger a reindex operation (POST /reindex)')
|
|
1480
1562
|
.option('-s, --scope <scope>', 'Reindex scope (issues|full|rules|path|prune)', 'rules')
|
|
1481
|
-
.option('-t, --path <paths...>', 'Target path(s) for path or rules scope')
|
|
1482
|
-
|
|
1483
|
-
|
|
1484
|
-
|
|
1485
|
-
|
|
1486
|
-
|
|
1487
|
-
|
|
1488
|
-
|
|
1489
|
-
|
|
1490
|
-
|
|
1491
|
-
|
|
1492
|
-
|
|
1493
|
-
|
|
1494
|
-
|
|
1495
|
-
|
|
1563
|
+
.option('-t, --path <paths...>', 'Target path(s) for path or rules scope')
|
|
1564
|
+
.option('--dry-run', 'Return the plan without running it')).action((opts) => runApiCommand(opts, async (client) => {
|
|
1565
|
+
const scope = reindexScopeSchema.safeParse(opts.scope);
|
|
1566
|
+
if (!scope.success) {
|
|
1567
|
+
console.error(`Invalid scope "${opts.scope}". Must be one of: ${reindexScopeSchema.options.join(', ')}`);
|
|
1568
|
+
process.exitCode = 1;
|
|
1569
|
+
return;
|
|
1570
|
+
}
|
|
1571
|
+
printJson(await client.reindex({
|
|
1572
|
+
scope: scope.data,
|
|
1573
|
+
...(opts.path
|
|
1574
|
+
? { path: opts.path.length === 1 ? opts.path[0] : opts.path }
|
|
1575
|
+
: {}),
|
|
1576
|
+
...(opts.dryRun ? { dryRun: true } : {}),
|
|
1577
|
+
}));
|
|
1578
|
+
}));
|
|
1496
1579
|
// --- rebuild-metadata ---
|
|
1497
1580
|
withApiOptions(program
|
|
1498
1581
|
.command('rebuild-metadata')
|
|
1499
|
-
.description('Rebuild metadata store from Qdrant (POST /rebuild-metadata)')).action(async (
|
|
1500
|
-
await
|
|
1501
|
-
|
|
1502
|
-
printJson(result);
|
|
1503
|
-
})();
|
|
1504
|
-
});
|
|
1582
|
+
.description('Rebuild metadata store from Qdrant (POST /rebuild-metadata)')).action((opts) => runApiCommand(opts, async (client) => {
|
|
1583
|
+
printJson(await client.rebuildMetadata({}));
|
|
1584
|
+
}));
|
|
1505
1585
|
// --- issues ---
|
|
1506
1586
|
withApiOptions(program
|
|
1507
1587
|
.command('issues')
|
|
1508
|
-
.description('List current processing issues (GET /issues)')).action(async (
|
|
1509
|
-
await
|
|
1510
|
-
|
|
1511
|
-
printJson(result);
|
|
1512
|
-
})();
|
|
1513
|
-
});
|
|
1588
|
+
.description('List current processing issues (GET /issues)')).action((opts) => runApiCommand(opts, async (client) => {
|
|
1589
|
+
printJson(await client.listIssues());
|
|
1590
|
+
}));
|
|
1514
1591
|
// --- helpers ---
|
|
1515
|
-
withApiOptions(program.command('helpers').description('List available helper functions')).action(async (
|
|
1516
|
-
|
|
1517
|
-
|
|
1518
|
-
|
|
1519
|
-
|
|
1520
|
-
|
|
1521
|
-
|
|
1522
|
-
|
|
1523
|
-
|
|
1524
|
-
|
|
1525
|
-
|
|
1526
|
-
|
|
1527
|
-
|
|
1528
|
-
|
|
1529
|
-
|
|
1530
|
-
|
|
1531
|
-
|
|
1532
|
-
|
|
1533
|
-
|
|
1534
|
-
|
|
1535
|
-
|
|
1536
|
-
else {
|
|
1537
|
-
console.log(sections.join('\n\n'));
|
|
1538
|
-
}
|
|
1539
|
-
})();
|
|
1540
|
-
});
|
|
1541
|
-
}
|
|
1542
|
-
/** Parse --json and --key options into a metadata object. Returns null on validation failure. */
|
|
1543
|
-
function parseMetadataArgs(json, keys) {
|
|
1544
|
-
let metadata = {};
|
|
1545
|
-
if (json) {
|
|
1546
|
-
try {
|
|
1547
|
-
metadata = JSON.parse(json);
|
|
1548
|
-
}
|
|
1549
|
-
catch {
|
|
1550
|
-
console.error('Invalid JSON:', json);
|
|
1551
|
-
process.exitCode = 1;
|
|
1552
|
-
return null;
|
|
1553
|
-
}
|
|
1554
|
-
}
|
|
1555
|
-
if (Array.isArray(keys) && keys.length > 0) {
|
|
1556
|
-
for (const pair of keys) {
|
|
1557
|
-
const eqIndex = pair.indexOf('=');
|
|
1558
|
-
if (eqIndex === -1) {
|
|
1559
|
-
console.error(`Invalid key-value pair: ${pair}`);
|
|
1560
|
-
process.exitCode = 1;
|
|
1561
|
-
return null;
|
|
1562
|
-
}
|
|
1563
|
-
metadata[pair.slice(0, eqIndex)] = pair.slice(eqIndex + 1);
|
|
1564
|
-
}
|
|
1565
|
-
}
|
|
1566
|
-
if (Object.keys(metadata).length === 0) {
|
|
1567
|
-
console.error('No metadata provided. Use --key or --json.');
|
|
1568
|
-
process.exitCode = 1;
|
|
1569
|
-
return null;
|
|
1570
|
-
}
|
|
1571
|
-
return metadata;
|
|
1572
|
-
}
|
|
1573
|
-
/** Format a helpers section for display. */
|
|
1574
|
-
function formatHelperSection(title, data) {
|
|
1575
|
-
if (!data || Object.keys(data).length === 0)
|
|
1576
|
-
return '';
|
|
1577
|
-
const lines = [`${title}:`];
|
|
1578
|
-
for (const [namespace, entry] of Object.entries(data)) {
|
|
1579
|
-
if (entry.description) {
|
|
1580
|
-
lines.push(` [${namespace}] ${entry.description}`);
|
|
1581
|
-
}
|
|
1582
|
-
if (entry.exports) {
|
|
1583
|
-
for (const [name, desc] of Object.entries(entry.exports)) {
|
|
1584
|
-
const descPart = desc ? ` - ${desc}` : '';
|
|
1585
|
-
lines.push(` ${name.padEnd(40)}${descPart}`);
|
|
1586
|
-
}
|
|
1587
|
-
}
|
|
1588
|
-
else {
|
|
1589
|
-
lines.push(` (no exports introspected)`);
|
|
1590
|
-
}
|
|
1591
|
-
}
|
|
1592
|
-
return lines.join('\n');
|
|
1592
|
+
withApiOptions(program.command('helpers').description('List available helper functions')).action((opts) => runApiCommand(opts, async (client) => {
|
|
1593
|
+
const query = (path) => client.config({ path });
|
|
1594
|
+
const [mapResult, tplResult] = await Promise.all([
|
|
1595
|
+
query('$.mapHelpers'),
|
|
1596
|
+
query('$.templateHelpers'),
|
|
1597
|
+
]);
|
|
1598
|
+
const mapData = mapResult.result[0];
|
|
1599
|
+
const tplData = tplResult.result[0];
|
|
1600
|
+
const sections = [];
|
|
1601
|
+
const mapSection = formatHelperSection('JsonMap lib functions', mapData);
|
|
1602
|
+
if (mapSection)
|
|
1603
|
+
sections.push(mapSection);
|
|
1604
|
+
const tplSection = formatHelperSection('Handlebars helpers', tplData);
|
|
1605
|
+
if (tplSection)
|
|
1606
|
+
sections.push(tplSection);
|
|
1607
|
+
console.log(sections.length === 0
|
|
1608
|
+
? 'No helpers configured.'
|
|
1609
|
+
: sections.join('\n\n'));
|
|
1610
|
+
}));
|
|
1611
|
+
// --- vcs ---
|
|
1612
|
+
registerVcsCommands(program);
|
|
1593
1613
|
}
|
|
1594
1614
|
|
|
1595
1615
|
/**
|
|
@@ -1626,6 +1646,9 @@ const watcherDescriptor = {
|
|
|
1626
1646
|
configSchema: jeevesWatcherConfigSchema,
|
|
1627
1647
|
configFileName: 'config.json',
|
|
1628
1648
|
initTemplate: () => ({ ...INIT_CONFIG_TEMPLATE }),
|
|
1649
|
+
// Agent-facing text, shared with the plugin through watcher-core.
|
|
1650
|
+
promptRules: WATCHER_PROMPT_RULES,
|
|
1651
|
+
skill: WATCHER_SKILL,
|
|
1629
1652
|
// onConfigApply is overridden in createApiServer (api/index.ts) via the
|
|
1630
1653
|
// descriptor passed as a dependency, where it has access to the live
|
|
1631
1654
|
// reindex tracker and config getter.
|
|
@@ -1654,7 +1677,8 @@ const watcherDescriptor = {
|
|
|
1654
1677
|
* jeeves-watcher CLI entrypoint.
|
|
1655
1678
|
* Uses core's createServiceCli factory with the watcher descriptor.
|
|
1656
1679
|
*/
|
|
1680
|
+
// First: libuv reads UV_THREADPOOL_SIZE when its threadpool starts (D170).
|
|
1657
1681
|
const program = createServiceCli(watcherDescriptor);
|
|
1658
1682
|
program.parse();
|
|
1659
1683
|
|
|
1660
|
-
export {
|
|
1684
|
+
export { validateFacetTypes as a, mergeInferenceRules as b, findReservedFieldWarnings as c, shutdownComponents as d, startFromConfig as e, findUnknownSourceKeys as f, loadConfig as l, mergeSchemas as m, resolveAndCoerce as r, settlesWithin as s, validateSchemaCompleteness as v };
|