dsh-tower 2.0.4 → 2.1.0-beta.1
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 +101 -0
- package/COMPATIBILITY.md +38 -0
- package/CONTRIBUTING.md +150 -2
- package/README.en.md +40 -3
- package/README.md +38 -3
- package/SECURITY.md +27 -0
- package/adapters/default.js +10 -0
- package/adapters/store-fs.js +6 -0
- package/adapters/vcs-git.js +4 -0
- package/app/batch.js +79 -0
- package/app/board.js +25 -0
- package/app/checkCompletion.js +76 -0
- package/app/enrichedMissions.js +26 -0
- package/app/findings.js +78 -0
- package/app/gate.js +108 -0
- package/app/init.js +249 -0
- package/app/merge.js +567 -0
- package/app/messages.js +66 -0
- package/app/mission.js +35 -0
- package/app/missionContext.js +14 -0
- package/app/plan.js +42 -0
- package/app/provision.js +193 -0
- package/app/read.js +57 -0
- package/app/rebase.js +302 -0
- package/app/recording.js +72 -0
- package/app/review.js +145 -0
- package/app/roster.js +123 -0
- package/app/spawn.js +125 -0
- package/app/teardown.js +184 -0
- package/app/tower.js +524 -0
- package/cordis.patch.yml +61 -53
- package/docs/README.md +3 -1
- package/docs/architecture/overview.md +111 -12
- package/docs/board.md +72 -0
- package/docs/guide/hot-reload.md +1 -1
- package/docs/guide/install-upgrade-migration.md +8 -1
- package/docs/guide/release.md +125 -0
- package/docs/operations/security.md +47 -2
- package/docs/reference/config.generated.md +66 -0
- package/docs/reference/config.md +4 -1
- package/docs/reference/errors.generated.md +39 -0
- package/docs/reference/errors.md +4 -2
- package/docs/reference/tools.generated.md +33 -0
- package/docs/reference/tools.md +1 -1
- package/docs/zh/README.md +3 -2
- package/docs/zh/guide/hot-reload.md +1 -1
- package/docs/zh/guide/install-upgrade-migration.md +6 -1
- package/docs/zh/operations/security.md +30 -1
- package/docs/zh/reference/config.md +4 -1
- package/docs/zh/reference/errors.md +3 -1
- package/docs/zh/reference/tools.md +1 -1
- package/domain/collab/batch.js +112 -0
- package/domain/collab/messages.js +127 -0
- package/domain/collab/roster.js +158 -0
- package/domain/collab/spawn.js +188 -0
- package/domain/config/schema.js +404 -0
- package/domain/contracts/outputs.js +279 -0
- package/domain/contracts/tool-inputs/collaboration.js +58 -0
- package/domain/contracts/tool-inputs/findings.js +29 -0
- package/domain/contracts/tool-inputs/index.js +94 -0
- package/domain/contracts/tool-inputs/integration.js +37 -0
- package/domain/contracts/tool-inputs/missions.js +94 -0
- package/domain/contracts/tool-inputs/ops.js +75 -0
- package/domain/contracts/tool-inputs/shared.js +25 -0
- package/domain/errors.js +96 -0
- package/domain/gate.js +125 -0
- package/domain/integration/gate.js +176 -0
- package/domain/integration/init.js +225 -0
- package/domain/integration/liveness.js +31 -0
- package/domain/integration/merge.js +377 -0
- package/domain/integration/provision.js +124 -0
- package/domain/integration/rebase.js +236 -0
- package/domain/integration/review.js +52 -0
- package/domain/integration/teardown.js +118 -0
- package/domain/mission.js +68 -0
- package/domain/missions/completion.js +114 -0
- package/domain/missions/findings.js +201 -0
- package/domain/missions/lifecycle.js +185 -0
- package/domain/missions/plan.js +149 -0
- package/domain/missions/review.js +179 -0
- package/domain/planning.js +118 -0
- package/domain/read/enrichedMissions.js +58 -0
- package/domain/read/missionContext.js +53 -0
- package/domain/read/views.js +128 -0
- package/domain/report.js +59 -0
- package/domain/roster.js +40 -0
- package/domain/scope.js +105 -0
- package/domain/state/schema.js +233 -0
- package/domain/state/semantics.js +117 -0
- package/domain/state.js +33 -0
- package/domain/summary.js +114 -0
- package/domain/vocabulary.js +179 -0
- package/index.js +63 -20
- package/lib/briefing.js +176 -0
- package/lib/compatibility.js +108 -0
- package/lib/config.js +33 -375
- package/lib/doctor.js +97 -4
- package/lib/git.js +60 -16
- package/lib/hmr.js +4 -3
- package/lib/leases.js +3 -2
- package/lib/legacy.js +20 -9
- package/lib/modeState.js +39 -0
- package/lib/modeStore.js +11 -43
- package/lib/persistence.js +349 -0
- package/lib/presets.js +35 -0
- package/lib/spawn-prechecks.js +168 -0
- package/lib/spawn-support.js +79 -0
- package/lib/store.js +54 -137
- package/lib/tests.js +11 -10
- package/lib/tool-registry.js +544 -0
- package/lib/workspaceLock.js +2 -2
- package/lib/worktreeOwnership.js +5 -4
- package/locale/en.json +23 -1
- package/locale/zh.json +22 -0
- package/package.json +32 -4
- package/ports/clock.js +6 -0
- package/ports/ids.js +7 -0
- package/ports/index.js +2 -0
- package/ports/store.js +11 -0
- package/ports/vcs.js +11 -0
- package/scripts/architecture.mjs +94 -0
- package/scripts/check-docs.mjs +9 -3
- package/scripts/check-pack.mjs +49 -0
- package/scripts/domain-metrics.mjs +48 -0
- package/scripts/generate-config-reference.mjs +46 -0
- package/scripts/generate-error-reference.mjs +29 -0
- package/scripts/generate-tools-reference.mjs +56 -0
- package/scripts/issue-audit.mjs +259 -0
- package/scripts/lib/cli.mjs +76 -0
- package/scripts/lib/tmp.mjs +20 -0
- package/scripts/mount-smoke/fixtures.mjs +162 -0
- package/scripts/mount-smoke/host.json +4 -0
- package/scripts/mount-smoke/probe.mjs +125 -0
- package/scripts/mount-smoke/surface.mjs +141 -0
- package/scripts/mount-smoke.mjs +270 -0
- package/scripts/pack-manifest.txt +203 -0
- package/scripts/release-check.mjs +303 -0
- package/tools/adopt.js +72 -0
- package/tools/batch.js +78 -0
- package/tools/complete.js +53 -0
- package/tools/diff.js +39 -0
- package/tools/doctor.js +50 -0
- package/tools/finding.js +40 -0
- package/tools/findings.js +39 -0
- package/tools/import-legacy.js +42 -0
- package/tools/inbox.js +39 -0
- package/tools/index.js +39 -0
- package/tools/init.js +63 -0
- package/tools/interrupt.js +42 -0
- package/tools/legacy-map.js +44 -0
- package/tools/merge.js +39 -0
- package/tools/mission.js +41 -0
- package/tools/plan.js +39 -0
- package/tools/preflight.js +44 -0
- package/tools/rebase.js +39 -0
- package/tools/recover.js +45 -0
- package/tools/reload.js +52 -0
- package/tools/resource.js +54 -0
- package/tools/review.js +45 -0
- package/tools/roster.js +60 -0
- package/tools/send.js +147 -0
- package/tools/spawn.js +196 -0
- package/tools/status.js +94 -0
- package/tools/teardown.js +42 -0
- package/tools/test.js +61 -0
- package/types/cordis.d.ts +6 -0
- package/types/index.d.ts +339 -0
- package/lib/errors.js +0 -123
- package/lib/tools.js +0 -2460
- package/lib/tower.js +0 -2912
- /package/{lib → domain}/batch.js +0 -0
|
@@ -0,0 +1,259 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Offline tracker audit (#96, #79): every closed P0/P1 must point at evidence.
|
|
3
|
+
//
|
|
4
|
+
// A closure is checkable when its closing comment carries the two-line contract:
|
|
5
|
+
//
|
|
6
|
+
// verified-by: tests/<file>.test.mjs
|
|
7
|
+
// commit: <sha>
|
|
8
|
+
//
|
|
9
|
+
// This script reads a *local tracker export* (no network, no credentials), finds
|
|
10
|
+
// every closed P0/P1, and requires `verified-by:` to name a test file that exists
|
|
11
|
+
// in the repository. A missing, nonexistent, non-test or repository-escaping
|
|
12
|
+
// pointer exits non-zero, so "closed" stops being an assertion by the author.
|
|
13
|
+
//
|
|
14
|
+
// Usage:
|
|
15
|
+
// node scripts/issue-audit.mjs [--tracker <file>] [--root <dir>]
|
|
16
|
+
// [--since <YYYY-MM-DD>] [--strict] [--json]
|
|
17
|
+
//
|
|
18
|
+
// CONTRIBUTING.md ("Commits, closures and evidence") documents the export
|
|
19
|
+
// command and the rule; `npm run audit:issues` is the short form.
|
|
20
|
+
import fs from 'node:fs'
|
|
21
|
+
import path from 'node:path'
|
|
22
|
+
import { fileURLToPath } from 'node:url'
|
|
23
|
+
import { findingRecorder, parseArgs, runMain, UsageError } from './lib/cli.mjs'
|
|
24
|
+
|
|
25
|
+
const PACKAGE_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..')
|
|
26
|
+
const DEFAULT_TRACKER = path.join('tracker', 'issues.json')
|
|
27
|
+
const TEST_PATTERN = /(?:^|\/)[^/]+\.(?:test|spec)\.[cm]?[jt]sx?$/
|
|
28
|
+
const USAGE = `usage: node scripts/issue-audit.mjs [--tracker <file>] [--root <dir>] [--since <YYYY-MM-DD>] [--strict] [--json]
|
|
29
|
+
|
|
30
|
+
--tracker local tracker export (default: tracker/issues.json)
|
|
31
|
+
--root repository the verified-by paths resolve against (default: this package)
|
|
32
|
+
--since only audit issues closed on or after this UTC date
|
|
33
|
+
--strict treat a missing "commit:" field as an error
|
|
34
|
+
--json print the report as JSON
|
|
35
|
+
--help print this text
|
|
36
|
+
|
|
37
|
+
The export is {"issues": [...]} or a bare array; each issue needs number, title,
|
|
38
|
+
state (or closedAt), labels, body and comments. Produce it once with gh (see
|
|
39
|
+
CONTRIBUTING.md); the audit itself never touches the network.`
|
|
40
|
+
|
|
41
|
+
// `verified-by:` / `commit:` are line fields, not prose. Backticks, a leading
|
|
42
|
+
// bullet or blockquote, and Windows path separators are tolerated because the
|
|
43
|
+
// contract is about the pointer, not the formatting.
|
|
44
|
+
function cleanValue(value) {
|
|
45
|
+
return String(value)
|
|
46
|
+
.trim()
|
|
47
|
+
.replace(/^`+|`+$/g, '')
|
|
48
|
+
.trim()
|
|
49
|
+
.replace(/^["']+|["']+$/g, '')
|
|
50
|
+
.trim()
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function extractFields(text) {
|
|
54
|
+
const fields = { verifiedBy: [], commit: null }
|
|
55
|
+
for (const rawLine of String(text ?? '').split(/\r?\n/)) {
|
|
56
|
+
const line = rawLine.replace(/^\s*(?:[-*+]\s+|>\s*)+/, '')
|
|
57
|
+
const match = /^(verified-by|commit)\s*:\s*(.+)$/i.exec(line)
|
|
58
|
+
if (!match) continue
|
|
59
|
+
const value = cleanValue(match[2])
|
|
60
|
+
if (!value) continue
|
|
61
|
+
if (match[1].toLowerCase() === 'verified-by') fields.verifiedBy.push(value)
|
|
62
|
+
else fields.commit = value
|
|
63
|
+
}
|
|
64
|
+
return fields
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function textSources(issue) {
|
|
68
|
+
const sources = []
|
|
69
|
+
if (typeof issue.body === 'string' && issue.body.trim()) sources.push(issue.body)
|
|
70
|
+
const comments = Array.isArray(issue.comments) ? issue.comments : []
|
|
71
|
+
for (const comment of comments) {
|
|
72
|
+
const body = typeof comment === 'string' ? comment : comment?.body
|
|
73
|
+
if (typeof body === 'string' && body.trim()) sources.push(body)
|
|
74
|
+
}
|
|
75
|
+
return sources
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// The newest text that carries a `verified-by:` line is the closure evidence;
|
|
79
|
+
// an older comment cannot keep an issue closed after an edit. Every verified-by
|
|
80
|
+
// line in that text is checked, so listing two tests cannot hide one gap.
|
|
81
|
+
export function selectEvidence(issue) {
|
|
82
|
+
const sources = textSources(issue)
|
|
83
|
+
for (let index = sources.length - 1; index >= 0; index -= 1) {
|
|
84
|
+
const fields = extractFields(sources[index])
|
|
85
|
+
if (fields.verifiedBy.length > 0) return fields
|
|
86
|
+
}
|
|
87
|
+
return null
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function labelsOf(issue) {
|
|
91
|
+
const labels = Array.isArray(issue.labels) ? issue.labels : []
|
|
92
|
+
return labels.map((label) => (typeof label === 'string' ? label : label?.name ?? '')).filter(Boolean)
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export function priorityOf(issue) {
|
|
96
|
+
const labels = labelsOf(issue).map((label) => label.toUpperCase())
|
|
97
|
+
if (labels.includes('P0')) return 'P0'
|
|
98
|
+
if (labels.includes('P1')) return 'P1'
|
|
99
|
+
// The historical record uses `[P0]`/`[P1]` title prefixes; a label that was
|
|
100
|
+
// never backfilled must not make a critical closure invisible to the audit.
|
|
101
|
+
const match = /(?:^|\s)\[(P[01])\]/i.exec(String(issue.title ?? ''))
|
|
102
|
+
return match ? match[1].toUpperCase() : null
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
export function isClosed(issue) {
|
|
106
|
+
const state = String(issue.state ?? '').toLowerCase()
|
|
107
|
+
return state === 'closed' || Boolean(issue.closedAt ?? issue.closed_at)
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function closedAtOf(issue) {
|
|
111
|
+
const raw = issue.closedAt ?? issue.closed_at
|
|
112
|
+
if (!raw) return null
|
|
113
|
+
const date = new Date(raw)
|
|
114
|
+
return Number.isNaN(date.getTime()) ? null : date
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// A pointer must stay inside the repository and name a real test file: an
|
|
118
|
+
// existing script or doc is not evidence of a regression test.
|
|
119
|
+
function inspectPointer(pointer, root) {
|
|
120
|
+
const normalized = String(pointer).replace(/\\/g, '/').trim()
|
|
121
|
+
if (!normalized) return { code: 'invalid-pointer', detail: 'empty' }
|
|
122
|
+
if (path.isAbsolute(normalized) || /^[A-Za-z]:\//.test(normalized) || normalized.startsWith('/')) {
|
|
123
|
+
return { code: 'invalid-pointer', detail: 'must be repository-relative' }
|
|
124
|
+
}
|
|
125
|
+
const resolved = path.resolve(root, normalized)
|
|
126
|
+
const relative = path.relative(root, resolved)
|
|
127
|
+
if (!relative || relative.startsWith('..') || path.isAbsolute(relative)) {
|
|
128
|
+
return { code: 'invalid-pointer', detail: 'escapes the repository' }
|
|
129
|
+
}
|
|
130
|
+
if (!fs.existsSync(resolved)) return { code: 'missing-test', detail: 'does not exist' }
|
|
131
|
+
if (!fs.statSync(resolved).isFile()) return { code: 'missing-test', detail: 'is not a file' }
|
|
132
|
+
const isTest = normalized.startsWith('tests/') || TEST_PATTERN.test(normalized)
|
|
133
|
+
if (!isTest) return { code: 'not-a-test', detail: 'not under tests/ and not named *.test.* / *.spec.*' }
|
|
134
|
+
return null
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
export function parseTracker(text) {
|
|
138
|
+
let parsed
|
|
139
|
+
try {
|
|
140
|
+
parsed = JSON.parse(text)
|
|
141
|
+
} catch (error) {
|
|
142
|
+
throw new Error(`tracker export is not valid JSON: ${error?.message ?? error}`)
|
|
143
|
+
}
|
|
144
|
+
const issues = Array.isArray(parsed) ? parsed : parsed?.issues
|
|
145
|
+
if (!Array.isArray(issues)) throw new Error('tracker export must be an array of issues or { "issues": [...] }')
|
|
146
|
+
for (const issue of issues) {
|
|
147
|
+
if (!issue || typeof issue !== 'object' || typeof issue.number !== 'number') {
|
|
148
|
+
throw new Error('tracker export must be an array of issues or { "issues": [...] }: every issue needs a numeric number')
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
return issues
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
export function auditIssues(issues, options = {}) {
|
|
155
|
+
const root = path.resolve(options.root ?? PACKAGE_ROOT)
|
|
156
|
+
const strict = Boolean(options.strict)
|
|
157
|
+
let since = null
|
|
158
|
+
if (options.since) {
|
|
159
|
+
since = new Date(`${String(options.since).slice(0, 10)}T00:00:00.000Z`)
|
|
160
|
+
if (Number.isNaN(since.getTime())) throw new UsageError(`--since is not a date: ${options.since}`)
|
|
161
|
+
}
|
|
162
|
+
const findings = []
|
|
163
|
+
let checked = 0
|
|
164
|
+
for (const issue of issues) {
|
|
165
|
+
if (!isClosed(issue)) continue
|
|
166
|
+
const priority = priorityOf(issue)
|
|
167
|
+
if (!priority) continue
|
|
168
|
+
const closedAt = closedAtOf(issue)
|
|
169
|
+
if (since && closedAt && closedAt < since) continue
|
|
170
|
+
checked += 1
|
|
171
|
+
const label = `#${issue.number} ${String(issue.title ?? '').trim()}`
|
|
172
|
+
const record = findingRecorder(findings, { number: issue.number, title: issue.title, priority })
|
|
173
|
+
|
|
174
|
+
const fields = selectEvidence(issue)
|
|
175
|
+
if (!fields) {
|
|
176
|
+
record('missing-evidence', 'error', `${label}: no verified-by: field in the body or comments`)
|
|
177
|
+
continue
|
|
178
|
+
}
|
|
179
|
+
for (const pointer of fields.verifiedBy) {
|
|
180
|
+
const problem = inspectPointer(pointer, root)
|
|
181
|
+
if (!problem) continue
|
|
182
|
+
if (problem.code === 'missing-test') {
|
|
183
|
+
record('missing-test', 'error', `${label}: verified-by names a missing test file: ${pointer}`)
|
|
184
|
+
} else if (problem.code === 'not-a-test') {
|
|
185
|
+
record('not-a-test', 'error', `${label}: verified-by is not a test file: ${pointer} (${problem.detail})`)
|
|
186
|
+
} else {
|
|
187
|
+
record('invalid-pointer', 'error', `${label}: verified-by must be a repository-relative path: ${pointer} (${problem.detail})`)
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
if (!fields.commit) {
|
|
191
|
+
record('missing-commit', strict ? 'error' : 'warning', `${label}: no commit: <sha> field (warning; --strict fails)`)
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
const errors = findings.filter((finding) => finding.level === 'error')
|
|
195
|
+
const warnings = findings.filter((finding) => finding.level === 'warning')
|
|
196
|
+
return {
|
|
197
|
+
ok: errors.length === 0,
|
|
198
|
+
checked,
|
|
199
|
+
findings,
|
|
200
|
+
errors: errors.length,
|
|
201
|
+
warnings: warnings.length,
|
|
202
|
+
strict,
|
|
203
|
+
since: options.since ?? null,
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
function parseOptions(argv) {
|
|
208
|
+
return parseArgs(argv, {
|
|
209
|
+
initial: { tracker: null, root: PACKAGE_ROOT, since: null, strict: false, json: false, help: false },
|
|
210
|
+
values: {
|
|
211
|
+
'--tracker': 'tracker',
|
|
212
|
+
'--root': { key: 'root', parse: path.resolve },
|
|
213
|
+
'--since': 'since',
|
|
214
|
+
},
|
|
215
|
+
flags: { '--strict': 'strict', '--json': 'json', '--help': 'help', '-h': 'help' },
|
|
216
|
+
})
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
function cli(argv) {
|
|
220
|
+
const options = parseOptions(argv)
|
|
221
|
+
if (options.help) {
|
|
222
|
+
console.log(USAGE)
|
|
223
|
+
return 0
|
|
224
|
+
}
|
|
225
|
+
const tracker = options.tracker ? path.resolve(process.cwd(), options.tracker) : path.join(PACKAGE_ROOT, DEFAULT_TRACKER)
|
|
226
|
+
let text
|
|
227
|
+
try {
|
|
228
|
+
text = fs.readFileSync(tracker, 'utf8')
|
|
229
|
+
} catch (error) {
|
|
230
|
+
console.error(`issue-audit: cannot read tracker export ${tracker}: ${error?.message ?? error}`)
|
|
231
|
+
console.error('issue-audit: produce one with the gh command in CONTRIBUTING.md ("Commits, closures and evidence")')
|
|
232
|
+
return 2
|
|
233
|
+
}
|
|
234
|
+
let issues
|
|
235
|
+
let result
|
|
236
|
+
try {
|
|
237
|
+
issues = parseTracker(text)
|
|
238
|
+
result = auditIssues(issues, { root: options.root, since: options.since, strict: options.strict })
|
|
239
|
+
} catch (error) {
|
|
240
|
+
console.error(`issue-audit: ${error.message}`)
|
|
241
|
+
return 2
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
if (options.json) {
|
|
245
|
+
console.log(JSON.stringify({ tracker, root: path.resolve(options.root), ...result }, null, 2))
|
|
246
|
+
return result.ok ? 0 : 1
|
|
247
|
+
}
|
|
248
|
+
console.log(`issue-audit: ${tracker}`)
|
|
249
|
+
console.log(`issue-audit: ${result.checked} closed P0/P1 checked, ${result.errors} errors, ${result.warnings} warnings`)
|
|
250
|
+
for (const finding of result.findings) console.log(`${finding.level} [${finding.code}] ${finding.message}`)
|
|
251
|
+
if (result.ok) {
|
|
252
|
+
console.log('issue-audit: every checked closure names an existing test file')
|
|
253
|
+
} else {
|
|
254
|
+
console.log(`issue-audit: ${result.errors} closure(s) are not verifiable; see CONTRIBUTING.md`)
|
|
255
|
+
}
|
|
256
|
+
return result.ok ? 0 : 1
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
await runMain(import.meta.url, () => cli(process.argv.slice(2)), { errorPrefix: 'issue-audit', usage: USAGE })
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
// Shared CLI plumbing for the maintenance entry points under scripts/ (#94).
|
|
2
|
+
//
|
|
3
|
+
// scripts/release-check.mjs, scripts/issue-audit.mjs, scripts/mount-smoke.mjs
|
|
4
|
+
// and scripts/mount-smoke/fixtures.mjs had near-identical `parseArgs`,
|
|
5
|
+
// `UsageError`, `isMain` and exit-code blocks. This is that block once: each
|
|
6
|
+
// script declares its flags and keeps its own usage text, messages and codes.
|
|
7
|
+
import path from 'node:path'
|
|
8
|
+
import { fileURLToPath } from 'node:url'
|
|
9
|
+
|
|
10
|
+
export class UsageError extends Error {}
|
|
11
|
+
|
|
12
|
+
function usageError(message) {
|
|
13
|
+
const error = new UsageError(message)
|
|
14
|
+
// Only argument-parsing failures print the usage block; a repository or file
|
|
15
|
+
// problem exits 2 with its message only.
|
|
16
|
+
error.showUsage = true
|
|
17
|
+
return error
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** True when this module is the program entry point, not an imported module. */
|
|
21
|
+
export function isMain(importMetaUrl) {
|
|
22
|
+
return Boolean(process.argv[1]) && path.resolve(process.argv[1]) === fileURLToPath(importMetaUrl)
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Parse `--name <value>` and boolean flags into a plain object.
|
|
27
|
+
*
|
|
28
|
+
* - `initial`: the object to start from (a copy is returned).
|
|
29
|
+
* - `flags`: flag -> property name for booleans.
|
|
30
|
+
* - `values`: flag -> property name, or `{ key, parse }`.
|
|
31
|
+
* - `error`: error factory for scripts whose argument errors are ordinary
|
|
32
|
+
* failures rather than usage errors (the mount smoke exits 1, not 2).
|
|
33
|
+
* - `optionLikeValues`: reject a value that itself starts with `--`.
|
|
34
|
+
*/
|
|
35
|
+
export function parseArgs(
|
|
36
|
+
argv,
|
|
37
|
+
{ initial = {}, flags = {}, values = {}, error = usageError, optionLikeValues = true } = {},
|
|
38
|
+
) {
|
|
39
|
+
const parsed = { ...initial }
|
|
40
|
+
for (let index = 0; index < argv.length; index += 1) {
|
|
41
|
+
const arg = argv[index]
|
|
42
|
+
if (Object.prototype.hasOwnProperty.call(flags, arg)) {
|
|
43
|
+
parsed[flags[arg]] = true
|
|
44
|
+
continue
|
|
45
|
+
}
|
|
46
|
+
if (!Object.prototype.hasOwnProperty.call(values, arg)) throw error(`unknown argument ${JSON.stringify(arg)}`)
|
|
47
|
+
const next = argv[index + 1]
|
|
48
|
+
if (next === undefined || (optionLikeValues && next.startsWith('--'))) throw error(`${arg} needs a value`)
|
|
49
|
+
index += 1
|
|
50
|
+
const spec = values[arg]
|
|
51
|
+
if (typeof spec === 'string') parsed[spec] = next
|
|
52
|
+
else parsed[spec.key] = spec.parse ? spec.parse(next) : next
|
|
53
|
+
}
|
|
54
|
+
return parsed
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** The finding recorder both gates use, so every call is `(code, level, message)`. */
|
|
58
|
+
export function findingRecorder(findings, base = {}) {
|
|
59
|
+
return (code, level, message) => findings.push({ ...base, code, level, message })
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Run a CLI as the program entry point: the returned exit code becomes
|
|
64
|
+
* `process.exitCode`. A UsageError exits 2 (printing the usage text when it
|
|
65
|
+
* came from argument parsing); any other error exits 1.
|
|
66
|
+
*/
|
|
67
|
+
export async function runMain(importMetaUrl, run, { errorPrefix, usage = null } = {}) {
|
|
68
|
+
if (!isMain(importMetaUrl)) return
|
|
69
|
+
try {
|
|
70
|
+
process.exitCode = await run()
|
|
71
|
+
} catch (error) {
|
|
72
|
+
console.error(`${errorPrefix}: ${error?.message ?? error}`)
|
|
73
|
+
if (usage && error?.showUsage) console.error(usage)
|
|
74
|
+
process.exitCode = error instanceof UsageError ? 2 : 1
|
|
75
|
+
}
|
|
76
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
// Best-effort temp-tree cleanup shared by the mount smoke and its fixtures (#91).
|
|
2
|
+
//
|
|
3
|
+
// Windows can hold a handle on a just-written file after a child process exits
|
|
4
|
+
// (EPERM/EBUSY). Cleanup is never part of a proof, so retry briefly and give up:
|
|
5
|
+
// a locked temp directory must not turn a green run red or hide the real cause
|
|
6
|
+
// of a red one.
|
|
7
|
+
import fs from 'node:fs'
|
|
8
|
+
|
|
9
|
+
export function removeTreeQuietly(dir) {
|
|
10
|
+
if (!dir) return false
|
|
11
|
+
for (let attempt = 0; attempt < 3; attempt += 1) {
|
|
12
|
+
try {
|
|
13
|
+
fs.rmSync(dir, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 })
|
|
14
|
+
return true
|
|
15
|
+
} catch {
|
|
16
|
+
// Still locked; try again, then leave the directory for the OS.
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
return false
|
|
20
|
+
}
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Seeded failure fixtures for the mount smoke (#95).
|
|
3
|
+
//
|
|
4
|
+
// npm run smoke:fixtures
|
|
5
|
+
// node scripts/mount-smoke/fixtures.mjs --host-dir <installed-host> --keep
|
|
6
|
+
//
|
|
7
|
+
// The real smoke proves the current tree mounts. These fixtures prove it can
|
|
8
|
+
// still fail for the right reason: each one copies the package into a temp
|
|
9
|
+
// directory, breaks exactly one claim the bundle makes, runs the smoke against
|
|
10
|
+
// the copy, and requires a red run that names the cause. A green control run
|
|
11
|
+
// comes first, so a broken environment is never mistaken for a caught bug.
|
|
12
|
+
//
|
|
13
|
+
// The fixtures run in CI as the non-required `mount-fixtures` job, with one
|
|
14
|
+
// cached host install shared by the control and both seeds through --host-dir;
|
|
15
|
+
// locally the short form is `npm run smoke:fixtures`.
|
|
16
|
+
import { spawnSync } from 'node:child_process'
|
|
17
|
+
import fs from 'node:fs'
|
|
18
|
+
import os from 'node:os'
|
|
19
|
+
import path from 'node:path'
|
|
20
|
+
import { fileURLToPath } from 'node:url'
|
|
21
|
+
import { parseArgs, runMain } from '../lib/cli.mjs'
|
|
22
|
+
import { removeTreeQuietly } from '../lib/tmp.mjs'
|
|
23
|
+
|
|
24
|
+
const scriptDir = path.dirname(fileURLToPath(import.meta.url))
|
|
25
|
+
const repoRoot = path.resolve(scriptDir, '..', '..')
|
|
26
|
+
const smokePath = path.join(scriptDir, '..', 'mount-smoke.mjs')
|
|
27
|
+
|
|
28
|
+
function parseOptions(argv) {
|
|
29
|
+
return parseArgs(argv, {
|
|
30
|
+
initial: { root: repoRoot, hostDir: undefined, keep: false },
|
|
31
|
+
values: {
|
|
32
|
+
'--root': { key: 'root', parse: path.resolve },
|
|
33
|
+
'--host-dir': { key: 'hostDir', parse: path.resolve },
|
|
34
|
+
},
|
|
35
|
+
flags: { '--keep': 'keep' },
|
|
36
|
+
error: (message) => new Error(message),
|
|
37
|
+
optionLikeValues: false,
|
|
38
|
+
})
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function copyPackage(from, to) {
|
|
42
|
+
fs.cpSync(from, to, {
|
|
43
|
+
recursive: true,
|
|
44
|
+
filter: (source) => {
|
|
45
|
+
const base = path.basename(source)
|
|
46
|
+
return base !== 'node_modules' && base !== '.git'
|
|
47
|
+
},
|
|
48
|
+
})
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function replaceOnce(text, search, replacement, label) {
|
|
52
|
+
const next = text.replace(search, replacement)
|
|
53
|
+
if (next === text) throw new Error(`seed did not apply: ${label}`)
|
|
54
|
+
return next
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const fixtures = [
|
|
58
|
+
{
|
|
59
|
+
name: 'renamed-tool-id',
|
|
60
|
+
what: 'one tool module registers an id the bundle does not claim',
|
|
61
|
+
seed(root) {
|
|
62
|
+
const file = path.join(root, 'tools', 'doctor.js')
|
|
63
|
+
const text = fs.readFileSync(file, 'utf8')
|
|
64
|
+
fs.writeFileSync(file, replaceOnce(text, "name: 'tower_doctor'", "name: 'tower_doctor_renamed'", 'tools/doctor.js tower_doctor'))
|
|
65
|
+
},
|
|
66
|
+
expect: ['tool: missing tower_doctor', 'tool: unexpected tower_doctor_renamed'],
|
|
67
|
+
},
|
|
68
|
+
{
|
|
69
|
+
name: 'dropped-skills-section',
|
|
70
|
+
what: 'the tower-skills row is removed from the bundle patch',
|
|
71
|
+
seed(root) {
|
|
72
|
+
const file = path.join(root, 'cordis.patch.yml')
|
|
73
|
+
const text = fs.readFileSync(file, 'utf8')
|
|
74
|
+
const next = replaceOnce(
|
|
75
|
+
text,
|
|
76
|
+
/\n {8}- id: tower-skills\n {10}name: 'dsh-tower\/skills'/,
|
|
77
|
+
'',
|
|
78
|
+
'cordis.patch.yml tower-skills row',
|
|
79
|
+
)
|
|
80
|
+
fs.writeFileSync(file, next)
|
|
81
|
+
},
|
|
82
|
+
expect: ['skill: missing tower-worker-protocol', 'bundle: tower-bundle is missing child tower-skills'],
|
|
83
|
+
},
|
|
84
|
+
]
|
|
85
|
+
|
|
86
|
+
function runSmoke(root, hostDir) {
|
|
87
|
+
const result = spawnSync(process.execPath, [smokePath, '--root', root, '--host-dir', hostDir], {
|
|
88
|
+
encoding: 'utf8',
|
|
89
|
+
timeout: 600_000,
|
|
90
|
+
maxBuffer: 64 * 1024 * 1024,
|
|
91
|
+
})
|
|
92
|
+
return { status: result.status, output: `${result.stdout ?? ''}${result.stderr ?? ''}${result.error ? String(result.error) : ''}` }
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function selfDescribing(output) {
|
|
96
|
+
const missing = []
|
|
97
|
+
if (!/^ {2}profile: /m.test(output)) missing.push('the resolved profile path')
|
|
98
|
+
if (!/^ {2}host: {4}@deepseek-ai\/dsh@/m.test(output)) missing.push('the host build')
|
|
99
|
+
return missing
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function main() {
|
|
103
|
+
const options = parseOptions(process.argv.slice(2))
|
|
104
|
+
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'dsh-tower-mount-fixtures-'))
|
|
105
|
+
const hostDir = options.hostDir ?? path.join(tmp, 'host')
|
|
106
|
+
let failures = 0
|
|
107
|
+
|
|
108
|
+
try {
|
|
109
|
+
process.stdout.write(`control: the unmodified package mounts green\n`)
|
|
110
|
+
const control = runSmoke(options.root, hostDir)
|
|
111
|
+
if (control.status !== 0) {
|
|
112
|
+
console.error('control: FAILED — the fixtures cannot distinguish a seed from a broken environment')
|
|
113
|
+
console.error(control.output.slice(-4000))
|
|
114
|
+
failures += 1
|
|
115
|
+
} else {
|
|
116
|
+
console.log('control: PASS')
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
for (const fixture of fixtures) {
|
|
120
|
+
const copyRoot = path.join(tmp, fixture.name)
|
|
121
|
+
copyPackage(options.root, copyRoot)
|
|
122
|
+
try {
|
|
123
|
+
fixture.seed(copyRoot)
|
|
124
|
+
} catch (error) {
|
|
125
|
+
console.error(`fixture ${fixture.name}: FAILED to seed — ${error.message}`)
|
|
126
|
+
failures += 1
|
|
127
|
+
continue
|
|
128
|
+
}
|
|
129
|
+
const run = runSmoke(copyRoot, hostDir)
|
|
130
|
+
const problems = []
|
|
131
|
+
if (run.status === 0) problems.push('the smoke stayed green; the seeded break was not detected')
|
|
132
|
+
for (const marker of fixture.expect) if (!run.output.includes(marker)) problems.push(`the red run did not say: ${marker}`)
|
|
133
|
+
for (const missing of selfDescribing(run.output)) problems.push(`the red run did not print ${missing}`)
|
|
134
|
+
if (run.status !== 0 && !run.output.includes('workspace kept:')) problems.push('the red run did not keep its workspace')
|
|
135
|
+
|
|
136
|
+
if (problems.length > 0) {
|
|
137
|
+
console.error(`fixture ${fixture.name}: FAILED (${fixture.what})`)
|
|
138
|
+
for (const problem of problems) console.error(` - ${problem}`)
|
|
139
|
+
console.error(run.output.slice(-4000))
|
|
140
|
+
failures += 1
|
|
141
|
+
} else {
|
|
142
|
+
const kept = /workspace kept: (.+)/.exec(run.output)?.[1]?.trim()
|
|
143
|
+
console.log(`fixture ${fixture.name}: PASS — the smoke failed and named the cause${kept ? ` (workspace ${kept})` : ''}`)
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
} finally {
|
|
147
|
+
if (options.keep) {
|
|
148
|
+
console.log(`fixtures workspace kept: ${tmp}`)
|
|
149
|
+
} else {
|
|
150
|
+
removeTreeQuietly(tmp)
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
if (failures > 0) {
|
|
155
|
+
console.error(`mount smoke fixtures: ${failures} of ${fixtures.length + 1} expectations failed`)
|
|
156
|
+
return 1
|
|
157
|
+
}
|
|
158
|
+
console.log(`mount smoke fixtures: control + ${fixtures.length} seeded failures behave as required`)
|
|
159
|
+
return 0
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
await runMain(import.meta.url, main, { errorPrefix: 'mount smoke fixtures failed' })
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
// Boot one throwaway profile through the real DSH loader and report its
|
|
2
|
+
// loaded surface as JSON (#95).
|
|
3
|
+
//
|
|
4
|
+
// This is the only file that touches host internals. It is spawned by
|
|
5
|
+
// scripts/mount-smoke.mjs against the pinned @deepseek-ai/dsh install, so the
|
|
6
|
+
// entry points below are the ones `dsh` itself calls: `runProfile` composes the
|
|
7
|
+
// profile's patch layers, mounts the tree, and hands back the root context.
|
|
8
|
+
// No model call, no browser, no session.
|
|
9
|
+
//
|
|
10
|
+
// Usage: node probe.mjs --home <DSH_HOME> --host <host-install> --profile <name> --out <json>
|
|
11
|
+
import fs from 'node:fs'
|
|
12
|
+
import path from 'node:path'
|
|
13
|
+
import { pathToFileURL } from 'node:url'
|
|
14
|
+
|
|
15
|
+
function parseArgs(argv) {
|
|
16
|
+
const options = {}
|
|
17
|
+
for (let index = 0; index < argv.length; index += 1) {
|
|
18
|
+
const key = argv[index]
|
|
19
|
+
const value = argv[index + 1]
|
|
20
|
+
if (!key.startsWith('--') || value === undefined) throw new Error(`probe: bad argument ${JSON.stringify(key)}`)
|
|
21
|
+
options[key.slice(2)] = value
|
|
22
|
+
index += 1
|
|
23
|
+
}
|
|
24
|
+
for (const required of ['home', 'host', 'profile', 'out']) {
|
|
25
|
+
if (!options[required]) throw new Error(`probe: --${required} is required`)
|
|
26
|
+
}
|
|
27
|
+
return options
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const options = parseArgs(process.argv.slice(2))
|
|
31
|
+
const home = path.resolve(options.home)
|
|
32
|
+
const host = path.resolve(options.host)
|
|
33
|
+
|
|
34
|
+
// Read by dsh-home-paths through the host's own module graph, so this must be
|
|
35
|
+
// set before anything from the host is imported.
|
|
36
|
+
process.env.DSH_HOME = home
|
|
37
|
+
process.env.DSH_TELEMETRY_DISABLED = '1'
|
|
38
|
+
|
|
39
|
+
const hostPackage = path.join(host, 'node_modules', '@deepseek-ai', 'dsh', 'package.json')
|
|
40
|
+
const hostVersion = JSON.parse(fs.readFileSync(hostPackage, 'utf8')).version
|
|
41
|
+
const profileBoot = path.join(host, 'node_modules', '@deepseek-ai', 'dsh', 'lib', 'profile-boot.js')
|
|
42
|
+
const appBoot = path.join(host, 'node_modules', '@deepseek-ai', 'dsh-app-boot', 'lib', 'index.js')
|
|
43
|
+
for (const file of [profileBoot, appBoot]) {
|
|
44
|
+
if (!fs.existsSync(file)) throw new Error(`probe: ${file} is missing; host ${hostVersion} does not expose the loader entry point`)
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const { runProfile } = await import(pathToFileURL(profileBoot).href)
|
|
48
|
+
const { loadLayeredEnv } = await import(pathToFileURL(appBoot).href)
|
|
49
|
+
if (typeof runProfile !== 'function') throw new Error(`probe: host ${hostVersion} does not export runProfile`)
|
|
50
|
+
|
|
51
|
+
/** Run one surface read, keeping a missing service a reportable value. */
|
|
52
|
+
async function read(run) {
|
|
53
|
+
try {
|
|
54
|
+
return { value: await run() }
|
|
55
|
+
} catch (error) {
|
|
56
|
+
return { error: String(error?.message ?? error) }
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const { ctx, shutdown } = await runProfile({
|
|
61
|
+
environment: loadLayeredEnv('dsh'),
|
|
62
|
+
profile: options.profile,
|
|
63
|
+
patchFiles: [],
|
|
64
|
+
args: [],
|
|
65
|
+
})
|
|
66
|
+
|
|
67
|
+
const surface = { host: { version: hostVersion }, profile: {}, tools: null, skills: null, presets: null, commands: null, tree: null }
|
|
68
|
+
try {
|
|
69
|
+
const profileContext = ctx.get('profileContext')
|
|
70
|
+
surface.profile = {
|
|
71
|
+
name: options.profile,
|
|
72
|
+
dir: profileContext?.dir ?? path.join(home, 'profiles', options.profile),
|
|
73
|
+
patchPath: profileContext?.patchPath ?? null,
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const tools = ctx.get('tools')
|
|
77
|
+
surface.tools = tools
|
|
78
|
+
? await read(() => tools.schemas().map((tool) => tool.name))
|
|
79
|
+
: { error: 'the tools service is not mounted' }
|
|
80
|
+
|
|
81
|
+
const skills = ctx.get('skills')
|
|
82
|
+
surface.skills = skills
|
|
83
|
+
? await read(async () => {
|
|
84
|
+
const all = await skills.list()
|
|
85
|
+
return { tower: all.filter((skill) => skill.provider === 'dsh-tower').map((skill) => skill.name), total: all.length }
|
|
86
|
+
})
|
|
87
|
+
: { error: 'the skills service is not mounted' }
|
|
88
|
+
|
|
89
|
+
const presets = ctx.get('agentPresets')
|
|
90
|
+
surface.presets = presets
|
|
91
|
+
? await read(async () => (await presets.list()).map((preset) => preset.id))
|
|
92
|
+
: { error: 'the agentPresets service is not mounted (profile has no preset registry)' }
|
|
93
|
+
|
|
94
|
+
const commands = ctx.get('commands')
|
|
95
|
+
surface.commands = commands
|
|
96
|
+
? await read(() => commands.list(undefined).map((command) => command.name))
|
|
97
|
+
: { error: 'the commands service is not mounted' }
|
|
98
|
+
|
|
99
|
+
const loader = ctx.get('loader')
|
|
100
|
+
surface.tree = loader
|
|
101
|
+
? await read(() => {
|
|
102
|
+
// `boot` mounts the profile behind one root include plugin; the composed
|
|
103
|
+
// rows live in that include's subtree. Stay correct if the root ever
|
|
104
|
+
// carries the tree directly.
|
|
105
|
+
const includeRow = loader.root.data.find((row) => row && row.name === 'cordis:include')
|
|
106
|
+
const tree = (includeRow && loader.store[includeRow.id]?.subtree) || loader
|
|
107
|
+
const top = tree.root.data.map((row) => ({
|
|
108
|
+
id: row?.id ?? null,
|
|
109
|
+
name: row?.name ?? null,
|
|
110
|
+
group: row?.group === true,
|
|
111
|
+
disabled: row?.disabled === true,
|
|
112
|
+
childIds: Array.isArray(row?.config)
|
|
113
|
+
? row.config.map((child) => (child && typeof child === 'object' && child.id !== undefined ? String(child.id) : null)).filter(Boolean)
|
|
114
|
+
: [],
|
|
115
|
+
}))
|
|
116
|
+
const states = Object.fromEntries(Object.entries(tree.store).map(([id, entry]) => [id, entry?.fiber?.state ?? null]))
|
|
117
|
+
return { top, states }
|
|
118
|
+
})
|
|
119
|
+
: { error: 'the loader service is not mounted' }
|
|
120
|
+
} finally {
|
|
121
|
+
await shutdown.shutdown(0)
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
fs.writeFileSync(options.out, `${JSON.stringify(surface, null, 2)}\n`)
|
|
125
|
+
process.exit(0)
|