uniweb 1.21.5 → 1.21.7

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "uniweb",
3
- "version": "1.21.5",
3
+ "version": "1.21.7",
4
4
  "description": "Create structured Vite + React sites with content/code separation",
5
5
  "type": "module",
6
6
  "bin": {
@@ -41,13 +41,13 @@
41
41
  "js-yaml": "^4.3.2",
42
42
  "prompts": "^2.4.2",
43
43
  "tar": "^7.5.21",
44
+ "@uniweb/core": "~1.4.9",
44
45
  "@uniweb/content-writer": "~1.3.1",
45
46
  "@uniweb/schemas": "~1.6.5",
46
- "@uniweb/semantic-parser": "~1.13.0",
47
- "@uniweb/core": "~1.4.7"
47
+ "@uniweb/semantic-parser": "~1.13.0"
48
48
  },
49
49
  "peerDependencies": {
50
- "@uniweb/build": "~1.10.5",
50
+ "@uniweb/build": "~1.10.6",
51
51
  "@uniweb/semantic-parser": "~1.13.0",
52
52
  "@uniweb/content-reader": "~1.5.7"
53
53
  },
@@ -67,7 +67,7 @@ https://uniweb.io/llms.txt
67
67
 
68
68
  It lists every documentation page with a one-line description and a fetchable link. Go there whenever your question isn't answered in this file — and *especially* when you don't know whether a feature exists at all. That's where guessing does the most damage: **a 404 on a path you invented tells you your guess was wrong, not that the feature is missing.** Concluding "the framework doesn't support X" from a failed fetch is a mistake worth actively guarding against; the index answers the existence question directly.
69
69
 
70
- Then **follow the link the index gives you** rather than constructing one yourself. The index is the source of truth for where pages live, so it stays correct when the site's URL conventions change. Each link it gives is the page as Markdown.
70
+ Then **follow the link the index gives you** rather than constructing one yourself. The index is the source of truth for where pages live, so it stays correct when the site's URL conventions change. Each link it gives is the page as Markdown, and a page's own address returns the same Markdown to a request sent with `Accept: text/markdown`.
71
71
 
72
72
  **Clone the repo instead** when you expect several lookups, or may lose network access later:
73
73
 
@@ -2862,7 +2862,7 @@ uniweb <command> --help # Per-command flags — no side effects. Prefe
2862
2862
  | Any static host at all | `uniweb export` | full control; no Uniweb account needed |
2863
2863
  | Uniweb Cloud | `uniweb publish` | teams with non-technical content authors, or client work |
2864
2864
 
2865
- `uniweb deploy` never assumes a host: with nothing configured it opens a picker listing only destinations it can act on, and records the choice in `deploy.yml` so later runs go straight there. On Cloudflare Pages / Netlify / Vercel, `add ci` also adds per-PR previews that comment the URL. Adapters: `github-pages`, `cloudflare-pages`, `netlify`, `vercel`, plus `s3-cloudfront` for `deploy`. That record is `deploy.yml`, beside `site.yml` — **the CLI writes it, you don't create it**: the first successful deploy scaffolds the whole file, and later deploys rewrite only its `deploys:` block. Edit `targets:` when you want to change where a site ships. Host credentials come from the environment, never from that committed file.
2865
+ `uniweb deploy` never assumes a host: with nothing configured it opens a picker listing only destinations it can act on, and records the choice in `deploy.yml` so later runs go straight there. On Cloudflare Pages / Netlify / Vercel, `add ci` also adds per-PR previews that comment the URL. GitHub runs workflows only from the top of the repository: in a project that is a folder of a larger repository, the project's own `.github/` never runs, and `uniweb status` says so — as it says when a target in `deploy.yml` was never deployed from this copy. Adapters: `github-pages`, `cloudflare-pages`, `netlify`, `vercel`, plus `s3-cloudfront` for `deploy`. That record is `deploy.yml`, beside `site.yml` — **the CLI writes it, you don't create it**: the first successful deploy scaffolds the whole file, and later deploys rewrite only its `deploys:` block. Edit `targets:` when you want to change where a site ships. Host credentials come from the environment, never from that committed file.
2866
2866
 
2867
2867
  Foundations have their own free path too: `uniweb add ci --target foundation` publishes to permanent versioned URLs on GitHub Pages.
2868
2868
 
@@ -2916,6 +2916,8 @@ A site can be in more than one place — usually while it moves from one host to
2916
2916
 
