@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
package/mcp.json
ADDED
package/package.json
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@aprovan/hardcopy",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Keep everything close at hand",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"exports": {
|
|
7
|
+
".": {
|
|
8
|
+
"import": "./dist/index.js",
|
|
9
|
+
"types": "./dist/index.d.ts"
|
|
10
|
+
}
|
|
11
|
+
},
|
|
12
|
+
"bin": {
|
|
13
|
+
"hardcopy": "./dist/cli.js"
|
|
14
|
+
},
|
|
15
|
+
"devDependencies": {
|
|
16
|
+
"@types/node": "^25.3.0",
|
|
17
|
+
"@typescript-eslint/eslint-plugin": "^8.14.0",
|
|
18
|
+
"@typescript-eslint/parser": "^8.14.0",
|
|
19
|
+
"eslint": "^8.57.0",
|
|
20
|
+
"eslint-plugin-tsdoc": "^0.5.0",
|
|
21
|
+
"lint-staged": "^13.0.3",
|
|
22
|
+
"prettier": "^2.7.1",
|
|
23
|
+
"tsup": "^8.5.1",
|
|
24
|
+
"tsx": "^4.19.2",
|
|
25
|
+
"typescript": "^5.7.3",
|
|
26
|
+
"vitest": "2.1.5"
|
|
27
|
+
},
|
|
28
|
+
"lint-staged": {
|
|
29
|
+
"*.{ts,tsx}": "eslint --fix",
|
|
30
|
+
"*.{js,jsx,ts,tsx,md,scss}": "prettier --write"
|
|
31
|
+
},
|
|
32
|
+
"engines": {
|
|
33
|
+
"node": ">=20.0.0"
|
|
34
|
+
},
|
|
35
|
+
"dependencies": {
|
|
36
|
+
"@libsql/client": "^0.17.0",
|
|
37
|
+
"@modelcontextprotocol/sdk": "^1.25.2",
|
|
38
|
+
"@types/better-sqlite3": "^7.6.13",
|
|
39
|
+
"better-sqlite3": "^12.6.2",
|
|
40
|
+
"commander": "^14.0.3",
|
|
41
|
+
"gray-matter": "^4.0.3",
|
|
42
|
+
"libsql": "^0.5.22",
|
|
43
|
+
"loro-crdt": "^1.10.6",
|
|
44
|
+
"minimatch": "^10.2.2",
|
|
45
|
+
"yaml": "^2.8.2"
|
|
46
|
+
},
|
|
47
|
+
"scripts": {
|
|
48
|
+
"hardcopy": "tsx src/cli.ts",
|
|
49
|
+
"build": "tsup",
|
|
50
|
+
"dev": "tsup --watch",
|
|
51
|
+
"setup:graphqlite": "tsx scripts/install-graphqlite.ts",
|
|
52
|
+
"postinstall": "tsx scripts/install-graphqlite.ts",
|
|
53
|
+
"typecheck": "tsc --noEmit",
|
|
54
|
+
"lint": "eslint \"**/*.ts?(x)\"",
|
|
55
|
+
"lint:fix": "eslint --fix \"**/*.ts?(x)\"",
|
|
56
|
+
"format": "prettier --write .",
|
|
57
|
+
"hardcopy:hard-refresh": "pnpm hardcopy:sync && pnpm hardcopy:refresh",
|
|
58
|
+
"hardcopy:sync": "pnpm hardcopy sync",
|
|
59
|
+
"hardcopy:status": "pnpm hardcopy status",
|
|
60
|
+
"hardcopy:refresh": "pnpm hardcopy refresh 'docs/**'",
|
|
61
|
+
"hardcopy:diff": "pnpm hardcopy diff 'docs/**'",
|
|
62
|
+
"hardcopy:push": "pnpm hardcopy push 'docs/**'"
|
|
63
|
+
}
|
|
64
|
+
}
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
#!/usr/bin/env tsx
|
|
2
|
+
/**
|
|
3
|
+
* Downloads the GraphQLite SQLite extension for the current platform.
|
|
4
|
+
* Run with: pnpm setup:graphqlite
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { createWriteStream, existsSync, mkdirSync, unlinkSync } from "node:fs";
|
|
8
|
+
import { pipeline } from "node:stream/promises";
|
|
9
|
+
import { createHash } from "node:crypto";
|
|
10
|
+
import { readFile } from "node:fs/promises";
|
|
11
|
+
import { dirname, join } from "node:path";
|
|
12
|
+
import { fileURLToPath } from "node:url";
|
|
13
|
+
|
|
14
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
15
|
+
const PROJECT_ROOT = join(__dirname, "..");
|
|
16
|
+
|
|
17
|
+
const RELEASE_BASE =
|
|
18
|
+
"https://github.com/colliery-io/graphqlite/releases/latest/download";
|
|
19
|
+
|
|
20
|
+
interface PlatformAsset {
|
|
21
|
+
filename: string;
|
|
22
|
+
sha256: string;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const ASSETS: Record<string, PlatformAsset> = {
|
|
26
|
+
"darwin-arm64": {
|
|
27
|
+
filename: "graphqlite-macos-arm64.dylib",
|
|
28
|
+
sha256: "a3e50c0bb133005ee0a00f1b881e9c964caef2fcfb76854b8fb800b05eab554d",
|
|
29
|
+
},
|
|
30
|
+
"darwin-x64": {
|
|
31
|
+
filename: "graphqlite-macos-x86_64.dylib",
|
|
32
|
+
sha256: "cdb517283d6de3dcb97e248b0edcc064a17bb34c6ac9296ced65da260ff0c5ec",
|
|
33
|
+
},
|
|
34
|
+
"linux-arm64": {
|
|
35
|
+
filename: "graphqlite-linux-aarch64.so",
|
|
36
|
+
sha256: "d86a0ca3c3415f1de529a1be847389544f5c3ab9557313dc08c32c3c7cbc318c",
|
|
37
|
+
},
|
|
38
|
+
"linux-x64": {
|
|
39
|
+
filename: "graphqlite-linux-x86_64.so",
|
|
40
|
+
sha256: "113ff2efe432d6910fed41c58da73a9bf656e7fd822bfe61c166c80b109a1300",
|
|
41
|
+
},
|
|
42
|
+
"win32-x64": {
|
|
43
|
+
filename: "graphqlite-windows-x86_64.dll",
|
|
44
|
+
sha256: "55ee805e3e26cba644f1970342e150cf1ce528eb4a513ca50835c825a02a21ea",
|
|
45
|
+
},
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
function getPlatformKey(): string {
|
|
49
|
+
const platform = process.platform;
|
|
50
|
+
const arch = process.arch;
|
|
51
|
+
return `${platform}-${arch}`;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function getExtensionDir(): string {
|
|
55
|
+
return join(PROJECT_ROOT, ".hardcopy", "extensions");
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function getExtensionPath(filename: string): string {
|
|
59
|
+
return join(getExtensionDir(), filename);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
async function downloadFile(url: string, dest: string): Promise<void> {
|
|
63
|
+
const response = await fetch(url);
|
|
64
|
+
if (!response.ok) {
|
|
65
|
+
throw new Error(
|
|
66
|
+
`Failed to download ${url}: ${response.status} ${response.statusText}`,
|
|
67
|
+
);
|
|
68
|
+
}
|
|
69
|
+
if (!response.body) {
|
|
70
|
+
throw new Error(`No body in response from ${url}`);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const dir = dirname(dest);
|
|
74
|
+
if (!existsSync(dir)) {
|
|
75
|
+
mkdirSync(dir, { recursive: true });
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const fileStream = createWriteStream(dest);
|
|
79
|
+
// @ts-expect-error Node streams compatibility
|
|
80
|
+
await pipeline(response.body, fileStream);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
async function computeSha256(path: string): Promise<string> {
|
|
84
|
+
const data = await readFile(path);
|
|
85
|
+
return createHash("sha256").update(data).digest("hex");
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
async function verifyChecksum(
|
|
89
|
+
path: string,
|
|
90
|
+
expected: string,
|
|
91
|
+
): Promise<boolean> {
|
|
92
|
+
const actual = await computeSha256(path);
|
|
93
|
+
return actual === expected;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
async function main(): Promise<void> {
|
|
97
|
+
const platformKey = getPlatformKey();
|
|
98
|
+
const asset = ASSETS[platformKey];
|
|
99
|
+
|
|
100
|
+
if (!asset) {
|
|
101
|
+
console.error(`Unsupported platform: ${platformKey}`);
|
|
102
|
+
console.error(`Supported platforms: ${Object.keys(ASSETS).join(", ")}`);
|
|
103
|
+
console.error("\nYou may need to build GraphQLite from source:");
|
|
104
|
+
console.error(" https://github.com/colliery-io/graphqlite");
|
|
105
|
+
process.exit(1);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
const destPath = getExtensionPath(asset.filename);
|
|
109
|
+
|
|
110
|
+
// Check if already installed and valid
|
|
111
|
+
if (existsSync(destPath)) {
|
|
112
|
+
console.log(`Checking existing installation at ${destPath}...`);
|
|
113
|
+
const valid = await verifyChecksum(destPath, asset.sha256);
|
|
114
|
+
if (valid) {
|
|
115
|
+
console.log("GraphQLite extension already installed and verified.");
|
|
116
|
+
printEnvHint(destPath);
|
|
117
|
+
return;
|
|
118
|
+
}
|
|
119
|
+
console.log("Checksum mismatch, re-downloading...");
|
|
120
|
+
unlinkSync(destPath);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
const url = `${RELEASE_BASE}/${asset.filename}`;
|
|
124
|
+
console.log(`Downloading GraphQLite for ${platformKey}...`);
|
|
125
|
+
console.log(` URL: ${url}`);
|
|
126
|
+
console.log(` Destination: ${destPath}`);
|
|
127
|
+
|
|
128
|
+
await downloadFile(url, destPath);
|
|
129
|
+
|
|
130
|
+
// Verify checksum
|
|
131
|
+
console.log("Verifying checksum...");
|
|
132
|
+
const valid = await verifyChecksum(destPath, asset.sha256);
|
|
133
|
+
if (!valid) {
|
|
134
|
+
unlinkSync(destPath);
|
|
135
|
+
console.error("Checksum verification failed!");
|
|
136
|
+
console.error(`Expected: ${asset.sha256}`);
|
|
137
|
+
console.error(`Got: ${await computeSha256(destPath)}`);
|
|
138
|
+
process.exit(1);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
console.log("GraphQLite extension installed successfully!");
|
|
142
|
+
printEnvHint(destPath);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function printEnvHint(path: string): void {
|
|
146
|
+
console.log("\n--- Setup Complete ---");
|
|
147
|
+
console.log("The extension will be auto-discovered at runtime.");
|
|
148
|
+
console.log(`Location: ${path}`);
|
|
149
|
+
console.log("\nAlternatively, set the environment variable:");
|
|
150
|
+
console.log(` export GRAPHQLITE_EXTENSION_PATH="${path}"`);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
main().catch((err) => {
|
|
154
|
+
console.error("Installation failed:", err);
|
|
155
|
+
process.exit(1);
|
|
156
|
+
});
|
package/src/cli.ts
ADDED
|
@@ -0,0 +1,356 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
import { Command } from "commander";
|
|
4
|
+
import { createInterface } from "node:readline/promises";
|
|
5
|
+
import { stdin as input, stdout as output } from "process";
|
|
6
|
+
import { minimatch } from "minimatch";
|
|
7
|
+
import { Hardcopy, initHardcopy } from "./hardcopy";
|
|
8
|
+
import { serveMcp } from "./mcp-server";
|
|
9
|
+
|
|
10
|
+
const program = new Command();
|
|
11
|
+
|
|
12
|
+
program
|
|
13
|
+
.name("hardcopy")
|
|
14
|
+
.description("Local-remote sync system")
|
|
15
|
+
.version("0.1.0");
|
|
16
|
+
|
|
17
|
+
program
|
|
18
|
+
.command("init")
|
|
19
|
+
.description("Initialize hardcopy in current directory")
|
|
20
|
+
.action(async () => {
|
|
21
|
+
const root = process.cwd();
|
|
22
|
+
await initHardcopy(root);
|
|
23
|
+
console.log("Initialized hardcopy at", root);
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
program
|
|
27
|
+
.command("sync")
|
|
28
|
+
.description("Sync all sources")
|
|
29
|
+
.action(async () => {
|
|
30
|
+
const hc = new Hardcopy({ root: process.cwd() });
|
|
31
|
+
await hc.initialize();
|
|
32
|
+
try {
|
|
33
|
+
const stats = await hc.sync();
|
|
34
|
+
console.log(`Synced ${stats.nodes} nodes, ${stats.edges} edges`);
|
|
35
|
+
if (stats.errors.length > 0) {
|
|
36
|
+
console.error("Errors:", stats.errors.join("\n"));
|
|
37
|
+
}
|
|
38
|
+
} finally {
|
|
39
|
+
await hc.close();
|
|
40
|
+
}
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
program
|
|
44
|
+
.command("refresh <pattern>")
|
|
45
|
+
.description("Refresh views matching pattern (supports glob, e.g. docs/*)")
|
|
46
|
+
.option("--clean", "Remove files that no longer match the view", false)
|
|
47
|
+
.option("--sync-first", "Sync data from remote before refreshing", false)
|
|
48
|
+
.action(
|
|
49
|
+
async (
|
|
50
|
+
pattern: string,
|
|
51
|
+
options: { clean: boolean; syncFirst: boolean },
|
|
52
|
+
) => {
|
|
53
|
+
const hc = new Hardcopy({ root: process.cwd() });
|
|
54
|
+
await hc.initialize();
|
|
55
|
+
try {
|
|
56
|
+
const allViews = await hc.getViews();
|
|
57
|
+
const matchingViews = allViews.filter(
|
|
58
|
+
(v) => v === pattern || minimatch(v, pattern),
|
|
59
|
+
);
|
|
60
|
+
|
|
61
|
+
if (matchingViews.length === 0) {
|
|
62
|
+
console.error(`No views match pattern: ${pattern}`);
|
|
63
|
+
console.log("Available views:", allViews.join(", "));
|
|
64
|
+
process.exit(1);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
if (options.syncFirst) {
|
|
68
|
+
console.log("Syncing from remote...");
|
|
69
|
+
const stats = await hc.sync();
|
|
70
|
+
console.log(`Synced ${stats.nodes} nodes, ${stats.edges} edges`);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
for (const view of matchingViews) {
|
|
74
|
+
const result = await hc.refreshView(view, { clean: options.clean });
|
|
75
|
+
console.log(`Refreshed view: ${view} (${result.rendered} files)`);
|
|
76
|
+
|
|
77
|
+
if (result.orphaned.length > 0) {
|
|
78
|
+
if (result.cleaned) {
|
|
79
|
+
console.log(` Cleaned ${result.orphaned.length} orphaned files`);
|
|
80
|
+
} else {
|
|
81
|
+
console.log(
|
|
82
|
+
` Found ${result.orphaned.length} orphaned files. ` +
|
|
83
|
+
`Use --clean to remove them:`,
|
|
84
|
+
);
|
|
85
|
+
for (const file of result.orphaned.slice(0, 5)) {
|
|
86
|
+
console.log(` - ${file}`);
|
|
87
|
+
}
|
|
88
|
+
if (result.orphaned.length > 5) {
|
|
89
|
+
console.log(` ... and ${result.orphaned.length - 5} more`);
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
} finally {
|
|
95
|
+
await hc.close();
|
|
96
|
+
}
|
|
97
|
+
},
|
|
98
|
+
);
|
|
99
|
+
|
|
100
|
+
program
|
|
101
|
+
.command("status")
|
|
102
|
+
.description("Show sync status and changed files")
|
|
103
|
+
.option("-s, --short", "Show short status (files only)")
|
|
104
|
+
.action(async (options: { short?: boolean }) => {
|
|
105
|
+
const hc = new Hardcopy({ root: process.cwd() });
|
|
106
|
+
await hc.initialize();
|
|
107
|
+
try {
|
|
108
|
+
await hc.loadConfig();
|
|
109
|
+
const status = await hc.status();
|
|
110
|
+
|
|
111
|
+
if (options.short) {
|
|
112
|
+
// Git-like short status
|
|
113
|
+
for (const file of status.changedFiles) {
|
|
114
|
+
const marker = file.status === "new" ? "A" : "M";
|
|
115
|
+
console.log(`${marker} ${file.path}`);
|
|
116
|
+
}
|
|
117
|
+
return;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// Full status
|
|
121
|
+
if (status.changedFiles.length > 0) {
|
|
122
|
+
console.log("Changes not pushed:");
|
|
123
|
+
console.log(' (use "hardcopy push <file>" to push changes)');
|
|
124
|
+
console.log(' (use "hardcopy diff <file>" to see changes)\n');
|
|
125
|
+
for (const file of status.changedFiles) {
|
|
126
|
+
const marker = file.status === "new" ? "new file:" : "modified:";
|
|
127
|
+
console.log(` ${marker} ${file.path}`);
|
|
128
|
+
}
|
|
129
|
+
console.log();
|
|
130
|
+
} else {
|
|
131
|
+
console.log("No local changes\n");
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
if (status.conflicts.length > 0) {
|
|
135
|
+
console.log("Conflicts:");
|
|
136
|
+
console.log(' (use "hardcopy conflicts" to list details)\n');
|
|
137
|
+
for (const conflict of status.conflicts) {
|
|
138
|
+
const fields = conflict.fields.map((f) => f.field).join(", ");
|
|
139
|
+
console.log(` conflict: ${conflict.nodeId} (${fields})`);
|
|
140
|
+
}
|
|
141
|
+
console.log();
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
console.log(
|
|
145
|
+
`Synced: ${status.totalNodes} nodes, ${status.totalEdges} edges`,
|
|
146
|
+
);
|
|
147
|
+
console.log("By type:");
|
|
148
|
+
for (const [type, count] of Object.entries(status.nodesByType)) {
|
|
149
|
+
console.log(` ${type}: ${count}`);
|
|
150
|
+
}
|
|
151
|
+
} finally {
|
|
152
|
+
await hc.close();
|
|
153
|
+
}
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
program
|
|
157
|
+
.command("push [pattern]")
|
|
158
|
+
.description("Push local changes to remotes (supports glob patterns)")
|
|
159
|
+
.option("--dry-run", "Show what would be pushed without actually pushing")
|
|
160
|
+
.option("--force", "Push even if conflicts are detected", false)
|
|
161
|
+
.action(
|
|
162
|
+
async (
|
|
163
|
+
pattern?: string,
|
|
164
|
+
options?: { dryRun?: boolean; force?: boolean },
|
|
165
|
+
) => {
|
|
166
|
+
const hc = new Hardcopy({ root: process.cwd() });
|
|
167
|
+
await hc.initialize();
|
|
168
|
+
try {
|
|
169
|
+
await hc.loadConfig();
|
|
170
|
+
|
|
171
|
+
if (options?.dryRun) {
|
|
172
|
+
const diffs = await hc.diff(pattern);
|
|
173
|
+
if (diffs.length === 0) {
|
|
174
|
+
console.log("No changes to push");
|
|
175
|
+
return;
|
|
176
|
+
}
|
|
177
|
+
console.log("Would push the following changes:");
|
|
178
|
+
for (const diff of diffs) {
|
|
179
|
+
console.log(`\n${diff.nodeId} (${diff.nodeType}):`);
|
|
180
|
+
for (const change of diff.changes) {
|
|
181
|
+
console.log(` ${change.field}: ${formatChange(change)}`);
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
return;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
const stats = await hc.push(pattern, { force: options?.force });
|
|
188
|
+
console.log(
|
|
189
|
+
`Pushed ${stats.pushed} changes, skipped ${stats.skipped}, conflicts ${stats.conflicts}`,
|
|
190
|
+
);
|
|
191
|
+
if (stats.conflicts > 0) {
|
|
192
|
+
const conflicts = await hc.listConflicts();
|
|
193
|
+
const resolved = await resolveConflictsInteractive(
|
|
194
|
+
hc,
|
|
195
|
+
conflicts.map((c) => c.nodeId),
|
|
196
|
+
);
|
|
197
|
+
if (resolved.length > 0) {
|
|
198
|
+
const retry = await hc.push(pattern, { force: options?.force });
|
|
199
|
+
console.log(
|
|
200
|
+
`Retry push: pushed ${retry.pushed}, skipped ${retry.skipped}, conflicts ${retry.conflicts}`,
|
|
201
|
+
);
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
if (stats.errors.length > 0) {
|
|
205
|
+
console.error("Errors:");
|
|
206
|
+
for (const err of stats.errors) {
|
|
207
|
+
console.error(` ${err}`);
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
} finally {
|
|
211
|
+
await hc.close();
|
|
212
|
+
}
|
|
213
|
+
},
|
|
214
|
+
);
|
|
215
|
+
|
|
216
|
+
program
|
|
217
|
+
.command("conflicts")
|
|
218
|
+
.description("List unresolved conflicts")
|
|
219
|
+
.action(async () => {
|
|
220
|
+
const hc = new Hardcopy({ root: process.cwd() });
|
|
221
|
+
await hc.initialize();
|
|
222
|
+
try {
|
|
223
|
+
const conflicts = await hc.listConflicts();
|
|
224
|
+
if (conflicts.length === 0) {
|
|
225
|
+
console.log("No conflicts");
|
|
226
|
+
return;
|
|
227
|
+
}
|
|
228
|
+
for (const conflict of conflicts) {
|
|
229
|
+
const fields = conflict.fields.map((f) => f.field).join(", ");
|
|
230
|
+
console.log(`${conflict.nodeId} (${fields})`);
|
|
231
|
+
}
|
|
232
|
+
} finally {
|
|
233
|
+
await hc.close();
|
|
234
|
+
}
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
program
|
|
238
|
+
.command("resolve <nodeId>")
|
|
239
|
+
.description("Resolve conflicts interactively")
|
|
240
|
+
.action(async (nodeId: string) => {
|
|
241
|
+
const hc = new Hardcopy({ root: process.cwd() });
|
|
242
|
+
await hc.initialize();
|
|
243
|
+
try {
|
|
244
|
+
await resolveConflictsInteractive(hc, [nodeId]);
|
|
245
|
+
} finally {
|
|
246
|
+
await hc.close();
|
|
247
|
+
}
|
|
248
|
+
});
|
|
249
|
+
|
|
250
|
+
async function resolveConflictsInteractive(
|
|
251
|
+
hc: Hardcopy,
|
|
252
|
+
nodeIds: string[],
|
|
253
|
+
): Promise<string[]> {
|
|
254
|
+
const rl = createInterface({ input, output });
|
|
255
|
+
const resolved: string[] = [];
|
|
256
|
+
try {
|
|
257
|
+
for (const nodeId of nodeIds) {
|
|
258
|
+
const detail = await hc.getConflictDetail(nodeId);
|
|
259
|
+
if (!detail) continue;
|
|
260
|
+
const conflict = detail.info;
|
|
261
|
+
|
|
262
|
+
console.log(`\nConflict: ${nodeId}`);
|
|
263
|
+
console.log(`Artifact: ${detail.artifactPath}`);
|
|
264
|
+
if (detail.body.trim()) {
|
|
265
|
+
console.log(detail.body.trim());
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
const resolution: Record<string, "local" | "remote"> = {};
|
|
269
|
+
|
|
270
|
+
for (const field of conflict.fields) {
|
|
271
|
+
if (field.status !== "diverged") continue;
|
|
272
|
+
let answer = "";
|
|
273
|
+
while (!answer) {
|
|
274
|
+
const response = await rl.question(
|
|
275
|
+
`Resolve ${field.field} for ${nodeId} (l=local, r=remote, s=skip): `,
|
|
276
|
+
);
|
|
277
|
+
const normalized = response.trim().toLowerCase();
|
|
278
|
+
if (normalized === "l" || normalized === "local") {
|
|
279
|
+
resolution[field.field] = "local";
|
|
280
|
+
answer = "local";
|
|
281
|
+
} else if (normalized === "r" || normalized === "remote") {
|
|
282
|
+
resolution[field.field] = "remote";
|
|
283
|
+
answer = "remote";
|
|
284
|
+
} else if (normalized === "s" || normalized === "skip") {
|
|
285
|
+
answer = "skip";
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
if (Object.keys(resolution).length === 0) {
|
|
291
|
+
console.log(`No fields resolved for ${nodeId}`);
|
|
292
|
+
continue;
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
await hc.resolveConflict(nodeId, resolution);
|
|
296
|
+
console.log(`Resolved conflict: ${nodeId}`);
|
|
297
|
+
resolved.push(nodeId);
|
|
298
|
+
}
|
|
299
|
+
} finally {
|
|
300
|
+
rl.close();
|
|
301
|
+
}
|
|
302
|
+
return resolved;
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
program
|
|
306
|
+
.command("diff [pattern]")
|
|
307
|
+
.description("Show local changes vs synced state (supports glob patterns)")
|
|
308
|
+
.option("--all", "Check all files, not just recently modified")
|
|
309
|
+
.action(async (pattern?: string, options?: { all?: boolean }) => {
|
|
310
|
+
const hc = new Hardcopy({ root: process.cwd() });
|
|
311
|
+
await hc.initialize();
|
|
312
|
+
try {
|
|
313
|
+
await hc.loadConfig();
|
|
314
|
+
const diffs = await hc.diff(pattern, { smart: !options?.all });
|
|
315
|
+
if (diffs.length === 0) {
|
|
316
|
+
console.log("No changes detected");
|
|
317
|
+
return;
|
|
318
|
+
}
|
|
319
|
+
for (const diff of diffs) {
|
|
320
|
+
console.log(`\n${diff.nodeId} (${diff.nodeType}):`);
|
|
321
|
+
console.log(` File: ${diff.filePath}`);
|
|
322
|
+
for (const change of diff.changes) {
|
|
323
|
+
console.log(` ${change.field}: ${formatChange(change)}`);
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
} finally {
|
|
327
|
+
await hc.close();
|
|
328
|
+
}
|
|
329
|
+
});
|
|
330
|
+
|
|
331
|
+
function formatChange(change: {
|
|
332
|
+
field: string;
|
|
333
|
+
oldValue: unknown;
|
|
334
|
+
newValue: unknown;
|
|
335
|
+
}): string {
|
|
336
|
+
const old =
|
|
337
|
+
typeof change.oldValue === "string"
|
|
338
|
+
? change.oldValue.slice(0, 50) +
|
|
339
|
+
(change.oldValue.length > 50 ? "..." : "")
|
|
340
|
+
: JSON.stringify(change.oldValue);
|
|
341
|
+
const newVal =
|
|
342
|
+
typeof change.newValue === "string"
|
|
343
|
+
? change.newValue.slice(0, 50) +
|
|
344
|
+
(change.newValue.length > 50 ? "..." : "")
|
|
345
|
+
: JSON.stringify(change.newValue);
|
|
346
|
+
return `${old} → ${newVal}`;
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
program
|
|
350
|
+
.command("mcp-serve")
|
|
351
|
+
.description("Start MCP server for LLM tool integration")
|
|
352
|
+
.action(async () => {
|
|
353
|
+
await serveMcp(process.cwd());
|
|
354
|
+
});
|
|
355
|
+
|
|
356
|
+
program.parse();
|
package/src/config.ts
ADDED
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import { readFile } from "fs/promises";
|
|
2
|
+
import yaml from "yaml";
|
|
3
|
+
|
|
4
|
+
export interface LinkConfig {
|
|
5
|
+
edge: string;
|
|
6
|
+
to: string;
|
|
7
|
+
match: string;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export interface SourceConfig {
|
|
11
|
+
name: string;
|
|
12
|
+
provider: string;
|
|
13
|
+
orgs?: string[];
|
|
14
|
+
repositories?: { path: string }[];
|
|
15
|
+
links?: LinkConfig[];
|
|
16
|
+
[key: string]: unknown;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export interface RenderConfig {
|
|
20
|
+
path: string;
|
|
21
|
+
type?: string;
|
|
22
|
+
template?: string;
|
|
23
|
+
args?: Record<string, unknown>;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export interface ViewConfig {
|
|
27
|
+
path: string;
|
|
28
|
+
description?: string;
|
|
29
|
+
query: string;
|
|
30
|
+
partition?: {
|
|
31
|
+
by: string;
|
|
32
|
+
fallback?: string;
|
|
33
|
+
};
|
|
34
|
+
render: RenderConfig[];
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export interface Config {
|
|
38
|
+
sources: SourceConfig[];
|
|
39
|
+
views: ViewConfig[];
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export async function loadConfig(path: string): Promise<Config> {
|
|
43
|
+
const content = await readFile(path, "utf-8");
|
|
44
|
+
return parseConfig(content);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function parseConfig(content: string): Config {
|
|
48
|
+
const parsed = yaml.parse(content);
|
|
49
|
+
return validateConfig(parsed);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function validateConfig(data: unknown): Config {
|
|
53
|
+
if (!data || typeof data !== "object") {
|
|
54
|
+
throw new Error("Config must be an object");
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const config = data as Record<string, unknown>;
|
|
58
|
+
|
|
59
|
+
return {
|
|
60
|
+
sources: validateSources(config["sources"]),
|
|
61
|
+
views: validateViews(config["views"]),
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function validateSources(data: unknown): SourceConfig[] {
|
|
66
|
+
if (!Array.isArray(data)) {
|
|
67
|
+
return [];
|
|
68
|
+
}
|
|
69
|
+
return data.map((s, i) => {
|
|
70
|
+
if (!s || typeof s !== "object") {
|
|
71
|
+
throw new Error(`Source ${i} must be an object`);
|
|
72
|
+
}
|
|
73
|
+
const source = s as Record<string, unknown>;
|
|
74
|
+
if (typeof source["name"] !== "string") {
|
|
75
|
+
throw new Error(`Source ${i} must have a name`);
|
|
76
|
+
}
|
|
77
|
+
if (typeof source["provider"] !== "string") {
|
|
78
|
+
throw new Error(`Source ${i} must have a provider`);
|
|
79
|
+
}
|
|
80
|
+
return source as unknown as SourceConfig;
|
|
81
|
+
});
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function validateViews(data: unknown): ViewConfig[] {
|
|
85
|
+
if (!Array.isArray(data)) {
|
|
86
|
+
return [];
|
|
87
|
+
}
|
|
88
|
+
return data.map((v, i) => {
|
|
89
|
+
if (!v || typeof v !== "object") {
|
|
90
|
+
throw new Error(`View ${i} must be an object`);
|
|
91
|
+
}
|
|
92
|
+
const view = v as Record<string, unknown>;
|
|
93
|
+
if (typeof view["path"] !== "string") {
|
|
94
|
+
throw new Error(`View ${i} must have a path`);
|
|
95
|
+
}
|
|
96
|
+
if (typeof view["query"] !== "string") {
|
|
97
|
+
throw new Error(`View ${i} must have a query`);
|
|
98
|
+
}
|
|
99
|
+
if (!Array.isArray(view["render"])) {
|
|
100
|
+
throw new Error(`View ${i} must have render configs`);
|
|
101
|
+
}
|
|
102
|
+
return view as unknown as ViewConfig;
|
|
103
|
+
});
|
|
104
|
+
}
|