@fluid-app/fluid-cli-theme-dev 0.1.55 → 0.1.56
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 +261 -100
- package/dist/index.mjs.map +1 -1
- package/package.json +2 -2
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
|
|
@@ -4280,6 +4373,38 @@ function renderPullFirst(spinner) {
|
|
|
4280
4373
|
console.log();
|
|
4281
4374
|
}
|
|
4282
4375
|
//#endregion
|
|
4376
|
+
//#region src/theme/background-pull.ts
|
|
4377
|
+
function containsSymlink(directory) {
|
|
4378
|
+
return readdirSync(directory, { withFileTypes: true }).some((entry) => {
|
|
4379
|
+
if (entry.name.startsWith(".") || entry.name === "node_modules") return false;
|
|
4380
|
+
return entry.isSymbolicLink() || entry.isDirectory() && containsSymlink(join(directory, entry.name));
|
|
4381
|
+
});
|
|
4382
|
+
}
|
|
4383
|
+
/** Fail closed for unattended updates. Desktop snapshots can advance shadow
|
|
4384
|
+
* HEAD with local work, so only a CLI sync baseline is eligible. */
|
|
4385
|
+
async function canPullThemeInBackground(root, themeId) {
|
|
4386
|
+
try {
|
|
4387
|
+
if (containsSymlink(root)) return false;
|
|
4388
|
+
const config = readThemeConfig(root);
|
|
4389
|
+
if (!config || config.themeId !== themeId || !existsSync(join(root, ".fluid-theme", "repo", "HEAD"))) return false;
|
|
4390
|
+
if (readFileSync(join(root, ".fluid-theme", "theme-id"), "utf8").trim() !== String(themeId)) return false;
|
|
4391
|
+
const manifestPath = join(root, ".fluid-assets.json");
|
|
4392
|
+
if (existsSync(manifestPath)) {
|
|
4393
|
+
const raw = JSON.parse(readFileSync(manifestPath, "utf8"));
|
|
4394
|
+
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;
|
|
4395
|
+
const manifest = new ThemeAssetManifest(root);
|
|
4396
|
+
if (manifest.entries().some(([, link]) => link.pending)) return false;
|
|
4397
|
+
if (!config.assetManifestSha || config.assetManifestSha !== manifest.fingerprint()) return false;
|
|
4398
|
+
} else if (config.assetManifestSha && config.assetManifestSha !== new ThemeAssetManifest(root).fingerprint()) return false;
|
|
4399
|
+
const shadow = await ShadowRepo.open(root, themeId);
|
|
4400
|
+
if (!await shadow.hasSyncedHead()) return false;
|
|
4401
|
+
const diff = await diffAgainstShadow(new ThemeRoot(root), shadow);
|
|
4402
|
+
return diff.changed.length === 0 && diff.deleted.length === 0;
|
|
4403
|
+
} catch {
|
|
4404
|
+
return false;
|
|
4405
|
+
}
|
|
4406
|
+
}
|
|
4407
|
+
//#endregion
|
|
4283
4408
|
//#region src/theme/merge-pull.ts
|
|
4284
4409
|
/** `write(..., RESOLVES_CONFLICT)`: this write is the chosen side of a conflict. */
|
|
4285
4410
|
const RESOLVES_CONFLICT = true;
|
|
@@ -4321,9 +4446,11 @@ async function mergePull(input) {
|
|
|
4321
4446
|
const unwrittenKeys = /* @__PURE__ */ new Set();
|
|
4322
4447
|
const tryWrite = (key, file, content) => {
|
|
4323
4448
|
try {
|
|
4324
|
-
file.write(content);
|
|
4449
|
+
if (input.backgroundGuard) input.backgroundGuard.mutate([key], () => file.write(content));
|
|
4450
|
+
else file.write(content);
|
|
4325
4451
|
return true;
|
|
4326
4452
|
} catch (e) {
|
|
4453
|
+
if (e instanceof BackgroundPullChangedError) throw e;
|
|
4327
4454
|
unwrittenKeys.add(key);
|
|
4328
4455
|
result.errors.push(`Reconcile ${key}: ${errMsg(e)}. ${RENAME_REMEDY}`);
|
|
4329
4456
|
return false;
|
|
@@ -4366,6 +4493,7 @@ async function mergePull(input) {
|
|
|
4366
4493
|
}
|
|
4367
4494
|
onProgress?.(++done, remote.length);
|
|
4368
4495
|
}
|
|
4496
|
+
input.backgroundGuard?.assertUnchanged();
|
|
4369
4497
|
for (const [key, remoteBuf] of remoteContent) {
|
|
4370
4498
|
const file = themeRoot.file(key);
|
|
4371
4499
|
if (!file.absolutePath.startsWith(themeRoot.root + sep)) {
|
|
@@ -4422,6 +4550,7 @@ async function mergePull(input) {
|
|
|
4422
4550
|
}
|
|
4423
4551
|
if (input.resolve) {
|
|
4424
4552
|
if (pendingWrites.some((w) => w.resolvesConflict)) await commitPushedState(themeRoot, shadow, syncCommitSubject("Snapshot before pull", input.actor ?? null));
|
|
4553
|
+
input.backgroundGuard?.assertUnchanged();
|
|
4425
4554
|
for (const { key, file, content, record } of pendingWrites) if (tryWrite(key, file, content)) record();
|
|
4426
4555
|
}
|
|
4427
4556
|
if (doDelete && await shadow.hasHead()) for (const file of themeRoot.files()) {
|
|
@@ -4433,9 +4562,12 @@ async function mergePull(input) {
|
|
|
4433
4562
|
if (!localBuf) continue;
|
|
4434
4563
|
if (!localBuf.equals(baseBuf)) continue;
|
|
4435
4564
|
try {
|
|
4436
|
-
unlinkSync(file.absolutePath);
|
|
4565
|
+
if (input.backgroundGuard) input.backgroundGuard.mutate([file.relativePath], () => unlinkSync(file.absolutePath));
|
|
4566
|
+
else unlinkSync(file.absolutePath);
|
|
4437
4567
|
result.deleted++;
|
|
4438
|
-
} catch {
|
|
4568
|
+
} catch (error) {
|
|
4569
|
+
if (error instanceof BackgroundPullChangedError) throw error;
|
|
4570
|
+
}
|
|
4439
4571
|
}
|
|
4440
4572
|
const commitEntries = [];
|
|
4441
4573
|
for (const [key, buf] of remoteContent) {
|
|
@@ -4465,7 +4597,10 @@ async function mergePull(input) {
|
|
|
4465
4597
|
sha: managedAssetSentinelSha
|
|
4466
4598
|
});
|
|
4467
4599
|
}
|
|
4468
|
-
if (commitEntries.length > 0 || remoteKeys.size === 0 && await shadow.hasHead())
|
|
4600
|
+
if (commitEntries.length > 0 || remoteKeys.size === 0 && await shadow.hasHead()) {
|
|
4601
|
+
input.backgroundGuard?.assertUnchanged();
|
|
4602
|
+
await shadow.commitState(commitEntries, syncCommitSubject("Pull", input.actor ?? null));
|
|
4603
|
+
}
|
|
4469
4604
|
return result;
|
|
4470
4605
|
}
|
|
4471
4606
|
function hasGeneratedConflictMarkers(content) {
|
|
@@ -4492,101 +4627,127 @@ async function fetchCompanySubdomain(api) {
|
|
|
4492
4627
|
return subdomain;
|
|
4493
4628
|
}
|
|
4494
4629
|
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…`;
|
|
4630
|
+
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) => {
|
|
4631
|
+
let spinner;
|
|
4632
|
+
try {
|
|
4633
|
+
requireToken();
|
|
4634
|
+
if (opts.resolve !== void 0 && opts.resolve !== "local" && opts.resolve !== "remote") {
|
|
4635
|
+
console.error(`Invalid --resolve value "${opts.resolve}" — use "local" or "remote".`);
|
|
4636
|
+
process.exit(1);
|
|
4558
4637
|
}
|
|
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}`);
|
|
4638
|
+
const resolveSide = opts.resolve;
|
|
4639
|
+
const api = createApiClient();
|
|
4640
|
+
const workspace = findWorkspace();
|
|
4641
|
+
const theme = opts.theme ? await findTheme(api, opts.theme) : await selectTheme(api, "Select a theme to pull");
|
|
4642
|
+
const subdomain = await fetchCompanySubdomain(api);
|
|
4643
|
+
let root;
|
|
4644
|
+
if (opts.root) root = opts.root;
|
|
4645
|
+
else if (workspace) root = resolveThemeRootFromCwd(workspace) ?? join(workspace.root, "local", subdomain);
|
|
4646
|
+
else root = `.`;
|
|
4647
|
+
const absoluteRoot = resolve(root);
|
|
4648
|
+
const existingConfig = readThemeConfig(absoluteRoot);
|
|
4649
|
+
const backgroundGuard = opts.onlyIfClean ? new BackgroundPullGuard(absoluteRoot) : void 0;
|
|
4576
4650
|
console.log();
|
|
4577
|
-
console.log(`
|
|
4578
|
-
console.log(`
|
|
4651
|
+
console.log(` Theme: ${chalk.bold(theme.name)} (#${theme.id})`);
|
|
4652
|
+
console.log(` Company: ${chalk.bold(subdomain)}`);
|
|
4653
|
+
console.log(` Target: ${chalk.bold(absoluteRoot)}`);
|
|
4579
4654
|
console.log();
|
|
4580
|
-
|
|
4581
|
-
|
|
4582
|
-
|
|
4583
|
-
|
|
4584
|
-
|
|
4585
|
-
|
|
4586
|
-
|
|
4587
|
-
|
|
4588
|
-
|
|
4589
|
-
|
|
4655
|
+
if (!opts.yes) {
|
|
4656
|
+
const { confirmed } = await prompts({
|
|
4657
|
+
type: "confirm",
|
|
4658
|
+
name: "confirmed",
|
|
4659
|
+
message: "Pull theme to this directory?",
|
|
4660
|
+
initial: true
|
|
4661
|
+
}, { onCancel: () => process.exit(130) });
|
|
4662
|
+
if (!confirmed) {
|
|
4663
|
+
console.log("Aborted.");
|
|
4664
|
+
process.exit(0);
|
|
4665
|
+
}
|
|
4666
|
+
}
|
|
4667
|
+
if (opts.onlyIfClean && !await canPullThemeInBackground(absoluteRoot, theme.id)) {
|
|
4668
|
+
console.log("Skipped: local changes or an unknown sync baseline.");
|
|
4669
|
+
process.exit(3);
|
|
4670
|
+
}
|
|
4671
|
+
backgroundGuard?.assertUnchanged();
|
|
4672
|
+
const themeRoot = new ThemeRoot(root);
|
|
4673
|
+
const shadow = await ShadowRepo.open(absoluteRoot, theme.id);
|
|
4674
|
+
await migrateLegacyChecksumsIntoShadow({
|
|
4675
|
+
shadow,
|
|
4676
|
+
themeRoot,
|
|
4677
|
+
absoluteRoot,
|
|
4678
|
+
themeId: theme.id
|
|
4679
|
+
});
|
|
4680
|
+
const syncer = new Syncer(api, theme.id, themeRoot);
|
|
4681
|
+
const actorPromise = fetchSyncActor(api);
|
|
4682
|
+
spinner = ora(`Pulling ${theme.name} (#${theme.id})…`).start();
|
|
4683
|
+
const resources = await syncer.downloadAll().catch((error) => {
|
|
4684
|
+
if (!(error instanceof CaseCollisionError)) throw error;
|
|
4685
|
+
if (spinner) spinner.fail(error.message);
|
|
4686
|
+
process.exit(1);
|
|
4687
|
+
});
|
|
4688
|
+
if (opts.onlyIfClean && !await canPullThemeInBackground(absoluteRoot, theme.id)) {
|
|
4689
|
+
spinner.stop();
|
|
4690
|
+
console.log("Skipped: local files changed while downloading.");
|
|
4691
|
+
process.exit(3);
|
|
4692
|
+
}
|
|
4693
|
+
const externalizedAssets = await syncer.externalizePulledAssets(resources, {
|
|
4694
|
+
delete: !opts.nodelete,
|
|
4695
|
+
backgroundGuard
|
|
4696
|
+
});
|
|
4697
|
+
const result = await mergePull({
|
|
4698
|
+
themeRoot,
|
|
4699
|
+
shadow,
|
|
4700
|
+
backgroundGuard,
|
|
4701
|
+
remote: resources,
|
|
4702
|
+
fetchBinary: (url) => syncer.downloadBinaryAsset(url),
|
|
4703
|
+
delete: !opts.nodelete,
|
|
4704
|
+
force: opts.force ?? false,
|
|
4705
|
+
skipRemoteKeys: externalizedAssets.managedKeys,
|
|
4706
|
+
resolve: resolveSide,
|
|
4707
|
+
actor: await actorPromise,
|
|
4708
|
+
onProgress: (done, total) => {
|
|
4709
|
+
if (spinner) spinner.text = `Downloading ${done}/${total} files…`;
|
|
4710
|
+
}
|
|
4711
|
+
});
|
|
4712
|
+
result.errors.push(...externalizedAssets.errors);
|
|
4713
|
+
const parts = [];
|
|
4714
|
+
if (result.written > 0) parts.push(`wrote ${result.written} file(s)`);
|
|
4715
|
+
if (result.merged > 0) parts.push(`merged ${result.merged} file(s) cleanly`);
|
|
4716
|
+
if (externalizedAssets.linked > 0) parts.push(`kept ${externalizedAssets.linked} binary asset(s) remote`);
|
|
4717
|
+
if (result.autoResolved.length > 0) parts.push(`auto-resolved ${result.autoResolved.length} conflict(s) (kept ${resolveSide})`);
|
|
4718
|
+
if (result.deleted > 0) parts.push(`deleted ${result.deleted} local file(s)`);
|
|
4719
|
+
if (result.skipped > 0) parts.push(`${result.skipped} already in sync`);
|
|
4720
|
+
if (result.errors.length) {
|
|
4721
|
+
spinner.warn(`Pulled with ${result.errors.length} error(s): ${parts.join(", ")}.`);
|
|
4722
|
+
for (const e of result.errors) console.error(` ${e}`);
|
|
4723
|
+
process.exitCode = 1;
|
|
4724
|
+
} else if (result.conflicts.length > 0) {
|
|
4725
|
+
spinner.warn(`${result.conflicts.length} conflict(s) — resolve markers before pushing: ${parts.join(", ")}.`);
|
|
4726
|
+
console.log();
|
|
4727
|
+
for (const c of result.conflicts) console.log(` ${chalk.yellow("CONFLICT")} ${c}`);
|
|
4728
|
+
console.log();
|
|
4729
|
+
console.log(` Edit each file above to reconcile the ${chalk.cyan("<<<<<<<")} / ${chalk.cyan(">>>>>>>")} markers,`);
|
|
4730
|
+
console.log(` then run ${chalk.cyan("fluid theme push")} once your resolution is in place.`);
|
|
4731
|
+
console.log();
|
|
4732
|
+
} else spinner.succeed(parts.join(", ") || "Already up to date.");
|
|
4733
|
+
const remoteSha = syncer.remoteSha();
|
|
4734
|
+
backgroundGuard?.assertUnchanged();
|
|
4735
|
+
const updateConfig = () => writeThemeConfig(absoluteRoot, {
|
|
4736
|
+
themeId: theme.id,
|
|
4737
|
+
themeName: theme.name,
|
|
4738
|
+
company: subdomain,
|
|
4739
|
+
baseSha: remoteSha ?? existingConfig?.baseSha,
|
|
4740
|
+
assetManifestSha: new ThemeAssetManifest(absoluteRoot).fingerprint({ excludePending: true })
|
|
4741
|
+
});
|
|
4742
|
+
if (backgroundGuard) backgroundGuard.mutate([".fluid-theme.json"], updateConfig);
|
|
4743
|
+
else updateConfig();
|
|
4744
|
+
if (result.conflicts.length > 0) process.exit(1);
|
|
4745
|
+
} catch (error) {
|
|
4746
|
+
if (!(error instanceof BackgroundPullChangedError)) throw error;
|
|
4747
|
+
spinner?.stop();
|
|
4748
|
+
console.log(error.message);
|
|
4749
|
+
process.exitCode = 3;
|
|
4750
|
+
}
|
|
4590
4751
|
});
|
|
4591
4752
|
}
|
|
4592
4753
|
//#endregion
|