@orkestrel/scaffold 0.0.101 → 0.0.102

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (58) hide show
  1. package/dist/agents/skills/orkestrel-dispatch/scripts/brief.js +6 -14
  2. package/dist/agents/skills/orkestrel-dispatch/scripts/cite.js +41 -28
  3. package/dist/agents/skills/orkestrel-dispatch/scripts/helpers.js +5 -12
  4. package/dist/agents/skills/orkestrel-dispatch/scripts/launch.js +25 -13
  5. package/dist/agents/skills/orkestrel-dispatch/scripts/login.js +18 -25
  6. package/dist/agents/skills/orkestrel-dispatch/scripts/result.js +11 -9
  7. package/dist/agents/skills/orkestrel-harden/scripts/discovery.js +233 -232
  8. package/dist/agents/skills/orkestrel-publish/scripts/compare.js +95 -89
  9. package/dist/agents/skills/orkestrel-publish/scripts/pins.js +5 -7
  10. package/dist/agents/skills/orkestrel-publish/scripts/swap.js +1273 -0
  11. package/dist/agents/skills/orkestrel-publish/scripts/wave.js +99 -89
  12. package/dist/agents/skills/orkestrel-publish/scripts/window.js +54 -52
  13. package/dist/agents/skills/orkestrel-scout/scripts/map.js +160 -161
  14. package/dist/bin/main.js +483 -444
  15. package/dist/bin/main.js.map +1 -1
  16. package/dist/host/AGENTS.md +1 -0
  17. package/dist/host/agents/skills/orkestrel-dispatch/SKILL.md +10 -10
  18. package/dist/host/agents/skills/orkestrel-dispatch/scripts/brief.ts +8 -11
  19. package/dist/host/agents/skills/orkestrel-dispatch/scripts/cite.ts +58 -28
  20. package/dist/host/agents/skills/orkestrel-dispatch/scripts/helpers.ts +5 -10
  21. package/dist/host/agents/skills/orkestrel-dispatch/scripts/launch.ts +31 -12
  22. package/dist/host/agents/skills/orkestrel-dispatch/scripts/login.ts +28 -23
  23. package/dist/host/agents/skills/orkestrel-dispatch/scripts/result.ts +12 -10
  24. package/dist/host/agents/skills/orkestrel-harden/scripts/discovery.ts +318 -237
  25. package/dist/host/agents/skills/orkestrel-journey/references/statechart.md +2 -5
  26. package/dist/host/agents/skills/orkestrel-publish/SKILL.md +7 -6
  27. package/dist/host/agents/skills/orkestrel-publish/references/release.md +12 -10
  28. package/dist/host/agents/skills/orkestrel-publish/references/wave.md +12 -5
  29. package/dist/host/agents/skills/orkestrel-publish/scripts/compare.ts +124 -82
  30. package/dist/host/agents/skills/orkestrel-publish/scripts/pins.ts +5 -7
  31. package/dist/host/agents/skills/orkestrel-publish/scripts/swap.ts +1655 -0
  32. package/dist/host/agents/skills/orkestrel-publish/scripts/wave.ts +128 -97
  33. package/dist/host/agents/skills/orkestrel-publish/scripts/window.ts +70 -55
  34. package/dist/host/agents/skills/orkestrel-scout/scripts/map.ts +215 -155
  35. package/dist/host/claude/rules/architecture.md +2 -2
  36. package/dist/host/claude/rules/tests.md +8 -2
  37. package/dist/host/claude/rules/typescript.md +130 -0
  38. package/dist/host/claude/rules/workspace.md +30 -12
  39. package/dist/host/configs/helpers.ts +206 -133
  40. package/dist/host/configs/policy.ts +110 -105
  41. package/dist/host/guides/scaffold.md +244 -170
  42. package/dist/host/manifest.json +35 -29
  43. package/dist/host/tests/config.test.ts +1220 -633
  44. package/dist/host/tests/policy.test.ts +19 -0
  45. package/dist/host/tests/setupPolicy.ts +1669 -985
  46. package/dist/src/core/index.cjs +1595 -1686
  47. package/dist/src/core/index.cjs.map +1 -1
  48. package/dist/src/core/index.d.cts +202 -18
  49. package/dist/src/core/index.d.ts +202 -18
  50. package/dist/src/core/index.js +1590 -1687
  51. package/dist/src/core/index.js.map +1 -1
  52. package/dist/src/server/index.cjs +448 -392
  53. package/dist/src/server/index.cjs.map +1 -1
  54. package/dist/src/server/index.d.cts +1182 -1052
  55. package/dist/src/server/index.d.ts +1182 -1052
  56. package/dist/src/server/index.js +447 -395
  57. package/dist/src/server/index.js.map +1 -1
  58. package/package.json +2 -2
