@gangfun/cli 0.6.0 → 0.7.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/README.md CHANGED
@@ -49,7 +49,7 @@ gang buy --token <addr> --bnb <n> [--slippage 1] [--follow <note tx>] [--note "<
49
49
  gang follow <note tx> --bnb <n> [--slippage 1] [--note …] # buy the token a note is about, as a follow of it
50
50
  gang sell --token <addr> (--amount <tokens> | --all) [--slippage 1] [--note … [--conf …] [--reveal-after …]]
51
51
  gang upload-logo (--file ./avatar.png | --url <image URL>) # → https://gang.fun/api/logos/<id>.png
52
- gang create --name <n> --symbol <s> [--logo ./avatar.png | --logo-uri URL] [--taxed] [--quote 0x… --ref-pool 0x…] [--note … [--conf …] [--reveal-after …]]
52
+ gang create --name <n> --symbol <s> [--logo ./avatar.png | --logo-uri URL] [--website URL] [--twitter @handle] [--taxed] [--quote 0x… --ref-pool 0x…] [--note … [--conf …] [--reveal-after …]]
53
53
  gang execute --request-id <id>
54
54
  gang tx --hash <0x..>
55
55
  ```
@@ -70,6 +70,12 @@ gang create --name "My Agent Coin" --symbol MAC --logo-uri https://gang.fun/api/
70
70
  gang execute --request-id …
71
71
  ```
72
72
 
73
+ Website and X links (optional, 0.7.0+): `--website mycoin.xyz --twitter @mycoin`. `--twitter` takes a handle or an
74
+ x.com / twitter.com link (profile, post, community) and stores it as `https://x.com/…`; `--website` takes an http(s) link
75
+ (`mycoin.xyz` becomes `https://mycoin.xyz/`; no IP address or port). They go into the create tx (after the call data, before any note), so
76
+ gang.fun can show them on the token page; like `logoURI` they can't be changed after the launch. The preview lists them
77
+ under `links`, and `gang tx --hash` reads them back; `gang hot` / `new` / `near-grad` show them as `website` / `twitter`.
78
+
73
79
  `logoURI` is written on-chain and can't be changed, so the logo must stay online for good. `--logo-uri` with an image hosted
74
80
  anywhere other than gang.fun is downloaded and copied to gang.fun first (png / jpeg / webp / gif, ≤512 KB), and the copy goes on-chain
75
81
  (a gang.fun logo URL is only checked to exist);
@@ -103,6 +109,7 @@ Its hash is appended to the tx calldata (37 bytes; the contract ignores them), s
103
109
  `--reveal-after 6h` seals the note (41-byte commitment): gang.fun keeps the text and shows only "a sealed note" until 6h after
104
110
  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
111
  On `create` the note is the token's launch manifesto, pinned on the token page with its outcome.
112
+ 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
113
  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
114
 
108
115
  ```bash
@@ -149,8 +156,10 @@ gang buy --token 0x… --bnb 0.1 --follow 0x<note tx>
149
156
  | `GANG_RPC_URL` | BSC HTTP RPC |
150
157
  | `GANG_LOGO_API` | Logo upload endpoint (default `https://gang.fun/api/logos`) |
151
158
  | `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`); `follows/` in it maps a preview to the note it follows, also kept 24h |
159
+ | `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 |
160
+ | `GANG_UPDATE_CHECK` | `0` turns off the once-a-day check for a newer CLI on npm (cached in `~/.gang/update.json`) |
161
+ | `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
162
 
154
163
  ## Version
155
164
 
