@twinsearth/fang-dsh-plugin 0.0.0-stage → 3.3.4

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.
Files changed (144) hide show
  1. package/CLA.md +79 -0
  2. package/LICENSE-COMMERCIAL +78 -0
  3. package/LICENSE-CORE +661 -0
  4. package/LICENSE-SDK +21 -0
  5. package/LICENSES/cordis-MIT.txt +39 -0
  6. package/NOTICE +95 -0
  7. package/README.md +158 -2
  8. package/client.js +7287 -0
  9. package/cordis.patch.yml +163 -0
  10. package/defaults/umbrella-rules.yaml +113 -0
  11. package/docs/CONTRACT-INTEROP.md +77 -0
  12. package/docs/CRASH-SAFETY.md +69 -0
  13. package/docs/DECISION-RELAY.md +46 -0
  14. package/docs/DSH-ARCHITECTURE-MAP.md +73 -0
  15. package/docs/FORK-SEED.md +64 -0
  16. package/docs/MAIN-CITY-GATE.md +58 -0
  17. package/docs/OVERLAY-MASTER-SWITCH.md +35 -0
  18. package/docs/PACKAGE-RENAME-v3.3.3.md +40 -0
  19. package/docs/PM-DECISION-POLICY.md +61 -0
  20. package/docs/WORKFLOW-UI.md +86 -0
  21. package/icon.svg +8 -0
  22. package/locale/en.json +6 -0
  23. package/locale/zh.json +6 -0
  24. package/package.json +71 -4
  25. package/sdk/README.md +65 -0
  26. package/sdk/examples/status-badge/README.md +16 -0
  27. package/sdk/examples/status-badge/client.js +60 -0
  28. package/sdk/examples/status-badge/cordis.patch.yml +7 -0
  29. package/sdk/examples/status-badge/index.js +12 -0
  30. package/sdk/examples/status-badge/package.json +24 -0
  31. package/sdk/templates/plugin/README.md +36 -0
  32. package/sdk/templates/plugin/client.js +35 -0
  33. package/sdk/templates/plugin/cordis.patch.yml +8 -0
  34. package/sdk/templates/plugin/icon.svg +5 -0
  35. package/sdk/templates/plugin/index.js +23 -0
  36. package/sdk/templates/plugin/locale/en.json +6 -0
  37. package/sdk/templates/plugin/package.json +36 -0
  38. package/src/compose.mjs +67 -0
  39. package/src/contract/VENDOR.json +16 -0
  40. package/src/contract/client.js +93 -0
  41. package/src/contract/constants.js +94 -0
  42. package/src/contract/envelope.js +83 -0
  43. package/src/contract/index.js +20 -0
  44. package/src/contract/negotiate.js +106 -0
  45. package/src/contract/relay.js +223 -0
  46. package/src/contract/server.js +77 -0
  47. package/src/contract/transports.js +319 -0
  48. package/src/index.mjs +11 -0
  49. package/src/kernel/core.mjs +100 -0
  50. package/src/kernel/extensions.mjs +61 -0
  51. package/src/kernel/fsx.mjs +349 -0
  52. package/src/kernel/index.mjs +21 -0
  53. package/src/kernel/ledger.mjs +94 -0
  54. package/src/kernel/paths.mjs +182 -0
  55. package/src/kernel/role.mjs +68 -0
  56. package/src/kernel/rounds.mjs +172 -0
  57. package/src/kernel/schema.mjs +5 -0
  58. package/src/kernel/types.mjs +266 -0
  59. package/src/kernel/vendor/cosmokit.mjs +421 -0
  60. package/src/kernel/vendor/schemastery.mjs +638 -0
  61. package/src/lib/adapter-client.mjs +383 -0
  62. package/src/lib/adapter-protocol.mjs +243 -0
  63. package/src/lib/ai-secret-store.mjs +202 -0
  64. package/src/lib/boot-guard.mjs +186 -0
  65. package/src/lib/branch-store.mjs +84 -0
  66. package/src/lib/branch.mjs +74 -0
  67. package/src/lib/bridge-client.mjs +133 -0
  68. package/src/lib/broker-adapter.mjs +67 -0
  69. package/src/lib/broker.mjs +45 -0
  70. package/src/lib/business-producers.mjs +109 -0
  71. package/src/lib/command-cache.mjs +209 -0
  72. package/src/lib/compat.mjs +170 -0
  73. package/src/lib/composer-journal.mjs +128 -0
  74. package/src/lib/container/index.mjs +806 -0
  75. package/src/lib/decision-events.mjs +129 -0
  76. package/src/lib/decision-ingest-client.mjs +41 -0
  77. package/src/lib/decision-outbox.mjs +129 -0
  78. package/src/lib/decision-policy.mjs +52 -0
  79. package/src/lib/default-essence.mjs +61 -0
  80. package/src/lib/desktop-launch.mjs +138 -0
  81. package/src/lib/essence.mjs +1211 -0
  82. package/src/lib/fork-client.mjs +410 -0
  83. package/src/lib/fork-config-lock.mjs +166 -0
  84. package/src/lib/fork-seed.mjs +304 -0
  85. package/src/lib/fork-sessions.mjs +183 -0
  86. package/src/lib/fork-supervision-settings.mjs +58 -0
  87. package/src/lib/fork-supervisor.mjs +122 -0
  88. package/src/lib/fs-guard.mjs +28 -0
  89. package/src/lib/http-auth.mjs +162 -0
  90. package/src/lib/idle-audit.mjs +178 -0
  91. package/src/lib/interaction-poke.mjs +61 -0
  92. package/src/lib/main-gate.mjs +579 -0
  93. package/src/lib/mirror-etag.mjs +92 -0
  94. package/src/lib/mirror-official-source.mjs +93 -0
  95. package/src/lib/mirror.mjs +478 -0
  96. package/src/lib/mother-link.mjs +156 -0
  97. package/src/lib/mutex.mjs +31 -0
  98. package/src/lib/permissions.mjs +115 -0
  99. package/src/lib/pnpm-store.mjs +189 -0
  100. package/src/lib/preferences.mjs +212 -0
  101. package/src/lib/producer-contract.mjs +162 -0
  102. package/src/lib/reflow.mjs +41 -0
  103. package/src/lib/remote.mjs +408 -0
  104. package/src/lib/restart-contract.mjs +752 -0
  105. package/src/lib/roots.mjs +30 -0
  106. package/src/lib/rule-registry.mjs +1127 -0
  107. package/src/lib/runtime-capture.mjs +307 -0
  108. package/src/lib/semaphore.mjs +34 -0
  109. package/src/lib/session-bundle.mjs +138 -0
  110. package/src/lib/session-format.mjs +672 -0
  111. package/src/lib/sse-parser.mjs +102 -0
  112. package/src/lib/ssrf-guard.mjs +170 -0
  113. package/src/lib/status-store.mjs +257 -0
  114. package/src/lib/task-handle.mjs +622 -0
  115. package/src/lib/ui-actions.mjs +55 -0
  116. package/src/lib/umbrella.mjs +1661 -0
  117. package/src/lib/upgrade-coordinator.mjs +107 -0
  118. package/src/lib/upgrade-status.mjs +43 -0
  119. package/src/plugins/adapter.mjs +1468 -0
  120. package/src/plugins/approver.mjs +1210 -0
  121. package/src/plugins/auditor.mjs +330 -0
  122. package/src/plugins/buffer-keeper.mjs +522 -0
  123. package/src/plugins/decision-center.mjs +2581 -0
  124. package/src/plugins/forker.mjs +1610 -0
  125. package/src/plugins/healer.mjs +702 -0
  126. package/src/plugins/hot-reloader.mjs +274 -0
  127. package/src/plugins/imports.mjs +247 -0
  128. package/src/plugins/orchestrator.mjs +3938 -0
  129. package/src/plugins/restart-guard.mjs +1022 -0
  130. package/src/plugins/router.mjs +714 -0
  131. package/src/plugins/scorer.mjs +661 -0
  132. package/src/plugins/session-bridge.mjs +166 -0
  133. package/src/plugins/session-catalog.mjs +771 -0
  134. package/src/plugins/session-router.mjs +125 -0
  135. package/src/plugins/session-sync.mjs +171 -0
  136. package/src/plugins/umbrella.mjs +419 -0
  137. package/tools/boot-guard.mjs +95 -0
  138. package/tools/decision-ingest.mjs +53 -0
  139. package/tools/desktop-launch.mjs +71 -0
  140. package/tools/fangctl.mjs +408 -0
  141. package/tools/gate/main-city.mjs +204 -0
  142. package/tools/mcp/server.mjs +121 -0
  143. package/tools/mcp/tools.mjs +65 -0
  144. package/tools/recover-queue.mjs +133 -0
