@hydra-acp/archiver 0.1.15 → 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 -155
  13. package/dist/daemon.js +1 -74
  14. package/dist/discovery.js +1 -82
  15. package/dist/envelope.js +1 -123
  16. package/dist/index.js +15 -240
  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
package/dist/index.js CHANGED
@@ -1,34 +1,13 @@
1
1
  #!/usr/bin/env node
2
- import { createHash } from "node:crypto";
3
- import { readFileSync } from "node:fs";
4
- import { userInfo } from "node:os";
5
- import { dirname, resolve } from "node:path";
6
- import { fileURLToPath } from "node:url";
7
- import { ArchiveLoop } from "./archive-loop.js";
8
- import { EncryptedBackend } from "./backend/encrypted.js";
9
- import { makeBackend } from "./backend/factory.js";
10
- import { ArchiverBridge } from "./bridge.js";
11
- import { runColdSweep } from "./cold-sweep.js";
12
- import { loadConfig, loadEncryptionKey, loadLoginConfig } from "./config.js";
13
- import { DaemonClient } from "./daemon.js";
14
- import { HydraDiscovery } from "./discovery.js";
15
- import { lineageFromKey } from "./envelope.js";
16
- import { runKeygen } from "./keygen.js";
17
- import { runGoogleLogin } from "./oauth/google.js";
18
- import { PullLoop } from "./pull-loop.js";
19
- import { DEFAULT_RULE, loadRule } from "./rule.js";
20
- import { SyncState } from "./state.js";
21
- import { logger, setDebug } from "./util/log.js";
22
- const log = logger("main");
23
- const USAGE = `usage: hydra-acp-archiver [<command>]
2
+ import{createHash as P}from"node:crypto";import{readFileSync as x}from"node:fs";import{userInfo as w}from"node:os";import{dirname as S,resolve as U}from"node:path";import{fileURLToPath as R}from"node:url";import{ArchiveLoop as C}from"./archive-loop.js";import{EncryptedBackend as E}from"./backend/encrypted.js";import{makeBackend as A}from"./backend/factory.js";import{ArchiverBridge as T}from"./bridge.js";import{runColdSweep as G}from"./cold-sweep.js";import{loadConfig as D,loadEncryptionKey as L,loadLoginConfig as B}from"./config.js";import{DaemonClient as M}from"./daemon.js";import{HydraDiscovery as H}from"./discovery.js";import{lineageFromKey as F}from"./envelope.js";import{runKeygen as K}from"./keygen.js";import{runGoogleLogin as _}from"./oauth/google.js";import{PullLoop as z}from"./pull-loop.js";import{DEFAULT_RULE as j,loadRule as y}from"./rule.js";import{SyncState as N}from"./state.js";import{logger as O,setDebug as v}from"./util/log.js";const o=O("main"),k=`usage: hydra-acp-archiver [<command>]
24
3
 
25
4
  Commands:
26
- setup Interactive first-run wizard picks a backend, sets up
5
+ setup Interactive first-run wizard \u2014 picks a backend, sets up
27
6
  OAuth/credentials/encryption, writes archiver.conf, and
28
7
  (optionally) registers the extension with hydra.
29
8
  (no args) Run as a daemon-managed extension (the daemon spawns it
30
9
  this way automatically when registered).
31
- gdrive login Interactive Google OAuth flow opens a browser and writes
10
+ gdrive login Interactive Google OAuth flow \u2014 opens a browser and writes
32
11
  the refresh token to ~/.hydra-acp/archiver-google-token.json.
33
12
  keygen Generate a symmetric encryption key and write it to
34
13
  HYDRA_ACP_ARCHIVER_KEY_PATH (or ~/.hydra-acp/archiver-key).
@@ -36,219 +15,15 @@ Commands:
36
15
  Flags:
37
16
  --version, -v Print version and exit.
38
17
  --help, -h Show this message.