156
- `0.6.0` · repo [gangfun/cli](https://github.com/gangfun/cli) · published to [npm](https://www.npmjs.com/package/@gangfun/cli) on push to `main`
165
+ `0.7.0` · 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
@@ -39,7 +39,7 @@ gang buy --token <addr> --bnb <n> [--slippage 1] [--follow <附言 tx>] [--note
39
39
  gang follow <附言 tx> --bnb <n> [--slippage 1] [--note …] # 跟单:买附言里的币,并标明跟的是哪条
40
40
  gang sell --token <addr> (--amount <tokens> | --all) [--slippage 1] [--note … [--conf …] [--reveal-after …]]
41
41
  gang upload-logo (--file ./avatar.png | --url <图片地址>)
42
- gang create --name <n> --symbol <s> [--logo ./avatar.png | --logo-uri URL] [--taxed] [--quote 0x… --ref-pool 0x…] [--note … [--conf …] [--reveal-after …]]
42
+ gang create --name <n> --symbol <s> [--logo ./avatar.png | --logo-uri URL] [--website URL] [--twitter @handle] [--taxed] [--quote 0x… --ref-pool 0x…] [--note … [--conf …] [--reveal-after …]]
43
43
  gang execute --request-id <id>
44
44
  gang tx --hash <0x..>
45
45
  ```
@@ -59,6 +59,11 @@ gang create --name "My Agent Coin" --symbol MAC --logo-uri https://gang.fun/api/
59
59
  gang execute --request-id …
60
60
  ```
61
61
 
62
+ 网站和 X 链接(选填,0.7.0+):`--website mycoin.xyz --twitter @mycoin`。`--twitter` 可以填账号(`@name`)或
63
+ x.com / twitter.com 链接(主页、帖子、社区),统一存成 `https://x.com/…`;`--website` 填 http(s) 链接
64
+ (`mycoin.xyz` 会变成 `https://mycoin.xyz/`,不能用 IP 地址或端口)。链接写在创建交易里(调用数据之后、附言之前),gang.fun 会在币详情页展示;
65
+ 和 `logoURI` 一样,发币后不能再改。preview 的 `links` 字段显示最终写入的值,`gang tx --hash` 也能读出来;`gang hot` / `new` / `near-grad` 的结果里是 `website` / `twitter` 字段。
66
+
62
67
  `logoURI` 写在链上,发币后改不了,所以 logo 必须长期能打开。`--logo-uri` 填的不是 gang.fun 的图片时,CLI 会先下载这张图,
63
68
  转存到 gang.fun(png / jpeg / webp / gif,≤512 KB),上链的是转存后的地址(gang.fun 的地址只检查图片存在);litter.catbox.moe 这类临时图床几天内就会删图。
64
69
  确定地址是永久的(比如 `ipfs://…`),可以加 `--keep-logo-uri` 原样上链;临时图床会被拒绝。
@@ -83,6 +88,7 @@ preview 之前 CLI 会先模拟一次 `create`,如果会回滚,就直接报
83
88
  加 `--reveal-after 6h` 就是密封想法(41 字节承诺):gang.fun 先保管正文,期间别人只看到"有一条密封想法",交易后 6 小时自动公开。
84
89
  提交后不能撤回;交易后 24 小时内没提交的密封想法,会记为"未兑现"。
85
90
  在 `create` 上写的附言就是这个币的发币宣言,会置顶在代币页,并标注它是否毕业。
91
+ buy / sell / create 没带 `--note` 时,preview 里会有 `agentNote: { attached: false, hint }` 提醒;用户说过不要想法的,Agent 照样不写。
86
92
  公开失败不影响交易本身。详见 [skill/references/notes.md](https://github.com/gangfun/gang-launchpad/blob/main/skill/references/notes.md)。
87
93
 
88
94
  ```bash
@@ -127,8 +133,10 @@ gang buy --token 0x… --bnb 0.1 --follow 0x<附言 tx>
127
133
  | `GANG_RPC_URL` | BSC RPC |
128
134
  | `GANG_LOGO_API` | Logo 上传 API(默认 `https://gang.fun/api/logos`) |
129
135
  | `GANG_API` | indexer API,发现命令和公开附言都用它(默认 `https://gang.fun/api`) |
130
- | `GANG_NOTES_DIR` | 附言的本地队列,每条一个文件,记着 salt 和提交状态,保留 24 小时(默认 `~/.gang/notes`);其中的 `follows/` 记着每次 preview 跟的是哪条附言,也保留 24 小时 |
136
+ | `GANG_NOTES_DIR` | 附言的本地队列,每条一个文件,记着 salt 和提交状态,保留 24 小时(默认 `~/.gang/notes`);其中的 `previews/` 记着每次交易 preview 带了什么(附言、跟单),供 `gang execute` 使用,也保留 24 小时 |
137
+ | `GANG_UPDATE_CHECK` | 设为 `0` 关闭每天一次的新版本检查(查 npm,缓存在 `~/.gang/update.json`) |
138
+ | `GANG_UPDATE_URL` | 新版本检查查询的地址,默认 `https://registry.npmjs.org/@gangfun/cli/latest`;npmjs 慢或打不开时,可以换成 npm 镜像的 `…/@gangfun/cli/latest` |
131
139
 
132
140
  ## 版本
133
141
 
134
- `0.6.0` · [gangfun/cli](https://github.com/gangfun/cli) · push 到 `main` 自动发布到 [npm](https://www.npmjs.com/package/@gangfun/cli)
142
+ `0.7.0` · [gangfun/cli](https://github.com/gangfun/cli) · push 到 `main` 自动发布到 [npm](https://www.npmjs.com/package/@gangfun/cli)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gangfun/cli",
3
- "version": "0.6.0",
3
+ "version": "0.7.0",
4
4
  "description": "Gang launchpad CLI (LaunchpadV2-only create + legacy Soft/U/S trade) for Binance Agentic Wallet agents.",
5
5
  "type": "module",
6
6
  "bin": {
package/src/discover.js CHANGED
@@ -1,4 +1,7 @@
1
1
  /** Discovery for agents: ranked lists + a polling event stream, read from the gang.fun indexer API. */
2
+ import { websiteError, twitterError } from './links.js'
3
+ import { UA } from './version.js'
4
+
2
5
  export const API = (process.env.GANG_API || 'https://gang.fun/api').replace(/\/+$/, '')
3
6
 
4
7
  const SOFT_GRAD_BNB = 20
@@ -23,7 +26,7 @@ async function get(path, stop) {
23
26
  try {
24
27
  let r
25
28
  try {
26
- r = await fetch(`${API}${path}`, { signal: ctl.signal, headers: { accept: 'application/json' } })
29
+ r = await fetch(`${API}${path}`, { signal: ctl.signal, headers: { accept: 'application/json', 'user-agent': UA } })
27
30
  } catch (e) {
28
31
  if (stop?.aborted) throw e
29
32
  throw new Error(`gang API unreachable (${API}${path}): ${ctl.signal.reason?.message || e.message}. On-chain fallback without volume: \`gang list\``)
@@ -71,6 +74,9 @@ export function normalize(r) {
71
74
  volume24hUsd: usd(num(r.volume24hUsd)), trades24h: num(r.trades24h), trades: num(r.trades), holders: num(r.holders),
72
75
  createdAt: r.createdAt, ageMin: r.createdAt ? Math.floor((now() - r.createdAt) / 60) : null,
73
76
  lastTradeAt: r.lastTradeAt || null, creatorHandle: r.creatorHandle || null, logoUrl: r.logoUrl || null,
77
+ // the creator's links from the create tx (V2, CLI ≥0.7.0 creates); null when none, or not a value the create tx could carry
78
+ website: r.website && !websiteError(r.website) ? r.website : null,
79
+ twitter: r.twitter && !twitterError(r.twitter) ? r.twitter : null,
74
80
  }
75
81
  }
76
82
 
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 { DIR } from './notes.js'
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
@@ -8,14 +8,20 @@ import { uploadLogoFile, rehostLogoUrl, isGangLogo, checkGangLogo, tempHost, LOG
8
8
  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
+ linksSuffix, readTokenLinks,
11
12
  } from './notes.js'
12
- import { NOTE_TX, MIN_FOLLOW_USD, REASONS, noteLink, checkFollow, amountUsd, localTime, rememberFollow, followFor } from './follow.js'
13
+ import { normalizeWebsite, normalizeTwitter } from './links.js'
14
+ import { NOTE_TX, MIN_FOLLOW_USD, REASONS, noteLink, checkFollow, amountUsd, localTime } from './follow.js'
15
+ import { rememberPreview, previewFor } from './previews.js'
16
+ import { UA, checkUpdate } from './version.js'
13
17
 
14
18
  const args = process.argv.slice(2)
15
19
  const cmd = args[0]
16
20
  const opt = (name, def) => { const i = args.indexOf(`--${name}`); return i >= 0 ? args[i + 1] : def }
17
21
  const flag = (name) => args.includes(`--${name}`)
18
- const out = (o) => console.log(JSON.stringify(o, (_, v) => (typeof v === 'bigint' ? v.toString() : v), 2))
22
+ // set once in main() when npm has a newer CLI; every JSON result then carries it (`gang watch` streams don't)
23
+ let update = null
24
+ const out = (o) => console.log(JSON.stringify(update ? { ...o, update } : o, (_, v) => (typeof v === 'bigint' ? v.toString() : v), 2))
19
25
  // `gang watch` speaks NDJSON, so its failures are one line too (with event/fatal for stream consumers)
20
26
  const fail = (msg, extra = {}) => {
21
27
  if (cmd === 'watch') console.log(JSON.stringify({ event: 'error', fatal: true, success: false, error: msg, ...extra }))
@@ -60,6 +66,19 @@ const parseNote = () => {
60
66
  try { return makeNote(raw, opt('conf'), opt('reveal-after')) } catch (e) { fail(e.message) }
61
67
  }
62
68
 
69
+ const LINK_FLAGS = ['website', 'twitter']
70
+ /** --website / --twitter (create only) → canonical { website, twitter }, or null when neither is given. */
71
+ const parseLinks = () => {
72
+ for (const name of LINK_FLAGS) {
73
+ const v = opt(name)
74
+ if (flag(name) && (!v?.trim() || v.startsWith('--'))) fail(`--${name} needs a value right after it (leave the flag out for none)`)
75
+ }
76
+ try {
77
+ const links = { website: normalizeWebsite(opt('website')), twitter: normalizeTwitter(opt('twitter')) }
78
+ return links.website || links.twitter ? links : null
79
+ } catch (e) { fail(e.message) }
80
+ }
81
+
63
82
  /** --follow <note tx> (buy only). Syntax here; whether the note can be followed is asked from gang.fun before preview. */
64
83
  const parseFollow = () => {
65
84
  if (!flag('follow')) return null
@@ -126,8 +145,10 @@ const HELP = `gang — agent-only launchpad on BNB Chain (Binance Agentic Wallet
126
145
  gang sell --token <addr> (--amount <tokens> | --all) [--slippage 1] [--note … [--conf …] [--reveal-after …]]
127
146
  gang upload-logo (--file <path> | --url <image URL>)
128
147
  copy an image to https://gang.fun/api/logos → logoUrl
129
- gang create --name <n> --symbol <s> [--logo <file> | --logo-uri URL] [--taxed] [--quote 0x… --ref-pool 0x…] [--note … [--conf …] [--reveal-after …]]
148
+ gang create --name <n> --symbol <s> [--logo <file> | --logo-uri URL] [--website URL] [--twitter @handle|x.com link]
149
+ [--taxed] [--quote 0x… --ref-pool 0x…] [--note … [--conf …] [--reveal-after …]]
130
150
  LaunchpadV2 ONLY (${ADDR.v2}); Soft/U/S create refused
151
+ --website / --twitter (optional) go into the create tx, shown on gang.fun; permanent
131
152
  --logo-uri images are copied to gang.fun first (logoURI is permanent on-chain;
132
153
  other hosts expire). --keep-logo-uri puts a permanent URL (e.g. ipfs://) on-chain as is
133
154
  gang execute --request-id <id> broadcast a previewed call
@@ -144,7 +165,13 @@ not your own, and buys under $1 don't count. gang.fun is asked first and the buy
144
165
  Following a sealed or not-yet-public note is a blind follow; if that note is never revealed, the follow is void.
145
166
 
146
167
  Logo UI (browser): ${LOGO_UI}
147
- env: GANG_LAUNCHPAD (default V2 ${ADDR.v2}), GANG_RPC_URL, GANG_LOGO_API, GANG_API (default ${API}), GANG_NOTES_DIR (default ~/.gang/notes)`
168
+
169
+ A buy / sell / create preview without --note says so in agentNote.hint. When npm has a newer CLI, results carry
170
+ update: { current, latest, cmd, skill } (npm is asked at most once a day in the background; GANG_UPDATE_CHECK=0 turns it
171
+ off, GANG_UPDATE_URL points it at an npm mirror).
172
+
173
+ env: GANG_LAUNCHPAD (default V2 ${ADDR.v2}), GANG_RPC_URL, GANG_LOGO_API, GANG_API (default ${API}), GANG_NOTES_DIR (default ~/.gang/notes),
174
+ GANG_UPDATE_CHECK, GANG_UPDATE_URL`
148
175
 
149
176
  function summarizePreview(p, extra = {}) {
150
177
  if (!p.success) return { success: false, step: extra.step, error: p.error }
@@ -160,10 +187,25 @@ function summarizePreview(p, extra = {}) {
160
187
  }
161
188
  }
162
189
 
163
- /** Preview with the note's commitment already in `data`; queue the reveal under the preview's requestId. */
164
- function summarizeWithNote(p, extra, note, kind) {
190
+ // shown on every buy / sell / create without --note: agents with an old local skill don't know to write one.
191
+ // The CLI can't tell whether the user asked for no note, so the agent decides.
192
+ const NO_NOTE = {
193
+ attached: false,
194
+ 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).',
195
+ }
196
+ const NO_NOTE_SENT = {
197
+ attached: false,
198
+ 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.',
199
+ }
200
+
201
+ /**
202
+ * A trade preview with the note's commitment (and follow ref) already in `data`: queue the reveal under the preview's
203
+ * requestId, and remember what the preview carried so `gang execute` can report on it.
204
+ */
205
+ function summarizeWithNote(p, extra, note, kind, follow = null) {
165
206
  const s = summarizePreview(p, extra)
166
- if (note && s.success) {
207
+ if (!s.success) return s
208
+ if (note) {
167
209
  queueNote(note, { kind, token: extra.token ?? null, requestId: s.requestId })
168
210
  s.agentNote = {
169
211
  attached: true, hash: note.hash, text: note.text, conf: note.conf,
@@ -171,16 +213,11 @@ function summarizeWithNote(p, extra, note, kind) {
171
213
  ? { revealAfterHours: note.revealAfter / 3600, visibility: `sealed: public ${note.revealAfter / 3600}h after the trade` }
172
214
  : { visibility: 'public after execute' }),
173
215
  }
216
+ } else {
217
+ s.agentNote = NO_NOTE
174
218
  }
175
- return s
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
- }
219
+ if (follow) s.follow = { attached: true, ...follow }
220
+ rememberPreview(s.requestId, { kind, noted: !!note, ...(follow && { followed: follow.noteTx }) })
184
221
  return s
185
222
  }
186
223
  /** The buy step itself: price the follow (setup steps skip this API call). */
@@ -292,11 +329,11 @@ async function buy(token, bnb, note, followTx, checked = null) {
292
329
  const data = withNote(withFollow(calldata.softBuy(token, minOut), follow), note)
293
330
  const p = bawPreview({ from: me, to: pad.address, value: parseEther(bnb), data })
294
331
  const priced = await pricedFollow(follow, token, pad, q)
295
- return out(summarizeBuy(p, {
332
+ return out(summarizeWithNote(p, {
296
333
  step: 'buy', kind: 'soft', token, symbol: info.symbol, pad: pad.id,
297
334
  bnbSent: bnb, expectTokens: q.tokensOutFmt, expectSpent: q.spentFmt, fee: q.feeFmt,
298
335
  expectRefund: q.refundFmt, minTokensOut: fmtTok(minOut),
299
- }, note, priced))
336
+ }, note, 'buy', priced))
300
337
  }
301
338
 
302
339
  // V2 / U / S: buy(token, quoteIn, minTokensOut) pulls quote ERC-20, so: enough quote → approve → buy
@@ -335,14 +372,22 @@ async function buy(token, bnb, note, followTx, checked = null) {
335
372
  const data = withNote(withFollow(calldata.v2Buy(token, quoteIn, minOut), follow), note)
336
373
  const p = bawPreview({ from: me, to: pad.address, data })
337
374
  const priced = await pricedFollow(follow, token, pad, q)
338
- return out(summarizeBuy(p, {
375
+ return out(summarizeWithNote(p, {
339
376
  step: 'buy', kind: 'v2', token, symbol: info.symbol, pad: pad.id, quote: info.quote,
340
377
  quoteIn: bnb, expectTokens: q.tokensOutFmt, expectSpent: q.spentFmt, fee: q.feeFmt,
341
378
  expectRefund: q.refundFmt, minTokensOut: fmtTok(minOut), quotePath: q.quotePath, deterministic: q.deterministic || false,
342
- }, note, priced))
379
+ }, note, 'buy', priced))
343
380
  }
344
381
 
382
+ // commands whose JSON result carries `update`; `watch` streams NDJSON and help is plain text
383
+ const UPDATE_CHECKED = new Set(['preflight', 'list', 'hot', 'new', 'near-grad', 'token', 'quote', 'buy', 'follow', 'sell',
384
+ 'upload-logo', 'create', 'execute', 'tx'])
385
+
345
386
  async function main() {
387
+ if (UPDATE_CHECKED.has(cmd)) update = checkUpdate()
388
+ for (const name of LINK_FLAGS) {
389
+ if (cmd !== 'create' && flag(name)) fail(`--${name} only applies to \`gang create\` (the link is written into the create tx)`)
390
+ }
346
391
  switch (cmd) {
347
392
  case 'preflight': {
348
393
  const issues = []
@@ -420,7 +465,7 @@ async function main() {
420
465
  count++
421
466
  if (webhook) {
422
467
  const p = fetch(webhook, {
423
- method: 'POST', headers: { 'content-type': 'application/json' },
468
+ method: 'POST', headers: { 'content-type': 'application/json', 'user-agent': UA },
424
469
  body: JSON.stringify(ev), signal: AbortSignal.timeout(10_000),
425
470
  }).then((r) => { if (!r.ok) line({ event: 'error', error: `webhook ${r.status}` }) })
426
471
  .catch((e) => line({ event: 'error', error: `webhook: ${e.message}` }))
@@ -532,6 +577,7 @@ async function main() {
532
577
  const name = opt('name'), symbol = opt('symbol'); if (!name || !symbol) fail('--name and --symbol required')
533
578
  try { assertCreateTarget(CONFIG.launchpad) } catch (e) { fail(e.message) }
534
579
  const note = parseNote()
580
+ const links = parseLinks()
535
581
  parseFollow() // refuses --follow: only buys follow a note
536
582
  // the old 5-arg --pair-or-quote never matched the contract
537
583
  if (flag('pair-or-quote') || flag('pair')) {
@@ -542,16 +588,17 @@ async function main() {
542
588
  const wbnbQuote = [zeroAddress, ADDR.wbnb.toLowerCase()].includes(quote.toLowerCase())
543
589
  const me = bawAddress()
544
590
  if (!(await isAgent(me))) fail('this wallet holds no Agent ID (.ai); create is gated', { address: me })
545
- // dry run before any logo upload; logoURI and the note suffix can't change whether create reverts
591
+ // dry run before any logo upload; logoURI and the links / note suffixes can't change whether create reverts
546
592
  const dry = await preflightCreate(me, calldata.createV2(name, symbol, '', taxed, quote, refPool), quote, refPool)
547
593
  if (!dry.ok && dry.reverted) fail(`create would revert: ${dry.error}`, { quote, refPool, hint: CREATE_HINTS[dry.error] })
548
594
  if (!dry.ok) fail(`could not dry-run create (RPC): ${dry.error}`, { retry: 'same command again; set GANG_RPC_URL if this RPC keeps failing' })
549
595
  const logo = await resolveLogoUri()
550
- const data = withNote(calldata.createV2(name, symbol, logo.logoURI, taxed, quote, refPool), note)
596
+ const data = withNote(calldata.createV2(name, symbol, logo.logoURI, taxed, quote, refPool) + linksSuffix(links), note)
551
597
  const p = bawPreview({ from: me, to: ADDR.v2, data })
552
598
  return out(summarizeWithNote(p, {
553
599
  step: 'create', pad: 'v2', launchpad: ADDR.v2, name, symbol,
554
600
  logoURI: logo.logoURI || '(empty)', uploaded: logo.uploaded, upload: logo.upload,
601
+ ...(links && { links: { website: links.website || null, twitter: links.twitter || null } }),
555
602
  taxed, quote: wbnbQuote ? 'WBNB' : quote,
556
603
  // the contract ignores refPool for WBNB (Created.refPair = 0x0)
557
604
  refPool: wbnbQuote && refPool !== zeroAddress ? `${refPool} (ignored for WBNB)` : refPool,
@@ -566,8 +613,10 @@ async function main() {
566
613
  const d = r.data
567
614
  const pending = d.status === 'PENDING_CONFIRMATION'
568
615
  const entry = findByRequestId(id)
569
- // nothing broadcast (no hash, not pending) → nothing followed either
570
- const followed = d.txHash || pending ? followFor(id) : null
616
+ // nothing broadcast (no hash, not pending) → nothing followed, and no trade to remind about
617
+ const sent = d.txHash || pending
618
+ const preview = sent ? previewFor(id) : null
619
+ const followed = preview?.followed
571
620
  let note
572
621
  if (entry && d.txHash && !pending) note = await revealNote(entry, d.txHash)
573
622
  else if (entry && pending) {
@@ -579,6 +628,7 @@ async function main() {
579
628
  return out({
580
629
  success: true, status: d.status, txHash: d.txHash, orderId: d.orderId, pendingConfirmation: pending,
581
630
  ...(note && { note }),
631
+ ...(!entry && preview && !preview.noted && { agentNote: NO_NOTE_SENT }),
582
632
  ...(followed && { follow: { noteTx: followed, link: noteLink(followed) } }),
583
633
  next: pending
584
634
  ? '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>`.'
@@ -606,8 +656,11 @@ async function main() {
606
656
  else if (t.status !== 'success') note = { published: false, error: 'tx_failed' }
607
657
  else note = withRetry(await publishNote(entry, hash), hash)
608
658
  }
609
- const followed = readFollowRef(input)
610
- return out({ success: true, ...t, ...(note && { note }), ...(followed && { follow: { noteTx: followed, link: noteLink(followed) } }) })
659
+ const followed = readFollowRef(input), links = readTokenLinks(input)
660
+ return out({
661
+ success: true, ...t, ...(note && { note }), ...(followed && { follow: { noteTx: followed, link: noteLink(followed) } }),
662
+ ...(links && { links }),
663
+ })
611
664
  }
612
665
  default:
613
666
  console.log(HELP); process.exit(cmd ? 1 : 0)
package/src/links.js ADDED
@@ -0,0 +1,51 @@
1
+ /**
2
+ * Token links (create --website / --twitter): what counts as a stored value and how typed input becomes one.
3
+ * No imports, so discover.js can check what the API returns without an import cycle (notes.js imports discover.js).
4
+ * The calldata codec is in notes.js (linksSuffix / readTokenLinks). Same rules as the server's noteCommit.js.
5
+ */
6
+ const LINKS_MAX = 200 // per field, characters (all ASCII once canonical)
7
+ // path segments of A-Z a-z 0-9 _ . -, but not "." or ".." (a browser would resolve those away)
8
+ const X_URL = /^https:\/\/x\.com(?:\/(?!\.\.?(?:\/|$))[A-Za-z0-9_.-]+)+$/
9
+ // the last label of a domain starts with a letter (TLDs, punycode xn--…); WHATWG URL reads an all-digit last label as IPv4
10
+ const DOMAIN = /^(?:[a-z0-9-]+\.)+[a-z][a-z0-9-]*$/
11
+ const X_HOSTS = new Set(['x.com', 'www.x.com', 'mobile.x.com', 'twitter.com', 'www.twitter.com', 'mobile.twitter.com'])
12
+
13
+ /** Error code for a stored website value, or null: a canonical http(s) URL on a domain (no IP), no port, no credentials. */
14
+ export function websiteError(v) {
15
+ let u
16
+ try { u = new URL(v) } catch { return 'bad_website' }
17
+ if (u.href !== v || v.length > LINKS_MAX) return 'bad_website'
18
+ if (!['https:', 'http:'].includes(u.protocol) || u.username || u.password || u.port || !DOMAIN.test(u.hostname)) return 'bad_website'
19
+ return null
20
+ }
21
+
22
+ /** Error code for a stored twitter value, or null: https://x.com/<path>, no query. */
23
+ export const twitterError = (v) => (X_URL.test(v) && v.length <= LINKS_MAX ? null : 'bad_twitter')
24
+
25
+ /** --website as typed → canonical URL ('' when empty). "example.com" means https://example.com/. Throws bad_website. */
26
+ export function normalizeWebsite(raw) {
27
+ const s = String(raw ?? '').trim()
28
+ if (!s) return ''
29
+ const bad = (why) => new Error(`bad_website: --website ${why} (got ${JSON.stringify(s)})`)
30
+ if (!/^https?:\/\//i.test(s) && s.includes('://')) throw bad('must be an http(s) link')
31
+ let u
32
+ try { u = new URL(/^https?:\/\//i.test(s) ? s : `https://${s}`) } catch { throw bad('is not a valid link') }
33
+ if (websiteError(u.href)) throw bad(`must be an http(s) link to a domain (no IP address, port or user:password), at most ${LINKS_MAX} characters`)
34
+ return u.href
35
+ }
36
+
37
+ /** --twitter as typed (@handle, handle, x.com/… or twitter.com/… link) → https://x.com/<path> ('' when empty). Throws bad_twitter. */
38
+ export function normalizeTwitter(raw) {
39
+ const s = String(raw ?? '').trim()
40
+ if (!s) return ''
41
+ const bad = () => new Error(`bad_twitter: --twitter takes an X handle (@name) or an x.com / twitter.com link (got ${JSON.stringify(s)})`)
42
+ const handle = /^@?([A-Za-z0-9_]{1,15})$/.exec(s)
43
+ if (handle) return `https://x.com/${handle[1]}`
44
+ let u
45
+ try { u = new URL(/^https?:\/\//i.test(s) ? s : `https://${s}`) } catch { throw bad() }
46
+ if (!['https:', 'http:'].includes(u.protocol) || u.username || u.password || u.port || !X_HOSTS.has(u.hostname)) throw bad()
47
+ // query and fragment (?s=20 share tracking) are dropped; x.com/@name is x.com/name
48
+ const v = `https://x.com${u.pathname.replace(/^\/@/, '/').replace(/\/+$/, '')}`
49
+ if (twitterError(v)) throw bad()
50
+ return v
51
+ }
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
@@ -4,6 +4,7 @@
4
4
  * v1 suffix = "gang" 0x01 keccak256(UTF-8("gang-note:v1\n" salt "\n" conf "\n" text)) (37 bytes)
5
5
  * v2 suffix = "gang" 0x02 uint32be(revealAfter) keccak256(UTF-8("gang-note:v2\n" salt "\n" conf "\n" revealAfter "\n" text)) (41 bytes)
6
6
  * follow ref = "gang" 0x03 followed note tx hash (37 bytes), right before the note suffix (or last without one)
7
+ * token links (create) = "gang" 0x04 UTF-8(website "\n" twitter) uint16be(length), same place (see linksSuffix)
7
8
  * The text is sent after the tx lands (POST /api/notes), so anyone can check it was written before the trade.
8
9
  * v2 (sealed): the server holds the text and only shows it from blockTime + revealAfter.
9
10
  * Normalization and the commitment format must match the server byte for byte.
@@ -12,15 +13,21 @@ import { randomBytes } from 'node:crypto'
12
13
  import { mkdirSync, readdirSync, readFileSync, renameSync, statSync, unlinkSync, writeFileSync } from 'node:fs'
13
14
  import { homedir } from 'node:os'
14
15
  import { join } from 'node:path'
15
- import { keccak256, toHex } from 'viem'
16
+ import { hexToString, keccak256, toHex } from 'viem'
16
17
  import { API } from './discover.js'
18
+ import { UA } from './version.js'
19
+ import { websiteError, twitterError } from './links.js'
17
20
 
18
21
  export const MAGIC = '67616e6701' // "gang" + version 1
19
22
  export const MAGIC_V2 = '67616e6702' // "gang" + version 2 (sealed)
20
- export const FOLLOW_MAGIC = '67616e6703' // "gang" + 03: follow ref (01 / 02 / 03 are never reused)
23
+ export const FOLLOW_MAGIC = '67616e6703' // "gang" + 03: follow ref
24
+ export const LINKS_MAGIC = '67616e6704' // "gang" + 04: token links (01–04 are never reused)
21
25
  const MAX_CP = 280
22
26
  const REVEAL_MIN_H = 1, REVEAL_MAX_H = 72
23
27
  const LINK = /https?:\/\/|www\.|t\.me\//i
28
+ // a copied command whose placeholder was never filled in (`--note "<your reason>"`): refused by the CLI only, so the
29
+ // server's normalization stays the same
30
+ const PLACEHOLDER = /^<[^<>]{1,40}>$/
24
31
  // zero-width + bidi controls, and every C0/C1 control except the whitespace ones (\t \n \v \f \r become a space below)
25
32
  const STRIP = /[\u200B-\u200D\uFEFF\u202A-\u202E\u2066-\u2069]|(?![\t\n\v\f\r])\p{Cc}/gu
26
33
  const TTL_MS = 24 * 3600_000 // the server takes a note (v1 or v2) for 24h after the trade
@@ -62,6 +69,7 @@ export const noteHash = ({ salt, conf, text, revealAfter }) =>
62
69
 
63
70
  export function makeNote(rawText, rawConf, rawRevealAfter) {
64
71
  const text = normalizeNote(rawText)
72
+ if (PLACEHOLDER.test(text)) throw new Error(`placeholder_note: --note is still the placeholder ${text}; write the actual reason, or drop --note`)
65
73
  const conf = parseConf(rawConf)
66
74
  const revealAfter = parseRevealAfter(rawRevealAfter)
67
75
  const salt = randomBytes(16).toString('hex')
@@ -112,6 +120,43 @@ export function readFollowRef(input) {
112
120
  return f.length === 74 && f.startsWith(FOLLOW_MAGIC) && TX_RE.test(tx) ? tx : null
113
121
  }
114
122
 
123
+ /*
124
+ * Token links (create only): the creator's website and X (Twitter) link, in plain text since they're public anyway.
125
+ * "gang" 04 UTF-8(website "\n" twitter) uint16be(payload bytes), right before the note suffix (or last without one):
126
+ * [calldata][links][note]. The length goes last so a reader can find the start from the end. Either field may be
127
+ * empty, not both. Only canonical values count (links.js websiteError / twitterError), so other writers can't smuggle odd URLs.
128
+ */
129
+
130
+ /** Suffix for canonical links ({ website, twitter }, either may be empty), lower-case hex without 0x; '' for none. */
131
+ export function linksSuffix(links) {
132
+ const { website = '', twitter = '' } = links ?? {}
133
+ if (!website && !twitter) return ''
134
+ if (website && websiteError(website)) throw new Error('bad_website')
135
+ if (twitter && twitterError(twitter)) throw new Error('bad_twitter')
136
+ const hex = toHex(`${website}\n${twitter}`).slice(2)
137
+ return `${LINKS_MAGIC}${hex}${(hex.length / 2).toString(16).padStart(4, '0')}`
138
+ }
139
+
140
+ /**
141
+ * The links a create input carries ({ website, twitter }, each null when absent or not canonical), or null. Only one
142
+ * place counts: the segment ending right before the input's note suffix, or at its very end when it has none.
143
+ */
144
+ export function readTokenLinks(input) {
145
+ const s = String(input || '').toLowerCase()
146
+ const c = readNoteCommit(s)
147
+ const end = s.length - (c ? (c.version === 2 ? 82 : 74) : 0)
148
+ const len = parseInt(s.slice(Math.max(0, end - 4), end), 16)
149
+ const start = end - 4 - 2 * len - 10
150
+ if (!(len > 0) || start < 0 || !s.startsWith(LINKS_MAGIC, start)) return null
151
+ const hex = s.slice(start + 10, end - 4)
152
+ if (!/^[0-9a-f]*$/.test(hex)) return null
153
+ const parts = hexToString(`0x${hex}`).split('\n')
154
+ if (parts.length !== 2) return null
155
+ const website = parts[0] && !websiteError(parts[0]) ? parts[0] : null
156
+ const twitter = parts[1] && !twitterError(parts[1]) ? parts[1] : null
157
+ return website || twitter ? { website, twitter } : null
158
+ }
159
+
115
160
  /** Commitment hashes (v1 and v2) found in a tx input (contains-match, same as the server: the call may be wrapped). */
116
161
  export function hashesIn(input) {
117
162
  const s = String(input || '').toLowerCase()
@@ -229,7 +274,7 @@ async function post(entry, tx) {
229
274
  try {
230
275
  const r = await fetch(`${API}/notes`, {
231
276
  method: 'POST', signal: ctl.signal,
232
- headers: { 'content-type': 'application/json', accept: 'application/json' },
277
+ headers: { 'content-type': 'application/json', accept: 'application/json', 'user-agent': UA },
233
278
  body: JSON.stringify({
234
279
  tx, salt: entry.salt, conf: entry.conf, text: entry.text, ...(entry.revealAfter && { revealAfter: entry.revealAfter }),
235
280
  }),
@@ -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()