@@ -73,6 +73,16 @@ interface StepResult {
73
73
  readonly note: string | undefined
74
74
  }
75
75
 
76
+ interface Verdict {
77
+ readonly exit: number | undefined
78
+ readonly note: string | undefined
79
+ }
80
+
81
+ interface RangeMove {
82
+ readonly ranges: Record<string, unknown>
83
+ readonly moved: readonly string[]
84
+ }
85
+
76
86
  interface Ruling {
77
87
  readonly distMoved: boolean | undefined
78
88
  readonly rangesMoved: boolean | undefined
@@ -108,15 +118,15 @@ function readRanges(
108
118
  manifest: Record<string, unknown>,
109
119
  fields: readonly string[],
110
120
  ): ReadonlyMap<string, string> {
111
- const ranges = new Map<string, string>()
112
- for (const field of fields) {
113
- const block = manifest[field]
114
- if (typeof block !== 'object' || block === null) continue
115
- for (const [name, range] of Object.entries(block)) {
116
- if (typeof range === 'string') ranges.set(`${field} ${name}`, range)
117
- }
118
- }
119
- return ranges
121
+ return new Map(
122
+ fields.flatMap((field) => {
123
+ const block = manifest[field]
124
+ if (typeof block !== 'object' || block === null) return []
125
+ return Object.entries(block).flatMap(([name, range]) =>
126
+ typeof range === 'string' ? [[`${field} ${name}`, range] as const] : [],
127
+ )
128
+ }),
129
+ )
120
130
  }
121
131
 
122
132
  function compareRanges(
@@ -180,12 +190,19 @@ function describeFailure(result: SpawnSyncReturns<string>): string | undefined {
180
190
  .join(' | ')
181
191
  }
182
192
 
193
+ function readVerdict(result: SpawnSyncReturns<string>): Verdict {
194
+ return { exit: result.status ?? undefined, note: describeFailure(result) }
195
+ }
196
+
197
+ // A judge reads the finished command and answers the exit and note its one log entry carries, so no
198
+ // entry is amended after it is appended.
183
199
  function runStep(
184
200
  runner: Runner,
185
201
  step: string,
186
202
  file: string,
187
203
  args: readonly string[],
188
204
  cwd: string,
205
+ judge: (result: SpawnSyncReturns<string>) => Verdict = readVerdict,
189
206
  ): SpawnSyncReturns<string> | undefined {
190
207
  const command = describeCommand(file, args)
191
208
  if (runner.dry) {
@@ -199,16 +216,46 @@ function runStep(
199
216
  windowsHide: true,
200
217
  maxBuffer: 64 * 1024 * 1024,
201
218
  })
202
- runner.steps.push({
203
- step,
204
- command,
205
- exit: result.status ?? undefined,
206
- durationMs: Date.now() - started,
207
- note: describeFailure(result),
208
- })
219
+ const durationMs = Date.now() - started
220
+ const verdict = judge(result)
221
+ runner.steps.push({ step, command, exit: verdict.exit, durationMs, note: verdict.note })
209
222
  return result
210
223
  }
211
224
 
225
+ // The audit exits 1 on the drift the pin step exists to close, so its exit is a reading here.
226
+ function judgePin(result: SpawnSyncReturns<string>): Verdict {
227
+ const releases = readReleases(result.stdout)
228
+ if (releases === undefined || (result.status !== 0 && result.status !== 1)) {
229
+ return {
230
+ exit: result.status === null || result.status === 0 ? 2 : result.status,
231
+ note:
232
+ releases === undefined ? 'the audit printed no releases envelope' : describeFailure(result),
233
+ }
234
+ }
235
+ return { exit: 0, note: `${releases.length} release reading(s)` }
236
+ }
237
+
238
+ function judgeOverwrite(result: SpawnSyncReturns<string>, offline: boolean): Verdict {
239
+ if (!offline || result.status !== 1) return readVerdict(result)
240
+ const note = readString(readJsonObject(result.stdout), 'note')
241
+ return note !== undefined && note.includes(OFFLINE_REFUSAL)
242
+ ? { exit: 0, note: 'offline overwrite skipped the catalog step by design' }
243
+ : { exit: 1, note: describeFailure(result) }
244
+ }
245
+
246
+ // The audit reads the canonical sources of scaffold's own checkout as foreign paths and exits 1.
247
+ function judgeVerify(result: SpawnSyncReturns<string>, own: boolean): Verdict {
248
+ if (readReleases(result.stdout) === undefined) {
249
+ return {
250
+ exit: result.status === null || result.status === 0 ? 2 : result.status,
251
+ note: 'the audit printed no releases envelope',
252
+ }
253
+ }
254
+ return own && result.status === 1
255
+ ? { exit: 0, note: 'foreign-path findings are expected on the scaffold checkout' }
256
+ : readVerdict(result)
257
+ }
258
+
212
259
  function runNpmStep(
213
260
  runner: Runner,
214
261
  step: string,
@@ -235,11 +282,6 @@ function recordStep(runner: Runner, step: string, command: string, note: string)
235
282
  runner.steps.push({ step, command, exit: runner.dry ? undefined : 0, durationMs: 0, note })
236
283
  }
237
284
 
238
- function replaceLastStep(runner: Runner, exit: number | undefined, note: string | undefined): void {
239
- const last = runner.steps.pop()
240
- if (last !== undefined) runner.steps.push({ ...last, exit, note })
241
- }
242
-
243
285
  function failed(runner: Runner): boolean {
244
286
  return runner.steps.some(
245
287
  (entry) =>
@@ -250,26 +292,40 @@ function failed(runner: Runner): boolean {
250
292
  )
251
293
  }
252
294
 
295
+ // A release moves a declared range to its registry caret when the range differs, and a later release
296
+ // of the same name reads the range the earlier one wrote.
297
+ function computeRanges(ranges: Record<string, unknown>, releases: readonly Release[]): RangeMove {
298
+ return releases.reduce<RangeMove>(
299
+ (state, release) => {
300
+ if (release.latest === undefined || !(release.name in state.ranges)) return state
301
+ const caret = `^${release.latest}`
302
+ return state.ranges[release.name] === caret
303
+ ? state
304
+ : {
305
+ ranges: { ...state.ranges, [release.name]: caret },
306
+ moved: [...state.moved, `${release.name} ${String(release.range)} -> ${caret}`],
307
+ }
308
+ },
309
+ { ranges, moved: [] },
310
+ )
311
+ }
312
+
253
313
  function writeRanges(target: string, releases: readonly Release[]): readonly string[] {
254
314
  const manifest = readManifest(target)
255
315
  if (manifest === undefined) return []
256
- const moved: string[] = []
257
- for (const field of RANGE_FIELDS) {
316
+ const fields = RANGE_FIELDS.flatMap((field) => {
258
317
  const block = manifest[field]
259
- if (typeof block !== 'object' || block === null) continue
260
- const ranges = Object.fromEntries(Object.entries(block))
261
- for (const release of releases) {
262
- if (release.latest === undefined || !(release.name in ranges)) continue
263
- const caret = `^${release.latest}`
264
- if (ranges[release.name] !== caret) {
265
- ranges[release.name] = caret
266
- moved.push(`${release.name} ${String(release.range)} -> ${caret}`)
267
- }
318
+ if (typeof block !== 'object' || block === null) return []
319
+ return [{ field, ...computeRanges(Object.fromEntries(Object.entries(block)), releases) }]
320
+ })
321
+ const moved = fields.flatMap((entry) => entry.moved)
322
+ if (moved.length > 0) {
323
+ const written = {
324
+ ...manifest,
325
+ ...Object.fromEntries(fields.map((entry) => [entry.field, entry.ranges])),
268
326
  }
269
- manifest[field] = ranges
327
+ writeFileSync(join(target, 'package.json'), `${JSON.stringify(written, null, '\t')}\n`)
270
328
  }
271
- if (moved.length > 0)
272
- writeFileSync(join(target, 'package.json'), `${JSON.stringify(manifest, null, '\t')}\n`)
273
329
  return moved
274
330
  }
275
331
 
@@ -285,19 +341,11 @@ function runPin(
285
341
  scaffoldFile,
286
342
  [...scaffoldPrefix, 'audit', '--json', ...offline],
287
343
  runner.target,
344
+ judgePin,
288
345
  )
289
346
  if (audit !== undefined) {
290
347
  const releases = readReleases(audit.stdout)
291
- if (releases === undefined || (audit.status !== 0 && audit.status !== 1)) {
292
- replaceLastStep(
293
- runner,
294
- audit.status === null || audit.status === 0 ? 2 : audit.status,
295
- releases === undefined ? 'the audit printed no releases envelope' : describeFailure(audit),
296
- )
297
- return
298
- }
299
- // The audit exits 1 on the drift this step exists to close, so its exit is a reading here.
300
- replaceLastStep(runner, 0, `${releases.length} release reading(s)`)
348
+ if (releases === undefined || (audit.status !== 0 && audit.status !== 1)) return
301
349
  const moved = writeRanges(runner.target, releases)
302
350
  recordStep(
303
351
  runner,
@@ -346,21 +394,14 @@ function runOverwrite(
346
394
  scaffoldPrefix: readonly string[],
347
395
  offline: readonly string[],
348
396
  ): void {
349
- const written = runStep(
397
+ runStep(
350
398
  runner,
351
399
  'overwrite',
352
400
  scaffoldFile,
353
401
  [...scaffoldPrefix, 'overwrite', '--json', ...offline],
354
402
  runner.target,
403
+ (result) => judgeOverwrite(result, runner.offline),
355
404
  )
356
- if (runner.offline && written !== undefined && written.status === 1) {
357
- const note = readString(readJsonObject(written.stdout), 'note')
358
- if (note !== undefined && note.includes(OFFLINE_REFUSAL)) {
359
- replaceLastStep(runner, 0, 'offline overwrite skipped the catalog step by design')
360
- } else {
361
- replaceLastStep(runner, 1, describeFailure(written))
362
- }
363
- }
364
405
  if (!failed(runner)) {
365
406
  runStep(
366
407
  runner,
@@ -385,21 +426,11 @@ function runVerify(
385
426
  scaffoldFile,
386
427
  [...scaffoldPrefix, 'audit', '--json', ...offline],
387
428
  runner.target,
429
+ (result) => judgeVerify(result, own),
388
430
  )
389
431
  if (audit === undefined) return
390
- // The audit reads the canonical sources of scaffold's own checkout as foreign paths and exits 1.
391
- if (own && audit.status === 1) {
392
- replaceLastStep(runner, 0, 'foreign-path findings are expected on the scaffold checkout')
393
- }
394
432
  const releases = readReleases(audit.stdout)
395
- if (releases === undefined) {
396
- replaceLastStep(
397
- runner,
398
- audit.status === null || audit.status === 0 ? 2 : audit.status,
399
- 'the audit printed no releases envelope',
400
- )
401
- return
402
- }
433
+ if (releases === undefined) return
403
434
  const drift = releases.filter(
404
435
  (release) => release.latest !== undefined && release.range !== `^${release.latest}`,
405
436
  )
@@ -504,13 +535,6 @@ function runVisit(argv: readonly string[]): Visit | undefined {
504
535
  const version = readString(manifest, 'version')
505
536
  const before = readRanges(manifest, RANGE_FIELDS)
506
537
  const offline = runner.offline ? ['--offline'] : []
507
- let hits: number | undefined
508
- let ruling: Ruling = {
509
- distMoved: undefined,
510
- rangesMoved: undefined,
511
- ranges: undefined,
512
- hits: undefined,
513
- }
514
538
  if (selected.has('pin')) runPin(runner, scaffoldFile, scaffoldPrefix, offline)
515
539
  if (selected.has('commit') && !failed(runner)) runCommit(runner)
516
540
  if (selected.has('overwrite') && !failed(runner)) {
@@ -527,9 +551,10 @@ function runVisit(argv: readonly string[]): Visit | undefined {
527
551
  runVerify(runner, scaffoldFile, scaffoldPrefix, offline, name === SCAFFOLD)
528
552
  }
529
553
  if (selected.has('install') && !failed(runner)) runNpmStep(runner, 'install', ['install'])
530
- if (selected.has('pins') && !failed(runner)) {
531
- hits = runPins(runner, before, readOption(argv, '--prior') ?? version)
532
- }
554
+ const hits =
555
+ selected.has('pins') && !failed(runner)
556
+ ? runPins(runner, before, readOption(argv, '--prior') ?? version)
557
+ : undefined
533
558
  if (selected.has('format') && !failed(runner)) runNpmStep(runner, 'format', ['run', 'format'])
534
559
  if (selected.has('gates') && !failed(runner)) {
535
560
  for (const gate of GATES) {
@@ -537,8 +562,11 @@ function runVisit(argv: readonly string[]): Visit | undefined {
537
562
  runNpmStep(runner, 'gates', ['run', gate])
538
563
  }
539
564
  }
540
- if (selected.has('compare') && !failed(runner)) ruling = runCompare(runner, name)
541
- ruling = { ...ruling, hits }
565
+ const compared: Ruling =
566
+ selected.has('compare') && !failed(runner)
567
+ ? runCompare(runner, name)
568
+ : { distMoved: undefined, rangesMoved: undefined, ranges: undefined, hits: undefined }
569
+ const ruling: Ruling = { ...compared, hits }
542
570
  const bump =
543
571
  ruling.distMoved === true || ruling.rangesMoved === true
544
572
  ? 'bump'
@@ -562,31 +590,34 @@ function readLayers(catalogPath: string): ReadonlyMap<string, readonly string[]>
562
590
  const start = text.indexOf('<!-- orkestrel:catalog -->')
563
591
  const end = text.indexOf('<!-- /orkestrel:catalog -->')
564
592
  if (start === -1 || end === -1) return undefined
565
- const layers = new Map<string, string[]>()
566
- for (const line of text.slice(start, end).split(/\r\n|\n/)) {
567
- const match = line.match(
568
- /^\|\s*`(@orkestrel\/[a-z0-9-]+)`\s*\|\s*`([^`]+)`\s*\|\s*(L\d+)\s*\|/u,
569
- )
570
- if (match?.[1] === undefined || match[3] === undefined) continue
571
- const members = layers.get(match[3]) ?? []
572
- members.push(`${match[1]} ${match[2] ?? ''}`.trim())
573
- layers.set(match[3], members)
574
- }
593
+ const rows = text
594
+ .slice(start, end)
595
+ .split(/\r\n|\n/)
596
+ .flatMap((line) => {
597
+ const match = line.match(
598
+ /^\|\s*`(@orkestrel\/[a-z0-9-]+)`\s*\|\s*`([^`]+)`\s*\|\s*(L\d+)\s*\|/u,
599
+ )
600
+ return match?.[1] === undefined || match[3] === undefined
601
+ ? []
602
+ : [{ layer: match[3], member: `${match[1]} ${match[2] ?? ''}`.trim() }]
603
+ })
575
604
  return new Map(
576
- [...layers.entries()].sort(([left], [right]) => Number(left.slice(1)) - Number(right.slice(1))),
605
+ [...Map.groupBy(rows, ({ layer }) => layer)]
606
+ .map(([layer, members]) => [layer, members.map(({ member }) => member)] as const)
607
+ .sort(([left], [right]) => Number(left.slice(1)) - Number(right.slice(1))),
577
608
  )
578
609
  }
579
610
 
580
611
  function renderVisit(visit: Visit): string {
581
- const lines = visit.steps.map(
582
- (entry) =>
583
- `wave: ${entry.step.padEnd(9)} exit=${entry.exit === undefined ? 'dry' : String(entry.exit)} ${String(entry.durationMs).padStart(7)} ms ${entry.command}${entry.note === undefined ? '' : ` (${entry.note})`}`,
584
- )
585
- lines.push(
612
+ const lines = [
613
+ ...visit.steps.map(
614
+ (entry) =>
615
+ `wave: ${entry.step.padEnd(9)} exit=${entry.exit === undefined ? 'dry' : String(entry.exit)} ${String(entry.durationMs).padStart(7)} ms ${entry.command}${entry.note === undefined ? '' : ` (${entry.note})`}`,
616
+ ),
586
617
  `wave: ruling dist ${describeReading(visit.ruling.distMoved, 'moved', 'same')}, ranges ${describeReading(visit.ruling.rangesMoved, 'moved', 'same')}${visit.ruling.hits === undefined ? '' : `, ${visit.ruling.hits} self-pin hit(s)`}`,
587
- )
588
- for (const range of visit.ruling.ranges ?? []) lines.push(`wave: range ${range}`)
589
- lines.push(visit.row)
618
+ ...(visit.ruling.ranges ?? []).map((range) => `wave: range ${range}`),
619
+ visit.row,
620
+ ]
590
621
  return `${lines.join('\n')}\n`
591
622
  }
592
623
 
@@ -19,6 +19,7 @@
19
19
  import { existsSync, mkdirSync, readFileSync, statSync, writeFileSync } from 'node:fs'
20
20
  import { dirname, join, resolve } from 'node:path'
21
21
  import {
22
+ parseJsonRecord,
22
23
  readList,
23
24
  readMissingFlags,
24
25
  readOption,
@@ -40,6 +41,11 @@ interface Upload {
40
41
  readonly journal: string
41
42
  }
42
43
 
44
+ interface Published {
45
+ readonly uploads: readonly Upload[]
46
+ readonly stopped: boolean
47
+ }
48
+
43
49
  function waitFor(ms: number): Promise<void> {
44
50
  return new Promise((settle) => {
45
51
  setTimeout(settle, ms)
@@ -52,14 +58,11 @@ function readWhoami(): string | undefined {
52
58
  return result.status === 0 && user !== '' ? user : undefined
53
59
  }
54
60
 
55
- async function pollWhoami(waitSeconds: number): Promise<string | undefined> {
56
- const started = Date.now()
57
- let user = readWhoami()
58
- while (user === undefined && Date.now() - started < waitSeconds * 1000) {
59
- await waitFor(POLL_MS)
60
- user = readWhoami()
61
- }
62
- return user
61
+ async function pollWhoami(waitSeconds: number, started = Date.now()): Promise<string | undefined> {
62
+ const user = readWhoami()
63
+ if (user !== undefined || Date.now() - started >= waitSeconds * 1000) return user
64
+ await waitFor(POLL_MS)
65
+ return pollWhoami(waitSeconds, started)
63
66
  }
64
67
 
65
68
  function readServed(name: string, version: string): boolean {
@@ -67,14 +70,22 @@ function readServed(name: string, version: string): boolean {
67
70
  return result.status === 0 && result.stdout.replace(/["\s]/gu, '') === version
68
71
  }
69
72
 
70
- async function confirmServed(name: string, version: string, waitSeconds: number): Promise<boolean> {
71
- const started = Date.now()
72
- let served = readServed(name, version)
73
- while (!served && Date.now() - started < waitSeconds * 1000) {
74
- await waitFor(POLL_MS)
75
- served = readServed(name, version)
76
- }
77
- return served
73
+ async function confirmServed(
74
+ name: string,
75
+ version: string,
76
+ waitSeconds: number,
77
+ started = Date.now(),
78
+ ): Promise<boolean> {
79
+ const served = readServed(name, version)
80
+ if (served || Date.now() - started >= waitSeconds * 1000) return served
81
+ await waitFor(POLL_MS)
82
+ return confirmServed(name, version, waitSeconds, started)
83
+ }
84
+
85
+ function findGitRoot(directory: string): string {
86
+ return existsSync(join(directory, '.git')) || dirname(directory) === directory
87
+ ? directory
88
+ : findGitRoot(dirname(directory))
78
89
  }
79
90
 
80
91
  function readErrorCode(text: string): string | undefined {
@@ -88,55 +99,60 @@ function readManifestName(directory: string): {
88
99
  const path = join(directory, 'package.json')
89
100
  if (!existsSync(path)) return { name: undefined, version: undefined }
90
101
  try {
91
- const parsed: unknown = JSON.parse(readFileSync(path, 'utf8'))
92
- if (typeof parsed !== 'object' || parsed === null)
93
- return { name: undefined, version: undefined }
94
- const record = Object.fromEntries(Object.entries(parsed))
102
+ const record = parseJsonRecord(readFileSync(path, 'utf8'))
95
103
  return { name: readString(record, 'name'), version: readString(record, 'version') }
96
104
  } catch {
97
105
  return { name: undefined, version: undefined }
98
106
  }
99
107
  }
100
108
 
109
+ function publishPackage(directory: string, otp: string): Upload {
110
+ const manifest = readManifestName(directory)
111
+ const journal = `tmp/units/publish-${(manifest.name ?? directory).replace(/[^A-Za-z0-9.-]+/gu, '-')}.log`
112
+ const result = runNpm(
113
+ ['publish', '--ignore-scripts', '--browser=false', `--otp=${otp}`],
114
+ directory,
115
+ )
116
+ writeFileSync(journal, `${result.stdout}\n${result.stderr}\nexit=${result.status ?? 'null'}\n`)
117
+ const match = `${result.stdout}\n${result.stderr}`.match(ACCEPTED)
118
+ const accepted = result.status === 0 && match !== null
119
+ const name = match?.[1] ?? manifest.name
120
+ const version = match?.[2] ?? manifest.version
121
+ const upload: Upload = {
122
+ directory,
123
+ name,
124
+ version,
125
+ exit: result.status ?? undefined,
126
+ accepted,
127
+ confirmed: false,
128
+ code: accepted ? undefined : readErrorCode(result.stderr),
129
+ journal,
130
+ }
131
+ if (accepted) {
132
+ console.error(`window: accepted ${name ?? '?'}@${version ?? '?'}`)
133
+ } else {
134
+ console.error(
135
+ `window: ${name ?? directory} refused (${upload.code ?? `exit ${String(result.status)}`}); resume from it on a fresh code`,
136
+ )
137
+ }
138
+ return upload
139
+ }
140
+
101
141
  async function publishAll(
102
142
  directories: readonly string[],
103
143
  otp: string,
104
144
  waitSeconds: number,
105
145
  ): Promise<readonly Upload[]> {
106
146
  mkdirSync('tmp/units', { recursive: true })
107
- const uploads: Upload[] = []
108
- for (const directory of directories) {
109
- const manifest = readManifestName(directory)
110
- const journal = `tmp/units/publish-${(manifest.name ?? directory).replace(/[^A-Za-z0-9.-]+/gu, '-')}.log`
111
- const result = runNpm(
112
- ['publish', '--ignore-scripts', '--browser=false', `--otp=${otp}`],
113
- directory,
114
- )
115
- writeFileSync(journal, `${result.stdout}\n${result.stderr}\nexit=${result.status ?? 'null'}\n`)
116
- const match = `${result.stdout}\n${result.stderr}`.match(ACCEPTED)
117
- const accepted = result.status === 0 && match !== null
118
- const name = match?.[1] ?? manifest.name
119
- const version = match?.[2] ?? manifest.version
120
- const upload: Upload = {
121
- directory,
122
- name,
123
- version,
124
- exit: result.status ?? undefined,
125
- accepted,
126
- confirmed: false,
127
- code: accepted ? undefined : readErrorCode(result.stderr),
128
- journal,
129
- }
130
- if (!accepted) {
131
- uploads.push(upload)
132
- console.error(
133
- `window: ${name ?? directory} refused (${upload.code ?? `exit ${String(result.status)}`}); resume from it on a fresh code`,
134
- )
135
- break
136
- }
137
- console.error(`window: accepted ${name ?? '?'}@${version ?? '?'}`)
138
- uploads.push(upload)
139
- }
147
+ // A step after the first refusal returns the state unchanged, so nothing publishes past it.
148
+ const { uploads } = directories.reduce<Published>(
149
+ (state, directory) => {
150
+ if (state.stopped) return state
151
+ const upload = publishPackage(directory, otp)
152
+ return { uploads: [...state.uploads, upload], stopped: !upload.accepted }
153
+ },
154
+ { uploads: [], stopped: false },
155
+ )
140
156
  const confirmed: Upload[] = []
141
157
  for (const upload of uploads) {
142
158
  if (!upload.accepted || upload.name === undefined || upload.version === undefined) {
@@ -199,8 +215,7 @@ async function main(argv: readonly string[]): Promise<number> {
199
215
  return 64
200
216
  }
201
217
  for (const directory of directories) {
202
- let root = resolve(directory)
203
- while (!existsSync(join(root, '.git')) && dirname(root) !== root) root = dirname(root)
218
+ const root = findGitRoot(resolve(directory))
204
219
  const entry = join(root, '.git')
205
220
  if (!existsSync(entry) || !statSync(entry).isFile()) continue
206
221
  const line = readFileSync(entry, 'utf8')