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.
Files changed (44) hide show
  1. package/CHANGELOG.md +369 -0
  2. package/LICENSE +21 -0
  3. package/README.md +318 -0
  4. package/dist/lib/api.mjs +79 -0
  5. package/dist/lib/compile.mjs +220 -0
  6. package/dist/lib/config.mjs +61 -0
  7. package/dist/lib/diff.mjs +74 -0
  8. package/dist/lib/executions.mjs +76 -0
  9. package/dist/lib/git.mjs +44 -0
  10. package/dist/lib/init.mjs +152 -0
  11. package/dist/lib/picker.mjs +226 -0
  12. package/dist/lib/prompt.mjs +34 -0
  13. package/dist/lib/proxy.mjs +204 -0
  14. package/dist/lib/pull.mjs +148 -0
  15. package/dist/lib/push.mjs +136 -0
  16. package/dist/lib/rename.mjs +156 -0
  17. package/dist/lib/run.mjs +208 -0
  18. package/dist/lib/state.mjs +156 -0
  19. package/dist/lib/status.mjs +102 -0
  20. package/dist/lib/style.mjs +40 -0
  21. package/dist/lib/types.mjs +2 -0
  22. package/dist/lib/util.mjs +201 -0
  23. package/dist/lib/validate.mjs +225 -0
  24. package/dist/lib/watch.mjs +296 -0
  25. package/dist/n8n-decanter.mjs +482 -0
  26. package/dist/scripts/typecheck.mjs +90 -0
  27. package/package.json +51 -0
  28. package/template/.claude/hooks/verify.mjs.example +48 -0
  29. package/template/.claude/settings.local.json.example +35 -0
  30. package/template/.cursor/rules/n8n-decanter.mdc.example +21 -0
  31. package/template/.env.example +10 -0
  32. package/template/.mcp.json.example +16 -0
  33. package/template/.vscode/settings.json.example +6 -0
  34. package/template/AGENTS.md.example +302 -0
  35. package/template/CLAUDE.md.example +20 -0
  36. package/template/decanter-ts-plugin/index.js.example +58 -0
  37. package/template/decanter-ts-plugin/package.json.example +7 -0
  38. package/template/n8n-globals.d.ts.example +190 -0
  39. package/template/opencode.json.example +19 -0
  40. package/template/package.json.example +15 -0
  41. package/template/shared/.gitkeep +0 -0
  42. package/template/shared/example-helpers.ts +13 -0
  43. package/template/tsconfig.json.example +26 -0
  44. package/template/workflows/.gitkeep +0 -0
