arkiv-cli 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.
@@ -0,0 +1,397 @@
1
+ /**
2
+ * `arkiv schema` - discover what is actually on the network, and lint the
3
+ * naming/structure mistakes that make a schema hard to find six months later.
4
+ *
5
+ * Discovery works by sampling: the query language has no "list schemas" call,
6
+ * so the CLI pages through entities that carry an arkiv.schema attribute,
7
+ * ordered by key so pages are stable while others write.
8
+ */
9
+ import { asc } from "@arkiv-network/sdk/query";
10
+ import { schemaOf } from "../render.js";
11
+ import { getTiming } from "../arkiv.js";
12
+ import { attributeSize, entityFootprint } from "../values.js";
13
+ import { MAX_ATTRIBUTES, MAX_ENTITY_BYTES, MAX_STRING_BYTES } from "../limits.js";
14
+ import { bold, cyan, dim, field, formatDate, formatDuration, green, grey, out, red, table, yellow, } from "../ui.js";
15
+ import { blockToUnix } from "../arkiv.js";
16
+ export async function sampleSchemas(ctx, options) {
17
+ const client = ctx.client();
18
+ const schemas = new Map();
19
+ const pageSize = 100;
20
+ const target = Math.max(pageSize, Math.min(options.sample, 1000));
21
+ let scanned = 0;
22
+ let block = 0;
23
+ for (let offset = 0; offset < target; offset += pageSize) {
24
+ const page = await client
25
+ .select({
26
+ $key: true,
27
+ $owner: true,
28
+ $expiresAt: true,
29
+ $attributeSchema: true,
30
+ $attributes: true,
31
+ })
32
+ .where("EXISTS(arkiv.schema)")
33
+ .orderBy(asc("$key"))
34
+ .limit(pageSize)
35
+ .offset(offset)
36
+ .fetch();
37
+ block = Number(page.blockNumber);
38
+ const entities = page.entities;
39
+ for (const entity of entities) {
40
+ scanned += 1;
41
+ const name = schemaOf(entity);
42
+ if (!name)
43
+ continue;
44
+ absorbEntity(schemas, entity);
45
+ }
46
+ if (entities.length < pageSize)
47
+ break;
48
+ }
49
+ return { schemas, scanned, block };
50
+ }
51
+ /**
52
+ * Records one entity into the schema map.
53
+ *
54
+ * Shared by the broad sample and the targeted lookup, so a schema's shape is
55
+ * computed the same way whether it was found by scanning or by asking for it.
56
+ */
57
+ function absorbEntity(schemas, entity) {
58
+ const name = schemaOf(entity);
59
+ if (!name)
60
+ return;
61
+ let summary = schemas.get(name);
62
+ if (!summary) {
63
+ summary = {
64
+ name,
65
+ entities: 0,
66
+ owners: [],
67
+ fields: new Map(),
68
+ typeConflicts: new Set(),
69
+ earliestExpiry: Number.POSITIVE_INFINITY,
70
+ };
71
+ schemas.set(name, summary);
72
+ }
73
+ summary.entities += 1;
74
+ if (entity.$owner && !summary.owners.includes(entity.$owner)) {
75
+ if (summary.owners.length < 8)
76
+ summary.owners.push(entity.$owner);
77
+ }
78
+ const expiry = Number(entity.$expiresAt ?? 0);
79
+ if (expiry > 0 && expiry < summary.earliestExpiry)
80
+ summary.earliestExpiry = expiry;
81
+ const declared = (entity.$attributeSchema ?? {});
82
+ const attributes = (entity.$attributes ?? {});
83
+ const footprint = entityFootprint(attributes);
84
+ for (const [field, meta] of Object.entries(declared)) {
85
+ const existing = summary.fields.get(field);
86
+ if (!existing) {
87
+ summary.fields.set(field, {
88
+ type: meta.type,
89
+ indexed: Boolean(meta.indexed),
90
+ maxStringBytes: 0,
91
+ maxEntityBytes: footprint,
92
+ });
93
+ }
94
+ else if (existing.type !== meta.type) {
95
+ summary.typeConflicts.add(field);
96
+ }
97
+ const value = attributes[field];
98
+ const size = attributeSize(value);
99
+ const current = summary.fields.get(field);
100
+ current.maxEntityBytes = Math.max(current.maxEntityBytes, footprint);
101
+ if (meta.type === "str")
102
+ current.maxStringBytes = Math.max(current.maxStringBytes, size);
103
+ }
104
+ }
105
+ export function lintSchema(summary) {
106
+ const findings = [];
107
+ const name = summary.name;
108
+ const bytes = Buffer.byteLength(name, "utf8");
109
+ if (bytes > 32) {
110
+ findings.push({
111
+ severity: "error",
112
+ rule: "name-length",
113
+ message: `name is ${bytes} bytes; the limit is 32`,
114
+ });
115
+ }
116
+ if (!/^[A-Za-z0-9][A-Za-z0-9._/-]*$/.test(name)) {
117
+ findings.push({
118
+ severity: "warn",
119
+ rule: "name-charset",
120
+ message: "name has characters outside letters, digits, dot, slash, dash, underscore",
121
+ });
122
+ }
123
+ if (!/[./]/.test(name)) {
124
+ findings.push({
125
+ severity: "warn",
126
+ rule: "namespace",
127
+ message: "flat name: no namespace, so it collides with anyone else's schema",
128
+ });
129
+ }
130
+ if (!/v\d+(\b|\/|\.|$)/.test(name)) {
131
+ findings.push({
132
+ severity: "warn",
133
+ rule: "versioning",
134
+ message: "no version segment; add /v1 so a format change can coexist",
135
+ });
136
+ }
137
+ if (summary.typeConflicts.size > 0) {
138
+ findings.push({
139
+ severity: "error",
140
+ rule: "type-drift",
141
+ message: `same attribute written with different types: ${[...summary.typeConflicts].join(", ")}`,
142
+ });
143
+ }
144
+ if (summary.fields.size > MAX_ATTRIBUTES) {
145
+ findings.push({
146
+ severity: "error",
147
+ rule: "attribute-count",
148
+ message: `${summary.fields.size} attributes; the limit is ${MAX_ATTRIBUTES}`,
149
+ });
150
+ }
151
+ for (const [field, info] of summary.fields) {
152
+ if (info.type === "str" && info.maxStringBytes > MAX_STRING_BYTES) {
153
+ findings.push({
154
+ severity: "error",
155
+ rule: "string-length",
156
+ message: `${field} holds ${info.maxStringBytes} bytes of text; the limit is ${MAX_STRING_BYTES}`,
157
+ });
158
+ }
159
+ if (info.maxEntityBytes > MAX_ENTITY_BYTES) {
160
+ findings.push({
161
+ severity: "error",
162
+ rule: "entity-size",
163
+ message: `a sampled entity is ${info.maxEntityBytes} bytes; the limit is ${MAX_ENTITY_BYTES}`,
164
+ });
165
+ }
166
+ }
167
+ if (summary.owners.length > 1) {
168
+ findings.push({
169
+ severity: "info",
170
+ rule: "shared-owner",
171
+ message: `${summary.owners.length}+ different signers write this schema`,
172
+ });
173
+ }
174
+ return findings;
175
+ }
176
+ export function worst(findings) {
177
+ if (findings.some((f) => f.severity === "error"))
178
+ return "error";
179
+ if (findings.some((f) => f.severity === "warn"))
180
+ return "warn";
181
+ if (findings.length)
182
+ return "info";
183
+ return null;
184
+ }
185
+ /* ------------------------------------------------------------------ listing */
186
+ export async function runSchemaList(ctx, options) {
187
+ const { schemas, scanned, block } = await sampleSchemas(ctx, options);
188
+ const ordered = [...schemas.values()].sort((a, b) => b.entities - a.entities);
189
+ if (ctx.json) {
190
+ const payload = {
191
+ scanned,
192
+ block,
193
+ note: "counts are from a sample, not a registry",
194
+ schemas: ordered.map((s) => ({
195
+ name: s.name,
196
+ entities: s.entities,
197
+ signers: s.owners.length,
198
+ fields: [...s.fields.entries()].map(([field, info]) => ({
199
+ name: field,
200
+ type: info.type,
201
+ indexed: info.indexed,
202
+ })),
203
+ findings: lintSchema(s),
204
+ })),
205
+ };
206
+ process.stdout.write(`${JSON.stringify(payload, null, 2)}\n`);
207
+ return;
208
+ }
209
+ const rows = ordered.map((s) => {
210
+ const findings = lintSchema(s);
211
+ const level = worst(findings);
212
+ const note = level === "error"
213
+ ? red(`${findings.filter((f) => f.severity === "error").length} error(s)`)
214
+ : level === "warn"
215
+ ? yellow(`${findings.length} warning(s)`)
216
+ : level === "info"
217
+ ? grey("note")
218
+ : green("clean");
219
+ return [
220
+ s.name,
221
+ String(s.owners.length),
222
+ String(s.entities),
223
+ String(s.fields.size),
224
+ note,
225
+ ];
226
+ });
227
+ out(dim(`sampled ${scanned} entities at block ${block.toLocaleString("en-US")}`));
228
+ out(table(["SCHEMA", "SIGNERS", "ENTITIES", "FIELDS", "LINT"], rows));
229
+ out();
230
+ out(dim("counts are sample-based; run `arkiv schema <name>` for the fields and findings behind a row"));
231
+ }
232
+ /* ------------------------------------------------------------------ detail */
233
+ /**
234
+ * Reads one schema directly.
235
+ *
236
+ * `schema` listings come from a broad sample, which can miss a schema that sits
237
+ * deep in the keyspace. Asking for an explicit schema should not depend on a
238
+ * lucky sample, so this queries the schema itself.
239
+ */
240
+ export async function collectSchema(ctx, name, limit = 200) {
241
+ const client = ctx.client();
242
+ const schemas = new Map();
243
+ const pageSize = 100;
244
+ const literal = name.replace(/\\/g, "\\\\").replace(/'/g, "\\'");
245
+ for (let offset = 0; offset < limit; offset += pageSize) {
246
+ const page = await client
247
+ .select({
248
+ $key: true,
249
+ $owner: true,
250
+ $expiresAt: true,
251
+ $attributeSchema: true,
252
+ $attributes: true,
253
+ })
254
+ .where(`arkiv.schema = str('${literal}')`)
255
+ .orderBy(asc("$key"))
256
+ .limit(pageSize)
257
+ .offset(offset)
258
+ .fetch();
259
+ const entities = page.entities;
260
+ for (const entity of entities)
261
+ absorbEntity(schemas, entity);
262
+ if (entities.length < pageSize)
263
+ break;
264
+ }
265
+ return schemas.get(name) ?? null;
266
+ }
267
+ export async function runSchemaDetail(ctx, name) {
268
+ const summary = await collectSchema(ctx, name);
269
+ if (!summary) {
270
+ out(red(`no entities found for schema "${name}" in the sample`));
271
+ out(dim("try `arkiv schema` to list what is actually on this network"));
272
+ process.exitCode = 1;
273
+ return;
274
+ }
275
+ const findings = lintSchema(summary);
276
+ let timing = null;
277
+ try {
278
+ timing = await getTiming(ctx.client());
279
+ }
280
+ catch {
281
+ /* optional */
282
+ }
283
+ if (ctx.json) {
284
+ process.stdout.write(`${JSON.stringify({
285
+ name: summary.name,
286
+ entities: summary.entities,
287
+ signers: summary.owners,
288
+ earliestExpiry: summary.earliestExpiry,
289
+ fields: [...summary.fields.entries()].map(([field, info]) => ({
290
+ name: field,
291
+ type: info.type,
292
+ indexed: info.indexed,
293
+ maxStringBytes: info.maxStringBytes,
294
+ })),
295
+ findings,
296
+ }, null, 2)}\n`);
297
+ return;
298
+ }
299
+ out(`${bold(summary.name)}`);
300
+ out(field("entities", String(summary.entities)));
301
+ out(field("signers", `${summary.owners.length}`));
302
+ if (timing && summary.earliestExpiry !== Number.POSITIVE_INFINITY) {
303
+ out(field("expires", `${formatDate(blockToUnix(timing, summary.earliestExpiry))} ${dim(`(worst case, in ${formatDuration(blockToUnix(timing, summary.earliestExpiry) - Date.now() / 1000)})`)}`));
304
+ }
305
+ out();
306
+ const fields = [...summary.fields.entries()].map(([field, info]) => [
307
+ field,
308
+ info.type,
309
+ info.indexed ? "indexed" : "-",
310
+ info.type === "str" ? `${info.maxStringBytes} B` : "",
311
+ ]);
312
+ out(table(["FIELD", "TYPE", "QUERYABLE", "MAX VALUE"], fields));
313
+ out();
314
+ if (findings.length === 0) {
315
+ out(green("no naming or structure findings"));
316
+ }
317
+ else {
318
+ const mark = { error: red("ERROR"), warn: yellow("WARN "), info: cyan("INFO ") };
319
+ for (const finding of findings) {
320
+ out(`${mark[finding.severity]} ${bold(finding.rule)} ${finding.message}`);
321
+ }
322
+ }
323
+ }
324
+ /* ------------------------------------------------------------------ atlas */
325
+ export function renderAtlas(schemas, meta) {
326
+ const ordered = [...schemas.values()].sort((a, b) => b.entities - a.entities);
327
+ const cards = ordered
328
+ .map((s) => {
329
+ const findings = lintSchema(s);
330
+ const level = worst(findings);
331
+ const badge = level === "error" ? "bad-error" : level === "warn" ? "bad-warn" : level === "info" ? "bad-info" : "bad-ok";
332
+ const label = level === "error" ? "needs fixing" : level === "warn" ? "review" : level === "info" ? "note" : "clean";
333
+ const fields = [...s.fields.entries()]
334
+ .map(([field, info]) => `<li><code>${escapeHtml(field)}</code><span class="type">${escapeHtml(info.type)}</span>${info.indexed ? '<span class="idx">indexed</span>' : ""}</li>`)
335
+ .join("");
336
+ const notes = findings
337
+ .map((f) => `<li class="f ${f.severity}">${escapeHtml(f.message)}</li>`)
338
+ .join("");
339
+ return `<article class="card">
340
+ <header><h2>${escapeHtml(s.name)}</h2><span class="badge ${badge}">${label}</span></header>
341
+ <p class="meta">${s.entities} entities &middot; ${s.owners.length} signer(s) &middot; ${s.fields.size} fields</p>
342
+ <ul class="fields">${fields}</ul>
343
+ ${notes ? `<ul class="findings">${notes}</ul>` : ""}
344
+ </article>`;
345
+ })
346
+ .join("\n");
347
+ return `<!doctype html>
348
+ <html lang="en"><head><meta charset="utf-8">
349
+ <meta name="viewport" content="width=device-width,initial-scale=1">
350
+ <title>Schema Atlas - ${escapeHtml(meta.network)}</title>
351
+ <style>
352
+ :root { color-scheme: dark; --bg:#0b0d10; --panel:#14181d; --line:#252b33; --text:#e8eaed; --muted:#8b939e; --accent:#7fd1b9; --warn:#e5b95c; --error:#e2705f; --info:#7aa8e0; }
353
+ * { box-sizing:border-box; }
354
+ body { margin:0; background:var(--bg); color:var(--text); font:15px/1.5 ui-sans-serif,system-ui,-apple-system,Segoe UI,Roboto,sans-serif; }
355
+ main { max-width:1120px; margin:0 auto; padding:48px 24px 80px; }
356
+ h1 { font-size:34px; margin:0 0 6px; letter-spacing:-.02em; }
357
+ .sub { color:var(--muted); margin:0 0 32px; }
358
+ .grid { display:grid; grid-template-columns:repeat(auto-fill,minmax(320px,1fr)); gap:16px; }
359
+ .card { background:var(--panel); border:1px solid var(--line); border-radius:14px; padding:18px; }
360
+ .card header { display:flex; justify-content:space-between; align-items:flex-start; gap:12px; }
361
+ h2 { font-size:16px; margin:0; word-break:break-word; font-family:ui-monospace,SFMono-Regular,Menlo,monospace; }
362
+ .badge { font-size:11px; text-transform:uppercase; letter-spacing:.08em; padding:3px 8px; border-radius:999px; white-space:nowrap; }
363
+ .bad-ok { background:rgba(127,209,185,.14); color:var(--accent); }
364
+ .bad-warn { background:rgba(229,185,92,.14); color:var(--warn); }
365
+ .bad-error { background:rgba(226,112,95,.16); color:var(--error); }
366
+ .bad-info { background:rgba(122,168,224,.14); color:var(--info); }
367
+ .meta { color:var(--muted); font-size:13px; margin:8px 0 12px; }
368
+ ul { list-style:none; margin:0; padding:0; }
369
+ .fields li { display:flex; gap:8px; align-items:baseline; padding:4px 0; border-bottom:1px dashed var(--line); font-size:13px; }
370
+ .fields li:last-child { border-bottom:0; }
371
+ code { font-family:ui-monospace,SFMono-Regular,Menlo,monospace; font-size:13px; }
372
+ .type { color:var(--muted); }
373
+ .idx { font-size:10px; color:var(--accent); border:1px solid rgba(127,209,185,.4); border-radius:4px; padding:0 4px; }
374
+ .findings { margin-top:12px; border-top:1px solid var(--line); padding-top:10px; }
375
+ .f { font-size:12px; color:var(--muted); padding-left:14px; position:relative; }
376
+ .f::before { content:"-"; position:absolute; left:0; }
377
+ .f.error { color:var(--error); }
378
+ .f.warn { color:var(--warn); }
379
+ footer { color:var(--muted); font-size:12px; margin-top:40px; }
380
+ </style></head>
381
+ <body><main>
382
+ <h1>Schema Atlas</h1>
383
+ <p class="sub">${escapeHtml(meta.network)} &middot; ${ordered.length} schemas in a sample of ${meta.scanned} entities &middot; block ${meta.block.toLocaleString("en-US")}</p>
384
+ <p class="sub">Generated by <code>arkiv schema --atlas</code>. Counts describe a sample, not a registry.</p>
385
+ <div class="grid">
386
+ ${cards}
387
+ </div>
388
+ <footer>Built By Harish Kotra &middot; harishkotra.me</footer>
389
+ </main></body></html>`;
390
+ }
391
+ function escapeHtml(input) {
392
+ return input
393
+ .replace(/&/g, "&amp;")
394
+ .replace(/</g, "&lt;")
395
+ .replace(/>/g, "&gt;")
396
+ .replace(/"/g, "&quot;");
397
+ }
@@ -0,0 +1,16 @@
1
+ /**
2
+ * `arkiv watch` - poll a query and print entities as they appear.
3
+ *
4
+ * The query language exposes $createdAt only as a projection, so there is no
5
+ * block cursor to resume from. Instead the CLI keeps a bounded, deterministically
6
+ * ordered window (ordered by key) and diffs it each tick. If the window fills up
7
+ * the CLI says so, because a full window means new entities could land outside it.
8
+ */
9
+ import type { Context } from "../context.js";
10
+ export interface WatchOptions {
11
+ expression: string;
12
+ limit: number;
13
+ intervalSeconds: number;
14
+ once: boolean;
15
+ }
16
+ export declare function runWatch(ctx: Context, options: WatchOptions): Promise<void>;
@@ -0,0 +1,124 @@
1
+ /**
2
+ * `arkiv watch` - poll a query and print entities as they appear.
3
+ *
4
+ * The query language exposes $createdAt only as a projection, so there is no
5
+ * block cursor to resume from. Instead the CLI keeps a bounded, deterministically
6
+ * ordered window (ordered by key) and diffs it each tick. If the window fills up
7
+ * the CLI says so, because a full window means new entities could land outside it.
8
+ */
9
+ import { asc } from "@arkiv-network/sdk/query";
10
+ import { renderEntities, schemaOf } from "../render.js";
11
+ import { explainError, getTiming, blockToUnix } from "../arkiv.js";
12
+ import { displayValue } from "../values.js";
13
+ import { cyan, dim, green, grey, out, red, sanitize, yellow } from "../ui.js";
14
+ async function snapshot(ctx, expression, limit) {
15
+ const client = ctx.client();
16
+ const collected = [];
17
+ const pageSize = 100;
18
+ for (let offset = 0; offset < limit; offset += pageSize) {
19
+ const take = Math.min(pageSize, limit - offset);
20
+ const page = await client
21
+ .select({
22
+ $key: true,
23
+ $owner: true,
24
+ $expiresAt: true,
25
+ $attributeSchema: true,
26
+ $attributes: true,
27
+ })
28
+ .where(expression)
29
+ .orderBy(asc("$key"))
30
+ .limit(take)
31
+ .offset(offset)
32
+ .fetch();
33
+ const entities = page.entities;
34
+ collected.push(...entities);
35
+ if (entities.length < take)
36
+ break;
37
+ }
38
+ return collected;
39
+ }
40
+ function summarise(entity) {
41
+ const attributes = (entity.$attributes ?? {});
42
+ const schema = schemaOf(entity) ?? "untyped";
43
+ const parts = Object.entries(attributes)
44
+ .filter(([name]) => name !== "arkiv.schema")
45
+ .slice(0, 3)
46
+ .map(([name, value]) => `${grey(name)}=${sanitize(displayValue(value, 48))}`);
47
+ return `${cyan(schema)} ${dim(entity.$key.slice(0, 12))}… ${parts.join(" ")}`;
48
+ }
49
+ export async function runWatch(ctx, options) {
50
+ const limit = Math.max(1, Math.min(options.limit, 1000));
51
+ const interval = Math.max(1, options.intervalSeconds) * 1000;
52
+ let previous = new Map();
53
+ let timing = null;
54
+ try {
55
+ timing = await getTiming(ctx.client());
56
+ }
57
+ catch {
58
+ /* timestamps degrade to block numbers, which is still useful */
59
+ }
60
+ const stamp = (entity) => {
61
+ if (timing && entity.$createdAt) {
62
+ return formatClock(blockToUnix(timing, Number(entity.$createdAt)));
63
+ }
64
+ return entity.$createdAt ? `block ${Number(entity.$createdAt)}` : "now";
65
+ };
66
+ const first = await snapshot(ctx, options.expression, limit);
67
+ for (const entity of first)
68
+ previous.set(entity.$key, entity);
69
+ if (options.once) {
70
+ out(renderEntities(first, timing, {}));
71
+ out(dim(`${first.length} matching ${first.length === 1 ? "entity" : "entities"} at ${new Date().toISOString()}`));
72
+ return;
73
+ }
74
+ out(dim(`${first.length} existing matching ${first.length === 1 ? "entity" : "entities"}; ` +
75
+ `watching every ${interval / 1000}s (ctrl-c to stop)`));
76
+ if (first.length >= limit) {
77
+ out(yellow(`the window is full at ${limit} rows, so new entities outside the key window can be missed; raise --limit`));
78
+ }
79
+ let stopping = false;
80
+ const stop = () => {
81
+ stopping = true;
82
+ out(dim("\nstopped"));
83
+ };
84
+ process.once("SIGINT", stop);
85
+ process.once("SIGTERM", stop);
86
+ while (!stopping) {
87
+ await sleep(interval);
88
+ if (stopping)
89
+ break;
90
+ let current;
91
+ try {
92
+ current = await snapshot(ctx, options.expression, limit);
93
+ }
94
+ catch (error) {
95
+ out(red(`poll failed: ${explainError(error)}`));
96
+ continue;
97
+ }
98
+ const next = new Map(current.map((entity) => [entity.$key, entity]));
99
+ let changes = 0;
100
+ for (const [key, entity] of next) {
101
+ if (previous.has(key))
102
+ continue;
103
+ changes += 1;
104
+ out(`${green("+")} ${grey(stamp(entity))} ${summarise(entity)}`);
105
+ }
106
+ for (const [key, entity] of previous) {
107
+ if (next.has(key))
108
+ continue;
109
+ changes += 1;
110
+ out(`${red("-")} ${grey(stamp(entity))} ${summarise(entity)}`);
111
+ }
112
+ previous = next;
113
+ if (ctx.verbose && changes === 0) {
114
+ out(dim(`no changes at ${new Date().toISOString()}`));
115
+ }
116
+ }
117
+ }
118
+ function sleep(ms) {
119
+ return new Promise((resolve) => setTimeout(resolve, ms));
120
+ }
121
+ function formatClock(unix) {
122
+ const date = new Date(unix * 1000);
123
+ return `${String(date.getHours()).padStart(2, "0")}:${String(date.getMinutes()).padStart(2, "0")}:${String(date.getSeconds()).padStart(2, "0")}`;
124
+ }
@@ -0,0 +1,25 @@
1
+ /**
2
+ * Shared command context: one environment, one client, no globals.
3
+ */
4
+ import { type EnvFile } from "./env.js";
5
+ import { type ArkivPublic } from "./arkiv.js";
6
+ export interface GlobalFlags {
7
+ json: boolean;
8
+ colour: boolean;
9
+ network?: string;
10
+ rpc?: string;
11
+ env?: string;
12
+ verbose: boolean;
13
+ }
14
+ export interface Context {
15
+ cwd: string;
16
+ envFile: EnvFile;
17
+ accessKey: string;
18
+ privateKey: string;
19
+ network: string;
20
+ rpcUrl?: string;
21
+ json: boolean;
22
+ verbose: boolean;
23
+ client: () => ArkivPublic;
24
+ }
25
+ export declare function buildContext(flags: GlobalFlags, cwd?: string): Context;
@@ -0,0 +1,31 @@
1
+ /**
2
+ * Shared command context: one environment, one client, no globals.
3
+ */
4
+ import path from "node:path";
5
+ import { readEnvFile, readSecret } from "./env.js";
6
+ import { makeClient } from "./arkiv.js";
7
+ export function buildContext(flags, cwd = process.cwd()) {
8
+ const envPath = flags.env ? path.resolve(cwd, flags.env) : path.join(cwd, ".env");
9
+ const envFile = readEnvFile(envPath);
10
+ const accessKey = readSecret("ARKIV_ACCESS_KEY", envFile);
11
+ /* ARKIV_PRIVATE_KEY is the documented name; PRIVATE_KEY is kept as an alias */
12
+ const privateKey = readSecret("ARKIV_PRIVATE_KEY", envFile) ?? readSecret("PRIVATE_KEY", envFile);
13
+ const network = flags.network ?? process.env.ARKIV_NETWORK ?? "rabdi";
14
+ const rpcUrl = flags.rpc ?? process.env.ARKIV_RPC_URL ?? undefined;
15
+ let cached = null;
16
+ return {
17
+ cwd,
18
+ envFile,
19
+ accessKey,
20
+ privateKey,
21
+ network,
22
+ rpcUrl,
23
+ json: Boolean(flags.json),
24
+ verbose: Boolean(flags.verbose),
25
+ client: () => {
26
+ if (!cached)
27
+ cached = makeClient({ network, accessKey, rpcUrl });
28
+ return cached;
29
+ },
30
+ };
31
+ }
package/dist/env.d.ts ADDED
@@ -0,0 +1,51 @@
1
+ /**
2
+ * Environment handling for the CLI.
3
+ *
4
+ * The rule this module enforces: credentials are read, validated and passed to
5
+ * the SDK, but never returned to a caller that prints them. Values are
6
+ * registered with the redactor as soon as they are read.
7
+ */
8
+ export interface EnvFile {
9
+ path: string;
10
+ exists: boolean;
11
+ values: Record<string, string>;
12
+ /** True when group or world bits are set on the file. */
13
+ tooOpen: boolean;
14
+ mode: number | null;
15
+ }
16
+ /** Minimal dotenv parser. No expansion, no shell evaluation, no surprises. */
17
+ export declare function parseDotenv(text: string): Record<string, string>;
18
+ export declare function readEnvFile(file: string): EnvFile;
19
+ /**
20
+ * Lookup order: a real environment variable wins, then .env in the working
21
+ * directory. This matches how the SDK and most deployment platforms behave.
22
+ */
23
+ export declare function readSecret(name: string, env: EnvFile): string;
24
+ export type KeyCheck = {
25
+ ok: true;
26
+ normalized: `0x${string}`;
27
+ } | {
28
+ ok: false;
29
+ reason: string;
30
+ };
31
+ /** Validate a secp256k1 private key without ever echoing it. */
32
+ export declare function validatePrivateKey(raw: string): KeyCheck;
33
+ export declare function validateAccessKey(raw: string): {
34
+ ok: boolean;
35
+ reason?: string;
36
+ };
37
+ /**
38
+ * Credentials travel in a request header. Sending them over plain http to a
39
+ * remote host would leak them to anyone on the path, so only https (or a
40
+ * loopback address) is accepted.
41
+ */
42
+ export declare function assertSafeRpcUrl(raw: string): {
43
+ ok: true;
44
+ url: string;
45
+ } | {
46
+ ok: false;
47
+ reason: string;
48
+ };
49
+ /** True when .env appears to be covered by the repository's .gitignore. */
50
+ export declare function envIsGitIgnored(dir: string): boolean | null;
51
+ export declare function tightenPermissions(file: string): boolean;