orcha-dev 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 alaeddine_gd
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,71 @@
1
+ # orcha-dev
2
+
3
+ [![npm version](https://img.shields.io/npm/v/orcha-dev.svg)](https://www.npmjs.com/package/orcha-dev)
4
+ [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
5
+
6
+ Unix pipes for AI workflows. Define reusable tasks and linear pipelines in
7
+ one `orcha.yaml` file; execute them from Node, the shell, or both.
8
+
9
+ `orcha-dev` is a thin Node wrapper around the same single Go binary used by
10
+ the Python SDK. The binary is downloaded into `~/.orcha/bin/` on first run
11
+ and verified by sha256; later runs are zero-network.
12
+
13
+ ## Install
14
+
15
+ ```bash
16
+ npm install orcha-dev
17
+ ```
18
+
19
+ Node 18+ is required. The first invocation downloads the engine binary that
20
+ matches the package version; pin a specific tag with `ORCHA_BINARY_VERSION`
21
+ or point at a local build with `ORCHA_BINARY_PATH=/abs/path/to/orcha`.
22
+
23
+ ## CLI
24
+
25
+ ```bash
26
+ # Run a pipeline; pretty progress on stderr, final output on stdout.
27
+ npx orcha run <pipeline> [-y orcha.yaml] [-i STR | -f FILE] [--json]
28
+
29
+ # Print version.
30
+ npx orcha version
31
+ ```
32
+
33
+ Input precedence: `-i` (inline string) → `-f` (file content) → stdin.
34
+ `--json` swaps pretty progress for a JSON-line event stream.
35
+
36
+ ## Programmatic API
37
+
38
+ ```js
39
+ const { Orcha } = require('orcha-dev');
40
+
41
+ const orcha = await Orcha.create('./orcha.yaml');
42
+
43
+ // Stream events as the pipeline runs.
44
+ for await (const event of orcha.run('summarize-article', './article.txt')) {
45
+ console.log(event.type, event.task, event.elapsed_ms);
46
+ }
47
+
48
+ // Or get just the final output.
49
+ const result = await orcha.runToCompletion('summarize-article', './article.txt');
50
+ ```
51
+
52
+ TypeScript types ship with the package — `import { Orcha, OrchaEvent } from 'orcha-dev'` works out of the box.
53
+
54
+ ## Providers
55
+
56
+ Set the matching environment variable for whichever provider your YAML uses:
57
+
58
+ | Provider | Variable |
59
+ |------------|------------------------|
60
+ | `openai` | `OPENAI_API_KEY` |
61
+ | `anthropic`| `ANTHROPIC_API_KEY` |
62
+ | `deepseek` | `DEEPSEEK_API_KEY` |
63
+ | custom | `ORCHA_<NAME>_API_KEY` |
64
+
65
+ ## Links
66
+
67
+ - Source: <https://github.com/ryfoo/orcha>
68
+ - Issues: <https://github.com/ryfoo/orcha/issues>
69
+ - Python SDK: <https://pypi.org/project/orcha-dev/>
70
+
71
+ MIT licensed.
package/bin/orcha.js ADDED
@@ -0,0 +1,180 @@
1
+ #!/usr/bin/env node
2
+ 'use strict';
3
+
4
+ // The `orcha` shell command shipped via the `orcha-dev` npm package. Mirrors
5
+ // the UX of the Go orcha-run binary and the Python `orcha` script so users
6
+ // who pick npm get the same subcommands and flags as everyone else.
7
+ //
8
+ // orcha run <pipeline> [-y YAML] [-i STR | -f FILE] [--json]
9
+ // orcha version
10
+
11
+ const fs = require('node:fs');
12
+ const path = require('node:path');
13
+
14
+ const { Orcha } = require('../lib/index');
15
+ const { OrchaError } = require('../lib/errors');
16
+ const pkg = require('../package.json');
17
+
18
+ function printUsage(stream = process.stderr) {
19
+ stream.write(
20
+ 'orcha — run an orcha.yaml pipeline from the command line.\n' +
21
+ '\n' +
22
+ 'Usage:\n' +
23
+ ' orcha run <pipeline> [-y YAML] [-i STR | -f FILE] [--json]\n' +
24
+ ' orcha version\n' +
25
+ '\n' +
26
+ 'Flags:\n' +
27
+ ' -y, --yaml PATH path to orcha.yaml (default: ./orcha.yaml)\n' +
28
+ ' -i, --input STR inline input string passed to the first task\n' +
29
+ ' -f, --input-file PATH read input from this file\n' +
30
+ ' --json emit JSON-line events to stdout instead of pretty progress\n',
31
+ );
32
+ }
33
+
34
+ function parseRunArgs(argv) {
35
+ const out = { pipeline: null, yaml: null, input: null, inputFile: null, json: false };
36
+ for (let i = 0; i < argv.length; i++) {
37
+ const a = argv[i];
38
+ const next = () => {
39
+ const v = argv[++i];
40
+ if (v === undefined) {
41
+ process.stderr.write(`orcha: ${a} requires a value\n`);
42
+ process.exit(2);
43
+ }
44
+ return v;
45
+ };
46
+ if (a === '-y' || a === '--yaml') out.yaml = next();
47
+ else if (a === '-i' || a === '--input') out.input = next();
48
+ else if (a === '-f' || a === '--input-file') out.inputFile = next();
49
+ else if (a === '--json') out.json = true;
50
+ else if (a === '-h' || a === '--help') { printUsage(process.stdout); process.exit(0); }
51
+ else if (a.startsWith('-')) { process.stderr.write(`orcha: unknown flag ${a}\n`); process.exit(2); }
52
+ else if (out.pipeline === null) out.pipeline = a;
53
+ else { process.stderr.write(`orcha: unexpected argument ${a}\n`); process.exit(2); }
54
+ }
55
+ if (out.pipeline === null) { printUsage(); process.exit(2); }
56
+ if (out.input !== null && out.inputFile !== null) {
57
+ process.stderr.write('orcha: pass either -i/--input or -f/--input-file, not both\n');
58
+ process.exit(2);
59
+ }
60
+ return out;
61
+ }
62
+
63
+ function resolveYaml(arg) {
64
+ if (arg) return arg;
65
+ const candidate = path.join(process.cwd(), 'orcha.yaml');
66
+ if (!fs.existsSync(candidate)) {
67
+ process.stderr.write(
68
+ `orcha: no -y/--yaml flag and ./orcha.yaml not found in ${process.cwd()}\n`,
69
+ );
70
+ process.exit(2);
71
+ }
72
+ return candidate;
73
+ }
74
+
75
+ async function readStdinAll() {
76
+ const chunks = [];
77
+ for await (const chunk of process.stdin) chunks.push(chunk);
78
+ return Buffer.concat(chunks).toString('utf8');
79
+ }
80
+
81
+ async function resolveInput(args) {
82
+ if (args.input !== null) return args.input;
83
+ if (args.inputFile) return fs.readFileSync(args.inputFile, 'utf8');
84
+ if (!process.stdin.isTTY) return readStdinAll();
85
+ return null;
86
+ }
87
+
88
+ async function runJson(orcha, pipeline, input) {
89
+ let exitCode = 0;
90
+ try {
91
+ for await (const ev of orcha.run(pipeline, input)) {
92
+ if (ev.type === 'task_fail') exitCode = 1;
93
+ process.stdout.write(JSON.stringify(ev) + '\n');
94
+ }
95
+ } catch (e) {
96
+ if (e instanceof OrchaError) {
97
+ process.stderr.write(`orcha: ${e.message}\n`);
98
+ return 1;
99
+ }
100
+ throw e;
101
+ }
102
+ return exitCode;
103
+ }
104
+
105
+ async function runPretty(orcha, pipeline, input) {
106
+ let final = null;
107
+ let finalType = '';
108
+ try {
109
+ for await (const ev of orcha.run(pipeline, input)) {
110
+ if (ev.type === 'task_start') {
111
+ process.stderr.write(`> ${ev.task} ...\n`);
112
+ } else if (ev.type === 'task_complete') {
113
+ process.stderr.write(`+ ${ev.task} (${ev.elapsed_ms}ms)\n`);
114
+ } else if (ev.type === 'task_fail') {
115
+ process.stderr.write(`x ${ev.task} -- ${ev.error}\n`);
116
+ return 1;
117
+ } else if (ev.type === 'pipeline_complete') {
118
+ final = ev.output;
119
+ finalType = ev.output_type;
120
+ process.stderr.write(`-- done in ${ev.elapsed_ms}ms\n`);
121
+ }
122
+ }
123
+ } catch (e) {
124
+ if (e instanceof OrchaError) {
125
+ process.stderr.write(`orcha: ${e.message}\n`);
126
+ return 1;
127
+ }
128
+ throw e;
129
+ }
130
+
131
+ if (final !== null && final !== undefined) {
132
+ if ((finalType === 'text' || finalType === 'filepath') && typeof final === 'string') {
133
+ process.stdout.write(final + '\n');
134
+ } else {
135
+ process.stdout.write(JSON.stringify(final, null, 2) + '\n');
136
+ }
137
+ }
138
+ return 0;
139
+ }
140
+
141
+ async function main(argv) {
142
+ const sub = argv[0];
143
+ if (!sub || sub === '-h' || sub === '--help') {
144
+ printUsage(sub ? process.stdout : process.stderr);
145
+ return sub ? 0 : 2;
146
+ }
147
+ if (sub === 'version' || sub === '--version' || sub === '-v') {
148
+ process.stdout.write(pkg.version + '\n');
149
+ return 0;
150
+ }
151
+ if (sub !== 'run') {
152
+ process.stderr.write(`orcha: unknown command: ${sub}\n`);
153
+ return 2;
154
+ }
155
+
156
+ const args = parseRunArgs(argv.slice(1));
157
+ const yamlPath = resolveYaml(args.yaml);
158
+ const input = await resolveInput(args);
159
+
160
+ let orcha;
161
+ try {
162
+ orcha = await Orcha.create(yamlPath);
163
+ } catch (e) {
164
+ if (e instanceof OrchaError) {
165
+ process.stderr.write(`orcha: ${e.message}\n`);
166
+ return 1;
167
+ }
168
+ throw e;
169
+ }
170
+
171
+ return args.json ? runJson(orcha, args.pipeline, input) : runPretty(orcha, args.pipeline, input);
172
+ }
173
+
174
+ main(process.argv.slice(2)).then(
175
+ (code) => process.exit(code || 0),
176
+ (err) => {
177
+ process.stderr.write(`orcha: ${err && err.message ? err.message : err}\n`);
178
+ process.exit(1);
179
+ },
180
+ );
@@ -0,0 +1,201 @@
1
+ 'use strict';
2
+
3
+ // Resolve and (lazily) download the orcha Go binary for the current platform.
4
+ //
5
+ // The binary lives at ~/.orcha/bin/orcha-<os>-<arch>[.exe]. On first call we:
6
+ //
7
+ // 1. Fetch a manifest.json from the GitHub release that matches this npm
8
+ // package's version.
9
+ // 2. Look up the entry for our platform — it gives us a binary URL and the
10
+ // expected sha256.
11
+ // 3. Download the binary, verify the hash, mark it executable, cache it.
12
+ //
13
+ // Subsequent runs find the cached file and skip the network entirely. The
14
+ // behavior matches the Python SDK's downloader.py byte-for-byte so a single
15
+ // GitHub release serves both ecosystems.
16
+ //
17
+ // Override hooks (env):
18
+ // ORCHA_BINARY_PATH — absolute path to a pre-built binary (no download
19
+ // or hash check is run).
20
+ // ORCHA_BINARY_VERSION — pin a specific release tag instead of using the
21
+ // npm package version.
22
+
23
+ const crypto = require('node:crypto');
24
+ const fs = require('node:fs');
25
+ const fsp = require('node:fs/promises');
26
+ const https = require('node:https');
27
+ const os = require('node:os');
28
+ const path = require('node:path');
29
+ const { URL } = require('node:url');
30
+
31
+ const { OrchaError } = require('./errors');
32
+ const pkg = require('../package.json');
33
+
34
+ const MANIFEST_URL_TEMPLATE = 'https://github.com/ryfoo/orcha/releases/download/v{version}/manifest.json';
35
+
36
+ function detectPlatform() {
37
+ const osMap = { linux: 'linux', darwin: 'darwin', win32: 'windows' };
38
+ const archMap = { x64: 'amd64', arm64: 'arm64' };
39
+
40
+ const sys = os.platform();
41
+ const arch = os.arch();
42
+ if (!(sys in osMap)) throw new OrchaError(`unsupported OS: ${sys}`);
43
+ if (!(arch in archMap)) throw new OrchaError(`unsupported architecture: ${arch}`);
44
+ return { osName: osMap[sys], arch: archMap[arch] };
45
+ }
46
+
47
+ function binaryFilename(osName, arch) {
48
+ let name = `orcha-${osName}-${arch}`;
49
+ if (osName === 'windows') name += '.exe';
50
+ return name;
51
+ }
52
+
53
+ function installDir() {
54
+ return path.join(os.homedir(), '.orcha', 'bin');
55
+ }
56
+
57
+ // Wrap https.get so we can transparently follow GitHub release redirects
58
+ // (objects.githubusercontent.com) without pulling in a dep.
59
+ function httpsGet(url, opts = {}, redirectsLeft = 5) {
60
+ return new Promise((resolve, reject) => {
61
+ const req = https.get(url, opts, (res) => {
62
+ const status = res.statusCode || 0;
63
+ if (status >= 300 && status < 400 && res.headers.location) {
64
+ if (redirectsLeft <= 0) {
65
+ res.resume();
66
+ reject(new OrchaError(`too many redirects fetching ${url}`));
67
+ return;
68
+ }
69
+ const next = new URL(res.headers.location, url).toString();
70
+ res.resume();
71
+ httpsGet(next, opts, redirectsLeft - 1).then(resolve, reject);
72
+ return;
73
+ }
74
+ resolve(res);
75
+ });
76
+ req.on('error', reject);
77
+ req.setTimeout(120_000, () => {
78
+ req.destroy(new OrchaError(`timeout fetching ${url}`));
79
+ });
80
+ });
81
+ }
82
+
83
+ async function fetchManifest(version) {
84
+ const url = MANIFEST_URL_TEMPLATE.replace('{version}', version);
85
+ let res;
86
+ try {
87
+ res = await httpsGet(url);
88
+ } catch (e) {
89
+ throw new OrchaError(`failed to fetch manifest at ${url}: ${e.message}`);
90
+ }
91
+ const status = res.statusCode || 0;
92
+ if (status === 404) {
93
+ res.resume();
94
+ throw new OrchaError(
95
+ `orcha release v${version} not found at ${url}. Either the release ` +
96
+ `hasn't been published yet, or ORCHA_BINARY_VERSION points at a tag ` +
97
+ `that doesn't exist.`,
98
+ );
99
+ }
100
+ if (status < 200 || status >= 300) {
101
+ res.resume();
102
+ throw new OrchaError(`failed to fetch manifest at ${url}: HTTP ${status}`);
103
+ }
104
+ const chunks = [];
105
+ for await (const chunk of res) chunks.push(chunk);
106
+ const body = Buffer.concat(chunks).toString('utf8');
107
+ try {
108
+ return JSON.parse(body);
109
+ } catch (e) {
110
+ throw new OrchaError(`manifest at ${url} was not valid JSON: ${e.message}`);
111
+ }
112
+ }
113
+
114
+ async function downloadTo(url, dest) {
115
+ const tmpPath = `${dest}.tmp-${process.pid}-${Date.now()}`;
116
+ let res;
117
+ try {
118
+ res = await httpsGet(url);
119
+ } catch (e) {
120
+ throw new OrchaError(`download failed (${url}): ${e.message}`);
121
+ }
122
+ const status = res.statusCode || 0;
123
+ if (status < 200 || status >= 300) {
124
+ res.resume();
125
+ throw new OrchaError(`download failed (${url}): HTTP ${status}`);
126
+ }
127
+ await new Promise((resolve, reject) => {
128
+ const out = fs.createWriteStream(tmpPath);
129
+ res.pipe(out);
130
+ out.on('finish', () => out.close(resolve));
131
+ out.on('error', reject);
132
+ res.on('error', reject);
133
+ });
134
+ try {
135
+ await fsp.rename(tmpPath, dest);
136
+ } catch (e) {
137
+ await fsp.unlink(tmpPath).catch(() => {});
138
+ throw e;
139
+ }
140
+ }
141
+
142
+ async function sha256(filePath) {
143
+ const hash = crypto.createHash('sha256');
144
+ const stream = fs.createReadStream(filePath);
145
+ for await (const chunk of stream) hash.update(chunk);
146
+ return hash.digest('hex');
147
+ }
148
+
149
+ // resolveBinary returns the cached binary path, downloading + verifying it
150
+ // the first time. Safe to call from multiple processes — concurrent downloads
151
+ // race only on the final rename, which is atomic on the platforms we target.
152
+ async function resolveBinary(version) {
153
+ const override = process.env.ORCHA_BINARY_PATH;
154
+ if (override) {
155
+ if (!fs.existsSync(override)) {
156
+ throw new OrchaError(`ORCHA_BINARY_PATH points to missing file: ${override}`);
157
+ }
158
+ return override;
159
+ }
160
+
161
+ const v = version || process.env.ORCHA_BINARY_VERSION || pkg.version;
162
+ const { osName, arch } = detectPlatform();
163
+ const name = binaryFilename(osName, arch);
164
+ const target = path.join(installDir(), name);
165
+ if (fs.existsSync(target)) return target;
166
+
167
+ await fsp.mkdir(path.dirname(target), { recursive: true });
168
+
169
+ const manifest = await fetchManifest(v);
170
+ const platformKey = `${osName}-${arch}`;
171
+ const entry = manifest?.binaries?.[platformKey];
172
+ if (!entry) {
173
+ const known = Object.keys(manifest?.binaries || {}).sort();
174
+ throw new OrchaError(
175
+ `release v${v} has no binary for ${platformKey} (available: [${known.join(', ')}])`,
176
+ );
177
+ }
178
+
179
+ await downloadTo(entry.url, target);
180
+ const actual = await sha256(target);
181
+ if (actual !== entry.sha256) {
182
+ await fsp.unlink(target).catch(() => {});
183
+ throw new OrchaError(
184
+ `sha256 mismatch for ${name}: expected ${entry.sha256}, got ${actual}`,
185
+ );
186
+ }
187
+ // chmod +x for owner/group/other; no-op on Windows.
188
+ if (osName !== 'windows') {
189
+ await fsp.chmod(target, 0o755);
190
+ }
191
+ return target;
192
+ }
193
+
194
+ module.exports = {
195
+ resolveBinary,
196
+ // Exported for tests / advanced callers.
197
+ detectPlatform,
198
+ binaryFilename,
199
+ installDir,
200
+ sha256,
201
+ };
package/lib/errors.js ADDED
@@ -0,0 +1,25 @@
1
+ 'use strict';
2
+
3
+ // OrchaError covers anything that goes wrong outside a task — binary
4
+ // download/verification, IPC plumbing, missing yaml, etc. Task-level failures
5
+ // surface as TaskFailed.
6
+ class OrchaError extends Error {
7
+ constructor(message) {
8
+ super(message);
9
+ this.name = 'OrchaError';
10
+ }
11
+ }
12
+
13
+ // TaskFailed is thrown by runToCompletion() when the engine emits a
14
+ // task_fail event, so callers can use plain try/catch.
15
+ class TaskFailed extends OrchaError {
16
+ constructor(task, index, error) {
17
+ super(`task ${task} (index ${index}) failed: ${error}`);
18
+ this.name = 'TaskFailed';
19
+ this.task = task;
20
+ this.index = index;
21
+ this.taskError = error;
22
+ }
23
+ }
24
+
25
+ module.exports = { OrchaError, TaskFailed };
package/lib/index.d.ts ADDED
@@ -0,0 +1,68 @@
1
+ // Type declarations for orcha-dev. Hand-written rather than generated so the
2
+ // runtime stays plain JS with no build step.
3
+
4
+ export type OutputType = 'text' | 'json' | 'filepath' | 'list';
5
+
6
+ export type EventType =
7
+ | 'task_start'
8
+ | 'task_complete'
9
+ | 'task_fail'
10
+ | 'pipeline_complete';
11
+
12
+ /** One streaming event from a pipeline run. Mirrors the Python OrchaEvent. */
13
+ export interface OrchaEvent {
14
+ type: EventType;
15
+ /** Name of the task; empty string on `pipeline_complete` in v1. */
16
+ task: string;
17
+ /** 0-based position in the pipeline; -1 for parse-time errors. */
18
+ index: number;
19
+ /** Final/intermediate output. `null` for `task_start` and `task_fail`. */
20
+ output: unknown;
21
+ /** Output type tag from the YAML; empty for `task_start`/`task_fail`. */
22
+ output_type: OutputType | '';
23
+ /** Error message; only set when `type === 'task_fail'`. */
24
+ error?: string | null;
25
+ /** Wall-clock duration of this step in ms. 0 for `task_start`. */
26
+ elapsed_ms: number;
27
+ }
28
+
29
+ export interface CreateOptions {
30
+ /** Absolute path to a pre-built engine binary (skips download). */
31
+ binaryPath?: string;
32
+ }
33
+
34
+ export type RunInput = string | Buffer | Uint8Array | object | unknown[] | null | undefined;
35
+
36
+ export class Orcha {
37
+ /** Use {@link Orcha.create} instead unless you already have a binary path. */
38
+ constructor(yamlPath: string, binaryPath: string);
39
+
40
+ /** Resolve (and lazily download + verify) the engine binary, then return an Orcha bound to a YAML file. */
41
+ static create(yamlPath: string, options?: CreateOptions): Promise<Orcha>;
42
+
43
+ /** Absolute path to the resolved orcha.yaml. */
44
+ readonly yamlPath: string;
45
+ /** Absolute path to the engine binary in use. */
46
+ readonly binary: string;
47
+
48
+ /** Run a pipeline and stream events as they arrive. */
49
+ run(target: string, input?: RunInput): AsyncIterable<OrchaEvent>;
50
+
51
+ /**
52
+ * Run a pipeline to completion and return its final output. Throws
53
+ * {@link TaskFailed} on the first failing task. Equivalent to Python's
54
+ * `run_sync`.
55
+ */
56
+ runToCompletion(target: string, input?: RunInput): Promise<unknown>;
57
+ }
58
+
59
+ export class OrchaError extends Error {
60
+ readonly name: 'OrchaError' | 'TaskFailed';
61
+ }
62
+
63
+ export class TaskFailed extends OrchaError {
64
+ readonly name: 'TaskFailed';
65
+ readonly task: string;
66
+ readonly index: number;
67
+ readonly taskError: string;
68
+ }
package/lib/index.js ADDED
@@ -0,0 +1,158 @@
1
+ 'use strict';
2
+
3
+ // Programmatic JS/TS API for the orcha-dev npm package.
4
+ //
5
+ // Spawns the Go binary as a subprocess, sends one JSON command on stdin, and
6
+ // streams JSON-line events back from stdout. The protocol is one-shot per
7
+ // invocation: every run() call is a fresh process. The wire format is
8
+ // shared with the Python SDK so the binary stays single-source-of-truth.
9
+
10
+ const fs = require('node:fs');
11
+ const path = require('node:path');
12
+ const { spawn } = require('node:child_process');
13
+
14
+ const { resolveBinary } = require('./downloader');
15
+ const { OrchaError, TaskFailed } = require('./errors');
16
+
17
+ class Orcha {
18
+ // Use Orcha.create() instead — resolving the binary involves I/O, so the
19
+ // factory is async. The bare constructor is kept for callers that already
20
+ // have an absolute binary path (tests, monorepo dev loops).
21
+ constructor(yamlPath, binaryPath) {
22
+ if (!yamlPath) throw new OrchaError('yamlPath is required');
23
+ this.yamlPath = path.resolve(yamlPath);
24
+ if (!fs.existsSync(this.yamlPath)) {
25
+ throw new OrchaError(`yaml file not found: ${this.yamlPath}`);
26
+ }
27
+ if (!binaryPath) {
28
+ throw new OrchaError('binaryPath is required (use Orcha.create() to auto-resolve)');
29
+ }
30
+ this.binary = path.resolve(binaryPath);
31
+ }
32
+
33
+ static async create(yamlPath, options = {}) {
34
+ const binaryPath = options.binaryPath
35
+ ? path.resolve(options.binaryPath)
36
+ : await resolveBinary();
37
+ return new Orcha(yamlPath, binaryPath);
38
+ }
39
+
40
+ // run() returns an async iterable that yields one event per JSON line the
41
+ // binary emits. The returned iterator is consumable with `for await`. If
42
+ // the engine exits non-zero, the iterator throws OrchaError after the last
43
+ // event; per-task failures arrive as `task_fail` events first.
44
+ run(target, input) {
45
+ if (input instanceof Buffer || input instanceof Uint8Array) {
46
+ input = Buffer.from(input).toString('utf8');
47
+ }
48
+ const cmd = {
49
+ command: 'run',
50
+ pipeline: target,
51
+ yaml_path: this.yamlPath,
52
+ input: input === undefined ? null : input,
53
+ };
54
+ return this._spawn(cmd);
55
+ }
56
+
57
+ // runToCompletion() drains the event stream and returns the final pipeline
58
+ // output. Throws TaskFailed if any task fails so callers can use plain
59
+ // try/catch. Equivalent to Python's run_sync().
60
+ async runToCompletion(target, input) {
61
+ let final;
62
+ for await (const ev of this.run(target, input)) {
63
+ if (ev.type === 'task_fail') {
64
+ throw new TaskFailed(ev.task, ev.index, ev.error || 'unknown error');
65
+ }
66
+ if (ev.type === 'pipeline_complete') {
67
+ final = ev.output;
68
+ } else if (ev.type === 'task_complete') {
69
+ // Track latest in case the binary doesn't emit pipeline_complete
70
+ // (it always does in v1, but defensiveness here is cheap).
71
+ final = ev.output;
72
+ }
73
+ }
74
+ return final;
75
+ }
76
+
77
+ _spawn(cmd) {
78
+ const proc = spawn(this.binary, [], {
79
+ stdio: ['pipe', 'pipe', 'pipe'],
80
+ });
81
+
82
+ // Drain stderr eagerly so a full pipe buffer never blocks the engine
83
+ // mid-run; surface the captured text only if the process exits non-zero.
84
+ let stderrBuf = '';
85
+ proc.stderr.setEncoding('utf8');
86
+ proc.stderr.on('data', (chunk) => { stderrBuf += chunk; });
87
+
88
+ let writeError = null;
89
+ try {
90
+ proc.stdin.write(JSON.stringify(cmd) + '\n');
91
+ proc.stdin.end();
92
+ } catch (e) {
93
+ writeError = e;
94
+ }
95
+
96
+ return iterateLines(proc.stdout, () => stderrBuf, proc, writeError);
97
+ }
98
+ }
99
+
100
+ // iterateLines consumes a Readable byte stream and yields one parsed JSON
101
+ // object per newline. Trailing partial lines are buffered until the next
102
+ // chunk arrives. When the stream ends, we await the child's exit code and
103
+ // throw if it's non-zero.
104
+ function iterateLines(stdout, getStderr, proc, initialError) {
105
+ return {
106
+ async *[Symbol.asyncIterator]() {
107
+ if (initialError) {
108
+ proc.kill();
109
+ await new Promise((resolve) => proc.once('exit', resolve));
110
+ throw new OrchaError(
111
+ `failed to send command to engine: ${initialError.message}; stderr=${getStderr()}`,
112
+ );
113
+ }
114
+
115
+ stdout.setEncoding('utf8');
116
+ let buf = '';
117
+ try {
118
+ for await (const chunk of stdout) {
119
+ buf += chunk;
120
+ let nl;
121
+ while ((nl = buf.indexOf('\n')) !== -1) {
122
+ const line = buf.slice(0, nl).trim();
123
+ buf = buf.slice(nl + 1);
124
+ if (!line) continue;
125
+ let raw;
126
+ try {
127
+ raw = JSON.parse(line);
128
+ } catch (_) {
129
+ continue;
130
+ }
131
+ yield raw;
132
+ }
133
+ }
134
+ const tail = buf.trim();
135
+ if (tail) {
136
+ try {
137
+ yield JSON.parse(tail);
138
+ } catch (_) {
139
+ // Ignore; matches Python which silently drops malformed lines.
140
+ }
141
+ }
142
+ } finally {
143
+ const code = await new Promise((resolve) => {
144
+ if (proc.exitCode !== null) resolve(proc.exitCode);
145
+ else proc.once('exit', (c) => resolve(c));
146
+ });
147
+ if (code !== 0) {
148
+ const stderr = (getStderr() || '').trim();
149
+ throw new OrchaError(
150
+ `orcha engine exited ${code}` + (stderr ? `: ${stderr}` : ''),
151
+ );
152
+ }
153
+ }
154
+ },
155
+ };
156
+ }
157
+
158
+ module.exports = { Orcha, OrchaError, TaskFailed };
package/package.json ADDED
@@ -0,0 +1,42 @@
1
+ {
2
+ "name": "orcha-dev",
3
+ "version": "0.2.0",
4
+ "description": "Unix pipes for AI workflows. Define tasks and pipelines in YAML; execute with a single Go binary auto-downloaded on first run.",
5
+ "main": "./lib/index.js",
6
+ "types": "./lib/index.d.ts",
7
+ "bin": {
8
+ "orcha": "./bin/orcha.js"
9
+ },
10
+ "files": [
11
+ "bin",
12
+ "lib",
13
+ "README.md",
14
+ "LICENSE"
15
+ ],
16
+ "engines": {
17
+ "node": ">=18"
18
+ },
19
+ "license": "MIT",
20
+ "homepage": "https://github.com/ryfoo/orcha",
21
+ "repository": {
22
+ "type": "git",
23
+ "url": "git+https://github.com/ryfoo/orcha.git"
24
+ },
25
+ "bugs": {
26
+ "url": "https://github.com/ryfoo/orcha/issues"
27
+ },
28
+ "keywords": [
29
+ "ai",
30
+ "workflow",
31
+ "pipeline",
32
+ "yaml",
33
+ "openai",
34
+ "anthropic",
35
+ "deepseek",
36
+ "llm",
37
+ "automation"
38
+ ],
39
+ "scripts": {
40
+ "test": "node --test test/*.test.js"
41
+ }
42
+ }