@muthuishere/vsync 0.3.1 → 0.5.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.
- package/README.md +124 -66
- package/bin/audit.ts +73 -0
- package/bin/export.ts +52 -2
- package/bin/import.ts +52 -2
- package/bin/init.ts +26 -0
- package/bin/pull.ts +56 -1
- package/bin/push.ts +56 -1
- package/bin/use.ts +175 -0
- package/bin/vsync.ts +19 -0
- package/package.json +7 -3
- package/src/argv.ts +15 -3
- package/src/audit.ts +752 -0
- package/src/repoconfig.ts +19 -0
- package/src/templates/docs.md.ts +27 -0
package/src/audit.ts
ADDED
|
@@ -0,0 +1,752 @@
|
|
|
1
|
+
// audit.ts — append-only CSV audit log at s3://<bucket>/<repo>/<env>/audit.csv.
|
|
2
|
+
//
|
|
3
|
+
// Wire format: SPEC-v0.4 §4. Columns are locked; readers parse by header.
|
|
4
|
+
// Append protocol: SPEC-v0.4 §5 — ETag-conditional PUT with up to 3 retries
|
|
5
|
+
// on 412 Precondition Failed, silent skip on 403, throw on any other error.
|
|
6
|
+
//
|
|
7
|
+
// NOTE on Bun.S3Client conditional writes
|
|
8
|
+
// ----------------------------------------
|
|
9
|
+
// Bun 1.3.0's S3Client surface exposes ETag on `stat()` but does NOT accept
|
|
10
|
+
// `If-Match` / `If-None-Match` on `write()` — there is no field in
|
|
11
|
+
// `S3Options` for it (checked node_modules/bun-types/s3.d.ts). The spec
|
|
12
|
+
// assumed it did. So this module uses the Bun client for the *reads*
|
|
13
|
+
// (`stat`, `text`) and falls back to a minimal AWS SigV4-signed `fetch`
|
|
14
|
+
// PUT for the *write* — that's the only place conditional headers are
|
|
15
|
+
// needed. Reads are unchanged; only the append path is hand-signed.
|
|
16
|
+
//
|
|
17
|
+
// If a future Bun release adds `ifMatch` / `ifNoneMatch` to S3Options,
|
|
18
|
+
// `signedPut` can be deleted and the call swapped for `client.file(k).write`.
|
|
19
|
+
|
|
20
|
+
import * as os from "node:os";
|
|
21
|
+
import type { S3Credentials } from "./s3";
|
|
22
|
+
|
|
23
|
+
// ---------------------------------------------------------------------------
|
|
24
|
+
// Types
|
|
25
|
+
|
|
26
|
+
export type AuditAction = "pull" | "push" | "import" | "export";
|
|
27
|
+
|
|
28
|
+
export type AuditRow = {
|
|
29
|
+
ts: string;
|
|
30
|
+
action: AuditAction;
|
|
31
|
+
version_ts: string;
|
|
32
|
+
hostname: string;
|
|
33
|
+
local_ip: string;
|
|
34
|
+
os_user: string;
|
|
35
|
+
git_email: string;
|
|
36
|
+
vsync_version: string;
|
|
37
|
+
bun_version: string;
|
|
38
|
+
/** Serialized JSON object, or empty string when no meta was supplied. */
|
|
39
|
+
meta: string;
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
/** CSV header — column order is locked. New columns go to the right. */
|
|
43
|
+
export const AUDIT_HEADER =
|
|
44
|
+
"ts,action,version_ts,hostname,local_ip,os_user,git_email,vsync_version,bun_version,meta";
|
|
45
|
+
|
|
46
|
+
const META_SIZE_LIMIT_BYTES = 2048;
|
|
47
|
+
|
|
48
|
+
// ---------------------------------------------------------------------------
|
|
49
|
+
// Path helpers
|
|
50
|
+
|
|
51
|
+
/** S3 key for the audit log of a given (repo, env). env is lowercased. */
|
|
52
|
+
export function auditKey(repo: string, env: string): string {
|
|
53
|
+
if (!repo) throw new Error("repo is required");
|
|
54
|
+
if (!env) throw new Error("env is required");
|
|
55
|
+
return `${repo}/${env.toLowerCase()}/audit.csv`;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// ---------------------------------------------------------------------------
|
|
59
|
+
// Row metadata gathering
|
|
60
|
+
|
|
61
|
+
/** Return the first non-loopback IPv4, else first non-loopback IPv6, else "". */
|
|
62
|
+
function firstUsableIp(): string {
|
|
63
|
+
let ifaces: NodeJS.Dict<os.NetworkInterfaceInfo[]>;
|
|
64
|
+
try {
|
|
65
|
+
ifaces = os.networkInterfaces();
|
|
66
|
+
} catch {
|
|
67
|
+
return "";
|
|
68
|
+
}
|
|
69
|
+
let ipv6Fallback = "";
|
|
70
|
+
for (const list of Object.values(ifaces)) {
|
|
71
|
+
if (!list) continue;
|
|
72
|
+
for (const i of list) {
|
|
73
|
+
if (i.internal) continue;
|
|
74
|
+
if (i.family === "IPv4" || (i as any).family === 4) {
|
|
75
|
+
return i.address;
|
|
76
|
+
}
|
|
77
|
+
if (!ipv6Fallback && (i.family === "IPv6" || (i as any).family === 6)) {
|
|
78
|
+
ipv6Fallback = i.address;
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
return ipv6Fallback;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function safeHostname(): string {
|
|
86
|
+
try {
|
|
87
|
+
return os.hostname() ?? "";
|
|
88
|
+
} catch {
|
|
89
|
+
return "";
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** Read `vsync_version` from package.json. Cached after first read. */
|
|
94
|
+
let _cachedVsyncVersion: string | null = null;
|
|
95
|
+
async function readVsyncVersion(): Promise<string> {
|
|
96
|
+
if (_cachedVsyncVersion !== null) return _cachedVsyncVersion;
|
|
97
|
+
try {
|
|
98
|
+
// The package.json sits two levels up from this module at install time
|
|
99
|
+
// (src/ → repo root). Use Bun.file which handles both source and bundle.
|
|
100
|
+
const url = new URL("../package.json", import.meta.url);
|
|
101
|
+
const text = await Bun.file(url.pathname).text();
|
|
102
|
+
const parsed = JSON.parse(text);
|
|
103
|
+
_cachedVsyncVersion = typeof parsed?.version === "string" ? parsed.version : "";
|
|
104
|
+
} catch {
|
|
105
|
+
_cachedVsyncVersion = "";
|
|
106
|
+
}
|
|
107
|
+
return _cachedVsyncVersion;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
async function readGitEmail(): Promise<string> {
|
|
111
|
+
try {
|
|
112
|
+
const proc = Bun.spawn(["git", "config", "--get", "user.email"], {
|
|
113
|
+
stderr: "pipe",
|
|
114
|
+
stdout: "pipe",
|
|
115
|
+
});
|
|
116
|
+
const code = await proc.exited;
|
|
117
|
+
if (code !== 0) return "";
|
|
118
|
+
return (await new Response(proc.stdout).text()).trim();
|
|
119
|
+
} catch {
|
|
120
|
+
return "";
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Build an AuditRow with every field populated except `meta` (caller fills
|
|
126
|
+
* via buildMeta). `versionTs` is the bundle TS for pull/push; empty for
|
|
127
|
+
* import/export.
|
|
128
|
+
*/
|
|
129
|
+
export async function gatherRowMetadata(
|
|
130
|
+
action: AuditAction,
|
|
131
|
+
versionTs: string = "",
|
|
132
|
+
): Promise<AuditRow> {
|
|
133
|
+
return {
|
|
134
|
+
ts: new Date().toISOString(),
|
|
135
|
+
action,
|
|
136
|
+
version_ts: versionTs,
|
|
137
|
+
hostname: safeHostname(),
|
|
138
|
+
local_ip: firstUsableIp(),
|
|
139
|
+
os_user: process.env.USER ?? process.env.USERNAME ?? "",
|
|
140
|
+
git_email: await readGitEmail(),
|
|
141
|
+
vsync_version: await readVsyncVersion(),
|
|
142
|
+
bun_version: process.versions.bun ?? "",
|
|
143
|
+
meta: "",
|
|
144
|
+
};
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// ---------------------------------------------------------------------------
|
|
148
|
+
// Meta merging (spec §4.1)
|
|
149
|
+
|
|
150
|
+
export type BuildMetaInput = {
|
|
151
|
+
/** Raw value of `$VSYNC_AUDIT_META` — a JSON object, or undefined. */
|
|
152
|
+
envMeta?: string;
|
|
153
|
+
/** Raw value of `$VSYNC_AUDIT_NOTE` — free text, or undefined. */
|
|
154
|
+
envNote?: string;
|
|
155
|
+
/** Repeated `--meta key=value` values, in order. */
|
|
156
|
+
flagMetaList?: string[];
|
|
157
|
+
/** Value of `--note=<text>`. */
|
|
158
|
+
flagNote?: string;
|
|
159
|
+
};
|
|
160
|
+
|
|
161
|
+
export type BuildMetaResult = {
|
|
162
|
+
/** Serialized JSON (or `""` when nothing was supplied). */
|
|
163
|
+
json: string;
|
|
164
|
+
/** Human-readable warnings to emit on stderr; empty when clean. */
|
|
165
|
+
warnings: string[];
|
|
166
|
+
};
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* Merge the four input sources per spec §4.1 priority order:
|
|
170
|
+
* envMeta < envNote < flagMetaList < flagNote
|
|
171
|
+
* Last writer wins per key. Returns `""` when no source supplied anything.
|
|
172
|
+
* Enforces the 2KB cap (over → returns `{"_truncated":true}` + warning).
|
|
173
|
+
*/
|
|
174
|
+
export function buildMeta(opts: BuildMetaInput): BuildMetaResult {
|
|
175
|
+
const warnings: string[] = [];
|
|
176
|
+
const merged: Record<string, string> = {};
|
|
177
|
+
let touched = false;
|
|
178
|
+
|
|
179
|
+
// 1. $VSYNC_AUDIT_META — must parse to a JSON object
|
|
180
|
+
if (opts.envMeta !== undefined && opts.envMeta !== "") {
|
|
181
|
+
try {
|
|
182
|
+
const parsed = JSON.parse(opts.envMeta);
|
|
183
|
+
if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
|
|
184
|
+
for (const [k, v] of Object.entries(parsed)) {
|
|
185
|
+
merged[k] = typeof v === "string" ? v : JSON.stringify(v);
|
|
186
|
+
}
|
|
187
|
+
touched = true;
|
|
188
|
+
} else {
|
|
189
|
+
warnings.push(
|
|
190
|
+
"warning: $VSYNC_AUDIT_META is not a JSON object, ignoring",
|
|
191
|
+
);
|
|
192
|
+
}
|
|
193
|
+
} catch (e) {
|
|
194
|
+
warnings.push(
|
|
195
|
+
`warning: $VSYNC_AUDIT_META is not valid JSON, ignoring (${(e as Error).message})`,
|
|
196
|
+
);
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
// 2. $VSYNC_AUDIT_NOTE — sugar for { note: <text> }
|
|
201
|
+
if (opts.envNote !== undefined && opts.envNote !== "") {
|
|
202
|
+
merged.note = opts.envNote;
|
|
203
|
+
touched = true;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
// 3. --meta key=value (repeatable)
|
|
207
|
+
if (opts.flagMetaList && opts.flagMetaList.length > 0) {
|
|
208
|
+
for (const raw of opts.flagMetaList) {
|
|
209
|
+
const eq = raw.indexOf("=");
|
|
210
|
+
if (eq === -1) {
|
|
211
|
+
// Per spec §4.1: "--meta key (no =) is a usage error."
|
|
212
|
+
// We surface this as a thrown error so the caller can exit cleanly
|
|
213
|
+
// instead of silently logging.
|
|
214
|
+
throw new Error(`--meta requires key=value form, got: ${raw}`);
|
|
215
|
+
}
|
|
216
|
+
const k = raw.slice(0, eq);
|
|
217
|
+
const v = raw.slice(eq + 1);
|
|
218
|
+
if (!k) throw new Error(`--meta requires a non-empty key, got: ${raw}`);
|
|
219
|
+
merged[k] = v;
|
|
220
|
+
touched = true;
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
// 4. --note=<text> — sugar for `--meta note=<text>`, highest precedence
|
|
225
|
+
if (opts.flagNote !== undefined && opts.flagNote !== "") {
|
|
226
|
+
merged.note = opts.flagNote;
|
|
227
|
+
touched = true;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
if (!touched) return { json: "", warnings };
|
|
231
|
+
|
|
232
|
+
const json = JSON.stringify(merged);
|
|
233
|
+
if (Buffer.byteLength(json, "utf8") > META_SIZE_LIMIT_BYTES) {
|
|
234
|
+
warnings.push(
|
|
235
|
+
`warning: audit meta exceeded ${META_SIZE_LIMIT_BYTES} bytes, replaced with {"_truncated":true}`,
|
|
236
|
+
);
|
|
237
|
+
return { json: JSON.stringify({ _truncated: true }), warnings };
|
|
238
|
+
}
|
|
239
|
+
return { json, warnings };
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
// ---------------------------------------------------------------------------
|
|
243
|
+
// RFC 4180 CSV serialize / parse
|
|
244
|
+
|
|
245
|
+
function csvQuote(field: string): string {
|
|
246
|
+
if (field === "") return "";
|
|
247
|
+
if (/[",\n\r]/.test(field)) {
|
|
248
|
+
return '"' + field.replace(/"/g, '""') + '"';
|
|
249
|
+
}
|
|
250
|
+
return field;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
/** Serialize a row to a single CSV line (no trailing newline). */
|
|
254
|
+
export function rowToCsv(row: AuditRow): string {
|
|
255
|
+
return [
|
|
256
|
+
row.ts,
|
|
257
|
+
row.action,
|
|
258
|
+
row.version_ts,
|
|
259
|
+
row.hostname,
|
|
260
|
+
row.local_ip,
|
|
261
|
+
row.os_user,
|
|
262
|
+
row.git_email,
|
|
263
|
+
row.vsync_version,
|
|
264
|
+
row.bun_version,
|
|
265
|
+
row.meta,
|
|
266
|
+
]
|
|
267
|
+
.map(csvQuote)
|
|
268
|
+
.join(",");
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
/**
|
|
272
|
+
* Parse RFC 4180 CSV into rows (arrays of strings). Handles quoted fields,
|
|
273
|
+
* doubled quotes, embedded `,` and newlines.
|
|
274
|
+
*/
|
|
275
|
+
function parseCsv(text: string): string[][] {
|
|
276
|
+
const rows: string[][] = [];
|
|
277
|
+
let row: string[] = [];
|
|
278
|
+
let field = "";
|
|
279
|
+
let inQuotes = false;
|
|
280
|
+
let i = 0;
|
|
281
|
+
while (i < text.length) {
|
|
282
|
+
const c = text[i];
|
|
283
|
+
if (inQuotes) {
|
|
284
|
+
if (c === '"') {
|
|
285
|
+
if (text[i + 1] === '"') {
|
|
286
|
+
field += '"';
|
|
287
|
+
i += 2;
|
|
288
|
+
continue;
|
|
289
|
+
}
|
|
290
|
+
inQuotes = false;
|
|
291
|
+
i++;
|
|
292
|
+
continue;
|
|
293
|
+
}
|
|
294
|
+
field += c;
|
|
295
|
+
i++;
|
|
296
|
+
continue;
|
|
297
|
+
}
|
|
298
|
+
if (c === '"') {
|
|
299
|
+
inQuotes = true;
|
|
300
|
+
i++;
|
|
301
|
+
continue;
|
|
302
|
+
}
|
|
303
|
+
if (c === ",") {
|
|
304
|
+
row.push(field);
|
|
305
|
+
field = "";
|
|
306
|
+
i++;
|
|
307
|
+
continue;
|
|
308
|
+
}
|
|
309
|
+
if (c === "\n" || c === "\r") {
|
|
310
|
+
// Eat \r\n as a single break
|
|
311
|
+
if (c === "\r" && text[i + 1] === "\n") i++;
|
|
312
|
+
row.push(field);
|
|
313
|
+
// Skip empty trailing rows (file ends with \n)
|
|
314
|
+
if (!(row.length === 1 && row[0] === "")) rows.push(row);
|
|
315
|
+
row = [];
|
|
316
|
+
field = "";
|
|
317
|
+
i++;
|
|
318
|
+
continue;
|
|
319
|
+
}
|
|
320
|
+
field += c;
|
|
321
|
+
i++;
|
|
322
|
+
}
|
|
323
|
+
// Last field if file lacks trailing newline
|
|
324
|
+
if (field !== "" || row.length > 0) {
|
|
325
|
+
row.push(field);
|
|
326
|
+
if (!(row.length === 1 && row[0] === "")) rows.push(row);
|
|
327
|
+
}
|
|
328
|
+
return rows;
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
/** Parse a full CSV (header + rows) into AuditRow[]. */
|
|
332
|
+
export function parseAuditCsv(text: string): AuditRow[] {
|
|
333
|
+
const grid = parseCsv(text);
|
|
334
|
+
if (grid.length === 0) return [];
|
|
335
|
+
const header = grid[0];
|
|
336
|
+
const cols = [
|
|
337
|
+
"ts",
|
|
338
|
+
"action",
|
|
339
|
+
"version_ts",
|
|
340
|
+
"hostname",
|
|
341
|
+
"local_ip",
|
|
342
|
+
"os_user",
|
|
343
|
+
"git_email",
|
|
344
|
+
"vsync_version",
|
|
345
|
+
"bun_version",
|
|
346
|
+
"meta",
|
|
347
|
+
] as const;
|
|
348
|
+
const idx: Record<string, number> = {};
|
|
349
|
+
for (const c of cols) idx[c] = header.indexOf(c);
|
|
350
|
+
const out: AuditRow[] = [];
|
|
351
|
+
for (let r = 1; r < grid.length; r++) {
|
|
352
|
+
const row = grid[r];
|
|
353
|
+
const get = (k: (typeof cols)[number]) =>
|
|
354
|
+
idx[k] >= 0 ? (row[idx[k]] ?? "") : "";
|
|
355
|
+
out.push({
|
|
356
|
+
ts: get("ts"),
|
|
357
|
+
action: get("action") as AuditAction,
|
|
358
|
+
version_ts: get("version_ts"),
|
|
359
|
+
hostname: get("hostname"),
|
|
360
|
+
local_ip: get("local_ip"),
|
|
361
|
+
os_user: get("os_user"),
|
|
362
|
+
git_email: get("git_email"),
|
|
363
|
+
vsync_version: get("vsync_version"),
|
|
364
|
+
bun_version: get("bun_version"),
|
|
365
|
+
meta: get("meta"),
|
|
366
|
+
});
|
|
367
|
+
}
|
|
368
|
+
return out;
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
// ---------------------------------------------------------------------------
|
|
372
|
+
// Append protocol — abstract client surface (for tests + real Bun.S3Client)
|
|
373
|
+
|
|
374
|
+
/**
|
|
375
|
+
* Minimal surface the audit code uses. Real impl is a thin wrapper around
|
|
376
|
+
* Bun's S3 client + a SigV4 fetch for conditional PUT. Tests pass a fake.
|
|
377
|
+
*/
|
|
378
|
+
export interface AuditClient {
|
|
379
|
+
/** Fetch object; null when 404. Returns text + ETag. */
|
|
380
|
+
read(key: string): Promise<{ text: string; etag: string } | null>;
|
|
381
|
+
/**
|
|
382
|
+
* Conditional PUT. `condition.ifMatch` for "must match this ETag";
|
|
383
|
+
* `condition.ifNoneMatch: "*"` for "object must not exist".
|
|
384
|
+
* Throws an Error whose `.status` is the HTTP status on failure.
|
|
385
|
+
*/
|
|
386
|
+
conditionalPut(
|
|
387
|
+
key: string,
|
|
388
|
+
body: string,
|
|
389
|
+
condition: { ifMatch?: string; ifNoneMatch?: string },
|
|
390
|
+
): Promise<void>;
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
/** Error carrying an HTTP-style status code, for retry classification. */
|
|
394
|
+
export class AuditHttpError extends Error {
|
|
395
|
+
status: number;
|
|
396
|
+
constructor(status: number, message: string) {
|
|
397
|
+
super(message);
|
|
398
|
+
this.name = "AuditHttpError";
|
|
399
|
+
this.status = status;
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
/**
|
|
404
|
+
* Append a single row using the ETag-conditional protocol from spec §5.
|
|
405
|
+
*
|
|
406
|
+
* 1. Read existing CSV (if any) + its ETag.
|
|
407
|
+
* 2. Build body (header+row on first write; existing+row on append).
|
|
408
|
+
* 3. PUT with If-None-Match: * (new) or If-Match: <etag> (append).
|
|
409
|
+
* 4. On 412 Precondition Failed → re-read, re-append. Up to 3 attempts.
|
|
410
|
+
* 5. On 403 → silently skip (caller has no write permission).
|
|
411
|
+
* 6. On any other failure → throw (the caller will catch + warn).
|
|
412
|
+
*/
|
|
413
|
+
export async function appendAuditRow(
|
|
414
|
+
client: AuditClient,
|
|
415
|
+
repo: string,
|
|
416
|
+
env: string,
|
|
417
|
+
row: AuditRow,
|
|
418
|
+
): Promise<void> {
|
|
419
|
+
const key = auditKey(repo, env);
|
|
420
|
+
const newLine = rowToCsv(row) + "\n";
|
|
421
|
+
|
|
422
|
+
for (let attempt = 1; attempt <= 3; attempt++) {
|
|
423
|
+
let existing: { text: string; etag: string } | null;
|
|
424
|
+
try {
|
|
425
|
+
existing = await client.read(key);
|
|
426
|
+
} catch (e) {
|
|
427
|
+
const status = (e as AuditHttpError).status;
|
|
428
|
+
if (status === 403) return; // read denied → skip silently
|
|
429
|
+
throw e;
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
let body: string;
|
|
433
|
+
let condition: { ifMatch?: string; ifNoneMatch?: string };
|
|
434
|
+
if (!existing) {
|
|
435
|
+
body = AUDIT_HEADER + "\n" + newLine;
|
|
436
|
+
condition = { ifNoneMatch: "*" };
|
|
437
|
+
} else {
|
|
438
|
+
// Ensure prior content ends with a newline so rows aren't joined.
|
|
439
|
+
const prior = existing.text.endsWith("\n")
|
|
440
|
+
? existing.text
|
|
441
|
+
: existing.text + "\n";
|
|
442
|
+
body = prior + newLine;
|
|
443
|
+
condition = { ifMatch: existing.etag };
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
try {
|
|
447
|
+
await client.conditionalPut(key, body, condition);
|
|
448
|
+
return; // success
|
|
449
|
+
} catch (e) {
|
|
450
|
+
const status = (e as AuditHttpError).status;
|
|
451
|
+
if (status === 403) return; // write denied → skip silently
|
|
452
|
+
if (status === 412 && attempt < 3) continue; // conflict → retry
|
|
453
|
+
throw e;
|
|
454
|
+
}
|
|
455
|
+
}
|
|
456
|
+
throw new AuditHttpError(
|
|
457
|
+
412,
|
|
458
|
+
"audit append: 3 conflicting writers in a row, giving up",
|
|
459
|
+
);
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
/** Fetch + parse the audit log. Returns `[]` when the object doesn't exist. */
|
|
463
|
+
export async function readAuditLog(
|
|
464
|
+
client: AuditClient,
|
|
465
|
+
repo: string,
|
|
466
|
+
env: string,
|
|
467
|
+
): Promise<AuditRow[]> {
|
|
468
|
+
const key = auditKey(repo, env);
|
|
469
|
+
const existing = await client.read(key);
|
|
470
|
+
if (!existing) return [];
|
|
471
|
+
return parseAuditCsv(existing.text);
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
// ---------------------------------------------------------------------------
|
|
475
|
+
// Pretty-print
|
|
476
|
+
|
|
477
|
+
/** Raw CSV passthrough (header + each row + trailing newline). */
|
|
478
|
+
export function formatAuditCsv(rows: AuditRow[]): string {
|
|
479
|
+
const lines = [AUDIT_HEADER, ...rows.map(rowToCsv)];
|
|
480
|
+
return lines.join("\n") + "\n";
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
type FormatOpts = { limit?: number; all?: boolean };
|
|
484
|
+
|
|
485
|
+
/**
|
|
486
|
+
* Pretty table, newest first. Default limit 50; `--all` overrides. Unwraps
|
|
487
|
+
* `meta.note` into its own column; remaining meta keys collapse into a
|
|
488
|
+
* `k=v, k2=v2` summary in the `meta` column.
|
|
489
|
+
*/
|
|
490
|
+
export function formatAuditTable(rows: AuditRow[], opts: FormatOpts = {}): string {
|
|
491
|
+
if (rows.length === 0) return "(no rows)";
|
|
492
|
+
const limit = opts.all ? rows.length : (opts.limit ?? 50);
|
|
493
|
+
// Newest first — `ts` is ISO 8601, so lexical sort works.
|
|
494
|
+
const sorted = [...rows].sort((a, b) => (a.ts < b.ts ? 1 : a.ts > b.ts ? -1 : 0));
|
|
495
|
+
const shown = sorted.slice(0, limit);
|
|
496
|
+
|
|
497
|
+
type Display = {
|
|
498
|
+
ts: string;
|
|
499
|
+
action: string;
|
|
500
|
+
version_ts: string;
|
|
501
|
+
user: string;
|
|
502
|
+
host: string;
|
|
503
|
+
note: string;
|
|
504
|
+
meta: string;
|
|
505
|
+
};
|
|
506
|
+
const display: Display[] = shown.map((r) => {
|
|
507
|
+
const { note, rest } = splitNote(r.meta);
|
|
508
|
+
return {
|
|
509
|
+
ts: r.ts,
|
|
510
|
+
action: r.action,
|
|
511
|
+
version_ts: r.version_ts,
|
|
512
|
+
user: r.os_user || r.git_email,
|
|
513
|
+
host: r.hostname || r.local_ip,
|
|
514
|
+
note,
|
|
515
|
+
meta: rest,
|
|
516
|
+
};
|
|
517
|
+
});
|
|
518
|
+
|
|
519
|
+
const cols: { key: keyof Display; label: string }[] = [
|
|
520
|
+
{ key: "ts", label: "TS" },
|
|
521
|
+
{ key: "action", label: "ACTION" },
|
|
522
|
+
{ key: "version_ts", label: "VERSION" },
|
|
523
|
+
{ key: "user", label: "USER" },
|
|
524
|
+
{ key: "host", label: "HOST" },
|
|
525
|
+
{ key: "note", label: "NOTE" },
|
|
526
|
+
{ key: "meta", label: "META" },
|
|
527
|
+
];
|
|
528
|
+
const widths: Record<string, number> = {};
|
|
529
|
+
for (const c of cols) {
|
|
530
|
+
widths[c.key] = c.label.length;
|
|
531
|
+
for (const d of display) widths[c.key] = Math.max(widths[c.key], d[c.key].length);
|
|
532
|
+
}
|
|
533
|
+
const pad = (s: string, w: number) => s + " ".repeat(Math.max(0, w - s.length));
|
|
534
|
+
const headerLine = cols.map((c) => pad(c.label, widths[c.key])).join(" ");
|
|
535
|
+
const sep = cols.map((c) => "-".repeat(widths[c.key])).join(" ");
|
|
536
|
+
const bodyLines = display.map((d) =>
|
|
537
|
+
cols.map((c) => pad(d[c.key], widths[c.key])).join(" "),
|
|
538
|
+
);
|
|
539
|
+
const omitted = rows.length - shown.length;
|
|
540
|
+
const footer =
|
|
541
|
+
omitted > 0
|
|
542
|
+
? `\n(${omitted} older row${omitted === 1 ? "" : "s"} not shown; pass --all to see everything)`
|
|
543
|
+
: "";
|
|
544
|
+
return [headerLine, sep, ...bodyLines].join("\n") + footer;
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
function splitNote(metaCell: string): { note: string; rest: string } {
|
|
548
|
+
if (!metaCell) return { note: "", rest: "" };
|
|
549
|
+
let obj: unknown;
|
|
550
|
+
try {
|
|
551
|
+
obj = JSON.parse(metaCell);
|
|
552
|
+
} catch {
|
|
553
|
+
return { note: "", rest: metaCell };
|
|
554
|
+
}
|
|
555
|
+
if (!obj || typeof obj !== "object" || Array.isArray(obj)) {
|
|
556
|
+
return { note: "", rest: metaCell };
|
|
557
|
+
}
|
|
558
|
+
const o = obj as Record<string, unknown>;
|
|
559
|
+
const note = typeof o.note === "string" ? o.note : "";
|
|
560
|
+
const rest = Object.entries(o)
|
|
561
|
+
.filter(([k]) => k !== "note")
|
|
562
|
+
.map(([k, v]) => `${k}=${typeof v === "string" ? v : JSON.stringify(v)}`)
|
|
563
|
+
.join(", ");
|
|
564
|
+
return { note, rest };
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
// ---------------------------------------------------------------------------
|
|
568
|
+
// Real AuditClient implementation — Bun.S3Client reads + SigV4 fetch PUT
|
|
569
|
+
|
|
570
|
+
/**
|
|
571
|
+
* Build a real AuditClient backed by Bun.S3Client (reads) and a hand-signed
|
|
572
|
+
* `fetch` PUT (writes — needed for If-Match / If-None-Match which Bun's
|
|
573
|
+
* S3Options doesn't expose as of 1.3.0).
|
|
574
|
+
*/
|
|
575
|
+
export function makeAuditClient(creds: S3Credentials): AuditClient {
|
|
576
|
+
return {
|
|
577
|
+
async read(key: string) {
|
|
578
|
+
const client = makeBunS3(creds);
|
|
579
|
+
const f = client.file(key);
|
|
580
|
+
let etag: string;
|
|
581
|
+
try {
|
|
582
|
+
const stat = await f.stat();
|
|
583
|
+
etag = stat.etag;
|
|
584
|
+
} catch (e: any) {
|
|
585
|
+
// Bun throws on 404; detect by name/status.
|
|
586
|
+
if (is404(e)) return null;
|
|
587
|
+
throw classify(e);
|
|
588
|
+
}
|
|
589
|
+
const text = await f.text();
|
|
590
|
+
return { text, etag };
|
|
591
|
+
},
|
|
592
|
+
async conditionalPut(key, body, condition) {
|
|
593
|
+
await sigv4Put(creds, key, body, condition);
|
|
594
|
+
},
|
|
595
|
+
};
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
function makeBunS3(creds: S3Credentials): Bun.S3Client {
|
|
599
|
+
const protocol = creds.useSsl ? "https://" : "http://";
|
|
600
|
+
const endpoint = creds.endpoint.startsWith("http")
|
|
601
|
+
? creds.endpoint
|
|
602
|
+
: protocol + creds.endpoint;
|
|
603
|
+
return new Bun.S3Client({
|
|
604
|
+
accessKeyId: creds.accessKeyId,
|
|
605
|
+
secretAccessKey: creds.secretAccessKey,
|
|
606
|
+
region: creds.region,
|
|
607
|
+
bucket: creds.bucket,
|
|
608
|
+
endpoint,
|
|
609
|
+
});
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
function is404(e: any): boolean {
|
|
613
|
+
if (!e) return false;
|
|
614
|
+
const msg = String(e?.message ?? e);
|
|
615
|
+
const code = (e as any).code ?? (e as any).status;
|
|
616
|
+
if (code === 404 || code === "NoSuchKey") return true;
|
|
617
|
+
return /NoSuchKey|not found|404|does not exist/i.test(msg);
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
function classify(e: any): Error {
|
|
621
|
+
const msg = String(e?.message ?? e);
|
|
622
|
+
const m = msg.match(/\b(40[0-9]|41[0-9]|42[0-9]|5\d\d)\b/);
|
|
623
|
+
if (m) return new AuditHttpError(parseInt(m[1], 10), msg);
|
|
624
|
+
return e instanceof Error ? e : new Error(msg);
|
|
625
|
+
}
|
|
626
|
+
|
|
627
|
+
// --- SigV4 PUT (audit.csv only) -------------------------------------------
|
|
628
|
+
|
|
629
|
+
async function sigv4Put(
|
|
630
|
+
creds: S3Credentials,
|
|
631
|
+
key: string,
|
|
632
|
+
body: string,
|
|
633
|
+
condition: { ifMatch?: string; ifNoneMatch?: string },
|
|
634
|
+
): Promise<void> {
|
|
635
|
+
const protocol = creds.useSsl ? "https" : "http";
|
|
636
|
+
// Endpoint may be host-only or full URL — normalize to a base URL.
|
|
637
|
+
const baseRaw = creds.endpoint.startsWith("http")
|
|
638
|
+
? creds.endpoint
|
|
639
|
+
: `${protocol}://${creds.endpoint}`;
|
|
640
|
+
const base = new URL(baseRaw);
|
|
641
|
+
|
|
642
|
+
// path-style: <endpoint>/<bucket>/<key>
|
|
643
|
+
const url = new URL(
|
|
644
|
+
`/${creds.bucket}/${key.split("/").map(encodeURIComponent).join("/")}`,
|
|
645
|
+
base,
|
|
646
|
+
);
|
|
647
|
+
|
|
648
|
+
const now = new Date();
|
|
649
|
+
const amzDate = isoBasic(now); // 20260516T012233Z
|
|
650
|
+
const dateStamp = amzDate.slice(0, 8); // 20260516
|
|
651
|
+
|
|
652
|
+
const bodyBytes = new TextEncoder().encode(body);
|
|
653
|
+
const payloadHash = await sha256Hex(bodyBytes);
|
|
654
|
+
|
|
655
|
+
const headers: Record<string, string> = {
|
|
656
|
+
host: url.host,
|
|
657
|
+
"content-type": "text/csv",
|
|
658
|
+
"x-amz-content-sha256": payloadHash,
|
|
659
|
+
"x-amz-date": amzDate,
|
|
660
|
+
};
|
|
661
|
+
if (condition.ifMatch) {
|
|
662
|
+
// Strip surrounding quotes — Ceph RGW (Hetzner Object Storage) rejects
|
|
663
|
+
// the quoted form with 412 even when the ETag matches. AWS S3 and MinIO
|
|
664
|
+
// accept either. Bun.S3Client returns ETags pre-quoted, so we strip.
|
|
665
|
+
headers["if-match"] = condition.ifMatch.replace(/^"|"$/g, "");
|
|
666
|
+
}
|
|
667
|
+
if (condition.ifNoneMatch) headers["if-none-match"] = condition.ifNoneMatch;
|
|
668
|
+
|
|
669
|
+
const signedHeaderNames = Object.keys(headers).sort();
|
|
670
|
+
const canonicalHeaders =
|
|
671
|
+
signedHeaderNames.map((n) => `${n}:${headers[n].trim()}\n`).join("");
|
|
672
|
+
const signedHeaders = signedHeaderNames.join(";");
|
|
673
|
+
|
|
674
|
+
const canonicalRequest = [
|
|
675
|
+
"PUT",
|
|
676
|
+
url.pathname,
|
|
677
|
+
"", // canonical querystring (none)
|
|
678
|
+
canonicalHeaders,
|
|
679
|
+
signedHeaders,
|
|
680
|
+
payloadHash,
|
|
681
|
+
].join("\n");
|
|
682
|
+
|
|
683
|
+
const region = creds.region;
|
|
684
|
+
const service = "s3";
|
|
685
|
+
const scope = `${dateStamp}/${region}/${service}/aws4_request`;
|
|
686
|
+
const stringToSign = [
|
|
687
|
+
"AWS4-HMAC-SHA256",
|
|
688
|
+
amzDate,
|
|
689
|
+
scope,
|
|
690
|
+
await sha256Hex(new TextEncoder().encode(canonicalRequest)),
|
|
691
|
+
].join("\n");
|
|
692
|
+
|
|
693
|
+
const kDate = await hmac(`AWS4${creds.secretAccessKey}`, dateStamp);
|
|
694
|
+
const kRegion = await hmac(kDate, region);
|
|
695
|
+
const kService = await hmac(kRegion, service);
|
|
696
|
+
const kSigning = await hmac(kService, "aws4_request");
|
|
697
|
+
const signature = bytesToHex(await hmac(kSigning, stringToSign));
|
|
698
|
+
|
|
699
|
+
const authHeader =
|
|
700
|
+
`AWS4-HMAC-SHA256 Credential=${creds.accessKeyId}/${scope}, ` +
|
|
701
|
+
`SignedHeaders=${signedHeaders}, Signature=${signature}`;
|
|
702
|
+
|
|
703
|
+
const fetchHeaders: Record<string, string> = { ...headers, authorization: authHeader };
|
|
704
|
+
|
|
705
|
+
const resp = await fetch(url.toString(), {
|
|
706
|
+
method: "PUT",
|
|
707
|
+
headers: fetchHeaders,
|
|
708
|
+
body: bodyBytes,
|
|
709
|
+
});
|
|
710
|
+
if (!resp.ok) {
|
|
711
|
+
const text = await resp.text().catch(() => "");
|
|
712
|
+
throw new AuditHttpError(resp.status, `S3 PUT ${resp.status}: ${text || resp.statusText}`);
|
|
713
|
+
}
|
|
714
|
+
}
|
|
715
|
+
|
|
716
|
+
function isoBasic(d: Date): string {
|
|
717
|
+
// 2026-05-16T01:22:33.123Z → 20260516T012233Z
|
|
718
|
+
return d.toISOString().replace(/[-:]/g, "").replace(/\.\d{3}Z$/, "Z");
|
|
719
|
+
}
|
|
720
|
+
|
|
721
|
+
async function sha256Hex(data: Uint8Array | string): Promise<string> {
|
|
722
|
+
const bytes = typeof data === "string" ? new TextEncoder().encode(data) : data;
|
|
723
|
+
const buf = await crypto.subtle.digest("SHA-256", bytes);
|
|
724
|
+
return bytesToHex(new Uint8Array(buf));
|
|
725
|
+
}
|
|
726
|
+
|
|
727
|
+
async function hmac(
|
|
728
|
+
key: string | Uint8Array,
|
|
729
|
+
msg: string,
|
|
730
|
+
): Promise<Uint8Array> {
|
|
731
|
+
const keyBytes =
|
|
732
|
+
typeof key === "string" ? new TextEncoder().encode(key) : key;
|
|
733
|
+
const cryptoKey = await crypto.subtle.importKey(
|
|
734
|
+
"raw",
|
|
735
|
+
keyBytes,
|
|
736
|
+
{ name: "HMAC", hash: "SHA-256" },
|
|
737
|
+
false,
|
|
738
|
+
["sign"],
|
|
739
|
+
);
|
|
740
|
+
const sig = await crypto.subtle.sign(
|
|
741
|
+
"HMAC",
|
|
742
|
+
cryptoKey,
|
|
743
|
+
new TextEncoder().encode(msg),
|
|
744
|
+
);
|
|
745
|
+
return new Uint8Array(sig);
|
|
746
|
+
}
|
|
747
|
+
|
|
748
|
+
function bytesToHex(b: Uint8Array): string {
|
|
749
|
+
let s = "";
|
|
750
|
+
for (let i = 0; i < b.length; i++) s += b[i].toString(16).padStart(2, "0");
|
|
751
|
+
return s;
|
|
752
|
+
}
|