uniweb 0.59.2 → 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.
@@ -6,16 +6,17 @@
6
6
  * does not recognize does not fail: it *disappears*, and the thing it was meant to
7
7
  * change silently keeps its default.
8
8
  *
9
- * That is tolerable for a cosmetic flag and dangerous for these two:
9
+ * Tolerable for a cosmetic flag; dangerous for any flag that aims the command or picks
10
+ * its identity. This turns that class into one sentence.
10
11
  *
11
- * --backend mistyped ⇒ the origin ladder falls through to the session origin,
12
- * ~/.uniweb/config.json, and finally https://uniweb.app. A command
13
- * aimed at a local backend can reach production.
14
- * --token mistyped ⇒ falls back to the stored session, so the request is
15
- * made as whoever is logged in rather than whoever was intended.
16
- *
17
- * Neither produces an error today; both produce a plausible success against the
18
- * wrong host. This turns that class into one sentence.
12
+ * ⛔ **`--backend` and `--token` are NOT flags of these verbs** *(2026-09-21)*. Every
13
+ * backend verb goes to the backend you are logged in to, with that login's session:
14
+ * switching and signing in are `uniweb login` (`--backend`, `--token`), and a script
15
+ * aims one process with UNIWEB_REGISTER_URL + UNIWEB_TOKEN. Both flags predate
16
+ * per-backend sessions. Passed now, each is an unknown flag — and this guard is what
17
+ * makes that a loud error, with a pointer to the login, instead of a command that
18
+ * quietly runs against wherever and as whoever you happen to be. `--backend` stays on
19
+ * `forget`, where it SELECTS which backend's records to remove.
19
20
  *
20
21
  * ⚠️ A wrong rejection is worse than a missed one — it breaks an invocation that
21
22
  * works — so the per-command lists must be complete, INCLUDING flags read by
@@ -38,8 +39,8 @@ const GLOBAL = ['--non-interactive', '--help', '-h']
38
39
  const AUTH = ['--browser', '--password', '--token-paste']
39
40
 
40
41
  /**
41
- * Flags a verb inherits by importing `commands/deploy.js` for `resolveSiteDir` /
42
- * `resolveSiteBackend`. Inert on these verbs, and listed rather than filtered:
42
+ * Flags a verb inherits by importing `commands/deploy.js` for `resolveSiteDir`.
43
+ * Inert on these verbs, and listed rather than filtered:
43
44
  * accepting a flag that does nothing is exactly the pre-guard behaviour, while
44
45
  * rejecting one that works is a broken command. The guard's job is catching
45
46
  * `--backed`, not policing inert-but-valid spellings.
@@ -54,8 +55,8 @@ const VIA_DEPLOY = ['--target', '--host', '--no-save']
54
55
  */
