@nysgpt/bucket-list 0.0.0-stage → 0.1.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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Brendan Stanton
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,87 @@
1
- # Temporary Holding Version
1
+ # Bucket List
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ Lightweight search for S3.
4
+
5
+ One command sweeps every bucket in your AWS account and writes a single
6
+ self-contained HTML file: drill down from account to bucket to folder, with
7
+ object counts, storage-class cost estimates, activity windows, largest files,
8
+ common file-name patterns, and instant search across everything. The file works
9
+ offline, in light and dark mode, and weighs a few hundred kilobytes even for
10
+ accounts with millions of objects.
11
+
12
+ ```
13
+ npx @nysgpt/bucket-list --open
14
+ ```
15
+
16
+ ## Why
17
+
18
+ AWS's own answers to "what's in my S3?" are Storage Lens, which stops at
19
+ bucket-level numbers, and S3 Inventory, which takes 24 hours and hands you
20
+ Parquet files. Neither gives you an instant, browsable, shareable picture.
21
+ Bucket List does, in minutes, for about a nickel per 10 million objects.
22
+
23
+ ## Requirements
24
+
25
+ - The AWS CLI (v2) installed and configured with credentials that can list
26
+ buckets, list objects, and read CloudWatch metrics
27
+ - Node.js 18+
28
+
29
+ That's the whole stack. No SDK, no AI, no cloud service, no telemetry. If
30
+ `aws s3 ls` works in your terminal, Bucket List works, whatever terminal,
31
+ agent, or editor you run it from.
32
+
33
+ ## Usage
34
+
35
+ ```
36
+ bucket-list [options]
37
+
38
+ --output <file> output path (default: bucket-list.html)
39
+ --buckets <a,b,c> only these buckets (default: every bucket)
40
+ --notes <file> annotations JSON (default: ./bucket-list.notes.json if present)
41
+ --concurrency <n> parallel AWS calls (default: 12)
42
+ --yes skip the cost estimate confirmation
43
+ --open open the file when done
44
+ ```
45
+
46
+ Before sweeping, it estimates the job from CloudWatch metrics and asks:
47
+
48
+ ```
49
+ 32 buckets, ~8,500,000 objects, ~2.1 TB (CloudWatch estimate).
50
+ Sweep will make ~10,300 LIST requests: ~$0.05, roughly 6 min.
51
+ Proceed? [y/N]
52
+ ```
53
+
54
+ ## What it does, exactly
55
+
56
+ - Discovers every bucket with `list-buckets` and resolves each bucket's region.
57
+ - Walks each bucket by folder, recursively splitting anything too large into
58
+ parallel streams, so a bucket with one five-million-object folder sweeps in
59
+ minutes instead of an hour.
60
+ - Reads names, sizes, and dates only. It never downloads an object's contents
61
+ and never modifies anything. Glacier-stored objects are listed for free like
62
+ everything else.
63
+ - Prices each bucket from CloudWatch per-storage-class metrics against the
64
+ us-east-1 list-price table, clearly labeled as an estimate.
65
+ - Flags buckets where CloudWatch counts far more objects than the listing
66
+ shows, which usually means old versions kept by bucket versioning and
67
+ reclaimable with a lifecycle rule.
68
+
69
+ ## Annotations
70
+
71
+ The generator knows nothing about your projects. If you want it to, drop a
72
+ `bucket-list.notes.json` next to where you run it:
73
+
74
+ ```json
75
+ {
76
+ "my-app-assets": { "verdict": "keep", "note": "serves production", "project": "my-app" },
77
+ "old-experiments": { "verdict": "disposable", "note": "rebuildable from source data" }
78
+ }
79
+ ```
80
+
81
+ Verdicts render as chips (`keep`, `review`, `disposable`, `empty`), notes
82
+ appear on the bucket's row and page, and the columns only appear if you use
83
+ them.
84
+
85
+ ## License
86
+
87
+ MIT
@@ -0,0 +1,179 @@
1
+ #!/usr/bin/env node
2
+ "use strict";
3
+ const fs = require("fs");
4
+ const path = require("path");
5
+ const readline = require("readline");
6
+ const { spawnSync, spawn } = require("child_process");
7
+ const { run, setConcurrency } = require("../lib/aws");
8
+ const { discoverBuckets, estimate, sweepBucket } = require("../lib/sweep");
9
+ const { render } = require("../lib/render");
10
+
11
+ const VERSION = require("../package.json").version;
12
+
13
+ const HELP = `Bucket List ${VERSION} — lightweight search for S3
14
+
15
+ Sweeps every bucket in the AWS account your CLI is signed into and writes one
16
+ self-contained HTML file: drill down by bucket and folder, object counts,
17
+ storage-class cost estimates, largest files, and instant search. Works offline
18
+ once generated. Requires the AWS CLI (v2) configured with list permissions.
19
+
20
+ Usage: bucket-list [options]
21
+
22
+ --output <file> output path (default: bucket-list.html)
23
+ --buckets <a,b,c> only these buckets (default: every bucket)
24
+ --notes <file> annotations JSON (default: ./bucket-list.notes.json if present)
25
+ --concurrency <n> parallel AWS calls (default: 12)
26
+ --yes skip the cost estimate confirmation
27
+ --open open the file when done
28
+ --version print version
29
+ --help this text
30
+
31
+ Annotations file format (all fields optional):
32
+ { "my-bucket": { "verdict": "keep|review|disposable|empty",
33
+ "note": "shown on the bucket's row and page",
34
+ "project": "label under the bucket name" } }
35
+
36
+ Cost note: the sweep itself uses S3 LIST requests at $0.005 per 1,000 — about
37
+ five cents per 10 million objects — plus one CloudWatch query per 50 buckets.
38
+ It reads names and sizes only; no object is ever downloaded or modified.`;
39
+
40
+ function parseArgs(argv) {
41
+ const a = { output: "bucket-list.html", buckets: null, notes: null, concurrency: 12, yes: false, open: false };
42
+ for (let i = 0; i < argv.length; i++) {
43
+ const v = argv[i];
44
+ if (v === "--help" || v === "-h") { console.log(HELP); process.exit(0); }
45
+ else if (v === "--version") { console.log(VERSION); process.exit(0); }
46
+ else if (v === "--yes" || v === "-y") a.yes = true;
47
+ else if (v === "--open") a.open = true;
48
+ else if (v === "--output") a.output = argv[++i];
49
+ else if (v === "--buckets") a.buckets = argv[++i].split(",").map((s) => s.trim()).filter(Boolean);
50
+ else if (v === "--notes") a.notes = argv[++i];
51
+ else if (v === "--concurrency") a.concurrency = parseInt(argv[++i], 10) || 12;
52
+ else { console.error(`unknown option: ${v} (try --help)`); process.exit(2); }
53
+ }
54
+ return a;
55
+ }
56
+
57
+ function confirm(question) {
58
+ return new Promise((resolve) => {
59
+ const rl = readline.createInterface({ input: process.stdin, output: process.stderr });
60
+ rl.question(question, (ans) => { rl.close(); resolve(/^y(es)?$/i.test(ans.trim())); });
61
+ });
62
+ }
63
+
64
+ function fmtBytes(b) {
65
+ if (b < 1024 ** 3) return (b / 1024 ** 2).toFixed(0) + " MB";
66
+ if (b < 1024 ** 4) return (b / 1024 ** 3).toFixed(1) + " GB";
67
+ return (b / 1024 ** 4).toFixed(2) + " TB";
68
+ }
69
+
70
+ async function main() {
71
+ const args = parseArgs(process.argv.slice(2));
72
+ setConcurrency(args.concurrency);
73
+
74
+ // 0. preconditions
75
+ const cliCheck = spawnSync("aws", ["--version"], { encoding: "utf8" });
76
+ if (cliCheck.error) {
77
+ console.error("The AWS CLI was not found. Install it and run `aws configure`, then retry.");
78
+ process.exit(1);
79
+ }
80
+ let account = "";
81
+ try {
82
+ const ident = await run(["sts", "get-caller-identity"]);
83
+ account = ident.Account || "";
84
+ } catch (e) {
85
+ console.error("AWS credentials aren't working: " + e.message);
86
+ console.error("Run `aws configure` (or set your profile/SSO session) and retry.");
87
+ process.exit(1);
88
+ }
89
+
90
+ // 1. discovery
91
+ process.stderr.write(`Discovering buckets in account ${account}...\n`);
92
+ const { buckets, skipped } = await discoverBuckets(args.buckets);
93
+ for (const s of skipped) process.stderr.write(` skipping ${s.name}: no access to its region/location\n`);
94
+ if (!buckets.length) {
95
+ console.error("No buckets to sweep.");
96
+ process.exit(1);
97
+ }
98
+
99
+ // 2. estimate + guardrail
100
+ const stats = await estimate(buckets);
101
+ let estObjects = 0;
102
+ let estBytes = 0;
103
+ for (const b of buckets) {
104
+ const s = stats.get(b.name);
105
+ estObjects += s.cwObjects || 0;
106
+ estBytes += Object.values(s.classes).reduce((a, gb) => a + gb, 0) * 1024 ** 3;
107
+ }
108
+ const estRequests = Math.ceil((estObjects / 1000) * 1.2) + buckets.length * 3;
109
+ const estCost = (estRequests / 1000) * 0.005;
110
+ const estMinutes = Math.max(1, Math.round((estRequests * 0.12) / args.concurrency / 60));
111
+ process.stderr.write(
112
+ `${buckets.length} buckets, ~${estObjects.toLocaleString("en-US")} objects, ~${fmtBytes(estBytes)} (CloudWatch estimate).\n` +
113
+ `Sweep will make ~${estRequests.toLocaleString("en-US")} LIST requests: ~$${estCost < 0.005 ? "0.01" : estCost.toFixed(2)}, roughly ${estMinutes} min.\n`
114
+ );
115
+ if (!args.yes) {
116
+ if (!process.stdin.isTTY) {
117
+ console.error("Not a terminal; re-run with --yes to proceed without confirmation.");
118
+ process.exit(1);
119
+ }
120
+ const ok = await confirm("Proceed? [y/N] ");
121
+ if (!ok) {
122
+ console.error("Aborted. Nothing was swept.");
123
+ process.exit(0);
124
+ }
125
+ }
126
+
127
+ // 3. sweep
128
+ const t0 = Date.now();
129
+ const results = [];
130
+ let done = 0;
131
+ await Promise.all(
132
+ buckets.map(async (b) => {
133
+ try {
134
+ const r = await sweepBucket(b.name, b.region);
135
+ r.region = b.region;
136
+ results.push(r);
137
+ } catch (e) {
138
+ process.stderr.write(` ${b.name}: FAILED (${e.message})\n`);
139
+ results.push({ bucket: b.name, region: b.region, n: 0, b: 0, largest: [], prefixes: [] });
140
+ }
141
+ done++;
142
+ process.stderr.write(` [${done}/${buckets.length}] ${b.name}\n`);
143
+ })
144
+ );
145
+
146
+ // 4. annotations
147
+ let notes = {};
148
+ const notesPath = args.notes || (fs.existsSync("bucket-list.notes.json") ? "bucket-list.notes.json" : null);
149
+ if (notesPath) {
150
+ try {
151
+ notes = JSON.parse(fs.readFileSync(notesPath, "utf8"));
152
+ process.stderr.write(`Annotations loaded from ${notesPath}.\n`);
153
+ } catch (e) {
154
+ process.stderr.write(`Could not read ${notesPath}: ${e.message} (continuing without annotations)\n`);
155
+ }
156
+ }
157
+
158
+ // 5. render
159
+ const html = render({ results, stats, notes, account });
160
+ const out = path.resolve(args.output);
161
+ fs.writeFileSync(out, html);
162
+ const totalN = results.reduce((a, r) => a + r.n, 0);
163
+ const totalB = results.reduce((a, r) => a + r.b, 0);
164
+ const secs = Math.round((Date.now() - t0) / 1000);
165
+ process.stderr.write(
166
+ `\nSwept ${totalN.toLocaleString("en-US")} objects (${fmtBytes(totalB)}) across ${results.length} buckets in ${secs}s.\n`
167
+ );
168
+ console.log(out);
169
+
170
+ if (args.open) {
171
+ const opener = process.platform === "darwin" ? "open" : process.platform === "win32" ? "start" : "xdg-open";
172
+ spawn(opener, [out], { stdio: "ignore", detached: true, shell: process.platform === "win32" }).unref();
173
+ }
174
+ }
175
+
176
+ main().catch((e) => {
177
+ console.error(e.message || e);
178
+ process.exit(1);
179
+ });
package/lib/aws.js ADDED
@@ -0,0 +1,107 @@
1
+ "use strict";
2
+ // Thin wrappers around the AWS CLI. No SDK, no dependencies: if `aws` works in
3
+ // the user's terminal, Bucket List works, regardless of what else they use.
4
+ const { spawn } = require("child_process");
5
+
6
+ let MAX = 12;
7
+ let active = 0;
8
+ const queue = [];
9
+
10
+ function setConcurrency(n) {
11
+ MAX = Math.max(1, n | 0);
12
+ }
13
+
14
+ function acquire() {
15
+ return new Promise((res) => {
16
+ if (active < MAX) {
17
+ active++;
18
+ res();
19
+ } else {
20
+ queue.push(res);
21
+ }
22
+ });
23
+ }
24
+
25
+ function release() {
26
+ active--;
27
+ if (queue.length) {
28
+ active++;
29
+ queue.shift()();
30
+ }
31
+ }
32
+
33
+ /** Run an aws CLI command, return parsed JSON (or raw text with json:false). */
34
+ async function run(args, { json = true, region = null } = {}) {
35
+ await acquire();
36
+ try {
37
+ return await new Promise((resolve, reject) => {
38
+ const full = [...args];
39
+ if (json) full.push("--output", "json");
40
+ if (region) full.push("--region", region);
41
+ const p = spawn("aws", full, { stdio: ["ignore", "pipe", "pipe"] });
42
+ let out = "";
43
+ let err = "";
44
+ p.stdout.setEncoding("utf8");
45
+ p.stderr.setEncoding("utf8");
46
+ p.stdout.on("data", (d) => (out += d));
47
+ p.stderr.on("data", (d) => (err += d));
48
+ p.on("error", reject);
49
+ p.on("close", (code) => {
50
+ if (code !== 0) {
51
+ return reject(new Error(`aws ${args.slice(0, 3).join(" ")} failed: ${err.trim().slice(0, 300) || "exit " + code}`));
52
+ }
53
+ if (!json) return resolve(out);
54
+ try {
55
+ resolve(out.trim() ? JSON.parse(out) : {});
56
+ } catch (e) {
57
+ reject(new Error(`bad JSON from aws ${args[0]} ${args[1]}: ${e.message}`));
58
+ }
59
+ });
60
+ });
61
+ } finally {
62
+ release();
63
+ }
64
+ }
65
+
66
+ /**
67
+ * Stream `aws s3 ls <url> --recursive` line by line.
68
+ * Calls onObject(dateYYYYMMDD, sizeBytes, key) per object. Constant memory,
69
+ * works for millions of objects.
70
+ */
71
+ async function streamLs(s3url, region, onObject) {
72
+ await acquire();
73
+ try {
74
+ return await new Promise((resolve, reject) => {
75
+ const args = ["s3", "ls", s3url, "--recursive"];
76
+ if (region) args.push("--region", region);
77
+ const p = spawn("aws", args, { stdio: ["ignore", "pipe", "pipe"] });
78
+ let buf = "";
79
+ let err = "";
80
+ const RE = /^(\d{4}-\d{2}-\d{2}) +\S+ +(\d+) (.*)$/;
81
+ p.stdout.setEncoding("utf8");
82
+ p.stderr.setEncoding("utf8");
83
+ p.stdout.on("data", (chunk) => {
84
+ buf += chunk;
85
+ let i;
86
+ while ((i = buf.indexOf("\n")) >= 0) {
87
+ const m = RE.exec(buf.slice(0, i));
88
+ buf = buf.slice(i + 1);
89
+ if (m) onObject(m[1], parseInt(m[2], 10), m[3]);
90
+ }
91
+ });
92
+ p.stderr.on("data", (d) => (err += d));
93
+ p.on("error", reject);
94
+ p.on("close", (code) => {
95
+ // `aws s3 ls` exits 1 on an empty prefix with no stderr — that's fine.
96
+ if (code !== 0 && err.trim()) {
97
+ return reject(new Error(`aws s3 ls ${s3url} failed: ${err.trim().slice(0, 300)}`));
98
+ }
99
+ resolve();
100
+ });
101
+ });
102
+ } finally {
103
+ release();
104
+ }
105
+ }
106
+
107
+ module.exports = { run, streamLs, setConcurrency };
package/lib/render.js ADDED
@@ -0,0 +1,457 @@
1
+ "use strict";
2
+ // Renders the sweep into a single self-contained HTML file:
3
+ // hash-routed pages (catalog -> bucket -> folder), search, chart, dark mode.
4
+
5
+ const MONTHS = ["", "Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"];
6
+ const CHIP = { keep: ["keep", "Keep"], review: ["review", "Review"], disposable: ["disp", "Disposable"], empty: ["disp", "Empty"] };
7
+
8
+ function esc(s) {
9
+ return String(s).replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;").replace(/'/g, "&#39;");
10
+ }
11
+ function fmtB(b) {
12
+ if (b <= 0) return "0";
13
+ if (b < 1024) return b + " B";
14
+ if (b < 1024 ** 2) return Math.round(b / 1024) + " KB";
15
+ if (b < 1024 ** 3) return (b / 1024 ** 2).toFixed(1) + " MB";
16
+ return b < 100 * 1024 ** 3 ? (b / 1024 ** 3).toFixed(1) + " GB" : Math.round(b / 1024 ** 3) + " GB";
17
+ }
18
+ function fmtN(n) {
19
+ return n.toLocaleString("en-US");
20
+ }
21
+ function fmtD(d, thisYear) {
22
+ if (!d || d === "9999") return "";
23
+ const y = d.slice(0, 4), m = parseInt(d.slice(5, 7), 10), dd = parseInt(d.slice(8, 10), 10);
24
+ const base = MONTHS[m] + " " + dd;
25
+ return y === thisYear ? base : base + ", " + y;
26
+ }
27
+ function fmtWin(o, w, thisYear) {
28
+ const fo = fmtD(o, thisYear), fw = fmtD(w, thisYear);
29
+ if (!fo && !fw) return "–";
30
+ return fo === fw ? fo : fo + " – " + fw;
31
+ }
32
+ function fmtUsd(u) {
33
+ if (u >= 0.01) return u.toFixed(2);
34
+ return u > 0 ? "&lt;0.01" : "0";
35
+ }
36
+ function mostly(node) {
37
+ const bits = (node.x || []).slice(0, 2).filter(([, bb]) => bb > 0).map(([e, bb]) => esc(e) + " " + fmtB(bb));
38
+ return bits.length ? bits.join(" · ") : "–";
39
+ }
40
+ function chip(v) {
41
+ const c = CHIP[v];
42
+ return c ? '<span class="chip ' + c[0] + '"><span class="dot"></span>' + c[1] + "</span>" : "";
43
+ }
44
+
45
+ const CSS_VARS = [
46
+ " :root { color-scheme: light; --page:#f9f9f7; --surface-1:#fcfcfb; --text-1:#0b0b0b; --text-2:#52514e; --muted:#898781; --grid:#e1e0d9; --baseline:#c3c2b7; --border:rgba(11,11,11,0.10); --series-1:#2a78d6; --good:#0ca30c; --warn:#fab219; --chip-keep-bg:rgba(12,163,12,0.10); --chip-review-bg:rgba(250,178,25,0.16); --chip-disp-bg:rgba(137,135,129,0.14); }",
47
+ " @media (prefers-color-scheme: dark) { :root { color-scheme: dark; --page:#0d0d0d; --surface-1:#1a1a19; --text-1:#f0efec; --text-2:#c3c2b7; --muted:#898781; --grid:#2c2c2a; --baseline:#383835; --border:rgba(255,255,255,0.10); --series-1:#3987e5; --good:#0ca30c; --warn:#fab219; --chip-keep-bg:rgba(12,163,12,0.16); --chip-review-bg:rgba(250,178,25,0.14); --chip-disp-bg:rgba(137,135,129,0.18); } }",
48
+ ].join("\n");
49
+
50
+ const CSS = [
51
+ " * { box-sizing: border-box; }",
52
+ ' body { margin: 0; background: var(--page); color: var(--text-1); font: 15px/1.45 system-ui, -apple-system, "Segoe UI", sans-serif; padding: 28px 20px 48px; }',
53
+ " .wrap { max-width: 1060px; margin: 0 auto; }",
54
+ " h1 { font-size: 21px; font-weight: 650; margin: 0 0 2px; }",
55
+ " .meta { color: var(--muted); font-size: 13px; margin-bottom: 26px; }",
56
+ " .toprow { display: flex; gap: 14px; flex-wrap: wrap; align-items: stretch; margin-bottom: 14px; }",
57
+ " .card { background: var(--surface-1); border: 1px solid var(--border); border-radius: 10px; padding: 18px 20px; }",
58
+ " .hero { flex: 1 1 240px; display: flex; flex-direction: column; justify-content: center; }",
59
+ " .hero .label { font-size: 13px; color: var(--text-2); }",
60
+ " .hero .value { font-size: 52px; font-weight: 650; line-height: 1.1; letter-spacing: -0.5px; }",
61
+ " .hero .sub { font-size: 13px; color: var(--muted); margin-top: 4px; }",
62
+ " .tiles { flex: 2 1 420px; display: grid; grid-template-columns: repeat(3, 1fr); gap: 14px; }",
63
+ " .tile { display: flex; flex-direction: column; justify-content: center; }",
64
+ " .tile .label { font-size: 12.5px; color: var(--text-2); }",
65
+ " .tile .value { font-size: 26px; font-weight: 650; margin-top: 2px; }",
66
+ " .tile .sub { font-size: 12px; color: var(--muted); margin-top: 2px; }",
67
+ " .chart { margin-bottom: 14px; }",
68
+ " .chart h2, .tablecard h2 { font-size: 15px; font-weight: 650; margin: 0 0 2px; }",
69
+ " .chart .sub, .tablecard .sub { font-size: 12.5px; color: var(--muted); margin: 0 0 14px; }",
70
+ " .chart h2 { margin-bottom: 12px; }",
71
+ " .barrow { display: grid; grid-template-columns: 1fr 64px; align-items: center; gap: 10px; padding: 3px 0; border-radius: 6px; }",
72
+ " .barrow:hover { background: rgba(42,120,214,0.07); }",
73
+ " .track { position: relative; height: 14px; }",
74
+ " .bar { position: absolute; left: 0; top: 0; bottom: 0; background: var(--series-1); border-radius: 0 4px 4px 0; min-width: 2px; }",
75
+ " .bar.other { opacity: 0.45; }",
76
+ " .barrow .val { font-size: 12.5px; color: var(--text-2); font-variant-numeric: tabular-nums; }",
77
+ " .axisline { border-left: 1px solid var(--baseline); position: absolute; top: 0; bottom: 0; left: 0; }",
78
+ " .tablecard { padding: 18px 0 6px; margin-bottom: 14px; }",
79
+ " .tablecard h2, .tablecard .sub { padding: 0 20px; }",
80
+ " table { width: 100%; border-collapse: collapse; font-size: 13px; }",
81
+ " th { text-align: left; font-weight: 600; color: var(--muted); font-size: 11.5px; text-transform: uppercase; letter-spacing: 0.04em; padding: 8px 10px; border-bottom: 1px solid var(--grid); white-space: nowrap; }",
82
+ " td { padding: 8px 10px; border-bottom: 1px solid var(--grid); vertical-align: top; }",
83
+ " tr:last-child td { border-bottom: none; }",
84
+ " tbody tr:hover { background: rgba(42,120,214,0.05); }",
85
+ " td.num, th.num { text-align: right; font-variant-numeric: tabular-nums; white-space: nowrap; }",
86
+ " td.bname { font-weight: 600; white-space: nowrap; }",
87
+ " td.bname .proj { display: block; font-weight: 400; font-size: 11.5px; color: var(--muted); }",
88
+ " td.contents { color: var(--text-2); font-size: 12.5px; max-width: 300px; }",
89
+ " td.contents .note { display: block; color: var(--muted); font-size: 12px; margin-top: 2px; }",
90
+ " .chip { display: inline-flex; align-items: center; gap: 5px; font-size: 11.5px; font-weight: 600; padding: 2px 8px; border-radius: 999px; white-space: nowrap; }",
91
+ " .chip.keep { background: var(--chip-keep-bg); }",
92
+ " .chip.review { background: var(--chip-review-bg); }",
93
+ " .chip.disp { background: var(--chip-disp-bg); color: var(--text-2); }",
94
+ " .chip .dot { width: 7px; height: 7px; border-radius: 50%; }",
95
+ " .chip.keep .dot { background: var(--good); }",
96
+ " .chip.review .dot { background: var(--warn); }",
97
+ " .chip.disp .dot { background: var(--muted); }",
98
+ " .foot { color: var(--muted); font-size: 12px; margin-top: 18px; }",
99
+ " .firstcol { padding-left: 20px; }",
100
+ " .lastcol { padding-right: 20px; }",
101
+ " .page { display: none; }",
102
+ " .page.active { display: block; }",
103
+ " tr.rowlink { cursor: pointer; }",
104
+ " .chev { color: var(--muted); font-weight: 400; margin-left: 6px; }",
105
+ " .pagehead { display: flex; align-items: center; gap: 12px; flex-wrap: wrap; margin-bottom: 2px; }",
106
+ " .backbtn { display: inline-flex; align-items: center; justify-content: center; width: 34px; height: 34px; border: 1px solid var(--border); border-radius: 9px; background: var(--surface-1); color: var(--text-2); text-decoration: none; font-size: 17px; line-height: 1; flex: 0 0 auto; }",
107
+ " .backbtn:hover { color: var(--text-1); border-color: var(--baseline); }",
108
+ " .tiles4 { display: grid; grid-template-columns: repeat(4, 1fr); gap: 14px; margin-bottom: 14px; }",
109
+ " @media (max-width: 760px) { .tiles4 { grid-template-columns: repeat(2, 1fr); } .tiles { grid-template-columns: 1fr; } }",
110
+ " td.key { font-size: 12.5px; color: var(--text-2); max-width: 420px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }",
111
+ " .crumb { font-size: 13px; color: var(--muted); margin-bottom: 14px; }",
112
+ " .crumb a { color: var(--text-2); text-decoration: none; }",
113
+ " .crumb a:hover { color: var(--text-1); }",
114
+ " .searchwrap { position: relative; max-width: 460px; margin: -12px 0 18px; }",
115
+ " .search { width: 100%; padding: 9px 12px; font: inherit; font-size: 14px; color: var(--text-1); background: var(--surface-1); border: 1px solid var(--border); border-radius: 9px; outline: none; }",
116
+ " .search:focus { border-color: var(--series-1); }",
117
+ " .search::placeholder { color: var(--muted); }",
118
+ " .qresults { display: none; position: absolute; top: calc(100% + 6px); left: 0; right: 0; background: var(--surface-1); border: 1px solid var(--border); border-radius: 10px; box-shadow: 0 8px 24px rgba(0,0,0,0.18); max-height: 340px; overflow-y: auto; z-index: 20; }",
119
+ " .qitem { display: flex; justify-content: space-between; gap: 12px; padding: 8px 12px; cursor: pointer; font-size: 13px; }",
120
+ " .qitem:hover { background: rgba(42,120,214,0.08); }",
121
+ " .qitem strong { font-weight: 600; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }",
122
+ " .qitem span { color: var(--muted); white-space: nowrap; }",
123
+ " tr.hit td { background: rgba(42,120,214,0.18); }",
124
+ " .viztip { position: fixed; z-index: 10; background: var(--surface-1); border: 1px solid var(--border); border-radius: 6px; padding: 5px 9px; font-size: 12.5px; color: var(--text-1); pointer-events: none; box-shadow: 0 2px 10px rgba(0,0,0,0.15); display: none; white-space: nowrap; }",
125
+ " .about { font-size: 13.5px; color: var(--text-2); margin: 0; }",
126
+ ].join("\n");
127
+
128
+ // Plain-string scripts (no template interpolation anywhere near user data).
129
+ const SCRIPT = [
130
+ "<script>",
131
+ " function route() {",
132
+ ' var h = location.hash.slice(1);',
133
+ ' var target = h ? "page-" + h : "page-main";',
134
+ ' var pages = document.querySelectorAll(".page");',
135
+ " var found = false;",
136
+ " pages.forEach(function (p) { if (p.id === target) found = true; });",
137
+ ' if (!found) target = "page-main";',
138
+ ' pages.forEach(function (p) { p.classList.toggle("active", p.id === target); });',
139
+ " window.scrollTo(0, 0);",
140
+ " }",
141
+ ' window.addEventListener("hashchange", route);',
142
+ " route();",
143
+ ' var tip = document.createElement("div");',
144
+ ' tip.className = "viztip";',
145
+ " document.body.appendChild(tip);",
146
+ ' document.querySelectorAll(".barrow[data-label]").forEach(function (row) {',
147
+ ' row.addEventListener("mousemove", function (e) {',
148
+ ' tip.textContent = row.dataset.label + " \\u00b7 " + row.dataset.val;',
149
+ ' tip.style.display = "block";',
150
+ " var x = Math.min(e.clientX + 14, window.innerWidth - tip.offsetWidth - 8);",
151
+ ' tip.style.left = x + "px";',
152
+ ' tip.style.top = (e.clientY + 14) + "px";',
153
+ " });",
154
+ ' row.addEventListener("mouseleave", function () { tip.style.display = "none"; });',
155
+ " });",
156
+ " (function () {",
157
+ ' var q = document.getElementById("q"), qr = document.getElementById("qr");',
158
+ " if (!q) return;",
159
+ " var idx = [], seen = {};",
160
+ ' document.querySelectorAll(".page").forEach(function (pg) {',
161
+ ' var h = pg.querySelector("h1");',
162
+ " var title = h ? h.textContent.trim() : pg.id;",
163
+ ' if (pg.id === "page-main") title = "All buckets";',
164
+ ' var hash = pg.id === "page-main" ? "" : pg.id.slice(5);',
165
+ ' pg.querySelectorAll("td.bname, td.key").forEach(function (td) {',
166
+ ' var t = td.childNodes[0] ? td.childNodes[0].textContent.trim() : "";',
167
+ " if (!t) return;",
168
+ ' var k = t + "|" + pg.id;',
169
+ " if (seen[k]) return;",
170
+ " seen[k] = 1;",
171
+ " idx.push({ t: t, l: t.toLowerCase(), hash: hash, title: title, row: td.parentElement });",
172
+ " });",
173
+ " });",
174
+ " var cur = [];",
175
+ " function go(e) {",
176
+ " location.hash = e.hash;",
177
+ ' qr.style.display = "none";',
178
+ " setTimeout(function () {",
179
+ ' e.row.classList.add("hit");',
180
+ ' e.row.scrollIntoView({ block: "center" });',
181
+ ' setTimeout(function () { e.row.classList.remove("hit"); }, 1800);',
182
+ " }, 60);",
183
+ " }",
184
+ " function render(list) {",
185
+ ' qr.innerHTML = "";',
186
+ " list.slice(0, 20).forEach(function (e) {",
187
+ ' var d = document.createElement("div");',
188
+ ' d.className = "qitem";',
189
+ ' var s = document.createElement("strong"); s.textContent = e.t;',
190
+ ' var c = document.createElement("span"); c.textContent = e.title;',
191
+ " d.appendChild(s); d.appendChild(c);",
192
+ ' d.addEventListener("mousedown", function (ev) { ev.preventDefault(); go(e); });',
193
+ " qr.appendChild(d);",
194
+ " });",
195
+ ' qr.style.display = list.length ? "block" : "none";',
196
+ " }",
197
+ ' q.addEventListener("input", function () {',
198
+ " var v = q.value.trim().toLowerCase();",
199
+ ' if (v.length < 2) { qr.style.display = "none"; cur = []; return; }',
200
+ " var pre = [], sub = [];",
201
+ " for (var i = 0; i < idx.length; i++) {",
202
+ " var p = idx[i].l.indexOf(v);",
203
+ " if (p === 0) pre.push(idx[i]); else if (p > 0) sub.push(idx[i]);",
204
+ " }",
205
+ " cur = pre.concat(sub);",
206
+ " render(cur);",
207
+ " });",
208
+ ' q.addEventListener("keydown", function (ev) {',
209
+ ' if (ev.key === "Enter" && cur.length) go(cur[0]);',
210
+ ' if (ev.key === "Escape") { q.value = ""; qr.style.display = "none"; }',
211
+ " });",
212
+ ' document.addEventListener("click", function (ev) {',
213
+ ' if (!ev.target.closest(".searchwrap")) qr.style.display = "none";',
214
+ " });",
215
+ " })();",
216
+ "</script>",
217
+ ].join("\n");
218
+
219
+ /**
220
+ * @param results array of serialized bucket sweeps ({bucket,n,b,largest,prefixes})
221
+ * @param stats Map bucket -> {cwObjects, classes, estMonthly}
222
+ * @param notes object bucket -> {verdict, note, project} (optional)
223
+ * @param account AWS account id string
224
+ */
225
+ function render({ results, stats, notes = {}, account = "" }) {
226
+ const now = new Date();
227
+ const thisYear = String(now.getFullYear());
228
+ const genDate = MONTHS[now.getMonth() + 1] + " " + now.getDate() + ", " + now.getFullYear();
229
+
230
+ const st = (b) => stats.get(b) || { cwObjects: 0, classes: {}, estMonthly: 0 };
231
+ const order = [...results].sort((a, c) => st(c.bucket).estMonthly - st(a.bucket).estMonthly || c.b - a.b);
232
+
233
+ // slugs
234
+ const slugs = new Map();
235
+ const used = new Set();
236
+ for (const r of order) {
237
+ let s = r.bucket.toLowerCase().replace(/[^a-z0-9-]/g, "-").replace(/-+/g, "-").slice(0, 50);
238
+ while (used.has(s)) s += "x";
239
+ used.add(s);
240
+ slugs.set(r.bucket, s);
241
+ }
242
+
243
+ const totalN = order.reduce((a, r) => a + r.n, 0);
244
+ const totalB = order.reduce((a, r) => a + r.b, 0);
245
+ const totalMo = order.reduce((a, r) => a + st(r.bucket).estMonthly, 0);
246
+ const regions = new Set(order.map((r) => r.region).filter(Boolean));
247
+ const coldB = order.reduce((a, r) => {
248
+ const c = st(r.bucket).classes;
249
+ return a + (((c.GlacierInstantRetrievalStorage || 0) + (c.GlacierStorage || 0) + (c.DeepArchiveStorage || 0)) * 1024 ** 3);
250
+ }, 0);
251
+ const hiddenBuckets = order.filter((r) => {
252
+ const h = st(r.bucket).cwObjects - r.n;
253
+ return h > 1000 && h > r.n * 0.01;
254
+ });
255
+
256
+ const anyVerdict = order.some((r) => notes[r.bucket] && notes[r.bucket].verdict);
257
+ const pages = [];
258
+
259
+ // -------------------------------------------------------------- main page
260
+ let rows = "";
261
+ for (const r of order) {
262
+ const a = notes[r.bucket] || {};
263
+ const slug = "b-" + slugs.get(r.bucket);
264
+ const prefs = r.prefixes.slice(0, 6).map((p) => esc(p.p));
265
+ let contents = prefs.length ? prefs.join(" · ") : "empty";
266
+ if (r.prefixes.length > 6) contents += " and " + (r.prefixes.length - 6) + " more";
267
+ const noteh = a.note ? '<span class="note">' + esc(a.note) + "</span>" : "";
268
+ rows +=
269
+ '<tr class="rowlink" onclick="location.hash=\'' + slug + '\'">' +
270
+ '<td class="bname firstcol">' + esc(r.bucket) + '<span class="chev">›</span>' +
271
+ '<span class="proj">' + esc(a.project || r.region || "") + "</span></td>" +
272
+ (anyVerdict ? "<td>" + chip(a.verdict) + "</td>" : "") +
273
+ '<td class="num">' + fmtB(r.b) + '</td><td class="num">' + fmtN(r.n) + '</td><td class="num">' + fmtUsd(st(r.bucket).estMonthly) + "</td>" +
274
+ '<td class="contents lastcol">' + contents + noteh + "</td></tr>";
275
+ }
276
+
277
+ let chartCard = "";
278
+ const chartItems = order.filter((r) => st(r.bucket).estMonthly >= 0.01).slice(0, 12);
279
+ if (chartItems.length >= 2) {
280
+ const maxu = st(chartItems[0].bucket).estMonthly;
281
+ const otherU = totalMo - chartItems.reduce((a, r) => a + st(r.bucket).estMonthly, 0);
282
+ let bars = "";
283
+ for (const r of chartItems) {
284
+ const u = st(r.bucket).estMonthly;
285
+ bars +=
286
+ '<div class="barrow" data-label="' + esc(r.bucket) + '" data-val="$' + u.toFixed(2) + '/mo"><div class="track">' +
287
+ '<div class="bar" style="width:' + ((u / maxu) * 100).toFixed(1) + '%"></div></div><div class="val">$' + u.toFixed(2) + "</div></div>";
288
+ }
289
+ if (otherU > 0.005) {
290
+ bars +=
291
+ '<div class="barrow" data-label="' + (order.length - chartItems.length) + ' more buckets" data-val="$' + otherU.toFixed(2) + '/mo"><div class="track">' +
292
+ '<div class="bar other" style="width:' + ((otherU / maxu) * 100).toFixed(1) + '%"></div></div><div class="val">$' + otherU.toFixed(2) + "</div></div>";
293
+ }
294
+ chartCard =
295
+ '<div class="card chart"><h2>Monthly cost by bucket</h2>' +
296
+ '<div style="position:relative;"><div class="axisline" style="left:0;"></div>' + bars + "</div></div>";
297
+ }
298
+
299
+ const hero = totalMo > 0
300
+ ? '<div class="card hero"><div class="label">S3 storage cost</div>' +
301
+ '<div class="value">$' + (totalMo / 30).toFixed(2) + '<span style="font-size:22px;color:var(--text-2);font-weight:500;">/day</span></div>' +
302
+ '<div class="sub">$' + totalMo.toFixed(2) + " per month, estimated at list prices</div></div>"
303
+ : '<div class="card hero"><div class="label">Stored</div>' +
304
+ '<div class="value">' + fmtB(totalB) + "</div>" +
305
+ '<div class="sub">across ' + order.length + " buckets</div></div>";
306
+
307
+ const storedTile = totalB > 0 && coldB > 0
308
+ ? Math.round((coldB / totalB) * 100) + "% in cold storage classes"
309
+ : "all storage classes";
310
+ const objectsSub = hiddenBuckets.length
311
+ ? hiddenBuckets.length + " bucket" + (hiddenBuckets.length > 1 ? "s" : "") + " also hide old versions"
312
+ : "current versions";
313
+
314
+ pages.push(
315
+ '<div id="page-main" class="page">' +
316
+ "<h1>Bucket List</h1>" +
317
+ '<div class="meta">Lightweight search for S3</div>' +
318
+ '<div class="searchwrap"><input id="q" class="search" type="search" placeholder="Search buckets, folders, files" autocomplete="off"><div id="qr" class="qresults"></div></div>' +
319
+ '<div class="toprow">' + hero +
320
+ '<div class="tiles">' +
321
+ '<div class="card tile"><div class="label">Stored</div><div class="value">' + fmtB(totalB) + '</div><div class="sub">' + storedTile + "</div></div>" +
322
+ '<div class="card tile"><div class="label">Objects</div><div class="value">' + (totalN >= 1e6 ? (totalN / 1e6).toFixed(1) + "M" : fmtN(totalN)) + '</div><div class="sub">' + objectsSub + "</div></div>" +
323
+ '<div class="card tile"><div class="label">Buckets</div><div class="value">' + order.length + '</div><div class="sub">' +
324
+ (regions.size > 1 ? "across " + regions.size + " regions" : [...regions][0] || "") + "</div></div>" +
325
+ "</div></div>" +
326
+ chartCard +
327
+ '<div class="card tablecard"><h2>All ' + order.length + " buckets</h2>" +
328
+ '<p class="sub">Every row opens a drill-down. Sorted by cost.</p>' +
329
+ '<table><thead><tr><th class="firstcol">Bucket</th>' + (anyVerdict ? "<th>Verdict</th>" : "") +
330
+ '<th class="num">Size</th><th class="num">Objects</th><th class="num">$/mo</th><th class="lastcol">Contents</th></tr></thead>' +
331
+ "<tbody>" + rows + "</tbody></table></div>" +
332
+ '<div class="foot">Full object enumeration via the AWS CLI · account ' + esc(account) + " · " + genDate +
333
+ " · storage only at us-east-1 list prices, an estimate; request and transfer charges excluded.</div>" +
334
+ "</div>"
335
+ );
336
+
337
+ // ------------------------------------------------- bucket + folder pages
338
+ for (const r of order) {
339
+ const a = notes[r.bucket] || {};
340
+ const slug = "b-" + slugs.get(r.bucket);
341
+ const bst = st(r.bucket);
342
+ let lastW = "";
343
+ let lastP = "";
344
+ for (const p of r.prefixes) {
345
+ if (p.w > lastW) {
346
+ lastW = p.w;
347
+ lastP = p.p;
348
+ }
349
+ }
350
+ const topShare = r.b > 0 && r.prefixes.length
351
+ ? Math.round((r.prefixes[0].b / r.b) * 100) + "% under " + esc(r.prefixes[0].p)
352
+ : "empty";
353
+
354
+ let prows = "";
355
+ r.prefixes.forEach((p, i) => {
356
+ const hasKids = !!(p.c && p.c.length);
357
+ const pid = "p-" + slugs.get(r.bucket) + "-" + i;
358
+ const click = hasKids ? ' class="rowlink" onclick="location.hash=\'' + pid + '\'"' : "";
359
+ const chev = hasKids ? '<span class="chev">›</span>' : "";
360
+ const fams = p.f || [];
361
+ const famtxt = fams.length && fams[0][1] > 2 ? " — " + esc(fams[0][0]) + " ×" + fmtN(fams[0][1]) : "";
362
+ prows +=
363
+ "<tr" + click + '><td class="bname firstcol">' + esc(p.p) + chev + "</td>" +
364
+ '<td class="num">' + fmtN(p.n) + '</td><td class="num">' + fmtB(p.b) + "</td>" +
365
+ "<td>" + fmtWin(p.o, p.w, thisYear) + "</td>" +
366
+ '<td class="contents lastcol">' + mostly(p) + famtxt + "</td></tr>";
367
+ });
368
+
369
+ let lrows = "";
370
+ for (const [key, sz, d] of r.largest.slice(0, 10)) {
371
+ lrows += '<tr><td class="key firstcol">' + esc(key) + '</td><td class="num">' + fmtB(sz) + '</td><td class="num lastcol">' + fmtD(d, thisYear) + "</td></tr>";
372
+ }
373
+ const largestCard = lrows
374
+ ? '<div class="card tablecard"><h2>Largest objects</h2>' +
375
+ '<table><thead><tr><th class="firstcol">Key</th><th class="num">Size</th><th class="num lastcol">Modified</th></tr></thead>' +
376
+ "<tbody>" + lrows + "</tbody></table></div>"
377
+ : "";
378
+
379
+ const hidden = bst.cwObjects - r.n;
380
+ let about = a.note ? esc(a.note) : "";
381
+ if (hidden > 1000 && hidden > r.n * 0.01) {
382
+ about += (about ? " " : "") +
383
+ "CloudWatch counts about " + fmtN(hidden) + " more objects than the listing shows, likely old versions kept by bucket versioning; a lifecycle rule expiring noncurrent versions would reclaim that storage.";
384
+ }
385
+ const aboutCard = about ? '<div class="card chart"><h2>About</h2><p class="about">' + about + "</p></div>" : "";
386
+
387
+ const tiles =
388
+ '<div class="tiles4">' +
389
+ '<div class="card tile"><div class="label">Stored</div><div class="value">' + fmtB(r.b) + '</div><div class="sub">' + topShare + "</div></div>" +
390
+ '<div class="card tile"><div class="label">Objects</div><div class="value">' + fmtN(r.n) + '</div><div class="sub">' + r.prefixes.length + " top-level folders</div></div>" +
391
+ '<div class="card tile"><div class="label">Cost</div><div class="value">$' + fmtUsd(bst.estMonthly) + '<span style="font-size:15px;color:var(--text-2);font-weight:500;">/mo</span></div><div class="sub">storage, estimated</div></div>' +
392
+ '<div class="card tile"><div class="label">Last write</div><div class="value">' + (fmtD(lastW, thisYear) || "–") + '</div><div class="sub">' + (esc(lastP) || "no objects") + "</div></div>" +
393
+ "</div>";
394
+
395
+ const ptable = prows
396
+ ? '<div class="card tablecard"><h2>By folder</h2><p class="sub">Sorted by size. Activity is the first-to-last write window.</p>' +
397
+ '<table><thead><tr><th class="firstcol">Folder</th><th class="num">Objects</th><th class="num">Size</th><th>Activity</th><th class="lastcol">Mostly</th></tr></thead>' +
398
+ "<tbody>" + prows + "</tbody></table></div>"
399
+ : '<div class="card chart"><p class="about">Empty bucket.</p></div>';
400
+
401
+ pages.push(
402
+ '<div id="page-' + slug + '" class="page">' +
403
+ '<div class="crumb"><a href="#">All buckets</a> › ' + esc(r.bucket) + "</div>" +
404
+ '<div class="pagehead"><a class="backbtn" href="#" aria-label="Back">←</a><h1>' + esc(r.bucket) + "</h1>" + (anyVerdict ? chip(a.verdict) : "") + "</div>" +
405
+ '<div class="meta">' + esc(r.region || "") + (a.project ? " · " + esc(a.project) : "") + "</div>" +
406
+ tiles + aboutCard + ptable + largestCard +
407
+ '<div class="foot">Full object enumeration via live S3 listing · nothing modified.</div>' +
408
+ "</div>"
409
+ );
410
+
411
+ // folder pages
412
+ r.prefixes.forEach((p, i) => {
413
+ if (!p.c || !p.c.length) return;
414
+ const pid = "p-" + slugs.get(r.bucket) + "-" + i;
415
+ let crows = "";
416
+ for (const k of p.c) {
417
+ crows +=
418
+ '<tr><td class="bname firstcol">' + esc(k.p) + "</td>" +
419
+ '<td class="num">' + fmtN(k.n) + '</td><td class="num">' + fmtB(k.b) + "</td>" +
420
+ "<td>" + fmtWin(k.o, k.w, thisYear) + "</td>" +
421
+ '<td class="contents lastcol">' + mostly(k) + "</td></tr>";
422
+ }
423
+ let fcard = "";
424
+ if (p.f && p.f.length) {
425
+ let frows = "";
426
+ for (const [pat, cnt, bb] of p.f.slice(0, 8)) {
427
+ frows += '<tr><td class="key firstcol">' + esc(pat) + '</td><td class="num">' + fmtN(cnt) + '</td><td class="num lastcol">' + fmtB(bb) + "</td></tr>";
428
+ }
429
+ fcard =
430
+ '<div class="card tablecard"><h2>Common file patterns</h2>' +
431
+ '<p class="sub"># stands in for any run of digits or hex, so one row is a whole family.</p>' +
432
+ '<table><thead><tr><th class="firstcol">Pattern</th><th class="num">Files</th><th class="num lastcol">Size</th></tr></thead>' +
433
+ "<tbody>" + frows + "</tbody></table></div>";
434
+ }
435
+ pages.push(
436
+ '<div id="page-' + pid + '" class="page">' +
437
+ '<div class="crumb"><a href="#">All buckets</a> › <a href="#' + slug + '">' + esc(r.bucket) + "</a> › " + esc(p.p) + "</div>" +
438
+ '<div class="pagehead"><a class="backbtn" href="#' + slug + '" aria-label="Back">←</a><h1>' + esc(p.p) + "</h1></div>" +
439
+ '<div class="meta">' + esc(r.bucket) + " · " + fmtN(p.n) + " objects · " + fmtB(p.b) + " · " + fmtWin(p.o, p.w, thisYear) + "</div>" +
440
+ '<div class="card tablecard"><h2>Folders</h2><p class="sub">Sorted by size.</p>' +
441
+ '<table><thead><tr><th class="firstcol">Folder</th><th class="num">Objects</th><th class="num">Size</th><th>Activity</th><th class="lastcol">Mostly</th></tr></thead>' +
442
+ "<tbody>" + crows + "</tbody></table></div>" + fcard +
443
+ '<div class="foot">Aggregated from the full object listing.</div>' +
444
+ "</div>"
445
+ );
446
+ });
447
+ }
448
+
449
+ return (
450
+ "<!doctype html>\n<html lang=\"en\">\n<head>\n<meta charset=\"utf-8\">\n" +
451
+ '<meta name="viewport" content="width=device-width, initial-scale=1">\n' +
452
+ "<title>Bucket List</title>\n<style>\n" + CSS_VARS + "\n" + CSS + "\n</style>\n</head>\n<body>\n" +
453
+ '<div class="wrap">\n' + pages.join("\n") + "\n</div>\n" + SCRIPT + "\n</body>\n</html>\n"
454
+ );
455
+ }
456
+
457
+ module.exports = { render };
package/lib/sweep.js ADDED
@@ -0,0 +1,289 @@
1
+ "use strict";
2
+ // Discovery, cost estimation, and the recursive sharded sweep.
3
+ const fs = require("fs");
4
+ const os = require("os");
5
+ const path = require("path");
6
+ const { run, streamLs } = require("./aws");
7
+
8
+ // $/GB-month at us-east-1 list prices, keyed by CloudWatch StorageType.
9
+ // Prices vary by region and change over time; everything derived from these
10
+ // is labeled an estimate in the output.
11
+ const STORAGE_PRICES = {
12
+ StandardStorage: 0.023,
13
+ StandardIAStorage: 0.0125,
14
+ OneZoneIAStorage: 0.01,
15
+ GlacierInstantRetrievalStorage: 0.004,
16
+ GlacierStorage: 0.0036,
17
+ DeepArchiveStorage: 0.00099,
18
+ IntelligentTieringFAStorage: 0.023,
19
+ IntelligentTieringIAStorage: 0.0125,
20
+ };
21
+
22
+ // ---------------------------------------------------------------- discovery
23
+
24
+ async function discoverBuckets(onlyNames) {
25
+ const res = await run(["s3api", "list-buckets"]);
26
+ let names = (res.Buckets || []).map((b) => b.Name);
27
+ if (onlyNames && onlyNames.length) {
28
+ const want = new Set(onlyNames);
29
+ const have = new Set(names);
30
+ for (const n of onlyNames) {
31
+ if (!have.has(n)) throw new Error(`bucket not found in this account: ${n}`);
32
+ }
33
+ names = names.filter((n) => want.has(n));
34
+ }
35
+ const buckets = [];
36
+ const skipped = [];
37
+ await Promise.all(
38
+ names.map(async (name) => {
39
+ try {
40
+ const lc = await run(["s3api", "get-bucket-location", "--bucket", name]);
41
+ let region = lc.LocationConstraint || "us-east-1";
42
+ if (region === "EU") region = "eu-west-1";
43
+ buckets.push({ name, region });
44
+ } catch (e) {
45
+ skipped.push({ name, reason: e.message });
46
+ }
47
+ })
48
+ );
49
+ buckets.sort((a, b) => a.name.localeCompare(b.name));
50
+ return { buckets, skipped };
51
+ }
52
+
53
+ // ---------------------------------------------------------------- estimate
54
+
55
+ async function estimate(buckets) {
56
+ // CloudWatch daily storage metrics, batched per region, <=50 buckets/call.
57
+ const byRegion = new Map();
58
+ for (const b of buckets) {
59
+ if (!byRegion.has(b.region)) byRegion.set(b.region, []);
60
+ byRegion.get(b.region).push(b.name);
61
+ }
62
+ const stats = new Map(); // name -> {cwObjects, classes:{type:GB}, estMonthly}
63
+ for (const b of buckets) stats.set(b.name, { cwObjects: 0, classes: {}, estMonthly: 0 });
64
+
65
+ const end = new Date();
66
+ const start = new Date(end.getTime() - 3 * 86400 * 1000);
67
+ const types = Object.keys(STORAGE_PRICES);
68
+
69
+ for (const [region, names] of byRegion) {
70
+ for (let i = 0; i < names.length; i += 50) {
71
+ const chunk = names.slice(i, i + 50);
72
+ const queries = [];
73
+ chunk.forEach((name, bi) => {
74
+ queries.push({
75
+ Id: `c${bi}`,
76
+ MetricStat: {
77
+ Metric: {
78
+ Namespace: "AWS/S3",
79
+ MetricName: "NumberOfObjects",
80
+ Dimensions: [
81
+ { Name: "BucketName", Value: name },
82
+ { Name: "StorageType", Value: "AllStorageTypes" },
83
+ ],
84
+ },
85
+ Period: 86400,
86
+ Stat: "Average",
87
+ },
88
+ ReturnData: true,
89
+ });
90
+ types.forEach((t, ti) => {
91
+ queries.push({
92
+ Id: `s${bi}_${ti}`,
93
+ MetricStat: {
94
+ Metric: {
95
+ Namespace: "AWS/S3",
96
+ MetricName: "BucketSizeBytes",
97
+ Dimensions: [
98
+ { Name: "BucketName", Value: name },
99
+ { Name: "StorageType", Value: t },
100
+ ],
101
+ },
102
+ Period: 86400,
103
+ Stat: "Average",
104
+ },
105
+ ReturnData: true,
106
+ });
107
+ });
108
+ });
109
+ const qfile = path.join(os.tmpdir(), `bucketlist-q-${process.pid}-${i}.json`);
110
+ fs.writeFileSync(qfile, JSON.stringify(queries));
111
+ try {
112
+ const res = await run(
113
+ [
114
+ "cloudwatch", "get-metric-data",
115
+ "--metric-data-queries", `file://${qfile}`,
116
+ "--start-time", start.toISOString(),
117
+ "--end-time", end.toISOString(),
118
+ "--scan-by", "TimestampDescending",
119
+ ],
120
+ { region }
121
+ );
122
+ for (const m of res.MetricDataResults || []) {
123
+ if (!m.Values || !m.Values.length) continue;
124
+ const v = m.Values[0];
125
+ const mm = /^c(\d+)$/.exec(m.Id) || /^s(\d+)_(\d+)$/.exec(m.Id);
126
+ if (!mm) continue;
127
+ const st = stats.get(chunk[parseInt(mm[1], 10)]);
128
+ if (m.Id.startsWith("c")) {
129
+ st.cwObjects = Math.round(v);
130
+ } else {
131
+ const t = types[parseInt(mm[2], 10)];
132
+ const gb = v / 1024 ** 3;
133
+ if (gb > 0.001) {
134
+ st.classes[t] = gb;
135
+ st.estMonthly += gb * STORAGE_PRICES[t];
136
+ }
137
+ }
138
+ }
139
+ } catch (e) {
140
+ // CloudWatch denied or absent: the sweep still works, costs show as 0.
141
+ } finally {
142
+ fs.unlinkSync(qfile);
143
+ }
144
+ }
145
+ }
146
+ return stats;
147
+ }
148
+
149
+ // ---------------------------------------------------------------- aggregator
150
+
151
+ function newNode() {
152
+ return { n: 0, b: 0, o: "9999", w: "", ext: new Map(), fam: new Map(), c: new Map() };
153
+ }
154
+
155
+ function bump(node, size, lm, ext, pat) {
156
+ node.n++;
157
+ node.b += size;
158
+ if (lm < node.o) node.o = lm;
159
+ if (lm > node.w) node.w = lm;
160
+ node.ext.set(ext, (node.ext.get(ext) || 0) + size);
161
+ const f = node.fam.get(pat);
162
+ if (f) {
163
+ f[0]++;
164
+ f[1] += size;
165
+ } else {
166
+ node.fam.set(pat, [1, size]);
167
+ }
168
+ if (node.fam.size > 1200) {
169
+ const keep = [...node.fam.entries()].sort((a, b) => b[1][1] - a[1][1]).slice(0, 500);
170
+ node.fam = new Map(keep);
171
+ }
172
+ }
173
+
174
+ function newAgg() {
175
+ return { n: 0, b: 0, nodes: new Map(), largest: [] };
176
+ }
177
+
178
+ function aggAdd(agg, key, size, lm) {
179
+ agg.n++;
180
+ agg.b += size;
181
+ const parts = key.split("/");
182
+ const fn = parts[parts.length - 1];
183
+ const pat = fn.replace(/[0-9a-fA-F]{6,}/g, "#").replace(/\d+/g, "#").slice(0, 80);
184
+ const dot = fn.lastIndexOf(".");
185
+ const ext = dot > 0 && fn.length - dot <= 10 ? fn.slice(dot).toLowerCase() : "(none)";
186
+ const l1 = parts.length > 1 ? parts[0] + "/" : "(root)";
187
+ let n1 = agg.nodes.get(l1);
188
+ if (!n1) agg.nodes.set(l1, (n1 = newNode()));
189
+ bump(n1, size, lm, ext, pat);
190
+ if (parts.length > 2) {
191
+ const l2 = parts[0] + "/" + parts[1] + "/";
192
+ let n2 = n1.c.get(l2);
193
+ if (!n2) n1.c.set(l2, (n2 = newNode()));
194
+ bump(n2, size, lm, ext, pat);
195
+ }
196
+ agg.largest.push([size, key, lm]);
197
+ if (agg.largest.length > 400) {
198
+ agg.largest.sort((a, b) => b[0] - a[0]);
199
+ agg.largest.length = 12;
200
+ }
201
+ }
202
+
203
+ function serNode(name, node, withKids) {
204
+ const out = {
205
+ p: name,
206
+ n: node.n,
207
+ b: node.b,
208
+ o: node.o,
209
+ w: node.w,
210
+ x: [...node.ext.entries()].sort((a, b) => b[1] - a[1]).slice(0, 3),
211
+ f: [...node.fam.entries()].sort((a, b) => b[1][1] - a[1][1]).slice(0, 6).map(([p, c]) => [p, c[0], c[1]]),
212
+ };
213
+ if (withKids && node.c.size) {
214
+ const kids = [...node.c.entries()].sort((a, b) => b[1].b - a[1].b);
215
+ out.c = kids.slice(0, 30).map(([k, v]) => serNode(k, v, false));
216
+ if (kids.length > 30) {
217
+ let rn = 0, rb = 0;
218
+ for (const [, v] of kids.slice(30)) {
219
+ rn += v.n;
220
+ rb += v.b;
221
+ }
222
+ out.c.push({ p: `(${kids.length - 30} more)`, n: rn, b: rb, o: "", w: "", x: [], f: [] });
223
+ }
224
+ }
225
+ return out;
226
+ }
227
+
228
+ function serialize(agg, bucket) {
229
+ agg.largest.sort((a, b) => b[0] - a[0]);
230
+ const prefs = [...agg.nodes.entries()].sort((a, b) => b[1].b - a[1].b);
231
+ return {
232
+ bucket,
233
+ n: agg.n,
234
+ b: agg.b,
235
+ largest: agg.largest.slice(0, 12).map(([s, k, d]) => [k, s, d]),
236
+ prefixes: prefs.slice(0, 40).map(([k, v]) => serNode(k, v, true)),
237
+ };
238
+ }
239
+
240
+ // ---------------------------------------------------------------- walker
241
+
242
+ const SPLIT_DEPTH = 4; // below this, stream flat
243
+ const KID_LIMIT = 1500; // folder explosion guard at the bucket root
244
+ const KID_SPLIT_MAX = 64; // deeper levels: more folders than this means a
245
+ // partitioned layout (dt=2025-03-21/ style); one stream beats 10,000 spawns
246
+ const PAGE_CAP = 50000; // max entries to hold from one delimiter listing
247
+
248
+ async function walk(bucket, region, prefix, depth, agg) {
249
+ if (depth >= SPLIT_DEPTH) return flat(bucket, region, prefix, agg);
250
+ let res;
251
+ try {
252
+ res = await run(
253
+ [
254
+ "s3api", "list-objects-v2",
255
+ "--bucket", bucket,
256
+ "--prefix", prefix,
257
+ "--delimiter", "/",
258
+ "--max-items", String(PAGE_CAP),
259
+ ],
260
+ { region }
261
+ );
262
+ } catch (e) {
263
+ // fall back to a flat stream on any listing hiccup
264
+ return flat(bucket, region, prefix, agg);
265
+ }
266
+ const kids = (res.CommonPrefixes || []).map((p) => p.Prefix);
267
+ const kidCap = depth === 0 ? KID_LIMIT : KID_SPLIT_MAX;
268
+ if (res.NextToken || kids.length > kidCap) {
269
+ // too big or too many folders at this level: stream the whole subtree
270
+ // (nothing from this listing was merged, so no double counting)
271
+ return flat(bucket, region, prefix, agg);
272
+ }
273
+ for (const o of res.Contents || []) {
274
+ aggAdd(agg, o.Key, o.Size, String(o.LastModified).slice(0, 10));
275
+ }
276
+ await Promise.all(kids.map((k) => walk(bucket, region, k, depth + 1, agg)));
277
+ }
278
+
279
+ async function flat(bucket, region, prefix, agg) {
280
+ await streamLs(`s3://${bucket}/${prefix}`, region, (d, s, k) => aggAdd(agg, k, s, d));
281
+ }
282
+
283
+ async function sweepBucket(bucket, region) {
284
+ const agg = newAgg();
285
+ await walk(bucket, region, "", 0, agg);
286
+ return serialize(agg, bucket);
287
+ }
288
+
289
+ module.exports = { discoverBuckets, estimate, sweepBucket, STORAGE_PRICES };
package/package.json CHANGED
@@ -1,6 +1,37 @@
1
1
  {
2
2
  "name": "@nysgpt/bucket-list",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
3
+ "version": "0.1.0",
4
+ "description": "Bucket List \u2014 lightweight search for S3. One command sweeps every bucket in an AWS account into a single offline HTML file: drill-down by bucket and folder, object counts, storage-class costs, largest files, and instant search.",
5
+ "bin": {
6
+ "bucket-list": "bin/bucket-list.js"
7
+ },
8
+ "files": [
9
+ "bin",
10
+ "lib",
11
+ "README.md",
12
+ "LICENSE"
13
+ ],
14
+ "engines": {
15
+ "node": ">=18"
16
+ },
17
+ "keywords": [
18
+ "aws",
19
+ "s3",
20
+ "inventory",
21
+ "storage",
22
+ "cost",
23
+ "dashboard",
24
+ "cli",
25
+ "bucket",
26
+ "audit"
27
+ ],
28
+ "author": "Brendan Stanton <brendan@nysgpt.com>",
29
+ "license": "MIT",
30
+ "repository": {
31
+ "type": "git",
32
+ "url": "https://github.com/nyd-user-1/bucket-list.git"
33
+ },
34
+ "publishConfig": {
35
+ "access": "public"
36
+ }
6
37
  }