uniweb 0.12.34 → 0.12.35

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/README.md CHANGED
@@ -62,7 +62,7 @@ pnpm build # Build foundation + site for production
62
62
  pnpm preview # Preview the production build
63
63
  ```
64
64
 
65
- The `build` command outputs to `site/dist/`. With pre-rendering enabled (the default for official templates), you get static HTML files ready to deploy anywhere. For the actual deploy step (and the `uniweb publish` / `uniweb deploy` commands), see [Deployment](#deployment) below.
65
+ The `build` command outputs to `site/dist/`. With pre-rendering enabled (the default for official templates), you get static HTML files ready to deploy anywhere. For the actual deploy step (and the `uniweb deploy` / `uniweb register` commands), see [Deployment](#deployment) below.
66
66
 
67
67
  ## What You Get
68
68
 
@@ -258,7 +258,7 @@ The `src/` folder (your project's foundation) ships with your project as a conve
258
258
  | **Local folder** | Foundation lives in your workspace | Developing site and components together |
259
259
  | **Runtime link** | Foundation loads from a URL | Independent release cycles, platform-managed sites |
260
260
 
261
- You can delete the `src/` folder entirely and point your site at a published foundation. Or develop a foundation locally, then publish it for other sites to consume. The site doesn't care where its components come from.
261
+ You can delete the `src/` folder entirely and point your site at a registered foundation. Or develop a foundation locally, then register it for other sites to consume. The site doesn't care where its components come from.
262
262
 
263
263
  **This enables two development patterns:**
264
264
 
@@ -327,20 +327,20 @@ uniweb add ci --host=github-pages
327
327
 
328
328
  ### Path 2 — Someone else manages the content
329
329
 
330
- You're building a foundation for clients, content authors, or any team that won't write markdown. The foundation is your product; the repo's `site/` is a test harness for the code (run `pnpm dev` to preview your components against sample content). You don't deploy a site — you publish a foundation:
330
+ You're building a foundation for clients, content authors, or any team that won't write markdown. The foundation is your product; the repo's `site/` is a test harness for the code (run `pnpm dev` to preview your components against sample content). You don't deploy a site — you register a foundation:
331
331
 
332
332
  ```bash
333
333
  cd src
