superpowers-manager 0.7.0 → 0.7.2

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 CHANGED
@@ -124,7 +124,7 @@ The [usage reference](https://github.com/j7an/superpowers-manager/blob/main/docs
124
124
 
125
125
  ## Requirements and platforms
126
126
 
127
- The installed package requires Node >=24. Native source, tests, and packaging tooling require Node >=24.12.0. Every other requirement is command-specific and is checked before dispatch. This table is derived from production by `tests/bin/readme-requirements.test.ts`.
127
+ The installed package requires Node >=24. Native source, tests, and packaging tooling target latest Node 24. Every other requirement is command-specific and is checked before dispatch. This table is derived from production by `tests/bin/readme-requirements.test.ts`.
128
128
 
129
129
  <!-- requirements:begin -->
130
130
 
@@ -143,7 +143,7 @@ The installed package requires Node >=24. Native source, tests, and packaging to
143
143
 
144
144
  Generated Codex manifests preserve upstream JSON number tokens while rejecting non-finite decimal or exponent values.
145
145
 
146
- macOS and Linux are tested. WSL2 is supported for the established Codex path; Pi in WSL2 and native Windows remain untested. The manager runs in-process and does not require Git Bash. The [Pi reference](https://github.com/j7an/superpowers-manager/blob/main/docs/pi.md#runtime-compatibility) explains its runtime qualification and admission checks.
146
+ macOS and Linux are tested. WSL2 is supported for the established Codex path; Pi in WSL2 and native Windows remain untested. The manager runs in-process and does not require Git Bash. The [Pi reference](https://github.com/j7an/superpowers-manager/blob/main/docs/pi.md#runtime-compatibility) explains its runtime qualification and admission checks. OpenCode support currently covers 1.x; OpenCode 2.x is untested and not certified as compatible (see the [OpenCode reference](https://github.com/j7an/superpowers-manager/blob/main/docs/opencode.md)).
147
147
 
148
148
  Native test versions qualify integration mechanisms; they are not runtime allowlists, and an untested runtime is not automatically certified as compatible.
149
149
 
@@ -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.2",
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",