uniweb 0.12.21 → 0.12.22
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/README.md +29 -0
- package/package.json +7 -7
- package/partials/agents.md +41 -11
- package/partials/config-reference.hbs +1 -2
- package/src/commands/add.js +1 -87
- package/src/commands/build.js +2 -2
- package/src/commands/clone.js +337 -0
- package/src/commands/content.js +199 -0
- package/src/commands/deploy.js +27 -6
- package/src/commands/docs.js +2 -3
- package/src/commands/org.js +66 -0
- package/src/commands/pull.js +238 -0
- package/src/commands/push.js +400 -0
- package/src/commands/register.js +274 -0
- package/src/commands/validate.js +288 -0
- package/src/framework-index.json +9 -8
- package/src/index.js +123 -3
- package/src/templates/processor.js +41 -19
- package/src/utils/config.js +21 -0
- package/src/utils/placement.js +100 -0
- package/src/utils/registry-auth.js +380 -0
- package/src/utils/registry-orgs.js +179 -0
- package/src/utils/scaffold.js +14 -3
- package/src/utils/site-content-refs.js +21 -0
- package/templates/foundation/_gitignore +5 -0
- package/templates/site/_gitignore +5 -0
- package/templates/site/package.json.hbs +2 -2
- package/templates/workspace/_gitignore +33 -0
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Package placement resolution — shared by `add` and `clone`.
|
|
3
|
+
*
|
|
4
|
+
* Kept dependency-free (no `@uniweb/build`, no fs) on purpose: `clone` imports
|
|
5
|
+
* it and must run from a global install before any project — anything that
|
|
6
|
+
* statically pulls in `@uniweb/build` would crash `npx uniweb@latest clone`
|
|
7
|
+
* (the same reason `utils/workspace.js` loads the classifier lazily).
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
/** Foundation placement defaults (folder `src/`, package `src`). */
|
|
11
|
+
export const FOUNDATION_KIND = { defaultDir: 'src', defaultPkg: 'src', projectSub: 'src' }
|
|
12
|
+
|
|
13
|
+
/** Site placement defaults (folder `site/`, package `site`). */
|
|
14
|
+
export const SITE_KIND = { defaultDir: 'site', defaultPkg: 'site', projectSub: 'site' }
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Resolve where a foundation or site should be placed, given the user's input.
|
|
18
|
+
*
|
|
19
|
+
* The rule: **the user names a folder, and we create exactly that folder.**
|
|
20
|
+
* No silent nesting under `foundations/` / `sites/`, no inferring layout from
|
|
21
|
+
* pre-existing globs. The framework doesn't require any particular folder
|
|
22
|
+
* structure (the build classifies packages by their contents, not their
|
|
23
|
+
* location), so the CLI shouldn't impose one.
|
|
24
|
+
*
|
|
25
|
+
* Resolution priority (foundation example, same shape for site):
|
|
26
|
+
*
|
|
27
|
+
* 1. `--path <dir>` → explicit folder. Name is the path's
|
|
28
|
+
* last segment (used as the package
|
|
29
|
+
* name unless `name` was also given).
|
|
30
|
+
* 2. `name` contains `/` → treat as a path (e.g., `foundations/ui`).
|
|
31
|
+
* Folder = the path, package name =
|
|
32
|
+
* the last segment.
|
|
33
|
+
* 3. `name` (no slash) → folder = `<name>/`, package name = `<name>`.
|
|
34
|
+
* 4. `--project <project>` → folder = `<project>/<defaultSub>` and
|
|
35
|
+
* package name = `<project>-<defaultSub>`
|
|
36
|
+
* (the co-located convention; only this
|
|
37
|
+
* one uses the `-src` / `-site` suffix).
|
|
38
|
+
* 5. (no input) → folder = `<defaultDir>/`, package name
|
|
39
|
+
* = `<defaultPkg>` (`src/` + `src`
|
|
40
|
+
* for foundations; `site/` + `site` for
|
|
41
|
+
* sites).
|
|
42
|
+
*
|
|
43
|
+
* @param {string} rootDir
|
|
44
|
+
* @param {string|null} name - Either a bare name or a path-with-slash.
|
|
45
|
+
* @param {{ path?: string, project?: string }} opts
|
|
46
|
+
* @param {{ defaultDir: string, defaultPkg: string, projectSub: string }} kind
|
|
47
|
+
* @returns {{ relativePath: string, packageName: string }}
|
|
48
|
+
*/
|
|
49
|
+
export function resolvePlacement(rootDir, name, opts, kind) {
|
|
50
|
+
// 1. --path is a PARENT directory. The folder is `<path>/<name>` if a
|
|
51
|
+
// name was given, or `<path>` itself if not (the path's last segment
|
|
52
|
+
// is then taken as the package name).
|
|
53
|
+
if (opts.path) {
|
|
54
|
+
const parent = opts.path.replace(/\/+$/, '')
|
|
55
|
+
if (name) {
|
|
56
|
+
const last = name.split('/').filter(Boolean).pop()
|
|
57
|
+
return {
|
|
58
|
+
relativePath: `${parent}/${name}`.replace(/\/+/g, '/'),
|
|
59
|
+
packageName: last,
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
const lastSegment = parent.split('/').filter(Boolean).pop() || parent
|
|
63
|
+
return {
|
|
64
|
+
relativePath: parent,
|
|
65
|
+
packageName: lastSegment,
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// 2. name contains a slash → treat as a path.
|
|
70
|
+
if (name && name.includes('/')) {
|
|
71
|
+
const relativePath = name.replace(/\/+$/, '')
|
|
72
|
+
const lastSegment = relativePath.split('/').filter(Boolean).pop()
|
|
73
|
+
return {
|
|
74
|
+
relativePath,
|
|
75
|
+
packageName: lastSegment,
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// 3. Bare name.
|
|
80
|
+
if (name) {
|
|
81
|
+
return {
|
|
82
|
+
relativePath: name,
|
|
83
|
+
packageName: name,
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// 4. --project (co-located convention with -src / -site suffix).
|
|
88
|
+
if (opts.project) {
|
|
89
|
+
return {
|
|
90
|
+
relativePath: `${opts.project}/${kind.projectSub}`,
|
|
91
|
+
packageName: `${opts.project}-${kind.projectSub}`,
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// 5. Default placement.
|
|
96
|
+
return {
|
|
97
|
+
relativePath: kind.defaultDir,
|
|
98
|
+
packageName: kind.defaultPkg,
|
|
99
|
+
}
|
|
100
|
+
}
|
|
@@ -0,0 +1,380 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Registry (new-backend) credential storage + login.
|
|
3
|
+
*
|
|
4
|
+
* SEPARATE from utils/auth.js on purpose. That module is the LEGACY platform
|
|
5
|
+
* auth — browser/social login via `cli-auth.php`, a JWT shared by `publish`
|
|
6
|
+
* and `deploy`, stored in ~/.uniweb/auth.json. This module serves the NEW
|
|
7
|
+
* backend that `uniweb register` talks to:
|
|
8
|
+
*
|
|
9
|
+
* - non-browser username/password login → POST {apiBase}/dev/auth/login
|
|
10
|
+
* - the returned bearer is an OPAQUE random token (NOT a JWT — never decode it,
|
|
11
|
+
* it carries no claims; org memberships come from a separate authed read)
|
|
12
|
+
* - stored in a register-scoped slot (~/.uniweb/registry-auth.json) so it can
|
|
13
|
+
* never clobber the legacy token publish/deploy rely on.
|
|
14
|
+
*
|
|
15
|
+
* Token resolution for `register` (the `--token` flag is handled by the caller,
|
|
16
|
+
* ahead of this): UNIWEB_TOKEN env > stored session (unexpired) >
|
|
17
|
+
* UNIWEB_USERNAME/UNIWEB_PASSWORD env (non-interactive) > interactive prompt.
|
|
18
|
+
*
|
|
19
|
+
* Login response shape (agreed with backend, 2026-05-26):
|
|
20
|
+
* { token, expires_at, account: { uuid, username, handle } }
|
|
21
|
+
* token + expiry top-level; identity nested under `account` (handle nullable).
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
import { existsSync } from 'node:fs'
|
|
25
|
+
import { readFile, writeFile, mkdir, unlink } from 'node:fs/promises'
|
|
26
|
+
import { join } from 'node:path'
|
|
27
|
+
import { createServer } from 'node:http'
|
|
28
|
+
import { randomBytes } from 'node:crypto'
|
|
29
|
+
import { getAuthDir, isExpired } from './auth.js'
|
|
30
|
+
|
|
31
|
+
const LOGIN_PATH = '/dev/auth/login'
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Path to the register-scoped credential file (~/.uniweb/registry-auth.json).
|
|
35
|
+
* Reuses the legacy auth dir (the ~/.uniweb home is shared infrastructure);
|
|
36
|
+
* only the filename differs, keeping the two tokens in separate slots.
|
|
37
|
+
* @returns {string}
|
|
38
|
+
*/
|
|
39
|
+
export function getRegistryAuthPath() {
|
|
40
|
+
return join(getAuthDir(), 'registry-auth.json')
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Read the stored registry session, or null. No JWT backfill — the token is
|
|
45
|
+
* opaque, so there are no claims to decode (unlike legacy readAuth()).
|
|
46
|
+
* @returns {Promise<{ token: string, expiresAt?: string, accountId?: number, sessionId?: number, username?: string, handle?: string, uuid?: string } | null>}
|
|
47
|
+
*/
|
|
48
|
+
export async function readRegistryAuth() {
|
|
49
|
+
const path = getRegistryAuthPath()
|
|
50
|
+
if (!existsSync(path)) return null
|
|
51
|
+
try {
|
|
52
|
+
return JSON.parse(await readFile(path, 'utf8'))
|
|
53
|
+
} catch {
|
|
54
|
+
return null
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Persist the registry session record.
|
|
60
|
+
* @param {Object} record
|
|
61
|
+
*/
|
|
62
|
+
export async function writeRegistryAuth(record) {
|
|
63
|
+
await mkdir(getAuthDir(), { recursive: true })
|
|
64
|
+
await writeFile(getRegistryAuthPath(), JSON.stringify(record, null, 2))
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Remove the stored registry session.
|
|
69
|
+
*/
|
|
70
|
+
export async function clearRegistryAuth() {
|
|
71
|
+
const path = getRegistryAuthPath()
|
|
72
|
+
if (existsSync(path)) await unlink(path)
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Username/password login against the new backend. POSTs to
|
|
77
|
+
* `{apiBase}/dev/auth/login`, reads the opaque token from the JSON body
|
|
78
|
+
* (the HttpOnly cookie the response also sets is ignored on the CLI), and
|
|
79
|
+
* persists it. Throws on non-2xx or a tokenless body.
|
|
80
|
+
*
|
|
81
|
+
* @param {Object} params
|
|
82
|
+
* @param {string} params.apiBase - new-backend origin, e.g. http://localhost:8080
|
|
83
|
+
* @param {string} params.username
|
|
84
|
+
* @param {string} params.password
|
|
85
|
+
* @returns {Promise<Object>} the stored session record (incl. `token`)
|
|
86
|
+
*/
|
|
87
|
+
export async function loginToRegistry({ apiBase, username, password } = {}) {
|
|
88
|
+
if (!apiBase) throw new Error('loginToRegistry: apiBase is required')
|
|
89
|
+
const url = `${apiBase.replace(/\/$/, '')}${LOGIN_PATH}`
|
|
90
|
+
|
|
91
|
+
let res
|
|
92
|
+
try {
|
|
93
|
+
res = await fetch(url, {
|
|
94
|
+
method: 'POST',
|
|
95
|
+
headers: { 'Content-Type': 'application/json' },
|
|
96
|
+
body: JSON.stringify({ username, password }),
|
|
97
|
+
})
|
|
98
|
+
} catch (err) {
|
|
99
|
+
throw new Error(`Could not reach the login endpoint at ${url}: ${err.message}`)
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
if (!res.ok) {
|
|
103
|
+
let detail = ''
|
|
104
|
+
try { detail = (await res.text()).slice(0, 300) } catch { /* ignore */ }
|
|
105
|
+
const e = new Error(`Login failed: HTTP ${res.status} ${res.statusText}${detail ? ` — ${detail}` : ''}`)
|
|
106
|
+
e.status = res.status
|
|
107
|
+
throw e
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
const data = await res.json().catch(() => null)
|
|
111
|
+
if (!data?.token) throw new Error('Login succeeded but the response carried no token.')
|
|
112
|
+
|
|
113
|
+
// New-backend login body (agreed with backend, 2026-05-26):
|
|
114
|
+
// { token, expires_at, account: { uuid, username, handle } }
|
|
115
|
+
// token/expires_at stay top-level; identity is nested under `account`. The
|
|
116
|
+
// token is opaque (no claims), and `expires_at` is stored as `expiresAt` so
|
|
117
|
+
// the shared isExpired() works unchanged. Tolerant of the pre-#2 flat body
|
|
118
|
+
// (no `account` key) — token + expiry still resolve, identity is just skipped.
|
|
119
|
+
const account = data.account || {}
|
|
120
|
+
const record = { token: data.token }
|
|
121
|
+
if (data.expires_at) record.expiresAt = data.expires_at
|
|
122
|
+
if (account.uuid) record.uuid = account.uuid
|
|
123
|
+
if (account.username) record.username = account.username
|
|
124
|
+
if (account.handle) record.handle = account.handle
|
|
125
|
+
|
|
126
|
+
await writeRegistryAuth(record)
|
|
127
|
+
return record
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Ensure a usable new-backend bearer for `register`. Resolution order:
|
|
132
|
+
* UNIWEB_TOKEN env > stored unexpired session > UNIWEB_USERNAME/PASSWORD env
|
|
133
|
+
* (non-interactive) > interactive username/password prompt.
|
|
134
|
+
*
|
|
135
|
+
* In non-interactive mode with no token/env creds, bails with an actionable
|
|
136
|
+
* error instead of hanging on a prompt (mirrors ensureAuth's CI guard).
|
|
137
|
+
*
|
|
138
|
+
* @param {Object} options
|
|
139
|
+
* @param {string} options.apiBase - new-backend origin (e.g. http://localhost:8080)
|
|
140
|
+
* @param {string} [options.command] - command needing auth (for messaging)
|
|
141
|
+
* @param {string[]} [options.args] - argv slice; checked for --non-interactive
|
|
142
|
+
* @returns {Promise<string>} bearer token
|
|
143
|
+
*/
|
|
144
|
+
export async function ensureRegistryAuth({ apiBase, command = 'This command', args = [] } = {}) {
|
|
145
|
+
if (process.env.UNIWEB_TOKEN) return process.env.UNIWEB_TOKEN
|
|
146
|
+
|
|
147
|
+
const stored = await readRegistryAuth()
|
|
148
|
+
if (stored?.token && !isExpired(stored)) return stored.token
|
|
149
|
+
|
|
150
|
+
// Non-interactive login from env (CI / agents) before any prompt.
|
|
151
|
+
const envUser = process.env.UNIWEB_USERNAME
|
|
152
|
+
const envPass = process.env.UNIWEB_PASSWORD
|
|
153
|
+
if (envUser && envPass) {
|
|
154
|
+
const record = await loginToRegistry({ apiBase, username: envUser, password: envPass })
|
|
155
|
+
return record.token
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
const { isNonInteractive, getCliPrefix } = await import('./interactive.js')
|
|
159
|
+
if (isNonInteractive(args)) {
|
|
160
|
+
const prefix = getCliPrefix()
|
|
161
|
+
const reason = stored && isExpired(stored) ? 'Session expired.' : 'Not logged in.'
|
|
162
|
+
console.error(`\x1b[31m✗\x1b[0m ${reason} ${command} requires a Uniweb account, and the CLI is non-interactive (CI / no TTY / --non-interactive).`)
|
|
163
|
+
console.error(' Options:')
|
|
164
|
+
console.error(` • Set UNIWEB_TOKEN to a bearer token.`)
|
|
165
|
+
console.error(` • Set UNIWEB_USERNAME + UNIWEB_PASSWORD to log in non-interactively.`)
|
|
166
|
+
console.error(` • Run \`${prefix} login\` interactively first, then re-run.`)
|
|
167
|
+
process.exit(1)
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
if (stored && isExpired(stored)) {
|
|
171
|
+
console.log(`\x1b[33mSession expired.\x1b[0m ${command} requires a Uniweb account.\n`)
|
|
172
|
+
} else {
|
|
173
|
+
console.log(`${command} requires a Uniweb account.\n`)
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
// Interactive: hand off to the multi-method login picker, reuse its session.
|
|
177
|
+
const record = await runRegistryLogin({ apiBase, args })
|
|
178
|
+
if (!record?.token) process.exit(1)
|
|
179
|
+
return record.token
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
// The browser/OAuth flow is wired below (loginViaBrowser: a loopback redirect
|
|
183
|
+
// against the backend's /dev/auth/cli/authorize, token-in-redirect). Kept
|
|
184
|
+
// gated until that endpoint is live on the backend — flip to true then, and the
|
|
185
|
+
// picker offers Browser/social as the default (and `--browser` works).
|
|
186
|
+
const BROWSER_AVAILABLE = false
|
|
187
|
+
|
|
188
|
+
/**
|
|
189
|
+
* GET /dev/auth/me with a bearer → the account object ({ uuid, username,
|
|
190
|
+
* handle }), or throws. Used to verify + identify a pasted token.
|
|
191
|
+
*/
|
|
192
|
+
export async function fetchMe({ apiBase, token }) {
|
|
193
|
+
const res = await fetch(`${apiBase.replace(/\/$/, '')}/dev/auth/me`, {
|
|
194
|
+
headers: { Authorization: `Bearer ${token}` },
|
|
195
|
+
})
|
|
196
|
+
if (!res.ok) {
|
|
197
|
+
const e = new Error(`token check failed: HTTP ${res.status} ${res.statusText}`)
|
|
198
|
+
e.status = res.status
|
|
199
|
+
throw e
|
|
200
|
+
}
|
|
201
|
+
const data = await res.json().catch(() => null)
|
|
202
|
+
return data?.account || null
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
// Username/password — prompts unless UNIWEB_USERNAME/PASSWORD are set.
|
|
206
|
+
async function loginViaPassword({ apiBase, nonInteractive }) {
|
|
207
|
+
let username = process.env.UNIWEB_USERNAME
|
|
208
|
+
let password = process.env.UNIWEB_PASSWORD
|
|
209
|
+
if (!username || !password) {
|
|
210
|
+
if (nonInteractive) {
|
|
211
|
+
throw new Error('username/password login needs a terminal — set UNIWEB_USERNAME + UNIWEB_PASSWORD (or UNIWEB_TOKEN).')
|
|
212
|
+
}
|
|
213
|
+
const prompts = (await import('prompts')).default
|
|
214
|
+
const resp = await prompts([
|
|
215
|
+
{ type: 'text', name: 'username', message: 'Username:', validate: (v) => (v ? true : 'Username is required') },
|
|
216
|
+
{ type: 'password', name: 'password', message: 'Password:', validate: (v) => (v ? true : 'Password is required') },
|
|
217
|
+
], { onCancel: () => { console.log('\nLogin cancelled.'); process.exit(0) } })
|
|
218
|
+
username = resp.username
|
|
219
|
+
password = resp.password
|
|
220
|
+
if (!username || !password) process.exit(1)
|
|
221
|
+
}
|
|
222
|
+
return loginToRegistry({ apiBase, username, password })
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
// Paste a token — verified + identified via /me before it's stored.
|
|
226
|
+
async function loginViaTokenPaste({ apiBase, nonInteractive }) {
|
|
227
|
+
if (nonInteractive) {
|
|
228
|
+
throw new Error('token paste needs a terminal — set UNIWEB_TOKEN instead.')
|
|
229
|
+
}
|
|
230
|
+
const prompts = (await import('prompts')).default
|
|
231
|
+
const { token } = await prompts({
|
|
232
|
+
type: 'password', name: 'token', message: 'Paste your token:', validate: (v) => (v ? true : 'Token is required'),
|
|
233
|
+
}, { onCancel: () => { console.log('\nLogin cancelled.'); process.exit(0) } })
|
|
234
|
+
if (!token) process.exit(1)
|
|
235
|
+
const account = await fetchMe({ apiBase, token }) // throws if the token is invalid
|
|
236
|
+
const record = { token }
|
|
237
|
+
if (account?.uuid) record.uuid = account.uuid
|
|
238
|
+
if (account?.username) record.username = account.username
|
|
239
|
+
if (account?.handle) record.handle = account.handle
|
|
240
|
+
await writeRegistryAuth(record)
|
|
241
|
+
return record
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
// Open a URL in the default browser. Returns whether it launched.
|
|
245
|
+
async function openBrowser(url) {
|
|
246
|
+
try {
|
|
247
|
+
const { exec } = await import('node:child_process')
|
|
248
|
+
const cmd = process.platform === 'darwin' ? `open "${url}"`
|
|
249
|
+
: process.platform === 'win32' ? `start "" "${url}"`
|
|
250
|
+
: `xdg-open "${url}"`
|
|
251
|
+
return await new Promise((resolve) => exec(cmd, (err) => resolve(!err)))
|
|
252
|
+
} catch {
|
|
253
|
+
return false
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
// Browser / social — loopback OAuth against the backend's /cli/authorize.
|
|
258
|
+
// The CLI hosts a one-shot 127.0.0.1 server, opens the browser to authorize, and
|
|
259
|
+
// the backend (after the Google dance) 302s back to the loopback with the token
|
|
260
|
+
// (or an error). state is a CSRF nonce echoed back and verified. The token never
|
|
261
|
+
// leaves browser→localhost. Gated by BROWSER_AVAILABLE until the endpoint is live.
|
|
262
|
+
async function loginViaBrowser({ apiBase }) {
|
|
263
|
+
if (!BROWSER_AVAILABLE) {
|
|
264
|
+
throw new Error('browser/social login for the new backend isn’t available yet — use --password or --token-paste.')
|
|
265
|
+
}
|
|
266
|
+
const base = apiBase.replace(/\/$/, '')
|
|
267
|
+
const state = randomBytes(16).toString('hex')
|
|
268
|
+
|
|
269
|
+
const result = await new Promise((resolve) => {
|
|
270
|
+
const server = createServer((req, res) => {
|
|
271
|
+
const u = new URL(req.url, 'http://127.0.0.1')
|
|
272
|
+
if (u.pathname !== '/callback') { res.writeHead(404); res.end('Not found'); return }
|
|
273
|
+
const token = u.searchParams.get('token')
|
|
274
|
+
const error = u.searchParams.get('error')
|
|
275
|
+
const gotState = u.searchParams.get('state')
|
|
276
|
+
const failed = !!error || !token || gotState !== state
|
|
277
|
+
res.writeHead(failed ? 400 : 200, { 'Content-Type': 'text/html' })
|
|
278
|
+
res.end(`<!doctype html><html><body style="font-family:system-ui,sans-serif;text-align:center;padding:60px">`
|
|
279
|
+
+ `<h2 style="color:${failed ? '#dc2626' : '#16a34a'}">${failed ? 'Login failed' : 'Login successful'}</h2>`
|
|
280
|
+
+ `<p>You can close this tab and return to your terminal.</p></body></html>`)
|
|
281
|
+
cleanup()
|
|
282
|
+
if (error) resolve({ error })
|
|
283
|
+
else if (gotState !== state) resolve({ error: 'state mismatch — please try again.' })
|
|
284
|
+
else if (!token) resolve({ error: 'no token returned by the callback.' })
|
|
285
|
+
else resolve({ token })
|
|
286
|
+
})
|
|
287
|
+
let timer
|
|
288
|
+
function cleanup() { clearTimeout(timer); server.close() }
|
|
289
|
+
server.on('error', (e) => resolve({ error: `loopback server error: ${e.message}` }))
|
|
290
|
+
server.listen(0, '127.0.0.1', async () => {
|
|
291
|
+
const { port } = server.address()
|
|
292
|
+
const redirectUri = `http://127.0.0.1:${port}/callback`
|
|
293
|
+
const authorizeUrl = `${base}/dev/auth/authorize?redirect_uri=${encodeURIComponent(redirectUri)}&state=${state}`
|
|
294
|
+
console.log('\x1b[36m→\x1b[0m Opening your browser to sign in…')
|
|
295
|
+
console.log(` \x1b[2m${authorizeUrl}\x1b[0m`)
|
|
296
|
+
const opened = await openBrowser(authorizeUrl)
|
|
297
|
+
if (!opened) console.log('\x1b[33m⚠\x1b[0m Could not open a browser automatically — open the URL above.')
|
|
298
|
+
console.log('\x1b[2mWaiting for sign-in to complete (120s)…\x1b[0m')
|
|
299
|
+
})
|
|
300
|
+
timer = setTimeout(() => { server.close(); resolve({ error: 'timed out waiting for sign-in (120s).' }) }, 120000)
|
|
301
|
+
})
|
|
302
|
+
|
|
303
|
+
if (result.error) throw new Error(result.error)
|
|
304
|
+
let account = null
|
|
305
|
+
try { account = await fetchMe({ apiBase, token: result.token }) } catch { /* identity optional; token is valid */ }
|
|
306
|
+
const record = { token: result.token }
|
|
307
|
+
if (account?.uuid) record.uuid = account.uuid
|
|
308
|
+
if (account?.username) record.username = account.username
|
|
309
|
+
if (account?.handle) record.handle = account.handle
|
|
310
|
+
await writeRegistryAuth(record)
|
|
311
|
+
return record
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
/**
|
|
315
|
+
* `uniweb login` against the new backend — a multi-method picker:
|
|
316
|
+
* browser/social (default, once available) · username+password · paste a token.
|
|
317
|
+
* Force a method with --browser / --password / --token-paste (skips the menu).
|
|
318
|
+
* No TTY → no menu; falls back to the non-browser path (env UNIWEB_USERNAME/
|
|
319
|
+
* PASSWORD; UNIWEB_TOKEN is handled earlier by ensureRegistryAuth).
|
|
320
|
+
*
|
|
321
|
+
* @param {Object} o
|
|
322
|
+
* @param {string} o.apiBase - new-backend origin
|
|
323
|
+
* @param {string[]} [o.args] - argv slice (force flags + --non-interactive)
|
|
324
|
+
* @returns {Promise<Object|undefined>} the stored session record
|
|
325
|
+
*/
|
|
326
|
+
export async function runRegistryLogin({ apiBase, args = [] } = {}) {
|
|
327
|
+
const existing = await readRegistryAuth()
|
|
328
|
+
if (existing?.token && !isExpired(existing)) {
|
|
329
|
+
const who = existing.username || existing.handle || (existing.uuid ? `account ${existing.uuid}` : '')
|
|
330
|
+
console.log(`Already logged in${who ? ` as \x1b[1m${who}\x1b[0m` : ''}${apiBase ? ` (${apiBase})` : ''}.`)
|
|
331
|
+
console.log('\x1b[2mContinuing will replace the existing session.\x1b[0m\n')
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
const { isNonInteractive } = await import('./interactive.js')
|
|
335
|
+
const nonInteractive = isNonInteractive(args)
|
|
336
|
+
|
|
337
|
+
let method = args.includes('--browser') ? 'browser'
|
|
338
|
+
: args.includes('--password') ? 'password'
|
|
339
|
+
: args.includes('--token-paste') ? 'token-paste'
|
|
340
|
+
: null
|
|
341
|
+
|
|
342
|
+
if (!method) {
|
|
343
|
+
if (nonInteractive) {
|
|
344
|
+
if (process.env.UNIWEB_USERNAME && process.env.UNIWEB_PASSWORD) {
|
|
345
|
+
method = 'password'
|
|
346
|
+
} else {
|
|
347
|
+
console.error('\x1b[31m✗\x1b[0m Cannot log in non-interactively without a method.')
|
|
348
|
+
console.error(' Set UNIWEB_USERNAME + UNIWEB_PASSWORD, set UNIWEB_TOKEN, or run `uniweb login` in a terminal.')
|
|
349
|
+
console.error(' Or force one: --password / --token-paste.')
|
|
350
|
+
process.exit(1)
|
|
351
|
+
}
|
|
352
|
+
} else {
|
|
353
|
+
const prompts = (await import('prompts')).default
|
|
354
|
+
const choices = []
|
|
355
|
+
if (BROWSER_AVAILABLE) choices.push({ title: 'Browser / social (Google, etc.)', value: 'browser' })
|
|
356
|
+
choices.push({ title: 'Username and password', value: 'password' })
|
|
357
|
+
choices.push({ title: 'Paste a token', value: 'token-paste' })
|
|
358
|
+
const { picked } = await prompts({
|
|
359
|
+
type: 'select', name: 'picked', message: 'How do you want to log in?', choices,
|
|
360
|
+
}, { onCancel: () => { console.log('\nLogin cancelled.'); process.exit(0) } })
|
|
361
|
+
if (!picked) process.exit(0)
|
|
362
|
+
method = picked
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
let record
|
|
367
|
+
try {
|
|
368
|
+
if (method === 'browser') record = await loginViaBrowser({ apiBase })
|
|
369
|
+
else if (method === 'token-paste') record = await loginViaTokenPaste({ apiBase, nonInteractive })
|
|
370
|
+
else record = await loginViaPassword({ apiBase, nonInteractive })
|
|
371
|
+
} catch (err) {
|
|
372
|
+
console.error(`\x1b[31m✗\x1b[0m ${err.message}`)
|
|
373
|
+
process.exit(1)
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
if (record?.token) {
|
|
377
|
+
console.log(`\x1b[32m✓\x1b[0m Logged in${record.username ? ` as \x1b[1m${record.username}\x1b[0m` : ''}${apiBase ? ` (${apiBase})` : ''}`)
|
|
378
|
+
}
|
|
379
|
+
return record
|
|
380
|
+
}
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* New-backend org operations for the publish-scope bootstrap — used by
|
|
3
|
+
* `uniweb register`'s scope resolution and the `uniweb org` command.
|
|
4
|
+
*
|
|
5
|
+
* One resource (Bearer auth, new backend); the verb selects the operation:
|
|
6
|
+
* GET /dev/orgs → [{ handle, is_primary }] (your orgs, primary first)
|
|
7
|
+
* POST /dev/orgs { handle } → { handle, uuid, is_primary } (creator becomes a
|
|
8
|
+
* member, primary iff their first)
|
|
9
|
+
*
|
|
10
|
+
* Handle grammar + reserved scopes are validated client-side for a clean prompt;
|
|
11
|
+
* the server is the backstop (409 taken-or-reserved, 422 bad grammar).
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
const ORGS_PATH = '/dev/orgs'
|
|
15
|
+
|
|
16
|
+
// Reserved registry scopes a dev cannot own — the system + standard namespaces
|
|
17
|
+
// (uwx-format scope model). Grammatically valid, so the server returns 409
|
|
18
|
+
// "taken" for these; we block them earlier for a clearer message.
|
|
19
|
+
const RESERVED_HANDLES = new Set(['uniweb', 'std'])
|
|
20
|
+
|
|
21
|
+
// Handle grammar (backend): lowercase alphanumerics + internal hyphens, 3–39 chars,
|
|
22
|
+
// no leading/trailing hyphen. The server is authoritative on reserved names (409).
|
|
23
|
+
const HANDLE_RE = /^[a-z0-9]([a-z0-9-]*[a-z0-9])?$/
|
|
24
|
+
const HANDLE_MIN = 3
|
|
25
|
+
const HANDLE_MAX = 39
|
|
26
|
+
|
|
27
|
+
/** Strip a leading `@` and any `/suffix`, returning the bare handle segment. */
|
|
28
|
+
export function bareHandle(scope) {
|
|
29
|
+
return String(scope || '').replace(/^@/, '').replace(/\/.*$/, '')
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Validate a handle client-side. Returns an error string, or null when valid.
|
|
34
|
+
* Accepts an optional leading `@` (stripped before checking).
|
|
35
|
+
*/
|
|
36
|
+
export function validateHandle(handle) {
|
|
37
|
+
const h = bareHandle(handle)
|
|
38
|
+
if (!h) return 'A handle is required.'
|
|
39
|
+
if (h.length < HANDLE_MIN || h.length > HANDLE_MAX) return `Handle must be ${HANDLE_MIN}–${HANDLE_MAX} characters.`
|
|
40
|
+
if (!HANDLE_RE.test(h)) return 'Use lowercase letters, digits, and internal hyphens only (e.g. acme-co).'
|
|
41
|
+
if (RESERVED_HANDLES.has(h)) return `@${h} is reserved.`
|
|
42
|
+
return null
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* List the authenticated account's org memberships (primary first).
|
|
47
|
+
* @returns {Promise<Array<{handle: string, is_primary: boolean}>>}
|
|
48
|
+
*/
|
|
49
|
+
export async function listOrgs({ apiBase, token }) {
|
|
50
|
+
const res = await fetch(`${apiBase.replace(/\/$/, '')}${ORGS_PATH}`, {
|
|
51
|
+
headers: { Authorization: `Bearer ${token}` },
|
|
52
|
+
})
|
|
53
|
+
if (!res.ok) throw new Error(`Could not list your orgs: HTTP ${res.status} ${res.statusText}`)
|
|
54
|
+
const data = await res.json().catch(() => null)
|
|
55
|
+
return Array.isArray(data) ? data : []
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Create an org. The creating account becomes a member (primary iff it's their
|
|
60
|
+
* first), so it's immediately publishable.
|
|
61
|
+
* @returns {Promise<{handle: string, uuid?: string, is_primary?: boolean}>}
|
|
62
|
+
*/
|
|
63
|
+
export async function createOrg({ apiBase, token, handle }) {
|
|
64
|
+
const h = bareHandle(handle)
|
|
65
|
+
const res = await fetch(`${apiBase.replace(/\/$/, '')}${ORGS_PATH}`, {
|
|
66
|
+
method: 'POST',
|
|
67
|
+
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` },
|
|
68
|
+
body: JSON.stringify({ handle: h }),
|
|
69
|
+
})
|
|
70
|
+
if (res.status === 409) {
|
|
71
|
+
const e = new Error(`@${h} is already taken (or reserved). Pick another.`)
|
|
72
|
+
e.status = 409
|
|
73
|
+
throw e
|
|
74
|
+
}
|
|
75
|
+
if (res.status === 422) {
|
|
76
|
+
const e = new Error(`@${h} is not a valid handle.`)
|
|
77
|
+
e.status = 422
|
|
78
|
+
throw e
|
|
79
|
+
}
|
|
80
|
+
if (!res.ok) {
|
|
81
|
+
let detail = ''
|
|
82
|
+
try { detail = (await res.text()).slice(0, 200) } catch { /* ignore */ }
|
|
83
|
+
const e = new Error(`Could not create @${h}: HTTP ${res.status}${detail ? ` — ${detail}` : ''}`)
|
|
84
|
+
e.status = res.status
|
|
85
|
+
throw e
|
|
86
|
+
}
|
|
87
|
+
return res.json()
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Derive the publish scope from login membership when none was supplied:
|
|
92
|
+
* 1 org → use it (confirm once)
|
|
93
|
+
* 0 orgs → offer to create one (proposes the account handle when set, else
|
|
94
|
+
* prompts — never username-derived; cold-start per handle-origin (ii))
|
|
95
|
+
* N orgs → pick
|
|
96
|
+
* Returns the chosen bare handle (no `@`), or null if cancelled. Persists
|
|
97
|
+
* nothing — the caller records it. Bails in non-interactive mode.
|
|
98
|
+
*
|
|
99
|
+
* @param {Object} p
|
|
100
|
+
* @param {string} p.apiBase
|
|
101
|
+
* @param {string} p.token
|
|
102
|
+
* @param {string|null} [p.accountHandle] - the account's handle (may be null)
|
|
103
|
+
* @param {string[]} [p.args] - argv slice; checked for --non-interactive
|
|
104
|
+
* @returns {Promise<string|null>}
|
|
105
|
+
*/
|
|
106
|
+
export async function deriveScope({ apiBase, token, accountHandle = null, args = [] }) {
|
|
107
|
+
const orgs = await listOrgs({ apiBase, token })
|
|
108
|
+
const { isNonInteractive } = await import('./interactive.js')
|
|
109
|
+
const nonInteractive = isNonInteractive(args)
|
|
110
|
+
|
|
111
|
+
if (orgs.length === 1) {
|
|
112
|
+
const h = orgs[0].handle
|
|
113
|
+
if (nonInteractive) {
|
|
114
|
+
console.log(`Publishing under your org \x1b[1m@${h}\x1b[0m.`)
|
|
115
|
+
return h
|
|
116
|
+
}
|
|
117
|
+
const prompts = (await import('prompts')).default
|
|
118
|
+
const { ok } = await prompts({
|
|
119
|
+
type: 'confirm', name: 'ok', message: `Publish under your org @${h}?`, initial: true,
|
|
120
|
+
}, { onCancel: () => { console.log('\nCancelled.'); process.exit(0) } })
|
|
121
|
+
if (!ok) {
|
|
122
|
+
console.log('Pass --scope @org, or create another with `uniweb org create <handle>`.')
|
|
123
|
+
return null
|
|
124
|
+
}
|
|
125
|
+
return h
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
if (orgs.length > 1) {
|
|
129
|
+
if (nonInteractive) {
|
|
130
|
+
const primary = orgs.find((u) => u.is_primary) || orgs[0]
|
|
131
|
+
console.log(`Multiple orgs; using primary \x1b[1m@${primary.handle}\x1b[0m (non-interactive).`)
|
|
132
|
+
return primary.handle
|
|
133
|
+
}
|
|
134
|
+
const prompts = (await import('prompts')).default
|
|
135
|
+
const { choice } = await prompts({
|
|
136
|
+
type: 'select',
|
|
137
|
+
name: 'choice',
|
|
138
|
+
message: 'Publish under which org?',
|
|
139
|
+
choices: orgs.map((u) => ({ title: `@${u.handle}${u.is_primary ? ' (primary)' : ''}`, value: u.handle })),
|
|
140
|
+
}, { onCancel: () => { console.log('\nCancelled.'); process.exit(0) } })
|
|
141
|
+
return choice || null
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// 0 orgs → offer to create.
|
|
145
|
+
if (nonInteractive) {
|
|
146
|
+
console.error('\x1b[31m✗\x1b[0m You have no org to publish under. Create one with `uniweb org create <handle>`, or pass --scope @org.')
|
|
147
|
+
process.exit(1)
|
|
148
|
+
}
|
|
149
|
+
return offerCreateOrg({ apiBase, token, accountHandle })
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* Cold-start: prompt for a handle (pre-filled with the account handle when it's
|
|
154
|
+
* set + valid — never the username), create the org, return its handle.
|
|
155
|
+
* @returns {Promise<string|null>}
|
|
156
|
+
*/
|
|
157
|
+
export async function offerCreateOrg({ apiBase, token, accountHandle = null }) {
|
|
158
|
+
const prompts = (await import('prompts')).default
|
|
159
|
+
const suggested = accountHandle && !validateHandle(accountHandle) ? bareHandle(accountHandle) : ''
|
|
160
|
+
|
|
161
|
+
console.log("You don't have an org yet — let's create one (it becomes your publish scope).")
|
|
162
|
+
const { handle } = await prompts({
|
|
163
|
+
type: 'text',
|
|
164
|
+
name: 'handle',
|
|
165
|
+
message: 'Org handle (e.g. acme):',
|
|
166
|
+
initial: suggested,
|
|
167
|
+
validate: (v) => validateHandle(v) || true,
|
|
168
|
+
}, { onCancel: () => { console.log('\nCancelled.'); process.exit(0) } })
|
|
169
|
+
if (!handle) return null
|
|
170
|
+
|
|
171
|
+
try {
|
|
172
|
+
const org = await createOrg({ apiBase, token, handle })
|
|
173
|
+
console.log(`\x1b[32m✓\x1b[0m Created \x1b[1m@${org.handle}\x1b[0m — you're a member${org.is_primary ? ' (primary)' : ''}.`)
|
|
174
|
+
return org.handle
|
|
175
|
+
} catch (err) {
|
|
176
|
+
console.error(`\x1b[31m✗\x1b[0m ${err.message}`)
|
|
177
|
+
return null
|
|
178
|
+
}
|
|
179
|
+
}
|