uniweb 0.13.5 → 0.13.7
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +7 -7
- package/partials/agents.md +66 -1
- package/src/backend/site-media.js +34 -5
- package/src/backend/site-sync.js +326 -14
- package/src/commands/deploy.js +6 -1
- package/src/commands/doctor.js +20 -0
- package/src/commands/publish.js +42 -3
- package/src/commands/pull.js +387 -7
- package/src/commands/push.js +114 -3
- package/src/commands/refresh.js +176 -0
- package/src/commands/sync.js +75 -0
- package/src/framework-index.json +18 -9
- package/src/index.js +16 -0
- package/src/utils/git.js +203 -0
package/src/utils/git.js
ADDED
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Git awareness for the sync verbs.
|
|
3
|
+
*
|
|
4
|
+
* The CLI's file-based workflow is for people who keep their content in git, so
|
|
5
|
+
* git is the right place to look before doing something destructive to the working
|
|
6
|
+
* tree — rather than building a merge engine to solve a problem the user's VCS
|
|
7
|
+
* already solves better.
|
|
8
|
+
*
|
|
9
|
+
* DETECTED, NEVER REQUIRED. A project may legitimately have no repo, and — more
|
|
10
|
+
* commonly — may live *inside* a larger one: `uniweb create` already skips
|
|
11
|
+
* `git init` when it finds itself inside a work tree ("common for
|
|
12
|
+
* monorepos/workspaces"), and whole scopes of real projects are versioned by a
|
|
13
|
+
* parent repo rather than being repo roots themselves. So the question is never
|
|
14
|
+
* "is this directory a git root" but "is this path tracked by some repo", which is
|
|
15
|
+
* the question git itself answers. Callers degrade to an explicit confirmation
|
|
16
|
+
* when the answer is no; sync already requires an account, and a second hard
|
|
17
|
+
* dependency for a safety net we can provide anyway is a bad trade.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import { execFileSync } from 'node:child_process'
|
|
21
|
+
import { readFileSync } from 'node:fs'
|
|
22
|
+
import { join } from 'node:path'
|
|
23
|
+
import yaml from 'js-yaml'
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* The files and directories `uniweb pull` writes into.
|
|
27
|
+
*
|
|
28
|
+
* ONE definition, shared by the guard that blocks a pull and the message that
|
|
29
|
+
* tells someone how to recover from a refused push — a second copy would drift,
|
|
30
|
+
* and a stale list here means either a guard that misses or advice that is wrong.
|
|
31
|
+
* `paths:` can relocate the content roots, so the local site.yml is consulted
|
|
32
|
+
* rather than assuming the defaults.
|
|
33
|
+
*/
|
|
34
|
+
export function siteContentRoots(siteDir) {
|
|
35
|
+
const roots = new Set(['site.yml', 'theme.yml', 'head.html', 'collections.yml', 'locales'])
|
|
36
|
+
let paths = {}
|
|
37
|
+
try {
|
|
38
|
+
paths = yaml.load(readFileSync(join(siteDir, 'site.yml'), 'utf8'))?.paths || {}
|
|
39
|
+
} catch { /* no or unreadable site.yml — the defaults are right */ }
|
|
40
|
+
roots.add(paths.pages || 'pages')
|
|
41
|
+
roots.add(paths.layout || 'layout')
|
|
42
|
+
roots.add(paths.collections || 'collections')
|
|
43
|
+
return [...roots]
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Is there uncommitted work where a pull would write? False outside a repo. */
|
|
47
|
+
export function hasUncommittedContent(siteDir) {
|
|
48
|
+
const dirty = uncommittedUnder(siteDir, siteContentRoots(siteDir))
|
|
49
|
+
return Array.isArray(dirty) && dirty.length > 0
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function git(args, cwd) {
|
|
53
|
+
return execFileSync('git', args, { cwd, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] })
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Is `dir` inside a git work tree? False when git is absent, when the path isn't
|
|
58
|
+
* tracked, or on any error — the caller treats all three the same way.
|
|
59
|
+
*/
|
|
60
|
+
export function isGitRepo(dir) {
|
|
61
|
+
try {
|
|
62
|
+
return git(['rev-parse', '--is-inside-work-tree'], dir).trim() === 'true'
|
|
63
|
+
} catch {
|
|
64
|
+
return false
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Uncommitted work under `relPaths`, relative to `dir`.
|
|
70
|
+
*
|
|
71
|
+
* Counts modified, staged, deleted AND untracked files. Untracked matters as much
|
|
72
|
+
* as modified here: a section file that exists only locally is not on the backend,
|
|
73
|
+
* so a pruning pull deletes it — losing work that was never committed anywhere.
|
|
74
|
+
*
|
|
75
|
+
* @returns {string[]|null} repo-relative-ish paths as git reports them, or `null`
|
|
76
|
+
* when this isn't a git work tree (distinct from `[]`, which means "clean").
|
|
77
|
+
*/
|
|
78
|
+
export function uncommittedUnder(dir, relPaths) {
|
|
79
|
+
if (!isGitRepo(dir)) return null
|
|
80
|
+
try {
|
|
81
|
+
const out = git(['status', '--porcelain', '--untracked-files=all', '--', ...relPaths], dir)
|
|
82
|
+
return out
|
|
83
|
+
.split('\n')
|
|
84
|
+
.filter(Boolean)
|
|
85
|
+
// porcelain v1: XY<space>path, and a rename is "orig -> new".
|
|
86
|
+
.map((line) => line.slice(3).trim())
|
|
87
|
+
.map((p) => (p.includes(' -> ') ? p.split(' -> ')[1] : p))
|
|
88
|
+
.filter(Boolean)
|
|
89
|
+
} catch {
|
|
90
|
+
// A path git doesn't know (e.g. none of the roots exist yet) is not an error
|
|
91
|
+
// worth failing a pull over.
|
|
92
|
+
return []
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* A file's committed content — the COMMON ANCESTOR for a three-way merge.
|
|
98
|
+
*
|
|
99
|
+
* This is the input a merge needs and the backend cannot supply: it keeps no
|
|
100
|
+
* per-version item content, and retaining some so it could feed a merge running on
|
|
101
|
+
* the client would put a permanent second write on its hottest table. It doesn't
|
|
102
|
+
* have to. `uniweb pull` writes the backend's content into these files, so the
|
|
103
|
+
* committed version IS the state both sides diverged from — the ancestor was always
|
|
104
|
+
* on this side, in the tool that already knows how to merge.
|
|
105
|
+
*
|
|
106
|
+
* @returns {Buffer|null} null when the path isn't in HEAD (a file added locally and
|
|
107
|
+
* never committed has no ancestor, so there is nothing to merge against).
|
|
108
|
+
*/
|
|
109
|
+
export function showAtHead(dir, relPath) {
|
|
110
|
+
try {
|
|
111
|
+
return execFileSync('git', ['show', `HEAD:${relPath}`], {
|
|
112
|
+
cwd: dir,
|
|
113
|
+
stdio: ['ignore', 'pipe', 'ignore'],
|
|
114
|
+
maxBuffer: 64 * 1024 * 1024,
|
|
115
|
+
})
|
|
116
|
+
} catch {
|
|
117
|
+
return null
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Three-way merge, in place: `minePath` is rewritten with the merged result.
|
|
123
|
+
*
|
|
124
|
+
* Delegates to `git merge-file`, which is the same machinery git uses for a merge —
|
|
125
|
+
* text that only one side touched is taken silently, and only genuine overlaps get
|
|
126
|
+
* conflict markers. Building our own would be a worse version of a solved problem.
|
|
127
|
+
*
|
|
128
|
+
* @returns {{ merged: boolean, conflicted: boolean }} `merged:false` means the merge
|
|
129
|
+
* could not be attempted at all, which is different from attempting it and finding
|
|
130
|
+
* conflicts — the caller must not conflate them.
|
|
131
|
+
*/
|
|
132
|
+
export function mergeFile(dir, minePath, basePath, theirsPath, labels = {}) {
|
|
133
|
+
try {
|
|
134
|
+
execFileSync(
|
|
135
|
+
'git',
|
|
136
|
+
[
|
|
137
|
+
'merge-file',
|
|
138
|
+
'-L', labels.mine || 'yours (local)',
|
|
139
|
+
'-L', labels.base || 'common ancestor',
|
|
140
|
+
'-L', labels.theirs || 'theirs (backend)',
|
|
141
|
+
minePath, basePath, theirsPath,
|
|
142
|
+
],
|
|
143
|
+
{ cwd: dir, stdio: ['ignore', 'ignore', 'ignore'] }
|
|
144
|
+
)
|
|
145
|
+
return { merged: true, conflicted: false }
|
|
146
|
+
} catch (err) {
|
|
147
|
+
// A positive status is the CONFLICT COUNT and the file still holds a valid
|
|
148
|
+
// merge with markers. Anything else (git missing, unreadable input) means no
|
|
149
|
+
// merge happened and the caller must fall back rather than trust the file.
|
|
150
|
+
if (typeof err?.status === 'number' && err.status > 0 && err.status < 128) {
|
|
151
|
+
return { merged: true, conflicted: true }
|
|
152
|
+
}
|
|
153
|
+
return { merged: false, conflicted: false }
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/** Does this repo have a remote configured to pull from? */
|
|
158
|
+
export function hasRemote(dir) {
|
|
159
|
+
try {
|
|
160
|
+
return git(['remote'], dir).trim().length > 0
|
|
161
|
+
} catch {
|
|
162
|
+
return false
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* `git pull --ff-only` — bring in teammates' commits.
|
|
168
|
+
*
|
|
169
|
+
* Fast-forward only, deliberately. A refresh should never silently create a merge
|
|
170
|
+
* commit or drop the user into a rebase they didn't ask for; if the branches have
|
|
171
|
+
* genuinely diverged, that is a git problem the user should handle in git, with
|
|
172
|
+
* git's own vocabulary. We report and step back.
|
|
173
|
+
*
|
|
174
|
+
* @returns {{ ok: boolean, changed: boolean, message: string }}
|
|
175
|
+
*/
|
|
176
|
+
export function pullRemote(dir) {
|
|
177
|
+
const before = (() => { try { return git(['rev-parse', 'HEAD'], dir).trim() } catch { return null } })()
|
|
178
|
+
try {
|
|
179
|
+
const out = execFileSync('git', ['pull', '--ff-only'], {
|
|
180
|
+
cwd: dir, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'],
|
|
181
|
+
})
|
|
182
|
+
const after = (() => { try { return git(['rev-parse', 'HEAD'], dir).trim() } catch { return null } })()
|
|
183
|
+
return { ok: true, changed: Boolean(before && after && before !== after), message: out.trim() }
|
|
184
|
+
} catch (err) {
|
|
185
|
+
const msg = [err?.stderr, err?.stdout].map((b) => (b ? String(b) : '')).join('\n').trim()
|
|
186
|
+
return { ok: false, changed: false, message: msg || 'git pull failed' }
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* Provenance for a deploy record: `{ sha, dirty }`, or null outside a repo.
|
|
192
|
+
* Answers "what is actually live?" later, which a version number cannot.
|
|
193
|
+
*/
|
|
194
|
+
export function headProvenance(dir) {
|
|
195
|
+
if (!isGitRepo(dir)) return null
|
|
196
|
+
try {
|
|
197
|
+
const sha = git(['rev-parse', 'HEAD'], dir).trim()
|
|
198
|
+
const dirty = git(['status', '--porcelain'], dir).trim().length > 0
|
|
199
|
+
return { sha, dirty }
|
|
200
|
+
} catch {
|
|
201
|
+
return null // a repo with no commits yet
|
|
202
|
+
}
|
|
203
|
+
}
|