larkit 0.0.0-stage → 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/AGENT_SKILL.md +12 -0
- package/README.md +765 -2
- package/bin/larkit-mcp.js +8 -0
- package/bin/larkit.js +532 -0
- package/larkit.config.example.json +12 -0
- package/package.json +65 -4
- package/scripts/.tmp-npm-webauth.mjs +49 -0
- package/scripts/build-index.mjs +66 -0
- package/scripts/check-syntax.mjs +28 -0
- package/scripts/login-worker.mjs +50 -0
- package/scripts/publish-pack.mjs +277 -0
- package/scripts/verify-bootstrap.mjs +238 -0
- package/scripts/verify-console.mjs +60 -0
- package/scripts/verify-index.mjs +59 -0
- package/scripts/verify-mcp.mjs +215 -0
- package/skills/larkit/SKILL.md +120 -0
- package/skills/larkit/references/console.md +86 -0
- package/skills/larkit/references/playbooks.md +177 -0
- package/skills/larkit/references/tools.md +115 -0
- package/skills/larkit/references/truth-model.md +117 -0
- package/src/auth/login.js +171 -0
- package/src/auth/service.js +213 -0
- package/src/auth/store.js +213 -0
- package/src/bootstrap/channel.js +201 -0
- package/src/bootstrap/cli-install.js +184 -0
- package/src/bootstrap/download.js +182 -0
- package/src/bootstrap/init.js +672 -0
- package/src/bootstrap/pack-cli.js +97 -0
- package/src/bootstrap/pack.js +541 -0
- package/src/bootstrap/report.js +62 -0
- package/src/bootstrap/skills.js +223 -0
- package/src/bootstrap/state.js +66 -0
- package/src/bootstrap/tar.js +417 -0
- package/src/console/browser.js +338 -0
- package/src/console/client.js +827 -0
- package/src/console/dispatch.js +101 -0
- package/src/docs/client.js +284 -0
- package/src/indexer/anchors.js +366 -0
- package/src/indexer/db.js +449 -0
- package/src/indexer/extract-assets.js +285 -0
- package/src/indexer/extract-deb.js +56 -0
- package/src/indexer/format-pool.js +64 -0
- package/src/indexer/format-worker.js +132 -0
- package/src/indexer/format.js +239 -0
- package/src/indexer/protocol.js +123 -0
- package/src/mcp/server.js +433 -0
- package/src/probe/client.js +225 -0
- package/src/probe/openapi.js +194 -0
- package/src/protocol/catalog.js +146 -0
- package/src/protocol/data/README.md +15 -0
- package/src/protocol/data/bundle.json +1 -0
- package/src/protocol/data/commands.json +12896 -0
- package/src/protocol/data/constants.json +8 -0
- package/src/protocol/data/reference/PROTOCOL.md +66 -0
- package/src/protocol/data/reference/endpoints.json +46 -0
- package/src/protocol/data/schema.json.gz +0 -0
- package/src/protocol/descriptor.js +358 -0
- package/src/protocol/schema.js +235 -0
- package/src/protocol/transport.js +387 -0
- package/src/shared/browser.js +70 -0
- package/src/shared/config.js +90 -0
- package/src/shared/paths.js +151 -0
- package/src/shared/quiet.js +12 -0
- package/src/shared/util.js +124 -0
- package/src/truth/fts.js +24 -0
- package/src/truth/search.js +554 -0
- package/src/truth/verified.js +107 -0
- package/verified_truths/README.md +28 -0
- package/verified_truths/card-json-2.0-official.md +55 -0
- package/verified_truths/client-ws-gateway-boundaries.md +33 -0
- package/verified_truths/feishu-developer-console-apis.md +104 -0
- package/workers/truth-api/src/index.js +180 -0
- package/workers/truth-api/wrangler.toml +31 -0
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import os from "node:os";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { SKILL_SOURCE_DIR } from "../shared/paths.js";
|
|
5
|
+
import {
|
|
6
|
+
ensureDir,
|
|
7
|
+
nowIso,
|
|
8
|
+
readJson,
|
|
9
|
+
sha256,
|
|
10
|
+
walkFiles,
|
|
11
|
+
writeJson,
|
|
12
|
+
} from "../shared/util.js";
|
|
13
|
+
|
|
14
|
+
const MANIFEST_NAME = ".larkit-skill.json";
|
|
15
|
+
|
|
16
|
+
export function skillTargetCandidates(cwd = process.cwd()) {
|
|
17
|
+
return [
|
|
18
|
+
{ name: "agents", dir: path.join(os.homedir(), ".agents", "skills", "larkit") },
|
|
19
|
+
{ name: "claude", dir: path.join(os.homedir(), ".claude", "skills", "larkit") },
|
|
20
|
+
{ name: "project", dir: path.join(cwd, ".agents", "skills", "larkit") },
|
|
21
|
+
];
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Default policy: install into every agent skills directory that already
|
|
26
|
+
* exists; if none exists, create the neutral `~/.agents/skills/larkit`.
|
|
27
|
+
*/
|
|
28
|
+
export function resolveSkillTargets({
|
|
29
|
+
targets = null,
|
|
30
|
+
cwd = process.cwd(),
|
|
31
|
+
project = false,
|
|
32
|
+
} = {}) {
|
|
33
|
+
const candidates = skillTargetCandidates(cwd);
|
|
34
|
+
if (targets?.length) {
|
|
35
|
+
const result = [];
|
|
36
|
+
for (const raw of targets) {
|
|
37
|
+
const value = String(raw).trim();
|
|
38
|
+
if (!value) continue;
|
|
39
|
+
if (value === "all") {
|
|
40
|
+
result.push(...candidates);
|
|
41
|
+
continue;
|
|
42
|
+
}
|
|
43
|
+
const named = candidates.find((candidate) => candidate.name === value);
|
|
44
|
+
if (named) {
|
|
45
|
+
result.push(named);
|
|
46
|
+
continue;
|
|
47
|
+
}
|
|
48
|
+
result.push({
|
|
49
|
+
name: path.basename(path.dirname(path.dirname(value))) || "custom",
|
|
50
|
+
dir: path.resolve(value),
|
|
51
|
+
});
|
|
52
|
+
}
|
|
53
|
+
return dedupeTargets(result);
|
|
54
|
+
}
|
|
55
|
+
if (project) {
|
|
56
|
+
return candidates.filter((candidate) => candidate.name === "project");
|
|
57
|
+
}
|
|
58
|
+
const existing = candidates.filter(
|
|
59
|
+
(candidate) =>
|
|
60
|
+
candidate.name !== "project" && fs.existsSync(path.dirname(candidate.dir)),
|
|
61
|
+
);
|
|
62
|
+
return existing.length ? existing : candidates.filter((c) => c.name === "agents");
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function dedupeTargets(targets) {
|
|
66
|
+
const seen = new Set();
|
|
67
|
+
return targets.filter((target) => {
|
|
68
|
+
if (seen.has(target.dir)) return false;
|
|
69
|
+
seen.add(target.dir);
|
|
70
|
+
return true;
|
|
71
|
+
});
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function sourceFiles() {
|
|
75
|
+
if (!fs.existsSync(SKILL_SOURCE_DIR)) {
|
|
76
|
+
throw new Error(`skill source missing: ${SKILL_SOURCE_DIR}`);
|
|
77
|
+
}
|
|
78
|
+
return walkFiles(SKILL_SOURCE_DIR, { skipDirs: new Set() }).map((entry) => ({
|
|
79
|
+
abs: entry.path,
|
|
80
|
+
rel: path.relative(SKILL_SOURCE_DIR, entry.path).split(path.sep).join("/"),
|
|
81
|
+
sha256: sha256(fs.readFileSync(entry.path)),
|
|
82
|
+
}));
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function targetFile(dir, rel) {
|
|
86
|
+
const root = path.resolve(dir);
|
|
87
|
+
const resolved = path.resolve(root, rel);
|
|
88
|
+
if (resolved !== root && !resolved.startsWith(root + path.sep)) {
|
|
89
|
+
throw new Error(`skill manifest path escapes its directory: ${rel}`);
|
|
90
|
+
}
|
|
91
|
+
return resolved;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function pruneEmpty(dir, stopAt) {
|
|
95
|
+
let current = path.resolve(dir);
|
|
96
|
+
const stop = path.resolve(stopAt);
|
|
97
|
+
while (current.startsWith(stop + path.sep)) {
|
|
98
|
+
try {
|
|
99
|
+
if (fs.readdirSync(current).length > 0) break;
|
|
100
|
+
fs.rmdirSync(current);
|
|
101
|
+
} catch {
|
|
102
|
+
break;
|
|
103
|
+
}
|
|
104
|
+
current = path.dirname(current);
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Copy the bundled skill into one or more agent skill directories. A
|
|
110
|
+
* `.larkit-skill.json` manifest makes updates safe: only files we own are
|
|
111
|
+
* overwritten or deleted, and an unmanaged pre-existing directory is left
|
|
112
|
+
* untouched unless `force` is set (then it is backed up, not deleted).
|
|
113
|
+
*/
|
|
114
|
+
export function installSkill({
|
|
115
|
+
targets,
|
|
116
|
+
version,
|
|
117
|
+
force = false,
|
|
118
|
+
dryRun = false,
|
|
119
|
+
} = {}) {
|
|
120
|
+
const files = sourceFiles();
|
|
121
|
+
const results = [];
|
|
122
|
+
for (const target of targets) {
|
|
123
|
+
const dir = target.dir;
|
|
124
|
+
const manifestFile = path.join(dir, MANIFEST_NAME);
|
|
125
|
+
const existing = readJson(manifestFile, null);
|
|
126
|
+
const exists = fs.existsSync(dir);
|
|
127
|
+
const populated = exists && fs.readdirSync(dir).length > 0;
|
|
128
|
+
if (populated && !existing && !force) {
|
|
129
|
+
results.push({
|
|
130
|
+
target: dir,
|
|
131
|
+
name: target.name,
|
|
132
|
+
status: "skipped",
|
|
133
|
+
reason: "existing unmanaged directory (use --force to back up and replace)",
|
|
134
|
+
});
|
|
135
|
+
continue;
|
|
136
|
+
}
|
|
137
|
+
let backup = null;
|
|
138
|
+
if (populated && !existing && force) {
|
|
139
|
+
backup = `${dir}.bak-${Date.now()}`;
|
|
140
|
+
if (!dryRun) fs.renameSync(dir, backup);
|
|
141
|
+
}
|
|
142
|
+
if (dryRun) {
|
|
143
|
+
results.push({
|
|
144
|
+
target: dir,
|
|
145
|
+
name: target.name,
|
|
146
|
+
status: existing ? "would-update" : "would-install",
|
|
147
|
+
files: files.length,
|
|
148
|
+
backup,
|
|
149
|
+
});
|
|
150
|
+
continue;
|
|
151
|
+
}
|
|
152
|
+
ensureDir(dir);
|
|
153
|
+
for (const file of files) {
|
|
154
|
+
const dest = path.join(dir, file.rel);
|
|
155
|
+
ensureDir(path.dirname(dest));
|
|
156
|
+
if (path.resolve(file.abs) !== path.resolve(dest)) {
|
|
157
|
+
fs.copyFileSync(file.abs, dest);
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
if (existing?.files) {
|
|
161
|
+
for (const rel of Object.keys(existing.files)) {
|
|
162
|
+
if (files.some((file) => file.rel === rel)) continue;
|
|
163
|
+
const stale = targetFile(dir, rel);
|
|
164
|
+
fs.rmSync(stale, { force: true });
|
|
165
|
+
pruneEmpty(path.dirname(stale), dir);
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
writeJson(manifestFile, {
|
|
169
|
+
skill: "larkit",
|
|
170
|
+
version,
|
|
171
|
+
installedAt: nowIso(),
|
|
172
|
+
files: Object.fromEntries(files.map((file) => [file.rel, file.sha256])),
|
|
173
|
+
});
|
|
174
|
+
results.push({
|
|
175
|
+
target: dir,
|
|
176
|
+
name: target.name,
|
|
177
|
+
status: existing ? "updated" : "installed",
|
|
178
|
+
files: files.length,
|
|
179
|
+
backup,
|
|
180
|
+
});
|
|
181
|
+
}
|
|
182
|
+
return results;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
export function listInstalledSkills({ cwd = process.cwd() } = {}) {
|
|
186
|
+
const candidates = skillTargetCandidates(cwd);
|
|
187
|
+
return candidates
|
|
188
|
+
.filter((candidate) => fs.existsSync(path.join(candidate.dir, MANIFEST_NAME)))
|
|
189
|
+
.map((candidate) => {
|
|
190
|
+
const manifest = readJson(path.join(candidate.dir, MANIFEST_NAME), {});
|
|
191
|
+
return {
|
|
192
|
+
name: candidate.name,
|
|
193
|
+
target: candidate.dir,
|
|
194
|
+
version: manifest.version || null,
|
|
195
|
+
files: manifest.files ? Object.keys(manifest.files).length : 0,
|
|
196
|
+
installed_at: manifest.installedAt || null,
|
|
197
|
+
};
|
|
198
|
+
});
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
export function removeSkill({ targets, dryRun = false } = {}) {
|
|
202
|
+
const results = [];
|
|
203
|
+
for (const target of targets) {
|
|
204
|
+
const dir = target.dir;
|
|
205
|
+
const manifestFile = path.join(dir, MANIFEST_NAME);
|
|
206
|
+
const manifest = readJson(manifestFile, null);
|
|
207
|
+
if (!manifest) {
|
|
208
|
+
results.push({ target: dir, status: "skipped", reason: "not installed by larkit" });
|
|
209
|
+
continue;
|
|
210
|
+
}
|
|
211
|
+
if (!dryRun) {
|
|
212
|
+
for (const rel of Object.keys(manifest.files || {})) {
|
|
213
|
+
const stale = targetFile(dir, rel);
|
|
214
|
+
fs.rmSync(stale, { force: true });
|
|
215
|
+
pruneEmpty(path.dirname(stale), dir);
|
|
216
|
+
}
|
|
217
|
+
fs.rmSync(manifestFile, { force: true });
|
|
218
|
+
pruneEmpty(dir, dir);
|
|
219
|
+
}
|
|
220
|
+
results.push({ target: dir, status: dryRun ? "would-remove" : "removed" });
|
|
221
|
+
}
|
|
222
|
+
return results;
|
|
223
|
+
}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import {
|
|
3
|
+
DATA_DIR,
|
|
4
|
+
FORMATTED_DIR,
|
|
5
|
+
INSTALL_STATE_FILE,
|
|
6
|
+
LARKIT_HOME,
|
|
7
|
+
TRUTH_DB,
|
|
8
|
+
} from "../shared/paths.js";
|
|
9
|
+
import { ensureDir, nowIso, readJson, writeJson } from "../shared/util.js";
|
|
10
|
+
|
|
11
|
+
export const STATE_SCHEMA_VERSION = 1;
|
|
12
|
+
|
|
13
|
+
export function initialState() {
|
|
14
|
+
return {
|
|
15
|
+
schemaVersion: STATE_SCHEMA_VERSION,
|
|
16
|
+
larkitVersion: null,
|
|
17
|
+
installedAt: null,
|
|
18
|
+
updatedAt: null,
|
|
19
|
+
home: LARKIT_HOME,
|
|
20
|
+
dataDir: DATA_DIR,
|
|
21
|
+
steps: {},
|
|
22
|
+
};
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function readState() {
|
|
26
|
+
const state = readJson(INSTALL_STATE_FILE, null);
|
|
27
|
+
if (!state || state.schemaVersion !== STATE_SCHEMA_VERSION) return null;
|
|
28
|
+
return state;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function writeState(state) {
|
|
32
|
+
ensureDir(LARKIT_HOME);
|
|
33
|
+
const next = {
|
|
34
|
+
...initialState(),
|
|
35
|
+
...state,
|
|
36
|
+
schemaVersion: STATE_SCHEMA_VERSION,
|
|
37
|
+
home: LARKIT_HOME,
|
|
38
|
+
dataDir: DATA_DIR,
|
|
39
|
+
updatedAt: nowIso(),
|
|
40
|
+
};
|
|
41
|
+
writeJson(INSTALL_STATE_FILE, next);
|
|
42
|
+
return next;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function updateState(mutator) {
|
|
46
|
+
const state = readState() || initialState();
|
|
47
|
+
const next = mutator(state) || state;
|
|
48
|
+
return writeState(next);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function isInitialized() {
|
|
52
|
+
const state = readState();
|
|
53
|
+
return Boolean(state?.steps?.truth?.status === "ready" && fs.existsSync(TRUTH_DB));
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export function hasCodeTier() {
|
|
57
|
+
return fs.existsSync(FORMATTED_DIR);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export function setStep(name, value) {
|
|
61
|
+
return updateState((state) => ({
|
|
62
|
+
...state,
|
|
63
|
+
installedAt: state.installedAt || nowIso(),
|
|
64
|
+
steps: { ...state.steps, [name]: { ...value, at: nowIso() } },
|
|
65
|
+
}));
|
|
66
|
+
}
|
|
@@ -0,0 +1,417 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import fsp from "node:fs/promises";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import zlib from "node:zlib";
|
|
5
|
+
import { Readable } from "node:stream";
|
|
6
|
+
import { pipeline } from "node:stream/promises";
|
|
7
|
+
|
|
8
|
+
const BLOCK = 512;
|
|
9
|
+
const CHUNK = 1024 * 1024;
|
|
10
|
+
|
|
11
|
+
function padding(size) {
|
|
12
|
+
return (BLOCK - (size % BLOCK)) % BLOCK;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function writeAscii(buf, offset, value, length) {
|
|
16
|
+
const text = String(value);
|
|
17
|
+
const bytes = Buffer.from(text, "utf8");
|
|
18
|
+
bytes.copy(buf, offset, 0, Math.min(bytes.length, length));
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function octal(value, width) {
|
|
22
|
+
const text = Math.max(0, Math.trunc(value)).toString(8);
|
|
23
|
+
return `${text.padStart(width - 1, "0")}\0`;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function splitUstarPath(relPath) {
|
|
27
|
+
if (Buffer.byteLength(relPath) <= 100) return { name: relPath, prefix: "" };
|
|
28
|
+
for (let i = relPath.length - 1; i > 0; i--) {
|
|
29
|
+
if (relPath[i] !== "/") continue;
|
|
30
|
+
const prefix = relPath.slice(0, i);
|
|
31
|
+
const name = relPath.slice(i + 1);
|
|
32
|
+
if (!name) continue;
|
|
33
|
+
if (Buffer.byteLength(prefix) <= 155 && Buffer.byteLength(name) <= 100) {
|
|
34
|
+
return { name, prefix };
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
return null;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function header({
|
|
41
|
+
name,
|
|
42
|
+
prefix = "",
|
|
43
|
+
mode = 0o644,
|
|
44
|
+
size = 0,
|
|
45
|
+
mtime = Math.floor(Date.now() / 1000),
|
|
46
|
+
typeflag = "0",
|
|
47
|
+
}) {
|
|
48
|
+
const buf = Buffer.alloc(BLOCK, 0);
|
|
49
|
+
writeAscii(buf, 0, name, 100);
|
|
50
|
+
writeAscii(buf, 100, octal(mode & 0o7777, 8), 8);
|
|
51
|
+
writeAscii(buf, 108, octal(0, 8), 8);
|
|
52
|
+
writeAscii(buf, 116, octal(0, 8), 8);
|
|
53
|
+
writeAscii(buf, 124, octal(size, 12), 12);
|
|
54
|
+
writeAscii(buf, 136, octal(mtime, 12), 12);
|
|
55
|
+
buf.fill(0x20, 148, 156);
|
|
56
|
+
writeAscii(buf, 156, typeflag, 1);
|
|
57
|
+
writeAscii(buf, 257, "ustar\0", 6);
|
|
58
|
+
writeAscii(buf, 263, "00", 2);
|
|
59
|
+
writeAscii(buf, 329, octal(0, 8), 8);
|
|
60
|
+
writeAscii(buf, 337, octal(0, 8), 8);
|
|
61
|
+
writeAscii(buf, 345, prefix, 155);
|
|
62
|
+
let sum = 0;
|
|
63
|
+
for (const byte of buf) sum += byte;
|
|
64
|
+
writeAscii(buf, 148, `${sum.toString(8).padStart(6, "0")}\0 `, 8);
|
|
65
|
+
return buf;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function paxRecord(key, value) {
|
|
69
|
+
const body = ` ${key}=${value}\n`;
|
|
70
|
+
let length = Buffer.byteLength(body) + 1;
|
|
71
|
+
for (;;) {
|
|
72
|
+
const candidate = `${length}${body}`;
|
|
73
|
+
const bytes = Buffer.byteLength(candidate);
|
|
74
|
+
if (bytes === length) return candidate;
|
|
75
|
+
length = bytes;
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function entryHeaders(tarPath, size, stat) {
|
|
80
|
+
const chunks = [];
|
|
81
|
+
const split = splitUstarPath(tarPath);
|
|
82
|
+
if (split) {
|
|
83
|
+
chunks.push(
|
|
84
|
+
header({
|
|
85
|
+
name: split.name,
|
|
86
|
+
prefix: split.prefix,
|
|
87
|
+
size,
|
|
88
|
+
mode: stat ? stat.mode : 0o644,
|
|
89
|
+
mtime: stat ? Math.floor(stat.mtimeMs / 1000) : undefined,
|
|
90
|
+
}),
|
|
91
|
+
);
|
|
92
|
+
return chunks;
|
|
93
|
+
}
|
|
94
|
+
const pax = paxRecord("path", tarPath);
|
|
95
|
+
chunks.push(
|
|
96
|
+
header({
|
|
97
|
+
name: `PaxHeader/${path.posix.basename(tarPath).slice(-90)}`,
|
|
98
|
+
size: Buffer.byteLength(pax),
|
|
99
|
+
mode: 0o644,
|
|
100
|
+
typeflag: "x",
|
|
101
|
+
}),
|
|
102
|
+
);
|
|
103
|
+
chunks.push(Buffer.from(pax, "utf8"), Buffer.alloc(padding(Buffer.byteLength(pax))));
|
|
104
|
+
chunks.push(
|
|
105
|
+
header({
|
|
106
|
+
name: path.posix.basename(tarPath).slice(-99),
|
|
107
|
+
size,
|
|
108
|
+
mode: stat ? stat.mode : 0o644,
|
|
109
|
+
mtime: stat ? Math.floor(stat.mtimeMs / 1000) : undefined,
|
|
110
|
+
}),
|
|
111
|
+
);
|
|
112
|
+
return chunks;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Create a .tar.gz from explicit entries. Entries are
|
|
117
|
+
* `{ tarPath, file }` or `{ tarPath, content }`; order is preserved.
|
|
118
|
+
*/
|
|
119
|
+
export async function createTarGz(outFile, { entries = [], onProgress } = {}) {
|
|
120
|
+
if (!entries.length) throw new Error("createTarGz: no entries");
|
|
121
|
+
fs.mkdirSync(path.dirname(outFile), { recursive: true });
|
|
122
|
+
let written = 0;
|
|
123
|
+
const report = () => onProgress?.(written);
|
|
124
|
+
|
|
125
|
+
async function* generate() {
|
|
126
|
+
for (const entry of entries) {
|
|
127
|
+
const tarPath = entry.tarPath.split(path.sep).join("/").replace(/^\/+/, "");
|
|
128
|
+
if (!tarPath) continue;
|
|
129
|
+
if (entry.content !== undefined) {
|
|
130
|
+
const buf = Buffer.isBuffer(entry.content)
|
|
131
|
+
? entry.content
|
|
132
|
+
: Buffer.from(entry.content);
|
|
133
|
+
for (const chunk of entryHeaders(tarPath, buf.length, null)) yield chunk;
|
|
134
|
+
if (buf.length) yield buf;
|
|
135
|
+
const pad = padding(buf.length);
|
|
136
|
+
if (pad) yield Buffer.alloc(pad);
|
|
137
|
+
written += buf.length;
|
|
138
|
+
report();
|
|
139
|
+
continue;
|
|
140
|
+
}
|
|
141
|
+
const stat = await fsp.stat(entry.file);
|
|
142
|
+
if (!stat.isFile()) continue;
|
|
143
|
+
for (const chunk of entryHeaders(tarPath, stat.size, stat)) yield chunk;
|
|
144
|
+
let sent = 0;
|
|
145
|
+
const stream = fs.createReadStream(entry.file, {
|
|
146
|
+
highWaterMark: CHUNK,
|
|
147
|
+
});
|
|
148
|
+
for await (const chunk of stream) {
|
|
149
|
+
sent += chunk.length;
|
|
150
|
+
written += chunk.length;
|
|
151
|
+
report();
|
|
152
|
+
yield chunk;
|
|
153
|
+
}
|
|
154
|
+
if (sent !== stat.size) {
|
|
155
|
+
throw new Error(
|
|
156
|
+
`createTarGz: ${entry.file} changed while reading (${sent}/${stat.size})`,
|
|
157
|
+
);
|
|
158
|
+
}
|
|
159
|
+
const pad = padding(stat.size);
|
|
160
|
+
if (pad) yield Buffer.alloc(pad);
|
|
161
|
+
}
|
|
162
|
+
yield Buffer.alloc(BLOCK * 2);
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
await pipeline(
|
|
166
|
+
Readable.from(generate()),
|
|
167
|
+
zlib.createGzip({ level: 6 }),
|
|
168
|
+
fs.createWriteStream(outFile),
|
|
169
|
+
);
|
|
170
|
+
return outFile;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
class ChunkReader {
|
|
174
|
+
constructor(iterable) {
|
|
175
|
+
this.iterator = iterable[Symbol.asyncIterator]();
|
|
176
|
+
this.buffer = Buffer.alloc(0);
|
|
177
|
+
this.done = false;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
async pull(size) {
|
|
181
|
+
while (this.buffer.length < size && !this.done) {
|
|
182
|
+
const { value, done } = await this.iterator.next();
|
|
183
|
+
if (done) {
|
|
184
|
+
this.done = true;
|
|
185
|
+
break;
|
|
186
|
+
}
|
|
187
|
+
const chunk = Buffer.isBuffer(value) ? value : Buffer.from(value);
|
|
188
|
+
this.buffer = this.buffer.length
|
|
189
|
+
? Buffer.concat([this.buffer, chunk])
|
|
190
|
+
: chunk;
|
|
191
|
+
}
|
|
192
|
+
return this.buffer.length >= size;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
async readExact(size) {
|
|
196
|
+
if (!(await this.pull(size))) {
|
|
197
|
+
throw new Error("unexpected end of tar stream");
|
|
198
|
+
}
|
|
199
|
+
const out = this.buffer.subarray(0, size);
|
|
200
|
+
this.buffer = this.buffer.subarray(size);
|
|
201
|
+
return out;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
async readUpTo(size) {
|
|
205
|
+
if (this.buffer.length === 0) await this.pull(1);
|
|
206
|
+
const take = Math.min(size, this.buffer.length);
|
|
207
|
+
if (!take) return null;
|
|
208
|
+
const out = this.buffer.subarray(0, take);
|
|
209
|
+
this.buffer = this.buffer.subarray(take);
|
|
210
|
+
return out;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
async skip(size) {
|
|
214
|
+
let left = size;
|
|
215
|
+
while (left > 0) {
|
|
216
|
+
const chunk = await this.readUpTo(left);
|
|
217
|
+
if (!chunk) throw new Error("unexpected end of tar stream");
|
|
218
|
+
left -= chunk.length;
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
function readString(buf, offset, length) {
|
|
224
|
+
const slice = buf.subarray(offset, offset + length);
|
|
225
|
+
const nul = slice.indexOf(0);
|
|
226
|
+
return slice.subarray(0, nul === -1 ? slice.length : nul).toString("utf8");
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
function parseOctal(buf, offset, length) {
|
|
230
|
+
const text = readString(buf, offset, length).trim();
|
|
231
|
+
return text ? Number.parseInt(text, 8) || 0 : 0;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
function parseHeader(buf) {
|
|
235
|
+
const name = readString(buf, 0, 100);
|
|
236
|
+
const prefix = readString(buf, 345, 155);
|
|
237
|
+
return {
|
|
238
|
+
name: prefix ? `${prefix}/${name}` : name,
|
|
239
|
+
mode: parseOctal(buf, 100, 8) & 0o777,
|
|
240
|
+
size: parseOctal(buf, 124, 12),
|
|
241
|
+
mtime: parseOctal(buf, 136, 12),
|
|
242
|
+
typeflag: readString(buf, 156, 1) || "0",
|
|
243
|
+
};
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
function parsePax(buf) {
|
|
247
|
+
const text = buf.toString("utf8");
|
|
248
|
+
const records = {};
|
|
249
|
+
let offset = 0;
|
|
250
|
+
while (offset < text.length) {
|
|
251
|
+
const space = text.indexOf(" ", offset);
|
|
252
|
+
if (space === -1) break;
|
|
253
|
+
const length = Number.parseInt(text.slice(offset, space), 10);
|
|
254
|
+
if (!Number.isFinite(length) || length <= 0) break;
|
|
255
|
+
const record = text.slice(space + 1, offset + length - 1);
|
|
256
|
+
const eq = record.indexOf("=");
|
|
257
|
+
if (eq > 0) records[record.slice(0, eq)] = record.slice(eq + 1);
|
|
258
|
+
offset += length;
|
|
259
|
+
}
|
|
260
|
+
return records;
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
function normalizeTarPath(raw) {
|
|
264
|
+
const clean = String(raw || "")
|
|
265
|
+
.replace(/\\/g, "/")
|
|
266
|
+
.replace(/^\.\/+/, "")
|
|
267
|
+
.replace(/\/+$/, "");
|
|
268
|
+
if (!clean || clean === ".") return "";
|
|
269
|
+
if (clean.startsWith("/") || /^[A-Za-z]:/.test(clean)) {
|
|
270
|
+
throw new Error(`tar entry uses an absolute path: ${raw}`);
|
|
271
|
+
}
|
|
272
|
+
if (clean.split("/").includes("..")) {
|
|
273
|
+
throw new Error(`tar entry escapes the destination: ${raw}`);
|
|
274
|
+
}
|
|
275
|
+
return clean.split("/").join(path.sep);
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
function ensureSafeDir(baseDir, dir) {
|
|
279
|
+
const base = path.resolve(baseDir);
|
|
280
|
+
const relative = path.relative(base, path.resolve(dir));
|
|
281
|
+
if (relative.startsWith("..") || path.isAbsolute(relative)) {
|
|
282
|
+
throw new Error(`tar entry escapes the destination: ${dir}`);
|
|
283
|
+
}
|
|
284
|
+
let current = base;
|
|
285
|
+
for (const segment of relative.split(path.sep).filter(Boolean)) {
|
|
286
|
+
current = path.join(current, segment);
|
|
287
|
+
let stat;
|
|
288
|
+
try {
|
|
289
|
+
stat = fs.lstatSync(current);
|
|
290
|
+
} catch (error) {
|
|
291
|
+
if (error.code !== "ENOENT") throw error;
|
|
292
|
+
fs.mkdirSync(current);
|
|
293
|
+
continue;
|
|
294
|
+
}
|
|
295
|
+
if (stat.isSymbolicLink()) {
|
|
296
|
+
throw new Error(`refusing to extract through symlink: ${current}`);
|
|
297
|
+
}
|
|
298
|
+
if (!stat.isDirectory()) {
|
|
299
|
+
throw new Error(`refusing to extract through non-directory: ${current}`);
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
/**
|
|
305
|
+
* Extract a .tar.gz stream. Symlinks/hardlinks are ignored; path traversal is
|
|
306
|
+
* rejected; existing directories are reused. `mapPath` receives POSIX paths.
|
|
307
|
+
*/
|
|
308
|
+
export async function extractTarGz(
|
|
309
|
+
archive,
|
|
310
|
+
destDir,
|
|
311
|
+
{ mapPath = (value) => value, onProgress, onWarning } = {},
|
|
312
|
+
) {
|
|
313
|
+
fs.mkdirSync(destDir, { recursive: true });
|
|
314
|
+
const source = fs.createReadStream(archive);
|
|
315
|
+
const gunzip = zlib.createGunzip();
|
|
316
|
+
source.on("error", (error) => gunzip.destroy(error));
|
|
317
|
+
source.pipe(gunzip);
|
|
318
|
+
const reader = new ChunkReader(gunzip);
|
|
319
|
+
let paxPath = null;
|
|
320
|
+
let longName = null;
|
|
321
|
+
let extractedBytes = 0;
|
|
322
|
+
let files = 0;
|
|
323
|
+
|
|
324
|
+
for (;;) {
|
|
325
|
+
let block;
|
|
326
|
+
try {
|
|
327
|
+
block = await reader.readExact(BLOCK);
|
|
328
|
+
} catch (error) {
|
|
329
|
+
if (/unexpected end of tar stream/.test(error.message)) break;
|
|
330
|
+
throw error;
|
|
331
|
+
}
|
|
332
|
+
if (block.every((byte) => byte === 0)) break;
|
|
333
|
+
const parsed = parseHeader(block);
|
|
334
|
+
let name = parsed.name;
|
|
335
|
+
if (longName !== null) {
|
|
336
|
+
name = longName;
|
|
337
|
+
longName = null;
|
|
338
|
+
}
|
|
339
|
+
if (paxPath !== null) {
|
|
340
|
+
name = paxPath;
|
|
341
|
+
paxPath = null;
|
|
342
|
+
}
|
|
343
|
+
const type = parsed.typeflag;
|
|
344
|
+
|
|
345
|
+
if (type === "x" || type === "g") {
|
|
346
|
+
const data = await reader.readExact(parsed.size);
|
|
347
|
+
const records = parsePax(data);
|
|
348
|
+
if (type === "g") {
|
|
349
|
+
if (records.path) paxPath = records.path;
|
|
350
|
+
} else if (records.path) {
|
|
351
|
+
paxPath = records.path;
|
|
352
|
+
}
|
|
353
|
+
await reader.skip(padding(parsed.size));
|
|
354
|
+
continue;
|
|
355
|
+
}
|
|
356
|
+
if (type === "L") {
|
|
357
|
+
const data = await reader.readExact(parsed.size);
|
|
358
|
+
longName = data.toString("utf8").replace(/\0+$/, "");
|
|
359
|
+
await reader.skip(padding(parsed.size));
|
|
360
|
+
continue;
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
const normalized = normalizeTarPath(mapPath(name));
|
|
364
|
+
if (normalized) {
|
|
365
|
+
if (type === "5") {
|
|
366
|
+
ensureSafeDir(destDir, path.join(destDir, normalized));
|
|
367
|
+
await reader.skip(parsed.size + padding(parsed.size));
|
|
368
|
+
} else if (
|
|
369
|
+
type === "0" ||
|
|
370
|
+
type === "7" ||
|
|
371
|
+
type === "\0" ||
|
|
372
|
+
type === ""
|
|
373
|
+
) {
|
|
374
|
+
const dest = path.join(destDir, normalized);
|
|
375
|
+
ensureSafeDir(destDir, path.dirname(dest));
|
|
376
|
+
try {
|
|
377
|
+
if (fs.lstatSync(dest).isSymbolicLink()) {
|
|
378
|
+
throw new Error(`refusing to overwrite symlink: ${dest}`);
|
|
379
|
+
}
|
|
380
|
+
} catch (error) {
|
|
381
|
+
if (error.code !== "ENOENT") throw error;
|
|
382
|
+
}
|
|
383
|
+
const fd = fs.openSync(dest, "w", parsed.mode || 0o644);
|
|
384
|
+
try {
|
|
385
|
+
let remaining = parsed.size;
|
|
386
|
+
while (remaining > 0) {
|
|
387
|
+
const chunk = await reader.readUpTo(Math.min(remaining, CHUNK));
|
|
388
|
+
if (!chunk) throw new Error("unexpected end of tar stream");
|
|
389
|
+
fs.writeSync(fd, chunk);
|
|
390
|
+
remaining -= chunk.length;
|
|
391
|
+
}
|
|
392
|
+
} finally {
|
|
393
|
+
fs.closeSync(fd);
|
|
394
|
+
}
|
|
395
|
+
if (parsed.mtime > 0) {
|
|
396
|
+
try {
|
|
397
|
+
fs.utimesSync(dest, new Date(), new Date(parsed.mtime * 1000));
|
|
398
|
+
} catch {
|
|
399
|
+
// mtime is cosmetic
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
extractedBytes += parsed.size;
|
|
403
|
+
files++;
|
|
404
|
+
onProgress?.(extractedBytes, files);
|
|
405
|
+
await reader.skip(padding(parsed.size));
|
|
406
|
+
} else if (type === "1" || type === "2") {
|
|
407
|
+
onWarning?.(`skipped link entry: ${normalized}`);
|
|
408
|
+
await reader.skip(parsed.size + padding(parsed.size));
|
|
409
|
+
} else {
|
|
410
|
+
await reader.skip(parsed.size + padding(parsed.size));
|
|
411
|
+
}
|
|
412
|
+
} else {
|
|
413
|
+
await reader.skip(parsed.size + padding(parsed.size));
|
|
414
|
+
}
|
|
415
|
+
}
|
|
416
|
+
return { bytes: extractedBytes, files };
|
|
417
|
+
}
|