@tankpkg/cli 0.5.0 → 0.6.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/dist/commands/install.d.ts +1 -16
- package/dist/commands/install.js +330 -363
- package/dist/commands/install.js.map +1 -1
- package/dist/commands/upgrade.js +8 -1
- package/dist/commands/upgrade.js.map +1 -1
- package/dist/lib/dependency-resolver.d.ts +51 -0
- package/dist/lib/dependency-resolver.js +181 -0
- package/dist/lib/dependency-resolver.js.map +1 -0
- package/dist/lib/install-pipeline.d.ts +23 -0
- package/dist/lib/install-pipeline.js +181 -0
- package/dist/lib/install-pipeline.js.map +1 -0
- package/dist/lib/permission-checker.d.ts +16 -0
- package/dist/lib/permission-checker.js +78 -0
- package/dist/lib/permission-checker.js.map +1 -0
- package/dist/lib/upgrade-check.js +9 -2
- package/dist/lib/upgrade-check.js.map +1 -1
- package/package.json +1 -1
package/dist/commands/install.js
CHANGED
|
@@ -1,9 +1,8 @@
|
|
|
1
|
+
import crypto from 'node:crypto';
|
|
1
2
|
import fs from 'node:fs';
|
|
2
3
|
import path from 'node:path';
|
|
3
|
-
import crypto from 'node:crypto';
|
|
4
4
|
import os from 'node:os';
|
|
5
5
|
import ora from 'ora';
|
|
6
|
-
import { extract } from 'tar';
|
|
7
6
|
import { resolve, LOCKFILE_VERSION } from '@tank/shared';
|
|
8
7
|
import { getConfig } from '../lib/config.js';
|
|
9
8
|
import { logger } from '../lib/logger.js';
|
|
@@ -11,241 +10,323 @@ import { prepareAgentSkillDir } from '../lib/frontmatter.js';
|
|
|
11
10
|
import { linkSkillToAgents } from '../lib/linker.js';
|
|
12
11
|
import { detectInstalledAgents, getGlobalSkillsDir, getGlobalAgentSkillsDir } from '../lib/agents.js';
|
|
13
12
|
import { USER_AGENT } from '../version.js';
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
* 10. Update skills.json
|
|
29
|
-
* 11. Update skills.lock
|
|
30
|
-
*/
|
|
31
|
-
export async function installCommand(options) {
|
|
32
|
-
const { name, versionRange = '*', directory = process.cwd(), configDir, global = false, homedir, } = options;
|
|
33
|
-
const config = getConfig(configDir);
|
|
34
|
-
const resolvedHome = homedir ?? os.homedir();
|
|
35
|
-
const requestHeaders = { 'User-Agent': USER_AGENT };
|
|
36
|
-
if (config.token) {
|
|
37
|
-
requestHeaders.Authorization = `Bearer ${config.token}`;
|
|
38
|
-
}
|
|
39
|
-
// 1. Read or create skills.json
|
|
40
|
-
const skillsJsonPath = path.join(directory, 'skills.json');
|
|
41
|
-
let skillsJson = { skills: {} };
|
|
42
|
-
if (!global) {
|
|
43
|
-
if (!fs.existsSync(skillsJsonPath)) {
|
|
44
|
-
skillsJson = { skills: {} };
|
|
45
|
-
fs.writeFileSync(skillsJsonPath, JSON.stringify(skillsJson, null, 2) + '\n');
|
|
46
|
-
logger.info('Created skills.json');
|
|
47
|
-
}
|
|
48
|
-
else {
|
|
13
|
+
import { resolveDependencyTree, buildSkillKey, } from '../lib/dependency-resolver.js';
|
|
14
|
+
import { checkPermissionBudget } from '../lib/permission-checker.js';
|
|
15
|
+
import { downloadAllParallel, extractSafely, getExtractDir, getGlobalExtractDir, getResolvedNodesInOrder, parseLockKey, parseVersionFromLockKey, readExtractedDependencies, verifyExtractedDependencies, writeLockfileWithResolvedGraph, } from '../lib/install-pipeline.js';
|
|
16
|
+
function createRegistryFetcher(registry, headers) {
|
|
17
|
+
const versionsCache = new Map();
|
|
18
|
+
const metadataCache = new Map();
|
|
19
|
+
return {
|
|
20
|
+
async fetchVersions(name) {
|
|
21
|
+
const cached = versionsCache.get(name);
|
|
22
|
+
if (cached) {
|
|
23
|
+
return cached;
|
|
24
|
+
}
|
|
25
|
+
const encoded = encodeURIComponent(name);
|
|
26
|
+
let res;
|
|
49
27
|
try {
|
|
50
|
-
|
|
51
|
-
skillsJson = JSON.parse(raw);
|
|
28
|
+
res = await fetch(`${registry}/api/v1/skills/${encoded}/versions`, { headers });
|
|
52
29
|
}
|
|
53
|
-
catch {
|
|
54
|
-
throw new Error(
|
|
30
|
+
catch (err) {
|
|
31
|
+
throw new Error(`Network error fetching versions: ${err instanceof Error ? err.message : String(err)}`);
|
|
55
32
|
}
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
}
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
33
|
+
if (!res.ok) {
|
|
34
|
+
if (res.status === 403)
|
|
35
|
+
throw new Error('Token lacks required scope: skills:read');
|
|
36
|
+
if (res.status === 404)
|
|
37
|
+
throw new Error(`Skill not found or no access: ${name}`);
|
|
38
|
+
const body = await res.json().catch(() => ({}));
|
|
39
|
+
throw new Error(body.error ?? res.statusText);
|
|
40
|
+
}
|
|
41
|
+
const data = await res.json();
|
|
42
|
+
versionsCache.set(name, data.versions);
|
|
43
|
+
return data.versions;
|
|
44
|
+
},
|
|
45
|
+
async fetchMetadata(name, version) {
|
|
46
|
+
const cacheKey = buildSkillKey(name, version);
|
|
47
|
+
const cached = metadataCache.get(cacheKey);
|
|
48
|
+
if (cached) {
|
|
49
|
+
return cached;
|
|
50
|
+
}
|
|
51
|
+
const encoded = encodeURIComponent(name);
|
|
52
|
+
let res;
|
|
53
|
+
try {
|
|
54
|
+
res = await fetch(`${registry}/api/v1/skills/${encoded}/${version}`, { headers });
|
|
55
|
+
}
|
|
56
|
+
catch (err) {
|
|
57
|
+
throw new Error(`Network error fetching metadata: ${err instanceof Error ? err.message : String(err)}`);
|
|
58
|
+
}
|
|
59
|
+
if (!res.ok) {
|
|
60
|
+
if (res.status === 403)
|
|
61
|
+
throw new Error('Token lacks required scope: skills:read');
|
|
62
|
+
if (res.status === 404)
|
|
63
|
+
throw new Error(`Skill not found or no access: ${name}@${version}`);
|
|
64
|
+
const body = await res.json().catch(() => ({}));
|
|
65
|
+
throw new Error(body.error ?? res.statusText);
|
|
66
|
+
}
|
|
67
|
+
const data = await res.json();
|
|
68
|
+
const normalized = {
|
|
69
|
+
...data,
|
|
70
|
+
dependencies: data.dependencies ?? {},
|
|
71
|
+
};
|
|
72
|
+
metadataCache.set(cacheKey, normalized);
|
|
73
|
+
return normalized;
|
|
74
|
+
},
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
function readSkillsJson(skillsJsonPath) {
|
|
78
78
|
try {
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
});
|
|
82
|
-
}
|
|
83
|
-
catch (err) {
|
|
84
|
-
spinner.fail('Failed to fetch versions');
|
|
85
|
-
throw new Error(`Network error fetching versions: ${err instanceof Error ? err.message : String(err)}`);
|
|
79
|
+
const raw = fs.readFileSync(skillsJsonPath, 'utf-8');
|
|
80
|
+
return JSON.parse(raw);
|
|
86
81
|
}
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
if (versionsRes.status === 403) {
|
|
90
|
-
throw new Error('Token lacks required scope: skills:read');
|
|
91
|
-
}
|
|
92
|
-
if (versionsRes.status === 404) {
|
|
93
|
-
throw new Error(`Skill not found or no access: ${name}`);
|
|
94
|
-
}
|
|
95
|
-
const body = await versionsRes.json().catch(() => ({}));
|
|
96
|
-
throw new Error(body.error ?? versionsRes.statusText);
|
|
82
|
+
catch {
|
|
83
|
+
throw new Error('Failed to read or parse skills.json');
|
|
97
84
|
}
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
85
|
+
}
|
|
86
|
+
function readOrCreateSkillsJson(skillsJsonPath) {
|
|
87
|
+
if (!fs.existsSync(skillsJsonPath)) {
|
|
88
|
+
const skillsJson = { skills: {} };
|
|
89
|
+
fs.writeFileSync(skillsJsonPath, JSON.stringify(skillsJson, null, 2) + '\n');
|
|
90
|
+
logger.info('Created skills.json');
|
|
91
|
+
return skillsJson;
|
|
105
92
|
}
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
return;
|
|
93
|
+
return readSkillsJson(skillsJsonPath);
|
|
94
|
+
}
|
|
95
|
+
function readLockOrFresh(lockPath) {
|
|
96
|
+
if (!fs.existsSync(lockPath)) {
|
|
97
|
+
return { lockfileVersion: LOCKFILE_VERSION, skills: {} };
|
|
112
98
|
}
|
|
113
|
-
// 5. Fetch version metadata
|
|
114
|
-
spinner.text = `Fetching ${name}@${resolved}...`;
|
|
115
|
-
const metaUrl = `${config.registry}/api/v1/skills/${encodedName}/${resolved}`;
|
|
116
|
-
let metaRes;
|
|
117
99
|
try {
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
});
|
|
100
|
+
const raw = fs.readFileSync(lockPath, 'utf-8');
|
|
101
|
+
return JSON.parse(raw);
|
|
121
102
|
}
|
|
122
|
-
catch
|
|
123
|
-
|
|
124
|
-
|
|
103
|
+
catch {
|
|
104
|
+
return { lockfileVersion: LOCKFILE_VERSION, skills: {} };
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
function buildLockedVersionByName(lock) {
|
|
108
|
+
const lockedVersionByName = new Map();
|
|
109
|
+
for (const key of Object.keys(lock.skills)) {
|
|
110
|
+
lockedVersionByName.set(parseLockKey(key), parseVersionFromLockKey(key));
|
|
111
|
+
}
|
|
112
|
+
return lockedVersionByName;
|
|
113
|
+
}
|
|
114
|
+
function createExtractDirResolver(directory, global, resolvedHome) {
|
|
115
|
+
return (skillName) => (global ? getGlobalExtractDir(resolvedHome, skillName) : getExtractDir(directory, skillName));
|
|
116
|
+
}
|
|
117
|
+
function validateResolvedNodes(resolvedNodes, projectPermissions, auditMinScore) {
|
|
118
|
+
if (!projectPermissions) {
|
|
119
|
+
logger.warn('No permission budget defined in skills.json. Install proceeding without permission checks.');
|
|
125
120
|
}
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
throw new Error('Token lacks required scope: skills:read');
|
|
121
|
+
for (const node of resolvedNodes) {
|
|
122
|
+
if (projectPermissions) {
|
|
123
|
+
checkPermissionBudget(projectPermissions, node.meta.permissions, node.name);
|
|
130
124
|
}
|
|
131
|
-
if (
|
|
132
|
-
|
|
125
|
+
if (auditMinScore !== undefined) {
|
|
126
|
+
if (node.meta.auditScore === null || node.meta.auditScore === undefined) {
|
|
127
|
+
logger.warn(`Audit score not yet available for ${node.name}. Install proceeding without audit score check.`);
|
|
128
|
+
}
|
|
129
|
+
else if (node.meta.auditScore < auditMinScore) {
|
|
130
|
+
throw new Error(`Audit score ${node.meta.auditScore} for ${node.name} is below minimum threshold ${auditMinScore} defined in skills.json`);
|
|
131
|
+
}
|
|
133
132
|
}
|
|
134
|
-
const body = await metaRes.json().catch(() => ({}));
|
|
135
|
-
throw new Error(body.error ?? metaRes.statusText);
|
|
136
133
|
}
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
const
|
|
140
|
-
const
|
|
141
|
-
|
|
142
|
-
if (!
|
|
143
|
-
|
|
134
|
+
}
|
|
135
|
+
async function runLegacyFallback(options) {
|
|
136
|
+
const { rootSkillNames, resolvedNodeByName, extractDirForSkill, directory, configDir, global, homedir } = options;
|
|
137
|
+
for (const skillName of rootSkillNames) {
|
|
138
|
+
const node = resolvedNodeByName.get(skillName);
|
|
139
|
+
if (!node || Object.keys(node.meta.dependencies).length > 0) {
|
|
140
|
+
continue;
|
|
144
141
|
}
|
|
145
|
-
|
|
146
|
-
|
|
142
|
+
const extractedDeps = readExtractedDependencies(extractDirForSkill(skillName));
|
|
143
|
+
for (const [depName, depRange] of Object.entries(extractedDeps)) {
|
|
144
|
+
if (depName === skillName) {
|
|
145
|
+
continue;
|
|
146
|
+
}
|
|
147
|
+
await installCommand({
|
|
148
|
+
name: depName,
|
|
149
|
+
versionRange: depRange,
|
|
150
|
+
directory,
|
|
151
|
+
configDir,
|
|
152
|
+
global,
|
|
153
|
+
homedir,
|
|
154
|
+
isTransitive: true,
|
|
155
|
+
});
|
|
147
156
|
}
|
|
148
157
|
}
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
158
|
+
}
|
|
159
|
+
function linkInstalledRoots(options) {
|
|
160
|
+
const { rootSkillNames, resolvedNodeByName, extractDirForSkill, directory, global, resolvedHome, homedir } = options;
|
|
161
|
+
const agentSkillsBaseDir = global
|
|
162
|
+
? getGlobalAgentSkillsDir(resolvedHome)
|
|
163
|
+
: path.join(directory, '.tank', 'agent-skills');
|
|
164
|
+
const linksDir = global ? path.join(resolvedHome, '.tank') : path.join(directory, '.tank');
|
|
165
|
+
for (const skillName of rootSkillNames) {
|
|
166
|
+
try {
|
|
167
|
+
const node = resolvedNodeByName.get(skillName);
|
|
168
|
+
if (!node) {
|
|
169
|
+
continue;
|
|
170
|
+
}
|
|
171
|
+
const agentSkillDir = prepareAgentSkillDir({
|
|
172
|
+
skillName,
|
|
173
|
+
extractDir: extractDirForSkill(skillName),
|
|
174
|
+
agentSkillsBaseDir,
|
|
175
|
+
description: node.meta.description,
|
|
176
|
+
});
|
|
177
|
+
const linkResult = linkSkillToAgents({
|
|
178
|
+
skillName,
|
|
179
|
+
sourceDir: agentSkillDir,
|
|
180
|
+
linksDir,
|
|
181
|
+
source: global ? 'global' : 'local',
|
|
182
|
+
homedir,
|
|
183
|
+
});
|
|
184
|
+
if (linkResult.linked.length > 0) {
|
|
185
|
+
logger.info(`Linked to ${linkResult.linked.length} agent(s)`);
|
|
186
|
+
}
|
|
187
|
+
if (linkResult.failed.length > 0) {
|
|
188
|
+
for (const failedLink of linkResult.failed) {
|
|
189
|
+
logger.warn(`Failed to link to ${failedLink.agentId}: ${failedLink.error}`);
|
|
190
|
+
}
|
|
191
|
+
}
|
|
154
192
|
}
|
|
155
|
-
|
|
156
|
-
|
|
193
|
+
catch {
|
|
194
|
+
if (rootSkillNames.length === 1) {
|
|
195
|
+
logger.warn('Agent linking skipped (non-fatal)');
|
|
196
|
+
}
|
|
197
|
+
else {
|
|
198
|
+
logger.warn(`Agent linking skipped for ${skillName} (non-fatal)`);
|
|
199
|
+
}
|
|
157
200
|
}
|
|
158
201
|
}
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
try {
|
|
163
|
-
downloadRes = await fetch(metadata.downloadUrl);
|
|
164
|
-
}
|
|
165
|
-
catch (err) {
|
|
166
|
-
spinner.fail('Download failed');
|
|
167
|
-
throw new Error(`Network error downloading tarball: ${err instanceof Error ? err.message : String(err)}`);
|
|
168
|
-
}
|
|
169
|
-
if (!downloadRes.ok) {
|
|
170
|
-
spinner.fail('Download failed');
|
|
171
|
-
throw new Error(`Failed to download tarball: ${downloadRes.status} ${downloadRes.statusText}`);
|
|
202
|
+
const detectedAgents = detectInstalledAgents(homedir);
|
|
203
|
+
if (detectedAgents.length === 0) {
|
|
204
|
+
logger.warn('No agents detected for linking');
|
|
172
205
|
}
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
spinner
|
|
176
|
-
const hash = crypto.createHash('sha512').update(tarballBuffer).digest('base64');
|
|
177
|
-
const computedIntegrity = `sha512-${hash}`;
|
|
178
|
-
if (computedIntegrity !== metadata.integrity) {
|
|
179
|
-
spinner.fail('Integrity check failed');
|
|
180
|
-
throw new Error(`Integrity mismatch for ${name}@${resolved}. Expected: ${metadata.integrity}, Got: ${computedIntegrity}`);
|
|
181
|
-
}
|
|
182
|
-
// 9. Extract tarball safely
|
|
183
|
-
spinner.text = `Extracting ${name}@${resolved}...`;
|
|
184
|
-
const extractDir = global
|
|
185
|
-
? getGlobalExtractDir(resolvedHome, name)
|
|
186
|
-
: getExtractDir(directory, name);
|
|
187
|
-
// Create extraction directory
|
|
188
|
-
fs.mkdirSync(extractDir, { recursive: true });
|
|
189
|
-
// Extract with safety checks
|
|
190
|
-
await extractSafely(tarballBuffer, extractDir);
|
|
191
|
-
// 10. Update skills.json
|
|
206
|
+
}
|
|
207
|
+
async function executeInstallPipeline(options) {
|
|
208
|
+
const { directory, configDir, global, homedir, resolvedHome, lock, lockPath, resolvedNodes, nodesToInstall, rootSkillNames, projectPermissions, auditMinScore, spinner, } = options;
|
|
192
209
|
if (!global) {
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
lock.skills = sortedSkills;
|
|
210
|
+
validateResolvedNodes(resolvedNodes, projectPermissions, auditMinScore);
|
|
211
|
+
}
|
|
212
|
+
const extractDirForSkill = createExtractDirResolver(directory, global, resolvedHome);
|
|
213
|
+
const resolvedNodeByName = new Map(resolvedNodes.map((node) => [node.name, node]));
|
|
214
|
+
const downloaded = await downloadAllParallel(nodesToInstall, spinner);
|
|
215
|
+
for (const node of nodesToInstall) {
|
|
216
|
+
const payload = downloaded.get(node.name);
|
|
217
|
+
if (!payload) {
|
|
218
|
+
throw new Error(`Missing downloaded tarball for ${node.name}@${node.version}`);
|
|
219
|
+
}
|
|
220
|
+
spinner.text = `Extracting ${node.name}@${node.version}...`;
|
|
221
|
+
const extractDir = extractDirForSkill(node.name);
|
|
222
|
+
fs.mkdirSync(extractDir, { recursive: true });
|
|
223
|
+
await extractSafely(payload.buffer, extractDir);
|
|
224
|
+
verifyExtractedDependencies(extractDir, node);
|
|
225
|
+
}
|
|
226
|
+
lock.lockfileVersion = LOCKFILE_VERSION;
|
|
227
|
+
const updatedLock = writeLockfileWithResolvedGraph(lock, resolvedNodes, downloaded);
|
|
212
228
|
fs.mkdirSync(path.dirname(lockPath), { recursive: true });
|
|
213
|
-
fs.writeFileSync(lockPath, JSON.stringify(
|
|
214
|
-
|
|
229
|
+
fs.writeFileSync(lockPath, JSON.stringify(updatedLock, null, 2) + '\n');
|
|
230
|
+
await runLegacyFallback({
|
|
231
|
+
rootSkillNames,
|
|
232
|
+
resolvedNodeByName,
|
|
233
|
+
extractDirForSkill,
|
|
234
|
+
directory,
|
|
235
|
+
configDir,
|
|
236
|
+
global,
|
|
237
|
+
homedir,
|
|
238
|
+
});
|
|
239
|
+
linkInstalledRoots({
|
|
240
|
+
rootSkillNames,
|
|
241
|
+
resolvedNodeByName,
|
|
242
|
+
extractDirForSkill,
|
|
243
|
+
directory,
|
|
244
|
+
global,
|
|
245
|
+
resolvedHome,
|
|
246
|
+
homedir,
|
|
247
|
+
});
|
|
248
|
+
return updatedLock;
|
|
249
|
+
}
|
|
250
|
+
export async function installCommand(options) {
|
|
251
|
+
const { name, versionRange = '*', directory = process.cwd(), configDir, global = false, homedir, isTransitive = false, } = options;
|
|
252
|
+
const config = getConfig(configDir);
|
|
253
|
+
const resolvedHome = homedir ?? os.homedir();
|
|
254
|
+
const requestHeaders = { 'User-Agent': USER_AGENT };
|
|
255
|
+
if (config.token) {
|
|
256
|
+
requestHeaders.Authorization = `Bearer ${config.token}`;
|
|
257
|
+
}
|
|
258
|
+
const skillsJsonPath = path.join(directory, 'skills.json');
|
|
259
|
+
const skillsJson = global ? { skills: {} } : readOrCreateSkillsJson(skillsJsonPath);
|
|
260
|
+
const lockPath = global
|
|
261
|
+
? path.join(resolvedHome, '.tank', 'skills.lock')
|
|
262
|
+
: path.join(directory, 'skills.lock');
|
|
263
|
+
const lock = readLockOrFresh(lockPath);
|
|
264
|
+
const spinner = ora('Resolving dependency graph...').start();
|
|
215
265
|
try {
|
|
216
|
-
const
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
const
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
agentSkillsBaseDir,
|
|
223
|
-
description: metadata.description,
|
|
224
|
-
});
|
|
225
|
-
const linkResult = linkSkillToAgents({
|
|
226
|
-
skillName: name,
|
|
227
|
-
sourceDir: agentSkillDir,
|
|
228
|
-
linksDir: global ? path.join(resolvedHome, '.tank') : path.join(directory, '.tank'),
|
|
229
|
-
source: global ? 'global' : 'local',
|
|
230
|
-
homedir: options.homedir,
|
|
231
|
-
});
|
|
232
|
-
const detectedAgents = detectInstalledAgents(options.homedir);
|
|
233
|
-
if (detectedAgents.length === 0) {
|
|
234
|
-
logger.warn('No agents detected for linking');
|
|
266
|
+
const fetcher = createRegistryFetcher(config.registry, requestHeaders);
|
|
267
|
+
const requestedVersions = await fetcher.fetchVersions(name);
|
|
268
|
+
const requestedAvailableVersions = requestedVersions.map((versionInfo) => versionInfo.version);
|
|
269
|
+
const requestedResolvedVersion = resolve(versionRange, requestedAvailableVersions);
|
|
270
|
+
if (!requestedResolvedVersion) {
|
|
271
|
+
throw new Error(`No version of ${name} satisfies range "${versionRange}". Available: ${requestedAvailableVersions.join(', ')}`);
|
|
235
272
|
}
|
|
236
|
-
|
|
237
|
-
|
|
273
|
+
const requestedLockKey = buildSkillKey(name, requestedResolvedVersion);
|
|
274
|
+
if (lock.skills[requestedLockKey]) {
|
|
275
|
+
logger.info(`${name}@${requestedResolvedVersion} is already installed`);
|
|
276
|
+
spinner.succeed(`${name}@${requestedResolvedVersion} is already installed`);
|
|
277
|
+
return;
|
|
238
278
|
}
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
279
|
+
const rootDependencies = {};
|
|
280
|
+
if (!global && !isTransitive) {
|
|
281
|
+
const existingSkills = (skillsJson.skills ?? {});
|
|
282
|
+
const lockedVersionByName = buildLockedVersionByName(lock);
|
|
283
|
+
for (const [skillName, range] of Object.entries(existingSkills)) {
|
|
284
|
+
if (typeof range !== 'string') {
|
|
285
|
+
continue;
|
|
286
|
+
}
|
|
287
|
+
rootDependencies[skillName] = lockedVersionByName.get(skillName) ?? range;
|
|
242
288
|
}
|
|
243
289
|
}
|
|
290
|
+
rootDependencies[name] = versionRange;
|
|
291
|
+
const resolvedGraph = await resolveDependencyTree(rootDependencies, fetcher);
|
|
292
|
+
const resolvedNodes = getResolvedNodesInOrder(resolvedGraph.nodes, resolvedGraph.installOrder);
|
|
293
|
+
const rootNode = resolvedGraph.nodes.get(name);
|
|
294
|
+
if (!rootNode) {
|
|
295
|
+
throw new Error(`Failed to resolve requested skill: ${name}`);
|
|
296
|
+
}
|
|
297
|
+
const nodesToInstall = resolvedNodes.filter((node) => {
|
|
298
|
+
const lockKey = buildSkillKey(node.name, node.version);
|
|
299
|
+
return !lock.skills[lockKey];
|
|
300
|
+
});
|
|
301
|
+
const projectPermissions = global ? undefined : skillsJson.permissions;
|
|
302
|
+
const auditMinScore = global ? undefined : skillsJson.audit?.min_score;
|
|
303
|
+
await executeInstallPipeline({
|
|
304
|
+
directory,
|
|
305
|
+
configDir,
|
|
306
|
+
global,
|
|
307
|
+
homedir,
|
|
308
|
+
resolvedHome,
|
|
309
|
+
lock,
|
|
310
|
+
lockPath,
|
|
311
|
+
resolvedNodes,
|
|
312
|
+
nodesToInstall,
|
|
313
|
+
rootSkillNames: [name],
|
|
314
|
+
projectPermissions,
|
|
315
|
+
auditMinScore,
|
|
316
|
+
spinner,
|
|
317
|
+
});
|
|
318
|
+
if (!global && !isTransitive) {
|
|
319
|
+
const skills = (skillsJson.skills ?? {});
|
|
320
|
+
skills[name] = versionRange === '*' ? `^${rootNode.version}` : versionRange;
|
|
321
|
+
skillsJson.skills = skills;
|
|
322
|
+
fs.writeFileSync(skillsJsonPath, JSON.stringify(skillsJson, null, 2) + '\n');
|
|
323
|
+
}
|
|
324
|
+
spinner.succeed(`Installed ${name}@${rootNode.version}`);
|
|
244
325
|
}
|
|
245
|
-
catch {
|
|
246
|
-
|
|
326
|
+
catch (err) {
|
|
327
|
+
spinner.fail('Install failed');
|
|
328
|
+
throw err;
|
|
247
329
|
}
|
|
248
|
-
spinner.succeed(`Installed ${name}@${resolved}`);
|
|
249
330
|
}
|
|
250
331
|
export async function installFromLockfile(options) {
|
|
251
332
|
const { directory = process.cwd(), configDir, global = false, homedir } = options;
|
|
@@ -283,7 +364,6 @@ export async function installFromLockfile(options) {
|
|
|
283
364
|
const skillName = parseLockKey(key);
|
|
284
365
|
const version = parseVersionFromLockKey(key);
|
|
285
366
|
spinner.text = `Installing ${key}...`;
|
|
286
|
-
// Fetch metadata from API to record download and get fresh signed URL
|
|
287
367
|
const encodedName = encodeURIComponent(skillName);
|
|
288
368
|
const metaUrl = `${config.registry}/api/v1/skills/${encodedName}/${version}`;
|
|
289
369
|
let metaRes;
|
|
@@ -309,8 +389,7 @@ export async function installFromLockfile(options) {
|
|
|
309
389
|
throw new Error(`Failed to download ${key}: ${downloadRes.status} ${downloadRes.statusText}`);
|
|
310
390
|
}
|
|
311
391
|
const tarballBuffer = Buffer.from(await downloadRes.arrayBuffer());
|
|
312
|
-
const
|
|
313
|
-
const computedIntegrity = `sha512-${hash}`;
|
|
392
|
+
const computedIntegrity = buildIntegrity(tarballBuffer);
|
|
314
393
|
if (computedIntegrity !== entry.integrity) {
|
|
315
394
|
throw new Error(`Integrity mismatch for ${key}. Expected: ${entry.integrity}, Got: ${computedIntegrity}`);
|
|
316
395
|
}
|
|
@@ -345,8 +424,8 @@ export async function installFromLockfile(options) {
|
|
|
345
424
|
logger.info(`Linked to ${linkResult.linked.length} agent(s)`);
|
|
346
425
|
}
|
|
347
426
|
if (linkResult.failed.length > 0) {
|
|
348
|
-
for (const
|
|
349
|
-
logger.warn(`Failed to link to ${
|
|
427
|
+
for (const failedLink of linkResult.failed) {
|
|
428
|
+
logger.warn(`Failed to link to ${failedLink.agentId}: ${failedLink.error}`);
|
|
350
429
|
}
|
|
351
430
|
}
|
|
352
431
|
}
|
|
@@ -368,6 +447,11 @@ export async function installFromLockfile(options) {
|
|
|
368
447
|
export async function installAll(options) {
|
|
369
448
|
const { directory = process.cwd(), configDir, global = false, homedir } = options;
|
|
370
449
|
const resolvedHome = homedir ?? os.homedir();
|
|
450
|
+
const config = getConfig(configDir);
|
|
451
|
+
const requestHeaders = { 'User-Agent': USER_AGENT };
|
|
452
|
+
if (config.token) {
|
|
453
|
+
requestHeaders.Authorization = `Bearer ${config.token}`;
|
|
454
|
+
}
|
|
371
455
|
const lockPath = global
|
|
372
456
|
? path.join(resolvedHome, '.tank', 'skills.lock')
|
|
373
457
|
: path.join(directory, 'skills.lock');
|
|
@@ -383,168 +467,51 @@ export async function installAll(options) {
|
|
|
383
467
|
logger.info('No skills.json found — nothing to install');
|
|
384
468
|
return;
|
|
385
469
|
}
|
|
386
|
-
|
|
387
|
-
try {
|
|
388
|
-
const raw = fs.readFileSync(skillsJsonPath, 'utf-8');
|
|
389
|
-
skillsJson = JSON.parse(raw);
|
|
390
|
-
}
|
|
391
|
-
catch {
|
|
392
|
-
throw new Error('Failed to read or parse skills.json');
|
|
393
|
-
}
|
|
470
|
+
const skillsJson = readSkillsJson(skillsJsonPath);
|
|
394
471
|
const skills = (skillsJson.skills ?? {});
|
|
395
472
|
const skillEntries = Object.entries(skills);
|
|
396
473
|
if (skillEntries.length === 0) {
|
|
397
474
|
logger.info('No skills defined in skills.json');
|
|
398
475
|
return;
|
|
399
476
|
}
|
|
400
|
-
|
|
401
|
-
await installCommand({ name, versionRange, directory, configDir, global, homedir });
|
|
402
|
-
}
|
|
403
|
-
}
|
|
404
|
-
function parseLockKey(key) {
|
|
405
|
-
const lastAt = key.lastIndexOf('@');
|
|
406
|
-
if (lastAt <= 0) {
|
|
407
|
-
throw new Error(`Invalid lockfile key: ${key}`);
|
|
408
|
-
}
|
|
409
|
-
return key.slice(0, lastAt);
|
|
410
|
-
}
|
|
411
|
-
function parseVersionFromLockKey(key) {
|
|
412
|
-
const lastAt = key.lastIndexOf('@');
|
|
413
|
-
if (lastAt <= 0 || lastAt === key.length - 1) {
|
|
414
|
-
throw new Error(`Invalid lockfile key: ${key}`);
|
|
415
|
-
}
|
|
416
|
-
return key.slice(lastAt + 1);
|
|
417
|
-
}
|
|
418
|
-
function getExtractDir(projectDir, skillName) {
|
|
419
|
-
if (skillName.startsWith('@')) {
|
|
420
|
-
const [scope, name] = skillName.split('/');
|
|
421
|
-
return path.join(projectDir, '.tank', 'skills', scope, name);
|
|
422
|
-
}
|
|
423
|
-
return path.join(projectDir, '.tank', 'skills', skillName);
|
|
424
|
-
}
|
|
425
|
-
function getGlobalExtractDir(homedir, skillName) {
|
|
426
|
-
const globalDir = path.join(homedir, '.tank', 'skills');
|
|
427
|
-
if (skillName.startsWith('@')) {
|
|
428
|
-
const [scope, name] = skillName.split('/');
|
|
429
|
-
return path.join(globalDir, scope, name);
|
|
430
|
-
}
|
|
431
|
-
return path.join(globalDir, skillName);
|
|
432
|
-
}
|
|
433
|
-
/**
|
|
434
|
-
* Extract a tarball safely with security checks.
|
|
435
|
-
* Rejects: absolute paths, path traversal (..), symlinks/hardlinks.
|
|
436
|
-
* Enforces max uncompressed size.
|
|
437
|
-
*/
|
|
438
|
-
async function extractSafely(tarball, destDir) {
|
|
439
|
-
// Write tarball to a temp file for extraction
|
|
440
|
-
const tmpTarball = path.join(destDir, '.tmp-tarball.tgz');
|
|
441
|
-
fs.writeFileSync(tmpTarball, tarball);
|
|
477
|
+
const spinner = ora('Resolving dependency graph...').start();
|
|
442
478
|
try {
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
filter: (entryPath) => {
|
|
448
|
-
// Reject absolute paths
|
|
449
|
-
if (path.isAbsolute(entryPath)) {
|
|
450
|
-
throw new Error(`Absolute path in tarball: ${entryPath}`);
|
|
451
|
-
}
|
|
452
|
-
// Reject path traversal
|
|
453
|
-
if (entryPath.split('/').includes('..') || entryPath.split(path.sep).includes('..')) {
|
|
454
|
-
throw new Error(`Path traversal in tarball: ${entryPath}`);
|
|
455
|
-
}
|
|
456
|
-
return true;
|
|
457
|
-
},
|
|
458
|
-
onReadEntry: (entry) => {
|
|
459
|
-
// Reject symlinks and hardlinks
|
|
460
|
-
if (entry.type === 'SymbolicLink' || entry.type === 'Link') {
|
|
461
|
-
throw new Error(`Symlink/hardlink in tarball: ${entry.path}`);
|
|
462
|
-
}
|
|
463
|
-
},
|
|
464
|
-
});
|
|
465
|
-
}
|
|
466
|
-
finally {
|
|
467
|
-
// Clean up temp tarball
|
|
468
|
-
if (fs.existsSync(tmpTarball)) {
|
|
469
|
-
fs.unlinkSync(tmpTarball);
|
|
470
|
-
}
|
|
471
|
-
}
|
|
472
|
-
}
|
|
473
|
-
/**
|
|
474
|
-
* Check if a skill's permissions fit within the project's permission budget.
|
|
475
|
-
* Throws if any permission exceeds the budget.
|
|
476
|
-
*/
|
|
477
|
-
function checkPermissionBudget(budget, skillPerms, skillName) {
|
|
478
|
-
if (!skillPerms)
|
|
479
|
-
return;
|
|
480
|
-
// Check subprocess
|
|
481
|
-
if (skillPerms.subprocess === true && budget.subprocess !== true) {
|
|
482
|
-
throw new Error(`Permission denied: ${skillName} requires subprocess access, but project budget does not allow it`);
|
|
483
|
-
}
|
|
484
|
-
// Check network outbound
|
|
485
|
-
if (skillPerms.network?.outbound && skillPerms.network.outbound.length > 0) {
|
|
486
|
-
const budgetDomains = budget.network?.outbound ?? [];
|
|
487
|
-
for (const domain of skillPerms.network.outbound) {
|
|
488
|
-
if (!isDomainAllowed(domain, budgetDomains)) {
|
|
489
|
-
throw new Error(`Permission denied: ${skillName} requests network access to "${domain}", which is not in the project's permission budget`);
|
|
490
|
-
}
|
|
491
|
-
}
|
|
492
|
-
}
|
|
493
|
-
// Check filesystem read
|
|
494
|
-
if (skillPerms.filesystem?.read && skillPerms.filesystem.read.length > 0) {
|
|
495
|
-
const budgetPaths = budget.filesystem?.read ?? [];
|
|
496
|
-
for (const p of skillPerms.filesystem.read) {
|
|
497
|
-
if (!isPathAllowed(p, budgetPaths)) {
|
|
498
|
-
throw new Error(`Permission denied: ${skillName} requests filesystem read access to "${p}", which is not in the project's permission budget`);
|
|
499
|
-
}
|
|
500
|
-
}
|
|
501
|
-
}
|
|
502
|
-
// Check filesystem write
|
|
503
|
-
if (skillPerms.filesystem?.write && skillPerms.filesystem.write.length > 0) {
|
|
504
|
-
const budgetPaths = budget.filesystem?.write ?? [];
|
|
505
|
-
for (const p of skillPerms.filesystem.write) {
|
|
506
|
-
if (!isPathAllowed(p, budgetPaths)) {
|
|
507
|
-
throw new Error(`Permission denied: ${skillName} requests filesystem write access to "${p}", which is not in the project's permission budget`);
|
|
479
|
+
const rootDependencies = {};
|
|
480
|
+
for (const [skillName, range] of skillEntries) {
|
|
481
|
+
if (typeof range === 'string') {
|
|
482
|
+
rootDependencies[skillName] = range;
|
|
508
483
|
}
|
|
509
484
|
}
|
|
485
|
+
const fetcher = createRegistryFetcher(config.registry, requestHeaders);
|
|
486
|
+
const resolvedGraph = await resolveDependencyTree(rootDependencies, fetcher);
|
|
487
|
+
const resolvedNodes = getResolvedNodesInOrder(resolvedGraph.nodes, resolvedGraph.installOrder);
|
|
488
|
+
const lock = { lockfileVersion: LOCKFILE_VERSION, skills: {} };
|
|
489
|
+
const projectPermissions = skillsJson.permissions;
|
|
490
|
+
const auditMinScore = skillsJson.audit?.min_score;
|
|
491
|
+
await executeInstallPipeline({
|
|
492
|
+
directory,
|
|
493
|
+
configDir,
|
|
494
|
+
global,
|
|
495
|
+
homedir,
|
|
496
|
+
resolvedHome,
|
|
497
|
+
lock,
|
|
498
|
+
lockPath,
|
|
499
|
+
resolvedNodes,
|
|
500
|
+
nodesToInstall: resolvedNodes,
|
|
501
|
+
rootSkillNames: skillEntries.map(([skillName]) => skillName),
|
|
502
|
+
projectPermissions,
|
|
503
|
+
auditMinScore,
|
|
504
|
+
spinner,
|
|
505
|
+
});
|
|
506
|
+
spinner.succeed(`Installed ${skillEntries.length} root skill${skillEntries.length === 1 ? '' : 's'}`);
|
|
510
507
|
}
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
* Supports wildcard matching: *.example.com matches sub.example.com
|
|
515
|
-
*/
|
|
516
|
-
function isDomainAllowed(domain, allowedDomains) {
|
|
517
|
-
for (const allowed of allowedDomains) {
|
|
518
|
-
if (allowed === domain)
|
|
519
|
-
return true;
|
|
520
|
-
// Wildcard matching: *.example.com
|
|
521
|
-
if (allowed.startsWith('*.')) {
|
|
522
|
-
const suffix = allowed.slice(1); // .example.com
|
|
523
|
-
if (domain.endsWith(suffix) || domain === allowed.slice(2)) {
|
|
524
|
-
return true;
|
|
525
|
-
}
|
|
526
|
-
// Also match if the skill requests the same wildcard pattern
|
|
527
|
-
if (domain === allowed)
|
|
528
|
-
return true;
|
|
529
|
-
}
|
|
508
|
+
catch (err) {
|
|
509
|
+
spinner.fail('Install failed');
|
|
510
|
+
throw err;
|
|
530
511
|
}
|
|
531
|
-
return false;
|
|
532
512
|
}
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
*/
|
|
537
|
-
function isPathAllowed(requestedPath, allowedPaths) {
|
|
538
|
-
for (const allowed of allowedPaths) {
|
|
539
|
-
if (allowed === requestedPath)
|
|
540
|
-
return true;
|
|
541
|
-
// If budget allows ./src/** and skill requests ./src/foo, it's allowed
|
|
542
|
-
if (allowed.endsWith('/**')) {
|
|
543
|
-
const prefix = allowed.slice(0, -3); // ./src
|
|
544
|
-
if (requestedPath.startsWith(prefix))
|
|
545
|
-
return true;
|
|
546
|
-
}
|
|
547
|
-
}
|
|
548
|
-
return false;
|
|
513
|
+
function buildIntegrity(buffer) {
|
|
514
|
+
const hash = crypto.createHash('sha512').update(buffer).digest('base64');
|
|
515
|
+
return `sha512-${hash}`;
|
|
549
516
|
}
|
|
550
517
|
//# sourceMappingURL=install.js.map
|