@hydra-acp/archiver 0.1.16 → 0.1.17

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 (55) hide show
  1. package/dist/acp/attach.js +1 -201
  2. package/dist/acp/protocol.js +1 -13
  3. package/dist/archive-loop.js +1 -134
  4. package/dist/backend/encrypted.js +1 -59
  5. package/dist/backend/factory.js +1 -28
  6. package/dist/backend/fs.js +1 -78
  7. package/dist/backend/google-drive.js +1 -182
  8. package/dist/backend/s3.js +5 -189
  9. package/dist/backend/types.js +0 -2
  10. package/dist/bridge.js +1 -100
  11. package/dist/cold-sweep.js +1 -54
  12. package/dist/config.js +1 -159
  13. package/dist/daemon.js +1 -81
  14. package/dist/discovery.js +1 -82
  15. package/dist/envelope.js +1 -123
  16. package/dist/index.js +15 -241
  17. package/dist/keygen.js +8 -26
  18. package/dist/oauth/google.js +6 -195
  19. package/dist/pull-loop.js +1 -147
  20. package/dist/rule.js +1 -37
  21. package/dist/setup/conf-writer.js +4 -85
  22. package/dist/setup/downloads-scan.js +1 -44
  23. package/dist/setup/prompts.js +14 -123
  24. package/dist/setup/wizard.js +17 -415
  25. package/dist/state.js +1 -129
  26. package/dist/util/aws-credentials.js +1 -82
  27. package/dist/util/log.js +2 -46
  28. package/package.json +5 -4
  29. package/dist/acp/attach.js.map +0 -1
  30. package/dist/acp/protocol.js.map +0 -1
  31. package/dist/archive-loop.js.map +0 -1
  32. package/dist/backend/encrypted.js.map +0 -1
  33. package/dist/backend/factory.js.map +0 -1
  34. package/dist/backend/fs.js.map +0 -1
  35. package/dist/backend/google-drive.js.map +0 -1
  36. package/dist/backend/s3.js.map +0 -1
  37. package/dist/backend/types.js.map +0 -1
  38. package/dist/bridge.js.map +0 -1
  39. package/dist/cold-sweep.js.map +0 -1
  40. package/dist/config.js.map +0 -1
  41. package/dist/daemon.js.map +0 -1
  42. package/dist/discovery.js.map +0 -1
  43. package/dist/envelope.js.map +0 -1
  44. package/dist/index.js.map +0 -1
  45. package/dist/keygen.js.map +0 -1
  46. package/dist/oauth/google.js.map +0 -1
  47. package/dist/pull-loop.js.map +0 -1
  48. package/dist/rule.js.map +0 -1
  49. package/dist/setup/conf-writer.js.map +0 -1
  50. package/dist/setup/downloads-scan.js.map +0 -1
  51. package/dist/setup/prompts.js.map +0 -1
  52. package/dist/setup/wizard.js.map +0 -1
  53. package/dist/state.js.map +0 -1
  54. package/dist/util/aws-credentials.js.map +0 -1
  55. package/dist/util/log.js.map +0 -1
