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,296 @@
1
+ import { existsSync, readFileSync, rmSync, watch, writeFileSync } from "node:fs";
2
+ import path from "node:path";
3
+ import { commitWorkflowDir } from "./git.mjs";
4
+ import { startProxy } from "./proxy.mjs";
5
+ import { createPrompt } from "./prompt.mjs";
6
+ import { pullWorkflow } from "./pull.mjs";
7
+ import { pushSingleNode, pushWorkflow } from "./push.mjs";
8
+ import { findWorkflowDir, readState } from "./state.mjs";
9
+ import { style } from "./style.mjs";
10
+ import { CODE_DIR, FILE_PLACEHOLDER_PREFIX, isJsCodeNode, publicationState, sha256, splitMarker, stableWorkflowJson, workflowStructureHash, } from "./util.mjs";
11
+ /**
12
+ * Pure 3-way decision for a workflow.json save event. `baseline` is the
13
+ * structure hash both sides agreed on at the session's last sync point; an
14
+ * absent baseline skips conflict detection (mirrors driftProblems).
15
+ */
16
+ export function structureAction(localHash, remoteHash, baseline) {
17
+ if (localHash === baseline || localHash === remoteHash)
18
+ return "skip";
19
+ if (baseline === undefined || remoteHash === baseline)
20
+ return "push";
21
+ return "conflict";
22
+ }
23
+ /**
24
+ * Fast inner loop for a whole workflow: resolve its folder by id, then
25
+ *
26
+ * - `code/` saves map back to their node (via `.decanter.json`, re-read each
27
+ * time so a mid-session rename still resolves) and push **only that node**
28
+ * (GET → swap jsCode → PUT), preserving remote structure.
29
+ * - `workflow.json` saves push the *structure* via a full pushWorkflow — but
30
+ * only after a 3-way check against the session baseline; if the remote
31
+ * structure also changed since the last sync, an interactive prompt offers
32
+ * merge (write workflow.remote.json) / keep local / keep remote.
33
+ *
34
+ * Every session starts with a safety snapshot commit followed by a pull, so
35
+ * the working tree begins committed and in sync with remote — pull overwrites
36
+ * plain .js files and workflow.json, hence the commit-first order. If the
37
+ * snapshot fails (no git), the startup pull is skipped rather than risk
38
+ * losing uncommitted local edits.
39
+ *
40
+ * The session baseline is deliberately *not* advanced by single-node pushes:
41
+ * their PUT responses re-baseline `.decanter.json`'s structure hash, silently
42
+ * absorbing n8n-UI structural edits — the in-memory baseline keeps those
43
+ * detectable (early warning after node pushes, conflict prompt on the next
44
+ * structural save).
45
+ *
46
+ * With `browserReload: "proxy"` a transparent dev proxy boots first (Plan 5);
47
+ * each successful push signals the browser tab to refresh (notifyPushed in
48
+ * lib/proxy).
49
+ */
50
+ export async function watchWorkflow(api, config, id, { force = false } = {}, log) {
51
+ const found = findWorkflowDir(config.root, id, log);
52
+ if (!found)
53
+ throw new Error(`workflow ${id} not found under ${config.root} — pull it first`);
54
+ let dir = found;
55
+ const { commitOnPush, commitOnPull } = config;
56
+ const wfJsonPath = () => path.join(dir, "workflow.json");
57
+ const remoteJsonRel = "workflow.remote.json";
58
+ const readLocalWorkflow = () => {
59
+ try {
60
+ return JSON.parse(readFileSync(wfJsonPath(), "utf8"));
61
+ }
62
+ catch {
63
+ return undefined;
64
+ }
65
+ };
66
+ // --- watch start: snapshot commit, then pull (commit BEFORE pull — pull
67
+ // overwrites plain .js files and workflow.json with remote unconditionally)
68
+ let structureBaseline = readState(dir)?.lastPulledWorkflowHash;
69
+ const snapshot = await commitWorkflowDir(dir, `decanter: watch start snapshot (${id})`, log);
70
+ if (snapshot === "failed") {
71
+ log.warn("no git safety net — skipping the watch-start pull; pull manually if desired");
72
+ }
73
+ else {
74
+ const local = readLocalWorkflow();
75
+ const remote = await api.getWorkflow(id);
76
+ if (publicationState(remote) === "published") {
77
+ log.warn("workflow is published — every push goes live immediately (n8n auto-publishes API updates)");
78
+ }
79
+ if (local && structureAction(workflowStructureHash(local), workflowStructureHash(remote), structureBaseline) === "conflict") {
80
+ log.warn("structural conflict at watch start: workflow.json and the remote workflow both changed since last sync — pulling remote; your local version is preserved in git, reconcile and save workflow.json to push");
81
+ }
82
+ const pulled = await pullWorkflow(api, config.root, id, { commitOnPull }, log);
83
+ dir = pulled.dir;
84
+ structureBaseline = readState(dir)?.lastPulledWorkflowHash;
85
+ }
86
+ // Deep link straight to the watched workflow — through the proxy when it
87
+ // runs (so live reload works in that tab), the configured upstream otherwise.
88
+ let editorOrigin = config.host;
89
+ let proxy = null;
90
+ if (config.browserReload === "proxy") {
91
+ proxy = await startProxy({ upstream: config.host, port: config.proxyPort }, log);
92
+ if (proxy)
93
+ editorOrigin = `http://127.0.0.1:${proxy.port}`;
94
+ }
95
+ const editorUrl = `${editorOrigin.replace(/\/+$/, "")}/workflow/${id}`;
96
+ log.info(`editor: ${style.link(editorUrl, editorUrl)}`);
97
+ /** Resolve a file name inside code/ back to its node id (state re-read live). */
98
+ const nodeIdForFile = (fileName) => {
99
+ const rel = `${CODE_DIR}/${fileName}`;
100
+ const state = readState(dir);
101
+ if (!state)
102
+ return undefined;
103
+ return Object.entries(state.nodes).find(([, ns]) => ns.file === rel)?.[0];
104
+ };
105
+ const dirty = new Set();
106
+ let structureDirty = false;
107
+ let timer;
108
+ let running = false;
109
+ let queued = false;
110
+ let lastUiWarnHash;
111
+ /** Early warning: a node push's PUT response revealed UI structural edits. */
112
+ const warnOnUiStructureEdit = () => {
113
+ const synced = readState(dir)?.lastPulledWorkflowHash;
114
+ if (!synced || synced === structureBaseline || synced === lastUiWarnHash)
115
+ return;
116
+ lastUiWarnHash = synced;
117
+ log.warn("workflow structure changed in the n8n UI — local workflow.json is stale; pull before editing it (a structural save will prompt)");
118
+ };
119
+ /**
120
+ * Remote workflow as a diff-friendly conflict file: placeholders substituted
121
+ * only where the remote code still matches the last sync (otherwise the
122
+ * changed remote code stays inline and visible).
123
+ */
124
+ const writeRemoteWorkflowFile = (remote) => {
125
+ const state = readState(dir);
126
+ const out = structuredClone(remote);
127
+ for (const node of out.nodes) {
128
+ if (!isJsCodeNode(node))
129
+ continue;
130
+ const ns = state?.nodes[node.id];
131
+ if (ns && sha256(splitMarker(node.parameters.jsCode).body) === ns.lastPushedHash) {
132
+ node.parameters.jsCode = FILE_PLACEHOLDER_PREFIX + ns.file;
133
+ }
134
+ }
135
+ writeFileSync(path.join(dir, remoteJsonRel), stableWorkflowJson(out));
136
+ log.info(`wrote ${remoteJsonRel} — reconcile into workflow.json, then save (choose "keep local") and delete it`);
137
+ };
138
+ const refreshBaseline = () => {
139
+ structureBaseline = readState(dir)?.lastPulledWorkflowHash;
140
+ lastUiWarnHash = undefined;
141
+ };
142
+ const doPushStructure = async (forceFlag) => {
143
+ await pushWorkflow(api, config.root, id, { force: forceFlag, commitOnPush }, log);
144
+ const stale = path.join(dir, remoteJsonRel);
145
+ if (existsSync(stale)) {
146
+ rmSync(stale);
147
+ log.info(`removed stale ${remoteJsonRel}`);
148
+ }
149
+ refreshBaseline();
150
+ };
151
+ /** Interactive conflict resolution; returns true when the workflow got fully synced. */
152
+ const resolveConflict = async (remote) => {
153
+ log.error("structural conflict: workflow.json and the remote workflow both changed since last sync");
154
+ if (!process.stdin.isTTY) {
155
+ log.error("non-interactive session — skipped the structural push; pull (or push --force) to resolve");
156
+ return false;
157
+ }
158
+ const rl = createPrompt();
159
+ let choice;
160
+ try {
161
+ choice = (await rl.question(` [m]erge — write ${remoteJsonRel} for manual reconciliation\n` +
162
+ " [l]ocal — push workflow.json, overwriting the remote changes\n" +
163
+ " [r]emote — pull, overwriting workflow.json (previous version is in git)\n" +
164
+ " [Enter] — skip for now (asks again on the next save)\n" +
165
+ "choice: ")).trim().toLowerCase();
166
+ }
167
+ finally {
168
+ rl.close();
169
+ }
170
+ switch (choice) {
171
+ case "m":
172
+ writeRemoteWorkflowFile(remote);
173
+ return false;
174
+ case "l":
175
+ await doPushStructure(true);
176
+ return true;
177
+ case "r": {
178
+ const pulled = await pullWorkflow(api, config.root, id, { commitOnPull }, log);
179
+ log.warn("workflow.json overwritten with the remote version — the previous version is in git");
180
+ if (pulled.dir !== dir) {
181
+ dir = pulled.dir;
182
+ armWatchers();
183
+ }
184
+ refreshBaseline();
185
+ return true;
186
+ }
187
+ default:
188
+ log.info("skipped — the conflict prompt returns on the next workflow.json save");
189
+ return false;
190
+ }
191
+ };
192
+ /** Handle a workflow.json save; returns true when nodes are covered too (full push or pull ran). */
193
+ const pushStructure = async () => {
194
+ const local = readLocalWorkflow();
195
+ if (!local) {
196
+ log.warn("workflow.json missing or unparsable — skipped, retries on the next save");
197
+ return false;
198
+ }
199
+ const localHash = workflowStructureHash(local);
200
+ if (localHash === structureBaseline)
201
+ return false; // formatting-only save or our own pull rewrite (anti-loop)
202
+ const remote = await api.getWorkflow(id);
203
+ const action = structureAction(localHash, workflowStructureHash(remote), structureBaseline);
204
+ if (action === "skip") {
205
+ log.info("workflow.json: no structural change against remote — nothing to push");
206
+ return false;
207
+ }
208
+ if (action === "conflict" && !force)
209
+ return resolveConflict(remote);
210
+ if (action === "conflict")
211
+ log.warn("--force: overwriting remote structural changes");
212
+ // clean push keeps the drift guard armed (UI *code* edits don't move the
213
+ // structure hash, so only pushWorkflow's per-node check can catch them)
214
+ await doPushStructure(action === "conflict" || force);
215
+ return true;
216
+ };
217
+ async function flush() {
218
+ if (running) {
219
+ queued = true;
220
+ return;
221
+ }
222
+ running = true;
223
+ try {
224
+ let nodesCovered = false;
225
+ if (structureDirty) {
226
+ structureDirty = false;
227
+ try {
228
+ nodesCovered = await pushStructure();
229
+ }
230
+ catch (err) {
231
+ log.error(err.message);
232
+ }
233
+ }
234
+ const ids = [...dirty];
235
+ dirty.clear();
236
+ if (!nodesCovered) {
237
+ for (const nodeId of ids) {
238
+ try {
239
+ await pushSingleNode(api, dir, nodeId, { force, commitOnPush }, log);
240
+ warnOnUiStructureEdit();
241
+ }
242
+ catch (err) {
243
+ log.error(err.message);
244
+ }
245
+ }
246
+ }
247
+ }
248
+ finally {
249
+ running = false;
250
+ if (queued || dirty.size > 0 || structureDirty) {
251
+ queued = false;
252
+ trigger();
253
+ }
254
+ }
255
+ }
256
+ const trigger = () => {
257
+ clearTimeout(timer);
258
+ timer = setTimeout(flush, 200);
259
+ };
260
+ let codeWatcher;
261
+ let dirWatcher;
262
+ /** (Re-)arm both watchers — dir watches, not file watches: editor saves replace inodes. */
263
+ const armWatchers = () => {
264
+ codeWatcher?.close();
265
+ dirWatcher?.close();
266
+ const codeDir = path.join(dir, CODE_DIR);
267
+ codeWatcher = existsSync(codeDir)
268
+ ? watch(codeDir, (_event, changed) => {
269
+ if (!changed)
270
+ return;
271
+ const nodeId = nodeIdForFile(changed);
272
+ if (!nodeId)
273
+ return; // not a tracked node file (.remote.js, .d.ts, or unknown)
274
+ dirty.add(nodeId);
275
+ trigger();
276
+ })
277
+ : undefined;
278
+ dirWatcher = watch(dir, (_event, changed) => {
279
+ if (changed !== "workflow.json")
280
+ return; // .decanter.json writes etc.
281
+ structureDirty = true;
282
+ trigger();
283
+ });
284
+ };
285
+ armWatchers();
286
+ const count = Object.keys(readState(dir)?.nodes ?? {}).length;
287
+ log.info(`watching workflow ${id} — ${count} Code node${count === 1 ? "" : "s"} in ${path.join(path.basename(dir), CODE_DIR)} + workflow.json — Ctrl-C to stop`);
288
+ return {
289
+ async close() {
290
+ clearTimeout(timer);
291
+ codeWatcher?.close();
292
+ dirWatcher?.close();
293
+ await proxy?.close();
294
+ },
295
+ };
296
+ }