dsh-nerd-mode 0.4.6 → 0.5.2
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 +11 -6
- package/README.zh.md +11 -6
- package/assets/calibration.svg +69 -69
- package/assets/nerd.svg +22 -22
- package/assets/section.md +15 -15
- package/client.js +2170 -1806
- package/cordis.patch.yml +24 -24
- package/lib/index.js +838 -223
- package/lib/study-sessions.js +283 -0
- package/package.json +2 -2
- package/skills/convert-documents-to-markdown/SKILL.md +26 -26
- package/skills/diagram/SKILL.md +43 -19
- package/skills/diagram/scripts/check-svg.mjs +184 -0
- package/skills/humanizer/.claude-plugin/marketplace.json +18 -18
- package/skills/humanizer/.claude-plugin/plugin.json +15 -15
- package/skills/humanizer/.cursor-plugin/plugin.json +12 -12
- package/skills/humanizer/.github/ISSUE_TEMPLATE/config.yml +8 -8
- package/skills/humanizer/.github/ISSUE_TEMPLATE/new-pattern.yml +24 -24
- package/skills/humanizer/.github/ISSUE_TEMPLATE/rewrite-problem.yml +44 -44
- package/skills/humanizer/.github/workflows/validate.yml +29 -29
- package/skills/humanizer/AGENTS.md +54 -54
- package/skills/humanizer/CHANGELOG.md +116 -116
- package/skills/humanizer/LICENSE +21 -21
- package/skills/humanizer/README.md +206 -206
- package/skills/humanizer/SKILL.md +397 -397
- package/skills/humanizer/agents/openai.yaml +4 -4
- package/skills/humanizer/scripts/validate-package.py +146 -146
- package/skills/study/SKILL.md +81 -78
- package/skills/study-plus/SKILL.md +246 -246
- package/skills/study-plus/scripts/book-client.js +284 -284
- package/skills/study-plus/scripts/book-data.mjs +289 -289
- package/skills/study-plus/scripts/build-book.mjs +155 -155
- package/skills/writing-beats/SKILL.md +66 -66
- package/skills/writing-beats/agents/openai.yaml +5 -5
package/lib/index.js
CHANGED
|
@@ -1,223 +1,838 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Nerd Mode: a per-session switch. While it is on, one prompt section joins
|
|
3
|
-
* every request and the `study` skill becomes the default answer shape. State
|
|
4
|
-
* folds from the session log, so it survives compaction, resume, and fork. The
|
|
5
|
-
* tool set never changes.
|
|
6
|
-
*
|
|
7
|
-
* The section registers at the profile level, not inside a preset, so it
|
|
8
|
-
* reaches every agent in every preset. See docs/adr/0008.
|
|
9
|
-
*/
|
|
10
|
-
|
|
11
|
-
import {
|
|
12
|
-
import {
|
|
13
|
-
import {
|
|
14
|
-
import
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
const
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
const
|
|
46
|
-
const
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
const
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
}
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
}
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
1
|
+
/**
|
|
2
|
+
* Nerd Mode: a per-session switch. While it is on, one prompt section joins
|
|
3
|
+
* every request and the `study` skill becomes the default answer shape. State
|
|
4
|
+
* folds from the session log, so it survives compaction, resume, and fork. The
|
|
5
|
+
* tool set never changes.
|
|
6
|
+
*
|
|
7
|
+
* The section registers at the profile level, not inside a preset, so it
|
|
8
|
+
* reaches every agent in every preset. See docs/adr/0008.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { spawn } from 'node:child_process'
|
|
12
|
+
import { readFileSync } from 'node:fs'
|
|
13
|
+
import { join, sep } from 'node:path'
|
|
14
|
+
import { fileURLToPath } from 'node:url'
|
|
15
|
+
import { COMMAND, KEY, fold, init } from './fold.js'
|
|
16
|
+
import { scan } from './study-sessions.js'
|
|
17
|
+
import z from '@deepseek-ai/schemastery'
|
|
18
|
+
|
|
19
|
+
export const name = 'dsh-nerd-mode'
|
|
20
|
+
export const PACKAGE_NAME = 'dsh-nerd-mode'
|
|
21
|
+
export const inject = ['systemPrompt', 'sessionProjections']
|
|
22
|
+
|
|
23
|
+
// The prompt text ships with the package. Keeping it out of the config stops
|
|
24
|
+
// every profile patch from carrying a stale copy of it.
|
|
25
|
+
const BUNDLED_SECTION = readFileSync(
|
|
26
|
+
fileURLToPath(new URL('../assets/section.md', import.meta.url)),
|
|
27
|
+
'utf8',
|
|
28
|
+
).trim()
|
|
29
|
+
|
|
30
|
+
export const Config = z.object({
|
|
31
|
+
section: z.string().pattern(/\S/).default(BUNDLED_SECTION),
|
|
32
|
+
recallSessionEnabled: z.boolean().default(false).volatile(),
|
|
33
|
+
unseenQuestionCount: z.number().step(1).min(0).max(10).default(3).volatile(),
|
|
34
|
+
unseenQuestionType: z.union([
|
|
35
|
+
z.const('short-answer'),
|
|
36
|
+
z.const('multiple-choice'),
|
|
37
|
+
z.const('mixed'),
|
|
38
|
+
]).default('short-answer').volatile(),
|
|
39
|
+
})
|
|
40
|
+
|
|
41
|
+
const SECTION = 'study:policy'
|
|
42
|
+
// Between PLAN_POLICY (500) and TEAM_POLICY (600).
|
|
43
|
+
const SECTION_ORDER = 550
|
|
44
|
+
const STUDY_SESSIONS_PATH = '/dsh-nerd-mode/api/study-sessions'
|
|
45
|
+
const UPDATE_PATH = '/dsh-nerd-mode/api/update'
|
|
46
|
+
const VERSION_PATTERN = /^\d+\.\d+\.\d+$/
|
|
47
|
+
const REGISTRIES = ['https://registry.npmjs.org/', 'https://registry.npmmirror.com/']
|
|
48
|
+
const REGISTRY_TIMEOUT_MS = 10_000
|
|
49
|
+
const UPDATE_CACHE_MS = 60_000
|
|
50
|
+
const BODY_LIMIT = 1024
|
|
51
|
+
const INSTALL_TIMEOUT_MS = 300_000
|
|
52
|
+
const OFFICIAL_REPO = 'bocchii-the-code/dsh-nerd-mode'
|
|
53
|
+
const GITHUB_SPEC = `github:${OFFICIAL_REPO}`
|
|
54
|
+
const RAW_BASE = 'https://raw.githubusercontent.com/'
|
|
55
|
+
// A commit, a tag, or a semver range never moves on its own.
|
|
56
|
+
const PINNED_REF = /^(?:[0-9a-f]{7,40}|v?\d+\.\d+(?:\.\d+)*(?:[-+].*)?|semver:.*)$/i
|
|
57
|
+
|
|
58
|
+
// This copy's version cannot change while the module is loaded.
|
|
59
|
+
const PACKAGE_VERSION = JSON.parse(
|
|
60
|
+
readFileSync(fileURLToPath(new URL('../package.json', import.meta.url)), 'utf8'),
|
|
61
|
+
).version
|
|
62
|
+
const studyPlusDefaultsBySession = new WeakMap()
|
|
63
|
+
const directStudyPlusBySession = new WeakMap()
|
|
64
|
+
// A PTC skill load leaves no message in the history, so keep its id for later
|
|
65
|
+
// turns the way an injected skill message would.
|
|
66
|
+
const ptcStudyPlusBySession = new WeakMap()
|
|
67
|
+
const SKILL_COMMAND = /(^|\s)\/([a-z0-9]+(?:-[a-z0-9]+)*)(?=\s|$)/g
|
|
68
|
+
|
|
69
|
+
// shortcut: parse-only schemas, no zod. The projection registry calls `parse`
|
|
70
|
+
// and nothing else. Upgrade to zod if the host starts introspecting schemas.
|
|
71
|
+
const stateSchema = {
|
|
72
|
+
parse: (value) => ({
|
|
73
|
+
active: value?.active === true,
|
|
74
|
+
running:
|
|
75
|
+
value?.running && typeof value.running.commandId === 'string'
|
|
76
|
+
? { commandId: value.running.commandId, wanted: value.running.wanted === true }
|
|
77
|
+
: null,
|
|
78
|
+
}),
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const viewSchema = { parse: (value) => ({ active: value?.active === true }) }
|
|
82
|
+
|
|
83
|
+
function studyPlusSettings(config, session, invocationId) {
|
|
84
|
+
let defaults = studyPlusDefaultsBySession.get(session)
|
|
85
|
+
if (defaults?.invocationId !== invocationId) {
|
|
86
|
+
defaults = {
|
|
87
|
+
invocationId,
|
|
88
|
+
recallSessionEnabled: config.recallSessionEnabled?.get?.() ?? false,
|
|
89
|
+
unseenQuestionCount: config.unseenQuestionCount?.get?.() ?? 3,
|
|
90
|
+
unseenQuestionType: config.unseenQuestionType?.get?.() ?? 'short-answer',
|
|
91
|
+
}
|
|
92
|
+
studyPlusDefaultsBySession.set(session, defaults)
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
return [
|
|
96
|
+
'',
|
|
97
|
+
'',
|
|
98
|
+
'Study-plus profile defaults for this tracked session:',
|
|
99
|
+
`- Recall Session: ${defaults.recallSessionEnabled ? 'enabled' : 'disabled'}.`,
|
|
100
|
+
`- Post-lecture questions: ${defaults.unseenQuestionCount}; type: ${defaults.unseenQuestionType}.`,
|
|
101
|
+
'Apply these defaults only during tracked study-plus sessions. Ignore them for ordinary study and all other requests.',
|
|
102
|
+
'An explicit preference in the current /study-plus request overrides its profile default for this session and does not update the profile.',
|
|
103
|
+
].join('\n')
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** Return the message id when the message is a direct /study-plus command, else undefined. */
|
|
107
|
+
function directStudyPlusId(message) {
|
|
108
|
+
if (message?.source?.kind !== 'user') return undefined
|
|
109
|
+
const text = (message.content ?? [])
|
|
110
|
+
.filter((block) => block.type === 'text')
|
|
111
|
+
.map((block) => block.text)
|
|
112
|
+
.join('\n')
|
|
113
|
+
return [...text.matchAll(SKILL_COMMAND)].at(-1)?.[2] === 'study-plus' ? message.id : undefined
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function studyPlusInvocation(session) {
|
|
117
|
+
const directInvocation = directStudyPlusBySession.get(session)
|
|
118
|
+
if (directInvocation !== undefined) return directInvocation
|
|
119
|
+
const ptcInvocation = ptcStudyPlusBySession.get(session)
|
|
120
|
+
if (ptcInvocation !== undefined) return ptcInvocation
|
|
121
|
+
|
|
122
|
+
// The host returns the full surface history here, not just this turn. See docs/agents/dsh-session-api.md.
|
|
123
|
+
const invocation = session.deriveMessages().findLast((message) => message.source?.kind === 'skill-invocation')
|
|
124
|
+
if (invocation?.source.name === 'study-plus') return invocation.id
|
|
125
|
+
|
|
126
|
+
const completedCalls = new Set()
|
|
127
|
+
let loadedId
|
|
128
|
+
let directId
|
|
129
|
+
let skillCall
|
|
130
|
+
let ptcSkillCall
|
|
131
|
+
for (let seq = session.seq - 1; seq >= 0; seq -= 1) {
|
|
132
|
+
const event = session.eventAt(seq)
|
|
133
|
+
if (event?.type === 'turn/start') break
|
|
134
|
+
// A resumed, forked, or seeded log may carry a partial event payload, so
|
|
135
|
+
// every nested read here is guarded. One bad record must not fail a turn.
|
|
136
|
+
if (event?.type === 'tool/result') {
|
|
137
|
+
const result = event.data?.message
|
|
138
|
+
if (result !== undefined && result.isError !== true && result.source?.callId !== undefined) {
|
|
139
|
+
completedCalls.add(result.source.callId)
|
|
140
|
+
}
|
|
141
|
+
} else if (event?.type === 'tool/call' && event.data?.name === 'skill') {
|
|
142
|
+
try {
|
|
143
|
+
if (JSON.parse(event.data.arguments).name === 'study-plus') skillCall = event.data.callId
|
|
144
|
+
} catch {}
|
|
145
|
+
} else if (event?.type === 'tool/ptc-dispatch' && event.data?.name === 'skill') {
|
|
146
|
+
// PTC records a skill call made inside run_code as a dispatch, not as a
|
|
147
|
+
// `tool/call`, and the dispatch event itself carries the outcome.
|
|
148
|
+
if (event.data.isError !== true && event.data.arguments?.name === 'study-plus') {
|
|
149
|
+
ptcSkillCall = event.data.subCallId
|
|
150
|
+
}
|
|
151
|
+
} else if (event?.type === 'user/message') {
|
|
152
|
+
// `user/message` stores the message fields directly in `data`; only
|
|
153
|
+
// `tool/result` and `system/message` wrap them under `data.message`.
|
|
154
|
+
const message = event.data
|
|
155
|
+
if (message?.source?.kind === 'skill-invocation') {
|
|
156
|
+
if (message.source.name === 'study-plus') loadedId = message.id
|
|
157
|
+
} else {
|
|
158
|
+
directId = directStudyPlusId(message) ?? directId
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// A skill load counts only when its tool call completed. A typed command counts at once.
|
|
164
|
+
if (skillCall !== undefined) {
|
|
165
|
+
return completedCalls.has(skillCall) ? loadedId ?? directId ?? skillCall : undefined
|
|
166
|
+
}
|
|
167
|
+
if (ptcSkillCall !== undefined) {
|
|
168
|
+
ptcStudyPlusBySession.set(session, ptcSkillCall)
|
|
169
|
+
return loadedId ?? directId ?? ptcSkillCall
|
|
170
|
+
}
|
|
171
|
+
return loadedId ?? directId
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
const projection = {
|
|
175
|
+
key: KEY,
|
|
176
|
+
stateVersion: 1,
|
|
177
|
+
stateSchema,
|
|
178
|
+
init,
|
|
179
|
+
apply: fold,
|
|
180
|
+
wire: {
|
|
181
|
+
viewSchema,
|
|
182
|
+
view: (state) => ({ active: state.active }),
|
|
183
|
+
},
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
function sendJson(res, status, body) {
|
|
187
|
+
res.writeHead(status, {
|
|
188
|
+
'content-type': 'application/json; charset=utf-8',
|
|
189
|
+
'cache-control': 'no-store',
|
|
190
|
+
})
|
|
191
|
+
res.end(JSON.stringify(body))
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
function cursorOf(req) {
|
|
195
|
+
const match = /[?&]cursor=(\d+)/.exec(req?.url ?? '')
|
|
196
|
+
return match === null ? 0 : Number.parseInt(match[1], 10)
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/**
|
|
200
|
+
* Build the read-only history route handler.
|
|
201
|
+
*
|
|
202
|
+
* `webServer.register` adds no auth fence, so the connection service decides
|
|
203
|
+
* first: a missing cookie is 401 and a bad Host or Origin is 403.
|
|
204
|
+
*
|
|
205
|
+
* @param options
|
|
206
|
+
* @param options.connection - the host connection service
|
|
207
|
+
* @param options.indexFile - forwarded to `scan`; tests point it at a temp file
|
|
208
|
+
* @param options.sessionsRoot - forwarded to `scan`; tests point it at a temp tree
|
|
209
|
+
* @returns {(req, res) => Promise<void>} replies `{ phase, cursor, done,
|
|
210
|
+
* total, sessions }` with `no-store`, `{ error }` with status 500, or the
|
|
211
|
+
* rejection status
|
|
212
|
+
*/
|
|
213
|
+
export function createStudySessionsHandler({ connection, ...scanOptions }) {
|
|
214
|
+
return async function studySessionsRoute(req, res) {
|
|
215
|
+
const rejection = connection.requestRejection(req)
|
|
216
|
+
if (rejection !== undefined) {
|
|
217
|
+
res.writeHead(rejection)
|
|
218
|
+
res.end(rejection === 401 ? 'unauthorized' : 'forbidden')
|
|
219
|
+
return
|
|
220
|
+
}
|
|
221
|
+
if (req.method !== 'GET') {
|
|
222
|
+
sendJson(res, 405, { error: 'Method not allowed.' })
|
|
223
|
+
return
|
|
224
|
+
}
|
|
225
|
+
try {
|
|
226
|
+
sendJson(res, 200, scan({ ...scanOptions, cursor: cursorOf(req) }))
|
|
227
|
+
} catch (error) {
|
|
228
|
+
sendJson(res, 500, { error: error instanceof Error ? error.message : String(error) })
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/**
|
|
234
|
+
* Return the parsed `package.json` under `root`, or undefined when it is absent.
|
|
235
|
+
*
|
|
236
|
+
* Args:
|
|
237
|
+
* root: a directory that may hold a manifest.
|
|
238
|
+
* Returns:
|
|
239
|
+
* The parsed object, or undefined when the file is missing or not JSON.
|
|
240
|
+
*/
|
|
241
|
+
function manifestOf(root) {
|
|
242
|
+
try {
|
|
243
|
+
return JSON.parse(readFileSync(join(root, 'package.json'), 'utf8'))
|
|
244
|
+
} catch {
|
|
245
|
+
return undefined
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
/** Return the manifest's dependency spec for this package, if any. */
|
|
250
|
+
function dependencySpec(manifest) {
|
|
251
|
+
return manifest?.dependencies?.[PACKAGE_NAME] ?? manifest?.devDependencies?.[PACKAGE_NAME]
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
/**
|
|
255
|
+
* Read the repository and ref from a GitHub dependency spec.
|
|
256
|
+
*
|
|
257
|
+
* Args:
|
|
258
|
+
* spec: a manifest value, possibly undefined or not a string.
|
|
259
|
+
* Returns:
|
|
260
|
+
* `{ repo, ref, pinned }`, or null when the spec names another source.
|
|
261
|
+
* `repo` is a lowercase `owner/repo`; `ref` is null when the spec tracks the
|
|
262
|
+
* default branch; `pinned` is true for a commit, a tag, or a semver range.
|
|
263
|
+
*/
|
|
264
|
+
export function githubTarget(spec) {
|
|
265
|
+
if (typeof spec !== 'string') return null
|
|
266
|
+
const [source, ref = null] = spec.split('#')
|
|
267
|
+
const patterns = [
|
|
268
|
+
/^github:([^/]+\/[^/]+?)(?:\.git)?$/i,
|
|
269
|
+
/^(?!@)([A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+)$/,
|
|
270
|
+
/^git\+https:\/\/github\.com\/([^/]+\/[^/]+?)(?:\.git)?$/i,
|
|
271
|
+
/^https:\/\/github\.com\/([^/]+\/[^/]+?)(?:\.git)?$/i,
|
|
272
|
+
/^git\+ssh:\/\/git@github\.com\/([^/]+\/[^/]+?)(?:\.git)?$/i,
|
|
273
|
+
/^git@github\.com:([^/]+\/[^/]+?)(?:\.git)?$/i,
|
|
274
|
+
]
|
|
275
|
+
for (const pattern of patterns) {
|
|
276
|
+
const match = pattern.exec(source)
|
|
277
|
+
if (match === null) continue
|
|
278
|
+
return {
|
|
279
|
+
repo: match[1].toLowerCase(),
|
|
280
|
+
ref,
|
|
281
|
+
pinned: ref !== null && PINNED_REF.test(ref),
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
return null
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
/**
|
|
288
|
+
* Classify a manifest spec as an npm, github, or local install.
|
|
289
|
+
*
|
|
290
|
+
* Args:
|
|
291
|
+
* spec: the manifest value, possibly undefined or not a string.
|
|
292
|
+
* Returns:
|
|
293
|
+
* `link` for a missing or `link:` spec, `github` for a GitHub spec, and
|
|
294
|
+
* `npm` for everything else.
|
|
295
|
+
*/
|
|
296
|
+
function sourceOf(spec) {
|
|
297
|
+
if (typeof spec !== 'string' || spec.startsWith('link:')) return 'link'
|
|
298
|
+
if (githubTarget(spec) !== null) return 'github'
|
|
299
|
+
if (spec.startsWith('git+') || spec.startsWith('git@') || spec.endsWith('.git')) return 'github'
|
|
300
|
+
return 'npm'
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
/**
|
|
304
|
+
* Return the profile root that holds this copy, or null when none lists it.
|
|
305
|
+
*
|
|
306
|
+
* pnpm nests the real package under `node_modules/.pnpm`, so the last
|
|
307
|
+
* `node_modules` segment is not always the profile. Walk the segments from
|
|
308
|
+
* last to first and take the first root whose manifest names the package.
|
|
309
|
+
*/
|
|
310
|
+
function profileRoot() {
|
|
311
|
+
const segments = fileURLToPath(import.meta.url).split(/[\\/]/)
|
|
312
|
+
for (let index = segments.length - 1; index > 0; index -= 1) {
|
|
313
|
+
if (segments[index] !== 'node_modules') continue
|
|
314
|
+
const root = segments.slice(0, index).join(sep)
|
|
315
|
+
if (dependencySpec(manifestOf(root)) !== undefined) return root
|
|
316
|
+
}
|
|
317
|
+
return null
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
/**
|
|
321
|
+
* Report this copy's version, install source, and manifest spec.
|
|
322
|
+
*
|
|
323
|
+
* Args:
|
|
324
|
+
* profile: the profile root, or null when the module sits outside one.
|
|
325
|
+
* Returns:
|
|
326
|
+
* `{ installedVersion, source, spec }`; a missing profile or manifest entry
|
|
327
|
+
* reads as a `link` install with no spec. The route keeps `spec` server-side.
|
|
328
|
+
*/
|
|
329
|
+
function readStatus(profile) {
|
|
330
|
+
const spec = profile === null ? undefined : dependencySpec(manifestOf(profile))
|
|
331
|
+
const source = profile === null ? 'link' : sourceOf(spec)
|
|
332
|
+
return { installedVersion: PACKAGE_VERSION, source, spec }
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
/**
|
|
336
|
+
* Read the request body as UTF-8, refusing anything over 1024 bytes.
|
|
337
|
+
*
|
|
338
|
+
* Args:
|
|
339
|
+
* req: the HTTP request stream.
|
|
340
|
+
* Returns:
|
|
341
|
+
* The body text.
|
|
342
|
+
* Raises:
|
|
343
|
+
* Error when the body grows past the limit.
|
|
344
|
+
*/
|
|
345
|
+
async function readBody(req) {
|
|
346
|
+
let size = 0
|
|
347
|
+
const chunks = []
|
|
348
|
+
for await (const chunk of req) {
|
|
349
|
+
size += chunk.length
|
|
350
|
+
if (size > BODY_LIMIT) throw new Error('The request body is too large.')
|
|
351
|
+
chunks.push(Buffer.from(chunk))
|
|
352
|
+
}
|
|
353
|
+
return Buffer.concat(chunks).toString('utf8')
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
/**
|
|
357
|
+
* Ask the npm registry for the newest published version, then the mirror.
|
|
358
|
+
*
|
|
359
|
+
* Args:
|
|
360
|
+
* fetchImpl: fetch implementation; tests replace it to stay offline.
|
|
361
|
+
* Returns:
|
|
362
|
+
* `{ latestVersion, registry, error }`. A failure in both registries keeps
|
|
363
|
+
* `latestVersion` and `registry` null and sets `error`.
|
|
364
|
+
*/
|
|
365
|
+
async function checkRegistries(fetchImpl) {
|
|
366
|
+
let reason = 'the request failed'
|
|
367
|
+
for (const registry of REGISTRIES) {
|
|
368
|
+
try {
|
|
369
|
+
const response = await fetchImpl(`${registry}${PACKAGE_NAME}`, {
|
|
370
|
+
headers: { accept: 'application/json' },
|
|
371
|
+
signal: AbortSignal.timeout(REGISTRY_TIMEOUT_MS),
|
|
372
|
+
})
|
|
373
|
+
if (!response.ok) throw new Error(`HTTP ${response.status}`)
|
|
374
|
+
const latest = (await response.json())?.['dist-tags']?.latest
|
|
375
|
+
if (typeof latest !== 'string' || !VERSION_PATTERN.test(latest)) {
|
|
376
|
+
throw new Error('the registry returned no version')
|
|
377
|
+
}
|
|
378
|
+
return { latestVersion: latest, registry, error: null }
|
|
379
|
+
} catch (error) {
|
|
380
|
+
reason = error instanceof Error ? error.message : String(error)
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
return {
|
|
384
|
+
latestVersion: null,
|
|
385
|
+
registry: null,
|
|
386
|
+
error: `No npm registry answered with a version: ${reason}`,
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
/**
|
|
391
|
+
* Read the newest version from the official GitHub repository.
|
|
392
|
+
*
|
|
393
|
+
* Args:
|
|
394
|
+
* target: `{ repo, ref }` from `githubTarget`; the repo must be official.
|
|
395
|
+
* fetchImpl: fetch implementation; tests replace it to stay offline.
|
|
396
|
+
* Returns:
|
|
397
|
+
* `{ latestVersion, registry, error }`. A failure keeps the first two null
|
|
398
|
+
* and sets `error`.
|
|
399
|
+
*/
|
|
400
|
+
async function checkGithub(target, fetchImpl) {
|
|
401
|
+
try {
|
|
402
|
+
const response = await fetchImpl(`${RAW_BASE}${target.repo}/${target.ref ?? 'HEAD'}/package.json`, {
|
|
403
|
+
headers: { accept: 'application/json' },
|
|
404
|
+
signal: AbortSignal.timeout(REGISTRY_TIMEOUT_MS),
|
|
405
|
+
})
|
|
406
|
+
if (!response.ok) throw new Error(`HTTP ${response.status}`)
|
|
407
|
+
const latest = (await response.json())?.version
|
|
408
|
+
if (typeof latest !== 'string' || !VERSION_PATTERN.test(latest)) {
|
|
409
|
+
throw new Error('the repository returned no version')
|
|
410
|
+
}
|
|
411
|
+
return { latestVersion: latest, registry: RAW_BASE, error: null }
|
|
412
|
+
} catch (error) {
|
|
413
|
+
return {
|
|
414
|
+
latestVersion: null,
|
|
415
|
+
registry: null,
|
|
416
|
+
error: `The repository did not answer with a version: ${error instanceof Error ? error.message : String(error)}`,
|
|
417
|
+
}
|
|
418
|
+
}
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
/**
|
|
422
|
+
* Run one pnpm command in the profile.
|
|
423
|
+
*
|
|
424
|
+
* Args:
|
|
425
|
+
* profile: the profile root, or null when there is nowhere to install.
|
|
426
|
+
* args: the pnpm arguments, built by the caller; no request text reaches
|
|
427
|
+
* here unless it already passed the version check or is the manifest spec.
|
|
428
|
+
* Returns:
|
|
429
|
+
* A promise that resolves on exit code 0. It rejects with an Error on a
|
|
430
|
+
* missing profile, spawn failure, non-zero exit, or a 300 s timeout. The
|
|
431
|
+
* message carries the stderr tail when pnpm wrote one.
|
|
432
|
+
*/
|
|
433
|
+
function runPnpm(profile, args) {
|
|
434
|
+
if (profile === null) {
|
|
435
|
+
return Promise.reject(new Error('This copy has no profile directory to install into.'))
|
|
436
|
+
}
|
|
437
|
+
return new Promise((resolve, reject) => {
|
|
438
|
+
// Windows cannot start the pnpm `.cmd` shim without a command processor,
|
|
439
|
+
// so keep `shell: false` and hand cmd.exe the fixed argument list.
|
|
440
|
+
const [command, prefix] =
|
|
441
|
+
process.platform === 'win32'
|
|
442
|
+
? [process.env.ComSpec ?? 'cmd.exe', ['/d', '/s', '/c', 'pnpm']]
|
|
443
|
+
: ['pnpm', []]
|
|
444
|
+
let child
|
|
445
|
+
try {
|
|
446
|
+
child = spawn(command, [...prefix, ...args], {
|
|
447
|
+
cwd: profile,
|
|
448
|
+
shell: false,
|
|
449
|
+
windowsHide: true,
|
|
450
|
+
})
|
|
451
|
+
} catch (error) {
|
|
452
|
+
reject(error)
|
|
453
|
+
return
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
let settled = false
|
|
457
|
+
let stderr = ''
|
|
458
|
+
let timer
|
|
459
|
+
const finish = (error) => {
|
|
460
|
+
if (settled) return
|
|
461
|
+
settled = true
|
|
462
|
+
clearTimeout(timer)
|
|
463
|
+
if (error === undefined) resolve()
|
|
464
|
+
else reject(error)
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
timer = setTimeout(() => {
|
|
468
|
+
// shortcut: the kill reaches cmd.exe only, upgrade to a tree kill if
|
|
469
|
+
// orphaned pnpm children keep running.
|
|
470
|
+
child.kill()
|
|
471
|
+
finish(new Error(`pnpm did not finish within ${INSTALL_TIMEOUT_MS / 1000} s.`))
|
|
472
|
+
}, INSTALL_TIMEOUT_MS)
|
|
473
|
+
child.stderr?.on('data', (chunk) => {
|
|
474
|
+
stderr = (stderr + chunk).slice(-500)
|
|
475
|
+
})
|
|
476
|
+
child.on('error', (error) => finish(new Error(`Could not start pnpm: ${error.message}`)))
|
|
477
|
+
child.on('close', (code) => {
|
|
478
|
+
if (code === 0) {
|
|
479
|
+
finish()
|
|
480
|
+
return
|
|
481
|
+
}
|
|
482
|
+
const tail = stderr.trim()
|
|
483
|
+
finish(new Error(tail === '' ? `pnpm exited with code ${code}.` : tail))
|
|
484
|
+
})
|
|
485
|
+
})
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
/**
|
|
489
|
+
* Install one exact registry version, pinned in the manifest.
|
|
490
|
+
*
|
|
491
|
+
* Args:
|
|
492
|
+
* profile: the profile root.
|
|
493
|
+
* version: a string already checked against `1.2.3`.
|
|
494
|
+
* Returns:
|
|
495
|
+
* The promise from `runPnpm`.
|
|
496
|
+
*/
|
|
497
|
+
function installVersion(profile, version) {
|
|
498
|
+
return runPnpm(profile, ['add', `${PACKAGE_NAME}@${version}`, '--save-exact'])
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
/**
|
|
502
|
+
* Re-resolve the existing GitHub spec to the newest commit of its ref.
|
|
503
|
+
*
|
|
504
|
+
* Args:
|
|
505
|
+
* profile: the profile root.
|
|
506
|
+
* spec: the manifest spec, unchanged.
|
|
507
|
+
* Returns:
|
|
508
|
+
* The promise from `runPnpm`.
|
|
509
|
+
*/
|
|
510
|
+
function installGithub(profile, spec) {
|
|
511
|
+
return runPnpm(profile, ['add', spec])
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
/**
|
|
515
|
+
* Read the version that landed in the profile, if pnpm wrote one.
|
|
516
|
+
*
|
|
517
|
+
* Args:
|
|
518
|
+
* profile: the profile root, or null.
|
|
519
|
+
* Returns:
|
|
520
|
+
* A `major.minor.patch` string, or null when the file is absent or unreadable.
|
|
521
|
+
*/
|
|
522
|
+
function installedOnDisk(profile) {
|
|
523
|
+
if (profile === null) return null
|
|
524
|
+
const version = manifestOf(join(profile, 'node_modules', PACKAGE_NAME))?.version
|
|
525
|
+
return typeof version === 'string' && VERSION_PATTERN.test(version) ? version : null
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
/** Turn an unexpected throw into the shared failure shape. */
|
|
529
|
+
function failedCheck(error) {
|
|
530
|
+
return { latestVersion: null, registry: null, error: error instanceof Error ? error.message : String(error) }
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
/**
|
|
534
|
+
* Compare two plain `major.minor.patch` versions.
|
|
535
|
+
*
|
|
536
|
+
* Args:
|
|
537
|
+
* left: a version string.
|
|
538
|
+
* right: a version string.
|
|
539
|
+
* Returns:
|
|
540
|
+
* A positive number when `left` is newer, a negative number when `right` is,
|
|
541
|
+
* and 0 when they match or either one is not plain `major.minor.patch`.
|
|
542
|
+
*/
|
|
543
|
+
function compareVersions(left, right) {
|
|
544
|
+
if (!VERSION_PATTERN.test(String(left ?? '')) || !VERSION_PATTERN.test(String(right ?? ''))) return 0
|
|
545
|
+
const first = left.split('.').map(Number)
|
|
546
|
+
const second = right.split('.').map(Number)
|
|
547
|
+
for (let at = 0; at < 3; at += 1) {
|
|
548
|
+
if (first[at] !== second[at]) return first[at] - second[at]
|
|
549
|
+
}
|
|
550
|
+
return 0
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
/**
|
|
554
|
+
* Choose the newest version across the two channels.
|
|
555
|
+
*
|
|
556
|
+
* Args:
|
|
557
|
+
* current: `{ source }` for the installed copy.
|
|
558
|
+
* npm: `{ latestVersion, registry, error }`.
|
|
559
|
+
* github: the same shape.
|
|
560
|
+
* Returns:
|
|
561
|
+
* `{ latestVersion, channel, registry, error }`. A tie stays on the installed
|
|
562
|
+
* channel so an update does not change the spec for nothing. When both
|
|
563
|
+
* channels fail, `error` carries both messages.
|
|
564
|
+
*/
|
|
565
|
+
function pickNewest(current, npm, github) {
|
|
566
|
+
if (npm.latestVersion === null && github.latestVersion === null) {
|
|
567
|
+
const reasons = [npm.error, github.error].filter((text) => typeof text === 'string')
|
|
568
|
+
return {
|
|
569
|
+
latestVersion: null,
|
|
570
|
+
channel: null,
|
|
571
|
+
registry: null,
|
|
572
|
+
error: reasons.length === 0 ? 'No source answered with a version.' : reasons.join(' '),
|
|
573
|
+
}
|
|
574
|
+
}
|
|
575
|
+
const order = compareVersions(github.latestVersion, npm.latestVersion)
|
|
576
|
+
const useGithub =
|
|
577
|
+
github.latestVersion !== null &&
|
|
578
|
+
(npm.latestVersion === null || order > 0 || (order === 0 && current.source === 'github'))
|
|
579
|
+
const winner = useGithub ? github : npm
|
|
580
|
+
return {
|
|
581
|
+
latestVersion: winner.latestVersion,
|
|
582
|
+
channel: useGithub ? 'github' : 'npm',
|
|
583
|
+
registry: winner.registry,
|
|
584
|
+
error: null,
|
|
585
|
+
}
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
/**
|
|
589
|
+
* Build the update route handler.
|
|
590
|
+
*
|
|
591
|
+
* `webServer.register` adds no auth fence, so the connection service decides
|
|
592
|
+
* first: a missing cookie is 401 and a bad Host or Origin is 403. The method
|
|
593
|
+
* check follows.
|
|
594
|
+
*
|
|
595
|
+
* GET replies 200 with `{ installedVersion, source, latestVersion, channel,
|
|
596
|
+
* registry, error, github }`. `latestVersion` is the higher of the npm and
|
|
597
|
+
* official-repository versions, and `channel` says which one answered
|
|
598
|
+
* (`npm` or `github`). `github` is `{ repo, ref, pinned, official }` for a
|
|
599
|
+
* GitHub install and null otherwise. A link install, a pinned ref, and a fork
|
|
600
|
+
* return nulls. A failed check keeps the 200 and sets `error`.
|
|
601
|
+
*
|
|
602
|
+
* POST replies 200 with `{ installedVersion }`, 400 `{ error }` for a body
|
|
603
|
+
* that is not JSON or, on the npm channel, carries no `1.2.3` version. It
|
|
604
|
+
* replies 409 `{ error }` for a source that cannot update or a run already in
|
|
605
|
+
* progress, and 500 `{ error }` when pnpm fails, exits non-zero, or times out.
|
|
606
|
+
* `channel` in the body picks the install source; a missing or unknown value
|
|
607
|
+
* keeps the installed source.
|
|
608
|
+
*
|
|
609
|
+
* Args:
|
|
610
|
+
* options: `connection` is required. Every other key replaces the real work
|
|
611
|
+
* so tests stay offline and never run pnpm.
|
|
612
|
+
* options.connection: the host connection service.
|
|
613
|
+
* options.profile: the profile root holding `package.json`; null reads as a
|
|
614
|
+
* `link` install. Defaults to the root found above this module.
|
|
615
|
+
* options.status: returns `{ installedVersion, source, spec }`.
|
|
616
|
+
* options.fetchImpl: fetch used for the registry or repository check.
|
|
617
|
+
* options.installNpm: installs one registry version.
|
|
618
|
+
* options.installGit: re-resolves the GitHub spec.
|
|
619
|
+
* options.readDisk: reads the version that landed in the profile.
|
|
620
|
+
* Returns:
|
|
621
|
+
* `(req, res) => Promise<void>`, which writes the reply itself. No promise
|
|
622
|
+
* rejection leaves the handler.
|
|
623
|
+
*/
|
|
624
|
+
export function createUpdateHandler({
|
|
625
|
+
connection,
|
|
626
|
+
profile = profileRoot(),
|
|
627
|
+
status = () => readStatus(profile),
|
|
628
|
+
fetchImpl = fetch,
|
|
629
|
+
installNpm = (version) => installVersion(profile, version),
|
|
630
|
+
installGit = (spec) => installGithub(profile, spec),
|
|
631
|
+
readDisk = () => installedOnDisk(profile),
|
|
632
|
+
} = {}) {
|
|
633
|
+
// One cache per route: a module-wide cache would leak between test handlers
|
|
634
|
+
// and keep a stale answer for the next 60 s after an install.
|
|
635
|
+
let cached
|
|
636
|
+
let busy = false
|
|
637
|
+
|
|
638
|
+
function githubInfo(current) {
|
|
639
|
+
if (current.source !== 'github') return null
|
|
640
|
+
const target = githubTarget(current.spec)
|
|
641
|
+
if (target === null) return { repo: null, ref: null, pinned: false, official: false }
|
|
642
|
+
return {
|
|
643
|
+
repo: target.repo,
|
|
644
|
+
ref: target.ref,
|
|
645
|
+
pinned: target.pinned,
|
|
646
|
+
official: target.repo === OFFICIAL_REPO,
|
|
647
|
+
}
|
|
648
|
+
}
|
|
649
|
+
|
|
650
|
+
async function releaseState(force, current) {
|
|
651
|
+
if (!force && cached !== undefined && Date.now() - cached.at < UPDATE_CACHE_MS) {
|
|
652
|
+
return cached.value
|
|
653
|
+
}
|
|
654
|
+
const target = current.source === 'github' ? githubTarget(current.spec) : null
|
|
655
|
+
// A link copy, a pinned ref, and a fork are deliberate choices: none moves.
|
|
656
|
+
if (
|
|
657
|
+
(current.source !== 'npm' && current.source !== 'github') ||
|
|
658
|
+
(current.source === 'github' && (target === null || target.repo !== OFFICIAL_REPO || target.pinned))
|
|
659
|
+
) {
|
|
660
|
+
const value = { latestVersion: null, channel: null, registry: null, error: null }
|
|
661
|
+
cached = { at: Date.now(), value }
|
|
662
|
+
return value
|
|
663
|
+
}
|
|
664
|
+
const githubSide = current.source === 'github' ? target : { repo: OFFICIAL_REPO, ref: null }
|
|
665
|
+
const [npm, github] = await Promise.all([
|
|
666
|
+
checkRegistries(fetchImpl).catch(failedCheck),
|
|
667
|
+
checkGithub(githubSide, fetchImpl).catch(failedCheck),
|
|
668
|
+
])
|
|
669
|
+
const value = pickNewest(current, npm, github)
|
|
670
|
+
cached = { at: Date.now(), value }
|
|
671
|
+
return value
|
|
672
|
+
}
|
|
673
|
+
|
|
674
|
+
return async function updateRoute(req, res) {
|
|
675
|
+
const rejection = connection.requestRejection(req)
|
|
676
|
+
if (rejection !== undefined) {
|
|
677
|
+
res.writeHead(rejection)
|
|
678
|
+
res.end(rejection === 401 ? 'unauthorized' : 'forbidden')
|
|
679
|
+
return
|
|
680
|
+
}
|
|
681
|
+
|
|
682
|
+
if (req.method === 'GET') {
|
|
683
|
+
const current = status()
|
|
684
|
+
const release = await releaseState(/[?&]force=1(?:&|$)/.test(req.url ?? ''), current)
|
|
685
|
+
const { spec, ...visible } = current
|
|
686
|
+
sendJson(res, 200, { ...visible, ...release, github: githubInfo(current) })
|
|
687
|
+
return
|
|
688
|
+
}
|
|
689
|
+
|
|
690
|
+
if (req.method !== 'POST') {
|
|
691
|
+
sendJson(res, 405, { error: 'Method not allowed.' })
|
|
692
|
+
return
|
|
693
|
+
}
|
|
694
|
+
|
|
695
|
+
let body
|
|
696
|
+
try {
|
|
697
|
+
body = JSON.parse(await readBody(req))
|
|
698
|
+
} catch (error) {
|
|
699
|
+
sendJson(res, 400, { error: error instanceof Error ? error.message : String(error) })
|
|
700
|
+
return
|
|
701
|
+
}
|
|
702
|
+
const current = status()
|
|
703
|
+
if (current.source !== 'npm' && current.source !== 'github') {
|
|
704
|
+
sendJson(res, 409, { error: 'This copy cannot be updated from this page.' })
|
|
705
|
+
return
|
|
706
|
+
}
|
|
707
|
+
const target = current.source === 'github' ? githubTarget(current.spec) : null
|
|
708
|
+
if (current.source === 'github' && (target === null || target.repo !== OFFICIAL_REPO)) {
|
|
709
|
+
sendJson(res, 409, { error: 'This copy comes from another repository.' })
|
|
710
|
+
return
|
|
711
|
+
}
|
|
712
|
+
if (current.source === 'github' && target.pinned) {
|
|
713
|
+
sendJson(res, 409, { error: 'This copy is pinned to an exact ref.' })
|
|
714
|
+
return
|
|
715
|
+
}
|
|
716
|
+
const channel = body?.channel === 'github' || body?.channel === 'npm' ? body.channel : current.source
|
|
717
|
+
if (channel === 'npm') {
|
|
718
|
+
const version = body?.version
|
|
719
|
+
if (typeof version !== 'string' || !VERSION_PATTERN.test(version)) {
|
|
720
|
+
sendJson(res, 400, { error: 'The version must look like 1.2.3.' })
|
|
721
|
+
return
|
|
722
|
+
}
|
|
723
|
+
if (busy) {
|
|
724
|
+
sendJson(res, 409, { error: 'An update is already running.' })
|
|
725
|
+
return
|
|
726
|
+
}
|
|
727
|
+
busy = true
|
|
728
|
+
try {
|
|
729
|
+
await installNpm(version)
|
|
730
|
+
sendJson(res, 200, { installedVersion: version })
|
|
731
|
+
} catch (error) {
|
|
732
|
+
sendJson(res, 500, { error: error instanceof Error ? error.message : String(error) })
|
|
733
|
+
} finally {
|
|
734
|
+
busy = false
|
|
735
|
+
}
|
|
736
|
+
return
|
|
737
|
+
}
|
|
738
|
+
if (busy) {
|
|
739
|
+
sendJson(res, 409, { error: 'An update is already running.' })
|
|
740
|
+
return
|
|
741
|
+
}
|
|
742
|
+
busy = true
|
|
743
|
+
try {
|
|
744
|
+
await installGit(current.source === 'github' ? current.spec : GITHUB_SPEC)
|
|
745
|
+
const requested = typeof body?.version === 'string' && VERSION_PATTERN.test(body.version) ? body.version : null
|
|
746
|
+
sendJson(res, 200, { installedVersion: readDisk() ?? requested ?? current.installedVersion })
|
|
747
|
+
} catch (error) {
|
|
748
|
+
sendJson(res, 500, { error: error instanceof Error ? error.message : String(error) })
|
|
749
|
+
} finally {
|
|
750
|
+
busy = false
|
|
751
|
+
}
|
|
752
|
+
}
|
|
753
|
+
}
|
|
754
|
+
|
|
755
|
+
/**
|
|
756
|
+
* @param config - `section` is an optional overlay text; a missing value uses
|
|
757
|
+
* the bundled `assets/section.md`.
|
|
758
|
+
*/
|
|
759
|
+
export function apply(ctx, config) {
|
|
760
|
+
|
|
761
|
+
const section = config?.section
|
|
762
|
+
if (typeof section !== 'string' || section.trim() === '') {
|
|
763
|
+
throw new Error('dsh-nerd-mode needs a non-empty string `section`')
|
|
764
|
+
}
|
|
765
|
+
|
|
766
|
+
ctx.sessionProjections.register(projection)
|
|
767
|
+
|
|
768
|
+
ctx.effect(
|
|
769
|
+
() => ctx.on('agent/inbox/claimed', ({ agent, message }) => {
|
|
770
|
+
directStudyPlusBySession.set(agent.session, directStudyPlusId(message))
|
|
771
|
+
}),
|
|
772
|
+
'dsh-nerd-mode: study-plus claim',
|
|
773
|
+
)
|
|
774
|
+
|
|
775
|
+
ctx.effect(
|
|
776
|
+
() =>
|
|
777
|
+
ctx.systemPrompt.section({
|
|
778
|
+
name: SECTION,
|
|
779
|
+
order: SECTION_ORDER,
|
|
780
|
+
text: (context) => {
|
|
781
|
+
if (context.agent === undefined) return ''
|
|
782
|
+
if (!ctx.sessionProjections.stateOf(context.agent.session, KEY).active) return ''
|
|
783
|
+
const session = context.agent.session
|
|
784
|
+
const invocationId = studyPlusInvocation(session)
|
|
785
|
+
if (invocationId === undefined) return section
|
|
786
|
+
return section + studyPlusSettings(config ?? {}, session, invocationId)
|
|
787
|
+
},
|
|
788
|
+
}),
|
|
789
|
+
'dsh-nerd-mode: section',
|
|
790
|
+
)
|
|
791
|
+
|
|
792
|
+
ctx.inject(['commands'], (commandCtx) => {
|
|
793
|
+
commandCtx.commands.register({
|
|
794
|
+
name: COMMAND,
|
|
795
|
+
description: 'Turn Nerd Mode on or off for this conversation',
|
|
796
|
+
input: { hint: '[on|off]' },
|
|
797
|
+
handler: ({ agent, rawInput }) => {
|
|
798
|
+
const arg = rawInput.trim().toLowerCase()
|
|
799
|
+
const active = ctx.sessionProjections.stateOf(agent.session, KEY).active
|
|
800
|
+
|
|
801
|
+
if (arg === 'off') {
|
|
802
|
+
return { kind: 'success', text: active ? 'Nerd Mode off.' : 'Nerd Mode is already off.' }
|
|
803
|
+
}
|
|
804
|
+
if (arg !== '' && arg !== 'on') {
|
|
805
|
+
return { kind: 'error', text: `Unknown argument "${arg}". Use /nerd-mode on or /nerd-mode off.` }
|
|
806
|
+
}
|
|
807
|
+
return {
|
|
808
|
+
kind: 'success',
|
|
809
|
+
text: active ? 'Nerd Mode is already on.' : 'Nerd Mode on. /study guides a concept or a task.',
|
|
810
|
+
}
|
|
811
|
+
},
|
|
812
|
+
})
|
|
813
|
+
})
|
|
814
|
+
|
|
815
|
+
// The web server is optional: a host without it still loads the switch and
|
|
816
|
+
// the prompt section, and the panel shows its failure line.
|
|
817
|
+
ctx.inject(['webServer', 'connection'], (webCtx) => {
|
|
818
|
+
webCtx.effect(
|
|
819
|
+
() =>
|
|
820
|
+
webCtx.webServer.register({
|
|
821
|
+
kind: 'exact',
|
|
822
|
+
path: STUDY_SESSIONS_PATH,
|
|
823
|
+
handler: createStudySessionsHandler({ connection: webCtx.connection }),
|
|
824
|
+
}),
|
|
825
|
+
'dsh-nerd-mode: study sessions route',
|
|
826
|
+
)
|
|
827
|
+
|
|
828
|
+
webCtx.effect(
|
|
829
|
+
() =>
|
|
830
|
+
webCtx.webServer.register({
|
|
831
|
+
kind: 'exact',
|
|
832
|
+
path: UPDATE_PATH,
|
|
833
|
+
handler: createUpdateHandler({ connection: webCtx.connection }),
|
|
834
|
+
}),
|
|
835
|
+
'dsh-nerd-mode: update route',
|
|
836
|
+
)
|
|
837
|
+
})
|
|
838
|
+
}
|