@tenonhq/dovetail-core 0.0.125 → 0.0.126
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +6 -1
- package/dist/MultiScopeWatcher.js +89 -0
- package/dist/allScopesCommands.js +12 -3
- package/dist/appUtils.js +12 -2
- package/dist/claudeSession.js +50 -5
- package/dist/commander.js +42 -1
- package/dist/createRecordCommand.js +23 -2
- package/dist/envArg.js +64 -15
- package/dist/gitHead.js +36 -0
- package/dist/pullRecord.js +91 -15
- package/dist/reconcileCommand.js +2 -0
- package/dist/snClient.js +45 -3
- package/dist/tests/claudeSession.test.js +22 -0
- package/dist/tests/commanderStrict.test.js +27 -0
- package/dist/tests/createRecordUpdateSet.test.js +40 -0
- package/dist/tests/dovetailUtilsMSTableOptions.test.js +109 -2
- package/dist/tests/envArg.test.js +90 -7
- package/dist/tests/gitHead.test.js +65 -0
- package/dist/tests/multi-scope-watcher.test.js +77 -0
- package/dist/tests/pullRecord.test.js +81 -0
- package/dist/tests/reconcileCreateGuard.test.js +60 -0
- package/dist/tests/snClient.createRecordGuards.test.js +35 -0
- package/dist/tests/snClient.getScopeId.test.js +67 -0
- package/dist/tests/switchUpdateSetSysId.test.js +83 -0
- package/dist/tests/watchClaudeGuard.test.js +132 -0
- package/dist/updateSetCommands.js +19 -2
- package/package.json +1 -1
- package/release-manifest.json +96 -69
- package/skills/dove-setup-project.md +1 -1
- package/skills/dove-troubleshoot-sync.md +1 -1
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
// readGitHead backs the watcher's branch-switch guard. It must resolve the
|
|
3
|
+
// HEAD commit inside a work tree and resolve null (never throw) elsewhere.
|
|
4
|
+
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
|
5
|
+
if (k2 === undefined) k2 = k;
|
|
6
|
+
var desc = Object.getOwnPropertyDescriptor(m, k);
|
|
7
|
+
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
|
8
|
+
desc = { enumerable: true, get: function() { return m[k]; } };
|
|
9
|
+
}
|
|
10
|
+
Object.defineProperty(o, k2, desc);
|
|
11
|
+
}) : (function(o, m, k, k2) {
|
|
12
|
+
if (k2 === undefined) k2 = k;
|
|
13
|
+
o[k2] = m[k];
|
|
14
|
+
}));
|
|
15
|
+
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
|
|
16
|
+
Object.defineProperty(o, "default", { enumerable: true, value: v });
|
|
17
|
+
}) : function(o, v) {
|
|
18
|
+
o["default"] = v;
|
|
19
|
+
});
|
|
20
|
+
var __importStar = (this && this.__importStar) || (function () {
|
|
21
|
+
var ownKeys = function(o) {
|
|
22
|
+
ownKeys = Object.getOwnPropertyNames || function (o) {
|
|
23
|
+
var ar = [];
|
|
24
|
+
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
|
|
25
|
+
return ar;
|
|
26
|
+
};
|
|
27
|
+
return ownKeys(o);
|
|
28
|
+
};
|
|
29
|
+
return function (mod) {
|
|
30
|
+
if (mod && mod.__esModule) return mod;
|
|
31
|
+
var result = {};
|
|
32
|
+
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
|
|
33
|
+
__setModuleDefault(result, mod);
|
|
34
|
+
return result;
|
|
35
|
+
};
|
|
36
|
+
})();
|
|
37
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
38
|
+
const fs = __importStar(require("fs"));
|
|
39
|
+
const os = __importStar(require("os"));
|
|
40
|
+
const path = __importStar(require("path"));
|
|
41
|
+
const gitHead_1 = require("../gitHead");
|
|
42
|
+
describe("readGitHead", function () {
|
|
43
|
+
it("resolves the HEAD commit id inside a git work tree", async function () {
|
|
44
|
+
// This test file lives inside the repository's own work tree.
|
|
45
|
+
var head = await (0, gitHead_1.readGitHead)(__dirname);
|
|
46
|
+
expect(head).toMatch(/^[0-9a-f]{40}([0-9a-f]{24})?$/);
|
|
47
|
+
});
|
|
48
|
+
it("resolves null outside a git work tree", async function () {
|
|
49
|
+
var dir = fs.mkdtempSync(path.join(os.tmpdir(), "dove-githead-"));
|
|
50
|
+
try {
|
|
51
|
+
expect(await (0, gitHead_1.readGitHead)(dir)).toBeNull();
|
|
52
|
+
}
|
|
53
|
+
finally {
|
|
54
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
55
|
+
}
|
|
56
|
+
});
|
|
57
|
+
it("resolves null for a directory that does not exist", async function () {
|
|
58
|
+
var head = await (0, gitHead_1.readGitHead)(path.join(os.tmpdir(), "dove-githead-missing-" + Date.now()));
|
|
59
|
+
expect(head).toBeNull();
|
|
60
|
+
});
|
|
61
|
+
it("resolves null for an empty or non-string dir", async function () {
|
|
62
|
+
expect(await (0, gitHead_1.readGitHead)("")).toBeNull();
|
|
63
|
+
expect(await (0, gitHead_1.readGitHead)(undefined)).toBeNull();
|
|
64
|
+
});
|
|
65
|
+
});
|
|
@@ -103,6 +103,11 @@ jest.mock("../logMessages", () => ({
|
|
|
103
103
|
jest.mock("../recentEdits", () => ({
|
|
104
104
|
writeRecentEdit: jest.fn(),
|
|
105
105
|
}));
|
|
106
|
+
// Branch-switch guard: default to "not a git work tree" (guard off) so the
|
|
107
|
+
// existing tests keep today's behaviour; the guard block overrides it.
|
|
108
|
+
jest.mock("../gitHead", () => ({
|
|
109
|
+
readGitHead: jest.fn().mockResolvedValue(null),
|
|
110
|
+
}));
|
|
106
111
|
jest.mock("../Logger", () => ({
|
|
107
112
|
logger: {
|
|
108
113
|
info: jest.fn(),
|
|
@@ -152,6 +157,7 @@ const ConfigManager = __importStar(require("../config"));
|
|
|
152
157
|
const FileUtils_1 = require("../FileUtils");
|
|
153
158
|
const appUtils_1 = require("../appUtils");
|
|
154
159
|
const Logger_1 = require("../Logger");
|
|
160
|
+
const gitHead_1 = require("../gitHead");
|
|
155
161
|
const MultiScopeWatcher_1 = require("../MultiScopeWatcher");
|
|
156
162
|
// Helper to flush microtask queue (for async code in setInterval callbacks)
|
|
157
163
|
const flushPromises = () => new Promise((resolve) => setImmediate(resolve));
|
|
@@ -400,6 +406,77 @@ describe("MultiScopeWatcherManager", () => {
|
|
|
400
406
|
expect(Logger_1.logger.error).toHaveBeenCalledWith(expect.stringContaining("Watcher error: FS error"));
|
|
401
407
|
});
|
|
402
408
|
});
|
|
409
|
+
describe("branch-switch guard (git HEAD)", () => {
|
|
410
|
+
var HEAD_A = "a".repeat(40);
|
|
411
|
+
var HEAD_B = "b".repeat(40);
|
|
412
|
+
beforeEach(async () => {
|
|
413
|
+
ConfigManager.getConfig.mockReturnValue({
|
|
414
|
+
...MOCK_CONFIG_TWO_SCOPES,
|
|
415
|
+
scopes: { x_test_core: { sourceDirectory: "src/x_test_core" } },
|
|
416
|
+
});
|
|
417
|
+
fs_1.default.existsSync.mockReturnValue(false);
|
|
418
|
+
FileUtils_1.getFileContextWithSkipReason.mockReturnValue({ context: makeFileContext() });
|
|
419
|
+
appUtils_1.groupAppFiles.mockReturnValue([{ table: "sys_script_include", sysId: "abc123", fields: {} }]);
|
|
420
|
+
appUtils_1.pushFiles.mockResolvedValue([{ success: true, message: "ok" }]);
|
|
421
|
+
});
|
|
422
|
+
afterEach(() => {
|
|
423
|
+
gitHead_1.readGitHead.mockReset();
|
|
424
|
+
gitHead_1.readGitHead.mockResolvedValue(null);
|
|
425
|
+
});
|
|
426
|
+
it("reads HEAD in the project root at start", async () => {
|
|
427
|
+
gitHead_1.readGitHead.mockResolvedValue(HEAD_A);
|
|
428
|
+
await (0, MultiScopeWatcher_1.startMultiScopeWatching)();
|
|
429
|
+
expect(gitHead_1.readGitHead).toHaveBeenCalledWith("/project");
|
|
430
|
+
});
|
|
431
|
+
it("pushes when HEAD is unchanged at flush time", async () => {
|
|
432
|
+
gitHead_1.readGitHead.mockResolvedValue(HEAD_A);
|
|
433
|
+
await (0, MultiScopeWatcher_1.startMultiScopeWatching)();
|
|
434
|
+
mockWatchers[0]._emit("change", makeFileContext().filePath);
|
|
435
|
+
await capturedDebounceFns[0]();
|
|
436
|
+
expect(gitHead_1.readGitHead).toHaveBeenCalledTimes(2);
|
|
437
|
+
expect(appUtils_1.pushFiles).toHaveBeenCalledTimes(1);
|
|
438
|
+
expect(Logger_1.logger.error).not.toHaveBeenCalledWith(expect.stringContaining("Git HEAD changed"));
|
|
439
|
+
});
|
|
440
|
+
it("drops the queue, pushes nothing, logs an error and pauses when HEAD moves between enqueue and flush", async () => {
|
|
441
|
+
gitHead_1.readGitHead.mockResolvedValue(HEAD_A);
|
|
442
|
+
await (0, MultiScopeWatcher_1.startMultiScopeWatching)();
|
|
443
|
+
mockWatchers[0]._emit("change", makeFileContext().filePath);
|
|
444
|
+
// A `git checkout` lands before the debounce fires.
|
|
445
|
+
gitHead_1.readGitHead.mockResolvedValue(HEAD_B);
|
|
446
|
+
await capturedDebounceFns[0]();
|
|
447
|
+
expect(appUtils_1.pushFiles).not.toHaveBeenCalled();
|
|
448
|
+
expect(mockSNClient.changeScope).not.toHaveBeenCalled();
|
|
449
|
+
expect(Logger_1.logger.error).toHaveBeenCalledWith(expect.stringContaining("Git HEAD changed"));
|
|
450
|
+
expect(Logger_1.logger.error).toHaveBeenCalledWith(expect.stringContaining("restart"));
|
|
451
|
+
var internals = MultiScopeWatcher_1.multiScopeWatcher;
|
|
452
|
+
expect(internals.pendingScopes.size).toBe(0);
|
|
453
|
+
internals.scopeWatchers.forEach(function (w) {
|
|
454
|
+
expect(w.pushQueue).toEqual([]);
|
|
455
|
+
});
|
|
456
|
+
// Paused: even if HEAD goes back, later changes are not pushed until restart.
|
|
457
|
+
gitHead_1.readGitHead.mockResolvedValue(HEAD_A);
|
|
458
|
+
mockWatchers[0]._emit("change", makeFileContext().filePath);
|
|
459
|
+
await capturedDebounceFns[0]();
|
|
460
|
+
expect(appUtils_1.pushFiles).not.toHaveBeenCalled();
|
|
461
|
+
});
|
|
462
|
+
it("treats an unreadable HEAD after start as a change (fail safe)", async () => {
|
|
463
|
+
gitHead_1.readGitHead.mockResolvedValue(HEAD_A);
|
|
464
|
+
await (0, MultiScopeWatcher_1.startMultiScopeWatching)();
|
|
465
|
+
mockWatchers[0]._emit("change", makeFileContext().filePath);
|
|
466
|
+
gitHead_1.readGitHead.mockResolvedValue(null);
|
|
467
|
+
await capturedDebounceFns[0]();
|
|
468
|
+
expect(appUtils_1.pushFiles).not.toHaveBeenCalled();
|
|
469
|
+
expect(Logger_1.logger.error).toHaveBeenCalledWith(expect.stringContaining("unreadable"));
|
|
470
|
+
});
|
|
471
|
+
it("behaves as before outside a git work tree (no HEAD at start)", async () => {
|
|
472
|
+
gitHead_1.readGitHead.mockResolvedValue(null);
|
|
473
|
+
await (0, MultiScopeWatcher_1.startMultiScopeWatching)();
|
|
474
|
+
mockWatchers[0]._emit("change", makeFileContext().filePath);
|
|
475
|
+
await capturedDebounceFns[0]();
|
|
476
|
+
expect(gitHead_1.readGitHead).toHaveBeenCalledTimes(1);
|
|
477
|
+
expect(appUtils_1.pushFiles).toHaveBeenCalledTimes(1);
|
|
478
|
+
});
|
|
479
|
+
});
|
|
403
480
|
describe("processScopeQueue - empty queue", () => {
|
|
404
481
|
it("returns early when queue is empty", async () => {
|
|
405
482
|
ConfigManager.getConfig.mockReturnValue({
|
|
@@ -517,6 +517,87 @@ describe("planPull — renamed record", function () {
|
|
|
517
517
|
expect(plan.warnings.join("\n")).toMatch(/formatting differs/);
|
|
518
518
|
});
|
|
519
519
|
});
|
|
520
|
+
describe("planPull — manifest key collisions (never overwrite another sys_id)", function () {
|
|
521
|
+
const TABLE = "sys_one_extend_capability";
|
|
522
|
+
const ID_DUP = "ddddddddddddddddddddddddddddddd4";
|
|
523
|
+
const NAME = "Skill Capability";
|
|
524
|
+
function metaFolder(key) {
|
|
525
|
+
return path.join(tmpRoot, "src", SCOPE, TABLE, key, "metaData.json");
|
|
526
|
+
}
|
|
527
|
+
test("two same-named metaData-only records pulled one after the other → both entries + both folders survive", async function () {
|
|
528
|
+
mockClient.getMissingFiles.mockResolvedValue(bulkDownload(TABLE, ID_META_ONLY, NAME, []));
|
|
529
|
+
await (0, pullRecord_1.applyPull)(await (0, pullRecord_1.planPull)([{ table: TABLE, sysId: ID_META_ONLY }]));
|
|
530
|
+
mockClient.getMissingFiles.mockResolvedValue(bulkDownload(TABLE, ID_DUP, NAME, []));
|
|
531
|
+
const plan = await (0, pullRecord_1.planPull)([{ table: TABLE, sysId: ID_DUP }]);
|
|
532
|
+
const dupKey = NAME + " (" + ID_DUP.substring(0, 8) + ")";
|
|
533
|
+
expect(plan.records[0].key).toBe(dupKey);
|
|
534
|
+
expect(plan.records[0].manifestAction).toBe("add");
|
|
535
|
+
await (0, pullRecord_1.applyPull)(plan);
|
|
536
|
+
const records = JSON.parse(readLocalManifest()).tables[TABLE].records;
|
|
537
|
+
expect(Object.keys(records)).toEqual([NAME, dupKey]);
|
|
538
|
+
expect(records[NAME].sys_id).toBe(ID_META_ONLY);
|
|
539
|
+
expect(records[dupKey]).toEqual({ files: [], name: dupKey, sys_id: ID_DUP });
|
|
540
|
+
expect(JSON.parse(fs.readFileSync(metaFolder(NAME), "utf8"))._sys_id).toBe(ID_META_ONLY);
|
|
541
|
+
expect(JSON.parse(fs.readFileSync(metaFolder(dupKey), "utf8"))._sys_id).toBe(ID_DUP);
|
|
542
|
+
// re-pulling either record is a no-op: the suffixed key is stable
|
|
543
|
+
const again = await (0, pullRecord_1.planPull)([{ table: TABLE, sysId: ID_DUP }]);
|
|
544
|
+
expect(again.records[0].key).toBe(dupKey);
|
|
545
|
+
expect(again.records[0].manifestAction).toBe("unchanged");
|
|
546
|
+
mockClient.getMissingFiles.mockResolvedValue(bulkDownload(TABLE, ID_META_ONLY, NAME, []));
|
|
547
|
+
const first = await (0, pullRecord_1.planPull)([{ table: TABLE, sysId: ID_META_ONLY }]);
|
|
548
|
+
expect(first.records[0].key).toBe(NAME);
|
|
549
|
+
expect(first.records[0].manifestAction).toBe("unchanged");
|
|
550
|
+
});
|
|
551
|
+
test("two same-named metaData-only records in ONE batch → both entries + both folders survive", async function () {
|
|
552
|
+
const a = bulkDownload(TABLE, ID_META_ONLY, NAME, [])[TABLE].records[NAME];
|
|
553
|
+
const b = bulkDownload(TABLE, ID_DUP, NAME, [])[TABLE].records[NAME];
|
|
554
|
+
mockClient.getMissingFiles.mockResolvedValue({ [TABLE]: { records: { [ID_META_ONLY]: a, [ID_DUP]: b } } });
|
|
555
|
+
const plan = await (0, pullRecord_1.planPull)([{ table: TABLE, sysId: ID_META_ONLY }, { table: TABLE, sysId: ID_DUP }]);
|
|
556
|
+
const dupKey = NAME + " (" + ID_DUP.substring(0, 8) + ")";
|
|
557
|
+
expect(plan.records.map(function (r) { return r.key + ":" + r.manifestAction; }))
|
|
558
|
+
.toEqual([NAME + ":add", dupKey + ":add"]);
|
|
559
|
+
await (0, pullRecord_1.applyPull)(plan);
|
|
560
|
+
const records = JSON.parse(readLocalManifest()).tables[TABLE].records;
|
|
561
|
+
expect(Object.keys(records)).toEqual([NAME, dupKey]);
|
|
562
|
+
expect(records[NAME].sys_id).toBe(ID_META_ONLY);
|
|
563
|
+
expect(records[dupKey].sys_id).toBe(ID_DUP);
|
|
564
|
+
expect(JSON.parse(fs.readFileSync(metaFolder(NAME), "utf8"))._sys_id).toBe(ID_META_ONLY);
|
|
565
|
+
expect(JSON.parse(fs.readFileSync(metaFolder(dupKey), "utf8"))._sys_id).toBe(ID_DUP);
|
|
566
|
+
});
|
|
567
|
+
test("a fallback key never takes a key the server manifest gives another record", async function () {
|
|
568
|
+
mockClient.getManifest.mockResolvedValue({
|
|
569
|
+
scope: SCOPE,
|
|
570
|
+
tables: { [TABLE]: { records: { [NAME]: { name: NAME, sys_id: ID_META_ONLY, files: [{ name: "script", type: "js" }] } } } },
|
|
571
|
+
});
|
|
572
|
+
mockClient.getMissingFiles.mockResolvedValue(bulkDownload(TABLE, ID_DUP, NAME, []));
|
|
573
|
+
const plan = await (0, pullRecord_1.planPull)([{ table: TABLE, sysId: ID_DUP }]);
|
|
574
|
+
expect(plan.records[0].key).toBe(NAME + " (" + ID_DUP.substring(0, 8) + ")");
|
|
575
|
+
});
|
|
576
|
+
test("a server-provided key held locally by a DIFFERENT sys_id → refused, points at dove refresh -t, writes nothing", async function () {
|
|
577
|
+
// Local manifest says "Sibling" is ID_SIB; the server now hands "Sibling" to
|
|
578
|
+
// another record (its duplicate-suffix order moved since the last refresh).
|
|
579
|
+
seedSibling();
|
|
580
|
+
writeLocalManifest(localManifestWithSibling(), true);
|
|
581
|
+
const before = snapshot(tmpRoot);
|
|
582
|
+
mockClient.getManifest.mockResolvedValue({
|
|
583
|
+
scope: SCOPE,
|
|
584
|
+
tables: { sys_script_include: { records: {
|
|
585
|
+
Sibling: { name: "Sibling", sys_id: ID_NEW, files: [{ name: "script", type: "js" }] },
|
|
586
|
+
} } },
|
|
587
|
+
});
|
|
588
|
+
await expect((0, pullRecord_1.planPull)([{ table: "sys_script_include", sysId: ID_NEW }]))
|
|
589
|
+
.rejects.toThrow(/manifest key 'Sibling' already belongs to sys_id aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa1[\s\S]*dove refresh -t sys_script_include/);
|
|
590
|
+
expect(mockClient.getMissingFiles).not.toHaveBeenCalled();
|
|
591
|
+
expect(snapshot(tmpRoot)).toEqual(before);
|
|
592
|
+
});
|
|
593
|
+
test("spliceManifestRecord refuses to overwrite a key held by a different sys_id", function () {
|
|
594
|
+
expect(function () {
|
|
595
|
+
(0, pullRecord_1.spliceManifestRecord)(localManifestWithSibling(), SCOPE, "sys_script_include", {
|
|
596
|
+
files: [], name: "Sibling", sys_id: ID_NEW,
|
|
597
|
+
});
|
|
598
|
+
}).toThrow(/already belongs to sys_id/);
|
|
599
|
+
});
|
|
600
|
+
});
|
|
520
601
|
// ---------- --from-update-set ----------
|
|
521
602
|
describe("targetsFromUpdateSet", function () {
|
|
522
603
|
const SET = "d".repeat(32);
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
// `dove reconcile --apply` creates net-new branch records through
|
|
3
|
+
// snClient.createRecord. A sys_update_set record in the branch must not crash
|
|
4
|
+
// the apply or be silently created in the session app: the createRecord guard
|
|
5
|
+
// refuses it and the refusal surfaces as a named, per-record failure (which
|
|
6
|
+
// applyMode lists and turns into exit code 1).
|
|
7
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
8
|
+
jest.mock("../Logger", function () {
|
|
9
|
+
return {
|
|
10
|
+
logger: {
|
|
11
|
+
info: jest.fn(),
|
|
12
|
+
debug: jest.fn(),
|
|
13
|
+
warn: jest.fn(),
|
|
14
|
+
error: jest.fn(),
|
|
15
|
+
success: jest.fn(),
|
|
16
|
+
getLogLevel: function () { return "debug"; },
|
|
17
|
+
},
|
|
18
|
+
};
|
|
19
|
+
});
|
|
20
|
+
jest.mock("../FileLogger", function () {
|
|
21
|
+
return { fileLogger: { debug: jest.fn(), info: jest.fn(), warn: jest.fn(), error: jest.fn() } };
|
|
22
|
+
});
|
|
23
|
+
jest.mock("../config", function () {
|
|
24
|
+
return {
|
|
25
|
+
getSourcePathForScope: function () {
|
|
26
|
+
return "/nonexistent-reconcile-source";
|
|
27
|
+
},
|
|
28
|
+
};
|
|
29
|
+
});
|
|
30
|
+
var mockPost = jest.fn();
|
|
31
|
+
jest.mock("../snClient", function () {
|
|
32
|
+
var actual = jest.requireActual("../snClient");
|
|
33
|
+
var client = actual.snClient("https://x.service-now.com/", "u", "p");
|
|
34
|
+
client.client.post = mockPost;
|
|
35
|
+
return Object.assign({}, actual, {
|
|
36
|
+
defaultClient: function () {
|
|
37
|
+
return client;
|
|
38
|
+
},
|
|
39
|
+
});
|
|
40
|
+
});
|
|
41
|
+
const reconcileCommand_1 = require("../reconcileCommand");
|
|
42
|
+
describe("reconcile applyCreates with a sys_update_set record", function () {
|
|
43
|
+
it("reports a clear per-record failure and never posts", async function () {
|
|
44
|
+
var change = {
|
|
45
|
+
kind: "create",
|
|
46
|
+
table: "sys_update_set",
|
|
47
|
+
scope: "x_s",
|
|
48
|
+
sys_id: "abc123",
|
|
49
|
+
name: "Foo",
|
|
50
|
+
fieldDeltas: [],
|
|
51
|
+
};
|
|
52
|
+
var outcomes = await (0, reconcileCommand_1.applyCreates)("x_s", [change], "us1");
|
|
53
|
+
expect(outcomes).toHaveLength(1);
|
|
54
|
+
expect(outcomes[0].ok).toBe(false);
|
|
55
|
+
expect(outcomes[0].change.sys_id).toBe("abc123");
|
|
56
|
+
expect(outcomes[0].error).toMatch(/Refusing to insert sys_update_set/);
|
|
57
|
+
expect(outcomes[0].error).toMatch(/dove createUpdateSet/);
|
|
58
|
+
expect(mockPost).not.toHaveBeenCalled();
|
|
59
|
+
});
|
|
60
|
+
});
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
// snClient.createRecord refuses tables the generic createRecord op cannot
|
|
3
|
+
// write correctly. sys_update_set: the op never sets `application`, so the set
|
|
4
|
+
// would land in the session app — callers must use createUpdateSet instead.
|
|
5
|
+
// The refusal happens before any HTTP call.
|
|
6
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
7
|
+
jest.mock("../Logger", function () {
|
|
8
|
+
return {
|
|
9
|
+
logger: {
|
|
10
|
+
info: jest.fn(),
|
|
11
|
+
debug: jest.fn(),
|
|
12
|
+
warn: jest.fn(),
|
|
13
|
+
error: jest.fn(),
|
|
14
|
+
getLogLevel: function () { return "debug"; },
|
|
15
|
+
},
|
|
16
|
+
};
|
|
17
|
+
});
|
|
18
|
+
jest.mock("../FileLogger", function () {
|
|
19
|
+
return { fileLogger: { debug: jest.fn(), info: jest.fn(), warn: jest.fn(), error: jest.fn() } };
|
|
20
|
+
});
|
|
21
|
+
const snClient_1 = require("../snClient");
|
|
22
|
+
describe("snClient.createRecord guards", function () {
|
|
23
|
+
it("rejects sys_update_set without posting, pointing at dove createUpdateSet", async function () {
|
|
24
|
+
var c = (0, snClient_1.snClient)("https://x.service-now.com/", "u", "p");
|
|
25
|
+
var post = jest.spyOn(c.client, "post");
|
|
26
|
+
await expect(c.createRecord({ table: "sys_update_set", fields: { name: "Foo" }, scope: "x_s" })).rejects.toThrow(/dove createUpdateSet/);
|
|
27
|
+
expect(post).not.toHaveBeenCalled();
|
|
28
|
+
});
|
|
29
|
+
it("still rejects sys_db_object without posting", async function () {
|
|
30
|
+
var c = (0, snClient_1.snClient)("https://x.service-now.com/", "u", "p");
|
|
31
|
+
var post = jest.spyOn(c.client, "post");
|
|
32
|
+
await expect(c.createRecord({ table: "sys_db_object", fields: { name: "x_t" } })).rejects.toThrow(/sys_db_object/);
|
|
33
|
+
expect(post).not.toHaveBeenCalled();
|
|
34
|
+
});
|
|
35
|
+
});
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
// snClient.getScopeId must resolve a scope name to exactly one sys_scope row.
|
|
3
|
+
// `scope=global` also matches sys_app rows on live instances, so Global is
|
|
4
|
+
// addressed by its fixed sys_id; any other name with >1 row is refused.
|
|
5
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
+
jest.mock("../Logger", function () {
|
|
7
|
+
return {
|
|
8
|
+
logger: {
|
|
9
|
+
info: jest.fn(),
|
|
10
|
+
debug: jest.fn(),
|
|
11
|
+
warn: jest.fn(),
|
|
12
|
+
error: jest.fn(),
|
|
13
|
+
getLogLevel: function () { return "debug"; },
|
|
14
|
+
},
|
|
15
|
+
};
|
|
16
|
+
});
|
|
17
|
+
jest.mock("../FileLogger", function () {
|
|
18
|
+
return { fileLogger: { debug: jest.fn(), info: jest.fn(), warn: jest.fn(), error: jest.fn() } };
|
|
19
|
+
});
|
|
20
|
+
const snClient_1 = require("../snClient");
|
|
21
|
+
// Rows keyed by the encoded query the client sends.
|
|
22
|
+
function stubGet(c, rowsByQuery) {
|
|
23
|
+
var queries = [];
|
|
24
|
+
jest.spyOn(c.client, "get").mockImplementation(async function (_url, config) {
|
|
25
|
+
var params = (config && config.params);
|
|
26
|
+
var q = (params && params.sysparm_query) || "";
|
|
27
|
+
queries.push(q);
|
|
28
|
+
return {
|
|
29
|
+
status: 200,
|
|
30
|
+
statusText: "OK",
|
|
31
|
+
headers: {},
|
|
32
|
+
config: {},
|
|
33
|
+
data: { result: rowsByQuery[q] || [] },
|
|
34
|
+
};
|
|
35
|
+
});
|
|
36
|
+
return queries;
|
|
37
|
+
}
|
|
38
|
+
describe("snClient.getScopeId", function () {
|
|
39
|
+
it("resolves global to the real Global scope even when sys_app rows share scope=global", async function () {
|
|
40
|
+
var c = (0, snClient_1.snClient)("https://x.service-now.com/", "u", "p");
|
|
41
|
+
var queries = stubGet(c, {
|
|
42
|
+
"scope=global": [{ sys_id: "489827aabbccdd" }, { sys_id: "global" }],
|
|
43
|
+
"sys_id=global": [{ sys_id: "global" }],
|
|
44
|
+
});
|
|
45
|
+
var rows = await (0, snClient_1.unwrapSNResponse)(c.getScopeId("global"));
|
|
46
|
+
expect(rows).toHaveLength(1);
|
|
47
|
+
expect(rows[0].sys_id).toBe("global");
|
|
48
|
+
expect(queries).toEqual(["sys_id=global"]);
|
|
49
|
+
});
|
|
50
|
+
it("throws when a non-global scope name matches more than one row", async function () {
|
|
51
|
+
var c = (0, snClient_1.snClient)("https://x.service-now.com/", "u", "p");
|
|
52
|
+
stubGet(c, { "scope=x_dup": [{ sys_id: "a1" }, { sys_id: "b2" }] });
|
|
53
|
+
await expect(c.getScopeId("x_dup")).rejects.toThrow(/ambiguous.*a1, b2/);
|
|
54
|
+
});
|
|
55
|
+
it("returns the single row for an unambiguous scope", async function () {
|
|
56
|
+
var c = (0, snClient_1.snClient)("https://x.service-now.com/", "u", "p");
|
|
57
|
+
stubGet(c, { "scope=x_one": [{ sys_id: "s1" }] });
|
|
58
|
+
var rows = await (0, snClient_1.unwrapSNResponse)(c.getScopeId("x_one"));
|
|
59
|
+
expect(rows).toEqual([{ sys_id: "s1" }]);
|
|
60
|
+
});
|
|
61
|
+
it("returns no rows for an unknown scope (callers handle the empty case)", async function () {
|
|
62
|
+
var c = (0, snClient_1.snClient)("https://x.service-now.com/", "u", "p");
|
|
63
|
+
stubGet(c, {});
|
|
64
|
+
var rows = await (0, snClient_1.unwrapSNResponse)(c.getScopeId("x_missing"));
|
|
65
|
+
expect(rows).toEqual([]);
|
|
66
|
+
});
|
|
67
|
+
});
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
// `dove switchUpdateSet --sysId <id>` selects the in-progress set by exact
|
|
3
|
+
// sys_id, so two same-named sets can't be confused. Invalid ids are refused
|
|
4
|
+
// before any query; an id with no in-progress match is an error, not a prompt.
|
|
5
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
6
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
7
|
+
};
|
|
8
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
9
|
+
var mockLogger = {
|
|
10
|
+
info: jest.fn(),
|
|
11
|
+
error: jest.fn(),
|
|
12
|
+
warn: jest.fn(),
|
|
13
|
+
debug: jest.fn(),
|
|
14
|
+
success: jest.fn(),
|
|
15
|
+
getLogLevel: jest.fn().mockReturnValue("info"),
|
|
16
|
+
};
|
|
17
|
+
jest.mock("../Logger", function () {
|
|
18
|
+
return { logger: mockLogger };
|
|
19
|
+
});
|
|
20
|
+
jest.mock("../commands", function () {
|
|
21
|
+
return { setLogLevel: jest.fn() };
|
|
22
|
+
});
|
|
23
|
+
var mockWriteRouting = jest.fn().mockReturnValue(true);
|
|
24
|
+
jest.mock("../updateSetConfig", function () {
|
|
25
|
+
return { writeUpdateSetRouting: mockWriteRouting };
|
|
26
|
+
});
|
|
27
|
+
jest.mock("inquirer", function () {
|
|
28
|
+
return { prompt: jest.fn() };
|
|
29
|
+
});
|
|
30
|
+
var TARGET_ID = "0123456789abcdef0123456789abcdef";
|
|
31
|
+
var SCOPE_SYS_ID = "scopeSysId0000000000000000000001";
|
|
32
|
+
var mockGet = jest.fn();
|
|
33
|
+
var mockClient = {
|
|
34
|
+
getScopeId: jest.fn(),
|
|
35
|
+
changeUpdateSet: jest.fn(),
|
|
36
|
+
getCurrentUpdateSet: jest.fn(),
|
|
37
|
+
getScopeById: jest.fn(),
|
|
38
|
+
client: { get: mockGet },
|
|
39
|
+
};
|
|
40
|
+
jest.mock("../snClient", function () {
|
|
41
|
+
return {
|
|
42
|
+
defaultClient: jest.fn(function () { return mockClient; }),
|
|
43
|
+
unwrapSNResponse: jest.fn(async function (p) {
|
|
44
|
+
var resp = await p;
|
|
45
|
+
return resp.data.result;
|
|
46
|
+
}),
|
|
47
|
+
unwrapTableAPIFirstItem: jest.fn(),
|
|
48
|
+
};
|
|
49
|
+
});
|
|
50
|
+
const inquirer_1 = __importDefault(require("inquirer"));
|
|
51
|
+
const updateSetCommands_1 = require("../updateSetCommands");
|
|
52
|
+
function axiosResult(result) {
|
|
53
|
+
return Promise.resolve({ data: { result: result } });
|
|
54
|
+
}
|
|
55
|
+
describe("switchUpdateSet --sysId", function () {
|
|
56
|
+
beforeEach(function () {
|
|
57
|
+
jest.clearAllMocks();
|
|
58
|
+
mockClient.getScopeId.mockReturnValue(axiosResult([{ sys_id: SCOPE_SYS_ID }]));
|
|
59
|
+
mockClient.changeUpdateSet.mockResolvedValue({ data: { result: { message: "Success" } } });
|
|
60
|
+
mockClient.getCurrentUpdateSet.mockResolvedValue({ data: { result: { sysId: TARGET_ID } } });
|
|
61
|
+
});
|
|
62
|
+
it("queries by exact sys_id within the scope and switches to that set", async function () {
|
|
63
|
+
mockGet.mockReturnValue(axiosResult([{ sys_id: TARGET_ID, name: "Dup Name", application: { value: SCOPE_SYS_ID, display_value: "App" } }]));
|
|
64
|
+
await (0, updateSetCommands_1.switchUpdateSetCommand)({ sysId: TARGET_ID, scope: "x_app", logLevel: "info" });
|
|
65
|
+
expect(mockGet).toHaveBeenCalledTimes(1);
|
|
66
|
+
var query = mockGet.mock.calls[0][1].params.sysparm_query;
|
|
67
|
+
expect(query).toBe("state=in progress^application=" + SCOPE_SYS_ID + "^sys_id=" + TARGET_ID);
|
|
68
|
+
expect(mockClient.changeUpdateSet).toHaveBeenCalledWith(expect.objectContaining({ sysId: TARGET_ID }));
|
|
69
|
+
expect(mockWriteRouting).toHaveBeenCalledWith({ scope: "x_app", sysId: TARGET_ID, name: "Dup Name" });
|
|
70
|
+
expect(inquirer_1.default.prompt).not.toHaveBeenCalled();
|
|
71
|
+
});
|
|
72
|
+
it("refuses a malformed sys_id before querying", async function () {
|
|
73
|
+
await expect((0, updateSetCommands_1.switchUpdateSetCommand)({ sysId: "abc^name=x", scope: "x_app", logLevel: "info" })).rejects.toThrow(/No update set selected/);
|
|
74
|
+
expect(mockGet).not.toHaveBeenCalled();
|
|
75
|
+
expect(mockClient.changeUpdateSet).not.toHaveBeenCalled();
|
|
76
|
+
});
|
|
77
|
+
it("errors (no prompt, no switch) when no in-progress set has that sys_id", async function () {
|
|
78
|
+
mockGet.mockReturnValue(axiosResult([]));
|
|
79
|
+
await expect((0, updateSetCommands_1.switchUpdateSetCommand)({ sysId: TARGET_ID, scope: "x_app", logLevel: "info" })).rejects.toThrow(/No update set selected/);
|
|
80
|
+
expect(mockClient.changeUpdateSet).not.toHaveBeenCalled();
|
|
81
|
+
expect(inquirer_1.default.prompt).not.toHaveBeenCalled();
|
|
82
|
+
});
|
|
83
|
+
});
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
// Handler-level tests for the Claude Code guard on the human-only watcher
|
|
3
|
+
// (watchAllScopesCommand, TenonHQ/Dovetail#155):
|
|
4
|
+
// - CLAUDECODE set -> exit 1 before the watcher starts;
|
|
5
|
+
// - CLAUDECODE + override -> warns, starts;
|
|
6
|
+
// - only a CLAUDE_CODE_* config variable (a human shell) -> warns, starts.
|
|
7
|
+
// The watcher module is mocked, so nothing is watched or pushed.
|
|
8
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
9
|
+
var mockLogger = {
|
|
10
|
+
info: jest.fn(),
|
|
11
|
+
debug: jest.fn(),
|
|
12
|
+
warn: jest.fn(),
|
|
13
|
+
error: jest.fn(),
|
|
14
|
+
success: jest.fn(),
|
|
15
|
+
getLogLevel: function () { return "info"; },
|
|
16
|
+
};
|
|
17
|
+
jest.mock("../Logger", function () { return { logger: mockLogger }; });
|
|
18
|
+
jest.mock("../FileLogger", function () {
|
|
19
|
+
return { fileLogger: { debug: jest.fn(), info: jest.fn(), warn: jest.fn(), error: jest.fn() } };
|
|
20
|
+
});
|
|
21
|
+
jest.mock("../config", function () { return {}; });
|
|
22
|
+
jest.mock("../snClient", function () {
|
|
23
|
+
return {
|
|
24
|
+
defaultClient: function () { return {}; },
|
|
25
|
+
unwrapSNResponse: function (p) { return Promise.resolve(p); },
|
|
26
|
+
};
|
|
27
|
+
});
|
|
28
|
+
jest.mock("../wizard", function () {
|
|
29
|
+
return { setupDotEnv: jest.fn(), getLoginInfo: jest.fn() };
|
|
30
|
+
});
|
|
31
|
+
jest.mock("../commands", function () { return { setLogLevel: jest.fn() }; });
|
|
32
|
+
var mockStartWatching = jest.fn().mockResolvedValue(undefined);
|
|
33
|
+
jest.mock("../MultiScopeWatcher", function () {
|
|
34
|
+
return { startMultiScopeWatching: mockStartWatching, stopMultiScopeWatching: jest.fn() };
|
|
35
|
+
});
|
|
36
|
+
const allScopesCommands_1 = require("../allScopesCommands");
|
|
37
|
+
const claudeSession_1 = require("../claudeSession");
|
|
38
|
+
class ExitSignal extends Error {
|
|
39
|
+
code;
|
|
40
|
+
constructor(code) {
|
|
41
|
+
super("process.exit(" + code + ")");
|
|
42
|
+
this.code = code;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
var ARGS = { logLevel: "info", noDashboard: true, noMonitoring: true };
|
|
46
|
+
// Every env key this suite touches. The suite may itself run inside a Claude
|
|
47
|
+
// Code shell, so the Claude markers are cleared before each test.
|
|
48
|
+
function isManagedKey(key) {
|
|
49
|
+
return (key === "CLAUDECODE" ||
|
|
50
|
+
key.indexOf("CLAUDE_CODE_") === 0 ||
|
|
51
|
+
key === claudeSession_1.WATCH_ALLOW_IN_CLAUDE_ENV ||
|
|
52
|
+
key === "SN_USER" ||
|
|
53
|
+
key === "SN_INSTANCE" ||
|
|
54
|
+
key === "SN_PASSWORD");
|
|
55
|
+
}
|
|
56
|
+
describe("watchAllScopesCommand Claude Code guard", function () {
|
|
57
|
+
var savedEnv = {};
|
|
58
|
+
var exitSpy;
|
|
59
|
+
var sigintBefore = [];
|
|
60
|
+
beforeEach(function () {
|
|
61
|
+
jest.clearAllMocks();
|
|
62
|
+
savedEnv = {};
|
|
63
|
+
Object.keys(process.env).forEach(function (key) {
|
|
64
|
+
if (isManagedKey(key)) {
|
|
65
|
+
savedEnv[key] = process.env[key];
|
|
66
|
+
delete process.env[key];
|
|
67
|
+
}
|
|
68
|
+
});
|
|
69
|
+
process.env.SN_USER = "test-user";
|
|
70
|
+
process.env.SN_INSTANCE = "example.service-now.com";
|
|
71
|
+
process.env.SN_PASSWORD = "not-a-real-value";
|
|
72
|
+
sigintBefore = process.listeners("SIGINT").slice();
|
|
73
|
+
exitSpy = jest
|
|
74
|
+
.spyOn(process, "exit")
|
|
75
|
+
.mockImplementation(function (code) {
|
|
76
|
+
throw new ExitSignal(typeof code === "number" ? code : undefined);
|
|
77
|
+
});
|
|
78
|
+
});
|
|
79
|
+
afterEach(function () {
|
|
80
|
+
exitSpy.mockRestore();
|
|
81
|
+
process.listeners("SIGINT").forEach(function (listener) {
|
|
82
|
+
if (sigintBefore.indexOf(listener) === -1) {
|
|
83
|
+
process.removeListener("SIGINT", listener);
|
|
84
|
+
}
|
|
85
|
+
});
|
|
86
|
+
Object.keys(process.env).forEach(function (key) {
|
|
87
|
+
if (isManagedKey(key)) {
|
|
88
|
+
delete process.env[key];
|
|
89
|
+
}
|
|
90
|
+
});
|
|
91
|
+
Object.keys(savedEnv).forEach(function (key) {
|
|
92
|
+
if (savedEnv[key] !== undefined) {
|
|
93
|
+
process.env[key] = savedEnv[key];
|
|
94
|
+
}
|
|
95
|
+
});
|
|
96
|
+
});
|
|
97
|
+
it("exits 1 before starting the watcher when CLAUDECODE is set", async function () {
|
|
98
|
+
process.env.CLAUDECODE = "1";
|
|
99
|
+
await expect((0, allScopesCommands_1.watchAllScopesCommand)(ARGS)).rejects.toBeInstanceOf(ExitSignal);
|
|
100
|
+
expect(exitSpy).toHaveBeenCalledWith(1);
|
|
101
|
+
expect(mockStartWatching).not.toHaveBeenCalled();
|
|
102
|
+
expect(mockLogger.error).toHaveBeenCalledWith(claudeSession_1.WATCH_BLOCKED_IN_CLAUDE_ERROR);
|
|
103
|
+
expect(claudeSession_1.WATCH_BLOCKED_IN_CLAUDE_ERROR).toContain(claudeSession_1.WATCH_ALLOW_IN_CLAUDE_ENV + "=1");
|
|
104
|
+
});
|
|
105
|
+
it("starts (with the warning) when CLAUDECODE is set and the human override is 1", async function () {
|
|
106
|
+
process.env.CLAUDECODE = "1";
|
|
107
|
+
process.env[claudeSession_1.WATCH_ALLOW_IN_CLAUDE_ENV] = "1";
|
|
108
|
+
await (0, allScopesCommands_1.watchAllScopesCommand)(ARGS);
|
|
109
|
+
expect(exitSpy).not.toHaveBeenCalled();
|
|
110
|
+
expect(mockStartWatching).toHaveBeenCalledTimes(1);
|
|
111
|
+
expect(mockLogger.warn).toHaveBeenCalledWith(claudeSession_1.WATCH_HUMAN_ONLY_WARNING);
|
|
112
|
+
});
|
|
113
|
+
it("does not treat an override other than 1 as consent", async function () {
|
|
114
|
+
process.env.CLAUDECODE = "1";
|
|
115
|
+
process.env[claudeSession_1.WATCH_ALLOW_IN_CLAUDE_ENV] = "true";
|
|
116
|
+
await expect((0, allScopesCommands_1.watchAllScopesCommand)(ARGS)).rejects.toBeInstanceOf(ExitSignal);
|
|
117
|
+
expect(mockStartWatching).not.toHaveBeenCalled();
|
|
118
|
+
});
|
|
119
|
+
it("only warns (and starts) for a human shell with a CLAUDE_CODE_* config variable", async function () {
|
|
120
|
+
process.env.CLAUDE_CODE_USE_BEDROCK = "1";
|
|
121
|
+
await (0, allScopesCommands_1.watchAllScopesCommand)(ARGS);
|
|
122
|
+
expect(exitSpy).not.toHaveBeenCalled();
|
|
123
|
+
expect(mockStartWatching).toHaveBeenCalledTimes(1);
|
|
124
|
+
expect(mockLogger.warn).toHaveBeenCalledWith(claudeSession_1.WATCH_HUMAN_ONLY_WARNING);
|
|
125
|
+
});
|
|
126
|
+
it("starts silently with no Claude markers at all", async function () {
|
|
127
|
+
await (0, allScopesCommands_1.watchAllScopesCommand)(ARGS);
|
|
128
|
+
expect(exitSpy).not.toHaveBeenCalled();
|
|
129
|
+
expect(mockStartWatching).toHaveBeenCalledTimes(1);
|
|
130
|
+
expect(mockLogger.warn).not.toHaveBeenCalledWith(claudeSession_1.WATCH_HUMAN_ONLY_WARNING);
|
|
131
|
+
});
|
|
132
|
+
});
|