@xmoon76/dsh-pi-tui 0.1.2 → 0.1.3

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.
@@ -0,0 +1,325 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * @xmoon76/dsh-pi-tui/scripts/tarball-smoke — verify a packed
4
+ * `@xmoon76/dsh-pi-tui` tarball end to end, so a fresh checkout can never
5
+ * publish a broken or leaky artifact:
6
+ *
7
+ * 1. structure — every `exports` file exists, config + cordis.patch.yml +
8
+ * repair scripts are included, nothing else leaks in (no test fixtures,
9
+ * no backups, no nested tarballs, no absolute paths);
10
+ * 2. content — no workspace absolute paths anywhere, and the bundle does
11
+ * NOT import `@xmoon76/pi-tui` externally (it is bundled);
12
+ * 3. parse — every `.mjs` passes `node --check`; every `.d.mts` parses via
13
+ * the TypeScript compiler when available;
14
+ * 4. install — the tarball installs standalone (`npm install --omit=dev`
15
+ * in a fresh temp project), both `exports` entries import, and
16
+ * `@xmoon76/pi-tui` is NOT resolvable from the installed package;
17
+ * 5. repair CLI — `--help` works and a read-only `--scan` over fixture
18
+ * sessions reports the damaged one and touches nothing.
19
+ *
20
+ * Usage: node scripts/tarball-smoke.mjs [path-to-tgz]
21
+ * The tarball path defaults to the newest `xmoon76-dsh-pi-tui-*.tgz` in
22
+ * the current directory (the `pnpm pack` / `npm pack` output location;
23
+ * npm/pnpm strip the `@` scope from tarball filenames).
24
+ *
25
+ * Env: TARBALL_SMOKE_SKIP_INSTALL=1 skips the npm install step (offline
26
+ * dev runs); TARBALL_SMOKE_KEEP=1 keeps the temp dirs for inspection.
27
+ * @module tarball-smoke
28
+ */
29
+
30
+ import { spawnSync } from 'node:child_process'
31
+ import {
32
+ existsSync,
33
+ mkdirSync,
34
+ mkdtempSync,
35
+ readdirSync,
36
+ readFileSync,
37
+ realpathSync,
38
+ rmSync,
39
+ statSync,
40
+ symlinkSync,
41
+ writeFileSync,
42
+ } from 'node:fs'
43
+ import { tmpdir } from 'node:os'
44
+ import { basename, dirname, join, resolve } from 'node:path'
45
+ import { fileURLToPath } from 'node:url'
46
+ import { createRequire } from 'node:module'
47
+ import { zstdCompressSync } from 'node:zlib'
48
+
49
+ const SCRIPT_DIR = dirname(fileURLToPath(import.meta.url))
50
+ /** The dsh-pi-tui package root (the workspace checkout). */
51
+ const PACKAGE_ROOT = join(SCRIPT_DIR, '..')
52
+
53
+ const failures = []
54
+ const checks = []
55
+
56
+ /** Record a passing check or a hard failure (failures exit non-zero at the end). */
57
+ function check(name, ok, detail = '') {
58
+ checks.push(`${ok ? 'ok ' : 'FAIL'} ${name}${detail === '' ? '' : ` — ${detail}`}`)
59
+ if (!ok) failures.push(`${name}${detail === '' ? '' : `: ${detail}`}`)
60
+ }
61
+
62
+ function run(command, args, options = {}) {
63
+ return spawnSync(command, args, { encoding: 'utf8', ...options })
64
+ }
65
+
66
+ /** Resolve the tarball: explicit arg, else the newest matching tgz in the
67
+ * current directory or its parent (pnpm workspace packs may land either
68
+ * place depending on how the lifecycle was invoked). */
69
+ function resolveTarball(explicit) {
70
+ if (explicit !== undefined) {
71
+ // Absolutize BEFORE any subprocess uses it: `npm install` below runs
72
+ // with cwd=probeDir, so a caller-relative path (e.g. the CI artifact
73
+ // download `tarball/xmoon76-*.tgz`) would resolve against the temp
74
+ // probe and fail with ENOENT.
75
+ const absolute = resolve(explicit)
76
+ if (!existsSync(absolute)) throw new Error(`tarball not found: ${explicit}`)
77
+ return absolute
78
+ }
79
+ // npm/pnpm strip the `@` scope from tarball filenames, so the glob is
80
+ // `xmoon76-dsh-pi-tui-*.tgz` even though the package is @xmoon76/dsh-pi-tui.
81
+ const candidates = [process.cwd(), join(process.cwd(), '..')]
82
+ .flatMap(dir => {
83
+ try {
84
+ return readdirSync(dir).map(name => join(dir, name))
85
+ } catch {
86
+ return []
87
+ }
88
+ })
89
+ .filter(path => /xmoon76-dsh-pi-tui-.*\.tgz$/.test(path))
90
+ .sort((a, b) => statSync(b).mtimeMs - statSync(a).mtimeMs)
91
+ if (candidates.length === 0) throw new Error(`no xmoon76-dsh-pi-tui-*.tgz in the current directory (${process.cwd()}) or its parent (${join(process.cwd(), '..')}); pass the tarball path`)
92
+ return candidates[0]
93
+ }
94
+
95
+ /** Extract an npm tarball; returns the `package/` directory. */
96
+ function extract(tarball, workDir) {
97
+ const result = run('tar', ['-xzf', tarball, '-C', workDir])
98
+ if (result.status !== 0) throw new Error(`tar extraction failed: ${result.stderr}`)
99
+ const extracted = join(workDir, 'package')
100
+ if (!existsSync(extracted)) throw new Error('tarball has no package/ root (not an npm tarball?)')
101
+ return extracted
102
+ }
103
+
104
+ /** Walk a directory tree, returning relative file paths. */
105
+ function walkFiles(root) {
106
+ const out = []
107
+ const walk = (dir, prefix) => {
108
+ for (const entry of readdirSync(dir, { withFileTypes: true })) {
109
+ const rel = prefix === '' ? entry.name : `${prefix}/${entry.name}`
110
+ if (entry.isDirectory()) walk(join(dir, entry.name), rel)
111
+ else out.push(rel)
112
+ }
113
+ }
114
+ walk(root, '')
115
+ return out
116
+ }
117
+
118
+ /** Load the TypeScript compiler for parse-only validation, when available. */
119
+ function loadTypescript() {
120
+ try {
121
+ return createRequire(join(PACKAGE_ROOT, 'package.json'))('typescript')
122
+ } catch {
123
+ return undefined
124
+ }
125
+ }
126
+
127
+ /** Parse-only check of a .d.mts file: no resolution, syntax errors only. */
128
+ function dtsParses(ts, file) {
129
+ const text = readFileSync(file, 'utf8')
130
+ const source = ts.createSourceFile(file, text, ts.ScriptTarget.ES2022, true, ts.ScriptKind.TS)
131
+ return source.parseDiagnostics.length === 0
132
+ ? { ok: true }
133
+ : { ok: false, detail: source.parseDiagnostics[0].messageText }
134
+ }
135
+
136
+ function main() {
137
+ const tarball = resolveTarball(process.argv[2])
138
+ const keep = process.env.TARBALL_SMOKE_KEEP === '1'
139
+ const skipInstall = process.env.TARBALL_SMOKE_SKIP_INSTALL === '1'
140
+ const workDir = mkdtempSync(join(tmpdir(), 'tarball-smoke-'))
141
+ const probeDir = join(workDir, 'probe')
142
+ try {
143
+ check('tarball exists', existsSync(tarball), basename(tarball))
144
+ const extracted = extract(tarball, workDir)
145
+ const pkg = JSON.parse(readFileSync(join(extracted, 'package.json'), 'utf8'))
146
+ check('package name', pkg.name === '@xmoon76/dsh-pi-tui', pkg.name)
147
+ check('published package is not private', pkg.private !== true, `private=${String(pkg.private)}`)
148
+
149
+ // --- structure ---
150
+ const files = walkFiles(extracted)
151
+ const has = (path) => files.includes(path)
152
+ check('exports entry dist/index.mjs', has('dist/index.mjs'))
153
+ check('exports entry dist/startup.mjs', has('dist/startup.mjs'))
154
+ check('types dist/index.d.mts', has('dist/index.d.mts'))
155
+ check('types dist/startup.d.mts', has('dist/startup.d.mts'))
156
+ check('cordis.patch.yml included', has('cordis.patch.yml'))
157
+ check('config/ included', files.some(name => name.startsWith('config/')))
158
+ check('repair-session.mjs included', has('scripts/repair-session.mjs'))
159
+ check('repair-core.mjs included', has('scripts/repair-core.mjs'))
160
+ check('README included', has('README.md'))
161
+ const leaks = files.filter(name => name.includes('test/')
162
+ || name.endsWith('.bak')
163
+ || name.endsWith('.tgz')
164
+ || /^\//.test(name)
165
+ || name.includes('..'))
166
+ check('no test fixtures / backups / nested tarballs / absolute paths', leaks.length === 0,
167
+ leaks.length === 0 ? '' : leaks.join(', '))
168
+
169
+ // --- content ---
170
+ const textFiles = files
171
+ .filter(name => /\.(mjs|ts|yml|yaml|json|md)$/.test(name))
172
+ .map(name => join(extracted, name))
173
+ // Leak detection is DYNAMIC, not tied to this machine: the runtime-
174
+ // resolved repo/package/dist roots (realpath) catch THIS checkout's
175
+ // absolute paths on any machine, and the generic patterns catch the
176
+ // common absolute-path shapes (Unix home, macOS Users, CI workspaces,
177
+ // Windows drives) everywhere. The smoke script itself is exempt from
178
+ // the generic patterns: its source deliberately assembles the old
179
+ // /home/xmoon/ marker at runtime (the literal would otherwise trip the
180
+ // check on its own file). The dynamic-root checks still apply to it.
181
+ const repoRoot = realpathSync(join(SCRIPT_DIR, '..', '..', '..'))
182
+ const packageRoot = realpathSync(PACKAGE_ROOT)
183
+ // The bundle dist may be absent (a smoke run on a raw checkout); the
184
+ // structure checks below already fail loudly for that, so never crash
185
+ // here — realpathSync on a missing dir would throw ENOENT and turn the
186
+ // failure into a bare `tarball-smoke: ...` stderr line.
187
+ const distRoot = join(PACKAGE_ROOT, 'dist')
188
+ const leakRoots = [...new Set([
189
+ repoRoot,
190
+ packageRoot,
191
+ ...(existsSync(distRoot) ? [realpathSync(distRoot)] : []),
192
+ ])]
193
+ const absolutePathPatterns = [
194
+ /\/home\/[^/"']+/,
195
+ /\/Users\/[^/"']+/,
196
+ /\/home\/runner\/work\//,
197
+ /[A-Za-z]:\\(?:[^\\"']*\\)+/,
198
+ ]
199
+ const workspaceLeaks = textFiles.filter(file => {
200
+ const text = readFileSync(file, 'utf8')
201
+ if (leakRoots.some(root => root.length > 1 && text.includes(root))) return true
202
+ if (basename(file) === 'tarball-smoke.mjs') return false
203
+ return absolutePathPatterns.some(pattern => pattern.test(text))
204
+ })
205
+ check('no workspace absolute paths in packaged files', workspaceLeaks.length === 0,
206
+ workspaceLeaks.length === 0 ? '' : workspaceLeaks.join(', '))
207
+ const dist = files.filter(name => name.startsWith('dist/') && name.endsWith('.mjs'))
208
+ .map(name => readFileSync(join(extracted, name), 'utf8'))
209
+ const externalFork = dist.filter(text => /from\s*["']@xmoon76\/pi-tui["']/.test(text) || /require\(\s*["']@xmoon76\/pi-tui["']/.test(text))
210
+ check('bundle does not import @xmoon76/pi-tui externally', externalFork.length === 0,
211
+ externalFork.length === 0 ? '' : `${externalFork.length} file(s) still import the fork`)
212
+
213
+ // --- parse ---
214
+ const mjsFiles = files.filter(name => /\.mjs$/.test(name)).map(name => join(extracted, name))
215
+ const badMjs = mjsFiles.filter(file => run(process.execPath, ['--check', file]).status !== 0)
216
+ check('every .mjs parses (node --check)', badMjs.length === 0,
217
+ badMjs.length === 0 ? '' : badMjs.join(', '))
218
+ const ts = loadTypescript()
219
+ const dtsFiles = files.filter(name => /\.d\.mts$/.test(name)).map(name => join(extracted, name))
220
+ if (ts !== undefined) {
221
+ const badDts = dtsFiles.filter(file => !dtsParses(ts, file).ok)
222
+ check('every .d.mts parses (typescript)', badDts.length === 0,
223
+ badDts.length === 0 ? '' : badDts.join(', '))
224
+ } else {
225
+ checks.push(`skip .d.mts parse — typescript unavailable`)
226
+ }
227
+
228
+ // --- install + runtime ---
229
+ if (skipInstall) {
230
+ checks.push('skip npm install (TARBALL_SMOKE_SKIP_INSTALL=1)')
231
+ } else {
232
+ mkdirSync(probeDir)
233
+ writeFileSync(join(probeDir, 'package.json'), JSON.stringify({ name: 'tarball-smoke-probe', private: true }))
234
+ const install = run('npm', [
235
+ 'install', '--omit=dev', '--no-save',
236
+ '--cache', join(workDir, 'npm-cache'),
237
+ tarball,
238
+ ], { cwd: probeDir, timeout: 180_000 })
239
+ check('tarball installs standalone (npm install --omit=dev)', install.status === 0,
240
+ install.status === 0 ? '' : (install.stderr.split('\n').slice(-3).join(' ')))
241
+ if (install.status === 0) {
242
+ const installed = join(probeDir, 'node_modules', '@xmoon76', 'dsh-pi-tui')
243
+ check('installed package contains dist', existsSync(join(installed, 'dist', 'index.mjs')))
244
+ const importRun = run(process.execPath, ['--input-type=module', '-e',
245
+ "Promise.all([import('@xmoon76/dsh-pi-tui'), import('@xmoon76/dsh-pi-tui/startup')])"
246
+ + ".then(m => console.log('imports-ok')).catch(e => { console.error(e.message); process.exit(1) })",
247
+ ], { cwd: probeDir })
248
+ check('both exports entries import', importRun.status === 0 && importRun.stdout.includes('imports-ok'),
249
+ importRun.status === 0 ? '' : importRun.stderr.slice(0, 200))
250
+ const forkRun = run(process.execPath, ['--input-type=module', '-e',
251
+ "import('@xmoon76/pi-tui').then(() => process.exit(0)).catch(() => process.exit(3))",
252
+ ], { cwd: probeDir })
253
+ check('@xmoon76/pi-tui is not resolvable from the installed package (self-contained)',
254
+ forkRun.status === 3, `status=${forkRun.status}`)
255
+ }
256
+
257
+ // --- repair CLI from the installed package ---
258
+ const cli = join(probeDir, 'node_modules', '@xmoon76', 'dsh-pi-tui', 'scripts', 'repair-session.mjs')
259
+ if (existsSync(cli)) {
260
+ const help = run(process.execPath, [cli, '--help'])
261
+ check('installed repair CLI shows help', help.status === 0 && help.stdout.includes('usage:'),
262
+ help.status === 0 ? '' : help.stderr.slice(0, 200))
263
+ // A read-only scan over fixtures: healthy + torn sessions. The CLI
264
+ // needs a dsh stub whose node_modules resolves @deepseek-ai/dsh-session
265
+ // (npm installed the peer deps into the probe).
266
+ const dshSession = join(probeDir, 'node_modules', '@deepseek-ai', 'dsh-session')
267
+ if (existsSync(dshSession)) {
268
+ const home = join(workDir, 'home')
269
+ const stub = join(workDir, 'dsh-stub')
270
+ const torn = join(home, 'sessions', 'proj', 'torn-session')
271
+ const healthy = join(home, 'sessions', 'proj', 'ok-session')
272
+ mkdirSync(join(stub, 'node_modules', '@deepseek-ai'), { recursive: true })
273
+ writeFileSync(join(stub, 'package.json'), JSON.stringify({ name: '@deepseek-ai/dsh', version: '0.0.0' }))
274
+ symlinkSync(dshSession, join(stub, 'node_modules', '@deepseek-ai', 'dsh-session'), 'dir')
275
+ mkdirSync(torn, { recursive: true })
276
+ mkdirSync(healthy, { recursive: true })
277
+ const HEADER = JSON.stringify({
278
+ type: 'session', version: 0, id: 'torn-session', createdAt: 1,
279
+ cwd: '/work', delegationDepth: 0, agentPreset: 'standard',
280
+ })
281
+ const text = [HEADER,
282
+ JSON.stringify({ type: 'permission/preset', seq: 0, time: 1, data: { preset: 'workspace-write' } }),
283
+ JSON.stringify({ type: 'user/message', seq: 1, time: 2, data: { content: [{ type: 'text', text: 'hi' }], source: { kind: 'user' } } }),
284
+ ].join('\n')
285
+ writeFileSync(join(healthy, 'session.jsonl'), `${text}\n`)
286
+ // A torn zstd artifact: complete frames + a truncated frame tail.
287
+ const full = Buffer.concat([
288
+ zstdCompressSync(Buffer.from(`${HEADER}\n`, 'utf8')),
289
+ zstdCompressSync(Buffer.from(`${text.slice(HEADER.length + 1)}\n`, 'utf8')),
290
+ zstdCompressSync(Buffer.from('{"type":"user/message","seq":2,"time":3,"data":{"content":[{"type":"text","text":"lost"}],"source":{"kind":"user"}}}\n', 'utf8')),
291
+ ])
292
+ writeFileSync(join(torn, 'session.jsonl.zstd'), full.subarray(0, full.length - 9))
293
+ const scan = run(process.execPath, [cli, '--scan', '--dsh-dir', stub, '--dsh-home', home])
294
+ check('installed repair CLI --scan reports the torn session and ignores the healthy one',
295
+ scan.status === 1 && scan.stdout.includes('torn-session') && !scan.stdout.includes('ok-session'),
296
+ `status=${scan.status} stdout=${scan.stdout.trim().slice(0, 200)}`)
297
+ const before = readFileSync(join(torn, 'session.jsonl.zstd'))
298
+ const dry = run(process.execPath, [cli, 'torn-session', '--dsh-dir', stub, '--dsh-home', home])
299
+ const after = readFileSync(join(torn, 'session.jsonl.zstd'))
300
+ check('installed repair CLI dry run is read-only', dry.status === 1 && before.equals(after),
301
+ `status=${dry.status} ${dry.stdout.trim().slice(0, 120)}`)
302
+ } else {
303
+ checks.push('skip repair CLI scan — peer deps not installed in probe')
304
+ }
305
+ }
306
+ }
307
+ } finally {
308
+ if (!keep) rmSync(workDir, { recursive: true, force: true })
309
+ }
310
+
311
+ for (const line of checks) console.log(line)
312
+ if (failures.length > 0) {
313
+ console.error(`\ntarball smoke FAILED (${failures.length}):`)
314
+ for (const failure of failures) console.error(` - ${failure}`)
315
+ process.exit(1)
316
+ }
317
+ console.log('\ntarball smoke passed')
318
+ }
319
+
320
+ try {
321
+ await main()
322
+ } catch (error) {
323
+ console.error(`tarball-smoke: ${error instanceof Error ? error.message : String(error)}`)
324
+ process.exit(1)
325
+ }