uniweb 0.16.5 → 0.18.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "uniweb",
3
- "version": "0.16.5",
3
+ "version": "0.18.0",
4
4
  "description": "Create structured Vite + React sites with content/code separation",
5
5
  "type": "module",
6
6
  "bin": {
@@ -43,7 +43,7 @@
43
43
  "tar": "^7.0.0",
44
44
  "@uniweb/core": "^0.8.5",
45
45
  "@uniweb/kit": "^0.11.3",
46
- "@uniweb/runtime": "^0.11.5"
46
+ "@uniweb/runtime": "^0.11.7"
47
47
  },
48
48
  "peerDependencies": {
49
49
  "@uniweb/build": "^0.18.5",
@@ -1906,6 +1906,7 @@ uniweb add ci --target foundation # Publish a foundation for free at permanent v
1906
1906
  # (GitHub Pages → foundations/<name>/<version>/entry.js)
1907
1907
 
1908
1908
  uniweb push / pull / clone / status # Git-style content sync with the Uniweb backend
1909
+ uniweb push --org @acme # First push/publish of a site: who owns it (see below)
1909
1910
  uniweb register [--scope @org] # Register a foundation + its data schemas to the registry
1910
1911
  uniweb login / logout # Start or clear the backend session the verbs above reuse
1911
1912
  uniweb org list / create <handle> # Publish orgs you belong to — the @org in a scoped ref
@@ -1947,6 +1948,26 @@ Foundations have their own free path too: `uniweb add ci --target foundation` pu
1947
1948
 
1948
1949
  **Content authors work visually in the Uniweb App.** They compose the same extended markdown and set the same component params you defined, through a visual editor — never touching code, git, or the CLI. They see exactly the section types your foundation offers and exactly the knobs each one exposes, because **every `meta.js` is registered as the foundation's schema** when you publish. Your `meta.js` is the app's UI (see *meta.js* in Part 4).
1949
1950
 
1951
+ > ⚠️ **The FIRST push or publish of a site asks who owns it — and refuses if it cannot ask.**
1952
+ > That create decides which organization owns the site (and whose storage its assets are billed
1953
+ > to), it is the only moment the choice is made, and there is no CLI command to change it
1954
+ > afterwards. At a terminal you get a picker. **Without a terminal — CI, a script, or you, an
1955
+ > agent — the command exits non-zero instead of choosing**, and `--yes` refuses too, because
1956
+ > guessing an owner is not an answer. Name it and it never asks again:
1957
+ >
1958
+ > ```bash
1959
+ > uniweb publish --org @acme # an organization
1960
+ > uniweb publish --personal # your personal account, deliberately
1961
+ > ```
1962
+ >
1963
+ > The answer is recorded in `site.yml::$org` and committed, so it is a one-time choice per site,
1964
+ > not per machine. **Ask the human which one to use** rather than picking for them — a site in
1965
+ > the wrong org cannot be moved from here. Sites that already exist are unaffected: their
1966
+ > ownership is settled, so nothing is asked.
1967
+ >
1968
+ > Relatedly: these commands now **reject flags they do not recognize** instead of ignoring them.
1969
+ > If you get `Unknown flag`, read the suggestion — it is usually a near miss.
1970
+
1950
1971
  **Sync is developer-only and one-sided by design.** `uniweb push` and `uniweb pull` are your commands, not theirs:
1951
1972
 
1952
1973
  - **The app is the live source of truth for content** — where authors work and where your push lands live.
@@ -444,6 +444,164 @@ function recordSiteOrg(siteDir, asOrg) {
444
444
  }
445
445
  }
446
446
 
