@defrex/autobuild 0.8.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.
Files changed (251) hide show
  1. package/LICENSE +201 -0
  2. package/README.md +192 -0
  3. package/SPEC.md +2269 -0
  4. package/bin/ab-build-runner.ts +47 -0
  5. package/bin/ab-dev.ts +27 -0
  6. package/bin/ab-dispatch-kernel.ts +92 -0
  7. package/bin/ab-harvest-runner.ts +29 -0
  8. package/bin/ab-init-probe.ts +19 -0
  9. package/bin/ab-process-group-reaper.ts +32 -0
  10. package/bin/ab.ts +6 -0
  11. package/bin/agent/ab +3 -0
  12. package/docs/answering-blocked-builds.md +89 -0
  13. package/docs/architecture.md +726 -0
  14. package/docs/assets/headline-wide.png +0 -0
  15. package/docs/configuration.md +1623 -0
  16. package/docs/hosted-dispatcher-cutover-validation.md +178 -0
  17. package/docs/hosted-dispatcher.md +204 -0
  18. package/docs/mcp.md +119 -0
  19. package/docs/operator-api.md +186 -0
  20. package/docs/remote-store-protocol.md +972 -0
  21. package/docs/remote-ticket-protocol.md +79 -0
  22. package/docs/setup.md +289 -0
  23. package/docs/spec-standard.md +63 -0
  24. package/docs/ticket-dependencies.md +59 -0
  25. package/docs/vercel-sandbox-rollout-probe.md +7 -0
  26. package/package.json +135 -0
  27. package/packages/core/src/cli/args.ts +74 -0
  28. package/packages/core/src/cli/artifact.ts +182 -0
  29. package/packages/core/src/cli/binary.ts +198 -0
  30. package/packages/core/src/cli/build-control.ts +789 -0
  31. package/packages/core/src/cli/build-progress.ts +55 -0
  32. package/packages/core/src/cli/bulk-control.ts +385 -0
  33. package/packages/core/src/cli/context.ts +470 -0
  34. package/packages/core/src/cli/dashboard/actions.ts +59 -0
  35. package/packages/core/src/cli/dashboard/cells.ts +92 -0
  36. package/packages/core/src/cli/dashboard/composer.ts +230 -0
  37. package/packages/core/src/cli/dashboard/detail.ts +100 -0
  38. package/packages/core/src/cli/dashboard/frame-image.ts +373 -0
  39. package/packages/core/src/cli/dashboard/live.ts +152 -0
  40. package/packages/core/src/cli/dashboard/model.ts +1173 -0
  41. package/packages/core/src/cli/dashboard/poll.ts +196 -0
  42. package/packages/core/src/cli/dashboard/render.ts +1568 -0
  43. package/packages/core/src/cli/dashboard/selection.ts +66 -0
  44. package/packages/core/src/cli/dashboard/session-feed.ts +244 -0
  45. package/packages/core/src/cli/dashboard/session-view.ts +506 -0
  46. package/packages/core/src/cli/dashboard/transcript.ts +108 -0
  47. package/packages/core/src/cli/dev.ts +46 -0
  48. package/packages/core/src/cli/dispatch-frontend.ts +1045 -0
  49. package/packages/core/src/cli/dispatch-process.ts +259 -0
  50. package/packages/core/src/cli/dispatch.ts +3692 -0
  51. package/packages/core/src/cli/dotenv.ts +48 -0
  52. package/packages/core/src/cli/env.ts +206 -0
  53. package/packages/core/src/cli/harvest.ts +698 -0
  54. package/packages/core/src/cli/help.ts +713 -0
  55. package/packages/core/src/cli/init-agent.ts +106 -0
  56. package/packages/core/src/cli/init-validation.ts +583 -0
  57. package/packages/core/src/cli/init.ts +756 -0
  58. package/packages/core/src/cli/installation.ts +333 -0
  59. package/packages/core/src/cli/keyboard.ts +261 -0
  60. package/packages/core/src/cli/main.ts +1399 -0
  61. package/packages/core/src/cli/mcp.ts +279 -0
  62. package/packages/core/src/cli/models.ts +65 -0
  63. package/packages/core/src/cli/observe.ts +55 -0
  64. package/packages/core/src/cli/plugin.ts +227 -0
  65. package/packages/core/src/cli/pr-attachments.ts +138 -0
  66. package/packages/core/src/cli/pr-summary.ts +124 -0
  67. package/packages/core/src/cli/publication-settlement.ts +188 -0
  68. package/packages/core/src/cli/repo-state.ts +213 -0
  69. package/packages/core/src/cli/repository-status.ts +80 -0
  70. package/packages/core/src/cli/self-update.ts +443 -0
  71. package/packages/core/src/cli/status.ts +895 -0
  72. package/packages/core/src/cli/store-opening.contract.ts +289 -0
  73. package/packages/core/src/cli/store-opening.ts +167 -0
  74. package/packages/core/src/cli/store-ref.ts +33 -0
  75. package/packages/core/src/cli/terminal-restore.ts +174 -0
  76. package/packages/core/src/cli/terminal.ts +361 -0
  77. package/packages/core/src/cli/terminals.ts +995 -0
  78. package/packages/core/src/cli/testkit.ts +194 -0
  79. package/packages/core/src/cli/ticket.ts +484 -0
  80. package/packages/core/src/cli/upgrade-agent.ts +180 -0
  81. package/packages/core/src/cli/upgrade-commit.ts +468 -0
  82. package/packages/core/src/cli/upgrade-notice.ts +91 -0
  83. package/packages/core/src/cli/upgrade-progress.ts +110 -0
  84. package/packages/core/src/cli/upgrade.ts +888 -0
  85. package/packages/core/src/cli/wait.ts +587 -0
  86. package/packages/core/src/cli/watch.ts +932 -0
  87. package/packages/core/src/config/live.ts +255 -0
  88. package/packages/core/src/config/load.ts +98 -0
  89. package/packages/core/src/config/roles.ts +438 -0
  90. package/packages/core/src/config/schema.ts +774 -0
  91. package/packages/core/src/distribution.ts +24 -0
  92. package/packages/core/src/events/catalog.ts +201 -0
  93. package/packages/core/src/events/envelope.ts +59 -0
  94. package/packages/core/src/events/payloads.ts +618 -0
  95. package/packages/core/src/events/repository.ts +559 -0
  96. package/packages/core/src/events/sessions.ts +180 -0
  97. package/packages/core/src/harvest/schema.ts +147 -0
  98. package/packages/core/src/ids.ts +45 -0
  99. package/packages/core/src/integration/harness.ts +681 -0
  100. package/packages/core/src/kernel/auto-merge.ts +259 -0
  101. package/packages/core/src/kernel/converge.ts +117 -0
  102. package/packages/core/src/kernel/dispatch-settings.ts +60 -0
  103. package/packages/core/src/kernel/dispatch-status.ts +141 -0
  104. package/packages/core/src/kernel/engine.ts +1047 -0
  105. package/packages/core/src/kernel/harvest.ts +823 -0
  106. package/packages/core/src/kernel/origin.ts +69 -0
  107. package/packages/core/src/kernel/phases.ts +196 -0
  108. package/packages/core/src/kernel/plan-verify-selection.ts +105 -0
  109. package/packages/core/src/kernel/pr-attachments.ts +82 -0
  110. package/packages/core/src/kernel/reducer.ts +752 -0
  111. package/packages/core/src/kernel/stall.ts +155 -0
  112. package/packages/core/src/kernel/verify-applicability.ts +52 -0
  113. package/packages/core/src/markdown.ts +67 -0
  114. package/packages/core/src/ontology.ts +288 -0
  115. package/packages/core/src/open-map.ts +203 -0
  116. package/packages/core/src/operator/annotations.ts +187 -0
  117. package/packages/core/src/operator/client.ts +311 -0
  118. package/packages/core/src/operator/control.ts +117 -0
  119. package/packages/core/src/operator/index.ts +62 -0
  120. package/packages/core/src/operator/presentation.ts +22 -0
  121. package/packages/core/src/operator/protocol.ts +98 -0
  122. package/packages/core/src/operator/query.ts +201 -0
  123. package/packages/core/src/operator/registry.ts +1043 -0
  124. package/packages/core/src/operator/requests.ts +138 -0
  125. package/packages/core/src/operator/sandbox.ts +458 -0
  126. package/packages/core/src/operator/server.ts +616 -0
  127. package/packages/core/src/operator/sessions.ts +223 -0
  128. package/packages/core/src/operator/tickets.ts +177 -0
  129. package/packages/core/src/plugin-sdk/index.ts +174 -0
  130. package/packages/core/src/plugins/contract-entry.ts +74 -0
  131. package/packages/core/src/plugins/contract-env.ts +3 -0
  132. package/packages/core/src/plugins/load.ts +259 -0
  133. package/packages/core/src/plugins/manifest.ts +236 -0
  134. package/packages/core/src/plugins/registry.ts +274 -0
  135. package/packages/core/src/plugins/runtimes.ts +75 -0
  136. package/packages/core/src/ports/forge/contract.ts +258 -0
  137. package/packages/core/src/ports/forge/create.ts +74 -0
  138. package/packages/core/src/ports/forge/fake.ts +488 -0
  139. package/packages/core/src/ports/forge/github-pr-attachments.ts +351 -0
  140. package/packages/core/src/ports/forge/github-transport.ts +352 -0
  141. package/packages/core/src/ports/forge/github.ts +1162 -0
  142. package/packages/core/src/ports/forge/local-git.ts +568 -0
  143. package/packages/core/src/ports/runner/claude.ts +873 -0
  144. package/packages/core/src/ports/runner/cli-stream.ts +90 -0
  145. package/packages/core/src/ports/runner/codex.ts +873 -0
  146. package/packages/core/src/ports/runner/contract.ts +492 -0
  147. package/packages/core/src/ports/runner/fake.ts +213 -0
  148. package/packages/core/src/ports/runner/json-record.ts +2 -0
  149. package/packages/core/src/ports/runner/live-contract-fixture.ts +176 -0
  150. package/packages/core/src/ports/runner/one-shot.ts +29 -0
  151. package/packages/core/src/ports/runner/pi-bridge-core.ts +136 -0
  152. package/packages/core/src/ports/runner/pi-bridge.js +6 -0
  153. package/packages/core/src/ports/runner/pi-rpc.ts +454 -0
  154. package/packages/core/src/ports/runner/pi.ts +617 -0
  155. package/packages/core/src/ports/runner/production.ts +74 -0
  156. package/packages/core/src/ports/runner/provider-error.ts +121 -0
  157. package/packages/core/src/ports/runner/routing.ts +234 -0
  158. package/packages/core/src/ports/runner/runtime.ts +214 -0
  159. package/packages/core/src/ports/runner/session-env.ts +34 -0
  160. package/packages/core/src/ports/runner/stream-parts.ts +103 -0
  161. package/packages/core/src/ports/tickets/contract.ts +570 -0
  162. package/packages/core/src/ports/tickets/create.ts +135 -0
  163. package/packages/core/src/ports/tickets/fake.ts +215 -0
  164. package/packages/core/src/ports/tickets/file.ts +559 -0
  165. package/packages/core/src/ports/tickets/linear.ts +811 -0
  166. package/packages/core/src/ports/tickets/operations.ts +161 -0
  167. package/packages/core/src/ports/tickets/remote-index.ts +13 -0
  168. package/packages/core/src/ports/tickets/remote-protocol.ts +80 -0
  169. package/packages/core/src/ports/tickets/remote-server.ts +185 -0
  170. package/packages/core/src/ports/tickets/remote.ts +130 -0
  171. package/packages/core/src/ports/tickets/update.ts +51 -0
  172. package/packages/core/src/ports/types.ts +528 -0
  173. package/packages/core/src/ports/workspace/build-execution.ts +123 -0
  174. package/packages/core/src/ports/workspace/contract.ts +136 -0
  175. package/packages/core/src/ports/workspace/create.ts +132 -0
  176. package/packages/core/src/ports/workspace/distribution-archive.ts +317 -0
  177. package/packages/core/src/ports/workspace/fake.ts +432 -0
  178. package/packages/core/src/ports/workspace/git-worktree.ts +848 -0
  179. package/packages/core/src/ports/workspace/harvest-execution.ts +111 -0
  180. package/packages/core/src/ports/workspace/in-process-build-execution.ts +30 -0
  181. package/packages/core/src/ports/workspace/local-build-execution.ts +88 -0
  182. package/packages/core/src/ports/workspace/operator-sandbox.ts +142 -0
  183. package/packages/core/src/ports/workspace/phase-scratch.ts +156 -0
  184. package/packages/core/src/ports/workspace/vercel-sandbox.ts +2541 -0
  185. package/packages/core/src/processes/build-child-exit.ts +78 -0
  186. package/packages/core/src/processes/build-child.ts +119 -0
  187. package/packages/core/src/processes/build-execution-state.ts +95 -0
  188. package/packages/core/src/processes/build-parent-watch.ts +35 -0
  189. package/packages/core/src/processes/build-runner.ts +2614 -0
  190. package/packages/core/src/processes/dispatcher.ts +2825 -0
  191. package/packages/core/src/processes/execution-settlement.ts +171 -0
  192. package/packages/core/src/processes/harvest-child.ts +115 -0
  193. package/packages/core/src/processes/harvest-execution-state.ts +121 -0
  194. package/packages/core/src/processes/harvest-runner.ts +1656 -0
  195. package/packages/core/src/processes/harvest.ts +595 -0
  196. package/packages/core/src/processes/infrastructure-failure-budget.ts +146 -0
  197. package/packages/core/src/processes/phase-failure-budget.ts +52 -0
  198. package/packages/core/src/processes/process-group-reaper.ts +35 -0
  199. package/packages/core/src/processes/process-group.ts +65 -0
  200. package/packages/core/src/processes/publication-loss.ts +62 -0
  201. package/packages/core/src/processes/publication-state.ts +93 -0
  202. package/packages/core/src/processes/sandbox-state.ts +101 -0
  203. package/packages/core/src/registry.ts +24 -0
  204. package/packages/core/src/sessions/reducer.ts +185 -0
  205. package/packages/core/src/skills.ts +7 -0
  206. package/packages/core/src/spec-standard.ts +47 -0
  207. package/packages/core/src/store/adapter.ts +80 -0
  208. package/packages/core/src/store/build-scope.ts +272 -0
  209. package/packages/core/src/store/contract.ts +2581 -0
  210. package/packages/core/src/store/local/blobs.ts +38 -0
  211. package/packages/core/src/store/local/schema.ts +180 -0
  212. package/packages/core/src/store/local/store.ts +1713 -0
  213. package/packages/core/src/store/memory.ts +1138 -0
  214. package/packages/core/src/store/phase-session.ts +425 -0
  215. package/packages/core/src/store/remote/client.ts +780 -0
  216. package/packages/core/src/store/remote/index.ts +12 -0
  217. package/packages/core/src/store/remote/protocol.ts +348 -0
  218. package/packages/core/src/store/remote/server.ts +923 -0
  219. package/packages/core/src/store/remote/token.ts +137 -0
  220. package/packages/core/src/store/remote/version.ts +10 -0
  221. package/packages/core/src/store/retention.ts +91 -0
  222. package/packages/core/src/store/session-handle.ts +265 -0
  223. package/packages/core/src/store/streams/assemble.ts +79 -0
  224. package/packages/core/src/store/streams/lock.ts +42 -0
  225. package/packages/core/src/store/streams/session-writer.ts +306 -0
  226. package/packages/core/src/store/streams/types.ts +171 -0
  227. package/packages/core/src/store/streams/wait.ts +127 -0
  228. package/packages/core/src/store/subscribe.ts +105 -0
  229. package/packages/core/src/store/types.ts +466 -0
  230. package/packages/core/src/testing/fixed.ts +44 -0
  231. package/packages/core/src/testing/store-failures.ts +32 -0
  232. package/packages/core/src/zod-issues.ts +91 -0
  233. package/skills/code-review/SKILL.md +100 -0
  234. package/skills/finalize/SKILL.md +44 -0
  235. package/skills/guide/SKILL.md +1781 -0
  236. package/skills/guide/references/operator-api.md +164 -0
  237. package/skills/guide/references/plugin-authoring.md +354 -0
  238. package/skills/guide/references/remote-store-protocol.md +972 -0
  239. package/skills/guide/references/remote-ticket-protocol.md +79 -0
  240. package/skills/guide/references/setup.md +289 -0
  241. package/skills/guide/references/spec-standard.md +63 -0
  242. package/skills/guide/references/ticket-dependencies.md +59 -0
  243. package/skills/harvest/SKILL.md +66 -0
  244. package/skills/harvest-review/SKILL.md +63 -0
  245. package/skills/implement/SKILL.md +82 -0
  246. package/skills/plan/SKILL.md +123 -0
  247. package/skills/plan-review/SKILL.md +121 -0
  248. package/skills/reconcile/SKILL.md +65 -0
  249. package/skills/spec/SKILL.md +132 -0
  250. package/skills/tickets/SKILL.md +139 -0
  251. package/templates/autobuild.toml +36 -0
