@hachej/boring-execution 0.0.0-stage → 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (43) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +97 -2
  3. package/dist/aws-code-interpreter.d.ts +149 -0
  4. package/dist/aws-code-interpreter.js +453 -0
  5. package/dist/bounded-error.d.ts +8 -0
  6. package/dist/bounded-error.js +12 -0
  7. package/dist/contracts.d.ts +46 -0
  8. package/dist/contracts.js +1 -0
  9. package/dist/fs-readers.d.ts +7 -0
  10. package/dist/fs-readers.js +72 -0
  11. package/dist/index.d.ts +3 -0
  12. package/dist/index.js +1 -0
  13. package/dist/native.d.ts +8 -0
  14. package/dist/native.js +1 -0
  15. package/dist/remote-files-handler.d.ts +21 -0
  16. package/dist/remote-files-handler.js +215 -0
  17. package/dist/remote-files-io.d.ts +8 -0
  18. package/dist/remote-files-io.js +46 -0
  19. package/dist/remote-files-positional.d.ts +12 -0
  20. package/dist/remote-files-positional.js +208 -0
  21. package/dist/remote-files-protocol.d.ts +342 -0
  22. package/dist/remote-files-protocol.js +113 -0
  23. package/dist/remote-files-watch.d.ts +29 -0
  24. package/dist/remote-files-watch.js +154 -0
  25. package/dist/remote-files.d.ts +17 -0
  26. package/dist/remote-files.js +238 -0
  27. package/dist/remote-shell-handler.d.ts +25 -0
  28. package/dist/remote-shell-handler.js +131 -0
  29. package/dist/remote-shell-protocol.d.ts +50 -0
  30. package/dist/remote-shell-protocol.js +163 -0
  31. package/dist/remote-shell-window.d.ts +14 -0
  32. package/dist/remote-shell-window.js +82 -0
  33. package/dist/remote-shell.d.ts +15 -0
  34. package/dist/remote-shell.js +127 -0
  35. package/dist/virtual-filesystem.d.ts +67 -0
  36. package/dist/virtual-filesystem.js +218 -0
  37. package/dist/virtual-git.d.ts +60 -0
  38. package/dist/virtual-git.js +116 -0
  39. package/dist/virtual-sqlite.d.ts +42 -0
  40. package/dist/virtual-sqlite.js +50 -0
  41. package/dist/virtual.d.ts +34 -0
  42. package/dist/virtual.js +277 -0
  43. package/package.json +94 -4
