zedx 0.3.1 → 0.5.0

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  # zedx
2
2
 
3
- Boilerplate generator for [Zed Editor](https://zed.dev/) extensions.
3
+ Scaffold [Zed Editor](https://zed.dev/) extensions and sync your settings across machines.
4
4
 
5
5
  ![screenshot](./assets/screenshot1.png)
6
6
 
@@ -19,11 +19,23 @@ brew install tahayvr/tap/zedx
19
19
  # Create a new extension
20
20
  zedx
21
21
 
22
+ # Add a theme or language to an existing extension
23
+ zedx add theme "Midnight Blue"
24
+ zedx add language rust
25
+
26
+ # Validate extension config and show what is missing or incomplete
27
+ zedx check
28
+
22
29
  # Bump extension version
23
- # Run inside extension dir
24
30
  zedx version patch # 1.2.3 → 1.2.4
25
31
  zedx version minor # 1.2.3 → 1.3.0
26
32
  zedx version major # 1.2.3 → 2.0.0
33
+
34
+ # Sync Zed settings and extensions via a GitHub repo
35
+ zedx sync init # Link a GitHub repo as the sync target (run once)
36
+ zedx sync # Sync local and remote config automatically
37
+ zedx sync install # Install an OS daemon to auto-sync when Zed config changes
38
+ zedx sync uninstall # Remove the OS daemon
27
39
  ```
28
40
 
29
41
  ### Supported extension types:
package/dist/add.d.ts ADDED
@@ -0,0 +1,2 @@
1
+ export declare function addTheme(callerDir: string, themeName: string): Promise<void>;
2
+ export declare function addLanguage(callerDir: string, languageId: string): Promise<void>;
package/dist/add.js ADDED
@@ -0,0 +1,137 @@
1
+ import fs from 'fs-extra';
2
+ import path from 'path';
3
+ import ejs from 'ejs';
4
+ import { fileURLToPath } from 'url';
5
+ import * as p from '@clack/prompts';
6
+ import color from 'picocolors';
7
+ const __filename = fileURLToPath(import.meta.url);
8
+ const __dirname = path.dirname(__filename);
9
+ const isDev = __dirname.includes('/src/');
10
+ const PROJECT_ROOT = isDev ? path.join(__dirname, '..') : __dirname;
11
+ const TEMPLATE_DIR = path.join(PROJECT_ROOT, 'templates');
12
+ async function renderTemplate(templatePath, data) {
13
+ const template = await fs.readFile(templatePath, 'utf-8');
14
+ return ejs.render(template, data);
15
+ }
16
+ function tomlGet(content, key) {
17
+ const match = content.match(new RegExp(`^${key}\\s*=\\s*"([^"]*)"`, 'm'));
18
+ return match?.[1];
19
+ }
20
+ function slugify(name) {
21
+ return name.toLowerCase().replace(/\s+/g, '-').replace(/[^a-z0-9-]/g, '');
22
+ }
23
+ export async function addTheme(callerDir, themeName) {
24
+ p.intro(`${color.bgBlue(color.bold(' zedx add theme '))} ${color.blue('Adding a theme to your extension…')}`);
25
+ const tomlPath = path.join(callerDir, 'extension.toml');
26
+ if (!(await fs.pathExists(tomlPath))) {
27
+ p.log.error(color.red('No extension.toml found. Run zedx from an extension directory.'));
28
+ process.exit(1);
29
+ }
30
+ const tomlContent = await fs.readFile(tomlPath, 'utf-8');
31
+ const extensionId = tomlGet(tomlContent, 'id') ?? 'extension';
32
+ const author = tomlGet(tomlContent, 'authors')
33
+ ?? tomlContent.match(/^authors\s*=\s*\["([^"]+)"\]/m)?.[1]
34
+ ?? '';
35
+ const appearance = await p.select({
36
+ message: 'Appearance:',
37
+ options: [
38
+ { value: 'dark', label: 'Dark' },
39
+ { value: 'light', label: 'Light' },
40
+ { value: 'both', label: 'Both (Dark & Light)' },
41
+ ],
42
+ initialValue: 'dark',
43
+ });
44
+ if (p.isCancel(appearance)) {
45
+ p.cancel('Cancelled.');
46
+ process.exit(0);
47
+ }
48
+ const appearances = appearance === 'both' ? ['dark', 'light'] : [appearance];
49
+ const themeSlug = slugify(themeName);
50
+ const themeFile = `${themeSlug}.json`;
51
+ const themesDir = path.join(callerDir, 'themes');
52
+ const themePath = path.join(themesDir, themeFile);
53
+ if (await fs.pathExists(themePath)) {
54
+ p.log.error(color.red(`themes/${themeFile} already exists.`));
55
+ process.exit(1);
56
+ }
57
+ await fs.ensureDir(themesDir);
58
+ const themeJson = await renderTemplate(path.join(TEMPLATE_DIR, 'theme/theme.json.ejs'), { id: extensionId, author, themeName, appearances });
59
+ await fs.writeFile(themePath, themeJson);
60
+ p.log.success(`Created ${color.cyan(`themes/${themeFile}`)}`);
61
+ p.outro(`${color.green('✓')} Theme added.\n` +
62
+ `${color.dim('Run')} ${color.cyan('zedx check')} ${color.dim('to validate your extension.')}`);
63
+ }
64
+ export async function addLanguage(callerDir, languageId) {
65
+ p.intro(`${color.bgBlue(color.bold(' zedx add language '))} ${color.blue('Adding a language to your extension…')}`);
66
+ const tomlPath = path.join(callerDir, 'extension.toml');
67
+ if (!(await fs.pathExists(tomlPath))) {
68
+ p.log.error(color.red('No extension.toml found. Run zedx from an extension directory.'));
69
+ process.exit(1);
70
+ }
71
+ const tomlContent = await fs.readFile(tomlPath, 'utf-8');
72
+ // Check for duplicate
73
+ const alreadyExists = new RegExp(`^\\[grammars\\.${languageId}\\]`, 'm').test(tomlContent)
74
+ || new RegExp(`^#\\s*\\[grammars\\.${languageId}\\]`, 'm').test(tomlContent);
75
+ if (alreadyExists) {
76
+ p.log.error(color.red(`Language "${languageId}" already exists in extension.toml.`));
77
+ process.exit(1);
78
+ }
79
+ const languageName = await p.text({
80
+ message: 'Language display name:',
81
+ placeholder: languageId,
82
+ });
83
+ if (p.isCancel(languageName)) {
84
+ p.cancel('Cancelled.');
85
+ process.exit(0);
86
+ }
87
+ const languageNameValue = String(languageName || languageId);
88
+ const langDir = path.join(callerDir, 'languages', languageId);
89
+ if (await fs.pathExists(langDir)) {
90
+ p.log.error(color.red(`languages/${languageId}/ already exists.`));
91
+ process.exit(1);
92
+ }
93
+ await fs.ensureDir(langDir);
94
+ // Write config.toml
95
+ const data = {
96
+ languageName: languageNameValue,
97
+ languageId,
98
+ pathSuffixes: [],
99
+ lineComments: ['//', '#'],
100
+ grammarRepo: '',
101
+ grammarRev: '',
102
+ };
103
+ const configToml = await renderTemplate(path.join(TEMPLATE_DIR, 'language/config.toml.ejs'), data);
104
+ await fs.writeFile(path.join(langDir, 'config.toml'), configToml);
105
+ p.log.success(`Created ${color.cyan(`languages/${languageId}/config.toml`)}`);
106
+ // Write .scm query files
107
+ const queryFiles = [
108
+ 'highlights.scm',
109
+ 'brackets.scm',
110
+ 'outline.scm',
111
+ 'indents.scm',
112
+ 'injections.scm',
113
+ 'overrides.scm',
114
+ 'textobjects.scm',
115
+ 'redactions.scm',
116
+ 'runnables.scm',
117
+ ];
118
+ for (const file of queryFiles) {
119
+ const templatePath = path.join(TEMPLATE_DIR, 'language', file);
120
+ if (await fs.pathExists(templatePath)) {
121
+ const content = ejs.render(await fs.readFile(templatePath, 'utf-8'), data);
122
+ await fs.writeFile(path.join(langDir, file), content);
123
+ }
124
+ }
125
+ p.log.success(`Created ${color.cyan(`languages/${languageId}/`)} query files`);
126
+ // Append grammar block to extension.toml
127
+ const grammarBlock = `\n# [grammars.${languageId}]\n` +
128
+ `# repository = "https://github.com/user/tree-sitter-${languageId}"\n` +
129
+ `# rev = "main"\n` +
130
+ `\n# [language_servers.${languageId}-lsp]\n` +
131
+ `# name = "${languageNameValue} LSP"\n` +
132
+ `# languages = ["${languageNameValue}"]\n`;
133
+ await fs.appendFile(tomlPath, grammarBlock);
134
+ p.log.success(`Updated ${color.cyan('extension.toml')} with grammar block`);
135
+ p.outro(`${color.green('✓')} Language added.\n` +
136
+ `${color.dim('Run')} ${color.cyan('zedx check')} ${color.dim('to validate your extension.')}`);
137
+ }
@@ -0,0 +1 @@
1
+ export declare function runCheck(callerDir: string): Promise<void>;
package/dist/check.js ADDED
@@ -0,0 +1,243 @@
1
+ import fs from 'fs-extra';
2
+ import path from 'path';
3
+ import * as p from '@clack/prompts';
4
+ import color from 'picocolors';
5
+ // Minimal TOML key extraction — handles `key = "value"` and `key = ["a", "b"]`
6
+ function tomlGet(content, key) {
7
+ const match = content.match(new RegExp(`^${key}\\s*=\\s*"([^"]*)"`, 'm'));
8
+ return match?.[1];
9
+ }
10
+ function tomlHasUncommentedKey(content, key) {
11
+ return new RegExp(`^${key}\\s*=`, 'm').test(content);
12
+ }
13
+ function tomlHasSection(content, section) {
14
+ // Looks for an uncommented [section] or [section.something] header
15
+ return new RegExp(`^\\[${section.replace('.', '\\.')}`, 'm').test(content);
16
+ }
17
+ export async function runCheck(callerDir) {
18
+ p.intro(`${color.bgBlue(color.bold(' zedx check '))} ${color.blue('Validating extension config…')}`);
19
+ const tomlPath = path.join(callerDir, 'extension.toml');
20
+ if (!(await fs.pathExists(tomlPath))) {
21
+ p.log.error(color.red('No extension.toml found in current directory.'));
22
+ p.log.info(`Run ${color.cyan('zedx')} to scaffold a new extension first.`);
23
+ process.exit(1);
24
+ }
25
+ const tomlContent = await fs.readFile(tomlPath, 'utf-8');
26
+ const extensionId = tomlGet(tomlContent, 'id');
27
+ const extensionName = tomlGet(tomlContent, 'name');
28
+ const repository = tomlGet(tomlContent, 'repository');
29
+ const results = [];
30
+ // ── extension.toml ────────────────────────────────────────────────────────
31
+ const extIssues = [];
32
+ if (!extensionId) {
33
+ extIssues.push({
34
+ file: 'extension.toml',
35
+ message: 'Missing required field: id',
36
+ });
37
+ }
38
+ if (!extensionName) {
39
+ extIssues.push({
40
+ file: 'extension.toml',
41
+ message: 'Missing required field: name',
42
+ });
43
+ }
44
+ if (!repository || repository.includes('username')) {
45
+ extIssues.push({
46
+ file: 'extension.toml',
47
+ message: 'repository still uses the default placeholder URL',
48
+ hint: 'Set it to your actual GitHub repository URL',
49
+ });
50
+ }
51
+ // Detect language entries by looking for uncommented [grammars.*] sections
52
+ const grammarMatches = [...tomlContent.matchAll(/^\[grammars\.(\S+)\]/gm)];
53
+ const commentedGrammarMatches = [...tomlContent.matchAll(/^#\s*\[grammars\.(\S+)\]/gm)];
54
+ const languageIds = grammarMatches.map(m => m[1]);
55
+ const hasLanguage = languageIds.length > 0 || commentedGrammarMatches.length > 0;
56
+ if (commentedGrammarMatches.length > 0 && grammarMatches.length === 0) {
57
+ const ids = commentedGrammarMatches.map(m => m[1]);
58
+ extIssues.push({
59
+ file: 'extension.toml',
60
+ message: `Grammar section is commented out for: ${ids.join(', ')}`,
61
+ hint: 'Uncomment [grammars.<id>] and set a real tree-sitter repository URL and rev',
62
+ });
63
+ }
64
+ // Detect theme entries by looking for themes/ directory
65
+ const themesDir = path.join(callerDir, 'themes');
66
+ const hasTheme = await fs.pathExists(themesDir);
67
+ results.push({ file: 'extension.toml', issues: extIssues });
68
+ // ── theme validation ──────────────────────────────────────────────────────
69
+ if (hasTheme) {
70
+ const themeIssues = [];
71
+ const themeFiles = (await fs.readdir(themesDir)).filter(f => f.endsWith('.json'));
72
+ if (themeFiles.length === 0) {
73
+ themeIssues.push({
74
+ file: 'themes/',
75
+ message: 'No .json theme files found in themes/ directory',
76
+ });
77
+ }
78
+ for (const themeFile of themeFiles) {
79
+ const themePath = path.join(themesDir, themeFile);
80
+ const themeIssuesForFile = [];
81
+ let themeJson;
82
+ try {
83
+ themeJson = await fs.readJson(themePath);
84
+ }
85
+ catch {
86
+ themeIssuesForFile.push({
87
+ file: `themes/${themeFile}`,
88
+ message: 'Invalid JSON — file could not be parsed',
89
+ });
90
+ results.push({ file: `themes/${themeFile}`, issues: themeIssuesForFile });
91
+ continue;
92
+ }
93
+ const themes = themeJson['themes'];
94
+ if (!themes || themes.length === 0) {
95
+ themeIssuesForFile.push({
96
+ file: `themes/${themeFile}`,
97
+ message: 'No theme variants found under the "themes" key',
98
+ });
99
+ }
100
+ else {
101
+ for (const variant of themes) {
102
+ const variantName = String(variant['name'] ?? 'unknown');
103
+ const style = variant['style'];
104
+ if (!style) {
105
+ themeIssuesForFile.push({
106
+ file: `themes/${themeFile}`,
107
+ message: `Variant "${variantName}": missing "style" block`,
108
+ });
109
+ continue;
110
+ }
111
+ // Check for placeholder-like neutral grays that indicate untouched scaffold
112
+ const background = style['background'];
113
+ const placeholderBgs = ['#1e1e1e', '#f5f5f5', '#ffffff', '#000000'];
114
+ if (background && placeholderBgs.includes(background.toLowerCase())) {
115
+ themeIssuesForFile.push({
116
+ file: `themes/${themeFile}`,
117
+ message: `Variant "${variantName}": background color is still the scaffold placeholder (${background})`,
118
+ hint: 'Replace with your actual theme colors',
119
+ });
120
+ }
121
+ // Check that syntax block is populated
122
+ const syntax = style['syntax'];
123
+ if (!syntax || Object.keys(syntax).length === 0) {
124
+ themeIssuesForFile.push({
125
+ file: `themes/${themeFile}`,
126
+ message: `Variant "${variantName}": "syntax" block is empty or missing`,
127
+ hint: 'Add syntax token color definitions',
128
+ });
129
+ }
130
+ }
131
+ }
132
+ themeIssues.push(...themeIssuesForFile);
133
+ }
134
+ results.push({ file: 'themes/', issues: themeIssues });
135
+ }
136
+ // ── language validation ───────────────────────────────────────────────────
137
+ if (hasLanguage) {
138
+ // Collect all language IDs from both uncommented and commented grammar sections
139
+ const allLanguageIds = [
140
+ ...grammarMatches.map(m => m[1]),
141
+ ...commentedGrammarMatches.map(m => m[1]),
142
+ ];
143
+ for (const langId of allLanguageIds) {
144
+ const langDir = path.join(callerDir, 'languages', langId);
145
+ const langIssues = [];
146
+ if (!(await fs.pathExists(langDir))) {
147
+ langIssues.push({
148
+ file: `languages/${langId}/`,
149
+ message: `Language directory does not exist`,
150
+ hint: `Expected at ${path.join('languages', langId)}`,
151
+ });
152
+ results.push({ file: `languages/${langId}/`, issues: langIssues });
153
+ continue;
154
+ }
155
+ // config.toml checks
156
+ const configPath = path.join(langDir, 'config.toml');
157
+ const configIssues = [];
158
+ if (!(await fs.pathExists(configPath))) {
159
+ configIssues.push({
160
+ file: `languages/${langId}/config.toml`,
161
+ message: 'config.toml is missing',
162
+ });
163
+ }
164
+ else {
165
+ const configContent = await fs.readFile(configPath, 'utf-8');
166
+ if (!tomlHasUncommentedKey(configContent, 'name')) {
167
+ configIssues.push({
168
+ file: `languages/${langId}/config.toml`,
169
+ message: 'Missing required field: name',
170
+ });
171
+ }
172
+ if (!tomlHasUncommentedKey(configContent, 'grammar')) {
173
+ configIssues.push({
174
+ file: `languages/${langId}/config.toml`,
175
+ message: 'Missing required field: grammar',
176
+ });
177
+ }
178
+ // path_suffixes is commented out in scaffold — flag it
179
+ if (!tomlHasUncommentedKey(configContent, 'path_suffixes')) {
180
+ configIssues.push({
181
+ file: `languages/${langId}/config.toml`,
182
+ message: 'path_suffixes is not set — files won\'t be associated with this language',
183
+ hint: 'Uncomment and fill in path_suffixes (e.g., ["myl"])',
184
+ });
185
+ }
186
+ // line_comments is commented out in scaffold — flag it
187
+ if (!tomlHasUncommentedKey(configContent, 'line_comments')) {
188
+ configIssues.push({
189
+ file: `languages/${langId}/config.toml`,
190
+ message: 'line_comments is not set — toggle-comment keybind won\'t work',
191
+ hint: 'Uncomment and set line_comments (e.g., ["// "])',
192
+ });
193
+ }
194
+ }
195
+ results.push({ file: `languages/${langId}/config.toml`, issues: configIssues });
196
+ // highlights.scm checks
197
+ const highlightsPath = path.join(langDir, 'highlights.scm');
198
+ const highlightIssues = [];
199
+ if (!(await fs.pathExists(highlightsPath))) {
200
+ highlightIssues.push({
201
+ file: `languages/${langId}/highlights.scm`,
202
+ message: 'highlights.scm is missing',
203
+ hint: 'Without it, no syntax highlighting will appear',
204
+ });
205
+ }
206
+ else {
207
+ const highlightsContent = await fs.readFile(highlightsPath, 'utf-8');
208
+ // Count non-comment, non-empty lines with actual query patterns
209
+ const activeLines = highlightsContent
210
+ .split('\n')
211
+ .filter(l => l.trim() && !l.trim().startsWith(';'));
212
+ if (activeLines.length <= 3) {
213
+ highlightIssues.push({
214
+ file: `languages/${langId}/highlights.scm`,
215
+ message: 'Only scaffold starter patterns present — no real grammar queries added yet',
216
+ hint: 'Add tree-sitter queries matching your language\'s grammar node types',
217
+ });
218
+ }
219
+ }
220
+ results.push({ file: `languages/${langId}/highlights.scm`, issues: highlightIssues });
221
+ }
222
+ }
223
+ // ── render results ────────────────────────────────────────────────────────
224
+ const allIssues = results.flatMap(r => r.issues);
225
+ const fileGroups = results.filter(r => r.issues.length > 0);
226
+ if (fileGroups.length === 0) {
227
+ p.log.success(color.green('No issues found. Your extension config looks good!'));
228
+ p.outro(`${color.dim('Load it in Zed:')} Extensions ${color.dim('>')} Install Dev Extension`);
229
+ return;
230
+ }
231
+ for (const group of fileGroups) {
232
+ p.log.warn(`${color.yellow(color.bold(group.issues[0].file))}`);
233
+ for (const issue of group.issues) {
234
+ process.stdout.write(` ${color.red('✗')} ${issue.message}\n`);
235
+ if (issue.hint) {
236
+ process.stdout.write(` ${color.dim('→')} ${color.dim(issue.hint)}\n`);
237
+ }
238
+ }
239
+ process.stdout.write('\n');
240
+ }
241
+ const issueCount = allIssues.length;
242
+ p.outro(`${color.red(`${issueCount} issue${issueCount === 1 ? '' : 's'} found`)} — fix the above before publishing`);
243
+ }
@@ -0,0 +1,2 @@
1
+ export declare function syncInstall(): Promise<void>;
2
+ export declare function syncUninstall(): Promise<void>;
package/dist/daemon.js ADDED
@@ -0,0 +1,183 @@
1
+ import os from 'os';
2
+ import path from 'path';
3
+ import fs from 'fs-extra';
4
+ import { execSync } from 'child_process';
5
+ import * as p from '@clack/prompts';
6
+ import color from 'picocolors';
7
+ import { resolveZedPaths } from './zed-paths.js';
8
+ const LAUNCHD_LABEL = 'dev.zedx.sync';
9
+ const LAUNCHD_PLIST_PATH = path.join(os.homedir(), 'Library', 'LaunchAgents', `${LAUNCHD_LABEL}.plist`);
10
+ const SYSTEMD_SERVICE_NAME = 'zedx-sync';
11
+ const SYSTEMD_UNIT_DIR = path.join(os.homedir(), '.config', 'systemd', 'user');
12
+ const SYSTEMD_SERVICE_PATH = path.join(SYSTEMD_UNIT_DIR, `${SYSTEMD_SERVICE_NAME}.service`);
13
+ const SYSTEMD_PATH_PATH = path.join(SYSTEMD_UNIT_DIR, `${SYSTEMD_SERVICE_NAME}.path`);
14
+ function resolveZedxBinary() {
15
+ try {
16
+ const bin = execSync('which zedx', { encoding: 'utf-8' }).trim();
17
+ if (bin)
18
+ return bin;
19
+ }
20
+ catch { /* fall through */ }
21
+ return `${process.execPath} ${process.argv[1]}`;
22
+ }
23
+ function unsupportedPlatform() {
24
+ p.log.error(color.red(`zedx sync install is only supported on macOS and Linux.`));
25
+ process.exit(1);
26
+ }
27
+ function buildPlist(zedxBin, watchPaths) {
28
+ const watchEntries = watchPaths
29
+ .map((wp) => ` <string>${wp}</string>`)
30
+ .join('\n');
31
+ return `<?xml version="1.0" encoding="UTF-8"?>
32
+ <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
33
+ <plist version="1.0">
34
+ <dict>
35
+ <key>Label</key>
36
+ <string>${LAUNCHD_LABEL}</string>
37
+
38
+ <key>ProgramArguments</key>
39
+ <array>
40
+ <string>${zedxBin}</string>
41
+ <string>sync</string>
42
+ </array>
43
+
44
+ <key>RunAtLoad</key>
45
+ <true/>
46
+
47
+ <key>WatchPaths</key>
48
+ <array>
49
+ ${watchEntries}
50
+ </array>
51
+
52
+ <key>ThrottleInterval</key>
53
+ <integer>30</integer>
54
+
55
+ <key>StandardOutPath</key>
56
+ <string>${os.homedir()}/Library/Logs/zedx-sync.log</string>
57
+
58
+ <key>StandardErrorPath</key>
59
+ <string>${os.homedir()}/Library/Logs/zedx-sync.log</string>
60
+ </dict>
61
+ </plist>
62
+ `;
63
+ }
64
+ async function installMacos(zedxBin, watchPaths) {
65
+ const plist = buildPlist(zedxBin, watchPaths);
66
+ await fs.ensureDir(path.dirname(LAUNCHD_PLIST_PATH));
67
+ await fs.writeFile(LAUNCHD_PLIST_PATH, plist, 'utf-8');
68
+ try {
69
+ execSync(`launchctl unload "${LAUNCHD_PLIST_PATH}" 2>/dev/null`, { stdio: 'pipe' });
70
+ }
71
+ catch { /* not loaded yet */ }
72
+ execSync(`launchctl load "${LAUNCHD_PLIST_PATH}"`);
73
+ p.log.success(`Daemon installed: ${color.dim(LAUNCHD_PLIST_PATH)}`);
74
+ p.log.info(`Logs: ${color.dim(`${os.homedir()}/Library/Logs/zedx-sync.log`)}`);
75
+ p.log.info(`To check status: ${color.cyan(`launchctl list ${LAUNCHD_LABEL}`)}`);
76
+ }
77
+ async function uninstallMacos() {
78
+ if (!(await fs.pathExists(LAUNCHD_PLIST_PATH))) {
79
+ p.log.warn(color.yellow('No launchd agent found — nothing to uninstall.'));
80
+ return;
81
+ }
82
+ try {
83
+ execSync(`launchctl unload "${LAUNCHD_PLIST_PATH}"`, { stdio: 'pipe' });
84
+ }
85
+ catch { /* already unloaded */ }
86
+ await fs.remove(LAUNCHD_PLIST_PATH);
87
+ p.log.success('Daemon uninstalled.');
88
+ }
89
+ function buildSystemdService(zedxBin) {
90
+ return `[Unit]
91
+ Description=zedx Zed config sync
92
+ After=network-online.target
93
+ Wants=network-online.target
94
+
95
+ [Service]
96
+ Type=oneshot
97
+ ExecStart=${zedxBin} sync
98
+ StandardOutput=journal
99
+ StandardError=journal
100
+
101
+ [Install]
102
+ WantedBy=default.target
103
+ `;
104
+ }
105
+ function buildSystemdPath(watchPaths) {
106
+ const pathChangedEntries = watchPaths
107
+ .map((wp) => `PathChanged=${wp}`)
108
+ .join('\n');
109
+ return `[Unit]
110
+ Description=Watch Zed config files for zedx sync
111
+
112
+ [Path]
113
+ ${pathChangedEntries}
114
+ Unit=${SYSTEMD_SERVICE_NAME}.service
115
+
116
+ [Install]
117
+ WantedBy=default.target
118
+ `;
119
+ }
120
+ async function installLinux(zedxBin, watchPaths) {
121
+ await fs.ensureDir(SYSTEMD_UNIT_DIR);
122
+ await fs.writeFile(SYSTEMD_SERVICE_PATH, buildSystemdService(zedxBin), 'utf-8');
123
+ await fs.writeFile(SYSTEMD_PATH_PATH, buildSystemdPath(watchPaths), 'utf-8');
124
+ execSync('systemctl --user daemon-reload');
125
+ execSync(`systemctl --user enable --now ${SYSTEMD_SERVICE_NAME}.path`);
126
+ p.log.success(`Service installed: ${color.dim(SYSTEMD_SERVICE_PATH)}`);
127
+ p.log.success(`Path unit installed: ${color.dim(SYSTEMD_PATH_PATH)}`);
128
+ p.log.info(`To check status: ${color.cyan(`systemctl --user status ${SYSTEMD_SERVICE_NAME}.path`)}`);
129
+ p.log.info(`Logs: ${color.cyan(`journalctl --user -u ${SYSTEMD_SERVICE_NAME}.service`)}`);
130
+ }
131
+ async function uninstallLinux() {
132
+ const serviceExists = await fs.pathExists(SYSTEMD_SERVICE_PATH);
133
+ const pathExists = await fs.pathExists(SYSTEMD_PATH_PATH);
134
+ if (!serviceExists && !pathExists) {
135
+ p.log.warn(color.yellow('No systemd units found — nothing to uninstall.'));
136
+ return;
137
+ }
138
+ try {
139
+ execSync(`systemctl --user disable --now ${SYSTEMD_SERVICE_NAME}.path`, { stdio: 'pipe' });
140
+ }
141
+ catch { /* already inactive */ }
142
+ if (serviceExists)
143
+ await fs.remove(SYSTEMD_SERVICE_PATH);
144
+ if (pathExists)
145
+ await fs.remove(SYSTEMD_PATH_PATH);
146
+ execSync('systemctl --user daemon-reload', { stdio: 'pipe' });
147
+ p.log.success('Daemon uninstalled.');
148
+ }
149
+ export async function syncInstall() {
150
+ p.intro(color.bold('zedx sync install'));
151
+ const platform = process.platform;
152
+ if (platform !== 'darwin' && platform !== 'linux')
153
+ unsupportedPlatform();
154
+ const zedPaths = resolveZedPaths();
155
+ const watchPaths = [zedPaths.settings, zedPaths.extensions];
156
+ const zedxBin = resolveZedxBinary();
157
+ p.log.info(`Binary: ${color.dim(zedxBin)}`);
158
+ p.log.info(`Watching:`);
159
+ for (const wp of watchPaths) {
160
+ p.log.info(` ${color.dim(wp)}`);
161
+ }
162
+ if (platform === 'darwin') {
163
+ await installMacos(zedxBin, watchPaths);
164
+ }
165
+ else {
166
+ await installLinux(zedxBin, watchPaths);
167
+ }
168
+ p.outro(`${color.green('✓')} zedx sync will now run automatically whenever your Zed config changes.\n\n` +
169
+ ` Run ${color.cyan('zedx sync uninstall')} to remove the daemon at any time.`);
170
+ }
171
+ export async function syncUninstall() {
172
+ p.intro(color.bold('zedx sync uninstall'));
173
+ const platform = process.platform;
174
+ if (platform !== 'darwin' && platform !== 'linux')
175
+ unsupportedPlatform();
176
+ if (platform === 'darwin') {
177
+ await uninstallMacos();
178
+ }
179
+ else {
180
+ await uninstallLinux();
181
+ }
182
+ p.outro(`${color.green('✓')} Done.`);
183
+ }
package/dist/dev.d.ts ADDED
@@ -0,0 +1 @@
1
+ export declare function runCheck(callerDir: string): Promise<void>;
package/dist/dev.js ADDED
@@ -0,0 +1,243 @@
1
+ import fs from 'fs-extra';
2
+ import path from 'path';
3
+ import * as p from '@clack/prompts';
4
+ import color from 'picocolors';
5
+ // Minimal TOML key extraction — handles `key = "value"` and `key = ["a", "b"]`
6
+ function tomlGet(content, key) {
7
+ const match = content.match(new RegExp(`^${key}\\s*=\\s*"([^"]*)"`, 'm'));
8
+ return match?.[1];
9
+ }
10
+ function tomlHasUncommentedKey(content, key) {
11
+ return new RegExp(`^${key}\\s*=`, 'm').test(content);
12
+ }
13
+ function tomlHasSection(content, section) {
14
+ // Looks for an uncommented [section] or [section.something] header
15
+ return new RegExp(`^\\[${section.replace('.', '\\.')}`, 'm').test(content);
16
+ }
17
+ export async function runCheck(callerDir) {
18
+ p.intro(`${color.bgBlue(color.bold(' zedx check '))} ${color.blue('Validating extension config…')}`);
19
+ const tomlPath = path.join(callerDir, 'extension.toml');
20
+ if (!(await fs.pathExists(tomlPath))) {
21
+ p.log.error(color.red('No extension.toml found in current directory.'));
22
+ p.log.info(`Run ${color.cyan('zedx')} to scaffold a new extension first.`);
23
+ process.exit(1);
24
+ }
25
+ const tomlContent = await fs.readFile(tomlPath, 'utf-8');
26
+ const extensionId = tomlGet(tomlContent, 'id');
27
+ const extensionName = tomlGet(tomlContent, 'name');
28
+ const repository = tomlGet(tomlContent, 'repository');
29
+ const results = [];
30
+ // ── extension.toml ────────────────────────────────────────────────────────
31
+ const extIssues = [];
32
+ if (!extensionId) {
33
+ extIssues.push({
34
+ file: 'extension.toml',
35
+ message: 'Missing required field: id',
36
+ });
37
+ }
38
+ if (!extensionName) {
39
+ extIssues.push({
40
+ file: 'extension.toml',
41
+ message: 'Missing required field: name',
42
+ });
43
+ }
44
+ if (!repository || repository.includes('username')) {
45
+ extIssues.push({
46
+ file: 'extension.toml',
47
+ message: 'repository still uses the default placeholder URL',
48
+ hint: 'Set it to your actual GitHub repository URL',
49
+ });
50
+ }
51
+ // Detect language entries by looking for uncommented [grammars.*] sections
52
+ const grammarMatches = [...tomlContent.matchAll(/^\[grammars\.(\S+)\]/gm)];
53
+ const commentedGrammarMatches = [...tomlContent.matchAll(/^#\s*\[grammars\.(\S+)\]/gm)];
54
+ const languageIds = grammarMatches.map(m => m[1]);
55
+ const hasLanguage = languageIds.length > 0 || commentedGrammarMatches.length > 0;
56
+ if (commentedGrammarMatches.length > 0 && grammarMatches.length === 0) {
57
+ const ids = commentedGrammarMatches.map(m => m[1]);
58
+ extIssues.push({
59
+ file: 'extension.toml',
60
+ message: `Grammar section is commented out for: ${ids.join(', ')}`,
61
+ hint: 'Uncomment [grammars.<id>] and set a real tree-sitter repository URL and rev',
62
+ });
63
+ }
64
+ // Detect theme entries by looking for themes/ directory
65
+ const themesDir = path.join(callerDir, 'themes');
66
+ const hasTheme = await fs.pathExists(themesDir);
67
+ results.push({ file: 'extension.toml', issues: extIssues });
68
+ // ── theme validation ──────────────────────────────────────────────────────
69
+ if (hasTheme) {
70
+ const themeIssues = [];
71
+ const themeFiles = (await fs.readdir(themesDir)).filter(f => f.endsWith('.json'));
72
+ if (themeFiles.length === 0) {
73
+ themeIssues.push({
74
+ file: 'themes/',
75
+ message: 'No .json theme files found in themes/ directory',
76
+ });
77
+ }
78
+ for (const themeFile of themeFiles) {
79
+ const themePath = path.join(themesDir, themeFile);
80
+ const themeIssuesForFile = [];
81
+ let themeJson;
82
+ try {
83
+ themeJson = await fs.readJson(themePath);
84
+ }
85
+ catch {
86
+ themeIssuesForFile.push({
87
+ file: `themes/${themeFile}`,
88
+ message: 'Invalid JSON — file could not be parsed',
89
+ });
90
+ results.push({ file: `themes/${themeFile}`, issues: themeIssuesForFile });
91
+ continue;
92
+ }
93
+ const themes = themeJson['themes'];
94
+ if (!themes || themes.length === 0) {
95
+ themeIssuesForFile.push({
96
+ file: `themes/${themeFile}`,
97
+ message: 'No theme variants found under the "themes" key',
98
+ });
99
+ }
100
+ else {
101
+ for (const variant of themes) {
102
+ const variantName = String(variant['name'] ?? 'unknown');
103
+ const style = variant['style'];
104
+ if (!style) {
105
+ themeIssuesForFile.push({
106
+ file: `themes/${themeFile}`,
107
+ message: `Variant "${variantName}": missing "style" block`,
108
+ });
109
+ continue;
110
+ }
111
+ // Check for placeholder-like neutral grays that indicate untouched scaffold
112
+ const background = style['background'];
113
+ const placeholderBgs = ['#1e1e1e', '#f5f5f5', '#ffffff', '#000000'];
114
+ if (background && placeholderBgs.includes(background.toLowerCase())) {
115
+ themeIssuesForFile.push({
116
+ file: `themes/${themeFile}`,
117
+ message: `Variant "${variantName}": background color is still the scaffold placeholder (${background})`,
118
+ hint: 'Replace with your actual theme colors',
119
+ });
120
+ }
121
+ // Check that syntax block is populated
122
+ const syntax = style['syntax'];
123
+ if (!syntax || Object.keys(syntax).length === 0) {
124
+ themeIssuesForFile.push({
125
+ file: `themes/${themeFile}`,
126
+ message: `Variant "${variantName}": "syntax" block is empty or missing`,
127
+ hint: 'Add syntax token color definitions',
128
+ });
129
+ }
130
+ }
131
+ }
132
+ themeIssues.push(...themeIssuesForFile);
133
+ }
134
+ results.push({ file: 'themes/', issues: themeIssues });
135
+ }
136
+ // ── language validation ───────────────────────────────────────────────────
137
+ if (hasLanguage) {
138
+ // Collect all language IDs from both uncommented and commented grammar sections
139
+ const allLanguageIds = [
140
+ ...grammarMatches.map(m => m[1]),
141
+ ...commentedGrammarMatches.map(m => m[1]),
142
+ ];
143
+ for (const langId of allLanguageIds) {
144
+ const langDir = path.join(callerDir, 'languages', langId);
145
+ const langIssues = [];
146
+ if (!(await fs.pathExists(langDir))) {
147
+ langIssues.push({
148
+ file: `languages/${langId}/`,
149
+ message: `Language directory does not exist`,
150
+ hint: `Expected at ${path.join('languages', langId)}`,
151
+ });
152
+ results.push({ file: `languages/${langId}/`, issues: langIssues });
153
+ continue;
154
+ }
155
+ // config.toml checks
156
+ const configPath = path.join(langDir, 'config.toml');
157
+ const configIssues = [];
158
+ if (!(await fs.pathExists(configPath))) {
159
+ configIssues.push({
160
+ file: `languages/${langId}/config.toml`,
161
+ message: 'config.toml is missing',
162
+ });
163
+ }
164
+ else {
165
+ const configContent = await fs.readFile(configPath, 'utf-8');
166
+ if (!tomlHasUncommentedKey(configContent, 'name')) {
167
+ configIssues.push({
168
+ file: `languages/${langId}/config.toml`,
169
+ message: 'Missing required field: name',
170
+ });
171
+ }
172
+ if (!tomlHasUncommentedKey(configContent, 'grammar')) {
173
+ configIssues.push({
174
+ file: `languages/${langId}/config.toml`,
175
+ message: 'Missing required field: grammar',
176
+ });
177
+ }
178
+ // path_suffixes is commented out in scaffold — flag it
179
+ if (!tomlHasUncommentedKey(configContent, 'path_suffixes')) {
180
+ configIssues.push({
181
+ file: `languages/${langId}/config.toml`,
182
+ message: 'path_suffixes is not set — files won\'t be associated with this language',
183
+ hint: 'Uncomment and fill in path_suffixes (e.g., ["myl"])',
184
+ });
185
+ }
186
+ // line_comments is commented out in scaffold — flag it
187
+ if (!tomlHasUncommentedKey(configContent, 'line_comments')) {
188
+ configIssues.push({
189
+ file: `languages/${langId}/config.toml`,
190
+ message: 'line_comments is not set — toggle-comment keybind won\'t work',
191
+ hint: 'Uncomment and set line_comments (e.g., ["// "])',
192
+ });
193
+ }
194
+ }
195
+ results.push({ file: `languages/${langId}/config.toml`, issues: configIssues });
196
+ // highlights.scm checks
197
+ const highlightsPath = path.join(langDir, 'highlights.scm');
198
+ const highlightIssues = [];
199
+ if (!(await fs.pathExists(highlightsPath))) {
200
+ highlightIssues.push({
201
+ file: `languages/${langId}/highlights.scm`,
202
+ message: 'highlights.scm is missing',
203
+ hint: 'Without it, no syntax highlighting will appear',
204
+ });
205
+ }
206
+ else {
207
+ const highlightsContent = await fs.readFile(highlightsPath, 'utf-8');
208
+ // Count non-comment, non-empty lines with actual query patterns
209
+ const activeLines = highlightsContent
210
+ .split('\n')
211
+ .filter(l => l.trim() && !l.trim().startsWith(';'));
212
+ if (activeLines.length <= 3) {
213
+ highlightIssues.push({
214
+ file: `languages/${langId}/highlights.scm`,
215
+ message: 'Only scaffold starter patterns present — no real grammar queries added yet',
216
+ hint: 'Add tree-sitter queries matching your language\'s grammar node types',
217
+ });
218
+ }
219
+ }
220
+ results.push({ file: `languages/${langId}/highlights.scm`, issues: highlightIssues });
221
+ }
222
+ }
223
+ // ── render results ────────────────────────────────────────────────────────
224
+ const allIssues = results.flatMap(r => r.issues);
225
+ const fileGroups = results.filter(r => r.issues.length > 0);
226
+ if (fileGroups.length === 0) {
227
+ p.log.success(color.green('No issues found. Your extension config looks good!'));
228
+ p.outro(`${color.dim('Load it in Zed:')} Extensions ${color.dim('>')} Install Dev Extension`);
229
+ return;
230
+ }
231
+ for (const group of fileGroups) {
232
+ p.log.warn(`${color.yellow(color.bold(group.issues[0].file))}`);
233
+ for (const issue of group.issues) {
234
+ process.stdout.write(` ${color.red('✗')} ${issue.message}\n`);
235
+ if (issue.hint) {
236
+ process.stdout.write(` ${color.dim('→')} ${color.dim(issue.hint)}\n`);
237
+ }
238
+ }
239
+ process.stdout.write('\n');
240
+ }
241
+ const issueCount = allIssues.length;
242
+ p.outro(`${color.red(`${issueCount} issue${issueCount === 1 ? '' : 's'} found`)} — fix the above before publishing`);
243
+ }
package/dist/index.js CHANGED
@@ -6,6 +6,10 @@ import { Command } from 'commander';
6
6
  import fs from 'fs-extra';
7
7
  import { promptUser, promptThemeDetails, promptLanguageDetails } from './prompts.js';
8
8
  import { generateExtension } from './generator.js';
9
+ import { runCheck } from './check.js';
10
+ import { addTheme, addLanguage } from './add.js';
11
+ import { syncInit, runSync } from './sync.js';
12
+ import { syncInstall, syncUninstall } from './daemon.js';
9
13
  function bumpVersion(version, type) {
10
14
  const [major, minor, patch] = version.split('.').map(Number);
11
15
  switch (type) {
@@ -53,6 +57,51 @@ async function main() {
53
57
  }
54
58
  await bumpExtensionVersion(type);
55
59
  });
60
+ program
61
+ .command('check')
62
+ .description('Validate extension config and show what is missing or incomplete')
63
+ .action(async () => {
64
+ await runCheck(getCallerDir());
65
+ });
66
+ const addCmd = program
67
+ .command('add')
68
+ .description('Add a theme or language to an existing extension');
69
+ addCmd
70
+ .command('theme <name>')
71
+ .description('Add a new theme to the extension')
72
+ .action(async (name) => {
73
+ await addTheme(getCallerDir(), name);
74
+ });
75
+ addCmd
76
+ .command('language <id>')
77
+ .description('Add a new language to the extension')
78
+ .action(async (id) => {
79
+ await addLanguage(getCallerDir(), id);
80
+ });
81
+ const syncCmd = program
82
+ .command('sync')
83
+ .description('Sync Zed settings and extensions via a GitHub repo')
84
+ .action(async () => {
85
+ await runSync();
86
+ });
87
+ syncCmd
88
+ .command('init')
89
+ .description('Link a GitHub repo as the sync target')
90
+ .action(async () => {
91
+ await syncInit();
92
+ });
93
+ syncCmd
94
+ .command('install')
95
+ .description('Install the OS daemon to auto-sync when Zed config changes')
96
+ .action(async () => {
97
+ await syncInstall();
98
+ });
99
+ syncCmd
100
+ .command('uninstall')
101
+ .description('Remove the OS daemon')
102
+ .action(async () => {
103
+ await syncUninstall();
104
+ });
56
105
  if (process.argv.length <= 2) {
57
106
  const options = await promptUser();
58
107
  if (options.types.includes('theme')) {
package/dist/sync.d.ts ADDED
@@ -0,0 +1,4 @@
1
+ export declare function syncInit(): Promise<void>;
2
+ export declare function runSync(opts?: {
3
+ silent?: boolean;
4
+ }): Promise<void>;
package/dist/sync.js ADDED
@@ -0,0 +1,317 @@
1
+ import os from 'os';
2
+ import path from 'path';
3
+ import fs from 'fs-extra';
4
+ import * as p from '@clack/prompts';
5
+ import color from 'picocolors';
6
+ import simpleGit from 'simple-git';
7
+ import { resolveZedPaths } from './zed-paths.js';
8
+ const ZEDX_CONFIG_DIR = path.join(os.homedir(), '.config', 'zedx');
9
+ const ZEDX_CONFIG_PATH = path.join(ZEDX_CONFIG_DIR, 'config.json');
10
+ async function readSyncConfig() {
11
+ if (!(await fs.pathExists(ZEDX_CONFIG_PATH))) {
12
+ return null;
13
+ }
14
+ return fs.readJson(ZEDX_CONFIG_PATH);
15
+ }
16
+ async function requireSyncConfig() {
17
+ const config = await readSyncConfig();
18
+ if (!config) {
19
+ p.log.error(color.red('No sync config found. Run ') + color.cyan('zedx sync init') + color.red(' first.'));
20
+ process.exit(1);
21
+ }
22
+ return config;
23
+ }
24
+ async function writeSyncConfig(config) {
25
+ await fs.ensureDir(ZEDX_CONFIG_DIR);
26
+ await fs.writeJson(ZEDX_CONFIG_PATH, config, { spaces: 4 });
27
+ }
28
+ // Temp dir helper
29
+ async function withTempDir(fn) {
30
+ const tmp = await fs.mkdtemp(path.join(os.tmpdir(), 'zedx-sync-'));
31
+ try {
32
+ return await fn(tmp);
33
+ }
34
+ finally {
35
+ await fs.remove(tmp);
36
+ }
37
+ }
38
+ // Extension merge helper
39
+ async function applyRemoteSettings(repoSettings, repoExtensions, localSettingsPath, silent = false) {
40
+ // Backup existing settings
41
+ if (await fs.pathExists(localSettingsPath)) {
42
+ await fs.copy(localSettingsPath, localSettingsPath + '.bak', { overwrite: true });
43
+ if (!silent)
44
+ p.log.info(`Backed up settings to ${color.dim(localSettingsPath + '.bak')}`);
45
+ }
46
+ let settingsJson = await fs.readFile(repoSettings, 'utf-8');
47
+ // Merge auto_install_extensions from index.json into settings
48
+ if (await fs.pathExists(repoExtensions)) {
49
+ try {
50
+ const indexJson = (await fs.readJson(repoExtensions));
51
+ const extensionIds = Object.keys(indexJson.extensions ?? {}).filter((id) => !indexJson.extensions[id]?.dev);
52
+ if (extensionIds.length > 0) {
53
+ const stripped = settingsJson.replace(/\/\/[^\n]*/g, '');
54
+ let settingsObj = {};
55
+ try {
56
+ settingsObj = JSON.parse(stripped);
57
+ }
58
+ catch {
59
+ if (!silent)
60
+ p.log.warn(color.yellow('Could not parse settings.json — skipping extension merge.'));
61
+ }
62
+ const autoInstall = {};
63
+ for (const id of extensionIds) {
64
+ autoInstall[id] = true;
65
+ }
66
+ settingsObj['auto_install_extensions'] = autoInstall;
67
+ settingsJson = JSON.stringify(settingsObj, null, 4);
68
+ }
69
+ }
70
+ catch {
71
+ if (!silent)
72
+ p.log.warn(color.yellow('Could not parse extensions/index.json — skipping extension merge.'));
73
+ }
74
+ }
75
+ await fs.ensureDir(path.dirname(localSettingsPath));
76
+ await fs.writeFile(localSettingsPath, settingsJson, 'utf-8');
77
+ }
78
+ // zedx sync init
79
+ export async function syncInit() {
80
+ p.intro(color.bold('zedx sync init'));
81
+ const repo = await p.text({
82
+ message: 'GitHub repo URL (SSH or HTTPS)',
83
+ placeholder: 'https://github.com/you/zed-config.git',
84
+ validate: (v) => {
85
+ if (!v.trim())
86
+ return 'Repo URL is required';
87
+ if (!v.startsWith('https://') && !v.startsWith('git@')) {
88
+ return 'Must be a valid HTTPS or SSH git URL';
89
+ }
90
+ }
91
+ });
92
+ if (p.isCancel(repo)) {
93
+ p.cancel('Cancelled.');
94
+ process.exit(0);
95
+ }
96
+ const branch = await p.text({
97
+ message: 'Branch name',
98
+ placeholder: 'main',
99
+ defaultValue: 'main'
100
+ });
101
+ if (p.isCancel(branch)) {
102
+ p.cancel('Cancelled.');
103
+ process.exit(0);
104
+ }
105
+ const spinner = p.spinner();
106
+ spinner.start('Verifying repo is reachable...');
107
+ try {
108
+ const git = simpleGit();
109
+ await git.listRemote(['--heads', repo]);
110
+ spinner.stop('Repo verified.');
111
+ }
112
+ catch {
113
+ spinner.stop(color.yellow('Could not verify repo (may be empty or private — continuing anyway).'));
114
+ }
115
+ const config = {
116
+ syncRepo: repo.trim(),
117
+ branch: (branch || 'main').trim()
118
+ };
119
+ await writeSyncConfig(config);
120
+ p.outro(`${color.green('✓')} Sync config saved to ${color.cyan(ZEDX_CONFIG_PATH)}\n\n` +
121
+ ` Run ${color.cyan('zedx sync')} to sync your Zed config.`);
122
+ }
123
+ // zedx sync
124
+ export async function runSync(opts = {}) {
125
+ const { silent = false } = opts;
126
+ // In silent mode (daemon/watch), route all UI through plain console.log
127
+ // Interactive conflict prompts fall back to "local wins".
128
+ const log = {
129
+ info: (msg) => {
130
+ if (!silent)
131
+ p.log.info(msg);
132
+ },
133
+ warn: (msg) => {
134
+ if (!silent)
135
+ p.log.warn(msg);
136
+ else
137
+ console.error(`[zedx] warn: ${msg}`);
138
+ },
139
+ success: (msg) => {
140
+ if (!silent)
141
+ p.log.success(msg);
142
+ }
143
+ };
144
+ if (!silent)
145
+ p.intro(color.bold('zedx sync'));
146
+ const config = await requireSyncConfig();
147
+ const zedPaths = resolveZedPaths();
148
+ // Spinner shim: in silent mode just log to stderr so daemons can capture it
149
+ const spinner = silent
150
+ ? {
151
+ start: (m) => console.error(`[zedx] ${m}`),
152
+ stop: (m) => console.error(`[zedx] ${m}`)
153
+ }
154
+ : p.spinner();
155
+ await withTempDir(async (tmp) => {
156
+ // 1. Clone the remote repo
157
+ const git = simpleGit(tmp);
158
+ let remoteExists = true;
159
+ spinner.start(`Fetching ${config.syncRepo}...`);
160
+ try {
161
+ await git.clone(config.syncRepo, tmp, ['--depth', '1', '--branch', config.branch]);
162
+ spinner.stop('Remote fetched.');
163
+ }
164
+ catch {
165
+ remoteExists = false;
166
+ spinner.stop('Remote is empty or branch not found — will push fresh.');
167
+ await git.init();
168
+ await git.addRemote('origin', config.syncRepo);
169
+ }
170
+ // 2. Determine what changed for each file
171
+ const lastSync = config.lastSync ? new Date(config.lastSync) : null;
172
+ const files = [
173
+ {
174
+ repoPath: path.join(tmp, 'settings.json'),
175
+ localPath: zedPaths.settings,
176
+ label: 'settings.json'
177
+ },
178
+ {
179
+ repoPath: path.join(tmp, 'extensions', 'index.json'),
180
+ localPath: zedPaths.extensions,
181
+ label: 'extensions/index.json'
182
+ }
183
+ ];
184
+ let anyChanges = false;
185
+ for (const file of files) {
186
+ const localExists = await fs.pathExists(file.localPath);
187
+ const remoteFileExists = remoteExists && (await fs.pathExists(file.repoPath));
188
+ // Both missing — skip
189
+ if (!localExists && !remoteFileExists) {
190
+ log.warn(`${file.label}: not found locally or remotely — skipping.`);
191
+ continue;
192
+ }
193
+ // Remote doesn't have it yet — push local
194
+ if (localExists && !remoteFileExists) {
195
+ log.info(`${file.label}: ${color.green('pushing')} (not in remote yet)`);
196
+ await fs.ensureDir(path.dirname(file.repoPath));
197
+ await fs.copy(file.localPath, file.repoPath, { overwrite: true });
198
+ anyChanges = true;
199
+ continue;
200
+ }
201
+ // Local doesn't have it — pull remote
202
+ if (!localExists && remoteFileExists) {
203
+ log.info(`${file.label}: ${color.cyan('pulling')} (not found locally)`);
204
+ if (file.label === 'settings.json') {
205
+ await applyRemoteSettings(file.repoPath, path.join(tmp, 'extensions', 'index.json'), file.localPath, silent);
206
+ }
207
+ else {
208
+ await fs.ensureDir(path.dirname(file.localPath));
209
+ await fs.copy(file.repoPath, file.localPath, { overwrite: true });
210
+ }
211
+ continue;
212
+ }
213
+ // Both exist — compare content
214
+ const localContent = await fs.readFile(file.localPath, 'utf-8');
215
+ const remoteContent = await fs.readFile(file.repoPath, 'utf-8');
216
+ if (localContent === remoteContent) {
217
+ log.success(`${file.label}: ${color.dim('already in sync')}`);
218
+ continue;
219
+ }
220
+ // Detect which side changed since last sync via mtime
221
+ const localMtime = (await fs.stat(file.localPath)).mtime;
222
+ const remoteMtime = remoteFileExists ? (await fs.stat(file.repoPath)).mtime : new Date(0);
223
+ const localChanged = !lastSync || localMtime > lastSync;
224
+ const remoteChanged = !lastSync || remoteMtime > lastSync;
225
+ if (localChanged && !remoteChanged) {
226
+ // Only local changed → push
227
+ log.info(`${file.label}: ${color.green('pushing')} (local is newer)`);
228
+ await fs.ensureDir(path.dirname(file.repoPath));
229
+ await fs.copy(file.localPath, file.repoPath, { overwrite: true });
230
+ anyChanges = true;
231
+ }
232
+ else if (remoteChanged && !localChanged) {
233
+ // Only remote changed → pull
234
+ log.info(`${file.label}: ${color.cyan('pulling')} (remote is newer)`);
235
+ if (file.label === 'settings.json') {
236
+ await applyRemoteSettings(file.repoPath, path.join(tmp, 'extensions', 'index.json'), file.localPath, silent);
237
+ }
238
+ else {
239
+ await fs.ensureDir(path.dirname(file.localPath));
240
+ await fs.copy(file.repoPath, file.localPath, { overwrite: true });
241
+ }
242
+ }
243
+ else {
244
+ // Both changed
245
+ if (silent) {
246
+ // Daemon can't prompt — local wins, will be pushed
247
+ log.warn(`${file.label}: conflict detected in unattended mode — keeping local.`);
248
+ await fs.ensureDir(path.dirname(file.repoPath));
249
+ await fs.copy(file.localPath, file.repoPath, { overwrite: true });
250
+ anyChanges = true;
251
+ }
252
+ else {
253
+ p.log.warn(color.yellow(`${file.label}: both local and remote changed.`));
254
+ const choice = await p.select({
255
+ message: `Which version of ${color.bold(file.label)} should win?`,
256
+ options: [
257
+ {
258
+ value: 'local',
259
+ label: 'Keep local',
260
+ hint: `modified ${localMtime.toLocaleString()}`
261
+ },
262
+ {
263
+ value: 'remote',
264
+ label: 'Use remote',
265
+ hint: `modified ${remoteMtime.toLocaleString()}`
266
+ }
267
+ ]
268
+ });
269
+ if (p.isCancel(choice)) {
270
+ p.cancel('Cancelled.');
271
+ process.exit(0);
272
+ }
273
+ if (choice === 'local') {
274
+ p.log.info(`${file.label}: ${color.green('keeping local, will push')}`);
275
+ await fs.ensureDir(path.dirname(file.repoPath));
276
+ await fs.copy(file.localPath, file.repoPath, { overwrite: true });
277
+ anyChanges = true;
278
+ }
279
+ else {
280
+ p.log.info(`${file.label}: ${color.cyan('applying remote')}`);
281
+ if (file.label === 'settings.json') {
282
+ await applyRemoteSettings(file.repoPath, path.join(tmp, 'extensions', 'index.json'), file.localPath, silent);
283
+ }
284
+ else {
285
+ await fs.ensureDir(path.dirname(file.localPath));
286
+ await fs.copy(file.repoPath, file.localPath, { overwrite: true });
287
+ }
288
+ }
289
+ }
290
+ }
291
+ }
292
+ // 3. Commit + push if any local files were written to the repo
293
+ if (anyChanges) {
294
+ spinner.start('Pushing changes to remote...');
295
+ await git.add(['settings.json', path.join('extensions', 'index.json')]);
296
+ const status = await git.status();
297
+ if (status.staged.length > 0) {
298
+ const timestamp = new Date().toISOString();
299
+ await git.commit(`sync: ${timestamp}`);
300
+ try {
301
+ await git.push('origin', config.branch, ['--set-upstream']);
302
+ }
303
+ catch {
304
+ await git.push('origin', config.branch);
305
+ }
306
+ spinner.stop('Pushed.');
307
+ }
308
+ else {
309
+ spinner.stop('Nothing staged to push.');
310
+ }
311
+ }
312
+ });
313
+ // 4. Save last sync timestamp
314
+ await writeSyncConfig({ ...config, lastSync: new Date().toISOString() });
315
+ if (!silent)
316
+ p.outro(`${color.green('✓')} Sync complete.`);
317
+ }
@@ -1,4 +1,12 @@
1
1
  export type ExtensionType = 'theme' | 'language';
2
+ export interface ZedPaths {
3
+ settings: string;
4
+ extensions: string;
5
+ }
6
+ export interface SyncConfig {
7
+ syncRepo: string;
8
+ branch: string;
9
+ }
2
10
  export type License = 'Apache-2.0' | 'BSD-2-Clause' | 'BSD-3-Clause' | 'GPL-3.0' | 'LGPL-3.0' | 'MIT' | 'Zlib';
3
11
  export interface ExtensionOptions {
4
12
  name: string;
@@ -0,0 +1,2 @@
1
+ import type { ZedPaths } from './types/index.js';
2
+ export declare function resolveZedPaths(): ZedPaths;
@@ -0,0 +1,30 @@
1
+ import os from 'os';
2
+ import path from 'path';
3
+ export function resolveZedPaths() {
4
+ const home = os.homedir();
5
+ const platform = process.platform;
6
+ if (platform === 'darwin') {
7
+ return {
8
+ settings: path.join(home, '.config', 'zed', 'settings.json'),
9
+ extensions: path.join(home, 'Library', 'Application Support', 'Zed', 'extensions', 'index.json'),
10
+ };
11
+ }
12
+ if (platform === 'linux') {
13
+ const xdgData = process.env.FLATPAK_XDG_DATA_HOME ||
14
+ process.env.XDG_DATA_HOME ||
15
+ path.join(home, '.local', 'share');
16
+ return {
17
+ settings: path.join(home, '.config', 'zed', 'settings.json'),
18
+ extensions: path.join(xdgData, 'zed', 'extensions', 'index.json'),
19
+ };
20
+ }
21
+ if (platform === 'win32') {
22
+ const appData = process.env.APPDATA || path.join(home, 'AppData', 'Roaming');
23
+ const localAppData = process.env.LOCALAPPDATA || path.join(home, 'AppData', 'Local');
24
+ return {
25
+ settings: path.join(appData, 'Zed', 'settings.json'),
26
+ extensions: path.join(localAppData, 'Zed', 'extensions', 'index.json'),
27
+ };
28
+ }
29
+ throw new Error(`Unsupported platform: ${platform}`);
30
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "zedx",
3
- "version": "0.3.1",
3
+ "version": "0.5.0",
4
4
  "description": "Boilerplate generator for Zed Edittor extensions.",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -47,7 +47,8 @@
47
47
  "commander": "^14.0.3",
48
48
  "ejs": "^4.0.1",
49
49
  "fs-extra": "^11.3.3",
50
- "picocolors": "^1.1.1"
50
+ "picocolors": "^1.1.1",
51
+ "simple-git": "^3.33.0"
51
52
  },
52
53
  "packageManager": "pnpm@10.30.0+sha512.2b5753de015d480eeb88f5b5b61e0051f05b4301808a82ec8b840c9d2adf7748eb352c83f5c1593ca703ff1017295bc3fdd3119abb9686efc96b9fcb18200937"
53
54
  }