uniweb 1.0.0 → 1.0.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (129) hide show
  1. package/LICENSE +201 -0
  2. package/README.md +532 -3
  3. package/package.json +60 -11
  4. package/partials/adding-pages.hbs +77 -0
  5. package/partials/agents.md +3000 -0
  6. package/partials/ai-assistance.hbs +28 -0
  7. package/partials/components-docs.hbs +13 -0
  8. package/partials/config-reference.hbs +66 -0
  9. package/partials/deployment.hbs +13 -0
  10. package/partials/learn-more.hbs +6 -0
  11. package/partials/quick-start.hbs +8 -0
  12. package/partials/search-docs.hbs +110 -0
  13. package/src/backend/asset-download.js +222 -0
  14. package/src/backend/client.js +986 -0
  15. package/src/backend/foundation-bring-along.js +692 -0
  16. package/src/backend/payment-handoff.js +362 -0
  17. package/src/backend/service-request.js +210 -0
  18. package/src/backend/site-media.js +255 -0
  19. package/src/backend/site-preview.js +77 -0
  20. package/src/backend/site-sync.js +2515 -0
  21. package/src/backend/workspace.js +234 -0
  22. package/src/commands/add.js +1900 -0
  23. package/src/commands/build.js +1214 -0
  24. package/src/commands/clone.js +466 -0
  25. package/src/commands/content.js +202 -0
  26. package/src/commands/deploy.js +537 -0
  27. package/src/commands/dev.js +202 -0
  28. package/src/commands/docs.js +530 -0
  29. package/src/commands/doctor.js +1982 -0
  30. package/src/commands/export.js +116 -0
  31. package/src/commands/families.js +82 -0
  32. package/src/commands/forget.js +197 -0
  33. package/src/commands/handoff.js +21 -0
  34. package/src/commands/i18n.js +1707 -0
  35. package/src/commands/inspect.js +375 -0
  36. package/src/commands/invite.js +24 -0
  37. package/src/commands/org.js +96 -0
  38. package/src/commands/publish.js +1087 -0
  39. package/src/commands/pull.js +1257 -0
  40. package/src/commands/push.js +663 -0
  41. package/src/commands/refresh.js +212 -0
  42. package/src/commands/register.js +1092 -0
  43. package/src/commands/rename.js +647 -0
  44. package/src/commands/site.js +358 -0
  45. package/src/commands/snapshot.js +461 -0
  46. package/src/commands/starter.js +206 -0
  47. package/src/commands/status.js +302 -0
  48. package/src/commands/sync.js +93 -0
  49. package/src/commands/template.js +107 -0
  50. package/src/commands/update.js +887 -0
  51. package/src/commands/validate.js +376 -0
  52. package/src/framework-index.json +248 -0
  53. package/src/index.js +2241 -0
  54. package/src/templates/fetchers/github.js +113 -0
  55. package/src/templates/fetchers/npm.js +85 -0
  56. package/src/templates/fetchers/release.js +92 -0
  57. package/src/templates/index.js +190 -0
  58. package/src/templates/processor.js +333 -0
  59. package/src/templates/resolver.js +232 -0
  60. package/src/templates/validator.js +155 -0
  61. package/src/utils/agents-stamp.js +110 -0
  62. package/src/utils/args.js +126 -0
  63. package/src/utils/asset-upload.js +270 -0
  64. package/src/utils/bytes.js +30 -0
  65. package/src/utils/code-upload.js +394 -0
  66. package/src/utils/config.js +417 -0
  67. package/src/utils/conformance.js +274 -0
  68. package/src/utils/dep-survey.js +176 -0
  69. package/src/utils/destination-prompt.js +146 -0
  70. package/src/utils/discover.js +75 -0
  71. package/src/utils/env.js +22 -0
  72. package/src/utils/exit.js +30 -0
  73. package/src/utils/fetch-retry.js +109 -0
  74. package/src/utils/flag-guard.js +246 -0
  75. package/src/utils/foundation-name.js +179 -0
  76. package/src/utils/git.js +342 -0
  77. package/src/utils/host-prompt.js +51 -0
  78. package/src/utils/install-integrity.js +210 -0
  79. package/src/utils/interactive.js +118 -0
  80. package/src/utils/json-file.js +72 -0
  81. package/src/utils/names.js +91 -0
  82. package/src/utils/placement.js +151 -0
  83. package/src/utils/pm.js +165 -0
  84. package/src/utils/preview.js +20 -0
  85. package/src/utils/pull-written.js +132 -0
  86. package/src/utils/records-guard.js +88 -0
  87. package/src/utils/registry-auth.js +994 -0
  88. package/src/utils/registry-orgs.js +290 -0
  89. package/src/utils/scaffold.js +483 -0
  90. package/src/utils/schemaless-report.js +30 -0
  91. package/src/utils/semver-precedence.js +80 -0
  92. package/src/utils/session-file.js +95 -0
  93. package/src/utils/site-content-refs.js +23 -0
  94. package/src/utils/site-copies.js +123 -0
  95. package/src/utils/site-data-upload.js +302 -0
  96. package/src/utils/site-identity.js +137 -0
  97. package/src/utils/snapshot-settings.js +112 -0
  98. package/src/utils/update-check.js +302 -0
  99. package/src/utils/uwx-read.js +157 -0
  100. package/src/utils/workspace.js +223 -0
  101. package/src/versions.js +467 -0
  102. package/starter/foundation/main.js +50 -0
  103. package/starter/foundation/sections/Section/index.jsx +95 -0
  104. package/starter/foundation/sections/Section/meta.js +44 -0
  105. package/starter/site/pages/about/1-about.md.hbs +13 -0
  106. package/starter/site/pages/about/page.yml +3 -0
  107. package/starter/site/pages/home/1-welcome.md.hbs +14 -0
  108. package/starter/site/pages/home/page.yml +3 -0
  109. package/templates/foundation/_gitignore +5 -0
  110. package/templates/foundation/components/.gitkeep +0 -0
  111. package/templates/foundation/main.js.hbs +33 -0
  112. package/templates/foundation/package.json.hbs +51 -0
  113. package/templates/foundation/sections/.gitkeep +0 -0
  114. package/templates/foundation/styles.css +6 -0
  115. package/templates/foundation/vite.config.js +3 -0
  116. package/templates/site/_gitignore +12 -0
  117. package/templates/site/entry.js +7 -0
  118. package/templates/site/index.html.hbs +13 -0
  119. package/templates/site/package.json.hbs +27 -0
  120. package/templates/site/public/favicon.svg +7 -0
  121. package/templates/site/site.yml.hbs +91 -0
  122. package/templates/site/theme.yml +99 -0
  123. package/templates/site/vite.config.js +7 -0
  124. package/templates/workspace/AGENTS.md.hbs +2 -0
  125. package/templates/workspace/README.md.hbs +87 -0
  126. package/templates/workspace/_gitignore +29 -0
  127. package/templates/workspace/_vscode/settings.json +6 -0
  128. package/templates/workspace/package.json.hbs +20 -0
  129. package/templates/workspace/pnpm-workspace.yaml.hbs +26 -0
