@fluid-app/fluid-cli-theme-dev 0.1.14 → 0.1.16

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.
@@ -0,0 +1,21 @@
1
+ module.exports = {
2
+ testEnvironment: "node",
3
+ testMatch: ["**/src/**/__tests__/**/*.test.ts"],
4
+ transform: {
5
+ "^.+\\.(t|j)sx?$": [
6
+ "@swc/jest",
7
+ {
8
+ module: {
9
+ type: "commonjs",
10
+ },
11
+ },
12
+ ],
13
+ },
14
+ moduleFileExtensions: ["ts", "tsx", "js", "jsx", "json", "node"],
15
+ moduleNameMapper: {
16
+ // The real package only ships ESM, which Jest can't resolve in CJS mode;
17
+ // map it to a stateful in-memory mock for plugin-state tests.
18
+ "^@fluid-app/fluid-cli$": "<rootDir>/jest.mocks/fluid-cli.ts",
19
+ "^(\\.{1,2}/.*)\\.js$": "$1",
20
+ },
21
+ };
@@ -0,0 +1,33 @@
1
+ /**
2
+ * Jest mock for @fluid-app/fluid-cli.
3
+ *
4
+ * The real package only exports ESM (.mjs), which Jest can't resolve in CJS
5
+ * mode. plugin-state.ts only uses `readConfig`/`updateConfig`, so this mock
6
+ * backs them with an in-memory config. Tests seed/reset state through the
7
+ * normal `updateConfig` surface, so no test-only exports are needed.
8
+ */
9
+
10
+ interface FakeConfig {
11
+ activeProfile: string | null;
12
+ profiles: Record<string, unknown>;
13
+ plugins: Record<string, unknown>;
14
+ enabledPlugins: string[] | null;
15
+ }
16
+
17
+ let config: FakeConfig = {
18
+ activeProfile: null,
19
+ profiles: {},
20
+ plugins: {},
21
+ enabledPlugins: null,
22
+ };
23
+
24
+ export function readConfig(): FakeConfig {
25
+ return config;
26
+ }
27
+
28
+ export function updateConfig(
29
+ updater: (config: FakeConfig) => FakeConfig,
30
+ ): FakeConfig {
31
+ config = updater(config);
32
+ return config;
33
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fluid-app/fluid-cli-theme-dev",
3
- "version": "0.1.14",
3
+ "version": "0.1.16",
4
4
  "description": "Fluid CLI plugin for theme developer workflows — dev server, push, pull, init",
5
5
  "type": "module",
6
6
  "main": "./dist/index.mjs",
@@ -26,14 +26,18 @@
26
26
  "@fluid-app/fluid-cli": "0.1.8"
27
27
  },
28
28
  "devDependencies": {
29
+ "@swc/core": "^1.15.18",
30
+ "@swc/jest": "^0.2.39",
31
+ "@types/jest": "^29.5.14",
29
32
  "@types/node": "^24",
30
33
  "@types/prompts": "^2.4.9",
34
+ "jest": "^29.7.0",
31
35
  "tsdown": "^0.21.0",
32
36
  "typescript": "^5",
33
- "@fluid-app/theme-schema": "0.1.0",
34
- "@fluid-app/themes-api-client": "0.1.0",
35
37
  "@fluid-app/api-client-core": "0.1.0",
36
- "@fluid-app/typescript-config": "0.0.0"
38
+ "@fluid-app/theme-schema": "0.1.0",
39
+ "@fluid-app/typescript-config": "0.0.0",
40
+ "@fluid-app/themes-api-client": "0.1.0"
37
41
  },
38
42
  "engines": {
39
43
  "node": ">=18.0.0"
@@ -41,6 +45,7 @@
41
45
  "scripts": {
42
46
  "build": "tsdown",
43
47
  "dev": "tsdown --watch",
44
- "typecheck": "tsc --noEmit"
48
+ "typecheck": "tsc --noEmit",
49
+ "test": "jest"
45
50
  }
46
51
  }