@@ -0,0 +1,579 @@
1
+ // SPDX-License-Identifier: AGPL-3.0-or-later
2
+ // Copyright (c) 2026 dsh-Fang contributors — see LICENSE-CORE.
3
+ //
4
+ // v3.3.0 主城门禁(main-city gate)
5
+ // ------------------------------------------------------------
6
+ // 目标:任何「进主城」的动作(打包、发布、安装、部署、新建 profile)都必须先过
7
+ // 同一套可复核的检查;拿不到证据时 fail-closed,而不是静默放行。
8
+ //
9
+ // 设计约束:
10
+ // * 纯本地、只读优先:默认不写 profile、不联网、不改产物。
11
+ // * 产物校验以 sha256/manifest 为准;profile 校验以「不越界 + 可解析 + 不覆盖」为准。
12
+ // * 崩溃循环 / 安全模式 / 审计链断裂 = blocker:系统级故障不能靠重试绕过。
13
+ // * 需要写盘的两个例外都显式开关:--repair 建 profile 备份;--create 新建 profile。
14
+ import fs from 'node:fs'
15
+ import path from 'node:path'
16
+ import crypto from 'node:crypto'
17
+
18
+ import { parseYamlSubset } from './rule-registry.mjs'
19
+ import { appendChained, verifyChain } from '../kernel/ledger.mjs'
20
+ import { scoreGateFindings } from '../plugins/scorer.mjs'
21
+ import { gateRepairPlan } from '../plugins/healer.mjs'
22
+ import { CRASH_LOOP_DEFAULTS, detectCrashLoop } from './restart-contract.mjs'
23
+
24
+ export const GATE_TARGETS = Object.freeze(['source', 'package', 'publish', 'install', 'deploy', 'workflows', 'profile'])
25
+ export const GATE_SEVERITIES = Object.freeze(['blocker', 'warning', 'info'])
26
+ export const DEFAULT_MIN_FREE_BYTES = 512 * 1024 * 1024
27
+ export const RESERVED_PROFILE_NAMES = Object.freeze(['desktop', 'plugin'])
28
+ export const PROFILE_NAME_RE = /^[a-z0-9][a-z0-9-]{0,62}$/
29
+
30
+ const REQUIRED_SOURCE_FILES = Object.freeze([
31
+ 'package.json',
32
+ 'client.js',
33
+ 'cordis.patch.yml',
34
+ 'src/index.mjs',
35
+ 'src/lib/boot-guard.mjs',
36
+ 'src/lib/fork-supervisor.mjs',
37
+ 'src/lib/restart-contract.mjs',
38
+ 'src/lib/main-gate.mjs',
39
+ 'src/plugins/restart-guard.mjs',
40
+ 'src/plugins/scorer.mjs',
41
+ 'src/plugins/approver.mjs',
42
+ 'src/plugins/auditor.mjs',
43
+ 'src/plugins/healer.mjs',
44
+ 'tools/gate/main-city.mjs',
45
+ 'tools/release/build.mjs',
46
+ 'tools/release/preflight.mjs',
47
+ 'docs/CRASH-SAFETY.md',
48
+ 'docs/MAIN-CITY-GATE.md',
49
+ 'docs/DSH-ARCHITECTURE-MAP.md',
50
+ 'docs/OVERLAY-MASTER-SWITCH.md',
51
+ 'docs/PACKAGE-RENAME-v3.3.3.md',
52
+ ])
53
+
54
+ const REQUIRED_ARTIFACT_FILES = Object.freeze([
55
+ 'package.json',
56
+ 'client.js',
57
+ 'cordis.patch.yml',
58
+ 'src/index.mjs',
59
+ 'src/lib/boot-guard.mjs',
60
+ 'src/lib/main-gate.mjs',
61
+ 'src/plugins/restart-guard.mjs',
62
+ 'tools/gate/main-city.mjs',
63
+ ])
64
+
65
+ const SECRET_PATTERNS = Object.freeze([
66
+ { id: 'aws-access-key', re: /AKIA[0-9A-Z]{16}/ },
67
+ { id: 'github-token', re: /gh[pousr]_[A-Za-z0-9]{20,}/ },
68
+ { id: 'openai-key', re: /sk-[A-Za-z0-9]{20,}/ },
69
+ { id: 'pem-private-key', re: /-----BEGIN (RSA |EC |OPENSSH )?PRIVATE KEY-----/ },
70
+ ])
71
+
72
+ const sha256 = (value) => crypto.createHash('sha256').update(value).digest('hex')
73
+ export const sha256File = (file) => sha256(fs.readFileSync(file))
74
+
75
+ function readJsonSafe(file) {
76
+ try { return JSON.parse(fs.readFileSync(file, 'utf8')) } catch { return null }
77
+ }
78
+
79
+ function isInside(parent, child) {
80
+ const p = path.resolve(String(parent ?? ''))
81
+ const c = path.resolve(String(child ?? ''))
82
+ const rel = path.relative(p, c)
83
+ return rel === '' || (!rel.startsWith('..') && !path.isAbsolute(rel))
84
+ }
85
+
86
+ function readTextSafe(file, fallback = '') {
87
+ try { return fs.readFileSync(file, 'utf8') } catch { return fallback }
88
+ }
89
+
90
+ function pidAlive(pid) {
91
+ if (!Number.isInteger(pid) || pid <= 0) return false
92
+ try { process.kill(pid, 0); return true } catch (error) { return error?.code === 'EPERM' }
93
+ }
94
+
95
+ function diskFree(dir) {
96
+ try {
97
+ const stat = fs.statfsSync(dir)
98
+ return Number(stat.bavail) * Number(stat.bsize)
99
+ } catch { return null }
100
+ }
101
+
102
+ function addCheck(checks, id, ok, severity, detail, repair = '') {
103
+ const level = ok ? 'info' : severity
104
+ checks.push({ id, ok: !!ok, severity: level, detail: String(detail ?? ''), ...(repair ? { repair } : {}) })
105
+ }
106
+
107
+ function fail(checks, id, detail, repair = '') {
108
+ addCheck(checks, id, false, 'blocker', detail, repair)
109
+ }
110
+
111
+ function warn(checks, id, detail, repair = '') {
112
+ addCheck(checks, id, false, 'warning', detail, repair)
113
+ }
114
+
115
+ function pass(checks, id, detail = '') {
116
+ addCheck(checks, id, true, 'info', detail)
117
+ }
118
+
119
+ function walkFiles(dir, rel = '', out = [], limit = 5000) {
120
+ if (out.length >= limit) return out
121
+ let entries = []
122
+ try { entries = fs.readdirSync(path.join(dir, rel), { withFileTypes: true }) } catch { return out }
123
+ for (const entry of entries) {
124
+ if (out.length >= limit) break
125
+ const child = rel ? path.join(rel, entry.name) : entry.name
126
+ if (entry.isDirectory()) walkFiles(dir, child, out, limit)
127
+ else if (entry.isFile()) out.push(child)
128
+ }
129
+ return out
130
+ }
131
+
132
+ /** 源码树:版本、文档、vendored 契约、关键防线文件、危险默认值。 */
133
+ export function checkSourceTree({ root = process.cwd(), version = '' } = {}) {
134
+ const checks = []
135
+ const base = path.resolve(root)
136
+ const pkg = readJsonSafe(path.join(base, 'package.json'))
137
+ const declared = String(pkg?.version ?? version ?? '').trim()
138
+ if (!/^\d+\.\d+\.\d+$/.test(declared)) fail(checks, 'source: semver', declared || '(missing)')
139
+ else pass(checks, 'source: semver', declared)
140
+ if (version && declared !== version) fail(checks, 'source: version matches requested', `${declared} != ${version}`)
141
+ else pass(checks, 'source: version matches requested', declared)
142
+
143
+ const kernel = readTextSafe(path.join(base, 'src/kernel/types.mjs'))
144
+ addCheck(checks, 'source: kernel FANG_VERSION', kernel.includes(`FANG_VERSION = '${declared}'`), 'blocker', declared)
145
+ addCheck(checks, 'source: CHANGELOG section', readTextSafe(path.join(base, 'docs/CHANGELOG.md')).includes(`## v${declared}`), 'blocker', `## v${declared}`)
146
+ addCheck(checks, 'source: AUDIT file', fs.existsSync(path.join(base, `docs/AUDIT-v${declared}.md`)), 'blocker', `docs/AUDIT-v${declared}.md`)
147
+
148
+ const missing = REQUIRED_SOURCE_FILES.filter((rel) => !fs.existsSync(path.join(base, rel)))
149
+ addCheck(checks, 'source: required guard files', missing.length === 0, 'blocker', missing.length ? `missing=${missing.join(',')}` : `${REQUIRED_SOURCE_FILES.length} files`, missing.length ? 'restore the missing guard files before packaging' : '')
150
+
151
+ const patch = parseYamlSubset(readTextSafe(path.join(base, 'cordis.patch.yml')))
152
+ addCheck(checks, 'source: bundle patch parses', patch.errors.length === 0, 'blocker', patch.errors[0] ?? 'ok')
153
+
154
+ const restart = readTextSafe(path.join(base, 'src/plugins/restart-guard.mjs'))
155
+ addCheck(checks, 'source: restart default observe', restart.includes("mode: 'observe'"), 'blocker', 'mode default must stay observe')
156
+ addCheck(checks, 'source: process restart disarmed by default', restart.includes('allowProcessRestart: false'), 'blocker', 'allowProcessRestart default must stay false')
157
+ addCheck(checks, 'source: crash-loop breaker present', restart.includes('crash-loop') && restart.includes('clearSafeMode'), 'blocker', 'restart-guard crash-loop breaker')
158
+ addCheck(checks, 'source: gate wired into build', readTextSafe(path.join(base, 'tools/release/build.mjs')).includes('main-city.mjs'), 'blocker', 'build must call the gate')
159
+ addCheck(checks, 'source: gate wired into publish', readTextSafe(path.join(base, 'tools/release/publish-npm.mjs')).includes('main-city.mjs'), 'blocker', 'publish must call the gate')
160
+ const workflows = checkWorkflows({ root: base })
161
+ checks.push(...workflows.checks)
162
+
163
+ const vendor = readJsonSafe(path.join(base, 'src/contract/VENDOR.json'))
164
+ if (!vendor) fail(checks, 'source: vendored contract manifest', 'src/contract/VENDOR.json missing', 're-sync contract from npm')
165
+ else {
166
+ const drifted = []
167
+ for (const [name, expected] of Object.entries(vendor.sha256 ?? {})) {
168
+ const file = path.join(base, 'src/contract', name)
169
+ try { if (sha256File(file) !== expected) drifted.push(name) } catch { drifted.push(name + ':missing') }
170
+ }
171
+ addCheck(checks, 'source: vendored contract bytes', drifted.length === 0, 'blocker', drifted.length ? `drift=${drifted.join(',')}` : `${Object.keys(vendor.sha256 ?? {}).length} files @ ${vendor.version}`)
172
+ }
173
+
174
+ const secretHits = []
175
+ for (const rel of walkFiles(base, 'src').slice(0, 2000)) {
176
+ const text = readTextSafe(path.join(base, rel))
177
+ for (const pattern of SECRET_PATTERNS) if (pattern.re.test(text)) secretHits.push(`${rel}:${pattern.id}`)
178
+ }
179
+ addCheck(checks, 'source: no hardcoded secrets', secretHits.length === 0, 'blocker', secretHits.length ? secretHits.slice(0, 5).join(',') : 'clean')
180
+
181
+ return { target: 'source', checks, version: declared }
182
+ }
183
+
184
+ /**
185
+ * v3.3.1:第七类目标 workflows —— CI/发布工作流本身的审计。
186
+ * 工作流是「谁能发布、能写什么」的执行入口;它被改坏时,源码门禁可能完全绕过。
187
+ */
188
+ export function checkWorkflows({ root = process.cwd() } = {}) {
189
+ const checks = []
190
+ const base = path.resolve(String(root))
191
+ const dir = path.join(base, '.github/workflows')
192
+ const required = ['ci.yml', 'release.yml']
193
+ const missing = required.filter((name) => !fs.existsSync(path.join(dir, name)))
194
+ addCheck(checks, 'workflows: required files', missing.length === 0, 'blocker', missing.length ? `missing=${missing.join(',')}` : 'ci.yml + release.yml', 'restore the CI/release workflows from the repository history')
195
+
196
+ const files = []
197
+ try {
198
+ for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
199
+ if (entry.isFile() && /\.ya?ml$/i.test(entry.name)) files.push(path.join(dir, entry.name))
200
+ }
201
+ } catch { /* missing dir already reported */ }
202
+ addCheck(checks, 'workflows: files discoverable', files.length >= required.length, 'blocker', `${files.length} files`)
203
+
204
+ for (const file of files) {
205
+ const name = path.basename(file)
206
+ const text = readTextSafe(file)
207
+ if (!text.trim()) { fail(checks, `workflows: ${name} not empty`, 'empty workflow'); continue }
208
+ addCheck(checks, `workflows: ${name} has on/jobs`, /^on:/m.test(text) && /^jobs:/m.test(text), 'blocker', name)
209
+ addCheck(checks, `workflows: ${name} no pull_request_target`, !/pull_request_target/.test(text), 'blocker', name, 'pull_request_target exposes secrets to fork PRs; use pull_request with explicit env passing')
210
+ addCheck(checks, `workflows: ${name} no write-all`, !/permissions:\s*write-all/.test(text), 'blocker', name, 'grant the narrowest permission per job')
211
+ addCheck(checks, `workflows: ${name} no secrets inherit`, !/secrets:\s*inherit/.test(text), 'blocker', name, 'map only the secrets a job needs')
212
+ const unsafeInterpolation = text.split(/\r?\n/).some((line) => /\$\{\{\s*github\.event\.pull_request\.(title|body|head\.ref)\s*\}\}/.test(line) && !/^\s*[A-Z][A-Z0-9_]*:\s*\$\{\{/.test(line) && !/^\s*#/.test(line))
213
+ addCheck(checks, `workflows: ${name} no untrusted title interpolation`, !unsafeInterpolation, 'blocker', name, 'pass PR fields through env:, never interpolate them into run:')
214
+ addCheck(checks, `workflows: ${name} has job timeouts`, /timeout-minutes:/m.test(text), 'warning', name, 'add timeout-minutes to every job so a stuck runner cannot hang the release')
215
+ }
216
+
217
+ const ci = readTextSafe(path.join(dir, 'ci.yml'))
218
+ addCheck(checks, 'workflows: ci runs main-city source gate', ci.includes('tools/gate/main-city.mjs') && ci.includes('--target'), 'blocker', 'ci.yml', 'add the main-city source/workflows gate before tests')
219
+ addCheck(checks, 'workflows: ci runs full test suite', /node --test/.test(ci), 'blocker', 'ci.yml')
220
+ addCheck(checks, 'workflows: ci matrix 22+24', /node:\s*\[?[^\n]*22/.test(ci) && /node:\s*\[?[^\n]*24/.test(ci), 'warning', 'ci.yml', 'test both supported Node versions')
221
+ const release = readTextSafe(path.join(dir, 'release.yml'))
222
+ addCheck(checks, 'workflows: release runs main-city gate', release.includes('tools/gate/main-city.mjs'), 'blocker', 'release.yml', 'add the source gate to the release guard job')
223
+ addCheck(checks, 'workflows: release builds via gated build.mjs', release.includes('tools/release/build.mjs'), 'blocker', 'release.yml', 'release must use build.mjs (it runs package/publish gates)')
224
+ addCheck(checks, 'workflows: release grants contents write only where needed', release.includes('permissions:') && !/permissions:\s*write-all/.test(release), 'warning', 'release.yml')
225
+ return { checks, files: files.map((file) => path.basename(file)) }
226
+ }
227
+
228
+ function checkStagedPackage({ packageDir, version }) {
229
+ const checks = []
230
+ const dir = path.resolve(String(packageDir ?? ''))
231
+ const pkg = readJsonSafe(path.join(dir, 'package.json'))
232
+ if (!pkg) { fail(checks, 'package: staged package.json', dir); return { checks, pkg: null } }
233
+ pass(checks, 'package: staged package.json', `${pkg.name ?? '?'}@${pkg.version ?? '?'}`)
234
+ addCheck(checks, 'package: name', pkg.name === '@twinsearth/fang-dsh-plugin', 'blocker', String(pkg.name))
235
+ addCheck(checks, 'package: version', !version || pkg.version === version, 'blocker', `${pkg.version}${version ? ' vs ' + version : ''}`)
236
+ addCheck(checks, 'package: not private', pkg.private !== true, 'blocker', String(pkg.private))
237
+ addCheck(checks, 'package: client half', pkg.dsh?.client?.platform === 'web', 'blocker', JSON.stringify(pkg.dsh?.client ?? null))
238
+ addCheck(checks, 'package: publish access public', pkg.publishConfig?.access === 'public' || pkg.access === 'public', 'blocker', String(pkg.publishConfig?.access ?? pkg.access ?? 'missing'), "add publishConfig.access=public so scoped npm publish does not 402")
239
+ const bundlePatch = String(pkg.dsh?.bundle?.patch ?? '').trim()
240
+ addCheck(checks, 'package: bundle manifest', bundlePatch.length > 0, 'blocker', bundlePatch || 'missing dsh.bundle.patch')
241
+ if (bundlePatch) addCheck(checks, 'package: bundle file', fs.existsSync(path.join(dir, bundlePatch)), 'blocker', bundlePatch)
242
+ const missing = REQUIRED_ARTIFACT_FILES.filter((rel) => !fs.existsSync(path.join(dir, rel)))
243
+ addCheck(checks, 'package: required artifact files', missing.length === 0, 'blocker', missing.length ? `missing=${missing.join(',')}` : `${REQUIRED_ARTIFACT_FILES.length} files`)
244
+ return { checks, pkg }
245
+ }
246
+
247
+ function checkTarball({ distDir, version, tarball, manifest }) {
248
+ const checks = []
249
+ const dist = path.resolve(String(distDir ?? 'dist'))
250
+ let doc = manifest ?? readJsonSafe(path.join(dist, 'manifest.json'))
251
+ if (!doc) { fail(checks, 'artifact: manifest', 'dist/manifest.json missing', 'run build first'); return { checks, manifest: null, tarballPath: null } }
252
+ pass(checks, 'artifact: manifest', `${doc.name}@${doc.version}`)
253
+ addCheck(checks, 'artifact: manifest name', doc.name === '@twinsearth/fang-dsh-plugin', 'blocker', String(doc.name))
254
+ addCheck(checks, 'artifact: manifest version', !version || doc.version === version, 'blocker', `${doc.version}${version ? ' vs ' + version : ''}`)
255
+ const tgz = tarball ? path.resolve(tarball) : path.join(dist, path.basename(String(doc.tarball ?? '')))
256
+ if (!fs.existsSync(tgz)) { fail(checks, 'artifact: tarball exists', tgz, 'rebuild the artifact'); return { checks, manifest: doc, tarballPath: tgz } }
257
+ const actual = sha256File(tgz)
258
+ addCheck(checks, 'artifact: tarball sha256', actual === doc.sha256, 'blocker', actual.slice(0, 16))
259
+ const sidecar = `${tgz}.sha256`
260
+ if (!fs.existsSync(sidecar)) warn(checks, 'artifact: sha256 sidecar', 'missing sidecar', 'rebuild writes the sidecar')
261
+ else {
262
+ const expected = readTextSafe(sidecar).trim().split(/\s+/)[0]
263
+ addCheck(checks, 'artifact: sha256 sidecar', expected === actual, 'blocker', expected ? expected.slice(0, 16) : 'empty')
264
+ }
265
+ const files = Array.isArray(doc.files) ? doc.files : []
266
+ addCheck(checks, 'artifact: file list present', files.length > 0, 'blocker', `${files.length} entries`)
267
+ const bad = files.filter((rel) => /^package\/(dist|node_modules|\.git|\.env)/.test(rel) || /\.pem$|npm-token|gh-master-token/i.test(rel))
268
+ addCheck(checks, 'artifact: no forbidden entries', bad.length === 0, 'blocker', bad.length ? bad.slice(0, 5).join(',') : 'clean')
269
+ const missing = REQUIRED_ARTIFACT_FILES.map((rel) => `package/${rel}`).filter((rel) => !files.includes(rel))
270
+ addCheck(checks, 'artifact: required files inside tarball', missing.length === 0, 'blocker', missing.length ? `missing=${missing.join(',')}` : `${REQUIRED_ARTIFACT_FILES.length} files`)
271
+ return { checks, manifest: doc, tarballPath: tgz }
272
+ }
273
+
274
+ function checkProfile({ homeDir, profile, profileDir }) {
275
+ const checks = []
276
+ const home = path.resolve(String(homeDir ?? ''))
277
+ const name = String(profile ?? path.basename(path.resolve(String(profileDir ?? '')))).trim()
278
+ const dir = path.resolve(String(profileDir ?? path.join(home, 'profiles', name)))
279
+ if (!home) fail(checks, 'profile: home required', home, 'pass --home')
280
+ addCheck(checks, 'profile: name format', PROFILE_NAME_RE.test(name), 'blocker', name)
281
+ addCheck(checks, 'profile: reserved name', !RESERVED_PROFILE_NAMES.includes(name), 'blocker', name)
282
+ addCheck(checks, 'profile: inside home/profiles', isInside(path.join(home, 'profiles'), dir), 'blocker', dir, 'refuse paths outside $DSH_HOME/profiles')
283
+ if (!fs.existsSync(dir)) {
284
+ warn(checks, 'profile: profile directory', `not created yet: ${dir}`, 'create it through the gate (--create)')
285
+ return { checks, dir, name, exists: false, pkg: null }
286
+ }
287
+ const pkg = readJsonSafe(path.join(dir, 'package.json'))
288
+ if (!pkg) fail(checks, 'profile: package.json', dir)
289
+ else {
290
+ pass(checks, 'profile: package.json', pkg.name ?? name)
291
+ const bundles = Array.isArray(pkg?.dsh?.profile?.bundles) ? pkg.dsh.profile.bundles.map(String) : null
292
+ if (!bundles) fail(checks, 'profile: bundles array', JSON.stringify(pkg?.dsh?.profile ?? null))
293
+ else {
294
+ const dup = bundles.filter((id, index) => bundles.indexOf(id) !== index)
295
+ addCheck(checks, 'profile: no duplicate bundles', dup.length === 0, 'blocker', dup.length ? dup.join(',') : `${bundles.length} bundles`)
296
+ }
297
+ }
298
+ const patchFile = path.join(dir, 'cordis.patch.yml')
299
+ if (!fs.existsSync(patchFile)) warn(checks, 'profile: patch file', 'cordis.patch.yml missing', 'profile may still boot; create an empty [] patch')
300
+ else {
301
+ const text = readTextSafe(patchFile)
302
+ // 空 patch 的合法形式:空文件、`[]`(DSH 模板常见)。
303
+ const empty = text.trim() === '' || text.trim() === '[]' || text.trim() === '{}'
304
+ if (empty) pass(checks, 'profile: patch parses', 'empty patch')
305
+ else {
306
+ const parsed = parseYamlSubset(text)
307
+ addCheck(checks, 'profile: patch parses', parsed.errors.length === 0, 'blocker', parsed.errors[0] ?? 'ok')
308
+ }
309
+ }
310
+ const backup = fs.existsSync(dir) && fs.readdirSync(dir).some((file) => /^package\.json\.bak-|^cordis\.patch\.yml\.bak-|^\.gate-backups$/.test(file))
311
+ addCheck(checks, 'profile: backup available', backup, 'warning', backup ? 'present' : 'none', 'run gate --repair to create a backup before changing this profile')
312
+ return { checks, dir, name, exists: true, pkg, backup }
313
+ }
314
+
315
+ function checkRuntimeGuards({ homeDir, profile }) {
316
+ const checks = []
317
+ const home = path.resolve(String(homeDir ?? ''))
318
+ const dataDir = path.join(home, 'fang', String(profile ?? ''))
319
+ const pidFile = path.join(dataDir, 'fang.pid')
320
+ const pidDoc = readJsonSafe(pidFile)
321
+ const live = pidDoc && pidAlive(Number(pidDoc.pid))
322
+ addCheck(checks, 'runtime: no live host on this profile', !live, 'blocker', live ? `pid=${pidDoc.pid} is alive` : 'no live fang.pid', 'fully exit the DSH client before install/deploy')
323
+ const safeFile = path.join(dataDir, 'restart/safe-mode.json')
324
+ const safe = readJsonSafe(safeFile)
325
+ addCheck(checks, 'runtime: restart safe-mode clear', !(safe && safe.active === true), 'blocker', safe?.active === true ? `${safe.reason ?? 'active'} at ${safe.at ?? '?'}` : 'clear', 'clear it only after investigating crashes: fangctl restart-safe clear --by <you>')
326
+ const crashFile = path.join(dataDir, 'restart/runtime-crash.jsonl')
327
+ const crash = fs.existsSync(crashFile) ? detectCrashLoop(readTextSafe(crashFile), { windowMs: CRASH_LOOP_DEFAULTS.windowMs, threshold: CRASH_LOOP_DEFAULTS.threshold }) : { active: false, count: 0 }
328
+ addCheck(checks, 'runtime: no active crash loop', crash.active !== true, 'blocker', crash.active ? `${crash.count} crashes in ${crash.windowMs}ms` : `count=${crash.count}`, 'inspect runtime-crash.jsonl and restart-guard safe mode; do not redeploy blindly')
329
+ const auditFile = path.join(dataDir, 'audit.jsonl')
330
+ if (fs.existsSync(auditFile)) {
331
+ const chain = verifyChain(auditFile)
332
+ addCheck(checks, 'audit: hash chain verifies', chain.ok === true, 'blocker', chain.ok ? `records=${chain.count}` : `${chain.reason}@${chain.badIndex}`, 'do not append/deploy on a broken audit chain; restore the ledger from backup and investigate')
333
+ } else {
334
+ pass(checks, 'audit: hash chain verifies', 'no ledger yet (first install)')
335
+ }
336
+ const ledger = path.join(home, 'audit.jsonl')
337
+ if (fs.existsSync(ledger) && ledger !== auditFile) {
338
+ const chain = verifyChain(ledger)
339
+ addCheck(checks, 'audit: home ledger verifies', chain.ok === true, 'blocker', chain.ok ? `records=${chain.count}` : `${chain.reason}@${chain.badIndex}`)
340
+ }
341
+ return { checks, dataDir }
342
+ }
343
+
344
+ function checkDiskSpace({ homeDir, minFreeBytes = DEFAULT_MIN_FREE_BYTES }) {
345
+ const checks = []
346
+ const target = path.resolve(String(homeDir ?? ''))
347
+ let dir = target
348
+ while (dir && !fs.existsSync(dir)) dir = path.dirname(dir)
349
+ const free = diskFree(dir)
350
+ if (free === null) warn(checks, 'disk: free space available', 'statfs unavailable', 'verify free space manually')
351
+ else addCheck(checks, 'disk: free space available', free >= minFreeBytes, 'blocker', `${(free / 1024 / 1024).toFixed(0)} MiB free (need ${(minFreeBytes / 1024 / 1024).toFixed(0)} MiB)`, 'free disk space before creating profiles / installing artifacts')
352
+ return { checks, free }
353
+ }
354
+
355
+ function checkPublishMetadata({ version, tag, npmLatest, allowExisting }) {
356
+ const checks = []
357
+ addCheck(checks, 'publish: version present', /^\d+\.\d+\.\d+$/.test(String(version ?? '')), 'blocker', String(version ?? ''))
358
+ if (tag !== undefined && String(tag).trim() !== '') {
359
+ addCheck(checks, 'publish: tag matches version', String(tag).trim() === `v${version}`, 'blocker', `${tag} vs v${version}`, 'create the annotated tag from the same version')
360
+ } else {
361
+ warn(checks, 'publish: tag provided', 'no tag supplied; CI tag/version check is skipped', 'pass --tag v<version> in release workflows')
362
+ }
363
+ if (npmLatest !== undefined && npmLatest !== null && String(npmLatest).trim() !== '') {
364
+ const existing = String(npmLatest).trim()
365
+ const equal = existing === String(version)
366
+ addCheck(checks, 'publish: registry version not already used', allowExisting === true || !equal, 'blocker', equal ? `npm latest already ${existing}` : `npm latest ${existing}`, 'bump the version; never overwrite an already published version')
367
+ } else {
368
+ pass(checks, 'publish: registry version not already used', 'not checked (no --npm-latest)')
369
+ }
370
+ return { checks }
371
+ }
372
+
373
+ function aggregate(target, checks, extra = {}) {
374
+ const blockers = checks.filter((item) => item.severity === 'blocker' && item.ok === false)
375
+ const warnings = checks.filter((item) => item.severity === 'warning' && item.ok === false)
376
+ const infos = checks.filter((item) => item.severity === 'info' && item.ok === true)
377
+ const score = scoreGateFindings(checks.map((item) => ({ severity: item.ok ? 'info' : item.severity })))
378
+ // 修复建议由 healer 统一给出(未知 blocker 也回落到 fail-closed 提示)。
379
+ const healerHints = new Map(gateRepairPlan(checks.map((item) => ({ ...item }))).map((item) => [item.id, item.hint]))
380
+ const repair = checks
381
+ .filter((item) => item.ok === false)
382
+ .map((item) => ({ id: item.id, hint: item.repair || healerHints.get(item.id) || 'inspect the gate report; do not bypass' }))
383
+ return {
384
+ ok: blockers.length === 0,
385
+ target,
386
+ at: new Date().toISOString(),
387
+ summary: { blockers: blockers.length, warnings: warnings.length, infos: infos.length, checks: checks.length },
388
+ score,
389
+ blockers: blockers.map((item) => ({ id: item.id, detail: item.detail })),
390
+ warnings: warnings.map((item) => ({ id: item.id, detail: item.detail })),
391
+ repair,
392
+ checks,
393
+ ...extra,
394
+ }
395
+ }
396
+
397
+ /** 主入口:按 target 聚合检查。缺参数时 fail-closed,不猜。 */
398
+ export function runMainGate(options = {}) {
399
+ const target = String(options.target ?? 'package').trim()
400
+ if (!GATE_TARGETS.includes(target)) {
401
+ return aggregate(target, [{ id: 'gate: target', ok: false, severity: 'blocker', detail: `unknown target ${target}` }])
402
+ }
403
+ const checks = []
404
+ let version = String(options.version ?? '').trim()
405
+ if (!version) version = String(readJsonSafe(path.join(path.resolve(String(options.root ?? process.cwd())), 'package.json'))?.version ?? '')
406
+ let dataDir = String(options.dataDir ?? '').trim()
407
+ if (!dataDir && options.homeDir && options.profile) dataDir = path.join(path.resolve(String(options.homeDir)), 'fang', String(options.profile))
408
+
409
+ if (target === 'source') {
410
+ const out = checkSourceTree({ root: options.root ?? process.cwd(), version })
411
+ checks.push(...out.checks)
412
+ version = out.version
413
+ }
414
+ if (target === 'package' || target === 'publish' || target === 'install' || target === 'deploy') {
415
+ if (options.packageDir) {
416
+ const out = checkStagedPackage({ packageDir: options.packageDir, version })
417
+ checks.push(...out.checks)
418
+ } else if (target === 'package' || target === 'publish') {
419
+ fail(checks, 'package: staged package directory', 'missing --package-dir', 'run build and pass dist/npm/package')
420
+ }
421
+ if (target === 'package' || target === 'publish') {
422
+ const out = checkTarball({ distDir: options.distDir ?? 'dist', version, tarball: options.tarball, manifest: options.manifest })
423
+ checks.push(...out.checks)
424
+ }
425
+ }
426
+ if (target === 'workflows') {
427
+ const out = checkWorkflows({ root: options.root ?? process.cwd() })
428
+ checks.push(...out.checks)
429
+ }
430
+ if (target === 'publish') {
431
+ const out = checkPublishMetadata({ version, tag: options.tag, npmLatest: options.npmLatest, allowExisting: options.allowExisting === true })
432
+ checks.push(...out.checks)
433
+ }
434
+ if (target === 'install' || target === 'deploy' || target === 'profile') {
435
+ if (!options.homeDir) fail(checks, 'profile: home required', 'missing --home', 'pass the DSH_HOME that owns the profile')
436
+ if (!options.profile) fail(checks, 'profile: name required', 'missing --profile', 'pass the profile name')
437
+ if (options.homeDir && options.profile) {
438
+ const out = checkProfile({ homeDir: options.homeDir, profile: options.profile, profileDir: options.profileDir })
439
+ checks.push(...out.checks)
440
+ if (out.exists && !out.backup && options.repair === true) {
441
+ const backup = createProfileBackup(out.dir)
442
+ addCheck(checks, 'profile: backup created by repair', backup.ok === true, 'blocker', backup.ok ? backup.dir : backup.error, 'repair could not create a backup')
443
+ }
444
+ if (out.exists && !out.backup && options.requireBackup === true && options.repair !== true) {
445
+ fail(checks, 'profile: backup required before deploy', out.dir, 'run gate --repair (or make a .bak copy) before deploy')
446
+ }
447
+ const guards = checkRuntimeGuards({ homeDir: options.homeDir, profile: options.profile })
448
+ checks.push(...guards.checks)
449
+ const disk = checkDiskSpace({ homeDir: options.homeDir, minFreeBytes: options.minFreeBytes })
450
+ checks.push(...disk.checks)
451
+ }
452
+ }
453
+ if (target === 'deploy') {
454
+ if (options.supervisorPresent !== true) {
455
+ warn(checks, 'deploy: external supervisor', 'no live supervisor marker supplied', 'start restart-watchdog --execute if unattended restart is required')
456
+ } else pass(checks, 'deploy: external supervisor', 'present')
457
+ }
458
+ if (target === 'profile' && options.create === true) {
459
+ const home = path.resolve(String(options.homeDir ?? ''))
460
+ const name = String(options.profile ?? '')
461
+ const dir = path.join(home, 'profiles', name)
462
+ if (!PROFILE_NAME_RE.test(name)) fail(checks, 'profile-create: name', name, 'use lowercase letters/digits/hyphens, 1-63 chars')
463
+ if (RESERVED_PROFILE_NAMES.includes(name)) fail(checks, 'profile-create: reserved', name, 'desktop/plugin are reserved')
464
+ if (!isInside(path.join(home, 'profiles'), dir)) fail(checks, 'profile-create: inside home/profiles', dir)
465
+ if (fs.existsSync(dir) && options.force !== true) fail(checks, 'profile-create: does not exist', dir, 'use --force only after a verified backup')
466
+ if (fs.existsSync(dir) && options.force === true) warn(checks, 'profile-create: overwrite requested', dir, 'existing profile will be replaced; backup is mandatory')
467
+ const disk = checkDiskSpace({ homeDir: home, minFreeBytes: options.minFreeBytes })
468
+ checks.push(...disk.checks)
469
+ }
470
+
471
+ const report = aggregate(target, checks, { version, dataDir: dataDir || null })
472
+ if (dataDir && (target === 'install' || target === 'deploy' || target === 'publish')) {
473
+ const audit = recordGateDecision({ dataDir, target, report, actor: options.actor })
474
+ report.audit = audit
475
+ if (audit.ok === false && audit.reason === 'chain-broken') {
476
+ report.ok = false
477
+ report.blockers.push({ id: 'audit: append gate record', detail: 'hash chain broken; refusing to append a gate record' })
478
+ }
479
+ }
480
+ return report
481
+ }
482
+
483
+ /** 在 profile 目录写 .gate-backups/<stamp>/(只复制配置,不动 node_modules/会话)。 */
484
+ export function createProfileBackup(profileDir) {
485
+ const dir = path.resolve(String(profileDir ?? ''))
486
+ if (!fs.existsSync(dir)) return { ok: false, error: 'profile-missing', dir }
487
+ const stamp = new Date().toISOString().replace(/[:.]/g, '-')
488
+ const target = path.join(dir, '.gate-backups', stamp)
489
+ try {
490
+ fs.mkdirSync(target, { recursive: true })
491
+ const copied = []
492
+ for (const name of ['package.json', 'cordis.patch.yml', 'cordis.yml']) {
493
+ const from = path.join(dir, name)
494
+ if (!fs.existsSync(from)) continue
495
+ fs.copyFileSync(from, path.join(target, name))
496
+ copied.push(name)
497
+ }
498
+ fs.writeFileSync(path.join(target, 'MANIFEST.json'), JSON.stringify({ at: new Date().toISOString(), profileDir: dir, copied }, null, 2), 'utf8')
499
+ return { ok: true, dir: target, copied }
500
+ } catch (error) {
501
+ return { ok: false, error: String(error?.message ?? error), dir: target }
502
+ }
503
+ }
504
+
505
+ /** 把门禁结论写进链式审计;没有 dataDir 时跳过。链坏了拒绝追加,避免洗白。 */
506
+ export function recordGateDecision({ dataDir, target, report, actor = '' } = {}) {
507
+ if (!dataDir) return { ok: false, skipped: true, reason: 'no-data-dir' }
508
+ const ledger = path.join(String(dataDir), 'audit.jsonl')
509
+ try {
510
+ fs.mkdirSync(String(dataDir), { recursive: true })
511
+ if (fs.existsSync(ledger)) {
512
+ const chain = verifyChain(ledger)
513
+ if (!chain.ok) return { ok: false, skipped: true, reason: 'chain-broken', chain }
514
+ }
515
+ const record = appendChained(ledger, {
516
+ kind: 'main-city-gate',
517
+ target,
518
+ ok: report?.ok === true,
519
+ version: report?.version ?? null,
520
+ blockers: report?.blockers?.map((item) => item.id).slice(0, 20) ?? [],
521
+ warnings: report?.warnings?.map((item) => item.id).slice(0, 20) ?? [],
522
+ grade: report?.score?.grade ?? null,
523
+ actor: String(actor ?? '').slice(0, 200) || null,
524
+ at: new Date().toISOString(),
525
+ })
526
+ return { ok: true, ledger, record }
527
+ } catch (error) {
528
+ return { ok: false, skipped: true, reason: 'append-failed', error: String(error?.message ?? error) }
529
+ }
530
+ }
531
+
532
+ /** 把 blocker 汇总成可直接打在 CI/终端上的一行(不隐藏原因)。 */
533
+ export function formatGateFailure(report) {
534
+ const blockers = report?.blockers ?? []
535
+ if (!blockers.length) return 'gate GREEN'
536
+ return blockers.map((item) => `${item.id}: ${item.detail}`).join(' | ')
537
+ }
538
+
539
+ export const DEFAULT_PROFILE_BUNDLES = Object.freeze([
540
+ '@deepseek-ai/dsh-base',
541
+ '@deepseek-ai/dsh-web-app',
542
+ '@twinsearth/fang-dsh-plugin',
543
+ ])
544
+
545
+ /**
546
+ * 通过门禁后真正创建一个新 profile(唯一允许的写路径)。
547
+ * 再次校验名称/路径/存在性,避免调用方跳过 gate 直接调用。
548
+ */
549
+ export function createGatedProfile({ homeDir, profile, bundles = DEFAULT_PROFILE_BUNDLES, force = false, minFreeBytes = DEFAULT_MIN_FREE_BYTES } = {}) {
550
+ const home = path.resolve(String(homeDir ?? ''))
551
+ const name = String(profile ?? '').trim()
552
+ if (!home) return { ok: false, error: 'home-required' }
553
+ if (!PROFILE_NAME_RE.test(name)) return { ok: false, error: 'profile-name-invalid', detail: name }
554
+ if (RESERVED_PROFILE_NAMES.includes(name)) return { ok: false, error: 'profile-name-reserved', detail: name }
555
+ const dir = path.join(home, 'profiles', name)
556
+ if (!isInside(path.join(home, 'profiles'), dir)) return { ok: false, error: 'profile-path-escape', detail: dir }
557
+ if (fs.existsSync(dir) && force !== true) return { ok: false, error: 'profile-exists', detail: dir }
558
+ const free = diskFree(home)
559
+ if (free !== null && free < minFreeBytes) return { ok: false, error: 'disk-low', detail: `${free} < ${minFreeBytes}` }
560
+ const list = (Array.isArray(bundles) ? bundles : DEFAULT_PROFILE_BUNDLES).map(String).filter(Boolean)
561
+ if (!list.length || new Set(list).size !== list.length) return { ok: false, error: 'bundles-invalid' }
562
+ try {
563
+ fs.mkdirSync(path.join(home, 'profiles'), { recursive: true })
564
+ if (fs.existsSync(dir) && force === true) {
565
+ const backup = createProfileBackup(dir)
566
+ if (!backup.ok) return { ok: false, error: 'backup-failed', detail: backup.error }
567
+ }
568
+ fs.mkdirSync(dir, { recursive: true })
569
+ const pkg = { name: `dsh-profile-${name}`, private: true, dsh: { profile: { bundles: list } } }
570
+ fs.writeFileSync(path.join(dir, 'package.json'), JSON.stringify(pkg, null, 2) + '\n', 'utf8')
571
+ const patch = path.join(dir, 'cordis.patch.yml')
572
+ if (!fs.existsSync(patch)) fs.writeFileSync(patch, '[]\n', 'utf8')
573
+ return { ok: true, dir, profile: name, bundles: list }
574
+ } catch (error) {
575
+ return { ok: false, error: 'create-failed', detail: String(error?.message ?? error), dir }
576
+ }
577
+ }
578
+
579
+ export const _internals = { checkSourceTree, checkStagedPackage, checkTarball, checkProfile, checkRuntimeGuards, checkPublishMetadata, checkWorkflows, isInside, pidAlive, diskFree }