@tapepack/core 0.1.1 → 0.2.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
@@ -7,25 +7,29 @@
7
7
  ## 一条命令部署
8
8
 
9
9
  ```bash
10
- export TAPEPACK_PRIVATE_KEY=0x… # 容器持有人钱包的私钥,只从环境变量读
11
10
  npx -p @tapepack/core tapepack deploy ./dist --site 1.2.344
12
11
  ```
13
12
 
14
- 流程依次是:
13
+ 不需要私钥,流程依次是:
15
14
 
16
15
  1. **读链上现有档案作增量基准**:只把变化的文件打成增量卷。
17
16
  2. **与链上逐文件比对**:只传变化的文件。
18
- 3. **报价**:列出链上费用、服务费和预估合计,确认后才执行。非交互环境加 `--yes`;只看报价用 `--dry-run`,不需要私钥。
19
- 4. **操作员模式上传**,与网页版同一套:
20
- - 主钱包只签两笔:给临时钱包充值(含服务费),授权它写入 2 小时。
21
- - 临时钱包流水线写入,写完转服务费,剩余余额退回主钱包,最后读链复核。
22
- - 按 Ctrl-C 会在当前这笔确认后停止,并照常退款。中途停止不收服务费。
17
+ 3. **报价**:列出链上费用、服务费和预估合计。只看报价用 `--dry-run`。
18
+ 4. **浏览器授权**:
19
+ - 本机生成一个临时钱包,并给出它的地址。
20
+ - 自动在浏览器打开本机授权页(只监听 127.0.0.1,带随机口令)。页面列出站点、容器、要授权的临时钱包、费用和服务费收款地址。
21
+ - 用持有人钱包(浏览器扩展)连接,钱包依次弹出两笔确认:给临时钱包充值(含服务费),授权它写入 2 小时。
22
+ 5. **拿到授权后上传**,与网页版同一套:
23
+ - 临时钱包流水线写入,进度同步显示在授权页上。
24
+ - 写完转服务费,剩余余额退回持有人,最后读链复核。
25
+ - 钱包拒签则中止,余额不动。按 Ctrl-C 会在当前这笔确认后停止,并照常退款。中途停止不收服务费。
23
26
 
24
27
  | 选项 | 说明 |
25
28
  |---|---|
26
29
  | `--site <名字>` | 链上站点名,如 `1.2.344`;用容器地址时另加 `--chain bnb\|xlayer\|base` |
27
30
  | `--dry-run` | 只报价 |
28
- | `--yes` | 不再询问,直接执行 |
31
+ | `--no-open` | 不自动打开浏览器,只打印授权页地址 |
32
+ | `--key env:<变量名>` | CI 等无浏览器环境:改用本地私钥签名(只从环境变量读);配 `--yes` 跳过确认 |
29
33
  | `--full` | 不做增量,整体重打 |
30
34
  | `--remove-leftovers` | 删除链上有、本地没有的文件(`.well-known/` 等点目录除外) |
31
35
  | `--out <目录>` | 保留产物(缺省用临时目录) |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tapepack/core",
3
- "version": "0.1.1",
3
+ "version": "0.2.0",
4
4
  "type": "module",
5
5
  "description": "TapePack:把静态站点压缩后部署到 DeWEB(TapeOut)链上,上链 Gas 直降——压缩、估算、增量更新、一条命令部署",
6
6
  "license": "MIT",
package/src/cli.mjs CHANGED
@@ -5,9 +5,11 @@
5
5
  // [--strategy auto|speed|gas] 加载策略:auto 按多花的 gas 占比为每个站点自动选(缺省)/ speed 加载优先 / gas 省钱优先
6
6
  // [--no-lazy] 不分延后卷(首屏等全部档案) [--no-embed] 首屏档案不内嵌进入口(多读一次链、省一点 gas)
7
7
  // tapepack estimate <dir> [--chain all|bnb,xlayer,base] [--native-usd bnb=600,base=3500]
8
- // tapepack deploy <siteDir> --site <站点名> [--dry-run 只报价] [--yes] [--key env:TAPEPACK_PRIVATE_KEY] [--out <产物目录>] [--full] [--remove-leftovers]
8
+ // tapepack deploy <siteDir> --site <站点名> [--dry-run 只报价] [--no-open] [--out <产物目录>] [--full] [--remove-leftovers]
9
9
  // [--preset ...] [--strategy ...] [--chain <链>(站点用容器地址时必填)] [--rpc <url>]
10
- // 打包(以链上现有档案为增量基准)→ 比对 → 报价(链上费用 + 服务费)→ 确认后操作员模式上传;私钥只从环境变量读
10
+ // [--key env:<变量名> [--yes]] CI 等无浏览器环境:用本地私钥代替浏览器授权
11
+ // 打包(以链上现有档案为增量基准)→ 比对 → 报价(链上费用 + 服务费)→ 给出临时钱包地址、打开浏览器授权页,
12
+ // 持有人在钱包里确认充值与授权 → 拿到授权后临时钱包上传、写完退款
11
13
  // tapepack refund --chain <链> [--to 0x...] # 退回上次中断留在临时钱包的余额
12
14
  // tapepack plan <outDir> --container 0x... [--chain xlayer] [--src <源站点目录>]
13
15
  // tapepack inspect <链上名字|容器地址>
