uniweb 0.59.2 → 0.61.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +7 -7
- package/partials/agents.md +24 -32
- package/src/backend/asset-download.js +11 -9
- package/src/backend/client.js +38 -91
- package/src/backend/foundation-bring-along.js +19 -14
- package/src/backend/service-request.js +14 -9
- package/src/backend/site-media.js +6 -6
- package/src/backend/site-sync.js +323 -221
- package/src/commands/clone.js +42 -39
- package/src/commands/deploy.js +45 -27
- package/src/commands/doctor.js +6 -7
- package/src/commands/forget.js +197 -0
- package/src/commands/i18n.js +19 -10
- package/src/commands/publish.js +112 -77
- package/src/commands/pull.js +63 -49
- package/src/commands/push.js +76 -60
- package/src/commands/refresh.js +22 -29
- package/src/commands/register.js +37 -23
- package/src/commands/status.js +13 -11
- package/src/commands/sync.js +0 -1
- package/src/framework-index.json +7 -7
- package/src/index.js +101 -76
- package/src/utils/args.js +3 -4
- package/src/utils/asset-upload.js +18 -5
- package/src/utils/config.js +79 -42
- package/src/utils/flag-guard.js +70 -27
- package/src/utils/git.js +1 -1
- package/src/utils/interactive.js +38 -0
- package/src/utils/records-guard.js +38 -30
- package/src/utils/registry-auth.js +150 -28
- package/src/utils/registry-orgs.js +19 -0
- package/src/utils/scaffold.js +11 -0
- package/src/utils/session-file.js +95 -0
- package/src/utils/site-copies.js +123 -0
- package/src/utils/site-data-upload.js +4 -3
- package/src/utils/site-identity.js +89 -299
- package/templates/site/site.yml.hbs +11 -9
|
@@ -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
|
|
36
|
-
*
|
|
37
|
-
*
|
|
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
|
-
|
|
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
|
|
86
|
+
return sessionFilePath()
|
|
81
87
|
}
|
|
82
88
|
|
|
83
89
|
/**
|
|
84
|
-
*
|
|
85
|
-
*
|
|
86
|
-
*
|
|
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
|
-
|
|
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
|
|
100
|
-
*
|
|
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
|
-
|
|
104
|
-
|
|
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
|
-
*
|
|
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))
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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` : ''}
|
|
693
|
+
`Already logged in${who ? ` as \x1b[1m${who}\x1b[0m` : ''} (${key}) — logging in again replaces that session.\n`
|
|
573
694
|
)
|
|
574
|
-
|
|
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).
|
|
583
|
-
// `--token` (
|
|
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.
|
package/src/utils/scaffold.js
CHANGED
|
@@ -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 **
|
|
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.
|