@inversealtruism/cairn-cli 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 +76 -0
- package/dist/cli.js +243 -0
- package/dist/lib/api.js +48 -0
- package/dist/lib/config.js +10 -0
- package/dist/lib/item.js +26 -0
- package/dist/lib/ui.js +121 -0
- package/package.json +25 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 InverseAltruism
|
|
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
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
# cairn-cli
|
|
2
|
+
|
|
3
|
+
A command-line client for a Cairn signal board on Compute Substrate. Browse what the community is
|
|
4
|
+
backing, and with a token, propose or support items, all from your terminal.
|
|
5
|
+
|
|
6
|
+
Cairn is a fee-weighted "paid attention" board: people spend CSD to surface what should be built,
|
|
7
|
+
fixed, or funded. `cairn-cli` is a thin HTTP client for a Cairn instance. Browsing needs no `csd`
|
|
8
|
+
binary and no key files.
|
|
9
|
+
|
|
10
|
+
## Install
|
|
11
|
+
|
|
12
|
+
```bash
|
|
13
|
+
npm install -g @inversealtruism/cairn-cli
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
Or run it without installing:
|
|
17
|
+
|
|
18
|
+
```bash
|
|
19
|
+
npx @inversealtruism/cairn-cli ls
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
Or build from source:
|
|
23
|
+
|
|
24
|
+
```bash
|
|
25
|
+
git clone https://github.com/InverseAltruism/cairn-cli
|
|
26
|
+
cd cairn-cli
|
|
27
|
+
npm install
|
|
28
|
+
npm run build
|
|
29
|
+
npm link
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
## Use
|
|
33
|
+
|
|
34
|
+
```bash
|
|
35
|
+
cairn # help
|
|
36
|
+
cairn domains # list categories + open domains
|
|
37
|
+
cairn ls csd:tools # browse a category (or any open domain, or: cairn ls)
|
|
38
|
+
cairn ls --window trending # trending / 7d / 30d / all
|
|
39
|
+
cairn ls --sort quadratic # lens: totalWeight|quadratic|repWeight|conviction|supporterCount|createdHeight
|
|
40
|
+
cairn watch # live auto-refreshing board
|
|
41
|
+
cairn recent # recent proposals and support
|
|
42
|
+
cairn show <id> # item detail and integrity check
|
|
43
|
+
cairn verify <id> # recompute the content hash and check it
|
|
44
|
+
cairn ls --json # machine-readable output
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
Posting needs a token from the board operator:
|
|
48
|
+
|
|
49
|
+
```bash
|
|
50
|
+
export CAIRN_TOKEN=… # the instance's write token
|
|
51
|
+
cairn propose --domain csd:features --title "Wallet GUI" --body "A graphical wallet…" --link https://…
|
|
52
|
+
cairn support <id> --fee 5000000 --score 90 --confidence 80
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
## Configuration (environment variables)
|
|
56
|
+
|
|
57
|
+
| Variable | Default | Purpose |
|
|
58
|
+
|---|---|---|
|
|
59
|
+
| `CAIRN_API` | `https://cairn-substrate.com` | the board to talk to (use your own, e.g. `http://127.0.0.1:7777`) |
|
|
60
|
+
| `CAIRN_TOKEN` | – | required only to post (propose or support) |
|
|
61
|
+
| `CAIRN_RPC` | – | optional csd node RPC; enables fully trustless `verify` by recomputing the hash and confirming the one on-chain |
|
|
62
|
+
|
|
63
|
+
## How it works
|
|
64
|
+
|
|
65
|
+
- `browse`, `show`, `recent`, and `watch` read the board's public API.
|
|
66
|
+
- `verify` fetches an item, recomputes `sha256(canonical content)` locally, and if `CAIRN_RPC` is set,
|
|
67
|
+
confirms that hash is the one committed on-chain. You trust the math, not the server.
|
|
68
|
+
- `propose` and `support` post through the instance, which records the item and submits the on-chain
|
|
69
|
+
proposal or attestation. Fees go to miners. Support is a paid demand signal, not a payment to the
|
|
70
|
+
author.
|
|
71
|
+
|
|
72
|
+
Fees are in base units (1 CSD = 1e8). Minimums are 0.25 CSD to propose and 0.05 CSD to attest.
|
|
73
|
+
|
|
74
|
+
## License
|
|
75
|
+
|
|
76
|
+
MIT
|
package/dist/cli.js
ADDED
|
@@ -0,0 +1,243 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// cairn — command-line client for a Cairn signal board on Compute Substrate.
|
|
3
|
+
// Reads are public; posting needs CAIRN_TOKEN. Config via env: CAIRN_API, CAIRN_TOKEN, CAIRN_RPC.
|
|
4
|
+
import { CAIRN_API, MIN_FEE_PROPOSE, MIN_FEE_ATTEST, CSD_PER_COIN, csdToCoins } from "./lib/config.js";
|
|
5
|
+
import * as api from "./lib/api.js";
|
|
6
|
+
import { buildCommitment } from "./lib/item.js";
|
|
7
|
+
import { c, banner, bannerAnimated, rule, badge, bar, csd as csdFmt, ok, warn, err, key as kdim, pad, spinner, sleep, isTty, clearScreen } from "./lib/ui.js";
|
|
8
|
+
function parse(argv) {
|
|
9
|
+
const _ = [];
|
|
10
|
+
const flags = {};
|
|
11
|
+
const multi = {};
|
|
12
|
+
for (let i = 0; i < argv.length; i++) {
|
|
13
|
+
const a = argv[i];
|
|
14
|
+
if (a.startsWith("--")) {
|
|
15
|
+
const k = a.slice(2);
|
|
16
|
+
const next = argv[i + 1];
|
|
17
|
+
if (next === undefined || next.startsWith("--"))
|
|
18
|
+
flags[k] = true;
|
|
19
|
+
else {
|
|
20
|
+
flags[k] = next;
|
|
21
|
+
(multi[k] ??= []).push(next);
|
|
22
|
+
i++;
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
else
|
|
26
|
+
_.push(a);
|
|
27
|
+
}
|
|
28
|
+
return { _, flags, multi };
|
|
29
|
+
}
|
|
30
|
+
const age = (sec) => {
|
|
31
|
+
if (!sec)
|
|
32
|
+
return "—";
|
|
33
|
+
const d = Math.max(0, Math.floor(Date.now() / 1000) - sec);
|
|
34
|
+
return d < 3600 ? `${Math.floor(d / 60)}m` : d < 86400 ? `${Math.floor(d / 3600)}h` : `${Math.floor(d / 86400)}d`;
|
|
35
|
+
};
|
|
36
|
+
// Lenses: each is one transparent reading of the same on-chain data (matches the web
|
|
37
|
+
// UI). The board returns all of these precomputed; we just sort client-side by one.
|
|
38
|
+
const LENS = {
|
|
39
|
+
totalWeight: "CSD support (raw)", quadratic: "quadratic", repWeight: "reputation-weighted",
|
|
40
|
+
conviction: "conviction", supporterCount: "# supporters", createdHeight: "newest",
|
|
41
|
+
};
|
|
42
|
+
function printRows(items, sort = "totalWeight") {
|
|
43
|
+
if (!items.length) {
|
|
44
|
+
console.log(c.gray(" (no items here)"));
|
|
45
|
+
return;
|
|
46
|
+
}
|
|
47
|
+
const max = items[0]?.[sort] || items[0]?.totalWeight || 1;
|
|
48
|
+
items.slice(0, 25).forEach((r, i) => {
|
|
49
|
+
// annotate only lenses whose value isn't already shown in the row (raw + supporters are)
|
|
50
|
+
const lens = !["totalWeight", "supporterCount"].includes(sort) && r[sort] != null
|
|
51
|
+
? c.gray(" · " + (sort === "createdHeight" ? "h" + r[sort] : csdFmt(r[sort]) + " " + sort)) : "";
|
|
52
|
+
console.log("");
|
|
53
|
+
console.log(` ${c.magenta(c.bold("#" + (i + 1)))} ${c.white(c.bold(r.title))} ${badge(r.source)}${r.sealed ? " " + c.gray(r.revealed ? "🔓 revealed" : "🔒 sealed") : ""}`);
|
|
54
|
+
console.log(` ${bar(r[sort] || r.totalWeight, max)} ${csdFmt(r.totalWeight)} ${c.gray("·")} ${c.green(String(r.supporterCount))} ${c.gray("supporters · score " + r.avgScore + " · " + age(r.createdTime) + " ago")}${lens}`);
|
|
55
|
+
console.log(c.gray(` ${r.domain} · id ${String(r.id).slice(0, 22)}…`));
|
|
56
|
+
});
|
|
57
|
+
}
|
|
58
|
+
async function cmdList(a) {
|
|
59
|
+
const domain = a._[1] ?? "all";
|
|
60
|
+
const window = String(a.flags.window ?? "all");
|
|
61
|
+
const sort = LENS[String(a.flags.sort ?? "")] ? String(a.flags.sort) : "totalWeight";
|
|
62
|
+
const r = await api.apiBoard(domain, window);
|
|
63
|
+
let items = r.items ?? [];
|
|
64
|
+
if (sort !== "totalWeight")
|
|
65
|
+
items = items.slice().sort((x, y) => (y[sort] || 0) - (x[sort] || 0));
|
|
66
|
+
if (a.flags.json) {
|
|
67
|
+
console.log(JSON.stringify(items, null, 2));
|
|
68
|
+
return;
|
|
69
|
+
}
|
|
70
|
+
banner();
|
|
71
|
+
rule(`${domain} · ${LENS[sort]} · ${window} · ${CAIRN_API.replace(/^https?:\/\//, "")}`);
|
|
72
|
+
printRows(items, sort);
|
|
73
|
+
}
|
|
74
|
+
async function cmdWatch(a) {
|
|
75
|
+
const domain = a._[1] ?? "all";
|
|
76
|
+
const window = String(a.flags.window ?? "trending");
|
|
77
|
+
if (!isTty) {
|
|
78
|
+
printRows((await api.apiBoard(domain, window)).items);
|
|
79
|
+
return;
|
|
80
|
+
}
|
|
81
|
+
process.stdout.write("\x1b[?25l");
|
|
82
|
+
process.on("SIGINT", () => { process.stdout.write("\x1b[?25h\n"); process.exit(0); });
|
|
83
|
+
for (;;) {
|
|
84
|
+
const r = await api.apiBoard(domain, window).catch(() => ({ items: [] }));
|
|
85
|
+
clearScreen();
|
|
86
|
+
banner();
|
|
87
|
+
rule(`watch · ${domain} · ${window} · ${new Date().toLocaleTimeString()}`);
|
|
88
|
+
printRows(r.items);
|
|
89
|
+
console.log(c.gray("\n ") + c.green("●") + c.gray(" live · refreshes every 5s · Ctrl+C to exit"));
|
|
90
|
+
await sleep(5000);
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
async function cmdRecent() {
|
|
94
|
+
const r = await api.apiActivity();
|
|
95
|
+
banner();
|
|
96
|
+
rule("recent activity");
|
|
97
|
+
for (const ev of r.activity ?? []) {
|
|
98
|
+
const verb = ev.type === "support" ? c.green("◈ supported") : c.cyan("✎ proposed ");
|
|
99
|
+
console.log(` ${verb} ${c.white(String(ev.item).slice(0, 42))} ${c.gray("· " + age(ev.time) + " ago · " + (ev.amount / 1e8) + " CSD")}`);
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
async function cmdShow(a) {
|
|
103
|
+
const id = a._[1];
|
|
104
|
+
if (!id) {
|
|
105
|
+
console.log(warn("usage: cairn show <id>"));
|
|
106
|
+
return;
|
|
107
|
+
}
|
|
108
|
+
const r = await api.apiItem(id).catch(() => null);
|
|
109
|
+
if (!r || !r.ok) {
|
|
110
|
+
console.log(err("not found"));
|
|
111
|
+
return;
|
|
112
|
+
}
|
|
113
|
+
const it = r.item;
|
|
114
|
+
rule(it.title);
|
|
115
|
+
console.log(` ${badge(it.source)} ${c.gray("·")} ${c.cyan(it.domain)}`);
|
|
116
|
+
console.log(`\n ${c.white(it.body)}\n`);
|
|
117
|
+
if (it.links?.length)
|
|
118
|
+
console.log(` ${kdim("links")} ${it.links.map((l) => c.cyan(l)).join(", ")}`);
|
|
119
|
+
const total = (r.supports ?? []).reduce((x, s) => x + s.weight, 0);
|
|
120
|
+
console.log(` ${kdim("support")} ${csdFmt(total)} ${c.gray("from")} ${c.green(String(new Set((r.supports ?? []).map((s) => s.attester)).size))} ${c.gray("supporters")}`);
|
|
121
|
+
console.log(` ${kdim("proposer")} ${c.gray(it.proposerHandle || it.proposer)}`);
|
|
122
|
+
console.log(` ${kdim("hash")} ${c.magenta(it.payloadHash)}`);
|
|
123
|
+
console.log(` ${kdim("integrity")} ${r.integrityOk ? ok("content matches commitment") : err("MISMATCH")}`);
|
|
124
|
+
}
|
|
125
|
+
async function cmdVerify(a) {
|
|
126
|
+
const id = a._[1];
|
|
127
|
+
if (!id) {
|
|
128
|
+
console.log(warn("usage: cairn verify <id>"));
|
|
129
|
+
return;
|
|
130
|
+
}
|
|
131
|
+
const sp = spinner("fetching + recomputing");
|
|
132
|
+
const r = await api.apiItem(id).catch(() => null);
|
|
133
|
+
if (!r || !r.ok) {
|
|
134
|
+
sp.stop();
|
|
135
|
+
console.log(err("not found"));
|
|
136
|
+
return;
|
|
137
|
+
}
|
|
138
|
+
const it = r.item;
|
|
139
|
+
const { payloadHash } = buildCommitment({ v: 1, domain: it.domain, title: it.title, body: it.body, links: it.links ?? [] });
|
|
140
|
+
const chain = await api.chainProposal(it.id);
|
|
141
|
+
sp.stop();
|
|
142
|
+
console.log(`${kdim("recomputed")} ${c.magenta(payloadHash)}`);
|
|
143
|
+
console.log(`${kdim("reported")} ${c.magenta(it.payloadHash)}`);
|
|
144
|
+
const contentOk = payloadHash.toLowerCase() === String(it.payloadHash).toLowerCase();
|
|
145
|
+
if (chain?.payload_hash) {
|
|
146
|
+
console.log(`${kdim("on-chain")} ${c.magenta(chain.payload_hash)}`);
|
|
147
|
+
console.log(contentOk && String(chain.payload_hash).toLowerCase() === payloadHash.toLowerCase()
|
|
148
|
+
? ok("VERIFIED — content matches the on-chain commitment (trustless, via CAIRN_RPC)")
|
|
149
|
+
: err("MISMATCH"));
|
|
150
|
+
}
|
|
151
|
+
else {
|
|
152
|
+
console.log(contentOk ? ok("content matches the reported commitment") + c.gray(" (set CAIRN_RPC to also check the chain directly)") : err("content does NOT match the reported hash"));
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
async function cmdPropose(a) {
|
|
156
|
+
const domain = String(a.flags.domain ?? "");
|
|
157
|
+
const title = String(a.flags.title ?? "");
|
|
158
|
+
const body = String(a.flags.body ?? "");
|
|
159
|
+
const links = a.multi.link ?? [];
|
|
160
|
+
if (!domain || !title) {
|
|
161
|
+
console.log(warn("usage: ") + c.cyan("cairn propose --domain csd:features --title <t> --body <b> [--link <url>] [--fee <base>]"));
|
|
162
|
+
return;
|
|
163
|
+
}
|
|
164
|
+
const fee = Number(a.flags.fee ?? MIN_FEE_PROPOSE);
|
|
165
|
+
const sp = spinner("posting to Cairn");
|
|
166
|
+
try {
|
|
167
|
+
const r = await api.apiPropose({ domain, title, body, links, fee: Number.isFinite(fee) && fee >= MIN_FEE_PROPOSE ? Math.floor(fee) : MIN_FEE_PROPOSE });
|
|
168
|
+
sp.stop();
|
|
169
|
+
console.log(r.ok ? ok(`proposed ${c.cyan(r.id)}`) : err(r.error || "failed"));
|
|
170
|
+
}
|
|
171
|
+
catch (e) {
|
|
172
|
+
sp.stop();
|
|
173
|
+
console.log(err(e.message));
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
async function cmdSupport(a) {
|
|
177
|
+
const id = a._[1];
|
|
178
|
+
if (!id) {
|
|
179
|
+
console.log(warn("usage: ") + c.cyan("cairn support <id> --fee <base> [--score 0-100] [--confidence 0-100]"));
|
|
180
|
+
return;
|
|
181
|
+
}
|
|
182
|
+
const fee = Number(a.flags.fee ?? MIN_FEE_ATTEST);
|
|
183
|
+
const sp = spinner("posting support to Cairn");
|
|
184
|
+
try {
|
|
185
|
+
const r = await api.apiSupport({ id, fee: Number.isFinite(fee) && fee >= MIN_FEE_ATTEST ? Math.floor(fee) : MIN_FEE_ATTEST, score: Number(a.flags.score ?? 75), confidence: Number(a.flags.confidence ?? 60) });
|
|
186
|
+
sp.stop();
|
|
187
|
+
console.log(r.ok ? ok(`supported ${c.cyan(r.id)}`) : err(r.error || "failed"));
|
|
188
|
+
}
|
|
189
|
+
catch (e) {
|
|
190
|
+
sp.stop();
|
|
191
|
+
console.log(err(e.message));
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
async function cmdDomains() {
|
|
195
|
+
const r = await api.apiDomains();
|
|
196
|
+
banner();
|
|
197
|
+
rule("categories");
|
|
198
|
+
for (const dom of r.domains ?? [])
|
|
199
|
+
console.log(` ${c.cyan(pad(dom.key, 20))} ${c.white(dom.title)} ${c.gray(dom.count != null ? "(" + dom.count + ")" : "")}`);
|
|
200
|
+
// open domains: anyone can create one by proposing into it (cairn ls <domain> works for any).
|
|
201
|
+
const disc = r.discovered ?? [];
|
|
202
|
+
if (disc.length) {
|
|
203
|
+
console.log(c.gray("\n open domains (created by proposing into them):"));
|
|
204
|
+
for (const d of disc)
|
|
205
|
+
console.log(` ${c.cyan(pad(d.key, 20))} ${c.gray((d.count != null ? d.count + " items" : "") + (d.totalWeight ? " · " + csdToCoins(d.totalWeight) + " CSD" : ""))}`);
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
async function help() {
|
|
209
|
+
await bannerAnimated();
|
|
210
|
+
const cmd = (n, args, d) => console.log(` ${c.cyan(pad(n, 9))} ${c.gray(pad(args, 44))} ${c.dim(d)}`);
|
|
211
|
+
console.log(c.bold(" commands"));
|
|
212
|
+
cmd("domains", "", "list categories + open domains");
|
|
213
|
+
cmd("ls", "[domain] --window trending|7d|30d|all --sort <lens>", "browse the board (+ --json)");
|
|
214
|
+
cmd("top", "[domain]", "alias for ls");
|
|
215
|
+
cmd("watch", "[domain]", "live auto-refreshing board");
|
|
216
|
+
cmd("recent", "", "recent proposals + support");
|
|
217
|
+
cmd("show", "<id>", "item detail + integrity");
|
|
218
|
+
cmd("verify", "<id>", "recompute hash, check vs chain");
|
|
219
|
+
cmd("propose", "--domain <d> --title <t> --body <b>", "post an item (needs CAIRN_TOKEN)");
|
|
220
|
+
cmd("support", "<id> --fee <base>", "back an item (needs CAIRN_TOKEN)");
|
|
221
|
+
console.log(c.gray("\n lenses (--sort): " + Object.keys(LENS).join(" · ")));
|
|
222
|
+
console.log(c.gray(` api: ${CAIRN_API} · 1 CSD = ${CSD_PER_COIN} base · propose ≥ ${MIN_FEE_PROPOSE} · attest ≥ ${MIN_FEE_ATTEST}`));
|
|
223
|
+
console.log(c.gray(" config: CAIRN_API (board url) · CAIRN_TOKEN (instance write token, to post) · CAIRN_RPC (trustless verify)"));
|
|
224
|
+
console.log(c.gray(" display: honors NO_COLOR · --no-color · --no-anim · TERM=dumb (color/animation auto-off when piped)"));
|
|
225
|
+
console.log(c.gray(" keyless non-custodial posting + sealed claims: use the Cairn Wallet (browser extension)."));
|
|
226
|
+
}
|
|
227
|
+
async function main() {
|
|
228
|
+
const a = parse(process.argv.slice(2));
|
|
229
|
+
switch (a._[0]) {
|
|
230
|
+
case "domains": return cmdDomains();
|
|
231
|
+
case "ls":
|
|
232
|
+
case "list":
|
|
233
|
+
case "top": return cmdList(a);
|
|
234
|
+
case "watch": return cmdWatch(a);
|
|
235
|
+
case "recent": return cmdRecent();
|
|
236
|
+
case "show": return cmdShow(a);
|
|
237
|
+
case "verify": return cmdVerify(a);
|
|
238
|
+
case "propose": return cmdPropose(a);
|
|
239
|
+
case "support": return cmdSupport(a);
|
|
240
|
+
default: return help();
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
main().catch((e) => { console.error(err(String(e?.message ?? e))); process.exit(1); });
|
package/dist/lib/api.js
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
// Thin client for a Cairn instance's HTTP API. Reads are public; writes need a token.
|
|
2
|
+
import { CAIRN_API, CAIRN_TOKEN, CAIRN_RPC } from "./config.js";
|
|
3
|
+
async function req(path, init) {
|
|
4
|
+
let res;
|
|
5
|
+
try {
|
|
6
|
+
res = await fetch(`${CAIRN_API}${path}`, { signal: AbortSignal.timeout(8000), ...init });
|
|
7
|
+
}
|
|
8
|
+
catch (e) {
|
|
9
|
+
throw new Error(`cannot reach ${CAIRN_API} (${e?.message ?? e}) — set CAIRN_API to a running Cairn instance`);
|
|
10
|
+
}
|
|
11
|
+
if (res.status === 401)
|
|
12
|
+
throw new Error(`${CAIRN_API} is password-gated (401). Point CAIRN_API at a public instance or your own (e.g. http://127.0.0.1:7777)`);
|
|
13
|
+
if (!res.ok)
|
|
14
|
+
throw new Error(`API ${path} → HTTP ${res.status}`);
|
|
15
|
+
return res.json();
|
|
16
|
+
}
|
|
17
|
+
function writeReq(path, body) {
|
|
18
|
+
if (!CAIRN_TOKEN)
|
|
19
|
+
throw new Error("posting needs a token — set CAIRN_TOKEN (the operator's write token)");
|
|
20
|
+
return req(path, {
|
|
21
|
+
method: "POST",
|
|
22
|
+
headers: { "content-type": "application/json", "x-cairn-token": CAIRN_TOKEN },
|
|
23
|
+
body: JSON.stringify(body),
|
|
24
|
+
});
|
|
25
|
+
}
|
|
26
|
+
export const apiHealth = () => req("/api/health");
|
|
27
|
+
export const apiDomains = () => req("/api/domains");
|
|
28
|
+
export const apiStats = () => req("/api/stats");
|
|
29
|
+
export const apiBoard = (domain, window) => req(`/api/board?domain=${encodeURIComponent(domain)}&window=${encodeURIComponent(window)}`);
|
|
30
|
+
export const apiItem = (id) => req(`/api/item/${encodeURIComponent(id)}`);
|
|
31
|
+
export const apiActivity = () => req("/api/activity");
|
|
32
|
+
export const apiPropose = (body) => writeReq("/api/propose", body);
|
|
33
|
+
export const apiSupport = (body) => writeReq("/api/support", body);
|
|
34
|
+
// optional: query a raw csd node RPC (for trustless verify)
|
|
35
|
+
export async function chainProposal(id) {
|
|
36
|
+
if (!CAIRN_RPC)
|
|
37
|
+
return null;
|
|
38
|
+
try {
|
|
39
|
+
const r = await fetch(`${CAIRN_RPC}/proposal/${id}`, { signal: AbortSignal.timeout(6000) });
|
|
40
|
+
if (!r.ok)
|
|
41
|
+
return null;
|
|
42
|
+
const j = await r.json();
|
|
43
|
+
return j.proposal ?? j ?? null;
|
|
44
|
+
}
|
|
45
|
+
catch {
|
|
46
|
+
return null;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
// cairn-cli configuration (env-overridable).
|
|
2
|
+
export const CAIRN_API = (process.env.CAIRN_API ?? "https://cairn-substrate.com").replace(/\/+$/, "");
|
|
3
|
+
export const CAIRN_TOKEN = process.env.CAIRN_TOKEN ?? ""; // required only for posting
|
|
4
|
+
export const CAIRN_RPC = process.env.CAIRN_RPC ?? ""; // optional: a csd node RPC, enables trustless verify
|
|
5
|
+
export const CSD_PER_COIN = 100_000_000;
|
|
6
|
+
export const MIN_FEE_PROPOSE = 25_000_000; // 0.25 CSD
|
|
7
|
+
export const MIN_FEE_ATTEST = 5_000_000; // 0.05 CSD
|
|
8
|
+
export function csdToCoins(base) {
|
|
9
|
+
return (base / CSD_PER_COIN).toLocaleString(undefined, { maximumFractionDigits: 4 });
|
|
10
|
+
}
|
package/dist/lib/item.js
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
// Canonical item record + integrity commitment.
|
|
2
|
+
// payload_hash = sha256(canonical JSON of the content record). No salt: the board
|
|
3
|
+
// is public, so the hash is for tamper-evidence, not secrecy.
|
|
4
|
+
import { createHash } from "node:crypto";
|
|
5
|
+
// Deterministic JSON: recursively sorted keys, no insignificant whitespace.
|
|
6
|
+
export function stableStringify(value) {
|
|
7
|
+
if (value === null || typeof value !== "object")
|
|
8
|
+
return JSON.stringify(value);
|
|
9
|
+
if (Array.isArray(value))
|
|
10
|
+
return `[${value.map(stableStringify).join(",")}]`;
|
|
11
|
+
const obj = value;
|
|
12
|
+
const keys = Object.keys(obj).sort();
|
|
13
|
+
return `{${keys.map((k) => `${JSON.stringify(k)}:${stableStringify(obj[k])}`).join(",")}}`;
|
|
14
|
+
}
|
|
15
|
+
export function sha256Hex(data) {
|
|
16
|
+
return "0x" + createHash("sha256").update(data).digest("hex");
|
|
17
|
+
}
|
|
18
|
+
export function buildCommitment(content) {
|
|
19
|
+
const canonical = stableStringify(content);
|
|
20
|
+
return { canonical, payloadHash: sha256Hex(canonical) };
|
|
21
|
+
}
|
|
22
|
+
// Verify that displayed content matches an on-chain payload_hash.
|
|
23
|
+
export function verifyContent(content, onchainHash) {
|
|
24
|
+
const { payloadHash } = buildCommitment(content);
|
|
25
|
+
return payloadHash.toLowerCase() === onchainHash.toLowerCase();
|
|
26
|
+
}
|
package/dist/lib/ui.js
ADDED
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
// Cairn CLI theme — matches the website + wallet: black-and-white terminal with a
|
|
2
|
+
// single phosphor-green accent, sharp edges, a blinking cursor and a fast decode
|
|
3
|
+
// "boot" reveal. Zero deps.
|
|
4
|
+
//
|
|
5
|
+
// clig.dev compliance:
|
|
6
|
+
// • color off when stdout/stderr isn't a TTY, NO_COLOR is set, TERM=dumb, or
|
|
7
|
+
// --no-color is passed (CAIRN_COLOR=1 forces it on for demos)
|
|
8
|
+
// • no animation unless interactive + color (CAIRN_NO_ANIM / --no-anim opt out)
|
|
9
|
+
// • the spinner (transient progress) goes to STDERR so stdout stays pipeable
|
|
10
|
+
const argv = process.argv;
|
|
11
|
+
const TTY = !!process.stdout.isTTY || process.env.CAIRN_COLOR === "1";
|
|
12
|
+
const NO_COLOR = (!!process.env.NO_COLOR && process.env.NO_COLOR !== "") || process.env.TERM === "dumb"
|
|
13
|
+
|| process.env.CAIRN_NO_COLOR === "1" || argv.includes("--no-color");
|
|
14
|
+
const COLOR = (TTY || process.env.CAIRN_COLOR === "1") && !NO_COLOR;
|
|
15
|
+
const TRUE = COLOR && /^(truecolor|24bit)$/.test(process.env.COLORTERM ?? "");
|
|
16
|
+
const ANIM = COLOR && !!process.stdout.isTTY && !process.env.CAIRN_NO_ANIM && !argv.includes("--no-anim");
|
|
17
|
+
// truecolor with a 256-color fallback, so the exact wallet/site palette renders when
|
|
18
|
+
// the terminal supports it and degrades gracefully when it doesn't.
|
|
19
|
+
const fg = (rgb, c256) => (s) => (COLOR ? `\x1b[${TRUE ? rgb : c256}m${s}\x1b[0m` : String(s));
|
|
20
|
+
const sgr = (code) => (s) => (COLOR ? `\x1b[${code}m${s}\x1b[0m` : String(s));
|
|
21
|
+
// palette — phosphor green #6ee7a0 accent, off-white text, dim grays, soft red #ff6b6b
|
|
22
|
+
const GREEN = fg("38;2;110;231;160", "38;5;114");
|
|
23
|
+
const WHITE = fg("38;2;230;230;230", "97");
|
|
24
|
+
const GRAY = fg("38;2;125;125;125", "38;5;245");
|
|
25
|
+
const FAINT = fg("38;2;90;90;90", "38;5;240");
|
|
26
|
+
const RED = fg("38;2;255;107;107", "38;5;203");
|
|
27
|
+
export const c = {
|
|
28
|
+
green: GREEN, white: WHITE, gray: GRAY, faint: FAINT, red: RED,
|
|
29
|
+
dim: sgr("2"), bold: sgr("1"),
|
|
30
|
+
// legacy role names remapped to the monochrome+green theme so call sites stay put:
|
|
31
|
+
cyan: GREEN, // accents · ids · bars → phosphor green
|
|
32
|
+
magenta: WHITE, // emphasis · rank · wordmark → bright white
|
|
33
|
+
amber: GRAY, // demo / muted → gray
|
|
34
|
+
};
|
|
35
|
+
const W = 64;
|
|
36
|
+
const TAG = "paid-attention board · compute substrate";
|
|
37
|
+
export function banner() {
|
|
38
|
+
if (!TTY)
|
|
39
|
+
return; // chrome is for humans; piped output skips it
|
|
40
|
+
console.log(` ${c.white(c.bold("▓▒░ CAIRN"))} ${c.green("▮")} ${c.gray(TAG)}`);
|
|
41
|
+
rule();
|
|
42
|
+
}
|
|
43
|
+
export function rule(label = "") {
|
|
44
|
+
if (label) {
|
|
45
|
+
const tail = Math.max(0, W - label.length - 5);
|
|
46
|
+
console.log(`${c.green("──")} ${c.white(c.bold(label))} ${c.faint("─".repeat(tail))}`);
|
|
47
|
+
}
|
|
48
|
+
else
|
|
49
|
+
console.log(c.faint("─".repeat(W)));
|
|
50
|
+
}
|
|
51
|
+
export function badge(source) {
|
|
52
|
+
if (source === "chain")
|
|
53
|
+
return c.green("⛓ on-chain");
|
|
54
|
+
if (source === "demo")
|
|
55
|
+
return c.gray("◆ demo");
|
|
56
|
+
if (source === "draft")
|
|
57
|
+
return c.gray("✎ draft");
|
|
58
|
+
return c.gray(source);
|
|
59
|
+
}
|
|
60
|
+
// proportional bar — green fill, faint track (matches the site's support bars)
|
|
61
|
+
export function bar(value, max, width = 16) {
|
|
62
|
+
const frac = max > 0 ? Math.min(1, value / max) : 0;
|
|
63
|
+
const fill = Math.round(frac * width);
|
|
64
|
+
return c.green("█".repeat(fill)) + c.faint("░".repeat(Math.max(0, width - fill)));
|
|
65
|
+
}
|
|
66
|
+
export function csd(base) {
|
|
67
|
+
return c.green(`${(base / 1e8).toLocaleString(undefined, { maximumFractionDigits: 4 })}`) + c.gray(" CSD");
|
|
68
|
+
}
|
|
69
|
+
export function ok(s) { return c.green("✓ ") + s; }
|
|
70
|
+
export function warn(s) { return c.gray("⚠ ") + s; }
|
|
71
|
+
export function err(s) { return c.red("✗ ") + s; }
|
|
72
|
+
export function key(k) { return c.gray(k); }
|
|
73
|
+
export function pad(s, n) {
|
|
74
|
+
const visible = s.replace(/\x1b\[[0-9;]*m/g, "").length;
|
|
75
|
+
return s + " ".repeat(Math.max(0, n - visible));
|
|
76
|
+
}
|
|
77
|
+
export const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
78
|
+
export const isTty = TTY;
|
|
79
|
+
// Phosphor braille spinner for network/async work. Writes to STDERR (clig.dev: keep
|
|
80
|
+
// stdout clean for piping) and is a no-op when output isn't an interactive terminal.
|
|
81
|
+
export function spinner(label) {
|
|
82
|
+
if (!ANIM)
|
|
83
|
+
return { stop: (final) => { if (final)
|
|
84
|
+
console.log(final); } };
|
|
85
|
+
const frames = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
|
|
86
|
+
let i = 0;
|
|
87
|
+
const t = setInterval(() => process.stderr.write(`\r${c.green(frames[i++ % frames.length])} ${c.gray(label)} `), 80);
|
|
88
|
+
return {
|
|
89
|
+
stop: (final) => {
|
|
90
|
+
clearInterval(t);
|
|
91
|
+
process.stderr.write("\r\x1b[K");
|
|
92
|
+
if (final)
|
|
93
|
+
console.log(final);
|
|
94
|
+
},
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
// Fast decode-reveal of the CAIRN wordmark: scramble glyphs resolve L→R into white
|
|
98
|
+
// letters with a green cursor — the site's "boot" feel. ~360ms, and only on an
|
|
99
|
+
// interactive TTY (static banner otherwise, so help piped to a file isn't garbage).
|
|
100
|
+
export async function bannerAnimated() {
|
|
101
|
+
if (!ANIM) {
|
|
102
|
+
banner();
|
|
103
|
+
return;
|
|
104
|
+
}
|
|
105
|
+
const word = "CAIRN";
|
|
106
|
+
const pool = "▖▗▘▙▚▛▜▝▞▟01#%/\\<>=".split("");
|
|
107
|
+
const rnd = () => pool[Math.floor(Math.random() * pool.length)];
|
|
108
|
+
for (let step = 0; step <= word.length; step++) {
|
|
109
|
+
let s = "";
|
|
110
|
+
for (let i = 0; i < word.length; i++)
|
|
111
|
+
s += i < step ? c.white(c.bold(word[i])) : c.green(rnd());
|
|
112
|
+
process.stdout.write(`\r ${c.gray("▓▒░")} ${s} ${c.green("▋")} ${c.gray("· " + TAG)} `);
|
|
113
|
+
await sleep(60);
|
|
114
|
+
}
|
|
115
|
+
process.stdout.write(`\r ${c.gray("▓▒░")} ${c.white(c.bold(word))} ${c.green("▮")} ${c.gray("· " + TAG)}\n`);
|
|
116
|
+
rule();
|
|
117
|
+
}
|
|
118
|
+
export function clearScreen() {
|
|
119
|
+
if (TTY)
|
|
120
|
+
process.stdout.write("\x1b[2J\x1b[H");
|
|
121
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@inversealtruism/cairn-cli",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Command-line client for a Cairn signal board on Compute Substrate — browse, propose, and back what to build.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"bin": { "cairn": "dist/cli.js" },
|
|
7
|
+
"files": ["dist", "README.md"],
|
|
8
|
+
"engines": { "node": ">=20" },
|
|
9
|
+
"repository": { "type": "git", "url": "git+https://github.com/InverseAltruism/cairn-cli.git" },
|
|
10
|
+
"homepage": "https://cairn-substrate.com",
|
|
11
|
+
"publishConfig": { "access": "public" },
|
|
12
|
+
"scripts": {
|
|
13
|
+
"build": "tsc",
|
|
14
|
+
"dev": "tsx src/cli.ts",
|
|
15
|
+
"test": "tsc && node test/e2e.mjs",
|
|
16
|
+
"prepare": "tsc"
|
|
17
|
+
},
|
|
18
|
+
"keywords": ["cairn", "compute-substrate", "cli"],
|
|
19
|
+
"license": "MIT",
|
|
20
|
+
"devDependencies": {
|
|
21
|
+
"@types/node": "^22.10.0",
|
|
22
|
+
"tsx": "^4.19.2",
|
|
23
|
+
"typescript": "^5.7.2"
|
|
24
|
+
}
|
|
25
|
+
}
|