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.
@@ -32,9 +32,9 @@
32
32
  * - stored in a register-scoped slot (~/.uniweb/registry-auth.json) so it can
33
33
  * never clobber the legacy token publish/deploy rely on.
34
34
  *
35
- * Token resolution for `register` (the `--token` flag is handled by the caller,
36
- * ahead of this): UNIWEB_TOKEN env > stored session (unexpired) >
37
- * UNIWEB_USERNAME/UNIWEB_PASSWORD env (non-interactive) > interactive prompt.
35
+ * Token resolution for the backend commands, which take no `--token` flag:
36
+ * UNIWEB_TOKEN env > stored session (unexpired) > UNIWEB_USERNAME/UNIWEB_PASSWORD
37
+ * env (non-interactive) > interactive prompt.
38
38
  *
39
39
  * Login response shape (agreed with backend, 2026-05-26):
40
40
  * { token, expires_at, account: { uuid, username, handle } }
@@ -48,6 +48,9 @@ import { homedir } from 'node:os'
48
48
  import { createServer } from 'node:http'
49
49
  import { randomBytes } from 'node:crypto'
50
50
 
51
+ import { DEFAULT_BACKEND_ORIGIN } from './config.js'
52
+ import { normalizeSessionFile, sessionFilePath, loggedInOriginOf } from './session-file.js'
53
+
51
54
  const LOGIN_PATH = '/dev/auth/login'
52
55
 
53
56
  /** The shared ~/.uniweb credential directory. */