@@ -0,0 +1,994 @@
1
+ /**
2
+ * ⛔ **HUMAN OUTPUT IN THIS FILE GOES TO STDERR, NEVER STDOUT.**
3
+ *
4
+ * `stdout` is the CLI's DATA channel. `uniweb register --json` promises a single
5
+ * parseable JSON line there, and `register` diverts its own output accordingly —
6
+ * but it calls into this module, which wrote prose to stdout directly, so the
7
+ * promise broke for any caller that piped it.
8
+ *
9
+ * ⭐ Measured by the `flows` lane at `uniweb@0.37.1`: two stray lines ahead of
10
+ * the JSON on the cold path — down from ~35 before the delegated builder's
11
+ * stdout was redirected to fd 2, which is why the two defects share one symptom
12
+ * and only one of them was fixed. Every line here is prose, a prompt, or a
13
+ * cancellation notice; none of it is anything a machine reads. On stderr it is
14
+ * equally visible to a human and invisible to a pipe.
15
+ *
16
+ * ⚖️ This is not a `--json` special case. A utility that cannot see the flag
17
+ * should not be choosing the stream at all — which is exactly how it got the
18
+ * choice wrong. `cli/test/porcelain-stdout.test.js` walks `register`'s import
19
+ * graph and fails on a new `console.log`.
20
+ */
21
+ /**
22
+ * Registry (new-backend) credential storage + login.
23
+ *
24
+ * SEPARATE from utils/auth.js on purpose. That module is the LEGACY platform
25
+ * auth — browser/social login via `cli-auth.php`, a JWT shared by `publish`
26
+ * and `deploy`, stored in ~/.uniweb/auth.json. This module serves the NEW
27
+ * backend that `uniweb register` talks to:
28
+ *
29
+ * - non-browser username/password login → POST {apiBase}/dev/auth/login
30
+ * - the returned bearer is an OPAQUE random token (NOT a JWT — never decode it,
31
+ * it carries no claims; org memberships come from a separate authed read)
32
+ * - stored in a register-scoped slot (~/.uniweb/registry-auth.json) so it can
33
+ * never clobber the legacy token publish/deploy rely on.
34
+ *
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
+ *
39
+ * Login response shape (agreed with backend, 2026-05-26):
40
+ * { token, expires_at, account: { uuid, username, handle } }
41
+ * token + expiry top-level; identity nested under `account` (handle nullable).
42
+ */
43
+
44
+ import { existsSync } from 'node:fs'
45
+ import { readFile, writeFile, mkdir, unlink } from 'node:fs/promises'
46
+ import { join } from 'node:path'
47
+ import { homedir } from 'node:os'
48
+ import { createServer } from 'node:http'
49
+ import { randomBytes } from 'node:crypto'
50
+
51
+ import { DEFAULT_BACKEND_ORIGIN } from './config.js'
52
+ import { readOrgFlag } from './args.js'
53
+ import { normalizeSessionFile, sessionFilePath, loggedInOriginOf } from './session-file.js'
54
+
55
+ const LOGIN_PATH = '/dev/auth/login'
56
+
57
+ /** The shared ~/.uniweb credential directory. */
58
+ export function getAuthDir() {
59
+ return join(homedir(), '.uniweb')
60
+ }
61
+
62
+ /** Normalize a backend URL to a bare origin (for stamping on the session). */
63
+ function normOrigin(u) {
64
+ try {
65
+ // http(s) only — `localhost:8080` parses as a scheme with the origin "null", and a
66
+ // session keyed "null" would answer for nothing.
67
+ const parsed = new URL(u)
68
+ return parsed.protocol === 'http:' || parsed.protocol === 'https:' ? parsed.origin : u
69
+ } catch {
70
+ return u
71
+ }
72
+ }
73
+
74
+ /** True when a stored session's `expiresAt` is in the past (absent → never expires). */
75
+ export function isExpired(auth) {
76
+ if (!auth?.expiresAt) return false
77
+ return new Date(auth.expiresAt) < new Date()
78
+ }
79
+
80
+ /**
81
+ * Path to the register-scoped credential file (~/.uniweb/registry-auth.json).
82
+ * Reuses the legacy auth dir (the ~/.uniweb home is shared infrastructure);
83
+ * only the filename differs, keeping the two tokens in separate slots.
84
+ * @returns {string}
85
+ */
86
+ export function getRegistryAuthPath() {
87
+ return sessionFilePath()
88
+ }
89
+
90
+ /**
91
+ * ONE SESSION, FOR ONE BACKEND — the on-disk shape.
92
+ *
93
+ * { version: 2, current: "https://uniweb.app",
94
+ * sessions: { "https://uniweb.app": { token, expiresAt, … } } }
95
+ *
96
+ * ⭐ **At most one session exists** *[Diego, 2026-09-21: "`uniweb login` log the user out
97
+ * of the existing session, if any, before logging in to the new backend … `uniweb logout`
98
+ * always logs you out of the one backend you may be logged in"]*. A login REPLACES the
99
+ * file — once it succeeds, so a cancelled or failed login leaves you where you were —
100
+ * and a logout deletes it. So "the backend you are logged in to" is exactly one thing
101
+ * or nothing, and logout needs no selector. ⚠️ One exception, and it says so: a login that
102
+ * signs in but cannot choose a workspace keeps the session and exits 2 (`finishLogin`).
103
+ *
104
+ * ⚖️ The v2 map is kept, holding one entry: the file then has one reader
105
+ * (utils/session-file.js), and it still understands what came before — a v1 flat
106
+ * record, and a v2 file holding SEVERAL sessions (the per-backend store of 2026-09-20,
107
+ * which kept every backend's session and marked the last login `current`). The next
108
+ * login or logout rewrites either into the single-session shape.
109
+ *
110
+ * ⛔ Every session is still checked against the backend it is for (`readRegistryAuth`
111
+ * takes the origin): a token issued by A is never handed to a request against B. That
112
+ * was the correctness bug of the original flat record, and it stays fixed.
113
+ *
114
+ * The shape is read in ONE place, `utils/session-file.js`.
115
+ */
116
+
117
+ /** The whole file, normalized to v2 shape. Never throws. */
118
+ async function readAuthFile() {
119
+ const path = getRegistryAuthPath()
120
+ if (!existsSync(path)) return normalizeSessionFile(null, DEFAULT_BACKEND_ORIGIN)
121
+ try {
122
+ return normalizeSessionFile(JSON.parse(await readFile(path, 'utf8')), DEFAULT_BACKEND_ORIGIN)
123
+ } catch {
124
+ return normalizeSessionFile(null, DEFAULT_BACKEND_ORIGIN)
125
+ }
126
+ }
127
+
128
+ async function writeAuthFile({ version, current, sessions }) {
129
+ await mkdir(getAuthDir(), { recursive: true })
130
+ await writeFile(
131
+ getRegistryAuthPath(),
132
+ JSON.stringify({ version, ...(current ? { current } : {}), sessions }, null, 2)
133
+ )
134
+ }
135
+
136
+ /**
137
+ * The stored session FOR ONE BACKEND, or null. No JWT backfill — the token is
138
+ * opaque, so there are no claims to decode (unlike legacy readAuth()).
139
+ *
140
+ * ⛔ `origin` is required in spirit: calling this without one used to mean "the
141
+ * session", which is the assumption this file exists to remove. It falls back to the
142
+ * default backend rather than throwing, because every caller now passes one and a
143
+ * throw here would turn a stale call site into a crash instead of a wrong answer.
144
+ *
145
+ * @param {string} [origin] - the backend whose session is wanted
146
+ * @returns {Promise<{ token: string, expiresAt?: string, accountId?: number, sessionId?: number, username?: string, handle?: string, uuid?: string, origin: string } | null>}
147
+ */
148
+ export async function readRegistryAuth(origin) {
149
+ const key = normOrigin(origin || DEFAULT_BACKEND_ORIGIN)
150
+ const { sessions } = await readAuthFile()
151
+ const found = sessions[key]
152
+ return found && typeof found.token === 'string' ? { ...found, origin: key } : null
153
+ }
154
+
155
+ /**
156
+ * Persist THE session — the backend the user is now logged in to, replacing whatever
157
+ * the file held. The record's own `origin` is the key, so every login path keeps
158
+ * stamping it exactly as before.
159
+ * @param {Object} record - must carry `origin` and `token`
160
+ */
161
+ export async function writeRegistryAuth(record) {
162
+ const key = normOrigin(record?.origin || DEFAULT_BACKEND_ORIGIN)
163
+ const { origin: _drop, ...rest } = record || {}
164
+ // ⭐ REPLACE, never merge: every caller is a successful login, and there is one session.
165
+ // Whatever the file held — another backend's session, or several from before —
166
+ // is gone: the user is logged in to this backend, and only this one.
167
+ await writeAuthFile({ version: 2, current: key, sessions: { [key]: rest } })
168
+ }
169
+
170
+ /**
171
+ * Log out: delete the session file. There is one session, so there is nothing to choose.
172
+ *
173
+ * ⚠️ Local only — the token is forgotten on this machine, not revoked on the backend.
174
+ *
175
+ * @returns {Promise<string[]>} the origins whose sessions were removed — one, or
176
+ * several for a file written before sessions were single (2026-09-21)
177
+ */
178
+ export async function clearRegistryAuth() {
179
+ const path = getRegistryAuthPath()
180
+ if (!existsSync(path)) return []
181
+ const had = Object.keys((await readAuthFile()).sessions)
182
+ await unlink(path)
183
+ return had
184
+ }
185
+
186
+ /**
187
+ * Username/password login against the new backend. POSTs to
188
+ * `{apiBase}/dev/auth/login`, reads the opaque token from the JSON body
189
+ * (the HttpOnly cookie the response also sets is ignored on the CLI), and
190
+ * persists it. Throws on non-2xx or a tokenless body.
191
+ *
192
+ * @param {Object} params
193
+ * @param {string} params.apiBase - new-backend origin, e.g. http://localhost:8080
194
+ * @param {string} params.username
195
+ * @param {string} params.password
196
+ * @returns {Promise<Object>} the stored session record (incl. `token`)
197
+ */
198
+ export async function loginToRegistry({ apiBase, username, password } = {}) {
199
+ if (!apiBase) throw new Error('loginToRegistry: apiBase is required')
200
+ const url = `${apiBase.replace(/\/$/, '')}${LOGIN_PATH}`
201
+
202
+ let res
203
+ try {
204
+ res = await fetch(url, {
205
+ method: 'POST',
206
+ headers: { 'Content-Type': 'application/json' },
207
+ body: JSON.stringify({ username, password })
208
+ })
209
+ } catch (err) {
210
+ throw new Error(
211
+ `Could not reach the login endpoint at ${url}: ${err.message}`
212
+ )
213
+ }
214
+
215
+ if (!res.ok) {
216
+ let detail = ''
217
+ try {
218
+ detail = (await res.text()).slice(0, 300)
219
+ } catch {
220
+ /* ignore */
221
+ }
222
+ const e = new Error(
223
+ `Login failed: HTTP ${res.status} ${res.statusText}${detail ? ` — ${detail}` : ''}`
224
+ )
225
+ e.status = res.status
226
+ throw e
227
+ }
228
+
229
+ const data = await res.json().catch(() => null)
230
+ if (!data?.token)
231
+ throw new Error('Login succeeded but the response carried no token.')
232
+
233
+ // New-backend login body (agreed with backend, 2026-05-26):
234
+ // { token, expires_at, account: { uuid, username, handle } }
235
+ // token/expires_at stay top-level; identity is nested under `account`. The
236
+ // token is opaque (no claims), and `expires_at` is stored as `expiresAt` so
237
+ // the shared isExpired() works unchanged. Tolerant of the pre-#2 flat body
238
+ // (no `account` key) — token + expiry still resolve, identity is just skipped.
239
+ const account = data.account || {}
240
+ const record = { token: data.token, origin: normOrigin(apiBase) }
241
+ if (data.expires_at) record.expiresAt = data.expires_at
242
+ if (account.uuid) record.uuid = account.uuid
243
+ if (account.username) record.username = account.username
244
+ if (account.handle) record.handle = account.handle
245
+
246
+ await writeRegistryAuth(record)
247
+ return record
248
+ }
249
+
250
+ /**
251
+ * Ensure a usable new-backend bearer for `register`. Resolution order:
252
+ * UNIWEB_TOKEN env > stored unexpired session > UNIWEB_USERNAME/PASSWORD env
253
+ * (non-interactive) > interactive username/password prompt.
254
+ *
255
+ * In non-interactive mode with no token/env creds, bails with an actionable
256
+ * error instead of hanging on a prompt (mirrors ensureAuth's CI guard).
257
+ *
258
+ * @param {Object} options
259
+ * @param {string} options.apiBase - new-backend origin (e.g. http://localhost:8080)
260
+ * @param {string} [options.command] - command needing auth (for messaging)
261
+ * @param {string[]} [options.args] - argv slice; checked for --non-interactive
262
+ * @returns {Promise<string>} bearer token
263
+ */
264
+ /** `args` without `--token` and its value — see ensureRegistryAuth. */
265
+ export function withoutLoginToken(args = []) {
266
+ const out = []
267
+ for (let i = 0; i < args.length; i++) {
268
+ const a = args[i]
269
+ if (a === '--token') {
270
+ i++ // and its value
271
+ continue
272
+ }
273
+ if (a.startsWith('--token=')) continue
274
+ out.push(a)
275
+ }
276
+ return out
277
+ }
278
+
279
+ export async function ensureRegistryAuth({
280
+ apiBase,
281
+ command = 'This command',
282
+ args = []
283
+ } = {}) {
284
+ if (process.env.UNIWEB_TOKEN) return process.env.UNIWEB_TOKEN
285
+
286
+ // ⛔ THE SESSION MUST BE THE ONE ISSUED FOR `apiBase`. Until 2026-09-20 this read
287
+ // "the" session and returned its token whenever it was unexpired — so a bearer minted
288
+ // by backend A was sent to backend B, which rejects it. The store is keyed by origin
289
+ // now, so asking for the right one is the same single read.
290
+ const stored = await readRegistryAuth(apiBase)
291
+ if (stored?.token && !isExpired(stored)) return stored.token
292
+
293
+ // Non-interactive login from env (CI / agents) before any prompt.
294
+ const envUser = process.env.UNIWEB_USERNAME
295
+ const envPass = process.env.UNIWEB_PASSWORD
296
+ if (envUser && envPass) {
297
+ const record = await loginToRegistry({
298
+ apiBase,
299
+ username: envUser,
300
+ password: envPass
301
+ })
302
+ return record.token
303
+ }
304
+
305
+ const { isNonInteractive, getCliPrefix } = await import('./interactive.js')
306
+ if (isNonInteractive(args)) {
307
+ const prefix = getCliPrefix()
308
+ const reason =
309
+ stored && isExpired(stored) ? 'Session expired.' : 'Not logged in.'
310
+ console.error(
311
+ `\x1b[31m✗\x1b[0m ${reason} ${command} requires a Uniweb account, and the CLI is non-interactive (CI / no TTY / --non-interactive).`
312
+ )
313
+ console.error(' Options:')
314
+ console.error(` • Set UNIWEB_TOKEN to a bearer token.`)
315
+ console.error(
316
+ ` • Set UNIWEB_USERNAME + UNIWEB_PASSWORD to log in non-interactively.`
317
+ )
318
+ console.error(
319
+ ` • Run \`${prefix} login\` interactively first, then re-run.`
320
+ )
321
+ process.exit(1)
322
+ }
323
+
324
+ if (stored && isExpired(stored)) {
325
+ console.error(
326
+ `\x1b[33mSession expired.\x1b[0m ${command} requires a Uniweb account.\n`
327
+ )
328
+ } else {
329
+ console.error(`${command} requires a Uniweb account.\n`)
330
+ }
331
+
332
+ // Interactive: hand off to the multi-method login picker, reuse its session.
333
+ // ⛔ WITHOUT `--token`: in `runRegistryLogin` it means "seed and STORE this bearer as a
334
+ // session" — `uniweb login --token`'s job alone. A command's own argv reaching it
335
+ // would log the machine in as a side effect of that command. (The backend commands
336
+ // refuse `--token` since 2026-09-21; this holds for any other caller too.)
337
+ const record = await runRegistryLogin({ apiBase, args: withoutLoginToken(args) })
338
+ if (!record?.token) process.exit(1)
339
+ return record.token
340
+ }
341
+
342
+
343
+ /**
344
+ * GET /dev/auth/me with a bearer → the account object ({ uuid, username,
345
+ * handle }), or throws. Used to verify + identify a pasted token.
346
+ */
347
+ export async function fetchMe({ apiBase, token }) {
348
+ const res = await fetch(`${apiBase.replace(/\/$/, '')}/dev/auth/me`, {
349
+ headers: { Authorization: `Bearer ${token}` }
350
+ })
351
+ if (!res.ok) {
352
+ const e = new Error(
353
+ `token check failed: HTTP ${res.status} ${res.statusText}`
354
+ )
355
+ e.status = res.status
356
+ throw e
357
+ }
358
+ const data = await res.json().catch(() => null)
359
+ return data?.account || null
360
+ }
361
+
362
+ // Username/password — prompts unless UNIWEB_USERNAME/PASSWORD are set.
363
+ async function loginViaPassword({ apiBase, nonInteractive }) {
364
+ let username = process.env.UNIWEB_USERNAME
365
+ let password = process.env.UNIWEB_PASSWORD
366
+ if (!username || !password) {
367
+ if (nonInteractive) {
368
+ throw new Error(
369
+ 'username/password login needs a terminal, or UNIWEB_USERNAME + UNIWEB_PASSWORD.'
370
+ )
371
+ }
372
+ const prompts = (await import('prompts')).default
373
+ const resp = await prompts(
374
+ [
375
+ {
376
+ type: 'text',
377
+ name: 'username',
378
+ message: 'Username:',
379
+ validate: (v) => (v ? true : 'Username is required')
380
+ },
381
+ {
382
+ type: 'password',
383
+ name: 'password',
384
+ message: 'Password:',
385
+ validate: (v) => (v ? true : 'Password is required')
386
+ }
387
+ ],
388
+ {
389
+ onCancel: () => {
390
+ console.error('\nLogin cancelled.')
391
+ process.exit(0)
392
+ }
393
+ }
394
+ )
395
+ username = resp.username
396
+ password = resp.password
397
+ if (!username || !password) process.exit(1)
398
+ }
399
+ return loginToRegistry({ apiBase, username, password })
400
+ }
401
+
402
+ // Paste a token — verified + identified via /me before it's stored.
403
+ async function loginViaTokenPaste({ apiBase, nonInteractive }) {
404
+ if (nonInteractive) {
405
+ throw new Error('token paste needs a terminal — pass the token instead: uniweb login --token <bearer>.')
406
+ }
407
+ const prompts = (await import('prompts')).default
408
+ const { token } = await prompts(
409
+ {
410
+ type: 'password',
411
+ name: 'token',
412
+ message: 'Paste your token:',
413
+ validate: (v) => (v ? true : 'Token is required')
414
+ },
415
+ {
416
+ onCancel: () => {
417
+ console.error('\nLogin cancelled.')
418
+ process.exit(0)
419
+ }
420
+ }
421
+ )
422
+ if (!token) process.exit(1)
423
+ const account = await fetchMe({ apiBase, token }) // throws if the token is invalid
424
+ const record = { token, origin: normOrigin(apiBase) }
425
+ if (account?.uuid) record.uuid = account.uuid
426
+ if (account?.username) record.username = account.username
427
+ if (account?.handle) record.handle = account.handle
428
+ await writeRegistryAuth(record)
429
+ return record
430
+ }
431
+
432
+ // Open a URL in the default browser. Returns whether it launched.
433
+ // Exported for the publish payment refusal, which opens the backend's
434
+ // settlement URL VERBATIM and needs no loopback (backend/payment-handoff.js).
435
+ export async function openBrowser(url) {
436
+ // ⛔ THE URL ARRIVES OVER THE NETWORK — a backend hands it to us and we open it.
437
+ //
438
+ // This built a SHELL STRING (`open "${url}"`) and interpolated that value into
439
+ // it. A `"` in the URL closes the quoted argument and everything after it is
440
+ // shell: one crafted or corrupted response, and the rest runs as the user. It
441
+ // was on the login path before it was on the publish path.
442
+ //
443
+ // ⇒ Two changes, and neither is about payment:
444
+ // 1. `execFile` with the URL as an ARGUMENT — no shell, so no quoting to get
445
+ // wrong and nothing to escape.
446
+ // 2. Only `http:` / `https:` are opened. `file:`, `javascript:` and the rest
447
+ // are not places a person goes, and refusing them costs nothing real.
448
+ // (The caller checks too; a safety rule that only holds at one call site
449
+ // is one refactor from being gone.)
450
+ if (!/^https?:\/\//i.test(String(url || ''))) return false
451
+ try {
452
+ const { execFile } = await import('node:child_process')
453
+ // `start` is a cmd.exe builtin rather than an executable, so Windows keeps a
454
+ // shell — but through `cmd /c` with the URL as its own argv entry, which is
455
+ // what removes the interpolation. The empty string is `start`'s title slot.
456
+ const [cmd, args] =
457
+ process.platform === 'darwin'
458
+ ? ['open', [url]]
459
+ : process.platform === 'win32'
460
+ ? ['cmd', ['/c', 'start', '', url]]
461
+ : ['xdg-open', [url]]
462
+ return await new Promise((resolve) =>
463
+ execFile(cmd, args, (err) => resolve(!err))
464
+ )
465
+ } catch {
466
+ return false
467
+ }
468
+ }
469
+
470
+ /**
471
+ * One-shot browser loopback — the reusable primitive behind both `uniweb login`
472
+ * (token-in-redirect) and `uniweb publish`'s payment handoff (done-signal).
473
+ *
474
+ * Hosts a one-shot `127.0.0.1` server on an ephemeral port, opens the browser to
475
+ * a URL built from that port, and resolves once the browser is redirected back
476
+ * to `/callback`. The value never leaves browser→localhost. Provider-agnostic:
477
+ * the CALLER supplies the URL to open (given the loopback redirect URI) and a
478
+ * validator that inspects the callback query — so the same tested server serves
479
+ * any "open a page, wait for it to come back" flow.
480
+ *
481
+ * @param {object} o
482
+ * @param {(redirectUri: string) => string} o.buildUrl - the URL to open, given the loopback /callback URI
483
+ * @param {(params: URLSearchParams) => ({ value: any } | { error: string })} o.validate
484
+ * - inspect the callback query; return `{ value }` to succeed or `{ error }` to fail
485
+ * @param {number} [o.timeoutMs=120000]
486
+ * @param {string} [o.openingLabel] - the "opening…" line
487
+ * @param {string} [o.waitingLabel] - the "waiting…" line
488
+ * @param {string} [o.okTitle='Done'] - success page heading
489
+ * @param {string} [o.errTitle='Something went wrong'] - failure page heading
490
+ * @returns {Promise<any>} the validated `value`
491
+ * @throws on validation error, timeout, or a loopback server error
492
+ */
493
+ export async function awaitBrowserCallback({
494
+ buildUrl,
495
+ validate,
496
+ timeoutMs = 120000,
497
+ openingLabel = 'Opening your browser…',
498
+ waitingLabel,
499
+ okTitle = 'Done',
500
+ errTitle = 'Something went wrong'
501
+ } = {}) {
502
+ const result = await new Promise((resolve) => {
503
+ const server = createServer((req, res) => {
504
+ const u = new URL(req.url, 'http://127.0.0.1')
505
+ if (u.pathname !== '/callback') {
506
+ res.writeHead(404)
507
+ res.end('Not found')
508
+ return
509
+ }
510
+ const verdict = validate(u.searchParams) || {
511
+ error: 'no result from the callback.'
512
+ }
513
+ const failed = !!verdict.error
514
+ res.writeHead(failed ? 400 : 200, { 'Content-Type': 'text/html' })
515
+ res.end(
516
+ `<!doctype html><html><body style="font-family:system-ui,sans-serif;text-align:center;padding:60px">` +
517
+ `<h2 style="color:${failed ? '#dc2626' : '#16a34a'}">${failed ? errTitle : okTitle}</h2>` +
518
+ `<p>You can close this tab and return to your terminal.</p></body></html>`
519
+ )
520
+ cleanup()
521
+ resolve(failed ? { error: verdict.error } : { value: verdict.value })
522
+ })
523
+ let timer
524
+ function cleanup() {
525
+ clearTimeout(timer)
526
+ server.close()
527
+ }
528
+ server.on('error', (e) =>
529
+ resolve({ error: `loopback server error: ${e.message}` })
530
+ )
531
+ server.listen(0, '127.0.0.1', async () => {
532
+ const { port } = server.address()
533
+ const redirectUri = `http://127.0.0.1:${port}/callback`
534
+ const url = buildUrl(redirectUri)
535
+ console.error(`\x1b[36m→\x1b[0m ${openingLabel}`)
536
+ console.error(` \x1b[2m${url}\x1b[0m`)
537
+ const opened = await openBrowser(url)
538
+ if (!opened)
539
+ console.error(
540
+ '\x1b[33m⚠\x1b[0m Could not open a browser automatically — open the URL above.'
541
+ )
542
+ console.error(
543
+ `\x1b[2m${waitingLabel || `Waiting (${Math.round(timeoutMs / 1000)}s)…`}\x1b[0m`
544
+ )
545
+ })
546
+ timer = setTimeout(() => {
547
+ server.close()
548
+ resolve({ error: `timed out (${Math.round(timeoutMs / 1000)}s).` })
549
+ }, timeoutMs)
550
+ })
551
+ if (result.error) throw new Error(result.error)
552
+ return result.value
553
+ }
554
+
555
+ // Browser / social — the backend's CLI delegation flow. The CLI never speaks to
556
+ // an identity provider and holds no client id, secret or provider knowledge: it
557
+ // opens ONE url (the backend's), catches a one-time code on a loopback, and
558
+ // trades that code for a bearer. Whatever methods the backend's own sign-in page
559
+ // offers — password, Google, Microsoft, anything added later — the CLI gains with
560
+ // no change here, because it never learns which one was used.
561
+ //
562
+ // Three legs, all verified against a live backend rather than read:
563
+ //
564
+ // GET {base}/dev/auth/authorize?callback=<loopback>&state=<nonce>
565
+ // no session → 302 {hub}/login?returnTo=… (the ordinary sign-in page)
566
+ // session → 302 <callback>?state=<ours>&code=<one-time>
567
+ // POST {base}/dev/auth/token {code} → { token, expires_at, account }
568
+ //
569
+ // ⛔ `callback` IS THE PARAMETER NAME, not `redirect_uri` — the backend serves
570
+ // this route and rejects the other spelling with a 400. ⛔ AND THE CALLBACK
571
+ // CARRIES A `code`, NEVER A TOKEN: the bearer is born on the POST, server-to-CLI,
572
+ // so it never touches the browser, the URL bar, history or a proxy log. Both were
573
+ // wrong here for three months — this code was written against an anticipated
574
+ // shape five days before the backend shipped, and being gated meant nothing could
575
+ // contradict it.
576
+ //
577
+ // ⛔ The loopback MUST be a v4 literal. `awaitBrowserCallback` binds 127.0.0.1
578
+ // explicitly and composes `http://127.0.0.1:<port>/callback`; the backend's
579
+ // validator accepts `http://` only, host exactly `127.0.0.1` or `localhost`, and
580
+ // refuses `[::1]` and any `user@host` (that last guard stops
581
+ // `http://127.0.0.1:1@evil.com/cb` from walking off with the code). Do not
582
+ // "modernise" the bind to `::`.
583
+ async function loginViaBrowser({ apiBase }) {
584
+ const base = apiBase.replace(/\/$/, '')
585
+ const state = randomBytes(16).toString('hex')
586
+
587
+ // Leg 1+2 — open the backend's authorize url, catch the one-time code.
588
+ const code = await awaitBrowserCallback({
589
+ buildUrl: (callback) =>
590
+ `${base}/dev/auth/authorize?callback=${encodeURIComponent(callback)}&state=${state}`,
591
+ validate: (params) => {
592
+ if (params.get('error')) return { error: params.get('error') }
593
+ // Check `state` BEFORE reading anything else: it is the only thing that
594
+ // ties this callback to the request we made.
595
+ if (params.get('state') !== state)
596
+ return { error: 'state mismatch — please try again.' }
597
+ const code = params.get('code')
598
+ if (!code) return { error: 'no code returned by the callback.' }
599
+ return { value: code }
600
+ },
601
+ openingLabel: 'Opening your browser to sign in…',
602
+ waitingLabel: 'Waiting for sign-in to complete (5 min)…',
603
+ // A person is signing in at an identity provider, not clicking one button:
604
+ // a first-time Google or Microsoft login can carry a consent screen, an
605
+ // account chooser and 2FA. The default 120s expires mid-flow and reports a
606
+ // TIMEOUT, which reads as "the CLI is broken" rather than "you were slow".
607
+ // Same reasoning, and the same value, as the publish payment handoff.
608
+ timeoutMs: 5 * 60 * 1000,
609
+ okTitle: 'Login successful',
610
+ errTitle: 'Login failed'
611
+ })
612
+
613
+ // Leg 3 — trade the code for a bearer. Anonymous: the code IS the credential,
614
+ // and it is single-use, so a replay gets a 400 rather than a second session.
615
+ const res = await fetch(`${base}/dev/auth/token`, {
616
+ method: 'POST',
617
+ headers: { 'Content-Type': 'application/json' },
618
+ body: JSON.stringify({ code })
619
+ })
620
+ const payload = await res.json().catch(() => null)
621
+ if (!res.ok || !payload?.token) {
622
+ const detail = payload?.detail || payload?.title || `HTTP ${res.status}`
623
+ throw new Error(`could not complete sign-in: ${detail}`)
624
+ }
625
+
626
+ // The token response already carries the account, so no second round trip.
627
+ // `fetchMe` remains the fallback for a backend that answers without one.
628
+ let account = payload.account || null
629
+ if (!account) {
630
+ try {
631
+ account = await fetchMe({ apiBase, token: payload.token })
632
+ } catch {
633
+ /* identity is optional; the bearer is valid either way */
634
+ }
635
+ }
636
+
637
+ const record = { token: payload.token, origin: normOrigin(apiBase) }
638
+ if (payload.expires_at) record.expiresAt = payload.expires_at
639
+ if (account?.uuid) record.uuid = account.uuid
640
+ if (account?.username) record.username = account.username
641
+ if (account?.handle) record.handle = account.handle
642
+ await writeRegistryAuth(record)
643
+ return record
644
+ }
645
+
646
+ /**
647
+ * Choose the WORKSPACE this login works in, and store it with the session.
648
+ *
649
+ * ⭐ A login works in one workspace *[Diego, 2026-09-23: "at login, it can be mandatory to
650
+ * specify which workspace"]* — `--org @acme` / `--personal`, else your personal workspace
651
+ * when you belong to no organization, else a pick at a terminal. Without one (no
652
+ * terminal, organizations, no flag) the login stays and this says how to choose; every
653
+ * command refuses until one is chosen (`backend/workspace.js`).
654
+ *
655
+ * @param {object} record - the session just stored (`token`, `origin`, …)
656
+ * @param {string[]} args
657
+ * @returns {Promise<{ record: object, refused?: string }>}
658
+ */
659
+ async function settleWorkspace(record, args) {
660
+ const { chooseWorkspace } = await import('../backend/workspace.js')
661
+ const pick = await chooseWorkspace({ apiBase: record.origin, token: record.token, args })
662
+ if (pick.refused) return { record, refused: pick.reason }
663
+ if (pick.note) console.error(`\x1b[2m${pick.note}\x1b[0m`)
664
+ const next = { ...record, workspace: pick.choice }
665
+ await writeRegistryAuth(next)
666
+ return { record: next }
667
+ }
668
+
669
+ /** ` · working in @acme` — the tail of a login line. */
670
+ async function workspaceTail(record) {
671
+ if (!record?.workspace) return ''
672
+ const { describeWorkspace } = await import('../backend/workspace.js')
673
+ return `, working in \x1b[1m${describeWorkspace(record.workspace)}\x1b[0m`
674
+ }
675
+
676
+ /**
677
+ * `uniweb login` against the new backend — a multi-method picker:
678
+ * browser/social (default, once available) · username+password · paste a token.
679
+ * Force a method with --browser / --password / --token-paste (skips the menu).
680
+ * No TTY → no menu; falls back to the non-browser path (env UNIWEB_USERNAME/
681
+ * PASSWORD, or `--token <bearer>`).
682
+ *
683
+ * ⭐ UNIWEB_TOKEN IS NOT A LOGIN METHOD. It is the one-process bearer each backend
684
+ * command reads first, ahead of any stored session (ensureRegistryAuth), and it is
685
+ * never stored — so in a process that has it, login only checks it and says so
686
+ * (reportTokenProcess). ⛔ This note used to say "UNIWEB_TOKEN is handled earlier by
687
+ * ensureRegistryAuth", true of the backend commands and not of login, which never
688
+ * calls it; read as covering login, it put "set UNIWEB_TOKEN" into login's own
689
+ * refusal — which, with the variable set, refused the same way (measured on 0.88.0).
690
+ *
691
+ * @param {Object} o
692
+ * @param {string} o.apiBase - new-backend origin
693
+ * @param {string[]} [o.args] - argv slice (force flags + --non-interactive)
694
+ * @returns {Promise<Object|undefined>} the stored session record
695
+ */
696
+ export async function runRegistryLogin({ apiBase, args = [] } = {}) {
697
+ // For THIS backend. It read "the" session and printed `apiBase` beside it, so on a
698
+ // machine logged into another backend it announced "Already logged in … (this origin)"
699
+ // about a session belonging to a different one — and then offered to replace it.
700
+ const { isNonInteractive, getCliPrefix } = await import('./interactive.js')
701
+ const nonInteractive = isNonInteractive(args)
702
+
703
+ const key = normOrigin(apiBase)
704
+ const file = await readAuthFile()
705
+ const current = loggedInOriginOf(file)
706
+ const existing = await readRegistryAuth(apiBase)
707
+ const forced =
708
+ args.includes('--token') ||
709
+ args.includes('--browser') ||
710
+ args.includes('--password') ||
711
+ args.includes('--token-paste')
712
+
713
+ // A process with UNIWEB_TOKEN is authenticated by it, ahead of any session: a login
714
+ // that asks for nothing has nothing to do but check it and say so. One that asks for
715
+ // something — a way of signing in, or a workspace switch — still does it, for the stored
716
+ // session, after saying that this process does not use that session.
717
+ const envToken = process.env.UNIWEB_TOKEN
718
+ if (envToken) {
719
+ if (!forced && !readOrgFlag(args) && !args.includes('--personal')) {
720
+ const named = args.some((a) => a === '--server' || a.startsWith('--server='))
721
+ return reportTokenProcess({ named: named ? key : null, token: envToken })
722
+ }
723
+ console.error(
724
+ `\x1b[2mUNIWEB_TOKEN is set, so this process's backend commands use it (and UNIWEB_WORKSPACE) — not the session or workspace this login stores.\x1b[0m\n`
725
+ )
726
+ }
727
+
728
+ if (existing?.token && !isExpired(existing)) {
729
+ const who =
730
+ existing.username ||
731
+ existing.handle ||
732
+ (existing.uuid ? `account ${existing.uuid}` : '')
733
+ if (!forced) {
734
+ // Already logged in to this backend: nothing to re-authenticate — logging in again is
735
+ // behind a method flag. (Also where a file from before sessions were single, holding
736
+ // this backend among others, becomes single: the rewrite keeps only this session.)
737
+ let session = { ...existing, origin: key }
738
+ if (current !== key || Object.keys(file.sessions).length > 1) {
739
+ await writeRegistryAuth(session)
740
+ }
741
+ // ⭐ SWITCHING WORKSPACE NEEDS NO NEW LOGIN: `--org @x` / `--personal` on a valid
742
+ // session just changes the workspace it works in. One with none chosen yet (a
743
+ // session from before workspaces) is asked now.
744
+ const wants = readOrgFlag(args) || args.includes('--personal')
745
+ if (wants || !session.workspace) {
746
+ const settled = await settleWorkspace(session, args)
747
+ if (settled.refused) {
748
+ // Say what the session works in now: unchanged, or — one from before workspaces —
749
+ // nothing yet (`finishLogin`).
750
+ const where = session.workspace
751
+ ? `${await workspaceTail(session)} — unchanged`
752
+ : ' — with no workspace chosen yet'
753
+ console.error(`\x1b[32m✓\x1b[0m Logged in to ${key}${who ? ` as \x1b[1m${who}\x1b[0m` : ''}${where}.`)
754
+ console.error(`\x1b[31m✗\x1b[0m ${settled.refused}`)
755
+ process.exit(2)
756
+ }
757
+ session = settled.record
758
+ if (wants) {
759
+ console.error(`\x1b[32m✓\x1b[0m Now working in ${(await workspaceTail(session)).replace(/^, working in /, '')} (${key}).`)
760
+ return session
761
+ }
762
+ }
763
+ console.error(
764
+ `\x1b[32m✓\x1b[0m Already logged in to ${key}${who ? ` as \x1b[1m${who}\x1b[0m` : ''}${await workspaceTail(session)}.`
765
+ )
766
+ console.error(
767
+ '\x1b[2mSwitch workspace: --org @acme or --personal. To log in again, name a method: --password, --browser, --token-paste or --token <bearer>.\x1b[0m'
768
+ )
769
+ return session
770
+ }
771
+ console.error(
772
+ `Already logged in${who ? ` as \x1b[1m${who}\x1b[0m` : ''} (${key}) — logging in again replaces that session.\n`
773
+ )
774
+ } else if (current && current !== key) {
775
+ // ⭐ ONE SESSION AT A TIME *[Diego, 2026-09-21]*: logging in here logs you out of there
776
+ // — when this login succeeds (writeRegistryAuth replaces the file), so cancelling or
777
+ // failing leaves you logged in where you were.
778
+ console.error(`\x1b[2mYou are logged in to ${current}; logging in to ${key} logs you out of it.\x1b[0m\n`)
779
+ }
780
+
781
+ // `--token <bearer>` seeds + verifies a session non-interactively (verified
782
+ // against /dev/auth/me before it's stored, so an invalid token fails loudly
783
+ // instead of poisoning the session file). ⭐ Login's alone: the backend commands
784
+ // take no `--token` (2026-09-21) — UNIWEB_TOKEN is the one-process bearer.
785
+ const { readFlagValue } = await import('./args.js')
786
+ const tokenFlag = readFlagValue(args, '--token')
787
+ if (tokenFlag) {
788
+ let account
789
+ try {
790
+ account = await fetchMe({ apiBase, token: tokenFlag })
791
+ } catch (err) {
792
+ console.error(
793
+ `\x1b[31m✗\x1b[0m Token rejected by ${apiBase}: ${err.message}`
794
+ )
795
+ process.exit(1)
796
+ }
797
+ const record = { token: tokenFlag, origin: normOrigin(apiBase) }
798
+ if (account?.uuid) record.uuid = account.uuid
799
+ if (account?.username) record.username = account.username
800
+ if (account?.handle) record.handle = account.handle
801
+ await writeRegistryAuth(record)
802
+ return finishLogin(record, apiBase, args)
803
+ }
804
+
805
+ let method = args.includes('--browser')
806
+ ? 'browser'
807
+ : args.includes('--password')
808
+ ? 'password'
809
+ : args.includes('--token-paste')
810
+ ? 'token-paste'
811
+ : null
812
+
813
+ if (!method) {
814
+ if (nonInteractive) {
815
+ if (process.env.UNIWEB_USERNAME && process.env.UNIWEB_PASSWORD) {
816
+ method = 'password'
817
+ } else {
818
+ // Only what works without a terminal: `--password` and `--token-paste` prompt. The
819
+ // command keeps the backend named, or re-run as printed it would go to the default.
820
+ const server = args.some((a) => a === '--server' || a.startsWith('--server='))
821
+ ? ` --server ${key}`
822
+ : ''
823
+ console.error(
824
+ '\x1b[31m✗\x1b[0m Without a terminal, a login needs a way to sign in that asks nothing:'
825
+ )
826
+ console.error(
827
+ ` ${getCliPrefix()} login${server} --token <bearer> (with --org @org or --personal)`
828
+ )
829
+ console.error(' UNIWEB_USERNAME + UNIWEB_PASSWORD, set for the login')
830
+ console.error(
831
+ ' Or skip the login: with UNIWEB_TOKEN set, every backend command in that process uses it, and nothing is stored.'
832
+ )
833
+ process.exit(1)
834
+ }
835
+ } else {
836
+ const prompts = (await import('prompts')).default
837
+ const choices = []
838
+ choices.push({
839
+ title: 'Browser / social (Google, Microsoft, …)',
840
+ value: 'browser'
841
+ })
842
+ choices.push({ title: 'Username and password', value: 'password' })
843
+ choices.push({ title: 'Paste a token', value: 'token-paste' })
844
+ const { picked } = await prompts(
845
+ {
846
+ type: 'select',
847
+ name: 'picked',
848
+ message: 'How do you want to log in?',
849
+ choices
850
+ },
851
+ {
852
+ onCancel: () => {
853
+ console.error('\nLogin cancelled.')
854
+ process.exit(0)
855
+ }
856
+ }
857
+ )
858
+ if (!picked) process.exit(0)
859
+ method = picked
860
+ }
861
+ }
862
+
863
+ let record
864
+ try {
865
+ if (method === 'browser') record = await loginViaBrowser({ apiBase })
866
+ else if (method === 'token-paste')
867
+ record = await loginViaTokenPaste({ apiBase, nonInteractive })
868
+ else record = await loginViaPassword({ apiBase, nonInteractive })
869
+ } catch (err) {
870
+ console.error(`\x1b[31m✗\x1b[0m ${err.message}`)
871
+ process.exit(1)
872
+ }
873
+
874
+ if (!record?.token) return record
875
+ return finishLogin({ ...record, origin: record.origin || normOrigin(apiBase) }, apiBase, args)
876
+ }
877
+
878
+ /**
879
+ * A login succeeded and its session is stored: choose its workspace, and say where the
880
+ * login works.
881
+ *
882
+ * ⭐ WITHOUT A WORKSPACE THE SESSION STAYS, AND THE LOGIN SAYS SO *[Diego, 2026-10-06]*. No
883
+ * terminal, organizations, neither `--org` nor `--personal` — or a pick cancelled at a
884
+ * terminal: you are logged in, with no workspace, and the login exits 2 naming the one
885
+ * command that finishes it without signing in again. The credential was good, and a
886
+ * second sign-in can be a second browser round trip. ⚠️ So this is the one login that
887
+ * exits non-zero having replaced your session — the reason it says so in as many words.
888
+ */
889
+ async function finishLogin(record, apiBase, args) {
890
+ const settled = await settleWorkspace(record, args)
891
+ const who = settled.record.username ? ` as \x1b[1m${settled.record.username}\x1b[0m` : ''
892
+ if (settled.refused) {
893
+ console.error(`\x1b[32m✓\x1b[0m Logged in to ${normOrigin(apiBase)}${who} — with no workspace chosen yet.`)
894
+ console.error(`\x1b[31m✗\x1b[0m ${settled.refused}`)
895
+ console.error('\x1b[2m Until one is chosen, the commands that work on a site refuse.\x1b[0m')
896
+ process.exit(2)
897
+ }
898
+ console.error(`\x1b[32m✓\x1b[0m Logged in${who} (${apiBase})${await workspaceTail(settled.record)}.`)
899
+ return settled.record
900
+ }
901
+
902
+ /**
903
+ * `uniweb login` in a process that has UNIWEB_TOKEN, asking for nothing: there is no login
904
+ * to do — every backend command here sends the token, ahead of any stored session — so
905
+ * check it with the backend those commands go to, and say who and where they work as.
906
+ *
907
+ * ⭐ CHECKED WHERE IT IS SENT. Those commands go to `getRegistryApiBaseUrl()` — UNIWEB_SERVER,
908
+ * else the backend logged in to, else the default — while a login goes where `--server`
909
+ * says. A login naming another backend is refused rather than checked there: a yes from a
910
+ * backend this process never reaches is a false answer, and the next command would show
911
+ * it as a 401 from the other one.
912
+ *
913
+ * Exits 1 when the token is refused or unreachable, 2 when it is good and its workspace is
914
+ * not settled (as `finishLogin` does); returns nothing, since nothing is stored.
915
+ *
916
+ * @param {object} o
917
+ * @param {string|null} o.named - the origin `--server` named, or null
918
+ * @param {string} o.token
919
+ */
920
+ async function reportTokenProcess({ named, token }) {
921
+ const { getRegistryApiBaseUrl } = await import('./config.js')
922
+ const { getCliPrefix } = await import('./interactive.js')
923
+ const origin = normOrigin(getRegistryApiBaseUrl())
924
+ if (named && named !== origin) {
925
+ console.error(
926
+ `\x1b[31m✗\x1b[0m UNIWEB_TOKEN is set, and this process's backend commands send it to ${origin} — not ${named}.`
927
+ )
928
+ console.error(` To use the token with ${named}: UNIWEB_SERVER=${named}`)
929
+ console.error(` To log in to ${named} instead, unset UNIWEB_TOKEN: ${getCliPrefix()} login --server ${named}`)
930
+ process.exit(1)
931
+ }
932
+
933
+ let account
934
+ try {
935
+ account = await fetchMe({ apiBase: origin, token })
936
+ } catch (err) {
937
+ console.error(
938
+ err.status
939
+ ? `\x1b[31m✗\x1b[0m ${origin} refuses UNIWEB_TOKEN (HTTP ${err.status}).`
940
+ : `\x1b[31m✗\x1b[0m Could not check UNIWEB_TOKEN with ${origin}: ${err.message}`
941
+ )
942
+ process.exit(1)
943
+ }
944
+ const who = account?.username || account?.handle || (account?.uuid ? `account ${account.uuid}` : null)
945
+ console.error(
946
+ `\x1b[32m✓\x1b[0m No login needed: this process's backend commands use UNIWEB_TOKEN, which ${origin} accepts${who ? ` as \x1b[1m${who}\x1b[0m` : ''}.`
947
+ )
948
+ console.error(
949
+ '\x1b[2m It comes before any stored session and is never stored. To store a session, name a way to sign in: --password, --browser, --token-paste or --token <bearer>.\x1b[0m'
950
+ )
951
+
952
+ // Their workspace is UNIWEB_WORKSPACE's — never the stored session's — and, with none
953
+ // named, personal for an account in no organization (resolveWorkspace, workspace.js).
954
+ const { WORKSPACE_ENV, PERSONAL, parseWorkspaceChoice, headerOf, describeWorkspace } =
955
+ await import('../backend/workspace.js')
956
+ const unsettled = (reason, { commandsRefuse }) => {
957
+ console.error(`\x1b[31m✗\x1b[0m ${reason}`)
958
+ if (commandsRefuse) {
959
+ console.error(`\x1b[2m Until a workspace is named, the commands that work on a site refuse.\x1b[0m`)
960
+ }
961
+ process.exit(2)
962
+ }
963
+ const raw = process.env[WORKSPACE_ENV]
964
+ const choice = parseWorkspaceChoice(raw)
965
+ if (raw && !choice) {
966
+ unsettled(`${WORKSPACE_ENV}=${JSON.stringify(raw)} names no workspace — give @org, or personal.`, {
967
+ commandsRefuse: true
968
+ })
969
+ }
970
+ let orgs
971
+ try {
972
+ orgs = (await (await import('./registry-orgs.js')).fetchOrgs({ apiBase: origin, token })).orgs
973
+ } catch (err) {
974
+ console.error(`\x1b[33m⚠\x1b[0m ${err.message} — the workspace is not checked.`)
975
+ return
976
+ }
977
+ const mine = orgs.map((o) => `@${o.handle}`)
978
+ if (choice && choice !== PERSONAL && !mine.includes(choice)) {
979
+ // As a login refuses `--org` for one (chooseWorkspace).
980
+ unsettled(`${WORKSPACE_ENV}=${raw} is not one of this account's organizations on ${origin}.`, {
981
+ commandsRefuse: false
982
+ })
983
+ }
984
+ if (!choice && mine.length) {
985
+ unsettled(
986
+ `The account belongs to organizations, so no workspace is assumed: set ${WORKSPACE_ENV}=${mine[0]} (or another, or personal).`,
987
+ { commandsRefuse: true }
988
+ )
989
+ }
990
+ const where = describeWorkspace(headerOf(choice || PERSONAL))
991
+ console.error(
992
+ ` They work in \x1b[1m${where}\x1b[0m${choice ? ` (${WORKSPACE_ENV})` : ' — the account belongs to no organization'}.`
993
+ )
994
+ }