@tenonhq/dovetail-core 0.0.124 → 0.0.126

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,161 @@
1
+ "use strict";
2
+ /**
3
+ * `dove pull <table> <sysId...>` — the per-record form of pull. See
4
+ * pullRecord.ts for the engine; this file is the CLI surface: argument
5
+ * normalization, the dry-run report, and the summary.
6
+ *
7
+ * A bare `dove pull` (no table, no --from-update-set) is still the historic
8
+ * alias of `dove refresh`; commander.ts routes that case before reaching here.
9
+ */
10
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
11
+ if (k2 === undefined) k2 = k;
12
+ var desc = Object.getOwnPropertyDescriptor(m, k);
13
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
14
+ desc = { enumerable: true, get: function() { return m[k]; } };
15
+ }
16
+ Object.defineProperty(o, k2, desc);
17
+ }) : (function(o, m, k, k2) {
18
+ if (k2 === undefined) k2 = k;
19
+ o[k2] = m[k];
20
+ }));
21
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
22
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
23
+ }) : function(o, v) {
24
+ o["default"] = v;
25
+ });
26
+ var __importStar = (this && this.__importStar) || (function () {
27
+ var ownKeys = function(o) {
28
+ ownKeys = Object.getOwnPropertyNames || function (o) {
29
+ var ar = [];
30
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
31
+ return ar;
32
+ };
33
+ return ownKeys(o);
34
+ };
35
+ return function (mod) {
36
+ if (mod && mod.__esModule) return mod;
37
+ var result = {};
38
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
39
+ __setModuleDefault(result, mod);
40
+ return result;
41
+ };
42
+ })();
43
+ var __importDefault = (this && this.__importDefault) || function (mod) {
44
+ return (mod && mod.__esModule) ? mod : { "default": mod };
45
+ };
46
+ Object.defineProperty(exports, "__esModule", { value: true });
47
+ exports.collectTargets = collectTargets;
48
+ exports.pullRecordCommand = pullRecordCommand;
49
+ const path_1 = __importDefault(require("path"));
50
+ const chalk_1 = __importDefault(require("chalk"));
51
+ const Logger_1 = require("./Logger");
52
+ const FileLogger_1 = require("./FileLogger");
53
+ const commands_1 = require("./commands");
54
+ const ConfigManager = __importStar(require("./config"));
55
+ const pullRecord_1 = require("./pullRecord");
56
+ function relToRoot(p) {
57
+ try {
58
+ return path_1.default.relative(ConfigManager.getRootDir(), p) || ".";
59
+ }
60
+ catch (e) {
61
+ return p;
62
+ }
63
+ }
64
+ function describeRecord(rec, dryRun) {
65
+ const lines = [];
66
+ const verb = dryRun ? "would write" : "wrote";
67
+ lines.push(chalk_1.default.bold(rec.table) + " " + rec.sysId + " → " + chalk_1.default.cyan(relToRoot(rec.recordDir) + path_1.default.sep) +
68
+ chalk_1.default.dim(" [" + rec.scope + "]"));
69
+ for (const f of rec.files) {
70
+ const mark = f.action === "write" ? chalk_1.default.green("+") : chalk_1.default.dim("=");
71
+ const note = f.action === "write" ? verb : "unchanged";
72
+ lines.push(" " + mark + " " + path_1.default.basename(f.path) + chalk_1.default.dim(" (" + note + ")"));
73
+ }
74
+ const manifestNote = rec.manifestAction === "add" ? "add" : rec.manifestAction === "update" ? "update" : "unchanged";
75
+ lines.push(" " + (rec.manifestAction === "unchanged" ? chalk_1.default.dim("=") : chalk_1.default.green("~")) +
76
+ " manifest key " + JSON.stringify(rec.key) + chalk_1.default.dim(" (" + manifestNote + ")"));
77
+ for (const w of rec.warnings)
78
+ lines.push(" " + chalk_1.default.yellow("!") + " " + w);
79
+ return lines;
80
+ }
81
+ function report(plan, dryRun) {
82
+ for (const rec of plan.records) {
83
+ for (const line of describeRecord(rec, dryRun))
84
+ Logger_1.logger.info(line);
85
+ }
86
+ for (const m of plan.manifests) {
87
+ const touched = m.records.filter((r) => r.manifestAction !== "unchanged").length;
88
+ if (touched === 0)
89
+ continue;
90
+ Logger_1.logger.info((dryRun ? "would splice " : "spliced ") + touched + " entr" + (touched === 1 ? "y" : "ies") +
91
+ " into " + chalk_1.default.cyan(relToRoot(m.manifestPath)) + (m.existed ? "" : chalk_1.default.dim(" (new file)")));
92
+ }
93
+ for (const w of plan.warnings)
94
+ Logger_1.logger.warn(w);
95
+ }
96
+ /**
97
+ * Turn the CLI arguments into pull targets. Positional sys_ids and `--sys-ids`
98
+ * compose; `--from-update-set` expands to the set's per-record captures (and
99
+ * composes with an explicit table + sys_ids too).
100
+ */
101
+ async function collectTargets(args) {
102
+ const targets = [];
103
+ let skipped = [];
104
+ const ids = (0, pullRecord_1.normalizeSysIds)([]
105
+ .concat(Array.isArray(args.sysId) ? args.sysId : [args.sysId])
106
+ .concat(Array.isArray(args.sysIds) ? args.sysIds : [args.sysIds])
107
+ .concat(Array.isArray(args["sys-ids"]) ? args["sys-ids"] : [args["sys-ids"]]));
108
+ if (args.table) {
109
+ const table = (0, pullRecord_1.assertTableName)(args.table);
110
+ if (ids.length === 0 && !args.fromUpdateSet) {
111
+ throw new Error("dove pull " + table + ": give at least one sys_id (positional, or --sys-ids a,b,c), " +
112
+ "or use --from-update-set <sys_id>. A bare 'dove pull' is the scope-wide refresh.");
113
+ }
114
+ for (const sysId of ids)
115
+ targets.push({ table, sysId });
116
+ }
117
+ else if (ids.length > 0) {
118
+ throw new Error("dove pull: a table name is required before the sys_id(s).");
119
+ }
120
+ if (args.fromUpdateSet) {
121
+ const fromSet = await (0, pullRecord_1.targetsFromUpdateSet)(String(args.fromUpdateSet));
122
+ for (const t of fromSet.targets)
123
+ targets.push(t);
124
+ skipped = fromSet.skipped;
125
+ }
126
+ return { targets, skipped };
127
+ }
128
+ async function pullRecordCommand(args) {
129
+ (0, commands_1.setLogLevel)(args);
130
+ try {
131
+ if (args.t !== undefined && !args.table) {
132
+ throw new Error("dove pull has no -t/--table flag. For a table-narrowed scope refresh use " +
133
+ "'dove refresh -t <table>'; for one record use 'dove pull <table> <sys_id>'.");
134
+ }
135
+ const dryRun = !!args.dryRun;
136
+ const { targets, skipped } = await collectTargets(args);
137
+ for (const s of skipped)
138
+ Logger_1.logger.warn("skipped " + s);
139
+ if (targets.length === 0) {
140
+ throw new Error("Nothing to pull: no per-record targets resolved.");
141
+ }
142
+ FileLogger_1.fileLogger.debug("pull: " + targets.length + " target(s), scope=" + (args.scope || "auto") +
143
+ ", force=" + !!args.force + ", dryRun=" + dryRun);
144
+ const plan = await (0, pullRecord_1.planPull)(targets, { scope: args.scope, force: !!args.force });
145
+ if (dryRun) {
146
+ Logger_1.logger.info(chalk_1.default.bold("Dry run — nothing written."));
147
+ report(plan, true);
148
+ return;
149
+ }
150
+ const result = await (0, pullRecord_1.applyPull)(plan);
151
+ report(plan, false);
152
+ Logger_1.logger.success("Pulled " + plan.records.length + " record(s): " + result.filesWritten + " file(s) written, " +
153
+ result.filesUnchanged + " unchanged, " + result.manifestsWritten.length + " manifest(s) updated.");
154
+ }
155
+ catch (e) {
156
+ const message = e instanceof Error ? e.message : String(e);
157
+ Logger_1.logger.error(message);
158
+ FileLogger_1.fileLogger.error("pull failed: " + message);
159
+ process.exitCode = 1;
160
+ }
161
+ }
@@ -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,54 @@ 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
+ };
400
+ /**
401
+ * Reads ONE record by sys_id, selecting only its class and owning scope, so a
402
+ * per-record pull can resolve (and refuse) the target scope before it fetches
403
+ * any content. Plain Table API read — no Dovetail server op involved.
404
+ */
405
+ const getRecordScope = (table, sysId) => {
406
+ const endpoint = "api/now/table/" + encodeURIComponent(table) + "/" + encodeURIComponent(sysId);
407
+ return client.get(endpoint, {
408
+ params: {
409
+ sysparm_fields: "sys_id,sys_class_name,sys_scope,sys_scope.scope",
410
+ sysparm_display_value: "false",
411
+ },
412
+ });
413
+ };
414
+ /**
415
+ * One page of a update set's customer updates (`sys_update_xml`), ordered by
416
+ * name so paging is stable. The caller pages with offset/limit; the instance
417
+ * caps a single page at 1000 rows.
418
+ */
419
+ const getUpdateSetMembers = (updateSetSysId, offset, limit) => {
420
+ const endpoint = "api/now/table/sys_update_xml";
421
+ return client.get(endpoint, {
422
+ params: {
423
+ sysparm_query: "update_set=" + updateSetSysId + "^ORDERBYname",
424
+ sysparm_fields: "sys_id,name,action,target_name,type",
425
+ sysparm_display_value: "false",
426
+ sysparm_limit: limit,
427
+ sysparm_offset: offset,
428
+ },
429
+ });
430
+ };
369
431
  const getUserSysId = (userName = process.env.SN_USER) => {
370
432
  const endpoint = "api/now/table/sys_user";
371
433
  return client.get(endpoint, {
@@ -527,6 +589,16 @@ const snClient = (baseURL, username, password, apiKey) => {
527
589
  "or in Studio then run `dove refresh -s <scope>` to materialise " +
528
590
  "the new records locally."));
529
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
+ }
530
602
  return _callDovetailApi("createRecord", (endpoint) => client.post(endpoint, params));
531
603
  };
