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.
@@ -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, classifyPackage, promptSelect } from '../utils/workspace.js'
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 = await classifyPackage(cwd)
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 (has src/foundation.js)
161
- if (!existsSync(join(foundationDir, 'src', 'foundation.js'))) {
162
- error('Not a foundation directory (no src/foundation.js)')
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 namespace (priority: --namespace flag > package.json uniweb.namespace > scoped name)
205
- const pkg = JSON.parse(await readFile(join(foundationDir, 'package.json'), 'utf8'))
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 scopedMatch = rawName.match(/^@([a-z0-9_-]+)\//)
208
- const namespace = namespaceFlag || uniwebNamespace || scopedMatch?.[1]
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(` ${colors.dim}Use one of:${colors.reset}`)
214
- console.log(` ${colors.cyan}uniweb publish --namespace <org-handle>${colors.reset}`)
215
- console.log(` ${colors.dim}Add ${colors.reset}"uniweb": { "namespace": "<org-handle>" }${colors.dim} to package.json${colors.reset}`)
216
- console.log(` ${colors.dim}Or use a scoped name: ${colors.reset}"name": "@org/foundation"${colors.dim} in package.json${colors.reset}`)
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
- // Construct scoped name: @namespace/foundationName
221
- const foundationName = scopedMatch ? rawName.slice(scopedMatch[0].length) : rawName
222
- const name = `@${namespace}/${foundationName}`
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 namespace check (Worker enforces — this is for early UX feedback)
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 (auth?.token) {
228
- try {
229
- const payload = JSON.parse(atob(auth.token.split('.')[1]))
230
- if (payload.namespaces && !payload.namespaces.includes(namespace)) {
231
- error(`You don't have publish access to namespace "${colors.bright}@${namespace}${colors.reset}"`)
232
- if (payload.namespaces.length > 0) {
233
- console.log(` ${colors.dim}Your namespaces: ${payload.namespaces.map(n => '@' + n).join(', ')}${colors.reset}`)
234
- } else {
235
- console.log(` ${colors.dim}You don't belong to any organizations. Ask an admin to add you.${colors.reset}`)
236
- }
237
- process.exit(1)
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
- } catch {
240
- // JWT decode failed — let the Worker validate
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
@@ -1,9 +1,9 @@
1
1
  {
2
2
  "schemaVersion": 1,
3
- "generatedAt": "2026-04-27T20:54:30.546Z",
3
+ "generatedAt": "2026-04-29T13:02:15.924Z",
4
4
  "packages": {
5
5
  "@uniweb/build": {
6
- "version": "0.11.9",
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.2.9",
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: ['foundation', 'site'],
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, 'foundation'), {
215
- name: 'foundation',
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: 'foundation',
226
- foundationPath: 'file:../foundation',
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: 'foundation' },
269
- { type: 'site', name: 'site', foundation: '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 || '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:../foundation'
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 !== 'foundation' ? foundationName : undefined,
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 named "foundation" 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 && f.name === 'foundation') {
373
- placed.push({ ...f, relativePath: 'foundation' })
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
- * Read stored credentials.
33
- * @returns {Promise<{ token: string, email: string, expiresAt?: string } | null>}
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
- return JSON.parse(await readFile(authPath, 'utf8'))
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
- * @param {{ token: string, email: string, expiresAt?: string }} auth
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(auth, null, 2))
129
+ await writeFile(join(dir, 'auth.json'), JSON.stringify(record, null, 2))
54
130
  }
55
131
 
56
132
  /**
@@ -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 directory names that would cause confusion: src, dist, build
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
- 'src', 'dist', 'build',
29
+ 'dist', 'build',
23
30
  ])
24
31
 
25
32
  /**