@tenonhq/dovetail-core 0.0.117 → 0.0.119

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/dist/FileUtils.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.writeFileForce = exports.writeSNFileForce = exports.writeSNFileIfNotExists = exports.writeBuildFile = exports.summarizeFile = exports.encodedPathsToFilePaths = exports.isValidPath = exports.splitEncodedPaths = exports.getPathsInPath = exports.isDirectory = exports.toAbsolutePath = exports.getFileContextFromPath = exports.getFileContextWithSkipReason = exports.getBuildExt = exports.isUnderPath = exports.appendToPath = exports.pathExists = exports.createDirRecursively = exports.writeSNFileIfDifferent = exports.writeSNFileCurry = exports.writeScopeManifest = exports.writeManifestFile = exports.SNFileExists = void 0;
39
+ exports.writeFileForce = exports.writeSNFileForce = exports.writeSNFileIfNotExists = exports.writeBuildFile = exports.summarizeFile = exports.encodedPathsToFilePaths = exports.isValidPath = exports.splitEncodedPaths = exports.getPathsInPath = exports.isExistingDirectory = exports.isDirectory = exports.toAbsolutePath = exports.getFileContextFromPath = exports.getFileContextWithSkipReason = exports.getBuildExt = exports.isUnderPath = exports.appendToPath = exports.pathExists = exports.createDirRecursively = exports.writeSNFileIfDifferent = exports.writeSNFileCurry = exports.writeScopeManifest = exports.writeManifestFile = exports.SNFileExists = void 0;
40
40
  exports.writeEnvVar = writeEnvVar;
41
41
  exports.writeEnvVars = writeEnvVars;
42
42
  const constants_1 = require("./constants");
