cursor-keys 1.2.2 → 1.2.4
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 +22 -19
- package/bin/cursor-keys.mjs +3 -0
- package/package.json +1 -1
- package/scripts/postinstall.mjs +41 -5
- package/src/commands.mjs +90 -30
- package/src/config.mjs +35 -8
package/README.md
CHANGED
|
@@ -48,6 +48,8 @@ npm install -g cursor-keys
|
|
|
48
48
|
|
|
49
49
|
Requires Node ≥18, the Cursor CLI (`Shell Command: Install 'cursor' in PATH`), and sqlite3 (preinstalled on macOS). Key injection is macOS-only; everything else is cross-platform shaped.
|
|
50
50
|
|
|
51
|
+
On global install, the companion Cursor extension is linked into `~/.cursor/extensions` automatically and a one-time hint prints (opt out of both: `CURSOR_KEYS_NO_POSTINSTALL=1`). Restart Cursor, then `Cmd+Shift+P` → "cursor-keys: Open Project...".
|
|
52
|
+
|
|
51
53
|
## Quick start
|
|
52
54
|
|
|
53
55
|
```bash
|
|
@@ -69,8 +71,8 @@ One-time extras for the full experience:
|
|
|
69
71
|
|
|
70
72
|
```bash
|
|
71
73
|
cursor-keys wrap --install # plain `cursor <path>` auto-picks the profile
|
|
72
|
-
cursor-keys aliases --install # per-project
|
|
73
|
-
cursor-keys install-extension # status bar
|
|
74
|
+
cursor-keys aliases --install # per-project aliases, `ck` shortcut, zsh tab completion
|
|
75
|
+
cursor-keys install-extension # status bar + wrong-window guard (auto-linked on global npm install)
|
|
74
76
|
```
|
|
75
77
|
|
|
76
78
|
## How it works
|
|
@@ -89,29 +91,30 @@ Settings flow one way, main to profiles: `sync` re-copies your main `settings.js
|
|
|
89
91
|
|
|
90
92
|
## Commands
|
|
91
93
|
|
|
92
|
-
| Command | What it does
|
|
93
|
-
| ---------------------------------------------- |
|
|
94
|
-
| `init [--root DIR] [--all]` | Discover git repos and register them
|
|
95
|
-
| `add <name> [--path] [--color] [--clone main]` | Create a project profile
|
|
96
|
-
| `clone <name> [--from main]` | Copy sign-in + settings + model config from a donor profile
|
|
97
|
-
| `set-key <name> [--key sk-...]` | Inject the project API key (encrypted, verified)
|
|
98
|
-
| `
|
|
99
|
-
| `
|
|
100
|
-
| `
|
|
101
|
-
| `
|
|
102
|
-
| `
|
|
103
|
-
| `
|
|
104
|
-
| `
|
|
94
|
+
| Command | What it does |
|
|
95
|
+
| ---------------------------------------------- | --------------------------------------------------------------- |
|
|
96
|
+
| `init [--root DIR] [--all]` | Discover git repos and register them |
|
|
97
|
+
| `add <name> [--path] [--color] [--clone main]` | Create a project profile |
|
|
98
|
+
| `clone <name> [--from main]` | Copy sign-in + settings + model config from a donor profile |
|
|
99
|
+
| `set-key <name> [--key sk-...]` | Inject the project API key (encrypted, verified) |
|
|
100
|
+
| `color <name> [#hex]` | Show or set the project title bar color |
|
|
101
|
+
| `open [name]` | Launch Cursor with the profile (auto-detect from cwd) |
|
|
102
|
+
| `status` | Projects, colors, running windows, key configured |
|
|
103
|
+
| `current` | Mapping for the current directory |
|
|
104
|
+
| `config [get <key> · baseUrl [URL]]` | Show summary, print one value, or set the gateway base URL |
|
|
105
|
+
| `sync [name]` | Re-copy main Cursor settings into profiles |
|
|
106
|
+
| `doctor [name]` | Health checks: key row, base URL, subagent overrides |
|
|
107
|
+
| `wrap [--install]` | zsh function so plain `cursor <path>` auto-picks the profile |
|
|
105
108
|
| `aliases [--install]` | Per-project shell aliases (also `ck` shortcut + tab completion) |
|
|
106
|
-
| `completion` | Print zsh tab completion (auto-sourced by `aliases --install`)
|
|
107
|
-
| `install-extension` | Link the Cursor extension
|
|
108
|
-
| `rm <name> [--delete]` | Unregister (optionally delete the profile)
|
|
109
|
+
| `completion` | Print zsh tab completion (auto-sourced by `aliases --install`) |
|
|
110
|
+
| `install-extension` | Link the Cursor extension (auto-linked on global npm install) |
|
|
111
|
+
| `rm <name> [--delete]` | Unregister (optionally delete the profile) |
|
|
109
112
|
|
|
110
113
|
`cursor-keys ls` prints registered project names one per line for scripts and tab completion; it is hidden from `--help`.
|
|
111
114
|
|
|
112
115
|
## Config
|
|
113
116
|
|
|
114
|
-
`~/.config/cursor-keys/config.json
|
|
117
|
+
`~/.config/cursor-keys/config.json` (writes are atomic and merge-on-save, so overlapping runs never clobber registrations):
|
|
115
118
|
|
|
116
119
|
```json
|
|
117
120
|
{
|
package/bin/cursor-keys.mjs
CHANGED
|
@@ -27,6 +27,9 @@ function printHelp() {
|
|
|
27
27
|
console.log(
|
|
28
28
|
" set-key <name> Inject the project API key (encrypted)",
|
|
29
29
|
);
|
|
30
|
+
console.log(
|
|
31
|
+
" color <name> [#hex] Show or set the project title bar color",
|
|
32
|
+
);
|
|
30
33
|
console.log(
|
|
31
34
|
" open [name] Open Cursor with this project's profile",
|
|
32
35
|
);
|
package/package.json
CHANGED
package/scripts/postinstall.mjs
CHANGED
|
@@ -1,20 +1,56 @@
|
|
|
1
|
-
// Postinstall
|
|
1
|
+
// Postinstall: one-time hint + best-effort companion extension install.
|
|
2
|
+
// Never breaks the install: the whole body is guarded and exits 0.
|
|
2
3
|
// Quiet installs: CURSOR_KEYS_NO_POSTINSTALL=1
|
|
4
|
+
import fs from "node:fs";
|
|
5
|
+
import os from "node:os";
|
|
6
|
+
import path from "node:path";
|
|
7
|
+
import { fileURLToPath } from "node:url";
|
|
8
|
+
|
|
3
9
|
try {
|
|
4
10
|
if (process.env.CURSOR_KEYS_NO_POSTINSTALL) process.exit(0);
|
|
5
11
|
const yellow = "\x1b[33m";
|
|
6
12
|
const dim = "\x1b[2m";
|
|
7
13
|
const reset = "\x1b[0m";
|
|
14
|
+
|
|
15
|
+
const pkgRoot = path.join(
|
|
16
|
+
path.dirname(fileURLToPath(import.meta.url)),
|
|
17
|
+
"..",
|
|
18
|
+
);
|
|
19
|
+
const extSrc = path.join(pkgRoot, "extension");
|
|
20
|
+
const extPkgPath = path.join(extSrc, "package.json");
|
|
21
|
+
const cursorDir = path.join(os.homedir(), ".cursor");
|
|
22
|
+
|
|
23
|
+
// Auto-link the companion extension on global installs when Cursor exists.
|
|
24
|
+
let extLine = "";
|
|
25
|
+
const isGlobal = process.env.npm_config_global === "true";
|
|
26
|
+
if (isGlobal && fs.existsSync(cursorDir) && fs.existsSync(extPkgPath)) {
|
|
27
|
+
try {
|
|
28
|
+
const extPkg = JSON.parse(fs.readFileSync(extPkgPath, "utf8"));
|
|
29
|
+
const extDir = path.join(cursorDir, "extensions");
|
|
30
|
+
fs.mkdirSync(extDir, { recursive: true });
|
|
31
|
+
const target = path.join(
|
|
32
|
+
extDir,
|
|
33
|
+
`${extPkg.publisher || "lubu"}.${extPkg.name}-${extPkg.version}`,
|
|
34
|
+
);
|
|
35
|
+
fs.rmSync(target, { recursive: true, force: true });
|
|
36
|
+
try {
|
|
37
|
+
fs.symlinkSync(extSrc, target, "dir");
|
|
38
|
+
} catch {
|
|
39
|
+
fs.cpSync(extSrc, target, { recursive: true });
|
|
40
|
+
}
|
|
41
|
+
extLine = `\n Cursor extension linked ${dim}· restart Cursor, then Cmd+Shift+P: "cursor-keys: Open Project..."${reset}`;
|
|
42
|
+
} catch {
|
|
43
|
+
extLine = `\n cursor-keys install-extension link the Cursor extension (status bar + warnings)`;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
8
47
|
console.log(`
|
|
9
48
|
${yellow}cursor-keys installed${reset}
|
|
10
49
|
|
|
11
50
|
cursor-keys --help commands
|
|
12
51
|
cursor-keys aliases --install aliases, 'ck' shortcut, tab completion
|
|
13
52
|
cursor-keys wrap --install plain 'cursor <path>' picks the profile
|
|
14
|
-
|
|
15
|
-
Inside Cursor: Cmd+Shift+P then "cursor-keys: Open Project..." after
|
|
16
|
-
'cursor-keys install-extension' (or install 'cursor-keys' from Open VSX).
|
|
17
|
-
|
|
53
|
+
${extLine}
|
|
18
54
|
${dim}Quiet installs: CURSOR_KEYS_NO_POSTINSTALL=1${reset}
|
|
19
55
|
`);
|
|
20
56
|
} catch {
|
package/src/commands.mjs
CHANGED
|
@@ -13,10 +13,11 @@ import {
|
|
|
13
13
|
findProjectByPath,
|
|
14
14
|
getConfigValue,
|
|
15
15
|
getProject,
|
|
16
|
+
isHexColor,
|
|
16
17
|
loadConfig,
|
|
17
18
|
pickColorForName,
|
|
18
19
|
pickUnusedColor,
|
|
19
|
-
|
|
20
|
+
updateConfig,
|
|
20
21
|
profileStateDb,
|
|
21
22
|
} from "./config.mjs";
|
|
22
23
|
import {
|
|
@@ -89,6 +90,8 @@ export async function runCommand(argv) {
|
|
|
89
90
|
return cmdInstallExtension(rest);
|
|
90
91
|
case "set-key":
|
|
91
92
|
return cmdSetKey(rest);
|
|
93
|
+
case "color":
|
|
94
|
+
return cmdColor(rest);
|
|
92
95
|
case "clone":
|
|
93
96
|
return cmdClone(rest);
|
|
94
97
|
case "rm":
|
|
@@ -238,8 +241,9 @@ async function cmdConfig(args) {
|
|
|
238
241
|
if (parsed.protocol !== "https:" && parsed.protocol !== "http:") {
|
|
239
242
|
throw new Error(`baseUrl must start with http(s)://: ${value}`);
|
|
240
243
|
}
|
|
241
|
-
|
|
242
|
-
|
|
244
|
+
updateConfig((c) => {
|
|
245
|
+
c.baseUrl = value;
|
|
246
|
+
});
|
|
243
247
|
console.log(ok(`baseUrl set: ${value}`));
|
|
244
248
|
console.log(`${dim}doctor verifies this per profile.${reset}`);
|
|
245
249
|
}
|
|
@@ -304,20 +308,20 @@ async function cmdInit(args) {
|
|
|
304
308
|
}
|
|
305
309
|
}
|
|
306
310
|
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
311
|
+
updateConfig((config) => {
|
|
312
|
+
for (const repo of selected) {
|
|
313
|
+
const name = path.basename(repo);
|
|
314
|
+
if (config.projects[name]) {
|
|
315
|
+
console.log(warn(`Skip ${name} (already registered)`));
|
|
316
|
+
continue;
|
|
317
|
+
}
|
|
318
|
+
config.projects[name] = {
|
|
319
|
+
path: collapseHome(repo),
|
|
320
|
+
color: pickColorForName(name),
|
|
321
|
+
};
|
|
322
|
+
console.log(ok(`Registered ${name}`));
|
|
313
323
|
}
|
|
314
|
-
|
|
315
|
-
path: collapseHome(repo),
|
|
316
|
-
color: pickColorForName(name),
|
|
317
|
-
};
|
|
318
|
-
console.log(ok(`Registered ${name}`));
|
|
319
|
-
}
|
|
320
|
-
saveConfig(config);
|
|
324
|
+
});
|
|
321
325
|
console.log(
|
|
322
326
|
`\nNext: cursor-keys add <name> --clone main (creates profile + copies main Cursor settings)`,
|
|
323
327
|
);
|
|
@@ -360,10 +364,11 @@ async function cmdAdd(args) {
|
|
|
360
364
|
flags.color ||
|
|
361
365
|
config.projects[name]?.color ||
|
|
362
366
|
pickUnusedColor(name, usedColors);
|
|
363
|
-
|
|
364
|
-
|
|
367
|
+
const saved = updateConfig((c) => {
|
|
368
|
+
c.projects[name] = { path: collapseHome(repoPath), color };
|
|
369
|
+
});
|
|
365
370
|
|
|
366
|
-
const project = getProject(
|
|
371
|
+
const project = getProject(saved, name);
|
|
367
372
|
if (!project) throw new Error("Failed to resolve project");
|
|
368
373
|
|
|
369
374
|
let cloneFrom = null;
|
|
@@ -371,7 +376,7 @@ async function cmdAdd(args) {
|
|
|
371
376
|
if (flags.clone === "main") {
|
|
372
377
|
cloneFrom = mainCursorProfileRoot();
|
|
373
378
|
} else {
|
|
374
|
-
const donor = getProject(
|
|
379
|
+
const donor = getProject(saved, flags.clone);
|
|
375
380
|
if (!donor) throw new Error(`Unknown clone donor: ${flags.clone}`);
|
|
376
381
|
cloneFrom = donor.profileDir;
|
|
377
382
|
}
|
|
@@ -601,10 +606,10 @@ async function cmdAliases(args) {
|
|
|
601
606
|
}
|
|
602
607
|
fs.writeFileSync(zshrc, content, "utf8");
|
|
603
608
|
console.log(ok(`Updated ${zshrc}`));
|
|
604
|
-
if (!content
|
|
609
|
+
if (!compinitLikelyConfigured(content)) {
|
|
605
610
|
console.log(
|
|
606
611
|
warn(
|
|
607
|
-
"Note:
|
|
612
|
+
"Note: tab completion needs compinit. If `cursor-keys open <TAB>` does nothing after sourcing ~/.zshrc, add: autoload -Uz compinit && compinit",
|
|
608
613
|
),
|
|
609
614
|
);
|
|
610
615
|
}
|
|
@@ -614,12 +619,28 @@ function sanitizeAlias(name) {
|
|
|
614
619
|
return name.replace(/[^a-zA-Z0-9_-]/g, "_");
|
|
615
620
|
}
|
|
616
621
|
|
|
622
|
+
const COMPINIT_HINT_RE = /compinit|oh-my-zsh|prezto|zinit|antigen|zplug|zimfw/;
|
|
623
|
+
|
|
624
|
+
/**
|
|
625
|
+
* Best-effort check that the user's zsh setup loads compinit. Frameworks
|
|
626
|
+
* (oh-my-zsh, prezto, zinit, ...) run it from sourced files rather than
|
|
627
|
+
* ~/.zshrc itself, and Warp injects it via shell integration, so a plain
|
|
628
|
+
* text grep of ~/.zshrc alone gives false positives. Exported for tests.
|
|
629
|
+
*/
|
|
630
|
+
export function compinitLikelyConfigured(
|
|
631
|
+
zshrcContent,
|
|
632
|
+
termProgram = process.env.TERM_PROGRAM,
|
|
633
|
+
) {
|
|
634
|
+
if (COMPINIT_HINT_RE.test(zshrcContent)) return true;
|
|
635
|
+
return termProgram === "WarpTerminal";
|
|
636
|
+
}
|
|
637
|
+
|
|
617
638
|
function shellEscapeName(name) {
|
|
618
639
|
return name.replace(/'/g, `'\\''`);
|
|
619
640
|
}
|
|
620
641
|
|
|
621
642
|
const COMPLETION_COMMANDS =
|
|
622
|
-
"init add clone set-key open status current config sync doctor aliases wrap install-extension completion ls rm";
|
|
643
|
+
"init add clone set-key color open status current config sync doctor aliases wrap install-extension completion ls rm";
|
|
623
644
|
|
|
624
645
|
/**
|
|
625
646
|
* Pure builder for the zsh completion script. Sourced via
|
|
@@ -630,7 +651,7 @@ export function completionScript() {
|
|
|
630
651
|
return `#compdef cursor-keys ckeys ck
|
|
631
652
|
_cursor_keys() {
|
|
632
653
|
local -a projects
|
|
633
|
-
if (( CURRENT == 3 )) && [[ \${words[2]} == (open|clone|set-key|doctor|sync|rm) ]]; then
|
|
654
|
+
if (( CURRENT == 3 )) && [[ \${words[2]} == (open|clone|set-key|color|doctor|sync|rm) ]]; then
|
|
634
655
|
projects=(\${(f)"$(cursor-keys ls 2>/dev/null)"})
|
|
635
656
|
_describe 'project' projects
|
|
636
657
|
else
|
|
@@ -680,10 +701,10 @@ function installZshrcBlock(startMarker, endMarker, block) {
|
|
|
680
701
|
content = content.trimEnd() + "\n\n" + block + "\n";
|
|
681
702
|
}
|
|
682
703
|
fs.writeFileSync(zshrc, content, "utf8");
|
|
683
|
-
if (!content
|
|
704
|
+
if (!compinitLikelyConfigured(content)) {
|
|
684
705
|
console.log(
|
|
685
706
|
warn(
|
|
686
|
-
"Note:
|
|
707
|
+
"Note: tab completion needs compinit. If `cursor-keys open <TAB>` does nothing after sourcing ~/.zshrc, add: autoload -Uz compinit && compinit",
|
|
687
708
|
),
|
|
688
709
|
);
|
|
689
710
|
}
|
|
@@ -807,12 +828,17 @@ async function cmdInstallExtension() {
|
|
|
807
828
|
"..",
|
|
808
829
|
);
|
|
809
830
|
const src = path.join(repoRoot, "extension");
|
|
810
|
-
|
|
831
|
+
const extPkgPath = path.join(src, "package.json");
|
|
832
|
+
if (!fs.existsSync(extPkgPath)) {
|
|
811
833
|
throw new Error(`Extension source missing: ${src}`);
|
|
812
834
|
}
|
|
835
|
+
const extPkg = JSON.parse(fs.readFileSync(extPkgPath, "utf8"));
|
|
813
836
|
const extDir = path.join(os.homedir(), ".cursor", "extensions");
|
|
814
837
|
fs.mkdirSync(extDir, { recursive: true });
|
|
815
|
-
const target = path.join(
|
|
838
|
+
const target = path.join(
|
|
839
|
+
extDir,
|
|
840
|
+
`${extPkg.publisher || "lubu"}.${extPkg.name}-${extPkg.version}`,
|
|
841
|
+
);
|
|
816
842
|
fs.rmSync(target, { recursive: true, force: true });
|
|
817
843
|
fs.symlinkSync(src, target, "dir");
|
|
818
844
|
console.log(ok(`Extension linked: ${target}`));
|
|
@@ -820,6 +846,39 @@ async function cmdInstallExtension() {
|
|
|
820
846
|
console.log(`Look for ${bold}$(key) <project>${reset} in the status bar.`);
|
|
821
847
|
}
|
|
822
848
|
|
|
849
|
+
async function cmdColor(args) {
|
|
850
|
+
const { flags, positional } = parseFlags(args);
|
|
851
|
+
if (flags.help) {
|
|
852
|
+
console.log("Usage: cursor-keys color <name> [#RRGGBB]");
|
|
853
|
+
console.log("Show or set the project's title bar color.");
|
|
854
|
+
return;
|
|
855
|
+
}
|
|
856
|
+
const name = positional[0];
|
|
857
|
+
if (!name) throw new Error("Usage: cursor-keys color <name> [#RRGGBB]");
|
|
858
|
+
const config = loadConfig();
|
|
859
|
+
const project = getProject(config, name);
|
|
860
|
+
if (!project) throw new Error(`Unknown project: ${name}. Run cursor-keys init or add.`);
|
|
861
|
+
|
|
862
|
+
const hex = positional[1];
|
|
863
|
+
if (!hex) {
|
|
864
|
+
console.log(`${blockSwatch(project.color)} ${project.color}`);
|
|
865
|
+
return;
|
|
866
|
+
}
|
|
867
|
+
if (!isHexColor(hex)) {
|
|
868
|
+
throw new Error(`Invalid color: ${hex} (expected #RRGGBB, e.g. #852e00)`);
|
|
869
|
+
}
|
|
870
|
+
|
|
871
|
+
updateConfig((c) => {
|
|
872
|
+
if (!c.projects[name]) throw new Error(`Unknown project: ${name}`);
|
|
873
|
+
c.projects[name].color = hex;
|
|
874
|
+
});
|
|
875
|
+
if (fs.existsSync(project.profileDir)) {
|
|
876
|
+
writeProfileSettings(project.profileDir, name, hex);
|
|
877
|
+
}
|
|
878
|
+
console.log(ok(`Color for ${name}: `) + `${blockSwatch(hex)} ${hex}`);
|
|
879
|
+
console.log(`${dim}Open Cursor windows reload settings automatically.${reset}`);
|
|
880
|
+
}
|
|
881
|
+
|
|
823
882
|
function promptHidden(question) {
|
|
824
883
|
return new Promise((resolve) => {
|
|
825
884
|
const rl = readline.createInterface({
|
|
@@ -900,8 +959,9 @@ async function cmdRm(args) {
|
|
|
900
959
|
if (!config.projects[name]) throw new Error(`Unknown project: ${name}`);
|
|
901
960
|
|
|
902
961
|
const p = getProject(config, name);
|
|
903
|
-
|
|
904
|
-
|
|
962
|
+
updateConfig((c) => {
|
|
963
|
+
delete c.projects[name];
|
|
964
|
+
});
|
|
905
965
|
console.log(ok(`Removed ${name} from config`));
|
|
906
966
|
|
|
907
967
|
if (flags.delete && p) {
|
package/src/config.mjs
CHANGED
|
@@ -8,6 +8,15 @@ export const DEFAULT_CONFIG_DIR = path.join(
|
|
|
8
8
|
"cursor-keys",
|
|
9
9
|
);
|
|
10
10
|
export const CONFIG_FILE = path.join(DEFAULT_CONFIG_DIR, "config.json");
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Resolved at call time so tests and scripts can point cursor-keys at a
|
|
14
|
+
* scratch config via CURSOR_KEYS_CONFIG_FILE.
|
|
15
|
+
*/
|
|
16
|
+
function configFilePath() {
|
|
17
|
+
const override = process.env.CURSOR_KEYS_CONFIG_FILE;
|
|
18
|
+
return override ? expandHome(override) : CONFIG_FILE;
|
|
19
|
+
}
|
|
11
20
|
export const DEFAULT_PROFILES_ROOT = path.join(
|
|
12
21
|
os.homedir(),
|
|
13
22
|
".cursor-profiles",
|
|
@@ -15,6 +24,11 @@ export const DEFAULT_PROFILES_ROOT = path.join(
|
|
|
15
24
|
|
|
16
25
|
const HEX_COLOR = /^#[0-9a-fA-F]{6}$/;
|
|
17
26
|
|
|
27
|
+
/** @param {unknown} s */
|
|
28
|
+
export function isHexColor(s) {
|
|
29
|
+
return typeof s === "string" && HEX_COLOR.test(s);
|
|
30
|
+
}
|
|
31
|
+
|
|
18
32
|
/** @param {string} p */
|
|
19
33
|
export function expandHome(p) {
|
|
20
34
|
if (!p || typeof p !== "string") return p;
|
|
@@ -40,25 +54,38 @@ export function defaultConfig() {
|
|
|
40
54
|
}
|
|
41
55
|
|
|
42
56
|
export function loadConfig() {
|
|
43
|
-
|
|
57
|
+
const file = configFilePath();
|
|
58
|
+
if (!fs.existsSync(file)) {
|
|
44
59
|
return defaultConfig();
|
|
45
60
|
}
|
|
46
|
-
const raw = fs.readFileSync(
|
|
61
|
+
const raw = fs.readFileSync(file, "utf8");
|
|
47
62
|
const data = JSON.parse(raw);
|
|
48
63
|
return validateConfig(data);
|
|
49
64
|
}
|
|
50
65
|
|
|
51
66
|
export function saveConfig(config) {
|
|
52
67
|
const validated = validateConfig(config);
|
|
53
|
-
|
|
54
|
-
fs.
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
);
|
|
68
|
+
const file = configFilePath();
|
|
69
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
70
|
+
// Atomic write: a crash or concurrent reader never sees a torn file.
|
|
71
|
+
const tmp = `${file}.tmp-${process.pid}`;
|
|
72
|
+
fs.writeFileSync(tmp, JSON.stringify(validated, null, 2) + "\n", "utf8");
|
|
73
|
+
fs.renameSync(tmp, file);
|
|
59
74
|
return validated;
|
|
60
75
|
}
|
|
61
76
|
|
|
77
|
+
/**
|
|
78
|
+
* Load fresh, mutate, save: the only safe way to change config. Keeps the
|
|
79
|
+
* lost-update window (two concurrent runs dropping each other's projects)
|
|
80
|
+
* down to the few ms inside this function instead of a whole command run.
|
|
81
|
+
* @param {(config: ReturnType<typeof loadConfig>) => void} mutate
|
|
82
|
+
*/
|
|
83
|
+
export function updateConfig(mutate) {
|
|
84
|
+
const config = loadConfig();
|
|
85
|
+
mutate(config);
|
|
86
|
+
return saveConfig(config);
|
|
87
|
+
}
|
|
88
|
+
|
|
62
89
|
/**
|
|
63
90
|
* @param {unknown} data
|
|
64
91
|
*/
|