@amalgm/shell 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (105) hide show
  1. package/PURPOSE.md +73 -0
  2. package/README.md +33 -0
  3. package/dist/automations-host.d.ts +19 -0
  4. package/dist/automations-host.js +41 -0
  5. package/dist/automations-host.js.map +1 -0
  6. package/dist/boot.d.ts +53 -0
  7. package/dist/boot.js +249 -0
  8. package/dist/boot.js.map +1 -0
  9. package/dist/bundle-apps.d.ts +12 -0
  10. package/dist/bundle-apps.js +71 -0
  11. package/dist/bundle-apps.js.map +1 -0
  12. package/dist/bundle-automations.d.ts +8 -0
  13. package/dist/bundle-automations.js +106 -0
  14. package/dist/bundle-automations.js.map +1 -0
  15. package/dist/bundle-bindings.d.ts +8 -0
  16. package/dist/bundle-bindings.js +101 -0
  17. package/dist/bundle-bindings.js.map +1 -0
  18. package/dist/bundle-port.d.ts +14 -0
  19. package/dist/bundle-port.js +166 -0
  20. package/dist/bundle-port.js.map +1 -0
  21. package/dist/chat-host.d.ts +32 -0
  22. package/dist/chat-host.js +167 -0
  23. package/dist/chat-host.js.map +1 -0
  24. package/dist/chat-payloads-host.d.ts +8 -0
  25. package/dist/chat-payloads-host.js +76 -0
  26. package/dist/chat-payloads-host.js.map +1 -0
  27. package/dist/chat-relay-host.d.ts +24 -0
  28. package/dist/chat-relay-host.js +90 -0
  29. package/dist/chat-relay-host.js.map +1 -0
  30. package/dist/cli.d.ts +1 -0
  31. package/dist/cli.js +157 -0
  32. package/dist/cli.js.map +1 -0
  33. package/dist/compose.d.ts +32 -0
  34. package/dist/compose.js +35 -0
  35. package/dist/compose.js.map +1 -0
  36. package/dist/credentials-host.d.ts +7 -0
  37. package/dist/credentials-host.js +143 -0
  38. package/dist/credentials-host.js.map +1 -0
  39. package/dist/diagnostics-host.d.ts +3 -0
  40. package/dist/diagnostics-host.js +92 -0
  41. package/dist/diagnostics-host.js.map +1 -0
  42. package/dist/drivers/claude-code.d.ts +27 -0
  43. package/dist/drivers/claude-code.js +72 -0
  44. package/dist/drivers/claude-code.js.map +1 -0
  45. package/dist/event-triggers-host.d.ts +4 -0
  46. package/dist/event-triggers-host.js +48 -0
  47. package/dist/event-triggers-host.js.map +1 -0
  48. package/dist/events-host.d.ts +9 -0
  49. package/dist/events-host.js +34 -0
  50. package/dist/events-host.js.map +1 -0
  51. package/dist/external-proxy-host.d.ts +15 -0
  52. package/dist/external-proxy-host.js +166 -0
  53. package/dist/external-proxy-host.js.map +1 -0
  54. package/dist/filesystem.d.ts +7 -0
  55. package/dist/filesystem.js +71 -0
  56. package/dist/filesystem.js.map +1 -0
  57. package/dist/forward.d.ts +11 -0
  58. package/dist/forward.js +36 -0
  59. package/dist/forward.js.map +1 -0
  60. package/dist/gateway.d.ts +33 -0
  61. package/dist/gateway.js +85 -0
  62. package/dist/gateway.js.map +1 -0
  63. package/dist/index.d.ts +23 -0
  64. package/dist/index.js +14 -0
  65. package/dist/index.js.map +1 -0
  66. package/dist/local-host.d.ts +8 -0
  67. package/dist/local-host.js +101 -0
  68. package/dist/local-host.js.map +1 -0
  69. package/dist/machine-host.d.ts +56 -0
  70. package/dist/machine-host.js +340 -0
  71. package/dist/machine-host.js.map +1 -0
  72. package/dist/main.d.ts +2 -0
  73. package/dist/main.js +4 -0
  74. package/dist/main.js.map +1 -0
  75. package/dist/parity.d.ts +40 -0
  76. package/dist/parity.js +108 -0
  77. package/dist/parity.js.map +1 -0
  78. package/dist/ports-host.d.ts +8 -0
  79. package/dist/ports-host.js +26 -0
  80. package/dist/ports-host.js.map +1 -0
  81. package/dist/pty-host.d.ts +14 -0
  82. package/dist/pty-host.js +141 -0
  83. package/dist/pty-host.js.map +1 -0
  84. package/dist/release-catalog.d.ts +5 -0
  85. package/dist/release-catalog.js +23 -0
  86. package/dist/release-catalog.js.map +1 -0
  87. package/dist/state-host.d.ts +4 -0
  88. package/dist/state-host.js +67 -0
  89. package/dist/state-host.js.map +1 -0
  90. package/dist/state-store.d.ts +15 -0
  91. package/dist/state-store.js +63 -0
  92. package/dist/state-store.js.map +1 -0
  93. package/dist/user-ground-host.d.ts +58 -0
  94. package/dist/user-ground-host.js +809 -0
  95. package/dist/user-ground-host.js.map +1 -0
  96. package/dist/wire-client.d.ts +44 -0
  97. package/dist/wire-client.js +235 -0
  98. package/dist/wire-client.js.map +1 -0
  99. package/dist/workspace-host.d.ts +5 -0
  100. package/dist/workspace-host.js +73 -0
  101. package/dist/workspace-host.js.map +1 -0
  102. package/dist/workspace-store.d.ts +10 -0
  103. package/dist/workspace-store.js +47 -0
  104. package/dist/workspace-store.js.map +1 -0
  105. package/package.json +55 -0
