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,208 @@
1
+ import { existsSync, readFileSync } from "node:fs";
2
+ import path from "node:path";
3
+ import { compileTs } from "./compile.mjs";
4
+ import { nodeFileContextDir } from "./state.mjs";
5
+ import { isJsCodeNode, placeholderFile, splitMarker } from "./util.mjs";
6
+ const AsyncFunction = Object.getPrototypeOf(async function () { }).constructor;
7
+ /**
8
+ * Locate the node whose //@file: placeholder points at the given file (for
9
+ * its run mode) plus the workflow's staticData. workflow.json sits next to
10
+ * the file, or — code/ layout — one level up. `node` is null when there is
11
+ * no workflow.json or no matching node (a bare file can still be run).
12
+ */
13
+ function findNodeContext(resolved) {
14
+ const dir = nodeFileContextDir(resolved, "workflow.json");
15
+ if (dir === null)
16
+ return { node: null, staticData: undefined };
17
+ const wfFile = path.join(dir, "workflow.json");
18
+ let wf;
19
+ try {
20
+ wf = JSON.parse(readFileSync(wfFile, "utf8"));
21
+ }
22
+ catch {
23
+ return { node: null, staticData: undefined };
24
+ }
25
+ const ref = path.relative(dir, resolved).split(path.sep).join("/");
26
+ const node = (wf.nodes ?? []).find((n) => isJsCodeNode(n) && placeholderFile(n) === ref) ?? null;
27
+ return { node, staticData: wf.staticData };
28
+ }
29
+ /**
30
+ * The two staticData slices a node can see. Workflow JSON stores them as
31
+ * `{ global: …, "node:<node name>": … }` (n8n's own key scheme); a fixture's
32
+ * `staticData.global` / `staticData.node` replaces the matching slice whole.
33
+ * The returned objects are mutable during the run — like in n8n — but `run`
34
+ * never persists them (offline by design).
35
+ */
36
+ function staticDataSlices(raw, nodeName, fixture) {
37
+ let parsed = raw;
38
+ if (typeof parsed === "string") {
39
+ // the API may deliver staticData in its DB-serialized string form
40
+ try {
41
+ parsed = JSON.parse(parsed);
42
+ }
43
+ catch {
44
+ parsed = undefined;
45
+ }
46
+ }
47
+ const seed = (parsed && typeof parsed === "object" ? parsed : {});
48
+ const slice = (v) => (v && typeof v === "object" ? v : {});
49
+ return {
50
+ global: slice(fixture.staticData?.global ?? seed.global),
51
+ node: slice(fixture.staticData?.node ?? (nodeName === undefined ? undefined : seed[`node:${nodeName}`])),
52
+ };
53
+ }
54
+ /** Load and shape a fixture file; every field is optional. */
55
+ function loadFixture(fixturePath, log) {
56
+ if (!fixturePath)
57
+ return {};
58
+ const resolved = path.resolve(fixturePath);
59
+ if (!existsSync(resolved))
60
+ throw new Error(`fixture not found: ${fixturePath}`);
61
+ let raw;
62
+ try {
63
+ raw = JSON.parse(readFileSync(resolved, "utf8"));
64
+ }
65
+ catch (err) {
66
+ throw new Error(`fixture is not valid JSON (${err.message})`);
67
+ }
68
+ return raw;
69
+ }
70
+ /** Normalize a value into an n8n item ({ json, binary? }). */
71
+ function asItem(value) {
72
+ if (value && typeof value === "object" && "json" in value)
73
+ return value;
74
+ return { json: value ?? {} };
75
+ }
76
+ function makeNodeRef(items) {
77
+ const list = items.map(asItem);
78
+ return {
79
+ all: () => list,
80
+ first: () => list[0],
81
+ last: () => list[list.length - 1],
82
+ item: list[0],
83
+ itemMatching: (i) => list[i],
84
+ params: {},
85
+ context: {},
86
+ isExecuted: true,
87
+ };
88
+ }
89
+ /**
90
+ * Build the globals an n8n Code node sees. `perItem` overrides the each-item
91
+ * fields ($json, $binary, $itemIndex, $input.item) for "Run Once for Each Item".
92
+ */
93
+ async function buildGlobals(fixture, log, context = {}) {
94
+ const staticData = staticDataSlices(context.staticData, context.nodeName, fixture);
95
+ const input = (fixture.input ?? [{ json: {} }]).map(asItem);
96
+ const nodes = {};
97
+ for (const [name, items] of Object.entries(fixture.nodes ?? {})) {
98
+ nodes[name] = makeNodeRef(items);
99
+ }
100
+ const $input = {
101
+ all: () => input,
102
+ first: () => input[0],
103
+ last: () => input[input.length - 1],
104
+ item: input[0],
105
+ params: fixture.params ?? {},
106
+ };
107
+ const $ = (name) => {
108
+ if (!nodes[name]) {
109
+ throw new Error(`node "${name}" has no fixture data — add it under "nodes" in the fixture JSON`);
110
+ }
111
+ return nodes[name];
112
+ };
113
+ // Luxon is what n8n exposes for DateTime/Duration/Interval. Optional: run
114
+ // still works for pure-logic nodes when it isn't installed.
115
+ let DateTime;
116
+ let Duration;
117
+ let Interval;
118
+ try {
119
+ ({ DateTime, Duration, Interval } = await import("luxon"));
120
+ }
121
+ catch {
122
+ log.warn("luxon not installed — DateTime/$now/$today are unavailable in this run");
123
+ }
124
+ return {
125
+ $input,
126
+ $,
127
+ $json: input[0]?.json ?? {},
128
+ $binary: input[0]?.binary ?? {},
129
+ // n8n's $env is scoped, not the whole host environment. A fixture's env
130
+ // always wins; otherwise $env is empty unless --allow-env opts into
131
+ // inheriting process.env (which can carry N8N_API_KEY and other secrets).
132
+ $env: fixture.env ?? (context.allowEnv ? { ...process.env } : {}),
133
+ $itemIndex: 0,
134
+ $runIndex: 0,
135
+ $now: DateTime ? DateTime.now() : undefined,
136
+ $today: DateTime ? DateTime.now().startOf("day") : undefined,
137
+ DateTime,
138
+ Duration,
139
+ Interval,
140
+ $workflow: fixture.workflow ?? { id: "local", name: "local", active: false },
141
+ $getWorkflowStaticData: (type) => {
142
+ if (type !== "global" && type !== "node") {
143
+ throw new Error(`$getWorkflowStaticData: type must be "global" or "node", got ${JSON.stringify(type)}`);
144
+ }
145
+ return staticData[type];
146
+ },
147
+ $execution: fixture.execution ?? { id: "local", mode: "test" },
148
+ $prevNode: fixture.prevNode ?? { name: "", outputIndex: 0, runIndex: 0 },
149
+ $jmespath: (data, expr) => {
150
+ throw new Error("$jmespath is not implemented in `run` — assert on the data directly");
151
+ },
152
+ console,
153
+ };
154
+ }
155
+ async function invoke(code, globals) {
156
+ const names = Object.keys(globals);
157
+ const fn = new AsyncFunction(...names, code);
158
+ return fn(...names.map((n) => globals[n]));
159
+ }
160
+ /**
161
+ * Execute a Code node's body locally against a (fake) n8n context and print
162
+ * what it returns. Offline; no credentials, no network. `.ts` is compiled with
163
+ * the same esbuild pass as push; `.js` runs verbatim (marker stripped if any).
164
+ */
165
+ export async function runNode(file, fixturePath, log, opts = {}) {
166
+ const resolved = path.resolve(file);
167
+ if (!existsSync(resolved))
168
+ throw new Error(`node file not found: ${file}`);
169
+ if (!/\.(ts|js)$/.test(resolved))
170
+ throw new Error(`not a node source file (need .js or .ts): ${file}`);
171
+ const basename = path.basename(resolved);
172
+ const { node, staticData } = findNodeContext(resolved);
173
+ const mode = node?.parameters?.mode ?? "runOnceForAllItems";
174
+ if (!node)
175
+ log.warn(`no workflow.json placeholder points at ${basename} — assuming ${mode}`);
176
+ const code = resolved.endsWith(".ts")
177
+ ? await compileTs(resolved, log)
178
+ : splitMarker(readFileSync(resolved, "utf8")).body;
179
+ const fixture = loadFixture(fixturePath, log);
180
+ const globals = await buildGlobals(fixture, log, { nodeName: node?.name, staticData, allowEnv: opts.allowEnv });
181
+ log.info(`running ${basename} (${mode})${fixturePath ? ` with fixture ${path.basename(fixturePath)}` : ""}`);
182
+ log.info("─".repeat(48));
183
+ let output;
184
+ if (mode === "runOnceForEachItem") {
185
+ const input = globals.$input.all();
186
+ output = [];
187
+ for (let i = 0; i < input.length; i++) {
188
+ const perItem = {
189
+ ...globals,
190
+ $json: input[i].json,
191
+ $binary: input[i].binary ?? {},
192
+ $itemIndex: i,
193
+ $input: { ...globals.$input, item: input[i] },
194
+ };
195
+ const ret = await invoke(code, perItem);
196
+ if (ret !== undefined)
197
+ output.push(ret);
198
+ }
199
+ }
200
+ else {
201
+ output = await invoke(code, globals);
202
+ }
203
+ log.info("─".repeat(48));
204
+ const count = Array.isArray(output) ? output.length : output === undefined ? 0 : 1;
205
+ log.info(`returned ${count} item${count === 1 ? "" : "s"}:`);
206
+ console.log(JSON.stringify(output, null, 2));
207
+ return output;
208
+ }
@@ -0,0 +1,156 @@
1
+ import { existsSync, readFileSync, readdirSync, renameSync, writeFileSync } from "node:fs";
2
+ import path from "node:path";
3
+ import { CODE_DIR } from "./util.mjs";
4
+ export const STATE_FILE = ".decanter.json";
5
+ /**
6
+ * Locate the workflow folder a node file belongs to: the file's own
7
+ * directory, or its parent when the file sits in `code/`. `marker` is the
8
+ * file that proves a workflow folder — `.decanter.json` for sync state,
9
+ * `workflow.json` for structure. Null when neither location qualifies.
10
+ */
11
+ export function nodeFileContextDir(filePath, marker = STATE_FILE) {
12
+ let dir = path.dirname(filePath);
13
+ if (!existsSync(path.join(dir, marker)) && path.basename(dir) === CODE_DIR) {
14
+ dir = path.dirname(dir);
15
+ }
16
+ return existsSync(path.join(dir, marker)) ? dir : null;
17
+ }
18
+ /**
19
+ * Rename a node's source file *and* its `.remote.js` sibling to a new kebab
20
+ * base. Never renames across extensions (an `ext` differing from the current
21
+ * file's leaves the source in place — compiled JS must not silently become
22
+ * "TS source"), and never clobbers an existing target: skipped renames are
23
+ * silent because both callers handle them (pull rewrites content afterwards,
24
+ * remote being authoritative; rename pre-checks collisions and throws).
25
+ * Returns the wanted relative path (`code/<base><ext>`).
26
+ */
27
+ export function renameNodeFilePair(dir, current, base, ext, log) {
28
+ const wanted = `${CODE_DIR}/${base}${ext}`;
29
+ const renames = [[current.replace(/\.(ts|js)$/, ".remote.js"), `${CODE_DIR}/${base}.remote.js`]];
30
+ if (path.extname(current) === ext)
31
+ renames.push([current, wanted]);
32
+ for (const [from, to] of renames) {
33
+ const fromPath = path.join(dir, from);
34
+ if (from !== to && existsSync(fromPath) && !existsSync(path.join(dir, to))) {
35
+ renameSync(fromPath, path.join(dir, to));
36
+ log.info(`renamed ${from} -> ${to}`);
37
+ }
38
+ }
39
+ return wanted;
40
+ }
41
+ /** Missing state is null; corrupt state throws a clear, file-naming error. */
42
+ export function readState(dir) {
43
+ const file = path.join(dir, STATE_FILE);
44
+ if (!existsSync(file))
45
+ return null;
46
+ try {
47
+ return JSON.parse(readFileSync(file, "utf8"));
48
+ }
49
+ catch (err) {
50
+ throw new Error(`corrupt ${STATE_FILE} (${err.message})`);
51
+ }
52
+ }
53
+ export function writeState(dir, state) {
54
+ writeFileSync(path.join(dir, STATE_FILE), JSON.stringify(state, null, 2) + "\n");
55
+ }
56
+ /** All workflow folders under root (dirs containing a .decanter.json). */
57
+ export function listWorkflowDirs(root) {
58
+ const found = [];
59
+ if (!existsSync(root))
60
+ return found;
61
+ const stack = [root];
62
+ while (stack.length > 0) {
63
+ const dir = stack.pop();
64
+ let entries;
65
+ try {
66
+ entries = readdirSync(dir, { withFileTypes: true });
67
+ }
68
+ catch {
69
+ continue;
70
+ }
71
+ if (entries.some((e) => e.isFile() && e.name === STATE_FILE)) {
72
+ found.push(dir); // workflow folders don't nest
73
+ continue;
74
+ }
75
+ for (const e of entries) {
76
+ if (e.isDirectory() && e.name !== "node_modules")
77
+ stack.push(path.join(dir, e.name));
78
+ }
79
+ }
80
+ return found.sort();
81
+ }
82
+ /**
83
+ * All pulled workflows with their addressable names. A corrupt or missing
84
+ * state file only skips its own folder (warned via `log`), same as
85
+ * findWorkflowDir.
86
+ */
87
+ export function listWorkflowRefs(root, log) {
88
+ const refs = [];
89
+ for (const dir of listWorkflowDirs(root)) {
90
+ let state;
91
+ try {
92
+ state = readState(dir);
93
+ }
94
+ catch (err) {
95
+ log?.warn(`${dir}: ${err.message} — skipping this folder`);
96
+ continue;
97
+ }
98
+ if (!state)
99
+ continue;
100
+ const folderName = path.basename(dir);
101
+ let wfName;
102
+ try {
103
+ wfName = JSON.parse(readFileSync(path.join(dir, "workflow.json"), "utf8")).name;
104
+ }
105
+ catch {
106
+ // no or unparsable workflow.json — the folder name still addresses it
107
+ }
108
+ const names = [...new Set([wfName, folderName].filter((n) => typeof n === "string" && n !== ""))];
109
+ refs.push({ id: state.workflowId, dir, name: wfName ?? folderName, names });
110
+ }
111
+ return refs;
112
+ }
113
+ /**
114
+ * Match a CLI workflow argument against candidates: exact id → exact name
115
+ * (case-insensitive) → unique name prefix. Several matches in a tier throw,
116
+ * listing the candidates; no match returns null (never a prompt — ambiguity
117
+ * must stay script- and LLM-safe).
118
+ */
119
+ export function matchWorkflowRef(candidates, ref) {
120
+ const byId = candidates.find((c) => c.id === ref);
121
+ if (byId)
122
+ return byId;
123
+ const lc = ref.toLowerCase();
124
+ for (const tier of [
125
+ candidates.filter((c) => c.names.some((n) => n.toLowerCase() === lc)),
126
+ candidates.filter((c) => c.names.some((n) => n.toLowerCase().startsWith(lc))),
127
+ ]) {
128
+ if (tier.length === 1)
129
+ return tier[0];
130
+ if (tier.length > 1) {
131
+ throw new Error(`ambiguous workflow "${ref}" — matches ${tier.map((c) => `"${c.names[0]}" (${c.id})`).join(", ")}`);
132
+ }
133
+ }
134
+ return null;
135
+ }
136
+ /** True when a ref could plausibly be a raw workflow id (opaque alphanumeric token). */
137
+ export function looksLikeWorkflowId(ref) {
138
+ return /^[A-Za-z0-9_-]+$/.test(ref);
139
+ }
140
+ /**
141
+ * Find the workflow folder for an id by scanning .decanter.json files.
142
+ * A corrupt state file only skips its own folder (warned via `log`) — it must
143
+ * not take down commands for every other workflow.
144
+ */
145
+ export function findWorkflowDir(root, workflowId, log) {
146
+ for (const dir of listWorkflowDirs(root)) {
147
+ try {
148
+ if (readState(dir)?.workflowId === workflowId)
149
+ return dir;
150
+ }
151
+ catch (err) {
152
+ log?.warn(`${dir}: ${err.message} — skipping this folder`);
153
+ }
154
+ }
155
+ return null;
156
+ }
@@ -0,0 +1,102 @@
1
+ import { existsSync, readFileSync } from "node:fs";
2
+ import path from "node:path";
3
+ import { compileTs } from "./compile.mjs";
4
+ import { unifiedDiff } from "./diff.mjs";
5
+ import { findWorkflowDir, readState } from "./state.mjs";
6
+ import { style } from "./style.mjs";
7
+ import { isJsCodeNode, publicationState, sha256, splitMarker, workflowStructureHash } from "./util.mjs";
8
+ /** The comparable local body: file content for .js, the compiled JS for .ts. */
9
+ async function localBody(dir, file, log) {
10
+ const filePath = path.join(dir, file);
11
+ if (!existsSync(filePath))
12
+ return null;
13
+ if (file.endsWith(".ts"))
14
+ return compileTs(filePath, log);
15
+ return readFileSync(filePath, "utf8");
16
+ }
17
+ export async function statusWorkflow(api, root, id, log, { diff = false } = {}) {
18
+ const remote = await api.getWorkflow(id);
19
+ const dir = findWorkflowDir(root, id, log);
20
+ if (!dir) {
21
+ log.warn(`${remote.name} (${id}): not pulled yet`);
22
+ return { remoteDrift: true };
23
+ }
24
+ let remoteDrift = false;
25
+ const drift = () => {
26
+ remoteDrift = true;
27
+ };
28
+ const state = readState(dir);
29
+ const pub = publicationState(remote);
30
+ log.info(`${remote.name} (${id}) [${path.relative(process.cwd(), dir)}]${pub ? ` ${pub}` : ""}`);
31
+ const remoteStruct = workflowStructureHash(remote);
32
+ const wfFile = path.join(dir, "workflow.json");
33
+ const localStruct = existsSync(wfFile)
34
+ ? workflowStructureHash(JSON.parse(readFileSync(wfFile, "utf8")))
35
+ : null;
36
+ const base = state.lastPulledWorkflowHash;
37
+ if (remoteStruct !== base && localStruct !== base) {
38
+ log.warn(" structure: changed both locally and remotely");
39
+ drift();
40
+ }
41
+ else if (remoteStruct !== base) {
42
+ log.warn(" structure: changed remotely — pull");
43
+ drift();
44
+ }
45
+ else if (localStruct !== base)
46
+ log.info(" structure: changed locally — push pending");
47
+ else
48
+ log.ok(" structure: in sync");
49
+ for (const node of remote.nodes) {
50
+ if (!isJsCodeNode(node))
51
+ continue;
52
+ const nodeState = state.nodes[node.id];
53
+ const label = ` ${node.name}`;
54
+ if (!nodeState) {
55
+ log.warn(`${label}: remote code node unknown locally — pull`);
56
+ drift();
57
+ continue;
58
+ }
59
+ const remoteBody = splitMarker(node.parameters.jsCode).body;
60
+ const remoteHash = sha256(remoteBody);
61
+ const body = await localBody(dir, nodeState.file, log);
62
+ const local = body === null ? null : sha256(body);
63
+ // --diff: the same bodies the hashes compare — for .ts that is the
64
+ // compiled JS, which is what push would put on the remote
65
+ const printDiff = () => {
66
+ if (!diff || body === null)
67
+ return;
68
+ log.info(` ${style.dim("--- remote (n8n)")}`);
69
+ log.info(` ${style.dim(`+++ local (${nodeState.file})`)}`);
70
+ for (const line of unifiedDiff(remoteBody, body)) {
71
+ const styled = line.startsWith("+") ? style.green(line) : line.startsWith("-") ? style.red(line) : style.dim(line);
72
+ log.info(` ${styled}`);
73
+ }
74
+ };
75
+ const last = nodeState.lastPushedHash;
76
+ if (local === null)
77
+ log.warn(`${label}: local file ${nodeState.file} missing`);
78
+ else if (local === remoteHash)
79
+ log.ok(`${label}: in sync (${nodeState.file})`);
80
+ else if (remoteHash === last) {
81
+ log.info(`${label}: local changes in ${nodeState.file} — push pending`);
82
+ printDiff();
83
+ }
84
+ else if (local === last) {
85
+ log.warn(`${label}: changed remotely — pull`);
86
+ drift();
87
+ printDiff();
88
+ }
89
+ else {
90
+ log.error(`${label}: CONFLICT — changed both locally and remotely`);
91
+ drift();
92
+ printDiff();
93
+ }
94
+ }
95
+ for (const [nodeId, nodeState] of Object.entries(state.nodes)) {
96
+ if (!remote.nodes.some((n) => n.id === nodeId)) {
97
+ log.warn(` ${nodeState.file}: node ${nodeId} deleted remotely`);
98
+ drift();
99
+ }
100
+ }
101
+ return { remoteDrift };
102
+ }
@@ -0,0 +1,40 @@
1
+ // TTY-gated styling (Plan 11). One rule: escape codes exist only when the
2
+ // target stream is a color-capable TTY — util.styleText handles that per
3
+ // stream and also honors NO_COLOR / FORCE_COLOR. Piped output (LLM harnesses,
4
+ // scripts, the e2e suite) gets the same words minus the escapes; color is
5
+ // additive decoration and never carries information alone.
6
+ import { styleText } from "node:util";
7
+ function makeStyle(stream) {
8
+ const s = (format, text) => styleText(format, text, { stream });
9
+ return {
10
+ bold: (t) => s("bold", t),
11
+ dim: (t) => s("dim", t),
12
+ green: (t) => s("green", t),
13
+ yellow: (t) => s("yellow", t),
14
+ red: (t) => s("red", t),
15
+ link: (text, url) => stream.isTTY ? `\x1b]8;;${url}\x1b\\${text}\x1b]8;;\x1b\\` : text === url ? url : `${text} ${url}`,
16
+ };
17
+ }
18
+ /** Styling gated on the stream the text actually goes to. */
19
+ export const style = makeStyle(process.stdout);
20
+ export const styleErr = makeStyle(process.stderr);
21
+ /**
22
+ * TTY-only transient status line ("pulling wf123…"): shown in place, erased
23
+ * before the next real log line replaces it. Piped output never sees it.
24
+ */
25
+ export function transientLine() {
26
+ let pending = false;
27
+ return {
28
+ show(text) {
29
+ if (!process.stdout.isTTY)
30
+ return;
31
+ process.stdout.write(text);
32
+ pending = true;
33
+ },
34
+ clear() {
35
+ if (pending)
36
+ process.stdout.write("\r\x1b[2K");
37
+ pending = false;
38
+ },
39
+ };
40
+ }
@@ -0,0 +1,2 @@
1
+ // Shared shapes of the decanter data model. Types only — no runtime exports.
2
+ export {};