uniweb 0.13.16 → 0.14.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (65) hide show
  1. package/README.md +1 -1
  2. package/package.json +7 -7
  3. package/partials/agents.md +51 -0
  4. package/src/backend/client.js +173 -40
  5. package/src/backend/data-bundle.js +15 -3
  6. package/src/backend/foundation-bring-along.js +76 -21
  7. package/src/backend/payment-handoff.js +28 -9
  8. package/src/backend/site-media.js +147 -19
  9. package/src/backend/site-sync.js +362 -40
  10. package/src/commands/add.js +575 -273
  11. package/src/commands/build.js +160 -57
  12. package/src/commands/clone.js +79 -26
  13. package/src/commands/content.js +11 -7
  14. package/src/commands/deploy.js +80 -32
  15. package/src/commands/dev.js +39 -17
  16. package/src/commands/docs.js +44 -16
  17. package/src/commands/doctor.js +338 -82
  18. package/src/commands/export.js +15 -7
  19. package/src/commands/handoff.js +6 -2
  20. package/src/commands/i18n.js +248 -99
  21. package/src/commands/inspect.js +48 -19
  22. package/src/commands/invite.js +9 -3
  23. package/src/commands/org.js +19 -5
  24. package/src/commands/publish.js +229 -60
  25. package/src/commands/pull.js +157 -48
  26. package/src/commands/push.js +144 -42
  27. package/src/commands/refresh.js +37 -10
  28. package/src/commands/register.js +235 -64
  29. package/src/commands/rename.js +126 -46
  30. package/src/commands/runtime.js +74 -24
  31. package/src/commands/status.js +48 -15
  32. package/src/commands/sync.js +7 -2
  33. package/src/commands/template.js +12 -4
  34. package/src/commands/update.js +263 -87
  35. package/src/commands/validate.js +115 -32
  36. package/src/framework-index.json +16 -14
  37. package/src/index.js +298 -145
  38. package/src/templates/fetchers/github.js +7 -7
  39. package/src/templates/fetchers/npm.js +3 -3
  40. package/src/templates/fetchers/release.js +21 -18
  41. package/src/templates/index.js +34 -12
  42. package/src/templates/processor.js +44 -12
  43. package/src/templates/resolver.js +42 -15
  44. package/src/templates/validator.js +14 -7
  45. package/src/utils/asset-upload.js +90 -22
  46. package/src/utils/code-upload.js +62 -37
  47. package/src/utils/config.js +31 -10
  48. package/src/utils/dep-survey.js +33 -7
  49. package/src/utils/destination-prompt.js +27 -17
  50. package/src/utils/git.js +66 -22
  51. package/src/utils/host-prompt.js +4 -3
  52. package/src/utils/install-integrity.js +8 -4
  53. package/src/utils/interactive.js +3 -3
  54. package/src/utils/json-file.js +6 -2
  55. package/src/utils/names.js +15 -6
  56. package/src/utils/placement.js +16 -8
  57. package/src/utils/registry-auth.js +185 -57
  58. package/src/utils/registry-orgs.js +142 -53
  59. package/src/utils/runtime-upload.js +52 -14
  60. package/src/utils/scaffold.js +55 -14
  61. package/src/utils/site-content-refs.js +3 -1
  62. package/src/utils/update-check.js +43 -17
  63. package/src/utils/workspace.js +13 -7
  64. package/src/versions.js +18 -7
  65. package/templates/site/_gitignore +5 -0
@@ -56,7 +56,13 @@
56
56
  * against the playground backend, 2026-06-17.
57
57
  */
58
58
 
59
- import { readFileSync, writeFileSync, mkdirSync, mkdtempSync, rmSync } from 'node:fs'
59
+ import {
60
+ readFileSync,
61
+ writeFileSync,
62
+ mkdirSync,
63
+ mkdtempSync,
64
+ rmSync
65
+ } from 'node:fs'
60
66
  import { tmpdir } from 'node:os'
61
67
  import { createHash } from 'node:crypto'
62
68
  import { createInterface } from 'node:readline/promises'
@@ -68,23 +74,38 @@ import {
68
74
  resolveCollectionsConfig,
69
75
  readZip,
70
76
  computeUnitHashes,
71
- collectUnitUuids,
77
+ collectUnitUuids
72
78
  } from '@uniweb/build/uwx'
