@xandreed/plugin-workspace 0.14.0 → 0.15.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/README.md +1 -1
- package/dist/bash-tool.adapter.js +10 -9
- package/dist/bash.adapter.d.ts +60 -8
- package/dist/bash.adapter.js +56 -13
- package/dist/file-system.adapter.d.ts +5 -1
- package/dist/file-system.adapter.js +1 -1
- package/dist/image-resizer.adapter.d.ts +5 -0
- package/dist/image-resizer.adapter.js +68 -0
- package/dist/image-resizer.entity.d.ts +23 -0
- package/dist/image-resizer.entity.functions.d.ts +15 -0
- package/dist/image-resizer.entity.functions.js +4 -0
- package/dist/image-resizer.entity.js +7 -0
- package/dist/index.d.ts +4 -2
- package/dist/index.js +4 -1
- package/dist/local-system.entity.d.ts +10 -3
- package/dist/local-system.entity.functions.d.ts +3 -2
- package/dist/local-system.entity.functions.js +16 -2
- package/dist/local-system.entity.js +3 -2
- package/dist/plugin.adapter.d.ts +103 -40
- package/dist/plugin.adapter.js +16 -5
- package/dist/read-tools.adapter.d.ts +10 -4
- package/dist/read-tools.adapter.js +40 -31
- package/dist/ripgrep.adapter.d.ts +74 -0
- package/dist/ripgrep.adapter.js +56 -0
- package/dist/ripgrep.entity.d.ts +63 -0
- package/dist/ripgrep.entity.functions.d.ts +87 -0
- package/dist/ripgrep.entity.functions.js +62 -0
- package/dist/ripgrep.entity.js +10 -0
- package/dist/shell.entity.functions.d.ts +2 -0
- package/dist/shell.entity.functions.js +1 -0
- package/dist/skills/skills-directory.adapter.d.ts +20 -14
- package/dist/skills/skills-directory.adapter.js +2 -2
- package/dist/skills/skills-directory.entity.d.ts +8 -5
- package/dist/skills/skills-directory.entity.functions.d.ts +58 -3
- package/dist/skills/skills-directory.entity.functions.js +5 -5
- package/dist/text-edit.entity.functions.js +3 -3
- package/dist/text-search.entity.functions.d.ts +7 -0
- package/dist/text-search.entity.functions.js +2 -2
- package/dist/workspace-path.entity.d.ts +21 -0
- package/dist/workspace-path.entity.functions.d.ts +39 -9
- package/dist/workspace-path.entity.functions.js +43 -18
- package/dist/workspace-path.entity.js +3 -2
- package/dist/workspace-tools.adapter.d.ts +48 -3
- package/dist/workspace-tools.adapter.js +23 -9
- package/dist/workspace-tools.entity.d.ts +136 -17
- package/dist/workspace-tools.entity.functions.d.ts +80 -5
- package/dist/workspace-tools.entity.functions.js +87 -31
- package/dist/workspace-tools.entity.js +26 -5
- package/dist/write-tools.adapter.js +10 -8
- package/package.json +6 -5
- package/dist/bash.adapter.test.d.ts +0 -1
- package/dist/file-system.adapter.test.d.ts +0 -1
- package/dist/local-system.entity.functions.test.d.ts +0 -1
- package/dist/plugin.adapter.test.d.ts +0 -1
- package/dist/skills/skills-directory.adapter.test.d.ts +0 -1
- package/dist/skills/skills-directory.entity.functions.test.d.ts +0 -1
- package/dist/text-edit.entity.functions.test.d.ts +0 -1
- package/dist/text-search.entity.functions.test.d.ts +0 -1
- package/dist/text-window.entity.functions.test.d.ts +0 -1
- package/dist/workspace-path.entity.functions.test.d.ts +0 -1
- package/dist/workspace-tools.adapter.test.d.ts +0 -1
- package/dist/workspace-tools.entity.functions.test.d.ts +0 -1
- package/dist/workspace-tools.journey.test.d.ts +0 -1
package/README.md
CHANGED
|
@@ -1,21 +1,22 @@
|
|
|
1
1
|
import { Tool } from "effect/ai";
|
|
2
|
-
import { Clock, Effect, Option, Random, Stream } from "effect";
|
|
3
|
-
import { Bash, CurrentToolInvocation, defineTool, Failure, publishToolProgress, RunContext } from "@xandreed/core";
|
|
2
|
+
import { Array as Arr, Clock, Effect, Option, Order, Random, Stream } from "effect";
|
|
3
|
+
import { Bash, BashEvent, CurrentToolInvocation, defineTool, Failure, publishToolProgress, RunContext } from "@xandreed/core";
|
|
4
4
|
import { clipHead, tailText, utf8Length } from "./text-window.entity.functions.js";
|
|
5
5
|
import { openWorkspace } from "./workspace-path.entity.functions.js";
|
|
6
6
|
import { BashParams, BashToolResult, WORKSPACE_PERMISSIONS } from "./workspace-tools.entity.js";
|
|
7
7
|
import { bashEnvironment, compactBash, renderBash, toolDescriptions } from "./workspace-tools.entity.functions.js";
|
|
8
|
-
import { failure } from "./read-tools.adapter.js";
|
|
9
|
-
const BashTool = (description) => Tool.make("bash", { description, parameters: BashParams, success: BashToolResult, failure: Failure, failureMode: "return" }),
|
|
8
|
+
import { failure, WORKSPACE_SERVICES } from "./read-tools.adapter.js";
|
|
9
|
+
const BashTool = (description) => Tool.make("bash", { description, parameters: BashParams, success: BashToolResult, failure: Failure, failureMode: "return" }), runsOf = (chunks) => chunks.reduce((runs, chunk) => {
|
|
10
10
|
const last = runs.at(-1);
|
|
11
11
|
if (last !== void 0 && last.stream === chunk.stream)
|
|
12
12
|
runs.splice(-1, 1, { stream: chunk.stream, text: last.text + chunk.text });
|
|
13
13
|
else
|
|
14
14
|
runs.push({ stream: chunk.stream, text: chunk.text });
|
|
15
15
|
return runs;
|
|
16
|
-
}, []), prune = (access, dir, keep) => access.fs.list(dir).pipe(Effect.map((entries) => entries.filter((entry) => entry.kind === "file" && /^bash-.*\.log$/.test(entry.name)).map((entry) => entry.name).
|
|
16
|
+
}, []), prune = (access, dir, keep) => access.fs.list(dir).pipe(Effect.map((entries) => Arr.sort(entries.filter((entry) => entry.kind === "file" && /^bash-.*\.log$/.test(entry.name)).map((entry) => entry.name), Order.String)), Effect.flatMap((names) => Effect.forEach(names.slice(0, Math.max(0, names.length - keep)), (name) => access.fs.remove(access.path.join(dir, name)), { concurrency: 1, discard: !0 })), Effect.ignore({ log: "Warn", message: `the older spill files in ${dir} were not pruned` }));
|
|
17
17
|
export const bashTool = (context) => defineTool({
|
|
18
18
|
tool: BashTool(toolDescriptions(context.config).bash),
|
|
19
|
+
requires: [...WORKSPACE_SERVICES, Bash],
|
|
19
20
|
handler: (params) => Effect.gen(function* () {
|
|
20
21
|
const { config } = context, access = yield* openWorkspace(context.path), bash = yield* Bash, run = yield* Effect.serviceOption(RunContext), invocation = yield* Effect.service(CurrentToolInvocation), timeoutMs = Math.min(params.timeout === void 0 ? config.bashTimeoutMs : params.timeout * 1000, config.bashMaxTimeoutMs), options = {
|
|
21
22
|
cwd: access.root,
|
|
@@ -40,12 +41,12 @@ export const bashTool = (context) => defineTool({
|
|
|
40
41
|
const next = fitting(spill.bytes, text);
|
|
41
42
|
return (next.piece.length === 0 ? Effect.void : access.fs.append(spill.file, next.piece).pipe(Effect.catch((error) => Effect.logWarning(`bash: could not add to ${spill.file}: ${error.message}`)))).pipe(Effect.as({ ...spill, bytes: next.bytes, cut: next.cut }));
|
|
42
43
|
}, over = (state) => state.bytes - state.crlf - 1 > config.maxBytes || state.breaks > config.maxLines, step = (state, group) => Effect.gen(function* () {
|
|
43
|
-
const chunks = group.filter(
|
|
44
|
-
yield* Effect.forEach(runsOf(chunks), (current) => publishToolProgress(current.stream, current.text), { discard: !0 });
|
|
44
|
+
const chunks = group.filter(BashEvent.guards.BashChunk);
|
|
45
|
+
yield* Effect.forEach(runsOf(chunks), (current) => publishToolProgress(current.stream, current.text), { concurrency: 1, discard: !0 });
|
|
45
46
|
const text = chunks.map((chunk) => chunk.text).join(""), bytes = state.bytes + utf8Length(text), breaks = state.breaks + (text.split(`
|
|
46
47
|
`).length - 1), crlf = state.crlf + (text.split(`\r
|
|
47
48
|
`).length - 1) + (state.tail.endsWith("\r") && text.startsWith(`
|
|
48
|
-
`) ? 1 : 0), exit = Option.orElse(Option.fromNullishOr(group.find(
|
|
49
|
+
`) ? 1 : 0), exit = Option.orElse(Option.fromNullishOr(group.find(BashEvent.guards.BashExit)), () => state.exit), base = { tail: (state.tail + text).slice(-(config.maxBytes + 2)), bytes, breaks, crlf, open: text.length === 0 ? state.open : !text.endsWith(`
|
|
49
50
|
`), exit };
|
|
50
51
|
if (Option.isSome(state.spill))
|
|
51
52
|
return { ...base, whole: Option.none(), spill: Option.some(yield* addToSpill(state.spill.value, text)) };
|
|
@@ -67,6 +68,6 @@ export const bashTool = (context) => defineTool({
|
|
|
67
68
|
durationMs: Option.match(gathered.exit, { onNone: () => ended - started, onSome: (exit) => exit.durationMs })
|
|
68
69
|
};
|
|
69
70
|
}),
|
|
70
|
-
view: { version: "
|
|
71
|
+
view: { version: "2", render: renderBash, compact: compactBash },
|
|
71
72
|
annotations: { permissions: [WORKSPACE_PERMISSIONS.exec], pollable: !0 }
|
|
72
73
|
});
|
package/dist/bash.adapter.d.ts
CHANGED
|
@@ -1,8 +1,47 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import
|
|
1
|
+
import type { Readable } from "node:stream";
|
|
2
|
+
import { Cause, Effect, Layer, Option, Queue, Stream } from "effect";
|
|
3
|
+
import type { Context, Scope } from "effect";
|
|
3
4
|
import { Bash, BashError } from "@xandreed/core";
|
|
5
|
+
import type { BashChunk, BashEvent, BashExit, BashOptions } from "@xandreed/core";
|
|
4
6
|
import type { BashLiveOptions } from "./local-system.entity.js";
|
|
5
7
|
type BashShape = Context.Service.Shape<typeof Bash>;
|
|
8
|
+
/**
|
|
9
|
+
* The most events a command's stream holds before the command is made to
|
|
10
|
+
* wait: a pipe read is at most 64 KB on Node, so about 1 MB of output.
|
|
11
|
+
*/
|
|
12
|
+
export declare const OUTPUT_BUFFER = 16;
|
|
13
|
+
/** How a command's output pipes feed its stream (`relayOutput`). */
|
|
14
|
+
export interface OutputRelay {
|
|
15
|
+
/** Read `pipe` as `name`: UTF-8, a character split between reads arriving whole; an error on it fails the stream. */
|
|
16
|
+
readonly attach: (name: BashChunk["stream"], pipe: Readable) => void;
|
|
17
|
+
/** Stop pausing the pipes: what is left of the output is read at once. */
|
|
18
|
+
readonly flow: () => void;
|
|
19
|
+
/** The decoders' last characters, then `exit`, after everything before them; the stream ends after the exit. */
|
|
20
|
+
readonly finish: (exit: BashExit) => void;
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* The bridge from a command's output pipes to its stream's buffer (`queue`,
|
|
24
|
+
* which holds at most `OUTPUT_BUFFER` events). An event goes straight in
|
|
25
|
+
* while there is room and nothing waits before it. Otherwise it waits, in
|
|
26
|
+
* arrival order, and the pipe it came from is paused, so the command blocks
|
|
27
|
+
* on a full pipe instead of its output piling up in memory; a fiber of the
|
|
28
|
+
* stream's scope moves waiting events in as the reader takes them, and
|
|
29
|
+
* resumes their pipes. `flow` ends the pausing, for once the command has
|
|
30
|
+
* exited (what is left then is the pipes' content, and whatever a
|
|
31
|
+
* background process writes in `exitGraceMs`). An error on a pipe fails the
|
|
32
|
+
* stream with `SpawnFailed`.
|
|
33
|
+
*/
|
|
34
|
+
export declare const relayOutput: (queue: Queue.Queue<{
|
|
35
|
+
readonly _tag: "BashChunk";
|
|
36
|
+
readonly stream: "stdout" | "stderr";
|
|
37
|
+
readonly text: string;
|
|
38
|
+
} | {
|
|
39
|
+
readonly _tag: "BashExit";
|
|
40
|
+
readonly termination: "interrupted" | "exit" | "signal" | "timeout";
|
|
41
|
+
readonly exitCode: Option.Option<number>;
|
|
42
|
+
readonly signal: Option.Option<string>;
|
|
43
|
+
readonly durationMs: number;
|
|
44
|
+
}, BashError | Cause.Done<void>>) => Effect.Effect<OutputRelay, never, Scope.Scope>;
|
|
6
45
|
/**
|
|
7
46
|
* Commands on the local machine through `node:child_process`, for Node and
|
|
8
47
|
* Bun: `<shell> -c <command>`, detached into a process group of its own, in
|
|
@@ -12,18 +51,31 @@ type BashShape = Context.Service.Shape<typeof Bash>;
|
|
|
12
51
|
* `ShellMissing`. Each command checks its `cwd` first (`CwdMissing`); its
|
|
13
52
|
* stdin is written and closed (a command that never reads it is fine), its
|
|
14
53
|
* stdout and stderr decoded as UTF-8 per stream, a character split between
|
|
15
|
-
* reads arriving whole.
|
|
54
|
+
* reads arriving whole. While the command runs, its output is read only as
|
|
55
|
+
* fast as the stream is (`relayOutput`): on Node a reader that falls behind
|
|
56
|
+
* holds the command back on a full pipe, so output is never gathered
|
|
57
|
+
* without bound (Bun reads a child's pipes ahead into its own memory, the
|
|
58
|
+
* reader notwithstanding). A timeout, or a caller that stops reading before
|
|
16
59
|
* the end, kills the whole group; with `killBackground` (the default) so
|
|
17
60
|
* does the command's return. The stream ends once the command has exited
|
|
18
61
|
* and its output has closed, or `exitGraceMs` after the exit if something
|
|
19
62
|
* still holds the output open; the adapter then closes its end of the
|
|
20
63
|
* output pipes, so a background process kept with `killBackground: false`
|
|
21
64
|
* that still writes to them gets `EPIPE` or dies of `SIGPIPE` unless it was
|
|
22
|
-
* started with its own output (`cmd > log 2>&1 &`).
|
|
23
|
-
*
|
|
24
|
-
* on
|
|
65
|
+
* started with its own output (`cmd > log 2>&1 &`). POSIX only (Linux and
|
|
66
|
+
* macOS): it relies on process groups. Passes `bashConformance` on Bun and
|
|
67
|
+
* Node (on Bun without the hold-back half of `backpressure`).
|
|
68
|
+
*/
|
|
69
|
+
export declare const makeBash: (options?: BashLiveOptions | undefined) => Effect.Effect<{
|
|
70
|
+
readonly stream: (command: string, options: BashOptions) => Stream.Stream<BashEvent, BashError>;
|
|
71
|
+
readonly exec: (command: string, options: BashOptions) => Effect.Effect<import("@xandreed/core").BashResult, BashError>;
|
|
72
|
+
}, BashError, never>;
|
|
73
|
+
/** Whether a `Bash` service is one `makeBash` built, so its commands see the same files as `localFileSystem`. */
|
|
74
|
+
export declare const runsOnThisMachine: (bash: BashShape) => boolean;
|
|
75
|
+
/**
|
|
76
|
+
* The local `Bash` (`makeBash`) as a layer; building it fails with `ShellMissing` when the shell is not installed.
|
|
77
|
+
*
|
|
78
|
+
* @public
|
|
25
79
|
*/
|
|
26
|
-
export declare const makeBash: (options?: BashLiveOptions) => Effect.Effect<BashShape, BashError>;
|
|
27
|
-
/** The local `Bash` (`makeBash`) as a layer; building it fails with `ShellMissing` when the shell is not installed. */
|
|
28
80
|
export declare const BashLive: (options?: BashLiveOptions) => Layer.Layer<Bash, BashError>;
|
|
29
81
|
export {};
|
package/dist/bash.adapter.js
CHANGED
|
@@ -5,13 +5,16 @@ import { Cause, Clock, Deferred, Effect, Layer, MutableRef, Option, Queue, Resul
|
|
|
5
5
|
import { Bash, BashError, collectBashResult, resolveBashEnv } from "@xandreed/core";
|
|
6
6
|
import { bashExitOf, errorMessageOf, executableCandidates, isClosedInput } from "./local-system.entity.functions.js";
|
|
7
7
|
import { localSystemDefaults } from "./local-system.entity.js";
|
|
8
|
-
const isExecutableFile = (path) => Effect.tryPromise(() => access(path, constants.X_OK)).pipe(Effect.andThen(Effect.tryPromise(() => stat(path))), Effect.map((info) => info.isFile()), Effect.orElseSucceed(() => !1)), resolveShell = (shell, path) => Effect.findFirst(executableCandidates(shell, path), isExecutableFile).pipe(Effect.flatMap(Option.match({
|
|
8
|
+
const isExecutableFile = (path) => Effect.tryPromise(() => access(path, constants.X_OK)).pipe(Effect.andThen(Effect.tryPromise(() => stat(path))), Effect.map((info) => info.isFile()), Effect.tapError(() => Effect.logDebug(`${path} is not an executable file`)), Effect.orElseSucceed(() => !1)), resolveShell = (shell, path) => Effect.findFirst(executableCandidates(shell, path), isExecutableFile).pipe(Effect.flatMap(Option.match({
|
|
9
9
|
onNone: () => Effect.fail(new BashError({
|
|
10
10
|
reason: "ShellMissing",
|
|
11
11
|
message: `no executable ${shell} ${shell.startsWith("/") ? "at that path" : `on the PATH (${Option.getOrElse(path, () => "unset")})`}`
|
|
12
12
|
})),
|
|
13
13
|
onSome: Effect.succeed
|
|
14
|
-
}))), checkCwd = (cwd) =>
|
|
14
|
+
}))), checkCwd = (cwd) => {
|
|
15
|
+
const missing = () => new BashError({ reason: "CwdMissing", message: `cwd is not an existing absolute directory: ${cwd}` });
|
|
16
|
+
return cwd.startsWith("/") ? Effect.tryPromise({ try: () => stat(cwd), catch: missing }).pipe(Effect.flatMap((info) => info.isDirectory() ? Effect.void : Effect.fail(missing()))) : Effect.fail(missing());
|
|
17
|
+
}, killGroup = (child, exited) => Option.match(Option.fromNullishOr(child.pid), {
|
|
15
18
|
onNone: () => {
|
|
16
19
|
return;
|
|
17
20
|
},
|
|
@@ -21,10 +24,42 @@ const isExecutableFile = (path) => Effect.tryPromise(() => access(path, constant
|
|
|
21
24
|
Result.try(() => process.kill(pid, "SIGKILL"));
|
|
22
25
|
}
|
|
23
26
|
});
|
|
24
|
-
export const
|
|
27
|
+
export const OUTPUT_BUFFER = 16, relayOutput = Effect.fnUntraced(function* (queue) {
|
|
28
|
+
const waiting = yield* Queue.unbounded(), behind = MutableRef.make(0), flowing = MutableRef.make(!1), pipes = MutableRef.make([]), decoders = { stdout: new TextDecoder, stderr: new TextDecoder }, wait = (event, pipe) => {
|
|
29
|
+
if (Option.isSome(pipe) && !MutableRef.get(flowing))
|
|
30
|
+
pipe.value.pause();
|
|
31
|
+
MutableRef.increment(behind);
|
|
32
|
+
Queue.offerUnsafe(waiting, { event, pipe });
|
|
33
|
+
}, deliver = (event, pipe) => {
|
|
34
|
+
if (MutableRef.get(behind) > 0 || !Queue.offerUnsafe(queue, event))
|
|
35
|
+
wait(event, Option.some(pipe));
|
|
36
|
+
}, chunk = (name, text) => text.length === 0 ? Option.none() : Option.some({ _tag: "BashChunk", stream: name, text });
|
|
37
|
+
yield* Queue.take(waiting).pipe(Effect.flatMap(({ event, pipe }) => Queue.offer(queue, event).pipe(Effect.andThen(Effect.sync(() => {
|
|
38
|
+
MutableRef.decrement(behind);
|
|
39
|
+
Option.map(pipe, (readable) => readable.resume());
|
|
40
|
+
if (event._tag === "BashExit")
|
|
41
|
+
Queue.endUnsafe(queue);
|
|
42
|
+
})))), Effect.forever, Effect.forkScoped);
|
|
43
|
+
return {
|
|
44
|
+
attach: (name, pipe) => {
|
|
45
|
+
MutableRef.update(pipes, (all) => [...all, pipe]);
|
|
46
|
+
pipe.on("data", (bytes) => Option.map(chunk(name, decoders[name].decode(bytes, { stream: !0 })), (event) => deliver(event, pipe)));
|
|
47
|
+
pipe.on("error", (error) => Queue.failCauseUnsafe(queue, Cause.fail(new BashError({ reason: "SpawnFailed", message: `could not read the command's ${name}: ${errorMessageOf(error)}` }))));
|
|
48
|
+
},
|
|
49
|
+
flow: () => {
|
|
50
|
+
MutableRef.set(flowing, !0);
|
|
51
|
+
MutableRef.get(pipes).forEach((pipe) => pipe.resume());
|
|
52
|
+
},
|
|
53
|
+
finish: (exit) => {
|
|
54
|
+
Option.map(chunk("stdout", decoders.stdout.decode()), (event) => wait(event, Option.none()));
|
|
55
|
+
Option.map(chunk("stderr", decoders.stderr.decode()), (event) => wait(event, Option.none()));
|
|
56
|
+
wait(exit, Option.none());
|
|
57
|
+
}
|
|
58
|
+
};
|
|
59
|
+
}), makeBash = Effect.fnUntraced(function* (options = {}) {
|
|
25
60
|
const { shell, killBackground, exitGraceMs } = { ...localSystemDefaults, ...options }, parentEnv = () => options.parentEnv ?? process.env, shellPath = yield* resolveShell(shell, Option.fromNullishOr(parentEnv().PATH)), stream = (command, run) => Stream.callback((queue) => Effect.gen(function* () {
|
|
26
61
|
yield* checkCwd(run.cwd);
|
|
27
|
-
const clock = yield* Effect.clockWith((current) => Effect.succeed(current)), exited = yield* Deferred.make(), closed = yield* Deferred.make(), finished = MutableRef.make(!1),
|
|
62
|
+
const clock = yield* Effect.clockWith((current) => Effect.succeed(current)), exited = yield* Deferred.make(), closed = yield* Deferred.make(), finished = MutableRef.make(!1), output = yield* relayOutput(queue), started = yield* Clock.currentTimeMillis, child = yield* Effect.acquireRelease(Effect.try({
|
|
28
63
|
try: () => {
|
|
29
64
|
const spawned = spawn(shellPath, ["-c", command], {
|
|
30
65
|
cwd: run.cwd,
|
|
@@ -32,8 +67,10 @@ export const makeBash = (options = {}) => Effect.gen(function* () {
|
|
|
32
67
|
detached: !0,
|
|
33
68
|
stdio: [run.stdin === void 0 ? "ignore" : "pipe", "pipe", "pipe"]
|
|
34
69
|
});
|
|
35
|
-
|
|
36
|
-
|
|
70
|
+
if (spawned.stdout !== null)
|
|
71
|
+
output.attach("stdout", spawned.stdout);
|
|
72
|
+
if (spawned.stderr !== null)
|
|
73
|
+
output.attach("stderr", spawned.stderr);
|
|
37
74
|
spawned.once("error", (error) => Deferred.doneUnsafe(exited, Effect.fail(new BashError({ reason: "SpawnFailed", message: errorMessageOf(error) }))));
|
|
38
75
|
spawned.once("exit", (code, signal) => Deferred.doneUnsafe(exited, Effect.succeed({ code, signal, at: clock.currentTimeMillisUnsafe() })));
|
|
39
76
|
spawned.once("close", () => Deferred.doneUnsafe(closed, Effect.void));
|
|
@@ -54,16 +91,22 @@ export const makeBash = (options = {}) => Effect.gen(function* () {
|
|
|
54
91
|
})));
|
|
55
92
|
if (killBackground)
|
|
56
93
|
killGroup(child, !0);
|
|
94
|
+
output.flow();
|
|
57
95
|
const drained = yield* Deferred.await(closed).pipe(Effect.timeoutOption(exitGraceMs));
|
|
58
96
|
if (Option.isNone(drained)) {
|
|
59
97
|
child.stdout?.destroy();
|
|
60
98
|
child.stderr?.destroy();
|
|
61
99
|
}
|
|
62
|
-
emit("stdout", decoders.stdout.decode());
|
|
63
|
-
emit("stderr", decoders.stderr.decode());
|
|
64
100
|
MutableRef.set(finished, !0);
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
101
|
+
output.finish(bashExitOf({ code: end.code, signal: end.signal, timedOut, durationMs: end.at - started }));
|
|
102
|
+
}), { bufferSize: OUTPUT_BUFFER }), local = Bash.of({
|
|
103
|
+
stream,
|
|
104
|
+
exec: Effect.fn("bash.exec")(function* (command, run) {
|
|
105
|
+
return yield* collectBashResult(stream(command, run));
|
|
106
|
+
})
|
|
107
|
+
});
|
|
108
|
+
localShells.add(local);
|
|
109
|
+
return local;
|
|
110
|
+
});
|
|
111
|
+
const localShells = new WeakSet;
|
|
112
|
+
export const runsOnThisMachine = (bash) => localShells.has(bash), BashLive = (options = {}) => Layer.effect(Bash, makeBash(options));
|
|
@@ -15,6 +15,10 @@ type Fs = Context.Service.Shape<typeof FileSystem>;
|
|
|
15
15
|
* `fileSystemConformance` on Bun and Node.
|
|
16
16
|
*/
|
|
17
17
|
export declare const localFileSystem: Fs;
|
|
18
|
-
/**
|
|
18
|
+
/**
|
|
19
|
+
* The local `FileSystem` (`localFileSystem`) as a layer.
|
|
20
|
+
*
|
|
21
|
+
* @public
|
|
22
|
+
*/
|
|
19
23
|
export declare const FileSystemLive: Layer.Layer<FileSystem>;
|
|
20
24
|
export {};
|
|
@@ -18,7 +18,7 @@ const refused = (path, reason, message) => new FsError({ path, reason, message }
|
|
|
18
18
|
return size <= 0 ? Effect.succeed(chunks) : attempt(path, () => handle.read(new Uint8Array(size), 0, size, position)).pipe(Effect.flatMap(({ bytesRead, buffer }) => bytesRead === 0 ? Effect.succeed(chunks) : gather(position === null ? null : position + bytesRead, remaining - bytesRead, [...chunks, buffer.subarray(0, bytesRead)])));
|
|
19
19
|
};
|
|
20
20
|
return gather(offset === 0 ? null : offset, Option.getOrElse(length, () => Number.POSITIVE_INFINITY), []).pipe(Effect.map(joined));
|
|
21
|
-
})), (handle) => Effect.tryPromise(() => handle.close()).pipe(Effect.ignore)), readBytes = (path, range) => {
|
|
21
|
+
})), (handle) => Effect.tryPromise(() => handle.close()).pipe(Effect.ignore({ log: "Warn", message: `the read handle of ${path} did not close` }))), readBytes = (path, range) => {
|
|
22
22
|
const offset = range?.offset ?? 0, length = Option.fromNullishOr(range?.length);
|
|
23
23
|
if (!(Number.isInteger(offset) && offset >= 0 && Option.match(length, { onNone: () => !0, onSome: (count) => Number.isInteger(count) && count >= 0 })))
|
|
24
24
|
return Effect.fail(refused(path, "Invalid", `offset and length must be whole numbers of at least 0, not ${JSON.stringify(range)}`));
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
import { Layer } from "effect";
|
|
2
|
+
import { Bash, FileSystem, ImageResizer } from "@xandreed/core";
|
|
3
|
+
import { ImageResizerConfig } from "./image-resizer.entity.js";
|
|
4
|
+
/** Fit images through an optional host CLI; FileSystem and Bash must share a file tree. @public */
|
|
5
|
+
export declare const ImageResizerCliLive: (options?: Partial<ImageResizerConfig>) => Layer.Layer<ImageResizer, import("@xandreed/core").HarnessError, FileSystem | Bash>;
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import { Effect, Layer, Option, Path, Schema } from "effect";
|
|
2
|
+
import { Bash, FileSystem, harnessError, ImageFitTarget, ImageInput, ImageResizeError, ImageResizer, imageSizeOf, mediaFromBytes, mergeOptions, sniffImageType } from "@xandreed/core";
|
|
3
|
+
import { imageOpacityCommand, imageResizeCommand, imageToolProbe, privateDirectoryCommand, privateInputCommand, privately } from "./image-resizer.entity.functions.js";
|
|
4
|
+
import { ImageResizerConfig, imageResizerDefaults, ImageResizeTool } from "./image-resizer.entity.js";
|
|
5
|
+
import { bashEnvironment } from "./workspace-tools.entity.functions.js";
|
|
6
|
+
import { workspaceToolsDefaults } from "./workspace-tools.entity.js";
|
|
7
|
+
const resizeError = (reason, message, cause) => new ImageResizeError({ reason, message, ...cause === void 0 ? {} : { cause } }), succeeded = (result) => result.termination === "exit" && Option.contains(result.exitCode, 0), makeResizer = Effect.fnUntraced(function* (options) {
|
|
8
|
+
const config = yield* Schema.decodeUnknownEffect(ImageResizerConfig)(mergeOptions(ImageResizerConfig, imageResizerDefaults, options), { onExcessProperty: "error" }).pipe(Effect.mapError((cause) => harnessError("config.options", "the image resizer options do not decode", cause))), fs = yield* FileSystem, bash = yield* Bash, path = yield* Path.Path.pipe(Effect.provide(Path.layer)), environment = bashEnvironment(workspaceToolsDefaults, Option.none()), candidates = config.tool === "auto" ? ImageResizeTool.literals : [config.tool], available = yield* Effect.findFirst(candidates, (tool) => bash.exec(imageToolProbe(tool), { cwd: "/", timeoutMs: Math.min(config.timeoutMs, 5000), ...environment }).pipe(Effect.map(succeeded), Effect.catch((error) => Effect.logDebug("an image conversion tool could not be probed", error).pipe(Effect.as(!1)))));
|
|
9
|
+
if (Option.isNone(available))
|
|
10
|
+
return yield* Effect.fail(harnessError("images.unavailable", "no image conversion tool is available; install ImageMagick, libvips or sips, or provide an ImageResizer"));
|
|
11
|
+
const tool = available.value, temporary = config.tempDir === null ? (yield* bash.exec(`printf '%s' "\${TMPDIR:-/tmp}"`, { cwd: "/", timeoutMs: Math.min(config.timeoutMs, 5000), ...environment }).pipe(Effect.mapError((cause) => harnessError("config.options", "the image resizer could not read TMPDIR; set tempDir", cause)))).stdout : config.tempDir;
|
|
12
|
+
if (!temporary.startsWith("/") || temporary.includes("\x00"))
|
|
13
|
+
return yield* Effect.fail(harnessError("config.options", "the image resizer tempDir must be an absolute POSIX directory"));
|
|
14
|
+
const fit = Effect.fn("images.fit")(function* (image, target) {
|
|
15
|
+
const input = yield* Schema.decodeUnknownEffect(ImageInput)(image).pipe(Effect.mapError((cause) => resizeError("Unsupported", "the image input does not decode", cause))), bounds = yield* Schema.decodeUnknownEffect(ImageFitTarget)(target).pipe(Effect.mapError((cause) => resizeError("Failed", "the image fit target does not decode", cause))), type = sniffImageType(input.bytes);
|
|
16
|
+
if (!Option.contains(type, input.mediaType))
|
|
17
|
+
return yield* Effect.fail(resizeError("Unsupported", "the bytes are not an image of the declared type"));
|
|
18
|
+
const size = imageSizeOf(input.bytes, input.mediaType);
|
|
19
|
+
if (Option.isNone(size))
|
|
20
|
+
return yield* Effect.fail(resizeError("Unsupported", "the image header does not give its dimensions"));
|
|
21
|
+
const original = size.value;
|
|
22
|
+
if (input.bytes.length <= bounds.maxBytes && Math.max(original.width, original.height) <= bounds.maxSide)
|
|
23
|
+
return input.bytes;
|
|
24
|
+
if (original.width * original.height > config.maxSourcePixels)
|
|
25
|
+
return yield* Effect.fail(resizeError("Unsupported", `the source image declares ${original.width}x${original.height} pixels, more than the resizer's maxSourcePixels (${config.maxSourcePixels})`));
|
|
26
|
+
return yield* Effect.scoped(Effect.gen(function* () {
|
|
27
|
+
const nonce = yield* Effect.sync(() => crypto.randomUUID()), directory = path.join(temporary, `efferent-img-${nonce}`);
|
|
28
|
+
yield* Effect.addFinalizer(() => fs.remove(directory, { recursive: !0 }).pipe(Effect.catch((error) => error.reason === "NotFound" ? Effect.void : Effect.logWarning("the image conversion temporary directory could not be removed", error))));
|
|
29
|
+
const created = yield* bash.exec(privateDirectoryCommand(directory), { cwd: temporary, timeoutMs: Math.min(config.timeoutMs, 5000), ...environment }).pipe(Effect.mapError((cause) => resizeError("Failed", "the image conversion directory could not be created", cause)));
|
|
30
|
+
if (!succeeded(created))
|
|
31
|
+
return yield* Effect.fail(resizeError("Failed", "the image conversion directory could not be created", created));
|
|
32
|
+
const written = yield* bash.exec(privateInputCommand("input.img"), { cwd: directory, stdin: input.bytes, timeoutMs: config.timeoutMs, ...environment }).pipe(Effect.mapError((cause) => resizeError("Failed", "the image conversion input could not be written", cause)));
|
|
33
|
+
if (!succeeded(written))
|
|
34
|
+
return yield* Effect.fail(resizeError("Failed", "the image conversion input could not be written", written));
|
|
35
|
+
const convert = (side, jpeg) => Effect.gen(function* () {
|
|
36
|
+
const output = path.join(directory, jpeg ? "output.jpg" : "output.png"), result = yield* bash.exec(privately(imageResizeCommand(tool, "input.img", jpeg ? "output.jpg" : "output.png", side, jpeg)), { cwd: directory, timeoutMs: config.timeoutMs, ...environment }).pipe(Effect.mapError((cause) => resizeError("Failed", "the image conversion command could not run", cause)));
|
|
37
|
+
if (!succeeded(result))
|
|
38
|
+
return yield* Effect.fail(resizeError("Failed", result.termination === "timeout" ? "the image conversion command timed out" : "the image conversion command failed", result));
|
|
39
|
+
const bytes = yield* fs.readBytes(output).pipe(Effect.mapError((cause) => resizeError("Failed", "the converted image could not be read", cause))), outputType = sniffImageType(bytes), outputSize = Option.flatMap(outputType, (mediaType) => imageSizeOf(bytes, mediaType));
|
|
40
|
+
if (Option.isNone(outputSize))
|
|
41
|
+
return yield* Effect.fail(resizeError("Failed", "the converted image header does not give its dimensions"));
|
|
42
|
+
const actual = outputSize.value;
|
|
43
|
+
if (!(actual.width <= original.width && actual.height <= original.height || actual.width <= original.height && actual.height <= original.width))
|
|
44
|
+
return yield* Effect.fail(resizeError("Failed", "the image converter enlarged the source"));
|
|
45
|
+
if (bytes.length > bounds.maxBytes || Math.max(actual.width, actual.height) > bounds.maxSide)
|
|
46
|
+
return Option.none();
|
|
47
|
+
return yield* mediaFromBytes(bytes).pipe(Effect.as(Option.some(bytes)), Effect.mapError((cause) => resizeError("Failed", "the converted image is not valid media", cause)));
|
|
48
|
+
}), fallback = (side) => Effect.gen(function* () {
|
|
49
|
+
const command = imageOpacityCommand(tool, "input.img");
|
|
50
|
+
if (input.mediaType === "image/png" && command.length > 0 ? yield* bash.exec(command, { cwd: directory, timeoutMs: config.timeoutMs, ...environment }).pipe(Effect.map((result) => succeeded(result) && (result.stdout.trim().toLowerCase() === "true" || /hasAlpha:\s*no\b/i.test(result.stdout))), Effect.catch((cause) => Effect.logDebug("the source opacity could not be checked", cause).pipe(Effect.as(!1)))) : !1) {
|
|
51
|
+
const fitted = yield* convert(side, !0);
|
|
52
|
+
if (Option.isSome(fitted))
|
|
53
|
+
return fitted.value;
|
|
54
|
+
}
|
|
55
|
+
return yield* Effect.fail(resizeError("CannotFit", "the image could not fit the requested byte and pixel limits"));
|
|
56
|
+
}), attempt = (side, remaining) => convert(side, input.mediaType === "image/jpeg").pipe(Effect.flatMap(Option.match({
|
|
57
|
+
onSome: Effect.succeed,
|
|
58
|
+
onNone: () => {
|
|
59
|
+
const smaller = Math.max(1, Math.floor(side * 0.75));
|
|
60
|
+
return remaining > 1 && smaller < side ? attempt(smaller, remaining - 1) : fallback(side);
|
|
61
|
+
}
|
|
62
|
+
})));
|
|
63
|
+
return yield* attempt(Math.min(bounds.maxSide, Math.max(original.width, original.height)), 4);
|
|
64
|
+
})).pipe(Effect.timeoutOrElse({ duration: config.timeoutMs, orElse: () => Effect.fail(resizeError("Failed", "the image conversion exceeded its time limit")) }));
|
|
65
|
+
});
|
|
66
|
+
return ImageResizer.of({ fit });
|
|
67
|
+
});
|
|
68
|
+
export const ImageResizerCliLive = (options = {}) => Layer.effect(ImageResizer, makeResizer(options));
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { Schema } from "effect";
|
|
2
|
+
/** The optional host CLI used to fit images. @public */
|
|
3
|
+
export declare const ImageResizeTool: Schema.Literals<readonly ["magick", "convert", "vips", "sips"]>;
|
|
4
|
+
export type ImageResizeTool = typeof ImageResizeTool.Type;
|
|
5
|
+
/**
|
|
6
|
+
* Opt-in image conversion through the host's matching FileSystem and Bash.
|
|
7
|
+
* auto probes once in tool order. tempDir is an existing absolute directory;
|
|
8
|
+
* null reads the Bash environment's TMPDIR, falling back to /tmp. timeoutMs
|
|
9
|
+
* bounds one fit, including its conversion attempts (cleanup follows).
|
|
10
|
+
* maxSourcePixels refuses a source whose header declares more pixels
|
|
11
|
+
* (width times height) before any converter decodes it, so a small file
|
|
12
|
+
* that declares a huge canvas cannot hold the host's memory or disk.
|
|
13
|
+
* @public
|
|
14
|
+
*/
|
|
15
|
+
export declare const ImageResizerConfig: Schema.Struct<{
|
|
16
|
+
readonly tool: Schema.Literals<readonly ["auto", "magick", "convert", "vips", "sips"]>;
|
|
17
|
+
readonly tempDir: Schema.NullOr<Schema.String>;
|
|
18
|
+
readonly timeoutMs: Schema.Int;
|
|
19
|
+
readonly maxSourcePixels: Schema.Int;
|
|
20
|
+
}>;
|
|
21
|
+
export type ImageResizerConfig = typeof ImageResizerConfig.Type;
|
|
22
|
+
/** @public */
|
|
23
|
+
export declare const imageResizerDefaults: ImageResizerConfig;
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import type { ImageResizeTool } from "./image-resizer.entity.js";
|
|
2
|
+
export declare const imageToolProbe: (tool: ImageResizeTool) => string;
|
|
3
|
+
/** A first-frame conversion; every shell word is literal and the box never upscales. */
|
|
4
|
+
export declare const imageResizeCommand: (tool: ImageResizeTool, input: string, output: string, side: number, jpeg?: boolean) => string;
|
|
5
|
+
/** The converter's own opacity check; a backend without one keeps transparency conservatively. */
|
|
6
|
+
export declare const imageOpacityCommand: (tool: ImageResizeTool, input: string) => string;
|
|
7
|
+
/**
|
|
8
|
+
* Run a command so that what it creates is its owner's alone: directories
|
|
9
|
+
* 0700, files 0600, whatever the host's umask.
|
|
10
|
+
*/
|
|
11
|
+
export declare const privately: (command: string) => string;
|
|
12
|
+
/** Create the conversion directory, private and new (it fails when the path exists). */
|
|
13
|
+
export declare const privateDirectoryCommand: (directory: string) => string;
|
|
14
|
+
/** Write standard input to a private file in the command's directory. */
|
|
15
|
+
export declare const privateInputCommand: (name: string) => string;
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
import { Match } from "effect";
|
|
2
|
+
import { shellQuote } from "./shell.entity.functions.js";
|
|
3
|
+
const executable = { magick: "magick", convert: "convert", vips: "vipsthumbnail", sips: "sips" };
|
|
4
|
+
export const imageToolProbe = (tool) => [executable[tool], tool === "magick" || tool === "convert" ? "-version" : "--version"].map(shellQuote).join(" "), imageResizeCommand = (tool, input, output, side, jpeg = !1) => Match.value(tool).pipe(Match.when("magick", () => ["magick", `${input}[0]`, "-coalesce", "-auto-orient", "-strip", "-resize", `${side}x${side}>`, "-quality", "85", output]), Match.when("convert", () => ["convert", `${input}[0]`, "-coalesce", "-auto-orient", "-strip", "-resize", `${side}x${side}>`, "-quality", "85", output]), Match.when("vips", () => ["vipsthumbnail", input, "--size", `${side}x${side}>`, "--path", `${output}[strip${jpeg ? ",Q=85" : ""}]`]), Match.when("sips", () => ["sips", "-s", "format", jpeg ? "jpeg" : "png", ...jpeg ? ["-s", "formatOptions", "85"] : [], "-Z", String(side), input, "--out", output]), Match.exhaustive, (args) => args.map(shellQuote).join(" ")), imageOpacityCommand = (tool, input) => Match.value(tool).pipe(Match.when("magick", () => ["magick", `${input}[0]`, "-format", "%[opaque]", "info:"]), Match.when("convert", () => ["convert", `${input}[0]`, "-format", "%[opaque]", "info:"]), Match.when("vips", () => []), Match.when("sips", () => ["sips", "-g", "hasAlpha", input]), Match.exhaustive, (args) => args.map(shellQuote).join(" ")), privately = (command) => `umask 077 && ${command}`, privateDirectoryCommand = (directory) => privately(["mkdir", "--", directory].map(shellQuote).join(" ")), privateInputCommand = (name) => privately(`cat > ${shellQuote(name)}`);
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import { Schema } from "effect";
|
|
2
|
+
export const ImageResizeTool = Schema.Literals(["magick", "convert", "vips", "sips"]), ImageResizerConfig = Schema.Struct({
|
|
3
|
+
tool: Schema.Literals(["auto", ...ImageResizeTool.literals]),
|
|
4
|
+
tempDir: Schema.NullOr(Schema.String.check(Schema.makeFilter((path) => path.startsWith("/") && !path.includes("\x00") ? !0 : "an absolute POSIX directory"))),
|
|
5
|
+
timeoutMs: Schema.Int.check(Schema.isBetween({ minimum: 1, maximum: 600000 })),
|
|
6
|
+
maxSourcePixels: Schema.Int.check(Schema.isBetween({ minimum: 1, maximum: 1e9 }))
|
|
7
|
+
}), imageResizerDefaults = { tool: "auto", tempDir: null, timeoutMs: 30000, maxSourcePixels: 1e8 };
|
package/dist/index.d.ts
CHANGED
|
@@ -8,5 +8,7 @@ export { SkillsDirectoryConfig, skillsDirectoryDefaults } from "./skills/skills-
|
|
|
8
8
|
export { ALWAYS_SECTION_ID, SKILLS_CAPABILITY_ID, skillsCapability } from "./skills/skills-directory.entity.functions.js";
|
|
9
9
|
export { loadSkillsDirectory, skillsDirectoryPlugin, SkillsDirectoryPluginLive } from "./skills/skills-directory.adapter.js";
|
|
10
10
|
export { workspaceTools } from "./workspace-tools.adapter.js";
|
|
11
|
-
export { BashParams, BashToolResult, EditParams, EditResult, FindParams, FindResult, GrepLine, GrepParams, GrepResult, LsEntry, LsParams, LsResult, ReadParams, ReadResult, TextEdit, WORKSPACE_PERMISSIONS, WorkspaceSkillName, WorkspaceToolName, workspaceToolSchemas, WorkspaceToolsConfig, workspaceToolsDefaults, WriteParams, WriteResult, } from "./workspace-tools.entity.js";
|
|
12
|
-
export {
|
|
11
|
+
export { BashParams, BashToolResult, EditParams, EditResult, FindParams, FindResult, GrepLine, GrepParams, GrepResult, GrepUnreadable, LsEntry, LsParams, LsResult, ReadParams, ReadResult, TextEdit, WORKSPACE_PERMISSIONS, WorkspaceSkillName, WorkspaceToolName, workspaceToolSchemas, WorkspaceToolsConfig, workspaceToolsDefaults, WriteParams, WriteResult, } from "./workspace-tools.entity.js";
|
|
12
|
+
export { ImageResizerCliLive } from "./image-resizer.adapter.js";
|
|
13
|
+
export { ImageResizeTool, ImageResizerConfig, imageResizerDefaults } from "./image-resizer.entity.js";
|
|
14
|
+
export { imageResizerPlugin, ImageResizerPluginLive } from "./plugin.adapter.js";
|
package/dist/index.js
CHANGED
|
@@ -17,6 +17,7 @@ export {
|
|
|
17
17
|
GrepLine,
|
|
18
18
|
GrepParams,
|
|
19
19
|
GrepResult,
|
|
20
|
+
GrepUnreadable,
|
|
20
21
|
LsEntry,
|
|
21
22
|
LsParams,
|
|
22
23
|
LsResult,
|
|
@@ -32,4 +33,6 @@ export {
|
|
|
32
33
|
WriteParams,
|
|
33
34
|
WriteResult
|
|
34
35
|
} from "./workspace-tools.entity.js";
|
|
35
|
-
export {
|
|
36
|
+
export { ImageResizerCliLive } from "./image-resizer.adapter.js";
|
|
37
|
+
export { ImageResizeTool, ImageResizerConfig, imageResizerDefaults } from "./image-resizer.entity.js";
|
|
38
|
+
export { imageResizerPlugin, ImageResizerPluginLive } from "./plugin.adapter.js";
|
|
@@ -13,22 +13,29 @@ import { Schema } from "effect";
|
|
|
13
13
|
* output pipes close then, so a background process kept with
|
|
14
14
|
* `killBackground: false` must have its own output (`cmd > log 2>&1 &`):
|
|
15
15
|
* one still writing to the command's dies of `SIGPIPE` or gets `EPIPE`.
|
|
16
|
+
*
|
|
17
|
+
* @public
|
|
16
18
|
*/
|
|
17
19
|
export declare const LocalSystemConfig: Schema.Struct<{
|
|
18
20
|
readonly shell: Schema.NonEmptyString;
|
|
19
21
|
readonly killBackground: Schema.Boolean;
|
|
20
22
|
readonly exitGraceMs: Schema.Int;
|
|
23
|
+
/** The host's base environment, or null to inherit from the agent process. Filtered per command by `resolveBashEnv`. */
|
|
24
|
+
readonly parentEnv: Schema.NullOr<Schema.$Record<Schema.String, Schema.String>>;
|
|
21
25
|
}>;
|
|
22
26
|
export type LocalSystemConfig = typeof LocalSystemConfig.Type;
|
|
27
|
+
/** @public */
|
|
23
28
|
export declare const localSystemDefaults: LocalSystemConfig;
|
|
24
29
|
/**
|
|
25
30
|
* `BashLive`'s options: the plugin's configuration, each part optional, and
|
|
26
31
|
* `parentEnv`, the environment commands inherit from (filtered by
|
|
27
32
|
* `resolveBashEnv`) and the shell is looked up on; `process.env` when not
|
|
28
|
-
* given.
|
|
33
|
+
* given or null.
|
|
34
|
+
*
|
|
35
|
+
* @public
|
|
29
36
|
*/
|
|
30
|
-
export type BashLiveOptions = Partial<LocalSystemConfig
|
|
31
|
-
readonly parentEnv?: Readonly<Record<string, string | undefined
|
|
37
|
+
export type BashLiveOptions = Partial<Omit<LocalSystemConfig, "parentEnv">> & {
|
|
38
|
+
readonly parentEnv?: Readonly<Record<string, string | undefined>> | null;
|
|
32
39
|
};
|
|
33
40
|
/** How a spawned process ended, as Node reports it, and whether the adapter killed it for running past its timeout. */
|
|
34
41
|
export declare const ProcessEnd: Schema.Struct<{
|
|
@@ -1,8 +1,6 @@
|
|
|
1
1
|
import { Option, Result } from "effect";
|
|
2
2
|
import type { BashExit, FsReason } from "@xandreed/core";
|
|
3
3
|
import type { ProcessEnd } from "./local-system.entity.js";
|
|
4
|
-
/** The `code` a Node error carries (`ENOENT`, `ERR_FS_EISDIR`, …), when it has one. */
|
|
5
|
-
export declare const errorCodeOf: (error: unknown) => Option.Option<string>;
|
|
6
4
|
/** A thrown value's message, for the `message` of the error it becomes. */
|
|
7
5
|
export declare const errorMessageOf: (error: unknown) => string;
|
|
8
6
|
/**
|
|
@@ -12,8 +10,11 @@ export declare const errorMessageOf: (error: unknown) => string;
|
|
|
12
10
|
* and `EPERM` `Permission`; `EINVAL`, a symbolic link loop (`ELOOP`), a
|
|
13
11
|
* name too long and an unusable argument (a path with a NUL byte) are
|
|
14
12
|
* `Invalid`; anything else, or no code at all, is `Io`.
|
|
13
|
+
*
|
|
14
|
+
* @public
|
|
15
15
|
*/
|
|
16
16
|
export declare const fsReasonOf: (error: unknown) => FsReason;
|
|
17
|
+
export declare const rgReasonOf: (code: number) => FsReason;
|
|
17
18
|
/**
|
|
18
19
|
* Whether a write to a command's standard input failed only because the
|
|
19
20
|
* command no longer reads it: it exited or closed its input first (`EPIPE`),
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { Option, Result } from "effect";
|
|
2
|
-
|
|
2
|
+
import { errorCodeOf } from "@xandreed/core";
|
|
3
|
+
export const errorMessageOf = (error) => error instanceof Error ? error.message : String(error);
|
|
3
4
|
const reasons = new Map([
|
|
4
5
|
["ENOENT", "NotFound"],
|
|
5
6
|
["EEXIST", "Exists"],
|
|
@@ -17,7 +18,20 @@ const reasons = new Map([
|
|
|
17
18
|
export const fsReasonOf = (error) => Option.match(errorCodeOf(error), {
|
|
18
19
|
onNone: () => "Io",
|
|
19
20
|
onSome: (code) => reasons.get(code) ?? "Io"
|
|
20
|
-
})
|
|
21
|
+
});
|
|
22
|
+
const rgReasons = new Map([
|
|
23
|
+
[1, "Permission"],
|
|
24
|
+
[13, "Permission"],
|
|
25
|
+
[2, "NotFound"],
|
|
26
|
+
[20, "NotDirectory"],
|
|
27
|
+
[21, "IsDirectory"],
|
|
28
|
+
[22, "Invalid"],
|
|
29
|
+
[36, "Invalid"],
|
|
30
|
+
[40, "Invalid"],
|
|
31
|
+
[62, "Invalid"],
|
|
32
|
+
[63, "Invalid"]
|
|
33
|
+
]);
|
|
34
|
+
export const rgReasonOf = (code) => rgReasons.get(code) ?? "Io", isClosedInput = (error) => Option.match(errorCodeOf(error), { onNone: () => !1, onSome: (code) => code === "EPIPE" || code === "ERR_STREAM_DESTROYED" }), executableCandidates = (name, path) => name.startsWith("/") ? [name] : name.includes("/") || name.length === 0 ? [] : Option.getOrElse(path, () => "").split(":").filter((dir) => dir.startsWith("/")).map((dir) => `${dir.endsWith("/") ? dir.slice(0, -1) : dir}/${name}`), removalPlace = (path) => {
|
|
21
35
|
const segments = path.split("/").filter((segment) => segment.length > 0);
|
|
22
36
|
return !path.startsWith("/") ? Result.fail(`paths must be absolute: ${path}`) : Option.match(Option.fromNullishOr(segments.at(-1)), {
|
|
23
37
|
onNone: () => Result.fail("the root directory is never removed"),
|
|
@@ -2,8 +2,9 @@ import { Schema } from "effect";
|
|
|
2
2
|
export const LocalSystemConfig = Schema.Struct({
|
|
3
3
|
shell: Schema.NonEmptyString,
|
|
4
4
|
killBackground: Schema.Boolean,
|
|
5
|
-
exitGraceMs: Schema.Int.pipe(Schema.check(Schema.isBetween({ minimum: 0, maximum: 60000 })))
|
|
6
|
-
|
|
5
|
+
exitGraceMs: Schema.Int.pipe(Schema.check(Schema.isBetween({ minimum: 0, maximum: 60000 }))),
|
|
6
|
+
parentEnv: Schema.NullOr(Schema.Record(Schema.String, Schema.String))
|
|
7
|
+
}), localSystemDefaults = { shell: "bash", killBackground: !0, exitGraceMs: 500, parentEnv: null }, ProcessEnd = Schema.Struct({
|
|
7
8
|
code: Schema.NullOr(Schema.Int),
|
|
8
9
|
signal: Schema.NullOr(Schema.String),
|
|
9
10
|
timedOut: Schema.Boolean,
|