editor-profile-sync 1.0.4 → 1.0.6

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
@@ -9,6 +9,7 @@ You can sync:
9
9
  - extensions
10
10
  - snippets
11
11
  - `settings.json`
12
+ - `keybindings.json`
12
13
 
13
14
  ## Requirements
14
15
 
@@ -67,8 +68,9 @@ npm install
67
68
  2. Choose a source editor.
68
69
  3. Choose what to share:
69
70
  - `Extensions`
70
- - `settings.json`
71
71
  - `Snippets`
72
+ - `settings.json`
73
+ - `keybindings.json`
72
74
  4. Choose mode(s) for selected item types:
73
75
  - Extensions:
74
76
  - Install on top of existing (additive)
@@ -123,6 +125,48 @@ Result:
123
125
  }
124
126
  ```
125
127
 
128
+ ### Keybindings merge behavior
129
+
130
+ For `keybindings.json`, keybindings are merged intelligently:
131
+
132
+ - Source keybindings override target ones with the same `key` + `command` combination
133
+ - Existing target keybindings that don't conflict are preserved
134
+ - New source keybindings are added to the target
135
+
136
+ Example:
137
+
138
+ Target:
139
+
140
+ ```json
141
+ [{ "key": "ctrl+k", "command": "workbench.action.terminal.clear" }]
142
+ ```
143
+
144
+ Source:
145
+
146
+ ```json
147
+ [
148
+ {
149
+ "key": "ctrl+k",
150
+ "command": "workbench.action.terminal.clear",
151
+ "when": "terminalFocus"
152
+ },
153
+ { "key": "ctrl+shift+p", "command": "workbench.action.showCommands" }
154
+ ]
155
+ ```
156
+
157
+ Result:
158
+
159
+ ```json
160
+ [
161
+ {
162
+ "key": "ctrl+k",
163
+ "command": "workbench.action.terminal.clear",
164
+ "when": "terminalFocus"
165
+ },
166
+ { "key": "ctrl+shift+p", "command": "workbench.action.showCommands" }
167
+ ]
168
+ ```
169
+
126
170
  ## Supported editors
127
171
 
128
172
  These editors are currently supported:
@@ -140,10 +184,6 @@ These editors are currently supported:
140
184
 
141
185
  **Windows/Linux**: Each editor must be installed and its CLI available in your terminal (e.g. `code`, `cursor`).
142
186
 
143
- ## Generated files
144
-
145
- - `extensions.txt` is created when syncing extensions and contains the exported source extension IDs.
146
-
147
187
  ## License
148
188
 
149
189
  MIT
package/index.js CHANGED
@@ -9,19 +9,22 @@ import updateNotifier from "update-notifier";
9
9
  import {
10
10
  EDITORS,
11
11
  EXTENSION_MODES,
12
- EXTENSIONS_FILE,
13
12
  SNIPPET_MODES,
14
13
  SYNC_ITEMS,
15
14
  } from "./lib/constants.js";
16
15
  import { isEditorInstalled } from "./lib/editor-cli.js";
16
+ import { exportExtensions, syncExtensions } from "./lib/extensions-sync.js";
17
17
  import {
18
- exportExtensions,
19
- readExtensionsFile,
20
- syncExtensions,
21
- } from "./lib/extensions-sync.js";
22
- import { getSettingsPath, getSnippetsPath } from "./lib/profile-paths.js";
18
+ getSettingsPath,
19
+ getSnippetsPath,
20
+ getKeybindingsPath,
21
+ } from "./lib/profile-paths.js";
23
22
  import { readSourceSettings, syncSettings } from "./lib/settings-sync.js";
24
23
  import { syncSnippets } from "./lib/snippets-sync.js";
24
+ import {
25
+ readSourceKeybindings,
26
+ syncKeybindings,
27
+ } from "./lib/keybindings-sync.js";
25
28
  import {
26
29
  promptExtensionMode,
27
30
  promptSnippetMode,
@@ -96,7 +99,7 @@ async function main() {
96
99
  }
97
100
 
98
101
  detectSpinner.succeed(
99
- `Found ${availableEditors.length} editor(s): ${availableEditors
102
+ `Found ${availableEditors.length} editor${availableEditors.length === 1 ? "" : "s"}: ${availableEditors
100
103
  .map((e) => e.name)
101
104
  .join(", ")}`,
102
105
  );
@@ -133,9 +136,6 @@ async function main() {
133
136
  targetIds.includes(e.id),
134
137
  );
