@hachej/boring-execution 0.0.0-stage → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +97 -2
- package/dist/aws-code-interpreter.d.ts +149 -0
- package/dist/aws-code-interpreter.js +453 -0
- package/dist/bounded-error.d.ts +8 -0
- package/dist/bounded-error.js +12 -0
- package/dist/contracts.d.ts +46 -0
- package/dist/contracts.js +1 -0
- package/dist/fs-readers.d.ts +7 -0
- package/dist/fs-readers.js +72 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.js +1 -0
- package/dist/native.d.ts +8 -0
- package/dist/native.js +1 -0
- package/dist/remote-files-handler.d.ts +21 -0
- package/dist/remote-files-handler.js +215 -0
- package/dist/remote-files-io.d.ts +8 -0
- package/dist/remote-files-io.js +46 -0
- package/dist/remote-files-positional.d.ts +12 -0
- package/dist/remote-files-positional.js +208 -0
- package/dist/remote-files-protocol.d.ts +342 -0
- package/dist/remote-files-protocol.js +113 -0
- package/dist/remote-files-watch.d.ts +29 -0
- package/dist/remote-files-watch.js +154 -0
- package/dist/remote-files.d.ts +17 -0
- package/dist/remote-files.js +238 -0
- package/dist/remote-shell-handler.d.ts +25 -0
- package/dist/remote-shell-handler.js +131 -0
- package/dist/remote-shell-protocol.d.ts +50 -0
- package/dist/remote-shell-protocol.js +163 -0
- package/dist/remote-shell-window.d.ts +14 -0
- package/dist/remote-shell-window.js +82 -0
- package/dist/remote-shell.d.ts +15 -0
- package/dist/remote-shell.js +127 -0
- package/dist/virtual-filesystem.d.ts +67 -0
- package/dist/virtual-filesystem.js +218 -0
- package/dist/virtual-git.d.ts +60 -0
- package/dist/virtual-git.js +116 -0
- package/dist/virtual-sqlite.d.ts +42 -0
- package/dist/virtual-sqlite.js +50 -0
- package/dist/virtual.d.ts +34 -0
- package/dist/virtual.js +277 -0
- package/package.json +94 -4
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
const encoder = new TextEncoder();
|
|
2
|
+
const byteLength = (text) => encoder.encode(text).length;
|
|
3
|
+
const newlines = (text) => { let count = 0; for (let at = text.indexOf('\n'); at !== -1; at = text.indexOf('\n', at + 1))
|
|
4
|
+
count++; return count; };
|
|
5
|
+
/** Index where the remainder holds more than `maxBytes` UTF-8 bytes (the shortest such suffix), or -1 when the text is not longer. */
|
|
6
|
+
function byteCut(text, maxBytes) {
|
|
7
|
+
let bytes = 0, index = text.length;
|
|
8
|
+
while (index > 0) {
|
|
9
|
+
const point = index >= 2 && text.charCodeAt(index - 1) >= 0xdc00 && text.charCodeAt(index - 1) <= 0xdfff && text.charCodeAt(index - 2) >= 0xd800 && text.charCodeAt(index - 2) <= 0xdbff ? 2 : 1;
|
|
10
|
+
index -= point;
|
|
11
|
+
bytes += byteLength(text.slice(index, index + point));
|
|
12
|
+
if (bytes > maxBytes)
|
|
13
|
+
return index === 0 ? -1 : index;
|
|
14
|
+
}
|
|
15
|
+
return -1;
|
|
16
|
+
}
|
|
17
|
+
/** Index where the remainder holds more than `maxLines` newlines (the shortest such suffix), or -1. */
|
|
18
|
+
function lineCut(text, maxLines) {
|
|
19
|
+
let at = text.length;
|
|
20
|
+
for (let count = 0; count <= maxLines; count++) {
|
|
21
|
+
at = text.lastIndexOf('\n', at - 1);
|
|
22
|
+
if (at <= 0)
|
|
23
|
+
return -1;
|
|
24
|
+
}
|
|
25
|
+
return at;
|
|
26
|
+
}
|
|
27
|
+
/** Collects output, drops what the window cannot keep, and flushes no faster than `minIntervalMs`. */
|
|
28
|
+
export function windowedOutput(window, emit) {
|
|
29
|
+
let text = '', stream = 'stdout', skip, last = 0;
|
|
30
|
+
let timer;
|
|
31
|
+
const omit = (omitted) => {
|
|
32
|
+
if (omitted === '')
|
|
33
|
+
return;
|
|
34
|
+
skip = { bytes: (skip?.bytes ?? 0) + byteLength(omitted), newlines: (skip?.newlines ?? 0) + newlines(omitted), endsWithNewline: omitted.endsWith('\n') };
|
|
35
|
+
};
|
|
36
|
+
const trim = () => {
|
|
37
|
+
const cuts = [byteCut(text, window.maxBytes), lineCut(text, window.maxLines)].filter(cut => cut > 0);
|
|
38
|
+
if (cuts.length === 0)
|
|
39
|
+
return;
|
|
40
|
+
const cut = Math.max(...cuts);
|
|
41
|
+
omit(text.slice(0, cut));
|
|
42
|
+
text = text.slice(cut);
|
|
43
|
+
};
|
|
44
|
+
const flush = () => {
|
|
45
|
+
if (timer !== undefined) {
|
|
46
|
+
clearTimeout(timer);
|
|
47
|
+
timer = undefined;
|
|
48
|
+
}
|
|
49
|
+
trim();
|
|
50
|
+
if (text === '' && skip === undefined)
|
|
51
|
+
return;
|
|
52
|
+
const chunk = { text, stream, ...(skip === undefined ? {} : { skipped: skip }) };
|
|
53
|
+
text = '';
|
|
54
|
+
skip = undefined;
|
|
55
|
+
last = Date.now();
|
|
56
|
+
emit(chunk);
|
|
57
|
+
};
|
|
58
|
+
return {
|
|
59
|
+
push(chunk, info) {
|
|
60
|
+
if (info.skipped !== undefined) {
|
|
61
|
+
omit(text);
|
|
62
|
+
text = '';
|
|
63
|
+
skip = { bytes: (skip?.bytes ?? 0) + info.skipped.bytes, newlines: (skip?.newlines ?? 0) + info.skipped.newlines, endsWithNewline: info.skipped.endsWithNewline };
|
|
64
|
+
}
|
|
65
|
+
text += chunk;
|
|
66
|
+
stream = info.stream;
|
|
67
|
+
trim();
|
|
68
|
+
const wait = last + window.minIntervalMs - Date.now();
|
|
69
|
+
if (wait <= 0)
|
|
70
|
+
flush();
|
|
71
|
+
else
|
|
72
|
+
timer ??= setTimeout(() => { timer = undefined; try {
|
|
73
|
+
flush();
|
|
74
|
+
}
|
|
75
|
+
catch { /* the transport closed */ } }, wait);
|
|
76
|
+
},
|
|
77
|
+
/** Deliver what is buffered before the terminal result. */
|
|
78
|
+
finish: flush,
|
|
79
|
+
cancel() { if (timer !== undefined)
|
|
80
|
+
clearTimeout(timer); timer = undefined; text = ''; skip = undefined; },
|
|
81
|
+
};
|
|
82
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import type { Shell } from '@earendil-works/pi-durable/env';
|
|
2
|
+
import type { WorkspaceIdentity, WorkspaceLease } from './contracts.js';
|
|
3
|
+
export { createRemoteShellHandler } from './remote-shell-handler.js';
|
|
4
|
+
export type { RemoteShellAccess, RemoteShellHandlerOptions } from './remote-shell-handler.js';
|
|
5
|
+
export interface RemoteShellOptions {
|
|
6
|
+
readonly identity: WorkspaceIdentity;
|
|
7
|
+
readonly endpoint: string | URL;
|
|
8
|
+
/** Trusted authenticated transport. It must honor redirect:error and must not retry command requests. */
|
|
9
|
+
readonly fetch: (request: Request) => Promise<Response>;
|
|
10
|
+
readonly maxRequestBytes?: number;
|
|
11
|
+
readonly maxFrameBytes?: number;
|
|
12
|
+
}
|
|
13
|
+
/** Borrow the remote workspace. Cleanup cancels this lease's transports, not the provider or another lease. */
|
|
14
|
+
export declare function createRemoteShellLease(options: RemoteShellOptions): WorkspaceLease<Shell>;
|
|
15
|
+
//# sourceMappingURL=remote-shell.d.ts.map
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
import { awaitWithContext, withAbortSignal } from '@earendil-works/chord/context';
|
|
2
|
+
import { ExecutionError, err } from '@earendil-works/pi-durable/env';
|
|
3
|
+
import { contentType, frame, identity, nativeVersion, positiveLimit, sameIdentity, schema, version, wireOptions } from './remote-shell-protocol.js';
|
|
4
|
+
import { randomUUID } from '@hachej/boring-files/platform';
|
|
5
|
+
import { shellCommand } from './fs-readers.js';
|
|
6
|
+
export { createRemoteShellHandler } from './remote-shell-handler.js';
|
|
7
|
+
/** Borrow the remote workspace. Cleanup cancels this lease's transports, not the provider or another lease. */
|
|
8
|
+
export function createRemoteShellLease(options) {
|
|
9
|
+
const selected = identity(options.identity), endpoint = new URL(options.endpoint);
|
|
10
|
+
if (!['https:', 'http:'].includes(endpoint.protocol) || endpoint.username || endpoint.password || endpoint.hash)
|
|
11
|
+
throw new TypeError('Expected an HTTP endpoint without URL credentials or fragment');
|
|
12
|
+
const fetch = options.fetch;
|
|
13
|
+
const maxRequestBytes = positiveLimit(options.maxRequestBytes ?? 65_536), maxFrameBytes = positiveLimit(options.maxFrameBytes ?? 1_048_576);
|
|
14
|
+
const active = new Set();
|
|
15
|
+
let closed = false;
|
|
16
|
+
const release = async () => { closed = true; for (const request of active)
|
|
17
|
+
request.abort(); active.clear(); };
|
|
18
|
+
const environment = {
|
|
19
|
+
cleanup: release,
|
|
20
|
+
exec: async (command, options, context) => {
|
|
21
|
+
if (closed)
|
|
22
|
+
return err(new ExecutionError('shell_unavailable', 'Remote shell lease is closed'));
|
|
23
|
+
if (context.abortSignal?.aborted)
|
|
24
|
+
return err(new ExecutionError('aborted', 'Command aborted before dispatch'));
|
|
25
|
+
const onOutput = options?.onOutput;
|
|
26
|
+
if (onOutput !== undefined && typeof onOutput !== 'function')
|
|
27
|
+
return err(new ExecutionError('callback_error', 'Expected a synchronous output callback'));
|
|
28
|
+
const requestId = randomUUID(), abort = new AbortController();
|
|
29
|
+
const signal = context.abortSignal ? AbortSignal.any([abort.signal, context.abortSignal]) : abort.signal;
|
|
30
|
+
const local = withAbortSignal(signal, context);
|
|
31
|
+
let reader;
|
|
32
|
+
let dispatched = false;
|
|
33
|
+
active.add(abort);
|
|
34
|
+
try {
|
|
35
|
+
const { onOutput: _callback, ...arguments_ } = options ?? {};
|
|
36
|
+
const body = JSON.stringify({ schema, version, nativeVersion, requestId, identity: selected, command: shellCommand(command), options: wireOptions(arguments_), output: onOutput !== undefined });
|
|
37
|
+
if ((typeof command !== 'string' && !Array.isArray(command)) || new TextEncoder().encode(body).byteLength > maxRequestBytes)
|
|
38
|
+
return err(new ExecutionError('shell_unavailable', 'Remote shell request is invalid or exceeds its byte limit'));
|
|
39
|
+
const request = new Request(endpoint, { method: 'POST', headers: { 'content-type': 'application/json' }, body, redirect: 'error', signal });
|
|
40
|
+
dispatched = true;
|
|
41
|
+
const pending = fetch(request);
|
|
42
|
+
void pending.then(response => { if (signal?.aborted)
|
|
43
|
+
void response.body?.cancel().catch(() => { }); }, () => { });
|
|
44
|
+
const response = await awaitWithContext(pending, local);
|
|
45
|
+
if (response.redirected || response.status !== 200 || response.headers.get('content-type')?.split(';')[0]?.trim() !== contentType || !response.body) {
|
|
46
|
+
void response.body?.cancel().catch(() => { });
|
|
47
|
+
return err(new ExecutionError('unknown', `Remote shell response refused or unavailable (${response.status}); command outcome is unconfirmed`));
|
|
48
|
+
}
|
|
49
|
+
reader = response.body.getReader();
|
|
50
|
+
let sequence = 0, header = false, terminal;
|
|
51
|
+
for await (const value of frames(reader, local, maxFrameBytes)) {
|
|
52
|
+
if (signal.aborted)
|
|
53
|
+
throw new Error('Remote shell observation aborted');
|
|
54
|
+
const current = frame(value, requestId, sequence++);
|
|
55
|
+
if (terminal !== undefined)
|
|
56
|
+
throw new Error('Data after terminal result');
|
|
57
|
+
if (!header) {
|
|
58
|
+
if (current.type !== 'header' || !sameIdentity(current.identity, selected))
|
|
59
|
+
throw new Error('Remote workspace identity mismatch');
|
|
60
|
+
header = true;
|
|
61
|
+
}
|
|
62
|
+
else if (current.type === 'output') {
|
|
63
|
+
try {
|
|
64
|
+
onOutput?.(current.text, context, current.skipped === undefined ? { stream: current.stream } : { stream: current.stream, skipped: current.skipped });
|
|
65
|
+
}
|
|
66
|
+
catch {
|
|
67
|
+
return err(new ExecutionError('callback_error', 'Local output callback failed; remote termination is unconfirmed'));
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
else if (current.type === 'result')
|
|
71
|
+
terminal = current.result;
|
|
72
|
+
else
|
|
73
|
+
throw new Error('Duplicate remote shell header');
|
|
74
|
+
}
|
|
75
|
+
if (signal.aborted || !header || terminal === undefined)
|
|
76
|
+
throw new Error('Missing terminal acknowledgement');
|
|
77
|
+
return terminal;
|
|
78
|
+
}
|
|
79
|
+
catch {
|
|
80
|
+
return err(new ExecutionError(signal.aborted ? 'aborted' : dispatched ? 'unknown' : 'shell_unavailable', dispatched
|
|
81
|
+
? 'Remote shell observation ended; prior effects may remain and remote termination is unconfirmed'
|
|
82
|
+
: 'Invalid remote shell request; command was not dispatched'));
|
|
83
|
+
}
|
|
84
|
+
finally {
|
|
85
|
+
abort.abort();
|
|
86
|
+
active.delete(abort);
|
|
87
|
+
if (reader) {
|
|
88
|
+
void reader.cancel().catch(() => { });
|
|
89
|
+
reader.releaseLock();
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
},
|
|
93
|
+
};
|
|
94
|
+
return { identity: selected, environment, ownership: 'borrowed', release };
|
|
95
|
+
}
|
|
96
|
+
async function* frames(reader, context, limit) {
|
|
97
|
+
let pieces = [], length = 0;
|
|
98
|
+
while (true) {
|
|
99
|
+
const item = await awaitWithContext(reader.read(), context);
|
|
100
|
+
if (item.done) {
|
|
101
|
+
if (length !== 0)
|
|
102
|
+
throw new Error('Truncated remote shell frame');
|
|
103
|
+
return;
|
|
104
|
+
}
|
|
105
|
+
let start = 0;
|
|
106
|
+
while (start < item.value.byteLength) {
|
|
107
|
+
const newline = item.value.indexOf(10, start), end = newline === -1 ? item.value.byteLength : newline;
|
|
108
|
+
const piece = item.value.subarray(start, end);
|
|
109
|
+
length += piece.byteLength;
|
|
110
|
+
if (length > limit)
|
|
111
|
+
throw new Error('Remote shell frame exceeds its byte limit');
|
|
112
|
+
pieces.push(piece);
|
|
113
|
+
if (newline === -1)
|
|
114
|
+
break;
|
|
115
|
+
const bytes = new Uint8Array(length);
|
|
116
|
+
let offset = 0;
|
|
117
|
+
for (const part of pieces) {
|
|
118
|
+
bytes.set(part, offset);
|
|
119
|
+
offset += part.byteLength;
|
|
120
|
+
}
|
|
121
|
+
pieces = [];
|
|
122
|
+
length = 0;
|
|
123
|
+
yield JSON.parse(new TextDecoder('utf-8', { fatal: true }).decode(bytes));
|
|
124
|
+
start = newline + 1;
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import { InMemoryFs } from 'just-bash';
|
|
2
|
+
import type { IFileSystem, FileContent, MkdirOptions, RmOptions, CpOptions, BufferEncoding, FsStat, CreateExclusiveOptions } from 'just-bash';
|
|
3
|
+
type ReadFileOptions = Exclude<Parameters<IFileSystem['readFile']>[1], string | undefined>;
|
|
4
|
+
type WriteFileOptions = Exclude<Parameters<IFileSystem['writeFile']>[2], string | undefined>;
|
|
5
|
+
type DirentEntry = Awaited<ReturnType<NonNullable<IFileSystem['readdirWithFileTypes']>>>[number];
|
|
6
|
+
import type { FileSystem } from '@earendil-works/pi-durable/env';
|
|
7
|
+
export { MAX_ERROR_CHARS, boundedMessage } from './bounded-error.js';
|
|
8
|
+
/** What the shell and the native environment need from the files under them: just-bash's interface plus an atomic file replace. */
|
|
9
|
+
export type VirtualFiles = IFileSystem & {
|
|
10
|
+
readonly replaceFile: (source: string, destination: string) => Promise<void>;
|
|
11
|
+
readonly createExclusive: (path: string, options: CreateExclusiveOptions) => Promise<void>;
|
|
12
|
+
};
|
|
13
|
+
/**
|
|
14
|
+
* just-bash's file interface over a Pi `FileSystem` (for example `@hachej/boring-files/sqlite-filesystem`), so the shell, Git and the native
|
|
15
|
+
* environment work on the very files the workspace provider reads and writes: one view, no copy. That file system has regular files
|
|
16
|
+
* and directories only: links are refused, `chmod` changes nothing (files report 0644, directories 0755) and `utimes` sets the
|
|
17
|
+
* modification time to now.
|
|
18
|
+
*/
|
|
19
|
+
export declare class FileSystemView implements VirtualFiles {
|
|
20
|
+
private readonly fs;
|
|
21
|
+
constructor(fs: FileSystem);
|
|
22
|
+
private stats;
|
|
23
|
+
readFile(path: string, options?: ReadFileOptions | BufferEncoding): Promise<string>;
|
|
24
|
+
readFileBuffer(path: string): Promise<Uint8Array>;
|
|
25
|
+
writeFile(path: string, content: FileContent, options?: WriteFileOptions | BufferEncoding): Promise<void>;
|
|
26
|
+
appendFile(path: string, content: FileContent, options?: WriteFileOptions | BufferEncoding): Promise<void>;
|
|
27
|
+
exists(path: string): Promise<boolean>;
|
|
28
|
+
stat(path: string): Promise<FsStat>;
|
|
29
|
+
lstat(path: string): Promise<FsStat>;
|
|
30
|
+
mkdir(path: string, options?: MkdirOptions): Promise<void>;
|
|
31
|
+
readdir(path: string): Promise<string[]>;
|
|
32
|
+
readdirWithFileTypes(path: string): Promise<DirentEntry[]>;
|
|
33
|
+
rm(path: string, options?: RmOptions): Promise<void>;
|
|
34
|
+
cp(source: string, destination: string, options?: CpOptions): Promise<void>;
|
|
35
|
+
mv(source: string, destination: string): Promise<void>;
|
|
36
|
+
replaceFile(source: string, destination: string): Promise<void>;
|
|
37
|
+
createExclusive(path: string, options: CreateExclusiveOptions): Promise<void>;
|
|
38
|
+
resolvePath(base: string, path: string): string;
|
|
39
|
+
/** Not enumerable synchronously over an asynchronous file system; nothing in the shell needs it. */
|
|
40
|
+
getAllPaths(): string[];
|
|
41
|
+
chmod(path: string): Promise<void>;
|
|
42
|
+
symlink(_target: string, path: string): Promise<void>;
|
|
43
|
+
link(_source: string, path: string): Promise<void>;
|
|
44
|
+
readlink(path: string): Promise<string>;
|
|
45
|
+
realpath(path: string): Promise<string>;
|
|
46
|
+
utimes(path: string): Promise<void>;
|
|
47
|
+
}
|
|
48
|
+
/** Resolve missing leaves and existing aliases through public upstream filesystem operations. */
|
|
49
|
+
export declare function mutationPath(fs: IFileSystem, input: string, parents: boolean, followFinal: boolean): Promise<string>;
|
|
50
|
+
/** Upstream owns storage. Mutating methods resolve aliases consistently with upstream readers. */
|
|
51
|
+
export declare class VirtualFileSystem extends InMemoryFs {
|
|
52
|
+
private requireFileTarget;
|
|
53
|
+
writeFile(path: string, content: FileContent, options?: Parameters<InMemoryFs['writeFile']>[2]): Promise<void>;
|
|
54
|
+
appendFile(path: string, content: FileContent, options?: Parameters<InMemoryFs['writeFile']>[2]): Promise<void>;
|
|
55
|
+
mkdir(path: string, options?: MkdirOptions): Promise<void>;
|
|
56
|
+
rm(path: string, options?: RmOptions): Promise<void>;
|
|
57
|
+
createExclusive(path: string, options: Parameters<InMemoryFs['createExclusive']>[1]): Promise<void>;
|
|
58
|
+
mv(_source: string, _destination: string): Promise<void>;
|
|
59
|
+
/** Move one regular file over a file or a missing path in a single synchronous upstream step, so a reader sees the old or the new bytes. */
|
|
60
|
+
replaceFile(source: string, destination: string): Promise<void>;
|
|
61
|
+
cp(source: string, destination: string, options?: CpOptions): Promise<void>;
|
|
62
|
+
symlink(target: string, path: string): Promise<void>;
|
|
63
|
+
link(_source: string, _destination: string): Promise<void>;
|
|
64
|
+
chmod(path: string, mode: number): Promise<void>;
|
|
65
|
+
utimes(path: string, atime: Date, mtime: Date): Promise<void>;
|
|
66
|
+
}
|
|
67
|
+
//# sourceMappingURL=virtual-filesystem.d.ts.map
|
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
import { posix } from 'node:path';
|
|
2
|
+
import { InMemoryFs } from 'just-bash';
|
|
3
|
+
import { BACKGROUND_CONTEXT } from '@earendil-works/chord/context';
|
|
4
|
+
import { boundedMessage } from './bounded-error.js';
|
|
5
|
+
export { MAX_ERROR_CHARS, boundedMessage } from './bounded-error.js';
|
|
6
|
+
const errno = {
|
|
7
|
+
not_found: 'ENOENT', not_directory: 'ENOTDIR', is_directory: 'EISDIR', permission_denied: 'EACCES', invalid: 'EINVAL', not_supported: 'ENOTSUP', aborted: 'ECANCELED', unknown: 'EIO',
|
|
8
|
+
};
|
|
9
|
+
async function value(pending) {
|
|
10
|
+
const result = await pending;
|
|
11
|
+
if (result.ok)
|
|
12
|
+
return result.value;
|
|
13
|
+
// Shells and Git read the POSIX code from the start of the message.
|
|
14
|
+
const message = boundedMessage(result.error);
|
|
15
|
+
throw new Error(/^E[A-Z]+\b/.test(message) ? message : `${errno[result.error.code]}: ${message}`, { cause: result.error });
|
|
16
|
+
}
|
|
17
|
+
const encodingOf = (options) => (typeof options === 'string' ? options : options?.encoding) ?? 'utf8';
|
|
18
|
+
const latin1 = (bytes) => Array.from(bytes, byte => String.fromCharCode(byte)).join('');
|
|
19
|
+
function decode(bytes, encoding) {
|
|
20
|
+
if (encoding === 'utf8' || encoding === 'utf-8' || encoding === null)
|
|
21
|
+
return new TextDecoder().decode(bytes);
|
|
22
|
+
if (encoding === 'base64')
|
|
23
|
+
return btoa(latin1(bytes));
|
|
24
|
+
if (encoding === 'hex')
|
|
25
|
+
return Array.from(bytes, byte => byte.toString(16).padStart(2, '0')).join('');
|
|
26
|
+
return encoding === 'ascii' ? latin1(bytes.map(byte => byte & 0x7f)) : latin1(bytes);
|
|
27
|
+
}
|
|
28
|
+
function encode(content, encoding) {
|
|
29
|
+
if (typeof content !== 'string')
|
|
30
|
+
return Uint8Array.from(content);
|
|
31
|
+
if (encoding === 'utf8' || encoding === 'utf-8')
|
|
32
|
+
return new TextEncoder().encode(content);
|
|
33
|
+
if (encoding === 'base64')
|
|
34
|
+
return Uint8Array.from(atob(content), char => char.charCodeAt(0));
|
|
35
|
+
if (encoding === 'hex')
|
|
36
|
+
return Uint8Array.from(content.match(/../g) ?? [], pair => Number.parseInt(pair, 16));
|
|
37
|
+
return Uint8Array.from(content, char => char.charCodeAt(0) & 0xff);
|
|
38
|
+
}
|
|
39
|
+
/**
|
|
40
|
+
* just-bash's file interface over a Pi `FileSystem` (for example `@hachej/boring-files/sqlite-filesystem`), so the shell, Git and the native
|
|
41
|
+
* environment work on the very files the workspace provider reads and writes: one view, no copy. That file system has regular files
|
|
42
|
+
* and directories only: links are refused, `chmod` changes nothing (files report 0644, directories 0755) and `utimes` sets the
|
|
43
|
+
* modification time to now.
|
|
44
|
+
*/
|
|
45
|
+
export class FileSystemView {
|
|
46
|
+
fs;
|
|
47
|
+
constructor(fs) {
|
|
48
|
+
this.fs = fs;
|
|
49
|
+
}
|
|
50
|
+
stats = async (path) => {
|
|
51
|
+
const info = await value(this.fs.fileInfo(path, BACKGROUND_CONTEXT));
|
|
52
|
+
const directory = info.kind === 'directory';
|
|
53
|
+
return { isFile: !directory, isDirectory: directory, isSymbolicLink: false, mode: directory ? 0o755 : 0o644, size: info.size, mtime: new Date(info.mtimeMs) };
|
|
54
|
+
};
|
|
55
|
+
async readFile(path, options) { return decode(await this.readFileBuffer(path), encodingOf(options)); }
|
|
56
|
+
readFileBuffer(path) { return value(this.fs.readBinaryFile(path, BACKGROUND_CONTEXT)); }
|
|
57
|
+
writeFile(path, content, options) { return value(this.fs.writeFile(path, encode(content, encodingOf(options) ?? 'utf8'), BACKGROUND_CONTEXT)); }
|
|
58
|
+
appendFile(path, content, options) { return value(this.fs.appendFile(path, encode(content, encodingOf(options) ?? 'utf8'), BACKGROUND_CONTEXT)); }
|
|
59
|
+
exists(path) { return value(this.fs.exists(path, BACKGROUND_CONTEXT)); }
|
|
60
|
+
stat(path) { return this.stats(path); }
|
|
61
|
+
lstat(path) { return this.stats(path); }
|
|
62
|
+
mkdir(path, options) { return value(this.fs.createDir(path, { recursive: options?.recursive === true }, BACKGROUND_CONTEXT)); }
|
|
63
|
+
async readdir(path) { return (await value(this.fs.listDir(path, BACKGROUND_CONTEXT))).map(entry => entry.name).sort(); }
|
|
64
|
+
async readdirWithFileTypes(path) {
|
|
65
|
+
return (await value(this.fs.listDir(path, BACKGROUND_CONTEXT))).sort((a, b) => a.name < b.name ? -1 : 1)
|
|
66
|
+
.map(entry => ({ name: entry.name, isFile: entry.kind === 'file', isDirectory: entry.kind === 'directory', isSymbolicLink: entry.kind === 'symlink' }));
|
|
67
|
+
}
|
|
68
|
+
rm(path, options) { return value(this.fs.remove(path, { recursive: options?.recursive === true, force: options?.force === true }, BACKGROUND_CONTEXT)); }
|
|
69
|
+
async cp(source, destination, options) {
|
|
70
|
+
if (!(await this.stats(source)).isDirectory)
|
|
71
|
+
return this.writeFile(destination, await this.readFileBuffer(source));
|
|
72
|
+
if (!options?.recursive)
|
|
73
|
+
throw new Error(`EISDIR: copying a directory needs recursive, '${source}'`);
|
|
74
|
+
await this.mkdir(destination, { recursive: true });
|
|
75
|
+
for (const name of await this.readdir(source))
|
|
76
|
+
await this.cp(posix.join(source, name), posix.join(destination, name), options);
|
|
77
|
+
}
|
|
78
|
+
mv(source, destination) { return this.replaceFile(source, destination); }
|
|
79
|
+
replaceFile(source, destination) { return value(this.fs.renameFile(source, destination, BACKGROUND_CONTEXT)); }
|
|
80
|
+
async createExclusive(path, options) {
|
|
81
|
+
if (await this.exists(path))
|
|
82
|
+
throw new Error(`EEXIST: file already exists, '${path}'`);
|
|
83
|
+
await (options.directory ? this.mkdir(path) : this.writeFile(path, new Uint8Array()));
|
|
84
|
+
}
|
|
85
|
+
resolvePath(base, path) { return posix.resolve(base, path); }
|
|
86
|
+
/** Not enumerable synchronously over an asynchronous file system; nothing in the shell needs it. */
|
|
87
|
+
getAllPaths() { return []; }
|
|
88
|
+
async chmod(path) { await this.stats(path); }
|
|
89
|
+
async symlink(_target, path) { throw new Error(`ENOTSUP: symbolic links are not supported by this file system, '${path}'`); }
|
|
90
|
+
async link(_source, path) { throw new Error(`ENOTSUP: hard links are not supported by this file system, '${path}'`); }
|
|
91
|
+
async readlink(path) { await this.stats(path); throw new Error(`EINVAL: not a symbolic link, '${path}'`); }
|
|
92
|
+
realpath(path) { return value(this.fs.canonicalPath(path, BACKGROUND_CONTEXT)); }
|
|
93
|
+
async utimes(path) { if (!(await this.stats(path)).isDirectory)
|
|
94
|
+
await this.appendFile(path, new Uint8Array()); }
|
|
95
|
+
}
|
|
96
|
+
/** Resolve missing leaves and existing aliases through public upstream filesystem operations. */
|
|
97
|
+
export async function mutationPath(fs, input, parents, followFinal) {
|
|
98
|
+
if (!input.startsWith('/') || input.includes('\0'))
|
|
99
|
+
throw new Error('EINVAL: an absolute virtual path is required');
|
|
100
|
+
let parts = posix.normalize(input).split('/').filter(Boolean), current = '/', links = 0;
|
|
101
|
+
while (parts.length) {
|
|
102
|
+
const part = parts.shift();
|
|
103
|
+
if (part === undefined)
|
|
104
|
+
break;
|
|
105
|
+
const candidate = posix.join(current, part), final = parts.length === 0;
|
|
106
|
+
let stat;
|
|
107
|
+
try {
|
|
108
|
+
stat = await fs.lstat(candidate);
|
|
109
|
+
}
|
|
110
|
+
catch (error) {
|
|
111
|
+
if (!(error instanceof Error) || !/\bENOENT\b/.test(error.message))
|
|
112
|
+
throw error;
|
|
113
|
+
if (final)
|
|
114
|
+
return candidate;
|
|
115
|
+
if (!parents)
|
|
116
|
+
throw error;
|
|
117
|
+
await fs.mkdir(candidate, { recursive: true });
|
|
118
|
+
current = candidate;
|
|
119
|
+
continue;
|
|
120
|
+
}
|
|
121
|
+
if (stat.isSymbolicLink && (!final || followFinal)) {
|
|
122
|
+
if (++links > 40)
|
|
123
|
+
throw new Error('ELOOP: too many symbolic links');
|
|
124
|
+
parts = posix.resolve(current, await fs.readlink(candidate), ...parts).split('/').filter(Boolean);
|
|
125
|
+
current = '/';
|
|
126
|
+
continue;
|
|
127
|
+
}
|
|
128
|
+
if (!final && !stat.isDirectory)
|
|
129
|
+
throw new Error('ENOTDIR: path ancestor is not a directory');
|
|
130
|
+
current = candidate;
|
|
131
|
+
}
|
|
132
|
+
return current;
|
|
133
|
+
}
|
|
134
|
+
/** Upstream owns storage. Mutating methods resolve aliases consistently with upstream readers. */
|
|
135
|
+
export class VirtualFileSystem extends InMemoryFs {
|
|
136
|
+
async requireFileTarget(path) {
|
|
137
|
+
try {
|
|
138
|
+
if ((await this.lstat(path)).isDirectory)
|
|
139
|
+
throw new Error('EISDIR: write target is a directory');
|
|
140
|
+
}
|
|
141
|
+
catch (error) {
|
|
142
|
+
if (!(error instanceof Error) || !/\bENOENT\b/.test(error.message))
|
|
143
|
+
throw error;
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
async writeFile(path, content, options) {
|
|
147
|
+
const bytes = typeof content === 'string' ? content : Uint8Array.from(content);
|
|
148
|
+
const target = await mutationPath(this, path, true, true);
|
|
149
|
+
await this.requireFileTarget(target);
|
|
150
|
+
await super.writeFile(target, bytes, options);
|
|
151
|
+
}
|
|
152
|
+
async appendFile(path, content, options) {
|
|
153
|
+
const bytes = typeof content === 'string' ? content : Uint8Array.from(content);
|
|
154
|
+
const target = await mutationPath(this, path, true, true);
|
|
155
|
+
await this.requireFileTarget(target);
|
|
156
|
+
await super.appendFile(target, bytes, options);
|
|
157
|
+
}
|
|
158
|
+
async mkdir(path, options) {
|
|
159
|
+
const target = await mutationPath(this, path, options?.recursive === true, options?.recursive === true);
|
|
160
|
+
try {
|
|
161
|
+
if (!(await this.lstat(target)).isDirectory)
|
|
162
|
+
throw new Error('ENOTDIR: mkdir target is not a directory');
|
|
163
|
+
}
|
|
164
|
+
catch (error) {
|
|
165
|
+
if (!(error instanceof Error) || !/\bENOENT\b/.test(error.message))
|
|
166
|
+
throw error;
|
|
167
|
+
}
|
|
168
|
+
await super.mkdir(target, options);
|
|
169
|
+
}
|
|
170
|
+
async rm(path, options) {
|
|
171
|
+
let target;
|
|
172
|
+
try {
|
|
173
|
+
target = await mutationPath(this, path, false, false);
|
|
174
|
+
}
|
|
175
|
+
catch (error) {
|
|
176
|
+
if (options?.force && error instanceof Error && /\bENOENT\b/.test(error.message))
|
|
177
|
+
return;
|
|
178
|
+
throw error;
|
|
179
|
+
}
|
|
180
|
+
await super.rm(target, options);
|
|
181
|
+
}
|
|
182
|
+
async createExclusive(path, options) {
|
|
183
|
+
await super.createExclusive(await mutationPath(this, path, false, false), options);
|
|
184
|
+
}
|
|
185
|
+
async mv(_source, _destination) {
|
|
186
|
+
throw new Error('ENOTSUP: backing move does not preserve native rename semantics');
|
|
187
|
+
}
|
|
188
|
+
/** Move one regular file over a file or a missing path in a single synchronous upstream step, so a reader sees the old or the new bytes. */
|
|
189
|
+
async replaceFile(source, destination) {
|
|
190
|
+
const from = await mutationPath(this, source, false, false), to = await mutationPath(this, destination, false, true);
|
|
191
|
+
if (!(await this.lstat(from)).isFile)
|
|
192
|
+
throw new Error('ENOTSUP: only a regular file can be replaced by rename');
|
|
193
|
+
await this.requireFileTarget(to);
|
|
194
|
+
await InMemoryFs.prototype.mv.call(this, from, to);
|
|
195
|
+
}
|
|
196
|
+
async cp(source, destination, options) {
|
|
197
|
+
const from = await mutationPath(this, source, false, false), to = await mutationPath(this, destination, false, true);
|
|
198
|
+
if ((await this.lstat(from)).isDirectory)
|
|
199
|
+
throw new Error('ENOTSUP: recursive directory copy is not qualified');
|
|
200
|
+
if (await this.exists(to)) {
|
|
201
|
+
if ((await this.stat(to)).isDirectory)
|
|
202
|
+
throw new Error('EISDIR: copy destination must be a file');
|
|
203
|
+
}
|
|
204
|
+
await super.cp(from, to, options);
|
|
205
|
+
}
|
|
206
|
+
async symlink(target, path) {
|
|
207
|
+
await super.symlink(target, await mutationPath(this, path, false, false));
|
|
208
|
+
}
|
|
209
|
+
async link(_source, _destination) {
|
|
210
|
+
throw new Error('ENOTSUP: hard links do not preserve shared file identity');
|
|
211
|
+
}
|
|
212
|
+
async chmod(path, mode) {
|
|
213
|
+
await super.chmod(await mutationPath(this, path, false, true), mode);
|
|
214
|
+
}
|
|
215
|
+
async utimes(path, atime, mtime) {
|
|
216
|
+
await super.utimes(await mutationPath(this, path, false, true), atime, mtime);
|
|
217
|
+
}
|
|
218
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import type { Bash, IFileSystem, BufferEncoding } from 'just-bash';
|
|
2
|
+
import type { createGitRepository } from '@hachej/boring-files/git';
|
|
3
|
+
/** isomorphic-git I/O over the selected just-bash view; no second checkout or native Git. */
|
|
4
|
+
export declare function createVirtualGitFs(filesystem: IFileSystem): {
|
|
5
|
+
filesystem: IFileSystem;
|
|
6
|
+
promises: {
|
|
7
|
+
readFile: (path: string, options?: BufferEncoding | {
|
|
8
|
+
encoding?: BufferEncoding;
|
|
9
|
+
} | undefined) => Promise<string | Uint8Array<ArrayBuffer>>;
|
|
10
|
+
writeFile: (path: string, bytes: string | Uint8Array<ArrayBufferLike>) => Promise<void>;
|
|
11
|
+
unlink: (path: string) => Promise<void>;
|
|
12
|
+
readdir: (path: string) => Promise<string[]>;
|
|
13
|
+
mkdir: (path: string, options?: number | {
|
|
14
|
+
recursive?: boolean;
|
|
15
|
+
} | undefined) => Promise<void>;
|
|
16
|
+
rmdir: (path: string) => Promise<void>;
|
|
17
|
+
stat: (path: string) => Promise<{
|
|
18
|
+
isFile: () => boolean;
|
|
19
|
+
isDirectory: () => boolean;
|
|
20
|
+
isSymbolicLink: () => boolean;
|
|
21
|
+
mode: number;
|
|
22
|
+
mtimeMs: number;
|
|
23
|
+
ctime: Date;
|
|
24
|
+
ctimeMs: number;
|
|
25
|
+
uid: number;
|
|
26
|
+
gid: number;
|
|
27
|
+
dev: number | bigint;
|
|
28
|
+
ino: number;
|
|
29
|
+
size: number;
|
|
30
|
+
mtime: Date;
|
|
31
|
+
identity?: string;
|
|
32
|
+
}>;
|
|
33
|
+
lstat: (path: string) => Promise<{
|
|
34
|
+
isFile: () => boolean;
|
|
35
|
+
isDirectory: () => boolean;
|
|
36
|
+
isSymbolicLink: () => boolean;
|
|
37
|
+
mode: number;
|
|
38
|
+
mtimeMs: number;
|
|
39
|
+
ctime: Date;
|
|
40
|
+
ctimeMs: number;
|
|
41
|
+
uid: number;
|
|
42
|
+
gid: number;
|
|
43
|
+
dev: number | bigint;
|
|
44
|
+
ino: number;
|
|
45
|
+
size: number;
|
|
46
|
+
mtime: Date;
|
|
47
|
+
identity?: string;
|
|
48
|
+
}>;
|
|
49
|
+
readlink: (path: string) => Promise<string>;
|
|
50
|
+
symlink: (target: string, path: string) => Promise<void>;
|
|
51
|
+
chmod: (path: string, mode: number) => Promise<void>;
|
|
52
|
+
};
|
|
53
|
+
};
|
|
54
|
+
export interface VirtualGitOptions {
|
|
55
|
+
readonly bash: Bash;
|
|
56
|
+
readonly repository: ReturnType<typeof createGitRepository<ReturnType<typeof createVirtualGitFs>>>;
|
|
57
|
+
}
|
|
58
|
+
/** Bind one repository service to its selected backing and root cwd. */
|
|
59
|
+
export declare function installVirtualGitCommand({ bash, repository }: VirtualGitOptions): void;
|
|
60
|
+
//# sourceMappingURL=virtual-git.d.ts.map
|