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,272 @@
1
+ /**
2
+ * Deploy Command
3
+ *
4
+ * Deploys a built site to Uniweb hosting.
5
+ *
6
+ * Usage:
7
+ * uniweb deploy # Deploy to Uniweb hosting
8
+ * uniweb deploy --local # Deploy to local server (no auth)
9
+ * uniweb deploy --registry <url> # Deploy to a specific server URL
10
+ * uniweb deploy --dry-run # Show what would be deployed
11
+ * uniweb deploy --prod # Deploy to production (future)
12
+ */
13
+
14
+ import { existsSync } from 'node:fs'
15
+ import { readFile, readdir } from 'node:fs/promises'
16
+ import { resolve, join, basename, relative } from 'node:path'
17
+ import { execSync } from 'node:child_process'
18
+
19
+ import { ensureAuth } from '../utils/auth.js'
20
+ import { findWorkspaceRoot, findSites, findFoundations, classifyPackage, promptSelect } from '../utils/workspace.js'
21
+ import { isNonInteractive, getCliPrefix } from '../utils/interactive.js'
22
+
23
+ // Colors for terminal output
24
+ const colors = {
25
+ reset: '\x1b[0m',
26
+ bright: '\x1b[1m',
27
+ dim: '\x1b[2m',
28
+ cyan: '\x1b[36m',
29
+ green: '\x1b[32m',
30
+ yellow: '\x1b[33m',
31
+ red: '\x1b[31m',
32
+ }
33
+
34
+ function success(message) {
35
+ console.log(`${colors.green}✓${colors.reset} ${message}`)
36
+ }
37
+
38
+ function error(message) {
39
+ console.error(`${colors.red}✗${colors.reset} ${message}`)
40
+ }
41
+
42
+ function info(message) {
43
+ console.log(`${colors.cyan}→${colors.reset} ${message}`)
44
+ }
45
+
46
+ /**
47
+ * Resolve the site directory to deploy.
48
+ *
49
+ * Priority:
50
+ * 1. In a site directory → use it
51
+ * 2. At workspace root, one site → use it
52
+ * 3. At workspace root, multiple → prompt (or error if non-interactive)
53
+ * 4. No site → educational error with alternatives
54
+ *
55
+ * @param {string[]} args
56
+ * @returns {Promise<string>} Absolute path to the site directory
57
+ */
58
+ async function resolveSiteDir(args) {
59
+ const cwd = process.cwd()
60
+ const prefix = getCliPrefix()
61
+
62
+ // Check if current directory is a site
63
+ const type = await classifyPackage(cwd)
64
+ if (type === 'site') {
65
+ return cwd
66
+ }
67
+
68
+ // Check workspace
69
+ const workspaceRoot = findWorkspaceRoot(cwd)
70
+ if (workspaceRoot) {
71
+ const sites = await findSites(workspaceRoot)
72
+
73
+ if (sites.length === 1) {
74
+ return resolve(workspaceRoot, sites[0])
75
+ }
76
+
77
+ if (sites.length > 1) {
78
+ if (isNonInteractive(args)) {
79
+ error('Multiple sites found. Specify which one to deploy.')
80
+ console.log('')
81
+ for (const s of sites) {
82
+ console.log(` ${colors.cyan}cd ${s} && ${prefix} deploy${colors.reset}`)
83
+ }
84
+ process.exit(1)
85
+ }
86
+
87
+ const choice = await promptSelect('Which site?', sites)
88
+ if (!choice) {
89
+ console.log('\nDeploy cancelled.')
90
+ process.exit(0)
91
+ }
92
+ return resolve(workspaceRoot, choice)
93
+ }
94
+ }
95
+
96
+ // No site found — educational error
97
+ error('No site found in this workspace.')
98
+ console.log('')
99
+ console.log(` ${colors.dim}\`deploy\` uploads your built site to Uniweb hosting.${colors.reset}`)
100
+ console.log('')
101
+ console.log(` ${colors.dim}The site is a standard Vite build — you can also upload dist/${colors.reset}`)
102
+ console.log(` ${colors.dim}to any static host.${colors.reset}`)
103
+ process.exit(1)
104
+ }
105
+
106
+ /**
107
+ * Parse --registry <url> from args.
108
+ * @param {string[]} args
109
+ * @returns {string|null}
110
+ */
111
+ function parseRegistryUrl(args) {
112
+ const idx = args.indexOf('--registry')
113
+ if (idx === -1 || !args[idx + 1]) return null
114
+ return args[idx + 1]
115
+ }
116
+
117
+ /**
118
+ * Derive a siteId from the site's package.json or directory name.
119
+ * @param {string} siteDir
120
+ * @returns {Promise<string>}
121
+ */
122
+ async function deriveSiteId(siteDir) {
123
+ const pkgPath = join(siteDir, 'package.json')
124
+ if (existsSync(pkgPath)) {
125
+ try {
126
+ const pkg = JSON.parse(await readFile(pkgPath, 'utf8'))
127
+ if (pkg.name) return pkg.name
128
+ } catch {
129
+ // Fall through
130
+ }
131
+ }
132
+ return basename(siteDir)
133
+ }
134
+
135
+ /**
136
+ * Walk a directory recursively and collect all files as base64.
137
+ * @param {string} dir
138
+ * @returns {Promise<Object<string, string>>} Map of relative paths to base64 content
139
+ */
140
+ async function collectFiles(dir) {
141
+ const files = {}
142
+ const entries = await readdir(dir, { withFileTypes: true, recursive: true })
143
+
144
+ for (const entry of entries) {
145
+ if (!entry.isFile()) continue
146
+ const fullPath = join(entry.parentPath || entry.path, entry.name)
147
+ const relPath = relative(dir, fullPath)
148
+ const content = await readFile(fullPath)
149
+ files[relPath] = content.toString('base64')
150
+ }
151
+
152
+ return files
153
+ }
154
+
155
+ /**
156
+ * Main deploy command handler
157
+ */
158
+ export async function deploy(args = []) {
159
+ const isLocal = args.includes('--local')
160
+ const isDryRun = args.includes('--dry-run')
161
+ const registryUrl = parseRegistryUrl(args)
162
+ const prefix = getCliPrefix()
163
+
164
+ // 1. Resolve site directory
165
+ const siteDir = await resolveSiteDir(args)
166
+
167
+ // 2. Check auth (unless --local)
168
+ let token = null
169
+ if (!isLocal) {
170
+ token = await ensureAuth({ command: 'Deploying' })
171
+ }
172
+
173
+ // 3. Auto-build if dist/ is missing
174
+ const distDir = join(siteDir, 'dist')
175
+ const indexHtml = join(distDir, 'index.html')
176
+
177
+ if (!existsSync(indexHtml)) {
178
+ console.log(`${colors.yellow}⚠${colors.reset} No build found. Building site...`)
179
+ console.log('')
180
+ execSync('npx uniweb build', {
181
+ cwd: siteDir,
182
+ stdio: 'inherit',
183
+ })
184
+ console.log('')
185
+
186
+ if (!existsSync(indexHtml)) {
187
+ error('Build did not produce dist/index.html')
188
+ process.exit(1)
189
+ }
190
+ }
191
+
192
+ // 4. Derive siteId
193
+ const siteId = await deriveSiteId(siteDir)
194
+
195
+ // 5. Collect files from dist/
196
+ const files = await collectFiles(distDir)
197
+ const filesCount = Object.keys(files).length
198
+
199
+ // 6. Dry-run check
200
+ if (isDryRun) {
201
+ console.log('')
202
+ info(`Would deploy ${colors.bright}${siteId}${colors.reset} (${filesCount} files)`)
203
+ console.log(` ${colors.dim}Source: ${distDir}${colors.reset}`)
204
+ const serverUrl = registryUrl || process.env.UNIWEB_REGISTRY_URL || 'http://localhost:4001'
205
+ console.log(` ${colors.dim}Target: ${serverUrl}/sites/${siteId}/${colors.reset}`)
206
+ return
207
+ }
208
+
209
+ // 7. Deploy
210
+ const serverUrl = registryUrl || process.env.UNIWEB_REGISTRY_URL || 'http://localhost:4001'
211
+ info(`Deploying ${colors.bright}${siteId}${colors.reset} (${filesCount} files)...`)
212
+
213
+ const headers = { 'Content-Type': 'application/json' }
214
+ if (token) {
215
+ headers['Authorization'] = `Bearer ${token}`
216
+ }
217
+
218
+ const payload = {
219
+ siteId,
220
+ files,
221
+ metadata: {
222
+ deployedBy: isLocal ? 'local' : 'cli',
223
+ },
224
+ }
225
+
226
+ let res
227
+ try {
228
+ res = await fetch(`${serverUrl}/deploy`, {
229
+ method: 'POST',
230
+ headers,
231
+ body: JSON.stringify(payload),
232
+ })
233
+ } catch (err) {
234
+ error(`Could not connect to ${serverUrl}`)
235
+ console.log('')
236
+ console.log(` ${colors.dim}Make sure the cloud server is running:${colors.reset}`)
237
+ console.log(` ${colors.cyan}cd packages/cloud && pnpm dev${colors.reset}`)
238
+ process.exit(1)
239
+ }
240
+
241
+ const body = await res.json()
242
+
243
+ if (!res.ok) {
244
+ if (res.status === 401) {
245
+ error('Authentication failed.')
246
+ console.log(` Run ${colors.cyan}${prefix} login${colors.reset} to refresh your credentials.`)
247
+ process.exit(1)
248
+ }
249
+ error(body.error || `Deploy failed (${res.status})`)
250
+ process.exit(1)
251
+ }
252
+
253
+ console.log('')
254
+ success(`Deployed ${colors.bright}${siteId}${colors.reset}`)
255
+
256
+ const siteUrl = body.siteUrl
257
+ ? `${serverUrl}${body.siteUrl}`
258
+ : `${serverUrl}/sites/${siteId}/`
259
+ console.log(` ${colors.cyan}${siteUrl}${colors.reset}`)
260
+
261
+ // Cross-promotion: if workspace has a foundation, tip about publish
262
+ const workspaceRoot = findWorkspaceRoot(siteDir)
263
+ if (workspaceRoot) {
264
+ const foundations = await findFoundations(workspaceRoot)
265
+ if (foundations.length > 0) {
266
+ console.log('')
267
+ console.log(` ${colors.dim}Tip: Run \`${prefix} publish\` to register your foundation and invite clients.${colors.reset}`)
268
+ }
269
+ }
270
+ }
271
+
272
+ export default deploy
@@ -379,7 +379,7 @@ export async function doctor(args = []) {
379
379
  message: `Foundation not built: ${matchingFoundation.name}`
380
380
  })