2917
2917
  **Nobody's work is overwritten without asking.** `uniweb push` is refused if the site changed on the backend since your last pull — usually an author editing in the app — and reports what changed; nothing is written. Edits to different sections never collide. **In a git repository `uniweb pull` merges, like `git pull`:** it commits the backend's content (*"The site's content on …"*) and merges that commit into your branch, against the last state of the site your history shares with the backend. Your work, committed or not, and a teammate's arriving through git are kept; a file the backend never had is never deleted; changes to different parts of a file combine silently; and only a genuine overlap leaves conflict markers and a non-zero exit — so `uniweb pull && uniweb push` never ships markers. Settle a conflict as in any merge, then `git commit`; where your uncommitted changes overlap what the pull brought, the markers are theirs — settle them and `git add` each file, then `git stash drop` (git keeps them in the stash too). A push or publish is refused while a file under the site is in conflict. **In a git repository a push sends what you have committed:** with the site's content not committed it is refused before anything is sent, naming the files — commit them, or run `uniweb push --commit -m "…"` (it commits the site's content only, and leaves anything else staged as it is); `publish` and `sync` take `--commit` too. That commit is where the next pull merges from, which is how the pull tells your changes from the backend's. What the push writes back into the files — the ids the backend assigned — it commits itself, as `uniweb`. When the backend has nothing new, a pull says so and lists the files where yours hold changes it lacks. `uniweb refresh` runs `git pull` first, then the same pull. A push is also refused when your branch does not hold what this copy last took from the backend — after `git merge --abort`, or in a fresh clone that has not pulled — so pull, then push. Outside git — which includes a folder the repository around it ignores, such as a scratch directory — `uniweb pull` takes the backend's version over your files, asking first. `--force` is the deliberate overwrite (on `push` it replaces the backend's changes, on `pull` it discards yours); don't use it to get past a refusal. `uniweb sync` is `refresh` then `push`; neither publishes.
2918
2918
 
2919
+ **Moving a synced project to another repository.** Where the copy last met the backend is in its git history, and in `.uniweb/`, which git never holds — both stay behind; `sync.json` comes along, so the copy still knows its site. Move one this way: `uniweb sync` in the old copy, then copy the project in without its `.git/` and commit it, then sign in to the site's workspace and `uniweb pull` once — it records where the copy and the site meet, with nothing to settle when the old copy had synced. When it had not, `uniweb status` says the copy shares no history with the site's content and lists the files that say something else; the first pull makes each a conflict. Settle each by what you know: an edit made in the app since the last push is the backend's side, a change made in the copy and never pushed is yours — ask the person when you cannot tell. Never `--force` it in either direction: one side's edits would be gone from every file.
2920
+
2919
2921
  **`sync.json` says which site this is, on each backend.** The first push to a backend records what that backend assigned — the site's id and owner, the ids of its records and uploaded files — in `sync.json` beside `site.yml`. In a git repository the push commits it; never edit it. **To make a new site from a copy of a project, run `uniweb forget --all` in the copy before its first push** — the copy carries the original's `sync.json`, so otherwise its push updates the original's site. When two projects in one workspace hold the same site, a push or publish from either is refused until that is done. `uniweb forget --server <url>` removes just one backend's records, such as a scratch server's. A record file's `$uuid` is its own id and stays in both cases. **`push`, `pull` and `publish` go to the backend you are logged in to** — the last `uniweb login --server <url>` — and never to one you are not logged in to. A bare `uniweb login` logs in to https://uniweb.app; for any other backend, name it — logging in is how you switch, and the backend commands take no `--server` of their own. When the project has no site on that backend but has one elsewhere, a push says so before creating a new one.
2920
2922
 
2921
2923
  **`services:` in `site.yml` is where a site says which services it uses** — site search, form
@@ -1012,6 +1012,8 @@ export function untakenBase(siteDir, backend) {
1012
1012
  }
1013
1013
  return {
1014
1014
  headline: "Not pushed — your branch does not hold the backend content this copy last took.",
1015
+ // Never taken here — a fresh copy, or one brought from another repository — not lost.
1016
+ ...(point && held ? {} : { never: true }),
1015
1017
  lines: [
1016
1018
  point && held
1017
1019
  ? 'The pull or push it came from is not in your history — a merge abandoned, or the branch moved.'
@@ -1980,6 +1982,24 @@ async function comparisonEmit(
1980
1982
  })
1981
1983
  }
1982
1984
 
