@teammy/cli 0.0.5 → 0.0.6
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 +27 -12
- package/bin/teammy.js +8 -1
- package/dist/main.js +2225 -1235
- package/package.json +1 -1
package/dist/main.js
CHANGED
|
@@ -1,14 +1,15 @@
|
|
|
1
|
-
// apps/client/
|
|
2
|
-
import {
|
|
1
|
+
// apps/client/main.ts
|
|
2
|
+
import { parseArgs as parseArgs4 } from "node:util";
|
|
3
|
+
import { resolve as resolve8 } from "node:path";
|
|
3
4
|
|
|
4
|
-
// packages/protocol/
|
|
5
|
+
// packages/protocol/project.ts
|
|
5
6
|
import { z as z3 } from "zod";
|
|
6
7
|
|
|
7
8
|
// packages/protocol/identity.ts
|
|
8
9
|
import { z } from "zod";
|
|
9
10
|
var NameSchema = z.string().regex(/^[a-z][a-z0-9_-]{0,63}$/);
|
|
10
11
|
var DeviceIdSchema = z.uuid();
|
|
11
|
-
var BindRequestSchema = z.object({ deviceId: DeviceIdSchema }).strict();
|
|
12
|
+
var BindRequestSchema = z.object({ deviceId: DeviceIdSchema, projectId: NameSchema.optional() }).strict();
|
|
12
13
|
var BindingSchema = z.object({
|
|
13
14
|
workspaceId: NameSchema,
|
|
14
15
|
memberId: NameSchema,
|
|
@@ -82,193 +83,198 @@ var FileTreeSchema = z2.object({
|
|
|
82
83
|
});
|
|
83
84
|
var FileSelectionSchema = z2.object({ revision: RevisionSchema, file: FileEntrySchema });
|
|
84
85
|
|
|
85
|
-
// packages/protocol/devices.ts
|
|
86
|
-
var CredentialSchema = z3.string().regex(/^tmy_[a-f0-9]{64}$/);
|
|
87
|
-
var EnrollmentSchema = z3.object({
|
|
88
|
-
operationId: z3.uuid(),
|
|
89
|
-
memberId: NameSchema,
|
|
90
|
-
deviceId: DeviceIdSchema,
|
|
91
|
-
credential: CredentialSchema
|
|
92
|
-
}).strict();
|
|
93
|
-
var EffectivePolicySchema = z3.object({
|
|
94
|
-
checkIntervalSeconds: z3.number().min(1).max(86400),
|
|
95
|
-
fileSettleSeconds: z3.number().min(0).max(86400)
|
|
96
|
-
}).strict();
|
|
97
|
-
var SyncSummarySchema = z3.object({
|
|
98
|
-
checkedAt: z3.string(),
|
|
99
|
-
published: z3.boolean(),
|
|
100
|
-
appliedFiles: z3.number().int().nonnegative(),
|
|
101
|
-
pendingFiles: z3.number().int().nonnegative(),
|
|
102
|
-
sessionSources: z3.number().int().nonnegative(),
|
|
103
|
-
errors: z3.array(z3.string().max(1e3)).max(20)
|
|
104
|
-
}).strict();
|
|
105
|
-
var DeviceStatusSchema = z3.object({
|
|
106
|
-
clientVersion: z3.string().max(64),
|
|
107
|
-
receivedRevision: RevisionSchema.nullable(),
|
|
108
|
-
appliedRevision: RevisionSchema.nullable(),
|
|
109
|
-
effectivePolicy: EffectivePolicySchema,
|
|
110
|
-
overrides: z3.array(z3.enum(["checkIntervalSeconds", "fileSettleSeconds"])).max(2),
|
|
111
|
-
configError: z3.string().max(2e3).nullable(),
|
|
112
|
-
reportError: z3.string().max(2e3).nullable(),
|
|
113
|
-
lastSync: SyncSummarySchema.nullable()
|
|
114
|
-
}).strict();
|
|
115
|
-
var WebDevicesSchema = z3.object({
|
|
116
|
-
devices: z3.array(
|
|
117
|
-
z3.object({
|
|
118
|
-
memberId: NameSchema,
|
|
119
|
-
bound: z3.boolean(),
|
|
120
|
-
lastSeenAt: z3.string().nullable(),
|
|
121
|
-
status: DeviceStatusSchema.nullable()
|
|
122
|
-
})
|
|
123
|
-
)
|
|
124
|
-
});
|
|
125
|
-
|
|
126
86
|
// packages/protocol/project.ts
|
|
127
|
-
|
|
128
|
-
var ServerUrlSchema = z4.string().url().max(2048).refine((value) => {
|
|
87
|
+
var ServerUrlSchema = z3.string().url().max(2048).refine((value) => {
|
|
129
88
|
const url = new URL(value);
|
|
130
89
|
return ["http:", "https:"].includes(url.protocol) && !url.username && !url.password && !url.search && !url.hash;
|
|
131
90
|
}, "Use an HTTP(S) server URL without credentials, query or fragment.");
|
|
132
|
-
var SyncPolicySchema =
|
|
133
|
-
checkIntervalSeconds:
|
|
134
|
-
fileSettleSeconds:
|
|
91
|
+
var SyncPolicySchema = z3.object({
|
|
92
|
+
checkIntervalSeconds: z3.number().int().min(1).max(86400),
|
|
93
|
+
fileSettleSeconds: z3.number().int().min(0).max(86400)
|
|
135
94
|
}).strict();
|
|
136
95
|
var DEFAULT_POLICY = { checkIntervalSeconds: 60, fileSettleSeconds: 60 };
|
|
137
|
-
var ProjectConfigSchema =
|
|
138
|
-
schemaVersion:
|
|
96
|
+
var ProjectConfigSchema = z3.object({
|
|
97
|
+
schemaVersion: z3.literal(1),
|
|
139
98
|
projectId: NameSchema,
|
|
140
|
-
projectName:
|
|
141
|
-
description:
|
|
99
|
+
projectName: z3.string().trim().min(1).max(120),
|
|
100
|
+
description: z3.string().max(2e3),
|
|
142
101
|
publicServerUrl: ServerUrlSchema,
|
|
143
102
|
configRevision: RevisionSchema,
|
|
144
|
-
scopeProfile:
|
|
103
|
+
scopeProfile: z3.literal("workspace-and-sessions-v1"),
|
|
145
104
|
sync: SyncPolicySchema
|
|
146
105
|
}).strict();
|
|
147
|
-
var SetupSchema =
|
|
148
|
-
operationId:
|
|
149
|
-
projectName:
|
|
150
|
-
description:
|
|
106
|
+
var SetupSchema = z3.object({
|
|
107
|
+
operationId: z3.uuid(),
|
|
108
|
+
projectName: z3.string().trim().min(1).max(120),
|
|
109
|
+
description: z3.string().max(2e3).default(""),
|
|
151
110
|
publicServerUrl: ServerUrlSchema,
|
|
152
111
|
adminId: NameSchema,
|
|
153
|
-
members:
|
|
154
|
-
existingWorkspaceId: NameSchema.optional(),
|
|
112
|
+
members: z3.array(NameSchema).max(100).default([]),
|
|
155
113
|
sync: SyncPolicySchema.default(DEFAULT_POLICY)
|
|
156
114
|
}).strict();
|
|
157
|
-
var SetupStatusSchema =
|
|
158
|
-
initialized:
|
|
159
|
-
existingWorkspaces: z4.array(NameSchema)
|
|
115
|
+
var SetupStatusSchema = z3.object({
|
|
116
|
+
initialized: z3.boolean()
|
|
160
117
|
});
|
|
161
118
|
var ConfigUpdateSchema = ProjectConfigSchema.pick({
|
|
162
119
|
projectName: true,
|
|
163
|
-
description: true
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
}).extend({ expectedRevision: RevisionSchema }).strict();
|
|
167
|
-
var MemberSchema = z4.object({
|
|
120
|
+
description: true
|
|
121
|
+
}).extend({ expectedRevision: RevisionSchema.optional() }).strict();
|
|
122
|
+
var MemberSchema = z3.object({
|
|
168
123
|
id: NameSchema,
|
|
169
|
-
displayName:
|
|
170
|
-
|
|
171
|
-
|
|
124
|
+
displayName: z3.string().max(120),
|
|
125
|
+
enabled: z3.boolean(),
|
|
126
|
+
accountEnabled: z3.boolean(),
|
|
172
127
|
revision: RevisionSchema,
|
|
173
|
-
bound:
|
|
128
|
+
bound: z3.boolean(),
|
|
174
129
|
fileRevision: RevisionSchema,
|
|
175
|
-
lastPublishedAt:
|
|
130
|
+
lastPublishedAt: z3.string().nullable()
|
|
176
131
|
});
|
|
177
|
-
var
|
|
178
|
-
workspaceId: NameSchema,
|
|
132
|
+
var WebIdentitySchema = z3.object({
|
|
179
133
|
memberId: NameSchema,
|
|
180
|
-
displayName:
|
|
181
|
-
|
|
134
|
+
displayName: z3.string(),
|
|
135
|
+
siteRole: z3.enum(["admin", "member"])
|
|
136
|
+
});
|
|
137
|
+
var WebSessionSchema = WebIdentitySchema.extend({
|
|
138
|
+
workspaceId: NameSchema
|
|
139
|
+
});
|
|
140
|
+
var MemberCreateSchema = z3.object({
|
|
141
|
+
id: NameSchema
|
|
142
|
+
}).strict();
|
|
143
|
+
var MemberUpdateSchema = z3.object({
|
|
144
|
+
expectedRevision: RevisionSchema,
|
|
145
|
+
displayName: z3.string().trim().max(120).optional(),
|
|
146
|
+
enabled: z3.boolean().optional()
|
|
147
|
+
}).strict();
|
|
148
|
+
var ProjectCreateSchema = z3.object({
|
|
149
|
+
operationId: z3.uuid(),
|
|
150
|
+
projectName: z3.string().trim().min(1).max(120),
|
|
151
|
+
description: z3.string().max(2e3).default("")
|
|
152
|
+
}).strict();
|
|
153
|
+
var ProjectSummarySchema = z3.object({
|
|
154
|
+
projectId: NameSchema,
|
|
155
|
+
projectName: z3.string(),
|
|
156
|
+
description: z3.string()
|
|
157
|
+
});
|
|
158
|
+
var ProjectsSchema = z3.object({ projects: z3.array(ProjectSummarySchema) });
|
|
159
|
+
var SiteConfigSchema = z3.object({
|
|
160
|
+
sync: SyncPolicySchema,
|
|
161
|
+
publicServerUrl: ServerUrlSchema,
|
|
162
|
+
revision: RevisionSchema
|
|
163
|
+
});
|
|
164
|
+
var SiteConfigUpdateSchema = z3.object({
|
|
165
|
+
publicServerUrl: ServerUrlSchema.optional(),
|
|
166
|
+
sync: SyncPolicySchema.optional(),
|
|
167
|
+
expectedRevision: RevisionSchema
|
|
168
|
+
}).strict().refine(
|
|
169
|
+
(v) => v.publicServerUrl !== void 0 || v.sync !== void 0,
|
|
170
|
+
"Provide a setting to update."
|
|
171
|
+
);
|
|
172
|
+
var UserSchema = z3.object({
|
|
173
|
+
id: NameSchema,
|
|
174
|
+
displayName: z3.string(),
|
|
175
|
+
siteRole: z3.enum(["admin", "member"]),
|
|
176
|
+
enabled: z3.boolean(),
|
|
177
|
+
revision: RevisionSchema
|
|
182
178
|
});
|
|
183
|
-
var
|
|
184
|
-
|
|
179
|
+
var UserCreateSchema = z3.object({
|
|
180
|
+
id: NameSchema,
|
|
181
|
+
displayName: z3.string().trim().max(120).default(""),
|
|
182
|
+
siteRole: z3.enum(["admin", "member"]).default("member")
|
|
183
|
+
}).strict();
|
|
184
|
+
var UserUpdateSchema = z3.object({
|
|
185
185
|
expectedRevision: RevisionSchema,
|
|
186
|
-
displayName:
|
|
187
|
-
enabled:
|
|
186
|
+
displayName: z3.string().trim().max(120).optional(),
|
|
187
|
+
enabled: z3.boolean().optional(),
|
|
188
|
+
siteRole: z3.enum(["admin", "member"]).optional()
|
|
188
189
|
}).strict();
|
|
189
190
|
|
|
190
191
|
// packages/protocol/sessions.ts
|
|
191
|
-
import { z as
|
|
192
|
-
var AgentSchema =
|
|
193
|
-
var SessionSnapshotSchema =
|
|
194
|
-
formatVersion:
|
|
195
|
-
adapterVersion:
|
|
192
|
+
import { z as z4 } from "zod";
|
|
193
|
+
var AgentSchema = z4.enum(["claude-code", "codex", "cursor", "pi", "dsh"]);
|
|
194
|
+
var SessionSnapshotSchema = z4.object({
|
|
195
|
+
formatVersion: z4.literal(1),
|
|
196
|
+
adapterVersion: z4.literal(1),
|
|
196
197
|
agent: AgentSchema,
|
|
197
|
-
nativeId:
|
|
198
|
-
cwd:
|
|
199
|
-
messages:
|
|
200
|
-
|
|
201
|
-
id:
|
|
202
|
-
role:
|
|
203
|
-
text:
|
|
204
|
-
source:
|
|
198
|
+
nativeId: z4.string().min(1).max(512),
|
|
199
|
+
cwd: z4.string().min(1).max(4096),
|
|
200
|
+
messages: z4.array(
|
|
201
|
+
z4.object({
|
|
202
|
+
id: z4.string().min(1).max(1024),
|
|
203
|
+
role: z4.enum(["user", "assistant"]),
|
|
204
|
+
text: z4.string().min(1).max(2 * 1024 * 1024),
|
|
205
|
+
source: z4.string().min(1).max(1024)
|
|
205
206
|
}).strict()
|
|
206
207
|
).max(5e4)
|
|
207
208
|
}).strict().refine(
|
|
208
209
|
(s) => new Set(s.messages.map((m) => m.id)).size === s.messages.length,
|
|
209
210
|
"Duplicate message IDs."
|
|
210
211
|
);
|
|
211
|
-
var SessionCommitSchema =
|
|
212
|
-
operationId:
|
|
212
|
+
var SessionCommitSchema = z4.object({
|
|
213
|
+
operationId: z4.uuid(),
|
|
213
214
|
sessionKey: HashSchema,
|
|
214
215
|
parentRevision: RevisionSchema,
|
|
215
216
|
blob: BlobRefSchema
|
|
216
217
|
}).strict();
|
|
217
|
-
var SessionInfoSchema =
|
|
218
|
+
var SessionInfoSchema = z4.object({
|
|
218
219
|
owner: NameSchema,
|
|
219
220
|
sessionKey: HashSchema,
|
|
220
221
|
agent: AgentSchema,
|
|
221
|
-
nativeId:
|
|
222
|
-
cwd:
|
|
223
|
-
title:
|
|
222
|
+
nativeId: z4.string(),
|
|
223
|
+
cwd: z4.string(),
|
|
224
|
+
title: z4.string(),
|
|
224
225
|
revision: RevisionSchema,
|
|
225
|
-
updatedAt:
|
|
226
|
-
messageCount:
|
|
226
|
+
updatedAt: z4.string(),
|
|
227
|
+
messageCount: z4.number().int().nonnegative(),
|
|
227
228
|
blob: BlobRefSchema
|
|
228
229
|
});
|
|
229
|
-
var SessionListSchema =
|
|
230
|
-
sessions:
|
|
230
|
+
var SessionListSchema = z4.object({
|
|
231
|
+
sessions: z4.array(SessionInfoSchema),
|
|
231
232
|
nextCursor: RevisionSchema.nullable()
|
|
232
233
|
});
|
|
233
|
-
var SessionStatsSchema =
|
|
234
|
-
totalSessions:
|
|
235
|
-
totalMessages:
|
|
236
|
-
memberCount:
|
|
237
|
-
updatedAt:
|
|
234
|
+
var SessionStatsSchema = z4.object({
|
|
235
|
+
totalSessions: z4.number().int().nonnegative(),
|
|
236
|
+
totalMessages: z4.number().int().nonnegative(),
|
|
237
|
+
memberCount: z4.number().int().nonnegative(),
|
|
238
|
+
updatedAt: z4.string().nullable()
|
|
238
239
|
});
|
|
239
|
-
var
|
|
240
|
-
|
|
241
|
-
|
|
240
|
+
var SessionScopeSchema = z4.object({
|
|
241
|
+
owner: NameSchema.optional(),
|
|
242
|
+
sessionKey: HashSchema.optional()
|
|
243
|
+
}).strict().refine((v) => !v.sessionKey || !!v.owner, "A session scope requires an owner.");
|
|
244
|
+
var SessionSearchSchema = z4.object({
|
|
245
|
+
matches: z4.array(
|
|
246
|
+
z4.object({
|
|
242
247
|
owner: NameSchema,
|
|
243
248
|
sessionKey: HashSchema,
|
|
244
249
|
revision: RevisionSchema,
|
|
245
|
-
messageId:
|
|
246
|
-
role:
|
|
247
|
-
snippet:
|
|
250
|
+
messageId: z4.string(),
|
|
251
|
+
role: z4.enum(["user", "assistant"]),
|
|
252
|
+
snippet: z4.string(),
|
|
253
|
+
title: z4.string()
|
|
248
254
|
})
|
|
249
255
|
),
|
|
250
256
|
nextCursor: RevisionSchema.nullable()
|
|
251
257
|
});
|
|
252
258
|
|
|
253
259
|
// packages/transport/hub-client.ts
|
|
254
|
-
import { z as
|
|
260
|
+
import { z as z6 } from "zod";
|
|
255
261
|
import { createHash } from "node:crypto";
|
|
256
262
|
|
|
257
263
|
// packages/protocol/index.ts
|
|
258
|
-
import { z as
|
|
264
|
+
import { z as z5 } from "zod";
|
|
259
265
|
var PROTOCOL_VERSION = 1;
|
|
260
|
-
var HealthSchema =
|
|
261
|
-
var ServerInfoSchema =
|
|
262
|
-
name:
|
|
263
|
-
version:
|
|
264
|
-
protocolVersion:
|
|
265
|
-
capabilities:
|
|
266
|
+
var HealthSchema = z5.object({ status: z5.literal("ok") });
|
|
267
|
+
var ServerInfoSchema = z5.object({
|
|
268
|
+
name: z5.literal("teammy"),
|
|
269
|
+
version: z5.string().min(1),
|
|
270
|
+
protocolVersion: z5.literal(PROTOCOL_VERSION),
|
|
271
|
+
capabilities: z5.array(z5.string())
|
|
266
272
|
});
|
|
267
|
-
var ErrorResponseSchema =
|
|
268
|
-
error:
|
|
269
|
-
code:
|
|
270
|
-
message:
|
|
271
|
-
requestId:
|
|
273
|
+
var ErrorResponseSchema = z5.object({
|
|
274
|
+
error: z5.object({
|
|
275
|
+
code: z5.string(),
|
|
276
|
+
message: z5.string(),
|
|
277
|
+
requestId: z5.string()
|
|
272
278
|
})
|
|
273
279
|
});
|
|
274
280
|
|
|
@@ -312,8 +318,11 @@ var HubClient = class {
|
|
|
312
318
|
get url() {
|
|
313
319
|
return this.baseUrl.toString();
|
|
314
320
|
}
|
|
315
|
-
bind(deviceId) {
|
|
316
|
-
return this.request("POST", "api/v1/bind", BindingSchema, {
|
|
321
|
+
bind(deviceId, projectId) {
|
|
322
|
+
return this.request("POST", "api/v1/bind", BindingSchema, {
|
|
323
|
+
deviceId,
|
|
324
|
+
...projectId ? { projectId } : {}
|
|
325
|
+
});
|
|
317
326
|
}
|
|
318
327
|
members() {
|
|
319
328
|
return this.request("GET", "api/v1/members", MembersSchema);
|
|
@@ -331,18 +340,18 @@ var HubClient = class {
|
|
|
331
340
|
"GET",
|
|
332
341
|
`api/v1/blobs/${HashSchema.parse(hash2)}`
|
|
333
342
|
);
|
|
334
|
-
if (!response.ok) return this.decode(response, signal,
|
|
343
|
+
if (!response.ok) return this.decode(response, signal, z6.never());
|
|
335
344
|
const reader = response.body?.getReader();
|
|
336
345
|
if (!reader) throw new Error("Server returned no content body.");
|
|
337
346
|
const parts = [];
|
|
338
347
|
let size = 0;
|
|
339
348
|
try {
|
|
340
349
|
for (; ; ) {
|
|
341
|
-
const
|
|
342
|
-
if (
|
|
343
|
-
size +=
|
|
350
|
+
const result2 = await reader.read();
|
|
351
|
+
if (result2.done) break;
|
|
352
|
+
size += result2.value.byteLength;
|
|
344
353
|
if (size > MAX_BLOB_BYTES) throw new Error("Downloaded content exceeds the size limit.");
|
|
345
|
-
parts.push(
|
|
354
|
+
parts.push(result2.value);
|
|
346
355
|
}
|
|
347
356
|
} finally {
|
|
348
357
|
await reader.cancel();
|
|
@@ -376,7 +385,7 @@ var HubClient = class {
|
|
|
376
385
|
return this.request(
|
|
377
386
|
"PUT",
|
|
378
387
|
"api/v1/devices/self/status",
|
|
379
|
-
|
|
388
|
+
z6.object({ ok: z6.literal(true) }),
|
|
380
389
|
status
|
|
381
390
|
);
|
|
382
391
|
}
|
|
@@ -420,6 +429,8 @@ var HubClient = class {
|
|
|
420
429
|
return this.decode(response, signal, schema);
|
|
421
430
|
}
|
|
422
431
|
async fetchResponse(method, path, requestBody) {
|
|
432
|
+
if (this.credentials?.workspaceId && path.startsWith("api/v1/") && !["api/v1/bind", "api/v1/server"].includes(path))
|
|
433
|
+
path = `api/v1/projects/${encodeURIComponent(this.credentials.workspaceId)}/${path.slice("api/v1/".length)}`;
|
|
423
434
|
const timeout = AbortSignal.timeout(this.timeoutMs);
|
|
424
435
|
const signal = this.abortSignal ? AbortSignal.any([timeout, this.abortSignal]) : timeout;
|
|
425
436
|
const binary = requestBody instanceof Uint8Array;
|
|
@@ -470,64 +481,107 @@ var HubClient = class {
|
|
|
470
481
|
}
|
|
471
482
|
};
|
|
472
483
|
|
|
473
|
-
// apps/client/
|
|
474
|
-
import {
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
484
|
+
// apps/client/sessions/command.ts
|
|
485
|
+
import { createHash as createHash6 } from "node:crypto";
|
|
486
|
+
import { realpath as realpath4 } from "node:fs/promises";
|
|
487
|
+
import { homedir } from "node:os";
|
|
488
|
+
import { dirname as dirname4, join as join6, resolve as resolve3 } from "node:path";
|
|
489
|
+
import { parseArgs } from "node:util";
|
|
490
|
+
|
|
491
|
+
// apps/client/runtime/state.ts
|
|
492
|
+
var runtimeMigration = `
|
|
493
|
+
ALTER TABLE file_index ADD COLUMN seen_local INTEGER NOT NULL DEFAULT 0;
|
|
494
|
+
UPDATE file_index SET seen_local=1 WHERE signature IS NOT NULL OR hash IS NOT NULL;
|
|
495
|
+
CREATE TABLE client_runtime(root TEXT PRIMARY KEY REFERENCES connections(root), enabled INTEGER NOT NULL DEFAULT 0, status_json TEXT);
|
|
496
|
+
CREATE TABLE client_preparations(root TEXT PRIMARY KEY REFERENCES connections(root), identity TEXT NOT NULL, input_json TEXT NOT NULL, complete INTEGER NOT NULL DEFAULT 0);
|
|
478
497
|
`;
|
|
479
|
-
var
|
|
480
|
-
constructor(state
|
|
498
|
+
var RuntimeState = class {
|
|
499
|
+
constructor(state) {
|
|
481
500
|
this.state = state;
|
|
482
|
-
this.root = root;
|
|
483
501
|
}
|
|
484
502
|
state;
|
|
485
|
-
root
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
throw new Error("Another enrollment is pending for this root; use the same member/server.");
|
|
493
|
-
return request2;
|
|
494
|
-
}
|
|
495
|
-
const request = {
|
|
496
|
-
operationId: randomUUID(),
|
|
497
|
-
memberId,
|
|
498
|
-
deviceId,
|
|
499
|
-
credential: `tmy_${randomBytes(32).toString("hex")}`
|
|
500
|
-
};
|
|
503
|
+
enabled(root) {
|
|
504
|
+
return Boolean(
|
|
505
|
+
this.state.db.prepare("SELECT enabled FROM client_runtime WHERE root=?").get(root)?.enabled
|
|
506
|
+
);
|
|
507
|
+
}
|
|
508
|
+
enable(root, enabled) {
|
|
509
|
+
if (!this.state.binding(root)) throw new Error("Connect this workspace first.");
|
|
501
510
|
this.state.db.prepare(
|
|
502
|
-
"INSERT INTO
|
|
503
|
-
).run(
|
|
504
|
-
return request;
|
|
511
|
+
"INSERT INTO client_runtime(root,enabled) VALUES (?,?) ON CONFLICT(root) DO UPDATE SET enabled=excluded.enabled"
|
|
512
|
+
).run(root, Number(enabled));
|
|
505
513
|
}
|
|
506
|
-
|
|
507
|
-
this.state.db.prepare("
|
|
514
|
+
roots() {
|
|
515
|
+
return this.state.db.prepare("SELECT root FROM client_runtime WHERE enabled=1").all().map((r) => String(r.root));
|
|
508
516
|
}
|
|
509
|
-
|
|
510
|
-
const row = this.state.db.prepare("SELECT
|
|
511
|
-
return row ?
|
|
512
|
-
config: ProjectConfigSchema.parse(JSON.parse(String(row.config_json))),
|
|
513
|
-
hash: String(row.descriptor_hash)
|
|
514
|
-
} : void 0;
|
|
517
|
+
status(root) {
|
|
518
|
+
const row = this.state.db.prepare("SELECT status_json FROM client_runtime WHERE root=?").get(root);
|
|
519
|
+
return row?.status_json ? JSON.parse(String(row.status_json)) : null;
|
|
515
520
|
}
|
|
516
|
-
save(
|
|
521
|
+
save(root, status) {
|
|
517
522
|
this.state.db.prepare(
|
|
518
|
-
"INSERT INTO
|
|
519
|
-
).run(
|
|
523
|
+
"INSERT INTO client_runtime(root,status_json) VALUES (?,?) ON CONFLICT(root) DO UPDATE SET status_json=excluded.status_json"
|
|
524
|
+
).run(root, JSON.stringify(status));
|
|
520
525
|
}
|
|
521
526
|
};
|
|
522
527
|
|
|
523
|
-
// apps/client/
|
|
524
|
-
import {
|
|
528
|
+
// apps/client/state.ts
|
|
529
|
+
import { randomUUID as randomUUID3 } from "node:crypto";
|
|
530
|
+
import { join as join4, relative as relative2, isAbsolute, sep as sep2 } from "node:path";
|
|
531
|
+
import { z as z8 } from "zod";
|
|
532
|
+
|
|
533
|
+
// packages/state/sqlite.ts
|
|
534
|
+
import { chmodSync, mkdirSync } from "node:fs";
|
|
535
|
+
import { dirname } from "node:path";
|
|
536
|
+
import { DatabaseSync } from "node:sqlite";
|
|
537
|
+
function transaction(db, action) {
|
|
538
|
+
db.exec("BEGIN IMMEDIATE");
|
|
539
|
+
try {
|
|
540
|
+
const result2 = action();
|
|
541
|
+
db.exec("COMMIT");
|
|
542
|
+
return result2;
|
|
543
|
+
} catch (error) {
|
|
544
|
+
db.exec("ROLLBACK");
|
|
545
|
+
throw error;
|
|
546
|
+
}
|
|
547
|
+
}
|
|
548
|
+
function openDatabase(path, migrations, busyTimeoutMs = 5e3) {
|
|
549
|
+
if (!Number.isSafeInteger(busyTimeoutMs) || busyTimeoutMs < 0)
|
|
550
|
+
throw new Error("Invalid SQLite busy timeout.");
|
|
551
|
+
mkdirSync(dirname(path), { recursive: true, mode: 448 });
|
|
552
|
+
const db = new DatabaseSync(path);
|
|
553
|
+
try {
|
|
554
|
+
chmodSync(path, 384);
|
|
555
|
+
db.exec(
|
|
556
|
+
`PRAGMA busy_timeout = ${busyTimeoutMs}; PRAGMA foreign_keys = ON; PRAGMA journal_mode = WAL; PRAGMA synchronous = FULL;`
|
|
557
|
+
);
|
|
558
|
+
transaction(db, () => {
|
|
559
|
+
const version = Number(db.prepare("PRAGMA user_version").get().user_version);
|
|
560
|
+
if (version > migrations.length)
|
|
561
|
+
throw new Error("Database was created by a newer Teammy version.");
|
|
562
|
+
for (let index = version; index < migrations.length; index++) {
|
|
563
|
+
db.exec(migrations[index]);
|
|
564
|
+
db.exec(`PRAGMA user_version = ${index + 1}`);
|
|
565
|
+
}
|
|
566
|
+
});
|
|
567
|
+
return db;
|
|
568
|
+
} catch (error) {
|
|
569
|
+
db.close();
|
|
570
|
+
throw error;
|
|
571
|
+
}
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
// apps/client/workspace.ts
|
|
575
|
+
import { execFile } from "node:child_process";
|
|
576
|
+
import { promisify } from "node:util";
|
|
577
|
+
import { lstat as lstat2, mkdir as mkdir2, realpath as realpath2, rmdir as rmdir2 } from "node:fs/promises";
|
|
578
|
+
import { basename, dirname as dirname3, join as join2, resolve } from "node:path";
|
|
525
579
|
|
|
526
580
|
// apps/client/files/local-fs.ts
|
|
527
|
-
import { createHash as createHash2, randomUUID
|
|
581
|
+
import { createHash as createHash2, randomUUID } from "node:crypto";
|
|
528
582
|
import { constants } from "node:fs";
|
|
529
583
|
import { lstat, mkdir, open, readdir, realpath, rename, rmdir, unlink } from "node:fs/promises";
|
|
530
|
-
import { dirname, join, relative, sep } from "node:path";
|
|
584
|
+
import { dirname as dirname2, join, relative, sep } from "node:path";
|
|
531
585
|
import { setImmediate } from "node:timers/promises";
|
|
532
586
|
function missing(error) {
|
|
533
587
|
return error instanceof Error && "code" in error && ["ENOENT", "ENOTDIR"].includes(String(error.code));
|
|
@@ -657,7 +711,7 @@ async function syncDirectory(directory) {
|
|
|
657
711
|
}
|
|
658
712
|
async function replaceFile(root, path, bytes, expected) {
|
|
659
713
|
const target = await safePath(root, path, true);
|
|
660
|
-
const temporary = join(
|
|
714
|
+
const temporary = join(dirname2(target), `.teammy-${randomUUID()}.tmp`);
|
|
661
715
|
const file = await open(temporary, "wx", 384);
|
|
662
716
|
try {
|
|
663
717
|
try {
|
|
@@ -674,11 +728,11 @@ async function replaceFile(root, path, bytes, expected) {
|
|
|
674
728
|
throw new Error(`Local file changed before replacement: ${path}`);
|
|
675
729
|
await safePath(root, path);
|
|
676
730
|
await rename(temporary, target);
|
|
677
|
-
let directory =
|
|
731
|
+
let directory = dirname2(target);
|
|
678
732
|
for (; ; ) {
|
|
679
733
|
await syncDirectory(directory);
|
|
680
|
-
if (directory ===
|
|
681
|
-
directory =
|
|
734
|
+
if (directory === dirname2(root)) break;
|
|
735
|
+
directory = dirname2(directory);
|
|
682
736
|
}
|
|
683
737
|
} finally {
|
|
684
738
|
await unlink(temporary).catch((error) => {
|
|
@@ -693,125 +747,420 @@ async function removeFile(root, path, expected) {
|
|
|
693
747
|
throw new Error(`Local file changed before deletion: ${path}`);
|
|
694
748
|
const target = await safePath(root, path);
|
|
695
749
|
await unlink(target);
|
|
696
|
-
await syncDirectory(
|
|
697
|
-
let parent =
|
|
750
|
+
await syncDirectory(dirname2(target));
|
|
751
|
+
let parent = dirname2(target);
|
|
698
752
|
while (parent !== root) {
|
|
699
753
|
try {
|
|
700
754
|
await rmdir(parent);
|
|
701
|
-
await syncDirectory(
|
|
755
|
+
await syncDirectory(dirname2(parent));
|
|
702
756
|
} catch {
|
|
703
757
|
break;
|
|
704
758
|
}
|
|
705
|
-
parent =
|
|
759
|
+
parent = dirname2(parent);
|
|
706
760
|
}
|
|
707
761
|
}
|
|
708
762
|
|
|
709
|
-
// apps/client/
|
|
710
|
-
var
|
|
711
|
-
async function
|
|
712
|
-
const
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
const
|
|
717
|
-
if (
|
|
718
|
-
throw new Error("
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
763
|
+
// apps/client/workspace.ts
|
|
764
|
+
var exec = promisify(execFile);
|
|
765
|
+
async function workspacePath(input) {
|
|
766
|
+
const path = resolve(input);
|
|
767
|
+
return join2(await realpath2(dirname3(path)), basename(path));
|
|
768
|
+
}
|
|
769
|
+
async function directoryIdentity(root) {
|
|
770
|
+
const stat2 = await lstat2(root, { bigint: true });
|
|
771
|
+
if (!stat2.isDirectory() || stat2.isSymbolicLink())
|
|
772
|
+
throw new Error("Workspace must be an ordinary directory.");
|
|
773
|
+
return `${stat2.dev}:${stat2.ino}`;
|
|
774
|
+
}
|
|
775
|
+
async function verifyWorkspace(state, root) {
|
|
776
|
+
const identity = await directoryIdentity(root);
|
|
777
|
+
const prior = state.db.prepare("SELECT identity FROM client_preparations WHERE root=?").get(root);
|
|
778
|
+
if (prior && prior.identity !== identity)
|
|
779
|
+
throw new Error(
|
|
780
|
+
"Workspace directory was replaced. Restore the original directory before syncing."
|
|
781
|
+
);
|
|
782
|
+
}
|
|
783
|
+
async function ignorePlan(root) {
|
|
784
|
+
const parent = dirname3(root), name2 = basename(root);
|
|
785
|
+
if (/[\r\n]/.test(name2)) throw new Error("Workspace name cannot contain a newline.");
|
|
786
|
+
let repo = false;
|
|
787
|
+
for (let p = parent; ; p = dirname3(p)) {
|
|
788
|
+
try {
|
|
789
|
+
await lstat2(join2(p, ".git"));
|
|
790
|
+
repo = true;
|
|
791
|
+
break;
|
|
792
|
+
} catch (e) {
|
|
793
|
+
if (!missing(e)) throw e;
|
|
794
|
+
}
|
|
795
|
+
if (p === dirname3(p)) break;
|
|
796
|
+
}
|
|
797
|
+
if (repo) {
|
|
798
|
+
let tracked;
|
|
799
|
+
try {
|
|
800
|
+
tracked = (await exec("git", ["-C", parent, "--literal-pathspecs", "ls-files", "-z", "--", name2], {
|
|
801
|
+
maxBuffer: 1024 * 1024
|
|
802
|
+
})).stdout;
|
|
803
|
+
} catch {
|
|
804
|
+
throw new Error("Cannot inspect the Git index. Check Git access before connecting.");
|
|
805
|
+
}
|
|
806
|
+
if (tracked)
|
|
729
807
|
throw new Error(
|
|
730
|
-
"
|
|
808
|
+
"Git already tracks this workspace path. Remove it from the Git index yourself before connecting."
|
|
731
809
|
);
|
|
732
|
-
|
|
733
|
-
|
|
810
|
+
try {
|
|
811
|
+
await exec("git", ["-C", parent, "check-ignore", "--no-index", "-q", "--", name2 + "/"]);
|
|
734
812
|
return;
|
|
813
|
+
} catch (e) {
|
|
814
|
+
if (e.code !== 1) throw new Error("Cannot check Git ignore rules.");
|
|
735
815
|
}
|
|
736
816
|
}
|
|
737
|
-
await
|
|
738
|
-
|
|
817
|
+
const entry = await metadata(parent, ".gitignore");
|
|
818
|
+
if (entry?.directory) throw new Error(".gitignore is a directory.");
|
|
819
|
+
const original = entry ? await readStable(parent, ".gitignore") : null;
|
|
820
|
+
const content = original ? new TextDecoder("utf-8", { fatal: true }).decode(original.bytes) : "";
|
|
821
|
+
const rule = "/" + name2.replace(/[\\*?[\]#! ]/g, "\\$&") + "/";
|
|
822
|
+
if (content.split(/\r?\n/).includes(rule)) return;
|
|
823
|
+
const newline = content.includes("\r\n") ? "\r\n" : "\n";
|
|
824
|
+
return {
|
|
825
|
+
parent,
|
|
826
|
+
bytes: Buffer.from(
|
|
827
|
+
content + (content && !content.endsWith("\n") ? newline : "") + rule + newline
|
|
828
|
+
),
|
|
829
|
+
signature: original?.signature ?? null
|
|
830
|
+
};
|
|
739
831
|
}
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
}),
|
|
765
|
-
overrides: Object.keys(overrides),
|
|
766
|
-
configError: null,
|
|
767
|
-
reportError: null,
|
|
768
|
-
lastSync: prior?.lastSync ?? null
|
|
769
|
-
};
|
|
770
|
-
apply(this.status.effectivePolicy);
|
|
771
|
-
}
|
|
772
|
-
state;
|
|
773
|
-
root;
|
|
774
|
-
overrides;
|
|
775
|
-
apply;
|
|
776
|
-
client;
|
|
777
|
-
local;
|
|
778
|
-
status;
|
|
779
|
-
enabled = true;
|
|
780
|
-
stop = new AbortController();
|
|
781
|
-
running;
|
|
782
|
-
persist() {
|
|
783
|
-
this.state.db.prepare(
|
|
784
|
-
"INSERT INTO client_control VALUES (?,?) ON CONFLICT(root) DO UPDATE SET status_json=excluded.status_json"
|
|
785
|
-
).run(this.root, JSON.stringify(this.status));
|
|
832
|
+
async function prepareWorkspace(state, root, input, reconnect = false) {
|
|
833
|
+
state.assertRootAvailable(root);
|
|
834
|
+
const prior = state.db.prepare("SELECT * FROM client_preparations WHERE root=?").get(root);
|
|
835
|
+
let present = false;
|
|
836
|
+
try {
|
|
837
|
+
await lstat2(root);
|
|
838
|
+
present = true;
|
|
839
|
+
} catch (e) {
|
|
840
|
+
if (!missing(e)) throw e;
|
|
841
|
+
}
|
|
842
|
+
if (present) {
|
|
843
|
+
if (reconnect && state.binding(root)) {
|
|
844
|
+
await verifyWorkspace(state, root);
|
|
845
|
+
return;
|
|
846
|
+
}
|
|
847
|
+
if (prior && !prior.complete && prior.input_json === JSON.stringify(input)) {
|
|
848
|
+
await verifyWorkspace(state, root);
|
|
849
|
+
const ignore2 = await ignorePlan(root);
|
|
850
|
+
if (ignore2) await replaceFile(ignore2.parent, ".gitignore", ignore2.bytes, ignore2.signature);
|
|
851
|
+
return;
|
|
852
|
+
}
|
|
853
|
+
throw new Error(
|
|
854
|
+
'Workspace path already exists. Choose another directory; for a connected workspace use "teammy sync on".'
|
|
855
|
+
);
|
|
786
856
|
}
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
857
|
+
if (state.binding(root) || prior)
|
|
858
|
+
throw new Error("Connected workspace is missing. Restore its directory before syncing.");
|
|
859
|
+
const ignore = await ignorePlan(root);
|
|
860
|
+
await mkdir2(root);
|
|
861
|
+
try {
|
|
862
|
+
const identity = await directoryIdentity(root);
|
|
863
|
+
state.deviceFor(root);
|
|
864
|
+
state.db.prepare("INSERT INTO client_preparations(root,identity,input_json) VALUES (?,?,?)").run(root, identity, JSON.stringify(input));
|
|
865
|
+
} catch (e) {
|
|
866
|
+
await rmdir2(root).catch(() => {
|
|
867
|
+
});
|
|
868
|
+
throw e;
|
|
790
869
|
}
|
|
791
|
-
|
|
792
|
-
|
|
870
|
+
if (ignore) await replaceFile(ignore.parent, ".gitignore", ignore.bytes, ignore.signature);
|
|
871
|
+
}
|
|
872
|
+
function completePreparation(state, root) {
|
|
873
|
+
state.db.prepare("UPDATE client_preparations SET complete=1 WHERE root=?").run(root);
|
|
874
|
+
}
|
|
875
|
+
|
|
876
|
+
// apps/client/runtime/log.ts
|
|
877
|
+
import { appendFileSync, mkdirSync as mkdirSync2, renameSync, statSync, unlinkSync, readFileSync } from "node:fs";
|
|
878
|
+
import { join as join3 } from "node:path";
|
|
879
|
+
import { createHash as createHash3 } from "node:crypto";
|
|
880
|
+
function rootKey(root) {
|
|
881
|
+
return createHash3("sha256").update(root).digest("hex").slice(0, 16);
|
|
882
|
+
}
|
|
883
|
+
function redact(value) {
|
|
884
|
+
return value.replace(/tmy_[a-f0-9]+/gi, "[credential]").replace(/Bearer\s+\S+/gi, "Bearer [redacted]").replace(/(https?:\/\/)[^\s/]+@/gi, "$1[redacted]@").replace(/[\x00-\x1f\x7f]/g, " ").slice(0, 1500);
|
|
885
|
+
}
|
|
886
|
+
function diagnostic(error) {
|
|
887
|
+
if (!(error instanceof Error)) return "Operation failed.";
|
|
888
|
+
const fields2 = error;
|
|
889
|
+
return redact([error.message, fields2.code, fields2.requestId].filter(Boolean).join(" \xB7 "));
|
|
890
|
+
}
|
|
891
|
+
var ClientLog = class {
|
|
892
|
+
constructor(directory, limit = 1024 * 1024) {
|
|
893
|
+
this.limit = limit;
|
|
894
|
+
const logs = join3(directory, "logs");
|
|
895
|
+
mkdirSync2(logs, { recursive: true, mode: 448 });
|
|
896
|
+
this.path = join3(logs, "client.jsonl");
|
|
897
|
+
}
|
|
898
|
+
limit;
|
|
899
|
+
path;
|
|
900
|
+
write(event, root, detail) {
|
|
793
901
|
try {
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
this.apply(effective);
|
|
799
|
-
this.status.effectivePolicy = effective;
|
|
800
|
-
this.status.appliedRevision = config.configRevision;
|
|
801
|
-
this.status.configError = null;
|
|
802
|
-
} catch (error) {
|
|
803
|
-
if (!this.local.cached() && error instanceof HubRequestError && ["SETUP_REQUIRED", "PROJECT_NOT_FOUND", "NOT_FOUND"].includes(error.code)) {
|
|
804
|
-
this.enabled = false;
|
|
805
|
-
return;
|
|
902
|
+
let size = 0;
|
|
903
|
+
try {
|
|
904
|
+
size = statSync(this.path).size;
|
|
905
|
+
} catch {
|
|
806
906
|
}
|
|
807
|
-
|
|
907
|
+
if (size >= this.limit) {
|
|
908
|
+
try {
|
|
909
|
+
unlinkSync(this.path + ".2");
|
|
910
|
+
} catch {
|
|
911
|
+
}
|
|
912
|
+
try {
|
|
913
|
+
renameSync(this.path + ".1", this.path + ".2");
|
|
914
|
+
} catch {
|
|
915
|
+
}
|
|
916
|
+
renameSync(this.path, this.path + ".1");
|
|
917
|
+
}
|
|
918
|
+
appendFileSync(
|
|
919
|
+
this.path,
|
|
920
|
+
JSON.stringify({
|
|
921
|
+
at: (/* @__PURE__ */ new Date()).toISOString(),
|
|
922
|
+
event,
|
|
923
|
+
root: root ? rootKey(root) : void 0,
|
|
924
|
+
detail: detail ? redact(detail) : void 0
|
|
925
|
+
}) + "\n",
|
|
926
|
+
{ mode: 384 }
|
|
927
|
+
);
|
|
928
|
+
} catch {
|
|
929
|
+
}
|
|
930
|
+
}
|
|
931
|
+
tail(root, count = 20) {
|
|
932
|
+
const lines = [];
|
|
933
|
+
for (const suffix of [".2", ".1", ""])
|
|
934
|
+
try {
|
|
935
|
+
for (const line of readFileSync(this.path + suffix, "utf8").split("\n")) {
|
|
936
|
+
if (!line) continue;
|
|
937
|
+
try {
|
|
938
|
+
const item = JSON.parse(line);
|
|
939
|
+
if (!root || item.root === rootKey(root) || !item.root) lines.push(line);
|
|
940
|
+
} catch {
|
|
941
|
+
}
|
|
942
|
+
}
|
|
943
|
+
} catch {
|
|
944
|
+
}
|
|
945
|
+
return lines.slice(-count).map((line) => JSON.parse(line));
|
|
946
|
+
}
|
|
947
|
+
};
|
|
948
|
+
|
|
949
|
+
// apps/client/control.ts
|
|
950
|
+
import { setTimeout as setTimeout2 } from "node:timers/promises";
|
|
951
|
+
|
|
952
|
+
// packages/protocol/devices.ts
|
|
953
|
+
import { z as z7 } from "zod";
|
|
954
|
+
var CredentialSchema = z7.string().regex(/^tmy_[a-f0-9]{64}$/);
|
|
955
|
+
var EnrollmentSchema = z7.object({
|
|
956
|
+
operationId: z7.uuid(),
|
|
957
|
+
projectId: NameSchema,
|
|
958
|
+
memberId: NameSchema,
|
|
959
|
+
deviceId: DeviceIdSchema,
|
|
960
|
+
credential: CredentialSchema
|
|
961
|
+
}).strict();
|
|
962
|
+
var EffectivePolicySchema = z7.object({
|
|
963
|
+
checkIntervalSeconds: z7.number().min(1).max(86400),
|
|
964
|
+
fileSettleSeconds: z7.number().min(0).max(86400)
|
|
965
|
+
}).strict();
|
|
966
|
+
var SyncSummarySchema = z7.object({
|
|
967
|
+
checkedAt: z7.string(),
|
|
968
|
+
published: z7.boolean(),
|
|
969
|
+
appliedFiles: z7.number().int().nonnegative(),
|
|
970
|
+
pendingFiles: z7.number().int().nonnegative(),
|
|
971
|
+
sessionSources: z7.number().int().nonnegative(),
|
|
972
|
+
errors: z7.array(z7.string().max(1e3)).max(20)
|
|
973
|
+
}).strict();
|
|
974
|
+
var DeviceStatusSchema = z7.object({
|
|
975
|
+
clientVersion: z7.string().max(64),
|
|
976
|
+
receivedRevision: RevisionSchema.nullable(),
|
|
977
|
+
appliedRevision: RevisionSchema.nullable(),
|
|
978
|
+
effectivePolicy: EffectivePolicySchema,
|
|
979
|
+
overrides: z7.array(z7.enum(["checkIntervalSeconds", "fileSettleSeconds"])).max(2),
|
|
980
|
+
configError: z7.string().max(2e3).nullable(),
|
|
981
|
+
reportError: z7.string().max(2e3).nullable(),
|
|
982
|
+
lastSync: SyncSummarySchema.nullable()
|
|
983
|
+
}).strict();
|
|
984
|
+
var WebDevicesSchema = z7.object({
|
|
985
|
+
devices: z7.array(
|
|
986
|
+
z7.object({
|
|
987
|
+
memberId: NameSchema,
|
|
988
|
+
bound: z7.boolean(),
|
|
989
|
+
lastSeenAt: z7.string().nullable(),
|
|
990
|
+
status: DeviceStatusSchema.nullable()
|
|
991
|
+
})
|
|
992
|
+
)
|
|
993
|
+
});
|
|
994
|
+
|
|
995
|
+
// apps/client/project-state.ts
|
|
996
|
+
import { randomBytes, randomUUID as randomUUID2 } from "node:crypto";
|
|
997
|
+
var projectClientMigration = String.raw`
|
|
998
|
+
CREATE TABLE client_enrollments(root TEXT PRIMARY KEY REFERENCES connections(root),server_url TEXT NOT NULL,request_json TEXT NOT NULL);
|
|
999
|
+
CREATE TABLE client_project_config(root TEXT PRIMARY KEY REFERENCES connections(root),config_json TEXT NOT NULL,descriptor_hash TEXT NOT NULL);
|
|
1000
|
+
`;
|
|
1001
|
+
var ClientProjectState = class {
|
|
1002
|
+
constructor(state, root) {
|
|
1003
|
+
this.state = state;
|
|
1004
|
+
this.root = root;
|
|
1005
|
+
}
|
|
1006
|
+
state;
|
|
1007
|
+
root;
|
|
1008
|
+
enrollment(url, memberId, projectId, reconnect = false) {
|
|
1009
|
+
const deviceId = this.state.deviceFor(this.root);
|
|
1010
|
+
const row = this.state.db.prepare("SELECT * FROM client_enrollments WHERE root=?").get(this.root);
|
|
1011
|
+
if (row && !reconnect) {
|
|
1012
|
+
const request2 = EnrollmentSchema.parse(JSON.parse(String(row.request_json)));
|
|
1013
|
+
if (row.server_url !== url || request2.memberId !== memberId || request2.projectId !== projectId)
|
|
1014
|
+
throw new Error(
|
|
1015
|
+
"Another enrollment is pending for this root; use the same member/server/project."
|
|
1016
|
+
);
|
|
1017
|
+
return request2;
|
|
1018
|
+
}
|
|
1019
|
+
const request = {
|
|
1020
|
+
operationId: randomUUID2(),
|
|
1021
|
+
memberId,
|
|
1022
|
+
projectId,
|
|
1023
|
+
deviceId,
|
|
1024
|
+
credential: `tmy_${randomBytes(32).toString("hex")}`
|
|
1025
|
+
};
|
|
1026
|
+
this.state.db.prepare(
|
|
1027
|
+
"INSERT INTO client_enrollments VALUES (?,?,?) ON CONFLICT(root) DO UPDATE SET server_url=excluded.server_url,request_json=excluded.request_json"
|
|
1028
|
+
).run(this.root, url, JSON.stringify(request));
|
|
1029
|
+
return request;
|
|
1030
|
+
}
|
|
1031
|
+
completeEnrollment() {
|
|
1032
|
+
this.state.db.prepare("DELETE FROM client_enrollments WHERE root=?").run(this.root);
|
|
1033
|
+
}
|
|
1034
|
+
cached() {
|
|
1035
|
+
const row = this.state.db.prepare("SELECT * FROM client_project_config WHERE root=?").get(this.root);
|
|
1036
|
+
return row ? {
|
|
1037
|
+
config: ProjectConfigSchema.parse(JSON.parse(String(row.config_json))),
|
|
1038
|
+
hash: String(row.descriptor_hash)
|
|
1039
|
+
} : void 0;
|
|
1040
|
+
}
|
|
1041
|
+
save(config, hash2) {
|
|
1042
|
+
this.state.db.prepare(
|
|
1043
|
+
"INSERT INTO client_project_config VALUES (?,?,?) ON CONFLICT(root) DO UPDATE SET config_json=excluded.config_json,descriptor_hash=excluded.descriptor_hash"
|
|
1044
|
+
).run(this.root, JSON.stringify(config), hash2);
|
|
1045
|
+
}
|
|
1046
|
+
};
|
|
1047
|
+
|
|
1048
|
+
// apps/client/project-file.ts
|
|
1049
|
+
import { createHash as createHash4 } from "node:crypto";
|
|
1050
|
+
var name = ".teammy.json";
|
|
1051
|
+
async function applyProjectFile(local, input) {
|
|
1052
|
+
const config = ProjectConfigSchema.parse(input);
|
|
1053
|
+
const binding = local.state.binding(local.root);
|
|
1054
|
+
if (!binding || binding.workspaceId !== config.projectId)
|
|
1055
|
+
throw new Error("Project configuration does not match the local binding.");
|
|
1056
|
+
const cached = local.cached();
|
|
1057
|
+
if (cached && config.configRevision < cached.config.configRevision)
|
|
1058
|
+
throw new Error("Server project configuration moved backwards.");
|
|
1059
|
+
const bytes = Buffer.from(JSON.stringify(config, null, 2) + "\n");
|
|
1060
|
+
const hash2 = createHash4("sha256").update(bytes).digest("hex");
|
|
1061
|
+
const entry = await metadata(local.root, name);
|
|
1062
|
+
let signature2 = null;
|
|
1063
|
+
if (entry) {
|
|
1064
|
+
if (entry.directory)
|
|
1065
|
+
throw new Error("Project configuration path is a directory; preserving it.");
|
|
1066
|
+
const existing = await readStable(local.root, name);
|
|
1067
|
+
signature2 = existing.signature;
|
|
1068
|
+
if (existing.sha256 !== hash2 && existing.sha256 !== cached?.hash)
|
|
1069
|
+
throw new Error(
|
|
1070
|
+
"Existing .teammy.json is not the expected managed copy. Preserve it elsewhere before retrying."
|
|
1071
|
+
);
|
|
1072
|
+
if (existing.sha256 === hash2) {
|
|
1073
|
+
local.save(config, hash2);
|
|
1074
|
+
return;
|
|
1075
|
+
}
|
|
1076
|
+
}
|
|
1077
|
+
await replaceFile(local.root, name, bytes, signature2);
|
|
1078
|
+
local.save(config, hash2);
|
|
1079
|
+
}
|
|
1080
|
+
|
|
1081
|
+
// apps/client/control.ts
|
|
1082
|
+
var controlClientMigration = "CREATE TABLE client_control(root TEXT PRIMARY KEY REFERENCES connections(root),status_json TEXT NOT NULL);";
|
|
1083
|
+
var ControlPlane = class {
|
|
1084
|
+
constructor(state, root, version, overrides, apply) {
|
|
1085
|
+
this.state = state;
|
|
1086
|
+
this.root = root;
|
|
1087
|
+
this.overrides = overrides;
|
|
1088
|
+
this.apply = apply;
|
|
1089
|
+
const binding = state.binding(root);
|
|
1090
|
+
if (!binding) throw new Error("Connect this root first.");
|
|
1091
|
+
this.client = new HubClient(binding.serverUrl, 1e4, binding, this.stop.signal);
|
|
1092
|
+
this.local = new ClientProjectState(state, root);
|
|
1093
|
+
const row = state.db.prepare("SELECT status_json FROM client_control WHERE root=?").get(root);
|
|
1094
|
+
const prior = row ? DeviceStatusSchema.parse(JSON.parse(String(row.status_json))) : void 0;
|
|
1095
|
+
const cached = this.local.cached();
|
|
1096
|
+
this.status = {
|
|
1097
|
+
clientVersion: version,
|
|
1098
|
+
receivedRevision: cached?.config.configRevision ?? null,
|
|
1099
|
+
appliedRevision: null,
|
|
1100
|
+
effectivePolicy: EffectivePolicySchema.parse({
|
|
1101
|
+
...DEFAULT_POLICY,
|
|
1102
|
+
...cached?.config.sync,
|
|
1103
|
+
...overrides
|
|
1104
|
+
}),
|
|
1105
|
+
overrides: Object.keys(overrides),
|
|
1106
|
+
configError: null,
|
|
1107
|
+
reportError: null,
|
|
1108
|
+
lastSync: prior?.lastSync ?? null
|
|
1109
|
+
};
|
|
1110
|
+
apply(this.status.effectivePolicy);
|
|
1111
|
+
}
|
|
1112
|
+
state;
|
|
1113
|
+
root;
|
|
1114
|
+
overrides;
|
|
1115
|
+
apply;
|
|
1116
|
+
client;
|
|
1117
|
+
local;
|
|
1118
|
+
status;
|
|
1119
|
+
enabled = true;
|
|
1120
|
+
stop = new AbortController();
|
|
1121
|
+
running;
|
|
1122
|
+
pending;
|
|
1123
|
+
persist() {
|
|
1124
|
+
this.state.db.prepare(
|
|
1125
|
+
"INSERT INTO client_control VALUES (?,?) ON CONFLICT(root) DO UPDATE SET status_json=excluded.status_json"
|
|
1126
|
+
).run(this.root, JSON.stringify(this.status));
|
|
1127
|
+
}
|
|
1128
|
+
updateSync(summary) {
|
|
1129
|
+
this.status.lastSync = summary;
|
|
1130
|
+
this.persist();
|
|
1131
|
+
}
|
|
1132
|
+
tick() {
|
|
1133
|
+
this.pending ??= this.check().finally(() => {
|
|
1134
|
+
this.pending = void 0;
|
|
1135
|
+
});
|
|
1136
|
+
return this.pending;
|
|
1137
|
+
}
|
|
1138
|
+
async check() {
|
|
1139
|
+
if (this.stop.signal.aborted) return;
|
|
1140
|
+
try {
|
|
1141
|
+
await verifyWorkspace(this.state, this.root);
|
|
1142
|
+
const config = await this.client.projectConfig();
|
|
1143
|
+
this.status.receivedRevision = config.configRevision;
|
|
1144
|
+
await verifyWorkspace(this.state, this.root);
|
|
1145
|
+
await applyProjectFile(this.local, config);
|
|
1146
|
+
const effective = EffectivePolicySchema.parse({ ...config.sync, ...this.overrides });
|
|
1147
|
+
this.apply(effective);
|
|
1148
|
+
this.status.effectivePolicy = effective;
|
|
1149
|
+
this.status.appliedRevision = config.configRevision;
|
|
1150
|
+
this.status.configError = null;
|
|
1151
|
+
} catch (error) {
|
|
1152
|
+
if (!this.local.cached() && error instanceof HubRequestError && ["SETUP_REQUIRED", "PROJECT_NOT_FOUND", "NOT_FOUND"].includes(error.code)) {
|
|
1153
|
+
this.enabled = false;
|
|
1154
|
+
return;
|
|
1155
|
+
}
|
|
1156
|
+
this.status.configError = diagnostic(error);
|
|
808
1157
|
}
|
|
809
1158
|
this.persist();
|
|
810
1159
|
this.status.reportError = null;
|
|
811
1160
|
try {
|
|
812
1161
|
await this.client.reportDeviceStatus(this.status);
|
|
813
1162
|
} catch (error) {
|
|
814
|
-
this.status.reportError =
|
|
1163
|
+
this.status.reportError = diagnostic(error);
|
|
815
1164
|
}
|
|
816
1165
|
this.persist();
|
|
817
1166
|
}
|
|
@@ -824,7 +1173,7 @@ var ControlPlane = class {
|
|
|
824
1173
|
await this.tick();
|
|
825
1174
|
} catch (error) {
|
|
826
1175
|
if (!this.stop.signal.aborted) {
|
|
827
|
-
this.status.configError =
|
|
1176
|
+
this.status.configError = diagnostic(error);
|
|
828
1177
|
}
|
|
829
1178
|
}
|
|
830
1179
|
}
|
|
@@ -833,6 +1182,7 @@ var ControlPlane = class {
|
|
|
833
1182
|
async close() {
|
|
834
1183
|
this.stop.abort();
|
|
835
1184
|
await this.running;
|
|
1185
|
+
await this.pending;
|
|
836
1186
|
}
|
|
837
1187
|
};
|
|
838
1188
|
function readControlStatus(state, root) {
|
|
@@ -840,176 +1190,39 @@ function readControlStatus(state, root) {
|
|
|
840
1190
|
return row ? DeviceStatusSchema.parse(JSON.parse(String(row.status_json))) : null;
|
|
841
1191
|
}
|
|
842
1192
|
|
|
843
|
-
// apps/client/
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
|
|
1193
|
+
// apps/client/sessions/state.ts
|
|
1194
|
+
var sessionClientMigration = String.raw`
|
|
1195
|
+
CREATE TABLE session_sources (
|
|
1196
|
+
root TEXT NOT NULL REFERENCES connections(root), path TEXT NOT NULL, agent TEXT NOT NULL, project_dir TEXT NOT NULL,
|
|
1197
|
+
session_key TEXT, revision INTEGER NOT NULL DEFAULT 0, hash TEXT, signature TEXT,
|
|
1198
|
+
checked_at INTEGER NOT NULL DEFAULT 0, verified_at INTEGER NOT NULL DEFAULT 0, last_error TEXT, adopt_existing INTEGER NOT NULL DEFAULT 0,
|
|
1199
|
+
pending_json TEXT, pending_signature TEXT, attempts INTEGER NOT NULL DEFAULT 0, retry_at INTEGER NOT NULL DEFAULT 0,
|
|
1200
|
+
PRIMARY KEY(root, path), UNIQUE(root, session_key)
|
|
1201
|
+
);
|
|
1202
|
+
CREATE INDEX session_source_scheduling ON session_sources(root, checked_at, path);
|
|
1203
|
+
CREATE TABLE session_discovery (
|
|
1204
|
+
root TEXT NOT NULL REFERENCES connections(root), directory TEXT NOT NULL, agent TEXT NOT NULL, project_dir TEXT NOT NULL,
|
|
1205
|
+
PRIMARY KEY(root, directory)
|
|
1206
|
+
);
|
|
1207
|
+
CREATE TABLE session_discovery_cache (
|
|
1208
|
+
root TEXT NOT NULL, path TEXT NOT NULL, signature TEXT NOT NULL, PRIMARY KEY(root, path)
|
|
1209
|
+
);
|
|
1210
|
+
`;
|
|
1211
|
+
var SessionState = class {
|
|
1212
|
+
constructor(state, root) {
|
|
1213
|
+
this.state = state;
|
|
1214
|
+
this.root = root;
|
|
864
1215
|
}
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
|
|
870
|
-
|
|
871
|
-
db.
|
|
872
|
-
|
|
873
|
-
|
|
874
|
-
|
|
875
|
-
const version = Number(db.prepare("PRAGMA user_version").get().user_version);
|
|
876
|
-
if (version > migrations.length)
|
|
877
|
-
throw new Error("Database was created by a newer Teammy version.");
|
|
878
|
-
for (let index = version; index < migrations.length; index++) {
|
|
879
|
-
db.exec(migrations[index]);
|
|
880
|
-
db.exec(`PRAGMA user_version = ${index + 1}`);
|
|
881
|
-
}
|
|
882
|
-
});
|
|
883
|
-
return db;
|
|
884
|
-
} catch (error) {
|
|
885
|
-
db.close();
|
|
886
|
-
throw error;
|
|
887
|
-
}
|
|
888
|
-
}
|
|
889
|
-
|
|
890
|
-
// apps/client/root-lock.ts
|
|
891
|
-
function acquireRootLock(root, stateDirectory) {
|
|
892
|
-
let lock;
|
|
893
|
-
try {
|
|
894
|
-
lock = openDatabase(
|
|
895
|
-
join2(stateDirectory, "locks", `${createHash4("sha256").update(root).digest("hex")}.sqlite3`),
|
|
896
|
-
[]
|
|
897
|
-
);
|
|
898
|
-
lock.exec("PRAGMA busy_timeout=0; BEGIN IMMEDIATE");
|
|
899
|
-
return lock;
|
|
900
|
-
} catch (error) {
|
|
901
|
-
lock?.close();
|
|
902
|
-
if (error instanceof Error && /locked|busy/.test(error.message))
|
|
903
|
-
throw new Error("Another sync or connect process is using this root.");
|
|
904
|
-
throw error;
|
|
905
|
-
}
|
|
906
|
-
}
|
|
907
|
-
|
|
908
|
-
// apps/client/connection.ts
|
|
909
|
-
async function connectProject(state, root, directory, options) {
|
|
910
|
-
const lock = acquireRootLock(root, directory);
|
|
911
|
-
try {
|
|
912
|
-
const transport = new HubClient(options.url);
|
|
913
|
-
const existing = state.binding(root);
|
|
914
|
-
const project = new ClientProjectState(state, root);
|
|
915
|
-
if (existing && existing.serverUrl !== transport.url)
|
|
916
|
-
throw new Error("This root is connected to another server.");
|
|
917
|
-
let member = options.member;
|
|
918
|
-
if (existing && !options.reconnect && !options.tokenFile) {
|
|
919
|
-
if (member && member !== existing.memberId)
|
|
920
|
-
throw new Error("This root belongs to a different member.");
|
|
921
|
-
const client2 = new HubClient(existing.serverUrl, 1e4, existing);
|
|
922
|
-
await client2.members();
|
|
923
|
-
} else if (options.tokenFile) {
|
|
924
|
-
const token = CredentialSchema.parse((await readFile(options.tokenFile, "utf8")).trim());
|
|
925
|
-
const deviceId = state.deviceFor(root);
|
|
926
|
-
const client2 = new HubClient(options.url, 1e4, { token, deviceId });
|
|
927
|
-
const binding2 = await client2.bind(deviceId);
|
|
928
|
-
state.saveBinding(root, client2.url, token, binding2);
|
|
929
|
-
} else {
|
|
930
|
-
member ??= existing?.memberId;
|
|
931
|
-
if (!member && process.stdin.isTTY) {
|
|
932
|
-
const rl = createInterface({ input: process.stdin, output: process.stderr });
|
|
933
|
-
try {
|
|
934
|
-
member = (await rl.question("Member ID: ")).trim();
|
|
935
|
-
} finally {
|
|
936
|
-
rl.close();
|
|
937
|
-
}
|
|
938
|
-
}
|
|
939
|
-
if (!member) throw new Error("--member is required when input is not interactive.");
|
|
940
|
-
NameSchema.parse(member);
|
|
941
|
-
if (existing && existing.memberId !== member)
|
|
942
|
-
throw new Error("This root belongs to a different member.");
|
|
943
|
-
const request = project.enrollment(transport.url, member, Boolean(options.reconnect));
|
|
944
|
-
const binding2 = await transport.enroll(request);
|
|
945
|
-
state.saveBinding(root, transport.url, request.credential, binding2);
|
|
946
|
-
project.completeEnrollment();
|
|
947
|
-
}
|
|
948
|
-
const binding = state.binding(root);
|
|
949
|
-
const client = new HubClient(binding.serverUrl, 1e4, binding);
|
|
950
|
-
const info = await client.serverInfo();
|
|
951
|
-
if (info.capabilities.includes("project-config")) {
|
|
952
|
-
try {
|
|
953
|
-
await applyProjectFile(project, await client.projectConfig());
|
|
954
|
-
} catch (error) {
|
|
955
|
-
if (!(options.tokenFile && error instanceof Error && "code" in error && ["SETUP_REQUIRED", "PROJECT_NOT_FOUND"].includes(String(error.code))))
|
|
956
|
-
throw error;
|
|
957
|
-
}
|
|
958
|
-
}
|
|
959
|
-
const own = await metadata(root, binding.memberId);
|
|
960
|
-
if (own && !own.directory) throw new Error("Member path is not a directory.");
|
|
961
|
-
if (!own) await mkdir2(await safePath(root, binding.memberId));
|
|
962
|
-
const { token: _, ...visible } = binding;
|
|
963
|
-
return { ...visible, configRevision: project.cached()?.config.configRevision ?? null };
|
|
964
|
-
} finally {
|
|
965
|
-
lock.close();
|
|
966
|
-
}
|
|
967
|
-
}
|
|
968
|
-
|
|
969
|
-
// apps/client/main.ts
|
|
970
|
-
import { parseArgs as parseArgs3 } from "node:util";
|
|
971
|
-
import { mkdir as mkdir7, realpath as realpath6 } from "node:fs/promises";
|
|
972
|
-
import { homedir as homedir3 } from "node:os";
|
|
973
|
-
import { join as join11, resolve as resolve6 } from "node:path";
|
|
974
|
-
|
|
975
|
-
// apps/client/state.ts
|
|
976
|
-
import { randomUUID as randomUUID3 } from "node:crypto";
|
|
977
|
-
import { join as join3 } from "node:path";
|
|
978
|
-
import { z as z8 } from "zod";
|
|
979
|
-
|
|
980
|
-
// apps/client/sessions/state.ts
|
|
981
|
-
var sessionClientMigration = String.raw`
|
|
982
|
-
CREATE TABLE session_sources (
|
|
983
|
-
root TEXT NOT NULL REFERENCES connections(root), path TEXT NOT NULL, agent TEXT NOT NULL, project_dir TEXT NOT NULL,
|
|
984
|
-
session_key TEXT, revision INTEGER NOT NULL DEFAULT 0, hash TEXT, signature TEXT,
|
|
985
|
-
checked_at INTEGER NOT NULL DEFAULT 0, verified_at INTEGER NOT NULL DEFAULT 0, last_error TEXT, adopt_existing INTEGER NOT NULL DEFAULT 0,
|
|
986
|
-
pending_json TEXT, pending_signature TEXT, attempts INTEGER NOT NULL DEFAULT 0, retry_at INTEGER NOT NULL DEFAULT 0,
|
|
987
|
-
PRIMARY KEY(root, path), UNIQUE(root, session_key)
|
|
988
|
-
);
|
|
989
|
-
CREATE INDEX session_source_scheduling ON session_sources(root, checked_at, path);
|
|
990
|
-
CREATE TABLE session_discovery (
|
|
991
|
-
root TEXT NOT NULL REFERENCES connections(root), directory TEXT NOT NULL, agent TEXT NOT NULL, project_dir TEXT NOT NULL,
|
|
992
|
-
PRIMARY KEY(root, directory)
|
|
993
|
-
);
|
|
994
|
-
CREATE TABLE session_discovery_cache (
|
|
995
|
-
root TEXT NOT NULL, path TEXT NOT NULL, signature TEXT NOT NULL, PRIMARY KEY(root, path)
|
|
996
|
-
);
|
|
997
|
-
`;
|
|
998
|
-
var SessionState = class {
|
|
999
|
-
constructor(state, root) {
|
|
1000
|
-
this.state = state;
|
|
1001
|
-
this.root = root;
|
|
1002
|
-
}
|
|
1003
|
-
state;
|
|
1004
|
-
root;
|
|
1005
|
-
track(agent, path, projectDir, adoptExisting = false) {
|
|
1006
|
-
const prior = this.get(path);
|
|
1007
|
-
if (prior && (prior.agent !== agent || prior.projectDir !== projectDir))
|
|
1008
|
-
throw new Error("Source is registered with different settings; untrack it first.");
|
|
1009
|
-
this.state.db.prepare(
|
|
1010
|
-
`INSERT INTO session_sources(root,path,agent,project_dir,adopt_existing) VALUES (?,?,?,?,?)
|
|
1011
|
-
ON CONFLICT(root,path) DO UPDATE SET adopt_existing=MAX(adopt_existing,excluded.adopt_existing)`
|
|
1012
|
-
).run(this.root, path, agent, projectDir, Number(adoptExisting));
|
|
1216
|
+
state;
|
|
1217
|
+
root;
|
|
1218
|
+
track(agent, path, projectDir, adoptExisting = false) {
|
|
1219
|
+
const prior = this.get(path);
|
|
1220
|
+
if (prior && (prior.agent !== agent || prior.projectDir !== projectDir))
|
|
1221
|
+
throw new Error("Source is registered with different settings; untrack it first.");
|
|
1222
|
+
this.state.db.prepare(
|
|
1223
|
+
`INSERT INTO session_sources(root,path,agent,project_dir,adopt_existing) VALUES (?,?,?,?,?)
|
|
1224
|
+
ON CONFLICT(root,path) DO UPDATE SET adopt_existing=MAX(adopt_existing,excluded.adopt_existing)`
|
|
1225
|
+
).run(this.root, path, agent, projectDir, Number(adoptExisting));
|
|
1013
1226
|
}
|
|
1014
1227
|
get(path) {
|
|
1015
1228
|
return this.sources(path)[0];
|
|
@@ -1115,7 +1328,8 @@ var SyncState = class {
|
|
|
1115
1328
|
generation: Number(row.generation),
|
|
1116
1329
|
observedAt: Number(row.observed_at),
|
|
1117
1330
|
hash: row.hash === null ? null : String(row.hash),
|
|
1118
|
-
verifiedAt: Number(row.verified_at)
|
|
1331
|
+
verifiedAt: Number(row.verified_at),
|
|
1332
|
+
seenLocal: Boolean(row.seen_local)
|
|
1119
1333
|
}));
|
|
1120
1334
|
}
|
|
1121
1335
|
audit(before, limit) {
|
|
@@ -1133,7 +1347,8 @@ var SyncState = class {
|
|
|
1133
1347
|
generation: Number(row.generation),
|
|
1134
1348
|
observedAt: Number(row.observed_at),
|
|
1135
1349
|
hash: row.hash === null ? null : String(row.hash),
|
|
1136
|
-
verifiedAt: Number(row.verified_at)
|
|
1350
|
+
verifiedAt: Number(row.verified_at),
|
|
1351
|
+
seenLocal: Boolean(row.seen_local)
|
|
1137
1352
|
} : void 0;
|
|
1138
1353
|
}
|
|
1139
1354
|
restartWaiting(now) {
|
|
@@ -1152,11 +1367,13 @@ var SyncState = class {
|
|
|
1152
1367
|
const prior = this.get(path);
|
|
1153
1368
|
if (!prior) {
|
|
1154
1369
|
if (signature2 !== null)
|
|
1155
|
-
this.db.prepare(
|
|
1370
|
+
this.db.prepare(
|
|
1371
|
+
"INSERT INTO file_index(root,path,signature,observed_at,seen_local) VALUES (?,?,?,?,1)"
|
|
1372
|
+
).run(this.root, path, signature2, now);
|
|
1156
1373
|
} else if (signature2 !== prior.signature) {
|
|
1157
1374
|
this.db.prepare(
|
|
1158
|
-
"UPDATE file_index SET signature=?, dirty=1, generation=generation+1, observed_at=?, event_pending=0 WHERE root=? AND path=?"
|
|
1159
|
-
).run(signature2, prior.eventPending ? prior.observedAt : now, this.root, path);
|
|
1375
|
+
"UPDATE file_index SET seen_local=MAX(seen_local, ? IS NOT NULL), signature=?, dirty=1, generation=generation+1, observed_at=?, event_pending=0 WHERE root=? AND path=?"
|
|
1376
|
+
).run(signature2, signature2, prior.eventPending ? prior.observedAt : now, this.root, path);
|
|
1160
1377
|
} else if (prior.eventPending) {
|
|
1161
1378
|
this.db.prepare("UPDATE file_index SET event_pending=0 WHERE root=? AND path=?").run(this.root, path);
|
|
1162
1379
|
}
|
|
@@ -1230,7 +1447,7 @@ var LocalBindingSchema = BindingSchema.extend({
|
|
|
1230
1447
|
var ClientState = class {
|
|
1231
1448
|
db;
|
|
1232
1449
|
constructor(directory) {
|
|
1233
|
-
this.db = openDatabase(
|
|
1450
|
+
this.db = openDatabase(join4(directory, "client.sqlite3"), [
|
|
1234
1451
|
String.raw`
|
|
1235
1452
|
CREATE TABLE connections (root TEXT PRIMARY KEY, device_id TEXT NOT NULL, binding_json TEXT);
|
|
1236
1453
|
`,
|
|
@@ -1242,13 +1459,27 @@ var ClientState = class {
|
|
|
1242
1459
|
syncMigration,
|
|
1243
1460
|
sessionClientMigration,
|
|
1244
1461
|
projectClientMigration,
|
|
1245
|
-
controlClientMigration
|
|
1462
|
+
controlClientMigration,
|
|
1463
|
+
runtimeMigration
|
|
1246
1464
|
]);
|
|
1247
1465
|
}
|
|
1248
1466
|
close() {
|
|
1249
1467
|
this.db.close();
|
|
1250
1468
|
}
|
|
1469
|
+
assertRootAvailable(root) {
|
|
1470
|
+
for (const row of this.db.prepare("SELECT root FROM connections").all()) {
|
|
1471
|
+
const other = String(row.root);
|
|
1472
|
+
if (root === other) continue;
|
|
1473
|
+
const contains = (parent, child) => {
|
|
1474
|
+
const path = relative2(parent, child);
|
|
1475
|
+
return path === "" || !isAbsolute(path) && path !== ".." && !path.startsWith(`..${sep2}`);
|
|
1476
|
+
};
|
|
1477
|
+
if (contains(root, other) || contains(other, root))
|
|
1478
|
+
throw new Error("Sync roots must not overlap. Use separate project directories.");
|
|
1479
|
+
}
|
|
1480
|
+
}
|
|
1251
1481
|
deviceFor(root) {
|
|
1482
|
+
this.assertRootAvailable(root);
|
|
1252
1483
|
this.db.prepare("INSERT OR IGNORE INTO connections (root, device_id) VALUES (?, ?)").run(root, randomUUID3());
|
|
1253
1484
|
return String(
|
|
1254
1485
|
this.db.prepare("SELECT device_id FROM connections WHERE root=?").get(root).device_id
|
|
@@ -1259,6 +1490,7 @@ var ClientState = class {
|
|
|
1259
1490
|
return row?.binding_json ? LocalBindingSchema.parse(JSON.parse(String(row.binding_json))) : void 0;
|
|
1260
1491
|
}
|
|
1261
1492
|
saveBinding(root, serverUrl, token, binding) {
|
|
1493
|
+
this.assertRootAvailable(root);
|
|
1262
1494
|
const prior = this.binding(root);
|
|
1263
1495
|
if (prior && (prior.workspaceId !== binding.workspaceId || prior.memberId !== binding.memberId || prior.serverUrl !== serverUrl)) {
|
|
1264
1496
|
throw new Error(
|
|
@@ -1312,10 +1544,10 @@ var ClientState = class {
|
|
|
1312
1544
|
this.db.prepare("INSERT INTO outbox(root, request_json) VALUES (?, ?)").run(root, JSON.stringify(fixed));
|
|
1313
1545
|
});
|
|
1314
1546
|
}
|
|
1315
|
-
acknowledge(root,
|
|
1547
|
+
acknowledge(root, result2) {
|
|
1316
1548
|
transaction(this.db, () => {
|
|
1317
1549
|
const pending = this.pending(root);
|
|
1318
|
-
if (!pending || pending.batch.operationId !==
|
|
1550
|
+
if (!pending || pending.batch.operationId !== result2.operationId || result2.revision < pending.batch.parentRevision || result2.revision > pending.batch.parentRevision + 1)
|
|
1319
1551
|
throw new Error("Unexpected publication acknowledgement.");
|
|
1320
1552
|
for (const change of pending.batch.changes) {
|
|
1321
1553
|
if (change.kind === "delete")
|
|
@@ -1325,7 +1557,7 @@ var ClientState = class {
|
|
|
1325
1557
|
"INSERT INTO owner_files VALUES (?, ?, ?, ?) ON CONFLICT(root, path) DO UPDATE SET hash=excluded.hash, size=excluded.size"
|
|
1326
1558
|
).run(root, change.path, change.sha256, change.size);
|
|
1327
1559
|
}
|
|
1328
|
-
this.db.prepare("UPDATE publications SET revision=? WHERE root=?").run(
|
|
1560
|
+
this.db.prepare("UPDATE publications SET revision=? WHERE root=?").run(result2.revision, root);
|
|
1329
1561
|
this.db.prepare("DELETE FROM outbox WHERE root=?").run(root);
|
|
1330
1562
|
});
|
|
1331
1563
|
}
|
|
@@ -1340,22 +1572,15 @@ var ClientState = class {
|
|
|
1340
1572
|
}
|
|
1341
1573
|
};
|
|
1342
1574
|
|
|
1343
|
-
// apps/client/sessions/command.ts
|
|
1344
|
-
import { createHash as createHash6 } from "node:crypto";
|
|
1345
|
-
import { realpath as realpath3 } from "node:fs/promises";
|
|
1346
|
-
import { homedir } from "node:os";
|
|
1347
|
-
import { dirname as dirname3, join as join5, resolve as resolve2 } from "node:path";
|
|
1348
|
-
import { parseArgs } from "node:util";
|
|
1349
|
-
|
|
1350
1575
|
// apps/client/state-location.ts
|
|
1351
|
-
import { mkdir as mkdir3, realpath as
|
|
1352
|
-
import { isAbsolute, relative as
|
|
1576
|
+
import { mkdir as mkdir3, realpath as realpath3 } from "node:fs/promises";
|
|
1577
|
+
import { isAbsolute as isAbsolute2, relative as relative3, resolve as resolve2, sep as sep3 } from "node:path";
|
|
1353
1578
|
async function stateLocation(root, directory) {
|
|
1354
|
-
await mkdir3(
|
|
1355
|
-
const canonical = await
|
|
1579
|
+
await mkdir3(resolve2(directory), { recursive: true, mode: 448 });
|
|
1580
|
+
const canonical = await realpath3(directory);
|
|
1356
1581
|
const contains = (parent, child) => {
|
|
1357
|
-
const path =
|
|
1358
|
-
return path === "" || !
|
|
1582
|
+
const path = relative3(parent, child);
|
|
1583
|
+
return path === "" || !isAbsolute2(path) && path !== ".." && !path.startsWith(`..${sep3}`);
|
|
1359
1584
|
};
|
|
1360
1585
|
if (contains(root, canonical) || contains(canonical, root))
|
|
1361
1586
|
throw new Error(
|
|
@@ -1367,7 +1592,7 @@ async function stateLocation(root, directory) {
|
|
|
1367
1592
|
// apps/client/sessions/hooks.ts
|
|
1368
1593
|
import { createHash as createHash5, randomUUID as randomUUID4 } from "node:crypto";
|
|
1369
1594
|
import { mkdir as mkdir4, open as open2, opendir, rename as rename2, unlink as unlink2 } from "node:fs/promises";
|
|
1370
|
-
import { isAbsolute as
|
|
1595
|
+
import { isAbsolute as isAbsolute3, join as join5 } from "node:path";
|
|
1371
1596
|
import { z as z9 } from "zod";
|
|
1372
1597
|
|
|
1373
1598
|
// apps/client/sessions/normalize.ts
|
|
@@ -1488,17 +1713,17 @@ function normalizeJsonl(agent, text) {
|
|
|
1488
1713
|
// apps/client/sessions/hooks.ts
|
|
1489
1714
|
var HintSchema = z9.object({
|
|
1490
1715
|
agent: AgentSchema,
|
|
1491
|
-
path: z9.string().min(1).max(4096).refine(
|
|
1492
|
-
projectDir: z9.string().min(1).max(4096).refine(
|
|
1716
|
+
path: z9.string().min(1).max(4096).refine(isAbsolute3),
|
|
1717
|
+
projectDir: z9.string().min(1).max(4096).refine(isAbsolute3)
|
|
1493
1718
|
});
|
|
1494
1719
|
function inbox(directory, root) {
|
|
1495
|
-
return
|
|
1720
|
+
return join5(directory, "session-hints", createHash5("sha256").update(root).digest("hex"));
|
|
1496
1721
|
}
|
|
1497
1722
|
async function recordHint(directory, root, input) {
|
|
1498
1723
|
const hint = HintSchema.parse(input);
|
|
1499
1724
|
const targetDir = inbox(directory, root);
|
|
1500
1725
|
await mkdir4(targetDir, { recursive: true, mode: 448 });
|
|
1501
|
-
const target =
|
|
1726
|
+
const target = join5(
|
|
1502
1727
|
targetDir,
|
|
1503
1728
|
`${createHash5("sha256").update(JSON.stringify(hint)).digest("hex")}.json`
|
|
1504
1729
|
);
|
|
@@ -1524,7 +1749,7 @@ async function consumeHints(directory, root, sessions) {
|
|
|
1524
1749
|
for await (const entry of dir) {
|
|
1525
1750
|
if (!entry.isFile() || !entry.name.endsWith(".json")) continue;
|
|
1526
1751
|
if (count++ >= 50) break;
|
|
1527
|
-
const path =
|
|
1752
|
+
const path = join5(inbox(directory, root), entry.name);
|
|
1528
1753
|
try {
|
|
1529
1754
|
const handle = await open2(path, "r");
|
|
1530
1755
|
let body;
|
|
@@ -1590,8 +1815,8 @@ async function runSessionCommand(args) {
|
|
|
1590
1815
|
const { values } = parseArgs({
|
|
1591
1816
|
args: args.slice(1),
|
|
1592
1817
|
options: {
|
|
1593
|
-
root: { type: "string", default:
|
|
1594
|
-
"state-dir": { type: "string", default:
|
|
1818
|
+
root: { type: "string", default: resolve3("workspaces") },
|
|
1819
|
+
"state-dir": { type: "string", default: join6(homedir(), ".teammy", "client") },
|
|
1595
1820
|
agent: { type: "string" },
|
|
1596
1821
|
file: { type: "string" },
|
|
1597
1822
|
directory: { type: "string" },
|
|
@@ -1610,16 +1835,16 @@ async function runSessionCommand(args) {
|
|
|
1610
1835
|
process.stdout.write(help);
|
|
1611
1836
|
return 0;
|
|
1612
1837
|
}
|
|
1613
|
-
const root = await
|
|
1838
|
+
const root = await realpath4(resolve3(values.root));
|
|
1614
1839
|
const directory = await stateLocation(root, values["state-dir"]);
|
|
1615
|
-
const projectDir = await
|
|
1840
|
+
const projectDir = await realpath4(resolve3(values["project-dir"] ?? dirname4(root)));
|
|
1616
1841
|
if (command === "hook") {
|
|
1617
1842
|
const payload = values.file ? {} : await hookPayload();
|
|
1618
1843
|
const file = values.file ?? payload.transcript_path ?? payload.session_file ?? payload.sessionFile;
|
|
1619
1844
|
if (typeof file === "string" && file)
|
|
1620
1845
|
await recordHint(directory, root, {
|
|
1621
1846
|
agent: AgentSchema.parse(values.agent),
|
|
1622
|
-
path:
|
|
1847
|
+
path: resolve3(file),
|
|
1623
1848
|
projectDir
|
|
1624
1849
|
});
|
|
1625
1850
|
return 0;
|
|
@@ -1633,7 +1858,7 @@ async function runSessionCommand(args) {
|
|
|
1633
1858
|
let output;
|
|
1634
1859
|
if (command === "track" || command === "untrack") {
|
|
1635
1860
|
if (!values.file) throw new Error("--file is required.");
|
|
1636
|
-
const path =
|
|
1861
|
+
const path = resolve3(values.file);
|
|
1637
1862
|
if (command === "track")
|
|
1638
1863
|
sessions.track(
|
|
1639
1864
|
AgentSchema.parse(values.agent),
|
|
@@ -1646,23 +1871,23 @@ async function runSessionCommand(args) {
|
|
|
1646
1871
|
} else if (command === "discover" || command === "undiscover") {
|
|
1647
1872
|
if (command === "undiscover") {
|
|
1648
1873
|
if (!values.directory) throw new Error("--directory is required.");
|
|
1649
|
-
state.db.prepare("DELETE FROM session_discovery WHERE root=? AND directory=?").run(root, await
|
|
1874
|
+
state.db.prepare("DELETE FROM session_discovery WHERE root=? AND directory=?").run(root, await realpath4(resolve3(values.directory)));
|
|
1650
1875
|
} else {
|
|
1651
1876
|
const agent = AgentSchema.parse(values.agent);
|
|
1652
1877
|
const defaults = {
|
|
1653
|
-
"claude-code":
|
|
1654
|
-
codex:
|
|
1655
|
-
cursor:
|
|
1656
|
-
process.env.CURSOR_CONFIG_DIR ??
|
|
1878
|
+
"claude-code": join6(homedir(), ".claude", "projects"),
|
|
1879
|
+
codex: join6(process.env.CODEX_HOME ?? join6(homedir(), ".codex"), "sessions"),
|
|
1880
|
+
cursor: join6(
|
|
1881
|
+
process.env.CURSOR_CONFIG_DIR ?? join6(homedir(), ".cursor"),
|
|
1657
1882
|
"chats",
|
|
1658
1883
|
createHash6("md5").update(projectDir).digest("hex")
|
|
1659
1884
|
),
|
|
1660
|
-
pi:
|
|
1885
|
+
pi: join6(process.env.PI_CODING_AGENT_DIR ?? join6(homedir(), ".pi", "agent"), "sessions"),
|
|
1661
1886
|
dsh: void 0
|
|
1662
1887
|
};
|
|
1663
1888
|
const path = values.directory ?? defaults[agent];
|
|
1664
1889
|
if (!path) throw new Error("DSH requires its configured persistence --directory.");
|
|
1665
|
-
sessions.discover(agent, await
|
|
1890
|
+
sessions.discover(agent, await realpath4(resolve3(path)), projectDir);
|
|
1666
1891
|
}
|
|
1667
1892
|
output = sessions.directories();
|
|
1668
1893
|
} else if (command === "sources")
|
|
@@ -1697,538 +1922,551 @@ async function runSessionCommand(args) {
|
|
|
1697
1922
|
}
|
|
1698
1923
|
|
|
1699
1924
|
// apps/client/sync-command.ts
|
|
1700
|
-
import {
|
|
1701
|
-
import {
|
|
1702
|
-
|
|
1703
|
-
|
|
1925
|
+
import { parseArgs as parseArgs3 } from "node:util";
|
|
1926
|
+
import { setTimeout as setTimeout4 } from "node:timers/promises";
|
|
1927
|
+
|
|
1928
|
+
// apps/client/client-command.ts
|
|
1704
1929
|
import { parseArgs as parseArgs2 } from "node:util";
|
|
1930
|
+
import { join as join10, resolve as resolve5 } from "node:path";
|
|
1931
|
+
import { homedir as homedir3 } from "node:os";
|
|
1932
|
+
import { createInterface as createInterface2 } from "node:readline/promises";
|
|
1705
1933
|
|
|
1706
|
-
// apps/client/
|
|
1707
|
-
import {
|
|
1708
|
-
import {
|
|
1934
|
+
// apps/client/connection.ts
|
|
1935
|
+
import { readFile, mkdir as mkdir5 } from "node:fs/promises";
|
|
1936
|
+
import { createInterface } from "node:readline/promises";
|
|
1709
1937
|
|
|
1710
|
-
//
|
|
1938
|
+
// apps/client/root-lock.ts
|
|
1711
1939
|
import { createHash as createHash7 } from "node:crypto";
|
|
1712
|
-
|
|
1713
|
-
|
|
1940
|
+
import { join as join7 } from "node:path";
|
|
1941
|
+
function acquireRootLock(root, stateDirectory) {
|
|
1942
|
+
let lock;
|
|
1943
|
+
try {
|
|
1944
|
+
lock = openDatabase(
|
|
1945
|
+
join7(stateDirectory, "locks", `${createHash7("sha256").update(root).digest("hex")}.sqlite3`),
|
|
1946
|
+
[],
|
|
1947
|
+
0
|
|
1948
|
+
);
|
|
1949
|
+
lock.exec("PRAGMA busy_timeout=0; BEGIN IMMEDIATE");
|
|
1950
|
+
return lock;
|
|
1951
|
+
} catch (error) {
|
|
1952
|
+
lock?.close();
|
|
1953
|
+
if (error instanceof Error && /locked|busy/.test(error.message))
|
|
1954
|
+
throw new Error("Another sync or connect process is using this root.");
|
|
1955
|
+
throw error;
|
|
1956
|
+
}
|
|
1714
1957
|
}
|
|
1715
1958
|
|
|
1716
|
-
//
|
|
1717
|
-
|
|
1718
|
-
|
|
1719
|
-
|
|
1720
|
-
|
|
1721
|
-
|
|
1722
|
-
|
|
1723
|
-
|
|
1724
|
-
|
|
1725
|
-
|
|
1726
|
-
|
|
1727
|
-
|
|
1728
|
-
|
|
1729
|
-
|
|
1730
|
-
|
|
1731
|
-
|
|
1732
|
-
|
|
1733
|
-
|
|
1734
|
-
|
|
1735
|
-
|
|
1736
|
-
|
|
1737
|
-
|
|
1738
|
-
|
|
1739
|
-
|
|
1740
|
-
|
|
1741
|
-
|
|
1742
|
-
|
|
1743
|
-
|
|
1744
|
-
|
|
1745
|
-
|
|
1746
|
-
|
|
1747
|
-
|
|
1748
|
-
|
|
1749
|
-
|
|
1750
|
-
|
|
1751
|
-
|
|
1959
|
+
// apps/client/connection.ts
|
|
1960
|
+
async function connectProject(state, root, directory, options) {
|
|
1961
|
+
const lock = acquireRootLock(root, directory);
|
|
1962
|
+
try {
|
|
1963
|
+
state.assertRootAvailable(root);
|
|
1964
|
+
const transport = new HubClient(options.url);
|
|
1965
|
+
const existing = state.binding(root);
|
|
1966
|
+
const project = new ClientProjectState(state, root);
|
|
1967
|
+
if (existing && existing.serverUrl !== transport.url)
|
|
1968
|
+
throw new Error("This root is connected to another server.");
|
|
1969
|
+
if (existing && options.project && options.project !== existing.workspaceId)
|
|
1970
|
+
throw new Error("This root belongs to a different project. Use a different local root.");
|
|
1971
|
+
const info = await transport.serverInfo();
|
|
1972
|
+
if (!info.capabilities.includes("multi-project"))
|
|
1973
|
+
throw new Error(
|
|
1974
|
+
"This client requires a multi-project server. Update the server before connecting."
|
|
1975
|
+
);
|
|
1976
|
+
let member = options.member;
|
|
1977
|
+
if (existing && !options.reconnect && !options.tokenFile) {
|
|
1978
|
+
if (member && member !== existing.memberId)
|
|
1979
|
+
throw new Error("This root belongs to a different member.");
|
|
1980
|
+
const client2 = new HubClient(existing.serverUrl, 1e4, existing);
|
|
1981
|
+
await client2.members();
|
|
1982
|
+
} else if (options.tokenFile) {
|
|
1983
|
+
const token = CredentialSchema.parse((await readFile(options.tokenFile, "utf8")).trim());
|
|
1984
|
+
const deviceId = state.deviceFor(root);
|
|
1985
|
+
const client2 = new HubClient(options.url, 1e4, { token, deviceId });
|
|
1986
|
+
const binding2 = await client2.bind(deviceId, options.project ?? existing?.workspaceId);
|
|
1987
|
+
if (options.project && binding2.workspaceId !== options.project)
|
|
1988
|
+
throw new Error("Token belongs to a different project.");
|
|
1989
|
+
state.saveBinding(root, client2.url, token, binding2);
|
|
1990
|
+
} else {
|
|
1991
|
+
member ??= existing?.memberId;
|
|
1992
|
+
if (!member && process.stdin.isTTY) {
|
|
1993
|
+
const rl = createInterface({ input: process.stdin, output: process.stderr });
|
|
1994
|
+
try {
|
|
1995
|
+
member = (await rl.question("Member ID: ")).trim();
|
|
1996
|
+
} finally {
|
|
1997
|
+
rl.close();
|
|
1998
|
+
}
|
|
1752
1999
|
}
|
|
2000
|
+
if (!member) throw new Error("--member is required when input is not interactive.");
|
|
2001
|
+
NameSchema.parse(member);
|
|
2002
|
+
if (existing && existing.memberId !== member)
|
|
2003
|
+
throw new Error("This root belongs to a different member.");
|
|
2004
|
+
const projectId = options.project ?? existing?.workspaceId;
|
|
2005
|
+
if (!projectId)
|
|
2006
|
+
throw new Error(
|
|
2007
|
+
"--project is required for a new connection; copy the command from the project settings."
|
|
2008
|
+
);
|
|
2009
|
+
NameSchema.parse(projectId);
|
|
2010
|
+
const request = project.enrollment(
|
|
2011
|
+
transport.url,
|
|
2012
|
+
member,
|
|
2013
|
+
projectId,
|
|
2014
|
+
Boolean(options.reconnect)
|
|
2015
|
+
);
|
|
2016
|
+
const binding2 = await transport.enroll(request);
|
|
2017
|
+
state.saveBinding(root, transport.url, request.credential, binding2);
|
|
2018
|
+
project.completeEnrollment();
|
|
2019
|
+
}
|
|
2020
|
+
const binding = state.binding(root);
|
|
2021
|
+
const client = new HubClient(binding.serverUrl, 1e4, binding);
|
|
2022
|
+
if (info.capabilities.includes("project-config")) {
|
|
1753
2023
|
try {
|
|
1754
|
-
await
|
|
2024
|
+
await applyProjectFile(project, await client.projectConfig());
|
|
1755
2025
|
} catch (error) {
|
|
1756
|
-
if (!
|
|
1757
|
-
|
|
1758
|
-
}
|
|
1759
|
-
if (process.platform !== "win32") {
|
|
1760
|
-
let directory = dirname4(target);
|
|
1761
|
-
for (; ; ) {
|
|
1762
|
-
const handle = await open3(directory, "r");
|
|
1763
|
-
try {
|
|
1764
|
-
await handle.sync();
|
|
1765
|
-
} finally {
|
|
1766
|
-
await handle.close();
|
|
1767
|
-
}
|
|
1768
|
-
if (directory === dirname4(this.root)) break;
|
|
1769
|
-
directory = dirname4(directory);
|
|
1770
|
-
}
|
|
2026
|
+
if (!(options.tokenFile && error instanceof Error && "code" in error && ["SETUP_REQUIRED", "PROJECT_NOT_FOUND"].includes(String(error.code))))
|
|
2027
|
+
throw error;
|
|
1771
2028
|
}
|
|
1772
|
-
} finally {
|
|
1773
|
-
await unlink3(temporary);
|
|
1774
2029
|
}
|
|
1775
|
-
|
|
1776
|
-
|
|
1777
|
-
|
|
1778
|
-
const
|
|
1779
|
-
|
|
1780
|
-
|
|
1781
|
-
|
|
1782
|
-
} catch (error) {
|
|
1783
|
-
if (hasCode(error, "ENOENT")) return void 0;
|
|
1784
|
-
throw error;
|
|
1785
|
-
}
|
|
1786
|
-
if (hash(content) !== sha256) throw new BlobIntegrityError(sha256);
|
|
1787
|
-
return content;
|
|
1788
|
-
}
|
|
1789
|
-
pathFor(sha256) {
|
|
1790
|
-
if (!/^[a-f0-9]{64}$/.test(sha256)) throw new Error("Invalid SHA-256 content identifier.");
|
|
1791
|
-
return join6(this.root, "sha256", sha256.slice(0, 2), sha256);
|
|
2030
|
+
const own = await metadata(root, binding.memberId);
|
|
2031
|
+
if (own && !own.directory) throw new Error("Member path is not a directory.");
|
|
2032
|
+
if (!own) await mkdir5(await safePath(root, binding.memberId));
|
|
2033
|
+
const { token: _, ...visible } = binding;
|
|
2034
|
+
return { ...visible, configRevision: project.cached()?.config.configRevision ?? null };
|
|
2035
|
+
} finally {
|
|
2036
|
+
lock.close();
|
|
1792
2037
|
}
|
|
1793
|
-
}
|
|
1794
|
-
|
|
1795
|
-
// apps/client/sessions/source.ts
|
|
1796
|
-
import { open as open4, stat, realpath as realpath4 } from "node:fs/promises";
|
|
1797
|
-
import { relative as relative3, isAbsolute as isAbsolute3, sep as sep3 } from "node:path";
|
|
1798
|
-
import * as zlib from "node:zlib";
|
|
2038
|
+
}
|
|
1799
2039
|
|
|
1800
|
-
// apps/client/
|
|
1801
|
-
import {
|
|
1802
|
-
import {
|
|
1803
|
-
import {
|
|
1804
|
-
import {
|
|
1805
|
-
|
|
1806
|
-
|
|
1807
|
-
|
|
1808
|
-
|
|
1809
|
-
|
|
1810
|
-
|
|
1811
|
-
|
|
1812
|
-
|
|
1813
|
-
|
|
1814
|
-
|
|
1815
|
-
}
|
|
1816
|
-
throw new Error("Invalid Cursor protobuf integer.");
|
|
1817
|
-
};
|
|
1818
|
-
const result = /* @__PURE__ */ new Map();
|
|
1819
|
-
while (offset < data.length) {
|
|
1820
|
-
const tag = varint();
|
|
1821
|
-
const field = Number(tag >> 3n);
|
|
1822
|
-
if (field < 1 || field > 536870911) throw new Error("Invalid Cursor protobuf field.");
|
|
1823
|
-
const wire = Number(tag & 7n);
|
|
1824
|
-
if (wire === 0) varint();
|
|
1825
|
-
else if (wire === 1) offset += 8;
|
|
1826
|
-
else if (wire === 5) offset += 4;
|
|
1827
|
-
else if (wire === 2) {
|
|
1828
|
-
const length = Number(varint());
|
|
1829
|
-
if (!Number.isSafeInteger(length) || length > data.length - offset)
|
|
1830
|
-
throw new Error("Truncated Cursor protobuf value.");
|
|
1831
|
-
const values = result.get(field) ?? [];
|
|
1832
|
-
values.push(data.subarray(offset, offset + length));
|
|
1833
|
-
result.set(field, values);
|
|
1834
|
-
offset += length;
|
|
1835
|
-
} else throw new Error("Unsupported Cursor protobuf wire type.");
|
|
1836
|
-
if (offset > data.length) throw new Error("Truncated Cursor protobuf value.");
|
|
1837
|
-
}
|
|
1838
|
-
return result;
|
|
2040
|
+
// apps/client/runtime/ipc.ts
|
|
2041
|
+
import { createConnection } from "node:net";
|
|
2042
|
+
import { mkdir as mkdir6, lstat as lstat3, chmod } from "node:fs/promises";
|
|
2043
|
+
import { join as join8 } from "node:path";
|
|
2044
|
+
import { z as z10 } from "zod";
|
|
2045
|
+
var RequestSchema = z10.object({
|
|
2046
|
+
method: z10.enum(["ping", "wake", "status", "once"]),
|
|
2047
|
+
root: z10.string().max(4096).optional()
|
|
2048
|
+
}).strict();
|
|
2049
|
+
function socketPath(directory) {
|
|
2050
|
+
return join8(
|
|
2051
|
+
"/tmp",
|
|
2052
|
+
`teammy-${process.getuid?.() ?? "user"}-${rootKey(directory)}`,
|
|
2053
|
+
"control.sock"
|
|
2054
|
+
);
|
|
1839
2055
|
}
|
|
1840
|
-
function
|
|
1841
|
-
const
|
|
1842
|
-
|
|
1843
|
-
|
|
1844
|
-
|
|
1845
|
-
|
|
1846
|
-
|
|
1847
|
-
|
|
1848
|
-
|
|
1849
|
-
|
|
1850
|
-
|
|
1851
|
-
|
|
1852
|
-
|
|
1853
|
-
let
|
|
1854
|
-
|
|
1855
|
-
|
|
1856
|
-
|
|
1857
|
-
|
|
1858
|
-
|
|
1859
|
-
|
|
1860
|
-
|
|
1861
|
-
if (createHash9("sha256").update(bytes).digest("hex") !== id)
|
|
1862
|
-
throw new Error("Cursor blob failed integrity verification.");
|
|
1863
|
-
return bytes;
|
|
2056
|
+
async function prepareSocketDirectory(directory) {
|
|
2057
|
+
const path = join8(socketPath(directory), "..");
|
|
2058
|
+
await mkdir6(path, { recursive: true, mode: 448 });
|
|
2059
|
+
const stat2 = await lstat3(path);
|
|
2060
|
+
if (!stat2.isDirectory() || stat2.isSymbolicLink() || stat2.uid !== process.getuid?.())
|
|
2061
|
+
throw new Error("Unsafe daemon socket directory.");
|
|
2062
|
+
await chmod(path, 448);
|
|
2063
|
+
}
|
|
2064
|
+
async function daemonRequest(directory, input, timeout = 15e3) {
|
|
2065
|
+
RequestSchema.parse(input);
|
|
2066
|
+
return new Promise((resolve9, reject) => {
|
|
2067
|
+
const socket = createConnection(socketPath(directory));
|
|
2068
|
+
socket.setEncoding("utf8");
|
|
2069
|
+
let body = "";
|
|
2070
|
+
let done = false;
|
|
2071
|
+
const finish = (error, result2) => {
|
|
2072
|
+
if (done) return;
|
|
2073
|
+
done = true;
|
|
2074
|
+
socket.destroy();
|
|
2075
|
+
if (error) reject(error);
|
|
2076
|
+
else resolve9(result2);
|
|
1864
2077
|
};
|
|
1865
|
-
|
|
1866
|
-
|
|
1867
|
-
|
|
1868
|
-
|
|
1869
|
-
|
|
2078
|
+
socket.setTimeout(
|
|
2079
|
+
timeout,
|
|
2080
|
+
() => finish(new Error("Background service did not respond. Run teammy status."))
|
|
2081
|
+
);
|
|
2082
|
+
socket.on("error", (e) => finish(e));
|
|
2083
|
+
socket.on("end", () => {
|
|
2084
|
+
if (!done)
|
|
2085
|
+
finish(
|
|
2086
|
+
Object.assign(new Error("Background service disconnected."), { code: "ECONNRESET" })
|
|
2087
|
+
);
|
|
2088
|
+
});
|
|
2089
|
+
socket.on("connect", () => socket.write(JSON.stringify(input) + "\n"));
|
|
2090
|
+
socket.on("data", (chunk) => {
|
|
2091
|
+
body += chunk.toString();
|
|
2092
|
+
if (body.length > 256 * 1024) return finish(new Error("Oversized daemon response."));
|
|
2093
|
+
if (!body.includes("\n")) return;
|
|
1870
2094
|
try {
|
|
1871
|
-
const
|
|
1872
|
-
if (
|
|
1873
|
-
|
|
1874
|
-
|
|
1875
|
-
|
|
1876
|
-
} catch {
|
|
1877
|
-
}
|
|
1878
|
-
}
|
|
1879
|
-
if (!cwd) throw new Error("Cursor workspace cannot be verified against its storage bucket.");
|
|
1880
|
-
const refs = [];
|
|
1881
|
-
for (const archive of root.get(13) ?? root.get(11) ?? [])
|
|
1882
|
-
refs.push(...fields(blob(archive.toString("hex"))).get(1) ?? []);
|
|
1883
|
-
refs.push(...root.get(1) ?? []);
|
|
1884
|
-
if (refs.length > 5e4) throw new Error("Cursor history exceeds 50000 messages.");
|
|
1885
|
-
const messages = [];
|
|
1886
|
-
const counts = /* @__PURE__ */ new Map();
|
|
1887
|
-
for (const ref of refs) {
|
|
1888
|
-
const key = ref.toString("hex");
|
|
1889
|
-
const message2 = object(
|
|
1890
|
-
JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(blob(key)))
|
|
1891
|
-
);
|
|
1892
|
-
const cursor = object(object(message2.providerOptions).cursor);
|
|
1893
|
-
if (cursor.isSummary || !["user", "assistant"].includes(String(message2.role))) continue;
|
|
1894
|
-
let text = textContent(message2.content);
|
|
1895
|
-
if (message2.role === "user") {
|
|
1896
|
-
if (!cursor.requestId) continue;
|
|
1897
|
-
const start = text.indexOf("<user_query>");
|
|
1898
|
-
const end = text.lastIndexOf("</user_query>");
|
|
1899
|
-
if (start >= 0 && end > start) text = text.slice(start + 12, end).trim();
|
|
2095
|
+
const response = JSON.parse(body.split("\n")[0]);
|
|
2096
|
+
if (response.ok) finish(void 0, response.result);
|
|
2097
|
+
else finish(new Error(response.error ?? "Background operation failed."));
|
|
2098
|
+
} catch (e) {
|
|
2099
|
+
finish(e);
|
|
1900
2100
|
}
|
|
1901
|
-
if (!text.trim()) continue;
|
|
1902
|
-
const id = createHash9("sha256").update(JSON.stringify([message2.role, cursor.requestId ?? "", text])).digest("hex");
|
|
1903
|
-
const count = (counts.get(id) ?? 0) + 1;
|
|
1904
|
-
counts.set(id, count);
|
|
1905
|
-
messages.push({
|
|
1906
|
-
id: `${id}#${count}`,
|
|
1907
|
-
role: message2.role,
|
|
1908
|
-
text,
|
|
1909
|
-
source: `blob:${key}`
|
|
1910
|
-
});
|
|
1911
|
-
}
|
|
1912
|
-
return SessionSnapshotSchema.parse({
|
|
1913
|
-
formatVersion: 1,
|
|
1914
|
-
adapterVersion: 1,
|
|
1915
|
-
agent: "cursor",
|
|
1916
|
-
nativeId: meta.agentId,
|
|
1917
|
-
cwd,
|
|
1918
|
-
messages
|
|
1919
2101
|
});
|
|
1920
|
-
}
|
|
1921
|
-
|
|
2102
|
+
});
|
|
2103
|
+
}
|
|
2104
|
+
async function runningDaemon(directory) {
|
|
2105
|
+
try {
|
|
2106
|
+
return await daemonRequest(directory, { method: "ping" }, 1e3);
|
|
2107
|
+
} catch (error) {
|
|
2108
|
+
if (["ENOENT", "ECONNREFUSED", "ECONNRESET", "EPIPE"].includes(
|
|
2109
|
+
String(error.code)
|
|
2110
|
+
))
|
|
2111
|
+
return null;
|
|
2112
|
+
throw error;
|
|
1922
2113
|
}
|
|
1923
2114
|
}
|
|
1924
2115
|
|
|
1925
|
-
// apps/client/
|
|
1926
|
-
|
|
1927
|
-
|
|
1928
|
-
|
|
1929
|
-
|
|
1930
|
-
|
|
2116
|
+
// apps/client/runtime/service.ts
|
|
2117
|
+
import { execFile as execFile2 } from "node:child_process";
|
|
2118
|
+
import { promisify as promisify2 } from "node:util";
|
|
2119
|
+
import { homedir as homedir2 } from "node:os";
|
|
2120
|
+
import { dirname as dirname5, join as join9, resolve as resolve4 } from "node:path";
|
|
2121
|
+
import { mkdir as mkdir7, readFile as readFile2, writeFile, rename as rename3 } from "node:fs/promises";
|
|
2122
|
+
import { setTimeout as setTimeout3 } from "node:timers/promises";
|
|
2123
|
+
var execute = promisify2(execFile2);
|
|
2124
|
+
var exec2 = (file, args, options = {}) => execute(file, args, { timeout: 45e3, ...options });
|
|
2125
|
+
var xml = (s) => s.replace(
|
|
2126
|
+
/[<>&"']/g,
|
|
2127
|
+
(c) => ({ "<": "<", ">": ">", "&": "&", '"': """, "'": "'" })[c]
|
|
2128
|
+
);
|
|
2129
|
+
var unit = (s) => '"' + s.replace(/%/g, "%%").replace(/\\/g, "\\\\").replace(/"/g, '\\"').replace(/\$/g, () => "$$") + '"';
|
|
2130
|
+
function serviceDefinition(directory, executable, entry, platform = process.platform, userHome = homedir2()) {
|
|
2131
|
+
for (const value of [directory, executable, entry])
|
|
2132
|
+
if (/[\r\n\0]/.test(value)) throw new Error("Invalid service path.");
|
|
2133
|
+
const id = `com.teammy.client.${rootKey(directory)}`;
|
|
2134
|
+
const args = [executable, entry, "_daemon", "--state-dir", directory];
|
|
2135
|
+
if (platform === "darwin")
|
|
2136
|
+
return {
|
|
2137
|
+
id,
|
|
2138
|
+
path: join9(userHome, "Library", "LaunchAgents", id + ".plist"),
|
|
2139
|
+
content: `<?xml version="1.0" encoding="UTF-8"?>
|
|
2140
|
+
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
2141
|
+
<plist version="1.0"><dict><key>Label</key><string>${id}</string><key>ProgramArguments</key><array>${args.map((a) => `<string>${xml(a)}</string>`).join("")}</array><key>RunAtLoad</key><true/><key>KeepAlive</key><true/><key>ThrottleInterval</key><integer>30</integer><key>WorkingDirectory</key><string>${xml(userHome)}</string></dict></plist>
|
|
2142
|
+
`
|
|
2143
|
+
};
|
|
2144
|
+
if (platform === "linux")
|
|
2145
|
+
return {
|
|
2146
|
+
id: id + ".service",
|
|
2147
|
+
path: join9(userHome, ".config", "systemd", "user", id + ".service"),
|
|
2148
|
+
content: `[Unit]
|
|
2149
|
+
Description=Teammy background synchronization
|
|
2150
|
+
[Service]
|
|
2151
|
+
Type=simple
|
|
2152
|
+
ExecStart=${args.map(unit).join(" ")}
|
|
2153
|
+
Restart=always
|
|
2154
|
+
RestartSec=30
|
|
2155
|
+
UMask=0077
|
|
2156
|
+
StandardOutput=null
|
|
2157
|
+
StandardError=journal
|
|
2158
|
+
[Install]
|
|
2159
|
+
WantedBy=default.target
|
|
2160
|
+
`
|
|
2161
|
+
};
|
|
2162
|
+
throw new Error(
|
|
2163
|
+
"Automatic background sync currently supports macOS and Linux. Use --no-start with sync --foreground on other systems."
|
|
2164
|
+
);
|
|
2165
|
+
}
|
|
2166
|
+
async function checkServiceSupport() {
|
|
2167
|
+
try {
|
|
2168
|
+
if (process.platform === "darwin")
|
|
2169
|
+
await exec2("launchctl", ["print", `gui/${process.getuid()}`], {
|
|
2170
|
+
maxBuffer: 4 * 1024 * 1024
|
|
2171
|
+
});
|
|
2172
|
+
else if (process.platform === "linux") await exec2("systemctl", ["--user", "show-environment"]);
|
|
2173
|
+
else throw new Error("unsupported");
|
|
2174
|
+
} catch {
|
|
2175
|
+
throw new Error(
|
|
2176
|
+
"A login-session service manager is required (macOS launchd or Linux systemd --user). Use --no-start and sync --foreground where unavailable."
|
|
2177
|
+
);
|
|
2178
|
+
}
|
|
2179
|
+
}
|
|
2180
|
+
async function bootstrapLaunchAgent(path) {
|
|
2181
|
+
const deadline = Date.now() + 1e4;
|
|
2182
|
+
for (; ; ) {
|
|
1931
2183
|
try {
|
|
1932
|
-
|
|
1933
|
-
|
|
1934
|
-
signatures.push([
|
|
1935
|
-
String(s.dev),
|
|
1936
|
-
String(s.ino),
|
|
1937
|
-
String(s.size),
|
|
1938
|
-
String(s.mtimeNs),
|
|
1939
|
-
String(s.ctimeNs)
|
|
1940
|
-
]);
|
|
2184
|
+
await exec2("launchctl", ["bootstrap", `gui/${process.getuid()}`, path]);
|
|
2185
|
+
return;
|
|
1941
2186
|
} catch (error) {
|
|
1942
|
-
if (
|
|
1943
|
-
|
|
1944
|
-
else throw error;
|
|
2187
|
+
if (error.code !== 5 || Date.now() >= deadline) throw error;
|
|
2188
|
+
await setTimeout3(250);
|
|
1945
2189
|
}
|
|
1946
2190
|
}
|
|
1947
|
-
return JSON.stringify(signatures);
|
|
1948
2191
|
}
|
|
1949
|
-
async function
|
|
1950
|
-
const
|
|
1951
|
-
let
|
|
1952
|
-
|
|
1953
|
-
|
|
1954
|
-
|
|
1955
|
-
|
|
2192
|
+
async function ensureDaemon(directory, version) {
|
|
2193
|
+
const definition = serviceDefinition(directory, process.execPath, resolve4(process.argv[1]));
|
|
2194
|
+
let old = "";
|
|
2195
|
+
try {
|
|
2196
|
+
old = await readFile2(definition.path, "utf8");
|
|
2197
|
+
} catch (e) {
|
|
2198
|
+
if (e.code !== "ENOENT") throw e;
|
|
2199
|
+
}
|
|
2200
|
+
const alive = await runningDaemon(directory);
|
|
2201
|
+
if (alive && old === definition.content && alive.version === version) return;
|
|
2202
|
+
await checkServiceSupport();
|
|
2203
|
+
await mkdir7(dirname5(definition.path), { recursive: true });
|
|
2204
|
+
if (old !== definition.content) {
|
|
2205
|
+
const temp = definition.path + `.${process.pid}.tmp`;
|
|
2206
|
+
await writeFile(temp, definition.content, { mode: 384 });
|
|
2207
|
+
await rename3(temp, definition.path);
|
|
2208
|
+
}
|
|
2209
|
+
if (process.platform === "darwin") {
|
|
2210
|
+
const domain = `gui/${process.getuid()}`, target = `${domain}/${definition.id}`;
|
|
2211
|
+
let loaded = false;
|
|
1956
2212
|
try {
|
|
1957
|
-
|
|
1958
|
-
|
|
1959
|
-
|
|
1960
|
-
let offset = 0;
|
|
1961
|
-
while (offset < bytes.length) {
|
|
1962
|
-
const read = await handle.read(bytes, offset, bytes.length - offset, offset);
|
|
1963
|
-
if (!read.bytesRead) break;
|
|
1964
|
-
offset += read.bytesRead;
|
|
1965
|
-
}
|
|
1966
|
-
bytes = bytes.subarray(0, offset);
|
|
1967
|
-
if (offset > size) throw new Error("Session source changed while reading; retry next cycle.");
|
|
1968
|
-
} finally {
|
|
1969
|
-
await handle.close();
|
|
2213
|
+
await exec2("launchctl", ["print", target]);
|
|
2214
|
+
loaded = true;
|
|
2215
|
+
} catch {
|
|
1970
2216
|
}
|
|
1971
|
-
if (
|
|
1972
|
-
|
|
1973
|
-
|
|
1974
|
-
throw new Error(
|
|
1975
|
-
"Compressed DSH sessions require Node.js with zstd support (22.15+ or 24+)."
|
|
1976
|
-
);
|
|
1977
|
-
bytes = decompress(bytes, { maxOutputLength: MAX_SOURCE_BYTES });
|
|
2217
|
+
if (loaded && (old !== definition.content || alive?.version !== version)) {
|
|
2218
|
+
await exec2("launchctl", ["bootout", target]);
|
|
2219
|
+
loaded = false;
|
|
1978
2220
|
}
|
|
1979
|
-
|
|
2221
|
+
if (!loaded) await bootstrapLaunchAgent(definition.path);
|
|
2222
|
+
await exec2("launchctl", ["kickstart", target]);
|
|
2223
|
+
} else {
|
|
2224
|
+
await exec2("systemctl", ["--user", "daemon-reload"]);
|
|
2225
|
+
await exec2("systemctl", ["--user", "enable", definition.id]);
|
|
2226
|
+
await exec2("systemctl", [
|
|
2227
|
+
"--user",
|
|
2228
|
+
old !== definition.content || alive?.version !== version ? "restart" : "start",
|
|
2229
|
+
definition.id
|
|
2230
|
+
]);
|
|
1980
2231
|
}
|
|
1981
|
-
|
|
1982
|
-
|
|
1983
|
-
|
|
1984
|
-
|
|
1985
|
-
|
|
1986
|
-
|
|
1987
|
-
|
|
1988
|
-
|
|
2232
|
+
const deadline = Date.now() + 15e3;
|
|
2233
|
+
while (Date.now() < deadline) {
|
|
2234
|
+
if ((await runningDaemon(directory))?.version === version) return;
|
|
2235
|
+
await setTimeout3(150);
|
|
2236
|
+
}
|
|
2237
|
+
throw new Error(
|
|
2238
|
+
"Background service did not start. Run teammy logs and check the user service manager."
|
|
2239
|
+
);
|
|
2240
|
+
}
|
|
2241
|
+
async function wakeDaemon(directory, version) {
|
|
2242
|
+
await ensureDaemon(directory, version);
|
|
2243
|
+
await daemonRequest(directory, { method: "wake" });
|
|
1989
2244
|
}
|
|
1990
2245
|
|
|
1991
|
-
// apps/client/
|
|
1992
|
-
|
|
1993
|
-
|
|
1994
|
-
|
|
1995
|
-
|
|
1996
|
-
|
|
1997
|
-
|
|
1998
|
-
const dir = await opendir2(current);
|
|
1999
|
-
for await (const entry of dir) {
|
|
2000
|
-
const path = join7(current, entry.name);
|
|
2001
|
-
if (entry.isDirectory()) queue.push(path);
|
|
2002
|
-
if (entry.isFile() && (agent === "cursor" ? entry.name === "store.db" : entry.name.endsWith(".jsonl") || agent === "dsh" && entry.name.endsWith(".jsonl.zstd")))
|
|
2003
|
-
yield path;
|
|
2004
|
-
else yield null;
|
|
2005
|
-
}
|
|
2006
|
-
}
|
|
2246
|
+
// apps/client/output.ts
|
|
2247
|
+
var styled = () => Boolean(
|
|
2248
|
+
process.stdout.isTTY && process.env.NO_COLOR === void 0 && process.env.TERM !== "dumb"
|
|
2249
|
+
);
|
|
2250
|
+
function bold(value) {
|
|
2251
|
+
const clean = redact(value);
|
|
2252
|
+
return styled() ? `\x1B[1m${clean}\x1B[22m` : clean;
|
|
2007
2253
|
}
|
|
2008
|
-
|
|
2009
|
-
|
|
2010
|
-
|
|
2011
|
-
|
|
2012
|
-
|
|
2013
|
-
|
|
2014
|
-
|
|
2015
|
-
|
|
2016
|
-
|
|
2017
|
-
|
|
2018
|
-
|
|
2019
|
-
|
|
2020
|
-
|
|
2021
|
-
|
|
2022
|
-
|
|
2023
|
-
|
|
2254
|
+
function result(title, detail) {
|
|
2255
|
+
process.stdout.write(`${bold(title)}${detail ? `
|
|
2256
|
+
${redact(detail)}` : ""}
|
|
2257
|
+
`);
|
|
2258
|
+
}
|
|
2259
|
+
function json(value) {
|
|
2260
|
+
process.stdout.write(JSON.stringify(value, null, 2) + "\n");
|
|
2261
|
+
}
|
|
2262
|
+
|
|
2263
|
+
// apps/client/client-command.ts
|
|
2264
|
+
var locationOptions = {
|
|
2265
|
+
root: { type: "string", default: resolve5("workspaces") },
|
|
2266
|
+
"state-dir": { type: "string", default: join10(homedir3(), ".teammy", "client") },
|
|
2267
|
+
json: { type: "boolean" },
|
|
2268
|
+
help: { type: "boolean", short: "h" }
|
|
2269
|
+
};
|
|
2270
|
+
async function runClientCommand(command, args, version) {
|
|
2271
|
+
const { values } = parseArgs2({
|
|
2272
|
+
args,
|
|
2273
|
+
options: {
|
|
2274
|
+
...locationOptions,
|
|
2275
|
+
url: { type: "string" },
|
|
2276
|
+
project: { type: "string" },
|
|
2277
|
+
member: { type: "string" },
|
|
2278
|
+
"token-file": { type: "string" },
|
|
2279
|
+
reconnect: { type: "boolean" },
|
|
2280
|
+
"no-start": { type: "boolean" },
|
|
2281
|
+
all: { type: "boolean" }
|
|
2024
2282
|
}
|
|
2025
|
-
|
|
2026
|
-
|
|
2027
|
-
|
|
2028
|
-
|
|
2029
|
-
|
|
2283
|
+
});
|
|
2284
|
+
if (values.help) {
|
|
2285
|
+
process.stdout.write(
|
|
2286
|
+
command === "connect" ? "Usage: teammy connect --url URL --project ID --member ID [--root DIR]\n\nCreates ./workspaces, adds Git ignore rules and starts login-session background sync.\nAdvanced: --no-start, --reconnect (stop sync first), --token-file FILE, --state-dir DIR, --json\n" : `Usage: teammy ${command} [--root DIR] [--state-dir DIR] [--json]${command === "logs" ? " [--all]" : ""}
|
|
2287
|
+
`
|
|
2288
|
+
);
|
|
2289
|
+
return 0;
|
|
2290
|
+
}
|
|
2291
|
+
const root = await workspacePath(values.root), directory = await stateLocation(root, values["state-dir"]);
|
|
2292
|
+
const state = new ClientState(directory), log = new ClientLog(directory), runtime = new RuntimeState(state);
|
|
2293
|
+
try {
|
|
2294
|
+
if (command === "logs") {
|
|
2295
|
+
const entries = log.tail(values.all ? void 0 : root);
|
|
2296
|
+
if (values.json) json(entries);
|
|
2297
|
+
else if (!entries.length) result("\u6CA1\u6709\u8BCA\u65AD\u8BB0\u5F55");
|
|
2298
|
+
else
|
|
2299
|
+
for (const item of entries)
|
|
2300
|
+
process.stdout.write(
|
|
2301
|
+
`${item.at} ${item.event}${item.detail ? " " + item.detail : ""}
|
|
2302
|
+
`
|
|
2303
|
+
);
|
|
2304
|
+
return 0;
|
|
2305
|
+
}
|
|
2306
|
+
if (command === "status") {
|
|
2307
|
+
const binding = state.binding(root);
|
|
2308
|
+
if (!binding) throw new Error("This workspace is not connected. Run teammy connect first.");
|
|
2309
|
+
const daemon = await runningDaemon(directory);
|
|
2310
|
+
const current = daemon ? await daemonRequest(directory, { method: "status", root }) : { enabled: runtime.enabled(root), running: false, status: runtime.status(root) };
|
|
2311
|
+
const { token: _, ...publicBinding } = binding;
|
|
2312
|
+
if (values.json)
|
|
2313
|
+
json({
|
|
2314
|
+
...current,
|
|
2315
|
+
binding: publicBinding,
|
|
2316
|
+
control: readControlStatus(state, root),
|
|
2317
|
+
daemon,
|
|
2318
|
+
logPath: log.path
|
|
2319
|
+
});
|
|
2320
|
+
else {
|
|
2321
|
+
const status = current.status;
|
|
2322
|
+
const title = !current.enabled ? "\u540C\u6B65\u5DF2\u505C\u6B62" : !current.running ? "\u540E\u53F0\u540C\u6B65\u672A\u8FD0\u884C" : status?.issue ? "\u540C\u6B65\u7B49\u5F85\u5904\u7406" : status?.pendingDownloads || !status?.lastSuccessAt ? "\u9996\u6B21\u540C\u6B65\u4E2D" : "\u540C\u6B65\u5DF2\u5F00\u542F";
|
|
2323
|
+
result(
|
|
2324
|
+
title,
|
|
2325
|
+
new ClientProjectState(state, root).cached()?.config.projectName ?? binding.workspaceId
|
|
2326
|
+
);
|
|
2327
|
+
if (status?.issue) result("\u5F85\u5904\u7406", status.issue);
|
|
2328
|
+
else if (status?.lastSuccessAt)
|
|
2329
|
+
process.stdout.write(` \u6700\u8FD1\u6210\u529F\uFF1A${status.lastSuccessAt}
|
|
2330
|
+
`);
|
|
2331
|
+
if (status?.pendingFiles || status?.pendingDownloads)
|
|
2332
|
+
process.stdout.write(
|
|
2333
|
+
` \u5F85\u4E0A\u4F20 ${status.pendingFiles} \xB7 \u5F85\u4E0B\u8F7D ${status.pendingDownloads}
|
|
2334
|
+
`
|
|
2335
|
+
);
|
|
2030
2336
|
}
|
|
2031
|
-
|
|
2337
|
+
return 0;
|
|
2338
|
+
}
|
|
2339
|
+
const existing = state.binding(root);
|
|
2340
|
+
const url = values.url ?? (values.reconnect ? existing?.serverUrl : void 0);
|
|
2341
|
+
if (!url) throw new Error("--url is required.");
|
|
2342
|
+
const transport = new HubClient(url);
|
|
2343
|
+
const selectedProject = values.project ?? (values.reconnect ? existing?.workspaceId : void 0);
|
|
2344
|
+
const project = selectedProject === void 0 && values["token-file"] ? void 0 : NameSchema.parse(selectedProject);
|
|
2345
|
+
let member = values.member ?? (values.reconnect ? existing?.memberId : void 0);
|
|
2346
|
+
if (!member && !values["token-file"] && process.stdin.isTTY) {
|
|
2347
|
+
const rl = createInterface2({ input: process.stdin, output: process.stderr });
|
|
2032
2348
|
try {
|
|
2033
|
-
|
|
2034
|
-
|
|
2035
|
-
|
|
2036
|
-
if (entry.done) {
|
|
2037
|
-
scan.iterator = candidates(config.directory, config.agent);
|
|
2038
|
-
scan.nextAt = Date.now() + 3e5;
|
|
2039
|
-
break;
|
|
2040
|
-
}
|
|
2041
|
-
const path = entry.value;
|
|
2042
|
-
if (!path || this.state.get(path)) continue;
|
|
2043
|
-
const signature2 = await sourceSignature(path, config.agent);
|
|
2044
|
-
const db = this.state.state.db;
|
|
2045
|
-
if (db.prepare(
|
|
2046
|
-
"SELECT 1 FROM session_discovery_cache WHERE root=? AND path=? AND signature=?"
|
|
2047
|
-
).get(this.state.root, path, signature2))
|
|
2048
|
-
continue;
|
|
2049
|
-
reads--;
|
|
2050
|
-
try {
|
|
2051
|
-
const result = await readSource(config.agent, path, config.projectDir);
|
|
2052
|
-
if (result.incomplete) continue;
|
|
2053
|
-
this.state.track(config.agent, path, config.projectDir);
|
|
2054
|
-
} catch (error) {
|
|
2055
|
-
const message2 = error instanceof Error ? error.message : "Discovery failed.";
|
|
2056
|
-
if (!message2.includes("outside the registered project"))
|
|
2057
|
-
issues.push(`${path}: ${message2}`);
|
|
2058
|
-
if (!message2.includes("outside the registered project")) continue;
|
|
2059
|
-
}
|
|
2060
|
-
db.prepare(
|
|
2061
|
-
"INSERT INTO session_discovery_cache VALUES (?,?,?) ON CONFLICT(root,path) DO UPDATE SET signature=excluded.signature"
|
|
2062
|
-
).run(this.state.root, path, signature2);
|
|
2063
|
-
}
|
|
2064
|
-
} catch (error) {
|
|
2065
|
-
issues.push(
|
|
2066
|
-
`${config.directory}: ${error instanceof Error ? error.message : "Discovery failed."}`
|
|
2067
|
-
);
|
|
2068
|
-
await scan.iterator.return(void 0);
|
|
2069
|
-
scan.iterator = candidates(config.directory, config.agent);
|
|
2070
|
-
scan.nextAt = Date.now() + 3e5;
|
|
2349
|
+
member = (await rl.question("Member ID: ")).trim();
|
|
2350
|
+
} finally {
|
|
2351
|
+
rl.close();
|
|
2071
2352
|
}
|
|
2072
2353
|
}
|
|
2073
|
-
|
|
2074
|
-
|
|
2075
|
-
|
|
2076
|
-
|
|
2354
|
+
if (!values["token-file"]) member = NameSchema.parse(member);
|
|
2355
|
+
await prepareWorkspace(
|
|
2356
|
+
state,
|
|
2357
|
+
root,
|
|
2358
|
+
{ url: transport.url, project: project ?? "", member: member ?? "" },
|
|
2359
|
+
Boolean(values.reconnect)
|
|
2360
|
+
);
|
|
2361
|
+
if (!values["no-start"]) await checkServiceSupport();
|
|
2362
|
+
const connected = await connectProject(state, root, directory, {
|
|
2363
|
+
url: transport.url,
|
|
2364
|
+
project,
|
|
2365
|
+
member,
|
|
2366
|
+
tokenFile: values["token-file"],
|
|
2367
|
+
reconnect: values.reconnect
|
|
2368
|
+
});
|
|
2369
|
+
completePreparation(state, root);
|
|
2370
|
+
runtime.enable(root, !values["no-start"]);
|
|
2371
|
+
log.write("workspace.connected", root);
|
|
2372
|
+
if (!values["no-start"]) await wakeDaemon(directory, version);
|
|
2373
|
+
if (values.json) json({ ...connected, syncEnabled: !values["no-start"] });
|
|
2374
|
+
else result(values["no-start"] ? "\u9879\u76EE\u5DF2\u8FDE\u63A5" : "\u5DF2\u8FDE\u63A5\uFF0C\u540E\u53F0\u540C\u6B65\u5DF2\u542F\u52A8", root);
|
|
2375
|
+
return 0;
|
|
2376
|
+
} catch (e) {
|
|
2377
|
+
log.write(`${command}.failed`, root, diagnostic(e));
|
|
2378
|
+
throw e;
|
|
2379
|
+
} finally {
|
|
2380
|
+
state.close();
|
|
2077
2381
|
}
|
|
2078
|
-
}
|
|
2382
|
+
}
|
|
2079
2383
|
|
|
2080
|
-
// apps/client/
|
|
2081
|
-
|
|
2082
|
-
|
|
2083
|
-
|
|
2084
|
-
|
|
2085
|
-
|
|
2086
|
-
|
|
2087
|
-
|
|
2088
|
-
|
|
2089
|
-
|
|
2090
|
-
|
|
2384
|
+
// apps/client/files/synchronizer.ts
|
|
2385
|
+
import { randomUUID as randomUUID6 } from "node:crypto";
|
|
2386
|
+
import { watch } from "node:fs";
|
|
2387
|
+
import { lstat as lstat4, mkdir as mkdir9 } from "node:fs/promises";
|
|
2388
|
+
import { join as join12, sep as sep4 } from "node:path";
|
|
2389
|
+
import { performance } from "node:perf_hooks";
|
|
2390
|
+
|
|
2391
|
+
// packages/storage/local-blob-store.ts
|
|
2392
|
+
import { createHash as createHash8, randomUUID as randomUUID5 } from "node:crypto";
|
|
2393
|
+
import { link, mkdir as mkdir8, open as open3, readFile as readFile3, unlink as unlink3 } from "node:fs/promises";
|
|
2394
|
+
import { dirname as dirname6, join as join11, resolve as resolve6 } from "node:path";
|
|
2395
|
+
function hash(content) {
|
|
2396
|
+
return createHash8("sha256").update(content).digest("hex");
|
|
2397
|
+
}
|
|
2398
|
+
function hasCode(error, code) {
|
|
2399
|
+
return error instanceof Error && "code" in error && error.code === code;
|
|
2400
|
+
}
|
|
2401
|
+
var BlobIntegrityError = class extends Error {
|
|
2402
|
+
constructor(sha256) {
|
|
2403
|
+
super(`Stored content failed integrity verification: ${sha256}`);
|
|
2404
|
+
this.sha256 = sha256;
|
|
2405
|
+
this.name = "BlobIntegrityError";
|
|
2091
2406
|
}
|
|
2407
|
+
sha256;
|
|
2408
|
+
};
|
|
2409
|
+
var LocalBlobStore = class {
|
|
2092
2410
|
root;
|
|
2093
|
-
|
|
2094
|
-
|
|
2095
|
-
|
|
2096
|
-
|
|
2097
|
-
|
|
2098
|
-
|
|
2099
|
-
|
|
2100
|
-
|
|
2101
|
-
|
|
2102
|
-
|
|
2103
|
-
|
|
2104
|
-
if (sources.length && !this.capable) {
|
|
2105
|
-
const info = await this.client.serverInfo();
|
|
2106
|
-
if (!["blobs", "session-revisions"].every(
|
|
2107
|
-
(capability) => info.capabilities.includes(capability)
|
|
2108
|
-
))
|
|
2109
|
-
throw new Error("Server does not support session publication. Upgrade the server first.");
|
|
2110
|
-
this.capable = true;
|
|
2111
|
-
}
|
|
2112
|
-
let reads = 5;
|
|
2113
|
-
let uploads = 5;
|
|
2114
|
-
for (const source of sources) {
|
|
2115
|
-
if (reads <= 0 || uploads <= 0 || report.checked >= 200) break;
|
|
2116
|
-
report.checked++;
|
|
2117
|
-
this.index.checked(source.path, source.lastError);
|
|
2411
|
+
constructor(root) {
|
|
2412
|
+
this.root = resolve6(root);
|
|
2413
|
+
}
|
|
2414
|
+
async put(content) {
|
|
2415
|
+
const bytes = Buffer.from(content);
|
|
2416
|
+
const sha256 = hash(bytes);
|
|
2417
|
+
const target = this.pathFor(sha256);
|
|
2418
|
+
await mkdir8(dirname6(target), { recursive: true, mode: 448 });
|
|
2419
|
+
const temporary = `${target}.${randomUUID5()}.tmp`;
|
|
2420
|
+
const file = await open3(temporary, "wx", 384);
|
|
2421
|
+
try {
|
|
2118
2422
|
try {
|
|
2119
|
-
|
|
2120
|
-
|
|
2121
|
-
|
|
2122
|
-
|
|
2123
|
-
|
|
2124
|
-
|
|
2125
|
-
|
|
2126
|
-
|
|
2127
|
-
|
|
2128
|
-
|
|
2129
|
-
|
|
2130
|
-
|
|
2131
|
-
|
|
2132
|
-
|
|
2133
|
-
|
|
2134
|
-
}
|
|
2135
|
-
reads--;
|
|
2136
|
-
const observed = await readSource(source.agent, source.path, source.projectDir);
|
|
2137
|
-
if (observed.incomplete)
|
|
2138
|
-
throw new Error("Final session record is incomplete; waiting for the next save.");
|
|
2139
|
-
if (!observed.snapshot.messages.length) {
|
|
2140
|
-
this.index.checked(source.path);
|
|
2141
|
-
continue;
|
|
2142
|
-
}
|
|
2143
|
-
const key = sessionKey(source.agent, observed.snapshot.nativeId);
|
|
2144
|
-
if (source.key && source.key !== key)
|
|
2145
|
-
throw new Error(
|
|
2146
|
-
"Native session identity changed at this path; register the new source explicitly."
|
|
2147
|
-
);
|
|
2148
|
-
const bytes = Buffer.from(JSON.stringify(observed.snapshot));
|
|
2149
|
-
if (bytes.length > MAX_BLOB_BYTES)
|
|
2150
|
-
throw new Error("Normalized session exceeds the 10 MiB snapshot limit.");
|
|
2151
|
-
const blob = await this.blobs.put(bytes);
|
|
2152
|
-
let revision = source.revision;
|
|
2153
|
-
if (!source.key) {
|
|
2423
|
+
await file.writeFile(bytes);
|
|
2424
|
+
await file.sync();
|
|
2425
|
+
} finally {
|
|
2426
|
+
await file.close();
|
|
2427
|
+
}
|
|
2428
|
+
try {
|
|
2429
|
+
await link(temporary, target);
|
|
2430
|
+
} catch (error) {
|
|
2431
|
+
if (!hasCode(error, "EEXIST")) throw error;
|
|
2432
|
+
await this.get(sha256);
|
|
2433
|
+
}
|
|
2434
|
+
if (process.platform !== "win32") {
|
|
2435
|
+
let directory = dirname6(target);
|
|
2436
|
+
for (; ; ) {
|
|
2437
|
+
const handle = await open3(directory, "r");
|
|
2154
2438
|
try {
|
|
2155
|
-
|
|
2156
|
-
|
|
2157
|
-
|
|
2158
|
-
throw new Error(
|
|
2159
|
-
"Server session already exists with different content; inspect it, then track with --adopt-existing to continue."
|
|
2160
|
-
);
|
|
2161
|
-
revision = current.revision;
|
|
2162
|
-
} catch (error) {
|
|
2163
|
-
if (!(error instanceof HubRequestError && error.code === "SESSION_NOT_FOUND"))
|
|
2164
|
-
throw error;
|
|
2439
|
+
await handle.sync();
|
|
2440
|
+
} finally {
|
|
2441
|
+
await handle.close();
|
|
2165
2442
|
}
|
|
2166
|
-
|
|
2167
|
-
|
|
2168
|
-
if (blob.sha256 === source.hash) {
|
|
2169
|
-
this.index.acknowledge(source.path, revision, blob.sha256, observed.signature);
|
|
2170
|
-
continue;
|
|
2443
|
+
if (directory === dirname6(this.root)) break;
|
|
2444
|
+
directory = dirname6(directory);
|
|
2171
2445
|
}
|
|
2172
|
-
this.index.stage(
|
|
2173
|
-
source.path,
|
|
2174
|
-
{ operationId: randomUUID6(), sessionKey: key, parentRevision: revision, blob },
|
|
2175
|
-
observed.signature
|
|
2176
|
-
);
|
|
2177
|
-
uploads--;
|
|
2178
|
-
await this.flush(this.index.get(source.path));
|
|
2179
|
-
report.published++;
|
|
2180
|
-
} catch (error) {
|
|
2181
|
-
const message2 = error instanceof Error ? error.message : "Session synchronization failed.";
|
|
2182
|
-
this.index.checked(source.path, message2);
|
|
2183
|
-
report.issues.push(`${source.path}: ${message2}`);
|
|
2184
2446
|
}
|
|
2447
|
+
} finally {
|
|
2448
|
+
await unlink3(temporary);
|
|
2185
2449
|
}
|
|
2186
|
-
return
|
|
2450
|
+
return { sha256, size: bytes.byteLength };
|
|
2187
2451
|
}
|
|
2188
|
-
async
|
|
2189
|
-
const
|
|
2452
|
+
async get(sha256) {
|
|
2453
|
+
const target = this.pathFor(sha256);
|
|
2454
|
+
let content;
|
|
2190
2455
|
try {
|
|
2191
|
-
|
|
2192
|
-
if (missing2.some((hash2) => hash2 !== request.blob.sha256))
|
|
2193
|
-
throw new Error("Server requested unrelated session content.");
|
|
2194
|
-
if (missing2.length) {
|
|
2195
|
-
const bytes = await this.blobs.get(request.blob.sha256);
|
|
2196
|
-
if (!bytes) throw new Error("Queued session snapshot is missing.");
|
|
2197
|
-
const ref = await this.client.uploadBlob(request.blob.sha256, bytes);
|
|
2198
|
-
if (ref.sha256 !== request.blob.sha256 || ref.size !== request.blob.size)
|
|
2199
|
-
throw new Error("Server acknowledged different session content.");
|
|
2200
|
-
}
|
|
2201
|
-
const result = await this.client.commitSession(request);
|
|
2202
|
-
if (result.operationId !== request.operationId || result.revision < request.parentRevision || result.revision > request.parentRevision + 1)
|
|
2203
|
-
throw new Error("Invalid session acknowledgement.");
|
|
2204
|
-
this.index.acknowledge(
|
|
2205
|
-
source.path,
|
|
2206
|
-
result.revision,
|
|
2207
|
-
request.blob.sha256,
|
|
2208
|
-
source.pendingSignature
|
|
2209
|
-
);
|
|
2456
|
+
content = await readFile3(target);
|
|
2210
2457
|
} catch (error) {
|
|
2211
|
-
|
|
2212
|
-
this.index.postpone(
|
|
2213
|
-
source,
|
|
2214
|
-
error instanceof Error ? error.message : "Upload failed.",
|
|
2215
|
-
blocked
|
|
2216
|
-
);
|
|
2458
|
+
if (hasCode(error, "ENOENT")) return void 0;
|
|
2217
2459
|
throw error;
|
|
2218
2460
|
}
|
|
2461
|
+
if (hash(content) !== sha256) throw new BlobIntegrityError(sha256);
|
|
2462
|
+
return content;
|
|
2219
2463
|
}
|
|
2220
|
-
|
|
2221
|
-
|
|
2464
|
+
pathFor(sha256) {
|
|
2465
|
+
if (!/^[a-f0-9]{64}$/.test(sha256)) throw new Error("Invalid SHA-256 content identifier.");
|
|
2466
|
+
return join11(this.root, "sha256", sha256.slice(0, 2), sha256);
|
|
2222
2467
|
}
|
|
2223
2468
|
};
|
|
2224
2469
|
|
|
2225
|
-
// apps/client/files/synchronizer.ts
|
|
2226
|
-
import { randomUUID as randomUUID7 } from "node:crypto";
|
|
2227
|
-
import { watch } from "node:fs";
|
|
2228
|
-
import { lstat as lstat2, mkdir as mkdir6 } from "node:fs/promises";
|
|
2229
|
-
import { join as join9, sep as sep4 } from "node:path";
|
|
2230
|
-
import { performance } from "node:perf_hooks";
|
|
2231
|
-
|
|
2232
2470
|
// apps/client/publication.ts
|
|
2233
2471
|
var PublicationQueue = class {
|
|
2234
2472
|
constructor(state, blobs, transport) {
|
|
@@ -2255,8 +2493,8 @@ var PublicationQueue = class {
|
|
|
2255
2493
|
if (!hashes.includes(hash2)) throw new Error("Server requested unrelated content.");
|
|
2256
2494
|
const content = await this.blobs.get(hash2);
|
|
2257
2495
|
if (!content) throw new Error("A queued content snapshot is missing from local storage.");
|
|
2258
|
-
const
|
|
2259
|
-
if (
|
|
2496
|
+
const result2 = await this.transport.uploadBlob(hash2, content);
|
|
2497
|
+
if (result2.sha256 !== hash2 || result2.size !== content.byteLength)
|
|
2260
2498
|
throw new Error("Server acknowledged different content.");
|
|
2261
2499
|
}
|
|
2262
2500
|
this.state.acknowledge(root, await this.transport.commitFiles(pending.batch));
|
|
@@ -2285,10 +2523,10 @@ var FileSynchronizer = class {
|
|
|
2285
2523
|
if (!binding) throw new Error("Connect this root before synchronizing.");
|
|
2286
2524
|
this.binding = binding;
|
|
2287
2525
|
this.client = new HubClient(binding.serverUrl, 3e4, binding, this.abort.signal);
|
|
2288
|
-
this.blobs = new LocalBlobStore(
|
|
2526
|
+
this.blobs = new LocalBlobStore(join12(stateDirectory, "blobs"));
|
|
2289
2527
|
this.queue = new PublicationQueue(state, this.blobs, this.client);
|
|
2290
2528
|
this.index = new SyncState(state, root);
|
|
2291
|
-
this.ownRoot =
|
|
2529
|
+
this.ownRoot = join12(root, binding.memberId);
|
|
2292
2530
|
const started = performance.now();
|
|
2293
2531
|
const epoch = Date.now();
|
|
2294
2532
|
this.clock = options.clock ?? (() => epoch + performance.now() - started);
|
|
@@ -2344,7 +2582,7 @@ var FileSynchronizer = class {
|
|
|
2344
2582
|
try {
|
|
2345
2583
|
try {
|
|
2346
2584
|
await this.initializeAuthor();
|
|
2347
|
-
const ownStat = await
|
|
2585
|
+
const ownStat = await lstat4(await safePath(this.root, this.binding.memberId));
|
|
2348
2586
|
if (!ownStat.isDirectory() || ownStat.isSymbolicLink())
|
|
2349
2587
|
throw new Error("Author directory is unavailable or is a symlink.");
|
|
2350
2588
|
this.listen();
|
|
@@ -2403,11 +2641,7 @@ var FileSynchronizer = class {
|
|
|
2403
2641
|
async initializeAuthor() {
|
|
2404
2642
|
if (this.state.publication(this.root).initialized) return;
|
|
2405
2643
|
const manifest = await this.manifest(this.binding.memberId);
|
|
2406
|
-
if (manifest.revision > 0) {
|
|
2407
|
-
if (!this.options.adoptBaseline)
|
|
2408
|
-
throw new Error(
|
|
2409
|
-
"Published history exists but the local baseline is missing. Verify local files, then use --adopt-baseline."
|
|
2410
|
-
);
|
|
2644
|
+
if (manifest.revision > 0 && this.options.adoptBaseline) {
|
|
2411
2645
|
for (const file of manifest.files) {
|
|
2412
2646
|
const local = await readStable(this.ownRoot, file.path);
|
|
2413
2647
|
if (local.sha256 !== file.sha256)
|
|
@@ -2415,12 +2649,11 @@ var FileSynchronizer = class {
|
|
|
2415
2649
|
`Cannot adopt baseline: local ${file.path} differs from the published file.`
|
|
2416
2650
|
);
|
|
2417
2651
|
}
|
|
2418
|
-
} else {
|
|
2419
|
-
const ownerPath = await safePath(this.root, this.binding.memberId);
|
|
2420
|
-
await mkdir6(ownerPath).catch((error) => {
|
|
2421
|
-
if (error.code !== "EEXIST") throw error;
|
|
2422
|
-
});
|
|
2423
2652
|
}
|
|
2653
|
+
const ownerPath = await safePath(this.root, this.binding.memberId);
|
|
2654
|
+
await mkdir9(ownerPath).catch((error) => {
|
|
2655
|
+
if (error.code !== "EEXIST") throw error;
|
|
2656
|
+
});
|
|
2424
2657
|
this.state.initializePublication(this.root, manifest.revision, manifest.files);
|
|
2425
2658
|
}
|
|
2426
2659
|
listen() {
|
|
@@ -2512,7 +2745,7 @@ var FileSynchronizer = class {
|
|
|
2512
2745
|
this.index.event(row.path, this.clock());
|
|
2513
2746
|
continue;
|
|
2514
2747
|
}
|
|
2515
|
-
if (baseline.has(row.path)) {
|
|
2748
|
+
if (baseline.has(row.path) && row.seenLocal) {
|
|
2516
2749
|
changes.push({ kind: "delete", path: row.path });
|
|
2517
2750
|
planned.delete(row.path);
|
|
2518
2751
|
} else {
|
|
@@ -2526,7 +2759,12 @@ var FileSynchronizer = class {
|
|
|
2526
2759
|
const collision = [...planned.keys()].some(
|
|
2527
2760
|
(path) => path !== row.path && (path.toLowerCase() === key || path.toLowerCase().startsWith(`${key}/`) || key.startsWith(`${path.toLowerCase()}/`))
|
|
2528
2761
|
);
|
|
2529
|
-
if (collision)
|
|
2762
|
+
if (collision) {
|
|
2763
|
+
issues.push(
|
|
2764
|
+
`${row.path}: conflicts with a preserved server path; rename the local file to publish it.`
|
|
2765
|
+
);
|
|
2766
|
+
continue;
|
|
2767
|
+
}
|
|
2530
2768
|
const local = await readStable(this.ownRoot, row.path);
|
|
2531
2769
|
bytes += local.bytes.length;
|
|
2532
2770
|
if (local.signature !== row.signature || this.index.get(row.path)?.generation !== row.generation) {
|
|
@@ -2537,245 +2775,1027 @@ var FileSynchronizer = class {
|
|
|
2537
2775
|
this.index.clean(row.path, row.generation, local.sha256, this.clock());
|
|
2538
2776
|
continue;
|
|
2539
2777
|
}
|
|
2540
|
-
const ref = await this.blobs.put(local.bytes);
|
|
2541
|
-
changes.push({ kind: "upsert", path: row.path, ...ref });
|
|
2542
|
-
planned.set(row.path, ref);
|
|
2543
|
-
snapshots.push({ path: row.path, generation: row.generation, hash: local.sha256 });
|
|
2778
|
+
const ref = await this.blobs.put(local.bytes);
|
|
2779
|
+
changes.push({ kind: "upsert", path: row.path, ...ref });
|
|
2780
|
+
planned.set(row.path, ref);
|
|
2781
|
+
snapshots.push({ path: row.path, generation: row.generation, hash: local.sha256 });
|
|
2782
|
+
} catch (error) {
|
|
2783
|
+
this.index.event(row.path, this.clock());
|
|
2784
|
+
issues.push(`${row.path}: ${message(error)}`);
|
|
2785
|
+
}
|
|
2786
|
+
}
|
|
2787
|
+
if (!changes.length) return;
|
|
2788
|
+
const rootStat = await lstat4(this.ownRoot);
|
|
2789
|
+
if (!rootStat.isDirectory() || rootStat.isSymbolicLink())
|
|
2790
|
+
throw new Error("Author directory disappeared before publication.");
|
|
2791
|
+
this.state.enqueue(this.root, {
|
|
2792
|
+
operationId: randomUUID6(),
|
|
2793
|
+
parentRevision: this.state.publication(this.root).revision,
|
|
2794
|
+
changes
|
|
2795
|
+
});
|
|
2796
|
+
for (const snapshot of snapshots)
|
|
2797
|
+
this.index.clean(snapshot.path, snapshot.generation, snapshot.hash, this.clock());
|
|
2798
|
+
}
|
|
2799
|
+
async receive(owner) {
|
|
2800
|
+
if (!this.index.job(owner)) {
|
|
2801
|
+
const revision = this.index.revision(owner);
|
|
2802
|
+
if (revision === void 0) {
|
|
2803
|
+
const manifest = await this.manifest(owner);
|
|
2804
|
+
this.index.queue(
|
|
2805
|
+
owner,
|
|
2806
|
+
manifest.revision,
|
|
2807
|
+
manifest.files.map((file) => ({
|
|
2808
|
+
kind: "upsert",
|
|
2809
|
+
path: file.path,
|
|
2810
|
+
sha256: file.sha256,
|
|
2811
|
+
size: file.size
|
|
2812
|
+
}))
|
|
2813
|
+
);
|
|
2814
|
+
} else {
|
|
2815
|
+
const delta = await this.client.fileChanges(owner, revision);
|
|
2816
|
+
if (!delta.revisions.length) return 0;
|
|
2817
|
+
const changes = /* @__PURE__ */ new Map();
|
|
2818
|
+
let expected = revision + 1;
|
|
2819
|
+
for (const entry of delta.revisions) {
|
|
2820
|
+
if (entry.revision !== expected++) throw new Error("Server change history has a gap.");
|
|
2821
|
+
for (const change of entry.changes) changes.set(change.path, change);
|
|
2822
|
+
}
|
|
2823
|
+
const finalNames = new Set(
|
|
2824
|
+
[...changes.values()].filter((change) => change.kind === "upsert").map((change) => change.path.toLowerCase())
|
|
2825
|
+
);
|
|
2826
|
+
for (const change of changes.values()) {
|
|
2827
|
+
if (change.kind === "delete" && !this.index.received(owner, change.path) && finalNames.has(change.path.toLowerCase()))
|
|
2828
|
+
changes.delete(change.path);
|
|
2829
|
+
}
|
|
2830
|
+
const ordered = [...changes.values()].sort(
|
|
2831
|
+
(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
|
|
2832
|
+
);
|
|
2833
|
+
this.index.queue(owner, delta.revisions.at(-1).revision, ordered);
|
|
2834
|
+
}
|
|
2835
|
+
}
|
|
2836
|
+
const job = this.index.job(owner);
|
|
2837
|
+
const ownerRoot = await safePath(this.root, owner);
|
|
2838
|
+
await mkdir9(ownerRoot).catch((error) => {
|
|
2839
|
+
if (error.code !== "EEXIST") throw error;
|
|
2840
|
+
});
|
|
2841
|
+
const ownerStat = await lstat4(ownerRoot);
|
|
2842
|
+
if (!ownerStat.isDirectory() || ownerStat.isSymbolicLink())
|
|
2843
|
+
throw new Error("Mirror root is not an ordinary directory.");
|
|
2844
|
+
let count = 0;
|
|
2845
|
+
let bytes = 0;
|
|
2846
|
+
for (let offset = job.nextIndex; offset < job.changes.length; offset++) {
|
|
2847
|
+
if (this.stopped || count >= (this.options.applyBudget ?? 200) || bytes > 20 * 1024 * 1024)
|
|
2848
|
+
break;
|
|
2849
|
+
const change = job.changes[offset];
|
|
2850
|
+
await this.apply(owner, ownerRoot, change);
|
|
2851
|
+
this.index.applied(owner, change, offset + 1);
|
|
2852
|
+
count++;
|
|
2853
|
+
if (change.kind === "upsert") bytes += change.size;
|
|
2854
|
+
}
|
|
2855
|
+
if (this.index.job(owner).nextIndex === job.changes.length) this.index.finish(owner);
|
|
2856
|
+
return count;
|
|
2857
|
+
}
|
|
2858
|
+
async apply(owner, root, change) {
|
|
2859
|
+
const prior = this.index.received(owner, change.path);
|
|
2860
|
+
const current = await metadata(root, change.path);
|
|
2861
|
+
let local;
|
|
2862
|
+
if (current && !current.directory) local = await readStable(root, change.path);
|
|
2863
|
+
if (change.kind === "delete") {
|
|
2864
|
+
if (!current) return;
|
|
2865
|
+
if (current.directory && !prior) return;
|
|
2866
|
+
if (!local || !prior || local.sha256 !== prior.sha256)
|
|
2867
|
+
throw new Error(`Local mirror differs; preserving ${change.path}.`);
|
|
2868
|
+
await removeFile(root, change.path, local.signature);
|
|
2869
|
+
} else {
|
|
2870
|
+
if (local?.sha256 === change.sha256) return;
|
|
2871
|
+
if (local && (!prior || local.sha256 !== prior.sha256))
|
|
2872
|
+
throw new Error(`Local mirror differs; preserving ${change.path}.`);
|
|
2873
|
+
if (!current && prior && !this.options.restoreMissing)
|
|
2874
|
+
throw new Error(
|
|
2875
|
+
`Local mirror was removed; use --restore-missing to restore ${change.path}.`
|
|
2876
|
+
);
|
|
2877
|
+
let content = await this.blobs.get(change.sha256);
|
|
2878
|
+
if (!content) {
|
|
2879
|
+
content = await this.client.downloadBlob(change.sha256);
|
|
2880
|
+
await this.blobs.put(content);
|
|
2881
|
+
}
|
|
2882
|
+
if (content.byteLength !== change.size)
|
|
2883
|
+
throw new Error(`Content size mismatch: ${change.path}`);
|
|
2884
|
+
await replaceFile(root, change.path, content, local?.signature ?? null);
|
|
2885
|
+
}
|
|
2886
|
+
}
|
|
2887
|
+
};
|
|
2888
|
+
function message(error) {
|
|
2889
|
+
return diagnostic(error);
|
|
2890
|
+
}
|
|
2891
|
+
|
|
2892
|
+
// apps/client/sessions/synchronizer.ts
|
|
2893
|
+
import { randomUUID as randomUUID7 } from "node:crypto";
|
|
2894
|
+
import { join as join14 } from "node:path";
|
|
2895
|
+
|
|
2896
|
+
// packages/protocol/session-key.ts
|
|
2897
|
+
import { createHash as createHash9 } from "node:crypto";
|
|
2898
|
+
function sessionKey(agent, nativeId) {
|
|
2899
|
+
return createHash9("sha256").update(JSON.stringify([agent, nativeId])).digest("hex");
|
|
2900
|
+
}
|
|
2901
|
+
|
|
2902
|
+
// apps/client/sessions/source.ts
|
|
2903
|
+
import { open as open4, stat, realpath as realpath5 } from "node:fs/promises";
|
|
2904
|
+
import { relative as relative4, isAbsolute as isAbsolute4, sep as sep5 } from "node:path";
|
|
2905
|
+
import * as zlib from "node:zlib";
|
|
2906
|
+
|
|
2907
|
+
// apps/client/sessions/cursor.ts
|
|
2908
|
+
import { createHash as createHash10 } from "node:crypto";
|
|
2909
|
+
import { basename as basename2, dirname as dirname7, resolve as resolve7 } from "node:path";
|
|
2910
|
+
import { DatabaseSync as DatabaseSync2 } from "node:sqlite";
|
|
2911
|
+
import { fileURLToPath } from "node:url";
|
|
2912
|
+
function fields(bytes) {
|
|
2913
|
+
const data = Buffer.from(bytes);
|
|
2914
|
+
let offset = 0;
|
|
2915
|
+
const varint = () => {
|
|
2916
|
+
let value = 0n;
|
|
2917
|
+
for (let i = 0; i < 10; i++) {
|
|
2918
|
+
if (offset >= data.length) throw new Error("Truncated Cursor protobuf.");
|
|
2919
|
+
const byte = data[offset++];
|
|
2920
|
+
value |= BigInt(byte & 127) << BigInt(i * 7);
|
|
2921
|
+
if (byte < 128) return value;
|
|
2922
|
+
}
|
|
2923
|
+
throw new Error("Invalid Cursor protobuf integer.");
|
|
2924
|
+
};
|
|
2925
|
+
const result2 = /* @__PURE__ */ new Map();
|
|
2926
|
+
while (offset < data.length) {
|
|
2927
|
+
const tag = varint();
|
|
2928
|
+
const field = Number(tag >> 3n);
|
|
2929
|
+
if (field < 1 || field > 536870911) throw new Error("Invalid Cursor protobuf field.");
|
|
2930
|
+
const wire = Number(tag & 7n);
|
|
2931
|
+
if (wire === 0) varint();
|
|
2932
|
+
else if (wire === 1) offset += 8;
|
|
2933
|
+
else if (wire === 5) offset += 4;
|
|
2934
|
+
else if (wire === 2) {
|
|
2935
|
+
const length = Number(varint());
|
|
2936
|
+
if (!Number.isSafeInteger(length) || length > data.length - offset)
|
|
2937
|
+
throw new Error("Truncated Cursor protobuf value.");
|
|
2938
|
+
const values = result2.get(field) ?? [];
|
|
2939
|
+
values.push(data.subarray(offset, offset + length));
|
|
2940
|
+
result2.set(field, values);
|
|
2941
|
+
offset += length;
|
|
2942
|
+
} else throw new Error("Unsupported Cursor protobuf wire type.");
|
|
2943
|
+
if (offset > data.length) throw new Error("Truncated Cursor protobuf value.");
|
|
2944
|
+
}
|
|
2945
|
+
return result2;
|
|
2946
|
+
}
|
|
2947
|
+
function readCursor(path, projectDir) {
|
|
2948
|
+
const db = new DatabaseSync2(path, { readOnly: true });
|
|
2949
|
+
try {
|
|
2950
|
+
db.exec("PRAGMA query_only=ON; PRAGMA busy_timeout=100; BEGIN");
|
|
2951
|
+
if (db.prepare("PRAGMA user_version").get()?.user_version !== 1)
|
|
2952
|
+
throw new Error("Unsupported Cursor CLI store version (expected 1).");
|
|
2953
|
+
const row = db.prepare("SELECT value FROM meta WHERE key='0' AND length(value)<=2097152").get();
|
|
2954
|
+
const hex = row?.value;
|
|
2955
|
+
if (typeof hex !== "string" || !/^(?:[a-fA-F0-9]{2})+$/.test(hex))
|
|
2956
|
+
throw new Error("Invalid Cursor metadata.");
|
|
2957
|
+
const meta = object(JSON.parse(Buffer.from(hex, "hex").toString("utf8")));
|
|
2958
|
+
if (typeof meta.agentId !== "string" || meta.agentId !== basename2(dirname7(path)) || meta.subagentInfo != null)
|
|
2959
|
+
throw new Error("Invalid or delegated Cursor session identity.");
|
|
2960
|
+
let budget = 64 * 1024 * 1024;
|
|
2961
|
+
const blob = (id) => {
|
|
2962
|
+
if (!/^[a-f0-9]{64}$/.test(id)) throw new Error("Invalid Cursor blob reference.");
|
|
2963
|
+
const value = db.prepare("SELECT data FROM blobs WHERE id=? AND length(data)<=?").get(id, budget)?.data;
|
|
2964
|
+
if (!(value instanceof Uint8Array))
|
|
2965
|
+
throw new Error("Cursor history missing or exceeds 64 MiB.");
|
|
2966
|
+
const bytes = Buffer.from(value);
|
|
2967
|
+
budget -= bytes.length;
|
|
2968
|
+
if (createHash10("sha256").update(bytes).digest("hex") !== id)
|
|
2969
|
+
throw new Error("Cursor blob failed integrity verification.");
|
|
2970
|
+
return bytes;
|
|
2971
|
+
};
|
|
2972
|
+
const root = fields(blob(String(meta.latestRootBlobId)));
|
|
2973
|
+
const bucket = basename2(dirname7(dirname7(path)));
|
|
2974
|
+
const md5 = (cwd2) => createHash10("md5").update(resolve7(cwd2)).digest("hex");
|
|
2975
|
+
let cwd = md5(projectDir) === bucket ? projectDir : void 0;
|
|
2976
|
+
for (const uri of root.get(9) ?? []) {
|
|
2977
|
+
try {
|
|
2978
|
+
const native = fileURLToPath(uri.toString("utf8"));
|
|
2979
|
+
if (md5(native) === bucket) {
|
|
2980
|
+
cwd = native;
|
|
2981
|
+
break;
|
|
2982
|
+
}
|
|
2983
|
+
} catch {
|
|
2984
|
+
}
|
|
2985
|
+
}
|
|
2986
|
+
if (!cwd) throw new Error("Cursor workspace cannot be verified against its storage bucket.");
|
|
2987
|
+
const refs = [];
|
|
2988
|
+
for (const archive of root.get(13) ?? root.get(11) ?? [])
|
|
2989
|
+
refs.push(...fields(blob(archive.toString("hex"))).get(1) ?? []);
|
|
2990
|
+
refs.push(...root.get(1) ?? []);
|
|
2991
|
+
if (refs.length > 5e4) throw new Error("Cursor history exceeds 50000 messages.");
|
|
2992
|
+
const messages = [];
|
|
2993
|
+
const counts = /* @__PURE__ */ new Map();
|
|
2994
|
+
for (const ref of refs) {
|
|
2995
|
+
const key = ref.toString("hex");
|
|
2996
|
+
const message2 = object(
|
|
2997
|
+
JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(blob(key)))
|
|
2998
|
+
);
|
|
2999
|
+
const cursor = object(object(message2.providerOptions).cursor);
|
|
3000
|
+
if (cursor.isSummary || !["user", "assistant"].includes(String(message2.role))) continue;
|
|
3001
|
+
let text = textContent(message2.content);
|
|
3002
|
+
if (message2.role === "user") {
|
|
3003
|
+
if (!cursor.requestId) continue;
|
|
3004
|
+
const start = text.indexOf("<user_query>");
|
|
3005
|
+
const end = text.lastIndexOf("</user_query>");
|
|
3006
|
+
if (start >= 0 && end > start) text = text.slice(start + 12, end).trim();
|
|
3007
|
+
}
|
|
3008
|
+
if (!text.trim()) continue;
|
|
3009
|
+
const id = createHash10("sha256").update(JSON.stringify([message2.role, cursor.requestId ?? "", text])).digest("hex");
|
|
3010
|
+
const count = (counts.get(id) ?? 0) + 1;
|
|
3011
|
+
counts.set(id, count);
|
|
3012
|
+
messages.push({
|
|
3013
|
+
id: `${id}#${count}`,
|
|
3014
|
+
role: message2.role,
|
|
3015
|
+
text,
|
|
3016
|
+
source: `blob:${key}`
|
|
3017
|
+
});
|
|
3018
|
+
}
|
|
3019
|
+
return SessionSnapshotSchema.parse({
|
|
3020
|
+
formatVersion: 1,
|
|
3021
|
+
adapterVersion: 1,
|
|
3022
|
+
agent: "cursor",
|
|
3023
|
+
nativeId: meta.agentId,
|
|
3024
|
+
cwd,
|
|
3025
|
+
messages
|
|
3026
|
+
});
|
|
3027
|
+
} finally {
|
|
3028
|
+
db.close();
|
|
3029
|
+
}
|
|
3030
|
+
}
|
|
3031
|
+
|
|
3032
|
+
// apps/client/sessions/source.ts
|
|
3033
|
+
var MAX_SOURCE_BYTES = 64 * 1024 * 1024;
|
|
3034
|
+
async function sourceSignature(path, agent) {
|
|
3035
|
+
const paths = agent === "cursor" ? [path, `${path}-wal`] : [path];
|
|
3036
|
+
const signatures = [];
|
|
3037
|
+
for (const file of paths) {
|
|
3038
|
+
try {
|
|
3039
|
+
const s = await stat(file, { bigint: true });
|
|
3040
|
+
if (!s.isFile()) throw new Error("Session source must be a regular file.");
|
|
3041
|
+
signatures.push([
|
|
3042
|
+
String(s.dev),
|
|
3043
|
+
String(s.ino),
|
|
3044
|
+
String(s.size),
|
|
3045
|
+
String(s.mtimeNs),
|
|
3046
|
+
String(s.ctimeNs)
|
|
3047
|
+
]);
|
|
3048
|
+
} catch (error) {
|
|
3049
|
+
if (file !== path && error.code === "ENOENT")
|
|
3050
|
+
signatures.push(null);
|
|
3051
|
+
else throw error;
|
|
3052
|
+
}
|
|
3053
|
+
}
|
|
3054
|
+
return JSON.stringify(signatures);
|
|
3055
|
+
}
|
|
3056
|
+
async function readSource(agent, path, projectDir) {
|
|
3057
|
+
const signature2 = await sourceSignature(path, agent);
|
|
3058
|
+
let result2;
|
|
3059
|
+
if (agent === "cursor") result2 = { snapshot: readCursor(path, projectDir), incomplete: false };
|
|
3060
|
+
else {
|
|
3061
|
+
const handle = await open4(path, "r");
|
|
3062
|
+
let bytes;
|
|
3063
|
+
try {
|
|
3064
|
+
const size = (await handle.stat()).size;
|
|
3065
|
+
if (size > MAX_SOURCE_BYTES) throw new Error("Session source exceeds 64 MiB.");
|
|
3066
|
+
bytes = Buffer.alloc(size + 1);
|
|
3067
|
+
let offset = 0;
|
|
3068
|
+
while (offset < bytes.length) {
|
|
3069
|
+
const read = await handle.read(bytes, offset, bytes.length - offset, offset);
|
|
3070
|
+
if (!read.bytesRead) break;
|
|
3071
|
+
offset += read.bytesRead;
|
|
3072
|
+
}
|
|
3073
|
+
bytes = bytes.subarray(0, offset);
|
|
3074
|
+
if (offset > size) throw new Error("Session source changed while reading; retry next cycle.");
|
|
3075
|
+
} finally {
|
|
3076
|
+
await handle.close();
|
|
3077
|
+
}
|
|
3078
|
+
if (agent === "dsh" && path.endsWith(".zstd")) {
|
|
3079
|
+
const decompress = zlib.zstdDecompressSync;
|
|
3080
|
+
if (!decompress)
|
|
3081
|
+
throw new Error(
|
|
3082
|
+
"Compressed DSH sessions require Node.js with zstd support (22.15+ or 24+)."
|
|
3083
|
+
);
|
|
3084
|
+
bytes = decompress(bytes, { maxOutputLength: MAX_SOURCE_BYTES });
|
|
3085
|
+
}
|
|
3086
|
+
result2 = normalizeJsonl(agent, new TextDecoder("utf-8", { fatal: true }).decode(bytes));
|
|
3087
|
+
}
|
|
3088
|
+
if (signature2 !== await sourceSignature(path, agent))
|
|
3089
|
+
throw new Error("Session source changed while reading; retry next cycle.");
|
|
3090
|
+
const cwd = await realpath5(result2.snapshot.cwd);
|
|
3091
|
+
const project = await realpath5(projectDir);
|
|
3092
|
+
const rel = relative4(project, cwd);
|
|
3093
|
+
if (rel === ".." || rel.startsWith(`..${sep5}`) || isAbsolute4(rel))
|
|
3094
|
+
throw new Error("Session cwd is outside the registered project.");
|
|
3095
|
+
return { ...result2, signature: signature2 };
|
|
3096
|
+
}
|
|
3097
|
+
|
|
3098
|
+
// apps/client/sessions/discovery.ts
|
|
3099
|
+
import { opendir as opendir2 } from "node:fs/promises";
|
|
3100
|
+
import { join as join13 } from "node:path";
|
|
3101
|
+
async function* candidates(directory, agent) {
|
|
3102
|
+
const queue = [directory];
|
|
3103
|
+
while (queue.length) {
|
|
3104
|
+
const current = queue.shift();
|
|
3105
|
+
const dir = await opendir2(current);
|
|
3106
|
+
for await (const entry of dir) {
|
|
3107
|
+
const path = join13(current, entry.name);
|
|
3108
|
+
if (entry.isDirectory()) queue.push(path);
|
|
3109
|
+
if (entry.isFile() && (agent === "cursor" ? entry.name === "store.db" : entry.name.endsWith(".jsonl") || agent === "dsh" && entry.name.endsWith(".jsonl.zstd")))
|
|
3110
|
+
yield path;
|
|
3111
|
+
else yield null;
|
|
3112
|
+
}
|
|
3113
|
+
}
|
|
3114
|
+
}
|
|
3115
|
+
var SessionDiscovery = class {
|
|
3116
|
+
constructor(state) {
|
|
3117
|
+
this.state = state;
|
|
3118
|
+
}
|
|
3119
|
+
state;
|
|
3120
|
+
scans = /* @__PURE__ */ new Map();
|
|
3121
|
+
async cycle() {
|
|
3122
|
+
const issues = [];
|
|
3123
|
+
let entries = 500;
|
|
3124
|
+
let reads = 5;
|
|
3125
|
+
const configs = this.state.directories();
|
|
3126
|
+
for (const [directory, scan] of this.scans) {
|
|
3127
|
+
if (!configs.some((config) => config.directory === directory)) {
|
|
3128
|
+
await scan.iterator.return(void 0);
|
|
3129
|
+
this.scans.delete(directory);
|
|
3130
|
+
}
|
|
3131
|
+
}
|
|
3132
|
+
for (const config of configs) {
|
|
3133
|
+
let scan = this.scans.get(config.directory);
|
|
3134
|
+
if (!scan) {
|
|
3135
|
+
scan = { iterator: candidates(config.directory, config.agent), nextAt: 0 };
|
|
3136
|
+
this.scans.set(config.directory, scan);
|
|
3137
|
+
}
|
|
3138
|
+
if (scan.nextAt > Date.now()) continue;
|
|
3139
|
+
try {
|
|
3140
|
+
while (entries > 0 && reads > 0) {
|
|
3141
|
+
entries--;
|
|
3142
|
+
const entry = await scan.iterator.next();
|
|
3143
|
+
if (entry.done) {
|
|
3144
|
+
scan.iterator = candidates(config.directory, config.agent);
|
|
3145
|
+
scan.nextAt = Date.now() + 3e5;
|
|
3146
|
+
break;
|
|
3147
|
+
}
|
|
3148
|
+
const path = entry.value;
|
|
3149
|
+
if (!path || this.state.get(path)) continue;
|
|
3150
|
+
const signature2 = await sourceSignature(path, config.agent);
|
|
3151
|
+
const db = this.state.state.db;
|
|
3152
|
+
if (db.prepare(
|
|
3153
|
+
"SELECT 1 FROM session_discovery_cache WHERE root=? AND path=? AND signature=?"
|
|
3154
|
+
).get(this.state.root, path, signature2))
|
|
3155
|
+
continue;
|
|
3156
|
+
reads--;
|
|
3157
|
+
try {
|
|
3158
|
+
const result2 = await readSource(config.agent, path, config.projectDir);
|
|
3159
|
+
if (result2.incomplete) continue;
|
|
3160
|
+
this.state.track(config.agent, path, config.projectDir);
|
|
3161
|
+
} catch (error) {
|
|
3162
|
+
const message2 = error instanceof Error ? error.message : "Discovery failed.";
|
|
3163
|
+
if (!message2.includes("outside the registered project"))
|
|
3164
|
+
issues.push(`${path}: ${message2}`);
|
|
3165
|
+
if (!message2.includes("outside the registered project")) continue;
|
|
3166
|
+
}
|
|
3167
|
+
db.prepare(
|
|
3168
|
+
"INSERT INTO session_discovery_cache VALUES (?,?,?) ON CONFLICT(root,path) DO UPDATE SET signature=excluded.signature"
|
|
3169
|
+
).run(this.state.root, path, signature2);
|
|
3170
|
+
}
|
|
3171
|
+
} catch (error) {
|
|
3172
|
+
issues.push(
|
|
3173
|
+
`${config.directory}: ${error instanceof Error ? error.message : "Discovery failed."}`
|
|
3174
|
+
);
|
|
3175
|
+
await scan.iterator.return(void 0);
|
|
3176
|
+
scan.iterator = candidates(config.directory, config.agent);
|
|
3177
|
+
scan.nextAt = Date.now() + 3e5;
|
|
3178
|
+
}
|
|
3179
|
+
}
|
|
3180
|
+
return issues;
|
|
3181
|
+
}
|
|
3182
|
+
async close() {
|
|
3183
|
+
for (const scan of this.scans.values()) await scan.iterator.return(void 0);
|
|
3184
|
+
}
|
|
3185
|
+
};
|
|
3186
|
+
|
|
3187
|
+
// apps/client/sessions/synchronizer.ts
|
|
3188
|
+
var SessionSynchronizer = class {
|
|
3189
|
+
constructor(state, root, directory, signal) {
|
|
3190
|
+
this.root = root;
|
|
3191
|
+
this.directory = directory;
|
|
3192
|
+
const binding = state.binding(root);
|
|
3193
|
+
if (!binding) throw new Error("Connect this root before collecting sessions.");
|
|
3194
|
+
this.client = new HubClient(binding.serverUrl, 1e4, binding, signal);
|
|
3195
|
+
this.blobs = new LocalBlobStore(join14(directory, "blobs"));
|
|
3196
|
+
this.index = new SessionState(state, root);
|
|
3197
|
+
this.discovery = new SessionDiscovery(this.index);
|
|
3198
|
+
}
|
|
3199
|
+
root;
|
|
3200
|
+
directory;
|
|
3201
|
+
index;
|
|
3202
|
+
client;
|
|
3203
|
+
blobs;
|
|
3204
|
+
discovery;
|
|
3205
|
+
capable = false;
|
|
3206
|
+
async cycle() {
|
|
3207
|
+
const report = { published: 0, checked: 0, issues: [] };
|
|
3208
|
+
report.issues.push(...await consumeHints(this.directory, this.root, this.index));
|
|
3209
|
+
report.issues.push(...await this.discovery.cycle());
|
|
3210
|
+
const sources = this.index.sources(void 0, 200);
|
|
3211
|
+
if (sources.length && !this.capable) {
|
|
3212
|
+
const info = await this.client.serverInfo();
|
|
3213
|
+
if (!["blobs", "session-revisions"].every(
|
|
3214
|
+
(capability) => info.capabilities.includes(capability)
|
|
3215
|
+
))
|
|
3216
|
+
throw new Error("Server does not support session publication. Upgrade the server first.");
|
|
3217
|
+
this.capable = true;
|
|
3218
|
+
}
|
|
3219
|
+
let reads = 5;
|
|
3220
|
+
let uploads = 5;
|
|
3221
|
+
for (const source of sources) {
|
|
3222
|
+
if (reads <= 0 || uploads <= 0 || report.checked >= 200) break;
|
|
3223
|
+
report.checked++;
|
|
3224
|
+
this.index.checked(source.path, source.lastError);
|
|
3225
|
+
try {
|
|
3226
|
+
if (source.pending) {
|
|
3227
|
+
if (source.retryAt > Date.now()) {
|
|
3228
|
+
if (source.lastError)
|
|
3229
|
+
report.issues.push(`${source.path}: ${source.lastError} (retry deferred)`);
|
|
3230
|
+
continue;
|
|
3231
|
+
}
|
|
3232
|
+
uploads--;
|
|
3233
|
+
await this.flush(source);
|
|
3234
|
+
report.published++;
|
|
3235
|
+
continue;
|
|
3236
|
+
}
|
|
3237
|
+
const signature2 = await sourceSignature(source.path, source.agent);
|
|
3238
|
+
if (signature2 === source.signature && Date.now() - source.verifiedAt < 864e5) {
|
|
3239
|
+
this.index.checked(source.path);
|
|
3240
|
+
continue;
|
|
3241
|
+
}
|
|
3242
|
+
reads--;
|
|
3243
|
+
const observed = await readSource(source.agent, source.path, source.projectDir);
|
|
3244
|
+
if (observed.incomplete)
|
|
3245
|
+
throw new Error("Final session record is incomplete; waiting for the next save.");
|
|
3246
|
+
if (!observed.snapshot.messages.length) {
|
|
3247
|
+
this.index.checked(source.path);
|
|
3248
|
+
continue;
|
|
3249
|
+
}
|
|
3250
|
+
const key = sessionKey(source.agent, observed.snapshot.nativeId);
|
|
3251
|
+
if (source.key && source.key !== key)
|
|
3252
|
+
throw new Error(
|
|
3253
|
+
"Native session identity changed at this path; register the new source explicitly."
|
|
3254
|
+
);
|
|
3255
|
+
const bytes = Buffer.from(JSON.stringify(observed.snapshot));
|
|
3256
|
+
if (bytes.length > MAX_BLOB_BYTES)
|
|
3257
|
+
throw new Error("Normalized session exceeds the 10 MiB snapshot limit.");
|
|
3258
|
+
const blob = await this.blobs.put(bytes);
|
|
3259
|
+
let revision = source.revision;
|
|
3260
|
+
if (!source.key) {
|
|
3261
|
+
try {
|
|
3262
|
+
const owner = this.index.state.binding(this.root).memberId;
|
|
3263
|
+
const current = await this.client.session(owner, key);
|
|
3264
|
+
if (current.blob.sha256 !== blob.sha256 && !source.adoptExisting)
|
|
3265
|
+
throw new Error(
|
|
3266
|
+
"Server session already exists with different content; inspect it, then track with --adopt-existing to continue."
|
|
3267
|
+
);
|
|
3268
|
+
revision = current.revision;
|
|
3269
|
+
} catch (error) {
|
|
3270
|
+
if (!(error instanceof HubRequestError && error.code === "SESSION_NOT_FOUND"))
|
|
3271
|
+
throw error;
|
|
3272
|
+
}
|
|
3273
|
+
this.index.baseline(source.path, key, revision);
|
|
3274
|
+
}
|
|
3275
|
+
if (blob.sha256 === source.hash) {
|
|
3276
|
+
this.index.acknowledge(source.path, revision, blob.sha256, observed.signature);
|
|
3277
|
+
continue;
|
|
3278
|
+
}
|
|
3279
|
+
this.index.stage(
|
|
3280
|
+
source.path,
|
|
3281
|
+
{ operationId: randomUUID7(), sessionKey: key, parentRevision: revision, blob },
|
|
3282
|
+
observed.signature
|
|
3283
|
+
);
|
|
3284
|
+
uploads--;
|
|
3285
|
+
await this.flush(this.index.get(source.path));
|
|
3286
|
+
report.published++;
|
|
2544
3287
|
} catch (error) {
|
|
2545
|
-
|
|
2546
|
-
|
|
3288
|
+
const message2 = error instanceof Error ? error.message : "Session synchronization failed.";
|
|
3289
|
+
this.index.checked(source.path, message2);
|
|
3290
|
+
report.issues.push(`${source.path}: ${message2}`);
|
|
2547
3291
|
}
|
|
2548
3292
|
}
|
|
2549
|
-
|
|
2550
|
-
const rootStat = await lstat2(this.ownRoot);
|
|
2551
|
-
if (!rootStat.isDirectory() || rootStat.isSymbolicLink())
|
|
2552
|
-
throw new Error("Author directory disappeared before publication.");
|
|
2553
|
-
this.state.enqueue(this.root, {
|
|
2554
|
-
operationId: randomUUID7(),
|
|
2555
|
-
parentRevision: this.state.publication(this.root).revision,
|
|
2556
|
-
changes
|
|
2557
|
-
});
|
|
2558
|
-
for (const snapshot of snapshots)
|
|
2559
|
-
this.index.clean(snapshot.path, snapshot.generation, snapshot.hash, this.clock());
|
|
3293
|
+
return report;
|
|
2560
3294
|
}
|
|
2561
|
-
async
|
|
2562
|
-
|
|
2563
|
-
|
|
2564
|
-
|
|
2565
|
-
|
|
2566
|
-
|
|
2567
|
-
|
|
2568
|
-
|
|
2569
|
-
|
|
2570
|
-
|
|
2571
|
-
|
|
2572
|
-
|
|
2573
|
-
size: file.size
|
|
2574
|
-
}))
|
|
2575
|
-
);
|
|
2576
|
-
} else {
|
|
2577
|
-
const delta = await this.client.fileChanges(owner, revision);
|
|
2578
|
-
if (!delta.revisions.length) return 0;
|
|
2579
|
-
const changes = /* @__PURE__ */ new Map();
|
|
2580
|
-
let expected = revision + 1;
|
|
2581
|
-
for (const entry of delta.revisions) {
|
|
2582
|
-
if (entry.revision !== expected++) throw new Error("Server change history has a gap.");
|
|
2583
|
-
for (const change of entry.changes) changes.set(change.path, change);
|
|
2584
|
-
}
|
|
2585
|
-
const finalNames = new Set(
|
|
2586
|
-
[...changes.values()].filter((change) => change.kind === "upsert").map((change) => change.path.toLowerCase())
|
|
2587
|
-
);
|
|
2588
|
-
for (const change of changes.values()) {
|
|
2589
|
-
if (change.kind === "delete" && !this.index.received(owner, change.path) && finalNames.has(change.path.toLowerCase()))
|
|
2590
|
-
changes.delete(change.path);
|
|
2591
|
-
}
|
|
2592
|
-
const ordered = [...changes.values()].sort(
|
|
2593
|
-
(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
|
|
2594
|
-
);
|
|
2595
|
-
this.index.queue(owner, delta.revisions.at(-1).revision, ordered);
|
|
3295
|
+
async flush(source) {
|
|
3296
|
+
const request = source.pending;
|
|
3297
|
+
try {
|
|
3298
|
+
const { missing: missing2 } = await this.client.missingBlobs([request.blob.sha256]);
|
|
3299
|
+
if (missing2.some((hash2) => hash2 !== request.blob.sha256))
|
|
3300
|
+
throw new Error("Server requested unrelated session content.");
|
|
3301
|
+
if (missing2.length) {
|
|
3302
|
+
const bytes = await this.blobs.get(request.blob.sha256);
|
|
3303
|
+
if (!bytes) throw new Error("Queued session snapshot is missing.");
|
|
3304
|
+
const ref = await this.client.uploadBlob(request.blob.sha256, bytes);
|
|
3305
|
+
if (ref.sha256 !== request.blob.sha256 || ref.size !== request.blob.size)
|
|
3306
|
+
throw new Error("Server acknowledged different session content.");
|
|
2596
3307
|
}
|
|
3308
|
+
const result2 = await this.client.commitSession(request);
|
|
3309
|
+
if (result2.operationId !== request.operationId || result2.revision < request.parentRevision || result2.revision > request.parentRevision + 1)
|
|
3310
|
+
throw new Error("Invalid session acknowledgement.");
|
|
3311
|
+
this.index.acknowledge(
|
|
3312
|
+
source.path,
|
|
3313
|
+
result2.revision,
|
|
3314
|
+
request.blob.sha256,
|
|
3315
|
+
source.pendingSignature
|
|
3316
|
+
);
|
|
3317
|
+
} catch (error) {
|
|
3318
|
+
const blocked = error instanceof HubRequestError && error.status >= 400 && error.status < 500 && ![408, 429].includes(error.status);
|
|
3319
|
+
this.index.postpone(
|
|
3320
|
+
source,
|
|
3321
|
+
error instanceof Error ? error.message : "Upload failed.",
|
|
3322
|
+
blocked
|
|
3323
|
+
);
|
|
3324
|
+
throw error;
|
|
2597
3325
|
}
|
|
2598
|
-
|
|
2599
|
-
|
|
2600
|
-
await
|
|
2601
|
-
|
|
3326
|
+
}
|
|
3327
|
+
async close() {
|
|
3328
|
+
await this.discovery.close();
|
|
3329
|
+
}
|
|
3330
|
+
};
|
|
3331
|
+
|
|
3332
|
+
// apps/client/runtime/job.ts
|
|
3333
|
+
var SyncJob = class {
|
|
3334
|
+
constructor(root, directory, version, log, options = {}) {
|
|
3335
|
+
this.root = root;
|
|
3336
|
+
this.log = log;
|
|
3337
|
+
this.lock = acquireRootLock(root, directory);
|
|
3338
|
+
let state;
|
|
3339
|
+
try {
|
|
3340
|
+
state = new ClientState(directory);
|
|
3341
|
+
this.state = state;
|
|
3342
|
+
this.runtime = new RuntimeState(state);
|
|
3343
|
+
this.files = options.sessionsOnly ? void 0 : new FileSynchronizer(state, root, directory, {
|
|
3344
|
+
...options.settleMs === void 0 ? {} : { settleMs: options.settleMs },
|
|
3345
|
+
adoptBaseline: Boolean(options.adoptBaseline),
|
|
3346
|
+
restoreMissing: Boolean(options.restoreMissing)
|
|
3347
|
+
});
|
|
3348
|
+
this.sessions = new SessionSynchronizer(state, root, directory, this.abort.signal);
|
|
3349
|
+
if (options.retry) {
|
|
3350
|
+
state.retryPublication(root);
|
|
3351
|
+
this.sessions.index.retry();
|
|
3352
|
+
}
|
|
3353
|
+
const overrides = {};
|
|
3354
|
+
if (options.intervalMs !== void 0)
|
|
3355
|
+
overrides.checkIntervalSeconds = options.intervalMs / 1e3;
|
|
3356
|
+
if (options.settleMs !== void 0) overrides.fileSettleSeconds = options.settleMs / 1e3;
|
|
3357
|
+
this.control = new ControlPlane(state, root, version, overrides, (policy) => {
|
|
3358
|
+
this.intervalMs = Math.round(policy.checkIntervalSeconds * 1e3);
|
|
3359
|
+
this.files?.setSettleMs(Math.round(policy.fileSettleSeconds * 1e3));
|
|
3360
|
+
});
|
|
3361
|
+
} catch (e) {
|
|
3362
|
+
state?.close();
|
|
3363
|
+
this.lock.close();
|
|
3364
|
+
throw e;
|
|
3365
|
+
}
|
|
3366
|
+
}
|
|
3367
|
+
root;
|
|
3368
|
+
log;
|
|
3369
|
+
state;
|
|
3370
|
+
runtime;
|
|
3371
|
+
control;
|
|
3372
|
+
files;
|
|
3373
|
+
sessions;
|
|
3374
|
+
lock;
|
|
3375
|
+
abort = new AbortController();
|
|
3376
|
+
pending;
|
|
3377
|
+
stopped = false;
|
|
3378
|
+
closing;
|
|
3379
|
+
controlStarted = false;
|
|
3380
|
+
lastIssue = "";
|
|
3381
|
+
issueCount = 0;
|
|
3382
|
+
lastLogAt = 0;
|
|
3383
|
+
nextAt = 0;
|
|
3384
|
+
intervalMs = 6e4;
|
|
3385
|
+
tick() {
|
|
3386
|
+
if (this.stopped) return Promise.reject(new Error("Synchronization stopped."));
|
|
3387
|
+
this.pending ??= this.cycle().finally(() => {
|
|
3388
|
+
this.pending = void 0;
|
|
2602
3389
|
});
|
|
2603
|
-
|
|
2604
|
-
|
|
2605
|
-
|
|
2606
|
-
|
|
2607
|
-
|
|
2608
|
-
|
|
2609
|
-
|
|
2610
|
-
|
|
2611
|
-
|
|
2612
|
-
|
|
2613
|
-
|
|
2614
|
-
|
|
2615
|
-
|
|
3390
|
+
return this.pending;
|
|
3391
|
+
}
|
|
3392
|
+
async once() {
|
|
3393
|
+
const prior = this.runtime.status(this.root);
|
|
3394
|
+
const report = await this.tick();
|
|
3395
|
+
if (this.control.enabled) await this.control.tick();
|
|
3396
|
+
const errors = [
|
|
3397
|
+
...report.issues,
|
|
3398
|
+
...report.sessions.issues,
|
|
3399
|
+
...this.control.enabled ? [this.control.status.configError, this.control.status.reportError].filter(
|
|
3400
|
+
(e) => Boolean(e)
|
|
3401
|
+
) : []
|
|
3402
|
+
].map(redact);
|
|
3403
|
+
if (errors[0] && !this.stopped) {
|
|
3404
|
+
const status = this.runtime.status(this.root);
|
|
3405
|
+
this.runtime.save(this.root, {
|
|
3406
|
+
...status,
|
|
3407
|
+
state: "waiting",
|
|
3408
|
+
lastSuccessAt: prior?.lastSuccessAt ?? null,
|
|
3409
|
+
issue: errors[0]
|
|
3410
|
+
});
|
|
3411
|
+
if (errors[0] !== report.errors[0]) this.log.write("sync.waiting", this.root, errors[0]);
|
|
2616
3412
|
}
|
|
2617
|
-
|
|
2618
|
-
return count;
|
|
3413
|
+
return { ...report, errors, control: this.control.enabled ? this.control.status : null };
|
|
2619
3414
|
}
|
|
2620
|
-
async
|
|
2621
|
-
const prior = this.
|
|
2622
|
-
|
|
2623
|
-
|
|
2624
|
-
|
|
2625
|
-
|
|
2626
|
-
|
|
2627
|
-
|
|
2628
|
-
|
|
2629
|
-
|
|
2630
|
-
|
|
3415
|
+
async cycle() {
|
|
3416
|
+
const prior = this.runtime.status(this.root);
|
|
3417
|
+
this.runtime.save(this.root, {
|
|
3418
|
+
state: "syncing",
|
|
3419
|
+
checkedAt: prior?.checkedAt ?? null,
|
|
3420
|
+
lastSuccessAt: prior?.lastSuccessAt ?? null,
|
|
3421
|
+
pendingFiles: prior?.pendingFiles ?? 0,
|
|
3422
|
+
pendingDownloads: prior?.pendingDownloads ?? 0,
|
|
3423
|
+
issue: null
|
|
3424
|
+
});
|
|
3425
|
+
let report = {
|
|
3426
|
+
published: false,
|
|
3427
|
+
appliedFiles: 0,
|
|
3428
|
+
waitingFiles: 0,
|
|
3429
|
+
pendingPublication: false,
|
|
3430
|
+
issues: []
|
|
3431
|
+
};
|
|
3432
|
+
let sessions = { published: 0, checked: 0, issues: [] };
|
|
3433
|
+
try {
|
|
3434
|
+
await verifyWorkspace(this.state, this.root);
|
|
3435
|
+
if (!this.controlStarted) {
|
|
3436
|
+
await this.control.tick();
|
|
3437
|
+
this.control.start();
|
|
3438
|
+
this.controlStarted = true;
|
|
3439
|
+
}
|
|
3440
|
+
if (this.files) report = await this.files.cycle();
|
|
3441
|
+
sessions = await this.sessions.cycle();
|
|
3442
|
+
} catch (e) {
|
|
3443
|
+
report.issues.push(diagnostic(e));
|
|
3444
|
+
}
|
|
3445
|
+
const errors = [
|
|
3446
|
+
...report.issues,
|
|
3447
|
+
...sessions.issues,
|
|
3448
|
+
...this.control.enabled ? [this.control.status.configError, this.control.status.reportError].filter(
|
|
3449
|
+
(e) => Boolean(e)
|
|
3450
|
+
) : []
|
|
3451
|
+
].map(redact);
|
|
3452
|
+
const pendingDownloads = Number(
|
|
3453
|
+
this.state.db.prepare(
|
|
3454
|
+
"SELECT COALESCE(SUM(json_array_length(changes_json)-next_index),0) n FROM receive_jobs WHERE root=?"
|
|
3455
|
+
).get(this.root).n
|
|
3456
|
+
);
|
|
3457
|
+
const pendingFiles = report.waitingFiles + (this.state.pending(this.root)?.batch.changes.length ?? 0);
|
|
3458
|
+
const now = Date.now(), checkedAt = new Date(now).toISOString();
|
|
3459
|
+
this.control.updateSync({
|
|
3460
|
+
checkedAt,
|
|
3461
|
+
published: report.published,
|
|
3462
|
+
appliedFiles: report.appliedFiles,
|
|
3463
|
+
pendingFiles,
|
|
3464
|
+
sessionSources: Number(
|
|
3465
|
+
this.state.db.prepare("SELECT COUNT(*) n FROM session_sources WHERE root=?").get(this.root).n
|
|
3466
|
+
),
|
|
3467
|
+
errors: errors.slice(0, 20).map((e) => e.slice(0, 1e3))
|
|
3468
|
+
});
|
|
3469
|
+
const issue = errors[0] ?? "";
|
|
3470
|
+
const issueKey = issue.replace(/[a-f0-9]{8}(?:-[a-f0-9]{4}){3}-[a-f0-9]{12}/gi, "[request]");
|
|
3471
|
+
if (issue) {
|
|
3472
|
+
this.issueCount++;
|
|
3473
|
+
if (issueKey !== this.lastIssue || now - this.lastLogAt >= 9e5) {
|
|
3474
|
+
this.log.write("sync.waiting", this.root, issue);
|
|
3475
|
+
this.lastLogAt = now;
|
|
3476
|
+
}
|
|
2631
3477
|
} else {
|
|
2632
|
-
if (
|
|
2633
|
-
|
|
2634
|
-
|
|
2635
|
-
if (
|
|
2636
|
-
|
|
2637
|
-
|
|
3478
|
+
if (this.lastIssue)
|
|
3479
|
+
this.log.write("sync.recovered", this.root, `${this.issueCount} failed checks`);
|
|
3480
|
+
this.issueCount = 0;
|
|
3481
|
+
if (report.published || report.appliedFiles || sessions.published)
|
|
3482
|
+
this.log.write(
|
|
3483
|
+
"sync.changed",
|
|
3484
|
+
this.root,
|
|
3485
|
+
`files received=${report.appliedFiles}, files published=${Number(report.published)}, sessions published=${sessions.published}`
|
|
2638
3486
|
);
|
|
2639
|
-
let content = await this.blobs.get(change.sha256);
|
|
2640
|
-
if (!content) {
|
|
2641
|
-
content = await this.client.downloadBlob(change.sha256);
|
|
2642
|
-
await this.blobs.put(content);
|
|
2643
|
-
}
|
|
2644
|
-
if (content.byteLength !== change.size)
|
|
2645
|
-
throw new Error(`Content size mismatch: ${change.path}`);
|
|
2646
|
-
await replaceFile(root, change.path, content, local?.signature ?? null);
|
|
2647
3487
|
}
|
|
3488
|
+
this.lastIssue = issueKey;
|
|
3489
|
+
this.nextAt = now + (issue ? Math.min(9e5, Math.max(this.intervalMs, 1e4) * 2 ** Math.min(this.issueCount - 1, 5)) : pendingDownloads ? 250 : this.intervalMs);
|
|
3490
|
+
this.runtime.save(this.root, {
|
|
3491
|
+
state: this.stopped ? "stopped" : issue ? "waiting" : "idle",
|
|
3492
|
+
checkedAt,
|
|
3493
|
+
lastSuccessAt: !issue && !pendingDownloads ? checkedAt : prior?.lastSuccessAt ?? null,
|
|
3494
|
+
pendingFiles,
|
|
3495
|
+
pendingDownloads,
|
|
3496
|
+
issue: issue || null
|
|
3497
|
+
});
|
|
3498
|
+
return {
|
|
3499
|
+
...report,
|
|
3500
|
+
sessions,
|
|
3501
|
+
pendingDownloads,
|
|
3502
|
+
control: this.control.enabled ? this.control.status : null,
|
|
3503
|
+
errors
|
|
3504
|
+
};
|
|
3505
|
+
}
|
|
3506
|
+
close() {
|
|
3507
|
+
if (this.closing) return this.closing;
|
|
3508
|
+
this.stopped = true;
|
|
3509
|
+
this.abort.abort();
|
|
3510
|
+
this.files?.close();
|
|
3511
|
+
return this.closing = (async () => {
|
|
3512
|
+
await this.control.close();
|
|
3513
|
+
await this.pending?.catch(() => {
|
|
3514
|
+
});
|
|
3515
|
+
await this.sessions.close();
|
|
3516
|
+
const prior = this.runtime.status(this.root);
|
|
3517
|
+
if (prior) this.runtime.save(this.root, { ...prior, state: "stopped" });
|
|
3518
|
+
this.state.close();
|
|
3519
|
+
this.lock.close();
|
|
3520
|
+
})();
|
|
2648
3521
|
}
|
|
2649
3522
|
};
|
|
2650
|
-
function message(error) {
|
|
2651
|
-
return error instanceof Error ? error.message : String(error);
|
|
2652
|
-
}
|
|
2653
3523
|
|
|
2654
3524
|
// apps/client/sync-command.ts
|
|
2655
|
-
var help2 = `Usage: teammy sync [
|
|
3525
|
+
var help2 = `Usage: teammy sync on | off [--root DIR]
|
|
2656
3526
|
|
|
2657
|
-
|
|
2658
|
-
|
|
2659
|
-
--once Run one check
|
|
2660
|
-
--
|
|
2661
|
-
--
|
|
2662
|
-
--adopt-baseline Accept existing server history only if every published file matches locally
|
|
2663
|
-
--restore-missing Restore missing mirror files; existing local edits are still preserved
|
|
2664
|
-
--retry Retry file/session publications paused by a previous failure
|
|
2665
|
-
--sessions-only Collect sessions without applying shared files
|
|
2666
|
-
-h, --help Show this help
|
|
3527
|
+
on Start background sync and resume after login
|
|
3528
|
+
off Stop this project's sync, including after login
|
|
3529
|
+
--once Run one check without changing the enabled state
|
|
3530
|
+
--foreground Run in this terminal for diagnostics (turn background sync off first)
|
|
3531
|
+
--json Machine-readable output
|
|
2667
3532
|
|
|
2668
|
-
|
|
2669
|
-
|
|
2670
|
-
|
|
3533
|
+
Advanced: --state-dir DIR, --interval-ms N, --settle-ms N,
|
|
3534
|
+
--restore-missing, --adopt-baseline, --retry, --sessions-only
|
|
3535
|
+
Use teammy status or teammy logs for diagnostics.
|
|
2671
3536
|
`;
|
|
2672
3537
|
async function runSyncCommand(args, version) {
|
|
2673
|
-
const
|
|
2674
|
-
|
|
3538
|
+
const action = ["on", "off"].includes(args[0] ?? "") ? args[0] : void 0;
|
|
3539
|
+
const { values } = parseArgs3({
|
|
3540
|
+
args: action ? args.slice(1) : args,
|
|
2675
3541
|
options: {
|
|
2676
|
-
|
|
2677
|
-
"state-dir": { type: "string", default: join10(homedir2(), ".teammy", "client") },
|
|
3542
|
+
...locationOptions,
|
|
2678
3543
|
once: { type: "boolean" },
|
|
3544
|
+
foreground: { type: "boolean" },
|
|
2679
3545
|
"interval-ms": { type: "string" },
|
|
2680
3546
|
"settle-ms": { type: "string" },
|
|
2681
3547
|
"adopt-baseline": { type: "boolean" },
|
|
2682
3548
|
"restore-missing": { type: "boolean" },
|
|
2683
3549
|
retry: { type: "boolean" },
|
|
2684
|
-
"sessions-only": { type: "boolean" }
|
|
2685
|
-
help: { type: "boolean", short: "h" }
|
|
3550
|
+
"sessions-only": { type: "boolean" }
|
|
2686
3551
|
}
|
|
2687
3552
|
});
|
|
2688
|
-
if (values.help) {
|
|
3553
|
+
if (values.help || !action && !values.once && !values.foreground) {
|
|
2689
3554
|
process.stdout.write(help2);
|
|
2690
3555
|
return 0;
|
|
2691
3556
|
}
|
|
2692
|
-
|
|
2693
|
-
|
|
2694
|
-
|
|
2695
|
-
|
|
2696
|
-
|
|
2697
|
-
|
|
2698
|
-
|
|
2699
|
-
|
|
2700
|
-
|
|
2701
|
-
|
|
2702
|
-
|
|
2703
|
-
|
|
3557
|
+
if (action && (values.once || values.foreground) || values.once && values.foreground)
|
|
3558
|
+
throw new Error("Choose sync on, sync off, --once or --foreground.");
|
|
3559
|
+
const options = {};
|
|
3560
|
+
for (const [key, target, min] of [
|
|
3561
|
+
["interval-ms", "intervalMs", 1e3],
|
|
3562
|
+
["settle-ms", "settleMs", 0]
|
|
3563
|
+
]) {
|
|
3564
|
+
if (values[key] === void 0) continue;
|
|
3565
|
+
const n = Number(values[key]);
|
|
3566
|
+
if (!Number.isSafeInteger(n) || n < min || n > 864e5)
|
|
3567
|
+
throw new Error("Invalid sync timing options.");
|
|
3568
|
+
options[target] = n;
|
|
3569
|
+
}
|
|
3570
|
+
if (values["adopt-baseline"]) options.adoptBaseline = true;
|
|
3571
|
+
if (values["restore-missing"]) options.restoreMissing = true;
|
|
3572
|
+
if (values.retry) options.retry = true;
|
|
3573
|
+
if (values["sessions-only"]) options.sessionsOnly = true;
|
|
3574
|
+
if (action && Object.keys(options).length)
|
|
3575
|
+
throw new Error(
|
|
3576
|
+
"Timing and recovery overrides require --once or --foreground. Manage normal sync timing in the dashboard."
|
|
3577
|
+
);
|
|
3578
|
+
const root = await workspacePath(values.root), directory = await stateLocation(root, values["state-dir"]);
|
|
3579
|
+
const state = new ClientState(directory), runtime = new RuntimeState(state), log = new ClientLog(directory);
|
|
3580
|
+
try {
|
|
3581
|
+
if (!state.binding(root)) throw new Error("Connect this workspace first.");
|
|
3582
|
+
if (action) {
|
|
3583
|
+
if (action === "on") await verifyWorkspace(state, root);
|
|
3584
|
+
runtime.enable(root, action === "on");
|
|
3585
|
+
if (action === "on") await wakeDaemon(directory, version);
|
|
3586
|
+
else {
|
|
3587
|
+
if (await runningDaemon(directory))
|
|
3588
|
+
await daemonRequest(directory, { method: "wake" }, 45e3);
|
|
3589
|
+
const status = runtime.status(root);
|
|
3590
|
+
if (status) runtime.save(root, { ...status, state: "stopped" });
|
|
3591
|
+
log.write("sync.off_requested", root);
|
|
3592
|
+
}
|
|
3593
|
+
if (values.json) json({ enabled: action === "on", root });
|
|
3594
|
+
else result(action === "on" ? "\u540E\u53F0\u540C\u6B65\u5DF2\u5F00\u542F" : "\u540C\u6B65\u5DF2\u505C\u6B62", root);
|
|
3595
|
+
return 0;
|
|
3596
|
+
}
|
|
3597
|
+
const daemon = await runningDaemon(directory);
|
|
3598
|
+
if (daemon && values.once && !Object.keys(options).length) {
|
|
3599
|
+
const report = await daemonRequest(directory, { method: "once", root }, 6e5);
|
|
3600
|
+
if (values.json) json(report);
|
|
3601
|
+
else result(report.errors.length ? "\u540C\u6B65\u9700\u8981\u5904\u7406" : "\u540C\u6B65\u68C0\u67E5\u5B8C\u6210", report.errors[0]);
|
|
3602
|
+
return report.errors.length ? 1 : 0;
|
|
3603
|
+
}
|
|
3604
|
+
if (runtime.enabled(root))
|
|
3605
|
+
throw new Error(
|
|
3606
|
+
'Run "teammy sync off" before using foreground synchronization or recovery overrides.'
|
|
3607
|
+
);
|
|
3608
|
+
const job = new SyncJob(root, directory, version, log, options);
|
|
3609
|
+
const abort = new AbortController();
|
|
3610
|
+
const stop = () => {
|
|
3611
|
+
abort.abort();
|
|
3612
|
+
void job.close();
|
|
3613
|
+
};
|
|
3614
|
+
process.once("SIGINT", stop);
|
|
3615
|
+
process.once("SIGTERM", stop);
|
|
3616
|
+
let prior = "";
|
|
3617
|
+
try {
|
|
3618
|
+
if (values.foreground && !values.json) result("\u540C\u6B65\u8FD0\u884C\u4E2D", "\u6309 Ctrl+C \u505C\u6B62");
|
|
3619
|
+
do {
|
|
3620
|
+
const report = await (values.once ? job.once() : job.tick());
|
|
3621
|
+
if (values.json) json(report);
|
|
3622
|
+
else if (values.once)
|
|
3623
|
+
result(report.errors.length ? "\u540C\u6B65\u9700\u8981\u5904\u7406" : "\u540C\u6B65\u68C0\u67E5\u5B8C\u6210", report.errors[0]);
|
|
3624
|
+
else if ((report.errors[0] ?? "") !== prior) {
|
|
3625
|
+
result(report.errors.length ? "\u540C\u6B65\u7B49\u5F85\u5904\u7406" : "\u540C\u6B65\u5DF2\u6062\u590D", report.errors[0]);
|
|
3626
|
+
prior = report.errors[0] ?? "";
|
|
3627
|
+
}
|
|
3628
|
+
if (values.once) return report.errors.length ? 1 : 0;
|
|
3629
|
+
await setTimeout4(Math.max(100, job.nextAt - Date.now()), void 0, {
|
|
3630
|
+
signal: abort.signal
|
|
3631
|
+
}).catch((e) => {
|
|
3632
|
+
if (!abort.signal.aborted) throw e;
|
|
3633
|
+
});
|
|
3634
|
+
} while (!abort.signal.aborted);
|
|
3635
|
+
} finally {
|
|
3636
|
+
process.off("SIGINT", stop);
|
|
3637
|
+
process.off("SIGTERM", stop);
|
|
3638
|
+
await job.close();
|
|
3639
|
+
}
|
|
3640
|
+
return 0;
|
|
3641
|
+
} catch (e) {
|
|
3642
|
+
log.write("sync.command_failed", root, diagnostic(e));
|
|
3643
|
+
throw e;
|
|
3644
|
+
} finally {
|
|
3645
|
+
state.close();
|
|
3646
|
+
}
|
|
3647
|
+
}
|
|
3648
|
+
|
|
3649
|
+
// apps/client/runtime/daemon.ts
|
|
3650
|
+
import { createServer } from "node:net";
|
|
3651
|
+
import { chmod as chmod2, unlink as unlink4 } from "node:fs/promises";
|
|
3652
|
+
async function runDaemon(directory, version) {
|
|
3653
|
+
const lock = acquireRootLock("daemon:" + directory, directory), state = new ClientState(directory), runtime = new RuntimeState(state), log = new ClientLog(directory);
|
|
3654
|
+
const jobs = /* @__PURE__ */ new Map(), retry = /* @__PURE__ */ new Map();
|
|
3655
|
+
let closing = false, serial = Promise.resolve();
|
|
3656
|
+
const reconcile = () => {
|
|
3657
|
+
serial = serial.catch(() => {
|
|
3658
|
+
}).then(async () => {
|
|
3659
|
+
if (closing) return;
|
|
3660
|
+
const enabled = new Set(runtime.roots());
|
|
3661
|
+
for (const [root, job] of jobs)
|
|
3662
|
+
if (!enabled.has(root)) {
|
|
3663
|
+
await job.close();
|
|
3664
|
+
jobs.delete(root);
|
|
3665
|
+
log.write("sync.off", root);
|
|
3666
|
+
}
|
|
3667
|
+
for (const root of enabled) {
|
|
3668
|
+
if (jobs.has(root) || oneShots.has(root) || (retry.get(root) ?? 0) > Date.now()) continue;
|
|
3669
|
+
try {
|
|
3670
|
+
jobs.set(root, new SyncJob(root, directory, version, log));
|
|
3671
|
+
retry.delete(root);
|
|
3672
|
+
log.write("sync.on", root);
|
|
3673
|
+
} catch (e) {
|
|
3674
|
+
retry.set(root, Date.now() + 6e4);
|
|
3675
|
+
log.write("sync.start_failed", root, diagnostic(e));
|
|
3676
|
+
runtime.save(root, {
|
|
3677
|
+
state: "waiting",
|
|
3678
|
+
checkedAt: null,
|
|
3679
|
+
lastSuccessAt: runtime.status(root)?.lastSuccessAt ?? null,
|
|
3680
|
+
pendingFiles: 0,
|
|
3681
|
+
pendingDownloads: 0,
|
|
3682
|
+
issue: diagnostic(e)
|
|
3683
|
+
});
|
|
3684
|
+
}
|
|
3685
|
+
}
|
|
3686
|
+
for (const job of jobs.values())
|
|
3687
|
+
if (job.nextAt <= Date.now())
|
|
3688
|
+
void job.tick().catch((e) => {
|
|
3689
|
+
job.nextAt = Date.now() + 6e4;
|
|
3690
|
+
log.write("sync.failed", job.root, diagnostic(e));
|
|
3691
|
+
});
|
|
3692
|
+
});
|
|
3693
|
+
return serial;
|
|
3694
|
+
};
|
|
3695
|
+
const sockets = /* @__PURE__ */ new Set(), temporaryJobs = /* @__PURE__ */ new Set();
|
|
3696
|
+
const oneShots = /* @__PURE__ */ new Map();
|
|
3697
|
+
const server = createServer((socket) => {
|
|
3698
|
+
sockets.add(socket);
|
|
3699
|
+
socket.once("close", () => sockets.delete(socket));
|
|
3700
|
+
let body = "", used = false;
|
|
3701
|
+
socket.setEncoding("utf8");
|
|
3702
|
+
socket.setTimeout(6e5, () => socket.destroy());
|
|
3703
|
+
socket.on("error", () => {
|
|
3704
|
+
});
|
|
3705
|
+
socket.on("data", (chunk) => {
|
|
3706
|
+
if (used) return;
|
|
3707
|
+
body += chunk.toString();
|
|
3708
|
+
if (body.length > 8192) {
|
|
3709
|
+
socket.destroy();
|
|
3710
|
+
return;
|
|
3711
|
+
}
|
|
3712
|
+
if (!body.includes("\n")) return;
|
|
3713
|
+
used = true;
|
|
3714
|
+
void (async () => {
|
|
3715
|
+
const input = RequestSchema.parse(JSON.parse(body.split("\n")[0]));
|
|
3716
|
+
if (closing) throw new Error("Background service is stopping.");
|
|
3717
|
+
if (input.method === "ping") return { pid: process.pid, version };
|
|
3718
|
+
if (input.method === "wake") {
|
|
3719
|
+
retry.clear();
|
|
3720
|
+
await reconcile();
|
|
3721
|
+
return { ok: true };
|
|
3722
|
+
}
|
|
3723
|
+
const root = input.root;
|
|
3724
|
+
if (!root || !state.binding(root)) throw new Error("Workspace is not connected.");
|
|
3725
|
+
if (input.method === "status")
|
|
3726
|
+
return {
|
|
3727
|
+
enabled: runtime.enabled(root),
|
|
3728
|
+
running: jobs.has(root),
|
|
3729
|
+
status: runtime.status(root)
|
|
3730
|
+
};
|
|
3731
|
+
const active = jobs.get(root);
|
|
3732
|
+
if (active) return active.once();
|
|
3733
|
+
if (runtime.enabled(root)) throw new Error("Workspace could not start. Run teammy status.");
|
|
3734
|
+
let work = oneShots.get(root);
|
|
3735
|
+
if (!work) {
|
|
3736
|
+
const job = new SyncJob(root, directory, version, log);
|
|
3737
|
+
temporaryJobs.add(job);
|
|
3738
|
+
work = job.once().finally(async () => {
|
|
3739
|
+
await job.close();
|
|
3740
|
+
temporaryJobs.delete(job);
|
|
3741
|
+
oneShots.delete(root);
|
|
3742
|
+
});
|
|
3743
|
+
oneShots.set(root, work);
|
|
3744
|
+
}
|
|
3745
|
+
return work;
|
|
3746
|
+
})().then(
|
|
3747
|
+
(result2) => socket.end(JSON.stringify({ ok: true, result: result2 }) + "\n"),
|
|
3748
|
+
(e) => socket.end(JSON.stringify({ ok: false, error: diagnostic(e) }) + "\n")
|
|
3749
|
+
);
|
|
3750
|
+
});
|
|
3751
|
+
});
|
|
3752
|
+
let timer;
|
|
3753
|
+
let resolveStop = () => {
|
|
3754
|
+
};
|
|
3755
|
+
const stopped = new Promise((resolve9) => {
|
|
3756
|
+
resolveStop = resolve9;
|
|
3757
|
+
});
|
|
2704
3758
|
const stop = () => {
|
|
2705
|
-
|
|
2706
|
-
|
|
3759
|
+
closing = true;
|
|
3760
|
+
resolveStop();
|
|
2707
3761
|
};
|
|
2708
3762
|
try {
|
|
2709
|
-
|
|
2710
|
-
|
|
2711
|
-
|
|
2712
|
-
|
|
2713
|
-
|
|
2714
|
-
|
|
2715
|
-
|
|
2716
|
-
|
|
2717
|
-
|
|
3763
|
+
await prepareSocketDirectory(directory);
|
|
3764
|
+
await unlink4(socketPath(directory)).catch((e) => {
|
|
3765
|
+
if (e.code !== "ENOENT") throw e;
|
|
3766
|
+
});
|
|
3767
|
+
await new Promise((resolve9, reject) => {
|
|
3768
|
+
server.once("error", reject);
|
|
3769
|
+
server.listen(socketPath(directory), () => {
|
|
3770
|
+
server.off("error", reject);
|
|
3771
|
+
resolve9();
|
|
2718
3772
|
});
|
|
2719
|
-
const overrides = {};
|
|
2720
|
-
if (values["interval-ms"] !== void 0) overrides.checkIntervalSeconds = intervalMs / 1e3;
|
|
2721
|
-
if (values["settle-ms"] !== void 0) overrides.fileSettleSeconds = settleMs / 1e3;
|
|
2722
|
-
control = new ControlPlane(state, root, version, overrides, (policy) => {
|
|
2723
|
-
intervalMs = Math.round(policy.checkIntervalSeconds * 1e3);
|
|
2724
|
-
synchronizer?.setSettleMs(Math.round(policy.fileSettleSeconds * 1e3));
|
|
2725
3773
|
});
|
|
2726
|
-
await
|
|
2727
|
-
await synchronizer?.checkCapabilities();
|
|
2728
|
-
if (!values.once) control.start();
|
|
3774
|
+
await chmod2(socketPath(directory), 384);
|
|
2729
3775
|
process.once("SIGINT", stop);
|
|
2730
3776
|
process.once("SIGTERM", stop);
|
|
2731
|
-
|
|
2732
|
-
|
|
2733
|
-
|
|
2734
|
-
|
|
2735
|
-
|
|
2736
|
-
|
|
2737
|
-
|
|
2738
|
-
published: 0,
|
|
2739
|
-
checked: 0,
|
|
2740
|
-
issues: [error instanceof Error ? error.message : "Session synchronization failed."]
|
|
2741
|
-
};
|
|
2742
|
-
}
|
|
2743
|
-
control.updateSync({
|
|
2744
|
-
checkedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
2745
|
-
published: report.published,
|
|
2746
|
-
appliedFiles: report.appliedFiles,
|
|
2747
|
-
pendingFiles: report.waitingFiles + (state.pending(root)?.batch.changes.length ?? 0),
|
|
2748
|
-
sessionSources: Number(
|
|
2749
|
-
state.db.prepare("SELECT COUNT(*) n FROM session_sources WHERE root=?").get(root).n
|
|
2750
|
-
),
|
|
2751
|
-
errors: [...report.issues, ...sessionReport.issues].slice(0, 20).map((message2) => message2.slice(0, 1e3))
|
|
2752
|
-
});
|
|
2753
|
-
if (values.once && control.enabled) await control.tick();
|
|
2754
|
-
const combined = {
|
|
2755
|
-
...report,
|
|
2756
|
-
sessions: sessionReport,
|
|
2757
|
-
control: control.enabled ? control.status : null
|
|
2758
|
-
};
|
|
2759
|
-
process.stdout.write(`${JSON.stringify(combined)}
|
|
2760
|
-
`);
|
|
2761
|
-
if (values.once)
|
|
2762
|
-
return report.issues.length || sessionReport.issues.length || control.enabled && (control.status.configError || control.status.reportError) ? 1 : 0;
|
|
2763
|
-
const finishedAt = Date.now();
|
|
2764
|
-
while (!stopped.signal.aborted && Date.now() - finishedAt < intervalMs)
|
|
2765
|
-
await setTimeout3(Math.min(1e3, intervalMs - (Date.now() - finishedAt)), void 0, {
|
|
2766
|
-
signal: stopped.signal
|
|
2767
|
-
}).catch((error) => {
|
|
2768
|
-
if (!stopped.signal.aborted) throw error;
|
|
2769
|
-
});
|
|
2770
|
-
} while (!stopped.signal.aborted);
|
|
2771
|
-
return 0;
|
|
3777
|
+
log.write("daemon.started", void 0, `version=${version}`);
|
|
3778
|
+
await reconcile();
|
|
3779
|
+
timer = setInterval(
|
|
3780
|
+
() => void reconcile().catch((e) => log.write("daemon.error", void 0, diagnostic(e))),
|
|
3781
|
+
1e3
|
|
3782
|
+
);
|
|
3783
|
+
await stopped;
|
|
2772
3784
|
} finally {
|
|
3785
|
+
closing = true;
|
|
3786
|
+
clearInterval(timer);
|
|
2773
3787
|
process.off("SIGINT", stop);
|
|
2774
3788
|
process.off("SIGTERM", stop);
|
|
2775
|
-
|
|
2776
|
-
|
|
2777
|
-
await
|
|
2778
|
-
|
|
3789
|
+
await serial.catch(() => {
|
|
3790
|
+
});
|
|
3791
|
+
await Promise.all([...jobs.values(), ...temporaryJobs].map((job) => job.close()));
|
|
3792
|
+
await Promise.allSettled(oneShots.values());
|
|
3793
|
+
for (const socket of sockets) socket.destroy();
|
|
3794
|
+
await new Promise((resolve9) => server.close(() => resolve9()));
|
|
3795
|
+
await unlink4(socketPath(directory)).catch(() => {
|
|
3796
|
+
});
|
|
3797
|
+
log.write("daemon.stopped");
|
|
3798
|
+
state.close();
|
|
2779
3799
|
lock.close();
|
|
2780
3800
|
}
|
|
2781
3801
|
}
|
|
@@ -2789,13 +3809,15 @@ Usage:
|
|
|
2789
3809
|
teammy --version Show the installed client version
|
|
2790
3810
|
teammy server-info --url URL Check server connection and protocol capabilities
|
|
2791
3811
|
teammy server-info --help Show connection options
|
|
2792
|
-
teammy connect --url URL [--member ID | --token-file FILE] [--reconnect] [--root DIR] [--state-dir DIR]
|
|
3812
|
+
teammy connect --url URL --project ID [--member ID | --token-file FILE] [--reconnect] [--root DIR] [--state-dir DIR]
|
|
2793
3813
|
teammy status [--root DIR] [--state-dir DIR]
|
|
2794
|
-
teammy sync [--root DIR]
|
|
3814
|
+
teammy sync on | off [--root DIR]
|
|
3815
|
+
teammy logs [--root DIR] Show recent diagnostic events
|
|
2795
3816
|
teammy session --help Collect, read and search Agent sessions
|
|
2796
3817
|
|
|
2797
3818
|
Root defaults to ./workspaces; local state defaults to ~/.teammy/client.
|
|
2798
|
-
|
|
3819
|
+
connect starts background sync; enabled projects resume after login.
|
|
3820
|
+
Use --json for scripts; manage sync timing in the server dashboard.
|
|
2799
3821
|
The hub server is installed separately as @teammy/server (command: teammy-server).
|
|
2800
3822
|
`;
|
|
2801
3823
|
var serverInfoHelp = `Usage: teammy server-info --url URL
|
|
@@ -2822,6 +3844,21 @@ async function runCli(args, version) {
|
|
|
2822
3844
|
process.stderr.write('Install @teammy/server and run "teammy-server" to start the hub.\n');
|
|
2823
3845
|
return 1;
|
|
2824
3846
|
}
|
|
3847
|
+
if (command === "_daemon") {
|
|
3848
|
+
const { values } = parseArgs4({
|
|
3849
|
+
args: args.slice(1),
|
|
3850
|
+
options: { "state-dir": { type: "string" } }
|
|
3851
|
+
});
|
|
3852
|
+
if (!values["state-dir"]) throw new Error("Missing daemon state directory.");
|
|
3853
|
+
const directory = resolve8(values["state-dir"]);
|
|
3854
|
+
try {
|
|
3855
|
+
await runDaemon(directory, version);
|
|
3856
|
+
return 0;
|
|
3857
|
+
} catch (e) {
|
|
3858
|
+
new ClientLog(directory).write("daemon.failed", void 0, diagnostic(e));
|
|
3859
|
+
return 1;
|
|
3860
|
+
}
|
|
3861
|
+
}
|
|
2825
3862
|
if (command === "session") {
|
|
2826
3863
|
try {
|
|
2827
3864
|
return await runSessionCommand(args.slice(1));
|
|
@@ -2838,64 +3875,17 @@ async function runCli(args, version) {
|
|
|
2838
3875
|
try {
|
|
2839
3876
|
return await runSyncCommand(args.slice(1), version);
|
|
2840
3877
|
} catch (error) {
|
|
2841
|
-
process.stderr.write(
|
|
2842
|
-
|
|
2843
|
-
`
|
|
2844
|
-
);
|
|
3878
|
+
process.stderr.write(`${diagnostic(error)}
|
|
3879
|
+
`);
|
|
2845
3880
|
return 1;
|
|
2846
3881
|
}
|
|
2847
3882
|
}
|
|
2848
|
-
if (command === "connect" || command === "status") {
|
|
3883
|
+
if (command === "connect" || command === "status" || command === "logs") {
|
|
2849
3884
|
try {
|
|
2850
|
-
|
|
2851
|
-
|
|
2852
|
-
|
|
2853
|
-
url: { type: "string" },
|
|
2854
|
-
"token-file": { type: "string" },
|
|
2855
|
-
member: { type: "string" },
|
|
2856
|
-
reconnect: { type: "boolean" },
|
|
2857
|
-
root: { type: "string", default: resolve6("workspaces") },
|
|
2858
|
-
"state-dir": { type: "string", default: join11(homedir3(), ".teammy", "client") }
|
|
2859
|
-
}
|
|
2860
|
-
});
|
|
2861
|
-
const rootPath = resolve6(values.root);
|
|
2862
|
-
if (command === "connect") await mkdir7(rootPath, { recursive: true });
|
|
2863
|
-
const root = await realpath6(rootPath);
|
|
2864
|
-
const state = new ClientState(await stateLocation(root, values["state-dir"]));
|
|
2865
|
-
try {
|
|
2866
|
-
if (command === "status") {
|
|
2867
|
-
const binding = state.binding(root);
|
|
2868
|
-
if (!binding) throw new Error("This root is not connected. Run teammy connect first.");
|
|
2869
|
-
const { token: _token, ...publicBinding } = binding;
|
|
2870
|
-
process.stdout.write(
|
|
2871
|
-
`${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)}
|
|
2872
|
-
`
|
|
2873
|
-
);
|
|
2874
|
-
} else {
|
|
2875
|
-
if (!values.url) throw new Error("--url is required.");
|
|
2876
|
-
const connected = await connectProject(
|
|
2877
|
-
state,
|
|
2878
|
-
root,
|
|
2879
|
-
await stateLocation(root, values["state-dir"]),
|
|
2880
|
-
{
|
|
2881
|
-
url: values.url,
|
|
2882
|
-
member: values.member,
|
|
2883
|
-
tokenFile: values["token-file"],
|
|
2884
|
-
reconnect: values.reconnect
|
|
2885
|
-
}
|
|
2886
|
-
);
|
|
2887
|
-
process.stdout.write(`${JSON.stringify(connected, null, 2)}
|
|
3885
|
+
return await runClientCommand(command, args.slice(1), version);
|
|
3886
|
+
} catch (e) {
|
|
3887
|
+
process.stderr.write(`${diagnostic(e)}
|
|
2888
3888
|
`);
|
|
2889
|
-
}
|
|
2890
|
-
} finally {
|
|
2891
|
-
state.close();
|
|
2892
|
-
}
|
|
2893
|
-
return 0;
|
|
2894
|
-
} catch (error) {
|
|
2895
|
-
process.stderr.write(
|
|
2896
|
-
`${error instanceof Error ? error.message : "Unable to open connection."}
|
|
2897
|
-
`
|
|
2898
|
-
);
|
|
2899
3889
|
return 1;
|
|
2900
3890
|
}
|
|
2901
3891
|
}
|
|
@@ -2904,7 +3894,7 @@ async function runCli(args, version) {
|
|
|
2904
3894
|
return 1;
|
|
2905
3895
|
}
|
|
2906
3896
|
try {
|
|
2907
|
-
const { values } =
|
|
3897
|
+
const { values } = parseArgs4({
|
|
2908
3898
|
args: args.slice(1),
|
|
2909
3899
|
options: {
|
|
2910
3900
|
help: { type: "boolean", short: "h" },
|