uniweb 1.0.0 → 1.0.2
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/LICENSE +201 -0
- package/README.md +532 -3
- package/package.json +60 -11
- package/partials/adding-pages.hbs +77 -0
- package/partials/agents.md +3000 -0
- package/partials/ai-assistance.hbs +28 -0
- package/partials/components-docs.hbs +13 -0
- package/partials/config-reference.hbs +66 -0
- package/partials/deployment.hbs +13 -0
- package/partials/learn-more.hbs +6 -0
- package/partials/quick-start.hbs +8 -0
- package/partials/search-docs.hbs +110 -0
- package/src/backend/asset-download.js +222 -0
- package/src/backend/client.js +986 -0
- package/src/backend/foundation-bring-along.js +692 -0
- package/src/backend/payment-handoff.js +362 -0
- package/src/backend/service-request.js +210 -0
- package/src/backend/site-media.js +255 -0
- package/src/backend/site-preview.js +77 -0
- package/src/backend/site-sync.js +2515 -0
- package/src/backend/workspace.js +234 -0
- package/src/commands/add.js +1900 -0
- package/src/commands/build.js +1214 -0
- package/src/commands/clone.js +466 -0
- package/src/commands/content.js +202 -0
- package/src/commands/deploy.js +537 -0
- package/src/commands/dev.js +202 -0
- package/src/commands/docs.js +530 -0
- package/src/commands/doctor.js +1982 -0
- package/src/commands/export.js +116 -0
- package/src/commands/families.js +82 -0
- package/src/commands/forget.js +197 -0
- package/src/commands/handoff.js +21 -0
- package/src/commands/i18n.js +1707 -0
- package/src/commands/inspect.js +375 -0
- package/src/commands/invite.js +24 -0
- package/src/commands/org.js +96 -0
- package/src/commands/publish.js +1087 -0
- package/src/commands/pull.js +1257 -0
- package/src/commands/push.js +663 -0
- package/src/commands/refresh.js +212 -0
- package/src/commands/register.js +1092 -0
- package/src/commands/rename.js +647 -0
- package/src/commands/site.js +358 -0
- package/src/commands/snapshot.js +461 -0
- package/src/commands/starter.js +206 -0
- package/src/commands/status.js +302 -0
- package/src/commands/sync.js +93 -0
- package/src/commands/template.js +107 -0
- package/src/commands/update.js +887 -0
- package/src/commands/validate.js +376 -0
- package/src/framework-index.json +248 -0
- package/src/index.js +2241 -0
- package/src/templates/fetchers/github.js +113 -0
- package/src/templates/fetchers/npm.js +85 -0
- package/src/templates/fetchers/release.js +92 -0
- package/src/templates/index.js +190 -0
- package/src/templates/processor.js +333 -0
- package/src/templates/resolver.js +232 -0
- package/src/templates/validator.js +155 -0
- package/src/utils/agents-stamp.js +110 -0
- package/src/utils/args.js +126 -0
- package/src/utils/asset-upload.js +270 -0
- package/src/utils/bytes.js +30 -0
- package/src/utils/code-upload.js +394 -0
- package/src/utils/config.js +417 -0
- package/src/utils/conformance.js +274 -0
- package/src/utils/dep-survey.js +176 -0
- package/src/utils/destination-prompt.js +146 -0
- package/src/utils/discover.js +75 -0
- package/src/utils/env.js +22 -0
- package/src/utils/exit.js +30 -0
- package/src/utils/fetch-retry.js +109 -0
- package/src/utils/flag-guard.js +246 -0
- package/src/utils/foundation-name.js +179 -0
- package/src/utils/git.js +342 -0
- package/src/utils/host-prompt.js +51 -0
- package/src/utils/install-integrity.js +210 -0
- package/src/utils/interactive.js +118 -0
- package/src/utils/json-file.js +72 -0
- package/src/utils/names.js +91 -0
- package/src/utils/placement.js +151 -0
- package/src/utils/pm.js +165 -0
- package/src/utils/preview.js +20 -0
- package/src/utils/pull-written.js +132 -0
- package/src/utils/records-guard.js +88 -0
- package/src/utils/registry-auth.js +994 -0
- package/src/utils/registry-orgs.js +290 -0
- package/src/utils/scaffold.js +483 -0
- package/src/utils/schemaless-report.js +30 -0
- package/src/utils/semver-precedence.js +80 -0
- package/src/utils/session-file.js +95 -0
- package/src/utils/site-content-refs.js +23 -0
- package/src/utils/site-copies.js +123 -0
- package/src/utils/site-data-upload.js +302 -0
- package/src/utils/site-identity.js +137 -0
- package/src/utils/snapshot-settings.js +112 -0
- package/src/utils/update-check.js +302 -0
- package/src/utils/uwx-read.js +157 -0
- package/src/utils/workspace.js +223 -0
- package/src/versions.js +467 -0
- package/starter/foundation/main.js +50 -0
- package/starter/foundation/sections/Section/index.jsx +95 -0
- package/starter/foundation/sections/Section/meta.js +44 -0
- package/starter/site/pages/about/1-about.md.hbs +13 -0
- package/starter/site/pages/about/page.yml +3 -0
- package/starter/site/pages/home/1-welcome.md.hbs +14 -0
- package/starter/site/pages/home/page.yml +3 -0
- package/templates/foundation/_gitignore +5 -0
- package/templates/foundation/components/.gitkeep +0 -0
- package/templates/foundation/main.js.hbs +33 -0
- package/templates/foundation/package.json.hbs +51 -0
- package/templates/foundation/sections/.gitkeep +0 -0
- package/templates/foundation/styles.css +6 -0
- package/templates/foundation/vite.config.js +3 -0
- package/templates/site/_gitignore +12 -0
- package/templates/site/entry.js +7 -0
- package/templates/site/index.html.hbs +13 -0
- package/templates/site/package.json.hbs +27 -0
- package/templates/site/public/favicon.svg +7 -0
- package/templates/site/site.yml.hbs +91 -0
- package/templates/site/theme.yml +99 -0
- package/templates/site/vite.config.js +7 -0
- package/templates/workspace/AGENTS.md.hbs +2 -0
- package/templates/workspace/README.md.hbs +87 -0
- package/templates/workspace/_gitignore +29 -0
- package/templates/workspace/_vscode/settings.json +6 -0
- package/templates/workspace/package.json.hbs +20 -0
- package/templates/workspace/pnpm-workspace.yaml.hbs +26 -0
|
@@ -0,0 +1,466 @@
|
|
|
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
|
+
* the site-content remote model): 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. read the site-content document via the backend client — pull the `foundation`
|
|
16
|
+
* ref out of that one document (no `@uniweb/build` needed for a read);
|
|
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 identity on that backend — its section of `sync.json` (plain
|
|
23
|
+
* JSON, written with no dependencies).
|
|
24
|
+
* The folder is pulled by this same uuid, so there is no separate folder uuid to
|
|
25
|
+
* seed;
|
|
26
|
+
* 4. install, then delegate the projection to the project-local `uniweb pull` (which
|
|
27
|
+
* resolves the now-installed project-local `@uniweb/build`; clone forwards
|
|
28
|
+
* `--no-records` to it when set).
|
|
29
|
+
*
|
|
30
|
+
* Sites are private — authenticate with `uniweb login` first; the session carries
|
|
31
|
+
* identity + the backend origin. There is no `--foundation` flag: the site carries
|
|
32
|
+
* its foundation ref and clone honors it verbatim (switching a site's foundation is a
|
|
33
|
+
* deliberate, high-risk operation, never a clone convenience).
|
|
34
|
+
*
|
|
35
|
+
* Usage:
|
|
36
|
+
* uniweb login
|
|
37
|
+
* uniweb clone <site-uuid> [name|.] New workspace (or `.` in-place / a site in
|
|
38
|
+
* the current workspace when run inside one)
|
|
39
|
+
* uniweb clone <uuid> --path sites Place under sites/ (segregated layout)
|
|
40
|
+
* uniweb clone <uuid> --project docs Co-located docs/site
|
|
41
|
+
* uniweb clone <uuid> --no-records Pull pages only; skip records
|
|
42
|
+
* uniweb clone <uuid> --org @org Work in @org for this clone, instead of the
|
|
43
|
+
* workspace chosen at `uniweb login`. A site in
|
|
44
|
+
* another workspace stops it.
|
|
45
|
+
*
|
|
46
|
+
* Backend: via BackendClient (the site-content pull lane). Origin from
|
|
47
|
+
* UNIWEB_SERVER > the local default (internal dev overrides;
|
|
48
|
+
* not the user-facing path — `uniweb login` determines the origin).
|
|
49
|
+
* Auth: UNIWEB_TOKEN > the stored session > `uniweb login`. No `--token` (retired
|
|
50
|
+
* from the backend commands 2026-09-21).
|
|
51
|
+
*/
|
|
52
|
+
|
|
53
|
+
import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'node:fs'
|
|
54
|
+
import { join, resolve, basename, dirname } from 'node:path'
|
|
55
|
+
import { spawnSync } from 'node:child_process'
|
|
56
|
+
import { scaffoldWorkspace, scaffoldSite } from '../utils/scaffold.js'
|
|
57
|
+
import { resolvePlacement, SITE_KIND } from '../utils/placement.js'
|
|
58
|
+
import { findWorkspaceRoot } from '../utils/workspace.js'
|
|
59
|
+
import { addWorkspaceGlob } from '../utils/config.js'
|
|
60
|
+
import { detectWorkspacePm, installCmd } from '../utils/pm.js'
|
|
61
|
+
import { BackendClient, describeRequestError, refusalDetail } from '../backend/client.js'
|
|
62
|
+
import { resolveWorkspace } from '../backend/workspace.js'
|
|
63
|
+
import { isNonInteractive, getCliPrefix } from '../utils/interactive.js'
|
|
64
|
+
import { extractFoundationRef } from '../utils/site-content-refs.js'
|
|
65
|
+
import { readUwxDocuments } from '../utils/uwx-read.js'
|
|
66
|
+
import { recordWritten } from '../utils/pull-written.js'
|
|
67
|
+
import { checkFlags } from '../utils/flag-guard.js'
|
|
68
|
+
|
|
69
|
+
const colors = {
|
|
70
|
+
reset: '\x1b[0m',
|
|
71
|
+
bright: '\x1b[1m',
|
|
72
|
+
dim: '\x1b[2m',
|
|
73
|
+
red: '\x1b[31m',
|
|
74
|
+
green: '\x1b[32m',
|
|
75
|
+
yellow: '\x1b[33m',
|
|
76
|
+
blue: '\x1b[36m',
|
|
77
|
+
cyan: '\x1b[36m'
|
|
78
|
+
}
|
|
79
|
+
const log = console.log
|
|
80
|
+
const success = (m) => log(`${colors.green}✓${colors.reset} ${m}`)
|
|
81
|
+
const error = (m) => console.error(`${colors.red}✗${colors.reset} ${m}`)
|
|
82
|
+
const info = (m) => log(`${colors.blue}→${colors.reset} ${m}`)
|
|
83
|
+
const note = (m) => log(` ${colors.dim}${m}${colors.reset}`)
|
|
84
|
+
|
|
85
|
+
function flagValue(args, name) {
|
|
86
|
+
const eq = args.find((a) => a.startsWith(`${name}=`))
|
|
87
|
+
if (eq) return eq.slice(name.length + 1)
|
|
88
|
+
const i = args.indexOf(name)
|
|
89
|
+
if (i !== -1 && args[i + 1] && !args[i + 1].startsWith('-'))
|
|
90
|
+
return args[i + 1]
|
|
91
|
+
return null
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function slugify(s) {
|
|
95
|
+
return String(s || '')
|
|
96
|
+
.toLowerCase()
|
|
97
|
+
.replace(/[^a-z0-9]+/g, '-')
|
|
98
|
+
.replace(/^-+|-+$/g, '')
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// Tolerant single-entity document extraction (mirrors pull.js; duplicated rather
|
|
102
|
+
// than imported because pull.js statically imports @uniweb/build).
|
|
103
|
+
export function extractDocument(payload) {
|
|
104
|
+
if (!payload || typeof payload !== 'object') return null
|
|
105
|
+
if (payload.$schema || payload.$uuid || payload.$id || payload.info) return payload
|
|
106
|
+
return payload.document || payload.entity || null
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// Unwrap a possibly-localized scalar (a `{ <locale>: value }` map) to a plain string.
|
|
110
|
+
function unwrapScalar(v) {
|
|
111
|
+
if (typeof v === 'string') return v
|
|
112
|
+
if (v && typeof v === 'object') {
|
|
113
|
+
const first = Object.values(v).find((x) => typeof x === 'string')
|
|
114
|
+
if (first) return first
|
|
115
|
+
}
|
|
116
|
+
return null
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Read the seeds clone needs out of a site-content `$`-document:
|
|
121
|
+
* - foundationRef: the `foundation` ref (a URL or our `@ns/name@ver`) — written
|
|
122
|
+
* verbatim into site.yml so the runtime loads it as a federated module;
|
|
123
|
+
* - name: a display name for the new project.
|
|
124
|
+
*
|
|
125
|
+
* No folder uuid is read: the site holds one identity (its site-content uuid), and
|
|
126
|
+
* the folder is pulled by that same uuid — the framework never holds a folder uuid.
|
|
127
|
+
*/
|
|
128
|
+
export function extractCloneSeeds(document) {
|
|
129
|
+
const info = document?.info || {}
|
|
130
|
+
return {
|
|
131
|
+
foundationRef: extractFoundationRef(info, document),
|
|
132
|
+
name: unwrapScalar(info.name) ?? unwrapScalar(document?.name) ?? null
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// Seed `sync.json` with the identity this clone was pulled by.
|
|
137
|
+
//
|
|
138
|
+
// ⛔ **Dependency-free on purpose, and this has bitten before.** `clone` runs BEFORE
|
|
139
|
+
// `pnpm install`, so `@uniweb/build`'s store is not on disk yet. The previous version
|
|
140
|
+
// of this recorded the scope through a lazy `import('@uniweb/build/uwx')` whose
|
|
141
|
+
// failure branch returned null and wrote NOTHING — so a fresh clone recorded nothing
|
|
142
|
+
// essentially always, and the next command stopped dead complaining about a backend
|
|
143
|
+
// the user had named on the command line. Measured by the backend lane 2026-09-18:
|
|
144
|
+
// 0.48.5 recorded it, 0.57.0 did not.
|
|
145
|
+
//
|
|
146
|
+
// ⭐ JSON needs no parser, which is most of why the store is JSON rather than YAML —
|
|
147
|
+
// `site.yml`'s equivalent needed a hand-rolled dependency-free WRITER for exactly
|
|
148
|
+
// this. The store stays the one writer everywhere else; this seeds a fresh file.
|
|
149
|
+
// `test/clone.test.js` covers it end to end with `skipInstall: true`.
|
|
150
|
+
function seedSyncJson(siteDir, origin, uuid) {
|
|
151
|
+
const file = join(siteDir, 'sync.json')
|
|
152
|
+
let doc = { version: 1, backends: {} }
|
|
153
|
+
if (existsSync(file)) {
|
|
154
|
+
try {
|
|
155
|
+
const parsed = JSON.parse(readFileSync(file, 'utf8'))
|
|
156
|
+
if (parsed?.backends && typeof parsed.backends === 'object') doc = parsed
|
|
157
|
+
} catch {
|
|
158
|
+
/* unreadable — a fresh clone's file is ours to write */
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
const key = new URL(origin).origin
|
|
162
|
+
// ⛔ No workspace: a command works in the one chosen with the login, and the clone
|
|
163
|
+
// does not know which workspace OWNS the site — it may read it from a parent.
|
|
164
|
+
doc.backends[key] = {
|
|
165
|
+
...(doc.backends[key] || {}),
|
|
166
|
+
site: { ...(doc.backends[key]?.site || {}), uuid }
|
|
167
|
+
}
|
|
168
|
+
mkdirSync(dirname(file), { recursive: true })
|
|
169
|
+
writeFileSync(file, JSON.stringify(doc, null, 2) + '\n')
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
// Build the package-manager argv to run the project-local `uniweb pull`.
|
|
173
|
+
function pullExecArgv(pm, extra) {
|
|
174
|
+
// npm needs `exec --` to forward flags to the binary; pnpm/yarn don't.
|
|
175
|
+
if (pm === 'npm') return ['exec', '--', 'uniweb', 'pull', ...extra]
|
|
176
|
+
return ['exec', 'uniweb', 'pull', ...extra]
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* @param {string[]} args
|
|
181
|
+
* @param {object} [deps] - injectable seams for testing:
|
|
182
|
+
* fetch, getToken, skipInstall, skipPull, runInstall(projectDir, pm),
|
|
183
|
+
* runPull(siteDir, pm, extraArgs).
|
|
184
|
+
*/
|
|
185
|
+
export async function clone(args = [], deps = {}) {
|
|
186
|
+
// See utils/flag-guard.js — an unrecognized flag is invisible to a
|
|
187
|
+
// literal scan, so it silently keeps the default (production, for --backend).
|
|
188
|
+
const badFlag = checkFlags('clone', args)
|
|
189
|
+
if (badFlag) {
|
|
190
|
+
error(badFlag.message)
|
|
191
|
+
return { exitCode: 2 }
|
|
192
|
+
}
|
|
193
|
+
const positionals = args.filter((a) => !a.startsWith('-'))
|
|
194
|
+
const siteUuid = positionals[0]
|
|
195
|
+
const target = positionals[1] || null // [name|.]
|
|
196
|
+
|
|
197
|
+
if (!siteUuid) {
|
|
198
|
+
error('Missing site uuid.')
|
|
199
|
+
log(
|
|
200
|
+
`\nUsage: ${getCliPrefix()} clone <site-uuid> [name|.] [--path <dir>] [--project <name>] [--no-records]`
|
|
201
|
+
)
|
|
202
|
+
log(
|
|
203
|
+
`${colors.dim}Sites are private — run \`uniweb login\` first.${colors.reset}`
|
|
204
|
+
)
|
|
205
|
+
return { exitCode: 2 }
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
const noRecords =
|
|
209
|
+
args.includes('--no-records') || args.includes('--content-only')
|
|
210
|
+
const pathFlag = flagValue(args, '--path')
|
|
211
|
+
const projectFlag = flagValue(args, '--project')
|
|
212
|
+
const client = new BackendClient({
|
|
213
|
+
getToken: deps.getToken,
|
|
214
|
+
fetchImpl: deps.fetch,
|
|
215
|
+
args,
|
|
216
|
+
command: 'Cloning'
|
|
217
|
+
})
|
|
218
|
+
// The workspace this clone works in — the login's, unless this command names another
|
|
219
|
+
// (`workspace.js`). A site the backend keeps elsewhere stops it, naming how to switch.
|
|
220
|
+
const ws = await resolveWorkspace({ client, args })
|
|
221
|
+
if (ws.refused) {
|
|
222
|
+
error('This clone works in one workspace, and none is chosen.')
|
|
223
|
+
note(ws.reason)
|
|
224
|
+
return { exitCode: 2 }
|
|
225
|
+
}
|
|
226
|
+
client.setWorkspace(ws.workspace, { source: ws.source })
|
|
227
|
+
|
|
228
|
+
// 1. GET the site-content document.
|
|
229
|
+
//
|
|
230
|
+
// ⛔ The body is a `.uwx` ZIP, not JSON — `application/vnd.uniweb.exchange.
|
|
231
|
+
// entity+zip`, on `content` and `folder` pulls alike. This used to call
|
|
232
|
+
// `res.json()`, which failed on the ZIP magic and reported *"Could not reach
|
|
233
|
+
// the backend: Unexpected token 'P'"* — blaming the network for a body we had
|
|
234
|
+
// received intact and mis-read. Decode with the local reader: this command runs
|
|
235
|
+
// before a project exists, so `@uniweb/build/uwx`'s `readZip` is out of reach
|
|
236
|
+
// (see `utils/uwx-read.js`).
|
|
237
|
+
info(
|
|
238
|
+
`Reading site ${colors.bright}${siteUuid}${colors.reset} from ${colors.dim}${client.origin}${colors.reset} …`
|
|
239
|
+
)
|
|
240
|
+
let documents
|
|
241
|
+
try {
|
|
242
|
+
const res = await client.pullSiteContent(siteUuid)
|
|
243
|
+
if (res.status === 404) {
|
|
244
|
+
error(`Site not found (404) — check the uuid, or you lack access.`)
|
|
245
|
+
return { exitCode: 1 }
|
|
246
|
+
}
|
|
247
|
+
if (!res.ok) {
|
|
248
|
+
error(`Could not read the site: HTTP ${res.status} ${res.statusText}`)
|
|
249
|
+
const detail = await refusalDetail(res)
|
|
250
|
+
if (detail) note(detail)
|
|
251
|
+
if (res.status === 401 || res.status === 403)
|
|
252
|
+
note('Run `uniweb login` first.')
|
|
253
|
+
return { exitCode: 1 }
|
|
254
|
+
}
|
|
255
|
+
documents = readUwxDocuments(Buffer.from(await res.arrayBuffer()))
|
|
256
|
+
} catch (err) {
|
|
257
|
+
error(describeRequestError(err, client.origin))
|
|
258
|
+
return { exitCode: 1 }
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
const document =
|
|
262
|
+
documents.map(extractDocument).find(Boolean) || null
|
|
263
|
+
if (!document) {
|
|
264
|
+
error('The site-content response carried no recognizable document.')
|
|
265
|
+
return { exitCode: 1 }
|
|
266
|
+
}
|
|
267
|
+
const { foundationRef, name: siteDisplayName } = extractCloneSeeds(document)
|
|
268
|
+
if (!foundationRef) {
|
|
269
|
+
note(
|
|
270
|
+
'! The pulled site declares no foundation ref — set `foundation:` in site.yml after clone.'
|
|
271
|
+
)
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
// 2. Resolve placement (one verb, context-aware).
|
|
275
|
+
const cwd = deps.cwd || process.cwd()
|
|
276
|
+
const inPlace = target === '.'
|
|
277
|
+
const existingRoot = inPlace ? null : findWorkspaceRoot(cwd)
|
|
278
|
+
|
|
279
|
+
let projectDir // the package-manager root (for install)
|
|
280
|
+
let siteDir // where the site package lands
|
|
281
|
+
let sitePkgName
|
|
282
|
+
let workspaceName
|
|
283
|
+
let isNewWorkspace
|
|
284
|
+
let placement = null
|
|
285
|
+
|
|
286
|
+
if (inPlace) {
|
|
287
|
+
isNewWorkspace = true
|
|
288
|
+
projectDir = cwd
|
|
289
|
+
workspaceName = slugify(basename(cwd)) || 'site'
|
|
290
|
+
siteDir = join(projectDir, 'site')
|
|
291
|
+
sitePkgName = 'site'
|
|
292
|
+
} else if (existingRoot) {
|
|
293
|
+
isNewWorkspace = false
|
|
294
|
+
projectDir = existingRoot
|
|
295
|
+
placement = resolvePlacement(
|
|
296
|
+
existingRoot,
|
|
297
|
+
target,
|
|
298
|
+
{ path: pathFlag, project: projectFlag },
|
|
299
|
+
SITE_KIND
|
|
300
|
+
)
|
|
301
|
+
if (placement.outsideRoot) {
|
|
302
|
+
error(`--path must name a folder inside the workspace: ${placement.outsideRoot}`)
|
|
303
|
+
note(
|
|
304
|
+
`The workspace root is ${existingRoot}. A package outside it cannot be a ` +
|
|
305
|
+
`workspace member — the directory and the manifests would disagree.`
|
|
306
|
+
)
|
|
307
|
+
return { exitCode: 1 }
|
|
308
|
+
}
|
|
309
|
+
siteDir = join(existingRoot, placement.relativePath)
|
|
310
|
+
sitePkgName = placement.packageName
|
|
311
|
+
workspaceName = sitePkgName
|
|
312
|
+
} else {
|
|
313
|
+
isNewWorkspace = true
|
|
314
|
+
workspaceName = target || slugify(siteDisplayName) || null
|
|
315
|
+
if (!workspaceName) {
|
|
316
|
+
error(
|
|
317
|
+
'Could not derive a project name from the site. Pass one: `uniweb clone <uuid> <name>`.'
|
|
318
|
+
)
|
|
319
|
+
return { exitCode: 2 }
|
|
320
|
+
}
|
|
321
|
+
if (!/^[a-z0-9-]+$/.test(workspaceName)) {
|
|
322
|
+
error(
|
|
323
|
+
`Invalid project name "${workspaceName}" — use lowercase letters, numbers, and hyphens.`
|
|
324
|
+
)
|
|
325
|
+
return { exitCode: 2 }
|
|
326
|
+
}
|
|
327
|
+
projectDir = resolve(cwd, workspaceName)
|
|
328
|
+
siteDir = join(projectDir, 'site')
|
|
329
|
+
sitePkgName = 'site'
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
// Conflict guards.
|
|
333
|
+
if (isNewWorkspace && !inPlace && existsSync(projectDir)) {
|
|
334
|
+
error(`Directory already exists: ${workspaceName}`)
|
|
335
|
+
return { exitCode: 1 }
|
|
336
|
+
}
|
|
337
|
+
if (existsSync(join(siteDir, 'site.yml'))) {
|
|
338
|
+
error(`A site already exists at ${siteDir} — refusing to overwrite.`)
|
|
339
|
+
return { exitCode: 1 }
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
// 3. Scaffold the harness (ref-only site: foundationRef, no foundationPath).
|
|
343
|
+
const onProgress = (m) => note(m)
|
|
344
|
+
const siteContext = {
|
|
345
|
+
name: sitePkgName,
|
|
346
|
+
projectName: workspaceName,
|
|
347
|
+
...(foundationRef ? { foundationRef } : {})
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
if (isNewWorkspace) {
|
|
351
|
+
info(`Scaffolding ${colors.bright}${workspaceName}${colors.reset} …`)
|
|
352
|
+
await scaffoldWorkspace(
|
|
353
|
+
projectDir,
|
|
354
|
+
{
|
|
355
|
+
projectName: workspaceName,
|
|
356
|
+
workspaceGlobs: ['site'],
|
|
357
|
+
scripts: { dev: 'uniweb dev', build: 'uniweb build' }
|
|
358
|
+
},
|
|
359
|
+
{ onProgress }
|
|
360
|
+
)
|
|
361
|
+
await scaffoldSite(siteDir, siteContext, { onProgress })
|
|
362
|
+
} else {
|
|
363
|
+
info(
|
|
364
|
+
`Adding site ${colors.bright}${sitePkgName}${colors.reset} to the workspace at ${colors.dim}${placement.relativePath}/${colors.reset} …`
|
|
365
|
+
)
|
|
366
|
+
await scaffoldSite(siteDir, siteContext, { onProgress })
|
|
367
|
+
await addWorkspaceGlob(existingRoot, placement.relativePath)
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
// 4. Seed the identity this clone was pulled by, under the backend that minted it.
|
|
371
|
+
// The folder is pulled by the same uuid (the backend resolves the site's
|
|
372
|
+
// @uniweb/folder from it), so there is no separate folder uuid to seed.
|
|
373
|
+
//
|
|
374
|
+
// ⭐ The backend is the KEY, not a separate note. `clone` was the sharpest case for
|
|
375
|
+
// the old `$backend`: the uuid came from whoever we read and nothing on disk said
|
|
376
|
+
// so, and a teammate who pulled while logged in elsewhere sent it to the wrong
|
|
377
|
+
// backend. Stored under the origin, that cannot be expressed.
|
|
378
|
+
seedSyncJson(siteDir, client.origin, siteUuid)
|
|
379
|
+
success(
|
|
380
|
+
`Scaffolded the site harness${foundationRef ? ` (foundation: ${foundationRef})` : ''}.`
|
|
381
|
+
)
|
|
382
|
+
note(`Bound to ${client.origin} (recorded in sync.json).`)
|
|
383
|
+
|
|
384
|
+
// 5. Install, then delegate the projection to the project-local `uniweb pull`.
|
|
385
|
+
const pm = detectWorkspacePm(projectDir) || 'pnpm'
|
|
386
|
+
|
|
387
|
+
if (deps.skipInstall) {
|
|
388
|
+
note('Skipping install (test mode).')
|
|
389
|
+
} else if (deps.runInstall) {
|
|
390
|
+
await deps.runInstall(projectDir, pm)
|
|
391
|
+
} else {
|
|
392
|
+
info(`Installing dependencies (${installCmd(pm)}) …`)
|
|
393
|
+
const r = spawnSync(pm, ['install'], { cwd: projectDir, stdio: 'inherit' })
|
|
394
|
+
if (r.status !== 0) {
|
|
395
|
+
error(
|
|
396
|
+
`Install failed. Once it succeeds, run \`uniweb pull\` from ${siteDir} to fetch the content.`
|
|
397
|
+
)
|
|
398
|
+
return { exitCode: 1 }
|
|
399
|
+
}
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
// Tell the delegated pull which files WE wrote, or it refuses on them.
|
|
403
|
+
//
|
|
404
|
+
// Pull guards against overwriting uncommitted work under the paths it rewrites,
|
|
405
|
+
// and `site.yml` / `theme.yml` are exactly those paths — uncommitted here
|
|
406
|
+
// because clone scaffolded them seconds ago. Without this, clone reliably ends
|
|
407
|
+
// in "Refusing to pull: 2 uncommitted change(s)" on a project it just created,
|
|
408
|
+
// where there is no user work to protect. The advice it prints (`--force`) is
|
|
409
|
+
// both unreachable — clone does not forward that flag — and wrong, since it
|
|
410
|
+
// means "discard my changes" and the changes are ours.
|
|
411
|
+
//
|
|
412
|
+
// ⭐ This exempts them for the RIGHT reason rather than overriding the guard.
|
|
413
|
+
// `recordWritten` stores a content hash, so a file the user edits between
|
|
414
|
+
// scaffold and pull no longer matches and the refusal correctly stands. The
|
|
415
|
+
// guard keeps its teeth; it just stops firing on machine output, which is what
|
|
416
|
+
// it already does for pull's own writes.
|
|
417
|
+
//
|
|
418
|
+
// Recorded AFTER the uuid seeding above — the hash has to be of the final
|
|
419
|
+
// bytes on disk, not of what the scaffolder first wrote.
|
|
420
|
+
recordWritten(
|
|
421
|
+
siteDir,
|
|
422
|
+
['site.yml', 'theme.yml', 'head.html', 'queries.yml', join('records', 'folder.yml')]
|
|
423
|
+
.map((f) => join(siteDir, f))
|
|
424
|
+
.filter((p) => existsSync(p))
|
|
425
|
+
)
|
|
426
|
+
|
|
427
|
+
const pullExtra = []
|
|
428
|
+
if (noRecords) pullExtra.push('--no-records')
|
|
429
|
+
// The workspace this clone was told to work in: the pull is another process, and asks the same
|
|
430
|
+
// question. ⛔ Until 2026-09-26 it was not told, so it used the login's — or, with none chosen,
|
|
431
|
+
// refused: "none is chosen". A workspace from the login or the environment reaches it by itself.
|
|
432
|
+
const orgFlag = flagValue(args, '--org')
|
|
433
|
+
if (orgFlag) pullExtra.push('--org', orgFlag)
|
|
434
|
+
if (args.includes('--personal')) pullExtra.push('--personal')
|
|
435
|
+
|
|
436
|
+
if (deps.skipPull) {
|
|
437
|
+
note('Skipping pull (test mode).')
|
|
438
|
+
} else if (deps.runPull) {
|
|
439
|
+
await deps.runPull(siteDir, pm, pullExtra)
|
|
440
|
+
} else {
|
|
441
|
+
info('Pulling content …')
|
|
442
|
+
const r = spawnSync(pm, pullExecArgv(pm, pullExtra), {
|
|
443
|
+
cwd: siteDir,
|
|
444
|
+
stdio: 'inherit'
|
|
445
|
+
})
|
|
446
|
+
if (r.status !== 0) {
|
|
447
|
+
error(
|
|
448
|
+
`Content pull failed. Fix the issue, then run \`uniweb pull\` from ${siteDir}.`
|
|
449
|
+
)
|
|
450
|
+
return { exitCode: 1 }
|
|
451
|
+
}
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
log('')
|
|
455
|
+
success(
|
|
456
|
+
`Cloned site into ${colors.bright}${isNewWorkspace && !inPlace ? workspaceName : siteDir}${colors.reset}`
|
|
457
|
+
)
|
|
458
|
+
if (isNewWorkspace && !inPlace) {
|
|
459
|
+
log(
|
|
460
|
+
`\nNext: ${colors.cyan}cd ${workspaceName} && uniweb dev${colors.reset}`
|
|
461
|
+
)
|
|
462
|
+
} else {
|
|
463
|
+
log(`\nNext: ${colors.cyan}uniweb dev${colors.reset}`)
|
|
464
|
+
}
|
|
465
|
+
return { exitCode: 0 }
|
|
466
|
+
}
|
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Content Command — `uniweb content export`
|
|
3
|
+
*
|
|
4
|
+
* Packages a site project (or a built foundation's schema) as a `.uwx`
|
|
5
|
+
* entity package — the Uniweb exchange format — for import into Uniweb.
|
|
6
|
+
* No JS/HTML: pure content / declarative data.
|
|
7
|
+
*
|
|
8
|
+
* uniweb content export Package the site in the cwd
|
|
9
|
+
* uniweb content export <dir> Package a specific site/foundation
|
|
10
|
+
* uniweb content export -o team.uwx Choose the output filename
|
|
11
|
+
* uniweb content export --no-sidecar Mint fresh ids (submit-once;
|
|
12
|
+
* default is the syncable round trip,
|
|
13
|
+
* persisting ids in .uniweb/uwx-ids.json)
|
|
14
|
+
* uniweb content export --dry-run Print a summary; write nothing
|
|
15
|
+
* uniweb content export --source-locale fr Wrap single-language fields under
|
|
16
|
+
* this locale (default: en)
|
|
17
|
+
*
|
|
18
|
+
* A site dir is detected by `site.yml`; a foundation by a built
|
|
19
|
+
* `dist/meta/schema.json` (run `uniweb build` first for a foundation).
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
import { existsSync, readFileSync, writeFileSync } from 'node:fs'
|
|
23
|
+
import { join, basename, resolve } from 'node:path'
|
|
24
|
+
|
|
25
|
+
const c = {
|
|
26
|
+
reset: '\x1b[0m',
|
|
27
|
+
dim: '\x1b[2m',
|
|
28
|
+
cyan: '\x1b[36m',
|
|
29
|
+
green: '\x1b[32m',
|
|
30
|
+
red: '\x1b[31m',
|
|
31
|
+
yellow: '\x1b[33m'
|
|
32
|
+
}
|
|
33
|
+
const say = {
|
|
34
|
+
ok: (m) => console.log(`${c.green}✓${c.reset} ${m}`),
|
|
35
|
+
info: (m) => console.log(`${c.cyan}→${c.reset} ${m}`),
|
|
36
|
+
err: (m) => console.error(`${c.red}✗${c.reset} ${m}`),
|
|
37
|
+
warn: (m) => console.log(`${c.yellow}!${c.reset} ${m}`),
|
|
38
|
+
dim: (m) => console.log(` ${c.dim}${m}${c.reset}`)
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function usage() {
|
|
42
|
+
console.log(`
|
|
43
|
+
${c.cyan}uniweb content export${c.reset} [dir] [options]
|
|
44
|
+
|
|
45
|
+
Package a site project (or built foundation schema) as a .uwx entity
|
|
46
|
+
package for import into Uniweb.
|
|
47
|
+
|
|
48
|
+
${c.dim}-o, --output <file>${c.reset} Output path (default: <name>.uwx)
|
|
49
|
+
${c.dim}--no-sidecar${c.reset} Mint fresh ids (submit-once) instead of
|
|
50
|
+
the syncable round trip
|
|
51
|
+
${c.dim}--dry-run${c.reset} Print a summary; write nothing
|
|
52
|
+
${c.dim}--source-locale <code>${c.reset} Locale for single-language fields (default: en)
|
|
53
|
+
`)
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export async function content(args = []) {
|
|
57
|
+
const sub = args[0]
|
|
58
|
+
if (!sub || sub === '--help' || sub === '-h') {
|
|
59
|
+
usage()
|
|
60
|
+
return
|
|
61
|
+
}
|
|
62
|
+
if (sub === 'export') {
|
|
63
|
+
await contentExport(args.slice(1))
|
|
64
|
+
return
|
|
65
|
+
}
|
|
66
|
+
say.err(`Unknown subcommand: content ${sub}`)
|
|
67
|
+
usage()
|
|
68
|
+
process.exit(1)
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
async function contentExport(args) {
|
|
72
|
+
const { readFlagValue } = await import('../utils/args.js')
|
|
73
|
+
const dryRun = args.includes('--dry-run')
|
|
74
|
+
const noSidecar = args.includes('--no-sidecar')
|
|
75
|
+
const sourceLocale = readFlagValue(args, '--source-locale') || undefined
|
|
76
|
+
let output = readFlagValue(args, '-o')
|
|
77
|
+
if (!output || output === true) output = readFlagValue(args, '--output')
|
|
78
|
+
const dir = resolve(
|
|
79
|
+
args.find((a) => !a.startsWith('-') && a !== output) || process.cwd()
|
|
80
|
+
)
|
|
81
|
+
|
|
82
|
+
const isSite = existsSync(join(dir, 'site.yml'))
|
|
83
|
+
const schemaPath = join(dir, 'dist', 'meta', 'schema.json')
|
|
84
|
+
const isFoundation = !isSite && existsSync(schemaPath)
|
|
85
|
+
|
|
86
|
+
if (!isSite && !isFoundation) {
|
|
87
|
+
say.err(`No site.yml or built dist/meta/schema.json found in ${dir}`)
|
|
88
|
+
say.dim('Point at a site directory, or `uniweb build` a foundation first.')
|
|
89
|
+
process.exit(1)
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const uwx = await import('@uniweb/build/uwx')
|
|
93
|
+
// --dry-run must have zero side effects: mint ids (no sidecar file).
|
|
94
|
+
const useSidecar = !dryRun && !noSidecar
|
|
95
|
+
|
|
96
|
+
let buf
|
|
97
|
+
let defaultName
|
|
98
|
+
try {
|
|
99
|
+
if (isSite) {
|
|
100
|
+
say.info(`Packaging site → @uniweb/site-content (.uwx)…`)
|
|
101
|
+
// Nested $-document on the sync lane. The sidecar is read-only here: it
|
|
102
|
+
// supplies $uuids a prior sync recorded (the backend mints, never this
|
|
103
|
+
// export), so a fresh project exports a uuid-less document.
|
|
104
|
+
buf = await uwx.emitSiteSyncPackage(dir, {
|
|
105
|
+
sidecar: useSidecar, // read <dir>/.uniweb/uwx-ids.json if present
|
|
106
|
+
sourceLocale
|
|
107
|
+
})
|
|
108
|
+
defaultName = basename(dir)
|
|
109
|
+
} else {
|
|
110
|
+
const schema = JSON.parse(readFileSync(schemaPath, 'utf8'))
|
|
111
|
+
say.info(
|
|
112
|
+
`Packaging foundation schema → @uniweb/foundation-schema (.uwx)…`
|
|
113
|
+
)
|
|
114
|
+
buf = uwx.emitFoundationSchemaPackage(schema, {
|
|
115
|
+
sidecar: useSidecar ? join(dir, uwx.SIDECAR_RELPATH) : undefined,
|
|
116
|
+
foundationDir: dir,
|
|
117
|
+
sourceLocale
|
|
118
|
+
})
|
|
119
|
+
defaultName = (schema?._self?.name || basename(dir)).replace(
|
|
120
|
+
/[^a-z0-9._-]+/gi,
|
|
121
|
+
'-'
|
|
122
|
+
)
|
|
123
|
+
}
|
|
124
|
+
} catch (err) {
|
|
125
|
+
say.err(err.message)
|
|
126
|
+
process.exit(1)
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// Structural summary (not an import simulation). The entity file is located
|
|
130
|
+
// via the manifest index, which works for both lanes: the register lane
|
|
131
|
+
// (foundation schema, flat `items[]`) and the sync lane (site, nested
|
|
132
|
+
// `$`-document).
|
|
133
|
+
const files = uwx.readZip(buf)
|
|
134
|
+
const manifest = JSON.parse(files.get('manifest.json').toString('utf8'))
|
|
135
|
+
const entityFile = manifest.entries?.[0]?.file
|
|
136
|
+
const entity = JSON.parse(files.get(entityFile).toString('utf8'))
|
|
137
|
+
const counts = {}
|
|
138
|
+
if (Array.isArray(entity.items)) {
|
|
139
|
+
// Flat register lane: one item per section occurrence.
|
|
140
|
+
for (const it of entity.items) {
|
|
141
|
+
counts[it.section] = (counts[it.section] || 0) + 1
|
|
142
|
+
}
|
|
143
|
+
} else {
|
|
144
|
+
// Nested sync lane: count records per top-level section, recursing into
|
|
145
|
+
// self-nested `$children` and inline `page_sections`.
|
|
146
|
+
const countPages = (pages) => {
|
|
147
|
+
let p = 0
|
|
148
|
+
let s = 0
|
|
149
|
+
for (const page of pages || []) {
|
|
150
|
+
p++
|
|
151
|
+
s += (page.page_sections || []).length
|
|
152
|
+
const sub = countPages(page.$children)
|
|
153
|
+
p += sub.p
|
|
154
|
+
s += sub.s
|
|
155
|
+
}
|
|
156
|
+
return { p, s }
|
|
157
|
+
}
|
|
158
|
+
if (entity.info) counts.info = 1
|
|
159
|
+
const pg = countPages(entity.pages)
|
|
160
|
+
if (pg.p) counts.pages = pg.p
|
|
161
|
+
if (pg.s) counts.page_sections = pg.s
|
|
162
|
+
if (entity.layout_sections?.length)
|
|
163
|
+
counts.layout_sections = entity.layout_sections.length
|
|
164
|
+
if (entity.extensions?.length) counts.extensions = entity.extensions.length
|
|
165
|
+
if (entity.queries?.length) counts.queries = entity.queries.length
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
console.log('')
|
|
169
|
+
say.dim(`subtype ${manifest.subtype} (format ${manifest.format})`)
|
|
170
|
+
say.dim(
|
|
171
|
+
`type ${manifest.models_required[0].name_at_export} ${manifest.models_required[0].uuid}`
|
|
172
|
+
)
|
|
173
|
+
say.dim(`entity ${entity.uuid || entity.$uuid || entity.$id}`)
|
|
174
|
+
say.dim(
|
|
175
|
+
`items ${Object.entries(counts)
|
|
176
|
+
.map(([s, n]) => `${s}:${n}`)
|
|
177
|
+
.join(' ')}`
|
|
178
|
+
)
|
|
179
|
+
say.dim(`package_sha256 ${manifest.package_sha256.slice(0, 16)}…`)
|
|
180
|
+
say.dim(`size ${(buf.length / 1024).toFixed(1)} KiB`)
|
|
181
|
+
console.log('')
|
|
182
|
+
|
|
183
|
+
if (dryRun) {
|
|
184
|
+
say.ok('Dry run — nothing written.')
|
|
185
|
+
return
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
const outPath = resolve(output || `${defaultName}.uwx`)
|
|
189
|
+
writeFileSync(outPath, buf)
|
|
190
|
+
say.ok(`Wrote ${c.cyan}${outPath}${c.reset}`)
|
|
191
|
+
// Only the register lane (foundation schema) mints + persists ids locally. The
|
|
192
|
+
// site sync lane reads the sidecar read-only — the backend mints on sync — so
|
|
193
|
+
// there is nothing to persist here for a site.
|
|
194
|
+
if (useSidecar && isFoundation) {
|
|
195
|
+
say.dim(
|
|
196
|
+
`ids persisted in ${uwx.SIDECAR_RELPATH} — commit it so re-exports update, not duplicate.`
|
|
197
|
+
)
|
|
198
|
+
}
|
|
199
|
+
console.log('')
|
|
200
|
+
say.warn('v0 scope: media bytes, records, and @-nested section')
|
|
201
|
+
say.dim('hierarchy are not yet carried (documented).')
|
|
202
|
+
}
|