zedx 0.8.1 → 0.10.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,10 @@
1
- # zedx
1
+ <div align="center">
2
2
 
3
- Scaffold [Zed Editor](https://zed.dev/) extensions and sync your settings across machines.
3
+ <img src="./assets/zedx-logo.png" width="300" alt="ZedX Logo" />
4
+
5
+ CLI toolkit for Scaffolding [Zed Editor](https://zed.dev/) extensions and syncing settings across machines.
6
+
7
+ </div>
4
8
 
5
9
  ![screenshot](./assets/screenshot1.png)
6
10
 
@@ -13,7 +17,9 @@ npm install -g zedx
13
17
  brew install tahayvr/tap/zedx
14
18
  ```
15
19
 
16
- ### Usage
20
+ ## Usage
21
+
22
+ ### Scaffolding an extension
17
23
 
18
24
  ```bash
19
25
  # Create a new extension
@@ -22,26 +28,77 @@ zedx create
22
28
  # Add a theme or language to an existing extension
23
29
  zedx add theme "Midnight Blue"
24
30
  zedx add language rust
31
+ ```
32
+
33
+ ### Supported extension types:
34
+
35
+ 1. **Themes** - Color schemes for the editor
36
+ 2. **Languages** - Syntax highlighting, indentation, and optional LSP support
37
+
38
+ You can choose to include theme, language, or both when creating an extension.
39
+
40
+ ### Validation
25
41
 
42
+ ```bash
26
43
  # Validate extension config and show what is missing or incomplete
27
44
  zedx check
45
+ ```
46
+
47
+ ### Sync
48
+
49
+ Sync your Zed config across machines using a private GitHub repo as the source of truth.
50
+
51
+ **1. Link a repo (one-time setup)**
52
+
53
+ ```bash
54
+ zedx sync init
55
+ ```
56
+
57
+ Prompts for a GitHub repo URL (SSH or HTTPS) and a branch name (defaults to `main`). The repo is saved to `~/.config/zedx/config.json`. No files are synced yet.
58
+
59
+ > [!NOTE]
60
+ > `settings.json` and `keymap.json` are tracked. Extension sync is handled via the `auto_install_extensions` field within `settings.json`, which Zed uses to automatically download and install extensions.
61
+
62
+ **2. Run a sync**
28
63
 
29
- # Bump extension version
64
+ ```bash
65
+ zedx sync # Sync local ↔ remote, prompts when both sides changed
66
+ zedx sync --local # Always keep local on conflict (no prompt)
67
+ zedx sync --remote # Always use remote on conflict (no prompt)
68
+ ```
69
+
70
+ **3. Check sync state**
71
+
72
+ ```bash
73
+ zedx sync status
74
+ ```
75
+
76
+ **4. Auto-sync with an OS daemon**
77
+
78
+ ```bash
79
+ zedx sync install # Install and enable the daemon
80
+ zedx sync uninstall # Disable and remove the daemon
81
+ ```
82
+
83
+ Installs a file-watcher that triggers `zedx sync` automatically whenever config files are saved. Supported platforms:
84
+
85
+ | Platform | Mechanism | Logs |
86
+ | -------- | ------------------------------------------------------ | ---------------------------------------- |
87
+ | macOS | launchd (`~/Library/LaunchAgents/dev.zedx.sync.plist`) | `~/Library/Logs/zedx-sync.log` |
88
+ | Linux | systemd user units (`~/.config/systemd/user/`) | `journalctl --user -u zedx-sync.service` |
89
+
90
+ The daemon enforces a 30-second throttle on macOS to avoid rapid re-triggers. When a conflict is detected in daemon mode (no TTY), local always wins and a warning is logged.
91
+
92
+ ### Versioning
93
+
94
+ Bump the extension version:
95
+
96
+ ```bash
30
97
  zedx version patch # 1.2.3 → 1.2.4
31
98
  zedx version minor # 1.2.3 → 1.3.0
32
99
  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 status # Show sync state between local config and the remote repo
38
- zedx sync install # Install an OS daemon to auto-sync when Zed config changes
39
- zedx sync uninstall # Remove the OS daemon
40
100
  ```
41
101
 
42
- ### Supported extension types:
102
+ ### License
43
103
 
44
- 1. **Themes** - Color schemes for the editor
45
- 2. **Languages** - Syntax highlighting, indentation, and optional LSP support
46
-
47
- You can choose to include theme, language, or both when creating an extension.
104
+ License is Apache-2.0. See [LICENSE](./LICENSE) for details.
package/dist/daemon.js CHANGED
@@ -157,7 +157,7 @@ export async function syncInstall() {
157
157
  if (platform !== 'darwin' && platform !== 'linux')
158
158
  unsupportedPlatform();
159
159
  const zedPaths = resolveZedPaths();
160
- const watchPaths = [zedPaths.settings, zedPaths.extensions];
160
+ const watchPaths = [zedPaths.settings, zedPaths.keymap];
161
161
  const zedxBin = resolveZedxBinary();
162
162
  p.log.info(`Binary: ${color.dim(zedxBin)}`);
163
163
  p.log.info(`Watching:`);
package/dist/index.js CHANGED
@@ -1,4 +1,5 @@
1
1
  #!/usr/bin/env node
2
+ import { createRequire } from 'module';
2
3
  import path from 'path';
3
4
  import * as p from '@clack/prompts';
4
5
  import { Command } from 'commander';
@@ -11,7 +12,9 @@ import { generateExtension } from './generator.js';
11
12
  import { installDevExtension } from './install.js';
12
13
  import { promptUser, promptThemeDetails, promptLanguageDetails } from './prompts.js';
13
14
  import { addLsp } from './snippet.js';
14
- import { syncInit, runSync, syncStatus } from './sync.js';
15
+ import { syncInit, runSync, syncStatus, syncSelect } from './sync.js';
16
+ const require = createRequire(import.meta.url);
17
+ const { version } = require('../package.json');
15
18
  function bumpVersion(version, type) {
16
19
  const [major, minor, patch] = version.split('.').map(Number);
17
20
  switch (type) {
@@ -65,6 +68,7 @@ function printWelcome() {
65
68
  ['zedx install', 'Install as a Zed dev extension'],
66
69
  ['zedx version <major|minor|patch>', 'Bump extension version'],
67
70
  ['zedx sync', 'Sync Zed settings via a git repo'],
71
+ ['zedx sync select', 'Choose which files to sync interactively'],
68
72
  ['zedx sync init', 'Link a git repo as the sync target'],
69
73
  ['zedx sync status', 'Show sync state between local and remote'],
70
74
  ['zedx sync install', 'Install the OS daemon for auto-sync'],
@@ -74,7 +78,7 @@ function printWelcome() {
74
78
  for (const [cmd, desc] of commands) {
75
79
  console.log(` ${color.cyan(cmd.padEnd(38))}${color.dim(desc)}`);
76
80
  }
77
- console.log(`\n ${color.dim('Docs:')} ${color.underline(color.blue('https://zed.dev/docs/extensions'))}\n`);
81
+ console.log(`\n ${color.dim('Zed Docs:')} ${color.underline(color.blue('https://zed.dev/docs/extensions'))}\n`);
78
82
  }
79
83
  async function runCreate() {
80
84
  const options = await promptUser();
@@ -99,7 +103,7 @@ async function runCreate() {
99
103
  }
100
104
  async function main() {
101
105
  const program = new Command();
102
- program.name('zedx').description('The CLI toolkit for Zed Editor.').helpOption(false);
106
+ program.name('zedx').description('The CLI toolkit for Zed Editor.').version(`zedx v${version}`);
103
107
  program
104
108
  .command('create')
105
109
  .description('Scaffold a new Zed extension')
@@ -156,8 +160,19 @@ async function main() {
156
160
  const syncCmd = program
157
161
  .command('sync')
158
162
  .description('Sync Zed settings and extensions via a GitHub repo')
159
- .action(async () => {
160
- await runSync();
163
+ .option('--local', 'On conflict, always keep the local version')
164
+ .option('--remote', 'On conflict, always use the remote version')
165
+ .action(async (opts) => {
166
+ if (opts.local && opts.remote) {
167
+ p.log.error(color.red('--local and --remote are mutually exclusive.'));
168
+ process.exit(1);
169
+ }
170
+ const conflict = opts.local
171
+ ? 'local'
172
+ : opts.remote
173
+ ? 'remote'
174
+ : 'prompt';
175
+ await runSync({ conflict });
161
176
  });
162
177
  syncCmd
163
178
  .command('init')
@@ -171,6 +186,12 @@ async function main() {
171
186
  .action(async () => {
172
187
  await syncStatus();
173
188
  });
189
+ syncCmd
190
+ .command('select')
191
+ .description('Interactively choose which files to sync')
192
+ .action(async () => {
193
+ await syncSelect();
194
+ });
174
195
  syncCmd
175
196
  .command('install')
176
197
  .description('Install the OS daemon to auto-sync when Zed config changes')
@@ -183,10 +204,11 @@ async function main() {
183
204
  .action(async () => {
184
205
  await syncUninstall();
185
206
  });
186
- if (process.argv.length <= 2) {
207
+ const argv = process.argv.filter(arg => arg !== '--');
208
+ if (argv.length <= 2) {
187
209
  printWelcome();
188
210
  return;
189
211
  }
190
- program.parse(process.argv);
212
+ program.parse(argv);
191
213
  }
192
214
  main().catch(console.error);
package/dist/snippet.js CHANGED
@@ -165,5 +165,5 @@ export async function addLsp(callerDir) {
165
165
  p.outro(`${color.green('✓')} LSP snippet added.\n\n` +
166
166
  ` ${color.dim('1.')} Edit ${color.cyan('src/lib.rs')} — implement ${color.white('language_server_command')}\n` +
167
167
  ` ${color.dim('2.')} Edit ${color.cyan('Cargo.toml')} — pin ${color.white('zed_extension_api')} to latest version\n` +
168
- ` ${color.dim('3.')} ${color.dim('Docs:')} ${color.underline(color.blue('https://zed.dev/docs/extensions/languages#language-servers'))}`);
168
+ ` ${color.dim('3.')} ${color.dim('Zed Docs:')} ${color.underline(color.blue('https://zed.dev/docs/extensions/languages#language-servers'))}`);
169
169
  }
package/dist/sync.d.ts CHANGED
@@ -1,5 +1,9 @@
1
1
  export declare function syncStatus(): Promise<void>;
2
2
  export declare function syncInit(): Promise<void>;
3
+ export type ConflictStrategy = 'local' | 'remote' | 'prompt';
4
+ export declare function syncSelect(): Promise<void>;
3
5
  export declare function runSync(opts?: {
4
6
  silent?: boolean;
7
+ conflict?: ConflictStrategy;
8
+ selectedFiles?: string[];
5
9
  }): Promise<void>;
package/dist/sync.js CHANGED
@@ -37,76 +37,66 @@ async function withTempDir(fn) {
37
37
  await fs.remove(tmp);
38
38
  }
39
39
  }
40
- // Prepare settings.json for pushing to the repo by stripping auto_install_extensions.
41
- // That field is derived from extensions/index.json on pull, so storing it in the
42
- // remote would create stale/conflicting data across machines.
43
- async function prepareSettingsForPush(localSettingsPath, repoSettingsPath) {
44
- const raw = await fs.readFile(localSettingsPath, 'utf-8');
45
- const stripped = raw.replace(/\/\/[^\n]*/g, '');
46
- let settingsObj = {};
47
- try {
48
- settingsObj = JSON.parse(stripped);
49
- }
50
- catch {
51
- // If we can't parse it (e.g. complex comments), push as-is
52
- await fs.ensureDir(path.dirname(repoSettingsPath));
53
- await fs.copy(localSettingsPath, repoSettingsPath, { overwrite: true });
40
+ // Before every push, reconcile auto_install_extensions in settings.json against
41
+ // the live extensions/index.json so that:
42
+ // - newly installed extensions (present in index, missing from the list) are added as true
43
+ // - uninstalled extensions (absent from index, set to true in the list) are removed
44
+ // - entries explicitly set to false by the user are always preserved (user intent)
45
+ async function reconcileAutoInstallExtensions(localSettingsPath, localExtensionsIndexPath, silent = false) {
46
+ if (!(await fs.pathExists(localExtensionsIndexPath)))
54
47
  return;
55
- }
56
- delete settingsObj['auto_install_extensions'];
57
- await fs.ensureDir(path.dirname(repoSettingsPath));
58
- await fs.writeFile(repoSettingsPath, JSON.stringify(settingsObj, null, 4), 'utf-8');
59
- }
60
- // Extension merge helper
61
- async function applyRemoteSettings(repoSettings, repoExtensions, localSettingsPath, silent = false) {
62
- // Backup existing settings
48
+ let settingsObj = {};
63
49
  if (await fs.pathExists(localSettingsPath)) {
64
- await fs.copy(localSettingsPath, localSettingsPath + '.bak', { overwrite: true });
65
- if (!silent)
66
- p.log.info(`Backed up settings to ${color.dim(localSettingsPath + '.bak')}`);
67
- }
68
- let settingsJson = await fs.readFile(repoSettings, 'utf-8');
69
- // Merge auto_install_extensions from index.json into settings
70
- if (await fs.pathExists(repoExtensions)) {
71
50
  try {
72
- const indexJson = (await fs.readJson(repoExtensions));
73
- const extensionIds = Object.keys(indexJson.extensions ?? {}).filter(id => !indexJson.extensions[id]?.dev);
74
- if (extensionIds.length > 0) {
75
- const stripped = settingsJson.replace(/\/\/[^\n]*/g, '');
76
- let settingsObj = {};
77
- try {
78
- settingsObj = JSON.parse(stripped);
79
- }
80
- catch {
81
- if (!silent)
82
- p.log.warn(color.yellow('Could not parse settings.json — skipping extension merge.'));
83
- }
84
- // Preserve any existing entries (e.g. false entries for "never install"),
85
- // then add true for every extension recorded in index.json.
86
- const existing = typeof settingsObj['auto_install_extensions'] === 'object' &&
87
- settingsObj['auto_install_extensions'] !== null
88
- ? settingsObj['auto_install_extensions']
89
- : {};
90
- const autoInstall = { ...existing };
91
- for (const id of extensionIds) {
92
- // Only set to true if there is no explicit user preference already
93
- if (!(id in autoInstall)) {
94
- autoInstall[id] = true;
95
- }
96
- }
97
- settingsObj['auto_install_extensions'] = autoInstall;
98
- settingsJson = JSON.stringify(settingsObj, null, 4);
99
- if (!silent)
100
- p.log.info(`Injected ${color.cyan(String(extensionIds.length))} extension(s) into ${color.dim('auto_install_extensions')}`);
101
- }
51
+ const raw = await fs.readFile(localSettingsPath, 'utf-8');
52
+ settingsObj = JSON.parse(raw.replace(/\/\/[^\n]*/g, ''));
102
53
  }
103
54
  catch {
104
55
  if (!silent)
105
- p.log.warn(color.yellow('Could not parse extensions/index.json — skipping extension merge.'));
56
+ p.log.warn(color.yellow('Could not parse settings.json — skipping extension reconciliation.'));
57
+ return;
106
58
  }
107
59
  }
60
+ let installedIds = [];
61
+ try {
62
+ const indexJson = (await fs.readJson(localExtensionsIndexPath));
63
+ installedIds = Object.keys(indexJson.extensions ?? {}).filter(id => !indexJson.extensions[id]?.dev);
64
+ }
65
+ catch {
66
+ if (!silent)
67
+ p.log.warn(color.yellow('Could not parse extensions/index.json — skipping extension reconciliation.'));
68
+ return;
69
+ }
70
+ const existing = typeof settingsObj['auto_install_extensions'] === 'object' &&
71
+ settingsObj['auto_install_extensions'] !== null
72
+ ? settingsObj['auto_install_extensions']
73
+ : {};
74
+ const installedSet = new Set(installedIds);
75
+ const reconciled = {};
76
+ // Keep all explicit false entries (user said "never install this")
77
+ for (const [id, val] of Object.entries(existing)) {
78
+ if (val === false)
79
+ reconciled[id] = false;
80
+ }
81
+ // Add every currently installed extension as true
82
+ for (const id of installedIds) {
83
+ reconciled[id] = true;
84
+ }
85
+ // Drop true entries for extensions no longer installed
86
+ // (already handled — we only re-add what's in installedSet above)
87
+ const added = installedIds.filter(id => !(id in existing));
88
+ const removed = Object.keys(existing).filter(id => existing[id] === true && !installedSet.has(id));
89
+ if (added.length === 0 && removed.length === 0)
90
+ return;
91
+ settingsObj['auto_install_extensions'] = reconciled;
108
92
  await fs.ensureDir(path.dirname(localSettingsPath));
109
- await fs.writeFile(localSettingsPath, settingsJson, 'utf-8');
93
+ await fs.writeFile(localSettingsPath, JSON.stringify(settingsObj, null, 4), 'utf-8');
94
+ if (!silent) {
95
+ if (added.length > 0)
96
+ p.log.info(`Added ${color.cyan(String(added.length))} new extension(s) to ${color.dim('auto_install_extensions')}: ${added.join(', ')}`);
97
+ if (removed.length > 0)
98
+ p.log.info(`Removed ${color.cyan(String(removed.length))} uninstalled extension(s) from ${color.dim('auto_install_extensions')}: ${removed.join(', ')}`);
99
+ }
110
100
  }
111
101
  // zedx sync status
112
102
  export async function syncStatus() {
@@ -138,14 +128,18 @@ export async function syncStatus() {
138
128
  {
139
129
  repoPath: path.join(tmp, 'settings.json'),
140
130
  localPath: zedPaths.settings,
141
- label: 'settings.json',
131
+ label: 'Settings',
142
132
  },
143
133
  {
144
- repoPath: path.join(tmp, 'extensions', 'index.json'),
145
- localPath: zedPaths.extensions,
146
- label: 'extensions/index.json',
134
+ repoPath: path.join(tmp, 'keymap.json'),
135
+ localPath: zedPaths.keymap,
136
+ label: 'Key bindings',
147
137
  },
148
138
  ];
139
+ // Track per-file actions needed for the outro message
140
+ const toPush = [];
141
+ const toPull = [];
142
+ const toResolve = [];
149
143
  for (const file of files) {
150
144
  const localExists = await fs.pathExists(file.localPath);
151
145
  const remoteFileExists = remoteExists && (await fs.pathExists(file.repoPath));
@@ -155,10 +149,12 @@ export async function syncStatus() {
155
149
  }
156
150
  if (localExists && !remoteFileExists) {
157
151
  p.log.warn(`${color.bold(file.label)}: ${color.green('local only')} — not pushed yet`);
152
+ toPush.push(file.label);
158
153
  continue;
159
154
  }
160
155
  if (!localExists && remoteFileExists) {
161
156
  p.log.warn(`${color.bold(file.label)}: ${color.cyan('remote only')} — not pulled yet`);
157
+ toPull.push(file.label);
162
158
  continue;
163
159
  }
164
160
  const localContent = await fs.readFile(file.localPath, 'utf-8');
@@ -174,16 +170,34 @@ export async function syncStatus() {
174
170
  const remoteChanged = !lastSync || remoteMtime > lastSync;
175
171
  if (localChanged && !remoteChanged) {
176
172
  p.log.warn(`${color.bold(file.label)}: ${color.green('local ahead')} — modified ${color.dim(localMtime.toLocaleString())}`);
173
+ toPush.push(file.label);
177
174
  }
178
175
  else if (remoteChanged && !localChanged) {
179
176
  p.log.warn(`${color.bold(file.label)}: ${color.cyan('remote ahead')} — modified ${color.dim(remoteMtime.toLocaleString())}`);
177
+ toPull.push(file.label);
180
178
  }
181
179
  else {
182
180
  p.log.warn(`${color.bold(file.label)}: ${color.yellow('conflict')} — both changed since last sync`);
181
+ toResolve.push(file.label);
183
182
  }
184
183
  }
184
+ const singleAction = [toPush, toPull, toResolve].filter(a => a.length > 0).length === 1;
185
+ const needsSync = toPush.length > 0 || toPull.length > 0 || toResolve.length > 0;
186
+ if (needsSync) {
187
+ if (singleAction && toPush.length > 0) {
188
+ p.outro(`Run ${color.cyan('zedx sync')} to push ${toPush.map(l => color.bold(l)).join(', ')} to remote.`);
189
+ }
190
+ else if (singleAction && toPull.length > 0) {
191
+ p.outro(`Run ${color.cyan('zedx sync')} to pull ${toPull.map(l => color.bold(l)).join(', ')} from remote.`);
192
+ }
193
+ else {
194
+ p.outro(`Run ${color.cyan('zedx sync')} to resolve.`);
195
+ }
196
+ }
197
+ else {
198
+ p.outro('Everything is in sync.');
199
+ }
185
200
  });
186
- p.outro(`Run ${color.cyan('zedx sync')} to resolve.`);
187
201
  }
188
202
  // zedx sync init
189
203
  export async function syncInit() {
@@ -231,9 +245,38 @@ export async function syncInit() {
231
245
  p.outro(`${color.green('✓')} Sync config saved to ${color.cyan(ZEDX_CONFIG_PATH)}\n\n` +
232
246
  ` Run ${color.cyan('zedx sync')} to sync your Zed config.`);
233
247
  }
248
+ // zedx sync select
249
+ export async function syncSelect() {
250
+ console.log('');
251
+ p.intro(`${color.bgBlue(color.bold(' zedx sync select '))} ${color.blue('Choose which files to sync…')}`);
252
+ await requireSyncConfig();
253
+ const allFiles = [
254
+ {
255
+ value: 'settings',
256
+ label: 'Settings',
257
+ hint: 'settings.json',
258
+ },
259
+ {
260
+ value: 'keymap',
261
+ label: 'Key bindings',
262
+ hint: 'keymap.json',
263
+ },
264
+ ];
265
+ const selected = await p.multiselect({
266
+ message: 'Select files to sync',
267
+ options: allFiles,
268
+ required: true,
269
+ });
270
+ if (p.isCancel(selected)) {
271
+ p.cancel('Cancelled.');
272
+ process.exit(0);
273
+ }
274
+ const selectedFiles = selected;
275
+ await runSync({ selectedFiles });
276
+ }
234
277
  // zedx sync
235
278
  export async function runSync(opts = {}) {
236
- const { silent = false } = opts;
279
+ const { silent = false, conflict = 'prompt', selectedFiles } = opts;
237
280
  // In silent mode (daemon/watch), route all UI through plain console.log
238
281
  // Interactive conflict prompts fall back to "local wins".
239
282
  const log = {
@@ -254,7 +297,7 @@ export async function runSync(opts = {}) {
254
297
  };
255
298
  if (!silent) {
256
299
  console.log('');
257
- p.intro(`${color.bgBlue(color.bold(' zedx sync '))} ${color.blue('Syncing Zed settings and extensions…')}`);
300
+ p.intro(`${color.bgBlue(color.bold(' zedx sync '))} ${color.blue('Syncing Zed config…')}`);
258
301
  }
259
302
  const config = await requireSyncConfig();
260
303
  const zedPaths = resolveZedPaths();
@@ -282,18 +325,23 @@ export async function runSync(opts = {}) {
282
325
  }
283
326
  // 2. Determine what changed for each file
284
327
  const lastSync = config.lastSync ? new Date(config.lastSync) : null;
285
- const files = [
328
+ const allFiles = [
286
329
  {
330
+ key: 'settings',
287
331
  repoPath: path.join(tmp, 'settings.json'),
288
332
  localPath: zedPaths.settings,
289
- label: 'settings.json',
333
+ label: 'Settings',
290
334
  },
291
335
  {
292
- repoPath: path.join(tmp, 'extensions', 'index.json'),
293
- localPath: zedPaths.extensions,
294
- label: 'extensions/index.json',
336
+ key: 'keymap',
337
+ repoPath: path.join(tmp, 'keymap.json'),
338
+ localPath: zedPaths.keymap,
339
+ label: 'Key bindings',
295
340
  },
296
341
  ];
342
+ const files = selectedFiles
343
+ ? allFiles.filter(f => selectedFiles.includes(f.key))
344
+ : allFiles;
297
345
  let anyChanges = false;
298
346
  for (const file of files) {
299
347
  const localExists = await fs.pathExists(file.localPath);
@@ -303,29 +351,27 @@ export async function runSync(opts = {}) {
303
351
  log.warn(`${file.label}: not found locally or remotely — skipping.`);
304
352
  continue;
305
353
  }
306
- // Remote doesn't have it yet — push local
354
+ // Remote doesn't have it yet — first push.
355
+ // Bootstrap auto_install_extensions from local extensions/index.json so
356
+ // the synced settings.json is self-contained on a fresh machine.
307
357
  if (localExists && !remoteFileExists) {
308
358
  log.info(`${file.label}: ${color.green('pushing')} (not in remote yet)`);
309
- if (file.label === 'settings.json') {
310
- await prepareSettingsForPush(file.localPath, file.repoPath);
311
- }
312
- else {
313
- await fs.ensureDir(path.dirname(file.repoPath));
314
- await fs.copy(file.localPath, file.repoPath, { overwrite: true });
315
- }
359
+ await reconcileAutoInstallExtensions(file.localPath, zedPaths.extensionsIndex, silent);
360
+ await fs.ensureDir(path.dirname(file.repoPath));
361
+ await fs.copy(file.localPath, file.repoPath, { overwrite: true });
316
362
  anyChanges = true;
317
363
  continue;
318
364
  }
319
365
  // Local doesn't have it — pull remote
320
366
  if (!localExists && remoteFileExists) {
321
367
  log.info(`${file.label}: ${color.cyan('pulling')} (not found locally)`);
322
- if (file.label === 'settings.json') {
323
- await applyRemoteSettings(file.repoPath, path.join(tmp, 'extensions', 'index.json'), file.localPath, silent);
324
- }
325
- else {
326
- await fs.ensureDir(path.dirname(file.localPath));
327
- await fs.copy(file.repoPath, file.localPath, { overwrite: true });
368
+ if (await fs.pathExists(file.localPath)) {
369
+ await fs.copy(file.localPath, file.localPath + '.bak', { overwrite: true });
370
+ if (!silent)
371
+ p.log.info(`Backed up settings to ${color.dim(file.localPath + '.bak')}`);
328
372
  }
373
+ await fs.ensureDir(path.dirname(file.localPath));
374
+ await fs.copy(file.repoPath, file.localPath, { overwrite: true });
329
375
  continue;
330
376
  }
331
377
  // Both exist — compare content
@@ -343,41 +389,36 @@ export async function runSync(opts = {}) {
343
389
  const localChanged = !lastSync || localMtime > lastSync;
344
390
  const remoteChanged = !lastSync || remoteMtime > lastSync;
345
391
  if (localChanged && !remoteChanged) {
346
- // Only local changed → push
392
+ // Only local changed → reconcile extensions then push
347
393
  log.info(`${file.label}: ${color.green('pushing')} (local is newer)`);
348
- if (file.label === 'settings.json') {
349
- await prepareSettingsForPush(file.localPath, file.repoPath);
350
- }
351
- else {
352
- await fs.ensureDir(path.dirname(file.repoPath));
353
- await fs.copy(file.localPath, file.repoPath, { overwrite: true });
354
- }
394
+ await reconcileAutoInstallExtensions(file.localPath, zedPaths.extensionsIndex, silent);
395
+ await fs.ensureDir(path.dirname(file.repoPath));
396
+ await fs.copy(file.localPath, file.repoPath, { overwrite: true });
355
397
  anyChanges = true;
356
398
  }
357
399
  else if (remoteChanged && !localChanged) {
358
400
  // Only remote changed → pull
359
401
  log.info(`${file.label}: ${color.cyan('pulling')} (remote is newer)`);
360
- if (file.label === 'settings.json') {
361
- await applyRemoteSettings(file.repoPath, path.join(tmp, 'extensions', 'index.json'), file.localPath, silent);
362
- }
363
- else {
364
- await fs.ensureDir(path.dirname(file.localPath));
365
- await fs.copy(file.repoPath, file.localPath, { overwrite: true });
366
- }
402
+ await fs.copy(file.localPath, file.localPath + '.bak', { overwrite: true });
403
+ if (!silent)
404
+ p.log.info(`Backed up settings to ${color.dim(file.localPath + '.bak')}`);
405
+ await fs.copy(file.repoPath, file.localPath, { overwrite: true });
367
406
  }
368
407
  else {
369
- // Both changed
370
- if (silent) {
408
+ // Both changed — resolve based on strategy
409
+ // Determine the effective resolution:
410
+ // - explicit --local / --remote flag always wins
411
+ // - silent (daemon) mode falls back to local
412
+ // - otherwise prompt interactively
413
+ let resolution;
414
+ if (conflict === 'local' || conflict === 'remote') {
415
+ resolution = conflict;
416
+ log.warn(`${file.label}: conflict — using ${color.bold(resolution)} (--${resolution} flag).`);
417
+ }
418
+ else if (silent) {
371
419
  // Daemon can't prompt — local wins, will be pushed
420
+ resolution = 'local';
372
421
  log.warn(`${file.label}: conflict detected in unattended mode — keeping local.`);
373
- if (file.label === 'settings.json') {
374
- await prepareSettingsForPush(file.localPath, file.repoPath);
375
- }
376
- else {
377
- await fs.ensureDir(path.dirname(file.repoPath));
378
- await fs.copy(file.localPath, file.repoPath, { overwrite: true });
379
- }
380
- anyChanges = true;
381
422
  }
382
423
  else {
383
424
  p.log.warn(color.yellow(`conflict between local and remote ${file.label}`));
@@ -400,34 +441,30 @@ export async function runSync(opts = {}) {
400
441
  p.cancel('Cancelled.');
401
442
  process.exit(0);
402
443
  }
403
- if (choice === 'local') {
444
+ resolution = choice;
445
+ }
446
+ if (resolution === 'local') {
447
+ if (!silent && conflict === 'prompt')
404
448
  p.log.info(`${file.label}: ${color.green('keeping local, will push')}`);
405
- if (file.label === 'settings.json') {
406
- await prepareSettingsForPush(file.localPath, file.repoPath);
407
- }
408
- else {
409
- await fs.ensureDir(path.dirname(file.repoPath));
410
- await fs.copy(file.localPath, file.repoPath, { overwrite: true });
411
- }
412
- anyChanges = true;
413
- }
414
- else {
449
+ await reconcileAutoInstallExtensions(file.localPath, zedPaths.extensionsIndex, silent);
450
+ await fs.ensureDir(path.dirname(file.repoPath));
451
+ await fs.copy(file.localPath, file.repoPath, { overwrite: true });
452
+ anyChanges = true;
453
+ }
454
+ else {
455
+ if (!silent && conflict === 'prompt')
415
456
  p.log.info(`${file.label}: ${color.cyan('applying remote')}`);
416
- if (file.label === 'settings.json') {
417
- await applyRemoteSettings(file.repoPath, path.join(tmp, 'extensions', 'index.json'), file.localPath, silent);
418
- }
419
- else {
420
- await fs.ensureDir(path.dirname(file.localPath));
421
- await fs.copy(file.repoPath, file.localPath, { overwrite: true });
422
- }
423
- }
457
+ await fs.copy(file.localPath, file.localPath + '.bak', { overwrite: true });
458
+ if (!silent)
459
+ p.log.info(`Backed up settings to ${color.dim(file.localPath + '.bak')}`);
460
+ await fs.copy(file.repoPath, file.localPath, { overwrite: true });
424
461
  }
425
462
  }
426
463
  }
427
464
  // 3. Commit + push if any local files were written to the repo
428
465
  if (anyChanges) {
429
466
  spinner.start('Pushing changes to remote...');
430
- await git.add(['settings.json', path.join('extensions', 'index.json')]);
467
+ await git.add(files.map(f => path.basename(f.repoPath)));
431
468
  const status = await git.status();
432
469
  if (status.staged.length > 0) {
433
470
  const timestamp = new Date().toISOString();
@@ -1,7 +1,8 @@
1
1
  export type ExtensionType = 'theme' | 'language';
2
2
  export interface ZedPaths {
3
3
  settings: string;
4
- extensions: string;
4
+ keymap: string;
5
+ extensionsIndex: string;
5
6
  }
6
7
  export interface SyncConfig {
7
8
  syncRepo: string;
package/dist/zed-paths.js CHANGED
@@ -4,26 +4,32 @@ export function resolveZedPaths() {
4
4
  const home = os.homedir();
5
5
  const platform = process.platform;
6
6
  if (platform === 'darwin') {
7
+ const configDir = path.join(home, '.config', 'zed');
7
8
  return {
8
- settings: path.join(home, '.config', 'zed', 'settings.json'),
9
- extensions: path.join(home, 'Library', 'Application Support', 'Zed', 'extensions', 'index.json'),
9
+ settings: path.join(configDir, 'settings.json'),
10
+ keymap: path.join(configDir, 'keymap.json'),
11
+ extensionsIndex: path.join(home, 'Library', 'Application Support', 'Zed', 'extensions', 'index.json'),
10
12
  };
11
13
  }
12
14
  if (platform === 'linux') {
13
15
  const xdgData = process.env.FLATPAK_XDG_DATA_HOME ||
14
16
  process.env.XDG_DATA_HOME ||
15
17
  path.join(home, '.local', 'share');
18
+ const configDir = path.join(home, '.config', 'zed');
16
19
  return {
17
- settings: path.join(home, '.config', 'zed', 'settings.json'),
18
- extensions: path.join(xdgData, 'zed', 'extensions', 'index.json'),
20
+ settings: path.join(configDir, 'settings.json'),
21
+ keymap: path.join(configDir, 'keymap.json'),
22
+ extensionsIndex: path.join(xdgData, 'zed', 'extensions', 'index.json'),
19
23
  };
20
24
  }
21
25
  if (platform === 'win32') {
22
26
  const appData = process.env.APPDATA || path.join(home, 'AppData', 'Roaming');
23
27
  const localAppData = process.env.LOCALAPPDATA || path.join(home, 'AppData', 'Local');
28
+ const configDir = path.join(appData, 'Zed');
24
29
  return {
25
- settings: path.join(appData, 'Zed', 'settings.json'),
26
- extensions: path.join(localAppData, 'Zed', 'extensions', 'index.json'),
30
+ settings: path.join(configDir, 'settings.json'),
31
+ keymap: path.join(configDir, 'keymap.json'),
32
+ extensionsIndex: path.join(localAppData, 'Zed', 'extensions', 'index.json'),
27
33
  };
28
34
  }
29
35
  throw new Error(`Unsupported platform: ${platform}`);
package/package.json CHANGED
@@ -1,11 +1,13 @@
1
1
  {
2
2
  "name": "zedx",
3
- "version": "0.8.1",
4
- "description": "Scaffold Zed Editor extensions and sync your settings across machines.",
3
+ "version": "0.10.0",
4
+ "description": "The CLI toolkit for Zed Editor.",
5
5
  "keywords": [
6
6
  "boilerplate",
7
7
  "extension",
8
8
  "scaffold",
9
+ "settings",
10
+ "sync",
9
11
  "zed",
10
12
  "zed-editor"
11
13
  ],