@aprovan/hardcopy 0.1.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/.eslintrc.json +22 -0
- package/.github/workflows/publish.yml +41 -0
- package/.prettierignore +17 -0
- package/LICENSE +21 -0
- package/README.md +183 -0
- package/dist/cli.d.ts +1 -0
- package/dist/cli.js +2950 -0
- package/dist/index.d.ts +406 -0
- package/dist/index.js +2737 -0
- package/dist/mcp-server.d.ts +7 -0
- package/dist/mcp-server.js +2665 -0
- package/docs/research/crdt.md +777 -0
- package/docs/research/github-issues.md +684 -0
- package/docs/research/gql.md +876 -0
- package/docs/research/index.md +19 -0
- package/docs/specs/conflict-resolution.md +1254 -0
- package/docs/specs/hardcopy.md +742 -0
- package/docs/specs/patchwork-integration.md +227 -0
- package/docs/specs/plugin-architecture.md +747 -0
- package/mcp.json +8 -0
- package/package.json +64 -0
- package/scripts/install-graphqlite.ts +156 -0
- package/src/cli.ts +356 -0
- package/src/config.ts +104 -0
- package/src/conflict-store.ts +136 -0
- package/src/conflict.ts +147 -0
- package/src/crdt.ts +100 -0
- package/src/db.ts +600 -0
- package/src/env.ts +34 -0
- package/src/format.ts +72 -0
- package/src/formats/github-issue.ts +55 -0
- package/src/hardcopy/core.ts +78 -0
- package/src/hardcopy/diff.ts +188 -0
- package/src/hardcopy/index.ts +67 -0
- package/src/hardcopy/init.ts +24 -0
- package/src/hardcopy/push.ts +444 -0
- package/src/hardcopy/sync.ts +37 -0
- package/src/hardcopy/types.ts +49 -0
- package/src/hardcopy/views.ts +199 -0
- package/src/hardcopy.ts +1 -0
- package/src/index.ts +13 -0
- package/src/llm-merge.ts +109 -0
- package/src/mcp-server.ts +388 -0
- package/src/merge.ts +75 -0
- package/src/provider.ts +40 -0
- package/src/providers/a2a/index.ts +166 -0
- package/src/providers/git/index.ts +212 -0
- package/src/providers/github/index.ts +236 -0
- package/src/providers/github/issues.ts +66 -0
- package/src/providers.ts +7 -0
- package/src/types.ts +101 -0
- package/tsconfig.json +21 -0
- package/tsup.config.ts +10 -0
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import type { FormatHandler, ParsedFile } from "../format";
|
|
2
|
+
import type { Node } from "../types";
|
|
3
|
+
import matter from "gray-matter";
|
|
4
|
+
|
|
5
|
+
export const githubIssueFormat: FormatHandler = {
|
|
6
|
+
type: "github.Issue",
|
|
7
|
+
editableFields: ["title", "body", "labels", "assignee", "milestone", "state"],
|
|
8
|
+
|
|
9
|
+
render(node: Node): string {
|
|
10
|
+
const attrs = node.attrs as Record<string, unknown>;
|
|
11
|
+
const frontmatter: Record<string, unknown> = {
|
|
12
|
+
_type: "github.Issue",
|
|
13
|
+
_id: node.id,
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
const addIfDefined = (key: string, value: unknown) => {
|
|
17
|
+
if (value !== undefined && value !== null) {
|
|
18
|
+
frontmatter[key] = value;
|
|
19
|
+
}
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
addIfDefined("number", attrs["number"]);
|
|
23
|
+
addIfDefined("title", attrs["title"]);
|
|
24
|
+
addIfDefined("state", attrs["state"]);
|
|
25
|
+
addIfDefined("url", attrs["url"]);
|
|
26
|
+
addIfDefined("labels", attrs["labels"]);
|
|
27
|
+
addIfDefined("assignee", attrs["assignee"]);
|
|
28
|
+
addIfDefined("milestone", attrs["milestone"]);
|
|
29
|
+
addIfDefined("created_at", attrs["created_at"]);
|
|
30
|
+
addIfDefined("updated_at", attrs["updated_at"]);
|
|
31
|
+
|
|
32
|
+
if (attrs["syncedAt"]) {
|
|
33
|
+
frontmatter["_synced"] = new Date(attrs["syncedAt"] as number).toISOString();
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const body = (attrs["body"] as string) ?? "";
|
|
37
|
+
return matter.stringify(body, frontmatter);
|
|
38
|
+
},
|
|
39
|
+
|
|
40
|
+
parse(content: string): ParsedFile {
|
|
41
|
+
const { data, content: body } = matter(content);
|
|
42
|
+
const attrs: Record<string, unknown> = {};
|
|
43
|
+
|
|
44
|
+
if (data["title"]) attrs["title"] = data["title"];
|
|
45
|
+
if (data["state"]) attrs["state"] = data["state"];
|
|
46
|
+
if (data["labels"]) attrs["labels"] = data["labels"];
|
|
47
|
+
if (data["assignee"]) attrs["assignee"] = data["assignee"];
|
|
48
|
+
if (data["milestone"]) attrs["milestone"] = data["milestone"];
|
|
49
|
+
|
|
50
|
+
return {
|
|
51
|
+
attrs,
|
|
52
|
+
body: body.trim(),
|
|
53
|
+
};
|
|
54
|
+
},
|
|
55
|
+
};
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import { join } from "path";
|
|
2
|
+
import { mkdir } from "fs/promises";
|
|
3
|
+
import { Database } from "../db";
|
|
4
|
+
import { CRDTStore } from "../crdt";
|
|
5
|
+
import { ConflictStore } from "../conflict-store";
|
|
6
|
+
import { loadEnvFile } from "../env";
|
|
7
|
+
import { loadConfig, type Config } from "../config";
|
|
8
|
+
import { getProvider, type Provider } from "../provider";
|
|
9
|
+
import type { HardcopyOptions } from "./types";
|
|
10
|
+
|
|
11
|
+
export class Hardcopy {
|
|
12
|
+
readonly root: string;
|
|
13
|
+
readonly dataDir: string;
|
|
14
|
+
private _db: Database | null = null;
|
|
15
|
+
private _crdt: CRDTStore | null = null;
|
|
16
|
+
private _config: Config | null = null;
|
|
17
|
+
private _providers = new Map<string, Provider>();
|
|
18
|
+
private _conflictStore: ConflictStore | null = null;
|
|
19
|
+
|
|
20
|
+
constructor(options: HardcopyOptions) {
|
|
21
|
+
this.root = options.root;
|
|
22
|
+
this.dataDir = join(options.root, ".hardcopy");
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
async initialize(): Promise<void> {
|
|
26
|
+
await mkdir(this.dataDir, { recursive: true });
|
|
27
|
+
await loadEnvFile(join(this.dataDir, ".env"));
|
|
28
|
+
await mkdir(join(this.dataDir, "crdt"), { recursive: true });
|
|
29
|
+
this._db = await Database.open(join(this.dataDir, "db.sqlite"));
|
|
30
|
+
this._crdt = new CRDTStore(join(this.dataDir, "crdt"));
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
async loadConfig(): Promise<Config> {
|
|
34
|
+
if (this._config) return this._config;
|
|
35
|
+
const configPath = join(this.root, "hardcopy.yaml");
|
|
36
|
+
this._config = await loadConfig(configPath);
|
|
37
|
+
await this.initializeProviders();
|
|
38
|
+
return this._config;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
private async initializeProviders(): Promise<void> {
|
|
42
|
+
if (!this._config) return;
|
|
43
|
+
for (const source of this._config.sources) {
|
|
44
|
+
const factory = getProvider(source.provider);
|
|
45
|
+
if (factory) {
|
|
46
|
+
this._providers.set(source.name, factory(source));
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
getDatabase(): Database {
|
|
52
|
+
if (!this._db) throw new Error("Database not initialized");
|
|
53
|
+
return this._db;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
getCRDTStore(): CRDTStore {
|
|
57
|
+
if (!this._crdt) throw new Error("CRDT store not initialized");
|
|
58
|
+
return this._crdt;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
getConflictStore(): ConflictStore {
|
|
62
|
+
if (!this._conflictStore) {
|
|
63
|
+
this._conflictStore = new ConflictStore(join(this.dataDir, "conflicts"));
|
|
64
|
+
}
|
|
65
|
+
return this._conflictStore;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
getProviders(): Map<string, Provider> {
|
|
69
|
+
return this._providers;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
async close(): Promise<void> {
|
|
73
|
+
if (this._db) {
|
|
74
|
+
await this._db.close();
|
|
75
|
+
this._db = null;
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
}
|
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
import { join } from "path";
|
|
2
|
+
import { readFile, stat } from "fs/promises";
|
|
3
|
+
import { minimatch } from "minimatch";
|
|
4
|
+
import { parseFile, getFormat } from "../format";
|
|
5
|
+
import type { Node, Change } from "../types";
|
|
6
|
+
import type { Hardcopy } from "./core";
|
|
7
|
+
import type { DiffResult, ChangedFile } from "./types";
|
|
8
|
+
import { listViewFiles } from "./views";
|
|
9
|
+
|
|
10
|
+
export async function getChangedFiles(
|
|
11
|
+
this: Hardcopy,
|
|
12
|
+
pattern?: string,
|
|
13
|
+
): Promise<ChangedFile[]> {
|
|
14
|
+
const config = await this.loadConfig();
|
|
15
|
+
const db = this.getDatabase();
|
|
16
|
+
const changedFiles: ChangedFile[] = [];
|
|
17
|
+
|
|
18
|
+
for (const view of config.views) {
|
|
19
|
+
const viewDir = join(this.root, view.path);
|
|
20
|
+
const files = await listViewFiles(viewDir);
|
|
21
|
+
|
|
22
|
+
for (const relPath of files) {
|
|
23
|
+
const fullPath = join(viewDir, relPath);
|
|
24
|
+
const viewRelPath = join(view.path, relPath);
|
|
25
|
+
|
|
26
|
+
if (
|
|
27
|
+
pattern &&
|
|
28
|
+
!minimatch(viewRelPath, pattern) &&
|
|
29
|
+
!viewRelPath.startsWith(pattern)
|
|
30
|
+
) {
|
|
31
|
+
continue;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const fileStat = await stat(fullPath).catch(() => null);
|
|
35
|
+
if (!fileStat) continue;
|
|
36
|
+
|
|
37
|
+
const content = await readFile(fullPath, "utf-8");
|
|
38
|
+
const parsed = parseFile(content, "generic");
|
|
39
|
+
const nodeId = (parsed.attrs._id ?? parsed.attrs.id) as
|
|
40
|
+
| string
|
|
41
|
+
| undefined;
|
|
42
|
+
if (!nodeId) continue;
|
|
43
|
+
|
|
44
|
+
const dbNode = await db.getNode(nodeId);
|
|
45
|
+
const fileMtime = fileStat.mtimeMs;
|
|
46
|
+
const syncedAt = dbNode?.syncedAt ?? 0;
|
|
47
|
+
|
|
48
|
+
if (fileMtime > syncedAt) {
|
|
49
|
+
changedFiles.push({
|
|
50
|
+
path: viewRelPath,
|
|
51
|
+
fullPath,
|
|
52
|
+
nodeId,
|
|
53
|
+
nodeType:
|
|
54
|
+
dbNode?.type ?? (parsed.attrs._type as string) ?? "unknown",
|
|
55
|
+
status: dbNode ? "modified" : "new",
|
|
56
|
+
mtime: fileMtime,
|
|
57
|
+
syncedAt,
|
|
58
|
+
});
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
return changedFiles;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export async function diff(
|
|
67
|
+
this: Hardcopy,
|
|
68
|
+
pattern?: string,
|
|
69
|
+
options: { smart?: boolean } = {},
|
|
70
|
+
): Promise<DiffResult[]> {
|
|
71
|
+
const config = await this.loadConfig();
|
|
72
|
+
const db = this.getDatabase();
|
|
73
|
+
const results: DiffResult[] = [];
|
|
74
|
+
|
|
75
|
+
const useSmart = options.smart !== false;
|
|
76
|
+
|
|
77
|
+
if (useSmart && pattern) {
|
|
78
|
+
const candidates = await getChangedFiles.call(this, pattern);
|
|
79
|
+
for (const candidate of candidates) {
|
|
80
|
+
const result = await diffFile.call(this, candidate.fullPath, db);
|
|
81
|
+
if (result && result.changes.length > 0) {
|
|
82
|
+
results.push(result);
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
return results;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
for (const view of config.views) {
|
|
89
|
+
const viewDir = join(this.root, view.path);
|
|
90
|
+
const files = await listViewFiles(viewDir);
|
|
91
|
+
|
|
92
|
+
for (const relPath of files) {
|
|
93
|
+
const fullPath = join(viewDir, relPath);
|
|
94
|
+
const viewRelPath = join(view.path, relPath);
|
|
95
|
+
|
|
96
|
+
if (pattern) {
|
|
97
|
+
const targetPath = join(this.root, pattern);
|
|
98
|
+
const isExactMatch = fullPath === targetPath;
|
|
99
|
+
const isGlobMatch = minimatch(viewRelPath, pattern);
|
|
100
|
+
const isPrefixMatch = viewRelPath.startsWith(pattern);
|
|
101
|
+
if (!isExactMatch && !isGlobMatch && !isPrefixMatch) continue;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
const result = await diffFile.call(this, fullPath, db);
|
|
105
|
+
if (result && result.changes.length > 0) {
|
|
106
|
+
results.push(result);
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
return results;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
async function diffFile(
|
|
115
|
+
this: Hardcopy,
|
|
116
|
+
fullPath: string,
|
|
117
|
+
db: ReturnType<Hardcopy["getDatabase"]>,
|
|
118
|
+
): Promise<DiffResult | null> {
|
|
119
|
+
try {
|
|
120
|
+
const content = await readFile(fullPath, "utf-8");
|
|
121
|
+
const parsed = parseFile(content, "generic");
|
|
122
|
+
|
|
123
|
+
const nodeId = (parsed.attrs._id ?? parsed.attrs.id) as string | undefined;
|
|
124
|
+
const nodeType = parsed.attrs._type as string | undefined;
|
|
125
|
+
if (!nodeId) return null;
|
|
126
|
+
|
|
127
|
+
const dbNode = await db.getNode(nodeId);
|
|
128
|
+
if (!dbNode) {
|
|
129
|
+
return {
|
|
130
|
+
nodeId,
|
|
131
|
+
nodeType: nodeType ?? "unknown",
|
|
132
|
+
filePath: fullPath,
|
|
133
|
+
changes: [{ field: "_new", oldValue: null, newValue: parsed.attrs }],
|
|
134
|
+
};
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
const format = getFormat(dbNode.type);
|
|
138
|
+
if (!format) return null;
|
|
139
|
+
|
|
140
|
+
const changes = detectChanges(parsed, dbNode, format.editableFields);
|
|
141
|
+
return {
|
|
142
|
+
nodeId,
|
|
143
|
+
nodeType: dbNode.type,
|
|
144
|
+
filePath: fullPath,
|
|
145
|
+
changes,
|
|
146
|
+
};
|
|
147
|
+
} catch {
|
|
148
|
+
return null;
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
export function detectChanges(
|
|
153
|
+
parsed: { attrs: Record<string, unknown>; body: string },
|
|
154
|
+
dbNode: Node,
|
|
155
|
+
editableFields: string[],
|
|
156
|
+
): Change[] {
|
|
157
|
+
const changes: Change[] = [];
|
|
158
|
+
const dbAttrs = dbNode.attrs as Record<string, unknown>;
|
|
159
|
+
|
|
160
|
+
for (const field of editableFields) {
|
|
161
|
+
if (field === "body") {
|
|
162
|
+
const oldBody = ((dbAttrs["body"] as string) ?? "").trim();
|
|
163
|
+
const newBody = parsed.body.trim();
|
|
164
|
+
if (newBody !== oldBody) {
|
|
165
|
+
changes.push({ field: "body", oldValue: oldBody, newValue: newBody });
|
|
166
|
+
}
|
|
167
|
+
} else {
|
|
168
|
+
const oldValue = dbAttrs[field];
|
|
169
|
+
const newValue = parsed.attrs[field];
|
|
170
|
+
if (!valuesEqual(oldValue, newValue)) {
|
|
171
|
+
changes.push({ field, oldValue, newValue });
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
return changes;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
function valuesEqual(a: unknown, b: unknown): boolean {
|
|
180
|
+
if (a === b) return true;
|
|
181
|
+
if (a == null && b == null) return true;
|
|
182
|
+
if (Array.isArray(a) && Array.isArray(b)) {
|
|
183
|
+
return (
|
|
184
|
+
a.length === b.length && a.every((v, i) => valuesEqual(v, b[i]))
|
|
185
|
+
);
|
|
186
|
+
}
|
|
187
|
+
return JSON.stringify(a) === JSON.stringify(b);
|
|
188
|
+
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import { registerFormat } from "../format";
|
|
2
|
+
import { githubIssueFormat } from "../formats/github-issue";
|
|
3
|
+
import "../providers";
|
|
4
|
+
|
|
5
|
+
import { Hardcopy } from "./core";
|
|
6
|
+
import { sync } from "./sync";
|
|
7
|
+
import { getViews, refreshView } from "./views";
|
|
8
|
+
import { diff, getChangedFiles } from "./diff";
|
|
9
|
+
import {
|
|
10
|
+
push,
|
|
11
|
+
status,
|
|
12
|
+
listConflicts,
|
|
13
|
+
getConflict,
|
|
14
|
+
getConflictDetail,
|
|
15
|
+
resolveConflict,
|
|
16
|
+
} from "./push";
|
|
17
|
+
|
|
18
|
+
registerFormat(githubIssueFormat);
|
|
19
|
+
|
|
20
|
+
// Extend Hardcopy prototype with operation methods
|
|
21
|
+
declare module "./core" {
|
|
22
|
+
interface Hardcopy {
|
|
23
|
+
sync(): Promise<import("./types").SyncStats>;
|
|
24
|
+
getViews(): Promise<string[]>;
|
|
25
|
+
refreshView(
|
|
26
|
+
viewPath: string,
|
|
27
|
+
options?: { clean?: boolean },
|
|
28
|
+
): Promise<import("./types").RefreshResult>;
|
|
29
|
+
diff(
|
|
30
|
+
pattern?: string,
|
|
31
|
+
options?: { smart?: boolean },
|
|
32
|
+
): Promise<import("./types").DiffResult[]>;
|
|
33
|
+
getChangedFiles(pattern?: string): Promise<import("./types").ChangedFile[]>;
|
|
34
|
+
push(
|
|
35
|
+
filePath?: string,
|
|
36
|
+
options?: { force?: boolean },
|
|
37
|
+
): Promise<import("./types").PushStats>;
|
|
38
|
+
status(): Promise<import("./types").StatusInfo>;
|
|
39
|
+
listConflicts(): Promise<import("../types").ConflictInfo[]>;
|
|
40
|
+
getConflict(nodeId: string): Promise<import("../types").ConflictInfo | null>;
|
|
41
|
+
getConflictDetail(nodeId: string): Promise<{
|
|
42
|
+
info: import("../types").ConflictInfo;
|
|
43
|
+
body: string;
|
|
44
|
+
artifactPath: string;
|
|
45
|
+
} | null>;
|
|
46
|
+
resolveConflict(
|
|
47
|
+
nodeId: string,
|
|
48
|
+
resolution: Record<string, "local" | "remote">,
|
|
49
|
+
): Promise<void>;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
Hardcopy.prototype.sync = sync;
|
|
54
|
+
Hardcopy.prototype.getViews = getViews;
|
|
55
|
+
Hardcopy.prototype.refreshView = refreshView;
|
|
56
|
+
Hardcopy.prototype.diff = diff;
|
|
57
|
+
Hardcopy.prototype.getChangedFiles = getChangedFiles;
|
|
58
|
+
Hardcopy.prototype.push = push;
|
|
59
|
+
Hardcopy.prototype.status = status;
|
|
60
|
+
Hardcopy.prototype.listConflicts = listConflicts;
|
|
61
|
+
Hardcopy.prototype.getConflict = getConflict;
|
|
62
|
+
Hardcopy.prototype.getConflictDetail = getConflictDetail;
|
|
63
|
+
Hardcopy.prototype.resolveConflict = resolveConflict;
|
|
64
|
+
|
|
65
|
+
export { Hardcopy } from "./core";
|
|
66
|
+
export { initHardcopy } from "./init";
|
|
67
|
+
export * from "./types";
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { join } from "path";
|
|
2
|
+
import { mkdir, writeFile, access } from "fs/promises";
|
|
3
|
+
import { Database } from "../db";
|
|
4
|
+
|
|
5
|
+
export async function initHardcopy(root: string): Promise<void> {
|
|
6
|
+
const dataDir = join(root, ".hardcopy");
|
|
7
|
+
await mkdir(dataDir, { recursive: true });
|
|
8
|
+
await mkdir(join(dataDir, "crdt"), { recursive: true });
|
|
9
|
+
await mkdir(join(dataDir, "errors"), { recursive: true });
|
|
10
|
+
|
|
11
|
+
const db = await Database.open(join(dataDir, "db.sqlite"));
|
|
12
|
+
await db.close();
|
|
13
|
+
|
|
14
|
+
const configPath = join(root, "hardcopy.yaml");
|
|
15
|
+
try {
|
|
16
|
+
await access(configPath);
|
|
17
|
+
} catch {
|
|
18
|
+
const defaultConfig = `# Hardcopy configuration
|
|
19
|
+
sources: []
|
|
20
|
+
views: []
|
|
21
|
+
`;
|
|
22
|
+
await writeFile(configPath, defaultConfig);
|
|
23
|
+
}
|
|
24
|
+
}
|