73
79
  import { makeModelResolver } from './push.js'
74
80
  import {
75
81
  mergeBaseVersions,
76
82
  mergeItemBaseVersions,
77
83
  writeUnitBases,
78
- writeItemUuids,
84
+ writeItemUuids
79
85
  } from '../backend/site-sync.js'
80
- import { uncommittedUnder, siteContentRoots, showAtHead, mergeFile } from '../utils/git.js'
86
+ import {
87
+ uncommittedUnder,
88
+ siteContentRoots,
89
+ showAtHead,
90
+ mergeFile
91
+ } from '../utils/git.js'
81
92
  import { isNonInteractive } from '../utils/interactive.js'
82
93
  import { BackendClient } from '../backend/client.js'
83
- import { resolveSiteDir as defaultResolveSiteDir, resolveSiteBackend } from './deploy.js'
94
+ import {
95
+ resolveSiteDir as defaultResolveSiteDir,
96
+ resolveSiteBackend
97
+ } from './deploy.js'
84
98
 
85
99
  const FOLDER_MODEL = '@uniweb/folder'
86
100
 
87
- const colors = { reset: '\x1b[0m', bright: '\x1b[1m', dim: '\x1b[2m', red: '\x1b[31m', green: '\x1b[32m', blue: '\x1b[36m' }
101
+ const colors = {
102
+ reset: '\x1b[0m',
103
+ bright: '\x1b[1m',
104
+ dim: '\x1b[2m',
105
+ red: '\x1b[31m',
106
+ green: '\x1b[32m',
107
+ blue: '\x1b[36m'
108
+ }
88
109
  const log = console.log
89
110
  const success = (m) => log(`${colors.green}✓${colors.reset} ${m}`)
90
111
  const error = (m) => console.error(`${colors.red}✗${colors.reset} ${m}`)
@@ -95,7 +116,8 @@ function flagValue(args, name) {
95
116
  const eq = args.find((a) => a.startsWith(`${name}=`))
96
117
  if (eq) return eq.slice(name.length + 1)
97
118
  const i = args.indexOf(name)
98
- if (i !== -1 && args[i + 1] && !args[i + 1].startsWith('-')) return args[i + 1]
119
+ if (i !== -1 && args[i + 1] && !args[i + 1].startsWith('-'))
120
+ return args[i + 1]
99
121
  return null
100
122
  }
101
123
 
