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,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
|
package/src/commands/doctor.js
CHANGED
|
@@ -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:
|
|
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:
|
|
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
|