uniweb 0.16.5 → 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 +5 -5
- package/src/backend/site-sync.js +224 -8
- package/src/commands/clone.js +34 -0
- package/src/commands/publish.js +33 -9
- package/src/commands/pull.js +17 -73
- package/src/commands/push.js +37 -12
- package/src/commands/register.js +8 -0
- package/src/commands/status.js +9 -0
- package/src/framework-index.json +2 -2
- package/src/index.js +10 -5
- 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/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/core": "^0.8.5",
|
|
45
44
|
"@uniweb/kit": "^0.11.3",
|
|
46
|
-
"@uniweb/
|
|
45
|
+
"@uniweb/core": "^0.8.5",
|
|
46
|
+
"@uniweb/runtime": "^0.11.6"
|
|
47
47
|
},
|
|
48
48
|
"peerDependencies": {
|
|
49
49
|
"@uniweb/build": "^0.18.5",
|
|
50
|
-
"@uniweb/
|
|
51
|
-
"@uniweb/
|
|
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
|
@@ -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
|
|
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
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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)
|
package/src/commands/clone.js
CHANGED
|
@@ -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)
|
package/src/commands/publish.js
CHANGED
|
@@ -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 --
|
|
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
|
-
|
|
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()
|
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')
|
package/src/commands/push.js
CHANGED
|
@@ -26,11 +26,15 @@
|
|
|
26
26
|
*
|
|
27
27
|
* Usage:
|
|
28
28
|
* uniweb push Build, push both lanes, back-fill $uuid
|
|
29
|
-
* uniweb push --
|
|
30
|
-
*
|
|
31
|
-
* which org owns it
|
|
32
|
-
* assets are charged to
|
|
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
|
-
|
|
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`
|
package/src/commands/register.js
CHANGED
|
@@ -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
|
package/src/commands/status.js
CHANGED
|
@@ -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')
|
package/src/framework-index.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"schemaVersion": 1,
|
|
3
|
-
"generatedAt": "2026-08-12T22:
|
|
3
|
+
"generatedAt": "2026-08-12T22:56:55.282Z",
|
|
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.
|
|
76
|
+
"version": "0.11.6",
|
|
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
|
-
--
|
|
1322
|
-
FIRST publish — that create decides which org
|
|
1323
|
-
and whose storage its assets are charged to.
|
|
1324
|
-
site.yml \$org and replayed, so it is never
|
|
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
|
-
--
|
|
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,89 @@
|
|
|
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
|
+
* Per-verb flag sets. Derived by scanning each command for dash-literals AND the
|
|
35
|
+
* helpers it calls — not from the help text, which has drifted from the parser in
|
|
36
|
+
* both directions (`--as-org` was implemented and undocumented; `--yes` is
|
|
37
|
+
* documented on `publish` and consumed two files away).
|
|
38
|
+
*/
|
|
39
|
+
export const VERB_FLAGS = {
|
|
40
|
+
push: [
|
|
41
|
+
'--all', '--as-org', '--org', '--backend', '--dry-run', '--force',
|
|
42
|
+
'--foundation', '--output', '-o', '--personal', '--registry', '--token',
|
|
43
|
+
'--no-validate'
|
|
44
|
+
],
|
|
45
|
+
publish: [
|
|
46
|
+
'--as-org', '--org', '--backend', '--dry-run', '--force', '--foundation',
|
|
47
|
+
'--no-save', '--personal', '--registry', '--token', '--no-validate', '--yes'
|
|
48
|
+
],
|
|
49
|
+
pull: [
|
|
50
|
+
'--backend', '--content-only', '--dry-run', '--force', '--merge',
|
|
51
|
+
'--no-collections', '--no-delete', '--no-prune', '--registry', '--token'
|
|
52
|
+
],
|
|
53
|
+
clone: [
|
|
54
|
+
'--backend', '--content-only', '--no-collections', '--path', '--project',
|
|
55
|
+
'--registry', '--token'
|
|
56
|
+
],
|
|
57
|
+
register: [
|
|
58
|
+
'--backend', '--dry-run', '--json', '--output', '-o', '--registry',
|
|
59
|
+
'--schema-only', '--scope', '--token'
|
|
60
|
+
],
|
|
61
|
+
status: ['--backend', '--json', '--registry', '--remote', '--token']
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Check `args` against the verb's accepted set. Returns null when everything is
|
|
66
|
+
* recognized, or a ready-to-print message naming the first offender (plus a
|
|
67
|
+
* suggestion when one is close).
|
|
68
|
+
*
|
|
69
|
+
* Reports ONE flag rather than all of them: the first is usually the cause, and a
|
|
70
|
+
* list invites skimming past the suggestion, which is the actionable half.
|
|
71
|
+
*
|
|
72
|
+
* @param {string} verb - a key of VERB_FLAGS
|
|
73
|
+
* @param {string[]} args - the argv slice for this command
|
|
74
|
+
* @returns {{ flag: string, message: string, suggestion: string|null }|null}
|
|
75
|
+
*/
|
|
76
|
+
export function checkFlags(verb, args = []) {
|
|
77
|
+
const known = VERB_FLAGS[verb]
|
|
78
|
+
if (!known) return null
|
|
79
|
+
const all = [...known, ...GLOBAL]
|
|
80
|
+
const unknown = findUnknownFlags(args, all)
|
|
81
|
+
if (!unknown.length) return null
|
|
82
|
+
|
|
83
|
+
const flag = unknown[0]
|
|
84
|
+
const suggestion = didYouMean(flag, all)
|
|
85
|
+
const lines = [`Unknown flag \`${flag}\` for \`uniweb ${verb}\`.`]
|
|
86
|
+
if (suggestion) lines.push(` Did you mean \`${suggestion}\`?`)
|
|
87
|
+
lines.push(` Run \`uniweb ${verb} --help\` for the accepted flags.`)
|
|
88
|
+
return { flag, suggestion, message: lines.join('\n') }
|
|
89
|
+
}
|
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
|
-
*
|
|
89
|
-
*
|
|
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
|
+
}
|