uniweb 0.8.21 → 0.8.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "uniweb",
3
- "version": "0.8.21",
3
+ "version": "0.8.22",
4
4
  "description": "Create structured Vite + React sites with content/code separation",
5
5
  "type": "module",
6
6
  "bin": {
@@ -41,12 +41,12 @@
41
41
  "js-yaml": "^4.1.0",
42
42
  "prompts": "^2.4.2",
43
43
  "tar": "^7.0.0",
44
- "@uniweb/core": "0.5.14",
45
- "@uniweb/kit": "0.7.15",
46
- "@uniweb/runtime": "0.6.15"
44
+ "@uniweb/core": "0.5.15",
45
+ "@uniweb/kit": "0.7.16",
46
+ "@uniweb/runtime": "0.6.16"
47
47
  },
48
48
  "peerDependencies": {
49
- "@uniweb/build": "0.8.20",
49
+ "@uniweb/build": "0.8.21",
50
50
  "@uniweb/content-reader": "1.1.4",
51
51
  "@uniweb/semantic-parser": "1.1.7"
52
52
  },
@@ -3,15 +3,21 @@
3
3
  *
4
4
  * Authenticates with the Uniweb platform. Stores credentials at ~/.uniweb/auth.json.
5
5
  *
6
- * Phase 1: Token-paste flow only.
7
- * Phase 2: Browser-based OAuth with token-paste fallback.
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
8
12
  *
9
13
  * Usage:
10
14
  * uniweb login
15
+ * uniweb login --token-paste # Skip browser, use token paste
11
16
  */
12
17
 
13
- import prompts from 'prompts'
18
+ import { createServer } from 'node:http'
14
19
  import { writeAuth, readAuth, isExpired } from '../utils/auth.js'
20
+ import { getBackendUrl } from '../utils/config.js'
15
21
 
16
22
  // Colors for terminal output
