@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 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
- - The set is created, not activated — use `npx dove switchUpdateSet --name "<name>" -s <scope>` (or `npx dove createUpdateSet`, which creates and activates in one step).
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
- // Soft guard, not a hard fail: `watch` is a human-only local-dev tool and a
377
- // branch switch mid-watch overwrites instance records. Warn an agent off it
378
- // but leave the human path untouched. See TenonHQ/Dovetail#155.
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 = displayKey + " (" + record.sys_id.substring(0, 8) + ")";
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
- for (var d = 0; d < declaredScopes.length; d++) {
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
- for (const scopeName of Object.keys(curManifest)) {
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
- await (0, exports.syncManifest)(curManifest.scope, childOptions);
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) => {
@@ -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` and warns when it detects it is running
9
- * inside a Claude Code session. See TenonHQ/Dovetail#155.
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 inside a
24
- * Claude Code session. Non-blocking by design (TenonHQ/Dovetail#155).
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: "Path to a .env file to load for this command (default: .env in the project root). Lets one checkout target multiple instances.",
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
- Logger_1.logger.success("Refresh complete!");
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 --name " +
472
- JSON.stringify(createdSet.name) +
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 .env.prod
17
- * npx dove status -e ../envs/workshop.env
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 path from a raw argv slice. Supports both
26
- * `--env <path>` and `--env=<path>` (and the `-e` / `--env-file` spellings).
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 string, or undefined when absent.
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-file argument to an absolute path. Relative
56
- * paths resolve against the directory the command was run from (cwd), matching
57
- * where the user typed the path.
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 (path_1.default.isAbsolute(rawPath))
64
- return rawPath;
65
- return path_1.default.resolve(cwd || process.cwd(), rawPath);
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
  }