@cueloop/extension-api 0.1.0-alpha.10 → 0.1.0-alpha.100
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/package.json +28 -21
- package/src/client-ui.ts +55 -0
- package/src/index.ts +0 -1
- package/src/installed-packages.test.ts +79 -0
- package/src/installed-packages.ts +99 -0
- package/src/registry.test.ts +70 -75
- package/src/registry.ts +34 -71
- package/src/types.ts +31 -42
- package/src/loader.ts +0 -98
package/package.json
CHANGED
|
@@ -1,32 +1,39 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cueloop/extension-api",
|
|
3
|
-
"version": "0.1.0-alpha.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
"
|
|
7
|
-
"
|
|
8
|
-
},
|
|
9
|
-
"dependencies": {
|
|
10
|
-
"@cueloop/schema": "0.1.0-alpha.10"
|
|
3
|
+
"version": "0.1.0-alpha.100",
|
|
4
|
+
"description": "The typed contract for extending cueloop: renderers, commands, keybindings, exporters",
|
|
5
|
+
"homepage": "https://github.com/mmurakaru/cueloop#readme",
|
|
6
|
+
"bugs": {
|
|
7
|
+
"url": "https://github.com/mmurakaru/cueloop/issues"
|
|
11
8
|
},
|
|
12
|
-
"
|
|
13
|
-
|
|
9
|
+
"license": "Apache-2.0",
|
|
10
|
+
"repository": {
|
|
11
|
+
"type": "git",
|
|
12
|
+
"url": "git+https://github.com/mmurakaru/cueloop.git",
|
|
13
|
+
"directory": "packages/extension-api"
|
|
14
14
|
},
|
|
15
15
|
"files": [
|
|
16
16
|
"src",
|
|
17
17
|
"README.md"
|
|
18
18
|
],
|
|
19
|
-
"
|
|
20
|
-
|
|
19
|
+
"type": "module",
|
|
20
|
+
"exports": {
|
|
21
|
+
".": "./src/index.ts",
|
|
22
|
+
"./client": "./src/client-ui.ts",
|
|
23
|
+
"./installed-packages": "./src/installed-packages.ts"
|
|
21
24
|
},
|
|
22
|
-
"
|
|
23
|
-
"
|
|
24
|
-
"url": "git+https://github.com/mmurakaru/cueloop.git",
|
|
25
|
-
"directory": "packages/extension-api"
|
|
25
|
+
"publishConfig": {
|
|
26
|
+
"access": "public"
|
|
26
27
|
},
|
|
27
|
-
"
|
|
28
|
-
|
|
29
|
-
"
|
|
28
|
+
"dependencies": {
|
|
29
|
+
"@cueloop/schema": "0.1.0-alpha.100",
|
|
30
|
+
"valibot": "^1.5.0"
|
|
31
|
+
},
|
|
32
|
+
"peerDependencies": {
|
|
33
|
+
"@opentui/react": "^0.5.11",
|
|
34
|
+
"react": "^19.3.0"
|
|
30
35
|
},
|
|
31
|
-
"
|
|
32
|
-
|
|
36
|
+
"engines": {
|
|
37
|
+
"bun": ">=1.3.0"
|
|
38
|
+
}
|
|
39
|
+
}
|
package/src/client-ui.ts
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import type * as React from "react";
|
|
2
|
+
import type { ComponentType } from "react";
|
|
3
|
+
|
|
4
|
+
export type ExtensionComponent = ComponentType<{ context: ExtensionUIContext }>;
|
|
5
|
+
|
|
6
|
+
/** The client supplies current workspace and Thread identity to extension UI. */
|
|
7
|
+
export interface ExtensionUIContext {
|
|
8
|
+
workspace: string | null;
|
|
9
|
+
threadId: string | null;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/** A host-owned registration that can be removed on extension reload. */
|
|
13
|
+
export interface ExtensionUIDisposable {
|
|
14
|
+
dispose(): void;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** A titled, height-bounded section below the scrolling Threads list. */
|
|
18
|
+
export interface ThreadSidebarSection {
|
|
19
|
+
id: string;
|
|
20
|
+
zone: "threads.sidebar";
|
|
21
|
+
title: string;
|
|
22
|
+
Component: ExtensionComponent;
|
|
23
|
+
when?: (context: ExtensionUIContext) => boolean;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** An action rendered with the Thread header's existing button primitive. */
|
|
27
|
+
export interface ThreadHeaderAction {
|
|
28
|
+
id: string;
|
|
29
|
+
zone: "thread.header";
|
|
30
|
+
label: string;
|
|
31
|
+
glyph?: string;
|
|
32
|
+
onPress(context: ExtensionUIContext): void | Promise<void>;
|
|
33
|
+
when?: (context: ExtensionUIContext) => boolean;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** A selectable full-height view inside the workspace's existing right pane. */
|
|
37
|
+
export interface WorkspacePanelView {
|
|
38
|
+
id: string;
|
|
39
|
+
zone: "workspace.panels";
|
|
40
|
+
title: string;
|
|
41
|
+
Component: ExtensionComponent;
|
|
42
|
+
when?: (context: ExtensionUIContext) => boolean;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Client-only registration API; server and VCS capabilities use the daemon entry point. */
|
|
46
|
+
export interface ClientExtensionAPI {
|
|
47
|
+
react: typeof React;
|
|
48
|
+
useKeyboard: typeof import("@opentui/react").useKeyboard;
|
|
49
|
+
registerSection(section: ThreadSidebarSection): ExtensionUIDisposable;
|
|
50
|
+
registerAction(action: ThreadHeaderAction): ExtensionUIDisposable;
|
|
51
|
+
registerView(view: WorkspacePanelView): ExtensionUIDisposable;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** Client packages export one factory that registers their UI contributions. */
|
|
55
|
+
export type ClientExtensionFactory = (api: ClientExtensionAPI) => void | Promise<void>;
|
package/src/index.ts
CHANGED
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import { afterEach, describe, expect, test } from "bun:test";
|
|
2
|
+
import { mkdtempSync, mkdirSync, realpathSync, rmSync, symlinkSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import { discoverInstalledExtensionPackages } from "./installed-packages";
|
|
6
|
+
|
|
7
|
+
const roots: string[] = [];
|
|
8
|
+
|
|
9
|
+
afterEach(() => {
|
|
10
|
+
for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true });
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
function installFixture(
|
|
14
|
+
name: string,
|
|
15
|
+
cueloop: { client?: string; daemon?: string } | undefined,
|
|
16
|
+
): string {
|
|
17
|
+
const root = mkdtempSync(join(tmpdir(), "cueloop-extensions-test-"));
|
|
18
|
+
const packageRoot = join(root, "node_modules", name);
|
|
19
|
+
|
|
20
|
+
roots.push(root);
|
|
21
|
+
mkdirSync(packageRoot, { recursive: true });
|
|
22
|
+
writeFileSync(join(root, "package.json"), JSON.stringify({ dependencies: { [name]: "1.0.0" } }));
|
|
23
|
+
writeFileSync(join(packageRoot, "package.json"), JSON.stringify({ name, cueloop }));
|
|
24
|
+
writeFileSync(join(packageRoot, "client.js"), "export default () => {};");
|
|
25
|
+
writeFileSync(join(packageRoot, "daemon.js"), "export default () => {};");
|
|
26
|
+
|
|
27
|
+
return root;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
describe("installed extension discovery", () => {
|
|
31
|
+
test("finds separate client and daemon entry points in a scoped package", () => {
|
|
32
|
+
const root = installFixture("@example/tools", { client: "./client.js", daemon: "./daemon.js" });
|
|
33
|
+
const result = discoverInstalledExtensionPackages(root);
|
|
34
|
+
|
|
35
|
+
expect(result.errors).toEqual([]);
|
|
36
|
+
expect(result.packages).toEqual([
|
|
37
|
+
{
|
|
38
|
+
name: "@example/tools",
|
|
39
|
+
root: join(root, "node_modules", "@example/tools"),
|
|
40
|
+
clientEntry: realpathSync(join(root, "node_modules", "@example/tools/client.js")),
|
|
41
|
+
daemonEntry: realpathSync(join(root, "node_modules", "@example/tools/daemon.js")),
|
|
42
|
+
},
|
|
43
|
+
]);
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
test("rejects an entry outside its package and continues discovery", () => {
|
|
47
|
+
const root = installFixture("example", { client: "./../outside.js" });
|
|
48
|
+
const result = discoverInstalledExtensionPackages(root);
|
|
49
|
+
|
|
50
|
+
expect(result.packages).toEqual([]);
|
|
51
|
+
expect(result.errors[0]).toContain("entry escapes package root");
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
test("rejects a symlink entry that leaves the package", () => {
|
|
55
|
+
const root = installFixture("example", { client: "./linked.js" });
|
|
56
|
+
const outside = join(root, "outside.js");
|
|
57
|
+
|
|
58
|
+
writeFileSync(outside, "export default () => {};");
|
|
59
|
+
symlinkSync(outside, join(root, "node_modules/example/linked.js"));
|
|
60
|
+
|
|
61
|
+
expect(discoverInstalledExtensionPackages(root).errors[0]).toContain(
|
|
62
|
+
"entry escapes package root",
|
|
63
|
+
);
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
test("rejects a directory entry before installation succeeds", () => {
|
|
67
|
+
const root = installFixture("example", { client: "./folder" });
|
|
68
|
+
|
|
69
|
+
mkdirSync(join(root, "node_modules/example/folder"));
|
|
70
|
+
|
|
71
|
+
expect(discoverInstalledExtensionPackages(root).errors[0]).toContain("entry is not a file");
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
test("rejects a package without a cueloop manifest", () => {
|
|
75
|
+
const root = installFixture("example", undefined);
|
|
76
|
+
|
|
77
|
+
expect(discoverInstalledExtensionPackages(root).errors[0]).toContain("no cueloop manifest");
|
|
78
|
+
});
|
|
79
|
+
});
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import { existsSync, readFileSync, realpathSync, statSync } from "node:fs";
|
|
2
|
+
import { homedir } from "node:os";
|
|
3
|
+
import { isAbsolute, join, relative, resolve, sep } from "node:path";
|
|
4
|
+
import * as v from "valibot";
|
|
5
|
+
|
|
6
|
+
const PackageJsonSchema = v.object({
|
|
7
|
+
dependencies: v.optional(v.record(v.string(), v.string())),
|
|
8
|
+
cueloop: v.optional(
|
|
9
|
+
v.object({
|
|
10
|
+
client: v.optional(v.string()),
|
|
11
|
+
daemon: v.optional(v.string()),
|
|
12
|
+
}),
|
|
13
|
+
),
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
/** The user-owned directory where `cueloop install` keeps npm packages. */
|
|
17
|
+
export function extensionInstallRoot(env = process.env): string {
|
|
18
|
+
return (
|
|
19
|
+
env.CUELOOP_EXTENSION_HOME ??
|
|
20
|
+
join(env.XDG_DATA_HOME ?? join(homedir(), ".local", "share"), "cueloop", "extensions")
|
|
21
|
+
);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** One installed package's separate client and daemon extension entry points. */
|
|
25
|
+
export interface InstalledExtensionPackage {
|
|
26
|
+
name: string;
|
|
27
|
+
root: string;
|
|
28
|
+
clientEntry?: string;
|
|
29
|
+
daemonEntry?: string;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** Discovery reports bad packages without preventing valid installed extensions from loading. */
|
|
33
|
+
export interface ExtensionPackageDiscovery {
|
|
34
|
+
packages: InstalledExtensionPackage[];
|
|
35
|
+
errors: string[];
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function readPackageJson(path: string): v.InferOutput<typeof PackageJsonSchema> {
|
|
39
|
+
const parsed = v.safeParse(PackageJsonSchema, JSON.parse(readFileSync(path, "utf8")));
|
|
40
|
+
|
|
41
|
+
if (!parsed.success) throw new Error("invalid package.json");
|
|
42
|
+
|
|
43
|
+
return parsed.output;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function packageEntry(root: string, entry: string | undefined): string | undefined {
|
|
47
|
+
if (entry === undefined) return undefined;
|
|
48
|
+
if (!entry.startsWith("./")) throw new Error("entry must start with ./");
|
|
49
|
+
const absolute = resolve(root, entry);
|
|
50
|
+
const lexicalWithin = relative(root, absolute);
|
|
51
|
+
|
|
52
|
+
if (isAbsolute(lexicalWithin) || lexicalWithin === ".." || lexicalWithin.startsWith(`..${sep}`))
|
|
53
|
+
throw new Error("entry escapes package root");
|
|
54
|
+
if (!existsSync(absolute)) throw new Error(`entry missing: ${entry}`);
|
|
55
|
+
if (!statSync(absolute).isFile()) throw new Error(`entry is not a file: ${entry}`);
|
|
56
|
+
const within = relative(realpathSync(root), realpathSync(absolute));
|
|
57
|
+
|
|
58
|
+
if (isAbsolute(within) || within === ".." || within.startsWith(`..${sep}`))
|
|
59
|
+
throw new Error("entry escapes package root");
|
|
60
|
+
|
|
61
|
+
return realpathSync(absolute);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** Discover only declared `cueloop` entries from packages in the managed install root. */
|
|
65
|
+
export function discoverInstalledExtensionPackages(
|
|
66
|
+
installRoot = extensionInstallRoot(),
|
|
67
|
+
): ExtensionPackageDiscovery {
|
|
68
|
+
const packages: InstalledExtensionPackage[] = [];
|
|
69
|
+
const errors: string[] = [];
|
|
70
|
+
const rootManifest = join(installRoot, "package.json");
|
|
71
|
+
|
|
72
|
+
if (!existsSync(rootManifest)) return { packages, errors };
|
|
73
|
+
let dependencies: Record<string, string>;
|
|
74
|
+
|
|
75
|
+
try {
|
|
76
|
+
dependencies = readPackageJson(rootManifest).dependencies ?? {};
|
|
77
|
+
} catch (error) {
|
|
78
|
+
return { packages, errors: [`Extension package discovery: ${String(error)}`] };
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
for (const name of Object.keys(dependencies).toSorted()) {
|
|
82
|
+
const root = join(installRoot, "node_modules", name);
|
|
83
|
+
|
|
84
|
+
try {
|
|
85
|
+
const manifest = readPackageJson(join(root, "package.json")).cueloop;
|
|
86
|
+
|
|
87
|
+
if (!manifest) throw new Error("package has no cueloop manifest");
|
|
88
|
+
const clientEntry = packageEntry(root, manifest.client);
|
|
89
|
+
const daemonEntry = packageEntry(root, manifest.daemon);
|
|
90
|
+
|
|
91
|
+
if (!clientEntry && !daemonEntry) throw new Error("manifest declares no entry point");
|
|
92
|
+
packages.push({ name, root, clientEntry, daemonEntry });
|
|
93
|
+
} catch (error) {
|
|
94
|
+
errors.push(`Extension package ${name}: ${String(error)}`);
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
return { packages, errors };
|
|
99
|
+
}
|
package/src/registry.test.ts
CHANGED
|
@@ -1,98 +1,93 @@
|
|
|
1
1
|
import { describe, expect, test } from "bun:test";
|
|
2
|
-
import { mkdtempSync, rmSync, writeFileSync, mkdirSync } from "node:fs";
|
|
3
|
-
import { tmpdir } from "node:os";
|
|
4
|
-
import { join } from "node:path";
|
|
5
2
|
import { Registry } from "./registry";
|
|
6
|
-
import {
|
|
7
|
-
import type { ReviewSession } from "@cueloop/schema";
|
|
3
|
+
import type { Thread } from "@cueloop/schema";
|
|
8
4
|
|
|
9
|
-
const SESSION = {
|
|
5
|
+
const SESSION: Thread = {
|
|
6
|
+
schemaVersion: "1",
|
|
7
|
+
id: "ses_x",
|
|
8
|
+
workspace: { repoRoot: "/repo", branch: "main" },
|
|
9
|
+
artifact: { type: "plan", content: "# Plan\n", meta: {} },
|
|
10
|
+
revisions: [],
|
|
11
|
+
annotations: [],
|
|
12
|
+
message: null,
|
|
13
|
+
status: "pending",
|
|
14
|
+
createdAt: "2026-01-01T00:00:00.000Z",
|
|
15
|
+
};
|
|
10
16
|
|
|
11
17
|
describe("Registry", () => {
|
|
12
|
-
test("captures
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
18
|
+
test("captures each extension's exporters, attributed by name", async () => {
|
|
19
|
+
// Arrange
|
|
20
|
+
const registry = new Registry();
|
|
21
|
+
|
|
22
|
+
// Act
|
|
23
|
+
await registry.load("obsidian", (api) => {
|
|
24
|
+
api.registerExporter("obsidian", async () => ({ success: true, path: "/vault/note.md" }));
|
|
16
25
|
});
|
|
17
|
-
await
|
|
18
|
-
api.
|
|
19
|
-
api.registerRenderer("svg", () => [[{ text: "svg" }]]);
|
|
26
|
+
await registry.load("bear", (api) => {
|
|
27
|
+
api.registerExporter("bear", async () => ({ success: true }));
|
|
20
28
|
});
|
|
21
|
-
|
|
22
|
-
|
|
29
|
+
|
|
30
|
+
// Assert
|
|
31
|
+
expect(registry.extensions.map((extension) => extension.name)).toEqual(["obsidian", "bear"]);
|
|
32
|
+
const obsidian = registry.extensions[0]!.exporters.get("obsidian")!;
|
|
33
|
+
|
|
34
|
+
expect(await obsidian(SESSION)).toEqual({ success: true, path: "/vault/note.md" });
|
|
23
35
|
});
|
|
24
36
|
|
|
25
37
|
test("a throwing factory is contained, not fatal", async () => {
|
|
26
|
-
|
|
27
|
-
const
|
|
38
|
+
// Arrange
|
|
39
|
+
const registry = new Registry();
|
|
40
|
+
|
|
41
|
+
// Act
|
|
42
|
+
const record = await registry.load("broken", () => {
|
|
28
43
|
throw new Error("boom");
|
|
29
44
|
});
|
|
30
|
-
expect(record.errors).toEqual(["boom"]);
|
|
31
|
-
expect(reg.extensions.length).toBe(1);
|
|
32
|
-
});
|
|
33
45
|
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
api.registerKeybinding({ action: "fine", defaultKeys: ["F5"], handler: () => {} });
|
|
39
|
-
});
|
|
40
|
-
expect(record.keybindings.length).toBe(1);
|
|
41
|
-
expect(record.errors[0]).toContain('"j" is reserved');
|
|
46
|
+
// Assert
|
|
47
|
+
expect(record.errors).toEqual(["boom"]);
|
|
48
|
+
expect(record.exporters.size).toBe(0);
|
|
49
|
+
expect(registry.extensions.length).toBe(1);
|
|
42
50
|
});
|
|
43
51
|
|
|
44
|
-
test("
|
|
45
|
-
const
|
|
46
|
-
const
|
|
47
|
-
|
|
48
|
-
api.
|
|
49
|
-
|
|
52
|
+
test("a failed factory discards VCS adapters and exporters registered before it threw", async () => {
|
|
53
|
+
const registry = new Registry();
|
|
54
|
+
const record = await registry.load("broken-vcs", (api) => {
|
|
55
|
+
api.registerExporter("draft", async () => ({ success: true }));
|
|
56
|
+
api.registerVcsAdapter({
|
|
57
|
+
apiVersion: 1,
|
|
58
|
+
id: "example.sapling",
|
|
59
|
+
detect: async () => "/repo",
|
|
60
|
+
captureWorkingDiff: async () => ({ patch: "", files: [] }),
|
|
61
|
+
listChanges: async () => [],
|
|
62
|
+
listFiles: async () => [],
|
|
50
63
|
});
|
|
51
|
-
|
|
64
|
+
throw new Error("incomplete extension");
|
|
52
65
|
});
|
|
53
|
-
|
|
54
|
-
expect(
|
|
66
|
+
|
|
67
|
+
expect(record.errors).toEqual(["incomplete extension"]);
|
|
68
|
+
expect(record.exporters.size).toBe(0);
|
|
69
|
+
expect(record.vcsAdapters.size).toBe(0);
|
|
55
70
|
});
|
|
56
|
-
});
|
|
57
71
|
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
const
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
`export default (api) => { api.registerCommand("hello", { description: "hi", handler() {} }); };`,
|
|
69
|
-
);
|
|
70
|
-
writeFileSync(
|
|
71
|
-
join(repoRoot, ".cueloop", "extensions", "repo-ext.ts"),
|
|
72
|
-
`export default (api) => { api.registerCommand("repo", { description: "r", handler() {} }); };`,
|
|
73
|
-
);
|
|
74
|
-
try {
|
|
75
|
-
// untrusted repo: user loads, repo skipped
|
|
76
|
-
const reg1 = new Registry();
|
|
77
|
-
const r1 = await loadExtensions({ registry: reg1, userDir, repoRoot, home });
|
|
78
|
-
expect(r1.loaded.length).toBe(1);
|
|
79
|
-
expect(r1.skipped.length).toBe(1);
|
|
80
|
-
expect(reg1.commandFor("hello")).toBeDefined();
|
|
81
|
-
expect(reg1.commandFor("repo")).toBeUndefined();
|
|
72
|
+
test("VCS IDs cannot replace built-in or previously registered adapters", async () => {
|
|
73
|
+
const registry = new Registry();
|
|
74
|
+
const adapter = {
|
|
75
|
+
apiVersion: 1 as const,
|
|
76
|
+
id: "example.sapling",
|
|
77
|
+
detect: async () => "/repo",
|
|
78
|
+
captureWorkingDiff: async () => ({ patch: "", files: [] }),
|
|
79
|
+
listChanges: async () => [],
|
|
80
|
+
listFiles: async () => [],
|
|
81
|
+
};
|
|
82
82
|
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
expect(readTrust(home).trusted).toContain(repoRoot);
|
|
83
|
+
await registry.load("first", (api) => api.registerVcsAdapter(adapter));
|
|
84
|
+
const duplicate = await registry.load("second", (api) => api.registerVcsAdapter(adapter));
|
|
85
|
+
const builtin = await registry.load("third", (api) =>
|
|
86
|
+
api.registerVcsAdapter({ ...adapter, id: "git" }),
|
|
87
|
+
);
|
|
89
88
|
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
expect(r3.loaded.length).toBe(2);
|
|
94
|
-
} finally {
|
|
95
|
-
rmSync(dir, { recursive: true, force: true });
|
|
96
|
-
}
|
|
89
|
+
expect(duplicate.errors).toEqual(["Invalid or duplicate VCS adapter ID: example.sapling"]);
|
|
90
|
+
expect(builtin.errors).toEqual(["Invalid or duplicate VCS adapter ID: git"]);
|
|
91
|
+
expect(registry.extensions[0]!.vcsAdapters.has("example.sapling")).toBe(true);
|
|
97
92
|
});
|
|
98
93
|
});
|
package/src/registry.ts
CHANGED
|
@@ -1,28 +1,26 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* The contribution registry
|
|
3
|
-
*
|
|
4
|
-
* broken extension
|
|
2
|
+
* The contribution registry: an extension factory runs against a captured
|
|
3
|
+
* API, and its exporter registrations land as plain data attributed to that
|
|
4
|
+
* extension. One broken extension never takes the host down.
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
|
-
import type {
|
|
8
|
-
|
|
9
|
-
CommandRegistration,
|
|
10
|
-
Exporter,
|
|
11
|
-
ExtensionAPI,
|
|
12
|
-
ExtensionFactory,
|
|
13
|
-
KeybindingRegistration,
|
|
14
|
-
} from "./types";
|
|
15
|
-
import type { ReviewSession } from "@cueloop/schema";
|
|
7
|
+
import type { Exporter, ExtensionAPI, ExtensionFactory, VcsAdapter } from "./types";
|
|
8
|
+
import * as v from "valibot";
|
|
16
9
|
|
|
17
|
-
const
|
|
10
|
+
const VcsAdapterSchema = v.object({
|
|
11
|
+
apiVersion: v.literal(1),
|
|
12
|
+
id: v.pipe(v.string(), v.regex(/^[a-z][a-z0-9-]*(?:\.[a-z][a-z0-9-]*)+$/)),
|
|
13
|
+
detect: v.function(),
|
|
14
|
+
captureWorkingDiff: v.function(),
|
|
15
|
+
captureChange: v.optional(v.function()),
|
|
16
|
+
listChanges: v.function(),
|
|
17
|
+
listFiles: v.function(),
|
|
18
|
+
});
|
|
18
19
|
|
|
19
20
|
export interface ExtensionRecord {
|
|
20
21
|
name: string;
|
|
21
|
-
renderers: Map<string, ArtifactRenderer>;
|
|
22
|
-
commands: Map<string, CommandRegistration>;
|
|
23
|
-
keybindings: KeybindingRegistration[];
|
|
24
22
|
exporters: Map<string, Exporter>;
|
|
25
|
-
|
|
23
|
+
vcsAdapters: Map<string, VcsAdapter>;
|
|
26
24
|
errors: string[];
|
|
27
25
|
}
|
|
28
26
|
|
|
@@ -33,73 +31,38 @@ export class Registry {
|
|
|
33
31
|
async load(name: string, factory: ExtensionFactory): Promise<ExtensionRecord> {
|
|
34
32
|
const record: ExtensionRecord = {
|
|
35
33
|
name,
|
|
36
|
-
renderers: new Map(),
|
|
37
|
-
commands: new Map(),
|
|
38
|
-
keybindings: [],
|
|
39
34
|
exporters: new Map(),
|
|
40
|
-
|
|
35
|
+
vcsAdapters: new Map(),
|
|
41
36
|
errors: [],
|
|
42
37
|
};
|
|
43
38
|
const api: ExtensionAPI = {
|
|
44
|
-
|
|
45
|
-
record.
|
|
39
|
+
registerExporter(exporterName, exporter) {
|
|
40
|
+
record.exporters.set(exporterName, exporter);
|
|
46
41
|
},
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
if (
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
record.exporters.set(expName, exporter);
|
|
60
|
-
},
|
|
61
|
-
on(event, handler) {
|
|
62
|
-
const list = record.listeners.get(event) ?? [];
|
|
63
|
-
list.push(handler);
|
|
64
|
-
record.listeners.set(event, list);
|
|
42
|
+
registerVcsAdapter: (adapter) => {
|
|
43
|
+
if (adapter.id === "git" || adapter.id === "jj")
|
|
44
|
+
throw new Error(`Invalid or duplicate VCS adapter ID: ${adapter.id}`);
|
|
45
|
+
const parsed = v.safeParse(VcsAdapterSchema, adapter);
|
|
46
|
+
|
|
47
|
+
if (!parsed.success) throw new Error(`Invalid VCS adapter contract: ${adapter.id}`);
|
|
48
|
+
if (
|
|
49
|
+
record.vcsAdapters.has(adapter.id) ||
|
|
50
|
+
this.extensions.some((extension) => extension.vcsAdapters.has(adapter.id))
|
|
51
|
+
)
|
|
52
|
+
throw new Error(`Invalid or duplicate VCS adapter ID: ${adapter.id}`);
|
|
53
|
+
record.vcsAdapters.set(adapter.id, adapter);
|
|
65
54
|
},
|
|
66
55
|
};
|
|
56
|
+
|
|
67
57
|
try {
|
|
68
58
|
await factory(api);
|
|
69
59
|
} catch (err) {
|
|
70
|
-
|
|
60
|
+
record.exporters.clear();
|
|
61
|
+
record.vcsAdapters.clear();
|
|
71
62
|
record.errors.push(err instanceof Error ? err.message : String(err));
|
|
72
63
|
}
|
|
73
64
|
this.extensions.push(record);
|
|
74
|
-
return record;
|
|
75
|
-
}
|
|
76
65
|
|
|
77
|
-
|
|
78
|
-
rendererFor(artifactType: string): ArtifactRenderer | undefined {
|
|
79
|
-
for (const ext of this.extensions) {
|
|
80
|
-
const r = ext.renderers.get(artifactType);
|
|
81
|
-
if (r) return r;
|
|
82
|
-
}
|
|
83
|
-
return undefined;
|
|
84
|
-
}
|
|
85
|
-
|
|
86
|
-
commandFor(name: string): { extension: string; registration: CommandRegistration } | undefined {
|
|
87
|
-
for (const ext of this.extensions) {
|
|
88
|
-
const c = ext.commands.get(name);
|
|
89
|
-
if (c) return { extension: ext.name, registration: c };
|
|
90
|
-
}
|
|
91
|
-
return undefined;
|
|
92
|
-
}
|
|
93
|
-
|
|
94
|
-
emit(event: string, session: ReviewSession): void {
|
|
95
|
-
for (const ext of this.extensions) {
|
|
96
|
-
for (const handler of ext.listeners.get(event) ?? []) {
|
|
97
|
-
try {
|
|
98
|
-
handler(session);
|
|
99
|
-
} catch {
|
|
100
|
-
// observers never cancel and never crash the host
|
|
101
|
-
}
|
|
102
|
-
}
|
|
103
|
-
}
|
|
66
|
+
return record;
|
|
104
67
|
}
|
|
105
68
|
}
|
package/src/types.ts
CHANGED
|
@@ -1,58 +1,47 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* The
|
|
3
|
-
*
|
|
4
|
-
* so the contract stays self-contained.
|
|
2
|
+
* The typed extension contract. Import-free beyond the schema, so the
|
|
3
|
+
* contract stays self-contained.
|
|
5
4
|
*
|
|
6
|
-
* An extension is a factory:
|
|
7
|
-
* export default (cueloop: ExtensionAPI) => { cueloop.
|
|
8
|
-
* Registration is data written into a per-extension record;
|
|
9
|
-
*
|
|
5
|
+
* An extension is a factory that registers an exporter:
|
|
6
|
+
* export default (cueloop: ExtensionAPI) => { cueloop.registerExporter(...) }
|
|
7
|
+
* Registration is data written into a per-extension record; the exporter runs
|
|
8
|
+
* later, host-driven. No import-time side effects.
|
|
10
9
|
*/
|
|
11
10
|
|
|
12
|
-
import type {
|
|
11
|
+
import type { DiffFileContents, DiffFileStatus, Thread } from "@cueloop/schema";
|
|
13
12
|
|
|
14
|
-
/**
|
|
15
|
-
export
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
/** Renderers project an artifact type into lines at a given width. */
|
|
24
|
-
export type ArtifactRenderer = (artifact: { content: string; meta: Record<string, unknown> }, width: number) => UiLine[];
|
|
25
|
-
|
|
26
|
-
export interface CommandContext {
|
|
27
|
-
session: ReviewSession | null;
|
|
28
|
-
annotate(annotation: Omit<Annotation, "createdAt">): Promise<void>;
|
|
29
|
-
resolve(verdict: VerdictKind, summary: string): Promise<void>;
|
|
30
|
-
notify(message: string): void;
|
|
13
|
+
/** Exporters ship resolved sessions somewhere (notes vaults, forges). */
|
|
14
|
+
export type Exporter = (
|
|
15
|
+
session: Thread,
|
|
16
|
+
) => Promise<{ success: boolean; path?: string; error?: string }>;
|
|
17
|
+
|
|
18
|
+
/** A source identity links a live diff to an exact captured revision. */
|
|
19
|
+
export interface VcsSourceIdentity {
|
|
20
|
+
changeId?: string;
|
|
21
|
+
revisionId: string;
|
|
31
22
|
}
|
|
32
23
|
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
24
|
+
/** One complete working-copy capture; file contents must match the returned patch. */
|
|
25
|
+
export interface VcsDiffSnapshot {
|
|
26
|
+
patch: string;
|
|
27
|
+
files: DiffFileContents[];
|
|
28
|
+
source?: VcsSourceIdentity;
|
|
36
29
|
}
|
|
37
30
|
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
31
|
+
/** The daemon-owned diff source contract; adapters never mutate Threads. */
|
|
32
|
+
export interface VcsAdapter {
|
|
33
|
+
apiVersion: 1;
|
|
34
|
+
id: string;
|
|
35
|
+
detect(cwd: string): Promise<string | null>;
|
|
36
|
+
captureWorkingDiff(repoRoot: string): Promise<VcsDiffSnapshot>;
|
|
37
|
+
captureChange?(repoRoot: string, changeId: string): Promise<VcsDiffSnapshot>;
|
|
38
|
+
listChanges(repoRoot: string): Promise<{ path: string; status: DiffFileStatus }[]>;
|
|
39
|
+
listFiles(repoRoot: string): Promise<string[]>;
|
|
43
40
|
}
|
|
44
41
|
|
|
45
|
-
/** Exporters ship resolved sessions somewhere (notes vaults, forges). */
|
|
46
|
-
export type Exporter = (session: ReviewSession) => Promise<{ success: boolean; path?: string; error?: string }>;
|
|
47
|
-
|
|
48
42
|
export interface ExtensionAPI {
|
|
49
|
-
/** Renderers for artifact types; built-ins register through this too. */
|
|
50
|
-
registerRenderer(artifactType: string, renderer: ArtifactRenderer): void;
|
|
51
|
-
registerCommand(name: string, registration: CommandRegistration): void;
|
|
52
|
-
registerKeybinding(registration: KeybindingRegistration): void;
|
|
53
43
|
registerExporter(name: string, exporter: Exporter): void;
|
|
54
|
-
|
|
55
|
-
on(event: "session.created" | "session.resolved" | "session.updated", handler: (session: ReviewSession) => void): void;
|
|
44
|
+
registerVcsAdapter(adapter: VcsAdapter): void;
|
|
56
45
|
}
|
|
57
46
|
|
|
58
47
|
export type ExtensionFactory = (cueloop: ExtensionAPI) => void | Promise<void>;
|
package/src/loader.ts
DELETED
|
@@ -1,98 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Extension discovery and loading (#10): zero-build TypeScript executed
|
|
3
|
-
* directly. Global user extensions always load; repo-local `.cueloop/
|
|
4
|
-
* extensions/` loads only when the repo is trusted (persisted decisions
|
|
5
|
-
* under the cueloop home).
|
|
6
|
-
*/
|
|
7
|
-
|
|
8
|
-
import { existsSync, readdirSync, readFileSync, writeFileSync, mkdirSync } from "node:fs";
|
|
9
|
-
import { homedir } from "node:os";
|
|
10
|
-
import { join } from "node:path";
|
|
11
|
-
import { Registry } from "./registry";
|
|
12
|
-
import type { ExtensionFactory } from "./types";
|
|
13
|
-
|
|
14
|
-
export function userExtensionsDir(): string {
|
|
15
|
-
return join(process.env.XDG_CONFIG_HOME ?? join(homedir(), ".config"), "cueloop", "extensions");
|
|
16
|
-
}
|
|
17
|
-
|
|
18
|
-
export interface LoadOptions {
|
|
19
|
-
registry: Registry;
|
|
20
|
-
userDir?: string;
|
|
21
|
-
repoRoot?: string;
|
|
22
|
-
/** Persisted trust decisions live under the cueloop home. */
|
|
23
|
-
home?: string;
|
|
24
|
-
/** Trust resolver for untrusted repos; default denies (never prompt-free). */
|
|
25
|
-
confirmTrust?: (repoRoot: string) => Promise<boolean>;
|
|
26
|
-
}
|
|
27
|
-
|
|
28
|
-
interface TrustStore {
|
|
29
|
-
trusted: string[];
|
|
30
|
-
}
|
|
31
|
-
|
|
32
|
-
function trustPath(home: string): string {
|
|
33
|
-
return join(home, "trusted-repos.json");
|
|
34
|
-
}
|
|
35
|
-
|
|
36
|
-
export function readTrust(home: string): TrustStore {
|
|
37
|
-
try {
|
|
38
|
-
return JSON.parse(readFileSync(trustPath(home), "utf8")) as TrustStore;
|
|
39
|
-
} catch {
|
|
40
|
-
return { trusted: [] };
|
|
41
|
-
}
|
|
42
|
-
}
|
|
43
|
-
|
|
44
|
-
export function grantTrust(home: string, repoRoot: string): void {
|
|
45
|
-
const store = readTrust(home);
|
|
46
|
-
if (!store.trusted.includes(repoRoot)) store.trusted.push(repoRoot);
|
|
47
|
-
mkdirSync(home, { recursive: true });
|
|
48
|
-
writeFileSync(trustPath(home), JSON.stringify(store, null, 2));
|
|
49
|
-
}
|
|
50
|
-
|
|
51
|
-
function extensionFiles(dir: string): string[] {
|
|
52
|
-
if (!existsSync(dir)) return [];
|
|
53
|
-
return readdirSync(dir)
|
|
54
|
-
.filter((f) => f.endsWith(".ts") || f.endsWith(".js"))
|
|
55
|
-
.map((f) => join(dir, f))
|
|
56
|
-
.sort();
|
|
57
|
-
}
|
|
58
|
-
|
|
59
|
-
export async function loadExtensions(opts: LoadOptions): Promise<{ loaded: string[]; skipped: string[] }> {
|
|
60
|
-
const loaded: string[] = [];
|
|
61
|
-
const skipped: string[] = [];
|
|
62
|
-
const files: { path: string; source: string }[] = [];
|
|
63
|
-
|
|
64
|
-
for (const path of extensionFiles(opts.userDir ?? userExtensionsDir())) {
|
|
65
|
-
files.push({ path, source: "user" });
|
|
66
|
-
}
|
|
67
|
-
if (opts.repoRoot) {
|
|
68
|
-
const repoDir = join(opts.repoRoot, ".cueloop", "extensions");
|
|
69
|
-
const repoFiles = extensionFiles(repoDir);
|
|
70
|
-
if (repoFiles.length) {
|
|
71
|
-
const home = opts.home ?? join(homedir(), ".cueloop");
|
|
72
|
-
const trusted =
|
|
73
|
-
readTrust(home).trusted.includes(opts.repoRoot) ||
|
|
74
|
-
(opts.confirmTrust ? await opts.confirmTrust(opts.repoRoot) : false);
|
|
75
|
-
if (trusted) {
|
|
76
|
-
grantTrust(home, opts.repoRoot);
|
|
77
|
-
for (const path of repoFiles) files.push({ path, source: "repo" });
|
|
78
|
-
} else {
|
|
79
|
-
skipped.push(...repoFiles);
|
|
80
|
-
}
|
|
81
|
-
}
|
|
82
|
-
}
|
|
83
|
-
|
|
84
|
-
for (const file of files) {
|
|
85
|
-
try {
|
|
86
|
-
const mod = (await import(file.path)) as { default?: ExtensionFactory };
|
|
87
|
-
if (typeof mod.default !== "function") {
|
|
88
|
-
skipped.push(file.path);
|
|
89
|
-
continue;
|
|
90
|
-
}
|
|
91
|
-
await opts.registry.load(file.path, mod.default);
|
|
92
|
-
loaded.push(file.path);
|
|
93
|
-
} catch {
|
|
94
|
-
skipped.push(file.path);
|
|
95
|
-
}
|
|
96
|
-
}
|
|
97
|
-
return { loaded, skipped };
|
|
98
|
-
}
|