@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.
@@ -202,6 +202,13 @@ function spliceManifestRecord(manifest, scope, table, entry, previousKey) {
202
202
  if (previousKey !== undefined && key === previousKey && key !== entry.name)
203
203
  continue;
204
204
  if (key === entry.name) {
205
+ const held = existing[key];
206
+ if (held && held.sys_id && held.sys_id !== entry.sys_id) {
207
+ // Never replace another record's entry: its folder would be overwritten
208
+ // and its sys_id dropped from the manifest.
209
+ throw new Error(table + ": manifest key '" + key + "' already belongs to sys_id " + held.sys_id +
210
+ ", not " + entry.sys_id + ". Refusing to overwrite it.");
211
+ }
205
212
  records[key] = entryShape(entry);
206
213
  placed = true;
207
214
  continue;
@@ -388,6 +395,18 @@ function tableIsWhitelisted(scope, table) {
388
395
  return false;
389
396
  }
390
397
  }
398
+ /** sys_id holding `key` in `table` of a manifest, or undefined when the key is free. */
399
+ function sysIdAtKey(manifest, table, key) {
400
+ if (!manifest || !manifest.tables)
401
+ return undefined;
402
+ const tableEntry = manifest.tables[table];
403
+ if (!tableEntry || !tableEntry.records)
404
+ return undefined;
405
+ if (!Object.prototype.hasOwnProperty.call(tableEntry.records, key))
406
+ return undefined;
407
+ const rec = tableEntry.records[key];
408
+ return rec && rec.sys_id ? rec.sys_id : undefined;
409
+ }
391
410
  async function readIfExists(p) {
392
411
  try {
393
412
  return await fs_1.promises.readFile(p, "utf8");
@@ -460,6 +479,40 @@ async function planPull(rawTargets, options = {}, client = (0, snClient_1.defaul
460
479
  const requestFiles = hit ? toManifestFiles(hit.record.files) : filesFromFieldOverrides(r.scope, r.table);
461
480
  return Object.assign({}, r, { serverKey: hit ? hit.key : undefined, requestFiles });
462
481
  });
482
+ // Local manifests, read once per scope (the diff below and the key-collision
483
+ // checks both need them).
484
+ const manifestTexts = {};
485
+ const manifestsByScope = {};
486
+ for (const scope of scopes) {
487
+ const manifestPath = ConfigManager.getScopeManifestPath(scope);
488
+ manifestTexts[scope] = parseManifestText(await readIfExists(manifestPath));
489
+ manifestsByScope[scope] = {
490
+ scope,
491
+ manifestPath,
492
+ formatPreserved: manifestTexts[scope].formatPreserved,
493
+ existed: manifestTexts[scope].existed,
494
+ records: [],
495
+ };
496
+ }
497
+ // A server-provided key the local manifest gives to a DIFFERENT record means the
498
+ // server's duplicate-suffix order moved since the last refresh. Splicing it in
499
+ // would overwrite that record's entry and folder, so refuse before any content
500
+ // is fetched; only a table refresh can re-key both consistently.
501
+ const keyConflicts = [];
502
+ for (const p of pending) {
503
+ if (p.serverKey === undefined)
504
+ continue;
505
+ const holder = sysIdAtKey(manifestTexts[p.scope].manifest, p.table, p.serverKey);
506
+ if (holder !== undefined && holder !== p.sysId) {
507
+ keyConflicts.push(p.table + " " + p.sysId + ": manifest key '" + p.serverKey + "' already belongs to sys_id " +
508
+ holder + " in the local manifest for scope '" + p.scope + "'. Run 'dove refresh -t " + p.table +
509
+ "' to re-key the table. Nothing was written.");
510
+ }
511
+ }
512
+ if (keyConflicts.length > 0) {
513
+ throw new Error((keyConflicts.length === 1 ? "Refusing to pull:\n " : "Refusing to pull (" + keyConflicts.length + " problems):\n ") +
514
+ keyConflicts.join("\n "));
515
+ }
463
516
  const missing = {};
464
517
  for (const p of pending) {
465
518
  if (!missing[p.table])
@@ -468,9 +521,9 @@ async function planPull(rawTargets, options = {}, client = (0, snClient_1.defaul
468
521
  }
469
522
  const downloaded = await (0, snClient_1.unwrapSNResponse)(client.getMissingFiles(missing, config.tableOptions || {}));
470
523
  // 4. Diff against disk + local manifests.
471
- const manifestTexts = {};
472
- const manifestsByScope = {};
473
524
  const records = [];
525
+ // Fallback keys claimed earlier in THIS batch: scope -> table -> key -> sys_id.
526
+ const claimed = {};
474
527
  const forceWrite = !!options.force;
475
528
  for (const p of pending) {
476
529
  const label = p.table + " " + p.sysId;
@@ -479,21 +532,44 @@ async function planPull(rawTargets, options = {}, client = (0, snClient_1.defaul
479
532
  throw new Error(label + ": the instance returned no content for this record (bulkDownload).");
480
533
  }
481
534
  const recWarnings = [];
482
- const key = p.serverKey !== undefined
483
- ? p.serverKey
484
- : (0, appUtils_1.toSafeFolderName)({ name: dl.record.name, sys_id: p.sysId, files: [] });
485
- if (!manifestTexts[p.scope]) {
486
- const manifestPath = ConfigManager.getScopeManifestPath(p.scope);
487
- manifestTexts[p.scope] = parseManifestText(await readIfExists(manifestPath));
488
- manifestsByScope[p.scope] = {
489
- scope: p.scope,
490
- manifestPath,
491
- formatPreserved: manifestTexts[p.scope].formatPreserved,
492
- existed: manifestTexts[p.scope].existed,
493
- records: [],
535
+ const local = manifestTexts[p.scope].manifest;
536
+ let key;
537
+ if (p.serverKey !== undefined) {
538
+ key = p.serverKey;
539
+ }
540
+ else {
541
+ // Not in the server manifest: the key is the bare display name, which may
542
+ // already belong to another record (locally, on the server, or earlier in
543
+ // this batch). Disambiguate the way normalizeManifestKeys does.
544
+ if (!claimed[p.scope])
545
+ claimed[p.scope] = {};
546
+ if (!claimed[p.scope][p.table])
547
+ claimed[p.scope][p.table] = {};
548
+ const batchClaims = claimed[p.scope][p.table];
549
+ const takenByOther = (k) => {
550
+ const holders = [
551
+ sysIdAtKey(local, p.table, k),
552
+ sysIdAtKey(serverManifests[p.scope], p.table, k),
553
+ Object.prototype.hasOwnProperty.call(batchClaims, k) ? batchClaims[k] : undefined,
554
+ ];
555
+ for (const h of holders)
556
+ if (h !== undefined && h !== p.sysId)
557
+ return h;
558
+ return undefined;
494
559
  };
560
+ key = (0, appUtils_1.toSafeFolderName)({ name: dl.record.name, sys_id: p.sysId, files: [] });
561
+ if (takenByOther(key) !== undefined) {
562
+ const suffixed = (0, appUtils_1.duplicateFolderName)(key, p.sysId);
563
+ const holder = takenByOther(suffixed);
564
+ if (holder !== undefined) {
565
+ throw new Error("Refusing to pull:\n " + label + ": manifest keys '" + key + "' and '" + suffixed +
566
+ "' both belong to other records (sys_id " + holder + "). Run 'dove refresh -t " + p.table +
567
+ "' to re-key the table. Nothing was written.");
568
+ }
569
+ key = suffixed;
570
+ }
571
+ batchClaims[key] = p.sysId;
495
572
  }
496
- const local = manifestTexts[p.scope].manifest;
497
573
  const existing = findRecordBySysId(local ? local.tables : undefined, p.table, p.sysId);
498
574
  const manifestEntry = { files: p.requestFiles.map((f) => ({ name: f.name, type: f.type })), name: key, sys_id: p.sysId };
499
575
  let manifestAction = "add";
@@ -53,6 +53,7 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
53
53
  return (mod && mod.__esModule) ? mod : { "default": mod };
54
54
  };
55
55
  Object.defineProperty(exports, "__esModule", { value: true });
56
+ exports.applyCreates = applyCreates;
56
57
  exports.reconcileCommand = reconcileCommand;
57
58
  const path_1 = __importDefault(require("path"));
58
59
  const os_1 = __importDefault(require("os"));
@@ -338,6 +339,7 @@ async function buildCreateFields(scope, change) {
338
339
  }
339
340
  return fields;
340
341
  }
342
+ // Exported for tests.
341
343
  async function applyCreates(scope, creates, updateSetSysId) {
342
344
  if (creates.length === 0) {
343
345
  return [];
package/dist/snClient.js CHANGED
@@ -334,14 +334,28 @@ const snClient = (baseURL, username, password, apiKey) => {
334
334
  const endpoint = `api/now/table/${table}/${recordId}`;
335
335
  return client.patch(endpoint, fields);
336
336
  };
337
- const getScopeId = (scopeName) => {
337
+ // Resolve a scope name to its sys_scope sys_id. `scope=<name>` is not unique
338
+ // on every instance: `scope=global` also matches sys_app rows (their scope
339
+ // column is "global"), so the real Global scope is addressed by its fixed
340
+ // sys_id, and any other ambiguous name is refused rather than guessed.
341
+ const getScopeId = async (scopeName) => {
338
342
  const endpoint = "api/now/table/sys_scope";
339
- return client.get(endpoint, {
343
+ const query = scopeName === "global" ? "sys_id=global" : `scope=${scopeName}`;
344
+ const resp = await client.get(endpoint, {
340
345
  params: {
341
- sysparm_query: `scope=${scopeName}`,
346
+ sysparm_query: query,
342
347
  sysparm_fields: "sys_id",
343
348
  },
344
349
  });
350
+ const rows = resp && resp.data && Array.isArray(resp.data.result)
351
+ ? resp.data.result
352
+ : [];
353
+ if (rows.length > 1) {
354
+ const ids = rows.map((row) => (row && row.sys_id) || "?").join(", ");
355
+ throw new Error(`Scope "${scopeName}" is ambiguous: ${rows.length} sys_scope rows match ` +
356
+ `(${ids}). Refusing to guess which one owns the change.`);
357
+ }
358
+ return resp;
345
359
  };
346
360
  const getScopeById = (scopeSysId) => {
347
361
  const endpoint = "api/now/table/sys_scope";
@@ -366,6 +380,23 @@ const snClient = (baseURL, username, password, apiKey) => {
366
380
  },
367
381
  });
368
382
  };
383
+ /**
384
+ * In-progress update sets with exactly this name in one application, so a
385
+ * create can refuse a duplicate (a retry after a timeout would otherwise
386
+ * leave two same-named sets, and activate-by-name picks either). A `^` in
387
+ * the name is escaped as `^^` so it cannot split the encoded query.
388
+ */
389
+ const getInProgressUpdateSetsByName = (updateSetName, applicationSysId) => {
390
+ const endpoint = "api/now/table/sys_update_set";
391
+ const safeName = String(updateSetName).replace(/\^/g, "^^");
392
+ return client.get(endpoint, {
393
+ params: {
394
+ sysparm_query: `name=${safeName}^application=${applicationSysId}` +
395
+ "^state=in progress",
396
+ sysparm_fields: "sys_id,name,application",
397
+ },
398
+ });
399
+ };
369
400
  /**
370
401
  * Reads ONE record by sys_id, selecting only its class and owning scope, so a
371
402
  * per-record pull can resolve (and refuse) the target scope before it fetches
@@ -558,6 +589,16 @@ const snClient = (baseURL, username, password, apiKey) => {
558
589
  "or in Studio then run `dove refresh -s <scope>` to materialise " +
559
590
  "the new records locally."));
560
591
  }
592
+ // Guard: the generic createRecord op never sets `application`, so an
593
+ // update set inserted through it lands in the session app (and a
594
+ // name-only read-back still passes). Update sets must go through the
595
+ // scope-correct createUpdateSet op.
596
+ if (params && params.table === "sys_update_set") {
597
+ return Promise.reject(new Error("Refusing to insert sys_update_set via createRecord: the generic " +
598
+ "createRecord op does not set the update set's application, so it " +
599
+ "would land in the session app. Use `dove createUpdateSet --name " +
600
+ "<name> --scope <scope>` instead."));
601
+ }
561
602
  return _callDovetailApi("createRecord", (endpoint) => client.post(endpoint, params));
562
603
  };
563
604
  const deleteRecord = (params) => {
@@ -569,6 +610,7 @@ const snClient = (baseURL, username, password, apiKey) => {
569
610
  getScopeId,
570
611
  getScopeById,
571
612
  getUpdateSetById,
613
+ getInProgressUpdateSetsByName,
572
614
  getRecordScope,
573
615
  getUpdateSetMembers,
574
616
  getUserSysId,
@@ -4,6 +4,28 @@
4
4
  // explicit env object is passed so the real process.env never leaks in.
5
5
  Object.defineProperty(exports, "__esModule", { value: true });
6
6
  const claudeSession_1 = require("../claudeSession");
7
+ describe("isClaudeCodeToolShell (hard-block signal)", function () {
8
+ it("is true only when CLAUDECODE has a value", function () {
9
+ expect((0, claudeSession_1.isClaudeCodeToolShell)({ CLAUDECODE: "1" })).toBe(true);
10
+ expect((0, claudeSession_1.isClaudeCodeToolShell)({ CLAUDECODE: " " })).toBe(false);
11
+ expect((0, claudeSession_1.isClaudeCodeToolShell)({})).toBe(false);
12
+ });
13
+ it("ignores CLAUDE_CODE_* config variables a human may have in their profile", function () {
14
+ expect((0, claudeSession_1.isClaudeCodeToolShell)({ CLAUDE_CODE_USE_BEDROCK: "1" })).toBe(false);
15
+ expect((0, claudeSession_1.isClaudeCodeSession)({ CLAUDE_CODE_USE_BEDROCK: "1" })).toBe(true);
16
+ });
17
+ });
18
+ describe("isWatchInClaudeAllowed", function () {
19
+ it("accepts exactly 1", function () {
20
+ expect((0, claudeSession_1.isWatchInClaudeAllowed)({ DOVE_ALLOW_WATCH_IN_CLAUDE: "1" })).toBe(true);
21
+ });
22
+ it("rejects unset, empty, and other truthy-looking values", function () {
23
+ expect((0, claudeSession_1.isWatchInClaudeAllowed)({})).toBe(false);
24
+ expect((0, claudeSession_1.isWatchInClaudeAllowed)({ DOVE_ALLOW_WATCH_IN_CLAUDE: "" })).toBe(false);
25
+ expect((0, claudeSession_1.isWatchInClaudeAllowed)({ DOVE_ALLOW_WATCH_IN_CLAUDE: "true" })).toBe(false);
26
+ expect((0, claudeSession_1.isWatchInClaudeAllowed)({ DOVE_ALLOW_WATCH_IN_CLAUDE: "0" })).toBe(false);
27
+ });
28
+ });
7
29
  describe("isClaudeCodeSession", function () {
8
30
  it("returns false for an empty environment", function () {
9
31
  expect((0, claudeSession_1.isClaudeCodeSession)({})).toBe(false);
@@ -84,12 +84,17 @@ async function run(args) {
84
84
  }
85
85
  beforeEach(function () {
86
86
  jest.clearAllMocks();
87
+ process.exitCode = undefined;
88
+ });
89
+ afterEach(function () {
90
+ process.exitCode = undefined;
87
91
  });
88
92
  describe("dove pull", function () {
89
93
  it("routes `pull` to the refresh command", async function () {
90
94
  const { error } = await run(["pull"]);
91
95
  expect(error).toBeUndefined();
92
96
  expect(commands_1.refreshCommand).toHaveBeenCalledTimes(1);
97
+ expect(process.exitCode).toBeUndefined();
93
98
  });
94
99
  it("still routes the canonical `refresh` and its `r` alias", async function () {
95
100
  await run(["refresh"]);
@@ -136,6 +141,28 @@ describe("dove pull", function () {
136
141
  expect(argv.sysIds).toBe("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa1,bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb2");
137
142
  expect(pullRecordCommand_1.pullRecordCommand).toHaveBeenCalledTimes(1);
138
143
  });
144
+ it("refuses a bare `pull --dry-run` instead of running a real full refresh", async function () {
145
+ const { error } = await run(["pull", "--dry-run"]);
146
+ expect(error).toBeDefined();
147
+ expect(String(error)).toMatch(/--dry-run needs a table/);
148
+ expect(String(error)).toMatch(/full refresh/);
149
+ expect(process.exitCode).toBe(1);
150
+ expect(commands_1.refreshCommand).not.toHaveBeenCalled();
151
+ expect(pullRecordCommand_1.pullRecordCommand).not.toHaveBeenCalled();
152
+ });
153
+ it("refuses `pull --sys-ids <id>` with no table instead of running a full refresh", async function () {
154
+ const { error } = await run(["pull", "--sys-ids", "94243ee5c3f78f10d4ddf1db050131a6"]);
155
+ expect(error).toBeDefined();
156
+ expect(String(error)).toMatch(/needs a table/);
157
+ expect(String(error)).toMatch(/full refresh/);
158
+ expect(commands_1.refreshCommand).not.toHaveBeenCalled();
159
+ expect(pullRecordCommand_1.pullRecordCommand).not.toHaveBeenCalled();
160
+ });
161
+ it("refuses a bare `pull -t <table> --dry-run` (refresh has no dry run)", async function () {
162
+ const { error } = await run(["pull", "-t", "sys_script_include", "--dry-run"]);
163
+ expect(error).toBeDefined();
164
+ expect(commands_1.refreshCommand).not.toHaveBeenCalled();
165
+ });
139
166
  it("routes `pull --from-update-set` (no table) to the per-record pull", async function () {
140
167
  const { error, argv } = await run(["pull", "--from-update-set", "dddddddddddddddddddddddddddddddd"]);
141
168
  expect(error).toBeUndefined();
@@ -66,6 +66,7 @@ var mockClient = {
66
66
  getScopeId: jest.fn(),
67
67
  getScopeById: jest.fn(),
68
68
  getUpdateSetById: jest.fn(),
69
+ getInProgressUpdateSetsByName: jest.fn(),
69
70
  createUpdateSet: jest.fn(),
70
71
  createRecord: jest.fn(),
71
72
  };
@@ -120,6 +121,9 @@ describe("dove create sys_update_set (#231)", function () {
120
121
  }
121
122
  return axiosResult([]);
122
123
  });
124
+ mockClient.getInProgressUpdateSetsByName.mockImplementation(function () {
125
+ return axiosResult([]);
126
+ });
123
127
  mockClient.createUpdateSet.mockImplementation(function (name, scopeSysId) {
124
128
  return axiosResult({ sys_id: SET_SYS_ID, name: name, application: scopeSysId });
125
129
  });
@@ -159,6 +163,42 @@ describe("dove create sys_update_set (#231)", function () {
159
163
  expect(success[0].msg).toContain(SET_SYS_ID);
160
164
  expect(success[0].msg).toContain(REQUESTED_SCOPE);
161
165
  });
166
+ it("prints an activation hint keyed on the new set's sys_id, not its name", async function () {
167
+ mockClient.getUpdateSetById.mockReturnValue(axiosResult([{ sys_id: SET_SYS_ID, application: { value: REQUESTED_SCOPE_SYS_ID } }]));
168
+ await (0, createRecordCommand_1.createRecordCommand)({
169
+ table: "sys_update_set",
170
+ name: "Journey - Fix 231",
171
+ scope: REQUESTED_SCOPE,
172
+ ci: true,
173
+ logLevel: "info",
174
+ });
175
+ expect(mockClient.getInProgressUpdateSetsByName).toHaveBeenCalledWith("Journey - Fix 231", REQUESTED_SCOPE_SYS_ID);
176
+ var hint = logMessages.filter(function (m) {
177
+ return m.level === "info" && m.msg.indexOf("switchUpdateSet") !== -1;
178
+ });
179
+ expect(hint).toHaveLength(1);
180
+ expect(hint[0].msg).toContain("--sysId " + SET_SYS_ID);
181
+ expect(hint[0].msg).toContain("-s " + REQUESTED_SCOPE);
182
+ expect(hint[0].msg).not.toContain("--name");
183
+ });
184
+ it("refuses (exit 1, no op call) when an in-progress set with the same name already exists in the scope", async function () {
185
+ mockClient.getInProgressUpdateSetsByName.mockImplementation(function () {
186
+ return axiosResult([{ sys_id: "existingSetSysId000000000000000cd", name: "Journey - Fix 231" }]);
187
+ });
188
+ await expect((0, createRecordCommand_1.createRecordCommand)({
189
+ table: "sys_update_set",
190
+ name: "Journey - Fix 231",
191
+ scope: REQUESTED_SCOPE,
192
+ ci: true,
193
+ logLevel: "info",
194
+ })).rejects.toBeInstanceOf(ExitSignal);
195
+ expect(exitSpy).toHaveBeenCalledWith(1);
196
+ expect(mockClient.createUpdateSet).not.toHaveBeenCalled();
197
+ expect(mockClient.createRecord).not.toHaveBeenCalled();
198
+ expect(logMessages.filter(function (m) { return m.level === "success"; })).toHaveLength(0);
199
+ expect(errorText()).toContain("already exists");
200
+ expect(errorText()).toContain("existingSetSysId000000000000000cd");
201
+ });
162
202
  it("fails loudly (exit 1, no success) when the created set's application is not the requested scope", async function () {
163
203
  // The op "succeeded" but the set landed in the session's current app.
164
204
  mockClient.createUpdateSet.mockImplementation(function (name) {
@@ -56,6 +56,7 @@ function makeGlideRecord(rows, captured, validFields) {
56
56
  return validFields === null ? true : validFields.indexOf(field) !== -1;
57
57
  };
58
58
  this.query = function () {
59
+ captured.queryCalls += 1;
59
60
  i = -1;
60
61
  };
61
62
  this.next = function () {
@@ -85,7 +86,11 @@ function makeGlideRecord(rows, captured, validFields) {
85
86
  function loadUtils(opts) {
86
87
  const rows = opts.rows || [];
87
88
  const warnings = [];
88
- const captured = { encodedQueries: [], fieldQueries: [] };
89
+ const captured = {
90
+ encodedQueries: [],
91
+ fieldQueries: [],
92
+ queryCalls: 0,
93
+ };
89
94
  const metadataClasses = opts.metadataClasses || [];
90
95
  const sandbox = {
91
96
  Class: {
@@ -185,7 +190,13 @@ describe("DovetailUtilsMS.buildTableMap — scopeQuery", () => {
185
190
  expect(scopeFilters).toHaveLength(0);
186
191
  });
187
192
  it("renders {scopeId} as the scope sys_id", () => {
188
- const out = buildChoices({ scopeQuery: "sys_package={scopeId}" });
193
+ const out = buildChoices({ scopeQuery: "sys_package={scopeId}" }, [
194
+ "name",
195
+ "element",
196
+ "value",
197
+ "label",
198
+ "sys_package",
199
+ ]);
189
200
  expect(out.captured.encodedQueries).toContain("sys_package=5e9f5f8b87420250369f33373cbb3559");
190
201
  });
191
202
  it("keeps the sys_scope filter when no scopeQuery is set (existing behaviour)", () => {
@@ -229,6 +240,102 @@ describe("DovetailUtilsMS.buildTableMap — scopeQuery", () => {
229
240
  expect(out.captured.encodedQueries).toHaveLength(0);
230
241
  });
231
242
  });
243
+ describe("DovetailUtilsMS.buildTableMap — scopeQuery fails closed", () => {
244
+ // ServiceNow drops an encoded-query term on an unknown column, so an unchecked
245
+ // scopeQuery typo would return the WHOLE table (sys_choice: >200k rows) for every
246
+ // scope. buildTableMap must refuse such a query and never call query().
247
+ function expectRefused(out, reason) {
248
+ expect(out.captured.queryCalls).toBe(0);
249
+ expect(out.captured.encodedQueries).toHaveLength(0);
250
+ expect(Object.keys(out.records)).toHaveLength(0);
251
+ expect(out.warnings).toHaveLength(1);
252
+ expect(out.warnings[0]).toContain("sys_choice");
253
+ expect(out.warnings[0]).toContain("scopeQuery");
254
+ expect(out.warnings[0]).toMatch(reason);
255
+ }
256
+ // The fixture's three "Failed" labels also raise duplicate-name warnings; only
257
+ // scopeQuery refusals matter to the accept-path assertions.
258
+ function scopeQueryWarnings(warnings) {
259
+ return warnings.filter(function (w) {
260
+ return w.indexOf("scopeQuery") !== -1;
261
+ });
262
+ }
263
+ it("refuses a scopeQuery whose field does not exist on the table (typo)", () => {
264
+ const out = buildChoices({ scopeQuery: "nmeSTARTSWITH{scope}_" });
265
+ expectRefused(out, /nme/);
266
+ });
267
+ it("refuses a scopeQuery with no {scope}/{scopeId} token", () => {
268
+ const out = buildChoices({ scopeQuery: "active=true" }, null);
269
+ expectRefused(out, /\{scope\}/);
270
+ });
271
+ it("refuses a scopeQuery containing ^NQ", () => {
272
+ const out = buildChoices({
273
+ scopeQuery: "nameSTARTSWITH{scope}_^NQelement=state",
274
+ });
275
+ expectRefused(out, /\^NQ/);
276
+ });
277
+ it("verifies every ^OR term, not just the first", () => {
278
+ const out = buildChoices({
279
+ scopeQuery: "nameSTARTSWITH{scope}_^ORelmentSTARTSWITH{scope}_",
280
+ });
281
+ expectRefused(out, /elment/);
282
+ });
283
+ it("refuses a dot-walked field it cannot verify", () => {
284
+ const out = buildChoices({ scopeQuery: "name.sys_scope={scopeId}" }, null);
285
+ expectRefused(out, /name\.sys_scope/);
286
+ });
287
+ it("refuses a term with no operator", () => {
288
+ const out = buildChoices({ scopeQuery: "nameSTARTSWITH{scope}_^element" });
289
+ expectRefused(out, /element/);
290
+ });
291
+ it("refuses an empty term (^^)", () => {
292
+ const out = buildChoices({
293
+ scopeQuery: "nameSTARTSWITH{scope}_^^element=x",
294
+ });
295
+ expectRefused(out, /empty/);
296
+ });
297
+ it("refuses when the scope token would render empty", () => {
298
+ const loaded = loadUtils({
299
+ rows: CHOICE_ROWS,
300
+ validFields: ["name", "element", "value", "label"],
301
+ });
302
+ const result = loaded.utils.buildTableMap({
303
+ tableName: "sys_choice",
304
+ scopeId: null,
305
+ scopeName: "",
306
+ getContents: false,
307
+ includes: { sys_choice: { label: { type: "txt" } } },
308
+ excludes: {},
309
+ tableOptions: { scopeQuery: "nameSTARTSWITH{scope}_" },
310
+ });
311
+ expect(loaded.captured.queryCalls).toBe(0);
312
+ expect(Object.keys(result.records)).toHaveLength(0);
313
+ expect(loaded.warnings[0]).toMatch(/empty/);
314
+ });
315
+ it("accepts a valid ^OR template unchanged and runs the query", () => {
316
+ const out = buildChoices({
317
+ scopeQuery: "nameSTARTSWITH{scope}_^ORelementSTARTSWITH{scope}_",
318
+ });
319
+ expect(out.captured.queryCalls).toBe(1);
320
+ expect(out.captured.encodedQueries).toEqual([
321
+ "nameSTARTSWITHx_cadso_automate_^ORelementSTARTSWITHx_cadso_automate_",
322
+ ]);
323
+ expect(scopeQueryWarnings(out.warnings)).toHaveLength(0);
324
+ expect(Object.keys(out.records).length).toBeGreaterThan(0);
325
+ });
326
+ it("accepts symbol and word operators (!=, ISNOTEMPTY, IN, ORDERBY)", () => {
327
+ const out = buildChoices({
328
+ scopeQuery: "nameSTARTSWITH{scope}_^element!=x^valueISNOTEMPTY^labelINa,b^ORDERBYname",
329
+ });
330
+ expect(out.captured.queryCalls).toBe(1);
331
+ expect(scopeQueryWarnings(out.warnings)).toHaveLength(0);
332
+ });
333
+ it("leaves tables without a scopeQuery on the sys_scope path (no validation)", () => {
334
+ const out = buildChoices({}, []);
335
+ expect(out.captured.queryCalls).toBe(1);
336
+ expect(scopeQueryWarnings(out.warnings)).toHaveLength(0);
337
+ });
338
+ });
232
339
  describe("DovetailUtilsMS.generateRecordName — nameTemplate", () => {
233
340
  it("names sys_choice folders {name}.{element}.{value} so same-label rows never collide", () => {
234
341
  const out = buildChoices({ nameTemplate: "{name}.{element}.{value}" });
@@ -1,26 +1,37 @@
1
1
  "use strict";
2
2
  // Tests for the per-command `--env` flag parser (envArg.ts). Covers the flag
3
3
  // spellings (--env, --env-file, --envFile, -e), `=` and space-separated forms,
4
- // last-wins precedence, absence, and absolute/relative path resolution.
4
+ // last-wins precedence, absence, and resolution of bare instance names,
5
+ // explicit env filenames, and relative/absolute paths.
5
6
  var __importDefault = (this && this.__importDefault) || function (mod) {
6
7
  return (mod && mod.__esModule) ? mod : { "default": mod };
7
8
  };
8
9
  Object.defineProperty(exports, "__esModule", { value: true });
10
+ const fs_1 = __importDefault(require("fs"));
11
+ const os_1 = __importDefault(require("os"));
9
12
  const path_1 = __importDefault(require("path"));
10
13
  const envArg_1 = require("../envArg");
14
+ // The conventional per-instance env-file prefix (`prod` → `<prefix>prod`).
15
+ var ENV_PREFIX = ".env.";
11
16
  describe("parseEnvArg", function () {
12
17
  it("returns undefined when no env flag is present", function () {
13
18
  expect((0, envArg_1.parseEnvArg)(["push", "--diff", "main"])).toBeUndefined();
14
19
  });
15
20
  it("parses --env <path> (space-separated)", function () {
16
- expect((0, envArg_1.parseEnvArg)(["push", "--env", ".env.prod"])).toBe(".env.prod");
21
+ expect((0, envArg_1.parseEnvArg)(["push", "--env", ENV_PREFIX + "prod"])).toBe(ENV_PREFIX + "prod");
22
+ });
23
+ it("parses --env <name> with a bare instance name", function () {
24
+ expect((0, envArg_1.parseEnvArg)(["refresh", "--env", "demo8"])).toBe("demo8");
17
25
  });
18
26
  it("parses --env=<path>", function () {
19
27
  expect((0, envArg_1.parseEnvArg)(["status", "--env=../envs/workshop.env"])).toBe("../envs/workshop.env");
20
28
  });
29
+ it("parses --env=<name> with a bare instance name", function () {
30
+ expect((0, envArg_1.parseEnvArg)(["push", "--env=demo8"])).toBe("demo8");
31
+ });
21
32
  it("parses the -e short alias (space and = forms)", function () {
22
- expect((0, envArg_1.parseEnvArg)(["status", "-e", ".env.dev"])).toBe(".env.dev");
23
- expect((0, envArg_1.parseEnvArg)(["status", "-e=.env.dev"])).toBe(".env.dev");
33
+ expect((0, envArg_1.parseEnvArg)(["status", "-e", ENV_PREFIX + "dev"])).toBe(ENV_PREFIX + "dev");
34
+ expect((0, envArg_1.parseEnvArg)(["status", "-e=" + ENV_PREFIX + "dev"])).toBe(ENV_PREFIX + "dev");
24
35
  });
25
36
  it("parses --env-file and --envFile spellings", function () {
26
37
  expect((0, envArg_1.parseEnvArg)(["push", "--env-file", "a.env"])).toBe("a.env");
@@ -29,21 +40,93 @@ describe("parseEnvArg", function () {
29
40
  it("takes the last occurrence when the flag is repeated", function () {
30
41
  expect((0, envArg_1.parseEnvArg)(["push", "--env", "first.env", "--env", "second.env"])).toBe("second.env");
31
42
  });
43
+ it("takes the last occurrence across mixed forms and spellings", function () {
44
+ expect((0, envArg_1.parseEnvArg)(["push", "--env=shop", "-e", "studio", "--env-file", "loft"])).toBe("loft");
45
+ expect((0, envArg_1.parseEnvArg)(["push", "-e", "studio", "--env=shop"])).toBe("shop");
46
+ });
32
47
  it("ignores a bare --env with no following value", function () {
33
48
  expect((0, envArg_1.parseEnvArg)(["push", "--env"])).toBeUndefined();
34
49
  });
35
50
  it("does not consume the next token when it looks like another flag", function () {
36
51
  expect((0, envArg_1.parseEnvArg)(["push", "--env", "--ci"])).toBeUndefined();
37
52
  });
38
- it("treats an empty --env= as absent", function () {
53
+ it("treats an empty or whitespace-only --env= as absent", function () {
39
54
  expect((0, envArg_1.parseEnvArg)(["push", "--env="])).toBeUndefined();
55
+ expect((0, envArg_1.parseEnvArg)(["push", "--env= "])).toBeUndefined();
56
+ });
57
+ it("returns undefined for a non-array argv", function () {
58
+ expect((0, envArg_1.parseEnvArg)(undefined)).toBeUndefined();
40
59
  });
41
60
  });
42
61
  describe("resolveEnvArgPath", function () {
62
+ var tmpDir;
63
+ beforeEach(function () {
64
+ tmpDir = fs_1.default.mkdtempSync(path_1.default.join(os_1.default.tmpdir(), "dove-envarg-"));
65
+ });
66
+ afterEach(function () {
67
+ fs_1.default.rmSync(tmpDir, { recursive: true, force: true });
68
+ });
43
69
  it("returns absolute paths unchanged", function () {
44
- expect((0, envArg_1.resolveEnvArgPath)("/abs/path/.env", "/cwd")).toBe("/abs/path/.env");
70
+ var abs = path_1.default.join(tmpDir, "abs", ENV_PREFIX + "prod");
71
+ expect((0, envArg_1.resolveEnvArgPath)(abs, "/cwd")).toBe(abs);
72
+ });
73
+ it("resolves an absolute path that has no dot as a path, not a bare name", function () {
74
+ var abs = path_1.default.join(tmpDir, "creds");
75
+ expect((0, envArg_1.resolveEnvArgPath)(abs, "/cwd")).toBe(abs);
45
76
  });
46
77
  it("resolves relative paths against the provided cwd", function () {
47
- expect((0, envArg_1.resolveEnvArgPath)(".env.prod", "/projects/sn")).toBe(path_1.default.join("/projects/sn", ".env.prod"));
78
+ expect((0, envArg_1.resolveEnvArgPath)("../envs/workshop.env", path_1.default.join(tmpDir, "repo"))).toBe(path_1.default.join(tmpDir, "envs", "workshop.env"));
79
+ });
80
+ it("treats a relative path without a dot in the basename as a path", function () {
81
+ expect((0, envArg_1.resolveEnvArgPath)(path_1.default.join("envs", "prod"), tmpDir)).toBe(path_1.default.join(tmpDir, "envs", "prod"));
82
+ });
83
+ it("uses an explicit env filename as-is (the file need not exist)", function () {
84
+ expect((0, envArg_1.resolveEnvArgPath)(ENV_PREFIX + "prod", tmpDir)).toBe(path_1.default.join(tmpDir, ENV_PREFIX + "prod"));
85
+ });
86
+ it("uses a dotted filename as-is even when it does not exist yet (dove login creates it)", function () {
87
+ expect((0, envArg_1.resolveEnvArgPath)("workshop.env", tmpDir)).toBe(path_1.default.join(tmpDir, "workshop.env"));
88
+ });
89
+ it("expands a bare instance name to the per-instance env file in cwd", function () {
90
+ expect((0, envArg_1.resolveEnvArgPath)("demo8", tmpDir)).toBe(path_1.default.join(tmpDir, ENV_PREFIX + "demo8"));
91
+ });
92
+ it("expands a bare name whether or not the target file exists", function () {
93
+ var target = path_1.default.join(tmpDir, ENV_PREFIX + "loft");
94
+ fs_1.default.writeFileSync(target, "");
95
+ expect((0, envArg_1.resolveEnvArgPath)("loft", tmpDir)).toBe(target);
96
+ });
97
+ it("prefers an existing literal file in cwd over bare-name expansion", function () {
98
+ var literal = path_1.default.join(tmpDir, "shop");
99
+ fs_1.default.writeFileSync(literal, "");
100
+ expect((0, envArg_1.resolveEnvArgPath)("shop", tmpDir)).toBe(literal);
101
+ });
102
+ it("does not treat a same-named directory as an existing literal file", function () {
103
+ fs_1.default.mkdirSync(path_1.default.join(tmpDir, "studio"));
104
+ expect((0, envArg_1.resolveEnvArgPath)("studio", tmpDir)).toBe(path_1.default.join(tmpDir, ENV_PREFIX + "studio"));
105
+ });
106
+ it("trims surrounding whitespace before resolving", function () {
107
+ expect((0, envArg_1.resolveEnvArgPath)(" demo8 ", tmpDir)).toBe(path_1.default.join(tmpDir, ENV_PREFIX + "demo8"));
108
+ });
109
+ it("resolves a bare name parsed from the --env= form end to end", function () {
110
+ var raw = (0, envArg_1.parseEnvArg)(["refresh", "--env=demo8"]);
111
+ expect(raw).toBe("demo8");
112
+ expect((0, envArg_1.resolveEnvArgPath)(raw, tmpDir)).toBe(path_1.default.join(tmpDir, ENV_PREFIX + "demo8"));
113
+ });
114
+ it("resolves the last-wins selection end to end", function () {
115
+ var raw = (0, envArg_1.parseEnvArg)(["push", "--env", ENV_PREFIX + "prod", "-e", "demo8"]);
116
+ expect((0, envArg_1.resolveEnvArgPath)(raw, tmpDir)).toBe(path_1.default.join(tmpDir, ENV_PREFIX + "demo8"));
117
+ });
118
+ it("defaults to process.cwd() when no cwd is given", function () {
119
+ expect((0, envArg_1.resolveEnvArgPath)("demo8")).toBe(path_1.default.resolve(process.cwd(), ENV_PREFIX + "demo8"));
120
+ });
121
+ it("throws on an empty or non-string selector", function () {
122
+ expect(function () {
123
+ (0, envArg_1.resolveEnvArgPath)("", tmpDir);
124
+ }).toThrow(/non-empty/);
125
+ expect(function () {
126
+ (0, envArg_1.resolveEnvArgPath)(" ", tmpDir);
127
+ }).toThrow(/non-empty/);
128
+ expect(function () {
129
+ (0, envArg_1.resolveEnvArgPath)(undefined, tmpDir);
130
+ }).toThrow(/non-empty/);
48
131
  });
49
132
  });