dsh-token-plugin 0.0.0-stage → 2.0.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/CHANGELOG.md +671 -0
- package/LICENSE +21 -0
- package/README.md +203 -3
- package/README.zh.md +205 -0
- package/cordis.patch.yml +23 -0
- package/lib/client.js +2725 -0
- package/lib/index.js +1722 -0
- package/lib/records.js +125 -0
- package/lib/storage.js +66 -0
- package/package.json +101 -6
- package/scripts/check-package.js +84 -0
- package/scripts/release.mjs +97 -0
- package/scripts/upload-asset.mjs +50 -0
- package/scripts/wire.js +109 -0
package/lib/records.js
ADDED
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dsh-usage-plugin — 用量记录归一化与身份判定(纯逻辑层,无副作用)。
|
|
3
|
+
*
|
|
4
|
+
* 两条原则:
|
|
5
|
+
* 1. 归一化不猜测、不补造数据。上游没上报的字段就是 0,绝不用别的字段"兜底"
|
|
6
|
+
* 填充——那会把口径污染成不可解释的数字(例如用未命中 token 冒充缓存写入,
|
|
7
|
+
* 会让"四桶合计"凭空多出一笔)。
|
|
8
|
+
* 2. 去重按"身份"而不是按"毫秒"。同一毫秒完全可能产生两条不同请求的记录,
|
|
9
|
+
* 只按 time 去重会让它们互相覆盖、静默丢数据。
|
|
10
|
+
*
|
|
11
|
+
* @module dsh-usage-plugin/records
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
/** 归一化会保留的字符串字段(缺省写空串,保持记录形状稳定)。 */
|
|
15
|
+
const STRING_FIELDS = ['model', 'provider', 'purpose', 'sessionId', 'finishReason', 'fxDate']
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* 只在确实有值时才写入的标识字段。
|
|
19
|
+
*
|
|
20
|
+
* 旧记录没有它们,缺省必须"字段不存在",而不是留下一个空串——空串会让
|
|
21
|
+
* `recordKey` 的分支判断和"这条记录是哪来的"这类展示逻辑看起来像有值。
|
|
22
|
+
*/
|
|
23
|
+
const OPTIONAL_STRING_FIELDS = ['origin', 'recordId']
|
|
24
|
+
|
|
25
|
+
/** 归一化会保留的非负计数字段。 */
|
|
26
|
+
const TOKEN_FIELDS = [
|
|
27
|
+
'inputTokens', 'outputTokens', 'cacheReadTokens', 'cacheWriteTokens',
|
|
28
|
+
'reasoningTokens', 'usdCnyRate', 'maxTokens', 'maxUses'
|
|
29
|
+
]
|
|
30
|
+
|
|
31
|
+
/** 归一化会保留的非负整数字段(宿主 turn/step/seq 语义)。 */
|
|
32
|
+
const INDEX_FIELDS = ['seq', 'turn', 'step', 'slotSeq']
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* 把一条来自磁盘 / 导入文件 / 实时探针的原始数据归一化为插件内部记录。
|
|
36
|
+
*
|
|
37
|
+
* @param raw - 任意来源的候选记录。
|
|
38
|
+
* @returns 归一化后的记录;缺少可用时间戳时返回 null(该条应被丢弃)。
|
|
39
|
+
*/
|
|
40
|
+
export function normalizeRecord(raw) {
|
|
41
|
+
if (!raw || typeof raw !== 'object') return null
|
|
42
|
+
const time = Number(raw.time)
|
|
43
|
+
if (!Number.isFinite(time) || time <= 0) return null
|
|
44
|
+
|
|
45
|
+
const rec = { time }
|
|
46
|
+
for (const key of STRING_FIELDS) rec[key] = String(raw[key] || '')
|
|
47
|
+
for (const key of OPTIONAL_STRING_FIELDS) {
|
|
48
|
+
if (typeof raw[key] === 'string' && raw[key] !== '') rec[key] = raw[key]
|
|
49
|
+
}
|
|
50
|
+
for (const key of TOKEN_FIELDS) {
|
|
51
|
+
const n = Number(raw[key])
|
|
52
|
+
rec[key] = Number.isFinite(n) && n > 0 ? n : 0
|
|
53
|
+
}
|
|
54
|
+
rec.interrupted = !!raw.interrupted
|
|
55
|
+
// 估算标记:为 true 时该记录的 token / 费用不是上游实测值,UI 与导出必须区分。
|
|
56
|
+
if (raw.estimated) rec.estimated = true
|
|
57
|
+
for (const key of INDEX_FIELDS) {
|
|
58
|
+
if (Number.isSafeInteger(raw[key]) && raw[key] >= 0) rec[key] = raw[key]
|
|
59
|
+
}
|
|
60
|
+
// 计费时刻:记录发生时"生效的价格档"所对应的时刻。历史回填 / 导入的记录
|
|
61
|
+
// 其 time 可能与真实调用时刻不同,用 pricingTime 冻结取价时点。
|
|
62
|
+
if (Number.isFinite(raw.pricingTime) && raw.pricingTime > 0) rec.pricingTime = raw.pricingTime
|
|
63
|
+
// 冻结单价快照:记录产生时实际用到的三个单价(元 / 百万 tokens)。
|
|
64
|
+
// 有它,历史费用就不会因为后来改价格表被重算;没它(旧记录 / 手工导入)
|
|
65
|
+
// 就退回用当前价格表计算,由 UI 另行提示。
|
|
66
|
+
const unit = raw.unit
|
|
67
|
+
if (unit && typeof unit === 'object') {
|
|
68
|
+
const hit = Number(unit.hit)
|
|
69
|
+
const miss = Number(unit.miss)
|
|
70
|
+
const out = Number(unit.out)
|
|
71
|
+
const any = [hit, miss, out].some((n) => Number.isFinite(n) && n >= 0)
|
|
72
|
+
if (any) {
|
|
73
|
+
const snapshot = {
|
|
74
|
+
hit: Number.isFinite(hit) && hit > 0 ? hit : 0,
|
|
75
|
+
miss: Number.isFinite(miss) && miss > 0 ? miss : 0,
|
|
76
|
+
out: Number.isFinite(out) && out > 0 ? out : 0
|
|
77
|
+
}
|
|
78
|
+
const fx = Number(unit.fx)
|
|
79
|
+
if (Number.isFinite(fx) && fx > 0) snapshot.fx = fx
|
|
80
|
+
rec.unit = snapshot
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
return rec
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* 一条记录的身份键。
|
|
88
|
+
*
|
|
89
|
+
* 优先级:
|
|
90
|
+
* 1. `recordId`——新探针写入的 UUID,天然唯一,直接用它。
|
|
91
|
+
* 2. 扫描来源的 `(origin, sessionId, seq)`——宿主日志的 seq 即数组下标,稳定。
|
|
92
|
+
* 3. 复合指纹——老记录没有上面两者,用"会话 + 时刻 + 模型 + 用途 + 四桶用量 +
|
|
93
|
+
* 结束原因 + 中断标记"共同构成身份。比只按 time 安全:同毫秒的不同请求不会
|
|
94
|
+
* 再互相覆盖,而真正的重复项仍能被识别出来。
|
|
95
|
+
*
|
|
96
|
+
* @param r - 归一化后的记录。
|
|
97
|
+
* @returns 字符串身份键。
|
|
98
|
+
*/
|
|
99
|
+
export function recordKey(r) {
|
|
100
|
+
if (r.recordId) return 'id:' + r.recordId
|
|
101
|
+
if (r.origin === 'scan' && Number.isSafeInteger(r.seq)) return JSON.stringify(['scan', r.sessionId, r.seq])
|
|
102
|
+
return JSON.stringify([
|
|
103
|
+
r.sessionId || '', r.time, r.provider, r.model, r.purpose || '',
|
|
104
|
+
r.origin || '', r.estimated === true,
|
|
105
|
+
r.inputTokens || 0, r.outputTokens || 0, r.cacheReadTokens || 0, r.cacheWriteTokens || 0,
|
|
106
|
+
r.reasoningTokens || 0, r.finishReason || '', !!r.interrupted
|
|
107
|
+
])
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* 一条记录在展示层的"可计费 token 合计"。
|
|
112
|
+
*
|
|
113
|
+
* 口径(与 DSH 宿主 `TokenUsage` 一致,四桶互斥):
|
|
114
|
+
* `未命中输入 + 缓存命中 + 缓存写入 + 输出`
|
|
115
|
+
*
|
|
116
|
+
* 注意 **不含 reasoningTokens**:宿主 `outputTokens` 就是上游的
|
|
117
|
+
* `completion_tokens`,而 `reasoningTokens` 是它的子集(信息性字段)。
|
|
118
|
+
* 把它再加一遍会让总数凭空多出一笔。
|
|
119
|
+
*
|
|
120
|
+
* @param r - 记录(已归一化或 API 投影形状均可)。
|
|
121
|
+
* @returns 互斥四桶之和。
|
|
122
|
+
*/
|
|
123
|
+
export function billableTokens(r) {
|
|
124
|
+
return (r.inputTokens || 0) + (r.cacheReadTokens || 0) + (r.cacheWriteTokens || 0) + (r.outputTokens || 0)
|
|
125
|
+
}
|
package/lib/storage.js
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dsh-usage-plugin — 本地持久化的写入 / 读取原语(无业务语义)。
|
|
3
|
+
*
|
|
4
|
+
* 用量历史是这个插件的唯一资产,而它此前有一处很危险的行为:
|
|
5
|
+
* 直接 `writeFile` 覆盖 + 读取时把解析失败静默 catch 成"空数组"。
|
|
6
|
+
* 一次进程崩溃或一次半截写入,就可能让整份历史被当成空数据再被覆盖回去。
|
|
7
|
+
* 这里把两件事分开:
|
|
8
|
+
* - 写:临时文件 → 刷盘 → 原子 rename,失败时原文件纹丝不动。
|
|
9
|
+
* - 读:文件不存在才是空;解析失败必须抛错,由调用方决定如何处置(保留原件并报错)。
|
|
10
|
+
*
|
|
11
|
+
* @module dsh-usage-plugin/storage
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { promises as fs } from 'node:fs'
|
|
15
|
+
import path from 'node:path'
|
|
16
|
+
import { randomUUID } from 'node:crypto'
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* 原子写入文本:同目录临时文件写完并 fsync 后 rename 覆盖目标。
|
|
20
|
+
*
|
|
21
|
+
* 任一环节失败都会在 finally 里清掉临时文件,并且**不触碰目标文件**——调用方
|
|
22
|
+
* 因此可以安全地把"写入失败"当作可重试事件,而不是数据销毁事件。
|
|
23
|
+
*
|
|
24
|
+
* @param file - 目标文件绝对路径(父目录会自动创建)。
|
|
25
|
+
* @param text - 要写入的完整文本。
|
|
26
|
+
*/
|
|
27
|
+
export async function atomicWriteText(file, text) {
|
|
28
|
+
await fs.mkdir(path.dirname(file), { recursive: true })
|
|
29
|
+
const temporary = file + '.' + randomUUID() + '.tmp'
|
|
30
|
+
let handle
|
|
31
|
+
try {
|
|
32
|
+
handle = await fs.open(temporary, 'wx')
|
|
33
|
+
await handle.writeFile(text, 'utf8')
|
|
34
|
+
await handle.sync()
|
|
35
|
+
await handle.close()
|
|
36
|
+
handle = null
|
|
37
|
+
await fs.rename(temporary, file)
|
|
38
|
+
} finally {
|
|
39
|
+
if (handle) await handle.close().catch(() => {})
|
|
40
|
+
await fs.unlink(temporary).catch(() => {})
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* 读取用量记录数组。
|
|
46
|
+
*
|
|
47
|
+
* 只有"文件确实不存在"才返回空数组。解析失败、类型不对、读取被拒绝一律抛出——
|
|
48
|
+
* 静默退化成空数组会让下一次写入把真实历史覆盖掉。
|
|
49
|
+
*
|
|
50
|
+
* @param file - 用量数据文件绝对路径。
|
|
51
|
+
* @returns 记录数组(未归一化,由调用方 `normalizeRecord`)。
|
|
52
|
+
* @throws 文件存在但不可读 / 不是合法 JSON / 顶层不是数组。
|
|
53
|
+
*/
|
|
54
|
+
export async function readStoredRecords(file) {
|
|
55
|
+
let text
|
|
56
|
+
try {
|
|
57
|
+
text = await fs.readFile(file, 'utf8')
|
|
58
|
+
} catch (error) {
|
|
59
|
+
if (error && error.code === 'ENOENT') return []
|
|
60
|
+
throw error
|
|
61
|
+
}
|
|
62
|
+
if (text.trim() === '') return []
|
|
63
|
+
const records = JSON.parse(text)
|
|
64
|
+
if (!Array.isArray(records)) throw new Error('用量数据文件的顶层必须是数组')
|
|
65
|
+
return records
|
|
66
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,101 @@
|
|
|
1
|
-
{
|
|
2
|
-
"name": "dsh-token-plugin",
|
|
3
|
-
"version": "
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
1
|
+
{
|
|
2
|
+
"name": "dsh-token-plugin",
|
|
3
|
+
"version": "2.0.0",
|
|
4
|
+
"description": "DeepSeek Harness usage & cost tracker plugin: per-call token/cache-hit stats, peak/off-peak billing, CSV/JSON/PNG export with custom destination, and persistent local storage. Ships a host half plus a web client half in one npm package; installs into a DSH profile as a dsh.bundle from a local package or a published release.",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"deepseek",
|
|
7
|
+
"harness",
|
|
8
|
+
"dsh",
|
|
9
|
+
"plugin",
|
|
10
|
+
"usage",
|
|
11
|
+
"cost",
|
|
12
|
+
"tokens",
|
|
13
|
+
"cache"
|
|
14
|
+
],
|
|
15
|
+
"license": "MIT",
|
|
16
|
+
"type": "module",
|
|
17
|
+
"main": "lib/index.js",
|
|
18
|
+
"repository": {
|
|
19
|
+
"type": "git",
|
|
20
|
+
"url": "git+https://github.com/realhenrylan/dsh-token-plugin.git"
|
|
21
|
+
},
|
|
22
|
+
"contributors": [
|
|
23
|
+
{
|
|
24
|
+
"name": "ayleen",
|
|
25
|
+
"url": "https://github.com/ayleen"
|
|
26
|
+
},
|
|
27
|
+
{
|
|
28
|
+
"name": "wuhuqif176",
|
|
29
|
+
"url": "https://github.com/wuhuqif176"
|
|
30
|
+
},
|
|
31
|
+
{
|
|
32
|
+
"name": "Martin-soaring-dev",
|
|
33
|
+
"url": "https://github.com/Martin-soaring-dev"
|
|
34
|
+
},
|
|
35
|
+
{
|
|
36
|
+
"name": "liu3734",
|
|
37
|
+
"url": "https://github.com/liu3734"
|
|
38
|
+
},
|
|
39
|
+
{
|
|
40
|
+
"name": "mumuer1024",
|
|
41
|
+
"url": "https://github.com/mumuer1024"
|
|
42
|
+
},
|
|
43
|
+
{
|
|
44
|
+
"name": "zhiqiangme",
|
|
45
|
+
"url": "https://github.com/zhiqiangme"
|
|
46
|
+
},
|
|
47
|
+
{
|
|
48
|
+
"name": "vb2250158",
|
|
49
|
+
"url": "https://github.com/vb2250158"
|
|
50
|
+
}
|
|
51
|
+
],
|
|
52
|
+
"homepage": "https://github.com/realhenrylan/dsh-token-plugin#readme",
|
|
53
|
+
"bugs": {
|
|
54
|
+
"url": "https://github.com/realhenrylan/dsh-token-plugin/issues"
|
|
55
|
+
},
|
|
56
|
+
"exports": {
|
|
57
|
+
".": "./lib/index.js",
|
|
58
|
+
"./client": "./lib/client.js",
|
|
59
|
+
"./package.json": "./package.json"
|
|
60
|
+
},
|
|
61
|
+
"engines": {
|
|
62
|
+
"node": ">=18"
|
|
63
|
+
},
|
|
64
|
+
"dsh": {
|
|
65
|
+
"bundle": {
|
|
66
|
+
"patch": "./cordis.patch.yml"
|
|
67
|
+
},
|
|
68
|
+
"client": {
|
|
69
|
+
"platform": "web",
|
|
70
|
+
"inject": [
|
|
71
|
+
"@deepseek-ai/dsh-client-ui-conversation"
|
|
72
|
+
]
|
|
73
|
+
}
|
|
74
|
+
},
|
|
75
|
+
"files": [
|
|
76
|
+
"lib",
|
|
77
|
+
"scripts",
|
|
78
|
+
"cordis.patch.yml",
|
|
79
|
+
"README.md",
|
|
80
|
+
"README.zh.md",
|
|
81
|
+
"CHANGELOG.md"
|
|
82
|
+
],
|
|
83
|
+
"scripts": {
|
|
84
|
+
"sync": "node sync-to-profile.mjs",
|
|
85
|
+
"wire": "node scripts/wire.js",
|
|
86
|
+
"check": "node scripts/check-package.js",
|
|
87
|
+
"prepublishOnly": "node scripts/check-package.js",
|
|
88
|
+
"pack": "npm pack"
|
|
89
|
+
},
|
|
90
|
+
"peerDependencies": {
|
|
91
|
+
"@deepseek-ai/cordis": ">=4.0.1"
|
|
92
|
+
},
|
|
93
|
+
"peerDependenciesMeta": {
|
|
94
|
+
"@deepseek-ai/cordis": {
|
|
95
|
+
"optional": true
|
|
96
|
+
}
|
|
97
|
+
},
|
|
98
|
+
"dependencies": {
|
|
99
|
+
"undici": "^7.0.0"
|
|
100
|
+
}
|
|
101
|
+
}
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dsh-usage-plugin — pre-publish sanity gate (dependency-free).
|
|
3
|
+
*
|
|
4
|
+
* Run by `npm run check` and `npm run prepublishOnly` before the package is
|
|
5
|
+
* packed/uploaded. It verifies every piece of the DSH plugin contract that
|
|
6
|
+
* would otherwise fail LOUDLY at boot or client-load time when missing:
|
|
7
|
+
*
|
|
8
|
+
* 1. `dsh.bundle.patch` — the profile bundle patch file exists and carries a
|
|
9
|
+
* valid insert entry naming this package (auto-activation via
|
|
10
|
+
* `dsh plugin --profile <name> add <pkg>` depends on it).
|
|
11
|
+
* 2. `dsh.client` — the web client declaration and `exports["./client"]`
|
|
12
|
+
* bundle exist (the host serves it at `/plugins/<name>/client.js`).
|
|
13
|
+
* 3. `files` — the publish whitelist covers every artifact above.
|
|
14
|
+
*
|
|
15
|
+
* This is a structural gate, not a full parser; the authoritative YAML +
|
|
16
|
+
* loader-composition validation is done by DSH itself at profile boot.
|
|
17
|
+
*/
|
|
18
|
+
import { readFileSync, existsSync } from "node:fs";
|
|
19
|
+
import { resolve, dirname } from "node:path";
|
|
20
|
+
import { fileURLToPath } from "node:url";
|
|
21
|
+
|
|
22
|
+
const pkgDir = resolve(dirname(fileURLToPath(import.meta.url)), "..");
|
|
23
|
+
|
|
24
|
+
const fail = (message) => {
|
|
25
|
+
console.error(`[check-package] FAIL: ${message}`);
|
|
26
|
+
process.exitCode = 1;
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
let ok = true;
|
|
30
|
+
const check = (condition, message) => {
|
|
31
|
+
if (!condition) {
|
|
32
|
+
fail(message);
|
|
33
|
+
ok = false;
|
|
34
|
+
}
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
let pkg;
|
|
38
|
+
try {
|
|
39
|
+
pkg = JSON.parse(readFileSync(resolve(pkgDir, "package.json"), "utf8"));
|
|
40
|
+
} catch (error) {
|
|
41
|
+
console.error(`[check-package] FAIL: cannot read package.json: ${String(error && error.message || error)}`);
|
|
42
|
+
process.exit(1);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// ── identity ───────────────────────────────────────────────────────────────
|
|
46
|
+
check(typeof pkg.name === "string" && pkg.name.length > 0, "package.json must declare a name");
|
|
47
|
+
check(typeof pkg.version === "string" && /^\d+\.\d+\.\d+/.test(pkg.version), `invalid version ${JSON.stringify(pkg.version)}`);
|
|
48
|
+
|
|
49
|
+
// ── dsh.bundle patch (host-side auto-activation) ───────────────────────────
|
|
50
|
+
const bundlePatch = pkg.dsh && pkg.dsh.bundle && pkg.dsh.bundle.patch;
|
|
51
|
+
check(typeof bundlePatch === "string" && bundlePatch.length > 0, 'package.json must declare "dsh": { "bundle": { "patch": "..." } }');
|
|
52
|
+
if (typeof bundlePatch === "string") {
|
|
53
|
+
const patchPath = resolve(pkgDir, bundlePatch);
|
|
54
|
+
check(existsSync(patchPath), `dsh.bundle.patch file not found: ${bundlePatch}`);
|
|
55
|
+
if (existsSync(patchPath)) {
|
|
56
|
+
const text = readFileSync(patchPath, "utf8");
|
|
57
|
+
check(text.includes("- insert:"), `bundle patch ${bundlePatch} must contain a top-level "- insert:" entry`);
|
|
58
|
+
check(text.includes(`name: '${pkg.name}'`) || text.includes(`name: "${pkg.name}"`), `bundle patch ${bundlePatch} must insert an entry with name: '${pkg.name}'`);
|
|
59
|
+
const required = ["fs", "webServer", "subprocess", "credentials", "sandboxPolicy", "agents"];
|
|
60
|
+
const missing = required.filter((service) => !text.includes(`- ${service}`));
|
|
61
|
+
check(missing.length === 0, `bundle patch ${bundlePatch} is missing inject entries: ${missing.join(", ")}`);
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// ── dsh.client + client bundle (browser half) ──────────────────────────────
|
|
66
|
+
const clientDecl = pkg.dsh && pkg.dsh.client;
|
|
67
|
+
check(clientDecl !== null && typeof clientDecl === "object", 'package.json must declare "dsh": { "client": { "platform": "web", ... } }');
|
|
68
|
+
if (clientDecl !== null && typeof clientDecl === "object") {
|
|
69
|
+
check(clientDecl.platform === "web", `dsh.client.platform must be "web" (got ${JSON.stringify(clientDecl.platform)})`);
|
|
70
|
+
check(Array.isArray(clientDecl.inject) && clientDecl.inject.length > 0, "dsh.client.inject must be a non-empty string array");
|
|
71
|
+
}
|
|
72
|
+
const clientRel = pkg.exports && (typeof pkg.exports["./client"] === "string" ? pkg.exports["./client"] : (pkg.exports["./client"] && pkg.exports["./client"].default));
|
|
73
|
+
check(typeof clientRel === "string", 'package.json must export "./client" pointing at the client bundle');
|
|
74
|
+
if (typeof clientRel === "string") {
|
|
75
|
+
check(existsSync(resolve(pkgDir, clientRel)), `client bundle not found: ${clientRel} (run the client build before publishing)`);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// ── files whitelist ────────────────────────────────────────────────────────
|
|
79
|
+
check(Array.isArray(pkg.files), "package.json must declare a files whitelist");
|
|
80
|
+
for (const entry of pkg.files || []) {
|
|
81
|
+
check(existsSync(resolve(pkgDir, entry)), `files entry not found: ${entry}`);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
if (ok) console.log(`[check-package] OK — ${pkg.name}@${pkg.version} satisfies the DSH plugin contract`);
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import { spawnSync } from 'node:child_process'
|
|
2
|
+
import https from 'node:https'
|
|
3
|
+
import fs from 'node:fs'
|
|
4
|
+
import path from 'node:path'
|
|
5
|
+
|
|
6
|
+
const REPO = 'realhenrylan/dsh-token-plugin'
|
|
7
|
+
const VERSION = JSON.parse(fs.readFileSync(path.join(process.cwd(), 'package.json'), 'utf8')).version
|
|
8
|
+
const TAG = `v${VERSION}`
|
|
9
|
+
|
|
10
|
+
function getCredential() {
|
|
11
|
+
const input = 'protocol=https\nhost=github.com\n\n'
|
|
12
|
+
const res = spawnSync('git', ['credential', 'fill'], { input, encoding: 'utf8' })
|
|
13
|
+
const cred = {}
|
|
14
|
+
for (const l of (res.stdout || '').split('\n')) {
|
|
15
|
+
const idx = l.indexOf('=')
|
|
16
|
+
if (idx > 0) cred[l.slice(0, idx).trim()] = l.slice(idx + 1).trim()
|
|
17
|
+
}
|
|
18
|
+
return cred
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function api(method, apiPath, body, contentType = 'application/json', host = 'api.github.com') {
|
|
22
|
+
return new Promise((resolve) => {
|
|
23
|
+
const cred = getCredential()
|
|
24
|
+
const isBuffer = Buffer.isBuffer(body)
|
|
25
|
+
const req = https.request({
|
|
26
|
+
host,
|
|
27
|
+
path: apiPath,
|
|
28
|
+
method,
|
|
29
|
+
headers: {
|
|
30
|
+
'Authorization': `Bearer ${cred.password || ''}`,
|
|
31
|
+
'User-Agent': 'dsh-usage-plugin-publish',
|
|
32
|
+
'Accept': 'application/vnd.github+json',
|
|
33
|
+
'Content-Type': contentType,
|
|
34
|
+
...(body != null ? { 'Content-Length': isBuffer ? body.length : Buffer.byteLength(body) } : {}),
|
|
35
|
+
},
|
|
36
|
+
rejectUnauthorized: false,
|
|
37
|
+
}, (res) => {
|
|
38
|
+
let data = ''
|
|
39
|
+
res.on('data', (d) => data += d)
|
|
40
|
+
res.on('end', () => resolve({ status: res.statusCode, body: data }))
|
|
41
|
+
})
|
|
42
|
+
req.on('error', (e) => resolve({ status: 0, body: e.message }))
|
|
43
|
+
if (body != null) req.write(body)
|
|
44
|
+
req.end()
|
|
45
|
+
})
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function extractChangelog(tag) {
|
|
49
|
+
const text = fs.readFileSync(path.join(process.cwd(), 'CHANGELOG.md'), 'utf8')
|
|
50
|
+
const lines = text.split('\n')
|
|
51
|
+
const re = new RegExp('^## ' + tag.replace(/\./g, '\\.') + '\\b')
|
|
52
|
+
const start = lines.findIndex((l) => re.test(l))
|
|
53
|
+
if (start < 0) return ''
|
|
54
|
+
let end = lines.length
|
|
55
|
+
for (let i = start + 1; i < lines.length; i++) {
|
|
56
|
+
if (/^## v\d/.test(lines[i])) { end = i; break }
|
|
57
|
+
}
|
|
58
|
+
return lines.slice(start, end).join('\n').trim()
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const body = extractChangelog(TAG)
|
|
62
|
+
if (!body) { console.error('changelog section not found for', TAG); process.exit(1) }
|
|
63
|
+
|
|
64
|
+
console.log('Creating release', TAG, 'for', REPO)
|
|
65
|
+
const created = await api('POST', `/repos/${REPO}/releases`, JSON.stringify({
|
|
66
|
+
tag_name: TAG,
|
|
67
|
+
name: TAG,
|
|
68
|
+
body,
|
|
69
|
+
draft: false,
|
|
70
|
+
prerelease: false,
|
|
71
|
+
}))
|
|
72
|
+
console.log('create status:', created.status)
|
|
73
|
+
if (created.status !== 201) {
|
|
74
|
+
console.error('create failed:', created.body.slice(0, 600))
|
|
75
|
+
process.exit(1)
|
|
76
|
+
}
|
|
77
|
+
const release = JSON.parse(created.body)
|
|
78
|
+
console.log('release id:', release.id, 'url:', release.html_url)
|
|
79
|
+
|
|
80
|
+
// 打包并上传 tarball 作为附件(Windows 下 npm 为 npm.cmd 批处理脚本,经 cmd /c 执行)
|
|
81
|
+
const pack = process.platform === 'win32'
|
|
82
|
+
? spawnSync('cmd', ['/c', 'npm', 'pack'], { encoding: 'utf8' })
|
|
83
|
+
: spawnSync('npm', ['pack'], { encoding: 'utf8' })
|
|
84
|
+
const tgzName = (pack.stdout || '').trim().split('\n').pop()
|
|
85
|
+
if (tgzName && fs.existsSync(tgzName)) {
|
|
86
|
+
const buf = fs.readFileSync(tgzName)
|
|
87
|
+
const assetPath = `/repos/${REPO}/releases/${release.id}/assets?name=${encodeURIComponent(tgzName)}`
|
|
88
|
+
console.log('uploading asset', tgzName, buf.length)
|
|
89
|
+
const asset = await api('POST', assetPath, buf, 'application/octet-stream', 'uploads.github.com')
|
|
90
|
+
console.log('asset status:', asset.status)
|
|
91
|
+
if (asset.status === 201) console.log('asset url:', JSON.parse(asset.body).browser_download_url)
|
|
92
|
+
else console.error('asset body:', asset.body.slice(0, 400))
|
|
93
|
+
try { fs.unlinkSync(tgzName) } catch (e) {}
|
|
94
|
+
} else {
|
|
95
|
+
console.log('no tarball produced; skip asset upload')
|
|
96
|
+
}
|
|
97
|
+
console.log('DONE')
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { spawnSync } from 'node:child_process'
|
|
2
|
+
import https from 'node:https'
|
|
3
|
+
import fs from 'node:fs'
|
|
4
|
+
|
|
5
|
+
const REPO = 'realhenrylan/dsh-token-plugin'
|
|
6
|
+
const RELEASE_ID = 375098880
|
|
7
|
+
|
|
8
|
+
function getCredential() {
|
|
9
|
+
const input = 'protocol=https\nhost=github.com\n\n'
|
|
10
|
+
const res = spawnSync('git', ['credential', 'fill'], { input, encoding: 'utf8' })
|
|
11
|
+
const cred = {}
|
|
12
|
+
for (const l of (res.stdout || '').split('\n')) {
|
|
13
|
+
const idx = l.indexOf('=')
|
|
14
|
+
if (idx > 0) cred[l.slice(0, idx).trim()] = l.slice(idx + 1).trim()
|
|
15
|
+
}
|
|
16
|
+
return cred
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function api(method, apiPath, body, contentType, host = 'api.github.com') {
|
|
20
|
+
return new Promise((resolve) => {
|
|
21
|
+
const cred = getCredential()
|
|
22
|
+
const isBuffer = Buffer.isBuffer(body)
|
|
23
|
+
const req = https.request({
|
|
24
|
+
host, path: apiPath, method,
|
|
25
|
+
headers: {
|
|
26
|
+
'Authorization': `Bearer ${cred.password || ''}`,
|
|
27
|
+
'User-Agent': 'dsh-usage-plugin-publish',
|
|
28
|
+
'Accept': 'application/vnd.github+json',
|
|
29
|
+
'Content-Type': contentType,
|
|
30
|
+
...(body != null ? { 'Content-Length': isBuffer ? body.length : Buffer.byteLength(body) } : {}),
|
|
31
|
+
},
|
|
32
|
+
rejectUnauthorized: false,
|
|
33
|
+
}, (res) => { let d = ''; res.on('data', (c) => d += c); res.on('end', () => resolve({ status: res.statusCode, body: d })) })
|
|
34
|
+
req.on('error', (e) => resolve({ status: 0, body: e.message }))
|
|
35
|
+
if (body != null) req.write(body)
|
|
36
|
+
req.end()
|
|
37
|
+
})
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const tgz = fs.readdirSync('.').find((f) => f.endsWith('.tgz'))
|
|
41
|
+
if (!tgz) { console.error('no tarball found'); process.exit(1) }
|
|
42
|
+
const buf = fs.readFileSync(tgz)
|
|
43
|
+
const assetPath = `/repos/${REPO}/releases/${RELEASE_ID}/assets?name=${encodeURIComponent(tgz)}`
|
|
44
|
+
console.log('uploading', tgz, buf.length)
|
|
45
|
+
const asset = await api('POST', assetPath, buf, 'application/octet-stream', 'uploads.github.com')
|
|
46
|
+
console.log('asset status:', asset.status)
|
|
47
|
+
if (asset.status === 201) console.log('asset url:', JSON.parse(asset.body).browser_download_url)
|
|
48
|
+
else console.error('asset body:', asset.body.slice(0, 400))
|
|
49
|
+
try { fs.unlinkSync(tgz) } catch (e) {}
|
|
50
|
+
console.log('DONE')
|
package/scripts/wire.js
ADDED
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dsh-usage-plugin — wiring script (LEGACY fallback).
|
|
3
|
+
*
|
|
4
|
+
* PREFERRED INSTALL: this package declares `dsh.bundle`, so it auto-activates
|
|
5
|
+
* with a single command and NO manual wiring:
|
|
6
|
+
*
|
|
7
|
+
* dsh plugin --profile web add dsh-token-plugin
|
|
8
|
+
*
|
|
9
|
+
* This script exists only for the manual fallback path (installing the package
|
|
10
|
+
* by hand into a profile's node_modules): it appends the plugin row to the
|
|
11
|
+
* profile's `cordis.patch.yml` so the plugin loads with the web app:
|
|
12
|
+
*
|
|
13
|
+
* npm run wire
|
|
14
|
+
*
|
|
15
|
+
* It is idempotent: running it again never duplicates the row. If it cannot
|
|
16
|
+
* find a profile patch it prints what to add manually and exits non-zero.
|
|
17
|
+
*/
|
|
18
|
+
import { readFileSync, writeFileSync, existsSync, readdirSync } from "node:fs";
|
|
19
|
+
import { resolve, join, dirname } from "node:path";
|
|
20
|
+
import { fileURLToPath } from "node:url";
|
|
21
|
+
|
|
22
|
+
const here = dirname(fileURLToPath(import.meta.url));
|
|
23
|
+
const pkgDir = resolve(here, "..");
|
|
24
|
+
const ROW_ID = "usage-plugin";
|
|
25
|
+
const PACKAGE_NAME = JSON.parse(readFileSync(join(pkgDir, "package.json"), "utf8")).name || "dsh-usage-plugin";
|
|
26
|
+
|
|
27
|
+
function candidateRoots() {
|
|
28
|
+
const roots = [];
|
|
29
|
+
// When installed at <profilesRoot>/node_modules/dsh-usage-plugin, the
|
|
30
|
+
// profiles root is two levels up; try several depths for other layouts.
|
|
31
|
+
for (let up = 1; up <= 4; up++) {
|
|
32
|
+
let p = pkgDir;
|
|
33
|
+
for (let i = 0; i < up; i++) p = resolve(p, "..");
|
|
34
|
+
roots.push(p);
|
|
35
|
+
}
|
|
36
|
+
if (process.env.DSH_HOME) {
|
|
37
|
+
roots.push(join(process.env.DSH_HOME, "profiles"));
|
|
38
|
+
roots.push(process.env.DSH_HOME);
|
|
39
|
+
}
|
|
40
|
+
return roots;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function findPatches(roots) {
|
|
44
|
+
const seen = new Set();
|
|
45
|
+
const patches = [];
|
|
46
|
+
for (const root of roots) {
|
|
47
|
+
let entries;
|
|
48
|
+
try {
|
|
49
|
+
entries = readdirSync(root, { withFileTypes: true });
|
|
50
|
+
} catch {
|
|
51
|
+
continue;
|
|
52
|
+
}
|
|
53
|
+
for (const entry of entries) {
|
|
54
|
+
if (!entry.isDirectory() || entry.name === "node_modules") continue;
|
|
55
|
+
const patch = join(root, entry.name, "cordis.patch.yml");
|
|
56
|
+
if (existsSync(patch) && !seen.has(patch)) {
|
|
57
|
+
seen.add(patch);
|
|
58
|
+
patches.push(patch);
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
return patches;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function alreadyWired(text) {
|
|
66
|
+
return text.includes(`id: ${ROW_ID}`);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function wire(patchPath) {
|
|
70
|
+
const text = readFileSync(patchPath, "utf8");
|
|
71
|
+
if (alreadyWired(text)) {
|
|
72
|
+
console.log(`[dsh-usage-plugin] already wired in ${patchPath}`);
|
|
73
|
+
return true;
|
|
74
|
+
}
|
|
75
|
+
const block =
|
|
76
|
+
"\n# " + PACKAGE_NAME + ": usage & cost tracking (installed via npm).\n" +
|
|
77
|
+
"- insert:\n" +
|
|
78
|
+
" - id: " + ROW_ID + "\n" +
|
|
79
|
+
" name: '" + PACKAGE_NAME + "'\n" +
|
|
80
|
+
" inject:\n" +
|
|
81
|
+
" - fs\n" +
|
|
82
|
+
" - webServer\n" +
|
|
83
|
+
" - subprocess\n" +
|
|
84
|
+
" - credentials\n" +
|
|
85
|
+
" - settings\n" +
|
|
86
|
+
" - sandboxPolicy\n" +
|
|
87
|
+
" - agents\n";
|
|
88
|
+
writeFileSync(patchPath, text.endsWith("\n") ? text + block : text + "\n" + block, "utf8");
|
|
89
|
+
console.log(`[dsh-usage-plugin] wired into ${patchPath}`);
|
|
90
|
+
return true;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
const patches = findPatches(candidateRoots());
|
|
94
|
+
if (patches.length === 0) {
|
|
95
|
+
console.error(
|
|
96
|
+
"[dsh-usage-plugin] no DSH profile patch found. Preferred: install with `dsh plugin --profile web add " + PACKAGE_NAME + "` (auto-wires via dsh.bundle, no manual editing).\n" +
|
|
97
|
+
"Manual fallback — add this row to your profile's cordis.patch.yml, then restart the app:\n\n" +
|
|
98
|
+
"- insert:\n" +
|
|
99
|
+
" - id: " + ROW_ID + "\n" +
|
|
100
|
+
" name: '" + PACKAGE_NAME + "'\n"
|
|
101
|
+
);
|
|
102
|
+
process.exit(1);
|
|
103
|
+
}
|
|
104
|
+
let ok = true;
|
|
105
|
+
for (const patch of patches) ok = wire(patch) && ok;
|
|
106
|
+
if (ok) {
|
|
107
|
+
console.log("[dsh-usage-plugin] done. Restart the DeepSeek Harness web app for the plugin to load.");
|
|
108
|
+
}
|
|
109
|
+
process.exit(ok ? 0 : 1);
|