@unicity-astrid/sdk 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (124) hide show
  1. package/README.md +120 -0
  2. package/dist/approval.d.ts +23 -0
  3. package/dist/approval.d.ts.map +1 -0
  4. package/dist/approval.js +29 -0
  5. package/dist/approval.js.map +1 -0
  6. package/dist/capabilities.d.ts +14 -0
  7. package/dist/capabilities.d.ts.map +1 -0
  8. package/dist/capabilities.js +19 -0
  9. package/dist/capabilities.js.map +1 -0
  10. package/dist/capsule.d.ts +39 -0
  11. package/dist/capsule.d.ts.map +1 -0
  12. package/dist/capsule.js +67 -0
  13. package/dist/capsule.js.map +1 -0
  14. package/dist/contracts.d.ts +1104 -0
  15. package/dist/contracts.d.ts.map +1 -0
  16. package/dist/contracts.js +4 -0
  17. package/dist/contracts.js.map +1 -0
  18. package/dist/elicit.d.ts +30 -0
  19. package/dist/elicit.d.ts.map +1 -0
  20. package/dist/elicit.js +103 -0
  21. package/dist/elicit.js.map +1 -0
  22. package/dist/env.d.ts +19 -0
  23. package/dist/env.d.ts.map +1 -0
  24. package/dist/env.js +27 -0
  25. package/dist/env.js.map +1 -0
  26. package/dist/errors.d.ts +46 -0
  27. package/dist/errors.d.ts.map +1 -0
  28. package/dist/errors.js +108 -0
  29. package/dist/errors.js.map +1 -0
  30. package/dist/fs.d.ts +135 -0
  31. package/dist/fs.d.ts.map +1 -0
  32. package/dist/fs.js +257 -0
  33. package/dist/fs.js.map +1 -0
  34. package/dist/http.d.ts +90 -0
  35. package/dist/http.d.ts.map +1 -0
  36. package/dist/http.js +276 -0
  37. package/dist/http.js.map +1 -0
  38. package/dist/identity.d.ts +46 -0
  39. package/dist/identity.d.ts.map +1 -0
  40. package/dist/identity.js +69 -0
  41. package/dist/identity.js.map +1 -0
  42. package/dist/index.d.ts +30 -0
  43. package/dist/index.d.ts.map +1 -0
  44. package/dist/index.js +27 -0
  45. package/dist/index.js.map +1 -0
  46. package/dist/interceptors.d.ts +21 -0
  47. package/dist/interceptors.d.ts.map +1 -0
  48. package/dist/interceptors.js +22 -0
  49. package/dist/interceptors.js.map +1 -0
  50. package/dist/ipc.d.ts +143 -0
  51. package/dist/ipc.d.ts.map +1 -0
  52. package/dist/ipc.js +261 -0
  53. package/dist/ipc.js.map +1 -0
  54. package/dist/kv.d.ts +45 -0
  55. package/dist/kv.d.ts.map +1 -0
  56. package/dist/kv.js +91 -0
  57. package/dist/kv.js.map +1 -0
  58. package/dist/log.d.ts +17 -0
  59. package/dist/log.d.ts.map +1 -0
  60. package/dist/log.js +40 -0
  61. package/dist/log.js.map +1 -0
  62. package/dist/net.d.ts +154 -0
  63. package/dist/net.d.ts.map +1 -0
  64. package/dist/net.js +421 -0
  65. package/dist/net.js.map +1 -0
  66. package/dist/process.d.ts +77 -0
  67. package/dist/process.d.ts.map +1 -0
  68. package/dist/process.js +128 -0
  69. package/dist/process.js.map +1 -0
  70. package/dist/runtime/bridge.d.ts +34 -0
  71. package/dist/runtime/bridge.d.ts.map +1 -0
  72. package/dist/runtime/bridge.js +326 -0
  73. package/dist/runtime/bridge.js.map +1 -0
  74. package/dist/runtime/index.d.ts +3 -0
  75. package/dist/runtime/index.d.ts.map +1 -0
  76. package/dist/runtime/index.js +3 -0
  77. package/dist/runtime/index.js.map +1 -0
  78. package/dist/runtime/registry.d.ts +58 -0
  79. package/dist/runtime/registry.d.ts.map +1 -0
  80. package/dist/runtime/registry.js +129 -0
  81. package/dist/runtime/registry.js.map +1 -0
  82. package/dist/runtime.d.ts +36 -0
  83. package/dist/runtime.d.ts.map +1 -0
  84. package/dist/runtime.js +50 -0
  85. package/dist/runtime.js.map +1 -0
  86. package/dist/time.d.ts +29 -0
  87. package/dist/time.d.ts.map +1 -0
  88. package/dist/time.js +43 -0
  89. package/dist/time.js.map +1 -0
  90. package/dist/tool.d.ts +48 -0
  91. package/dist/tool.d.ts.map +1 -0
  92. package/dist/tool.js +86 -0
  93. package/dist/tool.js.map +1 -0
  94. package/dist/uplink.d.ts +27 -0
  95. package/dist/uplink.d.ts.map +1 -0
  96. package/dist/uplink.js +36 -0
  97. package/dist/uplink.js.map +1 -0
  98. package/package.json +38 -0
  99. package/src/approval.ts +38 -0
  100. package/src/capabilities.ts +22 -0
  101. package/src/capsule.ts +90 -0
  102. package/src/contracts.ts +1189 -0
  103. package/src/elicit.ts +136 -0
  104. package/src/env.ts +31 -0
  105. package/src/errors.ts +122 -0
  106. package/src/fs.ts +357 -0
  107. package/src/http.ts +345 -0
  108. package/src/identity.ts +101 -0
  109. package/src/index.ts +83 -0
  110. package/src/interceptors.ts +25 -0
  111. package/src/ipc.ts +354 -0
  112. package/src/kv.ts +123 -0
  113. package/src/log.ts +43 -0
  114. package/src/net.ts +545 -0
  115. package/src/process.ts +205 -0
  116. package/src/runtime/bridge.ts +374 -0
  117. package/src/runtime/index.ts +11 -0
  118. package/src/runtime/registry.ts +178 -0
  119. package/src/runtime.ts +70 -0
  120. package/src/time.ts +48 -0
  121. package/src/tool.ts +125 -0
  122. package/src/uplink.ts +49 -0
  123. package/src/wit-imports.d.ts +689 -0
  124. package/wit-contracts/astrid-contracts.wit +1266 -0
