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,326 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Invite Command
|
|
3
|
+
*
|
|
4
|
+
* Creates, lists, revokes, and resends foundation invites.
|
|
5
|
+
*
|
|
6
|
+
* Usage:
|
|
7
|
+
* uniweb invite <email> # Create invite (1 use, 30 days)
|
|
8
|
+
* uniweb invite <email> --uses 5 # Multi-use invite
|
|
9
|
+
* uniweb invite <email> --expires 60 # 60-day expiry
|
|
10
|
+
* uniweb invite --list # List invites for your foundation
|
|
11
|
+
* uniweb invite --revoke <inviteId> # Revoke an invite
|
|
12
|
+
* uniweb invite --resend <inviteId> # Resend an invite
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { existsSync } from 'node:fs'
|
|
16
|
+
import { readFile } from 'node:fs/promises'
|
|
17
|
+
import { resolve, join } from 'node:path'
|
|
18
|
+
import { execSync } from 'node:child_process'
|
|
19
|
+
|
|
20
|
+
import { RemoteRegistry } from '../utils/registry.js'
|
|
21
|
+
import { ensureAuth } from '../utils/auth.js'
|
|
22
|
+
import { findWorkspaceRoot, findFoundations, classifyPackage, promptSelect } from '../utils/workspace.js'
|
|
23
|
+
import { isNonInteractive, getCliPrefix } from '../utils/interactive.js'
|
|
24
|
+
|
|
25
|
+
const colors = {
|
|
26
|
+
reset: '\x1b[0m',
|
|
27
|
+
bright: '\x1b[1m',
|
|
28
|
+
dim: '\x1b[2m',
|
|
29
|
+
cyan: '\x1b[36m',
|
|
30
|
+
green: '\x1b[32m',
|
|
31
|
+
yellow: '\x1b[33m',
|
|
32
|
+
red: '\x1b[31m',
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function success(message) {
|
|
36
|
+
console.log(`${colors.green}✓${colors.reset} ${message}`)
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function error(message) {
|
|
40
|
+
console.error(`${colors.red}✗${colors.reset} ${message}`)
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function info(message) {
|
|
44
|
+
console.log(`${colors.cyan}→${colors.reset} ${message}`)
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Parse a flag value from args.
|
|
49
|
+
* @param {string[]} args
|
|
50
|
+
* @param {string} flag - e.g. '--uses'
|
|
51
|
+
* @returns {string|null}
|
|
52
|
+
*/
|
|
53
|
+
function parseFlag(args, flag) {
|
|
54
|
+
const idx = args.indexOf(flag)
|
|
55
|
+
if (idx === -1 || !args[idx + 1]) return null
|
|
56
|
+
return args[idx + 1]
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Resolve the foundation directory (same logic as publish.js).
|
|
61
|
+
*/
|
|
62
|
+
async function resolveFoundationDir(args) {
|
|
63
|
+
const cwd = process.cwd()
|
|
64
|
+
const prefix = getCliPrefix()
|
|
65
|
+
|
|
66
|
+
const type = await classifyPackage(cwd)
|
|
67
|
+
if (type === 'foundation') return cwd
|
|
68
|
+
|
|
69
|
+
const workspaceRoot = findWorkspaceRoot(cwd)
|
|
70
|
+
if (workspaceRoot) {
|
|
71
|
+
const foundations = await findFoundations(workspaceRoot)
|
|
72
|
+
|
|
73
|
+
if (foundations.length === 1) {
|
|
74
|
+
return resolve(workspaceRoot, foundations[0])
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
if (foundations.length > 1) {
|
|
78
|
+
if (isNonInteractive(args)) {
|
|
79
|
+
error('Multiple foundations found. Specify which one.')
|
|
80
|
+
console.log('')
|
|
81
|
+
for (const f of foundations) {
|
|
82
|
+
console.log(` ${colors.cyan}cd ${f} && ${prefix} invite ...${colors.reset}`)
|
|
83
|
+
}
|
|
84
|
+
process.exit(1)
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const choice = await promptSelect('Which foundation?', foundations)
|
|
88
|
+
if (!choice) {
|
|
89
|
+
console.log('\nCancelled.')
|
|
90
|
+
process.exit(0)
|
|
91
|
+
}
|
|
92
|
+
return resolve(workspaceRoot, choice)
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
error('No foundation found in this workspace.')
|
|
97
|
+
console.log('')
|
|
98
|
+
console.log(` ${colors.dim}\`invite\` lets you authorize a client to create sites with your${colors.reset}`)
|
|
99
|
+
console.log(` ${colors.dim}foundation — they get their own site, set up automatically.${colors.reset}`)
|
|
100
|
+
console.log('')
|
|
101
|
+
console.log(` ${colors.dim}Run this command from a foundation directory or workspace root.${colors.reset}`)
|
|
102
|
+
process.exit(1)
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Read foundation name and version from dist/meta/schema.json, auto-building if needed.
|
|
107
|
+
*/
|
|
108
|
+
async function readSchema(foundationDir) {
|
|
109
|
+
const distDir = join(foundationDir, 'dist')
|
|
110
|
+
const foundationJs = join(distDir, 'foundation.js')
|
|
111
|
+
const schemaJson = join(distDir, 'meta', 'schema.json')
|
|
112
|
+
|
|
113
|
+
if (!existsSync(foundationJs) || !existsSync(schemaJson)) {
|
|
114
|
+
console.log(`${colors.yellow}⚠${colors.reset} No build found. Building foundation...`)
|
|
115
|
+
console.log('')
|
|
116
|
+
execSync('npx uniweb build --target foundation', {
|
|
117
|
+
cwd: foundationDir,
|
|
118
|
+
stdio: 'inherit',
|
|
119
|
+
})
|
|
120
|
+
console.log('')
|
|
121
|
+
|
|
122
|
+
if (!existsSync(foundationJs) || !existsSync(schemaJson)) {
|
|
123
|
+
error('Build did not produce dist/foundation.js and dist/meta/schema.json')
|
|
124
|
+
process.exit(1)
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
try {
|
|
129
|
+
const schema = JSON.parse(await readFile(schemaJson, 'utf8'))
|
|
130
|
+
const name = schema._self?.name
|
|
131
|
+
const version = schema._self?.version
|
|
132
|
+
|
|
133
|
+
if (!name || !version) {
|
|
134
|
+
error('dist/meta/schema.json missing _self.name or _self.version')
|
|
135
|
+
process.exit(1)
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
return { name, version, role: schema._self?.role }
|
|
139
|
+
} catch (err) {
|
|
140
|
+
error(`Failed to read dist/meta/schema.json: ${err.message}`)
|
|
141
|
+
process.exit(1)
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* Create a RemoteRegistry instance with auth.
|
|
147
|
+
*/
|
|
148
|
+
async function createRegistry(args) {
|
|
149
|
+
const token = await ensureAuth({ command: 'Creating invite' })
|
|
150
|
+
|
|
151
|
+
const registryUrl = parseFlag(args, '--registry')
|
|
152
|
+
const url = registryUrl || process.env.UNIWEB_REGISTRY_URL || 'http://localhost:4001'
|
|
153
|
+
|
|
154
|
+
return new RemoteRegistry(url, token)
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* Handle --list flag.
|
|
159
|
+
*/
|
|
160
|
+
async function handleList(args) {
|
|
161
|
+
const foundationDir = await resolveFoundationDir(args)
|
|
162
|
+
const { name } = await readSchema(foundationDir)
|
|
163
|
+
const registry = await createRegistry(args)
|
|
164
|
+
|
|
165
|
+
info(`Listing invites for ${colors.bright}${name}${colors.reset}...`)
|
|
166
|
+
|
|
167
|
+
try {
|
|
168
|
+
const invites = await registry.listInvites(name)
|
|
169
|
+
|
|
170
|
+
if (invites.length === 0) {
|
|
171
|
+
console.log(`\n No invites found for ${name}.`)
|
|
172
|
+
return
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
console.log('')
|
|
176
|
+
for (const inv of invites) {
|
|
177
|
+
const statusColor = inv.status === 'active' ? colors.green
|
|
178
|
+
: inv.status === 'revoked' ? colors.red
|
|
179
|
+
: colors.yellow
|
|
180
|
+
console.log(` ${statusColor}${inv.status}${colors.reset} ${inv.email} v${inv.majorVersion} ${inv.usedCount}/${inv.maxUses} uses ${colors.dim}${inv.inviteId}${colors.reset}`)
|
|
181
|
+
}
|
|
182
|
+
console.log('')
|
|
183
|
+
} catch (err) {
|
|
184
|
+
error(err.message)
|
|
185
|
+
process.exit(1)
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* Handle --revoke flag.
|
|
191
|
+
*/
|
|
192
|
+
async function handleRevoke(args, inviteId) {
|
|
193
|
+
const foundationDir = await resolveFoundationDir(args)
|
|
194
|
+
const { name } = await readSchema(foundationDir)
|
|
195
|
+
const registry = await createRegistry(args)
|
|
196
|
+
|
|
197
|
+
info(`Revoking invite ${colors.dim}${inviteId}${colors.reset}...`)
|
|
198
|
+
|
|
199
|
+
try {
|
|
200
|
+
const result = await registry.revokeInvite(name, inviteId)
|
|
201
|
+
console.log('')
|
|
202
|
+
success(`Revoked invite for ${colors.bright}${result.email}${colors.reset}`)
|
|
203
|
+
} catch (err) {
|
|
204
|
+
error(err.message)
|
|
205
|
+
process.exit(1)
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/**
|
|
210
|
+
* Handle --resend flag.
|
|
211
|
+
*/
|
|
212
|
+
async function handleResend(args, inviteId) {
|
|
213
|
+
const foundationDir = await resolveFoundationDir(args)
|
|
214
|
+
const { name } = await readSchema(foundationDir)
|
|
215
|
+
const registry = await createRegistry(args)
|
|
216
|
+
|
|
217
|
+
info(`Resending invite ${colors.dim}${inviteId}${colors.reset}...`)
|
|
218
|
+
|
|
219
|
+
try {
|
|
220
|
+
const result = await registry.resendInvite(name, inviteId)
|
|
221
|
+
console.log('')
|
|
222
|
+
success(`Resent invite to ${colors.bright}${result.email}${colors.reset}`)
|
|
223
|
+
} catch (err) {
|
|
224
|
+
error(err.message)
|
|
225
|
+
process.exit(1)
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/**
|
|
230
|
+
* Handle create invite (default action).
|
|
231
|
+
*/
|
|
232
|
+
async function handleCreate(args, email) {
|
|
233
|
+
const foundationDir = await resolveFoundationDir(args)
|
|
234
|
+
const { name, version, role } = await readSchema(foundationDir)
|
|
235
|
+
const registry = await createRegistry(args)
|
|
236
|
+
|
|
237
|
+
// Parse options
|
|
238
|
+
const versionFlag = parseFlag(args, '--version')
|
|
239
|
+
const majorVersion = versionFlag
|
|
240
|
+
? parseInt(versionFlag, 10)
|
|
241
|
+
: parseInt(version.split('.')[0], 10)
|
|
242
|
+
|
|
243
|
+
const usesFlag = parseFlag(args, '--uses')
|
|
244
|
+
const maxUses = usesFlag ? parseInt(usesFlag, 10) : 1
|
|
245
|
+
|
|
246
|
+
const expiresFlag = parseFlag(args, '--expires')
|
|
247
|
+
const expiresInDays = expiresFlag ? parseInt(expiresFlag, 10) : 30
|
|
248
|
+
|
|
249
|
+
if (isNaN(majorVersion)) {
|
|
250
|
+
error('Could not determine major version. Use --version to specify.')
|
|
251
|
+
process.exit(1)
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
info(`Creating invite for ${colors.bright}${email}${colors.reset} → ${name} v${majorVersion}...`)
|
|
255
|
+
|
|
256
|
+
try {
|
|
257
|
+
const invite = await registry.createInvite(name, {
|
|
258
|
+
email,
|
|
259
|
+
majorVersion,
|
|
260
|
+
maxUses,
|
|
261
|
+
expiresInDays,
|
|
262
|
+
})
|
|
263
|
+
|
|
264
|
+
const isExtension = role === 'extension'
|
|
265
|
+
|
|
266
|
+
console.log('')
|
|
267
|
+
success(`Invite created`)
|
|
268
|
+
console.log('')
|
|
269
|
+
console.log(` ${colors.dim}ID:${colors.reset} ${invite.inviteId}`)
|
|
270
|
+
console.log(` ${colors.dim}To:${colors.reset} ${invite.email}`)
|
|
271
|
+
console.log(` ${colors.dim}For:${colors.reset} ${name} v${invite.majorVersion}${isExtension ? ' (extension)' : ''}`)
|
|
272
|
+
console.log(` ${colors.dim}Uses:${colors.reset} ${invite.maxUses}`)
|
|
273
|
+
console.log(` ${colors.dim}Expires:${colors.reset} ${new Date(invite.expiresAt).toLocaleDateString()}`)
|
|
274
|
+
console.log(` ${colors.dim}Link:${colors.reset} ${colors.cyan}${registry.apiUrl}/invite/${invite.inviteId}${colors.reset}`)
|
|
275
|
+
console.log('')
|
|
276
|
+
if (isExtension) {
|
|
277
|
+
console.log(` ${colors.dim}When ${invite.email} adds ${name} to their site${colors.reset}`)
|
|
278
|
+
console.log(` ${colors.dim}on uniweb.app or Studio, it will be authorized automatically.${colors.reset}`)
|
|
279
|
+
} else {
|
|
280
|
+
console.log(` ${colors.dim}When ${invite.email} creates a site with ${name}${colors.reset}`)
|
|
281
|
+
console.log(` ${colors.dim}on uniweb.app or Studio, it will be authorized automatically.${colors.reset}`)
|
|
282
|
+
}
|
|
283
|
+
} catch (err) {
|
|
284
|
+
error(err.message)
|
|
285
|
+
process.exit(1)
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
/**
|
|
290
|
+
* Main invite command handler.
|
|
291
|
+
*/
|
|
292
|
+
export async function invite(args = []) {
|
|
293
|
+
// Dispatch based on flags
|
|
294
|
+
if (args.includes('--list')) {
|
|
295
|
+
await handleList(args)
|
|
296
|
+
return
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
const revokeId = parseFlag(args, '--revoke')
|
|
300
|
+
if (revokeId) {
|
|
301
|
+
await handleRevoke(args, revokeId)
|
|
302
|
+
return
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
const resendId = parseFlag(args, '--resend')
|
|
306
|
+
if (resendId) {
|
|
307
|
+
await handleResend(args, resendId)
|
|
308
|
+
return
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
// Default: create invite — first positional arg is the email
|
|
312
|
+
const email = args.find(a => !a.startsWith('--') && a.includes('@'))
|
|
313
|
+
if (!email) {
|
|
314
|
+
error('Email is required.')
|
|
315
|
+
console.log('')
|
|
316
|
+
console.log(` ${colors.dim}Usage: ${getCliPrefix()} invite <email> [--uses N] [--expires N]${colors.reset}`)
|
|
317
|
+
console.log(` ${colors.dim} ${getCliPrefix()} invite --list${colors.reset}`)
|
|
318
|
+
console.log(` ${colors.dim} ${getCliPrefix()} invite --revoke <id>${colors.reset}`)
|
|
319
|
+
console.log(` ${colors.dim} ${getCliPrefix()} invite --resend <id>${colors.reset}`)
|
|
320
|
+
process.exit(1)
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
await handleCreate(args, email)
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
export default invite
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Login Command
|
|
3
|
+
*
|
|
4
|
+
* Authenticates with the Uniweb platform. Stores credentials at ~/.uniweb/auth.json.
|
|
5
|
+
*
|
|
6
|
+
* Phase 1: Token-paste flow only.
|
|
7
|
+
* Phase 2: Browser-based OAuth with token-paste fallback.
|
|
8
|
+
*
|
|
9
|
+
* Usage:
|
|
10
|
+
* uniweb login
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import prompts from 'prompts'
|
|
14
|
+
import { writeAuth, readAuth, isExpired } from '../utils/auth.js'
|
|
15
|
+
|
|
16
|
+
// Colors for terminal output
|
|
17
|
+
const colors = {
|
|
18
|
+
reset: '\x1b[0m',
|
|
19
|
+
bright: '\x1b[1m',
|
|
20
|
+
dim: '\x1b[2m',
|
|
21
|
+
cyan: '\x1b[36m',
|
|
22
|
+
green: '\x1b[32m',
|
|
23
|
+
yellow: '\x1b[33m',
|
|
24
|
+
red: '\x1b[31m',
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function success(message) {
|
|
28
|
+
console.log(`${colors.green}✓${colors.reset} ${message}`)
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function error(message) {
|
|
32
|
+
console.error(`${colors.red}✗${colors.reset} ${message}`)
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Main login command handler
|
|
37
|
+
*/
|
|
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('')
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
console.log('Log in to your Uniweb account at uniweb.app.')
|
|
48
|
+
console.log('')
|
|
49
|
+
|
|
50
|
+
// Phase 1: token-paste flow
|
|
51
|
+
const response = await prompts([
|
|
52
|
+
{
|
|
53
|
+
type: 'text',
|
|
54
|
+
name: 'email',
|
|
55
|
+
message: 'Email:',
|
|
56
|
+
validate: (v) => (v && v.includes('@') ? true : 'Enter a valid email'),
|
|
57
|
+
},
|
|
58
|
+
{
|
|
59
|
+
type: 'password',
|
|
60
|
+
name: 'token',
|
|
61
|
+
message: 'Token (from uniweb.app/cli-login):',
|
|
62
|
+
validate: (v) => (v ? true : 'Token is required'),
|
|
63
|
+
},
|
|
64
|
+
], {
|
|
65
|
+
onCancel: () => {
|
|
66
|
+
console.log('\nLogin cancelled.')
|
|
67
|
+
process.exit(0)
|
|
68
|
+
},
|
|
69
|
+
})
|
|
70
|
+
|
|
71
|
+
if (!response.email || !response.token) {
|
|
72
|
+
error('Login cancelled.')
|
|
73
|
+
process.exit(1)
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// Store credentials
|
|
77
|
+
await writeAuth({
|
|
78
|
+
token: response.token,
|
|
79
|
+
email: response.email,
|
|
80
|
+
expiresAt: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000).toISOString(),
|
|
81
|
+
})
|
|
82
|
+
|
|
83
|
+
console.log('')
|
|
84
|
+
success(`Logged in as ${colors.bright}${response.email}${colors.reset}`)
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export default login
|
|
@@ -0,0 +1,300 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Publish Command
|
|
3
|
+
*
|
|
4
|
+
* Publishes a foundation to the Uniweb Registry.
|
|
5
|
+
*
|
|
6
|
+
* Usage:
|
|
7
|
+
* uniweb publish # Publish to remote registry
|
|
8
|
+
* uniweb publish --local # Publish to local registry (.unicloud/)
|
|
9
|
+
* uniweb publish --registry <url> # Publish to a specific registry URL
|
|
10
|
+
* uniweb publish --edit-access open # Anyone can edit in Studio (default: restricted)
|
|
11
|
+
* uniweb publish --dry-run # Show what would be published
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { existsSync } from 'node:fs'
|
|
15
|
+
import { readFile } from 'node:fs/promises'
|
|
16
|
+
import { resolve, join } from 'node:path'
|
|
17
|
+
import { execSync } from 'node:child_process'
|
|
18
|
+
|
|
19
|
+
import { createLocalRegistry, RemoteRegistry } from '../utils/registry.js'
|
|
20
|
+
import { ensureAuth, readAuth } from '../utils/auth.js'
|
|
21
|
+
import { findWorkspaceRoot, findFoundations, findSites, classifyPackage, promptSelect } from '../utils/workspace.js'
|
|
22
|
+
import { isNonInteractive, getCliPrefix } from '../utils/interactive.js'
|
|
23
|
+
|
|
24
|
+
// Colors for terminal output
|
|
25
|
+
const colors = {
|
|
26
|
+
reset: '\x1b[0m',
|
|
27
|
+
bright: '\x1b[1m',
|
|
28
|
+
dim: '\x1b[2m',
|
|
29
|
+
cyan: '\x1b[36m',
|
|
30
|
+
green: '\x1b[32m',
|
|
31
|
+
yellow: '\x1b[33m',
|
|
32
|
+
red: '\x1b[31m',
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function success(message) {
|
|
36
|
+
console.log(`${colors.green}✓${colors.reset} ${message}`)
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function error(message) {
|
|
40
|
+
console.error(`${colors.red}✗${colors.reset} ${message}`)
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function info(message) {
|
|
44
|
+
console.log(`${colors.cyan}→${colors.reset} ${message}`)
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Resolve the foundation directory to publish.
|
|
49
|
+
*
|
|
50
|
+
* Priority:
|
|
51
|
+
* 1. In a foundation directory → use it
|
|
52
|
+
* 2. At workspace root, one foundation → use it
|
|
53
|
+
* 3. At workspace root, multiple → prompt (or error if non-interactive)
|
|
54
|
+
* 4. No foundation → educational error
|
|
55
|
+
*
|
|
56
|
+
* @param {string[]} args
|
|
57
|
+
* @returns {Promise<string>} Absolute path to the foundation directory
|
|
58
|
+
*/
|
|
59
|
+
async function resolveFoundationDir(args) {
|
|
60
|
+
const cwd = process.cwd()
|
|
61
|
+
const prefix = getCliPrefix()
|
|
62
|
+
|
|
63
|
+
// Check if current directory is a foundation
|
|
64
|
+
const type = await classifyPackage(cwd)
|
|
65
|
+
if (type === 'foundation') {
|
|
66
|
+
return cwd
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// Check workspace
|
|
70
|
+
const workspaceRoot = findWorkspaceRoot(cwd)
|
|
71
|
+
if (workspaceRoot) {
|
|
72
|
+
const foundations = await findFoundations(workspaceRoot)
|
|
73
|
+
|
|
74
|
+
if (foundations.length === 1) {
|
|
75
|
+
return resolve(workspaceRoot, foundations[0])
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
if (foundations.length > 1) {
|
|
79
|
+
if (isNonInteractive(args)) {
|
|
80
|
+
error('Multiple foundations found. Specify which one to publish.')
|
|
81
|
+
console.log('')
|
|
82
|
+
for (const f of foundations) {
|
|
83
|
+
console.log(` ${colors.cyan}cd ${f} && ${prefix} publish${colors.reset}`)
|
|
84
|
+
}
|
|
85
|
+
process.exit(1)
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const choice = await promptSelect('Which foundation?', foundations)
|
|
89
|
+
if (!choice) {
|
|
90
|
+
console.log('\nPublish cancelled.')
|
|
91
|
+
process.exit(0)
|
|
92
|
+
}
|
|
93
|
+
return resolve(workspaceRoot, choice)
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// No foundation found — educational error
|
|
98
|
+
error('No foundation found in this workspace.')
|
|
99
|
+
console.log('')
|
|
100
|
+
console.log(` ${colors.dim}\`publish\` registers your foundation so clients you invite can${colors.reset}`)
|
|
101
|
+
console.log(` ${colors.dim}create and manage their own sites with it.${colors.reset}`)
|
|
102
|
+
console.log('')
|
|
103
|
+
console.log(` ${colors.dim}To publish, run this command from a foundation directory, or from a${colors.reset}`)
|
|
104
|
+
console.log(` ${colors.dim}workspace root that contains a foundation.${colors.reset}`)
|
|
105
|
+
process.exit(1)
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Parse --registry <url> from args.
|
|
110
|
+
* @param {string[]} args
|
|
111
|
+
* @returns {string|null}
|
|
112
|
+
*/
|
|
113
|
+
function parseRegistryUrl(args) {
|
|
114
|
+
const idx = args.indexOf('--registry')
|
|
115
|
+
if (idx === -1 || !args[idx + 1]) return null
|
|
116
|
+
return args[idx + 1]
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Parse --edit-access <policy> from args.
|
|
121
|
+
* @param {string[]} args
|
|
122
|
+
* @returns {'open'|'restricted'|null}
|
|
123
|
+
*/
|
|
124
|
+
function parseEditAccess(args) {
|
|
125
|
+
const idx = args.indexOf('--edit-access')
|
|
126
|
+
if (idx === -1 || !args[idx + 1]) return null
|
|
127
|
+
const value = args[idx + 1]
|
|
128
|
+
if (value !== 'open' && value !== 'restricted') {
|
|
129
|
+
error(`Invalid --edit-access value: "${value}". Must be "open" or "restricted".`)
|
|
130
|
+
process.exit(1)
|
|
131
|
+
}
|
|
132
|
+
return value
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Main publish command handler
|
|
137
|
+
*/
|
|
138
|
+
export async function publish(args = []) {
|
|
139
|
+
const isLocal = args.includes('--local')
|
|
140
|
+
const isDryRun = args.includes('--dry-run')
|
|
141
|
+
const registryUrl = parseRegistryUrl(args)
|
|
142
|
+
const editAccess = parseEditAccess(args)
|
|
143
|
+
|
|
144
|
+
// 1. Resolve foundation directory
|
|
145
|
+
const foundationDir = await resolveFoundationDir(args)
|
|
146
|
+
|
|
147
|
+
// Verify it's actually a foundation (has src/foundation.js)
|
|
148
|
+
if (!existsSync(join(foundationDir, 'src', 'foundation.js'))) {
|
|
149
|
+
error('Not a foundation directory (no src/foundation.js)')
|
|
150
|
+
process.exit(1)
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// 2. Auto-build if dist/ is missing
|
|
154
|
+
const distDir = join(foundationDir, 'dist')
|
|
155
|
+
const foundationJs = join(distDir, 'foundation.js')
|
|
156
|
+
const schemaJson = join(distDir, 'meta', 'schema.json')
|
|
157
|
+
|
|
158
|
+
if (!existsSync(foundationJs) || !existsSync(schemaJson)) {
|
|
159
|
+
console.log(`${colors.yellow}⚠${colors.reset} No build found. Building foundation...`)
|
|
160
|
+
console.log('')
|
|
161
|
+
execSync('npx uniweb build --target foundation', {
|
|
162
|
+
cwd: foundationDir,
|
|
163
|
+
stdio: 'inherit',
|
|
164
|
+
})
|
|
165
|
+
console.log('')
|
|
166
|
+
|
|
167
|
+
if (!existsSync(foundationJs) || !existsSync(schemaJson)) {
|
|
168
|
+
error('Build did not produce dist/foundation.js and dist/meta/schema.json')
|
|
169
|
+
process.exit(1)
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// 3. Read name and version from meta/schema.json
|
|
174
|
+
let schema
|
|
175
|
+
try {
|
|
176
|
+
schema = JSON.parse(await readFile(schemaJson, 'utf8'))
|
|
177
|
+
} catch (err) {
|
|
178
|
+
error(`Failed to read dist/meta/schema.json: ${err.message}`)
|
|
179
|
+
process.exit(1)
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
const name = schema._self?.name
|
|
183
|
+
const version = schema._self?.version
|
|
184
|
+
|
|
185
|
+
if (!name || !version) {
|
|
186
|
+
error('dist/meta/schema.json missing _self.name or _self.version')
|
|
187
|
+
console.log(`${colors.dim} Ensure your package.json has "name" and "version" fields.${colors.reset}`)
|
|
188
|
+
process.exit(1)
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
// 4. Create registry (local or remote)
|
|
192
|
+
const isRemote = !isLocal
|
|
193
|
+
let registry
|
|
194
|
+
|
|
195
|
+
if (isLocal) {
|
|
196
|
+
registry = createLocalRegistry(foundationDir)
|
|
197
|
+
} else {
|
|
198
|
+
// Remote publish — ensure authenticated (inline login if needed)
|
|
199
|
+
const token = await ensureAuth({ command: 'Publishing' })
|
|
200
|
+
|
|
201
|
+
const url = registryUrl || process.env.UNIWEB_REGISTRY_URL || 'http://localhost:4001'
|
|
202
|
+
registry = new RemoteRegistry(url, token)
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
const registryLabel = isLocal ? 'local registry' : `registry`
|
|
206
|
+
|
|
207
|
+
// 5. Check for duplicates
|
|
208
|
+
if (await registry.exists(name, version)) {
|
|
209
|
+
console.log('')
|
|
210
|
+
error(`${colors.bright}${name}@${version}${colors.reset} is already published.`)
|
|
211
|
+
console.log('')
|
|
212
|
+
console.log(` Bump the version in foundation.js to publish an update:`)
|
|
213
|
+
console.log(` ${colors.dim}export const version = '${bumpPatch(version)}'${colors.reset}`)
|
|
214
|
+
process.exit(1)
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
// 6. Dry-run check
|
|
218
|
+
if (isDryRun) {
|
|
219
|
+
console.log('')
|
|
220
|
+
info(`Would publish ${colors.bright}${name}@${version}${colors.reset} to ${registryLabel}`)
|
|
221
|
+
console.log(` ${colors.dim}Source: ${distDir}${colors.reset}`)
|
|
222
|
+
if (isLocal) {
|
|
223
|
+
console.log(` ${colors.dim}Target: ${registry.getPackagePath(name, version)}${colors.reset}`)
|
|
224
|
+
} else {
|
|
225
|
+
console.log(` ${colors.dim}Target: ${registry.apiUrl}${colors.reset}`)
|
|
226
|
+
}
|
|
227
|
+
return
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
// 7. Publish
|
|
231
|
+
info(`Publishing ${colors.bright}${name}@${version}${colors.reset} to ${registryLabel}...`)
|
|
232
|
+
|
|
233
|
+
// Resolve the publisher's identity
|
|
234
|
+
const auth = isLocal ? null : await readAuth()
|
|
235
|
+
const publishMetadata = {
|
|
236
|
+
publishedBy: auth?.email || (isLocal ? 'local' : 'cli'),
|
|
237
|
+
}
|
|
238
|
+
if (editAccess) {
|
|
239
|
+
publishMetadata.editAccess = editAccess
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
try {
|
|
243
|
+
await registry.publish(name, version, distDir, publishMetadata)
|
|
244
|
+
} catch (err) {
|
|
245
|
+
if (err.code === 'CONFLICT') {
|
|
246
|
+
error(`${colors.bright}${name}@${version}${colors.reset} already exists on the registry.`)
|
|
247
|
+
console.log(` Bump the version in foundation.js to publish an update.`)
|
|
248
|
+
process.exit(1)
|
|
249
|
+
}
|
|
250
|
+
if (err.code === 'UNAUTHORIZED') {
|
|
251
|
+
error('Authentication failed.')
|
|
252
|
+
console.log(` Run ${colors.cyan}${getCliPrefix()} login${colors.reset} to refresh your credentials.`)
|
|
253
|
+
process.exit(1)
|
|
254
|
+
}
|
|
255
|
+
throw err
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
const prefix = getCliPrefix()
|
|
259
|
+
const isExtension = schema._self?.role === 'extension'
|
|
260
|
+
console.log('')
|
|
261
|
+
success(`Published ${colors.bright}${name}@${version}${colors.reset}${isExtension ? ' (extension)' : ''}`)
|
|
262
|
+
if (editAccess) {
|
|
263
|
+
console.log(` ${colors.dim}Edit access: ${editAccess}${colors.reset}`)
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
// Cross-promotion: working with clients (remote only), deploy (if workspace has a site)
|
|
267
|
+
if (isRemote) {
|
|
268
|
+
console.log('')
|
|
269
|
+
if (isExtension) {
|
|
270
|
+
console.log(` ${colors.bright}Authorize a client to use this extension:${colors.reset}`)
|
|
271
|
+
console.log(` ${colors.bright}${prefix} invite <email>${colors.reset} Client adds this extension to their site`)
|
|
272
|
+
} else {
|
|
273
|
+
console.log(` ${colors.bright}Working with clients:${colors.reset}`)
|
|
274
|
+
console.log(` ${colors.bright}${prefix} invite <email>${colors.reset} Client creates their own site with your foundation`)
|
|
275
|
+
console.log(` ${colors.bright}${prefix} handoff <email>${colors.reset} Create a web or local site and hand it off to a client`)
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
const workspaceRoot = findWorkspaceRoot(foundationDir)
|
|
279
|
+
if (workspaceRoot) {
|
|
280
|
+
const sites = await findSites(workspaceRoot)
|
|
281
|
+
if (sites.length > 0) {
|
|
282
|
+
console.log('')
|
|
283
|
+
console.log(` ${colors.dim}Tip: Run \`${prefix} deploy\` for a conventional static bundle deployment.${colors.reset}`)
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
/**
|
|
289
|
+
* Bump the patch version of a semver string.
|
|
290
|
+
* @param {string} version - e.g. "1.0.0"
|
|
291
|
+
* @returns {string} - e.g. "1.0.1"
|
|
292
|
+
*/
|
|
293
|
+
function bumpPatch(version) {
|
|
294
|
+
const parts = version.split('.')
|
|
295
|
+
if (parts.length !== 3) return version
|
|
296
|
+
parts[2] = String(Number(parts[2]) + 1)
|
|
297
|
+
return parts.join('.')
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
export default publish
|