@khanglvm/outline-cli 0.1.1
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/.env.test.example +2 -0
- package/AGENTS.md +107 -0
- package/CHANGELOG.md +102 -0
- package/README.md +244 -0
- package/bin/outline-agent.js +5 -0
- package/bin/outline-cli.js +13 -0
- package/package.json +25 -0
- package/scripts/generate-entry-integrity.mjs +123 -0
- package/scripts/release.mjs +353 -0
- package/src/action-gate.js +257 -0
- package/src/agent-skills.js +759 -0
- package/src/cli.js +956 -0
- package/src/config-store.js +720 -0
- package/src/entry-integrity-binding.generated.js +6 -0
- package/src/entry-integrity-manifest.generated.js +74 -0
- package/src/entry-integrity.js +112 -0
- package/src/errors.js +15 -0
- package/src/outline-client.js +237 -0
- package/src/result-store.js +183 -0
- package/src/secure-keyring.js +290 -0
- package/src/tool-arg-schemas.js +2346 -0
- package/src/tools.extended.js +3252 -0
- package/src/tools.js +1056 -0
- package/src/tools.mutation.js +1807 -0
- package/src/tools.navigation.js +2273 -0
- package/src/tools.platform.js +554 -0
- package/src/utils.js +176 -0
- package/test/action-gate.unit.test.js +157 -0
- package/test/agent-skills.unit.test.js +52 -0
- package/test/config-store.unit.test.js +89 -0
- package/test/hardening.unit.test.js +3778 -0
- package/test/live.integration.test.js +5140 -0
- package/test/profile-selection.unit.test.js +279 -0
- package/test/security.unit.test.js +113 -0
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
// Auto-generated by scripts/generate-entry-integrity.mjs
|
|
2
|
+
export const ENTRY_INTEGRITY_MANIFEST = Object.freeze({
|
|
3
|
+
version: 1,
|
|
4
|
+
algorithm: "sha256",
|
|
5
|
+
signatureAlgorithm: "sha256-salted-manifest-v1",
|
|
6
|
+
signature: "26d0616d3f957992d3bf8ecf5a243bed9afb4bd239710449c5e2b8df56117996",
|
|
7
|
+
generatedAt: "2026-03-05T13:45:11.761Z",
|
|
8
|
+
files: [
|
|
9
|
+
{
|
|
10
|
+
"path": "src/action-gate.js",
|
|
11
|
+
"sha256": "f85adb76e6da6e1644715586b09d02bc5ab9b5bf37a29f6c207dce4cc26d6a78"
|
|
12
|
+
},
|
|
13
|
+
{
|
|
14
|
+
"path": "src/agent-skills.js",
|
|
15
|
+
"sha256": "d9ea2f000689311ecb6bfae92c469dd16a2d4f64ce84098f121cad2a24dd4529"
|
|
16
|
+
},
|
|
17
|
+
{
|
|
18
|
+
"path": "src/cli.js",
|
|
19
|
+
"sha256": "b19f98ced5aba789c860891bbc21f95d4a5443f185c6c48f00bced81eadc1acb"
|
|
20
|
+
},
|
|
21
|
+
{
|
|
22
|
+
"path": "src/config-store.js",
|
|
23
|
+
"sha256": "e0d62083c694f84895a93752738accbbd98830157227f06380ccdf8e421508fd"
|
|
24
|
+
},
|
|
25
|
+
{
|
|
26
|
+
"path": "src/entry-integrity.js",
|
|
27
|
+
"sha256": "3463fc3a3c6d0aead8e889ecce7c323b64b09ef54c772c52ff8b7266eb8c0d69"
|
|
28
|
+
},
|
|
29
|
+
{
|
|
30
|
+
"path": "src/errors.js",
|
|
31
|
+
"sha256": "45d49bbad4aaa5973fa01e176cd4c5efbddaa487692f18de698bdcad16be00a6"
|
|
32
|
+
},
|
|
33
|
+
{
|
|
34
|
+
"path": "src/outline-client.js",
|
|
35
|
+
"sha256": "2fe343f3cb1773c53fd14fad784d3ef1f5ff0e2c4c43b4a752ca9b7d5421677c"
|
|
36
|
+
},
|
|
37
|
+
{
|
|
38
|
+
"path": "src/result-store.js",
|
|
39
|
+
"sha256": "774e220493103fac4fa462a4539b9d644a06a760a73ec53d9779f7fb613d4544"
|
|
40
|
+
},
|
|
41
|
+
{
|
|
42
|
+
"path": "src/secure-keyring.js",
|
|
43
|
+
"sha256": "370d904265733f7d6b0f47b3213c7f026c95e59bc18d364dff96be90dbd7f479"
|
|
44
|
+
},
|
|
45
|
+
{
|
|
46
|
+
"path": "src/tool-arg-schemas.js",
|
|
47
|
+
"sha256": "de6f389349d6688296a8e66d2d612937f31b4fc2a0efdca085d36ec5b09cab26"
|
|
48
|
+
},
|
|
49
|
+
{
|
|
50
|
+
"path": "src/tools.extended.js",
|
|
51
|
+
"sha256": "8f29601301c7942cce8884c689916a78a22231e5f6691ecebfe09d9d4f87d6c3"
|
|
52
|
+
},
|
|
53
|
+
{
|
|
54
|
+
"path": "src/tools.js",
|
|
55
|
+
"sha256": "bfcb36045abecc758237ce9441d7e5994a4467893b09d48557749a90de424401"
|
|
56
|
+
},
|
|
57
|
+
{
|
|
58
|
+
"path": "src/tools.mutation.js",
|
|
59
|
+
"sha256": "013cee7d9815f868cca8990af39abe385239fa0f324054477d681e479605e903"
|
|
60
|
+
},
|
|
61
|
+
{
|
|
62
|
+
"path": "src/tools.navigation.js",
|
|
63
|
+
"sha256": "8cd6ad37db2ef4d8a2ddd2eea408c8ab50b5649b620680cef07bd8f3ecea7e89"
|
|
64
|
+
},
|
|
65
|
+
{
|
|
66
|
+
"path": "src/tools.platform.js",
|
|
67
|
+
"sha256": "4ddbc9a07d184fe9162a90b64ebf019d03c4edbc3067dc99e6b2a1ebcccc0afe"
|
|
68
|
+
},
|
|
69
|
+
{
|
|
70
|
+
"path": "src/utils.js",
|
|
71
|
+
"sha256": "c586c449ee51145c3cdc1fc98731acddbfb5d969255cab5064c291276e11012d"
|
|
72
|
+
}
|
|
73
|
+
],
|
|
74
|
+
});
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
import crypto from "node:crypto";
|
|
2
|
+
import fs from "node:fs/promises";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { fileURLToPath } from "node:url";
|
|
5
|
+
import { CliError } from "./errors.js";
|
|
6
|
+
import { ENTRY_INTEGRITY_BINDING } from "./entry-integrity-binding.generated.js";
|
|
7
|
+
import { ENTRY_INTEGRITY_MANIFEST } from "./entry-integrity-manifest.generated.js";
|
|
8
|
+
|
|
9
|
+
const REPO_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
|
|
10
|
+
|
|
11
|
+
function digestHex(value) {
|
|
12
|
+
return crypto.createHash("sha256").update(value).digest("hex");
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function canonicalManifestInput(files) {
|
|
16
|
+
return files
|
|
17
|
+
.map((item) => ({
|
|
18
|
+
path: item.path.replace(/\\/g, "/"),
|
|
19
|
+
sha256: String(item.sha256),
|
|
20
|
+
}))
|
|
21
|
+
.sort((a, b) => a.path.localeCompare(b.path))
|
|
22
|
+
.map((item) => `${item.path}:${item.sha256}`)
|
|
23
|
+
.join("\n");
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function signatureFor(files, keySalt) {
|
|
27
|
+
const canonical = canonicalManifestInput(files);
|
|
28
|
+
return digestHex(`${keySalt}\n${canonical}`);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
async function hashFile(absPath) {
|
|
32
|
+
const raw = await fs.readFile(absPath);
|
|
33
|
+
return digestHex(raw);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function shouldSkipIntegrityCheck() {
|
|
37
|
+
const value = String(process.env.OUTLINE_CLI_SKIP_INTEGRITY_CHECK || "").trim().toLowerCase();
|
|
38
|
+
return value === "1" || value === "true" || value === "yes";
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export async function assertEntryIntegrity(rootDir = REPO_ROOT) {
|
|
42
|
+
if (shouldSkipIntegrityCheck()) {
|
|
43
|
+
return {
|
|
44
|
+
ok: true,
|
|
45
|
+
skipped: true,
|
|
46
|
+
reason: "env-skip",
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const manifest = ENTRY_INTEGRITY_MANIFEST || {};
|
|
51
|
+
const files = Array.isArray(manifest.files) ? manifest.files : [];
|
|
52
|
+
if (files.length === 0) {
|
|
53
|
+
throw new CliError("Entry integrity manifest is empty", {
|
|
54
|
+
code: "ENTRY_INTEGRITY_MANIFEST_EMPTY",
|
|
55
|
+
});
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const computedSignature = signatureFor(files, ENTRY_INTEGRITY_BINDING.keySalt);
|
|
59
|
+
if (computedSignature !== manifest.signature) {
|
|
60
|
+
throw new CliError("Entry integrity signature mismatch", {
|
|
61
|
+
code: "ENTRY_INTEGRITY_SIGNATURE_MISMATCH",
|
|
62
|
+
expected: manifest.signature,
|
|
63
|
+
actual: computedSignature,
|
|
64
|
+
keyId: ENTRY_INTEGRITY_BINDING.keyId,
|
|
65
|
+
});
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const mismatches = [];
|
|
69
|
+
for (const item of files) {
|
|
70
|
+
const relPath = item.path.replace(/\\/g, "/");
|
|
71
|
+
const absPath = path.resolve(rootDir, relPath);
|
|
72
|
+
try {
|
|
73
|
+
const actual = await hashFile(absPath);
|
|
74
|
+
if (actual !== item.sha256) {
|
|
75
|
+
mismatches.push({
|
|
76
|
+
path: relPath,
|
|
77
|
+
expected: item.sha256,
|
|
78
|
+
actual,
|
|
79
|
+
});
|
|
80
|
+
}
|
|
81
|
+
} catch (err) {
|
|
82
|
+
mismatches.push({
|
|
83
|
+
path: relPath,
|
|
84
|
+
expected: item.sha256,
|
|
85
|
+
actual: null,
|
|
86
|
+
error: err?.message || String(err),
|
|
87
|
+
});
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
if (mismatches.length > 0) {
|
|
92
|
+
throw new CliError("Entry integrity check failed; one or more sub-modules do not match build-time state", {
|
|
93
|
+
code: "ENTRY_SUBMODULE_INTEGRITY_FAILED",
|
|
94
|
+
mismatchCount: mismatches.length,
|
|
95
|
+
mismatches,
|
|
96
|
+
});
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
return {
|
|
100
|
+
ok: true,
|
|
101
|
+
checkedFiles: files.length,
|
|
102
|
+
keyId: ENTRY_INTEGRITY_BINDING.keyId,
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export function signManifestFiles(files, keySalt) {
|
|
107
|
+
return signatureFor(files, keySalt);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
export function normalizeManifestFiles(files) {
|
|
111
|
+
return canonicalManifestInput(files);
|
|
112
|
+
}
|
package/src/errors.js
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
export class CliError extends Error {
|
|
2
|
+
constructor(message, details = {}) {
|
|
3
|
+
super(message);
|
|
4
|
+
this.name = "CliError";
|
|
5
|
+
this.details = details;
|
|
6
|
+
}
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export class ApiError extends Error {
|
|
10
|
+
constructor(message, details = {}) {
|
|
11
|
+
super(message);
|
|
12
|
+
this.name = "ApiError";
|
|
13
|
+
this.details = details;
|
|
14
|
+
}
|
|
15
|
+
}
|
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
import { ApiError } from "./errors.js";
|
|
2
|
+
import { sleep } from "./utils.js";
|
|
3
|
+
|
|
4
|
+
export class OutlineClient {
|
|
5
|
+
#tokenState = null;
|
|
6
|
+
|
|
7
|
+
constructor(profile) {
|
|
8
|
+
this.profile = profile;
|
|
9
|
+
this.baseApiUrl = `${profile.baseUrl.replace(/\/+$/, "")}/api`;
|
|
10
|
+
this.timeoutMs = profile.timeoutMs || 30000;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
async call(method, body = {}, options = {}) {
|
|
14
|
+
const apiMethod = method.startsWith("/") ? method.slice(1) : method;
|
|
15
|
+
const url = `${this.baseApiUrl}/${apiMethod}`;
|
|
16
|
+
const maxAttempts = Math.max(1, options.maxAttempts || 1);
|
|
17
|
+
|
|
18
|
+
let lastErr;
|
|
19
|
+
for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
|
|
20
|
+
try {
|
|
21
|
+
return await this.#callOnce(url, body, options);
|
|
22
|
+
} catch (err) {
|
|
23
|
+
lastErr = err;
|
|
24
|
+
const shouldRetry =
|
|
25
|
+
err instanceof ApiError &&
|
|
26
|
+
(err.details.status === 429 || err.details.status >= 500) &&
|
|
27
|
+
attempt < maxAttempts;
|
|
28
|
+
|
|
29
|
+
if (!shouldRetry) {
|
|
30
|
+
throw err;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const retryAfter = Number(err.details.retryAfter || 0);
|
|
34
|
+
const waitMs = retryAfter > 0 ? retryAfter * 1000 : attempt * 400;
|
|
35
|
+
await sleep(waitMs);
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
throw lastErr;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
async #callOnce(url, body, options) {
|
|
43
|
+
const bodyType = options.bodyType === "multipart" ? "multipart" : "json";
|
|
44
|
+
const requestBody = this.#buildRequestBody(bodyType, body);
|
|
45
|
+
const headers = await this.#buildHeaders(options.headers || {}, { bodyType });
|
|
46
|
+
const controller = new AbortController();
|
|
47
|
+
const timeout = setTimeout(() => controller.abort(), this.timeoutMs);
|
|
48
|
+
|
|
49
|
+
let res;
|
|
50
|
+
try {
|
|
51
|
+
res = await fetch(url, {
|
|
52
|
+
method: "POST",
|
|
53
|
+
headers,
|
|
54
|
+
body: requestBody,
|
|
55
|
+
signal: controller.signal,
|
|
56
|
+
});
|
|
57
|
+
} catch (err) {
|
|
58
|
+
if (err.name === "AbortError") {
|
|
59
|
+
throw new ApiError(`Request timeout after ${this.timeoutMs}ms`, {
|
|
60
|
+
status: 408,
|
|
61
|
+
url,
|
|
62
|
+
});
|
|
63
|
+
}
|
|
64
|
+
throw new ApiError(`Network error: ${err.message}`, {
|
|
65
|
+
status: 503,
|
|
66
|
+
url,
|
|
67
|
+
});
|
|
68
|
+
} finally {
|
|
69
|
+
clearTimeout(timeout);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const text = await res.text();
|
|
73
|
+
let parsed;
|
|
74
|
+
try {
|
|
75
|
+
parsed = text ? JSON.parse(text) : {};
|
|
76
|
+
} catch {
|
|
77
|
+
parsed = { ok: false, message: text || "Non-JSON response" };
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
if (!res.ok || parsed?.ok === false) {
|
|
81
|
+
const status = parsed?.status || res.status;
|
|
82
|
+
const retryAfter = res.headers.get("retry-after");
|
|
83
|
+
throw new ApiError(parsed?.message || parsed?.error || res.statusText, {
|
|
84
|
+
status,
|
|
85
|
+
retryAfter,
|
|
86
|
+
body: parsed,
|
|
87
|
+
url,
|
|
88
|
+
});
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
return {
|
|
92
|
+
ok: true,
|
|
93
|
+
status: res.status,
|
|
94
|
+
headers: Object.fromEntries(res.headers.entries()),
|
|
95
|
+
body: parsed,
|
|
96
|
+
};
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
#buildRequestBody(bodyType, body) {
|
|
100
|
+
if (bodyType === "multipart") {
|
|
101
|
+
if (body instanceof FormData) {
|
|
102
|
+
return body;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
const form = new FormData();
|
|
106
|
+
if (body && typeof body === "object") {
|
|
107
|
+
const keys = Object.keys(body).sort((a, b) => a.localeCompare(b));
|
|
108
|
+
for (const key of keys) {
|
|
109
|
+
const value = body[key];
|
|
110
|
+
if (value === undefined) {
|
|
111
|
+
continue;
|
|
112
|
+
}
|
|
113
|
+
if (value instanceof Blob) {
|
|
114
|
+
form.append(key, value);
|
|
115
|
+
continue;
|
|
116
|
+
}
|
|
117
|
+
if (value && typeof value === "object") {
|
|
118
|
+
form.append(key, JSON.stringify(value));
|
|
119
|
+
continue;
|
|
120
|
+
}
|
|
121
|
+
form.append(key, String(value));
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
return form;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
return JSON.stringify(body || {});
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
async #buildHeaders(extraHeaders, { bodyType = "json" } = {}) {
|
|
132
|
+
const headers = {
|
|
133
|
+
Accept: "application/json",
|
|
134
|
+
...this.profile.headers,
|
|
135
|
+
...extraHeaders,
|
|
136
|
+
};
|
|
137
|
+
const existingContentTypeKey = Object.keys(headers).find((key) => key.toLowerCase() === "content-type");
|
|
138
|
+
|
|
139
|
+
if (bodyType === "multipart") {
|
|
140
|
+
if (existingContentTypeKey) {
|
|
141
|
+
delete headers[existingContentTypeKey];
|
|
142
|
+
}
|
|
143
|
+
} else if (!existingContentTypeKey) {
|
|
144
|
+
headers["Content-Type"] = "application/json";
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
const auth = this.profile.auth || {};
|
|
148
|
+
if (auth.type === "apiKey") {
|
|
149
|
+
headers.Authorization = `Bearer ${auth.apiKey}`;
|
|
150
|
+
return headers;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
if (auth.type === "basic") {
|
|
154
|
+
headers.Authorization = `Basic ${Buffer.from(`${auth.username}:${auth.password}`).toString("base64")}`;
|
|
155
|
+
return headers;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
if (auth.type === "password") {
|
|
159
|
+
if (!auth.tokenEndpoint) {
|
|
160
|
+
headers.Authorization = `Basic ${Buffer.from(`${auth.username}:${auth.password}`).toString("base64")}`;
|
|
161
|
+
return headers;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
const token = await this.#getPasswordToken();
|
|
165
|
+
headers.Authorization = `Bearer ${token}`;
|
|
166
|
+
return headers;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
throw new ApiError(`Unsupported auth type: ${auth.type}`, { status: 400 });
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
async #getPasswordToken() {
|
|
173
|
+
if (this.#tokenState && this.#tokenState.expiresAt > Date.now() + 15_000) {
|
|
174
|
+
return this.#tokenState.token;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
const auth = this.profile.auth;
|
|
178
|
+
const endpoint = auth.tokenEndpoint;
|
|
179
|
+
const body = {
|
|
180
|
+
username: auth.username,
|
|
181
|
+
password: auth.password,
|
|
182
|
+
...(auth.tokenRequestBody || {}),
|
|
183
|
+
};
|
|
184
|
+
|
|
185
|
+
const controller = new AbortController();
|
|
186
|
+
const timeout = setTimeout(() => controller.abort(), this.timeoutMs);
|
|
187
|
+
let res;
|
|
188
|
+
try {
|
|
189
|
+
res = await fetch(endpoint, {
|
|
190
|
+
method: "POST",
|
|
191
|
+
headers: {
|
|
192
|
+
Accept: "application/json",
|
|
193
|
+
"Content-Type": "application/json",
|
|
194
|
+
},
|
|
195
|
+
body: JSON.stringify(body),
|
|
196
|
+
signal: controller.signal,
|
|
197
|
+
});
|
|
198
|
+
} catch (err) {
|
|
199
|
+
if (err.name === "AbortError") {
|
|
200
|
+
throw new ApiError(`Token request timeout after ${this.timeoutMs}ms`, {
|
|
201
|
+
status: 408,
|
|
202
|
+
endpoint,
|
|
203
|
+
});
|
|
204
|
+
}
|
|
205
|
+
throw new ApiError(`Token request failed: ${err.message}`, {
|
|
206
|
+
status: 503,
|
|
207
|
+
endpoint,
|
|
208
|
+
});
|
|
209
|
+
} finally {
|
|
210
|
+
clearTimeout(timeout);
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
const payload = await res.json().catch(() => ({}));
|
|
214
|
+
if (!res.ok) {
|
|
215
|
+
throw new ApiError(payload?.message || payload?.error || "Failed token exchange", {
|
|
216
|
+
status: res.status,
|
|
217
|
+
body: payload,
|
|
218
|
+
});
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
const token = payload?.[auth.tokenField || "access_token"];
|
|
222
|
+
if (!token) {
|
|
223
|
+
throw new ApiError(`Token field not found: ${auth.tokenField || "access_token"}`, {
|
|
224
|
+
status: 500,
|
|
225
|
+
body: payload,
|
|
226
|
+
});
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
const expiresIn = Number(payload?.expires_in || 3600);
|
|
230
|
+
this.#tokenState = {
|
|
231
|
+
token,
|
|
232
|
+
expiresAt: Date.now() + Math.max(30, expiresIn) * 1000,
|
|
233
|
+
};
|
|
234
|
+
|
|
235
|
+
return token;
|
|
236
|
+
}
|
|
237
|
+
}
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
import fs from "node:fs/promises";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { defaultTmpDir } from "./config-store.js";
|
|
4
|
+
import { sanitizeFileToken } from "./utils.js";
|
|
5
|
+
|
|
6
|
+
export class ResultStore {
|
|
7
|
+
constructor(options = {}) {
|
|
8
|
+
this.tmpDir = options.tmpDir || defaultTmpDir();
|
|
9
|
+
this.mode = options.mode || "auto";
|
|
10
|
+
this.inlineMaxBytes = Number(options.inlineMaxBytes || 12_000);
|
|
11
|
+
this.pretty = !!options.pretty;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
async emit(value, opts = {}) {
|
|
15
|
+
const pretty = opts.pretty ?? this.pretty;
|
|
16
|
+
const serialized = JSON.stringify(value, null, pretty ? 2 : 0);
|
|
17
|
+
const bytes = Buffer.byteLength(serialized);
|
|
18
|
+
const mode = opts.mode || this.mode;
|
|
19
|
+
const shouldStore =
|
|
20
|
+
mode === "file" || (mode === "auto" && bytes > this.inlineMaxBytes);
|
|
21
|
+
|
|
22
|
+
if (!shouldStore) {
|
|
23
|
+
process.stdout.write(`${serialized}\n`);
|
|
24
|
+
return { stored: false, bytes };
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const file = await this.write(value, {
|
|
28
|
+
label: opts.label,
|
|
29
|
+
ext: opts.ext,
|
|
30
|
+
pretty,
|
|
31
|
+
});
|
|
32
|
+
const preview = this.preview(value);
|
|
33
|
+
|
|
34
|
+
const envelope = {
|
|
35
|
+
ok: true,
|
|
36
|
+
stored: true,
|
|
37
|
+
file,
|
|
38
|
+
bytes,
|
|
39
|
+
preview,
|
|
40
|
+
hint: `Use shell tools to inspect file, e.g. jq '.' ${JSON.stringify(file)} | head`,
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
process.stdout.write(`${JSON.stringify(envelope, null, pretty ? 2 : 0)}\n`);
|
|
44
|
+
return { stored: true, file, bytes };
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
async write(value, opts = {}) {
|
|
48
|
+
await fs.mkdir(this.tmpDir, { recursive: true });
|
|
49
|
+
const ts = new Date().toISOString().replace(/[:.]/g, "-");
|
|
50
|
+
const label = sanitizeFileToken(opts.label || "result");
|
|
51
|
+
const ext = opts.ext || "json";
|
|
52
|
+
const name = `${ts}-${label}.${ext}`;
|
|
53
|
+
const file = path.join(this.tmpDir, name);
|
|
54
|
+
const payload = JSON.stringify(value, null, opts.pretty ? 2 : 0);
|
|
55
|
+
await fs.writeFile(file, `${payload}\n`, "utf8");
|
|
56
|
+
return file;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
async list() {
|
|
60
|
+
await fs.mkdir(this.tmpDir, { recursive: true });
|
|
61
|
+
const entries = await fs.readdir(this.tmpDir, { withFileTypes: true });
|
|
62
|
+
const files = [];
|
|
63
|
+
|
|
64
|
+
for (const entry of entries) {
|
|
65
|
+
if (!entry.isFile()) {
|
|
66
|
+
continue;
|
|
67
|
+
}
|
|
68
|
+
const fullPath = path.join(this.tmpDir, entry.name);
|
|
69
|
+
const stat = await fs.stat(fullPath);
|
|
70
|
+
files.push({
|
|
71
|
+
name: entry.name,
|
|
72
|
+
file: fullPath,
|
|
73
|
+
bytes: stat.size,
|
|
74
|
+
modifiedAt: stat.mtime.toISOString(),
|
|
75
|
+
});
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
files.sort((a, b) => (a.modifiedAt < b.modifiedAt ? 1 : -1));
|
|
79
|
+
return files;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
async read(filePath) {
|
|
83
|
+
const full = this.resolve(filePath);
|
|
84
|
+
const content = await fs.readFile(full, "utf8");
|
|
85
|
+
return { file: full, content };
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
async remove(filePath) {
|
|
89
|
+
const full = this.resolve(filePath);
|
|
90
|
+
await fs.rm(full, { force: true });
|
|
91
|
+
return { file: full, removed: true };
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
async gc(maxAgeHours = 24) {
|
|
95
|
+
const now = Date.now();
|
|
96
|
+
const cutoff = now - maxAgeHours * 3600 * 1000;
|
|
97
|
+
const files = await this.list();
|
|
98
|
+
const removed = [];
|
|
99
|
+
|
|
100
|
+
for (const item of files) {
|
|
101
|
+
if (Date.parse(item.modifiedAt) > cutoff) {
|
|
102
|
+
continue;
|
|
103
|
+
}
|
|
104
|
+
await fs.rm(item.file, { force: true });
|
|
105
|
+
removed.push(item.file);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
return {
|
|
109
|
+
removed,
|
|
110
|
+
removedCount: removed.length,
|
|
111
|
+
keptCount: files.length - removed.length,
|
|
112
|
+
};
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
resolve(filePath) {
|
|
116
|
+
if (!filePath) {
|
|
117
|
+
throw new Error("file path is required");
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
const root = path.resolve(this.tmpDir);
|
|
121
|
+
const target = path.isAbsolute(filePath) ? path.resolve(filePath) : path.resolve(root, filePath);
|
|
122
|
+
const relative = path.relative(root, target);
|
|
123
|
+
const outsideRoot = relative.startsWith("..") || path.isAbsolute(relative);
|
|
124
|
+
|
|
125
|
+
if (outsideRoot) {
|
|
126
|
+
throw new Error(`Refusing to access path outside tmp dir: ${target}`);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
return target;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
preview(value, opts = {}) {
|
|
133
|
+
const maxDepth = Math.max(0, Number(opts.maxDepth ?? 2));
|
|
134
|
+
const maxArrayItems = Math.max(1, Number(opts.maxArrayItems ?? 3));
|
|
135
|
+
const maxObjectKeys = Math.max(1, Number(opts.maxObjectKeys ?? 12));
|
|
136
|
+
const maxString = Math.max(16, Number(opts.maxString ?? 160));
|
|
137
|
+
|
|
138
|
+
const walk = (input, depth) => {
|
|
139
|
+
if (input === null || input === undefined) {
|
|
140
|
+
return input;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
if (typeof input === "string") {
|
|
144
|
+
return input.length > maxString ? `${input.slice(0, maxString)}...` : input;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
if (typeof input !== "object") {
|
|
148
|
+
return input;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
if (Array.isArray(input)) {
|
|
152
|
+
if (depth >= maxDepth) {
|
|
153
|
+
return { type: "array", count: input.length };
|
|
154
|
+
}
|
|
155
|
+
const items = input.slice(0, maxArrayItems).map((item) => walk(item, depth + 1));
|
|
156
|
+
if (input.length > maxArrayItems) {
|
|
157
|
+
items.push({ truncatedItems: input.length - maxArrayItems });
|
|
158
|
+
}
|
|
159
|
+
return items;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
const keys = Object.keys(input);
|
|
163
|
+
if (depth >= maxDepth) {
|
|
164
|
+
return {
|
|
165
|
+
type: "object",
|
|
166
|
+
keyCount: keys.length,
|
|
167
|
+
keys: keys.slice(0, maxObjectKeys),
|
|
168
|
+
};
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
const out = {};
|
|
172
|
+
for (const key of keys.slice(0, maxObjectKeys)) {
|
|
173
|
+
out[key] = walk(input[key], depth + 1);
|
|
174
|
+
}
|
|
175
|
+
if (keys.length > maxObjectKeys) {
|
|
176
|
+
out.truncatedKeys = keys.length - maxObjectKeys;
|
|
177
|
+
}
|
|
178
|
+
return out;
|
|
179
|
+
};
|
|
180
|
+
|
|
181
|
+
return walk(value, 0);
|
|
182
|
+
}
|
|
183
|
+
}
|