uniweb 1.20.4 → 1.21.1
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/partials/agents.md +24 -5
- package/partials/start.md +17 -0
- package/src/backend/app-changes.js +159 -0
- package/src/backend/client.js +3 -3
- package/src/backend/foundation-bring-along.js +1 -1
- package/src/backend/service-request.js +21 -5
- package/src/commands/doctor.js +89 -10
- package/src/commands/pull.js +82 -9
- package/src/commands/register.js +15 -15
- package/src/commands/status.js +1020 -256
- package/src/framework-index.json +2 -2
- package/src/index.js +38 -2
- package/src/utils/code-upload.js +27 -27
- package/src/utils/flag-guard.js +17 -2
- package/src/utils/git.js +91 -1
- package/src/utils/status-next.js +47 -0
- package/src/utils/sync-point.js +20 -2
- package/src/utils/update-check.js +8 -6
- package/templates/site/site.yml.hbs +4 -5
package/src/commands/pull.js
CHANGED
|
@@ -150,10 +150,12 @@ const colors = {
|
|
|
150
150
|
green: '\x1b[32m',
|
|
151
151
|
blue: '\x1b[36m'
|
|
152
152
|
}
|
|
153
|
-
// Looked up at each call, so a caller that captures the console captures these too
|
|
154
|
-
|
|
153
|
+
// Looked up at each call, so a caller that captures the console captures these too — and
|
|
154
|
+
// silent while a probe runs (`probePull`), which answers by what it returns.
|
|
155
|
+
let quiet = false
|
|
156
|
+
const log = (...a) => quiet || console.log(...a)
|
|
155
157
|
const success = (m) => log(`${colors.green}✓${colors.reset} ${m}`)
|
|
156
|
-
const error = (m) => console.error(`${colors.red}✗${colors.reset} ${m}`)
|
|
158
|
+
const error = (m) => quiet || console.error(`${colors.red}✗${colors.reset} ${m}`)
|
|
157
159
|
const info = (m) => log(`${colors.blue}→${colors.reset} ${m}`)
|
|
158
160
|
const note = (m) => log(` ${colors.dim}${m}${colors.reset}`)
|
|
159
161
|
|
|
@@ -238,7 +240,7 @@ function pullCachePath(siteDir) {
|
|
|
238
240
|
// Ignoring those once costs one full pull. (In a repository an ETag is echoed only while
|
|
239
241
|
// the sync point it was banked with is in the history — see `cacheHolds` below.)
|
|
240
242
|
const PULL_CACHE_VERSION = 2
|
|
241
|
-
function readPullCache(siteDir) {
|
|
243
|
+
export function readPullCache(siteDir) {
|
|
242
244
|
try {
|
|
243
245
|
const obj = JSON.parse(readFileSync(pullCachePath(siteDir), 'utf8'))
|
|
244
246
|
return obj && typeof obj === 'object' && obj.version >= PULL_CACHE_VERSION ? obj : {}
|
|
@@ -246,6 +248,21 @@ function readPullCache(siteDir) {
|
|
|
246
248
|
return {}
|
|
247
249
|
}
|
|
248
250
|
}
|
|
251
|
+
|
|
252
|
+
/**
|
|
253
|
+
* Whether the ETags this copy banked still describe its files — the condition a
|
|
254
|
+
* conditional GET is sent on. Outside git, always. In a repository, only while HEAD's
|
|
255
|
+
* history holds the sync point they were banked with: a merge abandoned, a branch reset,
|
|
256
|
+
* and the backend did not move but the files did.
|
|
257
|
+
*
|
|
258
|
+
* One rule for both readers of the lanes — this pull and `uniweb status`, which asks the
|
|
259
|
+
* same question without taking the answer.
|
|
260
|
+
*/
|
|
261
|
+
export function etagsHold(siteDir, origin, repo = repoOf(siteDir)) {
|
|
262
|
+
if (!repo) return true
|
|
263
|
+
const held = readSyncPoint(siteDir, origin)
|
|
264
|
+
return Boolean(held && isAncestor(repo, held))
|
|
265
|
+
}
|
|
249
266
|
function writePullCache(siteDir, { content, folder }) {
|
|
250
267
|
const p = pullCachePath(siteDir)
|
|
251
268
|
mkdirSync(dirname(p), { recursive: true })
|
|
@@ -553,6 +570,34 @@ const pulledFrom = (origin) => `The site's content on ${origin}`
|
|
|
553
570
|
const mergedFrom = (origin) => `Merge the site's content from ${origin}`
|
|
554
571
|
const takenFrom = (origin) => `Take the site's content from ${origin}`
|
|
555
572
|
|
|
573
|
+
/**
|
|
574
|
+
* What a pull would change in the files, and nothing more — for `uniweb status`.
|
|
575
|
+
*
|
|
576
|
+
* ⭐ THE PULL ITSELF, STOPPED BEFORE IT TAKES ANYTHING. The same requests, the same base,
|
|
577
|
+
* the same projection onto it in a stand-in — so status and pull cannot disagree about
|
|
578
|
+
* which files the backend changed: a section's translations (`locales/*.json`) and a
|
|
579
|
+
* record's file are named as the pull would write them. It downloads no media, merges
|
|
580
|
+
* nothing, commits nothing and banks nothing — no ETag, version, hash or sync point —
|
|
581
|
+
* and prints nothing.
|
|
582
|
+
*
|
|
583
|
+
* Null when a probe cannot answer: outside git, where a pull writes into the files; in a
|
|
584
|
+
* repository with no commit or no sync point to merge from; when a lane failed or the
|
|
585
|
+
* workspace was refused. The caller then says what it can by other means.
|
|
586
|
+
*
|
|
587
|
+
* @param {string[]} [args] - the workspace flags only (`--org`, `--personal`)
|
|
588
|
+
* @param {object} [deps] - as for `pull`; `getToken` keeps it from ever asking for a login
|
|
589
|
+
* @returns {Promise<{ files: Array<[string, string]>, recordsNotPlaced: boolean } | null>}
|
|
590
|
+
* `files` — `[status, path]`, status `A`, `M` or `D`, the path relative to the site
|
|
591
|
+
*/
|
|
592
|
+
export async function probePull(args = [], deps = {}) {
|
|
593
|
+
quiet = true
|
|
594
|
+
try {
|
|
595
|
+
return (await pull(args, { ...deps, probe: true }))?.probe ?? null
|
|
596
|
+
} finally {
|
|
597
|
+
quiet = false
|
|
598
|
+
}
|
|
599
|
+
}
|
|
600
|
+
|
|
556
601
|
export async function pull(args = [], deps = {}) {
|
|
557
602
|
// See utils/flag-guard.js — an unrecognized flag is invisible to a literal scan, so
|
|
558
603
|
// it silently keeps the default: a mistyped `--dry-run` would pull for real, and a
|
|
@@ -572,13 +617,20 @@ export async function pull(args = [], deps = {}) {
|
|
|
572
617
|
const force = args.includes('--force')
|
|
573
618
|
|
|
574
619
|
const siteDir = await resolveSiteDir(args, 'pull')
|
|
575
|
-
|
|
620
|
+
// ⛔ A probe (`probePull`) fetches no media: it says what changes, and takes nothing.
|
|
621
|
+
const probe = Boolean(deps.probe)
|
|
622
|
+
const fetchAssets = !probe && shouldFetchAssets(args, siteDir)
|
|
576
623
|
|
|
577
624
|
// ⭐ IN A REPOSITORY A PULL MERGES; OUTSIDE ONE IT CHECKS OUT. A merge needs the state both
|
|
578
625
|
// sides started from, and only a history holds one (`utils/sync-point.js`). Outside git
|
|
579
626
|
// a pull rewrites the files, so work there that a pull did not write stops it.
|
|
580
627
|
const repo = repoOf(siteDir)
|
|
581
|
-
|
|
628
|
+
// A probe writes onto a commit in a stand-in, never into the files: with no repository,
|
|
629
|
+
// or no commit to write onto, it has nothing to say.
|
|
630
|
+
if (probe && !repo?.head) return { exitCode: 0, probe: null }
|
|
631
|
+
// …and touching nothing here, it is not stopped by what a merge would be: a conflict or a
|
|
632
|
+
// merge waiting is the caller's to report.
|
|
633
|
+
if (repo && !dryRun && !probe) {
|
|
582
634
|
if (!repo.head) {
|
|
583
635
|
error('This repository has no commits yet, so there is nothing for a pull to merge into.')
|
|
584
636
|
note('Commit the project, then pull.')
|
|
@@ -616,7 +668,9 @@ export async function pull(args = [], deps = {}) {
|
|
|
616
668
|
command: 'Pulling'
|
|
617
669
|
})
|
|
618
670
|
// One reader of Models for the whole pull, so a Model is read once whichever lane asks first.
|
|
619
|
-
|
|
671
|
+
// ⛔ A probe keeps none: a Model kept here is what a later offline emit resolves with, and a
|
|
672
|
+
// probe takes nothing (`probePull`).
|
|
673
|
+
const readModel = makeModelResolver({ client, siteDir: probe ? null : siteDir })
|
|
620
674
|
|
|
621
675
|
// ⭐ No scope check any more — and none is needed. This project's identity on
|
|
622
676
|
// `client.origin` is read from that origin's own section of sync.json, so a
|
|
@@ -688,6 +742,9 @@ export async function pull(args = [], deps = {}) {
|
|
|
688
742
|
// body). `doRequest` is a thunk returning the client's Response promise. 404 / any
|
|
689
743
|
// failure → null (the lane is skipped, not fatal) — except a workspace mismatch,
|
|
690
744
|
// `{ refused: true }`, which stops the pull: the other lane would only say it again.
|
|
745
|
+
// A lane that answered 404 is named in `missing`, so a probe can tell a site with no
|
|
746
|
+
// records lane from a lane that did not answer.
|
|
747
|
+
const missing = new Set()
|
|
691
748
|
const getDocs = async (label, doRequest) => {
|
|
692
749
|
info(
|
|
693
750
|
`Pulling ${colors.bright}${label}${colors.reset} from ${colors.dim}${client.origin}${colors.reset} …`
|
|
@@ -703,6 +760,7 @@ export async function pull(args = [], deps = {}) {
|
|
|
703
760
|
}
|
|
704
761
|
if (res.status === 404) {
|
|
705
762
|
note(`${label}: not found (404) — it was deleted, or you lack access.`)
|
|
763
|
+
missing.add(label)
|
|
706
764
|
return null
|
|
707
765
|
}
|
|
708
766
|
if (res.status === 304) {
|
|
@@ -767,7 +825,7 @@ export async function pull(args = [], deps = {}) {
|
|
|
767
825
|
// only while the sync point it was banked with is.
|
|
768
826
|
const cache = readPullCache(siteDir)
|
|
769
827
|
const held = repo ? readSyncPoint(siteDir, client.origin) : null
|
|
770
|
-
const cacheHolds =
|
|
828
|
+
const cacheHolds = etagsHold(siteDir, client.origin, repo)
|
|
771
829
|
let etagContent = cache.content
|
|
772
830
|
let etagFolder = cache.folder
|
|
773
831
|
|
|
@@ -805,6 +863,12 @@ export async function pull(args = [], deps = {}) {
|
|
|
805
863
|
? splitRecordsPull(folderLane.docs)
|
|
806
864
|
: null
|
|
807
865
|
const projected = Boolean(siteDoc) || Boolean(folderTaken)
|
|
866
|
+
if (probe) {
|
|
867
|
+
// A lane that did not answer is not knowing — a records lane the backend has none of
|
|
868
|
+
// (404) is no records; both unchanged is nothing to take.
|
|
869
|
+
if (!content || (!noRecords && !folderLane && !missing.has('records'))) return { exitCode: 1, probe: null }
|
|
870
|
+
if (!projected) return { exitCode: 0, probe: { files: [], recordsNotPlaced: false } }
|
|
871
|
+
}
|
|
808
872
|
|
|
809
873
|
// ── What the project itself says, read before anything is written ──────────
|
|
810
874
|
// The foundation, the Models and the scope come from the project as it is: in a
|
|
@@ -935,6 +999,9 @@ export async function pull(args = [], deps = {}) {
|
|
|
935
999
|
origin: client.origin,
|
|
936
1000
|
siteUuid: siteContentUuid
|
|
937
1001
|
})
|
|
1002
|
+
// A probe with no base has no way to tell the backend's changes from this copy's: onto
|
|
1003
|
+
// HEAD, every difference would read as the backend's.
|
|
1004
|
+
if (probe && !base) return { exitCode: 0, probe: null }
|
|
938
1005
|
onto = force || !base ? repo.head : base
|
|
939
1006
|
try {
|
|
940
1007
|
standIn = openStandIn(repo, { roots, rev: onto })
|
|
@@ -1056,7 +1123,7 @@ export async function pull(args = [], deps = {}) {
|
|
|
1056
1123
|
// ⭐ A FILE RECORD'S BYTES (`@uniweb/file`, 2026-09-28): fetched to where `folder.yml` now
|
|
1057
1124
|
// places each one, as media are fetched. A file that did not arrive is a record not taken —
|
|
1058
1125
|
// reported, and the pull fails below as it does for any, so the next one fetches it again.
|
|
1059
|
-
if (report.fileDownloads?.length) {
|
|
1126
|
+
if (report.fileDownloads?.length && !probe) {
|
|
1060
1127
|
const got = await downloadFileRecords({
|
|
1061
1128
|
downloads: report.fileDownloads,
|
|
1062
1129
|
siteDir: projDir,
|
|
@@ -1091,6 +1158,12 @@ export async function pull(args = [], deps = {}) {
|
|
|
1091
1158
|
for (const w of report.warnings) note(`! ${w}`)
|
|
1092
1159
|
}
|
|
1093
1160
|
|
|
1161
|
+
// ⭐ A PROBE STOPS HERE, the stand-in holding what this pull would take: what differs
|
|
1162
|
+
// from the base is what the backend changed. Nothing is banked; `finally` closes it.
|
|
1163
|
+
if (probe) {
|
|
1164
|
+
return { exitCode: 0, probe: { files: standIn.changes(), recordsNotPlaced: Boolean(recordsNotPlaced) } }
|
|
1165
|
+
}
|
|
1166
|
+
|
|
1094
1167
|
// Outside git: what this pull wrote, so the next one can tell its own output from the
|
|
1095
1168
|
// author's work. Config files are rewritten every pull whether or not they changed;
|
|
1096
1169
|
// include the ones the projector owns so a pull-then-pull doesn't trip on them either.
|
package/src/commands/register.js
CHANGED
|
@@ -385,21 +385,6 @@ export async function settleFoundationScope(targetDir, { args, isPreview, flagSc
|
|
|
385
385
|
return { scope, source }
|
|
386
386
|
}
|
|
387
387
|
|
|
388
|
-
/**
|
|
389
|
-
* The scope a bare name registers under when `--scope` names none — as `@handle`, or null
|
|
390
|
-
* when none was chosen.
|
|
391
|
-
*
|
|
392
|
-
* ⭐ THE WORKSPACE THIS COMMAND WORKS IN *[Diego, 2026-10-06]* — an organization's handle,
|
|
393
|
-
* or your own for your personal workspace — said, not asked (`scopeOfWorkspace`). A team
|
|
394
|
-
* working in `@acme` registers `@acme/…`, which any of its members can release.
|
|
395
|
-
* ⛔ Until 2026-10-06 the default was your personal scope wherever you worked, so a team's
|
|
396
|
-
* foundation registered as `@jane/…` — releasable by Jane alone, and fixed only by
|
|
397
|
-
* renaming it into a new foundation.
|
|
398
|
-
*
|
|
399
|
-
* When the workspace names no scope — none is chosen, or it is a unit without a handle —
|
|
400
|
-
* the scope is derived from your orgs as before (`deriveScope`): your personal scope when
|
|
401
|
-
* you belong to none, else a pick at a terminal, your personal scope without one.
|
|
402
|
-
*/
|
|
403
388
|
/**
|
|
404
389
|
* Refuse a reserved scope wherever it came from, before anything is written or sent —
|
|
405
390
|
* `@uniweb` names the framework's own packages and a system account on every Uniweb
|
|
@@ -417,6 +402,21 @@ function refusedReservedScope(scope, lead) {
|
|
|
417
402
|
return true
|
|
418
403
|
}
|
|
419
404
|
|
|
405
|
+
/**
|
|
406
|
+
* The scope a bare name registers under when `--scope` names none — as `@handle`, or null
|
|
407
|
+
* when none was chosen.
|
|
408
|
+
*
|
|
409
|
+
* ⭐ THE WORKSPACE THIS COMMAND WORKS IN *[Diego, 2026-10-06]* — an organization's handle,
|
|
410
|
+
* or your own for your personal workspace — said, not asked (`scopeOfWorkspace`). A team
|
|
411
|
+
* working in `@acme` registers `@acme/…`, which any of its members can release.
|
|
412
|
+
* ⛔ Until 2026-10-06 the default was your personal scope wherever you worked, so a team's
|
|
413
|
+
* foundation registered as `@jane/…` — releasable by Jane alone, and fixed only by
|
|
414
|
+
* renaming it into a new foundation.
|
|
415
|
+
*
|
|
416
|
+
* When the workspace names no scope — none is chosen, or it is a unit without a handle —
|
|
417
|
+
* the scope is derived from your orgs as before (`deriveScope`): your personal scope when
|
|
418
|
+
* you belong to none, else a pick at a terminal, your personal scope without one.
|
|
419
|
+
*/
|
|
420
420
|
async function deriveScopeFromLogin(client, args) {
|
|
421
421
|
const token = await client.token()
|
|
422
422
|
const ws = await resolveWorkspace({ client, args })
|