uniweb 1.20.4 → 1.21.1

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.
@@ -150,10 +150,12 @@ const colors = {
150
150
  green: '\x1b[32m',
151
151
  blue: '\x1b[36m'
152
152
  }
153
- // Looked up at each call, so a caller that captures the console captures these too.
154
- const log = (...a) => console.log(...a)
153
+ // Looked up at each call, so a caller that captures the console captures these too — and
154
+ // silent while a probe runs (`probePull`), which answers by what it returns.
155
+ let quiet = false
156
+ const log = (...a) => quiet || console.log(...a)
155
157
  const success = (m) => log(`${colors.green}✓${colors.reset} ${m}`)
156
- const error = (m) => console.error(`${colors.red}✗${colors.reset} ${m}`)
158
+ const error = (m) => quiet || console.error(`${colors.red}✗${colors.reset} ${m}`)
157
159
  const info = (m) => log(`${colors.blue}→${colors.reset} ${m}`)
158
160
  const note = (m) => log(` ${colors.dim}${m}${colors.reset}`)
159
161
 
@@ -238,7 +240,7 @@ function pullCachePath(siteDir) {
238
240
  // Ignoring those once costs one full pull. (In a repository an ETag is echoed only while
239
241
  // the sync point it was banked with is in the history — see `cacheHolds` below.)
240
242
  const PULL_CACHE_VERSION = 2
241
- function readPullCache(siteDir) {
243
+ export function readPullCache(siteDir) {
242
244
  try {
243
245
  const obj = JSON.parse(readFileSync(pullCachePath(siteDir), 'utf8'))
244
246
  return obj && typeof obj === 'object' && obj.version >= PULL_CACHE_VERSION ? obj : {}
@@ -246,6 +248,21 @@ function readPullCache(siteDir) {
246
248
  return {}
247
249
  }
248
250
  }
251
+
252
+ /**
253
+ * Whether the ETags this copy banked still describe its files — the condition a
254
+ * conditional GET is sent on. Outside git, always. In a repository, only while HEAD's
255
+ * history holds the sync point they were banked with: a merge abandoned, a branch reset,
256
+ * and the backend did not move but the files did.
257
+ *
258
+ * One rule for both readers of the lanes — this pull and `uniweb status`, which asks the
259
+ * same question without taking the answer.
260
+ */
261
+ export function etagsHold(siteDir, origin, repo = repoOf(siteDir)) {
262
+ if (!repo) return true
263
+ const held = readSyncPoint(siteDir, origin)
264
+ return Boolean(held && isAncestor(repo, held))
265
+ }
249
266
  function writePullCache(siteDir, { content, folder }) {
250
267
  const p = pullCachePath(siteDir)
251
268
  mkdirSync(dirname(p), { recursive: true })
@@ -553,6 +570,34 @@ const pulledFrom = (origin) => `The site's content on ${origin}`
553
570
  const mergedFrom = (origin) => `Merge the site's content from ${origin}`
554
571
  const takenFrom = (origin) => `Take the site's content from ${origin}`
555
572
 
573
+ /**
574
+ * What a pull would change in the files, and nothing more — for `uniweb status`.
575
+ *
576
+ * ⭐ THE PULL ITSELF, STOPPED BEFORE IT TAKES ANYTHING. The same requests, the same base,
577
+ * the same projection onto it in a stand-in — so status and pull cannot disagree about
578
+ * which files the backend changed: a section's translations (`locales/*.json`) and a
579
+ * record's file are named as the pull would write them. It downloads no media, merges
580
+ * nothing, commits nothing and banks nothing — no ETag, version, hash or sync point —
581
+ * and prints nothing.
582
+ *
583
+ * Null when a probe cannot answer: outside git, where a pull writes into the files; in a
584
+ * repository with no commit or no sync point to merge from; when a lane failed or the
585
+ * workspace was refused. The caller then says what it can by other means.
586
+ *
587
+ * @param {string[]} [args] - the workspace flags only (`--org`, `--personal`)
588
+ * @param {object} [deps] - as for `pull`; `getToken` keeps it from ever asking for a login
589
+ * @returns {Promise<{ files: Array<[string, string]>, recordsNotPlaced: boolean } | null>}
590
+ * `files` — `[status, path]`, status `A`, `M` or `D`, the path relative to the site
591
+ */
592
+ export async function probePull(args = [], deps = {}) {
593
+ quiet = true
594
+ try {
595
+ return (await pull(args, { ...deps, probe: true }))?.probe ?? null
596
+ } finally {
597
+ quiet = false
598
+ }
599
+ }
600
+
556
601
  export async function pull(args = [], deps = {}) {
557
602
  // See utils/flag-guard.js — an unrecognized flag is invisible to a literal scan, so
558
603
  // it silently keeps the default: a mistyped `--dry-run` would pull for real, and a
@@ -572,13 +617,20 @@ export async function pull(args = [], deps = {}) {
572
617
  const force = args.includes('--force')
573
618
 
574
619
  const siteDir = await resolveSiteDir(args, 'pull')
575
- const fetchAssets = shouldFetchAssets(args, siteDir)
620
+ // ⛔ A probe (`probePull`) fetches no media: it says what changes, and takes nothing.
621
+ const probe = Boolean(deps.probe)
622
+ const fetchAssets = !probe && shouldFetchAssets(args, siteDir)
576
623
 
577
624
  // ⭐ IN A REPOSITORY A PULL MERGES; OUTSIDE ONE IT CHECKS OUT. A merge needs the state both
578
625
  // sides started from, and only a history holds one (`utils/sync-point.js`). Outside git
579
626
  // a pull rewrites the files, so work there that a pull did not write stops it.
580
627
  const repo = repoOf(siteDir)
581
- if (repo && !dryRun) {
628
+ // A probe writes onto a commit in a stand-in, never into the files: with no repository,
629
+ // or no commit to write onto, it has nothing to say.
630
+ if (probe && !repo?.head) return { exitCode: 0, probe: null }
631
+ // …and touching nothing here, it is not stopped by what a merge would be: a conflict or a
632
+ // merge waiting is the caller's to report.
633
+ if (repo && !dryRun && !probe) {
582
634
  if (!repo.head) {
583
635
  error('This repository has no commits yet, so there is nothing for a pull to merge into.')
584
636
  note('Commit the project, then pull.')
@@ -616,7 +668,9 @@ export async function pull(args = [], deps = {}) {
616
668
  command: 'Pulling'
617
669
  })
618
670
  // One reader of Models for the whole pull, so a Model is read once whichever lane asks first.
619
- const readModel = makeModelResolver({ client, siteDir })
671
+ // ⛔ A probe keeps none: a Model kept here is what a later offline emit resolves with, and a
672
+ // probe takes nothing (`probePull`).
673
+ const readModel = makeModelResolver({ client, siteDir: probe ? null : siteDir })
620
674
 
621
675
  // ⭐ No scope check any more — and none is needed. This project's identity on
622
676
  // `client.origin` is read from that origin's own section of sync.json, so a
@@ -688,6 +742,9 @@ export async function pull(args = [], deps = {}) {
688
742
  // body). `doRequest` is a thunk returning the client's Response promise. 404 / any
689
743
  // failure → null (the lane is skipped, not fatal) — except a workspace mismatch,
690
744
  // `{ refused: true }`, which stops the pull: the other lane would only say it again.
745
+ // A lane that answered 404 is named in `missing`, so a probe can tell a site with no
746
+ // records lane from a lane that did not answer.
747
+ const missing = new Set()
691
748
  const getDocs = async (label, doRequest) => {
692
749
  info(
693
750
  `Pulling ${colors.bright}${label}${colors.reset} from ${colors.dim}${client.origin}${colors.reset} …`
@@ -703,6 +760,7 @@ export async function pull(args = [], deps = {}) {
703
760
  }
704
761
  if (res.status === 404) {
705
762
  note(`${label}: not found (404) — it was deleted, or you lack access.`)
763
+ missing.add(label)
706
764
  return null
707
765
  }
708
766
  if (res.status === 304) {
@@ -767,7 +825,7 @@ export async function pull(args = [], deps = {}) {
767
825
  // only while the sync point it was banked with is.
768
826
  const cache = readPullCache(siteDir)
769
827
  const held = repo ? readSyncPoint(siteDir, client.origin) : null
770
- const cacheHolds = !repo || Boolean(held && isAncestor(repo, held))
828
+ const cacheHolds = etagsHold(siteDir, client.origin, repo)
771
829
  let etagContent = cache.content
772
830
  let etagFolder = cache.folder
773
831
 
@@ -805,6 +863,12 @@ export async function pull(args = [], deps = {}) {
805
863
  ? splitRecordsPull(folderLane.docs)
806
864
  : null
807
865
  const projected = Boolean(siteDoc) || Boolean(folderTaken)
866
+ if (probe) {
867
+ // A lane that did not answer is not knowing — a records lane the backend has none of
868
+ // (404) is no records; both unchanged is nothing to take.
869
+ if (!content || (!noRecords && !folderLane && !missing.has('records'))) return { exitCode: 1, probe: null }
870
+ if (!projected) return { exitCode: 0, probe: { files: [], recordsNotPlaced: false } }
871
+ }
808
872
 
809
873
  // ── What the project itself says, read before anything is written ──────────
810
874
  // The foundation, the Models and the scope come from the project as it is: in a
@@ -935,6 +999,9 @@ export async function pull(args = [], deps = {}) {
935
999
  origin: client.origin,
936
1000
  siteUuid: siteContentUuid
937
1001
  })
1002
+ // A probe with no base has no way to tell the backend's changes from this copy's: onto
1003
+ // HEAD, every difference would read as the backend's.
1004
+ if (probe && !base) return { exitCode: 0, probe: null }
938
1005
  onto = force || !base ? repo.head : base
939
1006
  try {
940
1007
  standIn = openStandIn(repo, { roots, rev: onto })
@@ -1056,7 +1123,7 @@ export async function pull(args = [], deps = {}) {
1056
1123
  // ⭐ A FILE RECORD'S BYTES (`@uniweb/file`, 2026-09-28): fetched to where `folder.yml` now
1057
1124
  // places each one, as media are fetched. A file that did not arrive is a record not taken —
1058
1125
  // reported, and the pull fails below as it does for any, so the next one fetches it again.
1059
- if (report.fileDownloads?.length) {
1126
+ if (report.fileDownloads?.length && !probe) {
1060
1127
  const got = await downloadFileRecords({
1061
1128
  downloads: report.fileDownloads,
1062
1129
  siteDir: projDir,
@@ -1091,6 +1158,12 @@ export async function pull(args = [], deps = {}) {
1091
1158
  for (const w of report.warnings) note(`! ${w}`)
1092
1159
  }
1093
1160
 
1161
+ // ⭐ A PROBE STOPS HERE, the stand-in holding what this pull would take: what differs
1162
+ // from the base is what the backend changed. Nothing is banked; `finally` closes it.
1163
+ if (probe) {
1164
+ return { exitCode: 0, probe: { files: standIn.changes(), recordsNotPlaced: Boolean(recordsNotPlaced) } }
1165
+ }
1166
+
1094
1167
  // Outside git: what this pull wrote, so the next one can tell its own output from the
1095
1168
  // author's work. Config files are rewritten every pull whether or not they changed;
1096
1169
  // include the ones the projector owns so a pull-then-pull doesn't trip on them either.
@@ -385,21 +385,6 @@ export async function settleFoundationScope(targetDir, { args, isPreview, flagSc
385
385
  return { scope, source }
386
386
  }
387
387
 
388
- /**
389
- * The scope a bare name registers under when `--scope` names none — as `@handle`, or null
390
- * when none was chosen.
391
- *
392
- * ⭐ THE WORKSPACE THIS COMMAND WORKS IN *[Diego, 2026-10-06]* — an organization's handle,
393
- * or your own for your personal workspace — said, not asked (`scopeOfWorkspace`). A team
394
- * working in `@acme` registers `@acme/…`, which any of its members can release.
395
- * ⛔ Until 2026-10-06 the default was your personal scope wherever you worked, so a team's
396
- * foundation registered as `@jane/…` — releasable by Jane alone, and fixed only by
397
- * renaming it into a new foundation.
398
- *
399
- * When the workspace names no scope — none is chosen, or it is a unit without a handle —
400
- * the scope is derived from your orgs as before (`deriveScope`): your personal scope when
401
- * you belong to none, else a pick at a terminal, your personal scope without one.
402
- */
403
388
  /**
404
389
  * Refuse a reserved scope wherever it came from, before anything is written or sent —
405
390
  * `@uniweb` names the framework's own packages and a system account on every Uniweb
@@ -417,6 +402,21 @@ function refusedReservedScope(scope, lead) {
417
402
  return true
418
403
  }
419
404
 
405
+ /**
406
+ * The scope a bare name registers under when `--scope` names none — as `@handle`, or null
407
+ * when none was chosen.
408
+ *
409
+ * ⭐ THE WORKSPACE THIS COMMAND WORKS IN *[Diego, 2026-10-06]* — an organization's handle,
410
+ * or your own for your personal workspace — said, not asked (`scopeOfWorkspace`). A team
411
+ * working in `@acme` registers `@acme/…`, which any of its members can release.
412
+ * ⛔ Until 2026-10-06 the default was your personal scope wherever you worked, so a team's
413
+ * foundation registered as `@jane/…` — releasable by Jane alone, and fixed only by
414
+ * renaming it into a new foundation.
415
+ *
416
+ * When the workspace names no scope — none is chosen, or it is a unit without a handle —
417
+ * the scope is derived from your orgs as before (`deriveScope`): your personal scope when
418
+ * you belong to none, else a pick at a terminal, your personal scope without one.
419
+ */
420
420
  async function deriveScopeFromLogin(client, args) {
421
421
  const token = await client.token()
422
422
  const ws = await resolveWorkspace({ client, args })