@@ -0,0 +1,204 @@
1
+ import http from "node:http";
2
+ import https from "node:https";
3
+ import net from "node:net";
4
+ import tls from "node:tls";
5
+ /**
6
+ * Transparent development proxy for `watch` (Plan 5): pipes every request to the
7
+ * upstream n8n instance untouched (auth, assets, native `/rest/push` WebSocket),
8
+ * but injects a tiny live-reload client into HTML responses. On a successful
9
+ * local push the client refreshes the editor tab — unless it has unsaved edits.
10
+ *
11
+ * Native `node:http`/`net`/`tls` only, matching the project's no-extra-deps rule.
12
+ * Supported cleanly for a *local http* upstream (`http://localhost:5678`);
13
+ * https/remote upstreams are best-effort — Secure cookies won't survive the
14
+ * plain-http hop, breaking auth (see PLAN.md / plans/5).
15
+ */
16
+ const CLIENT_PATH = "/__decanter/client.js";
17
+ const EVENTS_PATH = "/__decanter/events";
18
+ const INJECT_TAG = `<script src="${CLIENT_PATH}" defer></script>`;
19
+ /** The browser-side live-reload client, served at CLIENT_PATH and injected into HTML. */
20
+ const CLIENT_SCRIPT = `(function () {
21
+ "use strict";
22
+ var LOG = "[decanter] ";
23
+ // Best-effort, framework-agnostic dirty probe: dispatch a synthetic
24
+ // beforeunload and see whether any handler tries to block navigation — n8n
25
+ // installs such a guard only when the open workflow has unsaved changes.
26
+ // Fails safe toward NOT reloading when we can detect dirtiness; a missed
27
+ // detection (older/newer n8n) would reload over UI edits, so treat this as a
28
+ // heuristic, not a guarantee.
29
+ function editorIsDirty() {
30
+ try {
31
+ if (typeof window.onbeforeunload === "function") return true;
32
+ var ev = new Event("beforeunload", { cancelable: true });
33
+ window.dispatchEvent(ev);
34
+ return ev.defaultPrevented || ev.returnValue === false;
35
+ } catch (err) {
36
+ return false;
37
+ }
38
+ }
39
+ function openWorkflowId() {
40
+ var m = location.pathname.match(/\\/workflow\\/([^/?#]+)/);
41
+ return m ? m[1] : null;
42
+ }
43
+ function connect() {
44
+ var es = new EventSource(${JSON.stringify(EVENTS_PATH)});
45
+ es.addEventListener("pushed", function (e) {
46
+ var data = {};
47
+ try { data = JSON.parse(e.data || "{}"); } catch (_) {}
48
+ var open = openWorkflowId();
49
+ if (data.workflowId && open && open !== data.workflowId) return; // a different workflow is open
50
+ if (editorIsDirty()) {
51
+ console.warn(LOG + "push received but the editor has unsaved changes — not reloading. Save or discard, then refresh manually.");
52
+ return;
53
+ }
54
+ console.info(LOG + "reloading after push");
55
+ location.reload();
56
+ });
57
+ es.onerror = function () { /* EventSource reconnects on its own */ };
58
+ }
59
+ connect();
60
+ })();
61
+ `;
62
+ /** The one proxy a `watch` process runs, if any — the target of notifyPushed. */
63
+ let activeProxy = null;
64
+ /**
65
+ * Signal any browser tabs connected to a running dev proxy to reload after a
66
+ * push. No-op when no proxy is active (plain `push`, or watch without the
67
+ * proxy) — this is why push code can call it unconditionally.
68
+ */
69
+ export function notifyPushed(workflowId) {
70
+ activeProxy?.broadcast("pushed", { workflowId });
71
+ }
72
+ /**
73
+ * Boot the reverse proxy on 127.0.0.1:port, forwarding to `upstream`.
74
+ * Resolves to a handle, or `null` if the port can't be bound (caller keeps
75
+ * running without live reload — the acceptance "graceful fallback").
76
+ */
77
+ export async function startProxy({ upstream, port }, log) {
78
+ const target = new URL(upstream);
79
+ const secure = target.protocol === "https:";
80
+ const upstreamPort = target.port ? Number(target.port) : secure ? 443 : 80;
81
+ const clients = new Set();
82
+ const server = http.createServer((req, res) => {
83
+ const url = req.url ?? "/";
84
+ if (url === CLIENT_PATH)
85
+ return serveClient(res);
86
+ if (url === EVENTS_PATH)
87
+ return serveEvents(req, res, clients);
88
+ proxyHttp(req, res, { target, upstreamPort, secure }, log);
89
+ });
90
+ server.on("upgrade", (req, socket, head) => proxyUpgrade(req, socket, head, { target, upstreamPort, secure }, log));
91
+ const ping = setInterval(() => {
92
+ for (const res of clients)
93
+ res.write(": ping\n\n");
94
+ }, 25_000);
95
+ ping.unref();
96
+ try {
97
+ await new Promise((resolve, reject) => {
98
+ server.once("error", reject);
99
+ server.listen(port, "127.0.0.1", () => resolve());
100
+ });
101
+ }
102
+ catch (err) {
103
+ clearInterval(ping);
104
+ const e = err;
105
+ log.warn(`browser-reload proxy: could not bind 127.0.0.1:${port} (${e.code ?? e.message}) — continuing without live reload`);
106
+ return null;
107
+ }
108
+ const boundPort = server.address().port;
109
+ const handle = {
110
+ port: boundPort,
111
+ broadcast(event, data) {
112
+ const payload = `event: ${event}\ndata: ${JSON.stringify(data)}\n\n`;
113
+ for (const res of clients)
114
+ res.write(payload);
115
+ },
116
+ async close() {
117
+ clearInterval(ping);
118
+ for (const res of clients)
119
+ res.end();
120
+ clients.clear();
121
+ await new Promise((resolve) => server.close(() => resolve()));
122
+ if (activeProxy === handle)
123
+ activeProxy = null;
124
+ },
125
+ };
126
+ activeProxy = handle;
127
+ log.info(`browser-reload proxy: http://127.0.0.1:${boundPort} -> ${upstream} — open the n8n editor via the proxy URL`);
128
+ return handle;
129
+ }
130
+ function serveClient(res) {
131
+ res.writeHead(200, { "content-type": "application/javascript; charset=utf-8", "cache-control": "no-store" });
132
+ res.end(CLIENT_SCRIPT);
133
+ }
134
+ function serveEvents(req, res, clients) {
135
+ res.writeHead(200, { "content-type": "text/event-stream", "cache-control": "no-store", connection: "keep-alive" });
136
+ res.write(": connected\n\n");
137
+ clients.add(res);
138
+ req.on("close", () => clients.delete(res));
139
+ }
140
+ function proxyHttp(req, res, { target, upstreamPort, secure }, log) {
141
+ const headers = { ...req.headers, host: target.host };
142
+ // Force an uncompressed body so HTML can be string-injected without decoding.
143
+ delete headers["accept-encoding"];
144
+ const request = secure ? https.request : http.request;
145
+ const upstreamReq = request({ hostname: target.hostname, port: upstreamPort, method: req.method, path: req.url, headers }, (upstreamRes) => {
146
+ // HEAD carries no body to inject into — pass it straight through.
147
+ const isHtml = req.method !== "HEAD" && /text\/html/i.test(String(upstreamRes.headers["content-type"] ?? ""));
148
+ if (!isHtml) {
149
+ res.writeHead(upstreamRes.statusCode ?? 502, upstreamRes.headers);
150
+ upstreamRes.pipe(res);
151
+ return;
152
+ }
153
+ const chunks = [];
154
+ upstreamRes.on("data", (c) => chunks.push(c));
155
+ upstreamRes.on("end", () => {
156
+ const body = Buffer.from(injectClient(Buffer.concat(chunks).toString("utf8")), "utf8");
157
+ const outHeaders = { ...upstreamRes.headers };
158
+ delete outHeaders["content-length"];
159
+ delete outHeaders["transfer-encoding"];
160
+ res.writeHead(upstreamRes.statusCode ?? 200, { ...outHeaders, "content-length": String(body.byteLength) });
161
+ res.end(body);
162
+ });
163
+ });
164
+ upstreamReq.on("error", (err) => {
165
+ log.error(`browser-reload proxy: upstream request failed (${err.message})`);
166
+ if (!res.headersSent)
167
+ res.writeHead(502, { "content-type": "text/plain" });
168
+ res.end("decanter proxy: upstream error");
169
+ });
170
+ req.pipe(upstreamReq);
171
+ }
172
+ /** Inject the client bootstrapper right before </body> (append if there is none). */
173
+ function injectClient(html) {
174
+ const idx = html.toLowerCase().lastIndexOf("</body>");
175
+ return idx === -1 ? html + INJECT_TAG : html.slice(0, idx) + INJECT_TAG + html.slice(idx);
176
+ }
177
+ /** Pipe a raw WebSocket/Upgrade through to the upstream (n8n's native /rest/push). */
178
+ function proxyUpgrade(req, clientSocket, head, { target, upstreamPort, secure }, log) {
179
+ const onConnect = () => {
180
+ const lines = [`${req.method} ${req.url} HTTP/1.1`];
181
+ const headers = { ...req.headers, host: target.host };
182
+ for (const [key, value] of Object.entries(headers)) {
183
+ if (value === undefined)
184
+ continue;
185
+ for (const v of Array.isArray(value) ? value : [value])
186
+ lines.push(`${key}: ${v}`);
187
+ }
188
+ upstreamSocket.write(lines.join("\r\n") + "\r\n\r\n");
189
+ if (head?.length)
190
+ upstreamSocket.write(head);
191
+ upstreamSocket.pipe(clientSocket);
192
+ clientSocket.pipe(upstreamSocket);
193
+ };
194
+ const upstreamSocket = secure
195
+ ? tls.connect({ host: target.hostname, port: upstreamPort, servername: target.hostname }, onConnect)
196
+ : net.connect({ host: target.hostname, port: upstreamPort }, onConnect);
197
+ const onError = (err) => {
198
+ log.error(`browser-reload proxy: websocket upstream failed (${err.message})`);
199
+ clientSocket.destroy();
200
+ upstreamSocket.destroy();
201
+ };
202
+ upstreamSocket.on("error", onError);
203
+ clientSocket.on("error", onError);
204
+ }
@@ -0,0 +1,148 @@
1
+ import { existsSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs";
2
+ import path from "node:path";
3
+ import { compileTs } from "./compile.mjs";
4
+ import { commitWorkflowDir } from "./git.mjs";
5
+ import { findWorkflowDir, readState, renameNodeFilePair, writeState } from "./state.mjs";
6
+ import { CODE_DIR, FILE_PLACEHOLDER_PREFIX, isJsCodeNode, kebabCase, sanitizeFilename, sha256, splitMarker, stableWorkflowJson, workflowStructureHash, } from "./util.mjs";
7
+ function writeIfChanged(file, content) {
8
+ if (existsSync(file) && readFileSync(file, "utf8") === content)
9
+ return false;
10
+ writeFileSync(file, content);
11
+ return true;
12
+ }
13
+ /** Locate/create the workflow folder, renaming it if the workflow was renamed. */
14
+ function ensureWorkflowDir(root, wf, log) {
15
+ const wanted = sanitizeFilename(wf.name);
16
+ const existing = findWorkflowDir(root, wf.id, log);
17
+ if (!existing) {
18
+ const dir = path.join(root, wanted);
19
+ mkdirSync(dir, { recursive: true });
20
+ return { dir };
21
+ }
22
+ if (path.basename(existing) !== wanted) {
23
+ const target = path.join(path.dirname(existing), wanted);
24
+ if (existsSync(target)) {
25
+ log.warn(`workflow renamed to "${wf.name}" but ${target} already exists — keeping ${existing}`);
26
+ return { dir: existing };
27
+ }
28
+ renameSync(existing, target);
29
+ log.info(`renamed folder ${path.basename(existing)}/ -> ${wanted}/`);
30
+ return { dir: target, previousDir: existing };
31
+ }
32
+ return { dir: existing };
33
+ }
34
+ /**
35
+ * Pick/refresh the file name for a node: kebab-case under code/, renaming
36
+ * existing files on node rename (which also migrates pre-code/ layouts).
37
+ * Collision handling is per-pull (`usedNames`), deterministic across nodes
38
+ * that kebab to the same base.
39
+ */
40
+ function resolveNodeFile(dir, nodeState, node, ext, usedNames, log) {
41
+ let base = kebabCase(node.name);
42
+ if (usedNames.has(base))
43
+ base = `${base}-${node.id.slice(0, 8)}`;
44
+ usedNames.add(base);
45
+ const wanted = `${CODE_DIR}/${base}${ext}`;
46
+ mkdirSync(path.join(dir, CODE_DIR), { recursive: true });
47
+ const current = nodeState.file;
48
+ if (current && current !== wanted)
49
+ renameNodeFilePair(dir, current, base, ext, log);
50
+ return { file: wanted, base };
51
+ }
52
+ export async function pullWorkflow(api, root, id, { commitOnPull = false } = {}, log) {
53
+ const wf = await api.getWorkflow(id);
54
+ const { dir, previousDir } = ensureWorkflowDir(root, wf, log);
55
+ const state = readState(dir) ?? { workflowId: wf.id, nodes: {} };
56
+ state.workflowId = wf.id;
57
+ state.nodes ??= {};
58
+ const usedNames = new Set();
59
+ const placeholders = new Map(); // node id -> file name
60
+ for (const node of wf.nodes) {
61
+ if (!isJsCodeNode(node))
62
+ continue;
63
+ const nodeState = state.nodes[node.id] ?? {};
64
+ const remote = node.parameters.jsCode;
65
+ const { body: remoteBody, markerHash } = splitMarker(remote);
66
+ const remoteHash = sha256(remoteBody);
67
+ const tsManaged = markerHash !== null;
68
+ const { file, base } = resolveNodeFile(dir, nodeState, node, tsManaged ? ".ts" : ".js", usedNames, log);
69
+ const filePath = path.join(dir, file);
70
+ const remoteRel = `${CODE_DIR}/${base}.remote.js`;
71
+ const remoteJsFile = path.join(dir, remoteRel);
72
+ if (tsManaged) {
73
+ if (!existsSync(filePath)) {
74
+ writeFileSync(remoteJsFile, remoteBody);
75
+ log.warn(`${wf.name} / ${node.name}: TS-managed on remote but no local ${file} — compiled code saved to ${remoteRel}`);
76
+ }
77
+ else {
78
+ const compiled = await compileTs(filePath, log);
79
+ const localHash = sha256(compiled);
80
+ if (localHash === remoteHash) {
81
+ if (existsSync(remoteJsFile)) {
82
+ rmSync(remoteJsFile);
83
+ log.info(`${node.name}: in sync, removed stale ${remoteRel}`);
84
+ }
85
+ }
86
+ else if (localHash === nodeState.lastPushedHash) {
87
+ writeFileSync(remoteJsFile, remoteBody);
88
+ log.warn(`${wf.name} / ${node.name}: edited in the n8n UI since last push — remote code saved to ${remoteRel}; port it into ${file} manually`);
89
+ }
90
+ else if (remoteHash === nodeState.lastPushedHash) {
91
+ log.info(`${node.name}: local ${file} modified, not yet pushed`);
92
+ }
93
+ else {
94
+ writeFileSync(remoteJsFile, remoteBody);
95
+ log.warn(`${wf.name} / ${node.name}: CONFLICT — both ${file} and the remote code changed since last sync. Remote saved to ${remoteRel}; reconcile manually before pushing`);
96
+ }
97
+ }
98
+ }
99
+ else if (nodeState.file?.endsWith(".ts") || existsSync(path.join(dir, CODE_DIR, base + ".ts"))) {
100
+ // Local .ts exists but remote carries no marker: never clobber TS source
101
+ // and don't drop a competing .js next to it.
102
+ const tsFile = nodeState.file ?? `${CODE_DIR}/${base}.ts`;
103
+ writeFileSync(remoteJsFile, remoteBody);
104
+ log.warn(`${wf.name} / ${node.name}: local ${tsFile} exists but remote code has no @ts-n8n marker (not pushed from TS yet?) — remote saved to ${remoteRel}`);
105
+ placeholders.set(node.id, tsFile);
106
+ state.nodes[node.id] = { ...nodeState, file: tsFile, lastPushedHash: remoteHash };
107
+ continue;
108
+ }
109
+ else {
110
+ if (writeIfChanged(filePath, remoteBody))
111
+ log.info(`wrote ${path.basename(dir)}/${file}`);
112
+ }
113
+ placeholders.set(node.id, file);
114
+ state.nodes[node.id] = { ...nodeState, file, lastPushedHash: remoteHash };
115
+ }
116
+ // Drop state for nodes that no longer exist remotely (files stay; git is the safety net).
117
+ const liveIds = new Set(wf.nodes.map((n) => n.id));
118
+ for (const nodeId of Object.keys(state.nodes)) {
119
+ if (!liveIds.has(nodeId)) {
120
+ log.warn(`node ${nodeId} ("${state.nodes[nodeId].file}") no longer exists remotely — removing from state, delete the file manually if unwanted`);
121
+ delete state.nodes[nodeId];
122
+ }
123
+ }
124
+ const wfOut = structuredClone(wf);
125
+ // Keep workflow.json to the workflow itself: n8n 2.x GET responses embed a
126
+ // full server-side copy of the *published* version (`activeVersion`, code
127
+ // included) and sharing metadata (`shared`) — derived data that would
128
+ // duplicate every node's source in git and churn on each publish. Neither
129
+ // field is pushable (sanitizeForPut whitelists), so dropping them loses
130
+ // nothing.
131
+ delete wfOut.activeVersion;
132
+ delete wfOut.shared;
133
+ for (const node of wfOut.nodes) {
134
+ const file = placeholders.get(node.id);
135
+ if (file)
136
+ node.parameters.jsCode = FILE_PLACEHOLDER_PREFIX + file;
137
+ }
138
+ if (writeIfChanged(path.join(dir, "workflow.json"), stableWorkflowJson(wfOut))) {
139
+ log.info(`wrote ${path.basename(dir)}/workflow.json`);
140
+ }
141
+ state.lastPulledWorkflowHash = workflowStructureHash(wf);
142
+ writeState(dir, state);
143
+ if (commitOnPull) {
144
+ const extras = previousDir ? [path.relative(dir, previousDir)] : [];
145
+ await commitWorkflowDir(dir, `decanter: pulled "${wf.name}" (${id})`, log, extras);
146
+ }
147
+ return { dir, name: wf.name };
148
+ }
@@ -0,0 +1,136 @@
1
+ import { existsSync, readFileSync } from "node:fs";
2
+ import path from "node:path";
3
+ import { compileTs } from "./compile.mjs";
4
+ import { commitWorkflowDir } from "./git.mjs";
5
+ import { notifyPushed } from "./proxy.mjs";
6
+ import { findWorkflowDir, readState, writeState } from "./state.mjs";
7
+ import { isJsCodeNode, placeholderFile, publicationState, sanitizeForPut, sha256, splitMarker, withMarker, workflowStructureHash, } from "./util.mjs";
8
+ import { validateNodeFile, validateWorkflowDir } from "./validate.mjs";
9
+ /** Layout-compliance gate: warnings pass through, errors abort the push. */
10
+ function assertCompliant({ errors, warnings }, log, what) {
11
+ for (const w of warnings)
12
+ log.warn(w);
13
+ if (errors.length === 0)
14
+ return;
15
+ for (const e of errors)
16
+ log.error(e);
17
+ throw new Error(`${what} does not comply with the decanter layout (${errors.length} problem${errors.length === 1 ? "" : "s"}) — fix the issues above, see also: n8n-decanter check`);
18
+ }
19
+ /** Turn a node source file into the jsCode payload (+ hash of the marker-less body). */
20
+ export async function buildNodeCode(dir, file, log) {
21
+ const filePath = path.join(dir, file);
22
+ if (!existsSync(filePath))
23
+ throw new Error(`referenced node file missing: ${filePath}`);
24
+ if (file.endsWith(".ts")) {
25
+ return withMarker(await compileTs(filePath, log));
26
+ }
27
+ const jsCode = readFileSync(filePath, "utf8");
28
+ return { jsCode, hash: sha256(jsCode) };
29
+ }
30
+ /**
31
+ * Compare the freshly fetched remote workflow against the last-synced hashes.
32
+ * Returns human-readable problems; empty array means safe to push.
33
+ * `onlyNodeIds` restricts the per-node check (watch mode).
34
+ */
35
+ export function driftProblems(remote, state, onlyNodeIds = null) {
36
+ const problems = [];
37
+ for (const node of remote.nodes) {
38
+ if (!isJsCodeNode(node))
39
+ continue;
40
+ if (onlyNodeIds && !onlyNodeIds.has(node.id))
41
+ continue;
42
+ const nodeState = state.nodes[node.id];
43
+ const { body } = splitMarker(node.parameters.jsCode);
44
+ if (!nodeState) {
45
+ problems.push(`node "${node.name}" exists remotely but is unknown locally`);
46
+ }
47
+ else if (nodeState.lastPushedHash !== sha256(body)) {
48
+ problems.push(`node "${node.name}": remote code changed since last sync`);
49
+ }
50
+ }
51
+ if (!onlyNodeIds && state.lastPulledWorkflowHash &&
52
+ workflowStructureHash(remote) !== state.lastPulledWorkflowHash) {
53
+ problems.push("workflow structure changed remotely since last sync (nodes/connections/settings)");
54
+ }
55
+ return problems;
56
+ }
57
+ function assertNoDrift(problems, force, log) {
58
+ if (problems.length === 0)
59
+ return;
60
+ for (const p of problems)
61
+ log[force ? "warn" : "error"](p);
62
+ if (!force) {
63
+ throw new Error("remote changed since last sync — pull first (or repeat with --force to overwrite)");
64
+ }
65
+ log.warn("--force: overwriting remote changes");
66
+ }
67
+ /** Publication suffix for push result lines (see publicationState in util). */
68
+ function liveNote(wf) {
69
+ const state = publicationState(wf);
70
+ if (!state)
71
+ return "";
72
+ return state === "published" ? " — published: code is live now" : " — unpublished: draft only";
73
+ }
74
+ /** Update per-node + structure hashes from the workflow the server confirmed. */
75
+ function recordSync(state, confirmed) {
76
+ for (const node of confirmed.nodes) {
77
+ if (!isJsCodeNode(node))
78
+ continue;
79
+ const nodeState = state.nodes[node.id];
80
+ if (nodeState)
81
+ nodeState.lastPushedHash = sha256(splitMarker(node.parameters.jsCode).body);
82
+ }
83
+ state.lastPulledWorkflowHash = workflowStructureHash(confirmed);
84
+ }
85
+ export async function pushWorkflow(api, root, id, { force = false, commitOnPush = false } = {}, log) {
86
+ const dir = findWorkflowDir(root, id, log);
87
+ if (!dir)
88
+ throw new Error(`workflow ${id} not found under ${root} — pull it first`);
89
+ assertCompliant(validateWorkflowDir(dir), log, `"${path.basename(dir)}"`);
90
+ const state = readState(dir);
91
+ const wf = JSON.parse(readFileSync(path.join(dir, "workflow.json"), "utf8"));
92
+ for (const node of wf.nodes) {
93
+ if (!isJsCodeNode(node))
94
+ continue;
95
+ const file = placeholderFile(node);
96
+ if (file === null)
97
+ continue;
98
+ node.parameters.jsCode = (await buildNodeCode(dir, file, log)).jsCode;
99
+ state.nodes[node.id] = { ...state.nodes[node.id], file };
100
+ }
101
+ const remote = await api.getWorkflow(id);
102
+ assertNoDrift(driftProblems(remote, state, null), force, log);
103
+ const confirmed = await api.updateWorkflow(id, sanitizeForPut(wf));
104
+ recordSync(state, confirmed ?? wf);
105
+ writeState(dir, state);
106
+ log.ok(`pushed "${wf.name}" (${id})${liveNote(confirmed ?? remote)}`);
107
+ notifyPushed(id);
108
+ if (commitOnPush)
109
+ await commitWorkflowDir(dir, `decanter: pushed "${wf.name}" (${id})`, log);
110
+ return { dir, name: wf.name };
111
+ }
112
+ /** Push a single node's code (watch mode): GET, swap jsCode, PUT. */
113
+ export async function pushSingleNode(api, dir, nodeId, { force = false, commitOnPush = false } = {}, log) {
114
+ const state = readState(dir);
115
+ if (!state)
116
+ throw new Error(`missing .decanter.json in ${dir} — pull first`);
117
+ const nodeState = state.nodes[nodeId];
118
+ if (!nodeState)
119
+ throw new Error(`node ${nodeId} has no entry in ${dir}/.decanter.json — pull first`);
120
+ assertCompliant(validateNodeFile(dir, nodeState.file), log, nodeState.file);
121
+ const { jsCode } = await buildNodeCode(dir, nodeState.file, log);
122
+ const remote = await api.getWorkflow(state.workflowId);
123
+ assertNoDrift(driftProblems(remote, state, new Set([nodeId])), force, log);
124
+ const node = remote.nodes.find((n) => n.id === nodeId);
125
+ if (!node)
126
+ throw new Error(`node ${nodeId} no longer exists in remote workflow ${state.workflowId}`);
127
+ node.parameters.jsCode = jsCode;
128
+ const confirmed = await api.updateWorkflow(state.workflowId, sanitizeForPut(remote));
129
+ recordSync(state, confirmed ?? remote);
130
+ writeState(dir, state);
131
+ log.ok(`pushed node "${node.name}" -> workflow "${remote.name}"${liveNote(confirmed ?? remote)}`);
132
+ notifyPushed(state.workflowId);
133
+ if (commitOnPush) {
134
+ await commitWorkflowDir(dir, `decanter: pushed "${remote.name}" / node "${node.name}" (${state.workflowId})`, log);
135
+ }
136
+ }
@@ -0,0 +1,156 @@
1
+ import { existsSync, readFileSync, writeFileSync } from "node:fs";
2
+ import path from "node:path";
3
+ import { findWorkflowDir, readState, renameNodeFilePair, writeState } from "./state.mjs";
4
+ import { CODE_DIR, FILE_PLACEHOLDER_PREFIX, forEachConnectionTarget, isJsCodeNode, kebabCase, placeholderFile, renameNodeRefs, stableWorkflowJson } from "./util.mjs";
5
+ import { validateWorkflowDir } from "./validate.mjs";
6
+ function loadWorkflow(root, id, log) {
7
+ const dir = findWorkflowDir(root, id, log);
8
+ if (!dir)
9
+ throw new Error(`workflow ${id} not found under ${root} — pull it first`);
10
+ const wfFile = path.join(dir, "workflow.json");
11
+ if (!existsSync(wfFile))
12
+ throw new Error(`missing workflow.json in ${dir} — pull first`);
13
+ try {
14
+ return { dir, wf: JSON.parse(readFileSync(wfFile, "utf8")) };
15
+ }
16
+ catch (err) {
17
+ throw new Error(`${wfFile}: invalid JSON (${err.message})`);
18
+ }
19
+ }
20
+ /** Rename `old` -> `new` in connection keys and every `{ node: … }` target. */
21
+ function renameInConnections(connections, oldName, newName) {
22
+ let changes = 0;
23
+ forEachConnectionTarget(connections, (target) => {
24
+ if (target.node === oldName) {
25
+ target.node = newName;
26
+ changes++;
27
+ }
28
+ });
29
+ if (Object.hasOwn(connections, oldName)) {
30
+ connections[newName] = connections[oldName];
31
+ delete connections[oldName];
32
+ changes++;
33
+ }
34
+ return changes;
35
+ }
36
+ /** Rewrite `$('old')` in every string parameter of every node (skips the jsCode placeholder). */
37
+ function renameInParameters(value, oldName, newName, skipKey) {
38
+ if (typeof value === "string") {
39
+ const rewritten = renameNodeRefs(value, oldName, newName);
40
+ return { value: rewritten, changes: rewritten === value ? 0 : 1 };
41
+ }
42
+ let changes = 0;
43
+ if (Array.isArray(value)) {
44
+ const out = value.map((v) => {
45
+ const r = renameInParameters(v, oldName, newName);
46
+ changes += r.changes;
47
+ return r.value;
48
+ });
49
+ return { value: out, changes };
50
+ }
51
+ if (value && typeof value === "object") {
52
+ for (const [k, v] of Object.entries(value)) {
53
+ if (k === skipKey)
54
+ continue;
55
+ const r = renameInParameters(v, oldName, newName);
56
+ changes += r.changes;
57
+ value[k] = r.value;
58
+ }
59
+ }
60
+ return { value, changes };
61
+ }
62
+ /**
63
+ * Move the renamed node's source file to its new kebab-case name (plus
64
+ * .remote.js sibling). Collision handling is against the disk (unlike pull's
65
+ * per-pull name set): an occupied target falls back to the `-<id8>` suffix,
66
+ * a double collision throws for the user to resolve.
67
+ */
68
+ function renameNodeFile(dir, node, newName, log) {
69
+ if (!isJsCodeNode(node))
70
+ return;
71
+ const current = placeholderFile(node);
72
+ if (current === null)
73
+ return;
74
+ const ext = path.extname(current);
75
+ let base = kebabCase(newName);
76
+ if (`${CODE_DIR}/${base}${ext}` === current)
77
+ return;
78
+ if (existsSync(path.join(dir, `${CODE_DIR}/${base}${ext}`))) {
79
+ base = `${base}-${node.id.slice(0, 8)}`;
80
+ if (existsSync(path.join(dir, `${CODE_DIR}/${base}${ext}`))) {
81
+ throw new Error(`cannot rename ${current}: both kebab-case targets exist (${CODE_DIR}/${base}${ext})`);
82
+ }
83
+ }
84
+ const wanted = renameNodeFilePair(dir, current, base, ext, log);
85
+ node.parameters.jsCode = FILE_PLACEHOLDER_PREFIX + wanted;
86
+ const state = readState(dir);
87
+ if (state?.nodes[node.id]) {
88
+ state.nodes[node.id].file = wanted;
89
+ writeState(dir, state);
90
+ }
91
+ }
92
+ /**
93
+ * Atomically rename a node everywhere the old name is load-bearing:
94
+ * node.name, connection keys and targets, literal $('…') references in every
95
+ * code file and expression parameter, the source filename, its //@file:
96
+ * placeholder, and the .decanter.json entry. Offline; push propagates.
97
+ */
98
+ export function renameNode(root, id, oldName, newName, log) {
99
+ newName = newName.trim();
100
+ if (!newName)
101
+ throw new Error("new node name must not be empty");
102
+ if (newName === oldName)
103
+ throw new Error(`node is already named "${oldName}"`);
104
+ const { dir, wf } = loadWorkflow(root, id, log);
105
+ const node = wf.nodes.find((n) => n.name === oldName);
106
+ if (!node)
107
+ throw new Error(`no node named "${oldName}" in "${wf.name}" (nodes: ${wf.nodes.map((n) => `"${n.name}"`).join(", ")})`);
108
+ if (wf.nodes.some((n) => n.name === newName))
109
+ throw new Error(`a node named "${newName}" already exists in "${wf.name}"`);
110
+ node.name = newName;
111
+ const connectionChanges = renameInConnections(wf.connections ?? {}, oldName, newName);
112
+ let paramChanges = 0;
113
+ for (const n of wf.nodes) {
114
+ paramChanges += renameInParameters(n.parameters, oldName, newName, "jsCode").changes;
115
+ }
116
+ // Rewrite $('…') in every node source file (referenced ones; .remote.js
117
+ // snapshots mirror remote code and stay untouched).
118
+ let fileChanges = 0;
119
+ for (const n of wf.nodes) {
120
+ if (!isJsCodeNode(n))
121
+ continue;
122
+ const file = placeholderFile(n);
123
+ if (file === null)
124
+ continue;
125
+ const filePath = path.join(dir, file);
126
+ if (!existsSync(filePath))
127
+ continue;
128
+ const source = readFileSync(filePath, "utf8");
129
+ const rewritten = renameNodeRefs(source, oldName, newName);
130
+ if (rewritten !== source) {
131
+ writeFileSync(filePath, rewritten);
132
+ fileChanges++;
133
+ log.info(`updated $('${oldName}') references in ${file}`);
134
+ }
135
+ }
136
+ renameNodeFile(dir, node, newName, log);
137
+ writeFileSync(path.join(dir, "workflow.json"), stableWorkflowJson(wf));
138
+ log.info(`renamed node "${oldName}" -> "${newName}" (${connectionChanges} connection ref${connectionChanges === 1 ? "" : "s"}, ${paramChanges} parameter ref${paramChanges === 1 ? "" : "s"}, ${fileChanges} code file${fileChanges === 1 ? "" : "s"}) — push to propagate`);
139
+ const { errors } = validateWorkflowDir(dir);
140
+ if (errors.length > 0) {
141
+ throw new Error(`rename left the workflow non-compliant — files were written, inspect (git diff) and fix:\n${errors.map((e) => ` ${e}`).join("\n")}`);
142
+ }
143
+ }
144
+ /** Rename the workflow itself. The folder is cosmetic and follows on the next pull. */
145
+ export function renameWorkflow(root, id, newName, log) {
146
+ newName = newName.trim();
147
+ if (!newName)
148
+ throw new Error("new workflow name must not be empty");
149
+ const { dir, wf } = loadWorkflow(root, id, log);
150
+ if (wf.name === newName)
151
+ throw new Error(`workflow is already named "${newName}"`);
152
+ const oldName = wf.name;
153
+ wf.name = newName;
154
+ writeFileSync(path.join(dir, "workflow.json"), stableWorkflowJson(wf));
155
+ log.info(`renamed workflow "${oldName}" -> "${newName}" — push to propagate; the folder follows on the next pull`);
156
+ }