@oomerevren/tryforge 0.1.2 → 0.1.3
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/CHANGELOG.md +107 -13
- package/README.md +172 -232
- package/dist/cli/src/adapters/agents-md.js +24 -0
- package/dist/cli/src/adapters/base.js +99 -0
- package/dist/cli/src/adapters/claude.js +43 -14
- package/dist/cli/src/adapters/codex.js +18 -17
- package/dist/cli/src/adapters/cursor.js +44 -30
- package/dist/cli/src/adapters/dsh.js +28 -30
- package/dist/cli/src/adapters/generic.js +10 -7
- package/dist/cli/src/adapters/opencode.js +23 -23
- package/dist/cli/src/adapters/types.js +12 -9
- package/dist/cli/src/adapters/windsurf.js +48 -28
- package/dist/cli/src/commands/add-external.js +111 -0
- package/dist/cli/src/commands/audit.js +68 -30
- package/dist/cli/src/commands/init.js +2 -2
- package/dist/cli/src/commands/install.js +90 -15
- package/dist/cli/src/commands/pack.js +119 -0
- package/dist/cli/src/commands/sync.js +181 -0
- package/dist/cli/src/commands/test.js +72 -0
- package/dist/cli/src/commands/tui.js +191 -0
- package/dist/cli/src/commands/update.js +4 -4
- package/dist/cli/src/commands/verify.js +62 -0
- package/dist/cli/src/core/fsutil.js +37 -0
- package/dist/cli/src/core/installer.js +9 -9
- package/dist/cli/src/core/lock.js +97 -10
- package/dist/cli/src/core/merge.js +108 -0
- package/dist/cli/src/core/plugin.js +2 -2
- package/dist/cli/src/core/project.js +136 -4
- package/dist/cli/src/core/registry.js +9 -9
- package/dist/cli/src/core/scan.js +159 -0
- package/dist/cli/src/core/semver.js +7 -7
- package/dist/cli/src/core/sign.js +2 -2
- package/dist/cli/src/core/sources.js +171 -0
- package/dist/cli/src/index.js +83 -16
- package/dist/index.cjs +359 -0
- package/dist/scripts/build-registry.js +2 -4
- package/dist/scripts/publish-verified.js +17 -9
- package/dist/scripts/seed-registry-13lite.js +5 -5
- package/dist/scripts/seed-registry.js +4 -4
- package/dist/scripts/verify-npm-mcps.js +6 -4
- package/package.json +15 -6
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
// cli/src/commands/add-external.ts — Install from decentralized sources (Phase 3).
|
|
2
|
+
//
|
|
3
|
+
// Handles github:/git-URL/local-path packages: resolve → stage → security
|
|
4
|
+
// gate → copy into the forge store → install to adapters → record links.
|
|
5
|
+
// Fail-closed: HIGH scan findings refuse the install (exit 1) unless the
|
|
6
|
+
// package comes from the verified registry path (which only warns).
|
|
7
|
+
import { resolveExternalSource, } from "../core/sources.js";
|
|
8
|
+
import { copyDirRecursive } from "../core/fsutil.js";
|
|
9
|
+
import { ensureForgeDirs, readLinks, writeLinks, packageDir, toSlug, } from "../core/store.js";
|
|
10
|
+
import { detectAdapters } from "../adapters/index.js";
|
|
11
|
+
import { scanPackageDir } from "../core/scan.js";
|
|
12
|
+
import { findProjectToml, loadProjectToml } from "../core/project.js";
|
|
13
|
+
/**
|
|
14
|
+
* Copy staged external content into the forge store (adapters symlink the
|
|
15
|
+
* store, never tmp). Returns the store dir. Always cleans the stage dir.
|
|
16
|
+
*/
|
|
17
|
+
export function storeExternal(staged) {
|
|
18
|
+
const dest = packageDir(toSlug(staged.name), staged.version);
|
|
19
|
+
try {
|
|
20
|
+
ensureForgeDirs();
|
|
21
|
+
// NOTE: raw fs.cpSync silently yields empty dirs under non-ASCII
|
|
22
|
+
// Windows home paths — always use copyDirRecursive (see fsutil.ts).
|
|
23
|
+
copyDirRecursive(staged.dir, dest);
|
|
24
|
+
return dest;
|
|
25
|
+
}
|
|
26
|
+
finally {
|
|
27
|
+
staged.cleanup();
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
export async function installExternal(spec, opts = {}) {
|
|
31
|
+
console.log(`[forge] resolving external source ${spec.source}...`);
|
|
32
|
+
let staged;
|
|
33
|
+
try {
|
|
34
|
+
staged = resolveExternalSource(spec);
|
|
35
|
+
}
|
|
36
|
+
catch (e) {
|
|
37
|
+
console.error(e.message);
|
|
38
|
+
process.exitCode = 1;
|
|
39
|
+
return;
|
|
40
|
+
}
|
|
41
|
+
const slug = toSlug(staged.name);
|
|
42
|
+
console.log(`[forge] found ${staged.name}@${staged.version} [${staged.type}] — ${staged.description}`);
|
|
43
|
+
console.log(`[forge] source: ${staged.source} (external — no registry pin)`);
|
|
44
|
+
if (opts.dryRun) {
|
|
45
|
+
console.log(`[forge] (dry-run) would install to ~/.forge/packages/${slug}@${staged.version}/`);
|
|
46
|
+
staged.cleanup();
|
|
47
|
+
return;
|
|
48
|
+
}
|
|
49
|
+
// Security gate: HIGH findings refuse external installs (fail-closed).
|
|
50
|
+
if (!opts.skipScan) {
|
|
51
|
+
let permissions;
|
|
52
|
+
try {
|
|
53
|
+
const tomlPath = findProjectToml(process.cwd());
|
|
54
|
+
if (tomlPath)
|
|
55
|
+
permissions = loadProjectToml(tomlPath).permissions;
|
|
56
|
+
}
|
|
57
|
+
catch { /* project parse is best-effort; scan still runs */ }
|
|
58
|
+
const findings = scanPackageDir(staged.dir, { permissions });
|
|
59
|
+
const highs = findings.filter((f) => f.severity === "high");
|
|
60
|
+
const mediums = findings.filter((f) => f.severity === "medium");
|
|
61
|
+
for (const f of mediums.slice(0, 10)) {
|
|
62
|
+
console.warn(`[forge] scan warn [${f.rule}] ${f.file}${f.line > 0 ? `:${f.line}` : ""} — ${f.message}`);
|
|
63
|
+
}
|
|
64
|
+
if (highs.length > 0) {
|
|
65
|
+
console.error(`[forge] ✗ security scan FAILED for ${staged.name}: ${highs.length} high-severity finding(s)`);
|
|
66
|
+
for (const f of highs.slice(0, 10)) {
|
|
67
|
+
console.error(`[forge] [high] [${f.rule}] ${f.file}${f.line > 0 ? `:${f.line}` : ""} — ${f.message}`);
|
|
68
|
+
}
|
|
69
|
+
console.error(`[forge] Refusing to install. Re-run with --skip-scan only if you trust this source.`);
|
|
70
|
+
staged.cleanup();
|
|
71
|
+
process.exitCode = 1;
|
|
72
|
+
return;
|
|
73
|
+
}
|
|
74
|
+
console.log(`[forge] scan clean (${findings.length} finding(s), 0 high)`);
|
|
75
|
+
}
|
|
76
|
+
// Copy staged content into the store (adapters symlink the store, never tmp).
|
|
77
|
+
const dest = storeExternal(staged);
|
|
78
|
+
console.log(`[forge] package content ready: ${dest}`);
|
|
79
|
+
const adapters = detectAdapters();
|
|
80
|
+
console.log(`[forge] detected harnesses: ${adapters.map((a) => `${a.displayName} (${a.name})`).join(", ")}`);
|
|
81
|
+
let failed = 0;
|
|
82
|
+
for (const adapter of adapters) {
|
|
83
|
+
try {
|
|
84
|
+
await adapter.install(slug, dest, staged.type, { version: staged.version, description: staged.description });
|
|
85
|
+
console.log(` ✓ ${adapter.displayName} → ${adapter.skillDir(slug)}`);
|
|
86
|
+
}
|
|
87
|
+
catch (e) {
|
|
88
|
+
console.warn(` ✗ ${adapter.displayName} failed: ${e.message}`);
|
|
89
|
+
failed++;
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
if (failed > 0) {
|
|
93
|
+
console.log(`\n[forge] ✗ ${failed} harness(es) failed — installed on ${adapters.length - failed}/${adapters.length}`);
|
|
94
|
+
process.exitCode = 1;
|
|
95
|
+
}
|
|
96
|
+
else {
|
|
97
|
+
console.log(`\n[forge] ✓ installed ${staged.name}@${staged.version} on ${adapters.length} harness(es)`);
|
|
98
|
+
}
|
|
99
|
+
ensureForgeDirs();
|
|
100
|
+
const links = readLinks();
|
|
101
|
+
links[staged.name] = {
|
|
102
|
+
pkg: staged.name,
|
|
103
|
+
version: staged.version,
|
|
104
|
+
slug,
|
|
105
|
+
type: staged.type,
|
|
106
|
+
adapters: adapters.map((a) => a.name),
|
|
107
|
+
installedAt: new Date().toISOString(),
|
|
108
|
+
source: staged.source,
|
|
109
|
+
};
|
|
110
|
+
writeLinks(links);
|
|
111
|
+
}
|
|
@@ -1,53 +1,85 @@
|
|
|
1
|
-
// cli/src/commands/audit.ts — trust-tier audit
|
|
2
|
-
//
|
|
1
|
+
// cli/src/commands/audit.ts — trust-tier audit + static security scan (Phase 3).
|
|
2
|
+
//
|
|
3
|
+
// Two layers:
|
|
4
|
+
// 1. Trust tier per installed package (verified vs community vs mock).
|
|
5
|
+
// 2. Static scan of on-disk package content (shell-danger, prompt-inject,
|
|
6
|
+
// perm-violation — see core/scan.ts).
|
|
7
|
+
// Any HIGH-severity scan finding fails the audit (exit 1, fail-closed).
|
|
3
8
|
import { existsSync } from "fs";
|
|
4
9
|
import { join } from "path";
|
|
5
10
|
import { readLinks, packageDir } from "../core/store.js";
|
|
6
11
|
import { loadPackageDetail } from "../core/registry.js";
|
|
7
12
|
import { isPlaceholderSha } from "../core/installer.js";
|
|
13
|
+
import { scanPackageDir } from "../core/scan.js";
|
|
14
|
+
import { findProjectToml, loadProjectToml } from "../core/project.js";
|
|
8
15
|
export async function runAudit(opts = {}) {
|
|
9
|
-
|
|
10
|
-
const entries =
|
|
16
|
+
// opts.dirs overrides the links store (tests + `forge audit <path>` flows).
|
|
17
|
+
const entries = opts.dirs
|
|
18
|
+
? Object.entries(opts.dirs).map(([pkg, dir]) => ({ pkg, dir, version: "scan", slug: pkg }))
|
|
19
|
+
: Object.values(readLinks()).map((rec) => ({ pkg: rec.pkg, dir: packageDir(rec.slug, rec.version), version: rec.version, slug: rec.slug }));
|
|
11
20
|
const findings = [];
|
|
12
21
|
if (entries.length === 0) {
|
|
13
22
|
console.log("[forge] audit: no installed packages — nothing to check");
|
|
14
|
-
console.log("[forge] audit: full vulnerability DB in Faz 22");
|
|
15
23
|
return;
|
|
16
24
|
}
|
|
25
|
+
// Project permission boundaries apply to content checks when present.
|
|
26
|
+
let permissions;
|
|
27
|
+
try {
|
|
28
|
+
const tomlPath = findProjectToml(process.cwd());
|
|
29
|
+
if (tomlPath)
|
|
30
|
+
permissions = loadProjectToml(tomlPath).permissions;
|
|
31
|
+
}
|
|
32
|
+
catch { /* project parse is best-effort here; tier checks still run */ }
|
|
17
33
|
for (const rec of entries) {
|
|
18
|
-
|
|
19
|
-
const detail = await loadPackageDetail(rec.pkg);
|
|
20
|
-
const meta = detail.versions[rec.version];
|
|
21
|
-
if (!meta) {
|
|
22
|
-
findings.push({ pkg: rec.pkg, level: "warn", message: `version ${rec.version} not in registry` });
|
|
23
|
-
continue;
|
|
24
|
-
}
|
|
25
|
-
if (meta.sha256 && isPlaceholderSha(meta.sha256)) {
|
|
26
|
-
findings.push({ pkg: rec.pkg, level: 'info', message: 'community tier (unverified tarball) — install needs --mock' });
|
|
27
|
-
}
|
|
28
|
-
else if (!meta.verified) {
|
|
29
|
-
findings.push({ pkg: rec.pkg, level: 'warn', message: 'sha256 not verified against tarball content' });
|
|
30
|
-
}
|
|
31
|
-
if (!meta.tarball || meta.tarball.includes("placeholder")) {
|
|
32
|
-
findings.push({ pkg: rec.pkg, level: "warn", message: `tarball URL placeholder` });
|
|
33
|
-
}
|
|
34
|
-
// On-disk truth: .forge-mock marker means this install is mock content (--mock).
|
|
34
|
+
if (!opts.dirs) {
|
|
35
35
|
try {
|
|
36
|
-
|
|
37
|
-
|
|
36
|
+
const detail = await loadPackageDetail(rec.pkg);
|
|
37
|
+
const meta = detail.versions[rec.version];
|
|
38
|
+
if (!meta) {
|
|
39
|
+
findings.push({ pkg: rec.pkg, level: "warn", message: `version ${rec.version} not in registry` });
|
|
40
|
+
continue;
|
|
41
|
+
}
|
|
42
|
+
if (meta.sha256 && isPlaceholderSha(meta.sha256)) {
|
|
43
|
+
findings.push({ pkg: rec.pkg, level: "info", message: "community tier (unverified tarball) — install needs --mock" });
|
|
44
|
+
}
|
|
45
|
+
else if (!meta.verified) {
|
|
46
|
+
findings.push({ pkg: rec.pkg, level: "warn", message: "sha256 not verified against tarball content" });
|
|
38
47
|
}
|
|
48
|
+
if (!meta.tarball || meta.tarball.includes("placeholder")) {
|
|
49
|
+
findings.push({ pkg: rec.pkg, level: "warn", message: `tarball URL placeholder` });
|
|
50
|
+
}
|
|
51
|
+
// On-disk truth: .forge-mock marker means this install is mock content (--mock).
|
|
52
|
+
try {
|
|
53
|
+
if (existsSync(join(packageDir(rec.slug, rec.version), ".forge-mock"))) {
|
|
54
|
+
findings.push({ pkg: rec.pkg, level: "info", message: `installed from MOCK content (--mock was used)` });
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
catch { /* marker check is best-effort; unreadable dir simply means "not mock" */ }
|
|
58
|
+
}
|
|
59
|
+
catch (e) {
|
|
60
|
+
findings.push({ pkg: rec.pkg, level: "warn", message: `registry read failed: ${e.message}` });
|
|
39
61
|
}
|
|
40
|
-
catch { }
|
|
41
62
|
}
|
|
42
|
-
|
|
43
|
-
|
|
63
|
+
// Static scan of on-disk content (always runs when the dir exists).
|
|
64
|
+
if (existsSync(rec.dir)) {
|
|
65
|
+
const scan = scanPackageDir(rec.dir, { permissions });
|
|
66
|
+
for (const f of scan) {
|
|
67
|
+
findings.push({
|
|
68
|
+
pkg: rec.pkg,
|
|
69
|
+
level: f.severity === "high" ? "high" : f.severity === "medium" ? "warn" : "info",
|
|
70
|
+
message: `[${f.rule}] ${f.file}${f.line > 0 ? `:${f.line}` : ""} — ${f.message}`,
|
|
71
|
+
});
|
|
72
|
+
}
|
|
44
73
|
}
|
|
45
74
|
}
|
|
75
|
+
const highs = findings.filter((f) => f.level === "high").length;
|
|
46
76
|
if (opts.json) {
|
|
47
|
-
console.log(JSON.stringify({ packages: entries.length, findings }, null, 2));
|
|
77
|
+
console.log(JSON.stringify({ packages: entries.length, findings, highs }, null, 2));
|
|
78
|
+
if (highs > 0)
|
|
79
|
+
process.exitCode = 1;
|
|
48
80
|
return;
|
|
49
81
|
}
|
|
50
|
-
console.log(`[forge] audit — ${entries.length} package(s) checked
|
|
82
|
+
console.log(`[forge] audit — ${entries.length} package(s) checked:\n`);
|
|
51
83
|
if (findings.length === 0) {
|
|
52
84
|
console.log("[forge] ✓ no issues");
|
|
53
85
|
}
|
|
@@ -58,5 +90,11 @@ export async function runAudit(opts = {}) {
|
|
|
58
90
|
if (findings.length > 30)
|
|
59
91
|
console.log(` ... and ${findings.length - 30} more`);
|
|
60
92
|
}
|
|
61
|
-
|
|
93
|
+
if (highs > 0) {
|
|
94
|
+
console.log(`\n[forge] ✗ audit FAILED: ${highs} high-severity finding(s) — refusing to pass`);
|
|
95
|
+
process.exitCode = 1;
|
|
96
|
+
}
|
|
97
|
+
else {
|
|
98
|
+
console.log(`\n[forge] audit done: ${findings.length} finding(s)`);
|
|
99
|
+
}
|
|
62
100
|
}
|
|
@@ -82,11 +82,11 @@ export async function runInit(opts) {
|
|
|
82
82
|
if (!existsSync(full) || opts.force)
|
|
83
83
|
writeFileSync(full, content);
|
|
84
84
|
}
|
|
85
|
-
const relDir = targetDir === cwd ? "." : opts.name;
|
|
85
|
+
const relDir = targetDir === cwd ? "." : (opts.name ?? rawName);
|
|
86
86
|
console.log(`[forge] ✓ created ${relDir}/forge.toml [${type}]`);
|
|
87
87
|
if (Object.keys(extras).length)
|
|
88
88
|
console.log(`[forge] + ${Object.keys(extras).join(", ")}`);
|
|
89
|
-
console.log(`[forge] next: edit forge.toml & SKILL.md, then 'forge publish' (
|
|
89
|
+
console.log(`[forge] next: edit forge.toml & SKILL.md, then 'forge publish' (Phase 8)`);
|
|
90
90
|
}
|
|
91
91
|
function normalizeName(input) {
|
|
92
92
|
// allow my-skill -> my-skill/my-skill ? We expect scope/name
|
|
@@ -6,8 +6,12 @@ import { ensurePackageContent } from "../core/installer.js";
|
|
|
6
6
|
import { ensureForgeDirs, readLinks, writeLinks, toSlug } from "../core/store.js";
|
|
7
7
|
import { allAdapters, detectAdapters, addMcpServerToConfig } from "../adapters/index.js";
|
|
8
8
|
import { findProjectToml, loadProjectToml, validateProjectToml } from "../core/project.js";
|
|
9
|
-
import { readLock, writeLock } from "../core/lock.js";
|
|
9
|
+
import { readLock, writeLock, verifyLockIntegrity, lockEntryFor } from "../core/lock.js";
|
|
10
10
|
import { loadConfig } from "../core/config.js";
|
|
11
|
+
import { scanPackageDir } from "../core/scan.js";
|
|
12
|
+
import { parseSourceArg, resolveExternalSource } from "../core/sources.js";
|
|
13
|
+
import { storeExternal } from "./add-external.js";
|
|
14
|
+
import { injectMcpServers } from "./sync.js";
|
|
11
15
|
function pickAdapters(projectHarnesses) {
|
|
12
16
|
if (projectHarnesses && projectHarnesses.length > 0) {
|
|
13
17
|
const filtered = allAdapters.filter((a) => projectHarnesses.includes(a.name));
|
|
@@ -41,14 +45,15 @@ export async function runInstall(opts = {}) {
|
|
|
41
45
|
console.error(`[forge] ${e.message}`);
|
|
42
46
|
process.exit(1);
|
|
43
47
|
}
|
|
44
|
-
const errs = validateProjectToml(project
|
|
48
|
+
const errs = validateProjectToml(project);
|
|
45
49
|
if (errs.length > 0) {
|
|
46
50
|
for (const er of errs)
|
|
47
51
|
console.error(`[forge] ${er}`);
|
|
48
52
|
process.exit(1);
|
|
49
53
|
}
|
|
50
54
|
const deps = project.dependencies;
|
|
51
|
-
|
|
55
|
+
const hasSkills = Object.keys(project.skills ?? {}).length > 0;
|
|
56
|
+
if (Object.keys(deps).length === 0 && !hasSkills) {
|
|
52
57
|
console.log(`[forge] no dependencies in ${tomlPath}`);
|
|
53
58
|
return;
|
|
54
59
|
}
|
|
@@ -75,6 +80,13 @@ export async function runInstall(opts = {}) {
|
|
|
75
80
|
}
|
|
76
81
|
}
|
|
77
82
|
console.log(`[forge] installing ${lock.packages.length} package(s) from forge.lock (frozen)...`);
|
|
83
|
+
// Security barrier: yanked versions and hash drift refuse the install.
|
|
84
|
+
const integrityIssues = await verifyLockIntegrity(lock, { allowMock: opts.mock });
|
|
85
|
+
if (integrityIssues.length > 0) {
|
|
86
|
+
for (const issue of integrityIssues)
|
|
87
|
+
console.error(`[forge] ✗ ${issue.message}`);
|
|
88
|
+
process.exit(1);
|
|
89
|
+
}
|
|
78
90
|
const adapters = pickAdapters(project.forge?.harnesses);
|
|
79
91
|
console.log(`[forge] harnesses: ${adapters.map((a) => a.displayName).join(", ")}`);
|
|
80
92
|
const t0 = Date.now();
|
|
@@ -84,7 +96,10 @@ export async function runInstall(opts = {}) {
|
|
|
84
96
|
const { detail, version, versionMeta } = await resolveVersion(entry.name, entry.version);
|
|
85
97
|
const src = await ensurePackageContent(entry.name, version, detail, versionMeta, { allowMock: opts.mock });
|
|
86
98
|
for (const adapter of adapters) {
|
|
87
|
-
await adapter.install(toSlug(entry.name), src, detail.type
|
|
99
|
+
await adapter.install(toSlug(entry.name), src, detail.type, {
|
|
100
|
+
version,
|
|
101
|
+
description: detail.description,
|
|
102
|
+
});
|
|
88
103
|
if (detail.type === "mcp" && versionMeta.mcp) {
|
|
89
104
|
const cfgPath = adapter.mcpConfigPath();
|
|
90
105
|
if (cfgPath)
|
|
@@ -113,17 +128,71 @@ export async function runInstall(opts = {}) {
|
|
|
113
128
|
console.log(`\n[forge] ✓ installed ${ok}/${lock.packages.length} from lock in ${dt}s`);
|
|
114
129
|
return;
|
|
115
130
|
}
|
|
116
|
-
// Normal: resolve from dependencies
|
|
117
|
-
|
|
131
|
+
// Normal: resolve from dependencies (+ [skills] from the universal manifest)
|
|
132
|
+
const skillEntries = Object.entries(project.skills ?? {});
|
|
133
|
+
const totalWork = Object.keys(deps).length + skillEntries.length;
|
|
134
|
+
console.log(`[forge] installing ${totalWork} package(s) from ${tomlPath}...`);
|
|
118
135
|
const adapters = pickAdapters(project.forge?.harnesses);
|
|
119
136
|
console.log(`[forge] harnesses: ${adapters.map((a) => a.displayName).join(", ")}`);
|
|
120
137
|
const t0 = Date.now();
|
|
121
138
|
const lockEntries = [];
|
|
122
139
|
let ok = 0;
|
|
123
140
|
let skipped = 0;
|
|
141
|
+
// External [skills] (github:/URL/local) resolve outside the registry loop.
|
|
142
|
+
for (const [skillName, ref] of skillEntries) {
|
|
143
|
+
const source = ref.source ?? "registry";
|
|
144
|
+
if (source === "registry")
|
|
145
|
+
continue; // handled in the registry loop below
|
|
146
|
+
try {
|
|
147
|
+
const spec = parseSourceArg(ref.ref ? `${source}#${ref.ref}` : source);
|
|
148
|
+
const staged = resolveExternalSource(spec);
|
|
149
|
+
try {
|
|
150
|
+
const highs = scanPackageDir(staged.dir).filter((f) => f.severity === "high");
|
|
151
|
+
if (highs.length > 0) {
|
|
152
|
+
throw new Error(`security scan FAILED (${highs.length} high): ${highs[0].rule} ${highs[0].file} — refusing install`);
|
|
153
|
+
}
|
|
154
|
+
const srcDir = storeExternal(staged);
|
|
155
|
+
for (const adapter of adapters) {
|
|
156
|
+
await adapter.install(toSlug(skillName), srcDir, staged.type, { version: staged.version, description: staged.description });
|
|
157
|
+
}
|
|
158
|
+
ensureForgeDirs();
|
|
159
|
+
const links = readLinks();
|
|
160
|
+
links[skillName] = {
|
|
161
|
+
pkg: skillName,
|
|
162
|
+
version: staged.version,
|
|
163
|
+
slug: toSlug(skillName),
|
|
164
|
+
type: staged.type,
|
|
165
|
+
adapters: adapters.map((a) => a.name),
|
|
166
|
+
installedAt: new Date().toISOString(),
|
|
167
|
+
source: staged.source,
|
|
168
|
+
};
|
|
169
|
+
writeLinks(links);
|
|
170
|
+
lockEntries.push(lockEntryFor(skillName, staged.version, staged.type, {}, staged.source));
|
|
171
|
+
ok++;
|
|
172
|
+
console.log(` ✓ ${skillName}@${staged.version} (from ${staged.source})`);
|
|
173
|
+
}
|
|
174
|
+
catch (e) {
|
|
175
|
+
try {
|
|
176
|
+
staged.cleanup();
|
|
177
|
+
}
|
|
178
|
+
catch { /* best-effort */ }
|
|
179
|
+
throw e;
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
catch (e) {
|
|
183
|
+
console.error(` ✗ ${skillName} failed: ${e.message}`);
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
// Registry skills without explicit source join the semver loop.
|
|
187
|
+
const mergedDeps = { ...deps };
|
|
188
|
+
for (const [skillName, ref] of skillEntries) {
|
|
189
|
+
if ((ref.source ?? "registry") === "registry" && ref.version && mergedDeps[skillName] === undefined) {
|
|
190
|
+
mergedDeps[skillName] = ref.version;
|
|
191
|
+
}
|
|
192
|
+
}
|
|
124
193
|
// Check existing links to skip already-installed exact version? We reinstall if range resolves same version already installed? For idempotency allow skip? We'll still ensure content but count skipped.
|
|
125
194
|
const existingLinks = readLinks();
|
|
126
|
-
for (const [depName, depRange] of Object.entries(
|
|
195
|
+
for (const [depName, depRange] of Object.entries(mergedDeps)) {
|
|
127
196
|
try {
|
|
128
197
|
const { detail, version, versionMeta } = await resolveVersion(depName, depRange);
|
|
129
198
|
const already = existingLinks[depName];
|
|
@@ -132,7 +201,7 @@ export async function runInstall(opts = {}) {
|
|
|
132
201
|
// verify adapters still have it? For speed skip re-install? But ensure adapters have it
|
|
133
202
|
// For mcp packages isInstalled checks skillDir which is not created — treat store existence as enough
|
|
134
203
|
if (detail.type === "mcp") {
|
|
135
|
-
lockEntries.push(
|
|
204
|
+
lockEntries.push(lockEntryFor(depName, version, detail.type, versionMeta));
|
|
136
205
|
skipped++;
|
|
137
206
|
console.log(` = ${depName}@${version} already installed`);
|
|
138
207
|
continue;
|
|
@@ -144,7 +213,7 @@ export async function runInstall(opts = {}) {
|
|
|
144
213
|
allPresent = false;
|
|
145
214
|
}
|
|
146
215
|
if (allPresent) {
|
|
147
|
-
lockEntries.push(
|
|
216
|
+
lockEntries.push(lockEntryFor(depName, version, detail.type, versionMeta));
|
|
148
217
|
skipped++;
|
|
149
218
|
console.log(` = ${depName}@${version} already installed`);
|
|
150
219
|
continue;
|
|
@@ -152,7 +221,7 @@ export async function runInstall(opts = {}) {
|
|
|
152
221
|
}
|
|
153
222
|
const src = await ensurePackageContent(depName, version, detail, versionMeta, { allowMock: opts.mock });
|
|
154
223
|
for (const adapter of adapters) {
|
|
155
|
-
await adapter.install(toSlug(depName), src, detail.type);
|
|
224
|
+
await adapter.install(toSlug(depName), src, detail.type, { version, description: detail.description });
|
|
156
225
|
if (detail.type === "mcp" && versionMeta.mcp) {
|
|
157
226
|
const cfgPath = adapter.mcpConfigPath();
|
|
158
227
|
if (cfgPath)
|
|
@@ -166,7 +235,7 @@ export async function runInstall(opts = {}) {
|
|
|
166
235
|
const sub = await resolveVersion(subName, subRange);
|
|
167
236
|
const subSrc = await ensurePackageContent(subName, sub.version, sub.detail, sub.versionMeta, { allowMock: opts.mock });
|
|
168
237
|
for (const adapter of adapters)
|
|
169
|
-
await adapter.install(toSlug(subName), subSrc, sub.detail.type);
|
|
238
|
+
await adapter.install(toSlug(subName), subSrc, sub.detail.type, { version: sub.version, description: sub.detail.description });
|
|
170
239
|
console.log(` dep ${subName}@${sub.version}`);
|
|
171
240
|
}
|
|
172
241
|
catch (e) {
|
|
@@ -184,7 +253,7 @@ export async function runInstall(opts = {}) {
|
|
|
184
253
|
installedAt: new Date().toISOString(),
|
|
185
254
|
};
|
|
186
255
|
writeLinks(links);
|
|
187
|
-
lockEntries.push(
|
|
256
|
+
lockEntries.push(lockEntryFor(depName, version, detail.type, versionMeta));
|
|
188
257
|
ok++;
|
|
189
258
|
console.log(` ✓ ${depName}@${version}`);
|
|
190
259
|
}
|
|
@@ -195,15 +264,21 @@ export async function runInstall(opts = {}) {
|
|
|
195
264
|
// Write lock (deterministic order)
|
|
196
265
|
lockEntries.sort((a, b) => a.name.localeCompare(b.name));
|
|
197
266
|
writeLock(lockEntries, cwd);
|
|
267
|
+
// Project MCP servers → every adapter config.
|
|
268
|
+
const mcpServers = project.mcp?.servers ?? {};
|
|
269
|
+
if (Object.keys(mcpServers).length > 0) {
|
|
270
|
+
const writes = injectMcpServers(adapters, mcpServers);
|
|
271
|
+
console.log(`[forge] ✓ ${Object.keys(mcpServers).length} MCP server(s) injected (${writes} config writes)`);
|
|
272
|
+
}
|
|
198
273
|
const dt = ((Date.now() - t0) / 1000).toFixed(1);
|
|
199
274
|
const total = ok + skipped;
|
|
200
|
-
const failed =
|
|
275
|
+
const failed = totalWork - total;
|
|
201
276
|
if (failed > 0) {
|
|
202
|
-
console.log(`\n[forge] ✗ ${failed} package(s) failed — installed ${ok} new, ${skipped} cached (${total}/${
|
|
277
|
+
console.log(`\n[forge] ✗ ${failed} package(s) failed — installed ${ok} new, ${skipped} cached (${total}/${totalWork}) on ${adapters.length} harness(es) in ${dt}s`);
|
|
203
278
|
process.exitCode = 1;
|
|
204
279
|
}
|
|
205
280
|
else {
|
|
206
|
-
console.log(`\n[forge] ✓ installed ${ok} new, ${skipped} cached (${total}/${
|
|
281
|
+
console.log(`\n[forge] ✓ installed ${ok} new, ${skipped} cached (${total}/${totalWork}) on ${adapters.length} harness(es) in ${dt}s`);
|
|
207
282
|
}
|
|
208
283
|
console.log(`[forge] lock written to ${cwd}/forge.lock`);
|
|
209
284
|
}
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
// cli/src/commands/pack.ts — Package the current directory into a verified tarball.
|
|
2
|
+
import { existsSync, readFileSync, writeFileSync, mkdirSync, statSync } from "fs";
|
|
3
|
+
import { join } from "path";
|
|
4
|
+
import { createHash } from "crypto";
|
|
5
|
+
import { execFileSync } from "child_process";
|
|
6
|
+
import { findProjectToml, loadProjectToml } from "../core/project.js";
|
|
7
|
+
import { scanPackageDir, countBySeverity } from "../core/scan.js";
|
|
8
|
+
const SKIP_DIRS = new Set(["node_modules", ".git", ".hg", ".svn", "__pycache__", "dist", "build", ".cache", ".forge"]);
|
|
9
|
+
async function listFiles(dir, out = []) {
|
|
10
|
+
const { readdirSync, statSync: stat } = await import("fs");
|
|
11
|
+
let entries;
|
|
12
|
+
try {
|
|
13
|
+
entries = readdirSync(dir, { withFileTypes: true });
|
|
14
|
+
}
|
|
15
|
+
catch {
|
|
16
|
+
return out;
|
|
17
|
+
}
|
|
18
|
+
for (const e of entries) {
|
|
19
|
+
if (SKIP_DIRS.has(e.name) || e.name.startsWith(".forge-"))
|
|
20
|
+
continue;
|
|
21
|
+
const full = join(dir, e.name);
|
|
22
|
+
try {
|
|
23
|
+
const st = stat(full);
|
|
24
|
+
if (st.isDirectory())
|
|
25
|
+
await listFiles(full, out);
|
|
26
|
+
else if (st.isFile())
|
|
27
|
+
out.push(full);
|
|
28
|
+
}
|
|
29
|
+
catch { /* skip */ }
|
|
30
|
+
}
|
|
31
|
+
return out;
|
|
32
|
+
}
|
|
33
|
+
export async function runPack(opts = {}) {
|
|
34
|
+
const cwd = process.cwd();
|
|
35
|
+
const tomlPath = findProjectToml(cwd);
|
|
36
|
+
if (!tomlPath) {
|
|
37
|
+
console.error("[forge] pack requires a forge.toml in the current directory");
|
|
38
|
+
process.exit(1);
|
|
39
|
+
}
|
|
40
|
+
let name, version;
|
|
41
|
+
try {
|
|
42
|
+
const parsed = loadProjectToml(tomlPath);
|
|
43
|
+
if (!parsed.package) {
|
|
44
|
+
console.error("[forge] pack requires [package] table in forge.toml");
|
|
45
|
+
process.exit(1);
|
|
46
|
+
}
|
|
47
|
+
name = parsed.package.name;
|
|
48
|
+
version = parsed.package.version;
|
|
49
|
+
if (!name || !version) {
|
|
50
|
+
console.error("[forge] [package] must have name and version");
|
|
51
|
+
process.exit(1);
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
catch (e) {
|
|
55
|
+
console.error(`[forge] forge.toml parse error: ${e.message}`);
|
|
56
|
+
process.exit(1);
|
|
57
|
+
}
|
|
58
|
+
console.log(`[forge] packing ${name}@${version}...`);
|
|
59
|
+
const findings = scanPackageDir(cwd);
|
|
60
|
+
const counts = countBySeverity(findings);
|
|
61
|
+
console.log(`[forge] security: ${counts.high} high, ${counts.medium} medium, ${counts.low} low`);
|
|
62
|
+
if (counts.high > 0) {
|
|
63
|
+
console.log("[forge] pack FAILED — high-severity findings must be resolved first");
|
|
64
|
+
for (const f of findings.filter((x) => x.severity === "high")) {
|
|
65
|
+
console.log(` [high] ${f.rule}: ${f.file} — ${f.message}`);
|
|
66
|
+
}
|
|
67
|
+
process.exit(1);
|
|
68
|
+
}
|
|
69
|
+
if (opts.check) {
|
|
70
|
+
console.log("[forge] check passed");
|
|
71
|
+
return;
|
|
72
|
+
}
|
|
73
|
+
const files = (await listFiles(cwd)).sort();
|
|
74
|
+
if (files.length === 0) {
|
|
75
|
+
console.error("[forge] pack: no files to pack (empty directory?)");
|
|
76
|
+
process.exit(1);
|
|
77
|
+
}
|
|
78
|
+
const outDir = join(cwd, "dist");
|
|
79
|
+
if (!existsSync(outDir))
|
|
80
|
+
mkdirSync(outDir, { recursive: true });
|
|
81
|
+
const slug = name.replace("/", "-");
|
|
82
|
+
const tarball = join(outDir, `${slug}-${version}.tgz`);
|
|
83
|
+
try {
|
|
84
|
+
const fileList = files.map((f) => `"${f}"`).join("\n");
|
|
85
|
+
const listPath = join(cwd, ".forge-pack-list.txt");
|
|
86
|
+
writeFileSync(listPath, fileList + "\n");
|
|
87
|
+
execFileSync("python", ["-c", `
|
|
88
|
+
import tarfile, sys, os
|
|
89
|
+
cwd = os.getcwd()
|
|
90
|
+
with tarfile.open(sys.argv[1], 'w:gz') as tf:
|
|
91
|
+
for line in open('.forge-pack-list.txt'):
|
|
92
|
+
f = line.strip().strip('"')
|
|
93
|
+
if f and os.path.isfile(f):
|
|
94
|
+
tf.add(f, arcname=os.path.relpath(f, cwd))
|
|
95
|
+
`], { stdio: "pipe" });
|
|
96
|
+
const { unlinkSync } = await import("fs");
|
|
97
|
+
try {
|
|
98
|
+
unlinkSync(listPath);
|
|
99
|
+
}
|
|
100
|
+
catch { /* best-effort */ }
|
|
101
|
+
}
|
|
102
|
+
catch (e) {
|
|
103
|
+
console.error(`[forge] tarball creation failed: ${e.message}`);
|
|
104
|
+
process.exit(1);
|
|
105
|
+
}
|
|
106
|
+
let sha256;
|
|
107
|
+
try {
|
|
108
|
+
const buf = readFileSync(tarball);
|
|
109
|
+
sha256 = createHash("sha256").update(buf).digest("hex");
|
|
110
|
+
}
|
|
111
|
+
catch (e) {
|
|
112
|
+
console.error(`[forge] sha256 failed: ${e.message}`);
|
|
113
|
+
process.exit(1);
|
|
114
|
+
}
|
|
115
|
+
console.log(`[forge] packed ${tarball}`);
|
|
116
|
+
console.log(`[forge] sha256: ${sha256}`);
|
|
117
|
+
console.log(`[forge] files: ${files.length}`);
|
|
118
|
+
console.log(`[forge] size: ${statSync(tarball).size} bytes`);
|
|
119
|
+
}
|