@@ -127,7 +149,10 @@ function readPullCache(siteDir) {
127
149
  function writePullCache(siteDir, { content, folder }) {
128
150
  const p = pullCachePath(siteDir)
129
151
  mkdirSync(dirname(p), { recursive: true })
130
- writeFileSync(p, JSON.stringify({ version: 1, content, folder }, null, 2) + '\n')
152
+ writeFileSync(
153
+ p,
154
+ JSON.stringify({ version: 1, content, folder }, null, 2) + '\n'
155
+ )
131
156
  }
132
157
 
133
158
  // Extract a single entity `$`-document from a pull response. Tolerant of a raw
@@ -142,7 +167,8 @@ export function extractDocument(payload) {
142
167
  // folder document and the record documents. Tolerant of an array, an
143
168
  // `{ entities }` / `{ documents }` list, or an explicit `{ folder, records }`.
144
169
  export function splitCollectionsPull(payload) {
145
- if (payload?.folder) return { folderDoc: payload.folder, recordDocs: payload.records || [] }
170
+ if (payload?.folder)
171
+ return { folderDoc: payload.folder, recordDocs: payload.records || [] }
146
172
  const list = Array.isArray(payload)
147
173
  ? payload
148
174
  : Array.isArray(payload?.entities)
@@ -154,7 +180,7 @@ export function splitCollectionsPull(payload) {
154
180
  const docs = list.map(extractDocument).filter(Boolean)
155
181
  return {
156
182
  folderDoc: docs.find((d) => d.$model === FOLDER_MODEL) || null,
157
- recordDocs: docs.filter((d) => d.$model !== FOLDER_MODEL),
183
+ recordDocs: docs.filter((d) => d.$model !== FOLDER_MODEL)
158
184
  }
159
185
  }
160
186
 
@@ -186,8 +212,10 @@ export function readPullDocuments(buf) {
186
212
  } catch {
187
213
  return []
188
214
  }
189
- if (Array.isArray(payload)) return payload.map(extractDocument).filter(Boolean)
190
- if (payload?.folder) return [payload.folder, ...(payload.records || [])].filter(Boolean)
215
+ if (Array.isArray(payload))
216
+ return payload.map(extractDocument).filter(Boolean)
217
+ if (payload?.folder)
218
+ return [payload.folder, ...(payload.records || [])].filter(Boolean)
191
219
  const list = Array.isArray(payload?.entities)
192
220
  ? payload.entities
193
221
  : Array.isArray(payload?.documents)
@@ -249,7 +277,8 @@ function readManifestTokens(buf) {
249
277
  try {
250
278
  const manifest = JSON.parse(data.toString('utf8'))
251
279
  for (const entry of manifest?.entries || []) {
252
- if (entry?.uuid && typeof entry.version === 'string') out.entity[entry.uuid] = entry.version
280
+ if (entry?.uuid && typeof entry.version === 'string')
281
+ out.entity[entry.uuid] = entry.version
253
282
  const items = entry?.item_versions
254
283
  if (items && typeof items === 'object') {
255
284
  for (const [uuid, v] of Object.entries(items)) {
@@ -286,12 +315,20 @@ async function checkWorkingTree(siteDir, args) {
286
315
  // Not a git work tree. Nothing to fall back on if this goes wrong.
287
316
  if (isNonInteractive(args)) {
288
317
  error('Refusing to pull: this site is not in a git repository.')
289
- note('Pull rewrites pages, sections and layout from the backend and deletes what it no longer has.')
290
- note('Without version control there is no way back, and this session cannot ask.')
291
- note('Re-run with --force to accept that, or put the site under git first.')
318
+ note(
319
+ 'Pull rewrites pages, sections and layout from the backend and deletes what it no longer has.'
320
+ )
321
+ note(
322
+ 'Without version control there is no way back, and this session cannot ask.'
323
+ )
324
+ note(
325
+ 'Re-run with --force to accept that, or put the site under git first.'
326
+ )
292
327
  return { exitCode: 1 }
293
328
  }
294
- const ok = await confirm(`Pull will overwrite ${roots.length} content location(s) and this site is not in git. Continue?`)
329
+ const ok = await confirm(
330
+ `Pull will overwrite ${roots.length} content location(s) and this site is not in git. Continue?`
331
+ )
295
332
  if (!ok) {
296
333
  info('Nothing pulled.')
297
334
  return { exitCode: 0 }
@@ -303,11 +340,17 @@ async function checkWorkingTree(siteDir, args) {
303
340
  dirty = dirty.filter((f) => !isPullOutput(siteDir, f, written))
304
341
  if (!dirty.length) return null
305
342
 
306
- error(`Refusing to pull: ${dirty.length} uncommitted change(s) under the files pull rewrites.`)
343
+ error(
344
+ `Refusing to pull: ${dirty.length} uncommitted change(s) under the files pull rewrites.`
345
+ )
307
346
  for (const f of dirty.slice(0, 12)) note(` ${f}`)
308
347
  if (dirty.length > 12) note(` … and ${dirty.length - 12} more`)
309
- note('Pull reconciles the working tree to the backend, so these would be overwritten or deleted.')
310
- note('Commit or stash them first then pull, and your work is still in git either way.')
348
+ note(
349
+ 'Pull reconciles the working tree to the backend, so these would be overwritten or deleted.'
350
+ )
351
+ note(
352
+ 'Commit or stash them first — then pull, and your work is still in git either way.'
353
+ )
311
354
  note('To discard them and take the backend version, re-run with --force.')
312
355
  return { exitCode: 1 }
313
356
  }
@@ -327,7 +370,7 @@ function readWritten(siteDir) {
327
370
  const o = JSON.parse(readFileSync(writtenCachePath(siteDir), 'utf8'))
328
371
  return {
329
372
  files: o && typeof o.files === 'object' ? o.files : {},
330
- deleted: Array.isArray(o?.deleted) ? o.deleted : [],
373
+ deleted: Array.isArray(o?.deleted) ? o.deleted : []
331
374
  }
332
375
  } catch {
333
376
  return { files: {}, deleted: [] }
@@ -344,24 +387,40 @@ function recordWritten(siteDir, absPaths, deletedAbs = []) {
344
387
  const files = prior.files
345
388
  for (const abs of absPaths) {
346
389
  try {
347
- files[relative(siteDir, abs)] = createHash('sha256').update(readFileSync(abs)).digest('hex')
348
- } catch { /* deleted or unreadable — nothing to remember */ }
390
+ files[relative(siteDir, abs)] = createHash('sha256')
391
+ .update(readFileSync(abs))
392
+ .digest('hex')
393
+ } catch {
394
+ /* deleted or unreadable — nothing to remember */
395
+ }
349
396
  }
350
397
  // Pull PRUNES too, and a deletion is a dirty path git reports just like an edit.
351
398
  // Without recording them, pull's own pruning reads as the user having deleted
352
399
  // files — the same false alarm as its writes, arriving by the other door.
353
- const deleted = [...new Set([...prior.deleted, ...deletedAbs.map((a) => relative(siteDir, a))])]
400
+ const deleted = [
401
+ ...new Set([
402
+ ...prior.deleted,
403
+ ...deletedAbs.map((a) => relative(siteDir, a))
404
+ ])
405
+ ]
354
406
  try {
355
407
  mkdirSync(dirname(writtenCachePath(siteDir)), { recursive: true })
356
- writeFileSync(writtenCachePath(siteDir), JSON.stringify({ version: 1, files, deleted }, null, 2) + '\n')
357
- } catch { /* best-effort: losing it only costs a spurious refusal */ }
408
+ writeFileSync(
409
+ writtenCachePath(siteDir),
410
+ JSON.stringify({ version: 1, files, deleted }, null, 2) + '\n'
411
+ )
412
+ } catch {
413
+ /* best-effort: losing it only costs a spurious refusal */
414
+ }
358
415
  }
359
416
  // Is this dirty path just pull's own untouched output?
360
417
  function isPullOutput(siteDir, relPath, written) {
361
418
  let exists = true
362
419
  let hash = null
363
420
  try {
364
- hash = createHash('sha256').update(readFileSync(join(siteDir, relPath))).digest('hex')
421
+ hash = createHash('sha256')
422
+ .update(readFileSync(join(siteDir, relPath)))
423
+ .digest('hex')
365
424
  } catch {
366
425
  exists = false
367
426
  }
@@ -398,7 +457,10 @@ function captureLocalWork(siteDir) {
398
457
  for (const rel of dirty) {
399
458
  if (isPullOutput(siteDir, rel, written)) continue // pull's own output, not work
400
459
  try {
401
- out.set(rel, { content: readFileSync(join(siteDir, rel)), inHead: showAtHead(siteDir, rel) !== null })
460
+ out.set(rel, {
461
+ content: readFileSync(join(siteDir, rel)),
462
+ inHead: showAtHead(siteDir, rel) !== null
463
+ })
402
464
  } catch {
403
465
  // Locally deleted. Pull will restore the backend's copy, which is the
404
466
  // sensible reading of "I removed this and then asked for their version".
@@ -415,7 +477,11 @@ function mergeLocalWork(siteDir, captured) {
415
477
  for (const [rel, { content: mine, inHead }] of captured) {
416
478
  const abs = join(siteDir, rel)
417
479
  let theirs = null
418
- try { theirs = readFileSync(abs) } catch { /* pruned by the pull */ }
480
+ try {
481
+ theirs = readFileSync(abs)
482
+ } catch {
483
+ /* pruned by the pull */
484
+ }
419
485
 
420
486
  if (theirs === null) {
421
487
  // The backend no longer has it, but we changed it. Restoring is the
@@ -478,7 +544,8 @@ export async function pull(args = [], deps = {}) {
478
544
  const dryRun = args.includes('--dry-run')
479
545
  const tokenFlag = flagValue(args, '--token')
480
546
  const prune = !(args.includes('--no-delete') || args.includes('--no-prune')) // git-like by default
481
- const noCollections = args.includes('--no-collections') || args.includes('--content-only')
547
+ const noCollections =
548
+ args.includes('--no-collections') || args.includes('--content-only')
482
549
  const force = args.includes('--force')
483
550
  const mergeMode = args.includes('--merge')
484
551
 
@@ -501,8 +568,12 @@ export async function pull(args = [], deps = {}) {
501
568
  captured = captureLocalWork(siteDir)
502
569
  if (captured === null) {
503
570
  error('Cannot merge: this site is not in a git repository.')
504
- note('The common ancestor a merge needs is the committed version of each file.')
505
- note('Without a repo there is nothing to merge against use `uniweb pull --force` to take the backend version.')
571
+ note(
572
+ 'The common ancestor a merge needs is the committed version of each file.'
573
+ )
574
+ note(
575
+ 'Without a repo there is nothing to merge against — use `uniweb pull --force` to take the backend version.'
576
+ )
506
577
  return { exitCode: 1 }
507
578
  }
508
579
  } else if (!dryRun && !force) {
@@ -517,19 +588,23 @@ export async function pull(args = [], deps = {}) {
517
588
  getToken: deps.getToken,
518
589
  fetchImpl: deps.fetch,
519
590
  args,
520
- command: 'Pulling',
591
+ command: 'Pulling'
521
592
  })
522
593
  // One identity per site: `site.yml::$uuid`. Both lanes (content + folder) are keyed
523
594
  // by it — the backend resolves the site's `@uniweb/folder` from this uuid.
524
595
  const siteContentUuid = readYamlUuid(join(siteDir, 'site.yml'))
525
596
 
526
597
  if (!siteContentUuid) {
527
- info('Nothing to pull — this project has no $uuid yet. Run `uniweb push` first.')
598
+ info(
599
+ 'Nothing to pull — this project has no $uuid yet. Run `uniweb push` first.'
600
+ )
528
601
  return { exitCode: 0 }
529
602
  }
530
603
 
531
604
  if (dryRun) {
532
- info(`Dry run — would pull content from ${colors.dim}${client.origin}${colors.reset}`)
605
+ info(
606
+ `Dry run — would pull content from ${colors.dim}${client.origin}${colors.reset}`
607
+ )
533
608
  if (!noCollections) info(`Dry run — would also pull collections`)
534
609
  return { exitCode: 0 }
535
610
  }
@@ -540,7 +615,9 @@ export async function pull(args = [], deps = {}) {
540
615
  // body). `doRequest` is a thunk returning the client's Response promise. 404 / any
541
616
  // failure → null (the lane is skipped, not fatal).
542
617
  const getDocs = async (label, doRequest) => {
543
- info(`Pulling ${colors.bright}${label}${colors.reset} from ${colors.dim}${client.origin}${colors.reset} …`)
618
+ info(
619
+ `Pulling ${colors.bright}${label}${colors.reset} from ${colors.dim}${client.origin}${colors.reset} …`
620
+ )
544
621
  let res
545
622
  try {
546
623
  res = await doRequest()
@@ -559,7 +636,10 @@ export async function pull(args = [], deps = {}) {
559
636
  }
560
637
  if (!res.ok) {
561
638
  error(`${label} pull failed: HTTP ${res.status} ${res.statusText}`)
562
- if (res.status === 401 || res.status === 403) note("Credentials weren't accepted — supply a bearer with --token <bearer>.")
639
+ if (res.status === 401 || res.status === 403)
640
+ note(
641
+ "Credentials weren't accepted — supply a bearer with --token <bearer>."
642
+ )
563
643
  return null
564
644
  }
565
645
  try {
@@ -595,9 +675,15 @@ export async function pull(args = [], deps = {}) {
595
675
 
596
676
  // Lane 1 — content → config + pages/** + layout/**. The .uwx carries a single
597
677
  // entity (the site-content document). A 304 (unchanged) leaves local files as-is.
598
- const content = await getDocs('content', () => client.pullSiteContent(siteContentUuid, { etag: etagContent }))
678
+ const content = await getDocs('content', () =>
679
+ client.pullSiteContent(siteContentUuid, { etag: etagContent })
680
+ )
599
681
  if (content && !content.notModified) {
600
- const siteDoc = content.docs && (content.docs.find((d) => d?.info || d?.$model) || content.docs[0] || null)
682
+ const siteDoc =
683
+ content.docs &&
684
+ (content.docs.find((d) => d?.info || d?.$model) ||
685
+ content.docs[0] ||
686
+ null)
601
687
  if (siteDoc) {
602
688
  // Re-base the page attribution. The pulled document IS the backend's own
603
689
  // representation, so it becomes the remote base directly. We deliberately
@@ -610,9 +696,16 @@ export async function pull(args = [], deps = {}) {
610
696
  // Per-item identity for the next push. Without it the backend reads our
611
697
  // records as new and re-mints every page and section row.
612
698
  writeItemUuids(siteDir, collectUnitUuids(siteDoc))
613
- const report = siteContentDocumentToProject({ document: siteDoc, siteRoot: siteDir, prune })
699
+ const report = siteContentDocumentToProject({
700
+ document: siteDoc,
701
+ siteRoot: siteDir,
702
+ prune
703
+ })
614
704
  wrote.push(...report.pages, ...report.sections, ...report.layout)
615
- removed.push(...(report.deleted || []), ...(report.renamed || []).map((r) => r.from))
705
+ removed.push(
706
+ ...(report.deleted || []),
707
+ ...(report.renamed || []).map((r) => r.from)
708
+ )
616
709
  pages += report.pages.length
617
710
  sections += report.sections.length
618
711
  deleted += report.deleted.length
@@ -625,27 +718,37 @@ export async function pull(args = [], deps = {}) {
625
718
  // holds a folder uuid). Models are resolved by name (async) up front, so
626
719
  // collectionsToProject keeps its synchronous contract. A 304 leaves files as-is.
627
720
  if (!noCollections) {
628
- const folder = await getDocs('collections', () => client.pullFolder(siteContentUuid, { etag: etagFolder }))
721
+ const folder = await getDocs('collections', () =>
722
+ client.pullFolder(siteContentUuid, { etag: etagFolder })
723
+ )
629
724
  if (folder && !folder.notModified && folder.docs?.length) {
630
725
  const { folderDoc, recordDocs } = splitCollectionsPull(folder.docs)
631
726
  const resolveModel = makeModelResolver({ client })
632
727
  const declByModel = new Map()
633
- for (const model of [...new Set(recordDocs.map((d) => d.$model).filter(Boolean))]) {
728
+ for (const model of [
729
+ ...new Set(recordDocs.map((d) => d.$model).filter(Boolean))
730
+ ]) {
634
731
  try {
635
732
  declByModel.set(model, await resolveModel(model))
636
733
  } catch (err) {
637
734
  note(`! could not resolve model ${model}: ${err.message}`)
638
735
  }
639
736
  }
640
- const collectionsConfig = await resolveCollectionsConfig(siteDir).catch(() => null)
737
+ const collectionsConfig = await resolveCollectionsConfig(siteDir).catch(
738
+ () => null
739
+ )
641
740
  const report = collectionsToProject({
642
741
  folderDoc,
643
742
  recordDocs,
644
743
  siteRoot: siteDir,
645
- opts: { resolveDeclaration: (name) => declByModel.get(name) || null, collectionsConfig },
744
+ opts: {
745
+ resolveDeclaration: (name) => declByModel.get(name) || null,
746
+ collectionsConfig
747
+ }
646
748
  })
647
749
  records += report.placed.length + report.updated.length
648
- for (const s of report.skipped) note(`↷ ${s.slug ?? s.uuid ?? '(record)'}: ${s.reason}`)
750
+ for (const s of report.skipped)
751
+ note(`↷ ${s.slug ?? s.uuid ?? '(record)'}: ${s.reason}`)
649
752
  for (const w of report.warnings) note(`! ${w}`)
650
753
  }
651
754
  if (folder?.etag) etagFolder = folder.etag
@@ -674,12 +777,18 @@ export async function pull(args = [], deps = {}) {
674
777
 
675
778
  recordWritten(
676
779
  siteDir,
677
- [...wrote, ...['site.yml', 'theme.yml', 'head.html', 'collections.yml'].map((f) => join(siteDir, f))],
780
+ [
781
+ ...wrote,
782
+ ...['site.yml', 'theme.yml', 'head.html', 'collections.yml'].map((f) =>
783
+ join(siteDir, f)
784
+ )
785
+ ],
678
786
  removed
679
787
  )
680
788
 
681
789
  success(
682
- `Pulled — ${pages} page(s), ${sections} section(s), ${records} record(s)` + (deleted ? `, ${deleted} deleted` : '')
790
+ `Pulled — ${pages} page(s), ${sections} section(s), ${records} record(s)` +
791
+ (deleted ? `, ${deleted} deleted` : '')
683
792
  )
684
793
  // Unresolved conflicts are a non-zero exit, the way a conflicted `git merge` is.
685
794
  // The pull itself worked; there is work left for a human. This is what makes a