uniweb 0.16.4 → 0.17.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.4",
3
+ "version": "0.17.0",
4
4
  "description": "Create structured Vite + React sites with content/code separation",
5
5
  "type": "module",
6
6
  "bin": {
@@ -41,14 +41,14 @@
41
41
  "js-yaml": "^4.1.0",
42
42
  "prompts": "^2.4.2",
43
43
  "tar": "^7.0.0",
44
- "@uniweb/core": "^0.8.4",
45
- "@uniweb/kit": "^0.11.2",
46
- "@uniweb/runtime": "^0.11.4"
44
+ "@uniweb/kit": "^0.11.3",
45
+ "@uniweb/core": "^0.8.5",
46
+ "@uniweb/runtime": "^0.11.6"
47
47
  },
48
48
  "peerDependencies": {
49
- "@uniweb/build": "^0.18.4",
50
- "@uniweb/content-reader": "^1.2.2",
51
- "@uniweb/semantic-parser": "^1.2.1"
49
+ "@uniweb/build": "^0.18.5",
50
+ "@uniweb/semantic-parser": "^1.2.2",
51
+ "@uniweb/content-reader": "^1.2.2"
52
52
  },
53
53
  "peerDependenciesMeta": {
54
54
  "@uniweb/build": {
@@ -18,6 +18,7 @@ import { hasUncommittedContent } from '../utils/git.js'
18
18
  import {
19
19
  backfillEntityUuids,
20
20
  writeSiteEntityUuid,
21
+ writeSiteOrg,
21
22
  emitSyncPackages,
22
23
  readZip,
23
24
  diffSiteUnits,
@@ -390,6 +391,217 @@ export function writeItemUuids(siteDir, map) {
390
391
  updateSyncCache(siteDir, { itemUuids: map })
391
392
  }
392
393
 
394
+ /**
395
+ * The org this site was created under, as `@handle`, or null.
396
+ *
397
+ * Read back from `site.yml::$org` (stored bare — see `writeSiteOrg`) and re-dressed
398
+ * with the `@` the CLI and the wire both use. Callers pass it as `--as-org`'s default
399
+ * so an org named once, at create, does not have to be re-typed on every later push.
400
+ *
401
+ * Deliberately NOT a fallback for the flag: an explicit `--as-org` always wins and
402
+ * rides verbatim, so this can only add a value where the CLI previously sent none.
403
+ *
404
+ * @param {string} siteDir
405
+ * @returns {string|null}
406
+ */
407
+ export function readSiteOrg(siteDir) {
408
+ try {
409
+ const y = yaml.load(readFileSync(join(siteDir, 'site.yml'), 'utf8'))
410
+ const h = y && typeof y === 'object' ? y.$org : null
411
+ return typeof h === 'string' && h.trim()
412
+ ? `@${h.trim().replace(/^@/, '')}`
413
+ : null
414
+ } catch {
415
+ return null
416
+ }
417
+ }
418
+
419
+ /**
420
+ * Record the org a just-minted site was created under, if one was named.
421
+ *
422
+ * Only what we were TOLD is recorded — when no `--as-org` was passed the backend
423
+ * chose the owner and its create response carries no org, so there is nothing to
424
+ * write and guessing one would be worse than the gap. Returns the display form for
425
+ * the caller's "here's what resolved" line, or null when nothing was recorded.
426
+ *
427
+ * @param {string} siteDir
428
+ * @param {string|null|undefined} asOrg - the `--as-org` value, `@handle` or bare
429
+ * @returns {string|null}
430
+ */
431
+ function recordSiteOrg(siteDir, asOrg) {
432
+ const handle = String(asOrg || '')
433
+ .replace(/^@/, '')
434
+ .replace(/\/.*$/, '')
435
+ .trim()
436
+ if (!handle) return null
437
+ try {
438
+ writeSiteOrg(siteDir, handle)
439
+ return `@${handle}`
440
+ } catch {
441
+ // The uuid is the load-bearing back-fill; losing the org note must never
442
+ // fail a push that already succeeded on the backend.
443
+ return null
444
+ }
445
+ }
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
+
393
605
  /**
394
606
  * Guarantee the site EXISTS on the backend before anything is uploaded against it.
395
607
  *
@@ -429,7 +641,7 @@ export async function ensureSiteExists({
429
641
  /* unreadable site.yml — treat as un-synced and let the create decide */
430
642
  }
431
643
  if (typeof siteYml.$uuid === 'string') {
432
- return { uuid: siteYml.$uuid, created: false }
644
+ return { uuid: siteYml.$uuid, created: false, org: readSiteOrg(siteDir) }
433
645
  }
434
646
 
435
647
  // Both are required by the create. Catching it here turns a 400 into a sentence
@@ -471,7 +683,8 @@ export async function ensureSiteExists({
471
683
  : `HTTP ${res?.status} ${res?.statusText || ''}${body ? ` — ${body.slice(0, 200)}` : ''}`
472
684
  }
473
685
  }
474
- const minted = extractMintedSiteUuid(await res.json().catch(() => null))
686
+ const payload = await res.json().catch(() => null)
687
+ const minted = extractMintedSiteUuid(payload)
475
688
  if (!minted) {
476
689
  return {
477
690
  uuid: null,
@@ -484,8 +697,65 @@ export async function ensureSiteExists({
484
697
  // this point cannot leave a cache pointing at a different site with no way to
485
698
  // detect it.
486
699
  updateSyncCache(siteDir, { siteUuid: minted })
487
- note?.(`Created the site on the backend (recorded $uuid in site.yml).`)
488
- return { uuid: minted, created: true }
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
+
729
+ note?.(
730
+ org
731
+ ? `Created the site on the backend under ${org} (recorded $uuid + $org in site.yml).`
732
+ : `Created the site on the backend, owned personally (recorded $uuid in site.yml).`
733
+ )
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
489
759
  }
490
760
 
491
761
  /**
@@ -838,6 +1108,20 @@ export async function pushSyncPackages({
838
1108
  updateSyncCache(siteDir, { siteUuid: minted })
839
1109
  boundSiteUuid = minted
840
1110
  wrote.push('recorded site $uuid in site.yml')
1111
+ // The OTHER create path (a media-less push never reaches `ensureSiteExists`,
1112
+ // which is gated on the site having local media). Both mint a site, so both
1113
+ // owe the same record — recording it in only one place would make `$org`
1114
+ // present or absent depending on whether the site happens to have images.
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
+ })
1124
+ if (createdOrg) wrote.push(`recorded site $org (${createdOrg}) in site.yml`)
841
1125
  const createdFinalized = extractFinalized(payload)
842
1126
  harvest(createdFinalized)
843
1127
  siteFinalizedDoc = createdFinalized?.[0]?.document || null
@@ -56,6 +56,9 @@ import { detectWorkspacePm, installCmd } from '../utils/pm.js'
56
56
  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
+ import { readUwxDocuments } from '../utils/uwx-read.js'
60
+ import { recordWritten } from '../utils/pull-written.js'
61
+ import { checkFlags } from '../utils/flag-guard.js'
59
62
 
60
63
  const colors = {
61
64
  reset: '\x1b[0m',
@@ -158,6 +161,13 @@ function pullExecArgv(pm, extra) {
158
161
  * runPull(siteDir, pm, extraArgs).
159
162
  */
160
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
+ }
161
171
  const positionals = args.filter((a) => !a.startsWith('-'))
162
172
  const siteUuid = positionals[0]
163
173
  const target = positionals[1] || null // [name|.]
@@ -189,11 +199,19 @@ export async function clone(args = [], deps = {}) {
189
199
  command: 'Cloning'
190
200
  })
191
201
 
192
- // 1. GET the site-content document (no @uniweb/build needed for a read).
202
+ // 1. GET the site-content document.
203
+ //
204
+ // ⛔ The body is a `.uwx` ZIP, not JSON — `application/vnd.uniweb.exchange.
205
+ // entity+zip`, on `content` and `folder` pulls alike. This used to call
206
+ // `res.json()`, which failed on the ZIP magic and reported *"Could not reach
207
+ // the backend: Unexpected token 'P'"* — blaming the network for a body we had
208
+ // received intact and mis-read. Decode with the local reader: this command runs
209
+ // before a project exists, so `@uniweb/build/uwx`'s `readZip` is out of reach
210
+ // (see `utils/uwx-read.js`).
193
211
  info(
194
212
  `Reading site ${colors.bright}${siteUuid}${colors.reset} from ${colors.dim}${client.origin}${colors.reset} …`
195
213
  )
196
- let payload
214
+ let documents
197
215
  try {
198
216
  const res = await client.pullSiteContent(siteUuid)
199
217
  if (res.status === 404) {
@@ -206,13 +224,14 @@ export async function clone(args = [], deps = {}) {
206
224
  note('Run `uniweb login` first (or pass --token <bearer>).')
207
225
  return { exitCode: 1 }
208
226
  }
209
- payload = await res.json()
227
+ documents = readUwxDocuments(Buffer.from(await res.arrayBuffer()))
210
228
  } catch (err) {
211
229
  error(`Could not reach the backend at ${client.origin}: ${err.message}`)
212
230
  return { exitCode: 1 }
213
231
  }
214
232
 
215
- const document = extractDocument(payload)
233
+ const document =
234
+ documents.map(extractDocument).find(Boolean) || null
216
235
  if (!document) {
217
236
  error('The site-content response carried no recognizable document.')
218
237
  return { exitCode: 1 }
@@ -338,6 +357,31 @@ export async function clone(args = [], deps = {}) {
338
357
  }
339
358
  }
340
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
+
341
385
  const pullExtra = []
342
386
  if (explicitBackend) pullExtra.push('--backend', explicitBackend)
343
387
  if (tokenFlag) pullExtra.push('--token', tokenFlag)
@@ -28,6 +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 --org @org Publish under @org (alias: --as-org). Only the
32
+ * FIRST publish of a site reads it — that create is
33
+ * what decides which org owns the site and whose
34
+ * storage its assets are charged to. It is then
35
+ * recorded as `site.yml::$org` and replayed, so it
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.
31
40
  * uniweb publish --no-save Skip the deploy.yml lastDeploy auto-save
32
41
  * uniweb publish --backend <url> Override the backend origin
33
42
  * uniweb publish --token <bearer> Auth bearer (skips `uniweb login`)
@@ -55,7 +64,8 @@ import { resolveDefaultLocale } from '@uniweb/core/locale-config'
55
64
  import { BackendClient } from '../backend/client.js'
56
65
  import { resolveSiteDir, resolveSiteBackend } from './deploy.js'
57
66
  import { warnIfContentDoesNotConform } from '../utils/conformance.js'
58
- import { readFlagValue } from '../utils/args.js'
67
+ import { readFlagValue, readOrgFlag } from '../utils/args.js'
68
+ import { checkFlags } from '../utils/flag-guard.js'
59
69
  import { isNonInteractive } from '../utils/interactive.js'
60
70
  import { headProvenance } from '../utils/git.js'
61
71
  import {
@@ -66,7 +76,8 @@ import {
66
76
  ensureItemUuids,
67
77
  ensureSiteExists,
68
78
  clearRemoteSyncStateIfUnbound,
69
- pushSyncPackages
79
+ pushSyncPackages,
80
+ resolveSiteOrgForCreate
70
81
  } from '../backend/site-sync.js'
71
82
  import { uploadDataBundle } from '../backend/data-bundle.js'
72
83
  import { uploadSiteMedia, describeAssetRefusal } from '../backend/site-media.js'
@@ -165,9 +176,15 @@ async function persistLastDeploy(siteDir, opts) {
165
176
  }
166
177
 
167
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
+ }
168
186
  const dryRun = args.includes('--dry-run')
169
187
  const noSave = args.includes('--no-save')
170
- const asOrg = readFlagValue(args, '--as-org')
171
188
  const foundationDir = readFlagValue(args, '--foundation') // optional local foundation for Model schemas
172
189
 
173
190
  const siteDir = await resolveSiteDir(args, 'publish')
@@ -189,6 +206,25 @@ export async function publish(args = []) {
189
206
  command: 'Publishing'
190
207
  })
191
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
+
192
228
  // Capability handshake (cached). Publish ends in a go-live, so the publish
193
229
  // lane must be offered.
194
230
  const config = await client.discover()
@@ -77,6 +77,11 @@ import {
77
77
  collectUnitUuids
78
78
  } from '@uniweb/build/uwx'
79
79
  import { makeModelResolver } from './push.js'
80
+ import {
81
+ readWritten,
82
+ recordWritten,
83
+ isPullOutput
84
+ } from '../utils/pull-written.js'
80
85
  import {
81
86
  mergeBaseVersions,
82
87
  mergeItemBaseVersions,
@@ -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')