uniweb 0.12.4 → 0.12.5

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "uniweb",
3
- "version": "0.12.4",
3
+ "version": "0.12.5",
4
4
  "description": "Create structured Vite + React sites with content/code separation",
5
5
  "type": "module",
6
6
  "bin": {
@@ -41,9 +41,9 @@
41
41
  "js-yaml": "^4.1.0",
42
42
  "prompts": "^2.4.2",
43
43
  "tar": "^7.0.0",
44
+ "@uniweb/runtime": "0.8.10",
44
45
  "@uniweb/core": "0.7.9",
45
- "@uniweb/kit": "0.9.9",
46
- "@uniweb/runtime": "0.8.10"
46
+ "@uniweb/kit": "0.9.9"
47
47
  },
48
48
  "peerDependencies": {
49
49
  "@uniweb/build": "0.13.3",
@@ -166,13 +166,36 @@ const say = {
166
166
  dim: (m) => console.log(` ${c.dim}${m}${c.reset}`),
167
167
  }
168
168
 
169
+ /**
170
+ * Read the git state for `dir`, scoped to that directory's history and
171
+ * working tree — NOT the whole repo's HEAD.
172
+ *
173
+ * `gitSha` : last commit that touched `dir` (`git log -1 -- .`).
174
+ * `gitDirty`: uncommitted changes inside `dir` only (`git status -- .`).
175
+ *
176
+ * Why scope it. In a multi-package monorepo, `git rev-parse HEAD` is
177
+ * the same value for every directory — the repo's current HEAD. That
178
+ * meant editing a SITE then deploying triggered the foundation's
179
+ * staleness check (its receipt's recorded sha didn't match the new
180
+ * repo HEAD), even though the foundation source was unchanged. The
181
+ * receipt's `publishedFromGitSha` field is per-foundation by design;
182
+ * the comparison side has to be too.
183
+ *
184
+ * If the path is outside a git repo, or has no commits touching it
185
+ * yet, the function returns `{ gitSha: null, gitDirty: false }` —
186
+ * same fallback shape as before.
187
+ */
169
188
  function readGitState(dir) {
170
189
  try {
171
- const sha = execSync('git rev-parse HEAD', {
190
+ // `git log -1 --format=%H -- .` returns the SHA of the last
191
+ // commit that touched the cwd path. If no such commit exists
192
+ // yet (path was never committed), output is empty — caller
193
+ // treats null as "no published-from-sha to compare against."
194
+ const sha = execSync('git log -1 --format=%H -- .', {
172
195
  cwd: dir,
173
196
  stdio: ['ignore', 'pipe', 'ignore'],
174
197
  }).toString().trim()
175
- const status = execSync('git status --porcelain', {
198
+ const status = execSync('git status --porcelain -- .', {
176
199
  cwd: dir,
177
200
  stdio: ['ignore', 'pipe', 'ignore'],
178
201
  }).toString()
@@ -12,7 +12,7 @@
12
12
  */
13
13
 
14
14
  import { existsSync } from 'node:fs'
15
- import { readFile, writeFile } from 'node:fs/promises'
15
+ import { readFile, writeFile, mkdir } from 'node:fs/promises'
16
16
  import { resolve, join } from 'node:path'
17
17
  import { execSync } from 'node:child_process'
18
18
 
@@ -188,13 +188,129 @@ export async function publish(args = []) {
188
188
  process.exit(1)
189
189
  }
190
190
 
191
- // 2. Auto-build if dist/ is missing
191
+ // 2. Auto-build if dist/ is missing OR stale.
192
+ //
193
+ // "Stale" means the schema fingerprint baked into
194
+ // `dist/meta/schema.json::_self.version` doesn't match the user's
195
+ // current `package.json::version`. That happens when the user bumps
196
+ // the version and runs `uniweb publish` without rebuilding — the
197
+ // artifact in dist/ encodes the OLD version, but the publish
198
+ // intends the NEW one. Without rebuilding we'd ship inconsistent
199
+ // bytes (schema says one version, registry record says another).
192
200
  const distDir = join(foundationDir, 'dist')
193
201
  const foundationJs = join(distDir, 'foundation.js')
194
202
  const schemaJson = join(distDir, 'meta', 'schema.json')
195
203
 
196
- if (!existsSync(foundationJs) || !existsSync(schemaJson)) {
197
- console.log(`${colors.yellow}⚠${colors.reset} No build found. Building foundation...`)
204
+ // Pre-read package.json so we can compare its version against the
205
+ // schema before deciding whether to rebuild.
206
+ const pkgPath = join(foundationDir, 'package.json')
207
+ let earlyPkg
208
+ try {
209
+ earlyPkg = JSON.parse(await readFile(pkgPath, 'utf8'))
210
+ } catch (err) {
211
+ error(`Failed to read package.json: ${err.message}`)
212
+ process.exit(1)
213
+ }
214
+
215
+ let needsBuild = !existsSync(foundationJs) || !existsSync(schemaJson)
216
+ let buildReason = needsBuild ? 'no dist/ found' : null
217
+
218
+ if (!needsBuild) {
219
+ try {
220
+ const peekSchema = JSON.parse(await readFile(schemaJson, 'utf8'))
221
+ if (peekSchema?._self?.version && earlyPkg.version && peekSchema._self.version !== earlyPkg.version) {
222
+ needsBuild = true
223
+ buildReason = `package.json::version (${earlyPkg.version}) differs from dist/meta/schema.json::_self.version (${peekSchema._self.version})`
224
+ }
225
+ } catch {
226
+ // Malformed schema → treat as stale.
227
+ needsBuild = true
228
+ buildReason = 'dist/meta/schema.json could not be parsed'
229
+ }
230
+ }
231
+
232
+ // 2b. Pre-flight registry check — runs BEFORE the build so we don't
233
+ // burn vite cycles on a foundation we already know we can't (or
234
+ // don't need to) publish.
235
+ //
236
+ // Two outcomes short-circuit the build:
237
+ //
238
+ // a. The registry already has `<canonicalName>@<version>`
239
+ // published from the CURRENT git sha (per-foundation last
240
+ // commit). The artifact upstream is correct; refresh the
241
+ // local receipt and exit. (Same outcome as the post-build
242
+ // duplicate check, just earlier — saves a build.)
243
+ //
244
+ // b. The registry has the version published from a DIFFERENT
245
+ // sha. The user has unpublished changes against an already-
246
+ // published version → "bump the version" error before any
247
+ // build work. Was the eval skill's pp-03 row.
248
+ //
249
+ // If the pre-flight can't determine the canonical name from
250
+ // pkg.json + flags + auth alone (e.g., needs a TTY prompt for
251
+ // the foundation id), it falls through silently to the existing
252
+ // post-build path. No-build-saved is still the existing behavior.
253
+ if (!isLocal) {
254
+ const preflightName = quickResolveCanonicalName(earlyPkg, { namespaceFlag, nameFlag })
255
+ const preflightVersion = earlyPkg.version
256
+ if (preflightName && preflightVersion) {
257
+ try {
258
+ const auth = await readAuth()
259
+ if (auth?.token) {
260
+ const claims = decodeJwtPayload(auth.token)
261
+ const memberUuid = claims?.memberUuid
262
+ // Empty-scope publishes are server-rewritten to ~<memberUuid>/<id>.
263
+ // Mirror that here so getVersionEntry queries the canonical key.
264
+ const lookupName = preflightName.startsWith('@') || preflightName.startsWith('~')
265
+ ? preflightName
266
+ : memberUuid ? `~${memberUuid}/${preflightName}` : null
267
+ if (lookupName) {
268
+ const registryUrlPre = registryUrl || getRegistryUrl()
269
+ const registryPre = new RemoteRegistry(registryUrlPre, auth.token)
270
+ const existing = await registryPre.getVersionEntry(lookupName, preflightVersion)
271
+ if (existing) {
272
+ const { gitSha } = readGitState(foundationDir)
273
+ if (gitSha && existing.publishedFromGitSha === gitSha) {
274
+ // Match → refresh receipt, exit clean. NO BUILD.
275
+ const refreshed = receiptFromRegistryEntry({
276
+ existingEntry: existing,
277
+ registry: registryPre,
278
+ name: lookupName,
279
+ version: preflightVersion,
280
+ isLocal: false,
281
+ isPropagateDefault: isPropagate,
282
+ })
283
+ if (refreshed) {
284
+ await mkdir(distDir, { recursive: true })
285
+ await writeFile(join(distDir, 'publish.json'), JSON.stringify(refreshed, null, 2) + '\n')
286
+ console.log('')
287
+ success(`${colors.bright}${lookupName}@${preflightVersion}${colors.reset} already published from ${gitSha.slice(0, 7)} — receipt refreshed.`)
288
+ return
289
+ }
290
+ }
291
+ // Sha mismatch (or no provenance recorded for the existing
292
+ // entry, which shouldn't happen for new publishes after
293
+ // the receipt-as-cache work shipped). Clean error before
294
+ // any build work.
295
+ console.log('')
296
+ error(`${colors.bright}${lookupName}@${preflightVersion}${colors.reset} is already published.`)
297
+ console.log('')
298
+ console.log(` Bump the version in package.json to publish an update:`)
299
+ console.log(` ${colors.dim}"version": "${bumpPatch(preflightVersion)}"${colors.reset}`)
300
+ process.exit(1)
301
+ }
302
+ }
303
+ }
304
+ } catch {
305
+ // Network down, malformed auth, etc. — fall through to the
306
+ // existing post-build flow. No-build-saved is still the same
307
+ // behavior the user got before this pre-flight existed.
308
+ }
309
+ }
310
+ }
311
+
312
+ if (needsBuild) {
313
+ console.log(`${colors.yellow}⚠${colors.reset} ${buildReason}. Building foundation...`)
198
314
  console.log('')
199
315
  execSync('npx uniweb build --target foundation', {
200
316
  cwd: foundationDir,
@@ -208,7 +324,13 @@ export async function publish(args = []) {
208
324
  }
209
325
  }
210
326
 
211
- // 3. Read name and version from meta/schema.json
327
+ // 3. Read name + version from the (now-fresh) schema + package.json.
328
+ //
329
+ // `_self.name` is the build-RESOLVED form — applies `uniweb.id`,
330
+ // scope resolution, etc., that are easier to read off the build
331
+ // output than to redo here. `version` is sourced from package.json
332
+ // directly; the version-skew check above already ensured the
333
+ // schema and package.json agree.
212
334
  let schema
213
335
  try {
214
336
  schema = JSON.parse(await readFile(schemaJson, 'utf8'))
@@ -218,11 +340,12 @@ export async function publish(args = []) {
218
340
  }
219
341
 
220
342
  const rawName = schema._self?.name
221
- const version = schema._self?.version
343
+ const version = earlyPkg.version
222
344
 
223
345
  if (!rawName || !version) {
224
- error('dist/meta/schema.json missing _self.name or _self.version')
225
- console.log(`${colors.dim} Ensure your package.json has "name" and "version" fields.${colors.reset}`)
346
+ error('Foundation missing name or version')
347
+ console.log(`${colors.dim} Ensure your package.json has "name" and "version" fields,${colors.reset}`)
348
+ console.log(`${colors.dim} and that the build has produced dist/meta/schema.json with _self.name.${colors.reset}`)
226
349
  process.exit(1)
227
350
  }
228
351
 
@@ -267,8 +390,9 @@ export async function publish(args = []) {
267
390
  // affects only the registry identity, never the workspace. Most users
268
391
  // benefit from leaving `package.json::name` as the scaffold default
269
392
  // (`src`) and putting the published-as id in `uniweb.id`.
270
- const pkgPath = join(foundationDir, 'package.json')
271
- const pkg = JSON.parse(await readFile(pkgPath, 'utf8'))
393
+ // pkgPath was declared earlier (during the rebuild-stale-dist check).
394
+ // Reuse the already-loaded `earlyPkg` rather than re-reading from disk.
395
+ const pkg = earlyPkg
272
396
  const uniwebNamespace = pkg.uniweb?.namespace
273
397
  const uniwebId = pkg.uniweb?.id
274
398
  const orgScopeMatch = (pkg.name || '').match(/^@([a-z0-9_-]+)\/([a-z0-9_-]+)$/)
@@ -541,6 +665,20 @@ export async function publish(args = []) {
541
665
  isPropagateDefault: isPropagate,
542
666
  })
543
667
  if (refreshedReceipt) {
668
+ // Persist uniweb.id BEFORE the early return when an auto-derive
669
+ // or prompt-resolved id was set in this run. Without this, the
670
+ // next run wouldn't know the id and would have to re-derive
671
+ // from scratch — which means the pre-flight registry check at
672
+ // the top of publish() can't fire either (it relies on a
673
+ // resolvable id from pkg.json alone). Persisting here closes
674
+ // that loop so future deploys hit the pre-flight bail and skip
675
+ // the build entirely.
676
+ if (writeBackId) {
677
+ pkg.uniweb = pkg.uniweb || {}
678
+ pkg.uniweb.id = foundationName
679
+ await writeFile(pkgPath, JSON.stringify(pkg, null, 2) + '\n')
680
+ info(`Wrote ${colors.cyan}uniweb.id: "${foundationName}"${colors.reset} to ${colors.dim}package.json${colors.reset}`)
681
+ }
544
682
  await writeFile(join(distDir, 'publish.json'), JSON.stringify(refreshedReceipt, null, 2) + '\n')
545
683
  console.log('')
546
684
  success(`${colors.bright}${lookupName}@${version}${colors.reset} already published from ${gitSha.slice(0, 7)} — receipt refreshed.`)
@@ -650,6 +788,65 @@ export async function publish(args = []) {
650
788
  * @param {string} version - e.g. "1.0.0"
651
789
  * @returns {string} - e.g. "1.0.1"
652
790
  */
791
+ /**
792
+ * Quickly compute the canonical foundation name from `package.json` +
793
+ * CLI flags alone, without prompting and without reading the build's
794
+ * `dist/meta/schema.json`. Used by the pre-flight registry check so we
795
+ * can short-circuit the build when the registry already has this
796
+ * version published.
797
+ *
798
+ * Returns null when resolution would need a prompt or auto-derive
799
+ * (caller falls through to the existing post-build resolution path,
800
+ * which handles those cases). The returned string is one of:
801
+ * - `@<scope>/<id>` (org scope, full canonical form)
802
+ * - `~<handle>/<id>` (personal alias scope)
803
+ * - `<id>` (bare; caller may prepend `~<memberUuid>/`
804
+ * from the JWT for the actual lookup)
805
+ *
806
+ * The full resolution at line 313+ is the canonical implementation;
807
+ * this helper is a strict subset that mirrors the high-confidence
808
+ * paths only. If they diverge, the helper is the one that should
809
+ * stay conservative (return null on uncertainty).
810
+ */
811
+ function quickResolveCanonicalName(pkg, { namespaceFlag, nameFlag } = {}) {
812
+ if (!pkg) return null
813
+ const orgScopeMatch = (pkg.name || '').match(/^@([a-z0-9_-]+)\/([a-z0-9_-]+)$/)
814
+ const personalScopeMatch = (pkg.name || '').match(/^~([a-z0-9_-]+)\/([a-z0-9_-]+)$/)
815
+ const uniwebNamespace = pkg.uniweb?.namespace
816
+ const uniwebId = pkg.uniweb?.id
817
+
818
+ // Scope precedence mirrors the full resolution.
819
+ let scopeSigil = null
820
+ let scopeName = null
821
+ if (namespaceFlag) {
822
+ scopeSigil = '@'
823
+ scopeName = namespaceFlag
824
+ } else if (orgScopeMatch) {
825
+ scopeSigil = '@'
826
+ scopeName = orgScopeMatch[1]
827
+ } else if (personalScopeMatch) {
828
+ scopeSigil = '~'
829
+ scopeName = personalScopeMatch[1]
830
+ } else if (uniwebNamespace) {
831
+ scopeSigil = '@'
832
+ scopeName = uniwebNamespace
833
+ }
834
+
835
+ // Id precedence mirrors the full resolution but stops at "no-prompt"
836
+ // sources. Auto-derive and TTY prompts both happen post-build so the
837
+ // user sees suggestions in context; the pre-flight only fires when
838
+ // the id is already determined.
839
+ let id = null
840
+ if (nameFlag) id = nameFlag
841
+ else if (orgScopeMatch) id = orgScopeMatch[2]
842
+ else if (personalScopeMatch) id = personalScopeMatch[2]
843
+ else if (uniwebId) id = uniwebId
844
+ else return null
845
+
846
+ if (scopeSigil) return `${scopeSigil}${scopeName}/${id}`
847
+ return id
848
+ }
849
+
653
850
  function bumpPatch(version) {
654
851
  const parts = version.split('.')
655
852
  if (parts.length !== 3) return version
@@ -799,13 +996,21 @@ async function buildIdSuggestions({ foundationDir, workspaceRoot, pkg }) {
799
996
  return out
800
997
  }
801
998
 
999
+ /**
1000
+ * Per-directory git state. Mirrors `deploy.js::readGitState` exactly —
1001
+ * scopes the sha + dirty check to `dir` rather than reading the whole
1002
+ * repo's HEAD. Receipts compare against this; if publish records the
1003
+ * repo HEAD but deploy compares against the foundation's last commit,
1004
+ * the receipt-as-cache no-op-refresh path drifts. Both sides must read
1005
+ * the same shape.
1006
+ */
802
1007
  function readGitState(dir) {
803
1008
  try {
804
- const sha = execSync('git rev-parse HEAD', {
1009
+ const sha = execSync('git log -1 --format=%H -- .', {
805
1010
  cwd: dir,
806
1011
  stdio: ['ignore', 'pipe', 'ignore'],
807
1012
  }).toString().trim()
808
- const status = execSync('git status --porcelain', {
1013
+ const status = execSync('git status --porcelain -- .', {
809
1014
  cwd: dir,
810
1015
  stdio: ['ignore', 'pipe', 'ignore'],
811
1016
  }).toString()
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "schemaVersion": 1,
3
- "generatedAt": "2026-04-30T00:37:08.969Z",
3
+ "generatedAt": "2026-04-30T02:16:23.561Z",
4
4
  "packages": {
5
5
  "@uniweb/build": {
6
6
  "version": "0.13.3",
@@ -92,7 +92,7 @@
92
92
  "deps": []
93
93
  },
94
94
  "@uniweb/unipress": {
95
- "version": "0.4.2",
95
+ "version": "0.4.3",
96
96
  "path": "framework/unipress",
97
97
  "deps": [
98
98
  "@uniweb/build",