@@ -0,0 +1,186 @@
1
+ import { existsSync } from "node:fs";
2
+ import {
3
+ readConfig,
4
+ updateConfig,
5
+ type FluidConfig,
6
+ } from "@fluid-app/fluid-cli";
7
+ import {
8
+ devThemeKey,
9
+ getDevTheme,
10
+ setDevTheme,
11
+ clearDevTheme,
12
+ setLastDevThemeId,
13
+ getLastDevThemeId,
14
+ } from "../plugin-state.js";
15
+
16
+ // `@fluid-app/fluid-cli` is mapped to an in-memory mock (jest.mocks/fluid-cli.ts)
17
+ // so these helpers never touch the real ~/.fluid/config.json. State is seeded
18
+ // and reset through the normal `updateConfig` surface.
19
+ jest.mock("node:fs", () => ({ existsSync: jest.fn() }));
20
+ const existsSyncMock = existsSync as unknown as jest.Mock;
21
+
22
+ const PLUGIN_KEY = "theme-dev";
23
+
24
+ function freshConfig(): FluidConfig {
25
+ return {
26
+ activeProfile: null,
27
+ profiles: {},
28
+ plugins: {},
29
+ enabledPlugins: null,
30
+ };
31
+ }
32
+
33
+ function setPluginState(state: Record<string, unknown>): void {
34
+ updateConfig((config) => ({
35
+ ...config,
36
+ plugins: { ...config.plugins, [PLUGIN_KEY]: state },
37
+ }));
38
+ }
39
+
40
+ function pluginState(): Record<string, unknown> {
41
+ return readConfig().plugins[PLUGIN_KEY] as Record<string, unknown>;
42
+ }
43
+
44
+ beforeEach(() => {
45
+ updateConfig(() => freshConfig());
46
+ // Default: every stored project directory still exists (no pruning).
47
+ existsSyncMock.mockReset();
48
+ existsSyncMock.mockReturnValue(true);
49
+ });
50
+
51
+ describe("devThemeKey", () => {
52
+ it("combines company and theme root", () => {
53
+ expect(devThemeKey("acme", "/themes/store")).toBe("acme:/themes/store");
54
+ });
55
+
56
+ it("falls back to 'default' when company is undefined", () => {
57
+ expect(devThemeKey(undefined, "/themes/store")).toBe(
58
+ "default:/themes/store",
59
+ );
60
+ });
61
+ });
62
+
63
+ describe("setDevTheme / getDevTheme", () => {
64
+ it("stores and retrieves a dev theme per key", () => {
65
+ setDevTheme("acme:/a", { id: 1, name: "A" });
66
+ expect(getDevTheme("acme:/a")).toEqual({ id: 1, name: "A" });
67
+ });
68
+
69
+ it("isolates dev themes across keys", () => {
70
+ setDevTheme("acme:/a", { id: 1, name: "A" });
71
+ setDevTheme("acme:/b", { id: 2, name: "B" });
72
+ expect(getDevTheme("acme:/a")).toEqual({ id: 1, name: "A" });
73
+ expect(getDevTheme("acme:/b")).toEqual({ id: 2, name: "B" });
74
+ });
75
+
76
+ it("returns undefined for an unknown key", () => {
77
+ expect(getDevTheme("acme:/missing")).toBeUndefined();
78
+ });
79
+
80
+ it("marks the most recently stored theme as lastDevThemeId", () => {
81
+ setDevTheme("acme:/a", { id: 1, name: "A" });
82
+ setDevTheme("acme:/b", { id: 2, name: "B" });
83
+ expect(getLastDevThemeId()).toBe(2);
84
+ });
85
+ });
86
+
87
+ describe("clearDevTheme", () => {
88
+ it("removes only the targeted key", () => {
89
+ setDevTheme("acme:/a", { id: 1, name: "A" });
90
+ setDevTheme("acme:/b", { id: 2, name: "B" });
91
+ clearDevTheme("acme:/a");
92
+ expect(getDevTheme("acme:/a")).toBeUndefined();
93
+ expect(getDevTheme("acme:/b")).toEqual({ id: 2, name: "B" });
94
+ });
95
+
96
+ it("is a no-op for an unknown key", () => {
97
+ setDevTheme("acme:/a", { id: 1, name: "A" });
98
+ clearDevTheme("acme:/missing");
99
+ expect(getDevTheme("acme:/a")).toEqual({ id: 1, name: "A" });
100
+ });
101
+
102
+ it("clears lastDevThemeId when it pointed at the removed theme", () => {
103
+ setDevTheme("acme:/a", { id: 1, name: "A" });
104
+ expect(getLastDevThemeId()).toBe(1);
105
+ clearDevTheme("acme:/a");
106
+ expect(getLastDevThemeId()).toBeUndefined();
107
+ });
108
+
109
+ it("leaves lastDevThemeId when it points at a different theme", () => {
110
+ setDevTheme("acme:/a", { id: 1, name: "A" });
111
+ setDevTheme("acme:/b", { id: 2, name: "B" });
112
+ clearDevTheme("acme:/a"); // most-recent is /b (id 2)
113
+ expect(getLastDevThemeId()).toBe(2);
114
+ });
115
+ });
116
+
117
+ describe("devThemes map stays bounded", () => {
118
+ it("prunes entries whose theme directory no longer exists on the next write", () => {
119
+ setDevTheme("acme:/a", { id: 1, name: "A" });
120
+ setDevTheme("acme:/b", { id: 2, name: "B" });
121
+
122
+ // /a is deleted; /b and the new /c still exist.
123
+ existsSyncMock.mockImplementation((p: string) => p !== "/a");
124
+ setDevTheme("acme:/c", { id: 3, name: "C" });
125
+
126
+ expect(getDevTheme("acme:/a")).toBeUndefined(); // pruned
127
+ expect(getDevTheme("acme:/b")).toEqual({ id: 2, name: "B" });
128
+ expect(getDevTheme("acme:/c")).toEqual({ id: 3, name: "C" });
129
+ expect(
130
+ Object.keys(pluginState()["devThemes"] as Record<string, unknown>),
131
+ ).toEqual(["acme:/b", "acme:/c"]);
132
+ });
133
+
134
+ it("keeps the entry being written even if its own directory check fails", () => {
135
+ existsSyncMock.mockReturnValue(false);
136
+ setDevTheme("acme:/a", { id: 1, name: "A" });
137
+ expect(getDevTheme("acme:/a")).toEqual({ id: 1, name: "A" });
138
+ });
139
+ });
140
+
141
+ describe("setLastDevThemeId", () => {
142
+ it("updates the navigate pointer without recording a dev theme", () => {
143
+ setLastDevThemeId(123);
144
+ expect(getLastDevThemeId()).toBe(123);
145
+ expect(getDevTheme("acme:/a")).toBeUndefined();
146
+ });
147
+ });
148
+
149
+ describe("legacy migration", () => {
150
+ it("adopts a legacy global devThemeId for the first requesting key, then clears it", () => {
151
+ setPluginState({ devThemeId: 99, devThemeName: "Legacy" });
152
+
153
+ expect(getDevTheme("acme:/a")).toEqual({ id: 99, name: "Legacy" });
154
+
155
+ // Legacy fields are cleared so a second project can't adopt the same theme.
156
+ const state = pluginState();
157
+ expect(state["devThemeId"]).toBeUndefined();
158
+ expect(state["devThemeName"]).toBeUndefined();
159
+ expect(getDevTheme("acme:/b")).toBeUndefined();
160
+ expect(getLastDevThemeId()).toBe(99);
161
+ });
162
+
163
+ it("synthesizes a name when the legacy name is absent", () => {
164
+ setPluginState({ devThemeId: 42 });
165
+ expect(getDevTheme("acme:/a")).toEqual({ id: 42, name: "Development #42" });
166
+ });
167
+
168
+ it("prefers an explicit per-key entry over the legacy id", () => {
169
+ setPluginState({
170
+ devThemeId: 99,
171
+ devThemes: { "acme:/a": { id: 7, name: "Real" } },
172
+ });
173
+ expect(getDevTheme("acme:/a")).toEqual({ id: 7, name: "Real" });
174
+ });
175
+ });
176
+
177
+ describe("getLastDevThemeId", () => {
178
+ it("falls back to the legacy id before any per-project run", () => {
179
+ setPluginState({ devThemeId: 5 });
180
+ expect(getLastDevThemeId()).toBe(5);
181
+ });
182
+
183
+ it("returns undefined when nothing is stored", () => {
184
+ expect(getLastDevThemeId()).toBeUndefined();
185
+ });
186
+ });
@@ -1,7 +1,13 @@
1
1
  import { Command } from "commander";
