@ychris12138/dsh-usage-stats 0.2.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +362 -0
- package/SECURITY.md +17 -0
- package/cordis.patch.yml +5 -0
- package/docs/images/usage-panel.svg +176 -0
- package/lib/accounts.js +1272 -0
- package/lib/balance.js +126 -0
- package/lib/client.js +1531 -0
- package/lib/index.js +619 -0
- package/lib/subscriptions.js +610 -0
- package/lib/usage.js +276 -0
- package/package.json +72 -0
- package/scripts/install.mjs +142 -0
package/lib/usage.js
ADDED
|
@@ -0,0 +1,276 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dsh-usage-stats — pure per-day, per-model token-usage aggregation over
|
|
3
|
+
* session event logs. Kept free of cordis imports so it can be unit-tested
|
|
4
|
+
* and validated against real logs outside the running harness.
|
|
5
|
+
*
|
|
6
|
+
* Aggregation semantics mirror `dsh-token-meter`'s `tokenUsage` projection:
|
|
7
|
+
* a usage sample rides an `assistant/chunk` (`data.chunk.type === "usage"`)
|
|
8
|
+
* or an `assistant/message` (`data.usage`); a repeated sample for the same
|
|
9
|
+
* (turn, step) REPLACES the earlier value instead of double counting it, and
|
|
10
|
+
* the replacement is re-attributed to the day of the later event.
|
|
11
|
+
*
|
|
12
|
+
* Each sample is additionally attributed to the model that produced it:
|
|
13
|
+
* `assistant/message` carries `data.message.source.model`; usage chunks fall
|
|
14
|
+
* back to the last `request/header` `data.header.config.model`; samples with
|
|
15
|
+
* no model information land in the `unknown` bucket.
|
|
16
|
+
*
|
|
17
|
+
* @module dsh-usage-stats/usage
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
/** Local-calendar `YYYY-MM-DD` key for a millisecond epoch. */
|
|
21
|
+
export function dayKey(timeMs) {
|
|
22
|
+
const date = new Date(timeMs);
|
|
23
|
+
const month = String(date.getMonth() + 1).padStart(2, "0");
|
|
24
|
+
const day = String(date.getDate()).padStart(2, "0");
|
|
25
|
+
return `${date.getFullYear()}-${month}-${day}`;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** Empty token bucket. */
|
|
29
|
+
export function zeroBuckets() {
|
|
30
|
+
return {
|
|
31
|
+
inputTokens: 0,
|
|
32
|
+
outputTokens: 0,
|
|
33
|
+
cacheReadTokens: 0,
|
|
34
|
+
cacheWriteTokens: 0
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** Provider usage → buckets (missing cache fields are absent in some reports). */
|
|
39
|
+
export function bucketsOf(usage) {
|
|
40
|
+
return {
|
|
41
|
+
inputTokens: usage.inputTokens ?? 0,
|
|
42
|
+
outputTokens: usage.outputTokens ?? 0,
|
|
43
|
+
cacheReadTokens: usage.cacheReadTokens ?? 0,
|
|
44
|
+
cacheWriteTokens: usage.cacheWriteTokens ?? 0
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** Total tokens across all buckets. */
|
|
49
|
+
export function totalTokens(buckets) {
|
|
50
|
+
return buckets.inputTokens + buckets.outputTokens + buckets.cacheReadTokens + buckets.cacheWriteTokens;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Prompt-side cache hit rate in percent (0–100, one decimal), or null when
|
|
55
|
+
* no prompt tokens were reported at all. Hits over the whole prompt side:
|
|
56
|
+
* cacheRead / (input + cacheRead + cacheWrite).
|
|
57
|
+
*/
|
|
58
|
+
export function cacheHitRate(buckets) {
|
|
59
|
+
const input = buckets.inputTokens ?? 0;
|
|
60
|
+
const cacheRead = buckets.cacheReadTokens ?? 0;
|
|
61
|
+
const cacheWrite = buckets.cacheWriteTokens ?? 0;
|
|
62
|
+
const promptTokens = input + cacheRead + cacheWrite;
|
|
63
|
+
if (promptTokens <= 0) return null;
|
|
64
|
+
return Math.round((cacheRead / promptTokens) * 1000) / 10;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function addInto(target, source) {
|
|
68
|
+
target.inputTokens += source.inputTokens;
|
|
69
|
+
target.outputTokens += source.outputTokens;
|
|
70
|
+
target.cacheReadTokens += source.cacheReadTokens;
|
|
71
|
+
target.cacheWriteTokens += source.cacheWriteTokens;
|
|
72
|
+
return target;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function subtractFrom(target, source) {
|
|
76
|
+
target.inputTokens -= source.inputTokens;
|
|
77
|
+
target.outputTokens -= source.outputTokens;
|
|
78
|
+
target.cacheReadTokens -= source.cacheReadTokens;
|
|
79
|
+
target.cacheWriteTokens -= source.cacheWriteTokens;
|
|
80
|
+
return target;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** Extract the usage sample an event carries, if any. */
|
|
84
|
+
function sampleOf(event) {
|
|
85
|
+
if (event.type === "assistant/chunk" && event.data?.chunk?.type === "usage") {
|
|
86
|
+
return {
|
|
87
|
+
key: `${event.data.turn}:${event.data.step}`,
|
|
88
|
+
usage: event.data.chunk.usage
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
if (event.type === "assistant/message" && event.data?.usage !== void 0) {
|
|
92
|
+
return {
|
|
93
|
+
key: `${event.data.turn}:${event.data.step}`,
|
|
94
|
+
usage: event.data.usage
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
return void 0;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* The `provider/model` attribution key of a usage sample: the exact provider
|
|
102
|
+
* route (dsh adapter id or pi-ai route) plus the model id, so the SAME model
|
|
103
|
+
* served by different providers stays distinct. `assistant/message` names
|
|
104
|
+
* its provider via `data.message.source`; usage chunks fall back to the last
|
|
105
|
+
* `request/header` `data.header.config`; samples with no model information
|
|
106
|
+
* land in the `unknown/unknown` bucket.
|
|
107
|
+
*/
|
|
108
|
+
function modelOf(event) {
|
|
109
|
+
const source = event.data?.message?.source;
|
|
110
|
+
if (source !== void 0 && typeof source.model === "string") {
|
|
111
|
+
return `${typeof source.provider === "string" && source.provider.length > 0 ? source.provider : "unknown"}/${source.model}`;
|
|
112
|
+
}
|
|
113
|
+
const config = event.data?.header?.config;
|
|
114
|
+
if (config !== void 0 && typeof config.model === "string") {
|
|
115
|
+
return `${typeof config.provider === "string" && config.provider.length > 0 ? config.provider : "unknown"}/${config.model}`;
|
|
116
|
+
}
|
|
117
|
+
return void 0;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/** Day entry: totals plus a per-model bucket map. */
|
|
121
|
+
function entryOf(byDay, day) {
|
|
122
|
+
let entry = byDay.get(day);
|
|
123
|
+
if (entry === void 0) {
|
|
124
|
+
entry = {
|
|
125
|
+
totals: zeroBuckets(),
|
|
126
|
+
models: new Map()
|
|
127
|
+
};
|
|
128
|
+
byDay.set(day, entry);
|
|
129
|
+
}
|
|
130
|
+
return entry;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* One session's incremental fold state. `days` holds the already-folded
|
|
135
|
+
* per-day entries; `lastSample`/`currentModel` let a later event slice keep
|
|
136
|
+
* the replace-last-sample semantics and model attribution across fold
|
|
137
|
+
* boundaries without replaying the whole log.
|
|
138
|
+
*/
|
|
139
|
+
export function createUsageState() {
|
|
140
|
+
return {
|
|
141
|
+
days: new Map(),
|
|
142
|
+
lastSample: null,
|
|
143
|
+
currentModel: null,
|
|
144
|
+
consumed: 0
|
|
145
|
+
};
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* Fold a slice of NEW events onto an existing session state (mutating).
|
|
150
|
+
* Replacements for the same (turn, step) subtract the previous sample's
|
|
151
|
+
* buckets from the day/model bucket they were attributed to, so a slice
|
|
152
|
+
* starting mid-step (e.g. a usage chunk at the tail of the previous fold)
|
|
153
|
+
* stays exact.
|
|
154
|
+
* @param state - session fold state (mutated in place).
|
|
155
|
+
* @param events - the new events, in seq order, starting after the last fold.
|
|
156
|
+
*/
|
|
157
|
+
export function applyUsageDelta(state, events) {
|
|
158
|
+
let last = state.lastSample;
|
|
159
|
+
let currentModel = state.currentModel;
|
|
160
|
+
for (const event of events) {
|
|
161
|
+
if (event.type === "request/header") {
|
|
162
|
+
const model = modelOf(event);
|
|
163
|
+
if (model !== void 0) currentModel = model;
|
|
164
|
+
}
|
|
165
|
+
const sample = sampleOf(event);
|
|
166
|
+
if (sample === void 0) continue;
|
|
167
|
+
const buckets = bucketsOf(sample.usage);
|
|
168
|
+
const model = modelOf(event) ?? currentModel ?? "unknown/unknown";
|
|
169
|
+
const day = dayKey(event.time);
|
|
170
|
+
const entry = entryOf(state.days, day);
|
|
171
|
+
if (last !== null && last.key === sample.key) {
|
|
172
|
+
// Same turn/step re-reported: replace instead of double counting.
|
|
173
|
+
const previous = state.days.get(last.day);
|
|
174
|
+
if (previous !== void 0) {
|
|
175
|
+
subtractFrom(previous.totals, last.buckets);
|
|
176
|
+
const previousModel = previous.models.get(last.model);
|
|
177
|
+
if (previousModel !== void 0) subtractFrom(previousModel, last.buckets);
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
addInto(entry.totals, buckets);
|
|
181
|
+
let modelBucket = entry.models.get(model);
|
|
182
|
+
if (modelBucket === void 0) {
|
|
183
|
+
modelBucket = zeroBuckets();
|
|
184
|
+
entry.models.set(model, modelBucket);
|
|
185
|
+
}
|
|
186
|
+
addInto(modelBucket, buckets);
|
|
187
|
+
last = { key: sample.key, day, model, buckets };
|
|
188
|
+
}
|
|
189
|
+
state.lastSample = last;
|
|
190
|
+
state.currentModel = currentModel;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* Fold one session's events into per-day, per-model token buckets.
|
|
195
|
+
* @param events - session event log in seq order.
|
|
196
|
+
* @returns Map<`YYYY-MM-DD`, { totals, models: Map<model, buckets> }> with
|
|
197
|
+
* only days that saw usage.
|
|
198
|
+
*/
|
|
199
|
+
export function foldUsage(events) {
|
|
200
|
+
const state = createUsageState();
|
|
201
|
+
applyUsageDelta(state, events);
|
|
202
|
+
return state.days;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/**
|
|
206
|
+
* Merge one session's folded days into a global per-day map.
|
|
207
|
+
* @param byDay - global map to mutate.
|
|
208
|
+
* @param sessionDays - session day map (from foldUsage or a state).
|
|
209
|
+
*/
|
|
210
|
+
export function mergeInto(byDay, sessionDays) {
|
|
211
|
+
for (const [day, entry] of sessionDays) {
|
|
212
|
+
const target = entryOf(byDay, day);
|
|
213
|
+
addInto(target.totals, entry.totals);
|
|
214
|
+
for (const [model, buckets] of entry.models) {
|
|
215
|
+
let modelBucket = target.models.get(model);
|
|
216
|
+
if (modelBucket === void 0) {
|
|
217
|
+
modelBucket = zeroBuckets();
|
|
218
|
+
target.models.set(model, modelBucket);
|
|
219
|
+
}
|
|
220
|
+
addInto(modelBucket, buckets);
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/**
|
|
226
|
+
* Merge one session fold into a global per-day map (convenience wrapper).
|
|
227
|
+
* @param byDay - global map to mutate.
|
|
228
|
+
* @param events - session events.
|
|
229
|
+
*/
|
|
230
|
+
export function consumeEvents(byDay, events) {
|
|
231
|
+
mergeInto(byDay, foldUsage(events));
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/**
|
|
235
|
+
* Render a global per-day map into the wire shape for the usage endpoint.
|
|
236
|
+
* @param byDay - day → entry map.
|
|
237
|
+
* @param updatedAt - computation timestamp.
|
|
238
|
+
* @returns `{ days, total, updatedAt }` with `days` sorted ascending; each
|
|
239
|
+
* day carries `models` (descending by tokens) and a `cacheHitRate` percent.
|
|
240
|
+
*/
|
|
241
|
+
export function renderUsage(byDay, updatedAt) {
|
|
242
|
+
const days = [...byDay.entries()]
|
|
243
|
+
.map(([date, entry]) => {
|
|
244
|
+
const models = [...entry.models.entries()]
|
|
245
|
+
.map(([model, buckets]) => ({
|
|
246
|
+
model,
|
|
247
|
+
...buckets,
|
|
248
|
+
tokens: totalTokens(buckets),
|
|
249
|
+
cacheHitRate: cacheHitRate(buckets)
|
|
250
|
+
}))
|
|
251
|
+
// All-zero buckets come from warmup requests that report
|
|
252
|
+
// {input:0, output:0}; rendering them produces empty "0 tokens"
|
|
253
|
+
// model rows (#23).
|
|
254
|
+
.filter((entry) => entry.tokens > 0)
|
|
255
|
+
.sort((a, b) => b.tokens - a.tokens);
|
|
256
|
+
return {
|
|
257
|
+
date,
|
|
258
|
+
...entry.totals,
|
|
259
|
+
tokens: totalTokens(entry.totals),
|
|
260
|
+
cacheHitRate: cacheHitRate(entry.totals),
|
|
261
|
+
models
|
|
262
|
+
};
|
|
263
|
+
})
|
|
264
|
+
.sort((a, b) => (a.date < b.date ? -1 : a.date > b.date ? 1 : 0));
|
|
265
|
+
const total = zeroBuckets();
|
|
266
|
+
for (const [, entry] of byDay) addInto(total, entry.totals);
|
|
267
|
+
return {
|
|
268
|
+
days,
|
|
269
|
+
total: {
|
|
270
|
+
...total,
|
|
271
|
+
tokens: totalTokens(total),
|
|
272
|
+
cacheHitRate: cacheHitRate(total)
|
|
273
|
+
},
|
|
274
|
+
updatedAt
|
|
275
|
+
};
|
|
276
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@ychris12138/dsh-usage-stats",
|
|
3
|
+
"description": "Token usage heatmap, provider balances, and subscription quotas for the dsh web GUI",
|
|
4
|
+
"version": "0.2.6",
|
|
5
|
+
"repository": {
|
|
6
|
+
"type": "git",
|
|
7
|
+
"url": "git+https://github.com/Ychris12138/dsh-usage-stats.git"
|
|
8
|
+
},
|
|
9
|
+
"homepage": "https://github.com/Ychris12138/dsh-usage-stats#readme",
|
|
10
|
+
"bugs": "https://github.com/Ychris12138/dsh-usage-stats/issues",
|
|
11
|
+
"keywords": [
|
|
12
|
+
"deepseek",
|
|
13
|
+
"deepseek-harness",
|
|
14
|
+
"dsh",
|
|
15
|
+
"token-usage"
|
|
16
|
+
],
|
|
17
|
+
"bin": {
|
|
18
|
+
"dsh-usage-stats-install": "scripts/install.mjs"
|
|
19
|
+
},
|
|
20
|
+
"files": [
|
|
21
|
+
"lib/",
|
|
22
|
+
"cordis.patch.yml",
|
|
23
|
+
"docs/images/usage-panel.svg",
|
|
24
|
+
"scripts/install.mjs",
|
|
25
|
+
"README.md",
|
|
26
|
+
"LICENSE",
|
|
27
|
+
"SECURITY.md"
|
|
28
|
+
],
|
|
29
|
+
"type": "module",
|
|
30
|
+
"main": "lib/index.js",
|
|
31
|
+
"exports": {
|
|
32
|
+
".": "./lib/index.js",
|
|
33
|
+
"./client": "./lib/client.js",
|
|
34
|
+
"./usage": "./lib/usage.js",
|
|
35
|
+
"./package.json": "./package.json"
|
|
36
|
+
},
|
|
37
|
+
"dsh": {
|
|
38
|
+
"bundle": {
|
|
39
|
+
"patch": "./cordis.patch.yml"
|
|
40
|
+
},
|
|
41
|
+
"client": {
|
|
42
|
+
"platform": "web",
|
|
43
|
+
"inject": [
|
|
44
|
+
"@deepseek-ai/dsh-client-locale",
|
|
45
|
+
"@deepseek-ai/dsh-client-runtime",
|
|
46
|
+
"@deepseek-ai/dsh-client-ui-primitives"
|
|
47
|
+
]
|
|
48
|
+
}
|
|
49
|
+
},
|
|
50
|
+
"scripts": {
|
|
51
|
+
"check": "node --check lib/index.js && node --check lib/usage.js && node --check lib/balance.js && node --check lib/subscriptions.js && node --check lib/accounts.js && node --check lib/client.js && node --check scripts/install.mjs && node --check scripts/smoke-client.mjs && node --check scripts/test-overlay-layering.mjs && node --check scripts/test-bundle.mjs && node --check scripts/test-install.mjs && node --check scripts/test-server.mjs && node --check scripts/test-balance.mjs && node --check scripts/test-subscriptions.mjs && node --check scripts/test-accounts.mjs && node --check scripts/validate-fold.mjs && node --check scripts/verify-raw.mjs && node --check scripts/check-balance.mjs",
|
|
52
|
+
"test": "npm run test:bundle && npm run test:client && npm run test:overlay && npm run test:server && npm run test:balance && npm run test:subscriptions && npm run test:accounts && npm run test:install",
|
|
53
|
+
"test:bundle": "node scripts/test-bundle.mjs",
|
|
54
|
+
"test:client": "node scripts/smoke-client.mjs",
|
|
55
|
+
"test:overlay": "node scripts/test-overlay-layering.mjs",
|
|
56
|
+
"test:install": "node scripts/test-install.mjs",
|
|
57
|
+
"test:server": "node scripts/test-server.mjs",
|
|
58
|
+
"test:balance": "node scripts/test-balance.mjs",
|
|
59
|
+
"test:subscriptions": "node scripts/test-subscriptions.mjs",
|
|
60
|
+
"test:accounts": "node scripts/test-accounts.mjs",
|
|
61
|
+
"validate:live": "node scripts/validate-fold.mjs && node scripts/verify-raw.mjs",
|
|
62
|
+
"prepublishOnly": "npm run check && npm test"
|
|
63
|
+
},
|
|
64
|
+
"devDependencies": {
|
|
65
|
+
"react": "^18.2.0",
|
|
66
|
+
"react-dom": "^18.2.0"
|
|
67
|
+
},
|
|
68
|
+
"license": "MIT",
|
|
69
|
+
"publishConfig": {
|
|
70
|
+
"access": "public"
|
|
71
|
+
}
|
|
72
|
+
}
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
import { cp, mkdir, readFile, writeFile } from "node:fs/promises";
|
|
4
|
+
import { homedir } from "node:os";
|
|
5
|
+
import { dirname, join } from "node:path";
|
|
6
|
+
import { fileURLToPath } from "node:url";
|
|
7
|
+
|
|
8
|
+
const knownFlags = new Set(["--check", "--dry-run", "--no-enable", "--help"]);
|
|
9
|
+
const args = new Set(process.argv.slice(2));
|
|
10
|
+
for (const arg of args) {
|
|
11
|
+
if (!knownFlags.has(arg)) {
|
|
12
|
+
console.error(`Unknown option: ${arg}`);
|
|
13
|
+
process.exit(2);
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
if (args.has("--help")) {
|
|
18
|
+
console.log(`dsh-usage-stats installer
|
|
19
|
+
|
|
20
|
+
Usage:
|
|
21
|
+
npx --yes github:Ychris12138/dsh-usage-stats [options]
|
|
22
|
+
|
|
23
|
+
Options:
|
|
24
|
+
--check Verify the installed package and Cordis patch without changing them
|
|
25
|
+
--dry-run Print the resolved paths and planned changes
|
|
26
|
+
--no-enable Install files without editing cordis.patch.yml
|
|
27
|
+
--help Show this help
|
|
28
|
+
|
|
29
|
+
Set DSH_HOME to override the default ~/.dsh location.`);
|
|
30
|
+
process.exit(0);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const sourceRoot = dirname(dirname(fileURLToPath(import.meta.url)));
|
|
34
|
+
const sourcePackage = JSON.parse(await readFile(join(sourceRoot, "package.json"), "utf8"));
|
|
35
|
+
const dshHome = process.env.DSH_HOME ?? join(homedir(), ".dsh");
|
|
36
|
+
const target = join(dshHome, "profiles", "node_modules", "dsh-usage-stats");
|
|
37
|
+
const patchPath = join(dshHome, "profiles", "web", "cordis.patch.yml");
|
|
38
|
+
const pluginLine = /^\s+name:\s*dsh-usage-stats\s*$/gm;
|
|
39
|
+
const patchBlock = `# dsh-usage-stats: token usage heatmap + DeepSeek balance
|
|
40
|
+
- insert:
|
|
41
|
+
- id: usage-stats
|
|
42
|
+
name: dsh-usage-stats
|
|
43
|
+
`;
|
|
44
|
+
const emptySequenceRoot = /^\[\](?:[ \t]+#.*)?$/;
|
|
45
|
+
|
|
46
|
+
function meaningfulPatchLines(text) {
|
|
47
|
+
return String(text).split(/\r?\n/).map((line, index) => ({
|
|
48
|
+
index,
|
|
49
|
+
indent: line.match(/^[ \t]*/)?.[0].length ?? 0,
|
|
50
|
+
content: line.trim()
|
|
51
|
+
})).filter(({ content }) => content !== "" && !content.startsWith("#") && content !== "---" && content !== "...");
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** Remove a YAML document whose only value is the empty root sequence `[]`. */
|
|
55
|
+
function withoutEmptySequenceRoot(text) {
|
|
56
|
+
const meaningful = meaningfulPatchLines(text);
|
|
57
|
+
if (meaningful.length === 0) return text;
|
|
58
|
+
const rootIndent = Math.min(...meaningful.map(({ indent }) => indent));
|
|
59
|
+
const emptyRoot = meaningful.find(({ indent, content }) => indent === rootIndent && emptySequenceRoot.test(content));
|
|
60
|
+
if (emptyRoot === void 0) return text;
|
|
61
|
+
const lines = String(text).split(/\r?\n/);
|
|
62
|
+
const inlineComment = lines[emptyRoot.index].match(/^([ \t]*)\[\][ \t]+(#.*)$/);
|
|
63
|
+
if (inlineComment === null) lines.splice(emptyRoot.index, 1);
|
|
64
|
+
else lines[emptyRoot.index] = `${inlineComment[1]}${inlineComment[2]}`;
|
|
65
|
+
return lines.filter((line) => line.trim() !== "...").join("\n").trimEnd();
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** Detect the exact invalid shape produced by older installers: `[]` plus list entries. */
|
|
69
|
+
function assertNoEmptyRootConflict(text) {
|
|
70
|
+
const meaningful = meaningfulPatchLines(text);
|
|
71
|
+
if (meaningful.length < 2) return;
|
|
72
|
+
const rootIndent = Math.min(...meaningful.map(({ indent }) => indent));
|
|
73
|
+
const roots = meaningful.filter(({ indent }) => indent === rootIndent);
|
|
74
|
+
if (roots.some(({ content }) => emptySequenceRoot.test(content)) && roots.length > 1) {
|
|
75
|
+
throw new Error(`invalid YAML in ${patchPath}: empty root sequence [] cannot be combined with patch entries; rerun the installer to repair it`);
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** Preserve existing YAML/comments while adding exactly one plugin patch entry. */
|
|
80
|
+
function enablePluginInPatch(text) {
|
|
81
|
+
const base = withoutEmptySequenceRoot(text);
|
|
82
|
+
if ([...base.matchAll(pluginLine)].length > 0) return base;
|
|
83
|
+
return base.trim() === "" ? patchBlock : `${base.trimEnd()}\n\n${patchBlock}`;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
async function readOptional(path) {
|
|
87
|
+
try {
|
|
88
|
+
return await readFile(path, "utf8");
|
|
89
|
+
} catch (error) {
|
|
90
|
+
if (error?.code === "ENOENT") return null;
|
|
91
|
+
throw error;
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
async function verify(expectEnabled) {
|
|
96
|
+
const installedRaw = await readOptional(join(target, "package.json"));
|
|
97
|
+
if (installedRaw === null) throw new Error(`package is not installed at ${target}`);
|
|
98
|
+
const installed = JSON.parse(installedRaw);
|
|
99
|
+
if (installed.name !== sourcePackage.name || installed.version !== sourcePackage.version) {
|
|
100
|
+
throw new Error(`installed package is ${installed.name ?? "unknown"}@${installed.version ?? "unknown"}; expected ${sourcePackage.name}@${sourcePackage.version}`);
|
|
101
|
+
}
|
|
102
|
+
if (expectEnabled) {
|
|
103
|
+
const patch = await readOptional(patchPath);
|
|
104
|
+
const count = patch === null ? 0 : [...patch.matchAll(pluginLine)].length;
|
|
105
|
+
if (count !== 1) throw new Error(`expected exactly one dsh-usage-stats entry in ${patchPath}; found ${count}`);
|
|
106
|
+
assertNoEmptyRootConflict(patch);
|
|
107
|
+
}
|
|
108
|
+
console.log(`Verified ${sourcePackage.name}@${sourcePackage.version}`);
|
|
109
|
+
console.log(` package: ${target}`);
|
|
110
|
+
if (expectEnabled) console.log(` patch: ${patchPath}`);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
const enable = !args.has("--no-enable");
|
|
114
|
+
if (args.has("--dry-run")) {
|
|
115
|
+
console.log(`Would install ${sourcePackage.name}@${sourcePackage.version}`);
|
|
116
|
+
console.log(` package: ${target}`);
|
|
117
|
+
console.log(` patch: ${enable ? patchPath : "unchanged (--no-enable)"}`);
|
|
118
|
+
process.exit(0);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
if (args.has("--check")) {
|
|
122
|
+
await verify(enable);
|
|
123
|
+
process.exit(0);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
await mkdir(target, { recursive: true });
|
|
127
|
+
for (const entry of ["lib", "cordis.patch.yml", "package.json", "README.md", "LICENSE", "SECURITY.md"]) {
|
|
128
|
+
await cp(join(sourceRoot, entry), join(target, entry), { recursive: true, force: true });
|
|
129
|
+
}
|
|
130
|
+
await mkdir(join(target, "scripts"), { recursive: true });
|
|
131
|
+
await cp(fileURLToPath(import.meta.url), join(target, "scripts", "install.mjs"), { force: true });
|
|
132
|
+
|
|
133
|
+
if (enable) {
|
|
134
|
+
await mkdir(dirname(patchPath), { recursive: true });
|
|
135
|
+
const current = await readOptional(patchPath) ?? "";
|
|
136
|
+
const enabledPatch = enablePluginInPatch(current);
|
|
137
|
+
if (enabledPatch !== current) await writeFile(patchPath, enabledPatch, "utf8");
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
await verify(enable);
|
|
141
|
+
console.log("Installation complete. Restart dsh web, then hard-refresh the browser.");
|
|
142
|
+
console.log("Balance is optional: configure DEEPSEEK_API_KEY in <DSH_HOME>/.credentials.yaml.");
|