135
138
 
136
- const cwd = process.cwd();
137
- const extensionsPath = join(cwd, EXTENSIONS_FILE);
138
-
139
139
  let desiredExtensions = [];
140
140
  if (syncItems.includes("extensions")) {
141
141
  const exportSpinner = ora({
@@ -143,11 +143,8 @@ async function main() {
143
143
  color: "cyan",
144
144
  }).start();
145
145
  try {
146
- await exportExtensions(sourceEditor, extensionsPath);
147
- desiredExtensions = readExtensionsFile(extensionsPath);
148
- exportSpinner.succeed(
149
- `Exported ${desiredExtensions.length} extensions to ${EXTENSIONS_FILE}`,
150
- );
146
+ desiredExtensions = await exportExtensions(sourceEditor);
147
+ exportSpinner.succeed(`${desiredExtensions.length} extensions detected`);
151
148
  } catch (err) {
152
149
  exportSpinner.fail("Extension export failed");
153
150
  console.error(" Error:", err.message, "\n");
@@ -176,58 +173,43 @@ async function main() {
176
173
  }
177
174
  }
178
175
 
176
+ let sourceKeybindings = [];
177
+ if (syncItems.includes("keybindings")) {
178
+ try {
179
+ sourceKeybindings = readSourceKeybindings(
180
+ getKeybindingsPath(sourceEditor),
181
+ );
182
+ } catch (err) {
183
+ console.error(` ${err.message}\n`);
184
+ process.exit(1);
185
+ }
186
+ }
187
+
179
188
  console.log("");
180
189
 
181
190
  for (const editor of targetEditors) {
182
191
  try {
183
- console.log(chalk.bold(editor.name));
184
-
185
- if (syncItems.includes("extensions")) {
186
- const spinner = ora({
187
- text: `${chalk.bold(editor.name)}: Installing extensions...`,
188
- color: "cyan",
189
- }).start();
190
- const result = await syncExtensions(
191
- editor,
192
- desiredExtensions,
193
- extensionMode,
194
- (i, extensionId) => {
195
- spinner.text = `${chalk.bold(editor.name)}: [${i + 1}/${desiredExtensions.length}] Installing ${extensionId}`;
196
- },
197
- );
198
- const parts = [chalk.green(`${result.synced} extensions synced`)];
199
- if (result.failed.length > 0) {
200
- parts.push(chalk.red(`${result.failed.length} failed`));
201
- }
202
- spinner.succeed(`${chalk.bold(editor.name)}: ${parts.join(", ")}`);
203
- if (result.failed.length > 0) {
204
- console.log(
205
- ` ${chalk.red("Failed:")} ${result.failed.join(", ")}`,
206
- );
207
- }
208
- }
192
+ console.log(chalk.bold(editor.name) + ":");
209
193
 
210
194
  if (syncItems.includes("settings")) {
211
195
  const spinner = ora({
212
- text: `${chalk.bold(editor.name)}: Syncing settings.json...`,
196
+ text: "Syncing settings.json...",
213
197
  color: "cyan",
214
198
  }).start();
215
199
  try {
216
200
  const targetPath = getSettingsPath(editor, { createIfMissing: true });
217
201
  const merged = syncSettings(sourceSettings, targetPath);
218
202
  spinner.succeed(
219
- `${chalk.bold(editor.name)}: settings.json synced (${Object.keys(merged).length} keys)`,
203
+ `settings.json synced (${Object.keys(merged).length} keys)`,
220
204
  );
221
205
  } catch (err) {
222
- spinner.fail(
223
- `${chalk.bold(editor.name)}: settings sync failed (${err.message})`,
224
- );
206
+ spinner.fail(`settings sync failed (${err.message})`);
225
207
  }
226
208
  }
227
209
 
228
210
  if (syncItems.includes("snippets")) {
229
211
  const spinner = ora({
230
- text: `${chalk.bold(editor.name)}: Syncing snippets (${snippetMode})...`,
212
+ text: `Syncing snippets (${snippetMode} mode)...`,
231
213
  color: "cyan",
232
214
  }).start();
233
215
  try {
@@ -240,12 +222,52 @@ async function main() {
240
222
  snippetMode,
241
223
  );
242
224
  spinner.succeed(
243
- `${chalk.bold(editor.name)}: snippets synced (${fileCount} file${fileCount === 1 ? "" : "s"}, ${snippetMode})`,
225
+ `snippets synced (${fileCount} file${fileCount === 1 ? "" : "s"}, ${snippetMode} mode)`,
244
226
  );
245
227
  } catch (err) {
246
- spinner.fail(
247
- `${chalk.bold(editor.name)}: snippets sync failed (${err.message})`,
228
+ spinner.fail(`snippets sync failed (${err.message})`);
229
+ }
230
+ }
231
+
232
+ if (syncItems.includes("keybindings")) {
233
+ const spinner = ora({
234
+ text: "Syncing keybindings.json...",
235
+ color: "cyan",
236
+ }).start();
237
+ try {
238
+ const targetPath = getKeybindingsPath(editor, {
239
+ createIfMissing: true,
240
+ });
241
+ const merged = syncKeybindings(sourceKeybindings, targetPath);
242
+ spinner.succeed(
243
+ `keybindings.json synced (${merged.length} binding${merged.length === 1 ? "" : "s"})`,
248
244
  );
245
+ } catch (err) {
246
+ spinner.fail(`keybindings sync failed (${err.message})`);
247
+ }
248
+ }
249
+
250
+ if (syncItems.includes("extensions")) {
251
+ const spinner = ora({
252
+ text: "Installing extensions...",
253
+ color: "cyan",
254
+ }).start();
255
+ const result = await syncExtensions(
256
+ editor,
257
+ desiredExtensions,
258
+ extensionMode,
259
+ (i, extensionId) => {
260
+ spinner.text = `Installing extensions [${i + 1}/${desiredExtensions.length}] ${chalk.dim(extensionId)}`;
261
+ },
262
+ );
263
+ const successPart = chalk.green(`${result.synced} installed`);
264
+ const failedPart =
265
+ result.failed.length > 0
266
+ ? `, ${chalk.red(`${result.failed.length} failed`)}`
267
+ : "";
268
+ spinner.succeed(`extensions synced (${successPart}${failedPart})`);
269
+ if (result.failed.length > 0) {
270
+ console.log(`${chalk.red("Failed:")} ${result.failed.join(", ")}`);
249
271
  }
250
272
  }
251
273
 
package/lib/constants.js CHANGED
@@ -1,6 +1,6 @@
1
- export const EXTENSIONS_FILE = "extensions.txt";
2
1
  export const SETTINGS_FILE = "settings.json";
3
2
  export const SNIPPETS_DIR = "snippets";
3
+ export const KEYBINDINGS_FILE = "keybindings.json";
4
4
 
5
5
  // Sorted alphabetically by display name.
6
6
  export const EDITORS = [
@@ -44,15 +44,20 @@ export const SYNC_ITEMS = [
44
44
  name: "Extensions",
45
45
  short: "Extensions",
46
46
  },
47
+ {
48
+ value: "snippets",
49
+ name: "Snippets",
50
+ short: "Snippets",
51
+ },
47
52
  {
48
53
  value: "settings",
49
54
  name: "settings.json",
50
55
  short: "settings.json",
51
56
  },
52
57
  {
53
- value: "snippets",
54
- name: "Snippets",
55
- short: "Snippets",
58
+ value: "keybindings",
59
+ name: "keybindings.json",
60
+ short: "keybindings.json",
56
61
  },
57
62
  ];
58
63
 
@@ -1,32 +1,15 @@
1
- import { readFileSync, writeFileSync } from "fs";
2
1
  import {
3
2
  getInstalledExtensions,
4
3
  installExtension,
5
4
  uninstallExtension,
6
5
  } from "./editor-cli.js";
7
6
 
8
- export async function exportExtensions(sourceEditor, filePath) {
7
+ export async function exportExtensions(sourceEditor) {
9
8
  const list = await getInstalledExtensions(sourceEditor);
10
9
  if (list == null) {
11
10
  throw new Error(`${sourceEditor.name} CLI not found or failed.`);
12
11
  }
13
-
14
- const sorted = [...list].sort();
15
- writeFileSync(
16
- filePath,
17
- sorted.join("\n") + (sorted.length ? "\n" : ""),
18
- "utf-8",
19
- );
20
-
21
- return sorted;
22
- }
23
-
24
- export function readExtensionsFile(filePath) {
25
- const content = readFileSync(filePath, "utf-8");
26
- return content
27
- .split(/\r?\n/)
28
- .map((s) => s.trim())
29
- .filter(Boolean);
12
+ return [...list].sort();
30
13
  }
31
14
 
32
15
  export async function syncExtensions(editor, desired, mode, onProgress) {
@@ -0,0 +1,57 @@
1
+ import { existsSync, readFileSync, writeFileSync } from "fs";
2
+ import { parse, printParseErrorCode } from "jsonc-parser";
3
+
4
+ export function readSourceKeybindings(keybindingsPath) {
5
+ if (!existsSync(keybindingsPath)) {
6
+ throw new Error(`Source keybindings.json not found: ${keybindingsPath}`);
7
+ }
8
+
9
+ const raw = readFileSync(keybindingsPath, "utf-8");
10
+ const errors = [];
11
+ const parsed = parse(raw, errors, { allowTrailingComma: true });
12
+
13
+ if (errors.length > 0) {
14
+ const firstError = errors[0];
15
+ throw new Error(
16
+ `Invalid JSON in source keybindings.json: ${printParseErrorCode(firstError.error)} at offset ${firstError.offset}`,
17
+ );
18
+ }
19
+
20
+ if (!Array.isArray(parsed)) {
21
+ throw new Error("Source keybindings.json must be an array");
22
+ }
23
+
24
+ return parsed;
25
+ }
26
+
27
+ export function syncKeybindings(sourceKeybindings, targetPath) {
28
+ let targetKeybindings = [];
29
+
30
+ if (existsSync(targetPath)) {
31
+ const raw = readFileSync(targetPath, "utf-8");
32
+ const errors = [];
33
+ const parsed = parse(raw, errors, { allowTrailingComma: true });
34
+
35
+ if (errors.length === 0 && Array.isArray(parsed)) {
36
+ targetKeybindings = parsed;
37
+ }
38
+ }
39
+
40
+ // Merge: source keybindings override target ones with same key+command combo
41
+ const merged = [...targetKeybindings];
42
+
43
+ for (const sourceBinding of sourceKeybindings) {
44
+ const existingIndex = merged.findIndex(
45
+ (t) => t.key === sourceBinding.key && t.command === sourceBinding.command,
46
+ );
47
+
48
+ if (existingIndex >= 0) {
49
+ merged[existingIndex] = sourceBinding;
50
+ } else {
51
+ merged.push(sourceBinding);
52
+ }
53
+ }
54
+
55
+ writeFileSync(targetPath, JSON.stringify(merged, null, 2) + "\n", "utf-8");
56
+ return merged;
57
+ }
@@ -1,7 +1,12 @@
1
1
  import { existsSync, mkdirSync } from "fs";
2
2
  import { homedir } from "os";
3
3
  import { join } from "path";
4
- import { EDITOR_DATA_DIRS, SETTINGS_FILE, SNIPPETS_DIR } from "./constants.js";
4
+ import {
5
+ EDITOR_DATA_DIRS,
6
+ KEYBINDINGS_FILE,
7
+ SETTINGS_FILE,
8
+ SNIPPETS_DIR,
9
+ } from "./constants.js";
5
10
 
6
11
  function getConfigBaseDir() {
7
12
  if (process.platform === "win32") {
@@ -34,3 +39,7 @@ export function getSettingsPath(editor, options = {}) {
34
39
  export function getSnippetsPath(editor, options = {}) {
35
40
  return join(getEditorUserDir(editor, options), SNIPPETS_DIR);
36
41
  }
42
+
43
+ export function getKeybindingsPath(editor, options = {}) {
44
+ return join(getEditorUserDir(editor, options), KEYBINDINGS_FILE);
45
+ }
package/lib/prompts.js CHANGED
@@ -1,96 +1,51 @@
1
- import inquirer from "inquirer";
2
- import CheckboxPrompt from "inquirer/lib/prompts/checkbox.js";
3
-
4
- class CleanCheckboxPrompt extends CheckboxPrompt {
5
- constructor(questions, rl, answers) {
6
- super(questions, rl, answers);
7
- this.dontShowHints = true;
8
- }
9
- }
10
-
11
- let isRegistered = false;
12
-
13
- function ensurePromptRegistered() {
14
- if (isRegistered) return;
15
- inquirer.registerPrompt("clean-checkbox", CleanCheckboxPrompt);
16
- isRegistered = true;
17
- }
18
-
19
- const CHECKBOX_HINT =
20
- "(Press <space> to select, <a> to toggle all, <enter> to continue)";
1
+ import { select, checkbox } from "@inquirer/prompts";
21
2
 
22
3
  export async function promptSourceEditor(availableEditors) {
23
- ensurePromptRegistered();
24
- const answer = await inquirer.prompt([
25
- {
26
- type: "list",
27
- name: "sourceId",
28
- message: "Share from:",
29
- choices: availableEditors.map((e) => ({ name: e.name, value: e.id })),
30
- pageSize: 10,
31
- },
32
- ]);
33
- return answer.sourceId;
4
+ return await select({
5
+ message: "Share from:",
6
+ choices: availableEditors.map((e) => ({ name: e.name, value: e.id })),
7
+ pageSize: 10,
8
+ });
34
9
  }
35
10
 
36
11
  export async function promptSyncItems(syncItems, chalk) {
37
- ensurePromptRegistered();
38
- const answer = await inquirer.prompt([
39
- {
40
- type: "clean-checkbox",
41
- name: "selectedItems",
42
- message: `Share: ${chalk.dim(CHECKBOX_HINT)}`,
43
- choices: syncItems,
44
- validate: (v) => (v.length ? true : "Select at least one item."),
45
- },
46
- ]);
47
- return answer.selectedItems;
12
+ return await checkbox({
13
+ message: "Select items to sync:",
14
+ choices: syncItems,
15
+ required: true,
16
+ validate: (v) => (v.length ? true : "Select at least one item."),
17
+ });
48
18
  }
49
19
 
50
20
  export async function promptExtensionMode(extensionModes) {
51
- const answer = await inquirer.prompt([
52
- {
53
- type: "list",
54
- name: "extensionMode",
55
- message: "Extension mode:",
56
- choices: extensionModes.map((m) => ({
57
- name: m.name,
58
- value: m.value,
59
- short: m.short,
60
- })),
61
- default: "additive",
62
- },
63
- ]);
64
- return answer.extensionMode;
21
+ return await select({
22
+ message: "Extension mode:",
23
+ choices: extensionModes.map((m) => ({
24
+ name: m.name,
25
+ value: m.value,
26
+ short: m.short,
27
+ })),
28
+ default: "additive",
29
+ });
65
30
  }
66
31
 
67
32
  export async function promptSnippetMode(snippetModes) {
68
- const answer = await inquirer.prompt([
69
- {
70
- type: "list",
71
- name: "snippetMode",
72
- message: "Snippet mode:",
73
- choices: snippetModes.map((m) => ({
74
- name: m.name,
75
- value: m.value,
76
- short: m.short,
77
- })),
78
- default: "merge",
79
- },
80
- ]);
81
- return answer.snippetMode;
33
+ return await select({
34
+ message: "Snippet mode:",
35
+ choices: snippetModes.map((m) => ({
36
+ name: m.name,
37
+ value: m.value,
38
+ short: m.short,
39
+ })),
40
+ default: "merge",
41
+ });
82
42
  }
83
43
 
84
44
  export async function promptTargetEditors(targetChoices, chalk) {
85
- ensurePromptRegistered();
86
- const answer = await inquirer.prompt([
87
- {
88
- type: "clean-checkbox",
89
- name: "targetIds",
90
- message: `Sync to: ${chalk.dim(CHECKBOX_HINT)}`,
91
- choices: targetChoices,
92
- validate: (v) => (v.length ? true : "Select at least one editor."),
93
- },
94
- ]);
95
- return answer.targetIds;
45
+ return await checkbox({
46
+ message: "Select target editors:",
47
+ choices: targetChoices,
48
+ required: true,
49
+ validate: (v) => (v.length ? true : "Select at least one editor."),
50
+ });
96
51
  }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "editor-profile-sync",
3
- "version": "1.0.4",
4
- "description": "Cross-platform extensions, settings.json, and snippets sync for VS Code-based editors",
3
+ "version": "1.0.6",
4
+ "description": "Cross-platform extensions, settings.json, keybindings, and snippets sync for VS Code-based editors",
5
5
  "type": "module",
6
6
  "main": "index.js",
7
7
  "bin": {
@@ -32,8 +32,8 @@
32
32
  "node": ">=18"
33
33
  },
34
34
  "dependencies": {
35
+ "@inquirer/prompts": "^8.2.1",
35
36
  "chalk": "^5.6.2",
36
- "inquirer": "^9.2.22",
37
37
  "jsonc-parser": "^3.3.1",
38
38
  "ora": "^9.3.0",
39
39
  "update-notifier": "^7.3.1"