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.
@@ -26,7 +26,15 @@
26
26
  *
27
27
  * Usage:
28
28
  * uniweb push Build, push both lanes, back-fill $uuid
29
- * uniweb push --as-org @org Act as @org (membership-gated)
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
+ * `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.
30
38
  * uniweb push --dry-run Report what would be pushed; submit nothing
31
39
  * uniweb push -o out.uwx Write the .uwx file(s) per lane; submit nothing
32
40
  * uniweb push --registry <url> Override the backend origin
@@ -59,6 +67,8 @@ import { uploadSiteMedia, describeAssetRefusal } from '../backend/site-media.js'
59
67
  import { BackendClient } from '../backend/client.js'
60
68
  import { resolveSiteDir, resolveSiteBackend } from './deploy.js'
61
69
  import { warnIfContentDoesNotConform } from '../utils/conformance.js'
70
+ import { readOrgFlag } from '../utils/args.js'
71
+ import { checkFlags } from '../utils/flag-guard.js'
62
72
  import {
63
73
  makeModelResolver,
64
74
  readSyncCache,
@@ -68,7 +78,8 @@ import {
68
78
  ensureItemUuids,
69
79
  ensureSiteExists,
70
80
  clearRemoteSyncStateIfUnbound,
71
- pushSyncPackages
81
+ pushSyncPackages,
82
+ resolveSiteOrgForCreate
72
83
  } from '../backend/site-sync.js'
73
84
 
74
85
  // Re-exported for downstream importers (pull.js, push.test.js) that read these
@@ -103,10 +114,16 @@ function flagValue(args, name) {
103
114
  }
104
115
 
105
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
+ }
106
124
  const dryRun = args.includes('--dry-run')
107
125
  const output = flagValue(args, '-o') || flagValue(args, '--output')
108
126
  const tokenFlag = flagValue(args, '--token')
109
- const asOrg = flagValue(args, '--as-org')
110
127
  const foundationDir = flagValue(args, '--foundation')
111
128
  const sendAll = args.includes('--all') // bypass the send-only-changed cache
112
129
  // --force drops the optimistic-concurrency precondition, making the push
@@ -135,6 +152,25 @@ export async function push(args = []) {
135
152
  command: 'Syncing'
136
153
  })
137
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
+
138
174
  // Build BOTH directional packages (the producer side). Each carries its own
139
175
  // `index` — the per-entity source-file map for back-fill, correlated by submission
140
176
  // position. Non-local Models are fetched from the registry on demand. `priorHashes`
@@ -81,6 +81,7 @@ import {
81
81
  promptSelect
82
82
  } from '../utils/workspace.js'
83
83
  import { isNonInteractive, getCliPrefix } from '../utils/interactive.js'
84
+ import { checkFlags } from '../utils/flag-guard.js'
84
85
 
85
86
  const colors = {
86
87
  reset: '\x1b[0m',
@@ -265,6 +266,13 @@ export function foundationNeedsBuild(targetDir) {
265
266
  }
266
267
 
267
268
  export async function register(args = []) {
269
+ // See utils/flag-guard.js — an unrecognized flag is invisible to a
270
+ // literal scan, so it silently keeps the default (production, for --backend).
271
+ const badFlag = checkFlags('register', args)
272
+ if (badFlag) {
273
+ error(badFlag.message)
274
+ return { exitCode: 2 }
275
+ }
268
276
  jsonMode = args.includes('--json')
269
277
  jsonEmitted = false
270
278
  lastError = null
@@ -30,6 +30,7 @@ import { BackendClient } from '../backend/client.js'
30
30
  import { readFlagValue } from '../utils/args.js'
31
31
  import { resolveLocalFoundation } from '../backend/foundation-bring-along.js'
32
32
  import { computeFoundationDigest } from '../utils/code-upload.js'
33
+ import { checkFlags } from '../utils/flag-guard.js'
33
34
 
34
35
  const c = {
35
36
  reset: '\x1b[0m',
@@ -73,6 +74,14 @@ function splitFoundationRef(fnd) {
73
74
  }
74
75
 
75
76
  export async function status(args = []) {
77
+ // See utils/flag-guard.js — an unrecognized flag is invisible to a literal scan.
78
+ // Straight to stderr: this is a usage error, so it must not be mistaken for the
79
+ // status document even under --json.
80
+ const badFlag = checkFlags('status', args)
81
+ if (badFlag) {
82
+ console.error(`\x1b[31m✗\x1b[0m ${badFlag.message}`)
83
+ return { exitCode: 2 }
84
+ }
76
85
  const jsonMode = args.includes('--json')
77
86
  const remote = args.includes('--remote')
78
87
  const siteDir = await resolveSiteDir(args, 'status')
@@ -1,9 +1,9 @@
1
1
  {
2
2
  "schemaVersion": 1,
3
- "generatedAt": "2026-08-11T13:40:25.377Z",
3
+ "generatedAt": "2026-08-12T22:56:55.282Z",
4
4
  "packages": {
5
5
  "@uniweb/build": {
6
- "version": "0.18.4",
6
+ "version": "0.18.5",
7
7
  "path": "framework/build",
8
8
  "deps": [
9
9
  "@uniweb/content-reader",
@@ -28,7 +28,7 @@
28
28
  "deps": []
29
29
  },
30
30
  "@uniweb/core": {
31
- "version": "0.8.4",
31
+ "version": "0.8.5",
32
32
  "path": "framework/core",
33
33
  "deps": [
34
34
  "@uniweb/semantic-parser",
@@ -46,7 +46,7 @@
46
46
  "deps": []
47
47
  },
48
48
  "@uniweb/kit": {
49
- "version": "0.11.2",
49
+ "version": "0.11.3",
50
50
  "path": "framework/kit",
51
51
  "deps": [
52
52
  "@uniweb/core",
@@ -65,7 +65,7 @@
65
65
  "deps": []
66
66
  },
67
67
  "@uniweb/projections": {
68
- "version": "0.2.7",
68
+ "version": "0.2.8",
69
69
  "path": "framework/projections",
70
70
  "deps": [
71
71
  "@uniweb/content-writer",
@@ -73,7 +73,7 @@
73
73
  ]
74
74
  },
75
75
  "@uniweb/runtime": {
76
- "version": "0.11.4",
76
+ "version": "0.11.6",
77
77
  "path": "framework/runtime",
78
78
  "deps": [
79
79
  "@uniweb/core",
@@ -96,7 +96,7 @@
96
96
  "deps": []
97
97
  },
98
98
  "@uniweb/semantic-parser": {
99
- "version": "1.2.1",
99
+ "version": "1.2.2",
100
100
  "path": "framework/semantic-parser",
101
101
  "deps": []
102
102
  },
@@ -111,7 +111,7 @@
111
111
  "deps": []
112
112
  },
113
113
  "@uniweb/unipress": {
114
- "version": "0.8.3",
114
+ "version": "0.8.4",
115
115
  "path": "framework/unipress",
116
116
  "deps": [
117
117
  "@uniweb/build",
package/src/index.js CHANGED
@@ -1318,6 +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
+ --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).
1321
1329
  --backend <url> Backend origin (default: \$UNIWEB_REGISTER_URL or built-in)
1322
1330
  --token <bearer> Auth bearer (skips \`uniweb login\`)
1323
1331
  `,
@@ -1693,6 +1701,8 @@ ${colors.bright}Global Options:${colors.reset}
1693
1701
  ${colors.bright}Publish Options:${colors.reset}
1694
1702
  --dry-run Resolve everything; release/sync/POST nothing
1695
1703
  --yes Skip confirmations (CI); never block on a prompt
1704
+ --org @org Publish under @org (first publish only; then remembered)
1705
+ --personal Own the new site personally, deliberately (first publish only)
1696
1706
  --no-save Skip the deploy.yml lastDeploy auto-save
1697
1707
  --no-validate Skip the content-conformance check (it only warns)
1698
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
- * @returns {string[]|null} repo-relative-ish paths as git reports them, or `null`
89
- * when this isn't a git work tree (distinct from `[]`, which means "clean").
88
+ * **Returned relative to `dir`, which is NOT what git prints.** `--porcelain`
89
+ * paths are always relative to the REPOSITORY ROOT, whatever directory it runs
90
+ * in, so they only coincide with `dir`-relative when the site *is* the repo root.
91
+ * Both callers do `join(siteDir, rel)` with the result, so the un-normalized form
92
+ * was wrong for every nested layout — a site at `myproject/site/` inside a repo.
93
+ *
94
+ * What that cost, measured 2026-08-12: pull exempts its own previous output from
95
+ * the uncommitted-work guard by looking each dirty path up in a record keyed
96
+ * `dir`-relative (`utils/pull-written.js`). Given `myproject/site/site.yml` the
97
+ * lookup missed, so **pull refused on files pull itself had written** — the exact
98
+ * false alarm that record exists to prevent, and the one its own comment warns
99
+ * teaches people to reach for `--force`. `captureLocalWork` had the same defect
100
+ * one door along: it reads each path back with `join(siteDir, rel)`.
101
+ *
102
+ * The earlier `@returns` said "repo-relative-ish paths as git reports them" while
103
+ * the summary above said "relative to `dir`" — the ambiguity was noticed and left,
104
+ * and both callers had picked the other reading.
105
+ *
106
+ * @returns {string[]|null} paths relative to `dir`, or `null` when this isn't a
107
+ * git work tree (distinct from `[]`, which means "clean").
90
108
  */
91
109
  export function uncommittedUnder(dir, relPaths) {
92
110
  if (!isGitRepo(dir)) return null
111
+ // ⚠️ Both sides must be REAL paths before `relative` can compare them.
112
+ // `rev-parse --show-toplevel` resolves symlinks; the caller's `dir` usually has
113
+ // not. On macOS that alone breaks it — `/var` is a symlink to `/private/var`,
114
+ // so a repo under the temp dir yields a root of `/private/var/…` against a dir
115
+ // of `/var/…`, and `relative` walks all the way up and back down. The result is
116
+ // a valid-looking path that matches nothing, i.e. the same silent miss this
117
+ // normalization exists to fix. Any symlinked checkout does it, not just tmp.
118
+ let root
119
+ let base
120
+ try {
121
+ root = git(['rev-parse', '--show-toplevel'], dir).trim()
122
+ base = realpathSync(dir)
123
+ } catch {
124
+ return null
125
+ }
93
126
  try {
94
127
  const out = git(
95
128
  ['status', '--porcelain', '--untracked-files=all', '--', ...relPaths],
@@ -102,6 +135,12 @@ export function uncommittedUnder(dir, relPaths) {
102
135
  // porcelain v1: XY<space>path, and a rename is "orig -> new".
103
136
  .map((line) => line.slice(3).trim())
104
137
  .map((p) => (p.includes(' -> ') ? p.split(' -> ')[1] : p))
138
+ // Porcelain paths are repo-root-relative; callers want them relative to
139
+ // `dir`. Quoted paths (git quotes names with spaces or non-ASCII when
140
+ // `core.quotePath` is on) are left alone rather than half-decoded — a
141
+ // wrong path is worse than an unmatched one here, since an unmatched
142
+ // path merely stays "dirty" and the guard errs toward refusing.
143
+ .map((p) => (p.startsWith('"') ? p : relative(base, join(root, p))))
105
144
  .filter(Boolean)
106
145
  )
107
146
  } catch {
@@ -0,0 +1,127 @@
1
+ /**
2
+ * The record of what a machine wrote into a site project — pull's own output.
3
+ *
4
+ * ## What it is for
5
+ *
6
+ * `uniweb pull` refuses to run when there are uncommitted changes under the files
7
+ * it rewrites, because it reconciles the working tree to the backend and would
8
+ * overwrite them. That guard needs one distinction to be useful: **a file the
9
+ * user edited** versus **a file a previous pull wrote and nobody has touched
10
+ * since**. Without it the guard cries wolf — pull rewrites the tree, so the next
11
+ * pull sees its own output as uncommitted work and refuses, listing files the
12
+ * user never touched. A guard that fires on nothing teaches people to reach for
13
+ * `--force`, which is the destructive option.
14
+ *
15
+ * So each write is recorded with a content hash, and a dirty path whose hash
16
+ * still matches is not user work.
17
+ *
18
+ * ## Why it lives here rather than in `pull.js`
19
+ *
20
+ * `uniweb clone` has the same claim to make and cannot make it from there.
21
+ * `pull.js` statically imports `@uniweb/build`, which resolves from the
22
+ * *project's* `node_modules` — and `clone` runs before a project exists (see
23
+ * `utils/uwx-read.js` for the same constraint on the `.uwx` reader).
24
+ *
25
+ * Clone scaffolds `site.yml` and `theme.yml` and then delegates to `pull`, so
26
+ * without this the delegated pull sees clone's own scaffolding as uncommitted
27
+ * user work and refuses — on a project created seconds earlier, where there is no
28
+ * user work to protect. Clone records what it wrote; the guard then exempts those
29
+ * files **for the right reason** rather than being overridden.
30
+ *
31
+ * Nothing here imports `@uniweb/build`, and nothing here may start to.
32
+ */
33
+
34
+ import { readFileSync, writeFileSync, mkdirSync } from 'node:fs'
35
+ import { createHash } from 'node:crypto'
36
+ import { join, dirname, relative } from 'node:path'
37
+
38
+ /** Where the record lives — gitignored, beside the other per-site caches. */
39
+ export function writtenCachePath(siteDir) {
40
+ return join(siteDir, '.uniweb', 'pull-written.json')
41
+ }
42
+
43
+ /**
44
+ * @param {string} siteDir
45
+ * @returns {{files: Record<string,string>, deleted: string[]}}
46
+ */
47
+ export function readWritten(siteDir) {
48
+ try {
49
+ const o = JSON.parse(readFileSync(writtenCachePath(siteDir), 'utf8'))
50
+ return {
51
+ files: o && typeof o.files === 'object' ? o.files : {},
52
+ deleted: Array.isArray(o?.deleted) ? o.deleted : []
53
+ }
54
+ } catch {
55
+ return { files: {}, deleted: [] }
56
+ }
57
+ }
58
+
59
+ /**
60
+ * Record files as machine-written, by absolute path.
61
+ *
62
+ * MERGE, don't replace. A conditional pull that 304s writes nothing, and a
63
+ * partial pull writes only some lanes — in both cases the previous record is
64
+ * still "the last thing written there". Replacing would forget those paths and
65
+ * the next pull would see them as the user's work again, which is the false alarm
66
+ * this cache exists to prevent. A stale entry for a file that no longer exists is
67
+ * harmless: the hash read fails and it counts as a local change.
68
+ *
69
+ * @param {string} siteDir
70
+ * @param {string[]} absPaths - files just written
71
+ * @param {string[]} [deletedAbs] - files just pruned
72
+ */
73
+ export function recordWritten(siteDir, absPaths, deletedAbs = []) {
74
+ const prior = readWritten(siteDir)
75
+ const files = prior.files
76
+ for (const abs of absPaths) {
77
+ try {
78
+ files[relative(siteDir, abs)] = createHash('sha256')
79
+ .update(readFileSync(abs))
80
+ .digest('hex')
81
+ } catch {
82
+ /* deleted or unreadable — nothing to remember */
83
+ }
84
+ }
85
+ // Pull PRUNES too, and a deletion is a dirty path git reports just like an edit.
86
+ // Without recording them, pull's own pruning reads as the user having deleted
87
+ // files — the same false alarm as its writes, arriving by the other door.
88
+ const deleted = [
89
+ ...new Set([...prior.deleted, ...deletedAbs.map((a) => relative(siteDir, a))])
90
+ ]
91
+ try {
92
+ mkdirSync(dirname(writtenCachePath(siteDir)), { recursive: true })
93
+ writeFileSync(
94
+ writtenCachePath(siteDir),
95
+ JSON.stringify({ version: 1, files, deleted }, null, 2) + '\n'
96
+ )
97
+ } catch {
98
+ /* best-effort: losing it only costs a spurious refusal */
99
+ }
100
+ }
101
+
102
+ /**
103
+ * Is this dirty path just machine output nobody has touched?
104
+ *
105
+ * The hash comparison is what keeps the guard honest: a scaffolded or pulled file
106
+ * the user then EDITED no longer matches, so it counts as their work and the
107
+ * refusal stands.
108
+ *
109
+ * @param {string} siteDir
110
+ * @param {string} relPath
111
+ * @param {{files: Record<string,string>, deleted: string[]}} written
112
+ * @returns {boolean}
113
+ */
114
+ export function isPullOutput(siteDir, relPath, written) {
115
+ let exists = true
116
+ let hash = null
117
+ try {
118
+ hash = createHash('sha256')
119
+ .update(readFileSync(join(siteDir, relPath)))
120
+ .digest('hex')
121
+ } catch {
122
+ exists = false
123
+ }
124
+ // Absent because pull pruned it — not because the user deleted it.
125
+ if (!exists) return written.deleted.includes(relPath)
126
+ return written.files[relPath] === hash
127
+ }