@amerged/ohmyhost-mcp 0.1.28 → 0.1.29
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -1
- package/THIRD_PARTY_NOTICES.md +1419 -0
- package/dist/{chunk-GIP4M4CK.js → chunk-BWVBJWRR.js} +67 -21
- package/dist/index.js +1 -1
- package/dist/stdio-main.js +24152 -2353
- package/package.json +1 -1
- package/src/apps/mcp/package.json +1 -1
- package/src/apps/mcp/src/local-client.ts +12 -2
- package/src/apps/mcp/src/local-server.ts +333 -4
- package/src/apps/product-cli/package.json +4 -2
- package/src/apps/product-cli/src/cli.ts +312 -2
- package/src/apps/product-cli/src/command.ts +261 -0
- package/src/apps/product-cli/src/composition.ts +74 -4
- package/src/apps/product-cli/src/index.ts +6 -0
- package/src/apps/product-cli/src/product-api.ts +5 -0
- package/src/apps/product-cli/src/project-link-store.ts +92 -1
- package/src/apps/product-cli/src/runtime-contract.ts +34 -1
- package/src/apps/product-cli/src/source-managed-client.ts +189 -0
- package/src/apps/product-cli/src/source-publication-store.ts +563 -0
- package/src/apps/product-cli/src/source-publisher.ts +195 -0
- package/src/apps/product-cli/src/source-workspace.ts +552 -0
- package/src/packages/agent-skills/src/generated-skill-resources.ts +18 -8
- package/src/packages/contracts/package.json +10 -0
- package/src/packages/contracts/src/managed-sources.ts +582 -0
- package/src/packages/contracts/src/mcp-source-codec.ts +150 -0
- package/src/packages/contracts/src/operation-failure.ts +22 -6
- package/src/packages/contracts/src/problem-policy.ts +61 -14
- package/src/packages/sdk-ts/package.json +1 -1
- package/src/packages/sdk-ts/src/generated/index.ts +92 -0
- package/src/packages/sdk-ts/src/generated/sdk.gen.ts +541 -0
- package/src/packages/sdk-ts/src/generated/types.gen.ts +911 -4
- package/src/packages/workos-auth-contracts/src/tokens.ts +61 -4
|
@@ -0,0 +1,552 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { constants, promises as fs, type BigIntStats } from "node:fs";
|
|
3
|
+
import { dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
|
|
4
|
+
|
|
5
|
+
import ignore from "ignore";
|
|
6
|
+
import git from "isomorphic-git";
|
|
7
|
+
import { isManagedSourcePath } from "@ohmyhost/contracts/managed-sources";
|
|
8
|
+
|
|
9
|
+
export const SOURCE_SNAPSHOT_LIMITS = Object.freeze({
|
|
10
|
+
totalBytes: 8 * 1024 * 1024,
|
|
11
|
+
fileBytes: 2 * 1024 * 1024,
|
|
12
|
+
files: 2000,
|
|
13
|
+
});
|
|
14
|
+
const MAXIMUM_SCAN_ENTRIES = 20_000;
|
|
15
|
+
const MAXIMUM_DEPTH = 64;
|
|
16
|
+
const MAXIMUM_GIT_METADATA_BYTES = 64 * 1024;
|
|
17
|
+
|
|
18
|
+
export interface SourceWorkspaceRemote {
|
|
19
|
+
readonly name: string;
|
|
20
|
+
/** Credentials, query strings, fragments and local filesystem remotes are never returned. */
|
|
21
|
+
readonly url: string | null;
|
|
22
|
+
readonly githubRepository: string | null;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export interface SourceWorkspace {
|
|
26
|
+
readonly directory: string;
|
|
27
|
+
readonly repositoryRoot: string | null;
|
|
28
|
+
readonly applicationRoot: string;
|
|
29
|
+
readonly git: {
|
|
30
|
+
readonly head: string | null;
|
|
31
|
+
readonly branch: string | null;
|
|
32
|
+
readonly upstreamRemote: string | null;
|
|
33
|
+
readonly pushRemote: string | null;
|
|
34
|
+
readonly remotes: readonly SourceWorkspaceRemote[];
|
|
35
|
+
} | null;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export interface SourceSnapshotFile {
|
|
39
|
+
readonly path: string;
|
|
40
|
+
readonly contentBase64: string;
|
|
41
|
+
readonly sha256: string;
|
|
42
|
+
readonly byteLength: number;
|
|
43
|
+
readonly executable: boolean;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export interface SourceSnapshot {
|
|
47
|
+
readonly sha256: string;
|
|
48
|
+
readonly totalBytes: number;
|
|
49
|
+
readonly files: readonly SourceSnapshotFile[];
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
type SourceWorkspaceErrorCode =
|
|
53
|
+
| "source_directory_invalid"
|
|
54
|
+
| "source_directory_ignored"
|
|
55
|
+
| "source_git_invalid"
|
|
56
|
+
| "source_path_invalid"
|
|
57
|
+
| "source_symlink_forbidden"
|
|
58
|
+
| "source_file_too_large"
|
|
59
|
+
| "source_snapshot_too_large"
|
|
60
|
+
| "source_too_many_files"
|
|
61
|
+
| "source_scan_limit"
|
|
62
|
+
| "source_snapshot_changed"
|
|
63
|
+
| "source_read_failed";
|
|
64
|
+
|
|
65
|
+
const ERROR_MESSAGES: Record<SourceWorkspaceErrorCode, string> = {
|
|
66
|
+
source_directory_invalid: "Select an existing source directory.",
|
|
67
|
+
source_directory_ignored: "The selected source directory is excluded by an ancestor gitignore.",
|
|
68
|
+
source_git_invalid: "The source workspace Git metadata could not be read safely.",
|
|
69
|
+
source_path_invalid: "The source workspace contains an unsupported file path or file type.",
|
|
70
|
+
source_symlink_forbidden: "Source snapshots cannot contain or follow symbolic links.",
|
|
71
|
+
source_file_too_large: "A source file exceeds the 2 MiB limit.",
|
|
72
|
+
source_snapshot_too_large: "The source snapshot exceeds the 8 MiB limit.",
|
|
73
|
+
source_too_many_files: "The source snapshot exceeds the 2000 file limit.",
|
|
74
|
+
source_scan_limit: "The source directory exceeds the bounded scan limit.",
|
|
75
|
+
source_snapshot_changed:
|
|
76
|
+
"Source files changed during capture. Capture a new snapshot before publishing.",
|
|
77
|
+
source_read_failed: "The source workspace could not be read safely.",
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
/** Fixed messages keep operating-system paths and Git configuration values out of diagnostics. */
|
|
81
|
+
export class SourceWorkspaceError extends Error {
|
|
82
|
+
public constructor(readonly code: SourceWorkspaceErrorCode) {
|
|
83
|
+
super(ERROR_MESSAGES[code]);
|
|
84
|
+
this.name = "SourceWorkspaceError";
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
interface GitLayout {
|
|
89
|
+
readonly root: string;
|
|
90
|
+
readonly gitdir: string;
|
|
91
|
+
readonly commonDir: string;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** Inspection is local and read-only. A remote is evidence of a source, never of authorization. */
|
|
95
|
+
export async function inspectSourceWorkspace(directory: string): Promise<SourceWorkspace> {
|
|
96
|
+
const root = await sourceDirectory(directory);
|
|
97
|
+
const layout = await findGitLayout(root);
|
|
98
|
+
if (layout === null)
|
|
99
|
+
return { directory: root, repositoryRoot: null, applicationRoot: ".", git: null };
|
|
100
|
+
try {
|
|
101
|
+
const commonConfig = await optionalMetadata(join(layout.commonDir, "config"));
|
|
102
|
+
const commonFs = configFilesystem(layout.commonDir, commonConfig ?? "");
|
|
103
|
+
const worktreeEnabled = await git.getConfig({
|
|
104
|
+
fs: commonFs,
|
|
105
|
+
gitdir: layout.commonDir,
|
|
106
|
+
path: "extensions.worktreeConfig",
|
|
107
|
+
});
|
|
108
|
+
const worktreeConfig = /^(?:true|yes|on|1)$/iu.test(String(worktreeEnabled))
|
|
109
|
+
? await optionalMetadata(join(layout.gitdir, "config.worktree"))
|
|
110
|
+
: null;
|
|
111
|
+
const configFs = configFilesystem(
|
|
112
|
+
layout.commonDir,
|
|
113
|
+
`${commonConfig ?? ""}\n${worktreeConfig ?? ""}`,
|
|
114
|
+
);
|
|
115
|
+
const branch =
|
|
116
|
+
(await git.currentBranch({ fs: { promises: fs }, gitdir: layout.gitdir })) ?? null;
|
|
117
|
+
let head: string | null = null;
|
|
118
|
+
try {
|
|
119
|
+
head = await git.resolveRef({
|
|
120
|
+
fs: { promises: fs },
|
|
121
|
+
gitdir: branch === null ? layout.gitdir : layout.commonDir,
|
|
122
|
+
ref: branch === null ? "HEAD" : `refs/heads/${branch}`,
|
|
123
|
+
});
|
|
124
|
+
} catch (error) {
|
|
125
|
+
if (!(error instanceof git.Errors.NotFoundError)) throw error;
|
|
126
|
+
}
|
|
127
|
+
if (head !== null && !/^[a-f0-9]{40}$/u.test(head))
|
|
128
|
+
throw new SourceWorkspaceError("source_git_invalid");
|
|
129
|
+
const remotes = [
|
|
130
|
+
...new Map(
|
|
131
|
+
(await git.listRemotes({ fs: configFs, gitdir: layout.commonDir }))
|
|
132
|
+
.filter((remote) => typeof remote.remote === "string")
|
|
133
|
+
.map((remote) => [
|
|
134
|
+
remote.remote,
|
|
135
|
+
{ name: safeRemoteName(remote.remote), ...sanitizeRemote(remote.url) },
|
|
136
|
+
]),
|
|
137
|
+
).values(),
|
|
138
|
+
].sort((left, right) => left.name.localeCompare(right.name));
|
|
139
|
+
const upstreamRemote =
|
|
140
|
+
branch === null
|
|
141
|
+
? null
|
|
142
|
+
: ((await git.getConfig({
|
|
143
|
+
fs: configFs,
|
|
144
|
+
gitdir: layout.commonDir,
|
|
145
|
+
path: `branch.${branch}.remote`,
|
|
146
|
+
})) ?? null);
|
|
147
|
+
const pushRemote =
|
|
148
|
+
(branch === null
|
|
149
|
+
? undefined
|
|
150
|
+
: await git.getConfig({
|
|
151
|
+
fs: configFs,
|
|
152
|
+
gitdir: layout.commonDir,
|
|
153
|
+
path: `branch.${branch}.pushRemote`,
|
|
154
|
+
})) ??
|
|
155
|
+
(await git.getConfig({
|
|
156
|
+
fs: configFs,
|
|
157
|
+
gitdir: layout.commonDir,
|
|
158
|
+
path: "remote.pushDefault",
|
|
159
|
+
})) ??
|
|
160
|
+
upstreamRemote;
|
|
161
|
+
return {
|
|
162
|
+
directory: root,
|
|
163
|
+
repositoryRoot: layout.root,
|
|
164
|
+
applicationRoot: portableRelative(layout.root, root) || ".",
|
|
165
|
+
git: {
|
|
166
|
+
head,
|
|
167
|
+
branch,
|
|
168
|
+
remotes,
|
|
169
|
+
upstreamRemote: upstreamRemote === null ? null : safeRemoteName(upstreamRemote),
|
|
170
|
+
pushRemote: pushRemote === null ? null : safeRemoteName(pushRemote),
|
|
171
|
+
},
|
|
172
|
+
};
|
|
173
|
+
} catch {
|
|
174
|
+
throw new SourceWorkspaceError("source_git_invalid");
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
function configFilesystem(gitdir: string, config: string) {
|
|
179
|
+
return {
|
|
180
|
+
promises: {
|
|
181
|
+
...fs,
|
|
182
|
+
readFile: async (path: string, options?: BufferEncoding | { encoding?: BufferEncoding }) => {
|
|
183
|
+
const contents =
|
|
184
|
+
path === join(gitdir, "config") ? Buffer.from(config) : await fs.readFile(path);
|
|
185
|
+
const encoding = typeof options === "string" ? options : options?.encoding;
|
|
186
|
+
return encoding === undefined ? contents : contents.toString(encoding);
|
|
187
|
+
},
|
|
188
|
+
},
|
|
189
|
+
};
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
function safeRemoteName(name: string): string {
|
|
193
|
+
if (!/^[a-zA-Z0-9._/-]{1,128}$/u.test(name)) throw new SourceWorkspaceError("source_git_invalid");
|
|
194
|
+
return name;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
function sanitizeRemote(value: string): Pick<SourceWorkspaceRemote, "url" | "githubRepository"> {
|
|
198
|
+
let url: URL;
|
|
199
|
+
try {
|
|
200
|
+
const scp = /^(?:[^@\s]+@)?([^:/\s]+):([^\s]+)$/u.exec(value);
|
|
201
|
+
url = new URL(scp !== null && !value.includes("://") ? `ssh://${scp[1]}/${scp[2]}` : value);
|
|
202
|
+
if (!["https:", "http:", "ssh:", "git:"].includes(url.protocol))
|
|
203
|
+
return { url: null, githubRepository: null };
|
|
204
|
+
} catch {
|
|
205
|
+
return { url: null, githubRepository: null };
|
|
206
|
+
}
|
|
207
|
+
url.username = "";
|
|
208
|
+
url.password = "";
|
|
209
|
+
url.search = "";
|
|
210
|
+
url.hash = "";
|
|
211
|
+
const repository = url.pathname.replace(/^\//u, "").replace(/\.git\/?$/u, "");
|
|
212
|
+
if (
|
|
213
|
+
url.hostname === "github.com" &&
|
|
214
|
+
(url.port === "" || url.port === "443" || url.port === "22") &&
|
|
215
|
+
/^[a-zA-Z0-9][a-zA-Z0-9._-]{0,99}\/[a-zA-Z0-9][a-zA-Z0-9._-]{0,99}$/u.test(repository)
|
|
216
|
+
) {
|
|
217
|
+
return { url: `https://github.com/${repository}`, githubRepository: repository };
|
|
218
|
+
}
|
|
219
|
+
// Unknown hosts retain only their origin; their paths may carry provider-specific credentials.
|
|
220
|
+
return { url: `${url.protocol}//${url.host}`, githubRepository: null };
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
async function sourceDirectory(directory: string): Promise<string> {
|
|
224
|
+
try {
|
|
225
|
+
const path = resolve(directory);
|
|
226
|
+
const status = await fs.lstat(path);
|
|
227
|
+
if (status.isSymbolicLink()) throw new SourceWorkspaceError("source_symlink_forbidden");
|
|
228
|
+
if (!status.isDirectory()) throw new SourceWorkspaceError("source_directory_invalid");
|
|
229
|
+
return await fs.realpath(path);
|
|
230
|
+
} catch (error) {
|
|
231
|
+
if (error instanceof SourceWorkspaceError) throw error;
|
|
232
|
+
throw new SourceWorkspaceError("source_directory_invalid");
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
async function findGitLayout(directory: string): Promise<GitLayout | null> {
|
|
237
|
+
let root = directory;
|
|
238
|
+
for (;;) {
|
|
239
|
+
const marker = join(root, ".git");
|
|
240
|
+
const status = await optionalStat(marker);
|
|
241
|
+
if (status !== null) {
|
|
242
|
+
if (status.isSymbolicLink()) throw new SourceWorkspaceError("source_git_invalid");
|
|
243
|
+
try {
|
|
244
|
+
let gitdir = marker;
|
|
245
|
+
if (status.isFile()) {
|
|
246
|
+
const content = await readStableFile(marker, status, MAXIMUM_GIT_METADATA_BYTES);
|
|
247
|
+
const pointer = /^gitdir: ([^\r\n]+)\r?\n?$/u.exec(content.toString("utf8"))?.[1];
|
|
248
|
+
if (pointer === undefined || hasControlCharacter(pointer))
|
|
249
|
+
throw new SourceWorkspaceError("source_git_invalid");
|
|
250
|
+
gitdir = resolve(root, pointer);
|
|
251
|
+
}
|
|
252
|
+
gitdir = await fs.realpath(gitdir);
|
|
253
|
+
if (!(await fs.stat(gitdir)).isDirectory())
|
|
254
|
+
throw new SourceWorkspaceError("source_git_invalid");
|
|
255
|
+
const common = await optionalMetadata(join(gitdir, "commondir"));
|
|
256
|
+
if (
|
|
257
|
+
common !== null &&
|
|
258
|
+
(!/^[^\r\n]+\r?\n?$/u.test(common) || hasControlCharacter(common.trim()))
|
|
259
|
+
)
|
|
260
|
+
throw new SourceWorkspaceError("source_git_invalid");
|
|
261
|
+
const commonDir =
|
|
262
|
+
common === null ? gitdir : await fs.realpath(resolve(gitdir, common.trim()));
|
|
263
|
+
return { root, gitdir, commonDir };
|
|
264
|
+
} catch {
|
|
265
|
+
throw new SourceWorkspaceError("source_git_invalid");
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
const parent = dirname(root);
|
|
269
|
+
if (parent === root) return null;
|
|
270
|
+
root = parent;
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
async function optionalMetadata(path: string): Promise<string | null> {
|
|
275
|
+
const status = await optionalStat(path);
|
|
276
|
+
return status === null
|
|
277
|
+
? null
|
|
278
|
+
: (await readStableFile(path, status, MAXIMUM_GIT_METADATA_BYTES)).toString("utf8");
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
interface SourceEntry {
|
|
282
|
+
readonly path: string;
|
|
283
|
+
readonly absolute: string;
|
|
284
|
+
readonly status: BigIntStats;
|
|
285
|
+
}
|
|
286
|
+
interface IgnoreScope {
|
|
287
|
+
readonly directory: string;
|
|
288
|
+
readonly matcher: ReturnType<typeof ignore>;
|
|
289
|
+
}
|
|
290
|
+
interface Manifest {
|
|
291
|
+
readonly entries: readonly SourceEntry[];
|
|
292
|
+
readonly observations: ReadonlyMap<string, string>;
|
|
293
|
+
readonly totalBytes: number;
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
/**
|
|
297
|
+
* Capture current files, not Git commits or history. Reads are bounded and a second complete
|
|
298
|
+
* manifest verifies file/directory identities and ignore rules before any content can be uploaded.
|
|
299
|
+
*/
|
|
300
|
+
export async function createSourceSnapshot(options: {
|
|
301
|
+
readonly directory: string;
|
|
302
|
+
}): Promise<SourceSnapshot> {
|
|
303
|
+
const root = await sourceDirectory(options.directory);
|
|
304
|
+
try {
|
|
305
|
+
const layout = await findGitLayout(root);
|
|
306
|
+
const first = await sourceManifest(root, layout?.root ?? root);
|
|
307
|
+
const files: SourceSnapshotFile[] = [];
|
|
308
|
+
for (const entry of first.entries) {
|
|
309
|
+
const content = await readStableFile(
|
|
310
|
+
entry.absolute,
|
|
311
|
+
entry.status,
|
|
312
|
+
SOURCE_SNAPSHOT_LIMITS.fileBytes,
|
|
313
|
+
root,
|
|
314
|
+
);
|
|
315
|
+
files.push(
|
|
316
|
+
Object.freeze({
|
|
317
|
+
path: entry.path,
|
|
318
|
+
contentBase64: content.toString("base64"),
|
|
319
|
+
sha256: digest(content),
|
|
320
|
+
byteLength: content.byteLength,
|
|
321
|
+
executable: (entry.status.mode & 0o111n) !== 0n,
|
|
322
|
+
}),
|
|
323
|
+
);
|
|
324
|
+
}
|
|
325
|
+
const second = await sourceManifest(root, layout?.root ?? root);
|
|
326
|
+
if (
|
|
327
|
+
first.observations.size !== second.observations.size ||
|
|
328
|
+
[...first.observations].some(([path, stamp]) => second.observations.get(path) !== stamp)
|
|
329
|
+
) {
|
|
330
|
+
throw new SourceWorkspaceError("source_snapshot_changed");
|
|
331
|
+
}
|
|
332
|
+
return Object.freeze({
|
|
333
|
+
sha256: digest(
|
|
334
|
+
JSON.stringify(files.map(({ path, sha256, executable }) => [path, sha256, executable])),
|
|
335
|
+
),
|
|
336
|
+
totalBytes: first.totalBytes,
|
|
337
|
+
files: Object.freeze(files),
|
|
338
|
+
});
|
|
339
|
+
} catch (error) {
|
|
340
|
+
if (error instanceof SourceWorkspaceError) throw error;
|
|
341
|
+
throw new SourceWorkspaceError("source_read_failed");
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
async function sourceManifest(root: string, ignoreRoot: string): Promise<Manifest> {
|
|
346
|
+
const observations = new Map<string, string>();
|
|
347
|
+
const entries: SourceEntry[] = [];
|
|
348
|
+
let totalBytes = 0;
|
|
349
|
+
let scanned = 0;
|
|
350
|
+
const ancestors: string[] = [];
|
|
351
|
+
for (let directory = root; directory !== ignoreRoot; directory = dirname(directory))
|
|
352
|
+
ancestors.unshift(dirname(directory));
|
|
353
|
+
let inherited: readonly IgnoreScope[] = [];
|
|
354
|
+
for (const ancestor of ancestors) {
|
|
355
|
+
inherited = await readIgnoreScope(ancestor, inherited, observations);
|
|
356
|
+
const child = ancestors[ancestors.indexOf(ancestor) + 1] ?? root;
|
|
357
|
+
if (ignored(child, true, inherited)) throw new SourceWorkspaceError("source_directory_ignored");
|
|
358
|
+
}
|
|
359
|
+
async function walk(
|
|
360
|
+
directory: string,
|
|
361
|
+
scopes: readonly IgnoreScope[],
|
|
362
|
+
depth: number,
|
|
363
|
+
): Promise<void> {
|
|
364
|
+
if (depth > MAXIMUM_DEPTH) throw new SourceWorkspaceError("source_scan_limit");
|
|
365
|
+
const status = await fs.lstat(directory, { bigint: true });
|
|
366
|
+
if (status.isSymbolicLink()) throw new SourceWorkspaceError("source_symlink_forbidden");
|
|
367
|
+
if (!status.isDirectory() || (await fs.realpath(directory)) !== directory)
|
|
368
|
+
throw new SourceWorkspaceError("source_snapshot_changed");
|
|
369
|
+
observations.set(directory, stamp(status));
|
|
370
|
+
const active = await readIgnoreScope(directory, scopes, observations);
|
|
371
|
+
const children = await fs.readdir(directory, { withFileTypes: true });
|
|
372
|
+
scanned += children.length;
|
|
373
|
+
if (scanned > MAXIMUM_SCAN_ENTRIES) throw new SourceWorkspaceError("source_scan_limit");
|
|
374
|
+
for (const child of children.sort((left, right) =>
|
|
375
|
+
left.name < right.name ? -1 : left.name > right.name ? 1 : 0,
|
|
376
|
+
)) {
|
|
377
|
+
if (excluded(child.name)) continue;
|
|
378
|
+
const absolute = join(directory, child.name);
|
|
379
|
+
if (ignored(absolute, child.isDirectory(), active)) continue;
|
|
380
|
+
const path = portableRelative(root, absolute);
|
|
381
|
+
if (path.length > 512 || path.includes("\\") || hasControlCharacter(path))
|
|
382
|
+
throw new SourceWorkspaceError("source_path_invalid");
|
|
383
|
+
const childStatus = await fs.lstat(absolute, { bigint: true });
|
|
384
|
+
if (childStatus.isSymbolicLink()) throw new SourceWorkspaceError("source_symlink_forbidden");
|
|
385
|
+
if (childStatus.isDirectory()) await walk(absolute, active, depth + 1);
|
|
386
|
+
else {
|
|
387
|
+
if (!childStatus.isFile()) throw new SourceWorkspaceError("source_path_invalid");
|
|
388
|
+
if (!isManagedSourcePath(path)) throw new SourceWorkspaceError("source_path_invalid");
|
|
389
|
+
if (childStatus.size > BigInt(SOURCE_SNAPSHOT_LIMITS.fileBytes))
|
|
390
|
+
throw new SourceWorkspaceError("source_file_too_large");
|
|
391
|
+
if (entries.length >= SOURCE_SNAPSHOT_LIMITS.files)
|
|
392
|
+
throw new SourceWorkspaceError("source_too_many_files");
|
|
393
|
+
totalBytes += Number(childStatus.size);
|
|
394
|
+
if (totalBytes > SOURCE_SNAPSHOT_LIMITS.totalBytes)
|
|
395
|
+
throw new SourceWorkspaceError("source_snapshot_too_large");
|
|
396
|
+
entries.push({ path, absolute, status: childStatus });
|
|
397
|
+
observations.set(absolute, stamp(childStatus));
|
|
398
|
+
}
|
|
399
|
+
}
|
|
400
|
+
if (stamp(await fs.lstat(directory, { bigint: true })) !== stamp(status))
|
|
401
|
+
throw new SourceWorkspaceError("source_snapshot_changed");
|
|
402
|
+
}
|
|
403
|
+
await walk(root, inherited, 0);
|
|
404
|
+
entries.sort((left, right) => (left.path < right.path ? -1 : left.path > right.path ? 1 : 0));
|
|
405
|
+
return { entries, observations, totalBytes };
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
async function readIgnoreScope(
|
|
409
|
+
directory: string,
|
|
410
|
+
scopes: readonly IgnoreScope[],
|
|
411
|
+
observations: Map<string, string>,
|
|
412
|
+
): Promise<readonly IgnoreScope[]> {
|
|
413
|
+
const path = join(directory, ".gitignore");
|
|
414
|
+
const status = await optionalStat(path);
|
|
415
|
+
observations.set(path, status === null ? "absent" : stamp(status));
|
|
416
|
+
if (status === null) return scopes;
|
|
417
|
+
const contents = await readStableFile(path, status, SOURCE_SNAPSHOT_LIMITS.fileBytes);
|
|
418
|
+
return [
|
|
419
|
+
...scopes,
|
|
420
|
+
{ directory, matcher: ignore({ ignorecase: false }).add(contents.toString("utf8")) },
|
|
421
|
+
];
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
function ignored(path: string, directory: boolean, scopes: readonly IgnoreScope[]): boolean {
|
|
425
|
+
let result = false;
|
|
426
|
+
for (const scope of scopes) {
|
|
427
|
+
const match = scope.matcher.test(
|
|
428
|
+
`${portableRelative(scope.directory, path)}${directory ? "/" : ""}`,
|
|
429
|
+
);
|
|
430
|
+
if (match.ignored) result = true;
|
|
431
|
+
else if (match.unignored) result = false;
|
|
432
|
+
}
|
|
433
|
+
return result;
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
const EXCLUDED_NAMES = new Set([
|
|
437
|
+
".git",
|
|
438
|
+
".ohmyhost",
|
|
439
|
+
".aws",
|
|
440
|
+
".ssh",
|
|
441
|
+
".gnupg",
|
|
442
|
+
".kube",
|
|
443
|
+
".credentials",
|
|
444
|
+
"credentials",
|
|
445
|
+
".git-credentials",
|
|
446
|
+
".npmrc",
|
|
447
|
+
".netrc",
|
|
448
|
+
".pypirc",
|
|
449
|
+
".dev.vars",
|
|
450
|
+
".ds_store",
|
|
451
|
+
"node_modules",
|
|
452
|
+
".next",
|
|
453
|
+
".open-next",
|
|
454
|
+
".output",
|
|
455
|
+
".nuxt",
|
|
456
|
+
".svelte-kit",
|
|
457
|
+
".astro",
|
|
458
|
+
".turbo",
|
|
459
|
+
".vercel",
|
|
460
|
+
".wrangler",
|
|
461
|
+
".cache",
|
|
462
|
+
"dist",
|
|
463
|
+
"out",
|
|
464
|
+
"build",
|
|
465
|
+
"coverage",
|
|
466
|
+
]);
|
|
467
|
+
|
|
468
|
+
function excluded(name: string): boolean {
|
|
469
|
+
const lower = name.toLowerCase();
|
|
470
|
+
return (
|
|
471
|
+
EXCLUDED_NAMES.has(lower) ||
|
|
472
|
+
((lower === ".env" || lower.startsWith(".env.")) && lower !== ".env.example") ||
|
|
473
|
+
lower.startsWith(".dev.vars.") ||
|
|
474
|
+
/(?:\.(?:pem|key|p12|pfx|jks|keystore|log|tsbuildinfo)$|^id_(?:rsa|dsa|ecdsa|ed25519)(?:\.|$)|^(?:credentials|secrets|service[-_]account)(?:[.-]|$)|firebase-adminsdk)/u.test(
|
|
475
|
+
lower,
|
|
476
|
+
)
|
|
477
|
+
);
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
async function optionalStat(path: string): Promise<BigIntStats | null> {
|
|
481
|
+
try {
|
|
482
|
+
return await fs.lstat(path, { bigint: true });
|
|
483
|
+
} catch (error) {
|
|
484
|
+
if (typeof error === "object" && error !== null && "code" in error && error.code === "ENOENT")
|
|
485
|
+
return null;
|
|
486
|
+
throw new SourceWorkspaceError("source_read_failed");
|
|
487
|
+
}
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
async function readStableFile(
|
|
491
|
+
path: string,
|
|
492
|
+
expected: BigIntStats,
|
|
493
|
+
maximumBytes: number,
|
|
494
|
+
sourceRoot?: string,
|
|
495
|
+
): Promise<Buffer> {
|
|
496
|
+
if (expected.isSymbolicLink()) throw new SourceWorkspaceError("source_symlink_forbidden");
|
|
497
|
+
if (!expected.isFile()) throw new SourceWorkspaceError("source_path_invalid");
|
|
498
|
+
if (expected.size > BigInt(maximumBytes)) throw new SourceWorkspaceError("source_file_too_large");
|
|
499
|
+
if (sourceRoot !== undefined) {
|
|
500
|
+
const canonical = await fs.realpath(path);
|
|
501
|
+
const rel = relative(sourceRoot, canonical);
|
|
502
|
+
if (canonical !== path || rel.startsWith(`..${sep}`) || rel === ".." || isAbsolute(rel))
|
|
503
|
+
throw new SourceWorkspaceError("source_symlink_forbidden");
|
|
504
|
+
}
|
|
505
|
+
const handle = await fs.open(path, constants.O_RDONLY | constants.O_NOFOLLOW);
|
|
506
|
+
try {
|
|
507
|
+
if (stamp(await handle.stat({ bigint: true })) !== stamp(expected))
|
|
508
|
+
throw new SourceWorkspaceError("source_snapshot_changed");
|
|
509
|
+
const buffer = Buffer.alloc(Number(expected.size) + 1);
|
|
510
|
+
let length = 0;
|
|
511
|
+
while (length < buffer.byteLength) {
|
|
512
|
+
const result = await handle.read({
|
|
513
|
+
buffer,
|
|
514
|
+
offset: length,
|
|
515
|
+
length: buffer.byteLength - length,
|
|
516
|
+
position: length,
|
|
517
|
+
});
|
|
518
|
+
if (result.bytesRead === 0) break;
|
|
519
|
+
length += result.bytesRead;
|
|
520
|
+
}
|
|
521
|
+
if (
|
|
522
|
+
BigInt(length) !== expected.size ||
|
|
523
|
+
stamp(await handle.stat({ bigint: true })) !== stamp(expected) ||
|
|
524
|
+
stamp(await fs.lstat(path, { bigint: true })) !== stamp(expected) ||
|
|
525
|
+
(sourceRoot !== undefined && (await fs.realpath(path)) !== path)
|
|
526
|
+
)
|
|
527
|
+
throw new SourceWorkspaceError("source_snapshot_changed");
|
|
528
|
+
return buffer.subarray(0, length);
|
|
529
|
+
} finally {
|
|
530
|
+
await handle.close();
|
|
531
|
+
}
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
function stamp(status: BigIntStats): string {
|
|
535
|
+
return [status.dev, status.ino, status.mode, status.size, status.mtimeNs, status.ctimeNs].join(
|
|
536
|
+
":",
|
|
537
|
+
);
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
function portableRelative(root: string, path: string): string {
|
|
541
|
+
return relative(root, path).split(sep).join("/");
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
function digest(value: string | Uint8Array): string {
|
|
545
|
+
return `sha256:${createHash("sha256").update(value).digest("hex")}`;
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
function hasControlCharacter(value: string): boolean {
|
|
549
|
+
return [...value].some(
|
|
550
|
+
(character) => character.charCodeAt(0) < 32 || character.charCodeAt(0) === 127,
|
|
551
|
+
);
|
|
552
|
+
}
|