uniweb 0.10.13 → 0.12.0
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 +20 -18
- package/package.json +2 -2
- package/partials/agents.md +163 -39
- package/src/commands/add.js +152 -233
- package/src/commands/build.js +14 -42
- package/src/commands/deploy.js +262 -3
- package/src/commands/docs.js +5 -6
- package/src/commands/doctor.js +21 -22
- package/src/commands/publish.js +255 -34
- package/src/framework-index.json +3 -3
- package/src/index.js +27 -14
- package/src/utils/auth.js +82 -6
- package/src/utils/names.js +9 -2
- package/src/utils/registry.js +88 -16
- package/src/utils/scaffold.js +8 -2
- package/src/utils/workspace.js +8 -46
- package/starter/site/pages/home/1-welcome.md.hbs +1 -1
- package/templates/foundation/{src/foundation.js.hbs → main.js.hbs} +1 -1
- package/templates/foundation/package.json.hbs +6 -6
- package/templates/foundation/{src/styles.css → styles.css} +1 -1
- package/templates/site/index.html.hbs +1 -1
- package/templates/site/theme.yml +1 -1
- package/templates/workspace/README.md.hbs +9 -7
- /package/starter/foundation/{src/foundation.js → main.js} +0 -0
- /package/starter/foundation/{src/sections → sections}/Section/index.jsx +0 -0
- /package/starter/foundation/{src/sections → sections}/Section/meta.js +0 -0
- /package/templates/foundation/{src/components → components}/.gitkeep +0 -0
- /package/templates/foundation/{src/sections → sections}/.gitkeep +0 -0
- /package/templates/site/{main.js → entry.js} +0 -0
package/src/commands/publish.js
CHANGED
|
@@ -12,14 +12,15 @@
|
|
|
12
12
|
*/
|
|
13
13
|
|
|
14
14
|
import { existsSync } from 'node:fs'
|
|
15
|
-
import { readFile } from 'node:fs/promises'
|
|
15
|
+
import { readFile, writeFile } from 'node:fs/promises'
|
|
16
16
|
import { resolve, join } from 'node:path'
|
|
17
17
|
import { execSync } from 'node:child_process'
|
|
18
18
|
|
|
19
|
+
import { resolveFoundationSrcPath, classifyPackage } from '@uniweb/build'
|
|
19
20
|
import { createLocalRegistry, RemoteRegistry } from '../utils/registry.js'
|
|
20
21
|
import { ensureAuth, readAuth } from '../utils/auth.js'
|
|
21
22
|
import { getRegistryUrl } from '../utils/config.js'
|
|
22
|
-
import { findWorkspaceRoot, findFoundations, findSites,
|
|
23
|
+
import { findWorkspaceRoot, findFoundations, findSites, promptSelect } from '../utils/workspace.js'
|
|
23
24
|
import { isNonInteractive, getCliPrefix } from '../utils/interactive.js'
|
|
24
25
|
|
|
25
26
|
// Colors for terminal output
|
|
@@ -62,7 +63,7 @@ async function resolveFoundationDir(args) {
|
|
|
62
63
|
const prefix = getCliPrefix()
|
|
63
64
|
|
|
64
65
|
// Check if current directory is a foundation
|
|
65
|
-
const type =
|
|
66
|
+
const type = classifyPackage(cwd)
|
|
66
67
|
if (type === 'foundation') {
|
|
67
68
|
return cwd
|
|
68
69
|
}
|
|
@@ -128,6 +129,21 @@ function parseNamespace(args) {
|
|
|
128
129
|
return args[idx + 1]
|
|
129
130
|
}
|
|
130
131
|
|
|
132
|
+
/**
|
|
133
|
+
* Parse --name <id> from args.
|
|
134
|
+
* The publish-time "id" — the bare-name segment in the registry name.
|
|
135
|
+
* Distinct from `package.json::name` (a workspace concern). Persisted
|
|
136
|
+
* to `package.json::uniweb.id` after the first successful publish so
|
|
137
|
+
* it doesn't need to be supplied again.
|
|
138
|
+
* @param {string[]} args
|
|
139
|
+
* @returns {string|null}
|
|
140
|
+
*/
|
|
141
|
+
function parseName(args) {
|
|
142
|
+
const idx = args.indexOf('--name')
|
|
143
|
+
if (idx === -1 || !args[idx + 1]) return null
|
|
144
|
+
return args[idx + 1]
|
|
145
|
+
}
|
|
146
|
+
|
|
131
147
|
/**
|
|
132
148
|
* Parse --edit-access <policy> from args.
|
|
133
149
|
* @param {string[]} args
|
|
@@ -150,16 +166,24 @@ function parseEditAccess(args) {
|
|
|
150
166
|
export async function publish(args = []) {
|
|
151
167
|
const isLocal = args.includes('--local')
|
|
152
168
|
const isDryRun = args.includes('--dry-run')
|
|
169
|
+
// --propagate opts the new version into the registry's version-update
|
|
170
|
+
// walk: trusting sites whose policy permits the jump pick it up
|
|
171
|
+
// automatically via gated rollout. Without --propagate (default
|
|
172
|
+
// 'silent'), the artifact is stored but no site moves until republish
|
|
173
|
+
// or manual refresh.
|
|
174
|
+
const isPropagate = args.includes('--propagate')
|
|
153
175
|
const registryUrl = parseRegistryUrl(args)
|
|
154
176
|
const editAccess = parseEditAccess(args)
|
|
155
177
|
const namespaceFlag = parseNamespace(args)
|
|
178
|
+
const nameFlag = parseName(args)
|
|
156
179
|
|
|
157
180
|
// 1. Resolve foundation directory
|
|
158
181
|
const foundationDir = await resolveFoundationDir(args)
|
|
159
182
|
|
|
160
|
-
// Verify it's actually a foundation (
|
|
161
|
-
|
|
162
|
-
|
|
183
|
+
// Verify it's actually a foundation (canonical classifier checks
|
|
184
|
+
// package.json::main, then main.js, then legacy foundation.js).
|
|
185
|
+
if (classifyPackage(foundationDir) !== 'foundation') {
|
|
186
|
+
error(`Not a foundation directory: ${foundationDir}`)
|
|
163
187
|
process.exit(1)
|
|
164
188
|
}
|
|
165
189
|
|
|
@@ -201,45 +225,206 @@ export async function publish(args = []) {
|
|
|
201
225
|
process.exit(1)
|
|
202
226
|
}
|
|
203
227
|
|
|
204
|
-
// 3b. Resolve
|
|
205
|
-
|
|
228
|
+
// 3b. Resolve scope and foundation id.
|
|
229
|
+
//
|
|
230
|
+
// The publish-time identity is two pieces: a SCOPE (org `@`, personal `~`,
|
|
231
|
+
// or empty → server-resolved personal) and an ID (the bare name segment).
|
|
232
|
+
// They live in different places and get different defaults.
|
|
233
|
+
//
|
|
234
|
+
// Scope priority:
|
|
235
|
+
// 1. --namespace <handle> CLI flag → forces `@<handle>` org scope
|
|
236
|
+
// 2. Sigil in `package.json::name`:
|
|
237
|
+
// - `@org/x` → `@org`
|
|
238
|
+
// - `~user/x` → `~user` (personal alias)
|
|
239
|
+
// 3. `package.json::uniweb.namespace` → legacy explicit org field
|
|
240
|
+
// 4. (none) → empty scope; server attaches
|
|
241
|
+
// the publisher's personal
|
|
242
|
+
// scope at upload time
|
|
243
|
+
//
|
|
244
|
+
// ID priority (the bare name segment):
|
|
245
|
+
// 1. --name <id> CLI flag → override
|
|
246
|
+
// 2. Sigil-stripped `package.json::name` → @org/<id> or ~user/<id>
|
|
247
|
+
// 3. `package.json::uniweb.id` → persisted publish-id
|
|
248
|
+
// 4. Interactive prompt → and write back to
|
|
249
|
+
// `package.json::uniweb.id`
|
|
250
|
+
// so future publishes don't
|
|
251
|
+
// re-prompt.
|
|
252
|
+
// 5. Non-interactive without a usable id → fail with guidance.
|
|
253
|
+
//
|
|
254
|
+
// Note: a bare `package.json::name` (e.g. the scaffold default `src`)
|
|
255
|
+
// is intentionally NOT used as a fallback id. The workspace name is for
|
|
256
|
+
// pnpm linking and the file: dependency in site/package.json — using it
|
|
257
|
+
// as the publish id would couple the registry identity to the workspace,
|
|
258
|
+
// exactly what `uniweb.id` exists to prevent. Users who want their
|
|
259
|
+
// workspace name to be the publish id pass `--name <pkg-name>` once;
|
|
260
|
+
// it persists.
|
|
261
|
+
//
|
|
262
|
+
// Why two storage locations for an ID? `package.json::name` is a
|
|
263
|
+
// workspace concern — pnpm uses it to link packages, sites reference
|
|
264
|
+
// it via `file:` deps and `site.yml::foundation`. Renaming it cascades
|
|
265
|
+
// through several files. `uniweb.id` is publish-only — changing it
|
|
266
|
+
// affects only the registry identity, never the workspace. Most users
|
|
267
|
+
// benefit from leaving `package.json::name` as the scaffold default
|
|
268
|
+
// (`src`) and putting the published-as id in `uniweb.id`.
|
|
269
|
+
const pkgPath = join(foundationDir, 'package.json')
|
|
270
|
+
const pkg = JSON.parse(await readFile(pkgPath, 'utf8'))
|
|
206
271
|
const uniwebNamespace = pkg.uniweb?.namespace
|
|
207
|
-
const
|
|
208
|
-
const
|
|
272
|
+
const uniwebId = pkg.uniweb?.id
|
|
273
|
+
const orgScopeMatch = (pkg.name || '').match(/^@([a-z0-9_-]+)\/([a-z0-9_-]+)$/)
|
|
274
|
+
const personalScopeMatch = (pkg.name || '').match(/^~([a-z0-9_-]+)\/([a-z0-9_-]+)$/)
|
|
275
|
+
|
|
276
|
+
// Resolve the SCOPE.
|
|
277
|
+
let scopeSigil = null
|
|
278
|
+
let scopeName = null
|
|
279
|
+
if (namespaceFlag) {
|
|
280
|
+
scopeSigil = '@'
|
|
281
|
+
scopeName = namespaceFlag
|
|
282
|
+
} else if (orgScopeMatch) {
|
|
283
|
+
scopeSigil = '@'
|
|
284
|
+
scopeName = orgScopeMatch[1]
|
|
285
|
+
} else if (personalScopeMatch) {
|
|
286
|
+
scopeSigil = '~'
|
|
287
|
+
scopeName = personalScopeMatch[1]
|
|
288
|
+
} else if (uniwebNamespace) {
|
|
289
|
+
scopeSigil = '@'
|
|
290
|
+
scopeName = uniwebNamespace
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
// Resolve the ID.
|
|
294
|
+
const ID_RE = /^[a-z0-9_-]+$/
|
|
295
|
+
let foundationName = null
|
|
296
|
+
let writeBackId = false
|
|
297
|
+
if (nameFlag) {
|
|
298
|
+
foundationName = nameFlag
|
|
299
|
+
// Persist the flag's value when it differs from what's already in
|
|
300
|
+
// `uniweb.id`. This makes rename a one-shot:
|
|
301
|
+
// $ uniweb publish --name new-name
|
|
302
|
+
// From here on, `uniweb publish` (no flag) keeps using `new-name`.
|
|
303
|
+
// No-op when --name matches the existing id.
|
|
304
|
+
if (nameFlag !== uniwebId) writeBackId = true
|
|
305
|
+
} else if (orgScopeMatch) {
|
|
306
|
+
foundationName = orgScopeMatch[2]
|
|
307
|
+
} else if (personalScopeMatch) {
|
|
308
|
+
foundationName = personalScopeMatch[2]
|
|
309
|
+
} else if (uniwebId) {
|
|
310
|
+
foundationName = uniwebId
|
|
311
|
+
}
|
|
312
|
+
if (!foundationName) {
|
|
313
|
+
// No id resolvable from any field. Prompt — or fail in non-interactive.
|
|
314
|
+
if (isNonInteractive(process.argv)) {
|
|
315
|
+
error('Foundation id is required for publishing.')
|
|
316
|
+
console.log('')
|
|
317
|
+
console.log(` ${colors.dim}Use one of:${colors.reset}`)
|
|
318
|
+
console.log(` ${colors.cyan}uniweb publish --name <id>${colors.reset}`)
|
|
319
|
+
console.log(` ${colors.dim}Add ${colors.reset}"uniweb": { "id": "<your-id>" }${colors.dim} to package.json${colors.reset}`)
|
|
320
|
+
console.log(` ${colors.dim}Or use a scoped name in package.json: ${colors.reset}"name": "@org/<id>"${colors.reset}`)
|
|
321
|
+
process.exit(1)
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
const prompts = (await import('prompts')).default
|
|
325
|
+
// Default suggestion: derive from the workspace folder or pkg.name.
|
|
326
|
+
// Strip the suffix `-src` so a foundation in `marketing-src/` defaults
|
|
327
|
+
// to `marketing` as its publish id.
|
|
328
|
+
const workspaceRoot = findWorkspaceRoot(foundationDir) || foundationDir
|
|
329
|
+
const folderName = workspaceRoot === foundationDir
|
|
330
|
+
? null
|
|
331
|
+
: foundationDir.replace(workspaceRoot + '/', '').split('/')[0]
|
|
332
|
+
const suggestion =
|
|
333
|
+
(typeof pkg.name === 'string' && ID_RE.test(pkg.name) ? pkg.name : null) ||
|
|
334
|
+
(folderName ? folderName.replace(/-src$/, '') : null) ||
|
|
335
|
+
'foundation'
|
|
209
336
|
|
|
210
|
-
if (!namespace) {
|
|
211
|
-
error('Namespace is required for publishing.')
|
|
212
337
|
console.log('')
|
|
213
|
-
console.log(
|
|
214
|
-
console.log(
|
|
215
|
-
|
|
216
|
-
|
|
338
|
+
console.log(`${colors.dim}This is the first publish of this foundation. Pick a name${colors.reset}`)
|
|
339
|
+
console.log(`${colors.dim}for the registry — what your foundation will be known as.${colors.reset}`)
|
|
340
|
+
const response = await prompts({
|
|
341
|
+
type: 'text',
|
|
342
|
+
name: 'id',
|
|
343
|
+
message: 'Foundation name',
|
|
344
|
+
initial: suggestion,
|
|
345
|
+
validate: (v) => {
|
|
346
|
+
if (!v) return 'Required'
|
|
347
|
+
if (!ID_RE.test(v)) return 'Lowercase letters, digits, hyphens, underscores only'
|
|
348
|
+
return true
|
|
349
|
+
},
|
|
350
|
+
}, {
|
|
351
|
+
onCancel: () => {
|
|
352
|
+
console.log('')
|
|
353
|
+
console.log('Publish cancelled.')
|
|
354
|
+
process.exit(0)
|
|
355
|
+
},
|
|
356
|
+
})
|
|
357
|
+
if (!response.id) process.exit(0)
|
|
358
|
+
foundationName = response.id
|
|
359
|
+
writeBackId = true
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
// Validate the resolved id (may have come from any source).
|
|
363
|
+
if (!ID_RE.test(foundationName)) {
|
|
364
|
+
error(`Invalid foundation name: "${foundationName}"`)
|
|
365
|
+
console.log(` ${colors.dim}Names must be lowercase letters, digits, hyphens, or underscores.${colors.reset}`)
|
|
217
366
|
process.exit(1)
|
|
218
367
|
}
|
|
219
368
|
|
|
220
|
-
//
|
|
221
|
-
|
|
222
|
-
|
|
369
|
+
// Persist the id so future publishes don't re-prompt.
|
|
370
|
+
if (writeBackId) {
|
|
371
|
+
pkg.uniweb = pkg.uniweb || {}
|
|
372
|
+
pkg.uniweb.id = foundationName
|
|
373
|
+
await writeFile(pkgPath, JSON.stringify(pkg, null, 2) + '\n')
|
|
374
|
+
info(`Wrote ${colors.cyan}uniweb.id: "${foundationName}"${colors.reset} to ${colors.dim}package.json${colors.reset}`)
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
// The registry name. Three cases:
|
|
378
|
+
//
|
|
379
|
+
// 1. Explicit scope (`@org/x` or `~user/x`) → `<sigil><name>/<base>`.
|
|
380
|
+
// 2. Empty-scope, --local → synthesize a
|
|
381
|
+
// personal-scope form `~<loginName-or-sub-or-'me'>/<base>` so the
|
|
382
|
+
// local index mirrors what production will write. This is the
|
|
383
|
+
// local mock's stand-in for the server-side memberId resolution.
|
|
384
|
+
// 3. Empty-scope, remote → send the bare
|
|
385
|
+
// name. The Worker attaches the personal scope server-side
|
|
386
|
+
// (anchoring to the `sub` claim), and the publish response
|
|
387
|
+
// carries the canonical URL back to the CLI for the receipt.
|
|
388
|
+
let name
|
|
389
|
+
if (scopeSigil) {
|
|
390
|
+
name = `${scopeSigil}${scopeName}/${foundationName}`
|
|
391
|
+
} else if (isLocal) {
|
|
392
|
+
const localAuth = await readAuth()
|
|
393
|
+
const personalSeed = localAuth?.loginName || localAuth?.sub || 'me'
|
|
394
|
+
name = `~${personalSeed}/${foundationName}`
|
|
395
|
+
} else {
|
|
396
|
+
name = foundationName
|
|
397
|
+
}
|
|
223
398
|
|
|
224
|
-
// 3c. Advisory
|
|
399
|
+
// 3c. Advisory scope authorization (Worker enforces — this is for early UX feedback)
|
|
225
400
|
if (!isLocal) {
|
|
226
401
|
const auth = await readAuth()
|
|
227
|
-
if (
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
}
|
|
235
|
-
|
|
236
|
-
}
|
|
237
|
-
|
|
402
|
+
if (scopeSigil === '@') {
|
|
403
|
+
// Org scope: must be in the user's namespaces[] claim.
|
|
404
|
+
const namespaces = auth?.namespaces
|
|
405
|
+
if (Array.isArray(namespaces) && !namespaces.includes(scopeName)) {
|
|
406
|
+
error(`You don't have publish access to namespace "${colors.bright}@${scopeName}${colors.reset}"`)
|
|
407
|
+
if (namespaces.length > 0) {
|
|
408
|
+
console.log(` ${colors.dim}Your organizations: ${namespaces.map(n => '@' + n).join(', ')}${colors.reset}`)
|
|
409
|
+
console.log(` ${colors.dim}Or remove the scope from package.json::name to publish under your personal scope.${colors.reset}`)
|
|
410
|
+
} else {
|
|
411
|
+
console.log(` ${colors.dim}You don't belong to any organizations.${colors.reset}`)
|
|
412
|
+
console.log(` ${colors.dim}Use a bare name in package.json (e.g. "src") to publish under your personal scope.${colors.reset}`)
|
|
238
413
|
}
|
|
239
|
-
|
|
240
|
-
|
|
414
|
+
process.exit(1)
|
|
415
|
+
}
|
|
416
|
+
} else if (scopeSigil === '~') {
|
|
417
|
+
// Personal alias scope: must match the user's loginName claim
|
|
418
|
+
// (until handle-aliasing ships, this is loginName-only).
|
|
419
|
+
if (auth?.loginName && auth.loginName !== scopeName) {
|
|
420
|
+
error(`Personal scope "${colors.bright}~${scopeName}${colors.reset}" doesn't match your account`)
|
|
421
|
+
console.log(` ${colors.dim}Your personal scope: ~${auth.loginName}${colors.reset}`)
|
|
422
|
+
console.log(` ${colors.dim}Or remove the scope from package.json::name to publish under your personal scope.${colors.reset}`)
|
|
423
|
+
process.exit(1)
|
|
241
424
|
}
|
|
242
425
|
}
|
|
426
|
+
// Empty-scope: no client-side check. The server resolves to the
|
|
427
|
+
// memberId from the JWT (sub claim) and writes ownership accordingly.
|
|
243
428
|
}
|
|
244
429
|
|
|
245
430
|
// 4. Create registry (local or remote)
|
|
@@ -288,13 +473,15 @@ export async function publish(args = []) {
|
|
|
288
473
|
const auth = isLocal ? null : await readAuth()
|
|
289
474
|
const publishMetadata = {
|
|
290
475
|
publishedBy: auth?.email || (isLocal ? 'local' : 'cli'),
|
|
476
|
+
classification: isPropagate ? 'propagate' : 'silent',
|
|
291
477
|
}
|
|
292
478
|
if (editAccess) {
|
|
293
479
|
publishMetadata.editAccess = editAccess
|
|
294
480
|
}
|
|
295
481
|
|
|
482
|
+
let publishResult
|
|
296
483
|
try {
|
|
297
|
-
await registry.publish(name, version, distDir, publishMetadata)
|
|
484
|
+
publishResult = await registry.publish(name, version, distDir, publishMetadata)
|
|
298
485
|
} catch (err) {
|
|
299
486
|
if (err.code === 'CONFLICT') {
|
|
300
487
|
error(`${colors.bright}${name}@${version}${colors.reset} already exists on the registry.`)
|
|
@@ -309,6 +496,24 @@ export async function publish(args = []) {
|
|
|
309
496
|
throw err
|
|
310
497
|
}
|
|
311
498
|
|
|
499
|
+
// Local event memory — read by `uniweb deploy` to decide whether a
|
|
500
|
+
// workspace-local foundation needs republishing. Lives under dist/ which
|
|
501
|
+
// is gitignored; not part of the upload.
|
|
502
|
+
const receiptUrl = publishResult?.url
|
|
503
|
+
|| (isLocal
|
|
504
|
+
? `file://${registry.getPackagePath(name, version)}/`
|
|
505
|
+
: `${registry.apiUrl}/${name}/${version}/`)
|
|
506
|
+
const { gitSha, gitDirty } = readGitState(foundationDir)
|
|
507
|
+
const receipt = {
|
|
508
|
+
schemaVersion: 1,
|
|
509
|
+
publishedFromGitSha: gitSha,
|
|
510
|
+
publishedFromGitDirty: gitDirty,
|
|
511
|
+
url: receiptUrl,
|
|
512
|
+
publishedAt: new Date().toISOString(),
|
|
513
|
+
classification: isPropagate ? 'propagate' : 'silent',
|
|
514
|
+
}
|
|
515
|
+
await writeFile(join(distDir, 'publish.json'), JSON.stringify(receipt, null, 2) + '\n')
|
|
516
|
+
|
|
312
517
|
const prefix = getCliPrefix()
|
|
313
518
|
const isExtension = schema._self?.role === 'extension'
|
|
314
519
|
console.log('')
|
|
@@ -351,4 +556,20 @@ function bumpPatch(version) {
|
|
|
351
556
|
return parts.join('.')
|
|
352
557
|
}
|
|
353
558
|
|
|
559
|
+
function readGitState(dir) {
|
|
560
|
+
try {
|
|
561
|
+
const sha = execSync('git rev-parse HEAD', {
|
|
562
|
+
cwd: dir,
|
|
563
|
+
stdio: ['ignore', 'pipe', 'ignore'],
|
|
564
|
+
}).toString().trim()
|
|
565
|
+
const status = execSync('git status --porcelain', {
|
|
566
|
+
cwd: dir,
|
|
567
|
+
stdio: ['ignore', 'pipe', 'ignore'],
|
|
568
|
+
}).toString()
|
|
569
|
+
return { gitSha: sha || null, gitDirty: status.length > 0 }
|
|
570
|
+
} catch {
|
|
571
|
+
return { gitSha: null, gitDirty: false }
|
|
572
|
+
}
|
|
573
|
+
}
|
|
574
|
+
|
|
354
575
|
export default publish
|
package/src/framework-index.json
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
{
|
|
2
2
|
"schemaVersion": 1,
|
|
3
|
-
"generatedAt": "2026-04-
|
|
3
|
+
"generatedAt": "2026-04-29T13:02:15.924Z",
|
|
4
4
|
"packages": {
|
|
5
5
|
"@uniweb/build": {
|
|
6
|
-
"version": "0.
|
|
6
|
+
"version": "0.13.0",
|
|
7
7
|
"path": "framework/build",
|
|
8
8
|
"deps": [
|
|
9
9
|
"@uniweb/content-reader",
|
|
@@ -92,7 +92,7 @@
|
|
|
92
92
|
"deps": []
|
|
93
93
|
},
|
|
94
94
|
"@uniweb/unipress": {
|
|
95
|
-
"version": "0.
|
|
95
|
+
"version": "0.3.0",
|
|
96
96
|
"path": "framework/unipress",
|
|
97
97
|
"deps": [
|
|
98
98
|
"@uniweb/build",
|
package/src/index.js
CHANGED
|
@@ -201,7 +201,7 @@ async function createFromPackageTemplates(projectDir, projectName, options = {})
|
|
|
201
201
|
// 1. Scaffold workspace
|
|
202
202
|
await scaffoldWorkspace(projectDir, {
|
|
203
203
|
projectName,
|
|
204
|
-
workspaceGlobs: ['
|
|
204
|
+
workspaceGlobs: ['site', 'src'],
|
|
205
205
|
scripts: {
|
|
206
206
|
dev: filterCmd(pm, 'site', 'dev'),
|
|
207
207
|
build: 'uniweb build',
|
|
@@ -209,10 +209,13 @@ async function createFromPackageTemplates(projectDir, projectName, options = {})
|
|
|
209
209
|
},
|
|
210
210
|
}, { onProgress, onWarning })
|
|
211
211
|
|
|
212
|
-
// 2. Scaffold foundation
|
|
212
|
+
// 2. Scaffold foundation (folder: src/, package name: src)
|
|
213
|
+
// The folder name 'src' carries the meaning — a foundation is the site's
|
|
214
|
+
// source code. The package name 'src' keeps it unique within the
|
|
215
|
+
// workspace, since 'site' is taken by the site package.
|
|
213
216
|
onProgress?.('Creating foundation...')
|
|
214
|
-
await scaffoldFoundation(join(projectDir, '
|
|
215
|
-
name: '
|
|
217
|
+
await scaffoldFoundation(join(projectDir, 'src'), {
|
|
218
|
+
name: 'src',
|
|
216
219
|
projectName,
|
|
217
220
|
isExtension: false,
|
|
218
221
|
}, { onProgress, onWarning })
|
|
@@ -222,8 +225,9 @@ async function createFromPackageTemplates(projectDir, projectName, options = {})
|
|
|
222
225
|
await scaffoldSite(join(projectDir, 'site'), {
|
|
223
226
|
name: 'site',
|
|
224
227
|
projectName,
|
|
225
|
-
foundationName: '
|
|
226
|
-
foundationPath: 'file:../
|
|
228
|
+
foundationName: 'src',
|
|
229
|
+
foundationPath: 'file:../src',
|
|
230
|
+
foundationRef: 'src',
|
|
227
231
|
}, { onProgress, onWarning })
|
|
228
232
|
|
|
229
233
|
// 4. Apply starter content (unless creating a "none" project)
|
|
@@ -264,9 +268,12 @@ async function createFromContentTemplate(projectDir, projectName, metadata, temp
|
|
|
264
268
|
const { onProgress, onWarning, pm = 'pnpm' } = options
|
|
265
269
|
|
|
266
270
|
// Determine packages to create
|
|
271
|
+
// Default single-foundation single-site project uses package names
|
|
272
|
+
// 'src' (folder: src/) and 'site' (folder: site/). The folder
|
|
273
|
+
// convention is set in computePlacement() below.
|
|
267
274
|
const packages = metadata.packages || [
|
|
268
|
-
{ type: 'foundation', name: '
|
|
269
|
-
{ type: 'site', name: 'site', foundation: '
|
|
275
|
+
{ type: 'foundation', name: 'src' },
|
|
276
|
+
{ type: 'site', name: 'site', foundation: 'src' },
|
|
270
277
|
]
|
|
271
278
|
|
|
272
279
|
// Compute placement for each package
|
|
@@ -314,21 +321,25 @@ async function createFromContentTemplate(projectDir, projectName, metadata, temp
|
|
|
314
321
|
}, { onProgress, onWarning })
|
|
315
322
|
} else if (pkg.type === 'site') {
|
|
316
323
|
// Find the foundation this site wires to
|
|
317
|
-
const foundationName = pkg.foundation || '
|
|
324
|
+
const foundationName = pkg.foundation || 'src'
|
|
318
325
|
const foundationPkg = placed.find(p =>
|
|
319
326
|
(p.type === 'foundation') && (p.name === foundationName)
|
|
320
327
|
)
|
|
321
328
|
const foundationPath = foundationPkg
|
|
322
329
|
? computeFoundationFilePath(pkg.relativePath, foundationPkg.relativePath)
|
|
323
|
-
: 'file:../
|
|
330
|
+
: 'file:../src'
|
|
324
331
|
|
|
325
332
|
onProgress?.(`Creating site: ${pkg.name}...`)
|
|
333
|
+
// Always write `foundation: <name>` to site.yml — the value is
|
|
334
|
+
// never the implicit default in the new layout (the build's
|
|
335
|
+
// `detectFoundationType` defaults to 'foundation' when absent,
|
|
336
|
+
// which doesn't match 'src').
|
|
326
337
|
await scaffoldSite(fullPath, {
|
|
327
338
|
name: pkg.name,
|
|
328
339
|
projectName,
|
|
329
340
|
foundationName,
|
|
330
341
|
foundationPath,
|
|
331
|
-
foundationRef: foundationName
|
|
342
|
+
foundationRef: foundationName,
|
|
332
343
|
}, { onProgress, onWarning })
|
|
333
344
|
}
|
|
334
345
|
|
|
@@ -355,7 +366,9 @@ async function createFromContentTemplate(projectDir, projectName, metadata, temp
|
|
|
355
366
|
* Compute placement (relative paths) for packages
|
|
356
367
|
*
|
|
357
368
|
* Rules:
|
|
358
|
-
* - 1 foundation
|
|
369
|
+
* - 1 foundation → src/ (folder name is 'src' regardless of package name;
|
|
370
|
+
* the package name is typically 'src' or
|
|
371
|
+
* whatever the template declared)
|
|
359
372
|
* - Multiple foundations → foundations/{name}/
|
|
360
373
|
* - Extensions → extensions/{name}/
|
|
361
374
|
* - 1 site named "site" → site/
|
|
@@ -369,8 +382,8 @@ function computePlacement(packages) {
|
|
|
369
382
|
const placed = []
|
|
370
383
|
|
|
371
384
|
for (const f of foundations) {
|
|
372
|
-
if (foundations.length === 1
|
|
373
|
-
placed.push({ ...f, relativePath: '
|
|
385
|
+
if (foundations.length === 1) {
|
|
386
|
+
placed.push({ ...f, relativePath: 'src' })
|
|
374
387
|
} else {
|
|
375
388
|
placed.push({ ...f, relativePath: `foundations/${f.name}` })
|
|
376
389
|
}
|
package/src/utils/auth.js
CHANGED
|
@@ -5,6 +5,22 @@
|
|
|
5
5
|
* User-global (not workspace-local) — you publish as yourself, not as a project.
|
|
6
6
|
*
|
|
7
7
|
* Used by `login`, `publish`, and `deploy` commands.
|
|
8
|
+
*
|
|
9
|
+
* Stored shape (auth.json):
|
|
10
|
+
* {
|
|
11
|
+
* token: string, // bearer JWT, sent in Authorization: Bearer <token>
|
|
12
|
+
* email: string, // signup_email; permanent, deliverable
|
|
13
|
+
* loginName?: string, // PHP session login_name; immutable per session model
|
|
14
|
+
* sub?: string, // memberId from JWT; permanent, numeric
|
|
15
|
+
* namespaces?: string[], // org handles the user can publish under
|
|
16
|
+
* expiresAt?: string // ISO timestamp; JWT exp claim
|
|
17
|
+
* }
|
|
18
|
+
*
|
|
19
|
+
* The extra identity fields (loginName, sub, namespaces) are decoded from
|
|
20
|
+
* the JWT at write time and persisted alongside the token. They're cheap
|
|
21
|
+
* to derive (HS256 payload is base64url-encoded JSON), but persisting them
|
|
22
|
+
* means callers don't need to decode the JWT themselves to ask
|
|
23
|
+
* "who is the user?" — they just `readAuth()`.
|
|
8
24
|
*/
|
|
9
25
|
|
|
10
26
|
import { existsSync } from 'node:fs'
|
|
@@ -29,28 +45,88 @@ export function getAuthPath() {
|
|
|
29
45
|
}
|
|
30
46
|
|
|
31
47
|
/**
|
|
32
|
-
*
|
|
33
|
-
*
|
|
48
|
+
* Decode the payload of a JWT. Returns `null` for malformed tokens.
|
|
49
|
+
* No signature verification — that's the server's job; we just want to
|
|
50
|
+
* read the claims locally.
|
|
51
|
+
*
|
|
52
|
+
* @param {string} token
|
|
53
|
+
* @returns {Object|null}
|
|
54
|
+
*/
|
|
55
|
+
export function decodeJwtPayload(token) {
|
|
56
|
+
if (typeof token !== 'string') return null
|
|
57
|
+
const parts = token.split('.')
|
|
58
|
+
if (parts.length < 2) return null
|
|
59
|
+
try {
|
|
60
|
+
// base64url → base64
|
|
61
|
+
const b64 = parts[1].replace(/-/g, '+').replace(/_/g, '/')
|
|
62
|
+
return JSON.parse(Buffer.from(b64, 'base64').toString('utf8'))
|
|
63
|
+
} catch {
|
|
64
|
+
return null
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Read stored credentials. If the persisted record predates the
|
|
70
|
+
* identity-fields plumbing (no loginName/sub/namespaces) but has a
|
|
71
|
+
* token, derive the missing fields from the JWT in memory so callers
|
|
72
|
+
* see a consistent shape regardless of write generation.
|
|
73
|
+
*
|
|
74
|
+
* @returns {Promise<{ token: string, email: string, loginName?: string, sub?: string, namespaces?: string[], expiresAt?: string } | null>}
|
|
34
75
|
*/
|
|
35
76
|
export async function readAuth() {
|
|
36
77
|
const authPath = getAuthPath()
|
|
37
78
|
if (!existsSync(authPath)) return null
|
|
38
79
|
|
|
80
|
+
let auth
|
|
39
81
|
try {
|
|
40
|
-
|
|
82
|
+
auth = JSON.parse(await readFile(authPath, 'utf8'))
|
|
41
83
|
} catch {
|
|
42
84
|
return null
|
|
43
85
|
}
|
|
86
|
+
|
|
87
|
+
// Backfill identity fields from the JWT for older auth.json files
|
|
88
|
+
// that were written before this plumbing existed. Read-only — the
|
|
89
|
+
// file isn't rewritten until the next login.
|
|
90
|
+
if (auth?.token && (auth.loginName === undefined || auth.sub === undefined || auth.namespaces === undefined)) {
|
|
91
|
+
const payload = decodeJwtPayload(auth.token)
|
|
92
|
+
if (payload) {
|
|
93
|
+
if (auth.loginName === undefined && typeof payload.loginName === 'string') {
|
|
94
|
+
auth.loginName = payload.loginName
|
|
95
|
+
}
|
|
96
|
+
if (auth.sub === undefined && typeof payload.sub === 'string') {
|
|
97
|
+
auth.sub = payload.sub
|
|
98
|
+
}
|
|
99
|
+
if (auth.namespaces === undefined && Array.isArray(payload.namespaces)) {
|
|
100
|
+
auth.namespaces = payload.namespaces
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
return auth
|
|
44
106
|
}
|
|
45
107
|
|
|
46
108
|
/**
|
|
47
|
-
* Write credentials to storage.
|
|
48
|
-
*
|
|
109
|
+
* Write credentials to storage. Decodes the JWT and persists the
|
|
110
|
+
* identity claims (loginName, sub, namespaces) alongside the token,
|
|
111
|
+
* so future `readAuth()` calls don't have to decode it themselves.
|
|
112
|
+
*
|
|
113
|
+
* @param {{ token: string, email: string, expiresAt?: string }} auth - Caller passes the basics; identity fields are derived.
|
|
49
114
|
*/
|
|
50
115
|
export async function writeAuth(auth) {
|
|
116
|
+
const record = { ...auth }
|
|
117
|
+
|
|
118
|
+
if (record.token) {
|
|
119
|
+
const payload = decodeJwtPayload(record.token)
|
|
120
|
+
if (payload) {
|
|
121
|
+
if (typeof payload.loginName === 'string') record.loginName = payload.loginName
|
|
122
|
+
if (typeof payload.sub === 'string') record.sub = payload.sub
|
|
123
|
+
if (Array.isArray(payload.namespaces)) record.namespaces = payload.namespaces
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
51
127
|
const dir = getAuthDir()
|
|
52
128
|
await mkdir(dir, { recursive: true })
|
|
53
|
-
await writeFile(join(dir, 'auth.json'), JSON.stringify(
|
|
129
|
+
await writeFile(join(dir, 'auth.json'), JSON.stringify(record, null, 2))
|
|
54
130
|
}
|
|
55
131
|
|
|
56
132
|
/**
|
package/src/utils/names.js
CHANGED
|
@@ -14,12 +14,19 @@ import { join } from 'node:path'
|
|
|
14
14
|
* Names that must not be used as package names.
|
|
15
15
|
* - JS module keywords: default, undefined, null, true, false
|
|
16
16
|
* - Node/filesystem: node_modules, package
|
|
17
|
-
* - Common
|
|
17
|
+
* - Common build-output directories: dist, build (would shadow `dist/` /
|
|
18
|
+
* `build/` references)
|
|
19
|
+
*
|
|
20
|
+
* Note: `src` is NOT reserved. A foundation in `src/` whose package name is
|
|
21
|
+
* also `src` is the default scaffold pattern — folder name and package name
|
|
22
|
+
* match. Multi-foundation co-located workspaces still use suffixes
|
|
23
|
+
* (`<project>-src`) because pnpm requires workspace-unique package names;
|
|
24
|
+
* that's a real constraint, not aesthetic.
|
|
18
25
|
*/
|
|
19
26
|
const RESERVED_NAMES = new Set([
|
|
20
27
|
'default', 'undefined', 'null', 'true', 'false',
|
|
21
28
|
'node_modules', 'package',
|
|
22
|
-
'
|
|
29
|
+
'dist', 'build',
|
|
23
30
|
])
|
|
24
31
|
|
|
25
32
|
/**
|