uniweb 0.59.2 → 0.61.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +7 -7
- package/partials/agents.md +24 -32
- package/src/backend/asset-download.js +11 -9
- package/src/backend/client.js +38 -91
- package/src/backend/foundation-bring-along.js +19 -14
- package/src/backend/service-request.js +14 -9
- package/src/backend/site-media.js +6 -6
- package/src/backend/site-sync.js +323 -221
- package/src/commands/clone.js +42 -39
- package/src/commands/deploy.js +45 -27
- package/src/commands/doctor.js +6 -7
- package/src/commands/forget.js +197 -0
- package/src/commands/i18n.js +19 -10
- package/src/commands/publish.js +112 -77
- package/src/commands/pull.js +63 -49
- package/src/commands/push.js +76 -60
- package/src/commands/refresh.js +22 -29
- package/src/commands/register.js +37 -23
- package/src/commands/status.js +13 -11
- package/src/commands/sync.js +0 -1
- package/src/framework-index.json +7 -7
- package/src/index.js +101 -76
- package/src/utils/args.js +3 -4
- package/src/utils/asset-upload.js +18 -5
- package/src/utils/config.js +79 -42
- package/src/utils/flag-guard.js +70 -27
- package/src/utils/git.js +1 -1
- package/src/utils/interactive.js +38 -0
- package/src/utils/records-guard.js +38 -30
- package/src/utils/registry-auth.js +150 -28
- package/src/utils/registry-orgs.js +19 -0
- package/src/utils/scaffold.js +11 -0
- package/src/utils/session-file.js +95 -0
- package/src/utils/site-copies.js +123 -0
- package/src/utils/site-data-upload.js +4 -3
- package/src/utils/site-identity.js +89 -299
- package/templates/site/site.yml.hbs +11 -9
package/src/commands/clone.js
CHANGED
|
@@ -19,7 +19,8 @@
|
|
|
19
19
|
* foundationRef and no foundationPath) + AGENTS.md + deps pinned to this CLI's
|
|
20
20
|
* version matrix; placement reuses create (new workspace / in-place) and add's
|
|
21
21
|
* resolver (into an existing workspace, any shape);
|
|
22
|
-
* 3. seed the site's
|
|
22
|
+
* 3. seed the site's identity on that backend — its section of `sync.json` (plain
|
|
23
|
+
* JSON, written with no dependencies).
|
|
23
24
|
* The folder is pulled by this same uuid, so there is no separate folder uuid to
|
|
24
25
|
* seed;
|
|
25
26
|
* 4. install, then delegate the projection to the project-local `uniweb pull` (which
|
|
@@ -42,7 +43,8 @@
|
|
|
42
43
|
* Backend: via BackendClient (the site-content pull lane). Origin from
|
|
43
44
|
* UNIWEB_REGISTER_URL > the local default (internal dev overrides;
|
|
44
45
|
* not the user-facing path — `uniweb login` determines the origin).
|
|
45
|
-
* Auth:
|
|
46
|
+
* Auth: UNIWEB_TOKEN > the stored session > `uniweb login`. No `--token` (retired
|
|
47
|
+
* from the backend commands 2026-09-21).
|
|
46
48
|
*/
|
|
47
49
|
|
|
48
50
|
import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'node:fs'
|
|
@@ -59,7 +61,6 @@ import { extractFoundationRef } from '../utils/site-content-refs.js'
|
|
|
59
61
|
import { readUwxDocuments } from '../utils/uwx-read.js'
|
|
60
62
|
import { recordWritten } from '../utils/pull-written.js'
|
|
61
63
|
import { checkFlags } from '../utils/flag-guard.js'
|
|
62
|
-
import { recordSiteBackend } from '../utils/site-identity.js'
|
|
63
64
|
|
|
64
65
|
const colors = {
|
|
65
66
|
reset: '\x1b[0m',
|
|
@@ -128,24 +129,35 @@ export function extractCloneSeeds(document) {
|
|
|
128
129
|
}
|
|
129
130
|
}
|
|
130
131
|
|
|
131
|
-
//
|
|
132
|
-
//
|
|
133
|
-
//
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
132
|
+
// Seed `sync.json` with the identity this clone was pulled by.
|
|
133
|
+
//
|
|
134
|
+
// ⛔ **Dependency-free on purpose, and this has bitten before.** `clone` runs BEFORE
|
|
135
|
+
// `pnpm install`, so `@uniweb/build`'s store is not on disk yet. The previous version
|
|
136
|
+
// of this recorded the scope through a lazy `import('@uniweb/build/uwx')` whose
|
|
137
|
+
// failure branch returned null and wrote NOTHING — so a fresh clone recorded nothing
|
|
138
|
+
// essentially always, and the next command stopped dead complaining about a backend
|
|
139
|
+
// the user had named on the command line. Measured by the backend lane 2026-09-18:
|
|
140
|
+
// 0.48.5 recorded it, 0.57.0 did not.
|
|
141
|
+
//
|
|
142
|
+
// ⭐ JSON needs no parser, which is most of why the store is JSON rather than YAML —
|
|
143
|
+
// `site.yml`'s equivalent needed a hand-rolled dependency-free WRITER for exactly
|
|
144
|
+
// this. The store stays the one writer everywhere else; this seeds a fresh file.
|
|
145
|
+
// `test/clone.test.js` covers it end to end with `skipInstall: true`.
|
|
146
|
+
function seedSyncJson(siteDir, origin, uuid) {
|
|
147
|
+
const file = join(siteDir, 'sync.json')
|
|
148
|
+
let doc = { version: 1, backends: {} }
|
|
149
|
+
if (existsSync(file)) {
|
|
150
|
+
try {
|
|
151
|
+
const parsed = JSON.parse(readFileSync(file, 'utf8'))
|
|
152
|
+
if (parsed?.backends && typeof parsed.backends === 'object') doc = parsed
|
|
153
|
+
} catch {
|
|
154
|
+
/* unreadable — a fresh clone's file is ours to write */
|
|
145
155
|
}
|
|
146
156
|
}
|
|
147
|
-
|
|
148
|
-
|
|
157
|
+
const key = new URL(origin).origin
|
|
158
|
+
doc.backends[key] = { ...(doc.backends[key] || {}), site: { ...(doc.backends[key]?.site || {}), uuid } }
|
|
159
|
+
mkdirSync(dirname(file), { recursive: true })
|
|
160
|
+
writeFileSync(file, JSON.stringify(doc, null, 2) + '\n')
|
|
149
161
|
}
|
|
150
162
|
|
|
151
163
|
// Build the package-manager argv to run the project-local `uniweb pull`.
|
|
@@ -188,12 +200,7 @@ export async function clone(args = [], deps = {}) {
|
|
|
188
200
|
args.includes('--no-records') || args.includes('--content-only')
|
|
189
201
|
const pathFlag = flagValue(args, '--path')
|
|
190
202
|
const projectFlag = flagValue(args, '--project')
|
|
191
|
-
const tokenFlag = flagValue(args, '--token')
|
|
192
|
-
const explicitBackend =
|
|
193
|
-
flagValue(args, '--backend')
|
|
194
203
|
const client = new BackendClient({
|
|
195
|
-
originFlag: explicitBackend,
|
|
196
|
-
token: tokenFlag,
|
|
197
204
|
getToken: deps.getToken,
|
|
198
205
|
fetchImpl: deps.fetch,
|
|
199
206
|
args,
|
|
@@ -222,7 +229,7 @@ export async function clone(args = [], deps = {}) {
|
|
|
222
229
|
if (!res.ok) {
|
|
223
230
|
error(`Could not read the site: HTTP ${res.status} ${res.statusText}`)
|
|
224
231
|
if (res.status === 401 || res.status === 403)
|
|
225
|
-
note('Run `uniweb login` first
|
|
232
|
+
note('Run `uniweb login` first.')
|
|
226
233
|
return { exitCode: 1 }
|
|
227
234
|
}
|
|
228
235
|
documents = readUwxDocuments(Buffer.from(await res.arrayBuffer()))
|
|
@@ -340,21 +347,19 @@ export async function clone(args = [], deps = {}) {
|
|
|
340
347
|
await addWorkspaceGlob(existingRoot, placement.relativePath)
|
|
341
348
|
}
|
|
342
349
|
|
|
343
|
-
// 4. Seed the
|
|
344
|
-
// same uuid (the backend resolves the site's
|
|
345
|
-
// separate folder uuid to seed.
|
|
346
|
-
|
|
347
|
-
//
|
|
348
|
-
// the uuid
|
|
349
|
-
// so a teammate who
|
|
350
|
-
//
|
|
351
|
-
|
|
352
|
-
// an existing site and never reaches them, which is why it is written here too.)
|
|
353
|
-
const scope = await recordSiteBackend(siteDir, client.origin)
|
|
350
|
+
// 4. Seed the identity this clone was pulled by, under the backend that minted it.
|
|
351
|
+
// The folder is pulled by the same uuid (the backend resolves the site's
|
|
352
|
+
// @uniweb/folder from it), so there is no separate folder uuid to seed.
|
|
353
|
+
//
|
|
354
|
+
// ⭐ The backend is the KEY, not a separate note. `clone` was the sharpest case for
|
|
355
|
+
// the old `$backend`: the uuid came from whoever we read and nothing on disk said
|
|
356
|
+
// so, and a teammate who pulled while logged in elsewhere sent it to the wrong
|
|
357
|
+
// backend. Stored under the origin, that cannot be expressed.
|
|
358
|
+
seedSyncJson(siteDir, client.origin, siteUuid)
|
|
354
359
|
success(
|
|
355
360
|
`Scaffolded the site harness${foundationRef ? ` (foundation: ${foundationRef})` : ''}.`
|
|
356
361
|
)
|
|
357
|
-
|
|
362
|
+
note(`Bound to ${client.origin} (recorded in sync.json).`)
|
|
358
363
|
|
|
359
364
|
// 5. Install, then delegate the projection to the project-local `uniweb pull`.
|
|
360
365
|
const pm = detectWorkspacePm(projectDir) || 'pnpm'
|
|
@@ -400,8 +405,6 @@ export async function clone(args = [], deps = {}) {
|
|
|
400
405
|
)
|
|
401
406
|
|
|
402
407
|
const pullExtra = []
|
|
403
|
-
if (explicitBackend) pullExtra.push('--backend', explicitBackend)
|
|
404
|
-
if (tokenFlag) pullExtra.push('--token', tokenFlag)
|
|
405
408
|
if (noRecords) pullExtra.push('--no-records')
|
|
406
409
|
|
|
407
410
|
if (deps.skipPull) {
|
package/src/commands/deploy.js
CHANGED
|
@@ -40,7 +40,7 @@
|
|
|
40
40
|
* uniweb deploy --host=uniweb Delegate to `uniweb publish` (Uniweb hosting)
|
|
41
41
|
* uniweb deploy --target <name> Pick a target from deploy.yml
|
|
42
42
|
* uniweb deploy --dry-run Resolve everything; upload nothing
|
|
43
|
-
* uniweb deploy --no-save
|
|
43
|
+
* uniweb deploy --no-save Do not record this deploy in deploy.yml
|
|
44
44
|
*
|
|
45
45
|
* Escape hatch: UNIWEB_SKIP_BUILD=1 reuses an existing dist/.
|
|
46
46
|
*/
|
|
@@ -56,6 +56,8 @@ import {
|
|
|
56
56
|
} from '@uniweb/build/site'
|
|
57
57
|
import { promptForDestination } from '../utils/destination-prompt.js'
|
|
58
58
|
import { readFlagValue } from '../utils/args.js'
|
|
59
|
+
import { DEFAULT_BACKEND_ORIGIN, getRegistryApiBaseUrl, loggedInOrigin } from '../utils/config.js'
|
|
60
|
+
import { normalizeOrigin } from '../utils/site-identity.js'
|
|
59
61
|
import { parseBoolEnv } from '../utils/env.js'
|
|
60
62
|
import { headProvenance } from '../utils/git.js'
|
|
61
63
|
import { warnIfContentDoesNotConform } from '../utils/conformance.js'
|
|
@@ -185,10 +187,42 @@ export async function deploy(args = []) {
|
|
|
185
187
|
// Uniweb Cloud is `publish`'s flow — delegate so deploy.yml stays one
|
|
186
188
|
// actionable record and there's a single implementation of go-live.
|
|
187
189
|
if (plan.kind === 'uniweb') {
|
|
190
|
+
// ⭐ `--host=uniweb` IS `publish` *[Diego, 2026-09-21]*: it goes to the backend you are
|
|
191
|
+
// logged in to — deploy follows the login like every other command. A deploy.yml
|
|
192
|
+
// uniweb target's `backend:` records where that target's publishes went; it routes
|
|
193
|
+
// nothing (publish files its record under the target naming its backend).
|
|
194
|
+
const goingTo = getRegistryApiBaseUrl()
|
|
195
|
+
const aimedBy = process.env.UNIWEB_REGISTER_URL
|
|
196
|
+
? 'UNIWEB_REGISTER_URL'
|
|
197
|
+
: loggedInOrigin()
|
|
198
|
+
? 'the backend you are logged in to'
|
|
199
|
+
: 'the default backend — you are not logged in'
|
|
200
|
+
const fromTarget =
|
|
201
|
+
resolved.fromFile &&
|
|
202
|
+
resolved.host === 'uniweb' &&
|
|
203
|
+
// not after the wizard (`--host` with no value): a pick there names no target
|
|
204
|
+
(hostFromFlag === undefined || hostFromFlag === 'uniweb')
|
|
205
|
+
const targetBackend = fromTarget
|
|
206
|
+
? normalizeOrigin(resolved.config?.backend || DEFAULT_BACKEND_ORIGIN)
|
|
207
|
+
: null
|
|
208
|
+
if (targetBackend && targetBackend !== goingTo) {
|
|
209
|
+
if (targetFromFlag) {
|
|
210
|
+
// ⛔ A refusal, not a route: the user TYPED this destination, and going live
|
|
211
|
+
// somewhere else would contradict it with nothing said. Switching is a login.
|
|
212
|
+
say.err(
|
|
213
|
+
`Target '${resolved.targetName}' is on ${targetBackend}, but this would publish to ${goingTo} (${aimedBy}).`
|
|
214
|
+
)
|
|
215
|
+
say.dim(`To publish there, log in to it first: uniweb login --backend ${targetBackend}`)
|
|
216
|
+
process.exit(1)
|
|
217
|
+
}
|
|
218
|
+
say.dim(
|
|
219
|
+
`deploy.yml's default target '${resolved.targetName}' is on ${targetBackend}; publishing to ${goingTo} (${aimedBy}).`
|
|
220
|
+
)
|
|
221
|
+
}
|
|
188
222
|
say.info('Uniweb Cloud → running `uniweb publish`.')
|
|
189
223
|
console.log('')
|
|
190
|
-
// publish ignores deploy's --host/--target; --dry-run/--no-save
|
|
191
|
-
//
|
|
224
|
+
// publish ignores deploy's --host/--target; --dry-run/--no-save pass straight
|
|
225
|
+
// through.
|
|
192
226
|
const { publish } = await import('./publish.js')
|
|
193
227
|
// Conformance was already reported above, and publish runs the same check
|
|
194
228
|
// — without this the user reads one warning twice and learns to skim it.
|
|
@@ -225,11 +259,13 @@ export async function deploy(args = []) {
|
|
|
225
259
|
// want a one-off experiment to rewrite the file). A host picked interactively
|
|
226
260
|
// for a bare `deploy` is NOT an override — we DO want to remember it.
|
|
227
261
|
const hostOverridden = !!hostFromFlag && hostFromFlag !== resolved.host
|
|
228
|
-
|
|
262
|
+
// An ad-hoc --host override describes no target, so there is nothing to file a
|
|
263
|
+
// deploy under — the switch goes off without anyone typing it.
|
|
264
|
+
const saveDeploys = !(noSave || hostOverridden) && resolved.saveDeploys !== false
|
|
229
265
|
|
|
230
266
|
await deployStaticHost(siteDir, plan.host, resolved, {
|
|
231
267
|
dryRun,
|
|
232
|
-
|
|
268
|
+
saveDeploys,
|
|
233
269
|
hostOverridden
|
|
234
270
|
})
|
|
235
271
|
}
|
|
@@ -280,7 +316,7 @@ async function deployStaticHost(
|
|
|
280
316
|
siteDir,
|
|
281
317
|
hostName,
|
|
282
318
|
resolved,
|
|
283
|
-
{ dryRun,
|
|
319
|
+
{ dryRun, saveDeploys, hostOverridden }
|
|
284
320
|
) {
|
|
285
321
|
let getAdapter
|
|
286
322
|
try {
|
|
@@ -399,7 +435,7 @@ async function deployStaticHost(
|
|
|
399
435
|
}
|
|
400
436
|
|
|
401
437
|
// Record a fresh lastDeploy.<target> entry. Skipped on --no-save and
|
|
402
|
-
// on ad-hoc --host overrides — see
|
|
438
|
+
// on ad-hoc --host overrides — see saveDeploys gating in deploy().
|
|
403
439
|
const gitStamp = {
|
|
404
440
|
at: new Date().toISOString(),
|
|
405
441
|
git: headProvenance(siteDir)
|
|
@@ -409,7 +445,7 @@ async function deployStaticHost(
|
|
|
409
445
|
targetConfig: resolved.fromFile
|
|
410
446
|
? null
|
|
411
447
|
: { host: hostName, ...deployConfig },
|
|
412
|
-
|
|
448
|
+
saveDeploys,
|
|
413
449
|
lastDeploy: {
|
|
414
450
|
at: gitStamp.at,
|
|
415
451
|
host: hostName,
|
|
@@ -434,7 +470,7 @@ async function deployStaticHost(
|
|
|
434
470
|
// ─── deploy.yml lastDeploy persistence ──────────────────────────
|
|
435
471
|
|
|
436
472
|
async function persistLastDeploy(siteDir, opts) {
|
|
437
|
-
if (opts.
|
|
473
|
+
if (opts.saveDeploys === false) return
|
|
438
474
|
try {
|
|
439
475
|
const result = await recordLastDeploy(siteDir, opts)
|
|
440
476
|
if (result?.created) {
|
|
@@ -497,21 +533,3 @@ export async function resolveSiteDir(args, verb = 'deploy') {
|
|
|
497
533
|
}
|
|
498
534
|
process.exit(1)
|
|
499
535
|
}
|
|
500
|
-
|
|
501
|
-
// The site's deploy.yml-bound backend origin (for the resolved `uniweb` target),
|
|
502
|
-
// or null when there's no deploy.yml, the target isn't Uniweb hosting, or no
|
|
503
|
-
// backend was recorded. Site verbs pass this to BackendClient as `siteBackend`
|
|
504
|
-
// so a site stays bound to the backend it publishes to — deploy.yml is the
|
|
505
|
-
// record of *where* a site is deployed (the 98% case is uniweb.app, but a B2B
|
|
506
|
-
// university backend is just a `backend:` on the target). Sits below --backend
|
|
507
|
-
// and UNIWEB_REGISTER_URL but above the logged-in session in the resolution
|
|
508
|
-
// ladder (see resolveBackendOrigin). Best-effort: any read/parse error → null.
|
|
509
|
-
export async function resolveSiteBackend(siteDir) {
|
|
510
|
-
try {
|
|
511
|
-
const deployYml = await loadDeployYml(siteDir)
|
|
512
|
-
const resolved = resolveTarget(deployYml, null)
|
|
513
|
-
return resolved.host === 'uniweb' ? resolved.config?.backend || null : null
|
|
514
|
-
} catch {
|
|
515
|
-
return null
|
|
516
|
-
}
|
|
517
|
-
}
|
package/src/commands/doctor.js
CHANGED
|
@@ -166,9 +166,9 @@ function loadSiteYml(dir) {
|
|
|
166
166
|
/**
|
|
167
167
|
* Diagnose the compiled-collection output directory.
|
|
168
168
|
*
|
|
169
|
-
* `public/<DATA_DIR>/` holds what the build compiles from `
|
|
170
|
-
* nothing else — `
|
|
171
|
-
*
|
|
169
|
+
* `public/<DATA_DIR>/` holds what the build compiles from `records/`, and
|
|
170
|
+
* nothing else — `records/` is the only supported way to provide structured
|
|
171
|
+
* data. Two consequences, both checked here:
|
|
172
172
|
*
|
|
173
173
|
* 1. **The mapping is a bijection.** Every entry should be backed by a
|
|
174
174
|
* declared collection. An entry that isn't is stale — most often a
|
|
@@ -969,7 +969,7 @@ function checkGeneratedDataDir({ sitePath, siteName, siteYml, issues, shouldFix,
|
|
|
969
969
|
const dataDir = join(sitePath, 'public', DATA_DIR)
|
|
970
970
|
// ⚠️ THE QUERIES, not the pool. `public/<DATA_DIR>/x.json` is a query's
|
|
971
971
|
// MATERIALIZATION — one file per named query — so the bijection is with the
|
|
972
|
-
// declared queries, never with the schema folders under `
|
|
972
|
+
// declared queries, never with the schema folders under `records/`.
|
|
973
973
|
//
|
|
974
974
|
// ⛔ BOTH HOMES, or this reports every compiled file as an orphan. A site that
|
|
975
975
|
// keeps its queries in `queries.yml` has none in `site.yml`, and reading one
|
|
@@ -1045,7 +1045,7 @@ function checkGeneratedDataDir({ sitePath, siteName, siteYml, issues, shouldFix,
|
|
|
1045
1045
|
const body = existing === null ? '' : existing.replace(/\n*$/, '\n')
|
|
1046
1046
|
writeFileSync(
|
|
1047
1047
|
gitignorePath,
|
|
1048
|
-
`${body}\n# Compiled query results — generated from
|
|
1048
|
+
`${body}\n# Compiled query results — generated from records/ + queries.yml\n${rule}\n`
|
|
1049
1049
|
)
|
|
1050
1050
|
fixed(`added ${rule} to ${gitignorePath}`)
|
|
1051
1051
|
if (existsSync(dataDir)) {
|
|
@@ -1418,8 +1418,7 @@ export async function doctor(args = []) {
|
|
|
1418
1418
|
}
|
|
1419
1419
|
|
|
1420
1420
|
// `public/<DATA_DIR>/` is the build's output directory and nothing else —
|
|
1421
|
-
// `
|
|
1422
|
-
// data. That makes the mapping a bijection: every entry there should be backed
|
|
1421
|
+
// `records/` is the only supported way to provide structured data. That makes the mapping a bijection: every entry there should be backed
|
|
1423
1422
|
// by a declared QUERY, so anything else is stale, and identifiable.
|
|
1424
1423
|
//
|
|
1425
1424
|
// It matters because the directory is written into the source tree rather
|
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `uniweb forget` — remove what this project recorded about where it has synced and
|
|
3
|
+
* deployed. Local files only; nothing on any backend changes.
|
|
4
|
+
*
|
|
5
|
+
* uniweb forget --backend <url> one backend
|
|
6
|
+
* uniweb forget --all everything — for a COPY that is to become a new project
|
|
7
|
+
*
|
|
8
|
+
* ## `--backend <url>`
|
|
9
|
+
*
|
|
10
|
+
* - that backend's section of `sync.json` — the site uuid, the record map, asset
|
|
11
|
+
* ids, provisioned services and the rest of what it minted;
|
|
12
|
+
* - that backend's section of `.uniweb/backend-cache.json`;
|
|
13
|
+
* - the `deploys:` records in `deploy.yml` that name it. ⭐ Its TARGETS stay: a
|
|
14
|
+
* target is where you chose to ship, not something the backend told us, and the
|
|
15
|
+
* next publish there simply creates a new site.
|
|
16
|
+
*
|
|
17
|
+
* Every other backend is untouched, which is only possible because all three are
|
|
18
|
+
* keyed by backend.
|
|
19
|
+
*
|
|
20
|
+
* ## `--all`
|
|
21
|
+
*
|
|
22
|
+
* A copied project carries the original's `sync.json`, so its first push updates the
|
|
23
|
+
* ORIGINAL's site — the copy sends uuids that are the original's. `--all` deletes
|
|
24
|
+
* `sync.json`, `.uniweb/backend-cache.json` and `deploy.yml`, after which the copy's
|
|
25
|
+
* next push creates a new site.
|
|
26
|
+
*
|
|
27
|
+
* ⭐ **`deploy.yml` goes whole, targets included** *[Diego, 2026-09-21]*. Its targets
|
|
28
|
+
* name the original's destinations — its Pages project, its bucket, its domain — so a
|
|
29
|
+
* deploy from the copy would overwrite the original there. The copy's first deploy
|
|
30
|
+
* writes a fresh one.
|
|
31
|
+
*
|
|
32
|
+
* ## What neither form touches
|
|
33
|
+
*
|
|
34
|
+
* ⛔ **Record files.** A record's `$uuid` is its OWN id, not any backend's. It is
|
|
35
|
+
* written once, on the record's first push anywhere, and never changes after;
|
|
36
|
+
* removing it would delete the record's identity, which is not what forgetting a
|
|
37
|
+
* backend means. A copy keeps them too: they never reach a backend again once the
|
|
38
|
+
* map that pointed them at the original's entities is gone. *[Diego, 2026-09-20.]*
|
|
39
|
+
*
|
|
40
|
+
* ⛔ **Any backend.** Every site still exists where it was. Deleting one is a
|
|
41
|
+
* different operation, on the backend's side.
|
|
42
|
+
*
|
|
43
|
+
* ## Why it exists
|
|
44
|
+
*
|
|
45
|
+
* Two jobs. A script pushes a template site to a short-lived dev server and then
|
|
46
|
+
* removes the traces of it without discarding the rest of the project (`--backend`)
|
|
47
|
+
* — which is also why traceless publish was dropped: push and pull leave traces too.
|
|
48
|
+
* And someone duplicates a project to start a new site from it (`--all`).
|
|
49
|
+
*
|
|
50
|
+
* ⚠️ **No default target.** Forgetting a backend you still use means its next push
|
|
51
|
+
* creates a second site there, so the verb makes you name it — `--backend` is
|
|
52
|
+
* required even when the project has synced with only one.
|
|
53
|
+
*/
|
|
54
|
+
|
|
55
|
+
import { readFlagValue } from '../utils/args.js'
|
|
56
|
+
import { checkFlags } from '../utils/flag-guard.js'
|
|
57
|
+
import { resolveSiteDir } from './deploy.js'
|
|
58
|
+
import { forgetBackendCache, forgetAllBackendCaches } from '../backend/site-sync.js'
|
|
59
|
+
import { clearBackend, forgetSyncStore, readBackendState } from '@uniweb/build/uwx'
|
|
60
|
+
import { forgetDeploys, forgetDeployYml } from '@uniweb/build/site'
|
|
61
|
+
import { syncedBackends, normalizeOrigin } from '../utils/site-identity.js'
|
|
62
|
+
|
|
63
|
+
const c = {
|
|
64
|
+
reset: '\x1b[0m',
|
|
65
|
+
bold: '\x1b[1m',
|
|
66
|
+
dim: '\x1b[2m',
|
|
67
|
+
cyan: '\x1b[36m',
|
|
68
|
+
green: '\x1b[32m',
|
|
69
|
+
yellow: '\x1b[33m',
|
|
70
|
+
red: '\x1b[31m'
|
|
71
|
+
}
|
|
72
|
+
const say = {
|
|
73
|
+
ok: (m) => console.log(`${c.green}✓${c.reset} ${m}`),
|
|
74
|
+
info: (m) => console.log(`${c.cyan}→${c.reset} ${m}`),
|
|
75
|
+
warn: (m) => console.log(`${c.yellow}⚠${c.reset} ${m}`),
|
|
76
|
+
err: (m) => console.error(`${c.red}✗${c.reset} ${m}`),
|
|
77
|
+
dim: (m) => console.log(` ${c.dim}${m}${c.reset}`)
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** "a", "a and b", "a, b and c" */
|
|
81
|
+
function listed(items) {
|
|
82
|
+
return items.length < 2
|
|
83
|
+
? items.join('')
|
|
84
|
+
: `${items.slice(0, -1).join(', ')} and ${items[items.length - 1]}`
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* @param {string[]} args
|
|
89
|
+
* @returns {Promise<{ exitCode: number, forgot?: string[], removed?: string[] }>}
|
|
90
|
+
*/
|
|
91
|
+
export async function forget(args = []) {
|
|
92
|
+
const badFlag = checkFlags('forget', args)
|
|
93
|
+
if (badFlag) {
|
|
94
|
+
say.err(badFlag.message)
|
|
95
|
+
return { exitCode: 2 }
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const all = args.includes('--all')
|
|
99
|
+
const flag = readFlagValue(args, '--backend')
|
|
100
|
+
if (all && flag) {
|
|
101
|
+
say.err('Use --backend <url> or --all, not both.')
|
|
102
|
+
return { exitCode: 2 }
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
const siteDir = await resolveSiteDir(args, 'forget')
|
|
106
|
+
const known = syncedBackends(siteDir)
|
|
107
|
+
|
|
108
|
+
if (all) return forgetAll(siteDir)
|
|
109
|
+
|
|
110
|
+
if (!flag) {
|
|
111
|
+
say.err('Name what to forget: uniweb forget --backend <url>')
|
|
112
|
+
if (known.length) {
|
|
113
|
+
say.dim(`This project has synced with: ${known.join(', ')}`)
|
|
114
|
+
} else {
|
|
115
|
+
say.dim('This project has not synced with any backend.')
|
|
116
|
+
}
|
|
117
|
+
say.dim('For a copied project that should become a new one: uniweb forget --all')
|
|
118
|
+
return { exitCode: 2 }
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
const origin = normalizeOrigin(flag)
|
|
122
|
+
if (!origin) {
|
|
123
|
+
say.err(`Not a URL: ${flag}`)
|
|
124
|
+
return { exitCode: 2 }
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// Read before removing, so the report can say what the section held.
|
|
128
|
+
const state = readBackendState(siteDir, origin)
|
|
129
|
+
const removed = []
|
|
130
|
+
if (clearBackend(siteDir, origin)) removed.push('sync.json')
|
|
131
|
+
if (forgetBackendCache(siteDir, origin)) removed.push('.uniweb/backend-cache.json')
|
|
132
|
+
let deploys = []
|
|
133
|
+
try {
|
|
134
|
+
deploys = await forgetDeploys(siteDir, origin)
|
|
135
|
+
if (deploys.length) removed.push('deploy.yml')
|
|
136
|
+
} catch (err) {
|
|
137
|
+
// Not a stop: the identity above is already forgotten, and a deploy.yml that does
|
|
138
|
+
// not parse fails every deploy on its own. Rewriting a file we could not read
|
|
139
|
+
// would be the one thing worse than leaving it.
|
|
140
|
+
say.warn(`${err.message} — its deploy records were left as they are.`)
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// Idempotent on purpose: a script calls this after every run, and a second call
|
|
144
|
+
// (or a call for a backend the push never reached) is not an error.
|
|
145
|
+
if (!removed.length) {
|
|
146
|
+
say.info(`Nothing recorded for ${origin} — nothing to forget.`)
|
|
147
|
+
if (known.length) say.dim(`This project has synced with: ${known.join(', ')}`)
|
|
148
|
+
return { exitCode: 0, forgot: [origin], removed }
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
say.ok(`Forgot ${c.bold}${origin}${c.reset}`)
|
|
152
|
+
if (state.site?.uuid) say.dim(`site ${state.site.uuid}`)
|
|
153
|
+
const counts = [
|
|
154
|
+
['records', state.records],
|
|
155
|
+
['assets', state.assets]
|
|
156
|
+
]
|
|
157
|
+
.map(([name, map]) => [name, map ? Object.keys(map).length : 0])
|
|
158
|
+
.filter(([, n]) => n > 0)
|
|
159
|
+
.map(([name, n]) => `${n} ${name}`)
|
|
160
|
+
if (counts.length) say.dim(counts.join(' · '))
|
|
161
|
+
if (deploys.length) {
|
|
162
|
+
say.dim(`deploy records: ${deploys.join(', ')} (the targets stay)`)
|
|
163
|
+
}
|
|
164
|
+
say.dim(`removed from ${listed(removed)}`)
|
|
165
|
+
say.dim('The site still exists on that backend; this removed only what this project recorded.')
|
|
166
|
+
|
|
167
|
+
return { exitCode: 0, forgot: [origin], removed }
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/** `--all`: the three files, whole. */
|
|
171
|
+
async function forgetAll(siteDir) {
|
|
172
|
+
const origins = forgetSyncStore(siteDir)
|
|
173
|
+
const cache = forgetAllBackendCaches(siteDir)
|
|
174
|
+
const targets = await forgetDeployYml(siteDir)
|
|
175
|
+
|
|
176
|
+
const removed = []
|
|
177
|
+
if (origins) removed.push('sync.json')
|
|
178
|
+
if (targets) removed.push('deploy.yml')
|
|
179
|
+
if (cache) removed.push('.uniweb/backend-cache.json')
|
|
180
|
+
|
|
181
|
+
if (!removed.length) {
|
|
182
|
+
say.info('Nothing recorded — this project has not synced or deployed anywhere.')
|
|
183
|
+
return { exitCode: 0, forgot: [], removed }
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
say.ok('Forgot every backend and deploy target')
|
|
187
|
+
if (origins?.length) say.dim(`backends: ${origins.join(', ')}`)
|
|
188
|
+
if (targets?.length) say.dim(`deploy targets: ${targets.join(', ')}`)
|
|
189
|
+
say.dim(`removed ${listed(removed)}`)
|
|
190
|
+
say.dim('Its next push creates a new site. Every existing site is untouched.')
|
|
191
|
+
// ⛔ The mistake this cannot prevent: running it in the ORIGINAL. That project's
|
|
192
|
+
// next push would then mint a second site on every backend. Both files are
|
|
193
|
+
// committed, so the way back is one command — say it now, while it is cheap.
|
|
194
|
+
say.dim('If this was the original rather than a copy, restore sync.json and deploy.yml from git.')
|
|
195
|
+
|
|
196
|
+
return { exitCode: 0, forgot: origins || [], removed }
|
|
197
|
+
}
|
package/src/commands/i18n.js
CHANGED
|
@@ -319,7 +319,7 @@ async function runExtract(siteRoot, config, args) {
|
|
|
319
319
|
const dataDir = join(siteRoot, 'public', DATA_DIR)
|
|
320
320
|
if (!existsSync(dataDir)) {
|
|
321
321
|
if (recordsOnly) {
|
|
322
|
-
error('No records found.
|
|
322
|
+
error('No records found. Every file in records/<schema>/ is a record — add some there.')
|
|
323
323
|
process.exit(1)
|
|
324
324
|
}
|
|
325
325
|
log(`${colors.dim}No records found in public/data/.${colors.reset}`)
|
|
@@ -1022,11 +1022,11 @@ async function runAudit(siteRoot, config, args) {
|
|
|
1022
1022
|
* Usage:
|
|
1023
1023
|
* uniweb i18n init-freeform es pages/about hero
|
|
1024
1024
|
* uniweb i18n init-freeform es page-ids/installation intro
|
|
1025
|
-
* uniweb i18n init-freeform es
|
|
1025
|
+
* uniweb i18n init-freeform es records/article getting-started
|
|
1026
1026
|
*/
|
|
1027
1027
|
async function runInitFreeform(siteRoot, config, args) {
|
|
1028
1028
|
const locale = args[0]
|
|
1029
|
-
const pathType = args[1] // pages/about, page-ids/installation,
|
|
1029
|
+
const pathType = args[1] // pages/about, page-ids/installation, records/article
|
|
1030
1030
|
const sectionId = args[2] // hero, intro, getting-started
|
|
1031
1031
|
|
|
1032
1032
|
if (!locale || !pathType || !sectionId) {
|
|
@@ -1035,11 +1035,20 @@ async function runInitFreeform(siteRoot, config, args) {
|
|
|
1035
1035
|
log(' uniweb i18n init-freeform es pages/about hero')
|
|
1036
1036
|
log(' uniweb i18n init-freeform es page-ids/installation intro')
|
|
1037
1037
|
log(
|
|
1038
|
-
` uniweb i18n init-freeform es
|
|
1038
|
+
` uniweb i18n init-freeform es records/article getting-started${colors.reset}`
|
|
1039
1039
|
)
|
|
1040
1040
|
process.exit(1)
|
|
1041
1041
|
}
|
|
1042
1042
|
|
|
1043
|
+
// A path names a page by route, a page by id, or a record by its schema folder —
|
|
1044
|
+
// and nothing else. Said up front: past this point an unknown kind would only
|
|
1045
|
+
// surface as "source content not found", which names the wrong cause.
|
|
1046
|
+
if (!/^(pages|page-ids|records)\//.test(pathType)) {
|
|
1047
|
+
error(`Unknown kind of path: ${pathType}`)
|
|
1048
|
+
log(`${colors.dim}A path starts with pages/, page-ids/ or records/ (a record's schema folder).${colors.reset}`)
|
|
1049
|
+
process.exit(1)
|
|
1050
|
+
}
|
|
1051
|
+
|
|
1043
1052
|
const localesPath = join(siteRoot, config.localesDir)
|
|
1044
1053
|
const freeformDir = join(localesPath, 'freeform', locale)
|
|
1045
1054
|
|
|
@@ -1091,15 +1100,15 @@ async function runInitFreeform(siteRoot, config, args) {
|
|
|
1091
1100
|
if (sourceContent) break
|
|
1092
1101
|
}
|
|
1093
1102
|
}
|
|
1094
|
-
} else if (pathType.startsWith('
|
|
1103
|
+
} else if (pathType.startsWith('records/')) {
|
|
1095
1104
|
// ⛔ ADDRESSED BY THE RECORD, matching where the loader reads. The freeform
|
|
1096
|
-
// tree is `
|
|
1105
|
+
// tree is `records/<schema dirs>/<slug>.md`; this used to take
|
|
1097
1106
|
// `collections/<query>` and write a path nothing read — the loader moved
|
|
1098
1107
|
// and this did not.
|
|
1099
1108
|
//
|
|
1100
1109
|
// The record's CONTENT still lives in a query's materialization, so the
|
|
1101
1110
|
// query that covers this schema is resolved rather than named.
|
|
1102
|
-
const poolDirs = pathType.
|
|
1111
|
+
const poolDirs = pathType.slice('records/'.length)
|
|
1103
1112
|
const { resolveQueriesConfig, poolDirsForSchema } = await import('@uniweb/build/uwx')
|
|
1104
1113
|
let queryName = null
|
|
1105
1114
|
try {
|
|
@@ -1509,7 +1518,7 @@ async function runPrune(siteRoot, config, args) {
|
|
|
1509
1518
|
// reads one (`freeformSourceIndex`, the build's own index): every path a section's
|
|
1510
1519
|
// translation is read from — ⛔ until 2026-09-14 only `page-ids/<id>/…` on a page with
|
|
1511
1520
|
// an `id`, so a route-addressed translation the page rendered was deleted — and only
|
|
1512
|
-
// what the built content can see: never a record's translation (`
|
|
1521
|
+
// what the built content can see: never a record's translation (`records/…`), and
|
|
1513
1522
|
// never one for a page whose sections the content does not carry.
|
|
1514
1523
|
const { validPaths, canJudge } = freeformSourceIndex(siteContent)
|
|
1515
1524
|
|
|
@@ -1657,7 +1666,7 @@ ${colors.bright}File Structure:${colors.reset}
|
|
|
1657
1666
|
.manifest.json Staleness tracking
|
|
1658
1667
|
pages/about/hero.md Translated content for /about page, hero section
|
|
1659
1668
|
page-ids/install/intro.md Translated content by page ID
|
|
1660
|
-
|
|
1669
|
+
records/article/getting-started.md
|
|
1661
1670
|
|
|
1662
1671
|
${colors.bright}Examples:${colors.reset}
|
|
1663
1672
|
${colors.dim}# Hash-based workflow${colors.reset}
|
|
@@ -1677,7 +1686,7 @@ ${colors.bright}Examples:${colors.reset}
|
|
|
1677
1686
|
${colors.dim}# Free-form workflow (complete section replacement)${colors.reset}
|
|
1678
1687
|
uniweb i18n init-freeform es pages/about hero
|
|
1679
1688
|
uniweb i18n init-freeform es page-ids/installation intro
|
|
1680
|
-
uniweb i18n init-freeform es
|
|
1689
|
+
uniweb i18n init-freeform es records/article getting-started
|
|
1681
1690
|
uniweb i18n status --freeform # Show free-form translation status
|
|
1682
1691
|
uniweb i18n update-hash es --all-stale # Update hashes after review
|
|
1683
1692
|
uniweb i18n move pages/docs/setup pages/getting-started
|