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 +7 -7
- package/src/backend/site-sync.js +288 -4
- package/src/commands/clone.js +48 -4
- package/src/commands/publish.js +39 -3
- package/src/commands/pull.js +17 -73
- package/src/commands/push.js +39 -3
- package/src/commands/register.js +8 -0
- package/src/commands/status.js +9 -0
- package/src/framework-index.json +8 -8
- package/src/index.js +10 -0
- package/src/utils/args.js +98 -0
- package/src/utils/flag-guard.js +89 -0
- package/src/utils/git.js +43 -4
- package/src/utils/pull-written.js +127 -0
- package/src/utils/uwx-read.js +157 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "uniweb",
|
|
3
|
-
"version": "0.
|
|
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/
|
|
45
|
-
"@uniweb/
|
|
46
|
-
"@uniweb/runtime": "^0.11.
|
|
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.
|
|
50
|
-
"@uniweb/
|
|
51
|
-
"@uniweb/
|
|
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": {
|
package/src/backend/site-sync.js
CHANGED
|
@@ -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
|
|
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
|
-
|
|
488
|
-
|
|
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
|
package/src/commands/clone.js
CHANGED
|
@@ -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
|
|
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
|
|
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
|
-
|
|
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 =
|
|
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)
|
package/src/commands/publish.js
CHANGED
|
@@ -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()
|
package/src/commands/pull.js
CHANGED
|
@@ -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
|
-
//
|
|
359
|
-
//
|
|
360
|
-
//
|
|
361
|
-
//
|
|
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')
|