uniweb 0.12.21 → 0.12.22

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
@@ -361,6 +361,35 @@ Both paths use the same framework. The difference is who edits content, where it
361
361
 
362
362
  ---
363
363
 
364
+ ## Keeping a Project in Sync
365
+
366
+ Uniweb projects pin to specific `@uniweb/*` versions in their `package.json` files and ship an `AGENTS.md` stamped with the CLI version that wrote it. As the framework releases, both can drift from what the CLI you're running expects.
367
+
368
+ Two paired verbs cover the drift:
369
+
370
+ - **`uniweb doctor`** — diagnoses only. Flags `@uniweb/*` / `uniweb` declarations that lag the running CLI, plus `AGENTS.md` staleness, plus a small set of safely auto-fixable structural issues.
371
+ - **`uniweb update`** — the mutator. Rewrites `@uniweb/*` + `uniweb` version keys in every workspace `package.json` to the CLI's bundled matrix (deps that are *ahead* are left alone — never downgraded), runs your workspace's package manager, and refreshes `AGENTS.md`. Each file's existing indentation is preserved.
372
+
373
+ ```bash
374
+ npx uniweb@latest update # canonical: pin to the latest release (no install)
375
+ npx uniweb@latest update --yes # same, non-interactive (CI, agents, automation)
376
+ uniweb update --dry-run # preview the plan without touching files
377
+ ```
378
+
379
+ `update` aligns your project to *the CLI that's running it*. `npx uniweb@latest update` is the "pin to the newest release" path. A project-local copy (`node_modules/.bin/uniweb`) deliberately pins to your committed `uniweb` version — bump it in `package.json` and re-install, or use the npx form, to go further.
380
+
381
+ **Updating the CLI itself is separate**, and it's your package manager's job:
382
+
383
+ ```bash
384
+ npm i -g uniweb@latest # npm
385
+ pnpm add -g uniweb@latest # pnpm
386
+ yarn global add uniweb@latest # yarn
387
+ ```
388
+
389
+ `uniweb update` prints the right command for your install when a newer release exists. It will *not* update the CLI itself — running it twice was never the intent.
390
+
391
+ ---
392
+
364
393
  ## Documentation
365
394
 
