@nyte-ai/host 0.0.0-stage → 0.0.18

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 (235) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +6 -2
  3. package/THIRD-PARTY-NOTICES.md +800 -0
  4. package/dist/chunks/canvas-preview-0cmbkp2j.js +65 -0
  5. package/dist/chunks/canvas-preview-0nq75h9k.js +7 -0
  6. package/dist/chunks/canvas-preview-0nrnbt76.js +497 -0
  7. package/dist/chunks/canvas-preview-1sq7z9jq.js +17 -0
  8. package/dist/chunks/canvas-preview-1ta3avah.js +56 -0
  9. package/dist/chunks/canvas-preview-2zdyh9ny.js +26 -0
  10. package/dist/chunks/canvas-preview-31w7xm8z.js +34 -0
  11. package/dist/chunks/canvas-preview-4b6sayhg.js +295 -0
  12. package/dist/chunks/canvas-preview-4e0pxa32.js +63 -0
  13. package/dist/chunks/canvas-preview-518dh8by.js +169 -0
  14. package/dist/chunks/canvas-preview-5hxat7hh.js +239 -0
  15. package/dist/chunks/canvas-preview-5mex725c.js +14 -0
  16. package/dist/chunks/canvas-preview-5z63am63.js +295 -0
  17. package/dist/chunks/canvas-preview-65xqjw7f.js +20 -0
  18. package/dist/chunks/canvas-preview-69fkk9yn.js +95 -0
  19. package/dist/chunks/canvas-preview-75db60xy.js +457 -0
  20. package/dist/chunks/canvas-preview-76fwvd6e.js +272 -0
  21. package/dist/chunks/canvas-preview-8xgt67ka.js +598 -0
  22. package/dist/chunks/canvas-preview-98aj5p34.js +176 -0
  23. package/dist/chunks/canvas-preview-9aqczacs.js +73 -0
  24. package/dist/chunks/canvas-preview-a3yw0j70.js +72 -0
  25. package/dist/chunks/canvas-preview-azg56359.js +68 -0
  26. package/dist/chunks/canvas-preview-d9wwhbtz.js +240 -0
  27. package/dist/chunks/canvas-preview-ehf3vp31.js +411 -0
  28. package/dist/chunks/canvas-preview-evh92ywd.js +369 -0
  29. package/dist/chunks/canvas-preview-ewgspmsd.js +81 -0
  30. package/dist/chunks/canvas-preview-g3qj3y3k.js +156 -0
  31. package/dist/chunks/canvas-preview-h8hshnff.js +63 -0
  32. package/dist/chunks/canvas-preview-j1tnf51w.js +36 -0
  33. package/dist/chunks/canvas-preview-kdqgrstc.js +194 -0
  34. package/dist/chunks/canvas-preview-kn7kvbfj.js +12 -0
  35. package/dist/chunks/canvas-preview-m02v4yy6.js +386 -0
  36. package/dist/chunks/canvas-preview-mgcx2b2p.js +271 -0
  37. package/dist/chunks/canvas-preview-rp4x6y1h.js +144 -0
  38. package/dist/chunks/canvas-preview-rp6fz0w5.js +130 -0
  39. package/dist/chunks/canvas-preview-s94ebwf5.js +112 -0
  40. package/dist/chunks/canvas-preview-senpjwep.js +518 -0
  41. package/dist/chunks/canvas-preview-skzwvw4d.js +23 -0
  42. package/dist/chunks/canvas-preview-srgfpdmf.js +60 -0
  43. package/dist/chunks/canvas-preview-tjhabynd.js +252 -0
  44. package/dist/chunks/canvas-preview-vaatfznm.js +189 -0
  45. package/dist/chunks/canvas-preview-vax9ne4e.js +341 -0
  46. package/dist/chunks/canvas-preview-wg80rbc2.js +46 -0
  47. package/dist/chunks/canvas-preview-xn10d4f8.js +608 -0
  48. package/dist/chunks/canvas-preview-yjz0f7n4.js +50 -0
  49. package/dist/chunks/canvas-preview-ymgz0937.js +587 -0
  50. package/dist/chunks/canvas-preview-yrfz2ak6.js +134 -0
  51. package/dist/chunks/canvas-preview-zaaj99wd.js +244 -0
  52. package/dist/chunks/canvas-preview-zn2d0k9d.js +133 -0
  53. package/dist/src/canvas-preview.d.ts +29 -0
  54. package/dist/src/canvas-preview.js +267 -0
  55. package/dist/src/credentials.d.ts +44 -0
  56. package/dist/src/credentials.js +10 -0
  57. package/dist/src/deploy/cloudflare/worker.d.ts +50 -0
  58. package/dist/src/deploy/cloudflare/worker.js +196 -0
  59. package/dist/src/deploy/vercel/nitro.config.d.ts +2 -0
  60. package/dist/src/deploy/vercel/nitro.config.js +30 -0
  61. package/dist/src/deploy/vercel/server/routes/[...].d.ts +2 -0
  62. package/dist/src/deploy/vercel/server/routes/[...].js +18 -0
  63. package/dist/src/deploy/vercel/server/utils/host.d.ts +13 -0
  64. package/dist/src/deploy/vercel/server/utils/host.js +7 -0
  65. package/dist/src/deploy/vercel/server/utils/repair.d.ts +1 -0
  66. package/dist/src/deploy/vercel/server/utils/repair.js +6 -0
  67. package/dist/src/deploy/vercel/server/utils/step.d.ts +10 -0
  68. package/dist/src/deploy/vercel/server/utils/step.js +6 -0
  69. package/dist/src/deploy/vercel/server/workflows/drive.d.ts +14 -0
  70. package/dist/src/deploy/vercel/server/workflows/drive.js +13 -0
  71. package/dist/src/durable-object.d.ts +14 -0
  72. package/dist/src/durable-object.js +231 -0
  73. package/dist/src/hash.d.ts +1 -0
  74. package/dist/src/hash.js +6 -0
  75. package/dist/src/identity-key.d.ts +11 -0
  76. package/dist/src/identity-key.js +9 -0
  77. package/dist/src/identity.d.ts +12 -0
  78. package/dist/src/identity.js +6 -0
  79. package/dist/src/index.d.ts +21 -0
  80. package/dist/src/index.js +120 -0
  81. package/dist/src/key-file.d.ts +1 -0
  82. package/dist/src/key-file.js +7 -0
  83. package/dist/src/listen-fetch.d.ts +8 -0
  84. package/dist/src/listen-fetch.js +9 -0
  85. package/dist/src/listen.d.ts +4 -0
  86. package/dist/src/listen.js +10 -0
  87. package/dist/src/local/command.d.ts +15 -0
  88. package/dist/src/local/command.js +8 -0
  89. package/dist/src/local/feed.d.ts +7 -0
  90. package/dist/src/local/feed.js +6 -0
  91. package/dist/src/local/files.d.ts +18 -0
  92. package/dist/src/local/files.js +13 -0
  93. package/dist/src/local/git-watch.d.ts +15 -0
  94. package/dist/src/local/git-watch.js +7 -0
  95. package/dist/src/local/git.d.ts +5 -0
  96. package/dist/src/local/git.js +13 -0
  97. package/dist/src/local/github.d.ts +30 -0
  98. package/dist/src/local/github.js +9 -0
  99. package/dist/src/local/identity.d.ts +3 -0
  100. package/dist/src/local/identity.js +7 -0
  101. package/dist/src/local/jobs.d.ts +7 -0
  102. package/dist/src/local/jobs.js +6 -0
  103. package/dist/src/local/legacy-trust.d.ts +10 -0
  104. package/dist/src/local/legacy-trust.js +6 -0
  105. package/dist/src/local/patch.d.ts +16 -0
  106. package/dist/src/local/patch.js +17 -0
  107. package/dist/src/local/provider.d.ts +11 -0
  108. package/dist/src/local/provider.js +20 -0
  109. package/dist/src/local/snapshots.d.ts +7 -0
  110. package/dist/src/local/snapshots.js +11 -0
  111. package/dist/src/local/text.d.ts +1 -0
  112. package/dist/src/local/text.js +6 -0
  113. package/dist/src/local/tree.d.ts +50 -0
  114. package/dist/src/local/tree.js +16 -0
  115. package/dist/src/local/workspace-migration.d.ts +2 -0
  116. package/dist/src/local/workspace-migration.js +7 -0
  117. package/dist/src/local/worktrees.d.ts +46 -0
  118. package/dist/src/local/worktrees.js +14 -0
  119. package/dist/src/mcp/config.d.ts +75 -0
  120. package/dist/src/mcp/config.js +12 -0
  121. package/dist/src/mcp/connect.d.ts +28 -0
  122. package/dist/src/mcp/connect.js +9 -0
  123. package/dist/src/mcp/hub.d.ts +19 -0
  124. package/dist/src/mcp/hub.js +11 -0
  125. package/dist/src/mcp/oauth.d.ts +46 -0
  126. package/dist/src/mcp/oauth.js +12 -0
  127. package/dist/src/mcp/tools.d.ts +29 -0
  128. package/dist/src/mcp/tools.js +12 -0
  129. package/dist/src/open.d.ts +42 -0
  130. package/dist/src/open.js +44 -0
  131. package/dist/src/plugins/loader.d.ts +21 -0
  132. package/dist/src/plugins/loader.js +11 -0
  133. package/dist/src/postgres.d.ts +19 -0
  134. package/dist/src/postgres.js +8 -0
  135. package/dist/src/providers.d.ts +16 -0
  136. package/dist/src/providers.js +112 -0
  137. package/dist/src/secrets.d.ts +12 -0
  138. package/dist/src/secrets.js +8 -0
  139. package/dist/src/serve/binding.d.ts +39 -0
  140. package/dist/src/serve/binding.js +6 -0
  141. package/dist/src/serve/handler.d.ts +15 -0
  142. package/dist/src/serve/handler.js +8 -0
  143. package/dist/src/serve/stream-server.d.ts +6 -0
  144. package/dist/src/serve/stream-server.js +6 -0
  145. package/dist/src/session-index.d.ts +11 -0
  146. package/dist/src/session-index.js +11 -0
  147. package/dist/src/shell-env.d.ts +14 -0
  148. package/dist/src/shell-env.js +108 -0
  149. package/dist/src/sqlite-workers.d.ts +6 -0
  150. package/dist/src/sqlite-workers.js +6 -0
  151. package/dist/src/sqlite.d.ts +7 -0
  152. package/dist/src/sqlite.js +10 -0
  153. package/dist/src/store-owner.d.ts +15 -0
  154. package/dist/src/store-owner.js +9 -0
  155. package/dist/src/store-rows.d.ts +13 -0
  156. package/dist/src/store-rows.js +26 -0
  157. package/dist/src/store-usage.d.ts +25 -0
  158. package/dist/src/store-usage.js +16 -0
  159. package/dist/src/usage-scan.d.ts +24 -0
  160. package/dist/src/usage-scan.js +65 -0
  161. package/dist/src/usage-service.d.ts +22 -0
  162. package/dist/src/usage-service.js +12 -0
  163. package/dist/src/usage-summary.d.ts +9 -0
  164. package/dist/src/usage-summary.js +16 -0
  165. package/dist/src/usage-types.d.ts +86 -0
  166. package/dist/src/usage-types.js +0 -0
  167. package/dist/src/usage-worker.d.ts +1 -0
  168. package/dist/src/usage-worker.js +25 -0
  169. package/dist/src/usage.d.ts +72 -0
  170. package/dist/src/usage.js +13 -0
  171. package/package.json +132 -3
  172. package/src/canvas-preview.ts +402 -0
  173. package/src/credentials.ts +294 -0
  174. package/src/deploy/cloudflare/README.md +3 -0
  175. package/src/deploy/cloudflare/worker.ts +306 -0
  176. package/src/deploy/cloudflare/wrangler.jsonc +12 -0
  177. package/src/deploy/vercel/README.md +3 -0
  178. package/src/deploy/vercel/nitro.config.ts +29 -0
  179. package/src/deploy/vercel/server/routes/[...].ts +10 -0
  180. package/src/deploy/vercel/server/utils/host.ts +104 -0
  181. package/src/deploy/vercel/server/utils/repair.ts +35 -0
  182. package/src/deploy/vercel/server/utils/step.ts +25 -0
  183. package/src/deploy/vercel/server/workflows/drive.ts +46 -0
  184. package/src/deploy/vercel/tsconfig.json +9 -0
  185. package/src/deploy/vercel/types/bun.d.ts +2 -0
  186. package/src/durable-object.ts +386 -0
  187. package/src/hash.ts +5 -0
  188. package/src/identity-key.ts +20 -0
  189. package/src/identity.ts +66 -0
  190. package/src/index.ts +21 -0
  191. package/src/key-file.ts +53 -0
  192. package/src/listen-fetch.ts +409 -0
  193. package/src/listen.ts +228 -0
  194. package/src/local/command.ts +109 -0
  195. package/src/local/feed.ts +70 -0
  196. package/src/local/files.ts +812 -0
  197. package/src/local/git-watch.ts +189 -0
  198. package/src/local/git.ts +914 -0
  199. package/src/local/github.ts +692 -0
  200. package/src/local/identity.ts +21 -0
  201. package/src/local/jobs.ts +587 -0
  202. package/src/local/legacy-trust.ts +105 -0
  203. package/src/local/patch.ts +334 -0
  204. package/src/local/provider.ts +184 -0
  205. package/src/local/snapshots.ts +776 -0
  206. package/src/local/text.ts +9 -0
  207. package/src/local/tree.ts +398 -0
  208. package/src/local/workspace-migration.ts +95 -0
  209. package/src/local/worktrees.ts +239 -0
  210. package/src/mcp/config.ts +330 -0
  211. package/src/mcp/connect.ts +105 -0
  212. package/src/mcp/hub.ts +836 -0
  213. package/src/mcp/oauth.ts +436 -0
  214. package/src/mcp/tools.ts +333 -0
  215. package/src/open.ts +469 -0
  216. package/src/plugins/loader.ts +186 -0
  217. package/src/postgres.ts +492 -0
  218. package/src/providers.ts +135 -0
  219. package/src/secrets.ts +31 -0
  220. package/src/serve/binding.ts +628 -0
  221. package/src/serve/handler.ts +362 -0
  222. package/src/serve/stream-server.ts +176 -0
  223. package/src/session-index.ts +99 -0
  224. package/src/shell-env.ts +153 -0
  225. package/src/sqlite-workers.ts +283 -0
  226. package/src/sqlite.ts +528 -0
  227. package/src/store-owner.ts +98 -0
  228. package/src/store-rows.ts +110 -0
  229. package/src/store-usage.ts +344 -0
  230. package/src/usage-scan.ts +81 -0
  231. package/src/usage-service.ts +73 -0
  232. package/src/usage-summary.ts +79 -0
  233. package/src/usage-types.ts +87 -0
  234. package/src/usage-worker.ts +24 -0
  235. package/src/usage.ts +701 -0
