uniweb 0.12.26 → 0.12.28

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.
@@ -1,230 +0,0 @@
1
- /**
2
- * Login Command
3
- *
4
- * Authenticates with the Uniweb platform. Stores credentials at ~/.uniweb/auth.json.
5
- *
6
- * Flow:
7
- * 1. Start a temporary HTTP server on a random port
8
- * 2. Open the browser to {backend}/cli-auth.php?action=login&callback=http://localhost:{port}/callback
9
- * 3. PHP authenticates the user, signs a JWT, redirects to the callback
10
- * 4. CLI receives the token and stores it at ~/.uniweb/auth.json
11
- * 5. Falls back to token-paste if browser fails
12
- *
13
- * Usage:
14
- * uniweb login
15
- * uniweb login --token-paste # Skip browser, use token paste
16
- */
17
-
18
- import { createServer } from 'node:http'
19
- import { writeAuth, readAuth, isExpired } from '../utils/auth.js'
20
- import { getBackendUrl } from '../utils/config.js'
21
-
22
- // Colors for terminal output
23
- const colors = {
24
- reset: '\x1b[0m',
25
- bright: '\x1b[1m',
26
- dim: '\x1b[2m',
27
- cyan: '\x1b[36m',
28
- green: '\x1b[32m',
29
- yellow: '\x1b[33m',
30
- red: '\x1b[31m',
31
- }
32
-
33
- function success(message) {
34
- console.log(`${colors.green}✓${colors.reset} ${message}`)
35
- }
36
-
37
- function error(message) {
38
- console.error(`${colors.red}✗${colors.reset} ${message}`)
39
- }
40
-
41
- /**
42
- * Try to open a URL in the default browser.
43
- * @param {string} url
44
- * @returns {Promise<boolean>} Whether the browser was opened
45
- */
46
- async function openBrowser(url) {
47
- try {
48
- const { exec } = await import('node:child_process')
49
- const cmd = process.platform === 'darwin'
50
- ? `open "${url}"`
51
- : process.platform === 'win32'
52
- ? `start "" "${url}"`
53
- : `xdg-open "${url}"`
54
-
55
- return new Promise((resolve) => {
56
- exec(cmd, (err) => resolve(!err))
57
- })
58
- } catch {
59
- return false
60
- }
61
- }
62
-
63
- /**
64
- * Browser-based login flow.
65
- *
66
- * Starts a temp HTTP server, opens the browser to the PHP login page,
67
- * waits for the callback with the JWT token.
68
- *
69
- * @param {string} backendUrl - PHP backend URL
70
- * @param {number} [timeoutMs=120000] - Timeout in ms
71
- * @returns {Promise<{ token: string, email: string } | null>}
72
- */
73
- function browserLogin(backendUrl, timeoutMs = 120000) {
74
- return new Promise((resolve) => {
75
- const server = createServer((req, res) => {
76
- const url = new URL(req.url, `http://localhost`)
77
- if (url.pathname !== '/callback') {
78
- res.writeHead(404)
79
- res.end('Not found')
80
- return
81
- }
82
-
83
- const token = url.searchParams.get('token')
84
- const email = url.searchParams.get('email')
85
-
86
- if (!token) {
87
- res.writeHead(400, { 'Content-Type': 'text/html' })
88
- res.end('<h2>Login failed</h2><p>No token received. Please try again.</p>')
89
- cleanup()
90
- resolve(null)
91
- return
92
- }
93
-
94
- res.writeHead(200, { 'Content-Type': 'text/html' })
95
- res.end(`
96
- <html>
97
- <body style="font-family: system-ui, sans-serif; text-align: center; padding: 60px;">
98
- <h2 style="color: #16a34a;">Login successful!</h2>
99
- <p>You can close this window and return to your terminal.</p>
100
- </body>
101
- </html>
102
- `)
103
- cleanup()
104
- resolve({ token, email: email || '' })
105
- })
106
-
107
- let timeout
108
-
109
- function cleanup() {
110
- clearTimeout(timeout)
111
- server.close()
112
- }
113
-
114
- // Listen on a random port
115
- server.listen(0, '127.0.0.1', async () => {
116
- const port = server.address().port
117
- const callbackUrl = `http://localhost:${port}/callback`
118
- const loginUrl = `${backendUrl}/cli-auth.php?action=login&callback=${encodeURIComponent(callbackUrl)}`
119
-
120
- console.log(`${colors.cyan}→${colors.reset} Opening browser for login...`)
121
- console.log(` ${colors.dim}${loginUrl}${colors.reset}`)
122
- console.log('')
123
-
124
- const opened = await openBrowser(loginUrl)
125
- if (!opened) {
126
- console.log(`${colors.yellow}⚠${colors.reset} Could not open browser.`)
127
- console.log(` Open this URL manually: ${colors.cyan}${loginUrl}${colors.reset}`)
128
- }
129
-
130
- console.log(`${colors.dim}Waiting for login... (${timeoutMs / 1000}s timeout)${colors.reset}`)
131
- })
132
-
133
- // Timeout
134
- timeout = setTimeout(() => {
135
- server.close()
136
- resolve(null)
137
- }, timeoutMs)
138
-
139
- server.on('error', () => {
140
- resolve(null)
141
- })
142
- })
143
- }
144
-
145
- /**
146
- * Token-paste login flow (fallback).
147
- * @returns {Promise<{ token: string, email: string } | null>}
148
- */
149
- async function tokenPasteLogin() {
150
- const prompts = (await import('prompts')).default
151
-
152
- console.log('Paste your token from the Uniweb login page.')
153
- console.log('')
154
-
155
- const response = await prompts([
156
- {
157
- type: 'text',
158
- name: 'email',
159
- message: 'Email:',
160
- validate: (v) => (v && v.includes('@') ? true : 'Enter a valid email'),
161
- },
162
- {
163
- type: 'password',
164
- name: 'token',
165
- message: 'Token:',
166
- validate: (v) => (v ? true : 'Token is required'),
167
- },
168
- ], {
169
- onCancel: () => {
170
- console.log('\nLogin cancelled.')
171
- process.exit(0)
172
- },
173
- })
174
-
175
- if (!response.email || !response.token) {
176
- return null
177
- }
178
-
179
- return { token: response.token, email: response.email }
180
- }
181
-
182
- /**
183
- * Main login command handler
184
- */
185
- export async function login(args = []) {
186
- const forceTokenPaste = args.includes('--token-paste')
187
-
188
- // Check if already logged in
189
- const existing = await readAuth()
190
- if (existing && !isExpired(existing)) {
191
- console.log(`Already logged in as ${colors.bright}${existing.email}${colors.reset}`)
192
- console.log(`${colors.dim}Continuing will replace the existing session.${colors.reset}`)
193
- console.log('')
194
- }
195
-
196
- const backendUrl = getBackendUrl()
197
- let result = null
198
-
199
- if (!forceTokenPaste) {
200
- // Try browser-based login
201
- result = await browserLogin(backendUrl)
202
-
203
- if (!result) {
204
- console.log('')
205
- console.log(`${colors.yellow}⚠${colors.reset} Browser login timed out or failed.`)
206
- console.log(` Falling back to token paste...`)
207
- console.log('')
208
- result = await tokenPasteLogin()
209
- }
210
- } else {
211
- result = await tokenPasteLogin()
212
- }
213
-
214
- if (!result) {
215
- error('Login cancelled.')
216
- process.exit(1)
217
- }
218
-
219
- // Store credentials (JWT has 30-day expiry)
220
- await writeAuth({
221
- token: result.token,
222
- email: result.email,
223
- expiresAt: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000).toISOString(),
224
- })
225
-
226
- console.log('')
227
- success(`Logged in as ${colors.bright}${result.email}${colors.reset}`)
228
- }
229
-
230
- export default login
package/src/utils/auth.js DELETED
@@ -1,212 +0,0 @@
1
- /**
2
- * Credential Storage
3
- *
4
- * Manages authentication credentials at ~/.uniweb/auth.json.
5
- * User-global (not workspace-local) — you publish as yourself, not as a project.
6
- *
7
- * Used by `login`, `publish`, and `deploy` commands.
8
- *
9
- * Stored shape (auth.json):
10
- * {
11
- * token: string, // bearer JWT, sent in Authorization: Bearer <token>
12
- * email: string, // signup_email; permanent, deliverable
13
- * loginName?: string, // PHP session login_name; immutable per session model
14
- * sub?: string, // memberId from JWT; permanent, numeric
15
- * namespaces?: string[], // org handles the user can publish under
16
- * expiresAt?: string // ISO timestamp; JWT exp claim
17
- * }
18
- *
19
- * The extra identity fields (loginName, sub, namespaces) are decoded from
20
- * the JWT at write time and persisted alongside the token. They're cheap
21
- * to derive (HS256 payload is base64url-encoded JSON), but persisting them
22
- * means callers don't need to decode the JWT themselves to ask
23
- * "who is the user?" — they just `readAuth()`.
24
- */
25
-
26
- import { existsSync } from 'node:fs'
27
- import { readFile, writeFile, mkdir, unlink } from 'node:fs/promises'
28
- import { join } from 'node:path'
29
- import { homedir } from 'node:os'
30
-
31
- /**
32
- * Get the ~/.uniweb/ directory path.
33
- * @returns {string}
34
- */
35
- export function getAuthDir() {
36
- return join(homedir(), '.uniweb')
37
- }
38
-
39
- /**
40
- * Get the ~/.uniweb/auth.json file path.
41
- * @returns {string}
42
- */
43
- export function getAuthPath() {
44
- return join(getAuthDir(), 'auth.json')
45
- }
46
-
47
- /**
48
- * Decode the payload of a JWT. Returns `null` for malformed tokens.
49
- * No signature verification — that's the server's job; we just want to
50
- * read the claims locally.
51
- *
52
- * @param {string} token
53
- * @returns {Object|null}
54
- */
55
- export function decodeJwtPayload(token) {
56
- if (typeof token !== 'string') return null
57
- const parts = token.split('.')
58
- if (parts.length < 2) return null
59
- try {
60
- // base64url → base64
61
- const b64 = parts[1].replace(/-/g, '+').replace(/_/g, '/')
62
- return JSON.parse(Buffer.from(b64, 'base64').toString('utf8'))
63
- } catch {
64
- return null
65
- }
66
- }
67
-
68
- /**
69
- * Read stored credentials. If the persisted record predates the
70
- * identity-fields plumbing (no loginName/sub/namespaces) but has a
71
- * token, derive the missing fields from the JWT in memory so callers
72
- * see a consistent shape regardless of write generation.
73
- *
74
- * @returns {Promise<{ token: string, email: string, loginName?: string, sub?: string, namespaces?: string[], expiresAt?: string } | null>}
75
- */
76
- export async function readAuth() {
77
- const authPath = getAuthPath()
78
- if (!existsSync(authPath)) return null
79
-
80
- let auth
81
- try {
82
- auth = JSON.parse(await readFile(authPath, 'utf8'))
83
- } catch {
84
- return null
85
- }
86
-
87
- // Backfill identity fields from the JWT for older auth.json files
88
- // that were written before this plumbing existed. Read-only — the
89
- // file isn't rewritten until the next login.
90
- if (auth?.token && (auth.loginName === undefined || auth.sub === undefined || auth.namespaces === undefined)) {
91
- const payload = decodeJwtPayload(auth.token)
92
- if (payload) {
93
- if (auth.loginName === undefined && typeof payload.loginName === 'string') {
94
- auth.loginName = payload.loginName
95
- }
96
- if (auth.sub === undefined && typeof payload.sub === 'string') {
97
- auth.sub = payload.sub
98
- }
99
- if (auth.namespaces === undefined && Array.isArray(payload.namespaces)) {
100
- auth.namespaces = payload.namespaces
101
- }
102
- }
103
- }
104
-
105
- return auth
106
- }
107
-
108
- /**
109
- * Write credentials to storage. Decodes the JWT and persists the
110
- * identity claims (loginName, sub, namespaces) alongside the token,
111
- * so future `readAuth()` calls don't have to decode it themselves.
112
- *
113
- * @param {{ token: string, email: string, expiresAt?: string }} auth - Caller passes the basics; identity fields are derived.
114
- */
115
- export async function writeAuth(auth) {
116
- const record = { ...auth }
117
-
118
- if (record.token) {
119
- const payload = decodeJwtPayload(record.token)
120
- if (payload) {
121
- if (typeof payload.loginName === 'string') record.loginName = payload.loginName
122
- if (typeof payload.sub === 'string') record.sub = payload.sub
123
- if (Array.isArray(payload.namespaces)) record.namespaces = payload.namespaces
124
- }
125
- }
126
-
127
- const dir = getAuthDir()
128
- await mkdir(dir, { recursive: true })
129
- await writeFile(join(dir, 'auth.json'), JSON.stringify(record, null, 2))
130
- }
131
-
132
- /**
133
- * Remove stored credentials.
134
- */
135
- export async function clearAuth() {
136
- const authPath = getAuthPath()
137
- if (existsSync(authPath)) {
138
- await unlink(authPath)
139
- }
140
- }
141
-
142
- /**
143
- * Check if credentials are expired.
144
- * @param {{ expiresAt?: string }} auth
145
- * @returns {boolean}
146
- */
147
- export function isExpired(auth) {
148
- if (!auth?.expiresAt) return false
149
- return new Date(auth.expiresAt) < new Date()
150
- }
151
-
152
- /**
153
- * Ensure the user is authenticated. If not, prompt inline login.
154
- * Returns the auth token on success, exits the process on cancel.
155
- *
156
- * In non-interactive mode (CI, no TTY, or --non-interactive in args),
157
- * bails with an actionable error instead of opening a browser. The browser
158
- * login flow waits 120 seconds for a callback that can never arrive without
159
- * a user, then drops to a token-paste prompt that pipes can't answer —
160
- * silently burning two minutes per invocation. CI / agent / piped callers
161
- * must set `UNIWEB_TOKEN`, run `uniweb login` interactively first, or use
162
- * `--local` for the unicloud mock (see workspace root CLAUDE.md).
163
- *
164
- * @param {Object} options
165
- * @param {string} options.command - The command that needs auth (for messaging)
166
- * @param {string[]} [options.args] - Argv slice; checked for --non-interactive
167
- * @returns {Promise<string>} Bearer token
168
- */
169
- export async function ensureAuth({ command = 'This command', args = [] } = {}) {
170
- // Honor explicit token from env — useful for CI and agents.
171
- if (process.env.UNIWEB_TOKEN) {
172
- return process.env.UNIWEB_TOKEN
173
- }
174
-
175
- const auth = await readAuth()
176
-
177
- if (auth?.token && !isExpired(auth)) {
178
- return auth.token
179
- }
180
-
181
- // Non-interactive bail: don't open a browser, don't wait 120s, don't
182
- // prompt for a token paste. Print an actionable error and exit.
183
- const { isNonInteractive, getCliPrefix } = await import('./interactive.js')
184
- if (isNonInteractive(args)) {
185
- const prefix = getCliPrefix()
186
- const reason = auth && isExpired(auth) ? 'Session expired.' : 'Not logged in.'
187
- console.error(`\x1b[31m✗\x1b[0m ${reason} ${command} requires a Uniweb account, and the CLI is in non-interactive mode (CI / no TTY / --non-interactive).`)
188
- console.error(` Options:`)
189
- console.error(` • Run \`${prefix} login\` interactively first, then re-run.`)
190
- console.error(` • Set the \`UNIWEB_TOKEN\` env var to a bearer token.`)
191
- console.error(` • Use \`--local\` to target the unicloud mock (internal testing only — see workspace root CLAUDE.md).`)
192
- process.exit(1)
193
- }
194
-
195
- // Need to log in — delegate to the login command
196
- if (auth && isExpired(auth)) {
197
- console.log(`\x1b[33mSession expired.\x1b[0m ${command} requires a Uniweb account.\n`)
198
- } else {
199
- console.log(`${command} requires a Uniweb account.\n`)
200
- }
201
-
202
- const { login } = await import('../commands/login.js')
203
- await login([])
204
-
205
- // Re-read auth after login
206
- const newAuth = await readAuth()
207
- if (!newAuth?.token) {
208
- process.exit(1)
209
- }
210
-
211
- return newAuth.token
212
- }