55
56
  const VERBS = {
56
57
  push: [
57
- '--all', '--as-org', '--org', '--backend', '--dry-run', '--force',
58
- '--foundation', '--output', '-o', '--personal', '--token',
58
+ '--all', '--as-org', '--org', '--dry-run', '--force',
59
+ '--foundation', '--output', '-o', '--personal',
59
60
  // read in utils/conformance.js and backend/site-sync.js respectively —
60
61
  // neither appears in push.js
61
62
  '--no-validate', '--yes',
@@ -68,29 +69,36 @@ const VERBS = {
68
69
  '--no-release', ...VIA_DEPLOY
69
70
  ],
70
71
  publish: [
71
- '--as-org', '--org', '--backend', '--dry-run', '--force', '--foundation',
72
- '--personal', '--token',
72
+ '--as-org', '--org', '--dry-run', '--force', '--foundation',
73
+ '--personal',
73
74
  // read in utils/conformance.js, backend/site-sync.js, and
74
75
  // backend/foundation-bring-along.js — none appear in publish.js
75
76
  '--no-validate', '--yes', '--no-verify', '--no-release', ...VIA_DEPLOY
76
77
  ],
77
78
  pull: [
78
- '--backend', '--content-only', '--dry-run', '--force', '--merge',
79
+ '--content-only', '--dry-run', '--force', '--merge',
79
80
  '--no-assets',
80
- '--no-records', '--no-delete', '--no-prune', '--token',
81
+ '--no-records', '--no-delete', '--no-prune',
81
82
  // via backend/site-sync.js (the owner resolver) and utils/conformance.js
82
83
  '--yes', '--org', '--as-org', '--no-validate', ...VIA_DEPLOY
83
84
  ],
84
85
  clone: [
85
- '--backend', '--content-only', '--no-assets', '--no-records', '--path',
86
- '--project', '--token', '--org', '--as-org'
86
+ '--content-only', '--no-assets', '--no-records', '--path',
87
+ '--project', '--org', '--as-org'
87
88
  ],
88
89
  register: [
89
- '--backend', '--dry-run', '--json', '--output', '-o',
90
- '--schema-only', '--scope', '--token', '--org', '--as-org'
90
+ '--dry-run', '--json', '--output', '-o',
91
+ '--schema-only', '--scope', '--org', '--as-org'
91
92
  ],
93
+ /**
94
+ * `forget` = remove one backend's records (`--backend <url>`), or everything a
95
+ * copied project inherited (`--all`). One of the two is required — the verb refuses
96
+ * without it — and they exclude each other. `--non-interactive` reaches it through
97
+ * resolveSiteDir in a workspace of several sites.
98
+ */
99
+ forget: ['--backend', '--all'],
92
100
  status: [
93
- '--backend', '--json', '--remote', '--token', '--dry-run',
101
+ '--json', '--remote', '--dry-run',
94
102
  '--force', '--no-verify', '--no-validate', '--yes', '--org', '--as-org',
95
103
  // inert here, reachable through the bring-along module status imports for
96
104
  // `resolveLocalFoundation` — listed per the over-approximation note above
@@ -99,10 +107,9 @@ const VERBS = {
99
107
  /**
100
108
  * `refresh` = `git pull`, then a DELEGATED `pull --merge`.
101
109
  *
102
- * It forwards exactly two flags to that pull — `--backend` /
103
- * `--token`, via its own `collectPassthrough` — and constructs the rest of the
104
- * argv itself. So pull's own flags (`--merge`, `--force`, `--no-delete`,
105
- * `--no-prune`, `--content-only`, …) are NOT reachable from a `refresh` argv and
110
+ * It forwards nothing of its own argv to that pull — it builds `['--merge']`
111
+ * itself. So pull's own flags (`--merge`, `--force`, `--no-delete`, `--no-prune`,
112
+ * `--content-only`, …) are NOT reachable from a `refresh` argv and
106
113
  * are deliberately absent here. ⚠️ `--force` especially: `refresh` is read-only by
107
114
  * design, and forwarding it would ask pull to DISCARD local work.
108
115
  *
@@ -111,7 +118,7 @@ const VERBS = {
111
118
  * the VIA_DEPLOY note above, and required by `flag-guard-coverage.test.js`.
112
119
  */
113
120
  refresh: [
114
- '--backend', '--no-backend', '--no-git', '--token',
121
+ '--no-backend', '--no-git',
115
122
  '--as-org', '--org', '--dry-run', '--no-validate', '--yes', ...VIA_DEPLOY
116
123
  ]
117
124
  }
@@ -143,6 +150,18 @@ VERBS.sync = [...new Set([...VERBS.refresh, ...VERBS.push])]
143
150
  * flag accepted here but inert costs nothing (it was ignored before the guard
144
151
  * existed), while one rejected here breaks a working command.
145
152
  */
153
+ /**
154
+ * Read in the verbs' import graphs, and deliberately NOT accepted by them.
155
+ *
156
+ * `--token` is read by `runRegistryLogin` (utils/registry-auth.js) for
157
+ * `uniweb login --token <bearer>`, which seeds and STORES a session. The verbs reach
158
+ * that function through `ensureRegistryAuth`, which strips `--token` from their argv
159
+ * first — so a static walk sees the read, and no verb can honour it.
160
+ * `flag-guard-coverage.test.js` subtracts this list; add to it only with the same
161
+ * proof: a read no verb's argv can reach.
162
+ */
163
+ export const LOGIN_ONLY = ['--token']
164
+
146
165
  export const VERB_FLAGS = Object.fromEntries(
147
166
  Object.entries(VERBS).map(([verb, flags]) => [
148
167
  verb,
@@ -170,6 +189,30 @@ export function checkFlags(verb, args = []) {
170
189
  if (!unknown.length) return null
171
190
 
172
191
  const flag = unknown[0]
192
+ // ⭐ `--backend` and `--token` are not typos on these verbs — they are RETIRED
193
+ // (2026-09-21), and the useful answer is what replaced them, not "run --help".
194
+ if (flag === '--backend') {
195
+ return {
196
+ flag,
197
+ suggestion: null,
198
+ message: [
199
+ `\`uniweb ${verb}\` has no \`--backend\`: it goes to the backend you are logged in to.`,
200
+ ' Switch with: uniweb login --backend <url>',
201
+ ' (A script can aim one process with UNIWEB_REGISTER_URL instead.)'
202
+ ].join('\n')
203
+ }
204
+ }
205
+ if (flag === '--token') {
206
+ return {
207
+ flag,
208
+ suggestion: null,
209
+ message: [
210
+ `\`uniweb ${verb}\` has no \`--token\`: it uses the session of the backend you are logged in to.`,
211
+ ' Sign in with a token: uniweb login --backend <url> --token <bearer>',
212
+ ' (A script can authenticate one process with UNIWEB_TOKEN instead.)'
213
+ ].join('\n')
214
+ }
215
+ }
173
216
  const suggestion = didYouMean(flag, all)
174
217
  const lines = [`Unknown flag \`${flag}\` for \`uniweb ${verb}\`.`]
175
218
  if (suggestion) lines.push(` Did you mean \`${suggestion}\`?`)
@@ -45,6 +45,44 @@ export async function confirm(question, defaultYes = false) {
45
45
  }
46
46
  }
47
47
 
48
+ /**
49
+ * Ask the user to pick one of a short list, returning `fallback` on an empty answer
50
+ * or anything unparseable.
51
+ *
52
+ * ⛔ **Prompts on STDERR**, like everything else a human reads here — `stdout` is the
53
+ * CLI's data channel and a prompt written there corrupts any caller piping it.
54
+ *
55
+ * ⛔ Callers must decide NOT to ask before calling (see `confirm`): this reads stdin,
56
+ * and a prompt in a non-interactive run hangs a pipeline.
57
+ *
58
+ * @param {string} question
59
+ * @param {string[]} options - shown in order, numbered from 1
60
+ * @param {string} fallback - returned for an empty or unparseable answer
61
+ * @returns {Promise<string>}
62
+ */
63
+ export async function pickOne(question, options, fallback) {
64
+ if (!Array.isArray(options) || options.length === 0) return fallback
65
+ if (options.length === 1) return options[0]
66
+ const { createInterface } = await import('node:readline/promises')
67
+ const rl = createInterface({ input: process.stdin, output: process.stderr })
68
+ try {
69
+ process.stderr.write(`${question}\n`)
70
+ options.forEach((o, i) => {
71
+ const mark = o === fallback ? ' \x1b[2m(default)\x1b[0m' : ''
72
+ process.stderr.write(` \x1b[1m${i + 1}\x1b[0m) ${o}${mark}\n`)
73
+ })
74
+ const a = (await rl.question(`Choose [1-${options.length}]: `)).trim()
75
+ if (!a) return fallback
76
+ const n = Number.parseInt(a, 10)
77
+ if (Number.isInteger(n) && n >= 1 && n <= options.length) return options[n - 1]
78
+ // A pasted origin is a reasonable answer to a list of origins.
79
+ if (options.includes(a)) return a
80
+ return fallback
81
+ } finally {
82
+ rl.close()
83
+ }
84
+ }
85
+
48
86
  /**
49
87
  * Get the CLI invocation prefix to use in suggested commands.
50
88
  * Mirrors however the user actually ran the CLI.
@@ -40,17 +40,18 @@ export function countPlacedRecords(pathToUuid) {
40
40
  *
41
41
  * @param {object} params
42
42
  * @param {string} params.siteDir
43
+ * @param {string} params.backend - whose placements to count; they are minted per backend
43
44
  * @param {string[]} params.args - the verb's argv, for --yes / non-interactive
44
45
  * @param {(m: string) => void} params.warn - the CALLER's reporter. Each verb owns
45
46
  * its own output style; a second copy here would drift from all of them.
46
47
  * @param {(m: string) => void} params.note
47
48
  * @returns {Promise<{ ok: boolean, count: number }>} `ok: false` means abort
48
49
  */
49
- export async function guardEmptyRecords({ siteDir, args = [], warn, note }) {
50
+ export async function guardEmptyRecords({ siteDir, backend, args = [], warn, note }) {
50
51
  const cfg = await readRecordsConfig(siteDir)
51
52
  if (cfg.state !== FOLDER_EMPTY) return { ok: true, count: 0 }
52
53
 
53
- const count = countPlacedRecords(readFolderItemUuids(siteDir))
54
+ const count = countPlacedRecords(readFolderItemUuids(siteDir, backend))
54
55
  // Nothing banked ⇒ nothing this push can remove. A first push of an empty
55
56
  // folder is a legitimate (if odd) thing to do, and asking about it would train
56
57
  // people to type y.
@@ -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) {
@@ -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
+ }