532
604
  const deleteRecord = (params) => {
@@ -538,6 +610,9 @@ const snClient = (baseURL, username, password, apiKey) => {
538
610
  getScopeId,
539
611
  getScopeById,
540
612
  getUpdateSetById,
613
+ getInProgressUpdateSetsByName,
614
+ getRecordScope,
615
+ getUpdateSetMembers,
541
616
  getUserSysId,
542
617
  getCurrentAppUserPrefSysId,
543
618
  updateCurrentAppUserPref,
@@ -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);
@@ -39,6 +39,7 @@ jest.mock("../schemaCommand", () => ({
39
39
  jest.mock("../claudeCommand", () => ({ initClaudeCommand: jest.fn() }));
40
40
  jest.mock("../createRecordCommand", () => ({ createRecordCommand: jest.fn() }));
41
41
  jest.mock("../deleteRecordCommand", () => ({ deleteRecordCommand: jest.fn() }));
42
+ jest.mock("../pullRecordCommand", () => ({ pullRecordCommand: jest.fn() }));
42
43
  jest.mock("../reconcileCommand", () => ({ reconcileCommand: jest.fn() }));
43
44
  jest.mock("../migrateCommand", () => ({ migrateCommand: jest.fn() }));
44
45
  jest.mock("../loginCommand", () => ({ loginCommand: jest.fn() }));
@@ -58,6 +59,7 @@ jest.mock("../clickupCommands", () => ({
58
59
  const yargs_1 = __importDefault(require("yargs/yargs"));
59
60
  const commander_1 = require("../commander");
60
61
  const commands_1 = require("../commands");
62
+ const pullRecordCommand_1 = require("../pullRecordCommand");
61
63
  /**
62
64
  * @description Runs a fake `dove` invocation through a fresh, non-exiting parser.
63
65
  * @param {string[]} args - Argv as the user would type it, minus the binary name.
@@ -82,12 +84,17 @@ async function run(args) {
82
84
  }
83
85
  beforeEach(function () {
84
86
  jest.clearAllMocks();
87
+ process.exitCode = undefined;
88
+ });
89
+ afterEach(function () {
90
+ process.exitCode = undefined;
85
91
  });
86
92
  describe("dove pull", function () {
87
93
  it("routes `pull` to the refresh command", async function () {
88
94
  const { error } = await run(["pull"]);
89
95
  expect(error).toBeUndefined();
90
96
  expect(commands_1.refreshCommand).toHaveBeenCalledTimes(1);
97
+ expect(process.exitCode).toBeUndefined();
91
98
  });
92
99
  it("still routes the canonical `refresh` and its `r` alias", async function () {
93
100
  await run(["refresh"]);
@@ -101,6 +108,67 @@ describe("dove pull", function () {
101
108
  expect(argv.scope).toBe("x_cadso_core");
102
109
  expect(argv.force).toBe(true);
103
110
  expect(commands_1.refreshCommand).toHaveBeenCalledTimes(1);
111
+ expect(pullRecordCommand_1.pullRecordCommand).not.toHaveBeenCalled();
112
+ });
113
+ it("re-maps a bare `pull -t <table>` to refresh --table (the old spelling keeps working)", async function () {
114
+ const { error } = await run(["pull", "-t", "sys_script_include", "--metadata-only"]);
115
+ expect(error).toBeUndefined();
116
+ expect(commands_1.refreshCommand).toHaveBeenCalledTimes(1);
117
+ const passed = commands_1.refreshCommand.mock.calls[0][0];
118
+ expect(passed.table).toBe("sys_script_include");
119
+ expect(passed.metadataOnly).toBe(true);
120
+ expect(pullRecordCommand_1.pullRecordCommand).not.toHaveBeenCalled();
121
+ });
122
+ it("routes `pull <table> <sysId...>` to the per-record pull, not refresh", async function () {
123
+ const { error, argv } = await run([
124
+ "pull", "sys_security_acl", "94243ee5c3f78f10d4ddf1db050131a6", "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa1",
125
+ "--scope", "x_cadso_journey", "--dry-run",
126
+ ]);
127
+ expect(error).toBeUndefined();
128
+ expect(argv.table).toBe("sys_security_acl");
129
+ expect(argv.sysId).toEqual(["94243ee5c3f78f10d4ddf1db050131a6", "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa1"]);
130
+ expect(argv.dryRun).toBe(true);
131
+ expect(pullRecordCommand_1.pullRecordCommand).toHaveBeenCalledTimes(1);
132
+ expect(commands_1.refreshCommand).not.toHaveBeenCalled();
133
+ });
134
+ it("keeps --sys-ids distinct from the positional list", async function () {
135
+ const { error, argv } = await run([
136
+ "pull", "sys_security_acl", "94243ee5c3f78f10d4ddf1db050131a6",
137
+ "--sys-ids", "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa1,bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb2",
138
+ ]);
139
+ expect(error).toBeUndefined();
140
+ expect(argv.sysId).toEqual(["94243ee5c3f78f10d4ddf1db050131a6"]);
141
+ expect(argv.sysIds).toBe("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa1,bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb2");
142
+ expect(pullRecordCommand_1.pullRecordCommand).toHaveBeenCalledTimes(1);
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
+ });
166
+ it("routes `pull --from-update-set` (no table) to the per-record pull", async function () {
167
+ const { error, argv } = await run(["pull", "--from-update-set", "dddddddddddddddddddddddddddddddd"]);
168
+ expect(error).toBeUndefined();
169
+ expect(argv.fromUpdateSet).toBe("dddddddddddddddddddddddddddddddd");
170
+ expect(pullRecordCommand_1.pullRecordCommand).toHaveBeenCalledTimes(1);
171
+ expect(commands_1.refreshCommand).not.toHaveBeenCalled();
104
172
  });
105
173
  });
106
174
  describe("unknown commands", function () {
@@ -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}" });