@@ -0,0 +1,116 @@
1
+ import { defineCommand } from 'just-bash';
2
+ import { posix } from 'node:path';
3
+ import { boundedMessage, mutationPath } from './virtual-filesystem.js';
4
+ /** isomorphic-git I/O over the selected just-bash view; no second checkout or native Git. */
5
+ export function createVirtualGitFs(filesystem) {
6
+ const wrap = (operation) => async (...args) => {
7
+ try {
8
+ return await operation(...args);
9
+ }
10
+ catch (error) {
11
+ if (error instanceof Error) {
12
+ const code = /\b(ENOENT|EEXIST|ENOTDIR|EISDIR|ENOTEMPTY|EACCES|EPERM|EINVAL)\b/.exec(error.message)?.[1];
13
+ if (code)
14
+ throw Object.assign(new Error(error.message, { cause: error }), { code });
15
+ }
16
+ throw error;
17
+ }
18
+ };
19
+ // Racy-clean guard: isomorphic-git compares whole seconds, so a same-size edit in the second of its `add` looks clean. The
20
+ // reported inode folds in the millisecond mtime, and is unique per call while the file is fresh, so such entries are rehashed.
21
+ let fresh = 0;
22
+ const stat = (value) => {
23
+ const mtimeMs = value.mtime.getTime(), racy = Date.now() - mtimeMs < 1000;
24
+ return {
25
+ ...value, isFile: () => value.isFile, isDirectory: () => value.isDirectory, isSymbolicLink: () => value.isSymbolicLink,
26
+ mode: value.isSymbolicLink ? 0o120000 | (value.mode & 0o777) : value.mode,
27
+ mtimeMs, ctime: value.mtime, ctimeMs: mtimeMs, uid: 0, gid: 0, dev: value.dev ?? 0,
28
+ ino: racy ? 0x80000000 + (++fresh % 0x7fffffff) : (Number(value.ino ?? 0) * 31 + mtimeMs) % 0x80000000,
29
+ };
30
+ };
31
+ return { filesystem, promises: {
32
+ readFile: wrap(async (path, options) => {
33
+ const encoding = typeof options === 'string' ? options : options?.encoding;
34
+ return encoding ? filesystem.readFile(path, encoding) : Uint8Array.from(await filesystem.readFileBuffer(path));
35
+ }),
36
+ writeFile: wrap(async (path, bytes) => {
37
+ const captured = typeof bytes === 'string' ? bytes : Uint8Array.from(bytes);
38
+ await filesystem.writeFile(await mutationPath(filesystem, path, false, true), captured);
39
+ }),
40
+ unlink: wrap(async (path) => {
41
+ path = posix.join(await filesystem.realpath(posix.dirname(path)), posix.basename(path));
42
+ if ((await filesystem.lstat(path)).isDirectory)
43
+ throw new Error('EISDIR: unlink requires a file');
44
+ await filesystem.rm(path);
45
+ }),
46
+ readdir: wrap((path) => filesystem.readdir(path)),
47
+ mkdir: wrap(async (path, options) => {
48
+ const selected = typeof options === 'object' ? options : {};
49
+ await filesystem.mkdir(await mutationPath(filesystem, path, selected.recursive === true, false), selected);
50
+ }),
51
+ rmdir: wrap(async (path) => {
52
+ path = posix.join(await filesystem.realpath(posix.dirname(path)), posix.basename(path));
53
+ if (!(await filesystem.lstat(path)).isDirectory)
54
+ throw new Error('ENOTDIR: rmdir requires a directory');
55
+ await filesystem.rm(path);
56
+ }),
57
+ stat: wrap(async (path) => stat(await filesystem.stat(path))),
58
+ lstat: wrap(async (path) => stat(await filesystem.lstat(path))),
59
+ readlink: wrap((path) => filesystem.readlink(path)),
60
+ symlink: wrap((target, path) => filesystem.symlink(target, path)),
61
+ chmod: wrap((path, mode) => filesystem.chmod(path, mode)),
62
+ } };
63
+ }
64
+ /** Bind one repository service to its selected backing and root cwd. */
65
+ export function installVirtualGitCommand({ bash, repository }) {
66
+ if (bash.fs !== repository.fs.filesystem)
67
+ throw new TypeError('Git command requires its bound filesystem');
68
+ bash.registerCommand(defineCommand('git', async (args, context) => {
69
+ if (context.cwd !== repository.directory) {
70
+ return { stdout: '', stderr: 'Git command requires its bound filesystem and repository cwd\n', exitCode: 126 };
71
+ }
72
+ const [command, ...rest] = args;
73
+ const signal = context.signal;
74
+ let stdout = '';
75
+ try {
76
+ if (command === 'init' && rest.length === 0) {
77
+ await repository.init(signal);
78
+ stdout = 'Initialized virtual Git repository\n';
79
+ }
80
+ else if (command === 'status' && rest.length === 0) {
81
+ stdout = JSON.stringify(await repository.status(signal)) + '\n';
82
+ }
83
+ else if (command === 'add' && rest.length === 1 && rest[0] && !rest[0].startsWith('-')) {
84
+ await repository.add(rest[0], signal);
85
+ }
86
+ else if (command === 'rm' && rest.length === 2 && rest[0] === '--cached' && rest[1]) {
87
+ await repository.remove(rest[1], signal);
88
+ }
89
+ else if (command === 'commit' && rest.length === 2 && rest[0] === '-m' && rest[1]) {
90
+ stdout = await repository.commit(rest[1], signal) + '\n';
91
+ }
92
+ else if (command === 'log' && rest.length === 0) {
93
+ stdout = JSON.stringify(await repository.log(signal)) + '\n';
94
+ }
95
+ else if (command === 'branch' && rest.length === 0) {
96
+ stdout = (await repository.branches(signal)).join('\n') + '\n';
97
+ }
98
+ else if (command === 'branch' && rest.length === 1 && rest[0] && !rest[0].startsWith('-')) {
99
+ await repository.branch(rest[0], signal);
100
+ }
101
+ else if (command === 'checkout' && rest.length === 1 && rest[0] && !rest[0].startsWith('-')) {
102
+ await repository.checkout(rest[0], signal);
103
+ }
104
+ else if (command === 'diff' && (rest.length === 0 || rest.length === 1 && rest[0] === '--cached')) {
105
+ stdout = JSON.stringify(await repository.diff(rest.length ? { kind: 'tree', ref: 'HEAD' } : { kind: 'index' }, rest.length ? { kind: 'index' } : { kind: 'worktree' }, signal)) + '\n';
106
+ }
107
+ else {
108
+ return { stdout: '', stderr: 'Unsupported virtual Git command or arguments\n', exitCode: 126 };
109
+ }
110
+ return { stdout, stderr: '', exitCode: 0 };
111
+ }
112
+ catch (error) {
113
+ return { stdout, stderr: `${boundedMessage(error)}\n`, exitCode: 1 };
114
+ }
115
+ }));
116
+ }
@@ -0,0 +1,42 @@
1
+ import type { Context } from '@earendil-works/chord';
2
+ import type { ExecutionEnv, FileSystem } from '@earendil-works/pi-durable/env';
3
+ import { createGitRepository } from '@hachej/boring-files/git';
4
+ import type { GitOperation } from '@hachej/boring-files/git';
5
+ import { createVirtualGitFs } from './virtual-git.js';
6
+ export interface VirtualRepositoryOptions {
7
+ /** Where the files live, for example `openSqliteFileSystem({ connection, workspace, cwd: root })` from `@hachej/boring-files/sqlite-filesystem`. */
8
+ readonly fs: FileSystem;
9
+ /** Absolute directory of the repository inside the file system. Default `/repo`. */
10
+ readonly root?: string;
11
+ /** Files of a new repository, by path relative to `root`. They are committed as `seedMessage`; ignored when the repository exists. */
12
+ readonly seed?: Readonly<Record<string, string>>;
13
+ readonly seedMessage?: string;
14
+ /** Commit author for `repository.commit` (the shell's `git commit` too). */
15
+ readonly author?: {
16
+ readonly name: string;
17
+ readonly email: string;
18
+ };
19
+ /** Context for acquiring the workspace (a host context such as `BACKGROUND_CONTEXT`). */
20
+ readonly context: Context;
21
+ /** The host's git policy for `repository` (and the shell's `git`). Default: every operation is allowed. */
22
+ readonly authorize?: (operation: GitOperation) => boolean | Promise<boolean>;
23
+ /** Provider id recorded in the workspace identity. Default `virtual-sqlite`. */
24
+ readonly providerId?: string;
25
+ }
26
+ type Repository = ReturnType<typeof createGitRepository<ReturnType<typeof createVirtualGitFs>>>;
27
+ /**
28
+ * One repository, one working view: Pi's native `env` for reading, listing, writing and running commands (its bash has
29
+ * `git`), and the `@hachej/boring-files/git` service bound to the same files. There is deliberately no second file or shell API.
30
+ */
31
+ export interface VirtualRepository {
32
+ readonly root: string;
33
+ /** Pi's native `ExecutionEnv` over the repository: native tools use it; its `exec` shell has the `git` command. */
34
+ readonly env: ExecutionEnv;
35
+ readonly repository: Repository;
36
+ /** Release the workspace. The files stay where `fs` keeps them. */
37
+ close(): Promise<void>;
38
+ }
39
+ /** Open the repository in `fs`, or create it from `seed` when `root` has no `.git` yet. */
40
+ export declare function openVirtualRepository(options: VirtualRepositoryOptions): Promise<VirtualRepository>;
41
+ export {};
42
+ //# sourceMappingURL=virtual-sqlite.d.ts.map
@@ -0,0 +1,50 @@
1
+ // One git repository in a virtual workspace (a browser tab, or Node): a just-bash workspace with isomorphic-git whose files live in
2
+ // SQLite, through the Pi `FileSystem` of `@hachej/boring-files/sqlite-filesystem` (in a browser over `openBrowserSqliteConnection`). Every
3
+ // write lands in SQLite as it happens: there is no copy in memory and nothing to save. Pi's native tools reach it through a native
4
+ // `ExecutionEnv` whose bash also has the `git` command bound to the same repository. Nothing here is a second file or shell API:
5
+ // `env` is Pi's and `repository` is `@hachej/boring-files/git`'s.
6
+ import { posix } from 'node:path';
7
+ import { createGitRepository } from '@hachej/boring-files/git';
8
+ import { createVirtualWorkspace } from './virtual.js';
9
+ import { createVirtualGitFs, installVirtualGitCommand } from './virtual-git.js';
10
+ /** Open the repository in `fs`, or create it from `seed` when `root` has no `.git` yet. */
11
+ export async function openVirtualRepository(options) {
12
+ const { context } = options;
13
+ const root = posix.normalize(options.root ?? '/repo').replace(/(.)\/$/, '$1');
14
+ if (!root.startsWith('/') || root === '/')
15
+ throw new TypeError('root must be an absolute directory such as /repo');
16
+ // Every shell of the workspace, the native `exec`'s included, gets `git` bound to the one repository on the same files.
17
+ let repository;
18
+ const workspace = createVirtualWorkspace({ providerId: options.providerId ?? 'virtual-sqlite', fs: options.fs,
19
+ onBash: bash => { if (repository)
20
+ installVirtualGitCommand({ bash, repository }); } });
21
+ const filesystem = workspace.filesystem;
22
+ await filesystem.mkdir(root, { recursive: true });
23
+ const fresh = !await filesystem.exists(posix.join(root, '.git'));
24
+ if (fresh) {
25
+ for (const [path, text] of Object.entries(options.seed ?? {})) {
26
+ const absolute = posix.join(root, path);
27
+ await filesystem.mkdir(posix.dirname(absolute), { recursive: true });
28
+ await filesystem.writeFile(absolute, text);
29
+ }
30
+ }
31
+ const lease = await workspace.acquire({ operationId: 'virtual-sqlite', input: { cwd: root } }, context);
32
+ repository = createGitRepository({ fs: createVirtualGitFs(filesystem), directory: root, authorize: options.authorize ?? (() => true),
33
+ author: options.author ?? { name: 'Agent', email: 'agent@example.invalid' } });
34
+ if (fresh) {
35
+ await repository.init();
36
+ for (const path of Object.keys(options.seed ?? {}))
37
+ await repository.add(path);
38
+ if (Object.keys(options.seed ?? {}).length)
39
+ await repository.commit(options.seedMessage ?? 'Initial commit');
40
+ }
41
+ return {
42
+ root, env: lease.environment, repository,
43
+ async close() { try {
44
+ await lease.release(context);
45
+ }
46
+ finally {
47
+ workspace.dispose();
48
+ } },
49
+ };
50
+ }
@@ -0,0 +1,34 @@
1
+ import { Bash } from 'just-bash';
2
+ import type { BashOptions, InitialFiles, IFileSystem, InMemoryFs } from 'just-bash';
3
+ import type { ExecutionEnv, FileSystem } from '@earendil-works/pi-durable/env';
4
+ import type { WorkspaceProvider } from './contracts.js';
5
+ export { MAX_ERROR_CHARS } from './virtual-filesystem.js';
6
+ export interface VirtualWorkspaceOptions {
7
+ readonly providerId: string;
8
+ readonly files?: InitialFiles;
9
+ readonly storage?: ConstructorParameters<typeof InMemoryFs>[1];
10
+ /**
11
+ * Keep the files in this Pi `FileSystem` (for example `@hachej/boring-files/sqlite-filesystem`) instead of memory: the shell, the native
12
+ * environment and a workspace provider over the same `fs` then see the same files. Not combined with `files` or `storage`.
13
+ */
14
+ readonly fs?: FileSystem;
15
+ readonly bash?: Omit<BashOptions, 'fs' | 'files' | 'cwd' | 'python'>;
16
+ /**
17
+ * Offer `python3` and `python` in the shell: just-bash's CPython compiled to WebAssembly. Off by default. Node only (refused elsewhere,
18
+ * for example in a browser worker); it loads a ~10 MB runtime on first use and its memory is not contained like the shell's limits.
19
+ */
20
+ readonly python?: boolean;
21
+ /** Called with every shell this workspace creates, the native `exec`'s included, for example to install `git` bound to it. */
22
+ readonly onBash?: (bash: Bash) => void;
23
+ }
24
+ export interface VirtualWorkspace extends WorkspaceProvider<{
25
+ readonly cwd: string;
26
+ }, ExecutionEnv> {
27
+ /** Trusted host access. Raw upstream handles remain owned by the host. */
28
+ readonly filesystem: IFileSystem;
29
+ readonly createBash: (options: Omit<BashOptions, 'fs' | 'files' | 'python'>) => Bash;
30
+ readonly dispose: () => void;
31
+ }
32
+ /** Working storage: in memory (ephemeral), or the given `fs`. No publication receipts, host fallback or restart recovery. */
33
+ export declare function createVirtualWorkspace(options: VirtualWorkspaceOptions): VirtualWorkspace;
34
+ //# sourceMappingURL=virtual.d.ts.map
@@ -0,0 +1,277 @@
1
+ import { randomUUID } from '@hachej/boring-files/platform';
2
+ import { posix } from 'node:path';
3
+ import { Bash, defineCommand } from 'just-bash';
4
+ import { FileError, ExecutionError, ok, err } from '@earendil-works/pi-durable/env';
5
+ import { FileSystemView, VirtualFileSystem, boundedMessage } from './virtual-filesystem.js';
6
+ import { shellCommand, snapshotReaders } from './fs-readers.js';
7
+ export { MAX_ERROR_CHARS } from './virtual-filesystem.js';
8
+ function fileError(error, path) {
9
+ if (error instanceof FileError)
10
+ return error;
11
+ const cause = error instanceof Error ? error : new Error(String(error));
12
+ const message = boundedMessage(cause);
13
+ const code = /\b(ENOENT|ENOTDIR|EISDIR|EACCES|EPERM|EEXIST|EINVAL|ENOTEMPTY)\b/.exec(message)?.[1];
14
+ return new FileError(code === 'ENOENT' ? 'not_found' : code === 'ENOTDIR' ? 'not_directory' : code === 'EISDIR' ? 'is_directory'
15
+ : code === 'EACCES' || code === 'EPERM' ? 'permission_denied' : code === 'EEXIST' || code === 'EINVAL' || code === 'ENOTEMPTY' ? 'invalid' : 'unknown', message, path, cause);
16
+ }
17
+ /** Working storage: in memory (ephemeral), or the given `fs`. No publication receipts, host fallback or restart recovery. */
18
+ export function createVirtualWorkspace(options) {
19
+ if (typeof options.providerId !== 'string' || !options.providerId || /[\x00-\x1f]/.test(options.providerId))
20
+ throw new TypeError('A provider identifier is required');
21
+ const capture = (content) => typeof content === 'string' ? content : Uint8Array.from(content);
22
+ const files = Object.fromEntries(Object.entries(options.files ?? {}).map(([path, file]) => [path,
23
+ typeof file === 'function' ? async () => capture(await file()) : typeof file === 'string' || file instanceof Uint8Array ? capture(file)
24
+ : { ...file, content: capture(file.content), ...(file.mtime === undefined ? {} : { mtime: new Date(file.mtime) }) }]));
25
+ if (options.python === true && typeof globalThis.process?.versions?.node !== 'string') {
26
+ throw new TypeError('Python needs Node: just-bash runs CPython only under Node, not in a browser or its workers');
27
+ }
28
+ if (options.fs && (options.files || options.storage))
29
+ throw new TypeError('A virtual workspace over a file system takes no initial files or storage');
30
+ const filesystem = options.fs ? new FileSystemView(options.fs) : new VirtualFileSystem(files, options.storage);
31
+ const identity = Object.freeze({ providerId: options.providerId, instanceId: randomUUID(), incarnation: randomUUID(), viewId: randomUUID() });
32
+ const namespace = `virtual:${identity.instanceId}`;
33
+ let disposed = false;
34
+ const createBash = input => {
35
+ if (disposed)
36
+ throw new FileError('invalid', 'Virtual workspace is disposed');
37
+ const limits = options.bash?.executionLimits || input.executionLimits ? { executionLimits: { ...options.bash?.executionLimits, ...input.executionLimits } } : {};
38
+ // Python only when the workspace option says so, whatever a caller's shell options carry.
39
+ const selected = { ...options.bash, ...input, ...limits, fs: filesystem, python: options.python === true };
40
+ const commands = selected.customCommands ?? [];
41
+ const includeLinks = selected.commands === undefined || selected.commands.includes('ln');
42
+ const bash = new Bash({ ...selected, customCommands: includeLinks && !commands.some(command => command.name === 'ln')
43
+ ? [...commands, symbolicLinkCommand] : commands });
44
+ options.onBash?.(bash);
45
+ return bash;
46
+ };
47
+ return {
48
+ providerId: options.providerId, filesystem, createBash,
49
+ dispose: () => { disposed = true; },
50
+ acquire: async (request, context) => {
51
+ if (disposed)
52
+ throw new FileError('invalid', 'Virtual workspace is disposed');
53
+ if (context.abortSignal?.aborted)
54
+ throw new FileError('aborted', 'Workspace acquisition aborted');
55
+ const cwd = request.input.cwd;
56
+ if (typeof cwd !== 'string' || !cwd.startsWith('/') || cwd.includes('\0'))
57
+ throw new FileError('invalid', 'An absolute virtual cwd is required');
58
+ if (!(await filesystem.stat(cwd)).isDirectory)
59
+ throw new FileError('not_directory', 'Working directory is not a directory', cwd);
60
+ if (disposed || context.abortSignal?.aborted)
61
+ throw new FileError(disposed ? 'invalid' : 'aborted', 'Workspace acquisition is no longer available');
62
+ let released = false;
63
+ const environment = nativeEnvironment(filesystem, namespace, cwd, () => disposed || released, async () => { released = true; }, createBash);
64
+ return { identity, environment, ownership: 'borrowed', release: environment.cleanup };
65
+ },
66
+ };
67
+ }
68
+ // Stock ln -f removes the destination before calling fs.link, which this backing cannot support.
69
+ const symbolicLinkCommand = defineCommand('ln', async (input, context) => {
70
+ // Stock ln lacks -T/--no-target-directory. Strip it here and refuse a directory destination as native ln does.
71
+ const args = [];
72
+ let symbolic = false, plain = false, flags = true;
73
+ for (const arg of input) {
74
+ if (flags && (arg === '--' || !arg.startsWith('-')))
75
+ flags = false;
76
+ if (flags && arg === '--no-target-directory') {
77
+ plain = true;
78
+ continue;
79
+ }
80
+ if (flags && arg.includes('T') && /^-[sfvnT]+$/.test(arg)) {
81
+ plain = true;
82
+ if (arg.replaceAll('T', '') === '-')
83
+ continue;
84
+ args.push(arg.replaceAll('T', ''));
85
+ if (/^-[sfvn]*s/.test(arg))
86
+ symbolic = true;
87
+ continue;
88
+ }
89
+ if (flags && (arg === '--symbolic' || /^-[sfvn]*s[sfvn]*$/.test(arg)))
90
+ symbolic = true;
91
+ args.push(arg);
92
+ }
93
+ if (!symbolic && !(args.length === 1 && args[0] === '--help')) {
94
+ return { stdout: '', stderr: 'ln: ENOTSUP: hard links are not supported by this backing\n', exitCode: 1 };
95
+ }
96
+ if (!context.origCommand)
97
+ return { stdout: '', stderr: 'ln: stock command is unavailable\n', exitCode: 126 };
98
+ const destination = args.at(-1);
99
+ if (plain && destination && !destination.startsWith('-') && await context.fs.stat(context.fs.resolvePath(context.cwd, destination)).then(stat => stat.isDirectory, () => false)) {
100
+ return { stdout: '', stderr: `ln: cannot overwrite directory '${destination}'\n`, exitCode: 1 };
101
+ }
102
+ return context.origCommand(args);
103
+ });
104
+ function nativeEnvironment(fs, id, cwd, closed, release, createBash) {
105
+ const failure = (context, path) => closed() ? new FileError('invalid', 'Workspace lease is closed', path)
106
+ : context.abortSignal?.aborted ? new FileError('aborted', 'Operation aborted', path) : undefined;
107
+ const absolute = (path) => {
108
+ if (typeof path !== 'string' || path.includes('\0') || typeof environment.cwd !== 'string' || !environment.cwd.startsWith('/'))
109
+ throw new FileError('invalid', 'Invalid virtual path', path);
110
+ return posix.resolve(environment.cwd, path);
111
+ };
112
+ async function run(path, context, operation) {
113
+ try {
114
+ const before = failure(context, path);
115
+ if (before)
116
+ return err(before);
117
+ const value = await operation(absolute(path));
118
+ const after = failure(context, path);
119
+ return after ? err(after) : ok(value);
120
+ }
121
+ catch (error) {
122
+ return err(fileError(error, path));
123
+ }
124
+ }
125
+ const info = async (path) => {
126
+ const stat = await fs.lstat(path);
127
+ return { path, name: posix.basename(path), kind: stat.isSymbolicLink ? 'symlink' : stat.isDirectory ? 'directory' : 'file', size: stat.size, mtimeMs: stat.mtime.getTime() };
128
+ };
129
+ async function temporary(prefix, suffix, directory) {
130
+ if (prefix.includes('/') || suffix.includes('/') || prefix.includes('\0') || suffix.includes('\0'))
131
+ throw new FileError('invalid', 'Temporary names must be basenames');
132
+ await fs.mkdir('/tmp', { recursive: true });
133
+ const path = `/tmp/${prefix}${randomUUID()}${suffix}`;
134
+ await fs.createExclusive(path, { mode: directory ? 0o700 : 0o600, directory });
135
+ return path;
136
+ }
137
+ // Readers and the watcher over the memory file system's whole-file reads (see fs-readers.ts).
138
+ const readers = snapshotReaders({ readBinaryFile: (path, context) => environment.readBinaryFile(path, context), fileInfo: (path, context) => environment.fileInfo(path, context), listDir: (path, context) => environment.listDir(path, context) });
139
+ const environment = {
140
+ id, cwd: posix.normalize(cwd), ...readers,
141
+ absolutePath: (path, context) => run(path, context, path => path),
142
+ joinPath: (parts, context) => run('.', context, () => posix.join(...parts)),
143
+ readTextFile: (path, context) => run(path, context, path => fs.readFile(path)),
144
+ readBinaryFile: (path, context) => run(path, context, async (path) => Uint8Array.from(await fs.readFileBuffer(path))),
145
+ writeFile: (path, content, context) => run(path, context, path => fs.writeFile(path, typeof content === 'string' ? content : Uint8Array.from(content))),
146
+ appendFile: (path, content, context) => run(path, context, path => fs.appendFile(path, typeof content === 'string' ? content : Uint8Array.from(content))),
147
+ truncateFile: (path, size, context) => run(path, context, path => {
148
+ if (!Number.isSafeInteger(size) || size < 0)
149
+ throw new FileError('invalid', 'File size must be a non-negative safe integer', path);
150
+ throw new FileError('not_supported', 'The selected backing has no atomic truncate operation', path);
151
+ }),
152
+ flushFile: (path, context) => run(path, context, () => { throw new FileError('not_supported', 'Memory storage cannot flush to durable backing', path); }),
153
+ // Qualified for one regular file onto a file or a missing path only; anything else keeps refusing before effects.
154
+ renameFile: (source, destination, context) => run(source, context, async (from) => {
155
+ const to = absolute(destination);
156
+ try {
157
+ await fs.replaceFile(from, to);
158
+ }
159
+ catch (error) {
160
+ if (error instanceof Error && /\b(ENOTSUP|EISDIR)\b/.test(error.message))
161
+ throw new FileError('not_supported', 'The selected backing renames a regular file onto a file or a missing path only', from);
162
+ throw error;
163
+ }
164
+ }),
165
+ fileInfo: (path, context) => run(path, context, info),
166
+ listDir: (path, context) => run(path, context, async (path) => Promise.all((await fs.readdir(path)).map(name => info(posix.join(path, name))))),
167
+ canonicalPath: (path, context) => run(path, context, path => fs.realpath(path)),
168
+ exists: (path, context) => run(path, context, async (path) => {
169
+ try {
170
+ await fs.lstat(path);
171
+ return true;
172
+ }
173
+ catch (error) {
174
+ if (fileError(error, path).code === 'not_found')
175
+ return false;
176
+ throw error;
177
+ }
178
+ }),
179
+ createDir: (path, options, context) => run(path, context, path => fs.mkdir(path, { ...options, recursive: options?.recursive ?? true })),
180
+ remove: (path, options, context) => run(path, context, path => fs.rm(path, options)),
181
+ createTempDir: (prefix, context) => run('.', context, () => temporary(prefix ?? 'boring-', '', true)),
182
+ createTempFile: (options, context) => run('.', context, () => temporary(options?.prefix ?? 'boring-', options?.suffix ?? '', false)),
183
+ openTextLineReader: (path, context) => run(path, context, async (path) => {
184
+ const text = await fs.readFile(path);
185
+ let offset = 0, readerClosed = false;
186
+ const reader = {
187
+ readLine: async (context) => {
188
+ const invalid = failure(context, path);
189
+ if (invalid)
190
+ return err(invalid);
191
+ if (readerClosed)
192
+ return err(new FileError('invalid', 'Text line reader is closed', path));
193
+ if (offset === text.length)
194
+ return ok(undefined);
195
+ const newline = text.indexOf('\n', offset);
196
+ const line = { text: text.slice(offset, newline === -1 ? undefined : newline), terminated: newline !== -1 };
197
+ offset = newline === -1 ? text.length : newline + 1;
198
+ return ok(line);
199
+ },
200
+ close: async () => { readerClosed = true; },
201
+ };
202
+ return reader;
203
+ }),
204
+ readTextLines: async (path, options, context) => {
205
+ if (options?.maxLines !== undefined && options.maxLines <= 0)
206
+ return ok([]);
207
+ const opened = await environment.openTextLineReader(path, context);
208
+ if (!opened.ok)
209
+ return opened;
210
+ const lines = [];
211
+ try {
212
+ while (options?.maxLines === undefined || lines.length < options.maxLines) {
213
+ const line = await opened.value.readLine(context);
214
+ if (!line.ok)
215
+ return line;
216
+ if (line.value === undefined)
217
+ break;
218
+ lines.push(line.value.text);
219
+ }
220
+ return ok(lines);
221
+ }
222
+ finally {
223
+ await opened.value.close(context);
224
+ }
225
+ },
226
+ exec: async (command, options, context) => {
227
+ if (closed())
228
+ return err(new ExecutionError('shell_unavailable', 'Workspace lease is closed'));
229
+ if (context.abortSignal?.aborted)
230
+ return err(new ExecutionError('aborted', 'Command aborted'));
231
+ // just-bash buffers output: `onOutput` receives it once when the command ends, never as a live stream, and
232
+ // nothing is spilled to a file. This is what lets Pi's stock bash tool run here. `timeout` is in seconds.
233
+ const timer = new AbortController(), signals = [timer.signal, ...(context.abortSignal ? [context.abortSignal] : [])];
234
+ const started = Date.now();
235
+ const timeout = options?.timeout === undefined ? undefined : setTimeout(() => timer.abort(), options.timeout * 1000);
236
+ try {
237
+ const cwd = absolute(options?.cwd ?? '.');
238
+ try {
239
+ if (!(await fs.stat(cwd)).isDirectory)
240
+ throw new Error('not a directory');
241
+ }
242
+ catch (cause) {
243
+ return err(new ExecutionError('spawn_error', `Working directory does not exist: ${cwd}\nCannot execute bash commands.`, cause instanceof Error ? cause : undefined));
244
+ }
245
+ const result = await createBash({ cwd, ...(options?.timeout === undefined ? {} : { executionLimits: { maxExecutionTimeMs: options.timeout * 1000 } }) }).exec(shellCommand(command), {
246
+ ...(options?.env === undefined ? {} : { env: options.env }), replaceEnv: options?.inheritEnv === false,
247
+ signal: AbortSignal.any(signals),
248
+ });
249
+ if (context.abortSignal?.aborted)
250
+ return err(new ExecutionError('aborted', 'Command aborted; prior effects may remain'));
251
+ // A synchronous script never yields to the timer above; just-bash's own wall-clock limit stops it instead.
252
+ if (timer.signal.aborted || options?.timeout !== undefined && result.exitCode !== 0 && Date.now() - started >= options.timeout * 1000)
253
+ return err(new ExecutionError('timeout', 'Command timed out; prior effects may remain'));
254
+ if (options?.onOutput) {
255
+ try {
256
+ if (result.stdout)
257
+ options.onOutput(result.stdout, context, { stream: 'stdout' });
258
+ if (result.stderr)
259
+ options.onOutput(result.stderr, context, { stream: 'stderr' });
260
+ }
261
+ catch (error) {
262
+ return err(new ExecutionError('callback_error', boundedMessage(error), error instanceof Error ? error : undefined));
263
+ }
264
+ }
265
+ return ok({ exitCode: result.exitCode });
266
+ }
267
+ catch (error) {
268
+ return err(new ExecutionError('unknown', boundedMessage(error), error instanceof Error ? error : undefined));
269
+ }
270
+ finally {
271
+ clearTimeout(timeout);
272
+ }
273
+ },
274
+ cleanup: release,
275
+ };
276
+ return environment;
277
+ }
package/package.json CHANGED
@@ -1,6 +1,96 @@
1
1
  {
2
2
  "name": "@hachej/boring-execution",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.2.1",
4
+ "license": "MIT",
5
+ "private": false,
6
+ "type": "module",
7
+ "description": "Native Pi working-environment types and independently composable workspace leases.",
8
+ "sideEffects": false,
9
+ "files": [
10
+ "dist/**/*.js",
11
+ "dist/**/*.d.ts",
12
+ "README.md",
13
+ "LICENSE"
14
+ ],
15
+ "types": "./dist/index.d.ts",
16
+ "exports": {
17
+ ".": {
18
+ "types": "./dist/index.d.ts",
19
+ "import": "./dist/index.js"
20
+ },
21
+ "./native": {
22
+ "types": "./dist/native.d.ts",
23
+ "import": "./dist/native.js"
24
+ },
25
+ "./contracts": {
26
+ "types": "./dist/contracts.d.ts",
27
+ "import": "./dist/contracts.js"
28
+ },
29
+ "./virtual": {
30
+ "types": "./dist/virtual.d.ts",
31
+ "import": "./dist/virtual.js"
32
+ },
33
+ "./virtual-git": {
34
+ "types": "./dist/virtual-git.d.ts",
35
+ "import": "./dist/virtual-git.js"
36
+ },
37
+ "./remote-shell": {
38
+ "types": "./dist/remote-shell.d.ts",
39
+ "import": "./dist/remote-shell.js"
40
+ },
41
+ "./remote-files": {
42
+ "types": "./dist/remote-files.d.ts",
43
+ "import": "./dist/remote-files.js"
44
+ },
45
+ "./virtual-sqlite": {
46
+ "types": "./dist/virtual-sqlite.d.ts",
47
+ "import": "./dist/virtual-sqlite.js"
48
+ },
49
+ "./aws-code-interpreter": {
50
+ "types": "./dist/aws-code-interpreter.d.ts",
51
+ "import": "./dist/aws-code-interpreter.js"
52
+ }
53
+ },
54
+ "peerDependencies": {
55
+ "@earendil-works/pi-durable": "1.1.0",
56
+ "@earendil-works/chord": "1.1.0",
57
+ "just-bash": "3.6.0",
58
+ "isomorphic-git": "1.42.6",
59
+ "@hachej/boring-files": "0.2.1",
60
+ "zod": "4.6.5",
61
+ "@aws-sdk/client-bedrock-agentcore": "3.1146.0"
62
+ },
63
+ "engines": {
64
+ "node": ">=22.19.0"
65
+ },
66
+ "peerDependenciesMeta": {
67
+ "just-bash": {
68
+ "optional": true
69
+ },
70
+ "isomorphic-git": {
71
+ "optional": true
72
+ },
73
+ "@hachej/boring-files": {
74
+ "optional": true
75
+ },
76
+ "zod": {
77
+ "optional": true
78
+ },
79
+ "@aws-sdk/client-bedrock-agentcore": {
80
+ "optional": true
81
+ }
82
+ },
83
+ "repository": {
84
+ "type": "git",
85
+ "url": "git+https://github.com/hachej/boring-ui.git",
86
+ "directory": "packages/execution"
87
+ },
88
+ "homepage": "https://github.com/hachej/boring-ui#readme",
89
+ "bugs": {
90
+ "url": "https://github.com/hachej/boring-ui/issues"
91
+ },
92
+ "publishConfig": {
93
+ "access": "public",
94
+ "registry": "https://registry.npmjs.org/"
95
+ }
96
+ }