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,91 @@
1
+ /**
2
+ * `arkiv entity <key>` - read one entity and print everything the node returns.
3
+ * Attribute values are untrusted bytes, so every string goes through sanitize()
4
+ * before it reaches the terminal.
5
+ */
6
+ import { explainError, getTiming, blockToUnix, explorerUrl } from "../arkiv.js";
7
+ import { displayValue } from "../values.js";
8
+ import { bold, cyan, dim, formatDate, formatDuration, grey, out, red, table, yellow, } from "../ui.js";
9
+ const KEY_PATTERN = /^0x[0-9a-fA-F]{64}$/;
10
+ export async function runEntity(ctx, rawKey) {
11
+ const key = rawKey.trim();
12
+ if (!KEY_PATTERN.test(key)) {
13
+ out(red(`not an entity key: ${key}`));
14
+ out(dim("an entity key is 0x followed by 64 hex characters"));
15
+ process.exitCode = 2;
16
+ return;
17
+ }
18
+ let entity;
19
+ try {
20
+ entity = (await ctx.client().getEntity(key));
21
+ }
22
+ catch (error) {
23
+ out(red("could not read that entity"));
24
+ out(dim(explainError(error)));
25
+ process.exitCode = 1;
26
+ return;
27
+ }
28
+ const attributes = (entity.$attributes ?? {});
29
+ let timing = null;
30
+ try {
31
+ timing = await getTiming(ctx.client());
32
+ }
33
+ catch {
34
+ /* block timing is a nicety; the entity still prints without it */
35
+ }
36
+ if (ctx.json) {
37
+ const payload = {
38
+ key: entity.$key,
39
+ owner: entity.$owner,
40
+ creator: entity.$creator,
41
+ createdAt: entity.$createdAt ? Number(entity.$createdAt) : null,
42
+ updatedAt: entity.$updatedAt ? Number(entity.$updatedAt) : null,
43
+ expiresAt: entity.$expiresAt ? Number(entity.$expiresAt) : null,
44
+ flags: entity.$creationFlags,
45
+ attributes: Object.fromEntries(Object.entries(attributes).map(([name, value]) => [name, { type: value.type, value: value.value }])),
46
+ };
47
+ process.stdout.write(`${JSON.stringify(payload, (_k, v) => (typeof v === "bigint" ? v.toString() : v), 2)}\n`);
48
+ return;
49
+ }
50
+ const title = attributes["arkiv.schema"]?.value ?? "entity";
51
+ out(`${bold(String(title))} ${dim("entity")}`);
52
+ out(`${grey("key ")} ${entity.$key}`);
53
+ if (entity.$owner)
54
+ out(`${grey("owner ")} ${entity.$owner}`);
55
+ if (entity.$creator && entity.$creator !== entity.$owner) {
56
+ out(`${grey("creator ")} ${entity.$creator}`);
57
+ }
58
+ if (timing) {
59
+ if (entity.$createdAt) {
60
+ const created = blockToUnix(timing, Number(entity.$createdAt));
61
+ out(`${grey("created ")} ${formatDate(created)} ${dim(`(block ${Number(entity.$createdAt).toLocaleString("en-US")})`)}`);
62
+ }
63
+ if (entity.$updatedAt && entity.$updatedAt !== entity.$createdAt) {
64
+ out(`${grey("updated ")} ${formatDate(blockToUnix(timing, Number(entity.$updatedAt)))}`);
65
+ }
66
+ if (entity.$expiresAt && Number(entity.$expiresAt) > 0) {
67
+ const expires = blockToUnix(timing, Number(entity.$expiresAt));
68
+ const remaining = expires - Date.now() / 1000;
69
+ const tone = remaining < 0 ? red : remaining < 86_400 ? yellow : dim;
70
+ out(`${grey("expires ")} ${formatDate(expires)} ${tone(`(in ${formatDuration(remaining)})`)}`);
71
+ }
72
+ else {
73
+ out(`${grey("expires ")} ${dim("never (expiry disabled)")}`);
74
+ }
75
+ }
76
+ out();
77
+ const rows = Object.entries(attributes).map(([name, value]) => [
78
+ name,
79
+ cyan(value.type),
80
+ displayValue(value, 160),
81
+ ]);
82
+ if (rows.length === 0) {
83
+ out(dim("no attributes"));
84
+ }
85
+ else {
86
+ out(table(["ATTRIBUTE", "TYPE", "VALUE"], rows));
87
+ }
88
+ const url = explorerUrl(ctx.network, key);
89
+ if (url)
90
+ out(`\n${grey("explorer")} ${url}`);
91
+ }
@@ -0,0 +1,13 @@
1
+ /**
2
+ * `arkiv init` - scaffold a project that runs.
3
+ *
4
+ * The generated app is deliberately small: one file that connects, defines a
5
+ * schema, writes an entity and reads it back. Everything is written with `wx`
6
+ * so an existing project is never silently overwritten.
7
+ */
8
+ export interface InitOptions {
9
+ directory: string;
10
+ force: boolean;
11
+ install: boolean;
12
+ }
13
+ export declare function runInit(options: InitOptions): Promise<void>;
@@ -0,0 +1,205 @@
1
+ /**
2
+ * `arkiv init` - scaffold a project that runs.
3
+ *
4
+ * The generated app is deliberately small: one file that connects, defines a
5
+ * schema, writes an entity and reads it back. Everything is written with `wx`
6
+ * so an existing project is never silently overwritten.
7
+ */
8
+ import { existsSync, mkdirSync, writeFileSync } from "node:fs";
9
+ import { join, resolve, basename } from "node:path";
10
+ import { bold, dim, green, out, red, yellow } from "../ui.js";
11
+ const PACKAGE_JSON = (name) => JSON.stringify({
12
+ name,
13
+ version: "0.1.0",
14
+ private: true,
15
+ type: "module",
16
+ scripts: {
17
+ dev: "tsx src/main.ts",
18
+ start: "tsx src/main.ts",
19
+ typecheck: "tsc --noEmit",
20
+ },
21
+ dependencies: {
22
+ "@arkiv-network/sdk": "^1.0.1",
23
+ viem: "^2.21.0",
24
+ },
25
+ devDependencies: {
26
+ "@types/node": "^22.10.0",
27
+ tsx: "^4.19.0",
28
+ typescript: "^5.6.0",
29
+ },
30
+ }, null, 2) + "\n";
31
+ const TSCONFIG = `{
32
+ "compilerOptions": {
33
+ "target": "ES2022",
34
+ "module": "ESNext",
35
+ "moduleResolution": "Bundler",
36
+ "lib": ["ES2022"],
37
+ "strict": true,
38
+ "noUncheckedIndexedAccess": true,
39
+ "skipLibCheck": true,
40
+ "types": ["node"],
41
+ "noEmit": true
42
+ },
43
+ "include": ["src"]
44
+ }
45
+ `;
46
+ const GITIGNORE = `node_modules/
47
+ .env
48
+ .env.*
49
+ !.env.example
50
+ dist/
51
+ *.log
52
+ `;
53
+ const ENV_EXAMPLE = `# Access key from https://hub.arkiv.network/access-keys
54
+ # The public RPC is rate limited, so a key is not optional for real work.
55
+ ARKIV_ACCESS_KEY=
56
+
57
+ # A testnet-funded wallet. Generate nothing here: paste the key you already own
58
+ # and fund it from https://hub.arkiv.network/faucet
59
+ ARKIV_PRIVATE_KEY=
60
+
61
+ # rabdi (testnet) or localhost
62
+ ARKIV_NETWORK=rabdi
63
+ `;
64
+ const MAIN_TS = `/**
65
+ * A minimal Arkiv app: connect, write one entity, read it back.
66
+ *
67
+ * Run it with \`npm run dev\`. Everything is read from .env so no key ever
68
+ * appears in source.
69
+ */
70
+
71
+ import { createPublicClient, createWalletClient, ExpirationTime } from "@arkiv-network/sdk";
72
+ import { http } from "viem";
73
+ import { addr, bytes, defineSchema, str, u64 } from "@arkiv-network/sdk/schema";
74
+ import { rabdi } from "@arkiv-network/sdk/chains";
75
+ import { privateKeyToAccount } from "viem/accounts";
76
+
77
+ const accessKey = process.env.ARKIV_ACCESS_KEY ?? "";
78
+ const privateKey = process.env.ARKIV_PRIVATE_KEY ?? "";
79
+
80
+ if (!privateKey) {
81
+ console.error("ARKIV_PRIVATE_KEY is empty; copy .env.example to .env and fill it in");
82
+ process.exit(1);
83
+ }
84
+
85
+ const chain = rabdi({ accessKey });
86
+ const account = privateKeyToAccount(privateKey as \`0x\${string}\`);
87
+
88
+ const publicClient = createPublicClient({ chain, transport: http() });
89
+ const wallet = createWalletClient({ chain, transport: http(), account });
90
+
91
+ /**
92
+ * A schema is the contract for one kind of entity. Keep the name namespaced and
93
+ * versioned: "notes/v1" not "notes".
94
+ */
95
+ const Note = defineSchema("notes/v1", {
96
+ author: addr().indexed(),
97
+ slug: str().indexed(),
98
+ title: str(),
99
+ revision: u64(),
100
+ body: bytes(),
101
+ });
102
+
103
+ const slug = \`hello-\${Date.now()}\`;
104
+
105
+ const { entityKey, txHash, expiresAt } = await wallet.createEntity(
106
+ Note,
107
+ {
108
+ author: addr(account.address),
109
+ slug,
110
+ title: "First note",
111
+ revision: u64(1n),
112
+ body: new TextEncoder().encode("Written from a terminal."),
113
+ },
114
+ { expiration: ExpirationTime.fromDays(7) },
115
+ );
116
+
117
+ console.log("wrote", entityKey);
118
+ console.log("tx ", txHash);
119
+ console.log("until", Number(expiresAt));
120
+
121
+ const page = await publicClient
122
+ .select({ $key: true, $owner: true, $expiresAt: true, $attributes: true })
123
+ // the SDK types predicates strictly; the plain string form works at runtime
124
+ .where(\`slug = str('\${slug}')\` as never)
125
+ .limit(1)
126
+ .fetch();
127
+
128
+ console.log("read back", JSON.stringify(page.entities, (_key, value) => (typeof value === "bigint" ? value.toString() : value), 2));
129
+ `;
130
+ const README = (name) => `# ${name}
131
+
132
+ An Arkiv app scaffolded with \`arkiv init\`.
133
+
134
+ ## Setup
135
+
136
+ 1. \`npm install\`
137
+ 2. Copy \`.env.example\` to \`.env\`.
138
+ 3. Put your access key and a faucet-funded testnet private key in \`.env\`.
139
+ 4. \`npx arkiv-cli doctor\` checks the key format, the balance and the RPC.
140
+ 5. \`npm run dev\` writes one entity and reads it back.
141
+
142
+ ## Where things are
143
+
144
+ - \`src/main.ts\`: the whole app. A typed schema, one write, one query.
145
+ - \`.env\`: credentials, ignored by git.
146
+
147
+ ## Notes
148
+
149
+ - Schema names should be namespaced and versioned (\`notes/v1\`), because the
150
+ network has no registry and a flat name collides with everyone else's.
151
+ - Every attribute value has a size limit; run \`npx arkiv cost\` before writing
152
+ anything large, and \`npx arkiv limits\` for the full table.
153
+ - Entities expire. \`ExpirationTime.fromDays(7)\` sets the lifetime at write time.
154
+
155
+ Built By Harish Kotra \\- harishkotra.me
156
+ `;
157
+ export async function runInit(options) {
158
+ const target = resolve(options.directory);
159
+ if (existsSync(target)) {
160
+ if (!options.force) {
161
+ out(red(`${target} already exists`));
162
+ out(dim("pass --force to write into it anyway (existing files are still not overwritten)"));
163
+ process.exitCode = 1;
164
+ return;
165
+ }
166
+ }
167
+ mkdirSync(target, { recursive: true });
168
+ mkdirSync(join(target, "src"), { recursive: true });
169
+ const name = basename(target).replace(/[^a-zA-Z0-9._-]/g, "-") || "arkiv-app";
170
+ const files = [
171
+ ["package.json", PACKAGE_JSON(name)],
172
+ ["tsconfig.json", TSCONFIG],
173
+ [".gitignore", GITIGNORE],
174
+ [".env.example", ENV_EXAMPLE],
175
+ ["src/main.ts", MAIN_TS],
176
+ ["README.md", README(name)],
177
+ ];
178
+ const written = [];
179
+ const skipped = [];
180
+ for (const [relative, content] of files) {
181
+ const path = join(target, relative);
182
+ if (existsSync(path)) {
183
+ skipped.push(relative);
184
+ continue;
185
+ }
186
+ writeFileSync(path, content, { flag: "wx", mode: relative.startsWith(".env") ? 0o600 : 0o644 });
187
+ written.push(relative);
188
+ }
189
+ out(bold("scaffolded"));
190
+ for (const file of written)
191
+ out(` ${green("+")} ${file}`);
192
+ for (const file of skipped)
193
+ out(` ${yellow("=")} ${file} ${dim("kept")}`);
194
+ out();
195
+ out("next:");
196
+ out(` cd ${target}`);
197
+ out(" npm install");
198
+ out(" cp .env.example .env # then fill in the key");
199
+ out(" npx arkiv-cli doctor");
200
+ out(" npm run dev");
201
+ if (skipped.length > 0) {
202
+ out();
203
+ out(dim("existing files were left alone; delete them if you want the templates back"));
204
+ }
205
+ }
@@ -0,0 +1,24 @@
1
+ /**
2
+ * `arkiv query` - paginated reads with a readable default table.
3
+ */
4
+ import type { Context } from "../context.js";
5
+ import { explainError } from "../arkiv.js";
6
+ import { type ValueType } from "../values.js";
7
+ import { cyan } from "../ui.js";
8
+ export declare const MAX_LIMIT = 100;
9
+ export interface QueryOptions {
10
+ expression: string;
11
+ schema?: string;
12
+ limit: number;
13
+ offset: number;
14
+ countOnly?: boolean;
15
+ showKey?: boolean;
16
+ sort?: string;
17
+ sortType?: ValueType;
18
+ descending?: boolean;
19
+ select?: string[];
20
+ }
21
+ export declare function buildExpression(options: QueryOptions): string;
22
+ export declare function runQuery(ctx: Context, options: QueryOptions): Promise<void>;
23
+ export declare function stringifyEntity(entity: unknown): string;
24
+ export { explainError, cyan };
@@ -0,0 +1,111 @@
1
+ /**
2
+ * `arkiv query` - paginated reads with a readable default table.
3
+ */
4
+ import { asc, desc } from "@arkiv-network/sdk/query";
5
+ import { explainError, getTiming } from "../arkiv.js";
6
+ import { renderEntities, num } from "../render.js";
7
+ import { VALUE_TYPES } from "../values.js";
8
+ import { UsageError } from "../args.js";
9
+ import { bold, cyan, dim, out, yellow } from "../ui.js";
10
+ export const MAX_LIMIT = 100;
11
+ export function buildExpression(options) {
12
+ const parts = [];
13
+ const raw = options.expression.trim();
14
+ if (raw)
15
+ parts.push(`(${raw})`);
16
+ if (options.schema) {
17
+ if (!/^[A-Za-z0-9._/-]{1,64}$/.test(options.schema)) {
18
+ throw new UsageError("--schema contains characters that are not allowed in a schema name");
19
+ }
20
+ parts.push(`arkiv.schema = str('${options.schema}')`);
21
+ }
22
+ if (parts.length === 0) {
23
+ throw new UsageError('give a filter expression, for example: arkiv query "arkiv.schema = str(\'my-app-v1.post\')"');
24
+ }
25
+ const expression = parts.join(" AND ");
26
+ if (expression.length > 2000)
27
+ throw new UsageError("expression is too long (2000 character limit)");
28
+ return expression;
29
+ }
30
+ export async function runQuery(ctx, options) {
31
+ const expression = buildExpression(options);
32
+ if (!Number.isInteger(options.limit) || options.limit < 1 || options.limit > MAX_LIMIT) {
33
+ throw new UsageError(`--limit must be between 1 and ${MAX_LIMIT}`);
34
+ }
35
+ if (!Number.isInteger(options.offset) || options.offset < 0) {
36
+ throw new UsageError("--offset must be zero or more");
37
+ }
38
+ const client = ctx.client();
39
+ let builder = client.select();
40
+ if (options.select?.length) {
41
+ const selection = {};
42
+ for (const field of options.select)
43
+ selection[field] = true;
44
+ builder = client.select(selection);
45
+ }
46
+ builder = builder.where(expression).limit(options.limit);
47
+ if (options.offset > 0)
48
+ builder = builder.offset(options.offset);
49
+ if (options.sort) {
50
+ const type = options.sort.startsWith("$")
51
+ ? undefined
52
+ : (options.sortType ?? "str");
53
+ if (type && !VALUE_TYPES.includes(type))
54
+ throw new UsageError(`unknown --sort-type ${type}`);
55
+ const key = type
56
+ ? asc(options.sort, type)
57
+ : asc(options.sort);
58
+ const descendingKey = type
59
+ ? desc(options.sort, type)
60
+ : desc(options.sort);
61
+ builder = builder.orderBy(options.descending ? descendingKey : key);
62
+ }
63
+ const page = await builder.withCount().fetch();
64
+ const count = page.count === undefined ? undefined : Number(page.count);
65
+ const entities = page.entities;
66
+ if (options.countOnly) {
67
+ if (ctx.json) {
68
+ process.stdout.write(`${JSON.stringify({ count, block: String(page.blockNumber) })}\n`);
69
+ }
70
+ else {
71
+ out(`${bold(String(count ?? "?"))} ${dim("matching entities")}`);
72
+ }
73
+ return;
74
+ }
75
+ if (ctx.json) {
76
+ const payload = {
77
+ count,
78
+ block: String(page.blockNumber),
79
+ expression,
80
+ entities: entities.map((entity) => JSON.parse(stringifyEntity(entity))),
81
+ };
82
+ process.stdout.write(`${JSON.stringify(payload, null, 2)}\n`);
83
+ return;
84
+ }
85
+ let timing = null;
86
+ try {
87
+ timing = await getTiming(client);
88
+ }
89
+ catch {
90
+ /* expiry falls back to block numbers */
91
+ }
92
+ out(dim(`expression: ${expression}`));
93
+ out(renderEntities(entities, timing, { showKey: options.showKey }));
94
+ const shown = entities.length;
95
+ const from = options.offset + 1;
96
+ const to = options.offset + shown;
97
+ out();
98
+ out(dim(count === undefined
99
+ ? `${shown} shown`
100
+ : `${from}-${to} of ${count} ${shown === 0 ? "" : "shown in page"}`.replace(" ", " ")) +
101
+ dim(` (read at block ${num(page.blockNumber).toLocaleString("en-US")})`));
102
+ if (count !== undefined && to < count) {
103
+ out(dim(`next page: --offset ${to}`));
104
+ }
105
+ if (shown === 0)
106
+ out(yellow("nothing matched - check the schema name with `arkiv schema`"));
107
+ }
108
+ export function stringifyEntity(entity) {
109
+ return JSON.stringify(entity, (_key, value) => (typeof value === "bigint" ? value.toString() : value), 2);
110
+ }
111
+ export { explainError, cyan };
@@ -0,0 +1,55 @@
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 type { Context } from "../context.js";
10
+ export interface FieldInfo {
11
+ type: string;
12
+ indexed: boolean;
13
+ /** Distinct string lengths seen, used by the overflow lint. */
14
+ maxStringBytes: number;
15
+ maxEntityBytes: number;
16
+ }
17
+ export interface SchemaSummary {
18
+ name: string;
19
+ entities: number;
20
+ owners: string[];
21
+ fields: Map<string, FieldInfo>;
22
+ typeConflicts: Set<string>;
23
+ earliestExpiry: number;
24
+ }
25
+ export interface SampleOptions {
26
+ sample: number;
27
+ }
28
+ export declare function sampleSchemas(ctx: Context, options: SampleOptions): Promise<{
29
+ schemas: Map<string, SchemaSummary>;
30
+ scanned: number;
31
+ block: number;
32
+ }>;
33
+ export type Severity = "error" | "warn" | "info";
34
+ export interface Finding {
35
+ severity: Severity;
36
+ rule: string;
37
+ message: string;
38
+ }
39
+ export declare function lintSchema(summary: SchemaSummary): Finding[];
40
+ export declare function worst(findings: Finding[]): Severity | null;
41
+ export declare function runSchemaList(ctx: Context, options: SampleOptions): Promise<void>;
42
+ /**
43
+ * Reads one schema directly.
44
+ *
45
+ * `schema` listings come from a broad sample, which can miss a schema that sits
46
+ * deep in the keyspace. Asking for an explicit schema should not depend on a
47
+ * lucky sample, so this queries the schema itself.
48
+ */
49
+ export declare function collectSchema(ctx: Context, name: string, limit?: number): Promise<SchemaSummary | null>;
50
+ export declare function runSchemaDetail(ctx: Context, name: string): Promise<void>;
51
+ export declare function renderAtlas(schemas: Map<string, SchemaSummary>, meta: {
52
+ scanned: number;
53
+ block: number;
54
+ network: string;
55
+ }): string;