447
+ /**
448
+ * Resolve WHICH ORG will own a site that is about to be created.
449
+ *
450
+ * The create that mints `$uuid` is the only call that reads `as_org`, and the
451
+ * backend never moves ownership afterwards. There is also no CLI verb to transfer
452
+ * or delete a site. So this is a **one-shot, unrepealable** decision — and until
453
+ * this function existed the CLI made it silently, by sending nothing and letting
454
+ * the backend fall back to the session's personal context. A developer who belongs
455
+ * to several orgs could put a company site, and the storage it bills, somewhere
456
+ * they never named.
457
+ *
458
+ * `register` has refused to guess a scope for the foundation lane since it shipped
459
+ * (`deriveScope` → `package.json::uniweb.scope`). This is the same refusal, one
460
+ * lane over.
461
+ *
462
+ * Order, and only the last step is new:
463
+ * 1. `--as-org @org` — explicit, rides verbatim
464
+ * 2. `--personal` — explicit "no org, I mean it" → sends NO `as_org`
465
+ * 3. `site.yml::$org` — recorded at this site's own create
466
+ * 4. the site already exists (`$uuid`) → null; ownership is settled, ask nothing
467
+ * 5. otherwise ASK (TTY) or REFUSE (non-interactive)
468
+ *
469
+ * ⛔ **`--personal` sends no `as_org`, and is NOT the same as `--as-org @<handle>`.**
470
+ * The personal *org* `@jane` is an org like any other, lazily created on first use;
471
+ * the session's personal context is not an org at all. Whether the backend gives
472
+ * them the same owning unit is **its** business and unverified here, so the
473
+ * deliberate-personal spelling reproduces today's wire byte-for-byte rather than
474
+ * asserting an equivalence this lane cannot check.
475
+ *
476
+ * @returns {Promise<{ asOrg: string|null, refused?: true, reason?: string }>}
477
+ * `asOrg: null` with no `refused` means "send no as_org" — either a settled site
478
+ * or a deliberate personal choice.
479
+ */
480
+ export async function resolveSiteOrgForCreate({
481
+ client,
482
+ siteDir,
483
+ args = [],
484
+ flag,
485
+ personal = false,
486
+ offline = false
487
+ }) {
488
+ if (flag) return { asOrg: flag }
489
+ if (personal) return { asOrg: null }
490
+
491
+ const recorded = readSiteOrg(siteDir)
492
+ if (recorded) return { asOrg: recorded }
493
+
494
+ // Already created ⇒ nothing to decide. This is what keeps every existing site
495
+ // silent: ownership was settled at its create, and re-asking would be theatre.
496
+ let siteYml = {}
497
+ try {
498
+ const y = yaml.load(readFileSync(join(siteDir, 'site.yml'), 'utf8'))
499
+ if (y && typeof y === 'object') siteYml = y
500
+ } catch {
501
+ /* unreadable — treat as un-created and let the resolution below decide */
502
+ }
503
+ if (typeof siteYml.$uuid === 'string') return { asOrg: null }
504
+
505
+ // An offline preview (`--dry-run` / `-o`) must never authenticate, and it is
506
+ // creating nothing, so there is no decision to force. Say what is unresolved
507
+ // instead of prompting for an answer the run will not use.
508
+ if (offline) return { asOrg: null }
509
+
510
+ // `--yes` promises "never block on a prompt", so it has to answer this one too —
511
+ // and the only honest non-blocking answer to an unanswerable ownership question
512
+ // is a refusal. Treating it as consent-to-anything would reinstate the silent
513
+ // default this whole path exists to remove, behind a flag that reads like
514
+ // approval.
515
+ const { isNonInteractive } = await import('../utils/interactive.js')
516
+ if (isNonInteractive(args) || args.includes('--yes')) {
517
+ return {
518
+ asOrg: null,
519
+ refused: true,
520
+ reason:
521
+ 'This site does not exist on the backend yet, and no org was named.\n' +
522
+ ' The create decides who OWNS it — and which workspace its storage is billed to —\n' +
523
+ ' one time, with no CLI way to change it afterwards. Name it explicitly:\n' +
524
+ ' --as-org @org create it under an organization\n' +
525
+ ' --personal create it under your personal account, deliberately'
526
+ }
527
+ }
528
+
529
+ // Interactive: offer the real choice. Deliberately NOT `deriveScope` — that one
530
+ // serves the foundation lane, where every answer is an org and 0-orgs means
531
+ // "claim your personal org". Here "personal" must stay reachable as *no org*,
532
+ // and reusing deriveScope would quietly turn it into an org creation.
533
+ const { fetchOrgs, createOrg, validateHandle, bareHandle } = await import(
534
+ '../utils/registry-orgs.js'
535
+ )
536
+ let envelope
537
+ try {
538
+ envelope = await fetchOrgs({
539
+ apiBase: client.origin,
540
+ token: await client.token()
541
+ })
542
+ } catch (err) {
543
+ return { asOrg: null, refused: true, reason: err.message }
544
+ }
545
+ const personalHandle = envelope.account_handle || null
546
+ const prompts = (await import('prompts')).default
547
+ const choices = [
548
+ ...envelope.orgs.map((o) => ({
549
+ title: `@${o.handle}${o.handle === personalHandle ? ' — your personal org' : o.is_primary ? ' (primary)' : ''}`,
550
+ value: o.handle
551
+ })),
552
+ {
553
+ title: `Personal — no organization${personalHandle ? ` (${personalHandle})` : ''}`,
554
+ value: ':personal'
555
+ },
556
+ { title: 'A new organization…', value: ':new' }
557
+ ]
558
+ const { choice } = await prompts(
559
+ {
560
+ type: 'select',
561
+ name: 'choice',
562
+ message: 'Create this site under which owner?',
563
+ choices,
564
+ initial: 0
565
+ },
566
+ {
567
+ onCancel: () => {
568
+ console.log('\nCancelled.')
569
+ process.exit(0)
570
+ }
571
+ }
572
+ )
573
+ if (!choice) return { asOrg: null, refused: true, reason: 'No owner chosen.' }
574
+ if (choice === ':personal') return { asOrg: null }
575
+ if (choice !== ':new') return { asOrg: `@${choice}` }
576
+
577
+ const answer = await prompts(
578
+ {
579
+ type: 'text',
580
+ name: 'handle',
581
+ message: 'Org handle (e.g. acme):',
582
+ validate: (v) => validateHandle(v) || true
583
+ },
584
+ {
585
+ onCancel: () => {
586
+ console.log('\nCancelled.')
587
+ process.exit(0)
588
+ }
589
+ }
590
+ )
591
+ if (!answer.handle)
592
+ return { asOrg: null, refused: true, reason: 'No org handle given.' }
593
+ try {
594
+ const org = await createOrg({
595
+ apiBase: client.origin,
596
+ token: await client.token(),
597
+ handle: bareHandle(answer.handle)
598
+ })
599
+ return { asOrg: `@${org.handle}` }
600
+ } catch (err) {
601
+ return { asOrg: null, refused: true, reason: err.message }
602
+ }
603
+ }
604
+
447
605
  /**
448
606
  * Guarantee the site EXISTS on the backend before anything is uploaded against it.
449
607
  *
@@ -525,7 +683,8 @@ export async function ensureSiteExists({
525
683
  : `HTTP ${res?.status} ${res?.statusText || ''}${body ? ` — ${body.slice(0, 200)}` : ''}`
526
684
  }
527
685
  }
528
- const minted = extractMintedSiteUuid(await res.json().catch(() => null))
686
+ const payload = await res.json().catch(() => null)
687
+ const minted = extractMintedSiteUuid(payload)
529
688
  if (!minted) {
530
689
  return {
531
690
  uuid: null,
@@ -538,16 +697,65 @@ export async function ensureSiteExists({
538
697
  // this point cannot leave a cache pointing at a different site with no way to
539
698
  // detect it.
540
699
  updateSyncCache(siteDir, { siteUuid: minted })
541
- // Ownership is decided HERE and nowhere else — this is the one call that reads
542
- // `as_org`. Recording it beside the uuid is what makes the answer readable later,
543
- // and naming it now is the "show what resolved" half of the ask-once pattern.
544
- const org = recordSiteOrg(siteDir, asOrg)
700
+ const org = await recordAndDescribeOwner({
701
+ client,
702
+ siteDir,
703
+ payload,
704
+ asOrg,
705
+ note
706
+ })
707
+ return { uuid: minted, created: true, org }
708
+ }
709
+
710
+ /**
711
+ * Record and announce who owns a just-created site, from the backend's own echo.
712
+ *
713
+ * The echo reports what the site **is**; `asOrg` is only what we asked for. They
714
+ * agree in the normal case and the echo is the one to trust — it is read back off
715
+ * the created entity, so it also covers the case we could never record before: no
716
+ * `--as-org` at all, where the backend picked and we had nothing true to write.
717
+ *
718
+ * ⚠️ `org: null` is MEANINGFUL (personal), not missing. An older backend omits the
719
+ * key entirely, which is the only case that falls back to what we asked for.
720
+ *
721
+ * @returns {Promise<string|null>} the display handle recorded, or null for personal
722
+ */
723
+ async function recordAndDescribeOwner({ client, siteDir, payload, asOrg, note }) {
724
+ const echoed = payload && 'org' in payload ? payload.org : undefined
725
+ const owner =
726
+ echoed === undefined ? asOrg : typeof echoed === 'string' ? echoed : null
727
+ const org = recordSiteOrg(siteDir, owner)
728
+
545
729
  note?.(
546
730
  org
547
731
  ? `Created the site on the backend under ${org} (recorded $uuid + $org in site.yml).`
548
- : `Created the site on the backend (recorded $uuid in site.yml).`
732
+ : `Created the site on the backend, owned personally (recorded $uuid in site.yml).`
549
733
  )
550
- return { uuid: minted, created: true, org }
734
+
735
+ // The billing line needs the JOIN of two independent facts, and either alone
736
+ // gives a wrong answer:
737
+ // hosts_free — a property of the SCOPE (is this owner exempt?)
738
+ // siteSubscriptionRequired — a property of the DEPLOYMENT (does it charge at all?)
739
+ // Keyed on the scope alone, this fires on every local publish — where nothing
740
+ // enforces — until the warning is trained away. Keyed on the deployment alone it
741
+ // fires at exempt owners. An older backend supplies neither, so both read falsy
742
+ // and nothing is said: silence beats a claim we cannot justify.
743
+ const hostsFree = payload?.hosts_free === true
744
+ let enforces = false
745
+ try {
746
+ const cfg = await client.discover()
747
+ enforces = cfg?.delivery?.siteSubscriptionRequired === true
748
+ } catch {
749
+ /* discovery is advisory here — never fail a create over a message */
750
+ }
751
+ if (hostsFree) {
752
+ note?.('This owner is hosted free — publishing will not require a subscription.')
753
+ } else if (enforces) {
754
+ note?.(
755
+ 'Publishing this site live will require a hosting subscription on this backend.'
756
+ )
757
+ }
758
+ return org
551
759
  }
552
760
 