@@ -0,0 +1,809 @@
1
+ import { createHash, randomUUID } from "node:crypto";
2
+ import { existsSync, lstatSync, readFileSync, readdirSync, readlinkSync, rmSync, symlinkSync, watch, writeFileSync, } from "node:fs";
3
+ import { dirname, extname, join, sep } from "node:path";
4
+ import { buildUserHomeManifest, buildUserManifest, liveMachineStateDir, scopedAmalgmDir, shippedUserHomeDeclaration, } from "@amalgm/core/identity";
5
+ import { CHUNK_BYTES, ENTITY_CLOUD_CONTRACT, ENTITY_CLOUD_SCHEMA_VERSION, canonicalVersion, checkContentManifest, classifyFile, convergeUserGround, createUserGroundEnrollmentPolicy, manifestFromChunks, membershipHash, parseSnapshot, snapshotFromRecords, stableJson, } from "@amalgm/live";
6
+ import Database from "better-sqlite3";
7
+ import { atomicWrite, ensurePrivateDir } from "./filesystem.js";
8
+ import { WireClient, WireRequestError } from "./wire-client.js";
9
+ const PORTABLE_FIELDS = [
10
+ "uuid", "type", "parentUUID", "name", "status", "version",
11
+ "payloadVersion", "transportVersion",
12
+ ];
13
+ /** The production Files port injected into Core. Portable identity remains in
14
+ * Live; this class owns only Node filesystem, SQLite, Watch, and wire effects. */
15
+ export class UserGroundHost {
16
+ options;
17
+ wire;
18
+ watchers = new Map();
19
+ activeIdentity = null;
20
+ rescanTimer = null;
21
+ cloudState = null;
22
+ syncing = null;
23
+ port;
24
+ constructor(options) {
25
+ this.options = options;
26
+ this.wire = new WireClient({
27
+ url: options.tunnelUrl,
28
+ token: options.tunnelToken,
29
+ runtimeVersion: options.runtimeVersion,
30
+ });
31
+ this.port = {
32
+ converge: async (identity) => {
33
+ this.activeIdentity = normalizedIdentity(identity);
34
+ const local = this.localPort(this.activeIdentity);
35
+ const result = await convergeUserGround(this.activeIdentity, {
36
+ sha256Hex,
37
+ cloud: this.cloudPort(this.activeIdentity),
38
+ local,
39
+ });
40
+ return {
41
+ userId: result.userId,
42
+ userEmail: result.userEmail,
43
+ deviceId: result.deviceId,
44
+ localRoot: result.localRoot,
45
+ cloudHead: result.cloudHead,
46
+ mode: result.mode,
47
+ entityCount: result.entityCount,
48
+ watcherRoots: result.watcherRoots,
49
+ watching: result.watching,
50
+ };
51
+ },
52
+ };
53
+ }
54
+ databasePath(identity) {
55
+ return join(liveMachineStateDir(this.userRoot(identity), identity.deviceId), "identity.db");
56
+ }
57
+ rows(identity) {
58
+ return readRows(this.databasePath(identity));
59
+ }
60
+ /** Make every observed filesystem change durable in SQLite, immutable
61
+ * content storage, and the official cloud head before returning. */
62
+ async flush() {
63
+ if (!this.activeIdentity || !this.cloudState)
64
+ return;
65
+ if (!this.syncing) {
66
+ this.syncing = this.syncLocalChanges(this.activeIdentity).finally(() => {
67
+ this.syncing = null;
68
+ });
69
+ }
70
+ await this.syncing;
71
+ }
72
+ async activateRuntimeTunnel(gatewayPort, runtimeToken) {
73
+ await this.flush();
74
+ this.wire.configureRuntime({ gatewayPort, runtimeToken });
75
+ await this.wire.open();
76
+ }
77
+ async stopRuntimeTunnel() {
78
+ this.wire.configureRuntime({});
79
+ await this.wire.close();
80
+ }
81
+ async close() {
82
+ await this.flush();
83
+ if (this.rescanTimer)
84
+ clearTimeout(this.rescanTimer);
85
+ this.rescanTimer = null;
86
+ for (const watcher of this.watchers.values())
87
+ watcher.close();
88
+ this.watchers.clear();
89
+ await this.wire.close();
90
+ }
91
+ userRoot(identity) {
92
+ return scopedAmalgmDir(this.options.amalgmRoot, identity.userEmail);
93
+ }
94
+ cacheDir(identity) {
95
+ return join(liveMachineStateDir(this.userRoot(identity), identity.deviceId), "content");
96
+ }
97
+ cloudPort(identity) {
98
+ return {
99
+ lookup: async (resourceId) => {
100
+ await this.drainOutboxBeforeLookup(identity, resourceId);
101
+ const frame = await this.wire.request({
102
+ type: "private.resource.lookup",
103
+ resource_id: resourceId,
104
+ contract: ENTITY_CLOUD_CONTRACT,
105
+ }, ["private.resource.ready", "private.resource.missing"]);
106
+ if (frame.type === "private.resource.missing")
107
+ return null;
108
+ const ready = readyFromFrame(frame);
109
+ this.cloudState = ready.state;
110
+ return ready.cloud;
111
+ },
112
+ publishInitial: async (input) => {
113
+ const bytes = Buffer.from(stableJson(input.snapshot), "utf8");
114
+ const frame = await this.wire.request({
115
+ type: "private.resource.ensure",
116
+ resource_id: input.resourceId,
117
+ contract: ENTITY_CLOUD_CONTRACT,
118
+ schema_version: ENTITY_CLOUD_SCHEMA_VERSION,
119
+ snapshot_b64: bytes.toString("base64"),
120
+ snapshot_checksum: input.checksum,
121
+ snapshot_bytes: bytes.length,
122
+ }, ["private.resource.ready"]);
123
+ const ready = readyFromFrame(frame);
124
+ const current = ready.cloud;
125
+ this.cloudState = ready.state;
126
+ const created = frame.duplicate === false;
127
+ if (created)
128
+ await this.uploadSnapshotContent(identity, current.resourceId, current.snapshot.records);
129
+ return { created, current };
130
+ },
131
+ };
132
+ }
133
+ localPort(identity) {
134
+ const userRoot = this.userRoot(identity);
135
+ const database = this.databasePath(identity);
136
+ const cacheDir = this.cacheDir(identity);
137
+ return {
138
+ inspect: async () => {
139
+ if (!existsSync(database))
140
+ return null;
141
+ return localValue(identity, userRoot, database);
142
+ },
143
+ initialize: async () => {
144
+ createDeclaredHome({
145
+ identity,
146
+ userRoot,
147
+ declaration: this.options.declaration ?? shippedUserHomeDeclaration,
148
+ now: this.options.now?.() ?? new Date(),
149
+ });
150
+ scanAndRegister({ identity, userRoot, database, cacheDir });
151
+ return localValue(identity, userRoot, database);
152
+ },
153
+ install: async (_who, cloud) => {
154
+ await installCloudGround({
155
+ identity,
156
+ userRoot,
157
+ database,
158
+ cacheDir,
159
+ cloud,
160
+ readContent: (contentHash) => this.downloadContent(cloud.resourceId, contentHash),
161
+ });
162
+ return localValue(identity, userRoot, database);
163
+ },
164
+ watch: async () => {
165
+ this.ensureWatchers(identity);
166
+ return { active: true, roots: 1 };
167
+ },
168
+ };
169
+ }
170
+ async uploadSnapshotContent(identity, resourceId, records) {
171
+ const cache = this.cacheDir(identity);
172
+ const contentHeads = new Set(records
173
+ .filter((record) => ["file.text", "file.binary", "link"].includes(record.type))
174
+ .map((record) => record.payloadVersion)
175
+ .filter((head) => typeof head === "string"));
176
+ for (const contentHash of contentHeads) {
177
+ const bytes = readFileSync(join(cache, `${contentHash}.bin`));
178
+ if (sha256Hex(bytes) !== contentHash)
179
+ throw new Error(`local content ${contentHash} is corrupt`);
180
+ const chunks = [];
181
+ for (let offset = 0; offset < bytes.length; offset += CHUNK_BYTES) {
182
+ const part = bytes.subarray(offset, Math.min(offset + CHUNK_BYTES, bytes.length));
183
+ chunks.push({ sha256: sha256Hex(part), bytes: part.length, data: part });
184
+ }
185
+ const checked = manifestFromChunks(contentHash, chunks);
186
+ if (!checked.ok)
187
+ throw new Error(checked.error);
188
+ for (let index = 0; index < chunks.length; index += 1) {
189
+ const chunk = chunks[index];
190
+ await this.wire.request({
191
+ type: "private.entity-content.put",
192
+ resource_id: resourceId,
193
+ content_hash: contentHash,
194
+ kind: "chunk",
195
+ part_index: index,
196
+ part_count: chunks.length,
197
+ sha256: chunk.sha256,
198
+ bytes: chunk.bytes,
199
+ data_b64: chunk.data.toString("base64"),
200
+ }, ["private.entity-content.stored"]);
201
+ }
202
+ const manifestBytes = Buffer.from(stableJson(checked.value), "utf8");
203
+ await this.wire.request({
204
+ type: "private.entity-content.put",
205
+ resource_id: resourceId,
206
+ content_hash: contentHash,
207
+ kind: "manifest",
208
+ part_index: chunks.length,
209
+ part_count: chunks.length,
210
+ sha256: sha256Hex(manifestBytes),
211
+ bytes: manifestBytes.length,
212
+ data_b64: manifestBytes.toString("base64"),
213
+ }, ["private.entity-content.stored"]);
214
+ }
215
+ }
216
+ async downloadContent(resourceId, contentHash) {
217
+ const manifestFrame = await retryContent(() => this.wire.request({
218
+ type: "private.entity-content.get",
219
+ resource_id: resourceId,
220
+ content_hash: contentHash,
221
+ kind: "manifest",
222
+ part_index: 0,
223
+ }, ["private.entity-content.data"]));
224
+ const manifestBytes = frameBytes(manifestFrame);
225
+ const checked = checkContentManifest(JSON.parse(manifestBytes.toString("utf8")), contentHash);
226
+ if (!checked.ok)
227
+ throw new Error(checked.error);
228
+ const parts = [];
229
+ for (let index = 0; index < checked.value.chunks.length; index += 1) {
230
+ const chunk = checked.value.chunks[index];
231
+ const frame = await this.wire.request({
232
+ type: "private.entity-content.get",
233
+ resource_id: resourceId,
234
+ content_hash: contentHash,
235
+ kind: "chunk",
236
+ part_index: index,
237
+ sha256: chunk.sha256,
238
+ }, ["private.entity-content.data"]);
239
+ const bytes = frameBytes(frame);
240
+ if (bytes.length !== chunk.bytes || sha256Hex(bytes) !== chunk.sha256) {
241
+ throw new Error(`cloud content chunk ${index} is corrupt`);
242
+ }
243
+ parts.push(bytes);
244
+ }
245
+ const bytes = Buffer.concat(parts);
246
+ if (bytes.length !== checked.value.bytes || sha256Hex(bytes) !== contentHash) {
247
+ throw new Error(`cloud content ${contentHash} is corrupt`);
248
+ }
249
+ return bytes;
250
+ }
251
+ ensureWatchers(identity) {
252
+ const root = this.userRoot(identity);
253
+ const ignoreFile = join(root, ".amalgmignore");
254
+ const policy = createUserGroundEnrollmentPolicy(existsSync(ignoreFile) ? readFileSync(ignoreFile, "utf8") : "");
255
+ const wanted = new Set([root]);
256
+ const visit = (directory, base = "") => {
257
+ for (const entry of readdirSync(directory, { withFileTypes: true })) {
258
+ const rel = base ? `${base}/${entry.name}` : entry.name;
259
+ if (!entry.isDirectory() || entry.isSymbolicLink() || !policy(rel))
260
+ continue;
261
+ const child = join(directory, entry.name);
262
+ wanted.add(child);
263
+ visit(child, rel);
264
+ }
265
+ };
266
+ visit(root);
267
+ for (const [directory, watcher] of this.watchers) {
268
+ if (!wanted.has(directory)) {
269
+ watcher.close();
270
+ this.watchers.delete(directory);
271
+ }
272
+ }
273
+ for (const directory of wanted) {
274
+ if (this.watchers.has(directory))
275
+ continue;
276
+ const watcher = watch(directory, (_event, file) => {
277
+ if (String(file || "").split(/[\\/]/).includes(".amalgm"))
278
+ return;
279
+ this.scheduleRescan();
280
+ });
281
+ watcher.on("error", () => {
282
+ watcher.close();
283
+ this.watchers.delete(directory);
284
+ });
285
+ this.watchers.set(directory, watcher);
286
+ }
287
+ }
288
+ scheduleRescan() {
289
+ if (this.rescanTimer)
290
+ clearTimeout(this.rescanTimer);
291
+ this.rescanTimer = setTimeout(() => {
292
+ this.rescanTimer = null;
293
+ const identity = this.activeIdentity;
294
+ if (!identity)
295
+ return;
296
+ void this.flush().then(() => this.ensureWatchers(identity)).catch(() => {
297
+ // The durable outbox remains claimable by the next event or resume.
298
+ });
299
+ }, 150);
300
+ this.rescanTimer.unref();
301
+ }
302
+ async syncLocalChanges(identity) {
303
+ await this.drainOutbox(identity);
304
+ const records = scanAndRegister({
305
+ identity,
306
+ userRoot: this.userRoot(identity),
307
+ database: this.databasePath(identity),
308
+ cacheDir: this.cacheDir(identity),
309
+ });
310
+ const snapshot = snapshotFromRecords(records);
311
+ const checksum = sha256Hex(stableJson(snapshot));
312
+ if (checksum === this.cloudState?.checksum)
313
+ return;
314
+ const state = this.cloudState;
315
+ if (!state)
316
+ throw new Error("cloud state is unavailable for user-ground Watch");
317
+ const database = initializeDatabase(this.databasePath(identity));
318
+ try {
319
+ database.prepare(`
320
+ INSERT INTO cloud_outbox(
321
+ mutation_id, resource_id, authority_epoch, base_version,
322
+ snapshot_checksum, snapshot_json, created_at
323
+ ) VALUES (?, ?, ?, ?, ?, ?, ?)
324
+ `).run(randomUUID(), state.resourceId, state.authorityEpoch, state.headVersion, checksum, stableJson(snapshot), new Date().toISOString());
325
+ }
326
+ finally {
327
+ database.close();
328
+ }
329
+ await this.drainOutbox(identity);
330
+ }
331
+ async drainOutboxBeforeLookup(identity, resourceId) {
332
+ const pending = readOutbox(this.databasePath(identity));
333
+ if (pending.length === 0)
334
+ return;
335
+ const first = pending[0];
336
+ if (first.resourceId !== resourceId) {
337
+ throw new Error("local cloud outbox belongs to a different user-ground resource");
338
+ }
339
+ this.cloudState = {
340
+ resourceId: first.resourceId,
341
+ authorityEpoch: first.authorityEpoch,
342
+ headVersion: first.baseVersion,
343
+ checksum: "",
344
+ };
345
+ await this.drainOutbox(identity);
346
+ }
347
+ async drainOutbox(identity) {
348
+ for (const pending of readOutbox(this.databasePath(identity))) {
349
+ const snapshot = snapshotFromRecords(JSON.parse(pending.snapshotJson).records || []);
350
+ await this.uploadSnapshotContent(identity, pending.resourceId, snapshot.records);
351
+ const frame = await this.wire.request({
352
+ type: "shared.mutation.submit",
353
+ envelope: {
354
+ resourceId: pending.resourceId,
355
+ authorityEpoch: pending.authorityEpoch,
356
+ mutationId: pending.mutationId,
357
+ deviceId: identity.deviceId,
358
+ baseVersion: pending.baseVersion,
359
+ contract: ENTITY_CLOUD_CONTRACT,
360
+ schemaVersion: ENTITY_CLOUD_SCHEMA_VERSION,
361
+ operationKind: "entity.snapshot.replace",
362
+ operation: { kind: "entity.snapshot.replace", snapshot },
363
+ },
364
+ }, ["shared.mutation.ack"]);
365
+ this.cloudState = {
366
+ resourceId: pending.resourceId,
367
+ authorityEpoch: Number(frame.authority_epoch),
368
+ headVersion: Number(frame.version),
369
+ checksum: pending.snapshotChecksum,
370
+ };
371
+ deleteOutbox(this.databasePath(identity), pending.mutationId);
372
+ }
373
+ }
374
+ }
375
+ const sha256Hex = (input) => createHash("sha256").update(input).digest("hex");
376
+ function normalizedIdentity(identity) {
377
+ return {
378
+ userId: String(identity.userId).trim(),
379
+ userEmail: String(identity.userEmail).trim().toLowerCase(),
380
+ deviceId: String(identity.deviceId).trim(),
381
+ };
382
+ }
383
+ function immutableWrite(file, bytes) {
384
+ ensurePrivateDir(dirname(file));
385
+ try {
386
+ writeFileSync(file, bytes, { mode: 0o600, flag: "wx" });
387
+ }
388
+ catch (error) {
389
+ if (error.code !== "EEXIST")
390
+ throw error;
391
+ if (!readFileSync(file).equals(Buffer.from(bytes)))
392
+ throw new Error(`immutable object conflict at ${file}`);
393
+ }
394
+ }
395
+ function initializeDatabase(file) {
396
+ ensurePrivateDir(dirname(file));
397
+ const database = new Database(file);
398
+ database.pragma("journal_mode = WAL");
399
+ database.pragma("synchronous = NORMAL");
400
+ database.exec(`
401
+ CREATE TABLE IF NOT EXISTS ground_identity (
402
+ singleton INTEGER PRIMARY KEY CHECK (singleton = 1),
403
+ user_id TEXT NOT NULL,
404
+ user_email TEXT NOT NULL,
405
+ device_id TEXT NOT NULL
406
+ );
407
+ CREATE TABLE IF NOT EXISTS entities (
408
+ uuid TEXT PRIMARY KEY,
409
+ type TEXT NOT NULL,
410
+ parent_uuid TEXT,
411
+ name TEXT NOT NULL,
412
+ status TEXT NOT NULL,
413
+ version TEXT NOT NULL,
414
+ payload_version TEXT,
415
+ transport_version TEXT,
416
+ relative_path TEXT NOT NULL UNIQUE,
417
+ absolute_path TEXT NOT NULL,
418
+ device_number INTEGER NOT NULL,
419
+ inode INTEGER NOT NULL
420
+ );
421
+ CREATE TABLE IF NOT EXISTS cloud_outbox (
422
+ sequence INTEGER PRIMARY KEY AUTOINCREMENT,
423
+ mutation_id TEXT NOT NULL UNIQUE,
424
+ resource_id TEXT NOT NULL,
425
+ authority_epoch INTEGER NOT NULL,
426
+ base_version INTEGER NOT NULL,
427
+ snapshot_checksum TEXT NOT NULL,
428
+ snapshot_json TEXT NOT NULL,
429
+ created_at TEXT NOT NULL
430
+ );
431
+ `);
432
+ return database;
433
+ }
434
+ function readRows(file) {
435
+ if (!existsSync(file))
436
+ return [];
437
+ const database = new Database(file, { readonly: true });
438
+ try {
439
+ return database.prepare(`
440
+ SELECT uuid, type, parent_uuid AS parentUUID, name, status, version,
441
+ payload_version AS payloadVersion, transport_version AS transportVersion,
442
+ relative_path AS relativePath, absolute_path AS absolutePath,
443
+ device_number AS deviceNumber, inode
444
+ FROM entities ORDER BY uuid
445
+ `).all();
446
+ }
447
+ finally {
448
+ database.close();
449
+ }
450
+ }
451
+ function readOutbox(file) {
452
+ if (!existsSync(file))
453
+ return [];
454
+ const database = initializeDatabase(file);
455
+ try {
456
+ return database.prepare(`
457
+ SELECT mutation_id AS mutationId, resource_id AS resourceId,
458
+ authority_epoch AS authorityEpoch, base_version AS baseVersion,
459
+ snapshot_checksum AS snapshotChecksum, snapshot_json AS snapshotJson
460
+ FROM cloud_outbox ORDER BY sequence
461
+ `).all();
462
+ }
463
+ finally {
464
+ database.close();
465
+ }
466
+ }
467
+ function deleteOutbox(file, mutationId) {
468
+ const database = initializeDatabase(file);
469
+ try {
470
+ database.prepare("DELETE FROM cloud_outbox WHERE mutation_id = ?").run(mutationId);
471
+ }
472
+ finally {
473
+ database.close();
474
+ }
475
+ }
476
+ function portableRecords(file) {
477
+ return readRows(file).map((row) => Object.fromEntries(PORTABLE_FIELDS.map((field) => [field, row[field]])));
478
+ }
479
+ function persistRows(file, identity, rows) {
480
+ const database = initializeDatabase(file);
481
+ try {
482
+ const replaceIdentity = database.prepare(`
483
+ INSERT INTO ground_identity(singleton, user_id, user_email, device_id)
484
+ VALUES(1, ?, ?, ?)
485
+ ON CONFLICT(singleton) DO UPDATE SET
486
+ user_id = excluded.user_id,
487
+ user_email = excluded.user_email,
488
+ device_id = excluded.device_id
489
+ `);
490
+ const insert = database.prepare(`
491
+ INSERT INTO entities(
492
+ uuid, type, parent_uuid, name, status, version, payload_version,
493
+ transport_version, relative_path, absolute_path, device_number, inode
494
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
495
+ `);
496
+ const replace = database.transaction(() => {
497
+ replaceIdentity.run(identity.userId, identity.userEmail, identity.deviceId);
498
+ database.prepare("DELETE FROM entities").run();
499
+ for (const row of rows)
500
+ insert.run(row.record.uuid, row.record.type, row.record.parentUUID, row.record.name, row.record.status, row.record.version, row.record.payloadVersion, row.record.transportVersion, row.relativePath, row.absolutePath, row.deviceNumber, row.inode);
501
+ });
502
+ replace();
503
+ }
504
+ finally {
505
+ database.close();
506
+ }
507
+ }
508
+ function assertDatabaseIdentity(file, identity) {
509
+ const database = new Database(file, { readonly: true });
510
+ try {
511
+ const found = database.prepare("SELECT user_id AS userId, user_email AS userEmail, device_id AS deviceId FROM ground_identity WHERE singleton = 1").get();
512
+ if (!found || found.userId !== identity.userId || found.userEmail !== identity.userEmail
513
+ || found.deviceId !== identity.deviceId) {
514
+ throw new Error("local SQLite identity does not match the authenticated user and device");
515
+ }
516
+ }
517
+ finally {
518
+ database.close();
519
+ }
520
+ }
521
+ function localValue(identity, userRoot, database) {
522
+ assertDatabaseIdentity(database, identity);
523
+ return {
524
+ ...identity,
525
+ localRoot: userRoot,
526
+ sqlitePath: database,
527
+ records: portableRecords(database),
528
+ };
529
+ }
530
+ function createDeclaredHome(input) {
531
+ const { identity, userRoot, declaration, now } = input;
532
+ ensurePrivateDir(userRoot);
533
+ const ignore = join(userRoot, ".amalgmignore");
534
+ if (!existsSync(ignore))
535
+ atomicWrite(ignore, "/.amalgm/\n");
536
+ const userManifestPath = join(userRoot, ".amalgm-user.json");
537
+ if (!existsSync(userManifestPath)) {
538
+ const manifest = buildUserManifest({ userId: identity.userId, email: identity.userEmail }, null, {
539
+ nowIso: () => now.toISOString(),
540
+ });
541
+ atomicWrite(userManifestPath, `${JSON.stringify(manifest, null, 2)}\n`);
542
+ }
543
+ const homeManifest = buildUserHomeManifest(declaration);
544
+ for (const root of declaration.roots)
545
+ ensurePrivateDir(join(userRoot, root.relativePath));
546
+ for (const target of declaration.exactManaged) {
547
+ const path = join(userRoot, target.relativePath);
548
+ if (target.relativePath === target.rootKey)
549
+ continue;
550
+ if (extname(path) === ".json") {
551
+ if (!existsSync(path)) {
552
+ const value = target.key === "home-manifest"
553
+ ? homeManifest
554
+ : { schemaVersion: 1, id: target.toolId || target.key };
555
+ atomicWrite(path, `${JSON.stringify(value, null, 2)}\n`);
556
+ }
557
+ }
558
+ else {
559
+ ensurePrivateDir(path);
560
+ }
561
+ }
562
+ }
563
+ function fileType(bytes) {
564
+ return classifyFile({ binary: bytes.includes(0) });
565
+ }
566
+ function slashPath(path) {
567
+ return path.split(sep).join("/");
568
+ }
569
+ function scanAndRegister(input) {
570
+ const { identity, userRoot, database, cacheDir } = input;
571
+ const existingByPath = new Map(readRows(database).map((row) => [row.relativePath, row.uuid]));
572
+ const ignoreFile = join(userRoot, ".amalgmignore");
573
+ const policy = createUserGroundEnrollmentPolicy(existsSync(ignoreFile) ? readFileSync(ignoreFile, "utf8") : "");
574
+ const entries = [];
575
+ const rootStats = lstatSync(userRoot);
576
+ const rootUuid = existingByPath.get("") || randomUUID();
577
+ entries.push({
578
+ uuid: rootUuid,
579
+ type: "workspace",
580
+ parentUUID: null,
581
+ name: identity.userEmail,
582
+ relativePath: "",
583
+ absolutePath: userRoot,
584
+ payloadVersion: null,
585
+ transportVersion: null,
586
+ deviceNumber: rootStats.dev,
587
+ inode: rootStats.ino,
588
+ });
589
+ const visit = (directory, parentUUID, base = "") => {
590
+ const children = readdirSync(directory, { withFileTypes: true })
591
+ .sort((left, right) => left.name < right.name ? -1 : left.name > right.name ? 1 : 0);
592
+ for (const child of children) {
593
+ const relativePath = base ? `${base}/${child.name}` : child.name;
594
+ if (!policy(relativePath))
595
+ continue;
596
+ const absolutePath = join(directory, child.name);
597
+ const stats = lstatSync(absolutePath);
598
+ const uuid = existingByPath.get(relativePath) || randomUUID();
599
+ let type;
600
+ let payloadVersion = null;
601
+ if (stats.isSymbolicLink()) {
602
+ type = "link";
603
+ const bytes = Buffer.from(readlinkSync(absolutePath), "utf8");
604
+ payloadVersion = sha256Hex(bytes);
605
+ immutableWrite(join(cacheDir, `${payloadVersion}.bin`), bytes);
606
+ }
607
+ else if (stats.isDirectory()) {
608
+ type = existsSync(join(absolutePath, ".git")) ? "repo.git" : "folder";
609
+ }
610
+ else if (stats.isFile()) {
611
+ const bytes = readFileSync(absolutePath);
612
+ type = fileType(bytes);
613
+ payloadVersion = sha256Hex(bytes);
614
+ immutableWrite(join(cacheDir, `${payloadVersion}.bin`), bytes);
615
+ }
616
+ else {
617
+ continue;
618
+ }
619
+ entries.push({
620
+ uuid,
621
+ type,
622
+ parentUUID,
623
+ name: child.name,
624
+ relativePath,
625
+ absolutePath,
626
+ payloadVersion,
627
+ transportVersion: null,
628
+ deviceNumber: stats.dev,
629
+ inode: stats.ino,
630
+ });
631
+ if (stats.isDirectory() && !stats.isSymbolicLink())
632
+ visit(absolutePath, uuid, relativePath);
633
+ }
634
+ };
635
+ visit(userRoot, rootUuid);
636
+ const children = new Map();
637
+ for (const entry of entries) {
638
+ if (entry.parentUUID === null)
639
+ continue;
640
+ const group = children.get(entry.parentUUID) ?? [];
641
+ group.push({ uuid: entry.uuid, name: entry.name });
642
+ children.set(entry.parentUUID, group);
643
+ }
644
+ const rows = entries.map((entry) => {
645
+ const versionPayload = entry.type === "workspace" || entry.type === "folder"
646
+ ? membershipHash(children.get(entry.uuid) ?? [], sha256Hex)
647
+ : entry.payloadVersion;
648
+ const record = {
649
+ uuid: entry.uuid,
650
+ type: entry.type,
651
+ parentUUID: entry.parentUUID,
652
+ name: entry.name,
653
+ status: "active",
654
+ version: canonicalVersion({
655
+ type: entry.type,
656
+ parentUUID: entry.parentUUID,
657
+ name: entry.name,
658
+ status: "active",
659
+ payloadVersion: versionPayload,
660
+ }, sha256Hex),
661
+ payloadVersion: entry.payloadVersion,
662
+ transportVersion: entry.transportVersion,
663
+ };
664
+ return {
665
+ record,
666
+ relativePath: entry.relativePath,
667
+ absolutePath: entry.absolutePath,
668
+ deviceNumber: entry.deviceNumber,
669
+ inode: entry.inode,
670
+ };
671
+ });
672
+ persistRows(database, identity, rows);
673
+ return portableRecords(database);
674
+ }
675
+ function portablePaths(records) {
676
+ const byUuid = new Map(records.map((record) => [record.uuid, record]));
677
+ const memo = new Map();
678
+ const resolving = new Set();
679
+ const resolve = (record) => {
680
+ const found = memo.get(record.uuid);
681
+ if (found !== undefined)
682
+ return found;
683
+ if (resolving.has(record.uuid))
684
+ throw new Error("cloud user-ground graph has a parent cycle");
685
+ resolving.add(record.uuid);
686
+ let path = "";
687
+ if (record.parentUUID !== null) {
688
+ const parent = byUuid.get(record.parentUUID);
689
+ if (!parent)
690
+ throw new Error(`cloud entity ${record.uuid} has no parent`);
691
+ path = [resolve(parent), record.name].filter(Boolean).join("/");
692
+ }
693
+ resolving.delete(record.uuid);
694
+ memo.set(record.uuid, path);
695
+ return path;
696
+ };
697
+ for (const record of records)
698
+ resolve(record);
699
+ return memo;
700
+ }
701
+ function removeRegisteredPaths(userRoot, database) {
702
+ for (const row of readRows(database).sort((left, right) => right.relativePath.split("/").length - left.relativePath.split("/").length)) {
703
+ if (!row.relativePath)
704
+ continue;
705
+ const path = join(userRoot, ...row.relativePath.split("/"));
706
+ rmSync(path, { recursive: true, force: true });
707
+ }
708
+ }
709
+ function installCloudGround(input) {
710
+ const { identity, userRoot, database, cacheDir, cloud, readContent } = input;
711
+ return (async () => {
712
+ ensurePrivateDir(userRoot);
713
+ removeRegisteredPaths(userRoot, database);
714
+ const records = cloud.snapshot.records.filter((record) => record.status === "active");
715
+ const roots = records.filter((record) => record.parentUUID === null);
716
+ if (roots.length !== 1 || roots[0]?.type !== "workspace") {
717
+ throw new Error("cloud user ground must have exactly one workspace root");
718
+ }
719
+ const paths = portablePaths(records);
720
+ const ordered = [...records].sort((left, right) => {
721
+ const leftPath = paths.get(left.uuid);
722
+ const rightPath = paths.get(right.uuid);
723
+ return leftPath.split("/").length - rightPath.split("/").length
724
+ || (leftPath < rightPath ? -1 : leftPath > rightPath ? 1 : 0);
725
+ });
726
+ for (const record of ordered) {
727
+ const relativePath = paths.get(record.uuid);
728
+ const destination = relativePath ? join(userRoot, ...relativePath.split("/")) : userRoot;
729
+ if (record.parentUUID === null) {
730
+ ensurePrivateDir(destination);
731
+ }
732
+ else if (record.type === "folder") {
733
+ if (existsSync(destination))
734
+ throw new Error(`cloud materialization conflicts with ${relativePath}`);
735
+ ensurePrivateDir(destination);
736
+ }
737
+ else if (["file.text", "file.binary", "link"].includes(record.type)) {
738
+ if (!record.payloadVersion)
739
+ throw new Error(`cloud leaf ${record.uuid} has no content head`);
740
+ if (existsSync(destination))
741
+ throw new Error(`cloud materialization conflicts with ${relativePath}`);
742
+ const bytes = await readContent(record.payloadVersion);
743
+ ensurePrivateDir(dirname(destination));
744
+ if (record.type === "link")
745
+ symlinkSync(bytes.toString("utf8"), destination);
746
+ else
747
+ atomicWrite(destination, bytes);
748
+ immutableWrite(join(cacheDir, `${record.payloadVersion}.bin`), bytes);
749
+ }
750
+ else {
751
+ throw new Error(`materialization for ${record.type} requires its dedicated adapter`);
752
+ }
753
+ }
754
+ const rows = records.map((record) => {
755
+ const relativePath = paths.get(record.uuid);
756
+ const absolutePath = relativePath ? join(userRoot, ...relativePath.split("/")) : userRoot;
757
+ const stats = lstatSync(absolutePath);
758
+ return {
759
+ record,
760
+ relativePath,
761
+ absolutePath,
762
+ deviceNumber: stats.dev,
763
+ inode: stats.ino,
764
+ };
765
+ });
766
+ persistRows(database, identity, rows);
767
+ })();
768
+ }
769
+ function readyFromFrame(frame) {
770
+ const bytes = Buffer.from(String(frame.snapshot_b64 || ""), "base64");
771
+ const cloud = {
772
+ resourceId: String(frame.resource_id || ""),
773
+ version: Number(frame.head_version),
774
+ checksum: String(frame.snapshot_checksum || ""),
775
+ snapshot: parseSnapshot(bytes),
776
+ };
777
+ return {
778
+ cloud,
779
+ state: {
780
+ resourceId: cloud.resourceId,
781
+ authorityEpoch: Number(frame.authority_epoch),
782
+ headVersion: cloud.version,
783
+ checksum: cloud.checksum,
784
+ },
785
+ };
786
+ }
787
+ function frameBytes(frame) {
788
+ const bytes = Buffer.from(String(frame.data_b64 || ""), "base64");
789
+ if (bytes.length !== Number(frame.bytes) || sha256Hex(bytes) !== String(frame.sha256 || "")) {
790
+ throw new Error("wire content bytes do not match their checksum");
791
+ }
792
+ return bytes;
793
+ }
794
+ async function retryContent(read) {
795
+ let last;
796
+ for (let attempt = 0; attempt < 10; attempt += 1) {
797
+ try {
798
+ return await read();
799
+ }
800
+ catch (error) {
801
+ last = error;
802
+ if (!(error instanceof WireRequestError) || error.code !== "entity_content_not_found")
803
+ throw error;
804
+ await new Promise((resolve) => setTimeout(resolve, 250 * (attempt + 1)));
805
+ }
806
+ }
807
+ throw last;
808
+ }
809
+ //# sourceMappingURL=user-ground-host.js.map