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.
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "schemaVersion": 1,
3
- "generatedAt": "2026-10-26T02:00:48.151Z",
3
+ "generatedAt": "2026-10-26T17:19:54.265Z",
4
4
  "packages": {
5
5
  "@uniweb/api": {
6
6
  "version": "1.6.4"
7
7
  },
8
8
  "@uniweb/build": {
9
- "version": "1.10.2"
9
+ "version": "1.10.3"
10
10
  },
11
11
  "@uniweb/content-reader": {
12
12
  "version": "1.5.7"
package/src/index.js CHANGED
@@ -876,7 +876,7 @@ async function main() {
876
876
  await exitWhenDrained(result?.exitCode ?? 0)
877
877
  }
878
878
 
879
- // Handle status command (dynamic import — offline emit via @uniweb/build)
879
+ // Handle status command (dynamic import — its checks reach @uniweb/build)
880
880
  if (command === 'status') {
881
881
  const { status } = await importProjectCommand('./commands/status.js')
882
882
  const result = await status(args.slice(1))
@@ -1770,6 +1770,42 @@ ${colors.bright}Options:${colors.reset}
1770
1770
  --non-interactive Fail with usage info instead of prompting
1771
1771
 
1772
1772
  Exit code is 1 if errors are found (warnings only → exit 0).
1773
+ `,
1774
+ status: `
1775
+ ${colors.cyan}${colors.bright}uniweb status${colors.reset} ${colors.dim}— What to know before working on a site, and what to run about it${colors.reset}
1776
+
1777
+ ${colors.bright}Usage:${colors.reset}
1778
+ uniweb status [options]
1779
+
1780
+ Run it when you begin, when you come back to the project, before you push, publish
1781
+ or deploy, and after a command refuses. It opens with what the site is — synced
1782
+ where, live where, deployed where — and checks:
1783
+
1784
+ git conflicts, a merge waiting, the site's files not committed, the git remote
1785
+ app the session, what changed on the backend since this copy last synced —
1786
+ the files a pull would change — what is not pushed, who has the site
1787
+ open in the app, the foundation
1788
+ hosting where the site is live, the other hosts deploy.yml lists (by CI or by
1789
+ hand, and how far behind), and the services the live site has
1790
+ uniweb whether a newer release is out
1791
+ health doctor's findings
1792
+ dev the dev server
1793
+
1794
+ and ends with the steps to take, in the order to take them. A step marked
1795
+ "ask the person first" is theirs to decide — going live, for one.
1796
+
1797
+ It changes nothing and asks nothing: with no session it says how to sign in.
1798
+ A check it could not make says so, and is never reported as fine.
1799
+
1800
+ ${colors.bright}Options:${colors.reset}
1801
+ --offline Ask nothing beyond this machine — no backend, npm, git remote
1802
+ or dev server
1803
+ --json One JSON document: { schema: 1, checks: [...], next: [...] }
1804
+ --org @org The workspace to ask the backend in, for this command only
1805
+ --personal Your personal workspace, for this command only
1806
+
1807
+ Exit code is 0 whenever a status is produced, 2 for a usage error — read the
1808
+ document, not the exit code, for what needs doing.
1773
1809
  `,
1774
1810
  validate: `
1775
1811
  ${colors.cyan}${colors.bright}uniweb validate${colors.reset} ${colors.dim}— Check your content against your foundation's data schemas${colors.reset}
@@ -2137,7 +2173,7 @@ ${colors.bright}Commands:${colors.reset}
2137
2173
  pull Pull a site's content from the backend
2138
2174
  refresh Catch up with the git remote AND the backend (never pushes)
2139
2175
  sync Catch up, then push (refresh + push)
2140
- status Show a site's sync state (unpushed content, foundation)
2176
+ status What changed in the app, in git and in Uniweb — and what to run
2141
2177
  forget Forget a backend, or --all to make a copied project a new one
2142
2178
  site <cmd> The sites in a workspace: list, unpublish, delete
2143
2179
  inspect <path> Inspect parsed content shape of a markdown file or folder
@@ -119,33 +119,6 @@ export function uploadOrder(files) {
119
119
  return [...rest, ...entry]
120
120
  }
121
121
 
122
- /**
123
- * The deterministic content digest of a built foundation — the freshness
124
- * fingerprint `register` records and `publish`/`status` later compare against
125
- * (shipping-model.md §4.1). It answers one question — "did the foundation change
126
- * since it was released?" — with NO local state, so it has to be reproducible:
127
- * the same source, built on any machine, must yield the same digest.
128
- *
129
- * Algorithm (§4.1): `sha256` over the newline-joined, SORTED list of the
130
- * per-file `sha256` (hex) of exactly what `register` ships for a foundation —
131
- * - the code-upload set (`collectDistFiles`: entry + chunks + assets, with
132
- * `meta/**` and `*.map` already excluded), and
133
- * - the schema source (`meta/schema.json`) — NOT uploaded as a file, and not
134
- * sent verbatim: register submits its *content* as the foundation-schema
135
- * ENTITY in the `.uwx`. It's read here only to fold the schema into the
136
- * local freshness fingerprint (so a schema edit shows as "changed").
137
- * Hashes are SORTED (so the digest is order-independent) and taken over file
138
- * CONTENTS, not names, so content-hashed chunk filenames don't perturb it.
139
- * Returns null when there's nothing to hash (no usable dist), so callers skip
140
- * cleanly rather than fingerprinting emptiness.
141
- *
142
- * The result is OPAQUE to the backend — it stores the string and returns it
143
- * verbatim; the framework owns the algorithm end-to-end and can change it
144
- * freely (a digest only ever compares against one the same CLI computed).
145
- *
146
- * @param {string} distDir - the built `dist/` directory
147
- * @returns {string|null} `sha256:<hex>` or null when there's nothing to hash
148
- */
149
122
  /**
150
123
  * The `@uniweb/runtime` version a built foundation linked against, from
151
124
  * `dist/runtime-pin.json`.
@@ -180,6 +153,33 @@ export function readRuntimePin(distDir) {
180
153
  }
181
154
  }
182
155
 
156
+ /**
157
+ * The deterministic content digest of a built foundation — the freshness
158
+ * fingerprint `register` records and `publish`/`status` later compare against
159
+ * (shipping-model.md §4.1). It answers one question — "did the foundation change
160
+ * since it was released?" — with NO local state, so it has to be reproducible:
161
+ * the same source, built on any machine, must yield the same digest.
162
+ *
163
+ * Algorithm (§4.1): `sha256` over the newline-joined, SORTED list of the
164
+ * per-file `sha256` (hex) of exactly what `register` ships for a foundation —
165
+ * - the code-upload set (`collectDistFiles`: entry + chunks + assets, with
166
+ * `meta/**` and `*.map` already excluded), and
167
+ * - the schema source (`meta/schema.json`) — NOT uploaded as a file, and not
168
+ * sent verbatim: register submits its *content* as the foundation-schema
169
+ * ENTITY in the `.uwx`. It's read here only to fold the schema into the
170
+ * local freshness fingerprint (so a schema edit shows as "changed").
171
+ * Hashes are SORTED (so the digest is order-independent) and taken over file
172
+ * CONTENTS, not names, so content-hashed chunk filenames don't perturb it.
173
+ * Returns null when there's nothing to hash (no usable dist), so callers skip
174
+ * cleanly rather than fingerprinting emptiness.
175
+ *
176
+ * The result is OPAQUE to the backend — it stores the string and returns it
177
+ * verbatim; the framework owns the algorithm end-to-end and can change it
178
+ * freely (a digest only ever compares against one the same CLI computed).
179
+ *
180
+ * @param {string} distDir - the built `dist/` directory
181
+ * @returns {string|null} `sha256:<hex>` or null when there's nothing to hash
182
+ */
183
183
  export function computeFoundationDigest(distDir) {
184
184
  const hashes = collectDistFiles(distDir).map((f) => f.sha256)
185
185
  // The schema rides in the register `.uwx`, not the code-upload set, so fold
@@ -109,11 +109,14 @@ const VERBS = {
109
109
  */
110
110
  site: ['--json', '--yes', '--org', '--personal'],
111
111
  status: [
112
- '--json', '--remote', '--dry-run',
112
+ '--json', '--offline', '--dry-run',
113
113
  '--force', '--no-verify', '--no-validate', '--yes', '--org', '--personal',
114
114
  // inert here, reachable through the bring-along module status imports for
115
115
  // `resolveLocalFoundation` — listed per the over-approximation note above
116
- '--no-release', '--bump', ...VIA_DEPLOY
116
+ '--no-release', '--bump', ...VIA_DEPLOY,
117
+ // inert here too, reachable through `commands/pull.js`, whose lane readers status uses
118
+ // to ask what changed on the backend (`backend/app-changes.js`)
119
+ '--no-assets', '--no-delete', '--no-prune', '--no-records', '--content-only'
117
120
  ],
118
121
  /**
119
122
  * `refresh` = `git pull`, then a DELEGATED `pull`.
@@ -249,6 +252,18 @@ export function checkFlags(verb, args = []) {
249
252
  message: `\`--as-org\` is retired — use \`--org @org\` (e.g. \`uniweb ${verb} --org @acme\`).`
250
253
  }
251
254
  }
255
+ // ⭐ `status --remote` is RETIRED (2026-10-26): a status asks the backend by default, and
256
+ // `--offline` keeps it to this machine.
257
+ if (flag === '--remote' && verb === 'status') {
258
+ return {
259
+ flag,
260
+ suggestion: null,
261
+ message: [
262
+ '`uniweb status` has no `--remote`: it asks the backend by default now.',
263
+ ' Run `uniweb status`, or `uniweb status --offline` to ask nothing beyond this machine.'
264
+ ].join('\n')
265
+ }
266
+ }
252
267
  // ⭐ `pull --merge` is RETIRED (2026-10-17): in a repository a pull always merges — from the
253
268
  // newest sync point the history holds (`utils/sync-point.js`) — and outside one there is
254
269
  // nothing to merge against.
package/src/utils/git.js CHANGED
@@ -21,7 +21,7 @@
21
21
  * project counted as inside the harness, so `refresh` ran `git pull` on the harness.
22
22
  */
23
23
 
24
- import { execFileSync } from 'node:child_process'
24
+ import { execFile, execFileSync } from 'node:child_process'
25
25
  import { readFileSync } from 'node:fs'
26
26
  import { join } from 'node:path'
27
27
  import yaml from 'js-yaml'
@@ -198,6 +198,96 @@ export function pullRemote(dir) {
198
198
  }
199
199
  }
200
200
 
201
+ /**
202
+ * The branch HEAD is on and the one it tracks — `{ branch, remote, merge, tracking }`
203
+ * (`main`, `origin`, `refs/heads/main`, `origin/main`) — or null: no branch, or no upstream.
204
+ */
205
+ export function upstreamOf(dir) {
206
+ try {
207
+ const branch = git(['symbolic-ref', '--short', '-q', 'HEAD'], dir).trim()
208
+ if (!branch) return null
209
+ const remote = git(['config', '--get', `branch.${branch}.remote`], dir).trim()
210
+ const merge = git(['config', '--get', `branch.${branch}.merge`], dir).trim()
211
+ const tracking = git(['rev-parse', '--abbrev-ref', '--symbolic-full-name', '@{u}'], dir).trim()
212
+ return remote && merge && tracking ? { branch, remote, merge, tracking } : null
213
+ } catch {
214
+ return null
215
+ }
216
+ }
217
+
218
+ /**
219
+ * The commits HEAD has that its upstream lacks (`ahead`), and the reverse (`behind`) — as of
220
+ * the last fetch, which is all a repository knows without asking its remote. Null with no
221
+ * upstream.
222
+ */
223
+ export function aheadBehind(dir) {
224
+ try {
225
+ const [behind, ahead] = git(['rev-list', '--left-right', '--count', '@{u}...HEAD'], dir)
226
+ .trim()
227
+ .split(/\s+/)
228
+ .map(Number)
229
+ return Number.isInteger(ahead) && Number.isInteger(behind) ? { ahead, behind } : null
230
+ } catch {
231
+ return null
232
+ }
233
+ }
234
+
235
+ /**
236
+ * Does the git remote hold commits HEAD does not — teammates' work since the last fetch?
237
+ * Asked with `git ls-remote`, which fetches nothing and writes nothing, so a read-only
238
+ * command can ask it.
239
+ *
240
+ * ⛔ IT NEVER PROMPTS. A credential prompt would hang a command run by an agent, so: no
241
+ * terminal prompt (`GIT_TERMINAL_PROMPT=0`), no credential manager window
242
+ * (`GCM_INTERACTIVE=never`), ssh in batch mode with a connect timeout — added to the ssh
243
+ * command the person configured, never instead of it — and the whole call bounded.
244
+ *
245
+ * @param {string} dir
246
+ * @param {{ timeoutMs?: number }} [opts]
247
+ * @returns {Promise<{ moved: boolean, upstream: object } | null>} null when there is no
248
+ * upstream, or the remote did not answer in time
249
+ */
250
+ export async function remoteMoved(dir, { timeoutMs = 5000 } = {}) {
251
+ const upstream = upstreamOf(dir)
252
+ if (!upstream) return null
253
+ let ssh = process.env.GIT_SSH_COMMAND || ''
254
+ try {
255
+ ssh ||= git(['config', '--get', 'core.sshCommand'], dir).trim()
256
+ } catch {
257
+ // not configured
258
+ }
259
+ const env = {
260
+ ...process.env,
261
+ GIT_TERMINAL_PROMPT: '0',
262
+ GCM_INTERACTIVE: 'never',
263
+ GIT_SSH_COMMAND: `${ssh || 'ssh'} -o BatchMode=yes -o ConnectTimeout=${Math.ceil(timeoutMs / 1000)}`
264
+ }
265
+ let out
266
+ try {
267
+ out = await new Promise((resolve, reject) => {
268
+ execFile('git', ['ls-remote', '--', upstream.remote, upstream.merge], { cwd: dir, env, timeout: timeoutMs, encoding: 'utf8' }, (err, stdout) =>
269
+ err ? reject(err) : resolve(stdout)
270
+ )
271
+ })
272
+ } catch {
273
+ return null
274
+ }
275
+ const head = String(out)
276
+ .split('\n')
277
+ .map((line) => line.split('\t'))
278
+ .find(([, ref]) => ref === upstream.merge)?.[0]
279
+ if (!head) return null
280
+ // A head HEAD already holds — fetched earlier, or HEAD's own — is nothing new.
281
+ let held = false
282
+ try {
283
+ git(['merge-base', '--is-ancestor', head, 'HEAD'], dir)
284
+ held = true
285
+ } catch {
286
+ held = false
287
+ }
288
+ return { moved: !held, upstream }
289
+ }
290
+
201
291
  /**
202
292
  * Provenance for a deploy record: `{ sha, dirty }`, or null outside a repo.
203
293
  * Answers "what is actually live?" later, which a version number cannot.
@@ -0,0 +1,47 @@
1
+ /**
2
+ * The order `uniweb status`'s next steps are taken in.
3
+ *
4
+ * ⭐ FOLLOWED FROM THE TOP, THE LIST WORKS: each step's precondition is met by the ones above
5
+ * it. A list of individually right steps in the wrong order is a guard that names an
6
+ * unavailable recovery step — a push before the pull it needs is refused, and the refusal
7
+ * then sends the reader back up. So the order is one table, not each check's own idea:
8
+ *
9
+ * sign in → settle conflicts → finish a merge → take what others did (refresh, or pull)
10
+ * → push → push to git → go live → deploy to another host → update Uniweb → read doctor's findings
11
+ *
12
+ * Each check proposes steps by name (`step`); this sorts them, folds the ones another step
13
+ * already does, and drops duplicates.
14
+ */
15
+
16
+ const ORDER = ['login', 'conflicts', 'merge', 'refresh', 'pull', 'push', 'gitpush', 'publish', 'deploy', 'update', 'doctor']
17
+
18
+ /**
19
+ * @param {Array<{ step: string, command: string|null, why: string, ask?: boolean }>} proposals
20
+ * @returns {Array<{ command: string|null, why: string, ask: boolean }>}
21
+ */
22
+ export function planNext(proposals) {
23
+ const steps = [...proposals]
24
+ // `refresh` is `git pull`, then the same pull: it takes the app's changes too.
25
+ const refresh = steps.find((p) => p.step === 'refresh')
26
+ const pull = steps.find((p) => p.step === 'pull')
27
+ if (refresh && pull) {
28
+ refresh.why = `${refresh.why}, and ${pull.why}`
29
+ steps.splice(steps.indexOf(pull), 1)
30
+ }
31
+ // A newer release's update aligns the dependencies and AGENTS.md too: the project's own
32
+ // CLI aligning them to its matrix is then one step too many.
33
+ if (steps.some((p) => p.step === 'update' && p.latest)) {
34
+ for (let i = steps.length - 1; i >= 0; i--) if (steps[i].step === 'update' && !steps[i].latest) steps.splice(i, 1)
35
+ }
36
+ const seen = new Set()
37
+ return steps
38
+ .filter((p) => ORDER.includes(p.step))
39
+ .sort((a, b) => ORDER.indexOf(a.step) - ORDER.indexOf(b.step))
40
+ .filter((p) => {
41
+ const key = p.command ?? `${p.step}:${p.why}`
42
+ if (seen.has(key)) return false
43
+ seen.add(key)
44
+ return true
45
+ })
46
+ .map(({ command, why, ask }) => ({ command: command ?? null, why, ask: Boolean(ask) }))
47
+ }
@@ -382,7 +382,7 @@ function differsIn(repo, a, b, roots) {
382
382
  * @param {string[]} opts.roots - the site's content roots, relative to the site
383
383
  * @param {string|null} opts.rev - the commit whose content the roots are filled from;
384
384
  * null for none
385
- * @returns {{ siteDir: string, commit: Function, placeIgnored: Function, close: Function }}
385
+ * @returns {{ siteDir: string, commit: Function, changes: Function, placeIgnored: Function, close: Function }}
386
386
  */
387
387
  export function openStandIn(repo, { roots, rev }) {
388
388
  for (const r of roots) {
@@ -543,6 +543,24 @@ export function openStandIn(repo, { roots, rev }) {
543
543
  return placed
544
544
  }
545
545
 
546
+ /**
547
+ * What is written under the roots that `rev` does not hold so — each `[status, path]`,
548
+ * status `A`, `M` or `D`, the path relative to the site. Asked of the stand-in's own
549
+ * index, which holds `rev`: nothing is staged, committed or written to the repository.
550
+ * What the repository ignores is no change, as for `commit`.
551
+ * @returns {Array<[string, string]>}
552
+ */
553
+ const changes = () => {
554
+ const roots = full.filter((f) => present.includes(f) || existsSync(join(root, f)))
555
+ if (!roots.length) return []
556
+ const site = (p) => (sitePrefix && p.startsWith(sitePrefix) ? p.slice(sitePrefix.length) : p)
557
+ const out = []
558
+ const tracked = zlist(at(['diff', '--name-status', '-z', '--no-renames', '--', ...roots]))
559
+ for (let i = 0; i + 1 < tracked.length; i += 2) out.push([tracked[i], site(tracked[i + 1])])
560
+ for (const p of zlist(at(['ls-files', '--others', '--exclude-standard', '-z', '--', ...roots]))) out.push(['A', site(p)])
561
+ return out.sort((a, b) => (a[1] < b[1] ? -1 : a[1] > b[1] ? 1 : 0))
562
+ }
563
+
546
564
  // The roots git can hold here. ⛔ One the repository ignores whole is not named to
547
565
  // `git add`, which refuses an ignored path; what is written under it is placed instead.
548
566
  const held = (paths) => {
@@ -556,7 +574,7 @@ export function openStandIn(repo, { roots, rev }) {
556
574
  return paths.filter((p) => !ignored.includes(p))
557
575
  }
558
576
 
559
- return { siteDir, commit, placeIgnored, close }
577
+ return { siteDir, commit, changes, placeIgnored, close }
560
578
  } catch (err) {
561
579
  close()
562
580
  throw err
@@ -12,8 +12,10 @@ import { join } from 'node:path'
12
12
  import { detectGlobalCliPm, globalCliUpdateCmd } from './pm.js'
13
13
 
14
14
  const CHECK_INTERVAL = 24 * 60 * 60 * 1000 // 1 day
15
- const STATE_DIR = join(homedir(), '.uniweb')
16
- const STATE_FILE = join(STATE_DIR, 'update-check.json')
15
+ // Found when read, not when this module loads — as `getAuthDir` is — so HOME is the one in
16
+ // effect: a run with its own HOME (a test's) never reads or writes the person's cache.
17
+ const stateDir = () => join(homedir(), '.uniweb')
18
+ const stateFile = () => join(stateDir(), 'update-check.json')
17
19
 
18
20
  /**
19
21
  * Compare two semver strings.
@@ -34,8 +36,8 @@ function compareSemver(a, b) {
34
36
  */
35
37
  function readState() {
36
38
  try {
37
- if (existsSync(STATE_FILE)) {
38
- return JSON.parse(readFileSync(STATE_FILE, 'utf8'))
39
+ if (existsSync(stateFile())) {
40
+ return JSON.parse(readFileSync(stateFile(), 'utf8'))
39
41
  }
40
42
  } catch {
41
43
  /* ignore corrupt cache */
@@ -48,8 +50,8 @@ function readState() {
48
50
  */
49
51
  function writeState(state) {
50
52
  try {
51
- if (!existsSync(STATE_DIR)) mkdirSync(STATE_DIR, { recursive: true })
52
- writeFileSync(STATE_FILE, JSON.stringify(state))
53
+ if (!existsSync(stateDir())) mkdirSync(stateDir(), { recursive: true })
54
+ writeFileSync(stateFile(), JSON.stringify(state))
53
55
  } catch {
54
56
  /* ignore write errors */
55
57
  }
@@ -32,11 +32,10 @@ index: home
32
32
  # mounted directory is a folder of pages. Whatever that stub leaves unset comes
33
33
  # from the mounted directory's own folder.yml, so it need only say what differs.
34
34
  #
35
- # ! A `pages/<segment>:` sub-mount is BUILD-ONLY today. `uniweb export` and
36
- # `uniweb deploy --host <adapter>` mount it normally, but `uniweb push` /
37
- # `uniweb publish` do not read the key, so the mounted pages do not reach a
38
- # backend (the CLI warns when it skips them). The whole-directory form
39
- # `paths: { pages: <dir> }` IS carried by both. Static hosting: use either.
35
+ # A `pages/<segment>:` mount goes wherever the site goes: builds, `uniweb export`
36
+ # and `uniweb deploy --host <adapter>`, and `uniweb push` / `uniweb publish`,
37
+ # which send the mounted pages as they are on disk — update a mounted repository
38
+ # before you publish. A pull never writes into a mount.
40
39
 
41
40
  # ─── Structured Content ───────────────────────────────────────────────────────
42
41
  # Three things, and they are separate on purpose: