@hydra-acp/archiver 0.1.6 → 0.1.8
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 +151 -5
- package/dist/backend/encrypted.js +59 -0
- package/dist/backend/encrypted.js.map +1 -0
- package/dist/backend/factory.js +10 -1
- package/dist/backend/factory.js.map +1 -1
- package/dist/backend/fs.js +20 -15
- package/dist/backend/fs.js.map +1 -1
- package/dist/backend/google-drive.js +95 -64
- package/dist/backend/google-drive.js.map +1 -1
- package/dist/backend/s3.js +80 -0
- package/dist/backend/s3.js.map +1 -0
- package/dist/config.js +115 -41
- package/dist/config.js.map +1 -1
- package/dist/envelope.js +5 -2
- package/dist/envelope.js.map +1 -1
- package/dist/index.js +55 -12
- package/dist/index.js.map +1 -1
- package/dist/keygen.js +26 -0
- package/dist/keygen.js.map +1 -0
- package/dist/pull-loop.js +4 -0
- package/dist/pull-loop.js.map +1 -1
- package/package.json +2 -1
package/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# hydra-acp-archiver
|
|
2
2
|
|
|
3
|
-
Sync extension for [hydra-acp](https://github.com/smagnuso/hydra-acp). Keeps your agent sessions in sync across the machines you work on by uploading session bundles to a shared backend (Google Drive, plain filesystem) after every turn and importing peers' bundles in the background.
|
|
3
|
+
Sync extension for [hydra-acp](https://github.com/smagnuso/hydra-acp). Keeps your agent sessions in sync across the machines you work on by uploading session bundles to a shared backend (Google Drive, S3, plain filesystem) after every turn and importing peers' bundles in the background. Supports optional AES-256-GCM encryption so data at rest is unreadable without your shared key.
|
|
4
4
|
|
|
5
5
|
Runs as a daemon-managed process, so sessions started on machine A become available on machine B without any manual export/import.
|
|
6
6
|
|
|
@@ -78,7 +78,7 @@ You provide your own OAuth client (Google's terms make it impractical to ship a
|
|
|
78
78
|
6. Run:
|
|
79
79
|
|
|
80
80
|
```sh
|
|
81
|
-
hydra-acp-archiver login
|
|
81
|
+
hydra-acp-archiver gdrive login
|
|
82
82
|
```
|
|
83
83
|
|
|
84
84
|
Your browser opens to Google's consent screen. The "Google hasn't verified this app" interstitial is expected for an unverified personal-use client — click **Advanced → Go to (unsafe)** and approve. The redirect lands on a transient local server, the archiver writes `~/.hydra-acp/archiver-google-token.json` (mode 0600), and you're done.
|
|
@@ -91,6 +91,44 @@ Repeat the same login flow on each machine that should sync, using the **same Go
|
|
|
91
91
|
|
|
92
92
|
If you want a different Drive folder per "team" or "context," set `HYDRA_ACP_ARCHIVER_DRIVE_FOLDER` on every participating machine to the same value.
|
|
93
93
|
|
|
94
|
+
## S3 backend
|
|
95
|
+
|
|
96
|
+
Works with any S3-compatible store: AWS S3, Cloudflare R2, Backblaze B2, MinIO, Wasabi, etc.
|
|
97
|
+
|
|
98
|
+
Credentials come from the standard AWS SDK chain — environment variables (`AWS_ACCESS_KEY_ID` / `AWS_SECRET_ACCESS_KEY`), `~/.aws/credentials`, or an IAM role. Set `AWS_PROFILE` to use a non-default credentials profile.
|
|
99
|
+
|
|
100
|
+
```json
|
|
101
|
+
{
|
|
102
|
+
"extensions": {
|
|
103
|
+
"hydra-acp-archiver": {
|
|
104
|
+
"command": ["node"],
|
|
105
|
+
"args": ["/home/you/dev/hydra-acp-archiver/dist/index.js"],
|
|
106
|
+
"env": {
|
|
107
|
+
"HYDRA_ACP_ARCHIVER_BACKEND": "s3",
|
|
108
|
+
"HYDRA_ACP_ARCHIVER_S3_BUCKET": "my-hydra-archive",
|
|
109
|
+
"HYDRA_ACP_ARCHIVER_S3_REGION": "us-east-1"
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
For S3-compatible endpoints (R2, MinIO, B2):
|
|
117
|
+
|
|
118
|
+
```json
|
|
119
|
+
"HYDRA_ACP_ARCHIVER_S3_ENDPOINT": "https://<accountid>.r2.cloudflarestorage.com"
|
|
120
|
+
```
|
|
121
|
+
|
|
122
|
+
**Multi-machine setup**: point every machine at the same bucket.
|
|
123
|
+
|
|
124
|
+
**Data separation**: the archiver sets a prefix automatically so users sharing a bucket don't see each other's sessions:
|
|
125
|
+
- **Encryption on**: prefix defaults to the key fingerprint (e.g. `a1b2c3d4e5f6a7b8/`). Everyone sharing the same key lands in the same namespace; different keys land in different namespaces. Rotating the key changes the prefix, causing a full re-upload with the new key.
|
|
126
|
+
- **Encryption off**: prefix defaults to your OS username (e.g. `alice/`).
|
|
127
|
+
|
|
128
|
+
Set `HYDRA_ACP_ARCHIVER_PREFIX` explicitly to override the default.
|
|
129
|
+
|
|
130
|
+
**Delete semantics**: blobs are hard-deleted. Enable [S3 bucket versioning](https://docs.aws.amazon.com/AmazonS3/latest/userguide/Versioning.html) if you want recoverability.
|
|
131
|
+
|
|
94
132
|
## Filesystem backend
|
|
95
133
|
|
|
96
134
|
Useful for testing locally, or for pointing at a folder that a separate sync tool (Syncthing, Dropbox client) already mirrors across your machines.
|
|
@@ -110,13 +148,120 @@ Useful for testing locally, or for pointing at a folder that a separate sync too
|
|
|
110
148
|
}
|
|
111
149
|
```
|
|
112
150
|
|
|
151
|
+
## Encryption
|
|
152
|
+
|
|
153
|
+
All three backends support optional AES-256-GCM encryption. When enabled, blobs are encrypted before upload and decrypted after download — the backend never sees plaintext.
|
|
154
|
+
|
|
155
|
+
Generate a key on one machine:
|
|
156
|
+
|
|
157
|
+
```sh
|
|
158
|
+
hydra-acp-archiver keygen
|
|
159
|
+
```
|
|
160
|
+
|
|
161
|
+
This writes a 32-byte key as a hex file (mode 0600) and prints:
|
|
162
|
+
|
|
163
|
+
```
|
|
164
|
+
key written to ~/.hydra-acp/archiver-key
|
|
165
|
+
key fingerprint: a1b2c3d4e5f6a7b8
|
|
166
|
+
|
|
167
|
+
Copy ~/.hydra-acp/archiver-key to every machine that should share this archive.
|
|
168
|
+
Then add to your extension env config:
|
|
169
|
+
"HYDRA_ACP_ARCHIVER_KEY_PATH": "/home/you/.hydra-acp/archiver-key"
|
|
170
|
+
```
|
|
171
|
+
|
|
172
|
+
Copy the key file to each machine, then set `HYDRA_ACP_ARCHIVER_KEY_PATH` in every extension env block. The fingerprint lets you verify all machines have the same key.
|
|
173
|
+
|
|
174
|
+
**Key mismatch**: if a blob was encrypted with a different key, the archiver logs `key mismatch` and skips that blob rather than failing with a confusing crypto error.
|
|
175
|
+
|
|
176
|
+
**Enabling encryption on an existing archive**: the key fingerprint becomes the new prefix, so old unencrypted blobs (written under a different prefix) are simply ignored — no decryption errors. The cold sweep re-uploads all sessions under the new prefix. To clean up old blobs, remove them from the backend manually.
|
|
177
|
+
|
|
178
|
+
## Prefix and namespace isolation
|
|
179
|
+
|
|
180
|
+
Every backend supports an optional namespace prefix. The archiver sets one automatically so multiple users sharing the same storage container don't interfere with each other.
|
|
181
|
+
|
|
182
|
+
### How the prefix is chosen
|
|
183
|
+
|
|
184
|
+
1. **`HYDRA_ACP_ARCHIVER_PREFIX` is set** — that value is used verbatim. Set it to `""` to disable the prefix entirely (not recommended on shared storage).
|
|
185
|
+
2. **Encryption is on, no explicit prefix** — the key fingerprint (first 8 bytes of SHA-256 of the key, encoded as 16 hex chars followed by `/`) is used, e.g. `a1b2c3d4e5f6a7b8/`. Everyone sharing the same key lands in the same namespace; different keys land in different namespaces. Rotating the key changes the prefix, which isolates new blobs from old ones.
|
|
186
|
+
3. **Encryption off, no explicit prefix** — the OS username (sanitized to lowercase alphanumeric + hyphens, followed by `/`) is used, e.g. `alice/`.
|
|
187
|
+
|
|
188
|
+
### Host segregation
|
|
189
|
+
|
|
190
|
+
Within the user prefix, each machine writes to its own subdirectory named by its host ID. The full storage path is `<user-prefix><hostId>/<lineageId>.hydra.archive`. For example:
|
|
191
|
+
|
|
192
|
+
```
|
|
193
|
+
a1b2c3d4e5f6a7b8/
|
|
194
|
+
alice-macbook/uuid1.hydra.archive
|
|
195
|
+
alice-macbook/uuid2.hydra.archive
|
|
196
|
+
alice-desktop/uuid3.hydra.archive
|
|
197
|
+
```
|
|
198
|
+
|
|
199
|
+
The pull loop reads across all host subdirectories (seeing all peers' sessions) but skips its own host's files entirely — cheaper than parsing envelopes for self-loop detection. The session-ID check remains as a second layer for edge cases.
|
|
200
|
+
|
|
201
|
+
Set `HYDRA_ACP_ARCHIVER_HOST_ID` to override the default (`os.hostname()` sanitized). Useful in containers or after a machine rename. Use the same value consistently across daemon restarts on a machine — changing it creates a new subdirectory and triggers a full re-upload.
|
|
202
|
+
|
|
203
|
+
### What the prefix does per backend
|
|
204
|
+
|
|
205
|
+
| Backend | Prefix behaviour |
|
|
206
|
+
|---|---|
|
|
207
|
+
| **S3** | Prepended to the S3 object key. `ListObjectsV2` is called with the `Prefix` parameter so only matching objects are fetched — efficient on large shared buckets. |
|
|
208
|
+
| **fs** | Resolved as a subdirectory of the archive directory. Files land at `<dir>/<prefix>/<key>`. Each prefix gets its own isolated directory. |
|
|
209
|
+
| **Google Drive** | Prepended to the Drive filename. Drive has no native directory structure within a folder, so `alice/uuid.hydra.archive` is literally the filename. `list()` filters client-side. |
|
|
210
|
+
|
|
211
|
+
### Key rotation and re-upload
|
|
212
|
+
|
|
213
|
+
When encryption is enabled and you regenerate the key (`hydra-acp-archiver keygen`), the fingerprint prefix changes. The new prefix namespace is empty, so the cold sweep on the next daemon start re-uploads all sessions encrypted with the new key. Old blobs under the previous prefix are simply ignored — no decryption errors, no manual cleanup required (though you can delete the old prefix from the bucket/dir when convenient).
|
|
214
|
+
|
|
215
|
+
## Configuration file
|
|
216
|
+
|
|
217
|
+
Instead of setting environment variables in `config.json`, you can use a plain-text config file at `~/.hydra-acp/archiver.conf` (override with `HYDRA_ACP_ARCHIVER_CONF`). Environment variables always take precedence over file values.
|
|
218
|
+
|
|
219
|
+
```sh
|
|
220
|
+
# ~/.hydra-acp/archiver.conf
|
|
221
|
+
# All keys are optional — omit any you don't need.
|
|
222
|
+
# Env vars override these values when both are set.
|
|
223
|
+
|
|
224
|
+
BACKEND=s3
|
|
225
|
+
|
|
226
|
+
# S3
|
|
227
|
+
S3_BUCKET=my-hydra-archive
|
|
228
|
+
S3_REGION=us-east-1
|
|
229
|
+
# S3_ENDPOINT=https://<accountid>.r2.cloudflarestorage.com
|
|
230
|
+
|
|
231
|
+
# Encryption
|
|
232
|
+
KEY_PATH=/home/you/.hydra-acp/archiver-key
|
|
233
|
+
|
|
234
|
+
# Identity
|
|
235
|
+
HOST_ID=alice-macbook
|
|
236
|
+
|
|
237
|
+
# Google Drive
|
|
238
|
+
# GOOGLE_CREDENTIALS=/home/you/.hydra-acp/archiver-google-credentials.json
|
|
239
|
+
# DRIVE_FOLDER=hydra-acp-archive
|
|
240
|
+
|
|
241
|
+
# Tuning (rarely needed)
|
|
242
|
+
# DEBOUNCE_MS=5000
|
|
243
|
+
# PULL_MS=60000
|
|
244
|
+
# DEBUG=false
|
|
245
|
+
```
|
|
246
|
+
|
|
247
|
+
Restrict permissions on the file if it contains sensitive paths: `chmod 600 ~/.hydra-acp/archiver.conf`.
|
|
248
|
+
|
|
249
|
+
The file is optional — if it doesn't exist the archiver falls back to environment variables only.
|
|
250
|
+
|
|
113
251
|
## Environment variables
|
|
114
252
|
|
|
115
253
|
| Variable | Default | Notes |
|
|
116
254
|
| --- | --- | --- |
|
|
117
|
-
| `
|
|
255
|
+
| `HYDRA_ACP_ARCHIVER_CONF` | `~/.hydra-acp/archiver.conf` | Path to the config file |
|
|
256
|
+
| `HYDRA_ACP_ARCHIVER_BACKEND` | `google-drive` | `google-drive` \| `fs` \| `s3` |
|
|
118
257
|
| `HYDRA_ACP_ARCHIVER_DRIVE_FOLDER` | `hydra-acp-archive` | Drive folder name |
|
|
119
258
|
| `HYDRA_ACP_ARCHIVER_FS_DIR` | `~/.hydra-acp/archive` | Used when backend is `fs` |
|
|
259
|
+
| `HYDRA_ACP_ARCHIVER_S3_BUCKET` | — | Bucket name (required for `s3`) |
|
|
260
|
+
| `HYDRA_ACP_ARCHIVER_S3_REGION` | AWS SDK default | AWS region |
|
|
261
|
+
| `HYDRA_ACP_ARCHIVER_S3_ENDPOINT` | — | Custom endpoint for R2/MinIO/B2 |
|
|
262
|
+
| `HYDRA_ACP_ARCHIVER_PREFIX` | auto (fingerprint or username) | User-level namespace prefix applied by all backends |
|
|
263
|
+
| `HYDRA_ACP_ARCHIVER_HOST_ID` | `os.hostname()` (sanitized) | Host identifier used as a subdirectory under the user prefix |
|
|
264
|
+
| `HYDRA_ACP_ARCHIVER_KEY_PATH` | — | Path to encryption key file; if unset, encryption is off |
|
|
120
265
|
| `HYDRA_ACP_ARCHIVER_GOOGLE_CREDENTIALS` | `~/.hydra-acp/archiver-google-credentials.json` | OAuth client JSON from GCP |
|
|
121
266
|
| `HYDRA_ACP_ARCHIVER_CONFIG` | `~/.hydra-acp/archiver.config.js` | Rule file (optional) |
|
|
122
267
|
| `HYDRA_ACP_ARCHIVER_POLL_MS` | `2000` | Session discovery cadence |
|
|
@@ -155,15 +300,16 @@ Return `false` to skip an upload. Any other value (including `undefined`) archiv
|
|
|
155
300
|
- `~/.hydra-acp/archiver-state.json` — per-lineage cache of last uploaded hash + last seen remote upload, used for self-loop suppression. Safe to delete; archiver will rebuild it.
|
|
156
301
|
- `~/.hydra-acp/archiver-google-credentials.json` — OAuth client JSON you downloaded.
|
|
157
302
|
- `~/.hydra-acp/archiver-google-token.json` — refresh + access token (mode 0600). Re-run `hydra-acp-archiver login` to refresh.
|
|
303
|
+
- `~/.hydra-acp/archiver-key` — encryption key (mode 0600), written by `hydra-acp-archiver keygen`. Copy this file to every machine in your sync group. Keep it safe — losing it means losing access to encrypted blobs.
|
|
158
304
|
|
|
159
305
|
## Troubleshooting
|
|
160
306
|
|
|
161
307
|
- **`Missing HYDRA_ACP_TOKEN env var`** — you ran the archiver directly instead of via the daemon. Run it as a registered extension.
|
|
162
|
-
- **`No Google OAuth token at …`** — run `hydra-acp-archiver login` first.
|
|
308
|
+
- **`No Google OAuth token at …`** — run `hydra-acp-archiver gdrive login` first.
|
|
163
309
|
- **`OAuth credentials file not found`** — follow the **First-time Google setup** steps to download the client JSON from GCP Console.
|
|
164
310
|
- **Files aren't appearing in Drive** — check `~/.hydra-acp/extensions/hydra-acp-archiver.log` for errors. Common: consent-screen test-user list doesn't include your Google account.
|
|
165
311
|
- **Two machines kept overwriting each other** — that's last-writer-wins working as designed if both are actively editing the same session. Avoid editing the same session on two machines simultaneously; one of them will lose its diff. A future `activeOn` claim-lock will close this gap.
|
|
166
312
|
|
|
167
313
|
## Backends
|
|
168
314
|
|
|
169
|
-
Current: `google-drive`, `fs`. Adding a new one means implementing the `SyncBackend` interface in `src/backend/types.ts` and wiring it into `src/backend/factory.ts`.
|
|
315
|
+
Current: `google-drive`, `fs`, `s3`. Adding a new one means implementing the `SyncBackend` interface in `src/backend/types.ts` and wiring it into `src/backend/factory.ts`. Encryption is handled by the `EncryptedBackend` wrapper in `src/backend/encrypted.ts` — new backends get it for free.
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { createCipheriv, createDecipheriv, createHash, randomBytes, } from "node:crypto";
|
|
2
|
+
const VERSION = 1;
|
|
3
|
+
const VERSION_LEN = 1;
|
|
4
|
+
const FINGERPRINT_LEN = 8;
|
|
5
|
+
const IV_LEN = 12;
|
|
6
|
+
const TAG_LEN = 16;
|
|
7
|
+
// Offset to the start of ciphertext within a blob.
|
|
8
|
+
const HEADER_LEN = VERSION_LEN + FINGERPRINT_LEN + IV_LEN;
|
|
9
|
+
export class EncryptedBackend {
|
|
10
|
+
inner;
|
|
11
|
+
key;
|
|
12
|
+
keyFingerprint;
|
|
13
|
+
constructor(inner, key) {
|
|
14
|
+
this.inner = inner;
|
|
15
|
+
this.key = key;
|
|
16
|
+
if (key.length !== 32)
|
|
17
|
+
throw new Error("encryption key must be 32 bytes");
|
|
18
|
+
this.keyFingerprint = createHash("sha256").update(key).digest().subarray(0, FINGERPRINT_LEN);
|
|
19
|
+
}
|
|
20
|
+
init() {
|
|
21
|
+
return this.inner.init();
|
|
22
|
+
}
|
|
23
|
+
list() {
|
|
24
|
+
return this.inner.list();
|
|
25
|
+
}
|
|
26
|
+
delete(key) {
|
|
27
|
+
return this.inner.delete(key);
|
|
28
|
+
}
|
|
29
|
+
async put(key, data) {
|
|
30
|
+
const iv = randomBytes(IV_LEN);
|
|
31
|
+
const cipher = createCipheriv("aes-256-gcm", this.key, iv);
|
|
32
|
+
const ciphertext = Buffer.concat([cipher.update(data), cipher.final()]);
|
|
33
|
+
const tag = cipher.getAuthTag();
|
|
34
|
+
await this.inner.put(key, Buffer.concat([Buffer.from([VERSION]), this.keyFingerprint, iv, ciphertext, tag]));
|
|
35
|
+
}
|
|
36
|
+
async get(key) {
|
|
37
|
+
const blob = await this.inner.get(key);
|
|
38
|
+
if (blob.length < HEADER_LEN + TAG_LEN)
|
|
39
|
+
throw new Error(`encrypted blob too short for key ${key}`);
|
|
40
|
+
const version = blob[0];
|
|
41
|
+
if (version !== VERSION)
|
|
42
|
+
throw new Error(`unsupported encryption version ${version} for key ${key}: upgrade the archiver`);
|
|
43
|
+
const fingerprint = blob.subarray(VERSION_LEN, VERSION_LEN + FINGERPRINT_LEN);
|
|
44
|
+
if (!fingerprint.equals(this.keyFingerprint))
|
|
45
|
+
throw new Error(`key mismatch: blob ${key} was encrypted with a different key`);
|
|
46
|
+
const iv = blob.subarray(VERSION_LEN + FINGERPRINT_LEN, HEADER_LEN);
|
|
47
|
+
const tag = blob.subarray(blob.length - TAG_LEN);
|
|
48
|
+
const ciphertext = blob.subarray(HEADER_LEN, blob.length - TAG_LEN);
|
|
49
|
+
const decipher = createDecipheriv("aes-256-gcm", this.key, iv);
|
|
50
|
+
decipher.setAuthTag(tag);
|
|
51
|
+
try {
|
|
52
|
+
return Buffer.concat([decipher.update(ciphertext), decipher.final()]);
|
|
53
|
+
}
|
|
54
|
+
catch {
|
|
55
|
+
throw new Error(`blob ${key} failed authentication — data may be corrupted`);
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
//# sourceMappingURL=encrypted.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"encrypted.js","sourceRoot":"","sources":["../../src/backend/encrypted.ts"],"names":[],"mappings":"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"}
|
package/dist/backend/factory.js
CHANGED
|
@@ -1,14 +1,23 @@
|
|
|
1
1
|
import { FsBackend } from "./fs.js";
|
|
2
2
|
import { GoogleDriveBackend } from "./google-drive.js";
|
|
3
|
+
import { S3Backend } from "./s3.js";
|
|
3
4
|
export function makeBackend(config) {
|
|
4
5
|
switch (config.backend) {
|
|
5
6
|
case "fs":
|
|
6
|
-
return new FsBackend({ dir: config.fsDir });
|
|
7
|
+
return new FsBackend({ dir: config.fsDir, prefix: config.prefix });
|
|
7
8
|
case "google-drive":
|
|
8
9
|
return new GoogleDriveBackend({
|
|
9
10
|
credentialsPath: config.credentialsPath,
|
|
10
11
|
tokenPath: config.tokenPath,
|
|
11
12
|
folderName: config.driveFolderName,
|
|
13
|
+
prefix: config.prefix,
|
|
14
|
+
});
|
|
15
|
+
case "s3":
|
|
16
|
+
return new S3Backend({
|
|
17
|
+
bucket: config.s3Bucket,
|
|
18
|
+
region: config.s3Region,
|
|
19
|
+
endpoint: config.s3Endpoint,
|
|
20
|
+
prefix: config.prefix,
|
|
12
21
|
});
|
|
13
22
|
default: {
|
|
14
23
|
const exhaustive = config.backend;
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"factory.js","sourceRoot":"","sources":["../../src/backend/factory.ts"],"names":[],"mappings":"AACA,OAAO,EAAE,SAAS,EAAE,MAAM,SAAS,CAAC;AACpC,OAAO,EAAE,kBAAkB,EAAE,MAAM,mBAAmB,CAAC;
|
|
1
|
+
{"version":3,"file":"factory.js","sourceRoot":"","sources":["../../src/backend/factory.ts"],"names":[],"mappings":"AACA,OAAO,EAAE,SAAS,EAAE,MAAM,SAAS,CAAC;AACpC,OAAO,EAAE,kBAAkB,EAAE,MAAM,mBAAmB,CAAC;AACvD,OAAO,EAAE,SAAS,EAAE,MAAM,SAAS,CAAC;AAGpC,MAAM,UAAU,WAAW,CAAC,MAAc;IACxC,QAAQ,MAAM,CAAC,OAAO,EAAE,CAAC;QACvB,KAAK,IAAI;YACP,OAAO,IAAI,SAAS,CAAC,EAAE,GAAG,EAAE,MAAM,CAAC,KAAK,EAAE,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,CAAC,CAAC;QACrE,KAAK,cAAc;YACjB,OAAO,IAAI,kBAAkB,CAAC;gBAC5B,eAAe,EAAE,MAAM,CAAC,eAAe;gBACvC,SAAS,EAAE,MAAM,CAAC,SAAS;gBAC3B,UAAU,EAAE,MAAM,CAAC,eAAe;gBAClC,MAAM,EAAE,MAAM,CAAC,MAAM;aACtB,CAAC,CAAC;QACL,KAAK,IAAI;YACP,OAAO,IAAI,SAAS,CAAC;gBACnB,MAAM,EAAE,MAAM,CAAC,QAAQ;gBACvB,MAAM,EAAE,MAAM,CAAC,QAAQ;gBACvB,QAAQ,EAAE,MAAM,CAAC,UAAU;gBAC3B,MAAM,EAAE,MAAM,CAAC,MAAM;aACtB,CAAC,CAAC;QACL,OAAO,CAAC,CAAC,CAAC;YACR,MAAM,UAAU,GAAU,MAAM,CAAC,OAAO,CAAC;YACzC,MAAM,IAAI,KAAK,CAAC,oBAAoB,MAAM,CAAC,UAAU,CAAC,EAAE,CAAC,CAAC;QAC5D,CAAC;IACH,CAAC;AACH,CAAC"}
|
package/dist/backend/fs.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { mkdir, readFile, readdir, rename, stat, unlink, writeFile, } from "node:fs/promises";
|
|
2
|
-
import { resolve } from "node:path";
|
|
2
|
+
import { dirname, resolve } from "node:path";
|
|
3
3
|
import { logger } from "../util/log.js";
|
|
4
4
|
const log = logger("backend.fs");
|
|
5
5
|
// Filesystem-backed implementation. Useful for tests, for users who want
|
|
@@ -7,17 +7,22 @@ const log = logger("backend.fs");
|
|
|
7
7
|
// directory, and for development against a local daemon.
|
|
8
8
|
export class FsBackend {
|
|
9
9
|
opts;
|
|
10
|
+
root;
|
|
10
11
|
constructor(opts) {
|
|
11
12
|
this.opts = opts;
|
|
13
|
+
this.root = opts.prefix !== "" ? resolve(opts.dir, opts.prefix) : opts.dir;
|
|
12
14
|
}
|
|
13
15
|
async init() {
|
|
14
|
-
await mkdir(this.
|
|
15
|
-
log.info(`fs backend ready at ${this.
|
|
16
|
+
await mkdir(this.root, { recursive: true });
|
|
17
|
+
log.info(`fs backend ready at ${this.root}`);
|
|
16
18
|
}
|
|
17
19
|
async list() {
|
|
20
|
+
return this.walk(this.root, "");
|
|
21
|
+
}
|
|
22
|
+
async walk(dir, rel) {
|
|
18
23
|
let names;
|
|
19
24
|
try {
|
|
20
|
-
names = await readdir(
|
|
25
|
+
names = await readdir(dir);
|
|
21
26
|
}
|
|
22
27
|
catch (err) {
|
|
23
28
|
const e = err;
|
|
@@ -31,17 +36,16 @@ export class FsBackend {
|
|
|
31
36
|
if (name.startsWith(".") || name.endsWith(".tmp")) {
|
|
32
37
|
continue;
|
|
33
38
|
}
|
|
34
|
-
const full = resolve(
|
|
39
|
+
const full = resolve(dir, name);
|
|
40
|
+
const entryRel = rel !== "" ? `${rel}/${name}` : name;
|
|
35
41
|
try {
|
|
36
42
|
const s = await stat(full);
|
|
37
|
-
if (
|
|
38
|
-
|
|
43
|
+
if (s.isDirectory()) {
|
|
44
|
+
out.push(...await this.walk(full, entryRel));
|
|
45
|
+
}
|
|
46
|
+
else if (s.isFile()) {
|
|
47
|
+
out.push({ key: entryRel, size: s.size, modifiedAt: s.mtime.toISOString() });
|
|
39
48
|
}
|
|
40
|
-
out.push({
|
|
41
|
-
key: name,
|
|
42
|
-
size: s.size,
|
|
43
|
-
modifiedAt: s.mtime.toISOString(),
|
|
44
|
-
});
|
|
45
49
|
}
|
|
46
50
|
catch (err) {
|
|
47
51
|
log.debug(`stat ${full}: ${err.message}`);
|
|
@@ -50,17 +54,18 @@ export class FsBackend {
|
|
|
50
54
|
return out;
|
|
51
55
|
}
|
|
52
56
|
async get(key) {
|
|
53
|
-
return readFile(resolve(this.
|
|
57
|
+
return readFile(resolve(this.root, key));
|
|
54
58
|
}
|
|
55
59
|
async put(key, data) {
|
|
56
|
-
const dest = resolve(this.
|
|
60
|
+
const dest = resolve(this.root, key);
|
|
57
61
|
const tmp = `${dest}.tmp`;
|
|
62
|
+
await mkdir(dirname(dest), { recursive: true });
|
|
58
63
|
await writeFile(tmp, data);
|
|
59
64
|
await rename(tmp, dest);
|
|
60
65
|
}
|
|
61
66
|
async delete(key) {
|
|
62
67
|
try {
|
|
63
|
-
await unlink(resolve(this.
|
|
68
|
+
await unlink(resolve(this.root, key));
|
|
64
69
|
}
|
|
65
70
|
catch (err) {
|
|
66
71
|
const e = err;
|
package/dist/backend/fs.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"fs.js","sourceRoot":"","sources":["../../src/backend/fs.ts"],"names":[],"mappings":"AAAA,OAAO,EACL,KAAK,EACL,QAAQ,EACR,OAAO,EACP,MAAM,EACN,IAAI,EACJ,MAAM,EACN,SAAS,GACV,MAAM,kBAAkB,CAAC;AAC1B,OAAO,EAAE,OAAO,EAAE,MAAM,WAAW,CAAC;
|
|
1
|
+
{"version":3,"file":"fs.js","sourceRoot":"","sources":["../../src/backend/fs.ts"],"names":[],"mappings":"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"}
|
|
@@ -8,10 +8,21 @@ const FILE_MIME = "application/json";
|
|
|
8
8
|
// Drive-backed implementation. Uses the drive.file scope, which limits
|
|
9
9
|
// visibility to files the app created plus those explicitly handed to
|
|
10
10
|
// it — so the archiver never sees the user's other Drive contents.
|
|
11
|
+
//
|
|
12
|
+
// The prefix is resolved into real Drive subfolders on init(). A prefix
|
|
13
|
+
// of "a1b2c3d4/alice-macbook/" creates:
|
|
14
|
+
// hydra-acp-archive/
|
|
15
|
+
// a1b2c3d4/
|
|
16
|
+
// alice-macbook/
|
|
17
|
+
// <lineageId>.hydra.archive
|
|
18
|
+
//
|
|
19
|
+
// list() recurses into all immediate subfolders of the prefix folder so the
|
|
20
|
+
// sync backend (prefix = "user/") can see files from all host subfolders.
|
|
11
21
|
export class GoogleDriveBackend {
|
|
12
22
|
opts;
|
|
13
23
|
drive;
|
|
14
|
-
|
|
24
|
+
// The Drive folder ID corresponding to the resolved prefix path.
|
|
25
|
+
prefixFolderId;
|
|
15
26
|
constructor(opts) {
|
|
16
27
|
this.opts = opts;
|
|
17
28
|
}
|
|
@@ -21,39 +32,24 @@ export class GoogleDriveBackend {
|
|
|
21
32
|
tokenPath: this.opts.tokenPath,
|
|
22
33
|
});
|
|
23
34
|
this.drive = google.drive({ version: "v3", auth });
|
|
24
|
-
|
|
25
|
-
|
|
35
|
+
// Walk the root folder name + each prefix segment, ensuring each exists.
|
|
36
|
+
let currentId = await this.ensureFolder(this.opts.folderName, undefined);
|
|
37
|
+
const segments = this.opts.prefix
|
|
38
|
+
.split("/")
|
|
39
|
+
.filter((s) => s !== "");
|
|
40
|
+
for (const seg of segments) {
|
|
41
|
+
currentId = await this.ensureFolder(seg, currentId);
|
|
42
|
+
}
|
|
43
|
+
this.prefixFolderId = currentId;
|
|
44
|
+
log.info(`google-drive backend ready: folder="${this.opts.folderName}" prefix="${this.opts.prefix}" id=${this.prefixFolderId}`);
|
|
26
45
|
}
|
|
27
46
|
async list() {
|
|
28
|
-
|
|
29
|
-
const parent = this.requireFolderId();
|
|
30
|
-
const entries = [];
|
|
31
|
-
let pageToken;
|
|
32
|
-
do {
|
|
33
|
-
const res = await drive.files.list({
|
|
34
|
-
q: `'${parent}' in parents and trashed = false`,
|
|
35
|
-
fields: "nextPageToken, files(id, name, size, modifiedTime)",
|
|
36
|
-
spaces: "drive",
|
|
37
|
-
pageSize: 200,
|
|
38
|
-
...(pageToken !== undefined ? { pageToken } : {}),
|
|
39
|
-
});
|
|
40
|
-
for (const f of res.data.files ?? []) {
|
|
41
|
-
if (!f.name) {
|
|
42
|
-
continue;
|
|
43
|
-
}
|
|
44
|
-
entries.push({
|
|
45
|
-
key: f.name,
|
|
46
|
-
size: typeof f.size === "string" ? Number.parseInt(f.size, 10) : 0,
|
|
47
|
-
modifiedAt: f.modifiedTime ?? new Date(0).toISOString(),
|
|
48
|
-
});
|
|
49
|
-
}
|
|
50
|
-
pageToken = res.data.nextPageToken ?? undefined;
|
|
51
|
-
} while (pageToken);
|
|
52
|
-
return entries;
|
|
47
|
+
return this.listFolder(this.requirePrefixFolderId(), "");
|
|
53
48
|
}
|
|
54
49
|
async get(key) {
|
|
55
50
|
const drive = this.requireDrive();
|
|
56
|
-
const
|
|
51
|
+
const { parentId, name } = await this.resolveKey(key);
|
|
52
|
+
const fileId = await this.findFileId(name, parentId);
|
|
57
53
|
if (!fileId) {
|
|
58
54
|
throw new Error(`google-drive: no file named ${key}`);
|
|
59
55
|
}
|
|
@@ -62,85 +58,120 @@ export class GoogleDriveBackend {
|
|
|
62
58
|
}
|
|
63
59
|
async put(key, data) {
|
|
64
60
|
const drive = this.requireDrive();
|
|
65
|
-
const
|
|
66
|
-
const existing = await this.findFileId(
|
|
67
|
-
const media = {
|
|
68
|
-
mimeType: FILE_MIME,
|
|
69
|
-
body: Readable.from(data),
|
|
70
|
-
};
|
|
61
|
+
const { parentId, name } = await this.resolveKey(key);
|
|
62
|
+
const existing = await this.findFileId(name, parentId);
|
|
63
|
+
const media = { mimeType: FILE_MIME, body: Readable.from(data) };
|
|
71
64
|
if (existing) {
|
|
72
|
-
await drive.files.update({
|
|
73
|
-
fileId: existing,
|
|
74
|
-
media,
|
|
75
|
-
});
|
|
65
|
+
await drive.files.update({ fileId: existing, media });
|
|
76
66
|
return;
|
|
77
67
|
}
|
|
78
68
|
await drive.files.create({
|
|
79
|
-
requestBody: {
|
|
80
|
-
name: key,
|
|
81
|
-
mimeType: FILE_MIME,
|
|
82
|
-
parents: [parent],
|
|
83
|
-
},
|
|
69
|
+
requestBody: { name, mimeType: FILE_MIME, parents: [parentId] },
|
|
84
70
|
media,
|
|
85
71
|
fields: "id",
|
|
86
72
|
});
|
|
87
73
|
}
|
|
88
74
|
async delete(key) {
|
|
89
|
-
const
|
|
90
|
-
const fileId = await this.findFileId(
|
|
91
|
-
if (!fileId)
|
|
75
|
+
const { parentId, name } = await this.resolveKey(key);
|
|
76
|
+
const fileId = await this.findFileId(name, parentId);
|
|
77
|
+
if (!fileId)
|
|
92
78
|
return;
|
|
93
|
-
}
|
|
94
79
|
// Trash rather than hard-delete — recoverable for 30 days.
|
|
95
|
-
await
|
|
80
|
+
await this.requireDrive().files.update({
|
|
96
81
|
fileId,
|
|
97
82
|
requestBody: { trashed: true },
|
|
98
83
|
});
|
|
99
84
|
}
|
|
100
85
|
requireDrive() {
|
|
101
|
-
if (!this.drive)
|
|
86
|
+
if (!this.drive)
|
|
102
87
|
throw new Error("GoogleDriveBackend used before init()");
|
|
103
|
-
}
|
|
104
88
|
return this.drive;
|
|
105
89
|
}
|
|
106
|
-
|
|
107
|
-
if (!this.
|
|
90
|
+
requirePrefixFolderId() {
|
|
91
|
+
if (!this.prefixFolderId)
|
|
108
92
|
throw new Error("GoogleDriveBackend used before init()");
|
|
93
|
+
return this.prefixFolderId;
|
|
94
|
+
}
|
|
95
|
+
// Resolve a key that may contain a host subdirectory segment
|
|
96
|
+
// (e.g. "alice-macbook/uuid.hydra.archive") into a (parentId, filename)
|
|
97
|
+
// pair, creating intermediate folders as needed.
|
|
98
|
+
async resolveKey(key) {
|
|
99
|
+
const parts = key.split("/");
|
|
100
|
+
const name = parts[parts.length - 1];
|
|
101
|
+
let parentId = this.requirePrefixFolderId();
|
|
102
|
+
for (const seg of parts.slice(0, -1)) {
|
|
103
|
+
parentId = await this.ensureFolder(seg, parentId);
|
|
109
104
|
}
|
|
110
|
-
return
|
|
105
|
+
return { parentId, name };
|
|
111
106
|
}
|
|
112
|
-
|
|
107
|
+
// Recursive list: returns files in folderId and all immediate subfolders,
|
|
108
|
+
// with keys relative to folderId (e.g. "alice-macbook/uuid.hydra.archive").
|
|
109
|
+
async listFolder(folderId, relPath) {
|
|
110
|
+
const drive = this.requireDrive();
|
|
111
|
+
const entries = [];
|
|
112
|
+
let pageToken;
|
|
113
|
+
do {
|
|
114
|
+
const res = await drive.files.list({
|
|
115
|
+
q: `'${folderId}' in parents and trashed = false`,
|
|
116
|
+
fields: "nextPageToken, files(id, name, size, modifiedTime, mimeType)",
|
|
117
|
+
spaces: "drive",
|
|
118
|
+
pageSize: 200,
|
|
119
|
+
...(pageToken !== undefined ? { pageToken } : {}),
|
|
120
|
+
});
|
|
121
|
+
for (const f of res.data.files ?? []) {
|
|
122
|
+
if (!f.name)
|
|
123
|
+
continue;
|
|
124
|
+
const entryRel = relPath !== "" ? `${relPath}/${f.name}` : f.name;
|
|
125
|
+
if (f.mimeType === FOLDER_MIME) {
|
|
126
|
+
entries.push(...await this.listFolder(f.id, entryRel));
|
|
127
|
+
}
|
|
128
|
+
else {
|
|
129
|
+
entries.push({
|
|
130
|
+
key: entryRel,
|
|
131
|
+
size: typeof f.size === "string" ? Number.parseInt(f.size, 10) : 0,
|
|
132
|
+
modifiedAt: f.modifiedTime ?? new Date(0).toISOString(),
|
|
133
|
+
});
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
pageToken = res.data.nextPageToken ?? undefined;
|
|
137
|
+
} while (pageToken);
|
|
138
|
+
return entries;
|
|
139
|
+
}
|
|
140
|
+
// Ensure a folder with the given name exists under parentId (or at root if
|
|
141
|
+
// parentId is undefined). Returns the folder's Drive ID.
|
|
142
|
+
async ensureFolder(name, parentId) {
|
|
113
143
|
const drive = this.requireDrive();
|
|
114
144
|
const escaped = name.replace(/'/g, "\\'");
|
|
145
|
+
const parentClause = parentId !== undefined
|
|
146
|
+
? `'${parentId}' in parents and `
|
|
147
|
+
: "";
|
|
115
148
|
const res = await drive.files.list({
|
|
116
|
-
q:
|
|
149
|
+
q: `${parentClause}name = '${escaped}' and mimeType = '${FOLDER_MIME}' and trashed = false`,
|
|
117
150
|
fields: "files(id, name)",
|
|
118
151
|
spaces: "drive",
|
|
119
152
|
pageSize: 10,
|
|
120
153
|
});
|
|
121
154
|
const existing = res.data.files?.[0];
|
|
122
|
-
if (existing?.id)
|
|
155
|
+
if (existing?.id)
|
|
123
156
|
return existing.id;
|
|
124
|
-
}
|
|
125
157
|
const created = await drive.files.create({
|
|
126
158
|
requestBody: {
|
|
127
159
|
name,
|
|
128
160
|
mimeType: FOLDER_MIME,
|
|
161
|
+
...(parentId !== undefined ? { parents: [parentId] } : {}),
|
|
129
162
|
},
|
|
130
163
|
fields: "id",
|
|
131
164
|
});
|
|
132
|
-
if (!created.data.id)
|
|
165
|
+
if (!created.data.id)
|
|
133
166
|
throw new Error("google-drive: folder create returned no id");
|
|
134
|
-
}
|
|
135
167
|
log.info(`created Drive folder "${name}" id=${created.data.id}`);
|
|
136
168
|
return created.data.id;
|
|
137
169
|
}
|
|
138
|
-
async findFileId(
|
|
170
|
+
async findFileId(name, parentId) {
|
|
139
171
|
const drive = this.requireDrive();
|
|
140
|
-
const
|
|
141
|
-
const escaped = key.replace(/'/g, "\\'");
|
|
172
|
+
const escaped = name.replace(/'/g, "\\'");
|
|
142
173
|
const res = await drive.files.list({
|
|
143
|
-
q: `'${
|
|
174
|
+
q: `'${parentId}' in parents and name = '${escaped}' and trashed = false`,
|
|
144
175
|
fields: "files(id, name)",
|
|
145
176
|
spaces: "drive",
|
|
146
177
|
pageSize: 2,
|