orkestrate 0.1.14 → 0.2.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/AGENTS.md +56 -0
- package/CONTRIBUTING.md +35 -0
- package/README.md +38 -58
- package/SECURITY.md +24 -0
- package/bin/orkestrate.ts +2 -0
- package/docs/concepts.md +119 -0
- package/docs/demo-extension-builder.md +82 -0
- package/docs/extensions/adapters.md +57 -0
- package/docs/extensions/architecture.md +49 -0
- package/docs/extensions/introduction.md +26 -0
- package/docs/getting-started.md +85 -0
- package/docs/hosted-registry.md +90 -0
- package/docs/pack-authoring.md +75 -0
- package/docs/roadmap.md +59 -0
- package/docs/troubleshooting.md +28 -0
- package/extensions/opencode-adapter/index.ts +106 -0
- package/extensions/opencode-adapter/orkestrate.extension.json +17 -0
- package/package.json +40 -33
- package/packs/coding/harnesses/opencode/agents/coding.md +8 -0
- package/packs/coding/harnesses/opencode/opencode.json +24 -0
- package/packs/coding/harnesses/opencode/skills/orkestrate/SKILL.md +57 -0
- package/packs/coding/pack.yaml +5 -0
- package/packs/extension-builder/harnesses/opencode/agents/extension-builder.md +8 -0
- package/packs/extension-builder/harnesses/opencode/opencode.json +31 -0
- package/packs/extension-builder/harnesses/opencode/skills/orkestrate/SKILL.md +54 -0
- package/packs/extension-builder/harnesses/opencode/skills/orkestrate-pack-author/SKILL.md +59 -0
- package/packs/extension-builder/pack.yaml +5 -0
- package/src/cli/cmd/extension-submit.ts +267 -0
- package/src/cli/cmd/pack-create.ts +43 -0
- package/src/cli/cmd/pack.ts +53 -0
- package/src/cli/cmd/profile-create.ts +199 -0
- package/src/cli/cmd/profile-submit.ts +236 -0
- package/src/cli/cmd/profile-validate.ts +5 -0
- package/src/cli/cmd/registry.ts +66 -0
- package/src/cli/cmd/run.ts +37 -0
- package/src/cli/index.ts +163 -0
- package/src/cli/tui.ts +355 -0
- package/src/cli/ui/welcome.ts +73 -0
- package/src/cli.ts +1 -0
- package/src/sdk/cross-platform.ts +25 -0
- package/src/sdk/extensions/loader.ts +89 -0
- package/src/sdk/extensions/manifest.ts +193 -0
- package/src/sdk/extensions/types.ts +12 -0
- package/src/sdk/harness/sync-slice.ts +57 -0
- package/src/sdk/launch/broker.ts +87 -0
- package/src/sdk/launch/runner.ts +57 -0
- package/src/sdk/launch/terminal.ts +75 -0
- package/src/sdk/launch/types.ts +7 -0
- package/src/sdk/launch/windows.ts +109 -0
- package/src/sdk/packs/catalog.ts +172 -0
- package/src/sdk/packs/create.ts +99 -0
- package/src/sdk/packs/fs.ts +52 -0
- package/src/sdk/packs/github.ts +249 -0
- package/src/sdk/packs/paths.ts +19 -0
- package/src/sdk/packs/registry.ts +40 -0
- package/src/sdk/packs/schema.ts +51 -0
- package/src/sdk/packs/store.ts +172 -0
- package/src/sdk/profiles/catalog.ts +199 -0
- package/src/sdk/profiles/github.ts +177 -0
- package/src/sdk/profiles/install.ts +161 -0
- package/src/sdk/profiles/load.ts +209 -0
- package/src/sdk/profiles/materialize.ts +85 -0
- package/src/sdk/profiles/pack.ts +128 -0
- package/src/sdk/profiles/schema.ts +201 -0
- package/src/sdk/registry.ts +19 -0
- package/src/sdk/runs/registry.ts +142 -0
- package/src/sdk/runs/types.ts +15 -0
- package/src/sdk/types.ts +39 -0
- package/src/version.ts +3 -0
- package/dist/cli.js +0 -1668
- package/dist/cli.js.map +0 -1
- package/dist/mcp-entry.js +0 -181
- package/dist/mcp-entry.js.map +0 -1
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
import { cp, mkdir, readdir, rm, stat, writeFile } from "node:fs/promises";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { parsePackManifest, toPack, type Pack } from "./schema";
|
|
4
|
+
import { findPackManifestInDir, listCatalogEntries, pathExists, parseManifestYaml } from "./fs";
|
|
5
|
+
import { globalPacksDir, harnessSliceDir, PACK_MANIFEST, workspacePacksDir } from "./paths";
|
|
6
|
+
|
|
7
|
+
const moduleDir = import.meta.dir ?? process.cwd();
|
|
8
|
+
export const bundledCatalogDir = join(moduleDir, "..", "..", "..", "packs");
|
|
9
|
+
|
|
10
|
+
export type PackLocation = {
|
|
11
|
+
pack: Pack;
|
|
12
|
+
scope: "workspace" | "global" | "bundled";
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
async function loadPackFromDir(dir: string, scope: PackLocation["scope"]): Promise<Pack | null> {
|
|
16
|
+
const manifestPath = await findPackManifestInDir(dir);
|
|
17
|
+
if (!manifestPath) return null;
|
|
18
|
+
const raw = await Bun.file(manifestPath).text();
|
|
19
|
+
const manifest = parsePackManifest(parseManifestYaml(raw));
|
|
20
|
+
return toPack(manifest, dir, manifestPath);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
async function discoverInstalled(baseDir: string, scope: "workspace" | "global"): Promise<Pack[]> {
|
|
24
|
+
const found: Pack[] = [];
|
|
25
|
+
try {
|
|
26
|
+
const names = await readdir(baseDir);
|
|
27
|
+
for (const name of names) {
|
|
28
|
+
const full = join(baseDir, name);
|
|
29
|
+
const info = await stat(full);
|
|
30
|
+
if (!info.isDirectory()) continue;
|
|
31
|
+
const pack = await loadPackFromDir(full, scope);
|
|
32
|
+
if (pack) found.push(pack);
|
|
33
|
+
}
|
|
34
|
+
} catch {
|
|
35
|
+
return [];
|
|
36
|
+
}
|
|
37
|
+
return found;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export async function ensurePackStore(): Promise<void> {
|
|
41
|
+
await mkdir(globalPacksDir, { recursive: true });
|
|
42
|
+
await mkdir(workspacePacksDir, { recursive: true });
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export async function listInstalledPacks(): Promise<PackLocation[]> {
|
|
46
|
+
await ensurePackStore();
|
|
47
|
+
await seedBundledToGlobalIfMissing();
|
|
48
|
+
const global = (await discoverInstalled(globalPacksDir, "global")).map((pack) => ({
|
|
49
|
+
pack,
|
|
50
|
+
scope: "global" as const,
|
|
51
|
+
}));
|
|
52
|
+
const workspace = (await discoverInstalled(workspacePacksDir, "workspace")).map((pack) => ({
|
|
53
|
+
pack,
|
|
54
|
+
scope: "workspace" as const,
|
|
55
|
+
}));
|
|
56
|
+
const byId = new Map<string, PackLocation>();
|
|
57
|
+
for (const loc of global) byId.set(loc.pack.id, loc);
|
|
58
|
+
for (const loc of workspace) byId.set(loc.pack.id, loc);
|
|
59
|
+
return [...byId.values()].sort((a, b) => a.pack.id.localeCompare(b.pack.id));
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export async function resolvePack(packId: string): Promise<Pack> {
|
|
63
|
+
const locations = await listInstalledPacks();
|
|
64
|
+
const hit = locations.find((l) => l.pack.id === packId);
|
|
65
|
+
if (hit) return hit.pack;
|
|
66
|
+
|
|
67
|
+
const bundled = join(bundledCatalogDir, packId);
|
|
68
|
+
const fromBundled = await loadPackFromDir(bundled, "bundled");
|
|
69
|
+
if (fromBundled) return fromBundled;
|
|
70
|
+
|
|
71
|
+
throw new Error(`Pack "${packId}" is not installed. Run: orkestrate pack install ${packId}`);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export async function installPackFromDirectory(
|
|
75
|
+
sourceRoot: string,
|
|
76
|
+
options: { target?: "workspace" | "global"; overwrite?: boolean } = {}
|
|
77
|
+
): Promise<Pack> {
|
|
78
|
+
const manifestPath = await findPackManifestInDir(sourceRoot);
|
|
79
|
+
if (!manifestPath) {
|
|
80
|
+
throw new Error(`Source does not contain ${PACK_MANIFEST}`);
|
|
81
|
+
}
|
|
82
|
+
const raw = await Bun.file(manifestPath).text();
|
|
83
|
+
const manifest = parsePackManifest(parseManifestYaml(raw));
|
|
84
|
+
const pack = toPack(manifest, sourceRoot, manifestPath);
|
|
85
|
+
|
|
86
|
+
const target = options.target ?? "workspace";
|
|
87
|
+
const base = target === "global" ? globalPacksDir : workspacePacksDir;
|
|
88
|
+
const dest = join(base, pack.id);
|
|
89
|
+
|
|
90
|
+
if ((await pathExists(dest)) && !options.overwrite) {
|
|
91
|
+
throw new Error(`Pack "${pack.id}" is already installed at ${dest}`);
|
|
92
|
+
}
|
|
93
|
+
if (await pathExists(dest)) {
|
|
94
|
+
await rm(dest, { recursive: true, force: true });
|
|
95
|
+
}
|
|
96
|
+
await mkdir(base, { recursive: true });
|
|
97
|
+
await cp(sourceRoot, dest, { recursive: true, force: true });
|
|
98
|
+
|
|
99
|
+
return toPack(manifest, dest, join(dest, PACK_MANIFEST));
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export async function validatePackLayout(pack: Pack): Promise<{ errors: string[]; warnings: string[] }> {
|
|
103
|
+
const errors: string[] = [];
|
|
104
|
+
const warnings: string[] = [];
|
|
105
|
+
|
|
106
|
+
if (!(await pathExists(pack.manifestPath))) {
|
|
107
|
+
errors.push(`Missing ${PACK_MANIFEST} at pack root (${pack.packRoot})`);
|
|
108
|
+
return { errors, warnings };
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
const infoPath = join(pack.packRoot, "info.md");
|
|
112
|
+
if (!(await pathExists(infoPath))) {
|
|
113
|
+
warnings.push("Missing info.md (recommended pack readme for authors and agents)");
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
const slice = harnessSliceDir(pack.packRoot, pack.harness);
|
|
117
|
+
if (!(await pathExists(slice))) {
|
|
118
|
+
errors.push(
|
|
119
|
+
`Missing harness slice directory: ${join("harnesses", pack.harness)}/\n` +
|
|
120
|
+
` → Add native harness config under harnesses/${pack.harness}/ (see packs/coding).`
|
|
121
|
+
);
|
|
122
|
+
return { errors, warnings };
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
const configPath = join(slice, "opencode.json");
|
|
126
|
+
if (!(await pathExists(configPath))) {
|
|
127
|
+
errors.push(
|
|
128
|
+
`Missing ${join("harnesses", pack.harness, "opencode.json")}\n` +
|
|
129
|
+
` → Copy from packs/coding or run: orkestrate pack create <id> --from coding`
|
|
130
|
+
);
|
|
131
|
+
} else {
|
|
132
|
+
try {
|
|
133
|
+
const config = await Bun.file(configPath).json();
|
|
134
|
+
const agent = config.default_agent;
|
|
135
|
+
if (typeof agent !== "string" || !agent) {
|
|
136
|
+
warnings.push('opencode.json has no "default_agent" — launch will use pack id');
|
|
137
|
+
} else {
|
|
138
|
+
const agentFile = join(slice, "agents", `${agent}.md`);
|
|
139
|
+
if (!(await pathExists(agentFile))) {
|
|
140
|
+
errors.push(
|
|
141
|
+
`opencode.json default_agent "${agent}" has no agents/${agent}.md\n` +
|
|
142
|
+
` → Create harnesses/${pack.harness}/agents/${agent}.md`
|
|
143
|
+
);
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
} catch {
|
|
147
|
+
errors.push(`Invalid JSON: ${configPath}`);
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
const agentsDir = join(slice, "agents");
|
|
152
|
+
if (await pathExists(agentsDir)) {
|
|
153
|
+
const agents = (await readdir(agentsDir)).filter((f) => f.endsWith(".md"));
|
|
154
|
+
if (agents.length === 0) {
|
|
155
|
+
warnings.push(`No agent prompts in harnesses/${pack.harness}/agents/*.md`);
|
|
156
|
+
}
|
|
157
|
+
} else if (pack.harness === "opencode") {
|
|
158
|
+
warnings.push(`Missing harnesses/opencode/agents/ (primary agent markdown prompts)`);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
return { errors, warnings };
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
export async function seedBundledToGlobalIfMissing(): Promise<void> {
|
|
165
|
+
const entries = await listCatalogEntries(bundledCatalogDir);
|
|
166
|
+
for (const entry of entries) {
|
|
167
|
+
if (entry.kind !== "pack") continue;
|
|
168
|
+
const dest = join(globalPacksDir, entry.slug);
|
|
169
|
+
if (await pathExists(dest)) continue;
|
|
170
|
+
await installPackFromDirectory(entry.path, { target: "global", overwrite: false });
|
|
171
|
+
}
|
|
172
|
+
}
|
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
import { join } from "node:path";
|
|
2
|
+
import { parseProfile, type Profile } from "./schema";
|
|
3
|
+
import { assertProfileName, globalProfilesDir, workspaceProfilesDir } from "./load";
|
|
4
|
+
import {
|
|
5
|
+
listCatalogEntries,
|
|
6
|
+
parsePackMeta,
|
|
7
|
+
pathExists,
|
|
8
|
+
PROFILE_MANIFEST,
|
|
9
|
+
} from "./pack";
|
|
10
|
+
import {
|
|
11
|
+
installLegacyJsonProfile,
|
|
12
|
+
installPackFromDirectory,
|
|
13
|
+
installPackFromGitHub,
|
|
14
|
+
resolveGitHubFromRegistryItem,
|
|
15
|
+
} from "./install";
|
|
16
|
+
|
|
17
|
+
const profileModuleDir = import.meta.dir ?? process.cwd();
|
|
18
|
+
const localCatalogDir = join(profileModuleDir, "..", "..", "..", "profiles");
|
|
19
|
+
|
|
20
|
+
const REGISTRY_URL = process.env.ORKESTRATE_REGISTRY_URL || "https://orkestrate.space/api/registry";
|
|
21
|
+
|
|
22
|
+
export type CatalogProfile = {
|
|
23
|
+
id: string;
|
|
24
|
+
profile: Profile;
|
|
25
|
+
sourcePath: string;
|
|
26
|
+
installed: boolean;
|
|
27
|
+
source: "bundled" | "registry";
|
|
28
|
+
github?: string;
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
export type RegistryItem = {
|
|
32
|
+
id: string;
|
|
33
|
+
slug: string;
|
|
34
|
+
kind: string;
|
|
35
|
+
name: string;
|
|
36
|
+
description: string;
|
|
37
|
+
source_url: string;
|
|
38
|
+
manifest_url: string | null;
|
|
39
|
+
version: string;
|
|
40
|
+
manifest_json: any;
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
async function isProfileInstalled(profileName: string): Promise<boolean> {
|
|
44
|
+
const ws = join(workspaceProfilesDir, profileName);
|
|
45
|
+
const gl = join(globalProfilesDir, profileName);
|
|
46
|
+
if (await pathExists(join(ws, PROFILE_MANIFEST))) return true;
|
|
47
|
+
if (await pathExists(join(gl, PROFILE_MANIFEST))) return true;
|
|
48
|
+
if (await pathExists(join(ws, `${profileName}.json`))) return true;
|
|
49
|
+
if (await pathExists(join(gl, `${profileName}.json`))) return true;
|
|
50
|
+
return false;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
async function loadCatalogEntry(
|
|
54
|
+
slug: string,
|
|
55
|
+
kind: "pack" | "legacy-json",
|
|
56
|
+
path: string,
|
|
57
|
+
source: "bundled" | "registry"
|
|
58
|
+
): Promise<CatalogProfile> {
|
|
59
|
+
let profile: Profile;
|
|
60
|
+
if (kind === "pack") {
|
|
61
|
+
const manifestPath = join(path, PROFILE_MANIFEST);
|
|
62
|
+
profile = parseProfile(await Bun.file(manifestPath).json());
|
|
63
|
+
profile.sourcePath = manifestPath;
|
|
64
|
+
profile.packRoot = path;
|
|
65
|
+
} else {
|
|
66
|
+
profile = parseProfile(await Bun.file(path).json());
|
|
67
|
+
profile.sourcePath = path;
|
|
68
|
+
profile.packRoot = join(path, "..");
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
return {
|
|
72
|
+
id: slug,
|
|
73
|
+
profile,
|
|
74
|
+
sourcePath: path,
|
|
75
|
+
installed: await isProfileInstalled(profile.name),
|
|
76
|
+
source,
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
async function loadLocalCatalogProfiles(): Promise<CatalogProfile[]> {
|
|
81
|
+
const entries = await listCatalogEntries(localCatalogDir);
|
|
82
|
+
const results = await Promise.allSettled(
|
|
83
|
+
entries.map((entry) => loadCatalogEntry(entry.slug, entry.kind, entry.path, "bundled"))
|
|
84
|
+
);
|
|
85
|
+
return results
|
|
86
|
+
.filter((r): r is PromiseFulfilledResult<CatalogProfile> => r.status === "fulfilled")
|
|
87
|
+
.map((r) => r.value);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export async function fetchRegistry(): Promise<RegistryItem[]> {
|
|
91
|
+
try {
|
|
92
|
+
const response = await fetch(REGISTRY_URL, {
|
|
93
|
+
headers: { Accept: "application/json" },
|
|
94
|
+
});
|
|
95
|
+
if (!response.ok) {
|
|
96
|
+
throw new Error(`Failed to fetch registry: ${response.status} ${response.statusText}`);
|
|
97
|
+
}
|
|
98
|
+
return (await response.json()) as RegistryItem[];
|
|
99
|
+
} catch (error) {
|
|
100
|
+
console.warn(`[registry] could not connect to ${REGISTRY_URL}`, error);
|
|
101
|
+
return [];
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
export async function listCatalogProfiles(): Promise<CatalogProfile[]> {
|
|
106
|
+
const [localProfiles, remoteItems] = await Promise.all([
|
|
107
|
+
loadLocalCatalogProfiles(),
|
|
108
|
+
fetchRegistry(),
|
|
109
|
+
]);
|
|
110
|
+
|
|
111
|
+
const remoteProfilePacks = remoteItems.filter((item) => item.kind === "profile-pack");
|
|
112
|
+
|
|
113
|
+
const remoteResults = await Promise.allSettled(
|
|
114
|
+
remoteProfilePacks.map(async (item) => {
|
|
115
|
+
let manifest = item.manifest_json;
|
|
116
|
+
if (!manifest && item.manifest_url) {
|
|
117
|
+
const res = await fetch(item.manifest_url);
|
|
118
|
+
if (!res.ok) throw new Error(`Failed to fetch manifest for ${item.slug}`);
|
|
119
|
+
manifest = await res.json();
|
|
120
|
+
}
|
|
121
|
+
if (!manifest) {
|
|
122
|
+
throw new Error(`Profile pack "${item.slug}" has no manifest`);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
const profile = parseProfile(manifest);
|
|
126
|
+
const meta = parsePackMeta(manifest);
|
|
127
|
+
const gh = parseGitHubFromRegistryItem(item);
|
|
128
|
+
|
|
129
|
+
return {
|
|
130
|
+
id: item.slug,
|
|
131
|
+
profile,
|
|
132
|
+
sourcePath: item.source_url,
|
|
133
|
+
installed: await isProfileInstalled(profile.name),
|
|
134
|
+
source: "registry" as const,
|
|
135
|
+
github: `${gh.owner}/${gh.repo}@${gh.ref}${gh.packPath ? `/${gh.packPath}` : ""}`,
|
|
136
|
+
};
|
|
137
|
+
})
|
|
138
|
+
);
|
|
139
|
+
|
|
140
|
+
const remoteProfiles: CatalogProfile[] = remoteResults
|
|
141
|
+
.filter((r) => r.status === "fulfilled")
|
|
142
|
+
.map((r) => (r as PromiseFulfilledResult<CatalogProfile>).value);
|
|
143
|
+
|
|
144
|
+
const seen = new Set<string>();
|
|
145
|
+
const merged: CatalogProfile[] = [];
|
|
146
|
+
|
|
147
|
+
for (const p of [...localProfiles, ...remoteProfiles]) {
|
|
148
|
+
if (!seen.has(p.profile.name)) {
|
|
149
|
+
seen.add(p.profile.name);
|
|
150
|
+
merged.push(p);
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
return merged;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
function parseGitHubFromRegistryItem(item: RegistryItem) {
|
|
158
|
+
return resolveGitHubFromRegistryItem(item);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
export async function installCatalogProfile(
|
|
162
|
+
slug: string,
|
|
163
|
+
options?: { target?: "workspace" | "global"; overwrite?: boolean }
|
|
164
|
+
): Promise<Profile> {
|
|
165
|
+
const safeSlug = slug.replace(/[^a-z0-9-_]/gi, "");
|
|
166
|
+
|
|
167
|
+
const localEntries = await listCatalogEntries(localCatalogDir);
|
|
168
|
+
const local = localEntries.find((e) => e.slug === safeSlug || e.slug === slug);
|
|
169
|
+
const target = options?.target ?? "workspace";
|
|
170
|
+
const overwrite = options?.overwrite;
|
|
171
|
+
|
|
172
|
+
if (local) {
|
|
173
|
+
if (local.kind === "pack") {
|
|
174
|
+
return installPackFromDirectory(local.path, { target, overwrite });
|
|
175
|
+
}
|
|
176
|
+
return installLegacyJsonProfile(local.path, { target, overwrite });
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
const items = await fetchRegistry();
|
|
180
|
+
const item = items.find((i) => i.slug === slug || i.slug === safeSlug);
|
|
181
|
+
if (!item || item.kind !== "profile-pack") {
|
|
182
|
+
throw new Error(`Profile pack "${slug}" not found in catalog or registry.`);
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
if (!item.source_url) {
|
|
186
|
+
throw new Error(`Profile pack "${slug}" has no source_url`);
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
const gh = resolveGitHubFromRegistryItem(item);
|
|
190
|
+
const meta = parsePackMeta(item.manifest_json ?? {});
|
|
191
|
+
|
|
192
|
+
return installPackFromGitHub(item.source_url, {
|
|
193
|
+
ref: meta.ref ?? gh.ref,
|
|
194
|
+
packPath: meta.packPath ?? gh.packPath,
|
|
195
|
+
slug: item.slug,
|
|
196
|
+
target,
|
|
197
|
+
overwrite,
|
|
198
|
+
});
|
|
199
|
+
}
|
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
import { mkdir, readdir, stat, rm } from "node:fs/promises";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { copyDirectory, findProfileManifestInDir, pathExists } from "./pack";
|
|
5
|
+
|
|
6
|
+
export type GitHubSource = {
|
|
7
|
+
owner: string;
|
|
8
|
+
repo: string;
|
|
9
|
+
ref: string;
|
|
10
|
+
packPath: string;
|
|
11
|
+
webUrl: string;
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
export function parseGitHubSource(
|
|
15
|
+
sourceUrl: string,
|
|
16
|
+
options?: { ref?: string; packPath?: string }
|
|
17
|
+
): GitHubSource {
|
|
18
|
+
let url: URL;
|
|
19
|
+
try {
|
|
20
|
+
url = new URL(sourceUrl);
|
|
21
|
+
} catch {
|
|
22
|
+
throw new Error(`Invalid source URL: ${sourceUrl}`);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
if (url.hostname !== "github.com") {
|
|
26
|
+
throw new Error("Only github.com source URLs are supported in v1");
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const parts = url.pathname.split("/").filter(Boolean);
|
|
30
|
+
if (parts.length < 2) {
|
|
31
|
+
throw new Error(`Invalid GitHub URL: ${sourceUrl}`);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const owner = parts[0];
|
|
35
|
+
const repo = parts[1].replace(/\.git$/, "");
|
|
36
|
+
|
|
37
|
+
let ref = options?.ref ?? "main";
|
|
38
|
+
let packPath = options?.packPath ?? "";
|
|
39
|
+
|
|
40
|
+
if (parts[2] === "tree" || parts[2] === "blob") {
|
|
41
|
+
ref = parts[3] ?? ref;
|
|
42
|
+
packPath = parts.slice(4).join("/");
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
if (options?.packPath) {
|
|
46
|
+
packPath = options.packPath;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
return {
|
|
50
|
+
owner,
|
|
51
|
+
repo,
|
|
52
|
+
ref,
|
|
53
|
+
packPath: packPath.replace(/^\/+|\/+$/g, ""),
|
|
54
|
+
webUrl: `https://github.com/${owner}/${repo}`,
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
async function findPackRoot(cloneRoot: string, packPath: string): Promise<string> {
|
|
59
|
+
const trimmed = packPath.replace(/^\/+|\/+$/g, "");
|
|
60
|
+
if (trimmed) {
|
|
61
|
+
const candidate = join(cloneRoot, trimmed);
|
|
62
|
+
if (await findProfileManifestInDir(candidate)) {
|
|
63
|
+
return candidate;
|
|
64
|
+
}
|
|
65
|
+
throw new Error(`Pack path "${packPath}" does not contain ${PROFILE_MANIFEST}`);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
if (await findProfileManifestInDir(cloneRoot)) {
|
|
69
|
+
return cloneRoot;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const entries = await readdir(cloneRoot);
|
|
73
|
+
for (const entry of entries) {
|
|
74
|
+
const full = join(cloneRoot, entry);
|
|
75
|
+
const info = await stat(full);
|
|
76
|
+
if (info.isDirectory() && (await findProfileManifestInDir(full))) {
|
|
77
|
+
return full;
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
throw new Error("Repository does not contain a profile pack (profile.json not found)");
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const PROFILE_MANIFEST = "profile.json";
|
|
85
|
+
|
|
86
|
+
async function cloneWithGit(source: GitHubSource, dest: string): Promise<string> {
|
|
87
|
+
const cloneUrl = `${source.webUrl}.git`;
|
|
88
|
+
const proc = Bun.spawn(
|
|
89
|
+
["git", "clone", "--depth", "1", "--branch", source.ref, "--single-branch", cloneUrl, dest],
|
|
90
|
+
{ stdout: "pipe", stderr: "pipe" }
|
|
91
|
+
);
|
|
92
|
+
const code = await proc.exited;
|
|
93
|
+
if (code !== 0) {
|
|
94
|
+
const err = await new Response(proc.stderr).text();
|
|
95
|
+
throw new Error(`git clone failed: ${err.trim() || `exit ${code}`}`);
|
|
96
|
+
}
|
|
97
|
+
return await findPackRoot(dest, source.packPath);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
async function downloadTarball(source: GitHubSource, dest: string): Promise<string> {
|
|
101
|
+
const apiUrl = `https://api.github.com/repos/${source.owner}/${source.repo}/tarball/${source.ref}`;
|
|
102
|
+
const response = await fetch(apiUrl, {
|
|
103
|
+
headers: { Accept: "application/vnd.github+json", "User-Agent": "orkestrate-cli" },
|
|
104
|
+
redirect: "follow",
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
if (!response.ok) {
|
|
108
|
+
throw new Error(`GitHub archive download failed: ${response.status} ${response.statusText}`);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
const archivePath = join(dest, "archive.tar.gz");
|
|
112
|
+
await mkdir(dest, { recursive: true });
|
|
113
|
+
await Bun.write(archivePath, await response.arrayBuffer());
|
|
114
|
+
|
|
115
|
+
const extractDir = join(dest, "extract");
|
|
116
|
+
await mkdir(extractDir, { recursive: true });
|
|
117
|
+
|
|
118
|
+
const tar = Bun.spawn(["tar", "-xzf", archivePath, "-C", extractDir], {
|
|
119
|
+
stdout: "pipe",
|
|
120
|
+
stderr: "pipe",
|
|
121
|
+
});
|
|
122
|
+
const tarCode = await tar.exited;
|
|
123
|
+
if (tarCode !== 0) {
|
|
124
|
+
const err = await new Response(tar.stderr).text();
|
|
125
|
+
throw new Error(`tar extract failed: ${err.trim() || "install git or tar"}`);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
const top = await readdir(extractDir);
|
|
129
|
+
if (top.length === 0) {
|
|
130
|
+
throw new Error("GitHub archive was empty");
|
|
131
|
+
}
|
|
132
|
+
const root = join(extractDir, top[0]);
|
|
133
|
+
return await findPackRoot(root, source.packPath);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
export async function fetchGitHubPackRoot(source: GitHubSource): Promise<string> {
|
|
137
|
+
const work = join(tmpdir(), `orkestrate-fetch-${source.owner}-${source.repo}-${Date.now()}`);
|
|
138
|
+
await mkdir(work, { recursive: true });
|
|
139
|
+
|
|
140
|
+
try {
|
|
141
|
+
if (await commandExists("git")) {
|
|
142
|
+
try {
|
|
143
|
+
return await cloneWithGit(source, join(work, "repo"));
|
|
144
|
+
} catch {
|
|
145
|
+
// fall through to tarball
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
return await downloadTarball(source, work);
|
|
149
|
+
} catch (error) {
|
|
150
|
+
await rm(work, { recursive: true, force: true }).catch(() => {});
|
|
151
|
+
throw error;
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
export async function installGitHubPackToDirectory(
|
|
156
|
+
source: GitHubSource,
|
|
157
|
+
targetDir: string
|
|
158
|
+
): Promise<string> {
|
|
159
|
+
const packRoot = await fetchGitHubPackRoot(source);
|
|
160
|
+
const parent = join(packRoot, "..");
|
|
161
|
+
await copyDirectory(packRoot, targetDir);
|
|
162
|
+
await rm(parent, { recursive: true, force: true }).catch(() => {});
|
|
163
|
+
const manifest = join(targetDir, PROFILE_MANIFEST);
|
|
164
|
+
if (!(await pathExists(manifest))) {
|
|
165
|
+
throw new Error("Installed pack is missing profile.json");
|
|
166
|
+
}
|
|
167
|
+
return manifest;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
async function commandExists(command: string): Promise<boolean> {
|
|
171
|
+
const proc = Bun.spawnSync({
|
|
172
|
+
cmd: [command, "--version"],
|
|
173
|
+
stdout: "ignore",
|
|
174
|
+
stderr: "ignore",
|
|
175
|
+
});
|
|
176
|
+
return proc.exitCode === 0;
|
|
177
|
+
}
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
import { mkdir, rm, writeFile } from "node:fs/promises";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { homedir } from "node:os";
|
|
4
|
+
import { parseProfile, type Profile } from "./schema";
|
|
5
|
+
import { assertProfileName, globalProfilesDir, workspaceProfilesDir } from "./load";
|
|
6
|
+
import {
|
|
7
|
+
PROFILE_MANIFEST,
|
|
8
|
+
applyScaffold,
|
|
9
|
+
copyDirectory,
|
|
10
|
+
findProfileManifestInDir,
|
|
11
|
+
getPackRootFromProfilePath,
|
|
12
|
+
pathExists,
|
|
13
|
+
} from "./pack";
|
|
14
|
+
import { installGitHubPackToDirectory, parseGitHubSource, type GitHubSource } from "./github";
|
|
15
|
+
|
|
16
|
+
export type InstallTarget = "workspace" | "global";
|
|
17
|
+
|
|
18
|
+
export function packsCacheDir(): string {
|
|
19
|
+
return join(homedir(), ".orkestrate", "packs");
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function installedProfileDir(name: string, target: InstallTarget): string {
|
|
23
|
+
const base = target === "global" ? globalProfilesDir : workspaceProfilesDir;
|
|
24
|
+
return join(base, assertProfileName(name));
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export async function writeInstallRecord(
|
|
28
|
+
profileDir: string,
|
|
29
|
+
record: Record<string, unknown>
|
|
30
|
+
): Promise<void> {
|
|
31
|
+
await writeFile(join(profileDir, ".orkestrate-install.json"), JSON.stringify(record, null, 2) + "\n");
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export async function installPackFromDirectory(
|
|
35
|
+
sourcePackRoot: string,
|
|
36
|
+
options: {
|
|
37
|
+
target?: InstallTarget;
|
|
38
|
+
overwrite?: boolean;
|
|
39
|
+
applyWorkspaceScaffold?: boolean;
|
|
40
|
+
installRecord?: Record<string, unknown>;
|
|
41
|
+
} = {}
|
|
42
|
+
): Promise<Profile> {
|
|
43
|
+
const manifestPath = await findProfileManifestInDir(sourcePackRoot);
|
|
44
|
+
if (!manifestPath) {
|
|
45
|
+
throw new Error(`Source pack does not contain ${PROFILE_MANIFEST}`);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const raw = await Bun.file(manifestPath).json();
|
|
49
|
+
const profile = parseProfile(raw);
|
|
50
|
+
profile.sourcePath = manifestPath;
|
|
51
|
+
profile.packRoot = getPackRootFromProfilePath(manifestPath);
|
|
52
|
+
|
|
53
|
+
const target = options.target ?? "workspace";
|
|
54
|
+
const destDir = installedProfileDir(profile.name, target);
|
|
55
|
+
|
|
56
|
+
if ((await pathExists(destDir)) && !options.overwrite) {
|
|
57
|
+
throw new Error(`Profile "${profile.name}" is already installed at ${destDir}`);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
if (await pathExists(destDir)) {
|
|
61
|
+
await rm(destDir, { recursive: true, force: true });
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
await mkdir(join(target === "global" ? globalProfilesDir : workspaceProfilesDir), {
|
|
65
|
+
recursive: true,
|
|
66
|
+
});
|
|
67
|
+
await copyDirectory(sourcePackRoot, destDir);
|
|
68
|
+
|
|
69
|
+
const installedManifest = join(destDir, PROFILE_MANIFEST);
|
|
70
|
+
profile.sourcePath = installedManifest;
|
|
71
|
+
profile.packRoot = destDir;
|
|
72
|
+
|
|
73
|
+
if (options.installRecord) {
|
|
74
|
+
await writeInstallRecord(destDir, options.installRecord);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
if (options.applyWorkspaceScaffold !== false) {
|
|
78
|
+
await applyScaffold(destDir, process.cwd(), profile.name);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
return profile;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export async function installPackFromGitHub(
|
|
85
|
+
sourceUrl: string,
|
|
86
|
+
options: {
|
|
87
|
+
ref?: string;
|
|
88
|
+
packPath?: string;
|
|
89
|
+
slug?: string;
|
|
90
|
+
target?: InstallTarget;
|
|
91
|
+
overwrite?: boolean;
|
|
92
|
+
} = {}
|
|
93
|
+
): Promise<Profile> {
|
|
94
|
+
const source = parseGitHubSource(sourceUrl, {
|
|
95
|
+
ref: options.ref,
|
|
96
|
+
packPath: options.packPath,
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
const cacheSlug = options.slug ?? `${source.owner}-${source.repo}-${source.ref}`.replace(/[^a-z0-9-_]+/gi, "-");
|
|
100
|
+
const cacheDir = join(packsCacheDir(), cacheSlug);
|
|
101
|
+
|
|
102
|
+
if (await pathExists(cacheDir)) {
|
|
103
|
+
await rm(cacheDir, { recursive: true, force: true });
|
|
104
|
+
}
|
|
105
|
+
await mkdir(packsCacheDir(), { recursive: true });
|
|
106
|
+
|
|
107
|
+
await installGitHubPackToDirectory(source, cacheDir);
|
|
108
|
+
|
|
109
|
+
return installPackFromDirectory(cacheDir, {
|
|
110
|
+
target: options.target ?? "workspace",
|
|
111
|
+
overwrite: options.overwrite,
|
|
112
|
+
installRecord: {
|
|
113
|
+
source: "github",
|
|
114
|
+
sourceUrl,
|
|
115
|
+
ref: source.ref,
|
|
116
|
+
packPath: source.packPath,
|
|
117
|
+
slug: cacheSlug,
|
|
118
|
+
installedAt: new Date().toISOString(),
|
|
119
|
+
},
|
|
120
|
+
});
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
export async function installLegacyJsonProfile(
|
|
124
|
+
jsonPath: string,
|
|
125
|
+
options: { target?: InstallTarget; overwrite?: boolean } = {}
|
|
126
|
+
): Promise<Profile> {
|
|
127
|
+
const raw = await Bun.file(jsonPath).json();
|
|
128
|
+
const profile = parseProfile(raw);
|
|
129
|
+
const target = options.target ?? "workspace";
|
|
130
|
+
const destDir = installedProfileDir(profile.name, target);
|
|
131
|
+
|
|
132
|
+
if ((await pathExists(destDir)) && !options.overwrite) {
|
|
133
|
+
throw new Error(`Profile "${profile.name}" is already installed`);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
if (await pathExists(destDir)) {
|
|
137
|
+
await rm(destDir, { recursive: true, force: true });
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
await mkdir(destDir, { recursive: true });
|
|
141
|
+
const onlyManifest = join(destDir, PROFILE_MANIFEST);
|
|
142
|
+
await writeFile(onlyManifest, JSON.stringify(raw, null, 2) + "\n");
|
|
143
|
+
|
|
144
|
+
profile.sourcePath = onlyManifest;
|
|
145
|
+
profile.packRoot = destDir;
|
|
146
|
+
return profile;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
export function resolveGitHubFromRegistryItem(item: {
|
|
150
|
+
source_url: string;
|
|
151
|
+
version?: string;
|
|
152
|
+
manifest_json?: any;
|
|
153
|
+
slug: string;
|
|
154
|
+
}): GitHubSource & { slug: string } {
|
|
155
|
+
const manifest = item.manifest_json ?? {};
|
|
156
|
+
const meta = manifest.orkestrate ?? {};
|
|
157
|
+
const ref = typeof meta.ref === "string" ? meta.ref : item.version ?? "main";
|
|
158
|
+
const packPath = typeof meta.packPath === "string" ? meta.packPath : "";
|
|
159
|
+
const source = parseGitHubSource(item.source_url, { ref, packPath });
|
|
160
|
+
return { ...source, slug: item.slug };
|
|
161
|
+
}
|