dsh-office-kit 0.0.0-stage → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,363 @@
1
+ #!/usr/bin/env node
2
+ /* eslint-disable no-console */
3
+ /**
4
+ * dsh-office-kit postinstall.
5
+ *
6
+ * Ensures a compatible Python (3.10–3.13) with the Office helper libraries is
7
+ * available. On Windows a missing Python is downloaded from python.org,
8
+ * signature-checked, installed silently into the per-user standard directory,
9
+ * then isolated in a plugin-owned virtual environment.
10
+ *
11
+ * The plugin itself resolves the absolute interpreter path from its config
12
+ * file; the user PATH is updated only as a convenience and never replaces
13
+ * existing entries.
14
+ */
15
+
16
+ import { spawnSync } from 'node:child_process'
17
+ import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
18
+ import { homedir, tmpdir } from 'node:os'
19
+ import { dirname, join } from 'node:path'
20
+ import process from 'node:process'
21
+ import { fileURLToPath } from 'node:url'
22
+
23
+ const PACKAGE_ROOT = dirname(fileURLToPath(import.meta.url))
24
+ const IS_WINDOWS = process.platform === 'win32'
25
+ const PYTHON_VERSION = '3.12.10'
26
+ const PYTHON_INSTALL_DIR = join(process.env.LOCALAPPDATA || join(homedir(), 'AppData', 'Local'), 'Programs', 'Python', 'Python312')
27
+ const DATA_DIR = join(process.env.LOCALAPPDATA || join(homedir(), 'AppData', 'Local'), 'dsh-office-kit')
28
+ const CONFIG_DIR = join(process.env.APPDATA || join(homedir(), 'AppData', 'Roaming'), 'dsh-office-kit')
29
+ const VENV_DIR = join(DATA_DIR, 'venv')
30
+ const VENV_PYTHON = join(VENV_DIR, IS_WINDOWS ? 'Scripts/python.exe' : 'bin/python')
31
+ const CONFIG_FILE = join(CONFIG_DIR, 'config.json')
32
+ const REQUIREMENTS = join(PACKAGE_ROOT, 'python', 'requirements.txt')
33
+ const LOG_PREFIX = '[dsh-office-kit]'
34
+
35
+ function log(message) {
36
+ console.log(`${LOG_PREFIX} ${message}`)
37
+ }
38
+
39
+ function warn(message) {
40
+ console.warn(`${LOG_PREFIX} ${message}`)
41
+ }
42
+
43
+ function commandExists(command) {
44
+ const result = spawnSync(command, ['--version'], { stdio: 'ignore', shell: false })
45
+ return result.status === 0
46
+ }
47
+
48
+ function pythonInfo(executable, prefixArgs = []) {
49
+ const script = 'import json,sys;print(json.dumps({"version":list(sys.version_info[:3]),"executable":sys.executable,"prefix":sys.prefix}))'
50
+ const result = spawnSync(executable, [...prefixArgs, '-c', script], { encoding: 'utf8', timeout: 20000 })
51
+ if (result.status !== 0) {
52
+ return null
53
+ }
54
+ try {
55
+ const info = JSON.parse(result.stdout.trim())
56
+ const [major, minor] = info.version
57
+ if (major !== 3 || minor < 10 || minor > 13) {
58
+ return null
59
+ }
60
+ return info
61
+ }
62
+ catch {
63
+ return null
64
+ }
65
+ }
66
+
67
+ function listedInterpreters() {
68
+ if (!IS_WINDOWS) {
69
+ return []
70
+ }
71
+ try {
72
+ const result = spawnSync('py', ['-0p'], { encoding: 'utf8', timeout: 30000 })
73
+ if (result.status !== 0 || !result.stdout) {
74
+ return []
75
+ }
76
+ return String(result.stdout).split(/\r?\n/u).flatMap((line) => {
77
+ const match = /^\s*-V:\S+\s+([A-Z]:\\[^\r\n]*python\.exe)\s*$/iu.exec(line)
78
+ return match?.[1] ? [match[1]] : []
79
+ })
80
+ }
81
+ catch {
82
+ return []
83
+ }
84
+ }
85
+
86
+ function findCompatiblePython() {
87
+ if (process.env.DSH_OFFICE_PYTHON) {
88
+ const info = pythonInfo(process.env.DSH_OFFICE_PYTHON)
89
+ if (info) {
90
+ return { executable: process.env.DSH_OFFICE_PYTHON, args: [], info }
91
+ }
92
+ warn(`DSH_OFFICE_PYTHON is not a compatible Python 3.10–3.13: ${process.env.DSH_OFFICE_PYTHON}`)
93
+ }
94
+
95
+ const candidates = []
96
+ if (IS_WINDOWS) {
97
+ for (const version of ['3.13', '3.12', '3.11', '3.10']) {
98
+ candidates.push({ executable: 'py', args: [`-${version}`] })
99
+ }
100
+ candidates.push({ executable: 'py', args: ['-3'] })
101
+ for (const version of ['313', '312', '311', '310']) {
102
+ candidates.push({ executable: join(process.env.LOCALAPPDATA || '', 'Programs', 'Python', `Python${version}`, 'python.exe'), args: [] })
103
+ }
104
+ for (const executable of listedInterpreters()) {
105
+ candidates.push({ executable, args: [] })
106
+ }
107
+ }
108
+ candidates.push({ executable: 'python3', args: [] }, { executable: 'python', args: [] })
109
+
110
+ for (const candidate of candidates) {
111
+ if (!candidate.executable || (candidate.args.length === 0 && !existsSync(candidate.executable) && !commandExists(candidate.executable))) {
112
+ continue
113
+ }
114
+ const info = pythonInfo(candidate.executable, candidate.args)
115
+ if (info) {
116
+ return { executable: candidate.executable, args: candidate.args, info }
117
+ }
118
+ }
119
+
120
+ // A ready virtual environment still works even if its base interpreter was removed.
121
+ if (existsSync(VENV_PYTHON)) {
122
+ const info = pythonInfo(VENV_PYTHON)
123
+ if (info) {
124
+ return { executable: VENV_PYTHON, args: [], info }
125
+ }
126
+ }
127
+ return null
128
+ }
129
+
130
+ function download(url, target) {
131
+ log(`downloading ${url}`)
132
+ const script = 'Invoke-WebRequest -UseBasicParsing -Uri $env:DSH_URL -OutFile $env:DSH_TARGET'
133
+ const result = spawnSync('powershell', ['-NoProfile', '-NonInteractive', '-ExecutionPolicy', 'Bypass', '-Command', script], {
134
+ stdio: 'inherit',
135
+ env: { ...process.env, DSH_URL: url, DSH_TARGET: target },
136
+ timeout: 15 * 60 * 1000,
137
+ })
138
+ if (result.status !== 0 || !existsSync(target)) {
139
+ throw new Error(`download failed with status ${result.status}`)
140
+ }
141
+ }
142
+
143
+ function verifySignature(file) {
144
+ const script = [
145
+ '$sig = Get-AuthenticodeSignature -FilePath $env:DSH_FILE',
146
+ 'if ($sig.Status -ne "Valid") { Write-Output "status=$($sig.Status)"; exit 1 }',
147
+ 'if ($sig.SignerCertificate.Subject -notmatch "Python Software Foundation") { Write-Output "signer=$($sig.SignerCertificate.Subject)"; exit 2 }',
148
+ 'Write-Output "valid"',
149
+ ].join('; ')
150
+ const result = spawnSync('powershell', ['-NoProfile', '-NonInteractive', '-ExecutionPolicy', 'Bypass', '-Command', script], {
151
+ encoding: 'utf8',
152
+ env: { ...process.env, DSH_FILE: file },
153
+ timeout: 60000,
154
+ })
155
+ if (result.status !== 0) {
156
+ throw new Error(`Python installer signature verification failed: ${(result.stdout || result.stderr || '').trim()}`)
157
+ }
158
+ }
159
+
160
+ function installPython() {
161
+ if (!IS_WINDOWS) {
162
+ return null
163
+ }
164
+ // Unique per process: a stale installer from an interrupted run must not lock this download.
165
+ const installer = join(tmpdir(), `python-${PYTHON_VERSION}-amd64-${process.pid}-${Date.now()}.exe`)
166
+ const url = `https://www.python.org/ftp/python/${PYTHON_VERSION}/python-${PYTHON_VERSION}-amd64.exe`
167
+ download(url, installer)
168
+ verifySignature(installer)
169
+ log(`installing Python ${PYTHON_VERSION} into ${PYTHON_INSTALL_DIR}`)
170
+ const args = [
171
+ '/quiet',
172
+ 'InstallAllUsers=0',
173
+ `TargetDir=${PYTHON_INSTALL_DIR}`,
174
+ 'PrependPath=0',
175
+ 'Include_launcher=0',
176
+ 'Include_test=0',
177
+ 'Include_doc=0',
178
+ 'Include_pip=1',
179
+ 'SimpleInstall=1',
180
+ ]
181
+ const result = spawnSync(installer, args, { stdio: 'inherit', timeout: 15 * 60 * 1000 })
182
+ rmSync(installer, { force: true })
183
+ if (result.status !== 0) {
184
+ throw new Error(`Python installer exited with status ${result.status}`)
185
+ }
186
+ const target = join(PYTHON_INSTALL_DIR, 'python.exe')
187
+ if (!existsSync(target)) {
188
+ throw new Error(`Python installer finished but ${target} is missing`)
189
+ }
190
+ return target
191
+ }
192
+
193
+ function ensureVenv(base) {
194
+ const existing = pythonInfo(VENV_PYTHON)
195
+ if (existing) {
196
+ return existing
197
+ }
198
+ if (existsSync(VENV_DIR)) {
199
+ warn(`replacing incomplete virtual environment at ${VENV_DIR}`)
200
+ rmSync(VENV_DIR, { recursive: true, force: true })
201
+ }
202
+ mkdirSync(dirname(VENV_DIR), { recursive: true })
203
+ log(`creating virtual environment at ${VENV_DIR}`)
204
+ const result = spawnSync(base.executable, [...base.args, '-m', 'venv', VENV_DIR], { stdio: 'inherit', timeout: 300000 })
205
+ if (result.status !== 0 || !existsSync(VENV_PYTHON)) {
206
+ throw new Error(`could not create virtual environment (status ${result.status})`)
207
+ }
208
+ const info = pythonInfo(VENV_PYTHON)
209
+ if (!info) {
210
+ throw new Error('virtual environment interpreter is not executable')
211
+ }
212
+ return info
213
+ }
214
+
215
+ function installRequirements() {
216
+ if (!existsSync(REQUIREMENTS)) {
217
+ throw new Error(`requirements file missing: ${REQUIREMENTS}`)
218
+ }
219
+ log('installing Python packages (this may take a few minutes)')
220
+ const result = spawnSync(VENV_PYTHON, ['-m', 'pip', 'install', '--disable-pip-version-check', '--no-input', '--upgrade', '-r', REQUIREMENTS], {
221
+ stdio: 'inherit',
222
+ timeout: 30 * 60 * 1000,
223
+ })
224
+ if (result.status !== 0) {
225
+ throw new Error(`pip install exited with status ${result.status}`)
226
+ }
227
+ }
228
+
229
+ function verifyImports() {
230
+ const modules = ['docx', 'openpyxl', 'pptx', 'pypdf', 'reportlab', 'pdfplumber', 'pymupdf', 'PIL', 'latex2mathml', 'mathml2omml']
231
+ const script = `import importlib, json\nmissing = []\nfor name in ${JSON.stringify(modules)}:\n try:\n importlib.import_module(name)\n except Exception as exc:\n missing.append({"module": name, "error": f"{type(exc).__name__}: {exc}"})\nprint(json.dumps({"ok": not missing, "missing": missing}))`
232
+ const result = spawnSync(VENV_PYTHON, ['-c', script], { encoding: 'utf8', timeout: 120000 })
233
+ if (result.status !== 0) {
234
+ throw new Error(`import verification failed: ${(result.stderr || '').trim()}`)
235
+ }
236
+ const report = JSON.parse(result.stdout.trim())
237
+ if (!report.ok) {
238
+ throw new Error(`missing Python modules: ${JSON.stringify(report.missing)}`)
239
+ }
240
+ return report
241
+ }
242
+
243
+ function readConfig() {
244
+ if (!existsSync(CONFIG_FILE)) {
245
+ return {}
246
+ }
247
+ try {
248
+ return JSON.parse(readFileSync(CONFIG_FILE, 'utf8'))
249
+ }
250
+ catch {
251
+ return {}
252
+ }
253
+ }
254
+
255
+ function writeConfig(config) {
256
+ mkdirSync(dirname(CONFIG_FILE), { recursive: true })
257
+ writeFileSync(CONFIG_FILE, `${JSON.stringify(config, null, 2)}\n`, 'utf8')
258
+ }
259
+
260
+ function addToUserPath(entries) {
261
+ if (!IS_WINDOWS) {
262
+ return
263
+ }
264
+ const script = [
265
+ '$key = "HKCU:\\Environment"',
266
+ '$item = Get-Item $key',
267
+ '$current = [string]$item.GetValue("Path", "", "DoNotExpandEnvironmentNames")',
268
+ '$parts = @($current -split ";" | Where-Object { $_ -ne "" })',
269
+ '$changed = $false',
270
+ 'foreach ($entry in ($env:DSH_PATH_ENTRIES -split ";")) { if ($entry -and ($parts -notcontains $entry)) { $parts += $entry; $changed = $true } }',
271
+ 'if ($changed) { try { $item.SetValue("Path", ($parts -join ";"), "ExpandString"); Write-Output "updated" } catch { Write-Output "failed: $($_.Exception.Message)"; exit 3 } } else { Write-Output "unchanged" }',
272
+ ].join('; ')
273
+ const result = spawnSync('powershell', ['-NoProfile', '-NonInteractive', '-ExecutionPolicy', 'Bypass', '-Command', script], {
274
+ encoding: 'utf8',
275
+ env: { ...process.env, DSH_PATH_ENTRIES: entries.join(';') },
276
+ timeout: 60000,
277
+ })
278
+ if (result.status !== 0) {
279
+ warn(`could not update the user PATH: ${(result.stdout || result.stderr || '').trim()}`)
280
+ warn('The plugin still resolves the verified interpreter by absolute path; reopen the terminal only after a successful PATH update.')
281
+ }
282
+ else {
283
+ log(`user PATH ${(result.stdout || '').trim()}`)
284
+ }
285
+ }
286
+
287
+ function main() {
288
+ if (process.env.DSH_OFFICE_SKIP_POSTINSTALL === '1') {
289
+ log('skipped by DSH_OFFICE_SKIP_POSTINSTALL=1')
290
+ return
291
+ }
292
+ if (process.env.CI && process.env.DSH_OFFICE_POSTINSTALL !== '1') {
293
+ log('CI detected; skipping the Python environment setup. Set DSH_OFFICE_POSTINSTALL=1 to force it.')
294
+ return
295
+ }
296
+
297
+ const existingConfig = readConfig()
298
+ let base = findCompatiblePython()
299
+
300
+ if (!base) {
301
+ if (!IS_WINDOWS) {
302
+ warn('no compatible Python 3.10–3.13 was found. Install one and rerun `node postinstall.mjs`.')
303
+ return
304
+ }
305
+ try {
306
+ const installed = installPython()
307
+ const info = installed ? pythonInfo(installed) : null
308
+ base = installed && info ? { executable: installed, args: [], info } : null
309
+ }
310
+ catch (error) {
311
+ warn(`automatic Python installation failed: ${error.message}`)
312
+ warn('The plugin will report the missing runtime instead of pretending to work.')
313
+ process.exitCode = 1
314
+ return
315
+ }
316
+ }
317
+
318
+ if (!base) {
319
+ warn('no compatible Python interpreter could be established.')
320
+ process.exitCode = 1
321
+ return
322
+ }
323
+
324
+ log(`using Python ${base.info.version.join('.')} at ${base.info.executable}`)
325
+ try {
326
+ ensureVenv(base)
327
+ installRequirements()
328
+ const report = verifyImports()
329
+ const config = {
330
+ ...existingConfig,
331
+ python: VENV_PYTHON,
332
+ basePython: base.info.executable,
333
+ pythonVersion: base.info.version.join('.'),
334
+ pluginVersion: readPackageVersion(),
335
+ updatedAt: new Date().toISOString(),
336
+ imports: report,
337
+ }
338
+ writeConfig(config)
339
+ const pathEntries = IS_WINDOWS ? [dirname(base.info.executable), join(dirname(base.info.executable), 'Scripts')] : []
340
+ addToUserPath(pathEntries)
341
+ log('Python environment is ready.')
342
+ log(`interpreter: ${VENV_PYTHON}`)
343
+ log('Open a new terminal for PATH changes to apply to new shells.')
344
+ }
345
+ catch (error) {
346
+ warn(`Python environment setup failed: ${error.message}`)
347
+ warn('The plugin will report the missing runtime instead of pretending to work.')
348
+ process.exitCode = 1
349
+ }
350
+ }
351
+
352
+ function readPackageVersion() {
353
+ try {
354
+ return JSON.parse(readFileSync(join(PACKAGE_ROOT, 'package.json'), 'utf8')).version
355
+ }
356
+ catch {
357
+ return 'unknown'
358
+ }
359
+ }
360
+
361
+ const started = Date.now()
362
+ main()
363
+ log(`finished in ${((Date.now() - started) / 1000).toFixed(1)}s`)