@@ -263,11 +263,41 @@ const getFileContextFromPath = (filePath) => {
263
263
  exports.getFileContextFromPath = getFileContextFromPath;
264
264
  const toAbsolutePath = (p) => path_1.default.isAbsolute(p) ? p : path_1.default.join(process.cwd(), p);
265
265
  exports.toAbsolutePath = toAbsolutePath;
266
+ /**
267
+ * True when `p` is a directory. THROWS if `p` does not exist — `fsp.stat`
268
+ * rejects with ENOENT and that rejection is deliberately not swallowed, because
269
+ * `getPathsInPath` relies on a missing path being an error rather than being
270
+ * silently treated as a file.
271
+ *
272
+ * If you are asking "is there a directory here?" about a path that may legally
273
+ * be absent, you want `isExistingDirectory` instead. Using this one for that
274
+ * shipped a real bug: the `--metadata-only` refresh aborted an entire scope on
275
+ * the first record present on the instance but absent from the branch, which is
276
+ * the common case, not an edge one.
277
+ */
266
278
  const isDirectory = async (p) => {
267
279
  const stats = await fs_1.promises.stat(p);
268
280
  return stats.isDirectory();
269
281
  };
270
282
  exports.isDirectory = isDirectory;
283
+ /**
284
+ * True when `p` exists AND is a directory; false for a missing path, a regular
285
+ * file, or an unreadable one. Never throws.
286
+ *
287
+ * Prefer this over `pathExists` when the caller is about to write *into* `p` —
288
+ * `pathExists` also returns true for a regular file, so a stray file sitting at
289
+ * a record path would pass the check and fail confusingly at write time.
290
+ */
291
+ const isExistingDirectory = async (p) => {
292
+ try {
293
+ const stats = await fs_1.promises.stat(p);
294
+ return stats.isDirectory();
295
+ }
296
+ catch (e) {
297
+ return false;
298
+ }
299
+ };
300
+ exports.isExistingDirectory = isExistingDirectory;
271
301
  const getPathsInPath = async (p) => {
272
302
  if (!(0, exports.isUnderPath)(ConfigManager.getSourcePath(), p) &&
273
303
  !(0, exports.isUnderPath)(ConfigManager.getBuildPath(), p)) {
@@ -560,17 +560,10 @@ class MultiScopeWatcherManager {
560
560
  try {
561
561
  const { defaultClient } = await Promise.resolve().then(() => __importStar(require("./snClient")));
562
562
  const client = defaultClient();
563
- // Create axios client directly to get update set details
564
- const axios = (await Promise.resolve().then(() => __importStar(require("axios")))).default;
565
- const { SN_USER = "", SN_PASSWORD = "", SN_INSTANCE = "" } = process.env;
566
- const axiosClient = axios.create({
567
- auth: {
568
- username: SN_USER,
569
- password: SN_PASSWORD
570
- },
571
- baseURL: SN_INSTANCE
572
- });
573
- const response = await axiosClient.get(`/api/now/table/sys_update_set/${updateSetId}`, {
563
+ // Reuse the shared client's axios instance — it carries the resolved
564
+ // auth mode (inbound API key or basic) so this read never drifts from
565
+ // the rest of the tool's transport.
566
+ const response = await client.client.get(`/api/now/table/sys_update_set/${updateSetId}`, {
574
567
  params: {
575
568
  sysparm_fields: "name,state,sys_id"
576
569
  }
@@ -301,10 +301,12 @@ async function initScopesCommand(args) {
301
301
  Logger_1.logger.info(`Using ${apiDelay}ms delay between API calls to prevent server overload`);
302
302
  }
303
303
  try {
304
- // First check if we have environment variables set
304
+ // First check if we have environment variables set. SN_USER stays
305
+ // required even in API-key mode (acting-user resolution); the password
306
+ // is optional once an inbound SN_API_KEY is set.
305
307
  if (!process.env.SN_USER ||
306
- !process.env.SN_PASSWORD ||
307
- !process.env.SN_INSTANCE) {
308
+ !process.env.SN_INSTANCE ||
309
+ (!process.env.SN_PASSWORD && !process.env.SN_API_KEY)) {
308
310
  try {
309
311
  let loginAnswers = await (0, wizard_1.getLoginInfo)();
310
312
  await (0, wizard_1.setupDotEnv)(loginAnswers);
@@ -314,9 +316,9 @@ async function initScopesCommand(args) {
314
316
  throw error;
315
317
  }
316
318
  if (!process.env.SN_USER ||
317
- !process.env.SN_PASSWORD ||
318
- !process.env.SN_INSTANCE) {
319
- Logger_1.logger.error("Missing ServiceNow credentials. Please ensure SN_USER, SN_PASSWORD, and SN_INSTANCE are set in your .env file");
319
+ !process.env.SN_INSTANCE ||
320
+ (!process.env.SN_PASSWORD && !process.env.SN_API_KEY)) {
321
+ Logger_1.logger.error("Missing ServiceNow credentials. Please ensure SN_USER, SN_INSTANCE, and SN_PASSWORD (or SN_API_KEY) are set in your .env file");
320
322
  throw new Error("ServiceNow credentials not configured");
321
323
  }
322
324
  }
@@ -371,12 +373,14 @@ async function watchAllScopesCommand(args) {
371
373
  (0, commands_1.setLogLevel)(args);
372
374
  var dashboardProcess = null;
373
375
  try {
374
- // First check if we have environment variables set
376
+ // First check if we have environment variables set. SN_USER stays
377
+ // required even in API-key mode; the password is optional once an
378
+ // inbound SN_API_KEY is set.
375
379
  if (!process.env.SN_USER ||
376
- !process.env.SN_PASSWORD ||
377
- !process.env.SN_INSTANCE) {
380
+ !process.env.SN_INSTANCE ||
381
+ (!process.env.SN_PASSWORD && !process.env.SN_API_KEY)) {
378
382
  let loginAnswers = await (0, wizard_1.getLoginInfo)();
379
- Logger_1.logger.error("Missing ServiceNow credentials. Please ensure SN_USER, SN_PASSWORD, and SN_INSTANCE are set in your .env file");
383
+ Logger_1.logger.error("Missing ServiceNow credentials. Please ensure SN_USER, SN_INSTANCE, and SN_PASSWORD (or SN_API_KEY) are set in your .env file");
380
384
  throw new Error("ServiceNow credentials not configured");
381
385
  }
382
386
  // Start dashboard unless --no-dashboard flag is set
package/dist/appUtils.js CHANGED
@@ -95,16 +95,32 @@ const stripRecordLinkHost = (link) => link.replace(/^https?:\/\/[^/]+\.service-n
95
95
  // entirely and push keys off `value` / the manifest.
96
96
  const SYS_DATETIME_RE = /^\d{4}-\d{2}-\d{2}( \d{2}:\d{2}:\d{2})?$/;
97
97
  // References carried by nearly every record — one rename churns the whole tree.
98
- const WHOLE_TREE_REFERENCE_KEYS = ["sys_scope", "sys_package"];
99
- // Normalize for comparison: the server uses null and "" interchangeably across
100
- // the two halves of a pair, and String(null) would otherwise read as "null".
101
- const displayNorm = (v) => v === null || v === undefined ? "" : String(v);
98
+ const WHOLE_TREE_REFERENCE_KEYS = new Set(["sys_scope", "sys_package"]);
99
+ // Normalize a half of the pair for comparison. Returns null for anything that
100
+ // is not a primitive: String({}) collapses every object to "[object Object]",
101
+ // so two structurally different values would compare equal and silently lose a
102
+ // display_value. A non-primitive is treated as "not comparable" instead.
103
+ // null and "" are deliberately folded together — the server uses them
104
+ // interchangeably across the two halves, and String(null) would read as "null".
105
+ const displayNorm = (v) => {
106
+ if (v === null || v === undefined)
107
+ return "";
108
+ const t = typeof v;
109
+ if (t === "string" || t === "number" || t === "boolean")
110
+ return String(v);
111
+ return null;
112
+ };
102
113
  const shouldDropDisplayValue = (key, value, displayValue) => {
103
- if (displayNorm(value) === displayNorm(displayValue))
114
+ const normValue = displayNorm(value);
115
+ const normDisplay = displayNorm(displayValue);
116
+ // Identical -> the display_value carries no information, at any size.
117
+ if (normValue !== null && normValue === normDisplay)
104
118
  return true;
105
- if (SYS_DATETIME_RE.test(displayNorm(value)))
119
+ // Datetime -> display_value is rendered in the PULLER's timezone.
120
+ if (normValue !== null && SYS_DATETIME_RE.test(normValue))
106
121
  return true;
107
- if (WHOLE_TREE_REFERENCE_KEYS.indexOf(key) !== -1)
122
+ // Present in ~every record -> one rename rewrites the whole mirror.
123
+ if (WHOLE_TREE_REFERENCE_KEYS.has(key))
108
124
  return true;
109
125
  return false;
110
126
  };
@@ -745,11 +761,11 @@ const refreshAllFiles = async (newManifest, sourcePath, options = {}) => {
745
761
  // metadataOnly refreshes what is already checked out. A record the
746
762
  // instance has but this branch does not is left alone — pulling it in
747
763
  // would be a content change, which is exactly what this mode excludes.
748
- // pathExists, NOT isDirectory: isDirectory stats unguarded and REJECTS
749
- // on ENOENT, and processBatched propagates, so a single absent record
750
- // would abort the whole scope — the common case, since "the instance
751
- // has records this branch doesn't" is precisely what we're skipping.
752
- if (metadataOnly && !(await fUtils.pathExists(recPath))) {
764
+ // isExistingDirectory, not isDirectory (which REJECTS on ENOENT, and
765
+ // processBatched propagates, so one absent record aborted the whole
766
+ // scope) and not pathExists (which is true for a regular file too, so a
767
+ // stray file at a record path would pass and fail at write time).
768
+ if (metadataOnly && !(await fUtils.isExistingDirectory(recPath))) {
753
769
  skippedAbsentCount++;
754
770
  // Drop the downloaded content for skipped records too — filesToProcess
755
771
  // accumulates across every chunk, so holding it would grow unbounded
@@ -847,7 +863,10 @@ const refreshAllFiles = async (newManifest, sourcePath, options = {}) => {
847
863
  Logger_1.logger.debug("No file changes detected from instance (" + unchangedCount + " checked)");
848
864
  }
849
865
  if (options.benchmarkCollector) {
850
- options.benchmarkCollector.endScope(writtenCount, unchangedCount);
866
+ // metadataOnly writes no field file, so writtenCount is always 0 — report
867
+ // the metaData writes instead, or `--benchmark --metadata-only` would show
868
+ // a scope that did thousands of writes as having done nothing.
869
+ options.benchmarkCollector.endScope(metadataOnly ? metadataWrittenCount : writtenCount, metadataOnly ? skippedAbsentCount : unchangedCount);
851
870
  }
852
871
  }
853
872
  catch (e) {
@@ -98,6 +98,15 @@ exports.corePlugin = {
98
98
  },
99
99
  required: true,
100
100
  },
101
+ {
102
+ envKey: "SN_API_KEY",
103
+ prompt: {
104
+ type: "password",
105
+ message: "Inbound API key (optional, Enter to skip — becomes the default auth when set):",
106
+ mask: "*",
107
+ },
108
+ required: false,
109
+ },
101
110
  ],
102
111
  configure: [
103
112
  {
@@ -105,7 +114,7 @@ exports.corePlugin = {
105
114
  label: "Selecting ServiceNow applications",
106
115
  run: async (context) => {
107
116
  var baseUrl = instanceBaseUrl(context.env.SN_INSTANCE);
108
- var client = (0, snClient_1.snClient)(baseUrl, context.env.SN_USER, context.env.SN_PASSWORD);
117
+ var client = (0, snClient_1.snClient)(baseUrl, context.env.SN_USER, context.env.SN_PASSWORD, context.env.SN_API_KEY);
109
118
  Logger_1.logger.info("Fetching application list...");
110
119
  var apps = await (0, snClient_1.unwrapSNResponse)(client.getAppList());
111
120
  if (apps.length === 0) {
@@ -303,13 +312,16 @@ async function validateCoreLogin(context) {
303
312
  const instance = context.env.SN_INSTANCE;
304
313
  const user = context.env.SN_USER;
305
314
  const password = context.env.SN_PASSWORD;
306
- if (!instance || !user || !password) {
315
+ const apiKey = context.env.SN_API_KEY;
316
+ // SN_USER stays required even in API-key mode — the client's user-preference
317
+ // logic (current app / update set pinning) resolves the acting user from it.
318
+ if (!instance || !user || (!password && !apiKey)) {
307
319
  return "Missing required credentials";
308
320
  }
309
321
  const instanceUrl = normalizeInstance(instance);
310
322
  const baseUrl = instanceBaseUrl(instance);
311
323
  try {
312
- const client = (0, snClient_1.snClient)(baseUrl, user, password);
324
+ const client = (0, snClient_1.snClient)(baseUrl, user, password, apiKey);
313
325
  await (0, snClient_1.unwrapSNResponse)(client.getAppList());
314
326
  context.env.SN_INSTANCE = instanceUrl;
315
327
  return true;
@@ -321,7 +333,7 @@ async function validateCoreLogin(context) {
321
333
  return "Instance not found — check the URL (got: " + instanceUrl + ")";
322
334
  }
323
335
  if (status === 401 || msg.includes("401")) {
324
- return "Invalid username or password.";
336
+ return "Invalid credentials — check the username/password (or the API key and its REST API access policies).";
325
337
  }
326
338
  if (status === 403 || msg.includes("403")) {
327
339
  return "Access denied — user may lack required roles.";
@@ -95,11 +95,13 @@ function getUpdateSetConfig() {
95
95
  }
96
96
  const execFileAsync = (0, util_1.promisify)(child_process_1.execFile);
97
97
  function requireCreds() {
98
- const { SN_USER = "", SN_PASSWORD = "", SN_INSTANCE = "" } = process.env;
99
- if (!SN_USER || !SN_PASSWORD || !SN_INSTANCE) {
100
- throw new Error("Missing ServiceNow credentials. Ensure SN_INSTANCE, SN_USER, and SN_PASSWORD are set in your .env file or environment.");
98
+ const { SN_USER = "", SN_PASSWORD = "", SN_INSTANCE = "", SN_API_KEY = "", } = process.env;
99
+ // SN_USER stays required even in API-key mode (acting-user resolution);
100
+ // the password becomes optional once an inbound API key is set.
101
+ if (!SN_USER || !SN_INSTANCE || (!SN_PASSWORD && !SN_API_KEY)) {
102
+ throw new Error("Missing ServiceNow credentials. Ensure SN_INSTANCE, SN_USER, and SN_PASSWORD (or SN_API_KEY) are set in your .env file or environment.");
101
103
  }
102
- return { SN_USER, SN_PASSWORD, SN_INSTANCE };
104
+ return { SN_USER, SN_PASSWORD, SN_INSTANCE, SN_API_KEY };
103
105
  }
104
106
  function normalizeInstance(instance) {
105
107
  return instance.replace(/^https?:\/\//, "").replace(/\/$/, "");
@@ -158,6 +160,7 @@ async function buildSchemaDiffs(options) {
158
160
  instance: creds.SN_INSTANCE,
159
161
  username: creds.SN_USER,
160
162
  password: creds.SN_PASSWORD,
163
+ apiKey: creds.SN_API_KEY,
161
164
  outputDir: tmpDir,
162
165
  scopes,
163
166
  });
@@ -46,11 +46,13 @@ const path_1 = __importDefault(require("path"));
46
46
  const os_1 = __importDefault(require("os"));
47
47
  const fs_1 = require("fs");
48
48
  function requireCreds() {
49
- const { SN_USER = "", SN_PASSWORD = "", SN_INSTANCE = "" } = process.env;
50
- if (!SN_USER || !SN_PASSWORD || !SN_INSTANCE) {
51
- throw new Error("Missing ServiceNow credentials. Ensure SN_INSTANCE, SN_USER, and SN_PASSWORD are set in your .env file or environment.");
49
+ const { SN_USER = "", SN_PASSWORD = "", SN_INSTANCE = "", SN_API_KEY = "", } = process.env;
50
+ // SN_USER stays required even in API-key mode (acting-user resolution);
51
+ // the password becomes optional once an inbound API key is set.
52
+ if (!SN_USER || !SN_INSTANCE || (!SN_PASSWORD && !SN_API_KEY)) {
53
+ throw new Error("Missing ServiceNow credentials. Ensure SN_INSTANCE, SN_USER, and SN_PASSWORD (or SN_API_KEY) are set in your .env file or environment.");
52
54
  }
53
- return { SN_USER, SN_PASSWORD, SN_INSTANCE };
55
+ return { SN_USER, SN_PASSWORD, SN_INSTANCE, SN_API_KEY };
54
56
  }
55
57
  function normalizeInstance(instance) {
56
58
  return instance.replace(/^https?:\/\//, "").replace(/\/$/, "");
@@ -74,7 +76,7 @@ function outputDirFor(args) {
74
76
  return args.output ? path_1.default.resolve(args.output) : path_1.default.join(rootDir, "schema");
75
77
  }
76
78
  async function schemaPullCommand(args) {
77
- const { SN_USER, SN_PASSWORD, SN_INSTANCE } = requireCreds();
79
+ const { SN_USER, SN_PASSWORD, SN_INSTANCE, SN_API_KEY } = requireCreds();
78
80
  const scopes = resolveScopes(args);
79
81
  const outputDir = outputDirFor(args);
80
82
  try {
@@ -82,6 +84,7 @@ async function schemaPullCommand(args) {
82
84
  instance: SN_INSTANCE,
83
85
  username: SN_USER,
84
86
  password: SN_PASSWORD,
87
+ apiKey: SN_API_KEY,
85
88
  outputDir,
86
89
  scopes,
87
90
  });
@@ -119,6 +122,7 @@ async function resolveRefDir(options) {
119
122
  instance: creds.SN_INSTANCE,
120
123
  username: creds.SN_USER,
121
124
  password: creds.SN_PASSWORD,
125
+ apiKey: creds.SN_API_KEY,
122
126
  outputDir: tmpDir,
123
127
  scopes,
124
128
  });
package/dist/snClient.js CHANGED
@@ -243,17 +243,26 @@ const processPushResponse = (response, recSummary) => {
243
243
  };
244
244
  };
245
245
  exports.processPushResponse = processPushResponse;
246
- const snClient = (baseURL, username, password) => {
246
+ const snClient = (baseURL, username, password, apiKey) => {
247
247
  const jar = new tough_cookie_1.CookieJar();
248
+ // When an inbound API key is supplied it is the default auth mode: the
249
+ // x-sn-apikey header replaces basic auth entirely. Requires key-enabled
250
+ // REST API access policies on the instance.
251
+ const baseHeaders = {
252
+ "Content-Type": "application/json",
253
+ };
254
+ if (apiKey) {
255
+ baseHeaders["x-sn-apikey"] = apiKey;
256
+ }
248
257
  const rawAxios = (0, axios_cookiejar_support_1.wrapper)(axios_1.default.create({
249
258
  withCredentials: true,
250
- auth: {
251
- username,
252
- password,
253
- },
254
- headers: {
255
- "Content-Type": "application/json",
256
- },
259
+ auth: apiKey
260
+ ? undefined
261
+ : {
262
+ username,
263
+ password,
264
+ },
265
+ headers: baseHeaders,
257
266
  baseURL,
258
267
  jar,
259
268
  }));
@@ -540,8 +549,8 @@ const defaultClient = () => {
540
549
  if (internalClient) {
541
550
  return internalClient;
542
551
  }
543
- const { SN_USER = "", SN_PASSWORD = "", SN_INSTANCE = "" } = process.env;
544
- internalClient = (0, exports.snClient)(`https://${SN_INSTANCE}/`, SN_USER, SN_PASSWORD);
552
+ const { SN_USER = "", SN_PASSWORD = "", SN_INSTANCE = "", SN_API_KEY = "", } = process.env;
553
+ internalClient = (0, exports.snClient)(`https://${SN_INSTANCE}/`, SN_USER, SN_PASSWORD, SN_API_KEY);
545
554
  return internalClient;
546
555
  };
547
556
  exports.defaultClient = defaultClient;
@@ -0,0 +1,170 @@
1
+ "use strict";
2
+ /**
3
+ * Coverage for the `dove refresh` flag guards and the filesystem predicate the
4
+ * `--metadata-only` skip depends on.
5
+ *
6
+ * Both were gaps found reviewing the metaData work after it shipped:
7
+ *
8
+ * 1. `--metadata-only` + `--force` is contradictory (one writes no field file,
9
+ * the other overwrites every field file). refreshCommand refuses the
10
+ * combination, but that refusal was user-facing behaviour with zero tests.
11
+ *
12
+ * 2. `isDirectory` REJECTS on a missing path rather than returning false, and
13
+ * `processBatched` propagates — which aborted an entire scope on the first
14
+ * record present on the instance but absent from the branch. That is the
15
+ * common case for `--metadata-only`, not an edge one. `isExistingDirectory`
16
+ * is the guarded predicate; these tests pin the contract of all three so
17
+ * the next caller picks the right one deliberately.
18
+ */
19
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
20
+ if (k2 === undefined) k2 = k;
21
+ var desc = Object.getOwnPropertyDescriptor(m, k);
22
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
23
+ desc = { enumerable: true, get: function() { return m[k]; } };
24
+ }
25
+ Object.defineProperty(o, k2, desc);
26
+ }) : (function(o, m, k, k2) {
27
+ if (k2 === undefined) k2 = k;
28
+ o[k2] = m[k];
29
+ }));
30
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
31
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
32
+ }) : function(o, v) {
33
+ o["default"] = v;
34
+ });
35
+ var __importStar = (this && this.__importStar) || (function () {
36
+ var ownKeys = function(o) {
37
+ ownKeys = Object.getOwnPropertyNames || function (o) {
38
+ var ar = [];
39
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
40
+ return ar;
41
+ };
42
+ return ownKeys(o);
43
+ };
44
+ return function (mod) {
45
+ if (mod && mod.__esModule) return mod;
46
+ var result = {};
47
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
48
+ __setModuleDefault(result, mod);
49
+ return result;
50
+ };
51
+ })();
52
+ Object.defineProperty(exports, "__esModule", { value: true });
53
+ const fs = __importStar(require("fs"));
54
+ const os = __importStar(require("os"));
55
+ const path = __importStar(require("path"));
56
+ var mockLogger = {
57
+ info: jest.fn(),
58
+ debug: jest.fn(),
59
+ warn: jest.fn(),
60
+ error: jest.fn(),
61
+ success: jest.fn(),
62
+ setLogLevel: jest.fn(),
63
+ getLogLevel: function () { return "warn"; },
64
+ };
65
+ jest.mock("../Logger", function () { return { logger: mockLogger }; });
66
+ jest.mock("../FileLogger", function () {
67
+ return { fileLogger: { debug: jest.fn(), info: jest.fn(), warn: jest.fn(), error: jest.fn() } };
68
+ });
69
+ jest.mock("../snClient", function () {
70
+ return {
71
+ defaultClient: function () { return { getManifest: jest.fn(), getMissingFiles: jest.fn() }; },
72
+ unwrapSNResponse: function (p) { return Promise.resolve(p); },
73
+ processPushResponse: jest.fn(),
74
+ retryOnErr: jest.fn(),
75
+ retryOnHttpErr: jest.fn(),
76
+ unwrapTableAPIFirstItem: jest.fn(),
77
+ };
78
+ });
79
+ jest.mock("../config", function () {
80
+ return {
81
+ getConfig: jest.fn().mockReturnValue({ scopes: { x_cadso_core: {} }, tableOptions: {} }),
82
+ getManifest: jest.fn().mockResolvedValue({}),
83
+ getSourcePath: jest.fn(),
84
+ getSourcePathForScope: jest.fn(),
85
+ getManifestPath: jest.fn().mockReturnValue("/tmp/dove.manifest.json"),
86
+ getScopeManifestPath: jest.fn().mockReturnValue("/tmp/dove.manifest.json"),
87
+ resolveConfigForScope: jest.fn().mockReturnValue({
88
+ tables: ["sys_script_include"],
89
+ fieldOverrides: {},
90
+ apiIncludes: {},
91
+ apiExcludes: {},
92
+ }),
93
+ isMultiScopeManifest: jest.fn().mockReturnValue(true),
94
+ updateManifest: jest.fn(),
95
+ };
96
+ });
97
+ jest.mock("progress", function () {
98
+ return jest.fn().mockImplementation(function () { return { tick: jest.fn() }; });
99
+ });
100
+ const AppUtils = __importStar(require("../appUtils"));
101
+ const fUtils = __importStar(require("../FileUtils"));
102
+ const commands_1 = require("../commands");
103
+ describe("refresh — --metadata-only and --force are mutually exclusive", function () {
104
+ beforeEach(function () {
105
+ jest.clearAllMocks();
106
+ jest.spyOn(AppUtils, "syncManifest").mockResolvedValue(undefined);
107
+ });
108
+ afterEach(function () {
109
+ jest.restoreAllMocks();
110
+ });
111
+ test("rejects the combination instead of silently picking one", async function () {
112
+ // Silently honouring --force here would overwrite every field file from the
113
+ // instance for someone who explicitly asked to touch nothing but metaData.
114
+ await expect((0, commands_1.refreshCommand)({ metadataOnly: true, force: true })).rejects.toThrow(/--metadata-only cannot be combined with --force/);
115
+ });
116
+ test("does not reach syncManifest when the combination is refused", async function () {
117
+ await expect((0, commands_1.refreshCommand)({ metadataOnly: true, force: true })).rejects.toThrow();
118
+ expect(AppUtils.syncManifest).not.toHaveBeenCalled();
119
+ });
120
+ test("each flag on its own is accepted", async function () {
121
+ await (0, commands_1.refreshCommand)({ metadataOnly: true });
122
+ await (0, commands_1.refreshCommand)({ force: true });
123
+ expect(AppUtils.syncManifest).toHaveBeenCalledTimes(2);
124
+ expect(AppUtils.syncManifest.mock.calls[0][1]).toMatchObject({
125
+ metadataOnly: true,
126
+ force: false,
127
+ });
128
+ expect(AppUtils.syncManifest.mock.calls[1][1]).toMatchObject({
129
+ metadataOnly: false,
130
+ force: true,
131
+ });
132
+ });
133
+ });
134
+ describe("FileUtils directory predicates — pick the right one deliberately", function () {
135
+ var tmpRoot;
136
+ var dir;
137
+ var file;
138
+ var missing;
139
+ beforeEach(function () {
140
+ tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), "dove-dirpred-"));
141
+ dir = path.join(tmpRoot, "a-directory");
142
+ file = path.join(tmpRoot, "a-file");
143
+ missing = path.join(tmpRoot, "does-not-exist");
144
+ fs.mkdirSync(dir);
145
+ fs.writeFileSync(file, "x");
146
+ });
147
+ afterEach(function () {
148
+ try {
149
+ fs.rmSync(tmpRoot, { recursive: true, force: true });
150
+ }
151
+ catch (e) { }
152
+ });
153
+ test("isExistingDirectory: true only for a real directory, never throws", async function () {
154
+ expect(await fUtils.isExistingDirectory(dir)).toBe(true);
155
+ expect(await fUtils.isExistingDirectory(file)).toBe(false);
156
+ expect(await fUtils.isExistingDirectory(missing)).toBe(false);
157
+ });
158
+ test("isDirectory still REJECTS on a missing path (documented, relied on)", async function () {
159
+ // getPathsInPath treats a missing path as an error rather than a file, so
160
+ // this rejection is deliberate. Pinning it stops a well-meaning "fix" from
161
+ // changing that behaviour silently.
162
+ expect(await fUtils.isDirectory(dir)).toBe(true);
163
+ expect(await fUtils.isDirectory(file)).toBe(false);
164
+ await expect(fUtils.isDirectory(missing)).rejects.toThrow();
165
+ });
166
+ test("pathExists is true for a plain file — why it is the wrong write-target check", async function () {
167
+ expect(await fUtils.pathExists(file)).toBe(true);
168
+ expect(await fUtils.isExistingDirectory(file)).toBe(false);
169
+ });
170
+ });
@@ -0,0 +1,40 @@
1
+ "use strict";
2
+ // Auth-mode tests for snClient: an inbound API key (4th param) becomes the
3
+ // x-sn-apikey header on the real axios instance and basic auth is NOT set;
4
+ // without a key the existing basic-auth behavior is unchanged. Also covers
5
+ // defaultClient() picking SN_API_KEY out of the environment.
6
+ Object.defineProperty(exports, "__esModule", { value: true });
7
+ var mockLogger = {
8
+ info: jest.fn(),
9
+ debug: jest.fn(),
10
+ warn: jest.fn(),
11
+ error: jest.fn(),
12
+ getLogLevel: function () { return "debug"; },
13
+ };
14
+ jest.mock("../Logger", function () {
15
+ return { logger: mockLogger };
16
+ });
17
+ jest.mock("../FileLogger", function () {
18
+ return { fileLogger: { debug: jest.fn(), info: jest.fn(), warn: jest.fn(), error: jest.fn() } };
19
+ });
20
+ jest.mock("../genericUtils", function () {
21
+ return { wait: jest.fn().mockResolvedValue(undefined) };
22
+ });
23
+ const snClient_1 = require("../snClient");
24
+ describe("snClient — auth mode", function () {
25
+ it("key mode: sets x-sn-apikey header and no basic auth", function () {
26
+ var c = (0, snClient_1.snClient)("https://x.service-now.com/", "user", "pass", "key-123");
27
+ expect(c.client.defaults.headers["x-sn-apikey"]).toBe("key-123");
28
+ expect(c.client.defaults.auth).toBeUndefined();
29
+ });
30
+ it("basic mode: sets basic auth and no x-sn-apikey header", function () {
31
+ var c = (0, snClient_1.snClient)("https://x.service-now.com/", "user", "pass");
32
+ expect(c.client.defaults.auth).toEqual({ username: "user", password: "pass" });
33
+ expect(c.client.defaults.headers["x-sn-apikey"]).toBeUndefined();
34
+ });
35
+ it("empty-string key falls back to basic auth", function () {
36
+ var c = (0, snClient_1.snClient)("https://x.service-now.com/", "user", "pass", "");
37
+ expect(c.client.defaults.auth).toEqual({ username: "user", password: "pass" });
38
+ expect(c.client.defaults.headers["x-sn-apikey"]).toBeUndefined();
39
+ });
40
+ });
@@ -172,6 +172,15 @@ describe("stampMetadataContent — display_value DROPPED where it is churn", ()
172
172
  const out = (0, appUtils_1.stampMetadataContent)(metaFile({ controlled_by_refs: { value: null, display_value: "" } }));
173
173
  expect(parse(out).controlled_by_refs).toEqual({ value: null });
174
174
  });
175
+ it("does not treat two different objects as identical", () => {
176
+ // String({}) collapses every object to "[object Object]", so a naive
177
+ // stringify-and-compare would call these equal and drop a display_value
178
+ // that carries real information. Non-primitives are simply not comparable.
179
+ const out = (0, appUtils_1.stampMetadataContent)(metaFile({
180
+ weird: { value: { a: 1 }, display_value: { b: 2 } },
181
+ }));
182
+ expect(parse(out).weird.display_value).toEqual({ b: 2 });
183
+ });
175
184
  });
176
185
  describe("stampMetadataContent — display_value KEPT where it is information", () => {
177
186
  it("keeps a reference's human name (the sys_id -> name mapping)", () => {
package/dist/wizard.js CHANGED
@@ -24,17 +24,25 @@ async function getLoginInfo() {
24
24
  name: "password",
25
25
  message: "What is your password on that instance?",
26
26
  },
27
+ {
28
+ type: "password",
29
+ name: "apiKey",
30
+ message: "Inbound API key (optional, Enter to skip — becomes the default auth when set)?",
31
+ },
27
32
  ]);
28
33
  }
29
34
  async function setupDotEnv(answers) {
30
35
  process.env.SN_USER = answers.username;
31
36
  process.env.SN_PASSWORD = answers.password;
32
37
  process.env.SN_INSTANCE = answers.instance;
33
- (0, FileUtils_1.writeEnvVars)({
34
- vars: [
35
- { key: "SN_USER", value: answers.username },
36
- { key: "SN_PASSWORD", value: answers.password },
37
- { key: "SN_INSTANCE", value: answers.instance },
38
- ],
39
- });
38
+ var vars = [
39
+ { key: "SN_USER", value: answers.username },
40
+ { key: "SN_PASSWORD", value: answers.password },
41
+ { key: "SN_INSTANCE", value: answers.instance },
42
+ ];
43
+ if (answers.apiKey) {
44
+ process.env.SN_API_KEY = answers.apiKey;
45
+ vars.push({ key: "SN_API_KEY", value: answers.apiKey });
46
+ }
47
+ (0, FileUtils_1.writeEnvVars)({ vars: vars });
40
48
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tenonhq/dovetail-core",
3
- "version": "0.0.117",
3
+ "version": "0.0.119",
4
4
  "description": "Next-gen file syncer",
5
5
  "license": "GPL-3.0",
6
6
  "main": "./dist/index.js",
@@ -22,7 +22,7 @@
22
22
  "node": ">=22"
23
23
  },
24
24
  "devDependencies": {
25
- "@tenonhq/dovetail-types": "~0.0.20",
25
+ "@tenonhq/dovetail-types": "~0.0.21",
26
26
  "@types/dotenv": "^6.1.1",
27
27
  "@types/inquirer": "^8.2.10",
28
28
  "@types/jest": "^29.5.5",
@@ -39,7 +39,7 @@
39
39
  },
40
40
  "dependencies": {
41
41
  "@tenonhq/dovetail-clickup": "~0.0.13",
42
- "@tenonhq/dovetail-schema": "~0.0.12",
42
+ "@tenonhq/dovetail-schema": "~0.0.13",
43
43
  "axios": "^1.5.1",
44
44
  "axios-cookiejar-support": "^4.0.7",
45
45
  "axios-rate-limit": "^1.3.0",
@@ -1,7 +1,111 @@
1
1
  {
2
2
  "$schema": "dovetail-manifest/v1",
3
- "generated_at": "2026-07-28T06:17:01.139Z",
3
+ "generated_at": "2026-07-28T22:07:08.748Z",
4
4
  "events": [
5
+ {
6
+ "$schema": "dovetail-release-event/v1",
7
+ "event_id": "dovetail-servicenow@0.0.35",
8
+ "package": "@tenonhq/dovetail-servicenow",
9
+ "version": "0.0.35",
10
+ "prev_version": "0.0.34",
11
+ "published_at": "2026-07-28T22:07:08.734Z",
12
+ "git": {
13
+ "sha": "44c5d85d6b3232119e47397da43344b4185f94ff",
14
+ "tag": "dovetail-servicenow@0.0.35"
15
+ },
16
+ "semver_bump": "patch",
17
+ "commits": [
18
+ {
19
+ "sha": "44c5d85d6",
20
+ "subject": "feat(servicenow): remove-choices verb — soft-delete choice values (DEV-511) (#237)",
21
+ "pr": 237
22
+ },
23
+ {
24
+ "sha": "c80d5cc24",
25
+ "subject": "feat(servicenow): S1 execution-context gate + WRITE_DESTRUCTIVE tier (DEV-507) (#239)",
26
+ "pr": 239
27
+ },
28
+ {
29
+ "sha": "1749ba4f0",
30
+ "subject": "chore(release): publish 2 packages [skip ci]"
31
+ }
32
+ ],
33
+ "exports_delta": null,
34
+ "narrative": null
35
+ },
36
+ {
37
+ "$schema": "dovetail-release-event/v1",
38
+ "event_id": "dovetail-mcp@0.0.32",
39
+ "package": "@tenonhq/dovetail-mcp",
40
+ "version": "0.0.32",
41
+ "prev_version": "0.0.31",
42
+ "published_at": "2026-07-28T22:07:08.734Z",
43
+ "git": {
44
+ "sha": "44c5d85d6b3232119e47397da43344b4185f94ff",
45
+ "tag": "dovetail-mcp@0.0.32"
46
+ },
47
+ "semver_bump": "patch",
48
+ "commits": [
49
+ {
50
+ "sha": "1749ba4f0",
51
+ "subject": "chore(release): publish 2 packages [skip ci]"
52
+ }
53
+ ],
54
+ "exports_delta": null,
55
+ "narrative": null
56
+ },
57
+ {
58
+ "$schema": "dovetail-release-event/v1",
59
+ "event_id": "dovetail-core@0.0.118",
60
+ "package": "@tenonhq/dovetail-core",
61
+ "version": "0.0.118",
62
+ "prev_version": "0.0.117",
63
+ "published_at": "2026-07-28T07:09:33.080Z",
64
+ "git": {
65
+ "sha": "e4193968a18d54df70f599a15e2b7b940fa491b9",
66
+ "tag": "dovetail-core@0.0.118"
67
+ },
68
+ "semver_bump": "patch",
69
+ "commits": [
70
+ {
71
+ "sha": "e4193968a",
72
+ "subject": "fix(core): close review findings from the metaData work (#251)",
73
+ "pr": 251
74
+ },
75
+ {
76
+ "sha": "f245dd850",
77
+ "subject": "chore(release): @tenonhq/dovetail-core@0.0.117 [skip ci]"
78
+ }
79
+ ],
80
+ "exports_delta": null,
81
+ "narrative": null
82
+ },
83
+ {
84
+ "$schema": "dovetail-release-event/v1",
85
+ "event_id": "dovetail-core@0.0.117",
86
+ "package": "@tenonhq/dovetail-core",
87
+ "version": "0.0.117",
88
+ "prev_version": "0.0.116",
89
+ "published_at": "2026-07-28T06:44:03.528Z",
90
+ "git": {
91
+ "sha": "47199bcf436db3be5b8fd6728265444e72a96bc0",
92
+ "tag": "dovetail-core@0.0.117"
93
+ },
94
+ "semver_bump": "patch",
95
+ "commits": [
96
+ {
97
+ "sha": "47199bcf4",
98
+ "subject": "fix(core): keep informative display_value, drop only the churning kinds (#250)",
99
+ "pr": 250
100
+ },
101
+ {
102
+ "sha": "afd387889",
103
+ "subject": "chore(release): @tenonhq/dovetail-core@0.0.116 [skip ci]"
104
+ }
105
+ ],
106
+ "exports_delta": null,
107
+ "narrative": null
108
+ },
5
109
  {
6
110
  "$schema": "dovetail-release-event/v1",
7
111
  "event_id": "dovetail-core@0.0.116",
@@ -1150,110 +1254,6 @@
1150
1254
  ],
1151
1255
  "exports_delta": null,
1152
1256
  "narrative": null
1153
- },
1154
- {
1155
- "$schema": "dovetail-release-event/v1",
1156
- "event_id": "dovetail-babel-plugin@0.0.11",
1157
- "package": "@tenonhq/dovetail-babel-plugin",
1158
- "version": "0.0.11",
1159
- "prev_version": "0.0.10",
1160
- "published_at": "2026-06-16T01:18:07.728Z",
1161
- "git": {
1162
- "sha": "edabc263bd89f08b5c7a075f270c2172e82da4da",
1163
- "tag": "dovetail-babel-plugin@0.0.11"
1164
- },
1165
- "semver_bump": "patch",
1166
- "commits": [
1167
- {
1168
- "sha": "edabc263b",
1169
- "subject": "chore(deps): bump @babel/core from 7.29.0 to 7.29.6 (#181)",
1170
- "pr": 181
1171
- },
1172
- {
1173
- "sha": "de9d77971",
1174
- "subject": "chore(release): publish 14 packages [skip ci]"
1175
- }
1176
- ],
1177
- "exports_delta": null,
1178
- "narrative": null
1179
- },
1180
- {
1181
- "$schema": "dovetail-release-event/v1",
1182
- "event_id": "dovetail-babel-plugin-remove-modules@0.0.11",
1183
- "package": "@tenonhq/dovetail-babel-plugin-remove-modules",
1184
- "version": "0.0.11",
1185
- "prev_version": "0.0.10",
1186
- "published_at": "2026-06-16T01:18:07.728Z",
1187
- "git": {
1188
- "sha": "edabc263bd89f08b5c7a075f270c2172e82da4da",
1189
- "tag": "dovetail-babel-plugin-remove-modules@0.0.11"
1190
- },
1191
- "semver_bump": "patch",
1192
- "commits": [
1193
- {
1194
- "sha": "edabc263b",
1195
- "subject": "chore(deps): bump @babel/core from 7.29.0 to 7.29.6 (#181)",
1196
- "pr": 181
1197
- },
1198
- {
1199
- "sha": "de9d77971",
1200
- "subject": "chore(release): publish 14 packages [skip ci]"
1201
- }
1202
- ],
1203
- "exports_delta": null,
1204
- "narrative": null
1205
- },
1206
- {
1207
- "$schema": "dovetail-release-event/v1",
1208
- "event_id": "dovetail-babel-preset-servicenow@0.0.11",
1209
- "package": "@tenonhq/dovetail-babel-preset-servicenow",
1210
- "version": "0.0.11",
1211
- "prev_version": "0.0.10",
1212
- "published_at": "2026-06-16T01:18:07.728Z",
1213
- "git": {
1214
- "sha": "edabc263bd89f08b5c7a075f270c2172e82da4da",
1215
- "tag": "dovetail-babel-preset-servicenow@0.0.11"
1216
- },
1217
- "semver_bump": "patch",
1218
- "commits": [
1219
- {
1220
- "sha": "edabc263b",
1221
- "subject": "chore(deps): bump @babel/core from 7.29.0 to 7.29.6 (#181)",
1222
- "pr": 181
1223
- },
1224
- {
1225
- "sha": "de9d77971",
1226
- "subject": "chore(release): publish 14 packages [skip ci]"
1227
- }
1228
- ],
1229
- "exports_delta": null,
1230
- "narrative": null
1231
- },
1232
- {
1233
- "$schema": "dovetail-release-event/v1",
1234
- "event_id": "dovetail-servicenow@0.0.21",
1235
- "package": "@tenonhq/dovetail-servicenow",
1236
- "version": "0.0.21",
1237
- "prev_version": "0.0.20",
1238
- "published_at": "2026-06-15T17:59:30.698Z",
1239
- "git": {
1240
- "sha": "51e4e4592d491cd337f5bc1aa93d04f582f555a1",
1241
- "tag": "dovetail-servicenow@0.0.21"
1242
- },
1243
- "semver_bump": "patch",
1244
- "commits": [
1245
- {
1246
- "sha": "51e4e4592",
1247
- "subject": "feat(mcp): per-call instance retarget for servicenow_query_table (#180)",
1248
- "pr": 180
1249
- },
1250
- {
1251
- "sha": "c5d9106c4",
1252
- "subject": "chore(release): publish 2 packages [skip ci]"
1253
- }
1254
- ],
1255
- "exports_delta": null,
1256
- "narrative": null
1257
1257
  }
1258
1258
  ]
1259
1259
  }