@fluid-app/fluid-cli-theme-dev 0.1.55 → 0.1.57
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 +271 -105
- 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, gitSyncActorFromMe, gitSyncCommitSubject, readConfig, resolveGitSyncActor, summarizeChanges, updateConfig } from "@fluid-app/fluid-cli";
|
|
3
|
-
import { cpSync, existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, renameSync, rmSync, statSync, unlinkSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { cpSync, existsSync, lstatSync, 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, randomBytes } from "node:crypto";
|
|
6
6
|
import http from "node:http";
|
|
@@ -285,10 +285,16 @@ function createFetchClient(config) {
|
|
|
285
285
|
function getApiBase() {
|
|
286
286
|
return process.env["FLUID_API_BASE"] ?? "https://api.fluid.app";
|
|
287
287
|
}
|
|
288
|
+
let backgroundGate = Promise.resolve();
|
|
289
|
+
async function backgroundToken(tokenOverride) {
|
|
290
|
+
backgroundGate = backgroundGate.then(() => new Promise((resolve) => setTimeout(resolve, 1e3)));
|
|
291
|
+
await backgroundGate;
|
|
292
|
+
return tokenOverride ?? getAuthToken() ?? null;
|
|
293
|
+
}
|
|
288
294
|
function createApiClient(tokenOverride) {
|
|
289
295
|
return createFetchClient({
|
|
290
296
|
baseUrl: getApiBase(),
|
|
291
|
-
getAuthToken: () => tokenOverride ?? getAuthToken() ?? null
|
|
297
|
+
getAuthToken: () => process.env["FLUID_BACKGROUND_SYNC"] === "1" ? backgroundToken(tokenOverride) : tokenOverride ?? getAuthToken() ?? null
|
|
292
298
|
});
|
|
293
299
|
}
|
|
294
300
|
function requireToken() {
|
|
@@ -1455,6 +1461,75 @@ function watchTheme(root, handler) {
|
|
|
1455
1461
|
return () => watcher.close();
|
|
1456
1462
|
}
|
|
1457
1463
|
//#endregion
|
|
1464
|
+
//#region src/theme/background-pull-guard.ts
|
|
1465
|
+
const METADATA = [
|
|
1466
|
+
".fluid-assets.json",
|
|
1467
|
+
".fluid-theme.json",
|
|
1468
|
+
".fluidignore"
|
|
1469
|
+
];
|
|
1470
|
+
var BackgroundPullChangedError = class extends Error {
|
|
1471
|
+
constructor() {
|
|
1472
|
+
super("Local files changed during background pull; remaining updates skipped.");
|
|
1473
|
+
}
|
|
1474
|
+
};
|
|
1475
|
+
/** Keep the preflight snapshot across asynchronous download/merge work. Each
|
|
1476
|
+
* mutation checks its original bytes synchronously and records only our writes.
|
|
1477
|
+
* This prevents an edit made after preflight from becoming a merge input or
|
|
1478
|
+
* being overwritten by a deferred write, asset cleanup, or metadata update. */
|
|
1479
|
+
var BackgroundPullGuard = class {
|
|
1480
|
+
root;
|
|
1481
|
+
expected;
|
|
1482
|
+
constructor(root) {
|
|
1483
|
+
this.root = resolve(root);
|
|
1484
|
+
this.expected = this.snapshot();
|
|
1485
|
+
}
|
|
1486
|
+
assertUnchanged() {
|
|
1487
|
+
const current = this.snapshot();
|
|
1488
|
+
for (const key of new Set([...current.keys(), ...this.expected.keys()])) if ((current.get(key) ?? null) !== (this.expected.get(key) ?? null)) throw new BackgroundPullChangedError();
|
|
1489
|
+
}
|
|
1490
|
+
mutate(keys, action) {
|
|
1491
|
+
for (const key of keys) if (this.fingerprint(key) !== (this.expected.get(key) ?? null)) throw new BackgroundPullChangedError();
|
|
1492
|
+
action();
|
|
1493
|
+
for (const key of keys) this.expected.set(key, this.fingerprint(key));
|
|
1494
|
+
}
|
|
1495
|
+
fingerprint(key) {
|
|
1496
|
+
const path = resolve(this.root, key);
|
|
1497
|
+
if (!path.startsWith(this.root + sep)) throw new BackgroundPullChangedError();
|
|
1498
|
+
for (let cursor = path;; cursor = dirname(cursor)) {
|
|
1499
|
+
try {
|
|
1500
|
+
if (lstatSync(cursor).isSymbolicLink()) throw new BackgroundPullChangedError();
|
|
1501
|
+
} catch (error) {
|
|
1502
|
+
if (!(typeof error === "object" && error !== null && "code" in error && error.code === "ENOENT")) throw error;
|
|
1503
|
+
}
|
|
1504
|
+
if (cursor === this.root) break;
|
|
1505
|
+
}
|
|
1506
|
+
try {
|
|
1507
|
+
return createHash("sha256").update(readFileSync(path)).digest("hex");
|
|
1508
|
+
} catch (error) {
|
|
1509
|
+
if (typeof error === "object" && error !== null && "code" in error && error.code === "ENOENT") return null;
|
|
1510
|
+
throw error;
|
|
1511
|
+
}
|
|
1512
|
+
}
|
|
1513
|
+
snapshot() {
|
|
1514
|
+
const result = /* @__PURE__ */ new Map();
|
|
1515
|
+
const visit = (directory) => {
|
|
1516
|
+
for (const entry of readdirSync(directory, { withFileTypes: true })) {
|
|
1517
|
+
if (entry.name.startsWith(".") || entry.name === "node_modules") continue;
|
|
1518
|
+
if (entry.isSymbolicLink()) throw new BackgroundPullChangedError();
|
|
1519
|
+
const path = join(directory, entry.name);
|
|
1520
|
+
if (entry.isDirectory()) visit(path);
|
|
1521
|
+
else {
|
|
1522
|
+
const key = relative(this.root, path).split(sep).join("/");
|
|
1523
|
+
result.set(key, this.fingerprint(key));
|
|
1524
|
+
}
|
|
1525
|
+
}
|
|
1526
|
+
};
|
|
1527
|
+
visit(this.root);
|
|
1528
|
+
for (const key of METADATA) result.set(key, this.fingerprint(key));
|
|
1529
|
+
return result;
|
|
1530
|
+
}
|
|
1531
|
+
};
|
|
1532
|
+
//#endregion
|
|
1458
1533
|
//#region src/theme/case-collisions.ts
|
|
1459
1534
|
var CaseCollisionError = class extends Error {
|
|
1460
1535
|
constructor(collisions) {
|
|
@@ -2228,6 +2303,7 @@ var Syncer = class {
|
|
|
2228
2303
|
try {
|
|
2229
2304
|
assetMetadata = await this.fetchThemeAssetMetadata(this.themeId);
|
|
2230
2305
|
} catch (error) {
|
|
2306
|
+
if (error instanceof BackgroundPullChangedError) throw error;
|
|
2231
2307
|
errors.push(`Read remote asset metadata: ${formatError(error)}`);
|
|
2232
2308
|
for (const resource of resourcesNeedingMetadata) if (resource.key) unresolvedMetadataKeys.add(resource.key);
|
|
2233
2309
|
}
|
|
@@ -2238,6 +2314,7 @@ var Syncer = class {
|
|
|
2238
2314
|
errors.push(`Could not find usable metadata for ${key} in theme #${this.themeId}`);
|
|
2239
2315
|
}
|
|
2240
2316
|
}
|
|
2317
|
+
opts.backgroundGuard?.assertUnchanged();
|
|
2241
2318
|
for (const resource of resources) {
|
|
2242
2319
|
const key = resource.key;
|
|
2243
2320
|
if (!key) continue;
|
|
@@ -2277,6 +2354,7 @@ var Syncer = class {
|
|
|
2277
2354
|
managedKeys.add(key);
|
|
2278
2355
|
filesToRemove.set(key, file);
|
|
2279
2356
|
} catch (error) {
|
|
2357
|
+
if (error instanceof BackgroundPullChangedError) throw error;
|
|
2280
2358
|
errors.push(`Externalize ${key}: ${formatError(error)}`);
|
|
2281
2359
|
}
|
|
2282
2360
|
}
|
|
@@ -2286,8 +2364,10 @@ var Syncer = class {
|
|
|
2286
2364
|
manifestChanged = true;
|
|
2287
2365
|
}
|
|
2288
2366
|
if (manifestChanged) try {
|
|
2289
|
-
this.assetManifest.write();
|
|
2367
|
+
if (opts.backgroundGuard) opts.backgroundGuard.mutate([".fluid-assets.json"], () => this.assetManifest.write());
|
|
2368
|
+
else this.assetManifest.write();
|
|
2290
2369
|
} catch (error) {
|
|
2370
|
+
if (error instanceof BackgroundPullChangedError) throw error;
|
|
2291
2371
|
errors.push(`Persist remote asset manifest: ${formatError(error)}`);
|
|
2292
2372
|
for (const key of changedManifestKeys) managedKeys.delete(key);
|
|
2293
2373
|
return {
|
|
@@ -2298,9 +2378,11 @@ var Syncer = class {
|
|
|
2298
2378
|
}
|
|
2299
2379
|
let linked = 0;
|
|
2300
2380
|
for (const [key, file] of filesToRemove) try {
|
|
2301
|
-
if (file.exists) unlinkSync(file.absolutePath);
|
|
2381
|
+
if (file.exists) if (opts.backgroundGuard) opts.backgroundGuard.mutate([key], () => unlinkSync(file.absolutePath));
|
|
2382
|
+
else unlinkSync(file.absolutePath);
|
|
2302
2383
|
linked++;
|
|
2303
2384
|
} catch (error) {
|
|
2385
|
+
if (error instanceof BackgroundPullChangedError) throw error;
|
|
2304
2386
|
errors.push(`Externalize ${key}: ${formatError(error)}`);
|
|
2305
2387
|
}
|
|
2306
2388
|
return {
|
|
@@ -3137,6 +3219,17 @@ var ShadowRepo = class ShadowRepo {
|
|
|
3137
3219
|
}
|
|
3138
3220
|
return this.headExists;
|
|
3139
3221
|
}
|
|
3222
|
+
/** Desktop snapshots deliberately use a different author. They are local
|
|
3223
|
+
* recovery points, not proof that those bytes were synced to the server. */
|
|
3224
|
+
async hasSyncedHead() {
|
|
3225
|
+
if (!await this.hasHead()) return false;
|
|
3226
|
+
const { stdout } = await this.git([
|
|
3227
|
+
"log",
|
|
3228
|
+
"-1",
|
|
3229
|
+
"--format=%ae"
|
|
3230
|
+
]);
|
|
3231
|
+
return stdout.toString("utf8").trim() === "cli@fluid.app";
|
|
3232
|
+
}
|
|
3140
3233
|
/**
|
|
3141
3234
|
* Every path recorded under HEAD's tree, recursively. Callers use
|
|
3142
3235
|
* this to detect local deletions (paths in HEAD, absent from the
|
|
@@ -3196,9 +3289,14 @@ var ShadowRepo = class ShadowRepo {
|
|
|
3196
3289
|
async commitState(files, message) {
|
|
3197
3290
|
const indexPath = mkdtempSync(join(tmpdir(), "fluid-shadow-")) + "/index";
|
|
3198
3291
|
try {
|
|
3199
|
-
|
|
3200
|
-
|
|
3201
|
-
|
|
3292
|
+
if (files.length > 0) await this.git([
|
|
3293
|
+
"update-index",
|
|
3294
|
+
"-z",
|
|
3295
|
+
"--index-info"
|
|
3296
|
+
], {
|
|
3297
|
+
input: Buffer.from(files.map(({ path, sha }) => `100644 ${sha}\t${path}\0`).join("")),
|
|
3298
|
+
env: { GIT_INDEX_FILE: indexPath }
|
|
3299
|
+
});
|
|
3202
3300
|
const treeSha = (await this.git(["write-tree"], { env: { GIT_INDEX_FILE: indexPath } })).stdout.toString("utf8").trim();
|
|
3203
3301
|
const parent = await this.hasHead() ? (await this.git(["rev-parse", "HEAD"])).stdout.toString("utf8").trim() : null;
|
|
3204
3302
|
const commitArgs = [
|
|
@@ -3332,14 +3430,13 @@ var ShadowRepo = class ShadowRepo {
|
|
|
3332
3430
|
"pipe"
|
|
3333
3431
|
]
|
|
3334
3432
|
});
|
|
3335
|
-
if (opts.input) child.stdin.write(opts.input);
|
|
3336
|
-
child.stdin.end();
|
|
3337
3433
|
return new Promise((resolve, reject) => {
|
|
3338
3434
|
const stdout = [];
|
|
3339
3435
|
const stderr = [];
|
|
3340
3436
|
child.stdout.on("data", (chunk) => stdout.push(chunk));
|
|
3341
3437
|
child.stderr.on("data", (chunk) => stderr.push(chunk));
|
|
3342
3438
|
child.on("error", reject);
|
|
3439
|
+
child.stdin.on("error", reject);
|
|
3343
3440
|
child.on("close", (code) => {
|
|
3344
3441
|
const out = Buffer.concat(stdout);
|
|
3345
3442
|
const err = Buffer.concat(stderr);
|
|
@@ -3355,6 +3452,7 @@ var ShadowRepo = class ShadowRepo {
|
|
|
3355
3452
|
reject(e);
|
|
3356
3453
|
}
|
|
3357
3454
|
});
|
|
3455
|
+
child.stdin.end(opts.input);
|
|
3358
3456
|
});
|
|
3359
3457
|
}
|
|
3360
3458
|
};
|
|
@@ -4280,6 +4378,38 @@ function renderPullFirst(spinner) {
|
|
|
4280
4378
|
console.log();
|
|
4281
4379
|
}
|
|
4282
4380
|
//#endregion
|
|
4381
|
+
//#region src/theme/background-pull.ts
|
|
4382
|
+
function containsSymlink(directory) {
|
|
4383
|
+
return readdirSync(directory, { withFileTypes: true }).some((entry) => {
|
|
4384
|
+
if (entry.name.startsWith(".") || entry.name === "node_modules") return false;
|
|
4385
|
+
return entry.isSymbolicLink() || entry.isDirectory() && containsSymlink(join(directory, entry.name));
|
|
4386
|
+
});
|
|
4387
|
+
}
|
|
4388
|
+
/** Fail closed for unattended updates. Desktop snapshots can advance shadow
|
|
4389
|
+
* HEAD with local work, so only a CLI sync baseline is eligible. */
|
|
4390
|
+
async function canPullThemeInBackground(root, themeId) {
|
|
4391
|
+
try {
|
|
4392
|
+
if (containsSymlink(root)) return false;
|
|
4393
|
+
const config = readThemeConfig(root);
|
|
4394
|
+
if (!config || config.themeId !== themeId || !existsSync(join(root, ".fluid-theme", "repo", "HEAD"))) return false;
|
|
4395
|
+
if (readFileSync(join(root, ".fluid-theme", "theme-id"), "utf8").trim() !== String(themeId)) return false;
|
|
4396
|
+
const manifestPath = join(root, ".fluid-assets.json");
|
|
4397
|
+
if (existsSync(manifestPath)) {
|
|
4398
|
+
const raw = JSON.parse(readFileSync(manifestPath, "utf8"));
|
|
4399
|
+
if (!raw || typeof raw !== "object" || !("version" in raw) || raw.version !== 1 || !("assets" in raw) || !raw.assets || typeof raw.assets !== "object" || Array.isArray(raw.assets)) return false;
|
|
4400
|
+
const manifest = new ThemeAssetManifest(root);
|
|
4401
|
+
if (manifest.entries().some(([, link]) => link.pending)) return false;
|
|
4402
|
+
if (!config.assetManifestSha || config.assetManifestSha !== manifest.fingerprint()) return false;
|
|
4403
|
+
} else if (config.assetManifestSha && config.assetManifestSha !== new ThemeAssetManifest(root).fingerprint()) return false;
|
|
4404
|
+
const shadow = await ShadowRepo.open(root, themeId);
|
|
4405
|
+
if (!await shadow.hasSyncedHead()) return false;
|
|
4406
|
+
const diff = await diffAgainstShadow(new ThemeRoot(root), shadow);
|
|
4407
|
+
return diff.changed.length === 0 && diff.deleted.length === 0;
|
|
4408
|
+
} catch {
|
|
4409
|
+
return false;
|
|
4410
|
+
}
|
|
4411
|
+
}
|
|
4412
|
+
//#endregion
|
|
4283
4413
|
//#region src/theme/merge-pull.ts
|
|
4284
4414
|
/** `write(..., RESOLVES_CONFLICT)`: this write is the chosen side of a conflict. */
|
|
4285
4415
|
const RESOLVES_CONFLICT = true;
|
|
@@ -4321,9 +4451,11 @@ async function mergePull(input) {
|
|
|
4321
4451
|
const unwrittenKeys = /* @__PURE__ */ new Set();
|
|
4322
4452
|
const tryWrite = (key, file, content) => {
|
|
4323
4453
|
try {
|
|
4324
|
-
file.write(content);
|
|
4454
|
+
if (input.backgroundGuard) input.backgroundGuard.mutate([key], () => file.write(content));
|
|
4455
|
+
else file.write(content);
|
|
4325
4456
|
return true;
|
|
4326
4457
|
} catch (e) {
|
|
4458
|
+
if (e instanceof BackgroundPullChangedError) throw e;
|
|
4327
4459
|
unwrittenKeys.add(key);
|
|
4328
4460
|
result.errors.push(`Reconcile ${key}: ${errMsg(e)}. ${RENAME_REMEDY}`);
|
|
4329
4461
|
return false;
|
|
@@ -4366,6 +4498,7 @@ async function mergePull(input) {
|
|
|
4366
4498
|
}
|
|
4367
4499
|
onProgress?.(++done, remote.length);
|
|
4368
4500
|
}
|
|
4501
|
+
input.backgroundGuard?.assertUnchanged();
|
|
4369
4502
|
for (const [key, remoteBuf] of remoteContent) {
|
|
4370
4503
|
const file = themeRoot.file(key);
|
|
4371
4504
|
if (!file.absolutePath.startsWith(themeRoot.root + sep)) {
|
|
@@ -4422,6 +4555,7 @@ async function mergePull(input) {
|
|
|
4422
4555
|
}
|
|
4423
4556
|
if (input.resolve) {
|
|
4424
4557
|
if (pendingWrites.some((w) => w.resolvesConflict)) await commitPushedState(themeRoot, shadow, syncCommitSubject("Snapshot before pull", input.actor ?? null));
|
|
4558
|
+
input.backgroundGuard?.assertUnchanged();
|
|
4425
4559
|
for (const { key, file, content, record } of pendingWrites) if (tryWrite(key, file, content)) record();
|
|
4426
4560
|
}
|
|
4427
4561
|
if (doDelete && await shadow.hasHead()) for (const file of themeRoot.files()) {
|
|
@@ -4433,9 +4567,12 @@ async function mergePull(input) {
|
|
|
4433
4567
|
if (!localBuf) continue;
|
|
4434
4568
|
if (!localBuf.equals(baseBuf)) continue;
|
|
4435
4569
|
try {
|
|
4436
|
-
unlinkSync(file.absolutePath);
|
|
4570
|
+
if (input.backgroundGuard) input.backgroundGuard.mutate([file.relativePath], () => unlinkSync(file.absolutePath));
|
|
4571
|
+
else unlinkSync(file.absolutePath);
|
|
4437
4572
|
result.deleted++;
|
|
4438
|
-
} catch {
|
|
4573
|
+
} catch (error) {
|
|
4574
|
+
if (error instanceof BackgroundPullChangedError) throw error;
|
|
4575
|
+
}
|
|
4439
4576
|
}
|
|
4440
4577
|
const commitEntries = [];
|
|
4441
4578
|
for (const [key, buf] of remoteContent) {
|
|
@@ -4465,7 +4602,10 @@ async function mergePull(input) {
|
|
|
4465
4602
|
sha: managedAssetSentinelSha
|
|
4466
4603
|
});
|
|
4467
4604
|
}
|
|
4468
|
-
if (commitEntries.length > 0 || remoteKeys.size === 0 && await shadow.hasHead())
|
|
4605
|
+
if (commitEntries.length > 0 || remoteKeys.size === 0 && await shadow.hasHead()) {
|
|
4606
|
+
input.backgroundGuard?.assertUnchanged();
|
|
4607
|
+
await shadow.commitState(commitEntries, syncCommitSubject("Pull", input.actor ?? null));
|
|
4608
|
+
}
|
|
4469
4609
|
return result;
|
|
4470
4610
|
}
|
|
4471
4611
|
function hasGeneratedConflictMarkers(content) {
|
|
@@ -4492,101 +4632,127 @@ async function fetchCompanySubdomain(api) {
|
|
|
4492
4632
|
return subdomain;
|
|
4493
4633
|
}
|
|
4494
4634
|
function createPullCommand() {
|
|
4495
|
-
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)").option("--resolve <side>", "Auto-resolve merge conflicts to one side instead of writing conflict markers: 'local' keeps your files' hunks, 'remote' takes the server's (for non-interactive use)").action(async (opts) => {
|
|
4496
|
-
|
|
4497
|
-
|
|
4498
|
-
|
|
4499
|
-
|
|
4500
|
-
|
|
4501
|
-
|
|
4502
|
-
const api = createApiClient();
|
|
4503
|
-
const workspace = findWorkspace();
|
|
4504
|
-
const theme = opts.theme ? await findTheme(api, opts.theme) : await selectTheme(api, "Select a theme to pull");
|
|
4505
|
-
const subdomain = await fetchCompanySubdomain(api);
|
|
4506
|
-
let root;
|
|
4507
|
-
if (opts.root) root = opts.root;
|
|
4508
|
-
else if (workspace) root = resolveThemeRootFromCwd(workspace) ?? join(workspace.root, "local", subdomain);
|
|
4509
|
-
else root = `.`;
|
|
4510
|
-
const absoluteRoot = resolve(root);
|
|
4511
|
-
const existingConfig = readThemeConfig(absoluteRoot);
|
|
4512
|
-
console.log();
|
|
4513
|
-
console.log(` Theme: ${chalk.bold(theme.name)} (#${theme.id})`);
|
|
4514
|
-
console.log(` Company: ${chalk.bold(subdomain)}`);
|
|
4515
|
-
console.log(` Target: ${chalk.bold(absoluteRoot)}`);
|
|
4516
|
-
console.log();
|
|
4517
|
-
if (!opts.yes) {
|
|
4518
|
-
const { confirmed } = await prompts({
|
|
4519
|
-
type: "confirm",
|
|
4520
|
-
name: "confirmed",
|
|
4521
|
-
message: "Pull theme to this directory?",
|
|
4522
|
-
initial: true
|
|
4523
|
-
}, { onCancel: () => process.exit(130) });
|
|
4524
|
-
if (!confirmed) {
|
|
4525
|
-
console.log("Aborted.");
|
|
4526
|
-
process.exit(0);
|
|
4527
|
-
}
|
|
4528
|
-
}
|
|
4529
|
-
const themeRoot = new ThemeRoot(root);
|
|
4530
|
-
const shadow = await ShadowRepo.open(absoluteRoot, theme.id);
|
|
4531
|
-
await migrateLegacyChecksumsIntoShadow({
|
|
4532
|
-
shadow,
|
|
4533
|
-
themeRoot,
|
|
4534
|
-
absoluteRoot,
|
|
4535
|
-
themeId: theme.id
|
|
4536
|
-
});
|
|
4537
|
-
const syncer = new Syncer(api, theme.id, themeRoot);
|
|
4538
|
-
const actorPromise = fetchSyncActor(api);
|
|
4539
|
-
const spinner = ora(`Pulling ${theme.name} (#${theme.id})…`).start();
|
|
4540
|
-
const resources = await syncer.downloadAll().catch((error) => {
|
|
4541
|
-
if (!(error instanceof CaseCollisionError)) throw error;
|
|
4542
|
-
spinner.fail(error.message);
|
|
4543
|
-
process.exit(1);
|
|
4544
|
-
});
|
|
4545
|
-
const externalizedAssets = await syncer.externalizePulledAssets(resources, { delete: !opts.nodelete });
|
|
4546
|
-
const result = await mergePull({
|
|
4547
|
-
themeRoot,
|
|
4548
|
-
shadow,
|
|
4549
|
-
remote: resources,
|
|
4550
|
-
fetchBinary: (url) => syncer.downloadBinaryAsset(url),
|
|
4551
|
-
delete: !opts.nodelete,
|
|
4552
|
-
force: opts.force ?? false,
|
|
4553
|
-
skipRemoteKeys: externalizedAssets.managedKeys,
|
|
4554
|
-
resolve: resolveSide,
|
|
4555
|
-
actor: await actorPromise,
|
|
4556
|
-
onProgress: (done, total) => {
|
|
4557
|
-
spinner.text = `Downloading ${done}/${total} files…`;
|
|
4635
|
+
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("--only-if-clean", "Skip with exit code 3 when local work cannot be proven synced").option("-f, --force", "Overwrite local without merging (skip conflict markers)").option("--resolve <side>", "Auto-resolve merge conflicts to one side instead of writing conflict markers: 'local' keeps your files' hunks, 'remote' takes the server's (for non-interactive use)").action(async (opts) => {
|
|
4636
|
+
let spinner;
|
|
4637
|
+
try {
|
|
4638
|
+
requireToken();
|
|
4639
|
+
if (opts.resolve !== void 0 && opts.resolve !== "local" && opts.resolve !== "remote") {
|
|
4640
|
+
console.error(`Invalid --resolve value "${opts.resolve}" — use "local" or "remote".`);
|
|
4641
|
+
process.exit(1);
|
|
4558
4642
|
}
|
|
4559
|
-
|
|
4560
|
-
|
|
4561
|
-
|
|
4562
|
-
|
|
4563
|
-
|
|
4564
|
-
|
|
4565
|
-
|
|
4566
|
-
|
|
4567
|
-
|
|
4568
|
-
|
|
4569
|
-
|
|
4570
|
-
|
|
4571
|
-
process.exitCode = 1;
|
|
4572
|
-
} else if (result.conflicts.length > 0) {
|
|
4573
|
-
spinner.warn(`${result.conflicts.length} conflict(s) — resolve markers before pushing: ${parts.join(", ")}.`);
|
|
4574
|
-
console.log();
|
|
4575
|
-
for (const c of result.conflicts) console.log(` ${chalk.yellow("CONFLICT")} ${c}`);
|
|
4643
|
+
const resolveSide = opts.resolve;
|
|
4644
|
+
const api = createApiClient();
|
|
4645
|
+
const workspace = findWorkspace();
|
|
4646
|
+
const theme = opts.theme ? await findTheme(api, opts.theme) : await selectTheme(api, "Select a theme to pull");
|
|
4647
|
+
const subdomain = await fetchCompanySubdomain(api);
|
|
4648
|
+
let root;
|
|
4649
|
+
if (opts.root) root = opts.root;
|
|
4650
|
+
else if (workspace) root = resolveThemeRootFromCwd(workspace) ?? join(workspace.root, "local", subdomain);
|
|
4651
|
+
else root = `.`;
|
|
4652
|
+
const absoluteRoot = resolve(root);
|
|
4653
|
+
const existingConfig = readThemeConfig(absoluteRoot);
|
|
4654
|
+
const backgroundGuard = opts.onlyIfClean ? new BackgroundPullGuard(absoluteRoot) : void 0;
|
|
4576
4655
|
console.log();
|
|
4577
|
-
console.log(`
|
|
4578
|
-
console.log(`
|
|
4656
|
+
console.log(` Theme: ${chalk.bold(theme.name)} (#${theme.id})`);
|
|
4657
|
+
console.log(` Company: ${chalk.bold(subdomain)}`);
|
|
4658
|
+
console.log(` Target: ${chalk.bold(absoluteRoot)}`);
|
|
4579
4659
|
console.log();
|
|
4580
|
-
|
|
4581
|
-
|
|
4582
|
-
|
|
4583
|
-
|
|
4584
|
-
|
|
4585
|
-
|
|
4586
|
-
|
|
4587
|
-
|
|
4588
|
-
|
|
4589
|
-
|
|
4660
|
+
if (!opts.yes) {
|
|
4661
|
+
const { confirmed } = await prompts({
|
|
4662
|
+
type: "confirm",
|
|
4663
|
+
name: "confirmed",
|
|
4664
|
+
message: "Pull theme to this directory?",
|
|
4665
|
+
initial: true
|
|
4666
|
+
}, { onCancel: () => process.exit(130) });
|
|
4667
|
+
if (!confirmed) {
|
|
4668
|
+
console.log("Aborted.");
|
|
4669
|
+
process.exit(0);
|
|
4670
|
+
}
|
|
4671
|
+
}
|
|
4672
|
+
if (opts.onlyIfClean && !await canPullThemeInBackground(absoluteRoot, theme.id)) {
|
|
4673
|
+
console.log("Skipped: local changes or an unknown sync baseline.");
|
|
4674
|
+
process.exit(3);
|
|
4675
|
+
}
|
|
4676
|
+
backgroundGuard?.assertUnchanged();
|
|
4677
|
+
const themeRoot = new ThemeRoot(root);
|
|
4678
|
+
const shadow = await ShadowRepo.open(absoluteRoot, theme.id);
|
|
4679
|
+
await migrateLegacyChecksumsIntoShadow({
|
|
4680
|
+
shadow,
|
|
4681
|
+
themeRoot,
|
|
4682
|
+
absoluteRoot,
|
|
4683
|
+
themeId: theme.id
|
|
4684
|
+
});
|
|
4685
|
+
const syncer = new Syncer(api, theme.id, themeRoot);
|
|
4686
|
+
const actorPromise = fetchSyncActor(api);
|
|
4687
|
+
spinner = ora(`Pulling ${theme.name} (#${theme.id})…`).start();
|
|
4688
|
+
const resources = await syncer.downloadAll().catch((error) => {
|
|
4689
|
+
if (!(error instanceof CaseCollisionError)) throw error;
|
|
4690
|
+
if (spinner) spinner.fail(error.message);
|
|
4691
|
+
process.exit(1);
|
|
4692
|
+
});
|
|
4693
|
+
if (opts.onlyIfClean && !await canPullThemeInBackground(absoluteRoot, theme.id)) {
|
|
4694
|
+
spinner.stop();
|
|
4695
|
+
console.log("Skipped: local files changed while downloading.");
|
|
4696
|
+
process.exit(3);
|
|
4697
|
+
}
|
|
4698
|
+
const externalizedAssets = await syncer.externalizePulledAssets(resources, {
|
|
4699
|
+
delete: !opts.nodelete,
|
|
4700
|
+
backgroundGuard
|
|
4701
|
+
});
|
|
4702
|
+
const result = await mergePull({
|
|
4703
|
+
themeRoot,
|
|
4704
|
+
shadow,
|
|
4705
|
+
backgroundGuard,
|
|
4706
|
+
remote: resources,
|
|
4707
|
+
fetchBinary: (url) => syncer.downloadBinaryAsset(url),
|
|
4708
|
+
delete: !opts.nodelete,
|
|
4709
|
+
force: opts.force ?? false,
|
|
4710
|
+
skipRemoteKeys: externalizedAssets.managedKeys,
|
|
4711
|
+
resolve: resolveSide,
|
|
4712
|
+
actor: await actorPromise,
|
|
4713
|
+
onProgress: (done, total) => {
|
|
4714
|
+
if (spinner) spinner.text = `Downloading ${done}/${total} files…`;
|
|
4715
|
+
}
|
|
4716
|
+
});
|
|
4717
|
+
result.errors.push(...externalizedAssets.errors);
|
|
4718
|
+
const parts = [];
|
|
4719
|
+
if (result.written > 0) parts.push(`wrote ${result.written} file(s)`);
|
|
4720
|
+
if (result.merged > 0) parts.push(`merged ${result.merged} file(s) cleanly`);
|
|
4721
|
+
if (externalizedAssets.linked > 0) parts.push(`kept ${externalizedAssets.linked} binary asset(s) remote`);
|
|
4722
|
+
if (result.autoResolved.length > 0) parts.push(`auto-resolved ${result.autoResolved.length} conflict(s) (kept ${resolveSide})`);
|
|
4723
|
+
if (result.deleted > 0) parts.push(`deleted ${result.deleted} local file(s)`);
|
|
4724
|
+
if (result.skipped > 0) parts.push(`${result.skipped} already in sync`);
|
|
4725
|
+
if (result.errors.length) {
|
|
4726
|
+
spinner.warn(`Pulled with ${result.errors.length} error(s): ${parts.join(", ")}.`);
|
|
4727
|
+
for (const e of result.errors) console.error(` ${e}`);
|
|
4728
|
+
process.exitCode = 1;
|
|
4729
|
+
} else if (result.conflicts.length > 0) {
|
|
4730
|
+
spinner.warn(`${result.conflicts.length} conflict(s) — resolve markers before pushing: ${parts.join(", ")}.`);
|
|
4731
|
+
console.log();
|
|
4732
|
+
for (const c of result.conflicts) console.log(` ${chalk.yellow("CONFLICT")} ${c}`);
|
|
4733
|
+
console.log();
|
|
4734
|
+
console.log(` Edit each file above to reconcile the ${chalk.cyan("<<<<<<<")} / ${chalk.cyan(">>>>>>>")} markers,`);
|
|
4735
|
+
console.log(` then run ${chalk.cyan("fluid theme push")} once your resolution is in place.`);
|
|
4736
|
+
console.log();
|
|
4737
|
+
} else spinner.succeed(parts.join(", ") || "Already up to date.");
|
|
4738
|
+
const remoteSha = syncer.remoteSha();
|
|
4739
|
+
backgroundGuard?.assertUnchanged();
|
|
4740
|
+
const updateConfig = () => writeThemeConfig(absoluteRoot, {
|
|
4741
|
+
themeId: theme.id,
|
|
4742
|
+
themeName: theme.name,
|
|
4743
|
+
company: subdomain,
|
|
4744
|
+
baseSha: remoteSha ?? existingConfig?.baseSha,
|
|
4745
|
+
assetManifestSha: new ThemeAssetManifest(absoluteRoot).fingerprint({ excludePending: true })
|
|
4746
|
+
});
|
|
4747
|
+
if (backgroundGuard) backgroundGuard.mutate([".fluid-theme.json"], updateConfig);
|
|
4748
|
+
else updateConfig();
|
|
4749
|
+
if (result.conflicts.length > 0) process.exit(1);
|
|
4750
|
+
} catch (error) {
|
|
4751
|
+
if (!(error instanceof BackgroundPullChangedError)) throw error;
|
|
4752
|
+
spinner?.stop();
|
|
4753
|
+
console.log(error.message);
|
|
4754
|
+
process.exitCode = 3;
|
|
4755
|
+
}
|
|
4590
4756
|
});
|
|
4591
4757
|
}
|
|
4592
4758
|
//#endregion
|