rvlib-shipkit 1.7.0 → 1.9.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/CHANGELOG.md +48 -0
- package/package.json +1 -1
- package/src/assets/gen/comfyts-gen.ts +31 -14
- package/src/assets/gen/generate-roll.ts +20 -7
- package/src/assets/kit-config.ts +9 -0
- package/src/assets/kits/identity-kit.ts +7 -1
- package/src/assets/run-assets.ts +3 -3
- package/src/assets/server/studio-server.ts +3 -3
- package/src/checks/SK027-agent-wiki-links.check.ts +6 -2
- package/src/checks/SK033-global-contribution.check.ts +47 -0
- package/src/cli/cmd_Ci.ts +5 -4
- package/src/cli/cmd_GlobalContext.ts +47 -0
- package/src/cli/cmd_GlobalProcesses.ts +38 -1
- package/src/cli.ts +4 -1
- package/src/collections/providers.ts +1 -1
- package/src/core/check-runner.ts +13 -4
- package/src/core/discover-checks.ts +37 -8
- package/src/core/fix-scope.ts +5 -0
- package/src/core/floor-resolve.ts +2 -1
- package/src/core/git-root.ts +22 -12
- package/src/core/global-contribution.ts +42 -0
- package/src/core/one-lib.ts +4 -2
- package/src/core/run-ci.ts +12 -11
- package/src/core/slots.ts +36 -6
- package/src/home/global-context.ts +145 -0
- package/src/home/gui-app/ShipkitGui.swift +96 -0
- package/src/home/home-routes.ts +10 -0
- package/src/home/resources.ts +3 -0
- package/src/plan/planner.ts +21 -4
- package/src/plan/repo-source.ts +7 -3
- package/src/release/run-release.ts +17 -8
- package/src/release/unit-gate.ts +12 -5
- package/src/repo-config.ts +7 -0
- package/src/serve/hub-runtime.ts +34 -23
- package/src/serve/project-gutter.ts +5 -2
- package/src/serve/run-lanes.ts +138 -0
- package/src/smoke/run-smoke.ts +13 -6
- package/src/suite/judge-suites.ts +79 -11
- package/src/suite/reads-env.ts +8 -0
- package/src/suite/select.ts +510 -0
- package/src/suite/suite-cli.ts +213 -23
- package/src/suite/suite-reads-preload.ts +335 -0
- package/src/ts/typecheck-cache.ts +11 -0
- package/src/ts/typecheck-cli.ts +2 -1
|
@@ -130,6 +130,7 @@ final class Delegate: NSObject, NSApplicationDelegate, WKNavigationDelegate, WKU
|
|
|
130
130
|
|
|
131
131
|
func applicationDidFinishLaunching(_ note: Notification) {
|
|
132
132
|
if CommandLine.arguments.contains("--self-test-find") { return selfTestFind() }
|
|
133
|
+
if CommandLine.arguments.contains("--self-test-panels") { return selfTestPanels() }
|
|
133
134
|
buildMenu()
|
|
134
135
|
// esc closes the find bar of the key window, and reaches the page otherwise
|
|
135
136
|
NSEvent.addLocalMonitorForEvents(matching: .keyDown) { e in
|
|
@@ -267,6 +268,61 @@ final class Delegate: NSObject, NSApplicationDelegate, WKNavigationDelegate, WKU
|
|
|
267
268
|
web.loadHTMLString("<p>one needle, two needle</p>", baseURL: nil)
|
|
268
269
|
}
|
|
269
270
|
|
|
271
|
+
/** hears every js panel once it shows: the self test answers it */
|
|
272
|
+
var onPanel: ((NSAlert) -> Void)?
|
|
273
|
+
|
|
274
|
+
// `--self-test-panels`: a page in an offscreen window calls prompt, confirm
|
|
275
|
+
// and alert, answered OK after typing over the prompt's text, then again,
|
|
276
|
+
// answered with esc. What the page got printed, exit 0 only when every
|
|
277
|
+
// answer reached it. Return is checked as OK being the sheet's default
|
|
278
|
+
// button: a synthetic return reaches it only in a key window, and the test
|
|
279
|
+
// must not take the focus
|
|
280
|
+
func selfTestPanels() {
|
|
281
|
+
let web = WKWebView(frame: .zero, configuration: WKWebViewConfiguration())
|
|
282
|
+
web.navigationDelegate = self
|
|
283
|
+
web.uiDelegate = self
|
|
284
|
+
let win = NSWindow(contentRect: NSRect(x: -4000, y: -4000, width: 600, height: 400), styleMask: [.titled], backing: .buffered, defer: false)
|
|
285
|
+
win.contentView = Pane(web: web)
|
|
286
|
+
win.orderFrontRegardless()
|
|
287
|
+
windows.append(win)
|
|
288
|
+
var accept = true
|
|
289
|
+
var panels = 0
|
|
290
|
+
var returnIsOK = true
|
|
291
|
+
var escHeard = true
|
|
292
|
+
onPanel = { alert in
|
|
293
|
+
panels += 1
|
|
294
|
+
DispatchQueue.main.async {
|
|
295
|
+
if alert.window.defaultButtonCell !== alert.buttons[0].cell { returnIsOK = false }
|
|
296
|
+
if accept {
|
|
297
|
+
// typing replaces the default text only when it is selected in an active editor
|
|
298
|
+
if let field = alert.accessoryView as? NSTextField { (field.currentEditor() as? NSTextView)?.insertText("typed", replacementRange: NSRange(location: NSNotFound, length: 0)) }
|
|
299
|
+
return alert.buttons[0].performClick(nil)
|
|
300
|
+
}
|
|
301
|
+
if alert.buttons.count == 1 { return alert.buttons[0].performClick(nil) }
|
|
302
|
+
let esc = NSEvent.keyEvent(with: .keyDown, location: .zero, modifierFlags: [], timestamp: 0, windowNumber: alert.window.windowNumber, context: nil, characters: "\u{1b}", charactersIgnoringModifiers: "\u{1b}", isARepeat: false, keyCode: 53)
|
|
303
|
+
if let esc, alert.window.performKeyEquivalent(with: esc) { return }
|
|
304
|
+
escHeard = false
|
|
305
|
+
alert.buttons[1].performClick(nil)
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
let js = "JSON.stringify([prompt('name', 'x'), confirm('ok?'), (alert('hi'), 'alerted')])"
|
|
309
|
+
onLoad = { _ in
|
|
310
|
+
web.evaluateJavaScript(js) { a, _ in
|
|
311
|
+
accept = false
|
|
312
|
+
web.evaluateJavaScript(js) { b, _ in
|
|
313
|
+
let ok = (a as? String) ?? "none", cancel = (b as? String) ?? "none"
|
|
314
|
+
print("panels=\(panels)")
|
|
315
|
+
print("return-is-ok=\(returnIsOK)")
|
|
316
|
+
print("esc-heard=\(escHeard)")
|
|
317
|
+
print("ok=\(ok)")
|
|
318
|
+
print("cancel=\(cancel)")
|
|
319
|
+
exit(panels == 6 && returnIsOK && escHeard && ok == "[\"typed\",true,\"alerted\"]" && cancel == "[null,false,\"alerted\"]" ? 0 : 1)
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
web.loadHTMLString("<p>panels</p>", baseURL: nil)
|
|
324
|
+
}
|
|
325
|
+
|
|
270
326
|
func windowWillClose(_ note: Notification) {
|
|
271
327
|
windows.removeAll { $0 === note.object as? NSWindow }
|
|
272
328
|
}
|
|
@@ -293,6 +349,46 @@ final class Delegate: NSObject, NSApplicationDelegate, WKNavigationDelegate, WKU
|
|
|
293
349
|
return nil
|
|
294
350
|
}
|
|
295
351
|
|
|
352
|
+
// alert, confirm and prompt: without these WebKit answers each one at once
|
|
353
|
+
// (no panel, confirm false, prompt null), so a page's dialogs do nothing.
|
|
354
|
+
// WebKit throws when a handler is dropped or called twice: `panel` calls
|
|
355
|
+
// `done` exactly once on every path
|
|
356
|
+
|
|
357
|
+
/** one js panel: a sheet on the page's window, a modal where there is
|
|
358
|
+
* none. Return is OK, esc is Cancel; `done` hears whether OK was pressed */
|
|
359
|
+
func panel(_ web: WKWebView, _ message: String, cancel: Bool, field: NSTextField? = nil, done: @escaping (Bool) -> Void) {
|
|
360
|
+
let alert = NSAlert()
|
|
361
|
+
alert.messageText = message
|
|
362
|
+
alert.addButton(withTitle: "OK").keyEquivalent = "\r"
|
|
363
|
+
if cancel { alert.addButton(withTitle: "Cancel").keyEquivalent = "\u{1b}" }
|
|
364
|
+
if let field {
|
|
365
|
+
alert.accessoryView = field
|
|
366
|
+
alert.window.initialFirstResponder = field
|
|
367
|
+
}
|
|
368
|
+
guard let win = web.window else {
|
|
369
|
+
onPanel?(alert)
|
|
370
|
+
return done(alert.runModal() == .alertFirstButtonReturn)
|
|
371
|
+
}
|
|
372
|
+
alert.beginSheetModal(for: win) { r in done(r == .alertFirstButtonReturn) }
|
|
373
|
+
// first responder selects the whole default text, so typing replaces it
|
|
374
|
+
if let field { alert.window.makeFirstResponder(field) }
|
|
375
|
+
onPanel?(alert)
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
func webView(_ web: WKWebView, runJavaScriptAlertPanelWithMessage message: String, initiatedByFrame frame: WKFrameInfo, completionHandler: @escaping () -> Void) {
|
|
379
|
+
panel(web, message, cancel: false) { _ in completionHandler() }
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
func webView(_ web: WKWebView, runJavaScriptConfirmPanelWithMessage message: String, initiatedByFrame frame: WKFrameInfo, completionHandler: @escaping (Bool) -> Void) {
|
|
383
|
+
panel(web, message, cancel: true) { ok in completionHandler(ok) }
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
func webView(_ web: WKWebView, runJavaScriptTextInputPanelWithPrompt prompt: String, defaultText: String?, initiatedByFrame frame: WKFrameInfo, completionHandler: @escaping (String?) -> Void) {
|
|
387
|
+
let field = NSTextField(string: defaultText ?? "")
|
|
388
|
+
field.frame = NSRect(x: 0, y: 0, width: 300, height: 24)
|
|
389
|
+
panel(web, prompt, cancel: true, field: field) { ok in completionHandler(ok ? field.stringValue : nil) }
|
|
390
|
+
}
|
|
391
|
+
|
|
296
392
|
func item(_ menu: NSMenu, _ title: String, _ action: Selector, _ key: String, _ mods: NSEvent.ModifierFlags = [.command], tag: Int = 0) {
|
|
297
393
|
let i = menu.addItem(withTitle: title, action: action, keyEquivalent: key)
|
|
298
394
|
i.keyEquivalentModifierMask = mods
|
package/src/home/home-routes.ts
CHANGED
|
@@ -18,6 +18,7 @@ import type { Planner, PlanRow } from '../plan/planner.ts'
|
|
|
18
18
|
import type { Widgets, WidgetView } from './widgets.ts'
|
|
19
19
|
import { ActivityCache, repoActivity, windowMinutes } from './activity.ts'
|
|
20
20
|
import { devRepos, InsightsCache, type Scope } from './insights.ts'
|
|
21
|
+
import { GLOBAL_CONTEXT_EVERY_MS, houseContext, writeIfChanged } from './global-context.ts'
|
|
21
22
|
import { HouseRuns } from '../serve/house-runs.ts'
|
|
22
23
|
|
|
23
24
|
/** how far back a repo's git page lists its commits */
|
|
@@ -126,6 +127,7 @@ export function homeRoutes(d: HomeDeps) {
|
|
|
126
127
|
}
|
|
127
128
|
|
|
128
129
|
let active = new Set<string>()
|
|
130
|
+
let contextAt = -Infinity
|
|
129
131
|
if (d.plan != null) d.plan.active = (name) => active.has(name)
|
|
130
132
|
|
|
131
133
|
async function tick(): Promise<void> {
|
|
@@ -133,6 +135,14 @@ export function homeRoutes(d: HomeDeps) {
|
|
|
133
135
|
active = activeSet(rows, d.state.data.active, rows.find((r) => r.path === d.root)?.name ?? d.name)
|
|
134
136
|
// the plan's clock: one look per tick, and it starts at most one batch
|
|
135
137
|
d.plan?.tick()
|
|
138
|
+
// the house's generated context: rewritten only when a source moved,
|
|
139
|
+
// and never held on
|
|
140
|
+
if (Date.now() - contextAt >= GLOBAL_CONTEXT_EVERY_MS) {
|
|
141
|
+
contextAt = Date.now()
|
|
142
|
+
void houseContext(d.root)
|
|
143
|
+
.then((c) => { if (c != null && writeIfChanged(c.path, c.text)) console.log(`ok: wrote ${c.path}`) })
|
|
144
|
+
.catch((e) => console.error(`WARN the global context could not be written: ${(e as Error).message}`))
|
|
145
|
+
}
|
|
136
146
|
// a widget render never holds the tick back
|
|
137
147
|
void d.widgets?.tick(repos.map((r) => ({ name: r.name, path: r.path }))).catch((e) => console.error(`WARN the widgets broke: ${(e as Error).message}`))
|
|
138
148
|
await d.hubs.tick(active)
|
package/src/home/resources.ts
CHANGED
|
@@ -285,6 +285,9 @@ export class Resource {
|
|
|
285
285
|
* while it answers, else it starts and opens once it does */
|
|
286
286
|
async open(how: OpenHow): Promise<void> {
|
|
287
287
|
if (this.decl.url == null) throw new Error(`${this.decl.id} declares no url to open`)
|
|
288
|
+
// the status is the last tick's: a process that died since would get
|
|
289
|
+
// a window on a dead port and never start
|
|
290
|
+
await this.tick()
|
|
288
291
|
if (this.status === 'up') {
|
|
289
292
|
this.show(how, this.decl.url)
|
|
290
293
|
return
|
package/src/plan/planner.ts
CHANGED
|
@@ -11,7 +11,8 @@
|
|
|
11
11
|
//
|
|
12
12
|
// what keeps it off the cpu: a save burst is asked about once the repo is
|
|
13
13
|
// quiet (QUIET_MS, or MAX_WAIT_MS into a burst that never stops), a check a
|
|
14
|
-
// save moved waits COST_FACTOR times its last measured run
|
|
14
|
+
// save moved waits COST_FACTOR times its last measured run and GATED_HOLD_MS
|
|
15
|
+
// after the latest gate someone else started in its repo, and nothing it
|
|
15
16
|
// starts on its own starts while the load per core is above LOAD_MAX.
|
|
16
17
|
import { availableParallelism, loadavg } from 'os'
|
|
17
18
|
import { CATCH_UP_BEAT_MS, resumeCatchUp, type CatchUpStore } from './catch-up.ts'
|
|
@@ -39,6 +40,9 @@ export type PlanSource = {
|
|
|
39
40
|
checks: () => Promise<PlanCheck[]>
|
|
40
41
|
/** every check's last result on record */
|
|
41
42
|
last: () => Record<string, LastResult>
|
|
43
|
+
/** when the latest run someone asked for started here (a terminal, an
|
|
44
|
+
* agent, a click), never one the plan started; null when none did */
|
|
45
|
+
gatedAt?: () => number | null
|
|
42
46
|
/** the checks a save moved: their answer would not replay */
|
|
43
47
|
changed: () => Promise<string[]>
|
|
44
48
|
/** run these checks now, in the background; resolves to the exit code */
|
|
@@ -88,6 +92,11 @@ export const MAX_WAIT_MS = 10 * 60_000
|
|
|
88
92
|
* measured run: a 30s suite at most every 10 minutes, a 200ms lint at once */
|
|
89
93
|
export const COST_FACTOR = 20
|
|
90
94
|
|
|
95
|
+
/** a save moved check waits this long after the latest run someone else
|
|
96
|
+
* started in its repo (a terminal, an agent, a click): whoever gates that
|
|
97
|
+
* tree is still at work, and its own gate measures what it changed */
|
|
98
|
+
export const GATED_HOLD_MS = 10 * 60_000
|
|
99
|
+
|
|
91
100
|
/** above this 1 minute load average per core, the plan starts nothing on its
|
|
92
101
|
* own: every core already has a process waiting for it */
|
|
93
102
|
export const LOAD_MAX = 1
|
|
@@ -112,6 +121,7 @@ type Repo = {
|
|
|
112
121
|
src: PlanSource
|
|
113
122
|
checks: PlanCheck[]
|
|
114
123
|
last: Record<string, LastResult>
|
|
124
|
+
gatedAt: number | null
|
|
115
125
|
lastRead: number
|
|
116
126
|
marks: Map<string, Mark>
|
|
117
127
|
timer: unknown
|
|
@@ -169,7 +179,7 @@ export class Planner {
|
|
|
169
179
|
async add(src: PlanSource): Promise<void> {
|
|
170
180
|
const old = this.repos.get(src.repo)
|
|
171
181
|
if (old?.timer != null) this.timers.clear(old.timer)
|
|
172
|
-
const r: Repo = { src, checks: [], last: {}, lastRead: -Infinity, marks: old?.marks ?? new Map(), timer: null, burstAt: null, settleAt: null, detecting: null, again: false, error: null }
|
|
182
|
+
const r: Repo = { src, checks: [], last: {}, gatedAt: null, lastRead: -Infinity, marks: old?.marks ?? new Map(), timer: null, burstAt: null, settleAt: null, detecting: null, again: false, error: null }
|
|
173
183
|
this.repos.set(src.repo, r)
|
|
174
184
|
await this.reload(src.repo)
|
|
175
185
|
try {
|
|
@@ -223,6 +233,7 @@ export class Planner {
|
|
|
223
233
|
r.lastRead = now
|
|
224
234
|
try {
|
|
225
235
|
r.last = r.src.last()
|
|
236
|
+
r.gatedAt = r.src.gatedAt?.() ?? null
|
|
226
237
|
} catch (e) {
|
|
227
238
|
this.fail(r.src.repo, e)
|
|
228
239
|
}
|
|
@@ -315,7 +326,9 @@ export class Planner {
|
|
|
315
326
|
// a check pays for its own runs: one save cannot run it again
|
|
316
327
|
// before COST_FACTOR times its last measured run has passed
|
|
317
328
|
const spaced = last?.cost != null ? last.cost.at + COST_FACTOR * last.cost.ms : -Infinity
|
|
318
|
-
|
|
329
|
+
// and it waits for whoever gates this tree by hand to stop
|
|
330
|
+
const gated = r.gatedAt != null ? r.gatedAt + GATED_HOLD_MS : -Infinity
|
|
331
|
+
const at = Math.max(m.changedAt, spaced, gated)
|
|
319
332
|
return { ...base, state: at > now ? 'next' : 'due', reason: 'changed', dueAt: at }
|
|
320
333
|
}
|
|
321
334
|
const lastAt = Math.max(last?.at ?? -Infinity, m.attemptAt ?? -Infinity)
|
|
@@ -354,8 +367,12 @@ export class Planner {
|
|
|
354
367
|
held(): string[] {
|
|
355
368
|
const now = this.now()
|
|
356
369
|
const out: string[] = []
|
|
357
|
-
for (const r of this.repos.values())
|
|
370
|
+
for (const r of this.repos.values()) {
|
|
358
371
|
if (r.settleAt != null) out.push(`settling: ${r.src.repo}, ${Math.max(0, Math.ceil((r.settleAt - now) / 1_000))}s`)
|
|
372
|
+
const until = r.gatedAt == null ? null : r.gatedAt + GATED_HOLD_MS
|
|
373
|
+
if (until != null && until > now && [...r.marks.values()].some((m) => m.changedAt != null))
|
|
374
|
+
out.push(`deferred: ${r.src.repo}, a gate someone started there ${Math.floor((now - r.gatedAt!) / 60_000)}m ago, so what a save moved waits ${Math.ceil((until - now) / 60_000)}m`)
|
|
375
|
+
}
|
|
359
376
|
if (this.holdReason != null) out.push(this.holdReason)
|
|
360
377
|
return out
|
|
361
378
|
}
|
package/src/plan/repo-source.ts
CHANGED
|
@@ -82,12 +82,15 @@ export function readLastCosts(path: string): Record<string, { at: number; ms: nu
|
|
|
82
82
|
return out
|
|
83
83
|
}
|
|
84
84
|
|
|
85
|
-
/** when the latest run on record started,
|
|
86
|
-
|
|
85
|
+
/** when the latest run on record started, or null. `asked`: only a run
|
|
86
|
+
* someone asked for (a terminal, an agent, a click), never one the plan
|
|
87
|
+
* started on its own */
|
|
88
|
+
export function lastStarted(path: string, o: { asked?: boolean } = {}): number | null {
|
|
87
89
|
const opened = openDb(path, { create: false })
|
|
88
90
|
if (opened == null) return null
|
|
89
91
|
try {
|
|
90
|
-
const
|
|
92
|
+
const sql = o.asked === true ? "select max(started) as at from invocations where by != 'schedule'" : 'select max(started) as at from invocations'
|
|
93
|
+
const r = opened.db.query(sql).get() as { at: number | null } | null
|
|
91
94
|
return r?.at == null ? null : Number(r.at)
|
|
92
95
|
} catch {
|
|
93
96
|
return null
|
|
@@ -144,6 +147,7 @@ export function repoSource(d: RepoSourceDeps): PlanSource {
|
|
|
144
147
|
for (const [id, cost] of Object.entries(costs)) if (all[id] != null) all[id] = { ...all[id]!, cost }
|
|
145
148
|
return all
|
|
146
149
|
},
|
|
150
|
+
gatedAt: () => (d.record === '' ? null : lastStarted(d.record, { asked: true })),
|
|
147
151
|
changed: async () => {
|
|
148
152
|
const decls = rows.map((r) => r.decl)
|
|
149
153
|
if (decls.length === 0) return []
|
|
@@ -12,7 +12,7 @@ import { runCi } from '../core/run-ci.ts'
|
|
|
12
12
|
import { extractChangelogSection } from './changelog.ts'
|
|
13
13
|
import { deriveRemote, dirtyPaths, git, remoteTagState, tagExistsLocally, type RemoteTagState } from './git-facts.ts'
|
|
14
14
|
import { releaseScope } from './release-scope.ts'
|
|
15
|
-
import {
|
|
15
|
+
import { publishCommand, unitBlockers, type Publisher } from './unit-gate.ts'
|
|
16
16
|
import { formatUnitError, resolveUnits, unitById, unknownUnit, type ReleaseUnit } from './units.ts'
|
|
17
17
|
import type { RepoConfig } from '../repo-config.ts'
|
|
18
18
|
import { nestedIn, nestedRefusal } from '../core/nesting.ts'
|
|
@@ -37,6 +37,10 @@ export type ReleaseConfig = {
|
|
|
37
37
|
* Absent = rely on ambient npm auth. Never put a token in config.
|
|
38
38
|
* Repo-wide: one account publishes every unit. */
|
|
39
39
|
npmTokenCommand?: string[]
|
|
40
|
+
/** the tool that packs and publishes every npm unit (default 'npm'). A
|
|
41
|
+
* repo that tests its tarball with `bun pm pack` publishes with 'bun', so
|
|
42
|
+
* the packer it tests is the packer that ships. */
|
|
43
|
+
publisher?: Publisher
|
|
40
44
|
/** cut a GitHub release for the ROOT unit with `gh` (default true) */
|
|
41
45
|
githubRelease?: boolean
|
|
42
46
|
/** make a NON-blocking check failure block the release too. Blocking
|
|
@@ -301,18 +305,23 @@ export async function releaseUnit(root: string, config: RepoConfig, unit: Releas
|
|
|
301
305
|
if (wantNpm && !alreadyPublished) {
|
|
302
306
|
const env: Record<string, string> = { ...process.env as Record<string, string> }
|
|
303
307
|
const argv = rc.npmTokenCommand
|
|
308
|
+
const publisher = rc.publisher ?? 'npm'
|
|
304
309
|
let npmrc: string | null = null
|
|
310
|
+
let token: string | null = null
|
|
305
311
|
if (argv != null && argv[0] != null) {
|
|
306
312
|
const t = Bun.spawnSync(argv)
|
|
307
|
-
|
|
313
|
+
token = t.exitCode === 0 ? t.stdout.toString().trim() : ''
|
|
308
314
|
if (token === '') { print(`FAIL: release.npmTokenCommand produced no token (exit ${t.exitCode})`); return 1 }
|
|
309
|
-
// token via a throwaway userconfig
|
|
310
|
-
// committed .npmrc, never a line in a log
|
|
311
|
-
|
|
312
|
-
|
|
315
|
+
// token via a throwaway userconfig (npm) or the env (bun),
|
|
316
|
+
// never argv, never a committed .npmrc, never a line in a log
|
|
317
|
+
if (publisher === 'npm') {
|
|
318
|
+
npmrc = join(work, 'npmrc')
|
|
319
|
+
writeFileSync(npmrc, `//registry.npmjs.org/:_authToken=${token}\n`, { mode: 0o600 })
|
|
320
|
+
}
|
|
313
321
|
}
|
|
314
|
-
const
|
|
315
|
-
|
|
322
|
+
const cmd = publishCommand({ publisher, npmrc, token })
|
|
323
|
+
const p = Bun.spawnSync(cmd.argv, { cwd: unit.dir, env: { ...env, ...cmd.env }, stdout: 'inherit', stderr: 'inherit' })
|
|
324
|
+
if (p.exitCode !== 0) { print(`FAIL: ${publisher} publish failed for ${unit.id} (exit ${p.exitCode})`); return 1 }
|
|
316
325
|
print(`ok: published ${name}@${version}`)
|
|
317
326
|
}
|
|
318
327
|
|
package/src/release/unit-gate.ts
CHANGED
|
@@ -9,11 +9,18 @@ import { dirtyPaths } from './git-facts.ts'
|
|
|
9
9
|
import type { ReleaseUnit } from './units.ts'
|
|
10
10
|
import type { RepoConfig } from '../repo-config.ts'
|
|
11
11
|
|
|
12
|
-
/** the
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
12
|
+
/** the tool that packs and publishes an npm unit (`release.publisher`) */
|
|
13
|
+
export const PUBLISHERS = ['npm', 'bun'] as const
|
|
14
|
+
export type Publisher = (typeof PUBLISHERS)[number]
|
|
15
|
+
|
|
16
|
+
/** the publish argv and the env its token rides in. `--access public` is not
|
|
17
|
+
* a default: npm restricts a scoped package unless it is asked, and a
|
|
18
|
+
* restricted publish looks successful right up until a consumer cannot
|
|
19
|
+
* install it. npm reads the token from a throwaway userconfig; `bun publish`
|
|
20
|
+
* has no `--userconfig` and reads NPM_CONFIG_TOKEN instead. */
|
|
21
|
+
export function publishCommand(o: { publisher?: Publisher; npmrc?: string | null; token?: string | null } = {}): { argv: string[]; env: Record<string, string> } {
|
|
22
|
+
if (o.publisher === 'bun') return { argv: ['bun', 'publish', '--access', 'public'], env: o.token != null ? { NPM_CONFIG_TOKEN: o.token } : {} }
|
|
23
|
+
return { argv: ['npm', 'publish', '--access', 'public', ...(o.npmrc != null ? ['--userconfig', o.npmrc] : [])], env: {} }
|
|
17
24
|
}
|
|
18
25
|
|
|
19
26
|
/** everything that must hold for this unit, checked before anything is
|
package/src/repo-config.ts
CHANGED
|
@@ -25,6 +25,7 @@ import type { Rule } from './check.ts'
|
|
|
25
25
|
import { leakAllowProblems } from './hooks/leak-guard.ts'
|
|
26
26
|
import type { HooksConfig } from './hooks/run-hooks.ts'
|
|
27
27
|
import type { ReleaseConfig } from './release/run-release.ts'
|
|
28
|
+
import { PUBLISHERS } from './release/unit-gate.ts'
|
|
28
29
|
import { TEST_ONLY_UNIT_KEYS, UNIT_DECL_KEYS, type UnitDecl } from './release/unit-resolve.ts'
|
|
29
30
|
import { TASK_DEF_KEYS, type TaskDef } from './check.ts'
|
|
30
31
|
import { testsConfigProblems, type TestsConfig } from './suite/judge-suites.ts'
|
|
@@ -41,6 +42,10 @@ export type ServeConfig = {
|
|
|
41
42
|
* its own port — house range 5800+, default = the hub port plus 1000
|
|
42
43
|
* (build/comfyui.md owns that surface) */
|
|
43
44
|
comfyPort?: number
|
|
45
|
+
/** false hides the asset kit from the hub page (no asset rows, an empty
|
|
46
|
+
* studio) and from `shipkit needs`, for a repo whose own tool mounts
|
|
47
|
+
* `studioRoutes` and owns the picking. Default true */
|
|
48
|
+
assets?: boolean
|
|
44
49
|
/** custom wiki-graph provider for repos with their own link semantics.
|
|
45
50
|
* Default: a generic md scan of the repo root. */
|
|
46
51
|
wikiGraph?: {
|
|
@@ -320,6 +325,8 @@ export function defineRepo(c: RepoConfig): RepoConfig {
|
|
|
320
325
|
for (const k of Object.keys(c))
|
|
321
326
|
if (!KNOWN_KEYS.has(k))
|
|
322
327
|
throw new Error(`defineRepo: unknown key '${k}'${k === 'checks' ? ` — checks:[] was removed; drop a <name>.check.ts file instead ('shipkit check' auto-discovers it)` : k === 'home' ? ` — home.resources was removed; declare each one in a <name>.shipkit.ts file as \`export default globalProcess({...})\`` : ''}`)
|
|
328
|
+
if (c.release?.publisher != null && !(PUBLISHERS as readonly string[]).includes(c.release.publisher))
|
|
329
|
+
throw new Error(`defineRepo: release.publisher '${String(c.release.publisher)}' is not a publisher (${PUBLISHERS.join(', ')})`)
|
|
323
330
|
// a typo'd unit key would silently drop a target and release the wrong shape
|
|
324
331
|
for (const u of c.units ?? []) {
|
|
325
332
|
if (typeof u.dir !== 'string' || u.dir === '') throw new Error(`defineRepo: every units[] entry needs a 'dir'`)
|
package/src/serve/hub-runtime.ts
CHANGED
|
@@ -41,6 +41,7 @@ import { autoGatePool, takeSlot } from '../core/slots.ts'
|
|
|
41
41
|
import { liveJobs, startJob } from '../core/jobs.ts'
|
|
42
42
|
import { followJob } from '../core/job-follow.ts'
|
|
43
43
|
import { RUN_JOB } from '../home/home-words.ts'
|
|
44
|
+
import { RunLanes, runKey, treeStamp, writesTree } from './run-lanes.ts'
|
|
44
45
|
import { OtherRepos } from './other-repos.ts'
|
|
45
46
|
import { HouseRuns } from './house-runs.ts'
|
|
46
47
|
import { recordStamp } from './project-gutter.ts'
|
|
@@ -157,9 +158,10 @@ export async function resolveHubDeps(root: string, boot: RepoConfig, io: ServeIo
|
|
|
157
158
|
graphs.push({ id: 'agent', label: 'agent/', title: `${c.name} agent/`, load: () => scanMdGraph({ root: agentRoot, index: c.serve?.agentIndex }), lint: { root: agentRoot, index: c.serve?.agentIndex } })
|
|
158
159
|
// ONE page for every repo: the studio ALWAYS
|
|
159
160
|
// mounts — kit-less repos get an empty media section, never a second
|
|
160
|
-
// page. A broken kit degrades to the same empty kit, loudly
|
|
161
|
+
// page. A broken kit degrades to the same empty kit, loudly, and a repo
|
|
162
|
+
// whose own tool owns the picking turns the kit off here
|
|
161
163
|
let kit: ResolvedKit | null = null
|
|
162
|
-
if (existsSync(join(root, '.shipkit/assets/kit.ts'))) {
|
|
164
|
+
if (c.serve?.assets !== false && existsSync(join(root, '.shipkit/assets/kit.ts'))) {
|
|
163
165
|
try {
|
|
164
166
|
kit = await loadKit(root)
|
|
165
167
|
} catch (e) {
|
|
@@ -185,9 +187,11 @@ export async function resolveHubDeps(root: string, boot: RepoConfig, io: ServeIo
|
|
|
185
187
|
const port = o?.port ?? c.serve?.port ?? defaultHubPort(c.name)
|
|
186
188
|
// every run this process has going, whoever started it
|
|
187
189
|
let active = 0
|
|
188
|
-
//
|
|
189
|
-
// the
|
|
190
|
-
|
|
190
|
+
// every run, whoever asked: an identical one attaches, a repair pass goes
|
|
191
|
+
// alone, the rest overlap under the machine slot pool (run-lanes.ts)
|
|
192
|
+
const lanes = new RunLanes(() => treeStamp(root))
|
|
193
|
+
// the gate jobs this runtime started: one beside another is the lanes' call
|
|
194
|
+
const mine = new Set<string>()
|
|
191
195
|
// the latest verdict of every check, printed as one problem frame after
|
|
192
196
|
// each run for the vscode problems pane (problems/problem-line.ts)
|
|
193
197
|
const board = new ProblemBoard()
|
|
@@ -215,13 +219,22 @@ export async function resolveHubDeps(root: string, boot: RepoConfig, io: ServeIo
|
|
|
215
219
|
}
|
|
216
220
|
}
|
|
217
221
|
// a job still running here, started before this image or by a terminal's --detach
|
|
218
|
-
|
|
222
|
+
const foreign = (): boolean => liveJobs(root).some((j) => !mine.has(j.id))
|
|
223
|
+
if (foreign()) tap.line('note: a gate job this hub did not start is running here, so this one starts when it ends')
|
|
224
|
+
while (foreign()) await Bun.sleep(jobs.pollMs ?? 250)
|
|
219
225
|
const j = startJob(root, { cli: jobs.cli, kind, args: [], job: { opts, oneLib: true, turn: background, ...(env == null ? {} : { env }) } })
|
|
226
|
+
mine.add(j.id)
|
|
220
227
|
tap.job?.(j.id)
|
|
221
|
-
|
|
228
|
+
try {
|
|
229
|
+
return (await followJob(root, j.id, tap, { pollMs: jobs.pollMs })) ?? 1
|
|
230
|
+
} finally {
|
|
231
|
+
mine.delete(j.id)
|
|
232
|
+
}
|
|
222
233
|
}
|
|
223
234
|
const runChecks: HubDeps['runChecks'] = (tap, o) => {
|
|
224
|
-
|
|
235
|
+
// only a whole gate the hub was asked for runs the repair pass (hubGateOpts)
|
|
236
|
+
const writes = o?.auto !== true && o?.gate === true && (o.only == null || o.only.length === 0)
|
|
237
|
+
return lanes.submit({ key: null, writes, print: tap.line }, async () => {
|
|
225
238
|
active++
|
|
226
239
|
// a run the hub starts on its own takes the machine's background
|
|
227
240
|
// turn, once it is this hub's turn, never while it queues
|
|
@@ -243,27 +256,25 @@ export async function resolveHubDeps(root: string, boot: RepoConfig, io: ServeIo
|
|
|
243
256
|
for (const l of board.frame()) io.print(l)
|
|
244
257
|
}
|
|
245
258
|
})
|
|
246
|
-
chain = run.catch(() => {})
|
|
247
|
-
return run
|
|
248
259
|
}
|
|
249
260
|
// a `shipkit ci` or `shipkit check` from a terminal, run HERE (home/via-hub.ts):
|
|
250
|
-
// in the same
|
|
251
|
-
//
|
|
261
|
+
// in the same lanes as the hub's own runs, feeding the same problems
|
|
262
|
+
// frame, recorded as the cli's
|
|
252
263
|
const runCli: HubDeps['runCli'] = (kind, opts, print) => {
|
|
253
|
-
//
|
|
254
|
-
|
|
255
|
-
|
|
264
|
+
// a --json run prints the json and nothing else, so it never shares
|
|
265
|
+
const key = opts.json === true ? null : runKey(kind, opts)
|
|
266
|
+
// `check --fix` runs in process (home/via-hub.ts), so a check here never repairs
|
|
267
|
+
const writes = kind === 'ci' && writesTree(opts)
|
|
268
|
+
return lanes.submit({ key, writes, print, quiet: opts.json === true }, async (tee) => {
|
|
256
269
|
active++
|
|
257
270
|
try {
|
|
258
271
|
await refresh()
|
|
259
|
-
return await exec(kind, { ...opts, invokedBy: 'cli' }, { line:
|
|
272
|
+
return await exec(kind, { ...opts, invokedBy: 'cli' }, { line: tee, check: onBoard, job: (id) => tee(`${RUN_JOB}${id}`) }, false)
|
|
260
273
|
} finally {
|
|
261
274
|
active--
|
|
262
275
|
for (const l of board.frame()) io.print(l)
|
|
263
276
|
}
|
|
264
277
|
})
|
|
265
|
-
chain = run.catch(() => {})
|
|
266
|
-
return run
|
|
267
278
|
}
|
|
268
279
|
const runIn = async (tap: CheckTap, o: Parameters<HubDeps['runChecks']>[1], background: boolean): Promise<number> => {
|
|
269
280
|
const only = o?.only != null && o.only.length > 0 ? o.only : undefined
|
|
@@ -287,11 +298,11 @@ export async function resolveHubDeps(root: string, boot: RepoConfig, io: ServeIo
|
|
|
287
298
|
if (adopted.length > 0) {
|
|
288
299
|
io.print(`ok: following ${adopted.length} gate job(s) still running: ${adopted.map((j) => j.id).join(', ')}`)
|
|
289
300
|
active++
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
301
|
+
void lanes.submit({ key: null, writes: true, print: () => {} }, () =>
|
|
302
|
+
Promise.all(adopted.map((j) => followJob(root, j.id, { line: () => {}, check: onBoard }, { pollMs: jobs.pollMs }))).then(() => 0).finally(() => {
|
|
303
|
+
active--
|
|
304
|
+
for (const l of board.frame()) io.print(l)
|
|
305
|
+
})).catch(() => {})
|
|
295
306
|
}
|
|
296
307
|
}
|
|
297
308
|
// resolved once: it shells out to git, and the page asks on every poll
|
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
import { existsSync, readdirSync, readFileSync, statSync } from 'fs'
|
|
10
10
|
import { basename, dirname, join, resolve, sep } from 'path'
|
|
11
11
|
import { fileURLToPath } from 'url'
|
|
12
|
-
import { gitRoot } from '../core/git-root.ts'
|
|
12
|
+
import { gitRoot, isLinkedWorktree } from '../core/git-root.ts'
|
|
13
13
|
import { DB_FILE } from '../ledger/db.ts'
|
|
14
14
|
import { defaultHubPort } from './hub-port.ts'
|
|
15
15
|
import { LAYOUT } from '../core/layout.ts'
|
|
@@ -52,7 +52,9 @@ export const ICON_FILES = ['.shipkit/identity/icon.png', 'public/favicon.png', '
|
|
|
52
52
|
|
|
53
53
|
export const ICON_TYPES: Record<string, string> = { png: 'image/png', svg: 'image/svg+xml', ico: 'image/x-icon' }
|
|
54
54
|
|
|
55
|
-
/** every direct child of `parent` that includes `index`, sorted by name
|
|
55
|
+
/** every direct child of `parent` that includes `index`, sorted by name. A
|
|
56
|
+
* linked worktree of one is that repo checked out again, never a repo of its
|
|
57
|
+
* own: its processes would collide with the main checkout's ids and ports */
|
|
56
58
|
export function wiredRepos(parent: string, index: string, home: string): WiredRepo[] {
|
|
57
59
|
let names: string[]
|
|
58
60
|
try {
|
|
@@ -65,6 +67,7 @@ export function wiredRepos(parent: string, index: string, home: string): WiredRe
|
|
|
65
67
|
if (name.startsWith('.')) continue
|
|
66
68
|
const path = join(parent, name)
|
|
67
69
|
if (!includeTargets(path, home).includes(index)) continue
|
|
70
|
+
if (isLinkedWorktree(path)) continue
|
|
68
71
|
out.push({ name, path, icon: ICON_FILES.find((f) => existsSync(join(path, f))) ?? null })
|
|
69
72
|
}
|
|
70
73
|
return out
|