@teammy/cli 0.0.0-stage → 0.0.2
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 +23 -2
- package/bin/teammy.js +8 -0
- package/dist/LICENSE +21 -0
- package/dist/main.js +2908 -0
- package/package.json +26 -4
package/dist/main.js
ADDED
|
@@ -0,0 +1,2908 @@
|
|
|
1
|
+
// apps/client/control.ts
|
|
2
|
+
import { setTimeout as setTimeout2 } from "node:timers/promises";
|
|
3
|
+
|
|
4
|
+
// packages/protocol/devices.ts
|
|
5
|
+
import { z as z3 } from "zod";
|
|
6
|
+
|
|
7
|
+
// packages/protocol/identity.ts
|
|
8
|
+
import { z } from "zod";
|
|
9
|
+
var NameSchema = z.string().regex(/^[a-z][a-z0-9_-]{0,63}$/);
|
|
10
|
+
var DeviceIdSchema = z.uuid();
|
|
11
|
+
var BindRequestSchema = z.object({ deviceId: DeviceIdSchema }).strict();
|
|
12
|
+
var BindingSchema = z.object({
|
|
13
|
+
workspaceId: NameSchema,
|
|
14
|
+
memberId: NameSchema,
|
|
15
|
+
deviceId: DeviceIdSchema
|
|
16
|
+
});
|
|
17
|
+
var MembersSchema = z.object({ members: z.array(NameSchema) });
|
|
18
|
+
|
|
19
|
+
// packages/protocol/files.ts
|
|
20
|
+
import { z as z2 } from "zod";
|
|
21
|
+
var MAX_BLOB_BYTES = 10 * 1024 * 1024;
|
|
22
|
+
var MAX_BATCH_FILES = 200;
|
|
23
|
+
var HashSchema = z2.string().regex(/^[a-f0-9]{64}$/);
|
|
24
|
+
var RevisionSchema = z2.number().int().nonnegative().max(Number.MAX_SAFE_INTEGER);
|
|
25
|
+
var FilePathSchema = z2.string().min(1).max(1024).refine((path) => {
|
|
26
|
+
if (path !== path.normalize("NFC") || /[\\\x00-\x1f\x7f:<>"|?*]/.test(path)) return false;
|
|
27
|
+
return path.split("/").every(
|
|
28
|
+
(part) => part.length > 0 && part.length <= 255 && part !== "." && part !== ".." && !/[. ]$/.test(part) && !/^\.teammy(?:-|$)/i.test(part) && !/^(con|prn|aux|nul|com[1-9]|lpt[1-9])(?:\.|$)/i.test(part)
|
|
29
|
+
);
|
|
30
|
+
}, "Path must be a portable, normalized relative file path.");
|
|
31
|
+
var BlobRefSchema = z2.object({
|
|
32
|
+
sha256: HashSchema,
|
|
33
|
+
size: z2.number().int().min(0).max(MAX_BLOB_BYTES)
|
|
34
|
+
});
|
|
35
|
+
var FileChangeSchema = z2.discriminatedUnion("kind", [
|
|
36
|
+
BlobRefSchema.extend({ kind: z2.literal("upsert"), path: FilePathSchema }).strict(),
|
|
37
|
+
z2.object({ kind: z2.literal("delete"), path: FilePathSchema }).strict()
|
|
38
|
+
]);
|
|
39
|
+
var FileCommitSchema = z2.object({
|
|
40
|
+
operationId: z2.uuid(),
|
|
41
|
+
parentRevision: RevisionSchema,
|
|
42
|
+
changes: z2.array(FileChangeSchema).min(1).max(MAX_BATCH_FILES)
|
|
43
|
+
}).strict().refine(
|
|
44
|
+
(batch) => new Set(batch.changes.map((change) => change.path)).size === batch.changes.length,
|
|
45
|
+
"Duplicate paths in a batch."
|
|
46
|
+
);
|
|
47
|
+
var CommitResultSchema = z2.object({ operationId: z2.uuid(), revision: RevisionSchema });
|
|
48
|
+
var FileEntrySchema = BlobRefSchema.extend({
|
|
49
|
+
path: FilePathSchema,
|
|
50
|
+
revision: RevisionSchema
|
|
51
|
+
});
|
|
52
|
+
var ManifestSchema = z2.object({
|
|
53
|
+
revision: RevisionSchema,
|
|
54
|
+
files: z2.array(FileEntrySchema),
|
|
55
|
+
nextCursor: z2.string().nullable()
|
|
56
|
+
});
|
|
57
|
+
var FileRevisionSchema = z2.object({
|
|
58
|
+
revision: RevisionSchema,
|
|
59
|
+
operationId: z2.uuid(),
|
|
60
|
+
createdAt: z2.string(),
|
|
61
|
+
changes: z2.array(FileChangeSchema)
|
|
62
|
+
});
|
|
63
|
+
var ChangesSchema = z2.object({
|
|
64
|
+
headRevision: RevisionSchema,
|
|
65
|
+
revisions: z2.array(FileRevisionSchema)
|
|
66
|
+
});
|
|
67
|
+
var BlobCheckSchema = z2.object({ hashes: z2.array(HashSchema).max(MAX_BATCH_FILES) }).strict();
|
|
68
|
+
var MissingBlobsSchema = z2.object({ missing: z2.array(HashSchema) });
|
|
69
|
+
|
|
70
|
+
// packages/protocol/devices.ts
|
|
71
|
+
var CredentialSchema = z3.string().regex(/^tmy_[a-f0-9]{64}$/);
|
|
72
|
+
var EnrollmentSchema = z3.object({
|
|
73
|
+
operationId: z3.uuid(),
|
|
74
|
+
memberId: NameSchema,
|
|
75
|
+
deviceId: DeviceIdSchema,
|
|
76
|
+
credential: CredentialSchema
|
|
77
|
+
}).strict();
|
|
78
|
+
var EffectivePolicySchema = z3.object({
|
|
79
|
+
checkIntervalSeconds: z3.number().min(1).max(86400),
|
|
80
|
+
fileSettleSeconds: z3.number().min(0).max(86400)
|
|
81
|
+
}).strict();
|
|
82
|
+
var SyncSummarySchema = z3.object({
|
|
83
|
+
checkedAt: z3.string(),
|
|
84
|
+
published: z3.boolean(),
|
|
85
|
+
appliedFiles: z3.number().int().nonnegative(),
|
|
86
|
+
pendingFiles: z3.number().int().nonnegative(),
|
|
87
|
+
sessionSources: z3.number().int().nonnegative(),
|
|
88
|
+
errors: z3.array(z3.string().max(1e3)).max(20)
|
|
89
|
+
}).strict();
|
|
90
|
+
var DeviceStatusSchema = z3.object({
|
|
91
|
+
clientVersion: z3.string().max(64),
|
|
92
|
+
receivedRevision: RevisionSchema.nullable(),
|
|
93
|
+
appliedRevision: RevisionSchema.nullable(),
|
|
94
|
+
effectivePolicy: EffectivePolicySchema,
|
|
95
|
+
overrides: z3.array(z3.enum(["checkIntervalSeconds", "fileSettleSeconds"])).max(2),
|
|
96
|
+
configError: z3.string().max(2e3).nullable(),
|
|
97
|
+
reportError: z3.string().max(2e3).nullable(),
|
|
98
|
+
lastSync: SyncSummarySchema.nullable()
|
|
99
|
+
}).strict();
|
|
100
|
+
var WebDevicesSchema = z3.object({
|
|
101
|
+
devices: z3.array(
|
|
102
|
+
z3.object({
|
|
103
|
+
memberId: NameSchema,
|
|
104
|
+
bound: z3.boolean(),
|
|
105
|
+
lastSeenAt: z3.string().nullable(),
|
|
106
|
+
status: DeviceStatusSchema.nullable()
|
|
107
|
+
})
|
|
108
|
+
)
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
// packages/protocol/project.ts
|
|
112
|
+
import { z as z4 } from "zod";
|
|
113
|
+
var ServerUrlSchema = z4.string().url().max(2048).refine((value) => {
|
|
114
|
+
const url = new URL(value);
|
|
115
|
+
return ["http:", "https:"].includes(url.protocol) && !url.username && !url.password && !url.search && !url.hash;
|
|
116
|
+
}, "Use an HTTP(S) server URL without credentials, query or fragment.");
|
|
117
|
+
var SyncPolicySchema = z4.object({
|
|
118
|
+
checkIntervalSeconds: z4.number().int().min(1).max(86400),
|
|
119
|
+
fileSettleSeconds: z4.number().int().min(0).max(86400)
|
|
120
|
+
}).strict();
|
|
121
|
+
var DEFAULT_POLICY = { checkIntervalSeconds: 60, fileSettleSeconds: 60 };
|
|
122
|
+
var ProjectConfigSchema = z4.object({
|
|
123
|
+
schemaVersion: z4.literal(1),
|
|
124
|
+
projectId: NameSchema,
|
|
125
|
+
projectName: z4.string().trim().min(1).max(120),
|
|
126
|
+
description: z4.string().max(2e3),
|
|
127
|
+
publicServerUrl: ServerUrlSchema,
|
|
128
|
+
configRevision: RevisionSchema,
|
|
129
|
+
scopeProfile: z4.literal("workspace-and-sessions-v1"),
|
|
130
|
+
sync: SyncPolicySchema
|
|
131
|
+
}).strict();
|
|
132
|
+
var SetupSchema = z4.object({
|
|
133
|
+
operationId: z4.uuid(),
|
|
134
|
+
projectName: z4.string().trim().min(1).max(120),
|
|
135
|
+
description: z4.string().max(2e3).default(""),
|
|
136
|
+
publicServerUrl: ServerUrlSchema,
|
|
137
|
+
adminId: NameSchema,
|
|
138
|
+
members: z4.array(NameSchema).max(100).default([]),
|
|
139
|
+
existingWorkspaceId: NameSchema.optional(),
|
|
140
|
+
sync: SyncPolicySchema.default(DEFAULT_POLICY)
|
|
141
|
+
}).strict();
|
|
142
|
+
var SetupStatusSchema = z4.object({
|
|
143
|
+
initialized: z4.boolean(),
|
|
144
|
+
existingWorkspaces: z4.array(NameSchema)
|
|
145
|
+
});
|
|
146
|
+
var ConfigUpdateSchema = ProjectConfigSchema.pick({
|
|
147
|
+
projectName: true,
|
|
148
|
+
description: true,
|
|
149
|
+
publicServerUrl: true,
|
|
150
|
+
sync: true
|
|
151
|
+
}).extend({ expectedRevision: RevisionSchema }).strict();
|
|
152
|
+
var MemberSchema = z4.object({
|
|
153
|
+
id: NameSchema,
|
|
154
|
+
displayName: z4.string().max(120),
|
|
155
|
+
role: z4.enum(["admin", "member"]),
|
|
156
|
+
enabled: z4.boolean(),
|
|
157
|
+
revision: RevisionSchema,
|
|
158
|
+
bound: z4.boolean(),
|
|
159
|
+
fileRevision: RevisionSchema,
|
|
160
|
+
lastPublishedAt: z4.string().nullable()
|
|
161
|
+
});
|
|
162
|
+
var WebSessionSchema = z4.object({
|
|
163
|
+
workspaceId: NameSchema,
|
|
164
|
+
memberId: NameSchema,
|
|
165
|
+
displayName: z4.string(),
|
|
166
|
+
role: z4.enum(["admin", "member"])
|
|
167
|
+
});
|
|
168
|
+
var MemberCreateSchema = z4.object({ id: NameSchema, displayName: z4.string().trim().max(120).default("") }).strict();
|
|
169
|
+
var MemberUpdateSchema = z4.object({
|
|
170
|
+
expectedRevision: RevisionSchema,
|
|
171
|
+
displayName: z4.string().trim().max(120).optional(),
|
|
172
|
+
enabled: z4.boolean().optional()
|
|
173
|
+
}).strict();
|
|
174
|
+
|
|
175
|
+
// packages/protocol/sessions.ts
|
|
176
|
+
import { createHash } from "node:crypto";
|
|
177
|
+
import { z as z5 } from "zod";
|
|
178
|
+
var AgentSchema = z5.enum(["claude-code", "codex", "cursor", "pi", "dsh"]);
|
|
179
|
+
var SessionSnapshotSchema = z5.object({
|
|
180
|
+
formatVersion: z5.literal(1),
|
|
181
|
+
adapterVersion: z5.literal(1),
|
|
182
|
+
agent: AgentSchema,
|
|
183
|
+
nativeId: z5.string().min(1).max(512),
|
|
184
|
+
cwd: z5.string().min(1).max(4096),
|
|
185
|
+
messages: z5.array(
|
|
186
|
+
z5.object({
|
|
187
|
+
id: z5.string().min(1).max(1024),
|
|
188
|
+
role: z5.enum(["user", "assistant"]),
|
|
189
|
+
text: z5.string().min(1).max(2 * 1024 * 1024),
|
|
190
|
+
source: z5.string().min(1).max(1024)
|
|
191
|
+
}).strict()
|
|
192
|
+
).max(5e4)
|
|
193
|
+
}).strict().refine(
|
|
194
|
+
(s) => new Set(s.messages.map((m) => m.id)).size === s.messages.length,
|
|
195
|
+
"Duplicate message IDs."
|
|
196
|
+
);
|
|
197
|
+
function sessionKey(agent, nativeId) {
|
|
198
|
+
return createHash("sha256").update(JSON.stringify([agent, nativeId])).digest("hex");
|
|
199
|
+
}
|
|
200
|
+
var SessionCommitSchema = z5.object({
|
|
201
|
+
operationId: z5.uuid(),
|
|
202
|
+
sessionKey: HashSchema,
|
|
203
|
+
parentRevision: RevisionSchema,
|
|
204
|
+
blob: BlobRefSchema
|
|
205
|
+
}).strict();
|
|
206
|
+
var SessionInfoSchema = z5.object({
|
|
207
|
+
owner: NameSchema,
|
|
208
|
+
sessionKey: HashSchema,
|
|
209
|
+
agent: AgentSchema,
|
|
210
|
+
nativeId: z5.string(),
|
|
211
|
+
cwd: z5.string(),
|
|
212
|
+
title: z5.string(),
|
|
213
|
+
revision: RevisionSchema,
|
|
214
|
+
updatedAt: z5.string(),
|
|
215
|
+
messageCount: z5.number().int().nonnegative(),
|
|
216
|
+
blob: BlobRefSchema
|
|
217
|
+
});
|
|
218
|
+
var SessionListSchema = z5.object({
|
|
219
|
+
sessions: z5.array(SessionInfoSchema),
|
|
220
|
+
nextCursor: RevisionSchema.nullable()
|
|
221
|
+
});
|
|
222
|
+
var SessionSearchSchema = z5.object({
|
|
223
|
+
matches: z5.array(
|
|
224
|
+
z5.object({
|
|
225
|
+
owner: NameSchema,
|
|
226
|
+
sessionKey: HashSchema,
|
|
227
|
+
revision: RevisionSchema,
|
|
228
|
+
messageId: z5.string(),
|
|
229
|
+
role: z5.enum(["user", "assistant"]),
|
|
230
|
+
snippet: z5.string()
|
|
231
|
+
})
|
|
232
|
+
),
|
|
233
|
+
nextCursor: RevisionSchema.nullable()
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
// packages/transport/hub-client.ts
|
|
237
|
+
import { z as z7 } from "zod";
|
|
238
|
+
import { createHash as createHash2 } from "node:crypto";
|
|
239
|
+
|
|
240
|
+
// packages/protocol/index.ts
|
|
241
|
+
import { z as z6 } from "zod";
|
|
242
|
+
var PROTOCOL_VERSION = 1;
|
|
243
|
+
var HealthSchema = z6.object({ status: z6.literal("ok") });
|
|
244
|
+
var ServerInfoSchema = z6.object({
|
|
245
|
+
name: z6.literal("teammy"),
|
|
246
|
+
version: z6.string().min(1),
|
|
247
|
+
protocolVersion: z6.literal(PROTOCOL_VERSION),
|
|
248
|
+
capabilities: z6.array(z6.string())
|
|
249
|
+
});
|
|
250
|
+
var ErrorResponseSchema = z6.object({
|
|
251
|
+
error: z6.object({
|
|
252
|
+
code: z6.string(),
|
|
253
|
+
message: z6.string(),
|
|
254
|
+
requestId: z6.string()
|
|
255
|
+
})
|
|
256
|
+
});
|
|
257
|
+
|
|
258
|
+
// packages/transport/hub-client.ts
|
|
259
|
+
var HubRequestError = class extends Error {
|
|
260
|
+
constructor(message2, status, code, requestId) {
|
|
261
|
+
super(message2);
|
|
262
|
+
this.status = status;
|
|
263
|
+
this.code = code;
|
|
264
|
+
this.requestId = requestId;
|
|
265
|
+
this.name = "HubRequestError";
|
|
266
|
+
}
|
|
267
|
+
status;
|
|
268
|
+
code;
|
|
269
|
+
requestId;
|
|
270
|
+
};
|
|
271
|
+
var HubClient = class {
|
|
272
|
+
constructor(serverUrl, timeoutMs = 1e4, credentials, abortSignal) {
|
|
273
|
+
this.timeoutMs = timeoutMs;
|
|
274
|
+
this.credentials = credentials;
|
|
275
|
+
this.abortSignal = abortSignal;
|
|
276
|
+
this.baseUrl = new URL(serverUrl);
|
|
277
|
+
if (!["http:", "https:"].includes(this.baseUrl.protocol) || this.baseUrl.username || this.baseUrl.password || this.baseUrl.search || this.baseUrl.hash) {
|
|
278
|
+
throw new Error("Server URL must use HTTP(S), without credentials, query, or fragment.");
|
|
279
|
+
}
|
|
280
|
+
if (!Number.isSafeInteger(timeoutMs) || timeoutMs <= 0) {
|
|
281
|
+
throw new Error("Request timeout must be a positive integer.");
|
|
282
|
+
}
|
|
283
|
+
if (!this.baseUrl.pathname.endsWith("/")) this.baseUrl.pathname += "/";
|
|
284
|
+
}
|
|
285
|
+
timeoutMs;
|
|
286
|
+
credentials;
|
|
287
|
+
abortSignal;
|
|
288
|
+
baseUrl;
|
|
289
|
+
health() {
|
|
290
|
+
return this.request("GET", "healthz", HealthSchema);
|
|
291
|
+
}
|
|
292
|
+
serverInfo() {
|
|
293
|
+
return this.request("GET", "api/v1/server", ServerInfoSchema);
|
|
294
|
+
}
|
|
295
|
+
get url() {
|
|
296
|
+
return this.baseUrl.toString();
|
|
297
|
+
}
|
|
298
|
+
bind(deviceId) {
|
|
299
|
+
return this.request("POST", "api/v1/bind", BindingSchema, { deviceId });
|
|
300
|
+
}
|
|
301
|
+
members() {
|
|
302
|
+
return this.request("GET", "api/v1/members", MembersSchema);
|
|
303
|
+
}
|
|
304
|
+
missingBlobs(hashes) {
|
|
305
|
+
return this.request("POST", "api/v1/blobs/check", MissingBlobsSchema, { hashes });
|
|
306
|
+
}
|
|
307
|
+
uploadBlob(hash2, content) {
|
|
308
|
+
if (content.byteLength > MAX_BLOB_BYTES)
|
|
309
|
+
throw new Error("Content exceeds the file size limit.");
|
|
310
|
+
return this.request("PUT", `api/v1/blobs/${HashSchema.parse(hash2)}`, BlobRefSchema, content);
|
|
311
|
+
}
|
|
312
|
+
async downloadBlob(hash2) {
|
|
313
|
+
const { response, signal } = await this.fetchResponse(
|
|
314
|
+
"GET",
|
|
315
|
+
`api/v1/blobs/${HashSchema.parse(hash2)}`
|
|
316
|
+
);
|
|
317
|
+
if (!response.ok) return this.decode(response, signal, z7.never());
|
|
318
|
+
const reader = response.body?.getReader();
|
|
319
|
+
if (!reader) throw new Error("Server returned no content body.");
|
|
320
|
+
const parts = [];
|
|
321
|
+
let size = 0;
|
|
322
|
+
try {
|
|
323
|
+
for (; ; ) {
|
|
324
|
+
const result = await reader.read();
|
|
325
|
+
if (result.done) break;
|
|
326
|
+
size += result.value.byteLength;
|
|
327
|
+
if (size > MAX_BLOB_BYTES) throw new Error("Downloaded content exceeds the size limit.");
|
|
328
|
+
parts.push(result.value);
|
|
329
|
+
}
|
|
330
|
+
} finally {
|
|
331
|
+
await reader.cancel();
|
|
332
|
+
reader.releaseLock();
|
|
333
|
+
}
|
|
334
|
+
const bytes = Buffer.concat(parts, size);
|
|
335
|
+
if (createHash2("sha256").update(bytes).digest("hex") !== hash2)
|
|
336
|
+
throw new Error("Downloaded content failed integrity verification.");
|
|
337
|
+
return bytes;
|
|
338
|
+
}
|
|
339
|
+
commitFiles(batch) {
|
|
340
|
+
return this.request("POST", "api/v1/files/commits", CommitResultSchema, batch);
|
|
341
|
+
}
|
|
342
|
+
fileManifest(owner, revision, after = "", limit = 200) {
|
|
343
|
+
const query = new URLSearchParams({ after, limit: String(limit) });
|
|
344
|
+
if (revision !== void 0) query.set("revision", String(revision));
|
|
345
|
+
return this.request(
|
|
346
|
+
"GET",
|
|
347
|
+
`api/v1/files/${encodeURIComponent(owner)}/manifest?${query}`,
|
|
348
|
+
ManifestSchema
|
|
349
|
+
);
|
|
350
|
+
}
|
|
351
|
+
fileChanges(owner, after) {
|
|
352
|
+
return this.request(
|
|
353
|
+
"GET",
|
|
354
|
+
`api/v1/files/${encodeURIComponent(owner)}/changes?after=${after}`,
|
|
355
|
+
ChangesSchema
|
|
356
|
+
);
|
|
357
|
+
}
|
|
358
|
+
reportDeviceStatus(status) {
|
|
359
|
+
return this.request(
|
|
360
|
+
"PUT",
|
|
361
|
+
"api/v1/devices/self/status",
|
|
362
|
+
z7.object({ ok: z7.literal(true) }),
|
|
363
|
+
status
|
|
364
|
+
);
|
|
365
|
+
}
|
|
366
|
+
enroll(request) {
|
|
367
|
+
return this.request("POST", "api/v1/devices/enroll", BindingSchema, request);
|
|
368
|
+
}
|
|
369
|
+
projectConfig() {
|
|
370
|
+
return this.request("GET", "api/v1/project/config", ProjectConfigSchema);
|
|
371
|
+
}
|
|
372
|
+
commitSession(commit) {
|
|
373
|
+
return this.request(
|
|
374
|
+
"POST",
|
|
375
|
+
"api/v1/sessions/commits",
|
|
376
|
+
CommitResultSchema,
|
|
377
|
+
SessionCommitSchema.parse(commit)
|
|
378
|
+
);
|
|
379
|
+
}
|
|
380
|
+
sessions(after = 0, limit = 50) {
|
|
381
|
+
return this.request(
|
|
382
|
+
"GET",
|
|
383
|
+
`api/v1/sessions?${new URLSearchParams({ after: String(after), limit: String(limit) })}`,
|
|
384
|
+
SessionListSchema
|
|
385
|
+
);
|
|
386
|
+
}
|
|
387
|
+
session(owner, key, revision) {
|
|
388
|
+
return this.request(
|
|
389
|
+
"GET",
|
|
390
|
+
`api/v1/sessions/${encodeURIComponent(owner)}/${HashSchema.parse(key)}${revision === void 0 ? "" : `?revision=${revision}`}`,
|
|
391
|
+
SessionInfoSchema
|
|
392
|
+
);
|
|
393
|
+
}
|
|
394
|
+
searchSessions(query, after = 0, limit = 50) {
|
|
395
|
+
return this.request(
|
|
396
|
+
"GET",
|
|
397
|
+
`api/v1/sessions/search?${new URLSearchParams({ q: query, after: String(after), limit: String(limit) })}`,
|
|
398
|
+
SessionSearchSchema
|
|
399
|
+
);
|
|
400
|
+
}
|
|
401
|
+
async request(method, path, schema, requestBody) {
|
|
402
|
+
const { response, signal } = await this.fetchResponse(method, path, requestBody);
|
|
403
|
+
return this.decode(response, signal, schema);
|
|
404
|
+
}
|
|
405
|
+
async fetchResponse(method, path, requestBody) {
|
|
406
|
+
const timeout = AbortSignal.timeout(this.timeoutMs);
|
|
407
|
+
const signal = this.abortSignal ? AbortSignal.any([timeout, this.abortSignal]) : timeout;
|
|
408
|
+
const binary = requestBody instanceof Uint8Array;
|
|
409
|
+
const response = await fetch(new URL(path, this.baseUrl), {
|
|
410
|
+
method,
|
|
411
|
+
headers: {
|
|
412
|
+
accept: "application/json",
|
|
413
|
+
...requestBody === void 0 ? {} : { "content-type": binary ? "application/octet-stream" : "application/json" },
|
|
414
|
+
...this.credentials ? {
|
|
415
|
+
authorization: `Bearer ${this.credentials.token}`,
|
|
416
|
+
"x-teammy-device": this.credentials.deviceId
|
|
417
|
+
} : {}
|
|
418
|
+
},
|
|
419
|
+
...requestBody === void 0 ? {} : { body: binary ? Uint8Array.from(requestBody).buffer : JSON.stringify(requestBody) },
|
|
420
|
+
signal,
|
|
421
|
+
redirect: "error"
|
|
422
|
+
});
|
|
423
|
+
return { response, signal };
|
|
424
|
+
}
|
|
425
|
+
async decode(response, signal, schema) {
|
|
426
|
+
const requestId = response.headers.get("x-request-id") ?? void 0;
|
|
427
|
+
let body;
|
|
428
|
+
try {
|
|
429
|
+
body = await response.json();
|
|
430
|
+
} catch (error) {
|
|
431
|
+
if (signal.aborted) throw signal.reason;
|
|
432
|
+
if (!(error instanceof SyntaxError)) throw error;
|
|
433
|
+
}
|
|
434
|
+
if (!response.ok) {
|
|
435
|
+
const parsed2 = ErrorResponseSchema.safeParse(body);
|
|
436
|
+
throw new HubRequestError(
|
|
437
|
+
parsed2.success ? parsed2.data.error.message : "Hub request failed.",
|
|
438
|
+
response.status,
|
|
439
|
+
parsed2.success ? parsed2.data.error.code : "HTTP_ERROR",
|
|
440
|
+
parsed2.success ? parsed2.data.error.requestId : requestId
|
|
441
|
+
);
|
|
442
|
+
}
|
|
443
|
+
const parsed = schema.safeParse(body);
|
|
444
|
+
if (!parsed.success) {
|
|
445
|
+
throw new HubRequestError(
|
|
446
|
+
"Hub returned an incompatible response.",
|
|
447
|
+
response.status,
|
|
448
|
+
"INVALID_RESPONSE",
|
|
449
|
+
requestId
|
|
450
|
+
);
|
|
451
|
+
}
|
|
452
|
+
return parsed.data;
|
|
453
|
+
}
|
|
454
|
+
};
|
|
455
|
+
|
|
456
|
+
// apps/client/project-state.ts
|
|
457
|
+
import { randomBytes, randomUUID } from "node:crypto";
|
|
458
|
+
var projectClientMigration = String.raw`
|
|
459
|
+
CREATE TABLE client_enrollments(root TEXT PRIMARY KEY REFERENCES connections(root),server_url TEXT NOT NULL,request_json TEXT NOT NULL);
|
|
460
|
+
CREATE TABLE client_project_config(root TEXT PRIMARY KEY REFERENCES connections(root),config_json TEXT NOT NULL,descriptor_hash TEXT NOT NULL);
|
|
461
|
+
`;
|
|
462
|
+
var ClientProjectState = class {
|
|
463
|
+
constructor(state, root) {
|
|
464
|
+
this.state = state;
|
|
465
|
+
this.root = root;
|
|
466
|
+
}
|
|
467
|
+
state;
|
|
468
|
+
root;
|
|
469
|
+
enrollment(url, memberId, reconnect = false) {
|
|
470
|
+
const deviceId = this.state.deviceFor(this.root);
|
|
471
|
+
const row = this.state.db.prepare("SELECT * FROM client_enrollments WHERE root=?").get(this.root);
|
|
472
|
+
if (row && !reconnect) {
|
|
473
|
+
const request2 = EnrollmentSchema.parse(JSON.parse(String(row.request_json)));
|
|
474
|
+
if (row.server_url !== url || request2.memberId !== memberId)
|
|
475
|
+
throw new Error("Another enrollment is pending for this root; use the same member/server.");
|
|
476
|
+
return request2;
|
|
477
|
+
}
|
|
478
|
+
const request = {
|
|
479
|
+
operationId: randomUUID(),
|
|
480
|
+
memberId,
|
|
481
|
+
deviceId,
|
|
482
|
+
credential: `tmy_${randomBytes(32).toString("hex")}`
|
|
483
|
+
};
|
|
484
|
+
this.state.db.prepare(
|
|
485
|
+
"INSERT INTO client_enrollments VALUES (?,?,?) ON CONFLICT(root) DO UPDATE SET server_url=excluded.server_url,request_json=excluded.request_json"
|
|
486
|
+
).run(this.root, url, JSON.stringify(request));
|
|
487
|
+
return request;
|
|
488
|
+
}
|
|
489
|
+
completeEnrollment() {
|
|
490
|
+
this.state.db.prepare("DELETE FROM client_enrollments WHERE root=?").run(this.root);
|
|
491
|
+
}
|
|
492
|
+
cached() {
|
|
493
|
+
const row = this.state.db.prepare("SELECT * FROM client_project_config WHERE root=?").get(this.root);
|
|
494
|
+
return row ? {
|
|
495
|
+
config: ProjectConfigSchema.parse(JSON.parse(String(row.config_json))),
|
|
496
|
+
hash: String(row.descriptor_hash)
|
|
497
|
+
} : void 0;
|
|
498
|
+
}
|
|
499
|
+
save(config, hash2) {
|
|
500
|
+
this.state.db.prepare(
|
|
501
|
+
"INSERT INTO client_project_config VALUES (?,?,?) ON CONFLICT(root) DO UPDATE SET config_json=excluded.config_json,descriptor_hash=excluded.descriptor_hash"
|
|
502
|
+
).run(this.root, JSON.stringify(config), hash2);
|
|
503
|
+
}
|
|
504
|
+
};
|
|
505
|
+
|
|
506
|
+
// apps/client/project-file.ts
|
|
507
|
+
import { createHash as createHash4 } from "node:crypto";
|
|
508
|
+
|
|
509
|
+
// apps/client/files/local-fs.ts
|
|
510
|
+
import { createHash as createHash3, randomUUID as randomUUID2 } from "node:crypto";
|
|
511
|
+
import { constants } from "node:fs";
|
|
512
|
+
import { lstat, mkdir, open, readdir, realpath, rename, rmdir, unlink } from "node:fs/promises";
|
|
513
|
+
import { dirname, join, relative, sep } from "node:path";
|
|
514
|
+
import { setImmediate } from "node:timers/promises";
|
|
515
|
+
function missing(error) {
|
|
516
|
+
return error instanceof Error && "code" in error && ["ENOENT", "ENOTDIR"].includes(String(error.code));
|
|
517
|
+
}
|
|
518
|
+
function signature(stat2) {
|
|
519
|
+
return [stat2.dev, stat2.ino, stat2.size, stat2.mtimeNs, stat2.ctimeNs].join(":");
|
|
520
|
+
}
|
|
521
|
+
function ignored(path) {
|
|
522
|
+
return path.split("/").some(
|
|
523
|
+
(part) => part === ".git" || part === ".DS_Store" || /^\.teammy(?:-|$)/i.test(part) || /(?:~|\.(?:swp|swx|tmp|part))$/i.test(part)
|
|
524
|
+
);
|
|
525
|
+
}
|
|
526
|
+
async function safePath(root, path, createParents = false) {
|
|
527
|
+
FilePathSchema.parse(path);
|
|
528
|
+
const rootStat = await lstat(root);
|
|
529
|
+
if (!rootStat.isDirectory() || rootStat.isSymbolicLink())
|
|
530
|
+
throw new Error(`Sync root must be an ordinary directory: ${root}`);
|
|
531
|
+
const parts = path.split("/");
|
|
532
|
+
let parent = root;
|
|
533
|
+
for (const part of parts.slice(0, -1)) {
|
|
534
|
+
parent = join(parent, part);
|
|
535
|
+
if (createParents)
|
|
536
|
+
await mkdir(parent).catch((error) => {
|
|
537
|
+
if (error.code !== "EEXIST") throw error;
|
|
538
|
+
});
|
|
539
|
+
const stat2 = await lstat(parent);
|
|
540
|
+
if (stat2.isSymbolicLink()) throw new Error(`Refusing a symlink in sync path: ${path}`);
|
|
541
|
+
if (!stat2.isDirectory())
|
|
542
|
+
throw Object.assign(new Error(`Parent is not a directory: ${path}`), { code: "ENOTDIR" });
|
|
543
|
+
}
|
|
544
|
+
return join(root, ...parts);
|
|
545
|
+
}
|
|
546
|
+
async function metadata(root, path) {
|
|
547
|
+
try {
|
|
548
|
+
const target = await safePath(root, path);
|
|
549
|
+
const stat2 = await lstat(target, { bigint: true });
|
|
550
|
+
if (stat2.isSymbolicLink() || !stat2.isFile() && !stat2.isDirectory())
|
|
551
|
+
throw new Error(`Unsupported filesystem entry: ${path}`);
|
|
552
|
+
return { signature: signature(stat2), directory: stat2.isDirectory() };
|
|
553
|
+
} catch (error) {
|
|
554
|
+
if (missing(error)) return null;
|
|
555
|
+
throw error;
|
|
556
|
+
}
|
|
557
|
+
}
|
|
558
|
+
async function walk(root, prefix = "") {
|
|
559
|
+
const files = /* @__PURE__ */ new Map();
|
|
560
|
+
const issues = [];
|
|
561
|
+
const directory = prefix ? await safePath(root, prefix) : root;
|
|
562
|
+
const rootStat = await lstat(directory);
|
|
563
|
+
if (!rootStat.isDirectory() || rootStat.isSymbolicLink())
|
|
564
|
+
throw new Error("Author directory is unavailable or is not an ordinary directory.");
|
|
565
|
+
const pending = [prefix];
|
|
566
|
+
let visited = 0;
|
|
567
|
+
while (pending.length) {
|
|
568
|
+
const current = pending.pop();
|
|
569
|
+
try {
|
|
570
|
+
const full = current ? await safePath(root, current) : root;
|
|
571
|
+
const directoryStat = await lstat(full);
|
|
572
|
+
if (!directoryStat.isDirectory() || directoryStat.isSymbolicLink())
|
|
573
|
+
throw new Error(`Directory changed while scanning: ${current}`);
|
|
574
|
+
for (const entry of await readdir(full, { withFileTypes: true })) {
|
|
575
|
+
const path = current ? `${current}/${entry.name}` : entry.name;
|
|
576
|
+
if (ignored(path)) continue;
|
|
577
|
+
try {
|
|
578
|
+
FilePathSchema.parse(path);
|
|
579
|
+
const stat2 = await lstat(await safePath(root, path), { bigint: true });
|
|
580
|
+
if (stat2.isDirectory() && !stat2.isSymbolicLink()) pending.push(path);
|
|
581
|
+
else if (stat2.isFile()) files.set(path, signature(stat2));
|
|
582
|
+
else throw new Error(`Unsupported filesystem entry: ${path}`);
|
|
583
|
+
} catch (error) {
|
|
584
|
+
issues.push(
|
|
585
|
+
`${path}: ${error instanceof Error ? error.message : "Cannot inspect entry"}`
|
|
586
|
+
);
|
|
587
|
+
}
|
|
588
|
+
if (++visited % 200 === 0) await setImmediate();
|
|
589
|
+
if (visited > 1e5)
|
|
590
|
+
throw new Error("Scan limit exceeded; no deletions will be inferred.");
|
|
591
|
+
}
|
|
592
|
+
} catch (error) {
|
|
593
|
+
issues.push(error instanceof Error ? error.message : "Directory scan failed");
|
|
594
|
+
}
|
|
595
|
+
}
|
|
596
|
+
return { files, issues, complete: issues.length === 0 };
|
|
597
|
+
}
|
|
598
|
+
async function readStable(root, path) {
|
|
599
|
+
const target = await safePath(root, path);
|
|
600
|
+
const resolvedBefore = await realpath(target);
|
|
601
|
+
const inside = relative(await realpath(root), resolvedBefore);
|
|
602
|
+
if (inside.startsWith(`..${sep}`) || inside === "..")
|
|
603
|
+
throw new Error(`File escapes sync root: ${path}`);
|
|
604
|
+
const file = await open(target, constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0));
|
|
605
|
+
try {
|
|
606
|
+
const before = await file.stat({ bigint: true });
|
|
607
|
+
if (!before.isFile() || before.size > BigInt(MAX_BLOB_BYTES))
|
|
608
|
+
throw new Error(
|
|
609
|
+
`Only regular files up to ${MAX_BLOB_BYTES} bytes can be synchronized: ${path}`
|
|
610
|
+
);
|
|
611
|
+
const buffer = Buffer.alloc(Number(before.size) + 1);
|
|
612
|
+
let count = 0;
|
|
613
|
+
while (count < buffer.length) {
|
|
614
|
+
const { bytesRead } = await file.read(buffer, count, buffer.length - count, null);
|
|
615
|
+
if (!bytesRead) break;
|
|
616
|
+
count += bytesRead;
|
|
617
|
+
}
|
|
618
|
+
const after = await file.stat({ bigint: true });
|
|
619
|
+
const final = await lstat(await safePath(root, path), { bigint: true });
|
|
620
|
+
if (signature(before) !== signature(after) || signature(after) !== signature(final) || count !== Number(before.size) || await realpath(target) !== resolvedBefore)
|
|
621
|
+
throw new Error(`File changed while reading: ${path}`);
|
|
622
|
+
const bytes = buffer.subarray(0, count);
|
|
623
|
+
return {
|
|
624
|
+
bytes,
|
|
625
|
+
signature: signature(after),
|
|
626
|
+
sha256: createHash3("sha256").update(bytes).digest("hex")
|
|
627
|
+
};
|
|
628
|
+
} finally {
|
|
629
|
+
await file.close();
|
|
630
|
+
}
|
|
631
|
+
}
|
|
632
|
+
async function syncDirectory(directory) {
|
|
633
|
+
if (process.platform === "win32") return;
|
|
634
|
+
const handle = await open(directory, "r");
|
|
635
|
+
try {
|
|
636
|
+
await handle.sync();
|
|
637
|
+
} finally {
|
|
638
|
+
await handle.close();
|
|
639
|
+
}
|
|
640
|
+
}
|
|
641
|
+
async function replaceFile(root, path, bytes, expected) {
|
|
642
|
+
const target = await safePath(root, path, true);
|
|
643
|
+
const temporary = join(dirname(target), `.teammy-${randomUUID2()}.tmp`);
|
|
644
|
+
const file = await open(temporary, "wx", 384);
|
|
645
|
+
try {
|
|
646
|
+
try {
|
|
647
|
+
await file.writeFile(bytes);
|
|
648
|
+
await file.sync();
|
|
649
|
+
} finally {
|
|
650
|
+
await file.close();
|
|
651
|
+
}
|
|
652
|
+
const latest = await metadata(root, path);
|
|
653
|
+
if (latest?.directory) {
|
|
654
|
+
if (expected !== null) throw new Error(`Local file changed before replacement: ${path}`);
|
|
655
|
+
await rmdir(target);
|
|
656
|
+
} else if ((latest?.signature ?? null) !== expected)
|
|
657
|
+
throw new Error(`Local file changed before replacement: ${path}`);
|
|
658
|
+
await safePath(root, path);
|
|
659
|
+
await rename(temporary, target);
|
|
660
|
+
let directory = dirname(target);
|
|
661
|
+
for (; ; ) {
|
|
662
|
+
await syncDirectory(directory);
|
|
663
|
+
if (directory === dirname(root)) break;
|
|
664
|
+
directory = dirname(directory);
|
|
665
|
+
}
|
|
666
|
+
} finally {
|
|
667
|
+
await unlink(temporary).catch((error) => {
|
|
668
|
+
if (!missing(error)) throw error;
|
|
669
|
+
});
|
|
670
|
+
}
|
|
671
|
+
}
|
|
672
|
+
async function removeFile(root, path, expected) {
|
|
673
|
+
const latest = await metadata(root, path);
|
|
674
|
+
if (!latest) return;
|
|
675
|
+
if (latest.directory || latest.signature !== expected)
|
|
676
|
+
throw new Error(`Local file changed before deletion: ${path}`);
|
|
677
|
+
const target = await safePath(root, path);
|
|
678
|
+
await unlink(target);
|
|
679
|
+
await syncDirectory(dirname(target));
|
|
680
|
+
let parent = dirname(target);
|
|
681
|
+
while (parent !== root) {
|
|
682
|
+
try {
|
|
683
|
+
await rmdir(parent);
|
|
684
|
+
await syncDirectory(dirname(parent));
|
|
685
|
+
} catch {
|
|
686
|
+
break;
|
|
687
|
+
}
|
|
688
|
+
parent = dirname(parent);
|
|
689
|
+
}
|
|
690
|
+
}
|
|
691
|
+
|
|
692
|
+
// apps/client/project-file.ts
|
|
693
|
+
var name = ".teammy.json";
|
|
694
|
+
async function applyProjectFile(local, input) {
|
|
695
|
+
const config = ProjectConfigSchema.parse(input);
|
|
696
|
+
const binding = local.state.binding(local.root);
|
|
697
|
+
if (!binding || binding.workspaceId !== config.projectId)
|
|
698
|
+
throw new Error("Project configuration does not match the local binding.");
|
|
699
|
+
const cached = local.cached();
|
|
700
|
+
if (cached && config.configRevision < cached.config.configRevision)
|
|
701
|
+
throw new Error("Server project configuration moved backwards.");
|
|
702
|
+
const bytes = Buffer.from(JSON.stringify(config, null, 2) + "\n");
|
|
703
|
+
const hash2 = createHash4("sha256").update(bytes).digest("hex");
|
|
704
|
+
const entry = await metadata(local.root, name);
|
|
705
|
+
let signature2 = null;
|
|
706
|
+
if (entry) {
|
|
707
|
+
if (entry.directory)
|
|
708
|
+
throw new Error("Project configuration path is a directory; preserving it.");
|
|
709
|
+
const existing = await readStable(local.root, name);
|
|
710
|
+
signature2 = existing.signature;
|
|
711
|
+
if (existing.sha256 !== hash2 && existing.sha256 !== cached?.hash)
|
|
712
|
+
throw new Error(
|
|
713
|
+
"Existing .teammy.json is not the expected managed copy. Preserve it elsewhere before retrying."
|
|
714
|
+
);
|
|
715
|
+
if (existing.sha256 === hash2) {
|
|
716
|
+
local.save(config, hash2);
|
|
717
|
+
return;
|
|
718
|
+
}
|
|
719
|
+
}
|
|
720
|
+
await replaceFile(local.root, name, bytes, signature2);
|
|
721
|
+
local.save(config, hash2);
|
|
722
|
+
}
|
|
723
|
+
|
|
724
|
+
// apps/client/control.ts
|
|
725
|
+
var controlClientMigration = "CREATE TABLE client_control(root TEXT PRIMARY KEY REFERENCES connections(root),status_json TEXT NOT NULL);";
|
|
726
|
+
var ControlPlane = class {
|
|
727
|
+
constructor(state, root, version, overrides, apply) {
|
|
728
|
+
this.state = state;
|
|
729
|
+
this.root = root;
|
|
730
|
+
this.overrides = overrides;
|
|
731
|
+
this.apply = apply;
|
|
732
|
+
const binding = state.binding(root);
|
|
733
|
+
if (!binding) throw new Error("Connect this root first.");
|
|
734
|
+
this.client = new HubClient(binding.serverUrl, 1e4, binding, this.stop.signal);
|
|
735
|
+
this.local = new ClientProjectState(state, root);
|
|
736
|
+
const row = state.db.prepare("SELECT status_json FROM client_control WHERE root=?").get(root);
|
|
737
|
+
const prior = row ? DeviceStatusSchema.parse(JSON.parse(String(row.status_json))) : void 0;
|
|
738
|
+
const cached = this.local.cached();
|
|
739
|
+
this.status = {
|
|
740
|
+
clientVersion: version,
|
|
741
|
+
receivedRevision: cached?.config.configRevision ?? null,
|
|
742
|
+
appliedRevision: null,
|
|
743
|
+
effectivePolicy: EffectivePolicySchema.parse({
|
|
744
|
+
...DEFAULT_POLICY,
|
|
745
|
+
...cached?.config.sync,
|
|
746
|
+
...overrides
|
|
747
|
+
}),
|
|
748
|
+
overrides: Object.keys(overrides),
|
|
749
|
+
configError: null,
|
|
750
|
+
reportError: null,
|
|
751
|
+
lastSync: prior?.lastSync ?? null
|
|
752
|
+
};
|
|
753
|
+
apply(this.status.effectivePolicy);
|
|
754
|
+
}
|
|
755
|
+
state;
|
|
756
|
+
root;
|
|
757
|
+
overrides;
|
|
758
|
+
apply;
|
|
759
|
+
client;
|
|
760
|
+
local;
|
|
761
|
+
status;
|
|
762
|
+
enabled = true;
|
|
763
|
+
stop = new AbortController();
|
|
764
|
+
running;
|
|
765
|
+
persist() {
|
|
766
|
+
this.state.db.prepare(
|
|
767
|
+
"INSERT INTO client_control VALUES (?,?) ON CONFLICT(root) DO UPDATE SET status_json=excluded.status_json"
|
|
768
|
+
).run(this.root, JSON.stringify(this.status));
|
|
769
|
+
}
|
|
770
|
+
updateSync(summary) {
|
|
771
|
+
this.status.lastSync = summary;
|
|
772
|
+
this.persist();
|
|
773
|
+
}
|
|
774
|
+
async tick() {
|
|
775
|
+
if (this.stop.signal.aborted) return;
|
|
776
|
+
try {
|
|
777
|
+
const config = await this.client.projectConfig();
|
|
778
|
+
this.status.receivedRevision = config.configRevision;
|
|
779
|
+
await applyProjectFile(this.local, config);
|
|
780
|
+
const effective = EffectivePolicySchema.parse({ ...config.sync, ...this.overrides });
|
|
781
|
+
this.apply(effective);
|
|
782
|
+
this.status.effectivePolicy = effective;
|
|
783
|
+
this.status.appliedRevision = config.configRevision;
|
|
784
|
+
this.status.configError = null;
|
|
785
|
+
} catch (error) {
|
|
786
|
+
if (!this.local.cached() && error instanceof HubRequestError && ["SETUP_REQUIRED", "PROJECT_NOT_FOUND", "NOT_FOUND"].includes(error.code)) {
|
|
787
|
+
this.enabled = false;
|
|
788
|
+
return;
|
|
789
|
+
}
|
|
790
|
+
this.status.configError = (error instanceof Error ? error.message : "Configuration update failed.").slice(0, 2e3);
|
|
791
|
+
}
|
|
792
|
+
this.persist();
|
|
793
|
+
this.status.reportError = null;
|
|
794
|
+
try {
|
|
795
|
+
await this.client.reportDeviceStatus(this.status);
|
|
796
|
+
} catch (error) {
|
|
797
|
+
this.status.reportError = String(error).slice(0, 2e3);
|
|
798
|
+
}
|
|
799
|
+
this.persist();
|
|
800
|
+
}
|
|
801
|
+
start() {
|
|
802
|
+
if (this.running || !this.enabled) return;
|
|
803
|
+
this.running = (async () => {
|
|
804
|
+
while (!this.stop.signal.aborted && this.enabled) {
|
|
805
|
+
try {
|
|
806
|
+
await setTimeout2(3e4, void 0, { signal: this.stop.signal });
|
|
807
|
+
await this.tick();
|
|
808
|
+
} catch (error) {
|
|
809
|
+
if (!this.stop.signal.aborted) {
|
|
810
|
+
this.status.configError = String(error).slice(0, 2e3);
|
|
811
|
+
}
|
|
812
|
+
}
|
|
813
|
+
}
|
|
814
|
+
})();
|
|
815
|
+
}
|
|
816
|
+
async close() {
|
|
817
|
+
this.stop.abort();
|
|
818
|
+
await this.running;
|
|
819
|
+
}
|
|
820
|
+
};
|
|
821
|
+
function readControlStatus(state, root) {
|
|
822
|
+
const row = state.db.prepare("SELECT status_json FROM client_control WHERE root=?").get(root);
|
|
823
|
+
return row ? DeviceStatusSchema.parse(JSON.parse(String(row.status_json))) : null;
|
|
824
|
+
}
|
|
825
|
+
|
|
826
|
+
// apps/client/connection.ts
|
|
827
|
+
import { readFile, mkdir as mkdir2 } from "node:fs/promises";
|
|
828
|
+
import { createInterface } from "node:readline/promises";
|
|
829
|
+
|
|
830
|
+
// apps/client/root-lock.ts
|
|
831
|
+
import { createHash as createHash5 } from "node:crypto";
|
|
832
|
+
import { join as join2 } from "node:path";
|
|
833
|
+
|
|
834
|
+
// packages/state/sqlite.ts
|
|
835
|
+
import { chmodSync, mkdirSync } from "node:fs";
|
|
836
|
+
import { dirname as dirname2 } from "node:path";
|
|
837
|
+
import { DatabaseSync } from "node:sqlite";
|
|
838
|
+
function transaction(db, action) {
|
|
839
|
+
db.exec("BEGIN IMMEDIATE");
|
|
840
|
+
try {
|
|
841
|
+
const result = action();
|
|
842
|
+
db.exec("COMMIT");
|
|
843
|
+
return result;
|
|
844
|
+
} catch (error) {
|
|
845
|
+
db.exec("ROLLBACK");
|
|
846
|
+
throw error;
|
|
847
|
+
}
|
|
848
|
+
}
|
|
849
|
+
function openDatabase(path, migrations) {
|
|
850
|
+
mkdirSync(dirname2(path), { recursive: true, mode: 448 });
|
|
851
|
+
const db = new DatabaseSync(path);
|
|
852
|
+
try {
|
|
853
|
+
chmodSync(path, 384);
|
|
854
|
+
db.exec(
|
|
855
|
+
"PRAGMA busy_timeout = 5000; PRAGMA foreign_keys = ON; PRAGMA journal_mode = WAL; PRAGMA synchronous = FULL;"
|
|
856
|
+
);
|
|
857
|
+
transaction(db, () => {
|
|
858
|
+
const version = Number(db.prepare("PRAGMA user_version").get().user_version);
|
|
859
|
+
if (version > migrations.length)
|
|
860
|
+
throw new Error("Database was created by a newer Teammy version.");
|
|
861
|
+
for (let index = version; index < migrations.length; index++) {
|
|
862
|
+
db.exec(migrations[index]);
|
|
863
|
+
db.exec(`PRAGMA user_version = ${index + 1}`);
|
|
864
|
+
}
|
|
865
|
+
});
|
|
866
|
+
return db;
|
|
867
|
+
} catch (error) {
|
|
868
|
+
db.close();
|
|
869
|
+
throw error;
|
|
870
|
+
}
|
|
871
|
+
}
|
|
872
|
+
|
|
873
|
+
// apps/client/root-lock.ts
|
|
874
|
+
function acquireRootLock(root, stateDirectory) {
|
|
875
|
+
let lock;
|
|
876
|
+
try {
|
|
877
|
+
lock = openDatabase(
|
|
878
|
+
join2(stateDirectory, "locks", `${createHash5("sha256").update(root).digest("hex")}.sqlite3`),
|
|
879
|
+
[]
|
|
880
|
+
);
|
|
881
|
+
lock.exec("PRAGMA busy_timeout=0; BEGIN IMMEDIATE");
|
|
882
|
+
return lock;
|
|
883
|
+
} catch (error) {
|
|
884
|
+
lock?.close();
|
|
885
|
+
if (error instanceof Error && /locked|busy/.test(error.message))
|
|
886
|
+
throw new Error("Another sync or connect process is using this root.");
|
|
887
|
+
throw error;
|
|
888
|
+
}
|
|
889
|
+
}
|
|
890
|
+
|
|
891
|
+
// apps/client/connection.ts
|
|
892
|
+
async function connectProject(state, root, directory, options) {
|
|
893
|
+
const lock = acquireRootLock(root, directory);
|
|
894
|
+
try {
|
|
895
|
+
const transport = new HubClient(options.url);
|
|
896
|
+
const existing = state.binding(root);
|
|
897
|
+
const project = new ClientProjectState(state, root);
|
|
898
|
+
if (existing && existing.serverUrl !== transport.url)
|
|
899
|
+
throw new Error("This root is connected to another server.");
|
|
900
|
+
let member = options.member;
|
|
901
|
+
if (existing && !options.reconnect && !options.tokenFile) {
|
|
902
|
+
if (member && member !== existing.memberId)
|
|
903
|
+
throw new Error("This root belongs to a different member.");
|
|
904
|
+
const client2 = new HubClient(existing.serverUrl, 1e4, existing);
|
|
905
|
+
await client2.members();
|
|
906
|
+
} else if (options.tokenFile) {
|
|
907
|
+
const token = CredentialSchema.parse((await readFile(options.tokenFile, "utf8")).trim());
|
|
908
|
+
const deviceId = state.deviceFor(root);
|
|
909
|
+
const client2 = new HubClient(options.url, 1e4, { token, deviceId });
|
|
910
|
+
const binding2 = await client2.bind(deviceId);
|
|
911
|
+
state.saveBinding(root, client2.url, token, binding2);
|
|
912
|
+
} else {
|
|
913
|
+
member ??= existing?.memberId;
|
|
914
|
+
if (!member && process.stdin.isTTY) {
|
|
915
|
+
const rl = createInterface({ input: process.stdin, output: process.stderr });
|
|
916
|
+
try {
|
|
917
|
+
member = (await rl.question("Member ID: ")).trim();
|
|
918
|
+
} finally {
|
|
919
|
+
rl.close();
|
|
920
|
+
}
|
|
921
|
+
}
|
|
922
|
+
if (!member) throw new Error("--member is required when input is not interactive.");
|
|
923
|
+
NameSchema.parse(member);
|
|
924
|
+
if (existing && existing.memberId !== member)
|
|
925
|
+
throw new Error("This root belongs to a different member.");
|
|
926
|
+
const request = project.enrollment(transport.url, member, Boolean(options.reconnect));
|
|
927
|
+
const binding2 = await transport.enroll(request);
|
|
928
|
+
state.saveBinding(root, transport.url, request.credential, binding2);
|
|
929
|
+
project.completeEnrollment();
|
|
930
|
+
}
|
|
931
|
+
const binding = state.binding(root);
|
|
932
|
+
const client = new HubClient(binding.serverUrl, 1e4, binding);
|
|
933
|
+
const info = await client.serverInfo();
|
|
934
|
+
if (info.capabilities.includes("project-config")) {
|
|
935
|
+
try {
|
|
936
|
+
await applyProjectFile(project, await client.projectConfig());
|
|
937
|
+
} catch (error) {
|
|
938
|
+
if (!(options.tokenFile && error instanceof Error && "code" in error && ["SETUP_REQUIRED", "PROJECT_NOT_FOUND"].includes(String(error.code))))
|
|
939
|
+
throw error;
|
|
940
|
+
}
|
|
941
|
+
}
|
|
942
|
+
const own = await metadata(root, binding.memberId);
|
|
943
|
+
if (own && !own.directory) throw new Error("Member path is not a directory.");
|
|
944
|
+
if (!own) await mkdir2(await safePath(root, binding.memberId));
|
|
945
|
+
const { token: _, ...visible } = binding;
|
|
946
|
+
return { ...visible, configRevision: project.cached()?.config.configRevision ?? null };
|
|
947
|
+
} finally {
|
|
948
|
+
lock.close();
|
|
949
|
+
}
|
|
950
|
+
}
|
|
951
|
+
|
|
952
|
+
// apps/client/main.ts
|
|
953
|
+
import { parseArgs as parseArgs3 } from "node:util";
|
|
954
|
+
import { mkdir as mkdir7, realpath as realpath6 } from "node:fs/promises";
|
|
955
|
+
import { homedir as homedir3 } from "node:os";
|
|
956
|
+
import { join as join11, resolve as resolve6 } from "node:path";
|
|
957
|
+
|
|
958
|
+
// apps/client/state.ts
|
|
959
|
+
import { randomUUID as randomUUID3 } from "node:crypto";
|
|
960
|
+
import { join as join3 } from "node:path";
|
|
961
|
+
import { z as z8 } from "zod";
|
|
962
|
+
|
|
963
|
+
// apps/client/sessions/state.ts
|
|
964
|
+
var sessionClientMigration = String.raw`
|
|
965
|
+
CREATE TABLE session_sources (
|
|
966
|
+
root TEXT NOT NULL REFERENCES connections(root), path TEXT NOT NULL, agent TEXT NOT NULL, project_dir TEXT NOT NULL,
|
|
967
|
+
session_key TEXT, revision INTEGER NOT NULL DEFAULT 0, hash TEXT, signature TEXT,
|
|
968
|
+
checked_at INTEGER NOT NULL DEFAULT 0, verified_at INTEGER NOT NULL DEFAULT 0, last_error TEXT, adopt_existing INTEGER NOT NULL DEFAULT 0,
|
|
969
|
+
pending_json TEXT, pending_signature TEXT, attempts INTEGER NOT NULL DEFAULT 0, retry_at INTEGER NOT NULL DEFAULT 0,
|
|
970
|
+
PRIMARY KEY(root, path), UNIQUE(root, session_key)
|
|
971
|
+
);
|
|
972
|
+
CREATE INDEX session_source_scheduling ON session_sources(root, checked_at, path);
|
|
973
|
+
CREATE TABLE session_discovery (
|
|
974
|
+
root TEXT NOT NULL REFERENCES connections(root), directory TEXT NOT NULL, agent TEXT NOT NULL, project_dir TEXT NOT NULL,
|
|
975
|
+
PRIMARY KEY(root, directory)
|
|
976
|
+
);
|
|
977
|
+
CREATE TABLE session_discovery_cache (
|
|
978
|
+
root TEXT NOT NULL, path TEXT NOT NULL, signature TEXT NOT NULL, PRIMARY KEY(root, path)
|
|
979
|
+
);
|
|
980
|
+
`;
|
|
981
|
+
var SessionState = class {
|
|
982
|
+
constructor(state, root) {
|
|
983
|
+
this.state = state;
|
|
984
|
+
this.root = root;
|
|
985
|
+
}
|
|
986
|
+
state;
|
|
987
|
+
root;
|
|
988
|
+
track(agent, path, projectDir, adoptExisting = false) {
|
|
989
|
+
const prior = this.get(path);
|
|
990
|
+
if (prior && (prior.agent !== agent || prior.projectDir !== projectDir))
|
|
991
|
+
throw new Error("Source is registered with different settings; untrack it first.");
|
|
992
|
+
this.state.db.prepare(
|
|
993
|
+
`INSERT INTO session_sources(root,path,agent,project_dir,adopt_existing) VALUES (?,?,?,?,?)
|
|
994
|
+
ON CONFLICT(root,path) DO UPDATE SET adopt_existing=MAX(adopt_existing,excluded.adopt_existing)`
|
|
995
|
+
).run(this.root, path, agent, projectDir, Number(adoptExisting));
|
|
996
|
+
}
|
|
997
|
+
get(path) {
|
|
998
|
+
return this.sources(path)[0];
|
|
999
|
+
}
|
|
1000
|
+
sources(path, limit = 2147483647) {
|
|
1001
|
+
return this.state.db.prepare(
|
|
1002
|
+
`SELECT * FROM session_sources WHERE root=? ${path === void 0 ? "" : "AND path=?"} ORDER BY checked_at, path LIMIT ?`
|
|
1003
|
+
).all(this.root, ...path === void 0 ? [] : [path], limit).map((row) => ({
|
|
1004
|
+
path: String(row.path),
|
|
1005
|
+
agent: row.agent,
|
|
1006
|
+
projectDir: String(row.project_dir),
|
|
1007
|
+
key: row.session_key,
|
|
1008
|
+
revision: Number(row.revision),
|
|
1009
|
+
hash: row.hash,
|
|
1010
|
+
signature: row.signature,
|
|
1011
|
+
checkedAt: Number(row.checked_at),
|
|
1012
|
+
verifiedAt: Number(row.verified_at),
|
|
1013
|
+
lastError: row.last_error,
|
|
1014
|
+
adoptExisting: Boolean(row.adopt_existing),
|
|
1015
|
+
pending: row.pending_json ? SessionCommitSchema.parse(JSON.parse(String(row.pending_json))) : null,
|
|
1016
|
+
pendingSignature: row.pending_signature,
|
|
1017
|
+
attempts: Number(row.attempts),
|
|
1018
|
+
retryAt: Number(row.retry_at)
|
|
1019
|
+
}));
|
|
1020
|
+
}
|
|
1021
|
+
status() {
|
|
1022
|
+
return this.sources().map(({ pending, pendingSignature: _signature, ...row }) => ({
|
|
1023
|
+
...row,
|
|
1024
|
+
pendingOperation: pending?.operationId ?? null
|
|
1025
|
+
}));
|
|
1026
|
+
}
|
|
1027
|
+
untrack(path) {
|
|
1028
|
+
this.state.db.prepare("DELETE FROM session_sources WHERE root=? AND path=?").run(this.root, path);
|
|
1029
|
+
}
|
|
1030
|
+
checked(path, error = null) {
|
|
1031
|
+
this.state.db.prepare("UPDATE session_sources SET checked_at=?, last_error=? WHERE root=? AND path=?").run(Date.now(), error, this.root, path);
|
|
1032
|
+
}
|
|
1033
|
+
stage(path, request, signature2) {
|
|
1034
|
+
this.state.db.prepare(
|
|
1035
|
+
"UPDATE session_sources SET session_key=?, pending_json=?, pending_signature=?, attempts=0, retry_at=0 WHERE root=? AND path=? AND pending_json IS NULL"
|
|
1036
|
+
).run(request.sessionKey, JSON.stringify(request), signature2, this.root, path);
|
|
1037
|
+
}
|
|
1038
|
+
acknowledge(path, revision, hash2, signature2) {
|
|
1039
|
+
this.state.db.prepare(
|
|
1040
|
+
`UPDATE session_sources SET revision=?, hash=?, signature=?, verified_at=?, pending_json=NULL, pending_signature=NULL, attempts=0, retry_at=0, last_error=NULL, adopt_existing=0 WHERE root=? AND path=?`
|
|
1041
|
+
).run(revision, hash2, signature2, Date.now(), this.root, path);
|
|
1042
|
+
}
|
|
1043
|
+
baseline(path, key, revision) {
|
|
1044
|
+
this.state.db.prepare("UPDATE session_sources SET session_key=?, revision=? WHERE root=? AND path=?").run(key, revision, this.root, path);
|
|
1045
|
+
}
|
|
1046
|
+
retry() {
|
|
1047
|
+
this.state.db.prepare("UPDATE session_sources SET retry_at=0 WHERE root=?").run(this.root);
|
|
1048
|
+
}
|
|
1049
|
+
postpone(source, error, blocked) {
|
|
1050
|
+
const at = blocked ? Number.MAX_SAFE_INTEGER : Date.now() + Math.min(9e5, 6e4 * 2 ** Math.min(source.attempts, 4));
|
|
1051
|
+
this.state.db.prepare(
|
|
1052
|
+
"UPDATE session_sources SET retry_at=?, attempts=attempts+1, last_error=? WHERE root=? AND path=?"
|
|
1053
|
+
).run(at, error, this.root, source.path);
|
|
1054
|
+
}
|
|
1055
|
+
discover(agent, directory, projectDir) {
|
|
1056
|
+
this.state.db.prepare(
|
|
1057
|
+
"INSERT INTO session_discovery VALUES (?,?,?,?) ON CONFLICT(root,directory) DO UPDATE SET agent=excluded.agent,project_dir=excluded.project_dir"
|
|
1058
|
+
).run(this.root, directory, agent, projectDir);
|
|
1059
|
+
this.state.db.prepare("DELETE FROM session_discovery_cache WHERE root=?").run(this.root);
|
|
1060
|
+
}
|
|
1061
|
+
directories() {
|
|
1062
|
+
return this.state.db.prepare("SELECT * FROM session_discovery WHERE root=? ORDER BY directory").all(this.root).map((row) => ({
|
|
1063
|
+
directory: String(row.directory),
|
|
1064
|
+
agent: row.agent,
|
|
1065
|
+
projectDir: String(row.project_dir)
|
|
1066
|
+
}));
|
|
1067
|
+
}
|
|
1068
|
+
};
|
|
1069
|
+
|
|
1070
|
+
// apps/client/files/state.ts
|
|
1071
|
+
var syncMigration = String.raw`
|
|
1072
|
+
CREATE TABLE file_index (
|
|
1073
|
+
root TEXT NOT NULL, path TEXT NOT NULL, signature TEXT, dirty INTEGER NOT NULL DEFAULT 1,
|
|
1074
|
+
event_pending INTEGER NOT NULL DEFAULT 0, generation INTEGER NOT NULL DEFAULT 1,
|
|
1075
|
+
observed_at REAL NOT NULL, hash TEXT, verified_at REAL NOT NULL DEFAULT 0,
|
|
1076
|
+
PRIMARY KEY(root, path)
|
|
1077
|
+
);
|
|
1078
|
+
CREATE INDEX dirty_files ON file_index(root, dirty, observed_at);
|
|
1079
|
+
CREATE INDEX audit_files ON file_index(root, dirty, verified_at);
|
|
1080
|
+
CREATE TABLE received_files (root TEXT NOT NULL, owner TEXT NOT NULL, path TEXT NOT NULL, hash TEXT NOT NULL, size INTEGER NOT NULL, PRIMARY KEY(root, owner, path));
|
|
1081
|
+
CREATE TABLE received_progress (root TEXT NOT NULL, owner TEXT NOT NULL, revision INTEGER NOT NULL, PRIMARY KEY(root, owner));
|
|
1082
|
+
CREATE TABLE receive_jobs (root TEXT NOT NULL, owner TEXT NOT NULL, revision INTEGER NOT NULL, changes_json TEXT NOT NULL, next_index INTEGER NOT NULL DEFAULT 0, PRIMARY KEY(root, owner));
|
|
1083
|
+
CREATE TABLE sync_status (root TEXT PRIMARY KEY, status_json TEXT NOT NULL);
|
|
1084
|
+
`;
|
|
1085
|
+
var SyncState = class {
|
|
1086
|
+
constructor(state, root) {
|
|
1087
|
+
this.root = root;
|
|
1088
|
+
this.db = state.db;
|
|
1089
|
+
}
|
|
1090
|
+
root;
|
|
1091
|
+
db;
|
|
1092
|
+
index(onlyDirty = false) {
|
|
1093
|
+
return this.db.prepare(`SELECT * FROM file_index WHERE root=?${onlyDirty ? " AND dirty=1" : ""}`).all(this.root).map((row) => ({
|
|
1094
|
+
path: String(row.path),
|
|
1095
|
+
signature: row.signature === null ? null : String(row.signature),
|
|
1096
|
+
dirty: Boolean(row.dirty),
|
|
1097
|
+
eventPending: Boolean(row.event_pending),
|
|
1098
|
+
generation: Number(row.generation),
|
|
1099
|
+
observedAt: Number(row.observed_at),
|
|
1100
|
+
hash: row.hash === null ? null : String(row.hash),
|
|
1101
|
+
verifiedAt: Number(row.verified_at)
|
|
1102
|
+
}));
|
|
1103
|
+
}
|
|
1104
|
+
audit(before, limit) {
|
|
1105
|
+
return this.db.prepare(
|
|
1106
|
+
"SELECT path FROM file_index WHERE root=? AND dirty=0 AND signature IS NOT NULL AND verified_at<=? ORDER BY verified_at LIMIT ?"
|
|
1107
|
+
).all(this.root, before, limit).map((row) => this.get(String(row.path)));
|
|
1108
|
+
}
|
|
1109
|
+
get(path) {
|
|
1110
|
+
const row = this.db.prepare("SELECT * FROM file_index WHERE root=? AND path=?").get(this.root, path);
|
|
1111
|
+
return row ? {
|
|
1112
|
+
path,
|
|
1113
|
+
signature: row.signature === null ? null : String(row.signature),
|
|
1114
|
+
dirty: Boolean(row.dirty),
|
|
1115
|
+
eventPending: Boolean(row.event_pending),
|
|
1116
|
+
generation: Number(row.generation),
|
|
1117
|
+
observedAt: Number(row.observed_at),
|
|
1118
|
+
hash: row.hash === null ? null : String(row.hash),
|
|
1119
|
+
verifiedAt: Number(row.verified_at)
|
|
1120
|
+
} : void 0;
|
|
1121
|
+
}
|
|
1122
|
+
restartWaiting(now) {
|
|
1123
|
+
this.db.prepare("UPDATE file_index SET observed_at=? WHERE root=? AND dirty=1").run(now, this.root);
|
|
1124
|
+
}
|
|
1125
|
+
event(path, now) {
|
|
1126
|
+
this.db.prepare(
|
|
1127
|
+
`INSERT INTO file_index(root, path, observed_at, event_pending) VALUES (?, ?, ?, 1)
|
|
1128
|
+
ON CONFLICT(root,path) DO UPDATE SET dirty=1, event_pending=1, generation=generation+1, observed_at=excluded.observed_at`
|
|
1129
|
+
).run(this.root, path, now);
|
|
1130
|
+
this.db.prepare(
|
|
1131
|
+
"UPDATE file_index SET dirty=1, event_pending=1, generation=generation+1, observed_at=? WHERE root=? AND substr(path,1,length(?))=?"
|
|
1132
|
+
).run(now, this.root, `${path}/`, `${path}/`);
|
|
1133
|
+
}
|
|
1134
|
+
observe(path, signature2, now) {
|
|
1135
|
+
const prior = this.get(path);
|
|
1136
|
+
if (!prior) {
|
|
1137
|
+
if (signature2 !== null)
|
|
1138
|
+
this.db.prepare("INSERT INTO file_index(root,path,signature,observed_at) VALUES (?,?,?,?)").run(this.root, path, signature2, now);
|
|
1139
|
+
} else if (signature2 !== prior.signature) {
|
|
1140
|
+
this.db.prepare(
|
|
1141
|
+
"UPDATE file_index SET signature=?, dirty=1, generation=generation+1, observed_at=?, event_pending=0 WHERE root=? AND path=?"
|
|
1142
|
+
).run(signature2, prior.eventPending ? prior.observedAt : now, this.root, path);
|
|
1143
|
+
} else if (prior.eventPending) {
|
|
1144
|
+
this.db.prepare("UPDATE file_index SET event_pending=0 WHERE root=? AND path=?").run(this.root, path);
|
|
1145
|
+
}
|
|
1146
|
+
}
|
|
1147
|
+
clean(path, generation, hash2, now) {
|
|
1148
|
+
this.db.prepare(
|
|
1149
|
+
"UPDATE file_index SET dirty=0, hash=?, verified_at=? WHERE root=? AND path=? AND generation=?"
|
|
1150
|
+
).run(hash2, now, this.root, path, generation);
|
|
1151
|
+
}
|
|
1152
|
+
verified(path, now) {
|
|
1153
|
+
this.db.prepare("UPDATE file_index SET verified_at=? WHERE root=? AND path=?").run(now, this.root, path);
|
|
1154
|
+
}
|
|
1155
|
+
revision(owner) {
|
|
1156
|
+
const row = this.db.prepare("SELECT revision FROM received_progress WHERE root=? AND owner=?").get(this.root, owner);
|
|
1157
|
+
return row ? Number(row.revision) : void 0;
|
|
1158
|
+
}
|
|
1159
|
+
received(owner, path) {
|
|
1160
|
+
const row = this.db.prepare("SELECT hash,size FROM received_files WHERE root=? AND owner=? AND path=?").get(this.root, owner, path);
|
|
1161
|
+
return row ? { sha256: String(row.hash), size: Number(row.size) } : void 0;
|
|
1162
|
+
}
|
|
1163
|
+
job(owner) {
|
|
1164
|
+
const row = this.db.prepare("SELECT * FROM receive_jobs WHERE root=? AND owner=?").get(this.root, owner);
|
|
1165
|
+
return row ? {
|
|
1166
|
+
revision: Number(row.revision),
|
|
1167
|
+
changes: JSON.parse(String(row.changes_json)),
|
|
1168
|
+
nextIndex: Number(row.next_index)
|
|
1169
|
+
} : void 0;
|
|
1170
|
+
}
|
|
1171
|
+
queue(owner, revision, changes) {
|
|
1172
|
+
this.db.prepare("INSERT INTO receive_jobs(root, owner, revision, changes_json) VALUES (?, ?, ?, ?)").run(this.root, owner, revision, JSON.stringify(changes));
|
|
1173
|
+
}
|
|
1174
|
+
applied(owner, change, nextIndex) {
|
|
1175
|
+
transaction(this.db, () => {
|
|
1176
|
+
if (change.kind === "delete")
|
|
1177
|
+
this.db.prepare("DELETE FROM received_files WHERE root=? AND owner=? AND path=?").run(this.root, owner, change.path);
|
|
1178
|
+
else
|
|
1179
|
+
this.db.prepare(
|
|
1180
|
+
"INSERT INTO received_files VALUES (?,?,?,?,?) ON CONFLICT(root,owner,path) DO UPDATE SET hash=excluded.hash,size=excluded.size"
|
|
1181
|
+
).run(this.root, owner, change.path, change.sha256, change.size);
|
|
1182
|
+
this.db.prepare("UPDATE receive_jobs SET next_index=? WHERE root=? AND owner=?").run(nextIndex, this.root, owner);
|
|
1183
|
+
});
|
|
1184
|
+
}
|
|
1185
|
+
finish(owner) {
|
|
1186
|
+
transaction(this.db, () => {
|
|
1187
|
+
const job = this.job(owner);
|
|
1188
|
+
if (!job || job.nextIndex !== job.changes.length)
|
|
1189
|
+
throw new Error("Receive batch is not fully applied.");
|
|
1190
|
+
this.db.prepare(
|
|
1191
|
+
"INSERT INTO received_progress VALUES (?,?,?) ON CONFLICT(root,owner) DO UPDATE SET revision=excluded.revision"
|
|
1192
|
+
).run(this.root, owner, job.revision);
|
|
1193
|
+
this.db.prepare("DELETE FROM receive_jobs WHERE root=? AND owner=?").run(this.root, owner);
|
|
1194
|
+
});
|
|
1195
|
+
}
|
|
1196
|
+
saveStatus(status) {
|
|
1197
|
+
this.db.prepare(
|
|
1198
|
+
"INSERT INTO sync_status VALUES (?,?) ON CONFLICT(root) DO UPDATE SET status_json=excluded.status_json"
|
|
1199
|
+
).run(this.root, JSON.stringify(status));
|
|
1200
|
+
}
|
|
1201
|
+
status() {
|
|
1202
|
+
const row = this.db.prepare("SELECT status_json FROM sync_status WHERE root=?").get(this.root);
|
|
1203
|
+
return row ? JSON.parse(String(row.status_json)) : null;
|
|
1204
|
+
}
|
|
1205
|
+
};
|
|
1206
|
+
|
|
1207
|
+
// apps/client/state.ts
|
|
1208
|
+
var LocalBindingSchema = BindingSchema.extend({
|
|
1209
|
+
root: z8.string(),
|
|
1210
|
+
serverUrl: z8.string(),
|
|
1211
|
+
token: z8.string()
|
|
1212
|
+
});
|
|
1213
|
+
var ClientState = class {
|
|
1214
|
+
db;
|
|
1215
|
+
constructor(directory) {
|
|
1216
|
+
this.db = openDatabase(join3(directory, "client.sqlite3"), [
|
|
1217
|
+
String.raw`
|
|
1218
|
+
CREATE TABLE connections (root TEXT PRIMARY KEY, device_id TEXT NOT NULL, binding_json TEXT);
|
|
1219
|
+
`,
|
|
1220
|
+
String.raw`
|
|
1221
|
+
CREATE TABLE publications (root TEXT PRIMARY KEY REFERENCES connections(root), revision INTEGER NOT NULL DEFAULT 0, initialized INTEGER NOT NULL DEFAULT 0);
|
|
1222
|
+
CREATE TABLE owner_files (root TEXT NOT NULL REFERENCES connections(root), path TEXT NOT NULL, hash TEXT NOT NULL, size INTEGER NOT NULL, PRIMARY KEY(root, path));
|
|
1223
|
+
CREATE TABLE outbox (root TEXT PRIMARY KEY REFERENCES connections(root), request_json TEXT NOT NULL, attempts INTEGER NOT NULL DEFAULT 0, retry_at INTEGER NOT NULL DEFAULT 0, last_error TEXT);
|
|
1224
|
+
`,
|
|
1225
|
+
syncMigration,
|
|
1226
|
+
sessionClientMigration,
|
|
1227
|
+
projectClientMigration,
|
|
1228
|
+
controlClientMigration
|
|
1229
|
+
]);
|
|
1230
|
+
}
|
|
1231
|
+
close() {
|
|
1232
|
+
this.db.close();
|
|
1233
|
+
}
|
|
1234
|
+
deviceFor(root) {
|
|
1235
|
+
this.db.prepare("INSERT OR IGNORE INTO connections (root, device_id) VALUES (?, ?)").run(root, randomUUID3());
|
|
1236
|
+
return String(
|
|
1237
|
+
this.db.prepare("SELECT device_id FROM connections WHERE root=?").get(root).device_id
|
|
1238
|
+
);
|
|
1239
|
+
}
|
|
1240
|
+
binding(root) {
|
|
1241
|
+
const row = this.db.prepare("SELECT binding_json FROM connections WHERE root=?").get(root);
|
|
1242
|
+
return row?.binding_json ? LocalBindingSchema.parse(JSON.parse(String(row.binding_json))) : void 0;
|
|
1243
|
+
}
|
|
1244
|
+
saveBinding(root, serverUrl, token, binding) {
|
|
1245
|
+
const prior = this.binding(root);
|
|
1246
|
+
if (prior && (prior.workspaceId !== binding.workspaceId || prior.memberId !== binding.memberId || prior.serverUrl !== serverUrl)) {
|
|
1247
|
+
throw new Error(
|
|
1248
|
+
"This root is already connected to another member or server. Use a different local root."
|
|
1249
|
+
);
|
|
1250
|
+
}
|
|
1251
|
+
if (this.deviceFor(root) !== binding.deviceId)
|
|
1252
|
+
throw new Error("Server returned a different device identity.");
|
|
1253
|
+
const local = LocalBindingSchema.parse({ ...binding, root, serverUrl, token });
|
|
1254
|
+
this.db.prepare("UPDATE connections SET binding_json=? WHERE root=?").run(JSON.stringify(local), root);
|
|
1255
|
+
return local;
|
|
1256
|
+
}
|
|
1257
|
+
publication(root) {
|
|
1258
|
+
this.db.prepare("INSERT OR IGNORE INTO publications(root) VALUES (?)").run(root);
|
|
1259
|
+
const row = this.db.prepare("SELECT revision, initialized FROM publications WHERE root=?").get(root);
|
|
1260
|
+
return { revision: Number(row.revision), initialized: Boolean(row.initialized) };
|
|
1261
|
+
}
|
|
1262
|
+
initializePublication(root, revision, files) {
|
|
1263
|
+
transaction(this.db, () => {
|
|
1264
|
+
if (this.pending(root))
|
|
1265
|
+
throw new Error("Cannot replace a baseline with a publication pending.");
|
|
1266
|
+
this.db.prepare(
|
|
1267
|
+
"INSERT INTO publications VALUES (?, ?, 1) ON CONFLICT(root) DO UPDATE SET revision=excluded.revision, initialized=1"
|
|
1268
|
+
).run(root, revision);
|
|
1269
|
+
this.db.prepare("DELETE FROM owner_files WHERE root=?").run(root);
|
|
1270
|
+
for (const file of files)
|
|
1271
|
+
this.db.prepare("INSERT INTO owner_files VALUES (?, ?, ?, ?)").run(root, file.path, file.sha256, file.size);
|
|
1272
|
+
});
|
|
1273
|
+
}
|
|
1274
|
+
ownerFiles(root) {
|
|
1275
|
+
return new Map(
|
|
1276
|
+
this.db.prepare("SELECT path, hash, size FROM owner_files WHERE root=?").all(root).map((row) => [String(row.path), { sha256: String(row.hash), size: Number(row.size) }])
|
|
1277
|
+
);
|
|
1278
|
+
}
|
|
1279
|
+
pending(root) {
|
|
1280
|
+
const row = this.db.prepare("SELECT request_json, attempts, retry_at, last_error FROM outbox WHERE root=?").get(root);
|
|
1281
|
+
return row ? {
|
|
1282
|
+
batch: FileCommitSchema.parse(JSON.parse(String(row.request_json))),
|
|
1283
|
+
attempts: Number(row.attempts),
|
|
1284
|
+
retryAt: Number(row.retry_at),
|
|
1285
|
+
lastError: row.last_error === null ? null : String(row.last_error)
|
|
1286
|
+
} : void 0;
|
|
1287
|
+
}
|
|
1288
|
+
enqueue(root, batch) {
|
|
1289
|
+
const fixed = FileCommitSchema.parse(batch);
|
|
1290
|
+
transaction(this.db, () => {
|
|
1291
|
+
const publication = this.publication(root);
|
|
1292
|
+
if (!publication.initialized || publication.revision !== fixed.parentRevision)
|
|
1293
|
+
throw new Error("Publication baseline is not ready.");
|
|
1294
|
+
if (this.pending(root)) throw new Error("A publication is already pending.");
|
|
1295
|
+
this.db.prepare("INSERT INTO outbox(root, request_json) VALUES (?, ?)").run(root, JSON.stringify(fixed));
|
|
1296
|
+
});
|
|
1297
|
+
}
|
|
1298
|
+
acknowledge(root, result) {
|
|
1299
|
+
transaction(this.db, () => {
|
|
1300
|
+
const pending = this.pending(root);
|
|
1301
|
+
if (!pending || pending.batch.operationId !== result.operationId || result.revision < pending.batch.parentRevision || result.revision > pending.batch.parentRevision + 1)
|
|
1302
|
+
throw new Error("Unexpected publication acknowledgement.");
|
|
1303
|
+
for (const change of pending.batch.changes) {
|
|
1304
|
+
if (change.kind === "delete")
|
|
1305
|
+
this.db.prepare("DELETE FROM owner_files WHERE root=? AND path=?").run(root, change.path);
|
|
1306
|
+
else
|
|
1307
|
+
this.db.prepare(
|
|
1308
|
+
"INSERT INTO owner_files VALUES (?, ?, ?, ?) ON CONFLICT(root, path) DO UPDATE SET hash=excluded.hash, size=excluded.size"
|
|
1309
|
+
).run(root, change.path, change.sha256, change.size);
|
|
1310
|
+
}
|
|
1311
|
+
this.db.prepare("UPDATE publications SET revision=? WHERE root=?").run(result.revision, root);
|
|
1312
|
+
this.db.prepare("DELETE FROM outbox WHERE root=?").run(root);
|
|
1313
|
+
});
|
|
1314
|
+
}
|
|
1315
|
+
postpone(root, operationId, reason, now, blocked) {
|
|
1316
|
+
const pending = this.pending(root);
|
|
1317
|
+
if (!pending || pending.batch.operationId !== operationId) return;
|
|
1318
|
+
const retryAt = blocked ? Number.MAX_SAFE_INTEGER : now + Math.min(9e5, 6e4 * 2 ** Math.min(pending.attempts, 4));
|
|
1319
|
+
this.db.prepare("UPDATE outbox SET attempts=attempts+1, retry_at=?, last_error=? WHERE root=?").run(retryAt, reason, root);
|
|
1320
|
+
}
|
|
1321
|
+
retryPublication(root) {
|
|
1322
|
+
this.db.prepare("UPDATE outbox SET retry_at=0, last_error=NULL WHERE root=?").run(root);
|
|
1323
|
+
}
|
|
1324
|
+
};
|
|
1325
|
+
|
|
1326
|
+
// apps/client/sessions/command.ts
|
|
1327
|
+
import { createHash as createHash7 } from "node:crypto";
|
|
1328
|
+
import { realpath as realpath3 } from "node:fs/promises";
|
|
1329
|
+
import { homedir } from "node:os";
|
|
1330
|
+
import { dirname as dirname3, join as join5, resolve as resolve2 } from "node:path";
|
|
1331
|
+
import { parseArgs } from "node:util";
|
|
1332
|
+
|
|
1333
|
+
// apps/client/state-location.ts
|
|
1334
|
+
import { mkdir as mkdir3, realpath as realpath2 } from "node:fs/promises";
|
|
1335
|
+
import { isAbsolute, relative as relative2, resolve, sep as sep2 } from "node:path";
|
|
1336
|
+
async function stateLocation(root, directory) {
|
|
1337
|
+
await mkdir3(resolve(directory), { recursive: true, mode: 448 });
|
|
1338
|
+
const canonical = await realpath2(directory);
|
|
1339
|
+
const contains = (parent, child) => {
|
|
1340
|
+
const path = relative2(parent, child);
|
|
1341
|
+
return path === "" || !isAbsolute(path) && path !== ".." && !path.startsWith(`..${sep2}`);
|
|
1342
|
+
};
|
|
1343
|
+
if (contains(root, canonical) || contains(canonical, root))
|
|
1344
|
+
throw new Error(
|
|
1345
|
+
"Client state and the shared root must be separate directories, with neither containing the other."
|
|
1346
|
+
);
|
|
1347
|
+
return canonical;
|
|
1348
|
+
}
|
|
1349
|
+
|
|
1350
|
+
// apps/client/sessions/hooks.ts
|
|
1351
|
+
import { createHash as createHash6, randomUUID as randomUUID4 } from "node:crypto";
|
|
1352
|
+
import { mkdir as mkdir4, open as open2, opendir, rename as rename2, unlink as unlink2 } from "node:fs/promises";
|
|
1353
|
+
import { isAbsolute as isAbsolute2, join as join4 } from "node:path";
|
|
1354
|
+
import { z as z9 } from "zod";
|
|
1355
|
+
|
|
1356
|
+
// apps/client/sessions/normalize.ts
|
|
1357
|
+
function object(value) {
|
|
1358
|
+
return value && typeof value === "object" && !Array.isArray(value) ? value : {};
|
|
1359
|
+
}
|
|
1360
|
+
function textContent(value) {
|
|
1361
|
+
if (typeof value === "string") return value;
|
|
1362
|
+
if (!Array.isArray(value)) return "";
|
|
1363
|
+
return value.flatMap((block) => {
|
|
1364
|
+
const part = object(block);
|
|
1365
|
+
return ["text", "input_text", "output_text"].includes(String(part.type)) && typeof part.text === "string" ? [part.text] : [];
|
|
1366
|
+
}).join("\n");
|
|
1367
|
+
}
|
|
1368
|
+
function parseJsonl(text) {
|
|
1369
|
+
const lines = text.split("\n");
|
|
1370
|
+
const rows = [];
|
|
1371
|
+
for (let index = 0; index < lines.length; index++) {
|
|
1372
|
+
const line = lines[index];
|
|
1373
|
+
if (!line.trim()) continue;
|
|
1374
|
+
if (Buffer.byteLength(line) > 8 * 1024 * 1024) throw new Error("Session record exceeds 8 MiB.");
|
|
1375
|
+
let parsed;
|
|
1376
|
+
try {
|
|
1377
|
+
parsed = JSON.parse(line);
|
|
1378
|
+
} catch {
|
|
1379
|
+
if (index === lines.length - 1 && !text.endsWith("\n")) return { rows, incomplete: true };
|
|
1380
|
+
throw new Error(`Invalid session JSON at line ${index + 1}.`);
|
|
1381
|
+
}
|
|
1382
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed))
|
|
1383
|
+
throw new Error(`Invalid session record at line ${index + 1}.`);
|
|
1384
|
+
rows.push({ line: index + 1, value: object(parsed) });
|
|
1385
|
+
}
|
|
1386
|
+
return { rows, incomplete: false };
|
|
1387
|
+
}
|
|
1388
|
+
function normalizeJsonl(agent, text) {
|
|
1389
|
+
const { rows, incomplete } = parseJsonl(text);
|
|
1390
|
+
let nativeId;
|
|
1391
|
+
let cwd;
|
|
1392
|
+
const messages = [];
|
|
1393
|
+
const ids = /* @__PURE__ */ new Map();
|
|
1394
|
+
const add = (role, content, id, line) => {
|
|
1395
|
+
if (role !== "user" && role !== "assistant") return;
|
|
1396
|
+
const text2 = textContent(content);
|
|
1397
|
+
if (!text2.trim()) return;
|
|
1398
|
+
const base = typeof id === "string" && id ? id : `line:${line}`;
|
|
1399
|
+
const count = (ids.get(base) ?? 0) + 1;
|
|
1400
|
+
ids.set(base, count);
|
|
1401
|
+
messages.push({ id: `${base}#${count}`, role, text: text2, source: `line:${line}` });
|
|
1402
|
+
};
|
|
1403
|
+
for (const { line, value: row } of rows) {
|
|
1404
|
+
if (agent === "claude-code") {
|
|
1405
|
+
if (row.isSidechain === true) continue;
|
|
1406
|
+
if (row.sessionId) {
|
|
1407
|
+
if (nativeId && nativeId !== row.sessionId)
|
|
1408
|
+
throw new Error("Mixed Claude session identities.");
|
|
1409
|
+
nativeId = row.sessionId;
|
|
1410
|
+
}
|
|
1411
|
+
if (row.cwd && !cwd) cwd = row.cwd;
|
|
1412
|
+
if (row.type === "user" || row.type === "assistant") {
|
|
1413
|
+
const message2 = object(row.message);
|
|
1414
|
+
add(message2.role ?? row.type, message2.content, row.uuid, line);
|
|
1415
|
+
}
|
|
1416
|
+
} else if (agent === "codex") {
|
|
1417
|
+
const payload = object(row.payload);
|
|
1418
|
+
if (row.type === "session_meta") {
|
|
1419
|
+
if (nativeId && nativeId !== payload.id) throw new Error("Mixed Codex session identities.");
|
|
1420
|
+
nativeId = payload.id;
|
|
1421
|
+
cwd = payload.cwd;
|
|
1422
|
+
}
|
|
1423
|
+
if (row.type === "response_item" && payload.type === "message" && payload.channel !== "analysis")
|
|
1424
|
+
add(payload.role, payload.content, payload.id, line);
|
|
1425
|
+
} else if (agent === "pi") {
|
|
1426
|
+
if (row.type === "session") {
|
|
1427
|
+
if (row.version !== 3) throw new Error("Unsupported Pi session version (expected 3).");
|
|
1428
|
+
if (nativeId) throw new Error("Multiple Pi session headers.");
|
|
1429
|
+
nativeId = row.id;
|
|
1430
|
+
cwd = row.cwd;
|
|
1431
|
+
}
|
|
1432
|
+
if (row.type === "message") {
|
|
1433
|
+
const message2 = object(row.message);
|
|
1434
|
+
add(message2.role, message2.content, row.id, line);
|
|
1435
|
+
}
|
|
1436
|
+
} else {
|
|
1437
|
+
if (row.type === "session") {
|
|
1438
|
+
if (row.version !== 0) throw new Error("Unsupported DSH session version (expected 0).");
|
|
1439
|
+
if (nativeId) throw new Error("Multiple DSH session headers.");
|
|
1440
|
+
if (Number(row.delegationDepth ?? 0) > 0)
|
|
1441
|
+
throw new Error("DSH delegated sessions are not collected.");
|
|
1442
|
+
nativeId = row.id;
|
|
1443
|
+
cwd = row.cwd;
|
|
1444
|
+
}
|
|
1445
|
+
if (row.type === "user/message" || row.type === "assistant/message") {
|
|
1446
|
+
const data = object(row.data);
|
|
1447
|
+
const message2 = row.type === "assistant/message" ? object(data.message) : data;
|
|
1448
|
+
if (object(message2.source).kind === "plugin") continue;
|
|
1449
|
+
add(
|
|
1450
|
+
row.type === "user/message" ? "user" : "assistant",
|
|
1451
|
+
message2.content,
|
|
1452
|
+
`seq:${row.seq}`,
|
|
1453
|
+
line
|
|
1454
|
+
);
|
|
1455
|
+
}
|
|
1456
|
+
}
|
|
1457
|
+
}
|
|
1458
|
+
return {
|
|
1459
|
+
snapshot: SessionSnapshotSchema.parse({
|
|
1460
|
+
formatVersion: 1,
|
|
1461
|
+
adapterVersion: 1,
|
|
1462
|
+
agent,
|
|
1463
|
+
nativeId,
|
|
1464
|
+
cwd,
|
|
1465
|
+
messages
|
|
1466
|
+
}),
|
|
1467
|
+
incomplete
|
|
1468
|
+
};
|
|
1469
|
+
}
|
|
1470
|
+
|
|
1471
|
+
// apps/client/sessions/hooks.ts
|
|
1472
|
+
var HintSchema = z9.object({
|
|
1473
|
+
agent: AgentSchema,
|
|
1474
|
+
path: z9.string().min(1).max(4096).refine(isAbsolute2),
|
|
1475
|
+
projectDir: z9.string().min(1).max(4096).refine(isAbsolute2)
|
|
1476
|
+
});
|
|
1477
|
+
function inbox(directory, root) {
|
|
1478
|
+
return join4(directory, "session-hints", createHash6("sha256").update(root).digest("hex"));
|
|
1479
|
+
}
|
|
1480
|
+
async function recordHint(directory, root, input) {
|
|
1481
|
+
const hint = HintSchema.parse(input);
|
|
1482
|
+
const targetDir = inbox(directory, root);
|
|
1483
|
+
await mkdir4(targetDir, { recursive: true, mode: 448 });
|
|
1484
|
+
const target = join4(
|
|
1485
|
+
targetDir,
|
|
1486
|
+
`${createHash6("sha256").update(JSON.stringify(hint)).digest("hex")}.json`
|
|
1487
|
+
);
|
|
1488
|
+
const temporary = `${target}.${randomUUID4()}.tmp`;
|
|
1489
|
+
const handle = await open2(temporary, "wx", 384);
|
|
1490
|
+
try {
|
|
1491
|
+
await handle.writeFile(JSON.stringify(hint));
|
|
1492
|
+
} finally {
|
|
1493
|
+
await handle.close();
|
|
1494
|
+
}
|
|
1495
|
+
await rename2(temporary, target);
|
|
1496
|
+
}
|
|
1497
|
+
async function consumeHints(directory, root, sessions) {
|
|
1498
|
+
const issues = [];
|
|
1499
|
+
let dir;
|
|
1500
|
+
try {
|
|
1501
|
+
dir = await opendir(inbox(directory, root));
|
|
1502
|
+
} catch (error) {
|
|
1503
|
+
if (error.code === "ENOENT") return issues;
|
|
1504
|
+
throw error;
|
|
1505
|
+
}
|
|
1506
|
+
let count = 0;
|
|
1507
|
+
for await (const entry of dir) {
|
|
1508
|
+
if (!entry.isFile() || !entry.name.endsWith(".json")) continue;
|
|
1509
|
+
if (count++ >= 50) break;
|
|
1510
|
+
const path = join4(inbox(directory, root), entry.name);
|
|
1511
|
+
try {
|
|
1512
|
+
const handle = await open2(path, "r");
|
|
1513
|
+
let body;
|
|
1514
|
+
try {
|
|
1515
|
+
if ((await handle.stat()).size > 16384) throw new Error("Oversized hook hint.");
|
|
1516
|
+
body = await handle.readFile("utf8");
|
|
1517
|
+
} finally {
|
|
1518
|
+
await handle.close();
|
|
1519
|
+
}
|
|
1520
|
+
const hint = HintSchema.parse(JSON.parse(body));
|
|
1521
|
+
sessions.track(hint.agent, hint.path, hint.projectDir);
|
|
1522
|
+
await unlink2(path);
|
|
1523
|
+
} catch (error) {
|
|
1524
|
+
issues.push(
|
|
1525
|
+
`hook hint ${entry.name}: ${error instanceof Error ? error.message : "invalid hint"}`
|
|
1526
|
+
);
|
|
1527
|
+
}
|
|
1528
|
+
}
|
|
1529
|
+
return issues;
|
|
1530
|
+
}
|
|
1531
|
+
async function hookPayload() {
|
|
1532
|
+
let size = 0;
|
|
1533
|
+
const parts = [];
|
|
1534
|
+
const timeout = setTimeout(() => process.stdin.destroy(new Error("Hook input timeout.")), 500);
|
|
1535
|
+
try {
|
|
1536
|
+
for await (const part of process.stdin) {
|
|
1537
|
+
const bytes = Buffer.from(part);
|
|
1538
|
+
size += bytes.length;
|
|
1539
|
+
if (size > 16384) throw new Error("Hook payload too large.");
|
|
1540
|
+
parts.push(bytes);
|
|
1541
|
+
}
|
|
1542
|
+
return object(JSON.parse(Buffer.concat(parts).toString("utf8")));
|
|
1543
|
+
} finally {
|
|
1544
|
+
clearTimeout(timeout);
|
|
1545
|
+
}
|
|
1546
|
+
}
|
|
1547
|
+
|
|
1548
|
+
// apps/client/sessions/command.ts
|
|
1549
|
+
var help = `Usage: teammy session COMMAND [options]
|
|
1550
|
+
|
|
1551
|
+
track --agent AGENT --file PATH [--project-dir DIR] [--adopt-existing]
|
|
1552
|
+
untrack --file PATH (does not delete server history)
|
|
1553
|
+
discover --agent AGENT [--directory DIR] [--project-dir DIR]
|
|
1554
|
+
undiscover --directory DIR (also untrack any previously discovered sources to stop them)
|
|
1555
|
+
sources Show registered sources, discovery directories and collection errors
|
|
1556
|
+
hook --agent AGENT [--file PATH] [--project-dir DIR]; accepts JSON on stdin
|
|
1557
|
+
list [--after N] [--limit N]
|
|
1558
|
+
read --owner MEMBER --key KEY [--revision N]
|
|
1559
|
+
search --query TEXT [--after N] [--limit N]
|
|
1560
|
+
|
|
1561
|
+
Shared options: --root DIR (./workspaces), --state-dir DIR (~/.teammy/client).
|
|
1562
|
+
AGENT: claude-code, codex, cursor, pi, dsh. Project defaults to the shared root's parent.
|
|
1563
|
+
track/discover only register local sources. Run teammy sync to collect and publish.
|
|
1564
|
+
Discover uses native default locations except DSH, which requires --directory.
|
|
1565
|
+
Hook only records a local hint, emits no stdout, and always exits successfully.
|
|
1566
|
+
`;
|
|
1567
|
+
async function runSessionCommand(args) {
|
|
1568
|
+
const command = args[0];
|
|
1569
|
+
if (!command || ["--help", "-h", "help"].includes(command)) {
|
|
1570
|
+
process.stdout.write(help);
|
|
1571
|
+
return 0;
|
|
1572
|
+
}
|
|
1573
|
+
const { values } = parseArgs({
|
|
1574
|
+
args: args.slice(1),
|
|
1575
|
+
options: {
|
|
1576
|
+
root: { type: "string", default: resolve2("workspaces") },
|
|
1577
|
+
"state-dir": { type: "string", default: join5(homedir(), ".teammy", "client") },
|
|
1578
|
+
agent: { type: "string" },
|
|
1579
|
+
file: { type: "string" },
|
|
1580
|
+
directory: { type: "string" },
|
|
1581
|
+
"project-dir": { type: "string" },
|
|
1582
|
+
"adopt-existing": { type: "boolean" },
|
|
1583
|
+
owner: { type: "string" },
|
|
1584
|
+
key: { type: "string" },
|
|
1585
|
+
revision: { type: "string" },
|
|
1586
|
+
query: { type: "string" },
|
|
1587
|
+
after: { type: "string", default: "0" },
|
|
1588
|
+
limit: { type: "string", default: "50" },
|
|
1589
|
+
help: { type: "boolean", short: "h" }
|
|
1590
|
+
}
|
|
1591
|
+
});
|
|
1592
|
+
if (values.help) {
|
|
1593
|
+
process.stdout.write(help);
|
|
1594
|
+
return 0;
|
|
1595
|
+
}
|
|
1596
|
+
const root = await realpath3(resolve2(values.root));
|
|
1597
|
+
const directory = await stateLocation(root, values["state-dir"]);
|
|
1598
|
+
const projectDir = await realpath3(resolve2(values["project-dir"] ?? dirname3(root)));
|
|
1599
|
+
if (command === "hook") {
|
|
1600
|
+
const payload = values.file ? {} : await hookPayload();
|
|
1601
|
+
const file = values.file ?? payload.transcript_path ?? payload.session_file ?? payload.sessionFile;
|
|
1602
|
+
if (typeof file === "string" && file)
|
|
1603
|
+
await recordHint(directory, root, {
|
|
1604
|
+
agent: AgentSchema.parse(values.agent),
|
|
1605
|
+
path: resolve2(file),
|
|
1606
|
+
projectDir
|
|
1607
|
+
});
|
|
1608
|
+
return 0;
|
|
1609
|
+
}
|
|
1610
|
+
const state = new ClientState(directory);
|
|
1611
|
+
try {
|
|
1612
|
+
const binding = state.binding(root);
|
|
1613
|
+
if (!binding) throw new Error("Connect this root first.");
|
|
1614
|
+
const sessions = new SessionState(state, root);
|
|
1615
|
+
const client = new HubClient(binding.serverUrl, 1e4, binding);
|
|
1616
|
+
let output;
|
|
1617
|
+
if (command === "track" || command === "untrack") {
|
|
1618
|
+
if (!values.file) throw new Error("--file is required.");
|
|
1619
|
+
const path = resolve2(values.file);
|
|
1620
|
+
if (command === "track")
|
|
1621
|
+
sessions.track(
|
|
1622
|
+
AgentSchema.parse(values.agent),
|
|
1623
|
+
path,
|
|
1624
|
+
projectDir,
|
|
1625
|
+
Boolean(values["adopt-existing"])
|
|
1626
|
+
);
|
|
1627
|
+
else sessions.untrack(path);
|
|
1628
|
+
output = sessions.status();
|
|
1629
|
+
} else if (command === "discover" || command === "undiscover") {
|
|
1630
|
+
if (command === "undiscover") {
|
|
1631
|
+
if (!values.directory) throw new Error("--directory is required.");
|
|
1632
|
+
state.db.prepare("DELETE FROM session_discovery WHERE root=? AND directory=?").run(root, await realpath3(resolve2(values.directory)));
|
|
1633
|
+
} else {
|
|
1634
|
+
const agent = AgentSchema.parse(values.agent);
|
|
1635
|
+
const defaults = {
|
|
1636
|
+
"claude-code": join5(homedir(), ".claude", "projects"),
|
|
1637
|
+
codex: join5(process.env.CODEX_HOME ?? join5(homedir(), ".codex"), "sessions"),
|
|
1638
|
+
cursor: join5(
|
|
1639
|
+
process.env.CURSOR_CONFIG_DIR ?? join5(homedir(), ".cursor"),
|
|
1640
|
+
"chats",
|
|
1641
|
+
createHash7("md5").update(projectDir).digest("hex")
|
|
1642
|
+
),
|
|
1643
|
+
pi: join5(process.env.PI_CODING_AGENT_DIR ?? join5(homedir(), ".pi", "agent"), "sessions"),
|
|
1644
|
+
dsh: void 0
|
|
1645
|
+
};
|
|
1646
|
+
const path = values.directory ?? defaults[agent];
|
|
1647
|
+
if (!path) throw new Error("DSH requires its configured persistence --directory.");
|
|
1648
|
+
sessions.discover(agent, await realpath3(resolve2(path)), projectDir);
|
|
1649
|
+
}
|
|
1650
|
+
output = sessions.directories();
|
|
1651
|
+
} else if (command === "sources")
|
|
1652
|
+
output = { sources: sessions.status(), discovery: sessions.directories() };
|
|
1653
|
+
else {
|
|
1654
|
+
const after = Number(values.after);
|
|
1655
|
+
const limit = Number(values.limit);
|
|
1656
|
+
if (!Number.isSafeInteger(after) || after < 0 || !Number.isSafeInteger(limit) || limit < 1 || limit > 100)
|
|
1657
|
+
throw new Error("Invalid pagination options.");
|
|
1658
|
+
if (command === "list") output = await client.sessions(after, limit);
|
|
1659
|
+
else if (command === "search") {
|
|
1660
|
+
if (!values.query) throw new Error("--query is required.");
|
|
1661
|
+
output = await client.searchSessions(values.query, after, limit);
|
|
1662
|
+
} else if (command === "read") {
|
|
1663
|
+
if (!values.owner || !values.key) throw new Error("--owner and --key are required.");
|
|
1664
|
+
const revision = values.revision === void 0 ? void 0 : Number(values.revision);
|
|
1665
|
+
if (revision !== void 0 && (!Number.isSafeInteger(revision) || revision < 1))
|
|
1666
|
+
throw new Error("Invalid revision.");
|
|
1667
|
+
const session = await client.session(values.owner, values.key, revision);
|
|
1668
|
+
const snapshot = SessionSnapshotSchema.parse(
|
|
1669
|
+
JSON.parse(Buffer.from(await client.downloadBlob(session.blob.sha256)).toString("utf8"))
|
|
1670
|
+
);
|
|
1671
|
+
output = { session, snapshot };
|
|
1672
|
+
} else throw new Error("Unknown session command. Use teammy session --help.");
|
|
1673
|
+
}
|
|
1674
|
+
process.stdout.write(`${JSON.stringify(output, null, 2)}
|
|
1675
|
+
`);
|
|
1676
|
+
return 0;
|
|
1677
|
+
} finally {
|
|
1678
|
+
state.close();
|
|
1679
|
+
}
|
|
1680
|
+
}
|
|
1681
|
+
|
|
1682
|
+
// apps/client/sync-command.ts
|
|
1683
|
+
import { realpath as realpath5 } from "node:fs/promises";
|
|
1684
|
+
import { homedir as homedir2 } from "node:os";
|
|
1685
|
+
import { join as join10, resolve as resolve5 } from "node:path";
|
|
1686
|
+
import { setTimeout as setTimeout3 } from "node:timers/promises";
|
|
1687
|
+
import { parseArgs as parseArgs2 } from "node:util";
|
|
1688
|
+
|
|
1689
|
+
// apps/client/sessions/synchronizer.ts
|
|
1690
|
+
import { randomUUID as randomUUID6 } from "node:crypto";
|
|
1691
|
+
import { join as join8 } from "node:path";
|
|
1692
|
+
|
|
1693
|
+
// packages/storage/local-blob-store.ts
|
|
1694
|
+
import { createHash as createHash8, randomUUID as randomUUID5 } from "node:crypto";
|
|
1695
|
+
import { link, mkdir as mkdir5, open as open3, readFile as readFile2, unlink as unlink3 } from "node:fs/promises";
|
|
1696
|
+
import { dirname as dirname4, join as join6, resolve as resolve3 } from "node:path";
|
|
1697
|
+
function hash(content) {
|
|
1698
|
+
return createHash8("sha256").update(content).digest("hex");
|
|
1699
|
+
}
|
|
1700
|
+
function hasCode(error, code) {
|
|
1701
|
+
return error instanceof Error && "code" in error && error.code === code;
|
|
1702
|
+
}
|
|
1703
|
+
var BlobIntegrityError = class extends Error {
|
|
1704
|
+
constructor(sha256) {
|
|
1705
|
+
super(`Stored content failed integrity verification: ${sha256}`);
|
|
1706
|
+
this.sha256 = sha256;
|
|
1707
|
+
this.name = "BlobIntegrityError";
|
|
1708
|
+
}
|
|
1709
|
+
sha256;
|
|
1710
|
+
};
|
|
1711
|
+
var LocalBlobStore = class {
|
|
1712
|
+
root;
|
|
1713
|
+
constructor(root) {
|
|
1714
|
+
this.root = resolve3(root);
|
|
1715
|
+
}
|
|
1716
|
+
async put(content) {
|
|
1717
|
+
const bytes = Buffer.from(content);
|
|
1718
|
+
const sha256 = hash(bytes);
|
|
1719
|
+
const target = this.pathFor(sha256);
|
|
1720
|
+
await mkdir5(dirname4(target), { recursive: true, mode: 448 });
|
|
1721
|
+
const temporary = `${target}.${randomUUID5()}.tmp`;
|
|
1722
|
+
const file = await open3(temporary, "wx", 384);
|
|
1723
|
+
try {
|
|
1724
|
+
try {
|
|
1725
|
+
await file.writeFile(bytes);
|
|
1726
|
+
await file.sync();
|
|
1727
|
+
} finally {
|
|
1728
|
+
await file.close();
|
|
1729
|
+
}
|
|
1730
|
+
try {
|
|
1731
|
+
await link(temporary, target);
|
|
1732
|
+
} catch (error) {
|
|
1733
|
+
if (!hasCode(error, "EEXIST")) throw error;
|
|
1734
|
+
await this.get(sha256);
|
|
1735
|
+
}
|
|
1736
|
+
if (process.platform !== "win32") {
|
|
1737
|
+
let directory = dirname4(target);
|
|
1738
|
+
for (; ; ) {
|
|
1739
|
+
const handle = await open3(directory, "r");
|
|
1740
|
+
try {
|
|
1741
|
+
await handle.sync();
|
|
1742
|
+
} finally {
|
|
1743
|
+
await handle.close();
|
|
1744
|
+
}
|
|
1745
|
+
if (directory === dirname4(this.root)) break;
|
|
1746
|
+
directory = dirname4(directory);
|
|
1747
|
+
}
|
|
1748
|
+
}
|
|
1749
|
+
} finally {
|
|
1750
|
+
await unlink3(temporary);
|
|
1751
|
+
}
|
|
1752
|
+
return { sha256, size: bytes.byteLength };
|
|
1753
|
+
}
|
|
1754
|
+
async get(sha256) {
|
|
1755
|
+
const target = this.pathFor(sha256);
|
|
1756
|
+
let content;
|
|
1757
|
+
try {
|
|
1758
|
+
content = await readFile2(target);
|
|
1759
|
+
} catch (error) {
|
|
1760
|
+
if (hasCode(error, "ENOENT")) return void 0;
|
|
1761
|
+
throw error;
|
|
1762
|
+
}
|
|
1763
|
+
if (hash(content) !== sha256) throw new BlobIntegrityError(sha256);
|
|
1764
|
+
return content;
|
|
1765
|
+
}
|
|
1766
|
+
pathFor(sha256) {
|
|
1767
|
+
if (!/^[a-f0-9]{64}$/.test(sha256)) throw new Error("Invalid SHA-256 content identifier.");
|
|
1768
|
+
return join6(this.root, "sha256", sha256.slice(0, 2), sha256);
|
|
1769
|
+
}
|
|
1770
|
+
};
|
|
1771
|
+
|
|
1772
|
+
// apps/client/sessions/source.ts
|
|
1773
|
+
import { open as open4, stat, realpath as realpath4 } from "node:fs/promises";
|
|
1774
|
+
import { relative as relative3, isAbsolute as isAbsolute3, sep as sep3 } from "node:path";
|
|
1775
|
+
import * as zlib from "node:zlib";
|
|
1776
|
+
|
|
1777
|
+
// apps/client/sessions/cursor.ts
|
|
1778
|
+
import { createHash as createHash9 } from "node:crypto";
|
|
1779
|
+
import { basename, dirname as dirname5, resolve as resolve4 } from "node:path";
|
|
1780
|
+
import { DatabaseSync as DatabaseSync2 } from "node:sqlite";
|
|
1781
|
+
import { fileURLToPath } from "node:url";
|
|
1782
|
+
function fields(bytes) {
|
|
1783
|
+
const data = Buffer.from(bytes);
|
|
1784
|
+
let offset = 0;
|
|
1785
|
+
const varint = () => {
|
|
1786
|
+
let value = 0n;
|
|
1787
|
+
for (let i = 0; i < 10; i++) {
|
|
1788
|
+
if (offset >= data.length) throw new Error("Truncated Cursor protobuf.");
|
|
1789
|
+
const byte = data[offset++];
|
|
1790
|
+
value |= BigInt(byte & 127) << BigInt(i * 7);
|
|
1791
|
+
if (byte < 128) return value;
|
|
1792
|
+
}
|
|
1793
|
+
throw new Error("Invalid Cursor protobuf integer.");
|
|
1794
|
+
};
|
|
1795
|
+
const result = /* @__PURE__ */ new Map();
|
|
1796
|
+
while (offset < data.length) {
|
|
1797
|
+
const tag = varint();
|
|
1798
|
+
const field = Number(tag >> 3n);
|
|
1799
|
+
if (field < 1 || field > 536870911) throw new Error("Invalid Cursor protobuf field.");
|
|
1800
|
+
const wire = Number(tag & 7n);
|
|
1801
|
+
if (wire === 0) varint();
|
|
1802
|
+
else if (wire === 1) offset += 8;
|
|
1803
|
+
else if (wire === 5) offset += 4;
|
|
1804
|
+
else if (wire === 2) {
|
|
1805
|
+
const length = Number(varint());
|
|
1806
|
+
if (!Number.isSafeInteger(length) || length > data.length - offset)
|
|
1807
|
+
throw new Error("Truncated Cursor protobuf value.");
|
|
1808
|
+
const values = result.get(field) ?? [];
|
|
1809
|
+
values.push(data.subarray(offset, offset + length));
|
|
1810
|
+
result.set(field, values);
|
|
1811
|
+
offset += length;
|
|
1812
|
+
} else throw new Error("Unsupported Cursor protobuf wire type.");
|
|
1813
|
+
if (offset > data.length) throw new Error("Truncated Cursor protobuf value.");
|
|
1814
|
+
}
|
|
1815
|
+
return result;
|
|
1816
|
+
}
|
|
1817
|
+
function readCursor(path, projectDir) {
|
|
1818
|
+
const db = new DatabaseSync2(path, { readOnly: true });
|
|
1819
|
+
try {
|
|
1820
|
+
db.exec("PRAGMA query_only=ON; PRAGMA busy_timeout=100; BEGIN");
|
|
1821
|
+
if (db.prepare("PRAGMA user_version").get()?.user_version !== 1)
|
|
1822
|
+
throw new Error("Unsupported Cursor CLI store version (expected 1).");
|
|
1823
|
+
const row = db.prepare("SELECT value FROM meta WHERE key='0' AND length(value)<=2097152").get();
|
|
1824
|
+
const hex = row?.value;
|
|
1825
|
+
if (typeof hex !== "string" || !/^(?:[a-fA-F0-9]{2})+$/.test(hex))
|
|
1826
|
+
throw new Error("Invalid Cursor metadata.");
|
|
1827
|
+
const meta = object(JSON.parse(Buffer.from(hex, "hex").toString("utf8")));
|
|
1828
|
+
if (typeof meta.agentId !== "string" || meta.agentId !== basename(dirname5(path)) || meta.subagentInfo != null)
|
|
1829
|
+
throw new Error("Invalid or delegated Cursor session identity.");
|
|
1830
|
+
let budget = 64 * 1024 * 1024;
|
|
1831
|
+
const blob = (id) => {
|
|
1832
|
+
if (!/^[a-f0-9]{64}$/.test(id)) throw new Error("Invalid Cursor blob reference.");
|
|
1833
|
+
const value = db.prepare("SELECT data FROM blobs WHERE id=? AND length(data)<=?").get(id, budget)?.data;
|
|
1834
|
+
if (!(value instanceof Uint8Array))
|
|
1835
|
+
throw new Error("Cursor history missing or exceeds 64 MiB.");
|
|
1836
|
+
const bytes = Buffer.from(value);
|
|
1837
|
+
budget -= bytes.length;
|
|
1838
|
+
if (createHash9("sha256").update(bytes).digest("hex") !== id)
|
|
1839
|
+
throw new Error("Cursor blob failed integrity verification.");
|
|
1840
|
+
return bytes;
|
|
1841
|
+
};
|
|
1842
|
+
const root = fields(blob(String(meta.latestRootBlobId)));
|
|
1843
|
+
const bucket = basename(dirname5(dirname5(path)));
|
|
1844
|
+
const md5 = (cwd2) => createHash9("md5").update(resolve4(cwd2)).digest("hex");
|
|
1845
|
+
let cwd = md5(projectDir) === bucket ? projectDir : void 0;
|
|
1846
|
+
for (const uri of root.get(9) ?? []) {
|
|
1847
|
+
try {
|
|
1848
|
+
const native = fileURLToPath(uri.toString("utf8"));
|
|
1849
|
+
if (md5(native) === bucket) {
|
|
1850
|
+
cwd = native;
|
|
1851
|
+
break;
|
|
1852
|
+
}
|
|
1853
|
+
} catch {
|
|
1854
|
+
}
|
|
1855
|
+
}
|
|
1856
|
+
if (!cwd) throw new Error("Cursor workspace cannot be verified against its storage bucket.");
|
|
1857
|
+
const refs = [];
|
|
1858
|
+
for (const archive of root.get(13) ?? root.get(11) ?? [])
|
|
1859
|
+
refs.push(...fields(blob(archive.toString("hex"))).get(1) ?? []);
|
|
1860
|
+
refs.push(...root.get(1) ?? []);
|
|
1861
|
+
if (refs.length > 5e4) throw new Error("Cursor history exceeds 50000 messages.");
|
|
1862
|
+
const messages = [];
|
|
1863
|
+
const counts = /* @__PURE__ */ new Map();
|
|
1864
|
+
for (const ref of refs) {
|
|
1865
|
+
const key = ref.toString("hex");
|
|
1866
|
+
const message2 = object(
|
|
1867
|
+
JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(blob(key)))
|
|
1868
|
+
);
|
|
1869
|
+
const cursor = object(object(message2.providerOptions).cursor);
|
|
1870
|
+
if (cursor.isSummary || !["user", "assistant"].includes(String(message2.role))) continue;
|
|
1871
|
+
let text = textContent(message2.content);
|
|
1872
|
+
if (message2.role === "user") {
|
|
1873
|
+
if (!cursor.requestId) continue;
|
|
1874
|
+
const start = text.indexOf("<user_query>");
|
|
1875
|
+
const end = text.lastIndexOf("</user_query>");
|
|
1876
|
+
if (start >= 0 && end > start) text = text.slice(start + 12, end).trim();
|
|
1877
|
+
}
|
|
1878
|
+
if (!text.trim()) continue;
|
|
1879
|
+
const id = createHash9("sha256").update(JSON.stringify([message2.role, cursor.requestId ?? "", text])).digest("hex");
|
|
1880
|
+
const count = (counts.get(id) ?? 0) + 1;
|
|
1881
|
+
counts.set(id, count);
|
|
1882
|
+
messages.push({
|
|
1883
|
+
id: `${id}#${count}`,
|
|
1884
|
+
role: message2.role,
|
|
1885
|
+
text,
|
|
1886
|
+
source: `blob:${key}`
|
|
1887
|
+
});
|
|
1888
|
+
}
|
|
1889
|
+
return SessionSnapshotSchema.parse({
|
|
1890
|
+
formatVersion: 1,
|
|
1891
|
+
adapterVersion: 1,
|
|
1892
|
+
agent: "cursor",
|
|
1893
|
+
nativeId: meta.agentId,
|
|
1894
|
+
cwd,
|
|
1895
|
+
messages
|
|
1896
|
+
});
|
|
1897
|
+
} finally {
|
|
1898
|
+
db.close();
|
|
1899
|
+
}
|
|
1900
|
+
}
|
|
1901
|
+
|
|
1902
|
+
// apps/client/sessions/source.ts
|
|
1903
|
+
var MAX_SOURCE_BYTES = 64 * 1024 * 1024;
|
|
1904
|
+
async function sourceSignature(path, agent) {
|
|
1905
|
+
const paths = agent === "cursor" ? [path, `${path}-wal`] : [path];
|
|
1906
|
+
const signatures = [];
|
|
1907
|
+
for (const file of paths) {
|
|
1908
|
+
try {
|
|
1909
|
+
const s = await stat(file, { bigint: true });
|
|
1910
|
+
if (!s.isFile()) throw new Error("Session source must be a regular file.");
|
|
1911
|
+
signatures.push([
|
|
1912
|
+
String(s.dev),
|
|
1913
|
+
String(s.ino),
|
|
1914
|
+
String(s.size),
|
|
1915
|
+
String(s.mtimeNs),
|
|
1916
|
+
String(s.ctimeNs)
|
|
1917
|
+
]);
|
|
1918
|
+
} catch (error) {
|
|
1919
|
+
if (file !== path && error.code === "ENOENT")
|
|
1920
|
+
signatures.push(null);
|
|
1921
|
+
else throw error;
|
|
1922
|
+
}
|
|
1923
|
+
}
|
|
1924
|
+
return JSON.stringify(signatures);
|
|
1925
|
+
}
|
|
1926
|
+
async function readSource(agent, path, projectDir) {
|
|
1927
|
+
const signature2 = await sourceSignature(path, agent);
|
|
1928
|
+
let result;
|
|
1929
|
+
if (agent === "cursor") result = { snapshot: readCursor(path, projectDir), incomplete: false };
|
|
1930
|
+
else {
|
|
1931
|
+
const handle = await open4(path, "r");
|
|
1932
|
+
let bytes;
|
|
1933
|
+
try {
|
|
1934
|
+
const size = (await handle.stat()).size;
|
|
1935
|
+
if (size > MAX_SOURCE_BYTES) throw new Error("Session source exceeds 64 MiB.");
|
|
1936
|
+
bytes = Buffer.alloc(size + 1);
|
|
1937
|
+
let offset = 0;
|
|
1938
|
+
while (offset < bytes.length) {
|
|
1939
|
+
const read = await handle.read(bytes, offset, bytes.length - offset, offset);
|
|
1940
|
+
if (!read.bytesRead) break;
|
|
1941
|
+
offset += read.bytesRead;
|
|
1942
|
+
}
|
|
1943
|
+
bytes = bytes.subarray(0, offset);
|
|
1944
|
+
if (offset > size) throw new Error("Session source changed while reading; retry next cycle.");
|
|
1945
|
+
} finally {
|
|
1946
|
+
await handle.close();
|
|
1947
|
+
}
|
|
1948
|
+
if (agent === "dsh" && path.endsWith(".zstd")) {
|
|
1949
|
+
const decompress = zlib.zstdDecompressSync;
|
|
1950
|
+
if (!decompress)
|
|
1951
|
+
throw new Error(
|
|
1952
|
+
"Compressed DSH sessions require Node.js with zstd support (22.15+ or 24+)."
|
|
1953
|
+
);
|
|
1954
|
+
bytes = decompress(bytes, { maxOutputLength: MAX_SOURCE_BYTES });
|
|
1955
|
+
}
|
|
1956
|
+
result = normalizeJsonl(agent, new TextDecoder("utf-8", { fatal: true }).decode(bytes));
|
|
1957
|
+
}
|
|
1958
|
+
if (signature2 !== await sourceSignature(path, agent))
|
|
1959
|
+
throw new Error("Session source changed while reading; retry next cycle.");
|
|
1960
|
+
const cwd = await realpath4(result.snapshot.cwd);
|
|
1961
|
+
const project = await realpath4(projectDir);
|
|
1962
|
+
const rel = relative3(project, cwd);
|
|
1963
|
+
if (rel === ".." || rel.startsWith(`..${sep3}`) || isAbsolute3(rel))
|
|
1964
|
+
throw new Error("Session cwd is outside the registered project.");
|
|
1965
|
+
return { ...result, signature: signature2 };
|
|
1966
|
+
}
|
|
1967
|
+
|
|
1968
|
+
// apps/client/sessions/discovery.ts
|
|
1969
|
+
import { opendir as opendir2 } from "node:fs/promises";
|
|
1970
|
+
import { join as join7 } from "node:path";
|
|
1971
|
+
async function* candidates(directory, agent) {
|
|
1972
|
+
const queue = [directory];
|
|
1973
|
+
while (queue.length) {
|
|
1974
|
+
const current = queue.shift();
|
|
1975
|
+
const dir = await opendir2(current);
|
|
1976
|
+
for await (const entry of dir) {
|
|
1977
|
+
const path = join7(current, entry.name);
|
|
1978
|
+
if (entry.isDirectory()) queue.push(path);
|
|
1979
|
+
if (entry.isFile() && (agent === "cursor" ? entry.name === "store.db" : entry.name.endsWith(".jsonl") || agent === "dsh" && entry.name.endsWith(".jsonl.zstd")))
|
|
1980
|
+
yield path;
|
|
1981
|
+
else yield null;
|
|
1982
|
+
}
|
|
1983
|
+
}
|
|
1984
|
+
}
|
|
1985
|
+
var SessionDiscovery = class {
|
|
1986
|
+
constructor(state) {
|
|
1987
|
+
this.state = state;
|
|
1988
|
+
}
|
|
1989
|
+
state;
|
|
1990
|
+
scans = /* @__PURE__ */ new Map();
|
|
1991
|
+
async cycle() {
|
|
1992
|
+
const issues = [];
|
|
1993
|
+
let entries = 500;
|
|
1994
|
+
let reads = 5;
|
|
1995
|
+
const configs = this.state.directories();
|
|
1996
|
+
for (const [directory, scan] of this.scans) {
|
|
1997
|
+
if (!configs.some((config) => config.directory === directory)) {
|
|
1998
|
+
await scan.iterator.return(void 0);
|
|
1999
|
+
this.scans.delete(directory);
|
|
2000
|
+
}
|
|
2001
|
+
}
|
|
2002
|
+
for (const config of configs) {
|
|
2003
|
+
let scan = this.scans.get(config.directory);
|
|
2004
|
+
if (!scan) {
|
|
2005
|
+
scan = { iterator: candidates(config.directory, config.agent), nextAt: 0 };
|
|
2006
|
+
this.scans.set(config.directory, scan);
|
|
2007
|
+
}
|
|
2008
|
+
if (scan.nextAt > Date.now()) continue;
|
|
2009
|
+
try {
|
|
2010
|
+
while (entries > 0 && reads > 0) {
|
|
2011
|
+
entries--;
|
|
2012
|
+
const entry = await scan.iterator.next();
|
|
2013
|
+
if (entry.done) {
|
|
2014
|
+
scan.iterator = candidates(config.directory, config.agent);
|
|
2015
|
+
scan.nextAt = Date.now() + 3e5;
|
|
2016
|
+
break;
|
|
2017
|
+
}
|
|
2018
|
+
const path = entry.value;
|
|
2019
|
+
if (!path || this.state.get(path)) continue;
|
|
2020
|
+
const signature2 = await sourceSignature(path, config.agent);
|
|
2021
|
+
const db = this.state.state.db;
|
|
2022
|
+
if (db.prepare(
|
|
2023
|
+
"SELECT 1 FROM session_discovery_cache WHERE root=? AND path=? AND signature=?"
|
|
2024
|
+
).get(this.state.root, path, signature2))
|
|
2025
|
+
continue;
|
|
2026
|
+
reads--;
|
|
2027
|
+
try {
|
|
2028
|
+
const result = await readSource(config.agent, path, config.projectDir);
|
|
2029
|
+
if (result.incomplete) continue;
|
|
2030
|
+
this.state.track(config.agent, path, config.projectDir);
|
|
2031
|
+
} catch (error) {
|
|
2032
|
+
const message2 = error instanceof Error ? error.message : "Discovery failed.";
|
|
2033
|
+
if (!message2.includes("outside the registered project"))
|
|
2034
|
+
issues.push(`${path}: ${message2}`);
|
|
2035
|
+
if (!message2.includes("outside the registered project")) continue;
|
|
2036
|
+
}
|
|
2037
|
+
db.prepare(
|
|
2038
|
+
"INSERT INTO session_discovery_cache VALUES (?,?,?) ON CONFLICT(root,path) DO UPDATE SET signature=excluded.signature"
|
|
2039
|
+
).run(this.state.root, path, signature2);
|
|
2040
|
+
}
|
|
2041
|
+
} catch (error) {
|
|
2042
|
+
issues.push(
|
|
2043
|
+
`${config.directory}: ${error instanceof Error ? error.message : "Discovery failed."}`
|
|
2044
|
+
);
|
|
2045
|
+
await scan.iterator.return(void 0);
|
|
2046
|
+
scan.iterator = candidates(config.directory, config.agent);
|
|
2047
|
+
scan.nextAt = Date.now() + 3e5;
|
|
2048
|
+
}
|
|
2049
|
+
}
|
|
2050
|
+
return issues;
|
|
2051
|
+
}
|
|
2052
|
+
async close() {
|
|
2053
|
+
for (const scan of this.scans.values()) await scan.iterator.return(void 0);
|
|
2054
|
+
}
|
|
2055
|
+
};
|
|
2056
|
+
|
|
2057
|
+
// apps/client/sessions/synchronizer.ts
|
|
2058
|
+
var SessionSynchronizer = class {
|
|
2059
|
+
constructor(state, root, directory, signal) {
|
|
2060
|
+
this.root = root;
|
|
2061
|
+
this.directory = directory;
|
|
2062
|
+
const binding = state.binding(root);
|
|
2063
|
+
if (!binding) throw new Error("Connect this root before collecting sessions.");
|
|
2064
|
+
this.client = new HubClient(binding.serverUrl, 1e4, binding, signal);
|
|
2065
|
+
this.blobs = new LocalBlobStore(join8(directory, "blobs"));
|
|
2066
|
+
this.index = new SessionState(state, root);
|
|
2067
|
+
this.discovery = new SessionDiscovery(this.index);
|
|
2068
|
+
}
|
|
2069
|
+
root;
|
|
2070
|
+
directory;
|
|
2071
|
+
index;
|
|
2072
|
+
client;
|
|
2073
|
+
blobs;
|
|
2074
|
+
discovery;
|
|
2075
|
+
capable = false;
|
|
2076
|
+
async cycle() {
|
|
2077
|
+
const report = { published: 0, checked: 0, issues: [] };
|
|
2078
|
+
report.issues.push(...await consumeHints(this.directory, this.root, this.index));
|
|
2079
|
+
report.issues.push(...await this.discovery.cycle());
|
|
2080
|
+
const sources = this.index.sources(void 0, 200);
|
|
2081
|
+
if (sources.length && !this.capable) {
|
|
2082
|
+
const info = await this.client.serverInfo();
|
|
2083
|
+
if (!["blobs", "session-revisions"].every(
|
|
2084
|
+
(capability) => info.capabilities.includes(capability)
|
|
2085
|
+
))
|
|
2086
|
+
throw new Error("Server does not support session publication. Upgrade the server first.");
|
|
2087
|
+
this.capable = true;
|
|
2088
|
+
}
|
|
2089
|
+
let reads = 5;
|
|
2090
|
+
let uploads = 5;
|
|
2091
|
+
for (const source of sources) {
|
|
2092
|
+
if (reads <= 0 || uploads <= 0 || report.checked >= 200) break;
|
|
2093
|
+
report.checked++;
|
|
2094
|
+
this.index.checked(source.path, source.lastError);
|
|
2095
|
+
try {
|
|
2096
|
+
if (source.pending) {
|
|
2097
|
+
if (source.retryAt > Date.now()) {
|
|
2098
|
+
if (source.lastError)
|
|
2099
|
+
report.issues.push(`${source.path}: ${source.lastError} (retry deferred)`);
|
|
2100
|
+
continue;
|
|
2101
|
+
}
|
|
2102
|
+
uploads--;
|
|
2103
|
+
await this.flush(source);
|
|
2104
|
+
report.published++;
|
|
2105
|
+
continue;
|
|
2106
|
+
}
|
|
2107
|
+
const signature2 = await sourceSignature(source.path, source.agent);
|
|
2108
|
+
if (signature2 === source.signature && Date.now() - source.verifiedAt < 864e5) {
|
|
2109
|
+
this.index.checked(source.path);
|
|
2110
|
+
continue;
|
|
2111
|
+
}
|
|
2112
|
+
reads--;
|
|
2113
|
+
const observed = await readSource(source.agent, source.path, source.projectDir);
|
|
2114
|
+
if (observed.incomplete)
|
|
2115
|
+
throw new Error("Final session record is incomplete; waiting for the next save.");
|
|
2116
|
+
if (!observed.snapshot.messages.length) {
|
|
2117
|
+
this.index.checked(source.path);
|
|
2118
|
+
continue;
|
|
2119
|
+
}
|
|
2120
|
+
const key = sessionKey(source.agent, observed.snapshot.nativeId);
|
|
2121
|
+
if (source.key && source.key !== key)
|
|
2122
|
+
throw new Error(
|
|
2123
|
+
"Native session identity changed at this path; register the new source explicitly."
|
|
2124
|
+
);
|
|
2125
|
+
const bytes = Buffer.from(JSON.stringify(observed.snapshot));
|
|
2126
|
+
if (bytes.length > MAX_BLOB_BYTES)
|
|
2127
|
+
throw new Error("Normalized session exceeds the 10 MiB snapshot limit.");
|
|
2128
|
+
const blob = await this.blobs.put(bytes);
|
|
2129
|
+
let revision = source.revision;
|
|
2130
|
+
if (!source.key) {
|
|
2131
|
+
try {
|
|
2132
|
+
const owner = this.index.state.binding(this.root).memberId;
|
|
2133
|
+
const current = await this.client.session(owner, key);
|
|
2134
|
+
if (current.blob.sha256 !== blob.sha256 && !source.adoptExisting)
|
|
2135
|
+
throw new Error(
|
|
2136
|
+
"Server session already exists with different content; inspect it, then track with --adopt-existing to continue."
|
|
2137
|
+
);
|
|
2138
|
+
revision = current.revision;
|
|
2139
|
+
} catch (error) {
|
|
2140
|
+
if (!(error instanceof HubRequestError && error.code === "SESSION_NOT_FOUND"))
|
|
2141
|
+
throw error;
|
|
2142
|
+
}
|
|
2143
|
+
this.index.baseline(source.path, key, revision);
|
|
2144
|
+
}
|
|
2145
|
+
if (blob.sha256 === source.hash) {
|
|
2146
|
+
this.index.acknowledge(source.path, revision, blob.sha256, observed.signature);
|
|
2147
|
+
continue;
|
|
2148
|
+
}
|
|
2149
|
+
this.index.stage(
|
|
2150
|
+
source.path,
|
|
2151
|
+
{ operationId: randomUUID6(), sessionKey: key, parentRevision: revision, blob },
|
|
2152
|
+
observed.signature
|
|
2153
|
+
);
|
|
2154
|
+
uploads--;
|
|
2155
|
+
await this.flush(this.index.get(source.path));
|
|
2156
|
+
report.published++;
|
|
2157
|
+
} catch (error) {
|
|
2158
|
+
const message2 = error instanceof Error ? error.message : "Session synchronization failed.";
|
|
2159
|
+
this.index.checked(source.path, message2);
|
|
2160
|
+
report.issues.push(`${source.path}: ${message2}`);
|
|
2161
|
+
}
|
|
2162
|
+
}
|
|
2163
|
+
return report;
|
|
2164
|
+
}
|
|
2165
|
+
async flush(source) {
|
|
2166
|
+
const request = source.pending;
|
|
2167
|
+
try {
|
|
2168
|
+
const { missing: missing2 } = await this.client.missingBlobs([request.blob.sha256]);
|
|
2169
|
+
if (missing2.some((hash2) => hash2 !== request.blob.sha256))
|
|
2170
|
+
throw new Error("Server requested unrelated session content.");
|
|
2171
|
+
if (missing2.length) {
|
|
2172
|
+
const bytes = await this.blobs.get(request.blob.sha256);
|
|
2173
|
+
if (!bytes) throw new Error("Queued session snapshot is missing.");
|
|
2174
|
+
const ref = await this.client.uploadBlob(request.blob.sha256, bytes);
|
|
2175
|
+
if (ref.sha256 !== request.blob.sha256 || ref.size !== request.blob.size)
|
|
2176
|
+
throw new Error("Server acknowledged different session content.");
|
|
2177
|
+
}
|
|
2178
|
+
const result = await this.client.commitSession(request);
|
|
2179
|
+
if (result.operationId !== request.operationId || result.revision < request.parentRevision || result.revision > request.parentRevision + 1)
|
|
2180
|
+
throw new Error("Invalid session acknowledgement.");
|
|
2181
|
+
this.index.acknowledge(
|
|
2182
|
+
source.path,
|
|
2183
|
+
result.revision,
|
|
2184
|
+
request.blob.sha256,
|
|
2185
|
+
source.pendingSignature
|
|
2186
|
+
);
|
|
2187
|
+
} catch (error) {
|
|
2188
|
+
const blocked = error instanceof HubRequestError && error.status >= 400 && error.status < 500 && ![408, 429].includes(error.status);
|
|
2189
|
+
this.index.postpone(
|
|
2190
|
+
source,
|
|
2191
|
+
error instanceof Error ? error.message : "Upload failed.",
|
|
2192
|
+
blocked
|
|
2193
|
+
);
|
|
2194
|
+
throw error;
|
|
2195
|
+
}
|
|
2196
|
+
}
|
|
2197
|
+
async close() {
|
|
2198
|
+
await this.discovery.close();
|
|
2199
|
+
}
|
|
2200
|
+
};
|
|
2201
|
+
|
|
2202
|
+
// apps/client/files/synchronizer.ts
|
|
2203
|
+
import { randomUUID as randomUUID7 } from "node:crypto";
|
|
2204
|
+
import { watch } from "node:fs";
|
|
2205
|
+
import { lstat as lstat2, mkdir as mkdir6 } from "node:fs/promises";
|
|
2206
|
+
import { join as join9, sep as sep4 } from "node:path";
|
|
2207
|
+
import { performance } from "node:perf_hooks";
|
|
2208
|
+
|
|
2209
|
+
// apps/client/publication.ts
|
|
2210
|
+
var PublicationQueue = class {
|
|
2211
|
+
constructor(state, blobs, transport) {
|
|
2212
|
+
this.state = state;
|
|
2213
|
+
this.blobs = blobs;
|
|
2214
|
+
this.transport = transport;
|
|
2215
|
+
}
|
|
2216
|
+
state;
|
|
2217
|
+
blobs;
|
|
2218
|
+
transport;
|
|
2219
|
+
async flush(root, now = Date.now()) {
|
|
2220
|
+
const pending = this.state.pending(root);
|
|
2221
|
+
if (!pending || pending.retryAt > now) return false;
|
|
2222
|
+
try {
|
|
2223
|
+
const hashes = [
|
|
2224
|
+
...new Set(
|
|
2225
|
+
pending.batch.changes.flatMap(
|
|
2226
|
+
(change) => change.kind === "upsert" ? [change.sha256] : []
|
|
2227
|
+
)
|
|
2228
|
+
)
|
|
2229
|
+
];
|
|
2230
|
+
const { missing: missing2 } = await this.transport.missingBlobs(hashes);
|
|
2231
|
+
for (const hash2 of missing2) {
|
|
2232
|
+
if (!hashes.includes(hash2)) throw new Error("Server requested unrelated content.");
|
|
2233
|
+
const content = await this.blobs.get(hash2);
|
|
2234
|
+
if (!content) throw new Error("A queued content snapshot is missing from local storage.");
|
|
2235
|
+
const result = await this.transport.uploadBlob(hash2, content);
|
|
2236
|
+
if (result.sha256 !== hash2 || result.size !== content.byteLength)
|
|
2237
|
+
throw new Error("Server acknowledged different content.");
|
|
2238
|
+
}
|
|
2239
|
+
this.state.acknowledge(root, await this.transport.commitFiles(pending.batch));
|
|
2240
|
+
return true;
|
|
2241
|
+
} catch (error) {
|
|
2242
|
+
const blocked = error instanceof HubRequestError && error.status >= 400 && error.status < 500 && ![408, 429].includes(error.status);
|
|
2243
|
+
this.state.postpone(
|
|
2244
|
+
root,
|
|
2245
|
+
pending.batch.operationId,
|
|
2246
|
+
error instanceof Error ? error.message : "Publication failed.",
|
|
2247
|
+
now,
|
|
2248
|
+
blocked
|
|
2249
|
+
);
|
|
2250
|
+
throw error;
|
|
2251
|
+
}
|
|
2252
|
+
}
|
|
2253
|
+
};
|
|
2254
|
+
|
|
2255
|
+
// apps/client/files/synchronizer.ts
|
|
2256
|
+
var FileSynchronizer = class {
|
|
2257
|
+
constructor(state, root, stateDirectory, options = {}) {
|
|
2258
|
+
this.state = state;
|
|
2259
|
+
this.root = root;
|
|
2260
|
+
this.options = options;
|
|
2261
|
+
const binding = state.binding(root);
|
|
2262
|
+
if (!binding) throw new Error("Connect this root before synchronizing.");
|
|
2263
|
+
this.binding = binding;
|
|
2264
|
+
this.client = new HubClient(binding.serverUrl, 3e4, binding, this.abort.signal);
|
|
2265
|
+
this.blobs = new LocalBlobStore(join9(stateDirectory, "blobs"));
|
|
2266
|
+
this.queue = new PublicationQueue(state, this.blobs, this.client);
|
|
2267
|
+
this.index = new SyncState(state, root);
|
|
2268
|
+
this.ownRoot = join9(root, binding.memberId);
|
|
2269
|
+
const started = performance.now();
|
|
2270
|
+
const epoch = Date.now();
|
|
2271
|
+
this.clock = options.clock ?? (() => epoch + performance.now() - started);
|
|
2272
|
+
this.settleMs = options.settleMs ?? 6e4;
|
|
2273
|
+
this.index.restartWaiting(this.clock());
|
|
2274
|
+
}
|
|
2275
|
+
state;
|
|
2276
|
+
root;
|
|
2277
|
+
options;
|
|
2278
|
+
index;
|
|
2279
|
+
binding;
|
|
2280
|
+
client;
|
|
2281
|
+
blobs;
|
|
2282
|
+
queue;
|
|
2283
|
+
ownRoot;
|
|
2284
|
+
clock;
|
|
2285
|
+
settleMs;
|
|
2286
|
+
watcher;
|
|
2287
|
+
watchIssues = [];
|
|
2288
|
+
lastScan = -Infinity;
|
|
2289
|
+
busy = false;
|
|
2290
|
+
stopped = false;
|
|
2291
|
+
abort = new AbortController();
|
|
2292
|
+
async checkCapabilities() {
|
|
2293
|
+
const info = await this.client.serverInfo();
|
|
2294
|
+
if (!["identity", "blobs", "file-revisions"].every(
|
|
2295
|
+
(capability) => info.capabilities.includes(capability)
|
|
2296
|
+
))
|
|
2297
|
+
throw new Error("Server does not support file synchronization.");
|
|
2298
|
+
}
|
|
2299
|
+
setSettleMs(value) {
|
|
2300
|
+
if (!Number.isSafeInteger(value) || value < 0 || value > 864e5)
|
|
2301
|
+
throw new Error("Invalid stable wait interval.");
|
|
2302
|
+
this.settleMs = value;
|
|
2303
|
+
}
|
|
2304
|
+
close() {
|
|
2305
|
+
this.stopped = true;
|
|
2306
|
+
this.abort.abort();
|
|
2307
|
+
this.watcher?.close();
|
|
2308
|
+
this.watcher = void 0;
|
|
2309
|
+
}
|
|
2310
|
+
async cycle() {
|
|
2311
|
+
if (this.busy || this.stopped) throw new Error("Synchronizer is already running or closed.");
|
|
2312
|
+
this.busy = true;
|
|
2313
|
+
const settleMs = this.settleMs;
|
|
2314
|
+
const report = {
|
|
2315
|
+
published: false,
|
|
2316
|
+
appliedFiles: 0,
|
|
2317
|
+
waitingFiles: 0,
|
|
2318
|
+
pendingPublication: false,
|
|
2319
|
+
issues: this.watchIssues.splice(0)
|
|
2320
|
+
};
|
|
2321
|
+
try {
|
|
2322
|
+
try {
|
|
2323
|
+
await this.initializeAuthor();
|
|
2324
|
+
const ownStat = await lstat2(await safePath(this.root, this.binding.memberId));
|
|
2325
|
+
if (!ownStat.isDirectory() || ownStat.isSymbolicLink())
|
|
2326
|
+
throw new Error("Author directory is unavailable or is a symlink.");
|
|
2327
|
+
this.listen();
|
|
2328
|
+
if (this.clock() - this.lastScan >= (this.options.reconcileMs ?? 3e5)) {
|
|
2329
|
+
await this.reconcile("", report.issues);
|
|
2330
|
+
this.lastScan = this.clock();
|
|
2331
|
+
}
|
|
2332
|
+
const blocked = await this.refreshDirty(report.issues);
|
|
2333
|
+
await this.audit(report.issues);
|
|
2334
|
+
report.published = await this.queue.flush(this.root, this.clock());
|
|
2335
|
+
if (!this.state.pending(this.root) && !this.stopped) {
|
|
2336
|
+
await this.stage(blocked, report.issues, settleMs);
|
|
2337
|
+
report.published = await this.queue.flush(this.root, this.clock()) || report.published;
|
|
2338
|
+
}
|
|
2339
|
+
} catch (error) {
|
|
2340
|
+
report.issues.push(`publish: ${message(error)}`);
|
|
2341
|
+
}
|
|
2342
|
+
if (!this.stopped) {
|
|
2343
|
+
try {
|
|
2344
|
+
for (const member of (await this.client.members()).members) {
|
|
2345
|
+
if (member === this.binding.memberId || this.stopped) continue;
|
|
2346
|
+
try {
|
|
2347
|
+
report.appliedFiles += await this.receive(member);
|
|
2348
|
+
} catch (error) {
|
|
2349
|
+
report.issues.push(`${member}: ${message(error)}`);
|
|
2350
|
+
}
|
|
2351
|
+
}
|
|
2352
|
+
} catch (error) {
|
|
2353
|
+
report.issues.push(`receive: ${message(error)}`);
|
|
2354
|
+
}
|
|
2355
|
+
}
|
|
2356
|
+
report.waitingFiles = this.index.index(true).length;
|
|
2357
|
+
report.pendingPublication = Boolean(this.state.pending(this.root));
|
|
2358
|
+
this.index.saveStatus({ ...report, checkedAt: (/* @__PURE__ */ new Date()).toISOString() });
|
|
2359
|
+
return report;
|
|
2360
|
+
} finally {
|
|
2361
|
+
this.busy = false;
|
|
2362
|
+
}
|
|
2363
|
+
}
|
|
2364
|
+
async manifest(owner) {
|
|
2365
|
+
let revision;
|
|
2366
|
+
let after = "";
|
|
2367
|
+
const files = [];
|
|
2368
|
+
for (; ; ) {
|
|
2369
|
+
const page = await this.client.fileManifest(owner, revision, after);
|
|
2370
|
+
if (revision !== void 0 && page.revision !== revision)
|
|
2371
|
+
throw new Error("Manifest changed version during pagination.");
|
|
2372
|
+
revision = page.revision;
|
|
2373
|
+
files.push(...page.files);
|
|
2374
|
+
if (page.nextCursor === null) return { revision, files };
|
|
2375
|
+
if (page.nextCursor === after || files.length > 1e5)
|
|
2376
|
+
throw new Error("Manifest pagination did not make bounded progress.");
|
|
2377
|
+
after = page.nextCursor;
|
|
2378
|
+
}
|
|
2379
|
+
}
|
|
2380
|
+
async initializeAuthor() {
|
|
2381
|
+
if (this.state.publication(this.root).initialized) return;
|
|
2382
|
+
const manifest = await this.manifest(this.binding.memberId);
|
|
2383
|
+
if (manifest.revision > 0) {
|
|
2384
|
+
if (!this.options.adoptBaseline)
|
|
2385
|
+
throw new Error(
|
|
2386
|
+
"Published history exists but the local baseline is missing. Verify local files, then use --adopt-baseline."
|
|
2387
|
+
);
|
|
2388
|
+
for (const file of manifest.files) {
|
|
2389
|
+
const local = await readStable(this.ownRoot, file.path);
|
|
2390
|
+
if (local.sha256 !== file.sha256)
|
|
2391
|
+
throw new Error(
|
|
2392
|
+
`Cannot adopt baseline: local ${file.path} differs from the published file.`
|
|
2393
|
+
);
|
|
2394
|
+
}
|
|
2395
|
+
} else {
|
|
2396
|
+
const ownerPath = await safePath(this.root, this.binding.memberId);
|
|
2397
|
+
await mkdir6(ownerPath).catch((error) => {
|
|
2398
|
+
if (error.code !== "EEXIST") throw error;
|
|
2399
|
+
});
|
|
2400
|
+
}
|
|
2401
|
+
this.state.initializePublication(this.root, manifest.revision, manifest.files);
|
|
2402
|
+
}
|
|
2403
|
+
listen() {
|
|
2404
|
+
if (this.options.watch === false || this.watcher || this.stopped) return;
|
|
2405
|
+
try {
|
|
2406
|
+
this.watcher = watch(this.ownRoot, { recursive: true }, (_event, filename) => {
|
|
2407
|
+
if (!filename) {
|
|
2408
|
+
this.lastScan = -Infinity;
|
|
2409
|
+
return;
|
|
2410
|
+
}
|
|
2411
|
+
const path = filename.toString().split(sep4).join("/");
|
|
2412
|
+
if (!ignored(path)) this.index.event(path, this.clock());
|
|
2413
|
+
});
|
|
2414
|
+
this.watcher.on("error", (error) => {
|
|
2415
|
+
this.watchIssues.push(`File watcher: ${message(error)}; metadata scans remain enabled.`);
|
|
2416
|
+
this.watcher?.close();
|
|
2417
|
+
this.watcher = void 0;
|
|
2418
|
+
this.lastScan = -Infinity;
|
|
2419
|
+
});
|
|
2420
|
+
} catch (error) {
|
|
2421
|
+
this.watchIssues.push(`File watcher unavailable: ${message(error)}; using metadata scans.`);
|
|
2422
|
+
}
|
|
2423
|
+
}
|
|
2424
|
+
async reconcile(prefix, issues) {
|
|
2425
|
+
const scan = await walk(this.ownRoot, prefix);
|
|
2426
|
+
const now = this.clock();
|
|
2427
|
+
for (const [path, signature2] of scan.files) this.index.observe(path, signature2, now);
|
|
2428
|
+
if (scan.complete) {
|
|
2429
|
+
for (const prior of this.index.index()) {
|
|
2430
|
+
if ((!prefix || prior.path.startsWith(`${prefix}/`)) && !ignored(prior.path) && !scan.files.has(prior.path))
|
|
2431
|
+
this.index.observe(prior.path, null, now);
|
|
2432
|
+
}
|
|
2433
|
+
}
|
|
2434
|
+
issues.push(...scan.issues);
|
|
2435
|
+
}
|
|
2436
|
+
async refreshDirty(issues) {
|
|
2437
|
+
const blocked = /* @__PURE__ */ new Set();
|
|
2438
|
+
for (const prior of this.index.index(true)) {
|
|
2439
|
+
if (this.stopped) break;
|
|
2440
|
+
try {
|
|
2441
|
+
const current = await metadata(this.ownRoot, prior.path);
|
|
2442
|
+
if (current?.directory) {
|
|
2443
|
+
await this.reconcile(prior.path, issues);
|
|
2444
|
+
this.index.observe(prior.path, null, this.clock());
|
|
2445
|
+
} else this.index.observe(prior.path, current?.signature ?? null, this.clock());
|
|
2446
|
+
} catch (error) {
|
|
2447
|
+
blocked.add(prior.path);
|
|
2448
|
+
issues.push(`${prior.path}: ${message(error)}`);
|
|
2449
|
+
}
|
|
2450
|
+
}
|
|
2451
|
+
return blocked;
|
|
2452
|
+
}
|
|
2453
|
+
async audit(issues) {
|
|
2454
|
+
let bytes = 0;
|
|
2455
|
+
for (const prior of this.index.audit(
|
|
2456
|
+
this.clock() - (this.options.auditAgeMs ?? 864e5),
|
|
2457
|
+
this.options.auditBudget ?? 20
|
|
2458
|
+
)) {
|
|
2459
|
+
if (this.stopped || bytes > 20 * 1024 * 1024) break;
|
|
2460
|
+
try {
|
|
2461
|
+
const local = await readStable(this.ownRoot, prior.path);
|
|
2462
|
+
bytes += local.bytes.length;
|
|
2463
|
+
if (local.sha256 !== prior.hash || local.signature !== prior.signature)
|
|
2464
|
+
this.index.event(prior.path, this.clock());
|
|
2465
|
+
this.index.verified(prior.path, this.clock());
|
|
2466
|
+
} catch (error) {
|
|
2467
|
+
this.index.event(prior.path, this.clock());
|
|
2468
|
+
issues.push(`${prior.path}: ${message(error)}`);
|
|
2469
|
+
}
|
|
2470
|
+
}
|
|
2471
|
+
}
|
|
2472
|
+
async stage(blocked, issues, settleMs) {
|
|
2473
|
+
const ready = this.index.index(true).filter(
|
|
2474
|
+
(row) => !blocked.has(row.path) && !row.eventPending && this.clock() - row.observedAt >= settleMs
|
|
2475
|
+
);
|
|
2476
|
+
if (!ready.length) return;
|
|
2477
|
+
const baseline = this.state.ownerFiles(this.root);
|
|
2478
|
+
const planned = new Map(baseline);
|
|
2479
|
+
const changes = [];
|
|
2480
|
+
const snapshots = [];
|
|
2481
|
+
let bytes = 0;
|
|
2482
|
+
ready.sort((a, b) => Number(a.signature !== null) - Number(b.signature !== null));
|
|
2483
|
+
for (const row of ready) {
|
|
2484
|
+
if (this.stopped || changes.length >= MAX_BATCH_FILES || bytes > 20 * 1024 * 1024) break;
|
|
2485
|
+
try {
|
|
2486
|
+
if (row.signature === null) {
|
|
2487
|
+
const current = await metadata(this.ownRoot, row.path);
|
|
2488
|
+
if (current && !current.directory) {
|
|
2489
|
+
this.index.event(row.path, this.clock());
|
|
2490
|
+
continue;
|
|
2491
|
+
}
|
|
2492
|
+
if (baseline.has(row.path)) {
|
|
2493
|
+
changes.push({ kind: "delete", path: row.path });
|
|
2494
|
+
planned.delete(row.path);
|
|
2495
|
+
} else {
|
|
2496
|
+
this.index.clean(row.path, row.generation, null, this.clock());
|
|
2497
|
+
continue;
|
|
2498
|
+
}
|
|
2499
|
+
snapshots.push({ path: row.path, generation: row.generation, hash: null });
|
|
2500
|
+
continue;
|
|
2501
|
+
}
|
|
2502
|
+
const key = row.path.toLowerCase();
|
|
2503
|
+
const collision = [...planned.keys()].some(
|
|
2504
|
+
(path) => path !== row.path && (path.toLowerCase() === key || path.toLowerCase().startsWith(`${key}/`) || key.startsWith(`${path.toLowerCase()}/`))
|
|
2505
|
+
);
|
|
2506
|
+
if (collision) continue;
|
|
2507
|
+
const local = await readStable(this.ownRoot, row.path);
|
|
2508
|
+
bytes += local.bytes.length;
|
|
2509
|
+
if (local.signature !== row.signature || this.index.get(row.path)?.generation !== row.generation) {
|
|
2510
|
+
this.index.event(row.path, this.clock());
|
|
2511
|
+
continue;
|
|
2512
|
+
}
|
|
2513
|
+
if (baseline.get(row.path)?.sha256 === local.sha256) {
|
|
2514
|
+
this.index.clean(row.path, row.generation, local.sha256, this.clock());
|
|
2515
|
+
continue;
|
|
2516
|
+
}
|
|
2517
|
+
const ref = await this.blobs.put(local.bytes);
|
|
2518
|
+
changes.push({ kind: "upsert", path: row.path, ...ref });
|
|
2519
|
+
planned.set(row.path, ref);
|
|
2520
|
+
snapshots.push({ path: row.path, generation: row.generation, hash: local.sha256 });
|
|
2521
|
+
} catch (error) {
|
|
2522
|
+
this.index.event(row.path, this.clock());
|
|
2523
|
+
issues.push(`${row.path}: ${message(error)}`);
|
|
2524
|
+
}
|
|
2525
|
+
}
|
|
2526
|
+
if (!changes.length) return;
|
|
2527
|
+
const rootStat = await lstat2(this.ownRoot);
|
|
2528
|
+
if (!rootStat.isDirectory() || rootStat.isSymbolicLink())
|
|
2529
|
+
throw new Error("Author directory disappeared before publication.");
|
|
2530
|
+
this.state.enqueue(this.root, {
|
|
2531
|
+
operationId: randomUUID7(),
|
|
2532
|
+
parentRevision: this.state.publication(this.root).revision,
|
|
2533
|
+
changes
|
|
2534
|
+
});
|
|
2535
|
+
for (const snapshot of snapshots)
|
|
2536
|
+
this.index.clean(snapshot.path, snapshot.generation, snapshot.hash, this.clock());
|
|
2537
|
+
}
|
|
2538
|
+
async receive(owner) {
|
|
2539
|
+
if (!this.index.job(owner)) {
|
|
2540
|
+
const revision = this.index.revision(owner);
|
|
2541
|
+
if (revision === void 0) {
|
|
2542
|
+
const manifest = await this.manifest(owner);
|
|
2543
|
+
this.index.queue(
|
|
2544
|
+
owner,
|
|
2545
|
+
manifest.revision,
|
|
2546
|
+
manifest.files.map((file) => ({
|
|
2547
|
+
kind: "upsert",
|
|
2548
|
+
path: file.path,
|
|
2549
|
+
sha256: file.sha256,
|
|
2550
|
+
size: file.size
|
|
2551
|
+
}))
|
|
2552
|
+
);
|
|
2553
|
+
} else {
|
|
2554
|
+
const delta = await this.client.fileChanges(owner, revision);
|
|
2555
|
+
if (!delta.revisions.length) return 0;
|
|
2556
|
+
const changes = /* @__PURE__ */ new Map();
|
|
2557
|
+
let expected = revision + 1;
|
|
2558
|
+
for (const entry of delta.revisions) {
|
|
2559
|
+
if (entry.revision !== expected++) throw new Error("Server change history has a gap.");
|
|
2560
|
+
for (const change of entry.changes) changes.set(change.path, change);
|
|
2561
|
+
}
|
|
2562
|
+
const finalNames = new Set(
|
|
2563
|
+
[...changes.values()].filter((change) => change.kind === "upsert").map((change) => change.path.toLowerCase())
|
|
2564
|
+
);
|
|
2565
|
+
for (const change of changes.values()) {
|
|
2566
|
+
if (change.kind === "delete" && !this.index.received(owner, change.path) && finalNames.has(change.path.toLowerCase()))
|
|
2567
|
+
changes.delete(change.path);
|
|
2568
|
+
}
|
|
2569
|
+
const ordered = [...changes.values()].sort(
|
|
2570
|
+
(a, b) => a.kind !== b.kind ? a.kind === "delete" ? -1 : 1 : a.kind === "delete" ? b.path.length - a.path.length : a.path.length - b.path.length
|
|
2571
|
+
);
|
|
2572
|
+
this.index.queue(owner, delta.revisions.at(-1).revision, ordered);
|
|
2573
|
+
}
|
|
2574
|
+
}
|
|
2575
|
+
const job = this.index.job(owner);
|
|
2576
|
+
const ownerRoot = await safePath(this.root, owner);
|
|
2577
|
+
await mkdir6(ownerRoot).catch((error) => {
|
|
2578
|
+
if (error.code !== "EEXIST") throw error;
|
|
2579
|
+
});
|
|
2580
|
+
const ownerStat = await lstat2(ownerRoot);
|
|
2581
|
+
if (!ownerStat.isDirectory() || ownerStat.isSymbolicLink())
|
|
2582
|
+
throw new Error("Mirror root is not an ordinary directory.");
|
|
2583
|
+
let count = 0;
|
|
2584
|
+
let bytes = 0;
|
|
2585
|
+
for (let offset = job.nextIndex; offset < job.changes.length; offset++) {
|
|
2586
|
+
if (this.stopped || count >= (this.options.applyBudget ?? 200) || bytes > 20 * 1024 * 1024)
|
|
2587
|
+
break;
|
|
2588
|
+
const change = job.changes[offset];
|
|
2589
|
+
await this.apply(owner, ownerRoot, change);
|
|
2590
|
+
this.index.applied(owner, change, offset + 1);
|
|
2591
|
+
count++;
|
|
2592
|
+
if (change.kind === "upsert") bytes += change.size;
|
|
2593
|
+
}
|
|
2594
|
+
if (this.index.job(owner).nextIndex === job.changes.length) this.index.finish(owner);
|
|
2595
|
+
return count;
|
|
2596
|
+
}
|
|
2597
|
+
async apply(owner, root, change) {
|
|
2598
|
+
const prior = this.index.received(owner, change.path);
|
|
2599
|
+
const current = await metadata(root, change.path);
|
|
2600
|
+
let local;
|
|
2601
|
+
if (current && !current.directory) local = await readStable(root, change.path);
|
|
2602
|
+
if (change.kind === "delete") {
|
|
2603
|
+
if (!current) return;
|
|
2604
|
+
if (current.directory && !prior) return;
|
|
2605
|
+
if (!local || !prior || local.sha256 !== prior.sha256)
|
|
2606
|
+
throw new Error(`Local mirror differs; preserving ${change.path}.`);
|
|
2607
|
+
await removeFile(root, change.path, local.signature);
|
|
2608
|
+
} else {
|
|
2609
|
+
if (local?.sha256 === change.sha256) return;
|
|
2610
|
+
if (local && (!prior || local.sha256 !== prior.sha256))
|
|
2611
|
+
throw new Error(`Local mirror differs; preserving ${change.path}.`);
|
|
2612
|
+
if (!current && prior && !this.options.restoreMissing)
|
|
2613
|
+
throw new Error(
|
|
2614
|
+
`Local mirror was removed; use --restore-missing to restore ${change.path}.`
|
|
2615
|
+
);
|
|
2616
|
+
let content = await this.blobs.get(change.sha256);
|
|
2617
|
+
if (!content) {
|
|
2618
|
+
content = await this.client.downloadBlob(change.sha256);
|
|
2619
|
+
await this.blobs.put(content);
|
|
2620
|
+
}
|
|
2621
|
+
if (content.byteLength !== change.size)
|
|
2622
|
+
throw new Error(`Content size mismatch: ${change.path}`);
|
|
2623
|
+
await replaceFile(root, change.path, content, local?.signature ?? null);
|
|
2624
|
+
}
|
|
2625
|
+
}
|
|
2626
|
+
};
|
|
2627
|
+
function message(error) {
|
|
2628
|
+
return error instanceof Error ? error.message : String(error);
|
|
2629
|
+
}
|
|
2630
|
+
|
|
2631
|
+
// apps/client/sync-command.ts
|
|
2632
|
+
var help2 = `Usage: teammy sync [options]
|
|
2633
|
+
|
|
2634
|
+
--root DIR Shared root (default: ./workspaces)
|
|
2635
|
+
--state-dir DIR Local state (default: ~/.teammy/client)
|
|
2636
|
+
--once Run one check and exit; pending changes remain saved
|
|
2637
|
+
--interval-ms N Check interval (default: 60000, minimum: 1000)
|
|
2638
|
+
--settle-ms N Per-file quiet period (default: 60000)
|
|
2639
|
+
--adopt-baseline Accept existing server history only if every published file matches locally
|
|
2640
|
+
--restore-missing Restore missing mirror files; existing local edits are still preserved
|
|
2641
|
+
--retry Retry file/session publications paused by a previous failure
|
|
2642
|
+
--sessions-only Collect sessions without applying shared files
|
|
2643
|
+
-h, --help Show this help
|
|
2644
|
+
|
|
2645
|
+
Runs in the foreground until SIGINT/SIGTERM. Each run restarts the quiet period
|
|
2646
|
+
for pending files; --once does not wait for edits to settle. --settle-ms 0 can be
|
|
2647
|
+
used for an explicit immediate check. Only this member's directory is uploaded.
|
|
2648
|
+
`;
|
|
2649
|
+
async function runSyncCommand(args, version) {
|
|
2650
|
+
const { values } = parseArgs2({
|
|
2651
|
+
args,
|
|
2652
|
+
options: {
|
|
2653
|
+
root: { type: "string", default: resolve5("workspaces") },
|
|
2654
|
+
"state-dir": { type: "string", default: join10(homedir2(), ".teammy", "client") },
|
|
2655
|
+
once: { type: "boolean" },
|
|
2656
|
+
"interval-ms": { type: "string" },
|
|
2657
|
+
"settle-ms": { type: "string" },
|
|
2658
|
+
"adopt-baseline": { type: "boolean" },
|
|
2659
|
+
"restore-missing": { type: "boolean" },
|
|
2660
|
+
retry: { type: "boolean" },
|
|
2661
|
+
"sessions-only": { type: "boolean" },
|
|
2662
|
+
help: { type: "boolean", short: "h" }
|
|
2663
|
+
}
|
|
2664
|
+
});
|
|
2665
|
+
if (values.help) {
|
|
2666
|
+
process.stdout.write(help2);
|
|
2667
|
+
return 0;
|
|
2668
|
+
}
|
|
2669
|
+
let intervalMs = Number(values["interval-ms"] ?? 6e4);
|
|
2670
|
+
const settleMs = Number(values["settle-ms"] ?? 6e4);
|
|
2671
|
+
if (!Number.isSafeInteger(intervalMs) || intervalMs < 1e3 || intervalMs > 864e5 || !Number.isSafeInteger(settleMs) || settleMs < 0 || settleMs > 864e5)
|
|
2672
|
+
throw new Error("Invalid sync timing options.");
|
|
2673
|
+
const root = await realpath5(resolve5(values.root));
|
|
2674
|
+
const stateDirectory = await stateLocation(root, values["state-dir"]);
|
|
2675
|
+
const lock = acquireRootLock(root, stateDirectory);
|
|
2676
|
+
let state;
|
|
2677
|
+
let synchronizer;
|
|
2678
|
+
let sessions;
|
|
2679
|
+
let control;
|
|
2680
|
+
const stopped = new AbortController();
|
|
2681
|
+
const stop = () => {
|
|
2682
|
+
stopped.abort();
|
|
2683
|
+
synchronizer?.close();
|
|
2684
|
+
};
|
|
2685
|
+
try {
|
|
2686
|
+
state = new ClientState(stateDirectory);
|
|
2687
|
+
if (values.retry) state.retryPublication(root);
|
|
2688
|
+
sessions = new SessionSynchronizer(state, root, stateDirectory, stopped.signal);
|
|
2689
|
+
if (values.retry) sessions.index.retry();
|
|
2690
|
+
if (!values["sessions-only"])
|
|
2691
|
+
synchronizer = new FileSynchronizer(state, root, stateDirectory, {
|
|
2692
|
+
settleMs,
|
|
2693
|
+
adoptBaseline: Boolean(values["adopt-baseline"]),
|
|
2694
|
+
restoreMissing: Boolean(values["restore-missing"])
|
|
2695
|
+
});
|
|
2696
|
+
const overrides = {};
|
|
2697
|
+
if (values["interval-ms"] !== void 0) overrides.checkIntervalSeconds = intervalMs / 1e3;
|
|
2698
|
+
if (values["settle-ms"] !== void 0) overrides.fileSettleSeconds = settleMs / 1e3;
|
|
2699
|
+
control = new ControlPlane(state, root, version, overrides, (policy) => {
|
|
2700
|
+
intervalMs = Math.round(policy.checkIntervalSeconds * 1e3);
|
|
2701
|
+
synchronizer?.setSettleMs(Math.round(policy.fileSettleSeconds * 1e3));
|
|
2702
|
+
});
|
|
2703
|
+
await control.tick();
|
|
2704
|
+
await synchronizer?.checkCapabilities();
|
|
2705
|
+
if (!values.once) control.start();
|
|
2706
|
+
process.once("SIGINT", stop);
|
|
2707
|
+
process.once("SIGTERM", stop);
|
|
2708
|
+
do {
|
|
2709
|
+
const report = synchronizer ? await synchronizer.cycle() : { issues: [], published: false, appliedFiles: 0, waitingFiles: 0 };
|
|
2710
|
+
let sessionReport;
|
|
2711
|
+
try {
|
|
2712
|
+
sessionReport = await sessions.cycle();
|
|
2713
|
+
} catch (error) {
|
|
2714
|
+
sessionReport = {
|
|
2715
|
+
published: 0,
|
|
2716
|
+
checked: 0,
|
|
2717
|
+
issues: [error instanceof Error ? error.message : "Session synchronization failed."]
|
|
2718
|
+
};
|
|
2719
|
+
}
|
|
2720
|
+
control.updateSync({
|
|
2721
|
+
checkedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
2722
|
+
published: report.published,
|
|
2723
|
+
appliedFiles: report.appliedFiles,
|
|
2724
|
+
pendingFiles: report.waitingFiles + (state.pending(root)?.batch.changes.length ?? 0),
|
|
2725
|
+
sessionSources: Number(
|
|
2726
|
+
state.db.prepare("SELECT COUNT(*) n FROM session_sources WHERE root=?").get(root).n
|
|
2727
|
+
),
|
|
2728
|
+
errors: [...report.issues, ...sessionReport.issues].slice(0, 20).map((message2) => message2.slice(0, 1e3))
|
|
2729
|
+
});
|
|
2730
|
+
if (values.once && control.enabled) await control.tick();
|
|
2731
|
+
const combined = {
|
|
2732
|
+
...report,
|
|
2733
|
+
sessions: sessionReport,
|
|
2734
|
+
control: control.enabled ? control.status : null
|
|
2735
|
+
};
|
|
2736
|
+
process.stdout.write(`${JSON.stringify(combined)}
|
|
2737
|
+
`);
|
|
2738
|
+
if (values.once)
|
|
2739
|
+
return report.issues.length || sessionReport.issues.length || control.enabled && (control.status.configError || control.status.reportError) ? 1 : 0;
|
|
2740
|
+
const finishedAt = Date.now();
|
|
2741
|
+
while (!stopped.signal.aborted && Date.now() - finishedAt < intervalMs)
|
|
2742
|
+
await setTimeout3(Math.min(1e3, intervalMs - (Date.now() - finishedAt)), void 0, {
|
|
2743
|
+
signal: stopped.signal
|
|
2744
|
+
}).catch((error) => {
|
|
2745
|
+
if (!stopped.signal.aborted) throw error;
|
|
2746
|
+
});
|
|
2747
|
+
} while (!stopped.signal.aborted);
|
|
2748
|
+
return 0;
|
|
2749
|
+
} finally {
|
|
2750
|
+
process.off("SIGINT", stop);
|
|
2751
|
+
process.off("SIGTERM", stop);
|
|
2752
|
+
synchronizer?.close();
|
|
2753
|
+
await control?.close();
|
|
2754
|
+
await sessions?.close();
|
|
2755
|
+
state?.close();
|
|
2756
|
+
lock.close();
|
|
2757
|
+
}
|
|
2758
|
+
}
|
|
2759
|
+
|
|
2760
|
+
// apps/client/main.ts
|
|
2761
|
+
var help3 = `teammy \u2014 the Teammy collaboration client
|
|
2762
|
+
|
|
2763
|
+
Usage:
|
|
2764
|
+
teammy Show this help
|
|
2765
|
+
teammy --help Show this help
|
|
2766
|
+
teammy --version Show the installed client version
|
|
2767
|
+
teammy server-info --url URL Check server connection and protocol capabilities
|
|
2768
|
+
teammy server-info --help Show connection options
|
|
2769
|
+
teammy connect --url URL [--member ID | --token-file FILE] [--reconnect] [--root DIR] [--state-dir DIR]
|
|
2770
|
+
teammy status [--root DIR] [--state-dir DIR]
|
|
2771
|
+
teammy sync [--root DIR] [--once]
|
|
2772
|
+
teammy session --help Collect, read and search Agent sessions
|
|
2773
|
+
|
|
2774
|
+
Root defaults to ./workspaces; local state defaults to ~/.teammy/client.
|
|
2775
|
+
sync runs foreground synchronization; manage project settings in the server dashboard.
|
|
2776
|
+
The hub server is installed separately as @teammy/server (command: teammy-server).
|
|
2777
|
+
`;
|
|
2778
|
+
var serverInfoHelp = `Usage: teammy server-info --url URL
|
|
2779
|
+
|
|
2780
|
+
Options:
|
|
2781
|
+
--url URL Hub HTTP(S) URL, including an optional path prefix
|
|
2782
|
+
-h, --help Show this help
|
|
2783
|
+
|
|
2784
|
+
Prints server information as JSON after validating the protocol response.
|
|
2785
|
+
This command does not save a connection or bind a workspace.
|
|
2786
|
+
`;
|
|
2787
|
+
async function runCli(args, version) {
|
|
2788
|
+
const command = args[0];
|
|
2789
|
+
if (args.length === 0 || args.length === 1 && ["--help", "-h", "help"].includes(command)) {
|
|
2790
|
+
process.stdout.write(help3);
|
|
2791
|
+
return 0;
|
|
2792
|
+
}
|
|
2793
|
+
if (args.length === 1 && ["--version", "-v", "version"].includes(command)) {
|
|
2794
|
+
process.stdout.write(`${version}
|
|
2795
|
+
`);
|
|
2796
|
+
return 0;
|
|
2797
|
+
}
|
|
2798
|
+
if (command === "server") {
|
|
2799
|
+
process.stderr.write('Install @teammy/server and run "teammy-server" to start the hub.\n');
|
|
2800
|
+
return 1;
|
|
2801
|
+
}
|
|
2802
|
+
if (command === "session") {
|
|
2803
|
+
try {
|
|
2804
|
+
return await runSessionCommand(args.slice(1));
|
|
2805
|
+
} catch (error) {
|
|
2806
|
+
if (args[1] === "hook") return 0;
|
|
2807
|
+
process.stderr.write(
|
|
2808
|
+
`${error instanceof Error ? error.message : "Session command failed."}
|
|
2809
|
+
`
|
|
2810
|
+
);
|
|
2811
|
+
return 1;
|
|
2812
|
+
}
|
|
2813
|
+
}
|
|
2814
|
+
if (command === "sync") {
|
|
2815
|
+
try {
|
|
2816
|
+
return await runSyncCommand(args.slice(1), version);
|
|
2817
|
+
} catch (error) {
|
|
2818
|
+
process.stderr.write(
|
|
2819
|
+
`${error instanceof Error ? error.message : "Synchronization failed."}
|
|
2820
|
+
`
|
|
2821
|
+
);
|
|
2822
|
+
return 1;
|
|
2823
|
+
}
|
|
2824
|
+
}
|
|
2825
|
+
if (command === "connect" || command === "status") {
|
|
2826
|
+
try {
|
|
2827
|
+
const { values } = parseArgs3({
|
|
2828
|
+
args: args.slice(1),
|
|
2829
|
+
options: {
|
|
2830
|
+
url: { type: "string" },
|
|
2831
|
+
"token-file": { type: "string" },
|
|
2832
|
+
member: { type: "string" },
|
|
2833
|
+
reconnect: { type: "boolean" },
|
|
2834
|
+
root: { type: "string", default: resolve6("workspaces") },
|
|
2835
|
+
"state-dir": { type: "string", default: join11(homedir3(), ".teammy", "client") }
|
|
2836
|
+
}
|
|
2837
|
+
});
|
|
2838
|
+
const rootPath = resolve6(values.root);
|
|
2839
|
+
if (command === "connect") await mkdir7(rootPath, { recursive: true });
|
|
2840
|
+
const root = await realpath6(rootPath);
|
|
2841
|
+
const state = new ClientState(await stateLocation(root, values["state-dir"]));
|
|
2842
|
+
try {
|
|
2843
|
+
if (command === "status") {
|
|
2844
|
+
const binding = state.binding(root);
|
|
2845
|
+
if (!binding) throw new Error("This root is not connected. Run teammy connect first.");
|
|
2846
|
+
const { token: _token, ...publicBinding } = binding;
|
|
2847
|
+
process.stdout.write(
|
|
2848
|
+
`${JSON.stringify({ ...publicBinding, control: readControlStatus(state, root), sessions: new SessionState(state, root).status(), publication: state.publication(root), pending: state.pending(root) ? { operationId: state.pending(root).batch.operationId, retryAt: state.pending(root).retryAt, lastError: state.pending(root).lastError } : null, sync: new SyncState(state, root).status() }, null, 2)}
|
|
2849
|
+
`
|
|
2850
|
+
);
|
|
2851
|
+
} else {
|
|
2852
|
+
if (!values.url) throw new Error("--url is required.");
|
|
2853
|
+
const connected = await connectProject(
|
|
2854
|
+
state,
|
|
2855
|
+
root,
|
|
2856
|
+
await stateLocation(root, values["state-dir"]),
|
|
2857
|
+
{
|
|
2858
|
+
url: values.url,
|
|
2859
|
+
member: values.member,
|
|
2860
|
+
tokenFile: values["token-file"],
|
|
2861
|
+
reconnect: values.reconnect
|
|
2862
|
+
}
|
|
2863
|
+
);
|
|
2864
|
+
process.stdout.write(`${JSON.stringify(connected, null, 2)}
|
|
2865
|
+
`);
|
|
2866
|
+
}
|
|
2867
|
+
} finally {
|
|
2868
|
+
state.close();
|
|
2869
|
+
}
|
|
2870
|
+
return 0;
|
|
2871
|
+
} catch (error) {
|
|
2872
|
+
process.stderr.write(
|
|
2873
|
+
`${error instanceof Error ? error.message : "Unable to open connection."}
|
|
2874
|
+
`
|
|
2875
|
+
);
|
|
2876
|
+
return 1;
|
|
2877
|
+
}
|
|
2878
|
+
}
|
|
2879
|
+
if (command !== "server-info") {
|
|
2880
|
+
process.stderr.write('Unknown command or arguments. Run "teammy --help" for usage.\n');
|
|
2881
|
+
return 1;
|
|
2882
|
+
}
|
|
2883
|
+
try {
|
|
2884
|
+
const { values } = parseArgs3({
|
|
2885
|
+
args: args.slice(1),
|
|
2886
|
+
options: {
|
|
2887
|
+
help: { type: "boolean", short: "h" },
|
|
2888
|
+
url: { type: "string" }
|
|
2889
|
+
}
|
|
2890
|
+
});
|
|
2891
|
+
if (values.help) {
|
|
2892
|
+
process.stdout.write(serverInfoHelp);
|
|
2893
|
+
return 0;
|
|
2894
|
+
}
|
|
2895
|
+
if (!values.url?.trim()) throw new Error("--url is required.");
|
|
2896
|
+
const info = await new HubClient(values.url).serverInfo();
|
|
2897
|
+
process.stdout.write(`${JSON.stringify(info, null, 2)}
|
|
2898
|
+
`);
|
|
2899
|
+
return 0;
|
|
2900
|
+
} catch (error) {
|
|
2901
|
+
process.stderr.write(`${error instanceof Error ? error.message : "Unable to reach server."}
|
|
2902
|
+
`);
|
|
2903
|
+
return 1;
|
|
2904
|
+
}
|
|
2905
|
+
}
|
|
2906
|
+
export {
|
|
2907
|
+
runCli
|
|
2908
|
+
};
|