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,23 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Dep-free reader for the foundation ref the backend populates on a site-content
|
|
3
|
+
* document.
|
|
4
|
+
*
|
|
5
|
+
* Kept free of `@uniweb/build` so it's importable from `clone` (global, runs before a
|
|
6
|
+
* project exists) — the same constraint that keeps utils/placement.js dependency-free.
|
|
7
|
+
*
|
|
8
|
+
* The site-content `$`-document carries, on its `info` brief, the `foundation` ref the
|
|
9
|
+
* backend fills in when it wires a site. The reader is tolerant — a backend field
|
|
10
|
+
* rename should degrade, not crash — and is the single adjust-point if the wire field
|
|
11
|
+
* name settles differently. (The site's `@uniweb/folder` is NOT read here: the backend
|
|
12
|
+
* resolves it from the site-content uuid, so the framework never holds a folder uuid.)
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* The site's foundation ref — a URL or our `@ns/name@ver`. Written verbatim into
|
|
17
|
+
* site.yml; the runtime loads it as a federated module.
|
|
18
|
+
*/
|
|
19
|
+
export function extractFoundationRef(info = {}, document = {}) {
|
|
20
|
+
return (
|
|
21
|
+
info?.foundation ?? info?.foundation_name ?? document?.foundation ?? null
|
|
22
|
+
)
|
|
23
|
+
}
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Is this project a copy of another one in the same workspace?
|
|
3
|
+
*
|
|
4
|
+
* A plain directory copy carries `sync.json`, so the copy holds the ORIGINAL's site
|
|
5
|
+
* on every backend the original synced with — and its first push updates that site.
|
|
6
|
+
* `uniweb forget --all` in the copy is the fix. This is how the CLI notices a copy in
|
|
7
|
+
* which nobody ran it, before the push goes out.
|
|
8
|
+
*
|
|
9
|
+
* ## The signal, and why it is exact
|
|
10
|
+
*
|
|
11
|
+
* **Two directories in one workspace holding the same site uuid on the same
|
|
12
|
+
* backend.** Every create mints a new site, so two directories can only name one site
|
|
13
|
+
* if one got it from the other. Whichever of them pushes, the other is a stale copy
|
|
14
|
+
* of the same site waiting to overwrite it — so refusing is right from both sides.
|
|
15
|
+
*
|
|
16
|
+
* ## Why only within a workspace
|
|
17
|
+
*
|
|
18
|
+
* A teammate's clone holds the same site too, and legitimately: it IS the same
|
|
19
|
+
* project. A clone is a separate checkout, so it is never scanned. The price is the
|
|
20
|
+
* case this cannot see: a copy placed outside the workspace is byte-for-byte a clone,
|
|
21
|
+
* and nothing on disk tells them apart.
|
|
22
|
+
*
|
|
23
|
+
* ## Where it looks
|
|
24
|
+
*
|
|
25
|
+
* The workspace root and two levels below it — where sites live (`site/`, `sites/*`)
|
|
26
|
+
* and where a copy usually lands — plus this site's own siblings, for a site deeper
|
|
27
|
+
* than that. Dot-directories, `node_modules` and `dist` are skipped, and the walk is
|
|
28
|
+
* bounded, so a workspace root found somewhere unexpected costs a few milliseconds,
|
|
29
|
+
* not a disk scan.
|
|
30
|
+
*/
|
|
31
|
+
|
|
32
|
+
import { existsSync, readdirSync, realpathSync } from 'node:fs'
|
|
33
|
+
import { dirname, isAbsolute, join, relative } from 'node:path'
|
|
34
|
+
import { findWorkspaceRoot } from './workspace.js'
|
|
35
|
+
import { normalizeOrigin, readSiteIdentity } from './site-identity.js'
|
|
36
|
+
|
|
37
|
+
const SKIP = new Set(['node_modules', 'dist'])
|
|
38
|
+
const MAX_ENTRIES = 5000
|
|
39
|
+
|
|
40
|
+
const real = (p) => {
|
|
41
|
+
try {
|
|
42
|
+
return realpathSync(p)
|
|
43
|
+
} catch {
|
|
44
|
+
return p
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const within = (dir, root) => {
|
|
49
|
+
const rel = relative(root, dir)
|
|
50
|
+
return !rel.startsWith('..') && !isAbsolute(rel)
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** Directories holding a `sync.json`, `depth` levels below `dir` at most. */
|
|
54
|
+
function collect(dir, depth, out, budget) {
|
|
55
|
+
let entries
|
|
56
|
+
try {
|
|
57
|
+
entries = readdirSync(dir, { withFileTypes: true })
|
|
58
|
+
} catch {
|
|
59
|
+
return
|
|
60
|
+
}
|
|
61
|
+
for (const entry of entries) {
|
|
62
|
+
if (--budget.left < 0) return
|
|
63
|
+
if (!entry.isDirectory() || entry.name.startsWith('.') || SKIP.has(entry.name)) continue
|
|
64
|
+
const child = join(dir, entry.name)
|
|
65
|
+
if (existsSync(join(child, 'sync.json'))) out.add(child)
|
|
66
|
+
if (depth > 1) collect(child, depth - 1, out, budget)
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* The other directories in this workspace that hold this site's site on `origin`.
|
|
72
|
+
*
|
|
73
|
+
* @param {string} siteDir
|
|
74
|
+
* @param {string} origin - the backend a push is about to go to
|
|
75
|
+
* @returns {string[]} absolute paths, sorted; `[]` when the site has no identity on
|
|
76
|
+
* that backend yet (its push will create one), or it is in no workspace
|
|
77
|
+
*/
|
|
78
|
+
export function findSiteCopies(siteDir, origin) {
|
|
79
|
+
const key = normalizeOrigin(origin)
|
|
80
|
+
const mine = key ? readSiteIdentity(siteDir, key).uuid : null
|
|
81
|
+
if (!mine) return []
|
|
82
|
+
const root = findWorkspaceRoot(siteDir)
|
|
83
|
+
if (!root) return []
|
|
84
|
+
|
|
85
|
+
const candidates = new Set()
|
|
86
|
+
const budget = { left: MAX_ENTRIES }
|
|
87
|
+
if (existsSync(join(root, 'sync.json'))) candidates.add(root)
|
|
88
|
+
collect(root, 2, candidates, budget)
|
|
89
|
+
const parent = dirname(siteDir)
|
|
90
|
+
if (real(siteDir) !== real(root) && within(parent, root)) collect(parent, 1, candidates, budget)
|
|
91
|
+
|
|
92
|
+
const self = real(siteDir)
|
|
93
|
+
const found = []
|
|
94
|
+
for (const dir of candidates) {
|
|
95
|
+
if (real(dir) === self) continue
|
|
96
|
+
if (readSiteIdentity(dir, key).uuid === mine) found.push(dir)
|
|
97
|
+
}
|
|
98
|
+
return found.sort()
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* What to tell the person, as a headline and detail lines — each verb prints them
|
|
103
|
+
* with its own reporter.
|
|
104
|
+
*
|
|
105
|
+
* ⚖️ It cannot say WHICH directory is the copy: the two are identical, which is the
|
|
106
|
+
* whole problem. The person knows, because they made it.
|
|
107
|
+
*
|
|
108
|
+
* @param {string[]} copies - from findSiteCopies
|
|
109
|
+
* @param {string} origin
|
|
110
|
+
* @param {'push'|'publish'} verb
|
|
111
|
+
*/
|
|
112
|
+
export function describeSiteCopies(copies, origin, verb) {
|
|
113
|
+
const here = (dir) => relative(process.cwd(), dir) || '.'
|
|
114
|
+
return {
|
|
115
|
+
headline: `Another project in this workspace holds the same site on ${origin}:`,
|
|
116
|
+
lines: [
|
|
117
|
+
...copies.map((dir) => ` ${here(dir)}`),
|
|
118
|
+
`One of them is a copy of the other, so a ${verb} from either one updates that site.`,
|
|
119
|
+
'In the copy, run: uniweb forget --all',
|
|
120
|
+
`The copy then ${verb === 'publish' ? 'publishes' : 'pushes'} as a new site. The original needs nothing.`
|
|
121
|
+
]
|
|
122
|
+
}
|
|
123
|
+
}
|
|
@@ -0,0 +1,302 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Static collection data — the passthrough lane.
|
|
3
|
+
*
|
|
4
|
+
* A site's **schema-less** queries have no entity model, so their compiled
|
|
5
|
+
* `dist/data/**` JSON is delivered as files rather than synced as entities.
|
|
6
|
+
* This plans and uploads that set: one plan call, one object per file, each
|
|
7
|
+
* PUT to the target the backend returns.
|
|
8
|
+
*
|
|
9
|
+
* 1. PLAN — POST {apiBase}/dev/site/data-uploads/{site} with the file list
|
|
10
|
+
* ({ path, content_type, size, sha256 }). Response carries
|
|
11
|
+
* `mode` ('presigned' | 'direct'), `expires_in`, `serve_base`,
|
|
12
|
+
* and one `uploads` entry per file ({ path, method, url, headers }).
|
|
13
|
+
* 2. UPLOAD — PUT each file's bytes to its URL. Order is irrelevant; there is
|
|
14
|
+
* no entry file and no server confirm step.
|
|
15
|
+
*
|
|
16
|
+
* ⭐ **`path` is the SERVING TAIL** (`data/articles.json`), not a bookkeeping
|
|
17
|
+
* key. The file lands where it is served — which is the whole point of this
|
|
18
|
+
* lane, and why it returns no map: nothing has to record where anything went.
|
|
19
|
+
*
|
|
20
|
+
* ## Why this is not the asset lane
|
|
21
|
+
*
|
|
22
|
+
* Assets are **content-addressed, and dedup across the sites of one backend**
|
|
23
|
+
* — so the relation from a site's path to an object is many-to-one and a serve
|
|
24
|
+
* path cannot be a property of the object. *("Global" names that dedup scope; it
|
|
25
|
+
* never meant across backends — see utils/asset-upload.js.)* This lane is **per-site and
|
|
26
|
+
* path-addressed**. Sending static data through the asset store is what forced
|
|
27
|
+
* the old "data ball": one bundled asset the backend had to fetch, parse and
|
|
28
|
+
* fan out, which is the only place these bytes ever transited it.
|
|
29
|
+
*
|
|
30
|
+
* ⇒ **The backend is a PASSTHROUGH here** — it brokers the authorization, not
|
|
31
|
+
* the bytes. Hosting static content is the hosting platform's mission where an
|
|
32
|
+
* edge exists; where there is none (edgeless), the backend serves them itself,
|
|
33
|
+
* and that is the posture working rather than a fallback.
|
|
34
|
+
*
|
|
35
|
+
* ⛔ **Do not compose paths onto `serve_base`.** It is `null` on the presigned
|
|
36
|
+
* arm (prod serving is the delivery tier's URL shape, not the backend's to
|
|
37
|
+
* mint) and the gateway's data route on the direct arm. Nothing in a build
|
|
38
|
+
* needs it — the site's fetcher asks for a site-relative `/data/{path}` at
|
|
39
|
+
* runtime — so it is returned for parity and for a caller that wants to verify
|
|
40
|
+
* an upload landed.
|
|
41
|
+
*
|
|
42
|
+
* Contract ratified with the backend lane, 2026-08-18.
|
|
43
|
+
* **Wired into `publish.js` and shipped in `uniweb` 0.25.3.** Both arms of the
|
|
44
|
+
* endpoint exist (presigned landed 2026-08-18, per Diego).
|
|
45
|
+
*
|
|
46
|
+
* ⚠️ **The presigned arm has never been exercised against a real backend from
|
|
47
|
+
* here.** The branch is one line and unit-tested both ways, but a stub is not a
|
|
48
|
+
* presigning deployment — three claims diverged that way in a single day while
|
|
49
|
+
* this lane was being built. A local `uniwebd` answers `direct`, so proving it
|
|
50
|
+
* needs a presigning deployment and one real publish. **Believed correct, not
|
|
51
|
+
* demonstrated.**
|
|
52
|
+
*/
|
|
53
|
+
|
|
54
|
+
import { WORKSPACE_HEADER } from '../backend/client.js'
|
|
55
|
+
import { createHash } from 'node:crypto'
|
|
56
|
+
import { humanBytes } from './bytes.js'
|
|
57
|
+
import { fetchWithRetry, isTransientStatus } from './fetch-retry.js'
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Plan + upload a site's static collection data files.
|
|
61
|
+
*
|
|
62
|
+
* @param {object} opts
|
|
63
|
+
* @param {string} opts.apiBase - backend origin
|
|
64
|
+
* @param {string} opts.token - bearer, used on the direct arm only
|
|
65
|
+
* @param {string} opts.siteUuid - the site these files belong to (path segment)
|
|
66
|
+
* @param {string|null} [opts.workspace] - the workspace the requests name, `@handle`
|
|
67
|
+
* (`x-uniweb-workspace`) — on the plan and on every DIRECT PUT, never on a presigned
|
|
68
|
+
* one, which is self-authorizing and must carry nothing it was not signed with
|
|
69
|
+
* @param {{ data: Record<string, unknown> }|null} opts.ball - source of the set:
|
|
70
|
+
* `{ "<relpath under dist/data>": <json> }`, media refs already rewritten
|
|
71
|
+
* @param {(m: string) => void} [opts.onProgress]
|
|
72
|
+
* @returns {Promise<{ mode: string, uploaded: string[], failed: Array<{path:string,status:number,detail?:string}>, serveBase: string|null }>}
|
|
73
|
+
* **No map** — by design. The files are at their serving paths.
|
|
74
|
+
*/
|
|
75
|
+
export async function uploadSiteData({
|
|
76
|
+
apiBase,
|
|
77
|
+
token,
|
|
78
|
+
siteUuid,
|
|
79
|
+
workspace = null,
|
|
80
|
+
ball,
|
|
81
|
+
onProgress = () => {}
|
|
82
|
+
}) {
|
|
83
|
+
// A plan made with the header mints BARE PUT urls: the PUT names its workspace with
|
|
84
|
+
// the header like every other request (backend, 2026-09-23).
|
|
85
|
+
const workspaceHeader = workspace ? { [WORKSPACE_HEADER]: workspace } : {}
|
|
86
|
+
// ⛔ An EMPTY set still posts a plan, and that is the whole point of this lane
|
|
87
|
+
// being a manifest rather than a stream of files.
|
|
88
|
+
//
|
|
89
|
+
// The backend reconciles a site's data usage against what this plan declares:
|
|
90
|
+
// whatever is not in the manifest is gone, so deleting a collection stops
|
|
91
|
+
// costing on the next publish. ⭐ A plan with zero files is a STATEMENT that
|
|
92
|
+
// there are none; the ABSENCE of a request says nothing at all. Returning
|
|
93
|
+
// early here — which this did until 2026-09-01 — made "the user deleted their
|
|
94
|
+
// last schema-less collection" unexpressible on the wire, so that site kept
|
|
95
|
+
// paying for bytes it no longer served until the whole site was deleted.
|
|
96
|
+
// Nothing looked wrong at either end: no error, no warning, just a request
|
|
97
|
+
// that was never sent.
|
|
98
|
+
//
|
|
99
|
+
// Agreed both sides in channel backend↔framework; the backend's plan
|
|
100
|
+
// route accepted an empty `files` array in the same exchange (it was a 400
|
|
101
|
+
// before, which is what made the omission look like the only option).
|
|
102
|
+
const entries = Object.entries(ball?.data || {})
|
|
103
|
+
|
|
104
|
+
// One plan for the whole set. The per-request file cap counts a plan, so
|
|
105
|
+
// splitting would evade it rather than respect it; if a set ever exceeds it,
|
|
106
|
+
// the refusal is the correct outcome and the cap is the thing to fix.
|
|
107
|
+
const files = entries.map(([relPath, value]) => {
|
|
108
|
+
const bytes = Buffer.from(JSON.stringify(value))
|
|
109
|
+
return {
|
|
110
|
+
// ⚠️ The tail RELATIVE TO THE DATA ROOT — no `data/` prefix.
|
|
111
|
+
//
|
|
112
|
+
// The plan's `serve_base` already ends in `/data/`
|
|
113
|
+
// (`/gateway/site/{site}/data/`), and the runtime asks for
|
|
114
|
+
// `{config.base}/data/{name}.json` (`DATA_URL_PREFIX`). Sending
|
|
115
|
+
// `data/articles.json` would store one level too deep and serve at
|
|
116
|
+
// `…/data/data/articles.json`, which the runtime never requests.
|
|
117
|
+
//
|
|
118
|
+
// ⛔ And the failure is INVISIBLE from here: the plan succeeds, the PUT
|
|
119
|
+
// succeeds, and only a visitor's fetch 404s. Confirmed against the
|
|
120
|
+
// shipped contract's own example (`data/articles.json` with a
|
|
121
|
+
// `/data/`-bearing `serve_base`), which supersedes an earlier
|
|
122
|
+
// parenthetical that showed the prefix.
|
|
123
|
+
path: relPath,
|
|
124
|
+
content_type: 'application/json',
|
|
125
|
+
size: bytes.length,
|
|
126
|
+
sha256: createHash('sha256').update(bytes).digest('hex'),
|
|
127
|
+
bytes
|
|
128
|
+
}
|
|
129
|
+
})
|
|
130
|
+
|
|
131
|
+
const origin = apiBase.replace(/\/$/, '')
|
|
132
|
+
// `{site}` is a PATH segment, not a body field — every sibling on this
|
|
133
|
+
// controller addresses the site that way (`content/push/{site}`,
|
|
134
|
+
// `publish/{site}`, `status/{site}`). The asset lane's body-borne owner is
|
|
135
|
+
// the wrong precedent: that lane is global, this one is per-site.
|
|
136
|
+
const planRes = await fetch(
|
|
137
|
+
`${origin}/dev/site/data-uploads/${encodeURIComponent(siteUuid)}`,
|
|
138
|
+
{
|
|
139
|
+
method: 'POST',
|
|
140
|
+
headers: {
|
|
141
|
+
'Content-Type': 'application/json',
|
|
142
|
+
Authorization: `Bearer ${token}`,
|
|
143
|
+
...workspaceHeader
|
|
144
|
+
},
|
|
145
|
+
body: JSON.stringify({
|
|
146
|
+
files: files.map(({ path, content_type, size, sha256 }) => ({
|
|
147
|
+
path,
|
|
148
|
+
content_type,
|
|
149
|
+
size,
|
|
150
|
+
sha256
|
|
151
|
+
}))
|
|
152
|
+
})
|
|
153
|
+
}
|
|
154
|
+
)
|
|
155
|
+
if (!planRes.ok) {
|
|
156
|
+
const body = await planRes.text().catch(() => '')
|
|
157
|
+
// The PARSED problem document has to survive the throw. Flattening it into
|
|
158
|
+
// the message is what left the other two doors unable to branch on `reason`
|
|
159
|
+
// and printing raw JSON at users; this lane was the last one still doing it.
|
|
160
|
+
// Callers read `err.problem`; `describeDataRefusal` turns it into lines.
|
|
161
|
+
let problem = null
|
|
162
|
+
if (body) {
|
|
163
|
+
try {
|
|
164
|
+
const parsed = JSON.parse(body)
|
|
165
|
+
if (parsed && typeof parsed === 'object') problem = parsed
|
|
166
|
+
} catch {
|
|
167
|
+
/* not a problem document — prose refusal, or an upstream error page */
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
const err = new Error(
|
|
171
|
+
`site data-uploads plan rejected: HTTP ${planRes.status}${body ? ` — ${body.slice(0, 300)}` : ''}`
|
|
172
|
+
)
|
|
173
|
+
err.status = planRes.status
|
|
174
|
+
err.problem = problem
|
|
175
|
+
throw err
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
const plan = await planRes.json()
|
|
179
|
+
const targets = new Map((plan.uploads || []).map((u) => [u.path, u]))
|
|
180
|
+
// The one mode-aware bit, same rule as the code and runtime lanes: a direct
|
|
181
|
+
// PUT is a bearer-authed backend route; a presigned URL is self-authorizing
|
|
182
|
+
// and must NOT carry a foreign bearer, which can break signature validation.
|
|
183
|
+
const authHeaders =
|
|
184
|
+
plan.mode === 'presigned'
|
|
185
|
+
? {}
|
|
186
|
+
: { Authorization: `Bearer ${token}`, ...workspaceHeader }
|
|
187
|
+
|
|
188
|
+
const uploaded = []
|
|
189
|
+
const failed = []
|
|
190
|
+
for (const f of files) {
|
|
191
|
+
const target = targets.get(f.path)
|
|
192
|
+
if (!target) {
|
|
193
|
+
// A file the plan did not answer for is unaddressable. Report it; never
|
|
194
|
+
// invent a location for it.
|
|
195
|
+
failed.push({
|
|
196
|
+
path: f.path,
|
|
197
|
+
status: 0,
|
|
198
|
+
detail: 'no upload target in plan'
|
|
199
|
+
})
|
|
200
|
+
continue
|
|
201
|
+
}
|
|
202
|
+
try {
|
|
203
|
+
// Relative on the direct arm, absolute on presigned — `new URL` resolves
|
|
204
|
+
// both against the origin.
|
|
205
|
+
// ⭐ **Retried.** A single connection-level failure used to fail the whole
|
|
206
|
+
// verb after every other file had already gone up. The PUT is idempotent
|
|
207
|
+
// here — same bytes, same target, verified by `x-uniweb-sha256` on receipt
|
|
208
|
+
// — so repeating it is safe by construction, which is why this opts in to
|
|
209
|
+
// `retryOnStatus` rather than inheriting it.
|
|
210
|
+
//
|
|
211
|
+
// ⚠️ 120s per attempt, not the helper's 30s default: this bounds the time
|
|
212
|
+
// to send a whole BODY, and aborting a legitimately slow upload only to
|
|
213
|
+
// retry it is worse than not timing out at all.
|
|
214
|
+
const res = await fetchWithRetry(new URL(target.url, origin), {
|
|
215
|
+
method: target.method || 'PUT',
|
|
216
|
+
headers: {
|
|
217
|
+
'Content-Type': 'application/json',
|
|
218
|
+
...(target.headers || {}),
|
|
219
|
+
...authHeaders
|
|
220
|
+
},
|
|
221
|
+
body: f.bytes
|
|
222
|
+
}, { timeoutMs: 120_000, retryOnStatus: isTransientStatus })
|
|
223
|
+
if (res.ok) {
|
|
224
|
+
uploaded.push(f.path)
|
|
225
|
+
onProgress(`${f.path}`)
|
|
226
|
+
} else {
|
|
227
|
+
failed.push({ path: f.path, status: res.status })
|
|
228
|
+
}
|
|
229
|
+
} catch (err) {
|
|
230
|
+
failed.push({ path: f.path, status: 0, detail: err.message })
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
return {
|
|
235
|
+
mode: plan.mode || 'direct',
|
|
236
|
+
uploaded,
|
|
237
|
+
failed,
|
|
238
|
+
serveBase: plan.serve_base || null
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
/**
|
|
243
|
+
* Turn a typed data-uploads refusal into user-facing lines, or null when there is
|
|
244
|
+
* no typed `reason` (⇒ fall through to the generic message, degrading rather than
|
|
245
|
+
* swallowing).
|
|
246
|
+
*
|
|
247
|
+
* ⭐ The THIRD door, and the last to get one. `/dev/assets` has
|
|
248
|
+
* `describeAssetRefusal`, `POST /dev/site` has `describeCreateRefusal`, and this
|
|
249
|
+
* lane threw prose with the JSON inlined until 2026-09-01 — which is the failure
|
|
250
|
+
* the other two describers exist to prevent, so leaving it was just an untreated
|
|
251
|
+
* instance of a solved problem.
|
|
252
|
+
*
|
|
253
|
+
* ⛔ Branch on `reason`, never the status: `507` alone cannot be told from any
|
|
254
|
+
* other `507` and carries none of the numbers, and `detail` is prose the backend
|
|
255
|
+
* may reword.
|
|
256
|
+
*
|
|
257
|
+
* ⚖️ **The advice DIVERGES from the asset lane's, and that divergence is the whole
|
|
258
|
+
* reason this is a separate function rather than a reused one.** On the asset lane
|
|
259
|
+
* removing an image frees nothing — freeing is entity-deletion-granular. Here the
|
|
260
|
+
* publish declares the COMPLETE set of schema-less data files every time, so
|
|
261
|
+
* dropping a collection and re-publishing is a real way to stop paying for it.
|
|
262
|
+
* Telling a data user "editing content frees nothing" would be false, and telling
|
|
263
|
+
* an asset user "just remove it" would be worse.
|
|
264
|
+
*
|
|
265
|
+
* @param {Error & { problem?: object|null }} err
|
|
266
|
+
* @returns {{ headline: string, notes: string[] } | null}
|
|
267
|
+
*/
|
|
268
|
+
export function describeDataRefusal(err) {
|
|
269
|
+
const p = err?.problem
|
|
270
|
+
const reason = p?.reason
|
|
271
|
+
if (typeof reason !== 'string') return null
|
|
272
|
+
|
|
273
|
+
if (reason === 'storage_quota_exceeded') {
|
|
274
|
+
const notes = []
|
|
275
|
+
const used = humanBytes(p.used_bytes)
|
|
276
|
+
const limit = humanBytes(p.limit_bytes)
|
|
277
|
+
const needed = humanBytes(p.needed_bytes)
|
|
278
|
+
if (used) notes.push(` Used: ${used}`)
|
|
279
|
+
if (limit) notes.push(` Limit: ${limit}`)
|
|
280
|
+
if (needed) notes.push(` This publish adds: ${needed}`)
|
|
281
|
+
notes.push(
|
|
282
|
+
'Every publish declares the full set of schema-less data files, so removing'
|
|
283
|
+
)
|
|
284
|
+
notes.push(
|
|
285
|
+
'a collection and re-publishing stops it counting. Deleting a site or entity'
|
|
286
|
+
)
|
|
287
|
+
notes.push('frees space too.')
|
|
288
|
+
return {
|
|
289
|
+
headline:
|
|
290
|
+
"Storage quota reached — the site owner's workspace cannot take on more record data.",
|
|
291
|
+
notes
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
// An unrecognised reason still beats a status dump: name it, and let the
|
|
296
|
+
// backend's own prose follow when it sent any.
|
|
297
|
+
const detail = typeof p.detail === 'string' ? p.detail : ''
|
|
298
|
+
return {
|
|
299
|
+
headline: `Record data upload refused by the backend (${reason}).`,
|
|
300
|
+
notes: detail ? [` ${detail}`] : []
|
|
301
|
+
}
|
|
302
|
+
}
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The project's backend-minted identity — read from `sync.json`, never from `site.yml`.
|
|
3
|
+
*
|
|
4
|
+
* ⭐ **`site.yml` holds nothing a backend minted, as of 2026-09-20.** It carried
|
|
5
|
+
* `$uuid`, `$org` and `$backend`; all three moved to
|
|
6
|
+
* `sync.json::backends.<origin>.site`, keyed by the backend that minted them.
|
|
7
|
+
* Spec: `kb/framework/reference/sync-json.md`. Why:
|
|
8
|
+
* `kb/framework/plans/backend-scoped-project-state.md`.
|
|
9
|
+
*
|
|
10
|
+
* ## ⛔ What is GONE, and why nothing replaces it
|
|
11
|
+
*
|
|
12
|
+
* `assertSiteBackendScope` refused a command whose resolved origin disagreed with
|
|
13
|
+
* the project's recorded `$backend`. It existed because ONE `$uuid` sat in
|
|
14
|
+
* `site.yml` with no way to say which backend it was for, so it could be read
|
|
15
|
+
* against the wrong one and sent there.
|
|
16
|
+
*
|
|
17
|
+
* ⭐ **Keyed by origin that is unrepresentable.** A command for backend B reads B's
|
|
18
|
+
* section and finds B's ids or nothing at all. There is no configuration in which
|
|
19
|
+
* A's identity reaches B, so there is no mismatch to detect, no stop to print, and
|
|
20
|
+
* no accepted false positive to live with. The guard is not replaced; the shape it
|
|
21
|
+
* guarded against stopped existing.
|
|
22
|
+
*
|
|
23
|
+
* `recordSiteBackend` and `$backend` went with it: the store's KEY is the scope.
|
|
24
|
+
*
|
|
25
|
+
* ## ⛔ NO STATIC `@uniweb/build` IMPORT
|
|
26
|
+
*
|
|
27
|
+
* This module is reachable from the CLI's startup graph, and `@uniweb/build` is an
|
|
28
|
+
* OPTIONAL PEER — a static import makes `uniweb --version` die with
|
|
29
|
+
* ERR_MODULE_NOT_FOUND on a global install (`test/smoke-startup.test.js` catches it).
|
|
30
|
+
*
|
|
31
|
+
* ⭐ **`sync.json` is plain JSON, so reading it needs no parser and no dependency.**
|
|
32
|
+
* That is the whole reason the format is JSON rather than YAML: `site.yml`'s
|
|
33
|
+
* equivalent needed a hand-rolled dependency-free fallback WRITER to survive the
|
|
34
|
+
* same constraint. Writes go through `@uniweb/build/uwx`'s store, which is the one
|
|
35
|
+
* implementation; this file only reads.
|
|
36
|
+
*/
|
|
37
|
+
|
|
38
|
+
import { existsSync, readFileSync } from 'node:fs'
|
|
39
|
+
import { join } from 'node:path'
|
|
40
|
+
import { DEFAULT_BACKEND_ORIGIN } from './config.js'
|
|
41
|
+
|
|
42
|
+
/** A bare origin with no trailing slash, or null when unparseable. */
|
|
43
|
+
export function normalizeOrigin(value) {
|
|
44
|
+
if (typeof value !== 'string' || !value.trim()) return null
|
|
45
|
+
try {
|
|
46
|
+
// http(s) only — `localhost:8080` parses as a scheme with the origin "null".
|
|
47
|
+
const u = new URL(value)
|
|
48
|
+
return u.protocol === 'http:' || u.protocol === 'https:' ? u.origin : null
|
|
49
|
+
} catch {
|
|
50
|
+
return null
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* `sync.json`'s backends map, or `{}`. Read-only, dependency-free — see the header.
|
|
56
|
+
*
|
|
57
|
+
* ⚠️ A second READER of a format whose writer lives in `@uniweb/build`. Deliberate,
|
|
58
|
+
* and bounded: it parses one object and normalizes its keys. The shape it assumes is
|
|
59
|
+
* exactly what `sync-store.js` writes, and `test/two-backends.test.js` drives both.
|
|
60
|
+
*/
|
|
61
|
+
function readBackends(siteDir) {
|
|
62
|
+
const p = join(siteDir, 'sync.json')
|
|
63
|
+
if (!existsSync(p)) return {}
|
|
64
|
+
try {
|
|
65
|
+
const parsed = JSON.parse(readFileSync(p, 'utf8'))
|
|
66
|
+
const backends = parsed?.backends
|
|
67
|
+
if (!backends || typeof backends !== 'object' || Array.isArray(backends)) return {}
|
|
68
|
+
const out = {}
|
|
69
|
+
for (const [origin, state] of Object.entries(backends)) {
|
|
70
|
+
const key = normalizeOrigin(origin)
|
|
71
|
+
if (key && state && typeof state === 'object') out[key] = state
|
|
72
|
+
}
|
|
73
|
+
return out
|
|
74
|
+
} catch {
|
|
75
|
+
return {}
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* The identity this project holds for ONE backend.
|
|
81
|
+
*
|
|
82
|
+
* @param {string} siteDir
|
|
83
|
+
* @param {string} origin
|
|
84
|
+
* @returns {{ uuid: string|null, org: string|null }}
|
|
85
|
+
*/
|
|
86
|
+
export function readSiteIdentity(siteDir, origin) {
|
|
87
|
+
const key = normalizeOrigin(origin)
|
|
88
|
+
const site = key ? readBackends(siteDir)[key]?.site : null
|
|
89
|
+
const str = (v) => (typeof v === 'string' && v.trim() ? v.trim() : null)
|
|
90
|
+
return { uuid: str(site?.uuid), org: str(site?.org) }
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** Every backend this project has synced with, sorted. */
|
|
94
|
+
export function syncedBackends(siteDir) {
|
|
95
|
+
return Object.keys(readBackends(siteDir)).sort()
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* The backends this project HAS synced with, when `origin` is not one of them — or null.
|
|
100
|
+
*
|
|
101
|
+
* Every backend verb goes to the backend the user is logged in to *[Diego, 2026-09-21]*,
|
|
102
|
+
* so it can land where this project has no site while it has one elsewhere. A push
|
|
103
|
+
* there CREATES a second site rather than updating the one the project knows, and the
|
|
104
|
+
* owner question it asks does not say why — this is what lets the verb say it first.
|
|
105
|
+
*
|
|
106
|
+
* @param {string} siteDir
|
|
107
|
+
* @param {string} origin - where the verb resolved to
|
|
108
|
+
* @returns {string[]|null}
|
|
109
|
+
*/
|
|
110
|
+
export function syncedElsewhere(siteDir, origin) {
|
|
111
|
+
const key = normalizeOrigin(origin)
|
|
112
|
+
const known = syncedBackends(siteDir)
|
|
113
|
+
if (!key || !known.length || known.includes(key)) return null
|
|
114
|
+
return known
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* The heads-up for `syncedElsewhere`, as lines — each verb prints them with its own
|
|
119
|
+
* reporter. Only worth saying when the verb was not TOLD where to go: a backend named with
|
|
120
|
+
* UNIWEB_SERVER is already a decision.
|
|
121
|
+
*
|
|
122
|
+
* ⚖️ Worded for where the verb GOES, not for why: it is the logged-in backend, or — logged
|
|
123
|
+
* in nowhere — the default one, where the login it is about to ask for will be.
|
|
124
|
+
*
|
|
125
|
+
* @param {string[]} known
|
|
126
|
+
* @param {string} origin
|
|
127
|
+
* @param {'push'|'publish'} verb
|
|
128
|
+
*/
|
|
129
|
+
export function describeSyncedElsewhere(known, origin, verb) {
|
|
130
|
+
const where = known.length === 1 ? `site is on ${known[0]}` : `sites are on ${known.join(', ')}`
|
|
131
|
+
return [
|
|
132
|
+
`This project's ${where} — not on ${origin}, where this ${verb} goes.`,
|
|
133
|
+
`It creates a new site there. To ${verb} to ${known.length === 1 ? 'that one' : 'one of those'} instead: uniweb login --server <url>`
|
|
134
|
+
]
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
export { DEFAULT_BACKEND_ORIGIN }
|