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.
@@ -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
+ }