2
2
  import { requireToken, createApiClient } from "../api.js";
3
3
  import { readThemeConfig } from "../theme-config.js";
4
- import { getPluginState, setPluginState } from "../plugin-state.js";
4
+ import {
5
+ devThemeKey,
6
+ getDevTheme,
7
+ setDevTheme,
8
+ setLastDevThemeId,
9
+ clearDevTheme,
10
+ } from "../plugin-state.js";
5
11
  import { ThemeRoot } from "../theme/root.js";
6
12
  import { startDevServer } from "../theme/dev-server/index.js";
7
13
  import { themes } from "@fluid-app/themes-api-client";
@@ -14,24 +20,34 @@ interface CompanyMe {
14
20
 
15
21
  async function ensureDevTheme(
16
22
  api: ReturnType<typeof createApiClient>,
23
+ projectKey: string,
17
24
  identifier?: string,
18
25
  ): Promise<ApplicationTheme> {
19
26
  if (identifier) {
20
- return findTheme(api, identifier);
27
+ const theme = await findTheme(api, identifier);
28
+ // Keep `navigate` pointed at whatever the dev server is actually serving.
29
+ setLastDevThemeId(theme.id);
30
+ return theme;
21
31
  }
22
32
 
23
- // Reuse stored dev theme if it still exists
24
- const { devThemeId } = getPluginState();
25
- if (devThemeId) {
33
+ // Reuse this project's stored dev theme if it still exists and is still a
34
+ // development theme (a published/promoted theme must not be edited in place).
35
+ const stored = getDevTheme(projectKey);
36
+ if (stored) {
26
37
  try {
27
- const body = await themes.getApplicationTheme(api, devThemeId);
28
- if (body.application_theme) {
29
- console.log(`Using existing dev theme #${devThemeId}`);
30
- return body.application_theme;
38
+ const body = await themes.getApplicationTheme(api, stored.id);
39
+ const existing = body.application_theme;
40
+ if (existing && existing.status === "development") {
41
+ console.log(`Using existing dev theme #${existing.id}`);
42
+ // Refresh the stored name and mark it most-recent for `navigate`.
43
+ setDevTheme(projectKey, { id: existing.id, name: existing.name });
44
+ return existing;
31
45
  }
32
46
  } catch {
33
- // Theme no longer exists — create a new one
47
+ // Theme no longer exists — fall through to create a new one.
34
48
  }
49
+ // Stored theme is gone or no longer a dev theme; forget it.
50
+ clearDevTheme(projectKey);
35
51
  }
36
52
 
37
53
  // Create a new development theme
@@ -47,7 +63,7 @@ async function ensureDevTheme(
47
63
  application_theme: { name, status: "development" },
48
64
  });
49
65
  const theme = body.application_theme;
50
- setPluginState({ devThemeId: theme.id, devThemeName: theme.name });
66
+ setDevTheme(projectKey, { id: theme.id, name: theme.name });
51
67
  console.log(`Created dev theme: ${theme.name} (#${theme.id})`);
52
68
  return theme;
53
69
  }
@@ -121,12 +137,16 @@ export function createDevCommand(): Command {
121
137
  }
122
138
  }
123
139
 
124
- // Use theme from .fluid-theme.json if available and no --theme flag
140
+ // Always iterate on an isolated dev theme: reuse the stored one or
141
+ // create a fresh `development` theme. `--theme` is the explicit
142
+ // escape hatch for targeting an existing theme. We deliberately do NOT
143
+ // fall back to `.fluid-theme.json`'s themeId — that points at whatever
144
+ // was pulled (often the active/production theme), and the dev server's
145
+ // `delete: true` sync would overwrite it in place.
146
+ const projectKey = devThemeKey(company, themeRoot.root);
125
147
  const theme = opts.theme
126
- ? await ensureDevTheme(api, opts.theme)
127
- : config
128
- ? await ensureDevTheme(api, String(config.themeId))
129
- : await ensureDevTheme(api);
148
+ ? await ensureDevTheme(api, projectKey, opts.theme)
149
+ : await ensureDevTheme(api, projectKey);
130
150
  const editorUrl = `https://admin.fluid.app/themes/${theme.id}/editor`;
131
151
 
132
152
  let stop: (() => void) | undefined;
@@ -0,0 +1,175 @@
1
+ import chalk from "chalk";
2
+ import { Command } from "commander";
3
+ import {
4
+ findMissingSectionReferences,
5
+ validateSchemaText,
6
+ type BlocksSchemaType,
7
+ type Diagnostic,
8
+ type TemplateInput,
9
+ } from "@fluid-app/theme-schema";
10
+ import { ThemeRoot } from "../theme/root.js";
11
+ import { findWorkspace, resolveThemeRootFromCwd } from "../workspace.js";
12
+
13
+ interface FileDiagnostics {
14
+ path: string;
15
+ diagnostics: Diagnostic[];
16
+ }
17
+
18
+ // A theme section is defined by a liquid file under `templates/sections/`.
19
+ // Returns the section name a `{% section %}` tag would reference, or null if
20
+ // the file is not a section definition. Handles both the flat layout
21
+ // (`templates/sections/hero.liquid`) and the nested one
22
+ // (`templates/sections/hero/index.liquid`).
23
+ function sectionNameOf(relativePath: string): string | null {
24
+ const parts = relativePath.split(/[/\\]/);
25
+ if (
26
+ parts[0] === "templates" &&
27
+ parts[1] === "sections" &&
28
+ parts.length >= 3
29
+ ) {
30
+ return parts[2]!.replace(/\.liquid$/, "");
31
+ }
32
+ return null;
33
+ }
34
+
35
+ export function createLintCommand(): Command {
36
+ return new Command("lint")
37
+ .description("Validate theme files locally (read-only — no upload)")
38
+ .option("--root <path>", "Theme root directory", ".")
39
+ .option("--json", "Output results as compact JSON")
40
+ .action(async (opts: { root: string; json?: boolean }) => {
41
+ // Resolve the theme root the same way push/dev do: when left at the
42
+ // default, prefer the workspace's theme root if we're inside one.
43
+ let rootPath = opts.root;
44
+ if (rootPath === ".") {
45
+ const workspace = findWorkspace();
46
+ if (workspace) {
47
+ rootPath = resolveThemeRootFromCwd(workspace) ?? rootPath;
48
+ }
49
+ }
50
+
51
+ const themeRoot = new ThemeRoot(rootPath);
52
+ if (!themeRoot.isValid()) {
53
+ const message = `'${rootPath}' does not look like a theme directory.`;
54
+ if (opts.json) {
55
+ console.log(JSON.stringify({ ok: false, error: message }));
56
+ } else {
57
+ console.error(message);
58
+ }
59
+ process.exit(1);
60
+ }
61
+
62
+ const files = themeRoot.files();
63
+ // Read each liquid file once and reuse the content for both passes
64
+ // (validateSchemaText and the section scan) to avoid a double disk read.
65
+ const liquidFiles = files
66
+ .filter((f) => f.isLiquid)
67
+ .map((f) => ({ file: f, content: f.read() }));
68
+
69
+ const byFile = new Map<string, Diagnostic[]>();
70
+ const record = (path: string, diagnostic: Diagnostic): void => {
71
+ const existing = byFile.get(path);
72
+ if (existing) existing.push(diagnostic);
73
+ else byFile.set(path, [diagnostic]);
74
+ };
75
+
76
+ // ── Schema pass — the same {% schema %} validation `fluid theme push`
77
+ // runs. blocksSchemaType mirrors ThemeFile.validateSchema: page/layout
78
+ // templates use object blocks, sections use array blocks.
79
+ for (const { file, content } of liquidFiles) {
80
+ const blocksSchemaType: BlocksSchemaType = file.isTemplate
81
+ ? "object"
82
+ : "array";
83
+ for (const diagnostic of validateSchemaText(content, {
84
+ blocksSchemaType,
85
+ })) {
86
+ record(file.relativePath, diagnostic);
87
+ }
88
+ }
89
+
90
+ // ── Section pass — flag `{% section 'x' %}` references to a section
91
+ // that has no definition on disk. Section definitions and assets are
92
+ // not themselves referrers, so they are excluded from the scan.
93
+ const existingSectionNames = new Set<string>();
94
+ for (const { file } of liquidFiles) {
95
+ const name = sectionNameOf(file.relativePath);
96
+ if (name) existingSectionNames.add(name);
97
+ }
98
+ const referrers: TemplateInput[] = liquidFiles
99
+ .filter(({ file }) => sectionNameOf(file.relativePath) === null)
100
+ .map(({ file, content }) => ({ path: file.relativePath, content }));
101
+ for (const missing of findMissingSectionReferences(
102
+ referrers,
103
+ existingSectionNames,
104
+ )) {
105
+ record(missing.templatePath, missing.diagnostic);
106
+ }
107
+
108
+ const results: FileDiagnostics[] = [...byFile.entries()]
109
+ .map(([path, diagnostics]) => ({ path, diagnostics }))
110
+ .sort((a, b) => a.path.localeCompare(b.path));
111
+
112
+ let errors = 0;
113
+ let warnings = 0;
114
+ for (const { diagnostics } of results) {
115
+ for (const d of diagnostics) {
116
+ if (d.severity === "error") errors++;
117
+ else warnings++;
118
+ }
119
+ }
120
+
121
+ if (opts.json) {
122
+ console.log(
123
+ JSON.stringify({
124
+ ok: errors === 0,
125
+ errors,
126
+ warnings,
127
+ filesChecked: liquidFiles.length,
128
+ files: results,
129
+ }),
130
+ );
131
+ } else {
132
+ printText(results, errors, warnings, liquidFiles.length);
133
+ }
134
+
135
+ process.exit(errors > 0 ? 1 : 0);
136
+ });
137
+ }
138
+
139
+ function plural(count: number, noun: string): string {
140
+ return `${count} ${noun}${count === 1 ? "" : "s"}`;
141
+ }
142
+
143
+ function printText(
144
+ results: FileDiagnostics[],
145
+ errors: number,
146
+ warnings: number,
147
+ filesChecked: number,
148
+ ): void {
149
+ for (const { path, diagnostics } of results) {
150
+ console.log(chalk.bold(path));
151
+ for (const d of diagnostics) {
152
+ const label =
153
+ d.severity === "error"
154
+ ? chalk.red("error".padEnd(7))
155
+ : chalk.yellow("warning".padEnd(7));
156
+ // Only the first line — diagnostics like the invalid-setting-type list
157
+ // carry a long multi-line body that the `--json` output preserves.
158
+ const message = d.message.split("\n")[0];
159
+ console.log(` ${label} ${message}`);
160
+ }
161
+ }
162
+
163
+ const suffix = `(${plural(filesChecked, "file")} checked)`;
164
+ if (errors > 0) {
165
+ console.log(
166
+ `\n${chalk.red(`✖ ${plural(errors, "error")}, ${plural(warnings, "warning")}`)} ${suffix}`,
167
+ );
168
+ } else if (warnings > 0) {
169
+ console.log(
170
+ `\n${chalk.yellow(`⚠ ${plural(warnings, "warning")}`)} ${suffix}`,
171
+ );
172
+ } else {
173
+ console.log(`${chalk.green("✓ No problems found")} ${suffix}`);
174
+ }
175
+ }
@@ -1,7 +1,7 @@
1
1
  import { Command } from "commander";
2
2
  import prompts from "prompts";
3
3
  import { requireToken, createApiClient } from "../api.js";
4
- import { getPluginState } from "../plugin-state.js";
4
+ import { getLastDevThemeId } from "../plugin-state.js";
5
5
  import { themes } from "@fluid-app/themes-api-client";
6
6
 
7
7
  function localSuggest(
@@ -148,9 +148,7 @@ export function createNavigateCommand(): Command {
148
148
  .action(async (opts: { host: string; port: string; theme?: string }) => {
149
149
  requireToken();
150
150
 
151
- const themeId = opts.theme
152
- ? Number(opts.theme)
153
- : getPluginState().devThemeId;
151
+ const themeId = opts.theme ? Number(opts.theme) : getLastDevThemeId();
154
152
 
155
153
  if (!themeId) {
156
154
  console.error(
@@ -3,17 +3,19 @@ import type { PluginContext } from "@fluid-app/fluid-cli";
3
3
  import { createDevCommand } from "./dev.js";
4
4
  import { createPushCommand } from "./push.js";
5
5
  import { createPullCommand } from "./pull.js";
6
+ import { createLintCommand } from "./lint.js";
6
7
  import { createInitCommand } from "./init.js";
7
8
  import { createNavigateCommand } from "./navigate.js";
8
9
 
9
10
  export function registerThemeCommand(ctx: PluginContext): void {
10
11
  const cmd = new Command("theme").description(
11
- "Theme developer workflow — dev server, push, pull, init",
12
+ "Theme developer workflow — dev server, push, pull, lint, init",
12
13
  );
13
14
 
14
15
  cmd.addCommand(createDevCommand());
15
16
  cmd.addCommand(createPushCommand());
16
17
  cmd.addCommand(createPullCommand());
18
+ cmd.addCommand(createLintCommand());
17
19
  cmd.addCommand(createInitCommand());
18
20
  cmd.addCommand(createNavigateCommand());
19
21