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.
@@ -42,6 +42,8 @@ import { resolve, join, basename, relative, sep } from 'node:path'
42
42
  import { execSync } from 'node:child_process'
43
43
  import yaml from 'js-yaml'
44
44
 
45
+ import { detectFoundationType } from '@uniweb/build'
46
+
45
47
  import { ensureAuth } from '../utils/auth.js'
46
48
  import { getBackendUrl, getRegistryUrl } from '../utils/config.js'
47
49
  import {
@@ -55,6 +57,70 @@ import { isNonInteractive, getCliPrefix } from '../utils/interactive.js'
55
57
  const REVIEW_TIMEOUT_MS = 15 * 60 * 1000 // 15 min — matches PHP session TTL
56
58
  const ASSET_UPLOAD_CONCURRENCY = 6
57
59
  const ASSET_UPLOAD_RETRIES = 2
60
+
61
+ const FOUNDATION_POLICIES = new Set(['exact', 'auto-patch', 'auto-minor'])
62
+
63
+ /**
64
+ * Parse the `foundation:` field from site.yml into a normalized shape.
65
+ *
66
+ * Accepts:
67
+ * - string: '@uniweb/votiverse@0.1.1'
68
+ * - object: { ref: '@uniweb/votiverse@0.1.1', policy?: ..., pinned?: true }
69
+ *
70
+ * Returns one of:
71
+ * - { error: 'description of what's wrong' }
72
+ * - { normalized, policy?, pinned } where `normalized` is whichever
73
+ * shape we received (string or { ref, policy?, pinned? }) — the Worker
74
+ * accepts both. `policy`/`pinned` are also returned individually so
75
+ * the CLI can print friendly diagnostics.
76
+ *
77
+ * Validation rules (mirrors publish.js::parseFoundationConfig):
78
+ * - `policy` must be one of 'exact', 'auto-patch', 'auto-minor'
79
+ * - `pinned: true` + `policy: not-exact` is rejected as conflicting
80
+ */
81
+ function parseSiteFoundation(input) {
82
+ if (typeof input === 'string') {
83
+ return { normalized: input, policy: null, pinned: false }
84
+ }
85
+ if (!input || typeof input !== 'object') {
86
+ return { error: 'foundation must be a string or object' }
87
+ }
88
+
89
+ // Object form must carry `ref`; everything else is metadata.
90
+ if (!input.ref || typeof input.ref !== 'string') {
91
+ return { error: 'foundation.ref is required when using object form' }
92
+ }
93
+ if (!/^@[a-z0-9_-]+\/[a-z0-9_-]+@.+$/.test(input.ref)) {
94
+ return {
95
+ error: `foundation.ref does not match @namespace/name@version: '${input.ref}'`,
96
+ }
97
+ }
98
+
99
+ let policy = null
100
+ if (input.policy != null) {
101
+ if (!FOUNDATION_POLICIES.has(input.policy)) {
102
+ return {
103
+ error: `foundation.policy must be one of 'exact', 'auto-patch', 'auto-minor' (got '${input.policy}')`,
104
+ }
105
+ }
106
+ policy = input.policy
107
+ }
108
+ const pinned = input.pinned === true
109
+
110
+ if (pinned && policy && policy !== 'exact') {
111
+ return {
112
+ error: `foundation: 'pinned: true' conflicts with policy '${policy}'. ` +
113
+ `Use either 'pinned: true' or 'policy: \"exact\"' (they're equivalent), or drop one.`,
114
+ }
115
+ }
116
+
117
+ return {
118
+ normalized: { ref: input.ref, ...(policy ? { policy } : {}), ...(pinned ? { pinned: true } : {}) },
119
+ policy: pinned ? 'exact' : policy,
120
+ pinned,
121
+ }
122
+ }
123
+
58
124
  // Vite content-addresses these formats. Same filename → same content, so we
59
125
  // can skip upload without checking size. Unhashed formats fall through to
60
126
  // size-compare diffing.
@@ -98,6 +164,100 @@ const say = {
98
164
  dim: (m) => console.log(` ${c.dim}${m}${c.reset}`),
99
165
  }
100
166
 
167
+ function readGitState(dir) {
168
+ try {
169
+ const sha = execSync('git rev-parse HEAD', {
170
+ cwd: dir,
171
+ stdio: ['ignore', 'pipe', 'ignore'],
172
+ }).toString().trim()
173
+ const status = execSync('git status --porcelain', {
174
+ cwd: dir,
175
+ stdio: ['ignore', 'pipe', 'ignore'],
176
+ }).toString()
177
+ return { gitSha: sha || null, gitDirty: status.length > 0 }
178
+ } catch {
179
+ return { gitSha: null, gitDirty: false }
180
+ }
181
+ }
182
+
183
+ function composeFoundationUrl(ref, registryBase) {
184
+ if (typeof ref !== 'string') return null
185
+ if (ref.startsWith('https://') || ref.startsWith('http://')) return ref
186
+ const m = ref.match(/^(@[^/]+\/[^@]+|[^@]+)@(.+)$/)
187
+ if (!m || !registryBase) return null
188
+ const [, name, version] = m
189
+ return `${registryBase.replace(/\/$/, '')}/${name}/${version}/`
190
+ }
191
+
192
+ /**
193
+ * Inspect a workspace-local foundation's `dist/publish.json` (Phase 1 receipt)
194
+ * and decide whether it's stale relative to the current source tree.
195
+ *
196
+ * Returns `{ stale, reason, receipt }`. The caller decides whether to
197
+ * auto-publish (Phase 2 default) or fail (`--no-auto-publish`).
198
+ */
199
+ async function inspectLocalFoundationReceipt(localPath, { dirtyAsStale }) {
200
+ const receiptPath = join(localPath, 'dist', 'publish.json')
201
+ let receipt = null
202
+ try {
203
+ receipt = JSON.parse(await readFile(receiptPath, 'utf8'))
204
+ } catch {
205
+ return { stale: true, reason: 'no dist/publish.json (foundation has not been published from this checkout)' }
206
+ }
207
+
208
+ const { gitSha, gitDirty } = readGitState(localPath)
209
+ if (!gitSha) {
210
+ return { stale: true, reason: 'foundation directory is not in a git repo or has no commits', receipt }
211
+ }
212
+ if (receipt.publishedFromGitSha && receipt.publishedFromGitSha !== gitSha) {
213
+ return {
214
+ stale: true,
215
+ reason: `foundation has new commits since last publish (${receipt.publishedFromGitSha.slice(0, 7)} → ${gitSha.slice(0, 7)})`,
216
+ receipt,
217
+ }
218
+ }
219
+ if (gitDirty && dirtyAsStale) {
220
+ return { stale: true, reason: 'foundation working tree is dirty', receipt }
221
+ }
222
+ return { stale: false, receipt }
223
+ }
224
+
225
+ /**
226
+ * Read a workspace-local foundation's identity (scoped name + version) from
227
+ * its `dist/meta/schema.json` + `package.json`, mirroring `publish.js`'s
228
+ * namespace resolution. Returns the registry ref (`@ns/name@ver`), or null
229
+ * if any of the inputs are missing.
230
+ */
231
+ async function deriveLocalFoundationRef(localPath) {
232
+ let pkg
233
+ try {
234
+ pkg = JSON.parse(await readFile(join(localPath, 'package.json'), 'utf8'))
235
+ } catch {
236
+ return null
237
+ }
238
+
239
+ let rawName, version
240
+ try {
241
+ const schema = JSON.parse(await readFile(join(localPath, 'dist', 'meta', 'schema.json'), 'utf8'))
242
+ rawName = schema._self?.name
243
+ version = schema._self?.version
244
+ } catch {
245
+ // Fallback to package.json when the build hasn't run yet.
246
+ }
247
+ rawName = rawName || pkg.name
248
+ version = version || pkg.version
249
+ if (!rawName || !version) return null
250
+
251
+ const uniwebNamespace = pkg.uniweb?.namespace
252
+ const pkgScopeMatch = (pkg.name || '').match(/^@([a-z0-9_-]+)\//)
253
+ const selfScopeMatch = rawName.match(/^@([a-z0-9_-]+)\//)
254
+ const namespace = uniwebNamespace || pkgScopeMatch?.[1] || selfScopeMatch?.[1]
255
+ if (!namespace) return null
256
+
257
+ const bareName = selfScopeMatch ? rawName.slice(selfScopeMatch[0].length) : rawName
258
+ return `@${namespace}/${bareName}@${version}`
259
+ }
260
+
101
261
  // ─── Main ───────────────────────────────────────────────────
102
262
 
103
263
  export async function deploy(args = []) {
@@ -111,6 +271,11 @@ export async function deploy(args = []) {
111
271
  // to DB, so any change the user makes there ends up in site.yml after
112
272
  // the loopback finalize roundtrip.
113
273
  const forceReview = args.includes('--review')
274
+ // Phase 2 (deploy-ux-v4): when `foundation:` in site.yml points at a
275
+ // workspace-local file: ref, deploy auto-publishes the foundation when
276
+ // its `dist/publish.json` receipt is missing/stale. These flags opt out.
277
+ const autoPublishFoundation = !args.includes('--no-auto-publish')
278
+ const treatDirtyAsStale = !args.includes('--no-dirty-as-stale')
114
279
 
115
280
  const siteDir = await resolveSiteDir(args)
116
281
  const backendUrl = getBackendUrl()
@@ -120,13 +285,79 @@ export async function deploy(args = []) {
120
285
  // site.id / site.handle from prior deploys.
121
286
  const siteYmlPath = join(siteDir, 'site.yml')
122
287
  const siteYml = await readSiteYml(siteYmlPath)
123
- const foundation = siteYml.foundation
124
- if (!foundation) {
288
+ if (!siteYml.foundation) {
125
289
  say.err('site.yml is missing `foundation`.')
126
290
  say.dim('Add a line like: foundation: \'@uniweb/docs-foundation@0.1.20\'')
127
291
  process.exit(1)
128
292
  }
129
293
 
294
+ // Foundation may be string or object form (see site.yml docs).
295
+ const fnd = parseSiteFoundation(siteYml.foundation)
296
+ if (fnd.error) {
297
+ say.err(`site.yml: ${fnd.error}`)
298
+ process.exit(1)
299
+ }
300
+ // `foundation` is the on-the-wire shape we forward to PHP authorize +
301
+ // Worker publish. PHP only inspects the namespace via the ref string;
302
+ // it doesn't care about policy/pinned, so the object form passes through.
303
+ // The Worker (publish.js::parseFoundationConfig) handles both shapes.
304
+ let foundation = fnd.normalized
305
+ if (fnd.policy && fnd.policy !== 'auto-patch') {
306
+ say.dim(`Foundation policy: ${fnd.policy}${fnd.pinned ? ' (pinned)' : ''}`)
307
+ } else if (fnd.pinned) {
308
+ say.dim('Foundation policy: exact (pinned)')
309
+ }
310
+
311
+ // Phase 2: resolve workspace-local `file:` foundation refs.
312
+ //
313
+ // The object form of `foundation:` already requires a registry ref
314
+ // (`@ns/name@ver`) per parseSiteFoundation, so only the string form can
315
+ // resolve to a local path. Pass-through cases (registry ref, full URL,
316
+ // npm package) all leave `foundation` untouched. The resolved registry
317
+ // ref is also passed to the site build via UNIWEB_FOUNDATION_REF so the
318
+ // build runs in runtime mode against the just-published artifact instead
319
+ // of bundling the local foundation source. site.yml on disk is never
320
+ // modified.
321
+ let foundationBuildOverride = null
322
+ if (typeof foundation === 'string') {
323
+ const detected = detectFoundationType(foundation, siteDir)
324
+ if (detected.type === 'local') {
325
+ const localPath = detected.path
326
+ const relPath = relative(siteDir, localPath) || localPath
327
+
328
+ const inspection = await inspectLocalFoundationReceipt(localPath, {
329
+ dirtyAsStale: treatDirtyAsStale,
330
+ })
331
+
332
+ if (inspection.stale && !autoPublishFoundation) {
333
+ say.err(`Local foundation at ${relPath} is stale: ${inspection.reason}.`)
334
+ say.dim(`Run \`${getCliPrefix()} publish\` from ${relPath}, or drop --no-auto-publish to let deploy publish it for you.`)
335
+ process.exit(1)
336
+ }
337
+ if (inspection.stale) {
338
+ say.info(`Foundation at ${relPath} is stale (${inspection.reason}). Auto-publishing…`)
339
+ console.log('')
340
+ try {
341
+ execSync('npx uniweb publish', { cwd: localPath, stdio: 'inherit' })
342
+ } catch {
343
+ say.err(`Auto-publish of foundation at ${relPath} failed. See output above.`)
344
+ process.exit(1)
345
+ }
346
+ console.log('')
347
+ }
348
+
349
+ const resolved = await deriveLocalFoundationRef(localPath)
350
+ if (!resolved) {
351
+ say.err(`Could not derive a registry ref for foundation at ${relPath}.`)
352
+ say.dim(`Make sure its package.json has a name + version and a namespace (uniweb.namespace, scoped name, or run \`uniweb publish --namespace <handle>\` once).`)
353
+ process.exit(1)
354
+ }
355
+ say.dim(`Foundation: ${foundation} → ${resolved} (resolved from ${relPath})`)
356
+ foundation = resolved
357
+ foundationBuildOverride = resolved
358
+ }
359
+ }
360
+
130
361
  // Runtime defaults to "latest" resolved at authorize time.
131
362
  let runtimeVersion = siteYml.runtime
132
363
  if (!runtimeVersion) {
@@ -160,9 +391,18 @@ export async function deploy(args = []) {
160
391
  // link-mode URLs, which auto-enters runtime mode. Prerender also
161
392
  // auto-skips for link-mode foundations (HTML is rendered on the
162
393
  // serving edge, not here).
394
+ //
395
+ // For workspace-local foundations (Phase 2 resolution above),
396
+ // UNIWEB_FOUNDATION_REF tells defineSiteConfig to use the resolved
397
+ // registry ref instead of site.yml's literal value, so the build
398
+ // produces a runtime-mode bundle pointing at the just-published
399
+ // foundation rather than embedding the local source.
163
400
  execSync('npx uniweb build', {
164
401
  cwd: siteDir,
165
402
  stdio: 'inherit',
403
+ env: foundationBuildOverride
404
+ ? { ...process.env, UNIWEB_FOUNDATION_REF: foundationBuildOverride }
405
+ : process.env,
166
406
  })
167
407
  console.log('')
168
408
  } else if (!existsSync(contentPath)) {
@@ -202,7 +442,7 @@ export async function deploy(args = []) {
202
442
  say.info('Dry run — showing what would be deployed:')
203
443
  say.dim(`Site dir : ${siteDir}`)
204
444
  say.dim(`site.id : ${siteYml.site?.id || '(none — would use create flow)'}`)
205
- say.dim(`Foundation : ${foundation}`)
445
+ say.dim(`Foundation : ${typeof foundation === 'string' ? foundation : foundation.ref}`)
206
446
  say.dim(`Runtime : ${runtimeVersion}`)
207
447
  say.dim(`Languages : ${languages.join(', ')}`)
208
448
  say.dim(`Default locale : ${defaultLanguage}`)
@@ -397,6 +637,25 @@ export async function deploy(args = []) {
397
637
  }
398
638
  await callPublish({ url: publishUrl, token: publishToken, body: publishPayload })
399
639
 
640
+ // Local event memory — used by future re-deploys (e.g., to skip
641
+ // redundant work when nothing has changed). Lives under dist/ which is
642
+ // gitignored; the platform never reads it.
643
+ const foundationRef = typeof foundation === 'string' ? foundation : foundation?.ref
644
+ const { gitSha, gitDirty } = readGitState(siteDir)
645
+ const deployReceipt = {
646
+ schemaVersion: 1,
647
+ deployedFromGitSha: gitSha,
648
+ deployedFromGitDirty: gitDirty,
649
+ deployedAt: new Date().toISOString(),
650
+ url: handleResolved ? `https://${handleResolved}.uniweb.website/` : null,
651
+ foundation: {
652
+ ref: foundationRef,
653
+ url: composeFoundationUrl(foundationRef, getRegistryUrl()),
654
+ },
655
+ locales: languages,
656
+ }
657
+ await writeFile(join(distDir, 'deploy.json'), JSON.stringify(deployReceipt, null, 2) + '\n')
658
+
400
659
  // Write site.id / site.handle / features back to site.yml so the file
401
660
  // stays in sync with the live billing state. site.id and site.handle
402
661
  // are written on first deploy and any time the server-side handle drifts.
@@ -23,7 +23,7 @@
23
23
  import { existsSync } from 'node:fs'
24
24
  import { readFile } from 'node:fs/promises'
25
25
  import { resolve, join, dirname } from 'node:path'
26
- import { generateDocs } from '@uniweb/build'
26
+ import { generateDocs, resolveFoundationSrcPath, classifyPackage } from '@uniweb/build'
27
27
  import {
28
28
  isWorkspaceRoot,
29
29
  findFoundations,
@@ -160,7 +160,7 @@ ${colors.dim}Schema: https://raw.githubusercontent.com/uniweb/cli/main/schemas/p
160
160
  const META_REFERENCE = `
161
161
  ${colors.cyan}${colors.bright}Component meta.js Reference${colors.reset}
162
162
 
163
- Component metadata in foundation/src/components/[Name]/meta.js
163
+ Component metadata in <foundation>/components/[Name]/meta.js (or sections/[Name]/meta.js)
164
164
 
165
165
  ${colors.bright}Identity:${colors.reset}
166
166
  ${colors.cyan}title${colors.reset} Display name in editor
@@ -314,16 +314,15 @@ ${colors.dim}Notes:${colors.reset}
314
314
  * Detect if current directory is a foundation
315
315
  */
316
316
  function isFoundation(dir) {
317
- const srcDir = join(dir, 'src')
318
- const componentsDir = join(srcDir, 'components')
319
- return existsSync(componentsDir)
317
+ return classifyPackage(dir) === 'foundation'
320
318
  }
321
319
 
322
320
  /**
323
321
  * Detect if current directory is a site
324
322
  */
325
323
  function isSite(dir) {
326
- return existsSync(join(dir, 'site.yml')) || existsSync(join(dir, 'site.yaml'))
324
+ // Use the canonical classifier; also accept legacy `site.yaml`.
325
+ return classifyPackage(dir) === 'site' || existsSync(join(dir, 'site.yaml'))
327
326
  }
328
327
 
329
328
  /**
@@ -5,6 +5,7 @@
5
5
  import { existsSync, readFileSync, readdirSync } from 'node:fs'
6
6
  import { join, resolve, basename, dirname, relative } from 'node:path'
7
7
  import yaml from 'js-yaml'
8
+ import { resolveFoundationSrcPath, classifyPackage, isExtensionPackage as buildIsExtensionPackage } from '@uniweb/build'
8
9
  import { getCliVersion } from '../versions.js'
9
10
  import { readAgentsVersion } from '../utils/agents-stamp.js'
10
11
 
@@ -29,36 +30,37 @@ const log = console.log
29
30
  * Check if a directory is a site
30
31
  */
31
32
  function isSite(dir) {
32
- return existsSync(join(dir, 'site.yml')) || existsSync(join(dir, 'site.yaml'))
33
+ // Use the canonical classifier; also accept legacy `site.yaml`
34
+ // (the classifier only recognizes `.yml` and `.document.yml`).
35
+ return classifyPackage(dir) === 'site' || existsSync(join(dir, 'site.yaml'))
33
36
  }
34
37
 
35
38
  /**
36
39
  * Check if a directory is a foundation
37
40
  */
38
41
  function isFoundation(dir) {
39
- // Primary: has foundation.js config
40
- if (existsSync(join(dir, 'src', 'foundation.js'))) return true
41
- // Fallback: has src/sections/
42
- if (existsSync(join(dir, 'src', 'sections'))) return true
43
- // Legacy fallback: has src/components/
44
- if (existsSync(join(dir, 'src', 'components'))) return true
45
- return false
42
+ return classifyPackage(dir) === 'foundation'
46
43
  }
47
44
 
48
45
  /**
49
- * Load foundation.js config from a directory
50
- * Returns the default export, or null if not found/loadable
46
+ * Load the foundation's authored declarations file (main.js or legacy
47
+ * foundation.js) and return a minimal summary used by the doctor.
48
+ * Returns null if no declarations file is found.
51
49
  */
52
50
  function loadFoundationJs(dir) {
53
- const filePath = join(dir, 'src', 'foundation.js')
54
- if (!existsSync(filePath)) return null
55
- try {
56
- const content = readFileSync(filePath, 'utf8')
57
- // Simple extraction: check for extension: true
58
- return { extension: /extension\s*:\s*true/.test(content) }
59
- } catch {
60
- return null
51
+ const srcDir = resolveFoundationSrcPath(dir)
52
+ for (const name of ['main.js', 'foundation.js']) {
53
+ const filePath = join(srcDir, name)
54
+ if (existsSync(filePath)) {
55
+ try {
56
+ const content = readFileSync(filePath, 'utf8')
57
+ return { extension: /extension\s*:\s*true/.test(content) }
58
+ } catch {
59
+ return null
60
+ }
61
+ }
61
62
  }
63
+ return null
62
64
  }
63
65
 
64
66
  /**
@@ -78,10 +80,7 @@ function loadSchemaJson(dir) {
78
80
  * Check if a foundation is an extension (via schema.json or foundation.js)
79
81
  */
80
82
  function isExtensionPackage(dir) {
81
- const schema = loadSchemaJson(dir)
82
- if (schema?._self?.role === 'extension') return true
83
- const config = loadFoundationJs(dir)
84
- return config?.extension === true
83
+ return buildIsExtensionPackage(dir)
85
84
  }
86
85
 
87
86
  /**