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,61 @@
1
+ import { existsSync, readFileSync } from "node:fs";
2
+ import path from "node:path";
3
+ /** Parse KEY=VALUE lines (optional `export`, quotes stripped) from an env file. */
4
+ export function parseEnvFile(file) {
5
+ const values = {};
6
+ if (!existsSync(file))
7
+ return values;
8
+ for (const line of readFileSync(file, "utf8").split("\n")) {
9
+ const m = line.match(/^\s*(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(.*?)\s*$/);
10
+ if (!m || m[0].trimStart().startsWith("#"))
11
+ continue;
12
+ values[m[1]] = m[2].replace(/^(['"])(.*)\1$/, "$2");
13
+ }
14
+ return values;
15
+ }
16
+ /** Load .env (if present) into process.env, not overriding existing vars. */
17
+ export function loadEnv(dir) {
18
+ for (const [key, value] of Object.entries(parseEnvFile(path.join(dir, ".env")))) {
19
+ if (process.env[key] === undefined)
20
+ process.env[key] = value;
21
+ }
22
+ }
23
+ /** Load decanter.config.json from cwd (or nearest ancestor) and resolve paths. */
24
+ export function loadConfig(cwd = process.cwd(), { requireCredentials = true } = {}) {
25
+ let dir = path.resolve(cwd);
26
+ for (;;) {
27
+ const file = path.join(dir, "decanter.config.json");
28
+ if (existsSync(file)) {
29
+ let parsed;
30
+ try {
31
+ parsed = JSON.parse(readFileSync(file, "utf8"));
32
+ }
33
+ catch (err) {
34
+ throw new Error(`${file}: invalid JSON (${err.message})`);
35
+ }
36
+ const cfg = parsed;
37
+ loadEnv(dir);
38
+ const host = (process.env.N8N_HOST ?? "").replace(/\/+$/, "");
39
+ const apiKey = process.env.N8N_API_KEY ?? "";
40
+ if (requireCredentials && (!host || !apiKey)) {
41
+ throw new Error("N8N_HOST and N8N_API_KEY must be set (via .env next to decanter.config.json or the environment)");
42
+ }
43
+ return {
44
+ configDir: dir,
45
+ root: path.resolve(dir, cfg.root ?? "./workflows"),
46
+ workflows: cfg.workflows ?? [],
47
+ commitOnPush: cfg.commitOnPush !== false,
48
+ commitOnPull: cfg.commitOnPull !== false,
49
+ browserReload: cfg.browserReload === "proxy" ? "proxy" : "off",
50
+ proxyPort: typeof cfg.proxyPort === "number" ? cfg.proxyPort : 5679,
51
+ requestTimeoutMs: typeof cfg.requestTimeoutMs === "number" && cfg.requestTimeoutMs > 0 ? cfg.requestTimeoutMs : 30_000,
52
+ host,
53
+ apiKey,
54
+ };
55
+ }
56
+ const parent = path.dirname(dir);
57
+ if (parent === dir)
58
+ throw new Error("decanter.config.json not found (searched from " + cwd + " upward)");
59
+ dir = parent;
60
+ }
61
+ }
@@ -0,0 +1,74 @@
1
+ // Minimal unified line diff for `status --diff` (plans/3 B). Zero deps by
2
+ // design (Plan 11's rule); classic LCS backtracking is plenty at Code-node
3
+ // scale, with a size cutoff instead of a fancier algorithm.
4
+ function toLines(s) {
5
+ if (s === "")
6
+ return [];
7
+ const lines = s.split("\n");
8
+ if (lines[lines.length - 1] === "")
9
+ lines.pop(); // trailing newline is not a line
10
+ return lines;
11
+ }
12
+ /**
13
+ * Unified-style diff of `a` (rendered as `-`) vs `b` (`+`), with `@@` hunk
14
+ * headers and `context` unchanged lines around each change. Returns [] when
15
+ * the inputs are line-identical. The hunk numbers are informational — this
16
+ * diff is for reading, not for `patch`.
17
+ */
18
+ export function unifiedDiff(a, b, context = 2) {
19
+ const al = toLines(a);
20
+ const bl = toLines(b);
21
+ const n = al.length;
22
+ const m = bl.length;
23
+ if (n * m > 4_000_000)
24
+ return ["(diff too large to render — contents differ)"];
25
+ // dp[i][j] = LCS length of al[i..] vs bl[j..]
26
+ const dp = Array.from({ length: n + 1 }, () => new Uint32Array(m + 1));
27
+ for (let i = n - 1; i >= 0; i--) {
28
+ for (let j = m - 1; j >= 0; j--) {
29
+ dp[i][j] = al[i] === bl[j] ? dp[i + 1][j + 1] + 1 : Math.max(dp[i + 1][j], dp[i][j + 1]);
30
+ }
31
+ }
32
+ const ops = [];
33
+ let i = 0;
34
+ let j = 0;
35
+ while (i < n && j < m) {
36
+ if (al[i] === bl[j])
37
+ ops.push({ tag: " ", line: al[i], aLine: ++i, bLine: ++j });
38
+ else if (dp[i + 1][j] >= dp[i][j + 1])
39
+ ops.push({ tag: "-", line: al[i], aLine: ++i, bLine: j + 1 });
40
+ else
41
+ ops.push({ tag: "+", line: bl[j], aLine: i + 1, bLine: ++j });
42
+ }
43
+ while (i < n)
44
+ ops.push({ tag: "-", line: al[i], aLine: ++i, bLine: j + 1 });
45
+ while (j < m)
46
+ ops.push({ tag: "+", line: bl[j], aLine: i + 1, bLine: ++j });
47
+ const changed = ops.flatMap((op, idx) => (op.tag === " " ? [] : [idx]));
48
+ if (changed.length === 0)
49
+ return [];
50
+ // group changes whose context windows touch into hunks
51
+ const hunks = [];
52
+ let start = Math.max(0, changed[0] - context);
53
+ let end = Math.min(ops.length - 1, changed[0] + context);
54
+ for (const c of changed.slice(1)) {
55
+ if (c - context <= end + 1)
56
+ end = Math.min(ops.length - 1, c + context);
57
+ else {
58
+ hunks.push([start, end]);
59
+ start = Math.max(0, c - context);
60
+ end = Math.min(ops.length - 1, c + context);
61
+ }
62
+ }
63
+ hunks.push([start, end]);
64
+ const out = [];
65
+ for (const [from, to] of hunks) {
66
+ const slice = ops.slice(from, to + 1);
67
+ const aCount = slice.filter((o) => o.tag !== "+").length;
68
+ const bCount = slice.filter((o) => o.tag !== "-").length;
69
+ out.push(`@@ -${slice[0].aLine},${aCount} +${slice[0].bLine},${bCount} @@`);
70
+ for (const o of slice)
71
+ out.push(o.tag + o.line);
72
+ }
73
+ return out;
74
+ }
@@ -0,0 +1,76 @@
1
+ import { existsSync, mkdirSync, rmSync, writeFileSync } from "node:fs";
2
+ import path from "node:path";
3
+ import { findWorkflowDir, listWorkflowDirs } from "./state.mjs";
4
+ export const EXECUTIONS_DIR = "executions";
5
+ /**
6
+ * Materialize execution JSON under `<workflow>/executions/`. The dir is made
7
+ * self-ignoring via an `executions/.gitignore` containing `*` — execution
8
+ * data can hold credentials/PII and sits inside the commit-on-pull/push
9
+ * pathspec, so it must never reach git regardless of the sync dir's root
10
+ * .gitignore (which init scaffolds, but pre-existing dirs won't have).
11
+ */
12
+ function writeExecutionFiles(dir, executions, log) {
13
+ const outDir = path.join(dir, EXECUTIONS_DIR);
14
+ mkdirSync(outDir, { recursive: true });
15
+ writeFileSync(path.join(outDir, ".gitignore"), "*\n");
16
+ for (const exec of executions) {
17
+ const file = path.join(outDir, `${exec.id}.json`);
18
+ writeFileSync(file, JSON.stringify(exec, null, 2) + "\n");
19
+ log.info(`wrote ${path.relative(process.cwd(), file)}${exec.status !== undefined ? ` (${exec.status})` : ""}`);
20
+ }
21
+ }
22
+ /** Fetch recent executions of one workflow into its `executions/` dir. */
23
+ export async function fetchExecutions(api, root, workflowId, { status, limit }, log) {
24
+ const dir = findWorkflowDir(root, workflowId, log);
25
+ if (!dir)
26
+ throw new Error(`workflow ${workflowId} not found under ${root} — pull it first`);
27
+ const executions = await api.listExecutions({ workflowId, status, limit });
28
+ if (executions.length === 0) {
29
+ log.info(`no executions on the server for ${workflowId}${status !== undefined ? ` with status "${status}"` : ""}`);
30
+ return;
31
+ }
32
+ writeExecutionFiles(dir, executions, log);
33
+ log.ok(`${executions.length} execution${executions.length === 1 ? "" : "s"} -> ${path.relative(process.cwd(), path.join(dir, EXECUTIONS_DIR))} (gitignored — temp data, "executions clean" removes it)`);
34
+ }
35
+ /**
36
+ * Fetch one execution by its (numeric) id. The execution's own `workflowId`
37
+ * decides the target folder — no workflow ref needed on the CLI.
38
+ */
39
+ export async function fetchExecutionById(api, root, executionId, log) {
40
+ const exec = await api.getExecution(executionId);
41
+ const workflowId = typeof exec.workflowId === "string" ? exec.workflowId : undefined;
42
+ if (workflowId === undefined)
43
+ throw new Error(`execution ${executionId}: response carries no workflowId — cannot place it`);
44
+ const dir = findWorkflowDir(root, workflowId, log);
45
+ if (!dir)
46
+ throw new Error(`execution ${executionId} belongs to workflow ${workflowId}, which is not pulled under ${root} — pull it first`);
47
+ writeExecutionFiles(dir, [exec], log);
48
+ log.ok(`execution ${executionId} -> ${path.relative(process.cwd(), path.join(dir, EXECUTIONS_DIR))}`);
49
+ }
50
+ /**
51
+ * Delete `executions/` dirs — for the given workflow ids, or every pulled
52
+ * workflow when none are given. Offline; missing dirs are fine.
53
+ */
54
+ export function cleanExecutions(root, workflowIds, log) {
55
+ const dirs = workflowIds.length > 0
56
+ ? workflowIds.map((id) => {
57
+ const dir = findWorkflowDir(root, id, log);
58
+ if (!dir)
59
+ throw new Error(`workflow ${id} not found under ${root}`);
60
+ return dir;
61
+ })
62
+ : listWorkflowDirs(root);
63
+ let removed = 0;
64
+ for (const dir of dirs) {
65
+ const outDir = path.join(dir, EXECUTIONS_DIR);
66
+ if (!existsSync(outDir))
67
+ continue;
68
+ rmSync(outDir, { recursive: true, force: true });
69
+ log.info(`removed ${path.relative(process.cwd(), outDir)}`);
70
+ removed++;
71
+ }
72
+ if (removed === 0)
73
+ log.info("no executions/ dirs to clean");
74
+ else
75
+ log.ok(`cleaned ${removed} executions dir${removed === 1 ? "" : "s"}`);
76
+ }
@@ -0,0 +1,44 @@
1
+ import { execFile as execFileCb } from "node:child_process";
2
+ import { promisify } from "node:util";
3
+ const execFile = promisify(execFileCb);
4
+ /**
5
+ * Best-effort git commit of one workflow folder after a successful sync.
6
+ * Never fails the sync: no git / not a repo / mid-merge all degrade to a
7
+ * warning ("failed"); a tree with nothing to commit reports "clean". The
8
+ * commit is pathspec-scoped to the folder, so staged-but-unrelated changes
9
+ * elsewhere in the repo stay untouched and unstaged. `extraPaths` (relative
10
+ * to dir) covers a renamed-away old folder, whose deletions live outside the
11
+ * new folder's pathspec.
12
+ */
13
+ export async function commitWorkflowDir(dir, message, log, extraPaths = []) {
14
+ try {
15
+ await execFile("git", ["-C", dir, "rev-parse", "--is-inside-work-tree"]);
16
+ }
17
+ catch {
18
+ log.warn('not inside a git repository — skipping commit ("commitOnPush"/"commitOnPull": false silences this)');
19
+ return "failed";
20
+ }
21
+ try {
22
+ const spec = ["."];
23
+ for (const p of extraPaths) {
24
+ // only include extra paths that actually carry changes — an unmatched
25
+ // pathspec would make add/commit fail
26
+ const { stdout } = await execFile("git", ["-C", dir, "status", "--porcelain", "--", p]);
27
+ if (stdout.trim() !== "")
28
+ spec.push(p);
29
+ }
30
+ await execFile("git", ["-C", dir, "add", "-A", "--", ...spec]);
31
+ const { stdout } = await execFile("git", ["-C", dir, "status", "--porcelain", "--", ...spec]);
32
+ if (stdout.trim() === "")
33
+ return "clean"; // nothing changed under this folder
34
+ await execFile("git", ["-C", dir, "commit", "-m", message, "--", ...spec]);
35
+ log.info(`committed: ${message}`);
36
+ return "committed";
37
+ }
38
+ catch (err) {
39
+ const e = err;
40
+ const detail = (e.stderr || e.message || "").toString().trim().split("\n")[0];
41
+ log.warn(`git commit failed (${detail}) — push succeeded, commit skipped`);
42
+ return "failed";
43
+ }
44
+ }
@@ -0,0 +1,152 @@
1
+ import { copyFileSync, existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync } from "node:fs";
2
+ import path from "node:path";
3
+ import { fileURLToPath } from "node:url";
4
+ import { parseEnvFile } from "./config.mjs";
5
+ import { createPrompt } from "./prompt.mjs";
6
+ import { style } from "./style.mjs";
7
+ /**
8
+ * Nearest ancestor of `startDir` holding a package.json — the package root.
9
+ * Works from the checkout (lib/ → repo root) *and* from the published build
10
+ * (dist/lib/ → package root): dist/ ships no package.json, so a plain
11
+ * `../template` URL would resolve to the nonexistent dist/template in the
12
+ * npm tarball (release blocker found 2026-07-18). Exported for tests.
13
+ */
14
+ export function packageRootFrom(startDir) {
15
+ let dir = path.resolve(startDir);
16
+ for (;;) {
17
+ if (existsSync(path.join(dir, "package.json")))
18
+ return dir;
19
+ const parent = path.dirname(dir);
20
+ if (parent === dir)
21
+ return path.resolve(startDir); // fs root — let the template lookup fail loudly
22
+ dir = parent;
23
+ }
24
+ }
25
+ const PACKAGE_ROOT = packageRootFrom(path.dirname(fileURLToPath(import.meta.url)));
26
+ const TEMPLATE_DIR = path.join(PACKAGE_ROOT, "template");
27
+ /** Own package version (banner); tolerant of an unreadable package.json. */
28
+ function cliVersion() {
29
+ try {
30
+ return JSON.parse(readFileSync(path.join(PACKAGE_ROOT, "package.json"), "utf8")).version ?? "0.0.0";
31
+ }
32
+ catch {
33
+ return "0.0.0";
34
+ }
35
+ }
36
+ // Wordmark in the 2×2 quadrant-block minifont (Block Elements). The top row's
37
+ // leading offset is load-bearing (ascenders of 8/d/t) — keep it verbatim.
38
+ // Columns 0-5 are the "n8n" part (brand red on a TTY), the rest "decanter".
39
+ const LOGO_ROWS = [
40
+ " ▄▖ ▌ ▗",
41
+ "▛▌▙▌▛▌ ▛▌█▌▛▘▀▌▛▌▜▘█▌▛▘",
42
+ "▌▌▙▌▌▌ ▙▌▙▖▙▖█▌▌▌▐▖▙▖▌",
43
+ ];
44
+ /** TTY: logo + tagline + version. Piped: one plain, stable version line. */
45
+ export function printBanner(log) {
46
+ const version = cliVersion();
47
+ if (!process.stdout.isTTY) {
48
+ log.info(`n8n-decanter v${version}`);
49
+ return;
50
+ }
51
+ for (const row of LOGO_ROWS)
52
+ console.log(style.red(row.slice(0, 6)) + style.bold(row.slice(6)));
53
+ console.log(style.dim(`n8n workflows ⇄ agentic code · v${version}`));
54
+ }
55
+ function copyTemplate(srcDir, destDir, { force = false, protect = new Set() } = {}) {
56
+ const overwritten = [];
57
+ const walk = (src, dest, rel) => {
58
+ mkdirSync(dest, { recursive: true });
59
+ for (const entry of readdirSync(src, { withFileTypes: true })) {
60
+ const srcPath = path.join(src, entry.name);
61
+ if (entry.isDirectory()) {
62
+ walk(srcPath, path.join(dest, entry.name), path.join(rel, entry.name));
63
+ continue;
64
+ }
65
+ const name = entry.name.endsWith(".example") && entry.name !== ".example"
66
+ ? entry.name.slice(0, -".example".length)
67
+ : entry.name;
68
+ const destPath = path.join(dest, name);
69
+ const exists = existsSync(destPath);
70
+ if (exists && (!force || protect.has(destPath)))
71
+ continue;
72
+ copyFileSync(srcPath, destPath);
73
+ if (exists)
74
+ overwritten.push(path.join(rel, name));
75
+ }
76
+ };
77
+ walk(srcDir, destDir, "");
78
+ return overwritten;
79
+ }
80
+ /** Interactive bootstrap: prompt for credentials, write .env, copy template/. */
81
+ export async function init(targetDir, { force = false } = {}, log) {
82
+ printBanner(log);
83
+ const dir = path.resolve(targetDir ?? ".");
84
+ mkdirSync(dir, { recursive: true });
85
+ const envFile = path.join(dir, ".env");
86
+ const existing = parseEnvFile(envFile);
87
+ let host = existing.N8N_HOST ?? "";
88
+ let apiKey = existing.N8N_API_KEY ?? "";
89
+ if (host && apiKey) {
90
+ // complete .env → nothing to ask; edit or delete .env to change credentials
91
+ log.info(`using existing .env (${host})`);
92
+ }
93
+ else {
94
+ const rl = createPrompt();
95
+ try {
96
+ host = (await rl.question(`n8n host${host ? ` [${host}]` : ""}: `)).trim() || host;
97
+ apiKey = (await rl.question(`n8n API key${apiKey ? " [enter = keep existing]" : ""}: `)).trim() || apiKey;
98
+ }
99
+ finally {
100
+ rl.close();
101
+ }
102
+ if (!host || !apiKey)
103
+ throw new Error("host and API key are required");
104
+ if (!/^https?:\/\//.test(host))
105
+ host = "https://" + host;
106
+ host = host.replace(/\/+$/, "");
107
+ writeFileSync(envFile, `N8N_HOST=${host}\nN8N_API_KEY=${apiKey}\n`);
108
+ log.info(`wrote ${envFile}`);
109
+ }
110
+ // Copy the template completely (whatever it contains, recursively).
111
+ // Default: never overwrite files that already exist in the target.
112
+ // --force re-copies template files over existing ones (.env excepted —
113
+ // it was just written with real credentials).
114
+ // Files named `X.example` are inert in this repo (so agent tooling ignores
115
+ // them while working on the CLI itself) and materialize as `X` in the target.
116
+ const overwritten = copyTemplate(TEMPLATE_DIR, dir, { force, protect: new Set([envFile]) });
117
+ log.info(`copied template -> ${dir}`);
118
+ for (const rel of overwritten)
119
+ log.warn(`--force: overwrote ${rel} with the template version`);
120
+ const configFile = path.join(dir, "decanter.config.json");
121
+ if (!existsSync(configFile)) {
122
+ writeFileSync(configFile, JSON.stringify({ root: "./workflows", workflows: [] }, null, 2) + "\n");
123
+ log.info("wrote decanter.config.json — add your workflow ids to it");
124
+ }
125
+ const gitignoreFile = path.join(dir, ".gitignore");
126
+ if (!existsSync(gitignoreFile)) {
127
+ // executions/ holds fetched run data (may contain credentials/PII);
128
+ // belt-and-braces with the self-ignoring executions/.gitignore the
129
+ // `executions` verb writes into pre-existing sync dirs
130
+ writeFileSync(gitignoreFile, "node_modules/\n.env\nworkflows/*/executions/\n");
131
+ log.info("wrote .gitignore");
132
+ }
133
+ else if (!readFileSync(gitignoreFile, "utf8").split("\n").some((l) => l.trim() === ".env")) {
134
+ log.warn(".gitignore exists but does not ignore .env — add it, the file holds your API key");
135
+ }
136
+ try {
137
+ const res = await fetch(`${host}/api/v1/workflows?limit=1`, {
138
+ headers: { "X-N8N-API-KEY": apiKey, accept: "application/json" },
139
+ // best-effort probe: fail fast on a black-holed host rather than hanging init
140
+ signal: AbortSignal.timeout(10_000),
141
+ });
142
+ if (res.ok)
143
+ log.info(`credentials verified against ${host}`);
144
+ else
145
+ log.warn(`credential check failed (${res.status} ${res.statusText}) — .env written anyway`);
146
+ }
147
+ catch (err) {
148
+ const e = err;
149
+ const reason = e.name === "TimeoutError" ? "timed out after 10s" : e.cause?.code ?? e.message;
150
+ log.warn(`could not reach ${host} (${reason}) — .env written anyway`);
151
+ }
152
+ }
@@ -0,0 +1,226 @@
1
+ // Interactive workflow picker (Plan 19): bare `n8n-decanter` on a TTY in an
2
+ // inited project. Two stages — type-to-filter workflow list (pulled green,
3
+ // unpulled remote yellow), then a verb menu; unpulled entries pull directly.
4
+ // The pure state machine (filter, reducer, window) is exported for unit
5
+ // tests; only runPicker touches the terminal. Exists only behind a TTY gate,
6
+ // so piped output never sees any of this.
7
+ import { emitKeypressEvents } from "node:readline";
8
+ import { style } from "./style.mjs";
9
+ /** Verb menu for a pulled workflow, in display order. */
10
+ export const PICKER_VERBS = ["status", "pull", "push", "watch", "check", "executions"];
11
+ export function initialState(entries, loadingRemote, opts = {}) {
12
+ const base = { stage: "workflow", entries, query: "", cursor: 0, verbCursor: 0, loadingRemote, notice: opts.notice };
13
+ const entry = opts.resume ? entries.find((e) => e.id === opts.resume.id) : undefined;
14
+ if (!entry)
15
+ return base;
16
+ // resumed workflow still unpulled (its pull failed): back on the list, cursor on it
17
+ if (!entry.pulled)
18
+ return { ...base, cursor: entries.indexOf(entry) };
19
+ const verbIndex = PICKER_VERBS.indexOf(opts.resume.verb);
20
+ return { ...base, stage: "verb", selected: entry, verbCursor: Math.max(0, verbIndex) };
21
+ }
22
+ /** Case-insensitive substring match on name and id; empty query matches all. */
23
+ export function filterEntries(entries, query) {
24
+ const q = query.toLowerCase();
25
+ if (q === "")
26
+ return entries;
27
+ return entries.filter((e) => e.name.toLowerCase().includes(q) || e.id.toLowerCase().includes(q));
28
+ }
29
+ /** Append remote workflows not already present locally, marked unpulled. */
30
+ export function mergeRemote(entries, remote) {
31
+ const known = new Set(entries.map((e) => e.id));
32
+ return [...entries, ...remote.filter((w) => !known.has(w.id)).map((w) => ({ id: w.id, name: w.name, pulled: false }))];
33
+ }
34
+ /** Scroll window over a list: keeps the cursor visible, clamps to bounds. */
35
+ export function visibleWindow(length, cursor, height) {
36
+ if (length <= height)
37
+ return { start: 0, end: length };
38
+ const start = Math.min(Math.max(0, cursor - Math.floor(height / 2)), length - height);
39
+ return { start, end: start + height };
40
+ }
41
+ const isPrintable = (key) => key.sequence !== undefined && key.sequence.length === 1 && key.sequence >= " " && key.sequence !== "\x7f"
42
+ && key.ctrl !== true && key.meta !== true;
43
+ export function reduceKey(state, key) {
44
+ if (key.ctrl && key.name === "c")
45
+ return { done: true, result: "interrupted" };
46
+ if (state.stage === "workflow")
47
+ return reduceWorkflowKey(state, key);
48
+ return reduceVerbKey(state, key);
49
+ }
50
+ function reduceWorkflowKey(state, key) {
51
+ const filtered = filterEntries(state.entries, state.query);
52
+ switch (key.name) {
53
+ case "escape":
54
+ return { done: true, result: "quit" };
55
+ case "up":
56
+ return { done: false, state: { ...state, cursor: Math.max(0, state.cursor - 1) } };
57
+ case "down":
58
+ return { done: false, state: { ...state, cursor: Math.min(Math.max(0, filtered.length - 1), state.cursor + 1) } };
59
+ case "return":
60
+ case "enter": {
61
+ const entry = filtered[state.cursor];
62
+ if (!entry)
63
+ return { done: false, state };
64
+ if (!entry.pulled)
65
+ return { done: true, result: { verb: "pull", id: entry.id, name: entry.name } };
66
+ return { done: false, state: { ...state, stage: "verb", selected: entry, verbCursor: 0 } };
67
+ }
68
+ case "backspace":
69
+ return { done: false, state: { ...state, query: state.query.slice(0, -1), cursor: 0 } };
70
+ }
71
+ if (isPrintable(key))
72
+ return { done: false, state: { ...state, query: state.query + key.sequence, cursor: 0 } };
73
+ return { done: false, state };
74
+ }
75
+ function reduceVerbKey(state, key) {
76
+ switch (key.name) {
77
+ case "escape":
78
+ return { done: false, state: { ...state, stage: "workflow", selected: undefined } };
79
+ case "up":
80
+ return { done: false, state: { ...state, verbCursor: Math.max(0, state.verbCursor - 1) } };
81
+ case "down":
82
+ return { done: false, state: { ...state, verbCursor: Math.min(PICKER_VERBS.length - 1, state.verbCursor + 1) } };
83
+ case "return":
84
+ case "enter":
85
+ return { done: true, result: { verb: PICKER_VERBS[state.verbCursor], id: state.selected.id, name: state.selected.name } };
86
+ }
87
+ // a letter jumps to the next verb starting with it, cycling through
88
+ // matches ("p" alternates pull/push) — no collision-free hotkeys needed
89
+ if (isPrintable(key)) {
90
+ const c = key.sequence.toLowerCase();
91
+ for (let step = 1; step <= PICKER_VERBS.length; step++) {
92
+ const i = (state.verbCursor + step) % PICKER_VERBS.length;
93
+ if (PICKER_VERBS[i].startsWith(c))
94
+ return { done: false, state: { ...state, verbCursor: i } };
95
+ }
96
+ }
97
+ return { done: false, state };
98
+ }
99
+ // ---------- terminal IO (TTY only, untested by CI — keep this part thin) ----------
100
+ const LIST_HEIGHT = 10;
101
+ // Placeholder rows shown where remote workflows will appear while the list
102
+ // loads: light-gray blocks shaped like "name id", varied widths on purpose.
103
+ const SKELETON_WIDTHS = [[14, 8], [9, 8], [17, 8]];
104
+ const truncate = (text, max) => (text.length > max ? text.slice(0, max - 1) + "…" : text);
105
+ function renderLines(state) {
106
+ const d = style.dim;
107
+ const lines = [];
108
+ if (state.stage === "verb") {
109
+ const wf = state.selected;
110
+ lines.push(`${style.bold(truncate(wf.name, 48))} ${d(wf.id)}`);
111
+ PICKER_VERBS.forEach((verb, i) => {
112
+ lines.push(i === state.verbCursor ? `${style.green("❯")} ${style.bold(verb)}` : ` ${verb}`);
113
+ });
114
+ lines.push(d("↑↓ move · enter run · esc back"));
115
+ return lines;
116
+ }
117
+ const filtered = filterEntries(state.entries, state.query);
118
+ lines.push(`${style.bold("?")} ${state.query === "" ? d("type to filter") : state.query}`);
119
+ const { start, end } = visibleWindow(filtered.length, state.cursor, LIST_HEIGHT);
120
+ for (let i = start; i < end; i++) {
121
+ const e = filtered[i];
122
+ const pointer = i === state.cursor ? style.green("❯") : " ";
123
+ const name = e.pulled ? style.green(truncate(e.name, 48)) : style.yellow(truncate(e.name, 48));
124
+ // "(not pulled)" in words, not only color — NO_COLOR/monochrome safety
125
+ lines.push(`${pointer} ${name} ${d(e.id)}${e.pulled ? "" : ` ${d("(not pulled)")}`}`);
126
+ }
127
+ if (filtered.length === 0 && !state.loadingRemote)
128
+ lines.push(d(state.entries.length === 0 ? " nothing pulled yet" : " no match"));
129
+ if (end < filtered.length)
130
+ lines.push(d(` … ${filtered.length - end} more`));
131
+ if (state.loadingRemote) {
132
+ for (const [nameWidth, idWidth] of SKELETON_WIDTHS) {
133
+ lines.push(` ${d("░".repeat(nameWidth))} ${d("░".repeat(idWidth))}`);
134
+ }
135
+ }
136
+ if (state.notice)
137
+ lines.push(d(state.notice));
138
+ const enterHint = filtered[state.cursor]?.pulled === false ? "enter pull" : "enter select";
139
+ lines.push(d(`↑↓ move · ${enterHint} · esc quit`));
140
+ return lines;
141
+ }
142
+ /**
143
+ * Run the picker on the current TTY. `remote` (when credentials exist) is
144
+ * already in flight; its workflows are appended as they arrive. `resume`
145
+ * re-opens a workflow's verb menu (the picker loop passes the last run).
146
+ * Resolves with the chosen verb+id+name, or "quit" (Esc) / "interrupted"
147
+ * (Ctrl-C).
148
+ */
149
+ export async function runPicker(local, remote, opts = {}) {
150
+ let state = initialState(local, remote !== undefined, opts);
151
+ let prevLines = 0;
152
+ const repaint = () => {
153
+ const lines = renderLines(state);
154
+ const up = prevLines > 0 ? `\x1b[${prevLines}A` : "";
155
+ process.stdout.write(`${up}\r\x1b[J${lines.join("\n")}\n`);
156
+ prevLines = lines.length;
157
+ };
158
+ const erase = () => {
159
+ if (prevLines > 0)
160
+ process.stdout.write(`\x1b[${prevLines}A\r\x1b[J`);
161
+ prevLines = 0;
162
+ };
163
+ const stdin = process.stdin;
164
+ emitKeypressEvents(stdin);
165
+ const wasRaw = stdin.isRaw === true;
166
+ const restore = () => {
167
+ if (!wasRaw)
168
+ stdin.setRawMode(false);
169
+ process.stdout.write("\x1b[?25h");
170
+ };
171
+ stdin.setRawMode(true);
172
+ stdin.resume();
173
+ process.stdout.write("\x1b[?25l");
174
+ process.once("exit", restore); // belt and braces: never leave the terminal raw
175
+ let finished = false;
176
+ let onKeypress;
177
+ let onEnd;
178
+ try {
179
+ return await new Promise((resolve) => {
180
+ // stdin EOF can't happen on a real keyboard, but a closed input (pty
181
+ // teardown, misuse) must quit instead of wedging the process forever
182
+ onEnd = () => {
183
+ finished = true;
184
+ resolve("quit");
185
+ };
186
+ stdin.once("end", onEnd);
187
+ onKeypress = (_str, key) => {
188
+ const step = reduceKey(state, key ?? {});
189
+ if (step.done) {
190
+ finished = true;
191
+ resolve(step.result);
192
+ return;
193
+ }
194
+ state = step.state;
195
+ repaint();
196
+ };
197
+ // handlers attach synchronously so a fast rejection is never unhandled
198
+ remote
199
+ ?.then((workflows) => {
200
+ if (finished)
201
+ return;
202
+ state = { ...state, entries: mergeRemote(state.entries, workflows), loadingRemote: false };
203
+ repaint();
204
+ })
205
+ .catch((err) => {
206
+ if (finished)
207
+ return;
208
+ state = { ...state, loadingRemote: false, notice: `remote list unavailable (${err.message.split("\n")[0]})` };
209
+ repaint();
210
+ });
211
+ stdin.on("keypress", onKeypress);
212
+ repaint();
213
+ });
214
+ }
215
+ finally {
216
+ finished = true;
217
+ if (onKeypress)
218
+ stdin.removeListener("keypress", onKeypress);
219
+ if (onEnd)
220
+ stdin.removeListener("end", onEnd);
221
+ erase();
222
+ restore();
223
+ process.removeListener("exit", restore);
224
+ stdin.pause();
225
+ }
226
+ }
@@ -0,0 +1,34 @@
1
+ import { createInterface } from "node:readline/promises";
2
+ /**
3
+ * Prompt helper that also works with piped stdin: plain readline/promises
4
+ * drops lines arriving before question() is called and hangs forever on EOF.
5
+ */
6
+ export function createPrompt() {
7
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
8
+ const buffered = [];
9
+ const waiters = [];
10
+ let closed = false;
11
+ rl.on("line", (line) => {
12
+ const waiter = waiters.shift();
13
+ if (waiter)
14
+ waiter(line);
15
+ else
16
+ buffered.push(line);
17
+ });
18
+ rl.on("close", () => {
19
+ closed = true;
20
+ for (const waiter of waiters.splice(0))
21
+ waiter("");
22
+ });
23
+ return {
24
+ async question(prompt) {
25
+ process.stdout.write(prompt);
26
+ if (buffered.length > 0)
27
+ return buffered.shift();
28
+ if (closed)
29
+ return "";
30
+ return new Promise((resolve) => waiters.push(resolve));
31
+ },
32
+ close: () => rl.close(),
33
+ };
34
+ }