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.
- package/LICENSE +201 -0
- package/README.md +532 -3
- package/package.json +60 -11
- package/partials/adding-pages.hbs +77 -0
- package/partials/agents.md +3000 -0
- package/partials/ai-assistance.hbs +28 -0
- package/partials/components-docs.hbs +13 -0
- package/partials/config-reference.hbs +66 -0
- package/partials/deployment.hbs +13 -0
- package/partials/learn-more.hbs +6 -0
- package/partials/quick-start.hbs +8 -0
- package/partials/search-docs.hbs +110 -0
- package/src/backend/asset-download.js +222 -0
- package/src/backend/client.js +986 -0
- package/src/backend/foundation-bring-along.js +692 -0
- package/src/backend/payment-handoff.js +362 -0
- package/src/backend/service-request.js +210 -0
- package/src/backend/site-media.js +255 -0
- package/src/backend/site-preview.js +77 -0
- package/src/backend/site-sync.js +2515 -0
- package/src/backend/workspace.js +234 -0
- package/src/commands/add.js +1900 -0
- package/src/commands/build.js +1214 -0
- package/src/commands/clone.js +466 -0
- package/src/commands/content.js +202 -0
- package/src/commands/deploy.js +537 -0
- package/src/commands/dev.js +202 -0
- package/src/commands/docs.js +530 -0
- package/src/commands/doctor.js +1982 -0
- package/src/commands/export.js +116 -0
- package/src/commands/families.js +82 -0
- package/src/commands/forget.js +197 -0
- package/src/commands/handoff.js +21 -0
- package/src/commands/i18n.js +1707 -0
- package/src/commands/inspect.js +375 -0
- package/src/commands/invite.js +24 -0
- package/src/commands/org.js +96 -0
- package/src/commands/publish.js +1087 -0
- package/src/commands/pull.js +1257 -0
- package/src/commands/push.js +663 -0
- package/src/commands/refresh.js +212 -0
- package/src/commands/register.js +1092 -0
- package/src/commands/rename.js +647 -0
- package/src/commands/site.js +358 -0
- package/src/commands/snapshot.js +461 -0
- package/src/commands/starter.js +206 -0
- package/src/commands/status.js +302 -0
- package/src/commands/sync.js +93 -0
- package/src/commands/template.js +107 -0
- package/src/commands/update.js +887 -0
- package/src/commands/validate.js +376 -0
- package/src/framework-index.json +248 -0
- package/src/index.js +2241 -0
- package/src/templates/fetchers/github.js +113 -0
- package/src/templates/fetchers/npm.js +85 -0
- package/src/templates/fetchers/release.js +92 -0
- package/src/templates/index.js +190 -0
- package/src/templates/processor.js +333 -0
- package/src/templates/resolver.js +232 -0
- package/src/templates/validator.js +155 -0
- package/src/utils/agents-stamp.js +110 -0
- package/src/utils/args.js +126 -0
- package/src/utils/asset-upload.js +270 -0
- package/src/utils/bytes.js +30 -0
- package/src/utils/code-upload.js +394 -0
- package/src/utils/config.js +417 -0
- package/src/utils/conformance.js +274 -0
- package/src/utils/dep-survey.js +176 -0
- package/src/utils/destination-prompt.js +146 -0
- package/src/utils/discover.js +75 -0
- package/src/utils/env.js +22 -0
- package/src/utils/exit.js +30 -0
- package/src/utils/fetch-retry.js +109 -0
- package/src/utils/flag-guard.js +246 -0
- package/src/utils/foundation-name.js +179 -0
- package/src/utils/git.js +342 -0
- package/src/utils/host-prompt.js +51 -0
- package/src/utils/install-integrity.js +210 -0
- package/src/utils/interactive.js +118 -0
- package/src/utils/json-file.js +72 -0
- package/src/utils/names.js +91 -0
- package/src/utils/placement.js +151 -0
- package/src/utils/pm.js +165 -0
- package/src/utils/preview.js +20 -0
- package/src/utils/pull-written.js +132 -0
- package/src/utils/records-guard.js +88 -0
- package/src/utils/registry-auth.js +994 -0
- package/src/utils/registry-orgs.js +290 -0
- package/src/utils/scaffold.js +483 -0
- package/src/utils/schemaless-report.js +30 -0
- package/src/utils/semver-precedence.js +80 -0
- package/src/utils/session-file.js +95 -0
- package/src/utils/site-content-refs.js +23 -0
- package/src/utils/site-copies.js +123 -0
- package/src/utils/site-data-upload.js +302 -0
- package/src/utils/site-identity.js +137 -0
- package/src/utils/snapshot-settings.js +112 -0
- package/src/utils/update-check.js +302 -0
- package/src/utils/uwx-read.js +157 -0
- package/src/utils/workspace.js +223 -0
- package/src/versions.js +467 -0
- package/starter/foundation/main.js +50 -0
- package/starter/foundation/sections/Section/index.jsx +95 -0
- package/starter/foundation/sections/Section/meta.js +44 -0
- package/starter/site/pages/about/1-about.md.hbs +13 -0
- package/starter/site/pages/about/page.yml +3 -0
- package/starter/site/pages/home/1-welcome.md.hbs +14 -0
- package/starter/site/pages/home/page.yml +3 -0
- package/templates/foundation/_gitignore +5 -0
- package/templates/foundation/components/.gitkeep +0 -0
- package/templates/foundation/main.js.hbs +33 -0
- package/templates/foundation/package.json.hbs +51 -0
- package/templates/foundation/sections/.gitkeep +0 -0
- package/templates/foundation/styles.css +6 -0
- package/templates/foundation/vite.config.js +3 -0
- package/templates/site/_gitignore +12 -0
- package/templates/site/entry.js +7 -0
- package/templates/site/index.html.hbs +13 -0
- package/templates/site/package.json.hbs +27 -0
- package/templates/site/public/favicon.svg +7 -0
- package/templates/site/site.yml.hbs +91 -0
- package/templates/site/theme.yml +99 -0
- package/templates/site/vite.config.js +7 -0
- package/templates/workspace/AGENTS.md.hbs +2 -0
- package/templates/workspace/README.md.hbs +87 -0
- package/templates/workspace/_gitignore +29 -0
- package/templates/workspace/_vscode/settings.json +6 -0
- package/templates/workspace/package.json.hbs +20 -0
- 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
|
+
}
|