@@ -84,9 +86,11 @@ async function confirm(question) {
84
86
  return /^y(es)?$/i.test(ans.trim());
85
87
  }
86
88
 
89
+ /** --key env:<变量名>:CI 用本地私钥;缺省浏览器授权(返回 null) */
87
90
  const privateKeyArg = () => {
88
- const keyRef = arg('--key') || 'env:TAPEPACK_PRIVATE_KEY';
89
- if (!keyRef.startsWith('env:')) throw new Error('私钥只从环境变量读:--key env:<变量名>(默认 TAPEPACK_PRIVATE_KEY),不要写在命令行里');
91
+ const keyRef = arg('--key');
92
+ if (!keyRef) return null;
93
+ if (!keyRef.startsWith('env:')) throw new Error('私钥只从环境变量读:--key env:<变量名>,不要写在命令行里');
90
94
  const pk = process.env[keyRef.slice(4)];
91
95
  if (!pk) throw new Error(`没有找到私钥:请设置环境变量 ${keyRef.slice(4)}(容器持有人钱包的私钥)`);
92
96
  return pk;
@@ -94,8 +98,10 @@ const privateKeyArg = () => {
94
98
 
95
99
  function progress(e) {
96
100
  const w = (s) => console.error(s);
97
- if (e.t === 'operator:fund') w(`${e.topUp ? '补充' : '充值'}临时钱包 ${e.address}:${Number(e.amount) / 1e18}`);
98
- else if (e.t === 'operator:authorise') w(`授权临时钱包写入 ${e.ttl / 3600} 小时`);
101
+ if (e.t === 'operator:fund') w(`${e.topUp ? '补充' : '充值'}临时钱包 ${e.address}:${Number(e.amount) / 1e18}(等钱包确认)`);
102
+ else if (e.t === 'operator:funded') w(`充值已到账 ${e.hash}`);
103
+ else if (e.t === 'operator:authorise') w(`授权临时钱包写入 ${e.ttl / 3600} 小时(等钱包确认)`);
104
+ else if (e.t === 'operator:authorised') w(`授权已生效 ${e.hash},开始写入`);
99
105
  else if (e.t === 'file') w(`上传 ${e.done}/${e.total} ${e.path}`);
100
106
  else if (e.t === 'fee:paid') w(`服务费已支付:${e.hash}`);
101
107
  else if (e.t === 'fee:failed') w(`服务费转账失败(随余额退回,不收):${e.error}`);
@@ -145,8 +151,8 @@ if (cmd === 'pack' && a && b) {
145
151
  const site = arg('--site');
146
152
  if (!site) throw new Error('--site <站点名> required(如 1.2.344;用容器地址时另加 --chain)');
147
153
  if (fs.existsSync(path.join(a, 'tpack-manifest.json'))) throw new Error('deploy 需要源站点目录(含 index.html),不是 TapePack 产物目录:会在部署时自动压缩');
148
- const { quoteDeploy, quoteJson, runDeploy, keyWallet } = await import('./deploy-run.mjs');
149
- const dryRun = flag('--dry-run'); // 只报价,不需要私钥
154
+ const { quoteDeploy, quoteJson, runDeploy, deployViaBrowser, keyWallet } = await import('./deploy-run.mjs');
155
+ const dryRun = flag('--dry-run'); // 只报价
150
156
  const privateKey = dryRun ? null : privateKeyArg();
151
157
  const chainKey = arg('--chain');
152
158
  const rpcUrls = arg('--rpc') ? [arg('--rpc')] : undefined;
@@ -163,12 +169,22 @@ if (cmd === 'pack' && a && b) {
163
169
  console.log(JSON.stringify({ site: summary.site, chain: summary.chain, nothingToDo: true, message: '链上已是最新,无需上传' }, null, 2));
164
170
  } else if (dryRun) {
165
171
  console.log(JSON.stringify({ ...summary, deployed: false }, null, 2));
166
- } else if (await confirm(`用 ${from} 部署?(y/N) `)) {
172
+ } else if (!privateKey || await confirm(`用 ${from} 部署?(y/N) `)) {
173
+ // 浏览器授权:钱包里的确认就是同意,终端不再多问
167
174
  const ac = new AbortController();
168
175
  process.once('SIGINT', () => { console.error('\n收到中断:当前这笔确认后停止并退款(再按一次强制退出,余额可用 tapepack refund 找回)'); ac.abort(); process.once('SIGINT', () => process.exit(130)); });
169
- const r = await runDeploy(q, { privateKey, onEvent: progress, signal: ac.signal });
176
+ const r = privateKey
177
+ ? await runDeploy(q, { privateKey, onEvent: progress, signal: ac.signal })
178
+ : await deployViaBrowser(q, {
179
+ open: !flag('--no-open'), onEvent: progress, signal: ac.signal,
180
+ onUrl: (url, operator) => console.error([
181
+ `临时钱包 ${operator}:将被授权写入 ${summary.site || summary.container}(2 小时后自动失效,写完余额退回持有人)`,
182
+ `请在浏览器里用持有人钱包${summary.holder ? ` ${summary.holder} ` : ''}确认充值与授权${flag('--no-open') ? ',打开' : '(已自动打开;没打开就手动访问)'}:`,
183
+ ` ${url}`,
184
+ ].join('\n')),
185
+ });
170
186
  console.log(JSON.stringify(r, null, 2));
171
- if (!r.completed || !r.verified) process.exitCode = 1;
187
+ if (!r.nothingToDo && (!r.completed || !r.verified)) process.exitCode = 1;
172
188
  } else {
173
189
  console.log(JSON.stringify({ ...summary, deployed: false }, replacer, 2));
174
190
  process.exitCode = 2;
@@ -1,7 +1,8 @@
1
1
  // 命令行 / MCP 部署(Node):打包(以链上现有档案为增量基准)→ 与链上逐文件比对 → 服务费报价 →
2
- // 操作员模式上传。上传、续传、退款、服务费收取与网页版是同一套(onchain.uploadWithOperator),
3
- // 主钱包换成本地私钥:私钥只来自调用方(命令行读环境变量),不落盘;临时钱包钥匙存在 ~/.tapepack(0600),
4
- // 中断后下次部署接着用其中的余额,或用 refund 退回。
2
+ // 操作员模式上传。上传、续传、退款、服务费收取与网页版是同一套(onchain.uploadWithOperator)。
3
+ // 主钱包缺省用浏览器授权(wallet-bridge.mjs):给出临时钱包地址、打开本机授权页,持有人在浏览器钱包里确认
4
+ // 充值与授权,拿到授权后再上传——不碰主钱包私钥。CI 等无浏览器环境可改用本地私钥(只从环境变量读,不落盘)。
5
+ // 临时钱包钥匙存在 ~/.tapepack(0600),中断后下次部署接着用其中的余额,或用 refund 退回。
5
6
  //
6
7
  // 服务费与网页版一致(fee.mjs):节省额 × 20%,封顶 $2,节省不到 $0.10 免费;取不到实时币价时不收。
7
8
 
@@ -10,7 +11,8 @@ import os from 'node:os';
10
11
  import path from 'node:path';
11
12
  import { Wallet, Transaction, getAddress } from 'ethers';
12
13
  import { pack, readSite, outputsFromDir } from './pack.mjs';
13
- import { chainContext, chainKeyOf, resolveContainer, fetchBase, planUpload, uploadWithOperator, refundOperator, operatorStatus, gasPriceOf } from './onchain.mjs';
14
+ import { chainContext, chainKeyOf, resolveContainer, fetchBase, planUpload, uploadWithOperator, refundOperator, operatorStatus, gasPriceOf, INTERFACES, OPERATOR_TTL } from './onchain.mjs';
15
+ import { startWalletBridge, openInBrowser } from './wallet-bridge.mjs';
14
16
  import { serviceFee, FEE_TO, FEE_CAP_USD, FEE_FREE_BELOW_USD, FEE_RATE_BP } from './fee.mjs';
15
17
  import { fetchLivePrices } from './prices.mjs';
16
18
 
@@ -55,6 +57,11 @@ export function fileKeyStore({ dir = process.env.TAPEPACK_HOME || path.join(os.h
55
57
  fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
56
58
  fs.writeFileSync(file(chainId), JSON.stringify({ pk, holder, at: new Date().toISOString() }), { mode: 0o600 });
57
59
  },
60
+ /** 浏览器授权时临时钱包先建、持有人连上钱包后才知道:补记,供找回余额 */
61
+ setHolder: (chainId, h) => {
62
+ const j = readJson(chainId);
63
+ if (j) fs.writeFileSync(file(chainId), JSON.stringify({ ...j, holder: h }), { mode: 0o600 });
64
+ },
58
65
  clear: (chainId) => fs.rmSync(file(chainId), { force: true }),
59
66
  };
60
67
  }
@@ -155,18 +162,21 @@ export function quoteJson(q) {
155
162
  }
156
163
 
157
164
  /**
158
- * 执行部署:本地私钥作主钱包,走操作员模式(主钱包签 1~2 笔:充值含服务费、授权 2 小时)。
165
+ * 执行部署:走操作员模式(主钱包签 1~2 笔:充值含服务费、授权 2 小时)。
166
+ * 主钱包二选一:浏览器授权(authorizeInBrowser 给出的 wallet + holder,缺省做法)或本地私钥(privateKey,CI 用)。
159
167
  * @returns {Promise<object>} JSON 友好的结果
160
168
  */
161
- export async function runDeploy(q, { privateKey, keys, onEvent = () => {}, signal, window = 8 } = {}) {
169
+ export async function runDeploy(q, { privateKey, wallet, holder, keys, onEvent = () => {}, signal, window = 8 } = {}) {
162
170
  const { ctx, target, plan, svc } = q;
163
171
  if (!plan.calls.length) return { site: target.name, chain: ctx.key, nothingToDo: true, message: '链上已是最新,无需上传' };
164
- const wallet = keyWallet(ctx, privateKey);
165
- if (target.holder && wallet.address !== getAddress(target.holder)) throw new Error(`钱包 ${wallet.address} 不是 ${target.name} 的持有人(持有人 ${target.holder})`);
166
- keys = keys || fileKeyStore({ holder: wallet.address });
172
+ if (privateKey) { wallet = keyWallet(ctx, privateKey); holder = wallet.address; }
173
+ if (!wallet || !holder) throw new Error('需要主钱包:浏览器授权或私钥');
174
+ holder = getAddress(holder);
175
+ if (target.holder && holder !== getAddress(target.holder)) throw new Error(`钱包 ${holder} 不是 ${target.name} 的持有人(持有人 ${target.holder})`);
176
+ keys = keys || fileKeyStore({ holder });
167
177
  // 本链留着别的主钱包中断时的临时钱包:先把余额退给它,再为本次新建
168
178
  const prev = keys.holderOf?.(ctx.chainId);
169
- if (prev && getAddress(prev) !== wallet.address) await refundOperator({ ctx, keys, to: prev, onEvent });
179
+ if (prev && getAddress(prev) !== holder) await refundOperator({ ctx, keys, to: prev, onEvent });
170
180
  // 收款地址与金额上限再核对一次(与网页版签名前核对一致):不符即中止
171
181
  let fee = null;
172
182
  if (svc && svc.feeWei > 0n) {
@@ -174,7 +184,7 @@ export async function runDeploy(q, { privateKey, keys, onEvent = () => {}, signa
174
184
  if (svc.price && svc.feeUsd > FEE_CAP_USD * 1.0001) throw new Error('服务费超过封顶,已中止');
175
185
  fee = { to: svc.to, wei: svc.feeWei };
176
186
  }
177
- const r = await uploadWithOperator({ ctx, wallet, holder: wallet.address, plan, keys, onEvent, signal, fee, refundOnError: true, window });
187
+ const r = await uploadWithOperator({ ctx, wallet, holder, plan, keys, onEvent, signal, fee, refundOnError: true, window });
178
188
  const price = svc?.price ?? null;
179
189
  return {
180
190
  site: target.name, chain: ctx.key, container: target.container,
@@ -187,6 +197,86 @@ export async function runDeploy(q, { privateKey, keys, onEvent = () => {}, signa
187
197
  };
188
198
  }
189
199
 
200
+ const nativeText = (wei, cur) => `${native(wei)} ${cur}`;
201
+
202
+ /**
203
+ * 浏览器授权:先定下临时钱包(地址展示在授权页上),在本机起授权页并打开浏览器,等持有人钱包连上。
204
+ * 之后上传流程里主钱包要签的(充值含服务费、授权临时钱包、补充余额)都由页面转给钱包确认。
205
+ * @param {object} [opts]
206
+ * @param {boolean} [opts.open=true] 自动打开浏览器
207
+ * @param {(url: string, operator: string) => void} [opts.onUrl] 授权页地址(打不开浏览器时让用户手动打开)
208
+ * @returns {Promise<{ bridge, wallet, holder: string, operator: string, keys }>}
209
+ */
210
+ export async function authorizeInBrowser(q, { keys, open = true, onUrl = () => {}, timeoutMs = 15 * 60_000, signal } = {}) {
211
+ const { ctx, target } = q;
212
+ keys = keys || fileKeyStore({ holder: target.holder });
213
+ // 本链留着别的持有人中断时的临时钱包:先把余额退给它(只用临时钱包签名),本次另建
214
+ const prev = keys.holderOf?.(ctx.chainId);
215
+ if (prev && target.holder && getAddress(prev) !== getAddress(target.holder)) await refundOperator({ ctx, keys, to: prev });
216
+ // 沿用本链未清理的临时钱包(上次中断的余额接着用),否则新建并先存盘(进程被杀也能找回余额)
217
+ let pk = await keys.load(ctx.chainId);
218
+ if (!pk) { pk = Wallet.createRandom().privateKey; await keys.save(ctx.chainId, pk); }
219
+ const operator = new Wallet(pk).address;
220
+ const bridge = await startWalletBridge({
221
+ info: { site: target.name, chain: ctx.key, chainName: ctx.name, currency: ctx.currency, container: target.container, holder: target.holder, operator, ttlHours: OPERATOR_TTL / 3600, quote: quoteJson(q) },
222
+ accept: async (account) => {
223
+ let a;
224
+ try { a = getAddress(account); } catch { return '钱包地址无效'; }
225
+ if (target.holder) return a === getAddress(target.holder) ? null : `请切换到持有人钱包 ${target.holder}(当前连接的是 ${a})`;
226
+ const data = INTERFACES.registry.encodeFunctionData('canEdit', [target.container, a]);
227
+ const can = INTERFACES.registry.decodeFunctionResult('canEdit', await ctx.rpc.call('eth_call', [{ to: ctx.registry, data }, 'latest']))[0];
228
+ return can ? null : `${a} 不能编辑这个容器,请换持有人钱包`;
229
+ },
230
+ });
231
+ onUrl(bridge.url, operator);
232
+ if (open) openInBrowser(bridge.url);
233
+ let timer;
234
+ try {
235
+ const holder = await Promise.race([
236
+ bridge.connected,
237
+ new Promise((_, rej) => { timer = setTimeout(() => rej(new Error('等待浏览器钱包连接超时')), timeoutMs); }),
238
+ new Promise((_, rej) => signal?.addEventListener('abort', () => rej(new Error('已取消')))),
239
+ ]);
240
+ keys.setHolder?.(ctx.chainId, getAddress(holder));
241
+ return { bridge, wallet: bridge.wallet, holder: getAddress(holder), operator, keys };
242
+ } catch (e) {
243
+ bridge.fail(String(e.message || e));
244
+ await bridge.close(1500);
245
+ throw e;
246
+ } finally { clearTimeout(timer); }
247
+ }
248
+
249
+ /** 上传进度 → 授权页(下一笔钱包请求的说明、进度日志) */
250
+ function mirrorToPage(bridge, q, e) {
251
+ const { ctx, target, svc } = q;
252
+ const fee = svc && svc.feeWei > 0n ? `,含服务费 ${nativeText(svc.feeWei, ctx.currency)}` : '';
253
+ if (e.t === 'operator:fund') bridge.label(e.topUp ? `补充临时钱包余额(实际 gas 高于预估)` : `给临时钱包充值(链上费用 + 余量${fee};用不完的写完后退回)`);
254
+ else if (e.t === 'operator:authorise') bridge.label(`授权临时钱包 ${e.address} 写入 ${target.name || target.container}(${e.ttl / 3600} 小时后自动失效)`);
255
+ else if (e.t === 'operator:authorised') bridge.log({ text: '授权已生效,开始写入', cls: 'ok' });
256
+ else if (e.t === 'file') bridge.log({ text: `上传 ${e.done}/${e.total} ${e.path}` });
257
+ else if (e.t === 'fee:paid') bridge.log({ text: `服务费已支付 ${e.hash}` });
258
+ else if (e.t === 'fee:failed') bridge.log({ text: '服务费转账失败(随余额退回,不收)', cls: 'bad' });
259
+ else if (e.t === 'refund') bridge.log({ text: `余额已退回 ${nativeText(e.amount, ctx.currency)}` });
260
+ else if (e.t === 'cancelled') bridge.log({ text: '已停止:等在途交易落定后退款', cls: 'bad' });
261
+ }
262
+
263
+ /** 浏览器授权部署(命令行 / MCP 缺省做法):authorizeInBrowser → runDeploy,进度同步到授权页 */
264
+ export async function deployViaBrowser(q, { keys, open, onUrl, onEvent = () => {}, signal, window, timeoutMs } = {}) {
265
+ if (!q.plan.calls.length) return runDeploy(q, {});
266
+ const auth = await authorizeInBrowser(q, { keys, open, onUrl, signal, timeoutMs });
267
+ try {
268
+ const r = await runDeploy(q, { wallet: auth.wallet, holder: auth.holder, keys: auth.keys, signal, window, onEvent: (e) => { mirrorToPage(auth.bridge, q, e); onEvent(e); } });
269
+ auth.bridge.finish({
270
+ text: r.completed && r.verified ? `部署完成,读链复核通过:${r.url || r.container}。可以关闭本页。` : '未全部完成:余额已退回,重新运行部署会只传剩下的文件。',
271
+ completed: r.completed, verified: r.verified,
272
+ });
273
+ return { ...r, holder: auth.holder };
274
+ } catch (e) {
275
+ auth.bridge.fail('部署中止:' + String(e.message || e) + '。余额已自动退回持有人。');
276
+ throw e;
277
+ } finally { await auth.bridge.close(3000); }
278
+ }
279
+
190
280
  /** 找回临时钱包余额(上次中断留下的):退回到当初充值的主钱包 */
191
281
  export async function refundLeftover({ chainKey, rpcUrls, keys, to }) {
192
282
  const ctx = chainContext(chainKey, { rpcUrls: rpcOf(rpcUrls) });
@@ -0,0 +1,234 @@
1
+ // 浏览器授权桥(Node):命令行 / MCP 部署不碰主钱包私钥——本机起一个只监听 127.0.0.1、带随机口令的小服务,
2
+ // 打开授权页;页面连接用户的浏览器钱包(扩展),把上传流程(onchain.uploadWithOperator)里主钱包要签的请求
3
+ // (充值临时钱包、授权临时钱包写入、中途补充余额)转给钱包确认,结果交回本进程。
4
+ // 对 uploadWithOperator 来说它就是一个 EIP-1193 主钱包;只转发白名单里的方法。
5
+ // 页面只是展示 + 转发:上传由本进程的临时钱包完成,关掉页面不影响已授权后的写入(补充余额时才需要它)。
6
+
7
+ import http from 'node:http';
8
+ import { randomBytes } from 'node:crypto';
9
+ import { spawn } from 'node:child_process';
10
+
11
+ const FORWARD = new Set(['eth_chainId', 'eth_sendTransaction', 'wallet_switchEthereumChain', 'wallet_addEthereumChain']);
12
+
13
+ /** 用系统默认浏览器打开 */
14
+ export function openInBrowser(url) {
15
+ const [cmd, args] = process.platform === 'darwin' ? ['open', [url]]
16
+ : process.platform === 'win32' ? ['cmd', ['/c', 'start', '', url]] : ['xdg-open', [url]];
17
+ try { spawn(cmd, args, { stdio: 'ignore', detached: true }).on('error', () => {}).unref(); return true; } catch { return false; }
18
+ }
19
+
20
+ /**
21
+ * @param {object} p
22
+ * @param {object} p.info 页面展示:{ site, chain, chainName, currency, container, holder, operator, ttlHours, quote }
23
+ * @param {(account: string) => Promise<string|null>} p.accept 页面连上钱包时校验账户:返回 null 通过,否则返回原因
24
+ * @param {number} [p.port=0] 端口(0 = 随机)
25
+ * @returns {Promise<{ url, wallet, connected: Promise<string>, label(s), log(entry), finish(result), close() }>}
26
+ */
27
+ export async function startWalletBridge({ info, accept, port = 0 }) {
28
+ const token = randomBytes(16).toString('hex');
29
+ const state = { info, account: null, pending: null, history: [], log: [], done: null, error: null };
30
+ let seq = 0, label = '';
31
+ const waiters = new Map(); // id → { resolve, reject }
32
+ let onConnected;
33
+ const connected = new Promise((r) => { onConnected = r; });
34
+
35
+ const wallet = {
36
+ request({ method, params = [] }) {
37
+ if (!FORWARD.has(method)) return Promise.reject(new Error('unsupported wallet method: ' + method));
38
+ if (method === 'eth_chainId' && state.chainId) return Promise.resolve(state.chainId); // 页面连接时已报,切链后会更新
39
+ const id = ++seq;
40
+ state.pending = { id, method, params, label: method === 'eth_sendTransaction' ? label : '切换钱包网络' };
41
+ return new Promise((resolve, reject) => waiters.set(id, { resolve, reject }));
42
+ },
43
+ };
44
+
45
+ const json = (res, code, obj) => { res.writeHead(code, { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' }); res.end(JSON.stringify(obj)); };
46
+ const body = (req) => new Promise((resolve) => { let s = ''; req.on('data', (d) => { s += d; if (s.length > 65536) req.destroy(); }); req.on('end', () => { try { resolve(JSON.parse(s || '{}')); } catch { resolve({}); } }); });
47
+
48
+ const server = http.createServer(async (req, res) => {
49
+ const u = new URL(req.url, 'http://127.0.0.1');
50
+ if (u.searchParams.get('t') !== token) { res.writeHead(403).end('forbidden'); return; }
51
+ if (req.method === 'GET' && u.pathname === '/') {
52
+ // 不加 CSP:部分钱包扩展靠往页面注入脚本工作,严格 CSP 会把它们挡掉(页面只在本机、凭随机口令访问)
53
+ res.writeHead(200, { 'content-type': 'text/html; charset=utf-8', 'cache-control': 'no-store', 'referrer-policy': 'no-referrer' });
54
+ res.end(PAGE); return;
55
+ }
56
+ if (req.method === 'GET' && u.pathname === '/api/state') {
57
+ const { pending } = state;
58
+ return json(res, 200, { info: state.info, account: state.account, pending: pending && { id: pending.id, method: pending.method, params: pending.params, label: pending.label }, history: state.history, log: state.log.slice(-200), done: state.done, error: state.error });
59
+ }
60
+ if (req.method === 'POST' && u.pathname === '/api/connect') {
61
+ const b = await body(req);
62
+ const why = await accept(String(b.account || '')).catch((e) => String(e.message || e));
63
+ if (why) return json(res, 200, { ok: false, reason: why });
64
+ state.account = b.account; state.chainId = b.chainId || null;
65
+ onConnected(b.account);
66
+ return json(res, 200, { ok: true });
67
+ }
68
+ if (req.method === 'POST' && u.pathname === '/api/chain') { const b = await body(req); state.chainId = b.chainId || null; return json(res, 200, { ok: true }); }
69
+ if (req.method === 'POST' && u.pathname === '/api/result') {
70
+ const b = await body(req);
71
+ const w = waiters.get(b.id);
72
+ if (!w || state.pending?.id !== b.id) return json(res, 200, { ok: false });
73
+ waiters.delete(b.id);
74
+ const p = state.pending; state.pending = null;
75
+ if (b.error) {
76
+ state.history.push({ label: p.label, method: p.method, error: b.error.message || '钱包拒绝' });
77
+ w.reject(Object.assign(new Error(b.error.message || '钱包拒绝'), { code: b.error.code }));
78
+ } else {
79
+ if (p.method === 'wallet_switchEthereumChain' || p.method === 'wallet_addEthereumChain') state.chainId = p.params?.[0]?.chainId || state.chainId;
80
+ if (p.method === 'eth_sendTransaction') state.history.push({ label: p.label, method: p.method, hash: b.result });
81
+ w.resolve(b.result);
82
+ }
83
+ return json(res, 200, { ok: true });
84
+ }
85
+ res.writeHead(404).end();
86
+ });
87
+ await new Promise((r) => server.listen(port, '127.0.0.1', r));
88
+ const url = `http://127.0.0.1:${server.address().port}/?t=${token}`;
89
+
90
+ return {
91
+ url, wallet, connected,
92
+ /** 下一笔钱包请求在页面上的说明 */
93
+ label: (s) => { label = s; },
94
+ /** 进度(页面日志) */
95
+ log: (entry) => { state.log.push({ at: Date.now(), ...entry }); },
96
+ finish: (result) => { state.done = result; },
97
+ fail: (message) => {
98
+ state.error = message;
99
+ for (const [id, w] of waiters) { w.reject(new Error(message)); waiters.delete(id); }
100
+ state.pending = null;
101
+ },
102
+ /** 页面还能拉到最终结果:稍等再关 */
103
+ close: (delayMs = 0) => new Promise((r) => setTimeout(() => { server.closeAllConnections?.(); server.close(() => r()); }, delayMs)),
104
+ };
105
+ }
106
+
107
+ const PAGE = `<!doctype html>
108
+ <html lang="zh"><head><meta charset="utf-8">
109
+ <meta name="viewport" content="width=device-width,initial-scale=1">
110
+ <title>TapePack 部署授权</title>
111
+ <style>
112
+ :root{--fg:#141414;--dim:#6b6b6b;--line:#e6e2da;--bg:#faf8f4;--card:#fff;--acc:#d9481b;--ok:#0a7a2f;--bad:#b3261e}
113
+ @media (prefers-color-scheme:dark){:root{--fg:#eee;--dim:#9a9a9a;--line:#333;--bg:#151515;--card:#1e1e1e;--ok:#5fcf86;--bad:#ff8a80}}
114
+ *{box-sizing:border-box}
115
+ body{font:15px/1.65 -apple-system,"PingFang SC","Microsoft YaHei",sans-serif;background:var(--bg);color:var(--fg);max-width:760px;margin:0 auto;padding:28px 16px 60px}
116
+ h1{font-size:22px;margin:0 0 4px}.sub{color:var(--dim);margin:0 0 18px}
117
+ .card{background:var(--card);border:1px solid var(--line);border-radius:10px;padding:14px 16px;margin:14px 0}
118
+ table{border-collapse:collapse;width:100%}td{padding:5px 0;vertical-align:top}td:first-child{color:var(--dim);width:7.5em;padding-right:10px}
119
+ .mono{font-family:ui-monospace,Menlo,monospace;font-size:13px;word-break:break-all}
120
+ button,select{font:inherit;padding:9px 16px;border-radius:8px;border:1px solid var(--line);background:var(--card);color:var(--fg)}
121
+ button.primary{background:var(--acc);border-color:var(--acc);color:#fff}button:disabled{opacity:.5}
122
+ .step{display:flex;gap:10px;padding:8px 0;border-top:1px solid var(--line)}.step:first-child{border-top:0}
123
+ .st{min-width:4.5em}.ok{color:var(--ok)}.bad{color:var(--bad)}.dim{color:var(--dim)}
124
+ #log{max-height:260px;overflow:auto;font-size:13px}
125
+ .hl{border-color:var(--acc)}
126
+ </style></head><body>
127
+ <h1>TapePack 部署授权</h1>
128
+ <p class="sub" id="sub">命令行发起的部署,需要容器持有人的钱包确认。</p>
129
+
130
+ <div class="card"><table id="info"></table></div>
131
+
132
+ <div class="card" id="connectCard">
133
+ <div style="display:flex;gap:10px;flex-wrap:wrap;align-items:center">
134
+ <select id="wallet"><option value="">检测钱包中…</option></select>
135
+ <button class="primary" id="connect">连接钱包</button>
136
+ </div>
137
+ <p class="dim" id="connectMsg" style="margin:8px 0 0">请用持有人钱包连接。连接后,钱包会依次弹出要确认的交易。</p>
138
+ </div>
139
+
140
+ <div class="card"><b>需要钱包确认</b><div id="steps" style="margin-top:6px"><div class="dim">连接钱包后,命令行会发起:① 给临时钱包充值(含服务费)② 授权临时钱包写入。</div></div></div>
141
+ <div class="card"><b>上传进度</b><div id="log" class="mono" style="margin-top:6px"><span class="dim">授权完成后,命令行用临时钱包逐笔写入。</span></div></div>
142
+
143
+ <script>
144
+ const T = new URLSearchParams(location.search).get('t');
145
+ const api = (p, body) => fetch(p + '?t=' + T, body ? { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(body) } : {}).then((r) => r.json());
146
+ const $ = (id) => document.getElementById(id);
147
+ const esc = (s) => String(s ?? '').replace(/[&<>"]/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' }[c]));
148
+ const fmt = (wei, cur) => (Number(BigInt(wei)) / 1e18).toFixed(6).replace(/0+$/, '').replace(/\\.$/, '') + ' ' + cur;
149
+
150
+ // —— 钱包发现:EIP-6963 + 旧式注入 ——
151
+ const WALLETS = [];
152
+ function addWallet(name, provider) {
153
+ if (!provider || WALLETS.some((x) => x.provider === provider)) return;
154
+ WALLETS.push({ name, provider });
155
+ $('wallet').innerHTML = WALLETS.map((x, i) => '<option value="' + i + '">' + esc(x.name) + '</option>').join('');
156
+ }
157
+ window.addEventListener('eip6963:announceProvider', (e) => addWallet(e.detail.info.name, e.detail.provider));
158
+ window.dispatchEvent(new Event('eip6963:requestProvider'));
159
+ function legacy() {
160
+ if (window.okxwallet) addWallet('OKX Wallet', window.okxwallet);
161
+ if (window.ethereum) addWallet(window.ethereum.isOkxWallet ? 'OKX Wallet' : window.ethereum.isMetaMask ? 'MetaMask' : '浏览器钱包', window.ethereum);
162
+ }
163
+ setTimeout(() => { legacy(); if (!WALLETS.length) $('wallet').innerHTML = '<option value="">未检测到钱包扩展</option>'; }, 600);
164
+
165
+ let provider = null, account = null, busy = 0, info = null;
166
+ $('connect').onclick = async () => {
167
+ legacy();
168
+ provider = (WALLETS[Number($('wallet').value)] || WALLETS[0] || {}).provider || null;
169
+ if (!provider) { $('connectMsg').innerHTML = '<span class="bad">未检测到浏览器钱包:请确认扩展已启用、已解锁,并刷新本页。</span>'; return; }
170
+ try {
171
+ const [a] = await provider.request({ method: 'eth_requestAccounts' });
172
+ const chainId = await provider.request({ method: 'eth_chainId' });
173
+ const r = await api('/api/connect', { account: a, chainId });
174
+ if (!r.ok) { $('connectMsg').innerHTML = '<span class="bad">' + esc(r.reason) + '</span>'; return; }
175
+ account = a;
176
+ $('connectMsg').innerHTML = '<span class="ok">已连接 ' + esc(a) + '</span>,请留意钱包弹窗。';
177
+ $('connect').disabled = true;
178
+ provider.on?.('chainChanged', (c) => api('/api/chain', { chainId: c }));
179
+ } catch (e) { $('connectMsg').innerHTML = '<span class="bad">连接失败:' + esc(e && e.message || e) + '</span>'; }
180
+ };
181
+
182
+ async function forward(p) {
183
+ if (busy === p.id) return;
184
+ busy = p.id;
185
+ try {
186
+ const result = await provider.request({ method: p.method, params: p.params });
187
+ await api('/api/result', { id: p.id, result });
188
+ } catch (e) {
189
+ await api('/api/result', { id: p.id, error: { code: e && e.code, message: (e && e.message) || String(e) } });
190
+ }
191
+ }
192
+
193
+ function render(s) {
194
+ info = s.info;
195
+ const i = s.info, q = i.quote || {};
196
+ const rows = [
197
+ ['站点', esc(i.site || '') + '(' + esc(i.chainName) + ')'],
198
+ ['容器', '<span class="mono">' + esc(i.container) + '</span>'],
199
+ ['持有人', '<span class="mono">' + esc(i.holder || '(连接的钱包须能编辑该容器)') + '</span>'],
200
+ ['授权给', '<span class="mono">' + esc(i.operator) + '</span><br><span class="dim">本次部署的临时钱包,只能写本容器的网站文件,' + esc(i.ttlHours) + ' 小时后自动失效;写完余额退回持有人</span>'],
201
+ ];
202
+ if (q.upload) rows.push(['上传', q.upload.files.length + ' 个文件,' + q.upload.txs + ' 笔写入']);
203
+ if (q.chainCost) rows.push(['链上费用', q.chainCost.native + ' ' + esc(q.chainCost.currency) + (q.chainCost.usd != null ? '(约 $' + q.chainCost.usd + ')' : '')]);
204
+ if (q.serviceFee) rows.push(['服务费', q.serviceFee.free ? '免费' : q.serviceFee.native + ' ' + esc(q.chainCost.currency) + '($' + q.serviceFee.usd + ',转 <span class="mono">' + esc(q.serviceFee.to) + '</span>)']);
205
+ $('info').innerHTML = rows.map(([k, v]) => '<tr><td>' + k + '</td><td>' + v + '</td></tr>').join('');
206
+ $('sub').textContent = '命令行正在部署 ' + (i.site || i.container) + ',需要持有人钱包确认。';
207
+
208
+ const steps = s.history.map((h) => '<div class="step"><span class="st ' + (h.error ? 'bad">已拒绝' : 'ok">已确认') + '</span><span>' + esc(h.label) + (h.hash ? '<br><span class="mono dim">' + esc(h.hash) + '</span>' : '') + (h.error ? '<br><span class="bad">' + esc(h.error) + '</span>' : '') + '</span></div>');
209
+ if (s.pending) {
210
+ const p = s.pending, tx = p.params && p.params[0] || {};
211
+ const val = tx.value && BigInt(tx.value) > 0n ? ',金额 ' + fmt(tx.value, i.currency) : '';
212
+ steps.push('<div class="step"><span class="st">请确认</span><span>' + esc(p.label) + esc(val) + '<br><span class="dim">请在钱包弹窗里确认</span> <button id="retry" style="padding:3px 10px;font-size:13px">没看到弹窗?重新发起</button></span></div>');
213
+ }
214
+ if (steps.length) $('steps').innerHTML = steps.join('');
215
+ const r = document.getElementById('retry'); if (r) r.onclick = () => { busy = 0; if (s.pending && provider) forward(s.pending); };
216
+
217
+ if (s.log.length) $('log').innerHTML = s.log.map((l) => '<div class="' + (l.cls || '') + '">' + esc(l.text) + '</div>').join('');
218
+ if (s.done) $('log').insertAdjacentHTML('beforeend', '<div class="ok" style="margin-top:6px;font-weight:600">' + esc(s.done.text) + '</div>');
219
+ if (s.error) $('log').insertAdjacentHTML('beforeend', '<div class="bad" style="margin-top:6px">' + esc(s.error) + '</div>');
220
+ }
221
+
222
+ let stopped = false;
223
+ async function tick() {
224
+ try {
225
+ const s = await api('/api/state');
226
+ render(s);
227
+ if (s.pending && provider && account) forward(s.pending);
228
+ if (s.done || s.error) stopped = true;
229
+ } catch { if (stopped) return; }
230
+ if (!stopped) setTimeout(tick, 1000);
231
+ }
232
+ tick();
233
+ </script>
234
+ </body></html>`;