@yuandc/aica 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.
- package/README.md +9 -0
- package/dist/acp/agent.js +54 -0
- package/dist/acp/client/acp-client.js +102 -0
- package/dist/acp/client/acp-content.js +13 -0
- package/dist/acp/client/acp-events.js +106 -0
- package/dist/acp/client/acp-process.js +34 -0
- package/dist/acp/client/acp-runtime-pool.js +248 -0
- package/dist/acp/client/context-usage.js +29 -0
- package/dist/acp/client/json-rpc.js +128 -0
- package/dist/acp/provider-types.js +1 -0
- package/dist/acp/providers/codex/codex-process.js +51 -0
- package/dist/acp/providers/codex/events.js +1473 -0
- package/dist/acp/providers/codex/permissions.js +49 -0
- package/dist/acp/providers/codex/provider.js +376 -0
- package/dist/acp/providers/codex-acp/adapter.js +947 -0
- package/dist/acp/providers/codex-acp/context-maintenance.js +148 -0
- package/dist/acp/providers/codex-acp/launch.js +35 -0
- package/dist/acp/providers/codex-acp/provider.js +486 -0
- package/dist/acp/providers/mimo/provider.js +448 -0
- package/dist/acp/providers/opencode/provider.js +489 -0
- package/dist/acp/providers/registry.js +23 -0
- package/dist/acp/standard-events.js +167 -0
- package/dist/commands/start.js +137 -0
- package/dist/commands/worker-auth.js +100 -0
- package/dist/commands/worker-project.js +57 -0
- package/dist/core/aca-config.js +74 -0
- package/dist/core/aca-server-client.js +57 -0
- package/dist/core/acp-event-coalescer.js +108 -0
- package/dist/core/acp-event-upload-filter.js +16 -0
- package/dist/core/acp-orphan-cleanup.js +91 -0
- package/dist/core/affected-files.js +268 -0
- package/dist/core/auth.js +36 -0
- package/dist/core/file-transfer-worker.js +169 -0
- package/dist/core/fs.js +28 -0
- package/dist/core/heartbeat.js +578 -0
- package/dist/core/job-permission-policy.js +42 -0
- package/dist/core/job-worker.js +749 -0
- package/dist/core/logger.js +42 -0
- package/dist/core/long-poll-worker.js +26 -0
- package/dist/core/machine-filesystem-worker.js +352 -0
- package/dist/core/paths.js +26 -0
- package/dist/core/process-identity.js +34 -0
- package/dist/core/process.js +33 -0
- package/dist/core/provider-health.js +54 -0
- package/dist/core/runtime-options.js +38 -0
- package/dist/core/worktree.js +95 -0
- package/dist/worker-cli.js +27 -0
- package/dist/worker-single-cli.js +17 -0
- package/package.json +35 -0
|
@@ -0,0 +1,268 @@
|
|
|
1
|
+
import { execFileSync } from "node:child_process";
|
|
2
|
+
import fs from "node:fs";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import crypto from "node:crypto";
|
|
5
|
+
const MAX_HASH_BYTES = 50 * 1024 * 1024;
|
|
6
|
+
const PATH_KEYS = ["path", "file", "filePath", "file_path", "filename", "targetPath", "target_path", "move_path"];
|
|
7
|
+
export function captureGitSnapshot(cwd) {
|
|
8
|
+
if (!isGitWorktree(cwd))
|
|
9
|
+
return null;
|
|
10
|
+
const entries = parseGitStatus(cwd);
|
|
11
|
+
if (!entries)
|
|
12
|
+
return null;
|
|
13
|
+
const snapshot = new Map();
|
|
14
|
+
for (const entry of entries) {
|
|
15
|
+
snapshot.set(entry.path, {
|
|
16
|
+
status: entry.status,
|
|
17
|
+
objectHash: entry.status.includes("D") ? null : gitObjectHash(cwd, entry.path)
|
|
18
|
+
});
|
|
19
|
+
}
|
|
20
|
+
return snapshot;
|
|
21
|
+
}
|
|
22
|
+
export function collectAffectedFiles(input) {
|
|
23
|
+
const items = new Map();
|
|
24
|
+
for (const filePath of input.evidencePaths) {
|
|
25
|
+
addAffectedFile(items, input.cwd, filePath, "unknown", "acp_tool");
|
|
26
|
+
}
|
|
27
|
+
return [...items.values()]
|
|
28
|
+
.filter((item) => item.relativePath && !item.relativePath.startsWith(".git/"))
|
|
29
|
+
.sort((a, b) => a.relativePath.localeCompare(b.relativePath));
|
|
30
|
+
}
|
|
31
|
+
export function extractAffectedFilePathsFromAcpRaw(raw) {
|
|
32
|
+
const paths = new Set();
|
|
33
|
+
visitRaw(raw, (value, key) => {
|
|
34
|
+
if (typeof value !== "string")
|
|
35
|
+
return;
|
|
36
|
+
if (looksLikeFilePathKey(key))
|
|
37
|
+
addPathCandidate(paths, value);
|
|
38
|
+
if (key === "command") {
|
|
39
|
+
for (const filePath of extractShellWritePaths(value))
|
|
40
|
+
addPathCandidate(paths, filePath);
|
|
41
|
+
}
|
|
42
|
+
});
|
|
43
|
+
visitDiffBlocks(raw, paths);
|
|
44
|
+
visitRawChanges(raw, paths);
|
|
45
|
+
return [...paths];
|
|
46
|
+
}
|
|
47
|
+
function addAffectedFile(items, cwd, filePath, changeType, source) {
|
|
48
|
+
const resolved = resolveInside(cwd, filePath);
|
|
49
|
+
if (!resolved)
|
|
50
|
+
return;
|
|
51
|
+
const existing = items.get(resolved.relativePath);
|
|
52
|
+
const stat = safeStat(resolved.absolutePath);
|
|
53
|
+
const next = {
|
|
54
|
+
path: resolved.absolutePath,
|
|
55
|
+
absolutePath: resolved.absolutePath,
|
|
56
|
+
relativePath: resolved.relativePath,
|
|
57
|
+
changeType: mergeChangeType(existing?.changeType, changeType),
|
|
58
|
+
source: existing?.source === "acp_tool" || existing?.source === "acp_diff" ? existing.source : source,
|
|
59
|
+
exists: Boolean(stat?.isFile()),
|
|
60
|
+
downloadable: Boolean(stat?.isFile()),
|
|
61
|
+
...(stat?.isFile() ? {
|
|
62
|
+
size: stat.size,
|
|
63
|
+
mime: mimeForPath(resolved.absolutePath),
|
|
64
|
+
sha256: stat.size <= MAX_HASH_BYTES ? sha256File(resolved.absolutePath) : undefined
|
|
65
|
+
} : {})
|
|
66
|
+
};
|
|
67
|
+
items.set(resolved.relativePath, next);
|
|
68
|
+
}
|
|
69
|
+
function mergeChangeType(current, next) {
|
|
70
|
+
if (!current || current === "unknown")
|
|
71
|
+
return next;
|
|
72
|
+
if (next === "unknown")
|
|
73
|
+
return current;
|
|
74
|
+
if (current === next)
|
|
75
|
+
return current;
|
|
76
|
+
if (current === "created" && next === "modified")
|
|
77
|
+
return "created";
|
|
78
|
+
return next;
|
|
79
|
+
}
|
|
80
|
+
function resolveInside(root, filePath) {
|
|
81
|
+
if (!filePath || filePath.includes("\0"))
|
|
82
|
+
return null;
|
|
83
|
+
const rootAbs = path.resolve(root);
|
|
84
|
+
const absolutePath = path.isAbsolute(filePath) ? path.resolve(filePath) : path.resolve(rootAbs, filePath);
|
|
85
|
+
const relativePath = path.relative(rootAbs, absolutePath).replace(/\\/g, "/");
|
|
86
|
+
if (!relativePath || relativePath.startsWith("..") || path.isAbsolute(relativePath))
|
|
87
|
+
return null;
|
|
88
|
+
return { absolutePath, relativePath };
|
|
89
|
+
}
|
|
90
|
+
function isGitWorktree(cwd) {
|
|
91
|
+
try {
|
|
92
|
+
execFileSync("git", ["-C", cwd, "rev-parse", "--is-inside-work-tree"], { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] });
|
|
93
|
+
return true;
|
|
94
|
+
}
|
|
95
|
+
catch {
|
|
96
|
+
return false;
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
function parseGitStatus(cwd) {
|
|
100
|
+
try {
|
|
101
|
+
const output = execFileSync("git", ["-C", cwd, "status", "--porcelain=v1", "-z", "--untracked-files=all"], {
|
|
102
|
+
encoding: "buffer",
|
|
103
|
+
maxBuffer: 20 * 1024 * 1024
|
|
104
|
+
});
|
|
105
|
+
const parts = output.toString("utf8").split("\0").filter(Boolean);
|
|
106
|
+
const entries = [];
|
|
107
|
+
for (let index = 0; index < parts.length; index += 1) {
|
|
108
|
+
const item = parts[index];
|
|
109
|
+
const status = item.slice(0, 2);
|
|
110
|
+
const rest = item.slice(3);
|
|
111
|
+
if (!rest)
|
|
112
|
+
continue;
|
|
113
|
+
if (status.startsWith("R") || status.startsWith("C")) {
|
|
114
|
+
const target = parts[index + 1];
|
|
115
|
+
if (target) {
|
|
116
|
+
entries.push({ status, path: target });
|
|
117
|
+
index += 1;
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
else {
|
|
121
|
+
entries.push({ status, path: rest });
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
return entries;
|
|
125
|
+
}
|
|
126
|
+
catch {
|
|
127
|
+
return null;
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
function gitObjectHash(cwd, relativePath) {
|
|
131
|
+
try {
|
|
132
|
+
const output = execFileSync("git", ["-C", cwd, "hash-object", "--", relativePath], {
|
|
133
|
+
encoding: "utf8",
|
|
134
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
135
|
+
maxBuffer: 1024 * 1024
|
|
136
|
+
}).trim();
|
|
137
|
+
return /^[0-9a-f]{40,64}$/i.test(output) ? output : null;
|
|
138
|
+
}
|
|
139
|
+
catch {
|
|
140
|
+
return null;
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
function visitRaw(value, visitor, key, depth = 0) {
|
|
144
|
+
if (depth > 8)
|
|
145
|
+
return;
|
|
146
|
+
visitor(value, key);
|
|
147
|
+
if (!value || typeof value !== "object")
|
|
148
|
+
return;
|
|
149
|
+
if (Array.isArray(value)) {
|
|
150
|
+
for (const item of value)
|
|
151
|
+
visitRaw(item, visitor, undefined, depth + 1);
|
|
152
|
+
return;
|
|
153
|
+
}
|
|
154
|
+
for (const [childKey, childValue] of Object.entries(value)) {
|
|
155
|
+
visitRaw(childValue, visitor, childKey, depth + 1);
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
function visitDiffBlocks(raw, paths) {
|
|
159
|
+
visitRaw(raw, (value) => {
|
|
160
|
+
if (!value || typeof value !== "object" || Array.isArray(value))
|
|
161
|
+
return;
|
|
162
|
+
const record = value;
|
|
163
|
+
if (record.type !== "diff")
|
|
164
|
+
return;
|
|
165
|
+
addPathCandidate(paths, record.path);
|
|
166
|
+
});
|
|
167
|
+
}
|
|
168
|
+
function visitRawChanges(raw, paths) {
|
|
169
|
+
visitRaw(raw, (value, key) => {
|
|
170
|
+
if (key !== "changes" || !value || typeof value !== "object" || Array.isArray(value))
|
|
171
|
+
return;
|
|
172
|
+
for (const changePath of Object.keys(value)) {
|
|
173
|
+
addPathCandidate(paths, changePath);
|
|
174
|
+
}
|
|
175
|
+
});
|
|
176
|
+
}
|
|
177
|
+
function looksLikeFilePathKey(key) {
|
|
178
|
+
if (!key)
|
|
179
|
+
return false;
|
|
180
|
+
return PATH_KEYS.includes(key);
|
|
181
|
+
}
|
|
182
|
+
function addPathCandidate(paths, value) {
|
|
183
|
+
if (typeof value !== "string")
|
|
184
|
+
return;
|
|
185
|
+
const trimmed = cleanPathCandidate(value);
|
|
186
|
+
if (!trimmed || trimmed.length > 500 || trimmed.includes("\n"))
|
|
187
|
+
return;
|
|
188
|
+
if (trimmed === "." || trimmed === "..")
|
|
189
|
+
return;
|
|
190
|
+
paths.add(trimmed);
|
|
191
|
+
}
|
|
192
|
+
function cleanPathCandidate(value) {
|
|
193
|
+
// ACP/diff 输出里有时会把 “+19/-7 source=...” 拼在路径后面,这里只保留真实文件路径。
|
|
194
|
+
let candidate = value.trim();
|
|
195
|
+
candidate = candidate.replace(/\s+\+\d+\s*\/\s*-\d+(?:\s+.*)?$/, "");
|
|
196
|
+
candidate = candidate.replace(/\s+\(\s*\+\d+\s*[,/ ]\s*-\d+\s*\)(?:\s+.*)?$/, "");
|
|
197
|
+
candidate = candidate.replace(/\s+(?:source|event|workspace|session|last)=\S+.*$/, "");
|
|
198
|
+
const token = candidate.split(/\s+/)[0] || "";
|
|
199
|
+
if (token && /\.[A-Za-z][A-Za-z0-9+-]{0,12}$/.test(token))
|
|
200
|
+
candidate = token;
|
|
201
|
+
return candidate.replace(/^["']|["']$/g, "").trim();
|
|
202
|
+
}
|
|
203
|
+
function extractShellWritePaths(command) {
|
|
204
|
+
const paths = [];
|
|
205
|
+
const redirectPattern = /(?:^|\s)(?:\d?>|>>)\s*(?:"([^"]+)"|'([^']+)'|([^\s;&|<>]+))/g;
|
|
206
|
+
let match;
|
|
207
|
+
while ((match = redirectPattern.exec(command))) {
|
|
208
|
+
const value = match[1] || match[2] || match[3];
|
|
209
|
+
if (value)
|
|
210
|
+
paths.push(value);
|
|
211
|
+
}
|
|
212
|
+
const touchPattern = /(?:^|\s)touch\s+(?:"([^"]+)"|'([^']+)'|([^\s;&|<>]+))/g;
|
|
213
|
+
while ((match = touchPattern.exec(command))) {
|
|
214
|
+
const value = match[1] || match[2] || match[3];
|
|
215
|
+
if (value && !value.startsWith("-"))
|
|
216
|
+
paths.push(value);
|
|
217
|
+
}
|
|
218
|
+
return paths;
|
|
219
|
+
}
|
|
220
|
+
function safeStat(filePath) {
|
|
221
|
+
try {
|
|
222
|
+
return fs.statSync(filePath);
|
|
223
|
+
}
|
|
224
|
+
catch {
|
|
225
|
+
return null;
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
function sha256File(filePath) {
|
|
229
|
+
try {
|
|
230
|
+
return crypto.createHash("sha256").update(fs.readFileSync(filePath)).digest("hex");
|
|
231
|
+
}
|
|
232
|
+
catch {
|
|
233
|
+
return undefined;
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
function mimeForPath(filePath) {
|
|
237
|
+
switch (path.extname(filePath).toLowerCase()) {
|
|
238
|
+
case ".pdf":
|
|
239
|
+
return "application/pdf";
|
|
240
|
+
case ".md":
|
|
241
|
+
case ".markdown":
|
|
242
|
+
return "text/markdown; charset=utf-8";
|
|
243
|
+
case ".txt":
|
|
244
|
+
case ".log":
|
|
245
|
+
return "text/plain; charset=utf-8";
|
|
246
|
+
case ".json":
|
|
247
|
+
return "application/json; charset=utf-8";
|
|
248
|
+
case ".csv":
|
|
249
|
+
return "text/csv; charset=utf-8";
|
|
250
|
+
case ".png":
|
|
251
|
+
return "image/png";
|
|
252
|
+
case ".jpg":
|
|
253
|
+
case ".jpeg":
|
|
254
|
+
return "image/jpeg";
|
|
255
|
+
case ".gif":
|
|
256
|
+
return "image/gif";
|
|
257
|
+
case ".webp":
|
|
258
|
+
return "image/webp";
|
|
259
|
+
case ".zip":
|
|
260
|
+
return "application/zip";
|
|
261
|
+
case ".docx":
|
|
262
|
+
return "application/vnd.openxmlformats-officedocument.wordprocessingml.document";
|
|
263
|
+
case ".xlsx":
|
|
264
|
+
return "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet";
|
|
265
|
+
default:
|
|
266
|
+
return "application/octet-stream";
|
|
267
|
+
}
|
|
268
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { readJsonFile, writeJsonFile } from "./fs.js";
|
|
3
|
+
import { getCredentialsPath } from "./paths.js";
|
|
4
|
+
const CredentialsSchema = z.object({
|
|
5
|
+
token: z.string().optional(),
|
|
6
|
+
user: z
|
|
7
|
+
.object({
|
|
8
|
+
id: z.string().optional(),
|
|
9
|
+
email: z.string().optional()
|
|
10
|
+
})
|
|
11
|
+
.passthrough()
|
|
12
|
+
.optional(),
|
|
13
|
+
machine: z
|
|
14
|
+
.object({
|
|
15
|
+
machineId: z.string().optional(),
|
|
16
|
+
machineName: z.string().optional()
|
|
17
|
+
})
|
|
18
|
+
.passthrough()
|
|
19
|
+
.optional()
|
|
20
|
+
}).passthrough();
|
|
21
|
+
export function loadCredentials() {
|
|
22
|
+
const raw = readJsonFile(getCredentialsPath());
|
|
23
|
+
if (!raw)
|
|
24
|
+
return null;
|
|
25
|
+
const parsed = CredentialsSchema.safeParse(raw);
|
|
26
|
+
return parsed.success ? parsed.data : null;
|
|
27
|
+
}
|
|
28
|
+
export function saveApiKeyCredential(apiKey, machineName) {
|
|
29
|
+
const credentials = {
|
|
30
|
+
token: apiKey,
|
|
31
|
+
user: { id: "local-user" },
|
|
32
|
+
machine: { machineId: "local-machine", machineName }
|
|
33
|
+
};
|
|
34
|
+
writeJsonFile(getCredentialsPath(), credentials);
|
|
35
|
+
return credentials;
|
|
36
|
+
}
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import os from "node:os";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { Readable } from "node:stream";
|
|
5
|
+
import { acaServerRequest, acaServerStreamRequest } from "./aca-server-client.js";
|
|
6
|
+
import { loadAcaConfig } from "./aca-config.js";
|
|
7
|
+
import { getAcaHome } from "./paths.js";
|
|
8
|
+
import { startLongPollWorker } from "./long-poll-worker.js";
|
|
9
|
+
const LONG_POLL_WAIT_MS = 25_000;
|
|
10
|
+
export function startFileTransferWorkerLoop(logger) {
|
|
11
|
+
return startLongPollWorker({
|
|
12
|
+
run: async () => {
|
|
13
|
+
const config = loadAcaConfig();
|
|
14
|
+
if (!config.token)
|
|
15
|
+
throw new Error("Worker 尚未配置认证令牌");
|
|
16
|
+
const response = await acaServerRequest("GET", `/api/client/file-requests/claim?machineId=${encodeURIComponent(config.machineId)}&waitMs=${LONG_POLL_WAIT_MS}`);
|
|
17
|
+
if (!response.item)
|
|
18
|
+
return;
|
|
19
|
+
try {
|
|
20
|
+
await streamFileRequest(response.item, config, logger);
|
|
21
|
+
}
|
|
22
|
+
catch (error) {
|
|
23
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
24
|
+
logger.warn(`file request failed request=${response.item.requestId}: ${message}`);
|
|
25
|
+
try {
|
|
26
|
+
await failFileRequest(response.item.requestId, message);
|
|
27
|
+
}
|
|
28
|
+
catch (failError) {
|
|
29
|
+
logger.warn(`file request fail callback failed request=${response.item.requestId}: ${failError instanceof Error ? failError.message : String(failError)}`);
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
},
|
|
33
|
+
onError: (error, retryMs) => logger.warn(`file transfer worker failed, retry in ${retryMs}ms: ${error instanceof Error ? error.message : String(error)}`)
|
|
34
|
+
});
|
|
35
|
+
}
|
|
36
|
+
async function failFileRequest(requestId, message) {
|
|
37
|
+
await acaServerRequest("POST", `/api/client/file-requests/${encodeURIComponent(requestId)}/fail`, { message });
|
|
38
|
+
}
|
|
39
|
+
async function streamFileRequest(request, config, logger) {
|
|
40
|
+
const project = config.projects.find((item) => item.projectId === request.projectId);
|
|
41
|
+
const projectRoot = expandProjectRoot(request.projectRoot || project?.rootPath || "");
|
|
42
|
+
if (!projectRoot)
|
|
43
|
+
throw new Error(`file request ${request.requestId} project root is unknown`);
|
|
44
|
+
const purpose = request.purpose === "temp-image" ? "temp-image" : "project-file";
|
|
45
|
+
const resolved = purpose === "temp-image"
|
|
46
|
+
? resolveTempImageFile(request.path)
|
|
47
|
+
: resolveProjectFile(projectRoot, request.path);
|
|
48
|
+
const stat = fs.statSync(resolved);
|
|
49
|
+
if (!stat.isFile())
|
|
50
|
+
throw new Error(`file request ${request.requestId} target is not a file`);
|
|
51
|
+
if (stat.size > request.maxBytes)
|
|
52
|
+
throw new Error(`file request ${request.requestId} target is too large (${stat.size} bytes)`);
|
|
53
|
+
const mimeType = purpose === "temp-image" ? detectSafeTempImageMime(resolved) : mimeForPath(resolved);
|
|
54
|
+
logger.info(`streaming file request=${request.requestId} path=${resolved}`);
|
|
55
|
+
await acaServerStreamRequest("POST", `/api/client/file-requests/${encodeURIComponent(request.requestId)}/stream`, Readable.toWeb(fs.createReadStream(resolved)), {
|
|
56
|
+
"content-type": "application/octet-stream",
|
|
57
|
+
"x-aca-file-name": encodeURIComponent(path.basename(resolved)),
|
|
58
|
+
"x-aca-file-size": String(stat.size),
|
|
59
|
+
"x-aca-file-mime": mimeType
|
|
60
|
+
});
|
|
61
|
+
}
|
|
62
|
+
function resolveProjectFile(projectRoot, requestedPath) {
|
|
63
|
+
const root = fs.realpathSync(expandProjectRoot(projectRoot));
|
|
64
|
+
const candidate = path.isAbsolute(requestedPath)
|
|
65
|
+
? requestedPath
|
|
66
|
+
: path.join(root, requestedPath);
|
|
67
|
+
const target = fs.realpathSync(candidate);
|
|
68
|
+
const relative = path.relative(root, target);
|
|
69
|
+
if (relative === "" || relative.startsWith("..") || path.isAbsolute(relative)) {
|
|
70
|
+
throw new Error("requested file is outside project root");
|
|
71
|
+
}
|
|
72
|
+
return target;
|
|
73
|
+
}
|
|
74
|
+
export function resolveTempImageFile(requestedPath) {
|
|
75
|
+
const candidate = localPathFromUri(requestedPath);
|
|
76
|
+
if (!path.isAbsolute(candidate))
|
|
77
|
+
throw new Error("temporary image path must be absolute");
|
|
78
|
+
const sourceStat = fs.lstatSync(candidate);
|
|
79
|
+
if (sourceStat.isSymbolicLink())
|
|
80
|
+
throw new Error("temporary image cannot be a symbolic link");
|
|
81
|
+
if (!sourceStat.isFile())
|
|
82
|
+
throw new Error("temporary image target is not a regular file");
|
|
83
|
+
const tempRoot = fs.realpathSync(os.tmpdir());
|
|
84
|
+
const target = fs.realpathSync(candidate);
|
|
85
|
+
const relative = path.relative(tempRoot, target);
|
|
86
|
+
if (relative === "" || relative.startsWith("..") || path.isAbsolute(relative)) {
|
|
87
|
+
throw new Error("temporary image is outside the system temp directory");
|
|
88
|
+
}
|
|
89
|
+
return target;
|
|
90
|
+
}
|
|
91
|
+
export function detectSafeTempImageMime(filePath) {
|
|
92
|
+
const extension = path.extname(filePath).toLowerCase();
|
|
93
|
+
const header = Buffer.alloc(16);
|
|
94
|
+
const file = fs.openSync(filePath, "r");
|
|
95
|
+
let bytesRead = 0;
|
|
96
|
+
try {
|
|
97
|
+
bytesRead = fs.readSync(file, header, 0, header.length, 0);
|
|
98
|
+
}
|
|
99
|
+
finally {
|
|
100
|
+
fs.closeSync(file);
|
|
101
|
+
}
|
|
102
|
+
const bytes = header.subarray(0, bytesRead);
|
|
103
|
+
if (extension === ".png" && bytes.length >= 8 && bytes.subarray(0, 8).equals(Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]))) {
|
|
104
|
+
return "image/png";
|
|
105
|
+
}
|
|
106
|
+
if ([".jpg", ".jpeg"].includes(extension) && bytes.length >= 3 && bytes[0] === 0xff && bytes[1] === 0xd8 && bytes[2] === 0xff) {
|
|
107
|
+
return "image/jpeg";
|
|
108
|
+
}
|
|
109
|
+
if (extension === ".gif" && bytes.length >= 6 && ["GIF87a", "GIF89a"].includes(bytes.subarray(0, 6).toString("ascii"))) {
|
|
110
|
+
return "image/gif";
|
|
111
|
+
}
|
|
112
|
+
if (extension === ".webp" && bytes.length >= 12 && bytes.subarray(0, 4).toString("ascii") === "RIFF" && bytes.subarray(8, 12).toString("ascii") === "WEBP") {
|
|
113
|
+
return "image/webp";
|
|
114
|
+
}
|
|
115
|
+
throw new Error("temporary file is not a supported raster image");
|
|
116
|
+
}
|
|
117
|
+
function localPathFromUri(value) {
|
|
118
|
+
const trimmed = String(value || "").trim();
|
|
119
|
+
if (!trimmed.startsWith("file://"))
|
|
120
|
+
return trimmed;
|
|
121
|
+
try {
|
|
122
|
+
return decodeURIComponent(new URL(trimmed).pathname);
|
|
123
|
+
}
|
|
124
|
+
catch {
|
|
125
|
+
throw new Error("temporary image path is invalid");
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
function expandProjectRoot(value) {
|
|
129
|
+
if (!value)
|
|
130
|
+
return "";
|
|
131
|
+
if (value === "~")
|
|
132
|
+
return path.dirname(getAcaHome());
|
|
133
|
+
if (value.startsWith("~/"))
|
|
134
|
+
return path.join(path.dirname(getAcaHome()), value.slice(2));
|
|
135
|
+
return value;
|
|
136
|
+
}
|
|
137
|
+
function mimeForPath(filePath) {
|
|
138
|
+
switch (path.extname(filePath).toLowerCase()) {
|
|
139
|
+
case ".pdf":
|
|
140
|
+
return "application/pdf";
|
|
141
|
+
case ".md":
|
|
142
|
+
case ".markdown":
|
|
143
|
+
return "text/markdown; charset=utf-8";
|
|
144
|
+
case ".txt":
|
|
145
|
+
case ".log":
|
|
146
|
+
return "text/plain; charset=utf-8";
|
|
147
|
+
case ".html":
|
|
148
|
+
return "text/html; charset=utf-8";
|
|
149
|
+
case ".json":
|
|
150
|
+
return "application/json; charset=utf-8";
|
|
151
|
+
case ".png":
|
|
152
|
+
return "image/png";
|
|
153
|
+
case ".jpg":
|
|
154
|
+
case ".jpeg":
|
|
155
|
+
return "image/jpeg";
|
|
156
|
+
case ".gif":
|
|
157
|
+
return "image/gif";
|
|
158
|
+
case ".webp":
|
|
159
|
+
return "image/webp";
|
|
160
|
+
case ".zip":
|
|
161
|
+
return "application/zip";
|
|
162
|
+
case ".docx":
|
|
163
|
+
return "application/vnd.openxmlformats-officedocument.wordprocessingml.document";
|
|
164
|
+
case ".xlsx":
|
|
165
|
+
return "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet";
|
|
166
|
+
default:
|
|
167
|
+
return "application/octet-stream";
|
|
168
|
+
}
|
|
169
|
+
}
|
package/dist/core/fs.js
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
export function ensureDir(dir) {
|
|
4
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
5
|
+
}
|
|
6
|
+
export function writeJsonFile(file, value) {
|
|
7
|
+
ensureDir(path.dirname(file));
|
|
8
|
+
fs.writeFileSync(file, `${JSON.stringify(value, null, 2)}\n`, "utf8");
|
|
9
|
+
}
|
|
10
|
+
export function readJsonFile(file) {
|
|
11
|
+
try {
|
|
12
|
+
return JSON.parse(fs.readFileSync(file, "utf8"));
|
|
13
|
+
}
|
|
14
|
+
catch (error) {
|
|
15
|
+
if (error.code === "ENOENT")
|
|
16
|
+
return null;
|
|
17
|
+
throw error;
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
export function removeFileIfExists(file) {
|
|
21
|
+
try {
|
|
22
|
+
fs.unlinkSync(file);
|
|
23
|
+
}
|
|
24
|
+
catch (error) {
|
|
25
|
+
if (error.code !== "ENOENT")
|
|
26
|
+
throw error;
|
|
27
|
+
}
|
|
28
|
+
}
|