@defrex/autobuild 0.11.0 → 0.13.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.
- package/README.md +3 -1
- package/SPEC.md +335 -31
- package/docs/architecture.md +125 -7
- package/docs/configuration.md +142 -21
- package/docs/hosted-dispatcher.md +17 -2
- package/docs/hosted-service.md +240 -0
- package/docs/mcp.md +8 -2
- package/docs/operator-api.md +97 -7
- package/docs/remote-store-protocol.md +199 -144
- package/docs/remote-ticket-protocol.md +249 -19
- package/docs/setup.md +199 -16
- package/docs/ticket-dependencies.md +1 -1
- package/docs/ticket-migration.md +64 -0
- package/package.json +7 -6
- package/packages/core/src/cli/attention.ts +209 -0
- package/packages/core/src/cli/build-control.ts +12 -1
- package/packages/core/src/cli/configured-store.ts +17 -0
- package/packages/core/src/cli/connect.ts +364 -0
- package/packages/core/src/cli/context.ts +7 -1
- package/packages/core/src/cli/dashboard/model.ts +4 -5
- package/packages/core/src/cli/dashboard/poll.ts +1 -1
- package/packages/core/src/cli/dispatch.ts +336 -41
- package/packages/core/src/cli/harvest.ts +13 -3
- package/packages/core/src/cli/help.ts +51 -16
- package/packages/core/src/cli/init-validation.ts +27 -2
- package/packages/core/src/cli/installation.ts +67 -13
- package/packages/core/src/cli/main.ts +60 -16
- package/packages/core/src/cli/mcp.ts +9 -2
- package/packages/core/src/cli/pr-summary-publication.ts +220 -0
- package/packages/core/src/cli/pr-summary.ts +2 -0
- package/packages/core/src/cli/project-credentials.ts +132 -0
- package/packages/core/src/cli/repo-state.ts +15 -2
- package/packages/core/src/cli/self-update.ts +15 -1
- package/packages/core/src/cli/store-opening.ts +22 -14
- package/packages/core/src/cli/terminals.ts +0 -10
- package/packages/core/src/cli/ticket-assets.ts +7 -2
- package/packages/core/src/cli/ticket-editor.ts +82 -0
- package/packages/core/src/cli/ticket.ts +172 -35
- package/packages/core/src/cli/upgrade-compatibility.ts +80 -0
- package/packages/core/src/cli/wait.ts +84 -17
- package/packages/core/src/cli/watch.ts +197 -60
- package/packages/core/src/config/live.ts +3 -0
- package/packages/core/src/config/schema.ts +90 -11
- package/packages/core/src/connection.ts +86 -0
- package/packages/core/src/events/catalog.ts +11 -2
- package/packages/core/src/events/payloads.ts +50 -0
- package/packages/core/src/events/repository.ts +13 -0
- package/packages/core/src/integration/harness.ts +37 -2
- package/packages/core/src/kernel/__snapshots__/reducer-registry.test.ts.snap +145 -83
- package/packages/core/src/kernel/engine.ts +24 -0
- package/packages/core/src/kernel/generators/build-log.ts +11 -1
- package/packages/core/src/kernel/incremental-fixtures.ts +13 -2
- package/packages/core/src/kernel/log-index.ts +17 -0
- package/packages/core/src/kernel/pr-summary.ts +89 -0
- package/packages/core/src/kernel/reducer-registry.ts +10 -0
- package/packages/core/src/kernel/reducer.ts +8 -0
- package/packages/core/src/ontology.ts +2 -0
- package/packages/core/src/operator/annotations.ts +12 -0
- package/packages/core/src/operator/index.ts +12 -0
- package/packages/core/src/operator/protocol.ts +19 -2
- package/packages/core/src/operator/query.ts +38 -16
- package/packages/core/src/operator/registry.ts +61 -8
- package/packages/core/src/operator/ticket-source-backend.ts +17 -20
- package/packages/core/src/operator/tickets.ts +136 -28
- package/packages/core/src/plugin-sdk/index.ts +7 -0
- package/packages/core/src/plugins/manifest.ts +5 -3
- package/packages/core/src/plugins/runtimes.ts +7 -1
- package/packages/core/src/ports/forge/fake.ts +13 -1
- package/packages/core/src/ports/forge/github-transport.ts +36 -1
- package/packages/core/src/ports/forge/github.ts +29 -5
- package/packages/core/src/ports/forge/local-git.ts +12 -4
- package/packages/core/src/ports/forge/summary-marker.ts +8 -0
- package/packages/core/src/ports/runner/session-env.ts +3 -0
- package/packages/core/src/ports/tickets/contract.ts +66 -1
- package/packages/core/src/ports/tickets/create.ts +54 -10
- package/packages/core/src/ports/tickets/fake.ts +30 -1
- package/packages/core/src/ports/tickets/file-assets.ts +159 -0
- package/packages/core/src/ports/tickets/file-codec.ts +90 -0
- package/packages/core/src/ports/tickets/file.ts +114 -120
- package/packages/core/src/ports/tickets/grants.ts +47 -0
- package/packages/core/src/ports/tickets/hosted-index.ts +17 -3
- package/packages/core/src/ports/tickets/identity.ts +20 -0
- package/packages/core/src/ports/tickets/lifecycle.ts +18 -0
- package/packages/core/src/ports/tickets/linear.ts +74 -9
- package/packages/core/src/ports/tickets/migration.ts +336 -0
- package/packages/core/src/ports/tickets/operations.ts +38 -7
- package/packages/core/src/ports/tickets/portable-assets.ts +54 -0
- package/packages/core/src/ports/tickets/remote-protocol.ts +96 -0
- package/packages/core/src/ports/tickets/remote.ts +61 -3
- package/packages/core/src/ports/tickets/requested-id.ts +46 -0
- package/packages/core/src/ports/tickets/revision.ts +62 -0
- package/packages/core/src/ports/tickets/states.ts +61 -0
- package/packages/core/src/ports/types.ts +40 -3
- package/packages/core/src/ports/workspace/build-execution.ts +6 -1
- package/packages/core/src/ports/workspace/create.ts +15 -2
- package/packages/core/src/ports/workspace/distribution-archive.ts +3 -5
- package/packages/core/src/ports/workspace/harvest-execution.ts +3 -1
- package/packages/core/src/ports/workspace/local-build-execution.ts +8 -1
- package/packages/core/src/ports/workspace/operator-sandbox.ts +2 -0
- package/packages/core/src/ports/workspace/vercel-sandbox.ts +51 -19
- package/packages/core/src/processes/build-execution-state.ts +1 -1
- package/packages/core/src/processes/build-runner.ts +87 -26
- package/packages/core/src/processes/dispatch-failure-budget.ts +83 -0
- package/packages/core/src/processes/dispatcher.ts +168 -41
- package/packages/core/src/processes/execution-no-progress.ts +176 -0
- package/packages/core/src/processes/harvest-child.ts +31 -4
- package/packages/core/src/processes/harvest-execution-state.ts +19 -2
- package/packages/core/src/processes/harvest-runner.ts +166 -104
- package/packages/core/src/processes/harvest.ts +34 -14
- package/packages/core/src/processes/phase-failure-budget.ts +2 -0
- package/packages/core/src/processes/publication-loss.ts +18 -1
- package/packages/core/src/processes/repo-view.ts +35 -0
- package/packages/core/src/store/contract.ts +32 -1
- package/packages/core/src/store/local/store.ts +1 -1
- package/packages/core/src/store/remote/client.ts +1 -1
- package/packages/core/src/store/remote/compatibility.ts +90 -0
- package/packages/core/src/store/remote/credentials.ts +28 -0
- package/packages/core/src/store/remote/index.ts +14 -1
- package/packages/core/src/store/remote/protocol.ts +25 -0
- package/packages/core/src/store/remote/token.ts +74 -13
- package/packages/core/src/store/ticket-asset-snapshots.ts +192 -0
- package/packages/core/src/store/ticket-assets.ts +86 -8
- package/packages/core/src/store/types.ts +1 -1
- package/packages/core/src/testing/index.ts +7 -0
- package/skills/guide/SKILL.md +232 -34
- package/skills/guide/references/operator-api.md +142 -13
- package/skills/guide/references/plugin-authoring.md +10 -2
- package/skills/guide/references/remote-store-protocol.md +199 -144
- package/skills/guide/references/remote-ticket-protocol.md +253 -18
- package/skills/guide/references/setup.md +199 -16
- package/skills/guide/references/ticket-dependencies.md +1 -1
- package/skills/guide/references/ticket-migration.md +64 -0
- package/skills/operate/SKILL.md +126 -36
- package/skills/tickets/SKILL.md +71 -0
- package/templates/hosted-service/package.json +13 -0
- package/templates/hosted-service/vercel.json +7 -0
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
import { summaryBoundary, pendingSummaryRequests } from '../kernel/pr-summary'
|
|
2
|
+
export { summaryBoundary, pendingSummaryRequests } from '../kernel/pr-summary'
|
|
3
|
+
import type { AbEvent } from '../events/catalog'
|
|
4
|
+
import { KERNEL } from '../events/envelope'
|
|
5
|
+
import { isRemoteWorkspace } from '../events/workspace-remote'
|
|
6
|
+
import { reduceBuild } from '../kernel/reducer'
|
|
7
|
+
import { currentPrAttachments } from '../kernel/pr-attachments'
|
|
8
|
+
import { openExecution } from '../processes/execution-settlement'
|
|
9
|
+
import type { Forge } from '../ports/types'
|
|
10
|
+
import { githubRateLimitUntil } from '../ports/forge/github-transport'
|
|
11
|
+
import { summaryMarker } from '../ports/forge/summary-marker'
|
|
12
|
+
import type { BuildStore, Clock } from '../store/types'
|
|
13
|
+
import { html, shellQuote, renderPrSummary } from './pr-summary'
|
|
14
|
+
|
|
15
|
+
export const SUMMARY_LIMIT = 65_536
|
|
16
|
+
|
|
17
|
+
interface SummaryDeps {
|
|
18
|
+
store: BuildStore
|
|
19
|
+
forge: Forge
|
|
20
|
+
slug: string
|
|
21
|
+
workspacePath: string
|
|
22
|
+
storeRef: string
|
|
23
|
+
clock: Clock
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** No credentials are accepted by the renderer. The full oversized projection
|
|
27
|
+
* is deliberately not designated, avoiding recursive growth. */
|
|
28
|
+
export async function boundedSummary(
|
|
29
|
+
deps: SummaryDeps,
|
|
30
|
+
events: readonly AbEvent[],
|
|
31
|
+
): Promise<string> {
|
|
32
|
+
const env = {
|
|
33
|
+
build: deps.slug,
|
|
34
|
+
store: deps.storeRef,
|
|
35
|
+
phase: 'finalize' as const,
|
|
36
|
+
round: 1,
|
|
37
|
+
session: '',
|
|
38
|
+
}
|
|
39
|
+
const full = renderPrSummary(env, events)
|
|
40
|
+
if (full.length <= SUMMARY_LIMIT) return full
|
|
41
|
+
const meta = await deps.store.putArtifact(deps.slug, {
|
|
42
|
+
kind: 'pr-summary',
|
|
43
|
+
content: full,
|
|
44
|
+
metadata: {},
|
|
45
|
+
})
|
|
46
|
+
const command = `ab artifact download ${shellQuote(deps.slug)} ${shellQuote(`pr-summary@${meta.revision}`)} --store ${shellQuote(deps.storeRef)} --output pr-summary.md`
|
|
47
|
+
const compact = [
|
|
48
|
+
summaryMarker(deps.slug),
|
|
49
|
+
`## Autobuild: ${html(deps.slug)}`,
|
|
50
|
+
'',
|
|
51
|
+
`The full summary exceeds the comment limit. It includes ${currentPrAttachments(events).length} PR attachments.`,
|
|
52
|
+
`Store: <code>${html(deps.storeRef)}</code>`,
|
|
53
|
+
'',
|
|
54
|
+
'Download the complete summary, including all pinned artifact commands and hosted links:',
|
|
55
|
+
`<pre><code>${html(command)}</code></pre>`,
|
|
56
|
+
].join('\n')
|
|
57
|
+
if (compact.length > SUMMARY_LIMIT) throw new Error('Summary locator exceeds comment limit')
|
|
58
|
+
return compact
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** Caller owns the exclusive build lease and has fenced guest execution.
|
|
62
|
+
* Claim-before-effect plus CAS also fences overlapping refresh triggers. */
|
|
63
|
+
export async function publishSummary(
|
|
64
|
+
deps: SummaryDeps,
|
|
65
|
+
boundary: { occurrence: number; boundary: number },
|
|
66
|
+
): Promise<'attempted' | 'skipped' | 'deferred'> {
|
|
67
|
+
try {
|
|
68
|
+
const events = await deps.store.getEvents(deps.slug)
|
|
69
|
+
const pr = reduceBuild(events).pr
|
|
70
|
+
if (pr === undefined) return 'skipped'
|
|
71
|
+
if (
|
|
72
|
+
events.some(
|
|
73
|
+
(event) =>
|
|
74
|
+
event.type === 'pr-summary.attempted' && event.payload.occurrence === boundary.occurrence,
|
|
75
|
+
)
|
|
76
|
+
)
|
|
77
|
+
return 'attempted'
|
|
78
|
+
if (
|
|
79
|
+
events.some(
|
|
80
|
+
(event) =>
|
|
81
|
+
event.type === 'pr-summary.result' &&
|
|
82
|
+
event.payload.eligibleAfter !== undefined &&
|
|
83
|
+
Date.parse(event.payload.eligibleAfter) > deps.clock().getTime(),
|
|
84
|
+
)
|
|
85
|
+
)
|
|
86
|
+
return 'deferred'
|
|
87
|
+
const claim = await deps.store.appendIfCurrent(deps.slug, events.at(-1)?.seq ?? 0, {
|
|
88
|
+
actor: KERNEL,
|
|
89
|
+
type: 'pr-summary.attempted',
|
|
90
|
+
payload: { ...boundary, pr: pr.number },
|
|
91
|
+
})
|
|
92
|
+
if (claim === null) return 'skipped'
|
|
93
|
+
let outcome: 'success' | 'failure' | 'rate-limited' = 'success'
|
|
94
|
+
let eligibleAfter: string | undefined
|
|
95
|
+
try {
|
|
96
|
+
const body = await boundedSummary(deps, await deps.store.getEvents(deps.slug))
|
|
97
|
+
await deps.forge.commentOnPr(deps.workspacePath, pr.number, body)
|
|
98
|
+
} catch (error) {
|
|
99
|
+
eligibleAfter = githubRateLimitUntil(error, deps.clock().getTime())
|
|
100
|
+
outcome = eligibleAfter === undefined ? 'failure' : 'rate-limited'
|
|
101
|
+
}
|
|
102
|
+
await deps.store.append(deps.slug, {
|
|
103
|
+
actor: KERNEL,
|
|
104
|
+
type: 'pr-summary.result',
|
|
105
|
+
payload: {
|
|
106
|
+
attempt: claim.seq,
|
|
107
|
+
outcome,
|
|
108
|
+
...(eligibleAfter === undefined ? {} : { eligibleAfter }),
|
|
109
|
+
},
|
|
110
|
+
})
|
|
111
|
+
return 'attempted'
|
|
112
|
+
} catch {
|
|
113
|
+
return 'skipped'
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** Local boundary or durable guest handoff. Optional refresh failures never
|
|
118
|
+
* prevent pipeline progress, including a failed request deposit. */
|
|
119
|
+
export async function refreshSummaryBoundary(deps: SummaryDeps): Promise<boolean> {
|
|
120
|
+
try {
|
|
121
|
+
const events = await deps.store.getEvents(deps.slug)
|
|
122
|
+
const boundary = summaryBoundary(events)
|
|
123
|
+
if (boundary === undefined) return false
|
|
124
|
+
const workspace = events.findLast((event) => event.type === 'workspace.provisioned')
|
|
125
|
+
if (workspace?.type === 'workspace.provisioned' && isRemoteWorkspace(workspace.payload)) {
|
|
126
|
+
const request = events.find(
|
|
127
|
+
(event) =>
|
|
128
|
+
event.type === 'pr-summary.requested' && event.payload.occurrence === boundary.occurrence,
|
|
129
|
+
)
|
|
130
|
+
if (request !== undefined) {
|
|
131
|
+
// A lost result/disposition append must not repeatedly park a guest.
|
|
132
|
+
if (
|
|
133
|
+
events.some(
|
|
134
|
+
(event) =>
|
|
135
|
+
event.type === 'pr-summary.attempted' &&
|
|
136
|
+
event.payload.occurrence === boundary.occurrence,
|
|
137
|
+
)
|
|
138
|
+
)
|
|
139
|
+
return false
|
|
140
|
+
if (
|
|
141
|
+
events.some(
|
|
142
|
+
(event) =>
|
|
143
|
+
event.type === 'pr-summary.result' &&
|
|
144
|
+
event.payload.eligibleAfter !== undefined &&
|
|
145
|
+
Date.parse(event.payload.eligibleAfter) > deps.clock().getTime(),
|
|
146
|
+
)
|
|
147
|
+
)
|
|
148
|
+
return false
|
|
149
|
+
return pendingSummaryRequests(events).some((event) => event.seq === request.seq)
|
|
150
|
+
}
|
|
151
|
+
if (
|
|
152
|
+
reduceBuild(events).pr === undefined &&
|
|
153
|
+
!events.some(
|
|
154
|
+
(event) =>
|
|
155
|
+
event.type === 'publication.requested' && event.payload.operation === 'finalize',
|
|
156
|
+
)
|
|
157
|
+
)
|
|
158
|
+
return false
|
|
159
|
+
const written = await deps.store.appendIfCurrent(deps.slug, events.at(-1)?.seq ?? 0, {
|
|
160
|
+
actor: KERNEL,
|
|
161
|
+
type: 'pr-summary.requested',
|
|
162
|
+
payload: boundary,
|
|
163
|
+
})
|
|
164
|
+
return written !== null
|
|
165
|
+
}
|
|
166
|
+
await publishSummary(deps, boundary)
|
|
167
|
+
} catch {
|
|
168
|
+
/* Optional publication cannot fail phase execution. */
|
|
169
|
+
}
|
|
170
|
+
return false
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/** Host-only, after execution settlement. Reuse a launch reservation only
|
|
174
|
+
* before execution.start; otherwise acquire and release our own lease. */
|
|
175
|
+
export async function settleSummaryRequests(
|
|
176
|
+
deps: SummaryDeps & { holder: string; reservation?: string },
|
|
177
|
+
): Promise<void> {
|
|
178
|
+
let acquired = false
|
|
179
|
+
let heartbeat: ReturnType<typeof setInterval> | undefined
|
|
180
|
+
try {
|
|
181
|
+
let events = await deps.store.getEvents(deps.slug)
|
|
182
|
+
if (openExecution(events) !== null) return
|
|
183
|
+
// Repair a crash after terminal/session completion but before guest handoff.
|
|
184
|
+
const workspace = events.findLast((event) => event.type === 'workspace.provisioned')
|
|
185
|
+
if (workspace?.type !== 'workspace.provisioned' || !isRemoteWorkspace(workspace.payload)) return
|
|
186
|
+
const holder = deps.reservation ?? deps.holder
|
|
187
|
+
acquired = await deps.store.claimLease(deps.slug, holder, 60_000)
|
|
188
|
+
if (!acquired) return
|
|
189
|
+
heartbeat = setInterval(() => {
|
|
190
|
+
void deps.store.heartbeat(deps.slug, holder).catch(() => {})
|
|
191
|
+
}, 15_000)
|
|
192
|
+
events = await deps.store.getEvents(deps.slug)
|
|
193
|
+
if (openExecution(events) !== null) return
|
|
194
|
+
await refreshSummaryBoundary(deps)
|
|
195
|
+
events = await deps.store.getEvents(deps.slug)
|
|
196
|
+
const requests = pendingSummaryRequests(events)
|
|
197
|
+
const latest = requests.at(-1)
|
|
198
|
+
for (const request of requests) {
|
|
199
|
+
if (request.type !== 'pr-summary.requested') continue
|
|
200
|
+
const disposition =
|
|
201
|
+
request === latest ? await publishSummary(deps, request.payload) : 'skipped'
|
|
202
|
+
await deps.store.append(deps.slug, {
|
|
203
|
+
actor: KERNEL,
|
|
204
|
+
type: 'pr-summary.settled',
|
|
205
|
+
payload: { request: request.seq, disposition },
|
|
206
|
+
})
|
|
207
|
+
}
|
|
208
|
+
} catch {
|
|
209
|
+
/* Best effort, separate from branch publication and phase outcome. */
|
|
210
|
+
} finally {
|
|
211
|
+
if (heartbeat !== undefined) clearInterval(heartbeat)
|
|
212
|
+
if (acquired && deps.reservation === undefined) {
|
|
213
|
+
try {
|
|
214
|
+
await deps.store.releaseLease(deps.slug, deps.holder)
|
|
215
|
+
} catch {
|
|
216
|
+
/* lease expires */
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
}
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { summaryMarker } from '../ports/forge/summary-marker'
|
|
1
2
|
import type { AbEvent } from '../events/catalog'
|
|
2
3
|
import {
|
|
3
4
|
currentPrAttachments,
|
|
@@ -106,6 +107,7 @@ export function renderPrSummary(env: CliEnv, events: readonly AbEvent[]): string
|
|
|
106
107
|
}
|
|
107
108
|
|
|
108
109
|
return [
|
|
110
|
+
summaryMarker(env.build),
|
|
109
111
|
`## Autobuild: ${html(env.build)}`,
|
|
110
112
|
'',
|
|
111
113
|
'### Verdict history',
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
import { createHash, randomUUID } from 'node:crypto'
|
|
2
|
+
import {
|
|
3
|
+
constants,
|
|
4
|
+
fstatSync,
|
|
5
|
+
chmodSync,
|
|
6
|
+
lstatSync,
|
|
7
|
+
mkdirSync,
|
|
8
|
+
openSync,
|
|
9
|
+
closeSync,
|
|
10
|
+
readFileSync,
|
|
11
|
+
writeFileSync,
|
|
12
|
+
renameSync,
|
|
13
|
+
unlinkSync,
|
|
14
|
+
} from 'node:fs'
|
|
15
|
+
import { homedir } from 'node:os'
|
|
16
|
+
import { isAbsolute, join } from 'node:path'
|
|
17
|
+
import { normalizeServiceURL } from '../connection'
|
|
18
|
+
import { normalizeGitRemoteUrl } from '../kernel/origin'
|
|
19
|
+
|
|
20
|
+
export interface ProjectCredential {
|
|
21
|
+
service: string
|
|
22
|
+
repo: string
|
|
23
|
+
id: string
|
|
24
|
+
token: string
|
|
25
|
+
}
|
|
26
|
+
export class ProjectCredentials {
|
|
27
|
+
readonly directory: string
|
|
28
|
+
constructor(env: Record<string, string | undefined> = process.env) {
|
|
29
|
+
const root = env.XDG_CONFIG_HOME
|
|
30
|
+
if (root && !isAbsolute(root))
|
|
31
|
+
throw new Error('XDG_CONFIG_HOME must be absolute for project credentials')
|
|
32
|
+
this.directory = join(
|
|
33
|
+
root ||
|
|
34
|
+
(env.HOME && isAbsolute(env.HOME) ? join(env.HOME, '.config') : join(homedir(), '.config')),
|
|
35
|
+
'autobuild',
|
|
36
|
+
'credentials',
|
|
37
|
+
)
|
|
38
|
+
}
|
|
39
|
+
private path(service: string, repo: string): string {
|
|
40
|
+
return join(
|
|
41
|
+
this.directory,
|
|
42
|
+
`${createHash('sha256')
|
|
43
|
+
.update(JSON.stringify([normalizeServiceURL(service), normalizeGitRemoteUrl(repo)]))
|
|
44
|
+
.digest('hex')}.json`,
|
|
45
|
+
)
|
|
46
|
+
}
|
|
47
|
+
private guard(path: string): boolean {
|
|
48
|
+
try {
|
|
49
|
+
const info = lstatSync(path)
|
|
50
|
+
if (!info.isFile() || info.isSymbolicLink()) throw new Error('unsafe credential file')
|
|
51
|
+
return true
|
|
52
|
+
} catch (error) {
|
|
53
|
+
if ((error as NodeJS.ErrnoException).code === 'ENOENT') return false
|
|
54
|
+
throw error
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
load(service: string, repo: string): ProjectCredential | undefined {
|
|
58
|
+
try {
|
|
59
|
+
const path = this.path(service, repo)
|
|
60
|
+
if (!this.guard(path)) return undefined
|
|
61
|
+
const directory = lstatSync(this.directory)
|
|
62
|
+
if (directory.isSymbolicLink() || (directory.mode & 0o777) !== 0o700) throw new Error()
|
|
63
|
+
const fd = openSync(path, constants.O_RDONLY | constants.O_NOFOLLOW)
|
|
64
|
+
let entry: ProjectCredential
|
|
65
|
+
try {
|
|
66
|
+
if ((fstatSync(fd).mode & 0o777) !== 0o600) throw new Error()
|
|
67
|
+
entry = JSON.parse(readFileSync(fd, 'utf8'))
|
|
68
|
+
} finally {
|
|
69
|
+
closeSync(fd)
|
|
70
|
+
}
|
|
71
|
+
if (
|
|
72
|
+
entry.service !== normalizeServiceURL(service) ||
|
|
73
|
+
entry.repo !== normalizeGitRemoteUrl(repo) ||
|
|
74
|
+
typeof entry.id !== 'string' ||
|
|
75
|
+
typeof entry.token !== 'string' ||
|
|
76
|
+
!/^[a-f0-9]{32}$/.test(entry.id) ||
|
|
77
|
+
!new RegExp(`^abp_1_${entry.id}_[a-f0-9]{64}$`).test(entry.token)
|
|
78
|
+
)
|
|
79
|
+
throw new Error()
|
|
80
|
+
return entry
|
|
81
|
+
} catch {
|
|
82
|
+
throw new Error(
|
|
83
|
+
'Cannot read project credential; repair user credential storage and run ab connect',
|
|
84
|
+
)
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
save(entry: ProjectCredential): void {
|
|
88
|
+
const path = this.path(entry.service, entry.repo)
|
|
89
|
+
let temporary: string | undefined
|
|
90
|
+
try {
|
|
91
|
+
mkdirSync(this.directory, { recursive: true, mode: 0o700 })
|
|
92
|
+
if (lstatSync(this.directory).isSymbolicLink()) throw new Error()
|
|
93
|
+
chmodSync(this.directory, 0o700)
|
|
94
|
+
this.guard(path)
|
|
95
|
+
temporary = `${path}.${randomUUID()}`
|
|
96
|
+
const fd = openSync(temporary, 'wx', 0o600)
|
|
97
|
+
try {
|
|
98
|
+
writeFileSync(fd, `${JSON.stringify(entry)}\n`)
|
|
99
|
+
} finally {
|
|
100
|
+
closeSync(fd)
|
|
101
|
+
}
|
|
102
|
+
renameSync(temporary, path)
|
|
103
|
+
} catch {
|
|
104
|
+
throw new Error('Cannot save project credential; check user credential directory permissions')
|
|
105
|
+
} finally {
|
|
106
|
+
if (temporary) {
|
|
107
|
+
try {
|
|
108
|
+
unlinkSync(temporary)
|
|
109
|
+
} catch {
|
|
110
|
+
/* renamed or absent */
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
remove(service: string, repo: string): void {
|
|
116
|
+
const path = this.path(service, repo)
|
|
117
|
+
if (this.guard(path)) unlinkSync(path)
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
export function projectToken(
|
|
121
|
+
service: string,
|
|
122
|
+
repo: string,
|
|
123
|
+
env: Record<string, string | undefined>,
|
|
124
|
+
): string | undefined {
|
|
125
|
+
if (env.AB_TOKEN !== undefined && env.AB_TOKEN !== '') return env.AB_TOKEN
|
|
126
|
+
try {
|
|
127
|
+
normalizeServiceURL(service)
|
|
128
|
+
} catch {
|
|
129
|
+
return undefined
|
|
130
|
+
}
|
|
131
|
+
return new ProjectCredentials(env).load(service, repo)?.token
|
|
132
|
+
}
|
|
@@ -20,6 +20,7 @@
|
|
|
20
20
|
* normalized against that checkout so the dispatcher and agents cannot
|
|
21
21
|
* interpret a relative path from different working directories.
|
|
22
22
|
*/
|
|
23
|
+
import { configuredStore } from './configured-store'
|
|
23
24
|
import { basename, dirname, isAbsolute, join, resolve } from 'node:path'
|
|
24
25
|
import type { Exec } from '../ports/workspace/git-worktree'
|
|
25
26
|
import type { BuildRecord } from '../store/types'
|
|
@@ -92,6 +93,8 @@ export async function resolveMainRepo(targetRepo: string, exec: Exec): Promise<s
|
|
|
92
93
|
}
|
|
93
94
|
|
|
94
95
|
export interface RepoStatePaths {
|
|
96
|
+
/** Validated repository setting used for this selection, when present. */
|
|
97
|
+
configStore?: string
|
|
95
98
|
/** Repository identity in BuildStore records/journals: the checkout's
|
|
96
99
|
* normalized origin URL, falling back to the resolved checkout path only
|
|
97
100
|
* when the checkout has no origin remote. Opaque to the store schema. */
|
|
@@ -116,7 +119,7 @@ function nonBlank(value: string | undefined): string | undefined {
|
|
|
116
119
|
|
|
117
120
|
/**
|
|
118
121
|
* Select state with one precedence rule for every sessionless command:
|
|
119
|
-
* non-blank explicit `--store` > non-blank `AB_STORE` >
|
|
122
|
+
* non-blank explicit `--store` > non-blank `AB_STORE` > `[store] url` > local default.
|
|
120
123
|
*
|
|
121
124
|
* A local selection relocates the whole local tree, including worktrees. A
|
|
122
125
|
* remote store has no filesystem root, so its worktrees remain local beneath
|
|
@@ -130,14 +133,20 @@ export function resolveRepoStatePaths(opts: {
|
|
|
130
133
|
checkout?: string
|
|
131
134
|
storeRef?: string
|
|
132
135
|
envStore?: string
|
|
136
|
+
configStore?: string
|
|
133
137
|
}): RepoStatePaths {
|
|
134
138
|
const checkout = resolve(opts.checkout ?? opts.repo)
|
|
135
139
|
const defaultLocalRoot = join(checkout, LOCAL_STATE_DIR)
|
|
136
|
-
const selected =
|
|
140
|
+
const selected =
|
|
141
|
+
nonBlank(opts.storeRef) ??
|
|
142
|
+
nonBlank(opts.envStore) ??
|
|
143
|
+
nonBlank(opts.configStore) ??
|
|
144
|
+
defaultLocalRoot
|
|
137
145
|
const remote = isRemoteStoreRef(selected)
|
|
138
146
|
const storeRef = remote ? selected : resolve(checkout, selected)
|
|
139
147
|
const localStateRoot = remote ? defaultLocalRoot : storeRef
|
|
140
148
|
return {
|
|
149
|
+
...(opts.configStore !== undefined ? { configStore: opts.configStore } : {}),
|
|
141
150
|
repo: opts.repo,
|
|
142
151
|
checkout,
|
|
143
152
|
defaultLocalRoot,
|
|
@@ -223,10 +232,14 @@ export async function resolveRepoState(opts: {
|
|
|
223
232
|
exec: Exec
|
|
224
233
|
storeRef?: string
|
|
225
234
|
envStore?: string
|
|
235
|
+
configStore?: string
|
|
226
236
|
}): Promise<RepoStatePaths> {
|
|
227
237
|
const checkout = await resolveMainRepo(opts.targetRepo, opts.exec)
|
|
228
238
|
const origin = await resolveRepoOrigin(checkout, opts.exec)
|
|
229
239
|
return resolveRepoStatePaths({
|
|
240
|
+
configStore:
|
|
241
|
+
opts.configStore ??
|
|
242
|
+
(nonBlank(opts.storeRef) || nonBlank(opts.envStore) ? undefined : configuredStore(checkout)),
|
|
230
243
|
repo: origin ?? checkout,
|
|
231
244
|
checkout,
|
|
232
245
|
...(opts.storeRef !== undefined ? { storeRef: opts.storeRef } : {}),
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { join } from 'node:path'
|
|
2
|
+
import { checkUpgradeCompatibility, type CompatibilityFetch } from './upgrade-compatibility'
|
|
1
3
|
import { readFile, writeFile } from 'node:fs/promises'
|
|
2
4
|
import semver from 'semver'
|
|
3
5
|
import type { ExecResult } from '../ports/workspace/git-worktree'
|
|
@@ -71,6 +73,7 @@ export function registryVersion(response: { status: number; body: string }): str
|
|
|
71
73
|
|
|
72
74
|
export interface SelfUpdateOptions {
|
|
73
75
|
targetRepo: string
|
|
76
|
+
compatibilityFetch?: CompatibilityFetch
|
|
74
77
|
version?: string
|
|
75
78
|
distRoot?: string
|
|
76
79
|
env?: Record<string, string | undefined>
|
|
@@ -361,6 +364,7 @@ export async function selfUpdate(options: SelfUpdateOptions): Promise<SelfUpdate
|
|
|
361
364
|
const inspection = await inspectInstallation({
|
|
362
365
|
distRoot,
|
|
363
366
|
globalBin: globalBinResult.stdout.trim(),
|
|
367
|
+
cwd: options.targetRepo,
|
|
364
368
|
})
|
|
365
369
|
if (inspection.kind !== 'bun-forge' && inspection.kind !== 'npm-registry') {
|
|
366
370
|
return explicit ? fail(options, inspection.reason) : warn(options, inspection.reason)
|
|
@@ -393,6 +397,14 @@ export async function selfUpdate(options: SelfUpdateOptions): Promise<SelfUpdate
|
|
|
393
397
|
return { kind: 'continue' }
|
|
394
398
|
}
|
|
395
399
|
|
|
400
|
+
await checkUpgradeCompatibility({
|
|
401
|
+
targetRepo: options.targetRepo,
|
|
402
|
+
version: resolved,
|
|
403
|
+
env: options.env,
|
|
404
|
+
stderr: options.stderr,
|
|
405
|
+
fetch: options.compatibilityFetch,
|
|
406
|
+
})
|
|
407
|
+
|
|
396
408
|
const dependency = updateDependency(install, resolved)
|
|
397
409
|
let updateCommand: string[]
|
|
398
410
|
if (install.scope === 'global') {
|
|
@@ -459,7 +471,9 @@ export async function selfUpdate(options: SelfUpdateOptions): Promise<SelfUpdate
|
|
|
459
471
|
|
|
460
472
|
let updated: DistributionIdentity
|
|
461
473
|
try {
|
|
462
|
-
updated = await readDistributionIdentity(
|
|
474
|
+
updated = await readDistributionIdentity(
|
|
475
|
+
join(install.ownerRoot, 'node_modules', ...install.packageName.split('/')),
|
|
476
|
+
)
|
|
463
477
|
// Injected command fixtures do not rewrite disk; a real successful Bun
|
|
464
478
|
// operation must. Keep the check injectable by accepting the old identity
|
|
465
479
|
// only when the command seam itself was supplied.
|
|
@@ -6,6 +6,8 @@
|
|
|
6
6
|
* joins those seams to the production remote adapter and owns finite-command
|
|
7
7
|
* store lifecycle.
|
|
8
8
|
*/
|
|
9
|
+
import { projectToken } from './project-credentials'
|
|
10
|
+
import { isRemoteStoreRef } from './repo-state'
|
|
9
11
|
import type { Exec } from '../ports/workspace/git-worktree'
|
|
10
12
|
import { RemoteBuildStore } from '../store/remote/client'
|
|
11
13
|
import { scopeLocalStoreToPhaseSession } from '../store/phase-session'
|
|
@@ -16,7 +18,7 @@ import {
|
|
|
16
18
|
type CliEnv,
|
|
17
19
|
type HarvestCliEnv,
|
|
18
20
|
} from './env'
|
|
19
|
-
import {
|
|
21
|
+
import { resolveRepoState, type RepoStatePaths } from './repo-state'
|
|
20
22
|
import { resolveStore } from './store-ref'
|
|
21
23
|
|
|
22
24
|
/** Shared injection seam used by every command that opens a BuildStore. */
|
|
@@ -39,15 +41,14 @@ export const openProductionStore: StoreOpener = (ref, token) => {
|
|
|
39
41
|
}
|
|
40
42
|
|
|
41
43
|
/** Apply validated ambient authority to a selected Store handle. Remote
|
|
42
|
-
* handles retain
|
|
43
|
-
* the
|
|
44
|
+
* handles retain server-side project authorization; every handle receives
|
|
45
|
+
* the same in-process build/session scope. Keeping this primitive shared prevents
|
|
44
46
|
* phase commands and ambient-aware query commands from drifting. */
|
|
45
47
|
function scopeSelectedStoreToPhaseSession(
|
|
46
48
|
store: BuildStore,
|
|
47
|
-
|
|
49
|
+
_ref: string,
|
|
48
50
|
env: AmbientReadSession,
|
|
49
51
|
): BuildStore {
|
|
50
|
-
if (isRemoteStoreRef(ref)) return store
|
|
51
52
|
return scopeLocalStoreToPhaseSession(
|
|
52
53
|
store,
|
|
53
54
|
'build' in env
|
|
@@ -65,15 +66,15 @@ export interface OpenedStoreContext extends RepoStatePaths {
|
|
|
65
66
|
store: BuildStore
|
|
66
67
|
token?: string
|
|
67
68
|
/** The validated ambient phase/Harvest identity this invocation carries, if
|
|
68
|
-
* any — the same value that scoped `store
|
|
69
|
-
*
|
|
70
|
-
* where no session wrapper exists. */
|
|
69
|
+
* any — the same value that scoped `store`. Exposed for commands whose
|
|
70
|
+
* repository selection also depends on ambient own-build authority. */
|
|
71
71
|
ambient?: AmbientReadSession
|
|
72
72
|
}
|
|
73
73
|
|
|
74
74
|
export interface OpenStoreForRepoStateOpts {
|
|
75
75
|
env: Record<string, string | undefined>
|
|
76
76
|
openStore?: StoreOpener
|
|
77
|
+
useProjectCredential?: boolean
|
|
77
78
|
}
|
|
78
79
|
|
|
79
80
|
/** Open from an already canonicalized state selection. Dispatch uses this form
|
|
@@ -82,7 +83,13 @@ export function openStoreForRepoState(
|
|
|
82
83
|
state: RepoStatePaths,
|
|
83
84
|
opts: OpenStoreForRepoStateOpts,
|
|
84
85
|
): OpenedStoreContext {
|
|
85
|
-
const token =
|
|
86
|
+
const token =
|
|
87
|
+
opts.useProjectCredential !== false && isRemoteStoreRef(state.storeRef)
|
|
88
|
+
? projectToken(state.storeRef, state.repo, opts.env)
|
|
89
|
+
: optionalToken(opts.env.AB_TOKEN)
|
|
90
|
+
if (!token && isRemoteStoreRef(state.storeRef) && state.configStore === state.storeRef) {
|
|
91
|
+
throw new Error('No project credential for this service; run ab connect')
|
|
92
|
+
}
|
|
86
93
|
const store = (opts.openStore ?? openProductionStore)(state.storeRef, token)
|
|
87
94
|
return {
|
|
88
95
|
...state,
|
|
@@ -115,10 +122,8 @@ export async function openSessionlessStore(
|
|
|
115
122
|
/** Finite command ownership boundary. Opening failures occur before the
|
|
116
123
|
* try/finally, so only a successfully returned handle is ever closed.
|
|
117
124
|
*
|
|
118
|
-
*
|
|
119
|
-
*
|
|
120
|
-
* consume `ambient` from the context directly rather than rely on the handle
|
|
121
|
-
* enforcing a scope. */
|
|
125
|
+
* Ambient query openers apply in-process build/session scope to both local
|
|
126
|
+
* and remote handles; the server additionally enforces project authority. */
|
|
122
127
|
export async function withSessionlessStore<T>(
|
|
123
128
|
opts: SessionlessStoreOpts,
|
|
124
129
|
use: (context: OpenedStoreContext) => Promise<T> | T,
|
|
@@ -145,7 +150,10 @@ export async function openAmbientReadStore(
|
|
|
145
150
|
...(opts.storeRef !== undefined ? { storeRef: opts.storeRef } : {}),
|
|
146
151
|
...(opts.env.AB_STORE !== undefined ? { envStore: opts.env.AB_STORE } : {}),
|
|
147
152
|
})
|
|
148
|
-
const context = openStoreForRepoState(state,
|
|
153
|
+
const context = openStoreForRepoState(state, {
|
|
154
|
+
...opts,
|
|
155
|
+
useProjectCredential: ambient === undefined,
|
|
156
|
+
})
|
|
149
157
|
if (ambient === undefined) return context
|
|
150
158
|
return {
|
|
151
159
|
...context,
|
|
@@ -50,7 +50,6 @@ import type { ArtifactMeta, BuildStore } from '../store/types'
|
|
|
50
50
|
import { textContent } from '../store/types'
|
|
51
51
|
import { expandIssues } from '../zod-issues'
|
|
52
52
|
import { preparePrAttachments } from './pr-attachments'
|
|
53
|
-
import { renderPrSummary } from './pr-summary'
|
|
54
53
|
import type { CliEnv } from './env'
|
|
55
54
|
|
|
56
55
|
/** Structural subset of CliDeps (src/cli/main.ts) — what terminals need. */
|
|
@@ -441,15 +440,6 @@ export async function completeFinalizePr(
|
|
|
441
440
|
}
|
|
442
441
|
}
|
|
443
442
|
|
|
444
|
-
try {
|
|
445
|
-
await deps.forge.commentOnPr(
|
|
446
|
-
deps.workspacePath,
|
|
447
|
-
pr.number,
|
|
448
|
-
renderPrSummary(env, await store.getEvents(env.build)),
|
|
449
|
-
)
|
|
450
|
-
} catch {
|
|
451
|
-
// The durable audit trail remains authoritative.
|
|
452
|
-
}
|
|
453
443
|
return event
|
|
454
444
|
}
|
|
455
445
|
|
|
@@ -42,6 +42,7 @@ export function ticketAssetNote(
|
|
|
42
42
|
event: TicketAssetNoteEvent,
|
|
43
43
|
meta: TicketAssetMeta,
|
|
44
44
|
cli = 'ab',
|
|
45
|
+
hosted = false,
|
|
45
46
|
): string {
|
|
46
47
|
const fileCount = meta.entries.filter((entry) => entry.type === 'file').length
|
|
47
48
|
const label = `\`${meta.kind}/${meta.name}\``
|
|
@@ -52,7 +53,9 @@ export function ticketAssetNote(
|
|
|
52
53
|
return [
|
|
53
54
|
`Ticket asset attached: ${label} (revision ${meta.revision}, ${contents}).`,
|
|
54
55
|
'',
|
|
55
|
-
|
|
56
|
+
hosted
|
|
57
|
+
? 'The contents live in the ticket service storage.'
|
|
58
|
+
: 'The contents live in the Autobuild store, not in this ticket.',
|
|
56
59
|
`Download the latest revision: \`${get}\``,
|
|
57
60
|
`Download this exact revision: \`${get} --rev ${meta.revision}\``,
|
|
58
61
|
].join('\n')
|
|
@@ -60,7 +63,9 @@ export function ticketAssetNote(
|
|
|
60
63
|
return [
|
|
61
64
|
`Ticket asset replaced: ${label} is now revision ${meta.revision} (${contents}).`,
|
|
62
65
|
'',
|
|
63
|
-
|
|
66
|
+
hosted
|
|
67
|
+
? 'The contents live in the ticket service storage. Earlier revisions stay retrievable.'
|
|
68
|
+
: 'The contents live in the Autobuild store, not in this ticket. Earlier revisions stay retrievable.',
|
|
64
69
|
`Download the latest revision: \`${get}\``,
|
|
65
70
|
`Download this exact revision: \`${get} --rev ${meta.revision}\``,
|
|
66
71
|
].join('\n')
|