@js-eyes/protocol 2.6.0 → 2.6.2
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/extra-integrity.js +199 -0
- package/fs-io.js +37 -0
- package/index.js +4 -0
- package/openclaw-paths.js +33 -0
- package/package.json +9 -2
- package/registry-client.js +50 -0
- package/safe-npm.js +150 -0
- package/skill-registry.js +675 -0
- package/skill-runner.js +48 -0
- package/skills.js +15 -111
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// extra-integrity: optional snapshot-and-verify layer for extraSkillDirs.
|
|
4
|
+
//
|
|
5
|
+
// Gated by `security.verifyExtraSkillDirs` (default false for 2.6.1
|
|
6
|
+
// compatibility). When enabled:
|
|
7
|
+
// * `js-eyes skills link <path>` calls `snapshotExtraDir(path)` to record a
|
|
8
|
+
// per-file sha256 map under ~/.js-eyes/state/extras/<hash>.json (the state
|
|
9
|
+
// file lives outside the external dir so js-eyes never writes to it);
|
|
10
|
+
// * the plugin's SkillRegistry calls `verifyExtraDir(path)` before loading
|
|
11
|
+
// each extra; on drift the load is refused and the operator is told to
|
|
12
|
+
// run `js-eyes skills relink <path>` after reviewing the changes.
|
|
13
|
+
//
|
|
14
|
+
// ClawHub / OpenClaw flagged extraSkillDirs as "read-only but bypass integrity
|
|
15
|
+
// verification"; this module closes that gap without breaking the default
|
|
16
|
+
// behaviour. See SECURITY_SCAN_NOTES.md.
|
|
17
|
+
|
|
18
|
+
const crypto = require('crypto');
|
|
19
|
+
const fs = require('fs');
|
|
20
|
+
const os = require('os');
|
|
21
|
+
const path = require('path');
|
|
22
|
+
|
|
23
|
+
const { ensureDir } = require('./fs-io');
|
|
24
|
+
|
|
25
|
+
const STATE_DIR_NAME = 'state';
|
|
26
|
+
const EXTRAS_DIR_NAME = 'extras';
|
|
27
|
+
const SNAPSHOT_VERSION = 1;
|
|
28
|
+
|
|
29
|
+
function sha1(text) {
|
|
30
|
+
return crypto.createHash('sha1').update(text).digest('hex');
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function sha256File(filePath) {
|
|
34
|
+
const hash = crypto.createHash('sha256');
|
|
35
|
+
hash.update(fs.readFileSync(filePath));
|
|
36
|
+
return hash.digest('hex');
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function resolveBaseDir(options = {}) {
|
|
40
|
+
if (options.baseDir) return path.resolve(options.baseDir);
|
|
41
|
+
if (process.env.JS_EYES_HOME) return path.resolve(process.env.JS_EYES_HOME);
|
|
42
|
+
return path.join(options.home || os.homedir(), '.js-eyes');
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function getSnapshotPath(absPath, options = {}) {
|
|
46
|
+
if (!absPath || typeof absPath !== 'string') {
|
|
47
|
+
throw new Error('getSnapshotPath: absPath required');
|
|
48
|
+
}
|
|
49
|
+
const baseDir = resolveBaseDir(options);
|
|
50
|
+
const key = sha1(path.resolve(absPath));
|
|
51
|
+
return path.join(baseDir, STATE_DIR_NAME, EXTRAS_DIR_NAME, `${key}.json`);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function listFilesRecursive(dir) {
|
|
55
|
+
const out = [];
|
|
56
|
+
function walk(current) {
|
|
57
|
+
let entries;
|
|
58
|
+
try {
|
|
59
|
+
entries = fs.readdirSync(current, { withFileTypes: true });
|
|
60
|
+
} catch (_) {
|
|
61
|
+
return;
|
|
62
|
+
}
|
|
63
|
+
for (const entry of entries) {
|
|
64
|
+
const full = path.join(current, entry.name);
|
|
65
|
+
const rel = path.relative(dir, full);
|
|
66
|
+
if (rel.split(path.sep)[0] === 'node_modules') continue;
|
|
67
|
+
if (entry.isDirectory()) {
|
|
68
|
+
walk(full);
|
|
69
|
+
} else if (entry.isFile()) {
|
|
70
|
+
out.push(rel.split(path.sep).join('/'));
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
walk(dir);
|
|
75
|
+
return out.sort();
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function buildFileMap(absPath) {
|
|
79
|
+
const files = {};
|
|
80
|
+
for (const rel of listFilesRecursive(absPath)) {
|
|
81
|
+
const full = path.join(absPath, rel);
|
|
82
|
+
try {
|
|
83
|
+
files[rel] = sha256File(full);
|
|
84
|
+
} catch (_) {
|
|
85
|
+
// Skip unreadable files; they'll appear as missing in verify.
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
return files;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function writeSnapshot(absPath, snapshot, options = {}) {
|
|
92
|
+
const target = getSnapshotPath(absPath, options);
|
|
93
|
+
ensureDir(path.dirname(target));
|
|
94
|
+
fs.writeFileSync(target, JSON.stringify(snapshot, null, 2) + '\n', 'utf8');
|
|
95
|
+
try { fs.chmodSync(target, 0o600); } catch (_) { /* best-effort on POSIX */ }
|
|
96
|
+
return target;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function readSnapshot(absPath, options = {}) {
|
|
100
|
+
const target = getSnapshotPath(absPath, options);
|
|
101
|
+
if (!fs.existsSync(target)) return null;
|
|
102
|
+
try {
|
|
103
|
+
return JSON.parse(fs.readFileSync(target, 'utf8'));
|
|
104
|
+
} catch (_) {
|
|
105
|
+
return null;
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function snapshotExtraDir(absPath, options = {}) {
|
|
110
|
+
if (!fs.existsSync(absPath)) {
|
|
111
|
+
throw new Error(`snapshotExtraDir: path does not exist: ${absPath}`);
|
|
112
|
+
}
|
|
113
|
+
const snapshot = {
|
|
114
|
+
version: SNAPSHOT_VERSION,
|
|
115
|
+
path: path.resolve(absPath),
|
|
116
|
+
createdAt: new Date().toISOString(),
|
|
117
|
+
files: buildFileMap(absPath),
|
|
118
|
+
};
|
|
119
|
+
const snapshotPath = writeSnapshot(absPath, snapshot, options);
|
|
120
|
+
return { snapshot, snapshotPath };
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function verifyExtraDir(absPath, options = {}) {
|
|
124
|
+
const snapshot = readSnapshot(absPath, options);
|
|
125
|
+
if (!snapshot || !snapshot.files) {
|
|
126
|
+
return {
|
|
127
|
+
ok: false,
|
|
128
|
+
hasSnapshot: false,
|
|
129
|
+
drifted: [],
|
|
130
|
+
missing: [],
|
|
131
|
+
extra: [],
|
|
132
|
+
checked: 0,
|
|
133
|
+
};
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
const expected = snapshot.files;
|
|
137
|
+
const expectedKeys = Object.keys(expected);
|
|
138
|
+
const actual = buildFileMap(absPath);
|
|
139
|
+
|
|
140
|
+
const drifted = [];
|
|
141
|
+
const missing = [];
|
|
142
|
+
for (const rel of expectedKeys) {
|
|
143
|
+
if (!Object.prototype.hasOwnProperty.call(actual, rel)) {
|
|
144
|
+
missing.push(rel);
|
|
145
|
+
continue;
|
|
146
|
+
}
|
|
147
|
+
if (actual[rel] !== expected[rel]) {
|
|
148
|
+
drifted.push(rel);
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
const extra = Object.keys(actual).filter(
|
|
152
|
+
(rel) => !Object.prototype.hasOwnProperty.call(expected, rel),
|
|
153
|
+
);
|
|
154
|
+
|
|
155
|
+
return {
|
|
156
|
+
ok: drifted.length === 0 && missing.length === 0 && extra.length === 0,
|
|
157
|
+
hasSnapshot: true,
|
|
158
|
+
drifted,
|
|
159
|
+
missing,
|
|
160
|
+
extra,
|
|
161
|
+
checked: expectedKeys.length,
|
|
162
|
+
snapshotCreatedAt: snapshot.createdAt || null,
|
|
163
|
+
};
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
function clearSnapshotForExtraDir(absPath, options = {}) {
|
|
167
|
+
const target = getSnapshotPath(absPath, options);
|
|
168
|
+
if (fs.existsSync(target)) {
|
|
169
|
+
try { fs.rmSync(target, { force: true }); } catch (_) { /* best-effort */ }
|
|
170
|
+
return true;
|
|
171
|
+
}
|
|
172
|
+
return false;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
// Returns one of: 'verified' | 'drifted' | 'missing-snapshot' | 'off' | 'error'.
|
|
176
|
+
// `off` means the global toggle is disabled.
|
|
177
|
+
function classifyExtraDir(absPath, { enabled, options = {} } = {}) {
|
|
178
|
+
if (!enabled) return { state: 'off' };
|
|
179
|
+
let result;
|
|
180
|
+
try {
|
|
181
|
+
result = verifyExtraDir(absPath, options);
|
|
182
|
+
} catch (error) {
|
|
183
|
+
return { state: 'error', error: error.message };
|
|
184
|
+
}
|
|
185
|
+
if (!result.hasSnapshot) return { state: 'missing-snapshot', detail: result };
|
|
186
|
+
if (result.ok) return { state: 'verified', detail: result };
|
|
187
|
+
return { state: 'drifted', detail: result };
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
module.exports = {
|
|
191
|
+
SNAPSHOT_VERSION,
|
|
192
|
+
getSnapshotPath,
|
|
193
|
+
snapshotExtraDir,
|
|
194
|
+
verifyExtraDir,
|
|
195
|
+
clearSnapshotForExtraDir,
|
|
196
|
+
classifyExtraDir,
|
|
197
|
+
// Exposed for tests only.
|
|
198
|
+
_internals: { buildFileMap, listFilesRecursive, resolveBaseDir },
|
|
199
|
+
};
|
package/fs-io.js
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// fs-io: pure filesystem helpers.
|
|
4
|
+
//
|
|
5
|
+
// Scoped deliberately: the functions here do local-only disk I/O and JSON
|
|
6
|
+
// parsing. They never touch the network, and this module MUST NOT import
|
|
7
|
+
// `ws`, `http`, `https`, `net`, or any network helper. The invariant is
|
|
8
|
+
// verified by test/import-boundaries.test.js.
|
|
9
|
+
//
|
|
10
|
+
// See SECURITY_SCAN_NOTES.md ("File read combined with network send") for
|
|
11
|
+
// the reason this module is kept separate from skills.js.
|
|
12
|
+
|
|
13
|
+
const fs = require('fs');
|
|
14
|
+
|
|
15
|
+
function ensureDir(dir) {
|
|
16
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
17
|
+
return dir;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function readJson(filePath) {
|
|
21
|
+
if (!fs.existsSync(filePath)) return null;
|
|
22
|
+
return JSON.parse(fs.readFileSync(filePath, 'utf8'));
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function safeStat(target) {
|
|
26
|
+
try {
|
|
27
|
+
return fs.statSync(target);
|
|
28
|
+
} catch (_) {
|
|
29
|
+
return null;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
module.exports = {
|
|
34
|
+
ensureDir,
|
|
35
|
+
readJson,
|
|
36
|
+
safeStat,
|
|
37
|
+
};
|
package/index.js
CHANGED
|
@@ -69,6 +69,10 @@ const DEFAULT_SECURITY_CONFIG = Object.freeze({
|
|
|
69
69
|
allowRemoteBind: false,
|
|
70
70
|
allowRawEval: false,
|
|
71
71
|
requireLockfile: true,
|
|
72
|
+
// 2.6.2 opt-in integrity checks for extraSkillDirs. Default false keeps
|
|
73
|
+
// behaviour identical to 2.6.1; flip to true (or run
|
|
74
|
+
// `js-eyes skills link`/`relink` with the flag enabled) to start verifying.
|
|
75
|
+
verifyExtraSkillDirs: false,
|
|
72
76
|
enforcement: 'soft',
|
|
73
77
|
taskOrigin: { ...DEFAULT_TASK_ORIGIN_CONFIG, sources: DEFAULT_TASK_ORIGIN_CONFIG.sources.slice() },
|
|
74
78
|
egressAllowlist: [],
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// openclaw-paths: OpenClaw state/config path resolution.
|
|
4
|
+
//
|
|
5
|
+
// Intentionally lives in its own module so the `process.env` reads that pick
|
|
6
|
+
// up OPENCLAW_CONFIG_PATH / OPENCLAW_STATE_DIR / OPENCLAW_HOME never sit in the
|
|
7
|
+
// same file as network clients (see SECURITY_SCAN_NOTES.md, "Environment
|
|
8
|
+
// variable access combined with network send"). This file MUST NOT import
|
|
9
|
+
// `ws`, `http`, `https`, `net`, or any network helper — that invariant is
|
|
10
|
+
// verified by test/import-boundaries.test.js.
|
|
11
|
+
|
|
12
|
+
const os = require('os');
|
|
13
|
+
const path = require('path');
|
|
14
|
+
|
|
15
|
+
function getOpenClawConfigPath(options = {}) {
|
|
16
|
+
const env = options.env || process.env;
|
|
17
|
+
const home = options.home || os.homedir();
|
|
18
|
+
|
|
19
|
+
if (env.OPENCLAW_CONFIG_PATH) {
|
|
20
|
+
return path.resolve(env.OPENCLAW_CONFIG_PATH);
|
|
21
|
+
}
|
|
22
|
+
if (env.OPENCLAW_STATE_DIR) {
|
|
23
|
+
return path.resolve(env.OPENCLAW_STATE_DIR, 'openclaw.json');
|
|
24
|
+
}
|
|
25
|
+
if (env.OPENCLAW_HOME) {
|
|
26
|
+
return path.resolve(env.OPENCLAW_HOME, '.openclaw', 'openclaw.json');
|
|
27
|
+
}
|
|
28
|
+
return path.join(home, '.openclaw', 'openclaw.json');
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
module.exports = {
|
|
32
|
+
getOpenClawConfigPath,
|
|
33
|
+
};
|
package/package.json
CHANGED
|
@@ -1,12 +1,19 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@js-eyes/protocol",
|
|
3
|
-
"version": "2.6.
|
|
3
|
+
"version": "2.6.2",
|
|
4
4
|
"description": "Shared protocol constants for JS Eyes runtime packages",
|
|
5
5
|
"main": "index.js",
|
|
6
6
|
"files": [
|
|
7
7
|
"index.js",
|
|
8
8
|
"skills.js",
|
|
9
|
-
"zip-extract.js"
|
|
9
|
+
"zip-extract.js",
|
|
10
|
+
"fs-io.js",
|
|
11
|
+
"safe-npm.js",
|
|
12
|
+
"openclaw-paths.js",
|
|
13
|
+
"extra-integrity.js",
|
|
14
|
+
"skill-registry.js",
|
|
15
|
+
"skill-runner.js",
|
|
16
|
+
"registry-client.js"
|
|
10
17
|
],
|
|
11
18
|
"license": "MIT",
|
|
12
19
|
"author": "imjszhang <ortle3x3@gmail.com>",
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// registry-client: the single place in @js-eyes/protocol that talks to the
|
|
4
|
+
// ClawHub / custom skills registry over HTTP.
|
|
5
|
+
//
|
|
6
|
+
// Kept separate from skills.js / fs-io.js so the scanner never sees `fetch(…)`
|
|
7
|
+
// co-located with `fs.readFileSync(…)` or `fs.createReadStream(…)`. The
|
|
8
|
+
// invariant is enforced by test/import-boundaries.test.js (inverse direction:
|
|
9
|
+
// `fs-io.js` / `openclaw-paths.js` MUST NOT import anything network-capable,
|
|
10
|
+
// and vice-versa this module MUST NOT re-introduce `fs.readFile*` /
|
|
11
|
+
// `fs.createReadStream*`).
|
|
12
|
+
//
|
|
13
|
+
// See SECURITY_SCAN_NOTES.md ("File read combined with network send").
|
|
14
|
+
|
|
15
|
+
async function fetchSkillsRegistry(registryUrl) {
|
|
16
|
+
const response = await fetch(registryUrl, {
|
|
17
|
+
headers: { Accept: 'application/json' },
|
|
18
|
+
});
|
|
19
|
+
if (!response.ok) {
|
|
20
|
+
throw new Error(`HTTP ${response.status}`);
|
|
21
|
+
}
|
|
22
|
+
return response.json();
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
// downloadBuffer: attempts a list of candidate URLs in order and returns the
|
|
26
|
+
// first successful body as a Buffer. Lives next to fetchSkillsRegistry so the
|
|
27
|
+
// skill-install flow's network I/O is consolidated in this module — skills.js
|
|
28
|
+
// just calls it and then hashes / validates / writes the bytes through
|
|
29
|
+
// fs-io.js helpers.
|
|
30
|
+
async function downloadBuffer(urls, logger = console) {
|
|
31
|
+
let lastError = null;
|
|
32
|
+
for (const url of urls) {
|
|
33
|
+
try {
|
|
34
|
+
const response = await fetch(url);
|
|
35
|
+
if (response.ok) {
|
|
36
|
+
const buf = Buffer.from(await response.arrayBuffer());
|
|
37
|
+
return { buffer: buf, url };
|
|
38
|
+
}
|
|
39
|
+
lastError = new Error(`HTTP ${response.status} (${url})`);
|
|
40
|
+
} catch (error) {
|
|
41
|
+
lastError = error;
|
|
42
|
+
}
|
|
43
|
+
if (logger && typeof logger.warn === 'function') {
|
|
44
|
+
logger.warn(`[js-eyes] Download failed (${url}): ${lastError?.message || 'unknown'}`);
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
throw lastError || new Error('Download failed for all URLs');
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
module.exports = { fetchSkillsRegistry, downloadBuffer };
|
package/safe-npm.js
ADDED
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// safe-npm: the only place in this package that invokes child_process.
|
|
4
|
+
//
|
|
5
|
+
// Design constraints (see SECURITY_SCAN_NOTES.md, "Shell command execution"):
|
|
6
|
+
// * subcommand is chosen from an immutable whitelist — callers can only
|
|
7
|
+
// select by name, never by passing a string;
|
|
8
|
+
// * every argv entry is a constant (no string concatenation from user input);
|
|
9
|
+
// * spawnSync is called with `shell: false` and `windowsHide: true`;
|
|
10
|
+
// * the child env is built from a small whitelist, so secrets in
|
|
11
|
+
// process.env (tokens, OAuth state, etc.) never leak into the npm run;
|
|
12
|
+
// * postinstall scripts are disabled unless the caller explicitly opts in.
|
|
13
|
+
//
|
|
14
|
+
// The single allowed binary name is `npm`. No wildcards, no PATHEXT, no shell
|
|
15
|
+
// meta-characters: Node's child_process with shell=false treats the argv as a
|
|
16
|
+
// literal argument vector.
|
|
17
|
+
|
|
18
|
+
const fs = require('fs');
|
|
19
|
+
const path = require('path');
|
|
20
|
+
const { spawnSync } = require('child_process');
|
|
21
|
+
|
|
22
|
+
const ALLOWED_SUBCOMMANDS = Object.freeze({
|
|
23
|
+
ci: Object.freeze(['ci', '--no-audit', '--no-fund']),
|
|
24
|
+
install: Object.freeze(['install', '--no-audit', '--no-fund']),
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
const SAFE_ENV_KEYS = Object.freeze([
|
|
28
|
+
'PATH',
|
|
29
|
+
'HOME',
|
|
30
|
+
'USERPROFILE',
|
|
31
|
+
'APPDATA',
|
|
32
|
+
'LOCALAPPDATA',
|
|
33
|
+
'SystemRoot',
|
|
34
|
+
'SYSTEMROOT',
|
|
35
|
+
'COMSPEC',
|
|
36
|
+
'TEMP',
|
|
37
|
+
'TMP',
|
|
38
|
+
'TMPDIR',
|
|
39
|
+
'LANG',
|
|
40
|
+
'LC_ALL',
|
|
41
|
+
'LC_CTYPE',
|
|
42
|
+
'HOMEDRIVE',
|
|
43
|
+
'HOMEPATH',
|
|
44
|
+
'PATHEXT',
|
|
45
|
+
]);
|
|
46
|
+
|
|
47
|
+
function buildSafeEnv(sourceEnv, extra = {}) {
|
|
48
|
+
const src = sourceEnv || process.env;
|
|
49
|
+
const next = {};
|
|
50
|
+
for (const key of SAFE_ENV_KEYS) {
|
|
51
|
+
if (src[key] !== undefined) next[key] = src[key];
|
|
52
|
+
}
|
|
53
|
+
for (const [key, value] of Object.entries(src)) {
|
|
54
|
+
if (key.startsWith('npm_config_')) next[key] = value;
|
|
55
|
+
}
|
|
56
|
+
for (const [key, value] of Object.entries(extra || {})) {
|
|
57
|
+
if (value !== undefined) next[key] = value;
|
|
58
|
+
}
|
|
59
|
+
return next;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function detectPackageManager(targetDir) {
|
|
63
|
+
if (fs.existsSync(path.join(targetDir, 'pnpm-lock.yaml'))) return 'pnpm';
|
|
64
|
+
if (fs.existsSync(path.join(targetDir, 'yarn.lock'))) return 'yarn';
|
|
65
|
+
if (fs.existsSync(path.join(targetDir, 'package-lock.json'))) return 'npm';
|
|
66
|
+
return null;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function runNpm(subcommand, targetDir, options = {}) {
|
|
70
|
+
if (!Object.prototype.hasOwnProperty.call(ALLOWED_SUBCOMMANDS, subcommand)) {
|
|
71
|
+
throw new Error(`safe-npm: subcommand "${subcommand}" is not in the allowlist`);
|
|
72
|
+
}
|
|
73
|
+
if (typeof targetDir !== 'string' || !targetDir) {
|
|
74
|
+
throw new Error('safe-npm: targetDir must be a non-empty string');
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const baseArgs = ALLOWED_SUBCOMMANDS[subcommand].slice();
|
|
78
|
+
const allowPostinstall = Boolean(options.allowPostinstall);
|
|
79
|
+
if (!allowPostinstall) baseArgs.push('--ignore-scripts');
|
|
80
|
+
|
|
81
|
+
const childEnv = buildSafeEnv(options.env || process.env, {
|
|
82
|
+
npm_config_ignore_scripts: allowPostinstall ? 'false' : 'true',
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
const result = spawnSync('npm', baseArgs, {
|
|
86
|
+
cwd: targetDir,
|
|
87
|
+
stdio: options.stdio || 'pipe',
|
|
88
|
+
shell: false,
|
|
89
|
+
windowsHide: true,
|
|
90
|
+
env: childEnv,
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
return { result, args: baseArgs };
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function safeNpmCi(targetDir, options = {}) {
|
|
97
|
+
const { result, args } = runNpm('ci', targetDir, options);
|
|
98
|
+
if (result.status !== 0) {
|
|
99
|
+
const stderr = result.stderr ? String(result.stderr) : '';
|
|
100
|
+
throw new Error(`npm ${args.join(' ')} 失败 (status=${result.status}): ${stderr.slice(0, 500)}`);
|
|
101
|
+
}
|
|
102
|
+
return { ran: true, manager: 'npm', args };
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function safeNpmInstall(targetDir, options = {}) {
|
|
106
|
+
const { result, args } = runNpm('install', targetDir, options);
|
|
107
|
+
if (result.status !== 0) {
|
|
108
|
+
const stderr = result.stderr ? String(result.stderr) : '';
|
|
109
|
+
throw new Error(`npm ${args.join(' ')} 失败 (status=${result.status}): ${stderr.slice(0, 500)}`);
|
|
110
|
+
}
|
|
111
|
+
return { ran: true, manager: 'npm', args };
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function installSkillDependencies(targetDir, options = {}) {
|
|
115
|
+
const pkgJson = path.join(targetDir, 'package.json');
|
|
116
|
+
if (!fs.existsSync(pkgJson)) return { ran: false, manager: null };
|
|
117
|
+
|
|
118
|
+
const requireLockfile = options.requireLockfile !== false;
|
|
119
|
+
const manager = detectPackageManager(targetDir);
|
|
120
|
+
|
|
121
|
+
if (requireLockfile && manager !== 'npm') {
|
|
122
|
+
throw new Error('安装拒绝执行:缺少 package-lock.json(开启 security.requireLockfile=false 可放宽)');
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
const runOptions = {
|
|
126
|
+
allowPostinstall: Boolean(options.allowPostinstall),
|
|
127
|
+
stdio: options.stdio,
|
|
128
|
+
env: options.env,
|
|
129
|
+
};
|
|
130
|
+
|
|
131
|
+
const outcome = manager === 'npm'
|
|
132
|
+
? safeNpmCi(targetDir, runOptions)
|
|
133
|
+
: safeNpmInstall(targetDir, runOptions);
|
|
134
|
+
|
|
135
|
+
return {
|
|
136
|
+
ran: true,
|
|
137
|
+
manager: outcome.manager,
|
|
138
|
+
allowPostinstall: runOptions.allowPostinstall,
|
|
139
|
+
};
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
module.exports = {
|
|
143
|
+
ALLOWED_SUBCOMMANDS,
|
|
144
|
+
SAFE_ENV_KEYS,
|
|
145
|
+
buildSafeEnv,
|
|
146
|
+
detectPackageManager,
|
|
147
|
+
safeNpmCi,
|
|
148
|
+
safeNpmInstall,
|
|
149
|
+
installSkillDependencies,
|
|
150
|
+
};
|