@@ -0,0 +1,812 @@
1
+ import { createHash, randomUUID } from "node:crypto";
2
+ import {
3
+ chmod,
4
+ lstat,
5
+ mkdir,
6
+ open,
7
+ readFile,
8
+ readdir,
9
+ readlink,
10
+ realpath,
11
+ rename,
12
+ rm,
13
+ stat,
14
+ unlink,
15
+ writeFile,
16
+ } from "node:fs/promises";
17
+ import { basename, dirname, isAbsolute, join, matchesGlob, relative, sep } from "node:path";
18
+ import { createInterface } from "node:readline";
19
+ import { spawn } from "node:child_process";
20
+ import { asId, SdkError } from "@nyte-ai/schema";
21
+ import { serial } from "@nyte-ai/schema/serial";
22
+ import type {
23
+ ByteReader,
24
+ FileEntryView,
25
+ FileEvent,
26
+ FileVersion,
27
+ FormatOutcome,
28
+ SearchFrame,
29
+ SearchInput,
30
+ } from "@nyte-ai/schema";
31
+ import type { FileBatch, FilesApi } from "@nyte-ai/core";
32
+ import { Type } from "typebox";
33
+ import { Value } from "typebox/value";
34
+ import { sha256 } from "../hash.ts";
35
+ import { findExecutable, run } from "./command.ts";
36
+ import type { Ran } from "./command.ts";
37
+ import { feed } from "./feed.ts";
38
+ import { decodeText } from "./text.ts";
39
+ import { readErrorCode, createIgnores, walkTree, watchPath, watchTree } from "./tree.ts";
40
+
41
+ /** Returns a byte reader that carries the version of the file it read, which the server answers as `X-Nyte-Version` (§40h, §40p). */
42
+ export type VersionedReader = ByteReader & { readonly version: FileVersion };
43
+
44
+ /** Returns the local `FilesApi`, its `bytes` typed with the version it read. */
45
+ export type LocalFiles = Omit<FilesApi, "bytes"> & {
46
+ bytes(path: string): Promise<VersionedReader>;
47
+ };
48
+
49
+ /** The size past which `read` answers `large`: 1 MiB. */
50
+ export const TEXT_LIMIT = 1024 * 1024;
51
+
52
+ const CHUNK = 64 * 1024;
53
+
54
+ const FIND_DEFAULT = 20;
55
+
56
+ const FIND_CAP = 50;
57
+
58
+ const FIND_TTL_MS = 5_000;
59
+
60
+ const SEARCH_DEFAULT = 200;
61
+
62
+ const SEARCH_LINE = 500;
63
+
64
+ const WATCH_DELAY_MS = 100;
65
+
66
+ const WATCH_POLL_MS = 1_000;
67
+
68
+ const FORMATTERS = ["oxfmt", "prettier", "biome"] as const;
69
+
70
+ /** Returns a folder's files over `node:fs`: a relative path resolves against `root`, an absolute one is taken as given (the ancestor context files are read through it, §37g). A version is the SHA-256 of what is there, never an mtime: a file's contents (through a symlink, its target's), a directory's sorted entry names, and, for `stat` of a symlink itself, its target path. `read` answers `text` for UTF-8 without NUL up to 1 MiB, `large` above, `binary` otherwise, `missing` when absent (`SdkError("invalid")` for a directory); `bytes` streams the file with its size, a null mime and its version (`SdkError("unknown")` when absent); `stat` uses `lstat` (a symlink with its `target`), null when absent. `write` compares `expect` with the current version first (`stale { current }`, `absent` needing no file), answers `missing-parent` unless `parents`, and writes through a temporary file renamed into place (through a symlink, at its target), keeping an existing file's mode; `remove` compares `expect` the same way (`stale { current: null }` when nothing is there) and answers `saved` with the version it removed. `batch()` returns a plan whose `apply` and `upload()` reject with `SdkError("unavailable")`: core lays its own over them (`wrapFiles`). `format` runs `formatter`, else the first of `oxfmt`, `prettier` and `biome` under `<root>/node_modules/.bin`, then on the `PATH`, with the contents on stdin and the path for its config, answering `unchanged` when equal and `unavailable { reason }` when there is none or it fails. `scan` walks `directory` (default the root; an absolute one is walked as its own root and answered with absolute paths) breadth-first in pages of 500 entries, skipping `.git` and, unless `ignored: "include"` or the directory is itself ignored, what `.gitignore` files and `.git/info/exclude` ignore (`ignore`), then `complete`, or `incomplete` with `limit` after 50,000 entries or `permission` after an unreadable directory; entries carry no version. `find` matches the query against the scanned file paths, case-insensitively: exact name, name prefix, name substring, path substring, then a fuzzy subsequence, shorter first, at most `limit` (default 20, capped at 50), the scan reused for 5 s. `search` runs `ripgrep` (else `rg` on the PATH; with neither, a scan of the text files up to 1 MiB with a JavaScript regex) for the query, literal unless `regex`, with smart case unless `caseSensitive` is given, whole words alone when `wholeWord`, over the files the `include` globs pick (all when none) less the `exclude` globs; a file with a draft is searched in its draft's contents instead of on disk. It yields a `match` per matching line (1-based line and column and the first match's length, the line cut at 500 characters), drafts first, then `complete`, or `truncated` at `limit` (default 200); a regex or glob `rg` cannot read throws `SdkError("invalid")`. `watch` uses a recursive `fs.watch` on the root (a 1 s poll where there is none) or, given `paths`, a watch on each of them alone that waits for a missing one to appear and never polls, without ignored paths or directories, coalescing changes over 100 ms into `created`, `modified` and `removed` events with the version after (null when removed), a path's last change winning while the reader is behind. `signal` ends every open `watch`. */
71
+ export function localFiles(
72
+ root: string,
73
+ options?: {
74
+ readonly ripgrep?: string;
75
+ readonly formatter?: string;
76
+ readonly signal?: AbortSignal;
77
+ },
78
+ ): LocalFiles {
79
+ const absolute = (path: string): string => (isAbsolute(path) ? path : join(root, path));
80
+
81
+ const within = (path: string): string => {
82
+ const inside = relative(root, path);
83
+
84
+ return inside.startsWith("..") || isAbsolute(inside) ? path : inside.split(sep).join("/");
85
+ };
86
+
87
+ let found: { readonly at: number; readonly paths: Promise<readonly FileEntryView[]> } | null =
88
+ null;
89
+
90
+ const scanned = (): Promise<readonly FileEntryView[]> => {
91
+ if (found !== null && Date.now() - found.at < FIND_TTL_MS) return found.paths;
92
+ const paths = collectFiles(root);
93
+ found = { at: Date.now(), paths };
94
+ paths.catch(() => {
95
+ if (found?.paths === paths) found = null;
96
+ });
97
+
98
+ return paths;
99
+ };
100
+
101
+ return {
102
+ async read(path) {
103
+ const target = absolute(path);
104
+ const stats = await stat(target).catch(missing);
105
+
106
+ if (stats === null) return { kind: "missing", path };
107
+
108
+ if (stats.isDirectory()) throw new SdkError("invalid", `${path} is a directory.`);
109
+
110
+ if (stats.size > TEXT_LIMIT)
111
+ return { kind: "large", path, size: stats.size, version: await hashFile(target) };
112
+ const bytes = await readFile(target).catch(missing);
113
+
114
+ if (bytes === null) return { kind: "missing", path };
115
+ const version = hashVersion(bytes);
116
+ const text = decodeText(bytes);
117
+
118
+ return text === null
119
+ ? { kind: "binary", path, size: bytes.byteLength, version }
120
+ : { kind: "text", path, contents: text, version };
121
+ },
122
+ async bytes(path) {
123
+ const target = absolute(path);
124
+ const stats = await stat(target).catch(missing);
125
+
126
+ if (stats === null || !stats.isFile())
127
+ throw new SdkError("unknown", `No file is at ${path}.`);
128
+ const version = await hashFile(target);
129
+ let handle: Awaited<ReturnType<typeof open>> | null = null;
130
+
131
+ const close = async (): Promise<void> => {
132
+ const held = handle;
133
+ handle = null;
134
+ await held?.close();
135
+ };
136
+
137
+ return {
138
+ size: stats.size,
139
+ mime: null,
140
+ version,
141
+ async *chunks() {
142
+ handle = await open(target, "r");
143
+
144
+ try {
145
+ for (;;) {
146
+ const held = handle;
147
+
148
+ if (held === null) return;
149
+ const buffer = new Uint8Array(CHUNK);
150
+ const { bytesRead } = await held.read(buffer, 0, CHUNK, null);
151
+
152
+ if (bytesRead === 0) return;
153
+ yield buffer.subarray(0, bytesRead);
154
+ }
155
+ } finally {
156
+ await close();
157
+ }
158
+ },
159
+ close,
160
+ [Symbol.asyncDispose]: close,
161
+ };
162
+ },
163
+ async stat(path) {
164
+ const target = absolute(path);
165
+ const stats = await lstat(target).catch(missing);
166
+
167
+ if (stats === null) return null;
168
+
169
+ if (stats.isSymbolicLink()) {
170
+ const link = await readlink(target);
171
+
172
+ return { kind: "symlink", size: stats.size, version: hashVersion(link), target: link };
173
+ }
174
+
175
+ if (stats.isDirectory())
176
+ return { kind: "directory", size: stats.size, version: await directoryVersion(target) };
177
+
178
+ return { kind: "file", size: stats.size, version: await hashFile(target) };
179
+ },
180
+ async write(path, contents, writeOptions) {
181
+ const target = await resolveLink(absolute(path));
182
+ const current = await currentVersion(target);
183
+ const expect = writeOptions?.expect;
184
+
185
+ if (expect === "absent" ? current !== null : expect !== undefined && expect !== current)
186
+ return { kind: "stale", current };
187
+ const parent = dirname(target);
188
+ const folder = await stat(parent).catch(missing);
189
+
190
+ if (folder === null) {
191
+ if (writeOptions?.parents !== true) return { kind: "missing-parent" };
192
+ await mkdir(parent, { recursive: true });
193
+ } else if (!folder.isDirectory())
194
+ throw new SdkError("invalid", `${dirname(path)} is not a directory.`);
195
+
196
+ const bytes = typeof contents === "string" ? new TextEncoder().encode(contents) : contents;
197
+ const mode = (await stat(target).catch(missing))?.mode;
198
+ const temporary = join(parent, `.${basename(target)}.${randomUUID()}.nyte-tmp`);
199
+
200
+ try {
201
+ await writeFile(temporary, bytes);
202
+
203
+ if (mode !== undefined) await chmod(temporary, mode & 0o7777);
204
+ await rename(temporary, target);
205
+ } catch (error) {
206
+ await unlink(temporary).catch(() => undefined);
207
+
208
+ throw error;
209
+ }
210
+
211
+ return { kind: "saved", version: hashVersion(bytes) };
212
+ },
213
+ async remove(path, removeOptions) {
214
+ const target = absolute(path);
215
+ const link = await lstat(target).catch(missing);
216
+
217
+ if (link === null) return { kind: "stale", current: null };
218
+
219
+ const current =
220
+ (await currentVersion(target)) ??
221
+ (link.isSymbolicLink() ? hashVersion(await readlink(target)) : null);
222
+
223
+ if (removeOptions?.expect !== undefined && removeOptions.expect !== current)
224
+ return { kind: "stale", current };
225
+ await rm(target, { recursive: link.isDirectory() });
226
+
227
+ return current === null ? { kind: "stale", current } : { kind: "saved", version: current };
228
+ },
229
+ batch: () => refusedBatch(),
230
+ async format(path, contents) {
231
+ return format(root, absolute(path), contents, options?.formatter);
232
+ },
233
+ async *scan(input) {
234
+ const directory = input?.directory ?? "";
235
+ const own = isAbsolute(directory);
236
+ const base = own ? directory : root;
237
+ const start = own ? "" : directory;
238
+ const ignores = createIgnores(base);
239
+
240
+ const ignored =
241
+ input?.ignored === "include" || (start !== "" && (await ignores.ignored(start, true)))
242
+ ? "include"
243
+ : "exclude";
244
+
245
+ for await (const frame of walkTree(base, { directory: start, ignored, ignores })) {
246
+ if (frame.kind !== "entries") {
247
+ yield frame;
248
+ continue;
249
+ }
250
+
251
+ yield {
252
+ kind: "entries",
253
+ entries: frame.entries.map((entry): FileEntryView => ({
254
+ path: own ? join(directory, entry.path) : entry.path,
255
+ kind: entry.kind,
256
+ size: entry.size,
257
+ version: null,
258
+ })),
259
+ };
260
+ }
261
+ },
262
+ async find(input) {
263
+ const limit = Math.min(Math.max(1, input.limit ?? FIND_DEFAULT), FIND_CAP);
264
+
265
+ return rank(await scanned(), input.query, limit);
266
+ },
267
+ search(input) {
268
+ const drafts = new Map(
269
+ (input.drafts ?? []).map((draft) => [within(absolute(draft.path)), draft.contents]),
270
+ );
271
+
272
+ return search(root, input, drafts, options?.ripgrep);
273
+ },
274
+ watch(input) {
275
+ const paths = (input?.paths ?? []).map((path) => within(absolute(path)));
276
+
277
+ return watchFiles(root, paths, options?.signal);
278
+ },
279
+ upload: async () => {
280
+ throw new SdkError("unavailable", "Core stores uploads; the local provider does not.");
281
+ },
282
+ };
283
+ }
284
+
285
+ function hashVersion(data: string | Uint8Array): FileVersion {
286
+ return asId<"file-version">(sha256(data));
287
+ }
288
+
289
+ function missing(error: unknown): null {
290
+ const code = readErrorCode(error);
291
+
292
+ if (code === "ENOENT" || code === "ENOTDIR") return null;
293
+
294
+ throw error;
295
+ }
296
+
297
+ async function hashFile(path: string): Promise<FileVersion> {
298
+ const hash = createHash("sha256");
299
+ const handle = await open(path, "r");
300
+
301
+ try {
302
+ const buffer = new Uint8Array(CHUNK);
303
+
304
+ for (;;) {
305
+ const { bytesRead } = await handle.read(buffer, 0, CHUNK, null);
306
+
307
+ if (bytesRead === 0) break;
308
+ hash.update(buffer.subarray(0, bytesRead));
309
+ }
310
+ } finally {
311
+ await handle.close();
312
+ }
313
+
314
+ return asId<"file-version">(hash.digest("hex"));
315
+ }
316
+
317
+ async function directoryVersion(path: string): Promise<FileVersion> {
318
+ return hashVersion((await readdir(path)).sort().join("\n"));
319
+ }
320
+
321
+ async function currentVersion(path: string): Promise<FileVersion | null> {
322
+ const stats = await stat(path).catch(missing);
323
+
324
+ if (stats === null) return null;
325
+
326
+ return stats.isDirectory() ? directoryVersion(path) : hashFile(path).catch(missing);
327
+ }
328
+
329
+ async function resolveLink(path: string): Promise<string> {
330
+ const link = await lstat(path).catch(missing);
331
+
332
+ return link?.isSymbolicLink() === true ? realpath(path).catch(() => path) : path;
333
+ }
334
+
335
+ function refusedBatch(): FileBatch {
336
+ return {
337
+ put: () => refusedBatch(),
338
+ remove: () => refusedBatch(),
339
+ apply: async () => {
340
+ throw new SdkError("unavailable", "Core applies file batches; the local provider does not.");
341
+ },
342
+ };
343
+ }
344
+
345
+ async function collectFiles(root: string): Promise<readonly FileEntryView[]> {
346
+ const entries: FileEntryView[] = [];
347
+
348
+ for await (const frame of walkTree(root, { ignored: "exclude" }))
349
+ if (frame.kind === "entries")
350
+ for (const entry of frame.entries)
351
+ if (entry.kind !== "directory")
352
+ entries.push({ path: entry.path, kind: entry.kind, size: entry.size, version: null });
353
+
354
+ return entries;
355
+ }
356
+
357
+ function rank(
358
+ entries: readonly FileEntryView[],
359
+ query: string,
360
+ limit: number,
361
+ ): readonly FileEntryView[] {
362
+ const needle = query.trim().toLowerCase();
363
+
364
+ if (needle === "") return entries.slice(0, limit);
365
+
366
+ const scored = entries.flatMap((entry) => {
367
+ const path = entry.path.toLowerCase();
368
+ const name = path.slice(path.lastIndexOf("/") + 1);
369
+
370
+ const score =
371
+ name === needle
372
+ ? 0
373
+ : name.startsWith(needle)
374
+ ? 1
375
+ : name.includes(needle)
376
+ ? 2
377
+ : path.includes(needle)
378
+ ? 3
379
+ : subsequence(path, needle)
380
+ ? 4
381
+ : null;
382
+
383
+ return score === null ? [] : [{ entry, score }];
384
+ });
385
+
386
+ return scored
387
+ .sort(
388
+ (left, right) =>
389
+ left.score - right.score ||
390
+ left.entry.path.length - right.entry.path.length ||
391
+ (left.entry.path < right.entry.path ? -1 : 1),
392
+ )
393
+ .slice(0, limit)
394
+ .map(({ entry }) => entry);
395
+ }
396
+
397
+ function subsequence(text: string, needle: string): boolean {
398
+ let at = 0;
399
+
400
+ for (const char of text) {
401
+ if (char === needle[at]) at += 1;
402
+
403
+ if (at === needle.length) return true;
404
+ }
405
+
406
+ return false;
407
+ }
408
+
409
+ async function format(
410
+ root: string,
411
+ path: string,
412
+ contents: string,
413
+ formatter: string | undefined,
414
+ ): Promise<FormatOutcome> {
415
+ const binary = formatter ?? (await detectFormatter(root));
416
+
417
+ if (binary === null)
418
+ return { kind: "unavailable", reason: "No formatter is installed for this workspace." };
419
+ const name = basename(binary).replace(/\.(cmd|exe)$/i, "");
420
+
421
+ const args =
422
+ name === "biome"
423
+ ? ["format", `--stdin-file-path=${path}`]
424
+ : name === "oxfmt"
425
+ ? [`--stdin-filepath=${path}`]
426
+ : ["--stdin-filepath", path];
427
+
428
+ let ran: Ran;
429
+
430
+ try {
431
+ ran = await run(binary, args, { cwd: root, input: contents });
432
+ } catch (error) {
433
+ return { kind: "unavailable", reason: error instanceof Error ? error.message : String(error) };
434
+ }
435
+
436
+ if (ran.code !== 0)
437
+ return {
438
+ kind: "unavailable",
439
+ reason: `${name} could not format ${basename(path)}: ${ran.stderr.trim()}`,
440
+ };
441
+ const formatted = ran.stdout.toString("utf8");
442
+
443
+ return formatted === contents
444
+ ? { kind: "unchanged" }
445
+ : { kind: "formatted", contents: formatted };
446
+ }
447
+
448
+ async function detectFormatter(root: string): Promise<string | null> {
449
+ for (const name of FORMATTERS) {
450
+ const found = await findExecutable(name, [join(root, "node_modules", ".bin")]);
451
+
452
+ if (found !== null) return found;
453
+ }
454
+
455
+ return null;
456
+ }
457
+
458
+ async function* search(
459
+ root: string,
460
+ input: SearchInput,
461
+ drafts: ReadonlyMap<string, string>,
462
+ ripgrep: string | undefined,
463
+ ): AsyncGenerator<SearchFrame> {
464
+ const limit = Math.max(1, input.limit ?? SEARCH_DEFAULT);
465
+
466
+ if (input.query === "") {
467
+ yield { kind: "complete" };
468
+
469
+ return;
470
+ }
471
+
472
+ const binary = await findExecutable(ripgrep ?? "rg");
473
+
474
+ const matches =
475
+ binary === null ? scanText(root, input, drafts) : grep(binary, root, input, drafts);
476
+
477
+ let count = 0;
478
+
479
+ for await (const match of matches) {
480
+ if (count >= limit) {
481
+ yield { kind: "truncated", limit };
482
+
483
+ return;
484
+ }
485
+
486
+ count += 1;
487
+ yield match;
488
+ }
489
+
490
+ yield { kind: "complete" };
491
+ }
492
+
493
+ type SearchMatch = Extract<SearchFrame, { readonly kind: "match" }>;
494
+
495
+ /** The `rg` flags that read the query the way `input` asks: literal unless `regex`, smart case unless `caseSensitive` is given, whole words when `wholeWord`. */
496
+ function matchFlags(input: SearchInput): readonly string[] {
497
+ const casing =
498
+ input.caseSensitive === undefined
499
+ ? "--smart-case"
500
+ : input.caseSensitive
501
+ ? "--case-sensitive"
502
+ : "--ignore-case";
503
+
504
+ return [
505
+ "--json",
506
+ "--no-config",
507
+ "--no-messages",
508
+ casing,
509
+ ...(input.regex === true ? [] : ["--fixed-strings"]),
510
+ ...(input.wholeWord === true ? ["--word-regexp"] : []),
511
+ ];
512
+ }
513
+
514
+ /** The `rg` flags that pick the files a search reads: hidden ones too, never `.git`, then `include` and `exclude` as globs. */
515
+ function fileFlags(input: SearchInput): readonly string[] {
516
+ return [
517
+ "--no-config",
518
+ "--hidden",
519
+ "--no-require-git",
520
+ "--glob=!.git",
521
+ ...(input.include ?? []).map((glob) => `--glob=${glob}`),
522
+ ...(input.exclude ?? []).map((glob) => `--glob=!${glob}`),
523
+ ];
524
+ }
525
+
526
+ /** Throws `SdkError("invalid")` with what `rg` printed when it stopped on an error, such as a bad regex or glob; `--no-messages` keeps unreadable files out of it. */
527
+ function rejectFailure(code: number | null, stderr: string): void {
528
+ if (code === 2 && stderr.trim() !== "") throw new SdkError("invalid", stderr.trim());
529
+ }
530
+
531
+ function ripgrepPath(path: string): string {
532
+ return path.replace(/^\.\//, "").split(sep).join("/");
533
+ }
534
+
535
+ async function* grep(
536
+ binary: string,
537
+ root: string,
538
+ input: SearchInput,
539
+ drafts: ReadonlyMap<string, string>,
540
+ ): AsyncGenerator<SearchMatch> {
541
+ if (drafts.size > 0) {
542
+ const listed = await run(binary, ["--files", ...fileFlags(input), "."], { cwd: root });
543
+ rejectFailure(listed.code, listed.stderr);
544
+ const searched = new Set(listed.stdout.toString("utf8").split("\n").map(ripgrepPath));
545
+
546
+ for (const [path, contents] of drafts) {
547
+ if (!searched.has(path)) continue;
548
+
549
+ const ran = await run(binary, [...matchFlags(input), "--", input.query, "-"], {
550
+ cwd: root,
551
+ input: contents,
552
+ });
553
+
554
+ rejectFailure(ran.code, ran.stderr);
555
+
556
+ for (const line of ran.stdout.toString("utf8").split("\n")) {
557
+ const match = ripgrepMatch(line);
558
+
559
+ if (match !== null) yield { ...match, path };
560
+ }
561
+ }
562
+ }
563
+
564
+ const child = spawn(binary, [...matchFlags(input), ...fileFlags(input), "--", input.query, "."], {
565
+ cwd: root,
566
+ stdio: ["ignore", "pipe", "pipe"],
567
+ windowsHide: true,
568
+ });
569
+
570
+ let stderr = "";
571
+ child.stderr.setEncoding("utf8");
572
+ child.stderr.on("data", (chunk: string) => {
573
+ stderr += chunk;
574
+ });
575
+ const closed = new Promise<number | null>((resolve) => child.on("close", resolve));
576
+ const lines = createInterface({ input: child.stdout, crlfDelay: Number.POSITIVE_INFINITY });
577
+
578
+ try {
579
+ for await (const line of lines) {
580
+ const match = ripgrepMatch(line);
581
+
582
+ if (match !== null && !drafts.has(match.path)) yield match;
583
+ }
584
+
585
+ rejectFailure(await closed, stderr);
586
+ } finally {
587
+ lines.close();
588
+ child.kill();
589
+ }
590
+ }
591
+
592
+ /** The `match` records `rg --json` prints, the fields a search frame takes from them. */
593
+ const RipgrepMatchSchema = Type.Object({
594
+ type: Type.Literal("match"),
595
+ data: Type.Object({
596
+ path: Type.Object({ text: Type.String() }),
597
+ lines: Type.Object({ text: Type.String() }),
598
+ line_number: Type.Number(),
599
+ submatches: Type.Optional(Type.Array(Type.Unknown())),
600
+ }),
601
+ });
602
+
603
+ const SubmatchSchema = Type.Object({ start: Type.Number(), end: Type.Number() });
604
+
605
+ function ripgrepMatch(line: string): SearchMatch | null {
606
+ let record: unknown;
607
+
608
+ try {
609
+ record = JSON.parse(line);
610
+ } catch {
611
+ return null;
612
+ }
613
+
614
+ if (!Value.Check(RipgrepMatchSchema, record)) return null;
615
+ const { path, lines, line_number: number, submatches } = record.data;
616
+ const first = submatches?.[0];
617
+ const { start, end } = Value.Check(SubmatchSchema, first) ? first : { start: 0, end: 0 };
618
+ const text = lines.text.replace(/\r?\n$/, "");
619
+ const bytes = new TextEncoder().encode(text);
620
+ const decoder = new TextDecoder();
621
+
622
+ return {
623
+ kind: "match",
624
+ path: ripgrepPath(path.text),
625
+ line: number,
626
+ column: decoder.decode(bytes.subarray(0, start)).length + 1,
627
+ length: decoder.decode(bytes.subarray(start, end)).length,
628
+ text: text.slice(0, SEARCH_LINE),
629
+ };
630
+ }
631
+
632
+ /** The regex the scan without `rg` matches each line with, read as `matchFlags` reads the query for `rg`; `SdkError("invalid")` when `regex` is not one. */
633
+ function linePattern(input: SearchInput): RegExp {
634
+ const source = input.regex === true ? input.query : RegExp.escape(input.query);
635
+ const sensitive = input.caseSensitive ?? input.query !== input.query.toLowerCase();
636
+
637
+ try {
638
+ return new RegExp(
639
+ input.wholeWord === true ? `\\b(?:${source})\\b` : source,
640
+ sensitive ? "u" : "iu",
641
+ );
642
+ } catch (error) {
643
+ throw new SdkError("invalid", error instanceof Error ? error.message : String(error));
644
+ }
645
+ }
646
+
647
+ /** Whether `path` passes the globs as `rg` reads them: one without a `/` matches a name at any depth, and an excluded directory excludes what is under it. */
648
+ function passesFilters(path: string, input: SearchInput): boolean {
649
+ const parts = path.split("/");
650
+ const prefixes = parts.map((_, index) => parts.slice(0, index + 1).join("/"));
651
+
652
+ const matches = (glob: string, target: string): boolean =>
653
+ matchesGlob(target, glob) || (!glob.includes("/") && matchesGlob(basename(target), glob));
654
+
655
+ const include = input.include ?? [];
656
+
657
+ return (
658
+ (include.length === 0 || include.some((glob) => matches(glob, path))) &&
659
+ !(input.exclude ?? []).some((glob) => prefixes.some((prefix) => matches(glob, prefix)))
660
+ );
661
+ }
662
+
663
+ async function* scanText(
664
+ root: string,
665
+ input: SearchInput,
666
+ drafts: ReadonlyMap<string, string>,
667
+ ): AsyncGenerator<SearchMatch> {
668
+ const pattern = linePattern(input);
669
+
670
+ for await (const frame of walkTree(root, { ignored: "exclude" })) {
671
+ if (frame.kind !== "entries") continue;
672
+
673
+ for (const entry of frame.entries) {
674
+ if (entry.kind !== "file" || !passesFilters(entry.path, input)) continue;
675
+ const text =
676
+ drafts.get(entry.path) ?? (await readSearchable(join(root, entry.path), entry.size));
677
+
678
+ if (text === null) continue;
679
+
680
+ for (const [index, raw] of text.split("\n").entries()) {
681
+ const line = raw.replace(/\r$/, "");
682
+ const found = pattern.exec(line);
683
+
684
+ if (found === null) continue;
685
+
686
+ yield {
687
+ kind: "match",
688
+ path: entry.path,
689
+ line: index + 1,
690
+ column: found.index + 1,
691
+ length: found[0].length,
692
+ text: line.slice(0, SEARCH_LINE),
693
+ };
694
+ }
695
+ }
696
+ }
697
+ }
698
+
699
+ async function readSearchable(path: string, size: number | null): Promise<string | null> {
700
+ if ((size ?? 0) > TEXT_LIMIT) return null;
701
+ const bytes = await readFile(path).catch(() => null);
702
+
703
+ return bytes === null ? null : decodeText(bytes);
704
+ }
705
+
706
+ function watchFiles(
707
+ root: string,
708
+ paths: readonly string[],
709
+ signal: AbortSignal | undefined,
710
+ ): AsyncIterable<readonly FileEvent[]> {
711
+ return feed<readonly FileEvent[]>(
712
+ (sink) => {
713
+ const ignores = createIgnores(root);
714
+ const known = new Map<string, FileVersion | null>();
715
+ const started = Date.now();
716
+ const reads = serial();
717
+
718
+ const readEvent = async (path: string): Promise<FileEvent | null> => {
719
+ if (path.endsWith(".gitignore"))
720
+ ignores.forget(path.includes("/") ? path.slice(0, path.lastIndexOf("/")) : "");
721
+
722
+ if (await ignores.ignored(path, false)) return null;
723
+ const at = isAbsolute(path) ? path : join(root, path);
724
+ const stats = await lstat(at).catch(() => null);
725
+
726
+ if (stats?.isDirectory() === true) return null;
727
+
728
+ const version = stats === null ? null : await currentVersion(at).catch(() => null);
729
+
730
+ const before = known.get(path);
731
+ known.set(path, version);
732
+
733
+ if (version === null)
734
+ return before === null ? null : { kind: "removed", path, version: null };
735
+
736
+ if (before === version) return null;
737
+
738
+ const created =
739
+ before === null ||
740
+ (before === undefined && stats !== null && stats.birthtimeMs >= started - 1_000);
741
+
742
+ return { kind: created ? "created" : "modified", path, version };
743
+ };
744
+
745
+ const listMoved = async (
746
+ prefix: string,
747
+ changed: ReadonlySet<string>,
748
+ ): Promise<readonly string[]> => {
749
+ const inside = [...changed].flatMap((path) => (path === "" ? [] : [`${prefix}/${path}`]));
750
+
751
+ if (!changed.has("")) return inside;
752
+ const below = [...known.keys()].filter((path) => path.startsWith(`${prefix}/`));
753
+
754
+ for await (const frame of walkTree(isAbsolute(prefix) ? prefix : join(root, prefix), {
755
+ ignored: "include",
756
+ }))
757
+ if (frame.kind === "entries")
758
+ for (const entry of frame.entries)
759
+ if (entry.kind === "file") below.push(`${prefix}/${entry.path}`);
760
+
761
+ return [prefix, ...below, ...inside];
762
+ };
763
+
764
+ const heard = (changed: Iterable<string> | Promise<Iterable<string>>): void => {
765
+ void reads.run(async () => {
766
+ const events: FileEvent[] = [];
767
+
768
+ for (const path of await changed) {
769
+ if (path === "") continue;
770
+ const event = await readEvent(path).catch(() => null);
771
+
772
+ if (event !== null) events.push(event);
773
+ }
774
+
775
+ if (events.length > 0) sink.push(events);
776
+ });
777
+ };
778
+
779
+ const watching =
780
+ paths.length === 0 || paths.includes("")
781
+ ? [watchTree(root, { delay: WATCH_DELAY_MS, poll: WATCH_POLL_MS }, heard)]
782
+ : paths.map((prefix) =>
783
+ watchPath(
784
+ isAbsolute(prefix) ? prefix : join(root, prefix),
785
+ { delay: WATCH_DELAY_MS },
786
+ (changed) => heard(listMoved(prefix, changed)),
787
+ ),
788
+ );
789
+
790
+ const dispose = (): void => {
791
+ for (const stop of watching) stop();
792
+ };
793
+
794
+ const end = (): void => sink.end();
795
+ signal?.addEventListener("abort", end, { once: true });
796
+
797
+ if (signal?.aborted === true) end();
798
+
799
+ return () => {
800
+ signal?.removeEventListener("abort", end);
801
+ dispose();
802
+ };
803
+ },
804
+ (waiting, next) => {
805
+ const merged = new Map(waiting.map((event) => [event.path, event]));
806
+
807
+ for (const event of next) merged.set(event.path, event);
808
+
809
+ return [...merged.values()];
810
+ },
811
+ );
812
+ }