@fluid-app/fluid-cli-theme-dev 0.1.47 → 0.1.48
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/README.md +4 -0
- package/dist/index.mjs +1091 -995
- package/dist/index.mjs.map +1 -1
- package/package.json +3 -3
package/dist/index.mjs
CHANGED
|
@@ -6,12 +6,12 @@ import { createHash, randomBytes } from "node:crypto";
|
|
|
6
6
|
import http from "node:http";
|
|
7
7
|
import https from "node:https";
|
|
8
8
|
import chokidar from "chokidar";
|
|
9
|
+
import { execFileSync, spawn } from "node:child_process";
|
|
10
|
+
import { tmpdir } from "node:os";
|
|
9
11
|
import net from "node:net";
|
|
10
12
|
import chalk from "chalk";
|
|
11
13
|
import prompts from "prompts";
|
|
12
14
|
import ora from "ora";
|
|
13
|
-
import { execFileSync, spawn } from "node:child_process";
|
|
14
|
-
import { tmpdir } from "node:os";
|
|
15
15
|
import { fileURLToPath } from "node:url";
|
|
16
16
|
//#region ../../platform/api-client-core/src/api-error-shape.ts
|
|
17
17
|
/**
|
|
@@ -1487,6 +1487,15 @@ var ThemeAssetManifest = class {
|
|
|
1487
1487
|
entries() {
|
|
1488
1488
|
return Object.entries(this.assets).map(([key, link]) => [key, copyLink(link)]);
|
|
1489
1489
|
}
|
|
1490
|
+
/**
|
|
1491
|
+
* Stable digest of the URL references represented by this manifest.
|
|
1492
|
+
* Pull baselines exclude pending entries because those belong only to an
|
|
1493
|
+
* existing dev target and are deliberately absent from the pulled source.
|
|
1494
|
+
*/
|
|
1495
|
+
fingerprint(opts = {}) {
|
|
1496
|
+
const entries = this.entries().filter(([, link]) => !opts.excludePending || !link.pending).toSorted(([left], [right]) => left < right ? -1 : left > right ? 1 : 0);
|
|
1497
|
+
return createHash("sha256").update(JSON.stringify(entries)).digest("hex");
|
|
1498
|
+
}
|
|
1490
1499
|
has(key) {
|
|
1491
1500
|
return this.assets[key] !== void 0;
|
|
1492
1501
|
}
|
|
@@ -2117,7 +2126,7 @@ var Syncer = class {
|
|
|
2117
2126
|
const response = await deleteThemeResource(this.api, this.themeId, body);
|
|
2118
2127
|
if (response.content_version_sha) this.lastKnownRemoteSha = response.content_version_sha;
|
|
2119
2128
|
} catch (e) {
|
|
2120
|
-
throw this.rethrowIfConflict(e);
|
|
2129
|
+
if (!isNotFoundError(e)) throw this.rethrowIfConflict(e);
|
|
2121
2130
|
}
|
|
2122
2131
|
this.removeRemoteResource(relativePath);
|
|
2123
2132
|
}
|
|
@@ -2279,8 +2288,8 @@ var Syncer = class {
|
|
|
2279
2288
|
return result;
|
|
2280
2289
|
}
|
|
2281
2290
|
}
|
|
2282
|
-
await this.fetchChecksums();
|
|
2283
|
-
await this.preflightPush(opts.baseSha);
|
|
2291
|
+
if (!opts.diff) await this.fetchChecksums();
|
|
2292
|
+
if (!opts.skipPreflight) await this.preflightPush(opts.baseSha);
|
|
2284
2293
|
let baseSha = opts.baseSha ?? null;
|
|
2285
2294
|
if (opts.linkManagedAssets) {
|
|
2286
2295
|
result.linked = await this.linkManagedAssets(opts.linkManagedAssets);
|
|
@@ -2289,7 +2298,7 @@ var Syncer = class {
|
|
|
2289
2298
|
this.assetManifest.reload();
|
|
2290
2299
|
this.ensureManagedAssetsAreResolved();
|
|
2291
2300
|
}
|
|
2292
|
-
const toUpload = localFiles.filter((f) => f.exists && this.hasChanged(f));
|
|
2301
|
+
const toUpload = opts.diff?.changed ?? localFiles.filter((f) => f.exists && this.hasChanged(f));
|
|
2293
2302
|
let done = 0;
|
|
2294
2303
|
for (const file of toUpload) {
|
|
2295
2304
|
try {
|
|
@@ -2303,9 +2312,11 @@ var Syncer = class {
|
|
|
2303
2312
|
opts.onProgress?.(++done, toUpload.length);
|
|
2304
2313
|
}
|
|
2305
2314
|
if (opts.delete) {
|
|
2306
|
-
const
|
|
2307
|
-
|
|
2308
|
-
|
|
2315
|
+
const toDelete = opts.diff?.deleted ?? (() => {
|
|
2316
|
+
const localPaths = new Set(localFiles.map((f) => f.relativePath));
|
|
2317
|
+
for (const key of this.assetManifest.keys()) localPaths.add(key);
|
|
2318
|
+
return this.remoteKeys().filter((key) => !localPaths.has(key) && !this.themeRoot.ignore.ignore(key));
|
|
2319
|
+
})();
|
|
2309
2320
|
for (const key of toDelete) try {
|
|
2310
2321
|
await this.deleteRemoteFile(key, baseSha);
|
|
2311
2322
|
baseSha = this.lastKnownRemoteSha;
|
|
@@ -2459,1076 +2470,1158 @@ function isRecord(value) {
|
|
|
2459
2470
|
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
2460
2471
|
}
|
|
2461
2472
|
//#endregion
|
|
2462
|
-
//#region src/theme/
|
|
2473
|
+
//#region src/theme/shadow-repo.ts
|
|
2463
2474
|
/**
|
|
2464
|
-
*
|
|
2465
|
-
*
|
|
2466
|
-
*
|
|
2467
|
-
*
|
|
2468
|
-
*
|
|
2469
|
-
*
|
|
2470
|
-
*
|
|
2475
|
+
* A bare git repo hidden under `.fluid-theme/repo` that stands in for
|
|
2476
|
+
* git's index+HEAD when talking to the Fluid server. Every pull commits
|
|
2477
|
+
* the incoming remote state onto a single branch (`refs/heads/main`);
|
|
2478
|
+
* every successful push commits the outgoing local state onto the same
|
|
2479
|
+
* branch. HEAD therefore represents "the last state the CLI and server
|
|
2480
|
+
* agreed on", and its tree is the natural three-way-merge base for the
|
|
2481
|
+
* next pull.
|
|
2471
2482
|
*
|
|
2472
|
-
*
|
|
2473
|
-
*
|
|
2474
|
-
*
|
|
2475
|
-
*
|
|
2483
|
+
* We stay in git's plumbing layer — no working tree, no index file
|
|
2484
|
+
* next to the theme content — so the shadow repo cannot interfere
|
|
2485
|
+
* with the user's own git repo (if they have one) around the theme
|
|
2486
|
+
* dir. All state lives inside `.fluid-theme/`.
|
|
2476
2487
|
*
|
|
2477
|
-
*
|
|
2478
|
-
*
|
|
2479
|
-
*
|
|
2480
|
-
* warn-only heuristic — a real fix requires parsing liquid, which is
|
|
2481
|
-
* out of scope here (see the server-side validation note in the PR).
|
|
2488
|
+
* The class only wraps the small handful of plumbing commands we
|
|
2489
|
+
* actually need: hash-object, cat-file, write-tree (via a temp index),
|
|
2490
|
+
* commit-tree, update-ref, and merge-file. Everything else stays out.
|
|
2482
2491
|
*/
|
|
2483
|
-
|
|
2484
|
-
|
|
2485
|
-
|
|
2486
|
-
|
|
2487
|
-
|
|
2488
|
-
unclosedTags += 1;
|
|
2489
|
-
break;
|
|
2490
|
-
case "%}":
|
|
2491
|
-
if (unclosedTags > 0) unclosedTags -= 1;
|
|
2492
|
-
break;
|
|
2493
|
-
case "{{":
|
|
2494
|
-
unclosedOutputs += 1;
|
|
2495
|
-
break;
|
|
2496
|
-
case "}}":
|
|
2497
|
-
if (unclosedOutputs > 0) unclosedOutputs -= 1;
|
|
2498
|
-
break;
|
|
2492
|
+
var ShadowRepo = class ShadowRepo {
|
|
2493
|
+
headExists = void 0;
|
|
2494
|
+
constructor(themeRoot, gitDir) {
|
|
2495
|
+
this.themeRoot = themeRoot;
|
|
2496
|
+
this.gitDir = gitDir;
|
|
2499
2497
|
}
|
|
2500
|
-
|
|
2501
|
-
|
|
2502
|
-
|
|
2503
|
-
|
|
2504
|
-
|
|
2505
|
-
|
|
2506
|
-
|
|
2507
|
-
|
|
2508
|
-
|
|
2509
|
-
|
|
2510
|
-
|
|
2511
|
-
|
|
2512
|
-
|
|
2513
|
-
|
|
2514
|
-
|
|
2515
|
-
|
|
2516
|
-
|
|
2517
|
-
["unless", "endunless"]
|
|
2518
|
-
]);
|
|
2519
|
-
const CLOSING_TAGS = new Set(BLOCK_TAGS.values());
|
|
2520
|
-
const OPAQUE_BLOCK_TAGS = new Set([
|
|
2521
|
-
"comment",
|
|
2522
|
-
"javascript",
|
|
2523
|
-
"raw",
|
|
2524
|
-
"schema",
|
|
2525
|
-
"style",
|
|
2526
|
-
"stylesheet"
|
|
2527
|
-
]);
|
|
2528
|
-
/**
|
|
2529
|
-
* Find structurally unbalanced Liquid block tags such as an `{% if %}` with
|
|
2530
|
-
* no `{% endif %}`. This intentionally recognizes only established paired
|
|
2531
|
-
* tags; custom and inline tags are ignored rather than guessed at.
|
|
2532
|
-
*
|
|
2533
|
-
* Content inside raw/comment/schema/style/javascript blocks is opaque to
|
|
2534
|
-
* Liquid and therefore skipped until that block's matching close tag. This
|
|
2535
|
-
* prevents CSS, JSON, and examples containing Liquid-looking text from
|
|
2536
|
-
* producing false errors.
|
|
2537
|
-
*/
|
|
2538
|
-
function findLiquidBlockTagDiagnostics(content) {
|
|
2539
|
-
const stack = [];
|
|
2540
|
-
const diagnostics = [];
|
|
2541
|
-
let line = 1;
|
|
2542
|
-
let previousTagIndex = 0;
|
|
2543
|
-
const processTag = (name, tagLine) => {
|
|
2544
|
-
const open = stack.at(-1);
|
|
2545
|
-
if (open && OPAQUE_BLOCK_TAGS.has(open.name)) {
|
|
2546
|
-
if (name === open.expectedClose) stack.pop();
|
|
2547
|
-
return;
|
|
2548
|
-
}
|
|
2549
|
-
const expectedClose = BLOCK_TAGS.get(name);
|
|
2550
|
-
if (expectedClose) {
|
|
2551
|
-
stack.push({
|
|
2552
|
-
name,
|
|
2553
|
-
expectedClose,
|
|
2554
|
-
line: tagLine
|
|
2498
|
+
/**
|
|
2499
|
+
* Return a ShadowRepo bound to `themeId` for the given theme root.
|
|
2500
|
+
* The shadow is initialized on first use and re-initialized when
|
|
2501
|
+
* the caller passes a themeId different from the one previously
|
|
2502
|
+
* recorded — the merge base is only meaningful for the theme it
|
|
2503
|
+
* was captured against, so a cross-theme operation (pull A → push
|
|
2504
|
+
* B, or two pulls of different themes into one dir) starts from a
|
|
2505
|
+
* clean slate rather than pretending B's state matches A's HEAD.
|
|
2506
|
+
*/
|
|
2507
|
+
static async open(themeRoot, themeId) {
|
|
2508
|
+
const shadowDir = join(themeRoot, ".fluid-theme");
|
|
2509
|
+
const gitDir = join(shadowDir, "repo");
|
|
2510
|
+
const themeIdFile = join(shadowDir, "theme-id");
|
|
2511
|
+
if (existsSync(gitDir)) {
|
|
2512
|
+
if (readStoredThemeId(themeIdFile) !== themeId) rmSync(gitDir, {
|
|
2513
|
+
recursive: true,
|
|
2514
|
+
force: true
|
|
2555
2515
|
});
|
|
2556
|
-
return;
|
|
2557
2516
|
}
|
|
2558
|
-
|
|
2559
|
-
if (!
|
|
2560
|
-
|
|
2561
|
-
|
|
2562
|
-
|
|
2563
|
-
|
|
2564
|
-
|
|
2517
|
+
const repo = new ShadowRepo(themeRoot, gitDir);
|
|
2518
|
+
if (!existsSync(gitDir)) {
|
|
2519
|
+
mkdirSync(shadowDir, { recursive: true });
|
|
2520
|
+
await repo.git([
|
|
2521
|
+
"init",
|
|
2522
|
+
"--bare",
|
|
2523
|
+
"-b",
|
|
2524
|
+
"main",
|
|
2525
|
+
gitDir
|
|
2526
|
+
], { cwd: themeRoot });
|
|
2565
2527
|
}
|
|
2566
|
-
|
|
2567
|
-
|
|
2568
|
-
|
|
2569
|
-
|
|
2570
|
-
|
|
2571
|
-
|
|
2528
|
+
writeFileSync(themeIdFile, `${themeId}\n`, "utf-8");
|
|
2529
|
+
const shadowIgnore = join(shadowDir, ".gitignore");
|
|
2530
|
+
if (!existsSync(shadowIgnore)) writeFileSync(shadowIgnore, "# Fluid CLI shadow repo — internal state, not for version control.\n*\n");
|
|
2531
|
+
await ensureRootGitignoreHidesShadow(themeRoot);
|
|
2532
|
+
return repo;
|
|
2533
|
+
}
|
|
2534
|
+
/** True when the repo has at least one commit on `refs/heads/main`. */
|
|
2535
|
+
async hasHead() {
|
|
2536
|
+
if (this.headExists !== void 0) return this.headExists;
|
|
2537
|
+
try {
|
|
2538
|
+
await this.git([
|
|
2539
|
+
"rev-parse",
|
|
2540
|
+
"--verify",
|
|
2541
|
+
"HEAD"
|
|
2542
|
+
]);
|
|
2543
|
+
this.headExists = true;
|
|
2544
|
+
} catch {
|
|
2545
|
+
this.headExists = false;
|
|
2572
2546
|
}
|
|
2573
|
-
|
|
2574
|
-
};
|
|
2575
|
-
for (const match of content.matchAll(/\{%-?\s*([a-zA-Z_][\w-]*)\b(?:(?!\{%)[\s\S])*?-?%\}/g)) {
|
|
2576
|
-
const name = match[1]?.toLowerCase();
|
|
2577
|
-
if (!name) continue;
|
|
2578
|
-
const index = match.index ?? 0;
|
|
2579
|
-
for (let cursor = previousTagIndex; cursor < index; cursor++) if (content.charCodeAt(cursor) === 10) line += 1;
|
|
2580
|
-
previousTagIndex = index;
|
|
2581
|
-
const open = stack.at(-1);
|
|
2582
|
-
if (name === "liquid" && !(open && OPAQUE_BLOCK_TAGS.has(open.name))) {
|
|
2583
|
-
const statements = match[0].replace(/^\{%-?\s*liquid\b/i, "").replace(/-?%\}$/, "").split("\n");
|
|
2584
|
-
for (const [offset, statement] of statements.entries()) {
|
|
2585
|
-
const statementName = /^\s*([a-zA-Z_][\w-]*)\b/.exec(statement)?.[1];
|
|
2586
|
-
if (!statementName) continue;
|
|
2587
|
-
processTag(statementName.toLowerCase(), line + offset);
|
|
2588
|
-
}
|
|
2589
|
-
} else processTag(name, line);
|
|
2547
|
+
return this.headExists;
|
|
2590
2548
|
}
|
|
2591
|
-
|
|
2592
|
-
|
|
2593
|
-
|
|
2594
|
-
|
|
2595
|
-
|
|
2596
|
-
|
|
2597
|
-
|
|
2598
|
-
|
|
2599
|
-
|
|
2600
|
-
|
|
2601
|
-
|
|
2602
|
-
|
|
2603
|
-
|
|
2604
|
-
|
|
2605
|
-
|
|
2606
|
-
* that work starts so we fail fast with a clear message instead.
|
|
2607
|
-
*/
|
|
2608
|
-
var PortInUseError = class extends Error {
|
|
2609
|
-
constructor(host, port) {
|
|
2610
|
-
super(formatPortConflictMessage(host, port));
|
|
2611
|
-
this.host = host;
|
|
2612
|
-
this.port = port;
|
|
2613
|
-
this.name = "PortInUseError";
|
|
2549
|
+
/**
|
|
2550
|
+
* Every path recorded under HEAD's tree, recursively. Callers use
|
|
2551
|
+
* this to detect local deletions (paths in HEAD, absent from the
|
|
2552
|
+
* working tree). Returns [] when HEAD has never been committed.
|
|
2553
|
+
*/
|
|
2554
|
+
async headPaths() {
|
|
2555
|
+
if (!await this.hasHead()) return [];
|
|
2556
|
+
const { stdout } = await this.git([
|
|
2557
|
+
"ls-tree",
|
|
2558
|
+
"-r",
|
|
2559
|
+
"-z",
|
|
2560
|
+
"HEAD",
|
|
2561
|
+
"--name-only"
|
|
2562
|
+
]);
|
|
2563
|
+
return stdout.toString("utf8").split("\0").filter((line) => line.length > 0);
|
|
2614
2564
|
}
|
|
2615
|
-
|
|
2616
|
-
|
|
2617
|
-
|
|
2618
|
-
|
|
2619
|
-
|
|
2620
|
-
|
|
2621
|
-
|
|
2622
|
-
|
|
2623
|
-
|
|
2624
|
-
|
|
2625
|
-
|
|
2626
|
-
|
|
2627
|
-
|
|
2628
|
-
|
|
2629
|
-
|
|
2630
|
-
|
|
2631
|
-
server.once("listening", () => {
|
|
2632
|
-
server.close(() => resolve());
|
|
2633
|
-
});
|
|
2634
|
-
server.listen(port, host);
|
|
2635
|
-
});
|
|
2636
|
-
}
|
|
2637
|
-
//#endregion
|
|
2638
|
-
//#region src/theme/dev-server/index.ts
|
|
2639
|
-
function timestamp() {
|
|
2640
|
-
return (/* @__PURE__ */ new Date()).toLocaleTimeString("en-US", { hour12: false });
|
|
2641
|
-
}
|
|
2642
|
-
async function startDevServer(api, theme, themeRoot, opts, onReady) {
|
|
2643
|
-
const sse = new SSEStream();
|
|
2644
|
-
const syncer = new Syncer(api, theme.id, themeRoot);
|
|
2645
|
-
const pendingUpdates = /* @__PURE__ */ new Set();
|
|
2646
|
-
console.log(`\nSyncing theme ${theme.name} (#${theme.id})…`);
|
|
2647
|
-
const syncResult = await syncer.uploadTheme({
|
|
2648
|
-
delete: true,
|
|
2649
|
-
validate: opts.validate,
|
|
2650
|
-
linkManagedAssets: { replace: true },
|
|
2651
|
-
pendingBinaryAssets: true,
|
|
2652
|
-
onProgress: (done, total) => {
|
|
2653
|
-
process.stdout.write(`\r Uploading ${done}/${total} files…`);
|
|
2565
|
+
/**
|
|
2566
|
+
* The content of `path` in HEAD's tree, or null when the path does
|
|
2567
|
+
* not exist there. Callers use this as the merge base for pull
|
|
2568
|
+
* conflict resolution.
|
|
2569
|
+
*/
|
|
2570
|
+
async blobAtHead(path) {
|
|
2571
|
+
if (!await this.hasHead()) return null;
|
|
2572
|
+
try {
|
|
2573
|
+
const { stdout } = await this.git([
|
|
2574
|
+
"cat-file",
|
|
2575
|
+
"-p",
|
|
2576
|
+
`HEAD:${path}`
|
|
2577
|
+
]);
|
|
2578
|
+
return stdout;
|
|
2579
|
+
} catch {
|
|
2580
|
+
return null;
|
|
2654
2581
|
}
|
|
2655
|
-
});
|
|
2656
|
-
process.stdout.write("\n");
|
|
2657
|
-
if (syncResult.linked > 0) console.log(` Saved ${syncResult.linked} remote asset reference(s).`);
|
|
2658
|
-
if (syncResult.validationFailed) {
|
|
2659
|
-
console.error(`\nSchema validation failed (${syncResult.errors.length} error(s)). Use --force to skip.\n`);
|
|
2660
|
-
for (const e of syncResult.errors) console.error(` ${e}`);
|
|
2661
|
-
process.exit(1);
|
|
2662
|
-
} else if (syncResult.errors.length > 0) {
|
|
2663
|
-
for (const e of syncResult.errors) console.error(` ${e}`);
|
|
2664
|
-
if (syncResult.uploaded + syncResult.deleted === 0) process.exit(1);
|
|
2665
2582
|
}
|
|
2666
|
-
|
|
2667
|
-
|
|
2668
|
-
|
|
2669
|
-
|
|
2670
|
-
|
|
2671
|
-
|
|
2672
|
-
|
|
2673
|
-
|
|
2674
|
-
|
|
2675
|
-
|
|
2676
|
-
|
|
2677
|
-
|
|
2678
|
-
|
|
2679
|
-
|
|
2680
|
-
|
|
2681
|
-
|
|
2682
|
-
|
|
2683
|
-
|
|
2684
|
-
|
|
2685
|
-
|
|
2686
|
-
|
|
2687
|
-
|
|
2688
|
-
|
|
2689
|
-
|
|
2690
|
-
|
|
2691
|
-
|
|
2692
|
-
|
|
2693
|
-
|
|
2694
|
-
|
|
2695
|
-
|
|
2696
|
-
|
|
2697
|
-
|
|
2698
|
-
|
|
2699
|
-
|
|
2700
|
-
|
|
2701
|
-
|
|
2702
|
-
|
|
2703
|
-
|
|
2704
|
-
|
|
2705
|
-
|
|
2706
|
-
|
|
2707
|
-
|
|
2708
|
-
|
|
2709
|
-
|
|
2710
|
-
|
|
2711
|
-
|
|
2712
|
-
|
|
2713
|
-
|
|
2714
|
-
|
|
2715
|
-
|
|
2716
|
-
|
|
2717
|
-
console.error(`\n[Watcher] Upload failed: ${file.relativePath}: ${e}`);
|
|
2718
|
-
} finally {
|
|
2719
|
-
pendingUpdates.delete(file.relativePath);
|
|
2720
|
-
}
|
|
2721
|
-
}
|
|
2722
|
-
for (const file of removed) {
|
|
2723
|
-
if (themeRoot.ignore.ignore(file.relativePath)) continue;
|
|
2583
|
+
/**
|
|
2584
|
+
* Write `content` as a blob in the shadow repo and return its sha.
|
|
2585
|
+
* Used by `commitState` to stage each file's content before the
|
|
2586
|
+
* `write-tree` call.
|
|
2587
|
+
*/
|
|
2588
|
+
async writeBlob(content) {
|
|
2589
|
+
const buf = typeof content === "string" ? Buffer.from(content) : content;
|
|
2590
|
+
const { stdout } = await this.git([
|
|
2591
|
+
"hash-object",
|
|
2592
|
+
"-w",
|
|
2593
|
+
"--stdin"
|
|
2594
|
+
], { input: buf });
|
|
2595
|
+
return stdout.toString("utf8").trim();
|
|
2596
|
+
}
|
|
2597
|
+
/**
|
|
2598
|
+
* Commit `files` as HEAD's new tree, threaded onto the current HEAD
|
|
2599
|
+
* as the parent. Uses a per-call temp index so a partial run can't
|
|
2600
|
+
* corrupt anything reachable from HEAD; the previous commit stays
|
|
2601
|
+
* intact until `update-ref` at the end.
|
|
2602
|
+
*
|
|
2603
|
+
* Returns the new commit sha.
|
|
2604
|
+
*/
|
|
2605
|
+
async commitState(files, message) {
|
|
2606
|
+
const indexPath = mkdtempSync(join(tmpdir(), "fluid-shadow-")) + "/index";
|
|
2607
|
+
try {
|
|
2608
|
+
const indexArgs = ["update-index", "--add"];
|
|
2609
|
+
for (const { path, sha } of files) indexArgs.push("--cacheinfo", `100644,${sha},${path}`);
|
|
2610
|
+
if (files.length > 0) await this.git(indexArgs, { env: { GIT_INDEX_FILE: indexPath } });
|
|
2611
|
+
const treeSha = (await this.git(["write-tree"], { env: { GIT_INDEX_FILE: indexPath } })).stdout.toString("utf8").trim();
|
|
2612
|
+
const parent = await this.hasHead() ? (await this.git(["rev-parse", "HEAD"])).stdout.toString("utf8").trim() : null;
|
|
2613
|
+
const commitArgs = [
|
|
2614
|
+
"commit-tree",
|
|
2615
|
+
treeSha,
|
|
2616
|
+
"-m",
|
|
2617
|
+
message
|
|
2618
|
+
];
|
|
2619
|
+
if (parent) commitArgs.push("-p", parent);
|
|
2620
|
+
const commitSha = (await this.git(commitArgs, { env: {
|
|
2621
|
+
GIT_AUTHOR_NAME: "Fluid CLI",
|
|
2622
|
+
GIT_AUTHOR_EMAIL: "cli@fluid.app",
|
|
2623
|
+
GIT_COMMITTER_NAME: "Fluid CLI",
|
|
2624
|
+
GIT_COMMITTER_EMAIL: "cli@fluid.app"
|
|
2625
|
+
} })).stdout.toString("utf8").trim();
|
|
2626
|
+
await this.git([
|
|
2627
|
+
"update-ref",
|
|
2628
|
+
"refs/heads/main",
|
|
2629
|
+
commitSha
|
|
2630
|
+
]);
|
|
2631
|
+
this.headExists = true;
|
|
2632
|
+
return commitSha;
|
|
2633
|
+
} finally {
|
|
2724
2634
|
try {
|
|
2725
|
-
|
|
2726
|
-
|
|
2635
|
+
rmSync(indexPath, { force: true });
|
|
2636
|
+
rmSync(indexPath.substring(0, indexPath.length - 6), {
|
|
2637
|
+
recursive: true,
|
|
2638
|
+
force: true
|
|
2639
|
+
});
|
|
2727
2640
|
} catch {}
|
|
2728
2641
|
}
|
|
2729
|
-
|
|
2730
|
-
|
|
2731
|
-
|
|
2732
|
-
|
|
2733
|
-
|
|
2734
|
-
|
|
2735
|
-
|
|
2736
|
-
|
|
2737
|
-
|
|
2642
|
+
}
|
|
2643
|
+
/**
|
|
2644
|
+
* Three-way merge of `local` against `remote` with `base` as the
|
|
2645
|
+
* common ancestor. Returns the merged bytes and a flag when
|
|
2646
|
+
* `git merge-file` reported unresolved conflicts (i.e. the output
|
|
2647
|
+
* contains `<<<<<<<` markers for the reader to resolve).
|
|
2648
|
+
*
|
|
2649
|
+
* `base` is null when HEAD has never seen this path; we merge
|
|
2650
|
+
* against an empty base, which is what git itself does for a new
|
|
2651
|
+
* file added on both sides.
|
|
2652
|
+
*
|
|
2653
|
+
* `favor` maps to `git merge-file`'s `--ours` / `--theirs`: instead
|
|
2654
|
+
* of emitting `<<<<<<<` markers, conflicting hunks are resolved to
|
|
2655
|
+
* the local (`"local"` → `--ours`, local is file1) or remote
|
|
2656
|
+
* (`"remote"` → `--theirs`) side. The output then never contains
|
|
2657
|
+
* markers, so the result is reported conflict-free even when
|
|
2658
|
+
* merge-file's exit code still counts the auto-resolved hunks.
|
|
2659
|
+
*/
|
|
2660
|
+
async merge3(base, local, remote, favor) {
|
|
2661
|
+
const dir = mkdtempSync(join(tmpdir(), "fluid-merge-"));
|
|
2662
|
+
const localPath = join(dir, "local");
|
|
2663
|
+
const basePath = join(dir, "base");
|
|
2664
|
+
const remotePath = join(dir, "remote");
|
|
2738
2665
|
try {
|
|
2739
|
-
|
|
2740
|
-
|
|
2741
|
-
|
|
2742
|
-
|
|
2743
|
-
|
|
2744
|
-
|
|
2745
|
-
|
|
2746
|
-
|
|
2747
|
-
|
|
2748
|
-
|
|
2749
|
-
|
|
2750
|
-
|
|
2751
|
-
|
|
2752
|
-
|
|
2753
|
-
|
|
2666
|
+
writeFileSync(localPath, local);
|
|
2667
|
+
writeFileSync(basePath, base ?? Buffer.alloc(0));
|
|
2668
|
+
writeFileSync(remotePath, remote);
|
|
2669
|
+
try {
|
|
2670
|
+
const { stdout } = await this.git([
|
|
2671
|
+
"merge-file",
|
|
2672
|
+
"-p",
|
|
2673
|
+
...favor === "local" ? ["--ours"] : favor === "remote" ? ["--theirs"] : [],
|
|
2674
|
+
"-L",
|
|
2675
|
+
"local",
|
|
2676
|
+
"-L",
|
|
2677
|
+
"base",
|
|
2678
|
+
"-L",
|
|
2679
|
+
"remote",
|
|
2680
|
+
localPath,
|
|
2681
|
+
basePath,
|
|
2682
|
+
remotePath
|
|
2683
|
+
]);
|
|
2684
|
+
return {
|
|
2685
|
+
merged: stdout,
|
|
2686
|
+
hasConflicts: false
|
|
2687
|
+
};
|
|
2688
|
+
} catch (err) {
|
|
2689
|
+
const e = err;
|
|
2690
|
+
const merged = e.stdout instanceof Buffer ? e.stdout : e.stdout != null ? Buffer.from(e.stdout) : Buffer.alloc(0);
|
|
2691
|
+
const inConflictRange = typeof e.code === "number" && e.code >= 1 && e.code <= 127;
|
|
2692
|
+
if (inConflictRange && favor) return {
|
|
2693
|
+
merged,
|
|
2694
|
+
hasConflicts: false
|
|
2695
|
+
};
|
|
2696
|
+
if (inConflictRange && merged.length > 0) return {
|
|
2697
|
+
merged,
|
|
2698
|
+
hasConflicts: true
|
|
2699
|
+
};
|
|
2700
|
+
throw err;
|
|
2754
2701
|
}
|
|
2702
|
+
} finally {
|
|
2703
|
+
try {
|
|
2704
|
+
rmSync(dir, {
|
|
2705
|
+
recursive: true,
|
|
2706
|
+
force: true
|
|
2707
|
+
});
|
|
2708
|
+
} catch {}
|
|
2755
2709
|
}
|
|
2756
|
-
}
|
|
2757
|
-
|
|
2758
|
-
|
|
2759
|
-
|
|
2760
|
-
|
|
2761
|
-
|
|
2762
|
-
|
|
2763
|
-
|
|
2764
|
-
|
|
2765
|
-
|
|
2766
|
-
|
|
2767
|
-
|
|
2768
|
-
|
|
2769
|
-
|
|
2770
|
-
|
|
2771
|
-
|
|
2772
|
-
|
|
2773
|
-
}
|
|
2774
|
-
|
|
2775
|
-
|
|
2776
|
-
|
|
2777
|
-
|
|
2778
|
-
|
|
2779
|
-
|
|
2780
|
-
|
|
2781
|
-
|
|
2782
|
-
|
|
2783
|
-
|
|
2784
|
-
|
|
2785
|
-
|
|
2786
|
-
|
|
2787
|
-
|
|
2788
|
-
|
|
2789
|
-
|
|
2790
|
-
|
|
2791
|
-
|
|
2792
|
-
|
|
2793
|
-
|
|
2794
|
-
|
|
2795
|
-
|
|
2796
|
-
|
|
2797
|
-
|
|
2798
|
-
|
|
2799
|
-
|
|
2800
|
-
|
|
2801
|
-
|
|
2802
|
-
|
|
2803
|
-
|
|
2804
|
-
}
|
|
2805
|
-
|
|
2806
|
-
|
|
2807
|
-
|
|
2808
|
-
|
|
2809
|
-
|
|
2810
|
-
|
|
2811
|
-
let searchResults = [];
|
|
2812
|
-
while (true) {
|
|
2813
|
-
if (hasMore && allThemes.length < page * PAGE_SIZE) {
|
|
2814
|
-
const result = await fetchThemesPage(api, page);
|
|
2815
|
-
allThemes.push(...result.themes);
|
|
2816
|
-
hasMore = result.hasMore;
|
|
2817
|
-
}
|
|
2818
|
-
if (!allThemes.length) {
|
|
2819
|
-
console.error("No themes found.");
|
|
2820
|
-
process.exit(1);
|
|
2821
|
-
}
|
|
2822
|
-
const choices = themeChoices(allThemes, hasMore);
|
|
2823
|
-
const { id } = await prompts({
|
|
2824
|
-
type: "autocomplete",
|
|
2825
|
-
name: "id",
|
|
2826
|
-
message,
|
|
2827
|
-
initial: initialIndex,
|
|
2828
|
-
choices,
|
|
2829
|
-
suggest: async (input, choices) => {
|
|
2830
|
-
if (!input) {
|
|
2831
|
-
searchQuery = "";
|
|
2832
|
-
searchResults = [];
|
|
2833
|
-
return choices;
|
|
2834
|
-
}
|
|
2835
|
-
if (input !== searchQuery) {
|
|
2836
|
-
searchQuery = input;
|
|
2837
|
-
try {
|
|
2838
|
-
searchResults = (await fetchThemesPage(api, 1, input)).themes;
|
|
2839
|
-
} catch {
|
|
2840
|
-
searchResults = [];
|
|
2841
|
-
}
|
|
2710
|
+
}
|
|
2711
|
+
/**
|
|
2712
|
+
* Snapshot the working-tree copy of `paths` into HEAD as a single
|
|
2713
|
+
* commit. Intended for the migration path: on the first pull with
|
|
2714
|
+
* the new CLI (no shadow repo yet, but a `.fluid-theme.json` with
|
|
2715
|
+
* checksums exists) we seed HEAD with whatever is on disk before
|
|
2716
|
+
* running the merge, so unmodified files fast-forward cleanly and
|
|
2717
|
+
* modified files show a diff.
|
|
2718
|
+
*/
|
|
2719
|
+
async seedFromWorkingTree(files, message) {
|
|
2720
|
+
const entries = [];
|
|
2721
|
+
for (const { path, content } of files) entries.push({
|
|
2722
|
+
path,
|
|
2723
|
+
sha: await this.writeBlob(content)
|
|
2724
|
+
});
|
|
2725
|
+
await this.commitState(entries, message);
|
|
2726
|
+
}
|
|
2727
|
+
async git(args, opts = {}) {
|
|
2728
|
+
const child = spawn("git", args[0] === "init" ? args : [
|
|
2729
|
+
"--git-dir",
|
|
2730
|
+
this.gitDir,
|
|
2731
|
+
...args
|
|
2732
|
+
], {
|
|
2733
|
+
cwd: opts.cwd ?? this.themeRoot,
|
|
2734
|
+
env: {
|
|
2735
|
+
...process.env,
|
|
2736
|
+
...opts.env
|
|
2737
|
+
},
|
|
2738
|
+
stdio: [
|
|
2739
|
+
"pipe",
|
|
2740
|
+
"pipe",
|
|
2741
|
+
"pipe"
|
|
2742
|
+
]
|
|
2743
|
+
});
|
|
2744
|
+
if (opts.input) child.stdin.write(opts.input);
|
|
2745
|
+
child.stdin.end();
|
|
2746
|
+
return new Promise((resolve, reject) => {
|
|
2747
|
+
const stdout = [];
|
|
2748
|
+
const stderr = [];
|
|
2749
|
+
child.stdout.on("data", (chunk) => stdout.push(chunk));
|
|
2750
|
+
child.stderr.on("data", (chunk) => stderr.push(chunk));
|
|
2751
|
+
child.on("error", reject);
|
|
2752
|
+
child.on("close", (code) => {
|
|
2753
|
+
const out = Buffer.concat(stdout);
|
|
2754
|
+
const err = Buffer.concat(stderr);
|
|
2755
|
+
if (code === 0) resolve({
|
|
2756
|
+
stdout: out,
|
|
2757
|
+
stderr: err
|
|
2758
|
+
});
|
|
2759
|
+
else {
|
|
2760
|
+
const e = /* @__PURE__ */ new Error(`git ${args.join(" ")} exited with ${code}: ${err.toString("utf8")}`);
|
|
2761
|
+
e.code = code ?? -1;
|
|
2762
|
+
e.stdout = out;
|
|
2763
|
+
e.stderr = err;
|
|
2764
|
+
reject(e);
|
|
2842
2765
|
}
|
|
2843
|
-
|
|
2844
|
-
|
|
2845
|
-
value: t.id
|
|
2846
|
-
}));
|
|
2847
|
-
}
|
|
2848
|
-
}, { onCancel: () => process.exit(130) });
|
|
2849
|
-
if (id === LOAD_MORE_VALUE) {
|
|
2850
|
-
initialIndex = allThemes.length;
|
|
2851
|
-
page++;
|
|
2852
|
-
continue;
|
|
2853
|
-
}
|
|
2854
|
-
if (!id) {
|
|
2855
|
-
console.error("No theme selected.");
|
|
2856
|
-
process.exit(1);
|
|
2857
|
-
}
|
|
2858
|
-
const found = allThemes.find((t) => t.id === id) ?? searchResults.find((t) => t.id === id);
|
|
2859
|
-
if (found) return found;
|
|
2860
|
-
return (await getApplicationTheme(api, id)).application_theme;
|
|
2766
|
+
});
|
|
2767
|
+
});
|
|
2861
2768
|
}
|
|
2769
|
+
};
|
|
2770
|
+
/**
|
|
2771
|
+
* Content-type check the pull command uses to decide whether a file
|
|
2772
|
+
* is safe to run through `merge3` (line-based) or must fall back to
|
|
2773
|
+
* whole-file "either/or" resolution (binary).
|
|
2774
|
+
*/
|
|
2775
|
+
function looksBinary(content) {
|
|
2776
|
+
return content.subarray(0, Math.min(content.length, 8e3)).includes(0);
|
|
2862
2777
|
}
|
|
2863
|
-
|
|
2864
|
-
|
|
2865
|
-
|
|
2866
|
-
|
|
2867
|
-
|
|
2868
|
-
|
|
2869
|
-
let page = 1;
|
|
2870
|
-
let hasMore = true;
|
|
2871
|
-
while (hasMore) {
|
|
2872
|
-
const result = await fetchThemesPage(api, page, identifier);
|
|
2873
|
-
const found = result.themes.find((t) => t.name.toLowerCase() === identifier.toLowerCase());
|
|
2874
|
-
if (found) return found;
|
|
2875
|
-
hasMore = result.hasMore;
|
|
2876
|
-
page++;
|
|
2778
|
+
/** Best-effort readFile that returns null when the file does not exist. */
|
|
2779
|
+
function readIfExists(path) {
|
|
2780
|
+
try {
|
|
2781
|
+
return readFileSync(path);
|
|
2782
|
+
} catch {
|
|
2783
|
+
return null;
|
|
2877
2784
|
}
|
|
2878
|
-
console.error(`No theme found with identifier: ${identifier}`);
|
|
2879
|
-
process.exit(1);
|
|
2880
2785
|
}
|
|
2881
|
-
//#endregion
|
|
2882
|
-
//#region src/workspace.ts
|
|
2883
|
-
const WORKSPACE_FILE = ".fluid-workspace.json";
|
|
2884
2786
|
/**
|
|
2885
|
-
*
|
|
2886
|
-
*
|
|
2787
|
+
* Parse the numeric theme id from `.fluid-theme/theme-id`. Returns
|
|
2788
|
+
* null when the file is missing or the contents don't parse cleanly;
|
|
2789
|
+
* `open` treats that as "unknown theme" and rebuilds the shadow.
|
|
2887
2790
|
*/
|
|
2888
|
-
function
|
|
2889
|
-
|
|
2890
|
-
|
|
2891
|
-
|
|
2892
|
-
|
|
2893
|
-
|
|
2894
|
-
const config = JSON.parse(raw);
|
|
2895
|
-
return {
|
|
2896
|
-
root: dir,
|
|
2897
|
-
config
|
|
2898
|
-
};
|
|
2899
|
-
} catch {
|
|
2900
|
-
return null;
|
|
2901
|
-
}
|
|
2902
|
-
const parent = dirname(dir);
|
|
2903
|
-
if (parent === dir) break;
|
|
2904
|
-
dir = parent;
|
|
2791
|
+
function readStoredThemeId(themeIdFile) {
|
|
2792
|
+
try {
|
|
2793
|
+
const stored = parseInt(readFileSync(themeIdFile, "utf-8").trim(), 10);
|
|
2794
|
+
return Number.isFinite(stored) ? stored : null;
|
|
2795
|
+
} catch {
|
|
2796
|
+
return null;
|
|
2905
2797
|
}
|
|
2906
|
-
return null;
|
|
2907
2798
|
}
|
|
2908
2799
|
/**
|
|
2909
|
-
*
|
|
2910
|
-
* theme
|
|
2911
|
-
*
|
|
2912
|
-
*
|
|
2913
|
-
*
|
|
2914
|
-
* cwd = /code/fluid-theme-dev/local/acme-co/templates → /code/fluid-theme-dev/local/acme-co
|
|
2915
|
-
* cwd = /code/fluid-theme-dev → null
|
|
2916
|
-
* cwd = /code/fluid-theme-dev/local → null
|
|
2800
|
+
* Append `.fluid-theme/` to the theme root's `.gitignore` when the
|
|
2801
|
+
* theme dir sits inside a git working tree and the entry isn't
|
|
2802
|
+
* already there. Skipped when the user isn't in a git repo — no
|
|
2803
|
+
* point manufacturing a `.gitignore` for someone who doesn't use
|
|
2804
|
+
* git. Idempotent — a second call is a no-op.
|
|
2917
2805
|
*/
|
|
2918
|
-
function
|
|
2919
|
-
|
|
2920
|
-
|
|
2921
|
-
|
|
2922
|
-
|
|
2923
|
-
|
|
2924
|
-
|
|
2925
|
-
|
|
2806
|
+
async function ensureRootGitignoreHidesShadow(themeRoot) {
|
|
2807
|
+
if (!await new Promise((resolve) => {
|
|
2808
|
+
const child = spawn("git", ["rev-parse", "--is-inside-work-tree"], {
|
|
2809
|
+
cwd: themeRoot,
|
|
2810
|
+
stdio: [
|
|
2811
|
+
"ignore",
|
|
2812
|
+
"pipe",
|
|
2813
|
+
"pipe"
|
|
2814
|
+
]
|
|
2815
|
+
});
|
|
2816
|
+
child.on("close", (code) => resolve(code === 0));
|
|
2817
|
+
child.on("error", () => resolve(false));
|
|
2818
|
+
})) return;
|
|
2819
|
+
const gitignorePath = join(themeRoot, ".gitignore");
|
|
2820
|
+
let existing = "";
|
|
2821
|
+
try {
|
|
2822
|
+
existing = readFileSync(gitignorePath, "utf-8");
|
|
2823
|
+
} catch {
|
|
2824
|
+
existing = "";
|
|
2825
|
+
}
|
|
2826
|
+
const lines = existing.split("\n").map((line) => line.trim());
|
|
2827
|
+
if (lines.includes(".fluid-theme/") || lines.includes(".fluid-theme")) return;
|
|
2828
|
+
const separator = existing.length === 0 || existing.endsWith("\n") ? "" : "\n";
|
|
2829
|
+
writeFileSync(gitignorePath, `${existing}${separator}.fluid-theme/\n`, "utf-8");
|
|
2926
2830
|
}
|
|
2927
2831
|
//#endregion
|
|
2928
|
-
//#region src/
|
|
2832
|
+
//#region src/theme/merge-push.ts
|
|
2929
2833
|
/**
|
|
2930
|
-
*
|
|
2834
|
+
* Compute what changed locally since the last time the shadow repo
|
|
2835
|
+
* committed a state. Replaces the sha256 `checksums` map: shadow HEAD
|
|
2836
|
+
* is the source of truth for "what the CLI last saw the server have".
|
|
2931
2837
|
*
|
|
2932
|
-
*
|
|
2933
|
-
*
|
|
2934
|
-
*
|
|
2935
|
-
* empty-theme flow; other failures must remain visible to the developer.
|
|
2838
|
+
* Files whose local bytes are byte-identical to their HEAD blob are
|
|
2839
|
+
* skipped; anything else — new, modified, or a locally-deleted path
|
|
2840
|
+
* that HEAD still has — is included.
|
|
2936
2841
|
*/
|
|
2937
|
-
async function
|
|
2938
|
-
|
|
2939
|
-
|
|
2940
|
-
|
|
2941
|
-
|
|
2942
|
-
|
|
2842
|
+
async function diffAgainstShadow(themeRoot, shadow) {
|
|
2843
|
+
const changed = [];
|
|
2844
|
+
const deleted = [];
|
|
2845
|
+
const localFiles = themeRoot.files();
|
|
2846
|
+
const localByKey = /* @__PURE__ */ new Map();
|
|
2847
|
+
const assetManifest = new ThemeAssetManifest(themeRoot.root);
|
|
2848
|
+
for (const file of localFiles) {
|
|
2849
|
+
if (!file.exists) continue;
|
|
2850
|
+
localByKey.set(file.relativePath, file);
|
|
2851
|
+
const headBlob = await shadow.blobAtHead(file.relativePath);
|
|
2852
|
+
const localBuf = file.isText ? Buffer.from(file.read()) : file.readBinary();
|
|
2853
|
+
if (headBlob && headBlob.equals(localBuf)) continue;
|
|
2854
|
+
changed.push(file);
|
|
2943
2855
|
}
|
|
2944
|
-
|
|
2945
|
-
|
|
2946
|
-
|
|
2947
|
-
|
|
2948
|
-
|
|
2949
|
-
|
|
2950
|
-
if (identifier) {
|
|
2951
|
-
const theme = await findTheme(api, identifier);
|
|
2952
|
-
setLastDevThemeId(theme.id);
|
|
2953
|
-
return theme;
|
|
2856
|
+
if (await shadow.hasHead()) for (const key of await listHeadPaths(shadow)) {
|
|
2857
|
+
if (localByKey.has(key)) continue;
|
|
2858
|
+
if (themeRoot.ignore.ignore(key)) continue;
|
|
2859
|
+
if (assetManifest.has(key)) continue;
|
|
2860
|
+
if (isStylesheetKey(key)) continue;
|
|
2861
|
+
deleted.push(key);
|
|
2954
2862
|
}
|
|
2955
|
-
|
|
2956
|
-
|
|
2957
|
-
|
|
2958
|
-
|
|
2959
|
-
|
|
2960
|
-
|
|
2961
|
-
|
|
2962
|
-
|
|
2963
|
-
|
|
2964
|
-
|
|
2965
|
-
|
|
2966
|
-
|
|
2967
|
-
|
|
2968
|
-
|
|
2969
|
-
|
|
2863
|
+
return {
|
|
2864
|
+
changed,
|
|
2865
|
+
deleted
|
|
2866
|
+
};
|
|
2867
|
+
}
|
|
2868
|
+
/**
|
|
2869
|
+
* Read every path recorded under HEAD's tree. Used to detect local
|
|
2870
|
+
* deletions (paths in HEAD, absent from the working tree). Implemented
|
|
2871
|
+
* with `git ls-tree -r HEAD --name-only` piped through the shadow
|
|
2872
|
+
* repo's plumbing.
|
|
2873
|
+
*/
|
|
2874
|
+
async function listHeadPaths(shadow) {
|
|
2875
|
+
return shadow.headPaths();
|
|
2876
|
+
}
|
|
2877
|
+
/**
|
|
2878
|
+
* Refuse a push when any working file still contains a conflict marker
|
|
2879
|
+
* from a previous pull. Mirrors git's "you have unresolved conflicts;
|
|
2880
|
+
* fix them and re-run" behavior — the whole point of writing markers
|
|
2881
|
+
* on pull was to hand resolution to the user, so we can't send them
|
|
2882
|
+
* upstream.
|
|
2883
|
+
*/
|
|
2884
|
+
function findUnresolvedConflicts(files) {
|
|
2885
|
+
const flagged = [];
|
|
2886
|
+
for (const file of files) {
|
|
2887
|
+
if (!file.isText) continue;
|
|
2888
|
+
const buf = readIfExists(file.absolutePath);
|
|
2889
|
+
if (!buf) continue;
|
|
2890
|
+
if (containsConflictMarker(buf)) flagged.push(file.relativePath);
|
|
2970
2891
|
}
|
|
2971
|
-
|
|
2972
|
-
const theme = await createDevelopmentTheme(api, sourceThemeId, `Development (${hostname().split(".")[0] ?? "dev"}-${Math.random().toString(36).slice(2, 8)})`.slice(0, 50));
|
|
2973
|
-
setDevTheme(projectKey, {
|
|
2974
|
-
id: theme.id,
|
|
2975
|
-
name: theme.name,
|
|
2976
|
-
...sourceThemeId === void 0 ? {} : { sourceThemeId }
|
|
2977
|
-
});
|
|
2978
|
-
console.log(`Created dev theme: ${theme.name} (#${theme.id})`);
|
|
2979
|
-
return theme;
|
|
2892
|
+
return flagged;
|
|
2980
2893
|
}
|
|
2981
|
-
|
|
2982
|
-
|
|
2983
|
-
|
|
2984
|
-
|
|
2985
|
-
|
|
2986
|
-
|
|
2987
|
-
|
|
2988
|
-
|
|
2989
|
-
|
|
2990
|
-
|
|
2991
|
-
|
|
2992
|
-
|
|
2993
|
-
|
|
2994
|
-
|
|
2995
|
-
|
|
2996
|
-
|
|
2997
|
-
|
|
2998
|
-
|
|
2999
|
-
|
|
3000
|
-
|
|
3001
|
-
|
|
3002
|
-
|
|
3003
|
-
|
|
3004
|
-
|
|
3005
|
-
|
|
3006
|
-
|
|
3007
|
-
|
|
3008
|
-
|
|
3009
|
-
|
|
3010
|
-
|
|
3011
|
-
|
|
3012
|
-
|
|
3013
|
-
|
|
3014
|
-
|
|
3015
|
-
|
|
3016
|
-
|
|
3017
|
-
|
|
3018
|
-
const
|
|
3019
|
-
|
|
3020
|
-
|
|
3021
|
-
|
|
3022
|
-
const cleanup = () => {
|
|
3023
|
-
stop?.();
|
|
3024
|
-
process.exit(0);
|
|
3025
|
-
};
|
|
3026
|
-
process.on("SIGINT", cleanup);
|
|
3027
|
-
process.on("SIGTERM", cleanup);
|
|
3028
|
-
stop = await startDevServer(api, {
|
|
3029
|
-
id: theme.id,
|
|
3030
|
-
name: theme.name,
|
|
3031
|
-
company,
|
|
3032
|
-
editorUrl
|
|
3033
|
-
}, themeRoot, {
|
|
3034
|
-
host: opts.host,
|
|
3035
|
-
port,
|
|
3036
|
-
reloadMode,
|
|
3037
|
-
validate: !opts.force
|
|
3038
|
-
}, (address) => {
|
|
3039
|
-
console.log(`\n Dev server: ${address}`);
|
|
3040
|
-
console.log(` Web editor: ${editorUrl}`);
|
|
3041
|
-
console.log("\n Watching for file changes…\n");
|
|
3042
|
-
if (opts.navigate) import("open").then((m) => m.default(`${address}/home`));
|
|
2894
|
+
/**
|
|
2895
|
+
* Stable, machine-readable one-liner for non-interactive callers
|
|
2896
|
+
* (Mist Desktop's publish flow parses push output). Uploading marker-
|
|
2897
|
+
* bearing files to a live theme is never acceptable, so `--auto-
|
|
2898
|
+
* baseline` pushes still refuse — but they emit this line so the
|
|
2899
|
+
* desktop can surface WHICH files block the publish instead of a
|
|
2900
|
+
* dead-end wall of prose. Format:
|
|
2901
|
+
*
|
|
2902
|
+
* FLUID_THEME_PUSH_BLOCKED code=conflict_markers files=a.liquid,b.json
|
|
2903
|
+
*/
|
|
2904
|
+
function conflictMarkerBlockLine(files) {
|
|
2905
|
+
return `FLUID_THEME_PUSH_BLOCKED code=conflict_markers files=${files.join(",")}`;
|
|
2906
|
+
}
|
|
2907
|
+
const CONFLICT_START = Buffer.from("<<<<<<<");
|
|
2908
|
+
const CONFLICT_MID = Buffer.from("=======");
|
|
2909
|
+
const CONFLICT_END = Buffer.from(">>>>>>>");
|
|
2910
|
+
/**
|
|
2911
|
+
* A file counts as unresolved when it contains all three marker
|
|
2912
|
+
* shapes: `<<<<<<<`, `=======`, and `>>>>>>>`. Requiring all three
|
|
2913
|
+
* avoids false positives — a line of equals signs alone (e.g. inside
|
|
2914
|
+
* an ASCII table in a template comment) doesn't trip the guard.
|
|
2915
|
+
*/
|
|
2916
|
+
function containsConflictMarker(buf) {
|
|
2917
|
+
return buf.includes(CONFLICT_START) && buf.includes(CONFLICT_MID) && buf.includes(CONFLICT_END);
|
|
2918
|
+
}
|
|
2919
|
+
/**
|
|
2920
|
+
* Commit the current working-tree state to shadow HEAD after a
|
|
2921
|
+
* successful push. Ensures the next pull's merge base is the state
|
|
2922
|
+
* we know the server just accepted.
|
|
2923
|
+
*/
|
|
2924
|
+
async function commitPushedState(themeRoot, shadow, message) {
|
|
2925
|
+
const entries = [];
|
|
2926
|
+
const assetManifest = new ThemeAssetManifest(themeRoot.root);
|
|
2927
|
+
const localKeys = /* @__PURE__ */ new Set();
|
|
2928
|
+
for (const file of themeRoot.files()) {
|
|
2929
|
+
if (!file.exists) continue;
|
|
2930
|
+
localKeys.add(file.relativePath);
|
|
2931
|
+
const buf = file.isText ? Buffer.from(file.read()) : file.readBinary();
|
|
2932
|
+
entries.push({
|
|
2933
|
+
path: file.relativePath,
|
|
2934
|
+
sha: await shadow.writeBlob(buf)
|
|
3043
2935
|
});
|
|
3044
|
-
|
|
3045
|
-
|
|
2936
|
+
}
|
|
2937
|
+
let managedAssetSentinelSha;
|
|
2938
|
+
for (const key of assetManifest.keys()) {
|
|
2939
|
+
if (localKeys.has(key)) continue;
|
|
2940
|
+
managedAssetSentinelSha ??= await shadow.writeBlob(MANAGED_ASSET_SHADOW_SENTINEL);
|
|
2941
|
+
entries.push({
|
|
2942
|
+
path: key,
|
|
2943
|
+
sha: managedAssetSentinelSha
|
|
2944
|
+
});
|
|
2945
|
+
}
|
|
2946
|
+
if (entries.length > 0 || await shadow.hasHead()) await shadow.commitState(entries, message);
|
|
2947
|
+
}
|
|
2948
|
+
/**
|
|
2949
|
+
* A target can already have every URL-backed FileResource (for example from a
|
|
2950
|
+
* reference clone), while this checkout's manifest still names its old source
|
|
2951
|
+
* theme. Adopt the target and clear any legacy binary from shadow even though
|
|
2952
|
+
* no remote write was necessary.
|
|
2953
|
+
*/
|
|
2954
|
+
async function finalizeManifestOnlyPush(syncer, themeRoot, shadow) {
|
|
2955
|
+
syncer.repointManagedAssetsToCurrentTheme();
|
|
2956
|
+
await commitPushedState(themeRoot, shadow, `push @ ${(/* @__PURE__ */ new Date()).toISOString()}`);
|
|
3046
2957
|
}
|
|
3047
2958
|
//#endregion
|
|
3048
|
-
//#region src/theme/
|
|
2959
|
+
//#region src/theme/liquid-delimiters.ts
|
|
3049
2960
|
/**
|
|
3050
|
-
*
|
|
3051
|
-
*
|
|
3052
|
-
*
|
|
3053
|
-
*
|
|
3054
|
-
*
|
|
3055
|
-
*
|
|
3056
|
-
*
|
|
2961
|
+
* Heuristic-only check for obviously unbalanced liquid delimiters
|
|
2962
|
+
* (`{% %}` and `{{ }}`). This is NOT a liquid parser — it only tracks
|
|
2963
|
+
* opening delimiters until they are closed. It exists to give watch-mode users a signal
|
|
2964
|
+
* when a save is liquid-syntax-broken: the server accepts
|
|
2965
|
+
* syntax-broken liquid silently on upload, and the storefront
|
|
2966
|
+
* renderer then serves stale content for that section with no error
|
|
2967
|
+
* anywhere else in the pipeline.
|
|
3057
2968
|
*
|
|
3058
|
-
*
|
|
3059
|
-
*
|
|
3060
|
-
*
|
|
3061
|
-
*
|
|
2969
|
+
* Closing-looking tokens without a preceding Liquid opener are intentionally
|
|
2970
|
+
* ignored. Liquid files commonly contain CSS such as `width:100%}` or adjacent
|
|
2971
|
+
* block braces (`}}`), so treating every close token as Liquid creates noisy
|
|
2972
|
+
* false warnings on valid theme files.
|
|
3062
2973
|
*
|
|
3063
|
-
*
|
|
3064
|
-
*
|
|
3065
|
-
*
|
|
2974
|
+
* Known false positive: delimiters written literally inside a
|
|
2975
|
+
* `{% raw %}...{% endraw %}` block are still counted and can trip
|
|
2976
|
+
* this check even though the liquid is valid. Acceptable for a
|
|
2977
|
+
* warn-only heuristic — a real fix requires parsing liquid, which is
|
|
2978
|
+
* out of scope here (see the server-side validation note in the PR).
|
|
3066
2979
|
*/
|
|
3067
|
-
|
|
3068
|
-
|
|
3069
|
-
|
|
3070
|
-
|
|
3071
|
-
|
|
2980
|
+
function hasUnbalancedLiquidDelimiters(content) {
|
|
2981
|
+
let unclosedTags = 0;
|
|
2982
|
+
let unclosedOutputs = 0;
|
|
2983
|
+
for (const token of content.matchAll(/\{%|%\}|\{\{|\}\}/g)) switch (token[0]) {
|
|
2984
|
+
case "{%":
|
|
2985
|
+
unclosedTags += 1;
|
|
2986
|
+
break;
|
|
2987
|
+
case "%}":
|
|
2988
|
+
if (unclosedTags > 0) unclosedTags -= 1;
|
|
2989
|
+
break;
|
|
2990
|
+
case "{{":
|
|
2991
|
+
unclosedOutputs += 1;
|
|
2992
|
+
break;
|
|
2993
|
+
case "}}":
|
|
2994
|
+
if (unclosedOutputs > 0) unclosedOutputs -= 1;
|
|
2995
|
+
break;
|
|
3072
2996
|
}
|
|
3073
|
-
|
|
3074
|
-
|
|
3075
|
-
|
|
3076
|
-
|
|
3077
|
-
|
|
3078
|
-
|
|
3079
|
-
|
|
3080
|
-
|
|
3081
|
-
|
|
3082
|
-
|
|
3083
|
-
|
|
3084
|
-
|
|
3085
|
-
|
|
3086
|
-
|
|
3087
|
-
|
|
3088
|
-
|
|
3089
|
-
|
|
2997
|
+
return unclosedTags > 0 || unclosedOutputs > 0;
|
|
2998
|
+
}
|
|
2999
|
+
const BLOCK_TAGS = new Map([
|
|
3000
|
+
["capture", "endcapture"],
|
|
3001
|
+
["case", "endcase"],
|
|
3002
|
+
["comment", "endcomment"],
|
|
3003
|
+
["for", "endfor"],
|
|
3004
|
+
["form", "endform"],
|
|
3005
|
+
["if", "endif"],
|
|
3006
|
+
["ifchanged", "endifchanged"],
|
|
3007
|
+
["javascript", "endjavascript"],
|
|
3008
|
+
["paginate", "endpaginate"],
|
|
3009
|
+
["raw", "endraw"],
|
|
3010
|
+
["schema", "endschema"],
|
|
3011
|
+
["style", "endstyle"],
|
|
3012
|
+
["stylesheet", "endstylesheet"],
|
|
3013
|
+
["tablerow", "endtablerow"],
|
|
3014
|
+
["unless", "endunless"]
|
|
3015
|
+
]);
|
|
3016
|
+
const CLOSING_TAGS = new Set(BLOCK_TAGS.values());
|
|
3017
|
+
const OPAQUE_BLOCK_TAGS = new Set([
|
|
3018
|
+
"comment",
|
|
3019
|
+
"javascript",
|
|
3020
|
+
"raw",
|
|
3021
|
+
"schema",
|
|
3022
|
+
"style",
|
|
3023
|
+
"stylesheet"
|
|
3024
|
+
]);
|
|
3025
|
+
/**
|
|
3026
|
+
* Find structurally unbalanced Liquid block tags such as an `{% if %}` with
|
|
3027
|
+
* no `{% endif %}`. This intentionally recognizes only established paired
|
|
3028
|
+
* tags; custom and inline tags are ignored rather than guessed at.
|
|
3029
|
+
*
|
|
3030
|
+
* Content inside raw/comment/schema/style/javascript blocks is opaque to
|
|
3031
|
+
* Liquid and therefore skipped until that block's matching close tag. This
|
|
3032
|
+
* prevents CSS, JSON, and examples containing Liquid-looking text from
|
|
3033
|
+
* producing false errors.
|
|
3034
|
+
*/
|
|
3035
|
+
function findLiquidBlockTagDiagnostics(content) {
|
|
3036
|
+
const stack = [];
|
|
3037
|
+
const diagnostics = [];
|
|
3038
|
+
let line = 1;
|
|
3039
|
+
let previousTagIndex = 0;
|
|
3040
|
+
const processTag = (name, tagLine) => {
|
|
3041
|
+
const open = stack.at(-1);
|
|
3042
|
+
if (open && OPAQUE_BLOCK_TAGS.has(open.name)) {
|
|
3043
|
+
if (name === open.expectedClose) stack.pop();
|
|
3044
|
+
return;
|
|
3045
|
+
}
|
|
3046
|
+
const expectedClose = BLOCK_TAGS.get(name);
|
|
3047
|
+
if (expectedClose) {
|
|
3048
|
+
stack.push({
|
|
3049
|
+
name,
|
|
3050
|
+
expectedClose,
|
|
3051
|
+
line: tagLine
|
|
3090
3052
|
});
|
|
3053
|
+
return;
|
|
3091
3054
|
}
|
|
3092
|
-
|
|
3093
|
-
if (!
|
|
3094
|
-
|
|
3095
|
-
|
|
3096
|
-
|
|
3097
|
-
|
|
3098
|
-
|
|
3099
|
-
"main",
|
|
3100
|
-
gitDir
|
|
3101
|
-
], { cwd: themeRoot });
|
|
3055
|
+
if (!CLOSING_TAGS.has(name)) return;
|
|
3056
|
+
if (!open) {
|
|
3057
|
+
diagnostics.push({
|
|
3058
|
+
severity: "error",
|
|
3059
|
+
message: `Unexpected Liquid tag '{% ${name} %}' on line ${tagLine}; there is no open block to close.`
|
|
3060
|
+
});
|
|
3061
|
+
return;
|
|
3102
3062
|
}
|
|
3103
|
-
|
|
3104
|
-
|
|
3105
|
-
|
|
3106
|
-
|
|
3107
|
-
|
|
3108
|
-
|
|
3109
|
-
/** True when the repo has at least one commit on `refs/heads/main`. */
|
|
3110
|
-
async hasHead() {
|
|
3111
|
-
if (this.headExists !== void 0) return this.headExists;
|
|
3112
|
-
try {
|
|
3113
|
-
await this.git([
|
|
3114
|
-
"rev-parse",
|
|
3115
|
-
"--verify",
|
|
3116
|
-
"HEAD"
|
|
3117
|
-
]);
|
|
3118
|
-
this.headExists = true;
|
|
3119
|
-
} catch {
|
|
3120
|
-
this.headExists = false;
|
|
3063
|
+
if (name !== open.expectedClose) {
|
|
3064
|
+
diagnostics.push({
|
|
3065
|
+
severity: "error",
|
|
3066
|
+
message: `Mismatched Liquid tag '{% ${name} %}' on line ${tagLine}; '{% ${open.name} %}' from line ${open.line} must close with '{% ${open.expectedClose} %}'.`
|
|
3067
|
+
});
|
|
3068
|
+
return;
|
|
3121
3069
|
}
|
|
3122
|
-
|
|
3070
|
+
stack.pop();
|
|
3071
|
+
};
|
|
3072
|
+
for (const match of content.matchAll(/\{%-?\s*([a-zA-Z_][\w-]*)\b(?:(?!\{%)[\s\S])*?-?%\}/g)) {
|
|
3073
|
+
const name = match[1]?.toLowerCase();
|
|
3074
|
+
if (!name) continue;
|
|
3075
|
+
const index = match.index ?? 0;
|
|
3076
|
+
for (let cursor = previousTagIndex; cursor < index; cursor++) if (content.charCodeAt(cursor) === 10) line += 1;
|
|
3077
|
+
previousTagIndex = index;
|
|
3078
|
+
const open = stack.at(-1);
|
|
3079
|
+
if (name === "liquid" && !(open && OPAQUE_BLOCK_TAGS.has(open.name))) {
|
|
3080
|
+
const statements = match[0].replace(/^\{%-?\s*liquid\b/i, "").replace(/-?%\}$/, "").split("\n");
|
|
3081
|
+
for (const [offset, statement] of statements.entries()) {
|
|
3082
|
+
const statementName = /^\s*([a-zA-Z_][\w-]*)\b/.exec(statement)?.[1];
|
|
3083
|
+
if (!statementName) continue;
|
|
3084
|
+
processTag(statementName.toLowerCase(), line + offset);
|
|
3085
|
+
}
|
|
3086
|
+
} else processTag(name, line);
|
|
3123
3087
|
}
|
|
3124
|
-
|
|
3125
|
-
|
|
3126
|
-
|
|
3127
|
-
|
|
3128
|
-
|
|
3129
|
-
|
|
3130
|
-
|
|
3131
|
-
|
|
3132
|
-
|
|
3133
|
-
|
|
3134
|
-
|
|
3135
|
-
|
|
3136
|
-
|
|
3137
|
-
|
|
3138
|
-
|
|
3088
|
+
for (const open of stack.reverse()) diagnostics.push({
|
|
3089
|
+
severity: "error",
|
|
3090
|
+
message: `Unclosed Liquid tag '{% ${open.name} %}' on line ${open.line}; expected '{% ${open.expectedClose} %}'.`
|
|
3091
|
+
});
|
|
3092
|
+
return diagnostics;
|
|
3093
|
+
}
|
|
3094
|
+
//#endregion
|
|
3095
|
+
//#region src/theme/dev-server/port-preflight.ts
|
|
3096
|
+
/**
|
|
3097
|
+
* The dev command does real work before it ever binds a port: it resolves
|
|
3098
|
+
* (or creates) a server-side dev theme and runs a full initial sync, which
|
|
3099
|
+
* can take minutes on a large theme. If the requested port is already taken
|
|
3100
|
+
* — most commonly by another `fluid theme dev` or the Mist Desktop preview,
|
|
3101
|
+
* which both default to 9292 — all of that work is wasted and the process
|
|
3102
|
+
* used to die with a raw `EADDRINUSE` stack trace. Call this before any of
|
|
3103
|
+
* that work starts so we fail fast with a clear message instead.
|
|
3104
|
+
*/
|
|
3105
|
+
var PortInUseError = class extends Error {
|
|
3106
|
+
constructor(host, port) {
|
|
3107
|
+
super(formatPortConflictMessage(host, port));
|
|
3108
|
+
this.host = host;
|
|
3109
|
+
this.port = port;
|
|
3110
|
+
this.name = "PortInUseError";
|
|
3139
3111
|
}
|
|
3140
|
-
|
|
3141
|
-
|
|
3142
|
-
|
|
3143
|
-
|
|
3144
|
-
|
|
3145
|
-
|
|
3146
|
-
|
|
3112
|
+
};
|
|
3113
|
+
function formatPortConflictMessage(host, port) {
|
|
3114
|
+
return `Port ${port} on ${host} is already in use — likely another \`fluid theme dev\` or the Mist Desktop preview. Stop the other server or pass --port <number>.`;
|
|
3115
|
+
}
|
|
3116
|
+
/**
|
|
3117
|
+
* Attempt to bind `host:port`, then immediately release it. Resolves if the
|
|
3118
|
+
* port is free; rejects with `PortInUseError` on `EADDRINUSE`/`EACCES`, or
|
|
3119
|
+
* the raw error for anything else unexpected.
|
|
3120
|
+
*/
|
|
3121
|
+
function checkPortAvailable(host, port) {
|
|
3122
|
+
return new Promise((resolve, reject) => {
|
|
3123
|
+
const server = net.createServer();
|
|
3124
|
+
server.once("error", (err) => {
|
|
3125
|
+
if (err.code === "EADDRINUSE" || err.code === "EACCES") reject(new PortInUseError(host, port));
|
|
3126
|
+
else reject(err);
|
|
3127
|
+
});
|
|
3128
|
+
server.once("listening", () => {
|
|
3129
|
+
server.close(() => resolve());
|
|
3130
|
+
});
|
|
3131
|
+
server.listen(port, host);
|
|
3132
|
+
});
|
|
3133
|
+
}
|
|
3134
|
+
//#endregion
|
|
3135
|
+
//#region src/theme/dev-server/index.ts
|
|
3136
|
+
function timestamp() {
|
|
3137
|
+
return (/* @__PURE__ */ new Date()).toLocaleTimeString("en-US", { hour12: false });
|
|
3138
|
+
}
|
|
3139
|
+
async function startDevServer(api, theme, themeRoot, opts, onReady) {
|
|
3140
|
+
const sse = new SSEStream();
|
|
3141
|
+
const syncer = new Syncer(api, theme.id, themeRoot);
|
|
3142
|
+
const recordRemoteSha = () => {
|
|
3143
|
+
const remoteSha = syncer.remoteSha();
|
|
3144
|
+
if (remoteSha) opts.onRemoteSha?.(remoteSha);
|
|
3145
|
+
};
|
|
3146
|
+
const pendingUpdates = /* @__PURE__ */ new Set();
|
|
3147
|
+
console.log(`\nSyncing theme ${theme.name} (#${theme.id})…`);
|
|
3148
|
+
const progress = (done, total) => {
|
|
3149
|
+
process.stdout.write(`\r Uploading ${done}/${total} files…`);
|
|
3150
|
+
};
|
|
3151
|
+
const uploadFromRemoteIndex = () => syncer.uploadTheme({
|
|
3152
|
+
delete: true,
|
|
3153
|
+
validate: opts.validate,
|
|
3154
|
+
linkManagedAssets: { replace: true },
|
|
3155
|
+
pendingBinaryAssets: true,
|
|
3156
|
+
onProgress: progress
|
|
3157
|
+
});
|
|
3158
|
+
let syncResult;
|
|
3159
|
+
if (opts.initialSync) {
|
|
3160
|
+
let shadowIsTrusted = false;
|
|
3147
3161
|
try {
|
|
3148
|
-
|
|
3149
|
-
|
|
3150
|
-
|
|
3151
|
-
|
|
3152
|
-
]);
|
|
3153
|
-
return stdout;
|
|
3154
|
-
} catch {
|
|
3155
|
-
return null;
|
|
3162
|
+
await syncer.preflightPush(opts.initialSync.remoteSha);
|
|
3163
|
+
shadowIsTrusted = true;
|
|
3164
|
+
} catch (error) {
|
|
3165
|
+
if (!(error instanceof PushConflictError)) throw error;
|
|
3156
3166
|
}
|
|
3167
|
+
if (shadowIsTrusted) {
|
|
3168
|
+
const diff = await diffAgainstShadow(themeRoot, opts.initialSync.shadow);
|
|
3169
|
+
syncResult = await syncer.uploadTheme({
|
|
3170
|
+
delete: true,
|
|
3171
|
+
validate: opts.validate,
|
|
3172
|
+
pendingBinaryAssets: true,
|
|
3173
|
+
baseSha: syncer.remoteSha(),
|
|
3174
|
+
diff,
|
|
3175
|
+
skipPreflight: true,
|
|
3176
|
+
onProgress: progress
|
|
3177
|
+
});
|
|
3178
|
+
} else syncResult = await uploadFromRemoteIndex();
|
|
3179
|
+
} else syncResult = await uploadFromRemoteIndex();
|
|
3180
|
+
process.stdout.write("\n");
|
|
3181
|
+
if (syncResult.linked > 0) console.log(` Saved ${syncResult.linked} remote asset reference(s).`);
|
|
3182
|
+
if (syncResult.validationFailed) {
|
|
3183
|
+
console.error(`\nSchema validation failed (${syncResult.errors.length} error(s)). Use --force to skip.\n`);
|
|
3184
|
+
for (const e of syncResult.errors) console.error(` ${e}`);
|
|
3185
|
+
process.exit(1);
|
|
3186
|
+
} else if (syncResult.errors.length > 0) {
|
|
3187
|
+
for (const e of syncResult.errors) console.error(` ${e}`);
|
|
3188
|
+
if (syncResult.uploaded + syncResult.deleted === 0) process.exit(1);
|
|
3157
3189
|
}
|
|
3158
|
-
|
|
3159
|
-
|
|
3160
|
-
|
|
3161
|
-
|
|
3162
|
-
|
|
3163
|
-
|
|
3164
|
-
|
|
3165
|
-
|
|
3166
|
-
|
|
3167
|
-
|
|
3168
|
-
|
|
3169
|
-
|
|
3170
|
-
|
|
3171
|
-
|
|
3172
|
-
|
|
3173
|
-
|
|
3174
|
-
|
|
3175
|
-
|
|
3176
|
-
|
|
3177
|
-
|
|
3178
|
-
|
|
3179
|
-
|
|
3180
|
-
|
|
3181
|
-
|
|
3182
|
-
|
|
3183
|
-
|
|
3184
|
-
|
|
3185
|
-
|
|
3186
|
-
|
|
3187
|
-
const parent = await this.hasHead() ? (await this.git(["rev-parse", "HEAD"])).stdout.toString("utf8").trim() : null;
|
|
3188
|
-
const commitArgs = [
|
|
3189
|
-
"commit-tree",
|
|
3190
|
-
treeSha,
|
|
3191
|
-
"-m",
|
|
3192
|
-
message
|
|
3193
|
-
];
|
|
3194
|
-
if (parent) commitArgs.push("-p", parent);
|
|
3195
|
-
const commitSha = (await this.git(commitArgs, { env: {
|
|
3196
|
-
GIT_AUTHOR_NAME: "Fluid CLI",
|
|
3197
|
-
GIT_AUTHOR_EMAIL: "cli@fluid.app",
|
|
3198
|
-
GIT_COMMITTER_NAME: "Fluid CLI",
|
|
3199
|
-
GIT_COMMITTER_EMAIL: "cli@fluid.app"
|
|
3200
|
-
} })).stdout.toString("utf8").trim();
|
|
3201
|
-
await this.git([
|
|
3202
|
-
"update-ref",
|
|
3203
|
-
"refs/heads/main",
|
|
3204
|
-
commitSha
|
|
3205
|
-
]);
|
|
3206
|
-
this.headExists = true;
|
|
3207
|
-
return commitSha;
|
|
3208
|
-
} finally {
|
|
3209
|
-
try {
|
|
3210
|
-
rmSync(indexPath, { force: true });
|
|
3211
|
-
rmSync(indexPath.substring(0, indexPath.length - 6), {
|
|
3212
|
-
recursive: true,
|
|
3213
|
-
force: true
|
|
3214
|
-
});
|
|
3215
|
-
} catch {}
|
|
3190
|
+
if (syncResult.errors.length === 0) recordRemoteSha();
|
|
3191
|
+
const SYNC_IDLE_MS = 2e3;
|
|
3192
|
+
let lastArrivedAt = 0;
|
|
3193
|
+
let pendingSync = null;
|
|
3194
|
+
let syncInFlight = Promise.resolve();
|
|
3195
|
+
let askOwed = false;
|
|
3196
|
+
const sendSync = () => {
|
|
3197
|
+
syncInFlight = syncInFlight.then(async () => {
|
|
3198
|
+
askOwed = !await syncer.requestSync();
|
|
3199
|
+
});
|
|
3200
|
+
};
|
|
3201
|
+
const flushSyncNow = () => {
|
|
3202
|
+
if (!pendingSync) return;
|
|
3203
|
+
clearTimeout(pendingSync);
|
|
3204
|
+
pendingSync = null;
|
|
3205
|
+
sendSync();
|
|
3206
|
+
};
|
|
3207
|
+
const scheduleSync = () => {
|
|
3208
|
+
if (pendingSync) clearTimeout(pendingSync);
|
|
3209
|
+
pendingSync = setTimeout(() => {
|
|
3210
|
+
pendingSync = null;
|
|
3211
|
+
sendSync();
|
|
3212
|
+
}, SYNC_IDLE_MS);
|
|
3213
|
+
};
|
|
3214
|
+
const stopWatcher = watchTheme(themeRoot, async (modified, added, removed, arrivedAt) => {
|
|
3215
|
+
if (arrivedAt - lastArrivedAt > SYNC_IDLE_MS) flushSyncNow();
|
|
3216
|
+
else if (pendingSync) {
|
|
3217
|
+
clearTimeout(pendingSync);
|
|
3218
|
+
pendingSync = null;
|
|
3216
3219
|
}
|
|
3217
|
-
|
|
3218
|
-
|
|
3219
|
-
|
|
3220
|
-
|
|
3221
|
-
|
|
3222
|
-
|
|
3223
|
-
|
|
3224
|
-
|
|
3225
|
-
|
|
3226
|
-
|
|
3227
|
-
|
|
3228
|
-
|
|
3229
|
-
|
|
3230
|
-
|
|
3231
|
-
|
|
3232
|
-
|
|
3233
|
-
|
|
3234
|
-
*/
|
|
3235
|
-
async merge3(base, local, remote, favor) {
|
|
3236
|
-
const dir = mkdtempSync(join(tmpdir(), "fluid-merge-"));
|
|
3237
|
-
const localPath = join(dir, "local");
|
|
3238
|
-
const basePath = join(dir, "base");
|
|
3239
|
-
const remotePath = join(dir, "remote");
|
|
3240
|
-
try {
|
|
3241
|
-
writeFileSync(localPath, local);
|
|
3242
|
-
writeFileSync(basePath, base ?? Buffer.alloc(0));
|
|
3243
|
-
writeFileSync(remotePath, remote);
|
|
3220
|
+
lastArrivedAt = arrivedAt;
|
|
3221
|
+
await syncInFlight;
|
|
3222
|
+
if (askOwed) {
|
|
3223
|
+
sendSync();
|
|
3224
|
+
await syncInFlight;
|
|
3225
|
+
}
|
|
3226
|
+
const changed = [...modified, ...added];
|
|
3227
|
+
let wroteRemote = false;
|
|
3228
|
+
for (const file of changed) {
|
|
3229
|
+
if (opts.validate && file.isLiquid) {
|
|
3230
|
+
const diagnostics = file.validateSchema();
|
|
3231
|
+
for (const d of diagnostics) {
|
|
3232
|
+
const prefix = d.severity === "error" ? "Schema error" : "Schema warning";
|
|
3233
|
+
console.warn(`\n[${prefix}] ${file.relativePath}: ${d.message}`);
|
|
3234
|
+
}
|
|
3235
|
+
}
|
|
3236
|
+
pendingUpdates.add(file.relativePath);
|
|
3244
3237
|
try {
|
|
3245
|
-
const
|
|
3246
|
-
|
|
3247
|
-
|
|
3248
|
-
|
|
3249
|
-
|
|
3250
|
-
|
|
3251
|
-
|
|
3252
|
-
|
|
3253
|
-
|
|
3254
|
-
"remote",
|
|
3255
|
-
localPath,
|
|
3256
|
-
basePath,
|
|
3257
|
-
remotePath
|
|
3258
|
-
]);
|
|
3259
|
-
return {
|
|
3260
|
-
merged: stdout,
|
|
3261
|
-
hasConflicts: false
|
|
3262
|
-
};
|
|
3263
|
-
} catch (err) {
|
|
3264
|
-
const e = err;
|
|
3265
|
-
const merged = e.stdout instanceof Buffer ? e.stdout : e.stdout != null ? Buffer.from(e.stdout) : Buffer.alloc(0);
|
|
3266
|
-
const inConflictRange = typeof e.code === "number" && e.code >= 1 && e.code <= 127;
|
|
3267
|
-
if (inConflictRange && favor) return {
|
|
3268
|
-
merged,
|
|
3269
|
-
hasConflicts: false
|
|
3270
|
-
};
|
|
3271
|
-
if (inConflictRange && merged.length > 0) return {
|
|
3272
|
-
merged,
|
|
3273
|
-
hasConflicts: true
|
|
3274
|
-
};
|
|
3275
|
-
throw err;
|
|
3238
|
+
const uploadedContent = await syncer.uploadFile(file, void 0, { pendingAsset: true });
|
|
3239
|
+
wroteRemote = true;
|
|
3240
|
+
console.log(` ✓ synced ${file.relativePath} (${timestamp()})`);
|
|
3241
|
+
if (file.isLiquid && uploadedContent !== null && hasUnbalancedLiquidDelimiters(uploadedContent)) console.warn(` ⚠ ${file.relativePath}: unbalanced liquid delimiters — the storefront may silently serve stale content for this section`);
|
|
3242
|
+
if (file.isLiquid && uploadedContent !== null) for (const diagnostic of findLiquidBlockTagDiagnostics(uploadedContent)) console.warn(` ⚠ ${file.relativePath}: ${diagnostic.message}`);
|
|
3243
|
+
} catch (e) {
|
|
3244
|
+
console.error(`\n[Watcher] Upload failed: ${file.relativePath}: ${e}`);
|
|
3245
|
+
} finally {
|
|
3246
|
+
pendingUpdates.delete(file.relativePath);
|
|
3276
3247
|
}
|
|
3277
|
-
}
|
|
3248
|
+
}
|
|
3249
|
+
for (const file of removed) {
|
|
3250
|
+
if (themeRoot.ignore.ignore(file.relativePath)) continue;
|
|
3278
3251
|
try {
|
|
3279
|
-
|
|
3280
|
-
|
|
3281
|
-
|
|
3282
|
-
});
|
|
3252
|
+
await syncer.deleteRemoteFile(file.relativePath);
|
|
3253
|
+
wroteRemote = true;
|
|
3254
|
+
console.log(` ✓ removed ${file.relativePath}`);
|
|
3283
3255
|
} catch {}
|
|
3284
3256
|
}
|
|
3285
|
-
|
|
3286
|
-
|
|
3287
|
-
|
|
3288
|
-
|
|
3289
|
-
|
|
3290
|
-
|
|
3291
|
-
|
|
3292
|
-
|
|
3293
|
-
|
|
3294
|
-
|
|
3295
|
-
|
|
3296
|
-
|
|
3297
|
-
|
|
3298
|
-
|
|
3299
|
-
|
|
3300
|
-
|
|
3301
|
-
|
|
3302
|
-
|
|
3303
|
-
|
|
3304
|
-
"--git-dir",
|
|
3305
|
-
this.gitDir,
|
|
3306
|
-
...args
|
|
3307
|
-
], {
|
|
3308
|
-
cwd: opts.cwd ?? this.themeRoot,
|
|
3309
|
-
env: {
|
|
3310
|
-
...process.env,
|
|
3311
|
-
...opts.env
|
|
3312
|
-
},
|
|
3313
|
-
stdio: [
|
|
3314
|
-
"pipe",
|
|
3315
|
-
"pipe",
|
|
3316
|
-
"pipe"
|
|
3317
|
-
]
|
|
3318
|
-
});
|
|
3319
|
-
if (opts.input) child.stdin.write(opts.input);
|
|
3320
|
-
child.stdin.end();
|
|
3321
|
-
return new Promise((resolve, reject) => {
|
|
3322
|
-
const stdout = [];
|
|
3323
|
-
const stderr = [];
|
|
3324
|
-
child.stdout.on("data", (chunk) => stdout.push(chunk));
|
|
3325
|
-
child.stderr.on("data", (chunk) => stderr.push(chunk));
|
|
3326
|
-
child.on("error", reject);
|
|
3327
|
-
child.on("close", (code) => {
|
|
3328
|
-
const out = Buffer.concat(stdout);
|
|
3329
|
-
const err = Buffer.concat(stderr);
|
|
3330
|
-
if (code === 0) resolve({
|
|
3331
|
-
stdout: out,
|
|
3332
|
-
stderr: err
|
|
3333
|
-
});
|
|
3334
|
-
else {
|
|
3335
|
-
const e = /* @__PURE__ */ new Error(`git ${args.join(" ")} exited with ${code}: ${err.toString("utf8")}`);
|
|
3336
|
-
e.code = code ?? -1;
|
|
3337
|
-
e.stdout = out;
|
|
3338
|
-
e.stderr = err;
|
|
3339
|
-
reject(e);
|
|
3340
|
-
}
|
|
3257
|
+
if (wroteRemote) recordRemoteSha();
|
|
3258
|
+
if (removed.length > 0) sse.broadcast(JSON.stringify({ reload_page: true }));
|
|
3259
|
+
else if (changed.length > 0) sse.broadcast(JSON.stringify({ modified: changed.map((f) => f.relativePath) }));
|
|
3260
|
+
scheduleSync();
|
|
3261
|
+
});
|
|
3262
|
+
const server = http.createServer(async (req, res) => {
|
|
3263
|
+
if (req.url === "/hot-reload") {
|
|
3264
|
+
sse.add(res);
|
|
3265
|
+
return;
|
|
3266
|
+
}
|
|
3267
|
+
try {
|
|
3268
|
+
await proxyRequest(req, res, {
|
|
3269
|
+
company: theme.company,
|
|
3270
|
+
themeId: theme.id,
|
|
3271
|
+
reloadMode: opts.reloadMode,
|
|
3272
|
+
pendingFiles: () => [...pendingUpdates].map((p) => themeRoot.file(p)).filter((f) => f.isText).map((f) => ({
|
|
3273
|
+
relativePath: f.relativePath,
|
|
3274
|
+
read: () => f.read()
|
|
3275
|
+
}))
|
|
3341
3276
|
});
|
|
3277
|
+
} catch (e) {
|
|
3278
|
+
console.error(`[Proxy] ${req.method} ${req.url} → ${e}`);
|
|
3279
|
+
if (!res.headersSent) {
|
|
3280
|
+
const message = e instanceof Error ? e.message : String(e);
|
|
3281
|
+
res.writeHead(502, { "content-type": "text/plain; charset=utf-8" });
|
|
3282
|
+
res.end(`Bad Gateway — the local preview could not reach ${theme.company}.fluid.app: ${message}\nThis is the dev machine's network path to Fluid, not the theme. Common causes: TLS-inspecting security software (its root CA is in the OS keychain, which Node does not read — set NODE_EXTRA_CA_CERTS to its certificate), DNS, or a proxy. The same error is logged by the theme dev server process.`);
|
|
3283
|
+
}
|
|
3284
|
+
}
|
|
3285
|
+
});
|
|
3286
|
+
await new Promise((resolve, reject) => {
|
|
3287
|
+
server.once("error", (err) => {
|
|
3288
|
+
if (err.code === "EADDRINUSE" || err.code === "EACCES") {
|
|
3289
|
+
console.error(formatPortConflictMessage(opts.host, opts.port));
|
|
3290
|
+
process.exit(1);
|
|
3291
|
+
}
|
|
3292
|
+
reject(err);
|
|
3342
3293
|
});
|
|
3343
|
-
|
|
3344
|
-
};
|
|
3345
|
-
|
|
3346
|
-
|
|
3347
|
-
|
|
3348
|
-
|
|
3349
|
-
|
|
3350
|
-
|
|
3351
|
-
|
|
3294
|
+
server.listen(opts.port, opts.host, () => resolve());
|
|
3295
|
+
});
|
|
3296
|
+
const address = `http://${opts.host}:${opts.port}`;
|
|
3297
|
+
onReady?.(address);
|
|
3298
|
+
return function stop() {
|
|
3299
|
+
sse.close();
|
|
3300
|
+
stopWatcher();
|
|
3301
|
+
server.close();
|
|
3302
|
+
};
|
|
3352
3303
|
}
|
|
3353
|
-
|
|
3354
|
-
|
|
3355
|
-
|
|
3356
|
-
|
|
3357
|
-
|
|
3358
|
-
|
|
3359
|
-
}
|
|
3304
|
+
//#endregion
|
|
3305
|
+
//#region src/theme-picker.ts
|
|
3306
|
+
const PAGE_SIZE = 50;
|
|
3307
|
+
const LOAD_MORE_VALUE = -1;
|
|
3308
|
+
function themeLabel(t) {
|
|
3309
|
+
const active = t.status === "active" ? ` ${chalk.green("[active]")}` : "";
|
|
3310
|
+
return `${t.name} (#${t.id})${active}`;
|
|
3360
3311
|
}
|
|
3361
|
-
|
|
3362
|
-
|
|
3363
|
-
|
|
3364
|
-
|
|
3365
|
-
|
|
3366
|
-
|
|
3367
|
-
|
|
3368
|
-
|
|
3369
|
-
|
|
3370
|
-
|
|
3371
|
-
return null;
|
|
3372
|
-
}
|
|
3312
|
+
function themeChoices(themeList, hasMore) {
|
|
3313
|
+
const choices = themeList.map((t) => ({
|
|
3314
|
+
title: themeLabel(t),
|
|
3315
|
+
value: t.id
|
|
3316
|
+
}));
|
|
3317
|
+
if (hasMore) choices.push({
|
|
3318
|
+
title: chalk.dim(`── Load more themes ──`),
|
|
3319
|
+
value: LOAD_MORE_VALUE
|
|
3320
|
+
});
|
|
3321
|
+
return choices;
|
|
3373
3322
|
}
|
|
3374
|
-
|
|
3375
|
-
|
|
3376
|
-
|
|
3377
|
-
|
|
3378
|
-
|
|
3379
|
-
|
|
3380
|
-
|
|
3381
|
-
|
|
3382
|
-
|
|
3383
|
-
|
|
3384
|
-
cwd: themeRoot,
|
|
3385
|
-
stdio: [
|
|
3386
|
-
"ignore",
|
|
3387
|
-
"pipe",
|
|
3388
|
-
"pipe"
|
|
3389
|
-
]
|
|
3390
|
-
});
|
|
3391
|
-
child.on("close", (code) => resolve(code === 0));
|
|
3392
|
-
child.on("error", () => resolve(false));
|
|
3393
|
-
})) return;
|
|
3394
|
-
const gitignorePath = join(themeRoot, ".gitignore");
|
|
3395
|
-
let existing = "";
|
|
3396
|
-
try {
|
|
3397
|
-
existing = readFileSync(gitignorePath, "utf-8");
|
|
3398
|
-
} catch {
|
|
3399
|
-
existing = "";
|
|
3400
|
-
}
|
|
3401
|
-
const lines = existing.split("\n").map((line) => line.trim());
|
|
3402
|
-
if (lines.includes(".fluid-theme/") || lines.includes(".fluid-theme")) return;
|
|
3403
|
-
const separator = existing.length === 0 || existing.endsWith("\n") ? "" : "\n";
|
|
3404
|
-
writeFileSync(gitignorePath, `${existing}${separator}.fluid-theme/\n`, "utf-8");
|
|
3323
|
+
async function fetchThemesPage(api, page, searchQuery) {
|
|
3324
|
+
const body = await listApplicationThemes(api, {
|
|
3325
|
+
per_page: PAGE_SIZE,
|
|
3326
|
+
page,
|
|
3327
|
+
...searchQuery ? { search_query: searchQuery } : {}
|
|
3328
|
+
});
|
|
3329
|
+
return {
|
|
3330
|
+
themes: body.application_themes ?? [],
|
|
3331
|
+
hasMore: page < (body.meta?.total_pages ?? 1)
|
|
3332
|
+
};
|
|
3405
3333
|
}
|
|
3406
|
-
|
|
3407
|
-
|
|
3408
|
-
|
|
3409
|
-
|
|
3410
|
-
|
|
3411
|
-
|
|
3412
|
-
|
|
3413
|
-
|
|
3414
|
-
|
|
3415
|
-
|
|
3416
|
-
|
|
3417
|
-
|
|
3418
|
-
|
|
3419
|
-
|
|
3420
|
-
|
|
3421
|
-
|
|
3422
|
-
|
|
3423
|
-
|
|
3424
|
-
|
|
3425
|
-
|
|
3426
|
-
|
|
3427
|
-
|
|
3428
|
-
|
|
3429
|
-
|
|
3430
|
-
|
|
3431
|
-
|
|
3432
|
-
|
|
3433
|
-
|
|
3434
|
-
|
|
3435
|
-
|
|
3436
|
-
|
|
3334
|
+
async function selectTheme(api, message) {
|
|
3335
|
+
const allThemes = [];
|
|
3336
|
+
let page = 1;
|
|
3337
|
+
let hasMore = true;
|
|
3338
|
+
let initialIndex = 0;
|
|
3339
|
+
let searchQuery = "";
|
|
3340
|
+
let searchResults = [];
|
|
3341
|
+
while (true) {
|
|
3342
|
+
if (hasMore && allThemes.length < page * PAGE_SIZE) {
|
|
3343
|
+
const result = await fetchThemesPage(api, page);
|
|
3344
|
+
allThemes.push(...result.themes);
|
|
3345
|
+
hasMore = result.hasMore;
|
|
3346
|
+
}
|
|
3347
|
+
if (!allThemes.length) {
|
|
3348
|
+
console.error("No themes found.");
|
|
3349
|
+
process.exit(1);
|
|
3350
|
+
}
|
|
3351
|
+
const choices = themeChoices(allThemes, hasMore);
|
|
3352
|
+
const { id } = await prompts({
|
|
3353
|
+
type: "autocomplete",
|
|
3354
|
+
name: "id",
|
|
3355
|
+
message,
|
|
3356
|
+
initial: initialIndex,
|
|
3357
|
+
choices,
|
|
3358
|
+
suggest: async (input, choices) => {
|
|
3359
|
+
if (!input) {
|
|
3360
|
+
searchQuery = "";
|
|
3361
|
+
searchResults = [];
|
|
3362
|
+
return choices;
|
|
3363
|
+
}
|
|
3364
|
+
if (input !== searchQuery) {
|
|
3365
|
+
searchQuery = input;
|
|
3366
|
+
try {
|
|
3367
|
+
searchResults = (await fetchThemesPage(api, 1, input)).themes;
|
|
3368
|
+
} catch {
|
|
3369
|
+
searchResults = [];
|
|
3370
|
+
}
|
|
3371
|
+
}
|
|
3372
|
+
return searchResults.map((t) => ({
|
|
3373
|
+
title: themeLabel(t),
|
|
3374
|
+
value: t.id
|
|
3375
|
+
}));
|
|
3376
|
+
}
|
|
3377
|
+
}, { onCancel: () => process.exit(130) });
|
|
3378
|
+
if (id === LOAD_MORE_VALUE) {
|
|
3379
|
+
initialIndex = allThemes.length;
|
|
3380
|
+
page++;
|
|
3381
|
+
continue;
|
|
3382
|
+
}
|
|
3383
|
+
if (!id) {
|
|
3384
|
+
console.error("No theme selected.");
|
|
3385
|
+
process.exit(1);
|
|
3386
|
+
}
|
|
3387
|
+
const found = allThemes.find((t) => t.id === id) ?? searchResults.find((t) => t.id === id);
|
|
3388
|
+
if (found) return found;
|
|
3389
|
+
return (await getApplicationTheme(api, id)).application_theme;
|
|
3437
3390
|
}
|
|
3438
|
-
return {
|
|
3439
|
-
changed,
|
|
3440
|
-
deleted
|
|
3441
|
-
};
|
|
3442
3391
|
}
|
|
3443
|
-
|
|
3444
|
-
|
|
3445
|
-
|
|
3446
|
-
|
|
3447
|
-
|
|
3448
|
-
|
|
3449
|
-
|
|
3450
|
-
|
|
3392
|
+
async function findTheme(api, identifier) {
|
|
3393
|
+
const idNum = Number(identifier);
|
|
3394
|
+
if (Number.isInteger(idNum) && idNum > 0) try {
|
|
3395
|
+
const body = await getApplicationTheme(api, idNum);
|
|
3396
|
+
if (body.application_theme) return body.application_theme;
|
|
3397
|
+
} catch {}
|
|
3398
|
+
let page = 1;
|
|
3399
|
+
let hasMore = true;
|
|
3400
|
+
while (hasMore) {
|
|
3401
|
+
const result = await fetchThemesPage(api, page, identifier);
|
|
3402
|
+
const found = result.themes.find((t) => t.name.toLowerCase() === identifier.toLowerCase());
|
|
3403
|
+
if (found) return found;
|
|
3404
|
+
hasMore = result.hasMore;
|
|
3405
|
+
page++;
|
|
3406
|
+
}
|
|
3407
|
+
console.error(`No theme found with identifier: ${identifier}`);
|
|
3408
|
+
process.exit(1);
|
|
3451
3409
|
}
|
|
3410
|
+
//#endregion
|
|
3411
|
+
//#region src/workspace.ts
|
|
3412
|
+
const WORKSPACE_FILE = ".fluid-workspace.json";
|
|
3452
3413
|
/**
|
|
3453
|
-
*
|
|
3454
|
-
*
|
|
3455
|
-
* fix them and re-run" behavior — the whole point of writing markers
|
|
3456
|
-
* on pull was to hand resolution to the user, so we can't send them
|
|
3457
|
-
* upstream.
|
|
3414
|
+
* Walk up from `startDir` looking for `.fluid-workspace.json`.
|
|
3415
|
+
* Returns the workspace info if found, or `null` if not in a workspace.
|
|
3458
3416
|
*/
|
|
3459
|
-
function
|
|
3460
|
-
|
|
3461
|
-
|
|
3462
|
-
|
|
3463
|
-
|
|
3464
|
-
|
|
3465
|
-
|
|
3417
|
+
function findWorkspace(startDir) {
|
|
3418
|
+
let dir = resolve(startDir ?? process.cwd());
|
|
3419
|
+
while (true) {
|
|
3420
|
+
const candidate = join(dir, WORKSPACE_FILE);
|
|
3421
|
+
if (existsSync(candidate)) try {
|
|
3422
|
+
const raw = readFileSync(candidate, "utf-8");
|
|
3423
|
+
const config = JSON.parse(raw);
|
|
3424
|
+
return {
|
|
3425
|
+
root: dir,
|
|
3426
|
+
config
|
|
3427
|
+
};
|
|
3428
|
+
} catch {
|
|
3429
|
+
return null;
|
|
3430
|
+
}
|
|
3431
|
+
const parent = dirname(dir);
|
|
3432
|
+
if (parent === dir) break;
|
|
3433
|
+
dir = parent;
|
|
3466
3434
|
}
|
|
3467
|
-
return
|
|
3435
|
+
return null;
|
|
3468
3436
|
}
|
|
3469
3437
|
/**
|
|
3470
|
-
*
|
|
3471
|
-
*
|
|
3472
|
-
* bearing files to a live theme is never acceptable, so `--auto-
|
|
3473
|
-
* baseline` pushes still refuse — but they emit this line so the
|
|
3474
|
-
* desktop can surface WHICH files block the publish instead of a
|
|
3475
|
-
* dead-end wall of prose. Format:
|
|
3438
|
+
* If cwd is already inside `{workspace}/local/{company}/...`, return that
|
|
3439
|
+
* theme root directory. Otherwise return null.
|
|
3476
3440
|
*
|
|
3477
|
-
*
|
|
3441
|
+
* Examples (workspace root = /code/fluid-theme-dev):
|
|
3442
|
+
* cwd = /code/fluid-theme-dev/local/acme-co → /code/fluid-theme-dev/local/acme-co
|
|
3443
|
+
* cwd = /code/fluid-theme-dev/local/acme-co/templates → /code/fluid-theme-dev/local/acme-co
|
|
3444
|
+
* cwd = /code/fluid-theme-dev → null
|
|
3445
|
+
* cwd = /code/fluid-theme-dev/local → null
|
|
3478
3446
|
*/
|
|
3479
|
-
function
|
|
3480
|
-
|
|
3447
|
+
function resolveThemeRootFromCwd(workspace) {
|
|
3448
|
+
const cwd = resolve(process.cwd());
|
|
3449
|
+
const localDir = join(workspace.root, "local");
|
|
3450
|
+
const rel = relative(localDir, cwd);
|
|
3451
|
+
if (rel.startsWith("..") || rel === ".") return null;
|
|
3452
|
+
const firstSegment = rel.split(sep)[0];
|
|
3453
|
+
if (!firstSegment) return null;
|
|
3454
|
+
return join(localDir, firstSegment);
|
|
3481
3455
|
}
|
|
3482
|
-
|
|
3483
|
-
|
|
3484
|
-
const CONFLICT_END = Buffer.from(">>>>>>>");
|
|
3456
|
+
//#endregion
|
|
3457
|
+
//#region src/commands/dev.ts
|
|
3485
3458
|
/**
|
|
3486
|
-
*
|
|
3487
|
-
*
|
|
3488
|
-
*
|
|
3489
|
-
*
|
|
3459
|
+
* Return the state needed to trust the source shadow for a dev sync.
|
|
3460
|
+
*
|
|
3461
|
+
* All three values must still describe the same world: the checkout has not
|
|
3462
|
+
* pulled a newer source, the dev theme has not changed outside this CLI, and
|
|
3463
|
+
* managed ImageKit references have not changed outside the shadow's file tree.
|
|
3490
3464
|
*/
|
|
3491
|
-
function
|
|
3492
|
-
|
|
3465
|
+
function devShadowTrust(config, devTheme, assetManifestSha) {
|
|
3466
|
+
if (!config?.baseSha || !devTheme?.remoteSha || !devTheme.sourceBaseSha || !devTheme.assetManifestSha) return null;
|
|
3467
|
+
if (devTheme.sourceThemeId !== config.themeId) return null;
|
|
3468
|
+
if (devTheme.sourceBaseSha !== config.baseSha) return null;
|
|
3469
|
+
if (devTheme.assetManifestSha !== assetManifestSha) return null;
|
|
3470
|
+
return {
|
|
3471
|
+
sourceThemeId: config.themeId,
|
|
3472
|
+
remoteSha: devTheme.remoteSha
|
|
3473
|
+
};
|
|
3493
3474
|
}
|
|
3494
3475
|
/**
|
|
3495
|
-
*
|
|
3496
|
-
*
|
|
3497
|
-
*
|
|
3476
|
+
* Create the isolated theme used by `theme dev`.
|
|
3477
|
+
*
|
|
3478
|
+
* A checkout from `theme pull` has a source theme id. New servers clone that
|
|
3479
|
+
* source by reference, preserving its DAM/ImageKit assets without moving
|
|
3480
|
+
* bytes. A 404/405 keeps older deployments compatible with the established
|
|
3481
|
+
* empty-theme flow; other failures must remain visible to the developer.
|
|
3498
3482
|
*/
|
|
3499
|
-
async function
|
|
3500
|
-
|
|
3501
|
-
|
|
3502
|
-
|
|
3503
|
-
|
|
3504
|
-
|
|
3505
|
-
localKeys.add(file.relativePath);
|
|
3506
|
-
const buf = file.isText ? Buffer.from(file.read()) : file.readBinary();
|
|
3507
|
-
entries.push({
|
|
3508
|
-
path: file.relativePath,
|
|
3509
|
-
sha: await shadow.writeBlob(buf)
|
|
3510
|
-
});
|
|
3483
|
+
async function createDevelopmentTheme(api, sourceThemeId, name) {
|
|
3484
|
+
if (sourceThemeId !== void 0) try {
|
|
3485
|
+
return (await cloneApplicationThemeForDevelopment(api, sourceThemeId, { application_theme: { name } })).application_theme;
|
|
3486
|
+
} catch (error) {
|
|
3487
|
+
if (!isApiError(error) || error.status !== 404 && error.status !== 405) throw error;
|
|
3488
|
+
console.warn("Server-side theme cloning is unavailable; falling back to an empty development theme. The first sync may take longer.");
|
|
3511
3489
|
}
|
|
3512
|
-
|
|
3513
|
-
|
|
3514
|
-
|
|
3515
|
-
|
|
3516
|
-
|
|
3517
|
-
|
|
3518
|
-
|
|
3519
|
-
|
|
3490
|
+
return (await createApplicationTheme(api, { application_theme: {
|
|
3491
|
+
name,
|
|
3492
|
+
status: "development"
|
|
3493
|
+
} })).application_theme;
|
|
3494
|
+
}
|
|
3495
|
+
async function ensureDevTheme(api, projectKey, identifier, sourceThemeId, sourceBaseSha, sourceAssetManifestSha) {
|
|
3496
|
+
if (identifier) {
|
|
3497
|
+
const theme = await findTheme(api, identifier);
|
|
3498
|
+
setLastDevThemeId(theme.id);
|
|
3499
|
+
return theme;
|
|
3520
3500
|
}
|
|
3521
|
-
|
|
3501
|
+
const stored = getDevTheme(projectKey);
|
|
3502
|
+
if (stored && stored.sourceThemeId === sourceThemeId) {
|
|
3503
|
+
try {
|
|
3504
|
+
const existing = (await getApplicationTheme(api, stored.id)).application_theme;
|
|
3505
|
+
if (existing && existing.status === "development") {
|
|
3506
|
+
console.log(`Using existing dev theme #${existing.id}`);
|
|
3507
|
+
setDevTheme(projectKey, {
|
|
3508
|
+
...stored,
|
|
3509
|
+
id: existing.id,
|
|
3510
|
+
name: existing.name,
|
|
3511
|
+
...sourceThemeId === void 0 ? {} : { sourceThemeId }
|
|
3512
|
+
});
|
|
3513
|
+
return existing;
|
|
3514
|
+
}
|
|
3515
|
+
} catch {}
|
|
3516
|
+
clearDevTheme(projectKey);
|
|
3517
|
+
}
|
|
3518
|
+
const { hostname } = await import("node:os");
|
|
3519
|
+
const theme = await createDevelopmentTheme(api, sourceThemeId, `Development (${hostname().split(".")[0] ?? "dev"}-${Math.random().toString(36).slice(2, 8)})`.slice(0, 50));
|
|
3520
|
+
setDevTheme(projectKey, {
|
|
3521
|
+
id: theme.id,
|
|
3522
|
+
name: theme.name,
|
|
3523
|
+
...sourceThemeId === void 0 ? {} : { sourceThemeId },
|
|
3524
|
+
...sourceThemeId !== void 0 && sourceBaseSha ? {
|
|
3525
|
+
sourceBaseSha,
|
|
3526
|
+
remoteSha: sourceBaseSha
|
|
3527
|
+
} : {},
|
|
3528
|
+
...sourceAssetManifestSha ? { assetManifestSha: sourceAssetManifestSha } : {}
|
|
3529
|
+
});
|
|
3530
|
+
console.log(`Created dev theme: ${theme.name} (#${theme.id})`);
|
|
3531
|
+
return theme;
|
|
3522
3532
|
}
|
|
3523
|
-
|
|
3524
|
-
|
|
3525
|
-
|
|
3526
|
-
|
|
3527
|
-
|
|
3528
|
-
|
|
3529
|
-
|
|
3530
|
-
|
|
3531
|
-
|
|
3533
|
+
function createDevCommand() {
|
|
3534
|
+
return new Command("dev").description("Start the theme dev server with hot reload").option("--host <host>", "Local server host", "127.0.0.1").option("--port <port>", "Local server port", "9292").option("-t, --theme <name-or-id>", "Use an existing theme instead of dev theme").option("-f, --force", "Skip schema validation on upload").option("--live-reload <mode>", "Reload mode: full-page | off", "full-page").option("--navigate", "Open browser navigator after server starts").option("--root <path>", "Theme root directory", ".").action(async (opts) => {
|
|
3535
|
+
requireToken();
|
|
3536
|
+
let rootPath = opts.root;
|
|
3537
|
+
if (rootPath === ".") {
|
|
3538
|
+
const workspace = findWorkspace();
|
|
3539
|
+
if (workspace) rootPath = resolveThemeRootFromCwd(workspace) ?? rootPath;
|
|
3540
|
+
}
|
|
3541
|
+
const themeRoot = new ThemeRoot(rootPath);
|
|
3542
|
+
if (!themeRoot.isValid()) {
|
|
3543
|
+
console.error(`'${rootPath}' does not look like a theme directory.`);
|
|
3544
|
+
process.exit(1);
|
|
3545
|
+
}
|
|
3546
|
+
const port = Number(opts.port);
|
|
3547
|
+
if (!Number.isInteger(port) || port < 1 || port > 65535) {
|
|
3548
|
+
console.error(`Invalid port: '${opts.port}'. Must be an integer between 1 and 65535.`);
|
|
3549
|
+
process.exit(1);
|
|
3550
|
+
}
|
|
3551
|
+
try {
|
|
3552
|
+
await checkPortAvailable(opts.host, port);
|
|
3553
|
+
} catch (e) {
|
|
3554
|
+
if (e instanceof PortInUseError) console.error(e.message);
|
|
3555
|
+
else console.error(`Failed to check port availability: ${e}`);
|
|
3556
|
+
process.exit(1);
|
|
3557
|
+
}
|
|
3558
|
+
const reloadMode = opts.liveReload === "off" ? "off" : "full-page";
|
|
3559
|
+
const api = createApiClient();
|
|
3560
|
+
const config = readThemeConfig(themeRoot.root);
|
|
3561
|
+
let company;
|
|
3562
|
+
if (config?.company) company = config.company;
|
|
3563
|
+
else {
|
|
3564
|
+
company = (await api.get("/api/company/v1/companies/me")).data?.company?.subdomain ?? "";
|
|
3565
|
+
if (!company) {
|
|
3566
|
+
console.error("Could not determine company subdomain. Make sure your token is valid.");
|
|
3567
|
+
process.exit(1);
|
|
3568
|
+
}
|
|
3569
|
+
}
|
|
3570
|
+
const projectKey = devThemeKey(company, themeRoot.root);
|
|
3571
|
+
const theme = opts.theme ? await ensureDevTheme(api, projectKey, opts.theme) : await ensureDevTheme(api, projectKey, void 0, config?.themeId, config?.baseSha, config?.assetManifestSha);
|
|
3572
|
+
const devTheme = opts.theme ? void 0 : getDevTheme(projectKey);
|
|
3573
|
+
const assetManifest = new ThemeAssetManifest(themeRoot.root);
|
|
3574
|
+
const trust = opts.theme ? null : devShadowTrust(config, devTheme, assetManifest.fingerprint());
|
|
3575
|
+
let initialSync;
|
|
3576
|
+
if (trust) {
|
|
3577
|
+
const shadow = await ShadowRepo.open(themeRoot.root, trust.sourceThemeId);
|
|
3578
|
+
if (await shadow.hasHead()) initialSync = {
|
|
3579
|
+
shadow,
|
|
3580
|
+
remoteSha: trust.remoteSha
|
|
3581
|
+
};
|
|
3582
|
+
}
|
|
3583
|
+
let rememberRemoteSha;
|
|
3584
|
+
if (!opts.theme && config?.baseSha && devTheme) {
|
|
3585
|
+
const sourceBaseSha = config.baseSha;
|
|
3586
|
+
let rememberedDevTheme = devTheme;
|
|
3587
|
+
rememberRemoteSha = (remoteSha) => {
|
|
3588
|
+
rememberedDevTheme = {
|
|
3589
|
+
...rememberedDevTheme,
|
|
3590
|
+
sourceBaseSha,
|
|
3591
|
+
remoteSha,
|
|
3592
|
+
assetManifestSha: new ThemeAssetManifest(themeRoot.root).fingerprint()
|
|
3593
|
+
};
|
|
3594
|
+
setDevTheme(projectKey, rememberedDevTheme);
|
|
3595
|
+
};
|
|
3596
|
+
}
|
|
3597
|
+
const editorUrl = `https://admin.fluid.app/themes/${theme.id}/editor`;
|
|
3598
|
+
let stop;
|
|
3599
|
+
const cleanup = () => {
|
|
3600
|
+
stop?.();
|
|
3601
|
+
process.exit(0);
|
|
3602
|
+
};
|
|
3603
|
+
process.on("SIGINT", cleanup);
|
|
3604
|
+
process.on("SIGTERM", cleanup);
|
|
3605
|
+
stop = await startDevServer(api, {
|
|
3606
|
+
id: theme.id,
|
|
3607
|
+
name: theme.name,
|
|
3608
|
+
company,
|
|
3609
|
+
editorUrl
|
|
3610
|
+
}, themeRoot, {
|
|
3611
|
+
host: opts.host,
|
|
3612
|
+
port,
|
|
3613
|
+
reloadMode,
|
|
3614
|
+
validate: !opts.force,
|
|
3615
|
+
...initialSync ? { initialSync } : {},
|
|
3616
|
+
...rememberRemoteSha ? { onRemoteSha: rememberRemoteSha } : {}
|
|
3617
|
+
}, (address) => {
|
|
3618
|
+
console.log(`\n Dev server: ${address}`);
|
|
3619
|
+
console.log(` Web editor: ${editorUrl}`);
|
|
3620
|
+
console.log("\n Watching for file changes…\n");
|
|
3621
|
+
if (opts.navigate) import("open").then((m) => m.default(`${address}/home`));
|
|
3622
|
+
});
|
|
3623
|
+
await new Promise(() => {});
|
|
3624
|
+
});
|
|
3532
3625
|
}
|
|
3533
3626
|
//#endregion
|
|
3534
3627
|
//#region src/theme/auto-baseline.ts
|
|
@@ -3917,7 +4010,8 @@ function createPushCommand() {
|
|
|
3917
4010
|
themeId: theme.id,
|
|
3918
4011
|
themeName: theme.name,
|
|
3919
4012
|
company: config.company,
|
|
3920
|
-
baseSha: baseSha ?? void 0
|
|
4013
|
+
baseSha: baseSha ?? void 0,
|
|
4014
|
+
assetManifestSha: new ThemeAssetManifest(themeRoot.root).fingerprint({ excludePending: true })
|
|
3921
4015
|
});
|
|
3922
4016
|
return;
|
|
3923
4017
|
}
|
|
@@ -3929,7 +4023,8 @@ function createPushCommand() {
|
|
|
3929
4023
|
themeId: theme.id,
|
|
3930
4024
|
themeName: theme.name,
|
|
3931
4025
|
company: subdomain,
|
|
3932
|
-
baseSha: baseSha ?? void 0
|
|
4026
|
+
baseSha: baseSha ?? void 0,
|
|
4027
|
+
assetManifestSha: new ThemeAssetManifest(themeRoot.root).fingerprint({ excludePending: true })
|
|
3933
4028
|
});
|
|
3934
4029
|
} catch {}
|
|
3935
4030
|
}
|
|
@@ -4252,7 +4347,8 @@ function createPullCommand() {
|
|
|
4252
4347
|
themeId: theme.id,
|
|
4253
4348
|
themeName: theme.name,
|
|
4254
4349
|
company: subdomain,
|
|
4255
|
-
baseSha: remoteSha ?? existingConfig?.baseSha
|
|
4350
|
+
baseSha: remoteSha ?? existingConfig?.baseSha,
|
|
4351
|
+
assetManifestSha: new ThemeAssetManifest(absoluteRoot).fingerprint({ excludePending: true })
|
|
4256
4352
|
});
|
|
4257
4353
|
if (result.conflicts.length > 0) process.exit(1);
|
|
4258
4354
|
});
|