@@ -1,201 +1 @@
1
- import { EventEmitter } from "node:events";
2
- import { readFileSync } from "node:fs";
3
- import { WebSocket } from "ws";
4
- import { logger } from "../util/log.js";
5
- const pkg = JSON.parse(readFileSync(new URL("../../package.json", import.meta.url), "utf8"));
6
- import { ACP_PROTOCOL_VERSION, isNotification, isRequest, isResponse, } from "./protocol.js";
7
- const log = logger("acp");
8
- export class AcpAttach extends EventEmitter {
9
- opts;
10
- ws;
11
- nextId = 1;
12
- pending = new Map();
13
- connected = false;
14
- constructor(opts) {
15
- super();
16
- this.opts = opts;
17
- }
18
- get sessionId() {
19
- return this.opts.sessionId;
20
- }
21
- get isConnected() {
22
- return this.connected;
23
- }
24
- start() {
25
- log.debug(`connecting ${this.opts.daemonWsUrl} for ${this.opts.sessionId}`);
26
- const subprotocols = ["acp.v1", `hydra-acp-token.${this.opts.token}`];
27
- let ws;
28
- try {
29
- ws = new WebSocket(this.opts.daemonWsUrl, subprotocols);
30
- }
31
- catch (err) {
32
- this.emit("error", err);
33
- return;
34
- }
35
- this.ws = ws;
36
- ws.on("open", () => {
37
- this.connected = true;
38
- log.info(`ws open ${this.opts.sessionId}`);
39
- void this.handshake()
40
- .then(() => {
41
- this.emit("open");
42
- })
43
- .catch((err) => {
44
- this.emit("error", err);
45
- try {
46
- this.ws?.close();
47
- }
48
- catch {
49
- void 0;
50
- }
51
- });
52
- });
53
- ws.on("message", (data, isBinary) => {
54
- if (isBinary) {
55
- return;
56
- }
57
- const text = data.toString("utf8");
58
- try {
59
- const parsed = JSON.parse(text);
60
- this.onMessage(parsed);
61
- }
62
- catch (err) {
63
- log.warn(`parse error on ${this.opts.sessionId}: ${err.message}; raw=${text.slice(0, 200)}`);
64
- }
65
- });
66
- ws.on("error", (err) => {
67
- log.warn(`ws error ${this.opts.sessionId}: ${err.message}`);
68
- this.emit("error", err);
69
- });
70
- ws.on("close", (code, reason) => {
71
- const hadError = code >= 4000 || code === 1006 || code === 1011;
72
- const reasonText = reason.toString("utf8");
73
- this.connected = false;
74
- log.info(`ws closed ${this.opts.sessionId} code=${code}${reasonText ? ` reason=${reasonText}` : ""}`);
75
- for (const [, p] of this.pending) {
76
- p.reject(new Error("ws closed"));
77
- }
78
- this.pending.clear();
79
- this.emit("close", { hadError });
80
- });
81
- }
82
- stop() {
83
- if (this.ws && this.ws.readyState !== WebSocket.CLOSED) {
84
- try {
85
- this.ws.close();
86
- }
87
- catch {
88
- void 0;
89
- }
90
- }
91
- }
92
- async request(method, params) {
93
- const id = this.nextId++;
94
- const msg = {
95
- jsonrpc: "2.0",
96
- id,
97
- method,
98
- ...(params !== undefined ? { params } : {}),
99
- };
100
- this.write(msg);
101
- return new Promise((resolve, reject) => {
102
- this.pending.set(id, {
103
- resolve: (resp) => {
104
- if (resp.error) {
105
- reject(new Error(`${resp.error.code}: ${resp.error.message}`));
106
- }
107
- else {
108
- resolve(resp.result);
109
- }
110
- },
111
- reject,
112
- });
113
- });
114
- }
115
- notify(method, params) {
116
- const msg = {
117
- jsonrpc: "2.0",
118
- method,
119
- ...(params !== undefined ? { params } : {}),
120
- };
121
- this.write(msg);
122
- }
123
- reply(id, result) {
124
- const msg = { jsonrpc: "2.0", id, result };
125
- this.write(msg);
126
- }
127
- replyError(id, code, message) {
128
- const msg = {
129
- jsonrpc: "2.0",
130
- id,
131
- error: { code, message },
132
- };
133
- this.write(msg);
134
- }
135
- async handshake() {
136
- try {
137
- await this.request("initialize", {
138
- protocolVersion: ACP_PROTOCOL_VERSION,
139
- clientCapabilities: {
140
- fs: { readTextFile: false, writeTextFile: false },
141
- terminal: false,
142
- },
143
- });
144
- }
145
- catch (err) {
146
- log.warn(`initialize failed for ${this.opts.sessionId}: ${err.message}`);
147
- }
148
- try {
149
- // historyPolicy: "none" — the archiver pulls full bundles via REST,
150
- // it doesn't need history replayed over the WS attach.
151
- //
152
- // readonly: true — the archiver is a passive observer. On a live
153
- // session this still binds as a real attached client and streams
154
- // session/update. On a cold session it takes the daemon's viewer
155
- // path instead of resurrecting an agent, so re-attaching after an
156
- // idle close can't keep a quiet session on a resurrection treadmill.
157
- const attachResult = await this.request("session/attach", {
158
- sessionId: this.opts.sessionId,
159
- historyPolicy: "none",
160
- // readonly is a hydra extension → rides under _meta["hydra-acp"]
161
- // (session/attach keeps only RFD #533 fields at the top level).
162
- _meta: { "hydra-acp": { readonly: true } },
163
- clientInfo: { name: "hydra-acp-archiver", version: pkg.version },
164
- });
165
- log.info(`attached ${this.opts.sessionId}${attachResult.replayed !== undefined
166
- ? ` replayed=${attachResult.replayed}`
167
- : ""}`);
168
- }
169
- catch (err) {
170
- log.warn(`session/attach failed for ${this.opts.sessionId}: ${err.message}`);
171
- throw err;
172
- }
173
- }
174
- write(msg) {
175
- if (!this.ws || this.ws.readyState !== WebSocket.OPEN) {
176
- log.warn(`drop write to closed ws: ${JSON.stringify(msg)}`);
177
- return;
178
- }
179
- this.ws.send(JSON.stringify(msg));
180
- }
181
- onMessage(m) {
182
- if (isResponse(m)) {
183
- const p = this.pending.get(m.id);
184
- if (p) {
185
- this.pending.delete(m.id);
186
- p.resolve(m);
187
- }
188
- else {
189
- log.debug(`unmatched response id=${String(m.id)}`);
190
- }
191
- this.emit("response", m);
192
- }
193
- else if (isRequest(m)) {
194
- this.emit("request", m);
195
- }
196
- else if (isNotification(m)) {
197
- this.emit("notification", m);
198
- }
199
- }
200
- }
201
- //# sourceMappingURL=attach.js.map
1
+ import{EventEmitter as p}from"node:events";import{readFileSync as d}from"node:fs";import{WebSocket as c}from"ws";import{logger as h}from"../util/log.js";const l=JSON.parse(d(new URL("../../package.json",import.meta.url),"utf8"));import{ACP_PROTOCOL_VERSION as f,isNotification as g,isRequest as u,isResponse as w}from"./protocol.js";const o=h("acp");class E extends p{constructor(e){super();this.opts=e}opts;ws;nextId=1;pending=new Map;connected=!1;get sessionId(){return this.opts.sessionId}get isConnected(){return this.connected}start(){o.debug(`connecting ${this.opts.daemonWsUrl} for ${this.opts.sessionId}`);const e=["acp.v1",`hydra-acp-token.${this.opts.token}`];let t;try{t=new c(this.opts.daemonWsUrl,e)}catch(s){this.emit("error",s);return}this.ws=t,t.on("open",()=>{this.connected=!0,o.info(`ws open ${this.opts.sessionId}`),this.handshake().then(()=>{this.emit("open")}).catch(s=>{this.emit("error",s);try{this.ws?.close()}catch{}})}),t.on("message",(s,r)=>{if(r)return;const i=s.toString("utf8");try{const n=JSON.parse(i);this.onMessage(n)}catch(n){o.warn(`parse error on ${this.opts.sessionId}: ${n.message}; raw=${i.slice(0,200)}`)}}),t.on("error",s=>{o.warn(`ws error ${this.opts.sessionId}: ${s.message}`),this.emit("error",s)}),t.on("close",(s,r)=>{const i=s>=4e3||s===1006||s===1011,n=r.toString("utf8");this.connected=!1,o.info(`ws closed ${this.opts.sessionId} code=${s}${n?` reason=${n}`:""}`);for(const[,a]of this.pending)a.reject(new Error("ws closed"));this.pending.clear(),this.emit("close",{hadError:i})})}stop(){if(this.ws&&this.ws.readyState!==c.CLOSED)try{this.ws.close()}catch{}}async request(e,t){const s=this.nextId++,r={jsonrpc:"2.0",id:s,method:e,...t!==void 0?{params:t}:{}};return this.write(r),new Promise((i,n)=>{this.pending.set(s,{resolve:a=>{a.error?n(new Error(`${a.error.code}: ${a.error.message}`)):i(a.result)},reject:n})})}notify(e,t){const s={jsonrpc:"2.0",method:e,...t!==void 0?{params:t}:{}};this.write(s)}reply(e,t){const s={jsonrpc:"2.0",id:e,result:t};this.write(s)}replyError(e,t,s){const r={jsonrpc:"2.0",id:e,error:{code:t,message:s}};this.write(r)}async handshake(){try{await this.request("initialize",{protocolVersion:f,clientCapabilities:{fs:{readTextFile:!1,writeTextFile:!1},terminal:!1}})}catch(e){o.warn(`initialize failed for ${this.opts.sessionId}: ${e.message}`)}try{const e=await this.request("session/attach",{sessionId:this.opts.sessionId,historyPolicy:"none",_meta:{"hydra-acp":{readonly:!0}},clientInfo:{name:"hydra-acp-archiver",version:l.version}});o.info(`attached ${this.opts.sessionId}${e.replayed!==void 0?` replayed=${e.replayed}`:""}`)}catch(e){throw o.warn(`session/attach failed for ${this.opts.sessionId}: ${e.message}`),e}}write(e){if(!this.ws||this.ws.readyState!==c.OPEN){o.warn(`drop write to closed ws: ${JSON.stringify(e)}`);return}this.ws.send(JSON.stringify(e))}onMessage(e){if(w(e)){const t=this.pending.get(e.id);t?(this.pending.delete(e.id),t.resolve(e)):o.debug(`unmatched response id=${String(e.id)}`),this.emit("response",e)}else u(e)?this.emit("request",e):g(e)&&this.emit("notification",e)}}export{E as AcpAttach};
@@ -1,13 +1 @@
1
- // ACP wire protocol version this extension speaks. Single source of
2
- // truth for the initialize handshake; never a literal at the callsite.
3
- export const ACP_PROTOCOL_VERSION = 1;
4
- export function isRequest(m) {
5
- return "method" in m && "id" in m;
6
- }
7
- export function isNotification(m) {
8
- return "method" in m && !("id" in m);
9
- }
10
- export function isResponse(m) {
11
- return !("method" in m) && "id" in m;
12
- }
13
- //# sourceMappingURL=protocol.js.map
1
+ const e=1;function o(n){return"method"in n&&"id"in n}function s(n){return"method"in n&&!("id"in n)}function t(n){return!("method"in n)&&"id"in n}export{e as ACP_PROTOCOL_VERSION,s as isNotification,o as isRequest,t as isResponse};
@@ -1,134 +1 @@
1
- import { hostname, userInfo } from "node:os";
2
- import { hashBundle, keyFor, serialize, wrap, } from "./envelope.js";
3
- import { logger } from "./util/log.js";
4
- const log = logger("archive");
5
- export class ArchiveLoop {
6
- opts;
7
- host;
8
- pending = new Map();
9
- // Tracks sessionId -> meta we know about (just cwd/title/agentId for
10
- // now), so the rule fn has something to decide on.
11
- meta = new Map();
12
- stopped = false;
13
- constructor(opts) {
14
- this.opts = opts;
15
- this.host = opts.host ?? defaultHost();
16
- }
17
- setMeta(sessionId, meta) {
18
- this.meta.set(sessionId, meta);
19
- }
20
- forgetSession(sessionId) {
21
- const p = this.pending.get(sessionId);
22
- if (p) {
23
- clearTimeout(p.timer);
24
- this.pending.delete(sessionId);
25
- }
26
- this.meta.delete(sessionId);
27
- }
28
- // Cancel any pending debounced upload and synchronously run one final
29
- // flush — used when a session transitions live → cold so we capture
30
- // its last state. Errors are logged, not thrown, so callers can
31
- // fire-and-forget. The session's meta is dropped after the flush
32
- // resolves.
33
- async finalFlush(sessionId) {
34
- const existing = this.pending.get(sessionId);
35
- if (existing) {
36
- clearTimeout(existing.timer);
37
- this.pending.delete(sessionId);
38
- }
39
- try {
40
- await this.flush(sessionId);
41
- }
42
- catch (err) {
43
- log.warn(`final flush ${sessionId} failed: ${err.message}`);
44
- }
45
- this.meta.delete(sessionId);
46
- }
47
- markDirty(sessionId) {
48
- if (this.stopped) {
49
- return;
50
- }
51
- const existing = this.pending.get(sessionId);
52
- if (existing) {
53
- clearTimeout(existing.timer);
54
- }
55
- const timer = setTimeout(() => {
56
- this.pending.delete(sessionId);
57
- void this.flush(sessionId).catch((err) => {
58
- log.warn(`flush ${sessionId} failed: ${err.message}`);
59
- });
60
- }, this.opts.debounceMs);
61
- timer.unref();
62
- this.pending.set(sessionId, { timer });
63
- }
64
- stop() {
65
- this.stopped = true;
66
- for (const p of this.pending.values()) {
67
- clearTimeout(p.timer);
68
- }
69
- this.pending.clear();
70
- }
71
- // flushNow is exposed for tests so they don't have to wait out the
72
- // debounce window. Production code goes through markDirty.
73
- async flushNow(sessionId) {
74
- const existing = this.pending.get(sessionId);
75
- if (existing) {
76
- clearTimeout(existing.timer);
77
- this.pending.delete(sessionId);
78
- }
79
- await this.flush(sessionId);
80
- }
81
- async flush(sessionId) {
82
- const bundle = await this.opts.daemon.exportSession(sessionId);
83
- const lineageId = bundle.session.lineageId;
84
- if (!lineageId) {
85
- log.warn(`session ${sessionId} export had no lineageId; skipping`);
86
- return;
87
- }
88
- const meta = this.meta.get(sessionId);
89
- const rule = this.opts.getRule();
90
- const shouldArchive = await runRule(rule, {
91
- sessionId,
92
- lineageId,
93
- meta: meta ?? {},
94
- });
95
- if (!shouldArchive) {
96
- log.debug(`rule skipped session ${sessionId} (lineage ${lineageId})`);
97
- return;
98
- }
99
- const newHash = hashBundle(bundle);
100
- const prev = this.opts.state.get(lineageId);
101
- if (prev.lastUploadedHash === newHash) {
102
- log.debug(`unchanged content for lineage ${lineageId} — skip upload`);
103
- return;
104
- }
105
- const envelope = wrap(bundle, lineageId, this.host);
106
- await this.opts.backend.put(keyFor(lineageId), serialize(envelope));
107
- await this.opts.state.set(lineageId, {
108
- lastUploadedHash: newHash,
109
- lastUploadedAt: envelope.uploadedAt,
110
- });
111
- log.info(`uploaded lineage=${lineageId} session=${sessionId} hash=${newHash.slice(0, 22)}…`);
112
- }
113
- }
114
- async function runRule(rule, ev) {
115
- try {
116
- const r = await rule(ev);
117
- return r !== false;
118
- }
119
- catch (err) {
120
- log.warn(`rule threw for ${ev.sessionId}: ${err.message}; defaulting to archive`);
121
- return true;
122
- }
123
- }
124
- function defaultHost() {
125
- let user = "unknown";
126
- try {
127
- user = userInfo().username;
128
- }
129
- catch {
130
- // happens in some sandboxes; fall through
131
- }
132
- return { host: hostname(), user };
133
- }
134
- //# sourceMappingURL=archive-loop.js.map
1
+ import{hostname as p,userInfo as h}from"node:os";import{hashBundle as c,keyFor as u,serialize as d,wrap as g}from"./envelope.js";import{logger as m}from"./util/log.js";const n=m("archive");class b{constructor(e){this.opts=e;this.host=e.host??v()}opts;host;pending=new Map;meta=new Map;stopped=!1;setMeta(e,t){this.meta.set(e,t)}forgetSession(e){const t=this.pending.get(e);t&&(clearTimeout(t.timer),this.pending.delete(e)),this.meta.delete(e)}async finalFlush(e){const t=this.pending.get(e);t&&(clearTimeout(t.timer),this.pending.delete(e));try{await this.flush(e)}catch(i){n.warn(`final flush ${e} failed: ${i.message}`)}this.meta.delete(e)}markDirty(e){if(this.stopped)return;const t=this.pending.get(e);t&&clearTimeout(t.timer);const i=setTimeout(()=>{this.pending.delete(e),this.flush(e).catch(r=>{n.warn(`flush ${e} failed: ${r.message}`)})},this.opts.debounceMs);i.unref(),this.pending.set(e,{timer:i})}stop(){this.stopped=!0;for(const e of this.pending.values())clearTimeout(e.timer);this.pending.clear()}async flushNow(e){const t=this.pending.get(e);t&&(clearTimeout(t.timer),this.pending.delete(e)),await this.flush(e)}async flush(e){const t=await this.opts.daemon.exportSession(e),i=t.session.lineageId;if(!i){n.warn(`session ${e} export had no lineageId; skipping`);return}const r=this.meta.get(e),l=this.opts.getRule();if(!await f(l,{sessionId:e,lineageId:i,meta:r??{}})){n.debug(`rule skipped session ${e} (lineage ${i})`);return}const s=c(t);if(this.opts.state.get(i).lastUploadedHash===s){n.debug(`unchanged content for lineage ${i} \u2014 skip upload`);return}const a=g(t,i,this.host);await this.opts.backend.put(u(i),d(a)),await this.opts.state.set(i,{lastUploadedHash:s,lastUploadedAt:a.uploadedAt}),n.info(`uploaded lineage=${i} session=${e} hash=${s.slice(0,22)}\u2026`)}}async function f(o,e){try{return await o(e)!==!1}catch(t){return n.warn(`rule threw for ${e.sessionId}: ${t.message}; defaulting to archive`),!0}}function v(){let o="unknown";try{o=h().username}catch{}return{host:p(),user:o}}export{b as ArchiveLoop};
@@ -1,59 +1 @@
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
1
+ import{createCipheriv as d,createDecipheriv as g,createHash as l,randomBytes as m}from"node:crypto";const y=1,i=1,o=8,f=12,h=16,p=i+o+f;class B{constructor(r,e){this.inner=r;this.key=e;if(e.length!==32)throw new Error("encryption key must be 32 bytes");this.keyFingerprint=l("sha256").update(e).digest().subarray(0,o)}inner;key;keyFingerprint;init(){return this.inner.init()}list(){return this.inner.list()}delete(r){return this.inner.delete(r)}async put(r,e){const t=m(f),n=d("aes-256-gcm",this.key,t),s=Buffer.concat([n.update(e),n.final()]),a=n.getAuthTag();await this.inner.put(r,Buffer.concat([Buffer.from([y]),this.keyFingerprint,t,s,a]))}async get(r){const e=await this.inner.get(r);if(e.length<p+h)throw new Error(`encrypted blob too short for key ${r}`);const t=e[0];if(t!==y)throw new Error(`unsupported encryption version ${t} for key ${r}: upgrade the archiver`);if(!e.subarray(i,i+o).equals(this.keyFingerprint))throw new Error(`key mismatch: blob ${r} was encrypted with a different key`);const s=e.subarray(i+o,p),a=e.subarray(e.length-h),u=e.subarray(p,e.length-h),c=g("aes-256-gcm",this.key,s);c.setAuthTag(a);try{return Buffer.concat([c.update(u),c.final()])}catch{throw new Error(`blob ${r} failed authentication \u2014 data may be corrupted`)}}}export{B as EncryptedBackend};
@@ -1,28 +1 @@
1
- import { FsBackend } from "./fs.js";
2
- import { GoogleDriveBackend } from "./google-drive.js";
3
- import { S3Backend } from "./s3.js";
4
- export function makeBackend(config) {
5
- switch (config.backend) {
6
- case "fs":
7
- return new FsBackend({ dir: config.fsDir, prefix: config.prefix });
8
- case "google-drive":
9
- return new GoogleDriveBackend({
10
- credentialsPath: config.credentialsPath,
11
- tokenPath: config.tokenPath,
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,
21
- });
22
- default: {
23
- const exhaustive = config.backend;
24
- throw new Error(`unknown backend: ${String(exhaustive)}`);
25
- }
26
- }
27
- }
28
- //# sourceMappingURL=factory.js.map
1
+ import{FsBackend as t}from"./fs.js";import{GoogleDriveBackend as n}from"./google-drive.js";import{S3Backend as a}from"./s3.js";function s(e){switch(e.backend){case"fs":return new t({dir:e.fsDir,prefix:e.prefix});case"google-drive":return new n({credentialsPath:e.credentialsPath,tokenPath:e.tokenPath,folderName:e.driveFolderName,prefix:e.prefix});case"s3":return new a({bucket:e.s3Bucket,region:e.s3Region,endpoint:e.s3Endpoint,prefix:e.prefix});default:{const r=e.backend;throw new Error(`unknown backend: ${String(r)}`)}}}export{s as makeBackend};
@@ -1,78 +1 @@
1
- import { mkdir, readFile, readdir, rename, stat, unlink, writeFile, } from "node:fs/promises";
2
- import { dirname, resolve } from "node:path";
3
- import { logger } from "../util/log.js";
4
- const log = logger("backend.fs");
5
- // Filesystem-backed implementation. Useful for tests, for users who want
6
- // to point a folder-sync tool (Syncthing, Dropbox) at the archive
7
- // directory, and for development against a local daemon.
8
- export class FsBackend {
9
- opts;
10
- root;
11
- constructor(opts) {
12
- this.opts = opts;
13
- this.root = opts.prefix !== "" ? resolve(opts.dir, opts.prefix) : opts.dir;
14
- }
15
- async init() {
16
- await mkdir(this.root, { recursive: true });
17
- log.info(`fs backend ready at ${this.root}`);
18
- }
19
- async list() {
20
- return this.walk(this.root, "");
21
- }
22
- async walk(dir, rel) {
23
- let names;
24
- try {
25
- names = await readdir(dir);
26
- }
27
- catch (err) {
28
- const e = err;
29
- if (e.code === "ENOENT") {
30
- return [];
31
- }
32
- throw err;
33
- }
34
- const out = [];
35
- for (const name of names) {
36
- if (name.startsWith(".") || name.endsWith(".tmp")) {
37
- continue;
38
- }
39
- const full = resolve(dir, name);
40
- const entryRel = rel !== "" ? `${rel}/${name}` : name;
41
- try {
42
- const s = await stat(full);
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() });
48
- }
49
- }
50
- catch (err) {
51
- log.debug(`stat ${full}: ${err.message}`);
52
- }
53
- }
54
- return out;
55
- }
56
- async get(key) {
57
- return readFile(resolve(this.root, key));
58
- }
59
- async put(key, data) {
60
- const dest = resolve(this.root, key);
61
- const tmp = `${dest}.tmp`;
62
- await mkdir(dirname(dest), { recursive: true });
63
- await writeFile(tmp, data);
64
- await rename(tmp, dest);
65
- }
66
- async delete(key) {
67
- try {
68
- await unlink(resolve(this.root, key));
69
- }
70
- catch (err) {
71
- const e = err;
72
- if (e.code !== "ENOENT") {
73
- throw err;
74
- }
75
- }
76
- }
77
- }
78
- //# sourceMappingURL=fs.js.map
1
+ import{mkdir as d,readFile as y,readdir as f,rename as p,stat as l,unlink as u,writeFile as h}from"node:fs/promises";import{dirname as k,resolve as o}from"node:path";import{logger as g}from"../util/log.js";const m=g("backend.fs");class v{constructor(t){this.opts=t;this.root=t.prefix!==""?o(t.dir,t.prefix):t.dir}opts;root;async init(){await d(this.root,{recursive:!0}),m.info(`fs backend ready at ${this.root}`)}async list(){return this.walk(this.root,"")}async walk(t,e){let i;try{i=await f(t)}catch(r){if(r.code==="ENOENT")return[];throw r}const n=[];for(const r of i){if(r.startsWith(".")||r.endsWith(".tmp"))continue;const a=o(t,r),c=e!==""?`${e}/${r}`:r;try{const s=await l(a);s.isDirectory()?n.push(...await this.walk(a,c)):s.isFile()&&n.push({key:c,size:s.size,modifiedAt:s.mtime.toISOString()})}catch(s){m.debug(`stat ${a}: ${s.message}`)}}return n}async get(t){return y(o(this.root,t))}async put(t,e){const i=o(this.root,t),n=`${i}.tmp`;await d(k(i),{recursive:!0}),await h(n,e),await p(n,i)}async delete(t){try{await u(o(this.root,t))}catch(e){if(e.code!=="ENOENT")throw e}}}export{v as FsBackend};