@telorun/shell 0.1.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 +17 -0
- package/README.md +92 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +1 -0
- package/dist/local-host-controller.d.ts +23 -0
- package/dist/local-host-controller.js +246 -0
- package/dist/shell-command-controller.d.ts +28 -0
- package/dist/shell-command-controller.js +26 -0
- package/dist/shell-command-stream-controller.d.ts +30 -0
- package/dist/shell-command-stream-controller.js +28 -0
- package/dist/shell-host-ref.d.ts +15 -0
- package/dist/shell-host-ref.js +34 -0
- package/dist/shell-host.d.ts +56 -0
- package/dist/shell-host.js +1 -0
- package/package.json +62 -0
- package/src/index.ts +8 -0
- package/src/local-host-controller.ts +269 -0
- package/src/shell-command-controller.ts +51 -0
- package/src/shell-command-stream-controller.ts +52 -0
- package/src/shell-host-ref.ts +48 -0
- package/src/shell-host.ts +50 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
# SUSTAINABLE USE LICENSE (Fair-code)
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 CodeNet Sp. z o.o.
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to use, copy, modify, and distribute the Software for any purpose—including commercial purposes—subject to the following conditions:
|
|
6
|
+
|
|
7
|
+
1. ANTI-COMPETITION RESTRICTION: The Software may not be provided to third parties as a managed service, commercial SaaS (Software-as-a-Service), PaaS (Platform-as-a-Service), BaaS (Backend-as-a-Service), or similar offering where the primary value provided to the user is the functionality of the Software itself, without a separate commercial license from the copyright holder.
|
|
8
|
+
|
|
9
|
+
2. PERMITTED COMMERCIAL USE: You are free to use the Software to build, host, and monetize your own commercial applications, products, and services, provided such use does not violate Clause 1.
|
|
10
|
+
|
|
11
|
+
3. ATTRIBUTION: This copyright notice and license must be included in all copies or substantial portions of the Software.
|
|
12
|
+
|
|
13
|
+
4. CONTRIBUTIONS: Contributions to the Software are welcome and encouraged. By contributing, you agree that your contributions may be incorporated into the Software and distributed under this license.
|
|
14
|
+
|
|
15
|
+
5. DISCLAIMER: The Software is provided "as is", without warranty of any kind, express or implied, including but not limited to the warranties of merchantability, fitness for a particular purpose and noninfringement. In no event shall the authors or copyright holders be liable for any claim, damages or other liability, whether in an action of contract, tort or otherwise, arising from, out of or in connection with the Software or the use or other dealings in the Software.
|
|
16
|
+
|
|
17
|
+
For commercial licensing, managed hosting exemptions, or enterprise inquiries, please contact <contact@codenet.pl>.
|
package/README.md
ADDED
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
<p align="center">
|
|
2
|
+
<img src="https://raw.githubusercontent.com/telorun/telo/main/assets/telo.png" alt="Telo" width="200" />
|
|
3
|
+
</p>
|
|
4
|
+
|
|
5
|
+
<h1 align="center">Telo</h1>
|
|
6
|
+
|
|
7
|
+
<p align="center">Runtime for declarative backends.</p>
|
|
8
|
+
|
|
9
|
+
<p align="center">
|
|
10
|
+
<a href="https://github.com/telorun/telo/actions/workflows/test.yml"><img alt="Tests" src="https://github.com/telorun/telo/actions/workflows/test.yml/badge.svg" /></a>
|
|
11
|
+
<a href="https://www.npmjs.com/package/@telorun/cli"><img alt="node" src="https://img.shields.io/node/v/@telorun/cli" /></a>
|
|
12
|
+
<br />
|
|
13
|
+
<a href="https://github.com/telorun/telo/commits/main"><img alt="Last commit" src="https://img.shields.io/github/last-commit/telorun/telo" /></a>
|
|
14
|
+
<a href="https://github.com/telorun/telo/issues"><img alt="Issues" src="https://img.shields.io/github/issues/telorun/telo" /></a>
|
|
15
|
+
<a href="https://github.com/telorun/telo/pulls"><img alt="Pull requests" src="https://img.shields.io/github/issues-pr/telorun/telo" /></a>
|
|
16
|
+
<br />
|
|
17
|
+
<img alt="Changesets" src="https://img.shields.io/badge/maintained%20with-changesets-176de3" />
|
|
18
|
+
</p>
|
|
19
|
+
|
|
20
|
+
Telo is an execution engine (Micro-Kernel) that runs logic defined entirely in YAML manifests. Instead of writing imperative backend code, you define your routes, databases, schemas, and AI workflows as atomic, interconnected YAML documents. Telo takes those manifests and runs them.
|
|
21
|
+
|
|
22
|
+
Built to be language-agnostic and infinitely extensible.
|
|
23
|
+
|
|
24
|
+
```bash
|
|
25
|
+
# Reconcile your manifest into a running backend
|
|
26
|
+
$ telo ./examples/hello-api
|
|
27
|
+
|
|
28
|
+
{"level":30,"time":1771610393008,"pid":1310178,"hostname":"dev","msg":"Server listening at http://127.0.0.1:8844"}
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
## Why use Telo?
|
|
32
|
+
|
|
33
|
+
- **Open Standards:** Built on YAML, JSON Schema, and CEL — no proprietary DSL.
|
|
34
|
+
- **Static Analysis:** CEL type checking, reference validation, and IDE diagnostics catch errors before runtime.
|
|
35
|
+
- **Micro-Kernel Architecture:** Telo itself knows nothing about HTTP or SQL. Everything is a module you import, scope, and compose with typed variable and secret contracts.
|
|
36
|
+
- **Language Agnostic:** Available as a Node.js runtime today, with a shared YAML runtime contract that allows for future Rust or Go implementations without changing your manifests.
|
|
37
|
+
|
|
38
|
+
## What It Does
|
|
39
|
+
|
|
40
|
+
- **Loads** YAML resources and compiles CEL expressions (`${{ }}`) into an in-memory registry.
|
|
41
|
+
- **Resolves** resource dependencies via a multi-pass init loop, handling ordering automatically.
|
|
42
|
+
- **Indexes** resources by Kind and Name for constant-time lookup.
|
|
43
|
+
- **Dispatches** execution to the controller that owns each Kind.
|
|
44
|
+
|
|
45
|
+
## Example manifest
|
|
46
|
+
|
|
47
|
+
See [examples/](./examples/) for a list of working applications.
|
|
48
|
+
|
|
49
|
+
## Status
|
|
50
|
+
|
|
51
|
+
Telo is under **active development**. The core runtime, module system, and standard library are functional, but the API surface — including YAML shapes — may change without notice. Not yet recommended for production use.
|
|
52
|
+
|
|
53
|
+
## The Meaning of Telo
|
|
54
|
+
|
|
55
|
+
The name Telo is derived from the Greek root Telos - meaning the "end goal", "purpose", or "final state". That is exactly the philosophy behind this runtime. In standard imperative programming, you have to write thousands of lines of code to tell a server exactly how to start. With Telo, you simply declare your desired final state.
|
|
56
|
+
|
|
57
|
+
You define the end state. Telo makes it real.
|
|
58
|
+
|
|
59
|
+
## Philosophy
|
|
60
|
+
|
|
61
|
+
Modern platforms often spend disproportionate effort on technical mechanics-wiring frameworks, managing infrastructure, and negotiating toolchains-while the original business problem gets delayed or diluted. Telo pushes in the opposite direction: it treats kernel execution as a stable, predictable host so teams can concentrate on the **business logic and outcomes** instead of the plumbing.
|
|
62
|
+
|
|
63
|
+
By separating "what the system should do" from "how it is hosted", the runtime reduces friction for domain‑level changes. Teams can move faster on product requirements, experiment more safely, and keep conversations centered on value delivered rather than implementation trivia.
|
|
64
|
+
|
|
65
|
+
Telo also aims to **join forces across all programming language communities**, so the best ideas, patterns, and implementations can converge into a shared kernel truth without forcing everyone into a single stack.
|
|
66
|
+
|
|
67
|
+
YAML also makes the system more **AI‑friendly** than traditional programming languages: it is explicit, structured, and easier for tools to generate, review, and transform without losing intent.
|
|
68
|
+
|
|
69
|
+
## Modularity
|
|
70
|
+
|
|
71
|
+
Telo is built around **modules** that own specific resource kinds. A module is loaded from a manifest, declares which kinds it implements, and then receives only the resources of those kinds. This keeps concerns isolated and lets teams compose systems from focused building blocks rather than monolithic services.
|
|
72
|
+
|
|
73
|
+
At kernel execution time, execution is always routed by **Kind.Name**. The kernel resolves the Kind to its owning module and hands off execution. Modules can call back into the kernel to execute other resources, enabling composition without tight coupling.
|
|
74
|
+
|
|
75
|
+
## Architecture
|
|
76
|
+
|
|
77
|
+
The architecture is inspired by Kubernetes-style manifests: declarative resources, explicit kinds, and a control plane that routes work based on those definitions.
|
|
78
|
+
Those manifests were taken to the next level by allowing them to run inside a standalone runtime host.
|
|
79
|
+
|
|
80
|
+
## See more at
|
|
81
|
+
|
|
82
|
+
- [Telo Kernel](./kernel/README.md)
|
|
83
|
+
- [Telo SDK for module authors](sdk/README.md)
|
|
84
|
+
- [Modules](modules/README.md)
|
|
85
|
+
|
|
86
|
+
## License
|
|
87
|
+
|
|
88
|
+
See [LICENSE](https://github.com/telorun/telo/blob/main/LICENSE).
|
|
89
|
+
|
|
90
|
+
## Contribution Note
|
|
91
|
+
|
|
92
|
+
By contributing, you agree that code and examples in this repository may be translated or re‑implemented in other programming languages (including by AI systems) to support the project’s polyglot goals.
|
package/dist/index.d.ts
ADDED
package/dist/index.js
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { resolveShellHost } from "./shell-host-ref.js";
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import type { InvokeContext, ResourceContext, ResourceInstance } from "@telorun/sdk";
|
|
2
|
+
import type { ExecutionHandle, RunOptions, ShellHost } from "./shell-host.js";
|
|
3
|
+
interface LocalHostManifest {
|
|
4
|
+
metadata: {
|
|
5
|
+
name: string;
|
|
6
|
+
module: string;
|
|
7
|
+
};
|
|
8
|
+
cwd?: string;
|
|
9
|
+
shell?: string;
|
|
10
|
+
env?: Record<string, string>;
|
|
11
|
+
}
|
|
12
|
+
declare class LocalShellHost implements ShellHost, ResourceInstance {
|
|
13
|
+
private readonly cwd;
|
|
14
|
+
private readonly shell;
|
|
15
|
+
private readonly baseEnv;
|
|
16
|
+
private readonly hostEnv;
|
|
17
|
+
constructor(cwd: string, shell: string, baseEnv: Record<string, string>, hostEnv: Record<string, string>);
|
|
18
|
+
snapshot(): Record<string, unknown>;
|
|
19
|
+
exec(command: string, options: RunOptions, ctx?: InvokeContext): ExecutionHandle;
|
|
20
|
+
}
|
|
21
|
+
export declare function register(): void;
|
|
22
|
+
export declare function create(resource: LocalHostManifest, ctx: ResourceContext): Promise<LocalShellHost>;
|
|
23
|
+
export {};
|
|
@@ -0,0 +1,246 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
/** Filter the kernel-sanctioned host env (`ctx.env`) down to defined entries. */
|
|
3
|
+
function toEnvRecord(env) {
|
|
4
|
+
const out = {};
|
|
5
|
+
for (const [key, value] of Object.entries(env)) {
|
|
6
|
+
if (typeof value === "string")
|
|
7
|
+
out[key] = value;
|
|
8
|
+
}
|
|
9
|
+
return out;
|
|
10
|
+
}
|
|
11
|
+
function defaultShell(hostEnv) {
|
|
12
|
+
if (process.platform === "win32")
|
|
13
|
+
return hostEnv.ComSpec ?? "cmd.exe";
|
|
14
|
+
return "/bin/sh";
|
|
15
|
+
}
|
|
16
|
+
function shellInvocation(shell, command) {
|
|
17
|
+
if (process.platform === "win32")
|
|
18
|
+
return { file: shell, args: ["/d", "/s", "/c", command] };
|
|
19
|
+
return { file: shell, args: ["-c", command] };
|
|
20
|
+
}
|
|
21
|
+
/** Spawn the shell as its own process-group leader (POSIX) so the whole tree
|
|
22
|
+
* can be torn down together. Without this, killing the shell orphans any
|
|
23
|
+
* grandchild it spawned (a `sleep`, a dev server), which also keeps the stdout
|
|
24
|
+
* pipe open so `close` never fires. */
|
|
25
|
+
function spawnChild(spec) {
|
|
26
|
+
return spawn(spec.file, spec.args, {
|
|
27
|
+
cwd: spec.cwd,
|
|
28
|
+
env: spec.env,
|
|
29
|
+
detached: process.platform !== "win32",
|
|
30
|
+
});
|
|
31
|
+
}
|
|
32
|
+
/** Kill the child's entire process group (POSIX) / process (Windows). No-op if
|
|
33
|
+
* it has already exited. */
|
|
34
|
+
function killChild(child) {
|
|
35
|
+
if (child.pid === undefined || child.exitCode !== null || child.signalCode !== null)
|
|
36
|
+
return;
|
|
37
|
+
try {
|
|
38
|
+
if (process.platform === "win32")
|
|
39
|
+
child.kill("SIGKILL");
|
|
40
|
+
else
|
|
41
|
+
process.kill(-child.pid, "SIGKILL");
|
|
42
|
+
}
|
|
43
|
+
catch {
|
|
44
|
+
// ESRCH — the process group is already gone.
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
function writeStdin(child, stdin) {
|
|
48
|
+
if (!child.stdin)
|
|
49
|
+
return;
|
|
50
|
+
// A child that doesn't read stdin (or exits early) makes writes/`end()` emit
|
|
51
|
+
// EPIPE on the writable; with no listener Node escalates to an
|
|
52
|
+
// uncaughtException and tears the kernel down. EPIPE here is benign — the
|
|
53
|
+
// command's outcome is decided by its exit code — so absorb stdin errors.
|
|
54
|
+
child.stdin.on("error", () => { });
|
|
55
|
+
if (stdin !== undefined)
|
|
56
|
+
child.stdin.write(stdin);
|
|
57
|
+
child.stdin.end();
|
|
58
|
+
}
|
|
59
|
+
function spawnError(err, spec) {
|
|
60
|
+
if (err.code === "ENOENT")
|
|
61
|
+
return new Error(`Shell: interpreter not found: ${spec.file}`);
|
|
62
|
+
return new Error(`Shell: failed to run command (${err.code ?? err.message}): ${spec.command}`);
|
|
63
|
+
}
|
|
64
|
+
function runBuffered(spec) {
|
|
65
|
+
return new Promise((resolve, reject) => {
|
|
66
|
+
const child = spawnChild(spec);
|
|
67
|
+
let stdout = "";
|
|
68
|
+
let stderr = "";
|
|
69
|
+
let timedOut = false;
|
|
70
|
+
let cancelled = false;
|
|
71
|
+
const timer = spec.timeoutMs
|
|
72
|
+
? setTimeout(() => {
|
|
73
|
+
timedOut = true;
|
|
74
|
+
killChild(child);
|
|
75
|
+
}, spec.timeoutMs)
|
|
76
|
+
: undefined;
|
|
77
|
+
timer?.unref?.();
|
|
78
|
+
const onAbort = () => {
|
|
79
|
+
cancelled = true;
|
|
80
|
+
killChild(child);
|
|
81
|
+
};
|
|
82
|
+
if (spec.signal?.aborted)
|
|
83
|
+
onAbort();
|
|
84
|
+
else
|
|
85
|
+
spec.signal?.addEventListener("abort", onAbort, { once: true });
|
|
86
|
+
const cleanup = () => {
|
|
87
|
+
if (timer)
|
|
88
|
+
clearTimeout(timer);
|
|
89
|
+
spec.signal?.removeEventListener("abort", onAbort);
|
|
90
|
+
};
|
|
91
|
+
child.stdout?.setEncoding("utf8");
|
|
92
|
+
child.stdout?.on("data", (d) => {
|
|
93
|
+
stdout += d;
|
|
94
|
+
});
|
|
95
|
+
child.stderr?.setEncoding("utf8");
|
|
96
|
+
child.stderr?.on("data", (d) => {
|
|
97
|
+
stderr += d;
|
|
98
|
+
});
|
|
99
|
+
child.on("error", (err) => {
|
|
100
|
+
cleanup();
|
|
101
|
+
reject(spawnError(err, spec));
|
|
102
|
+
});
|
|
103
|
+
child.on("close", (code, signal) => {
|
|
104
|
+
cleanup();
|
|
105
|
+
if (timedOut) {
|
|
106
|
+
reject(new Error(`Shell command timed out after ${spec.timeoutMs}ms: ${spec.command}`));
|
|
107
|
+
return;
|
|
108
|
+
}
|
|
109
|
+
if (cancelled) {
|
|
110
|
+
reject(new Error(`Shell command cancelled: ${spec.command}`));
|
|
111
|
+
return;
|
|
112
|
+
}
|
|
113
|
+
if (signal) {
|
|
114
|
+
reject(new Error(`Shell command terminated by signal ${signal}: ${spec.command}`));
|
|
115
|
+
return;
|
|
116
|
+
}
|
|
117
|
+
resolve({ stdout, stderr, exitCode: code ?? 0 });
|
|
118
|
+
});
|
|
119
|
+
writeStdin(child, spec.stdin);
|
|
120
|
+
});
|
|
121
|
+
}
|
|
122
|
+
function runStream(spec) {
|
|
123
|
+
return {
|
|
124
|
+
async *[Symbol.asyncIterator]() {
|
|
125
|
+
const child = spawnChild(spec);
|
|
126
|
+
const queue = [];
|
|
127
|
+
let finished = false;
|
|
128
|
+
let wake = null;
|
|
129
|
+
const wakeUp = () => {
|
|
130
|
+
if (wake) {
|
|
131
|
+
const w = wake;
|
|
132
|
+
wake = null;
|
|
133
|
+
w();
|
|
134
|
+
}
|
|
135
|
+
};
|
|
136
|
+
const push = (part) => {
|
|
137
|
+
queue.push(part);
|
|
138
|
+
// Backpressure: pause the source streams while output is buffered; the
|
|
139
|
+
// consumer resumes them after it drains the queue, bounding memory.
|
|
140
|
+
child.stdout?.pause();
|
|
141
|
+
child.stderr?.pause();
|
|
142
|
+
wakeUp();
|
|
143
|
+
};
|
|
144
|
+
let timedOut = false;
|
|
145
|
+
let cancelled = false;
|
|
146
|
+
const timer = spec.timeoutMs
|
|
147
|
+
? setTimeout(() => {
|
|
148
|
+
timedOut = true;
|
|
149
|
+
killChild(child);
|
|
150
|
+
}, spec.timeoutMs)
|
|
151
|
+
: undefined;
|
|
152
|
+
timer?.unref?.();
|
|
153
|
+
const onAbort = () => {
|
|
154
|
+
cancelled = true;
|
|
155
|
+
killChild(child);
|
|
156
|
+
};
|
|
157
|
+
if (spec.signal?.aborted)
|
|
158
|
+
onAbort();
|
|
159
|
+
else
|
|
160
|
+
spec.signal?.addEventListener("abort", onAbort, { once: true });
|
|
161
|
+
child.stdout?.setEncoding("utf8");
|
|
162
|
+
child.stdout?.on("data", (d) => push({ type: "stdout", chunk: d }));
|
|
163
|
+
child.stderr?.setEncoding("utf8");
|
|
164
|
+
child.stderr?.on("data", (d) => push({ type: "stderr", chunk: d }));
|
|
165
|
+
child.on("error", (err) => {
|
|
166
|
+
push({ type: "error", error: { message: err.message, code: err.code } });
|
|
167
|
+
finished = true;
|
|
168
|
+
wakeUp();
|
|
169
|
+
});
|
|
170
|
+
child.on("close", (code, signal) => {
|
|
171
|
+
if (timedOut) {
|
|
172
|
+
push({ type: "error", error: { message: `Shell command timed out after ${spec.timeoutMs}ms`, code: "ETIMEDOUT" } });
|
|
173
|
+
}
|
|
174
|
+
else if (cancelled) {
|
|
175
|
+
push({ type: "error", error: { message: "Shell command cancelled", code: "ECANCELLED" } });
|
|
176
|
+
}
|
|
177
|
+
else {
|
|
178
|
+
push({ type: "exit", exitCode: code ?? 0, signal: signal ?? null });
|
|
179
|
+
}
|
|
180
|
+
finished = true;
|
|
181
|
+
wakeUp();
|
|
182
|
+
});
|
|
183
|
+
writeStdin(child, spec.stdin);
|
|
184
|
+
try {
|
|
185
|
+
while (true) {
|
|
186
|
+
while (queue.length)
|
|
187
|
+
yield queue.shift();
|
|
188
|
+
if (finished)
|
|
189
|
+
return;
|
|
190
|
+
// Drained — resume the source and wait for the next record.
|
|
191
|
+
child.stdout?.resume();
|
|
192
|
+
child.stderr?.resume();
|
|
193
|
+
await new Promise((r) => {
|
|
194
|
+
wake = r;
|
|
195
|
+
});
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
finally {
|
|
199
|
+
// Early termination (consumer break/return, a downstream throw, the
|
|
200
|
+
// enclosing sequence failing) lands here without `close` having fired —
|
|
201
|
+
// tear down the whole process group and clear timer/abort listener.
|
|
202
|
+
if (timer)
|
|
203
|
+
clearTimeout(timer);
|
|
204
|
+
spec.signal?.removeEventListener("abort", onAbort);
|
|
205
|
+
killChild(child);
|
|
206
|
+
}
|
|
207
|
+
},
|
|
208
|
+
};
|
|
209
|
+
}
|
|
210
|
+
class LocalShellHost {
|
|
211
|
+
cwd;
|
|
212
|
+
shell;
|
|
213
|
+
baseEnv;
|
|
214
|
+
hostEnv;
|
|
215
|
+
constructor(cwd, shell, baseEnv, hostEnv) {
|
|
216
|
+
this.cwd = cwd;
|
|
217
|
+
this.shell = shell;
|
|
218
|
+
this.baseEnv = baseEnv;
|
|
219
|
+
this.hostEnv = hostEnv;
|
|
220
|
+
}
|
|
221
|
+
snapshot() {
|
|
222
|
+
return {};
|
|
223
|
+
}
|
|
224
|
+
exec(command, options, ctx) {
|
|
225
|
+
const { file, args } = shellInvocation(this.shell, command);
|
|
226
|
+
const spec = {
|
|
227
|
+
file,
|
|
228
|
+
args,
|
|
229
|
+
cwd: this.cwd,
|
|
230
|
+
env: { ...this.hostEnv, ...this.baseEnv, ...(options.env ?? {}) },
|
|
231
|
+
signal: ctx?.cancellation.signal,
|
|
232
|
+
timeoutMs: options.timeoutMs,
|
|
233
|
+
stdin: options.stdin,
|
|
234
|
+
command,
|
|
235
|
+
};
|
|
236
|
+
return {
|
|
237
|
+
buffered: () => runBuffered(spec),
|
|
238
|
+
stream: () => runStream(spec),
|
|
239
|
+
};
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
export function register() { }
|
|
243
|
+
export async function create(resource, ctx) {
|
|
244
|
+
const hostEnv = toEnvRecord(ctx.env);
|
|
245
|
+
return new LocalShellHost(resource.cwd ?? ".", resource.shell ?? defaultShell(hostEnv), resource.env ?? {}, hostEnv);
|
|
246
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import type { InvokeContext, ResourceContext, ResourceInstance } from "@telorun/sdk";
|
|
2
|
+
import type { BufferedResult, ShellHost } from "./shell-host.js";
|
|
3
|
+
interface HostRef {
|
|
4
|
+
name: string;
|
|
5
|
+
alias?: string;
|
|
6
|
+
}
|
|
7
|
+
interface ShellCommandManifest {
|
|
8
|
+
metadata: {
|
|
9
|
+
name: string;
|
|
10
|
+
module: string;
|
|
11
|
+
};
|
|
12
|
+
host?: ShellHost | HostRef;
|
|
13
|
+
}
|
|
14
|
+
interface CommandInput {
|
|
15
|
+
command: string;
|
|
16
|
+
env?: Record<string, string>;
|
|
17
|
+
stdin?: string;
|
|
18
|
+
timeoutMs?: number;
|
|
19
|
+
}
|
|
20
|
+
declare class ShellCommandResource implements ResourceInstance {
|
|
21
|
+
private readonly manifest;
|
|
22
|
+
private readonly ctx;
|
|
23
|
+
constructor(manifest: ShellCommandManifest, ctx: ResourceContext);
|
|
24
|
+
invoke(input: CommandInput, ctx?: InvokeContext): Promise<BufferedResult>;
|
|
25
|
+
}
|
|
26
|
+
export declare function register(): void;
|
|
27
|
+
export declare function create(resource: ShellCommandManifest, ctx: ResourceContext): Promise<ShellCommandResource>;
|
|
28
|
+
export {};
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { resolveShellHost } from "./shell-host-ref.js";
|
|
2
|
+
function requireCommand(input) {
|
|
3
|
+
if (typeof input?.command !== "string" || input.command.length === 0) {
|
|
4
|
+
throw new Error("Shell.Command: 'command' input is required and must be a non-empty string");
|
|
5
|
+
}
|
|
6
|
+
return input.command;
|
|
7
|
+
}
|
|
8
|
+
class ShellCommandResource {
|
|
9
|
+
manifest;
|
|
10
|
+
ctx;
|
|
11
|
+
constructor(manifest, ctx) {
|
|
12
|
+
this.manifest = manifest;
|
|
13
|
+
this.ctx = ctx;
|
|
14
|
+
}
|
|
15
|
+
async invoke(input, ctx) {
|
|
16
|
+
const host = resolveShellHost(this.manifest.host, this.ctx);
|
|
17
|
+
const command = requireCommand(input);
|
|
18
|
+
return host
|
|
19
|
+
.exec(command, { env: input.env, stdin: input.stdin, timeoutMs: input.timeoutMs }, ctx)
|
|
20
|
+
.buffered();
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
export function register() { }
|
|
24
|
+
export async function create(resource, ctx) {
|
|
25
|
+
return new ShellCommandResource(resource, ctx);
|
|
26
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { Stream, type InvokeContext, type ResourceContext, type ResourceInstance } from "@telorun/sdk";
|
|
2
|
+
import type { ShellHost, StreamPart } from "./shell-host.js";
|
|
3
|
+
interface HostRef {
|
|
4
|
+
name: string;
|
|
5
|
+
alias?: string;
|
|
6
|
+
}
|
|
7
|
+
interface ShellCommandStreamManifest {
|
|
8
|
+
metadata: {
|
|
9
|
+
name: string;
|
|
10
|
+
module: string;
|
|
11
|
+
};
|
|
12
|
+
host?: ShellHost | HostRef;
|
|
13
|
+
}
|
|
14
|
+
interface CommandInput {
|
|
15
|
+
command: string;
|
|
16
|
+
env?: Record<string, string>;
|
|
17
|
+
stdin?: string;
|
|
18
|
+
timeoutMs?: number;
|
|
19
|
+
}
|
|
20
|
+
declare class ShellCommandStreamResource implements ResourceInstance {
|
|
21
|
+
private readonly manifest;
|
|
22
|
+
private readonly ctx;
|
|
23
|
+
constructor(manifest: ShellCommandStreamManifest, ctx: ResourceContext);
|
|
24
|
+
invoke(input: CommandInput, ctx?: InvokeContext): Promise<{
|
|
25
|
+
output: Stream<StreamPart>;
|
|
26
|
+
}>;
|
|
27
|
+
}
|
|
28
|
+
export declare function register(): void;
|
|
29
|
+
export declare function create(resource: ShellCommandStreamManifest, ctx: ResourceContext): Promise<ShellCommandStreamResource>;
|
|
30
|
+
export {};
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { Stream } from "@telorun/sdk";
|
|
2
|
+
import { resolveShellHost } from "./shell-host-ref.js";
|
|
3
|
+
function requireCommand(input) {
|
|
4
|
+
if (typeof input?.command !== "string" || input.command.length === 0) {
|
|
5
|
+
throw new Error("Shell.CommandStream: 'command' input is required and must be a non-empty string");
|
|
6
|
+
}
|
|
7
|
+
return input.command;
|
|
8
|
+
}
|
|
9
|
+
class ShellCommandStreamResource {
|
|
10
|
+
manifest;
|
|
11
|
+
ctx;
|
|
12
|
+
constructor(manifest, ctx) {
|
|
13
|
+
this.manifest = manifest;
|
|
14
|
+
this.ctx = ctx;
|
|
15
|
+
}
|
|
16
|
+
async invoke(input, ctx) {
|
|
17
|
+
const host = resolveShellHost(this.manifest.host, this.ctx);
|
|
18
|
+
const command = requireCommand(input);
|
|
19
|
+
const iterable = host
|
|
20
|
+
.exec(command, { env: input.env, stdin: input.stdin, timeoutMs: input.timeoutMs }, ctx)
|
|
21
|
+
.stream();
|
|
22
|
+
return { output: new Stream(iterable) };
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
export function register() { }
|
|
26
|
+
export async function create(resource, ctx) {
|
|
27
|
+
return new ShellCommandStreamResource(resource, ctx);
|
|
28
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import type { ResourceContext } from "@telorun/sdk";
|
|
2
|
+
import type { ShellHost } from "./shell-host.js";
|
|
3
|
+
interface HostRef {
|
|
4
|
+
name: string;
|
|
5
|
+
alias?: string;
|
|
6
|
+
}
|
|
7
|
+
/**
|
|
8
|
+
* Resolve the `host` field of a Shell operation to a live `Shell.Host`. The
|
|
9
|
+
* value is either the Phase-5-injected instance or — for a cross-module
|
|
10
|
+
* `!ref Alias.host` reached through a nested library — the raw `{name, alias}`
|
|
11
|
+
* ref, which must route through the import's exported scope. Mirrors
|
|
12
|
+
* `resolveSqlConnection`.
|
|
13
|
+
*/
|
|
14
|
+
export declare function resolveShellHost(value: ShellHost | HostRef | undefined, ctx: ResourceContext): ShellHost;
|
|
15
|
+
export {};
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
function isShellHost(value) {
|
|
2
|
+
return typeof value?.exec === "function";
|
|
3
|
+
}
|
|
4
|
+
/**
|
|
5
|
+
* Resolve the `host` field of a Shell operation to a live `Shell.Host`. The
|
|
6
|
+
* value is either the Phase-5-injected instance or — for a cross-module
|
|
7
|
+
* `!ref Alias.host` reached through a nested library — the raw `{name, alias}`
|
|
8
|
+
* ref, which must route through the import's exported scope. Mirrors
|
|
9
|
+
* `resolveSqlConnection`.
|
|
10
|
+
*/
|
|
11
|
+
export function resolveShellHost(value, ctx) {
|
|
12
|
+
if (!value) {
|
|
13
|
+
throw new Error("Shell: 'host' is required");
|
|
14
|
+
}
|
|
15
|
+
if (isShellHost(value)) {
|
|
16
|
+
return value;
|
|
17
|
+
}
|
|
18
|
+
const ref = value;
|
|
19
|
+
if (typeof ref.name !== "string") {
|
|
20
|
+
throw new Error("Shell: invalid host reference");
|
|
21
|
+
}
|
|
22
|
+
if (ref.alias && ref.alias !== "Self") {
|
|
23
|
+
const instance = ctx.moduleContext.resolveImportedInstance(ref.alias, ref.name);
|
|
24
|
+
if (!isShellHost(instance)) {
|
|
25
|
+
throw new Error(`Shell: host reference '${ref.alias}.${ref.name}' did not resolve to a Shell.Host instance.`);
|
|
26
|
+
}
|
|
27
|
+
return instance;
|
|
28
|
+
}
|
|
29
|
+
const instance = ctx.moduleContext.getInstance(ref.name);
|
|
30
|
+
if (!isShellHost(instance)) {
|
|
31
|
+
throw new Error(`Shell: host reference '${ref.name}' did not resolve to a Shell.Host instance.`);
|
|
32
|
+
}
|
|
33
|
+
return instance;
|
|
34
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import type { InvokeContext } from "@telorun/sdk";
|
|
2
|
+
/**
|
|
3
|
+
* The transport-neutral `Shell.Host` contract — the module's central seam.
|
|
4
|
+
* Every driver (the bundled local host, and future `shell-ssh` / Docker / k8s
|
|
5
|
+
* modules) implements `ShellHost`; the generic operations (`Shell.Command`,
|
|
6
|
+
* `Shell.CommandStream`) and the ref resolver depend only on this file, never
|
|
7
|
+
* on a concrete driver. Mirrors `sql`'s `sqlite-driver-interface.ts` split.
|
|
8
|
+
*/
|
|
9
|
+
/** One record in a `Shell.CommandStream` output stream. */
|
|
10
|
+
export type StreamPart = {
|
|
11
|
+
type: "stdout";
|
|
12
|
+
chunk: string;
|
|
13
|
+
} | {
|
|
14
|
+
type: "stderr";
|
|
15
|
+
chunk: string;
|
|
16
|
+
} | {
|
|
17
|
+
type: "exit";
|
|
18
|
+
exitCode: number;
|
|
19
|
+
signal: string | null;
|
|
20
|
+
} | {
|
|
21
|
+
type: "error";
|
|
22
|
+
error: {
|
|
23
|
+
message: string;
|
|
24
|
+
code?: string;
|
|
25
|
+
};
|
|
26
|
+
};
|
|
27
|
+
export interface BufferedResult {
|
|
28
|
+
stdout: string;
|
|
29
|
+
stderr: string;
|
|
30
|
+
exitCode: number;
|
|
31
|
+
}
|
|
32
|
+
export interface RunOptions {
|
|
33
|
+
/** Per-call environment overlay, merged over the host's base env. */
|
|
34
|
+
env?: Record<string, string>;
|
|
35
|
+
/** Written to the child's stdin, which is then closed. */
|
|
36
|
+
stdin?: string;
|
|
37
|
+
/** Kill the child and fail after this many milliseconds. */
|
|
38
|
+
timeoutMs?: number;
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* The execution primitive every driver implements. `exec` composes the command
|
|
42
|
+
* for its target and returns a lazy handle the operations consume either
|
|
43
|
+
* buffered (`Shell.Command`) or streamed (`Shell.CommandStream`) — exactly one
|
|
44
|
+
* per call. The host owns all composition (`<shell> -c <command>`, env merge,
|
|
45
|
+
* cwd) so the operations stay backend-agnostic. (Named `exec`, not `run`, to
|
|
46
|
+
* avoid the Runnable capability's reserved `run()`.)
|
|
47
|
+
*/
|
|
48
|
+
export interface ShellHost {
|
|
49
|
+
exec(command: string, options: RunOptions, ctx?: InvokeContext): ExecutionHandle;
|
|
50
|
+
}
|
|
51
|
+
export interface ExecutionHandle {
|
|
52
|
+
/** Spawn, collect all output, and resolve once the process exits. */
|
|
53
|
+
buffered(): Promise<BufferedResult>;
|
|
54
|
+
/** Spawn on iteration; yield stdout/stderr records then a terminal exit/error. */
|
|
55
|
+
stream(): AsyncIterable<StreamPart>;
|
|
56
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
package/package.json
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@telorun/shell",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Telo shell module - run shell commands behind a transport-neutral host abstraction.",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"telo",
|
|
7
|
+
"shell",
|
|
8
|
+
"command",
|
|
9
|
+
"exec",
|
|
10
|
+
"process"
|
|
11
|
+
],
|
|
12
|
+
"author": "Bartosz Pasiński <bartosz.pasinski@codenet.pl>",
|
|
13
|
+
"license": "SEE LICENSE IN LICENSE",
|
|
14
|
+
"repository": {
|
|
15
|
+
"type": "git",
|
|
16
|
+
"url": "git+https://github.com/telorun/telo.git",
|
|
17
|
+
"directory": "modules/shell/nodejs"
|
|
18
|
+
},
|
|
19
|
+
"homepage": "https://github.com/telorun/telo#readme",
|
|
20
|
+
"bugs": {
|
|
21
|
+
"url": "https://github.com/telorun/telo/issues"
|
|
22
|
+
},
|
|
23
|
+
"type": "module",
|
|
24
|
+
"main": "./dist/index.js",
|
|
25
|
+
"module": "./dist/index.js",
|
|
26
|
+
"types": "./dist/index.d.ts",
|
|
27
|
+
"exports": {
|
|
28
|
+
".": {
|
|
29
|
+
"types": "./dist/index.d.ts",
|
|
30
|
+
"bun": "./src/index.ts",
|
|
31
|
+
"import": "./dist/index.js"
|
|
32
|
+
},
|
|
33
|
+
"./local-host": {
|
|
34
|
+
"types": "./dist/local-host-controller.d.ts",
|
|
35
|
+
"bun": "./src/local-host-controller.ts",
|
|
36
|
+
"import": "./dist/local-host-controller.js"
|
|
37
|
+
},
|
|
38
|
+
"./shell-command": {
|
|
39
|
+
"bun": "./src/shell-command-controller.ts",
|
|
40
|
+
"import": "./dist/shell-command-controller.js"
|
|
41
|
+
},
|
|
42
|
+
"./shell-command-stream": {
|
|
43
|
+
"bun": "./src/shell-command-stream-controller.ts",
|
|
44
|
+
"import": "./dist/shell-command-stream-controller.js"
|
|
45
|
+
}
|
|
46
|
+
},
|
|
47
|
+
"files": [
|
|
48
|
+
"dist",
|
|
49
|
+
"src/**"
|
|
50
|
+
],
|
|
51
|
+
"devDependencies": {
|
|
52
|
+
"@types/node": "^20.0.0",
|
|
53
|
+
"typescript": "^5.0.0",
|
|
54
|
+
"@telorun/sdk": "0.38.0"
|
|
55
|
+
},
|
|
56
|
+
"peerDependencies": {
|
|
57
|
+
"@telorun/sdk": "*"
|
|
58
|
+
},
|
|
59
|
+
"scripts": {
|
|
60
|
+
"build": "tsc -p tsconfig.lib.json"
|
|
61
|
+
}
|
|
62
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,269 @@
|
|
|
1
|
+
import type { InvokeContext, ResourceContext, ResourceInstance } from "@telorun/sdk";
|
|
2
|
+
import { spawn, type ChildProcess } from "node:child_process";
|
|
3
|
+
import type {
|
|
4
|
+
BufferedResult,
|
|
5
|
+
ExecutionHandle,
|
|
6
|
+
RunOptions,
|
|
7
|
+
ShellHost,
|
|
8
|
+
StreamPart,
|
|
9
|
+
} from "./shell-host.js";
|
|
10
|
+
|
|
11
|
+
interface SpawnSpec {
|
|
12
|
+
file: string;
|
|
13
|
+
args: string[];
|
|
14
|
+
cwd: string;
|
|
15
|
+
env: NodeJS.ProcessEnv;
|
|
16
|
+
signal?: AbortSignal;
|
|
17
|
+
timeoutMs?: number;
|
|
18
|
+
stdin?: string;
|
|
19
|
+
command: string;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
interface LocalHostManifest {
|
|
23
|
+
metadata: { name: string; module: string };
|
|
24
|
+
cwd?: string;
|
|
25
|
+
shell?: string;
|
|
26
|
+
env?: Record<string, string>;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** Filter the kernel-sanctioned host env (`ctx.env`) down to defined entries. */
|
|
30
|
+
function toEnvRecord(env: Record<string, string | undefined>): Record<string, string> {
|
|
31
|
+
const out: Record<string, string> = {};
|
|
32
|
+
for (const [key, value] of Object.entries(env)) {
|
|
33
|
+
if (typeof value === "string") out[key] = value;
|
|
34
|
+
}
|
|
35
|
+
return out;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function defaultShell(hostEnv: Record<string, string>): string {
|
|
39
|
+
if (process.platform === "win32") return hostEnv.ComSpec ?? "cmd.exe";
|
|
40
|
+
return "/bin/sh";
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function shellInvocation(shell: string, command: string): { file: string; args: string[] } {
|
|
44
|
+
if (process.platform === "win32") return { file: shell, args: ["/d", "/s", "/c", command] };
|
|
45
|
+
return { file: shell, args: ["-c", command] };
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** Spawn the shell as its own process-group leader (POSIX) so the whole tree
|
|
49
|
+
* can be torn down together. Without this, killing the shell orphans any
|
|
50
|
+
* grandchild it spawned (a `sleep`, a dev server), which also keeps the stdout
|
|
51
|
+
* pipe open so `close` never fires. */
|
|
52
|
+
function spawnChild(spec: SpawnSpec): ChildProcess {
|
|
53
|
+
return spawn(spec.file, spec.args, {
|
|
54
|
+
cwd: spec.cwd,
|
|
55
|
+
env: spec.env,
|
|
56
|
+
detached: process.platform !== "win32",
|
|
57
|
+
});
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** Kill the child's entire process group (POSIX) / process (Windows). No-op if
|
|
61
|
+
* it has already exited. */
|
|
62
|
+
function killChild(child: ChildProcess): void {
|
|
63
|
+
if (child.pid === undefined || child.exitCode !== null || child.signalCode !== null) return;
|
|
64
|
+
try {
|
|
65
|
+
if (process.platform === "win32") child.kill("SIGKILL");
|
|
66
|
+
else process.kill(-child.pid, "SIGKILL");
|
|
67
|
+
} catch {
|
|
68
|
+
// ESRCH — the process group is already gone.
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function writeStdin(child: ChildProcess, stdin?: string): void {
|
|
73
|
+
if (!child.stdin) return;
|
|
74
|
+
// A child that doesn't read stdin (or exits early) makes writes/`end()` emit
|
|
75
|
+
// EPIPE on the writable; with no listener Node escalates to an
|
|
76
|
+
// uncaughtException and tears the kernel down. EPIPE here is benign — the
|
|
77
|
+
// command's outcome is decided by its exit code — so absorb stdin errors.
|
|
78
|
+
child.stdin.on("error", () => {});
|
|
79
|
+
if (stdin !== undefined) child.stdin.write(stdin);
|
|
80
|
+
child.stdin.end();
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function spawnError(err: NodeJS.ErrnoException, spec: SpawnSpec): Error {
|
|
84
|
+
if (err.code === "ENOENT") return new Error(`Shell: interpreter not found: ${spec.file}`);
|
|
85
|
+
return new Error(`Shell: failed to run command (${err.code ?? err.message}): ${spec.command}`);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function runBuffered(spec: SpawnSpec): Promise<BufferedResult> {
|
|
89
|
+
return new Promise<BufferedResult>((resolve, reject) => {
|
|
90
|
+
const child = spawnChild(spec);
|
|
91
|
+
let stdout = "";
|
|
92
|
+
let stderr = "";
|
|
93
|
+
let timedOut = false;
|
|
94
|
+
let cancelled = false;
|
|
95
|
+
const timer = spec.timeoutMs
|
|
96
|
+
? setTimeout(() => {
|
|
97
|
+
timedOut = true;
|
|
98
|
+
killChild(child);
|
|
99
|
+
}, spec.timeoutMs)
|
|
100
|
+
: undefined;
|
|
101
|
+
timer?.unref?.();
|
|
102
|
+
const onAbort = () => {
|
|
103
|
+
cancelled = true;
|
|
104
|
+
killChild(child);
|
|
105
|
+
};
|
|
106
|
+
if (spec.signal?.aborted) onAbort();
|
|
107
|
+
else spec.signal?.addEventListener("abort", onAbort, { once: true });
|
|
108
|
+
const cleanup = () => {
|
|
109
|
+
if (timer) clearTimeout(timer);
|
|
110
|
+
spec.signal?.removeEventListener("abort", onAbort);
|
|
111
|
+
};
|
|
112
|
+
child.stdout?.setEncoding("utf8");
|
|
113
|
+
child.stdout?.on("data", (d: string) => {
|
|
114
|
+
stdout += d;
|
|
115
|
+
});
|
|
116
|
+
child.stderr?.setEncoding("utf8");
|
|
117
|
+
child.stderr?.on("data", (d: string) => {
|
|
118
|
+
stderr += d;
|
|
119
|
+
});
|
|
120
|
+
child.on("error", (err: NodeJS.ErrnoException) => {
|
|
121
|
+
cleanup();
|
|
122
|
+
reject(spawnError(err, spec));
|
|
123
|
+
});
|
|
124
|
+
child.on("close", (code: number | null, signal: NodeJS.Signals | null) => {
|
|
125
|
+
cleanup();
|
|
126
|
+
if (timedOut) {
|
|
127
|
+
reject(new Error(`Shell command timed out after ${spec.timeoutMs}ms: ${spec.command}`));
|
|
128
|
+
return;
|
|
129
|
+
}
|
|
130
|
+
if (cancelled) {
|
|
131
|
+
reject(new Error(`Shell command cancelled: ${spec.command}`));
|
|
132
|
+
return;
|
|
133
|
+
}
|
|
134
|
+
if (signal) {
|
|
135
|
+
reject(new Error(`Shell command terminated by signal ${signal}: ${spec.command}`));
|
|
136
|
+
return;
|
|
137
|
+
}
|
|
138
|
+
resolve({ stdout, stderr, exitCode: code ?? 0 });
|
|
139
|
+
});
|
|
140
|
+
writeStdin(child, spec.stdin);
|
|
141
|
+
});
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function runStream(spec: SpawnSpec): AsyncIterable<StreamPart> {
|
|
145
|
+
return {
|
|
146
|
+
async *[Symbol.asyncIterator]() {
|
|
147
|
+
const child = spawnChild(spec);
|
|
148
|
+
const queue: StreamPart[] = [];
|
|
149
|
+
let finished = false;
|
|
150
|
+
let wake: (() => void) | null = null;
|
|
151
|
+
const wakeUp = () => {
|
|
152
|
+
if (wake) {
|
|
153
|
+
const w = wake;
|
|
154
|
+
wake = null;
|
|
155
|
+
w();
|
|
156
|
+
}
|
|
157
|
+
};
|
|
158
|
+
const push = (part: StreamPart) => {
|
|
159
|
+
queue.push(part);
|
|
160
|
+
// Backpressure: pause the source streams while output is buffered; the
|
|
161
|
+
// consumer resumes them after it drains the queue, bounding memory.
|
|
162
|
+
child.stdout?.pause();
|
|
163
|
+
child.stderr?.pause();
|
|
164
|
+
wakeUp();
|
|
165
|
+
};
|
|
166
|
+
let timedOut = false;
|
|
167
|
+
let cancelled = false;
|
|
168
|
+
const timer = spec.timeoutMs
|
|
169
|
+
? setTimeout(() => {
|
|
170
|
+
timedOut = true;
|
|
171
|
+
killChild(child);
|
|
172
|
+
}, spec.timeoutMs)
|
|
173
|
+
: undefined;
|
|
174
|
+
timer?.unref?.();
|
|
175
|
+
const onAbort = () => {
|
|
176
|
+
cancelled = true;
|
|
177
|
+
killChild(child);
|
|
178
|
+
};
|
|
179
|
+
if (spec.signal?.aborted) onAbort();
|
|
180
|
+
else spec.signal?.addEventListener("abort", onAbort, { once: true });
|
|
181
|
+
child.stdout?.setEncoding("utf8");
|
|
182
|
+
child.stdout?.on("data", (d: string) => push({ type: "stdout", chunk: d }));
|
|
183
|
+
child.stderr?.setEncoding("utf8");
|
|
184
|
+
child.stderr?.on("data", (d: string) => push({ type: "stderr", chunk: d }));
|
|
185
|
+
child.on("error", (err: NodeJS.ErrnoException) => {
|
|
186
|
+
push({ type: "error", error: { message: err.message, code: err.code } });
|
|
187
|
+
finished = true;
|
|
188
|
+
wakeUp();
|
|
189
|
+
});
|
|
190
|
+
child.on("close", (code: number | null, signal: NodeJS.Signals | null) => {
|
|
191
|
+
if (timedOut) {
|
|
192
|
+
push({ type: "error", error: { message: `Shell command timed out after ${spec.timeoutMs}ms`, code: "ETIMEDOUT" } });
|
|
193
|
+
} else if (cancelled) {
|
|
194
|
+
push({ type: "error", error: { message: "Shell command cancelled", code: "ECANCELLED" } });
|
|
195
|
+
} else {
|
|
196
|
+
push({ type: "exit", exitCode: code ?? 0, signal: signal ?? null });
|
|
197
|
+
}
|
|
198
|
+
finished = true;
|
|
199
|
+
wakeUp();
|
|
200
|
+
});
|
|
201
|
+
writeStdin(child, spec.stdin);
|
|
202
|
+
try {
|
|
203
|
+
while (true) {
|
|
204
|
+
while (queue.length) yield queue.shift() as StreamPart;
|
|
205
|
+
if (finished) return;
|
|
206
|
+
// Drained — resume the source and wait for the next record.
|
|
207
|
+
child.stdout?.resume();
|
|
208
|
+
child.stderr?.resume();
|
|
209
|
+
await new Promise<void>((r) => {
|
|
210
|
+
wake = r;
|
|
211
|
+
});
|
|
212
|
+
}
|
|
213
|
+
} finally {
|
|
214
|
+
// Early termination (consumer break/return, a downstream throw, the
|
|
215
|
+
// enclosing sequence failing) lands here without `close` having fired —
|
|
216
|
+
// tear down the whole process group and clear timer/abort listener.
|
|
217
|
+
if (timer) clearTimeout(timer);
|
|
218
|
+
spec.signal?.removeEventListener("abort", onAbort);
|
|
219
|
+
killChild(child);
|
|
220
|
+
}
|
|
221
|
+
},
|
|
222
|
+
};
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
class LocalShellHost implements ShellHost, ResourceInstance {
|
|
226
|
+
constructor(
|
|
227
|
+
private readonly cwd: string,
|
|
228
|
+
private readonly shell: string,
|
|
229
|
+
private readonly baseEnv: Record<string, string>,
|
|
230
|
+
private readonly hostEnv: Record<string, string>,
|
|
231
|
+
) {}
|
|
232
|
+
|
|
233
|
+
snapshot(): Record<string, unknown> {
|
|
234
|
+
return {};
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
exec(command: string, options: RunOptions, ctx?: InvokeContext): ExecutionHandle {
|
|
238
|
+
const { file, args } = shellInvocation(this.shell, command);
|
|
239
|
+
const spec: SpawnSpec = {
|
|
240
|
+
file,
|
|
241
|
+
args,
|
|
242
|
+
cwd: this.cwd,
|
|
243
|
+
env: { ...this.hostEnv, ...this.baseEnv, ...(options.env ?? {}) },
|
|
244
|
+
signal: ctx?.cancellation.signal,
|
|
245
|
+
timeoutMs: options.timeoutMs,
|
|
246
|
+
stdin: options.stdin,
|
|
247
|
+
command,
|
|
248
|
+
};
|
|
249
|
+
return {
|
|
250
|
+
buffered: () => runBuffered(spec),
|
|
251
|
+
stream: () => runStream(spec),
|
|
252
|
+
};
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
export function register(): void {}
|
|
257
|
+
|
|
258
|
+
export async function create(
|
|
259
|
+
resource: LocalHostManifest,
|
|
260
|
+
ctx: ResourceContext,
|
|
261
|
+
): Promise<LocalShellHost> {
|
|
262
|
+
const hostEnv = toEnvRecord(ctx.env);
|
|
263
|
+
return new LocalShellHost(
|
|
264
|
+
resource.cwd ?? ".",
|
|
265
|
+
resource.shell ?? defaultShell(hostEnv),
|
|
266
|
+
resource.env ?? {},
|
|
267
|
+
hostEnv,
|
|
268
|
+
);
|
|
269
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import type { InvokeContext, ResourceContext, ResourceInstance } from "@telorun/sdk";
|
|
2
|
+
import type { BufferedResult, ShellHost } from "./shell-host.js";
|
|
3
|
+
import { resolveShellHost } from "./shell-host-ref.js";
|
|
4
|
+
|
|
5
|
+
interface HostRef {
|
|
6
|
+
name: string;
|
|
7
|
+
alias?: string;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
interface ShellCommandManifest {
|
|
11
|
+
metadata: { name: string; module: string };
|
|
12
|
+
host?: ShellHost | HostRef;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
interface CommandInput {
|
|
16
|
+
command: string;
|
|
17
|
+
env?: Record<string, string>;
|
|
18
|
+
stdin?: string;
|
|
19
|
+
timeoutMs?: number;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function requireCommand(input: CommandInput): string {
|
|
23
|
+
if (typeof input?.command !== "string" || input.command.length === 0) {
|
|
24
|
+
throw new Error("Shell.Command: 'command' input is required and must be a non-empty string");
|
|
25
|
+
}
|
|
26
|
+
return input.command;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
class ShellCommandResource implements ResourceInstance {
|
|
30
|
+
constructor(
|
|
31
|
+
private readonly manifest: ShellCommandManifest,
|
|
32
|
+
private readonly ctx: ResourceContext,
|
|
33
|
+
) {}
|
|
34
|
+
|
|
35
|
+
async invoke(input: CommandInput, ctx?: InvokeContext): Promise<BufferedResult> {
|
|
36
|
+
const host = resolveShellHost(this.manifest.host, this.ctx);
|
|
37
|
+
const command = requireCommand(input);
|
|
38
|
+
return host
|
|
39
|
+
.exec(command, { env: input.env, stdin: input.stdin, timeoutMs: input.timeoutMs }, ctx)
|
|
40
|
+
.buffered();
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function register(): void {}
|
|
45
|
+
|
|
46
|
+
export async function create(
|
|
47
|
+
resource: ShellCommandManifest,
|
|
48
|
+
ctx: ResourceContext,
|
|
49
|
+
): Promise<ShellCommandResource> {
|
|
50
|
+
return new ShellCommandResource(resource, ctx);
|
|
51
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { Stream, type InvokeContext, type ResourceContext, type ResourceInstance } from "@telorun/sdk";
|
|
2
|
+
import type { ShellHost, StreamPart } from "./shell-host.js";
|
|
3
|
+
import { resolveShellHost } from "./shell-host-ref.js";
|
|
4
|
+
|
|
5
|
+
interface HostRef {
|
|
6
|
+
name: string;
|
|
7
|
+
alias?: string;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
interface ShellCommandStreamManifest {
|
|
11
|
+
metadata: { name: string; module: string };
|
|
12
|
+
host?: ShellHost | HostRef;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
interface CommandInput {
|
|
16
|
+
command: string;
|
|
17
|
+
env?: Record<string, string>;
|
|
18
|
+
stdin?: string;
|
|
19
|
+
timeoutMs?: number;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function requireCommand(input: CommandInput): string {
|
|
23
|
+
if (typeof input?.command !== "string" || input.command.length === 0) {
|
|
24
|
+
throw new Error("Shell.CommandStream: 'command' input is required and must be a non-empty string");
|
|
25
|
+
}
|
|
26
|
+
return input.command;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
class ShellCommandStreamResource implements ResourceInstance {
|
|
30
|
+
constructor(
|
|
31
|
+
private readonly manifest: ShellCommandStreamManifest,
|
|
32
|
+
private readonly ctx: ResourceContext,
|
|
33
|
+
) {}
|
|
34
|
+
|
|
35
|
+
async invoke(input: CommandInput, ctx?: InvokeContext): Promise<{ output: Stream<StreamPart> }> {
|
|
36
|
+
const host = resolveShellHost(this.manifest.host, this.ctx);
|
|
37
|
+
const command = requireCommand(input);
|
|
38
|
+
const iterable = host
|
|
39
|
+
.exec(command, { env: input.env, stdin: input.stdin, timeoutMs: input.timeoutMs }, ctx)
|
|
40
|
+
.stream();
|
|
41
|
+
return { output: new Stream(iterable) };
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function register(): void {}
|
|
46
|
+
|
|
47
|
+
export async function create(
|
|
48
|
+
resource: ShellCommandStreamManifest,
|
|
49
|
+
ctx: ResourceContext,
|
|
50
|
+
): Promise<ShellCommandStreamResource> {
|
|
51
|
+
return new ShellCommandStreamResource(resource, ctx);
|
|
52
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import type { ResourceContext } from "@telorun/sdk";
|
|
2
|
+
import type { ShellHost } from "./shell-host.js";
|
|
3
|
+
|
|
4
|
+
interface HostRef {
|
|
5
|
+
name: string;
|
|
6
|
+
alias?: string;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
function isShellHost(value: unknown): value is ShellHost {
|
|
10
|
+
return typeof (value as ShellHost | undefined)?.exec === "function";
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Resolve the `host` field of a Shell operation to a live `Shell.Host`. The
|
|
15
|
+
* value is either the Phase-5-injected instance or — for a cross-module
|
|
16
|
+
* `!ref Alias.host` reached through a nested library — the raw `{name, alias}`
|
|
17
|
+
* ref, which must route through the import's exported scope. Mirrors
|
|
18
|
+
* `resolveSqlConnection`.
|
|
19
|
+
*/
|
|
20
|
+
export function resolveShellHost(value: ShellHost | HostRef | undefined, ctx: ResourceContext): ShellHost {
|
|
21
|
+
if (!value) {
|
|
22
|
+
throw new Error("Shell: 'host' is required");
|
|
23
|
+
}
|
|
24
|
+
if (isShellHost(value)) {
|
|
25
|
+
return value;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const ref = value as HostRef;
|
|
29
|
+
if (typeof ref.name !== "string") {
|
|
30
|
+
throw new Error("Shell: invalid host reference");
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
if (ref.alias && ref.alias !== "Self") {
|
|
34
|
+
const instance = ctx.moduleContext.resolveImportedInstance(ref.alias, ref.name);
|
|
35
|
+
if (!isShellHost(instance)) {
|
|
36
|
+
throw new Error(
|
|
37
|
+
`Shell: host reference '${ref.alias}.${ref.name}' did not resolve to a Shell.Host instance.`,
|
|
38
|
+
);
|
|
39
|
+
}
|
|
40
|
+
return instance;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const instance = ctx.moduleContext.getInstance(ref.name);
|
|
44
|
+
if (!isShellHost(instance)) {
|
|
45
|
+
throw new Error(`Shell: host reference '${ref.name}' did not resolve to a Shell.Host instance.`);
|
|
46
|
+
}
|
|
47
|
+
return instance;
|
|
48
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import type { InvokeContext } from "@telorun/sdk";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The transport-neutral `Shell.Host` contract — the module's central seam.
|
|
5
|
+
* Every driver (the bundled local host, and future `shell-ssh` / Docker / k8s
|
|
6
|
+
* modules) implements `ShellHost`; the generic operations (`Shell.Command`,
|
|
7
|
+
* `Shell.CommandStream`) and the ref resolver depend only on this file, never
|
|
8
|
+
* on a concrete driver. Mirrors `sql`'s `sqlite-driver-interface.ts` split.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
/** One record in a `Shell.CommandStream` output stream. */
|
|
12
|
+
export type StreamPart =
|
|
13
|
+
| { type: "stdout"; chunk: string }
|
|
14
|
+
| { type: "stderr"; chunk: string }
|
|
15
|
+
| { type: "exit"; exitCode: number; signal: string | null }
|
|
16
|
+
| { type: "error"; error: { message: string; code?: string } };
|
|
17
|
+
|
|
18
|
+
export interface BufferedResult {
|
|
19
|
+
stdout: string;
|
|
20
|
+
stderr: string;
|
|
21
|
+
exitCode: number;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export interface RunOptions {
|
|
25
|
+
/** Per-call environment overlay, merged over the host's base env. */
|
|
26
|
+
env?: Record<string, string>;
|
|
27
|
+
/** Written to the child's stdin, which is then closed. */
|
|
28
|
+
stdin?: string;
|
|
29
|
+
/** Kill the child and fail after this many milliseconds. */
|
|
30
|
+
timeoutMs?: number;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* The execution primitive every driver implements. `exec` composes the command
|
|
35
|
+
* for its target and returns a lazy handle the operations consume either
|
|
36
|
+
* buffered (`Shell.Command`) or streamed (`Shell.CommandStream`) — exactly one
|
|
37
|
+
* per call. The host owns all composition (`<shell> -c <command>`, env merge,
|
|
38
|
+
* cwd) so the operations stay backend-agnostic. (Named `exec`, not `run`, to
|
|
39
|
+
* avoid the Runnable capability's reserved `run()`.)
|
|
40
|
+
*/
|
|
41
|
+
export interface ShellHost {
|
|
42
|
+
exec(command: string, options: RunOptions, ctx?: InvokeContext): ExecutionHandle;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export interface ExecutionHandle {
|
|
46
|
+
/** Spawn, collect all output, and resolve once the process exits. */
|
|
47
|
+
buffered(): Promise<BufferedResult>;
|
|
48
|
+
/** Spawn on iteration; yield stdout/stderr records then a terminal exit/error. */
|
|
49
|
+
stream(): AsyncIterable<StreamPart>;
|
|
50
|
+
}
|