39
- `;
40
- function readVersion() {
41
- try {
42
- const here = dirname(fileURLToPath(import.meta.url));
43
- const pkg = JSON.parse(readFileSync(resolve(here, "../package.json"), "utf8"));
44
- return pkg.version ?? "unknown";
45
- }
46
- catch {
47
- return "unknown";
48
- }
49
- }
50
- const TRUTHY = new Set(["1", "true", "yes", "on", "t"]);
51
- async function runLogin() {
52
- setDebug(TRUTHY.has((process.env.DEBUG ?? "").toLowerCase()));
53
- const cfg = loadLoginConfig();
54
- log.info(`credentials=${cfg.credentialsPath} token=${cfg.tokenPath}`);
55
- await runGoogleLogin({
56
- credentialsPath: cfg.credentialsPath,
57
- tokenPath: cfg.tokenPath,
58
- });
59
- log.info("login complete — you can now start the archiver extension");
60
- }
61
- async function runExtension() {
62
- const config = loadConfig();
63
- setDebug(config.debug);
64
- const encryptionKey = await loadEncryptionKey(config.encryptionKeyPath);
65
- if (config.prefix === "") {
66
- if (encryptionKey !== undefined) {
67
- config.prefix = createHash("sha256").update(encryptionKey).digest().subarray(0, 8).toString("hex") + "/";
68
- log.info(`auto-prefix (key fingerprint): ${config.prefix}`);
69
- }
70
- else {
71
- config.prefix = userInfo().username.toLowerCase().replace(/[^a-z0-9-]/g, "-") + "/";
72
- log.info(`auto-prefix (username): ${config.prefix}`);
73
- }
74
- }
75
- const state = new SyncState(config.statePath);
76
- await state.load(readVersion(), config.prefix, config.backend);
77
- function buildBackend(prefix) {
78
- const raw = makeBackend({ ...config, prefix });
79
- return encryptionKey !== undefined ? new EncryptedBackend(raw, encryptionKey) : raw;
80
- }
81
- // syncBackend sees the full user namespace (all hosts) — used for listing
82
- // and pulling. uploadBackend scopes writes to this host's subdirectory.
83
- const syncBackend = buildBackend(config.prefix);
84
- const uploadBackend = buildBackend(config.prefix + config.hostId + "/");
85
- await syncBackend.init();
86
- await uploadBackend.init();
87
- // Reconcile state.json with what's actually on the backend. Without
88
- // this a wiped backend (manual nuke, retention delete, expired share)
89
- // leaves us believing every lineage is "already uploaded" — the hash
90
- // skip in archive-loop never fires a re-upload and the user wonders
91
- // why nothing flooded in. One list call at startup makes state a
92
- // hint-cache instead of a source of truth.
93
- try {
94
- const entries = await syncBackend.list();
95
- const presentLineages = new Set();
96
- for (const e of entries) {
97
- const id = lineageFromKey(e.key);
98
- if (id !== undefined) {
99
- presentLineages.add(id);
100
- }
101
- }
102
- const pruned = await state.reconcile(presentLineages);
103
- log.info(`reconciled state with backend: present=${presentLineages.size} pruned=${pruned}`);
104
- }
105
- catch (err) {
106
- log.warn(`state reconcile skipped — backend list failed: ${err.message}`);
107
- }
108
- const daemon = new DaemonClient({
109
- daemonUrl: config.hydraDaemonUrl,
110
- token: config.hydraToken,
111
- });
112
- let currentRule = DEFAULT_RULE;
113
- currentRule = await loadRule(config.ruleConfigPath);
114
- const archive = new ArchiveLoop({
115
- daemon,
116
- backend: uploadBackend,
117
- state,
118
- getRule: () => currentRule,
119
- debounceMs: config.uploadDebounceMs,
120
- host: { host: config.hostId, user: userInfo().username },
121
- });
122
- const pull = new PullLoop({
123
- daemon,
124
- backend: syncBackend,
125
- state,
126
- intervalMs: config.pullIntervalMs,
127
- hostId: config.hostId,
128
- });
129
- pull.start();
130
- // Backfill: archive every cold session the daemon knows about.
131
- // Runs in the background so the rest of startup (discovery,
132
- // bridges) doesn't block on it. Hash dedup means restarts are cheap.
133
- void runColdSweep({
134
- daemonUrl: config.hydraDaemonUrl,
135
- token: config.hydraToken,
136
- archive,
137
- }).catch((err) => {
138
- log.warn(`cold sweep failed: ${err.message}`);
139
- });
140
- const bridges = new Map();
141
- const discovery = new HydraDiscovery({
142
- daemonUrl: config.hydraDaemonUrl,
143
- token: config.hydraToken,
144
- pollIntervalMs: config.hydraPollIntervalMs,
145
- onAdd: (session) => {
146
- if (bridges.has(session.sessionId)) {
147
- return;
148
- }
149
- log.info(`attaching to ${session.sessionId} agent=${session.agentId ?? "?"} cwd=${session.cwd}`);
150
- const bridge = new ArchiverBridge({
151
- daemonWsUrl: config.hydraWsUrl,
152
- token: config.hydraToken,
153
- sessionId: session.sessionId,
154
- meta: {
155
- ...(session.cwd !== undefined ? { cwd: session.cwd } : {}),
156
- ...(session.agentId !== undefined
157
- ? { agentId: session.agentId }
158
- : {}),
159
- ...(session.title !== undefined ? { title: session.title } : {}),
160
- },
161
- archive,
162
- });
163
- bridges.set(session.sessionId, bridge);
164
- bridge.start();
165
- },
166
- onRemove: (sessionId) => {
167
- const bridge = bridges.get(sessionId);
168
- if (!bridge) {
169
- return;
170
- }
171
- log.info(`detaching from ${sessionId}`);
172
- bridges.delete(sessionId);
173
- bridge.stop();
174
- },
175
- });
176
- discovery.start();
177
- process.on("SIGHUP", () => {
178
- log.info(`SIGHUP — reloading rule from ${config.ruleConfigPath}`);
179
- loadRule(config.ruleConfigPath)
180
- .then((rule) => {
181
- currentRule = rule;
182
- log.info("rule reload complete");
183
- })
184
- .catch((err) => {
185
- log.warn(`rule reload failed: ${err.message}`);
186
- });
187
- });
188
- const shutdown = (sig) => {
189
- log.info(`${sig} received — shutting down`);
190
- discovery.stop();
191
- pull.stop();
192
- archive.stop();
193
- for (const bridge of bridges.values()) {
194
- bridge.stop();
195
- }
196
- setTimeout(() => process.exit(0), 200).unref();
197
- };
198
- process.on("SIGINT", () => shutdown("SIGINT"));
199
- process.on("SIGTERM", () => shutdown("SIGTERM"));
200
- log.info(`hydra-acp-archiver up; daemon=${config.hydraDaemonUrl} backend=${config.backend} host=${config.hostId} rule=${config.ruleConfigPath}`);
201
- }
202
- async function main() {
203
- const cmd = process.argv[2];
204
- if (cmd === "-v" || cmd === "--version") {
205
- process.stdout.write(`hydra-acp-archiver ${readVersion()}\n`);
206
- return;
207
- }
208
- if (cmd === undefined) {
209
- await runExtension();
210
- return;
211
- }
212
- if (cmd === "gdrive") {
213
- const sub = process.argv[3];
214
- if (sub === "login") {
215
- await runLogin();
216
- return;
217
- }
218
- const subcmds = "login";
219
- process.stderr.write(sub !== undefined
220
- ? `hydra-acp-archiver gdrive: unknown subcommand "${sub}"\n\nAvailable: ${subcmds}\n`
221
- : `hydra-acp-archiver gdrive: missing subcommand\n\nAvailable: ${subcmds}\n`);
222
- process.exit(2);
223
- }
224
- if (cmd === "login") {
225
- process.stderr.write(`hydra-acp-archiver: "login" is not a command; did you mean "gdrive login"?\n`);
226
- process.exit(2);
227
- }
228
- if (cmd === "keygen") {
229
- await runKeygen();
230
- return;
231
- }
232
- if (cmd === "setup") {
233
- const { runSetup } = await import("./setup/wizard.js");
234
- await runSetup();
235
- return;
236
- }
237
- if (cmd === "-h" || cmd === "--help" || cmd === "help") {
238
- process.stdout.write(USAGE);
239
- return;
240
- }
241
- process.stderr.write(`hydra-acp-archiver: unknown command "${cmd}"\n\n${USAGE}`);
242
- process.exit(2);
243
- }
244
- main().catch((err) => {
245
- const msg = err.message;
246
- process.stderr.write(`hydra-acp-archiver: ${msg}\n`);
247
- if (/invalid_grant/i.test(msg)) {
248
- process.stderr.write("Google refresh token is invalid. Run `hydra-acp-archiver gdrive login` to re-authorize.\n");
249
- // sysexits EX_CONFIG — tells the daemon supervisor not to restart us.
250
- process.exit(78);
251
- }
252
- process.exit(1);
253
- });
254
- //# sourceMappingURL=index.js.map
18
+ `;function b(){try{const e=S(R(import.meta.url));return JSON.parse(x(U(e,"../package.json"),"utf8")).version??"unknown"}catch{return"unknown"}}const Y=new Set(["1","true","yes","on","t"]);async function V(){v(Y.has((process.env.DEBUG??"").toLowerCase()));const e=B();o.info(`credentials=${e.credentialsPath} token=${e.tokenPath}`),await _({credentialsPath:e.credentialsPath,tokenPath:e.tokenPath}),o.info("login complete \u2014 you can now start the archiver extension")}async function W(){const e=D();v(e.debug);const n=await L(e.encryptionKeyPath);e.prefix===""&&(n!==void 0?(e.prefix=P("sha256").update(n).digest().subarray(0,8).toString("hex")+"/",o.info(`auto-prefix (key fingerprint): ${e.prefix}`)):(e.prefix=w().username.toLowerCase().replace(/[^a-z0-9-]/g,"-")+"/",o.info(`auto-prefix (username): ${e.prefix}`)));const a=new N(e.statePath);await a.load(b(),e.prefix,e.backend);function p(r){const t=A({...e,prefix:r});return n!==void 0?new E(t,n):t}const s=p(e.prefix),l=p(e.prefix+e.hostId+"/");await s.init(),await l.init();try{const r=await s.list(),t=new Set;for(const $ of r){const g=F($.key);g!==void 0&&t.add(g)}const I=await a.reconcile(t);o.info(`reconciled state with backend: present=${t.size} pruned=${I}`)}catch(r){o.warn(`state reconcile skipped \u2014 backend list failed: ${r.message}`)}const u=new M({daemonUrl:e.hydraDaemonUrl,token:e.hydraToken,toolContent:e.toolContent});let c=j;c=await y(e.ruleConfigPath);const d=new C({daemon:u,backend:l,state:a,getRule:()=>c,debounceMs:e.uploadDebounceMs,host:{host:e.hostId,user:w().username}}),m=new z({daemon:u,backend:s,state:a,intervalMs:e.pullIntervalMs,hostId:e.hostId});m.start(),G({daemonUrl:e.hydraDaemonUrl,token:e.hydraToken,archive:d}).catch(r=>{o.warn(`cold sweep failed: ${r.message}`)});const i=new Map,f=new H({daemonUrl:e.hydraDaemonUrl,token:e.hydraToken,pollIntervalMs:e.hydraPollIntervalMs,onAdd:r=>{if(i.has(r.sessionId))return;o.info(`attaching to ${r.sessionId} agent=${r.agentId??"?"} cwd=${r.cwd}`);const t=new T({daemonWsUrl:e.hydraWsUrl,token:e.hydraToken,sessionId:r.sessionId,meta:{...r.cwd!==void 0?{cwd:r.cwd}:{},...r.agentId!==void 0?{agentId:r.agentId}:{},...r.title!==void 0?{title:r.title}:{}},archive:d});i.set(r.sessionId,t),t.start()},onRemove:r=>{const t=i.get(r);t&&(o.info(`detaching from ${r}`),i.delete(r),t.stop())}});f.start(),process.on("SIGHUP",()=>{o.info(`SIGHUP \u2014 reloading rule from ${e.ruleConfigPath}`),y(e.ruleConfigPath).then(r=>{c=r,o.info("rule reload complete")}).catch(r=>{o.warn(`rule reload failed: ${r.message}`)})});const h=r=>{o.info(`${r} received \u2014 shutting down`),f.stop(),m.stop(),d.stop();for(const t of i.values())t.stop();setTimeout(()=>process.exit(0),200).unref()};process.on("SIGINT",()=>h("SIGINT")),process.on("SIGTERM",()=>h("SIGTERM")),o.info(`hydra-acp-archiver up; daemon=${e.hydraDaemonUrl} backend=${e.backend} host=${e.hostId} rule=${e.ruleConfigPath}`)}async function J(){const e=process.argv[2];if(e==="-v"||e==="--version"){process.stdout.write(`hydra-acp-archiver ${b()}
19
+ `);return}if(e===void 0){await W();return}if(e==="gdrive"){const n=process.argv[3];if(n==="login"){await V();return}const a="login";process.stderr.write(n!==void 0?`hydra-acp-archiver gdrive: unknown subcommand "${n}"
20
+
21
+ Available: ${a}
22
+ `:`hydra-acp-archiver gdrive: missing subcommand
23
+
24
+ Available: ${a}
25
+ `),process.exit(2)}if(e==="login"&&(process.stderr.write(`hydra-acp-archiver: "login" is not a command; did you mean "gdrive login"?
26
+ `),process.exit(2)),e==="keygen"){await K();return}if(e==="setup"){const{runSetup:n}=await import("./setup/wizard.js");await n();return}if(e==="-h"||e==="--help"||e==="help"){process.stdout.write(k);return}process.stderr.write(`hydra-acp-archiver: unknown command "${e}"
27
+
28
+ ${k}`),process.exit(2)}J().catch(e=>{const n=e.message;process.stderr.write(`hydra-acp-archiver: ${n}
29
+ `),/invalid_grant/i.test(n)&&(process.stderr.write("Google refresh token is invalid. Run `hydra-acp-archiver gdrive login` to re-authorize.\n"),process.exit(78)),process.exit(1)});
package/dist/keygen.js CHANGED
@@ -1,26 +1,8 @@
1
- import { chmod, mkdir, writeFile } from "node:fs/promises";
2
- import { homedir } from "node:os";
3
- import { dirname, resolve } from "node:path";
4
- import { createHash, randomBytes } from "node:crypto";
5
- export async function runKeygen() {
6
- const keyPath = process.env.HYDRA_ACP_ARCHIVER_KEY_PATH ??
7
- resolve(homedir(), ".hydra-acp", "archiver-key");
8
- const key = randomBytes(32);
9
- const hex = key.toString("hex");
10
- const fingerprint = createHash("sha256")
11
- .update(key)
12
- .digest()
13
- .subarray(0, 8)
14
- .toString("hex");
15
- await mkdir(dirname(keyPath), { recursive: true });
16
- await writeFile(keyPath, hex + "\n", { mode: 0o600 });
17
- await chmod(keyPath, 0o600);
18
- process.stdout.write(`key written to ${keyPath}\n`);
19
- process.stdout.write(`key fingerprint: ${fingerprint}\n`);
20
- process.stdout.write(`\n`);
21
- process.stdout.write(`Copy ${keyPath} to every machine that should share this archive.\n`);
22
- if (!process.env.HYDRA_ACP_ARCHIVER_KEY_PATH) {
23
- process.stdout.write(`Then add to your extension env config:\n "HYDRA_ACP_ARCHIVER_KEY_PATH": "${keyPath}"\n`);
24
- }
25
- }
26
- //# sourceMappingURL=keygen.js.map
1
+ import{chmod as i,mkdir as s,writeFile as n}from"node:fs/promises";import{homedir as a}from"node:os";import{dirname as c,resolve as d}from"node:path";import{createHash as h,randomBytes as p}from"node:crypto";async function _(){const e=process.env.HYDRA_ACP_ARCHIVER_KEY_PATH??d(a(),".hydra-acp","archiver-key"),t=p(32),r=t.toString("hex"),o=h("sha256").update(t).digest().subarray(0,8).toString("hex");await s(c(e),{recursive:!0}),await n(e,r+`
2
+ `,{mode:384}),await i(e,384),process.stdout.write(`key written to ${e}
3
+ `),process.stdout.write(`key fingerprint: ${o}
4
+ `),process.stdout.write(`
5
+ `),process.stdout.write(`Copy ${e} to every machine that should share this archive.
6
+ `),process.env.HYDRA_ACP_ARCHIVER_KEY_PATH||process.stdout.write(`Then add to your extension env config:
7
+ "HYDRA_ACP_ARCHIVER_KEY_PATH": "${e}"
8
+ `)}export{_ as runKeygen};
@@ -1,16 +1,4 @@
1
- import { spawn } from "node:child_process";
2
- import { createServer } from "node:http";
3
- import { chmod, mkdir, readFile, writeFile } from "node:fs/promises";
4
- import { dirname } from "node:path";
5
- import { google } from "googleapis";
6
- import { logger } from "../util/log.js";
7
- const log = logger("oauth");
8
- // drive.file is a non-sensitive scope: the app can only see files it
9
- // creates plus those a user explicitly hands to it via the picker. This
10
- // keeps the verification footprint small and the user's other Drive
11
- // content invisible to the archiver.
12
- const SCOPES = ["https://www.googleapis.com/auth/drive.file"];
13
- const CREDS_HINT = `
1
+ import{spawn as w}from"node:child_process";import{createServer as k}from"node:http";import{chmod as y,mkdir as v,readFile as h,writeFile as C}from"node:fs/promises";import{dirname as _}from"node:path";import{google as p}from"googleapis";import{logger as O}from"../util/log.js";const l=O("oauth"),P=["https://www.googleapis.com/auth/drive.file"],g=`
14
2
  Set up Google OAuth credentials first:
15
3
  1. Visit https://console.cloud.google.com/
16
4
  2. Create or select a project, then enable the Google Drive API
@@ -18,185 +6,8 @@ Set up Google OAuth credentials first:
18
6
  4. Create credentials -> OAuth client ID -> Application type: Desktop app
19
7
  5. Download the JSON and save it to ~/.hydra-acp/archiver-google-credentials.json
20
8
  (or set HYDRA_ACP_ARCHIVER_GOOGLE_CREDENTIALS)
21
- `;
22
- async function loadCredentials(path) {
23
- let text;
24
- try {
25
- text = await readFile(path, "utf8");
26
- }
27
- catch (err) {
28
- const e = err;
29
- if (e.code === "ENOENT") {
30
- throw new Error(`OAuth credentials file not found at ${path}.\n${CREDS_HINT}`);
31
- }
32
- throw err;
33
- }
34
- const parsed = JSON.parse(text);
35
- const c = parsed.installed ?? parsed.web;
36
- if (!c || !c.client_id || !c.client_secret) {
37
- throw new Error(`OAuth credentials at ${path} missing client_id/client_secret.\n${CREDS_HINT}`);
38
- }
39
- return { client_id: c.client_id, client_secret: c.client_secret };
40
- }
41
- async function loadToken(path) {
42
- try {
43
- const text = await readFile(path, "utf8");
44
- return JSON.parse(text);
45
- }
46
- catch (err) {
47
- const e = err;
48
- if (e.code === "ENOENT") {
49
- return undefined;
50
- }
51
- throw err;
52
- }
53
- }
54
- async function saveToken(path, token) {
55
- await mkdir(dirname(path), { recursive: true });
56
- await writeFile(path, JSON.stringify(token, null, 2), { mode: 0o600 });
57
- await chmod(path, 0o600);
58
- }
59
- function tryOpenBrowser(url) {
60
- let cmd;
61
- let args;
62
- switch (process.platform) {
63
- case "darwin":
64
- cmd = "open";
65
- args = [url];
66
- break;
67
- case "win32":
68
- cmd = "cmd";
69
- args = ["/c", "start", "", url];
70
- break;
71
- default:
72
- cmd = "xdg-open";
73
- args = [url];
74
- break;
75
- }
76
- try {
77
- const child = spawn(cmd, args, { stdio: "ignore", detached: true });
78
- child.on("error", () => {
79
- log.info("could not auto-open browser; copy the URL above into a browser");
80
- });
81
- child.unref();
82
- }
83
- catch {
84
- log.info("could not auto-open browser; copy the URL above into a browser");
85
- }
86
- }
87
- // Runtime-side: load creds + token, return an OAuth2Client that
88
- // auto-refreshes its access token. Throws with a clear message if the
89
- // user hasn't run the login flow yet.
90
- export async function loadGoogleAuth(opts) {
91
- const creds = await loadCredentials(opts.credentialsPath);
92
- const token = await loadToken(opts.tokenPath);
93
- if (!token || !token.refresh_token) {
94
- throw new Error(`No Google OAuth token at ${opts.tokenPath}. Run \`hydra-acp-archiver-login\` first.`);
95
- }
96
- const client = new google.auth.OAuth2(creds.client_id, creds.client_secret);
97
- client.setCredentials(token);
98
- // Persist refreshed tokens whenever google-auth-library issues a new
99
- // access token, so we keep the latest expiry on disk for future runs.
100
- client.on("tokens", (next) => {
101
- const merged = { ...token, ...next };
102
- void saveToken(opts.tokenPath, merged).catch((err) => {
103
- log.warn(`failed to persist refreshed token: ${err.message}`);
104
- });
105
- });
106
- return client;
107
- }
108
- // Login-side: interactive flow using a loopback redirect on a
109
- // kernel-assigned port. Writes refresh+access tokens to tokenPath.
110
- export async function runGoogleLogin(opts) {
111
- const creds = await loadCredentials(opts.credentialsPath);
112
- // Bind loopback server first so we can build the redirect_uri with
113
- // the assigned port.
114
- const { port, codePromise, close } = await startCallbackServer();
115
- const redirectUri = `http://127.0.0.1:${port}`;
116
- const client = new google.auth.OAuth2(creds.client_id, creds.client_secret, redirectUri);
117
- const authUrl = client.generateAuthUrl({
118
- access_type: "offline",
119
- scope: SCOPES,
120
- // prompt=consent forces a refresh_token even if the user previously
121
- // granted access — without it, repeat logins return only an access
122
- // token.
123
- prompt: "consent",
124
- });
125
- log.info("Open this URL in a browser to authorize the archiver:");
126
- log.info(authUrl);
127
- tryOpenBrowser(authUrl);
128
- let code;
129
- try {
130
- code = await codePromise;
131
- }
132
- finally {
133
- close();
134
- }
135
- const { tokens } = await client.getToken(code);
136
- if (!tokens.refresh_token) {
137
- throw new Error("Google did not return a refresh_token. Revoke the app at https://myaccount.google.com/permissions and try again.");
138
- }
139
- await saveToken(opts.tokenPath, tokens);
140
- log.info(`token saved to ${opts.tokenPath}`);
141
- }
142
- async function startCallbackServer() {
143
- let resolveCode = () => void 0;
144
- let rejectCode = () => void 0;
145
- const codePromise = new Promise((resolve, reject) => {
146
- resolveCode = resolve;
147
- rejectCode = reject;
148
- });
149
- const server = createServer((req, res) => {
150
- try {
151
- const url = new URL(req.url ?? "/", `http://127.0.0.1`);
152
- const code = url.searchParams.get("code");
153
- const error = url.searchParams.get("error");
154
- if (error) {
155
- res.statusCode = 400;
156
- res.setHeader("content-type", "text/plain; charset=utf-8");
157
- res.end(`Authorization error: ${error}\n\nYou can close this tab.`);
158
- rejectCode(new Error(`OAuth error: ${error}`));
159
- return;
160
- }
161
- if (!code) {
162
- res.statusCode = 400;
163
- res.setHeader("content-type", "text/plain; charset=utf-8");
164
- res.end("Missing ?code parameter. You can close this tab.");
165
- return;
166
- }
167
- res.statusCode = 200;
168
- res.setHeader("content-type", "text/plain; charset=utf-8");
169
- res.end("hydra-acp-archiver: authorization complete. You can close this tab.");
170
- resolveCode(code);
171
- }
172
- catch (err) {
173
- res.statusCode = 500;
174
- res.end("internal error");
175
- rejectCode(err);
176
- }
177
- });
178
- await new Promise((resolve, reject) => {
179
- server.once("error", reject);
180
- server.listen(0, "127.0.0.1", () => resolve());
181
- });
182
- const addr = server.address();
183
- if (!addr || typeof addr === "string") {
184
- server.close();
185
- throw new Error("failed to determine callback server port");
186
- }
187
- const port = addr.port;
188
- log.info(`listening for OAuth redirect on http://127.0.0.1:${port}`);
189
- return {
190
- port,
191
- codePromise,
192
- close: () => {
193
- try {
194
- server.close();
195
- }
196
- catch {
197
- // ignore
198
- }
199
- },
200
- };
201
- }
202
- //# sourceMappingURL=google.js.map
9
+ `;async function f(e){let t;try{t=await h(e,"utf8")}catch(i){throw i.code==="ENOENT"?new Error(`OAuth credentials file not found at ${e}.
10
+ ${g}`):i}const n=JSON.parse(t),r=n.installed??n.web;if(!r||!r.client_id||!r.client_secret)throw new Error(`OAuth credentials at ${e} missing client_id/client_secret.
11
+ ${g}`);return{client_id:r.client_id,client_secret:r.client_secret}}async function A(e){try{const t=await h(e,"utf8");return JSON.parse(t)}catch(t){if(t.code==="ENOENT")return;throw t}}async function m(e,t){await v(_(e),{recursive:!0}),await C(e,JSON.stringify(t,null,2),{mode:384}),await y(e,384)}function E(e){let t,n;switch(process.platform){case"darwin":t="open",n=[e];break;case"win32":t="cmd",n=["/c","start","",e];break;default:t="xdg-open",n=[e];break}try{const r=w(t,n,{stdio:"ignore",detached:!0});r.on("error",()=>{l.info("could not auto-open browser; copy the URL above into a browser")}),r.unref()}catch{l.info("could not auto-open browser; copy the URL above into a browser")}}async function $(e){const t=await f(e.credentialsPath),n=await A(e.tokenPath);if(!n||!n.refresh_token)throw new Error(`No Google OAuth token at ${e.tokenPath}. Run \`hydra-acp-archiver-login\` first.`);const r=new p.auth.OAuth2(t.client_id,t.client_secret);return r.setCredentials(n),r.on("tokens",i=>{const a={...n,...i};m(e.tokenPath,a).catch(s=>{l.warn(`failed to persist refreshed token: ${s.message}`)})}),r}async function U(e){const t=await f(e.credentialsPath),{port:n,codePromise:r,close:i}=await b(),a=`http://127.0.0.1:${n}`,s=new p.auth.OAuth2(t.client_id,t.client_secret,a),o=s.generateAuthUrl({access_type:"offline",scope:P,prompt:"consent"});l.info("Open this URL in a browser to authorize the archiver:"),l.info(o),E(o);let c;try{c=await r}finally{i()}const{tokens:d}=await s.getToken(c);if(!d.refresh_token)throw new Error("Google did not return a refresh_token. Revoke the app at https://myaccount.google.com/permissions and try again.");await m(e.tokenPath,d),l.info(`token saved to ${e.tokenPath}`)}async function b(){let e=()=>{},t=()=>{};const n=new Promise((s,o)=>{e=s,t=o}),r=k((s,o)=>{try{const c=new URL(s.url??"/","http://127.0.0.1"),d=c.searchParams.get("code"),u=c.searchParams.get("error");if(u){o.statusCode=400,o.setHeader("content-type","text/plain; charset=utf-8"),o.end(`Authorization error: ${u}
12
+
13
+ You can close this tab.`),t(new Error(`OAuth error: ${u}`));return}if(!d){o.statusCode=400,o.setHeader("content-type","text/plain; charset=utf-8"),o.end("Missing ?code parameter. You can close this tab.");return}o.statusCode=200,o.setHeader("content-type","text/plain; charset=utf-8"),o.end("hydra-acp-archiver: authorization complete. You can close this tab."),e(d)}catch(c){o.statusCode=500,o.end("internal error"),t(c)}});await new Promise((s,o)=>{r.once("error",o),r.listen(0,"127.0.0.1",()=>s())});const i=r.address();if(!i||typeof i=="string")throw r.close(),new Error("failed to determine callback server port");const a=i.port;return l.info(`listening for OAuth redirect on http://127.0.0.1:${a}`),{port:a,codePromise:n,close:()=>{try{r.close()}catch{}}}}export{$ as loadGoogleAuth,U as runGoogleLogin};