1985
+ /**
1986
+ * What a push would send from the files as they stand, every entity hashed — the same emit
1987
+ * the banked hashes are compared with (`comparisonEmit`) — or null when the files do not emit
1988
+ * (a key the build refuses, say). Two folders whose hashes agree say the same thing to the
1989
+ * backend, however their files are written: a pull with no common history reads that to leave
1990
+ * a file alone (`commands/pull.js`).
1991
+ *
1992
+ * @returns {Promise<Record<string,string>|null>}
1993
+ */
1994
+ export async function entityHashes(siteDir, backend = null, { declarations, fields = null } = {}) {
1995
+ try {
1996
+ const pkg = await comparisonEmit(siteDir, { backend, sendAll: true, declarations, fields })
1997
+ return pkg.hashes || {}
1998
+ } catch {
1999
+ return null
2000
+ }
2001
+ }
2002
+
1983
2003
  /**
1984
2004
  * Re-bank the send-only-changed hashes over the files as they NOW stand.
1985
2005
  *
@@ -1928,6 +1928,38 @@ async function runDoctor(args = []) {
1928
1928
  try {
1929
1929
  deployYml = await loadDeployYml(sitePath)
1930
1930
  } catch (err) {
1931
+ // `saveDeploys:` holding a value of the retired `autoSave:` — the key renamed by hand, its
1932
+ // value kept (measured 2026-10-26 on a copied project) — is the retired key's to repair.
1933
+ let kept = null
1934
+ try {
1935
+ const v = yaml.load(readFileSync(deployYmlPath, 'utf8'))?.saveDeploys
1936
+ kept = v === 'lastDeploy' || v === 'off' ? v : null
1937
+ } catch {
1938
+ kept = null
1939
+ }
1940
+ if (kept) {
1941
+ const id = 'deploy-yml-retired-key'
1942
+ const issue = { id, type: 'error', site: siteName, message: `deploy.yml has \`saveDeploys: ${kept}\`, a value of the retired \`autoSave:\`` }
1943
+ issues.push(issue)
1944
+ error(`[${id}] ${siteName}: deploy.yml has \`saveDeploys: ${kept}\` — a value of the retired \`autoSave:\`, where \`saveDeploys\` is true or false.`)
1945
+ log(
1946
+ ` ${colors.dim}${kept === 'off' ? '`autoSave: off` is `saveDeploys: false`.' : '`autoSave: lastDeploy` is what happens anyway — deploys are recorded — so the line goes.'}${colors.reset}`
1947
+ )
1948
+ if (shouldFix(id)) {
1949
+ try {
1950
+ const { retireDeployYmlKeys } = await import('@uniweb/build/site')
1951
+ if (await retireDeployYmlKeys(sitePath)) {
1952
+ issue.fixed = true
1953
+ fixed(`${relative(workspaceDir, deployYmlPath)}: ${kept === 'off' ? 'wrote `saveDeploys: false`' : 'removed `saveDeploys: lastDeploy`'}`)
1954
+ }
1955
+ } catch (fixErr) {
1956
+ warn(`[${id}] could not rewrite ${relative(workspaceDir, deployYmlPath)}: ${fixErr.message}`)
1957
+ }
1958
+ } else {
1959
+ log(` ${colors.dim}Run \`uniweb doctor --fix ${id}\` to write what it meant, keeping your comments.${colors.reset}`)
1960
+ }
1961
+ continue
1962
+ }
1931
1963
  issues.push({
1932
1964
  id: 'deploy-yml-malformed',
1933
1965
  type: 'error',
@@ -2013,10 +2045,13 @@ async function runDoctor(args = []) {
2013
2045
  if (names.length) lost.push(`the last deploy of ${names.map((n) => `\`${n}\``).join(', ')} is recorded only there, where nothing sees it`)
2014
2046
  }
2015
2047
  if (String(deployYml.autoSave) === 'off') lost.push('`autoSave: off` is ignored, so deploys are recorded')
2016
- const renamed = { lastDeploy: 'deploys', autoSave: 'saveDeploys' }
2017
- log(
2018
- ` ${colors.dim}${retired.map((k) => `\`${k}:\` is \`${renamed[k]}:\``).join(' and ')} now${lost.length ? ` — ${lost.join('; ')}` : ''}.${colors.reset}`
2019
- )
2048
+ // What each one means now — said by value, not as a rename: "`autoSave:` is `saveDeploys:`"
2049
+ // read as one, and a copy came back holding `saveDeploys: lastDeploy` (2026-10-26).
2050
+ const means = {
2051
+ lastDeploy: '`lastDeploy:` is `deploys:` now',
2052
+ autoSave: '`autoSave:` goes — `autoSave: off` is `saveDeploys: false`, and `autoSave: lastDeploy` is what happens anyway'
2053
+ }
2054
+ log(` ${colors.dim}${retired.map((k) => means[k]).join('; ')}${lost.length ? ` — ${lost.join('; ')}` : ''}.${colors.reset}`)
2020
2055
  if (shouldFix(id)) {
2021
2056
  try {
2022
2057
  const { retireDeployYmlKeys } = await import('@uniweb/build/site')
@@ -97,6 +97,7 @@ import {
97
97
  makeModelResolver,
98
98
  ensureRegisteredFoundation,
99
99
  deploymentFields,
100
+ entityHashes,
100
101
  rebankSyncHashes,
101
102
  writeQueryUuids,
102
103
  mergeBaseVersions,
@@ -118,6 +119,7 @@ import {
118
119
  madeOnto,
119
120
  baseFor,
120
121
  openStandIn,
122
+ fileAt,
121
123
  mergeSyncPoint,
122
124
  takeSyncPoint,
123
125
  snapshotFiles,
@@ -565,6 +567,100 @@ function restore(path, bytes) {
565
567
  else writeFileSync(path, bytes)
566
568
  }
567
569
 
570
+ /**
571
+ * ⭐ WITH NO COMMON HISTORY, A FILE THAT SAYS WHAT THE BACKEND HOLDS IS LEFT AS IT IS.
572
+ *
573
+ * A project copied into another repository leaves its sync points in the old history, so its
574
+ * first pull merges two histories that share nothing: every file the projection rewrites is a
575
+ * conflict, this copy's against the backend's. The writer already keeps an author's text where it
576
+ * would write the same document (`project-writer.js`, "an unchanged body keeps the author's own
577
+ * text"); what it rewrites, this tries once more, two ways. Every hash a push would send, with the
578
+ * backend's file put in among this copy's (`entityHashes`): where none moves, the two say the
579
+ * same. And the same asset written two ways, which a push sends as different data
580
+ * (`sameButAssetForms`). Either way this copy's text goes into the backend's commit, and the merge
581
+ * has nothing to settle there. A backend file that does not emit at all — a key the build
582
+ * refuses — says something else, and stays the backend's.
583
+ *
584
+ * Measured 2026-10-26 on two sites copied out of their own repositories: six conflicts, three of
585
+ * them content — a logo added in the app, a key the copy had renamed on both sites — and three
586
+ * gallery records naming their images by URL where the backend's files name them by path.
587
+ *
588
+ * @returns {Promise<string[]>} the files left as this copy writes them
589
+ */
590
+ async function keepWhatMeansTheSame({ standIn, repo, origin, declarations, fields }) {
591
+ const dir = standIn.siteDir
592
+ const ours = new Map()
593
+ const theirs = new Map()
594
+ for (const [status, path] of standIn.changes()) {
595
+ if (status !== 'M') continue
596
+ const head = fileAt(repo, 'HEAD', path)
597
+ const now = bytesOf(join(dir, path))
598
+ if (head && now) {
599
+ ours.set(path, head)
600
+ theirs.set(path, now)
601
+ }
602
+ }
603
+ if (!ours.size) return []
604
+ const opts = { ...(declarations?.size ? { declarations } : {}), fields }
605
+ const kept = []
606
+ // What the emits print is a comparison's, not this pull's to say.
607
+ const said = { warn: console.warn, log: console.log, info: console.info }
608
+ console.warn = console.log = console.info = () => {}
609
+ try {
610
+ // This copy's text everywhere: the files each backend one is tried among.
611
+ for (const [path, bytes] of ours) writeFileSync(join(dir, path), bytes)
612
+ const mine = await entityHashes(dir, origin, opts)
613
+ if (mine) {
614
+ for (const [path, bytes] of theirs) {
615
+ writeFileSync(join(dir, path), bytes)
616
+ const tried = await entityHashes(dir, origin, opts)
617
+ if ((tried && sameHashes(tried, mine)) || sameButAssetForms(path, ours.get(path), bytes)) kept.push(path)
618
+ writeFileSync(join(dir, path), ours.get(path))
619
+ }
620
+ }
621
+ } finally {
622
+ Object.assign(console, said)
623
+ // The backend's text back wherever it says something else.
624
+ for (const [path, bytes] of theirs) if (!kept.includes(path)) writeFileSync(join(dir, path), bytes)
625
+ }
626
+ return kept
627
+ }
628
+
629
+ function sameHashes(a, b) {
630
+ const keys = Object.keys(a)
631
+ return keys.length === Object.keys(b).length && keys.every((k) => a[k] === b[k])
632
+ }
633
+
634
+ /**
635
+ * Two versions of a file that differ only in how they name the same assets: a URL to the
636
+ * backend's copy (`https://…/assets/<sha256>/base.jpeg`) where the other has the site's own path
637
+ * (`/assets/<sha256>.jpeg`). A push sends different data from each — a URL, an asset reference —
638
+ * so the hashes differ, but the asset is one, by its content hash. This copy's text is kept: the
639
+ * banked hash is its own, so a push leaves the file alone, and the next pull from this base writes
640
+ * the backend's way (ours unchanged, theirs different). Measured 2026-10-26: three gallery records
641
+ * an older pull had written with URLs.
642
+ */
643
+ export function sameButAssetForms(path, a, b) {
644
+ const asset = (s) => s.replace(/(?:https?:\/\/[^\s"'()]+)?\/assets\/([0-9a-f]{64})(?:\/[^\s"'()]*?)?(?:\.[A-Za-z0-9]+)?(?=[\s"'()]|$)/g, 'asset:$1')
645
+ const read = (bytes) => {
646
+ const text = bytes.toString('utf8')
647
+ if (!/\.ya?ml$/.test(path)) return asset(text)
648
+ try {
649
+ return JSON.stringify(sortedDeep(yaml.load(text)), (_, v) => (typeof v === 'string' ? asset(v) : v))
650
+ } catch {
651
+ return asset(text)
652
+ }
653
+ }
654
+ const [x, y] = [read(a), read(b)]
655
+ return x === y && a.toString('utf8') !== b.toString('utf8')
656
+ }
657
+
658
+ function sortedDeep(v) {
659
+ if (Array.isArray(v)) return v.map(sortedDeep)
660
+ if (v && typeof v === 'object' && !(v instanceof Date)) return Object.fromEntries(Object.keys(v).sort().map((k) => [k, sortedDeep(v[k])]))
661
+ return v
662
+ }
663
+
568
664
  // The sync point's subject line, and the merge's.