package/src/elicit.ts ADDED
@@ -0,0 +1,136 @@
1
+ /**
2
+ * Interactive user input during install/upgrade. Mirrors `astrid_sdk::elicit`.
3
+ *
4
+ * These functions are only callable from `@install` / `@upgrade` lifecycle
5
+ * methods; calling them from a tool or interceptor returns `not-in-lifecycle`.
6
+ * The host blocks the WASM thread until the frontend collects input or the
7
+ * request times out (120s).
8
+ *
9
+ * Post per-domain WIT split, the host returns a typed `elicit-response`
10
+ * variant directly. We unpack it into the language-natural shape: `text` /
11
+ * `textWithDefault` / `select` return strings; `array` returns string[];
12
+ * `secret` returns void (the value never leaves the SecretStore).
13
+ */
14
+
15
+ import {
16
+ elicit as hostElicit,
17
+ hasSecret as hostHasSecret,
18
+ type ElicitResponse,
19
+ } from "astrid:elicit/host@1.0.0";
20
+ import { SysError, callHost } from "./errors.js";
21
+
22
+ function validateKey(key: string): void {
23
+ if (key.trim() === "") {
24
+ throw SysError.api("elicit key must not be empty");
25
+ }
26
+ }
27
+
28
+ /**
29
+ * Store a secret via the kernel's SecretStore. The capsule NEVER receives
30
+ * the value back — verify via {@link hasSecret} and dereference at runtime
31
+ * by name.
32
+ */
33
+ export function secret(key: string, description: string): void {
34
+ validateKey(key);
35
+ const resp = callHost(`elicit.secret(${JSON.stringify(key)})`, () =>
36
+ hostElicit({
37
+ kind: "secret",
38
+ key,
39
+ description,
40
+ options: undefined,
41
+ defaultValue: undefined,
42
+ }),
43
+ );
44
+ if (resp.tag !== "secret-stored") {
45
+ throw SysError.api(
46
+ `elicit.secret: expected 'secret-stored' response, got '${resp.tag}'`,
47
+ );
48
+ }
49
+ }
50
+
51
+ /** Check whether a secret with this key was previously stored. */
52
+ export function hasSecret(key: string): boolean {
53
+ validateKey(key);
54
+ return callHost(`elicit.hasSecret(${JSON.stringify(key)})`, () => hostHasSecret(key));
55
+ }
56
+
57
+ /** Prompt the user for a text value. */
58
+ export function text(key: string, description: string): string {
59
+ return elicitText(key, description, undefined);
60
+ }
61
+
62
+ /** Prompt with a pre-filled default. */
63
+ export function textWithDefault(
64
+ key: string,
65
+ description: string,
66
+ defaultValue: string,
67
+ ): string {
68
+ return elicitText(key, description, defaultValue);
69
+ }
70
+
71
+ /** Prompt for a selection from a list. */
72
+ export function select(key: string, description: string, options: string[]): string {
73
+ validateKey(key);
74
+ if (options.length === 0) {
75
+ throw SysError.api("elicit.select requires at least one option");
76
+ }
77
+ const resp = callHost(`elicit.select(${JSON.stringify(key)})`, () =>
78
+ hostElicit({
79
+ kind: "select",
80
+ key,
81
+ description,
82
+ options,
83
+ defaultValue: undefined,
84
+ }),
85
+ );
86
+ const value = expectValue("elicit.select", resp);
87
+ if (options.indexOf(value) < 0) {
88
+ throw SysError.api(
89
+ `host returned value not in provided options: ${value.slice(0, 64)}`,
90
+ );
91
+ }
92
+ return value;
93
+ }
94
+
95
+ /** Prompt for multiple text values. */
96
+ export function array(key: string, description: string): string[] {
97
+ validateKey(key);
98
+ const resp = callHost(`elicit.array(${JSON.stringify(key)})`, () =>
99
+ hostElicit({
100
+ kind: "array",
101
+ key,
102
+ description,
103
+ options: undefined,
104
+ defaultValue: undefined,
105
+ }),
106
+ );
107
+ if (resp.tag !== "values") {
108
+ throw SysError.api(`elicit.array: expected 'values' response, got '${resp.tag}'`);
109
+ }
110
+ return resp.val;
111
+ }
112
+
113
+ function elicitText(
114
+ key: string,
115
+ description: string,
116
+ defaultValue: string | undefined,
117
+ ): string {
118
+ validateKey(key);
119
+ const resp = callHost(`elicit.text(${JSON.stringify(key)})`, () =>
120
+ hostElicit({
121
+ kind: "text",
122
+ key,
123
+ description,
124
+ options: undefined,
125
+ defaultValue,
126
+ }),
127
+ );
128
+ return expectValue("elicit.text", resp);
129
+ }
130
+
131
+ function expectValue(label: string, resp: ElicitResponse): string {
132
+ if (resp.tag !== "value") {
133
+ throw SysError.api(`${label}: expected 'value' response, got '${resp.tag}'`);
134
+ }
135
+ return resp.val;
136
+ }
package/src/env.ts ADDED
@@ -0,0 +1,31 @@
1
+ /**
2
+ * Capsule configuration — Astrid's equivalent of environment variables.
3
+ * Mirrors `astrid_sdk::env`. Values are injected by the kernel at load time
4
+ * from `Capsule.toml [env]` entries.
5
+ *
6
+ * Per the per-domain WIT split, `get-config` now returns `option<string>` so
7
+ * the caller can distinguish "key not set" from "key explicitly set to empty
8
+ * string". The wrapper preserves that distinction via {@link tryGet}; {@link get}
9
+ * collapses `none` to `""` for the std::env::var-style ergonomic case.
10
+ */
11
+
12
+ import { getConfig } from "astrid:sys/host@1.0.0";
13
+ import { callHost } from "./errors.js";
14
+
15
+ /** Well-known config key carrying the kernel's Unix domain socket path. */
16
+ export const CONFIG_SOCKET_PATH = "ASTRID_SOCKET_PATH";
17
+
18
+ /** Read a config value. Returns `""` if the key is not set. */
19
+ export function get(key: string): string {
20
+ return tryGet(key) ?? "";
21
+ }
22
+
23
+ /** Read a config value or `undefined` if not set. */
24
+ export function tryGet(key: string): string | undefined {
25
+ return callHost(`env.get(${JSON.stringify(key)})`, () => getConfig(key));
26
+ }
27
+
28
+ /** Read a config value as bytes. */
29
+ export function getBytes(key: string): Uint8Array {
30
+ return new TextEncoder().encode(get(key));
31
+ }
package/src/errors.ts ADDED
@@ -0,0 +1,122 @@
1
+ /**
2
+ * Unified error type for SDK operations. Mirrors `astrid_sdk::SysError` but
3
+ * uses idiomatic JS throw/catch instead of `Result<T, E>`.
4
+ *
5
+ * Post per-domain-WIT migration, every fallible host call returns a typed
6
+ * `result<T, error-code>` where `error-code` is a domain-specific variant
7
+ * (e.g. `astrid:fs/host.error-code`, `astrid:ipc/host.error-code`).
8
+ * ComponentizeJS rejects with `{ tag, val? }` objects on the error arm. We
9
+ * preserve the variant tag on `SysError.code` so downstream code can branch
10
+ * on `if (err.code === "quota") ...` without losing the typed kind. The
11
+ * raw payload (the unpacked variant value, if any) survives on `err.payload`
12
+ * for the few callers that need it.
13
+ *
14
+ * The three legacy origin tags — `HostError`, `JsonError`, `ApiError` —
15
+ * remain on `SysError.kind` for source compatibility. Code that previously
16
+ * checked `err.code` for "HostError" should migrate to `err.kind` because
17
+ * `err.code` now carries the WIT variant tag.
18
+ */
19
+
20
+ export type SysErrorKind = "HostError" | "JsonError" | "ApiError";
21
+
22
+ export class SysError extends Error {
23
+ override readonly name = "SysError";
24
+ /** Legacy classification: where the error originated. */
25
+ readonly kind: SysErrorKind;
26
+ /** Typed WIT variant tag (e.g. "quota", "capability-denied", "timeout").
27
+ * `undefined` for SDK-internal errors that didn't come from the host. */
28
+ readonly code: string | undefined;
29
+ /** Raw unpacked WIT variant payload, when present. */
30
+ readonly payload: unknown;
31
+
32
+ constructor(
33
+ kind: SysErrorKind,
34
+ message: string,
35
+ options?: ErrorOptions & { code?: string; payload?: unknown },
36
+ ) {
37
+ super(`[${kind}${options?.code ? `:${options.code}` : ""}] ${message}`, options);
38
+ this.kind = kind;
39
+ this.code = options?.code;
40
+ this.payload = options?.payload;
41
+ }
42
+
43
+ static host(
44
+ message: string,
45
+ cause?: unknown,
46
+ code?: string,
47
+ payload?: unknown,
48
+ ): SysError {
49
+ const opts: ErrorOptions & { code?: string; payload?: unknown } = {};
50
+ if (cause !== undefined) opts.cause = cause;
51
+ if (code !== undefined) opts.code = code;
52
+ if (payload !== undefined) opts.payload = payload;
53
+ return new SysError("HostError", message, opts);
54
+ }
55
+
56
+ static json(message: string, cause?: unknown): SysError {
57
+ return new SysError("JsonError", message, cause === undefined ? undefined : { cause });
58
+ }
59
+
60
+ static api(message: string, cause?: unknown): SysError {
61
+ return new SysError("ApiError", message, cause === undefined ? undefined : { cause });
62
+ }
63
+ }
64
+
65
+ /**
66
+ * Wraps a synchronous host call, normalizing any thrown value into a
67
+ * `SysError`. After the per-domain WIT split, host imports throw
68
+ * `{ tag, val? }` objects representing the typed error-code variant.
69
+ * We unpack the tag onto `SysError.code` so capsule authors can branch
70
+ * on the WIT variant by name. The raw payload (the unpacked `val`, if any)
71
+ * survives on `SysError.payload` for callers that need it.
72
+ */
73
+ export function callHost<T>(label: string, fn: () => T): T {
74
+ try {
75
+ return fn();
76
+ } catch (raw) {
77
+ if (raw instanceof SysError) throw raw;
78
+ const wit = extractWitError(raw);
79
+ if (wit !== undefined) {
80
+ throw SysError.host(`${label}: ${wit.message}`, raw, wit.code, wit.payload);
81
+ }
82
+ const message = typeof raw === "string" ? raw : (raw as Error)?.message ?? String(raw);
83
+ throw SysError.host(`${label}: ${message}`, raw);
84
+ }
85
+ }
86
+
87
+ interface WitErrorView {
88
+ code: string;
89
+ message: string;
90
+ payload: unknown;
91
+ }
92
+
93
+ /**
94
+ * Componentize-js rejects fallible host calls with `{ tag, val? }` shapes
95
+ * (the unpacked variant of `result<T, error-code>`). Strings or numbers
96
+ * occasionally surface for trap-style failures. This helper produces a
97
+ * uniform view of the variant for `callHost` to convert into `SysError`.
98
+ */
99
+ function extractWitError(raw: unknown): WitErrorView | undefined {
100
+ if (raw === null || typeof raw !== "object") return undefined;
101
+ const r = raw as Record<string, unknown>;
102
+ if (typeof r["tag"] !== "string") return undefined;
103
+ const code = r["tag"];
104
+ const val = r["val"];
105
+ let message: string;
106
+ if (typeof val === "string") {
107
+ message = `${code}: ${val}`;
108
+ } else if (val === undefined) {
109
+ message = code;
110
+ } else {
111
+ message = `${code}: ${safeStringify(val)}`;
112
+ }
113
+ return { code, message, payload: val };
114
+ }
115
+
116
+ function safeStringify(v: unknown): string {
117
+ try {
118
+ return JSON.stringify(v);
119
+ } catch {
120
+ return String(v);
121
+ }
122
+ }
package/src/fs.ts ADDED
@@ -0,0 +1,357 @@
1
+ /**
2
+ * Virtual filesystem — shape-compatible with `node:fs/promises` where it
3
+ * makes sense, including a `Stats`-like object with `isFile()` /
4
+ * `isDirectory()` methods.
5
+ *
6
+ * Path schemes follow VFS conventions (`workspace://`, `home://`, `tmp://`).
7
+ * The kernel re-resolves and re-validates every path on every call;
8
+ * {@link canonicalize} is for display / equality only, NOT a security check
9
+ * that subsequent calls can rely on.
10
+ *
11
+ * The Rust SDK's surface is sync (`std::fs`); we expose async (`await`) to
12
+ * match Node idioms even though the underlying host calls are synchronous.
13
+ * StarlingMonkey syncifies awaits at the WASM boundary.
14
+ */
15
+
16
+ import {
17
+ fsOpen as hostOpen,
18
+ fsExists as hostExists,
19
+ fsMkdir as hostMkdir,
20
+ fsMkdirAll as hostMkdirAll,
21
+ fsReaddir as hostReaddir,
22
+ fsStat as hostStat,
23
+ fsStatSymlink as hostStatSymlink,
24
+ fsUnlink as hostUnlink,
25
+ readFile as hostReadFile,
26
+ writeFile as hostWriteFile,
27
+ fsAppend as hostAppend,
28
+ fsCopy as hostCopy,
29
+ fsRename as hostRename,
30
+ fsRemoveDirAll as hostRemoveDirAll,
31
+ fsCanonicalize as hostCanonicalize,
32
+ fsReadLink as hostReadLink,
33
+ fsHardLink as hostHardLink,
34
+ type FileStat,
35
+ type FileHandle as WitFileHandle,
36
+ type OpenMode,
37
+ type FileType,
38
+ } from "astrid:fs/host@1.0.0";
39
+ import { SysError, callHost } from "./errors.js";
40
+
41
+ const decoder = new TextDecoder();
42
+ const encoder = new TextEncoder();
43
+
44
+ export type { OpenMode, FileType } from "astrid:fs/host@1.0.0";
45
+
46
+ /**
47
+ * Stat result. Shaped like Node's `fs.Stats` for the fields the Astrid VFS
48
+ * surfaces. `size` is `bigint` (WIT `u64`); use `Number(size)` if you need a
49
+ * regular number and you're sure it fits.
50
+ */
51
+ export class Stats {
52
+ readonly size: bigint;
53
+ readonly mode: number;
54
+ readonly kind: FileType;
55
+ readonly mtimeMs: number | undefined;
56
+ readonly birthtimeMs: number | undefined;
57
+ readonly atimeMs: number | undefined;
58
+
59
+ constructor(stat: FileStat) {
60
+ this.size = stat.size;
61
+ this.mode = stat.mode;
62
+ this.kind = stat.kind;
63
+ this.mtimeMs = datetimeToMs(stat.modified);
64
+ this.birthtimeMs = datetimeToMs(stat.created);
65
+ this.atimeMs = datetimeToMs(stat.accessed);
66
+ }
67
+
68
+ isFile(): boolean {
69
+ return this.kind === "regular";
70
+ }
71
+
72
+ isDirectory(): boolean {
73
+ return this.kind === "directory";
74
+ }
75
+
76
+ isSymbolicLink(): boolean {
77
+ return this.kind === "symlink";
78
+ }
79
+
80
+ isEmpty(): boolean {
81
+ return this.size === 0n;
82
+ }
83
+ }
84
+
85
+ /** Directory entry returned by `readdir({ withFileTypes: true })`. */
86
+ export class Dirent {
87
+ readonly name: string;
88
+ readonly path: string;
89
+ readonly parentPath: string;
90
+
91
+ constructor(parentPath: string, name: string) {
92
+ this.name = name;
93
+ this.parentPath = parentPath;
94
+ this.path = parentPath.endsWith("/") ? parentPath + name : `${parentPath}/${name}`;
95
+ }
96
+ }
97
+
98
+ /**
99
+ * Open file handle. Returned from {@link open}. The host releases the
100
+ * underlying file descriptor automatically when the handle is dropped via
101
+ * `Symbol.dispose` or `.close()`. Per-capsule cap: 16 open file handles.
102
+ *
103
+ * ```ts
104
+ * using f = await fs.open("workspace://data.bin", "read-write");
105
+ * await f.writeAt(0n, new TextEncoder().encode("hello"));
106
+ * ```
107
+ */
108
+ export class FileHandle {
109
+ #inner: WitFileHandle | undefined;
110
+ readonly path: string;
111
+
112
+ constructor(inner: WitFileHandle, path: string) {
113
+ this.#inner = inner;
114
+ this.path = path;
115
+ }
116
+
117
+ /** Read up to `maxBytes` from `offset`. Empty result signals EOF at that offset. */
118
+ async readAt(offset: bigint, maxBytes: number): Promise<Uint8Array> {
119
+ return callHost(`fs.FileHandle.readAt(${quote(this.path)})`, () =>
120
+ this.#requireInner().readAt(offset, maxBytes),
121
+ );
122
+ }
123
+
124
+ /** Write `data` at `offset`. Returns bytes actually written. */
125
+ async writeAt(offset: bigint, data: Uint8Array): Promise<number> {
126
+ return callHost(`fs.FileHandle.writeAt(${quote(this.path)})`, () =>
127
+ this.#requireInner().writeAt(offset, data),
128
+ );
129
+ }
130
+
131
+ /** Flush buffered data (only) to disk — `fdatasync(2)`. */
132
+ async syncData(): Promise<void> {
133
+ callHost(`fs.FileHandle.syncData(${quote(this.path)})`, () =>
134
+ this.#requireInner().syncData(),
135
+ );
136
+ }
137
+
138
+ /** Flush both data and metadata to disk — `fsync(2)`. */
139
+ async syncAll(): Promise<void> {
140
+ callHost(`fs.FileHandle.syncAll(${quote(this.path)})`, () =>
141
+ this.#requireInner().syncAll(),
142
+ );
143
+ }
144
+
145
+ /** Race-free counterpart to {@link stat} on the path. */
146
+ async stat(): Promise<Stats> {
147
+ const raw = callHost(`fs.FileHandle.stat(${quote(this.path)})`, () =>
148
+ this.#requireInner().stat(),
149
+ );
150
+ return new Stats(raw);
151
+ }
152
+
153
+ /** Truncate or extend the file to `size` bytes. Extending past end fills with zeros. */
154
+ async setLen(size: bigint): Promise<void> {
155
+ callHost(`fs.FileHandle.setLen(${quote(this.path)})`, () =>
156
+ this.#requireInner().setLen(size),
157
+ );
158
+ }
159
+
160
+ close(): void {
161
+ if (this.#inner === undefined) return;
162
+ const inner = this.#inner;
163
+ this.#inner = undefined;
164
+ try {
165
+ inner[Symbol.dispose]();
166
+ } catch {
167
+ // Already disposed by the runtime; safe to ignore.
168
+ }
169
+ }
170
+
171
+ [Symbol.dispose](): void {
172
+ this.close();
173
+ }
174
+
175
+ #requireInner(): WitFileHandle {
176
+ if (this.#inner === undefined) {
177
+ throw SysError.api(`FileHandle ${quote(this.path)} is closed`);
178
+ }
179
+ return this.#inner;
180
+ }
181
+ }
182
+
183
+ export interface ReadFileOptions {
184
+ /** When set, decode bytes as a string with this encoding. */
185
+ encoding?: "utf8";
186
+ }
187
+
188
+ export interface ReaddirOptions {
189
+ /** When true, yields `Dirent` objects instead of bare name strings. */
190
+ withFileTypes?: boolean;
191
+ }
192
+
193
+ /** Open a file by path. Required capability depends on `mode`. */
194
+ export async function open(path: string, mode: OpenMode): Promise<FileHandle> {
195
+ const inner = callHost(`fs.open(${quote(path)})`, () => hostOpen(path, mode));
196
+ return new FileHandle(inner, path);
197
+ }
198
+
199
+ export async function exists(path: string): Promise<boolean> {
200
+ return callHost(`fs.exists(${quote(path)})`, () => hostExists(path));
201
+ }
202
+
203
+ export async function stat(path: string): Promise<Stats> {
204
+ const raw = callHost(`fs.stat(${quote(path)})`, () => hostStat(path));
205
+ return new Stats(raw);
206
+ }
207
+
208
+ /** Stat without following symlinks — `lstat(2)`. */
209
+ export async function lstat(path: string): Promise<Stats> {
210
+ const raw = callHost(`fs.lstat(${quote(path)})`, () => hostStatSymlink(path));
211
+ return new Stats(raw);
212
+ }
213
+
214
+ export async function readFile(
215
+ path: string,
216
+ options?: ReadFileOptions,
217
+ ): Promise<Uint8Array | string> {
218
+ const bytes = callHost(`fs.readFile(${quote(path)})`, () => hostReadFile(path));
219
+ if (options?.encoding === "utf8") return decoder.decode(bytes);
220
+ return bytes;
221
+ }
222
+
223
+ /** Read a file as UTF-8 text. */
224
+ export async function readTextFile(path: string): Promise<string> {
225
+ const bytes = callHost(`fs.readTextFile(${quote(path)})`, () => hostReadFile(path));
226
+ try {
227
+ return decoder.decode(bytes);
228
+ } catch (err) {
229
+ throw SysError.api(`fs.readTextFile(${quote(path)}): ${(err as Error).message}`, err);
230
+ }
231
+ }
232
+
233
+ export async function writeFile(path: string, data: string | Uint8Array): Promise<void> {
234
+ const bytes = typeof data === "string" ? encoder.encode(data) : data;
235
+ callHost(`fs.writeFile(${quote(path)})`, () => hostWriteFile(path, bytes));
236
+ }
237
+
238
+ /** Append `data` to a file, creating it if absent. */
239
+ export async function appendFile(path: string, data: string | Uint8Array): Promise<void> {
240
+ const bytes = typeof data === "string" ? encoder.encode(data) : data;
241
+ callHost(`fs.appendFile(${quote(path)})`, () => hostAppend(path, bytes));
242
+ }
243
+
244
+ /**
245
+ * Create a directory. Mirrors `std::fs::create_dir` / `mkdir(2)` — strict.
246
+ * Fails with `already-exists` if the path exists. Use {@link mkdirAll} for
247
+ * idempotent "ensure-exists" semantics.
248
+ */
249
+ export async function mkdir(path: string): Promise<void> {
250
+ callHost(`fs.mkdir(${quote(path)})`, () => hostMkdir(path));
251
+ }
252
+
253
+ /** Create a directory and all missing parents. Idempotent. */
254
+ export async function mkdirAll(path: string): Promise<void> {
255
+ callHost(`fs.mkdirAll(${quote(path)})`, () => hostMkdirAll(path));
256
+ }
257
+
258
+ /** Remove a file. Mirrors `fs.unlink` / `fs.rm` (file-only). */
259
+ export async function rm(path: string): Promise<void> {
260
+ callHost(`fs.rm(${quote(path)})`, () => hostUnlink(path));
261
+ }
262
+
263
+ /** Alias for {@link rm} matching Node's `fs.unlink`. */
264
+ export const unlink = rm;
265
+
266
+ /**
267
+ * Remove a directory and all its contents recursively. Refuses to traverse
268
+ * symlinks to prevent sandbox escapes. Returns the count of removed entries.
269
+ */
270
+ export async function removeDirAll(path: string): Promise<bigint> {
271
+ return callHost(`fs.removeDirAll(${quote(path)})`, () => hostRemoveDirAll(path));
272
+ }
273
+
274
+ /** Copy a file from `src` to `dst`. Overwrites `dst`. */
275
+ export async function copy(src: string, dst: string): Promise<void> {
276
+ callHost(`fs.copy(${quote(src)} -> ${quote(dst)})`, () => hostCopy(src, dst));
277
+ }
278
+
279
+ /** Rename (move) within the same VFS scheme. Cross-scheme returns `cross-vfs`. */
280
+ export async function rename(src: string, dst: string): Promise<void> {
281
+ callHost(`fs.rename(${quote(src)} -> ${quote(dst)})`, () => hostRename(src, dst));
282
+ }
283
+
284
+ /**
285
+ * Resolve a path to its canonical form, following symlinks. Returns a
286
+ * VFS-scheme path, never a host real-path. NOT a TOCTOU-safe security check.
287
+ */
288
+ export async function canonicalize(path: string): Promise<string> {
289
+ return callHost(`fs.canonicalize(${quote(path)})`, () => hostCanonicalize(path));
290
+ }
291
+
292
+ /** Read a symlink target without following it. */
293
+ export async function readLink(path: string): Promise<string> {
294
+ return callHost(`fs.readLink(${quote(path)})`, () => hostReadLink(path));
295
+ }
296
+
297
+ /** Create a hard link. Both endpoints must be in the same VFS scheme. */
298
+ export async function hardLink(src: string, linkPath: string): Promise<void> {
299
+ callHost(`fs.hardLink(${quote(src)} -> ${quote(linkPath)})`, () =>
300
+ hostHardLink(src, linkPath),
301
+ );
302
+ }
303
+
304
+ /**
305
+ * Read directory entries. Returns string[] by default to match
306
+ * `fs.readdir(path)`; pass `{ withFileTypes: true }` for `Dirent[]`.
307
+ */
308
+ export async function readdir(path: string): Promise<string[]>;
309
+ export async function readdir(
310
+ path: string,
311
+ options: { withFileTypes: true },
312
+ ): Promise<Dirent[]>;
313
+ export async function readdir(
314
+ path: string,
315
+ options?: ReaddirOptions,
316
+ ): Promise<string[] | Dirent[]> {
317
+ const names = callHost(`fs.readdir(${quote(path)})`, () => hostReaddir(path));
318
+ if (options?.withFileTypes) {
319
+ return names.map((n) => new Dirent(path, n));
320
+ }
321
+ return names;
322
+ }
323
+
324
+ /**
325
+ * Stream-style directory iteration. Mirrors `fs.opendir` / `Dir`. The VFS
326
+ * resolves all entries in one host call, so the async-iterator is fully
327
+ * populated up-front; the shape matches Node for compatibility.
328
+ */
329
+ export async function opendir(path: string): Promise<AsyncIterableIterator<Dirent>> {
330
+ const entries = await readdir(path, { withFileTypes: true });
331
+ let i = 0;
332
+ const iter: AsyncIterableIterator<Dirent> = {
333
+ [Symbol.asyncIterator](): AsyncIterableIterator<Dirent> {
334
+ return iter;
335
+ },
336
+ async next(): Promise<IteratorResult<Dirent>> {
337
+ if (i >= entries.length) {
338
+ return { value: undefined as unknown as Dirent, done: true };
339
+ }
340
+ return { value: entries[i++]!, done: false };
341
+ },
342
+ async return(): Promise<IteratorResult<Dirent>> {
343
+ i = entries.length;
344
+ return { value: undefined as unknown as Dirent, done: true };
345
+ },
346
+ };
347
+ return iter;
348
+ }
349
+
350
+ function datetimeToMs(dt: FileStat["modified"]): number | undefined {
351
+ if (dt === undefined) return undefined;
352
+ return Number(dt.seconds) * 1000 + dt.nanoseconds / 1_000_000;
353
+ }
354
+
355
+ function quote(s: string): string {
356
+ return `"${s.replace(/"/g, '\\"')}"`;
357
+ }