553
761
  /**
@@ -904,7 +1112,15 @@ export async function pushSyncPackages({
904
1112
  // which is gated on the site having local media). Both mint a site, so both
905
1113
  // owe the same record — recording it in only one place would make `$org`
906
1114
  // present or absent depending on whether the site happens to have images.
907
- const createdOrg = recordSiteOrg(siteDir, asOrg)
1115
+ // The backend echoes `org`/`hosts_free` top-level here too, beside `report`
1116
+ // and `site` (NOT beside `finalized`, which lives at report.finalized).
1117
+ const createdOrg = await recordAndDescribeOwner({
1118
+ client,
1119
+ siteDir,
1120
+ payload,
1121
+ asOrg,
1122
+ note
1123
+ })
908
1124
  if (createdOrg) wrote.push(`recorded site $org (${createdOrg}) in site.yml`)
909
1125
  const createdFinalized = extractFinalized(payload)
910
1126
  harvest(createdFinalized)
@@ -57,6 +57,8 @@ import { BackendClient } from '../backend/client.js'
57
57
  import { isNonInteractive, getCliPrefix } from '../utils/interactive.js'
58
58
  import { extractFoundationRef } from '../utils/site-content-refs.js'
59
59
  import { readUwxDocuments } from '../utils/uwx-read.js'
60
+ import { recordWritten } from '../utils/pull-written.js'
61
+ import { checkFlags } from '../utils/flag-guard.js'
60
62
 
61
63
  const colors = {
62
64
  reset: '\x1b[0m',
@@ -159,6 +161,13 @@ function pullExecArgv(pm, extra) {
159
161
  * runPull(siteDir, pm, extraArgs).
160
162
  */
161
163
  export async function clone(args = [], deps = {}) {
164
+ // See utils/flag-guard.js — an unrecognized flag is invisible to a
165
+ // literal scan, so it silently keeps the default (production, for --backend).
166
+ const badFlag = checkFlags('clone', args)
167
+ if (badFlag) {
168
+ error(badFlag.message)
169
+ return { exitCode: 2 }
170
+ }
162
171
  const positionals = args.filter((a) => !a.startsWith('-'))
163
172
  const siteUuid = positionals[0]
164
173
  const target = positionals[1] || null // [name|.]
@@ -348,6 +357,31 @@ export async function clone(args = [], deps = {}) {
348
357
  }
349
358
  }
350
359
 
360
+ // Tell the delegated pull which files WE wrote, or it refuses on them.
361
+ //
362
+ // Pull guards against overwriting uncommitted work under the paths it rewrites,
363
+ // and `site.yml` / `theme.yml` are exactly those paths — uncommitted here
364
+ // because clone scaffolded them seconds ago. Without this, clone reliably ends
365
+ // in "Refusing to pull: 2 uncommitted change(s)" on a project it just created,
366
+ // where there is no user work to protect. The advice it prints (`--force`) is
367
+ // both unreachable — clone does not forward that flag — and wrong, since it
368
+ // means "discard my changes" and the changes are ours.
369
+ //
370
+ // ⭐ This exempts them for the RIGHT reason rather than overriding the guard.
371
+ // `recordWritten` stores a content hash, so a file the user edits between
372
+ // scaffold and pull no longer matches and the refusal correctly stands. The
373
+ // guard keeps its teeth; it just stops firing on machine output, which is what
374
+ // it already does for pull's own writes.
375
+ //
376
+ // Recorded AFTER the uuid seeding above — the hash has to be of the final
377
+ // bytes on disk, not of what the scaffolder first wrote.
378
+ recordWritten(
379
+ siteDir,
380
+ ['site.yml', 'theme.yml', 'head.html', 'collections.yml']
381
+ .map((f) => join(siteDir, f))
382
+ .filter((p) => existsSync(p))
383
+ )
384
+
351
385
  const pullExtra = []
352
386
  if (explicitBackend) pullExtra.push('--backend', explicitBackend)
353
387
  if (tokenFlag) pullExtra.push('--token', tokenFlag)
@@ -28,12 +28,15 @@
28
28
  * uniweb publish --dry-run Resolve everything; POST nothing
29
29
  * uniweb publish --yes Skip confirmations (CI); never block on a prompt
30
30
  * uniweb publish --force Overwrite upstream app-side edits (drop the push gate)
31
- * uniweb publish --as-org @org Publish under @org (membership-gated). Only the
31
+ * uniweb publish --org @org Publish under @org (alias: --as-org). Only the
32
32
  * FIRST publish of a site reads it — that create is
33
33
  * what decides which org owns the site and whose
34
34
  * storage its assets are charged to. It is then
35
35
  * recorded as `site.yml::$org` and replayed, so it
36
36
  * never has to be re-typed.
37
+ * uniweb publish --personal Own the new site personally, deliberately. Sends
38
+ * NO `as_org` — byte-identical to the wire before
39
+ * the owner prompt existed. First publish only.
37
40
  * uniweb publish --no-save Skip the deploy.yml lastDeploy auto-save
38
41
  * uniweb publish --backend <url> Override the backend origin
39
42
  * uniweb publish --token <bearer> Auth bearer (skips `uniweb login`)
@@ -61,7 +64,8 @@ import { resolveDefaultLocale } from '@uniweb/core/locale-config'
61
64
  import { BackendClient } from '../backend/client.js'
62
65
  import { resolveSiteDir, resolveSiteBackend } from './deploy.js'
63
66
  import { warnIfContentDoesNotConform } from '../utils/conformance.js'
64
- import { readFlagValue } from '../utils/args.js'
67
+ import { readFlagValue, readOrgFlag } from '../utils/args.js'
68
+ import { checkFlags } from '../utils/flag-guard.js'
65
69
  import { isNonInteractive } from '../utils/interactive.js'
66
70
  import { headProvenance } from '../utils/git.js'