366
395
  Full documentation is available at **[github.com/uniweb/docs](https://github.com/uniweb/docs)**:
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "uniweb",
3
- "version": "0.12.21",
3
+ "version": "0.12.22",
4
4
  "description": "Create structured Vite + React sites with content/code separation",
5
5
  "type": "module",
6
6
  "bin": {
@@ -41,14 +41,14 @@
41
41
  "js-yaml": "^4.1.0",
42
42
  "prompts": "^2.4.2",
43
43
  "tar": "^7.0.0",
44
- "@uniweb/runtime": "0.8.14",
45
- "@uniweb/core": "0.7.11",
46
- "@uniweb/kit": "0.9.13"
44
+ "@uniweb/core": "0.7.12",
45
+ "@uniweb/kit": "0.9.14",
46
+ "@uniweb/runtime": "0.8.15"
47
47
  },
48
48
  "peerDependencies": {
49
- "@uniweb/build": "0.14.5",
50
- "@uniweb/content-reader": "1.1.11",
51
- "@uniweb/semantic-parser": "1.1.17"
49
+ "@uniweb/semantic-parser": "1.1.17",
50
+ "@uniweb/content-reader": "1.1.12",
51
+ "@uniweb/build": "0.14.6"
52
52
  },
53
53
  "peerDependenciesMeta": {
54
54
  "@uniweb/build": {
@@ -149,10 +149,12 @@ uniweb deploy --host=<adapter> # Deploy to a static host: cloudflare-pages, n
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
151
  uniweb publish # Publish a foundation to the Uniweb registry
152
+ uniweb register # Register a foundation + the data schemas it defines
152
153
  uniweb doctor # Diagnose project configuration issues (--fix to auto-repair)
153
- uniweb update # Reconcile workspace state with the CLI: align @uniweb/*
154
- # deps in every package.json + refresh this AGENTS.md.
155
- # Use --dry-run to preview, --deps-only to skip the doc.
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.
156
158
 
157
159
  # Help
158
160
  uniweb --help # Top-level help
@@ -161,7 +163,11 @@ uniweb <command> --help # Per-command help (no side effects)
161
163
 
162
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.
163
165
 
164
- If this AGENTS.md was stamped against an older CLI than the workspace's installed `@uniweb/*` packages, run `uniweb update --dry-run` to see the gap. The verb refuses to refresh the doc while declared deps lag the CLI a stale doc that documents features the installed code doesn't have is worse than no refresh.
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`).
167
+
168
+ **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
+
170
+ **Staying current.** `uniweb update` aligns this project's `@uniweb/*` deps and `AGENTS.md` to the CLI that runs it; `uniweb doctor` reports drift without mutating. To pin to the newest published release, run `npx uniweb@latest update --yes` — no global install needed. The verb won't refresh AGENTS.md while declared deps still lag the CLI, or while edited deps haven't been installed: both would put the doc ahead of the code. Updating the CLI itself is your package manager's job (`npm i -g uniweb@latest`, `pnpm add -g uniweb@latest`, …); `uniweb update` does not do that.
165
171
 
166
172
  ---
167
173
 
@@ -276,8 +282,6 @@ The system has multi-layer fallbacks so missing or partial information is always
276
282
 
277
283
  Bottom line: a foundation that doesn't set `runtimePolicy` gets `auto-minor` behavior automatically. A foundation that doesn't ship `runtime-pin.json` at all (e.g. a legacy build) still serves correctly through the platform's compatibility path — you just don't get the propagation benefits. Set `runtimePolicy` explicitly only when you want to override the default (typically to `exact` for stability-critical builds).
278
284
 
279
- (For platform operators interested in how propagation walks consume this field, see `kb/platform/operations/version-propagation.md` in the private docs.)
280
-
281
285
  ---
282
286
 
283
287
  ## Content Authoring
@@ -1038,18 +1042,44 @@ All non-reserved frontmatter fields become `params`. Reserved: `type`, `preset`,
1038
1042
 
1039
1043
  ### Data
1040
1044
 
1041
- A component on a page with a `data:` or `fetch:` declaration automatically receives that data in `content.data.{schema}`. No opt-in required in `meta.js`. On a template page (`[slug]/`), the matched item is also delivered as `content.data.{singular}` (e.g. `content.data.article` when the parent declares `data: articles`).
1045
+ A component on a page with a `data:` or `fetch:` declaration automatically receives that data in `content.data.{key}`. No opt-in required in `meta.js`. **Bound collections always arrive as arrays.** On a list page, `content.data.articles` is the full collection. On a template page (`[slug]/`), the matched record is delivered under the *same* collection key as a single-element array — the detail section reads `content.data.articles[0]`. When nothing matches, the key is `[]`. The runtime never coerces the array to a single object and never synthesizes a separate singular key.
1042
1046
 
1043
1047
  ```jsx
1044
1048
  function Article({ content, block }) {
1045
1049
  if (block.dataLoading) return <DataPlaceholder />
1046
- const article = content.data.article
1050
+ const article = content.data.articles?.[0] // focused record on a [slug] page
1047
1051
  if (!article) return <NotFound />
1048
1052
  return <ArticleView article={article} />
1049
1053
  }
1050
1054
  ```
1051
1055
 
1052
- Components can ignore keys in `content.data` they don't need — the same way unused `params` are ignored. For declarative shape hints consumed by the editor and `prepare-props`, add `data: { entity: 'articles' }` to `meta.js`. For an explicit opt-out (rare), set `data: false`.
1056
+ Components can ignore keys in `content.data` they don't need — the same way unused `params` are ignored.
1057
+
1058
+ **Declaring data schemas.** `meta.js` declares the schema for each `content.data` key with a single `data:` field — there is no separate `schemas:` key. Each entry's value is one of: a **named ref** (`'@/article'` resolves to this foundation's `foundation/schemas/article.{js,json,yml}`; `'@std/person'` is a shared standard), an **inline field map** (`{ field: { type, default } }`), or an **inline rich-form** (`{ fields: [...] }`, an editor form). Refs use Uniweb namespacing, resolved on disk at build time, never fetched: `@/name` (this foundation's own `foundation/schemas/`), `@std/name` (the shared standard schemas, shipped in the `@uniweb/schemas` package), and `@org/name` (an org's own schemas, from its `@org/schemas` package — define a schema once and share it across foundations). The schema is a hint: it supplies field defaults and drives the editor, not delivery (which is default-on). For an explicit opt-out (rare), set `data: false`.
1059
+
1060
+ ```js
1061
+ // meta.js
1062
+ export default {
1063
+ data: {
1064
+ articles: '@/article', // named ref (this foundation)
1065
+ authors: '@std/person', // named ref (shared standard)
1066
+ pricing: { tier: { type: 'string', default: '' } }, // inline field map
1067
+ },
1068
+ }
1069
+ ```
1070
+
1071
+ **Routing a scope to a folder.** By default `@org/name` resolves from that org's `@org/schemas` package. A foundation can instead route a scope to a plain folder of schema files — anywhere on disk, no package, no install — via an optional `schemas.config.js` at its root. This is how a team shares one set of schemas across many foundations:
1072
+
1073
+ ```js
1074
+ // schemas.config.js — @acme/person → ../shared/acme-schemas/person.{js,yml}
1075
+ export default { '@acme': '../shared/acme-schemas', '@brand': process.env.BRAND_SCHEMAS }
1076
+ ```
1077
+
1078
+ Plain JS, so paths can be relative, absolute, or read from an env var. A routed scope wins over the package convention; `@/` and `@uniweb` are never routable; an empty value (e.g. an unset env var) falls back to the package convention.
1079
+
1080
+ **Validate your data.** `uniweb validate` checks your file-based data against these declared schemas — missing required fields, type/enum/format mismatches, nested fields — before you ship. Warns by default, `--strict` for a non-zero CI exit. It's distinct from `uniweb doctor` (which checks project structure): `validate` checks your *data* against the schemas you *declared*. Remote (`url:`), `ref`/`options`, and rich `sections`-form inputs are reported deferred — validate those against live data.
1081
+
1082
+ When the same record needs to be a single object rather than a one-element array, that's the foundation's job: read `content.data.articles[0]`, or reshape `content.data` once with a `handlers.data` hook.
1053
1083
 
1054
1084
  **Authoring queries.** Fetch declarations (`fetch:` or the `data:` shorthand) accept query operators that describe which records you want, in what order, how many: `where:` (a where-object predicate), `sort:` (e.g. `date desc`), `limit:` (first N records). Whether the source evaluates them or the framework applies them as a runtime fallback is a transport detail controlled by the site's `fetcher.supports:` declaration.
1055
1085
 
@@ -1478,7 +1508,7 @@ const page = website.activePage
1478
1508
  // Returns [{ id, route, navigableRoute, translatedRoute, title, label, description, hasContent, version, children }]
1479
1509
  //
1480
1510
  // Options:
1481
- // for: 'header' | 'footer' — filter by nav type (respects hideInHeader/hideInFooter)
1511
+ // for: 'header' | 'footer' | <area> — filter by nav area (respects the page's hideIn)
1482
1512
  // nested: true (default) — nested hierarchy with children; false = flat list
1483
1513
  // includeHidden: false — include hidden pages
1484
1514
  // filter: (page) => bool — custom filter function
@@ -1490,7 +1520,7 @@ const page = website.activePage
1490
1520
  // website.getAllPages() — flat list: getPageHierarchy({ nested: false })
1491
1521
  //
1492
1522
  // Common patterns:
1493
- website.getPageHierarchy({ for: 'header' }) // Header nav (excludes hideInHeader pages)
1523
+ website.getPageHierarchy({ for: 'header' }) // Header nav (excludes pages with 'header' in hideIn)
1494
1524
  website.getPageHierarchy() // Full nested hierarchy (no nav filtering)
1495
1525
  website.getPageHierarchy({ nested: false }) // Flat list of all visible pages
1496
1526
  website.getPageHierarchy({ nested: false, includeHidden: true }) // Everything including hidden
@@ -40,8 +40,7 @@ index: intro # Or just name the index
40
40
 
41
41
  # Navigation visibility
42
42
  hidden: true # Hide from all navigation
43
- hideInHeader: true # Hide from header only
44
- hideInFooter: true # Hide from footer only
43
+ hideIn: [header] # Hide from named nav areas only (header, footer, sidebar, …)
45
44
 
46
45
  # Layout overrides
47
46
  layout:
@@ -18,6 +18,7 @@ import prompts from 'prompts'
18
18
  import yaml from 'js-yaml'
19
19
  import { resolveFoundationSrcPath } from '@uniweb/build'
20
20
  import { scaffoldFoundation, scaffoldSite, applyContent, applyStarter, mergeTemplateDependencies } from '../utils/scaffold.js'
21
+ import { resolvePlacement, SITE_KIND, FOUNDATION_KIND } from '../utils/placement.js'
21
22
  import {
22
23
  readWorkspaceConfig,
23
24
  addWorkspaceGlob,
@@ -212,7 +213,6 @@ async function addFoundation(rootDir, projectName, opts, pm = 'pnpm') {
212
213
  // name (`ui`) or a path (`foundations/ui`); resolvePlacement handles
213
214
  // both. Format validation runs on the derived package name below, not
214
215
  // on the raw input — slashes in the input are intentional path syntax.
215
- const FOUNDATION_KIND = { defaultDir: 'src', defaultPkg: 'src', projectSub: 'src' }
216
216
  const placement = resolvePlacement(rootDir, name, opts, FOUNDATION_KIND)
217
217
  const { relativePath } = placement
218
218
  let { packageName } = placement
@@ -306,7 +306,6 @@ async function addSite(rootDir, projectName, opts, pm = 'pnpm') {
306
306
  const existingNames = await getExistingPackageNames(rootDir)
307
307
 
308
308
  // Resolve placement first (path + package name); see notes in addFoundation.
309
- const SITE_KIND = { defaultDir: 'site', defaultPkg: 'site', projectSub: 'site' }
310
309
  const placement = resolvePlacement(rootDir, name, opts, SITE_KIND)
311
310
  const { relativePath } = placement
312
311
  let siteName = placement.packageName
@@ -651,91 +650,6 @@ async function addProject(rootDir, projectName, opts, pm = 'pnpm') {
651
650
  log(`Next: ${colors.cyan}${installCmd(pm)} && uniweb dev ${sitePkgName}${colors.reset}`)
652
651
  }
653
652
 
654
- /**
655
- * Resolve where a foundation or site should be placed, given the user's input.
656
- *
657
- * The rule: **the user names a folder, and we create exactly that folder.**
658
- * No silent nesting under `foundations/` / `sites/`, no inferring layout from
659
- * pre-existing globs. The framework doesn't require any particular folder
660
- * structure (the build classifies packages by their contents, not their
661
- * location), so the CLI shouldn't impose one.
662
- *
663
- * Resolution priority (foundation example, same shape for site):
664
- *
665
- * 1. `--path <dir>` → explicit folder. Name is the path's
666
- * last segment (used as the package
667
- * name unless `name` was also given).
668
- * 2. `name` contains `/` → treat as a path (e.g., `foundations/ui`).
669
- * Folder = the path, package name =
670
- * the last segment.
671
- * 3. `name` (no slash) → folder = `<name>/`, package name = `<name>`.
672
- * 4. `--project <project>` → folder = `<project>/<defaultSub>` and
673
- * package name = `<project>-<defaultSub>`
674
- * (the co-located convention; only this
675
- * one uses the `-src` / `-site` suffix).
676
- * 5. (no input) → folder = `<defaultDir>/`, package name
677
- * = `<defaultPkg>` (`src/` + `src`
678
- * for foundations; `site/` + `site` for
679
- * sites).
680
- *
681
- * @param {string} rootDir
682
- * @param {string|null} name - Either a bare name or a path-with-slash.
683
- * @param {{ path?: string, project?: string }} opts
684
- * @param {{ defaultDir: string, defaultPkg: string, projectSub: string }} kind
685
- * @returns {{ relativePath: string, packageName: string }}
686
- */
687
- function resolvePlacement(rootDir, name, opts, kind) {
688
- // 1. --path is a PARENT directory. The folder is `<path>/<name>` if a
689
- // name was given, or `<path>` itself if not (the path's last segment
690
- // is then taken as the package name).
691
- if (opts.path) {
692
- const parent = opts.path.replace(/\/+$/, '')
693
- if (name) {
694
- const last = name.split('/').filter(Boolean).pop()
695
- return {
696
- relativePath: `${parent}/${name}`.replace(/\/+/g, '/'),
697
- packageName: last,
698
- }
699
- }
700
- const lastSegment = parent.split('/').filter(Boolean).pop() || parent
701
- return {
702
- relativePath: parent,
703
- packageName: lastSegment,
704
- }
705
- }
706
-
707
- // 2. name contains a slash → treat as a path.
708
- if (name && name.includes('/')) {
709
- const relativePath = name.replace(/\/+$/, '')
710
- const lastSegment = relativePath.split('/').filter(Boolean).pop()
711
- return {
712
- relativePath,
713
- packageName: lastSegment,
714
- }
715
- }
716
-
717
- // 3. Bare name.
718
- if (name) {
719
- return {
720
- relativePath: name,
721
- packageName: name,
722
- }
723
- }
724
-
725
- // 4. --project (co-located convention with -src / -site suffix).
726
- if (opts.project) {
727
- return {
728
- relativePath: `${opts.project}/${kind.projectSub}`,
729
- packageName: `${opts.project}-${kind.projectSub}`,
730
- }
731
- }
732
-
733
- // 5. Default placement.
734
- return {
735
- relativePath: kind.defaultDir,
736
- packageName: kind.defaultPkg,
737
- }
738
- }
739
653
 
740
654
  /**
741
655
  * Resolve which foundation to wire a site to
@@ -548,7 +548,7 @@ function resolveFoundationDir(projectDir, siteConfig) {
548
548
  * and deploy was reading that stripped version, causing the worker to
549
549
  * mis-detect split and serve blank pages. Link mode skips prerender
550
550
  * entirely; `dist/site-content.json` keeps full sections; the worker
551
- * splits correctly. See `kb/framework/build/workspace-ergonomics.md`.
551
+ * splits correctly.
552
552
  */
553
553
  async function buildSiteLink(projectDir, options = {}) {
554
554
  const { siteConfig = null } = options
@@ -966,7 +966,7 @@ export async function build(args = []) {
966
966
  // --host names the host adapter for this build's prerender step.
967
967
  // Default = 'cloudflare-pages' (resolved inside prerender.js, via the
968
968
  // registry). Build does not read deploy.yml; that is the deploy
969
- // orchestrator's job. See kb/framework/plans/static-host-deploy-adapters.md.
969
+ // orchestrator's job.
970
970
  //
971
971
  // `--host` with no value → interactive picker (errors in CI / non-TTY).
972
972
  const { readFlagValue } = await import('../utils/args.js')
@@ -0,0 +1,337 @@
1
+ /**
2
+ * uniweb clone <site-uuid> — materialize a backend site as a local file project.
3
+ *
4
+ * The "git clone" of the site-content remote model (see
5
+ * kb/framework/plans/site-content-remote-model.md): the backend is the remote, a
6
+ * file project is a working clone. `clone` is the create-side sibling of
7
+ * `uniweb pull`/`uniweb push` — it bootstraps a brand-new project from a site that
8
+ * already lives in the backend (typically authored in the visual app).
9
+ *
10
+ * What it does, and a key constraint: clone runs from a GLOBAL install before any
11
+ * project exists, so it must NOT statically import `@uniweb/build` (that would crash
12
+ * `npx uniweb clone`, same reason utils/workspace.js loads the classifier lazily).
13
+ * So clone does the minimum itself and delegates the heavy lifting:
14
+ *
15
+ * 1. plain `fetch` GET <origin>/dev/site/content/pull/<uuid> — read the `foundation`
16
+ * ref out of that one document (no `@uniweb/build` needed for a GET);
17
+ * 2. scaffold the HARNESS — a full Vite site package whose foundation is
18
+ * REFERENCED (runtime-loaded), no local foundation sibling (scaffoldSite with
19
+ * foundationRef and no foundationPath) + AGENTS.md + deps pinned to this CLI's
20
+ * version matrix; placement reuses create (new workspace / in-place) and add's
21
+ * resolver (into an existing workspace, any shape);
22
+ * 3. seed the site's one identity — site.yml::$uuid (a plain YAML scalar write).
23
+ * The folder is pulled by this same uuid, so there is no separate folder uuid to
24
+ * seed;
25
+ * 4. install, then delegate the projection to the project-local `uniweb pull` (which
26
+ * resolves the now-installed project-local `@uniweb/build`; clone forwards
27
+ * `--no-collections` to it when set).
28
+ *
29
+ * Sites are private — authenticate with `uniweb login` first; the session carries
30
+ * identity + the backend origin. There is no `--foundation` flag: the site carries
31
+ * its foundation ref and clone honors it verbatim (switching a site's foundation is a
32
+ * deliberate, high-risk operation, never a clone convenience).
33
+ *
34
+ * Usage:
35
+ * uniweb login
36
+ * uniweb clone <site-uuid> [name|.] New workspace (or `.` in-place / a site in
37
+ * the current workspace when run inside one)
38
+ * uniweb clone <uuid> --path sites Place under sites/ (segregated layout)
39
+ * uniweb clone <uuid> --project docs Co-located docs/site
40
+ * uniweb clone <uuid> --no-collections Pull pages only; skip collection records
41
+ *
42
+ * Endpoints: <origin>/dev/site/content/pull/<uuid>. Origin from
43
+ * --registry > UNIWEB_REGISTER_URL > the local default (internal dev overrides;
44
+ * not the user-facing path — `uniweb login` determines the origin).
45
+ * Auth: --token > UNIWEB_TOKEN > `uniweb login` session.
46
+ */
47
+
48
+ import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'node:fs'
49
+ import { join, resolve, basename, dirname } from 'node:path'
50
+ import { spawnSync } from 'node:child_process'
51
+ import { scaffoldWorkspace, scaffoldSite } from '../utils/scaffold.js'
52
+ import { resolvePlacement, SITE_KIND } from '../utils/placement.js'
53
+ import { findWorkspaceRoot } from '../utils/workspace.js'
54
+ import { addWorkspaceGlob } from '../utils/config.js'
55
+ import { detectWorkspacePm, installCmd } from '../utils/pm.js'
56
+ import { ensureRegistryAuth } from '../utils/registry-auth.js'
57
+ import { isNonInteractive, getCliPrefix } from '../utils/interactive.js'
58
+ import { extractFoundationRef } from '../utils/site-content-refs.js'
59
+
60
+ const DEFAULT_BACKEND_ORIGIN = 'http://localhost:8080'
61
+
62
+ const colors = {
63
+ reset: '\x1b[0m', bright: '\x1b[1m', dim: '\x1b[2m',
64
+ red: '\x1b[31m', green: '\x1b[32m', yellow: '\x1b[33m', blue: '\x1b[36m', cyan: '\x1b[36m',
65
+ }
66
+ const log = console.log
67
+ const success = (m) => log(`${colors.green}✓${colors.reset} ${m}`)
68
+ const error = (m) => console.error(`${colors.red}✗${colors.reset} ${m}`)
69
+ const info = (m) => log(`${colors.blue}→${colors.reset} ${m}`)
70
+ const note = (m) => log(` ${colors.dim}${m}${colors.reset}`)
71
+
72
+ function flagValue(args, name) {
73
+ const eq = args.find((a) => a.startsWith(`${name}=`))
74
+ if (eq) return eq.slice(name.length + 1)
75
+ const i = args.indexOf(name)
76
+ if (i !== -1 && args[i + 1] && !args[i + 1].startsWith('-')) return args[i + 1]
77
+ return null
78
+ }
79
+
80
+ function slugify(s) {
81
+ return String(s || '').toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '')
82
+ }
83
+
84
+ // Tolerant single-entity document extraction (mirrors pull.js; duplicated rather
85
+ // than imported because pull.js statically imports @uniweb/build).
86
+ export function extractDocument(payload) {
87
+ if (!payload || typeof payload !== 'object') return null
88
+ if (payload.$model || payload.$id || payload.info) return payload
89
+ return payload.document || payload.entity || null
90
+ }
91
+
92
+ // Unwrap a possibly-localized scalar (a `{ <locale>: value }` map) to a plain string.
93
+ function unwrapScalar(v) {
94
+ if (typeof v === 'string') return v
95
+ if (v && typeof v === 'object') {
96
+ const first = Object.values(v).find((x) => typeof x === 'string')
97
+ if (first) return first
98
+ }
99
+ return null
100
+ }
101
+
102
+ /**
103
+ * Read the seeds clone needs out of a site-content `$`-document:
104
+ * - foundationRef: the `foundation` ref (a URL or our `@ns/name@ver`) — written
105
+ * verbatim into site.yml so the runtime loads it as a federated module;
106
+ * - name: a display name for the new project.
107
+ *
108
+ * No folder uuid is read: the site holds one identity (its site-content uuid), and
109
+ * the folder is pulled by that same uuid — the framework never holds a folder uuid.
110
+ */
111
+ export function extractCloneSeeds(document) {
112
+ const info = document?.info || {}
113
+ return {
114
+ foundationRef: extractFoundationRef(info, document),
115
+ name: unwrapScalar(info.name) ?? unwrapScalar(document?.name) ?? null,
116
+ }
117
+ }
118
+
119
+ // Insert/replace a top-level `$uuid:` scalar in a YAML file's text without
120
+ // disturbing the rest (the scaffolded site.yml is comment-heavy — don't round-trip
121
+ // through a YAML dumper). Inserts after the first `name:` line, else prepends.
122
+ function seedYamlUuid(filePath, uuid) {
123
+ let text = existsSync(filePath) ? readFileSync(filePath, 'utf8') : ''
124
+ if (/^\$uuid:/m.test(text)) {
125
+ text = text.replace(/^\$uuid:.*$/m, `$uuid: ${uuid}`)
126
+ } else {
127
+ const nameMatch = text.match(/^name:.*$/m)
128
+ if (nameMatch) {
129
+ const idx = nameMatch.index + nameMatch[0].length
130
+ text = text.slice(0, idx) + `\n$uuid: ${uuid}` + text.slice(idx)
131
+ } else {
132
+ text = `$uuid: ${uuid}\n` + text
133
+ }
134
+ }
135
+ mkdirSync(dirname(filePath), { recursive: true })
136
+ writeFileSync(filePath, text)
137
+ }
138
+
139
+ // Build the package-manager argv to run the project-local `uniweb pull`.
140
+ function pullExecArgv(pm, extra) {
141
+ // npm needs `exec --` to forward flags to the binary; pnpm/yarn don't.
142
+ if (pm === 'npm') return ['exec', '--', 'uniweb', 'pull', ...extra]
143
+ return ['exec', 'uniweb', 'pull', ...extra]
144
+ }
145
+
146
+ /**
147
+ * @param {string[]} args
148
+ * @param {object} [deps] - injectable seams for testing:
149
+ * fetch, getToken, skipInstall, skipPull, runInstall(projectDir, pm),
150
+ * runPull(siteDir, pm, extraArgs).
151
+ */
152
+ export async function clone(args = [], deps = {}) {
153
+ const fetchImpl = deps.fetch || globalThis.fetch
154
+
155
+ const positionals = args.filter((a) => !a.startsWith('-'))
156
+ const siteUuid = positionals[0]
157
+ const target = positionals[1] || null // [name|.]
158
+
159
+ if (!siteUuid) {
160
+ error('Missing site uuid.')
161
+ log(`\nUsage: ${getCliPrefix()} clone <site-uuid> [name|.] [--path <dir>] [--project <name>] [--no-collections]`)
162
+ log(`${colors.dim}Sites are private — run \`uniweb login\` first.${colors.reset}`)
163
+ return { exitCode: 2 }
164
+ }
165
+
166
+ const noCollections = args.includes('--no-collections') || args.includes('--content-only')
167
+ const pathFlag = flagValue(args, '--path')
168
+ const projectFlag = flagValue(args, '--project')
169
+ const tokenFlag = flagValue(args, '--token')
170
+ const registryFlag = flagValue(args, '--registry') || process.env.UNIWEB_REGISTER_URL || DEFAULT_BACKEND_ORIGIN
171
+
172
+ let apiBase
173
+ try {
174
+ apiBase = new URL(registryFlag).origin
175
+ } catch {
176
+ error(`Invalid --registry / UNIWEB_REGISTER_URL: ${registryFlag}`)
177
+ return { exitCode: 2 }
178
+ }
179
+
180
+ let cachedToken = null
181
+ const getToken =
182
+ deps.getToken ||
183
+ (async () => {
184
+ if (cachedToken) return cachedToken
185
+ cachedToken = tokenFlag || process.env.UNIWEB_TOKEN || (await ensureRegistryAuth({ apiBase, command: 'Cloning', args }))
186
+ return cachedToken
187
+ })
188
+
189
+ // 1. GET the site-content document (plain fetch — no @uniweb/build).
190
+ const url = `${apiBase}/dev/site/content/pull/${encodeURIComponent(siteUuid)}`
191
+ info(`Reading site ${colors.bright}${siteUuid}${colors.reset} from ${colors.dim}${url}${colors.reset} …`)
192
+ let payload
193
+ try {
194
+ const res = await fetchImpl(url, { headers: { Authorization: `Bearer ${await getToken()}` } })
195
+ if (res.status === 404) {
196
+ error(`Site not found (404) — check the uuid, or you lack access.`)
197
+ return { exitCode: 1 }
198
+ }
199
+ if (!res.ok) {
200
+ error(`Could not read the site: HTTP ${res.status} ${res.statusText}`)
201
+ if (res.status === 401 || res.status === 403) note('Run `uniweb login` first (or pass --token <bearer>).')
202
+ return { exitCode: 1 }
203
+ }
204
+ payload = await res.json()
205
+ } catch (err) {
206
+ error(`Could not reach the backend at ${url}: ${err.message}`)
207
+ return { exitCode: 1 }
208
+ }
209
+
210
+ const document = extractDocument(payload)
211
+ if (!document) {
212
+ error('The site-content response carried no recognizable document.')
213
+ return { exitCode: 1 }
214
+ }
215
+ const { foundationRef, name: siteDisplayName } = extractCloneSeeds(document)
216
+ if (!foundationRef) {
217
+ note('! The pulled site declares no foundation ref — set `foundation:` in site.yml after clone.')
218
+ }
219
+
220
+ // 2. Resolve placement (one verb, context-aware).
221
+ const cwd = deps.cwd || process.cwd()
222
+ const inPlace = target === '.'
223
+ const existingRoot = inPlace ? null : findWorkspaceRoot(cwd)
224
+
225
+ let projectDir // the package-manager root (for install)
226
+ let siteDir // where the site package lands
227
+ let sitePkgName
228
+ let workspaceName
229
+ let isNewWorkspace
230
+ let placement = null
231
+
232
+ if (inPlace) {
233
+ isNewWorkspace = true
234
+ projectDir = cwd
235
+ workspaceName = slugify(basename(cwd)) || 'site'
236
+ siteDir = join(projectDir, 'site')
237
+ sitePkgName = 'site'
238
+ } else if (existingRoot) {
239
+ isNewWorkspace = false
240
+ projectDir = existingRoot
241
+ placement = resolvePlacement(existingRoot, target, { path: pathFlag, project: projectFlag }, SITE_KIND)
242
+ siteDir = join(existingRoot, placement.relativePath)
243
+ sitePkgName = placement.packageName
244
+ workspaceName = sitePkgName
245
+ } else {
246
+ isNewWorkspace = true
247
+ workspaceName = target || slugify(siteDisplayName) || null
248
+ if (!workspaceName) {
249
+ error('Could not derive a project name from the site. Pass one: `uniweb clone <uuid> <name>`.')
250
+ return { exitCode: 2 }
251
+ }
252
+ if (!/^[a-z0-9-]+$/.test(workspaceName)) {
253
+ error(`Invalid project name "${workspaceName}" — use lowercase letters, numbers, and hyphens.`)
254
+ return { exitCode: 2 }
255
+ }
256
+ projectDir = resolve(cwd, workspaceName)
257
+ siteDir = join(projectDir, 'site')
258
+ sitePkgName = 'site'
259
+ }
260
+
261
+ // Conflict guards.
262
+ if (isNewWorkspace && !inPlace && existsSync(projectDir)) {
263
+ error(`Directory already exists: ${workspaceName}`)
264
+ return { exitCode: 1 }
265
+ }
266
+ if (existsSync(join(siteDir, 'site.yml'))) {
267
+ error(`A site already exists at ${siteDir} — refusing to overwrite.`)
268
+ return { exitCode: 1 }
269
+ }
270
+
271
+ // 3. Scaffold the harness (ref-only site: foundationRef, no foundationPath).
272
+ const onProgress = (m) => note(m)
273
+ const siteContext = { name: sitePkgName, projectName: workspaceName, ...(foundationRef ? { foundationRef } : {}) }
274
+
275
+ if (isNewWorkspace) {
276
+ info(`Scaffolding ${colors.bright}${workspaceName}${colors.reset} …`)
277
+ await scaffoldWorkspace(
278
+ projectDir,
279
+ { projectName: workspaceName, workspaceGlobs: ['site'], scripts: { dev: 'uniweb dev', build: 'uniweb build' } },
280
+ { onProgress },
281
+ )
282
+ await scaffoldSite(siteDir, siteContext, { onProgress })
283
+ } else {
284
+ info(`Adding site ${colors.bright}${sitePkgName}${colors.reset} to the workspace at ${colors.dim}${placement.relativePath}/${colors.reset} …`)
285
+ await scaffoldSite(siteDir, siteContext, { onProgress })
286
+ await addWorkspaceGlob(existingRoot, placement.relativePath)
287
+ }
288
+
289
+ // 4. Seed the site's one identity — site.yml::$uuid. The folder is pulled by this
290
+ // same uuid (the backend resolves the site's @uniweb/folder from it), so there is no
291
+ // separate folder uuid to seed.
292
+ seedYamlUuid(join(siteDir, 'site.yml'), siteUuid)
293
+ success(`Scaffolded the site harness${foundationRef ? ` (foundation: ${foundationRef})` : ''}.`)
294
+
295
+ // 5. Install, then delegate the projection to the project-local `uniweb pull`.
296
+ const pm = detectWorkspacePm(projectDir) || 'pnpm'
297
+
298
+ if (deps.skipInstall) {
299
+ note('Skipping install (test mode).')
300
+ } else if (deps.runInstall) {
301
+ await deps.runInstall(projectDir, pm)
302
+ } else {
303
+ info(`Installing dependencies (${installCmd(pm)}) …`)
304
+ const r = spawnSync(pm, ['install'], { cwd: projectDir, stdio: 'inherit' })
305
+ if (r.status !== 0) {
306
+ error(`Install failed. Once it succeeds, run \`uniweb pull\` from ${siteDir} to fetch the content.`)
307
+ return { exitCode: 1 }
308
+ }
309
+ }
310
+
311
+ const pullExtra = []
312
+ if (flagValue(args, '--registry')) pullExtra.push('--registry', registryFlag)
313
+ if (tokenFlag) pullExtra.push('--token', tokenFlag)
314
+ if (noCollections) pullExtra.push('--no-collections')
315
+
316
+ if (deps.skipPull) {
317
+ note('Skipping pull (test mode).')
318
+ } else if (deps.runPull) {
319
+ await deps.runPull(siteDir, pm, pullExtra)
320
+ } else {
321
+ info('Pulling content …')
322
+ const r = spawnSync(pm, pullExecArgv(pm, pullExtra), { cwd: siteDir, stdio: 'inherit' })
323
+ if (r.status !== 0) {
324
+ error(`Content pull failed. Fix the issue, then run \`uniweb pull\` from ${siteDir}.`)
325
+ return { exitCode: 1 }
326
+ }
327
+ }
328
+
329
+ log('')
330
+ success(`Cloned site into ${colors.bright}${isNewWorkspace && !inPlace ? workspaceName : siteDir}${colors.reset}`)
331
+ if (isNewWorkspace && !inPlace) {
332
+ log(`\nNext: ${colors.cyan}cd ${workspaceName} && uniweb dev${colors.reset}`)
333
+ } else {
334
+ log(`\nNext: ${colors.cyan}uniweb dev${colors.reset}`)
335
+ }
336
+ return { exitCode: 0 }
337
+ }