@@ -58,7 +61,10 @@ export function getAuthDir() {
58
61
  /** Normalize a backend URL to a bare origin (for stamping on the session). */
59
62
  function normOrigin(u) {
60
63
  try {
61
- return new URL(u).origin
64
+ // http(s) only — `localhost:8080` parses as a scheme with the origin "null", and a
65
+ // session keyed "null" would answer for nothing.
66
+ const parsed = new URL(u)
67
+ return parsed.protocol === 'http:' || parsed.protocol === 'https:' ? parsed.origin : u
62
68
  } catch {
63
69
  return u
64
70
  }
@@ -77,39 +83,102 @@ export function isExpired(auth) {
77
83
  * @returns {string}
78
84
  */
79
85
  export function getRegistryAuthPath() {
80
- return join(getAuthDir(), 'registry-auth.json')
86
+ return sessionFilePath()
81
87
  }
82
88
 
83
89
  /**
84
- * Read the stored registry session, or null. No JWT backfill — the token is
85
- * opaque, so there are no claims to decode (unlike legacy readAuth()).
86
- * @returns {Promise<{ token: string, expiresAt?: string, accountId?: number, sessionId?: number, username?: string, handle?: string, uuid?: string } | null>}
90
+ * ONE SESSION, FOR ONE BACKEND — the on-disk shape.
91
+ *
92
+ * { version: 2, current: "https://uniweb.app",
93
+ * sessions: { "https://uniweb.app": { token, expiresAt, … } } }
94
+ *
95
+ * ⭐ **At most one session exists** *[Diego, 2026-09-21: "`uniweb login` log the user out
96
+ * of the existing session, if any, before logging in to the new backend … `uniweb logout`
97
+ * always logs you out of the one backend you may be logged in"]*. A login REPLACES the
98
+ * file — once it succeeds, so a cancelled or failed login leaves you where you were —
99
+ * and a logout deletes it. So "the backend you are logged in to" is exactly one thing
100
+ * or nothing, and logout needs no selector.
101
+ *
102
+ * ⚖️ The v2 map is kept, holding one entry: the file then has one reader
103
+ * (utils/session-file.js), and it still understands what came before — a v1 flat
104
+ * record, and a v2 file holding SEVERAL sessions (the per-backend store of 2026-09-20,
105
+ * which kept every backend's session and marked the last login `current`). The next
106
+ * login or logout rewrites either into the single-session shape.
107
+ *
108
+ * ⛔ Every session is still checked against the backend it is for (`readRegistryAuth`
109
+ * takes the origin): a token issued by A is never handed to a request against B. That
110
+ * was the correctness bug of the original flat record, and it stays fixed.
111
+ *
112
+ * The shape is read in ONE place, `utils/session-file.js`.
87
113
  */
88
- export async function readRegistryAuth() {
114
+
115
+ /** The whole file, normalized to v2 shape. Never throws. */
116
+ async function readAuthFile() {
89
117
  const path = getRegistryAuthPath()
90
- if (!existsSync(path)) return null
118
+ if (!existsSync(path)) return normalizeSessionFile(null, DEFAULT_BACKEND_ORIGIN)
91
119
  try {
92
- return JSON.parse(await readFile(path, 'utf8'))
120
+ return normalizeSessionFile(JSON.parse(await readFile(path, 'utf8')), DEFAULT_BACKEND_ORIGIN)
93
121
  } catch {
94
- return null
122
+ return normalizeSessionFile(null, DEFAULT_BACKEND_ORIGIN)
95
123
  }
96
124
  }
97
125
 
126
+ async function writeAuthFile({ version, current, sessions }) {
127
+ await mkdir(getAuthDir(), { recursive: true })
128
+ await writeFile(
129
+ getRegistryAuthPath(),
130
+ JSON.stringify({ version, ...(current ? { current } : {}), sessions }, null, 2)
131
+ )
132
+ }
133
+
134
+ /**
135
+ * The stored session FOR ONE BACKEND, or null. No JWT backfill — the token is
136
+ * opaque, so there are no claims to decode (unlike legacy readAuth()).
137
+ *
138
+ * ⛔ `origin` is required in spirit: calling this without one used to mean "the
139
+ * session", which is the assumption this file exists to remove. It falls back to the
140
+ * default backend rather than throwing, because every caller now passes one and a
141
+ * throw here would turn a stale call site into a crash instead of a wrong answer.
142
+ *
143
+ * @param {string} [origin] - the backend whose session is wanted
144
+ * @returns {Promise<{ token: string, expiresAt?: string, accountId?: number, sessionId?: number, username?: string, handle?: string, uuid?: string, origin: string } | null>}
145
+ */
146
+ export async function readRegistryAuth(origin) {
147
+ const key = normOrigin(origin || DEFAULT_BACKEND_ORIGIN)
148
+ const { sessions } = await readAuthFile()
149
+ const found = sessions[key]
150
+ return found && typeof found.token === 'string' ? { ...found, origin: key } : null
151
+ }
152
+
98
153
  /**
99
- * Persist the registry session record.
100
- * @param {Object} record
154
+ * Persist THE session — the backend the user is now logged in to, replacing whatever
155
+ * the file held. The record's own `origin` is the key, so every login path keeps
156
+ * stamping it exactly as before.
157
+ * @param {Object} record - must carry `origin` and `token`
101
158
  */
102
159
  export async function writeRegistryAuth(record) {
103
- await mkdir(getAuthDir(), { recursive: true })
104
- await writeFile(getRegistryAuthPath(), JSON.stringify(record, null, 2))
160
+ const key = normOrigin(record?.origin || DEFAULT_BACKEND_ORIGIN)
161
+ const { origin: _drop, ...rest } = record || {}
162
+ // ⭐ REPLACE, never merge: every caller is a successful login, and there is one session.
163
+ // Whatever the file held — another backend's session, or several from before —
164
+ // is gone: the user is logged in to this backend, and only this one.
165
+ await writeAuthFile({ version: 2, current: key, sessions: { [key]: rest } })
105
166
  }
106
167
 
107
168
  /**
108
- * Remove the stored registry session.
169
+ * Log out: delete the session file. There is one session, so there is nothing to choose.
170
+ *
171
+ * ⚠️ Local only — the token is forgotten on this machine, not revoked on the backend.
172
+ *
173
+ * @returns {Promise<string[]>} the origins whose sessions were removed — one, or
174
+ * several for a file written before sessions were single (2026-09-21)
109
175
  */
110
176
  export async function clearRegistryAuth() {
111
177
  const path = getRegistryAuthPath()
112
- if (existsSync(path)) await unlink(path)
178
+ if (!existsSync(path)) return []
179
+ const had = Object.keys((await readAuthFile()).sessions)
180
+ await unlink(path)
181
+ return had
113
182
  }
114
183
 
115
184
  /**
@@ -190,6 +259,21 @@ export async function loginToRegistry({ apiBase, username, password } = {}) {
190
259
  * @param {string[]} [options.args] - argv slice; checked for --non-interactive
191
260
  * @returns {Promise<string>} bearer token
192
261
  */
262
+ /** `args` without `--token` and its value — see ensureRegistryAuth. */
263
+ export function withoutLoginToken(args = []) {
264
+ const out = []
265
+ for (let i = 0; i < args.length; i++) {
266
+ const a = args[i]
267
+ if (a === '--token') {
268
+ i++ // and its value
269
+ continue
270
+ }
271
+ if (a.startsWith('--token=')) continue
272
+ out.push(a)
273
+ }
274
+ return out
275
+ }
276
+
193
277
  export async function ensureRegistryAuth({
194
278
  apiBase,
195
279
  command = 'This command',
@@ -197,7 +281,11 @@ export async function ensureRegistryAuth({
197
281
  } = {}) {
198
282
  if (process.env.UNIWEB_TOKEN) return process.env.UNIWEB_TOKEN
199
283
 
200
- const stored = await readRegistryAuth()
284
+ // ⛔ THE SESSION MUST BE THE ONE ISSUED FOR `apiBase`. Until 2026-09-20 this read
285
+ // "the" session and returned its token whenever it was unexpired — so a bearer minted
286
+ // by backend A was sent to backend B, which rejects it. The store is keyed by origin
287
+ // now, so asking for the right one is the same single read.
288
+ const stored = await readRegistryAuth(apiBase)
201
289
  if (stored?.token && !isExpired(stored)) return stored.token
202
290
 
203
291
  // Non-interactive login from env (CI / agents) before any prompt.
@@ -240,7 +328,11 @@ export async function ensureRegistryAuth({
240
328
  }
241
329
 
242
330
  // Interactive: hand off to the multi-method login picker, reuse its session.
243
- const record = await runRegistryLogin({ apiBase, args })
331
+ // ⛔ WITHOUT `--token`: in `runRegistryLogin` it means "seed and STORE this bearer as a
332
+ // session" — `uniweb login --token`'s job alone. A command's own argv reaching it
333
+ // would log the machine in as a side effect of that command. (The backend commands
334
+ // refuse `--token` since 2026-09-21; this holds for any other caller too.)
335
+ const record = await runRegistryLogin({ apiBase, args: withoutLoginToken(args) })
244
336
  if (!record?.token) process.exit(1)
245
337
  return record.token
246
338
  }
@@ -562,25 +654,55 @@ async function loginViaBrowser({ apiBase }) {
562
654
  * @returns {Promise<Object|undefined>} the stored session record
563
655
  */
564
656
  export async function runRegistryLogin({ apiBase, args = [] } = {}) {
565
- const existing = await readRegistryAuth()
657
+ // For THIS backend. It read "the" session and printed `apiBase` beside it, so on a
658
+ // machine logged into another backend it announced "Already logged in … (this origin)"
659
+ // about a session belonging to a different one — and then offered to replace it.
660
+ const { isNonInteractive } = await import('./interactive.js')
661
+ const nonInteractive = isNonInteractive(args)
662
+
663
+ const key = normOrigin(apiBase)
664
+ const file = await readAuthFile()
665
+ const current = loggedInOriginOf(file)
666
+ const existing = await readRegistryAuth(apiBase)
667
+ const forced =
668
+ args.includes('--token') ||
669
+ args.includes('--browser') ||
670
+ args.includes('--password') ||
671
+ args.includes('--token-paste')
566
672
  if (existing?.token && !isExpired(existing)) {
567
673
  const who =
568
674
  existing.username ||
569
675
  existing.handle ||
570
676
  (existing.uuid ? `account ${existing.uuid}` : '')
677
+ if (!forced) {
678
+ // Already logged in to this backend: nothing to do — logging in again is behind a
679
+ // method flag. (Also where a file from before sessions were single, holding this
680
+ // backend among others, becomes single: the rewrite keeps only this session.)
681
+ if (current !== key || Object.keys(file.sessions).length > 1) {
682
+ await writeRegistryAuth({ ...existing, origin: key })
683
+ }
684
+ console.error(
685
+ `\x1b[32m✓\x1b[0m Already logged in to ${key}${who ? ` as \x1b[1m${who}\x1b[0m` : ''}.`
686
+ )
687
+ console.error(
688
+ '\x1b[2mTo log in again, name a method: --password, --browser, --token-paste or --token <bearer>.\x1b[0m'
689
+ )
690
+ return { ...existing, origin: key }
691
+ }
571
692
  console.error(
572
- `Already logged in${who ? ` as \x1b[1m${who}\x1b[0m` : ''}${apiBase ? ` (${apiBase})` : ''}.`
693
+ `Already logged in${who ? ` as \x1b[1m${who}\x1b[0m` : ''} (${key}) — logging in again replaces that session.\n`
573
694
  )
574
- console.error('\x1b[2mContinuing will replace the existing session.\x1b[0m\n')
695
+ } else if (current && current !== key) {
696
+ // ⭐ ONE SESSION AT A TIME *[Diego, 2026-09-21]*: logging in here logs you out of there
697
+ // — when this login succeeds (writeRegistryAuth replaces the file), so cancelling or
698
+ // failing leaves you logged in where you were.
699
+ console.error(`\x1b[2mYou are logged in to ${current}; logging in to ${key} logs you out of it.\x1b[0m\n`)
575
700
  }
576
701
 
577
- const { isNonInteractive } = await import('./interactive.js')
578
- const nonInteractive = isNonInteractive(args)
579
-
580
702
  // `--token <bearer>` seeds + verifies a session non-interactively (verified
581
703
  // against /dev/auth/me before it's stored, so an invalid token fails loudly
582
- // instead of poisoning the session file). Distinct from the per-command
583
- // `--token` (ephemeral, never stored) and from UNIWEB_TOKEN env.
704
+ // instead of poisoning the session file). ⭐ Login's alone: the backend commands
705
+ // take no `--token` (2026-09-21) — UNIWEB_TOKEN is the one-process bearer.
584
706
  const { readFlagValue } = await import('./args.js')
585
707
  const tokenFlag = readFlagValue(args, '--token')
586
708
  if (tokenFlag) {
@@ -59,6 +59,25 @@ export function bareHandle(scope) {
59
59
  .replace(/\/.*$/, '')
60
60
  }
61
61
 
62
+ /**
63
+ * A publish scope in the one form the registry names things by — `@acme` — from any
64
+ * spelling `--scope` or `package.json::uniweb.scope` may carry it in (`@acme`, `acme`,
65
+ * `@acme/…`). Null when there is no handle in it.
66
+ *
67
+ * ⛔ Every name built from a scope goes through this. The `.uwx` assembly
68
+ * (`@uniweb/build`'s `buildRegistryPackage`) has always accepted `acme` as well as
69
+ * `@acme`, so a name composed from the RAW value disagreed with the one it registered:
70
+ * until 2026-09-21 `register --scope std` registered `@std/src` and then asked to
71
+ * deliver code for `std/src`, which the registry refuses (a scoped name is `@org/name`).
72
+ *
73
+ * @param {unknown} value
74
+ * @returns {string|null}
75
+ */
76
+ export function publishScope(value) {
77
+ const handle = bareHandle(value)
78
+ return handle ? `@${handle}` : null
79
+ }
80
+
62
81
  /**
63
82
  * Validate a handle's GRAMMAR client-side (reserved names are the server's
64
83
  * call — a 409 carries the verdict). Returns an error string, or null.
@@ -207,6 +207,15 @@ export async function applyContent(
207
207
  )
208
208
  }
209
209
 
210
+ /**
211
+ * What a project records about where IT synced and deployed — a site's identity on
212
+ * each backend, its deploy targets and history, and the local caches of both. A new
213
+ * project made from a template owns none of it: carried over, its first push would
214
+ * update the template's own site. The same line `uniweb forget --all` draws for a
215
+ * copied project. Matched at the top of a content directory, where a site keeps them.
216
+ */
217
+ const INHERITED_STATE = new Set(['sync.json', 'deploy.yml', '.uniweb'])
218
+
210
219
  /**
211
220
  * Recursively copy content files, skipping structural files.
212
221
  *
@@ -234,6 +243,7 @@ async function copyContentRecursive(
234
243
  const sourcePath = join(sourceDir, entry.name)
235
244
 
236
245
  if (entry.isDirectory()) {
246
+ if (depth === 0 && INHERITED_STATE.has(entry.name)) continue
237
247
  const targetSubDir = join(targetDir, entry.name)
238
248
  await copyContentRecursive(
239
249
  sourcePath,
@@ -260,6 +270,7 @@ async function copyContentRecursive(
260
270
 
261
271
  // Skip structural files
262
272
  if (structuralFiles.has(outputName)) continue
273
+ if (depth === 0 && INHERITED_STATE.has(outputName)) continue
263
274
 
264
275
  const targetPath = join(targetDir, outputName)
265
276
 
@@ -0,0 +1,95 @@
1
+ /**
2
+ * `~/.uniweb/registry-auth.json` — the ONE reader of its shape.
3
+ *
4
+ * { version: 2, current: "<origin>", sessions: { "<origin>": { token, … } } }
5
+ *
6
+ * ⭐ ONE session since 2026-09-21 — a login replaces the file, a logout deletes it
7
+ * (registry-auth.js). The map shape stays so this reader also understands a file from
8
+ * 2026-09-20→21, when the store kept one session PER backend.
9
+ *
10
+ * ⛔ **There were two readers until 2026-09-21, and the copy is how "the backend you are
11
+ * logged in to" stopped meaning anything.** `registry-auth.js` writes the file and read
12
+ * it; the origin ladder in `config.js` kept a sync copy that read a top-level `origin`.
13
+ * When the file became one session per backend (`fb4907e`), the writer moved to the shape
14
+ * above and the copy kept reading `origin` — a field the new shape does not have — so
15
+ * every command that falls back to the logged-in backend quietly fell through to the
16
+ * default one. Both now read through here, so the shape has one reader to change.
17
+ *
18
+ * ⛔ Dependency-free, by requirement: `config.js` imports this, and `registry-auth.js`
19
+ * imports `config.js`, so this module can import neither.
20
+ */
21
+
22
+ import { existsSync, readFileSync } from 'node:fs'
23
+ import { homedir } from 'node:os'
24
+ import { join } from 'node:path'
25
+
26
+ export const SESSION_FILE_VERSION = 2
27
+
28
+ export function sessionFilePath() {
29
+ return join(homedir(), '.uniweb', 'registry-auth.json')
30
+ }
31
+
32
+ const originOf = (value) => {
33
+ try {
34
+ // http(s) only — `localhost:8080` parses as a scheme with the origin "null".
35
+ const u = new URL(value)
36
+ return u.protocol === 'http:' || u.protocol === 'https:' ? u.origin : null
37
+ } catch {
38
+ return null
39
+ }
40
+ }
41
+
42
+ /**
43
+ * Any parsed value → `{ version: 2, current, sessions }`. Never throws.
44
+ *
45
+ * A v1 flat record is ONE session, keyed by its own `origin` stamp — or by
46
+ * `defaultOrigin` when it carries none — and that session is current: it was the only
47
+ * login there was.
48
+ *
49
+ * @param {*} raw - the parsed file, or anything
50
+ * @param {string} defaultOrigin
51
+ */
52
+ export function normalizeSessionFile(raw, defaultOrigin) {
53
+ const empty = { version: SESSION_FILE_VERSION, current: null, sessions: {} }
54
+ if (!raw || typeof raw !== 'object') return empty
55
+ if (raw.sessions && typeof raw.sessions === 'object' && !Array.isArray(raw.sessions)) {
56
+ return { version: SESSION_FILE_VERSION, current: originOf(raw.current), sessions: raw.sessions }
57
+ }
58
+ if (typeof raw.token === 'string') {
59
+ const { origin, ...rest } = raw
60
+ const key = originOf(origin) || defaultOrigin
61
+ return { version: SESSION_FILE_VERSION, current: key, sessions: { [key]: rest } }
62
+ }
63
+ return empty
64
+ }
65
+
66
+ /** The file, normalized; synchronous, for the origin ladder. Never throws. */
67
+ export function readSessionFileSync(defaultOrigin) {
68
+ const p = sessionFilePath()
69
+ if (!existsSync(p)) return normalizeSessionFile(null, defaultOrigin)
70
+ try {
71
+ return normalizeSessionFile(JSON.parse(readFileSync(p, 'utf8')), defaultOrigin)
72
+ } catch {
73
+ return normalizeSessionFile(null, defaultOrigin)
74
+ }
75
+ }
76
+
77
+ /**
78
+ * **The backend the user is logged in to** — the one session's backend.
79
+ *
80
+ * A file written since sessions became single holds one session, marked `current`. The
81
+ * other branches read what came before: a per-backend file names its last login
82
+ * `current`; one with a single unmarked session answers with it; several and none
83
+ * marked is no answer, and saying so beats guessing. An expired session still answers —
84
+ * the user logged in there, and the next request asks them to log in there again.
85
+ *
86
+ * @param {{ current?: string|null, sessions?: object }} file - normalized
87
+ * @returns {string|null}
88
+ */
89
+ export function loggedInOriginOf(file) {
90
+ const sessions = file?.sessions || {}
91
+ const has = (o) => !!(o && sessions[o] && typeof sessions[o].token === 'string')
92
+ if (has(file?.current)) return file.current
93
+ const all = Object.keys(sessions).filter(has)
94
+ return all.length === 1 ? all[0] : null
95
+ }
@@ -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 **global and content-addressed** — identical bytes dedup across
23
- * sites — so the relation from a site's path to an object is many-to-one and a
24
- * serve path cannot be a property of the object. This lane is **per-site and
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.