67
71
  import {
@@ -73,7 +77,7 @@ import {
73
77
  ensureSiteExists,
74
78
  clearRemoteSyncStateIfUnbound,
75
79
  pushSyncPackages,
76
- readSiteOrg
80
+ resolveSiteOrgForCreate
77
81
  } from '../backend/site-sync.js'
78
82
  import { uploadDataBundle } from '../backend/data-bundle.js'
79
83
  import { uploadSiteMedia, describeAssetRefusal } from '../backend/site-media.js'
@@ -172,18 +176,19 @@ async function persistLastDeploy(siteDir, opts) {
172
176
  }
173
177
 
174
178
  export async function publish(args = []) {
179
+ // See utils/flag-guard.js — an unrecognized flag is invisible to a literal
180
+ // scan, and for --backend the silent default can be production.
181
+ const bad = checkFlags('publish', args)
182
+ if (bad) {
183
+ say.err(bad.message)
184
+ return { exitCode: 2 }
185
+ }
175
186
  const dryRun = args.includes('--dry-run')
176
187
  const noSave = args.includes('--no-save')
177
188
  const foundationDir = readFlagValue(args, '--foundation') // optional local foundation for Model schemas
178
189
 
179
190
  const siteDir = await resolveSiteDir(args, 'publish')
180
191
 
181
- // The acting org: the flag verbatim, else the one this site was CREATED under
182
- // (`site.yml::$org`). Only the create reads `as_org`, so replaying the recorded
183
- // handle reasserts existing ownership rather than choosing new ownership. Absent
184
- // both, no `as_org` is sent — unchanged from before the record existed.
185
- const asOrg = readFlagValue(args, '--as-org') || readSiteOrg(siteDir)
186
-
187
192
  // Advisory only — warns and ships. See utils/conformance.js for why this
188
193
  // is not a gate.
189
194
  await warnIfContentDoesNotConform(siteDir, { args })
@@ -201,6 +206,25 @@ export async function publish(args = []) {
201
206
  command: 'Publishing'
202
207
  })
203
208
 
209
+ // WHO will own this site, if this publish is the one that creates it. Resolved
210
+ // up front: `ensureSiteExists` below is the create, and it must not be reached
211
+ // with the question still open. An already-created site resolves to null without
212
+ // asking — its ownership was settled once and cannot be changed from here.
213
+ const org = await resolveSiteOrgForCreate({
214
+ client,
215
+ siteDir,
216
+ args,
217
+ flag: readOrgFlag(args),
218
+ personal: args.includes('--personal'),
219
+ offline: dryRun
220
+ })
221
+ if (org.refused) {
222
+ say.err('Refusing to create this site without naming an owner.')
223
+ say.dim(org.reason)
224
+ return { exitCode: 2 }
225
+ }
226
+ const asOrg = org.asOrg
227
+
204
228
  // Capability handshake (cached). Publish ends in a go-live, so the publish
205
229
  // lane must be offered.
206
230
  const config = await client.discover()
@@ -76,8 +76,13 @@ import {
76
76
  computeUnitHashes,
77
77
  collectUnitUuids
78
78
  } from '@uniweb/build/uwx'
79
- import { makeModelResolver } from './push.js'
80
79
  import {
80
+ readWritten,
81
+ recordWritten,
82
+ isPullOutput
83
+ } from '../utils/pull-written.js'
84
+ import {
85
+ makeModelResolver,
81
86
  mergeBaseVersions,
82
87
  mergeItemBaseVersions,
83
88
  writeUnitBases,
@@ -95,6 +100,7 @@ import {
95
100
  resolveSiteDir as defaultResolveSiteDir,
96
101
  resolveSiteBackend
97
102
  } from './deploy.js'
103
+ import { checkFlags } from '../utils/flag-guard.js'
98
104
 
99
105
  const FOLDER_MODEL = '@uniweb/folder'
100
106
 
@@ -355,79 +361,10 @@ async function checkWorkingTree(siteDir, args) {
355
361
  return { exitCode: 1 }
356
362
  }
357
363
 
358
- // What the last pull wrote: `{ <repo-relative path>: <sha256 of the bytes it wrote> }`.
359
- //
360
- // Without this the guard cries wolf: pull rewrites the tree, so the very next pull
361
- // sees its OWN output as uncommitted work and refuses, listing files the user never
362
- // touched. A guard that fires on nothing teaches people to reach for --force, which
363
- // is the destructive option — the same failure mode as a gate that refuses disjoint
364
- // edits. A file still byte-identical to what pull last wrote is not user work.
365
- function writtenCachePath(siteDir) {
366
- return join(siteDir, '.uniweb', 'pull-written.json')
367
- }
368
- function readWritten(siteDir) {
369
- try {
370
- const o = JSON.parse(readFileSync(writtenCachePath(siteDir), 'utf8'))
371
- return {
372
- files: o && typeof o.files === 'object' ? o.files : {},
373
- deleted: Array.isArray(o?.deleted) ? o.deleted : []
374
- }
375
- } catch {
376
- return { files: {}, deleted: [] }
377
- }
378
- }
379
- function recordWritten(siteDir, absPaths, deletedAbs = []) {
380
- // MERGE, don't replace. A conditional pull that 304s writes nothing, and a
381
- // partial pull writes only some lanes — in both cases the previous record is
382
- // still "the last thing pull wrote there". Replacing would forget those paths and
383
- // the next pull would see them as the user's work again, which is the false alarm
384
- // this cache exists to prevent. A stale entry for a file that no longer exists is
385
- // harmless: the hash read fails and it counts as a local change.
386
- const prior = readWritten(siteDir)
387
- const files = prior.files
388
- for (const abs of absPaths) {
389
- try {
390
- files[relative(siteDir, abs)] = createHash('sha256')
391
- .update(readFileSync(abs))
392
- .digest('hex')
393
- } catch {
394
- /* deleted or unreadable — nothing to remember */
395
- }
396
- }
397
- // Pull PRUNES too, and a deletion is a dirty path git reports just like an edit.
398
- // Without recording them, pull's own pruning reads as the user having deleted
399
- // files — the same false alarm as its writes, arriving by the other door.
400
- const deleted = [
401
- ...new Set([
402
- ...prior.deleted,
403
- ...deletedAbs.map((a) => relative(siteDir, a))
404
- ])
405
- ]
406
- try {
407
- mkdirSync(dirname(writtenCachePath(siteDir)), { recursive: true })
408
- writeFileSync(
409
- writtenCachePath(siteDir),
410
- JSON.stringify({ version: 1, files, deleted }, null, 2) + '\n'
411
- )
412
- } catch {
413
- /* best-effort: losing it only costs a spurious refusal */
414
- }
415
- }
416
- // Is this dirty path just pull's own untouched output?
417
- function isPullOutput(siteDir, relPath, written) {
418
- let exists = true
419
- let hash = null
420
- try {
421
- hash = createHash('sha256')
422
- .update(readFileSync(join(siteDir, relPath)))
423
- .digest('hex')
424
- } catch {
425
- exists = false
426
- }
427
- // Absent because pull pruned it — not because the user deleted it.
428
- if (!exists) return written.deleted.includes(relPath)
429
- return written.files[relPath] === hash
430
- }
364
+ // The written-record helpers live in `utils/pull-written.js` so `clone` can share
365
+ // them: clone scaffolds site.yml/theme.yml and then delegates here, and this
366
+ // module cannot be imported from there it statically imports `@uniweb/build`,
367
+ // which resolves from a project that does not exist yet when clone runs.
431
368
 
432
369
  /**
433
370
  * `--merge`: keep local work instead of refusing, by three-way merging it with what
@@ -539,6 +476,13 @@ async function confirm(question) {
539
476
  }
540
477
 
541
478
  export async function pull(args = [], deps = {}) {
479
+ // See utils/flag-guard.js — an unrecognized flag is invisible to a
480
+ // literal scan, so it silently keeps the default (production, for --backend).
481
+ const badFlag = checkFlags('pull', args)
482
+ if (badFlag) {
483
+ error(badFlag.message)
484
+ return { exitCode: 2 }
485
+ }
542
486
  const resolveSiteDir = deps.resolveSiteDir || defaultResolveSiteDir
543
487
 
544
488
  const dryRun = args.includes('--dry-run')
@@ -26,11 +26,15 @@
26
26
  *
27
27
  * Usage:
28
28
  * uniweb push Build, push both lanes, back-fill $uuid
29
- * uniweb push --as-org @org Act as @org (membership-gated). Needed only
30
- * for the FIRST push of a site — it decides
31
- * which org owns it (and whose storage its
32
- * assets are charged to). Recorded as
29
+ * uniweb push --org @org Own the new site under @org (alias: --as-org).
30
+ * Read only on the FIRST push of a site — it
31
+ * decides which org owns it, and whose storage
32
+ * its assets are charged to. Recorded as
33
33
  * `site.yml::$org` and replayed after that.
34
+ * Without it, you are asked once.
35
+ * uniweb push --personal Own the new site personally, deliberately.
36
+ * Sends NO `as_org` — the same wire as before
37
+ * this prompt existed. First push only.
34
38
  * uniweb push --dry-run Report what would be pushed; submit nothing
35
39
  * uniweb push -o out.uwx Write the .uwx file(s) per lane; submit nothing
36
40
  * uniweb push --registry <url> Override the backend origin
@@ -63,6 +67,8 @@ import { uploadSiteMedia, describeAssetRefusal } from '../backend/site-media.js'
63
67
  import { BackendClient } from '../backend/client.js'
64
68
  import { resolveSiteDir, resolveSiteBackend } from './deploy.js'
65
69
  import { warnIfContentDoesNotConform } from '../utils/conformance.js'
70
+ import { readOrgFlag } from '../utils/args.js'
71
+ import { checkFlags } from '../utils/flag-guard.js'
66
72
  import {
67
73
  makeModelResolver,
68
74
  readSyncCache,
@@ -73,7 +79,7 @@ import {
73
79
  ensureSiteExists,
74
80
  clearRemoteSyncStateIfUnbound,
75
81
  pushSyncPackages,
76
- readSiteOrg
82
+ resolveSiteOrgForCreate
77
83
  } from '../backend/site-sync.js'
78
84
 
79
85
  // Re-exported for downstream importers (pull.js, push.test.js) that read these
@@ -108,6 +114,13 @@ function flagValue(args, name) {
108
114
  }
109
115
 
110
116
  export async function push(args = []) {
117
+ // An unrecognized flag is invisible to a literal scan, so it silently keeps the
118
+ // default — including for --backend, where the default can be production.
119
+ const bad = checkFlags('push', args)
120
+ if (bad) {
121
+ error(bad.message)
122
+ return { exitCode: 2 }
123
+ }
111
124
  const dryRun = args.includes('--dry-run')
112
125
  const output = flagValue(args, '-o') || flagValue(args, '--output')
113
126
  const tokenFlag = flagValue(args, '--token')
@@ -122,13 +135,6 @@ export async function push(args = []) {
122
135
 
123
136
  const siteDir = await resolveSiteDir(args, 'push')
124
137
 
125
- // The acting org: the flag verbatim, else the one this site was CREATED under
126
- // (`site.yml::$org`). The org is consumed by the create that mints `$uuid`, so
127
- // a site that already exists is already owned — replaying the recorded handle
128
- // reasserts that rather than choosing anything new. A site with no `$org`
129
- // recorded sends no `as_org`, exactly as before.
130
- const asOrg = flagValue(args, '--as-org') || readSiteOrg(siteDir)
131
-
132
138
  // Advisory only — warns and pushes. A malformed data block otherwise rides
133
139
  // the sync wire unchecked; see utils/conformance.js.
134
140
  await warnIfContentDoesNotConform(siteDir, { args })
@@ -146,6 +152,25 @@ export async function push(args = []) {
146
152
  command: 'Syncing'
147
153
  })
148
154
 
155
+ // WHO will own this site, if this push is the one that creates it. Resolved
156
+ // before any lane runs, because both create paths below consume it and neither
157
+ // should be reached with the question still open. A site that already exists
158
+ // resolves to null without asking — ownership was settled at its create.
159
+ const org = await resolveSiteOrgForCreate({
160
+ client,
161
+ siteDir,
162
+ args,
163
+ flag: readOrgFlag(args),
164
+ personal: args.includes('--personal'),
165
+ offline: !!output || dryRun
166
+ })
167
+ if (org.refused) {
168
+ error('Refusing to create this site without naming an owner.')
169
+ note(org.reason)
170
+ return { exitCode: 2 }
171
+ }
172
+ const asOrg = org.asOrg
173
+
149
174
  // Build BOTH directional packages (the producer side). Each carries its own
150
175
  // `index` — the per-entity source-file map for back-fill, correlated by submission
151
176
  // position. Non-local Models are fetched from the registry on demand. `priorHashes`
@@ -81,6 +81,7 @@ import {
81
81
  promptSelect
82
82
  } from '../utils/workspace.js'
83
83
  import { isNonInteractive, getCliPrefix } from '../utils/interactive.js'
84
+ import { checkFlags } from '../utils/flag-guard.js'
84
85
 
85
86
  const colors = {
86
87
  reset: '\x1b[0m',
@@ -265,6 +266,13 @@ export function foundationNeedsBuild(targetDir) {
265
266
  }
266
267
 
267
268
  export async function register(args = []) {
269
+ // See utils/flag-guard.js — an unrecognized flag is invisible to a
270
+ // literal scan, so it silently keeps the default (production, for --backend).
271
+ const badFlag = checkFlags('register', args)
272
+ if (badFlag) {
273
+ error(badFlag.message)
274
+ return { exitCode: 2 }
275
+ }
268
276
  jsonMode = args.includes('--json')
269
277
  jsonEmitted = false
270
278
  lastError = null
@@ -30,6 +30,7 @@ import { BackendClient } from '../backend/client.js'
30
30
  import { readFlagValue } from '../utils/args.js'
31
31
  import { resolveLocalFoundation } from '../backend/foundation-bring-along.js'
32
32
  import { computeFoundationDigest } from '../utils/code-upload.js'
33
+ import { checkFlags } from '../utils/flag-guard.js'
33
34
 
34
35
  const c = {
35
36
  reset: '\x1b[0m',
@@ -73,6 +74,14 @@ function splitFoundationRef(fnd) {
73
74
  }
74
75
 
75
76
  export async function status(args = []) {
77
+ // See utils/flag-guard.js — an unrecognized flag is invisible to a literal scan.
78
+ // Straight to stderr: this is a usage error, so it must not be mistaken for the
79
+ // status document even under --json.
80
+ const badFlag = checkFlags('status', args)
81
+ if (badFlag) {
82
+ console.error(`\x1b[31m✗\x1b[0m ${badFlag.message}`)
83
+ return { exitCode: 2 }
84
+ }
76
85
  const jsonMode = args.includes('--json')
77
86
  const remote = args.includes('--remote')
78
87
  const siteDir = await resolveSiteDir(args, 'status')
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "schemaVersion": 1,
3
- "generatedAt": "2026-08-12T22:01:32.128Z",
3
+ "generatedAt": "2026-08-12T23:11:50.036Z",
4
4
  "packages": {
5
5
  "@uniweb/build": {
6
6
  "version": "0.18.5",
@@ -73,7 +73,7 @@
73
73
  ]
74
74
  },
75
75
  "@uniweb/runtime": {
76
- "version": "0.11.5",
76
+ "version": "0.11.7",
77
77
  "path": "framework/runtime",
78
78
  "deps": [
79
79
  "@uniweb/core",
package/src/index.js CHANGED
@@ -1318,10 +1318,14 @@ ${colors.bright}Options:${colors.reset}
1318
1318
  --yes Skip confirmations (CI); never block on a prompt
1319
1319
  --no-save Skip the deploy.yml lastDeploy auto-save
1320
1320
  --no-validate Skip the content-conformance check (it only warns)
1321
- --as-org @org Publish under @org (membership-gated). Read only on a site's
1322
- FIRST publish — that create decides which org owns the site,
1323
- and whose storage its assets are charged to. Recorded as
1324
- site.yml \$org and replayed, so it is never re-typed.
1321
+ --org @org Publish under @org (membership-gated; alias: --as-org). Read
1322
+ only on a site's FIRST publish — that create decides which org
1323
+ owns the site, and whose storage its assets are charged to.
1324
+ Recorded as site.yml \$org and replayed, so it is never
1325
+ re-typed. Without it, you are asked once.
1326
+ --personal Create the site under your personal account, deliberately.
1327
+ Only needed on a first publish, and only to answer the owner
1328
+ question without a prompt (CI, agents, scripts).
1325
1329
  --backend <url> Backend origin (default: \$UNIWEB_REGISTER_URL or built-in)
1326
1330
  --token <bearer> Auth bearer (skips \`uniweb login\`)
1327
1331
  `,
@@ -1697,7 +1701,8 @@ ${colors.bright}Global Options:${colors.reset}
1697
1701
  ${colors.bright}Publish Options:${colors.reset}
1698
1702
  --dry-run Resolve everything; release/sync/POST nothing
1699
1703
  --yes Skip confirmations (CI); never block on a prompt
1700
- --as-org @org Publish under @org (first publish only; then remembered)
1704
+ --org @org Publish under @org (first publish only; then remembered)
1705
+ --personal Own the new site personally, deliberately (first publish only)
1701
1706
  --no-save Skip the deploy.yml lastDeploy auto-save
1702
1707
  --no-validate Skip the content-conformance check (it only warns)
1703
1708
  --backend <url> Backend origin (default: \$UNIWEB_REGISTER_URL or built-in)
package/src/utils/args.js CHANGED
@@ -20,6 +20,104 @@
20
20
  * @param {string} name — Including the leading dashes, e.g. '--host'.
21
21
  * @returns {string | null | undefined}
22
22
  */
23
+ /**
24
+ * The org a site is created under. `--org` is the documented spelling; `--as-org`
25
+ * is a working alias.
26
+ *
27
+ * `--as-org` mirrors the wire (`?as_org=`) and names an *acting capacity* — the
28
+ * request is made as a member of that org, membership-gated. That is accurate, and
29
+ * it is also not the name anyone reaches for: asked in one day, the backend's docs
30
+ * said `--as-unit` and a second reader said `--org`; nobody produced `--as-org`.
31
+ * Since the flag's main job is answering *who owns this site*, `--org` is the name
32
+ * that matches the question, and the alias costs one `||` (the same shape
33
+ * `--backend` / `--registry` already uses).
34
+ *
35
+ * `||`, not `??`, on purpose: a valueless `--org` falls through to `--as-org`
36
+ * rather than shadowing it.
37
+ *
38
+ * @param {string[]} args
39
+ * @returns {string|null|undefined}
40
+ */
41
+ export function readOrgFlag(args) {
42
+ return readFlagValue(args, '--org') || readFlagValue(args, '--as-org')
43
+ }
44
+
45
+ /**
46
+ * Every `--flag` / `-f` token in `args` that `known` does not list.
47
+ *
48
+ * The CLI reads flags by scanning argv for a literal, so an unrecognized flag is
49
+ * not an error — it is *invisible*, and whatever it was meant to change silently
50
+ * keeps its default. The sharp case is `--backend`: mistype it and the origin
51
+ * ladder falls through to the session, the saved config, or `https://uniweb.app`,
52
+ * so a command aimed at localhost can reach production. `--token` degrades the
53
+ * same way, to a stored session belonging to someone else.
54
+ *
55
+ * Scanning rules, chosen to avoid false positives (a wrong rejection is worse than
56
+ * a missed one — it breaks a working command):
57
+ * - only tokens beginning with `-` are candidates; a VALUE is never one unless it
58
+ * itself looks like a flag, which `readFlagValue` already refuses to consume;
59
+ * - `--flag=value` is checked on the name half;
60
+ * - a bare `--` ends flag scanning, the POSIX convention;
61
+ * - a lone `-` is a value (stdin), not a flag.
62
+ *
63
+ * @param {string[]} args
64
+ * @param {string[]} known - every flag this command accepts, with dashes
65
+ * @returns {string[]} the unrecognized tokens, in order, deduped
66
+ */
67
+ export function findUnknownFlags(args, known) {
68
+ const set = new Set(known)
69
+ const out = []
70
+ for (const raw of args) {
71
+ if (raw === '--') break
72
+ if (raw === '-' || !raw.startsWith('-')) continue
73
+ const name = raw.split('=')[0]
74
+ if (set.has(name) || out.includes(name)) continue
75
+ out.push(name)
76
+ }
77
+ return out
78
+ }
79
+
80
+ /** Levenshtein distance, small and iterative — used only for a suggestion. */
81
+ function editDistance(a, b) {
82
+ const prev = Array.from({ length: b.length + 1 }, (_, i) => i)
83
+ for (let i = 1; i <= a.length; i++) {
84
+ let diag = prev[0]
85
+ prev[0] = i
86
+ for (let j = 1; j <= b.length; j++) {
87
+ const tmp = prev[j]
88
+ prev[j] = Math.min(
89
+ prev[j] + 1,
90
+ prev[j - 1] + 1,
91
+ diag + (a[i - 1] === b[j - 1] ? 0 : 1)
92
+ )
93
+ diag = tmp
94
+ }
95
+ }
96
+ return prev[b.length]
97
+ }
98
+
99
+ /**
100
+ * The closest known flag to `flag`, or null when nothing is close enough.
101
+ * The threshold scales with length so `--org` → `--as-org` is offered while two
102
+ * unrelated short flags are not.
103
+ * @param {string} flag
104
+ * @param {string[]} known
105
+ * @returns {string|null}
106
+ */
107
+ export function didYouMean(flag, known) {
108
+ let best = null
109
+ let bestD = Infinity
110
+ for (const k of known) {
111
+ const d = editDistance(flag, k)
112
+ if (d < bestD) {
113
+ bestD = d
114
+ best = k
115
+ }
116
+ }
117
+ const limit = Math.max(2, Math.floor(flag.length / 3))
118
+ return best && bestD <= limit ? best : null
119
+ }
120
+
23
121
  export function readFlagValue(args, name) {
24
122
  const eqPrefix = name + '='
25
123
  for (let i = 0; i < args.length; i++) {
@@ -0,0 +1,133 @@
1
+ /**
2
+ * Reject unrecognized flags on the backend verbs.
3
+ *
4
+ * Every one of these commands can send data to, or authenticate against, a remote
5
+ * host — and the CLI resolves flags by scanning argv for a literal, so a flag it
6
+ * does not recognize does not fail: it *disappears*, and the thing it was meant to
7
+ * change silently keeps its default.
8
+ *
9
+ * That is tolerable for a cosmetic flag and dangerous for these two:
10
+ *
11
+ * --backend mistyped ⇒ the origin ladder falls through to the session origin,
12
+ * ~/.uniweb/config.json, and finally https://uniweb.app. A command
13
+ * aimed at a local backend can reach production.
14
+ * --token mistyped ⇒ falls back to the stored session, so the request is
15
+ * made as whoever is logged in rather than whoever was intended.
16
+ *
17
+ * Neither produces an error today; both produce a plausible success against the
18
+ * wrong host. This turns that class into one sentence.
19
+ *
20
+ * ⚠️ A wrong rejection is worse than a missed one — it breaks an invocation that
21
+ * works — so the per-command lists must be complete, INCLUDING flags read by
22
+ * helpers rather than by the command file itself. Two live examples: `--no-validate`
23
+ * is consumed inside `utils/conformance.js`, and `--yes` inside
24
+ * `backend/foundation-bring-along.js`. Grepping only the command's own source
25
+ * misses both. When you add a flag anywhere on one of these paths, add it here.
26
+ */
27
+
28
+ import { findUnknownFlags, didYouMean } from './args.js'
29
+
30
+ /** Accepted by every command, wherever they are actually consumed. */
31
+ const GLOBAL = ['--non-interactive', '--help', '-h']
32
+
33
+ /**
34
+ * Login-method flags. Any verb that can hit an unauthenticated backend may fall
35
+ * into `ensureRegistryAuth`, which hands `args` to the login picker — so these are
36
+ * genuinely reachable from all of them, not just from `uniweb login`.
37
+ */
38
+ const AUTH = ['--browser', '--password', '--token-paste']
39
+
40
+ /**
41
+ * Flags a verb inherits by importing `commands/deploy.js` for `resolveSiteDir` /
42
+ * `resolveSiteBackend`. Inert on these verbs, and listed rather than filtered:
43
+ * accepting a flag that does nothing is exactly the pre-guard behaviour, while
44
+ * rejecting one that works is a broken command. The guard's job is catching
45
+ * `--backed`, not policing inert-but-valid spellings.
46
+ */
47
+ const VIA_DEPLOY = ['--target', '--host', '--no-save']
48
+
49
+ /**
50
+ * Per-verb flag sets. Derived by scanning each command for dash-literals AND the
51
+ * helpers it calls — not from the help text, which has drifted from the parser in
52
+ * both directions (`--as-org` was implemented and undocumented; `--yes` is
53
+ * documented on `publish` and consumed two files away).
54
+ */
55
+ const VERBS = {
56
+ push: [
57
+ '--all', '--as-org', '--org', '--backend', '--dry-run', '--force',
58
+ '--foundation', '--output', '-o', '--personal', '--registry', '--token',
59
+ // read in utils/conformance.js and backend/site-sync.js respectively —
60
+ // neither appears in push.js
61
+ '--no-validate', '--yes', ...VIA_DEPLOY
62
+ ],
63
+ publish: [
64
+ '--as-org', '--org', '--backend', '--dry-run', '--force', '--foundation',
65
+ '--personal', '--registry', '--token',
66
+ // read in utils/conformance.js, backend/site-sync.js, and
67
+ // backend/foundation-bring-along.js — none appear in publish.js
68
+ '--no-validate', '--yes', '--no-verify', ...VIA_DEPLOY
69
+ ],
70
+ pull: [
71
+ '--backend', '--content-only', '--dry-run', '--force', '--merge',
72
+ '--no-collections', '--no-delete', '--no-prune', '--registry', '--token',
73
+ // via backend/site-sync.js (the owner resolver) and utils/conformance.js
74
+ '--yes', '--org', '--as-org', '--no-validate', ...VIA_DEPLOY
75
+ ],
76
+ clone: [
77
+ '--backend', '--content-only', '--no-collections', '--path', '--project',
78
+ '--registry', '--token', '--org', '--as-org'
79
+ ],
80
+ register: [
81
+ '--backend', '--dry-run', '--json', '--output', '-o', '--registry',
82
+ '--schema-only', '--scope', '--token', '--org', '--as-org'
83
+ ],
84
+ status: [
85
+ '--backend', '--json', '--registry', '--remote', '--token', '--dry-run',
86
+ '--force', '--no-verify', '--no-validate', '--yes', '--org', '--as-org',
87
+ ...VIA_DEPLOY
88
+ ]
89
+ }
90
+
91
+ /**
92
+ * The accepted set per verb: its own flags, plus the login-method flags every
93
+ * backend verb can reach, plus the globals.
94
+ *
95
+ * Derived from each verb's IMPORT GRAPH, not from its own source and not from the
96
+ * help text — `test/flag-guard-coverage.test.js` walks that graph and fails if a
97
+ * verb can honour a flag this list omits. Deliberately an over-approximation: a
98
+ * flag accepted here but inert costs nothing (it was ignored before the guard
99
+ * existed), while one rejected here breaks a working command.
100
+ */
101
+ export const VERB_FLAGS = Object.fromEntries(
102
+ Object.entries(VERBS).map(([verb, flags]) => [
103
+ verb,
104
+ [...new Set([...flags, ...AUTH])]
105
+ ])
106
+ )
107
+
108
+ /**
109
+ * Check `args` against the verb's accepted set. Returns null when everything is
110
+ * recognized, or a ready-to-print message naming the first offender (plus a
111
+ * suggestion when one is close).
112
+ *
113
+ * Reports ONE flag rather than all of them: the first is usually the cause, and a
114
+ * list invites skimming past the suggestion, which is the actionable half.
115
+ *
116
+ * @param {string} verb - a key of VERB_FLAGS
117
+ * @param {string[]} args - the argv slice for this command
118
+ * @returns {{ flag: string, message: string, suggestion: string|null }|null}
119
+ */
120
+ export function checkFlags(verb, args = []) {
121
+ const known = VERB_FLAGS[verb]
122
+ if (!known) return null
123
+ const all = [...known, ...GLOBAL]
124
+ const unknown = findUnknownFlags(args, all)
125
+ if (!unknown.length) return null
126
+
127
+ const flag = unknown[0]
128
+ const suggestion = didYouMean(flag, all)
129
+ const lines = [`Unknown flag \`${flag}\` for \`uniweb ${verb}\`.`]
130
+ if (suggestion) lines.push(` Did you mean \`${suggestion}\`?`)
131
+ lines.push(` Run \`uniweb ${verb} --help\` for the accepted flags.`)
132
+ return { flag, suggestion, message: lines.join('\n') }
133
+ }
package/src/utils/git.js CHANGED
@@ -18,8 +18,8 @@
18
18
  */
19
19
 
20
20
  import { execFileSync } from 'node:child_process'
21
- import { readFileSync } from 'node:fs'
22
- import { join } from 'node:path'
21
+ import { readFileSync, realpathSync } from 'node:fs'
22
+ import { join, relative } from 'node:path'
23
23
  import yaml from 'js-yaml'
24
24
 
25
25
  /**
@@ -85,11 +85,44 @@ export function isGitRepo(dir) {
85
85
  * as modified here: a section file that exists only locally is not on the backend,
86
86
  * so a pruning pull deletes it — losing work that was never committed anywhere.
87
87
  *
88
- * @returns {string[]|null} repo-relative-ish paths as git reports them, or `null`
89
- * when this isn't a git work tree (distinct from `[]`, which means "clean").
88
+ * **Returned relative to `dir`, which is NOT what git prints.** `--porcelain`
89
+ * paths are always relative to the REPOSITORY ROOT, whatever directory it runs
90
+ * in, so they only coincide with `dir`-relative when the site *is* the repo root.
91
+ * Both callers do `join(siteDir, rel)` with the result, so the un-normalized form
92
+ * was wrong for every nested layout — a site at `myproject/site/` inside a repo.
93
+ *
94
+ * What that cost, measured 2026-08-12: pull exempts its own previous output from
95
+ * the uncommitted-work guard by looking each dirty path up in a record keyed
96
+ * `dir`-relative (`utils/pull-written.js`). Given `myproject/site/site.yml` the
97
+ * lookup missed, so **pull refused on files pull itself had written** — the exact
98
+ * false alarm that record exists to prevent, and the one its own comment warns
99
+ * teaches people to reach for `--force`. `captureLocalWork` had the same defect
100
+ * one door along: it reads each path back with `join(siteDir, rel)`.
101
+ *
102
+ * The earlier `@returns` said "repo-relative-ish paths as git reports them" while
103
+ * the summary above said "relative to `dir`" — the ambiguity was noticed and left,
104
+ * and both callers had picked the other reading.
105
+ *
106
+ * @returns {string[]|null} paths relative to `dir`, or `null` when this isn't a
107
+ * git work tree (distinct from `[]`, which means "clean").
90
108
  */
91
109
  export function uncommittedUnder(dir, relPaths) {
92
110
  if (!isGitRepo(dir)) return null
111
+ // ⚠️ Both sides must be REAL paths before `relative` can compare them.
112
+ // `rev-parse --show-toplevel` resolves symlinks; the caller's `dir` usually has
113
+ // not. On macOS that alone breaks it — `/var` is a symlink to `/private/var`,
114
+ // so a repo under the temp dir yields a root of `/private/var/…` against a dir
115
+ // of `/var/…`, and `relative` walks all the way up and back down. The result is
116
+ // a valid-looking path that matches nothing, i.e. the same silent miss this
117
+ // normalization exists to fix. Any symlinked checkout does it, not just tmp.
118
+ let root
119
+ let base
120
+ try {
121
+ root = git(['rev-parse', '--show-toplevel'], dir).trim()
122
+ base = realpathSync(dir)
123
+ } catch {
124
+ return null
125
+ }
93
126
  try {
94
127
  const out = git(
95
128
  ['status', '--porcelain', '--untracked-files=all', '--', ...relPaths],
@@ -102,6 +135,12 @@ export function uncommittedUnder(dir, relPaths) {
102
135
  // porcelain v1: XY<space>path, and a rename is "orig -> new".
103
136
  .map((line) => line.slice(3).trim())
104
137
  .map((p) => (p.includes(' -> ') ? p.split(' -> ')[1] : p))
138
+ // Porcelain paths are repo-root-relative; callers want them relative to
139
+ // `dir`. Quoted paths (git quotes names with spaces or non-ASCII when
140
+ // `core.quotePath` is on) are left alone rather than half-decoded — a
141
+ // wrong path is worse than an unmatched one here, since an unmatched
142
+ // path merely stays "dirty" and the guard errs toward refusing.
143
+ .map((p) => (p.startsWith('"') ? p : relative(base, join(root, p))))
105
144
  .filter(Boolean)
106
145
  )
107
146
  } catch {
@@ -0,0 +1,127 @@
1
+ /**
2
+ * The record of what a machine wrote into a site project — pull's own output.
3
+ *
4
+ * ## What it is for
5
+ *
6
+ * `uniweb pull` refuses to run when there are uncommitted changes under the files
7
+ * it rewrites, because it reconciles the working tree to the backend and would
8
+ * overwrite them. That guard needs one distinction to be useful: **a file the
9
+ * user edited** versus **a file a previous pull wrote and nobody has touched
10
+ * since**. Without it the guard cries wolf — pull rewrites the tree, so the next
11
+ * pull sees its own output as uncommitted work and refuses, listing files the
12
+ * user never touched. A guard that fires on nothing teaches people to reach for
13
+ * `--force`, which is the destructive option.
14
+ *
15
+ * So each write is recorded with a content hash, and a dirty path whose hash
16
+ * still matches is not user work.
17
+ *
18
+ * ## Why it lives here rather than in `pull.js`
19
+ *
20
+ * `uniweb clone` has the same claim to make and cannot make it from there.
21
+ * `pull.js` statically imports `@uniweb/build`, which resolves from the
22
+ * *project's* `node_modules` — and `clone` runs before a project exists (see
23
+ * `utils/uwx-read.js` for the same constraint on the `.uwx` reader).
24
+ *
25
+ * Clone scaffolds `site.yml` and `theme.yml` and then delegates to `pull`, so
26
+ * without this the delegated pull sees clone's own scaffolding as uncommitted
27
+ * user work and refuses — on a project created seconds earlier, where there is no
28
+ * user work to protect. Clone records what it wrote; the guard then exempts those
29
+ * files **for the right reason** rather than being overridden.
30
+ *
31
+ * Nothing here imports `@uniweb/build`, and nothing here may start to.
32
+ */
33
+
34
+ import { readFileSync, writeFileSync, mkdirSync } from 'node:fs'
35
+ import { createHash } from 'node:crypto'
36
+ import { join, dirname, relative } from 'node:path'
37
+
38
+ /** Where the record lives — gitignored, beside the other per-site caches. */
39
+ export function writtenCachePath(siteDir) {
40
+ return join(siteDir, '.uniweb', 'pull-written.json')
41
+ }
42
+
43
+ /**
44
+ * @param {string} siteDir
45
+ * @returns {{files: Record<string,string>, deleted: string[]}}
46
+ */
47
+ export function readWritten(siteDir) {
48
+ try {
49
+ const o = JSON.parse(readFileSync(writtenCachePath(siteDir), 'utf8'))
50
+ return {
51
+ files: o && typeof o.files === 'object' ? o.files : {},
52
+ deleted: Array.isArray(o?.deleted) ? o.deleted : []
53
+ }
54
+ } catch {
55
+ return { files: {}, deleted: [] }
56
+ }
57
+ }
58
+
59
+ /**
60
+ * Record files as machine-written, by absolute path.
61
+ *
62
+ * MERGE, don't replace. A conditional pull that 304s writes nothing, and a
63
+ * partial pull writes only some lanes — in both cases the previous record is
64
+ * still "the last thing written there". Replacing would forget those paths and
65
+ * the next pull would see them as the user's work again, which is the false alarm
66
+ * this cache exists to prevent. A stale entry for a file that no longer exists is
67
+ * harmless: the hash read fails and it counts as a local change.
68
+ *
69
+ * @param {string} siteDir
70
+ * @param {string[]} absPaths - files just written
71
+ * @param {string[]} [deletedAbs] - files just pruned
72
+ */
73
+ export function recordWritten(siteDir, absPaths, deletedAbs = []) {
74
+ const prior = readWritten(siteDir)
75
+ const files = prior.files
76
+ for (const abs of absPaths) {
77
+ try {
78
+ files[relative(siteDir, abs)] = createHash('sha256')
79
+ .update(readFileSync(abs))
80
+ .digest('hex')
81
+ } catch {
82
+ /* deleted or unreadable — nothing to remember */
83
+ }
84
+ }
85
+ // Pull PRUNES too, and a deletion is a dirty path git reports just like an edit.
86
+ // Without recording them, pull's own pruning reads as the user having deleted
87
+ // files — the same false alarm as its writes, arriving by the other door.
88
+ const deleted = [
89
+ ...new Set([...prior.deleted, ...deletedAbs.map((a) => relative(siteDir, a))])
90
+ ]
91
+ try {
92
+ mkdirSync(dirname(writtenCachePath(siteDir)), { recursive: true })
93
+ writeFileSync(
94
+ writtenCachePath(siteDir),
95
+ JSON.stringify({ version: 1, files, deleted }, null, 2) + '\n'
96
+ )
97
+ } catch {
98
+ /* best-effort: losing it only costs a spurious refusal */
99
+ }
100
+ }
101
+
102
+ /**
103
+ * Is this dirty path just machine output nobody has touched?
104
+ *
105
+ * The hash comparison is what keeps the guard honest: a scaffolded or pulled file
106
+ * the user then EDITED no longer matches, so it counts as their work and the
107
+ * refusal stands.
108
+ *
109
+ * @param {string} siteDir
110
+ * @param {string} relPath
111
+ * @param {{files: Record<string,string>, deleted: string[]}} written
112
+ * @returns {boolean}
113
+ */
114
+ export function isPullOutput(siteDir, relPath, written) {
115
+ let exists = true
116
+ let hash = null
117
+ try {
118
+ hash = createHash('sha256')
119
+ .update(readFileSync(join(siteDir, relPath)))
120
+ .digest('hex')
121
+ } catch {
122
+ exists = false
123
+ }
124
+ // Absent because pull pruned it — not because the user deleted it.
125
+ if (!exists) return written.deleted.includes(relPath)
126
+ return written.files[relPath] === hash
127
+ }