569
665
  const pulledFrom = (origin) => `The site's content on ${origin}`
570
666
  const mergedFrom = (origin) => `Merge the site's content from ${origin}`
@@ -999,9 +1095,9 @@ export async function pull(args = [], deps = {}) {
999
1095
  origin: client.origin,
1000
1096
  siteUuid: siteContentUuid
1001
1097
  })
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 }
1098
+ // A probe with no base cannot tell the backend's changes from this copy's: onto HEAD, it says
1099
+ // what differs, and which way is the person's to read (`noBase`, below). ⛔ Until 2026-10-26
1100
+ // it said nothing at all, and status could not size a copied project's first pull.
1005
1101
  onto = force || !base ? repo.head : base
1006
1102
  try {
1007
1103
  standIn = openStandIn(repo, { roots, rev: onto })
@@ -1158,10 +1254,20 @@ export async function pull(args = [], deps = {}) {
1158
1254
  for (const w of report.warnings) note(`! ${w}`)
1159
1255
  }
1160
1256
 
1257
+ // ⭐ With no common history, each file that says what the backend holds is left as this copy
1258
+ // writes it (`keepWhatMeansTheSame`): only a difference in content is a conflict.
1259
+ if (standIn && !base && !force) {
1260
+ merge.keptAsWritten = await keepWhatMeansTheSame({ standIn, repo, origin: client.origin, declarations, fields })
1261
+ }
1262
+
1161
1263
  // ⭐ 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.
1264
+ // from the base is what the backend changed — or, with no base, what differs at all, which
1265
+ // way unknown (`noBase`). Nothing is banked; `finally` closes it.
1163
1266
  if (probe) {
1164
- return { exitCode: 0, probe: { files: standIn.changes(), recordsNotPlaced: Boolean(recordsNotPlaced) } }
1267
+ return {
1268
+ exitCode: 0,
1269
+ probe: { files: standIn.changes(), recordsNotPlaced: Boolean(recordsNotPlaced), ...(base ? {} : { noBase: true }) }
1270
+ }
1165
1271
  }
1166
1272
 
1167
1273
  // Outside git: what this pull wrote, so the next one can tell its own output from the
@@ -1369,6 +1475,11 @@ export async function pull(args = [], deps = {}) {
1369
1475
  if (ahead.length > 12) note(` … and ${ahead.length - 12} more`)
1370
1476
  }
1371
1477
  }
