@fluid-app/fluid-cli-theme-dev 0.1.21 → 0.1.23
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 +14 -0
- package/dist/index.mjs +155 -2
- package/dist/index.mjs.map +1 -1
- package/package.json +8 -4
- package/.turbo/turbo-build.log +0 -16
- package/.turbo/turbo-typecheck.log +0 -4
- package/jest.config.cjs +0 -21
- package/jest.mocks/fluid-cli.ts +0 -33
- package/src/__tests__/plugin-state.test.ts +0 -186
- package/src/api.ts +0 -28
- package/src/commands/dev.ts +0 -186
- package/src/commands/init.ts +0 -51
- package/src/commands/lint.ts +0 -186
- package/src/commands/navigate.ts +0 -259
- package/src/commands/pull.ts +0 -242
- package/src/commands/push.ts +0 -220
- package/src/commands/theme.ts +0 -23
- package/src/index.ts +0 -12
- package/src/plugin-state.ts +0 -171
- package/src/theme/dev-server/hot-reload.ts +0 -65
- package/src/theme/dev-server/index.ts +0 -145
- package/src/theme/dev-server/proxy.ts +0 -125
- package/src/theme/dev-server/sse.ts +0 -43
- package/src/theme/dev-server/watcher.ts +0 -54
- package/src/theme/file.ts +0 -104
- package/src/theme/fluid-ignore.ts +0 -64
- package/src/theme/mime-type.ts +0 -45
- package/src/theme/root.ts +0 -54
- package/src/theme/syncer.ts +0 -338
- package/src/theme-config.ts +0 -34
- package/src/theme-picker.ts +0 -164
- package/src/workspace.ts +0 -71
- package/tsconfig.json +0 -10
- package/tsdown.config.ts +0 -19
- /package/{skills → dist/skills}/themes-review/SKILL.md +0 -0
- /package/{skills → dist/skills}/themes-review/references/blocks-vs-sections.md +0 -0
- /package/{skills → dist/skills}/themes-review/references/css-js-hygiene.md +0 -0
- /package/{skills → dist/skills}/themes-review/references/dead-code.md +0 -0
- /package/{skills → dist/skills}/themes-review/references/dynamism.md +0 -0
- /package/{skills → dist/skills}/themes-review/references/editor-attributes.md +0 -0
- /package/{skills → dist/skills}/themes-review/references/examples.md +0 -0
- /package/{skills → dist/skills}/themes-review/references/fairshare-attributes.md +0 -0
- /package/{skills → dist/skills}/themes-review/references/global-settings.md +0 -0
- /package/{skills → dist/skills}/themes-review/references/liquid-correctness.md +0 -0
- /package/{skills → dist/skills}/themes-review/references/navigation.md +0 -0
- /package/{skills → dist/skills}/themes-review/references/performance.md +0 -0
- /package/{skills → dist/skills}/themes-review/references/security-accessibility.md +0 -0
- /package/{skills → dist/skills}/themes-review/references/setting-types.md +0 -0
package/src/commands/lint.ts
DELETED
|
@@ -1,186 +0,0 @@
|
|
|
1
|
-
import chalk from "chalk";
|
|
2
|
-
import { Command } from "commander";
|
|
3
|
-
import {
|
|
4
|
-
findMissingSectionReferences,
|
|
5
|
-
validateSchemaText,
|
|
6
|
-
VALID_SETTING_TYPES,
|
|
7
|
-
type BlocksSchemaType,
|
|
8
|
-
type Diagnostic,
|
|
9
|
-
type TemplateInput,
|
|
10
|
-
} from "@fluid-app/theme-schema";
|
|
11
|
-
import { ThemeRoot } from "../theme/root.js";
|
|
12
|
-
import { findWorkspace, resolveThemeRootFromCwd } from "../workspace.js";
|
|
13
|
-
|
|
14
|
-
interface FileDiagnostics {
|
|
15
|
-
path: string;
|
|
16
|
-
diagnostics: Diagnostic[];
|
|
17
|
-
}
|
|
18
|
-
|
|
19
|
-
// A theme section is defined by a liquid file under the top-level `sections/`
|
|
20
|
-
// directory. Returns the section name a `{% section %}` tag would reference, or
|
|
21
|
-
// null if the file is not a section definition. Handles both the flat layout
|
|
22
|
-
// (`sections/hero.liquid`) and the nested one (`sections/hero/index.liquid`).
|
|
23
|
-
function sectionNameOf(relativePath: string): string | null {
|
|
24
|
-
const parts = relativePath.split(/[/\\]/);
|
|
25
|
-
if (parts[0] === "sections" && parts.length >= 2) {
|
|
26
|
-
return parts[1]!.replace(/\.liquid$/, "");
|
|
27
|
-
}
|
|
28
|
-
return null;
|
|
29
|
-
}
|
|
30
|
-
|
|
31
|
-
export function createLintCommand(): Command {
|
|
32
|
-
return new Command("lint")
|
|
33
|
-
.description("Validate theme files locally (read-only — no upload)")
|
|
34
|
-
.option("--root <path>", "Theme root directory", ".")
|
|
35
|
-
.option("--json", "Output results as compact JSON")
|
|
36
|
-
.action(async (opts: { root: string; json?: boolean }) => {
|
|
37
|
-
// Resolve the theme root the same way push/dev do: when left at the
|
|
38
|
-
// default, prefer the workspace's theme root if we're inside one.
|
|
39
|
-
let rootPath = opts.root;
|
|
40
|
-
if (rootPath === ".") {
|
|
41
|
-
const workspace = findWorkspace();
|
|
42
|
-
if (workspace) {
|
|
43
|
-
rootPath = resolveThemeRootFromCwd(workspace) ?? rootPath;
|
|
44
|
-
}
|
|
45
|
-
}
|
|
46
|
-
|
|
47
|
-
const themeRoot = new ThemeRoot(rootPath);
|
|
48
|
-
if (!themeRoot.isValid()) {
|
|
49
|
-
const message = `'${rootPath}' does not look like a theme directory.`;
|
|
50
|
-
if (opts.json) {
|
|
51
|
-
console.log(JSON.stringify({ ok: false, error: message }));
|
|
52
|
-
} else {
|
|
53
|
-
console.error(message);
|
|
54
|
-
}
|
|
55
|
-
process.exit(1);
|
|
56
|
-
}
|
|
57
|
-
|
|
58
|
-
const files = themeRoot.files();
|
|
59
|
-
// Read each liquid file once and reuse the content for both passes
|
|
60
|
-
// (validateSchemaText and the section scan) to avoid a double disk read.
|
|
61
|
-
const liquidFiles = files
|
|
62
|
-
.filter((f) => f.isLiquid)
|
|
63
|
-
.map((f) => ({ file: f, content: f.read() }));
|
|
64
|
-
|
|
65
|
-
const byFile = new Map<string, Diagnostic[]>();
|
|
66
|
-
const record = (path: string, diagnostic: Diagnostic): void => {
|
|
67
|
-
const existing = byFile.get(path);
|
|
68
|
-
if (existing) existing.push(diagnostic);
|
|
69
|
-
else byFile.set(path, [diagnostic]);
|
|
70
|
-
};
|
|
71
|
-
|
|
72
|
-
// ── Schema pass — the same {% schema %} validation `fluid theme push`
|
|
73
|
-
// runs. blocksSchemaType mirrors ThemeFile.validateSchema: page/layout
|
|
74
|
-
// templates use object blocks, sections use array blocks.
|
|
75
|
-
for (const { file, content } of liquidFiles) {
|
|
76
|
-
const blocksSchemaType: BlocksSchemaType = file.isTemplate
|
|
77
|
-
? "object"
|
|
78
|
-
: "array";
|
|
79
|
-
for (const diagnostic of validateSchemaText(content, {
|
|
80
|
-
blocksSchemaType,
|
|
81
|
-
})) {
|
|
82
|
-
record(file.relativePath, diagnostic);
|
|
83
|
-
}
|
|
84
|
-
}
|
|
85
|
-
|
|
86
|
-
// ── Section pass — flag `{% section 'x' %}` references to a section
|
|
87
|
-
// that has no definition on disk. Section definitions and assets are
|
|
88
|
-
// not themselves referrers, so they are excluded from the scan.
|
|
89
|
-
const existingSectionNames = new Set<string>();
|
|
90
|
-
for (const { file } of liquidFiles) {
|
|
91
|
-
const name = sectionNameOf(file.relativePath);
|
|
92
|
-
if (name) existingSectionNames.add(name);
|
|
93
|
-
}
|
|
94
|
-
const referrers: TemplateInput[] = liquidFiles
|
|
95
|
-
.filter(({ file }) => sectionNameOf(file.relativePath) === null)
|
|
96
|
-
.map(({ file, content }) => ({ path: file.relativePath, content }));
|
|
97
|
-
for (const missing of findMissingSectionReferences(
|
|
98
|
-
referrers,
|
|
99
|
-
existingSectionNames,
|
|
100
|
-
)) {
|
|
101
|
-
record(missing.templatePath, missing.diagnostic);
|
|
102
|
-
}
|
|
103
|
-
|
|
104
|
-
const results: FileDiagnostics[] = [...byFile.entries()]
|
|
105
|
-
.map(([path, diagnostics]) => ({ path, diagnostics }))
|
|
106
|
-
.sort((a, b) => a.path.localeCompare(b.path));
|
|
107
|
-
|
|
108
|
-
let errors = 0;
|
|
109
|
-
let warnings = 0;
|
|
110
|
-
for (const { diagnostics } of results) {
|
|
111
|
-
for (const d of diagnostics) {
|
|
112
|
-
if (d.severity === "error") errors++;
|
|
113
|
-
else warnings++;
|
|
114
|
-
}
|
|
115
|
-
}
|
|
116
|
-
|
|
117
|
-
// Surface the canonical setting types once (not in every diagnostic) so a
|
|
118
|
-
// consumer fixing an "Invalid settings type" error has the valid set to
|
|
119
|
-
// hand without it bloating each message.
|
|
120
|
-
const hasInvalidSettingType = results.some(({ diagnostics }) =>
|
|
121
|
-
diagnostics.some(
|
|
122
|
-
(d) =>
|
|
123
|
-
d.target?.kind === "setting" &&
|
|
124
|
-
d.target.field === "type" &&
|
|
125
|
-
d.target.settingType !== undefined,
|
|
126
|
-
),
|
|
127
|
-
);
|
|
128
|
-
|
|
129
|
-
if (opts.json) {
|
|
130
|
-
console.log(
|
|
131
|
-
JSON.stringify({
|
|
132
|
-
ok: errors === 0,
|
|
133
|
-
errors,
|
|
134
|
-
warnings,
|
|
135
|
-
filesChecked: liquidFiles.length,
|
|
136
|
-
...(hasInvalidSettingType
|
|
137
|
-
? { validSettingTypes: VALID_SETTING_TYPES }
|
|
138
|
-
: {}),
|
|
139
|
-
files: results,
|
|
140
|
-
}),
|
|
141
|
-
);
|
|
142
|
-
} else {
|
|
143
|
-
printText(results, errors, warnings, liquidFiles.length);
|
|
144
|
-
}
|
|
145
|
-
|
|
146
|
-
process.exit(errors > 0 ? 1 : 0);
|
|
147
|
-
});
|
|
148
|
-
}
|
|
149
|
-
|
|
150
|
-
function plural(count: number, noun: string): string {
|
|
151
|
-
return `${count} ${noun}${count === 1 ? "" : "s"}`;
|
|
152
|
-
}
|
|
153
|
-
|
|
154
|
-
function printText(
|
|
155
|
-
results: FileDiagnostics[],
|
|
156
|
-
errors: number,
|
|
157
|
-
warnings: number,
|
|
158
|
-
filesChecked: number,
|
|
159
|
-
): void {
|
|
160
|
-
for (const { path, diagnostics } of results) {
|
|
161
|
-
console.log(chalk.bold(path));
|
|
162
|
-
for (const d of diagnostics) {
|
|
163
|
-
const label =
|
|
164
|
-
d.severity === "error"
|
|
165
|
-
? chalk.red("error".padEnd(7))
|
|
166
|
-
: chalk.yellow("warning".padEnd(7));
|
|
167
|
-
// Only the first line — a few messages (e.g. the `Invalid JSON:` parse
|
|
168
|
-
// error) carry a multi-line body that `--json` preserves in full.
|
|
169
|
-
const message = d.message.split("\n")[0];
|
|
170
|
-
console.log(` ${label} ${message}`);
|
|
171
|
-
}
|
|
172
|
-
}
|
|
173
|
-
|
|
174
|
-
const suffix = `(${plural(filesChecked, "file")} checked)`;
|
|
175
|
-
if (errors > 0) {
|
|
176
|
-
console.log(
|
|
177
|
-
`\n${chalk.red(`✖ ${plural(errors, "error")}, ${plural(warnings, "warning")}`)} ${suffix}`,
|
|
178
|
-
);
|
|
179
|
-
} else if (warnings > 0) {
|
|
180
|
-
console.log(
|
|
181
|
-
`\n${chalk.yellow(`⚠ ${plural(warnings, "warning")}`)} ${suffix}`,
|
|
182
|
-
);
|
|
183
|
-
} else {
|
|
184
|
-
console.log(`${chalk.green("✓ No problems found")} ${suffix}`);
|
|
185
|
-
}
|
|
186
|
-
}
|
package/src/commands/navigate.ts
DELETED
|
@@ -1,259 +0,0 @@
|
|
|
1
|
-
import { Command } from "commander";
|
|
2
|
-
import prompts from "prompts";
|
|
3
|
-
import { requireToken, createApiClient } from "../api.js";
|
|
4
|
-
import { getLastDevThemeId } from "../plugin-state.js";
|
|
5
|
-
import { themes } from "@fluid-app/themes-api-client";
|
|
6
|
-
|
|
7
|
-
function localSuggest(
|
|
8
|
-
input: string,
|
|
9
|
-
choices: prompts.Choice[],
|
|
10
|
-
): prompts.Choice[] {
|
|
11
|
-
if (!input) return choices;
|
|
12
|
-
const lower = input.toLowerCase();
|
|
13
|
-
return choices.filter((c) => c.title.toLowerCase().includes(lower));
|
|
14
|
-
}
|
|
15
|
-
|
|
16
|
-
interface ThemeTemplate {
|
|
17
|
-
id: number;
|
|
18
|
-
name: string;
|
|
19
|
-
themeable_type: string;
|
|
20
|
-
default: boolean;
|
|
21
|
-
}
|
|
22
|
-
|
|
23
|
-
interface TemplatesResponse {
|
|
24
|
-
templates: ThemeTemplate[];
|
|
25
|
-
}
|
|
26
|
-
|
|
27
|
-
const THEMEABLE_TYPE_MAP: Record<string, string> = {
|
|
28
|
-
"/home": "home_page",
|
|
29
|
-
"/home/shop": "shop_page",
|
|
30
|
-
"/home/join": "join_page",
|
|
31
|
-
"/cart": "cart_page",
|
|
32
|
-
"/home/blog": "post_page",
|
|
33
|
-
"/home/categories": "category_page",
|
|
34
|
-
"/home/collections": "collection_page",
|
|
35
|
-
};
|
|
36
|
-
|
|
37
|
-
const STATIC_ROUTES = [
|
|
38
|
-
{ label: "Home", path: "/home" },
|
|
39
|
-
{ label: "Shop", path: "/home/shop" },
|
|
40
|
-
{ label: "Join / Sign Up", path: "/home/join" },
|
|
41
|
-
{ label: "Cart", path: "/cart" },
|
|
42
|
-
{ label: "Blog", path: "/home/blog" },
|
|
43
|
-
{ label: "Categories (all)", path: "/home/categories" },
|
|
44
|
-
{ label: "Collections (all)", path: "/home/collections" },
|
|
45
|
-
] as const;
|
|
46
|
-
|
|
47
|
-
const RESOURCE_ROUTES = [
|
|
48
|
-
{
|
|
49
|
-
label: "Category",
|
|
50
|
-
type: "category",
|
|
51
|
-
template: "/home/categories/%s",
|
|
52
|
-
fallback: "/home/categories",
|
|
53
|
-
},
|
|
54
|
-
{
|
|
55
|
-
label: "Collection",
|
|
56
|
-
type: "collection",
|
|
57
|
-
template: "/home/collections/%s",
|
|
58
|
-
fallback: "/home/collections",
|
|
59
|
-
},
|
|
60
|
-
{
|
|
61
|
-
label: "Product",
|
|
62
|
-
type: "product",
|
|
63
|
-
template: "/home/products/%s",
|
|
64
|
-
fallback: "/home/shop",
|
|
65
|
-
},
|
|
66
|
-
{
|
|
67
|
-
label: "Library",
|
|
68
|
-
type: "library",
|
|
69
|
-
template: "/home/libraries/%s",
|
|
70
|
-
fallback: "/home/libraries",
|
|
71
|
-
},
|
|
72
|
-
{
|
|
73
|
-
label: "Post",
|
|
74
|
-
type: "post",
|
|
75
|
-
template: "/home/posts/%s",
|
|
76
|
-
fallback: "/home/blog",
|
|
77
|
-
},
|
|
78
|
-
{
|
|
79
|
-
label: "Media",
|
|
80
|
-
type: "medium",
|
|
81
|
-
template: "/home/media/%s",
|
|
82
|
-
fallback: "/home/media",
|
|
83
|
-
},
|
|
84
|
-
{
|
|
85
|
-
label: "Enrollment Pack",
|
|
86
|
-
type: "enrollment_pack",
|
|
87
|
-
template: "/home/enrollments/%s",
|
|
88
|
-
fallback: "/home/join",
|
|
89
|
-
},
|
|
90
|
-
{
|
|
91
|
-
label: "Page",
|
|
92
|
-
type: "page",
|
|
93
|
-
template: "/home/pages/%s",
|
|
94
|
-
fallback: "/home/pages",
|
|
95
|
-
},
|
|
96
|
-
] as const;
|
|
97
|
-
|
|
98
|
-
async function fetchTemplatesForType(
|
|
99
|
-
api: ReturnType<typeof createApiClient>,
|
|
100
|
-
themeId: number,
|
|
101
|
-
themeableType: string,
|
|
102
|
-
): Promise<ThemeTemplate[]> {
|
|
103
|
-
const params = new URLSearchParams({
|
|
104
|
-
application_theme_id: String(themeId),
|
|
105
|
-
themeable_type: themeableType,
|
|
106
|
-
published: "true",
|
|
107
|
-
});
|
|
108
|
-
const body = await api.get<TemplatesResponse>(
|
|
109
|
-
`/api/application_theme_templates?${params}`,
|
|
110
|
-
);
|
|
111
|
-
return body.templates ?? [];
|
|
112
|
-
}
|
|
113
|
-
|
|
114
|
-
async function selectTemplate(
|
|
115
|
-
api: ReturnType<typeof createApiClient>,
|
|
116
|
-
themeId: number,
|
|
117
|
-
themeableType: string,
|
|
118
|
-
onCancel: () => void,
|
|
119
|
-
): Promise<number | null> {
|
|
120
|
-
const templates = await fetchTemplatesForType(api, themeId, themeableType);
|
|
121
|
-
if (templates.length <= 1) return null;
|
|
122
|
-
|
|
123
|
-
const templateChoices = templates.map((t) => ({
|
|
124
|
-
title: `${t.name}${t.default ? " (default)" : ""}`,
|
|
125
|
-
value: t.id,
|
|
126
|
-
}));
|
|
127
|
-
const { templateId } = await prompts(
|
|
128
|
-
{
|
|
129
|
-
type: "autocomplete",
|
|
130
|
-
name: "templateId",
|
|
131
|
-
message: "Select a template",
|
|
132
|
-
choices: templateChoices,
|
|
133
|
-
suggest: (input: string, choices: prompts.Choice[]) =>
|
|
134
|
-
Promise.resolve(localSuggest(input, choices)),
|
|
135
|
-
},
|
|
136
|
-
{ onCancel },
|
|
137
|
-
);
|
|
138
|
-
|
|
139
|
-
return templateId ?? null;
|
|
140
|
-
}
|
|
141
|
-
|
|
142
|
-
export function createNavigateCommand(): Command {
|
|
143
|
-
return new Command("navigate")
|
|
144
|
-
.description("Interactively navigate to a route in the dev server browser")
|
|
145
|
-
.option("--host <host>", "Dev server host", "127.0.0.1")
|
|
146
|
-
.option("--port <port>", "Dev server port", "9292")
|
|
147
|
-
.option("-t, --theme <id>", "Theme ID (defaults to active dev theme)")
|
|
148
|
-
.action(async (opts: { host: string; port: string; theme?: string }) => {
|
|
149
|
-
requireToken();
|
|
150
|
-
|
|
151
|
-
const themeId = opts.theme ? Number(opts.theme) : getLastDevThemeId();
|
|
152
|
-
|
|
153
|
-
if (!themeId) {
|
|
154
|
-
console.error(
|
|
155
|
-
"No active dev theme. Run `fluid theme dev` first, or pass --theme <id>.",
|
|
156
|
-
);
|
|
157
|
-
process.exit(1);
|
|
158
|
-
}
|
|
159
|
-
|
|
160
|
-
const address = `http://${opts.host}:${opts.port}`;
|
|
161
|
-
|
|
162
|
-
type Choice = {
|
|
163
|
-
title: string;
|
|
164
|
-
value:
|
|
165
|
-
| string
|
|
166
|
-
| {
|
|
167
|
-
resourceType: string;
|
|
168
|
-
template: string;
|
|
169
|
-
fallback: string;
|
|
170
|
-
label: string;
|
|
171
|
-
};
|
|
172
|
-
};
|
|
173
|
-
const choices: Choice[] = [
|
|
174
|
-
...STATIC_ROUTES.map((r) => ({ title: r.label, value: r.path })),
|
|
175
|
-
...RESOURCE_ROUTES.map((r) => ({
|
|
176
|
-
title: `${r.label} (select specific)`,
|
|
177
|
-
value: {
|
|
178
|
-
resourceType: r.type,
|
|
179
|
-
template: r.template,
|
|
180
|
-
fallback: r.fallback,
|
|
181
|
-
label: r.label,
|
|
182
|
-
},
|
|
183
|
-
})),
|
|
184
|
-
];
|
|
185
|
-
|
|
186
|
-
const onCancel = () => process.exit(130);
|
|
187
|
-
|
|
188
|
-
const { dest } = await prompts(
|
|
189
|
-
{
|
|
190
|
-
type: "autocomplete",
|
|
191
|
-
name: "dest",
|
|
192
|
-
message: "Select a route",
|
|
193
|
-
choices,
|
|
194
|
-
suggest: (input: string, choices: prompts.Choice[]) =>
|
|
195
|
-
Promise.resolve(localSuggest(input, choices)),
|
|
196
|
-
},
|
|
197
|
-
{ onCancel },
|
|
198
|
-
);
|
|
199
|
-
|
|
200
|
-
if (!dest) return;
|
|
201
|
-
|
|
202
|
-
const api = createApiClient();
|
|
203
|
-
let path: string;
|
|
204
|
-
let themeableType: string | undefined;
|
|
205
|
-
|
|
206
|
-
if (typeof dest === "string") {
|
|
207
|
-
path = dest;
|
|
208
|
-
themeableType = THEMEABLE_TYPE_MAP[dest];
|
|
209
|
-
} else {
|
|
210
|
-
themeableType = dest.resourceType;
|
|
211
|
-
const body = await themes.getApplicationThemeAvailableThemeables(
|
|
212
|
-
api,
|
|
213
|
-
themeId,
|
|
214
|
-
{ themeable: dest.resourceType, per_page: 50 },
|
|
215
|
-
);
|
|
216
|
-
const resources = body.available_themeables ?? [];
|
|
217
|
-
|
|
218
|
-
if (!resources.length) {
|
|
219
|
-
console.log(`No ${dest.label} resources found, using listing page.`);
|
|
220
|
-
path = dest.fallback;
|
|
221
|
-
} else {
|
|
222
|
-
const resourceChoices = resources.map((r) => ({
|
|
223
|
-
title: r.title ?? r.slug ?? "Untitled",
|
|
224
|
-
value: r.slug,
|
|
225
|
-
}));
|
|
226
|
-
const { slug } = await prompts(
|
|
227
|
-
{
|
|
228
|
-
type: "autocomplete",
|
|
229
|
-
name: "slug",
|
|
230
|
-
message: `Select a ${dest.label.toLowerCase()}`,
|
|
231
|
-
choices: resourceChoices,
|
|
232
|
-
suggest: (input: string, choices: prompts.Choice[]) =>
|
|
233
|
-
Promise.resolve(localSuggest(input, choices)),
|
|
234
|
-
},
|
|
235
|
-
{ onCancel },
|
|
236
|
-
);
|
|
237
|
-
path = dest.template.replace("%s", slug as string);
|
|
238
|
-
}
|
|
239
|
-
}
|
|
240
|
-
|
|
241
|
-
let templateParam = "";
|
|
242
|
-
if (themeableType) {
|
|
243
|
-
const templateId = await selectTemplate(
|
|
244
|
-
api,
|
|
245
|
-
themeId,
|
|
246
|
-
themeableType,
|
|
247
|
-
onCancel,
|
|
248
|
-
);
|
|
249
|
-
if (templateId) {
|
|
250
|
-
templateParam = `?theme_template_id=${templateId}`;
|
|
251
|
-
}
|
|
252
|
-
}
|
|
253
|
-
|
|
254
|
-
const url = `${address}${path}${templateParam}`;
|
|
255
|
-
console.log(`\nNavigating to: ${url}\n`);
|
|
256
|
-
const open = (await import("open")).default;
|
|
257
|
-
await open(url);
|
|
258
|
-
});
|
|
259
|
-
}
|
package/src/commands/pull.ts
DELETED
|
@@ -1,242 +0,0 @@
|
|
|
1
|
-
import { join, resolve } from "node:path";
|
|
2
|
-
import chalk from "chalk";
|
|
3
|
-
import { Command } from "commander";
|
|
4
|
-
import ora from "ora";
|
|
5
|
-
import prompts from "prompts";
|
|
6
|
-
import { requireToken, createApiClient } from "../api.js";
|
|
7
|
-
import { readThemeConfig, writeThemeConfig } from "../theme-config.js";
|
|
8
|
-
import { ThemeRoot } from "../theme/root.js";
|
|
9
|
-
import { Syncer } from "../theme/syncer.js";
|
|
10
|
-
import { selectTheme, findTheme } from "../theme-picker.js";
|
|
11
|
-
import { findWorkspace, resolveThemeRootFromCwd } from "../workspace.js";
|
|
12
|
-
|
|
13
|
-
interface CompanyMe {
|
|
14
|
-
data: { company: { subdomain?: string; name?: string } };
|
|
15
|
-
}
|
|
16
|
-
|
|
17
|
-
async function fetchCompanySubdomain(
|
|
18
|
-
api: ReturnType<typeof createApiClient>,
|
|
19
|
-
): Promise<string> {
|
|
20
|
-
const res = await api.get<CompanyMe>("/api/company/v1/companies/me");
|
|
21
|
-
const subdomain = res.data?.company?.subdomain;
|
|
22
|
-
if (!subdomain) {
|
|
23
|
-
console.error(
|
|
24
|
-
"Could not determine company subdomain. Make sure your token is valid.",
|
|
25
|
-
);
|
|
26
|
-
process.exit(1);
|
|
27
|
-
}
|
|
28
|
-
return subdomain;
|
|
29
|
-
}
|
|
30
|
-
|
|
31
|
-
function formatRelativeTime(iso: string): string {
|
|
32
|
-
const diff = Date.now() - new Date(iso).getTime();
|
|
33
|
-
const minutes = Math.floor(diff / 60_000);
|
|
34
|
-
if (minutes < 1) return "just now";
|
|
35
|
-
if (minutes < 60) return `${minutes}m ago`;
|
|
36
|
-
const hours = Math.floor(minutes / 60);
|
|
37
|
-
if (hours < 24) return `${hours}h ago`;
|
|
38
|
-
const days = Math.floor(hours / 24);
|
|
39
|
-
if (days === 1) return "yesterday";
|
|
40
|
-
const date = new Date(iso);
|
|
41
|
-
return `${days}d ago (${date.toLocaleDateString("en-US", { month: "short", day: "numeric", year: "numeric" })})`;
|
|
42
|
-
}
|
|
43
|
-
|
|
44
|
-
/**
|
|
45
|
-
* Detect files where both local and remote have changed since the last pull.
|
|
46
|
-
* Returns the set of conflicting resource keys.
|
|
47
|
-
*/
|
|
48
|
-
function detectConflicts(
|
|
49
|
-
storedChecksums: Record<string, string>,
|
|
50
|
-
remoteChecksums: Record<string, string>,
|
|
51
|
-
themeRoot: ThemeRoot,
|
|
52
|
-
): string[] {
|
|
53
|
-
const conflicts: string[] = [];
|
|
54
|
-
for (const [key, storedChecksum] of Object.entries(storedChecksums)) {
|
|
55
|
-
const remoteChecksum = remoteChecksums[key];
|
|
56
|
-
if (remoteChecksum === undefined) continue; // deleted on remote, not a conflict
|
|
57
|
-
if (remoteChecksum === storedChecksum) continue; // remote unchanged
|
|
58
|
-
|
|
59
|
-
// Remote changed — check if local also changed
|
|
60
|
-
const file = themeRoot.file(key);
|
|
61
|
-
if (!file.exists) continue; // local deleted, not a conflict (remote wins)
|
|
62
|
-
const localChecksum = file.checksum();
|
|
63
|
-
if (localChecksum === storedChecksum) continue; // local unchanged, safe to overwrite
|
|
64
|
-
if (localChecksum === remoteChecksum) continue; // both sides made same change
|
|
65
|
-
|
|
66
|
-
conflicts.push(key);
|
|
67
|
-
}
|
|
68
|
-
return conflicts;
|
|
69
|
-
}
|
|
70
|
-
|
|
71
|
-
export function createPullCommand(): Command {
|
|
72
|
-
return new Command("pull")
|
|
73
|
-
.description("Pull a remote theme to your local directory")
|
|
74
|
-
.option("-t, --theme <name-or-id>", "Theme name or ID to pull")
|
|
75
|
-
.option("-n, --nodelete", "Do not delete local files missing on remote")
|
|
76
|
-
.option("--root <path>", "Theme root directory")
|
|
77
|
-
.option("-y, --yes", "Skip confirmation prompt")
|
|
78
|
-
.action(
|
|
79
|
-
async (opts: {
|
|
80
|
-
theme?: string;
|
|
81
|
-
nodelete?: boolean;
|
|
82
|
-
root?: string;
|
|
83
|
-
yes?: boolean;
|
|
84
|
-
}) => {
|
|
85
|
-
requireToken();
|
|
86
|
-
|
|
87
|
-
const api = createApiClient();
|
|
88
|
-
const workspace = findWorkspace();
|
|
89
|
-
|
|
90
|
-
const theme = opts.theme
|
|
91
|
-
? await findTheme(api, opts.theme)
|
|
92
|
-
: await selectTheme(api, "Select a theme to pull");
|
|
93
|
-
|
|
94
|
-
// Resolve output directory
|
|
95
|
-
const subdomain = await fetchCompanySubdomain(api);
|
|
96
|
-
let root: string;
|
|
97
|
-
if (opts.root) {
|
|
98
|
-
root = opts.root;
|
|
99
|
-
} else if (workspace) {
|
|
100
|
-
// If already inside local/{company}/, use that directory
|
|
101
|
-
root =
|
|
102
|
-
resolveThemeRootFromCwd(workspace) ??
|
|
103
|
-
join(workspace.root, "local", subdomain);
|
|
104
|
-
} else {
|
|
105
|
-
root = `.`;
|
|
106
|
-
}
|
|
107
|
-
|
|
108
|
-
const absoluteRoot = resolve(root);
|
|
109
|
-
const existingConfig = readThemeConfig(absoluteRoot);
|
|
110
|
-
|
|
111
|
-
// Pre-flight summary
|
|
112
|
-
console.log();
|
|
113
|
-
console.log(` Theme: ${chalk.bold(theme.name)} (#${theme.id})`);
|
|
114
|
-
console.log(` Company: ${chalk.bold(subdomain)}`);
|
|
115
|
-
console.log(` Target: ${chalk.bold(absoluteRoot)}`);
|
|
116
|
-
if (existingConfig?.lastPulledAt) {
|
|
117
|
-
console.log(
|
|
118
|
-
` Last pulled: ${formatRelativeTime(existingConfig.lastPulledAt)}`,
|
|
119
|
-
);
|
|
120
|
-
}
|
|
121
|
-
console.log();
|
|
122
|
-
|
|
123
|
-
// Conflict detection — only possible if we have a previous pull's checksums
|
|
124
|
-
const themeRoot = new ThemeRoot(root);
|
|
125
|
-
let skipKeys: Set<string> | undefined;
|
|
126
|
-
|
|
127
|
-
if (existingConfig?.checksums) {
|
|
128
|
-
const fetchSpinner = ora("Checking for conflicts…").start();
|
|
129
|
-
const syncer = new Syncer(api, theme.id, themeRoot);
|
|
130
|
-
await syncer.fetchChecksums();
|
|
131
|
-
const remoteChecksums = syncer.remoteChecksums();
|
|
132
|
-
const conflicts = detectConflicts(
|
|
133
|
-
existingConfig.checksums,
|
|
134
|
-
remoteChecksums,
|
|
135
|
-
themeRoot,
|
|
136
|
-
);
|
|
137
|
-
fetchSpinner.stop();
|
|
138
|
-
|
|
139
|
-
if (conflicts.length > 0) {
|
|
140
|
-
console.log(
|
|
141
|
-
chalk.yellow(`⚠ ${conflicts.length} conflict(s) detected:\n`),
|
|
142
|
-
);
|
|
143
|
-
for (const key of conflicts) {
|
|
144
|
-
console.log(` ${key}`);
|
|
145
|
-
}
|
|
146
|
-
console.log();
|
|
147
|
-
|
|
148
|
-
const { resolution } = await prompts(
|
|
149
|
-
{
|
|
150
|
-
type: "select",
|
|
151
|
-
name: "resolution",
|
|
152
|
-
message: "How do you want to handle conflicts?",
|
|
153
|
-
choices: [
|
|
154
|
-
{
|
|
155
|
-
title: "Keep local (skip conflicting files)",
|
|
156
|
-
value: "keep-local",
|
|
157
|
-
},
|
|
158
|
-
{
|
|
159
|
-
title: "Use remote (overwrite local changes)",
|
|
160
|
-
value: "use-remote",
|
|
161
|
-
},
|
|
162
|
-
{ title: "Abort", value: "abort" },
|
|
163
|
-
],
|
|
164
|
-
},
|
|
165
|
-
{ onCancel: () => process.exit(130) },
|
|
166
|
-
);
|
|
167
|
-
|
|
168
|
-
if (resolution === "abort") {
|
|
169
|
-
console.log("Aborted.");
|
|
170
|
-
process.exit(0);
|
|
171
|
-
}
|
|
172
|
-
|
|
173
|
-
if (resolution === "keep-local") {
|
|
174
|
-
skipKeys = new Set(conflicts);
|
|
175
|
-
}
|
|
176
|
-
// "use-remote" → skipKeys stays undefined, everything gets overwritten
|
|
177
|
-
}
|
|
178
|
-
}
|
|
179
|
-
|
|
180
|
-
if (!opts.yes && !skipKeys) {
|
|
181
|
-
const { confirmed } = await prompts(
|
|
182
|
-
{
|
|
183
|
-
type: "confirm",
|
|
184
|
-
name: "confirmed",
|
|
185
|
-
message: "Pull theme to this directory?",
|
|
186
|
-
initial: true,
|
|
187
|
-
},
|
|
188
|
-
{ onCancel: () => process.exit(130) },
|
|
189
|
-
);
|
|
190
|
-
if (!confirmed) {
|
|
191
|
-
console.log("Aborted.");
|
|
192
|
-
process.exit(0);
|
|
193
|
-
}
|
|
194
|
-
}
|
|
195
|
-
|
|
196
|
-
const syncer = new Syncer(api, theme.id, themeRoot);
|
|
197
|
-
const spinner = ora(`Pulling ${theme.name} (#${theme.id})…`).start();
|
|
198
|
-
|
|
199
|
-
const result = await syncer.downloadTheme({
|
|
200
|
-
delete: !opts.nodelete,
|
|
201
|
-
skip: skipKeys,
|
|
202
|
-
onProgress: (d, total) => {
|
|
203
|
-
spinner.text = `Downloading ${d}/${total} files…`;
|
|
204
|
-
},
|
|
205
|
-
});
|
|
206
|
-
|
|
207
|
-
// Write .fluid-theme.json with the post-pull state.
|
|
208
|
-
// For skipped files, preserve the old stored checksum so the conflict
|
|
209
|
-
// is still detected on the next pull (instead of silently overwriting).
|
|
210
|
-
const newChecksums = syncer.remoteChecksums();
|
|
211
|
-
if (skipKeys && existingConfig?.checksums) {
|
|
212
|
-
for (const key of skipKeys) {
|
|
213
|
-
const oldChecksum = existingConfig.checksums[key];
|
|
214
|
-
if (oldChecksum) {
|
|
215
|
-
newChecksums[key] = oldChecksum;
|
|
216
|
-
}
|
|
217
|
-
}
|
|
218
|
-
}
|
|
219
|
-
|
|
220
|
-
writeThemeConfig(absoluteRoot, {
|
|
221
|
-
themeId: theme.id,
|
|
222
|
-
themeName: theme.name,
|
|
223
|
-
company: subdomain,
|
|
224
|
-
lastPulledAt: new Date().toISOString(),
|
|
225
|
-
checksums: newChecksums,
|
|
226
|
-
});
|
|
227
|
-
|
|
228
|
-
const parts: string[] = [`Downloaded ${result.downloaded} file(s)`];
|
|
229
|
-
if (result.deleted > 0)
|
|
230
|
-
parts.push(`deleted ${result.deleted} local file(s)`);
|
|
231
|
-
if (result.skipped > 0)
|
|
232
|
-
parts.push(`skipped ${result.skipped} conflict(s)`);
|
|
233
|
-
|
|
234
|
-
if (result.errors.length) {
|
|
235
|
-
spinner.warn(`Pulled with ${result.errors.length} error(s).`);
|
|
236
|
-
for (const e of result.errors) console.error(` ${e}`);
|
|
237
|
-
} else {
|
|
238
|
-
spinner.succeed(`${parts.join(", ")}.`);
|
|
239
|
-
}
|
|
240
|
-
},
|
|
241
|
-
);
|
|
242
|
-
}
|