uniweb 0.8.14 → 0.8.16

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,150 @@
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
+
10
+ import { existsSync } from 'node:fs'
11
+ import { readFile, writeFile, mkdir, unlink } from 'node:fs/promises'
12
+ import { join } from 'node:path'
13
+ import { homedir } from 'node:os'
14
+
15
+ /**
16
+ * Get the ~/.uniweb/ directory path.
17
+ * @returns {string}
18
+ */
19
+ export function getAuthDir() {
20
+ return join(homedir(), '.uniweb')
21
+ }
22
+
23
+ /**
24
+ * Get the ~/.uniweb/auth.json file path.
25
+ * @returns {string}
26
+ */
27
+ export function getAuthPath() {
28
+ return join(getAuthDir(), 'auth.json')
29
+ }
30
+
31
+ /**
32
+ * Read stored credentials.
33
+ * @returns {Promise<{ token: string, email: string, expiresAt?: string } | null>}
34
+ */
35
+ export async function readAuth() {
36
+ const authPath = getAuthPath()
37
+ if (!existsSync(authPath)) return null
38
+
39
+ try {
40
+ return JSON.parse(await readFile(authPath, 'utf8'))
41
+ } catch {
42
+ return null
43
+ }
44
+ }
45
+
46
+ /**
47
+ * Write credentials to storage.
48
+ * @param {{ token: string, email: string, expiresAt?: string }} auth
49
+ */
50
+ export async function writeAuth(auth) {
51
+ const dir = getAuthDir()
52
+ await mkdir(dir, { recursive: true })
53
+ await writeFile(join(dir, 'auth.json'), JSON.stringify(auth, null, 2))
54
+ }
55
+
56
+ /**
57
+ * Remove stored credentials.
58
+ */
59
+ export async function clearAuth() {
60
+ const authPath = getAuthPath()
61
+ if (existsSync(authPath)) {
62
+ await unlink(authPath)
63
+ }
64
+ }
65
+
66
+ /**
67
+ * Check if credentials are expired.
68
+ * @param {{ expiresAt?: string }} auth
69
+ * @returns {boolean}
70
+ */
71
+ export function isExpired(auth) {
72
+ if (!auth?.expiresAt) return false
73
+ return new Date(auth.expiresAt) < new Date()
74
+ }
75
+
76
+ /**
77
+ * Ensure the user is authenticated. If not, prompt inline login.
78
+ * Returns the auth token on success, exits the process on cancel.
79
+ *
80
+ * @param {Object} options
81
+ * @param {string} options.command - The command that needs auth (for messaging)
82
+ * @returns {Promise<string>} Bearer token
83
+ */
84
+ export async function ensureAuth({ command = 'This command' } = {}) {
85
+ const auth = await readAuth()
86
+
87
+ if (auth?.token && !isExpired(auth)) {
88
+ return auth.token
89
+ }
90
+
91
+ // Need to log in
92
+ const prompts = (await import('prompts')).default
93
+
94
+ if (auth && isExpired(auth)) {
95
+ console.log(`\x1b[33mSession expired.\x1b[0m ${command} requires a Uniweb account.\n`)
96
+ } else {
97
+ console.log(`${command} requires a Uniweb account.\n`)
98
+ }
99
+
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
+ })
136
+
137
+ if (!response.email || !response.token) {
138
+ process.exit(1)
139
+ }
140
+
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
150
+ }
@@ -0,0 +1,361 @@
1
+ /**
2
+ * Local Foundation Registry
3
+ *
4
+ * Manages published foundations in .unicloud/registry/.
5
+ * Same on-disk format as scripts/platform/registry.js, so
6
+ * scripts/platform/serve.js can still serve them.
7
+ *
8
+ * Layout:
9
+ * .unicloud/
10
+ * registry/
11
+ * index.json # { "name": { versions: { "1.0.0": { ... } } } }
12
+ * packages/
13
+ * name/
14
+ * 1.0.0/
15
+ * foundation.js
16
+ * schema.json
17
+ * assets/...
18
+ */
19
+
20
+ import { existsSync } from 'node:fs'
21
+ import { readFile, writeFile, readdir, mkdir, cp } from 'node:fs/promises'
22
+ import { join, dirname, relative } from 'node:path'
23
+
24
+ import { findWorkspaceRoot } from './workspace.js'
25
+
26
+ /**
27
+ * Get the .unicloud/registry/ directory path.
28
+ * Looks for workspace root first; falls back to cwd.
29
+ * @param {string} [startDir]
30
+ * @returns {string}
31
+ */
32
+ export function getRegistryDir(startDir = process.cwd()) {
33
+ const root = findWorkspaceRoot(startDir)
34
+ const base = root || startDir
35
+ return join(base, '.unicloud', 'registry')
36
+ }
37
+
38
+ /**
39
+ * Sanitize a package name for filesystem use.
40
+ * '@org/pkg' → '@org__pkg'
41
+ * @param {string} name
42
+ * @returns {string}
43
+ */
44
+ function sanitizeName(name) {
45
+ return name.replace(/\//g, '__')
46
+ }
47
+
48
+ /**
49
+ * Local registry — stores published foundations in .unicloud/registry/
50
+ */
51
+ export class LocalRegistry {
52
+ constructor(startDir) {
53
+ this.registryDir = getRegistryDir(startDir)
54
+ this.indexPath = join(this.registryDir, 'index.json')
55
+ this.packagesDir = join(this.registryDir, 'packages')
56
+ }
57
+
58
+ async _readIndex() {
59
+ if (!existsSync(this.indexPath)) return {}
60
+ return JSON.parse(await readFile(this.indexPath, 'utf8'))
61
+ }
62
+
63
+ async _writeIndex(index) {
64
+ await mkdir(this.registryDir, { recursive: true })
65
+ await writeFile(this.indexPath, JSON.stringify(index, null, 2))
66
+ }
67
+
68
+ /**
69
+ * Check if a specific version exists.
70
+ * @param {string} name
71
+ * @param {string} version
72
+ * @returns {Promise<boolean>}
73
+ */
74
+ async exists(name, version) {
75
+ const index = await this._readIndex()
76
+ return !!index[name]?.versions?.[version]
77
+ }
78
+
79
+ /**
80
+ * Get all published versions for a package.
81
+ * @param {string} name
82
+ * @returns {Promise<Object>}
83
+ */
84
+ async getVersions(name) {
85
+ const index = await this._readIndex()
86
+ return index[name]?.versions || {}
87
+ }
88
+
89
+ /**
90
+ * Publish a foundation to the local registry.
91
+ * Copies the dist directory and updates the index.
92
+ * @param {string} name
93
+ * @param {string} version
94
+ * @param {string} distDir - Path to the foundation's dist/ directory
95
+ * @param {Object} [metadata] - Additional metadata (publishedBy, etc.)
96
+ */
97
+ async publish(name, version, distDir, metadata = {}) {
98
+ const safeName = sanitizeName(name)
99
+ const destDir = join(this.packagesDir, safeName, version)
100
+
101
+ await mkdir(destDir, { recursive: true })
102
+ await cp(distDir, destDir, { recursive: true })
103
+
104
+ const index = await this._readIndex()
105
+ if (!index[name]) {
106
+ index[name] = { versions: {} }
107
+ }
108
+ index[name].versions[version] = {
109
+ publishedAt: new Date().toISOString(),
110
+ ...metadata,
111
+ }
112
+ await this._writeIndex(index)
113
+ }
114
+
115
+ /**
116
+ * Get the filesystem path for a published package version.
117
+ * @param {string} name
118
+ * @param {string} version
119
+ * @returns {string}
120
+ */
121
+ getPackagePath(name, version) {
122
+ return join(this.packagesDir, sanitizeName(name), version)
123
+ }
124
+ }
125
+
126
+ /**
127
+ * Create a local registry instance.
128
+ * @param {string} [startDir]
129
+ * @returns {LocalRegistry}
130
+ */
131
+ export function createLocalRegistry(startDir) {
132
+ return new LocalRegistry(startDir)
133
+ }
134
+
135
+ /**
136
+ * Remote registry — publishes foundations to a cloud server via HTTP.
137
+ */
138
+ export class RemoteRegistry {
139
+ /**
140
+ * @param {string} apiUrl - Registry server URL (e.g. "http://localhost:4001")
141
+ * @param {string} [token] - Bearer token for authentication
142
+ */
143
+ constructor(apiUrl, token) {
144
+ this.apiUrl = apiUrl.replace(/\/$/, '')
145
+ this.token = token
146
+ }
147
+
148
+ /**
149
+ * Fetch the registry index from the server.
150
+ * @returns {Promise<Object>}
151
+ */
152
+ async _fetchIndex() {
153
+ const res = await fetch(`${this.apiUrl}/`)
154
+ if (!res.ok) throw new Error(`Registry request failed: ${res.status}`)
155
+ return res.json()
156
+ }
157
+
158
+ /**
159
+ * Check if a specific version exists on the remote.
160
+ * @param {string} name
161
+ * @param {string} version
162
+ * @returns {Promise<boolean>}
163
+ */
164
+ async exists(name, version) {
165
+ try {
166
+ const index = await this._fetchIndex()
167
+ return !!index[name]?.versions?.[version]
168
+ } catch {
169
+ return false
170
+ }
171
+ }
172
+
173
+ /**
174
+ * Get all published versions for a package.
175
+ * @param {string} name
176
+ * @returns {Promise<Object>}
177
+ */
178
+ async getVersions(name) {
179
+ const index = await this._fetchIndex()
180
+ return index[name]?.versions || {}
181
+ }
182
+
183
+ /**
184
+ * Publish a foundation to the remote registry.
185
+ * Reads files from distDir, encodes as base64, and POSTs to the server.
186
+ *
187
+ * @param {string} name
188
+ * @param {string} version
189
+ * @param {string} distDir - Path to the foundation's dist/ directory
190
+ * @param {Object} [metadata] - Additional metadata
191
+ * @returns {Promise<{ name: string, version: string, filesCount: number }>}
192
+ */
193
+ async publish(name, version, distDir, metadata = {}) {
194
+ // Walk distDir recursively and encode files as base64
195
+ const files = {}
196
+ const entries = await readdir(distDir, { withFileTypes: true, recursive: true })
197
+
198
+ for (const entry of entries) {
199
+ if (!entry.isFile()) continue
200
+ const fullPath = join(entry.parentPath || entry.path, entry.name)
201
+ const relPath = relative(distDir, fullPath)
202
+ const content = await readFile(fullPath)
203
+ files[relPath] = content.toString('base64')
204
+ }
205
+
206
+ const { editAccess, ...restMetadata } = metadata
207
+ const payload = { name, version, files, metadata: restMetadata }
208
+ if (editAccess) {
209
+ payload.editAccess = editAccess
210
+ }
211
+
212
+ const headers = { 'Content-Type': 'application/json' }
213
+ if (this.token) {
214
+ headers['Authorization'] = `Bearer ${this.token}`
215
+ }
216
+
217
+ const res = await fetch(`${this.apiUrl}/foundations`, {
218
+ method: 'POST',
219
+ headers,
220
+ body: JSON.stringify(payload),
221
+ })
222
+
223
+ const body = await res.json()
224
+
225
+ if (!res.ok) {
226
+ if (res.status === 409) {
227
+ throw Object.assign(new Error(body.error || `${name}@${version} already exists`), { code: 'CONFLICT' })
228
+ }
229
+ if (res.status === 401) {
230
+ throw Object.assign(new Error(body.error || 'Unauthorized'), { code: 'UNAUTHORIZED' })
231
+ }
232
+ throw new Error(body.error || `Server error (${res.status})`)
233
+ }
234
+
235
+ return body
236
+ }
237
+
238
+ /**
239
+ * Common fetch helper with auth headers.
240
+ * @param {string} url
241
+ * @param {Object} [options]
242
+ * @returns {Promise<Response>}
243
+ */
244
+ _authHeaders() {
245
+ const headers = { 'Content-Type': 'application/json' }
246
+ if (this.token) {
247
+ headers['Authorization'] = `Bearer ${this.token}`
248
+ }
249
+ return headers
250
+ }
251
+
252
+ /**
253
+ * Create a foundation invite.
254
+ * @param {string} foundationName
255
+ * @param {Object} payload - { email, majorVersion, maxUses?, expiresInDays? }
256
+ * @returns {Promise<Object>}
257
+ */
258
+ async createInvite(foundationName, payload) {
259
+ const res = await fetch(`${this.apiUrl}/api/foundations/${foundationName}/invites`, {
260
+ method: 'POST',
261
+ headers: this._authHeaders(),
262
+ body: JSON.stringify(payload),
263
+ })
264
+ const body = await res.json()
265
+ if (!res.ok) {
266
+ throw Object.assign(new Error(body.error || `Server error (${res.status})`), { statusCode: res.status })
267
+ }
268
+ return body
269
+ }
270
+
271
+ /**
272
+ * List invites for a foundation.
273
+ * @param {string} foundationName
274
+ * @returns {Promise<Array>}
275
+ */
276
+ async listInvites(foundationName) {
277
+ const res = await fetch(`${this.apiUrl}/api/foundations/${foundationName}/invites`, {
278
+ headers: this._authHeaders(),
279
+ })
280
+ const body = await res.json()
281
+ if (!res.ok) {
282
+ throw Object.assign(new Error(body.error || `Server error (${res.status})`), { statusCode: res.status })
283
+ }
284
+ return body.invites || []
285
+ }
286
+
287
+ /**
288
+ * Revoke a foundation invite.
289
+ * @param {string} foundationName
290
+ * @param {string} inviteId
291
+ * @returns {Promise<Object>}
292
+ */
293
+ async revokeInvite(foundationName, inviteId) {
294
+ const res = await fetch(`${this.apiUrl}/api/foundations/${foundationName}/invites/${inviteId}`, {
295
+ method: 'DELETE',
296
+ headers: this._authHeaders(),
297
+ })
298
+ const body = await res.json()
299
+ if (!res.ok) {
300
+ throw Object.assign(new Error(body.error || `Server error (${res.status})`), { statusCode: res.status })
301
+ }
302
+ return body
303
+ }
304
+
305
+ /**
306
+ * Resend a foundation invite.
307
+ * @param {string} foundationName
308
+ * @param {string} inviteId
309
+ * @returns {Promise<Object>}
310
+ */
311
+ async resendInvite(foundationName, inviteId) {
312
+ const res = await fetch(`${this.apiUrl}/api/foundations/${foundationName}/invites/${inviteId}/resend`, {
313
+ method: 'POST',
314
+ headers: this._authHeaders(),
315
+ })
316
+ const body = await res.json()
317
+ if (!res.ok) {
318
+ throw Object.assign(new Error(body.error || `Server error (${res.status})`), { statusCode: res.status })
319
+ }
320
+ return body
321
+ }
322
+
323
+ /**
324
+ * Create a site record on Unicloud.
325
+ * @param {string} siteId
326
+ * @param {Object} options
327
+ * @param {Object} options.foundation - { name }
328
+ * @returns {Promise<Object>}
329
+ */
330
+ async createSite(siteId, { foundation }) {
331
+ const res = await fetch(`${this.apiUrl}/api/sites`, {
332
+ method: 'POST',
333
+ headers: this._authHeaders(),
334
+ body: JSON.stringify({ siteId, foundation }),
335
+ })
336
+ const body = await res.json()
337
+ if (!res.ok) {
338
+ throw Object.assign(new Error(body.error || `Server error (${res.status})`), { statusCode: res.status })
339
+ }
340
+ return body
341
+ }
342
+
343
+ /**
344
+ * Transfer site ownership.
345
+ * @param {string} siteId
346
+ * @param {string} newOwner - Email of the new owner
347
+ * @returns {Promise<Object>}
348
+ */
349
+ async transferSiteOwnership(siteId, newOwner) {
350
+ const res = await fetch(`${this.apiUrl}/api/sites/${siteId}/owner`, {
351
+ method: 'PATCH',
352
+ headers: this._authHeaders(),
353
+ body: JSON.stringify({ newOwner }),
354
+ })
355
+ const body = await res.json()
356
+ if (!res.ok) {
357
+ throw Object.assign(new Error(body.error || `Server error (${res.status})`), { statusCode: res.status })
358
+ }
359
+ return body
360
+ }
361
+ }
@@ -0,0 +1,105 @@
1
+ /**
2
+ * Lightweight Update Notification
3
+ *
4
+ * Checks the npm registry for newer versions of the CLI.
5
+ * Runs at most once per day, caches results in ~/.uniweb/update-check.json.
6
+ * Uses Node 20+ built-in fetch — no external dependencies.
7
+ */
8
+
9
+ import { homedir } from 'node:os'
10
+ import { existsSync, readFileSync, mkdirSync, writeFileSync } from 'node:fs'
11
+ import { join } from 'node:path'
12
+
13
+ const CHECK_INTERVAL = 24 * 60 * 60 * 1000 // 1 day
14
+ const STATE_DIR = join(homedir(), '.uniweb')
15
+ const STATE_FILE = join(STATE_DIR, 'update-check.json')
16
+
17
+ /**
18
+ * Compare two semver strings.
19
+ * Returns 1 if a > b, -1 if a < b, 0 if equal.
20
+ */
21
+ function compareSemver(a, b) {
22
+ const pa = a.split('.').map(Number)
23
+ const pb = b.split('.').map(Number)
24
+ for (let i = 0; i < 3; i++) {
25
+ if ((pa[i] || 0) > (pb[i] || 0)) return 1
26
+ if ((pa[i] || 0) < (pb[i] || 0)) return -1
27
+ }
28
+ return 0
29
+ }
30
+
31
+ /**
32
+ * Read cached update check state.
33
+ */
34
+ function readState() {
35
+ try {
36
+ if (existsSync(STATE_FILE)) {
37
+ return JSON.parse(readFileSync(STATE_FILE, 'utf8'))
38
+ }
39
+ } catch { /* ignore corrupt cache */ }
40
+ return {}
41
+ }
42
+
43
+ /**
44
+ * Write update check state to disk.
45
+ */
46
+ function writeState(state) {
47
+ try {
48
+ if (!existsSync(STATE_DIR)) mkdirSync(STATE_DIR, { recursive: true })
49
+ writeFileSync(STATE_FILE, JSON.stringify(state))
50
+ } catch { /* ignore write errors */ }
51
+ }
52
+
53
+ /**
54
+ * Print update notification to stderr (doesn't interfere with piped output).
55
+ */
56
+ function printNotification(current, latest) {
57
+ const yellow = '\x1b[33m'
58
+ const cyan = '\x1b[36m'
59
+ const dim = '\x1b[2m'
60
+ const reset = '\x1b[0m'
61
+ console.error('')
62
+ console.error(`${yellow}Update available:${reset} ${dim}${current}${reset} → ${cyan}${latest}${reset}`)
63
+ console.error(`${dim}Run${reset} npm i -g uniweb ${dim}to update${reset}`)
64
+ }
65
+
66
+ /**
67
+ * Start a non-blocking update check.
68
+ *
69
+ * Returns a function that, when called (optionally awaited), prints
70
+ * the notification if a newer version was found.
71
+ *
72
+ * @param {string} currentVersion - The currently running CLI version
73
+ * @returns {Function} Call at the end of command execution to show notification
74
+ */
75
+ export function startUpdateCheck(currentVersion) {
76
+ let notification = null
77
+ const state = readState()
78
+
79
+ // Use cached result if checked recently
80
+ if (state.lastCheck && (Date.now() - state.lastCheck) < CHECK_INTERVAL) {
81
+ if (state.latestVersion && compareSemver(state.latestVersion, currentVersion) > 0) {
82
+ notification = state.latestVersion
83
+ }
84
+ return () => {
85
+ if (notification) printNotification(currentVersion, notification)
86
+ }
87
+ }
88
+
89
+ // Background fetch (non-blocking)
90
+ const fetchPromise = fetch('https://registry.npmjs.org/uniweb/latest')
91
+ .then(r => r.json())
92
+ .then(data => {
93
+ const latest = data.version
94
+ writeState({ lastCheck: Date.now(), latestVersion: latest })
95
+ if (compareSemver(latest, currentVersion) > 0) {
96
+ notification = latest
97
+ }
98
+ })
99
+ .catch(() => { /* network error — ignore silently */ })
100
+
101
+ return async () => {
102
+ await fetchPromise
103
+ if (notification) printNotification(currentVersion, notification)
104
+ }
105
+ }
package/src/versions.js CHANGED
@@ -61,7 +61,7 @@ export function getResolvedVersions() {
61
61
  if (resolvedVersions) return resolvedVersions
62
62
 
63
63
  const pkg = getCliPackageJson()
64
- const deps = { ...pkg.dependencies, ...pkg.devDependencies }
64
+ const deps = { ...pkg.dependencies, ...pkg.devDependencies, ...pkg.peerDependencies }
65
65
 
66
66
  // All @uniweb/* packages are now direct dependencies of the CLI.
67
67
  // When publishing with pnpm, workspace:* gets resolved to actual versions.