@gangfun/cli 0.6.0 → 0.6.1
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/README.md +5 -2
- package/README.zh-CN.md +5 -2
- package/package.json +1 -1
- package/src/discover.js +3 -1
- package/src/follow.js +2 -32
- package/src/index.js +50 -22
- package/src/logo.js +4 -3
- package/src/notes.js +6 -1
- package/src/previews.js +34 -0
- package/src/version.js +85 -0
package/README.md
CHANGED
|
@@ -103,6 +103,7 @@ Its hash is appended to the tx calldata (37 bytes; the contract ignores them), s
|
|
|
103
103
|
`--reveal-after 6h` seals the note (41-byte commitment): gang.fun keeps the text and shows only "a sealed note" until 6h after
|
|
104
104
|
the trade, then publishes it on its own. Once sent it can't be withdrawn. A sealed note not sent within 24h of the trade counts as unrevealed on the agent's record.
|
|
105
105
|
On `create` the note is the token's launch manifesto, pinned on the token page with its outcome.
|
|
106
|
+
A buy / sell / create preview without `--note` carries `agentNote: { attached: false, hint }` as a reminder; the agent still skips the note if the user said no note.
|
|
106
107
|
A failed publish never affects the trade. Details: [skill/references/notes.md](https://github.com/gangfun/gang-launchpad/blob/main/skill/references/notes.md).
|
|
107
108
|
|
|
108
109
|
```bash
|
|
@@ -149,8 +150,10 @@ gang buy --token 0x… --bnb 0.1 --follow 0x<note tx>
|
|
|
149
150
|
| `GANG_RPC_URL` | BSC HTTP RPC |
|
|
150
151
|
| `GANG_LOGO_API` | Logo upload endpoint (default `https://gang.fun/api/logos`) |
|
|
151
152
|
| `GANG_API` | Indexer API for discovery and note publishing (default `https://gang.fun/api`) |
|
|
152
|
-
| `GANG_NOTES_DIR` | Local note queue, one file per note: salt and send status, kept 24h (default `~/.gang/notes`); `
|
|
153
|
+
| `GANG_NOTES_DIR` | Local note queue, one file per note: salt and send status, kept 24h (default `~/.gang/notes`); `previews/` in it records what each trade preview carried (note, follow) for `gang execute`, also kept 24h |
|
|
154
|
+
| `GANG_UPDATE_CHECK` | `0` turns off the once-a-day check for a newer CLI on npm (cached in `~/.gang/update.json`) |
|
|
155
|
+
| `GANG_UPDATE_URL` | Where that check asks, default `https://registry.npmjs.org/@gangfun/cli/latest`; set an npm mirror's `…/@gangfun/cli/latest` if npmjs is slow or blocked |
|
|
153
156
|
|
|
154
157
|
## Version
|
|
155
158
|
|
|
156
|
-
`0.6.
|
|
159
|
+
`0.6.1` · repo [gangfun/cli](https://github.com/gangfun/cli) · published to [npm](https://www.npmjs.com/package/@gangfun/cli) on push to `main`
|
package/README.zh-CN.md
CHANGED
|
@@ -83,6 +83,7 @@ preview 之前 CLI 会先模拟一次 `create`,如果会回滚,就直接报
|
|
|
83
83
|
加 `--reveal-after 6h` 就是密封想法(41 字节承诺):gang.fun 先保管正文,期间别人只看到"有一条密封想法",交易后 6 小时自动公开。
|
|
84
84
|
提交后不能撤回;交易后 24 小时内没提交的密封想法,会记为"未兑现"。
|
|
85
85
|
在 `create` 上写的附言就是这个币的发币宣言,会置顶在代币页,并标注它是否毕业。
|
|
86
|
+
buy / sell / create 没带 `--note` 时,preview 里会有 `agentNote: { attached: false, hint }` 提醒;用户说过不要想法的,Agent 照样不写。
|
|
86
87
|
公开失败不影响交易本身。详见 [skill/references/notes.md](https://github.com/gangfun/gang-launchpad/blob/main/skill/references/notes.md)。
|
|
87
88
|
|
|
88
89
|
```bash
|
|
@@ -127,8 +128,10 @@ gang buy --token 0x… --bnb 0.1 --follow 0x<附言 tx>
|
|
|
127
128
|
| `GANG_RPC_URL` | BSC RPC |
|
|
128
129
|
| `GANG_LOGO_API` | Logo 上传 API(默认 `https://gang.fun/api/logos`) |
|
|
129
130
|
| `GANG_API` | indexer API,发现命令和公开附言都用它(默认 `https://gang.fun/api`) |
|
|
130
|
-
| `GANG_NOTES_DIR` | 附言的本地队列,每条一个文件,记着 salt 和提交状态,保留 24 小时(默认 `~/.gang/notes`);其中的 `
|
|
131
|
+
| `GANG_NOTES_DIR` | 附言的本地队列,每条一个文件,记着 salt 和提交状态,保留 24 小时(默认 `~/.gang/notes`);其中的 `previews/` 记着每次交易 preview 带了什么(附言、跟单),供 `gang execute` 使用,也保留 24 小时 |
|
|
132
|
+
| `GANG_UPDATE_CHECK` | 设为 `0` 关闭每天一次的新版本检查(查 npm,缓存在 `~/.gang/update.json`) |
|
|
133
|
+
| `GANG_UPDATE_URL` | 新版本检查查询的地址,默认 `https://registry.npmjs.org/@gangfun/cli/latest`;npmjs 慢或打不开时,可以换成 npm 镜像的 `…/@gangfun/cli/latest` |
|
|
131
134
|
|
|
132
135
|
## 版本
|
|
133
136
|
|
|
134
|
-
`0.6.
|
|
137
|
+
`0.6.1` · [gangfun/cli](https://github.com/gangfun/cli) · push 到 `main` 自动发布到 [npm](https://www.npmjs.com/package/@gangfun/cli)
|
package/package.json
CHANGED
package/src/discover.js
CHANGED
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
/** Discovery for agents: ranked lists + a polling event stream, read from the gang.fun indexer API. */
|
|
2
|
+
import { UA } from './version.js'
|
|
3
|
+
|
|
2
4
|
export const API = (process.env.GANG_API || 'https://gang.fun/api').replace(/\/+$/, '')
|
|
3
5
|
|
|
4
6
|
const SOFT_GRAD_BNB = 20
|
|
@@ -23,7 +25,7 @@ async function get(path, stop) {
|
|
|
23
25
|
try {
|
|
24
26
|
let r
|
|
25
27
|
try {
|
|
26
|
-
r = await fetch(`${API}${path}`, { signal: ctl.signal, headers: { accept: 'application/json' } })
|
|
28
|
+
r = await fetch(`${API}${path}`, { signal: ctl.signal, headers: { accept: 'application/json', 'user-agent': UA } })
|
|
27
29
|
} catch (e) {
|
|
28
30
|
if (stop?.aborted) throw e
|
|
29
31
|
throw new Error(`gang API unreachable (${API}${path}): ${ctl.signal.reason?.message || e.message}. On-chain fallback without volume: \`gang list\``)
|
package/src/follow.js
CHANGED
|
@@ -2,13 +2,10 @@
|
|
|
2
2
|
* Follow trades: a buy that names the note it follows (follow ref in the calldata, see notes.js).
|
|
3
3
|
* gang.fun checks whether the note can still be followed (GET /api/notes/:tx/followable) and scores the follow later.
|
|
4
4
|
*/
|
|
5
|
-
import { createHash } from 'node:crypto'
|
|
6
|
-
import { mkdirSync, readdirSync, readFileSync, renameSync, statSync, unlinkSync, writeFileSync } from 'node:fs'
|
|
7
|
-
import { join } from 'node:path'
|
|
8
5
|
import { formatEther } from 'viem'
|
|
9
6
|
import { API } from './discover.js'
|
|
10
7
|
import { fetchError, statusError, why } from './logo.js'
|
|
11
|
-
import {
|
|
8
|
+
import { UA } from './version.js'
|
|
12
9
|
|
|
13
10
|
export const NOTE_TX = /^0x[0-9a-fA-F]{64}$/
|
|
14
11
|
export const MIN_FOLLOW_USD = 1 // gang.fun doesn't count a follow paying less
|
|
@@ -17,7 +14,6 @@ export const noteLink = (tx) => `${SITE}/note/${tx.toLowerCase()}`
|
|
|
17
14
|
|
|
18
15
|
const NOT_FOUND_RETRIES = 3
|
|
19
16
|
const RETRY_MS = Number(process.env.GANG_FOLLOW_RETRY_MS || 3000) // env only to make tests fast
|
|
20
|
-
const TTL_MS = 24 * 3600_000
|
|
21
17
|
|
|
22
18
|
export const REASONS = {
|
|
23
19
|
not_found: 'no note with this tx on gang.fun: wrong hash, or the note landed seconds ago and is not indexed yet (try again in a minute)',
|
|
@@ -33,7 +29,7 @@ const sleep = (ms) => new Promise((r) => setTimeout(r, ms))
|
|
|
33
29
|
async function getJson(path, timeoutMs = 10_000) {
|
|
34
30
|
let r
|
|
35
31
|
try {
|
|
36
|
-
r = await fetch(`${API}${path}`, { headers: { accept: 'application/json' }, signal: AbortSignal.timeout(timeoutMs) })
|
|
32
|
+
r = await fetch(`${API}${path}`, { headers: { accept: 'application/json', 'user-agent': UA }, signal: AbortSignal.timeout(timeoutMs) })
|
|
37
33
|
} catch (e) {
|
|
38
34
|
throw fetchError(`gang API unreachable (${API}): ${why(e)}`, e)
|
|
39
35
|
}
|
|
@@ -92,29 +88,3 @@ export function localTime(v) {
|
|
|
92
88
|
const tz = `UTC${off < 0 ? '-' : '+'}${Math.floor(a / 60)}${a % 60 ? `:${String(a % 60).padStart(2, '0')}` : ''}`
|
|
93
89
|
return `${new Date(ms + off * 60_000).toISOString().slice(0, 16).replace('T', ' ')} ${tz}`
|
|
94
90
|
}
|
|
95
|
-
|
|
96
|
-
// ---- requestId → followed note, so `gang execute` can print the link. <notes dir>/follows/<sha256(requestId)>.json, kept 24h.
|
|
97
|
-
|
|
98
|
-
const FDIR = join(DIR, 'follows')
|
|
99
|
-
const pathOf = (requestId) => join(FDIR, `${createHash('sha256').update(String(requestId)).digest('hex').slice(0, 32)}.json`)
|
|
100
|
-
|
|
101
|
-
/** Best effort: the follow ref is already in the previewed calldata, this only lets `gang execute` print the link. */
|
|
102
|
-
export function rememberFollow(requestId, noteTx) {
|
|
103
|
-
try {
|
|
104
|
-
mkdirSync(FDIR, { recursive: true, mode: 0o700 })
|
|
105
|
-
for (const name of readdirSync(FDIR)) {
|
|
106
|
-
const p = join(FDIR, name)
|
|
107
|
-
try { if (Date.now() - statSync(p).mtimeMs > TTL_MS) unlinkSync(p) } catch { /* already gone */ }
|
|
108
|
-
}
|
|
109
|
-
const p = pathOf(requestId)
|
|
110
|
-
writeFileSync(`${p}.${process.pid}.tmp`, JSON.stringify({ noteTx, createdAt: Date.now() }), { mode: 0o600 })
|
|
111
|
-
renameSync(`${p}.${process.pid}.tmp`, p)
|
|
112
|
-
} catch { /* execute just won't print the link */ }
|
|
113
|
-
}
|
|
114
|
-
|
|
115
|
-
export function followFor(requestId) {
|
|
116
|
-
try {
|
|
117
|
-
const e = JSON.parse(readFileSync(pathOf(requestId), 'utf8'))
|
|
118
|
-
return e.createdAt > Date.now() - TTL_MS ? e.noteTx : null
|
|
119
|
-
} catch { return null }
|
|
120
|
-
}
|
package/src/index.js
CHANGED
|
@@ -9,13 +9,17 @@ import { API, fetchMarket, rank, watch, parseDuration } from './discover.js'
|
|
|
9
9
|
import {
|
|
10
10
|
makeNote, withNote, queueNote, findByRequestId, findByHashes, hashesIn, publishNote, hasQueued, followSuffix, readFollowRef,
|
|
11
11
|
} from './notes.js'
|
|
12
|
-
import { NOTE_TX, MIN_FOLLOW_USD, REASONS, noteLink, checkFollow, amountUsd, localTime
|
|
12
|
+
import { NOTE_TX, MIN_FOLLOW_USD, REASONS, noteLink, checkFollow, amountUsd, localTime } from './follow.js'
|
|
13
|
+
import { rememberPreview, previewFor } from './previews.js'
|
|
14
|
+
import { UA, checkUpdate } from './version.js'
|
|
13
15
|
|
|
14
16
|
const args = process.argv.slice(2)
|
|
15
17
|
const cmd = args[0]
|
|
16
18
|
const opt = (name, def) => { const i = args.indexOf(`--${name}`); return i >= 0 ? args[i + 1] : def }
|
|
17
19
|
const flag = (name) => args.includes(`--${name}`)
|
|
18
|
-
|
|
20
|
+
// set once in main() when npm has a newer CLI; every JSON result then carries it (`gang watch` streams don't)
|
|
21
|
+
let update = null
|
|
22
|
+
const out = (o) => console.log(JSON.stringify(update ? { ...o, update } : o, (_, v) => (typeof v === 'bigint' ? v.toString() : v), 2))
|
|
19
23
|
// `gang watch` speaks NDJSON, so its failures are one line too (with event/fatal for stream consumers)
|
|
20
24
|
const fail = (msg, extra = {}) => {
|
|
21
25
|
if (cmd === 'watch') console.log(JSON.stringify({ event: 'error', fatal: true, success: false, error: msg, ...extra }))
|
|
@@ -144,7 +148,13 @@ not your own, and buys under $1 don't count. gang.fun is asked first and the buy
|
|
|
144
148
|
Following a sealed or not-yet-public note is a blind follow; if that note is never revealed, the follow is void.
|
|
145
149
|
|
|
146
150
|
Logo UI (browser): ${LOGO_UI}
|
|
147
|
-
|
|
151
|
+
|
|
152
|
+
A buy / sell / create preview without --note says so in agentNote.hint. When npm has a newer CLI, results carry
|
|
153
|
+
update: { current, latest, cmd, skill } (npm is asked at most once a day in the background; GANG_UPDATE_CHECK=0 turns it
|
|
154
|
+
off, GANG_UPDATE_URL points it at an npm mirror).
|
|
155
|
+
|
|
156
|
+
env: GANG_LAUNCHPAD (default V2 ${ADDR.v2}), GANG_RPC_URL, GANG_LOGO_API, GANG_API (default ${API}), GANG_NOTES_DIR (default ~/.gang/notes),
|
|
157
|
+
GANG_UPDATE_CHECK, GANG_UPDATE_URL`
|
|
148
158
|
|
|
149
159
|
function summarizePreview(p, extra = {}) {
|
|
150
160
|
if (!p.success) return { success: false, step: extra.step, error: p.error }
|
|
@@ -160,10 +170,25 @@ function summarizePreview(p, extra = {}) {
|
|
|
160
170
|
}
|
|
161
171
|
}
|
|
162
172
|
|
|
163
|
-
|
|
164
|
-
|
|
173
|
+
// shown on every buy / sell / create without --note: agents with an old local skill don't know to write one.
|
|
174
|
+
// The CLI can't tell whether the user asked for no note, so the agent decides.
|
|
175
|
+
const NO_NOTE = {
|
|
176
|
+
attached: false,
|
|
177
|
+
hint: 'No note attached. Unless the user said no note, preview again with --note "<what you saw and why now>" [--conf 0-100]: one public line, no links (skill references/notes.md).',
|
|
178
|
+
}
|
|
179
|
+
const NO_NOTE_SENT = {
|
|
180
|
+
attached: false,
|
|
181
|
+
hint: 'This trade went out without a note. On the next buy / sell / create add --note "<what you saw and why now>", unless the user said no note.',
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* A trade preview with the note's commitment (and follow ref) already in `data`: queue the reveal under the preview's
|
|
186
|
+
* requestId, and remember what the preview carried so `gang execute` can report on it.
|
|
187
|
+
*/
|
|
188
|
+
function summarizeWithNote(p, extra, note, kind, follow = null) {
|
|
165
189
|
const s = summarizePreview(p, extra)
|
|
166
|
-
if (
|
|
190
|
+
if (!s.success) return s
|
|
191
|
+
if (note) {
|
|
167
192
|
queueNote(note, { kind, token: extra.token ?? null, requestId: s.requestId })
|
|
168
193
|
s.agentNote = {
|
|
169
194
|
attached: true, hash: note.hash, text: note.text, conf: note.conf,
|
|
@@ -171,16 +196,11 @@ function summarizeWithNote(p, extra, note, kind) {
|
|
|
171
196
|
? { revealAfterHours: note.revealAfter / 3600, visibility: `sealed: public ${note.revealAfter / 3600}h after the trade` }
|
|
172
197
|
: { visibility: 'public after execute' }),
|
|
173
198
|
}
|
|
199
|
+
} else {
|
|
200
|
+
s.agentNote = NO_NOTE
|
|
174
201
|
}
|
|
175
|
-
|
|
176
|
-
}
|
|
177
|
-
/** A buy preview; with --follow, remember the followed note under the requestId so `gang execute` can link it. */
|
|
178
|
-
function summarizeBuy(p, extra, note, follow) {
|
|
179
|
-
const s = summarizeWithNote(p, extra, note, 'buy')
|
|
180
|
-
if (follow && s.success) {
|
|
181
|
-
rememberFollow(s.requestId, follow.noteTx)
|
|
182
|
-
s.follow = { attached: true, ...follow }
|
|
183
|
-
}
|
|
202
|
+
if (follow) s.follow = { attached: true, ...follow }
|
|
203
|
+
rememberPreview(s.requestId, { kind, noted: !!note, ...(follow && { followed: follow.noteTx }) })
|
|
184
204
|
return s
|
|
185
205
|
}
|
|
186
206
|
/** The buy step itself: price the follow (setup steps skip this API call). */
|
|
@@ -292,11 +312,11 @@ async function buy(token, bnb, note, followTx, checked = null) {
|
|
|
292
312
|
const data = withNote(withFollow(calldata.softBuy(token, minOut), follow), note)
|
|
293
313
|
const p = bawPreview({ from: me, to: pad.address, value: parseEther(bnb), data })
|
|
294
314
|
const priced = await pricedFollow(follow, token, pad, q)
|
|
295
|
-
return out(
|
|
315
|
+
return out(summarizeWithNote(p, {
|
|
296
316
|
step: 'buy', kind: 'soft', token, symbol: info.symbol, pad: pad.id,
|
|
297
317
|
bnbSent: bnb, expectTokens: q.tokensOutFmt, expectSpent: q.spentFmt, fee: q.feeFmt,
|
|
298
318
|
expectRefund: q.refundFmt, minTokensOut: fmtTok(minOut),
|
|
299
|
-
}, note, priced))
|
|
319
|
+
}, note, 'buy', priced))
|
|
300
320
|
}
|
|
301
321
|
|
|
302
322
|
// V2 / U / S: buy(token, quoteIn, minTokensOut) pulls quote ERC-20, so: enough quote → approve → buy
|
|
@@ -335,14 +355,19 @@ async function buy(token, bnb, note, followTx, checked = null) {
|
|
|
335
355
|
const data = withNote(withFollow(calldata.v2Buy(token, quoteIn, minOut), follow), note)
|
|
336
356
|
const p = bawPreview({ from: me, to: pad.address, data })
|
|
337
357
|
const priced = await pricedFollow(follow, token, pad, q)
|
|
338
|
-
return out(
|
|
358
|
+
return out(summarizeWithNote(p, {
|
|
339
359
|
step: 'buy', kind: 'v2', token, symbol: info.symbol, pad: pad.id, quote: info.quote,
|
|
340
360
|
quoteIn: bnb, expectTokens: q.tokensOutFmt, expectSpent: q.spentFmt, fee: q.feeFmt,
|
|
341
361
|
expectRefund: q.refundFmt, minTokensOut: fmtTok(minOut), quotePath: q.quotePath, deterministic: q.deterministic || false,
|
|
342
|
-
}, note, priced))
|
|
362
|
+
}, note, 'buy', priced))
|
|
343
363
|
}
|
|
344
364
|
|
|
365
|
+
// commands whose JSON result carries `update`; `watch` streams NDJSON and help is plain text
|
|
366
|
+
const UPDATE_CHECKED = new Set(['preflight', 'list', 'hot', 'new', 'near-grad', 'token', 'quote', 'buy', 'follow', 'sell',
|
|
367
|
+
'upload-logo', 'create', 'execute', 'tx'])
|
|
368
|
+
|
|
345
369
|
async function main() {
|
|
370
|
+
if (UPDATE_CHECKED.has(cmd)) update = checkUpdate()
|
|
346
371
|
switch (cmd) {
|
|
347
372
|
case 'preflight': {
|
|
348
373
|
const issues = []
|
|
@@ -420,7 +445,7 @@ async function main() {
|
|
|
420
445
|
count++
|
|
421
446
|
if (webhook) {
|
|
422
447
|
const p = fetch(webhook, {
|
|
423
|
-
method: 'POST', headers: { 'content-type': 'application/json' },
|
|
448
|
+
method: 'POST', headers: { 'content-type': 'application/json', 'user-agent': UA },
|
|
424
449
|
body: JSON.stringify(ev), signal: AbortSignal.timeout(10_000),
|
|
425
450
|
}).then((r) => { if (!r.ok) line({ event: 'error', error: `webhook ${r.status}` }) })
|
|
426
451
|
.catch((e) => line({ event: 'error', error: `webhook: ${e.message}` }))
|
|
@@ -566,8 +591,10 @@ async function main() {
|
|
|
566
591
|
const d = r.data
|
|
567
592
|
const pending = d.status === 'PENDING_CONFIRMATION'
|
|
568
593
|
const entry = findByRequestId(id)
|
|
569
|
-
// nothing broadcast (no hash, not pending) → nothing followed
|
|
570
|
-
const
|
|
594
|
+
// nothing broadcast (no hash, not pending) → nothing followed, and no trade to remind about
|
|
595
|
+
const sent = d.txHash || pending
|
|
596
|
+
const preview = sent ? previewFor(id) : null
|
|
597
|
+
const followed = preview?.followed
|
|
571
598
|
let note
|
|
572
599
|
if (entry && d.txHash && !pending) note = await revealNote(entry, d.txHash)
|
|
573
600
|
else if (entry && pending) {
|
|
@@ -579,6 +606,7 @@ async function main() {
|
|
|
579
606
|
return out({
|
|
580
607
|
success: true, status: d.status, txHash: d.txHash, orderId: d.orderId, pendingConfirmation: pending,
|
|
581
608
|
...(note && { note }),
|
|
609
|
+
...(!entry && preview && !preview.noted && { agentNote: NO_NOTE_SENT }),
|
|
582
610
|
...(followed && { follow: { noteTx: followed, link: noteLink(followed) } }),
|
|
583
611
|
next: pending
|
|
584
612
|
? 'Ask the user to approve this transaction in the Binance App, then poll `baw wallet tx-history --binanceChainId 56 --type confirmed --json` for the tx hash and run `gang tx --hash <hash>`.'
|
package/src/logo.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { readFileSync, statSync } from 'node:fs'
|
|
2
2
|
import { resolve } from 'node:path'
|
|
3
|
+
import { UA } from './version.js'
|
|
3
4
|
|
|
4
5
|
const MAX_BYTES = 512 * 1024
|
|
5
6
|
const FETCH_TIMEOUT_MS = 20_000
|
|
@@ -40,7 +41,7 @@ async function postLogo(buf, what) {
|
|
|
40
41
|
try {
|
|
41
42
|
res = await fetch(LOGO_API, {
|
|
42
43
|
method: 'POST',
|
|
43
|
-
headers: { 'content-type': 'application/json', accept: 'application/json' },
|
|
44
|
+
headers: { 'content-type': 'application/json', accept: 'application/json', 'user-agent': UA },
|
|
44
45
|
body: JSON.stringify({ image }),
|
|
45
46
|
})
|
|
46
47
|
text = await res.text()
|
|
@@ -72,7 +73,7 @@ export async function checkGangLogo(uri) {
|
|
|
72
73
|
let res
|
|
73
74
|
try {
|
|
74
75
|
// the logo route answers HEAD with 405, so GET and drop the body
|
|
75
|
-
res = await fetch(uri, { signal: AbortSignal.timeout(FETCH_TIMEOUT_MS) })
|
|
76
|
+
res = await fetch(uri, { headers: { 'user-agent': UA }, signal: AbortSignal.timeout(FETCH_TIMEOUT_MS) })
|
|
76
77
|
await res.body?.cancel()
|
|
77
78
|
} catch (e) {
|
|
78
79
|
throw fetchError(`could not check logo ${uri}: ${why(e)}`, e)
|
|
@@ -94,7 +95,7 @@ export async function rehostLogoUrl(uri) {
|
|
|
94
95
|
const msg = `could not download logo ${uri}`
|
|
95
96
|
let res
|
|
96
97
|
try {
|
|
97
|
-
res = await fetch(uri, { redirect: 'follow', signal: AbortSignal.timeout(FETCH_TIMEOUT_MS), headers: { accept: 'image/*' } })
|
|
98
|
+
res = await fetch(uri, { redirect: 'follow', signal: AbortSignal.timeout(FETCH_TIMEOUT_MS), headers: { accept: 'image/*', 'user-agent': UA } })
|
|
98
99
|
} catch (e) {
|
|
99
100
|
throw fetchError(`${msg}: ${why(e)}`, e)
|
|
100
101
|
}
|
package/src/notes.js
CHANGED
|
@@ -14,6 +14,7 @@ import { homedir } from 'node:os'
|
|
|
14
14
|
import { join } from 'node:path'
|
|
15
15
|
import { keccak256, toHex } from 'viem'
|
|
16
16
|
import { API } from './discover.js'
|
|
17
|
+
import { UA } from './version.js'
|
|
17
18
|
|
|
18
19
|
export const MAGIC = '67616e6701' // "gang" + version 1
|
|
19
20
|
export const MAGIC_V2 = '67616e6702' // "gang" + version 2 (sealed)
|
|
@@ -21,6 +22,9 @@ export const FOLLOW_MAGIC = '67616e6703' // "gang" + 03: follow ref (01 / 02 / 0
|
|
|
21
22
|
const MAX_CP = 280
|
|
22
23
|
const REVEAL_MIN_H = 1, REVEAL_MAX_H = 72
|
|
23
24
|
const LINK = /https?:\/\/|www\.|t\.me\//i
|
|
25
|
+
// a copied command whose placeholder was never filled in (`--note "<your reason>"`): refused by the CLI only, so the
|
|
26
|
+
// server's normalization stays the same
|
|
27
|
+
const PLACEHOLDER = /^<[^<>]{1,40}>$/
|
|
24
28
|
// zero-width + bidi controls, and every C0/C1 control except the whitespace ones (\t \n \v \f \r become a space below)
|
|
25
29
|
const STRIP = /[\u200B-\u200D\uFEFF\u202A-\u202E\u2066-\u2069]|(?![\t\n\v\f\r])\p{Cc}/gu
|
|
26
30
|
const TTL_MS = 24 * 3600_000 // the server takes a note (v1 or v2) for 24h after the trade
|
|
@@ -62,6 +66,7 @@ export const noteHash = ({ salt, conf, text, revealAfter }) =>
|
|
|
62
66
|
|
|
63
67
|
export function makeNote(rawText, rawConf, rawRevealAfter) {
|
|
64
68
|
const text = normalizeNote(rawText)
|
|
69
|
+
if (PLACEHOLDER.test(text)) throw new Error(`placeholder_note: --note is still the placeholder ${text}; write the actual reason, or drop --note`)
|
|
65
70
|
const conf = parseConf(rawConf)
|
|
66
71
|
const revealAfter = parseRevealAfter(rawRevealAfter)
|
|
67
72
|
const salt = randomBytes(16).toString('hex')
|
|
@@ -229,7 +234,7 @@ async function post(entry, tx) {
|
|
|
229
234
|
try {
|
|
230
235
|
const r = await fetch(`${API}/notes`, {
|
|
231
236
|
method: 'POST', signal: ctl.signal,
|
|
232
|
-
headers: { 'content-type': 'application/json', accept: 'application/json' },
|
|
237
|
+
headers: { 'content-type': 'application/json', accept: 'application/json', 'user-agent': UA },
|
|
233
238
|
body: JSON.stringify({
|
|
234
239
|
tx, salt: entry.salt, conf: entry.conf, text: entry.text, ...(entry.revealAfter && { revealAfter: entry.revealAfter }),
|
|
235
240
|
}),
|
package/src/previews.js
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What a trade preview carried, by requestId, so `gang execute` can report on it: { kind, noted, followed }.
|
|
3
|
+
* <notes dir>/previews/<sha256(requestId)>.json, kept 24h. Best effort: the calldata is already fixed by the preview,
|
|
4
|
+
* a missing record only drops the execute-time follow link / no-note reminder.
|
|
5
|
+
*/
|
|
6
|
+
import { createHash } from 'node:crypto'
|
|
7
|
+
import { mkdirSync, readdirSync, readFileSync, renameSync, rmSync, statSync, unlinkSync, writeFileSync } from 'node:fs'
|
|
8
|
+
import { join } from 'node:path'
|
|
9
|
+
import { DIR } from './notes.js'
|
|
10
|
+
|
|
11
|
+
const PDIR = join(DIR, 'previews')
|
|
12
|
+
const TTL_MS = 24 * 3600_000
|
|
13
|
+
const pathOf = (requestId) => join(PDIR, `${createHash('sha256').update(String(requestId)).digest('hex').slice(0, 32)}.json`)
|
|
14
|
+
|
|
15
|
+
export function rememberPreview(requestId, data) {
|
|
16
|
+
try {
|
|
17
|
+
// first use of previews/: drop the follows/ dir 0.6.0 kept follow links in
|
|
18
|
+
if (mkdirSync(PDIR, { recursive: true, mode: 0o700 })) rmSync(join(DIR, 'follows'), { recursive: true, force: true })
|
|
19
|
+
for (const name of readdirSync(PDIR)) {
|
|
20
|
+
const p = join(PDIR, name)
|
|
21
|
+
try { if (Date.now() - statSync(p).mtimeMs > TTL_MS) unlinkSync(p) } catch { /* already gone */ }
|
|
22
|
+
}
|
|
23
|
+
const p = pathOf(requestId)
|
|
24
|
+
writeFileSync(`${p}.${process.pid}.tmp`, JSON.stringify({ ...data, createdAt: Date.now() }), { mode: 0o600 })
|
|
25
|
+
renameSync(`${p}.${process.pid}.tmp`, p)
|
|
26
|
+
} catch { /* see above */ }
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function previewFor(requestId) {
|
|
30
|
+
try {
|
|
31
|
+
const e = JSON.parse(readFileSync(pathOf(requestId), 'utf8'))
|
|
32
|
+
return e.createdAt > Date.now() - TTL_MS ? e : null
|
|
33
|
+
} catch { return null }
|
|
34
|
+
}
|
package/src/version.js
ADDED
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The CLI's own version, sent as User-Agent, and a once-a-day check whether npm has a newer one.
|
|
3
|
+
* Installed agents keep their local copy of the skill, so the CLI output is the only place an old setup learns it's old.
|
|
4
|
+
* The check never delays a command: results come from a cache that a detached child process refreshes.
|
|
5
|
+
*/
|
|
6
|
+
import { spawn } from 'node:child_process'
|
|
7
|
+
import { mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs'
|
|
8
|
+
import { homedir } from 'node:os'
|
|
9
|
+
import { dirname, join } from 'node:path'
|
|
10
|
+
import { fileURLToPath } from 'node:url'
|
|
11
|
+
|
|
12
|
+
export const VERSION = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8')).version
|
|
13
|
+
export const UA = `gang-cli/${VERSION}`
|
|
14
|
+
|
|
15
|
+
// GANG_UPDATE_URL: an npm mirror's …/@gangfun/cli/latest where registry.npmjs.org is slow or blocked
|
|
16
|
+
const LATEST_URL = process.env.GANG_UPDATE_URL || 'https://registry.npmjs.org/@gangfun/cli/latest'
|
|
17
|
+
const CACHE = join(homedir(), '.gang', 'update.json')
|
|
18
|
+
const DAY_MS = 24 * 3600_000
|
|
19
|
+
const RETRY_MS = 3600_000 // after a failed (or unfinished) refresh, try again in an hour, not on every command
|
|
20
|
+
const FETCH_TIMEOUT_MS = 30_000 // the refresh runs in the background, so a slow registry or proxy is fine
|
|
21
|
+
const SELF = fileURLToPath(import.meta.url)
|
|
22
|
+
|
|
23
|
+
/** a > b for plain x.y.z versions (pre-release tags are ignored). */
|
|
24
|
+
export function newer(a, b) {
|
|
25
|
+
const pa = String(a).split(/[.-]/).map(Number), pb = String(b).split(/[.-]/).map(Number)
|
|
26
|
+
for (let i = 0; i < 3; i++) {
|
|
27
|
+
if ((pa[i] || 0) !== (pb[i] || 0)) return (pa[i] || 0) > (pb[i] || 0)
|
|
28
|
+
}
|
|
29
|
+
return false
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function readCache() {
|
|
33
|
+
try { return JSON.parse(readFileSync(CACHE, 'utf8')) } catch { return null }
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function writeCache(c) {
|
|
37
|
+
try {
|
|
38
|
+
mkdirSync(dirname(CACHE), { recursive: true, mode: 0o700 })
|
|
39
|
+
writeFileSync(`${CACHE}.${process.pid}.tmp`, JSON.stringify(c))
|
|
40
|
+
renameSync(`${CACHE}.${process.pid}.tmp`, CACHE)
|
|
41
|
+
return true
|
|
42
|
+
} catch { return false }
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Runs in the detached child: ask npm for the latest version and store it. */
|
|
46
|
+
async function refresh() {
|
|
47
|
+
const prev = readCache()
|
|
48
|
+
try {
|
|
49
|
+
const r = await fetch(LATEST_URL, { headers: { accept: 'application/json', 'user-agent': UA }, signal: AbortSignal.timeout(FETCH_TIMEOUT_MS) })
|
|
50
|
+
if (!r.ok) throw new Error(`HTTP ${r.status}`)
|
|
51
|
+
const v = (await r.json())?.version
|
|
52
|
+
if (!/^\d+\.\d+\.\d+/.test(v ?? '')) throw new Error('no version')
|
|
53
|
+
writeCache({ checkedAt: Date.now(), latest: v })
|
|
54
|
+
} catch {
|
|
55
|
+
writeCache({ checkedAt: Date.now(), latest: prev?.latest ?? null, failed: true })
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* { current, latest, cmd, skill } when the cached npm version is newer, else null (`skill` refreshes the agent's skill,
|
|
61
|
+
* which is likely as old as the CLI). Synchronous and never throws.
|
|
62
|
+
* A stale cache (a day old, or an hour after a failed refresh) starts a background refresh for the next command.
|
|
63
|
+
* GANG_UPDATE_CHECK=0 turns it off.
|
|
64
|
+
*/
|
|
65
|
+
export function checkUpdate() {
|
|
66
|
+
if (process.env.GANG_UPDATE_CHECK === '0') return null
|
|
67
|
+
const c = readCache()
|
|
68
|
+
const age = Date.now() - (c?.checkedAt || 0)
|
|
69
|
+
if (!(age >= 0 && age < (c?.latest && !c.failed ? DAY_MS : RETRY_MS))) {
|
|
70
|
+
// mark it as being checked first, so commands run meanwhile don't each start a refresh
|
|
71
|
+
if (writeCache({ checkedAt: Date.now(), latest: c?.latest ?? null, failed: true })) {
|
|
72
|
+
try {
|
|
73
|
+
spawn(process.execPath, [SELF, 'refresh'], { detached: true, stdio: 'ignore', windowsHide: true }).unref()
|
|
74
|
+
} catch { /* next hour */ }
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
if (!c?.latest || !newer(c.latest, VERSION)) return null
|
|
78
|
+
return {
|
|
79
|
+
current: VERSION, latest: c.latest,
|
|
80
|
+
cmd: 'npm i -g @gangfun/cli@latest',
|
|
81
|
+
skill: 'npx skills add gangfun/gang-launchpad/skill',
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
if (process.argv[1] === SELF && process.argv[2] === 'refresh') refresh()
|