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.
package/dist/env.js ADDED
@@ -0,0 +1,136 @@
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
+ import { existsSync, readFileSync, statSync, chmodSync } from "node:fs";
9
+ import path from "node:path";
10
+ import { registerSecret } from "./ui.js";
11
+ /** Minimal dotenv parser. No expansion, no shell evaluation, no surprises. */
12
+ export function parseDotenv(text) {
13
+ const values = {};
14
+ for (const rawLine of text.split(/\r?\n/)) {
15
+ const line = rawLine.trim();
16
+ if (!line || line.startsWith("#"))
17
+ continue;
18
+ const eq = line.indexOf("=");
19
+ if (eq <= 0)
20
+ continue;
21
+ let key = line.slice(0, eq).trim();
22
+ if (key.startsWith("export "))
23
+ key = key.slice(7).trim();
24
+ if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(key))
25
+ continue;
26
+ let value = line.slice(eq + 1).trim();
27
+ if ((value.startsWith('"') && value.endsWith('"') && value.length > 1) ||
28
+ (value.startsWith("'") && value.endsWith("'") && value.length > 1)) {
29
+ value = value.slice(1, -1);
30
+ }
31
+ else {
32
+ const hash = value.indexOf(" #");
33
+ if (hash >= 0)
34
+ value = value.slice(0, hash).trim();
35
+ }
36
+ values[key] = value;
37
+ registerSecret(values[key]);
38
+ }
39
+ return values;
40
+ }
41
+ export function readEnvFile(file) {
42
+ if (!existsSync(file)) {
43
+ return { path: file, exists: false, values: {}, tooOpen: false, mode: null };
44
+ }
45
+ const mode = statSync(file).mode & 0o777;
46
+ const values = parseDotenv(readFileSync(file, "utf8"));
47
+ return { path: file, exists: true, values, tooOpen: (mode & 0o077) !== 0, mode };
48
+ }
49
+ /**
50
+ * Lookup order: a real environment variable wins, then .env in the working
51
+ * directory. This matches how the SDK and most deployment platforms behave.
52
+ */
53
+ export function readSecret(name, env) {
54
+ const fromProcess = process.env[name];
55
+ const value = (fromProcess && fromProcess.trim()) || env.values[name] || "";
56
+ registerSecret(value);
57
+ return value.trim();
58
+ }
59
+ /** Validate a secp256k1 private key without ever echoing it. */
60
+ export function validatePrivateKey(raw) {
61
+ if (!raw)
62
+ return { ok: false, reason: "ARKIV_PRIVATE_KEY is not set" };
63
+ const body = raw.startsWith("0x") ? raw.slice(2) : raw;
64
+ if (!/^[0-9a-fA-F]{64}$/.test(body)) {
65
+ return {
66
+ ok: false,
67
+ reason: "ARKIV_PRIVATE_KEY must be 64 hexadecimal characters (with or without 0x)",
68
+ };
69
+ }
70
+ if (/^0+$/.test(body))
71
+ return { ok: false, reason: "ARKIV_PRIVATE_KEY is all zeros" };
72
+ return { ok: true, normalized: `0x${body}` };
73
+ }
74
+ export function validateAccessKey(raw) {
75
+ if (!raw)
76
+ return { ok: false, reason: "ARKIV_ACCESS_KEY is not set" };
77
+ if (raw.length < 8)
78
+ return { ok: false, reason: "ARKIV_ACCESS_KEY looks too short" };
79
+ if (/\s/.test(raw))
80
+ return { ok: false, reason: "ARKIV_ACCESS_KEY contains whitespace" };
81
+ if (!/^[\x20-\x7e]+$/.test(raw)) {
82
+ return { ok: false, reason: "ARKIV_ACCESS_KEY contains non-printable characters" };
83
+ }
84
+ return { ok: true };
85
+ }
86
+ /**
87
+ * Credentials travel in a request header. Sending them over plain http to a
88
+ * remote host would leak them to anyone on the path, so only https (or a
89
+ * loopback address) is accepted.
90
+ */
91
+ export function assertSafeRpcUrl(raw) {
92
+ let url;
93
+ try {
94
+ url = new URL(raw);
95
+ }
96
+ catch {
97
+ return { ok: false, reason: "RPC URL is not a valid absolute URL" };
98
+ }
99
+ const loopback = ["localhost", "127.0.0.1", "::1", "[::1]"].includes(url.hostname);
100
+ if (url.protocol === "https:")
101
+ return { ok: true, url: url.toString() };
102
+ if (url.protocol === "http:" && loopback)
103
+ return { ok: true, url: url.toString() };
104
+ return {
105
+ ok: false,
106
+ reason: `refusing to send credentials over ${url.protocol} to ${url.hostname}; use https or a loopback host`,
107
+ };
108
+ }
109
+ /** True when .env appears to be covered by the repository's .gitignore. */
110
+ export function envIsGitIgnored(dir) {
111
+ const gitignore = path.join(dir, ".gitignore");
112
+ if (!existsSync(gitignore))
113
+ return null;
114
+ const lines = readFileSync(gitignore, "utf8")
115
+ .split(/\r?\n/)
116
+ .map((l) => l.trim())
117
+ .filter((l) => l && !l.startsWith("#"));
118
+ return lines.some((pattern) => {
119
+ const p = pattern.replace(/^!/, "").replace(/^\//, "").replace(/\/$/, "");
120
+ return (p === ".env" ||
121
+ p === ".env*" ||
122
+ p === "*.env" ||
123
+ p === ".env.*" ||
124
+ p === "/.env" ||
125
+ p.endsWith("/.env"));
126
+ });
127
+ }
128
+ export function tightenPermissions(file) {
129
+ try {
130
+ chmodSync(file, 0o600);
131
+ return true;
132
+ }
133
+ catch {
134
+ return false;
135
+ }
136
+ }
@@ -0,0 +1,19 @@
1
+ /**
2
+ * Library surface, so the same helpers are usable from a script:
3
+ *
4
+ * import { makeClient, runQuery } from "arkiv-cli";
5
+ *
6
+ * The CLI is the product; this module exists so people can script against the
7
+ * exact same connection and redaction behaviour instead of re-deriving it.
8
+ */
9
+ export { main } from "./cli.js";
10
+ export { buildContext, type Context, type GlobalFlags } from "./context.js";
11
+ export { makeClient, makeWallet, getTiming, blockToUnix, unixToBlock, explainError, networkNames, NETWORKS, type Timing, type ArkivPublic } from "./arkiv.js";
12
+ export { parseArgs, UsageError, type Parsed, type FlagSpec, type FlagValue } from "./args.js";
13
+ export { readEnvFile, parseDotenv, type EnvFile } from "./env.js";
14
+ export { sanitize, redact, registerSecret, table, formatBytes, formatDuration, formatDate, shortKey, shortAddress } from "./ui.js";
15
+ export { MAX_ATTRIBUTES, MAX_ENTITY_BYTES, MAX_STRING_BYTES, MAX_NAME_BYTES, MAX_LIFETIME, MAX_BYTES_LENGTH, BLOCK_SECONDS } from "./limits.js";
16
+ export { runQuery, type QueryOptions } from "./commands/query.js";
17
+ export { sampleSchemas, lintSchema, type SchemaSummary, type Finding } from "./commands/schema.js";
18
+ export { runDoctor } from "./commands/doctor.js";
19
+ export { runCost, parseLifetime, type CostOptions } from "./commands/cost.js";
package/dist/index.js ADDED
@@ -0,0 +1,19 @@
1
+ /**
2
+ * Library surface, so the same helpers are usable from a script:
3
+ *
4
+ * import { makeClient, runQuery } from "arkiv-cli";
5
+ *
6
+ * The CLI is the product; this module exists so people can script against the
7
+ * exact same connection and redaction behaviour instead of re-deriving it.
8
+ */
9
+ export { main } from "./cli.js";
10
+ export { buildContext } from "./context.js";
11
+ export { makeClient, makeWallet, getTiming, blockToUnix, unixToBlock, explainError, networkNames, NETWORKS } from "./arkiv.js";
12
+ export { parseArgs, UsageError } from "./args.js";
13
+ export { readEnvFile, parseDotenv } from "./env.js";
14
+ export { sanitize, redact, registerSecret, table, formatBytes, formatDuration, formatDate, shortKey, shortAddress } from "./ui.js";
15
+ export { MAX_ATTRIBUTES, MAX_ENTITY_BYTES, MAX_STRING_BYTES, MAX_NAME_BYTES, MAX_LIFETIME, MAX_BYTES_LENGTH, BLOCK_SECONDS } from "./limits.js";
16
+ export { runQuery } from "./commands/query.js";
17
+ export { sampleSchemas, lintSchema } from "./commands/schema.js";
18
+ export { runDoctor } from "./commands/doctor.js";
19
+ export { runCost, parseLifetime } from "./commands/cost.js";
@@ -0,0 +1,21 @@
1
+ /**
2
+ * Protocol limits, taken from the SDK so the CLI cannot drift from the chain.
3
+ * The fallbacks are the values the SDK itself ships; if a future SDK renames an
4
+ * export, the CLI still reports the correct documented numbers.
5
+ */
6
+ export declare const MAX_ATTRIBUTES: number;
7
+ export declare const MAX_ENTITY_BYTES: number;
8
+ export declare const MAX_STRING_BYTES: number;
9
+ export declare const MAX_NAME_BYTES: number;
10
+ export declare const MAX_LIFETIME: number;
11
+ export declare const MAX_BYTES_LENGTH: number;
12
+ export declare const MAX_EXPIRES_AT: number;
13
+ export declare const MAX_LIMIT = 100;
14
+ export declare const BLOCK_SECONDS = 2;
15
+ export interface LimitRow {
16
+ name: string;
17
+ value: number;
18
+ unit: string;
19
+ note: string;
20
+ }
21
+ export declare const LIMIT_ROWS: LimitRow[];
package/dist/limits.js ADDED
@@ -0,0 +1,28 @@
1
+ /**
2
+ * Protocol limits, taken from the SDK so the CLI cannot drift from the chain.
3
+ * The fallbacks are the values the SDK itself ships; if a future SDK renames an
4
+ * export, the CLI still reports the correct documented numbers.
5
+ */
6
+ import { MAX_ATTRIBUTES as SDK_MAX_ATTRIBUTES, MAX_BYTES_LENGTH as SDK_MAX_BYTES_LENGTH, MAX_ENTITY_BYTES as SDK_MAX_ENTITY_BYTES, MAX_EXPIRES_AT as SDK_MAX_EXPIRES_AT, MAX_LIFETIME as SDK_MAX_LIFETIME, MAX_NAME_BYTES as SDK_MAX_NAME_BYTES, MAX_STRING_BYTES as SDK_MAX_STRING_BYTES, } from "@arkiv-network/sdk";
7
+ function limit(value, fallback) {
8
+ const n = Number(value);
9
+ return Number.isFinite(n) && n > 0 ? n : fallback;
10
+ }
11
+ export const MAX_ATTRIBUTES = limit(SDK_MAX_ATTRIBUTES, 32);
12
+ export const MAX_ENTITY_BYTES = limit(SDK_MAX_ENTITY_BYTES, 131072);
13
+ export const MAX_STRING_BYTES = limit(SDK_MAX_STRING_BYTES, 128);
14
+ export const MAX_NAME_BYTES = limit(SDK_MAX_NAME_BYTES, 32);
15
+ export const MAX_LIFETIME = limit(SDK_MAX_LIFETIME, 7_884_000);
16
+ export const MAX_BYTES_LENGTH = limit(SDK_MAX_BYTES_LENGTH, 131072);
17
+ export const MAX_EXPIRES_AT = Number(SDK_MAX_EXPIRES_AT ?? 2n ** 64n - 1n);
18
+ export const MAX_LIMIT = 100;
19
+ export const BLOCK_SECONDS = 2;
20
+ export const LIMIT_ROWS = [
21
+ { name: "attributes per entity", value: MAX_ATTRIBUTES, unit: "count", note: "including arkiv.schema" },
22
+ { name: "attribute name", value: MAX_NAME_BYTES, unit: "bytes", note: "UTF-8" },
23
+ { name: "string attribute", value: MAX_STRING_BYTES, unit: "bytes", note: "longer text goes in bytes" },
24
+ { name: "bytes attribute", value: MAX_BYTES_LENGTH, unit: "bytes", note: "the binary payload ceiling" },
25
+ { name: "entity payload", value: MAX_ENTITY_BYTES, unit: "bytes", note: "all attributes together" },
26
+ { name: "entity lifetime", value: MAX_LIFETIME, unit: "blocks", note: `${MAX_LIFETIME * BLOCK_SECONDS} seconds at ${BLOCK_SECONDS}s blocks` },
27
+ { name: "query page", value: MAX_LIMIT, unit: "rows", note: "per request" },
28
+ ];
@@ -0,0 +1,26 @@
1
+ /**
2
+ * Shared rendering for entity lists (query and watch).
3
+ */
4
+ import { type Timing } from "./arkiv.js";
5
+ import { type AttributeValue } from "./values.js";
6
+ export interface Entity {
7
+ $key: string;
8
+ $owner?: string;
9
+ $createdAt?: bigint | number;
10
+ $expiresAt?: bigint | number;
11
+ $attributes?: Record<string, AttributeValue>;
12
+ [key: string]: unknown;
13
+ }
14
+ export declare function attributesOf(entity: Entity): Record<string, AttributeValue>;
15
+ export declare function schemaOf(entity: Entity): string;
16
+ export declare function num(value: bigint | number | undefined): number;
17
+ export declare function expiresIn(entity: Entity, timing: Timing | null, now?: number): number;
18
+ export declare function entityRows(entities: Entity[], timing: Timing | null, options?: {
19
+ showKey?: boolean;
20
+ maxAttrs?: number;
21
+ }): string[][];
22
+ export declare function renderEntities(entities: Entity[], timing: Timing | null, options?: {
23
+ showKey?: boolean;
24
+ maxAttrs?: number;
25
+ }): string;
26
+ export declare function entityDetail(entity: Entity, timing: Timing | null): string;
package/dist/render.js ADDED
@@ -0,0 +1,77 @@
1
+ /**
2
+ * Shared rendering for entity lists (query and watch).
3
+ */
4
+ import { blockToUnix } from "./arkiv.js";
5
+ import { displayValue } from "./values.js";
6
+ import { dim, formatDuration, formatDate, shortAddress, shortKey, table, truncate } from "./ui.js";
7
+ export function attributesOf(entity) {
8
+ const raw = (entity.$attributes ?? {});
9
+ return raw;
10
+ }
11
+ export function schemaOf(entity) {
12
+ const attr = attributesOf(entity)["arkiv.schema"];
13
+ if (attr)
14
+ return String(attr.value);
15
+ const direct = entity["arkiv.schema"];
16
+ return direct ? String(direct.value) : "";
17
+ }
18
+ export function num(value) {
19
+ if (typeof value === "bigint")
20
+ return Number(value);
21
+ return typeof value === "number" ? value : 0;
22
+ }
23
+ export function expiresIn(entity, timing, now = Date.now() / 1000) {
24
+ const expiresAt = num(entity.$expiresAt);
25
+ if (!expiresAt)
26
+ return Number.POSITIVE_INFINITY;
27
+ if (!timing)
28
+ return Number.POSITIVE_INFINITY;
29
+ return blockToUnix(timing, expiresAt) - now;
30
+ }
31
+ export function entityRows(entities, timing, options = {}) {
32
+ const now = Date.now() / 1000;
33
+ return entities.map((entity) => {
34
+ const attrs = attributesOf(entity);
35
+ const parts = Object.entries(attrs)
36
+ .filter(([name]) => name !== "arkiv.schema")
37
+ .slice(0, options.maxAttrs ?? 4)
38
+ .map(([name, value]) => `${name}=${displayValue(value, 20)}`);
39
+ const more = Object.keys(attrs).length > (options.maxAttrs ?? 4) + 1 ? " +" : "";
40
+ return [
41
+ options.showKey ? entity.$key : shortKey(entity.$key),
42
+ formatDuration(expiresIn(entity, timing, now)),
43
+ truncate(schemaOf(entity) || "-", 28),
44
+ parts.join(" ") + more || dim("-"),
45
+ ];
46
+ });
47
+ }
48
+ export function renderEntities(entities, timing, options = {}) {
49
+ if (entities.length === 0)
50
+ return dim("no entities matched");
51
+ return table(["KEY", "EXPIRES", "SCHEMA", "ATTRIBUTES"], entityRows(entities, timing, options), {
52
+ maxColumnWidth: 64,
53
+ });
54
+ }
55
+ export function entityDetail(entity, timing) {
56
+ const attrs = attributesOf(entity);
57
+ const lines = [];
58
+ lines.push(`${dim("key")} ${entity.$key}`);
59
+ if (entity.$owner)
60
+ lines.push(`${dim("owner")} ${entity.$owner} ${dim(`(${shortAddress(entity.$owner)})`)}`);
61
+ const created = num(entity.$createdAt);
62
+ const expires = num(entity.$expiresAt);
63
+ if (created && timing)
64
+ lines.push(`${dim("created")} ${formatDate(blockToUnix(timing, created))} ${dim(`block ${created}`)}`);
65
+ if (expires && timing) {
66
+ const unix = blockToUnix(timing, expires);
67
+ lines.push(`${dim("expires")} ${formatDate(unix)} ${dim(`block ${expires}, in ${formatDuration(unix - Date.now() / 1000)}`)}`);
68
+ }
69
+ lines.push("");
70
+ const width = Math.max(4, ...Object.keys(attrs).map((k) => k.length));
71
+ for (const [name, value] of Object.entries(attrs)) {
72
+ lines.push(`${dim(name.padEnd(width))} ${dim(value.type.padEnd(6))} ${displayValue(value, 120)}`);
73
+ }
74
+ if (Object.keys(attrs).length === 0)
75
+ lines.push(dim("no attributes"));
76
+ return lines.join("\n");
77
+ }
package/dist/ui.d.ts ADDED
@@ -0,0 +1,41 @@
1
+ /**
2
+ * Terminal output helpers: colour, tables, redaction and sanitisation.
3
+ *
4
+ * Two rules live here and apply to every command:
5
+ * 1. Nothing that looks like a credential ever reaches stdout or stderr.
6
+ * 2. Nothing read from the chain is printed without stripping control
7
+ * sequences first, because attribute values are arbitrary bytes and a
8
+ * malicious entity could otherwise rewrite the user's terminal.
9
+ */
10
+ export declare function setColour(on: boolean): void;
11
+ export declare const bold: (s: string) => string;
12
+ export declare const dim: (s: string) => string;
13
+ export declare const red: (s: string) => string;
14
+ export declare const green: (s: string) => string;
15
+ export declare const yellow: (s: string) => string;
16
+ export declare const blue: (s: string) => string;
17
+ export declare const magenta: (s: string) => string;
18
+ export declare const cyan: (s: string) => string;
19
+ export declare const grey: (s: string) => string;
20
+ /** Register a value that must never appear in output. Empty strings ignored. */
21
+ export declare function registerSecret(value: string | undefined | null): void;
22
+ /** Remove credentials and key-shaped strings from arbitrary text. */
23
+ export declare function redact(input: unknown): string;
24
+ /** Strip ANSI escapes and control characters from untrusted text. */
25
+ export declare function sanitize(input: unknown): string;
26
+ export declare function shortKey(key: string): string;
27
+ export declare function shortAddress(address: string): string;
28
+ export declare function formatBytes(n: number): string;
29
+ export declare function formatDuration(totalSeconds: number): string;
30
+ export declare function formatDate(unixSeconds: number): string;
31
+ export declare function truncate(s: string, max: number): string;
32
+ export interface TableOptions {
33
+ maxColumnWidth?: number;
34
+ align?: ("left" | "right")[];
35
+ /** Rows that should be dimmed, by index. */
36
+ dim?: number[];
37
+ }
38
+ export declare function table(headers: string[], rows: string[][], options?: TableOptions): string;
39
+ export declare function out(line?: string): void;
40
+ export declare function err(line: string): void;
41
+ export declare function field(label: string, value: string): string;
package/dist/ui.js ADDED
@@ -0,0 +1,144 @@
1
+ /**
2
+ * Terminal output helpers: colour, tables, redaction and sanitisation.
3
+ *
4
+ * Two rules live here and apply to every command:
5
+ * 1. Nothing that looks like a credential ever reaches stdout or stderr.
6
+ * 2. Nothing read from the chain is printed without stripping control
7
+ * sequences first, because attribute values are arbitrary bytes and a
8
+ * malicious entity could otherwise rewrite the user's terminal.
9
+ */
10
+ let colourEnabled = Boolean(process.stdout.isTTY) && !process.env.NO_COLOR;
11
+ export function setColour(on) {
12
+ colourEnabled = on && !process.env.NO_COLOR;
13
+ }
14
+ const wrap = (open, close = 0) => (s) => colourEnabled ? `\u001b[${open}m${s}\u001b[${close}m` : s;
15
+ export const bold = wrap(1);
16
+ export const dim = wrap(2);
17
+ export const red = wrap(31);
18
+ export const green = wrap(32);
19
+ export const yellow = wrap(33);
20
+ export const blue = wrap(34);
21
+ export const magenta = wrap(35);
22
+ export const cyan = wrap(36);
23
+ export const grey = wrap(90);
24
+ /* ------------------------------------------------------------------ secrets */
25
+ const secrets = new Set();
26
+ /** Register a value that must never appear in output. Empty strings ignored. */
27
+ export function registerSecret(value) {
28
+ if (!value)
29
+ return;
30
+ const v = value.trim();
31
+ if (v.length >= 6)
32
+ secrets.add(v);
33
+ }
34
+ const KEY_RE = /\b0x[0-9a-fA-F]{64}\b/g;
35
+ /*
36
+ * A 64-hex string is only secret when it is presented as one. Entity keys and
37
+ * other public identifiers are also 64 hex characters, and redacting them would
38
+ * break the thing the CLI exists to show, so the pattern only fires on lines
39
+ * that talk about key material.
40
+ */
41
+ const KEY_CONTEXT_RE = /(private[\s_-]?key|secret|mnemonic|seed phrase|passphrase|api[\s_-]?key|access[\s_-]?key)/i;
42
+ const ASSIGN_RE = /((?:private[_-]?key|access[_-]?key|secret|token|password|passwd|api[_-]?key)\s*[:=]\s*["']?)([^\s"',;]{6,})/gi;
43
+ /** Remove credentials and key-shaped strings from arbitrary text. */
44
+ export function redact(input) {
45
+ let s = typeof input === "string" ? input : String(input ?? "");
46
+ for (const secret of secrets) {
47
+ if (secret)
48
+ s = s.split(secret).join("[REDACTED]");
49
+ }
50
+ s = s
51
+ .split("\n")
52
+ .map((line) => (KEY_CONTEXT_RE.test(line) ? line.replace(KEY_RE, "0x[REDACTED]") : line))
53
+ .join("\n");
54
+ s = s.replace(ASSIGN_RE, (_m, p1) => `${p1}[REDACTED]`);
55
+ return s;
56
+ }
57
+ /* ------------------------------------------------------------------ sanitise */
58
+ const CSI_RE = /\u001b\[[0-9;?]*[ -/]*[@-~]/g;
59
+ const CTRL_RE = /[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f\u0080-\u009f]/g;
60
+ /** Strip ANSI escapes and control characters from untrusted text. */
61
+ export function sanitize(input) {
62
+ const s = typeof input === "string" ? input : String(input ?? "");
63
+ return s.replace(CSI_RE, "").replace(CTRL_RE, "\u00b7");
64
+ }
65
+ /* ------------------------------------------------------------------ formats */
66
+ export function shortKey(key) {
67
+ if (!key || key.length < 14)
68
+ return key;
69
+ return `${key.slice(0, 8)}\u2026${key.slice(-6)}`;
70
+ }
71
+ export function shortAddress(address) {
72
+ if (!address || address.length < 12)
73
+ return address;
74
+ return `${address.slice(0, 6)}\u2026${address.slice(-4)}`;
75
+ }
76
+ export function formatBytes(n) {
77
+ if (!Number.isFinite(n) || n < 0)
78
+ return "?";
79
+ if (n < 1024)
80
+ return `${n} B`;
81
+ if (n < 1024 * 1024)
82
+ return `${(n / 1024).toFixed(1)} KB`;
83
+ return `${(n / (1024 * 1024)).toFixed(1)} MB`;
84
+ }
85
+ export function formatDuration(totalSeconds) {
86
+ const s = Math.round(totalSeconds);
87
+ if (!Number.isFinite(s))
88
+ return "?";
89
+ if (s <= 0)
90
+ return "expired";
91
+ const d = Math.floor(s / 86_400);
92
+ const h = Math.floor((s % 86_400) / 3600);
93
+ const m = Math.floor((s % 3600) / 60);
94
+ const sec = s % 60;
95
+ if (d > 0)
96
+ return h > 0 ? `${d}d ${h}h` : `${d}d`;
97
+ if (h > 0)
98
+ return m > 0 ? `${h}h ${m}m` : `${h}h`;
99
+ if (m > 0)
100
+ return `${m}m`;
101
+ return `${sec}s`;
102
+ }
103
+ export function formatDate(unixSeconds) {
104
+ if (!Number.isFinite(unixSeconds) || unixSeconds <= 0)
105
+ return "?";
106
+ return new Date(unixSeconds * 1000).toISOString().replace(".000Z", "Z");
107
+ }
108
+ export function truncate(s, max) {
109
+ if (s.length <= max)
110
+ return s;
111
+ if (max <= 1)
112
+ return s.slice(0, max);
113
+ return `${s.slice(0, max - 1)}\u2026`;
114
+ }
115
+ function width(s) {
116
+ // Colour codes are not counted; the only codes we emit wrap whole cells.
117
+ return s.replace(/\u001b\[[0-9;]*m/g, "").length;
118
+ }
119
+ export function table(headers, rows, options = {}) {
120
+ const max = options.maxColumnWidth ?? 48;
121
+ const clean = rows.map((r) => headers.map((_, i) => sanitize(r[i] ?? "").replace(/\s+/g, " ").trim()));
122
+ const cells = clean.map((r) => r.map((c) => truncate(c, max)));
123
+ const widths = headers.map((h, i) => Math.max(width(h), ...cells.map((r) => width(r[i] ?? "")), 3));
124
+ const pad = (s, i) => {
125
+ const gap = " ".repeat(Math.max(0, widths[i] - width(s)));
126
+ return (options.align?.[i] ?? "left") === "right" ? gap + s : s + gap;
127
+ };
128
+ const head = headers.map((h, i) => bold(pad(h, i))).join(" ");
129
+ const body = cells.map((r, ri) => {
130
+ const line = r.map((c, i) => pad(c, i)).join(" ");
131
+ return (options.dim ?? []).includes(ri) ? dim(line) : line;
132
+ });
133
+ return [head, ...body].join("\n");
134
+ }
135
+ /* ------------------------------------------------------------------ writing */
136
+ export function out(line = "") {
137
+ process.stdout.write(`${redact(line)}\n`);
138
+ }
139
+ export function err(line) {
140
+ process.stderr.write(`${redact(line)}\n`);
141
+ }
142
+ export function field(label, value) {
143
+ return `${bold(label.padEnd(12))} ${value}`;
144
+ }
@@ -0,0 +1,26 @@
1
+ /**
2
+ * Reading and writing Arkiv attribute values.
3
+ *
4
+ * Values arriving from the chain are untrusted: they can contain control
5
+ * characters, very long strings, or bytes that are not UTF-8. Everything is
6
+ * sanitised and truncated before display.
7
+ */
8
+ export interface AttributeValue {
9
+ type: string;
10
+ value: unknown;
11
+ }
12
+ export declare function safeText(input: unknown): string;
13
+ export declare function bytesToText(hex: string): string | null;
14
+ export declare function previewBytes(hex: string, max?: number): string;
15
+ /** Human readable one-line rendering of an attribute. */
16
+ export declare function displayValue(attribute: AttributeValue | undefined, max?: number): string;
17
+ export declare function attributeSize(attribute: AttributeValue | undefined): number;
18
+ /** Total payload size of an entity's attributes, used by doctor and create. */
19
+ export declare function entityFootprint(attributes: Record<string, AttributeValue>): number;
20
+ export type ValueType = "str" | "u64" | "i32" | "u256" | "bool" | "bytes" | "addr" | "key" | "dec";
21
+ export declare const VALUE_TYPES: ValueType[];
22
+ /**
23
+ * Convert a JSON value from a create file into a typed SDK value. Types are
24
+ * explicit because the chain does not coerce between them.
25
+ */
26
+ export declare function typedValue(type: ValueType, raw: unknown): unknown;
package/dist/values.js ADDED
@@ -0,0 +1,132 @@
1
+ /**
2
+ * Reading and writing Arkiv attribute values.
3
+ *
4
+ * Values arriving from the chain are untrusted: they can contain control
5
+ * characters, very long strings, or bytes that are not UTF-8. Everything is
6
+ * sanitised and truncated before display.
7
+ */
8
+ import { addr, bool as boolValue, bytes as bytesValue, dec, i32, key as keyValue, str, u64, u256 } from "@arkiv-network/sdk";
9
+ import { sanitize, truncate } from "./ui.js";
10
+ export function safeText(input) {
11
+ return sanitize(input);
12
+ }
13
+ const PRINTABLE = /^[\x09\x0a\x0d\x20-\x7e\u00a0-\uffff]*$/;
14
+ export function bytesToText(hex) {
15
+ const body = hex.startsWith("0x") ? hex.slice(2) : hex;
16
+ if (body.length % 2 !== 0 || body.length === 0)
17
+ return null;
18
+ try {
19
+ const buf = Buffer.from(body, "hex");
20
+ if (buf.toString("hex") !== body.toLowerCase())
21
+ return null;
22
+ const text = buf.toString("utf8");
23
+ if (text.includes("\ufffd"))
24
+ return null;
25
+ if (!PRINTABLE.test(text))
26
+ return null;
27
+ return text;
28
+ }
29
+ catch {
30
+ return null;
31
+ }
32
+ }
33
+ export function previewBytes(hex, max = 48) {
34
+ const text = bytesToText(hex);
35
+ const body = hex.startsWith("0x") ? hex.slice(2) : hex;
36
+ const size = Math.floor(body.length / 2);
37
+ if (text !== null) {
38
+ const clean = text.replace(/\s+/g, " ").trim();
39
+ return `"${truncate(clean, max)}" (${size} B)`;
40
+ }
41
+ return `0x${truncate(body, max)} (${size} B)`;
42
+ }
43
+ /** Human readable one-line rendering of an attribute. */
44
+ export function displayValue(attribute, max = 48) {
45
+ if (!attribute)
46
+ return "";
47
+ const { type, value } = attribute;
48
+ if (value === null || value === undefined)
49
+ return "";
50
+ switch (type) {
51
+ case "bytes":
52
+ return previewBytes(String(value), max);
53
+ case "str":
54
+ return truncate(safeText(value), max);
55
+ case "bool":
56
+ return value ? "true" : "false";
57
+ case "u64":
58
+ case "i32":
59
+ case "u256":
60
+ case "dec":
61
+ case "addr":
62
+ case "key":
63
+ return truncate(String(value), max);
64
+ default:
65
+ return truncate(safeText(String(value)), max);
66
+ }
67
+ }
68
+ export function attributeSize(attribute) {
69
+ if (!attribute || attribute.value == null)
70
+ return 0;
71
+ switch (attribute.type) {
72
+ case "bytes": {
73
+ const body = String(attribute.value).replace(/^0x/, "");
74
+ return Math.floor(body.length / 2);
75
+ }
76
+ case "u64":
77
+ return 8;
78
+ case "i32":
79
+ return 4;
80
+ case "u256":
81
+ return 32;
82
+ case "bool":
83
+ return 1;
84
+ case "addr":
85
+ return 20;
86
+ case "key":
87
+ return 32;
88
+ default:
89
+ return Buffer.byteLength(String(attribute.value), "utf8");
90
+ }
91
+ }
92
+ /** Total payload size of an entity's attributes, used by doctor and create. */
93
+ export function entityFootprint(attributes) {
94
+ return Object.entries(attributes).reduce((total, [name, attr]) => total + Buffer.byteLength(name, "utf8") + attributeSize(attr), 0);
95
+ }
96
+ export const VALUE_TYPES = ["str", "u64", "i32", "u256", "bool", "bytes", "addr", "key", "dec"];
97
+ /**
98
+ * Convert a JSON value from a create file into a typed SDK value. Types are
99
+ * explicit because the chain does not coerce between them.
100
+ */
101
+ export function typedValue(type, raw) {
102
+ switch (type) {
103
+ case "str":
104
+ if (typeof raw !== "string")
105
+ throw new Error("str values must be strings");
106
+ return str(raw);
107
+ case "u64":
108
+ return u64(BigInt(String(raw)));
109
+ case "i32":
110
+ return i32(Number(raw));
111
+ case "u256":
112
+ return u256(BigInt(String(raw)));
113
+ case "bool":
114
+ if (typeof raw !== "boolean")
115
+ throw new Error("bool values must be true or false");
116
+ return boolValue(raw);
117
+ case "bytes": {
118
+ const hex = String(raw);
119
+ if (!/^0x([0-9a-fA-F]{2})*$/.test(hex))
120
+ throw new Error("bytes values must be 0x-prefixed hex");
121
+ return bytesValue(hex);
122
+ }
123
+ case "addr":
124
+ return addr(String(raw));
125
+ case "key":
126
+ return keyValue(String(raw));
127
+ case "dec":
128
+ return dec(String(raw));
129
+ default:
130
+ throw new Error(`unknown attribute type "${type}"`);
131
+ }
132
+ }