@tenonhq/dovetail-core 0.0.125 → 0.0.127
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +6 -1
- package/dist/MultiScopeWatcher.js +89 -0
- package/dist/allScopesCommands.js +12 -3
- package/dist/appUtils.js +29 -11
- package/dist/claudeSession.js +50 -5
- package/dist/commander.js +42 -1
- package/dist/commands.js +16 -2
- package/dist/createRecordCommand.js +30 -3
- package/dist/envArg.js +64 -15
- package/dist/gitHead.js +36 -0
- package/dist/pullRecord.js +91 -15
- package/dist/reconcileCommand.js +2 -0
- package/dist/snClient.js +45 -3
- package/dist/tests/claudeSession.test.js +22 -0
- package/dist/tests/commanderStrict.test.js +27 -0
- package/dist/tests/createRecordUpdateSet.test.js +40 -0
- package/dist/tests/dovetailUtilsMSTableOptions.test.js +109 -2
- package/dist/tests/envArg.test.js +90 -7
- package/dist/tests/gitHead.test.js +65 -0
- package/dist/tests/multi-scope-watcher.test.js +77 -0
- package/dist/tests/pullRecord.test.js +81 -0
- package/dist/tests/reconcileCreateGuard.test.js +60 -0
- package/dist/tests/refreshFailedScopes.test.js +194 -0
- package/dist/tests/snClient.createRecordGuards.test.js +35 -0
- package/dist/tests/snClient.getScopeId.test.js +67 -0
- package/dist/tests/switchUpdateSetSysId.test.js +83 -0
- package/dist/tests/watchClaudeGuard.test.js +132 -0
- package/dist/updateSetCommands.js +19 -2
- package/package.json +1 -1
- package/release-manifest.json +131 -95
- package/skills/dove-setup-project.md +1 -1
- package/skills/dove-troubleshoot-sync.md +1 -1
package/README.md
CHANGED
|
@@ -34,6 +34,10 @@ npx dove dashboard # update-set dashboard web UI
|
|
|
34
34
|
npx dove migrate # migrate a Sincronia project to Dovetail (dry-run by default; --apply to write)
|
|
35
35
|
```
|
|
36
36
|
|
|
37
|
+
`dove watch` refuses to start (exit 1) inside a Claude Code tool shell — any shell with `CLAUDECODE` set — because a branch switch mid-watch overwrites instance records. A human who really means to run it from such a shell (e.g. an IDE terminal that inherited the variable) can set `DOVE_ALLOW_WATCH_IN_CLAUDE=1`. Other `CLAUDE_CODE_*` variables only print a warning.
|
|
38
|
+
|
|
39
|
+
The watcher also records the git `HEAD` when it starts and re-reads it before every push. If `HEAD` moved (a checkout, branch switch, reset or pull), it drops the queued changes, pushes nothing, logs an error and pauses syncing until you restart it on the branch you mean to sync. Outside a git work tree this check is off.
|
|
40
|
+
|
|
37
41
|
See [`UPDATE_SET_COMMANDS.md`](UPDATE_SET_COMMANDS.md) for the full update-set CLI surface.
|
|
38
42
|
|
|
39
43
|
### `dove create sys_update_set`
|
|
@@ -42,7 +46,8 @@ See [`UPDATE_SET_COMMANDS.md`](UPDATE_SET_COMMANDS.md) for the full update-set C
|
|
|
42
46
|
|
|
43
47
|
- `--scope` decides the application. It is **required** with `--ci`; interactively it is confirmed in the summary.
|
|
44
48
|
- Only `name` and `description` map onto the op; other `--field` values are ignored with a warning.
|
|
45
|
-
-
|
|
49
|
+
- An in-progress set with the same name already in that scope is refused (exit 1) rather than duplicated.
|
|
50
|
+
- The set is created, not activated — the success line prints its sys_id; use `npx dove switchUpdateSet --sysId <sys_id> -s <scope>` (or `npx dove createUpdateSet`, which creates and activates in one step).
|
|
46
51
|
|
|
47
52
|
## Plugins
|
|
48
53
|
|
|
@@ -50,6 +50,7 @@ const path = __importStar(require("path"));
|
|
|
50
50
|
const fs = __importStar(require("fs"));
|
|
51
51
|
const ConfigManager = __importStar(require("./config"));
|
|
52
52
|
const projectFiles_1 = require("./projectFiles");
|
|
53
|
+
const gitHead_1 = require("./gitHead");
|
|
53
54
|
const DEBOUNCE_MS = 300;
|
|
54
55
|
class MultiScopeWatcherManager {
|
|
55
56
|
scopeWatchers = new Map();
|
|
@@ -58,6 +59,12 @@ class MultiScopeWatcherManager {
|
|
|
58
59
|
cachedScope = null;
|
|
59
60
|
pendingScopes = new Map(); // scope -> first change timestamp
|
|
60
61
|
globalProcessQueue = null;
|
|
62
|
+
// Branch-switch guard: the git HEAD the watcher started on. The watcher
|
|
63
|
+
// cannot tell a `git checkout` from real edits, so it re-reads HEAD before
|
|
64
|
+
// every push and halts if it moved. null = not a git work tree (guard off).
|
|
65
|
+
gitRootDir = null;
|
|
66
|
+
gitHeadAtStart = null;
|
|
67
|
+
syncHalted = false;
|
|
61
68
|
async startWatchingAllScopes(options) {
|
|
62
69
|
var opts = options || { monitorIntervalMs: 120000 };
|
|
63
70
|
try {
|
|
@@ -68,6 +75,7 @@ class MultiScopeWatcherManager {
|
|
|
68
75
|
Logger_1.logger.error("No scopes defined in dove.config.js");
|
|
69
76
|
throw new Error("No scopes defined in configuration");
|
|
70
77
|
}
|
|
78
|
+
await this.recordGitHeadAtStart();
|
|
71
79
|
const scopes = Object.keys(config.scopes);
|
|
72
80
|
Logger_1.logger.info(`Starting multi-scope watch for ${scopes.length} scopes: ${scopes.join(", ")}`);
|
|
73
81
|
// Start watching each scope
|
|
@@ -122,6 +130,8 @@ class MultiScopeWatcherManager {
|
|
|
122
130
|
}, DEBOUNCE_MS);
|
|
123
131
|
}
|
|
124
132
|
watcher.on("change", (filePath) => {
|
|
133
|
+
if (this.syncHalted)
|
|
134
|
+
return;
|
|
125
135
|
Logger_1.logger.info(`[${scopeName}] File changed: ${path.relative(sourceDirectory, filePath)}`);
|
|
126
136
|
scopeWatcher.pushQueue.push(filePath);
|
|
127
137
|
if (!this.pendingScopes.has(scopeName)) {
|
|
@@ -130,6 +140,8 @@ class MultiScopeWatcherManager {
|
|
|
130
140
|
this.globalProcessQueue();
|
|
131
141
|
});
|
|
132
142
|
watcher.on("add", (filePath) => {
|
|
143
|
+
if (this.syncHalted)
|
|
144
|
+
return;
|
|
133
145
|
Logger_1.logger.info(`[${scopeName}] File added: ${path.relative(sourceDirectory, filePath)}`);
|
|
134
146
|
scopeWatcher.pushQueue.push(filePath);
|
|
135
147
|
if (!this.pendingScopes.has(scopeName)) {
|
|
@@ -346,6 +358,10 @@ class MultiScopeWatcherManager {
|
|
|
346
358
|
}
|
|
347
359
|
}
|
|
348
360
|
async processAllPendingScopes() {
|
|
361
|
+
if (this.syncHalted) {
|
|
362
|
+
this.dropPendingChanges();
|
|
363
|
+
return;
|
|
364
|
+
}
|
|
349
365
|
// Sort scopes by first file change timestamp (FIFO)
|
|
350
366
|
var sorted = Array.from(this.pendingScopes.entries()).sort(function (a, b) {
|
|
351
367
|
return a[1] - b[1];
|
|
@@ -355,10 +371,80 @@ class MultiScopeWatcherManager {
|
|
|
355
371
|
var scopeName = sorted[i][0];
|
|
356
372
|
var scopeWatcher = this.scopeWatchers.get(scopeName);
|
|
357
373
|
if (scopeWatcher && scopeWatcher.pushQueue.length > 0) {
|
|
374
|
+
// Re-check right before every push: a checkout can land mid-flush.
|
|
375
|
+
if (!(await this.isGitHeadUnchanged())) {
|
|
376
|
+
return;
|
|
377
|
+
}
|
|
358
378
|
await this.processScopeQueue(scopeWatcher);
|
|
359
379
|
}
|
|
360
380
|
}
|
|
361
381
|
}
|
|
382
|
+
// Records the git HEAD of the project root so later flushes can detect a
|
|
383
|
+
// branch switch. Outside a git work tree the guard is off (today's behaviour).
|
|
384
|
+
async recordGitHeadAtStart() {
|
|
385
|
+
this.syncHalted = false;
|
|
386
|
+
this.gitRootDir = null;
|
|
387
|
+
this.gitHeadAtStart = null;
|
|
388
|
+
var rootDir = null;
|
|
389
|
+
try {
|
|
390
|
+
rootDir = ConfigManager.getRootDir();
|
|
391
|
+
}
|
|
392
|
+
catch (e) {
|
|
393
|
+
rootDir = null;
|
|
394
|
+
}
|
|
395
|
+
if (typeof rootDir !== "string" || rootDir === "") {
|
|
396
|
+
Logger_1.logger.debug("[MultiScope] No project root — branch-switch guard off");
|
|
397
|
+
return;
|
|
398
|
+
}
|
|
399
|
+
var head = await (0, gitHead_1.readGitHead)(rootDir);
|
|
400
|
+
if (!head) {
|
|
401
|
+
Logger_1.logger.debug("[MultiScope] Not a git work tree — branch-switch guard off");
|
|
402
|
+
return;
|
|
403
|
+
}
|
|
404
|
+
this.gitRootDir = rootDir;
|
|
405
|
+
this.gitHeadAtStart = head;
|
|
406
|
+
Logger_1.logger.debug("[MultiScope] Branch-switch guard armed at HEAD " + head);
|
|
407
|
+
}
|
|
408
|
+
// True when it is safe to push: no guard (not a git work tree) or HEAD is
|
|
409
|
+
// where it was at start. Otherwise halts syncing and returns false.
|
|
410
|
+
async isGitHeadUnchanged() {
|
|
411
|
+
if (this.syncHalted) {
|
|
412
|
+
this.dropPendingChanges();
|
|
413
|
+
return false;
|
|
414
|
+
}
|
|
415
|
+
if (!this.gitHeadAtStart || !this.gitRootDir) {
|
|
416
|
+
return true;
|
|
417
|
+
}
|
|
418
|
+
var current = await (0, gitHead_1.readGitHead)(this.gitRootDir);
|
|
419
|
+
if (current === this.gitHeadAtStart) {
|
|
420
|
+
return true;
|
|
421
|
+
}
|
|
422
|
+
this.haltForBranchChange(current);
|
|
423
|
+
return false;
|
|
424
|
+
}
|
|
425
|
+
dropPendingChanges() {
|
|
426
|
+
var dropped = 0;
|
|
427
|
+
this.scopeWatchers.forEach(function (scopeWatcher) {
|
|
428
|
+
dropped += scopeWatcher.pushQueue.length;
|
|
429
|
+
scopeWatcher.pushQueue = [];
|
|
430
|
+
});
|
|
431
|
+
this.pendingScopes.clear();
|
|
432
|
+
return dropped;
|
|
433
|
+
}
|
|
434
|
+
// HEAD moved (or can no longer be read): the queued "changes" are most
|
|
435
|
+
// likely the other branch's files, so pushing them would overwrite live
|
|
436
|
+
// records. Drop them and pause until the user restarts the watcher.
|
|
437
|
+
haltForBranchChange(currentHead) {
|
|
438
|
+
this.syncHalted = true;
|
|
439
|
+
var dropped = this.dropPendingChanges();
|
|
440
|
+
var from = this.gitHeadAtStart ? this.gitHeadAtStart.slice(0, 12) : "unknown";
|
|
441
|
+
var to = currentHead ? currentHead.slice(0, 12) : "unreadable";
|
|
442
|
+
Logger_1.logger.error("Git HEAD changed since the watcher started (" + from + " -> " + to + "). " +
|
|
443
|
+
"A branch switch or checkout rewrites the working tree, so pushing now would " +
|
|
444
|
+
"overwrite instance records with that tree. Dropped " + dropped +
|
|
445
|
+
" queued file(s); nothing was pushed. Syncing is PAUSED — stop the watcher " +
|
|
446
|
+
"(Ctrl+C) and restart it on the branch you mean to sync.");
|
|
447
|
+
}
|
|
362
448
|
async processScopeQueue(scopeWatcher) {
|
|
363
449
|
if (scopeWatcher.pushQueue.length === 0)
|
|
364
450
|
return;
|
|
@@ -591,6 +677,9 @@ class MultiScopeWatcherManager {
|
|
|
591
677
|
this.globalProcessQueue = null;
|
|
592
678
|
}
|
|
593
679
|
this.pendingScopes.clear();
|
|
680
|
+
this.syncHalted = false;
|
|
681
|
+
this.gitRootDir = null;
|
|
682
|
+
this.gitHeadAtStart = null;
|
|
594
683
|
// Stop update set monitoring
|
|
595
684
|
if (this.updateSetCheckInterval) {
|
|
596
685
|
clearInterval(this.updateSetCheckInterval);
|
|
@@ -373,9 +373,18 @@ async function initScopesCommand(args) {
|
|
|
373
373
|
}
|
|
374
374
|
async function watchAllScopesCommand(args) {
|
|
375
375
|
(0, commands_1.setLogLevel)(args);
|
|
376
|
-
//
|
|
377
|
-
//
|
|
378
|
-
//
|
|
376
|
+
// `watch` is a human-only local-dev tool and a branch switch mid-watch
|
|
377
|
+
// overwrites instance records. An agent's shell call blocks on (or
|
|
378
|
+
// backgrounds) the daemon, so a warning alone can't stop it: fail closed in
|
|
379
|
+
// a Claude Code tool shell (CLAUDECODE) unless a human sets the override.
|
|
380
|
+
// The broader CLAUDE_CODE_* signal also matches humans' config variables,
|
|
381
|
+
// so it only warns. See TenonHQ/Dovetail#155.
|
|
382
|
+
if ((0, claudeSession_1.isClaudeCodeToolShell)() && !(0, claudeSession_1.isWatchInClaudeAllowed)()) {
|
|
383
|
+
Logger_1.logger.error(claudeSession_1.WATCH_BLOCKED_IN_CLAUDE_ERROR);
|
|
384
|
+
FileLogger_1.fileLogger.warn("watch refused inside a Claude Code tool shell (TenonHQ/Dovetail#155)");
|
|
385
|
+
process.exit(1);
|
|
386
|
+
return;
|
|
387
|
+
}
|
|
379
388
|
if ((0, claudeSession_1.isClaudeCodeSession)()) {
|
|
380
389
|
Logger_1.logger.warn(claudeSession_1.WATCH_HUMAN_ONLY_WARNING);
|
|
381
390
|
FileLogger_1.fileLogger.warn("dove watch started inside a Claude Code session (TenonHQ/Dovetail#155)");
|
package/dist/appUtils.js
CHANGED
|
@@ -36,7 +36,7 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
|
36
36
|
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
37
37
|
};
|
|
38
38
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
39
|
-
exports.createAndAssignUpdateSet = exports.swapScope = exports.buildFiles = exports.summarizeRecord = exports.pushFiles = exports.getAppFileList = exports.groupAppFiles = exports.processMissingFiles = exports.refreshAllFiles = exports.findMissingFiles = exports.syncManifest = exports.narrowManifestToRecord = exports.narrowManifestToTables = exports.processManifest = exports.normalizeManifestKeys = exports.toSafeFolderName = exports.stampMetadataContent = exports.emptyMetadataFile = exports.EMPTY_METADATA_CONTENT = void 0;
|
|
39
|
+
exports.createAndAssignUpdateSet = exports.swapScope = exports.buildFiles = exports.summarizeRecord = exports.pushFiles = exports.getAppFileList = exports.groupAppFiles = exports.processMissingFiles = exports.refreshAllFiles = exports.findMissingFiles = exports.syncManifest = exports.narrowManifestToRecord = exports.narrowManifestToTables = exports.processManifest = exports.normalizeManifestKeys = exports.duplicateFolderName = exports.toSafeFolderName = exports.stampMetadataContent = exports.emptyMetadataFile = exports.EMPTY_METADATA_CONTENT = void 0;
|
|
40
40
|
const path_1 = __importDefault(require("path"));
|
|
41
41
|
const progress_1 = __importDefault(require("progress"));
|
|
42
42
|
const fUtils = __importStar(require("./FileUtils"));
|
|
@@ -294,6 +294,16 @@ const toSafeFolderName = (record) => {
|
|
|
294
294
|
return isUnsafe ? record.sys_id : name;
|
|
295
295
|
};
|
|
296
296
|
exports.toSafeFolderName = toSafeFolderName;
|
|
297
|
+
/**
|
|
298
|
+
* The key a record gets when its folder name is already taken by a different
|
|
299
|
+
* record in the same table: the display key plus the first 8 hex chars of its
|
|
300
|
+
* sys_id. Shared by every writer that resolves key collisions so a record keeps
|
|
301
|
+
* the same folder whichever path mirrored it.
|
|
302
|
+
*/
|
|
303
|
+
const duplicateFolderName = (key, sysId) => {
|
|
304
|
+
return key + " (" + String(sysId || "").substring(0, 8) + ")";
|
|
305
|
+
};
|
|
306
|
+
exports.duplicateFolderName = duplicateFolderName;
|
|
297
307
|
/**
|
|
298
308
|
* Re-keys manifest records from sys_id to a filesystem-safe folder name.
|
|
299
309
|
* Some ServiceNow tables return records keyed by sys_id instead of display name.
|
|
@@ -316,7 +326,7 @@ const normalizeManifestKeys = (manifest) => {
|
|
|
316
326
|
var displayKey = (0, exports.toSafeFolderName)(record);
|
|
317
327
|
// Handle duplicate folder names by appending sys_id suffix
|
|
318
328
|
if (normalized[displayKey]) {
|
|
319
|
-
displayKey =
|
|
329
|
+
displayKey = (0, exports.duplicateFolderName)(displayKey, record.sys_id);
|
|
320
330
|
}
|
|
321
331
|
// Keep record.name === manifest key so all writers (which build the folder
|
|
322
332
|
// path from record.name) and push (which looks up by folder name) agree.
|
|
@@ -414,6 +424,7 @@ const narrowManifestToRecord = (manifest, table, sysId) => {
|
|
|
414
424
|
};
|
|
415
425
|
exports.narrowManifestToRecord = narrowManifestToRecord;
|
|
416
426
|
const syncManifest = async (scope, options = {}) => {
|
|
427
|
+
var failedScopes = [];
|
|
417
428
|
// Top-level entry owns the collector lifecycle. Recursive calls (all-scopes
|
|
418
429
|
// → per-scope) inherit the collector via options._benchmarkCollector.
|
|
419
430
|
var isBenchmarkOwner = false;
|
|
@@ -438,7 +449,7 @@ const syncManifest = async (scope, options = {}) => {
|
|
|
438
449
|
Logger_1.logger.warn("Skipping scope '" + scope + "' — not declared in dove.config.js `scopes`. " +
|
|
439
450
|
"Add it to config.scopes to sync, or remove its manifest file.");
|
|
440
451
|
FileLogger_1.fileLogger.debug("syncManifest: skipped undeclared scope '" + scope + "'");
|
|
441
|
-
return;
|
|
452
|
+
return { failedScopes: failedScopes };
|
|
442
453
|
}
|
|
443
454
|
Logger_1.logger.info("Refreshing scope: " + scope + "...");
|
|
444
455
|
const client = (0, snClient_1.defaultClient)();
|
|
@@ -545,20 +556,25 @@ const syncManifest = async (scope, options = {}) => {
|
|
|
545
556
|
tables: options.tables,
|
|
546
557
|
_benchmarkCollector: collector,
|
|
547
558
|
};
|
|
559
|
+
// Each child call catches its own failure and reports it in its result,
|
|
560
|
+
// so one broken scope never stops the rest — collect, then report.
|
|
561
|
+
var childScopes = [];
|
|
548
562
|
if (declaredScopes.length > 0) {
|
|
549
|
-
|
|
550
|
-
await (0, exports.syncManifest)(declaredScopes[d], childOptions);
|
|
551
|
-
}
|
|
563
|
+
childScopes = declaredScopes;
|
|
552
564
|
}
|
|
553
565
|
else if (ConfigManager.isMultiScopeManifest(curManifest)) {
|
|
554
566
|
// No declared scopes — fall back to the persisted manifest's scopes.
|
|
555
|
-
|
|
556
|
-
await (0, exports.syncManifest)(scopeName, childOptions);
|
|
557
|
-
}
|
|
567
|
+
childScopes = Object.keys(curManifest);
|
|
558
568
|
}
|
|
559
569
|
else if (curManifest.scope) {
|
|
560
570
|
// Single scope manifest
|
|
561
|
-
|
|
571
|
+
childScopes = [curManifest.scope];
|
|
572
|
+
}
|
|
573
|
+
for (var d = 0; d < childScopes.length; d++) {
|
|
574
|
+
var childResult = await (0, exports.syncManifest)(childScopes[d], childOptions);
|
|
575
|
+
if (childResult && Array.isArray(childResult.failedScopes)) {
|
|
576
|
+
failedScopes = failedScopes.concat(childResult.failedScopes);
|
|
577
|
+
}
|
|
562
578
|
}
|
|
563
579
|
}
|
|
564
580
|
}
|
|
@@ -568,7 +584,8 @@ const syncManifest = async (scope, options = {}) => {
|
|
|
568
584
|
message = e.message;
|
|
569
585
|
else
|
|
570
586
|
message = String(e);
|
|
571
|
-
Logger_1.logger.error("Refresh failed: " + message);
|
|
587
|
+
Logger_1.logger.error("Refresh failed" + (scope ? " for " + scope : "") + ": " + message);
|
|
588
|
+
failedScopes.push({ scope: scope || "(all scopes)", error: message });
|
|
572
589
|
}
|
|
573
590
|
finally {
|
|
574
591
|
if (isBenchmarkOwner && collector) {
|
|
@@ -576,6 +593,7 @@ const syncManifest = async (scope, options = {}) => {
|
|
|
576
593
|
Logger_1.logger.info(collector.formatSummary());
|
|
577
594
|
}
|
|
578
595
|
}
|
|
596
|
+
return { failedScopes: failedScopes };
|
|
579
597
|
};
|
|
580
598
|
exports.syncManifest = syncManifest;
|
|
581
599
|
const markFileMissing = (missingObj) => (table) => (recordId) => (file) => {
|
package/dist/claudeSession.js
CHANGED
|
@@ -5,14 +5,17 @@
|
|
|
5
5
|
* `dove watch` auto-syncs the working tree to the instance, and a git branch
|
|
6
6
|
* switch mid-watch pushes the post-switch file state over live records. An
|
|
7
7
|
* agent can't guarantee it stops the watcher before every branch operation, so
|
|
8
|
-
* the command is hidden from `--help
|
|
9
|
-
*
|
|
8
|
+
* the command is hidden from `--help`, refuses to start in a Claude Code tool
|
|
9
|
+
* shell (`CLAUDECODE`), and only warns on the broader `CLAUDE_CODE_*` signal.
|
|
10
|
+
* See TenonHQ/Dovetail#155.
|
|
10
11
|
*
|
|
11
12
|
* Pure functions — no I/O — so the detection is unit-testable without touching
|
|
12
13
|
* `process.env` directly.
|
|
13
14
|
*/
|
|
14
15
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
15
|
-
exports.WATCH_HUMAN_ONLY_WARNING = void 0;
|
|
16
|
+
exports.WATCH_BLOCKED_IN_CLAUDE_ERROR = exports.WATCH_ALLOW_IN_CLAUDE_ENV = exports.WATCH_HUMAN_ONLY_WARNING = void 0;
|
|
17
|
+
exports.isClaudeCodeToolShell = isClaudeCodeToolShell;
|
|
18
|
+
exports.isWatchInClaudeAllowed = isWatchInClaudeAllowed;
|
|
16
19
|
exports.isClaudeCodeSession = isClaudeCodeSession;
|
|
17
20
|
// Claude Code exports `CLAUDECODE=1` into every tool shell, plus a family of
|
|
18
21
|
// `CLAUDE_CODE_*` variables (e.g. `CLAUDE_CODE_SESSION_ID`, which claude-plans
|
|
@@ -20,13 +23,55 @@ exports.isClaudeCodeSession = isClaudeCodeSession;
|
|
|
20
23
|
var CLAUDE_SESSION_FLAG = "CLAUDECODE";
|
|
21
24
|
var CLAUDE_SESSION_PREFIX = "CLAUDE_CODE_";
|
|
22
25
|
/**
|
|
23
|
-
* @description Human-only warning printed when `dove watch` starts
|
|
24
|
-
* Claude Code
|
|
26
|
+
* @description Human-only warning printed when `dove watch` starts with a
|
|
27
|
+
* Claude Code signal that does not block it (a `CLAUDE_CODE_*` config
|
|
28
|
+
* variable, or `CLAUDECODE` with the human override). TenonHQ/Dovetail#155.
|
|
25
29
|
*/
|
|
26
30
|
exports.WATCH_HUMAN_ONLY_WARNING = "⚠ `dove watch` is a human-only local-dev tool — it auto-syncs to the " +
|
|
27
31
|
"instance and a branch switch mid-watch overwrites records. Don't run this " +
|
|
28
32
|
"inside Claude Code. Use `npx dove push --diff <branch>` / `npx dove refresh` / " +
|
|
29
33
|
"`npx dove status` instead. See TenonHQ/Dovetail#155.";
|
|
34
|
+
/**
|
|
35
|
+
* @description Env var a human sets to `1` to run `dove watch` anyway from a
|
|
36
|
+
* shell that carries `CLAUDECODE` (e.g. an IDE terminal that inherited it).
|
|
37
|
+
*/
|
|
38
|
+
exports.WATCH_ALLOW_IN_CLAUDE_ENV = "DOVE_ALLOW_WATCH_IN_CLAUDE";
|
|
39
|
+
/**
|
|
40
|
+
* @description Error printed when `dove watch` refuses to start inside a
|
|
41
|
+
* Claude Code tool shell. Names the human override.
|
|
42
|
+
*/
|
|
43
|
+
exports.WATCH_BLOCKED_IN_CLAUDE_ERROR = "`dove watch` refused: this is a Claude Code tool shell (CLAUDECODE is set). " +
|
|
44
|
+
"The watcher auto-syncs to the instance and a branch switch mid-watch " +
|
|
45
|
+
"overwrites records. Use `npx dove push --diff <branch>` / `npx dove refresh` / " +
|
|
46
|
+
"`npx dove status` instead. A human who really means to run it from this " +
|
|
47
|
+
"shell can set " +
|
|
48
|
+
exports.WATCH_ALLOW_IN_CLAUDE_ENV +
|
|
49
|
+
"=1. See TenonHQ/Dovetail#155.";
|
|
50
|
+
/**
|
|
51
|
+
* @description Reports whether the environment is a Claude Code tool shell:
|
|
52
|
+
* `CLAUDECODE` set to a non-empty value. This is the hard-block signal — the
|
|
53
|
+
* broader `CLAUDE_CODE_*` prefix match also hits humans with config variables
|
|
54
|
+
* (e.g. `CLAUDE_CODE_USE_BEDROCK`) in their shell profile, so it only warns.
|
|
55
|
+
* @param {NodeJS.ProcessEnv} env - Environment to inspect (defaults to `process.env`).
|
|
56
|
+
* @returns {boolean} True when `CLAUDECODE` is set.
|
|
57
|
+
*/
|
|
58
|
+
function isClaudeCodeToolShell(env = process.env) {
|
|
59
|
+
if (!env || typeof env !== "object")
|
|
60
|
+
return false;
|
|
61
|
+
return hasValue(env[CLAUDE_SESSION_FLAG]);
|
|
62
|
+
}
|
|
63
|
+
/**
|
|
64
|
+
* @description True when the human override for running `dove watch` inside a
|
|
65
|
+
* Claude Code tool shell is set to exactly `1`.
|
|
66
|
+
* @param {NodeJS.ProcessEnv} env - Environment to inspect (defaults to `process.env`).
|
|
67
|
+
* @returns {boolean} Whether the override is active.
|
|
68
|
+
*/
|
|
69
|
+
function isWatchInClaudeAllowed(env = process.env) {
|
|
70
|
+
if (!env || typeof env !== "object")
|
|
71
|
+
return false;
|
|
72
|
+
var value = env[exports.WATCH_ALLOW_IN_CLAUDE_ENV];
|
|
73
|
+
return typeof value === "string" && value.trim() === "1";
|
|
74
|
+
}
|
|
30
75
|
/**
|
|
31
76
|
* @description Reports whether the given environment looks like a Claude Code
|
|
32
77
|
* session: `CLAUDECODE` is set, or any `CLAUDE_CODE_*` variable is set, to a
|
package/dist/commander.js
CHANGED
|
@@ -20,6 +20,31 @@ const clickupCommands_1 = require("./clickupCommands");
|
|
|
20
20
|
const loginCommand_1 = require("./loginCommand");
|
|
21
21
|
const knowledgeDiffCommand_1 = require("./knowledgeDiffCommand");
|
|
22
22
|
const yargs_1 = __importDefault(require("yargs"));
|
|
23
|
+
/**
|
|
24
|
+
* @description A bare `dove pull` (no table, no --from-update-set) is a real,
|
|
25
|
+
* all-scope refresh, and refresh has no dry run. Per-record-only flags must
|
|
26
|
+
* never fall through to it: a forgotten table would otherwise overwrite local
|
|
27
|
+
* edits across every scope.
|
|
28
|
+
* @param {Record<string, unknown>} argv - Parsed `pull` arguments.
|
|
29
|
+
* @returns {string | undefined} The refusal message, or undefined when the invocation is fine.
|
|
30
|
+
*/
|
|
31
|
+
function barePullRefusal(argv) {
|
|
32
|
+
if (argv.table || argv.fromUpdateSet)
|
|
33
|
+
return undefined;
|
|
34
|
+
const hasValue = function (v) {
|
|
35
|
+
if (Array.isArray(v))
|
|
36
|
+
return v.some(hasValue);
|
|
37
|
+
if (typeof v === "string")
|
|
38
|
+
return v.trim() !== "";
|
|
39
|
+
return v !== undefined && v !== null && v !== false;
|
|
40
|
+
};
|
|
41
|
+
const dryRun = argv.dryRun === true;
|
|
42
|
+
if (!dryRun && !hasValue(argv.sysId) && !hasValue(argv.sysIds) && !hasValue(argv["sys-ids"]))
|
|
43
|
+
return undefined;
|
|
44
|
+
return ((dryRun ? "--dry-run" : "--sys-ids") +
|
|
45
|
+
" needs a table: a per-record pull is 'dove pull <table> <sys_id...>' (or --from-update-set <sys_id>). " +
|
|
46
|
+
"A bare 'dove pull' is a full refresh of every scope and has no dry run, so nothing was run.");
|
|
47
|
+
}
|
|
23
48
|
/**
|
|
24
49
|
* @description Registers every `dove` command, the global options, and the
|
|
25
50
|
* parser-strictness rules on a yargs instance. Split out from initCommands so
|
|
@@ -41,7 +66,7 @@ function configureCli(cli) {
|
|
|
41
66
|
// the parser honest about what is actually accepted.
|
|
42
67
|
alias: ["e", "env-file", "envFile"],
|
|
43
68
|
type: "string",
|
|
44
|
-
describe: "
|
|
69
|
+
describe: "Instance name or path of a .env file to load for this command (default: .env in the project root). A bare name like 'prod' loads .env.prod from the current directory. Lets one checkout target multiple instances.",
|
|
45
70
|
})
|
|
46
71
|
.global("env")
|
|
47
72
|
.option("debug", {
|
|
@@ -177,10 +202,22 @@ function configureCli(cli) {
|
|
|
177
202
|
default: false,
|
|
178
203
|
describe: "Per-record only: list the files and manifest keys the pull would touch, write nothing",
|
|
179
204
|
},
|
|
205
|
+
})
|
|
206
|
+
.check(function (argv) {
|
|
207
|
+
const refusal = barePullRefusal(argv);
|
|
208
|
+
if (refusal !== undefined)
|
|
209
|
+
throw new Error(refusal);
|
|
210
|
+
return true;
|
|
180
211
|
});
|
|
181
212
|
return cmdArgs;
|
|
182
213
|
}, async (args) => {
|
|
183
214
|
const pullArgs = args;
|
|
215
|
+
if (barePullRefusal(args) !== undefined) {
|
|
216
|
+
// The builder's .check() has already reported it; never fall through
|
|
217
|
+
// to a real refresh even when the parser was told not to exit.
|
|
218
|
+
process.exitCode = 1;
|
|
219
|
+
return;
|
|
220
|
+
}
|
|
184
221
|
if (!pullArgs.table && !pullArgs.fromUpdateSet) {
|
|
185
222
|
// Bare pull == refresh. The refresh-only flags arrive undeclared
|
|
186
223
|
// (strictCommands tolerates them, yargs still camel-cases them), so
|
|
@@ -398,6 +435,10 @@ function configureCli(cli) {
|
|
|
398
435
|
type: "string",
|
|
399
436
|
describe: "Name or partial name of the update set to switch to",
|
|
400
437
|
},
|
|
438
|
+
sysId: {
|
|
439
|
+
type: "string",
|
|
440
|
+
describe: "Exact sys_id of the update set to switch to (unambiguous when names repeat)",
|
|
441
|
+
},
|
|
401
442
|
scope: {
|
|
402
443
|
alias: "s",
|
|
403
444
|
type: "string",
|
package/dist/commands.js
CHANGED
|
@@ -134,13 +134,27 @@ async function refreshCommand(args, log = true) {
|
|
|
134
134
|
", metadataOnly=" + !!args.metadataOnly +
|
|
135
135
|
", benchmark=" + !!args.benchmark +
|
|
136
136
|
", tables=" + (tables.length > 0 ? tables.join(",") : "all") + ")");
|
|
137
|
-
await AppUtils.syncManifest(args.scope, {
|
|
137
|
+
const result = await AppUtils.syncManifest(args.scope, {
|
|
138
138
|
force: !!args.force,
|
|
139
139
|
metadataOnly: !!args.metadataOnly,
|
|
140
140
|
benchmark: !!args.benchmark,
|
|
141
141
|
tables: tables.length > 0 ? tables : undefined,
|
|
142
142
|
});
|
|
143
|
-
|
|
143
|
+
const failed = (result && Array.isArray(result.failedScopes)) ? result.failedScopes : [];
|
|
144
|
+
if (failed.length > 0) {
|
|
145
|
+
// Every remaining scope was still attempted (syncManifest keeps going
|
|
146
|
+
// past a broken scope); now say so loudly and exit non-zero so scripts
|
|
147
|
+
// and CI can't mistake a partial refresh for a clean one.
|
|
148
|
+
Logger_1.logger.error("Refresh finished with " + failed.length + " failed scope" +
|
|
149
|
+
(failed.length === 1 ? "" : "s") + ":");
|
|
150
|
+
for (const f of failed) {
|
|
151
|
+
Logger_1.logger.error(" - " + f.scope + ": " + f.error);
|
|
152
|
+
}
|
|
153
|
+
process.exitCode = 1;
|
|
154
|
+
}
|
|
155
|
+
else {
|
|
156
|
+
Logger_1.logger.success("Refresh complete!");
|
|
157
|
+
}
|
|
144
158
|
setLogLevel(args);
|
|
145
159
|
}
|
|
146
160
|
catch (e) {
|
|
@@ -302,6 +302,27 @@ async function createUpdateSetRecord(client, scope, fields) {
|
|
|
302
302
|
if (typeof scopeSysId !== "string" || scopeSysId.trim() === "") {
|
|
303
303
|
throw new Error('Scope "' + scope + '" resolved without a sys_id.');
|
|
304
304
|
}
|
|
305
|
+
// Refuse a duplicate: a second in-progress set with the same name in the
|
|
306
|
+
// same scope (e.g. a retry after a timeout that had already succeeded) makes
|
|
307
|
+
// every later activate-by-name ambiguous.
|
|
308
|
+
var existingSets = await (0, snClient_1.unwrapSNResponse)(client.getInProgressUpdateSetsByName(name, scopeSysId));
|
|
309
|
+
if (Array.isArray(existingSets) && existingSets.length > 0) {
|
|
310
|
+
var existingIds = existingSets
|
|
311
|
+
.map(function (row) {
|
|
312
|
+
return row && typeof row.sys_id === "string" ? row.sys_id : "?";
|
|
313
|
+
})
|
|
314
|
+
.join(", ");
|
|
315
|
+
throw new Error('An in-progress update set named "' +
|
|
316
|
+
name +
|
|
317
|
+
'" already exists in scope ' +
|
|
318
|
+
scope +
|
|
319
|
+
" (" +
|
|
320
|
+
existingIds +
|
|
321
|
+
"). Refusing to create a duplicate — reuse it with " +
|
|
322
|
+
"npx dove switchUpdateSet --sysId <sys_id> -s " +
|
|
323
|
+
scope +
|
|
324
|
+
", or pick a different name.");
|
|
325
|
+
}
|
|
305
326
|
FileLogger_1.fileLogger.debug("createUpdateSet op:", JSON.stringify({ name: name, scope: scope, application: scopeSysId }));
|
|
306
327
|
var created = await (0, snClient_1.unwrapSNResponse)(client.createUpdateSet(name, scopeSysId, description));
|
|
307
328
|
var createdSysId = created && typeof created.sys_id === "string" ? created.sys_id : "";
|
|
@@ -468,8 +489,8 @@ async function createRecordCommand(args) {
|
|
|
468
489
|
" (" +
|
|
469
490
|
createdSet.scopeSysId +
|
|
470
491
|
").");
|
|
471
|
-
Logger_1.logger.info("Not activated. To route pushes to it: npx dove switchUpdateSet --
|
|
472
|
-
|
|
492
|
+
Logger_1.logger.info("Not activated. To route pushes to it: npx dove switchUpdateSet --sysId " +
|
|
493
|
+
createdSet.sysId +
|
|
473
494
|
" -s " +
|
|
474
495
|
createdSet.scope);
|
|
475
496
|
return;
|
|
@@ -515,9 +536,15 @@ async function createRecordCommand(args) {
|
|
|
515
536
|
Logger_1.logger.info("Syncing record to local files...");
|
|
516
537
|
FileLogger_1.fileLogger.debug("Starting single-record sync for scope:", scope);
|
|
517
538
|
try {
|
|
518
|
-
await AppUtils.syncManifest(scope, {
|
|
539
|
+
var syncResult = await AppUtils.syncManifest(scope, {
|
|
519
540
|
record: { table: table, sysId: newSysId },
|
|
520
541
|
});
|
|
542
|
+
// syncManifest reports a failed scope in its result instead of
|
|
543
|
+
// throwing; route it to the catch below so we never claim the local
|
|
544
|
+
// files were created when the refresh failed.
|
|
545
|
+
if (syncResult && syncResult.failedScopes && syncResult.failedScopes.length > 0) {
|
|
546
|
+
throw new Error(syncResult.failedScopes[0].error);
|
|
547
|
+
}
|
|
521
548
|
// Resolve the scope's own source directory — syncManifest writes into
|
|
522
549
|
// getSourcePathForScope(scope), which a scope config can override. Using
|
|
523
550
|
// the top-level getSourcePath() here would print a wrong path in
|
package/dist/envArg.js
CHANGED
|
@@ -5,33 +5,47 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
|
5
5
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
6
|
exports.parseEnvArg = parseEnvArg;
|
|
7
7
|
exports.resolveEnvArgPath = resolveEnvArgPath;
|
|
8
|
+
const fs_1 = __importDefault(require("fs"));
|
|
8
9
|
const path_1 = __importDefault(require("path"));
|
|
9
10
|
/**
|
|
10
11
|
* Per-invocation .env selection for the `dove` CLI.
|
|
11
12
|
*
|
|
12
|
-
* Every command accepts `--env <path>` (alias `-e`, also `--env-file` /
|
|
13
|
+
* Every command accepts `--env <name|path>` (alias `-e`, also `--env-file` /
|
|
13
14
|
* `--envFile`) so a single checkout can target multiple instances by pointing
|
|
14
15
|
* at different credential files, e.g.:
|
|
15
16
|
*
|
|
16
|
-
* npx dove push --env
|
|
17
|
-
* npx dove
|
|
17
|
+
* npx dove push --env prod (bare name → <cwd>/.env.prod)
|
|
18
|
+
* npx dove push --env .env.prod (explicit filename)
|
|
19
|
+
* npx dove status -e ../envs/workshop.env (relative or absolute path)
|
|
20
|
+
*
|
|
21
|
+
* A bare instance name resolves exactly like `dove-sn --env <name>` and the
|
|
22
|
+
* MCP tools' per-call `env` (see resolveEnvArgPath for the full rules).
|
|
23
|
+
*
|
|
24
|
+
* Unlike `dove-sn`, a missing file is NOT an error here: `dove login --env`
|
|
25
|
+
* creates the file it is pointed at, so the selected path may not exist yet.
|
|
18
26
|
*
|
|
19
27
|
* The flag is parsed from raw argv before config/env load (see bootstrap.ts)
|
|
20
28
|
* and also registered as a global yargs option so it appears in `--help`.
|
|
21
29
|
*/
|
|
22
30
|
// Flag spellings that select an env file. `-e` is the short alias.
|
|
23
31
|
var ENV_FLAGS = ["--env", "--env-file", "--envFile", "-e"];
|
|
32
|
+
// Prefix a bare instance name is expanded with (`prod` → `.env.prod`).
|
|
33
|
+
var BARE_NAME_PREFIX = ".env.";
|
|
24
34
|
/**
|
|
25
|
-
* @description Extracts the env-file
|
|
26
|
-
* `--env <
|
|
27
|
-
* Returns the last occurrence so a later flag overrides an earlier one.
|
|
35
|
+
* @description Extracts the env-file selector from a raw argv slice. Supports
|
|
36
|
+
* both `--env <value>` and `--env=<value>` (and the `-e` / `--env-file`
|
|
37
|
+
* spellings). Returns the last occurrence so a later flag overrides an earlier one.
|
|
28
38
|
* @param {string[]} argv - Arguments after the node + script entries (process.argv.slice(2)).
|
|
29
|
-
* @returns {string|undefined} The raw path
|
|
39
|
+
* @returns {string|undefined} The raw selector (bare name or path), or undefined when absent.
|
|
30
40
|
*/
|
|
31
41
|
function parseEnvArg(argv) {
|
|
42
|
+
if (!Array.isArray(argv))
|
|
43
|
+
return undefined;
|
|
32
44
|
var found;
|
|
33
45
|
for (var i = 0; i < argv.length; i++) {
|
|
34
46
|
var arg = argv[i];
|
|
47
|
+
if (typeof arg !== "string")
|
|
48
|
+
continue;
|
|
35
49
|
// `--env=path` / `-e=path`
|
|
36
50
|
var eq = arg.match(/^(--env|--env-file|--envFile|-e)=(.*)$/);
|
|
37
51
|
if (eq) {
|
|
@@ -47,20 +61,55 @@ function parseEnvArg(argv) {
|
|
|
47
61
|
}
|
|
48
62
|
}
|
|
49
63
|
}
|
|
50
|
-
if (found === undefined || found === "")
|
|
64
|
+
if (found === undefined || found.trim() === "")
|
|
51
65
|
return undefined;
|
|
52
66
|
return found;
|
|
53
67
|
}
|
|
54
68
|
/**
|
|
55
|
-
* @description Resolves a raw env
|
|
56
|
-
*
|
|
57
|
-
*
|
|
69
|
+
* @description Resolves a raw `--env` selector to an absolute env-file path.
|
|
70
|
+
* Mirrors `dove-sn`'s resolveEnvSelection (packages/servicenow/src/loadEnv.ts):
|
|
71
|
+
* 1. An absolute path, or any value containing a path separator, is a path;
|
|
72
|
+
* relative paths resolve against cwd (unchanged legacy behavior).
|
|
73
|
+
* 2. A value that exists as a file in cwd (e.g. `my.env`) is that file.
|
|
74
|
+
* 3. A value starting with `.` or containing a `.` (e.g. `.env.prod`,
|
|
75
|
+
* `workshop.env`) is an explicit filename in cwd, used as-is even when
|
|
76
|
+
* it does not exist yet — `dove login --env` may be about to create it.
|
|
77
|
+
* 4. Anything else is a bare instance name: `prod` → `<cwd>/.env.prod`.
|
|
78
|
+
* Does not check existence; a missing file keeps dove's historical behavior
|
|
79
|
+
* (nothing is loaded from it).
|
|
58
80
|
* @param {string} rawPath - The value returned by parseEnvArg.
|
|
59
|
-
* @param {string} [cwd] - Base directory for relative paths (defaults to process.cwd()).
|
|
81
|
+
* @param {string} [cwd] - Base directory for relative paths and bare names (defaults to process.cwd()).
|
|
60
82
|
* @returns {string} An absolute path to the requested .env file.
|
|
83
|
+
* @throws {Error} When rawPath is not a non-empty string.
|
|
61
84
|
*/
|
|
62
85
|
function resolveEnvArgPath(rawPath, cwd) {
|
|
63
|
-
if (
|
|
64
|
-
|
|
65
|
-
|
|
86
|
+
if (typeof rawPath !== "string" || rawPath.trim().length === 0) {
|
|
87
|
+
throw new Error("--env must be a non-empty env-file name or path.");
|
|
88
|
+
}
|
|
89
|
+
var value = rawPath.trim();
|
|
90
|
+
var base = typeof cwd === "string" && cwd.length > 0 ? cwd : process.cwd();
|
|
91
|
+
if (path_1.default.isAbsolute(value) || /[\\/]/.test(value)) {
|
|
92
|
+
return path_1.default.resolve(base, value);
|
|
93
|
+
}
|
|
94
|
+
var literal = path_1.default.resolve(base, value);
|
|
95
|
+
if (isExistingFile(literal)) {
|
|
96
|
+
return literal;
|
|
97
|
+
}
|
|
98
|
+
if (value.indexOf(".") !== -1) {
|
|
99
|
+
return literal;
|
|
100
|
+
}
|
|
101
|
+
return path_1.default.resolve(base, BARE_NAME_PREFIX + value);
|
|
102
|
+
}
|
|
103
|
+
/**
|
|
104
|
+
* @description True when the path exists and is a regular file. Never throws.
|
|
105
|
+
* @param {string} candidate - Absolute path to test.
|
|
106
|
+
* @returns {boolean}
|
|
107
|
+
*/
|
|
108
|
+
function isExistingFile(candidate) {
|
|
109
|
+
try {
|
|
110
|
+
return fs_1.default.statSync(candidate).isFile();
|
|
111
|
+
}
|
|
112
|
+
catch (e) {
|
|
113
|
+
return false;
|
|
114
|
+
}
|
|
66
115
|
}
|