@tenonhq/dovetail-core 0.0.124 → 0.0.126
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 +8 -2
- package/dist/MultiScopeWatcher.js +89 -0
- package/dist/allScopesCommands.js +12 -3
- package/dist/appUtils.js +12 -2
- package/dist/claudeSession.js +50 -5
- package/dist/commander.js +113 -5
- package/dist/createRecordCommand.js +23 -2
- package/dist/envArg.js +64 -15
- package/dist/gitHead.js +36 -0
- package/dist/pullRecord.js +684 -0
- package/dist/pullRecordCommand.js +161 -0
- package/dist/reconcileCommand.js +2 -0
- package/dist/snClient.js +78 -3
- package/dist/tests/claudeSession.test.js +22 -0
- package/dist/tests/commanderStrict.test.js +68 -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 +690 -0
- package/dist/tests/reconcileCreateGuard.test.js +60 -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 +356 -308
- package/skills/dove-setup-project.md +1 -1
- package/skills/dove-troubleshoot-sync.md +1 -1
package/README.md
CHANGED
|
@@ -26,13 +26,18 @@ Quick reference:
|
|
|
26
26
|
```bash
|
|
27
27
|
npx dove watch # HUMAN-ONLY local dev: multi-scope watch + dashboard (hidden from --help; stop before git checkout — see #155)
|
|
28
28
|
npx dove push # build + push current files
|
|
29
|
-
npx dove refresh # pull manifest + new files (
|
|
29
|
+
npx dove refresh # pull manifest + new files, scope-wide (alias: r; bare `dove pull` does the same)
|
|
30
|
+
npx dove pull <table> <sys_id> # mirror ONE record: its folder + its manifest key, nothing else (--dry-run, --sys-ids, --from-update-set)
|
|
30
31
|
npx dove build # local build only
|
|
31
32
|
npx dove deploy # deploy built artifacts
|
|
32
33
|
npx dove dashboard # update-set dashboard web UI
|
|
33
34
|
npx dove migrate # migrate a Sincronia project to Dovetail (dry-run by default; --apply to write)
|
|
34
35
|
```
|
|
35
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
|
+
|
|
36
41
|
See [`UPDATE_SET_COMMANDS.md`](UPDATE_SET_COMMANDS.md) for the full update-set CLI surface.
|
|
37
42
|
|
|
38
43
|
### `dove create sys_update_set`
|
|
@@ -41,7 +46,8 @@ See [`UPDATE_SET_COMMANDS.md`](UPDATE_SET_COMMANDS.md) for the full update-set C
|
|
|
41
46
|
|
|
42
47
|
- `--scope` decides the application. It is **required** with `--ci`; interactively it is confirmed in the summary.
|
|
43
48
|
- Only `name` and `description` map onto the op; other `--field` values are ignored with a warning.
|
|
44
|
-
-
|
|
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).
|
|
45
51
|
|
|
46
52
|
## Plugins
|
|
47
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.
|
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
|
@@ -13,12 +13,38 @@ const schemaCommand_1 = require("./schemaCommand");
|
|
|
13
13
|
const claudeCommand_1 = require("./claudeCommand");
|
|
14
14
|
const createRecordCommand_1 = require("./createRecordCommand");
|
|
15
15
|
const deleteRecordCommand_1 = require("./deleteRecordCommand");
|
|
16
|
+
const pullRecordCommand_1 = require("./pullRecordCommand");
|
|
16
17
|
const reconcileCommand_1 = require("./reconcileCommand");
|
|
17
18
|
const migrateCommand_1 = require("./migrateCommand");
|
|
18
19
|
const clickupCommands_1 = require("./clickupCommands");
|
|
19
20
|
const loginCommand_1 = require("./loginCommand");
|
|
20
21
|
const knowledgeDiffCommand_1 = require("./knowledgeDiffCommand");
|
|
21
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
|
+
}
|
|
22
48
|
/**
|
|
23
49
|
* @description Registers every `dove` command, the global options, and the
|
|
24
50
|
* parser-strictness rules on a yargs instance. Split out from initCommands so
|
|
@@ -40,7 +66,7 @@ function configureCli(cli) {
|
|
|
40
66
|
// the parser honest about what is actually accepted.
|
|
41
67
|
alias: ["e", "env-file", "envFile"],
|
|
42
68
|
type: "string",
|
|
43
|
-
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.",
|
|
44
70
|
})
|
|
45
71
|
.global("env")
|
|
46
72
|
.option("debug", {
|
|
@@ -87,10 +113,7 @@ function configureCli(cli) {
|
|
|
87
113
|
}, async (args) => {
|
|
88
114
|
await (0, allScopesCommands_1.watchAllScopesCommand)(args);
|
|
89
115
|
})
|
|
90
|
-
.command(
|
|
91
|
-
// `pull` is the verb people reach for ("pull before push") and it appears
|
|
92
|
-
// in downstream docs and scripts — alias it rather than silently no-op.
|
|
93
|
-
["refresh", "r", "pull"], "Pull latest manifest and file contents from the ServiceNow instance", (cmdArgs) => {
|
|
116
|
+
.command(["refresh", "r"], "Pull latest manifest and file contents from the ServiceNow instance (scope-wide; for one record see `dove pull <table> <sysId>`)", (cmdArgs) => {
|
|
94
117
|
cmdArgs.options({
|
|
95
118
|
...sharedOptions,
|
|
96
119
|
force: {
|
|
@@ -128,6 +151,87 @@ function configureCli(cli) {
|
|
|
128
151
|
return cmdArgs;
|
|
129
152
|
}, async (args) => {
|
|
130
153
|
await (0, commands_1.refreshCommand)(args);
|
|
154
|
+
})
|
|
155
|
+
.command(
|
|
156
|
+
// Two commands in one verb, chosen by the positional:
|
|
157
|
+
// dove pull → the historic alias of `dove refresh`
|
|
158
|
+
// ("pull before push" is in dozens of
|
|
159
|
+
// docs and scripts), scope-wide.
|
|
160
|
+
// dove pull <table> <sysId...> → per-record mirror (TenonHQ/Dovetail#319):
|
|
161
|
+
// ONE record folder + ONE manifest entry,
|
|
162
|
+
// every other entry byte-identical.
|
|
163
|
+
// The refresh-only flags (-t/--table, --metadata-only, --benchmark) are
|
|
164
|
+
// deliberately NOT declared here: -t would collide with the positional
|
|
165
|
+
// table, and strictCommands tolerates undeclared flags, so the handler
|
|
166
|
+
// turns a stray -t into an error instead of a silent no-op.
|
|
167
|
+
"pull [table] [sysId..]", "Mirror one or more records by sys_id into the repo without a scope refresh; with no table, same as `dove refresh`", (cmdArgs) => {
|
|
168
|
+
cmdArgs
|
|
169
|
+
.positional("table", {
|
|
170
|
+
type: "string",
|
|
171
|
+
describe: "ServiceNow table of the record(s), e.g. sys_security_acl",
|
|
172
|
+
})
|
|
173
|
+
// Singular on purpose: yargs camel-cases `--sys-ids` to `sysIds`, and a
|
|
174
|
+
// positional of the same name would swallow the flag.
|
|
175
|
+
.positional("sysId", {
|
|
176
|
+
type: "string",
|
|
177
|
+
describe: "One or more record sys_ids (space- or comma-separated)",
|
|
178
|
+
})
|
|
179
|
+
.options({
|
|
180
|
+
...sharedOptions,
|
|
181
|
+
"sys-ids": {
|
|
182
|
+
type: "string",
|
|
183
|
+
describe: "Comma-separated sys_ids (alternative to the positional list)",
|
|
184
|
+
},
|
|
185
|
+
"from-update-set": {
|
|
186
|
+
type: "string",
|
|
187
|
+
describe: "Pull every per-record capture (<table>_<sys_id>) in this update set; schema/choice-set captures are reported and skipped",
|
|
188
|
+
},
|
|
189
|
+
scope: {
|
|
190
|
+
alias: "s",
|
|
191
|
+
type: "string",
|
|
192
|
+
describe: "Target scope. Per-record: every record must belong to it (a mismatch refuses the whole pull). Bare pull: refresh this scope only.",
|
|
193
|
+
},
|
|
194
|
+
force: {
|
|
195
|
+
alias: "f",
|
|
196
|
+
type: "boolean",
|
|
197
|
+
default: false,
|
|
198
|
+
describe: "Overwrite local files even when content matches the instance",
|
|
199
|
+
},
|
|
200
|
+
"dry-run": {
|
|
201
|
+
type: "boolean",
|
|
202
|
+
default: false,
|
|
203
|
+
describe: "Per-record only: list the files and manifest keys the pull would touch, write nothing",
|
|
204
|
+
},
|
|
205
|
+
})
|
|
206
|
+
.check(function (argv) {
|
|
207
|
+
const refusal = barePullRefusal(argv);
|
|
208
|
+
if (refusal !== undefined)
|
|
209
|
+
throw new Error(refusal);
|
|
210
|
+
return true;
|
|
211
|
+
});
|
|
212
|
+
return cmdArgs;
|
|
213
|
+
}, async (args) => {
|
|
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
|
+
}
|
|
221
|
+
if (!pullArgs.table && !pullArgs.fromUpdateSet) {
|
|
222
|
+
// Bare pull == refresh. The refresh-only flags arrive undeclared
|
|
223
|
+
// (strictCommands tolerates them, yargs still camel-cases them), so
|
|
224
|
+
// they pass straight through; a bare `-t <table>` is re-mapped to
|
|
225
|
+
// refresh's --table so the old `dove pull -t x` spelling keeps working.
|
|
226
|
+
const refreshArgs = args;
|
|
227
|
+
const stray = pullArgs.t;
|
|
228
|
+
if (typeof stray === "string" || Array.isArray(stray)) {
|
|
229
|
+
refreshArgs.table = stray;
|
|
230
|
+
}
|
|
231
|
+
await (0, commands_1.refreshCommand)(refreshArgs);
|
|
232
|
+
return;
|
|
233
|
+
}
|
|
234
|
+
await (0, pullRecordCommand_1.pullRecordCommand)(pullArgs);
|
|
131
235
|
})
|
|
132
236
|
.command(["push [target]"], "[DESTRUCTIVE] Push all files from current local files to ServiceNow instance.", (cmdArgs) => {
|
|
133
237
|
cmdArgs.options({
|
|
@@ -331,6 +435,10 @@ function configureCli(cli) {
|
|
|
331
435
|
type: "string",
|
|
332
436
|
describe: "Name or partial name of the update set to switch to",
|
|
333
437
|
},
|
|
438
|
+
sysId: {
|
|
439
|
+
type: "string",
|
|
440
|
+
describe: "Exact sys_id of the update set to switch to (unambiguous when names repeat)",
|
|
441
|
+
},
|
|
334
442
|
scope: {
|
|
335
443
|
alias: "s",
|
|
336
444
|
type: "string",
|
|
@@ -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;
|
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
|
}
|