@tenonhq/dovetail-core 0.0.85 → 0.0.87
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 +51 -2
- package/dist/FileLogger.js +34 -1
- package/dist/flowDesigner/values.js +22 -5
- package/dist/logMessages.js +9 -4
- package/dist/tests/v2Values.test.js +113 -0
- package/package.json +2 -3
package/README.md
CHANGED
|
@@ -1,6 +1,55 @@
|
|
|
1
1
|
# @tenonhq/dovetail-core
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
It can interact with other plugins after you configure them.
|
|
3
|
+
The core of Dovetail — ships the `dove` CLI binary and the sync engine that connects local source files to a ServiceNow instance via REST.
|
|
5
4
|
|
|
5
|
+
## What's in this package
|
|
6
6
|
|
|
7
|
+
- **`dove` CLI** — command router built on yargs. Watch, push, pull, build, deploy, scope/update-set management, record CRUD, ClickUp integration, schema pull, dashboard launch, and the project migration command (`dove migrate`).
|
|
8
|
+
- **Sync engine** — file watcher, manifest-driven file ↔ ServiceNow record mapping, plugin pipeline runner, Scripted REST API client.
|
|
9
|
+
- **Skills** — `skills/` ships ~8 Claude Code skill markdowns. `dove init-claude` copies them into `<cwd>/.claude/commands/` for in-project Claude usage.
|
|
10
|
+
|
|
11
|
+
## Installation
|
|
12
|
+
|
|
13
|
+
```bash
|
|
14
|
+
nvm use 20
|
|
15
|
+
npm i -D @tenonhq/dovetail-core
|
|
16
|
+
npx dove init # scaffolds dove.config.js
|
|
17
|
+
npx dove configure # creates .env (do not commit)
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
## Commands
|
|
21
|
+
|
|
22
|
+
The full command surface is documented in the [root CLAUDE.md](../../CLAUDE.md#essential-commands). Source of truth lives in [`src/commander.ts`](src/commander.ts).
|
|
23
|
+
|
|
24
|
+
Quick reference:
|
|
25
|
+
|
|
26
|
+
```bash
|
|
27
|
+
npx dove watch # multi-scope watch + dashboard
|
|
28
|
+
npx dove push # build + push current files
|
|
29
|
+
npx dove refresh # pull manifest + new files
|
|
30
|
+
npx dove build # local build only
|
|
31
|
+
npx dove deploy # deploy built artifacts
|
|
32
|
+
npx dove dashboard # update-set dashboard web UI
|
|
33
|
+
npx dove migrate # migrate a Sincronia project to Dovetail (dry-run by default; --apply to write)
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
See [`UPDATE_SET_COMMANDS.md`](UPDATE_SET_COMMANDS.md) for the full update-set CLI surface.
|
|
37
|
+
|
|
38
|
+
## Plugins
|
|
39
|
+
|
|
40
|
+
Dovetail's build pipeline is plugin-driven. Each plugin is an npm package implementing `run(context, content, options) => Promise<PluginResults>`. Shipped plugins live in sibling packages (`@tenonhq/dovetail-typescript-plugin`, `-babel-plugin`, `-webpack-plugin`, `-sass-plugin`, `-eslint-plugin`, `-prettier-plugin`, `-babel-preset-servicenow`, `-babel-plugin-remove-modules`).
|
|
41
|
+
|
|
42
|
+
## Related packages
|
|
43
|
+
|
|
44
|
+
| Package | Purpose |
|
|
45
|
+
|---|---|
|
|
46
|
+
| `@tenonhq/dovetail-types` | Shared `Sinc.*` + `SN.*` type namespaces |
|
|
47
|
+
| `@tenonhq/dovetail-schema` | ServiceNow table schema fetcher |
|
|
48
|
+
| `@tenonhq/dovetail-dashboard` | Update-set dashboard web UI |
|
|
49
|
+
| `@tenonhq/dovetail-servicenow` | Platform helpers + `dove-sn` build-flow CLI |
|
|
50
|
+
| `@tenonhq/dovetail-sawmill` | Cross-instance update-set retrieve/preview/commit |
|
|
51
|
+
| `@tenonhq/dovetail-mcp` | Read-only MCP server (ClickUp, Gmail, Calendar, ServiceNow) |
|
|
52
|
+
|
|
53
|
+
## Full spec
|
|
54
|
+
|
|
55
|
+
See [`docs/dovetail-platform-spec.md`](../../docs/dovetail-platform-spec.md) for the complete architecture, type system, REST API contract, build pipeline, and sync mechanism.
|
package/dist/FileLogger.js
CHANGED
|
@@ -41,10 +41,15 @@ exports.enableFileLogging = enableFileLogging;
|
|
|
41
41
|
const fs = __importStar(require("fs"));
|
|
42
42
|
const path = __importStar(require("path"));
|
|
43
43
|
const chalk_1 = __importDefault(require("chalk"));
|
|
44
|
+
// Roll over to a fresh log file once the current one has had this many writes.
|
|
45
|
+
// Long-running sessions (e.g. `dove watch`) would otherwise grow a single
|
|
46
|
+
// debug log unbounded; capping at 200 writes per file keeps each one small.
|
|
47
|
+
const ROTATION_WRITE_LIMIT = 200;
|
|
44
48
|
class FileLogger {
|
|
45
49
|
logFilePath;
|
|
46
50
|
logStream = null;
|
|
47
51
|
initialized = false;
|
|
52
|
+
writeCount = 0;
|
|
48
53
|
constructor() {
|
|
49
54
|
// Initialize on first use
|
|
50
55
|
this.logFilePath = "";
|
|
@@ -63,7 +68,8 @@ class FileLogger {
|
|
|
63
68
|
// Create or append to the log file
|
|
64
69
|
this.logStream = fs.createWriteStream(this.logFilePath, { flags: 'a' });
|
|
65
70
|
this.initialized = true;
|
|
66
|
-
|
|
71
|
+
this.writeCount = 0;
|
|
72
|
+
// Write header to log file — once, at the top of each (rotated) file
|
|
67
73
|
this.writeToFile(`\n${"=".repeat(80)}`);
|
|
68
74
|
this.writeToFile(`Dovetail Debug Log - Started at ${new Date().toISOString()}`);
|
|
69
75
|
this.writeToFile(`Log file: ${this.logFilePath}`);
|
|
@@ -76,6 +82,29 @@ class FileLogger {
|
|
|
76
82
|
console.error(chalk_1.default.red(`Failed to create log file: ${error}`));
|
|
77
83
|
}
|
|
78
84
|
}
|
|
85
|
+
/**
|
|
86
|
+
* Close the current log file and start a new one once it has reached
|
|
87
|
+
* ROTATION_WRITE_LIMIT writes. Keeps any single debug log from growing huge
|
|
88
|
+
* during long watch sessions.
|
|
89
|
+
*/
|
|
90
|
+
rotateIfNeeded() {
|
|
91
|
+
if (!this.initialized)
|
|
92
|
+
return;
|
|
93
|
+
if (this.writeCount < ROTATION_WRITE_LIMIT)
|
|
94
|
+
return;
|
|
95
|
+
if (this.logStream && this.logStream.writable) {
|
|
96
|
+
const timestamp = new Date().toISOString();
|
|
97
|
+
this.logStream.write(`[${timestamp}] ${"=".repeat(80)}\n`);
|
|
98
|
+
this.logStream.write(`[${timestamp}] Log rotated after ${ROTATION_WRITE_LIMIT} writes — continues in a new file\n`);
|
|
99
|
+
this.logStream.write(`[${timestamp}] ${"=".repeat(80)}\n`);
|
|
100
|
+
}
|
|
101
|
+
if (this.logStream) {
|
|
102
|
+
this.logStream.end();
|
|
103
|
+
this.logStream = null;
|
|
104
|
+
}
|
|
105
|
+
this.initialized = false;
|
|
106
|
+
this.initialize();
|
|
107
|
+
}
|
|
79
108
|
/**
|
|
80
109
|
* Write a message to the log file
|
|
81
110
|
*/
|
|
@@ -83,9 +112,13 @@ class FileLogger {
|
|
|
83
112
|
if (!this.initialized) {
|
|
84
113
|
this.initialize();
|
|
85
114
|
}
|
|
115
|
+
else {
|
|
116
|
+
this.rotateIfNeeded();
|
|
117
|
+
}
|
|
86
118
|
if (this.logStream && this.logStream.writable) {
|
|
87
119
|
const timestamp = new Date().toISOString();
|
|
88
120
|
this.logStream.write(`[${timestamp}] ${message}\n`);
|
|
121
|
+
this.writeCount++;
|
|
89
122
|
}
|
|
90
123
|
}
|
|
91
124
|
/**
|
|
@@ -45,11 +45,28 @@ function decodeV2Values(blob) {
|
|
|
45
45
|
/**
|
|
46
46
|
* Encode a V2 values payload back to ServiceNow's storage format.
|
|
47
47
|
*
|
|
48
|
-
* Inverse of decodeV2Values.
|
|
49
|
-
*
|
|
50
|
-
*
|
|
48
|
+
* Inverse of decodeV2Values. The output's gzip envelope may differ byte-for-byte
|
|
49
|
+
* from a Java-produced original (Deflater and zlib emit different but compatible
|
|
50
|
+
* streams), but the decompressed JSON is identical content. Round-trip
|
|
51
|
+
* fidelity is verified against real Tenon-shipped fixtures in
|
|
52
|
+
* tests/v2Values.test.ts; SN-side acceptance is verified by the
|
|
53
|
+
* `@tenonhq/sincronia-servicenow` flowDesigner write path.
|
|
54
|
+
*
|
|
55
|
+
* Throws when `value` is undefined or otherwise not JSON-serializable
|
|
56
|
+
* (e.g. functions, BigInt). Anything that survives JSON.stringify will be
|
|
57
|
+
* accepted; structural validity is the caller's responsibility.
|
|
51
58
|
*/
|
|
52
|
-
function encodeV2Values(value) {
|
|
59
|
+
function encodeV2Values(value, opts = {}) {
|
|
60
|
+
if (value === undefined) {
|
|
61
|
+
throw new Error("encodeV2Values: value must not be undefined");
|
|
62
|
+
}
|
|
53
63
|
const json = JSON.stringify(value);
|
|
54
|
-
|
|
64
|
+
if (json === undefined) {
|
|
65
|
+
throw new Error("encodeV2Values: value is not JSON-serializable (e.g. function or BigInt at the root)");
|
|
66
|
+
}
|
|
67
|
+
const zlibOpts = {};
|
|
68
|
+
if (opts.level !== undefined && opts.level !== null) {
|
|
69
|
+
zlibOpts.level = opts.level;
|
|
70
|
+
}
|
|
71
|
+
return (0, zlib_1.gzipSync)(Buffer.from(json, "utf8"), zlibOpts).toString("base64");
|
|
55
72
|
}
|
package/dist/logMessages.js
CHANGED
|
@@ -7,6 +7,7 @@ exports.logBuildResults = exports.logPushResults = exports.log = void 0;
|
|
|
7
7
|
exports.logFilePush = logFilePush;
|
|
8
8
|
exports.logDeploy = logDeploy;
|
|
9
9
|
const Logger_1 = require("./Logger");
|
|
10
|
+
const FileLogger_1 = require("./FileLogger");
|
|
10
11
|
const chalk_1 = __importDefault(require("chalk"));
|
|
11
12
|
exports.log = console.log;
|
|
12
13
|
function parseError(err) {
|
|
@@ -20,14 +21,18 @@ function logFilePush(context, res) {
|
|
|
20
21
|
const instance = process.env.SN_INSTANCE || "instance";
|
|
21
22
|
const label = chalk_1.default.bold.blue;
|
|
22
23
|
const timestamp = new Date().toLocaleTimeString();
|
|
24
|
+
const fileLabel = context.tableName + "/" + context.name + " (" + context.targetField + ")";
|
|
23
25
|
if (success) {
|
|
24
|
-
Logger_1.logger.info(chalk_1.default.green("Pushed") + " " +
|
|
25
|
-
"
|
|
26
|
+
Logger_1.logger.info(chalk_1.default.green("Pushed") + " " + fileLabel + " to " + instance +
|
|
27
|
+
" at " + timestamp);
|
|
28
|
+
// Persist every push to the session log file (file-only, no console echo).
|
|
29
|
+
// The log file rotates every 200 writes (see FileLogger) so it never grows huge.
|
|
30
|
+
FileLogger_1.fileLogger.debug("Pushed " + fileLabel + " to " + instance + " at " + timestamp);
|
|
26
31
|
}
|
|
27
32
|
else {
|
|
28
|
-
Logger_1.logger.error("Failed to push " +
|
|
29
|
-
" (" + context.targetField + ") to " + instance);
|
|
33
|
+
Logger_1.logger.error("Failed to push " + fileLabel + " to " + instance);
|
|
30
34
|
Logger_1.logger.error(message);
|
|
35
|
+
FileLogger_1.fileLogger.debug("Failed to push " + fileLabel + " to " + instance + ": " + message);
|
|
31
36
|
}
|
|
32
37
|
}
|
|
33
38
|
function multiLog(files, success, resultSet, successMessage, errorMessage, err) {
|
|
@@ -1,7 +1,43 @@
|
|
|
1
1
|
"use strict";
|
|
2
|
+
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
|
3
|
+
if (k2 === undefined) k2 = k;
|
|
4
|
+
var desc = Object.getOwnPropertyDescriptor(m, k);
|
|
5
|
+
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
|
6
|
+
desc = { enumerable: true, get: function() { return m[k]; } };
|
|
7
|
+
}
|
|
8
|
+
Object.defineProperty(o, k2, desc);
|
|
9
|
+
}) : (function(o, m, k, k2) {
|
|
10
|
+
if (k2 === undefined) k2 = k;
|
|
11
|
+
o[k2] = m[k];
|
|
12
|
+
}));
|
|
13
|
+
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
|
|
14
|
+
Object.defineProperty(o, "default", { enumerable: true, value: v });
|
|
15
|
+
}) : function(o, v) {
|
|
16
|
+
o["default"] = v;
|
|
17
|
+
});
|
|
18
|
+
var __importStar = (this && this.__importStar) || (function () {
|
|
19
|
+
var ownKeys = function(o) {
|
|
20
|
+
ownKeys = Object.getOwnPropertyNames || function (o) {
|
|
21
|
+
var ar = [];
|
|
22
|
+
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
|
|
23
|
+
return ar;
|
|
24
|
+
};
|
|
25
|
+
return ownKeys(o);
|
|
26
|
+
};
|
|
27
|
+
return function (mod) {
|
|
28
|
+
if (mod && mod.__esModule) return mod;
|
|
29
|
+
var result = {};
|
|
30
|
+
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
|
|
31
|
+
__setModuleDefault(result, mod);
|
|
32
|
+
return result;
|
|
33
|
+
};
|
|
34
|
+
})();
|
|
2
35
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
36
|
+
const fs = __importStar(require("fs"));
|
|
37
|
+
const path = __importStar(require("path"));
|
|
3
38
|
const zlib_1 = require("zlib");
|
|
4
39
|
const values_1 = require("../flowDesigner/values");
|
|
40
|
+
const FIXTURES_DIR = path.join(__dirname, "fixtures", "v2-blobs");
|
|
5
41
|
describe("decodeV2Values / encodeV2Values", () => {
|
|
6
42
|
describe("round-trip", () => {
|
|
7
43
|
it("decodes what encodeV2Values encoded (primitive array)", () => {
|
|
@@ -76,4 +112,81 @@ describe("decodeV2Values / encodeV2Values", () => {
|
|
|
76
112
|
expect(decoded[0].id).toBe("1");
|
|
77
113
|
});
|
|
78
114
|
});
|
|
115
|
+
describe("encode — input validation", () => {
|
|
116
|
+
it("throws on undefined", () => {
|
|
117
|
+
expect(() => (0, values_1.encodeV2Values)(undefined)).toThrow(/must not be undefined/);
|
|
118
|
+
});
|
|
119
|
+
it("throws on non-JSON-serializable values (function at root)", () => {
|
|
120
|
+
expect(() => (0, values_1.encodeV2Values)(() => 1)).toThrow(/not JSON-serializable/);
|
|
121
|
+
});
|
|
122
|
+
it("throws on non-JSON-serializable values (BigInt at root)", () => {
|
|
123
|
+
expect(() => (0, values_1.encodeV2Values)(BigInt(1))).toThrow();
|
|
124
|
+
});
|
|
125
|
+
it("accepts null", () => {
|
|
126
|
+
expect((0, values_1.decodeV2Values)((0, values_1.encodeV2Values)(null))).toBeNull();
|
|
127
|
+
});
|
|
128
|
+
it("accepts plain objects (logic_instance_v2 shape)", () => {
|
|
129
|
+
const original = { outputsToAssign: [], inputs: [{ name: "x", value: "" }] };
|
|
130
|
+
expect((0, values_1.decodeV2Values)((0, values_1.encodeV2Values)(original))).toEqual(original);
|
|
131
|
+
});
|
|
132
|
+
});
|
|
133
|
+
describe("encode — options", () => {
|
|
134
|
+
it("round-trips correctly at every supported compression level", () => {
|
|
135
|
+
const v = Array.from({ length: 200 }, (_, i) => ({ id: String(i), value: "x".repeat(50) }));
|
|
136
|
+
for (let level = 1; level <= 9; level++) {
|
|
137
|
+
const blob = (0, values_1.encodeV2Values)(v, { level });
|
|
138
|
+
expect((0, values_1.decodeV2Values)(blob)).toEqual(v);
|
|
139
|
+
}
|
|
140
|
+
});
|
|
141
|
+
it("emits different byte streams at different levels (level is honored)", () => {
|
|
142
|
+
// Highly compressible payload — level=1 vs level=9 must differ in bytes,
|
|
143
|
+
// even if which is shorter is not strictly monotonic for tiny inputs.
|
|
144
|
+
const v = Array.from({ length: 500 }, (_, i) => ({ id: String(i), value: "abcde".repeat(20) }));
|
|
145
|
+
const lvl1 = (0, values_1.encodeV2Values)(v, { level: 1 });
|
|
146
|
+
const lvl9 = (0, values_1.encodeV2Values)(v, { level: 9 });
|
|
147
|
+
expect(lvl1).not.toEqual(lvl9);
|
|
148
|
+
});
|
|
149
|
+
it("treats undefined level as default (no throw, valid output)", () => {
|
|
150
|
+
const v = [{ id: "1", value: "a" }];
|
|
151
|
+
expect((0, values_1.decodeV2Values)((0, values_1.encodeV2Values)(v, {}))).toEqual(v);
|
|
152
|
+
expect((0, values_1.decodeV2Values)((0, values_1.encodeV2Values)(v, { level: undefined }))).toEqual(v);
|
|
153
|
+
});
|
|
154
|
+
});
|
|
155
|
+
describe("round-trip — real Tenon fixtures", () => {
|
|
156
|
+
// Real V2 blobs pulled from tenonworkstudio sit under
|
|
157
|
+
// tests/fixtures/v2-blobs/<table>.<sys_id>.txt. Each one must survive a
|
|
158
|
+
// decode→encode→decode pass with structural deep-equality preserved. This
|
|
159
|
+
// is the gate that lets us trust encodeV2Values for write-path use.
|
|
160
|
+
const fixtures = fs.existsSync(FIXTURES_DIR)
|
|
161
|
+
? fs.readdirSync(FIXTURES_DIR).filter((f) => f.endsWith(".txt"))
|
|
162
|
+
: [];
|
|
163
|
+
it("fixtures directory has at least one sample per V2 table", () => {
|
|
164
|
+
expect(fixtures.length).toBeGreaterThan(0);
|
|
165
|
+
const tables = new Set(fixtures.map((f) => f.split(".")[0]));
|
|
166
|
+
// We expect samples for at least the action_instance and flow_logic_instance
|
|
167
|
+
// shapes. trigger_instance is nice-to-have but not gating.
|
|
168
|
+
expect(tables.has("sys_hub_action_instance_v2")).toBe(true);
|
|
169
|
+
expect(tables.has("sys_hub_flow_logic_instance_v2")).toBe(true);
|
|
170
|
+
});
|
|
171
|
+
fixtures.forEach((filename) => {
|
|
172
|
+
it(`round-trips: ${filename}`, () => {
|
|
173
|
+
const blob = fs.readFileSync(path.join(FIXTURES_DIR, filename), "utf8").trim();
|
|
174
|
+
const decoded = (0, values_1.decodeV2Values)(blob);
|
|
175
|
+
const reencoded = (0, values_1.encodeV2Values)(decoded);
|
|
176
|
+
const reDecoded = (0, values_1.decodeV2Values)(reencoded);
|
|
177
|
+
expect(reDecoded).toEqual(decoded);
|
|
178
|
+
});
|
|
179
|
+
});
|
|
180
|
+
it("re-encoded blobs decompress to byte-identical JSON via Node's zlib", () => {
|
|
181
|
+
// Belt-and-suspenders: our own zlib can read what we just wrote.
|
|
182
|
+
// (RFC 1952 conformance check; SN-side acceptance is a separate integration test.)
|
|
183
|
+
if (!fixtures.length)
|
|
184
|
+
return;
|
|
185
|
+
const blob = fs.readFileSync(path.join(FIXTURES_DIR, fixtures[0]), "utf8").trim();
|
|
186
|
+
const decoded = (0, values_1.decodeV2Values)(blob);
|
|
187
|
+
const reencoded = (0, values_1.encodeV2Values)(decoded);
|
|
188
|
+
const ourJson = (0, zlib_1.gunzipSync)(Buffer.from(reencoded, "base64")).toString("utf8");
|
|
189
|
+
expect(JSON.parse(ourJson)).toEqual(decoded);
|
|
190
|
+
});
|
|
191
|
+
});
|
|
79
192
|
});
|
package/package.json
CHANGED
|
@@ -1,14 +1,13 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@tenonhq/dovetail-core",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.87",
|
|
4
4
|
"description": "Next-gen file syncer",
|
|
5
5
|
"license": "GPL-3.0",
|
|
6
6
|
"main": "./dist/index.js",
|
|
7
7
|
"author": "Multiple",
|
|
8
8
|
"private": false,
|
|
9
9
|
"scripts": {
|
|
10
|
-
"
|
|
11
|
-
"prepack": "npm run copy:skills && tsc && npm run validate-dist",
|
|
10
|
+
"prepack": "tsc && npm run validate-dist",
|
|
12
11
|
"validate-dist": "node -e \"var fs=require('fs');if(!fs.existsSync('./dist/index.js')){console.error('FATAL: dist/index.js missing after build');process.exit(1)}\"",
|
|
13
12
|
"test": "jest --ci --reporters=jest-junit --reporters=default --coverage --coverageReporters=cobertura --coverageReporters=html",
|
|
14
13
|
"version:bump": "node ./../../Scripts/bump-version.js ./package.json",
|