17
23
  const colors = {
@@ -33,21 +39,119 @@ function error(message) {
33
39
  }
34
40
 
35
41
  /**
36
- * Main login command handler
42
+ * Try to open a URL in the default browser.
43
+ * @param {string} url
44
+ * @returns {Promise<boolean>} Whether the browser was opened
37
45
  */
38
- export async function login(args = []) {
39
- // Check if already logged in
40
- const existing = await readAuth()
41
- if (existing && !isExpired(existing)) {
42
- console.log(`Already logged in as ${colors.bright}${existing.email}${colors.reset}`)
43
- console.log(`${colors.dim}Run \`uniweb login\` again to switch accounts.${colors.reset}`)
44
- console.log('')
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
45
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
+ }
46
144
 
47
- console.log('Log in to your Uniweb account at uniweb.app.')
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.')
48
153
  console.log('')
49
154
 
50
- // Phase 1: token-paste flow
51
155
  const response = await prompts([
52
156
  {
53
157
  type: 'text',
@@ -58,7 +162,7 @@ export async function login(args = []) {
58
162
  {
59
163
  type: 'password',
60
164
  name: 'token',
61
- message: 'Token (from uniweb.app/cli-login):',
165
+ message: 'Token:',
62
166
  validate: (v) => (v ? true : 'Token is required'),
63
167
  },
64
168
  ], {
@@ -69,19 +173,58 @@ export async function login(args = []) {
69
173
  })
70
174
 
71
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) {
72
215
  error('Login cancelled.')
73
216
  process.exit(1)
74
217
  }
75
218
 
76
- // Store credentials
219
+ // Store credentials (JWT has 30-day expiry)
77
220
  await writeAuth({
78
- token: response.token,
79
- email: response.email,
221
+ token: result.token,
222
+ email: result.email,
80
223
  expiresAt: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000).toISOString(),
81
224
  })
82
225
 
83
226
  console.log('')
84
- success(`Logged in as ${colors.bright}${response.email}${colors.reset}`)
227
+ success(`Logged in as ${colors.bright}${result.email}${colors.reset}`)
85
228
  }
86
229
 
87
230
  export default login
@@ -18,6 +18,7 @@ import { execSync } from 'node:child_process'
18
18
 
19
19
  import { createLocalRegistry, RemoteRegistry } from '../utils/registry.js'
20
20
  import { ensureAuth, readAuth } from '../utils/auth.js'
21
+ import { getRegistryUrl } from '../utils/config.js'
21
22
  import { findWorkspaceRoot, findFoundations, findSites, classifyPackage, promptSelect } from '../utils/workspace.js'
22
23
  import { isNonInteractive, getCliPrefix } from '../utils/interactive.js'
23
24
 
@@ -116,6 +117,17 @@ function parseRegistryUrl(args) {
116
117
  return args[idx + 1]
117
118
  }
118
119
 
120
+ /**
121
+ * Parse --namespace <handle> from args.
122
+ * @param {string[]} args
123
+ * @returns {string|null}
124
+ */
125
+ function parseNamespace(args) {
126
+ const idx = args.indexOf('--namespace')
127
+ if (idx === -1 || !args[idx + 1]) return null
128
+ return args[idx + 1]
129
+ }
130
+
119
131
  /**
120
132
  * Parse --edit-access <policy> from args.
121
133
  * @param {string[]} args
@@ -140,6 +152,7 @@ export async function publish(args = []) {
140
152
  const isDryRun = args.includes('--dry-run')
141
153
  const registryUrl = parseRegistryUrl(args)
142
154
  const editAccess = parseEditAccess(args)
155
+ const namespaceFlag = parseNamespace(args)
143
156
 
144
157
  // 1. Resolve foundation directory
145
158
  const foundationDir = await resolveFoundationDir(args)
@@ -179,15 +192,56 @@ export async function publish(args = []) {
179
192
  process.exit(1)
180
193
  }
181
194
 
182
- const name = schema._self?.name
195
+ const rawName = schema._self?.name
183
196
  const version = schema._self?.version
184
197
 
185
- if (!name || !version) {
198
+ if (!rawName || !version) {
186
199
  error('dist/meta/schema.json missing _self.name or _self.version')
187
200
  console.log(`${colors.dim} Ensure your package.json has "name" and "version" fields.${colors.reset}`)
188
201
  process.exit(1)
189
202
  }
190
203
 
204
+ // 3b. Resolve namespace (priority: --namespace flag > package.json uniweb.namespace > scoped name)
205
+ const pkg = JSON.parse(await readFile(join(foundationDir, 'package.json'), 'utf8'))
206
+ const uniwebNamespace = pkg.uniweb?.namespace
207
+ const scopedMatch = rawName.match(/^@([a-z0-9_-]+)\//)
208
+ const namespace = namespaceFlag || uniwebNamespace || scopedMatch?.[1]
209
+
210
+ if (!namespace) {
211
+ error('Namespace is required for publishing.')
212
+ console.log('')
213
+ console.log(` ${colors.dim}Use one of:${colors.reset}`)
214
+ console.log(` ${colors.cyan}uniweb publish --namespace <org-handle>${colors.reset}`)
215
+ console.log(` ${colors.dim}Add ${colors.reset}"uniweb": { "namespace": "<org-handle>" }${colors.dim} to package.json${colors.reset}`)
216
+ console.log(` ${colors.dim}Or use a scoped name: ${colors.reset}"name": "@org/foundation"${colors.dim} in package.json${colors.reset}`)
217
+ process.exit(1)
218
+ }
219
+
220
+ // Construct scoped name: @namespace/foundationName
221
+ const foundationName = scopedMatch ? rawName.slice(scopedMatch[0].length) : rawName
222
+ const name = `@${namespace}/${foundationName}`
223
+
224
+ // 3c. Advisory namespace check (Worker enforces — this is for early UX feedback)
225
+ if (!isLocal) {
226
+ const auth = await readAuth()
227
+ if (auth?.token) {
228
+ try {
229
+ const payload = JSON.parse(atob(auth.token.split('.')[1]))
230
+ if (payload.namespaces && !payload.namespaces.includes(namespace)) {
231
+ error(`You don't have publish access to namespace "${colors.bright}@${namespace}${colors.reset}"`)
232
+ if (payload.namespaces.length > 0) {
233
+ console.log(` ${colors.dim}Your namespaces: ${payload.namespaces.map(n => '@' + n).join(', ')}${colors.reset}`)
234
+ } else {
235
+ console.log(` ${colors.dim}You don't belong to any organizations. Ask an admin to add you.${colors.reset}`)
236
+ }
237
+ process.exit(1)
238
+ }
239
+ } catch {
240
+ // JWT decode failed — let the Worker validate
241
+ }
242
+ }
243
+ }
244
+
191
245
  // 4. Create registry (local or remote)
192
246
  const isRemote = !isLocal
193
247
  let registry
@@ -198,7 +252,7 @@ export async function publish(args = []) {
198
252
  // Remote publish — ensure authenticated (inline login if needed)
199
253
  const token = await ensureAuth({ command: 'Publishing' })
200
254
 
201
- const url = registryUrl || process.env.UNIWEB_REGISTRY_URL || 'http://localhost:4001'
255
+ const url = registryUrl || getRegistryUrl()
202
256
  registry = new RemoteRegistry(url, token)
203
257
  }
204
258
 
package/src/utils/auth.js CHANGED
@@ -88,63 +88,21 @@ export async function ensureAuth({ command = 'This command' } = {}) {
88
88
  return auth.token
89
89
  }
90
90
 
91
- // Need to log in
92
- const prompts = (await import('prompts')).default
93
-
91
+ // Need to log in — delegate to the login command
94
92
  if (auth && isExpired(auth)) {
95
93
  console.log(`\x1b[33mSession expired.\x1b[0m ${command} requires a Uniweb account.\n`)
96
94
  } else {
97
95
  console.log(`${command} requires a Uniweb account.\n`)
98
96
  }
99
97
 
100
- const { action } = await prompts({
101
- type: 'select',
102
- name: 'action',
103
- message: 'What would you like to do?',
104
- choices: [
105
- { title: 'Log in (paste token from uniweb.app/cli-login)', value: 'login' },
106
- { title: 'Cancel', value: 'cancel' },
107
- ],
108
- }, {
109
- onCancel: () => {
110
- process.exit(0)
111
- },
112
- })
113
-
114
- if (action !== 'login') {
115
- process.exit(0)
116
- }
117
-
118
- const response = await prompts([
119
- {
120
- type: 'text',
121
- name: 'email',
122
- message: 'Email:',
123
- validate: (v) => (v && v.includes('@') ? true : 'Enter a valid email'),
124
- },
125
- {
126
- type: 'password',
127
- name: 'token',
128
- message: 'Token:',
129
- validate: (v) => (v ? true : 'Token is required'),
130
- },
131
- ], {
132
- onCancel: () => {
133
- process.exit(0)
134
- },
135
- })
98
+ const { login } = await import('../commands/login.js')
99
+ await login([])
136
100
 
137
- if (!response.email || !response.token) {
101
+ // Re-read auth after login
102
+ const newAuth = await readAuth()
103
+ if (!newAuth?.token) {
138
104
  process.exit(1)
139
105
  }
140
106
 
141
- await writeAuth({
142
- token: response.token,
143
- email: response.email,
144
- expiresAt: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000).toISOString(),
145
- })
146
-
147
- console.log(`\n\x1b[32m✓\x1b[0m Logged in as \x1b[1m${response.email}\x1b[0m\n`)
148
-
149
- return response.token
107
+ return newAuth.token
150
108
  }
@@ -5,12 +5,70 @@
5
5
  * Used by both `create` and `add` commands.
6
6
  */
7
7
 
8
- import { existsSync } from 'node:fs'
8
+ import { existsSync, readFileSync } from 'node:fs'
9
9
  import { readFile, writeFile } from 'node:fs/promises'
10
10
  import { join } from 'node:path'
11
+ import { homedir } from 'node:os'
11
12
  import yaml from 'js-yaml'
12
13
  import { filterCmd } from './pm.js'
13
14
 
15
+ // ── Platform URLs ──────────────────────────────────────────────
16
+
17
+ // Production defaults — regular users get these out of the box
18
+ const PRODUCTION_BACKEND_URL = 'https://uniweb.app'
19
+ const PRODUCTION_REGISTRY_URL = 'https://site-router.uniweb-edge.workers.dev'
20
+
21
+ /**
22
+ * Read ~/.uniweb/config.json for persistent URL overrides.
23
+ * Platform developers use this to point CLI to local servers.
24
+ *
25
+ * Example ~/.uniweb/config.json:
26
+ * { "backendUrl": "http://127.0.0.1:8002", "registryUrl": "http://localhost:4001" }
27
+ *
28
+ * @returns {{ backendUrl?: string, registryUrl?: string }}
29
+ */
30
+ let _cliConfig = undefined
31
+ function readCliConfig() {
32
+ if (_cliConfig !== undefined) return _cliConfig
33
+
34
+ try {
35
+ const configPath = join(homedir(), '.uniweb', 'config.json')
36
+ if (existsSync(configPath)) {
37
+ _cliConfig = JSON.parse(readFileSync(configPath, 'utf8'))
38
+ return _cliConfig
39
+ }
40
+ } catch {
41
+ // ignore
42
+ }
43
+
44
+ _cliConfig = {}
45
+ return _cliConfig
46
+ }
47
+
48
+ /**
49
+ * Get the PHP backend URL.
50
+ *
51
+ * Priority: env var > ~/.uniweb/config.json > production default
52
+ * @returns {string}
53
+ */
54
+ export function getBackendUrl() {
55
+ return process.env.UNIWEB_BACKEND_URL
56
+ || readCliConfig().backendUrl
57
+ || PRODUCTION_BACKEND_URL
58
+ }
59
+
60
+ /**
61
+ * Get the registry API URL (Cloudflare Worker or local unicloud).
62
+ *
63
+ * Priority: env var > ~/.uniweb/config.json > production default
64
+ * @returns {string}
65
+ */
66
+ export function getRegistryUrl() {
67
+ return process.env.UNIWEB_REGISTRY_URL
68
+ || readCliConfig().registryUrl
69
+ || PRODUCTION_REGISTRY_URL
70
+ }
71
+
14
72
  /**
15
73
  * Read workspace package globs.
16
74
  * Tries pnpm-workspace.yaml first, falls back to package.json workspaces.
@@ -42,7 +42,8 @@ export function getRegistryDir(startDir = process.cwd()) {
42
42
  * @returns {string}
43
43
  */
44
44
  function sanitizeName(name) {
45
- return name.replace(/\//g, '__')
45
+ // Strip leading @ for directory structure: @org/name → org/name
46
+ return name.startsWith('@') ? name.slice(1) : name
46
47
  }
47
48
 
48
49
  /**
@@ -256,7 +257,7 @@ export class RemoteRegistry {
256
257
  * @returns {Promise<Object>}
257
258
  */
258
259
  async createInvite(foundationName, payload) {
259
- const res = await fetch(`${this.apiUrl}/api/foundations/${foundationName}/invites`, {
260
+ const res = await fetch(`${this.apiUrl}/api/foundations/${encodeURIComponent(foundationName)}/invites`, {
260
261
  method: 'POST',
261
262
  headers: this._authHeaders(),
262
263
  body: JSON.stringify(payload),
@@ -274,7 +275,7 @@ export class RemoteRegistry {
274
275
  * @returns {Promise<Array>}
275
276
  */
276
277
  async listInvites(foundationName) {
277
- const res = await fetch(`${this.apiUrl}/api/foundations/${foundationName}/invites`, {
278
+ const res = await fetch(`${this.apiUrl}/api/foundations/${encodeURIComponent(foundationName)}/invites`, {
278
279
  headers: this._authHeaders(),
279
280
  })
280
281
  const body = await res.json()
@@ -291,7 +292,7 @@ export class RemoteRegistry {
291
292
  * @returns {Promise<Object>}
292
293
  */
293
294
  async revokeInvite(foundationName, inviteId) {
294
- const res = await fetch(`${this.apiUrl}/api/foundations/${foundationName}/invites/${inviteId}`, {
295
+ const res = await fetch(`${this.apiUrl}/api/foundations/${encodeURIComponent(foundationName)}/invites/${inviteId}`, {
295
296
  method: 'DELETE',
296
297
  headers: this._authHeaders(),
297
298
  })
@@ -309,7 +310,7 @@ export class RemoteRegistry {
309
310
  * @returns {Promise<Object>}
310
311
  */
311
312
  async resendInvite(foundationName, inviteId) {
312
- const res = await fetch(`${this.apiUrl}/api/foundations/${foundationName}/invites/${inviteId}/resend`, {
313
+ const res = await fetch(`${this.apiUrl}/api/foundations/${encodeURIComponent(foundationName)}/invites/${inviteId}/resend`, {
313
314
  method: 'POST',
314
315
  headers: this._authHeaders(),
315
316
  })