@cueloop/daemon 0.1.0-alpha.89 → 0.1.0-alpha.90
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 +4 -2
- package/src/api.ts +181 -24
- package/src/client.ts +16 -5
- package/src/diff-hot-reload.test.ts +128 -0
- package/src/diff-watcher.ts +22 -3
- package/src/jj-working-tree.ts +131 -0
- package/src/server.ts +6 -2
- package/src/thread-review.test.ts +90 -1
- package/src/thread-review.ts +85 -9
- package/src/validate.test.ts +10 -1
- package/src/validate.ts +21 -1
- package/src/vcs-source.test.ts +180 -0
- package/src/vcs-source.ts +228 -0
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
import { afterEach, describe, expect, test } from "bun:test";
|
|
2
|
+
import { mkdirSync, rmSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import {
|
|
5
|
+
createTestVcsDirectory,
|
|
6
|
+
createTestVcsGitRepo,
|
|
7
|
+
runTestVcsCommand,
|
|
8
|
+
} from "../../../test/helpers/vcs-repo";
|
|
9
|
+
import { VcsSourceManager } from "./vcs-source";
|
|
10
|
+
import { workingTreeDiff } from "./working-tree";
|
|
11
|
+
|
|
12
|
+
const created: string[] = [];
|
|
13
|
+
|
|
14
|
+
afterEach(() => {
|
|
15
|
+
for (const path of created.splice(0)) rmSync(path, { recursive: true, force: true });
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
describe("VcsSourceManager", () => {
|
|
19
|
+
test("a directory without a VCS keeps the empty Git workbench behavior", async () => {
|
|
20
|
+
const plain = createTestVcsDirectory(created);
|
|
21
|
+
const config = join(createTestVcsDirectory(created), "config.toml");
|
|
22
|
+
const source = new VcsSourceManager(config);
|
|
23
|
+
|
|
24
|
+
expect((await source.select(plain)).adapter.id).toBe("git");
|
|
25
|
+
expect(await source.capture(plain)).toMatchObject({ patch: "", files: [] });
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
test("Git auto capture preserves the existing patch and curatable files", async () => {
|
|
29
|
+
const repo = createTestVcsGitRepo(created);
|
|
30
|
+
const config = join(createTestVcsDirectory(created), "config.toml");
|
|
31
|
+
|
|
32
|
+
writeFileSync(join(repo, "a.txt"), "after\n");
|
|
33
|
+
writeFileSync(join(repo, "new.txt"), "new\n");
|
|
34
|
+
writeFileSync(join(repo, "space name.txt"), "space\n");
|
|
35
|
+
const baseline = await workingTreeDiff(repo);
|
|
36
|
+
const source = new VcsSourceManager(config);
|
|
37
|
+
const selected = await source.select(repo);
|
|
38
|
+
const captured = await source.capture(repo);
|
|
39
|
+
|
|
40
|
+
expect(selected.adapter.id).toBe("git");
|
|
41
|
+
expect({ patch: captured.patch, files: captured.files }).toEqual(baseline);
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
test("JJ wins a colocated checkout and captures its native working-copy revision", async () => {
|
|
45
|
+
const repo = createTestVcsGitRepo(created);
|
|
46
|
+
const config = join(createTestVcsDirectory(created), "config.toml");
|
|
47
|
+
|
|
48
|
+
runTestVcsCommand(repo, "jj", "git", "init", "--colocate", repo);
|
|
49
|
+
writeFileSync(join(repo, "a.txt"), "after\n");
|
|
50
|
+
writeFileSync(join(repo, "new.txt"), "new\n");
|
|
51
|
+
writeFileSync(join(repo, "space name.txt"), "space\n");
|
|
52
|
+
const source = new VcsSourceManager(config);
|
|
53
|
+
const selected = await source.select(repo);
|
|
54
|
+
const captured = await source.capture(repo);
|
|
55
|
+
|
|
56
|
+
expect(selected.adapter.id).toBe("jj");
|
|
57
|
+
expect(captured.patch).toContain("+after");
|
|
58
|
+
expect(captured.patch).toContain("+new");
|
|
59
|
+
expect(captured.files).toContainEqual({
|
|
60
|
+
path: "a.txt",
|
|
61
|
+
oldContents: "before\n",
|
|
62
|
+
newContents: "after\n",
|
|
63
|
+
status: "modified",
|
|
64
|
+
});
|
|
65
|
+
expect(captured.files).toContainEqual({
|
|
66
|
+
path: "new.txt",
|
|
67
|
+
oldContents: "",
|
|
68
|
+
newContents: "new\n",
|
|
69
|
+
status: "added",
|
|
70
|
+
});
|
|
71
|
+
expect(captured.files).toContainEqual({
|
|
72
|
+
path: "space name.txt",
|
|
73
|
+
oldContents: "",
|
|
74
|
+
newContents: "space\n",
|
|
75
|
+
status: "added",
|
|
76
|
+
});
|
|
77
|
+
expect(await selected.adapter.listFiles(repo)).toContain("space name.txt");
|
|
78
|
+
expect(captured.source?.changeId).toBeTruthy();
|
|
79
|
+
expect(captured.source?.revisionId).toMatch(/^[0-9a-f]{40}$/);
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
test("repo config can select Git in a colocated checkout", async () => {
|
|
83
|
+
const repo = createTestVcsGitRepo(created);
|
|
84
|
+
const config = join(createTestVcsDirectory(created), "config.toml");
|
|
85
|
+
|
|
86
|
+
runTestVcsCommand(repo, "jj", "git", "init", "--colocate", repo);
|
|
87
|
+
mkdirSync(join(repo, ".cueloop"));
|
|
88
|
+
writeFileSync(join(repo, ".cueloop", "config.toml"), '[vcs]\nprovider = "git"\n');
|
|
89
|
+
const source = new VcsSourceManager(config);
|
|
90
|
+
|
|
91
|
+
expect((await source.select(repo)).adapter.id).toBe("git");
|
|
92
|
+
expect((await source.select(repo, "jj")).adapter.id).toBe("jj");
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
test("a personal extension can register a custom adapter", async () => {
|
|
96
|
+
const root = createTestVcsDirectory(created);
|
|
97
|
+
const repo = join(root, "repo");
|
|
98
|
+
const extension = join(root, "adapter.ts");
|
|
99
|
+
const config = join(root, "config.toml");
|
|
100
|
+
|
|
101
|
+
mkdirSync(repo);
|
|
102
|
+
writeFileSync(
|
|
103
|
+
extension,
|
|
104
|
+
`export default (api) => api.registerVcsAdapter({
|
|
105
|
+
apiVersion: 1,
|
|
106
|
+
id: "example.sapling",
|
|
107
|
+
detect: async (cwd) => cwd,
|
|
108
|
+
captureWorkingDiff: async () => ({ patch: "custom patch", files: [] }),
|
|
109
|
+
listChanges: async () => [],
|
|
110
|
+
listFiles: async () => ["readme.md"],
|
|
111
|
+
});\n`,
|
|
112
|
+
);
|
|
113
|
+
writeFileSync(config, `[vcs]\nprovider = "example.sapling"\nextensions = ["${extension}"]\n`);
|
|
114
|
+
const source = new VcsSourceManager(config);
|
|
115
|
+
|
|
116
|
+
expect((await source.select(repo)).adapter.id).toBe("example.sapling");
|
|
117
|
+
expect((await source.capture(repo)).patch).toBe("custom patch");
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
test("an installed package loads its daemon adapter entry", async () => {
|
|
121
|
+
const root = createTestVcsDirectory(created);
|
|
122
|
+
const repo = join(root, "repo");
|
|
123
|
+
const installRoot = join(root, "extensions");
|
|
124
|
+
const packageRoot = join(installRoot, "node_modules", "example-vcs");
|
|
125
|
+
|
|
126
|
+
mkdirSync(repo);
|
|
127
|
+
mkdirSync(packageRoot, { recursive: true });
|
|
128
|
+
writeFileSync(
|
|
129
|
+
join(installRoot, "package.json"),
|
|
130
|
+
JSON.stringify({ dependencies: { "example-vcs": "1.0.0" } }),
|
|
131
|
+
);
|
|
132
|
+
writeFileSync(
|
|
133
|
+
join(packageRoot, "package.json"),
|
|
134
|
+
JSON.stringify({ cueloop: { daemon: "./daemon.ts" } }),
|
|
135
|
+
);
|
|
136
|
+
writeFileSync(
|
|
137
|
+
join(packageRoot, "daemon.ts"),
|
|
138
|
+
`export default (api) => api.registerVcsAdapter({
|
|
139
|
+
apiVersion: 1,
|
|
140
|
+
id: "example.installed",
|
|
141
|
+
detect: async (cwd) => cwd,
|
|
142
|
+
captureWorkingDiff: async () => ({ patch: "installed patch", files: [] }),
|
|
143
|
+
listChanges: async () => [],
|
|
144
|
+
listFiles: async () => [],
|
|
145
|
+
});`,
|
|
146
|
+
);
|
|
147
|
+
writeFileSync(join(root, "config.toml"), '[vcs]\nprovider = "example.installed"\n');
|
|
148
|
+
const source = new VcsSourceManager(join(root, "config.toml"), installRoot);
|
|
149
|
+
|
|
150
|
+
expect((await source.select(repo)).adapter.id).toBe("example.installed");
|
|
151
|
+
expect((await source.capture(repo)).patch).toBe("installed patch");
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
test("a failing extension detection leaves the built-in Git source usable", async () => {
|
|
155
|
+
const root = createTestVcsDirectory(created);
|
|
156
|
+
const repo = createTestVcsGitRepo(created);
|
|
157
|
+
const extension = join(root, "broken.ts");
|
|
158
|
+
const config = join(root, "config.toml");
|
|
159
|
+
|
|
160
|
+
writeFileSync(
|
|
161
|
+
extension,
|
|
162
|
+
`export default (api) => api.registerVcsAdapter({
|
|
163
|
+
apiVersion: 1,
|
|
164
|
+
id: "example.broken",
|
|
165
|
+
detect: async () => { throw new Error("broken checkout probe"); },
|
|
166
|
+
captureWorkingDiff: async () => ({ patch: "", files: [] }),
|
|
167
|
+
listChanges: async () => [],
|
|
168
|
+
listFiles: async () => [],
|
|
169
|
+
});\n`,
|
|
170
|
+
);
|
|
171
|
+
writeFileSync(config, `[vcs]\nextensions = ["${extension}"]\n`);
|
|
172
|
+
const source = new VcsSourceManager(config);
|
|
173
|
+
|
|
174
|
+
expect((await source.select(repo)).adapter.id).toBe("git");
|
|
175
|
+
expect((await source.capture(repo)).vcs).toBe("git");
|
|
176
|
+
expect(source.extensionErrors).toContain(
|
|
177
|
+
"VCS adapter example.broken detection failed: Error: broken checkout probe",
|
|
178
|
+
);
|
|
179
|
+
});
|
|
180
|
+
});
|
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
/** Select and run VCS diff adapters without exposing daemon state to extensions. */
|
|
2
|
+
|
|
3
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
4
|
+
import { homedir } from "node:os";
|
|
5
|
+
import { dirname, isAbsolute, join, resolve } from "node:path";
|
|
6
|
+
import { pathToFileURL } from "node:url";
|
|
7
|
+
import type { VcsAdapter, VcsDiffSnapshot, ExtensionFactory } from "@cueloop/extension-api";
|
|
8
|
+
import { Registry } from "@cueloop/extension-api";
|
|
9
|
+
import {
|
|
10
|
+
discoverInstalledExtensionPackages,
|
|
11
|
+
extensionInstallRoot,
|
|
12
|
+
} from "@cueloop/extension-api/installed-packages";
|
|
13
|
+
import { parse as parseToml } from "smol-toml";
|
|
14
|
+
import * as v from "valibot";
|
|
15
|
+
import { jjVcsAdapter } from "./jj-working-tree";
|
|
16
|
+
import { workingChangeList, workingTreeDiff } from "./working-tree";
|
|
17
|
+
import { listProjectFiles } from "./project-files";
|
|
18
|
+
import { DiffFileContentsSchema } from "./validate";
|
|
19
|
+
|
|
20
|
+
const VcsDiffSnapshotSchema = v.object({
|
|
21
|
+
patch: v.string(),
|
|
22
|
+
files: v.array(DiffFileContentsSchema),
|
|
23
|
+
source: v.optional(
|
|
24
|
+
v.object({
|
|
25
|
+
changeId: v.optional(v.pipe(v.string(), v.minLength(1))),
|
|
26
|
+
revisionId: v.pipe(v.string(), v.minLength(1)),
|
|
27
|
+
}),
|
|
28
|
+
),
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
async function gitRoot(cwd: string): Promise<string | null> {
|
|
32
|
+
try {
|
|
33
|
+
const child = Bun.spawn(["git", "rev-parse", "--show-toplevel"], {
|
|
34
|
+
cwd,
|
|
35
|
+
stdout: "pipe",
|
|
36
|
+
stderr: "ignore",
|
|
37
|
+
});
|
|
38
|
+
const output = await new Response(child.stdout).text();
|
|
39
|
+
|
|
40
|
+
return (await child.exited) === 0 ? output.trim() : null;
|
|
41
|
+
} catch {
|
|
42
|
+
return null;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** The Git adapter retains the established HEAD-to-working-tree capture path. */
|
|
47
|
+
export const gitVcsAdapter: VcsAdapter = {
|
|
48
|
+
apiVersion: 1,
|
|
49
|
+
id: "git",
|
|
50
|
+
detect: gitRoot,
|
|
51
|
+
captureWorkingDiff: workingTreeDiff,
|
|
52
|
+
listChanges: workingChangeList,
|
|
53
|
+
listFiles: async (root) => listProjectFiles(root),
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
interface VcsConfig {
|
|
57
|
+
provider: string;
|
|
58
|
+
extensions: string[];
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const VcsConfigSchema = v.object({
|
|
62
|
+
vcs: v.optional(
|
|
63
|
+
v.object({
|
|
64
|
+
provider: v.optional(v.string()),
|
|
65
|
+
extensions: v.optional(v.array(v.string())),
|
|
66
|
+
}),
|
|
67
|
+
),
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
const ExtensionModuleSchema = v.object({ default: v.function() });
|
|
71
|
+
|
|
72
|
+
function readVcsToml(path: string): Partial<VcsConfig> {
|
|
73
|
+
if (!existsSync(path)) return {};
|
|
74
|
+
try {
|
|
75
|
+
const parsed = v.safeParse(VcsConfigSchema, parseToml(readFileSync(path, "utf8")));
|
|
76
|
+
|
|
77
|
+
return parsed.success ? (parsed.output.vcs ?? {}) : {};
|
|
78
|
+
} catch {
|
|
79
|
+
return {};
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** A selected adapter and the checkout root it owns. */
|
|
84
|
+
export interface SelectedVcsSource {
|
|
85
|
+
adapter: VcsAdapter;
|
|
86
|
+
repoRoot: string;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** VCS extensions load only from personal config; repo config may select an installed adapter. */
|
|
90
|
+
export class VcsSourceManager {
|
|
91
|
+
private readonly registry = new Registry();
|
|
92
|
+
private loaded: Promise<void> | null = null;
|
|
93
|
+
readonly extensionErrors: string[] = [];
|
|
94
|
+
|
|
95
|
+
constructor(
|
|
96
|
+
private readonly userConfigPath = process.env.CUELOOP_CONFIG ??
|
|
97
|
+
join(process.env.XDG_CONFIG_HOME ?? join(homedir(), ".config"), "cueloop", "config.toml"),
|
|
98
|
+
private readonly installRoot = extensionInstallRoot(),
|
|
99
|
+
) {}
|
|
100
|
+
|
|
101
|
+
private async adapters(): Promise<VcsAdapter[]> {
|
|
102
|
+
if (!this.loaded) this.loaded = this.loadExtensions();
|
|
103
|
+
await this.loaded;
|
|
104
|
+
|
|
105
|
+
return [
|
|
106
|
+
gitVcsAdapter,
|
|
107
|
+
jjVcsAdapter,
|
|
108
|
+
...this.registry.extensions.flatMap((record) => [...record.vcsAdapters.values()]),
|
|
109
|
+
];
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
private async loadExtensions(): Promise<void> {
|
|
113
|
+
const paths = readVcsToml(this.userConfigPath).extensions ?? [];
|
|
114
|
+
|
|
115
|
+
for (const path of paths) {
|
|
116
|
+
const absolute = isAbsolute(path) ? path : resolve(dirname(this.userConfigPath), path);
|
|
117
|
+
await this.loadAdapterExtension(absolute);
|
|
118
|
+
}
|
|
119
|
+
const discovery = discoverInstalledExtensionPackages(this.installRoot);
|
|
120
|
+
|
|
121
|
+
this.extensionErrors.push(...discovery.errors);
|
|
122
|
+
for (const extension of discovery.packages) {
|
|
123
|
+
if (extension.daemonEntry) await this.loadAdapterExtension(extension.daemonEntry);
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
private async loadAdapterExtension(absolute: string): Promise<void> {
|
|
128
|
+
try {
|
|
129
|
+
const module: unknown = await import(pathToFileURL(absolute).href);
|
|
130
|
+
const parsed = v.safeParse(ExtensionModuleSchema, module);
|
|
131
|
+
|
|
132
|
+
if (!parsed.success) throw new Error("VCS extension must export a default factory");
|
|
133
|
+
const factory: ExtensionFactory = async (api) => {
|
|
134
|
+
await parsed.output.default(api);
|
|
135
|
+
};
|
|
136
|
+
const record = await this.registry.load(absolute, factory);
|
|
137
|
+
|
|
138
|
+
for (const error of record.errors)
|
|
139
|
+
this.extensionErrors.push(`VCS extension ${absolute}: ${error}`);
|
|
140
|
+
} catch (error) {
|
|
141
|
+
this.extensionErrors.push(`VCS extension load failed: ${absolute}: ${String(error)}`);
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/** Auto chooses the nearest checkout, preferring jj over Git at one root. */
|
|
146
|
+
async select(cwd: string, pinnedProvider?: string): Promise<SelectedVcsSource> {
|
|
147
|
+
const adapters = await this.adapters();
|
|
148
|
+
const detected = (
|
|
149
|
+
await Promise.all(
|
|
150
|
+
adapters.map(async (adapter) => {
|
|
151
|
+
try {
|
|
152
|
+
return { adapter, repoRoot: await adapter.detect(cwd) };
|
|
153
|
+
} catch (error) {
|
|
154
|
+
const message = `VCS adapter ${adapter.id} detection failed: ${String(error)}`;
|
|
155
|
+
|
|
156
|
+
if (!this.extensionErrors.includes(message)) this.extensionErrors.push(message);
|
|
157
|
+
|
|
158
|
+
return { adapter, repoRoot: null };
|
|
159
|
+
}
|
|
160
|
+
}),
|
|
161
|
+
)
|
|
162
|
+
).filter((item): item is SelectedVcsSource => item.repoRoot !== null);
|
|
163
|
+
const nearest = detected.toSorted((a, b) => b.repoRoot.length - a.repoRoot.length)[0];
|
|
164
|
+
const repoConfig = nearest
|
|
165
|
+
? readVcsToml(join(nearest.repoRoot, ".cueloop", "config.toml"))
|
|
166
|
+
: {};
|
|
167
|
+
const requested =
|
|
168
|
+
pinnedProvider ??
|
|
169
|
+
process.env.CUELOOP_VCS ??
|
|
170
|
+
repoConfig.provider ??
|
|
171
|
+
readVcsToml(this.userConfigPath).provider ??
|
|
172
|
+
"auto";
|
|
173
|
+
|
|
174
|
+
if (requested !== "auto") {
|
|
175
|
+
const selected = detected.find(({ adapter }) => adapter.id === requested);
|
|
176
|
+
|
|
177
|
+
if (!selected && requested === "git" && detected.length === 0)
|
|
178
|
+
return { adapter: gitVcsAdapter, repoRoot: cwd };
|
|
179
|
+
if (!selected)
|
|
180
|
+
throw new Error(
|
|
181
|
+
`VCS adapter unavailable: ${requested}${this.extensionErrors.length > 0 ? ` (${this.extensionErrors.join("; ")})` : ""}`,
|
|
182
|
+
);
|
|
183
|
+
|
|
184
|
+
return selected;
|
|
185
|
+
}
|
|
186
|
+
detected.sort((a, b) => {
|
|
187
|
+
const rootDistance = b.repoRoot.length - a.repoRoot.length;
|
|
188
|
+
|
|
189
|
+
if (rootDistance !== 0) return rootDistance;
|
|
190
|
+
|
|
191
|
+
return (b.adapter.id === "jj" ? 1 : 0) - (a.adapter.id === "jj" ? 1 : 0);
|
|
192
|
+
});
|
|
193
|
+
const selected = detected[0];
|
|
194
|
+
|
|
195
|
+
if (!selected) return { adapter: gitVcsAdapter, repoRoot: cwd };
|
|
196
|
+
|
|
197
|
+
return selected;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/** Capture with the selected source and preserve its identity alongside the diff. */
|
|
201
|
+
async capture(cwd: string, pinnedProvider?: string): Promise<VcsDiffSnapshot & { vcs: string }> {
|
|
202
|
+
const selected = await this.select(cwd, pinnedProvider);
|
|
203
|
+
const snapshot = v.parse(
|
|
204
|
+
VcsDiffSnapshotSchema,
|
|
205
|
+
await selected.adapter.captureWorkingDiff(selected.repoRoot),
|
|
206
|
+
);
|
|
207
|
+
|
|
208
|
+
return { ...snapshot, vcs: selected.adapter.id };
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/** Resolve one logical change; ambiguous or unsupported identities fail explicitly. */
|
|
212
|
+
async captureChange(
|
|
213
|
+
cwd: string,
|
|
214
|
+
provider: string,
|
|
215
|
+
changeId: string,
|
|
216
|
+
): Promise<VcsDiffSnapshot & { vcs: string }> {
|
|
217
|
+
const selected = await this.select(cwd, provider);
|
|
218
|
+
|
|
219
|
+
if (!selected.adapter.captureChange)
|
|
220
|
+
throw new Error(`VCS adapter ${provider} cannot follow a change ID`);
|
|
221
|
+
const snapshot = v.parse(
|
|
222
|
+
VcsDiffSnapshotSchema,
|
|
223
|
+
await selected.adapter.captureChange(selected.repoRoot, changeId),
|
|
224
|
+
);
|
|
225
|
+
|
|
226
|
+
return { ...snapshot, vcs: selected.adapter.id };
|
|
227
|
+
}
|
|
228
|
+
}
|