334
- uniweb publish @your-org/foundation-name
334
+ uniweb register --scope @your-org
335
335
  ```
336
336
 
337
337
  Real sites built on your foundation are managed in the **Uniweb apps** (web + desktop) — visual editors designed for non-technical authors. They never see git, markdown, yaml, or React. They see *your* components, with live previews and visual controls for the params you defined. The foundation becomes the editor's native vocabulary for that site: you keep creative control of the design system, they get an editor that feels custom-built for them.
338
338
 
339
339
  This is the best path when site content has a life independent of the foundation's release cycle — agencies, design studios, multi-client teams, or any project where content authors aren't the same people as the developers.
340
340
 
341
- ### Roadmap — Hybrid
341
+ ### Hybrid
342
342
 
343
- A future version will let markdown in a git repo and content in the Uniweb apps stay in two-way sync. Authors edit visually, devs edit in their IDE, both surfaces work on the same content. On the roadmap; not available today.
343
+ Markdown in a git repo and content in the Uniweb apps can share the same site. Directional sync is available today: `uniweb push` sends local content to the backend, `uniweb pull` brings the backend's content back to local files, and `uniweb clone` bootstraps a local project from a site that already lives in the backend. Authors edit visually, devs edit in their IDE, both surfaces work on the same content. The advanced two-way-merge experience — conflict resolution, branch isolation, and review flows when both surfaces change the same content concurrently — is still evolving.
344
344
 
345
345
  ### Commands at a glance
346
346
 
@@ -349,7 +349,7 @@ A future version will let markdown in a git repo and content in the Uniweb apps
349
349
  | `uniweb add ci --host=<adapter>` | Scaffold a CI workflow in your repo (today: `github-pages`). The host runs `uniweb build` on each push. |
350
350
  | `uniweb deploy` | Deploy to Uniweb hosting (default). With `--host=<adapter>`, push directly to a static host — builds, uploads, invalidates in one step. |
351
351
  | `uniweb export` | Produce a self-contained `dist/` for any static host. You upload it yourself. `--host=<adapter>` adds host-specific helper files. |
352
- | `uniweb publish @org/name` | Publish a foundation to the registry (path 2). |
352
+ | `uniweb register --scope @org` | Register a foundation to the registry (path 2). |
353
353
  | `uniweb build` | Inspect a build locally. For shipping, use `deploy` or `export`. |
354
354
  | `uniweb update` | Align this project with the CLI you're running: bump `@uniweb/*` deps in every `package.json` to the CLI's matrix (then install), and refresh `AGENTS.md`. Pins to *this* CLI's matrix — run `npx uniweb@latest update` to align to the latest release. Updating the CLI itself is your package manager's job (`npm i -g uniweb@latest`). |
355
355
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "uniweb",
3
- "version": "0.12.34",
3
+ "version": "0.12.35",
4
4
  "description": "Create structured Vite + React sites with content/code separation",
5
5
  "type": "module",
6
6
  "bin": {
@@ -46,9 +46,9 @@
46
46
  "@uniweb/runtime": "0.8.20"
47
47
  },
48
48
  "peerDependencies": {
49
- "@uniweb/build": "0.14.17",
50
49
  "@uniweb/content-reader": "1.1.12",
51
- "@uniweb/semantic-parser": "1.1.17"
50
+ "@uniweb/semantic-parser": "1.1.17",
51
+ "@uniweb/build": "0.14.18"
52
52
  },
53
53
  "peerDependenciesMeta": {
54
54
  "@uniweb/build": {
@@ -142,28 +142,39 @@ pnpm install # Install dependencies
142
142
  pnpm build # Build for production
143
143
  pnpm preview # Preview production build (SSG + SPA)
144
144
 
145
- # Ship the site (uniweb verbs)
146
- uniweb deploy # Deploy to Uniweb hosting (default; needs `uniweb login` first)
147
- uniweb deploy --host=<adapter> # Deploy to a static host: cloudflare-pages, netlify,
145
+ # Ship a site to Uniweb hosting (or a static host)
146
+ uniweb deploy # Build + push + publish in one shot (default; needs `uniweb login`)
147
+ uniweb deploy --host=<adapter> # Deploy to a static host instead: cloudflare-pages, netlify,
148
148
  # vercel, github-pages, s3-cloudfront, generic-static
149
149
  uniweb deploy --dry-run # Resolve foundation/runtime + print summary; no writes
150
150
  uniweb export # Build dist/ for any static host (no Uniweb account)
151
- uniweb publish # Publish a foundation to the Uniweb registry
152
- uniweb register # Register a foundation + the data schemas it defines
151
+
152
+ # Sync a site with the Uniweb backend (git-style; needs `uniweb login`)
153
+ uniweb push # Push local content to the backend (creates the site on first push)
154
+ uniweb pull # Pull backend content back to local files
155
+ uniweb clone <site-uuid> # Start a new local project from a site already in the backend
156
+ uniweb status # Show what's unpushed / out of sync (local, offline)
157
+ uniweb publish # Make the synced site's current backend state live —
158
+ # does NOT push; run `uniweb push` first, or just `uniweb deploy`
159
+
160
+ # Register a foundation + its data schemas to the Uniweb registry
161
+ uniweb register # Register this foundation + the data schemas it defines
162
+ uniweb register --scope @org # Register under @org (also works from a schemas-only package)
163
+
164
+ # Project health + maintenance
153
165
  uniweb doctor # Diagnose project configuration issues (--fix to auto-repair)
154
- uniweb validate # Check your file-based data against your declared schemas (--strict for CI)
155
- uniweb update # Align @uniweb/* deps + this AGENTS.md with the CLI's matrix.
156
- # Use --dry-run to preview, --yes for non-interactive.
157
- # `npx uniweb@latest update` pins to the latest release.
166
+ uniweb validate # Check file-based data against your declared schemas (--strict for CI)
167
+ uniweb update # Align @uniweb/* deps + this AGENTS.md with the CLI's matrix
168
+ # (--dry-run to preview, --yes for non-interactive)
158
169
 
159
170
  # Help
160
171
  uniweb --help # Top-level help
161
172
  uniweb <command> --help # Per-command help (no side effects)
162
173
  ```
163
174
 
164
- `uniweb deploy` auto-publishes a workspace-local foundation as part of the deploy under a site-scoped slot — no separate `uniweb publish` step needed for site-bound foundations.
175
+ `uniweb deploy` auto-registers a workspace-local foundation as part of the deploy under a site-scoped slot — no separate `uniweb register` step needed for site-bound foundations.
165
176
 
166
- **Registering data schemas.** A foundation that defines data schemas (`@/article`, …) uses `uniweb register` to register the foundation together with those schemas in the Uniweb registry — so content authors can create and manage entities of those types. It requires authentication: run `uniweb login`, or supply a bearer token directly with `--token <bearer>` (or the `UNIWEB_TOKEN` env var). Point at a specific registry with `--registry <url>` (or `UNIWEB_REGISTER_URL`). Preview without auth using `--dry-run` (or `-o <file>` to write the submission), and set the publish org with `--scope @org` (default: the foundation's `package.json` `uniweb.scope`).
177
+ **Registering data schemas.** A foundation that defines data schemas (`@/article`, …) uses `uniweb register` to register the foundation together with those schemas in the Uniweb registry — so content authors can create and manage entities of those types. It requires authentication: run `uniweb login`, or supply a bearer token directly with `--token <bearer>` (or the `UNIWEB_TOKEN` env var). Point at a specific registry with `--registry <url>` (or `UNIWEB_REGISTER_URL`). Preview without auth using `--dry-run` (or `-o <file>` to write the submission), and set the org scope with `--scope @org` (default: the foundation's `package.json` `uniweb.scope`).
167
178
 
168
179
  **Registering standard or shared schemas (no foundation).** Data schemas can also be registered on their own — without a foundation — straight from a schemas package. Run `uniweb register` from a package that *exports* schemas (`@uniweb/schemas`, or any `@org/schemas` you maintain), or from a bare folder of `schemas/*.{yml,json,js}` files: it detects the schemas-only package automatically and submits just the data schemas (no foundation) under `--scope`. This is how the standard schemas are published under `@std`, and how an org publishes its own shared `@org/schemas` once for many foundations to reference. The same flags apply (`--scope`, `--dry-run`, `-o`, `--token`, `--registry`); `--dry-run` (or `-o <file>`) previews the exact submission without authenticating.
169
180
 
@@ -192,16 +203,16 @@ The `uniweb` block in `package.json` carries platform-specific configuration tha
192
203
 
193
204
  | Field | Where used | Default | Purpose |
194
205
  |---|---|---|---|
195
- | `id` | `uniweb publish` | (set via `--name` or scoped `package.json::name`) | The foundation's published id — the bare-name segment in `@org/<id>`. Decoupled from `package.json::name` (a workspace concern), so renaming the foundation on the registry doesn't ripple through site dependencies. Only relevant for catalog-published foundations; site-bound foundations skip this. |
196
- | `namespace` | `uniweb publish` | (none — see scope resolution) | Legacy explicit org-namespace override. Equivalent to using a scoped `package.json::name` (`"@myorg/foundation"`). Rarely needed in modern foundations. |
206
+ | `id` | `uniweb register` | (the bare segment of a scoped `package.json::name`, or `uniweb.id`) | The foundation's registered id — the bare-name segment in `@org/<id>`. Decoupled from `package.json::name` (a workspace concern), so renaming the foundation on the registry doesn't ripple through site dependencies. Only relevant for catalog-registered foundations; site-bound foundations skip this. |
207
+ | `namespace` | `uniweb register` | (none — see scope resolution) | Legacy explicit org-namespace override. Equivalent to using a scoped `package.json::name` (`"@myorg/foundation"`). Rarely needed in modern foundations. |
197
208
  | `runtimePolicy` | `dist/runtime-pin.json` (foundation build) | `"auto-minor"` | Controls how sites using this foundation receive runtime updates. Three values: `"exact"`, `"auto-patch"`, `"auto-minor"`. See "Foundation runtime policy" below. |
198
209
 
199
210
  **Catalog vs site-bound foundations.** Two distribution intents share the same `dist/foundation.js` artifact:
200
211
 
201
- - A **catalog foundation** is a deliberate product — named, versioned, listed in the catalog, consumable by other developers' sites. Use `uniweb publish @org/name` for these. The CLI requires an explicit name argument so you don't accidentally catalog a foundation that was meant to be site-bound.
202
- - A **site-bound foundation** powers exactly one site. Don't run `uniweb publish` for it. Just run `uniweb deploy` from the site directory — the CLI auto-publishes your local foundation as part of the deploy, **uploaded with the site's other published assets** (per-site storage, never to the catalog). With no naming ceremony, no catalog visibility, and no developer-vs-site ownership confusion. To later promote the foundation to a catalog product, run `uniweb publish @org/name` from the foundation directory and update the site's `site.yml` to a versioned ref (`foundation: '@org/name@1.2.3'`).
212
+ - A **catalog foundation** is a deliberate product — named, versioned, listed in the catalog, consumable by other developers' sites. Use `uniweb register --scope @org` for these cataloging is a deliberate, explicitly-scoped step, so you don't accidentally catalog a foundation that was meant to be site-bound.
213
+ - A **site-bound foundation** powers exactly one site. Don't run `uniweb register` for it. Just run `uniweb deploy` from the site directory — the CLI auto-registers your local foundation as part of the deploy, **uploaded with the site's other assets** (per-site storage, never to the catalog). With no naming ceremony, no catalog visibility, and no developer-vs-site ownership confusion. To later promote the foundation to a catalog product, run `uniweb register --scope @org` from the foundation directory and update the site's `site.yml` to a versioned ref (`foundation: '@org/name@1.2.3'`).
203
214
 
204
- **On the split between `package.json::name` and `uniweb.id`:** the workspace name is what pnpm uses for `file:` linking and what `site.yml::foundation` references. The published id is what the registry stores. Keeping them separate means renaming on the registry (e.g. `marketing` → `marketing-pro`) is a one-shot `uniweb publish --name marketing-pro` — it persists to `uniweb.id` without touching the workspace.
215
+ **On the split between `package.json::name` and `uniweb.id`:** the workspace name is what pnpm uses for `file:` linking and what `site.yml::foundation` references. The published id is what the registry stores. Keeping them separate means a workspace rename (e.g. `marketing` → `marketing-pro`) is a one-shot `uniweb rename foundation marketing marketing-pro` — it updates the package, folder, and every dependent site, while the foundation's registered id (`uniweb.id`) stays independent.
205
216
 
206
217
  These are the only fields the platform consumes today. Future platform features that need static configuration will land here too.
207
218
 
@@ -207,6 +207,25 @@ export class BackendClient {
207
207
  return res.json()
208
208
  }
209
209
 
210
+ /**
211
+ * ASSUMED endpoint (not built yet — kb/framework/plans/shipping-verbs-and-freshness.md §6.5).
212
+ * GET /dev/registry/foundation/{scope}/{name} → { latest_version } for a foundation's
213
+ * latest registered version, or null on 404 / any failure (callers degrade gracefully).
214
+ * A bare/unscoped name → null (only `@org/name` can be looked up).
215
+ * @param {string} scopedName
216
+ * @returns {Promise<{ latest_version: string }|null>}
217
+ */
218
+ async readFoundationLatest(scopedName) {
219
+ const m = /^@([^/]+)\/([^@/]+)/.exec(String(scopedName || ''))
220
+ if (!m) return null
221
+ try {
222
+ const res = await this.request(`/dev/registry/foundation/${encodeURIComponent(m[1])}/${encodeURIComponent(m[2])}`)
223
+ return res.ok ? await res.json().catch(() => null) : null
224
+ } catch {
225
+ return null
226
+ }
227
+ }
228
+
210
229
  // ── Orgs ──────────────────────────────────────────────────────────────────────
