n8n-decanter 0.3.0
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/CHANGELOG.md +369 -0
- package/LICENSE +21 -0
- package/README.md +318 -0
- package/dist/lib/api.mjs +79 -0
- package/dist/lib/compile.mjs +220 -0
- package/dist/lib/config.mjs +61 -0
- package/dist/lib/diff.mjs +74 -0
- package/dist/lib/executions.mjs +76 -0
- package/dist/lib/git.mjs +44 -0
- package/dist/lib/init.mjs +152 -0
- package/dist/lib/picker.mjs +226 -0
- package/dist/lib/prompt.mjs +34 -0
- package/dist/lib/proxy.mjs +204 -0
- package/dist/lib/pull.mjs +148 -0
- package/dist/lib/push.mjs +136 -0
- package/dist/lib/rename.mjs +156 -0
- package/dist/lib/run.mjs +208 -0
- package/dist/lib/state.mjs +156 -0
- package/dist/lib/status.mjs +102 -0
- package/dist/lib/style.mjs +40 -0
- package/dist/lib/types.mjs +2 -0
- package/dist/lib/util.mjs +201 -0
- package/dist/lib/validate.mjs +225 -0
- package/dist/lib/watch.mjs +296 -0
- package/dist/n8n-decanter.mjs +482 -0
- package/dist/scripts/typecheck.mjs +90 -0
- package/package.json +51 -0
- package/template/.claude/hooks/verify.mjs.example +48 -0
- package/template/.claude/settings.local.json.example +35 -0
- package/template/.cursor/rules/n8n-decanter.mdc.example +21 -0
- package/template/.env.example +10 -0
- package/template/.mcp.json.example +16 -0
- package/template/.vscode/settings.json.example +6 -0
- package/template/AGENTS.md.example +302 -0
- package/template/CLAUDE.md.example +20 -0
- package/template/decanter-ts-plugin/index.js.example +58 -0
- package/template/decanter-ts-plugin/package.json.example +7 -0
- package/template/n8n-globals.d.ts.example +190 -0
- package/template/opencode.json.example +19 -0
- package/template/package.json.example +15 -0
- package/template/shared/.gitkeep +0 -0
- package/template/shared/example-helpers.ts +13 -0
- package/template/tsconfig.json.example +26 -0
- package/template/workflows/.gitkeep +0 -0
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
export const CODE_NODE_TYPE = "n8n-nodes-base.code";
|
|
3
|
+
export const FILE_PLACEHOLDER_PREFIX = "//@file:";
|
|
4
|
+
/** Subdir inside a workflow folder that holds the node source files. */
|
|
5
|
+
export const CODE_DIR = "code";
|
|
6
|
+
const MARKER_PREFIX = "// @ts-n8n ";
|
|
7
|
+
/** True for Code nodes whose source is JavaScript (the only kind we extract). */
|
|
8
|
+
export function isJsCodeNode(node) {
|
|
9
|
+
return (node?.type === CODE_NODE_TYPE &&
|
|
10
|
+
typeof node.parameters?.jsCode === "string" &&
|
|
11
|
+
(node.parameters.language === undefined || node.parameters.language === "javaScript"));
|
|
12
|
+
}
|
|
13
|
+
export function sha256(text) {
|
|
14
|
+
return "sha256:" + createHash("sha256").update(text, "utf8").digest("hex");
|
|
15
|
+
}
|
|
16
|
+
/**
|
|
17
|
+
* Split trailing `// @ts-n8n sha256:<hex>` marker off a jsCode string.
|
|
18
|
+
* The marker must be the last non-blank line. `body` keeps everything up to
|
|
19
|
+
* the marker line byte-exactly (including the newline before it), so
|
|
20
|
+
* hash(body) matches the hash computed at push time.
|
|
21
|
+
*/
|
|
22
|
+
export function splitMarker(code) {
|
|
23
|
+
const m = code.match(/(?:^|\n)(\/\/ @ts-n8n (sha256:[0-9a-f]{64}))[ \t]*\n?[ \t\n]*$/);
|
|
24
|
+
if (!m)
|
|
25
|
+
return { body: code, marker: null, markerHash: null };
|
|
26
|
+
const start = m.index + (m[0].startsWith("\n") ? 1 : 0);
|
|
27
|
+
return { body: code.slice(0, start), marker: m[1], markerHash: m[2] };
|
|
28
|
+
}
|
|
29
|
+
/** Build the jsCode payload for a TS-managed node from compiled JS. */
|
|
30
|
+
export function withMarker(compiledJs) {
|
|
31
|
+
const body = compiledJs.endsWith("\n") ? compiledJs : compiledJs + "\n";
|
|
32
|
+
return { jsCode: body + MARKER_PREFIX + sha256(body), hash: sha256(body) };
|
|
33
|
+
}
|
|
34
|
+
/** File a `//@file:` placeholder points at, or null for inline code. */
|
|
35
|
+
export function placeholderFile(node) {
|
|
36
|
+
const jsCode = node.parameters.jsCode;
|
|
37
|
+
if (!jsCode.startsWith(FILE_PLACEHOLDER_PREFIX))
|
|
38
|
+
return null;
|
|
39
|
+
return jsCode.slice(FILE_PLACEHOLDER_PREFIX.length).trim();
|
|
40
|
+
}
|
|
41
|
+
/**
|
|
42
|
+
* Walk every `{ node: … }` target in a connections object (the four-level
|
|
43
|
+
* source → type → group → target nesting), calling `cb` for each target
|
|
44
|
+
* object. Non-object levels are skipped defensively.
|
|
45
|
+
*/
|
|
46
|
+
export function forEachConnectionTarget(connections, cb) {
|
|
47
|
+
for (const [source, byType] of Object.entries(connections)) {
|
|
48
|
+
if (!byType || typeof byType !== "object")
|
|
49
|
+
continue;
|
|
50
|
+
for (const [type, groups] of Object.entries(byType)) {
|
|
51
|
+
if (!Array.isArray(groups))
|
|
52
|
+
continue;
|
|
53
|
+
for (const group of groups) {
|
|
54
|
+
if (!Array.isArray(group))
|
|
55
|
+
continue;
|
|
56
|
+
for (const target of group) {
|
|
57
|
+
if (target && typeof target === "object")
|
|
58
|
+
cb(target, source, type);
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
/** Sanitize a workflow/node name for use as a file or folder name. */
|
|
65
|
+
export function sanitizeFilename(name) {
|
|
66
|
+
const cleaned = name
|
|
67
|
+
.replace(/[/\\:*?"<>|]/g, "-")
|
|
68
|
+
// eslint-disable-next-line no-control-regex
|
|
69
|
+
.replace(/[\x00-\x1f]/g, "")
|
|
70
|
+
.trim()
|
|
71
|
+
.replace(/\.+$/, "");
|
|
72
|
+
return cleaned || "unnamed";
|
|
73
|
+
}
|
|
74
|
+
/**
|
|
75
|
+
* Literal single-argument `$('Name')` / `$("Name")` / `$(`Name`)` calls.
|
|
76
|
+
* Heuristic on purpose (no parse): non-literal args like `$(var)`, multi-arg
|
|
77
|
+
* calls, template literals with `${…}`, and the legacy `$node["Name"]` form
|
|
78
|
+
* don't match and are left alone.
|
|
79
|
+
*/
|
|
80
|
+
const NODE_REF_RE = /\$\(\s*(['"`])((?:\\.|(?!\1)[^\\\n])*)\1\s*\)/g;
|
|
81
|
+
const unescapeRef = (raw) => raw.replace(/\\(.)/g, "$1");
|
|
82
|
+
/** Distinct node names referenced via literal `$('…')` in a piece of source. */
|
|
83
|
+
export function findNodeRefs(source) {
|
|
84
|
+
const names = new Set();
|
|
85
|
+
for (const m of source.matchAll(NODE_REF_RE)) {
|
|
86
|
+
const name = unescapeRef(m[2]);
|
|
87
|
+
if (!name.includes("${"))
|
|
88
|
+
names.add(name);
|
|
89
|
+
}
|
|
90
|
+
return [...names];
|
|
91
|
+
}
|
|
92
|
+
/** Rewrite literal `$('old')` references to the new name, keeping each call's quote style. */
|
|
93
|
+
export function renameNodeRefs(source, oldName, newName) {
|
|
94
|
+
return source.replace(NODE_REF_RE, (whole, quote, raw) => {
|
|
95
|
+
if (unescapeRef(raw) !== oldName)
|
|
96
|
+
return whole;
|
|
97
|
+
const escaped = newName.replace(/\\/g, "\\\\").replaceAll(quote, `\\${quote}`);
|
|
98
|
+
return `$(${quote}${escaped}${quote})`;
|
|
99
|
+
});
|
|
100
|
+
}
|
|
101
|
+
/** Kebab-case node-file name from a node name ("Parse Order" -> "parse-order"). */
|
|
102
|
+
export function kebabCase(name) {
|
|
103
|
+
const kebab = sanitizeFilename(name)
|
|
104
|
+
.replace(/([\p{Ll}\p{N}])(\p{Lu})/gu, "$1-$2")
|
|
105
|
+
.replace(/(\p{Lu}+)(\p{Lu}\p{Ll})/gu, "$1-$2")
|
|
106
|
+
.toLowerCase()
|
|
107
|
+
.replace(/[^\p{L}\p{N}]+/gu, "-")
|
|
108
|
+
.replace(/^-+|-+$/g, "");
|
|
109
|
+
return kebab || "unnamed";
|
|
110
|
+
}
|
|
111
|
+
const TOP_LEVEL_ORDER = [
|
|
112
|
+
"id", "name", "active", "isArchived", "createdAt", "updatedAt",
|
|
113
|
+
"nodes", "connections", "settings", "staticData", "meta", "pinData",
|
|
114
|
+
"tags", "versionId",
|
|
115
|
+
];
|
|
116
|
+
const NODE_ORDER = [
|
|
117
|
+
"id", "name", "type", "typeVersion", "position", "disabled",
|
|
118
|
+
"parameters", "credentials",
|
|
119
|
+
];
|
|
120
|
+
function sortKeys(obj, preferred = []) {
|
|
121
|
+
const keys = Object.keys(obj);
|
|
122
|
+
keys.sort((a, b) => {
|
|
123
|
+
const ia = preferred.indexOf(a);
|
|
124
|
+
const ib = preferred.indexOf(b);
|
|
125
|
+
if (ia !== -1 || ib !== -1) {
|
|
126
|
+
if (ia === -1)
|
|
127
|
+
return 1;
|
|
128
|
+
if (ib === -1)
|
|
129
|
+
return -1;
|
|
130
|
+
return ia - ib;
|
|
131
|
+
}
|
|
132
|
+
return a < b ? -1 : a > b ? 1 : 0;
|
|
133
|
+
});
|
|
134
|
+
return keys;
|
|
135
|
+
}
|
|
136
|
+
function orderDeep(value, preferred = []) {
|
|
137
|
+
if (Array.isArray(value))
|
|
138
|
+
return value.map((v) => orderDeep(v));
|
|
139
|
+
if (value && typeof value === "object") {
|
|
140
|
+
const out = {};
|
|
141
|
+
for (const k of sortKeys(value, preferred))
|
|
142
|
+
out[k] = orderDeep(value[k]);
|
|
143
|
+
return out;
|
|
144
|
+
}
|
|
145
|
+
return value;
|
|
146
|
+
}
|
|
147
|
+
/** Deterministic pretty JSON for a workflow: stable key order, clean diffs. */
|
|
148
|
+
export function stableWorkflowJson(wf) {
|
|
149
|
+
const ordered = {};
|
|
150
|
+
for (const k of sortKeys(wf, TOP_LEVEL_ORDER)) {
|
|
151
|
+
ordered[k] = k === "nodes"
|
|
152
|
+
? wf.nodes.map((n) => orderDeep(n, NODE_ORDER))
|
|
153
|
+
: orderDeep(wf[k]);
|
|
154
|
+
}
|
|
155
|
+
return JSON.stringify(ordered, null, 2) + "\n";
|
|
156
|
+
}
|
|
157
|
+
const SETTINGS_WHITELIST = [
|
|
158
|
+
"saveExecutionProgress", "saveManualExecutions", "saveDataErrorExecution",
|
|
159
|
+
"saveDataSuccessExecution", "executionTimeout", "timezone", "errorWorkflow",
|
|
160
|
+
];
|
|
161
|
+
/** Reduce a workflow to the fields the PUT endpoint accepts. */
|
|
162
|
+
export function sanitizeForPut(wf) {
|
|
163
|
+
const settings = {};
|
|
164
|
+
for (const k of SETTINGS_WHITELIST) {
|
|
165
|
+
if (wf.settings && wf.settings[k] !== undefined)
|
|
166
|
+
settings[k] = wf.settings[k];
|
|
167
|
+
}
|
|
168
|
+
const out = {
|
|
169
|
+
name: wf.name,
|
|
170
|
+
nodes: wf.nodes,
|
|
171
|
+
connections: wf.connections,
|
|
172
|
+
settings,
|
|
173
|
+
};
|
|
174
|
+
if (wf.staticData !== undefined && wf.staticData !== null)
|
|
175
|
+
out.staticData = wf.staticData;
|
|
176
|
+
return out;
|
|
177
|
+
}
|
|
178
|
+
/**
|
|
179
|
+
* Publication state from an API workflow response. n8n's public API
|
|
180
|
+
* auto-publishes updates to a *published* workflow (publishIfActive is
|
|
181
|
+
* hardcoded server-side), so pushes there go live immediately; on an
|
|
182
|
+
* unpublished workflow they only update the draft. Undefined when the
|
|
183
|
+
* server doesn't report `active`.
|
|
184
|
+
*/
|
|
185
|
+
export function publicationState(wf) {
|
|
186
|
+
if (typeof wf?.active !== "boolean")
|
|
187
|
+
return undefined;
|
|
188
|
+
return wf.active ? "published" : "unpublished";
|
|
189
|
+
}
|
|
190
|
+
/**
|
|
191
|
+
* Hash of the sanitized, code-stripped workflow — detects structural edits
|
|
192
|
+
* (nodes added/moved/reconnected, settings changed) independent of code edits.
|
|
193
|
+
*/
|
|
194
|
+
export function workflowStructureHash(wf) {
|
|
195
|
+
const clone = structuredClone(sanitizeForPut(wf));
|
|
196
|
+
for (const node of clone.nodes ?? []) {
|
|
197
|
+
if (isJsCodeNode(node))
|
|
198
|
+
node.parameters.jsCode = "";
|
|
199
|
+
}
|
|
200
|
+
return sha256(JSON.stringify(orderDeep(clone)));
|
|
201
|
+
}
|
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
import { execFile as execFileCb } from "node:child_process";
|
|
2
|
+
import { existsSync, readFileSync, readdirSync } from "node:fs";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { fileURLToPath } from "node:url";
|
|
5
|
+
import { promisify } from "node:util";
|
|
6
|
+
import { checkNodeImports, findBundleContext, scanNodeImports } from "./compile.mjs";
|
|
7
|
+
import { readState } from "./state.mjs";
|
|
8
|
+
import { CODE_DIR, FILE_PLACEHOLDER_PREFIX, findNodeRefs, forEachConnectionTarget, isJsCodeNode, placeholderFile, splitMarker } from "./util.mjs";
|
|
9
|
+
const execFile = promisify(execFileCb);
|
|
10
|
+
/** Compliance checks for one referenced node file. */
|
|
11
|
+
export function validateNodeFile(dir, file, label = file) {
|
|
12
|
+
const errors = [];
|
|
13
|
+
const warnings = [];
|
|
14
|
+
if (file.endsWith(".remote.js")) {
|
|
15
|
+
errors.push(`${label}: placeholder references the conflict artifact ${file} — resolve it into the real node file instead`);
|
|
16
|
+
return { errors, warnings };
|
|
17
|
+
}
|
|
18
|
+
if (!/\.(ts|js)$/.test(file)) {
|
|
19
|
+
errors.push(`${label}: referenced file ${file} must be .js or .ts`);
|
|
20
|
+
return { errors, warnings };
|
|
21
|
+
}
|
|
22
|
+
if (!file.startsWith(CODE_DIR + "/") || file.slice(CODE_DIR.length + 1).includes("/")) {
|
|
23
|
+
errors.push(`${label}: node file ${file} sits outside ${CODE_DIR}/ — node sources live directly in the ${CODE_DIR}/ subdir (a fresh pull migrates old layouts)`);
|
|
24
|
+
}
|
|
25
|
+
const filePath = path.join(dir, file);
|
|
26
|
+
if (!existsSync(filePath)) {
|
|
27
|
+
errors.push(`${label}: referenced file ${file} is missing`);
|
|
28
|
+
return { errors, warnings };
|
|
29
|
+
}
|
|
30
|
+
if (file.endsWith(".js")) {
|
|
31
|
+
const jsSource = readFileSync(filePath, "utf8");
|
|
32
|
+
if (splitMarker(jsSource).marker) {
|
|
33
|
+
errors.push(`${label}: ${file} ends with an @ts-n8n marker — that line is reserved for compiled TS pushes and would make the node look TS-managed on the next pull; remove it`);
|
|
34
|
+
}
|
|
35
|
+
// .js is pushed verbatim — an import would reach n8n unbundled and fail
|
|
36
|
+
// at runtime (imports are a .ts feature, bundled on push; plans/14)
|
|
37
|
+
if (scanNodeImports(jsSource).specifiers.length > 0) {
|
|
38
|
+
errors.push(`${label}: ${file} has an import — .js nodes run verbatim in n8n, where import/require fail; convert the node to .ts (imports are bundled on push) or inline the code`);
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
if (file.endsWith(".ts")) {
|
|
42
|
+
// bundling rules (plans/14), offline lexical subset: same checker the
|
|
43
|
+
// compiler runs, so check and push can't disagree
|
|
44
|
+
const { specifiers, body } = scanNodeImports(readFileSync(filePath, "utf8"));
|
|
45
|
+
if (specifiers.length > 0) {
|
|
46
|
+
for (const p of checkNodeImports(filePath, specifiers, findBundleContext(dir))) {
|
|
47
|
+
errors.push(`${label}: ${p}`);
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
if (/^import[ \t]/m.test(body)) {
|
|
51
|
+
warnings.push(`${label}: ${file} has an import below the first statement — only imports at the top of the file are bundled; the push compile will fail on it`);
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
const remoteSibling = file.replace(/\.(ts|js)$/, ".remote.js");
|
|
55
|
+
if (existsSync(path.join(dir, remoteSibling))) {
|
|
56
|
+
warnings.push(`${label}: unresolved remote copy ${remoteSibling} — its remote edits will be overwritten on push; port them, then delete the file`);
|
|
57
|
+
}
|
|
58
|
+
return { errors, warnings };
|
|
59
|
+
}
|
|
60
|
+
/** Dangling literal `$('…')` references in one string of source/expression text. */
|
|
61
|
+
function danglingRefs(text, nodeNames) {
|
|
62
|
+
return findNodeRefs(text).filter((name) => !nodeNames.has(name));
|
|
63
|
+
}
|
|
64
|
+
/** Every string inside a node's parameters, skipping the jsCode placeholder. */
|
|
65
|
+
function parameterStrings(value, skipKey) {
|
|
66
|
+
if (typeof value === "string")
|
|
67
|
+
return [value];
|
|
68
|
+
if (Array.isArray(value))
|
|
69
|
+
return value.flatMap((v) => parameterStrings(v));
|
|
70
|
+
if (value && typeof value === "object") {
|
|
71
|
+
return Object.entries(value).flatMap(([k, v]) => (k === skipKey ? [] : parameterStrings(v)));
|
|
72
|
+
}
|
|
73
|
+
return [];
|
|
74
|
+
}
|
|
75
|
+
/**
|
|
76
|
+
* Validate a pulled workflow folder against the decanter layout:
|
|
77
|
+
* every Code node behind a //@file: placeholder, referenced files present and
|
|
78
|
+
* well-formed, no marker inside .js files, unique node names/ids, connection
|
|
79
|
+
* integrity, no orphan code files, no dangling literal $('…') references;
|
|
80
|
+
* warn on *.remote.js / workflow.remote.json leftovers.
|
|
81
|
+
*/
|
|
82
|
+
export function validateWorkflowDir(dir) {
|
|
83
|
+
const errors = [];
|
|
84
|
+
const warnings = [];
|
|
85
|
+
try {
|
|
86
|
+
if (!readState(dir))
|
|
87
|
+
errors.push("missing .decanter.json — pull first");
|
|
88
|
+
}
|
|
89
|
+
catch (err) {
|
|
90
|
+
errors.push(err.message); // "corrupt .decanter.json (…)"
|
|
91
|
+
}
|
|
92
|
+
const wfFile = path.join(dir, "workflow.json");
|
|
93
|
+
if (!existsSync(wfFile)) {
|
|
94
|
+
errors.push("missing workflow.json — pull first");
|
|
95
|
+
return { errors, warnings };
|
|
96
|
+
}
|
|
97
|
+
let wf;
|
|
98
|
+
try {
|
|
99
|
+
wf = JSON.parse(readFileSync(wfFile, "utf8"));
|
|
100
|
+
}
|
|
101
|
+
catch (err) {
|
|
102
|
+
errors.push(`workflow.json: invalid JSON (${err.message})`);
|
|
103
|
+
return { errors, warnings };
|
|
104
|
+
}
|
|
105
|
+
const nodes = wf.nodes ?? [];
|
|
106
|
+
const nodeNames = new Set(nodes.map((n) => n.name));
|
|
107
|
+
// Uniqueness: duplicate names corrupt connections and $('…') resolution,
|
|
108
|
+
// duplicate ids corrupt the id→file map.
|
|
109
|
+
for (const key of ["name", "id"]) {
|
|
110
|
+
const seen = new Set();
|
|
111
|
+
for (const node of nodes) {
|
|
112
|
+
const value = node[key];
|
|
113
|
+
if (seen.has(value))
|
|
114
|
+
errors.push(`duplicate node ${key} "${value}" — node ${key}s must be unique`);
|
|
115
|
+
seen.add(value);
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
// Connection integrity: every source key and every target must be a real node.
|
|
119
|
+
const connectionErrors = new Set();
|
|
120
|
+
for (const source of Object.keys(wf.connections ?? {})) {
|
|
121
|
+
if (!nodeNames.has(source))
|
|
122
|
+
connectionErrors.add(`connections: source "${source}" is not a node in this workflow`);
|
|
123
|
+
}
|
|
124
|
+
forEachConnectionTarget(wf.connections ?? {}, (target, source, type) => {
|
|
125
|
+
if (typeof target.node === "string" && !nodeNames.has(target.node)) {
|
|
126
|
+
connectionErrors.add(`connections: "${source}" (${type}) targets missing node "${target.node}"`);
|
|
127
|
+
}
|
|
128
|
+
});
|
|
129
|
+
errors.push(...connectionErrors);
|
|
130
|
+
const referencedFiles = new Set();
|
|
131
|
+
const coveredRemoteFiles = new Set();
|
|
132
|
+
for (const node of nodes) {
|
|
133
|
+
if (!isJsCodeNode(node))
|
|
134
|
+
continue;
|
|
135
|
+
const file = placeholderFile(node);
|
|
136
|
+
if (file === null) {
|
|
137
|
+
errors.push(`node "${node.name}": inline code in workflow.json — node code belongs in its own file behind a ${FILE_PLACEHOLDER_PREFIX} placeholder (a fresh pull extracts it)`);
|
|
138
|
+
continue;
|
|
139
|
+
}
|
|
140
|
+
const result = validateNodeFile(dir, file, `node "${node.name}"`);
|
|
141
|
+
errors.push(...result.errors);
|
|
142
|
+
warnings.push(...result.warnings);
|
|
143
|
+
referencedFiles.add(file);
|
|
144
|
+
coveredRemoteFiles.add(file.replace(/\.(ts|js)$/, ".remote.js"));
|
|
145
|
+
// Dangling $('…') in the node's source (marker line can't contain a ref).
|
|
146
|
+
const filePath = path.join(dir, file);
|
|
147
|
+
if (existsSync(filePath)) {
|
|
148
|
+
for (const name of danglingRefs(readFileSync(filePath, "utf8"), nodeNames)) {
|
|
149
|
+
errors.push(`node "${node.name}": ${file} references $('${name}') — no node by that name`);
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
// Dangling $('…') inside expression parameters of any node (the n8n UI
|
|
154
|
+
// rewrites these on rename; a dangling one breaks at run time).
|
|
155
|
+
for (const node of nodes) {
|
|
156
|
+
const texts = parameterStrings(node.parameters, "jsCode");
|
|
157
|
+
const dangling = new Set(texts.flatMap((t) => danglingRefs(t, nodeNames)));
|
|
158
|
+
for (const name of dangling) {
|
|
159
|
+
errors.push(`node "${node.name}": a parameter references $('${name}') — no node by that name`);
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
// Orphans and strays. Only the folder root and code/ are scanned: other
|
|
163
|
+
// subdirs are reserved for future artifacts (executions/, fixtures/ — see
|
|
164
|
+
// plans 3 and 7) and must not trip the guard.
|
|
165
|
+
const codeDir = path.join(dir, CODE_DIR);
|
|
166
|
+
const entries = readdirSync(dir, { withFileTypes: true })
|
|
167
|
+
.filter((e) => e.isFile())
|
|
168
|
+
.map((e) => e.name);
|
|
169
|
+
if (existsSync(codeDir)) {
|
|
170
|
+
entries.push(...readdirSync(codeDir, { withFileTypes: true }).filter((e) => e.isFile()).map((e) => `${CODE_DIR}/${e.name}`));
|
|
171
|
+
}
|
|
172
|
+
for (const entry of entries) {
|
|
173
|
+
if (entry.endsWith(".remote.js")) {
|
|
174
|
+
if (!coveredRemoteFiles.has(entry)) {
|
|
175
|
+
warnings.push(`stray remote copy ${entry} — no placeholder references its node; port or delete it`);
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
else if (/\.(ts|js)$/.test(entry) && !entry.endsWith(".d.ts") && !referencedFiles.has(entry)) {
|
|
179
|
+
errors.push(`orphan code file ${entry} — no ${FILE_PLACEHOLDER_PREFIX} placeholder references it; delete it or point a Code node at it`);
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
if (existsSync(path.join(dir, "workflow.remote.json"))) {
|
|
183
|
+
warnings.push("unresolved structural conflict workflow.remote.json — reconcile into workflow.json, then delete it");
|
|
184
|
+
}
|
|
185
|
+
return { errors, warnings };
|
|
186
|
+
}
|
|
187
|
+
/**
|
|
188
|
+
* Run scripts/typecheck.mts against the nearest tsconfig.json at or above
|
|
189
|
+
* startDir. Missing tsconfig (e.g. an init'ed sync dir without one) is an
|
|
190
|
+
* info-level skip, not an error. Throws on type errors. `scopeDirs` limits
|
|
191
|
+
* which files' diagnostics are reported (the whole project still compiles).
|
|
192
|
+
*/
|
|
193
|
+
export async function runTypecheck(startDir, log, scopeDirs) {
|
|
194
|
+
let dir = path.resolve(startDir);
|
|
195
|
+
let tsconfigDir = null;
|
|
196
|
+
for (;;) {
|
|
197
|
+
if (existsSync(path.join(dir, "tsconfig.json"))) {
|
|
198
|
+
tsconfigDir = dir;
|
|
199
|
+
break;
|
|
200
|
+
}
|
|
201
|
+
const parent = path.dirname(dir);
|
|
202
|
+
if (parent === dir)
|
|
203
|
+
break;
|
|
204
|
+
dir = parent;
|
|
205
|
+
}
|
|
206
|
+
if (!tsconfigDir) {
|
|
207
|
+
log.info("no tsconfig.json found — skipping typecheck");
|
|
208
|
+
return;
|
|
209
|
+
}
|
|
210
|
+
// dev runs the .mts sources directly; the published package ships compiled
|
|
211
|
+
// .mjs (Node won't type-strip under node_modules), so mirror our own extension
|
|
212
|
+
const ext = import.meta.url.endsWith(".mjs") ? ".mjs" : ".mts";
|
|
213
|
+
const script = fileURLToPath(new URL(`../scripts/typecheck${ext}`, import.meta.url));
|
|
214
|
+
// absolute paths: the script resolves its arguments against tsconfigDir's cwd
|
|
215
|
+
const scopeArgs = (scopeDirs ?? []).map((d) => path.resolve(d));
|
|
216
|
+
try {
|
|
217
|
+
await execFile(process.execPath, [script, ...scopeArgs], { cwd: tsconfigDir, encoding: "utf8" });
|
|
218
|
+
log.ok("typecheck OK");
|
|
219
|
+
}
|
|
220
|
+
catch (err) {
|
|
221
|
+
const e = err;
|
|
222
|
+
const output = ((e.stdout ?? "") + (e.stderr ?? "")).trim();
|
|
223
|
+
throw new Error(`typecheck failed:\n${output}`);
|
|
224
|
+
}
|
|
225
|
+
}
|