@perrylink/dsh-github 0.6.1 → 0.6.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.
@@ -1,259 +1,259 @@
1
- // Local simulator for the composite action — explicitly isolated.
2
- //
3
- // The real action resolves every path from the runner environment:
4
- // DSH_HOME = ${{ runner.temp }}/dsh-home
5
- // DSH_PROFILE_DIR = ${{ runner.temp }}/dsh-home/profiles/headless
6
- // output dir = ${{ inputs.output-dir }} (default ${{ runner.temp }}/dsh-github)
7
- // A naive local copy inherits the developer's real environment instead: a
8
- // process-level (or machine-scope) DSH_HOME wins over everything, so the
9
- // headless profile, sessions, storages, and reports would be written straight
10
- // into the real dsh home — which is exactly what shuts local content down.
11
- //
12
- // This script therefore never reads DSH_HOME / DSH_PROFILE_DIR / RUNNER_TEMP
13
- // from the inherited environment. Every spawned step receives hardcoded
14
- // process-level values rooted in a fresh system-temp sandbox:
15
- // DSH_HOME = <tmp>/dsh-github-local-<n>/dsh-home
16
- // DSH_PROFILE_DIR = <tmp>/dsh-github-local-<n>/dsh-home/profiles/headless
17
- // RUNNER_TEMP = <tmp>/dsh-github-local-<n>
18
- // GITHUB_WORKSPACE = --workspace (default: this repository root)
19
- // INPUT_OUTPUT_DIR = <tmp>/dsh-github-local-<n>/output
20
- //
21
- // Steps replayed, mirroring action.yml: install → prepare (action-patch.mjs)
22
- // → headless run → post (action-post.mjs). Nothing outside the sandbox is
23
- // written. Run `node scripts/local-test.mjs --help` for the option list.
24
- import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
25
- import { spawnSync } from 'node:child_process'
26
- import { tmpdir } from 'node:os'
27
- import { dirname, delimiter, isAbsolute, join, resolve } from 'node:path'
28
- import { fileURLToPath } from 'node:url'
29
-
30
- const repoRoot = dirname(dirname(fileURLToPath(import.meta.url)))
31
-
32
- const USAGE = `dsh-github local action simulator
33
-
34
- Usage: node scripts/local-test.mjs [options]
35
-
36
- Options:
37
- --task <review|fix-ci|report> CI task to simulate (default: review)
38
- --pr <number> Pull request number (required for review/fix-ci)
39
- --owner-repo <owner/repo> Repository to review (required for review/fix-ci)
40
- --task-prompt <text> Complete replacement for the default task text
41
- --model <id> Model for the headless session (default: deepseek-v4-flash)
42
- --engine <static|model> Review engine (default: static)
43
- --check-name <name> Status-check name (default: dsh-github-review)
44
- --blocking <true|false> Fail on needs-changes verdict (default: true)
45
- --fail-on <error|warning> Lowest severity that flips the verdict (default: error)
46
- --label-filters <a,b> Comma-separated label filters
47
- --path-filters <glob,glob> Comma-separated path filters
48
- --max-diff-chars <n> Diff character cap (default: 8000)
49
- --post-comments <true|false> Post inline review comments (default: true)
50
- --post-check <true|false> Publish the status check (default: true)
51
- --request-timeout-ms <n> Per GitHub request timeout (default: 30000)
52
- --plugin-version <version|path> @perrylink/dsh-github version or local folder
53
- (default: latest)
54
- --workspace <dir> GITHUB_WORKSPACE for the sandbox (default: repo root)
55
- --skip-install Skip the npm install of dsh-github into the profile
56
- --skip-run Only install + generate the overlay/task (no headless run)
57
- --clean Delete the temp sandbox when finished
58
-
59
- Environment (passed through from your shell, never logged):
60
- DEEPSEEK_API_KEY Required (the action's deepseek-api-key input)
61
- GITHUB_TOKEN | DSH_GITHUB_TOKEN Optional (the action's github-token input)
62
-
63
- Isolation: DSH_HOME, DSH_PROFILE_DIR, RUNNER_TEMP, and the output directory are
64
- hardcoded under the system temp directory for every spawned step, overriding any
65
- inherited DSH_HOME (including machine-scope values). Your real dsh home is never
66
- read or written.`
67
-
68
- const args = process.argv.slice(2)
69
- const options = {
70
- task: 'review',
71
- pr: '',
72
- ownerRepo: '',
73
- taskPrompt: '',
74
- model: 'deepseek-v4-flash',
75
- engine: 'static',
76
- checkName: 'dsh-github-review',
77
- blocking: 'true',
78
- failOn: 'error',
79
- labelFilters: '',
80
- pathFilters: '',
81
- maxDiffChars: '8000',
82
- postComments: 'true',
83
- postCheck: 'true',
84
- requestTimeoutMs: '30000',
85
- pluginVersion: 'latest',
86
- workspace: repoRoot,
87
- skipInstall: false,
88
- skipRun: false,
89
- clean: false,
90
- }
91
- const valueOf = { task: 1, pr: 1, 'owner-repo': 1, 'task-prompt': 1, model: 1, engine: 1, 'check-name': 1, blocking: 1, 'fail-on': 1, 'label-filters': 1, 'path-filters': 1, 'max-diff-chars': 1, 'post-comments': 1, 'post-check': 1, 'request-timeout-ms': 1, 'plugin-version': 1, workspace: 1 }
92
- const keyOf = { task: 'task', pr: 'pr', 'owner-repo': 'ownerRepo', 'task-prompt': 'taskPrompt', model: 'model', engine: 'engine', 'check-name': 'checkName', blocking: 'blocking', 'fail-on': 'failOn', 'label-filters': 'labelFilters', 'path-filters': 'pathFilters', 'max-diff-chars': 'maxDiffChars', 'post-comments': 'postComments', 'post-check': 'postCheck', 'request-timeout-ms': 'requestTimeoutMs', 'plugin-version': 'pluginVersion', workspace: 'workspace' }
93
- for (let i = 0; i < args.length; i++) {
94
- const arg = args[i]
95
- if (arg === '--help' || arg === '-h') { console.log(USAGE); process.exit(0) }
96
- if (arg === '--skip-install') { options.skipInstall = true; continue }
97
- if (arg === '--skip-run') { options.skipRun = true; continue }
98
- if (arg === '--clean') { options.clean = true; continue }
99
- const eq = arg.indexOf('=')
100
- const name = eq === -1 ? arg.slice(2) : arg.slice(2, eq)
101
- if (name in valueOf) {
102
- const inline = eq === -1 ? '' : arg.slice(eq + 1)
103
- const value = inline !== '' ? inline : args[++i] ?? ''
104
- options[keyOf[name]] = value
105
- continue
106
- }
107
- if (arg.startsWith('-')) {
108
- console.error(`dsh-github local-test: unknown option ${arg}`)
109
- console.error('Run `node scripts/local-test.mjs --help` for the option list.')
110
- process.exit(2)
111
- }
112
- }
113
-
114
- // Sandbox — hardcoded under the system temp directory, derived from nothing
115
- // that the developer's environment could point at their real home.
116
- const sandboxRoot = mkdtempSync(join(tmpdir(), 'dsh-github-local-'))
117
- const dshHome = join(sandboxRoot, 'dsh-home')
118
- const profileDir = join(dshHome, 'profiles', 'headless')
119
- const outputDir = join(sandboxRoot, 'output')
120
- const workspace = isAbsolute(options.workspace) ? options.workspace : resolve(options.workspace)
121
-
122
- console.log('dsh-github local action simulator — isolated sandbox')
123
- console.log(` sandbox: ${sandboxRoot}`)
124
- console.log(` DSH_HOME: ${dshHome}`)
125
- console.log(` DSH_PROFILE_DIR:${profileDir}`)
126
- console.log(` output dir: ${outputDir}`)
127
- console.log(` workspace: ${workspace}`)
128
- console.log('The real dsh home and any inherited DSH_HOME are never read or written.')
129
-
130
- /** Run one shell command line, inheriting the caller's environment plus explicit overrides. */
131
- function run(commandLine, { env = {}, allowFail = false, capture = false } = {}) {
132
- const result = spawnSync(commandLine, {
133
- shell: true,
134
- cwd: repoRoot,
135
- env: { ...process.env, ...env },
136
- stdio: capture ? ['ignore', 'pipe', 'pipe'] : 'inherit',
137
- encoding: 'utf8',
138
- maxBuffer: 64 * 1024 * 1024,
139
- })
140
- const status = typeof result.status === 'number' ? result.status : 1
141
- if (!allowFail && status !== 0) {
142
- console.error(`dsh-github local-test: command failed (exit ${status}): ${commandLine}`)
143
- process.exit(status)
144
- }
145
- return { status, stdout: result.stdout ?? '', stderr: result.stderr ?? '' }
146
- }
147
-
148
- /** Locate an executable (with a .cmd/.exe/.bat shim on Windows) on PATH. */
149
- function findOnPath(name) {
150
- const entries = (process.env.PATH ?? '').split(delimiter)
151
- const extensions = process.platform === 'win32' ? ['cmd', 'exe', 'bat', ''] : ['']
152
- for (const entry of entries) {
153
- for (const extension of extensions) {
154
- const candidate = join(entry, extension === '' ? name : `${name}.${extension}`)
155
- if (candidate !== name && existsSync(candidate)) return candidate
156
- }
157
- }
158
- return ''
159
- }
160
- const q = (value) => JSON.stringify(value)
161
-
162
- // ---- Step 1: install the profile (action.yml "Install dsh and dsh-github") ----
163
- mkdirSync(profileDir, { recursive: true })
164
- writeFileSync(join(profileDir, 'package.json'), '{"private": true}\n')
165
- const installEnv = { DSH_HOME: dshHome, DSH_PROFILE_DIR: profileDir }
166
- if (options.skipInstall) {
167
- console.log('skipping npm install (--skip-install): the overlay row @perrylink/dsh-github must resolve from the profile node_modules')
168
- } else {
169
- console.log(`installing @perrylink/dsh-github@${options.pluginVersion} into the sandboxed profile …`)
170
- const target = existsSync(options.pluginVersion) || options.pluginVersion.includes('/') || options.pluginVersion.includes('\\')
171
- ? resolve(options.pluginVersion)
172
- : `@perrylink/dsh-github@${options.pluginVersion}`
173
- run(`npm install --prefix ${q(profileDir)} --legacy-peer-deps --package-lock=false --no-save --no-audit --no-fund ${q(target)}`, { env: installEnv })
174
- }
175
-
176
- // ---- Step 2: prepare (action.yml "Generate the dsh profile overlay and task") ----
177
- mkdirSync(outputDir, { recursive: true })
178
- const inputs = {
179
- INPUT_TASK: options.task,
180
- INPUT_PR: options.pr,
181
- INPUT_OWNER_REPO: options.ownerRepo,
182
- INPUT_MODEL: options.model,
183
- INPUT_ENGINE: options.engine,
184
- INPUT_CHECK_NAME: options.checkName,
185
- INPUT_BLOCKING: options.blocking,
186
- INPUT_FAIL_ON: options.failOn,
187
- INPUT_LABEL_FILTERS: options.labelFilters,
188
- INPUT_PATH_FILTERS: options.pathFilters,
189
- INPUT_MAX_DIFF_CHARS: options.maxDiffChars,
190
- INPUT_POST_COMMENTS: options.postComments,
191
- INPUT_POST_CHECK: options.postCheck,
192
- INPUT_REQUEST_TIMEOUT_MS: options.requestTimeoutMs,
193
- INPUT_TASK_PROMPT: options.taskPrompt,
194
- INPUT_OUTPUT_DIR: outputDir,
195
- }
196
- run(`node ${q(join(repoRoot, 'scripts', 'action-patch.mjs'))}`, {
197
- env: { RUNNER_TEMP: sandboxRoot, GITHUB_WORKSPACE: workspace, ...inputs },
198
- })
199
- console.log(`overlay and task written: ${join(outputDir, 'dsh-github-ci.cordis.yml')}`)
200
-
201
- if (options.skipRun) {
202
- console.log('stopping after prepare (--skip-run); the sandbox is kept for inspection.')
203
- console.log(`sandbox: ${sandboxRoot}`)
204
- process.exit(0)
205
- }
206
-
207
- // ---- Step 3: headless run (action.yml "Run dsh headless") ----
208
- if ((process.env.DEEPSEEK_API_KEY ?? '').trim() === '') {
209
- console.error('dsh-github local-test: DEEPSEEK_API_KEY is required (the action\'s deepseek-api-key input). Set it in your shell, not in the profile.')
210
- console.error(`sandbox kept for inspection: ${sandboxRoot}`)
211
- process.exit(2)
212
- }
213
- if (findOnPath('dsh') === '') {
214
- console.error('dsh-github local-test: the dsh CLI was not found on PATH. Install it first: npm install --global @deepseek-ai/dsh')
215
- console.error(`sandbox kept for inspection: ${sandboxRoot}`)
216
- process.exit(2)
217
- }
218
- const githubToken = process.env.DSH_GITHUB_TOKEN ?? process.env.GITHUB_TOKEN ?? ''
219
- if (githubToken === '' && options.postComments === 'true') {
220
- console.log('note: no DSH_GITHUB_TOKEN / GITHUB_TOKEN in the environment — posting comments and the status check will fail per operation (static dry inspections still work).')
221
- }
222
- const taskText = readFileSync(join(outputDir, 'task.txt'), 'utf8')
223
- const overlayPath = join(outputDir, 'dsh-github-ci.cordis.yml')
224
- const runEnv = {
225
- DSH_HOME: dshHome,
226
- DEEPSEEK_API_KEY: process.env.DEEPSEEK_API_KEY,
227
- DSH_GITHUB_CI_DRIVER: '1',
228
- DSH_GITHUB_CI_OUTPUT_DIR: outputDir,
229
- DSH_TELEMETRY_DISABLED: '1',
230
- }
231
- if (githubToken !== '') runEnv.DSH_GITHUB_TOKEN = githubToken
232
- console.log('running dsh headless in the sandboxed home …')
233
- const headless = run(`dsh --profile headless --patch ${q(overlayPath)} ${q(taskText)}`, { env: runEnv, allowFail: true, capture: true })
234
- writeFileSync(join(outputDir, 'dsh-github-stdout.log'), headless.stdout)
235
- writeFileSync(join(outputDir, 'dsh-github-stderr.log'), headless.stderr)
236
- writeFileSync(join(outputDir, 'dsh-github-exit.txt'), `${headless.status}\n`)
237
- console.log(headless.stdout)
238
- if (headless.stderr.trim() !== '') console.log(headless.stderr)
239
-
240
- // ---- Step 4: post (action.yml "Publish outputs and enforce the gate") ----
241
- const post = run(`node ${q(join(repoRoot, 'scripts', 'action-post.mjs'))}`, {
242
- env: {
243
- RUNNER_TEMP: sandboxRoot,
244
- GITHUB_WORKSPACE: workspace,
245
- INPUT_OUTPUT_DIR: outputDir,
246
- INPUT_BLOCKING: options.blocking,
247
- GITHUB_OUTPUT: join(outputDir, 'github-output.env'),
248
- },
249
- allowFail: true,
250
- })
251
- console.log(`\nsandbox kept at: ${sandboxRoot}`)
252
- console.log(` result: ${join(outputDir, 'dsh-github-ci-result.json')}`)
253
- console.log(` summary: ${join(outputDir, 'dsh-github-ci-summary.md')}`)
254
- console.log(` logs: ${join(outputDir, 'dsh-github-stdout.log')} / dsh-github-stderr.log`)
255
- if (options.clean) {
256
- rmSync(sandboxRoot, { recursive: true, force: true })
257
- console.log('sandbox removed (--clean).')
258
- }
259
- process.exit(post.status)
1
+ // Local simulator for the composite action — explicitly isolated.
2
+ //
3
+ // The real action resolves every path from the runner environment:
4
+ // DSH_HOME = ${{ runner.temp }}/dsh-home
5
+ // DSH_PROFILE_DIR = ${{ runner.temp }}/dsh-home/profiles/headless
6
+ // output dir = ${{ inputs.output-dir }} (default ${{ runner.temp }}/dsh-github)
7
+ // A naive local copy inherits the developer's real environment instead: a
8
+ // process-level (or machine-scope) DSH_HOME wins over everything, so the
9
+ // headless profile, sessions, storages, and reports would be written straight
10
+ // into the real dsh home — which is exactly what shuts local content down.
11
+ //
12
+ // This script therefore never reads DSH_HOME / DSH_PROFILE_DIR / RUNNER_TEMP
13
+ // from the inherited environment. Every spawned step receives hardcoded
14
+ // process-level values rooted in a fresh system-temp sandbox:
15
+ // DSH_HOME = <tmp>/dsh-github-local-<n>/dsh-home
16
+ // DSH_PROFILE_DIR = <tmp>/dsh-github-local-<n>/dsh-home/profiles/headless
17
+ // RUNNER_TEMP = <tmp>/dsh-github-local-<n>
18
+ // GITHUB_WORKSPACE = --workspace (default: this repository root)
19
+ // INPUT_OUTPUT_DIR = <tmp>/dsh-github-local-<n>/output
20
+ //
21
+ // Steps replayed, mirroring action.yml: install → prepare (action-patch.mjs)
22
+ // → headless run → post (action-post.mjs). Nothing outside the sandbox is
23
+ // written. Run `node scripts/local-test.mjs --help` for the option list.
24
+ import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
25
+ import { spawnSync } from 'node:child_process'
26
+ import { tmpdir } from 'node:os'
27
+ import { dirname, delimiter, isAbsolute, join, resolve } from 'node:path'
28
+ import { fileURLToPath } from 'node:url'
29
+
30
+ const repoRoot = dirname(dirname(fileURLToPath(import.meta.url)))
31
+
32
+ const USAGE = `dsh-github local action simulator
33
+
34
+ Usage: node scripts/local-test.mjs [options]
35
+
36
+ Options:
37
+ --task <review|fix-ci|report> CI task to simulate (default: review)
38
+ --pr <number> Pull request number (required for review/fix-ci)
39
+ --owner-repo <owner/repo> Repository to review (required for review/fix-ci)
40
+ --task-prompt <text> Complete replacement for the default task text
41
+ --model <id> Model for the headless session (default: deepseek-v4-flash)
42
+ --engine <static|model> Review engine (default: static)
43
+ --check-name <name> Status-check name (default: dsh-github-review)
44
+ --blocking <true|false> Fail on needs-changes verdict (default: true)
45
+ --fail-on <error|warning> Lowest severity that flips the verdict (default: error)
46
+ --label-filters <a,b> Comma-separated label filters
47
+ --path-filters <glob,glob> Comma-separated path filters
48
+ --max-diff-chars <n> Diff character cap (default: 8000)
49
+ --post-comments <true|false> Post inline review comments (default: true)
50
+ --post-check <true|false> Publish the status check (default: true)
51
+ --request-timeout-ms <n> Per GitHub request timeout (default: 30000)
52
+ --plugin-version <version|path> @perrylink/dsh-github version or local folder
53
+ (default: latest)
54
+ --workspace <dir> GITHUB_WORKSPACE for the sandbox (default: repo root)
55
+ --skip-install Skip the npm install of dsh-github into the profile
56
+ --skip-run Only install + generate the overlay/task (no headless run)
57
+ --clean Delete the temp sandbox when finished
58
+
59
+ Environment (passed through from your shell, never logged):
60
+ DEEPSEEK_API_KEY Required (the action's deepseek-api-key input)
61
+ GITHUB_TOKEN | DSH_GITHUB_TOKEN Optional (the action's github-token input)
62
+
63
+ Isolation: DSH_HOME, DSH_PROFILE_DIR, RUNNER_TEMP, and the output directory are
64
+ hardcoded under the system temp directory for every spawned step, overriding any
65
+ inherited DSH_HOME (including machine-scope values). Your real dsh home is never
66
+ read or written.`
67
+
68
+ const args = process.argv.slice(2)
69
+ const options = {
70
+ task: 'review',
71
+ pr: '',
72
+ ownerRepo: '',
73
+ taskPrompt: '',
74
+ model: 'deepseek-v4-flash',
75
+ engine: 'static',
76
+ checkName: 'dsh-github-review',
77
+ blocking: 'true',
78
+ failOn: 'error',
79
+ labelFilters: '',
80
+ pathFilters: '',
81
+ maxDiffChars: '8000',
82
+ postComments: 'true',
83
+ postCheck: 'true',
84
+ requestTimeoutMs: '30000',
85
+ pluginVersion: 'latest',
86
+ workspace: repoRoot,
87
+ skipInstall: false,
88
+ skipRun: false,
89
+ clean: false,
90
+ }
91
+ const valueOf = { task: 1, pr: 1, 'owner-repo': 1, 'task-prompt': 1, model: 1, engine: 1, 'check-name': 1, blocking: 1, 'fail-on': 1, 'label-filters': 1, 'path-filters': 1, 'max-diff-chars': 1, 'post-comments': 1, 'post-check': 1, 'request-timeout-ms': 1, 'plugin-version': 1, workspace: 1 }
92
+ const keyOf = { task: 'task', pr: 'pr', 'owner-repo': 'ownerRepo', 'task-prompt': 'taskPrompt', model: 'model', engine: 'engine', 'check-name': 'checkName', blocking: 'blocking', 'fail-on': 'failOn', 'label-filters': 'labelFilters', 'path-filters': 'pathFilters', 'max-diff-chars': 'maxDiffChars', 'post-comments': 'postComments', 'post-check': 'postCheck', 'request-timeout-ms': 'requestTimeoutMs', 'plugin-version': 'pluginVersion', workspace: 'workspace' }
93
+ for (let i = 0; i < args.length; i++) {
94
+ const arg = args[i]
95
+ if (arg === '--help' || arg === '-h') { console.log(USAGE); process.exit(0) }
96
+ if (arg === '--skip-install') { options.skipInstall = true; continue }
97
+ if (arg === '--skip-run') { options.skipRun = true; continue }
98
+ if (arg === '--clean') { options.clean = true; continue }
99
+ const eq = arg.indexOf('=')
100
+ const name = eq === -1 ? arg.slice(2) : arg.slice(2, eq)
101
+ if (name in valueOf) {
102
+ const inline = eq === -1 ? '' : arg.slice(eq + 1)
103
+ const value = inline !== '' ? inline : args[++i] ?? ''
104
+ options[keyOf[name]] = value
105
+ continue
106
+ }
107
+ if (arg.startsWith('-')) {
108
+ console.error(`dsh-github local-test: unknown option ${arg}`)
109
+ console.error('Run `node scripts/local-test.mjs --help` for the option list.')
110
+ process.exit(2)
111
+ }
112
+ }
113
+
114
+ // Sandbox — hardcoded under the system temp directory, derived from nothing
115
+ // that the developer's environment could point at their real home.
116
+ const sandboxRoot = mkdtempSync(join(tmpdir(), 'dsh-github-local-'))
117
+ const dshHome = join(sandboxRoot, 'dsh-home')
118
+ const profileDir = join(dshHome, 'profiles', 'headless')
119
+ const outputDir = join(sandboxRoot, 'output')
120
+ const workspace = isAbsolute(options.workspace) ? options.workspace : resolve(options.workspace)
121
+
122
+ console.log('dsh-github local action simulator — isolated sandbox')
123
+ console.log(` sandbox: ${sandboxRoot}`)
124
+ console.log(` DSH_HOME: ${dshHome}`)
125
+ console.log(` DSH_PROFILE_DIR:${profileDir}`)
126
+ console.log(` output dir: ${outputDir}`)
127
+ console.log(` workspace: ${workspace}`)
128
+ console.log('The real dsh home and any inherited DSH_HOME are never read or written.')
129
+
130
+ /** Run one shell command line, inheriting the caller's environment plus explicit overrides. */
131
+ function run(commandLine, { env = {}, allowFail = false, capture = false } = {}) {
132
+ const result = spawnSync(commandLine, {
133
+ shell: true,
134
+ cwd: repoRoot,
135
+ env: { ...process.env, ...env },
136
+ stdio: capture ? ['ignore', 'pipe', 'pipe'] : 'inherit',
137
+ encoding: 'utf8',
138
+ maxBuffer: 64 * 1024 * 1024,
139
+ })
140
+ const status = typeof result.status === 'number' ? result.status : 1
141
+ if (!allowFail && status !== 0) {
142
+ console.error(`dsh-github local-test: command failed (exit ${status}): ${commandLine}`)
143
+ process.exit(status)
144
+ }
145
+ return { status, stdout: result.stdout ?? '', stderr: result.stderr ?? '' }
146
+ }
147
+
148
+ /** Locate an executable (with a .cmd/.exe/.bat shim on Windows) on PATH. */
149
+ function findOnPath(name) {
150
+ const entries = (process.env.PATH ?? '').split(delimiter)
151
+ const extensions = process.platform === 'win32' ? ['cmd', 'exe', 'bat', ''] : ['']
152
+ for (const entry of entries) {
153
+ for (const extension of extensions) {
154
+ const candidate = join(entry, extension === '' ? name : `${name}.${extension}`)
155
+ if (candidate !== name && existsSync(candidate)) return candidate
156
+ }
157
+ }
158
+ return ''
159
+ }
160
+ const q = (value) => JSON.stringify(value)
161
+
162
+ // ---- Step 1: install the profile (action.yml "Install dsh and dsh-github") ----
163
+ mkdirSync(profileDir, { recursive: true })
164
+ writeFileSync(join(profileDir, 'package.json'), '{"private": true}\n')
165
+ const installEnv = { DSH_HOME: dshHome, DSH_PROFILE_DIR: profileDir }
166
+ if (options.skipInstall) {
167
+ console.log('skipping npm install (--skip-install): the overlay row @perrylink/dsh-github must resolve from the profile node_modules')
168
+ } else {
169
+ console.log(`installing @perrylink/dsh-github@${options.pluginVersion} into the sandboxed profile …`)
170
+ const target = existsSync(options.pluginVersion) || options.pluginVersion.includes('/') || options.pluginVersion.includes('\\')
171
+ ? resolve(options.pluginVersion)
172
+ : `@perrylink/dsh-github@${options.pluginVersion}`
173
+ run(`npm install --prefix ${q(profileDir)} --legacy-peer-deps --package-lock=false --no-save --no-audit --no-fund ${q(target)}`, { env: installEnv })
174
+ }
175
+
176
+ // ---- Step 2: prepare (action.yml "Generate the dsh profile overlay and task") ----
177
+ mkdirSync(outputDir, { recursive: true })
178
+ const inputs = {
179
+ INPUT_TASK: options.task,
180
+ INPUT_PR: options.pr,
181
+ INPUT_OWNER_REPO: options.ownerRepo,
182
+ INPUT_MODEL: options.model,
183
+ INPUT_ENGINE: options.engine,
184
+ INPUT_CHECK_NAME: options.checkName,
185
+ INPUT_BLOCKING: options.blocking,
186
+ INPUT_FAIL_ON: options.failOn,
187
+ INPUT_LABEL_FILTERS: options.labelFilters,
188
+ INPUT_PATH_FILTERS: options.pathFilters,
189
+ INPUT_MAX_DIFF_CHARS: options.maxDiffChars,
190
+ INPUT_POST_COMMENTS: options.postComments,
191
+ INPUT_POST_CHECK: options.postCheck,
192
+ INPUT_REQUEST_TIMEOUT_MS: options.requestTimeoutMs,
193
+ INPUT_TASK_PROMPT: options.taskPrompt,
194
+ INPUT_OUTPUT_DIR: outputDir,
195
+ }
196
+ run(`node ${q(join(repoRoot, 'scripts', 'action-patch.mjs'))}`, {
197
+ env: { RUNNER_TEMP: sandboxRoot, GITHUB_WORKSPACE: workspace, ...inputs },
198
+ })
199
+ console.log(`overlay and task written: ${join(outputDir, 'dsh-github-ci.cordis.yml')}`)
200
+
201
+ if (options.skipRun) {
202
+ console.log('stopping after prepare (--skip-run); the sandbox is kept for inspection.')
203
+ console.log(`sandbox: ${sandboxRoot}`)
204
+ process.exit(0)
205
+ }
206
+
207
+ // ---- Step 3: headless run (action.yml "Run dsh headless") ----
208
+ if ((process.env.DEEPSEEK_API_KEY ?? '').trim() === '') {
209
+ console.error('dsh-github local-test: DEEPSEEK_API_KEY is required (the action\'s deepseek-api-key input). Set it in your shell, not in the profile.')
210
+ console.error(`sandbox kept for inspection: ${sandboxRoot}`)
211
+ process.exit(2)
212
+ }
213
+ if (findOnPath('dsh') === '') {
214
+ console.error('dsh-github local-test: the dsh CLI was not found on PATH. Install it first: npm install --global @deepseek-ai/dsh')
215
+ console.error(`sandbox kept for inspection: ${sandboxRoot}`)
216
+ process.exit(2)
217
+ }
218
+ const githubToken = process.env.DSH_GITHUB_TOKEN ?? process.env.GITHUB_TOKEN ?? ''
219
+ if (githubToken === '' && options.postComments === 'true') {
220
+ console.log('note: no DSH_GITHUB_TOKEN / GITHUB_TOKEN in the environment — posting comments and the status check will fail per operation (static dry inspections still work).')
221
+ }
222
+ const taskText = readFileSync(join(outputDir, 'task.txt'), 'utf8')
223
+ const overlayPath = join(outputDir, 'dsh-github-ci.cordis.yml')
224
+ const runEnv = {
225
+ DSH_HOME: dshHome,
226
+ DEEPSEEK_API_KEY: process.env.DEEPSEEK_API_KEY,
227
+ DSH_GITHUB_CI_DRIVER: '1',
228
+ DSH_GITHUB_CI_OUTPUT_DIR: outputDir,
229
+ DSH_TELEMETRY_DISABLED: '1',
230
+ }
231
+ if (githubToken !== '') runEnv.DSH_GITHUB_TOKEN = githubToken
232
+ console.log('running dsh headless in the sandboxed home …')
233
+ const headless = run(`dsh --profile headless --patch ${q(overlayPath)} ${q(taskText)}`, { env: runEnv, allowFail: true, capture: true })
234
+ writeFileSync(join(outputDir, 'dsh-github-stdout.log'), headless.stdout)
235
+ writeFileSync(join(outputDir, 'dsh-github-stderr.log'), headless.stderr)
236
+ writeFileSync(join(outputDir, 'dsh-github-exit.txt'), `${headless.status}\n`)
237
+ console.log(headless.stdout)
238
+ if (headless.stderr.trim() !== '') console.log(headless.stderr)
239
+
240
+ // ---- Step 4: post (action.yml "Publish outputs and enforce the gate") ----
241
+ const post = run(`node ${q(join(repoRoot, 'scripts', 'action-post.mjs'))}`, {
242
+ env: {
243
+ RUNNER_TEMP: sandboxRoot,
244
+ GITHUB_WORKSPACE: workspace,
245
+ INPUT_OUTPUT_DIR: outputDir,
246
+ INPUT_BLOCKING: options.blocking,
247
+ GITHUB_OUTPUT: join(outputDir, 'github-output.env'),
248
+ },
249
+ allowFail: true,
250
+ })
251
+ console.log(`\nsandbox kept at: ${sandboxRoot}`)
252
+ console.log(` result: ${join(outputDir, 'dsh-github-ci-result.json')}`)
253
+ console.log(` summary: ${join(outputDir, 'dsh-github-ci-summary.md')}`)
254
+ console.log(` logs: ${join(outputDir, 'dsh-github-stdout.log')} / dsh-github-stderr.log`)
255
+ if (options.clean) {
256
+ rmSync(sandboxRoot, { recursive: true, force: true })
257
+ console.log('sandbox removed (--clean).')
258
+ }
259
+ process.exit(post.status)