@secondlayer/cli 12.1.0 → 12.2.0

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
@@ -51,7 +51,44 @@ No account. Writes `.env.local`, restores history, prints the Stacks observer st
51
51
  | `secondlayer bootstrap --against <manifest> [--to-block <n>] [--public-key <pem>] [-y] [--json]` | Restore chain history from a verified archive into an empty database. Exit `0` restored, `1` diverged, `2` refused |
52
52
  | `secondlayer observer [--mode indexer\|signer-shared] [--endpoint host:port] [--recovery journal\|archive] [--network …]` | Print the `[[events_observer]]` stanza. Signer-shared requires `--recovery` |
53
53
  | `secondlayer verify [all\|raw\|decode:<name>\|subgraph:<name>] --against <manifest> [--quick\|--deep\|--anchor]` | Compare local data to a signed archive. Default target `raw`. Exit `0` clean, `1` diverged, `2` unanchored |
54
- | `secondlayer repair --against <archive> [--apply]` | Plan (default) or apply an archive repair |
54
+ | `secondlayer repair --against <archive> [--apply] [-y]` | Plan (default) or apply an archive repair |
55
+
56
+ Bootstrap and repair against the official hosted archive (`archive.secondlayer.tools`)
57
+ are metered per partition; against any other manifest (a mirror, a teammate's
58
+ box, a local file) they are free. See [Metered fetches](#metered-fetches).
59
+
60
+ ### Metered fetches
61
+
62
+ `secondlayer bootstrap` and `secondlayer repair` pull partitions from the
63
+ signed archive instead of replaying the chain, and that pull costs money only
64
+ when it targets the official hosted archive. Point `--against` at a mirror or
65
+ a local manifest and nothing is charged, nothing is even contacted beyond
66
+ that manifest, because self-hosting the archive is a supported way to use
67
+ these commands, not a workaround.
68
+
69
+ Against the official host, both commands quote before they charge:
70
+
71
+ 1. The manifest's partition list is priced with a free, no-write call
72
+ (`POST /api/archive/quote`).
73
+ 2. The quote prints into the existing plan output, for example
74
+ `metered: 528 partitions ≈ $44.00 · balance $50.00`, or for `repair`
75
+ inside its monthly allowance, `metered: free (4 of 6 monthly repair
76
+ fetches remaining)`.
77
+ 3. You confirm, or pass `-y` to skip the prompt. `-y` never skips the quote
78
+ or the balance check: if the balance is short, the command exits before
79
+ any partition is fetched and prints the shortfall and
80
+ `secondlayer credits buy`.
81
+ 4. Only then does the command fetch, and only the partitions it actually
82
+ reads are charged.
83
+
84
+ A partition already charged in the last 24 hours re-presigns for free, so a
85
+ resumed or retried bootstrap never pays twice for the same bytes. `repair`
86
+ gets 6 free range-bundles a month; `bootstrap` does not, since it is the
87
+ whole-chain operation the free tier exists to not subsidize.
88
+
89
+ `secondlayer verify` is unaffected: it reads manifests and digests, never
90
+ partition bytes, and stays free and anonymous no matter which archive it
91
+ points at.
55
92
 
56
93
  ### Subgraphs
57
94
 
package/dist/cli.js CHANGED
@@ -34320,7 +34320,7 @@ init_esm();
34320
34320
  // package.json
34321
34321
  var package_default = {
34322
34322
  name: "@secondlayer/cli",
34323
- version: "12.1.0",
34323
+ version: "12.2.0",
34324
34324
  description: "Run a Stacks index on your own hardware — set it up, restore verified history, and keep it current.",
34325
34325
  repository: {
34326
34326
  type: "git",
@@ -34364,7 +34364,7 @@ var package_default = {
34364
34364
  "@secondlayer/bundler": "^0.5.1",
34365
34365
  "@secondlayer/scaffold": "^1.5.4",
34366
34366
  "@secondlayer/sdk": "^8.0.1",
34367
- "@secondlayer/shared": "^10.0.0",
34367
+ "@secondlayer/shared": "^10.0.1",
34368
34368
  "@secondlayer/stacks": "^4.0.0",
34369
34369
  "@secondlayer/subgraphs": "^4.0.2",
34370
34370
  esbuild: "^0.27.4",
@@ -38217,9 +38217,22 @@ async function resolveArchivePublicKey(input4) {
38217
38217
  }
38218
38218
  return;
38219
38219
  }
38220
- async function fetchVerifiedPartition(reference, partition) {
38220
+ async function fetchViaGate(gate, partition, retried) {
38221
+ const url = await gate.getUrl(partition.path, retried ? { forceRefresh: true } : undefined);
38222
+ const response2 = await fetch(url, { signal: AbortSignal.timeout(300000) });
38223
+ if (response2.status === 403 && !retried) {
38224
+ return fetchViaGate(gate, partition, true);
38225
+ }
38226
+ if (!response2.ok) {
38227
+ throw new Error(`could not fetch ${partition.path} (${response2.status} ${response2.statusText})`);
38228
+ }
38229
+ return Buffer.from(await response2.arrayBuffer());
38230
+ }
38231
+ async function fetchVerifiedPartition(reference, partition, gate) {
38221
38232
  let bytes;
38222
- if (reference.isRemote) {
38233
+ if (gate) {
38234
+ bytes = await fetchViaGate(gate, partition, false);
38235
+ } else if (reference.isRemote) {
38223
38236
  const url = `${reference.root.replace(/\/$/, "")}/${partition.path}`;
38224
38237
  const response2 = await fetch(url, { signal: AbortSignal.timeout(300000) });
38225
38238
  if (!response2.ok) {
@@ -38448,11 +38461,123 @@ import { unlink as unlink2, writeFile as writeFile5 } from "node:fs/promises";
38448
38461
  import { tmpdir as tmpdir2 } from "node:os";
38449
38462
  import { join as join15 } from "node:path";
38450
38463
  import { ParquetReader } from "@dsnp/parquetjs";
38464
+ import { confirm as confirm7 } from "@inquirer/prompts";
38451
38465
  import {
38452
38466
  compareRangeDigests as compareRangeDigests2,
38453
38467
  computeRangeDigest as computeRangeDigest2
38454
38468
  } from "@secondlayer/shared/archive/range-digest";
38455
38469
  import { getDb as getDb3, sql as sql2 } from "@secondlayer/shared/db";
38470
+
38471
+ // src/lib/archive-gate.ts
38472
+ init_http();
38473
+ var OFFICIAL_ARCHIVE_HOST = "archive.secondlayer.tools";
38474
+ function isOfficialArchive(reference) {
38475
+ if (!reference.isRemote)
38476
+ return false;
38477
+ try {
38478
+ return new URL(reference.origin).hostname === OFFICIAL_ARCHIVE_HOST;
38479
+ } catch {
38480
+ return false;
38481
+ }
38482
+ }
38483
+ var defaultDeps = { httpArchiveOps };
38484
+ function quoteFromBody(body) {
38485
+ return {
38486
+ partitions: body.partitions,
38487
+ bundles: body.bundles,
38488
+ usdMicros: body.usd_micros,
38489
+ usd: body.usd,
38490
+ freeAllowanceAppliedMicros: body.free_allowance_applied_micros,
38491
+ allowanceRemainingBundles: body.allowance_remaining_bundles,
38492
+ balanceUsdMicros: body.balance_usd_micros,
38493
+ sufficient: body.sufficient
38494
+ };
38495
+ }
38496
+ async function quoteArchiveFetch(paths, flow, deps = defaultDeps) {
38497
+ try {
38498
+ const body = await deps.httpArchiveOps("/api/archive/quote", { method: "POST", body: { paths, flow } });
38499
+ return { ok: true, quote: quoteFromBody(body) };
38500
+ } catch (err) {
38501
+ if (err instanceof CliHttpError) {
38502
+ if (err.status === 503)
38503
+ return { ok: false, kind: "not_configured" };
38504
+ if (err.status === 401) {
38505
+ return { ok: false, kind: "not_authed", message: err.message };
38506
+ }
38507
+ return { ok: false, kind: "error", message: err.message };
38508
+ }
38509
+ throw err;
38510
+ }
38511
+ }
38512
+ var BUY_CREDITS_COMMAND = "secondlayer credits buy";
38513
+ function formatInsufficientMessage(quote) {
38514
+ const shortfallUsd = ((quote.usdMicros - quote.balanceUsdMicros) / 1e6).toFixed(2);
38515
+ const balanceUsd = (quote.balanceUsdMicros / 1e6).toFixed(2);
38516
+ return `Insufficient archive credits: quote $${quote.usd}, balance $${balanceUsd}, short $${shortfallUsd}. Buy more with \`${BUY_CREDITS_COMMAND}\`.`;
38517
+ }
38518
+ var ARCHIVE_GATE_NOT_CONFIGURED_MESSAGE = "the archive gate is not configured on the server; contact the operator";
38519
+ var ALLOWANCE_MONTHLY_BUNDLES = 6;
38520
+ function formatQuoteValue(quote, flow) {
38521
+ if (flow === "repair" && quote.usdMicros === 0 && quote.freeAllowanceAppliedMicros > 0) {
38522
+ return `free (${quote.allowanceRemainingBundles} of ${ALLOWANCE_MONTHLY_BUNDLES} monthly repair fetches remaining)`;
38523
+ }
38524
+ const balance = `$${(quote.balanceUsdMicros / 1e6).toFixed(2)}`;
38525
+ return `${quote.partitions} partitions ≈ $${quote.usd} · balance ${balance}`;
38526
+ }
38527
+ function shouldPromptForGatedFetch(opts) {
38528
+ return !opts.yes && !opts.json;
38529
+ }
38530
+ var FETCH_BATCH_SIZE = 16;
38531
+ function createGatedFetcher(paths, flow, deps = defaultDeps) {
38532
+ const cache = new Map;
38533
+ const batchOf = new Map;
38534
+ for (const [index, path11] of paths.entries()) {
38535
+ batchOf.set(path11, Math.floor(index / FETCH_BATCH_SIZE));
38536
+ }
38537
+ const fetchedBatches = new Set;
38538
+ const inFlight = new Map;
38539
+ async function fetchAndCache(batchPaths) {
38540
+ const body = await deps.httpArchiveOps("/api/archive/fetch", { method: "POST", body: { paths: batchPaths, flow } });
38541
+ for (const u3 of body.urls)
38542
+ cache.set(u3.path, u3.url);
38543
+ }
38544
+ return {
38545
+ async getUrl(path11, opts) {
38546
+ if (opts?.forceRefresh) {
38547
+ if (process.env.DEBUG) {
38548
+ console.error(`[archive-gate] re-issuing expired URL for ${path11}`);
38549
+ }
38550
+ await fetchAndCache([path11]);
38551
+ const fresh = cache.get(path11);
38552
+ if (!fresh) {
38553
+ throw new Error(`archive gate: no URL returned for ${path11}`);
38554
+ }
38555
+ return fresh;
38556
+ }
38557
+ const batchIndex = batchOf.get(path11);
38558
+ if (batchIndex === undefined) {
38559
+ throw new Error(`archive gate: ${path11} was not in the quoted batch`);
38560
+ }
38561
+ if (!fetchedBatches.has(batchIndex)) {
38562
+ let inflight = inFlight.get(batchIndex);
38563
+ if (!inflight) {
38564
+ const batchPaths = paths.filter((p2) => batchOf.get(p2) === batchIndex);
38565
+ inflight = fetchAndCache(batchPaths).then(() => {
38566
+ fetchedBatches.add(batchIndex);
38567
+ });
38568
+ inFlight.set(batchIndex, inflight);
38569
+ }
38570
+ await inflight;
38571
+ }
38572
+ const url = cache.get(path11);
38573
+ if (!url)
38574
+ throw new Error(`archive gate: no URL returned for ${path11}`);
38575
+ return url;
38576
+ }
38577
+ };
38578
+ }
38579
+
38580
+ // src/commands/repair.ts
38456
38581
  init_output();
38457
38582
  var REPAIR_EXIT = {
38458
38583
  OK: 0,
@@ -38502,8 +38627,8 @@ function parseHeight3(value, flag) {
38502
38627
  }
38503
38628
  return parsed;
38504
38629
  }
38505
- async function planRange(db, reference, partition) {
38506
- const bytes = await fetchVerifiedPartition(reference, partition);
38630
+ async function planRange(db, reference, partition, gate) {
38631
+ const bytes = await fetchVerifiedPartition(reference, partition, gate);
38507
38632
  const archiveBlocks = await readBlocksPartition(bytes, `${partition.from_block}-${partition.to_block}`);
38508
38633
  const localRows = await db.selectFrom("blocks").select(["height", "hash"]).where("canonical", "=", true).where("height", ">=", partition.from_block).where("height", "<=", partition.to_block).execute();
38509
38634
  const localByHeight = new Map(localRows.map((r2) => [Number(r2.height), r2.hash]));
@@ -38530,10 +38655,10 @@ async function planRange(db, reference, partition) {
38530
38655
  const extraHeights = [...localByHeight.keys()].sort((a4, b3) => a4 - b3);
38531
38656
  return { fixes, extraHeights };
38532
38657
  }
38533
- async function applyFixes(db, reference, partition, fixes) {
38658
+ async function applyFixes(db, reference, partition, fixes, gate) {
38534
38659
  if (fixes.length === 0)
38535
38660
  return 0;
38536
- const bytes = await fetchVerifiedPartition(reference, partition);
38661
+ const bytes = await fetchVerifiedPartition(reference, partition, gate);
38537
38662
  const archiveBlocks = await readBlocksPartition(bytes, `apply-${partition.from_block}`);
38538
38663
  const byHeight = new Map(archiveBlocks.map((b3) => [b3.height, b3]));
38539
38664
  const targets = fixes.map((fix) => byHeight.get(fix.height)).filter((b3) => b3 !== undefined);
@@ -38562,7 +38687,7 @@ async function applyFixes(db, reference, partition, fixes) {
38562
38687
  return targets.length;
38563
38688
  }
38564
38689
  function registerRepairCommand(program2) {
38565
- program2.command("repair").description("Replace local chain data that diverges from a signed archive (dry-run by default)").requiredOption("--against <manifest>", "archive manifest: an https URL or a local file path").option("--from-block <n>", "first height to consider").option("--to-block <n>", "last height to consider").option("--apply", "write the repair (default is a dry-run plan)").option("--public-key <pem>", "pin the signing key instead of fetching it").option("--json", "Output as JSON").addHelpText("after", `
38690
+ program2.command("repair").description("Replace local chain data that diverges from a signed archive (dry-run by default)").requiredOption("--against <manifest>", "archive manifest: an https URL or a local file path").option("--from-block <n>", "first height to consider").option("--to-block <n>", "last height to consider").option("--apply", "write the repair (default is a dry-run plan)").option("--public-key <pem>", "pin the signing key instead of fetching it").option("-y, --yes", "skip the confirmation prompt for a metered fetch").option("--json", "Output as JSON").addHelpText("after", `
38566
38691
  Examples:
38567
38692
  $ secondlayer repair --against ./snapshot.json # plan only
38568
38693
  $ secondlayer repair --against ./snapshot.json --apply # write the fix
@@ -38608,12 +38733,37 @@ Exit codes:
38608
38733
  process.exit(REPAIR_EXIT.OK);
38609
38734
  }
38610
38735
  const partitions = (reference.manifest.partitions ?? []).filter((p2) => p2.dataset === "blocks" && divergent.some((d2) => d2.from_block === p2.from_block && d2.to_block === p2.to_block));
38736
+ let gate;
38737
+ if (isOfficialArchive(reference)) {
38738
+ const paths = partitions.map((p2) => p2.path);
38739
+ const result = await quoteArchiveFetch(paths, "repair");
38740
+ if (!result.ok) {
38741
+ printError(result.kind === "not_configured" ? ARCHIVE_GATE_NOT_CONFIGURED_MESSAGE : result.message);
38742
+ process.exit(REPAIR_EXIT.UNANCHORED);
38743
+ }
38744
+ if (!result.quote.sufficient) {
38745
+ printError(formatInsufficientMessage(result.quote));
38746
+ process.exit(REPAIR_EXIT.UNANCHORED);
38747
+ }
38748
+ note(` metered: ${formatQuoteValue(result.quote, "repair")}`);
38749
+ if (shouldPromptForGatedFetch(opts)) {
38750
+ const proceed = await confirm7({
38751
+ message: `Fetch ${partitions.length} partition(s) from the archive?`,
38752
+ default: true
38753
+ });
38754
+ if (!proceed) {
38755
+ note("Nothing was fetched.");
38756
+ process.exit(REPAIR_EXIT.UNANCHORED);
38757
+ }
38758
+ }
38759
+ gate = createGatedFetcher(paths, "repair");
38760
+ }
38611
38761
  const allFixes = [];
38612
38762
  const allExtra = [];
38613
38763
  const planned = [];
38614
38764
  for (const partition of partitions) {
38615
38765
  note(` reading ${partition.dataset} ${partition.from_block}-${partition.to_block}`);
38616
- const { fixes, extraHeights } = await planRange(db, reference, partition);
38766
+ const { fixes, extraHeights } = await planRange(db, reference, partition, gate);
38617
38767
  planned.push({ partition, fixes });
38618
38768
  allFixes.push(...fixes);
38619
38769
  allExtra.push(...extraHeights);
@@ -38621,7 +38771,7 @@ Exit codes:
38621
38771
  let applied = 0;
38622
38772
  if (opts.apply) {
38623
38773
  for (const { partition, fixes } of planned) {
38624
- applied += await applyFixes(db, reference, partition, fixes);
38774
+ applied += await applyFixes(db, reference, partition, fixes, gate);
38625
38775
  }
38626
38776
  }
38627
38777
  let remaining = divergent.length;
@@ -38698,7 +38848,7 @@ import { unlink as unlink3, writeFile as writeFile6 } from "node:fs/promises";
38698
38848
  import { tmpdir as tmpdir3 } from "node:os";
38699
38849
  import { join as join16 } from "node:path";
38700
38850
  import { ParquetReader as ParquetReader2 } from "@dsnp/parquetjs";
38701
- import { confirm as confirm7 } from "@inquirer/prompts";
38851
+ import { confirm as confirm8 } from "@inquirer/prompts";
38702
38852
  import {
38703
38853
  copyStatement,
38704
38854
  writeRowsToCopyStream
@@ -38794,8 +38944,8 @@ async function* readPartitionRows(bytes, label) {
38794
38944
  await unlink3(path11).catch(() => {});
38795
38945
  }
38796
38946
  }
38797
- async function loadPartition(reference, partition, rawClient) {
38798
- const bytes = await fetchVerifiedPartition(reference, partition);
38947
+ async function loadPartition(reference, partition, rawClient, gate) {
38948
+ const bytes = await fetchVerifiedPartition(reference, partition, gate);
38799
38949
  const writable2 = await rawClient.unsafe(copyStatement(partition.dataset)).writable();
38800
38950
  const written = await writeRowsToCopyStream({
38801
38951
  writable: writable2,
@@ -38872,6 +39022,23 @@ Exit codes:
38872
39022
  const tipHeight = Math.max(...partitions.map((p2) => p2.to_block));
38873
39023
  const restoreSeconds = totalRows / RESTORE_ROWS_PER_SECOND;
38874
39024
  const genesisSeconds = tipHeight / BLOCKS_PER_DAY_ESTIMATE * 86400;
39025
+ const gated = isOfficialArchive(reference);
39026
+ let quoteLine;
39027
+ let gate;
39028
+ if (gated) {
39029
+ const paths = partitions.map((p2) => p2.path);
39030
+ const result = await quoteArchiveFetch(paths, "bootstrap");
39031
+ if (!result.ok) {
39032
+ printError(result.kind === "not_configured" ? ARCHIVE_GATE_NOT_CONFIGURED_MESSAGE : result.message);
39033
+ process.exit(BOOTSTRAP_EXIT.REFUSED);
39034
+ }
39035
+ if (!result.quote.sufficient) {
39036
+ printError(formatInsufficientMessage(result.quote));
39037
+ process.exit(BOOTSTRAP_EXIT.REFUSED);
39038
+ }
39039
+ quoteLine = formatQuoteValue(result.quote, "bootstrap");
39040
+ gate = createGatedFetcher(paths, "bootstrap");
39041
+ }
38875
39042
  if (!opts.json) {
38876
39043
  console.error("");
38877
39044
  console.error(formatKeyValue([
@@ -38882,15 +39049,16 @@ Exit codes:
38882
39049
  ],
38883
39050
  ["rows", totalRows.toLocaleString()],
38884
39051
  ["download", `${(totalBytes / 1e9).toFixed(1)} GB`],
38885
- ["signature", "verified"]
39052
+ ["signature", "verified"],
39053
+ ...quoteLine ? [["metered", quoteLine]] : []
38886
39054
  ]));
38887
39055
  console.error("");
38888
39056
  console.error(` sync from genesis ${bold(`~${formatDuration2(genesisSeconds)}`)} ${dim("(estimate)")}`);
38889
39057
  console.error(` restore from archive ${bold(`~${formatDuration2(restoreSeconds)}`)}`);
38890
39058
  console.error("");
38891
39059
  }
38892
- if (!opts.yes && !opts.json) {
38893
- const proceed = await confirm7({
39060
+ if (shouldPromptForGatedFetch(opts)) {
39061
+ const proceed = await confirm8({
38894
39062
  message: "Restore from the verified archive?",
38895
39063
  default: true
38896
39064
  });
@@ -38907,7 +39075,7 @@ Exit codes:
38907
39075
  for (const dataset of order) {
38908
39076
  const datasetPartitions = partitions.filter((p2) => p2.dataset === dataset).sort((a4, b3) => a4.from_block - b3.from_block);
38909
39077
  for (const [index, partition] of datasetPartitions.entries()) {
38910
- loaded[dataset] += await loadPartition(reference, partition, rawClient);
39078
+ loaded[dataset] += await loadPartition(reference, partition, rawClient, gate);
38911
39079
  const done = index + 1;
38912
39080
  if (done % 10 === 0 || done === datasetPartitions.length) {
38913
39081
  const elapsed2 = (Date.now() - startedAt) / 1000;
@@ -38964,7 +39132,8 @@ Exit codes:
38964
39132
  elapsed_seconds: Math.round(elapsed),
38965
39133
  resume_from: tipHeight + 1,
38966
39134
  node_tip_at_start: nodeTipAtStart,
38967
- catch_up_blocks: seam?.gap ?? null
39135
+ catch_up_blocks: seam?.gap ?? null,
39136
+ metered: quoteLine ?? null
38968
39137
  };
38969
39138
  output({
38970
39139
  json: opts.json,
@@ -39639,5 +39808,5 @@ program.commandsGroup("Archive:");
39639
39808
  registerCreditsCommand(program);
39640
39809
  program.parse();
39641
39810
 
39642
- //# debugId=135579D3DFB23FDC64756E2164756E21
39811
+ //# debugId=5BF91F30BB4466C964756E2164756E21
39643
39812
  //# sourceMappingURL=cli.js.map