uniweb 0.59.1 → 0.60.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 +3 -3
- package/partials/agents.md +6 -4
- package/src/backend/asset-download.js +6 -4
- package/src/backend/client.js +38 -91
- package/src/backend/foundation-bring-along.js +12 -12
- package/src/backend/service-request.js +12 -8
- package/src/backend/site-sync.js +243 -164
- package/src/commands/clone.js +42 -39
- package/src/commands/deploy.js +45 -27
- package/src/commands/forget.js +197 -0
- package/src/commands/publish.js +102 -73
- package/src/commands/pull.js +58 -46
- package/src/commands/push.js +69 -53
- package/src/commands/refresh.js +22 -29
- package/src/commands/register.js +10 -14
- package/src/commands/status.js +13 -11
- package/src/commands/sync.js +0 -1
- package/src/framework-index.json +4 -4
- package/src/index.js +100 -75
- 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/interactive.js +38 -0
- package/src/utils/records-guard.js +3 -2
- package/src/utils/registry-auth.js +150 -28
- 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
|
@@ -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
|
+
}
|
|
@@ -19,9 +19,10 @@
|
|
|
19
19
|
*
|
|
20
20
|
* ## Why this is not the asset lane
|
|
21
21
|
*
|
|
22
|
-
* Assets are **
|
|
23
|
-
*
|
|
24
|
-
*
|
|
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
|
|
25
26
|
* path-addressed**. Sending static data through the asset store is what forced
|
|
26
27
|
* the old "data ball": one bundled asset the backend had to fetch, parse and
|
|
27
28
|
* fan out, which is the only place these bytes ever transited it.
|
|
@@ -1,347 +1,137 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* The project's
|
|
2
|
+
* The project's backend-minted identity — read from `sync.json`, never from `site.yml`.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
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`.
|
|
5
9
|
*
|
|
6
|
-
*
|
|
7
|
-
* · every collection record `$uuid` in its own source file (the pull-side match key)
|
|
8
|
-
* · `assets.json` local asset path → content-addressed id
|
|
9
|
-
* · `.uniweb/sync-cache.json` item uuids, hashes, base versions
|
|
10
|
+
* ## ⛔ What is GONE, and why nothing replaces it
|
|
10
11
|
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
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.
|
|
15
16
|
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
*
|
|
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.
|
|
21
22
|
*
|
|
22
|
-
*
|
|
23
|
-
*
|
|
24
|
-
*
|
|
25
|
-
*
|
|
26
|
-
*
|
|
27
|
-
*
|
|
28
|
-
*
|
|
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.
|
|
29
36
|
*/
|
|
30
37
|
|
|
31
|
-
import { existsSync, readFileSync
|
|
32
|
-
import { join
|
|
33
|
-
import yaml from 'js-yaml'
|
|
38
|
+
import { existsSync, readFileSync } from 'node:fs'
|
|
39
|
+
import { join } from 'node:path'
|
|
34
40
|
import { DEFAULT_BACKEND_ORIGIN } from './config.js'
|
|
35
41
|
|
|
36
|
-
// ⛔ NO STATIC `@uniweb/build` IMPORT IN THIS FILE — it is an OPTIONAL PEER, and this
|
|
37
|
-
// module is reachable from the CLI's startup graph (`index.js` imports `clone` eagerly,
|
|
38
|
-
// and `clone` imports this). A static import here makes `uniweb --version` die with
|
|
39
|
-
// ERR_MODULE_NOT_FOUND on a global install that has no build package:
|
|
40
|
-
//
|
|
41
|
-
// $ npm i -g uniweb && uniweb --version
|
|
42
|
-
// Error [ERR_MODULE_NOT_FOUND]: Cannot find package '@uniweb/build'
|
|
43
|
-
//
|
|
44
|
-
// `test/smoke-startup.test.js` catches this structurally — and did, on the commit that
|
|
45
|
-
// introduced this file. The writer below therefore imports lazily, which also keeps the
|
|
46
|
-
// cost off every startup that never writes. Command modules may import build statically;
|
|
47
|
-
// anything a `utils/` leaf pulls in cannot.
|
|
48
|
-
|
|
49
42
|
/** A bare origin with no trailing slash, or null when unparseable. */
|
|
50
43
|
export function normalizeOrigin(value) {
|
|
51
44
|
if (typeof value !== 'string' || !value.trim()) return null
|
|
52
45
|
try {
|
|
53
|
-
|
|
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
|
|
54
49
|
} catch {
|
|
55
50
|
return null
|
|
56
51
|
}
|
|
57
52
|
}
|
|
58
53
|
|
|
59
54
|
/**
|
|
60
|
-
*
|
|
61
|
-
* optional — a project may be unsynced (no `uuid`), on the default backend (no
|
|
62
|
-
* `backend`), or personally owned (no `org`).
|
|
55
|
+
* `sync.json`'s backends map, or `{}`. Read-only, dependency-free — see the header.
|
|
63
56
|
*
|
|
64
|
-
* ⚠️
|
|
65
|
-
*
|
|
66
|
-
*
|
|
67
|
-
* that one guard solves better). Consolidating them is separable cleanup.
|
|
68
|
-
*
|
|
69
|
-
* @param {string} siteDir
|
|
70
|
-
* @returns {{ uuid: string|null, backend: string|null, org: string|null }}
|
|
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.
|
|
71
60
|
*/
|
|
72
|
-
|
|
73
|
-
const
|
|
74
|
-
if (!existsSync(
|
|
75
|
-
let y
|
|
61
|
+
function readBackends(siteDir) {
|
|
62
|
+
const p = join(siteDir, 'sync.json')
|
|
63
|
+
if (!existsSync(p)) return {}
|
|
76
64
|
try {
|
|
77
|
-
|
|
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
|
|
78
74
|
} catch {
|
|
79
|
-
|
|
80
|
-
// caller here is a guard or a default; none of them should be the reason a command
|
|
81
|
-
// dies, and a malformed site.yml has its own, better error elsewhere.
|
|
82
|
-
return { uuid: null, backend: null, org: null }
|
|
83
|
-
}
|
|
84
|
-
if (!y || typeof y !== 'object') return { uuid: null, backend: null, org: null }
|
|
85
|
-
const str = (v) => (typeof v === 'string' && v.trim() ? v.trim() : null)
|
|
86
|
-
return {
|
|
87
|
-
uuid: str(y.$uuid),
|
|
88
|
-
backend: normalizeOrigin(y.$backend),
|
|
89
|
-
org: str(y.$org)
|
|
75
|
+
return {}
|
|
90
76
|
}
|
|
91
77
|
}
|
|
92
78
|
|
|
93
79
|
/**
|
|
94
|
-
* The
|
|
95
|
-
*
|
|
96
|
-
* Use this rather than `readSiteIdentity().backend` wherever an absent value means the
|
|
97
|
-
* default — which is everywhere except a "was it recorded?" question.
|
|
98
|
-
*
|
|
99
|
-
* ⛔ **NOT for the origin ladder.** `resolveBackendOrigin`'s `siteScope` tier must receive
|
|
100
|
-
* the RAW `readSiteIdentity().backend`, which is null when nothing was recorded. Handing
|
|
101
|
-
* it this defaulted value would make the tier fire for EVERY project, and since it sits
|
|
102
|
-
* above the session it would shadow `uniweb login --backend <local>` on any project
|
|
103
|
-
* without `$backend` — i.e. break local development for the 98% that never record one.
|
|
104
|
-
* "Absent means the default" is the right rule for a comparison and the wrong one for a
|
|
105
|
-
* precedence chain, where absent has to mean "defer to the next tier".
|
|
80
|
+
* The identity this project holds for ONE backend.
|
|
106
81
|
*
|
|
107
82
|
* @param {string} siteDir
|
|
108
|
-
* @
|
|
83
|
+
* @param {string} origin
|
|
84
|
+
* @returns {{ uuid: string|null, org: string|null }}
|
|
109
85
|
*/
|
|
110
|
-
export function
|
|
111
|
-
|
|
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) }
|
|
112
91
|
}
|
|
113
92
|
|
|
114
|
-
/**
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
* A no-op for the default backend (see the ⛔ above) and a no-op when the value is
|
|
118
|
-
* already what we would write, so a re-push never dirties `git status`.
|
|
119
|
-
*
|
|
120
|
-
* @param {string} siteDir
|
|
121
|
-
* @param {string} origin - the backend the site was just created on
|
|
122
|
-
* @param {object} [deps]
|
|
123
|
-
* @param {() => Promise<object>} [deps.loadUwx] - injected module loader. Exists so the
|
|
124
|
-
* two failure branches below can be TESTED: both are about which `@uniweb/build`
|
|
125
|
-
* happens to be on disk, which a test cannot otherwise vary — and the too-old
|
|
126
|
-
* branch is precisely the one that used to fail silently.
|
|
127
|
-
* @returns {Promise<string|null>} the origin recorded, or null when nothing was written
|
|
128
|
-
*/
|
|
129
|
-
export async function recordSiteBackend(siteDir, origin, deps = {}) {
|
|
130
|
-
const norm = normalizeOrigin(origin)
|
|
131
|
-
if (!norm || norm === DEFAULT_BACKEND_ORIGIN) return null
|
|
132
|
-
if (readSiteIdentity(siteDir).backend === norm) return null
|
|
133
|
-
const loadUwx = deps.loadUwx || (() => import('@uniweb/build/uwx'))
|
|
134
|
-
let mod
|
|
135
|
-
try {
|
|
136
|
-
// Lazy by necessity, not by taste — see the header.
|
|
137
|
-
mod = await loadUwx()
|
|
138
|
-
} catch {
|
|
139
|
-
// ⛔ NO `@uniweb/build` — and this is the COMMON case on the path that needs it
|
|
140
|
-
// most, not the rare one this branch used to assume.
|
|
141
|
-
//
|
|
142
|
-
// `clone` calls us straight after scaffolding and BEFORE `pnpm install`, so the
|
|
143
|
-
// site has no `node_modules` yet; under `npx uniweb@latest` the CLI's own tree
|
|
144
|
-
// may not carry build either (it is an optional peer). The import therefore
|
|
145
|
-
// fails on a FRESH CLONE essentially always, and returning null wrote nothing.
|
|
146
|
-
//
|
|
147
|
-
// The cost is not cosmetic: the clone then holds a `$uuid` minted by the
|
|
148
|
-
// `--backend` origin it was given, with nothing recording that origin, so the
|
|
149
|
-
// scope guard infers the DEFAULT backend and stops the very next command with
|
|
150
|
-
// "this project's stored identity belongs to https://uniweb.app". The origin was
|
|
151
|
-
// on the command line the whole time.
|
|
152
|
-
// Measured by the backend lane 2026-09-18: `0.48.5` recorded `$backend`,
|
|
153
|
-
// `0.57.0` did not, and their clone was unusable until they added the line by hand.
|
|
154
|
-
//
|
|
155
|
-
// ⇒ Fall back to a dependency-free write. `js-yaml` is the CLI's own direct
|
|
156
|
-
// dependency, so this path needs nothing installed in the project.
|
|
157
|
-
return writeSiteBackendFallback(siteDir, norm)
|
|
158
|
-
}
|
|
159
|
-
|
|
160
|
-
// ⚠️ PRESENT BUT TOO OLD is a different problem, and it must not look like the one
|
|
161
|
-
// above. `@uniweb/build` gained `writeSiteBackend` in 0.25.3; against an older copy the
|
|
162
|
-
// import SUCCEEDS and the export is `undefined`, so calling it throws a TypeError that
|
|
163
|
-
// a blanket catch would swallow — leaving the scope silently unrecorded on a project
|
|
164
|
-
// that will later be stopped by the guard and told to add `$backend` by hand. Since the
|
|
165
|
-
// CLI declares build as a peer at a caret range, a lockfile pinned to an older patch
|
|
166
|
-
// reaches exactly this state on a CLI-only upgrade. Say so once.
|
|
167
|
-
if (typeof mod.writeSiteBackend !== 'function') {
|
|
168
|
-
console.error(
|
|
169
|
-
`\x1b[33m⚠\x1b[0m This project's @uniweb/build is too old to record which backend it syncs with — upgrade it (\`npx uniweb@latest update\`), or add \`$backend: ${norm}\` to site.yml.`
|
|
170
|
-
)
|
|
171
|
-
return null
|
|
172
|
-
}
|
|
173
|
-
|
|
174
|
-
try {
|
|
175
|
-
mod.writeSiteBackend(siteDir, norm)
|
|
176
|
-
return norm
|
|
177
|
-
} catch {
|
|
178
|
-
// Same rule as `recordSiteOrg`: the uuid is the load-bearing write. Losing the scope
|
|
179
|
-
// note must never fail a create that already succeeded on the backend — the guard
|
|
180
|
-
// degrades to the pre-`$backend` behaviour, which is what everyone had until now.
|
|
181
|
-
return null
|
|
182
|
-
}
|
|
93
|
+
/** Every backend this project has synced with, sorted. */
|
|
94
|
+
export function syncedBackends(siteDir) {
|
|
95
|
+
return Object.keys(readBackends(siteDir)).sort()
|
|
183
96
|
}
|
|
184
97
|
|
|
185
98
|
/**
|
|
186
|
-
*
|
|
187
|
-
* project whose dependencies are not installed yet (see `recordSiteBackend`).
|
|
99
|
+
* The backends this project HAS synced with, when `origin` is not one of them — or null.
|
|
188
100
|
*
|
|
189
|
-
*
|
|
190
|
-
*
|
|
191
|
-
*
|
|
192
|
-
*
|
|
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.
|
|
193
105
|
*
|
|
194
|
-
* @
|
|
106
|
+
* @param {string} siteDir
|
|
107
|
+
* @param {string} origin - where the verb resolved to
|
|
108
|
+
* @returns {string[]|null}
|
|
195
109
|
*/
|
|
196
|
-
function
|
|
197
|
-
const
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
} catch {
|
|
202
|
-
return null // no site.yml — nothing to annotate, and we do not create one
|
|
203
|
-
}
|
|
204
|
-
try {
|
|
205
|
-
// Quote through js-yaml rather than by hand: an origin is plain today, but the
|
|
206
|
-
// value is not ours to assume. Replace an existing key, else prepend.
|
|
207
|
-
const scalar = yaml.dump(norm, { lineWidth: -1 }).trim()
|
|
208
|
-
const next = /^\$backend:/m.test(text)
|
|
209
|
-
? text.replace(/^\$backend:.*$/m, `$backend: ${scalar}`)
|
|
210
|
-
: `$backend: ${scalar}\n` + text
|
|
211
|
-
const parsed = yaml.load(next)
|
|
212
|
-
if (!parsed || typeof parsed !== 'object' || parsed.$backend !== norm) return null
|
|
213
|
-
writeFileSync(file, next)
|
|
214
|
-
return norm
|
|
215
|
-
} catch {
|
|
216
|
-
return null
|
|
217
|
-
}
|
|
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
|
|
218
115
|
}
|
|
219
116
|
|
|
220
117
|
/**
|
|
221
|
-
*
|
|
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` the
|
|
120
|
+
* user typed is already a decision.
|
|
222
121
|
*
|
|
223
|
-
*
|
|
224
|
-
*
|
|
225
|
-
* This is the third leg and it is categorically worse: the stored surface is foreign, so
|
|
226
|
-
* proceeding sends one backend's identity to another. The observed outcomes are a 404
|
|
227
|
-
* whose stock advice destroys the binding, and — on the record lane — a hard refusal
|
|
228
|
-
* (*"item uuids are globally unique; cross-entity move is not supported"*).
|
|
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.
|
|
229
124
|
*
|
|
230
|
-
*
|
|
231
|
-
*
|
|
232
|
-
*
|
|
233
|
-
* @param {string} siteDir
|
|
234
|
-
* @param {string} origin - the backend this command resolved to
|
|
235
|
-
* @returns {{ ok: true } | { ok: false, message: string, hint: string[] }}
|
|
125
|
+
* @param {string[]} known
|
|
126
|
+
* @param {string} origin
|
|
127
|
+
* @param {'push'|'publish'} verb
|
|
236
128
|
*/
|
|
237
|
-
export function
|
|
238
|
-
const
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
if (!uuid) return { ok: true }
|
|
243
|
-
|
|
244
|
-
const target = normalizeOrigin(origin)
|
|
245
|
-
const bound = backend || DEFAULT_BACKEND_ORIGIN
|
|
246
|
-
if (!target || target === bound) return { ok: true }
|
|
247
|
-
|
|
248
|
-
const hint = [
|
|
249
|
-
`site.yml::$uuid (${uuid}) was minted by ${bound}, and the record uuids, assets.json`,
|
|
250
|
-
'and .uniweb/sync-cache.json are scoped to it too. Sending them elsewhere is refused.',
|
|
251
|
-
'',
|
|
252
|
-
`To work with ${bound}: uniweb login --backend ${bound} (or pass --backend ${bound})`,
|
|
253
|
-
`To move this project to ${target}, it becomes a NEW site there — clear $uuid, $org and`,
|
|
254
|
-
'$backend from site.yml and delete .uniweb/ and assets.json first.'
|
|
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 --backend <url>`
|
|
255
134
|
]
|
|
256
|
-
|
|
257
|
-
// ⚠️ THE ONE FALSE POSITIVE, and it prints its own fix.
|
|
258
|
-
//
|
|
259
|
-
// `$backend` is omitted for the default backend, so an ABSENT value is ambiguous: it
|
|
260
|
-
// means "the default" for anything written under this scheme, and "unknown" for a
|
|
261
|
-
// project that synced to a non-default backend BEFORE the key existed. This branch
|
|
262
|
-
// reads absent as the default, so that second project is stopped and told it belongs
|
|
263
|
-
// to a backend it never used.
|
|
264
|
-
//
|
|
265
|
-
// Accepted deliberately rather than softened to a warning, because the alternative is
|
|
266
|
-
// worse in the case that matters: warning-on-absent would leave every DEFAULT-backend
|
|
267
|
-
// project — the 98% — unprotected forever, since those never record the key. Stopping
|
|
268
|
-
// on a guess is only tolerable when the guess prints the one-line correction, so it
|
|
269
|
-
// does. (The population at risk is also near-empty by construction: pre-`$backend`
|
|
270
|
-
// projects on a non-default backend, at a moment when no such backend is running.)
|
|
271
|
-
if (!backend) {
|
|
272
|
-
hint.push(
|
|
273
|
-
'',
|
|
274
|
-
`If this project actually syncs with ${target}, nothing recorded that — say so once:`,
|
|
275
|
-
` add $backend: ${target} to site.yml, and this stops asking.`
|
|
276
|
-
)
|
|
277
|
-
}
|
|
278
|
-
|
|
279
|
-
return {
|
|
280
|
-
ok: false,
|
|
281
|
-
message: `This project's stored identity belongs to ${bound}, but this command targets ${target}.`,
|
|
282
|
-
hint
|
|
283
|
-
}
|
|
284
135
|
}
|
|
285
136
|
|
|
286
|
-
|
|
287
|
-
* The `$backend` of the site project `startDir` sits in — for verbs that are NOT site
|
|
288
|
-
* verbs and so never resolve a site directory of their own.
|
|
289
|
-
*
|
|
290
|
-
* ⛔ **This does NOT feed the origin ladder, deliberately.** `login` writes a
|
|
291
|
-
* MACHINE-WIDE session (`~/.uniweb/registry-auth.json`), not a per-project one, so
|
|
292
|
-
* letting whichever directory you happen to stand in decide which backend you
|
|
293
|
-
* authenticate against would be a silent surprise — the same class of surprise
|
|
294
|
-
* `$backend` exists to remove. What this enables is a **notice**: the project says
|
|
295
|
-
* where it belongs, so we say so before authenticating somewhere else.
|
|
296
|
-
*
|
|
297
|
-
* ⭐ **Conservative by construction — it answers only when there is exactly ONE
|
|
298
|
-
* candidate.** A workspace of several sites has no single answer, and a confident
|
|
299
|
-
* *"did you mean localhost?"* aimed at the wrong one of three sites is worse than
|
|
300
|
-
* saying nothing. Ambiguity returns null and the caller stays quiet.
|
|
301
|
-
*
|
|
302
|
-
* ⚠️ Build-free, like everything else in this file — `login` is a STANDALONE command
|
|
303
|
-
* that must work outside a project, where `@uniweb/build` is not installed. That is why
|
|
304
|
-
* this cannot reuse `resolveSiteDir` (`commands/deploy.js`), which pulls build in.
|
|
305
|
-
*
|
|
306
|
-
* @param {string} startDir
|
|
307
|
-
* @returns {{ siteDir: string, backend: string }|null}
|
|
308
|
-
*/
|
|
309
|
-
export function findNearbySiteBackend(startDir) {
|
|
310
|
-
// 1. Walk UP for the site we are standing in or under. Bounded: a `site.yml` more
|
|
311
|
-
// than a few levels above is not "the project you are in", it is a coincidence,
|
|
312
|
-
// and at the filesystem root it would be someone else's entirely.
|
|
313
|
-
let dir = startDir
|
|
314
|
-
for (let i = 0; i < 4; i++) {
|
|
315
|
-
if (existsSync(join(dir, 'site.yml'))) {
|
|
316
|
-
const { backend } = readSiteIdentity(dir)
|
|
317
|
-
return backend ? { siteDir: dir, backend } : null
|
|
318
|
-
}
|
|
319
|
-
const up = dirname(dir)
|
|
320
|
-
if (up === dir) break
|
|
321
|
-
dir = up
|
|
322
|
-
}
|
|
323
|
-
|
|
324
|
-
// 2. Standing AT a project root, the site is one level down — `site/` in the default
|
|
325
|
-
// layout, or a lone entry under `sites/`. Two or more candidates is a workspace,
|
|
326
|
-
// which is exactly the ambiguity above.
|
|
327
|
-
const candidates = []
|
|
328
|
-
if (existsSync(join(startDir, 'site', 'site.yml')))
|
|
329
|
-
candidates.push(join(startDir, 'site'))
|
|
330
|
-
const sitesDir = join(startDir, 'sites')
|
|
331
|
-
if (existsSync(sitesDir)) {
|
|
332
|
-
let entries = []
|
|
333
|
-
try {
|
|
334
|
-
entries = readdirSync(sitesDir, { withFileTypes: true })
|
|
335
|
-
} catch {
|
|
336
|
-
entries = []
|
|
337
|
-
}
|
|
338
|
-
for (const e of entries) {
|
|
339
|
-
if (!e.isDirectory()) continue
|
|
340
|
-
const d = join(sitesDir, e.name)
|
|
341
|
-
if (existsSync(join(d, 'site.yml'))) candidates.push(d)
|
|
342
|
-
}
|
|
343
|
-
}
|
|
344
|
-
if (candidates.length !== 1) return null
|
|
345
|
-
const { backend } = readSiteIdentity(candidates[0])
|
|
346
|
-
return backend ? { siteDir: candidates[0], backend } : null
|
|
347
|
-
}
|
|
137
|
+
export { DEFAULT_BACKEND_ORIGIN }
|