1478
+ if (merge.keptAsWritten?.length) {
1479
+ note(`${merge.keptAsWritten.length} file(s) differ from ${client.origin}'s only in how they are written, and are left as they are:`)
1480
+ for (const p of merge.keptAsWritten.slice(0, 12)) note(` ${repo.prefix}${p}`)
1481
+ if (merge.keptAsWritten.length > 12) note(` … and ${merge.keptAsWritten.length - 12} more`)
1482
+ }
1372
1483
  if (merge.conflicted.length) {
1373
1484
  error(`${merge.conflicted.length} file(s) have conflicts to resolve:`)
1374
1485
  for (const f of merge.conflicted) note(` ${f}`)
@@ -1379,7 +1490,7 @@ export async function pull(args = [], deps = {}) {
1379
1490
  } else {
1380
1491
  if (merge.noBase) {
1381
1492
  note(`No commit in this history is one ${client.origin}'s content came from, so nothing tells`)
1382
- note("your changes from the backend's: each file where they differ is a conflict, yours against the backend's.")
1493
+ note("your changes from the backend's: each file that says something else is a conflict, yours against the backend's.")
1383
1494
  note('Once this merge is committed, later pulls merge from it.')
1384
1495
  }
1385
1496
  note(
@@ -43,7 +43,7 @@
43
43
  */
44
44
 
45
45
  import { execFileSync } from 'node:child_process'
46
- import { existsSync, readFileSync, statSync } from 'node:fs'
46
+ import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs'
47
47
  import { basename, dirname, join, relative, resolve, sep } from 'node:path'
48
48
  import yaml from 'js-yaml'
49
49
 
@@ -467,7 +467,11 @@ async function appChecks(siteDir, siteYml, { offline, args, git }) {
467
467
  checks.push({
468
468
  id: 'app.base',
469
469
  state: 'blocking',
470
- summary: base.headline.replace(/^Not pushed — /, '').replace(/^./, (ch) => ch.toUpperCase()),
470
+ // ⛔ Not "does not hold what this copy last took" for a copy that took nothing here — a fresh
471
+ // copy, or a project brought from another repository, whose sync points stayed there.
472
+ summary: base.never
473
+ ? 'This copy has not taken the site from the backend yet: a push waits until a pull has merged its content.'
474
+ : base.headline.replace(/^Not pushed — /, '').replace(/^./, (ch) => ch.toUpperCase()),
471
475
  blocks: ['push', 'publish']
472
476
  })
473
477
  proposals.push({ step: 'pull', command: 'npx uniweb pull', why: "merge the backend's content with yours" })
@@ -502,6 +506,14 @@ async function appChecks(siteDir, siteYml, { offline, args, git }) {
502
506
  }
503
507
  : { id: 'app.unpushed', state: 'ok', summary: 'Nothing waiting to be pushed.' }
504
508
  )
509
+ } else if (base?.never && git.repo) {
510
+ // ⛔ A copy that took nothing here has no record of what it last sent, so every entity would
511
+ // count as unsent — "holds changes" is a guess, and a push step before the pull it needs.
512
+ checks.push({
513
+ id: 'app.unpushed',
514
+ state: 'unknown',
515
+ summary: "What this copy holds that the backend lacks is known once a pull has merged the site's content here."
516
+ })
505
517
  } else {
506
518
  try {
507
519
  const probe = await probeUnpushed(siteDir, { backend: origin })
@@ -575,8 +587,9 @@ async function appChecks(siteDir, siteYml, { offline, args, git }) {
575
587
 
576
588
  // ⭐ WHAT CHANGED ON THE BACKEND, AS A PULL WOULD TAKE IT. In a repository, the pull itself,
577
589
  // stopped before it takes anything (`probePull`): the files it would change, translations
578
- // and records included. Where it cannot answer — outside git, no sync point to merge from —
579
- // the backend's units against this copy's last sync (`backend/app-changes.js`).
590
+ // and records included — with no sync point in this history, the files that differ, which way
591
+ // unknown (`noBaseChanges`). Outside git, the backend's units against this copy's last sync
592
+ // (`backend/app-changes.js`).
580
593
  const readChanges = async () => {
581
594
  if (git.repo) {
582
595
  const org = readOrgFlag(args)
@@ -712,7 +725,7 @@ function changeChecks(siteDir, changes, unpushedFiles, proposals) {
712
725
  if (!moved) {
713
726
  return [
714
727
  unknown.length
715
- ? { id: 'app.changes', state: 'unknown', summary: `Could not tell what changed on the backend: ${unknown.map((l) => l.detail).join('; ')}.` }
728
+ ? { id: 'app.changes', state: 'unknown', summary: `Could not tell what changed on the backend: ${[...new Set(unknown.map((l) => l.detail))].join('; ')}.` }
716
729
  : { id: 'app.changes', state: 'ok', summary: 'Nothing changed on the backend since this copy last synced.', detail: { source: 'units' } }
717
730
  ]
718
731
  }
@@ -746,7 +759,8 @@ function changeChecks(siteDir, changes, unpushedFiles, proposals) {
746
759
  * `app.changes` from the pull's own answer (`probePull`): the files it would change, each
747
760
  * as the pull would write it.
748
761
  */
749
- function probedChanges({ files: list, recordsNotPlaced }, unpushedFiles, proposals) {
762
+ function probedChanges({ files: list, recordsNotPlaced, noBase }, unpushedFiles, proposals) {
763
+ if (noBase) return noBaseChanges(list, recordsNotPlaced)
750
764
  if (!list.length) {
751
765
  return [{ id: 'app.changes', state: 'ok', summary: 'Nothing changed on the backend since this copy last synced.', detail: { source: 'pull', files: [] } }]
752
766
  }
@@ -776,6 +790,34 @@ function probedChanges({ files: list, recordsNotPlaced }, unpushedFiles, proposa
776
790
  ]
777
791
  }
778
792
 
793
+ /**
794
+ * `app.changes` for a copy whose history shares no commit with the site's content — a project
795
+ * brought into another repository, whose sync points stayed in the old one. What differs is
796
+ * said, which way unknown: the first pull makes each such file a conflict, yours against the
797
+ * backend's, and records where the two meet; one that says what the backend holds, written
798
+ * another way, it leaves alone, and it is not listed (`keepWhatMeansTheSame`, `commands/pull.js`).
799
+ * The pull itself is `app.base`'s step.
800
+ */
801
+ function noBaseChanges(list, recordsNotPlaced) {
802
+ const differ = list.filter(([s]) => s === 'M').map(([, p]) => p)
803
+ const added = list.filter(([s]) => s === 'A').map(([, p]) => p)
804
+ const lines = []
805
+ if (added.length) lines.push(`${count(added.length, 'file')} only the backend has — the pull adds ${added.length === 1 ? 'it' : 'them'}.`)
806
+ if (recordsNotPlaced) lines.push('Some records could not be placed in files — `npx uniweb pull` says which, and why.')
807
+ return [
808
+ {
809
+ id: 'app.changes',
810
+ state: 'attention',
811
+ summary: differ.length
812
+ ? `This copy shares no history with the site's content on the backend — brought from another repository, most likely. ${count(differ.length, 'file')} here ${differ.length === 1 ? 'says' : 'say'} something else, and the first pull makes ${differ.length === 1 ? 'it a conflict' : 'each a conflict'} to settle, yours against the backend's.`
813
+ : "This copy shares no history with the site's content on the backend, and its files say what the site holds: the first pull records where they meet, with nothing to settle.",
814
+ files: differ,
815
+ lines,
816
+ detail: { source: 'pull', noBase: true, files: list.map(([s, p]) => ({ path: p, change: CHANGE[s] || s })) }
817
+ }
818
+ ]
819
+ }
820
+
779
821
  /**
780
822
  * `app.foundation`: the local foundation against its registered version — what the next
781
823
  * push or publish does with it (`backend/foundation-bring-along.js`).
@@ -1215,18 +1257,34 @@ async function deployChecks(siteDir) {
1215
1257
  host: t.host,
1216
1258
  title,
1217
1259
  default: name === defaultName,
1260
+ deployed: Boolean(ci || receipt || retired),
1218
1261
  ci: ci ? { file: ci.file, branches: ci.branches } : null,
1219
1262
  last: receipt ? { at: receipt.at ?? null, sha: receipt.git?.sha ?? null, url: receipt.url ?? null } : null,
1220
1263
  behind,
1221
1264
  ...(retired ? { retired: true } : {})
1222
1265
  })
1223
1266
  }
1267
+ // ⭐ A TARGET IS A DESTINATION, NOT A DEPLOY. Only CI or a receipt says the site went there;
1268
+ // a target with neither is named as what it is. ⛔ Until 2026-10-26 every target was "Also
1269
+ // deployed to", and the status opened with it — on a project copied from a repository whose
1270
+ // CI deployed it, after the site had moved to Uniweb Cloud.
1271
+ const deployed = targets.filter((t) => t.deployed)
1272
+ const never = targets.filter((t) => !t.deployed)
1273
+ const parts = []
1274
+ if (deployed.length) parts.push(`Also deployed to ${deployed.map((t) => t.title).join(' and ')}.`)
1275
+ if (never.length) {
1276
+ parts.push(`deploy.yml names ${never.map((t) => t.title).join(' and ')}, never deployed from this copy.`)
1277
+ attention = true
1278
+ lines.push(
1279
+ 'A target never deployed from here is a host the site has not gone to yet, or one it left — when it left, remove the target from deploy.yml, and `default:` when it names it.'
1280
+ )
1281
+ }
1224
1282
  return {
1225
1283
  checks: [
1226
1284
  {
1227
1285
  id: 'hosting.deploys',
1228
1286
  state: attention ? 'attention' : 'ok',
1229
- summary: `Also deployed to ${targets.map((t) => t.title).join(' and ')}.`,
1287
+ summary: parts.join(' '),
1230
1288
  lines,
1231
1289
  detail: { targets }
1232
1290
  }
@@ -1236,6 +1294,40 @@ async function deployChecks(siteDir) {
1236
1294
  }
1237
1295
  }
1238
1296
 
1297
+ /**
1298
+ * `hosting.workflows`: GitHub Actions workflows between this site and the top of its
1299
+ * repository — a project's own `.github/workflows` — which GitHub never runs: it runs the
1300
+ * repository's top-level workflows alone, so these are files, not CI.
1301
+ *
1302
+ * ⭐ A project copied into another repository brings its `.github/` along (`uniweb add ci`
1303
+ * writes one). Measured 2026-10-26 on a repository of client projects: a site that had moved to
1304
+ * Uniweb Cloud still carried the GitHub Pages workflow that used to deploy it.
1305
+ */
1306
+ function workflowsCheck(siteDir) {
1307
+ const repo = repoOf(siteDir)
1308
+ if (!repo) return null
1309
+ const found = []
1310
+ for (let d = siteDir; d !== repo.top && d.startsWith(repo.top + sep); d = dirname(d)) {
1311
+ const dir = join(d, '.github', 'workflows')
1312
+ let names
1313
+ try {
1314
+ names = readdirSync(dir).filter((n) => /\.ya?ml$/.test(n))
1315
+ } catch {
1316
+ continue
1317
+ }
1318
+ for (const n of names.sort()) found.push(relative(siteDir, join(dir, n)))
1319
+ }
1320
+ if (!found.length) return { id: 'hosting.workflows', state: 'ok', summary: 'No workflow here that GitHub would not run.', quiet: true }
1321
+ const n = found.length
1322
+ return {
1323
+ id: 'hosting.workflows',
1324
+ state: 'attention',
1325
+ summary: `${count(n, 'workflow')} here never ${n === 1 ? 'runs' : 'run'}: GitHub runs workflows only from the top of the repository, ${relative(siteDir, join(repo.top, '.github', 'workflows'))}.`,
1326
+ files: found,
1327
+ lines: ['Most likely left from the repository this project came from. When the site no longer deploys that way, the folder can go — ask the person first.']
1328
+ }
1329
+ }
1330
+
1239
1331
  const list = (names) => names.map((n) => `\`${n}\``).join(', ')
1240
1332
 
1241
1333
  /**
@@ -1305,7 +1397,8 @@ function summaryLine({ name, synced, live, targets, origin }) {
1305
1397
  const parts = [synced.length ? `synced with ${synced.join(' and ')}` : 'not synced with a backend']
1306
1398
  if (live?.published) parts.push(`live on ${cloudName(origin)}${live.url ? ` at ${live.url}` : ''}`)
1307
1399
  else if (live) parts.push(`not live on ${cloudName(origin)} yet`)
1308
- if (targets.length) parts.push(`${live?.published ? 'also deployed' : 'deployed'} to ${targets.map((t) => t.title).join(' and ')}`)
1400
+ const deployed = targets.filter((t) => t.deployed)
1401
+ if (deployed.length) parts.push(`${live?.published ? 'also deployed' : 'deployed'} to ${deployed.map((t) => t.title).join(' and ')}`)
1309
1402
  else if (!synced.length) parts.push('not hosted anywhere yet')
1310
1403
  return `${name} — ${parts.join(' · ')}`
1311
1404
  }
@@ -1399,7 +1492,8 @@ async function workspaceOverview(root, { json }) {
1399
1492
  })
1400
1493
  const languages = normalizeLanguageList(yml.languages)
1401
1494
  const parts = [synced.length ? `synced with ${synced.join(' and ')}` : 'not synced']
1402
- if (targets.length) parts.push(`deployed to ${targets.map((t) => t.title).join(' and ')}`)
1495
+ const deployed = targets.filter((t) => t.deployed)
1496
+ if (deployed.length) parts.push(`deployed to ${deployed.map((t) => t.title).join(' and ')}`)
1403
1497
  parts.push(
1404
1498
  fnd.kind === 'local'
1405
1499
  ? `foundation ${fnd.name}`
@@ -1414,7 +1508,7 @@ async function workspaceOverview(root, { json }) {
1414
1508
  name: s.name,
1415
1509
  summary: `${s.path} — ${parts.join(' · ')}`,
1416
1510
  synced,
1417
- deploys: targets.map(({ name, host, title, default: isDefault, ci }) => ({ name, host, title, default: isDefault, ci })),
1511
+ deploys: targets.map(({ name, host, title, default: isDefault, deployed: was, ci }) => ({ name, host, title, default: isDefault, deployed: was, ci })),
1418
1512
  foundation: { kind: fnd.kind, name: fnd.name ?? null, path: fnd.path ?? null },
1419
1513
  mounts,
1420
1514
  languages
@@ -1508,7 +1602,18 @@ export async function status(args = []) {
1508
1602
  } finally {
1509
1603
  Object.assign(console, saved)
1510
1604
  }
1511
- const checks = [...project.checks, ...git.checks, ...app.checks, ...deploys.checks, ...services.checks, ...version.checks, ...health.checks, dev]
1605
+ const workflows = workflowsCheck(siteDir)
1606
+ const checks = [
1607
+ ...project.checks,
1608
+ ...git.checks,
1609
+ ...app.checks,
1610
+ ...deploys.checks,
1611
+ ...(workflows ? [workflows] : []),
1612
+ ...services.checks,
1613
+ ...version.checks,
1614
+ ...health.checks,
1615
+ dev
1616
+ ]
1512
1617
  const next = planNext([...git.proposals, ...app.proposals, ...deploys.proposals, ...services.proposals, ...version.proposals, ...health.proposals])
1513
1618
 
1514
1619
  const root = findWorkspaceRoot(siteDir) || siteDir
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "schemaVersion": 1,
3
- "generatedAt": "2026-10-26T19:45:51.544Z",
3
+ "generatedAt": "2026-10-26T22:50:14.357Z",
4
4
  "packages": {
5
5
  "@uniweb/api": {
6
6
  "version": "1.6.4"
7
7
  },
8
8
  "@uniweb/build": {
9
- "version": "1.10.5"
9
+ "version": "1.10.6"
10
10
  },
11
11
  "@uniweb/content-reader": {
12
12
  "version": "1.5.7"
@@ -15,7 +15,7 @@
15
15
  "version": "1.3.1"
16
16
  },
17
17
  "@uniweb/core": {
18
- "version": "1.4.7"
18
+ "version": "1.4.9"
19
19
  },
20
20
  "@uniweb/frame-bridge": {
21
21
  "version": "1.0.2"
@@ -33,10 +33,10 @@
33
33
  "version": "1.1.4"
34
34
  },
35
35
  "@uniweb/projections": {
36
- "version": "1.2.1"
36
+ "version": "1.2.3"
37
37
  },
38
38
  "@uniweb/runtime": {
39
- "version": "1.2.3"
39
+ "version": "1.2.5"
40
40
  },
41
41
  "@uniweb/scene": {
42
42
  "version": "1.0.1"
package/src/index.js CHANGED
@@ -1789,8 +1789,9 @@ where, live where, deployed where — and checks:
1789
1789
  app the session, what changed on the backend since this copy last synced —
1790
1790
  the files a pull would change — what is not pushed, who has the site
1791
1791
  open in the app, the foundation
1792
- hosting where the site is live, the other hosts deploy.yml lists (by CI or by
1793
- hand, and how far behind), and the services the live site has
1792
+ hosting where the site is live, the other hosts deploy.yml lists (by CI, by hand
1793
+ and how far behind, or never from this copy), workflows GitHub never runs
1794
+ (a project's own .github/), and the services the live site has
1794
1795
  uniweb whether a newer release is out
1795
1796
  health doctor's findings
1796
1797
  dev the dev server
@@ -759,6 +759,15 @@ export function uncommittedPaths(repo, roots) {
759
759
  return [...new Set(out.map((p) => siteRelative(repo, p)))]
760
760
  }
761
761
 
762
+ /** A site file's bytes as `rev` holds it — `path` relative to the site — or null when it holds none. */
763
+ export function fileAt(repo, rev, path) {
764
+ try {
765
+ return git(repo.top, ['cat-file', 'blob', `${rev}:${repo.prefix}${path}`])
766
+ } catch {
767
+ return null
768
+ }
769
+ }
770
+
762
771
  /**
763
772
  * The files under the roots that differ from what `rev` holds there — changed, deleted, or
764
773
  * new and not ignored — relative to the site.