381
381
  warn(`Foundation not built yet`)
382
- log(` ${colors.dim}Run: npx uniweb build${colors.reset}`)
382
+ log(` ${colors.dim}Run: uniweb build${colors.reset}`)
383
383
  } else {
384
384
  success(`Foundation built: dist/foundation.js exists`)
385
385
  }
@@ -431,7 +431,7 @@ export async function doctor(args = []) {
431
431
  message: `Extension not built: ${ext.name}`
432
432
  })
433
433
  warn(`Extension not built yet`)
434
- log(` ${colors.dim}Run: npx uniweb build${colors.reset}`)
434
+ log(` ${colors.dim}Run: uniweb build${colors.reset}`)
435
435
  } else {
436
436
  success(`Extension built: dist/foundation.js exists`)
437
437
  }
@@ -0,0 +1,254 @@
1
+ /**
2
+ * Handoff Command
3
+ *
4
+ * Creates a site record on Unicloud and transfers ownership to a client.
5
+ * The developer builds content locally and hands off a licensed, registered site.
6
+ *
7
+ * Usage:
8
+ * uniweb handoff <email> # Register site + transfer to client
9
+ * uniweb handoff <email> --site <id> # Specify site ID (default: auto-generated)
10
+ * uniweb handoff <email> --web # Show web-based handoff instructions
11
+ */
12
+
13
+ import { existsSync } from 'node:fs'
14
+ import { readFile } from 'node:fs/promises'
15
+ import { resolve, join } from 'node:path'
16
+ import { execSync } from 'node:child_process'
17
+ import { randomUUID } from 'node:crypto'
18
+
19
+ import { RemoteRegistry } from '../utils/registry.js'
20
+ import { ensureAuth } from '../utils/auth.js'
21
+ import { findWorkspaceRoot, findFoundations, classifyPackage, promptSelect } from '../utils/workspace.js'
22
+ import { isNonInteractive, getCliPrefix } from '../utils/interactive.js'
23
+
24
+ const colors = {
25
+ reset: '\x1b[0m',
26
+ bright: '\x1b[1m',
27
+ dim: '\x1b[2m',
28
+ cyan: '\x1b[36m',
29
+ green: '\x1b[32m',
30
+ yellow: '\x1b[33m',
31
+ red: '\x1b[31m',
32
+ }
33
+
34
+ function success(message) {
35
+ console.log(`${colors.green}✓${colors.reset} ${message}`)
36
+ }
37
+
38
+ function error(message) {
39
+ console.error(`${colors.red}✗${colors.reset} ${message}`)
40
+ }
41
+
42
+ function info(message) {
43
+ console.log(`${colors.cyan}→${colors.reset} ${message}`)
44
+ }
45
+
46
+ /**
47
+ * Parse a flag value from args.
48
+ * @param {string[]} args
49
+ * @param {string} flag - e.g. '--site'
50
+ * @returns {string|null}
51
+ */
52
+ function parseFlag(args, flag) {
53
+ const idx = args.indexOf(flag)
54
+ if (idx === -1 || !args[idx + 1]) return null
55
+ return args[idx + 1]
56
+ }
57
+
58
+ /**
59
+ * Resolve the foundation directory (same logic as invite.js).
60
+ */
61
+ async function resolveFoundationDir(args) {
62
+ const cwd = process.cwd()
63
+ const prefix = getCliPrefix()
64
+
65
+ const type = await classifyPackage(cwd)
66
+ if (type === 'foundation') return cwd
67
+
68
+ const workspaceRoot = findWorkspaceRoot(cwd)
69
+ if (workspaceRoot) {
70
+ const foundations = await findFoundations(workspaceRoot)
71
+
72
+ if (foundations.length === 1) {
73
+ return resolve(workspaceRoot, foundations[0])
74
+ }
75
+
76
+ if (foundations.length > 1) {
77
+ if (isNonInteractive(args)) {
78
+ error('Multiple foundations found. Specify which one.')
79
+ console.log('')
80
+ for (const f of foundations) {
81
+ console.log(` ${colors.cyan}cd ${f} && ${prefix} handoff ...${colors.reset}`)
82
+ }
83
+ process.exit(1)
84
+ }
85
+
86
+ const choice = await promptSelect('Which foundation?', foundations)
87
+ if (!choice) {
88
+ console.log('\nCancelled.')
89
+ process.exit(0)
90
+ }
91
+ return resolve(workspaceRoot, choice)
92
+ }
93
+ }
94
+
95
+ error('No foundation found in this workspace.')
96
+ console.log('')
97
+ console.log(` ${colors.dim}\`handoff\` creates a site record for your foundation and transfers${colors.reset}`)
98
+ console.log(` ${colors.dim}ownership to a client — they get a licensed, ready-to-use project.${colors.reset}`)
99
+ console.log('')
100
+ console.log(` ${colors.dim}Run this command from a foundation directory or workspace root.${colors.reset}`)
101
+ process.exit(1)
102
+ }
103
+
104
+ /**
105
+ * Read foundation name and version from dist/meta/schema.json, auto-building if needed.
106
+ */
107
+ async function readSchema(foundationDir) {
108
+ const distDir = join(foundationDir, 'dist')
109
+ const foundationJs = join(distDir, 'foundation.js')
110
+ const schemaJson = join(distDir, 'meta', 'schema.json')
111
+
112
+ if (!existsSync(foundationJs) || !existsSync(schemaJson)) {
113
+ console.log(`${colors.yellow}⚠${colors.reset} No build found. Building foundation...`)
114
+ console.log('')
115
+ execSync('npx uniweb build --target foundation', {
116
+ cwd: foundationDir,
117
+ stdio: 'inherit',
118
+ })
119
+ console.log('')
120
+
121
+ if (!existsSync(foundationJs) || !existsSync(schemaJson)) {
122
+ error('Build did not produce dist/foundation.js and dist/meta/schema.json')
123
+ process.exit(1)
124
+ }
125
+ }
126
+
127
+ try {
128
+ const schema = JSON.parse(await readFile(schemaJson, 'utf8'))
129
+ const name = schema._self?.name
130
+ const version = schema._self?.version
131
+
132
+ if (!name || !version) {
133
+ error('dist/meta/schema.json missing _self.name or _self.version')
134
+ process.exit(1)
135
+ }
136
+
137
+ return { name, version }
138
+ } catch (err) {
139
+ error(`Failed to read dist/meta/schema.json: ${err.message}`)
140
+ process.exit(1)
141
+ }
142
+ }
143
+
144
+ /**
145
+ * Create a RemoteRegistry instance with auth.
146
+ */
147
+ async function createRegistry(args) {
148
+ const token = await ensureAuth({ command: 'Handing off' })
149
+
150
+ const registryUrl = parseFlag(args, '--registry')
151
+ const url = registryUrl || process.env.UNIWEB_REGISTRY_URL || 'http://localhost:4001'
152
+
153
+ return new RemoteRegistry(url, token)
154
+ }
155
+
156
+ /**
157
+ * Handle --web flag: show web-based handoff guidance.
158
+ */
159
+ function showWebHandoff(email, name) {
160
+ console.log('')
161
+ info(`Web-based handoff`)
162
+ console.log('')
163
+ console.log(` 1. Create a site on ${colors.cyan}uniweb.app${colors.reset} using ${colors.bright}${name}${colors.reset}`)
164
+ console.log(` 2. Add pages and content`)
165
+ console.log(` 3. Transfer ownership to ${colors.bright}${email}${colors.reset}:`)
166
+ console.log(` ${colors.dim}Settings → Transfer site${colors.reset}`)
167
+ console.log('')
168
+ console.log(` ${colors.dim}The license stays with the site — the client can edit${colors.reset}`)
169
+ console.log(` ${colors.dim}and publish immediately.${colors.reset}`)
170
+ }
171
+
172
+ /**
173
+ * Main handoff command handler.
174
+ */
175
+ export async function handoff(args = []) {
176
+ const prefix = getCliPrefix()
177
+
178
+ // Extract email (first positional arg with @)
179
+ const email = args.find(a => !a.startsWith('--') && a.includes('@'))
180
+ if (!email) {
181
+ error('Email is required.')
182
+ console.log('')
183
+ console.log(` ${colors.dim}Usage: ${prefix} handoff <email> [--site <id>] [--web]${colors.reset}`)
184
+ console.log('')
185
+ console.log(` ${colors.dim}Creates a site record for your foundation and transfers ownership${colors.reset}`)
186
+ console.log(` ${colors.dim}to the client. They get a licensed project, ready to use.${colors.reset}`)
187
+ process.exit(1)
188
+ }
189
+
190
+ const foundationDir = await resolveFoundationDir(args)
191
+ const { name, version } = await readSchema(foundationDir)
192
+
193
+ // --web: guidance-only, no API call
194
+ if (args.includes('--web')) {
195
+ showWebHandoff(email, name)
196
+ return
197
+ }
198
+
199
+ const registry = await createRegistry(args)
200
+
201
+ // Derive site ID
202
+ const siteIdFlag = parseFlag(args, '--site')
203
+ const siteId = siteIdFlag || `${name}-${randomUUID().slice(0, 6)}`
204
+
205
+ info(`Creating site ${colors.bright}${siteId}${colors.reset} with ${name} v${version}...`)
206
+
207
+ // 1. Create site record
208
+ let siteResult
209
+ try {
210
+ siteResult = await registry.createSite(siteId, { foundation: { name } })
211
+ } catch (err) {
212
+ if (err.statusCode === 409) {
213
+ error(`Site "${siteId}" already exists.`)
214
+ console.log(` ${colors.dim}Use --site <id> to specify a different site identifier.${colors.reset}`)
215
+ process.exit(1)
216
+ }
217
+ if (err.statusCode === 404) {
218
+ error(err.message)
219
+ console.log(` ${colors.dim}Make sure your foundation is published: ${prefix} publish${colors.reset}`)
220
+ process.exit(1)
221
+ }
222
+ error(err.message)
223
+ process.exit(1)
224
+ }
225
+
226
+ // 2. Transfer ownership to client
227
+ info(`Transferring to ${colors.bright}${email}${colors.reset}...`)
228
+
229
+ try {
230
+ await registry.transferSiteOwnership(siteId, email)
231
+ } catch (err) {
232
+ error(`Site created but transfer failed: ${err.message}`)
233
+ console.log(` ${colors.dim}Site "${siteId}" is registered. Transfer manually:${colors.reset}`)
234
+ console.log(` ${colors.dim} PATCH /api/sites/${siteId}/owner { "newOwner": "${email}" }${colors.reset}`)
235
+ process.exit(1)
236
+ }
237
+
238
+ // 3. Show result
239
+ console.log('')
240
+ success(`Site created and transferred`)
241
+ console.log('')
242
+ console.log(` ${colors.dim}Site:${colors.reset} ${colors.bright}${siteId}${colors.reset}`)
243
+ console.log(` ${colors.dim}Foundation:${colors.reset} ${name} v${version.split('.')[0]}`)
244
+ console.log(` ${colors.dim}Owner:${colors.reset} ${email}`)
245
+ console.log(` ${colors.dim}License:${colors.reset} ${siteResult.license ? `${colors.green}✓${colors.reset} granted` : `${colors.yellow}⚠${colors.reset} not granted`}`)
246
+ console.log('')
247
+ console.log(` ${colors.bright}Next steps:${colors.reset}`)
248
+ console.log(` 1. Add ${colors.cyan}id: ${siteId}${colors.reset} to your site.yml`)
249
+ console.log(` 2. Share the site files with ${colors.bright}${email}${colors.reset}`)
250
+ console.log(` ${colors.dim}(git repo, zip, shared drive — any method works)${colors.reset}`)
251
+ console.log(` 3. Client opens the project in Uniweb Studio`)
252
+ }
253
+
254
+ export default handoff