superpowers-manager 0.7.0 → 0.7.1

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.
@@ -1,4 +1,5 @@
1
1
  import { Buffer } from "node:buffer";
2
+ import { createHash } from "node:crypto";
2
3
  import { constants } from "node:fs";
3
4
  import { open } from "node:fs/promises";
4
5
  import { tmpdir, userInfo } from "node:os";
@@ -12,9 +13,7 @@ import { canonicalizeProspectivePath, classifyPathNoFollow, isContained, isErrno
12
13
  import { runValidator } from "../../validator.js";
13
14
  import { withWorkspace } from "../../workspace.js";
14
15
  import { parseOpenCodeConfig, readOpenCodeConfig, } from "./config.js";
15
- // Account inspection pays a full-copy cost so it never opens the native DB or
16
- // its WAL with SQLite. Keep DB+WAL to one finite 16 MiB capture, without retry.
17
- const MAX_ACCOUNT_FILE_BYTES = 16 * 1024 * 1024;
16
+ const MAX_AUTH_FILE_BYTES = 16 * 1024 * 1024;
18
17
  const ACCOUNT_QUERY_POLICY = {
19
18
  timeoutMs: 2_000,
20
19
  graceMs: 200,
@@ -339,7 +338,7 @@ async function captureFile(path) {
339
338
  try {
340
339
  handle = await open(path, constants.O_RDONLY | constants.O_NOFOLLOW);
341
340
  const stat = await handle.stat();
342
- if (!stat.isFile() || stat.size > MAX_ACCOUNT_FILE_BYTES)
341
+ if (!stat.isFile() || stat.size > MAX_AUTH_FILE_BYTES)
343
342
  throw new Error("unsupported account database file");
344
343
  const bytes = Buffer.allocUnsafe(stat.size);
345
344
  let offset = 0;
@@ -360,13 +359,59 @@ async function captureFile(path) {
360
359
  await handle?.close().catch(() => { });
361
360
  }
362
361
  }
363
- function sameCapture(left, right) {
362
+ async function hashFile(path, copyTo) {
363
+ let handle;
364
+ try {
365
+ handle = await open(path, constants.O_RDONLY | constants.O_NOFOLLOW);
366
+ }
367
+ catch (cause) {
368
+ if (isErrno(cause, "ENOENT"))
369
+ return null;
370
+ throw cause;
371
+ }
372
+ try {
373
+ const stat = await handle.stat();
374
+ if (!stat.isFile())
375
+ throw new Error("unsupported account database file");
376
+ const hash = createHash("sha256");
377
+ const out = copyTo === undefined
378
+ ? undefined
379
+ : await open(copyTo, constants.O_CREAT | constants.O_EXCL | constants.O_WRONLY, 0o600);
380
+ try {
381
+ for await (const chunk of handle.createReadStream({
382
+ autoClose: false,
383
+ start: 0,
384
+ })) {
385
+ hash.update(chunk);
386
+ for (let offset = 0; out !== undefined && offset < chunk.length;) {
387
+ const { bytesWritten } = await out.write(chunk, offset);
388
+ if (bytesWritten === 0)
389
+ throw new Error("account copy stalled");
390
+ offset += bytesWritten;
391
+ }
392
+ }
393
+ }
394
+ finally {
395
+ await out?.close();
396
+ }
397
+ return {
398
+ hash: hash.digest("hex"),
399
+ dev: stat.dev,
400
+ ino: stat.ino,
401
+ mode: stat.mode,
402
+ };
403
+ }
404
+ finally {
405
+ await handle.close().catch(() => { });
406
+ }
407
+ }
408
+ function sameFile(left, right) {
364
409
  return left === null || right === null
365
410
  ? left === right
366
411
  : left.dev === right.dev &&
367
412
  left.ino === right.ino &&
368
413
  left.mode === right.mode &&
369
- left.bytes.equals(right.bytes);
414
+ left.hash === right.hash;
370
415
  }
371
416
  function accountDatabasePath(env, dataRoot) {
372
417
  const configured = env.OPENCODE_DB;
@@ -381,46 +426,31 @@ async function inspectAccount(state, dataRoot) {
381
426
  if (database === null)
382
427
  return;
383
428
  const wal = `${database}-wal`;
384
- let initialDb;
385
- let initialWal;
386
429
  try {
387
- [initialDb, initialWal] = await Promise.all([
388
- captureFile(database),
389
- captureFile(wal),
390
- ]);
391
- if (initialDb === null) {
392
- if (initialWal !== null)
430
+ if ((await classifyPathNoFollow(database)) === "missing") {
431
+ if ((await classifyPathNoFollow(wal)) !== "missing")
393
432
  throw new Error("orphan account WAL");
394
433
  return;
395
434
  }
396
- if (initialDb.bytes.length + (initialWal?.bytes.length ?? 0) >
397
- MAX_ACCOUNT_FILE_BYTES)
398
- throw new Error("account database capture limit");
435
+ // Account inspection pays a streamed full-copy cost so it never opens the
436
+ // native DB or its WAL with SQLite: a live read-only open rewrites -shm and
437
+ // creates -wal/-shm when absent. ponytail: copy time and temp space scale
438
+ // with DB size; query live only if that cost is measured to matter and
439
+ // -shm writes can be avoided.
399
440
  const result = await withWorkspace(tmpdir(), "spw-opencode-account-", async (workspace) => {
400
441
  const copy = join(workspace, "opencode.db");
401
- await (await open(copy, constants.O_CREAT | constants.O_EXCL | constants.O_WRONLY, 0o600)).close();
402
- const dbOut = await open(copy, constants.O_WRONLY);
403
- try {
404
- await dbOut.writeFile(initialDb.bytes);
405
- }
406
- finally {
407
- await dbOut.close();
408
- }
409
- if (initialWal !== null) {
410
- const walOut = await open(`${copy}-wal`, constants.O_CREAT | constants.O_EXCL | constants.O_WRONLY, 0o600);
411
- try {
412
- await walOut.writeFile(initialWal.bytes);
413
- }
414
- finally {
415
- await walOut.close();
416
- }
417
- }
442
+ const [initialDb, initialWal] = await Promise.all([
443
+ hashFile(database, copy),
444
+ hashFile(wal, `${copy}-wal`),
445
+ ]);
446
+ if (initialDb === null)
447
+ throw new Error("account database moved");
418
448
  const beforeQuery = await Promise.all([
419
- captureFile(database),
420
- captureFile(wal),
449
+ hashFile(database),
450
+ hashFile(wal),
421
451
  ]);
422
- if (!sameCapture(initialDb, beforeQuery[0]) ||
423
- !sameCapture(initialWal, beforeQuery[1]))
452
+ if (!sameFile(initialDb, beforeQuery[0]) ||
453
+ !sameFile(initialWal, beforeQuery[1]))
424
454
  throw new Error("account database moved");
425
455
  const run = await runValidator([
426
456
  process.execPath,
@@ -436,15 +466,15 @@ async function inspectAccount(state, dataRoot) {
436
466
  run.stderr.text !== "" ||
437
467
  (run.stdout.text !== "active" && run.stdout.text !== "absent"))
438
468
  throw new Error("account database query failed");
469
+ const afterQuery = await Promise.all([
470
+ hashFile(database),
471
+ hashFile(wal),
472
+ ]);
473
+ if (!sameFile(initialDb, afterQuery[0]) ||
474
+ !sameFile(initialWal, afterQuery[1]))
475
+ throw new Error("account database moved");
439
476
  return run.stdout.text;
440
477
  });
441
- const afterQuery = await Promise.all([
442
- captureFile(database),
443
- captureFile(wal),
444
- ]);
445
- if (!sameCapture(initialDb, afterQuery[0]) ||
446
- !sameCapture(initialWal, afterQuery[1]))
447
- throw new Error("account database moved");
448
478
  if (result === "active")
449
479
  addBlocked(state, "OpenCode active account remote configuration");
450
480
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "superpowers-manager",
3
- "version": "0.7.0",
3
+ "version": "0.7.1",
4
4
  "description": "Install obra/superpowers directly from upstream for Codex, Pi, and OpenCode. Stay on a known-good release or commit, try a branch, and upgrade when you choose.",
5
5
  "keywords": [
6
6
  "superpowers",