@tenonhq/dovetail-core 0.0.107 → 0.0.109
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/FileLogger.js +52 -0
- package/dist/appUtils.js +22 -0
- package/dist/commander.js +6 -0
- package/dist/tests/fileLoggerEnableGate.test.js +140 -0
- package/dist/tests/stampMetadataContent.test.js +46 -0
- package/package.json +1 -1
- package/release-manifest.json +643 -712
- package/skills/dove-create-plugin.md +1 -1
- package/skills/dove-troubleshoot-sync.md +2 -2
package/dist/FileLogger.js
CHANGED
|
@@ -37,6 +37,7 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
|
37
37
|
};
|
|
38
38
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
39
39
|
exports.fileLogger = void 0;
|
|
40
|
+
exports.shouldEnableFileLogging = shouldEnableFileLogging;
|
|
40
41
|
exports.enableFileLogging = enableFileLogging;
|
|
41
42
|
const fs = __importStar(require("fs"));
|
|
42
43
|
const path = __importStar(require("path"));
|
|
@@ -45,14 +46,58 @@ const chalk_1 = __importDefault(require("chalk"));
|
|
|
45
46
|
// Long-running sessions (e.g. `dove watch`) would otherwise grow a single
|
|
46
47
|
// debug log unbounded; capping at 200 writes per file keeps each one small.
|
|
47
48
|
const ROTATION_WRITE_LIMIT = 200;
|
|
49
|
+
// Truthy spellings accepted for the DOVETAIL_DEBUG env var.
|
|
50
|
+
const TRUTHY = /^(1|true|yes|on)$/i;
|
|
51
|
+
/**
|
|
52
|
+
* @description Decide whether the file logger should write `dovetail-debug-*.log`
|
|
53
|
+
* files to disk. File logging is OFF by default and only turns on when the user
|
|
54
|
+
* explicitly opts in — either the `--debug` CLI flag (`--debug` or
|
|
55
|
+
* `--debug=<truthy>`) or the `DOVETAIL_DEBUG` env var set to a truthy value.
|
|
56
|
+
* Pure and side-effect free so it can be unit-tested with injected inputs.
|
|
57
|
+
* @param {string[]} argv - CLI args after the node + script entries (process.argv.slice(2)).
|
|
58
|
+
* @param {NodeJS.ProcessEnv} env - Environment map (defaults to an empty object).
|
|
59
|
+
* @returns {boolean} true when file logging should be enabled.
|
|
60
|
+
*/
|
|
61
|
+
function shouldEnableFileLogging(argv = [], env = {}) {
|
|
62
|
+
const raw = typeof env.DOVETAIL_DEBUG === "string" ? env.DOVETAIL_DEBUG.trim() : "";
|
|
63
|
+
if (TRUTHY.test(raw))
|
|
64
|
+
return true;
|
|
65
|
+
const args = Array.isArray(argv) ? argv : [];
|
|
66
|
+
for (let i = 0; i < args.length; i++) {
|
|
67
|
+
const arg = args[i];
|
|
68
|
+
if (arg === "--debug")
|
|
69
|
+
return true;
|
|
70
|
+
const eq = typeof arg === "string" ? arg.match(/^--debug=(.*)$/) : null;
|
|
71
|
+
if (eq)
|
|
72
|
+
return TRUTHY.test(eq[1].trim());
|
|
73
|
+
}
|
|
74
|
+
return false;
|
|
75
|
+
}
|
|
48
76
|
class FileLogger {
|
|
49
77
|
logFilePath;
|
|
50
78
|
logStream = null;
|
|
51
79
|
initialized = false;
|
|
52
80
|
writeCount = 0;
|
|
81
|
+
// File logging is opt-in — nothing touches disk unless this is true. Seeded
|
|
82
|
+
// from `--debug` / DOVETAIL_DEBUG so a stray log file is never created just by
|
|
83
|
+
// running a command; can also be flipped on at runtime via enable().
|
|
84
|
+
enabled;
|
|
53
85
|
constructor() {
|
|
54
86
|
// Initialize on first use
|
|
55
87
|
this.logFilePath = "";
|
|
88
|
+
this.enabled = shouldEnableFileLogging(Array.isArray(process.argv) ? process.argv.slice(2) : [], process.env);
|
|
89
|
+
}
|
|
90
|
+
/**
|
|
91
|
+
* Turn file logging on for this process (programmatic opt-in). Idempotent.
|
|
92
|
+
*/
|
|
93
|
+
enable() {
|
|
94
|
+
this.enabled = true;
|
|
95
|
+
}
|
|
96
|
+
/**
|
|
97
|
+
* Whether file logging is currently active.
|
|
98
|
+
*/
|
|
99
|
+
isEnabled() {
|
|
100
|
+
return this.enabled;
|
|
56
101
|
}
|
|
57
102
|
/**
|
|
58
103
|
* Initialize the file logger in the current working directory
|
|
@@ -109,6 +154,11 @@ class FileLogger {
|
|
|
109
154
|
* Write a message to the log file
|
|
110
155
|
*/
|
|
111
156
|
writeToFile(message) {
|
|
157
|
+
// Opt-in gate: without --debug / DOVETAIL_DEBUG we never create or write a
|
|
158
|
+
// log file. Console output (info/warn/error/success) still happens because
|
|
159
|
+
// those methods log to the console before calling writeToFile.
|
|
160
|
+
if (!this.enabled)
|
|
161
|
+
return;
|
|
112
162
|
if (!this.initialized) {
|
|
113
163
|
this.initialize();
|
|
114
164
|
}
|
|
@@ -215,6 +265,8 @@ const fileLogger = new FileLogger();
|
|
|
215
265
|
exports.fileLogger = fileLogger;
|
|
216
266
|
// Also export a function to replace console.log globally
|
|
217
267
|
function enableFileLogging() {
|
|
268
|
+
// Opt in to file logging even if neither --debug nor DOVETAIL_DEBUG was set.
|
|
269
|
+
fileLogger.enable();
|
|
218
270
|
// Store original console.log
|
|
219
271
|
const originalConsoleLog = console.log;
|
|
220
272
|
// Override console.log to also write to file
|
package/dist/appUtils.js
CHANGED
|
@@ -60,6 +60,27 @@ const getUpdateSetConfig = () => (0, updateSetConfig_1.readUpdateSetConfig)();
|
|
|
60
60
|
// (/<table>.do?sys_id=...) resolves against whatever instance the reader is on.
|
|
61
61
|
// Matches any *.service-now.com host so the output is portable across instances.
|
|
62
62
|
const stripRecordLinkHost = (link) => link.replace(/^https?:\/\/[^/]+\.service-now\.com/i, "");
|
|
63
|
+
// Strip the derived `display_value` from every field pair, keeping only `value`.
|
|
64
|
+
// A ServiceNow field is serialized as a { value, display_value } pair. The
|
|
65
|
+
// display_value is the *current* display name of the field's value — for a
|
|
66
|
+
// reference field, the referenced record's name, resolved live on the server at
|
|
67
|
+
// pull time. That makes it non-deterministic on disk: rename a referenced record
|
|
68
|
+
// on the instance and every metaData.json pointing at it re-churns its
|
|
69
|
+
// display_value on the next pull, even though the pointing record is unchanged.
|
|
70
|
+
// `value` (the sys_id / raw value) is the stable identity; display_value is a
|
|
71
|
+
// convenience the mirror does not need. Reconcile already ignores metaData.json
|
|
72
|
+
// content and pushes key off `value` / the manifest, so no consumer depends on
|
|
73
|
+
// the persisted display_value. Dropping it keeps re-pulls byte-identical.
|
|
74
|
+
const stripFieldDisplayValues = (metadata) => {
|
|
75
|
+
for (const key of Object.keys(metadata)) {
|
|
76
|
+
const field = metadata[key];
|
|
77
|
+
if (field !== null &&
|
|
78
|
+
typeof field === "object" &&
|
|
79
|
+
"display_value" in field) {
|
|
80
|
+
delete field.display_value;
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
};
|
|
63
84
|
// Merge _lastUpdatedOn into the server-provided metadata content. Preserves all
|
|
64
85
|
// record fields (sys_id, sys_scope, field value/display_value pairs, etc.) so
|
|
65
86
|
// the local metaData.json is a full snapshot of the record, not just a stub.
|
|
@@ -81,6 +102,7 @@ const stampMetadataContent = (file) => {
|
|
|
81
102
|
if (typeof metadata._record_link === "string") {
|
|
82
103
|
metadata._record_link = stripRecordLinkHost(metadata._record_link);
|
|
83
104
|
}
|
|
105
|
+
stripFieldDisplayValues(metadata);
|
|
84
106
|
return { ...file, content: JSON.stringify(metadata, null, 2) };
|
|
85
107
|
}
|
|
86
108
|
catch (e) {
|
package/dist/commander.js
CHANGED
|
@@ -31,6 +31,12 @@ async function initCommands() {
|
|
|
31
31
|
describe: "Path to a .env file to load for this command (default: .env in the project root). Lets one checkout target multiple instances.",
|
|
32
32
|
})
|
|
33
33
|
.global("env")
|
|
34
|
+
.option("debug", {
|
|
35
|
+
type: "boolean",
|
|
36
|
+
default: false,
|
|
37
|
+
describe: "Write a dovetail-debug-*.log file for this command (off by default; also enabled by DOVETAIL_DEBUG=1).",
|
|
38
|
+
})
|
|
39
|
+
.global("debug")
|
|
34
40
|
.command(["watch", "w", "watchAllScopes"], "Watch all scopes for file changes and sync to ServiceNow", (cmdArgs) => {
|
|
35
41
|
cmdArgs.options({
|
|
36
42
|
...sharedOptions,
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* Regression: `dove` used to drop a `dovetail-debug-*.log` file into the cwd on
|
|
4
|
+
* EVERY invocation, because the file logger lazily created its file on the first
|
|
5
|
+
* write and `index.ts` logs "Starting Dovetail..." (plus scattered debug traces)
|
|
6
|
+
* on every command. The file logger is now opt-in — it only touches disk when
|
|
7
|
+
* the user asks for it via `--debug` or `DOVETAIL_DEBUG`.
|
|
8
|
+
*
|
|
9
|
+
* These tests cover:
|
|
10
|
+
* - the pure decision helper (`shouldEnableFileLogging`), and
|
|
11
|
+
* - the singleton's disk behavior: writes no file by default, writes once
|
|
12
|
+
* enabled, while console output is unaffected in both states.
|
|
13
|
+
*/
|
|
14
|
+
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
|
15
|
+
if (k2 === undefined) k2 = k;
|
|
16
|
+
var desc = Object.getOwnPropertyDescriptor(m, k);
|
|
17
|
+
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
|
18
|
+
desc = { enumerable: true, get: function() { return m[k]; } };
|
|
19
|
+
}
|
|
20
|
+
Object.defineProperty(o, k2, desc);
|
|
21
|
+
}) : (function(o, m, k, k2) {
|
|
22
|
+
if (k2 === undefined) k2 = k;
|
|
23
|
+
o[k2] = m[k];
|
|
24
|
+
}));
|
|
25
|
+
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
|
|
26
|
+
Object.defineProperty(o, "default", { enumerable: true, value: v });
|
|
27
|
+
}) : function(o, v) {
|
|
28
|
+
o["default"] = v;
|
|
29
|
+
});
|
|
30
|
+
var __importStar = (this && this.__importStar) || (function () {
|
|
31
|
+
var ownKeys = function(o) {
|
|
32
|
+
ownKeys = Object.getOwnPropertyNames || function (o) {
|
|
33
|
+
var ar = [];
|
|
34
|
+
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
|
|
35
|
+
return ar;
|
|
36
|
+
};
|
|
37
|
+
return ownKeys(o);
|
|
38
|
+
};
|
|
39
|
+
return function (mod) {
|
|
40
|
+
if (mod && mod.__esModule) return mod;
|
|
41
|
+
var result = {};
|
|
42
|
+
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
|
|
43
|
+
__setModuleDefault(result, mod);
|
|
44
|
+
return result;
|
|
45
|
+
};
|
|
46
|
+
})();
|
|
47
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
48
|
+
const fs = __importStar(require("fs"));
|
|
49
|
+
const os = __importStar(require("os"));
|
|
50
|
+
const path = __importStar(require("path"));
|
|
51
|
+
const FileLogger_1 = require("../FileLogger");
|
|
52
|
+
function countDebugLogs(dir) {
|
|
53
|
+
return fs.readdirSync(dir).filter((f) => /^dovetail-debug-.*\.log$/.test(f))
|
|
54
|
+
.length;
|
|
55
|
+
}
|
|
56
|
+
// The file logger opens its stream asynchronously; poll until a predicate holds.
|
|
57
|
+
async function waitFor(predicate, timeoutMs = 2000) {
|
|
58
|
+
const start = Date.now();
|
|
59
|
+
while (!predicate()) {
|
|
60
|
+
if (Date.now() - start > timeoutMs) {
|
|
61
|
+
throw new Error("waitFor: condition not met before timeout");
|
|
62
|
+
}
|
|
63
|
+
await new Promise((resolve) => setTimeout(resolve, 10));
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
describe("shouldEnableFileLogging (pure)", () => {
|
|
67
|
+
it("is OFF by default — no flag, no env", () => {
|
|
68
|
+
expect((0, FileLogger_1.shouldEnableFileLogging)([], {})).toBe(false);
|
|
69
|
+
expect((0, FileLogger_1.shouldEnableFileLogging)(["push", "--diff", "main"], {})).toBe(false);
|
|
70
|
+
});
|
|
71
|
+
it("turns ON with the --debug flag", () => {
|
|
72
|
+
expect((0, FileLogger_1.shouldEnableFileLogging)(["push", "--debug"], {})).toBe(true);
|
|
73
|
+
expect((0, FileLogger_1.shouldEnableFileLogging)(["--debug", "status"], {})).toBe(true);
|
|
74
|
+
});
|
|
75
|
+
it("honors --debug=<value>", () => {
|
|
76
|
+
expect((0, FileLogger_1.shouldEnableFileLogging)(["--debug=true"], {})).toBe(true);
|
|
77
|
+
expect((0, FileLogger_1.shouldEnableFileLogging)(["--debug=1"], {})).toBe(true);
|
|
78
|
+
expect((0, FileLogger_1.shouldEnableFileLogging)(["--debug=false"], {})).toBe(false);
|
|
79
|
+
expect((0, FileLogger_1.shouldEnableFileLogging)(["--debug=0"], {})).toBe(false);
|
|
80
|
+
});
|
|
81
|
+
it("turns ON with a truthy DOVETAIL_DEBUG env var", () => {
|
|
82
|
+
for (const v of ["1", "true", "TRUE", "yes", "on", " on "]) {
|
|
83
|
+
expect((0, FileLogger_1.shouldEnableFileLogging)([], { DOVETAIL_DEBUG: v })).toBe(true);
|
|
84
|
+
}
|
|
85
|
+
});
|
|
86
|
+
it("stays OFF for a falsy/empty DOVETAIL_DEBUG env var", () => {
|
|
87
|
+
for (const v of ["", "0", "false", "no", "off"]) {
|
|
88
|
+
expect((0, FileLogger_1.shouldEnableFileLogging)([], { DOVETAIL_DEBUG: v })).toBe(false);
|
|
89
|
+
}
|
|
90
|
+
});
|
|
91
|
+
it("tolerates non-array argv defensively", () => {
|
|
92
|
+
expect((0, FileLogger_1.shouldEnableFileLogging)(undefined, {})).toBe(false);
|
|
93
|
+
});
|
|
94
|
+
});
|
|
95
|
+
describe("fileLogger disk gate", () => {
|
|
96
|
+
let tmpDir;
|
|
97
|
+
let cwdSpy;
|
|
98
|
+
let logSpy;
|
|
99
|
+
let errSpy;
|
|
100
|
+
beforeEach(() => {
|
|
101
|
+
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "dovetail-log-gate-"));
|
|
102
|
+
cwdSpy = jest.spyOn(process, "cwd").mockReturnValue(tmpDir);
|
|
103
|
+
logSpy = jest.spyOn(console, "log").mockImplementation(() => undefined);
|
|
104
|
+
errSpy = jest.spyOn(console, "error").mockImplementation(() => undefined);
|
|
105
|
+
});
|
|
106
|
+
afterEach(async () => {
|
|
107
|
+
FileLogger_1.fileLogger.close();
|
|
108
|
+
// Let any in-flight stream close settle before removing the directory.
|
|
109
|
+
await new Promise((resolve) => setTimeout(resolve, 20));
|
|
110
|
+
cwdSpy.mockRestore();
|
|
111
|
+
logSpy.mockRestore();
|
|
112
|
+
errSpy.mockRestore();
|
|
113
|
+
fs.rmSync(tmpDir, { recursive: true, force: true });
|
|
114
|
+
});
|
|
115
|
+
it("writes NO log file when disabled (jest runs with no --debug / env)", () => {
|
|
116
|
+
expect(FileLogger_1.fileLogger.isEnabled()).toBe(false);
|
|
117
|
+
FileLogger_1.fileLogger.debug("should not create a file");
|
|
118
|
+
FileLogger_1.fileLogger.info("informational");
|
|
119
|
+
FileLogger_1.fileLogger.warn("warning");
|
|
120
|
+
FileLogger_1.fileLogger.error("boom");
|
|
121
|
+
expect(countDebugLogs(tmpDir)).toBe(0);
|
|
122
|
+
expect(FileLogger_1.fileLogger.getLogFilePath()).toBe("");
|
|
123
|
+
});
|
|
124
|
+
it("still prints info to the console when disabled", () => {
|
|
125
|
+
FileLogger_1.fileLogger.info("visible to the user");
|
|
126
|
+
expect(logSpy).toHaveBeenCalled();
|
|
127
|
+
});
|
|
128
|
+
it("creates and writes a log file once enabled", async () => {
|
|
129
|
+
FileLogger_1.fileLogger.enable();
|
|
130
|
+
expect(FileLogger_1.fileLogger.isEnabled()).toBe(true);
|
|
131
|
+
FileLogger_1.fileLogger.debug("now this lands on disk");
|
|
132
|
+
// Path is assigned synchronously in initialize(); the stream opens async.
|
|
133
|
+
const logPath = FileLogger_1.fileLogger.getLogFilePath();
|
|
134
|
+
expect(logPath.startsWith(tmpDir)).toBe(true);
|
|
135
|
+
expect(/dovetail-debug-.*\.log$/.test(path.basename(logPath))).toBe(true);
|
|
136
|
+
await waitFor(() => fs.existsSync(logPath) &&
|
|
137
|
+
fs.readFileSync(logPath, "utf8").includes("now this lands on disk"));
|
|
138
|
+
expect(countDebugLogs(tmpDir)).toBe(1);
|
|
139
|
+
});
|
|
140
|
+
});
|
|
@@ -60,3 +60,49 @@ describe("stampMetadataContent — _record_link host stripping", () => {
|
|
|
60
60
|
expect((0, appUtils_1.stampMetadataContent)(script)).toEqual(script);
|
|
61
61
|
});
|
|
62
62
|
});
|
|
63
|
+
describe("stampMetadataContent — display_value stripping", () => {
|
|
64
|
+
it("drops display_value from field pairs, keeping value", () => {
|
|
65
|
+
const out = (0, appUtils_1.stampMetadataContent)(metaFile({
|
|
66
|
+
sys_updated_on: { value: "2025-08-21 17:09:02" },
|
|
67
|
+
action: {
|
|
68
|
+
value: "85de623c33ef2a107b18bc534d5c7b92",
|
|
69
|
+
display_value: "Parse hashes for to_addresses",
|
|
70
|
+
},
|
|
71
|
+
}));
|
|
72
|
+
const parsed = parse(out);
|
|
73
|
+
expect(parsed.action).toEqual({
|
|
74
|
+
value: "85de623c33ef2a107b18bc534d5c7b92",
|
|
75
|
+
});
|
|
76
|
+
expect(parsed.action.display_value).toBeUndefined();
|
|
77
|
+
});
|
|
78
|
+
it("strips every field's display_value in one pass", () => {
|
|
79
|
+
const out = (0, appUtils_1.stampMetadataContent)(metaFile({
|
|
80
|
+
a: { value: "1", display_value: "One" },
|
|
81
|
+
b: { value: "2", display_value: "Two" },
|
|
82
|
+
}));
|
|
83
|
+
const parsed = parse(out);
|
|
84
|
+
expect(parsed.a).toEqual({ value: "1" });
|
|
85
|
+
expect(parsed.b).toEqual({ value: "2" });
|
|
86
|
+
});
|
|
87
|
+
it("leaves value-only fields and string keys untouched", () => {
|
|
88
|
+
const out = (0, appUtils_1.stampMetadataContent)(metaFile({
|
|
89
|
+
sys_updated_on: { value: "2025-08-21 17:09:02" },
|
|
90
|
+
_record_link: "/incident.do?sys_id=1",
|
|
91
|
+
name: { value: "plain" },
|
|
92
|
+
}));
|
|
93
|
+
const parsed = parse(out);
|
|
94
|
+
expect(parsed.sys_updated_on).toEqual({ value: "2025-08-21 17:09:02" });
|
|
95
|
+
expect(parsed.name).toEqual({ value: "plain" });
|
|
96
|
+
expect(parsed._record_link).toBe("/incident.do?sys_id=1");
|
|
97
|
+
});
|
|
98
|
+
it("is idempotent — a display-value-free record stays byte-identical", () => {
|
|
99
|
+
const clean = {
|
|
100
|
+
sys_updated_on: { value: "2025-08-21 17:09:02" },
|
|
101
|
+
action: { value: "abc" },
|
|
102
|
+
_record_link: "/incident.do?sys_id=1",
|
|
103
|
+
};
|
|
104
|
+
const first = (0, appUtils_1.stampMetadataContent)(metaFile(clean));
|
|
105
|
+
const second = (0, appUtils_1.stampMetadataContent)(first);
|
|
106
|
+
expect(second.content).toBe(first.content);
|
|
107
|
+
});
|
|
108
|
+
});
|