@@ -0,0 +1,333 @@
1
+ import { stat, readFile, realpath } from 'node:fs/promises'
2
+ import { join, resolve, sep } from 'node:path'
3
+ import semver from 'semver'
4
+ import { PLUGIN_API_VERSION } from '../plugins/manifest'
5
+ import { defaultDistRoot } from './init'
6
+
7
+ export interface DistributionIdentity {
8
+ root: string
9
+ packageName: string
10
+ version: string
11
+ /** Bun records the resolved forge commit in a distribution-local `.bun-tag`. */
12
+ commit?: string
13
+ bunTag?: string
14
+ sourceCheckout: boolean
15
+ binaryPath: string
16
+ }
17
+
18
+ export type InstallScope = 'local' | 'global'
19
+
20
+ /** Where the owning package manager obtained the distribution: a `github:`
21
+ * forge dependency, or a version range satisfied from the npm registry. */
22
+ export type InstallChannel = 'github' | 'npm'
23
+
24
+ interface ManagedInstallationBase extends DistributionIdentity {
25
+ sourceCheckout: false
26
+ ownerRoot: string
27
+ ownerManifest: string
28
+ ownerLock: string
29
+ /** The owning manifest's direct dependency value, byte for byte. */
30
+ dependency: string
31
+ scope: InstallScope
32
+ }
33
+
34
+ export interface BunForgeInstallation extends ManagedInstallationBase {
35
+ channel: 'github'
36
+ owner: string
37
+ repository: string
38
+ }
39
+
40
+ export interface NpmRegistryInstallation extends ManagedInstallationBase {
41
+ channel: 'npm'
42
+ }
43
+
44
+ export type ManagedInstallation = BunForgeInstallation | NpmRegistryInstallation
45
+
46
+ export type InstallationInspection =
47
+ | { kind: 'source'; identity: DistributionIdentity; reason: string }
48
+ | { kind: 'bun-forge'; installation: BunForgeInstallation }
49
+ | { kind: 'npm-registry'; installation: NpmRegistryInstallation }
50
+ | { kind: 'unknown'; identity: DistributionIdentity; reason: string }
51
+
52
+ async function exists(path: string): Promise<boolean> {
53
+ try {
54
+ await stat(path)
55
+ return true
56
+ } catch (error) {
57
+ if ((error as NodeJS.ErrnoException).code === 'ENOENT') return false
58
+ throw error
59
+ }
60
+ }
61
+
62
+ function object(value: unknown): Record<string, unknown> | undefined {
63
+ return typeof value === 'object' && value !== null && !Array.isArray(value)
64
+ ? (value as Record<string, unknown>)
65
+ : undefined
66
+ }
67
+
68
+ function exactVersion(value: unknown, path: string): string {
69
+ if (typeof value !== 'string' || semver.valid(value) !== value) {
70
+ throw new Error(`${path} has an invalid package version`)
71
+ }
72
+ return value
73
+ }
74
+
75
+ function binaryRelativePath(manifest: Record<string, unknown>, path: string): string {
76
+ const bin = manifest.bin
77
+ if (typeof bin === 'string' && bin !== '') return bin
78
+ const entries = object(bin)
79
+ const ab = entries?.ab
80
+ if (typeof ab !== 'string' || ab === '') {
81
+ throw new Error(`${path} does not declare the ab binary`)
82
+ }
83
+ return ab
84
+ }
85
+
86
+ /** Read only distribution-local metadata. This is the complete `ab --version`
87
+ * path: no cwd, project configuration, package-manager command, or network. */
88
+ export async function readDistributionIdentity(
89
+ root = defaultDistRoot(),
90
+ ): Promise<DistributionIdentity> {
91
+ const distributionRoot = resolve(root)
92
+ const packagePath = join(distributionRoot, 'package.json')
93
+ let manifest: Record<string, unknown>
94
+ try {
95
+ manifest = object(JSON.parse(await readFile(packagePath, 'utf8'))) ?? {}
96
+ } catch (error) {
97
+ throw new Error(
98
+ `cannot read installed Autobuild metadata at ${packagePath}: ${
99
+ error instanceof Error ? error.message : String(error)
100
+ }`,
101
+ )
102
+ }
103
+ if (typeof manifest.name !== 'string' || manifest.name === '') {
104
+ throw new Error(`${packagePath} has no package name`)
105
+ }
106
+ const binary = binaryRelativePath(manifest, packagePath)
107
+ const sourceCheckout = await exists(join(distributionRoot, '.git'))
108
+ let bunTag: string | undefined
109
+ let commit: string | undefined
110
+ try {
111
+ bunTag = (await readFile(join(distributionRoot, '.bun-tag'), 'utf8')).trim()
112
+ const match = /-([0-9a-f]{7,40})$/i.exec(bunTag)
113
+ if (match?.[1] !== undefined) commit = match[1]
114
+ } catch (error) {
115
+ if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error
116
+ }
117
+ return {
118
+ root: distributionRoot,
119
+ packageName: manifest.name,
120
+ version: exactVersion(manifest.version, packagePath),
121
+ ...(commit === undefined ? {} : { commit }),
122
+ ...(bunTag === undefined ? {} : { bunTag }),
123
+ sourceCheckout,
124
+ binaryPath: resolve(distributionRoot, binary),
125
+ }
126
+ }
127
+
128
+ export function formatInstalledVersion(identity: DistributionIdentity): string {
129
+ return [
130
+ `autobuild ${identity.version}${identity.commit === undefined ? '' : ` (commit ${identity.commit})`}`,
131
+ `plugin API ${PLUGIN_API_VERSION}`,
132
+ ].join('\n')
133
+ }
134
+
135
+ function ownerRootForPackage(identity: DistributionIdentity): string | undefined {
136
+ const packageSegments = identity.packageName.split('/')
137
+ if (packageSegments.some((segment) => segment === '' || segment === '.' || segment === '..')) {
138
+ return undefined
139
+ }
140
+ const suffix = [sep, 'node_modules', sep, packageSegments.join(sep)].join('')
141
+ if (!identity.root.endsWith(suffix)) return undefined
142
+ const owner = identity.root.slice(0, -suffix.length)
143
+ return owner === '' ? sep : owner
144
+ }
145
+
146
+ function githubDependency(value: unknown): { owner: string; repository: string } | undefined {
147
+ if (typeof value !== 'string') return undefined
148
+ const match = /^github:([A-Za-z0-9_.-]+)\/([A-Za-z0-9_.-]+)(?:#([^\s]+))?$/.exec(value)
149
+ if (match?.[1] === undefined || match[2] === undefined) return undefined
150
+ return { owner: match[1], repository: match[2] }
151
+ }
152
+
153
+ /** A registry dependency is a semver range (what `bun add <pkg>@<version>`
154
+ * writes) that the installed version satisfies. Aliases, tags, URLs, and
155
+ * `file:`/`link:` specifiers are not registry installs. */
156
+ function registryDependency(value: unknown, installedVersion: string): boolean {
157
+ if (typeof value !== 'string' || value.trim() === '') return false
158
+ if (semver.validRange(value) === null) return false
159
+ return semver.satisfies(installedVersion, value)
160
+ }
161
+
162
+ async function parseJson(path: string): Promise<Record<string, unknown>> {
163
+ try {
164
+ return object(JSON.parse(await readFile(path, 'utf8'))) ?? {}
165
+ } catch (error) {
166
+ throw new Error(
167
+ `${path} is not valid JSON: ${error instanceof Error ? error.message : String(error)}`,
168
+ )
169
+ }
170
+ }
171
+
172
+ async function parseJsonc(path: string): Promise<Record<string, unknown>> {
173
+ try {
174
+ const value = Bun.JSONC.parse(await readFile(path, 'utf8'))
175
+ return object(value) ?? {}
176
+ } catch (error) {
177
+ throw new Error(
178
+ `${path} is not valid JSONC: ${error instanceof Error ? error.message : String(error)}`,
179
+ )
180
+ }
181
+ }
182
+
183
+ function lockDependency(lock: Record<string, unknown>, packageName: string): unknown {
184
+ const workspace = object(object(lock.workspaces)?.[''])
185
+ return object(workspace?.dependencies)?.[packageName]
186
+ }
187
+
188
+ function lockPackageRecord(
189
+ lock: Record<string, unknown>,
190
+ packageName: string,
191
+ ): unknown[] | undefined {
192
+ const value = object(lock.packages)?.[packageName]
193
+ return Array.isArray(value) ? value : undefined
194
+ }
195
+
196
+ async function samePath(left: string, right: string): Promise<boolean> {
197
+ try {
198
+ return (await realpath(left)) === (await realpath(right))
199
+ } catch (error) {
200
+ if ((error as NodeJS.ErrnoException).code === 'ENOENT') return false
201
+ throw error
202
+ }
203
+ }
204
+
205
+ /** Validate Bun's direct install records before permitting mutation. A
206
+ * `github:` dependency is a forge install whose repository comes from the
207
+ * owning manifest/lock, never by splitting the ambiguous hyphen-delimited
208
+ * `.bun-tag`. A semver-range dependency satisfied by the installed version,
209
+ * whose lock record resolves to that exact registry version, is an npm
210
+ * registry install. Anything else is a named refusal. */
211
+ export async function inspectInstallation(options: {
212
+ distRoot?: string
213
+ globalBin: string
214
+ }): Promise<InstallationInspection> {
215
+ const identity = await readDistributionIdentity(options.distRoot)
216
+ if (identity.sourceCheckout) {
217
+ return {
218
+ kind: 'source',
219
+ identity,
220
+ reason: 'running from a source checkout (.git is present)',
221
+ }
222
+ }
223
+
224
+ const ownerRoot = ownerRootForPackage(identity)
225
+ if (ownerRoot === undefined) {
226
+ return {
227
+ kind: 'unknown',
228
+ identity,
229
+ reason: 'the distribution is not a direct Bun node_modules package',
230
+ }
231
+ }
232
+ const ownerManifest = join(ownerRoot, 'package.json')
233
+ const ownerLock = join(ownerRoot, 'bun.lock')
234
+
235
+ try {
236
+ const manifest = await parseJson(ownerManifest)
237
+ const lock = await parseJsonc(ownerLock)
238
+ const dependency = object(manifest.dependencies)?.[identity.packageName]
239
+ const repository = githubDependency(dependency)
240
+ const registry = repository === undefined && registryDependency(dependency, identity.version)
241
+ if ((repository === undefined && !registry) || typeof dependency !== 'string') {
242
+ return {
243
+ kind: 'unknown',
244
+ identity,
245
+ reason: `${ownerManifest} does not declare ${identity.packageName} as a direct github: dependency or an npm registry version range satisfied by ${identity.version}`,
246
+ }
247
+ }
248
+ if (lockDependency(lock, identity.packageName) !== dependency) {
249
+ return {
250
+ kind: 'unknown',
251
+ identity,
252
+ reason: `${ownerLock} does not agree with the direct dependency in ${ownerManifest}`,
253
+ }
254
+ }
255
+ const scope: InstallScope = (await samePath(join(options.globalBin, 'ab'), identity.binaryPath))
256
+ ? 'global'
257
+ : 'local'
258
+ if (repository === undefined) {
259
+ // Registry provenance: Bun records `<name>@<version>` with an empty
260
+ // registry marker; a forge, tarball, or workspace resolution differs.
261
+ const record = lockPackageRecord(lock, identity.packageName)
262
+ if (
263
+ record === undefined ||
264
+ record[0] !== `${identity.packageName}@${identity.version}` ||
265
+ record[1] !== ''
266
+ ) {
267
+ return {
268
+ kind: 'unknown',
269
+ identity,
270
+ reason: `${ownerLock} does not resolve ${identity.packageName} to registry version ${identity.version}`,
271
+ }
272
+ }
273
+ return {
274
+ kind: 'npm-registry',
275
+ installation: {
276
+ ...identity,
277
+ sourceCheckout: false,
278
+ channel: 'npm',
279
+ ownerRoot,
280
+ ownerManifest,
281
+ ownerLock,
282
+ dependency,
283
+ scope,
284
+ },
285
+ }
286
+ }
287
+ const expectedTagPrefix = `${repository.owner}-${repository.repository}-`
288
+ if (
289
+ identity.bunTag === undefined ||
290
+ !identity.bunTag.startsWith(expectedTagPrefix) ||
291
+ identity.commit === undefined
292
+ ) {
293
+ return {
294
+ kind: 'unknown',
295
+ identity,
296
+ reason: 'the installed .bun-tag is malformed or contradicts the owning dependency',
297
+ }
298
+ }
299
+ const record = lockPackageRecord(lock, identity.packageName)
300
+ const resolvedPackage =
301
+ `${identity.packageName}@github:${repository.owner}/${repository.repository}` +
302
+ `#${identity.commit}`
303
+ if (record === undefined || record[0] !== resolvedPackage || record[2] !== identity.bunTag) {
304
+ return {
305
+ kind: 'unknown',
306
+ identity,
307
+ reason: `${ownerLock} does not contain matching Bun forge provenance`,
308
+ }
309
+ }
310
+
311
+ return {
312
+ kind: 'bun-forge',
313
+ installation: {
314
+ ...identity,
315
+ sourceCheckout: false,
316
+ channel: 'github',
317
+ ownerRoot,
318
+ ownerManifest,
319
+ ownerLock,
320
+ owner: repository.owner,
321
+ repository: repository.repository,
322
+ dependency,
323
+ scope,
324
+ },
325
+ }
326
+ } catch (error) {
327
+ return {
328
+ kind: 'unknown',
329
+ identity,
330
+ reason: error instanceof Error ? error.message : String(error),
331
+ }
332
+ }
333
+ }
@@ -0,0 +1,261 @@
1
+ import type { TerminalInputEvent } from './terminal'
2
+ import {
3
+ KITTY_KEYBOARD_FLAGS,
4
+ KITTY_KEYBOARD_MODE,
5
+ type TerminalModeController,
6
+ } from './terminal-restore'
7
+
8
+ /** Progressive enhancement flags requested from Kitty-compatible terminals. */
9
+ export { KITTY_KEYBOARD_FLAGS } from './terminal-restore'
10
+ /** Ask for the current progressive keyboard enhancement flags. */
11
+ export const QUERY_KEYBOARD_FLAGS = '\x1b[?u'
12
+ /**
13
+ * Push disambiguated escape codes (1), alternate keys (4), all keys as escape
14
+ * codes (8), and associated text (16). Alternate keys are essential: without
15
+ * them Cyrillic Ctrl-C reports key 1089 and the decoder cannot recognize it.
16
+ */
17
+ export const PUSH_KEYBOARD_FLAGS = KITTY_KEYBOARD_MODE.enter
18
+ /** Pop exactly the keyboard mode frame pushed by this process. */
19
+ export const POP_KEYBOARD_FLAGS = KITTY_KEYBOARD_MODE.restore
20
+ /**
21
+ * Primary Device Attributes is not a keyboard command. It follows the
22
+ * verification query because its guaranteed, ordered reply lets verification
23
+ * fail deterministically when no keyboard-flags reply arrives.
24
+ */
25
+ export const DEVICE_ATTRIBUTES_QUERY = '\x1b[c'
26
+
27
+ /**
28
+ * Kitty modifiers are `1 + bitfield`; caps and num lock are reported on every
29
+ * key in report-all-keys mode and must not turn an ordinary key into a chord.
30
+ */
31
+ export const MODIFIER_LOCK_MASK = 0b1100_0000
32
+ /** Shift and lock bits may still produce text; ctrl/alt/super/hyper/meta may not. */
33
+ const MODIFIER_TEXT_MASK = 0b1100_0001
34
+ /** No decoded numeric parameter can exceed Unicode's seven-digit maximum scalar. */
35
+ export const MAX_CSI_PARAM_DIGITS = 7
36
+ /**
37
+ * Three 7-digit key fields, two colons, a semicolon, 3-digit modifier, `:1`,
38
+ * `ESC[` and `u` fit in 32 bytes. Forty therefore covers every Ctrl-C report
39
+ * while keeping the parallel, non-consuming interrupt watch deliberately tight.
40
+ */
41
+ export const INTERRUPT_WATCH_MAX = 40
42
+
43
+ /** ECMA-48 parameter/intermediate bytes that may extend a CSI run. */
44
+ export function isCsiParameterByte(ch: string): boolean {
45
+ if (ch.length !== 1) return false
46
+ const code = ch.charCodeAt(0)
47
+ return code >= 0x20 && code <= 0x3f
48
+ }
49
+
50
+ /**
51
+ * CSI sequences end at a byte in 0x40-0x7e. This includes every ASCII letter,
52
+ * so one ordinary letter always terminates an accumulating or draining run.
53
+ */
54
+ export function endsCsi(sequence: string): boolean {
55
+ if (sequence.length <= 2) return false
56
+ const code = sequence.charCodeAt(sequence.length - 1)
57
+ return code >= 0x40 && code <= 0x7e
58
+ }
59
+
60
+ /** Decode a `1 + bitfield` modifier parameter into submit-versus-newline. */
61
+ function modifiedReturn(mod: number | undefined): TerminalInputEvent {
62
+ if (mod === undefined) return { type: 'enter' }
63
+ const bits = Math.max(0, mod - 1)
64
+ return (bits & ~MODIFIER_LOCK_MASK) === 0 ? { type: 'enter' } : { type: 'newline' }
65
+ }
66
+
67
+ function parseParam(value: string | undefined): number | undefined {
68
+ if (value === undefined || value === '') return undefined
69
+ const parsed = Number.parseInt(value, 10)
70
+ return Number.isFinite(parsed) ? parsed : undefined
71
+ }
72
+
73
+ function isTextCodePoint(code: number | undefined): code is number {
74
+ return (
75
+ code !== undefined &&
76
+ code >= 0x20 &&
77
+ code <= 0x10ffff &&
78
+ !(code >= 0x7f && code <= 0x9f) &&
79
+ !(code >= 0xd800 && code <= 0xdfff)
80
+ )
81
+ }
82
+
83
+ /** Kitty reserves U+E000-U+F8FF in the key-code field for functional keys. */
84
+ function isFunctionalKeyCode(code: number): boolean {
85
+ return code >= 57344 && code <= 63743
86
+ }
87
+
88
+ export type CsiReport =
89
+ | { kind: 'input'; event: TerminalInputEvent }
90
+ | { kind: 'keyboard-flags'; flags: number }
91
+ | { kind: 'device-attributes' }
92
+ | { kind: 'ignored' }
93
+
94
+ const ignored = (): CsiReport => ({ kind: 'ignored' })
95
+ const input = (event: TerminalInputEvent): CsiReport => ({ kind: 'input', event })
96
+
97
+ /** Decode one complete CSI sequence, whether legacy, Kitty CSI-u, or a reply. */
98
+ export function decodeCsi(sequence: string): CsiReport {
99
+ if (!sequence.startsWith('\x1b[') || !endsCsi(sequence)) return ignored()
100
+ const final = sequence.at(-1)
101
+ const body = sequence.slice(2, -1)
102
+
103
+ if (final === 'u' && body.startsWith('?')) {
104
+ return { kind: 'keyboard-flags', flags: parseParam(body.slice(1)) ?? 0 }
105
+ }
106
+ if (final === 'c' && body.startsWith('?')) return { kind: 'device-attributes' }
107
+
108
+ const params = body.split(';')
109
+ if (final === 'u') {
110
+ const [primaryRaw, shiftedRaw, baseRaw] = (params[0] ?? '').split(':')
111
+ const primary = parseParam(primaryRaw)
112
+ const shifted = parseParam(shiftedRaw)
113
+ const base = parseParam(baseRaw)
114
+ const [modRaw, eventRaw] = (params[1] ?? '').split(':')
115
+ const mod = parseParam(modRaw)
116
+ const event = parseParam(eventRaw) ?? 1
117
+ if (event !== 1 && event !== 2) return ignored()
118
+
119
+ if (primary === 13) return input(modifiedReturn(mod))
120
+ if (primary === 27) return input({ type: 'escape' })
121
+ if (primary === 127 || primary === 8) return input({ type: 'backspace' })
122
+ if (primary === 9) return ignored()
123
+
124
+ const bits = Math.max(0, (mod ?? 1) - 1)
125
+ if ((bits & 0b100) !== 0) {
126
+ const chordKeys = base === undefined ? [primary] : [primary, base]
127
+ // The Dvorak physical-QWERTY-C collision contains both 106 and 99. The
128
+ // produced `j` wins: a newline is recoverable while an interrupt is not.
129
+ if (chordKeys.includes(106)) return input({ type: 'newline' })
130
+ if (chordKeys.includes(99)) return input({ type: 'interrupt' })
131
+ }
132
+
133
+ if ((bits & ~MODIFIER_TEXT_MASK) !== 0) return ignored()
134
+
135
+ if (params[2] !== undefined) {
136
+ const codes = params[2].split(':').map(parseParam)
137
+ if (codes.length === 0 || !codes.every(isTextCodePoint)) return ignored()
138
+ // Do not spread: valid associated text is unbounded and a large argument
139
+ // list eventually throws. Folding remains safe for long IME commits.
140
+ let text = ''
141
+ for (const code of codes) text += String.fromCodePoint(code)
142
+ return input({ type: 'text', text })
143
+ }
144
+
145
+ const shiftedOnly = (bits & ~MODIFIER_LOCK_MASK) === 0b1
146
+ // The base-layout subfield names a physical position, not text in the
147
+ // operator's layout, and is intentionally never a printable fallback.
148
+ const code = shiftedOnly && shifted !== undefined ? shifted : primary
149
+ if (!isTextCodePoint(code) || isFunctionalKeyCode(code)) return ignored()
150
+ const text = String.fromCodePoint(code)
151
+ // Best effort for the bounded pre-verification window. Associated text is
152
+ // required after verification because this is wrong for shifted layouts.
153
+ return input({
154
+ type: 'text',
155
+ text: shiftedOnly && shifted === undefined ? text.toUpperCase() : text,
156
+ })
157
+ }
158
+
159
+ if (final === '~') {
160
+ if (params[0] === '27' && parseParam(params[2]) === 13) {
161
+ return input(modifiedReturn(parseParam(params[1])))
162
+ }
163
+ if (params[0] === '1' || params[0] === '7') return input({ type: 'home' })
164
+ if (params[0] === '4' || params[0] === '8') return input({ type: 'end' })
165
+ return ignored()
166
+ }
167
+
168
+ type Motion = Exclude<TerminalInputEvent['type'], 'text' | 'paste'>
169
+ const motions: Record<string, Motion | undefined> = {
170
+ A: 'up',
171
+ B: 'down',
172
+ C: 'right',
173
+ D: 'left',
174
+ H: 'home',
175
+ F: 'end',
176
+ }
177
+ const motion = final === undefined ? undefined : motions[final]
178
+ return motion === undefined ? ignored() : input({ type: motion })
179
+ }
180
+
181
+ export function isInterruptSequence(sequence: string): boolean {
182
+ const report = decodeCsi(sequence)
183
+ return report.kind === 'input' && report.event.type === 'interrupt'
184
+ }
185
+
186
+ type KeyboardPhase =
187
+ | 'idle'
188
+ | 'probing'
189
+ | 'supported'
190
+ | 'verifying'
191
+ | 'active'
192
+ | 'declined'
193
+ | 'finished'
194
+
195
+ export interface KeyboardProtocol {
196
+ query(): void
197
+ reported(flags: number): void
198
+ deviceAttributes(): void
199
+ screenEntered(): void
200
+ screenLeaving(): void
201
+ readonly pushed: boolean
202
+ }
203
+
204
+ /** Negotiate and balance the per-screen Kitty keyboard mode stack. */
205
+ export function createKeyboardProtocol(
206
+ write: (chunk: string) => void,
207
+ modes: TerminalModeController,
208
+ ): KeyboardProtocol {
209
+ let phase: KeyboardPhase = 'idle'
210
+ let onScreen = false
211
+
212
+ const push = (): void => {
213
+ if (phase !== 'supported' || !onScreen) return
214
+ phase = 'verifying'
215
+ // The ledger arms before the push write. Queries do not alter terminal
216
+ // state and therefore remain ordinary output after the declared enter.
217
+ modes.enter(KITTY_KEYBOARD_MODE)
218
+ write(QUERY_KEYBOARD_FLAGS + DEVICE_ATTRIBUTES_QUERY)
219
+ }
220
+
221
+ return {
222
+ query(): void {
223
+ if (phase !== 'idle') return
224
+ write(QUERY_KEYBOARD_FLAGS)
225
+ phase = 'probing'
226
+ },
227
+ reported(flags: number): void {
228
+ if (phase === 'probing') {
229
+ phase = 'supported'
230
+ push()
231
+ return
232
+ }
233
+ if (phase !== 'verifying') return
234
+ if ((flags & KITTY_KEYBOARD_FLAGS) === KITTY_KEYBOARD_FLAGS) {
235
+ phase = 'active'
236
+ } else {
237
+ modes.leave(KITTY_KEYBOARD_MODE)
238
+ phase = 'declined'
239
+ }
240
+ },
241
+ deviceAttributes(): void {
242
+ if (phase !== 'verifying') return
243
+ modes.leave(KITTY_KEYBOARD_MODE)
244
+ phase = 'declined'
245
+ },
246
+ screenEntered(): void {
247
+ if (phase === 'finished') return
248
+ onScreen = true
249
+ push()
250
+ },
251
+ screenLeaving(): void {
252
+ if (phase === 'finished') return
253
+ if (phase === 'verifying' || phase === 'active') modes.leave(KITTY_KEYBOARD_MODE)
254
+ phase = 'finished'
255
+ onScreen = false
256
+ },
257
+ get pushed(): boolean {
258
+ return phase === 'verifying' || phase === 'active'
259
+ },
260
+ }
261
+ }