@hydra-acp/archiver 0.1.16 → 0.1.18
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 +26 -9
- package/dist/acp/attach.js +1 -201
- package/dist/acp/protocol.js +1 -13
- package/dist/archive-loop.js +1 -134
- package/dist/backend/encrypted.js +1 -59
- package/dist/backend/factory.js +1 -28
- package/dist/backend/fs.js +1 -78
- package/dist/backend/google-drive.js +1 -182
- package/dist/backend/s3.js +5 -189
- package/dist/backend/types.js +0 -2
- package/dist/bridge.js +1 -100
- package/dist/cold-sweep.js +1 -54
- package/dist/config.js +1 -159
- package/dist/daemon.js +1 -81
- package/dist/discovery.js +1 -82
- package/dist/envelope.js +1 -123
- package/dist/index.js +15 -241
- package/dist/keygen.js +8 -26
- package/dist/oauth/google.js +6 -195
- package/dist/pull-loop.js +1 -147
- package/dist/rule.js +1 -37
- package/dist/setup/conf-writer.js +4 -85
- package/dist/setup/downloads-scan.js +1 -44
- package/dist/setup/prompts.js +14 -123
- package/dist/setup/wizard.js +17 -415
- package/dist/state.js +1 -129
- package/dist/util/aws-credentials.js +1 -82
- package/dist/util/log.js +2 -46
- package/package.json +5 -4
- package/dist/acp/attach.js.map +0 -1
- package/dist/acp/protocol.js.map +0 -1
- package/dist/archive-loop.js.map +0 -1
- package/dist/backend/encrypted.js.map +0 -1
- package/dist/backend/factory.js.map +0 -1
- package/dist/backend/fs.js.map +0 -1
- package/dist/backend/google-drive.js.map +0 -1
- package/dist/backend/s3.js.map +0 -1
- package/dist/backend/types.js.map +0 -1
- package/dist/bridge.js.map +0 -1
- package/dist/cold-sweep.js.map +0 -1
- package/dist/config.js.map +0 -1
- package/dist/daemon.js.map +0 -1
- package/dist/discovery.js.map +0 -1
- package/dist/envelope.js.map +0 -1
- package/dist/index.js.map +0 -1
- package/dist/keygen.js.map +0 -1
- package/dist/oauth/google.js.map +0 -1
- package/dist/pull-loop.js.map +0 -1
- package/dist/rule.js.map +0 -1
- package/dist/setup/conf-writer.js.map +0 -1
- package/dist/setup/downloads-scan.js.map +0 -1
- package/dist/setup/prompts.js.map +0 -1
- package/dist/setup/wizard.js.map +0 -1
- package/dist/state.js.map +0 -1
- package/dist/util/aws-credentials.js.map +0 -1
- package/dist/util/log.js.map +0 -1
package/dist/setup/wizard.js
CHANGED
|
@@ -1,415 +1,17 @@
|
|
|
1
|
-
import {
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
const
|
|
16
|
-
|
|
17
|
-
const
|
|
18
|
-
const DIM = "\x1b[2m";
|
|
19
|
-
const RESET = "\x1b[0m";
|
|
20
|
-
function header(num, total, title) {
|
|
21
|
-
process.stdout.write(`\n ${BOLD}[${num}/${total}] ${title}${RESET}\n\n`);
|
|
22
|
-
}
|
|
23
|
-
function ok(msg) {
|
|
24
|
-
process.stdout.write(` ${GREEN}✓${RESET} ${msg}\n`);
|
|
25
|
-
}
|
|
26
|
-
function warn(msg) {
|
|
27
|
-
process.stdout.write(` ${YELLOW}⚠${RESET} ${msg}\n`);
|
|
28
|
-
}
|
|
29
|
-
function fail(msg) {
|
|
30
|
-
process.stderr.write(` ${RED}✗ ${msg}${RESET}\n`);
|
|
31
|
-
process.exit(1);
|
|
32
|
-
}
|
|
33
|
-
function info(msg) {
|
|
34
|
-
process.stdout.write(` ${msg}\n`);
|
|
35
|
-
}
|
|
36
|
-
function blank() {
|
|
37
|
-
process.stdout.write("\n");
|
|
38
|
-
}
|
|
39
|
-
function hasBin(name) {
|
|
40
|
-
const dirs = (process.env.PATH ?? "").split(delimiter);
|
|
41
|
-
const exts = process.platform === "win32" ? [".exe", ".cmd", ".bat", ""] : [""];
|
|
42
|
-
for (const dir of dirs) {
|
|
43
|
-
if (!dir)
|
|
44
|
-
continue;
|
|
45
|
-
for (const ext of exts) {
|
|
46
|
-
const full = join(dir, name + ext);
|
|
47
|
-
try {
|
|
48
|
-
if (statSync(full).isFile())
|
|
49
|
-
return true;
|
|
50
|
-
}
|
|
51
|
-
catch {
|
|
52
|
-
// ignore
|
|
53
|
-
}
|
|
54
|
-
}
|
|
55
|
-
}
|
|
56
|
-
return false;
|
|
57
|
-
}
|
|
58
|
-
const HYDRA_HOME = resolve(homedir(), ".hydra-acp");
|
|
59
|
-
const HYDRA_CONFIG_PATH = resolve(HYDRA_HOME, "config.json");
|
|
60
|
-
const DEFAULT_KEY_PATH = resolve(HYDRA_HOME, "archiver-key");
|
|
61
|
-
const DEFAULT_FS_DIR = resolve(HYDRA_HOME, "archive");
|
|
62
|
-
const DEFAULT_DRIVE_FOLDER = "hydra-acp-archive";
|
|
63
|
-
function readHydraConfigExtensions() {
|
|
64
|
-
try {
|
|
65
|
-
const cfg = JSON.parse(readFileSync(HYDRA_CONFIG_PATH, "utf8"));
|
|
66
|
-
return new Set(Object.keys(cfg.extensions ?? {}));
|
|
67
|
-
}
|
|
68
|
-
catch {
|
|
69
|
-
return new Set();
|
|
70
|
-
}
|
|
71
|
-
}
|
|
72
|
-
function keyFingerprint(keyPath) {
|
|
73
|
-
try {
|
|
74
|
-
const hex = readFileSync(keyPath, "utf8").trim();
|
|
75
|
-
if (!/^[0-9a-f]+$/i.test(hex))
|
|
76
|
-
return undefined;
|
|
77
|
-
const bytes = Buffer.from(hex, "hex");
|
|
78
|
-
if (bytes.length !== 32)
|
|
79
|
-
return undefined;
|
|
80
|
-
return createHash("sha256").update(bytes).digest().subarray(0, 8).toString("hex");
|
|
81
|
-
}
|
|
82
|
-
catch {
|
|
83
|
-
return undefined;
|
|
84
|
-
}
|
|
85
|
-
}
|
|
86
|
-
async function step1ExistingCheck() {
|
|
87
|
-
header(1, TOTAL_STEPS, "Checking existing setup");
|
|
88
|
-
info("This wizard configures a backend (Google Drive / S3 / Filesystem),");
|
|
89
|
-
info("optional encryption, and registers the archiver with hydra.");
|
|
90
|
-
blank();
|
|
91
|
-
const { map } = readExisting(PRIMARY_CONF_PATH);
|
|
92
|
-
if (map.size === 0) {
|
|
93
|
-
ok("No existing archiver config found.");
|
|
94
|
-
return { reconfigure: true };
|
|
95
|
-
}
|
|
96
|
-
const backend = map.get("BACKEND") ?? "google-drive";
|
|
97
|
-
info(`Existing config at ${PRIMARY_CONF_PATH}`);
|
|
98
|
-
info(` Backend: ${backend}`);
|
|
99
|
-
if (backend === "google-drive")
|
|
100
|
-
info(` Drive folder: ${map.get("DRIVE_FOLDER") ?? DEFAULT_DRIVE_FOLDER}`);
|
|
101
|
-
else if (backend === "s3")
|
|
102
|
-
info(` S3 bucket: ${map.get("S3_BUCKET") ?? "(missing)"}`);
|
|
103
|
-
else if (backend === "fs")
|
|
104
|
-
info(` FS dir: ${map.get("FS_DIR") ?? DEFAULT_FS_DIR}`);
|
|
105
|
-
const keyPath = map.get("KEY_PATH") ?? (existsSync(DEFAULT_KEY_PATH) ? DEFAULT_KEY_PATH : undefined);
|
|
106
|
-
if (map.get("KEY_PATH")) {
|
|
107
|
-
const fp = keyFingerprint(map.get("KEY_PATH"));
|
|
108
|
-
info(` Encryption: on${fp ? ` (fingerprint ${fp})` : ""}`);
|
|
109
|
-
}
|
|
110
|
-
else {
|
|
111
|
-
info(` Encryption: off`);
|
|
112
|
-
}
|
|
113
|
-
const registered = readHydraConfigExtensions().has("hydra-acp-archiver");
|
|
114
|
-
info(` Registered with hydra: ${registered ? "yes" : "no"}`);
|
|
115
|
-
blank();
|
|
116
|
-
if (!(await confirm("Reconfigure from scratch?", false))) {
|
|
117
|
-
info("Edit ~/.hydra-acp/archiver.conf directly to tune individual settings.");
|
|
118
|
-
return { reconfigure: false };
|
|
119
|
-
}
|
|
120
|
-
return { reconfigure: true };
|
|
121
|
-
}
|
|
122
|
-
async function step2PickBackend() {
|
|
123
|
-
header(2, TOTAL_STEPS, "Pick a backend");
|
|
124
|
-
const choices = [
|
|
125
|
-
{
|
|
126
|
-
backend: "google-drive",
|
|
127
|
-
label: "Google Drive\n Easiest cross-machine sync. Free up to 15 GB. One-time GCP\n setup (~5 min in the Cloud Console).",
|
|
128
|
-
},
|
|
129
|
-
{
|
|
130
|
-
backend: "s3",
|
|
131
|
-
label: "S3 / S3-compatible (R2, B2, MinIO, Wasabi)\n Best for larger archives. Needs AWS-style creds and an\n existing bucket.",
|
|
132
|
-
},
|
|
133
|
-
{
|
|
134
|
-
backend: "fs",
|
|
135
|
-
label: "Filesystem\n Useful with Syncthing/Dropbox mirroring a folder, or for\n local-only testing.",
|
|
136
|
-
},
|
|
137
|
-
];
|
|
138
|
-
const picked = await pickFromList("Choose:", choices, (c) => c.label);
|
|
139
|
-
if (!picked)
|
|
140
|
-
fail("A backend is required.");
|
|
141
|
-
ok(`Backend: ${picked.backend}`);
|
|
142
|
-
return { backend: picked.backend };
|
|
143
|
-
}
|
|
144
|
-
async function step3aGoogleDrive() {
|
|
145
|
-
header(3, TOTAL_STEPS, "Configure Google Drive");
|
|
146
|
-
const login = loadLoginConfig();
|
|
147
|
-
const credentialsPath = login.credentialsPath;
|
|
148
|
-
if (existsSync(credentialsPath)) {
|
|
149
|
-
ok(`Google OAuth credentials found at ${credentialsPath}.`);
|
|
150
|
-
}
|
|
151
|
-
else {
|
|
152
|
-
info("First-time setup. You need an OAuth client from Google Cloud Console.");
|
|
153
|
-
blank();
|
|
154
|
-
info(" 1. Pick or create a project");
|
|
155
|
-
info(" 2. APIs & Services → Library → enable Google Drive API");
|
|
156
|
-
info(" 3. OAuth consent screen → User type: External → add yourself as a Test User");
|
|
157
|
-
info(" 4. Credentials → Create credentials → OAuth client ID → Application: Desktop app");
|
|
158
|
-
info(" 5. Download the JSON (lands in ~/Downloads)");
|
|
159
|
-
blank();
|
|
160
|
-
await pause("Press Enter to open the Cloud Console...");
|
|
161
|
-
openBrowser("https://console.cloud.google.com/");
|
|
162
|
-
blank();
|
|
163
|
-
await pause("Press Enter once you've downloaded the JSON...");
|
|
164
|
-
const hit = scanDownloadsForGoogleCredentials();
|
|
165
|
-
let srcPath;
|
|
166
|
-
if (hit) {
|
|
167
|
-
blank();
|
|
168
|
-
info(`Found ${hit.path} (${formatAge(hit.ageMs)}).`);
|
|
169
|
-
if (await confirm("Use this file?", true))
|
|
170
|
-
srcPath = hit.path;
|
|
171
|
-
}
|
|
172
|
-
if (!srcPath) {
|
|
173
|
-
blank();
|
|
174
|
-
const manual = await ask("Path to the downloaded JSON");
|
|
175
|
-
if (!manual)
|
|
176
|
-
fail("A credentials file is required.");
|
|
177
|
-
srcPath = manual.startsWith("~/") ? manual.replace(/^~/, homedir()) : manual;
|
|
178
|
-
}
|
|
179
|
-
if (!existsSync(srcPath))
|
|
180
|
-
fail(`File not found: ${srcPath}`);
|
|
181
|
-
mkdirSync(HYDRA_HOME, { recursive: true });
|
|
182
|
-
copyFileSync(srcPath, credentialsPath);
|
|
183
|
-
try {
|
|
184
|
-
const { chmodSync } = await import("node:fs");
|
|
185
|
-
chmodSync(credentialsPath, 0o600);
|
|
186
|
-
}
|
|
187
|
-
catch {
|
|
188
|
-
// chmod not meaningful on win32
|
|
189
|
-
}
|
|
190
|
-
ok(`Saved to ${credentialsPath} (chmod 600).`);
|
|
191
|
-
}
|
|
192
|
-
blank();
|
|
193
|
-
const driveFolder = await ask("Drive folder name", DEFAULT_DRIVE_FOLDER);
|
|
194
|
-
blank();
|
|
195
|
-
info("Now we run Google's OAuth flow. Your browser will open to a consent");
|
|
196
|
-
info("screen. The 'Google hasn't verified this app' interstitial is expected");
|
|
197
|
-
info("for a personal OAuth client — click 'Advanced' → 'Go to (unsafe)' → 'Allow'.");
|
|
198
|
-
blank();
|
|
199
|
-
await pause("Press Enter to start OAuth...");
|
|
200
|
-
try {
|
|
201
|
-
await runGoogleLogin({ credentialsPath, tokenPath: login.tokenPath });
|
|
202
|
-
}
|
|
203
|
-
catch (err) {
|
|
204
|
-
fail(`Google OAuth failed: ${err.message}`);
|
|
205
|
-
}
|
|
206
|
-
ok(`OAuth complete. Token saved to ${login.tokenPath}.`);
|
|
207
|
-
return { driveFolder, credentialsPath };
|
|
208
|
-
}
|
|
209
|
-
async function step3bS3() {
|
|
210
|
-
header(3, TOTAL_STEPS, "Configure S3");
|
|
211
|
-
blank();
|
|
212
|
-
info("S3 endpoint hints:");
|
|
213
|
-
info(` ${DIM}AWS S3${RESET} leave Endpoint blank, set Region`);
|
|
214
|
-
info(` ${DIM}Cloudflare R2${RESET} https://<accountid>.r2.cloudflarestorage.com`);
|
|
215
|
-
info(` ${DIM}Backblaze B2${RESET} https://s3.<region>.backblazeb2.com`);
|
|
216
|
-
info(` ${DIM}MinIO${RESET} your MinIO endpoint URL`);
|
|
217
|
-
blank();
|
|
218
|
-
const bucket = await ask("S3 bucket name");
|
|
219
|
-
if (!bucket)
|
|
220
|
-
fail("A bucket name is required for S3.");
|
|
221
|
-
const region = (await ask("Region (blank for SDK default)")) || undefined;
|
|
222
|
-
const endpoint = (await ask("Endpoint URL (blank for AWS S3)")) || undefined;
|
|
223
|
-
blank();
|
|
224
|
-
try {
|
|
225
|
-
const creds = loadAwsCredentials();
|
|
226
|
-
const masked = `${creds.accessKeyId.slice(0, 4)}…${creds.accessKeyId.slice(-4)}`;
|
|
227
|
-
const source = process.env.AWS_ACCESS_KEY_ID ? "env vars" : `~/.aws/credentials (profile: ${process.env.AWS_PROFILE ?? "default"})`;
|
|
228
|
-
ok(`AWS creds found via ${source} — access key ${masked}.`);
|
|
229
|
-
}
|
|
230
|
-
catch (err) {
|
|
231
|
-
warn(`${err.message}`);
|
|
232
|
-
info("The archiver will fail to start until creds are in place.");
|
|
233
|
-
}
|
|
234
|
-
return { bucket, region, endpoint };
|
|
235
|
-
}
|
|
236
|
-
async function step3cFilesystem() {
|
|
237
|
-
header(3, TOTAL_STEPS, "Configure Filesystem");
|
|
238
|
-
const suggestions = [];
|
|
239
|
-
for (const candidate of ["Syncthing", "Dropbox", "iCloud Drive", "OneDrive"]) {
|
|
240
|
-
const full = resolve(homedir(), candidate);
|
|
241
|
-
if (existsSync(full))
|
|
242
|
-
suggestions.push(full);
|
|
243
|
-
}
|
|
244
|
-
let dir;
|
|
245
|
-
if (suggestions.length > 0) {
|
|
246
|
-
info("Found sync folders that could host the archive:");
|
|
247
|
-
blank();
|
|
248
|
-
suggestions.forEach((s, i) => info(` ${i + 1}. ${s}/hydra-acp-archive`));
|
|
249
|
-
info(` c. Custom path`);
|
|
250
|
-
info(` d. Default (${DEFAULT_FS_DIR})`);
|
|
251
|
-
blank();
|
|
252
|
-
const reply = (await ask("Choice", "d")).toLowerCase();
|
|
253
|
-
if (reply === "d" || reply === "") {
|
|
254
|
-
dir = DEFAULT_FS_DIR;
|
|
255
|
-
}
|
|
256
|
-
else if (reply === "c") {
|
|
257
|
-
const custom = await ask("Archive directory", DEFAULT_FS_DIR);
|
|
258
|
-
dir = custom.startsWith("~/") ? custom.replace(/^~/, homedir()) : custom;
|
|
259
|
-
}
|
|
260
|
-
else {
|
|
261
|
-
const n = Number.parseInt(reply, 10);
|
|
262
|
-
if (Number.isInteger(n) && n >= 1 && n <= suggestions.length)
|
|
263
|
-
dir = resolve(suggestions[n - 1], "hydra-acp-archive");
|
|
264
|
-
}
|
|
265
|
-
if (!dir)
|
|
266
|
-
fail("Invalid choice.");
|
|
267
|
-
}
|
|
268
|
-
else {
|
|
269
|
-
const raw = await ask("Archive directory", DEFAULT_FS_DIR);
|
|
270
|
-
dir = raw.startsWith("~/") ? raw.replace(/^~/, homedir()) : raw;
|
|
271
|
-
}
|
|
272
|
-
try {
|
|
273
|
-
mkdirSync(dir, { recursive: true });
|
|
274
|
-
const probe = resolve(dir, ".hydra-acp-archiver-test");
|
|
275
|
-
writeFileSync(probe, "ok");
|
|
276
|
-
rmSync(probe);
|
|
277
|
-
ok(`Directory writable: ${dir}`);
|
|
278
|
-
}
|
|
279
|
-
catch (err) {
|
|
280
|
-
fail(`Cannot write to ${dir}: ${err.message}`);
|
|
281
|
-
}
|
|
282
|
-
return { dir };
|
|
283
|
-
}
|
|
284
|
-
async function step4Encryption(backend) {
|
|
285
|
-
header(4, TOTAL_STEPS, "Encryption");
|
|
286
|
-
const defaultYes = backend !== "fs";
|
|
287
|
-
if (backend === "fs")
|
|
288
|
-
info("Filesystem backend — encryption is optional (you already control the disk).");
|
|
289
|
-
else
|
|
290
|
-
info(`${backend === "google-drive" ? "Google Drive" : "S3"} backend — encryption is recommended (data leaves your machine).`);
|
|
291
|
-
blank();
|
|
292
|
-
if (!(await confirm("Enable AES-256-GCM encryption at rest?", defaultYes))) {
|
|
293
|
-
return { enabled: false, keyPath: undefined };
|
|
294
|
-
}
|
|
295
|
-
const existingFp = keyFingerprint(DEFAULT_KEY_PATH);
|
|
296
|
-
if (existingFp) {
|
|
297
|
-
info(`Existing key at ${DEFAULT_KEY_PATH} (fingerprint ${existingFp}).`);
|
|
298
|
-
if (await confirm("Use this key?", true))
|
|
299
|
-
return { enabled: true, keyPath: DEFAULT_KEY_PATH };
|
|
300
|
-
blank();
|
|
301
|
-
warn("Rotating the key makes the existing archive unreadable until re-uploaded.");
|
|
302
|
-
if (!(await confirm("Generate a new key (overwrites the existing file)?", false)))
|
|
303
|
-
return { enabled: true, keyPath: DEFAULT_KEY_PATH };
|
|
304
|
-
}
|
|
305
|
-
blank();
|
|
306
|
-
await runKeygen();
|
|
307
|
-
blank();
|
|
308
|
-
info(`Copy ${DEFAULT_KEY_PATH} to each machine that should share this archive.`);
|
|
309
|
-
return { enabled: true, keyPath: DEFAULT_KEY_PATH };
|
|
310
|
-
}
|
|
311
|
-
async function step5WriteConfig(args) {
|
|
312
|
-
header(5, TOTAL_STEPS, "Writing config");
|
|
313
|
-
const login = loadLoginConfig();
|
|
314
|
-
const updates = {
|
|
315
|
-
BACKEND: args.backend,
|
|
316
|
-
};
|
|
317
|
-
if (args.backend === "google-drive" && args.google) {
|
|
318
|
-
if (args.google.driveFolder !== DEFAULT_DRIVE_FOLDER)
|
|
319
|
-
updates.DRIVE_FOLDER = args.google.driveFolder;
|
|
320
|
-
if (args.google.credentialsPath !== login.credentialsPath)
|
|
321
|
-
updates.GOOGLE_CREDENTIALS = args.google.credentialsPath;
|
|
322
|
-
}
|
|
323
|
-
else if (args.backend === "s3" && args.s3) {
|
|
324
|
-
updates.S3_BUCKET = args.s3.bucket;
|
|
325
|
-
if (args.s3.region)
|
|
326
|
-
updates.S3_REGION = args.s3.region;
|
|
327
|
-
if (args.s3.endpoint)
|
|
328
|
-
updates.S3_ENDPOINT = args.s3.endpoint;
|
|
329
|
-
}
|
|
330
|
-
else if (args.backend === "fs" && args.fs) {
|
|
331
|
-
if (args.fs.dir !== DEFAULT_FS_DIR)
|
|
332
|
-
updates.FS_DIR = args.fs.dir;
|
|
333
|
-
}
|
|
334
|
-
if (args.encryption.enabled && args.encryption.keyPath)
|
|
335
|
-
updates.KEY_PATH = args.encryption.keyPath;
|
|
336
|
-
writeConf(PRIMARY_CONF_PATH, updates);
|
|
337
|
-
ok(`Wrote ${PRIMARY_CONF_PATH} (chmod 600).`);
|
|
338
|
-
blank();
|
|
339
|
-
info("Final config:");
|
|
340
|
-
const { map } = readExisting(PRIMARY_CONF_PATH);
|
|
341
|
-
for (const [k, v] of map)
|
|
342
|
-
info(` ${k}=${v}`);
|
|
343
|
-
}
|
|
344
|
-
async function step6RegisterExtension() {
|
|
345
|
-
header(6, TOTAL_STEPS, "Register with hydra (optional)");
|
|
346
|
-
if (!hasBin("hydra-acp")) {
|
|
347
|
-
info("hydra-acp not found on PATH. Register manually later with:");
|
|
348
|
-
info(" hydra-acp extensions add hydra-acp-archiver");
|
|
349
|
-
return;
|
|
350
|
-
}
|
|
351
|
-
if (readHydraConfigExtensions().has("hydra-acp-archiver")) {
|
|
352
|
-
ok("Already registered as a hydra extension.");
|
|
353
|
-
info("Restart the daemon to pick up the new config: hydra-acp daemon restart");
|
|
354
|
-
return;
|
|
355
|
-
}
|
|
356
|
-
info("hydra can manage hydra-acp-archiver as a subprocess that auto-starts");
|
|
357
|
-
info("with the daemon. This adds an entry to ~/.hydra-acp/config.json.");
|
|
358
|
-
blank();
|
|
359
|
-
if (!(await confirm("Register hydra-acp-archiver as a hydra extension?", true))) {
|
|
360
|
-
info("Skipping. Register later with:");
|
|
361
|
-
info(" hydra-acp extensions add hydra-acp-archiver");
|
|
362
|
-
return;
|
|
363
|
-
}
|
|
364
|
-
const cmdArgs = ["extensions", "add", "hydra-acp-archiver"];
|
|
365
|
-
if (!hasBin("hydra-acp-archiver")) {
|
|
366
|
-
const scriptPath = process.argv[1] ?? "";
|
|
367
|
-
if (!scriptPath) {
|
|
368
|
-
warn("Couldn't determine script path; falling back to bare command.");
|
|
369
|
-
}
|
|
370
|
-
else if (scriptPath.includes("/.npm/_npx/")) {
|
|
371
|
-
warn("Looks like you're running via npx — registering this transient path");
|
|
372
|
-
warn("would break on the next npx cache cleanup. Install globally first:");
|
|
373
|
-
info(" npm install -g @hydra-acp/archiver");
|
|
374
|
-
info("Then register with: hydra-acp extensions add hydra-acp-archiver");
|
|
375
|
-
return;
|
|
376
|
-
}
|
|
377
|
-
else {
|
|
378
|
-
cmdArgs.push("--command", "node", "--args", scriptPath);
|
|
379
|
-
}
|
|
380
|
-
}
|
|
381
|
-
info(`Running: hydra-acp ${cmdArgs.join(" ")}`);
|
|
382
|
-
const result = spawnSync("hydra-acp", cmdArgs, { stdio: "inherit" });
|
|
383
|
-
if (result.status === 0) {
|
|
384
|
-
ok("Registered.");
|
|
385
|
-
info("Start the daemon (or restart if already running): hydra-acp daemon restart");
|
|
386
|
-
}
|
|
387
|
-
else {
|
|
388
|
-
blank();
|
|
389
|
-
warn(`hydra-acp exited with code ${result.status ?? "?"}.`);
|
|
390
|
-
info("Register manually later with:");
|
|
391
|
-
info(` hydra-acp ${cmdArgs.join(" ")}`);
|
|
392
|
-
}
|
|
393
|
-
}
|
|
394
|
-
export async function runSetup() {
|
|
395
|
-
process.stdout.write(`\n ${BOLD}hydra-acp-archiver setup${RESET}\n`);
|
|
396
|
-
const step1 = await step1ExistingCheck();
|
|
397
|
-
if (!step1.reconfigure)
|
|
398
|
-
return;
|
|
399
|
-
const { backend } = await step2PickBackend();
|
|
400
|
-
let google;
|
|
401
|
-
let s3;
|
|
402
|
-
let fs;
|
|
403
|
-
if (backend === "google-drive")
|
|
404
|
-
google = await step3aGoogleDrive();
|
|
405
|
-
else if (backend === "s3")
|
|
406
|
-
s3 = await step3bS3();
|
|
407
|
-
else
|
|
408
|
-
fs = await step3cFilesystem();
|
|
409
|
-
const encryption = await step4Encryption(backend);
|
|
410
|
-
await step5WriteConfig({ backend, google, s3, fs, encryption });
|
|
411
|
-
await step6RegisterExtension();
|
|
412
|
-
blank();
|
|
413
|
-
ok("Setup complete.");
|
|
414
|
-
}
|
|
415
|
-
//# sourceMappingURL=wizard.js.map
|
|
1
|
+
import{spawnSync as G}from"node:child_process";import{createHash as T}from"node:crypto";import{copyFileSync as I,existsSync as w,mkdirSync as A,readFileSync as P,rmSync as B,statSync as L,writeFileSync as N}from"node:fs";import{homedir as v}from"node:os";import{delimiter as H,join as K,resolve as p}from"node:path";import{loadLoginConfig as x}from"../config.js";import{runKeygen as W}from"../keygen.js";import{runGoogleLogin as Y}from"../oauth/google.js";import{loadAwsCredentials as U}from"../util/aws-credentials.js";import{PRIMARY_CONF_PATH as b,readExisting as C,writeConf as M}from"./conf-writer.js";import{formatAge as j,scanDownloadsForGoogleCredentials as z}from"./downloads-scan.js";import{ask as l,confirm as y,openBrowser as J,pause as E,pickFromList as q}from"./prompts.js";const u=6,D="\x1B[1m",V="\x1B[32m",Q="\x1B[33m",X="\x1B[31m",S="\x1B[2m",d="\x1B[0m";function f(e,n,i){process.stdout.write(`
|
|
2
|
+
${D}[${e}/${n}] ${i}${d}
|
|
3
|
+
|
|
4
|
+
`)}function c(e){process.stdout.write(` ${V}\u2713${d} ${e}
|
|
5
|
+
`)}function m(e){process.stdout.write(` ${Q}\u26A0${d} ${e}
|
|
6
|
+
`)}function g(e){process.stderr.write(` ${X}\u2717 ${e}${d}
|
|
7
|
+
`),process.exit(1)}function t(e){process.stdout.write(` ${e}
|
|
8
|
+
`)}function o(){process.stdout.write(`
|
|
9
|
+
`)}function F(e){const n=(process.env.PATH??"").split(H),i=process.platform==="win32"?[".exe",".cmd",".bat",""]:[""];for(const r of n)if(r)for(const s of i){const a=K(r,e+s);try{if(L(a).isFile())return!0}catch{}}return!1}const $=p(v(),".hydra-acp"),Z=p($,"config.json"),h=p($,"archiver-key"),k=p($,"archive"),R="hydra-acp-archive";function O(){try{const e=JSON.parse(P(Z,"utf8"));return new Set(Object.keys(e.extensions??{}))}catch{return new Set}}function _(e){try{const n=P(e,"utf8").trim();if(!/^[0-9a-f]+$/i.test(n))return;const i=Buffer.from(n,"hex");return i.length!==32?void 0:T("sha256").update(i).digest().subarray(0,8).toString("hex")}catch{return}}async function ee(){f(1,u,"Checking existing setup"),t("This wizard configures a backend (Google Drive / S3 / Filesystem),"),t("optional encryption, and registers the archiver with hydra."),o();const{map:e}=C(b);if(e.size===0)return c("No existing archiver config found."),{reconfigure:!0};const n=e.get("BACKEND")??"google-drive";t(`Existing config at ${b}`),t(` Backend: ${n}`),n==="google-drive"?t(` Drive folder: ${e.get("DRIVE_FOLDER")??R}`):n==="s3"?t(` S3 bucket: ${e.get("S3_BUCKET")??"(missing)"}`):n==="fs"&&t(` FS dir: ${e.get("FS_DIR")??k}`);const i=e.get("KEY_PATH")??(w(h)?h:void 0);if(e.get("KEY_PATH")){const s=_(e.get("KEY_PATH"));t(` Encryption: on${s?` (fingerprint ${s})`:""}`)}else t(" Encryption: off");const r=O().has("hydra-acp-archiver");return t(` Registered with hydra: ${r?"yes":"no"}`),o(),await y("Reconfigure from scratch?",!1)?{reconfigure:!0}:(t("Edit ~/.hydra-acp/archiver.conf directly to tune individual settings."),{reconfigure:!1})}async function te(){f(2,u,"Pick a backend");const n=await q("Choose:",[{backend:"google-drive",label:`Google Drive
|
|
10
|
+
Easiest cross-machine sync. Free up to 15 GB. One-time GCP
|
|
11
|
+
setup (~5 min in the Cloud Console).`},{backend:"s3",label:`S3 / S3-compatible (R2, B2, MinIO, Wasabi)
|
|
12
|
+
Best for larger archives. Needs AWS-style creds and an
|
|
13
|
+
existing bucket.`},{backend:"fs",label:`Filesystem
|
|
14
|
+
Useful with Syncthing/Dropbox mirroring a folder, or for
|
|
15
|
+
local-only testing.`}],i=>i.label);return n||g("A backend is required."),c(`Backend: ${n.backend}`),{backend:n.backend}}async function ne(){f(3,u,"Configure Google Drive");const e=x(),n=e.credentialsPath;if(w(n))c(`Google OAuth credentials found at ${n}.`);else{t("First-time setup. You need an OAuth client from Google Cloud Console."),o(),t(" 1. Pick or create a project"),t(" 2. APIs & Services \u2192 Library \u2192 enable Google Drive API"),t(" 3. OAuth consent screen \u2192 User type: External \u2192 add yourself as a Test User"),t(" 4. Credentials \u2192 Create credentials \u2192 OAuth client ID \u2192 Application: Desktop app"),t(" 5. Download the JSON (lands in ~/Downloads)"),o(),await E("Press Enter to open the Cloud Console..."),J("https://console.cloud.google.com/"),o(),await E("Press Enter once you've downloaded the JSON...");const r=z();let s;if(r&&(o(),t(`Found ${r.path} (${j(r.ageMs)}).`),await y("Use this file?",!0)&&(s=r.path)),!s){o();const a=await l("Path to the downloaded JSON");a||g("A credentials file is required."),s=a.startsWith("~/")?a.replace(/^~/,v()):a}w(s)||g(`File not found: ${s}`),A($,{recursive:!0}),I(s,n);try{const{chmodSync:a}=await import("node:fs");a(n,384)}catch{}c(`Saved to ${n} (chmod 600).`)}o();const i=await l("Drive folder name",R);o(),t("Now we run Google's OAuth flow. Your browser will open to a consent"),t("screen. The 'Google hasn't verified this app' interstitial is expected"),t("for a personal OAuth client \u2014 click 'Advanced' \u2192 'Go to (unsafe)' \u2192 'Allow'."),o(),await E("Press Enter to start OAuth...");try{await Y({credentialsPath:n,tokenPath:e.tokenPath})}catch(r){g(`Google OAuth failed: ${r.message}`)}return c(`OAuth complete. Token saved to ${e.tokenPath}.`),{driveFolder:i,credentialsPath:n}}async function ie(){f(3,u,"Configure S3"),o(),t("S3 endpoint hints:"),t(` ${S}AWS S3${d} leave Endpoint blank, set Region`),t(` ${S}Cloudflare R2${d} https://<accountid>.r2.cloudflarestorage.com`),t(` ${S}Backblaze B2${d} https://s3.<region>.backblazeb2.com`),t(` ${S}MinIO${d} your MinIO endpoint URL`),o();const e=await l("S3 bucket name");e||g("A bucket name is required for S3.");const n=await l("Region (blank for SDK default)")||void 0,i=await l("Endpoint URL (blank for AWS S3)")||void 0;o();try{const r=U(),s=`${r.accessKeyId.slice(0,4)}\u2026${r.accessKeyId.slice(-4)}`,a=process.env.AWS_ACCESS_KEY_ID?"env vars":`~/.aws/credentials (profile: ${process.env.AWS_PROFILE??"default"})`;c(`AWS creds found via ${a} \u2014 access key ${s}.`)}catch(r){m(`${r.message}`),t("The archiver will fail to start until creds are in place.")}return{bucket:e,region:n,endpoint:i}}async function re(){f(3,u,"Configure Filesystem");const e=[];for(const i of["Syncthing","Dropbox","iCloud Drive","OneDrive"]){const r=p(v(),i);w(r)&&e.push(r)}let n;if(e.length>0){t("Found sync folders that could host the archive:"),o(),e.forEach((r,s)=>t(` ${s+1}. ${r}/hydra-acp-archive`)),t(" c. Custom path"),t(` d. Default (${k})`),o();const i=(await l("Choice","d")).toLowerCase();if(i==="d"||i==="")n=k;else if(i==="c"){const r=await l("Archive directory",k);n=r.startsWith("~/")?r.replace(/^~/,v()):r}else{const r=Number.parseInt(i,10);Number.isInteger(r)&&r>=1&&r<=e.length&&(n=p(e[r-1],"hydra-acp-archive"))}n||g("Invalid choice.")}else{const i=await l("Archive directory",k);n=i.startsWith("~/")?i.replace(/^~/,v()):i}try{A(n,{recursive:!0});const i=p(n,".hydra-acp-archiver-test");N(i,"ok"),B(i),c(`Directory writable: ${n}`)}catch(i){g(`Cannot write to ${n}: ${i.message}`)}return{dir:n}}async function oe(e){f(4,u,"Encryption");const n=e!=="fs";if(t(e==="fs"?"Filesystem backend \u2014 encryption is optional (you already control the disk).":`${e==="google-drive"?"Google Drive":"S3"} backend \u2014 encryption is recommended (data leaves your machine).`),o(),!await y("Enable AES-256-GCM encryption at rest?",n))return{enabled:!1,keyPath:void 0};const i=_(h);if(i){if(t(`Existing key at ${h} (fingerprint ${i}).`),await y("Use this key?",!0))return{enabled:!0,keyPath:h};if(o(),m("Rotating the key makes the existing archive unreadable until re-uploaded."),!await y("Generate a new key (overwrites the existing file)?",!1))return{enabled:!0,keyPath:h}}return o(),await W(),o(),t(`Copy ${h} to each machine that should share this archive.`),{enabled:!0,keyPath:h}}async function se(e){f(5,u,"Writing config");const n=x(),i={BACKEND:e.backend};e.backend==="google-drive"&&e.google?(e.google.driveFolder!==R&&(i.DRIVE_FOLDER=e.google.driveFolder),e.google.credentialsPath!==n.credentialsPath&&(i.GOOGLE_CREDENTIALS=e.google.credentialsPath)):e.backend==="s3"&&e.s3?(i.S3_BUCKET=e.s3.bucket,e.s3.region&&(i.S3_REGION=e.s3.region),e.s3.endpoint&&(i.S3_ENDPOINT=e.s3.endpoint)):e.backend==="fs"&&e.fs&&e.fs.dir!==k&&(i.FS_DIR=e.fs.dir),e.encryption.enabled&&e.encryption.keyPath&&(i.KEY_PATH=e.encryption.keyPath),M(b,i),c(`Wrote ${b} (chmod 600).`),o(),t("Final config:");const{map:r}=C(b);for(const[s,a]of r)t(` ${s}=${a}`)}async function ae(){if(f(6,u,"Register with hydra (optional)"),!F("hydra-acp")){t("hydra-acp not found on PATH. Register manually later with:"),t(" hydra-acp extensions add hydra-acp-archiver");return}if(O().has("hydra-acp-archiver")){c("Already registered as a hydra extension."),t("Restart the daemon to pick up the new config: hydra-acp daemon restart");return}if(t("hydra can manage hydra-acp-archiver as a subprocess that auto-starts"),t("with the daemon. This adds an entry to ~/.hydra-acp/config.json."),o(),!await y("Register hydra-acp-archiver as a hydra extension?",!0)){t("Skipping. Register later with:"),t(" hydra-acp extensions add hydra-acp-archiver");return}const e=["extensions","add","hydra-acp-archiver"];if(!F("hydra-acp-archiver")){const i=process.argv[1]??"";if(!i)m("Couldn't determine script path; falling back to bare command.");else if(i.includes("/.npm/_npx/")){m("Looks like you're running via npx \u2014 registering this transient path"),m("would break on the next npx cache cleanup. Install globally first:"),t(" npm install -g @hydra-acp/archiver"),t("Then register with: hydra-acp extensions add hydra-acp-archiver");return}else e.push("--command","node","--args",i)}t(`Running: hydra-acp ${e.join(" ")}`);const n=G("hydra-acp",e,{stdio:"inherit"});n.status===0?(c("Registered."),t("Start the daemon (or restart if already running): hydra-acp daemon restart")):(o(),m(`hydra-acp exited with code ${n.status??"?"}.`),t("Register manually later with:"),t(` hydra-acp ${e.join(" ")}`))}async function Se(){if(process.stdout.write(`
|
|
16
|
+
${D}hydra-acp-archiver setup${d}
|
|
17
|
+
`),!(await ee()).reconfigure)return;const{backend:n}=await te();let i,r,s;n==="google-drive"?i=await ne():n==="s3"?r=await ie():s=await re();const a=await oe(n);await se({backend:n,google:i,s3:r,fs:s,encryption:a}),await ae(),o(),c("Setup complete.")}export{Se as runSetup};
|
package/dist/state.js
CHANGED
|
@@ -1,129 +1 @@
|
|
|
1
|
-
import
|
|
2
|
-
import { dirname } from "node:path";
|
|
3
|
-
import { logger } from "./util/log.js";
|
|
4
|
-
const log = logger("state");
|
|
5
|
-
function emptyState(appVersion, prefix, backend) {
|
|
6
|
-
return { appVersion, prefix, backend, lineages: {} };
|
|
7
|
-
}
|
|
8
|
-
// All writes go through a single in-process queue so concurrent
|
|
9
|
-
// setLineageState calls don't interleave their read-modify-write cycles
|
|
10
|
-
// and lose updates. Cross-process safety is out of scope — only one
|
|
11
|
-
// archiver runs per hydra daemon.
|
|
12
|
-
export class SyncState {
|
|
13
|
-
path;
|
|
14
|
-
cache;
|
|
15
|
-
writeChain = Promise.resolve();
|
|
16
|
-
constructor(path) {
|
|
17
|
-
this.path = path;
|
|
18
|
-
}
|
|
19
|
-
async load(appVersion, prefix, backend) {
|
|
20
|
-
try {
|
|
21
|
-
const text = await readFile(this.path, "utf8");
|
|
22
|
-
const parsed = JSON.parse(text);
|
|
23
|
-
if (!parsed.lineages) {
|
|
24
|
-
log.warn(`state file at ${this.path} had unexpected shape; ignoring`);
|
|
25
|
-
this.cache = emptyState(appVersion, prefix, backend);
|
|
26
|
-
return;
|
|
27
|
-
}
|
|
28
|
-
const storedVersion = parsed.appVersion;
|
|
29
|
-
const storedPrefix = parsed.prefix;
|
|
30
|
-
const storedBackend = parsed.backend;
|
|
31
|
-
this.cache = parsed;
|
|
32
|
-
this.cache.appVersion = appVersion;
|
|
33
|
-
this.cache.prefix = prefix;
|
|
34
|
-
this.cache.backend = backend;
|
|
35
|
-
const versionChanged = storedVersion !== appVersion;
|
|
36
|
-
const namespaceChanged = storedPrefix !== prefix || storedBackend !== backend;
|
|
37
|
-
if (versionChanged || namespaceChanged) {
|
|
38
|
-
if (versionChanged) {
|
|
39
|
-
log.info(`version changed (${storedVersion ?? "?"} → ${appVersion}); resetting pull state`);
|
|
40
|
-
}
|
|
41
|
-
if (namespaceChanged) {
|
|
42
|
-
log.info(`namespace changed (${storedBackend ?? "?"}:${storedPrefix ?? "none"} → ${backend}:${prefix}); resetting pull state`);
|
|
43
|
-
}
|
|
44
|
-
for (const entry of Object.values(this.cache.lineages)) {
|
|
45
|
-
delete entry.lastSeenRemoteUploadedAt;
|
|
46
|
-
delete entry.lastSeenRemoteBy;
|
|
47
|
-
delete entry.importedSessionId;
|
|
48
|
-
}
|
|
49
|
-
await this.flush();
|
|
50
|
-
}
|
|
51
|
-
}
|
|
52
|
-
catch (err) {
|
|
53
|
-
const e = err;
|
|
54
|
-
if (e.code === "ENOENT") {
|
|
55
|
-
this.cache = emptyState(appVersion, prefix, backend);
|
|
56
|
-
return;
|
|
57
|
-
}
|
|
58
|
-
log.warn(`failed to read ${this.path}: ${e.message}; starting fresh`);
|
|
59
|
-
this.cache = emptyState(appVersion, prefix, backend);
|
|
60
|
-
}
|
|
61
|
-
}
|
|
62
|
-
get(lineageId) {
|
|
63
|
-
if (!this.cache) {
|
|
64
|
-
throw new Error("SyncState.get called before load()");
|
|
65
|
-
}
|
|
66
|
-
return this.cache.lineages[lineageId] ?? {};
|
|
67
|
-
}
|
|
68
|
-
set(lineageId, patch) {
|
|
69
|
-
if (!this.cache) {
|
|
70
|
-
throw new Error("SyncState.set called before load()");
|
|
71
|
-
}
|
|
72
|
-
const prev = this.cache.lineages[lineageId] ?? {};
|
|
73
|
-
this.cache.lineages[lineageId] = { ...prev, ...patch };
|
|
74
|
-
return this.flush();
|
|
75
|
-
}
|
|
76
|
-
lineageIds() {
|
|
77
|
-
if (!this.cache)
|
|
78
|
-
throw new Error("SyncState.lineageIds called before load()");
|
|
79
|
-
return Object.keys(this.cache.lineages);
|
|
80
|
-
}
|
|
81
|
-
// Clear pull-side state for a lineage whose imported session was deleted,
|
|
82
|
-
// so the pull loop will treat the next peer envelope as unseen.
|
|
83
|
-
async resetImport(lineageId) {
|
|
84
|
-
if (!this.cache)
|
|
85
|
-
throw new Error("SyncState.resetImport called before load()");
|
|
86
|
-
const entry = this.cache.lineages[lineageId];
|
|
87
|
-
if (!entry)
|
|
88
|
-
return;
|
|
89
|
-
delete entry.lastSeenRemoteUploadedAt;
|
|
90
|
-
delete entry.lastSeenRemoteBy;
|
|
91
|
-
delete entry.importedSessionId;
|
|
92
|
-
return this.flush();
|
|
93
|
-
}
|
|
94
|
-
// Drop any lineage entry whose key isn't present on the backend.
|
|
95
|
-
// Called once at startup so that a wiped backend (Drive nuke, retention
|
|
96
|
-
// delete, etc.) doesn't leave us with stale "I already uploaded" beliefs
|
|
97
|
-
// — the next flush re-uploads from scratch. State stays a hint cache;
|
|
98
|
-
// the backend stays the source of truth.
|
|
99
|
-
async reconcile(presentLineageIds) {
|
|
100
|
-
if (!this.cache) {
|
|
101
|
-
throw new Error("SyncState.reconcile called before load()");
|
|
102
|
-
}
|
|
103
|
-
let pruned = 0;
|
|
104
|
-
for (const id of Object.keys(this.cache.lineages)) {
|
|
105
|
-
if (!presentLineageIds.has(id)) {
|
|
106
|
-
delete this.cache.lineages[id];
|
|
107
|
-
pruned += 1;
|
|
108
|
-
}
|
|
109
|
-
}
|
|
110
|
-
if (pruned > 0) {
|
|
111
|
-
await this.flush();
|
|
112
|
-
}
|
|
113
|
-
return pruned;
|
|
114
|
-
}
|
|
115
|
-
flush() {
|
|
116
|
-
const snapshot = JSON.stringify(this.cache, null, 2);
|
|
117
|
-
const next = this.writeChain.then(async () => {
|
|
118
|
-
await mkdir(dirname(this.path), { recursive: true });
|
|
119
|
-
const tmp = `${this.path}.tmp`;
|
|
120
|
-
await writeFile(tmp, snapshot, { mode: 0o600 });
|
|
121
|
-
await rename(tmp, this.path);
|
|
122
|
-
});
|
|
123
|
-
this.writeChain = next.catch((err) => {
|
|
124
|
-
log.warn(`failed to persist ${this.path}: ${err.message}`);
|
|
125
|
-
});
|
|
126
|
-
return next;
|
|
127
|
-
}
|
|
128
|
-
}
|
|
129
|
-
//# sourceMappingURL=state.js.map
|
|
1
|
+
import{mkdir as p,readFile as m,rename as S,writeFile as u}from"node:fs/promises";import{dirname as w}from"node:path";import{logger as y}from"./util/log.js";const a=y("state");function o(c,t,e){return{appVersion:c,prefix:t,backend:e,lineages:{}}}class P{constructor(t){this.path=t}path;cache;writeChain=Promise.resolve();async load(t,e,s){try{const r=await m(this.path,"utf8"),i=JSON.parse(r);if(!i.lineages){a.warn(`state file at ${this.path} had unexpected shape; ignoring`),this.cache=o(t,e,s);return}const h=i.appVersion,l=i.prefix,d=i.backend;this.cache=i,this.cache.appVersion=t,this.cache.prefix=e,this.cache.backend=s;const g=h!==t,f=l!==e||d!==s;if(g||f){g&&a.info(`version changed (${h??"?"} \u2192 ${t}); resetting pull state`),f&&a.info(`namespace changed (${d??"?"}:${l??"none"} \u2192 ${s}:${e}); resetting pull state`);for(const n of Object.values(this.cache.lineages))delete n.lastSeenRemoteUploadedAt,delete n.lastSeenRemoteBy,delete n.importedSessionId;await this.flush()}}catch(r){const i=r;if(i.code==="ENOENT"){this.cache=o(t,e,s);return}a.warn(`failed to read ${this.path}: ${i.message}; starting fresh`),this.cache=o(t,e,s)}}get(t){if(!this.cache)throw new Error("SyncState.get called before load()");return this.cache.lineages[t]??{}}set(t,e){if(!this.cache)throw new Error("SyncState.set called before load()");const s=this.cache.lineages[t]??{};return this.cache.lineages[t]={...s,...e},this.flush()}lineageIds(){if(!this.cache)throw new Error("SyncState.lineageIds called before load()");return Object.keys(this.cache.lineages)}async resetImport(t){if(!this.cache)throw new Error("SyncState.resetImport called before load()");const e=this.cache.lineages[t];if(e)return delete e.lastSeenRemoteUploadedAt,delete e.lastSeenRemoteBy,delete e.importedSessionId,this.flush()}async reconcile(t){if(!this.cache)throw new Error("SyncState.reconcile called before load()");let e=0;for(const s of Object.keys(this.cache.lineages))t.has(s)||(delete this.cache.lineages[s],e+=1);return e>0&&await this.flush(),e}flush(){const t=JSON.stringify(this.cache,null,2),e=this.writeChain.then(async()=>{await p(w(this.path),{recursive:!0});const s=`${this.path}.tmp`;await u(s,t,{mode:384}),await S(s,this.path)});return this.writeChain=e.catch(s=>{a.warn(`failed to persist ${this.path}: ${s.message}`)}),e}}export{P as SyncState};
|
|
@@ -1,82 +1 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import { homedir } from "node:os";
|
|
3
|
-
import { resolve } from "node:path";
|
|
4
|
-
function parseIni(text) {
|
|
5
|
-
const sections = new Map();
|
|
6
|
-
let current;
|
|
7
|
-
for (const raw of text.split(/\r?\n/)) {
|
|
8
|
-
const line = raw.trim();
|
|
9
|
-
if (!line || line.startsWith("#") || line.startsWith(";"))
|
|
10
|
-
continue;
|
|
11
|
-
if (line.startsWith("[") && line.endsWith("]")) {
|
|
12
|
-
// config file uses "[profile name]", credentials file uses "[name]"
|
|
13
|
-
const name = line.slice(1, -1).trim().replace(/^profile\s+/, "");
|
|
14
|
-
current = new Map();
|
|
15
|
-
sections.set(name, current);
|
|
16
|
-
}
|
|
17
|
-
else if (current) {
|
|
18
|
-
const eq = line.indexOf("=");
|
|
19
|
-
if (eq >= 0)
|
|
20
|
-
current.set(line.slice(0, eq).trim(), line.slice(eq + 1).trim());
|
|
21
|
-
}
|
|
22
|
-
}
|
|
23
|
-
return sections;
|
|
24
|
-
}
|
|
25
|
-
function readSection(path, profile) {
|
|
26
|
-
try {
|
|
27
|
-
return parseIni(readFileSync(path, "utf8")).get(profile);
|
|
28
|
-
}
|
|
29
|
-
catch {
|
|
30
|
-
return undefined;
|
|
31
|
-
}
|
|
32
|
-
}
|
|
33
|
-
// Credential chain: env vars > ~/.aws/credentials > ~/.aws/config
|
|
34
|
-
export function loadAwsCredentials(profile) {
|
|
35
|
-
const profileName = profile ??
|
|
36
|
-
process.env.AWS_PROFILE ??
|
|
37
|
-
process.env.AWS_DEFAULT_PROFILE ??
|
|
38
|
-
"default";
|
|
39
|
-
const envKey = process.env.AWS_ACCESS_KEY_ID;
|
|
40
|
-
const envSecret = process.env.AWS_SECRET_ACCESS_KEY;
|
|
41
|
-
if (envKey && envSecret) {
|
|
42
|
-
return {
|
|
43
|
-
accessKeyId: envKey,
|
|
44
|
-
secretAccessKey: envSecret,
|
|
45
|
-
sessionToken: process.env.AWS_SESSION_TOKEN,
|
|
46
|
-
};
|
|
47
|
-
}
|
|
48
|
-
const home = homedir();
|
|
49
|
-
const credsPath = process.env.AWS_SHARED_CREDENTIALS_FILE ??
|
|
50
|
-
resolve(home, ".aws", "credentials");
|
|
51
|
-
const cfgPath = process.env.AWS_CONFIG_FILE ?? resolve(home, ".aws", "config");
|
|
52
|
-
const creds = readSection(credsPath, profileName);
|
|
53
|
-
const cfg = readSection(cfgPath, profileName);
|
|
54
|
-
const accessKeyId = creds?.get("aws_access_key_id") ?? cfg?.get("aws_access_key_id");
|
|
55
|
-
const secretAccessKey = creds?.get("aws_secret_access_key") ?? cfg?.get("aws_secret_access_key");
|
|
56
|
-
if (!accessKeyId || !secretAccessKey) {
|
|
57
|
-
throw new Error(`No AWS credentials found for profile "${profileName}". ` +
|
|
58
|
-
`Set AWS_ACCESS_KEY_ID and AWS_SECRET_ACCESS_KEY, or configure ~/.aws/credentials.`);
|
|
59
|
-
}
|
|
60
|
-
return {
|
|
61
|
-
accessKeyId,
|
|
62
|
-
secretAccessKey,
|
|
63
|
-
sessionToken: creds?.get("aws_session_token") ?? cfg?.get("aws_session_token"),
|
|
64
|
-
};
|
|
65
|
-
}
|
|
66
|
-
// Region resolution: explicit > env vars > ~/.aws/config > us-east-1
|
|
67
|
-
export function resolveRegion(explicit, profile) {
|
|
68
|
-
if (explicit)
|
|
69
|
-
return explicit;
|
|
70
|
-
if (process.env.AWS_REGION)
|
|
71
|
-
return process.env.AWS_REGION;
|
|
72
|
-
if (process.env.AWS_DEFAULT_REGION)
|
|
73
|
-
return process.env.AWS_DEFAULT_REGION;
|
|
74
|
-
const profileName = profile ??
|
|
75
|
-
process.env.AWS_PROFILE ??
|
|
76
|
-
process.env.AWS_DEFAULT_PROFILE ??
|
|
77
|
-
"default";
|
|
78
|
-
const cfgPath = process.env.AWS_CONFIG_FILE ??
|
|
79
|
-
resolve(homedir(), ".aws", "config");
|
|
80
|
-
return readSection(cfgPath, profileName)?.get("region") ?? "us-east-1";
|
|
81
|
-
}
|
|
82
|
-
//# sourceMappingURL=aws-credentials.js.map
|
|
1
|
+
import{readFileSync as A}from"node:fs";import{homedir as S}from"node:os";import{resolve as a}from"node:path";function E(t){const s=new Map;let n;for(const c of t.split(/\r?\n/)){const e=c.trim();if(!(!e||e.startsWith("#")||e.startsWith(";"))){if(e.startsWith("[")&&e.endsWith("]")){const r=e.slice(1,-1).trim().replace(/^profile\s+/,"");n=new Map,s.set(r,n)}else if(n){const r=e.indexOf("=");r>=0&&n.set(e.slice(0,r).trim(),e.slice(r+1).trim())}}}return s}function _(t,s){try{return E(A(t,"utf8")).get(s)}catch{return}}function I(t){const s=t??process.env.AWS_PROFILE??process.env.AWS_DEFAULT_PROFILE??"default",n=process.env.AWS_ACCESS_KEY_ID,c=process.env.AWS_SECRET_ACCESS_KEY;if(n&&c)return{accessKeyId:n,secretAccessKey:c,sessionToken:process.env.AWS_SESSION_TOKEN};const e=S(),r=process.env.AWS_SHARED_CREDENTIALS_FILE??a(e,".aws","credentials"),p=process.env.AWS_CONFIG_FILE??a(e,".aws","config"),i=_(r,s),o=_(p,s),f=i?.get("aws_access_key_id")??o?.get("aws_access_key_id"),g=i?.get("aws_secret_access_key")??o?.get("aws_secret_access_key");if(!f||!g)throw new Error(`No AWS credentials found for profile "${s}". Set AWS_ACCESS_KEY_ID and AWS_SECRET_ACCESS_KEY, or configure ~/.aws/credentials.`);return{accessKeyId:f,secretAccessKey:g,sessionToken:i?.get("aws_session_token")??o?.get("aws_session_token")}}function W(t,s){if(t)return t;if(process.env.AWS_REGION)return process.env.AWS_REGION;if(process.env.AWS_DEFAULT_REGION)return process.env.AWS_DEFAULT_REGION;const n=s??process.env.AWS_PROFILE??process.env.AWS_DEFAULT_PROFILE??"default",c=process.env.AWS_CONFIG_FILE??a(S(),".aws","config");return _(c,n)?.get("region")??"us-east-1"}export{I as loadAwsCredentials,W as resolveRegion};
|