rvlib-shipkit 2.3.0 → 2.5.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 +19 -2
- package/package.json +2 -2
- package/src/assets/kits/identity-kit.ts +5 -2
- package/src/cli/cmd_Commands.ts +30 -5
- package/src/cli/cmd_FleetPush.ts +47 -0
- package/src/cli/cmd_GlobalProcesses.ts +5 -5
- package/src/cli/cmd_Review.ts +52 -11
- package/src/cli/cmd_Sound.ts +209 -0
- package/src/cli/cmd_Trailer.ts +11 -3
- package/src/cli/family-pages.ts +49 -0
- package/src/cli/kit/builtins.ts +1 -0
- package/src/cli/kit/verb.ts +11 -3
- package/src/cli/registry.ts +137 -0
- package/src/cli.ts +47 -133
- package/src/core/app-icon.ts +10 -6
- package/src/core/last-good.ts +217 -0
- package/src/core/load-probe.ts +71 -0
- package/src/core/reexec.ts +5 -3
- package/src/fleet/push-fleet.ts +80 -0
- package/src/home/home-routes.ts +12 -2
- package/src/home/lib-watch.ts +110 -20
- package/src/home/process-rows.ts +4 -2
- package/src/home/run-daemon.ts +1 -1
- package/src/repo-config.ts +3 -0
- package/src/review/format.ts +11 -3
- package/src/review/model.ts +66 -5
- package/src/review/paste.ts +30 -0
- package/src/review/project.ts +20 -4
- package/src/review/routes.ts +22 -4
- package/src/review/store.ts +15 -6
- package/src/review/unit.ts +19 -1
- package/src/scaffold/files/agent-index.md +2 -2
- package/src/scaffold/project-scaffold.ts +2 -2
- package/src/serve/hub-app/activity-st.ts +31 -0
- package/src/serve/hub-app/activity-view.tsx +9 -0
- package/src/serve/hub-app/activity.css +6 -0
- package/src/serve/hub-app/admin-svc.ts +1 -1
- package/src/serve/hub-app/admin-view.tsx +1 -1
- package/src/serve/hub-app/hash-route.ts +12 -4
- package/src/serve/hub-app/home-st.ts +7 -1
- package/src/serve/hub-app/home-strip.tsx +3 -1
- package/src/serve/hub-app/hub.css +38 -1
- package/src/serve/hub-app/icons.tsx +6 -0
- package/src/serve/hub-app/layout-st.ts +2 -2
- package/src/serve/hub-app/machine-st.ts +1 -1
- package/src/serve/hub-app/md.ts +32 -0
- package/src/serve/hub-app/project-list.css +9 -7
- package/src/serve/hub-app/project-list.tsx +5 -2
- package/src/serve/hub-app/projects-svc.ts +3 -2
- package/src/serve/hub-app/repo-st.ts +22 -7
- package/src/serve/hub-app/review/clip-st.ts +57 -7
- package/src/serve/hub-app/review/kind-view.tsx +78 -9
- package/src/serve/hub-app/review/review-answer.tsx +67 -58
- package/src/serve/hub-app/review/review-page.tsx +17 -15
- package/src/serve/hub-app/review/review-st.ts +191 -20
- package/src/serve/hub-app/review/review-views.tsx +154 -9
- package/src/serve/hub-app/review.css +73 -13
- package/src/serve/hub-app/wiki-st.ts +190 -0
- package/src/serve/hub-app/wikis-view.tsx +134 -5
- package/src/serve/hub-runtime.ts +4 -4
- package/src/serve/project-gutter-route.ts +25 -1
- package/src/serve/project-gutter.ts +1 -1
- package/src/serve/run-serve.ts +25 -0
- package/src/serve/serve-hub.ts +15 -1
- package/src/serve/stale-restart.ts +19 -2
- package/src/shot/client.ts +17 -5
- package/src/shot/shooter.ts +19 -3
- package/src/shot/shot-ask.ts +9 -1
- package/src/shot/stepped-clip.ts +53 -0
- package/src/trailer/adapter.ts +33 -4
- package/src/trailer/build.ts +182 -33
- package/src/trailer/ffmpeg.ts +21 -1
- package/src/trailer/fonts.ts +55 -0
- package/src/trailer/footage.ts +4 -4
- package/src/trailer/grammar.ts +429 -67
- package/src/trailer/graph.ts +130 -56
- package/src/trailer/look.ts +3 -0
- package/src/trailer/motion.ts +207 -0
- package/src/trailer/plan.ts +2 -2
- package/src/trailer/prune.ts +13 -2
- package/src/trailer/score/compose.ts +18 -2
- package/src/trailer/score/gm-render.ts +43 -0
- package/src/trailer/score/gm.ts +90 -0
- package/src/trailer/score/midi.ts +143 -0
- package/src/trailer/score/pattern.ts +13 -6
- package/src/trailer/score/score.ts +28 -14
- package/src/trailer/score/sound.ts +12 -1
- package/src/trailer/score/synth.ts +24 -2
- package/src/trailer/sound/audio.ts +148 -0
- package/src/trailer/sound/brand.ts +42 -0
- package/src/trailer/sound/comfy.ts +38 -0
- package/src/trailer/sound/eleven.ts +72 -0
- package/src/trailer/sound/fluid.ts +71 -0
- package/src/trailer/sound/gen.ts +139 -0
- package/src/trailer/sound/index.ts +60 -0
- package/src/trailer/sound/presets.ts +69 -0
- package/src/trailer/sound/run.ts +189 -0
- package/src/trailer/titles.ts +451 -235
- package/src/trailer/url.ts +18 -3
- package/src/trailer/vocab.ts +12 -1
- package/src/trailer/words.ts +29 -8
- package/src/wiki/graph-mount.ts +3 -0
- package/src/wiki/graph-page.ts +45 -9
- package/src/wiki/graph-pages.ts +128 -0
package/src/cli.ts
CHANGED
|
@@ -1,144 +1,58 @@
|
|
|
1
1
|
#!/usr/bin/env bun
|
|
2
|
-
//
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
import {
|
|
12
|
-
import { CheckCommand } from './cli/cmd_Check.ts'
|
|
13
|
-
import { CommandsCommand } from './cli/cmd_Commands.ts'
|
|
14
|
-
import { aliasArgs, CrossRepoActivityCommand } from './cli/cmd_CrossRepoActivity.ts'
|
|
15
|
-
import { ConfigKeysCommand } from './cli/cmd_ConfigKeys.ts'
|
|
16
|
-
import { GlobalProcessesCommand, GlobalProcessOpenCommand, GlobalProcessRestartCommand, GlobalProcessStartCommand, GlobalProcessStopCommand } from './cli/cmd_GlobalProcesses.ts'
|
|
17
|
-
import { PlannedCommand } from './cli/cmd_Planned.ts'
|
|
18
|
-
import { InsightsCommand } from './cli/cmd_Insights.ts'
|
|
19
|
-
import { GlobalContextCommand } from './cli/cmd_GlobalContext.ts'
|
|
20
|
-
import { OverviewCommand } from './cli/cmd_Overview.ts'
|
|
21
|
-
import { CiCommand } from './cli/cmd_Ci.ts'
|
|
22
|
-
import { ContractsCommand } from './cli/cmd_Contracts.ts'
|
|
23
|
-
import { FleetCommand } from './cli/cmd_Fleet.ts'
|
|
24
|
-
import { DaemonCommand, GuiCommand, PmCommand, PmInstallCommand, PmUninstallCommand, WebCommand } from './cli/cmd_Home.ts'
|
|
25
|
-
import { HooksCommitMsgCommand, HooksInstallCommand, HooksListCommand, HooksPreCommitCommand } from './cli/cmd_Hooks.ts'
|
|
26
|
-
import { InitCommand } from './cli/cmd_Init.ts'
|
|
27
|
-
import { KeysCommand } from './cli/cmd_Keys.ts'
|
|
28
|
-
import { MvCommand } from './cli/cmd_Mv.ts'
|
|
29
|
-
import { NeedsCommand } from './cli/cmd_Needs.ts'
|
|
30
|
-
import { PbCollectionCommand } from './cli/cmd_Pb.ts'
|
|
31
|
-
import { ReflectCommand } from './cli/cmd_Reflect.ts'
|
|
32
|
-
import { ReleaseCommand } from './cli/cmd_Release.ts'
|
|
33
|
-
import { ReleasesCommand } from './cli/cmd_Releases.ts'
|
|
34
|
-
import { ChecksCommand, UnitsAliasCommand } from './cli/cmd_Checks.ts'
|
|
35
|
-
import { WhenCommand } from './cli/cmd_When.ts'
|
|
36
|
-
import { CacheAuditCommand } from './cli/cmd_CacheAudit.ts'
|
|
37
|
-
import { TraceAuditCommand } from './cli/cmd_TraceAudit.ts'
|
|
38
|
-
import { ServeCommand } from './cli/cmd_Serve.ts'
|
|
39
|
-
import { SmokeCommand } from './cli/cmd_Smoke.ts'
|
|
40
|
-
import { ShotCommand } from './cli/cmd_Shot.ts'
|
|
41
|
-
import { StatusCommand } from './cli/cmd_Status.ts'
|
|
42
|
-
import { StartCommand } from './cli/cmd_Start.ts'
|
|
43
|
-
import { TRAILER_COMMANDS } from './cli/cmd_Trailer.ts'
|
|
44
|
-
import { REVIEW_COMMANDS } from './cli/cmd_Review.ts'
|
|
45
|
-
import { TuiCommand } from './cli/cmd_Tui.ts'
|
|
46
|
-
import { WikiLintCommand, WikiServeCommand } from './cli/cmd_Wiki.ts'
|
|
2
|
+
// the way in: the launcher, a repo's install handing off, the commit hooks
|
|
3
|
+
// and pm's roles all run this file. It loads the registry (cli/registry.ts)
|
|
4
|
+
// and the boot steps with await import, so a file of the tree that fails to
|
|
5
|
+
// load is caught here and the last good copy runs instead, with one line
|
|
6
|
+
// (core/last-good.ts). An error once the command runs is the command's own.
|
|
7
|
+
//
|
|
8
|
+
// the one constraint a reader breaks first: no static import but
|
|
9
|
+
// core/last-good.ts, else a broken file stops this file before it can fall
|
|
10
|
+
// back (tests/cli-boot.test.ts).
|
|
11
|
+
import { bootLoaded, runLastGood } from './core/last-good.ts'
|
|
47
12
|
|
|
13
|
+
/** a long running role's runner, which its command loads by await import:
|
|
14
|
+
* loaded here, a role pm starts on a broken tree runs the copy too */
|
|
15
|
+
const loadRole = (cmd: string | undefined): Promise<unknown> | null =>
|
|
16
|
+
cmd === 'daemon' ? import('./home/run-daemon.ts')
|
|
17
|
+
: cmd === 'web' ? import('./home/run-web.ts')
|
|
18
|
+
: cmd === 'pm' ? import('./home/run-pm.ts')
|
|
19
|
+
: cmd === 'serve' ? import('./serve/run-serve.ts')
|
|
20
|
+
: null
|
|
48
21
|
|
|
49
|
-
/** the
|
|
50
|
-
*
|
|
51
|
-
*
|
|
52
|
-
export
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
22
|
+
/** every module the boot runs, loaded, with the runner of the role `cmd`
|
|
23
|
+
* names. The load probe asks it too (core/load-probe.ts), so a restart is
|
|
24
|
+
* tried on exactly this */
|
|
25
|
+
export const loadBoot = (cmd?: string) =>
|
|
26
|
+
Promise.all([
|
|
27
|
+
import('./cli/registry.ts'),
|
|
28
|
+
import('./cli/cmd_CrossRepoActivity.ts'),
|
|
29
|
+
import('./home/handoff.ts'),
|
|
30
|
+
import('./core/one-lib.ts'),
|
|
31
|
+
import('./cli/kit/calls.ts'),
|
|
32
|
+
import('./home/autostart.ts'),
|
|
33
|
+
loadRole(cmd),
|
|
34
|
+
])
|
|
60
35
|
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
cli.register(ReleaseCommand)
|
|
67
|
-
cli.register(ReleasesCommand)
|
|
68
|
-
cli.register(HooksInstallCommand)
|
|
69
|
-
cli.register(HooksPreCommitCommand)
|
|
70
|
-
cli.register(HooksCommitMsgCommand)
|
|
71
|
-
cli.register(HooksListCommand)
|
|
72
|
-
cli.register(MvCommand)
|
|
73
|
-
cli.register(CheckCommand)
|
|
74
|
-
cli.register(TuiCommand)
|
|
75
|
-
for (const c of TRAILER_COMMANDS) cli.register(c)
|
|
76
|
-
for (const c of REVIEW_COMMANDS) cli.register(c)
|
|
77
|
-
cli.register(ServeCommand)
|
|
78
|
-
cli.register(PmCommand)
|
|
79
|
-
cli.register(DaemonCommand)
|
|
80
|
-
cli.register(WebCommand)
|
|
81
|
-
cli.register(PmInstallCommand)
|
|
82
|
-
cli.register(PmUninstallCommand)
|
|
83
|
-
cli.register(GuiCommand)
|
|
84
|
-
cli.register(ContractsCommand)
|
|
85
|
-
cli.register(AsksCommand)
|
|
86
|
-
cli.register(AsksCheckCommand)
|
|
87
|
-
cli.register(AskNewCommand)
|
|
88
|
-
cli.register(WikiServeCommand)
|
|
89
|
-
cli.register(WikiLintCommand)
|
|
90
|
-
cli.register(AssetsFillCommand)
|
|
91
|
-
cli.register(AssetsAddCommand)
|
|
92
|
-
cli.register(AssetsGenCommand)
|
|
93
|
-
cli.register(AssetsIdentityCommand)
|
|
94
|
-
cli.register(AssetsCheckCommand)
|
|
95
|
-
cli.register(InitCommand)
|
|
96
|
-
cli.register(StartCommand)
|
|
97
|
-
cli.register(NeedsCommand)
|
|
98
|
-
cli.register(FleetCommand)
|
|
99
|
-
cli.register(PbCollectionCommand)
|
|
100
|
-
cli.register(SmokeCommand)
|
|
101
|
-
cli.register(ShotCommand)
|
|
102
|
-
cli.register(ReflectCommand)
|
|
103
|
-
cli.register(KeysCommand)
|
|
104
|
-
cli.register(GlobalProcessesCommand)
|
|
105
|
-
cli.register(GlobalProcessStartCommand)
|
|
106
|
-
cli.register(GlobalProcessStopCommand)
|
|
107
|
-
cli.register(GlobalProcessRestartCommand)
|
|
108
|
-
cli.register(GlobalProcessOpenCommand)
|
|
109
|
-
cli.register(CrossRepoActivityCommand)
|
|
110
|
-
cli.register(PlannedCommand)
|
|
111
|
-
cli.register(InsightsCommand)
|
|
112
|
-
cli.register(GlobalContextCommand)
|
|
113
|
-
cli.register(OverviewCommand)
|
|
114
|
-
cli.register(ChecksCommand)
|
|
115
|
-
cli.register(WhenCommand)
|
|
116
|
-
cli.register(CacheAuditCommand)
|
|
117
|
-
cli.register(TraceAuditCommand)
|
|
118
|
-
cli.register(UnitsAliasCommand)
|
|
119
|
-
cli.register(CommandsCommand)
|
|
120
|
-
cli.register(CallsCommand)
|
|
121
|
-
cli.register(TimeCommand)
|
|
122
|
-
cli.register(ConfigKeysCommand)
|
|
123
|
-
|
|
124
|
-
return cli
|
|
125
|
-
}
|
|
126
|
-
|
|
127
|
-
// only when this file IS the process, so importing it costs nothing. A repo's
|
|
128
|
-
// installed copy first hands the command to the house source (handoff.ts). One copy
|
|
129
|
-
// of the lib per process: a repo config that imports its own install lands on
|
|
130
|
-
// this one (one-lib.ts), so the machine's version runs in every repo. The run
|
|
131
|
-
// is logged (cli/kit/calls.ts, read back by `sk calls`). Then pm is started
|
|
132
|
-
// when it is down (autostart.ts), before the command runs
|
|
36
|
+
// only when this file IS the process. A repo's installed copy first hands the
|
|
37
|
+
// command to the house source (handoff.ts). One copy of the lib per process:
|
|
38
|
+
// a repo config that imports its own install lands on this one (one-lib.ts).
|
|
39
|
+
// The run is logged (cli/kit/calls.ts, read back by `sk calls`). Then pm is
|
|
40
|
+
// started when it is down (autostart.ts), before the command runs
|
|
133
41
|
if (import.meta.main) {
|
|
134
|
-
const
|
|
135
|
-
|
|
42
|
+
const raw = process.argv.slice(2)
|
|
43
|
+
let m
|
|
44
|
+
try {
|
|
45
|
+
m = await loadBoot(raw[0])
|
|
46
|
+
} catch (e) {
|
|
47
|
+
runLastGood(e, raw)
|
|
48
|
+
throw e
|
|
49
|
+
}
|
|
50
|
+
bootLoaded()
|
|
51
|
+
const [{ buildCli }, { aliasArgs }, { handoff }, { installOneLib }, { callsFile, logCall }, { autostart }] = m
|
|
52
|
+
const argv = aliasArgs(raw)
|
|
136
53
|
handoff(argv)
|
|
137
|
-
const { installOneLib } = await import('./core/one-lib.ts')
|
|
138
54
|
installOneLib()
|
|
139
|
-
const { callsFile, logCall } = await import('./cli/kit/calls.ts')
|
|
140
55
|
logCall(callsFile((await import('os')).homedir(), 'sk'), 'sk', argv)
|
|
141
|
-
const { autostart } = await import('./home/autostart.ts')
|
|
142
56
|
await autostart(argv)
|
|
143
57
|
void buildCli().runExit(argv)
|
|
144
58
|
}
|
package/src/core/app-icon.ts
CHANGED
|
@@ -10,8 +10,9 @@ import { identityOfName, identityOfText, type Identity } from './identity-color.
|
|
|
10
10
|
import { configName, CONFIG_FILE } from './identity.ts'
|
|
11
11
|
import { LAYOUT } from './layout.ts'
|
|
12
12
|
|
|
13
|
-
/** the mark an app wears: the repo's own, largest first
|
|
14
|
-
|
|
13
|
+
/** the mark an app wears: the repo's own, largest first; an svg mark is drawn
|
|
14
|
+
* as an image too (the canvas decodes it) */
|
|
15
|
+
export const APP_ICON_FILES = [`${LAYOUT.identity}/icon.png`, `${LAYOUT.identity}/icon.svg`, 'public/logo.png', 'public/favicon.png']
|
|
15
16
|
|
|
16
17
|
export function appIconOf(root: string): string | null {
|
|
17
18
|
for (const f of APP_ICON_FILES) if (existsSync(join(root, f))) return join(root, f)
|
|
@@ -133,15 +134,18 @@ export async function drawAppIcon(bytes: Uint8Array, id: Identity): Promise<{ ca
|
|
|
133
134
|
return { canvas: out, plate: colour }
|
|
134
135
|
}
|
|
135
136
|
|
|
136
|
-
/** the icon as pngs, one per size asked, drawn once
|
|
137
|
-
|
|
137
|
+
/** the icon as pngs, one per size asked, drawn once. `plate` crops the macOS
|
|
138
|
+
* grid's margin away, the rounded plate edge to edge (the hub's rail) */
|
|
139
|
+
export async function appIconPngs(bytes: Uint8Array, id: Identity, sizes: number[] = [ICON_PX], crop: 'canvas' | 'plate' = 'canvas'): Promise<Buffer[]> {
|
|
138
140
|
const { canvas } = await drawAppIcon(bytes, id)
|
|
141
|
+
const o = crop === 'plate' ? (ICON_PX - PLATE) / 2 : 0
|
|
142
|
+
const side = crop === 'plate' ? PLATE : ICON_PX
|
|
139
143
|
return sizes.map((px) => {
|
|
140
|
-
if (px === ICON_PX) return canvas.toBuffer('image/png')
|
|
144
|
+
if (px === ICON_PX && crop === 'canvas') return canvas.toBuffer('image/png')
|
|
141
145
|
const small = createCanvas(px, px)
|
|
142
146
|
const ctx = small.getContext('2d')
|
|
143
147
|
ctx.imageSmoothingQuality = 'high'
|
|
144
|
-
ctx.drawImage(canvas, 0, 0, px, px)
|
|
148
|
+
ctx.drawImage(canvas, o, o, side, side, 0, 0, px, px)
|
|
145
149
|
return small.toBuffer('image/png')
|
|
146
150
|
})
|
|
147
151
|
}
|
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
// the last good copy of the toolkit: what `sk` and the home run while the
|
|
2
|
+
// checkout fails to load. The daemon clones the source after a load that
|
|
3
|
+
// works (home/lib-watch.ts); the boot (cli.ts) runs the copy when the tree
|
|
4
|
+
// fails to load, with one line that names the file.
|
|
5
|
+
//
|
|
6
|
+
// the one constraint a reader breaks first: cli.ts imports this file
|
|
7
|
+
// statically, so it imports node builtins only. A lib import here would let
|
|
8
|
+
// a broken file stop the boot before it can fall back.
|
|
9
|
+
import { constants, copyFileSync, existsSync, mkdirSync, readdirSync, readFileSync, readlinkSync, realpathSync, renameSync, rmSync, symlinkSync, writeFileSync } from 'fs'
|
|
10
|
+
import { homedir } from 'os'
|
|
11
|
+
import { join, relative } from 'path'
|
|
12
|
+
|
|
13
|
+
/** set on a process that runs the copy: the Held record, as json */
|
|
14
|
+
export const LAST_GOOD_ENV = 'SHIPKIT_LAST_GOOD'
|
|
15
|
+
|
|
16
|
+
/** why a process runs code older than its tree */
|
|
17
|
+
export type Held = {
|
|
18
|
+
/** epoch ms the tree was first seen failing */
|
|
19
|
+
since: number
|
|
20
|
+
/** the file that fails, relative to the lib (`src/trailer/graph.ts:12`) */
|
|
21
|
+
file: string
|
|
22
|
+
error: string
|
|
23
|
+
/** the lib root whose tree fails */
|
|
24
|
+
from: string
|
|
25
|
+
/** epoch ms the copy was taken, null when this process runs its own boot image */
|
|
26
|
+
copyAt: number | null
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** how long a replaced copy stays, from when it stopped being current: a
|
|
30
|
+
* process that runs it may still import a module from it lazily */
|
|
31
|
+
export const OLD_COPY_MS = 10 * 60_000
|
|
32
|
+
|
|
33
|
+
/** this lib's root (its package.json beside src) */
|
|
34
|
+
export const OWN_LIB = join(import.meta.dir, '../..')
|
|
35
|
+
|
|
36
|
+
const real = (p: string): string => {
|
|
37
|
+
try {
|
|
38
|
+
return realpathSync(p)
|
|
39
|
+
} catch {
|
|
40
|
+
return p
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export const lastGoodHome = (env: Record<string, string | undefined> = process.env): string =>
|
|
45
|
+
env.SHIPKIT_LAST_GOOD_HOME != null && env.SHIPKIT_LAST_GOOD_HOME !== '' ? env.SHIPKIT_LAST_GOOD_HOME : join(homedir(), '.cache/shipkit/last-good')
|
|
46
|
+
|
|
47
|
+
/** one folder per lib checkout, so a worktree never runs the main checkout's copy */
|
|
48
|
+
export const lastGoodDir = (lib: string, home = lastGoodHome()): string => join(home, Bun.hash(real(lib)).toString(16).padStart(16, '0'))
|
|
49
|
+
|
|
50
|
+
const STAMP = 'stamp.json'
|
|
51
|
+
/** in a copy that is no longer current: when it stopped being */
|
|
52
|
+
const RETIRED = 'retired'
|
|
53
|
+
|
|
54
|
+
/** a copy carries a stamp: the lib it was taken from and when */
|
|
55
|
+
export function copyStamp(root: string): { at: number; from: string } | null {
|
|
56
|
+
try {
|
|
57
|
+
const j = JSON.parse(readFileSync(join(root, STAMP), 'utf8')) as { at?: number; from?: string }
|
|
58
|
+
return typeof j.at === 'number' && typeof j.from === 'string' ? { at: j.at, from: j.from } : null
|
|
59
|
+
} catch {
|
|
60
|
+
return null
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** the copy's cli and when it was taken, null when the lib has none */
|
|
65
|
+
export function lastGood(lib: string, home = lastGoodHome()): { cli: string; at: number } | null {
|
|
66
|
+
const cur = join(lastGoodDir(lib, home), 'current')
|
|
67
|
+
const s = copyStamp(cur)
|
|
68
|
+
const cli = join(cur, 'src/cli.ts')
|
|
69
|
+
return s == null || !existsSync(cli) ? null : { cli, at: s.at }
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** clone the lib's source as the last good copy (APFS clone: about 0.2 s for
|
|
73
|
+
* the whole source), its node_modules linked, probe it in its own folder,
|
|
74
|
+
* then point `current` at it. Throws when the clone does not load */
|
|
75
|
+
export async function takeLastGood(lib: string, o: { home?: string; now?: number } = {}): Promise<{ dir: string; ms: number }> {
|
|
76
|
+
const t0 = performance.now()
|
|
77
|
+
const now = o.now ?? Date.now()
|
|
78
|
+
const base = lastGoodDir(lib, o.home)
|
|
79
|
+
const name = `v${now}`
|
|
80
|
+
const dir = join(base, name)
|
|
81
|
+
mkdirSync(dir, { recursive: true })
|
|
82
|
+
const cp = Bun.spawn(['cp', '-c', '-R', join(lib, 'src'), join(dir, 'src')], { stdout: 'ignore', stderr: 'pipe' })
|
|
83
|
+
if ((await cp.exited) !== 0) {
|
|
84
|
+
const err = (await new Response(cp.stderr).text()).trim()
|
|
85
|
+
rmSync(dir, { recursive: true, force: true })
|
|
86
|
+
throw new Error(`could not clone ${lib}/src: ${err}`)
|
|
87
|
+
}
|
|
88
|
+
for (const f of ['package.json', 'tsconfig.json']) if (existsSync(join(lib, f))) copyFileSync(join(lib, f), join(dir, f), constants.COPYFILE_FICLONE)
|
|
89
|
+
symlinkSync(join(real(lib), 'node_modules'), join(dir, 'node_modules'))
|
|
90
|
+
writeFileSync(join(dir, STAMP), `${JSON.stringify({ at: now, from: real(lib) })}\n`)
|
|
91
|
+
// a write that landed during the clone is in it: the copy loads in its own folder, or never becomes current
|
|
92
|
+
const { probeLoad, bootEntries } = await import('./load-probe.ts')
|
|
93
|
+
const a = await probeLoad(dir, bootEntries(dir, 'all'))
|
|
94
|
+
if (!a.ok) {
|
|
95
|
+
rmSync(dir, { recursive: true, force: true })
|
|
96
|
+
throw new Error(`the clone fails to load (${a.file}: ${a.error}), so the copy before it stays current`)
|
|
97
|
+
}
|
|
98
|
+
// a link renamed over the old one: a reader sees the old copy or the new, never none
|
|
99
|
+
const link = join(base, `.current-${process.pid}`)
|
|
100
|
+
rmSync(link, { force: true })
|
|
101
|
+
symlinkSync(name, link)
|
|
102
|
+
let was: string | null = null
|
|
103
|
+
try { was = readlinkSync(join(base, 'current')) } catch { /* the first copy */ }
|
|
104
|
+
renameSync(link, join(base, 'current'))
|
|
105
|
+
// a copy ages from when it stopped being current: a process that runs it keeps its modules
|
|
106
|
+
if (was != null && was !== name && existsSync(join(base, was))) writeFileSync(join(base, was, RETIRED), `${now}\n`)
|
|
107
|
+
for (const e of readdirSync(base)) {
|
|
108
|
+
const at = /^v(\d+)$/.exec(e)
|
|
109
|
+
if (at == null || e === name) continue
|
|
110
|
+
let since = Number(at[1])
|
|
111
|
+
try { since = Number(readFileSync(join(base, e, RETIRED), 'utf8')) } catch { /* never current: a clone that failed or was cut */ }
|
|
112
|
+
if (now - since > OLD_COPY_MS) rmSync(join(base, e), { recursive: true, force: true })
|
|
113
|
+
}
|
|
114
|
+
return { dir, ms: performance.now() - t0 }
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** the lib file that imports `name` from `module`, read only on a failure */
|
|
118
|
+
function importerOf(lib: string, name: string, module: string): string | null {
|
|
119
|
+
const want = real(module).replace(/\.[cm]?[jt]sx?$/, '')
|
|
120
|
+
const re = new RegExp(`(?:import|export)\\s*(?:type\\s*)?\\{[^}]*\\b${name.replace(/[^\w$]/g, '')}\\b[^}]*\\}\\s*from\\s*['"]([^'"]+)['"]`, 'g')
|
|
121
|
+
for (const f of new Bun.Glob('src/**/*.{ts,tsx}').scanSync(lib)) {
|
|
122
|
+
const full = join(lib, f)
|
|
123
|
+
let text: string
|
|
124
|
+
try { text = readFileSync(full, 'utf8') } catch { continue }
|
|
125
|
+
if (!text.includes(name)) continue
|
|
126
|
+
for (const m of text.matchAll(re)) {
|
|
127
|
+
if (!m[1]!.startsWith('.')) continue
|
|
128
|
+
if (real(join(full, '..', m[1]!)).replace(/\.[cm]?[jt]sx?$/, '') === want) return full
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
return null
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/** the file a load error names, relative to `lib`, and its first line */
|
|
135
|
+
export function loadFailure(e: unknown, lib: string): { file: string; error: string } {
|
|
136
|
+
const x = (e ?? {}) as { message?: string; position?: { file?: string; line?: number } | null; referrer?: string; stack?: string }
|
|
137
|
+
const rel = (p: string): string => {
|
|
138
|
+
const r = relative(real(lib), real(p))
|
|
139
|
+
return r.startsWith('..') ? p : r
|
|
140
|
+
}
|
|
141
|
+
const error = String(x.message ?? e).split('\n')[0]!.slice(0, 200)
|
|
142
|
+
// bun names the module a missing export was asked of, never the file that asked
|
|
143
|
+
const miss = /Export named '([^']+)' not found in module '([^']+)'/.exec(error)
|
|
144
|
+
if (miss != null) {
|
|
145
|
+
const importer = importerOf(lib, miss[1]!, miss[2]!)
|
|
146
|
+
return { file: rel(importer ?? miss[2]!), error: `imports '${miss[1]}', which ${rel(miss[2]!)} does not export` }
|
|
147
|
+
}
|
|
148
|
+
if (x.position?.file != null) return { file: `${rel(x.position.file)}${x.position.line != null ? `:${x.position.line}` : ''}`, error }
|
|
149
|
+
if (typeof x.referrer === 'string' && x.referrer !== '') return { file: rel(x.referrer), error }
|
|
150
|
+
const at = /\(?((?:\/[^\s():]+)+\.[cm]?[jt]sx?):(\d+)/.exec(x.stack ?? '')
|
|
151
|
+
return { file: at == null ? '?' : `${rel(at[1]!)}:${at[2]}`, error }
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
const hhmm = (t: number): string => new Date(t).toTimeString().slice(0, 5)
|
|
155
|
+
|
|
156
|
+
/** when a copy was taken: HH:MM within the last hour of today, else its
|
|
157
|
+
* date, time and age, so an old copy is never mistaken for a fresh one */
|
|
158
|
+
export function copyWhen(at: number, now = Date.now()): string {
|
|
159
|
+
const d = new Date(at)
|
|
160
|
+
if (d.toDateString() === new Date(now).toDateString() && now - at < 3_600_000) return hhmm(at)
|
|
161
|
+
const age = now - at
|
|
162
|
+
const old = age < 48 * 3_600_000 ? `${Math.round(age / 3_600_000)} h old` : `${Math.floor(age / 86_400_000)} d old`
|
|
163
|
+
const day = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`
|
|
164
|
+
return `${day} ${hhmm(at)}, ${old}`
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/** the one line every surface shows (the hub, the overview, a log) */
|
|
168
|
+
export const heldText = (h: Held): string =>
|
|
169
|
+
`running last good code since ${hhmm(h.since)} because ${h.file} fails to load: ${h.error}${h.copyAt == null ? '' : ` (the copy from ${copyWhen(h.copyAt)})`}`
|
|
170
|
+
|
|
171
|
+
let held: Held | null = null
|
|
172
|
+
|
|
173
|
+
/** this process's own state: a refused restart set it, or the boot that ran
|
|
174
|
+
* the copy left it in the env */
|
|
175
|
+
export function heldNow(): Held | null {
|
|
176
|
+
if (held != null) return held
|
|
177
|
+
const raw = process.env[LAST_GOOD_ENV]
|
|
178
|
+
if (raw == null || raw === '' || copyStamp(OWN_LIB) == null) return null
|
|
179
|
+
try {
|
|
180
|
+
return JSON.parse(raw) as Held
|
|
181
|
+
} catch {
|
|
182
|
+
return null
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
export function setHeld(h: Held | null): void {
|
|
187
|
+
held = h
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/** the tree this process should go back to: its own, or the one its copy stands in for */
|
|
191
|
+
export const homeLib = (): string => (copyStamp(OWN_LIB)?.from ?? OWN_LIB)
|
|
192
|
+
|
|
193
|
+
/** the boot of a tree that loads: an env a copy left is not this process's */
|
|
194
|
+
export function bootLoaded(): void {
|
|
195
|
+
if (copyStamp(OWN_LIB) == null) delete process.env[LAST_GOOD_ENV]
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/** the boot of a tree that failed to load: runs the copy (never returns), or
|
|
199
|
+
* returns when there is none so the caller throws the error itself */
|
|
200
|
+
export function runLastGood(e: unknown, argv: string[]): void {
|
|
201
|
+
const f = loadFailure(e, OWN_LIB)
|
|
202
|
+
// the copy itself failing is the error to show, never a loop
|
|
203
|
+
if (copyStamp(OWN_LIB) != null) return
|
|
204
|
+
const copy = lastGood(OWN_LIB)
|
|
205
|
+
if (copy == null) {
|
|
206
|
+
process.stderr.write(`FAIL shipkit: ${f.file} fails to load (${f.error}), and this checkout has no last good copy: the home's daemon keeps one\n`)
|
|
207
|
+
return
|
|
208
|
+
}
|
|
209
|
+
const h: Held = { since: Date.now(), file: f.file, error: f.error, from: real(OWN_LIB), copyAt: copy.at }
|
|
210
|
+
process.stderr.write(`WARN shipkit: ${f.file} fails to load (${f.error}), so this runs the last good copy from ${copyWhen(copy.at)}\n`)
|
|
211
|
+
const env = Object.fromEntries(Object.entries({ ...process.env, [LAST_GOOD_ENV]: JSON.stringify(h) }).filter((x): x is [string, string] => x[1] != null))
|
|
212
|
+
const args = [process.execPath, ...process.execArgv, copy.cli, ...argv]
|
|
213
|
+
const execve = (process as { execve?: (file: string, args: string[], env: Record<string, string>) => never }).execve
|
|
214
|
+
if (typeof execve === 'function') execve.call(process, process.execPath, args, env)
|
|
215
|
+
const child = Bun.spawnSync(args, { cwd: process.cwd(), stdout: 'inherit', stderr: 'inherit', stdin: 'inherit', env })
|
|
216
|
+
process.exit(child.exitCode ?? 1)
|
|
217
|
+
}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
// does a tree load: a child bun imports its entries and says which file
|
|
2
|
+
// fails. Asked before a machine process restarts onto new code, and before
|
|
3
|
+
// the daemon keeps a copy as the last good one. Run as a child so a module
|
|
4
|
+
// that fails, or one whose top level runs, never touches the asking process.
|
|
5
|
+
import { join } from 'path'
|
|
6
|
+
import { loadFailure } from './last-good.ts'
|
|
7
|
+
import type { LoadVerdict } from '../serve/stale-restart.ts'
|
|
8
|
+
|
|
9
|
+
export type LoadAnswer = { ok: true; ms: number } | { ok: false; ms: number; file: string; error: string }
|
|
10
|
+
|
|
11
|
+
export type Role = 'daemon' | 'pm' | 'web' | 'serve'
|
|
12
|
+
|
|
13
|
+
const RUNNER: Record<Role, string> = { daemon: 'src/home/run-daemon.ts', pm: 'src/home/run-pm.ts', web: 'src/home/run-web.ts', serve: 'src/serve/run-serve.ts' }
|
|
14
|
+
|
|
15
|
+
/** the modules a restart through the cli loads: the boot's (cli.ts
|
|
16
|
+
* `loadBoot`: the registry and what runs before it) and the role's runner;
|
|
17
|
+
* `all`: every role's, what a copy every process may run must load */
|
|
18
|
+
export const bootEntries = (lib: string, role?: Role | 'all'): string[] => [
|
|
19
|
+
join(lib, 'src/cli.ts'),
|
|
20
|
+
...(role == null ? [] : (role === 'all' ? Object.values(RUNNER) : [RUNNER[role]]).map((r) => join(lib, r))),
|
|
21
|
+
]
|
|
22
|
+
|
|
23
|
+
/** import `entries` in a child bun from `lib`'s own probe file */
|
|
24
|
+
export async function probeLoad(lib: string, entries: string[]): Promise<LoadAnswer> {
|
|
25
|
+
const t0 = performance.now()
|
|
26
|
+
const p = Bun.spawn([process.execPath, join(lib, 'src/core/load-probe.ts'), ...entries], { cwd: lib, stdout: 'pipe', stderr: 'pipe', env: { ...process.env, SHIPKIT_NO_HOME: '1' } })
|
|
27
|
+
const [out, err, code] = await Promise.all([new Response(p.stdout).text(), new Response(p.stderr).text(), p.exited])
|
|
28
|
+
const ms = performance.now() - t0
|
|
29
|
+
if (code === 0) return { ok: true, ms }
|
|
30
|
+
try {
|
|
31
|
+
const j = JSON.parse(out.trim().split('\n').at(-1) ?? '') as { file: string; error: string }
|
|
32
|
+
return { ok: false, ms, file: j.file, error: j.error }
|
|
33
|
+
} catch {
|
|
34
|
+
// the probe file itself did not load
|
|
35
|
+
const line = err.split('\n').find((l) => /error/i.test(l)) ?? err.trim().split('\n')[0] ?? `exit ${code}`
|
|
36
|
+
return { ok: false, ms, file: 'src/core/load-probe.ts', error: line.trim().slice(0, 200) }
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** one probe per state of the tree: asked again only once the key moves */
|
|
41
|
+
export class TreeProbe {
|
|
42
|
+
private key: string | null = null
|
|
43
|
+
private verdict: LoadVerdict = 'pending'
|
|
44
|
+
|
|
45
|
+
constructor(private readonly run: () => Promise<LoadAnswer>, private readonly keyOf: () => string) {}
|
|
46
|
+
|
|
47
|
+
loads(): LoadVerdict {
|
|
48
|
+
const k = this.keyOf()
|
|
49
|
+
if (k !== this.key) {
|
|
50
|
+
this.key = k
|
|
51
|
+
this.verdict = 'pending'
|
|
52
|
+
const land = (v: LoadVerdict): void => { if (this.key === k) this.verdict = v }
|
|
53
|
+
void this.run().then((a) => land(a.ok ? 'ok' : { file: a.file, error: a.error }), (e: Error) => land({ file: '?', error: e.message }))
|
|
54
|
+
}
|
|
55
|
+
return this.verdict
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
if (import.meta.main) {
|
|
60
|
+
const lib = join(import.meta.dir, '../..')
|
|
61
|
+
for (const e of process.argv.slice(2)) {
|
|
62
|
+
try {
|
|
63
|
+
const m = (await import(e)) as { loadBoot?: () => Promise<unknown> }
|
|
64
|
+
if (typeof m.loadBoot === 'function') await m.loadBoot()
|
|
65
|
+
} catch (x) {
|
|
66
|
+
console.log(JSON.stringify(loadFailure(x, lib)))
|
|
67
|
+
process.exit(3)
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
process.exit(0)
|
|
71
|
+
}
|
package/src/core/reexec.ts
CHANGED
|
@@ -5,7 +5,9 @@
|
|
|
5
5
|
// code, which keeps the terminal but leaves this process waiting under it.
|
|
6
6
|
import { existsSync } from 'fs'
|
|
7
7
|
|
|
8
|
-
|
|
8
|
+
/** `entry` in place of the running script: a process on the last good copy
|
|
9
|
+
* goes back to the tree's own cli (core/last-good.ts) */
|
|
10
|
+
const argvOf = (entry?: string): string[] => [process.execPath, ...process.execArgv, ...(entry == null ? process.argv.slice(1) : [entry, ...process.argv.slice(2)])]
|
|
9
11
|
|
|
10
12
|
/** why this invocation cannot run again, null when it can. Asked BEFORE a
|
|
11
13
|
* caller tears anything down, so a refusal leaves the process serving */
|
|
@@ -16,8 +18,8 @@ export function reexecBlocker(): string | null {
|
|
|
16
18
|
}
|
|
17
19
|
|
|
18
20
|
/** never returns. `env` defaults to this process's own */
|
|
19
|
-
export function reexecSelf(env: Record<string, string | undefined> = process.env): never {
|
|
20
|
-
const argv = argvOf()
|
|
21
|
+
export function reexecSelf(env: Record<string, string | undefined> = process.env, entry?: string): never {
|
|
22
|
+
const argv = argvOf(entry)
|
|
21
23
|
const clean = Object.fromEntries(Object.entries(env).filter((e): e is [string, string] => e[1] != null))
|
|
22
24
|
const execve = (process as { execve?: (file: string, args: string[], env: Record<string, string>) => never }).execve
|
|
23
25
|
if (typeof execve === 'function') execve.call(process, process.execPath, argv, clean)
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
// push every house repo that holds commits its remote does not: `sk fleet
|
|
2
|
+
// push` and the activity page's push button run this one path. A plain
|
|
3
|
+
// `git push`, never a force: a branch that also fell behind its remote waits
|
|
4
|
+
// for a pull, a branch with no upstream goes to its one remote with -u, and
|
|
5
|
+
// git itself refuses anything that is not a fast-forward. Local git only, no
|
|
6
|
+
// fetch first: "behind" is as of the last fetch, and the push is what finds
|
|
7
|
+
// out for sure.
|
|
8
|
+
import { parseStatus } from '../home/activity.ts'
|
|
9
|
+
|
|
10
|
+
export type PushAction = 'push' | 'push-upstream' | 'none' | 'skip'
|
|
11
|
+
export type PushPlan = { name: string; path: string; branch: string | null; remote: string | null; ahead: number; action: PushAction; why: string }
|
|
12
|
+
export type PushResult = PushPlan & { ok: boolean; line: string }
|
|
13
|
+
|
|
14
|
+
/** at most this many pushes at once */
|
|
15
|
+
const PARALLEL = 4
|
|
16
|
+
const PUSH_MS = 60_000
|
|
17
|
+
|
|
18
|
+
async function git(cwd: string, args: string[], ms = 15_000): Promise<{ code: number; out: string; err: string }> {
|
|
19
|
+
// a push that would ask for a password fails instead of hanging the page
|
|
20
|
+
const p = Bun.spawn(['git', ...args], { cwd, stdout: 'pipe', stderr: 'pipe', env: { ...process.env, GIT_TERMINAL_PROMPT: '0' } })
|
|
21
|
+
const timer = setTimeout(() => p.kill(), ms)
|
|
22
|
+
const [out, err, code] = await Promise.all([new Response(p.stdout).text(), new Response(p.stderr).text(), p.exited])
|
|
23
|
+
clearTimeout(timer)
|
|
24
|
+
return { code, out, err }
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** what to do with one repo, from `git status --porcelain=v2 --branch`, its
|
|
28
|
+
* remotes and its commit count. Pure: the tests feed it text */
|
|
29
|
+
export function planOf(repo: { name: string; path: string }, status: string, remotes: string[], commits: number): PushPlan {
|
|
30
|
+
const s = parseStatus(status)
|
|
31
|
+
const base = { name: repo.name, path: repo.path, branch: s.branch, remote: null as string | null, ahead: s.ahead ?? 0 }
|
|
32
|
+
if (s.branch == null) return { ...base, action: 'skip', why: s.detached != null ? `detached at ${s.detached}: no branch to push` : 'no commit yet' }
|
|
33
|
+
if (s.upstream == null) {
|
|
34
|
+
if (commits === 0) return { ...base, action: 'none', why: 'no commit yet' }
|
|
35
|
+
const remote = remotes.length === 1 ? remotes[0]! : remotes.includes('origin') ? 'origin' : null
|
|
36
|
+
if (remote == null) return { ...base, action: 'skip', why: remotes.length === 0 ? 'no remote to push to' : `no upstream, and ${remotes.length} remotes: set one with git push -u <remote> ${s.branch}` }
|
|
37
|
+
return { ...base, remote, ahead: commits, action: 'push-upstream', why: `no upstream: push -u ${remote} ${s.branch}` }
|
|
38
|
+
}
|
|
39
|
+
if (s.gone) return { ...base, action: 'skip', why: `its upstream ${s.upstream} is gone` }
|
|
40
|
+
const ahead = s.ahead ?? 0
|
|
41
|
+
const behind = s.behind ?? 0
|
|
42
|
+
if (ahead === 0) return { ...base, action: 'none', why: 'pushed' }
|
|
43
|
+
if (behind > 0) return { ...base, action: 'skip', why: `${ahead} ahead and ${behind} behind ${s.upstream}: pull first, never a force` }
|
|
44
|
+
return { ...base, remote: s.upstream.split('/')[0] ?? null, action: 'push', why: `${ahead} unpushed to ${s.upstream}` }
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export async function pushPlan(repo: { name: string; path: string }): Promise<PushPlan> {
|
|
48
|
+
const [status, remotes, count] = await Promise.all([
|
|
49
|
+
git(repo.path, ['status', '--porcelain=v2', '--branch']),
|
|
50
|
+
git(repo.path, ['remote']),
|
|
51
|
+
git(repo.path, ['rev-list', '--count', 'HEAD']),
|
|
52
|
+
])
|
|
53
|
+
if (status.code !== 0) return { name: repo.name, path: repo.path, branch: null, remote: null, ahead: 0, action: 'skip', why: `git status failed: ${status.err.trim().split('\n')[0]}` }
|
|
54
|
+
return planOf(repo, status.out, remotes.out.split('\n').filter((r) => r !== ''), Number(count.out.trim()) || 0)
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
async function pushOne(p: PushPlan): Promise<PushResult> {
|
|
58
|
+
if (p.action !== 'push' && p.action !== 'push-upstream') return { ...p, ok: p.action === 'none', line: `${p.action === 'none' ? 'ok' : 'skip'}: ${p.name}: ${p.why}` }
|
|
59
|
+
const args = p.action === 'push' ? ['push'] : ['push', '-u', p.remote!, p.branch!]
|
|
60
|
+
const r = await git(p.path, args, PUSH_MS)
|
|
61
|
+
if (r.code === 0) return { ...p, ok: true, line: `ok: ${p.name}: pushed ${p.ahead} commit${p.ahead === 1 ? '' : 's'} (${p.branch})` }
|
|
62
|
+
const why = r.err.split('\n').map((l) => l.trim()).filter((l) => l !== '' && !l.startsWith('hint:')).slice(-1)[0] ?? `git push exited ${r.code}`
|
|
63
|
+
return { ...p, ok: false, line: `FAIL: ${p.name}: git ${args.join(' ')}: ${why}` }
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** every repo planned, the ones that need it pushed, a few at a time; `dry`
|
|
67
|
+
* plans only. Results come back in the repos' order */
|
|
68
|
+
export async function pushFleet(repos: { name: string; path: string }[], o: { dry?: boolean } = {}): Promise<PushResult[]> {
|
|
69
|
+
const plans = await Promise.all(repos.map(pushPlan))
|
|
70
|
+
if (o.dry === true) return plans.map((p) => ({ ...p, ok: p.action !== 'skip', line: `${p.action === 'skip' ? 'skip' : p.action === 'none' ? 'ok' : 'would push'}: ${p.name}: ${p.why}` }))
|
|
71
|
+
const out: PushResult[] = new Array(plans.length)
|
|
72
|
+
let next = 0
|
|
73
|
+
await Promise.all(Array.from({ length: PARALLEL }, async () => {
|
|
74
|
+
while (next < plans.length) {
|
|
75
|
+
const i = next++
|
|
76
|
+
out[i] = await pushOne(plans[i]!)
|
|
77
|
+
}
|
|
78
|
+
}))
|
|
79
|
+
return out
|
|
80
|
+
}
|