uniweb 0.12.20 → 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 +30 -1
- 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/doctor.js +24 -5
- package/src/commands/org.js +66 -0
- package/src/commands/publish.js +4 -3
- package/src/commands/pull.js +238 -0
- package/src/commands/push.js +400 -0
- package/src/commands/register.js +274 -0
- package/src/commands/rename.js +10 -5
- package/src/commands/update.js +211 -245
- package/src/commands/validate.js +288 -0
- package/src/framework-index.json +11 -10
- package/src/index.js +155 -26
- package/src/templates/processor.js +41 -19
- package/src/utils/config.js +30 -2
- package/src/utils/dep-survey.js +99 -0
- package/src/utils/json-file.js +68 -0
- package/src/utils/placement.js +100 -0
- package/src/utils/pm.js +29 -0
- package/src/utils/registry-auth.js +380 -0
- package/src/utils/registry-orgs.js +179 -0
- package/src/utils/scaffold.js +18 -5
- package/src/utils/site-content-refs.js +21 -0
- package/src/utils/update-check.js +4 -2
- package/src/versions.js +11 -4
- 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,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
|
+
}
|
package/src/utils/scaffold.js
CHANGED
|
@@ -13,6 +13,7 @@ import yaml from 'js-yaml'
|
|
|
13
13
|
import Handlebars from 'handlebars'
|
|
14
14
|
import { copyTemplateDirectory, enumerateTemplateOutputs, registerVersions } from '../templates/processor.js'
|
|
15
15
|
import { getVersionsForTemplates, getCliVersion } from '../versions.js'
|
|
16
|
+
import { writeJsonPreservingStyleAsync } from './json-file.js'
|
|
16
17
|
|
|
17
18
|
const __dirname = dirname(fileURLToPath(import.meta.url))
|
|
18
19
|
const TEMPLATES_DIR = join(__dirname, '..', '..', 'templates')
|
|
@@ -93,13 +94,24 @@ export async function scaffoldFoundation(targetDir, context, options = {}) {
|
|
|
93
94
|
/**
|
|
94
95
|
* Scaffold a site from the site package template
|
|
95
96
|
*
|
|
97
|
+
* Two shapes, by whether a local foundation is wired:
|
|
98
|
+
* - Local foundation (the default for `create`/`add site`): pass
|
|
99
|
+
* `foundationName` + `foundationPath` (a `file:` spec) and the site's
|
|
100
|
+
* package.json gets that dependency.
|
|
101
|
+
* - Referenced foundation (no local sibling — what `uniweb clone` uses):
|
|
102
|
+
* OMIT `foundationName`/`foundationPath` and set `foundationRef` to a
|
|
103
|
+
* registry ref (`@ns/name@ver`) or URL. No `file:` dependency is written;
|
|
104
|
+
* `site.yml::foundation` carries the ref and the runtime loads it as a
|
|
105
|
+
* federated module (runtime mode). Deps still pin to the CLI version
|
|
106
|
+
* matrix via the template's `{{version}}` helper.
|
|
107
|
+
*
|
|
96
108
|
* @param {string} targetDir - Target directory for the site
|
|
97
109
|
* @param {Object} context - Template context
|
|
98
110
|
* @param {string} context.name - Package name
|
|
99
111
|
* @param {string} context.projectName - Workspace name
|
|
100
|
-
* @param {string} context.foundationName -
|
|
101
|
-
* @param {string} context.foundationPath - Relative file: path to foundation
|
|
102
|
-
* @param {string} [context.foundationRef] - Foundation ref
|
|
112
|
+
* @param {string} [context.foundationName] - Local foundation package name (omit for a referenced foundation)
|
|
113
|
+
* @param {string} [context.foundationPath] - Relative file: path to a local foundation (omit for a referenced foundation)
|
|
114
|
+
* @param {string} [context.foundationRef] - Foundation ref written to site.yml (a local package name, a registry ref, or a URL)
|
|
103
115
|
* @param {Object} [options] - Processing options
|
|
104
116
|
*/
|
|
105
117
|
export async function scaffoldSite(targetDir, context, options = {}) {
|
|
@@ -380,12 +392,13 @@ function resolveDependencyVersion(rawValue) {
|
|
|
380
392
|
*/
|
|
381
393
|
export async function mergeTemplateDependencies(packageJsonPath, deps) {
|
|
382
394
|
if (!deps || Object.keys(deps).length === 0) return
|
|
383
|
-
const
|
|
395
|
+
const src = await fs.readFile(packageJsonPath, 'utf-8')
|
|
396
|
+
const pkg = JSON.parse(src)
|
|
384
397
|
if (!pkg.dependencies) pkg.dependencies = {}
|
|
385
398
|
for (const [name, version] of Object.entries(deps)) {
|
|
386
399
|
if (!pkg.dependencies[name] && !pkg.devDependencies?.[name]) {
|
|
387
400
|
pkg.dependencies[name] = resolveDependencyVersion(version)
|
|
388
401
|
}
|
|
389
402
|
}
|
|
390
|
-
await
|
|
403
|
+
await writeJsonPreservingStyleAsync(packageJsonPath, pkg, src)
|
|
391
404
|
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Dep-free reader for the foundation ref the backend populates on a site-content
|
|
3
|
+
* document.
|
|
4
|
+
*
|
|
5
|
+
* Kept free of `@uniweb/build` so it's importable from `clone` (global, runs before a
|
|
6
|
+
* project exists) — the same constraint that keeps utils/placement.js dependency-free.
|
|
7
|
+
*
|
|
8
|
+
* The site-content `$`-document carries, on its `info` brief, the `foundation` ref the
|
|
9
|
+
* backend fills in when it wires a site. The reader is tolerant — a backend field
|
|
10
|
+
* rename should degrade, not crash — and is the single adjust-point if the wire field
|
|
11
|
+
* name settles differently. (The site's `@uniweb/folder` is NOT read here: the backend
|
|
12
|
+
* resolves it from the site-content uuid, so the framework never holds a folder uuid.)
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* The site's foundation ref — a URL or our `@ns/name@ver`. Written verbatim into
|
|
17
|
+
* site.yml; the runtime loads it as a federated module.
|
|
18
|
+
*/
|
|
19
|
+
export function extractFoundationRef(info = {}, document = {}) {
|
|
20
|
+
return info?.foundation ?? info?.foundation_name ?? document?.foundation ?? null
|
|
21
|
+
}
|
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
import { homedir } from 'node:os'
|
|
10
10
|
import { existsSync, readFileSync, mkdirSync, writeFileSync } from 'node:fs'
|
|
11
11
|
import { join } from 'node:path'
|
|
12
|
+
import { detectGlobalCliPm, globalCliUpdateCmd } from './pm.js'
|
|
12
13
|
|
|
13
14
|
const CHECK_INTERVAL = 24 * 60 * 60 * 1000 // 1 day
|
|
14
15
|
const STATE_DIR = join(homedir(), '.uniweb')
|
|
@@ -62,14 +63,15 @@ function printNotification(current, latest, tone = 'soft') {
|
|
|
62
63
|
const cyan = '\x1b[36m'
|
|
63
64
|
const dim = '\x1b[2m'
|
|
64
65
|
const reset = '\x1b[0m'
|
|
66
|
+
const updateCmd = globalCliUpdateCmd(detectGlobalCliPm())
|
|
65
67
|
console.error('')
|
|
66
68
|
if (tone === 'eager') {
|
|
67
69
|
console.error(`${yellow}Heads up:${reset} this CLI is ${dim}${current}${reset}; latest is ${cyan}${latest}${reset}.`)
|
|
68
|
-
console.error(`${dim}Templates ship with the CLI — consider updating first:${reset}
|
|
70
|
+
console.error(`${dim}Templates ship with the CLI — consider updating first:${reset} ${updateCmd}`)
|
|
69
71
|
console.error(`${dim}Or run a one-shot fresh:${reset} npx uniweb@latest <command>`)
|
|
70
72
|
} else {
|
|
71
73
|
console.error(`${yellow}Update available:${reset} ${dim}${current}${reset} → ${cyan}${latest}${reset}`)
|
|
72
|
-
console.error(`${dim}Run${reset}
|
|
74
|
+
console.error(`${dim}Run${reset} ${updateCmd} ${dim}to update the CLI${reset}`)
|
|
73
75
|
}
|
|
74
76
|
}
|
|
75
77
|
|