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/LICENSE +21 -0
- package/README.md +512 -2
- package/dist/args.d.ts +19 -0
- package/dist/args.js +124 -0
- package/dist/arkiv.d.ts +77 -0
- package/dist/arkiv.js +158 -0
- package/dist/cli.d.ts +9 -0
- package/dist/cli.js +343 -0
- package/dist/commands/cost.d.ts +22 -0
- package/dist/commands/cost.js +144 -0
- package/dist/commands/create.d.ts +27 -0
- package/dist/commands/create.js +239 -0
- package/dist/commands/doctor.d.ts +23 -0
- package/dist/commands/doctor.js +190 -0
- package/dist/commands/entity.d.ts +7 -0
- package/dist/commands/entity.js +91 -0
- package/dist/commands/init.d.ts +13 -0
- package/dist/commands/init.js +205 -0
- package/dist/commands/query.d.ts +24 -0
- package/dist/commands/query.js +111 -0
- package/dist/commands/schema.d.ts +55 -0
- package/dist/commands/schema.js +397 -0
- package/dist/commands/watch.d.ts +16 -0
- package/dist/commands/watch.js +124 -0
- package/dist/context.d.ts +25 -0
- package/dist/context.js +31 -0
- package/dist/env.d.ts +51 -0
- package/dist/env.js +136 -0
- package/dist/index.d.ts +19 -0
- package/dist/index.js +19 -0
- package/dist/limits.d.ts +21 -0
- package/dist/limits.js +28 -0
- package/dist/render.d.ts +26 -0
- package/dist/render.js +77 -0
- package/dist/ui.d.ts +41 -0
- package/dist/ui.js +144 -0
- package/dist/values.d.ts +26 -0
- package/dist/values.js +132 -0
- package/package.json +46 -4
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Harish Kotra
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,513 @@
|
|
|
1
|
-
#
|
|
1
|
+
# arkiv
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
A terminal toolkit for the [Arkiv](https://arkiv.network) network. One binary that takes you from "I have a key" to "I can read and write entities" without reading the SDK source first.
|
|
4
|
+
|
|
5
|
+
```
|
|
6
|
+
arkiv init [dir] scaffold a runnable project
|
|
7
|
+
arkiv doctor check node, keys, balance, network and .env safety
|
|
8
|
+
arkiv query <expression> run a query
|
|
9
|
+
arkiv entity <key> read one entity in full
|
|
10
|
+
arkiv schema [name] discover schemas, lint their names, or open one
|
|
11
|
+
arkiv create write one entity
|
|
12
|
+
arkiv watch <expression> poll a query and print changes
|
|
13
|
+
arkiv cost size a write before you send it
|
|
14
|
+
arkiv limits protocol limits in one table
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
## Why this exists
|
|
18
|
+
|
|
19
|
+
Arkiv ships a good SDK and good docs. What it does not ship is the loop every developer runs on day one: check the environment, get a key working, run one query, read one entity, write one entity, and see the write land. Today that loop is assembled by hand from docs pages and examples, and it fails in ways the SDK cannot explain. Wrong node version. A key with a stray newline. A rate-limited public RPC. A schema name that is legal but collides with someone else's.
|
|
20
|
+
|
|
21
|
+
`arkiv` is that loop, in the terminal, with the failure modes named.
|
|
22
|
+
|
|
23
|
+
## Install
|
|
24
|
+
|
|
25
|
+
```bash
|
|
26
|
+
npm install -g arkiv-cli # or: npx arkiv-cli <command>
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
Node 22.10 or newer. That floor comes from the SDK, not from taste. Verified on Node 26.
|
|
30
|
+
|
|
31
|
+
From source:
|
|
32
|
+
|
|
33
|
+
```bash
|
|
34
|
+
git clone https://github.com/harishkotra/arkiv-cli
|
|
35
|
+
cd arkiv-cli
|
|
36
|
+
npm install
|
|
37
|
+
npm run build
|
|
38
|
+
node dist/cli.js doctor
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
## Stack
|
|
42
|
+
|
|
43
|
+
| Layer | Choice | Why |
|
|
44
|
+
| --- | --- | --- |
|
|
45
|
+
| Language | TypeScript 5.6, ES2022, NodeNext | Types catch the SDK's positional APIs at build time |
|
|
46
|
+
| Runtime | Node 22.10+ | The SDK's floor |
|
|
47
|
+
| Chain access | `@arkiv-network/sdk` | The same client everyone else uses, no private endpoints |
|
|
48
|
+
| Transport and accounts | `viem` | Key validation, address derivation, `http()` transport |
|
|
49
|
+
| CLI plumbing | Hand written | No framework. ~140 lines of parser buys strict flags and typed values |
|
|
50
|
+
| Tests | `node:test` | Ships with Node, no test runner to configure |
|
|
51
|
+
| Build | `tsc` | One step, no bundler |
|
|
52
|
+
|
|
53
|
+
Runtime dependencies: two. That is deliberate. A CLI that pulls thirty packages to print a table is harder to audit than one you can read in an evening.
|
|
54
|
+
|
|
55
|
+
## Credentials
|
|
56
|
+
|
|
57
|
+
Two environment variables, read from `.env` in the current directory (or `--env <path>`, or the process environment):
|
|
58
|
+
|
|
59
|
+
```bash
|
|
60
|
+
ARKIV_ACCESS_KEY=... # free at https://hub.arkiv.network/access-keys
|
|
61
|
+
ARKIV_PRIVATE_KEY=0x... # only needed for writes
|
|
62
|
+
ARKIV_RPC_URL=... # optional, overrides the network default
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
The CLI never generates a wallet, never derives an address for you to copy, never prints a key. `doctor` checks the format of both values without echoing them, and every value it validates registers with the output redactor, so a value that leaks into an error message comes back as `[REDACTED]`.
|
|
66
|
+
|
|
67
|
+
Copy `.env.example` to `.env` to start. `.gitignore` already excludes `.env`; `doctor` fails loudly if it does not.
|
|
68
|
+
|
|
69
|
+
## Quickstart
|
|
70
|
+
|
|
71
|
+
```bash
|
|
72
|
+
npx arkiv-cli init my-app
|
|
73
|
+
cd my-app
|
|
74
|
+
cp .env.example .env # paste your key, keep the file out of git
|
|
75
|
+
npx arkiv-cli doctor
|
|
76
|
+
npm run dev
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
`init` writes a single-file runnable project (TypeScript, `tsx`, the SDK), and refuses to touch a directory that already has files unless you pass `--force`.
|
|
80
|
+
|
|
81
|
+
## Commands
|
|
82
|
+
|
|
83
|
+
### `arkiv doctor`
|
|
84
|
+
|
|
85
|
+
The front door. Every check prints `ok`, `warn` or `fail` with a fix:
|
|
86
|
+
|
|
87
|
+
- Node version against the SDK's floor (22.10)
|
|
88
|
+
- `ARKIV_ACCESS_KEY` presence and shape
|
|
89
|
+
- `ARKIV_PRIVATE_KEY` presence and shape (only for write work)
|
|
90
|
+
- tGLM balance for the configured key
|
|
91
|
+
- clock skew between your machine and the node's last block
|
|
92
|
+
- reachability and chain id of the configured network
|
|
93
|
+
- whether `.env` is git-ignored and whether its mode is world-readable
|
|
94
|
+
|
|
95
|
+
Exit code is non-zero only when a check `fail`s, so it is safe in CI.
|
|
96
|
+
|
|
97
|
+
```bash
|
|
98
|
+
arkiv doctor
|
|
99
|
+
arkiv doctor --json | jq '.checks[] | select(.status != "ok")'
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
### `arkiv query <expression>`
|
|
103
|
+
|
|
104
|
+

|
|
105
|
+
|
|
106
|
+
Runs a query with the SDK's own predicate language, no translation layer:
|
|
107
|
+
|
|
108
|
+
```bash
|
|
109
|
+
arkiv query "arkiv.schema = str('notes/v1')" --limit 5
|
|
110
|
+
arkiv query "EXISTS(title) AND kind = str('note')" --sort updatedAt --type u64 --desc
|
|
111
|
+
arkiv query "arkiv.schema = str('notes/v1')" --count
|
|
112
|
+
arkiv query "..." --key # keys only, cheap to pipe
|
|
113
|
+
arkiv query "..." --select title,kind
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
`--schema <name>` is a shortcut that adds `arkiv.schema = str(name)` and refuses names that could break out of the string literal. An empty expression is a usage error rather than a full scan. `--limit` is capped at 100, which is the node's page size.
|
|
117
|
+
|
|
118
|
+
### `arkiv entity <key>`
|
|
119
|
+
|
|
120
|
+

|
|
121
|
+
|
|
122
|
+
Reads one entity in full: owner, created and expiry timestamps, attribute schema, every attribute value, and an explorer link. The key is validated locally (`0x` plus 64 hex) before any network call, so a typo costs nothing and exits 2.
|
|
123
|
+
|
|
124
|
+
Attribute values are chain data, which means arbitrary bytes. Every value is stripped of control sequences and non-printable characters before it reaches your terminal, and long values are truncated with a byte count.
|
|
125
|
+
|
|
126
|
+
### `arkiv schema [name]`
|
|
127
|
+
|
|
128
|
+

|
|
129
|
+
|
|
130
|
+
There is no "list schemas" call on the node, so this pages through entities that carry `arkiv.schema`, samples them, and builds the picture from what is actually written:
|
|
131
|
+
|
|
132
|
+
```bash
|
|
133
|
+
arkiv schema # every schema seen in the sample, with owners and counts
|
|
134
|
+
arkiv schema notes/v1 # fields, types, index flags, sample values for one schema
|
|
135
|
+
arkiv schema --sample 1000 # sample deeper
|
|
136
|
+
arkiv schema --atlas atlas.html # write a standalone HTML page for the sample
|
|
137
|
+
```
|
|
138
|
+
|
|
139
|
+
A named schema is queried directly rather than filtered out of a sample, so it resolves even when it sits deep in the keyspace.
|
|
140
|
+
|
|
141
|
+
The linter flags the naming problems that make a schema hard to find or unsafe to extend:
|
|
142
|
+
|
|
143
|
+
- dots, slashes and hyphens mixed in one namespace
|
|
144
|
+
- no version suffix, so the schema cannot evolve
|
|
145
|
+
- names longer than the protocol's 32-byte attribute-name limit
|
|
146
|
+
- the same schema name written by more than one owner (collision)
|
|
147
|
+
- `str` attributes close to the 128-byte limit
|
|
148
|
+
- entities at or near the 32-attribute limit
|
|
149
|
+
|
|
150
|
+
### `arkiv create`
|
|
151
|
+
|
|
152
|
+

|
|
153
|
+
|
|
154
|
+
Writes one entity. The schema is built at runtime from the flags, so there is no separate schema file to keep in sync:
|
|
155
|
+
|
|
156
|
+
```bash
|
|
157
|
+
arkiv create --schema notes/v1 \
|
|
158
|
+
--set title=str:Hello \
|
|
159
|
+
--set kind=str:note \
|
|
160
|
+
--set body=bytes:@./README.md \
|
|
161
|
+
--expires 30d \
|
|
162
|
+
--dry-run
|
|
163
|
+
```
|
|
164
|
+
|
|
165
|
+
Attribute syntax is `name=type:value`, where type is one of `str`, `u64`, `i32`, `u256`, `bool`, `bytes`, `addr`, `key`, `dec`. `bytes:@path` reads a file; `bytes:0x...` takes hex. `--expires` accepts `s`, `m`, `h`, `d`, `w`, `y` and is validated against the protocol's maximum lifetime.
|
|
166
|
+
|
|
167
|
+
`--dry-run` prints the exact plan (attribute table, payload bytes, blocks until expiry, gas estimate) and stops before signing. Use it to check a write, and in tests.
|
|
168
|
+
|
|
169
|
+
### `arkiv watch <expression>`
|
|
170
|
+
|
|
171
|
+

|
|
172
|
+
|
|
173
|
+
Polls a query and prints only what changed:
|
|
174
|
+
|
|
175
|
+
```bash
|
|
176
|
+
arkiv watch "arkiv.schema = str('notes/v1')" --interval 5
|
|
177
|
+
arkiv watch "..." --once # one snapshot, then exit
|
|
178
|
+
```
|
|
179
|
+
|
|
180
|
+
The watcher keeps a bounded, key-ordered window of matches and diffs it each tick. When the window saturates it says so, because a full window can hide new entities behind the ordering.
|
|
181
|
+
|
|
182
|
+
### `arkiv cost`
|
|
183
|
+
|
|
184
|
+

|
|
185
|
+
|
|
186
|
+
Sizes a write before you pay for it:
|
|
187
|
+
|
|
188
|
+
```bash
|
|
189
|
+
arkiv cost --bytes 2048 --attributes 4 --expires 30d
|
|
190
|
+
arkiv cost --file ./payload.json --attributes 6
|
|
191
|
+
```
|
|
192
|
+
|
|
193
|
+
Reports payload bytes, attribute overhead, blocks until expiry, the share of each protocol limit the write consumes, how many such writes fit in the limits, and the live gas price. It does not invent a GLM price: the network's pricing page is the source for that, and a CLI that guesses is worse than one that shows you the inputs.
|
|
194
|
+
|
|
195
|
+
### `arkiv limits`
|
|
196
|
+
|
|
197
|
+

|
|
198
|
+
|
|
199
|
+
The protocol's hard limits in one table, sourced from the SDK's own exports with documented fallbacks, so the CLI cannot drift from the chain.
|
|
200
|
+
|
|
201
|
+
## Configuration
|
|
202
|
+
|
|
203
|
+
| Flag | Meaning |
|
|
204
|
+
| --- | --- |
|
|
205
|
+
| `--network <name>` | `rabdi` (default) or `localhost` |
|
|
206
|
+
| `--rpc <url>` | override the RPC endpoint (http/https only) |
|
|
207
|
+
| `--env <path>` | use a different env file |
|
|
208
|
+
| `--json` | machine-readable output where supported |
|
|
209
|
+
| `--no-colour` | plain text, also honoured via `NO_COLOR` |
|
|
210
|
+
| `--verbose` | extra detail on failures |
|
|
211
|
+
| `-h`, `--help` | help for a command |
|
|
212
|
+
| `-v`, `--version` | version |
|
|
213
|
+
|
|
214
|
+
Unknown flags are an error, not something to ignore. The argument parser rejects them, because a silently ignored `--limt 5` looks like a query that returned everything.
|
|
215
|
+
|
|
216
|
+
## Architecture
|
|
217
|
+
|
|
218
|
+
```mermaid
|
|
219
|
+
flowchart TD
|
|
220
|
+
A["argv"] --> B["args.ts<br/>strict parser"]
|
|
221
|
+
B --> C["cli.ts<br/>dispatch + exit codes"]
|
|
222
|
+
C --> D["context.ts<br/>network, env file, client"]
|
|
223
|
+
D --> E["env.ts<br/>dotenv subset + secret registry"]
|
|
224
|
+
D --> F["arkiv.ts<br/>client and wallet factories"]
|
|
225
|
+
F --> G["@arkiv-network/sdk"]
|
|
226
|
+
F --> H["viem<br/>transport + accounts"]
|
|
227
|
+
C --> I["commands/query.ts"]
|
|
228
|
+
C --> J["commands/schema.ts"]
|
|
229
|
+
C --> K["commands/entity.ts"]
|
|
230
|
+
C --> L["commands/create.ts"]
|
|
231
|
+
C --> M["commands/watch.ts"]
|
|
232
|
+
C --> N["commands/cost.ts"]
|
|
233
|
+
C --> O["commands/doctor.ts"]
|
|
234
|
+
C --> P["commands/init.ts"]
|
|
235
|
+
I --> Q["values.ts<br/>types, sizes, printable text"]
|
|
236
|
+
J --> Q
|
|
237
|
+
L --> Q
|
|
238
|
+
I --> R["render.ts<br/>tables and entity blocks"]
|
|
239
|
+
J --> R
|
|
240
|
+
M --> R
|
|
241
|
+
C --> S["ui.ts<br/>colour, sanitize, redact"]
|
|
242
|
+
S --> T["stdout / --json"]
|
|
243
|
+
```
|
|
244
|
+
|
|
245
|
+
The same flow as text, for anyone reading this in a terminal:
|
|
246
|
+
|
|
247
|
+
```
|
|
248
|
+
argv
|
|
249
|
+
└─ args.ts ──► cli.ts ──► context.ts ──► arkiv.ts ──► SDK ──► RPC
|
|
250
|
+
│ │
|
|
251
|
+
│ └─ env.ts (dotenv, secret registry, URL guard)
|
|
252
|
+
│
|
|
253
|
+
├─ commands/{init,doctor,query,entity,schema,create,watch,cost}
|
|
254
|
+
│ ├─ values.ts (attribute types and sizing)
|
|
255
|
+
│ └─ render.ts (tables, entity blocks)
|
|
256
|
+
│
|
|
257
|
+
└─ ui.ts (colour, sanitize, redact) ──► stdout
|
|
258
|
+
```
|
|
259
|
+
|
|
260
|
+
### Module map
|
|
261
|
+
|
|
262
|
+
| Module | Lines | Responsibility |
|
|
263
|
+
| --- | --- | --- |
|
|
264
|
+
| `commands/schema.ts` | 499 | Sampling, schema map, linter, atlas renderer |
|
|
265
|
+
| `cli.ts` | 396 | Flag declarations, dispatch, exit codes, help |
|
|
266
|
+
| `commands/create.ts` | 299 | Attribute parsing, limit checks, dry run, signing |
|
|
267
|
+
| `arkiv.ts` | 241 | Network table, client and wallet factories, notice capture |
|
|
268
|
+
| `commands/init.ts` | 226 | Scaffold templates and safe writes |
|
|
269
|
+
| `commands/doctor.ts` | 224 | Checks, fixes, JSON report |
|
|
270
|
+
| `commands/cost.ts` | 188 | Lifetime parsing, byte math, limit comparison |
|
|
271
|
+
| `ui.ts` | 176 | Colour, sanitize, redact, table primitives |
|
|
272
|
+
| `commands/watch.ts` | 147 | Windowed polling and diffing |
|
|
273
|
+
| `env.ts` | 146 | dotenv subset, mode checks, secret capture, URL guard |
|
|
274
|
+
| `values.ts` | 141 | Attribute constructors, sizing, safe display |
|
|
275
|
+
| `commands/query.ts` | 140 | Expression building, sort, paging |
|
|
276
|
+
| `args.ts` | 139 | Strict argument parser |
|
|
277
|
+
| `commands/entity.ts` | 109 | Key validation and single read |
|
|
278
|
+
| `render.ts` | 96 | Entity rendering |
|
|
279
|
+
| `context.ts` | 54 | Wiring: env, network, client |
|
|
280
|
+
| `limits.ts` | 47 | Protocol constants in one place |
|
|
281
|
+
|
|
282
|
+
About 3,400 lines including tests.
|
|
283
|
+
|
|
284
|
+
## How a few parts work
|
|
285
|
+
|
|
286
|
+
### Arguments are parsed strictly
|
|
287
|
+
|
|
288
|
+
The parser knows every flag, its type and whether it repeats. Anything else is a usage error with the valid flags printed. That is why `--limit` cannot arrive as a string, and why repeated `--set` flags accumulate instead of overwriting:
|
|
289
|
+
|
|
290
|
+
```ts
|
|
291
|
+
const put = (key: string, value: FlagValue): void => {
|
|
292
|
+
const previous = flags[key];
|
|
293
|
+
if (spec.repeat) {
|
|
294
|
+
const list = Array.isArray(previous) ? previous : previous === undefined ? [] : [previous];
|
|
295
|
+
flags[key] = [...list, value];
|
|
296
|
+
return;
|
|
297
|
+
}
|
|
298
|
+
if (previous !== undefined) throw new UsageError(`--${key} was given more than once`);
|
|
299
|
+
flags[key] = value;
|
|
300
|
+
};
|
|
301
|
+
```
|
|
302
|
+
|
|
303
|
+
### Query expressions are built with a guard, not concatenated blindly
|
|
304
|
+
|
|
305
|
+
```ts
|
|
306
|
+
export function buildExpression(options: QueryOptions): string {
|
|
307
|
+
const parts: string[] = [];
|
|
308
|
+
const raw = options.expression.trim();
|
|
309
|
+
if (raw) parts.push(`(${raw})`);
|
|
310
|
+
if (options.schema) {
|
|
311
|
+
if (!/^[A-Za-z0-9._/-]{1,64}$/.test(options.schema)) {
|
|
312
|
+
throw new UsageError("--schema contains characters that are not allowed in a schema name");
|
|
313
|
+
}
|
|
314
|
+
parts.push(`arkiv.schema = str('${options.schema}')`);
|
|
315
|
+
}
|
|
316
|
+
if (parts.length === 0) {
|
|
317
|
+
throw new UsageError(
|
|
318
|
+
'give a filter expression, for example: arkiv query "arkiv.schema = str(\'my-app-v1.post\')"',
|
|
319
|
+
);
|
|
320
|
+
}
|
|
321
|
+
const expression = parts.join(" AND ");
|
|
322
|
+
if (expression.length > 2000) throw new UsageError("expression is too long (2000 character limit)");
|
|
323
|
+
return expression;
|
|
324
|
+
}
|
|
325
|
+
```
|
|
326
|
+
|
|
327
|
+
`--schema` is checked against a character whitelist because that value is interpolated into the predicate, not bound. The free-form expression is passed through as written, since writing predicates is the point of the command.
|
|
328
|
+
|
|
329
|
+
### Redaction sits in front of stdout
|
|
330
|
+
|
|
331
|
+
```ts
|
|
332
|
+
export function redact(input: unknown): string {
|
|
333
|
+
let s = typeof input === "string" ? input : String(input ?? "");
|
|
334
|
+
for (const secret of secrets) {
|
|
335
|
+
if (secret) s = s.split(secret).join("[REDACTED]");
|
|
336
|
+
}
|
|
337
|
+
s = s
|
|
338
|
+
.split("\n")
|
|
339
|
+
.map((line) => (KEY_CONTEXT_RE.test(line) ? line.replace(KEY_RE, "0x[REDACTED]") : line))
|
|
340
|
+
.join("\n");
|
|
341
|
+
s = s.replace(ASSIGN_RE, (_m, p1: string) => `${p1}[REDACTED]`);
|
|
342
|
+
return s;
|
|
343
|
+
}
|
|
344
|
+
```
|
|
345
|
+
|
|
346
|
+
Registered secrets are replaced everywhere. Key-shaped strings are only redacted on lines that talk about key material, because entity keys are also 64 hex characters and redacting those would break the tool. The first version got this wrong and printed `key 0x[REDACTED]` for a valid entity.
|
|
347
|
+
|
|
348
|
+
Chain data takes a different path. `sanitize()` strips ANSI escapes and replaces control characters, so a hostile attribute value cannot move your cursor or repaint the screen.
|
|
349
|
+
|
|
350
|
+
### Watching uses a window, not a cursor
|
|
351
|
+
|
|
352
|
+
Arkiv's public RPC has no subscription stream, so the watcher reads a key-ordered window each tick and diffs it:
|
|
353
|
+
|
|
354
|
+
```ts
|
|
355
|
+
const next = new Map(current.map((entity) => [entity.$key, entity]));
|
|
356
|
+
for (const [key, entity] of next) {
|
|
357
|
+
if (previous.has(key)) continue;
|
|
358
|
+
out(`${green("+")} ${grey(stamp(entity))} ${summarise(entity)}`);
|
|
359
|
+
}
|
|
360
|
+
for (const [key, entity] of previous) {
|
|
361
|
+
if (next.has(key)) continue;
|
|
362
|
+
out(`${red("-")} ${grey(stamp(entity))} ${summarise(entity)}`);
|
|
363
|
+
}
|
|
364
|
+
previous = next;
|
|
365
|
+
```
|
|
366
|
+
|
|
367
|
+
Ordering by key keeps the window stable between ticks. If the window fills, the CLI prints a saturation warning rather than silently dropping entities that fell outside the range.
|
|
368
|
+
|
|
369
|
+
### SDK noise is captured, not printed
|
|
370
|
+
|
|
371
|
+
The SDK writes a rate-limit notice through `console.warn`. Left alone it lands in the middle of a table and breaks `--json`:
|
|
372
|
+
|
|
373
|
+
```ts
|
|
374
|
+
function quietWarn(): () => void {
|
|
375
|
+
const originals = { log: console.log, warn: console.warn, info: console.info, debug: console.debug };
|
|
376
|
+
const capture = (...args: unknown[]) => {
|
|
377
|
+
const text = args.map(String).join(" ");
|
|
378
|
+
if (text.includes("heavily rate limited")) {
|
|
379
|
+
capturedNotice = text.trim();
|
|
380
|
+
return;
|
|
381
|
+
}
|
|
382
|
+
originals.log(...args);
|
|
383
|
+
};
|
|
384
|
+
console.log = capture;
|
|
385
|
+
console.info = capture;
|
|
386
|
+
console.debug = capture;
|
|
387
|
+
console.warn = capture;
|
|
388
|
+
// returns a restore function, called in a finally block
|
|
389
|
+
}
|
|
390
|
+
```
|
|
391
|
+
|
|
392
|
+
The notice is replayed once at the end, after the results, so the information survives without corrupting the output.
|
|
393
|
+
|
|
394
|
+
## Security model
|
|
395
|
+
|
|
396
|
+
The threat model is a CLI that talks to a public network and prints data it did not author.
|
|
397
|
+
|
|
398
|
+
- **Credentials cannot be printed.** `.env` values are parsed without shell expansion (a hostile `.env` cannot execute anything), validated, and registered with a central redactor on read. Every output path runs through `sanitize`/`redact`, so key-shaped strings and registered secrets become `[REDACTED]`.
|
|
399
|
+
- **No key material on disk.** The CLI never writes a private key anywhere, never generates one, and never puts one in a URL.
|
|
400
|
+
- **Chain data is untrusted.** Attribute values are arbitrary bytes and can contain ANSI escapes or terminal control sequences. All chain-sourced strings are stripped before display.
|
|
401
|
+
- **RPC URLs are constrained.** Only `http`/`https` are accepted; anything else is refused before a client is built.
|
|
402
|
+
- **No implicit writes.** Writes require an explicit `create`, and `--dry-run` gives you the plan first. Nothing else in the CLI mutates the chain.
|
|
403
|
+
- **No destructive defaults.** `init` uses `wx` (fail if the file exists) and requires `--force` for a non-empty directory.
|
|
404
|
+
- **Rate-limit notices are captured, not printed over your output.** The SDK's public-endpoint warning is intercepted on every console channel and surfaced once, at the end, so it cannot corrupt `--json`.
|
|
405
|
+
|
|
406
|
+
Tests cover the parser, the dotenv reader, the redactor, the sanitizer, the query builder and the protocol-limit checks. 23 tests, run against the built output because that is what ships.
|
|
407
|
+
|
|
408
|
+
## The Schema Atlas
|
|
409
|
+
|
|
410
|
+
`schema --atlas out.html` writes a standalone page (no network calls, no scripts) listing every schema in the sample, its owners, attribute names and types. It is generated from live data, so it is a snapshot rather than a registry. `docs/schema-atlas.html` is one such snapshot of Rabdi, built from a 500 entity sample: 78 schemas.
|
|
411
|
+
|
|
412
|
+
## Development
|
|
413
|
+
|
|
414
|
+
```bash
|
|
415
|
+
npm install
|
|
416
|
+
npm run build # tsc -> dist/
|
|
417
|
+
npm test # build + node --test
|
|
418
|
+
npm run typecheck # no emit
|
|
419
|
+
npm run dev -- ... # run from source with tsx
|
|
420
|
+
```
|
|
421
|
+
|
|
422
|
+
Tests run against `dist/` on purpose. A green suite against source that fails after `tsc` is a class of bug worth excluding.
|
|
423
|
+
|
|
424
|
+
Repo layout:
|
|
425
|
+
|
|
426
|
+
```
|
|
427
|
+
arkiv-cli/
|
|
428
|
+
├─ src/
|
|
429
|
+
│ ├─ cli.ts command surface and flag declarations
|
|
430
|
+
│ ├─ args.ts strict argument parser
|
|
431
|
+
│ ├─ env.ts dotenv reading, mode checks, credential validation
|
|
432
|
+
│ ├─ arkiv.ts client construction, network table, quiet SDK warnings
|
|
433
|
+
│ ├─ context.ts wiring
|
|
434
|
+
│ ├─ values.ts attribute constructors, safe display, sizing
|
|
435
|
+
│ ├─ render.ts table and entity rendering
|
|
436
|
+
│ ├─ ui.ts colour, sanitize, redact
|
|
437
|
+
│ ├─ limits.ts protocol limits with fallbacks
|
|
438
|
+
│ └─ commands/ one module per command
|
|
439
|
+
├─ test/ node:test suites against dist/
|
|
440
|
+
├─ docs/
|
|
441
|
+
│ ├─ schema-atlas.html generated atlas
|
|
442
|
+
│ ├─ screenshots/ real runs, 2x PNG
|
|
443
|
+
│ └─ blog/ technical write-up
|
|
444
|
+
└─ dist/ build output, not committed
|
|
445
|
+
```
|
|
446
|
+
|
|
447
|
+
## Contributing
|
|
448
|
+
|
|
449
|
+
Fork, branch, write a failing test, make it pass, open a PR. Keep the diff small and put the reasoning in the PR body.
|
|
450
|
+
|
|
451
|
+
```bash
|
|
452
|
+
gh repo fork harishkotra/arkiv-cli --clone
|
|
453
|
+
cd arkiv-cli
|
|
454
|
+
npm install
|
|
455
|
+
npm test
|
|
456
|
+
git checkout -b feat/your-thing
|
|
457
|
+
```
|
|
458
|
+
|
|
459
|
+
Two rules matter more than style:
|
|
460
|
+
|
|
461
|
+
1. Nothing that touches a credential gets printed. Add a test if you touch output.
|
|
462
|
+
2. New protocol limits go in `limits.ts`, never inline.
|
|
463
|
+
|
|
464
|
+
A good PR shows the command working, includes the failure path, and says what you chose not to do.
|
|
465
|
+
|
|
466
|
+
### Good first issues
|
|
467
|
+
|
|
468
|
+
- `arkiv schema --diff a b`, printing field additions and removals between two schema names.
|
|
469
|
+
- `--format csv` for `query` and `schema`, alongside the existing `--json`.
|
|
470
|
+
- Shell completion generation for zsh, bash and fish.
|
|
471
|
+
- `arkiv export --schema <name> --out dir/`, writing one JSON file per entity and resuming by key.
|
|
472
|
+
- Colour handling: honour `NO_COLOR`, `FORCE_COLOR` and `isatty` in one place, with tests.
|
|
473
|
+
|
|
474
|
+
### Larger ideas
|
|
475
|
+
|
|
476
|
+
- `arkiv push <dir>`, reading a directory of JSON entities and writing them with a per-entity dry run and idempotency by key.
|
|
477
|
+
- `arkiv replay`, recording a query window to disk and replaying it offline, so CI can test against a fixed view of the chain.
|
|
478
|
+
- Schema drift in the atlas: show what changed in a namespace between two snapshots.
|
|
479
|
+
- `arkiv bench`, measuring read latency and write confirmation time per network.
|
|
480
|
+
- An MCP server exposing `query`, `schema` and `create --dry-run` to agents, reusing the validated core.
|
|
481
|
+
- A scheduled GitHub Action running `doctor` plus `schema --lint`, opening an issue when a namespace starts colliding.
|
|
482
|
+
|
|
483
|
+
### Conventions
|
|
484
|
+
|
|
485
|
+
- ES modules, NodeNext resolution, strict TypeScript. `noUncheckedIndexedAccess` is off on purpose; the casts at the SDK boundary are explicit.
|
|
486
|
+
- No new runtime dependency without a reason in the PR. Current runtime deps: `@arkiv-network/sdk`, `viem`.
|
|
487
|
+
- Command functions take `Context` and an options object, return `Promise<void>`, and print. They do not throw strings.
|
|
488
|
+
- Exit codes: `0` success, `1` runtime failure, `2` usage error.
|
|
489
|
+
|
|
490
|
+
## Troubleshooting
|
|
491
|
+
|
|
492
|
+
| Symptom | Cause | Fix |
|
|
493
|
+
| --- | --- | --- |
|
|
494
|
+
| `heavily rate limited` notice | Public endpoint | Set `ARKIV_ACCESS_KEY` in `.env` |
|
|
495
|
+
| `doctor` clock check fails | Local clock drift | Turn on automatic time sync |
|
|
496
|
+
| `entity` exits 2 | Malformed key | Keys are `0x` plus 64 hex characters |
|
|
497
|
+
| `create` reports the attribute limit | Over 32 attributes | Drop fields or split the entity |
|
|
498
|
+
| `create` reports a string limit | `str` over 128 bytes | Use `bytes` for long values |
|
|
499
|
+
| `schema` misses a namespace | Sample did not reach it | Query it by name, or raise `--sample` |
|
|
500
|
+
| `init` refuses to write | Directory is not empty | Pass `--force` or pick another path |
|
|
501
|
+
|
|
502
|
+
## Protocol facts the CLI relies on
|
|
503
|
+
|
|
504
|
+
- An entity carries at most 32 attributes, and a `str` attribute is at most 128 bytes. Long text belongs in the payload as `bytes`.
|
|
505
|
+
- Queries cannot match every entity. A predicate or schema filter is required, which is why an empty expression is a usage error rather than a table dump.
|
|
506
|
+
- `$createdAt` is not a sortable column.
|
|
507
|
+
- The public Rabdi endpoint is rate limited and shared. An access key is the difference between a demo and a workflow.
|
|
508
|
+
|
|
509
|
+
## License
|
|
510
|
+
|
|
511
|
+
MIT. See [LICENSE](LICENSE).
|
|
512
|
+
|
|
513
|
+
Built By [Harish Kotra](https://harishkotra.me) · [Checkout my other builds](https://dailybuild.xyz)
|
package/dist/args.d.ts
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A deliberately small argument parser.
|
|
3
|
+
*
|
|
4
|
+
* It rejects unknown flags instead of guessing, which turns a typo like
|
|
5
|
+
* `--limt 5` into a clear error rather than a confusing empty result.
|
|
6
|
+
*/
|
|
7
|
+
export type FlagType = "bool" | "string" | "number";
|
|
8
|
+
export type FlagSpec = Record<string, FlagType>;
|
|
9
|
+
export type FlagValue = string | number | boolean | Array<string | number>;
|
|
10
|
+
export interface Parsed {
|
|
11
|
+
positionals: string[];
|
|
12
|
+
flags: Record<string, FlagValue>;
|
|
13
|
+
}
|
|
14
|
+
export declare class UsageError extends Error {
|
|
15
|
+
}
|
|
16
|
+
export declare function parseArgs(argv: string[], spec: FlagSpec, aliases?: Record<string, string>): Parsed;
|
|
17
|
+
export declare function flagString(p: Parsed, name: string, fallback?: string): string;
|
|
18
|
+
export declare function flagNumber(p: Parsed, name: string, fallback: number): number;
|
|
19
|
+
export declare function flagBool(p: Parsed, name: string): boolean;
|
package/dist/args.js
ADDED
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A deliberately small argument parser.
|
|
3
|
+
*
|
|
4
|
+
* It rejects unknown flags instead of guessing, which turns a typo like
|
|
5
|
+
* `--limt 5` into a clear error rather than a confusing empty result.
|
|
6
|
+
*/
|
|
7
|
+
export class UsageError extends Error {
|
|
8
|
+
}
|
|
9
|
+
const SHORT = {
|
|
10
|
+
h: "help",
|
|
11
|
+
v: "version",
|
|
12
|
+
n: "limit",
|
|
13
|
+
j: "json",
|
|
14
|
+
s: "schema",
|
|
15
|
+
q: "quiet",
|
|
16
|
+
};
|
|
17
|
+
export function parseArgs(argv, spec, aliases = {}) {
|
|
18
|
+
const flags = {};
|
|
19
|
+
const positionals = [];
|
|
20
|
+
/* repeating a flag accumulates, so --set a=1 --set b=2 keeps both */
|
|
21
|
+
const put = (name, value) => {
|
|
22
|
+
const previous = flags[name];
|
|
23
|
+
if (previous === undefined) {
|
|
24
|
+
flags[name] = value;
|
|
25
|
+
}
|
|
26
|
+
else if (Array.isArray(previous)) {
|
|
27
|
+
previous.push(value);
|
|
28
|
+
}
|
|
29
|
+
else {
|
|
30
|
+
flags[name] = [previous, value];
|
|
31
|
+
}
|
|
32
|
+
};
|
|
33
|
+
const allowed = new Set(Object.keys(spec));
|
|
34
|
+
for (let i = 0; i < argv.length; i += 1) {
|
|
35
|
+
const token = argv[i];
|
|
36
|
+
if (token === "--") {
|
|
37
|
+
positionals.push(...argv.slice(i + 1));
|
|
38
|
+
break;
|
|
39
|
+
}
|
|
40
|
+
if (token.startsWith("--")) {
|
|
41
|
+
let name = token.slice(2);
|
|
42
|
+
let inline;
|
|
43
|
+
const eq = name.indexOf("=");
|
|
44
|
+
if (eq >= 0) {
|
|
45
|
+
inline = name.slice(eq + 1);
|
|
46
|
+
name = name.slice(0, eq);
|
|
47
|
+
}
|
|
48
|
+
let negated = false;
|
|
49
|
+
if (name.startsWith("no-")) {
|
|
50
|
+
negated = true;
|
|
51
|
+
name = name.slice(3);
|
|
52
|
+
}
|
|
53
|
+
name = aliases[name] ?? name;
|
|
54
|
+
if (!allowed.has(name)) {
|
|
55
|
+
throw new UsageError(`unknown option --${name}`);
|
|
56
|
+
}
|
|
57
|
+
const type = spec[name];
|
|
58
|
+
if (type === "bool") {
|
|
59
|
+
flags[name] = !negated;
|
|
60
|
+
continue;
|
|
61
|
+
}
|
|
62
|
+
if (negated)
|
|
63
|
+
throw new UsageError(`--no-${name} is not valid`);
|
|
64
|
+
let value = inline;
|
|
65
|
+
if (value === undefined) {
|
|
66
|
+
const next = argv[i + 1];
|
|
67
|
+
if (next === undefined || next.startsWith("-")) {
|
|
68
|
+
throw new UsageError(`--${name} needs a value`);
|
|
69
|
+
}
|
|
70
|
+
value = next;
|
|
71
|
+
i += 1;
|
|
72
|
+
}
|
|
73
|
+
if (type === "number") {
|
|
74
|
+
const n = Number(value);
|
|
75
|
+
if (!Number.isFinite(n))
|
|
76
|
+
throw new UsageError(`--${name} must be a number`);
|
|
77
|
+
put(name, n);
|
|
78
|
+
}
|
|
79
|
+
else {
|
|
80
|
+
put(name, value);
|
|
81
|
+
}
|
|
82
|
+
continue;
|
|
83
|
+
}
|
|
84
|
+
if (token.startsWith("-") && token.length > 1) {
|
|
85
|
+
const letters = token.slice(1);
|
|
86
|
+
for (let k = 0; k < letters.length; k += 1) {
|
|
87
|
+
const letter = letters[k];
|
|
88
|
+
const name = SHORT[letter] ?? aliases[letter] ?? letter;
|
|
89
|
+
if (!allowed.has(name))
|
|
90
|
+
throw new UsageError(`unknown option -${letter}`);
|
|
91
|
+
if (spec[name] === "bool") {
|
|
92
|
+
flags[name] = true;
|
|
93
|
+
}
|
|
94
|
+
else {
|
|
95
|
+
const rest = letters.slice(k + 1);
|
|
96
|
+
let value = rest;
|
|
97
|
+
if (!value) {
|
|
98
|
+
const next = argv[i + 1];
|
|
99
|
+
if (next === undefined)
|
|
100
|
+
throw new UsageError(`-${letter} needs a value`);
|
|
101
|
+
value = next;
|
|
102
|
+
i += 1;
|
|
103
|
+
}
|
|
104
|
+
put(name, spec[name] === "number" ? Number(value) : value);
|
|
105
|
+
break;
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
continue;
|
|
109
|
+
}
|
|
110
|
+
positionals.push(token);
|
|
111
|
+
}
|
|
112
|
+
return { positionals, flags };
|
|
113
|
+
}
|
|
114
|
+
export function flagString(p, name, fallback = "") {
|
|
115
|
+
const v = p.flags[name];
|
|
116
|
+
return typeof v === "string" ? v : fallback;
|
|
117
|
+
}
|
|
118
|
+
export function flagNumber(p, name, fallback) {
|
|
119
|
+
const v = p.flags[name];
|
|
120
|
+
return typeof v === "number" ? v : fallback;
|
|
121
|
+
}
|
|
122
|
+
export function flagBool(p, name) {
|
|
123
|
+
return p.flags[name] === true;
|
|
124
|
+
}
|