@fluid-app/fluid-cli-theme-dev 0.1.33 → 0.1.35
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/dist/index.mjs +748 -181
- package/dist/index.mjs.map +1 -1
- package/package.json +4 -4
package/dist/index.mjs
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { Command } from "commander";
|
|
2
2
|
import { getAuthToken, readConfig, updateConfig } from "@fluid-app/fluid-cli";
|
|
3
|
-
import { cpSync, existsSync, mkdirSync, readFileSync, readdirSync, renameSync, rmSync, statSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { cpSync, existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, renameSync, rmSync, statSync, unlinkSync, writeFileSync } from "node:fs";
|
|
4
4
|
import { basename, dirname, extname, isAbsolute, join, relative, resolve, sep } from "node:path";
|
|
5
5
|
import { createHash } from "node:crypto";
|
|
6
6
|
import http from "node:http";
|
|
@@ -9,7 +9,8 @@ import chokidar from "chokidar";
|
|
|
9
9
|
import chalk from "chalk";
|
|
10
10
|
import prompts from "prompts";
|
|
11
11
|
import ora from "ora";
|
|
12
|
-
import { execFileSync } from "node:child_process";
|
|
12
|
+
import { execFileSync, spawn } from "node:child_process";
|
|
13
|
+
import { tmpdir } from "node:os";
|
|
13
14
|
import { fileURLToPath } from "node:url";
|
|
14
15
|
//#region ../../platform/api-client-core/src/fetch-client.ts
|
|
15
16
|
/**
|
|
@@ -276,6 +277,21 @@ function readThemeConfig(themeRoot) {
|
|
|
276
277
|
return null;
|
|
277
278
|
}
|
|
278
279
|
}
|
|
280
|
+
/**
|
|
281
|
+
* Read a legacy config (pre-shadow-repo). Used only by the migration
|
|
282
|
+
* path on first new-CLI pull to seed shadow HEAD from files whose
|
|
283
|
+
* local content still matches their stored sha256 checksum.
|
|
284
|
+
*/
|
|
285
|
+
function readLegacyThemeConfig(themeRoot) {
|
|
286
|
+
const path = configPath(themeRoot);
|
|
287
|
+
if (!existsSync(path)) return null;
|
|
288
|
+
try {
|
|
289
|
+
const raw = readFileSync(path, "utf-8");
|
|
290
|
+
return JSON.parse(raw);
|
|
291
|
+
} catch {
|
|
292
|
+
return null;
|
|
293
|
+
}
|
|
294
|
+
}
|
|
279
295
|
/** Write `.fluid-theme.json` to a theme directory. */
|
|
280
296
|
function writeThemeConfig(themeRoot, config) {
|
|
281
297
|
writeFileSync(configPath(themeRoot), JSON.stringify(config, null, 2) + "\n", "utf-8");
|
|
@@ -1931,24 +1947,444 @@ function createDevCommand() {
|
|
|
1931
1947
|
});
|
|
1932
1948
|
}
|
|
1933
1949
|
//#endregion
|
|
1934
|
-
//#region src/
|
|
1950
|
+
//#region src/theme/shadow-repo.ts
|
|
1935
1951
|
/**
|
|
1936
|
-
*
|
|
1937
|
-
*
|
|
1952
|
+
* A bare git repo hidden under `.fluid-theme/repo` that stands in for
|
|
1953
|
+
* git's index+HEAD when talking to the Fluid server. Every pull commits
|
|
1954
|
+
* the incoming remote state onto a single branch (`refs/heads/main`);
|
|
1955
|
+
* every successful push commits the outgoing local state onto the same
|
|
1956
|
+
* branch. HEAD therefore represents "the last state the CLI and server
|
|
1957
|
+
* agreed on", and its tree is the natural three-way-merge base for the
|
|
1958
|
+
* next pull.
|
|
1959
|
+
*
|
|
1960
|
+
* We stay in git's plumbing layer — no working tree, no index file
|
|
1961
|
+
* next to the theme content — so the shadow repo cannot interfere
|
|
1962
|
+
* with the user's own git repo (if they have one) around the theme
|
|
1963
|
+
* dir. All state lives inside `.fluid-theme/`.
|
|
1964
|
+
*
|
|
1965
|
+
* The class only wraps the small handful of plumbing commands we
|
|
1966
|
+
* actually need: hash-object, cat-file, write-tree (via a temp index),
|
|
1967
|
+
* commit-tree, update-ref, and merge-file. Everything else stays out.
|
|
1938
1968
|
*/
|
|
1939
|
-
|
|
1940
|
-
|
|
1941
|
-
|
|
1942
|
-
|
|
1943
|
-
|
|
1944
|
-
|
|
1945
|
-
|
|
1969
|
+
var ShadowRepo = class ShadowRepo {
|
|
1970
|
+
headExists = void 0;
|
|
1971
|
+
constructor(themeRoot, gitDir) {
|
|
1972
|
+
this.themeRoot = themeRoot;
|
|
1973
|
+
this.gitDir = gitDir;
|
|
1974
|
+
}
|
|
1975
|
+
/**
|
|
1976
|
+
* Return a ShadowRepo bound to `themeId` for the given theme root.
|
|
1977
|
+
* The shadow is initialized on first use and re-initialized when
|
|
1978
|
+
* the caller passes a themeId different from the one previously
|
|
1979
|
+
* recorded — the merge base is only meaningful for the theme it
|
|
1980
|
+
* was captured against, so a cross-theme operation (pull A → push
|
|
1981
|
+
* B, or two pulls of different themes into one dir) starts from a
|
|
1982
|
+
* clean slate rather than pretending B's state matches A's HEAD.
|
|
1983
|
+
*/
|
|
1984
|
+
static async open(themeRoot, themeId) {
|
|
1985
|
+
const shadowDir = join(themeRoot, ".fluid-theme");
|
|
1986
|
+
const gitDir = join(shadowDir, "repo");
|
|
1987
|
+
const themeIdFile = join(shadowDir, "theme-id");
|
|
1988
|
+
if (existsSync(gitDir)) {
|
|
1989
|
+
if (readStoredThemeId(themeIdFile) !== themeId) rmSync(gitDir, {
|
|
1990
|
+
recursive: true,
|
|
1991
|
+
force: true
|
|
1992
|
+
});
|
|
1993
|
+
}
|
|
1994
|
+
const repo = new ShadowRepo(themeRoot, gitDir);
|
|
1995
|
+
if (!existsSync(gitDir)) {
|
|
1996
|
+
mkdirSync(shadowDir, { recursive: true });
|
|
1997
|
+
await repo.git([
|
|
1998
|
+
"init",
|
|
1999
|
+
"--bare",
|
|
2000
|
+
"-b",
|
|
2001
|
+
"main",
|
|
2002
|
+
gitDir
|
|
2003
|
+
], { cwd: themeRoot });
|
|
2004
|
+
}
|
|
2005
|
+
writeFileSync(themeIdFile, `${themeId}\n`, "utf-8");
|
|
2006
|
+
const shadowIgnore = join(shadowDir, ".gitignore");
|
|
2007
|
+
if (!existsSync(shadowIgnore)) writeFileSync(shadowIgnore, "# Fluid CLI shadow repo — internal state, not for version control.\n*\n");
|
|
2008
|
+
await ensureRootGitignoreHidesShadow(themeRoot);
|
|
2009
|
+
return repo;
|
|
2010
|
+
}
|
|
2011
|
+
/** True when the repo has at least one commit on `refs/heads/main`. */
|
|
2012
|
+
async hasHead() {
|
|
2013
|
+
if (this.headExists !== void 0) return this.headExists;
|
|
2014
|
+
try {
|
|
2015
|
+
await this.git([
|
|
2016
|
+
"rev-parse",
|
|
2017
|
+
"--verify",
|
|
2018
|
+
"HEAD"
|
|
2019
|
+
]);
|
|
2020
|
+
this.headExists = true;
|
|
2021
|
+
} catch {
|
|
2022
|
+
this.headExists = false;
|
|
2023
|
+
}
|
|
2024
|
+
return this.headExists;
|
|
2025
|
+
}
|
|
2026
|
+
/**
|
|
2027
|
+
* Every path recorded under HEAD's tree, recursively. Callers use
|
|
2028
|
+
* this to detect local deletions (paths in HEAD, absent from the
|
|
2029
|
+
* working tree). Returns [] when HEAD has never been committed.
|
|
2030
|
+
*/
|
|
2031
|
+
async headPaths() {
|
|
2032
|
+
if (!await this.hasHead()) return [];
|
|
2033
|
+
const { stdout } = await this.git([
|
|
2034
|
+
"ls-tree",
|
|
2035
|
+
"-r",
|
|
2036
|
+
"HEAD",
|
|
2037
|
+
"--name-only"
|
|
2038
|
+
]);
|
|
2039
|
+
return stdout.toString("utf8").split("\n").filter((line) => line.length > 0);
|
|
2040
|
+
}
|
|
2041
|
+
/**
|
|
2042
|
+
* The content of `path` in HEAD's tree, or null when the path does
|
|
2043
|
+
* not exist there. Callers use this as the merge base for pull
|
|
2044
|
+
* conflict resolution.
|
|
2045
|
+
*/
|
|
2046
|
+
async blobAtHead(path) {
|
|
2047
|
+
if (!await this.hasHead()) return null;
|
|
2048
|
+
try {
|
|
2049
|
+
const { stdout } = await this.git([
|
|
2050
|
+
"cat-file",
|
|
2051
|
+
"-p",
|
|
2052
|
+
`HEAD:${path}`
|
|
2053
|
+
]);
|
|
2054
|
+
return stdout;
|
|
2055
|
+
} catch {
|
|
2056
|
+
return null;
|
|
2057
|
+
}
|
|
2058
|
+
}
|
|
2059
|
+
/**
|
|
2060
|
+
* Write `content` as a blob in the shadow repo and return its sha.
|
|
2061
|
+
* Used by `commitState` to stage each file's content before the
|
|
2062
|
+
* `write-tree` call.
|
|
2063
|
+
*/
|
|
2064
|
+
async writeBlob(content) {
|
|
2065
|
+
const buf = typeof content === "string" ? Buffer.from(content) : content;
|
|
2066
|
+
const { stdout } = await this.git([
|
|
2067
|
+
"hash-object",
|
|
2068
|
+
"-w",
|
|
2069
|
+
"--stdin"
|
|
2070
|
+
], { input: buf });
|
|
2071
|
+
return stdout.toString("utf8").trim();
|
|
2072
|
+
}
|
|
2073
|
+
/**
|
|
2074
|
+
* Commit `files` as HEAD's new tree, threaded onto the current HEAD
|
|
2075
|
+
* as the parent. Uses a per-call temp index so a partial run can't
|
|
2076
|
+
* corrupt anything reachable from HEAD; the previous commit stays
|
|
2077
|
+
* intact until `update-ref` at the end.
|
|
2078
|
+
*
|
|
2079
|
+
* Returns the new commit sha.
|
|
2080
|
+
*/
|
|
2081
|
+
async commitState(files, message) {
|
|
2082
|
+
const indexPath = mkdtempSync(join(tmpdir(), "fluid-shadow-")) + "/index";
|
|
2083
|
+
try {
|
|
2084
|
+
for (const { path, sha } of files) await this.git([
|
|
2085
|
+
"update-index",
|
|
2086
|
+
"--add",
|
|
2087
|
+
"--cacheinfo",
|
|
2088
|
+
`100644,${sha},${path}`
|
|
2089
|
+
], { env: { GIT_INDEX_FILE: indexPath } });
|
|
2090
|
+
const treeSha = (await this.git(["write-tree"], { env: { GIT_INDEX_FILE: indexPath } })).stdout.toString("utf8").trim();
|
|
2091
|
+
const parent = await this.hasHead() ? (await this.git(["rev-parse", "HEAD"])).stdout.toString("utf8").trim() : null;
|
|
2092
|
+
const commitArgs = [
|
|
2093
|
+
"commit-tree",
|
|
2094
|
+
treeSha,
|
|
2095
|
+
"-m",
|
|
2096
|
+
message
|
|
2097
|
+
];
|
|
2098
|
+
if (parent) commitArgs.push("-p", parent);
|
|
2099
|
+
const commitSha = (await this.git(commitArgs, { env: {
|
|
2100
|
+
GIT_AUTHOR_NAME: "Fluid CLI",
|
|
2101
|
+
GIT_AUTHOR_EMAIL: "cli@fluid.app",
|
|
2102
|
+
GIT_COMMITTER_NAME: "Fluid CLI",
|
|
2103
|
+
GIT_COMMITTER_EMAIL: "cli@fluid.app"
|
|
2104
|
+
} })).stdout.toString("utf8").trim();
|
|
2105
|
+
await this.git([
|
|
2106
|
+
"update-ref",
|
|
2107
|
+
"refs/heads/main",
|
|
2108
|
+
commitSha
|
|
2109
|
+
]);
|
|
2110
|
+
this.headExists = true;
|
|
2111
|
+
return commitSha;
|
|
2112
|
+
} finally {
|
|
2113
|
+
try {
|
|
2114
|
+
rmSync(indexPath, { force: true });
|
|
2115
|
+
rmSync(indexPath.substring(0, indexPath.length - 6), {
|
|
2116
|
+
recursive: true,
|
|
2117
|
+
force: true
|
|
2118
|
+
});
|
|
2119
|
+
} catch {}
|
|
2120
|
+
}
|
|
2121
|
+
}
|
|
2122
|
+
/**
|
|
2123
|
+
* Three-way merge of `local` against `remote` with `base` as the
|
|
2124
|
+
* common ancestor. Returns the merged bytes and a flag when
|
|
2125
|
+
* `git merge-file` reported unresolved conflicts (i.e. the output
|
|
2126
|
+
* contains `<<<<<<<` markers for the reader to resolve).
|
|
2127
|
+
*
|
|
2128
|
+
* `base` is null when HEAD has never seen this path; we merge
|
|
2129
|
+
* against an empty base, which is what git itself does for a new
|
|
2130
|
+
* file added on both sides.
|
|
2131
|
+
*/
|
|
2132
|
+
async merge3(base, local, remote) {
|
|
2133
|
+
const dir = mkdtempSync(join(tmpdir(), "fluid-merge-"));
|
|
2134
|
+
const localPath = join(dir, "local");
|
|
2135
|
+
const basePath = join(dir, "base");
|
|
2136
|
+
const remotePath = join(dir, "remote");
|
|
2137
|
+
try {
|
|
2138
|
+
writeFileSync(localPath, local);
|
|
2139
|
+
writeFileSync(basePath, base ?? Buffer.alloc(0));
|
|
2140
|
+
writeFileSync(remotePath, remote);
|
|
2141
|
+
try {
|
|
2142
|
+
const { stdout } = await this.git([
|
|
2143
|
+
"merge-file",
|
|
2144
|
+
"-p",
|
|
2145
|
+
"-L",
|
|
2146
|
+
"local",
|
|
2147
|
+
"-L",
|
|
2148
|
+
"base",
|
|
2149
|
+
"-L",
|
|
2150
|
+
"remote",
|
|
2151
|
+
localPath,
|
|
2152
|
+
basePath,
|
|
2153
|
+
remotePath
|
|
2154
|
+
]);
|
|
2155
|
+
return {
|
|
2156
|
+
merged: stdout,
|
|
2157
|
+
hasConflicts: false
|
|
2158
|
+
};
|
|
2159
|
+
} catch (err) {
|
|
2160
|
+
const e = err;
|
|
2161
|
+
const merged = e.stdout instanceof Buffer ? e.stdout : e.stdout != null ? Buffer.from(e.stdout) : Buffer.alloc(0);
|
|
2162
|
+
if (typeof e.code === "number" && e.code >= 1 && e.code <= 127 && merged.length > 0) return {
|
|
2163
|
+
merged,
|
|
2164
|
+
hasConflicts: true
|
|
2165
|
+
};
|
|
2166
|
+
throw err;
|
|
2167
|
+
}
|
|
2168
|
+
} finally {
|
|
2169
|
+
try {
|
|
2170
|
+
rmSync(dir, {
|
|
2171
|
+
recursive: true,
|
|
2172
|
+
force: true
|
|
2173
|
+
});
|
|
2174
|
+
} catch {}
|
|
2175
|
+
}
|
|
2176
|
+
}
|
|
2177
|
+
/**
|
|
2178
|
+
* Snapshot the working-tree copy of `paths` into HEAD as a single
|
|
2179
|
+
* commit. Intended for the migration path: on the first pull with
|
|
2180
|
+
* the new CLI (no shadow repo yet, but a `.fluid-theme.json` with
|
|
2181
|
+
* checksums exists) we seed HEAD with whatever is on disk before
|
|
2182
|
+
* running the merge, so unmodified files fast-forward cleanly and
|
|
2183
|
+
* modified files show a diff.
|
|
2184
|
+
*/
|
|
2185
|
+
async seedFromWorkingTree(files, message) {
|
|
2186
|
+
const entries = [];
|
|
2187
|
+
for (const { path, content } of files) entries.push({
|
|
2188
|
+
path,
|
|
2189
|
+
sha: await this.writeBlob(content)
|
|
2190
|
+
});
|
|
2191
|
+
await this.commitState(entries, message);
|
|
2192
|
+
}
|
|
2193
|
+
async git(args, opts = {}) {
|
|
2194
|
+
const child = spawn("git", args[0] === "init" ? args : [
|
|
2195
|
+
"--git-dir",
|
|
2196
|
+
this.gitDir,
|
|
2197
|
+
...args
|
|
2198
|
+
], {
|
|
2199
|
+
cwd: opts.cwd ?? this.themeRoot,
|
|
2200
|
+
env: {
|
|
2201
|
+
...process.env,
|
|
2202
|
+
...opts.env
|
|
2203
|
+
},
|
|
2204
|
+
stdio: [
|
|
2205
|
+
"pipe",
|
|
2206
|
+
"pipe",
|
|
2207
|
+
"pipe"
|
|
2208
|
+
]
|
|
2209
|
+
});
|
|
2210
|
+
if (opts.input) child.stdin.write(opts.input);
|
|
2211
|
+
child.stdin.end();
|
|
2212
|
+
return new Promise((resolve, reject) => {
|
|
2213
|
+
const stdout = [];
|
|
2214
|
+
const stderr = [];
|
|
2215
|
+
child.stdout.on("data", (chunk) => stdout.push(chunk));
|
|
2216
|
+
child.stderr.on("data", (chunk) => stderr.push(chunk));
|
|
2217
|
+
child.on("error", reject);
|
|
2218
|
+
child.on("close", (code) => {
|
|
2219
|
+
const out = Buffer.concat(stdout);
|
|
2220
|
+
const err = Buffer.concat(stderr);
|
|
2221
|
+
if (code === 0) resolve({
|
|
2222
|
+
stdout: out,
|
|
2223
|
+
stderr: err
|
|
2224
|
+
});
|
|
2225
|
+
else {
|
|
2226
|
+
const e = /* @__PURE__ */ new Error(`git ${args.join(" ")} exited with ${code}: ${err.toString("utf8")}`);
|
|
2227
|
+
e.code = code ?? -1;
|
|
2228
|
+
e.stdout = out;
|
|
2229
|
+
e.stderr = err;
|
|
2230
|
+
reject(e);
|
|
2231
|
+
}
|
|
2232
|
+
});
|
|
2233
|
+
});
|
|
2234
|
+
}
|
|
2235
|
+
};
|
|
2236
|
+
/**
|
|
2237
|
+
* Content-type check the pull command uses to decide whether a file
|
|
2238
|
+
* is safe to run through `merge3` (line-based) or must fall back to
|
|
2239
|
+
* whole-file "either/or" resolution (binary).
|
|
2240
|
+
*/
|
|
2241
|
+
function looksBinary(content) {
|
|
2242
|
+
return content.subarray(0, Math.min(content.length, 8e3)).includes(0);
|
|
2243
|
+
}
|
|
2244
|
+
/** Best-effort readFile that returns null when the file does not exist. */
|
|
2245
|
+
function readIfExists(path) {
|
|
2246
|
+
try {
|
|
2247
|
+
return readFileSync(path);
|
|
2248
|
+
} catch {
|
|
2249
|
+
return null;
|
|
2250
|
+
}
|
|
2251
|
+
}
|
|
2252
|
+
/**
|
|
2253
|
+
* Parse the numeric theme id from `.fluid-theme/theme-id`. Returns
|
|
2254
|
+
* null when the file is missing or the contents don't parse cleanly;
|
|
2255
|
+
* `open` treats that as "unknown theme" and rebuilds the shadow.
|
|
2256
|
+
*/
|
|
2257
|
+
function readStoredThemeId(themeIdFile) {
|
|
2258
|
+
try {
|
|
2259
|
+
const stored = parseInt(readFileSync(themeIdFile, "utf-8").trim(), 10);
|
|
2260
|
+
return Number.isFinite(stored) ? stored : null;
|
|
2261
|
+
} catch {
|
|
2262
|
+
return null;
|
|
2263
|
+
}
|
|
2264
|
+
}
|
|
2265
|
+
/**
|
|
2266
|
+
* Append `.fluid-theme/` to the theme root's `.gitignore` when the
|
|
2267
|
+
* theme dir sits inside a git working tree and the entry isn't
|
|
2268
|
+
* already there. Skipped when the user isn't in a git repo — no
|
|
2269
|
+
* point manufacturing a `.gitignore` for someone who doesn't use
|
|
2270
|
+
* git. Idempotent — a second call is a no-op.
|
|
2271
|
+
*/
|
|
2272
|
+
async function ensureRootGitignoreHidesShadow(themeRoot) {
|
|
2273
|
+
if (!await new Promise((resolve) => {
|
|
2274
|
+
const child = spawn("git", ["rev-parse", "--is-inside-work-tree"], {
|
|
2275
|
+
cwd: themeRoot,
|
|
2276
|
+
stdio: [
|
|
2277
|
+
"ignore",
|
|
2278
|
+
"pipe",
|
|
2279
|
+
"pipe"
|
|
2280
|
+
]
|
|
2281
|
+
});
|
|
2282
|
+
child.on("close", (code) => resolve(code === 0));
|
|
2283
|
+
child.on("error", () => resolve(false));
|
|
2284
|
+
})) return;
|
|
2285
|
+
const gitignorePath = join(themeRoot, ".gitignore");
|
|
2286
|
+
let existing = "";
|
|
2287
|
+
try {
|
|
2288
|
+
existing = readFileSync(gitignorePath, "utf-8");
|
|
2289
|
+
} catch {
|
|
2290
|
+
existing = "";
|
|
2291
|
+
}
|
|
2292
|
+
const lines = existing.split("\n").map((line) => line.trim());
|
|
2293
|
+
if (lines.includes(".fluid-theme/") || lines.includes(".fluid-theme")) return;
|
|
2294
|
+
const separator = existing.length === 0 || existing.endsWith("\n") ? "" : "\n";
|
|
2295
|
+
writeFileSync(gitignorePath, `${existing}${separator}.fluid-theme/\n`, "utf-8");
|
|
2296
|
+
}
|
|
2297
|
+
//#endregion
|
|
2298
|
+
//#region src/theme/merge-push.ts
|
|
2299
|
+
/**
|
|
2300
|
+
* Compute what changed locally since the last time the shadow repo
|
|
2301
|
+
* committed a state. Replaces the sha256 `checksums` map: shadow HEAD
|
|
2302
|
+
* is the source of truth for "what the CLI last saw the server have".
|
|
2303
|
+
*
|
|
2304
|
+
* Files whose local bytes are byte-identical to their HEAD blob are
|
|
2305
|
+
* skipped; anything else — new, modified, or a locally-deleted path
|
|
2306
|
+
* that HEAD still has — is included.
|
|
2307
|
+
*/
|
|
2308
|
+
async function diffAgainstShadow(themeRoot, shadow) {
|
|
2309
|
+
const changed = [];
|
|
2310
|
+
const deleted = [];
|
|
2311
|
+
const localFiles = themeRoot.files();
|
|
2312
|
+
const localByKey = /* @__PURE__ */ new Map();
|
|
2313
|
+
for (const file of localFiles) {
|
|
1946
2314
|
if (!file.exists) continue;
|
|
1947
|
-
|
|
1948
|
-
|
|
2315
|
+
localByKey.set(file.relativePath, file);
|
|
2316
|
+
const headBlob = await shadow.blobAtHead(file.relativePath);
|
|
2317
|
+
const localBuf = file.isText ? Buffer.from(file.read()) : file.readBinary();
|
|
2318
|
+
if (headBlob && headBlob.equals(localBuf)) continue;
|
|
2319
|
+
changed.push(file);
|
|
1949
2320
|
}
|
|
1950
|
-
|
|
2321
|
+
if (await shadow.hasHead()) for (const key of await listHeadPaths(shadow)) {
|
|
2322
|
+
if (localByKey.has(key)) continue;
|
|
2323
|
+
if (isStylesheetKey(key)) continue;
|
|
2324
|
+
deleted.push(key);
|
|
2325
|
+
}
|
|
2326
|
+
return {
|
|
2327
|
+
changed,
|
|
2328
|
+
deleted
|
|
2329
|
+
};
|
|
1951
2330
|
}
|
|
2331
|
+
/**
|
|
2332
|
+
* Read every path recorded under HEAD's tree. Used to detect local
|
|
2333
|
+
* deletions (paths in HEAD, absent from the working tree). Implemented
|
|
2334
|
+
* with `git ls-tree -r HEAD --name-only` piped through the shadow
|
|
2335
|
+
* repo's plumbing.
|
|
2336
|
+
*/
|
|
2337
|
+
async function listHeadPaths(shadow) {
|
|
2338
|
+
return shadow.headPaths();
|
|
2339
|
+
}
|
|
2340
|
+
/**
|
|
2341
|
+
* Refuse a push when any working file still contains a conflict marker
|
|
2342
|
+
* from a previous pull. Mirrors git's "you have unresolved conflicts;
|
|
2343
|
+
* fix them and re-run" behavior — the whole point of writing markers
|
|
2344
|
+
* on pull was to hand resolution to the user, so we can't send them
|
|
2345
|
+
* upstream.
|
|
2346
|
+
*/
|
|
2347
|
+
function findUnresolvedConflicts(files) {
|
|
2348
|
+
const flagged = [];
|
|
2349
|
+
for (const file of files) {
|
|
2350
|
+
if (!file.isText) continue;
|
|
2351
|
+
const buf = readIfExists(file.absolutePath);
|
|
2352
|
+
if (!buf) continue;
|
|
2353
|
+
if (containsConflictMarker(buf)) flagged.push(file.relativePath);
|
|
2354
|
+
}
|
|
2355
|
+
return flagged;
|
|
2356
|
+
}
|
|
2357
|
+
const CONFLICT_START = Buffer.from("<<<<<<<");
|
|
2358
|
+
const CONFLICT_MID = Buffer.from("=======");
|
|
2359
|
+
const CONFLICT_END = Buffer.from(">>>>>>>");
|
|
2360
|
+
/**
|
|
2361
|
+
* A file counts as unresolved when it contains all three marker
|
|
2362
|
+
* shapes: `<<<<<<<`, `=======`, and `>>>>>>>`. Requiring all three
|
|
2363
|
+
* avoids false positives — a line of equals signs alone (e.g. inside
|
|
2364
|
+
* an ASCII table in a template comment) doesn't trip the guard.
|
|
2365
|
+
*/
|
|
2366
|
+
function containsConflictMarker(buf) {
|
|
2367
|
+
return buf.includes(CONFLICT_START) && buf.includes(CONFLICT_MID) && buf.includes(CONFLICT_END);
|
|
2368
|
+
}
|
|
2369
|
+
/**
|
|
2370
|
+
* Commit the current working-tree state to shadow HEAD after a
|
|
2371
|
+
* successful push. Ensures the next pull's merge base is the state
|
|
2372
|
+
* we know the server just accepted.
|
|
2373
|
+
*/
|
|
2374
|
+
async function commitPushedState(themeRoot, shadow, message) {
|
|
2375
|
+
const entries = [];
|
|
2376
|
+
for (const file of themeRoot.files()) {
|
|
2377
|
+
if (!file.exists) continue;
|
|
2378
|
+
const buf = file.isText ? Buffer.from(file.read()) : file.readBinary();
|
|
2379
|
+
entries.push({
|
|
2380
|
+
path: file.relativePath,
|
|
2381
|
+
sha: await shadow.writeBlob(buf)
|
|
2382
|
+
});
|
|
2383
|
+
}
|
|
2384
|
+
if (entries.length > 0) await shadow.commitState(entries, message);
|
|
2385
|
+
}
|
|
2386
|
+
//#endregion
|
|
2387
|
+
//#region src/commands/push.ts
|
|
1952
2388
|
function createPushCommand() {
|
|
1953
2389
|
return new Command("push").description("Push local theme files to a remote theme").option("-t, --theme <name-or-id>", "Theme name or ID to push to").option("-n, --nodelete", "Do not delete remote files missing locally").option("-f, --force", "Skip schema validation and the server-side merge check").option("-p, --publish", "Publish the theme after pushing").option("-u, --unpublished", "Create a new unpublished theme and push to it").option("--root <path>", "Theme root directory", ".").action(async (opts) => {
|
|
1954
2390
|
requireToken();
|
|
@@ -1985,88 +2421,107 @@ function createPushCommand() {
|
|
|
1985
2421
|
console.log(` Using theme from .fluid-theme.json: ${chalk.bold(config.themeName)} (#${config.themeId})`);
|
|
1986
2422
|
theme = (await getApplicationTheme(api, config.themeId)).application_theme;
|
|
1987
2423
|
} else theme = await selectTheme(api, "Select a theme to push to");
|
|
1988
|
-
|
|
1989
|
-
|
|
1990
|
-
|
|
1991
|
-
|
|
1992
|
-
|
|
1993
|
-
const
|
|
1994
|
-
|
|
1995
|
-
|
|
1996
|
-
|
|
1997
|
-
|
|
1998
|
-
|
|
1999
|
-
|
|
2000
|
-
|
|
2001
|
-
|
|
2002
|
-
|
|
2003
|
-
|
|
2004
|
-
choices: [
|
|
2005
|
-
{
|
|
2006
|
-
title: "Push anyway (overwrite remote changes)",
|
|
2007
|
-
value: "push"
|
|
2008
|
-
},
|
|
2009
|
-
{
|
|
2010
|
-
title: "Pull first, then push",
|
|
2011
|
-
value: "pull-first"
|
|
2012
|
-
},
|
|
2013
|
-
{
|
|
2014
|
-
title: "Abort",
|
|
2015
|
-
value: "abort"
|
|
2016
|
-
}
|
|
2017
|
-
]
|
|
2018
|
-
}, { onCancel: () => process.exit(130) });
|
|
2019
|
-
if (resolution === "abort") {
|
|
2020
|
-
console.log("Aborted.");
|
|
2021
|
-
process.exit(0);
|
|
2022
|
-
}
|
|
2023
|
-
if (resolution === "pull-first") {
|
|
2024
|
-
console.log(`Run ${chalk.cyan("fluid theme pull")} first, then push again.`);
|
|
2025
|
-
process.exit(0);
|
|
2026
|
-
}
|
|
2027
|
-
if (resolution === "push") overrideMergeCheck = true;
|
|
2028
|
-
}
|
|
2424
|
+
const shadow = await ShadowRepo.open(themeRoot.root, theme.id);
|
|
2425
|
+
const unresolved = findUnresolvedConflicts(themeRoot.files().filter((f) => f.exists));
|
|
2426
|
+
if (unresolved.length > 0) {
|
|
2427
|
+
console.log();
|
|
2428
|
+
console.log(chalk.red(`✗ ${unresolved.length} file(s) still contain unresolved conflict markers:`));
|
|
2429
|
+
for (const key of unresolved) console.log(` ${key}`);
|
|
2430
|
+
console.log();
|
|
2431
|
+
console.log(` Edit each file to reconcile the ${chalk.cyan("<<<<<<<")} / ${chalk.cyan(">>>>>>>")} sections,`);
|
|
2432
|
+
console.log(` then re-run ${chalk.cyan("fluid theme push")}.`);
|
|
2433
|
+
console.log();
|
|
2434
|
+
process.exit(1);
|
|
2435
|
+
}
|
|
2436
|
+
const { changed, deleted } = await diffAgainstShadow(themeRoot, shadow);
|
|
2437
|
+
if (changed.length === 0 && deleted.length === 0) {
|
|
2438
|
+
console.log("Nothing to push — local matches the last synced state.");
|
|
2439
|
+
return;
|
|
2029
2440
|
}
|
|
2030
|
-
const syncer = new Syncer(api, theme.id, themeRoot);
|
|
2031
2441
|
const spinner = ora(`Pushing to ${theme.name} (#${theme.id})…`).start();
|
|
2032
|
-
const
|
|
2033
|
-
|
|
2442
|
+
const syncer = new Syncer(api, theme.id, themeRoot);
|
|
2443
|
+
const configMatchesTheme = config?.themeId === theme.id;
|
|
2444
|
+
let baseSha = opts.force || !configMatchesTheme ? null : config?.baseSha ?? null;
|
|
2445
|
+
if (!opts.force && !opts.unpublished && !baseSha) {
|
|
2446
|
+
console.error();
|
|
2447
|
+
console.error(chalk.red(`No local baseline for theme "${theme.name}" (#${theme.id}).`));
|
|
2448
|
+
console.error();
|
|
2449
|
+
console.error(` Run ${chalk.cyan(`fluid theme pull -t ${theme.id}`)} first to sync down the current server state,`);
|
|
2450
|
+
console.error(` then push — this way you see what would change before it goes live.`);
|
|
2451
|
+
console.error();
|
|
2452
|
+
console.error(` Or, if you know what you're doing and want to overwrite the server's current`);
|
|
2453
|
+
console.error(` contents wholesale, re-run with ${chalk.cyan("--force")}.`);
|
|
2454
|
+
console.error();
|
|
2455
|
+
process.exit(1);
|
|
2456
|
+
}
|
|
2034
2457
|
try {
|
|
2035
|
-
|
|
2036
|
-
|
|
2037
|
-
validate: !opts.force,
|
|
2038
|
-
baseSha,
|
|
2039
|
-
onProgress: (d, total) => {
|
|
2040
|
-
spinner.text = `Pushing ${d}/${total} files…`;
|
|
2041
|
-
}
|
|
2042
|
-
});
|
|
2458
|
+
await syncer.preflightPush(baseSha);
|
|
2459
|
+
baseSha = syncer.remoteSha() ?? baseSha;
|
|
2043
2460
|
} catch (e) {
|
|
2044
2461
|
if (e instanceof PushConflictError) {
|
|
2045
|
-
spinner
|
|
2046
|
-
console.log();
|
|
2047
|
-
console.log(` ${chalk.cyan("Run `fluid theme pull` first")} to sync down the remote changes,`);
|
|
2048
|
-
console.log(` then push again. Use ${chalk.cyan("fluid theme push --force")} to overwrite anyway.`);
|
|
2049
|
-
console.log();
|
|
2462
|
+
renderPullFirst(spinner);
|
|
2050
2463
|
process.exit(1);
|
|
2051
2464
|
}
|
|
2052
2465
|
throw e;
|
|
2053
2466
|
}
|
|
2054
|
-
if (
|
|
2055
|
-
|
|
2056
|
-
for (const
|
|
2057
|
-
|
|
2058
|
-
|
|
2059
|
-
|
|
2060
|
-
|
|
2061
|
-
|
|
2062
|
-
|
|
2063
|
-
|
|
2064
|
-
|
|
2065
|
-
|
|
2066
|
-
|
|
2067
|
-
|
|
2068
|
-
|
|
2467
|
+
if (!opts.force) {
|
|
2468
|
+
const errors = [];
|
|
2469
|
+
for (const file of changed) {
|
|
2470
|
+
if (!file.isLiquid) continue;
|
|
2471
|
+
for (const d of file.validateSchema()) if (d.severity === "error") errors.push(`${file.relativePath}: ${d.message}`);
|
|
2472
|
+
}
|
|
2473
|
+
if (errors.length > 0) {
|
|
2474
|
+
spinner.fail(`Schema validation failed (${errors.length} error(s)). Use --force to skip.`);
|
|
2475
|
+
for (const err of errors) console.error(` ${err}`);
|
|
2476
|
+
process.exit(1);
|
|
2477
|
+
}
|
|
2478
|
+
}
|
|
2479
|
+
let uploaded = 0;
|
|
2480
|
+
let deletedCount = 0;
|
|
2481
|
+
const errors = [];
|
|
2482
|
+
let progress = 0;
|
|
2483
|
+
const total = changed.length + (opts.nodelete ? 0 : deleted.length);
|
|
2484
|
+
for (const file of changed) {
|
|
2485
|
+
try {
|
|
2486
|
+
await syncer.uploadFile(file, baseSha);
|
|
2487
|
+
baseSha = syncer.remoteSha() ?? baseSha;
|
|
2488
|
+
uploaded++;
|
|
2489
|
+
} catch (e) {
|
|
2490
|
+
if (e instanceof PushConflictError) {
|
|
2491
|
+
spinner.stop();
|
|
2492
|
+
renderPullFirst(ora());
|
|
2493
|
+
process.exit(1);
|
|
2494
|
+
}
|
|
2495
|
+
errors.push(`Upload ${file.relativePath}: ${formatError(e)}`);
|
|
2496
|
+
}
|
|
2497
|
+
spinner.text = `Pushing ${++progress}/${total} files…`;
|
|
2069
2498
|
}
|
|
2499
|
+
if (!opts.nodelete) for (const key of deleted) {
|
|
2500
|
+
try {
|
|
2501
|
+
await syncer.deleteRemoteFile(key, baseSha);
|
|
2502
|
+
baseSha = syncer.remoteSha() ?? baseSha;
|
|
2503
|
+
deletedCount++;
|
|
2504
|
+
} catch (e) {
|
|
2505
|
+
if (e instanceof PushConflictError) {
|
|
2506
|
+
spinner.stop();
|
|
2507
|
+
renderPullFirst(ora());
|
|
2508
|
+
process.exit(1);
|
|
2509
|
+
}
|
|
2510
|
+
errors.push(`Delete ${key}: ${formatError(e)}`);
|
|
2511
|
+
}
|
|
2512
|
+
spinner.text = `Pushing ${++progress}/${total} files…`;
|
|
2513
|
+
}
|
|
2514
|
+
if (errors.length) {
|
|
2515
|
+
spinner.warn(`Pushed with ${errors.length} error(s).`);
|
|
2516
|
+
for (const err of errors) console.error(` ${err}`);
|
|
2517
|
+
} else spinner.succeed(`Pushed ${uploaded} file(s)` + (deletedCount > 0 ? `, deleted ${deletedCount} remote file(s).` : "."));
|
|
2518
|
+
if (errors.length === 0) await commitPushedState(themeRoot, shadow, `push @ ${(/* @__PURE__ */ new Date()).toISOString()}`);
|
|
2519
|
+
if (config) writeThemeConfig(themeRoot.root, {
|
|
2520
|
+
themeId: theme.id,
|
|
2521
|
+
themeName: theme.name,
|
|
2522
|
+
company: config.company,
|
|
2523
|
+
baseSha: baseSha ?? void 0
|
|
2524
|
+
});
|
|
2070
2525
|
if (opts.publish) {
|
|
2071
2526
|
const pubSpinner = ora("Publishing theme…").start();
|
|
2072
2527
|
try {
|
|
@@ -2078,6 +2533,176 @@ function createPushCommand() {
|
|
|
2078
2533
|
}
|
|
2079
2534
|
});
|
|
2080
2535
|
}
|
|
2536
|
+
function renderPullFirst(spinner) {
|
|
2537
|
+
spinner.fail("Server has changed since your last pull. Push aborted.");
|
|
2538
|
+
console.log();
|
|
2539
|
+
console.log(` ${chalk.cyan("Run `fluid theme pull` first")} to merge the remote changes,`);
|
|
2540
|
+
console.log(` then re-run push. Use ${chalk.cyan("fluid theme push --force")} to overwrite anyway.`);
|
|
2541
|
+
console.log();
|
|
2542
|
+
}
|
|
2543
|
+
//#endregion
|
|
2544
|
+
//#region src/theme/merge-pull.ts
|
|
2545
|
+
/**
|
|
2546
|
+
* Reconcile the just-downloaded remote tree against the working tree,
|
|
2547
|
+
* using the shadow repo's HEAD as the merge base. Text files that
|
|
2548
|
+
* diverge on both sides get run through `git merge-file`; the result
|
|
2549
|
+
* — clean or with `<<<<<<<` markers — is written to disk. Binary
|
|
2550
|
+
* files fall back to "take remote" because there is no principled
|
|
2551
|
+
* three-way merge for them.
|
|
2552
|
+
*
|
|
2553
|
+
* HEAD advances to reflect the remote state we just materialized,
|
|
2554
|
+
* even when unresolved conflict markers remain in the working tree.
|
|
2555
|
+
* The user resolves markers in their editor and re-runs push; push
|
|
2556
|
+
* refuses to send files whose content still starts with a marker,
|
|
2557
|
+
* so a half-resolved push cannot silently ship broken content.
|
|
2558
|
+
*/
|
|
2559
|
+
async function mergePull(input) {
|
|
2560
|
+
const { themeRoot, shadow, remote, fetchBinary, onProgress } = input;
|
|
2561
|
+
const doDelete = input.delete;
|
|
2562
|
+
const result = {
|
|
2563
|
+
written: 0,
|
|
2564
|
+
merged: 0,
|
|
2565
|
+
conflicts: [],
|
|
2566
|
+
deleted: 0,
|
|
2567
|
+
skipped: 0,
|
|
2568
|
+
errors: []
|
|
2569
|
+
};
|
|
2570
|
+
const remoteContent = /* @__PURE__ */ new Map();
|
|
2571
|
+
const remoteKeys = /* @__PURE__ */ new Set();
|
|
2572
|
+
let done = 0;
|
|
2573
|
+
for (const resource of remote) {
|
|
2574
|
+
remoteKeys.add(resource.key);
|
|
2575
|
+
try {
|
|
2576
|
+
const buf = await materialize(resource, fetchBinary);
|
|
2577
|
+
if (buf) remoteContent.set(resource.key, buf);
|
|
2578
|
+
} catch (e) {
|
|
2579
|
+
result.errors.push(`Download ${resource.key}: ${errMsg(e)}`);
|
|
2580
|
+
}
|
|
2581
|
+
onProgress?.(++done, remote.length);
|
|
2582
|
+
}
|
|
2583
|
+
for (const [key, remoteBuf] of remoteContent) {
|
|
2584
|
+
const file = themeRoot.file(key);
|
|
2585
|
+
if (!file.absolutePath.startsWith(themeRoot.root + sep)) {
|
|
2586
|
+
result.errors.push(`Reconcile ${key}: path traversal detected`);
|
|
2587
|
+
continue;
|
|
2588
|
+
}
|
|
2589
|
+
if (input.force) {
|
|
2590
|
+
file.write(remoteBuf);
|
|
2591
|
+
result.written++;
|
|
2592
|
+
continue;
|
|
2593
|
+
}
|
|
2594
|
+
const localBuf = readIfExists(file.absolutePath);
|
|
2595
|
+
const baseBuf = await shadow.blobAtHead(key);
|
|
2596
|
+
if (localBuf == null) {
|
|
2597
|
+
file.write(remoteBuf);
|
|
2598
|
+
result.written++;
|
|
2599
|
+
continue;
|
|
2600
|
+
}
|
|
2601
|
+
if (localBuf.equals(remoteBuf)) {
|
|
2602
|
+
result.skipped++;
|
|
2603
|
+
continue;
|
|
2604
|
+
}
|
|
2605
|
+
if (baseBuf && localBuf.equals(baseBuf)) {
|
|
2606
|
+
file.write(remoteBuf);
|
|
2607
|
+
result.written++;
|
|
2608
|
+
continue;
|
|
2609
|
+
}
|
|
2610
|
+
if (baseBuf && remoteBuf.equals(baseBuf)) {
|
|
2611
|
+
result.skipped++;
|
|
2612
|
+
continue;
|
|
2613
|
+
}
|
|
2614
|
+
if (looksBinary(localBuf) || looksBinary(remoteBuf) || (baseBuf ? looksBinary(baseBuf) : false)) {
|
|
2615
|
+
file.write(remoteBuf);
|
|
2616
|
+
result.conflicts.push(`${key} (binary — kept remote)`);
|
|
2617
|
+
continue;
|
|
2618
|
+
}
|
|
2619
|
+
const { merged, hasConflicts } = await shadow.merge3(baseBuf, localBuf, remoteBuf);
|
|
2620
|
+
file.write(merged);
|
|
2621
|
+
if (hasConflicts) result.conflicts.push(key);
|
|
2622
|
+
else result.merged++;
|
|
2623
|
+
}
|
|
2624
|
+
if (doDelete && await shadow.hasHead()) for (const file of themeRoot.files()) {
|
|
2625
|
+
if (remoteKeys.has(file.relativePath)) continue;
|
|
2626
|
+
if (isStylesheetKey(file.relativePath)) continue;
|
|
2627
|
+
const baseBuf = await shadow.blobAtHead(file.relativePath);
|
|
2628
|
+
if (!baseBuf) continue;
|
|
2629
|
+
const localBuf = readIfExists(file.absolutePath);
|
|
2630
|
+
if (!localBuf) continue;
|
|
2631
|
+
if (!localBuf.equals(baseBuf)) continue;
|
|
2632
|
+
try {
|
|
2633
|
+
unlinkSync(file.absolutePath);
|
|
2634
|
+
result.deleted++;
|
|
2635
|
+
} catch {}
|
|
2636
|
+
}
|
|
2637
|
+
const commitEntries = [];
|
|
2638
|
+
for (const [key, buf] of remoteContent) commitEntries.push({
|
|
2639
|
+
path: key,
|
|
2640
|
+
sha: await shadow.writeBlob(buf)
|
|
2641
|
+
});
|
|
2642
|
+
for (const key of remoteKeys) {
|
|
2643
|
+
if (remoteContent.has(key)) continue;
|
|
2644
|
+
const prevBlob = await shadow.blobAtHead(key);
|
|
2645
|
+
if (prevBlob == null) continue;
|
|
2646
|
+
commitEntries.push({
|
|
2647
|
+
path: key,
|
|
2648
|
+
sha: await shadow.writeBlob(prevBlob)
|
|
2649
|
+
});
|
|
2650
|
+
}
|
|
2651
|
+
if (commitEntries.length > 0) await shadow.commitState(commitEntries, `pull @ ${(/* @__PURE__ */ new Date()).toISOString()}`);
|
|
2652
|
+
return result;
|
|
2653
|
+
}
|
|
2654
|
+
async function materialize(resource, fetchBinary) {
|
|
2655
|
+
if (resource.resource_type === "FileResource" && resource.url) return fetchBinary(resource.url);
|
|
2656
|
+
if (resource.content == null) return null;
|
|
2657
|
+
const text = typeof resource.content === "string" ? resource.content : JSON.stringify(resource.content);
|
|
2658
|
+
return Buffer.from(text);
|
|
2659
|
+
}
|
|
2660
|
+
function errMsg(e) {
|
|
2661
|
+
return e instanceof Error ? e.message : String(e);
|
|
2662
|
+
}
|
|
2663
|
+
//#endregion
|
|
2664
|
+
//#region src/theme/legacy-migration.ts
|
|
2665
|
+
/**
|
|
2666
|
+
* On first pull after upgrading from a checksum-era CLI, the shadow
|
|
2667
|
+
* repo starts with no HEAD. `mergePull` would then run every diverged
|
|
2668
|
+
* file through a null-base merge — even files the user never touched
|
|
2669
|
+
* locally — producing spurious `<<<<<<<` markers for every file the
|
|
2670
|
+
* server updated since the last pull.
|
|
2671
|
+
*
|
|
2672
|
+
* Recover a real merge base by trusting the legacy sha256 checksums:
|
|
2673
|
+
* any local file whose content still matches its stored checksum is
|
|
2674
|
+
* "unmodified since last pull" and can be committed as HEAD. Files
|
|
2675
|
+
* whose local sha256 diverges from the stored checksum stay
|
|
2676
|
+
* unseeded — we don't have their pre-modification content, so a
|
|
2677
|
+
* null-base merge (marker-first UX) is the honest fallback for them.
|
|
2678
|
+
*
|
|
2679
|
+
* Idempotent: no-op when HEAD already exists, when there is no
|
|
2680
|
+
* legacy config, when the config is for a different theme, or when
|
|
2681
|
+
* the checksums map is empty. Runs before `mergePull` so its base
|
|
2682
|
+
* lookups see the seeded tree.
|
|
2683
|
+
*/
|
|
2684
|
+
async function migrateLegacyChecksumsIntoShadow(input) {
|
|
2685
|
+
const { shadow, themeRoot, absoluteRoot, themeId } = input;
|
|
2686
|
+
if (await shadow.hasHead()) return;
|
|
2687
|
+
const legacy = readLegacyThemeConfig(absoluteRoot);
|
|
2688
|
+
if (!legacy) return;
|
|
2689
|
+
if (legacy.themeId !== themeId) return;
|
|
2690
|
+
if (!legacy.checksums || Object.keys(legacy.checksums).length === 0) return;
|
|
2691
|
+
const seed = [];
|
|
2692
|
+
for (const file of themeRoot.files()) {
|
|
2693
|
+
if (!file.exists) continue;
|
|
2694
|
+
const stored = legacy.checksums[file.relativePath];
|
|
2695
|
+
if (!stored) continue;
|
|
2696
|
+
if (file.checksum() !== stored) continue;
|
|
2697
|
+
const content = file.isText ? Buffer.from(file.read()) : file.readBinary();
|
|
2698
|
+
seed.push({
|
|
2699
|
+
path: file.relativePath,
|
|
2700
|
+
content
|
|
2701
|
+
});
|
|
2702
|
+
}
|
|
2703
|
+
if (seed.length === 0) return;
|
|
2704
|
+
await shadow.seedFromWorkingTree(seed, `migrate from checksum-era CLI @ ${(/* @__PURE__ */ new Date()).toISOString()}`);
|
|
2705
|
+
}
|
|
2081
2706
|
//#endregion
|
|
2082
2707
|
//#region src/commands/pull.ts
|
|
2083
2708
|
async function fetchCompanySubdomain(api) {
|
|
@@ -2088,42 +2713,8 @@ async function fetchCompanySubdomain(api) {
|
|
|
2088
2713
|
}
|
|
2089
2714
|
return subdomain;
|
|
2090
2715
|
}
|
|
2091
|
-
function formatRelativeTime(iso) {
|
|
2092
|
-
const diff = Date.now() - new Date(iso).getTime();
|
|
2093
|
-
const minutes = Math.floor(diff / 6e4);
|
|
2094
|
-
if (minutes < 1) return "just now";
|
|
2095
|
-
if (minutes < 60) return `${minutes}m ago`;
|
|
2096
|
-
const hours = Math.floor(minutes / 60);
|
|
2097
|
-
if (hours < 24) return `${hours}h ago`;
|
|
2098
|
-
const days = Math.floor(hours / 24);
|
|
2099
|
-
if (days === 1) return "yesterday";
|
|
2100
|
-
return `${days}d ago (${new Date(iso).toLocaleDateString("en-US", {
|
|
2101
|
-
month: "short",
|
|
2102
|
-
day: "numeric",
|
|
2103
|
-
year: "numeric"
|
|
2104
|
-
})})`;
|
|
2105
|
-
}
|
|
2106
|
-
/**
|
|
2107
|
-
* Detect files where both local and remote have changed since the last pull.
|
|
2108
|
-
* Returns the set of conflicting resource keys.
|
|
2109
|
-
*/
|
|
2110
|
-
function detectConflicts(storedChecksums, remoteChecksums, themeRoot) {
|
|
2111
|
-
const conflicts = [];
|
|
2112
|
-
for (const [key, storedChecksum] of Object.entries(storedChecksums)) {
|
|
2113
|
-
const remoteChecksum = remoteChecksums[key];
|
|
2114
|
-
if (remoteChecksum === void 0) continue;
|
|
2115
|
-
if (remoteChecksum === storedChecksum) continue;
|
|
2116
|
-
const file = themeRoot.file(key);
|
|
2117
|
-
if (!file.exists) continue;
|
|
2118
|
-
const localChecksum = file.checksum();
|
|
2119
|
-
if (localChecksum === storedChecksum) continue;
|
|
2120
|
-
if (localChecksum === remoteChecksum) continue;
|
|
2121
|
-
conflicts.push(key);
|
|
2122
|
-
}
|
|
2123
|
-
return conflicts;
|
|
2124
|
-
}
|
|
2125
2716
|
function createPullCommand() {
|
|
2126
|
-
return new Command("pull").description("Pull a remote theme to your local directory").option("-t, --theme <name-or-id>", "Theme name or ID to pull").option("-n, --nodelete", "Do not delete local files missing on remote").option("--root <path>", "Theme root directory").option("-y, --yes", "Skip confirmation prompt").option("-f, --force", "Overwrite local
|
|
2717
|
+
return new Command("pull").description("Pull a remote theme to your local directory").option("-t, --theme <name-or-id>", "Theme name or ID to pull").option("-n, --nodelete", "Do not delete local files missing on remote").option("--root <path>", "Theme root directory").option("-y, --yes", "Skip confirmation prompt").option("-f, --force", "Overwrite local without merging (skip conflict markers)").action(async (opts) => {
|
|
2127
2718
|
requireToken();
|
|
2128
2719
|
const api = createApiClient();
|
|
2129
2720
|
const workspace = findWorkspace();
|
|
@@ -2139,48 +2730,8 @@ function createPullCommand() {
|
|
|
2139
2730
|
console.log(` Theme: ${chalk.bold(theme.name)} (#${theme.id})`);
|
|
2140
2731
|
console.log(` Company: ${chalk.bold(subdomain)}`);
|
|
2141
2732
|
console.log(` Target: ${chalk.bold(absoluteRoot)}`);
|
|
2142
|
-
if (existingConfig?.lastPulledAt) console.log(` Last pulled: ${formatRelativeTime(existingConfig.lastPulledAt)}`);
|
|
2143
2733
|
console.log();
|
|
2144
|
-
|
|
2145
|
-
let skipKeys;
|
|
2146
|
-
if (existingConfig?.checksums && !opts.force) {
|
|
2147
|
-
const fetchSpinner = ora("Checking for conflicts…").start();
|
|
2148
|
-
const syncer = new Syncer(api, theme.id, themeRoot);
|
|
2149
|
-
await syncer.fetchChecksums();
|
|
2150
|
-
const remoteChecksums = syncer.remoteChecksums();
|
|
2151
|
-
const conflicts = detectConflicts(existingConfig.checksums, remoteChecksums, themeRoot);
|
|
2152
|
-
fetchSpinner.stop();
|
|
2153
|
-
if (conflicts.length > 0) {
|
|
2154
|
-
console.log(chalk.yellow(`⚠ ${conflicts.length} conflict(s) detected:\n`));
|
|
2155
|
-
for (const key of conflicts) console.log(` ${key}`);
|
|
2156
|
-
console.log();
|
|
2157
|
-
const { resolution } = await prompts({
|
|
2158
|
-
type: "select",
|
|
2159
|
-
name: "resolution",
|
|
2160
|
-
message: "How do you want to handle conflicts?",
|
|
2161
|
-
choices: [
|
|
2162
|
-
{
|
|
2163
|
-
title: "Keep local (skip conflicting files)",
|
|
2164
|
-
value: "keep-local"
|
|
2165
|
-
},
|
|
2166
|
-
{
|
|
2167
|
-
title: "Use remote (overwrite local changes)",
|
|
2168
|
-
value: "use-remote"
|
|
2169
|
-
},
|
|
2170
|
-
{
|
|
2171
|
-
title: "Abort",
|
|
2172
|
-
value: "abort"
|
|
2173
|
-
}
|
|
2174
|
-
]
|
|
2175
|
-
}, { onCancel: () => process.exit(130) });
|
|
2176
|
-
if (resolution === "abort") {
|
|
2177
|
-
console.log("Aborted.");
|
|
2178
|
-
process.exit(0);
|
|
2179
|
-
}
|
|
2180
|
-
if (resolution === "keep-local") skipKeys = new Set(conflicts);
|
|
2181
|
-
}
|
|
2182
|
-
}
|
|
2183
|
-
if (!opts.yes && !skipKeys) {
|
|
2734
|
+
if (!opts.yes) {
|
|
2184
2735
|
const { confirmed } = await prompts({
|
|
2185
2736
|
type: "confirm",
|
|
2186
2737
|
name: "confirmed",
|
|
@@ -2192,36 +2743,52 @@ function createPullCommand() {
|
|
|
2192
2743
|
process.exit(0);
|
|
2193
2744
|
}
|
|
2194
2745
|
}
|
|
2746
|
+
const themeRoot = new ThemeRoot(root);
|
|
2747
|
+
const shadow = await ShadowRepo.open(absoluteRoot, theme.id);
|
|
2748
|
+
await migrateLegacyChecksumsIntoShadow({
|
|
2749
|
+
shadow,
|
|
2750
|
+
themeRoot,
|
|
2751
|
+
absoluteRoot,
|
|
2752
|
+
themeId: theme.id
|
|
2753
|
+
});
|
|
2195
2754
|
const syncer = new Syncer(api, theme.id, themeRoot);
|
|
2196
2755
|
const spinner = ora(`Pulling ${theme.name} (#${theme.id})…`).start();
|
|
2197
|
-
const result = await
|
|
2756
|
+
const result = await mergePull({
|
|
2757
|
+
themeRoot,
|
|
2758
|
+
shadow,
|
|
2759
|
+
remote: await syncer.downloadAll(),
|
|
2760
|
+
fetchBinary: (url) => syncer.downloadBinaryAsset(url),
|
|
2198
2761
|
delete: !opts.nodelete,
|
|
2199
|
-
|
|
2200
|
-
onProgress: (
|
|
2201
|
-
spinner.text = `Downloading ${
|
|
2762
|
+
force: opts.force ?? false,
|
|
2763
|
+
onProgress: (done, total) => {
|
|
2764
|
+
spinner.text = `Downloading ${done}/${total} files…`;
|
|
2202
2765
|
}
|
|
2203
2766
|
});
|
|
2204
|
-
const
|
|
2205
|
-
if (
|
|
2206
|
-
|
|
2207
|
-
|
|
2208
|
-
}
|
|
2767
|
+
const parts = [];
|
|
2768
|
+
if (result.written > 0) parts.push(`wrote ${result.written} file(s)`);
|
|
2769
|
+
if (result.merged > 0) parts.push(`merged ${result.merged} file(s) cleanly`);
|
|
2770
|
+
if (result.deleted > 0) parts.push(`deleted ${result.deleted} local file(s)`);
|
|
2771
|
+
if (result.skipped > 0) parts.push(`${result.skipped} already in sync`);
|
|
2772
|
+
if (result.errors.length) {
|
|
2773
|
+
spinner.warn(`Pulled with ${result.errors.length} error(s): ${parts.join(", ")}.`);
|
|
2774
|
+
for (const e of result.errors) console.error(` ${e}`);
|
|
2775
|
+
} else if (result.conflicts.length > 0) {
|
|
2776
|
+
spinner.warn(`${result.conflicts.length} conflict(s) — resolve markers before pushing: ${parts.join(", ")}.`);
|
|
2777
|
+
console.log();
|
|
2778
|
+
for (const c of result.conflicts) console.log(` ${chalk.yellow("CONFLICT")} ${c}`);
|
|
2779
|
+
console.log();
|
|
2780
|
+
console.log(` Edit each file above to reconcile the ${chalk.cyan("<<<<<<<")} / ${chalk.cyan(">>>>>>>")} markers,`);
|
|
2781
|
+
console.log(` then run ${chalk.cyan("fluid theme push")} once your resolution is in place.`);
|
|
2782
|
+
console.log();
|
|
2783
|
+
} else spinner.succeed(parts.join(", ") || "Already up to date.");
|
|
2209
2784
|
const remoteSha = syncer.remoteSha();
|
|
2210
2785
|
writeThemeConfig(absoluteRoot, {
|
|
2211
2786
|
themeId: theme.id,
|
|
2212
2787
|
themeName: theme.name,
|
|
2213
2788
|
company: subdomain,
|
|
2214
|
-
lastPulledAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
2215
|
-
checksums: newChecksums,
|
|
2216
2789
|
baseSha: remoteSha ?? existingConfig?.baseSha
|
|
2217
2790
|
});
|
|
2218
|
-
|
|
2219
|
-
if (result.deleted > 0) parts.push(`deleted ${result.deleted} local file(s)`);
|
|
2220
|
-
if (result.skipped > 0) parts.push(`skipped ${result.skipped} conflict(s)`);
|
|
2221
|
-
if (result.errors.length) {
|
|
2222
|
-
spinner.warn(`Pulled with ${result.errors.length} error(s).`);
|
|
2223
|
-
for (const e of result.errors) console.error(` ${e}`);
|
|
2224
|
-
} else spinner.succeed(`${parts.join(", ")}.`);
|
|
2791
|
+
if (result.conflicts.length > 0) process.exit(1);
|
|
2225
2792
|
});
|
|
2226
2793
|
}
|
|
2227
2794
|
//#endregion
|