@tenonhq/dovetail-core 0.0.125 → 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 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.
@@ -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",
@@ -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;
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
  }
@@ -0,0 +1,36 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.readGitHead = readGitHead;
4
+ const child_process_1 = require("child_process");
5
+ // A full object id: SHA-1 (40 hex) or SHA-256 (64 hex) repositories.
6
+ var OBJECT_ID_PATTERN = /^[0-9a-f]{40}([0-9a-f]{24})?$/i;
7
+ /**
8
+ * @description Resolves the commit HEAD points at for the git work tree that
9
+ * contains `dir` (`git rev-parse HEAD`, so linked worktrees and submodules
10
+ * resolve correctly). Never throws: resolves `null` when `dir` is not inside a
11
+ * git work tree, the repo has no commits yet, git is not installed, or the
12
+ * output is not an object id.
13
+ * @param {string} dir - Any directory inside the work tree.
14
+ * @returns {Promise<string | null>} The HEAD commit id, or null when unknown.
15
+ */
16
+ function readGitHead(dir) {
17
+ return new Promise(function (resolve) {
18
+ if (typeof dir !== "string" || dir.trim() === "") {
19
+ resolve(null);
20
+ return;
21
+ }
22
+ try {
23
+ (0, child_process_1.execFile)("git", ["rev-parse", "HEAD"], { cwd: dir, timeout: 5000, windowsHide: true }, function (error, stdout) {
24
+ if (error) {
25
+ resolve(null);
26
+ return;
27
+ }
28
+ var head = String(stdout || "").trim();
29
+ resolve(OBJECT_ID_PATTERN.test(head) ? head : null);
30
+ });
31
+ }
32
+ catch (e) {
33
+ resolve(null);
34
+ }
35
+ });
36
+ }