hatcher-connect 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/README.md +52 -0
- package/dist/index.js +824 -0
- package/package.json +49 -0
package/README.md
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
# Hatcher Workspace Connector
|
|
2
|
+
|
|
3
|
+
Standalone connector that gives one Hatcher agent controlled access to one
|
|
4
|
+
dedicated folder on the user's computer. It does not depend on the retired
|
|
5
|
+
Hatcher CLI.
|
|
6
|
+
|
|
7
|
+
## Install
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
npm install --global hatcher-connect
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
This installs the `hatcher-connect` command.
|
|
14
|
+
|
|
15
|
+
## Connect a folder
|
|
16
|
+
|
|
17
|
+
```bash
|
|
18
|
+
hatcher-connect login
|
|
19
|
+
hatcher-connect agents
|
|
20
|
+
hatcher-connect connect --agent <agent-id> --folder ./agent-workspace
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
Use `--read-only` to allow listing, searching, and reading without file
|
|
24
|
+
changes. The connector remains in the foreground; Ctrl+C disconnects the
|
|
25
|
+
folder immediately.
|
|
26
|
+
|
|
27
|
+
`HATCHER_API_KEY` and `HATCHER_API_URL` can replace saved credentials in
|
|
28
|
+
automated environments. The API key is sent in an Authorization header and is
|
|
29
|
+
never placed in the WebSocket URL.
|
|
30
|
+
|
|
31
|
+
## File safety
|
|
32
|
+
|
|
33
|
+
- Every path is relative to the selected canonical folder.
|
|
34
|
+
- Absolute paths, traversal, root `.hatcher` access, and symbolic links are
|
|
35
|
+
rejected.
|
|
36
|
+
- Files larger than 512 KiB are not read; writes are limited to 45 KiB.
|
|
37
|
+
- Replacing a file requires the SHA-256 revision returned by the last read.
|
|
38
|
+
- Previous contents are stored in `.hatcher/history`.
|
|
39
|
+
- Deletes are moved to `.hatcher/trash` and remain recoverable.
|
|
40
|
+
- The filesystem root and the user's home directory cannot be shared.
|
|
41
|
+
|
|
42
|
+
Only file content requested by the agent is sent through Hatcher for model
|
|
43
|
+
processing.
|
|
44
|
+
|
|
45
|
+
## Development
|
|
46
|
+
|
|
47
|
+
```bash
|
|
48
|
+
npm install
|
|
49
|
+
npm run build --workspace=hatcher-connect
|
|
50
|
+
npm test --workspace=hatcher-connect
|
|
51
|
+
node apps/workspace-connector/dist/index.js --help
|
|
52
|
+
```
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,824 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
"use strict";
|
|
3
|
+
var __create = Object.create;
|
|
4
|
+
var __defProp = Object.defineProperty;
|
|
5
|
+
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
|
|
6
|
+
var __getOwnPropNames = Object.getOwnPropertyNames;
|
|
7
|
+
var __getProtoOf = Object.getPrototypeOf;
|
|
8
|
+
var __hasOwnProp = Object.prototype.hasOwnProperty;
|
|
9
|
+
var __copyProps = (to, from, except, desc) => {
|
|
10
|
+
if (from && typeof from === "object" || typeof from === "function") {
|
|
11
|
+
for (let key of __getOwnPropNames(from))
|
|
12
|
+
if (!__hasOwnProp.call(to, key) && key !== except)
|
|
13
|
+
__defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
|
|
14
|
+
}
|
|
15
|
+
return to;
|
|
16
|
+
};
|
|
17
|
+
var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps(
|
|
18
|
+
// If the importer is in node compatibility mode or this is not an ESM
|
|
19
|
+
// file that has been converted to a CommonJS file using a Babel-
|
|
20
|
+
// compatible transform (i.e. "__esModule" has not been set), then set
|
|
21
|
+
// "default" to the CommonJS "module.exports" for node compatibility.
|
|
22
|
+
isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", { value: mod, enumerable: true }) : target,
|
|
23
|
+
mod
|
|
24
|
+
));
|
|
25
|
+
|
|
26
|
+
// src/index.ts
|
|
27
|
+
var import_commander = require("commander");
|
|
28
|
+
|
|
29
|
+
// src/api.ts
|
|
30
|
+
var ApiError = class extends Error {
|
|
31
|
+
constructor(status, message, code) {
|
|
32
|
+
super(message);
|
|
33
|
+
this.status = status;
|
|
34
|
+
this.code = code;
|
|
35
|
+
this.name = "ApiError";
|
|
36
|
+
}
|
|
37
|
+
status;
|
|
38
|
+
code;
|
|
39
|
+
};
|
|
40
|
+
var ApiClient = class {
|
|
41
|
+
constructor(config) {
|
|
42
|
+
this.config = config;
|
|
43
|
+
this.baseUrl = config.baseUrl.replace(/\/+$/, "");
|
|
44
|
+
}
|
|
45
|
+
config;
|
|
46
|
+
baseUrl;
|
|
47
|
+
async request(path) {
|
|
48
|
+
const response = await fetch(`${this.baseUrl}/api/v1${path}`, {
|
|
49
|
+
headers: {
|
|
50
|
+
Authorization: `Bearer ${this.config.apiKey}`,
|
|
51
|
+
Accept: "application/json"
|
|
52
|
+
}
|
|
53
|
+
});
|
|
54
|
+
let body;
|
|
55
|
+
try {
|
|
56
|
+
body = await response.json();
|
|
57
|
+
} catch {
|
|
58
|
+
throw new ApiError(
|
|
59
|
+
response.status,
|
|
60
|
+
`Unexpected response from Hatcher (HTTP ${response.status})`
|
|
61
|
+
);
|
|
62
|
+
}
|
|
63
|
+
if (!response.ok || !body.success) {
|
|
64
|
+
const failure = body;
|
|
65
|
+
throw new ApiError(response.status, failure.error || `HTTP ${response.status}`, failure.code);
|
|
66
|
+
}
|
|
67
|
+
return body.data;
|
|
68
|
+
}
|
|
69
|
+
getAccount() {
|
|
70
|
+
return this.request("/me");
|
|
71
|
+
}
|
|
72
|
+
getAgent(id) {
|
|
73
|
+
return this.request(`/agents/${encodeURIComponent(id)}`);
|
|
74
|
+
}
|
|
75
|
+
async listAgents() {
|
|
76
|
+
const response = await this.request("/agents?limit=100");
|
|
77
|
+
return response.agents;
|
|
78
|
+
}
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
// src/config.ts
|
|
82
|
+
var import_node_fs = require("fs");
|
|
83
|
+
var import_node_os = require("os");
|
|
84
|
+
var import_node_path = require("path");
|
|
85
|
+
var DEFAULT_BASE_URL = "https://api.hatcher.host";
|
|
86
|
+
var CONFIG_DIRECTORY = (0, import_node_path.join)((0, import_node_os.homedir)(), ".hatcher-connect");
|
|
87
|
+
var CONFIG_FILE = (0, import_node_path.join)(CONFIG_DIRECTORY, "config.json");
|
|
88
|
+
function normalizeBaseUrl(value) {
|
|
89
|
+
const url = new URL(value?.trim() || DEFAULT_BASE_URL);
|
|
90
|
+
if (url.protocol !== "https:" && url.protocol !== "http:") {
|
|
91
|
+
throw new Error("Hatcher API URL must use http or https");
|
|
92
|
+
}
|
|
93
|
+
url.pathname = url.pathname.replace(/\/+$/, "");
|
|
94
|
+
url.search = "";
|
|
95
|
+
url.hash = "";
|
|
96
|
+
return url.toString().replace(/\/$/, "");
|
|
97
|
+
}
|
|
98
|
+
function loadStoredConfig() {
|
|
99
|
+
if (!(0, import_node_fs.existsSync)(CONFIG_FILE)) return null;
|
|
100
|
+
try {
|
|
101
|
+
const parsed = JSON.parse((0, import_node_fs.readFileSync)(CONFIG_FILE, "utf8"));
|
|
102
|
+
if (!parsed.apiKey?.startsWith("hk_")) return null;
|
|
103
|
+
return { apiKey: parsed.apiKey, baseUrl: normalizeBaseUrl(parsed.baseUrl) };
|
|
104
|
+
} catch {
|
|
105
|
+
return null;
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
function loadConfig() {
|
|
109
|
+
const stored = loadStoredConfig();
|
|
110
|
+
const environmentKey = process.env.HATCHER_API_KEY?.trim();
|
|
111
|
+
if (environmentKey) {
|
|
112
|
+
return {
|
|
113
|
+
apiKey: environmentKey,
|
|
114
|
+
baseUrl: normalizeBaseUrl(process.env.HATCHER_API_URL ?? stored?.baseUrl)
|
|
115
|
+
};
|
|
116
|
+
}
|
|
117
|
+
return stored;
|
|
118
|
+
}
|
|
119
|
+
function saveConfig(config) {
|
|
120
|
+
(0, import_node_fs.mkdirSync)(CONFIG_DIRECTORY, { recursive: true, mode: 448 });
|
|
121
|
+
(0, import_node_fs.writeFileSync)(
|
|
122
|
+
CONFIG_FILE,
|
|
123
|
+
`${JSON.stringify({ ...config, baseUrl: normalizeBaseUrl(config.baseUrl) }, null, 2)}
|
|
124
|
+
`,
|
|
125
|
+
{ mode: 384 }
|
|
126
|
+
);
|
|
127
|
+
}
|
|
128
|
+
function removeConfig() {
|
|
129
|
+
if (!(0, import_node_fs.existsSync)(CONFIG_FILE)) return false;
|
|
130
|
+
(0, import_node_fs.unlinkSync)(CONFIG_FILE);
|
|
131
|
+
return true;
|
|
132
|
+
}
|
|
133
|
+
function requireConfig() {
|
|
134
|
+
const config = loadConfig();
|
|
135
|
+
if (!config) {
|
|
136
|
+
throw new Error("Not connected to Hatcher. Run `hatcher-connect login` first.");
|
|
137
|
+
}
|
|
138
|
+
if (!config.apiKey.startsWith("hk_")) {
|
|
139
|
+
throw new Error("HATCHER_API_KEY must start with hk_");
|
|
140
|
+
}
|
|
141
|
+
return config;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// src/commands/agents.ts
|
|
145
|
+
function pad(value, width) {
|
|
146
|
+
return value.length >= width ? value.slice(0, width) : value.padEnd(width);
|
|
147
|
+
}
|
|
148
|
+
function registerAgentCommands(program2) {
|
|
149
|
+
program2.command("agents").description("List agents available to connect").action(async () => {
|
|
150
|
+
const agents = await new ApiClient(requireConfig()).listAgents();
|
|
151
|
+
if (agents.length === 0) {
|
|
152
|
+
console.log("No agents found in this Hatcher account.");
|
|
153
|
+
return;
|
|
154
|
+
}
|
|
155
|
+
console.log(`${pad("NAME", 25)} ${pad("FRAMEWORK", 12)} ${pad("STATUS", 10)} ID`);
|
|
156
|
+
for (const agent of agents) {
|
|
157
|
+
console.log(
|
|
158
|
+
`${pad(agent.name, 25)} ${pad(agent.framework, 12)} ${pad(agent.status, 10)} ${agent.id}`
|
|
159
|
+
);
|
|
160
|
+
}
|
|
161
|
+
});
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// src/commands/auth.ts
|
|
165
|
+
var import_promises = require("readline/promises");
|
|
166
|
+
var import_node_process = require("process");
|
|
167
|
+
async function promptHidden(question) {
|
|
168
|
+
if (!import_node_process.stdin.isTTY || !import_node_process.stdout.isTTY || typeof import_node_process.stdin.setRawMode !== "function") {
|
|
169
|
+
const readline = (0, import_promises.createInterface)({ input: import_node_process.stdin, output: import_node_process.stdout });
|
|
170
|
+
try {
|
|
171
|
+
return (await readline.question(question)).trim();
|
|
172
|
+
} finally {
|
|
173
|
+
readline.close();
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
import_node_process.stdout.write(question);
|
|
177
|
+
const previousRawMode = import_node_process.stdin.isRaw;
|
|
178
|
+
import_node_process.stdin.setRawMode(true);
|
|
179
|
+
import_node_process.stdin.resume();
|
|
180
|
+
import_node_process.stdin.setEncoding("utf8");
|
|
181
|
+
return new Promise((resolve2, reject) => {
|
|
182
|
+
let value = "";
|
|
183
|
+
const cleanup = () => {
|
|
184
|
+
import_node_process.stdin.removeListener("data", onData);
|
|
185
|
+
import_node_process.stdin.setRawMode(previousRawMode);
|
|
186
|
+
import_node_process.stdin.pause();
|
|
187
|
+
};
|
|
188
|
+
const onData = (chunk) => {
|
|
189
|
+
for (const character of chunk) {
|
|
190
|
+
if (character === "") {
|
|
191
|
+
cleanup();
|
|
192
|
+
import_node_process.stdout.write("\n");
|
|
193
|
+
reject(new Error("Login cancelled"));
|
|
194
|
+
return;
|
|
195
|
+
}
|
|
196
|
+
if (character === "\r" || character === "\n") {
|
|
197
|
+
cleanup();
|
|
198
|
+
import_node_process.stdout.write("\n");
|
|
199
|
+
resolve2(value.trim());
|
|
200
|
+
return;
|
|
201
|
+
}
|
|
202
|
+
if (character === "\x7F" || character === "\b") {
|
|
203
|
+
if (value.length > 0) {
|
|
204
|
+
value = value.slice(0, -1);
|
|
205
|
+
import_node_process.stdout.write("\b \b");
|
|
206
|
+
}
|
|
207
|
+
continue;
|
|
208
|
+
}
|
|
209
|
+
if (character >= " ") {
|
|
210
|
+
value += character;
|
|
211
|
+
import_node_process.stdout.write("*");
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
};
|
|
215
|
+
import_node_process.stdin.on("data", onData);
|
|
216
|
+
});
|
|
217
|
+
}
|
|
218
|
+
function registerAuthCommands(program2) {
|
|
219
|
+
program2.command("login").description("Authenticate the workspace connector with a Hatcher API key").option("--key <apiKey>", "Hatcher API key (or use HATCHER_API_KEY)").option("--url <baseUrl>", "Hatcher API URL", process.env.HATCHER_API_URL ?? DEFAULT_BASE_URL).action(async (options) => {
|
|
220
|
+
const apiKey = options.key?.trim() || process.env.HATCHER_API_KEY?.trim() || await promptHidden("Hatcher API key: ");
|
|
221
|
+
if (!apiKey.startsWith("hk_")) throw new Error("Hatcher API keys must start with hk_");
|
|
222
|
+
const config = { apiKey, baseUrl: options.url };
|
|
223
|
+
const account = await new ApiClient(config).getAccount();
|
|
224
|
+
saveConfig(config);
|
|
225
|
+
console.log(`Authenticated Hatcher account ${account.id}.`);
|
|
226
|
+
console.log(`Credentials saved to ${CONFIG_FILE}.`);
|
|
227
|
+
});
|
|
228
|
+
program2.command("logout").description("Remove the workspace connector credentials").action(
|
|
229
|
+
() => console.log(removeConfig() ? "Workspace connector logged out." : "No saved credentials.")
|
|
230
|
+
);
|
|
231
|
+
program2.command("whoami").description("Verify the current workspace connector credentials").action(async () => {
|
|
232
|
+
const config = loadConfig();
|
|
233
|
+
if (!config) throw new Error("Not connected to Hatcher. Run `hatcher-connect login` first.");
|
|
234
|
+
const account = await new ApiClient(config).getAccount();
|
|
235
|
+
console.log(`Account: ${account.id}`);
|
|
236
|
+
console.log(`API: ${config.baseUrl}`);
|
|
237
|
+
});
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
// src/commands/connect.ts
|
|
241
|
+
var import_node_path3 = require("path");
|
|
242
|
+
var import_promises3 = require("timers/promises");
|
|
243
|
+
var import_ws = __toESM(require("ws"));
|
|
244
|
+
|
|
245
|
+
// src/local-workspace.ts
|
|
246
|
+
var import_node_crypto = require("crypto");
|
|
247
|
+
var import_promises2 = require("fs/promises");
|
|
248
|
+
var import_node_os2 = require("os");
|
|
249
|
+
var import_node_path2 = require("path");
|
|
250
|
+
var META_DIRECTORY = ".hatcher";
|
|
251
|
+
var MAX_LIST_ENTRIES = 1e3;
|
|
252
|
+
var MAX_SEARCH_FILES = 1e3;
|
|
253
|
+
var MAX_SEARCH_RESULTS = 100;
|
|
254
|
+
var MAX_TEXT_FILE_BYTES = 512 * 1024;
|
|
255
|
+
var MAX_WRITE_BYTES = 45 * 1024;
|
|
256
|
+
var WorkspaceError = class extends Error {
|
|
257
|
+
constructor(message, code = "VALIDATION_ERROR") {
|
|
258
|
+
super(message);
|
|
259
|
+
this.code = code;
|
|
260
|
+
this.name = "WorkspaceError";
|
|
261
|
+
}
|
|
262
|
+
code;
|
|
263
|
+
};
|
|
264
|
+
function asRecord(value) {
|
|
265
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
|
266
|
+
throw new WorkspaceError("Tool arguments must be an object");
|
|
267
|
+
}
|
|
268
|
+
return value;
|
|
269
|
+
}
|
|
270
|
+
function requiredString(input, name, max = 4096) {
|
|
271
|
+
const value = input[name];
|
|
272
|
+
if (typeof value !== "string" || !value.trim()) {
|
|
273
|
+
throw new WorkspaceError(`${name} must be a non-empty string`);
|
|
274
|
+
}
|
|
275
|
+
if (value.length > max) throw new WorkspaceError(`${name} is too long`);
|
|
276
|
+
return value;
|
|
277
|
+
}
|
|
278
|
+
function requiredContent(input, name, max) {
|
|
279
|
+
const value = input[name];
|
|
280
|
+
if (typeof value !== "string") throw new WorkspaceError(`${name} must be a string`);
|
|
281
|
+
if (value.length > max) throw new WorkspaceError(`${name} is too long`);
|
|
282
|
+
return value;
|
|
283
|
+
}
|
|
284
|
+
function optionalString(input, name) {
|
|
285
|
+
const value = input[name];
|
|
286
|
+
if (value === void 0) return void 0;
|
|
287
|
+
if (typeof value !== "string") throw new WorkspaceError(`${name} must be a string`);
|
|
288
|
+
return value;
|
|
289
|
+
}
|
|
290
|
+
function normalizeRelativePath(raw, allowRoot) {
|
|
291
|
+
const value = (raw ?? ".").trim().replaceAll("\\", "/");
|
|
292
|
+
if (value.includes("\0")) throw new WorkspaceError("Paths must not contain null bytes");
|
|
293
|
+
if ((0, import_node_path2.isAbsolute)(value) || import_node_path2.win32.isAbsolute(value)) {
|
|
294
|
+
throw new WorkspaceError(
|
|
295
|
+
"Only paths relative to the connected workspace are allowed",
|
|
296
|
+
"FORBIDDEN"
|
|
297
|
+
);
|
|
298
|
+
}
|
|
299
|
+
const normalized = import_node_path2.posix.normalize(value || ".");
|
|
300
|
+
if (normalized === ".." || normalized.startsWith("../")) {
|
|
301
|
+
throw new WorkspaceError("Path traversal outside the workspace is not allowed", "FORBIDDEN");
|
|
302
|
+
}
|
|
303
|
+
if (!allowRoot && normalized === ".")
|
|
304
|
+
throw new WorkspaceError("A file or directory path is required");
|
|
305
|
+
if (normalized === META_DIRECTORY || normalized.startsWith(`${META_DIRECTORY}/`)) {
|
|
306
|
+
throw new WorkspaceError("The .hatcher metadata directory is reserved", "FORBIDDEN");
|
|
307
|
+
}
|
|
308
|
+
return normalized;
|
|
309
|
+
}
|
|
310
|
+
function isWithinRoot(root, candidate) {
|
|
311
|
+
const rel = (0, import_node_path2.relative)(root, candidate);
|
|
312
|
+
return rel === "" || !rel.startsWith(`..${import_node_path2.sep}`) && rel !== ".." && !(0, import_node_path2.isAbsolute)(rel);
|
|
313
|
+
}
|
|
314
|
+
async function pathExists(path) {
|
|
315
|
+
try {
|
|
316
|
+
await (0, import_promises2.lstat)(path);
|
|
317
|
+
return true;
|
|
318
|
+
} catch (error) {
|
|
319
|
+
if (error.code === "ENOENT") return false;
|
|
320
|
+
throw error;
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
async function assertNoSymlink(root, relativePath, allowMissingLeaf) {
|
|
324
|
+
if (relativePath === ".") return;
|
|
325
|
+
const segments = relativePath.split("/");
|
|
326
|
+
let current = root;
|
|
327
|
+
for (let index = 0; index < segments.length; index += 1) {
|
|
328
|
+
current = (0, import_node_path2.join)(current, segments[index]);
|
|
329
|
+
try {
|
|
330
|
+
const info = await (0, import_promises2.lstat)(current);
|
|
331
|
+
if (info.isSymbolicLink()) {
|
|
332
|
+
throw new WorkspaceError(
|
|
333
|
+
"Symbolic links are not accessible through a local workspace",
|
|
334
|
+
"FORBIDDEN"
|
|
335
|
+
);
|
|
336
|
+
}
|
|
337
|
+
} catch (error) {
|
|
338
|
+
if (error.code === "ENOENT" && allowMissingLeaf) return;
|
|
339
|
+
throw error;
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
async function resolveWorkspacePath(root, raw, options = {}) {
|
|
344
|
+
const normalized = normalizeRelativePath(raw, options.allowRoot ?? false);
|
|
345
|
+
const absolute = (0, import_node_path2.resolve)(root, ...normalized.split("/"));
|
|
346
|
+
if (!isWithinRoot(root, absolute)) {
|
|
347
|
+
throw new WorkspaceError("Path escapes the connected workspace", "FORBIDDEN");
|
|
348
|
+
}
|
|
349
|
+
await assertNoSymlink(root, normalized, options.allowMissing ?? false);
|
|
350
|
+
if (!options.allowMissing && !await pathExists(absolute)) {
|
|
351
|
+
throw new WorkspaceError(`Path not found: ${normalized}`, "NOT_FOUND");
|
|
352
|
+
}
|
|
353
|
+
return { absolute, relative: normalized };
|
|
354
|
+
}
|
|
355
|
+
function portablePath(root, absolute) {
|
|
356
|
+
const value = (0, import_node_path2.relative)(root, absolute).split(import_node_path2.sep).join("/");
|
|
357
|
+
return value || ".";
|
|
358
|
+
}
|
|
359
|
+
function sha256(content) {
|
|
360
|
+
return (0, import_node_crypto.createHash)("sha256").update(content).digest("hex");
|
|
361
|
+
}
|
|
362
|
+
function timestampDirectory() {
|
|
363
|
+
return (/* @__PURE__ */ new Date()).toISOString().replace(/[:.]/g, "-");
|
|
364
|
+
}
|
|
365
|
+
function isProbablyBinary(content) {
|
|
366
|
+
return content.subarray(0, Math.min(content.length, 8192)).includes(0);
|
|
367
|
+
}
|
|
368
|
+
async function listWorkspace(root, args) {
|
|
369
|
+
const requested = optionalString(args, "path");
|
|
370
|
+
const recursive = args.recursive === true;
|
|
371
|
+
const target = await resolveWorkspacePath(root, requested, { allowRoot: true });
|
|
372
|
+
const targetInfo = await (0, import_promises2.stat)(target.absolute);
|
|
373
|
+
if (!targetInfo.isDirectory()) throw new WorkspaceError(`${target.relative} is not a directory`);
|
|
374
|
+
const entries = [];
|
|
375
|
+
const queue = [target.absolute];
|
|
376
|
+
while (queue.length > 0 && entries.length < MAX_LIST_ENTRIES) {
|
|
377
|
+
const current = queue.shift();
|
|
378
|
+
const children = await import("fs/promises").then(
|
|
379
|
+
({ readdir }) => readdir(current, { withFileTypes: true })
|
|
380
|
+
);
|
|
381
|
+
children.sort((left, right) => left.name.localeCompare(right.name));
|
|
382
|
+
for (const child of children) {
|
|
383
|
+
if (current === root && child.name === META_DIRECTORY) continue;
|
|
384
|
+
const absolute = (0, import_node_path2.join)(current, child.name);
|
|
385
|
+
const info = await (0, import_promises2.lstat)(absolute);
|
|
386
|
+
const type = info.isSymbolicLink() ? "symlink" : info.isDirectory() ? "directory" : "file";
|
|
387
|
+
entries.push({
|
|
388
|
+
path: portablePath(root, absolute),
|
|
389
|
+
type,
|
|
390
|
+
size: info.isFile() ? info.size : 0,
|
|
391
|
+
modifiedAt: info.mtime.toISOString()
|
|
392
|
+
});
|
|
393
|
+
if (entries.length >= MAX_LIST_ENTRIES) break;
|
|
394
|
+
if (recursive && type === "directory") queue.push(absolute);
|
|
395
|
+
}
|
|
396
|
+
}
|
|
397
|
+
return { path: target.relative, entries, truncated: entries.length >= MAX_LIST_ENTRIES };
|
|
398
|
+
}
|
|
399
|
+
async function readWorkspaceFile(root, args) {
|
|
400
|
+
const requested = requiredString(args, "path");
|
|
401
|
+
const target = await resolveWorkspacePath(root, requested);
|
|
402
|
+
const info = await (0, import_promises2.stat)(target.absolute);
|
|
403
|
+
if (!info.isFile()) throw new WorkspaceError(`${target.relative} is not a file`);
|
|
404
|
+
if (info.size > MAX_TEXT_FILE_BYTES) {
|
|
405
|
+
throw new WorkspaceError(`File is larger than ${MAX_TEXT_FILE_BYTES} bytes`);
|
|
406
|
+
}
|
|
407
|
+
const content = await (0, import_promises2.readFile)(target.absolute);
|
|
408
|
+
if (isProbablyBinary(content)) throw new WorkspaceError("Binary files are not supported");
|
|
409
|
+
return {
|
|
410
|
+
path: target.relative,
|
|
411
|
+
content: content.toString("utf8"),
|
|
412
|
+
sha256: sha256(content),
|
|
413
|
+
size: content.length,
|
|
414
|
+
modifiedAt: info.mtime.toISOString()
|
|
415
|
+
};
|
|
416
|
+
}
|
|
417
|
+
async function writeWorkspaceFile(root, args) {
|
|
418
|
+
const requested = requiredString(args, "path");
|
|
419
|
+
const content = requiredContent(args, "content", MAX_WRITE_BYTES);
|
|
420
|
+
const expectedSha256 = optionalString(args, "expectedSha256");
|
|
421
|
+
const target = await resolveWorkspacePath(root, requested, { allowMissing: true });
|
|
422
|
+
const parent = await resolveWorkspacePath(root, portablePath(root, (0, import_node_path2.dirname)(target.absolute)), {
|
|
423
|
+
allowRoot: true
|
|
424
|
+
});
|
|
425
|
+
const parentInfo = await (0, import_promises2.stat)(parent.absolute).catch(() => null);
|
|
426
|
+
if (!parentInfo?.isDirectory())
|
|
427
|
+
throw new WorkspaceError("Parent directory does not exist", "NOT_FOUND");
|
|
428
|
+
const exists = await pathExists(target.absolute);
|
|
429
|
+
let previousSha256 = null;
|
|
430
|
+
if (exists) {
|
|
431
|
+
const targetInfo = await (0, import_promises2.lstat)(target.absolute);
|
|
432
|
+
if (!targetInfo.isFile()) throw new WorkspaceError("Only regular files can be replaced");
|
|
433
|
+
const previous = await (0, import_promises2.readFile)(target.absolute);
|
|
434
|
+
previousSha256 = sha256(previous);
|
|
435
|
+
if (!expectedSha256) {
|
|
436
|
+
throw new WorkspaceError(
|
|
437
|
+
"expectedSha256 is required when replacing an existing file",
|
|
438
|
+
"CONFLICT"
|
|
439
|
+
);
|
|
440
|
+
}
|
|
441
|
+
if (expectedSha256 !== previousSha256) {
|
|
442
|
+
throw new WorkspaceError(
|
|
443
|
+
"File changed after it was read; read it again before writing",
|
|
444
|
+
"CONFLICT"
|
|
445
|
+
);
|
|
446
|
+
}
|
|
447
|
+
const historyPath = (0, import_node_path2.join)(
|
|
448
|
+
root,
|
|
449
|
+
META_DIRECTORY,
|
|
450
|
+
"history",
|
|
451
|
+
timestampDirectory(),
|
|
452
|
+
...target.relative.split("/")
|
|
453
|
+
);
|
|
454
|
+
await (0, import_promises2.mkdir)((0, import_node_path2.dirname)(historyPath), { recursive: true });
|
|
455
|
+
await (0, import_promises2.writeFile)(historyPath, previous);
|
|
456
|
+
} else if (expectedSha256) {
|
|
457
|
+
throw new WorkspaceError("File no longer exists; omit expectedSha256 to create it", "CONFLICT");
|
|
458
|
+
}
|
|
459
|
+
const next = Buffer.from(content, "utf8");
|
|
460
|
+
if (next.length > MAX_WRITE_BYTES)
|
|
461
|
+
throw new WorkspaceError(`Content is larger than ${MAX_WRITE_BYTES} bytes`);
|
|
462
|
+
const temporary = (0, import_node_path2.join)(
|
|
463
|
+
(0, import_node_path2.dirname)(target.absolute),
|
|
464
|
+
`.${(0, import_node_path2.basename)(target.absolute)}.hatcher-${(0, import_node_crypto.randomUUID)()}.tmp`
|
|
465
|
+
);
|
|
466
|
+
await (0, import_promises2.writeFile)(temporary, next, { flag: "wx" });
|
|
467
|
+
try {
|
|
468
|
+
await (0, import_promises2.rename)(temporary, target.absolute);
|
|
469
|
+
} catch (error) {
|
|
470
|
+
await import("fs/promises").then(({ rm }) => rm(temporary, { force: true })).catch(() => void 0);
|
|
471
|
+
throw error;
|
|
472
|
+
}
|
|
473
|
+
return {
|
|
474
|
+
path: target.relative,
|
|
475
|
+
created: !exists,
|
|
476
|
+
previousSha256,
|
|
477
|
+
sha256: sha256(next),
|
|
478
|
+
size: next.length
|
|
479
|
+
};
|
|
480
|
+
}
|
|
481
|
+
async function createWorkspaceDirectory(root, args) {
|
|
482
|
+
const requested = requiredString(args, "path");
|
|
483
|
+
const target = await resolveWorkspacePath(root, requested, { allowMissing: true });
|
|
484
|
+
await (0, import_promises2.mkdir)(target.absolute, { recursive: true });
|
|
485
|
+
await assertNoSymlink(root, target.relative, false);
|
|
486
|
+
return { path: target.relative, created: true };
|
|
487
|
+
}
|
|
488
|
+
async function moveWorkspacePath(root, args) {
|
|
489
|
+
const source = await resolveWorkspacePath(root, requiredString(args, "from"));
|
|
490
|
+
const destination = await resolveWorkspacePath(root, requiredString(args, "to"), {
|
|
491
|
+
allowMissing: true
|
|
492
|
+
});
|
|
493
|
+
if (await pathExists(destination.absolute))
|
|
494
|
+
throw new WorkspaceError("Destination already exists", "CONFLICT");
|
|
495
|
+
const destinationParent = await resolveWorkspacePath(
|
|
496
|
+
root,
|
|
497
|
+
portablePath(root, (0, import_node_path2.dirname)(destination.absolute)),
|
|
498
|
+
{ allowRoot: true }
|
|
499
|
+
);
|
|
500
|
+
if (!(await (0, import_promises2.stat)(destinationParent.absolute)).isDirectory()) {
|
|
501
|
+
throw new WorkspaceError("Destination parent is not a directory");
|
|
502
|
+
}
|
|
503
|
+
await (0, import_promises2.rename)(source.absolute, destination.absolute);
|
|
504
|
+
return { from: source.relative, to: destination.relative };
|
|
505
|
+
}
|
|
506
|
+
async function deleteWorkspacePath(root, args) {
|
|
507
|
+
const target = await resolveWorkspacePath(root, requiredString(args, "path"));
|
|
508
|
+
const trashRelative = (0, import_node_path2.join)("trash", timestampDirectory(), ...target.relative.split("/"));
|
|
509
|
+
const trashAbsolute = (0, import_node_path2.join)(root, META_DIRECTORY, trashRelative);
|
|
510
|
+
await (0, import_promises2.mkdir)((0, import_node_path2.dirname)(trashAbsolute), { recursive: true });
|
|
511
|
+
await (0, import_promises2.rename)(target.absolute, trashAbsolute);
|
|
512
|
+
return {
|
|
513
|
+
path: target.relative,
|
|
514
|
+
recoverable: true,
|
|
515
|
+
trashPath: `${META_DIRECTORY}/${trashRelative.split(import_node_path2.sep).join("/")}`
|
|
516
|
+
};
|
|
517
|
+
}
|
|
518
|
+
async function searchWorkspace(root, args) {
|
|
519
|
+
const query = requiredString(args, "query", 256);
|
|
520
|
+
const caseSensitive = args.caseSensitive === true;
|
|
521
|
+
const requested = optionalString(args, "path");
|
|
522
|
+
const target = await resolveWorkspacePath(root, requested, { allowRoot: true });
|
|
523
|
+
if (!(await (0, import_promises2.stat)(target.absolute)).isDirectory())
|
|
524
|
+
throw new WorkspaceError(`${target.relative} is not a directory`);
|
|
525
|
+
const needle = caseSensitive ? query : query.toLocaleLowerCase();
|
|
526
|
+
const matches = [];
|
|
527
|
+
const queue = [target.absolute];
|
|
528
|
+
let scannedFiles = 0;
|
|
529
|
+
while (queue.length > 0 && scannedFiles < MAX_SEARCH_FILES && matches.length < MAX_SEARCH_RESULTS) {
|
|
530
|
+
const current = queue.shift();
|
|
531
|
+
const children = await import("fs/promises").then(
|
|
532
|
+
({ readdir }) => readdir(current, { withFileTypes: true })
|
|
533
|
+
);
|
|
534
|
+
for (const child of children) {
|
|
535
|
+
if (current === root && child.name === META_DIRECTORY) continue;
|
|
536
|
+
const absolute = (0, import_node_path2.join)(current, child.name);
|
|
537
|
+
const info = await (0, import_promises2.lstat)(absolute);
|
|
538
|
+
if (info.isSymbolicLink()) continue;
|
|
539
|
+
if (info.isDirectory()) {
|
|
540
|
+
queue.push(absolute);
|
|
541
|
+
continue;
|
|
542
|
+
}
|
|
543
|
+
if (!info.isFile() || info.size > MAX_TEXT_FILE_BYTES) continue;
|
|
544
|
+
scannedFiles += 1;
|
|
545
|
+
const content = await (0, import_promises2.readFile)(absolute);
|
|
546
|
+
if (isProbablyBinary(content)) continue;
|
|
547
|
+
const lines = content.toString("utf8").split(/\r?\n/);
|
|
548
|
+
for (let index = 0; index < lines.length; index += 1) {
|
|
549
|
+
const haystack = caseSensitive ? lines[index] : lines[index].toLocaleLowerCase();
|
|
550
|
+
if (haystack.includes(needle)) {
|
|
551
|
+
matches.push({
|
|
552
|
+
path: portablePath(root, absolute),
|
|
553
|
+
line: index + 1,
|
|
554
|
+
text: lines[index].slice(0, 500)
|
|
555
|
+
});
|
|
556
|
+
if (matches.length >= MAX_SEARCH_RESULTS) break;
|
|
557
|
+
}
|
|
558
|
+
}
|
|
559
|
+
}
|
|
560
|
+
}
|
|
561
|
+
return {
|
|
562
|
+
query,
|
|
563
|
+
matches,
|
|
564
|
+
scannedFiles,
|
|
565
|
+
truncated: scannedFiles >= MAX_SEARCH_FILES || matches.length >= MAX_SEARCH_RESULTS
|
|
566
|
+
};
|
|
567
|
+
}
|
|
568
|
+
async function prepareWorkspaceRoot(input) {
|
|
569
|
+
const absolute = (0, import_node_path2.resolve)(input);
|
|
570
|
+
await (0, import_promises2.mkdir)(absolute, { recursive: true });
|
|
571
|
+
const canonical = await (0, import_promises2.realpath)(absolute);
|
|
572
|
+
const canonicalHome = await (0, import_promises2.realpath)((0, import_node_os2.homedir)()).catch(() => (0, import_node_path2.resolve)((0, import_node_os2.homedir)()));
|
|
573
|
+
if (canonical === (0, import_node_path2.parse)(canonical).root || canonical === canonicalHome) {
|
|
574
|
+
throw new WorkspaceError(
|
|
575
|
+
"Connect a dedicated subfolder, not a filesystem root or your home directory",
|
|
576
|
+
"FORBIDDEN"
|
|
577
|
+
);
|
|
578
|
+
}
|
|
579
|
+
const info = await (0, import_promises2.stat)(canonical);
|
|
580
|
+
if (!info.isDirectory()) throw new WorkspaceError("Workspace path must be a directory");
|
|
581
|
+
await (0, import_promises2.mkdir)((0, import_node_path2.join)(canonical, META_DIRECTORY), { recursive: true });
|
|
582
|
+
return canonical;
|
|
583
|
+
}
|
|
584
|
+
async function executeWorkspaceTool(root, tool, rawArguments, readOnly) {
|
|
585
|
+
const args = asRecord(rawArguments);
|
|
586
|
+
if (readOnly && !["workspace.list", "workspace.search", "workspace.read"].includes(tool)) {
|
|
587
|
+
throw new WorkspaceError("This local workspace was connected in read-only mode", "FORBIDDEN");
|
|
588
|
+
}
|
|
589
|
+
switch (tool) {
|
|
590
|
+
case "workspace.list":
|
|
591
|
+
return listWorkspace(root, args);
|
|
592
|
+
case "workspace.search":
|
|
593
|
+
return searchWorkspace(root, args);
|
|
594
|
+
case "workspace.read":
|
|
595
|
+
return readWorkspaceFile(root, args);
|
|
596
|
+
case "workspace.write":
|
|
597
|
+
return writeWorkspaceFile(root, args);
|
|
598
|
+
case "workspace.mkdir":
|
|
599
|
+
return createWorkspaceDirectory(root, args);
|
|
600
|
+
case "workspace.move":
|
|
601
|
+
return moveWorkspacePath(root, args);
|
|
602
|
+
case "workspace.delete":
|
|
603
|
+
return deleteWorkspacePath(root, args);
|
|
604
|
+
}
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
// src/commands/connect.ts
|
|
608
|
+
var PROTOCOL_VERSION = 1;
|
|
609
|
+
var VALID_TOOLS = /* @__PURE__ */ new Set([
|
|
610
|
+
"workspace.list",
|
|
611
|
+
"workspace.search",
|
|
612
|
+
"workspace.read",
|
|
613
|
+
"workspace.write",
|
|
614
|
+
"workspace.mkdir",
|
|
615
|
+
"workspace.move",
|
|
616
|
+
"workspace.delete"
|
|
617
|
+
]);
|
|
618
|
+
var ConnectionError = class extends Error {
|
|
619
|
+
constructor(message, retryable) {
|
|
620
|
+
super(message);
|
|
621
|
+
this.retryable = retryable;
|
|
622
|
+
this.name = "ConnectionError";
|
|
623
|
+
}
|
|
624
|
+
retryable;
|
|
625
|
+
};
|
|
626
|
+
function websocketUrl(baseUrl, agentId) {
|
|
627
|
+
const url = new URL(
|
|
628
|
+
`/integrations/local-workspace/agents/${encodeURIComponent(agentId)}/ws`,
|
|
629
|
+
baseUrl
|
|
630
|
+
);
|
|
631
|
+
if (url.protocol === "https:") url.protocol = "wss:";
|
|
632
|
+
else if (url.protocol === "http:") url.protocol = "ws:";
|
|
633
|
+
else throw new Error("Hatcher API URL must use http or https");
|
|
634
|
+
return url.toString();
|
|
635
|
+
}
|
|
636
|
+
function parseRequest(raw) {
|
|
637
|
+
let payload;
|
|
638
|
+
try {
|
|
639
|
+
payload = JSON.parse(raw.toString());
|
|
640
|
+
} catch {
|
|
641
|
+
return null;
|
|
642
|
+
}
|
|
643
|
+
if (!payload || typeof payload !== "object" || Array.isArray(payload)) return null;
|
|
644
|
+
const frame = payload;
|
|
645
|
+
if (frame.type !== "request" || typeof frame.id !== "string" || typeof frame.tool !== "string")
|
|
646
|
+
return null;
|
|
647
|
+
if (!VALID_TOOLS.has(frame.tool)) return null;
|
|
648
|
+
return {
|
|
649
|
+
type: "request",
|
|
650
|
+
id: frame.id,
|
|
651
|
+
tool: frame.tool,
|
|
652
|
+
arguments: frame.arguments ?? {}
|
|
653
|
+
};
|
|
654
|
+
}
|
|
655
|
+
async function serveConnection(input) {
|
|
656
|
+
await new Promise((resolve2, reject) => {
|
|
657
|
+
const socket = new import_ws.default(input.url, {
|
|
658
|
+
headers: { Authorization: `Bearer ${input.apiKey}` },
|
|
659
|
+
maxPayload: 1024 * 1024
|
|
660
|
+
});
|
|
661
|
+
let authenticated = false;
|
|
662
|
+
let settled = false;
|
|
663
|
+
let operationChain = Promise.resolve();
|
|
664
|
+
const keepAlive = setInterval(() => {
|
|
665
|
+
if (socket.readyState === import_ws.default.OPEN) socket.ping();
|
|
666
|
+
}, 25e3);
|
|
667
|
+
keepAlive.unref?.();
|
|
668
|
+
socket.on("open", () => {
|
|
669
|
+
socket.send(
|
|
670
|
+
JSON.stringify({
|
|
671
|
+
type: "hello",
|
|
672
|
+
protocolVersion: PROTOCOL_VERSION,
|
|
673
|
+
workspaceName: (0, import_node_path3.basename)(input.root),
|
|
674
|
+
readOnly: input.readOnly
|
|
675
|
+
})
|
|
676
|
+
);
|
|
677
|
+
});
|
|
678
|
+
socket.on("message", (raw) => {
|
|
679
|
+
let control = null;
|
|
680
|
+
try {
|
|
681
|
+
const parsed = JSON.parse(raw.toString());
|
|
682
|
+
control = parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : null;
|
|
683
|
+
} catch {
|
|
684
|
+
control = null;
|
|
685
|
+
}
|
|
686
|
+
if (control?.type === "ready") {
|
|
687
|
+
authenticated = true;
|
|
688
|
+
console.log(`Connected to agent ${input.agentId}. Press Ctrl+C to disconnect.`);
|
|
689
|
+
return;
|
|
690
|
+
}
|
|
691
|
+
if (control?.type === "error" && typeof control.error === "string") {
|
|
692
|
+
if (!authenticated) reject(new ConnectionError(control.error, false));
|
|
693
|
+
else console.error(`Hatcher: ${control.error}`);
|
|
694
|
+
socket.close();
|
|
695
|
+
return;
|
|
696
|
+
}
|
|
697
|
+
const request = parseRequest(raw);
|
|
698
|
+
if (!request) return;
|
|
699
|
+
operationChain = operationChain.then(async () => {
|
|
700
|
+
try {
|
|
701
|
+
const result = await executeWorkspaceTool(
|
|
702
|
+
input.root,
|
|
703
|
+
request.tool,
|
|
704
|
+
request.arguments,
|
|
705
|
+
input.readOnly
|
|
706
|
+
);
|
|
707
|
+
if (socket.readyState === import_ws.default.OPEN) {
|
|
708
|
+
socket.send(JSON.stringify({ type: "result", id: request.id, result }));
|
|
709
|
+
}
|
|
710
|
+
} catch (error) {
|
|
711
|
+
const workspaceError = error instanceof WorkspaceError ? error : new WorkspaceError(error instanceof Error ? error.message : String(error));
|
|
712
|
+
if (socket.readyState === import_ws.default.OPEN) {
|
|
713
|
+
socket.send(
|
|
714
|
+
JSON.stringify({
|
|
715
|
+
type: "error",
|
|
716
|
+
id: request.id,
|
|
717
|
+
error: workspaceError.message,
|
|
718
|
+
code: workspaceError.code
|
|
719
|
+
})
|
|
720
|
+
);
|
|
721
|
+
}
|
|
722
|
+
}
|
|
723
|
+
});
|
|
724
|
+
});
|
|
725
|
+
socket.on("error", (error) => {
|
|
726
|
+
if (!authenticated && !settled) {
|
|
727
|
+
settled = true;
|
|
728
|
+
clearInterval(keepAlive);
|
|
729
|
+
reject(new ConnectionError(error.message, true));
|
|
730
|
+
}
|
|
731
|
+
});
|
|
732
|
+
socket.on("close", (code, reason) => {
|
|
733
|
+
clearInterval(keepAlive);
|
|
734
|
+
if (settled) return;
|
|
735
|
+
settled = true;
|
|
736
|
+
if (!input.stopped() && (code === 1008 || code === 4009)) {
|
|
737
|
+
reject(new ConnectionError(reason.toString() || `Connection closed (${code})`, false));
|
|
738
|
+
} else if (!authenticated && !input.stopped()) {
|
|
739
|
+
reject(new ConnectionError(reason.toString() || `Connection closed (${code})`, true));
|
|
740
|
+
} else {
|
|
741
|
+
resolve2();
|
|
742
|
+
}
|
|
743
|
+
});
|
|
744
|
+
const close = () => {
|
|
745
|
+
if (socket.readyState === import_ws.default.OPEN || socket.readyState === import_ws.default.CONNECTING) {
|
|
746
|
+
socket.close(1e3, "User disconnected");
|
|
747
|
+
}
|
|
748
|
+
};
|
|
749
|
+
process.once("SIGINT", close);
|
|
750
|
+
process.once("SIGTERM", close);
|
|
751
|
+
socket.once("close", () => {
|
|
752
|
+
process.removeListener("SIGINT", close);
|
|
753
|
+
process.removeListener("SIGTERM", close);
|
|
754
|
+
});
|
|
755
|
+
});
|
|
756
|
+
}
|
|
757
|
+
function registerConnectCommand(program2) {
|
|
758
|
+
program2.command("connect").description("Connect a dedicated local folder to one Hatcher agent").requiredOption("--agent <id>", "Agent ID that can access the folder").requiredOption("--folder <path>", "Dedicated local folder to connect").option("--read-only", "Allow listing, searching, and reading only").action(async (options) => {
|
|
759
|
+
const config = requireConfig();
|
|
760
|
+
const client = new ApiClient(config);
|
|
761
|
+
const root = await prepareWorkspaceRoot(options.folder);
|
|
762
|
+
const agent = await client.getAgent(options.agent);
|
|
763
|
+
const readOnly = options.readOnly === true;
|
|
764
|
+
console.log(`Local folder: ${root}`);
|
|
765
|
+
console.log(`Agent: ${agent.name} (${agent.id})`);
|
|
766
|
+
console.log(
|
|
767
|
+
`Access: ${readOnly ? "read only" : "read, create, edit, move, and recoverable delete"}`
|
|
768
|
+
);
|
|
769
|
+
console.log("Only content requested by the agent is sent to Hatcher for processing.");
|
|
770
|
+
let stopped = false;
|
|
771
|
+
const reconnectAbort = new AbortController();
|
|
772
|
+
const stop = () => {
|
|
773
|
+
stopped = true;
|
|
774
|
+
reconnectAbort.abort();
|
|
775
|
+
};
|
|
776
|
+
process.once("SIGINT", stop);
|
|
777
|
+
process.once("SIGTERM", stop);
|
|
778
|
+
let attempt = 0;
|
|
779
|
+
try {
|
|
780
|
+
while (!stopped) {
|
|
781
|
+
try {
|
|
782
|
+
await serveConnection({
|
|
783
|
+
url: websocketUrl(config.baseUrl, agent.id),
|
|
784
|
+
apiKey: config.apiKey,
|
|
785
|
+
agentId: agent.id,
|
|
786
|
+
root,
|
|
787
|
+
readOnly,
|
|
788
|
+
stopped: () => stopped
|
|
789
|
+
});
|
|
790
|
+
if (stopped) break;
|
|
791
|
+
} catch (error) {
|
|
792
|
+
if (error instanceof ConnectionError && !error.retryable) throw error;
|
|
793
|
+
if (attempt === 0)
|
|
794
|
+
console.error(
|
|
795
|
+
`Connection failed: ${error instanceof Error ? error.message : String(error)}`
|
|
796
|
+
);
|
|
797
|
+
}
|
|
798
|
+
attempt += 1;
|
|
799
|
+
const waitMs = Math.min(3e4, 1e3 * 2 ** Math.min(attempt, 5));
|
|
800
|
+
console.log(`Disconnected. Reconnecting in ${Math.round(waitMs / 1e3)}s...`);
|
|
801
|
+
await (0, import_promises3.setTimeout)(waitMs, void 0, { signal: reconnectAbort.signal, ref: true }).catch(
|
|
802
|
+
(error) => {
|
|
803
|
+
if (!stopped) throw error;
|
|
804
|
+
}
|
|
805
|
+
);
|
|
806
|
+
}
|
|
807
|
+
} finally {
|
|
808
|
+
process.removeListener("SIGINT", stop);
|
|
809
|
+
process.removeListener("SIGTERM", stop);
|
|
810
|
+
}
|
|
811
|
+
console.log("Local workspace disconnected.");
|
|
812
|
+
});
|
|
813
|
+
}
|
|
814
|
+
|
|
815
|
+
// src/index.ts
|
|
816
|
+
var program = new import_commander.Command();
|
|
817
|
+
program.name("hatcher-connect").description("Connect a dedicated local folder to a Hatcher agent").version("0.1.0");
|
|
818
|
+
registerAuthCommands(program);
|
|
819
|
+
registerAgentCommands(program);
|
|
820
|
+
registerConnectCommand(program);
|
|
821
|
+
program.parseAsync(process.argv).catch((error) => {
|
|
822
|
+
console.error(error instanceof Error ? error.message : String(error));
|
|
823
|
+
process.exitCode = 1;
|
|
824
|
+
});
|
package/package.json
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "hatcher-connect",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Standalone local workspace connector for Hatcher agents",
|
|
5
|
+
"bin": {
|
|
6
|
+
"hatcher-connect": "dist/index.js"
|
|
7
|
+
},
|
|
8
|
+
"files": [
|
|
9
|
+
"dist",
|
|
10
|
+
"README.md"
|
|
11
|
+
],
|
|
12
|
+
"scripts": {
|
|
13
|
+
"build": "tsup src/index.ts --format cjs --clean",
|
|
14
|
+
"prepack": "npm run build",
|
|
15
|
+
"test": "vitest run --config vitest.config.ts",
|
|
16
|
+
"type-check": "tsc --noEmit"
|
|
17
|
+
},
|
|
18
|
+
"keywords": [
|
|
19
|
+
"hatcher",
|
|
20
|
+
"agent",
|
|
21
|
+
"workspace",
|
|
22
|
+
"connector"
|
|
23
|
+
],
|
|
24
|
+
"license": "MIT",
|
|
25
|
+
"dependencies": {
|
|
26
|
+
"commander": "^12.0.0",
|
|
27
|
+
"ws": "^8.18.3"
|
|
28
|
+
},
|
|
29
|
+
"devDependencies": {
|
|
30
|
+
"@types/node": "^22.0.0",
|
|
31
|
+
"@types/ws": "^8.18.1",
|
|
32
|
+
"tsup": "^8.0.0",
|
|
33
|
+
"typescript": "^5.7.0",
|
|
34
|
+
"vitest": "^4.1.11"
|
|
35
|
+
},
|
|
36
|
+
"repository": {
|
|
37
|
+
"type": "git",
|
|
38
|
+
"url": "git+https://github.com/HatcherLabs/Hatcher.git",
|
|
39
|
+
"directory": "apps/workspace-connector"
|
|
40
|
+
},
|
|
41
|
+
"homepage": "https://hatcher.host",
|
|
42
|
+
"publishConfig": {
|
|
43
|
+
"access": "public",
|
|
44
|
+
"registry": "https://registry.npmjs.org/"
|
|
45
|
+
},
|
|
46
|
+
"engines": {
|
|
47
|
+
"node": ">=20.0.0"
|
|
48
|
+
}
|
|
49
|
+
}
|