@enactprotocol/cli 1.2.13 → 2.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +88 -0
- package/package.json +34 -38
- package/src/commands/auth/index.ts +940 -0
- package/src/commands/cache/index.ts +361 -0
- package/src/commands/config/README.md +239 -0
- package/src/commands/config/index.ts +164 -0
- package/src/commands/env/README.md +197 -0
- package/src/commands/env/index.ts +392 -0
- package/src/commands/exec/README.md +110 -0
- package/src/commands/exec/index.ts +195 -0
- package/src/commands/get/index.ts +198 -0
- package/src/commands/index.ts +30 -0
- package/src/commands/inspect/index.ts +264 -0
- package/src/commands/install/README.md +146 -0
- package/src/commands/install/index.ts +682 -0
- package/src/commands/list/README.md +115 -0
- package/src/commands/list/index.ts +138 -0
- package/src/commands/publish/index.ts +350 -0
- package/src/commands/report/index.ts +366 -0
- package/src/commands/run/README.md +124 -0
- package/src/commands/run/index.ts +686 -0
- package/src/commands/search/index.ts +368 -0
- package/src/commands/setup/index.ts +274 -0
- package/src/commands/sign/index.ts +652 -0
- package/src/commands/trust/README.md +214 -0
- package/src/commands/trust/index.ts +453 -0
- package/src/commands/unyank/index.ts +107 -0
- package/src/commands/yank/index.ts +143 -0
- package/src/index.ts +96 -0
- package/src/types.ts +81 -0
- package/src/utils/errors.ts +409 -0
- package/src/utils/exit-codes.ts +159 -0
- package/src/utils/ignore.ts +147 -0
- package/src/utils/index.ts +107 -0
- package/src/utils/output.ts +242 -0
- package/src/utils/spinner.ts +214 -0
- package/tests/commands/auth.test.ts +217 -0
- package/tests/commands/cache.test.ts +286 -0
- package/tests/commands/config.test.ts +277 -0
- package/tests/commands/env.test.ts +293 -0
- package/tests/commands/exec.test.ts +112 -0
- package/tests/commands/get.test.ts +179 -0
- package/tests/commands/inspect.test.ts +201 -0
- package/tests/commands/install-integration.test.ts +343 -0
- package/tests/commands/install.test.ts +288 -0
- package/tests/commands/list.test.ts +160 -0
- package/tests/commands/publish.test.ts +186 -0
- package/tests/commands/report.test.ts +194 -0
- package/tests/commands/run.test.ts +231 -0
- package/tests/commands/search.test.ts +131 -0
- package/tests/commands/sign.test.ts +164 -0
- package/tests/commands/trust.test.ts +236 -0
- package/tests/commands/unyank.test.ts +114 -0
- package/tests/commands/yank.test.ts +154 -0
- package/tests/e2e.test.ts +554 -0
- package/tests/fixtures/calculator/enact.yaml +34 -0
- package/tests/fixtures/echo-tool/enact.md +31 -0
- package/tests/fixtures/env-tool/enact.yaml +19 -0
- package/tests/fixtures/greeter/enact.yaml +18 -0
- package/tests/fixtures/invalid-tool/enact.yaml +4 -0
- package/tests/index.test.ts +8 -0
- package/tests/types.test.ts +84 -0
- package/tests/utils/errors.test.ts +303 -0
- package/tests/utils/exit-codes.test.ts +189 -0
- package/tests/utils/ignore.test.ts +461 -0
- package/tests/utils/output.test.ts +126 -0
- package/tsconfig.json +17 -0
- package/tsconfig.tsbuildinfo +1 -0
- package/dist/index.js +0 -231612
- package/dist/index.js.bak +0 -231611
- package/dist/web/static/app.js +0 -663
- package/dist/web/static/index.html +0 -117
- package/dist/web/static/style.css +0 -291
|
@@ -0,0 +1,682 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* enact install command
|
|
3
|
+
*
|
|
4
|
+
* Install a tool to the project or globally.
|
|
5
|
+
* All tools are extracted to ~/.enact/cache/{tool}/{version}/
|
|
6
|
+
* - Project install: Adds entry to .enact/tools.json
|
|
7
|
+
* - Global install: Adds entry to ~/.enact/tools.json
|
|
8
|
+
*
|
|
9
|
+
* Supports local paths and registry tools with verification.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { cpSync, existsSync, mkdirSync, rmSync, unlinkSync, writeFileSync } from "node:fs";
|
|
13
|
+
import { dirname, join, resolve } from "node:path";
|
|
14
|
+
import {
|
|
15
|
+
type AttestationListResponse,
|
|
16
|
+
type ToolVersionInfo,
|
|
17
|
+
createApiClient,
|
|
18
|
+
downloadBundle,
|
|
19
|
+
getAttestationList,
|
|
20
|
+
getToolInfo,
|
|
21
|
+
getToolVersion,
|
|
22
|
+
verifyAllAttestations,
|
|
23
|
+
} from "@enactprotocol/api";
|
|
24
|
+
import {
|
|
25
|
+
addToolToRegistry,
|
|
26
|
+
getCacheDir,
|
|
27
|
+
getInstalledVersion,
|
|
28
|
+
getMinimumAttestations,
|
|
29
|
+
getProjectEnactDir,
|
|
30
|
+
getToolCachePath,
|
|
31
|
+
getTrustPolicy,
|
|
32
|
+
getTrustedAuditors,
|
|
33
|
+
loadConfig,
|
|
34
|
+
loadManifestFromDir,
|
|
35
|
+
pathExists,
|
|
36
|
+
} from "@enactprotocol/shared";
|
|
37
|
+
// Trust verification is done using @enactprotocol/api functions
|
|
38
|
+
import type { Command } from "commander";
|
|
39
|
+
import type { CommandContext, GlobalOptions } from "../../types";
|
|
40
|
+
import {
|
|
41
|
+
EXIT_FAILURE,
|
|
42
|
+
EXIT_TRUST_ERROR,
|
|
43
|
+
ManifestError,
|
|
44
|
+
RegistryError,
|
|
45
|
+
TrustError,
|
|
46
|
+
colors,
|
|
47
|
+
confirm,
|
|
48
|
+
dim,
|
|
49
|
+
error,
|
|
50
|
+
formatError,
|
|
51
|
+
handleError,
|
|
52
|
+
info,
|
|
53
|
+
json,
|
|
54
|
+
keyValue,
|
|
55
|
+
newline,
|
|
56
|
+
success,
|
|
57
|
+
suggest,
|
|
58
|
+
symbols,
|
|
59
|
+
withSpinner,
|
|
60
|
+
} from "../../utils";
|
|
61
|
+
|
|
62
|
+
interface InstallOptions extends GlobalOptions {
|
|
63
|
+
global?: boolean;
|
|
64
|
+
force?: boolean;
|
|
65
|
+
allowYanked?: boolean;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Parse tool@version syntax
|
|
70
|
+
*/
|
|
71
|
+
function parseToolSpec(spec: string): { name: string; version: string | undefined } {
|
|
72
|
+
// Handle scoped packages like @scope/tool@version
|
|
73
|
+
const match = spec.match(/^(@[^@/]+\/[^@]+|[^@]+)(?:@(.+))?$/);
|
|
74
|
+
if (match?.[1]) {
|
|
75
|
+
return {
|
|
76
|
+
name: match[1],
|
|
77
|
+
version: match[2],
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
return { name: spec, version: undefined };
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Copy a directory recursively
|
|
85
|
+
*/
|
|
86
|
+
function copyDir(src: string, dest: string): void {
|
|
87
|
+
mkdirSync(dest, { recursive: true });
|
|
88
|
+
cpSync(src, dest, { recursive: true });
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Check if a tool name looks like a local path
|
|
93
|
+
* Local paths start with ./, ../, or /
|
|
94
|
+
*/
|
|
95
|
+
function isLocalPath(toolName: string): boolean {
|
|
96
|
+
return (
|
|
97
|
+
toolName === "." ||
|
|
98
|
+
toolName.startsWith("./") ||
|
|
99
|
+
toolName.startsWith("../") ||
|
|
100
|
+
toolName.startsWith("/")
|
|
101
|
+
);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Extract a tar.gz bundle to a directory
|
|
106
|
+
* Uses tar command (available on all supported platforms)
|
|
107
|
+
*/
|
|
108
|
+
async function extractBundle(bundleData: ArrayBuffer, destPath: string): Promise<void> {
|
|
109
|
+
// Create a temporary file for the bundle
|
|
110
|
+
const tempFile = join(getCacheDir(), `bundle-${Date.now()}.tar.gz`);
|
|
111
|
+
mkdirSync(dirname(tempFile), { recursive: true });
|
|
112
|
+
writeFileSync(tempFile, Buffer.from(bundleData));
|
|
113
|
+
|
|
114
|
+
// Create destination directory
|
|
115
|
+
mkdirSync(destPath, { recursive: true });
|
|
116
|
+
|
|
117
|
+
// Extract using tar command (available on all supported platforms)
|
|
118
|
+
const proc = Bun.spawn(["tar", "-xzf", tempFile, "-C", destPath], {
|
|
119
|
+
stdout: "pipe",
|
|
120
|
+
stderr: "pipe",
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
const exitCode = await proc.exited;
|
|
124
|
+
|
|
125
|
+
// Clean up temp file
|
|
126
|
+
try {
|
|
127
|
+
unlinkSync(tempFile);
|
|
128
|
+
} catch {
|
|
129
|
+
// Ignore cleanup errors
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
if (exitCode !== 0) {
|
|
133
|
+
const stderr = await new Response(proc.stderr).text();
|
|
134
|
+
throw new Error(`Failed to extract bundle: ${stderr}`);
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* Format bytes to human-readable string
|
|
140
|
+
*/
|
|
141
|
+
function formatBytes(bytes: number): string {
|
|
142
|
+
if (bytes === 0) return "0 B";
|
|
143
|
+
const k = 1024;
|
|
144
|
+
const sizes = ["B", "KB", "MB", "GB"];
|
|
145
|
+
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
|
146
|
+
return `${(bytes / k ** i).toFixed(1)} ${sizes[i]}`;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* Install from the registry
|
|
151
|
+
*/
|
|
152
|
+
async function installFromRegistry(
|
|
153
|
+
toolName: string,
|
|
154
|
+
version: string | undefined,
|
|
155
|
+
options: InstallOptions,
|
|
156
|
+
ctx: CommandContext
|
|
157
|
+
): Promise<void> {
|
|
158
|
+
const config = loadConfig();
|
|
159
|
+
const registryUrl =
|
|
160
|
+
process.env.ENACT_REGISTRY_URL ??
|
|
161
|
+
config.registry?.url ??
|
|
162
|
+
"https://siikwkfgsmouioodghho.supabase.co/functions/v1";
|
|
163
|
+
const authToken = config.registry?.authToken;
|
|
164
|
+
const client = createApiClient({
|
|
165
|
+
baseUrl: registryUrl,
|
|
166
|
+
authToken: authToken,
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
// Get tool info to find latest version if not specified
|
|
170
|
+
let targetVersion = version;
|
|
171
|
+
let toolInfo: ToolVersionInfo;
|
|
172
|
+
|
|
173
|
+
try {
|
|
174
|
+
if (!targetVersion) {
|
|
175
|
+
// Fetch tool metadata to get latest version
|
|
176
|
+
const metadata = await withSpinner(
|
|
177
|
+
`Fetching ${toolName} info...`,
|
|
178
|
+
async () => getToolInfo(client, toolName),
|
|
179
|
+
`${symbols.success} Found ${toolName}`
|
|
180
|
+
);
|
|
181
|
+
targetVersion = metadata.latestVersion;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
// Get version-specific info
|
|
185
|
+
toolInfo = await withSpinner(
|
|
186
|
+
`Fetching ${toolName}@${targetVersion} details...`,
|
|
187
|
+
async () => getToolVersion(client, toolName, targetVersion!),
|
|
188
|
+
`${symbols.success} Got version details`
|
|
189
|
+
);
|
|
190
|
+
} catch (err) {
|
|
191
|
+
throw new RegistryError(`Failed to fetch tool info: ${formatError(err)}`);
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
// Check if version is yanked
|
|
195
|
+
if (toolInfo.yanked) {
|
|
196
|
+
const yankMessage = toolInfo.yankReason
|
|
197
|
+
? `Version ${targetVersion} has been yanked: ${toolInfo.yankReason}`
|
|
198
|
+
: `Version ${targetVersion} has been yanked`;
|
|
199
|
+
|
|
200
|
+
if (options.allowYanked) {
|
|
201
|
+
info(`${symbols.warning} ${yankMessage}`);
|
|
202
|
+
if (toolInfo.yankReplacement) {
|
|
203
|
+
dim(` Recommended replacement: ${toolInfo.yankReplacement}`);
|
|
204
|
+
}
|
|
205
|
+
} else if (ctx.isInteractive) {
|
|
206
|
+
info(`${symbols.warning} ${yankMessage}`);
|
|
207
|
+
if (toolInfo.yankReplacement) {
|
|
208
|
+
info(`Recommended replacement: ${toolInfo.yankReplacement}`);
|
|
209
|
+
const useReplacement = await confirm(`Install ${toolInfo.yankReplacement} instead?`);
|
|
210
|
+
if (useReplacement) {
|
|
211
|
+
// Recursively install the replacement version
|
|
212
|
+
return installFromRegistry(toolName, toolInfo.yankReplacement, options, ctx);
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
const proceed = await confirm("Install yanked version anyway?");
|
|
216
|
+
if (!proceed) {
|
|
217
|
+
info("Installation cancelled.");
|
|
218
|
+
process.exit(0);
|
|
219
|
+
}
|
|
220
|
+
} else {
|
|
221
|
+
error(`${yankMessage}`);
|
|
222
|
+
if (toolInfo.yankReplacement) {
|
|
223
|
+
info(`Recommended replacement: ${toolInfo.yankReplacement}`);
|
|
224
|
+
suggest(`Run: enact install ${toolName}@${toolInfo.yankReplacement}`);
|
|
225
|
+
}
|
|
226
|
+
info("Use --allow-yanked to install yanked versions.");
|
|
227
|
+
process.exit(EXIT_FAILURE);
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
// Check trust policy - fetch and verify attestations
|
|
232
|
+
try {
|
|
233
|
+
const trustPolicy = getTrustPolicy();
|
|
234
|
+
const minimumAttestations = getMinimumAttestations();
|
|
235
|
+
const trustedAuditors = getTrustedAuditors();
|
|
236
|
+
|
|
237
|
+
// Fetch attestations from registry
|
|
238
|
+
const attestationsResponse: AttestationListResponse = await getAttestationList(
|
|
239
|
+
client,
|
|
240
|
+
toolName,
|
|
241
|
+
targetVersion!
|
|
242
|
+
);
|
|
243
|
+
const attestations = attestationsResponse.attestations;
|
|
244
|
+
|
|
245
|
+
if (attestations.length === 0) {
|
|
246
|
+
// No attestations found
|
|
247
|
+
info(`${symbols.warning} Tool ${toolName}@${targetVersion} has no attestations.`);
|
|
248
|
+
|
|
249
|
+
if (trustPolicy === "require_attestation") {
|
|
250
|
+
error("Trust policy requires attestations. Installation blocked.");
|
|
251
|
+
info("Configure trust policy in ~/.enact/config.yaml");
|
|
252
|
+
process.exit(EXIT_TRUST_ERROR);
|
|
253
|
+
} else if (ctx.isInteractive && trustPolicy === "prompt") {
|
|
254
|
+
const proceed = await confirm("Install unverified tool?");
|
|
255
|
+
if (!proceed) {
|
|
256
|
+
info("Installation cancelled.");
|
|
257
|
+
process.exit(0);
|
|
258
|
+
}
|
|
259
|
+
} else if (!ctx.isInteractive && trustPolicy === "prompt") {
|
|
260
|
+
error("Cannot install unverified tools in non-interactive mode.");
|
|
261
|
+
info("Run interactively to confirm installation.");
|
|
262
|
+
process.exit(EXIT_TRUST_ERROR);
|
|
263
|
+
}
|
|
264
|
+
// trustPolicy === "allow" - continue without prompting
|
|
265
|
+
} else {
|
|
266
|
+
// Verify attestations locally (never trust registry's verification status)
|
|
267
|
+
const verifiedAuditors = await verifyAllAttestations(
|
|
268
|
+
client,
|
|
269
|
+
toolName,
|
|
270
|
+
targetVersion!,
|
|
271
|
+
toolInfo.bundle?.hash ?? ""
|
|
272
|
+
);
|
|
273
|
+
|
|
274
|
+
// Check verified auditors against trust config using provider:identity format
|
|
275
|
+
const trustedVerifiedAuditors = verifiedAuditors
|
|
276
|
+
.filter((auditor) => trustedAuditors.includes(auditor.providerIdentity))
|
|
277
|
+
.map((auditor) => auditor.providerIdentity);
|
|
278
|
+
|
|
279
|
+
if (trustedVerifiedAuditors.length > 0) {
|
|
280
|
+
// Check if we meet minimum attestations threshold
|
|
281
|
+
if (trustedVerifiedAuditors.length < minimumAttestations) {
|
|
282
|
+
info(
|
|
283
|
+
`${symbols.warning} Tool ${toolName}@${targetVersion} has ${trustedVerifiedAuditors.length} trusted attestation(s), but ${minimumAttestations} required.`
|
|
284
|
+
);
|
|
285
|
+
dim(`Trusted attestations: ${trustedVerifiedAuditors.join(", ")}`);
|
|
286
|
+
|
|
287
|
+
if (trustPolicy === "require_attestation") {
|
|
288
|
+
error(
|
|
289
|
+
`Trust policy requires at least ${minimumAttestations} attestation(s) from trusted identities. Installation blocked.`
|
|
290
|
+
);
|
|
291
|
+
suggest("Run 'enact trust <identity>' to add more trusted identities");
|
|
292
|
+
process.exit(EXIT_TRUST_ERROR);
|
|
293
|
+
} else if (ctx.isInteractive && trustPolicy === "prompt") {
|
|
294
|
+
const proceed = await confirm("Install with fewer attestations than required?");
|
|
295
|
+
if (!proceed) {
|
|
296
|
+
info("Installation cancelled.");
|
|
297
|
+
process.exit(0);
|
|
298
|
+
}
|
|
299
|
+
} else if (!ctx.isInteractive && trustPolicy === "prompt") {
|
|
300
|
+
error(
|
|
301
|
+
"Cannot install tool without meeting minimum attestation requirement in non-interactive mode."
|
|
302
|
+
);
|
|
303
|
+
process.exit(EXIT_TRUST_ERROR);
|
|
304
|
+
}
|
|
305
|
+
// trustPolicy === "allow" - continue without prompting
|
|
306
|
+
} else {
|
|
307
|
+
// Tool meets or exceeds minimum attestations
|
|
308
|
+
if (options.verbose) {
|
|
309
|
+
success(
|
|
310
|
+
`Tool verified by ${trustedVerifiedAuditors.length} trusted identity(ies): ${trustedVerifiedAuditors.join(", ")}`
|
|
311
|
+
);
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
} else {
|
|
315
|
+
// Has attestations but none from trusted auditors
|
|
316
|
+
info(
|
|
317
|
+
`${symbols.warning} Tool ${toolName}@${targetVersion} has ${verifiedAuditors.length} attestation(s), but none from trusted auditors.`
|
|
318
|
+
);
|
|
319
|
+
|
|
320
|
+
if (trustPolicy === "require_attestation") {
|
|
321
|
+
error(
|
|
322
|
+
"Trust policy requires attestations from trusted identities. Installation blocked."
|
|
323
|
+
);
|
|
324
|
+
dim(`Your trusted auditors: ${trustedAuditors.join(", ")}`);
|
|
325
|
+
dim(`Tool attested by: ${verifiedAuditors.map((a) => a.providerIdentity).join(", ")}`);
|
|
326
|
+
suggest("Run 'enact trust <auditor>' to add a trusted auditor");
|
|
327
|
+
process.exit(EXIT_TRUST_ERROR);
|
|
328
|
+
} else if (ctx.isInteractive && trustPolicy === "prompt") {
|
|
329
|
+
dim(`Attested by: ${verifiedAuditors.map((a) => a.providerIdentity).join(", ")}`);
|
|
330
|
+
dim(`Your trusted auditors: ${trustedAuditors.join(", ")}`);
|
|
331
|
+
const proceed = await confirm("Install anyway?");
|
|
332
|
+
if (!proceed) {
|
|
333
|
+
info("Installation cancelled.");
|
|
334
|
+
process.exit(0);
|
|
335
|
+
}
|
|
336
|
+
} else if (!ctx.isInteractive && trustPolicy === "prompt") {
|
|
337
|
+
error("Cannot install tool without trusted attestations in non-interactive mode.");
|
|
338
|
+
process.exit(EXIT_TRUST_ERROR);
|
|
339
|
+
}
|
|
340
|
+
// trustPolicy === "allow" - continue without prompting
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
} catch (err) {
|
|
344
|
+
if (options.verbose) {
|
|
345
|
+
dim(`Trust check failed: ${formatError(err)}`);
|
|
346
|
+
}
|
|
347
|
+
// Continue with installation if trust check fails (with warning)
|
|
348
|
+
info(`${symbols.warning} Could not verify trust status. Proceeding with caution.`);
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
// Download bundle
|
|
352
|
+
let bundleResult: { data: ArrayBuffer; hash: string; size: number };
|
|
353
|
+
try {
|
|
354
|
+
bundleResult = await withSpinner(
|
|
355
|
+
`Downloading ${toolName}@${targetVersion}...`,
|
|
356
|
+
async () =>
|
|
357
|
+
downloadBundle(client, {
|
|
358
|
+
name: toolName,
|
|
359
|
+
version: targetVersion!,
|
|
360
|
+
verify: true,
|
|
361
|
+
acknowledgeYanked: toolInfo.yanked === true || options.allowYanked === true,
|
|
362
|
+
}),
|
|
363
|
+
`${symbols.success} Downloaded`
|
|
364
|
+
);
|
|
365
|
+
} catch (err) {
|
|
366
|
+
throw new RegistryError(`Failed to download bundle: ${formatError(err)}`);
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
// Verify hash if provided
|
|
370
|
+
if (toolInfo.bundle?.hash) {
|
|
371
|
+
const downloadedHash = bundleResult.hash.replace("sha256:", "");
|
|
372
|
+
const expectedHash = toolInfo.bundle.hash.replace("sha256:", "");
|
|
373
|
+
|
|
374
|
+
if (downloadedHash !== expectedHash) {
|
|
375
|
+
throw new TrustError(
|
|
376
|
+
`Bundle hash mismatch! The downloaded file may be corrupted or tampered with.${options.verbose ? `\nExpected: ${expectedHash}\nGot: ${downloadedHash}` : ""}`
|
|
377
|
+
);
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
// All installs: extract to cache and update tools.json
|
|
382
|
+
const isGlobal = options.global ?? false;
|
|
383
|
+
const scope = isGlobal ? "global" : "project";
|
|
384
|
+
const cachePath = getToolCachePath(toolName, targetVersion!);
|
|
385
|
+
|
|
386
|
+
// Check if already installed in the target scope
|
|
387
|
+
const existingVersion = getInstalledVersion(toolName, scope, isGlobal ? undefined : ctx.cwd);
|
|
388
|
+
if (existingVersion && !options.force) {
|
|
389
|
+
if (existingVersion === targetVersion) {
|
|
390
|
+
info(
|
|
391
|
+
`Tool ${toolName}@${targetVersion} is already installed ${isGlobal ? "globally" : "in this project"}.`
|
|
392
|
+
);
|
|
393
|
+
return;
|
|
394
|
+
}
|
|
395
|
+
if (ctx.isInteractive) {
|
|
396
|
+
const shouldOverwrite = await confirm(
|
|
397
|
+
`Tool ${toolName}@${existingVersion} is installed. Update to ${targetVersion}?`
|
|
398
|
+
);
|
|
399
|
+
if (!shouldOverwrite) {
|
|
400
|
+
info("Installation cancelled.");
|
|
401
|
+
return;
|
|
402
|
+
}
|
|
403
|
+
} else {
|
|
404
|
+
error(`Tool ${toolName}@${existingVersion} is already installed. Use --force to update.`);
|
|
405
|
+
process.exit(EXIT_FAILURE);
|
|
406
|
+
}
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
// Extract bundle to cache
|
|
410
|
+
try {
|
|
411
|
+
await withSpinner(
|
|
412
|
+
`Extracting ${toolName}...`,
|
|
413
|
+
async () => {
|
|
414
|
+
// Remove existing directory if force
|
|
415
|
+
if (pathExists(cachePath)) {
|
|
416
|
+
rmSync(cachePath, { recursive: true, force: true });
|
|
417
|
+
}
|
|
418
|
+
await extractBundle(bundleResult.data, cachePath);
|
|
419
|
+
},
|
|
420
|
+
`${symbols.success} Extracted to ${cachePath}`
|
|
421
|
+
);
|
|
422
|
+
} catch (err) {
|
|
423
|
+
throw new RegistryError(`Failed to extract bundle: ${formatError(err)}`);
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
// Update tools.json for the appropriate scope
|
|
427
|
+
addToolToRegistry(toolName, targetVersion!, scope, isGlobal ? undefined : ctx.cwd);
|
|
428
|
+
|
|
429
|
+
// Output result
|
|
430
|
+
if (options.json) {
|
|
431
|
+
json({
|
|
432
|
+
installed: true,
|
|
433
|
+
tool: toolName,
|
|
434
|
+
version: targetVersion,
|
|
435
|
+
location: cachePath,
|
|
436
|
+
scope,
|
|
437
|
+
hash: bundleResult.hash,
|
|
438
|
+
size: bundleResult.size,
|
|
439
|
+
verified: true,
|
|
440
|
+
});
|
|
441
|
+
return;
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
newline();
|
|
445
|
+
keyValue("Tool", toolName);
|
|
446
|
+
keyValue("Version", targetVersion ?? "unknown");
|
|
447
|
+
keyValue("Location", cachePath);
|
|
448
|
+
keyValue("Scope", isGlobal ? "global (~/.enact/tools.json)" : "project (.enact/tools.json)");
|
|
449
|
+
keyValue("Size", formatBytes(bundleResult.size));
|
|
450
|
+
keyValue("Hash", `${bundleResult.hash.substring(0, 20)}...`);
|
|
451
|
+
newline();
|
|
452
|
+
success(`Installed ${colors.bold(toolName)}@${targetVersion}`);
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
/**
|
|
456
|
+
* Install from a local path
|
|
457
|
+
*
|
|
458
|
+
* Both global and project installs:
|
|
459
|
+
* 1. Copy tool to cache (~/.enact/cache/{tool}/{version}/)
|
|
460
|
+
* 2. Update tools.json (global: ~/.enact/tools.json, project: .enact/tools.json)
|
|
461
|
+
*/
|
|
462
|
+
async function installFromPath(
|
|
463
|
+
sourcePath: string,
|
|
464
|
+
options: InstallOptions,
|
|
465
|
+
ctx: CommandContext
|
|
466
|
+
): Promise<void> {
|
|
467
|
+
const resolvedPath = resolve(ctx.cwd, sourcePath);
|
|
468
|
+
|
|
469
|
+
// Load manifest from source
|
|
470
|
+
const loaded = loadManifestFromDir(resolvedPath);
|
|
471
|
+
if (!loaded) {
|
|
472
|
+
throw new ManifestError(`No valid manifest found in: ${resolvedPath}`);
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
const manifest = loaded.manifest;
|
|
476
|
+
const isGlobal = options.global ?? false;
|
|
477
|
+
const version = manifest.version ?? "0.0.0";
|
|
478
|
+
const scope = isGlobal ? "global" : "project";
|
|
479
|
+
|
|
480
|
+
// All tools go to cache
|
|
481
|
+
const cachePath = getToolCachePath(manifest.name, version);
|
|
482
|
+
|
|
483
|
+
// Check if already installed in the target scope
|
|
484
|
+
const existingVersion = getInstalledVersion(manifest.name, scope, isGlobal ? undefined : ctx.cwd);
|
|
485
|
+
if (existingVersion && !options.force) {
|
|
486
|
+
if (existingVersion === version) {
|
|
487
|
+
info(
|
|
488
|
+
`Tool ${manifest.name}@${version} is already installed ${isGlobal ? "globally" : "in this project"}.`
|
|
489
|
+
);
|
|
490
|
+
return;
|
|
491
|
+
}
|
|
492
|
+
if (ctx.isInteractive) {
|
|
493
|
+
const shouldOverwrite = await confirm(
|
|
494
|
+
`Tool ${manifest.name}@${existingVersion} is installed. Update to ${version}?`
|
|
495
|
+
);
|
|
496
|
+
if (!shouldOverwrite) {
|
|
497
|
+
info("Installation cancelled.");
|
|
498
|
+
return;
|
|
499
|
+
}
|
|
500
|
+
} else {
|
|
501
|
+
error(
|
|
502
|
+
`Tool ${manifest.name}@${existingVersion} is already installed. Use --force to update.`
|
|
503
|
+
);
|
|
504
|
+
process.exit(EXIT_FAILURE);
|
|
505
|
+
}
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
// Copy tool to cache
|
|
509
|
+
await withSpinner(
|
|
510
|
+
`Installing ${manifest.name}...`,
|
|
511
|
+
async () => {
|
|
512
|
+
if (pathExists(cachePath)) {
|
|
513
|
+
rmSync(cachePath, { recursive: true, force: true });
|
|
514
|
+
}
|
|
515
|
+
mkdirSync(dirname(cachePath), { recursive: true });
|
|
516
|
+
copyDir(resolvedPath, cachePath);
|
|
517
|
+
},
|
|
518
|
+
`${symbols.success} Installed ${manifest.name}`
|
|
519
|
+
);
|
|
520
|
+
|
|
521
|
+
// Update tools.json for the appropriate scope
|
|
522
|
+
addToolToRegistry(manifest.name, version, scope, isGlobal ? undefined : ctx.cwd);
|
|
523
|
+
|
|
524
|
+
if (options.json) {
|
|
525
|
+
json({
|
|
526
|
+
installed: true,
|
|
527
|
+
tool: manifest.name,
|
|
528
|
+
version: manifest.version,
|
|
529
|
+
location: cachePath,
|
|
530
|
+
scope,
|
|
531
|
+
});
|
|
532
|
+
return;
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
newline();
|
|
536
|
+
keyValue("Tool", manifest.name);
|
|
537
|
+
keyValue("Version", manifest.version ?? "unversioned");
|
|
538
|
+
keyValue("Location", cachePath);
|
|
539
|
+
keyValue("Scope", isGlobal ? "global (~/.enact/tools.json)" : "project (.enact/tools.json)");
|
|
540
|
+
newline();
|
|
541
|
+
success(`Installed ${colors.bold(manifest.name)}`);
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
/**
|
|
545
|
+
* Install from a tool name (registry tool)
|
|
546
|
+
*/
|
|
547
|
+
async function installFromName(
|
|
548
|
+
toolSpec: string,
|
|
549
|
+
options: InstallOptions,
|
|
550
|
+
ctx: CommandContext
|
|
551
|
+
): Promise<void> {
|
|
552
|
+
const { name: toolName, version } = parseToolSpec(toolSpec);
|
|
553
|
+
|
|
554
|
+
// If it looks like a local path, use installFromPath
|
|
555
|
+
if (isLocalPath(toolName)) {
|
|
556
|
+
return installFromPath(toolName, options, ctx);
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
// Otherwise, it's a registry tool - fetch from registry
|
|
560
|
+
return installFromRegistry(toolName, version, options, ctx);
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
/**
|
|
564
|
+
* Install all tools from project .enact/tools.json
|
|
565
|
+
*/
|
|
566
|
+
async function installProjectTools(options: InstallOptions, ctx: CommandContext): Promise<void> {
|
|
567
|
+
const projectDir = getProjectEnactDir(ctx.cwd);
|
|
568
|
+
|
|
569
|
+
if (!projectDir) {
|
|
570
|
+
info("No .enact/ directory found. Nothing to install.");
|
|
571
|
+
suggest("Run 'enact install <tool>' to install a specific tool.");
|
|
572
|
+
return;
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
const toolsJsonPath = join(projectDir, "tools.json");
|
|
576
|
+
|
|
577
|
+
if (!existsSync(toolsJsonPath)) {
|
|
578
|
+
info("No .enact/tools.json found. Nothing to install.");
|
|
579
|
+
suggest("Run 'enact install <tool>' to install a specific tool.");
|
|
580
|
+
return;
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
// Parse tools.json
|
|
584
|
+
let toolsConfig: { tools?: Record<string, string> };
|
|
585
|
+
try {
|
|
586
|
+
const content = await Bun.file(toolsJsonPath).text();
|
|
587
|
+
toolsConfig = JSON.parse(content);
|
|
588
|
+
} catch (err) {
|
|
589
|
+
throw new ManifestError(`Failed to parse .enact/tools.json: ${formatError(err)}`);
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
if (!toolsConfig.tools || Object.keys(toolsConfig.tools).length === 0) {
|
|
593
|
+
info("No tools specified in .enact/tools.json");
|
|
594
|
+
return;
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
// Install each tool
|
|
598
|
+
const tools = Object.entries(toolsConfig.tools);
|
|
599
|
+
info(`Installing ${tools.length} tool(s) from tools.json...`);
|
|
600
|
+
newline();
|
|
601
|
+
|
|
602
|
+
let installed = 0;
|
|
603
|
+
let failed = 0;
|
|
604
|
+
|
|
605
|
+
for (const [toolName, toolVersion] of tools) {
|
|
606
|
+
try {
|
|
607
|
+
const toolSpec = toolVersion ? `${toolName}@${toolVersion}` : toolName;
|
|
608
|
+
await installFromName(toolSpec, { ...options, json: false }, ctx);
|
|
609
|
+
installed++;
|
|
610
|
+
} catch (err) {
|
|
611
|
+
error(`Failed to install ${toolName}: ${formatError(err)}`);
|
|
612
|
+
failed++;
|
|
613
|
+
}
|
|
614
|
+
}
|
|
615
|
+
|
|
616
|
+
newline();
|
|
617
|
+
if (failed === 0) {
|
|
618
|
+
success(`Installed all ${installed} tool(s) successfully`);
|
|
619
|
+
} else {
|
|
620
|
+
info(`Installed ${installed} tool(s), ${failed} failed`);
|
|
621
|
+
}
|
|
622
|
+
|
|
623
|
+
if (options.json) {
|
|
624
|
+
json({
|
|
625
|
+
installed,
|
|
626
|
+
failed,
|
|
627
|
+
total: tools.length,
|
|
628
|
+
});
|
|
629
|
+
}
|
|
630
|
+
}
|
|
631
|
+
|
|
632
|
+
/**
|
|
633
|
+
* Install command handler
|
|
634
|
+
*/
|
|
635
|
+
async function installHandler(
|
|
636
|
+
tool: string | undefined,
|
|
637
|
+
options: InstallOptions,
|
|
638
|
+
ctx: CommandContext
|
|
639
|
+
): Promise<void> {
|
|
640
|
+
// If no tool specified, install from project
|
|
641
|
+
if (!tool) {
|
|
642
|
+
return installProjectTools(options, ctx);
|
|
643
|
+
}
|
|
644
|
+
|
|
645
|
+
// Check if it's a path
|
|
646
|
+
if (tool === "." || tool.startsWith("./") || tool.startsWith("/") || tool.startsWith("..")) {
|
|
647
|
+
return installFromPath(tool, options, ctx);
|
|
648
|
+
}
|
|
649
|
+
|
|
650
|
+
// Otherwise, try to resolve as tool name (local or registry)
|
|
651
|
+
return installFromName(tool, options, ctx);
|
|
652
|
+
}
|
|
653
|
+
|
|
654
|
+
/**
|
|
655
|
+
* Configure the install command
|
|
656
|
+
*/
|
|
657
|
+
export function configureInstallCommand(program: Command): void {
|
|
658
|
+
program
|
|
659
|
+
.command("install")
|
|
660
|
+
.alias("i")
|
|
661
|
+
.description("Install a tool to the project or globally")
|
|
662
|
+
.argument("[tool]", "Tool to install (name[@version], path, or '.' for current directory)")
|
|
663
|
+
.option("-g, --global", "Install globally (adds to ~/.enact/tools.json)")
|
|
664
|
+
.option("-f, --force", "Overwrite existing installation")
|
|
665
|
+
.option("--allow-yanked", "Allow installing yanked versions")
|
|
666
|
+
.option("-v, --verbose", "Show detailed output")
|
|
667
|
+
.option("--json", "Output result as JSON")
|
|
668
|
+
.action(async (tool: string | undefined, options: InstallOptions) => {
|
|
669
|
+
const ctx: CommandContext = {
|
|
670
|
+
cwd: process.cwd(),
|
|
671
|
+
options,
|
|
672
|
+
isCI: Boolean(process.env.CI),
|
|
673
|
+
isInteractive: process.stdout.isTTY ?? false,
|
|
674
|
+
};
|
|
675
|
+
|
|
676
|
+
try {
|
|
677
|
+
await installHandler(tool, options, ctx);
|
|
678
|
+
} catch (err) {
|
|
679
|
+
handleError(err, options.verbose ? { verbose: true } : undefined);
|
|
680
|
+
}
|
|
681
|
+
});
|
|
682
|
+
}
|