211
230
 
212
231
  /** GET /dev/orgs → { account_handle, personal_org_exists, orgs[] }. */
@@ -311,6 +330,23 @@ export class BackendClient {
311
330
  return this.request(`/dev/site/unpublish/${encodeURIComponent(uuid)}`, { method: 'POST' })
312
331
  }
313
332
 
333
+ /**
334
+ * ASSUMED endpoint (not built yet — kb/framework/plans/shipping-verbs-and-freshness.md §6.5).
335
+ * GET /dev/site/{uuid}/status → the site's publish lifecycle:
336
+ * { published: boolean, last_pushed_at?: string, last_published_at?: string, draft_dirty?: boolean }
337
+ * `draft_dirty` = the synced draft differs from what's currently live. null on 404 / any failure.
338
+ * @param {string} uuid - the site-content uuid
339
+ * @returns {Promise<object|null>}
340
+ */
341
+ async siteStatus(uuid) {
342
+ try {
343
+ const res = await this.request(`/dev/site/${encodeURIComponent(uuid)}/status`)
344
+ return res.ok ? await res.json().catch(() => null) : null
345
+ } catch {
346
+ return null
347
+ }
348
+ }
349
+
314
350
  /**
315
351
  * Deliver a site's processed assets (plan → PUT-per-file) to the backend's
316
352
  * content-addressed store. Thin pass-through to utils/asset-upload.js with this
@@ -13,7 +13,7 @@
13
13
 
14
14
  import { writeFileSync, readFileSync, mkdirSync } from 'node:fs'
15
15
  import { join, dirname } from 'node:path'
16
- import { backfillEntityUuids, writeSiteEntityUuid } from '@uniweb/build/uwx'
16
+ import { backfillEntityUuids, writeSiteEntityUuid, emitSyncPackages } from '@uniweb/build/uwx'
17
17
 
18
18
  // Pull the finalized entities out of the restore response. The backend returns
19
19
  // `{ report: { finalized: [ { index, uuid, changed, document }, … ] } }` — each entry
@@ -106,6 +106,27 @@ export function writeSyncCache(siteDir, hashes) {
106
106
  writeFileSync(p, JSON.stringify({ version: 1, hashes }, null, 2) + '\n')
107
107
  }
108
108
 
109
+ /**
110
+ * Offline-probe how many of a site's entities differ from the last successful push.
111
+ * Runs the SAME emit + send-only-changed diff `uniweb push` runs, but with an
112
+ * OFFLINE Model resolver — no auth, no submit, no backend round-trip. Used by
113
+ * `uniweb status` and the `uniweb publish` pre-flight. Throws if the producer
114
+ * can't build the sync packages (e.g. an unresolved data Model); callers report it.
115
+ *
116
+ * @param {string} siteDir
117
+ * @returns {Promise<{ changed: number, unchanged: number, warnings: string[] }>}
118
+ */
119
+ export async function probeUnpushed(siteDir, { sendAll = false } = {}) {
120
+ const priorHashes = readSyncCache(siteDir)
121
+ const pkg = await emitSyncPackages(siteDir, {
122
+ resolveModel: makeModelResolver({ client: null, offline: true }),
123
+ priorHashes,
124
+ sendAll,
125
+ })
126
+ const changed = (pkg.siteContent?.entityCount || 0) + (pkg.collections?.entityCount || 0)
127
+ return { changed, unchanged: pkg.skipped || 0, warnings: pkg.warnings || [] }
128
+ }
129
+
109
130
  /**
110
131
  * Submit a site's emitted sync packages over both directional lanes, back-fill the
111
132
  * minted uuids, and persist the send-only-changed cache. The HTTP + file-write-back
@@ -33,9 +33,13 @@ import { readFile } from 'node:fs/promises'
33
33
  import { join } from 'node:path'
34
34
  import yaml from 'js-yaml'
35
35
 
36
+ import { createInterface } from 'node:readline/promises'
37
+
36
38
  import { BackendClient } from '../backend/client.js'
37
39
  import { resolveSiteDir } from './deploy.js'
38
40
  import { readFlagValue } from '../utils/args.js'
41
+ import { isNonInteractive } from '../utils/interactive.js'
42
+ import { probeUnpushed } from '../backend/site-sync.js'
39
43
 
40
44
  const c = {
41
45
  reset: '\x1b[0m', bold: '\x1b[1m', dim: '\x1b[2m',
@@ -49,6 +53,18 @@ const say = {
49
53
  dim: (m) => console.log(` ${c.dim}${m}${c.reset}`),
50
54
  }
51
55
 
56
+ // Minimal yes/no prompt. Returns `defaultYes` on an empty answer.
57
+ async function confirm(question, defaultYes = false) {
58
+ const rl = createInterface({ input: process.stdin, output: process.stdout })
59
+ try {
60
+ const a = (await rl.question(`${question} ${defaultYes ? '[Y/n]' : '[y/N]'} `)).trim().toLowerCase()
61
+ if (!a) return defaultYes
62
+ return a === 'y' || a === 'yes'
63
+ } finally {
64
+ rl.close()
65
+ }
66
+ }
67
+
52
68
  // Highest installed runtime from the backend's /dev/config list (numeric-aware
53
69
  // sort). Mirrors deploy.js's resolver. Null when the list is empty.
54
70
  function pickHighestRuntime(installed) {
@@ -76,6 +92,7 @@ function extractLanguages(siteYml) {
76
92
 
77
93
  export async function publish(args = []) {
78
94
  const dryRun = args.includes('--dry-run')
95
+ const skipVerify = args.includes('--yes') || args.includes('--force') || args.includes('--no-verify')
79
96
  const siteDir = await resolveSiteDir(args, 'publish')
80
97
 
81
98
  // The site-content uuid lives in site.yml::$uuid (written by `uniweb push`).
@@ -134,6 +151,27 @@ export async function publish(args = []) {
134
151
  return { exitCode: 0 }
135
152
  }
136
153
 
154
+ // Pre-flight: `publish` makes the BACKEND's current state live — NOT your local
155
+ // files. If local content differs from the last push, surface it. Interactive
156
+ // only; --yes / --force / --no-verify skip it, and a build error never blocks.
157
+ if (!skipVerify && !isNonInteractive(args)) {
158
+ let probe = null
159
+ try {
160
+ probe = await probeUnpushed(siteDir)
161
+ } catch {
162
+ probe = null
163
+ }
164
+ if (probe && probe.changed > 0) {
165
+ say.warn(`You have ${probe.changed} unpushed local content change${probe.changed === 1 ? '' : 's'}.`)
166
+ say.dim('`publish` makes the backend state live as-is; local edits are not included. Push first, or `uniweb deploy` to do both.')
167
+ const proceed = await confirm('Publish the current backend state anyway?', true)
168
+ if (!proceed) {
169
+ say.info('Aborted — run `uniweb push`, then `uniweb publish`.')
170
+ return { exitCode: 0 }
171
+ }
172
+ }
173
+ }
174
+
137
175
  say.info(`Publishing the synced site to ${c.dim}${client.origin}${c.reset} …`)
138
176
  say.dim('Publishes the CURRENT backend state (incl. app-side edits) — run `uniweb push` first to include local edits.')
139
177
  let res
@@ -30,9 +30,11 @@
30
30
  * (target name already taken, target not found, folder collision,
31
31
  * type mismatch) we bail with a clear message and no partial state.
32
32
  *
33
- * Out of scope: registry side. The publish id (package.json::uniweb.id)
34
- * is independent of the workspace name and stays untouched. Users who
35
- * want to also rename on the registry run `uniweb publish --name <new>`.
33
+ * Out of scope: registry side. The registered id (the scoped
34
+ * `package.json::name` / `uniweb.id`) is independent of the workspace name and
35
+ * stays untouched. There is no registry-rename flag a registered version is
36
+ * immutable; to change a foundation's registered identity, register it under
37
+ * the new name (consuming sites repoint their `foundation:` ref).
36
38
  *
37
39
  * Usage:
38
40
  * uniweb rename foundation <old> <new>
@@ -0,0 +1,174 @@
1
+ /**
2
+ * uniweb status — show how a site's local files compare to the Uniweb backend:
3
+ * its sync identity, unpushed content changes, and the foundation it references.
4
+ *
5
+ * LOCAL + OFFLINE by default: it builds the sync packages with an OFFLINE Model
6
+ * resolver and diffs them against the send-only-changed cache (the same diff
7
+ * `uniweb push` runs) — no auth, no backend round-trip.
8
+ *
9
+ * `--remote` adds the backend signals (may prompt for login, like `git fetch`):
10
+ * - whether the synced draft differs from what's live (publish needed), and
11
+ * - whether a newer foundation version is registered than the site pins.
12
+ * Those use ASSUMED endpoints (see kb shipping-verbs-and-freshness.md §6.5); until
13
+ * the backend exposes them, `--remote` degrades silently to the local view.
14
+ *
15
+ * Usage:
16
+ * uniweb status Sync identity + unpushed content + foundation ref (local)
17
+ * uniweb status --remote Also: draft-vs-live + a newer-registered-foundation check
18
+ * uniweb status --json One JSON line (adds a `remote` object under --remote)
19
+ *
20
+ * Run from a site, or a workspace with one site.
21
+ */
22
+
23
+ import { existsSync, readFileSync } from 'node:fs'
24
+ import { join } from 'node:path'
25
+ import yaml from 'js-yaml'
26
+
27
+ import { resolveSiteDir } from './deploy.js'
28
+ import { probeUnpushed } from '../backend/site-sync.js'
29
+ import { BackendClient } from '../backend/client.js'
30
+ import { readFlagValue } from '../utils/args.js'
31
+
32
+ const c = {
33
+ reset: '\x1b[0m', bold: '\x1b[1m', dim: '\x1b[2m',
34
+ cyan: '\x1b[36m', green: '\x1b[32m', yellow: '\x1b[33m',
35
+ }
36
+ const say = {
37
+ ok: (m) => console.log(`${c.green}✓${c.reset} ${m}`),
38
+ info: (m) => console.log(`${c.cyan}→${c.reset} ${m}`),
39
+ warn: (m) => console.log(`${c.yellow}⚠${c.reset} ${m}`),
40
+ dim: (m) => console.log(` ${c.dim}${m}${c.reset}`),
41
+ }
42
+
43
+ function readSiteYml(siteDir) {
44
+ const p = join(siteDir, 'site.yml')
45
+ if (!existsSync(p)) return {}
46
+ try {
47
+ return yaml.load(readFileSync(p, 'utf8')) || {}
48
+ } catch {
49
+ return {}
50
+ }
51
+ }
52
+
53
+ function foundationRef(siteYml) {
54
+ const f = siteYml.foundation
55
+ if (!f) return null
56
+ return typeof f === 'string' ? f : f.ref || null
57
+ }
58
+
59
+ // A versioned registry ref `@org/name@1.2.3` → its scoped name `@org/name` and
60
+ // pinned version `1.2.3`. A bare/local/unversioned ref → nulls.
61
+ function splitFoundationRef(fnd) {
62
+ if (!fnd || fnd[0] !== '@') return { scope: null, version: null }
63
+ const at = fnd.lastIndexOf('@')
64
+ return at > 0 ? { scope: fnd.slice(0, at), version: fnd.slice(at + 1) } : { scope: null, version: null }
65
+ }
66
+
67
+ export async function status(args = []) {
68
+ const jsonMode = args.includes('--json')
69
+ const remote = args.includes('--remote')
70
+ const siteDir = await resolveSiteDir(args, 'status')
71
+ const siteYml = readSiteYml(siteDir)
72
+ const uuid = siteYml.$uuid || null
73
+ const fnd = foundationRef(siteYml)
74
+ const { scope: fndScope, version: fndVersion } = splitFoundationRef(fnd)
75
+
76
+ // Local content diff — builds the sync packages, never authenticates.
77
+ let probe = null
78
+ let probeErr = null
79
+ try {
80
+ probe = await probeUnpushed(siteDir)
81
+ } catch (err) {
82
+ probeErr = err.message
83
+ }
84
+
85
+ // Remote signals — opt-in (`--remote`). May prompt for login. Degrades to null
86
+ // on 404 / any failure, so a backend without the endpoints just shows local.
87
+ let site = null
88
+ let fdnLatest = null
89
+ if (remote) {
90
+ try {
91
+ const client = new BackendClient({
92
+ originFlag: readFlagValue(args, '--backend') || readFlagValue(args, '--registry'),
93
+ token: readFlagValue(args, '--token') || undefined,
94
+ args,
95
+ command: 'Status',
96
+ })
97
+ if (uuid) site = await client.siteStatus(uuid)
98
+ if (fndScope) fdnLatest = await client.readFoundationLatest(fndScope)
99
+ } catch {
100
+ // degrade silently
101
+ }
102
+ }
103
+
104
+ if (jsonMode) {
105
+ console.log(
106
+ JSON.stringify({
107
+ synced: Boolean(uuid),
108
+ uuid,
109
+ foundation: fnd,
110
+ changed: probe ? probe.changed : null,
111
+ unchanged: probe ? probe.unchanged : null,
112
+ ...(probeErr ? { error: probeErr } : {}),
113
+ ...(remote ? { remote: { site, foundation_latest: fdnLatest?.latest_version ?? null } } : {}),
114
+ })
115
+ )
116
+ return { exitCode: 0 }
117
+ }
118
+
119
+ console.log('')
120
+
121
+ // Sync identity
122
+ if (uuid) {
123
+ say.ok(`Synced — site-content ${c.bold}${uuid}${c.reset}`)
124
+ } else {
125
+ say.warn('Not synced — this site has never been pushed to a backend.')
126
+ say.dim('Run `uniweb push` to create it, or `uniweb deploy` to ship it in one step.')
127
+ }
128
+
129
+ // Content
130
+ if (probeErr) {
131
+ say.warn(`Couldn't compute content changes: ${probeErr}`)
132
+ say.dim('A build error or an unresolved data Model can block the offline diff.')
133
+ } else if (!uuid) {
134
+ const n = probe.changed
135
+ say.info(`${n} content ${n === 1 ? 'entity' : 'entities'} ready to push.`)
136
+ } else if (probe.changed === 0) {
137
+ say.ok('Content is in sync with the last push.')
138
+ } else {
139
+ const n = probe.changed
140
+ say.info(
141
+ `${c.bold}${n}${c.reset} content ${n === 1 ? 'entity' : 'entities'} not pushed` +
142
+ (probe.unchanged ? ` (${probe.unchanged} unchanged)` : '') +
143
+ '.'
144
+ )
145
+ say.dim('Run `uniweb push` to sync, then `uniweb publish` to go live (or `uniweb deploy` for both).')
146
+ }
147
+
148
+ // Foundation
149
+ if (fnd) say.dim(`Foundation: ${fnd}`)
150
+
151
+ // Remote signals
152
+ if (remote) {
153
+ if (site) {
154
+ if (site.draft_dirty) {
155
+ say.info('Synced draft has changes not yet live — run `uniweb publish` to go live.')
156
+ } else if (site.published) {
157
+ say.ok('Live with the latest synced content.')
158
+ } else {
159
+ say.info('Synced but not published yet — run `uniweb publish` to go live.')
160
+ }
161
+ }
162
+ if (fdnLatest?.latest_version && fndVersion && fdnLatest.latest_version !== fndVersion) {
163
+ say.info(`A newer foundation version (${fdnLatest.latest_version}) is registered than the site pins (${fndVersion}).`)
164
+ }
165
+ if (!site && !fdnLatest) {
166
+ say.dim('(No remote signals — the backend may not expose them yet.)')
167
+ }
168
+ }
169
+
170
+ console.log('')
171
+ return { exitCode: 0 }
172
+ }
173
+
174
+ export default status
@@ -1,9 +1,9 @@
1
1
  {
2
2
  "schemaVersion": 1,
3
- "generatedAt": "2026-06-23T17:13:47.118Z",
3
+ "generatedAt": "2026-06-24T03:15:27.555Z",
4
4
  "packages": {
5
5
  "@uniweb/build": {
6
- "version": "0.14.17",
6
+ "version": "0.14.18",
7
7
  "path": "framework/build",
8
8
  "deps": [
9
9
  "@uniweb/content-reader",
@@ -99,7 +99,7 @@
99
99
  "deps": []
100
100
  },
101
101
  "@uniweb/unipress": {
102
- "version": "0.4.23",
102
+ "version": "0.4.24",
103
103
  "path": "framework/unipress",
104
104
  "deps": [
105
105
  "@uniweb/build",
package/src/index.js CHANGED
@@ -627,6 +627,13 @@ async function main() {
627
627
  process.exit(result?.exitCode ?? 0)
628
628
  }
629
629
 
630
+ // Handle status command (dynamic import — offline emit via @uniweb/build)
631
+ if (command === 'status') {
632
+ const { status } = await importProjectCommand('./commands/status.js')
633
+ const result = await status(args.slice(1))
634
+ process.exit(result?.exitCode ?? 0)
635
+ }
636
+
630
637
  // Handle clone command (global — bootstraps a new project from a backend site;
631
638
  // STANDALONE, so a global `uniweb clone` runs here instead of delegating to a
632
639
  // project-local CLI that doesn't exist yet. clone.js avoids any static
@@ -1470,6 +1477,7 @@ ${colors.bright}Commands:${colors.reset}
1470
1477
  runtime register Register an @uniweb/runtime version to the backend (@std only)
1471
1478
  push Push a site's content to the backend
1472
1479
  pull Pull a site's content from the backend
1480
+ status Show a site's sync state (unpushed content, foundation)
1473
1481
  inspect <path> Inspect parsed content shape of a markdown file or folder
1474
1482
  docs Generate component documentation
1475
1483
  doctor Diagnose project configuration issues