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.
- package/README.md +42 -12
- package/package.json +19 -6
- package/partials/agents.md +16 -13
- package/partials/components-docs.hbs +2 -2
- package/partials/config-reference.hbs +1 -1
- package/src/commands/add.js +21 -10
- package/src/commands/build.js +29 -41
- package/src/commands/deploy.js +272 -0
- package/src/commands/doctor.js +2 -2
- package/src/commands/handoff.js +254 -0
- package/src/commands/invite.js +326 -0
- package/src/commands/login.js +87 -0
- package/src/commands/publish.js +300 -0
- package/src/commands/template.js +230 -0
- package/src/index.js +265 -28
- package/src/utils/auth.js +150 -0
- package/src/utils/registry.js +361 -0
- package/src/utils/update-check.js +105 -0
- package/src/versions.js +1 -1
|
@@ -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.
|