@oomerevren/tryforge 0.1.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +25 -0
- package/LICENSE +21 -0
- package/README.md +361 -0
- package/dist/cli/src/adapters/claude.js +31 -0
- package/dist/cli/src/adapters/codex.js +30 -0
- package/dist/cli/src/adapters/cursor.js +42 -0
- package/dist/cli/src/adapters/dsh.js +46 -0
- package/dist/cli/src/adapters/generic.js +23 -0
- package/dist/cli/src/adapters/index.js +30 -0
- package/dist/cli/src/adapters/opencode.js +41 -0
- package/dist/cli/src/adapters/types.js +140 -0
- package/dist/cli/src/adapters/windsurf.js +43 -0
- package/dist/cli/src/commands/audit.js +62 -0
- package/dist/cli/src/commands/init.js +104 -0
- package/dist/cli/src/commands/install.js +212 -0
- package/dist/cli/src/commands/update.js +119 -0
- package/dist/cli/src/core/config.js +47 -0
- package/dist/cli/src/core/installer.js +279 -0
- package/dist/cli/src/core/lock.js +41 -0
- package/dist/cli/src/core/permissions.js +29 -0
- package/dist/cli/src/core/plugin.js +34 -0
- package/dist/cli/src/core/project.js +60 -0
- package/dist/cli/src/core/registry.js +137 -0
- package/dist/cli/src/core/semver.js +162 -0
- package/dist/cli/src/core/sign.js +92 -0
- package/dist/cli/src/core/store.js +71 -0
- package/dist/cli/src/index.js +484 -0
- package/dist/scripts/build-registry.js +145 -0
- package/dist/scripts/publish-verified.js +82 -0
- package/dist/scripts/seed-registry-13lite.js +208 -0
- package/dist/scripts/seed-registry.js +144 -0
- package/dist/scripts/verify-npm-mcps.js +60 -0
- package/forge.toml +18 -0
- package/install.ps1 +59 -0
- package/install.sh +81 -0
- package/package.json +64 -0
- package/registry/index.json +336 -0
- package/registry/packages/agent-changelog-writer.json +27 -0
- package/registry/packages/agent-debugger.json +27 -0
- package/registry/packages/agent-pr-reviewer.json +27 -0
- package/registry/packages/agent-researcher.json +27 -0
- package/registry/packages/agent-security-auditor.json +27 -0
- package/registry/packages/cmd-plan.json +26 -0
- package/registry/packages/cmd-review.json +26 -0
- package/registry/packages/mcp-filesystem.json +34 -0
- package/registry/packages/mcp-github.json +33 -0
- package/registry/packages/mcp-memory.json +33 -0
- package/registry/packages/mcp-postgres.json +34 -0
- package/registry/packages/mcp-sequential-thinking.json +33 -0
- package/registry/packages/obra-superpowers.json +29 -0
- package/registry/packages/pdf-compress.json +27 -0
- package/registry/packages/pdf-convert.json +27 -0
- package/registry/packages/pdf-extract.json +27 -0
- package/registry/packages/pdf-forms.json +27 -0
- package/registry/packages/pdf-merge.json +27 -0
- package/registry/packages/pdf-ocr.json +27 -0
- package/registry/packages/pdf-split.json +27 -0
- package/registry/packages/pdf-tables.json +27 -0
- package/registry/search.json +248 -0
- package/registry/stats.json +13 -0
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
import { readLinks } from "../core/store.js";
|
|
2
|
+
import { loadIndex, loadPackageDetail, resolveVersion } from "../core/registry.js";
|
|
3
|
+
import { compareSemver } from "../core/semver.js";
|
|
4
|
+
import { ensurePackageContent } from "../core/installer.js";
|
|
5
|
+
import { toSlug, ensureForgeDirs, writeLinks } from "../core/store.js";
|
|
6
|
+
import { detectAdapters, addMcpServerToConfig, allAdapters } from "../adapters/index.js";
|
|
7
|
+
import { loadConfig } from "../core/config.js";
|
|
8
|
+
import { readLock, writeLock } from "../core/lock.js";
|
|
9
|
+
import { findProjectToml, loadProjectToml } from "../core/project.js";
|
|
10
|
+
import { resolve as resolvePath } from "path";
|
|
11
|
+
function pickAdapters(harnesses) {
|
|
12
|
+
if (harnesses && harnesses.length > 0) {
|
|
13
|
+
const filtered = allAdapters.filter((a) => harnesses.includes(a.name));
|
|
14
|
+
if (filtered.length)
|
|
15
|
+
return filtered;
|
|
16
|
+
}
|
|
17
|
+
const cfg = loadConfig();
|
|
18
|
+
if (cfg.defaultHarnesses.length) {
|
|
19
|
+
const f = allAdapters.filter((a) => cfg.defaultHarnesses.includes(a.name));
|
|
20
|
+
if (f.length)
|
|
21
|
+
return f;
|
|
22
|
+
}
|
|
23
|
+
return detectAdapters();
|
|
24
|
+
}
|
|
25
|
+
export async function runOutdated(opts = {}) {
|
|
26
|
+
const t0 = Date.now();
|
|
27
|
+
const cwd = resolvePath(opts.cwd ?? process.cwd());
|
|
28
|
+
void cwd;
|
|
29
|
+
const links = readLinks();
|
|
30
|
+
const entries = Object.values(links);
|
|
31
|
+
if (entries.length === 0) {
|
|
32
|
+
console.log("[forge] no packages installed");
|
|
33
|
+
return;
|
|
34
|
+
}
|
|
35
|
+
// Faz 10 polish: index'i tek sefer yükle (20 pakette <10s hedefi için), semver-aware karşılaştır
|
|
36
|
+
const index = await loadIndex();
|
|
37
|
+
let outdated = 0;
|
|
38
|
+
for (const rec of entries) {
|
|
39
|
+
try {
|
|
40
|
+
const summary = index.packages[rec.pkg];
|
|
41
|
+
const latest = summary?.latest ?? (await loadPackageDetail(rec.pkg)).latest;
|
|
42
|
+
if (compareSemver(latest, rec.version) > 0) {
|
|
43
|
+
console.log(`${rec.pkg} ${rec.version} → ${latest} (latest)`);
|
|
44
|
+
outdated++;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
catch { }
|
|
48
|
+
}
|
|
49
|
+
const dt = ((Date.now() - t0) / 1000).toFixed(1);
|
|
50
|
+
if (outdated === 0)
|
|
51
|
+
console.log(`[forge] all packages up to date (${entries.length} checked in ${dt}s)`);
|
|
52
|
+
else
|
|
53
|
+
console.log(`\n[forge] ${outdated} package(s) outdated — run 'forge update' (${dt}s)`);
|
|
54
|
+
}
|
|
55
|
+
export async function runUpdate(pkgArg, opts = {}) {
|
|
56
|
+
const cwd = resolvePath(opts.cwd ?? process.cwd());
|
|
57
|
+
const links = readLinks();
|
|
58
|
+
const lock = readLock(cwd);
|
|
59
|
+
const tomlPath = findProjectToml(cwd);
|
|
60
|
+
let projectHarnesses;
|
|
61
|
+
if (tomlPath) {
|
|
62
|
+
try {
|
|
63
|
+
const proj = loadProjectToml(tomlPath);
|
|
64
|
+
projectHarnesses = proj.forge?.harnesses;
|
|
65
|
+
}
|
|
66
|
+
catch { }
|
|
67
|
+
}
|
|
68
|
+
const adapters = pickAdapters(projectHarnesses);
|
|
69
|
+
const targets = pkgArg ? [pkgArg] : Object.keys(links);
|
|
70
|
+
if (targets.length === 0) {
|
|
71
|
+
console.log("[forge] nothing to update — no installed packages");
|
|
72
|
+
return;
|
|
73
|
+
}
|
|
74
|
+
let updated = 0;
|
|
75
|
+
for (const name of targets) {
|
|
76
|
+
const rec = links[name];
|
|
77
|
+
if (!rec) {
|
|
78
|
+
console.warn(`[forge] ${name} not installed — skipping`);
|
|
79
|
+
continue;
|
|
80
|
+
}
|
|
81
|
+
try {
|
|
82
|
+
const detail = await loadPackageDetail(name);
|
|
83
|
+
const latest = detail.latest;
|
|
84
|
+
if (latest === rec.version) {
|
|
85
|
+
console.log(` = ${name}@${rec.version} already latest`);
|
|
86
|
+
continue;
|
|
87
|
+
}
|
|
88
|
+
const { version, versionMeta } = await resolveVersion(name, latest);
|
|
89
|
+
const src = await ensurePackageContent(name, version, detail, versionMeta, { allowMock: opts.mock });
|
|
90
|
+
for (const adapter of adapters) {
|
|
91
|
+
await adapter.install(toSlug(name), src, detail.type);
|
|
92
|
+
if (detail.type === "mcp" && versionMeta.mcp) {
|
|
93
|
+
const cfgPath = adapter.mcpConfigPath();
|
|
94
|
+
if (cfgPath)
|
|
95
|
+
addMcpServerToConfig(cfgPath, toSlug(name), versionMeta.mcp);
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
ensureForgeDirs();
|
|
99
|
+
links[name] = { ...rec, version, adapters: adapters.map((a) => a.name), installedAt: new Date().toISOString() };
|
|
100
|
+
writeLinks(links);
|
|
101
|
+
console.log(` ✓ ${name} ${rec.version} → ${version}`);
|
|
102
|
+
updated++;
|
|
103
|
+
}
|
|
104
|
+
catch (e) {
|
|
105
|
+
console.warn(` ✗ ${name} failed: ${e.message}`);
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
// Refresh lock if project has one
|
|
109
|
+
if (lock) {
|
|
110
|
+
const updatedEntries = Object.values(links).map((r) => ({ name: r.pkg, version: r.version, type: r.type ?? "skill" }));
|
|
111
|
+
updatedEntries.sort((a, b) => a.name.localeCompare(b.name));
|
|
112
|
+
writeLock(updatedEntries, cwd);
|
|
113
|
+
console.log(`[forge] lock refreshed: ${cwd}/forge.lock`);
|
|
114
|
+
}
|
|
115
|
+
if (updated === 0)
|
|
116
|
+
console.log("[forge] nothing updated");
|
|
117
|
+
else
|
|
118
|
+
console.log(`\n[forge] ✓ updated ${updated} package(s)`);
|
|
119
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "fs";
|
|
2
|
+
import { join } from "path";
|
|
3
|
+
import { parse, stringify } from "smol-toml";
|
|
4
|
+
import { forgeHome } from "./store.js";
|
|
5
|
+
const DEFAULT_CONFIG = {
|
|
6
|
+
registry: "registry/index.json",
|
|
7
|
+
defaultHarnesses: [],
|
|
8
|
+
autoUpdate: false,
|
|
9
|
+
telemetry: false,
|
|
10
|
+
};
|
|
11
|
+
export function configPath() {
|
|
12
|
+
return join(forgeHome(), "config.toml");
|
|
13
|
+
}
|
|
14
|
+
export function loadConfig() {
|
|
15
|
+
const p = configPath();
|
|
16
|
+
if (!existsSync(p))
|
|
17
|
+
return { ...DEFAULT_CONFIG };
|
|
18
|
+
try {
|
|
19
|
+
const raw = readFileSync(p, "utf-8");
|
|
20
|
+
const parsed = parse(raw);
|
|
21
|
+
return {
|
|
22
|
+
registry: typeof parsed.registry === "string" ? parsed.registry : DEFAULT_CONFIG.registry,
|
|
23
|
+
defaultHarnesses: Array.isArray(parsed.defaultHarnesses) ? parsed.defaultHarnesses : [],
|
|
24
|
+
autoUpdate: typeof parsed.autoUpdate === "boolean" ? parsed.autoUpdate : false,
|
|
25
|
+
telemetry: typeof parsed.telemetry === "boolean" ? parsed.telemetry : false,
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
catch {
|
|
29
|
+
return { ...DEFAULT_CONFIG };
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
export function saveConfig(cfg) {
|
|
33
|
+
const dir = forgeHome();
|
|
34
|
+
if (!existsSync(dir))
|
|
35
|
+
mkdirSync(dir, { recursive: true });
|
|
36
|
+
const content = stringify(cfg);
|
|
37
|
+
writeFileSync(configPath(), content);
|
|
38
|
+
}
|
|
39
|
+
export function ensureConfig() {
|
|
40
|
+
const p = configPath();
|
|
41
|
+
if (!existsSync(p)) {
|
|
42
|
+
const cfg = { ...DEFAULT_CONFIG };
|
|
43
|
+
saveConfig(cfg);
|
|
44
|
+
return cfg;
|
|
45
|
+
}
|
|
46
|
+
return loadConfig();
|
|
47
|
+
}
|
|
@@ -0,0 +1,279 @@
|
|
|
1
|
+
import { existsSync, mkdirSync, writeFileSync, rmSync, readFileSync } from "fs";
|
|
2
|
+
import { join, resolve, sep } from "path";
|
|
3
|
+
import { createHash } from "crypto";
|
|
4
|
+
import { tmpdir } from "os";
|
|
5
|
+
import { execFileSync } from "child_process";
|
|
6
|
+
import { packageDir, packagesDir, isPackageInstalled, toSlug, ensureForgeDirs, tarballsDir } from "./store.js";
|
|
7
|
+
/**
|
|
8
|
+
* Ensure ~/.forge/packages/<slug>@<version>/ exists with package content.
|
|
9
|
+
* Fail-closed: download/hash errors throw (exit-1 path in callers), partial
|
|
10
|
+
* dirs are cleaned up. Mock generation requires opts.allowMock (`--mock`).
|
|
11
|
+
* Returns the source dir path.
|
|
12
|
+
*/
|
|
13
|
+
export async function ensurePackageContent(pkgName, version, detail, versionMeta, opts = {}) {
|
|
14
|
+
const slug = toSlug(pkgName);
|
|
15
|
+
const dest = packageDir(slug, version);
|
|
16
|
+
if (isPackageInstalled(slug, version)) {
|
|
17
|
+
// Empty-dir guard: a leftover/partial dir must never read as "installed".
|
|
18
|
+
try {
|
|
19
|
+
const { readdirSync } = await import("fs");
|
|
20
|
+
if (readdirSync(dest).length > 0)
|
|
21
|
+
return dest;
|
|
22
|
+
rmSync(dest, { recursive: true, force: true });
|
|
23
|
+
}
|
|
24
|
+
catch {
|
|
25
|
+
return dest;
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
ensureForgeDirs();
|
|
29
|
+
const tarballUrl = versionMeta.tarball ?? "";
|
|
30
|
+
const sha256 = versionMeta.sha256 ?? "";
|
|
31
|
+
const isPlaceholder = sha256.startsWith("placeholder") || tarballUrl.includes("placeholder");
|
|
32
|
+
// No verified tarball in registry: mock ONLY with explicit opt-in, else hard error.
|
|
33
|
+
if (isPlaceholder) {
|
|
34
|
+
if (!opts.allowMock) {
|
|
35
|
+
throw new Error(`[forge] ${pkgName}@${version} has no verified tarball yet (placeholder SHA).\n` +
|
|
36
|
+
`[forge] Re-run with --mock to install mock content, or run 'forge audit' to see verification status.`);
|
|
37
|
+
}
|
|
38
|
+
console.warn(`[forge] warning: installing MOCK content for ${pkgName}@${version} (--mock)`);
|
|
39
|
+
let createdMockDir = false;
|
|
40
|
+
if (!existsSync(dest)) {
|
|
41
|
+
mkdirSync(dest, { recursive: true });
|
|
42
|
+
createdMockDir = true;
|
|
43
|
+
}
|
|
44
|
+
try {
|
|
45
|
+
generateMockPackage(pkgName, version, detail, versionMeta, dest);
|
|
46
|
+
}
|
|
47
|
+
catch (err) {
|
|
48
|
+
if (createdMockDir) {
|
|
49
|
+
try {
|
|
50
|
+
rmSync(dest, { recursive: true, force: true });
|
|
51
|
+
}
|
|
52
|
+
catch { }
|
|
53
|
+
}
|
|
54
|
+
throw err;
|
|
55
|
+
}
|
|
56
|
+
return dest;
|
|
57
|
+
}
|
|
58
|
+
// Real tarball: fail-closed. Any download/hash/extract error throws and
|
|
59
|
+
// cleans up the partial dir so a half-install never reads as "installed".
|
|
60
|
+
let createdDir = false;
|
|
61
|
+
if (!existsSync(dest)) {
|
|
62
|
+
mkdirSync(dest, { recursive: true });
|
|
63
|
+
createdDir = true;
|
|
64
|
+
}
|
|
65
|
+
try {
|
|
66
|
+
await downloadAndExtract(tarballUrl, sha256, dest, slug);
|
|
67
|
+
return dest;
|
|
68
|
+
}
|
|
69
|
+
catch (err) {
|
|
70
|
+
if (createdDir) {
|
|
71
|
+
try {
|
|
72
|
+
rmSync(dest, { recursive: true, force: true });
|
|
73
|
+
}
|
|
74
|
+
catch { }
|
|
75
|
+
}
|
|
76
|
+
throw new Error(`[forge] download/verify failed for ${pkgName}@${version}: ${err.message}\n` +
|
|
77
|
+
`[forge] Nothing was installed. Re-run with --mock to install mock content instead.`);
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
function generateMockPackage(pkgName, version, detail, versionMeta, dest) {
|
|
81
|
+
const type = detail.type;
|
|
82
|
+
const skillName = pkgName.split("/")[1] ?? pkgName;
|
|
83
|
+
// Faz 11: her tip gerçek iskelet üretir — forge add her tipi doğru yere kurar
|
|
84
|
+
if (type === "skill") {
|
|
85
|
+
const content = `# ${skillName} — ${detail.description}
|
|
86
|
+
|
|
87
|
+
> Installed via Forge v0.1 — ${pkgName}@${version}
|
|
88
|
+
> Source: ${detail.repository ?? detail.homepage ?? "registry"}
|
|
89
|
+
> Type: ${type}
|
|
90
|
+
|
|
91
|
+
## Usage
|
|
92
|
+
This is a mock package generated by Forge when the real tarball is not yet available.
|
|
93
|
+
Replace with real content when tarball download is enabled.
|
|
94
|
+
|
|
95
|
+
## Info
|
|
96
|
+
- Package: ${pkgName}
|
|
97
|
+
- Version: ${version}
|
|
98
|
+
- Type: ${type}
|
|
99
|
+
- Keywords: ${(detail.keywords ?? []).join(", ")}
|
|
100
|
+
|
|
101
|
+
## Forge
|
|
102
|
+
\`\`\`bash
|
|
103
|
+
forge info ${pkgName}
|
|
104
|
+
forge list
|
|
105
|
+
forge doctor
|
|
106
|
+
\`\`\`
|
|
107
|
+
`;
|
|
108
|
+
writeFileSync(join(dest, "SKILL.md"), content);
|
|
109
|
+
}
|
|
110
|
+
if (type === "agent") {
|
|
111
|
+
writeFileSync(join(dest, "agent.md"), `# ${skillName} — ${detail.description}\n\n> Installed via Forge — ${pkgName}@${version}\n\nDefine your agent's system prompt here.\n`);
|
|
112
|
+
writeFileSync(join(dest, "SKILL.md"), `# ${skillName} — agent\n\nSee agent.md for system prompt.\n`);
|
|
113
|
+
}
|
|
114
|
+
if (type === "command") {
|
|
115
|
+
writeFileSync(join(dest, "command.md"), `# ${skillName} — ${detail.description}\n\n> Installed via Forge — ${pkgName}@${version}\n\nSlash command: /${skillName}\n`);
|
|
116
|
+
}
|
|
117
|
+
if (type === "hook") {
|
|
118
|
+
writeFileSync(join(dest, "hook.json"), JSON.stringify({ name: pkgName, hooks: { "pre-tool": { entry: "./hook.js" } } }, null, 2) + "\n");
|
|
119
|
+
writeFileSync(join(dest, "hook.js"), `// ${pkgName} hook entry\nmodule.exports = {};\n`);
|
|
120
|
+
}
|
|
121
|
+
if (type === "plugin") {
|
|
122
|
+
writeFileSync(join(dest, "plugin.json"), JSON.stringify({ name: skillName, entry: "index.js", hooks: ["pre-tool", "post-tool"] }, null, 2) + "\n");
|
|
123
|
+
writeFileSync(join(dest, "index.js"), `// ${pkgName} plugin entry\nmodule.exports = {};\n`);
|
|
124
|
+
}
|
|
125
|
+
// MCP: also write a forge-mcp.json manifest for adapter use (Faz 11: mcp.json dahil)
|
|
126
|
+
if (type === "mcp" && versionMeta.mcp) {
|
|
127
|
+
writeFileSync(join(dest, "forge-mcp.json"), JSON.stringify(versionMeta.mcp, null, 2));
|
|
128
|
+
writeFileSync(join(dest, "mcp.json"), JSON.stringify({ mcpServers: { [skillName]: versionMeta.mcp } }, null, 2) + "\n");
|
|
129
|
+
// also SKILL.md for discoverability
|
|
130
|
+
writeFileSync(join(dest, "SKILL.md"), `# ${pkgName} — MCP Server\n\nMCP command: \`${versionMeta.mcp.command} ${(versionMeta.mcp.args ?? []).join(" ")}\`\n`);
|
|
131
|
+
}
|
|
132
|
+
// forge.toml manifest inside package (for debugging / audit)
|
|
133
|
+
const manifest = `# Generated by Forge — mock package
|
|
134
|
+
[package]
|
|
135
|
+
name = "${pkgName}"
|
|
136
|
+
version = "${version}"
|
|
137
|
+
type = "${type}"
|
|
138
|
+
description = "${detail.description.replace(/"/g, '\\"')}"
|
|
139
|
+
`;
|
|
140
|
+
writeFileSync(join(dest, "forge.toml"), manifest);
|
|
141
|
+
// Also write .forge-meta.json for tooling
|
|
142
|
+
writeFileSync(join(dest, ".forge-meta.json"), JSON.stringify({ name: pkgName, version, type, installedAt: new Date().toISOString(), tarball: versionMeta.tarball }, null, 2));
|
|
143
|
+
// Fail-closed marker: lets `forge audit` / `forge doctor` distinguish mock
|
|
144
|
+
// content from verified installs. Never write this for real tarballs.
|
|
145
|
+
writeFileSync(join(dest, ".forge-mock"), JSON.stringify({ mock: true, name: pkgName, version, installedAt: new Date().toISOString() }, null, 2) + "\n");
|
|
146
|
+
}
|
|
147
|
+
async function downloadAndExtract(url, expectedSha256, dest, slug) {
|
|
148
|
+
// Path-escape guard: dest must stay inside the packages store.
|
|
149
|
+
const storeRoot = resolve(packagesDir()) + sep;
|
|
150
|
+
if (!resolve(dest).startsWith(storeRoot)) {
|
|
151
|
+
throw new Error(`refusing to extract outside packages dir: ${dest}`);
|
|
152
|
+
}
|
|
153
|
+
const tmpFile = join(tmpdir(), `forge-${process.pid}-${Date.now()}-${Math.random().toString(36).slice(2)}.tgz`);
|
|
154
|
+
// Prefer gh CLI for GitHub release assets (handles auth/redirects).
|
|
155
|
+
if (url.includes("github.com") && url.includes("/releases/download/")) {
|
|
156
|
+
try {
|
|
157
|
+
execFileSync("gh", ["release", "download", "--repo", "oomerevren-beep/forge", "--pattern", "*.tar.gz", "-D", tmpdir(), "--clobber"], { stdio: "pipe" });
|
|
158
|
+
// Find the downloaded file
|
|
159
|
+
const { readdirSync } = await import("fs");
|
|
160
|
+
const files = readdirSync(tmpdir()).filter(f => f.endsWith(".tar.gz") && f.startsWith(slug));
|
|
161
|
+
if (files.length > 0) {
|
|
162
|
+
const downloaded = join(tmpdir(), files[0]);
|
|
163
|
+
const buf = readFileSync(downloaded);
|
|
164
|
+
const sha256 = createHash("sha256").update(buf).digest("hex");
|
|
165
|
+
if (sha256 !== expectedSha256) {
|
|
166
|
+
throw new Error(`sha256 mismatch: expected ${expectedSha256}, got ${sha256}`);
|
|
167
|
+
}
|
|
168
|
+
// Use Python tarfile for extraction (works reliably on Windows + Unix)
|
|
169
|
+
// Epoch 1e: filter='data' prevents tar-slip (../ path traversal) and symlink escapes
|
|
170
|
+
execFileSync("python", ["-c", `
|
|
171
|
+
import tarfile, os, sys
|
|
172
|
+
with tarfile.open(sys.argv[1], 'r:gz') as tf:
|
|
173
|
+
tf.extractall(sys.argv[2], filter='data')
|
|
174
|
+
`, downloaded, dest], { stdio: "pipe" });
|
|
175
|
+
rmSync(downloaded, { force: true });
|
|
176
|
+
return;
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
catch (e) {
|
|
180
|
+
// fall through to fetch fallback
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
// Fallback: fetch (works for public URLs without auth)
|
|
184
|
+
const res = await fetch(url);
|
|
185
|
+
if (!res.ok)
|
|
186
|
+
throw new Error(`HTTP ${res.status} for ${url}`);
|
|
187
|
+
const buf = Buffer.from(await res.arrayBuffer());
|
|
188
|
+
// Verify sha256
|
|
189
|
+
if (expectedSha256 && !expectedSha256.startsWith("placeholder")) {
|
|
190
|
+
const hash = createHash("sha256").update(buf).digest("hex");
|
|
191
|
+
if (hash !== expectedSha256) {
|
|
192
|
+
throw new Error(`SHA256 mismatch: expected ${expectedSha256}, got ${hash}`);
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
// Save to cache (filename namespaced by slug to avoid collisions across scopes)
|
|
196
|
+
const rawName = url.split("/").pop() || "package.tgz";
|
|
197
|
+
const safeName = rawName.replace(/[^a-zA-Z0-9._-]/g, "_");
|
|
198
|
+
const cached = join(tarballsDir(), `${slug}--${safeName}`);
|
|
199
|
+
try {
|
|
200
|
+
const { writeFileSync } = await import("fs");
|
|
201
|
+
writeFileSync(cached, buf);
|
|
202
|
+
}
|
|
203
|
+
catch {
|
|
204
|
+
// ignore cache write fail
|
|
205
|
+
}
|
|
206
|
+
// Write to temp file for extraction
|
|
207
|
+
const { writeFileSync: wfs } = await import("fs");
|
|
208
|
+
wfs(tmpFile, buf);
|
|
209
|
+
// Extract via Python tarfile (works reliably on Windows + Unix)
|
|
210
|
+
// Epoch 1e: filter='data' prevents tar-slip (../ path traversal) and symlink escapes
|
|
211
|
+
try {
|
|
212
|
+
execFileSync("python", ["-c", `
|
|
213
|
+
import tarfile, os, sys
|
|
214
|
+
with tarfile.open(sys.argv[1], 'r:gz') as tf:
|
|
215
|
+
tf.extractall(sys.argv[2], filter='data')
|
|
216
|
+
`, tmpFile, dest], { stdio: "pipe" });
|
|
217
|
+
}
|
|
218
|
+
catch (e) {
|
|
219
|
+
throw new Error(`tar extract failed: ${e.message}`);
|
|
220
|
+
}
|
|
221
|
+
finally {
|
|
222
|
+
try {
|
|
223
|
+
const { unlinkSync } = await import("fs");
|
|
224
|
+
unlinkSync(tmpFile);
|
|
225
|
+
}
|
|
226
|
+
catch { }
|
|
227
|
+
}
|
|
228
|
+
// Defense-in-depth: filter='data' already strips symlinks and ../ escapes,
|
|
229
|
+
// but we verify post-extraction as a safety net.
|
|
230
|
+
await assertNoSymlinks(dest);
|
|
231
|
+
}
|
|
232
|
+
/** Walk dest; throw on any symlink entry OR path that escapes the dest root.
|
|
233
|
+
* Epoch 1e: symlinks that stay WITHIN the dest dir are safe (GitHub archives use them). */
|
|
234
|
+
async function assertNoSymlinks(dest) {
|
|
235
|
+
const { readdirSync, lstatSync, realpathSync, readlinkSync } = await import("fs");
|
|
236
|
+
const destReal = realpathSync(dest);
|
|
237
|
+
const stack = [dest];
|
|
238
|
+
while (stack.length > 0) {
|
|
239
|
+
const dir = stack.pop();
|
|
240
|
+
for (const entry of readdirSync(dir, { withFileTypes: true })) {
|
|
241
|
+
const full = join(dir, entry.name);
|
|
242
|
+
const stat = lstatSync(full);
|
|
243
|
+
if (stat.isSymbolicLink()) {
|
|
244
|
+
// Epoch 1e: allow symlinks that resolve within the package dir (GitHub archives)
|
|
245
|
+
try {
|
|
246
|
+
const linkTarget = readlinkSync(full);
|
|
247
|
+
const resolved = realpathSync(full);
|
|
248
|
+
if (!resolved.startsWith(destReal + sep) && resolved !== destReal) {
|
|
249
|
+
throw new Error(`tarball symlink escapes package dir: ${entry.name} → ${linkTarget}`);
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
catch (e) {
|
|
253
|
+
if (e.message?.includes("escapes"))
|
|
254
|
+
throw e;
|
|
255
|
+
// If we can't resolve, refuse
|
|
256
|
+
throw new Error(`tarball contains unresolvable symlink (${entry.name}) — refusing to install`);
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
else {
|
|
260
|
+
// Epoch 1e: verify resolved path stays within dest (anti-tar-slip)
|
|
261
|
+
try {
|
|
262
|
+
const fullReal = realpathSync(full);
|
|
263
|
+
if (!fullReal.startsWith(destReal + sep) && fullReal !== destReal) {
|
|
264
|
+
throw new Error(`tarball entry escapes package dir: ${entry.name}`);
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
catch (e) {
|
|
268
|
+
if (e.message?.includes("escapes"))
|
|
269
|
+
throw e;
|
|
270
|
+
}
|
|
271
|
+
if (stat.isDirectory())
|
|
272
|
+
stack.push(full);
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
export function isPlaceholderSha(sha) {
|
|
278
|
+
return sha.startsWith("placeholder");
|
|
279
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { existsSync, readFileSync, writeFileSync } from "fs";
|
|
2
|
+
import { join, resolve } from "path";
|
|
3
|
+
import { parse } from "smol-toml";
|
|
4
|
+
export function lockPath(cwd = process.cwd()) {
|
|
5
|
+
return join(resolve(cwd), "forge.lock");
|
|
6
|
+
}
|
|
7
|
+
export function readLock(cwd = process.cwd()) {
|
|
8
|
+
const p = lockPath(cwd);
|
|
9
|
+
if (!existsSync(p))
|
|
10
|
+
return null;
|
|
11
|
+
try {
|
|
12
|
+
const raw = readFileSync(p, "utf-8");
|
|
13
|
+
const parsed = parse(raw);
|
|
14
|
+
const pkgs = parsed.packages;
|
|
15
|
+
if (!Array.isArray(pkgs))
|
|
16
|
+
return null;
|
|
17
|
+
const packages = pkgs.map((x) => ({
|
|
18
|
+
name: String(x.name),
|
|
19
|
+
version: String(x.version),
|
|
20
|
+
type: String(x.type ?? "skill"),
|
|
21
|
+
}));
|
|
22
|
+
return { packages };
|
|
23
|
+
}
|
|
24
|
+
catch {
|
|
25
|
+
return null;
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
export function writeLock(entries, cwd = process.cwd()) {
|
|
29
|
+
const p = lockPath(cwd);
|
|
30
|
+
// smol-toml stringify doesn't support array-of-tables well via object; craft manually
|
|
31
|
+
// Use [[packages]] TOML array of tables
|
|
32
|
+
const lines = ['# Generated by Forge — do not edit manually', ''];
|
|
33
|
+
for (const e of entries) {
|
|
34
|
+
lines.push("[[packages]]");
|
|
35
|
+
lines.push(`name = "${e.name}"`);
|
|
36
|
+
lines.push(`version = "${e.version}"`);
|
|
37
|
+
lines.push(`type = "${e.type}"`);
|
|
38
|
+
lines.push("");
|
|
39
|
+
}
|
|
40
|
+
writeFileSync(p, lines.join("\n"));
|
|
41
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
export const DEFAULT_PERMISSIONS = {
|
|
2
|
+
network: false,
|
|
3
|
+
filesystem: "read",
|
|
4
|
+
exec: false,
|
|
5
|
+
env: [],
|
|
6
|
+
};
|
|
7
|
+
export function validatePermissions(manifest) {
|
|
8
|
+
const errors = [];
|
|
9
|
+
if (manifest.network && typeof manifest.network !== "boolean") {
|
|
10
|
+
errors.push("network must be boolean");
|
|
11
|
+
}
|
|
12
|
+
if (manifest.filesystem && !["read", "write", "none"].includes(manifest.filesystem)) {
|
|
13
|
+
errors.push("filesystem must be 'read', 'write', or 'none'");
|
|
14
|
+
}
|
|
15
|
+
if (manifest.exec && typeof manifest.exec !== "boolean") {
|
|
16
|
+
errors.push("exec must be boolean");
|
|
17
|
+
}
|
|
18
|
+
return errors;
|
|
19
|
+
}
|
|
20
|
+
export function formatPermissions(manifest) {
|
|
21
|
+
const parts = [];
|
|
22
|
+
parts.push(`Network: ${manifest.network ? "allowed" : "denied"}`);
|
|
23
|
+
parts.push(`Filesystem: ${manifest.filesystem ?? "read"}`);
|
|
24
|
+
parts.push(`Exec: ${manifest.exec ? "allowed" : "denied"}`);
|
|
25
|
+
if (manifest.env && manifest.env.length > 0) {
|
|
26
|
+
parts.push(`Env: ${manifest.env.join(", ")}`);
|
|
27
|
+
}
|
|
28
|
+
return parts.join(" | ");
|
|
29
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
// cli/src/core/plugin.ts — Epoch 1e: adapter plugin sistemi
|
|
2
|
+
// Adapter'ları CLI çekirdeğinden ayırarak dinamik yükleme
|
|
3
|
+
import { existsSync, readdirSync } from "fs";
|
|
4
|
+
import { join } from "path";
|
|
5
|
+
/**
|
|
6
|
+
* Dynamically load adapter plugins from a directory.
|
|
7
|
+
* Each .ts/.js file must export a default Adapter or an `adapter` named export.
|
|
8
|
+
*/
|
|
9
|
+
export async function loadAdapterPlugins(pluginDir) {
|
|
10
|
+
if (!existsSync(pluginDir))
|
|
11
|
+
return [];
|
|
12
|
+
const plugins = [];
|
|
13
|
+
const files = readdirSync(pluginDir).filter((f) => f.endsWith(".ts") || f.endsWith(".js"));
|
|
14
|
+
for (const file of files) {
|
|
15
|
+
try {
|
|
16
|
+
const mod = await import(join(pluginDir, file));
|
|
17
|
+
const adapter = mod.default ?? mod.adapter;
|
|
18
|
+
if (adapter && typeof adapter.name === "string") {
|
|
19
|
+
plugins.push({ name: adapter.name, adapter });
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
catch (e) {
|
|
23
|
+
console.warn(`[forge] failed to load adapter plugin ${file}: ${e.message}`);
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
return plugins;
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* Load built-in adapters (bundled with CLI)
|
|
30
|
+
*/
|
|
31
|
+
export function loadBuiltInAdapters() {
|
|
32
|
+
// Dynamic imports for tree-shaking
|
|
33
|
+
return [];
|
|
34
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { existsSync, readFileSync } from "fs";
|
|
2
|
+
import { join, resolve } from "path";
|
|
3
|
+
import { parse } from "smol-toml";
|
|
4
|
+
import { isValidRange } from "./semver.js";
|
|
5
|
+
export const DEP_NAME_RE = /^[a-z0-9-]+\/[a-z0-9-]+$/;
|
|
6
|
+
export function findProjectToml(cwd) {
|
|
7
|
+
const candidate = join(resolve(cwd), "forge.toml");
|
|
8
|
+
if (existsSync(candidate))
|
|
9
|
+
return candidate;
|
|
10
|
+
return null;
|
|
11
|
+
}
|
|
12
|
+
export function loadProjectToml(path) {
|
|
13
|
+
let raw;
|
|
14
|
+
try {
|
|
15
|
+
raw = readFileSync(path, "utf-8");
|
|
16
|
+
}
|
|
17
|
+
catch (e) {
|
|
18
|
+
throw new Error(`Cannot read ${path}: ${e.message}`);
|
|
19
|
+
}
|
|
20
|
+
let parsed;
|
|
21
|
+
try {
|
|
22
|
+
parsed = parse(raw);
|
|
23
|
+
}
|
|
24
|
+
catch (e) {
|
|
25
|
+
throw new Error(`${path}: invalid TOML — ${e.message}`);
|
|
26
|
+
}
|
|
27
|
+
// detect author [package] file (not a project install target)
|
|
28
|
+
if (parsed.package && !parsed.dependencies && !parsed.project) {
|
|
29
|
+
throw new Error(`${path} looks like a package manifest ([package]), not a project file. ` +
|
|
30
|
+
`Use 'forge add <pkg>' or create a project forge.toml with [dependencies].`);
|
|
31
|
+
}
|
|
32
|
+
const deps = (parsed.dependencies ?? {});
|
|
33
|
+
if (typeof deps !== "object" || Array.isArray(deps)) {
|
|
34
|
+
throw new Error(`${path}: [dependencies] must be a table`);
|
|
35
|
+
}
|
|
36
|
+
const dependencies = {};
|
|
37
|
+
for (const [k, v] of Object.entries(deps)) {
|
|
38
|
+
if (typeof v !== "string")
|
|
39
|
+
throw new Error(`${path}: dependency "${k}" must be a version string, got ${typeof v}`);
|
|
40
|
+
dependencies[k] = v;
|
|
41
|
+
}
|
|
42
|
+
const forge = parsed.forge;
|
|
43
|
+
const project = parsed.project;
|
|
44
|
+
return { project, dependencies, forge, package: parsed.package };
|
|
45
|
+
}
|
|
46
|
+
export function validateProjectToml(p, pathForMsg = "forge.toml") {
|
|
47
|
+
const errs = [];
|
|
48
|
+
for (const [name, range] of Object.entries(p.dependencies)) {
|
|
49
|
+
if (!DEP_NAME_RE.test(name))
|
|
50
|
+
errs.push(`Invalid dependency name "${name}" — expected scope/name (e.g. anthropics/plan)`);
|
|
51
|
+
if (!range || typeof range !== "string")
|
|
52
|
+
errs.push(`Invalid range for "${name}": ${String(range)}`);
|
|
53
|
+
else if (!isValidRange(range))
|
|
54
|
+
errs.push(`Invalid semver range for "${name}": "${range}"`);
|
|
55
|
+
}
|
|
56
|
+
if (p.forge?.harnesses !== undefined && !Array.isArray(p.forge.harnesses)) {
|
|
57
|
+
errs.push(`[forge].harnesses must be an array`);
|
|
58
|
+
}
|
|
59
|
+
return errs;
|
|
60
|
+
}
|