skillrepo 2.0.0 → 3.0.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 +215 -150
- package/bin/skillrepo.mjs +210 -36
- package/package.json +6 -3
- package/src/commands/add.mjs +176 -0
- package/src/commands/get.mjs +116 -0
- package/src/commands/init.mjs +471 -143
- package/src/commands/list.mjs +176 -0
- package/src/commands/remove.mjs +167 -0
- package/src/commands/search.mjs +188 -0
- package/src/commands/update.mjs +67 -0
- package/src/lib/cli-config.mjs +230 -0
- package/src/lib/config.mjs +238 -0
- package/src/lib/detect-ides.mjs +0 -19
- package/src/lib/errors.mjs +264 -0
- package/src/lib/file-write.mjs +705 -0
- package/src/lib/http.mjs +817 -37
- package/src/lib/identifier.mjs +153 -0
- package/src/lib/mcp-merge.mjs +275 -0
- package/src/lib/mergers/gitignore.mjs +73 -18
- package/src/lib/paths.mjs +46 -17
- package/src/lib/prompt.mjs +11 -44
- package/src/lib/sync.mjs +305 -0
- package/src/test/commands/add.test.mjs +285 -0
- package/src/test/commands/get.test.mjs +176 -0
- package/src/test/commands/init.test.mjs +486 -0
- package/src/test/commands/list.test.mjs +172 -0
- package/src/test/commands/remove.test.mjs +234 -0
- package/src/test/commands/search.test.mjs +204 -0
- package/src/test/commands/update.test.mjs +164 -0
- package/src/test/detect-ides.test.mjs +9 -14
- package/src/test/dispatcher.test.mjs +224 -0
- package/src/test/e2e/cli-commands.test.mjs +576 -0
- package/src/test/e2e/mock-server.mjs +364 -22
- package/src/test/helpers/capture-stream.mjs +48 -0
- package/src/test/integration/file-write.integration.test.mjs +279 -0
- package/src/test/lib/cli-config.test.mjs +407 -0
- package/src/test/lib/config.test.mjs +257 -0
- package/src/test/lib/errors.test.mjs +359 -0
- package/src/test/lib/file-write.test.mjs +784 -0
- package/src/test/lib/http.test.mjs +1198 -0
- package/src/test/lib/identifier.test.mjs +157 -0
- package/src/test/lib/mcp-merge.test.mjs +345 -0
- package/src/test/lib/paths.test.mjs +83 -0
- package/src/test/lib/sync.test.mjs +514 -0
- package/src/test/mergers/gitignore.test.mjs +145 -20
- package/src/lib/write-configs.mjs +0 -202
- package/src/test/e2e/HANDOFF.md +0 -223
- package/src/test/e2e/cli-init.test.mjs +0 -213
- package/src/test/e2e/payload-factory.mjs +0 -22
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared credential + flag resolution for command modules.
|
|
3
|
+
*
|
|
4
|
+
* Every command needs to:
|
|
5
|
+
* 1. Resolve `--key`/`--url`/`--ide`/`--global`/`--json` flags
|
|
6
|
+
* 2. Fall back to ~/.claude/skillrepo/config.json
|
|
7
|
+
* 3. Fall back to SKILLREPO_ACCESS_KEY / SKILLREPO_URL env vars
|
|
8
|
+
* 4. Hard-error with an actionable hint pointing at `init` if no
|
|
9
|
+
* key is configured
|
|
10
|
+
*
|
|
11
|
+
* Centralizing this here keeps the four command modules thin and
|
|
12
|
+
* means a future change to credential resolution (e.g., adding a
|
|
13
|
+
* keychain backend) is a single edit instead of four.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
17
|
+
|
|
18
|
+
import { globalConfigPath } from "./paths.mjs";
|
|
19
|
+
import { authError, validationError } from "./errors.mjs";
|
|
20
|
+
|
|
21
|
+
const VALID_VENDORS = new Set(["claudeCode", "cursor", "windsurf", "vscode"]);
|
|
22
|
+
const VENDOR_ALIASES = { claude: "claudeCode" };
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* @typedef {Object} ResolvedFlags
|
|
26
|
+
* @property {string} serverUrl
|
|
27
|
+
* @property {string} apiKey
|
|
28
|
+
* @property {boolean} global
|
|
29
|
+
* @property {string[]|null} vendors - null = use the default
|
|
30
|
+
* @property {boolean} json
|
|
31
|
+
*/
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Parse common flags from an argv slice. Returns a typed object with
|
|
35
|
+
* `serverUrl` and `apiKey` resolved per the priority order documented
|
|
36
|
+
* at the top of this file.
|
|
37
|
+
*
|
|
38
|
+
* Throws `validationError` for unknown flags (caller can intercept
|
|
39
|
+
* extra positional args before calling this).
|
|
40
|
+
*
|
|
41
|
+
* @param {string[]} argv - The argv slice the dispatcher passed to the command
|
|
42
|
+
* @param {object} [opts]
|
|
43
|
+
* @param {boolean} [opts.requireAuth=true] - If false, skip the no-key error
|
|
44
|
+
* (used by commands that may run without credentials, but PR2 has none)
|
|
45
|
+
* @param {boolean} [opts.skipConfig=false] - If true, DO NOT read from
|
|
46
|
+
* ~/.claude/skillrepo/config.json as a fallback. Only flag values
|
|
47
|
+
* and env vars are considered. Used by `init` so that its own
|
|
48
|
+
* --force / stale-key flow can decide whether to consume cached
|
|
49
|
+
* credentials — without this, resolveFlags would silently inject
|
|
50
|
+
* the cached key before init's decision logic runs, making
|
|
51
|
+
* --force a no-op.
|
|
52
|
+
* @param {(arg: string, i: number, argv: string[]) => boolean | number} [opts.acceptPositional]
|
|
53
|
+
* Optional callback to consume positional args. Return:
|
|
54
|
+
* - `false` (or any falsy non-zero) → arg rejected as unknown
|
|
55
|
+
* - a positive integer N → consume N args (the current arg
|
|
56
|
+
* plus the next N-1 if any)
|
|
57
|
+
* - 0 is INVALID and treated the same as `false` — a
|
|
58
|
+
* callback that "handles but consumes nothing" is a
|
|
59
|
+
* contract violation and would loop forever
|
|
60
|
+
* - any non-finite or negative number → invalid, treated as `false`
|
|
61
|
+
* @returns {ResolvedFlags}
|
|
62
|
+
*/
|
|
63
|
+
export function resolveFlags(argv, opts = {}) {
|
|
64
|
+
const flagState = {
|
|
65
|
+
global: false,
|
|
66
|
+
vendors: null,
|
|
67
|
+
json: false,
|
|
68
|
+
key: null,
|
|
69
|
+
serverUrl: null,
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
for (let i = 0; i < argv.length; i++) {
|
|
73
|
+
const arg = argv[i];
|
|
74
|
+
|
|
75
|
+
if (arg === "--global") {
|
|
76
|
+
flagState.global = true;
|
|
77
|
+
} else if (arg === "--ide" && argv[i + 1] !== undefined) {
|
|
78
|
+
flagState.vendors = parseVendorList(argv[++i]);
|
|
79
|
+
} else if (arg === "--json") {
|
|
80
|
+
flagState.json = true;
|
|
81
|
+
} else if ((arg === "--key" || arg === "-k") && argv[i + 1] !== undefined) {
|
|
82
|
+
flagState.key = argv[++i];
|
|
83
|
+
} else if ((arg === "--url" || arg === "-u") && argv[i + 1] !== undefined) {
|
|
84
|
+
flagState.serverUrl = argv[++i];
|
|
85
|
+
} else if (arg === "--help" || arg === "-h") {
|
|
86
|
+
// Dispatcher should have intercepted this. Defensive no-op.
|
|
87
|
+
continue;
|
|
88
|
+
} else {
|
|
89
|
+
// Allow the caller to consume a positional arg before we treat
|
|
90
|
+
// it as unknown. This is how `get @owner/name` and
|
|
91
|
+
// `search <query>` slot in their main argument.
|
|
92
|
+
//
|
|
93
|
+
// Contract: callback returns a positive integer N (consume N
|
|
94
|
+
// args), `false`, or any falsy/zero/negative/non-integer value
|
|
95
|
+
// (reject the arg). Returning 0 is INVALID — it would mean
|
|
96
|
+
// "handled but consumed nothing", which would infinite-loop on
|
|
97
|
+
// the same arg. We reject zero defensively rather than trust
|
|
98
|
+
// every caller to read the JSDoc.
|
|
99
|
+
const consumed = opts.acceptPositional?.(arg, i, argv);
|
|
100
|
+
if (
|
|
101
|
+
typeof consumed === "number" &&
|
|
102
|
+
Number.isInteger(consumed) &&
|
|
103
|
+
consumed > 0
|
|
104
|
+
) {
|
|
105
|
+
i += consumed - 1; // -1 because the for loop also increments
|
|
106
|
+
} else {
|
|
107
|
+
throw validationError(`Unknown argument: ${arg}`, {
|
|
108
|
+
hint: "Run the command with --help for valid options.",
|
|
109
|
+
});
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// Resolve credentials in priority order
|
|
115
|
+
let serverUrl = flagState.serverUrl;
|
|
116
|
+
let apiKey = flagState.key;
|
|
117
|
+
|
|
118
|
+
// `skipConfig` suppresses the config-file fallback AND the eager
|
|
119
|
+
// default for serverUrl. `init` needs this because it owns the
|
|
120
|
+
// credential lifecycle: it reads the config file itself so
|
|
121
|
+
// `--force` and the stale-key re-prompt path can decide whether
|
|
122
|
+
// to use the cached credentials, and it needs to see `null`
|
|
123
|
+
// serverUrl (not the baked-in production URL) so its own
|
|
124
|
+
// "existingConfig.serverUrl → env → DEFAULT_URL" fallback chain
|
|
125
|
+
// takes effect. Without this, `init`'s `!serverUrl` branch was
|
|
126
|
+
// dead because resolveFlags would already have set it to
|
|
127
|
+
// https://skillrepo.dev.
|
|
128
|
+
if (opts.skipConfig) {
|
|
129
|
+
// Env var still applies — it's explicit config, not a cached
|
|
130
|
+
// credential. But the production URL default does NOT apply;
|
|
131
|
+
// the caller is expected to provide its own default.
|
|
132
|
+
if (!serverUrl) serverUrl = process.env.SKILLREPO_URL || null;
|
|
133
|
+
if (!apiKey) apiKey = process.env.SKILLREPO_ACCESS_KEY || null;
|
|
134
|
+
} else {
|
|
135
|
+
if (!serverUrl || !apiKey) {
|
|
136
|
+
const config = readGlobalConfig();
|
|
137
|
+
if (config) {
|
|
138
|
+
serverUrl = serverUrl || config.serverUrl;
|
|
139
|
+
apiKey = apiKey || config.apiKey;
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
if (!serverUrl) serverUrl = process.env.SKILLREPO_URL || "https://skillrepo.dev";
|
|
143
|
+
if (!apiKey) apiKey = process.env.SKILLREPO_ACCESS_KEY || null;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
if (!apiKey && opts.requireAuth !== false) {
|
|
147
|
+
throw authError("No access key configured.", {
|
|
148
|
+
hint: "Run `skillrepo init` first, or pass --key sk_live_...",
|
|
149
|
+
});
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
return {
|
|
153
|
+
serverUrl,
|
|
154
|
+
apiKey,
|
|
155
|
+
global: flagState.global,
|
|
156
|
+
vendors: flagState.vendors,
|
|
157
|
+
json: flagState.json,
|
|
158
|
+
};
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* Compute the vendor list to pass to file-write / sync. Defaults to
|
|
163
|
+
* `["claudeCode"]` when neither `--ide` nor `--global` is provided —
|
|
164
|
+
* the v3.0.0 CLI deliberately does NOT silently fall back to
|
|
165
|
+
* `[claudeCode, cursor]` like v2.0.0 did. The user opts in.
|
|
166
|
+
*/
|
|
167
|
+
export function effectiveVendors(flags) {
|
|
168
|
+
if (flags.global) return undefined; // global mode ignores vendors
|
|
169
|
+
if (flags.vendors) return flags.vendors;
|
|
170
|
+
return ["claudeCode"];
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// ── Internals ──────────────────────────────────────────────────────────
|
|
174
|
+
|
|
175
|
+
function parseVendorList(raw) {
|
|
176
|
+
if (typeof raw !== "string") {
|
|
177
|
+
throw validationError(`--ide expects a comma-separated list, got: ${raw}`);
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
// First pass: collect tokens, normalize aliases, detect `all`.
|
|
181
|
+
const pieces = raw.split(",").map((p) => p.trim()).filter(Boolean);
|
|
182
|
+
const normalized = pieces.map((v) => VENDOR_ALIASES[v] ?? v);
|
|
183
|
+
const hasAll = normalized.includes("all");
|
|
184
|
+
const otherVendors = normalized.filter((v) => v !== "all");
|
|
185
|
+
|
|
186
|
+
// Defense against confusing input: `--ide cursor,all` is ambiguous
|
|
187
|
+
// — the user might think it means "cursor first, then everything"
|
|
188
|
+
// or "all minus duplicates". Both interpretations are wrong because
|
|
189
|
+
// `all` is the full set. Reject the combination so the user is
|
|
190
|
+
// forced to pick one.
|
|
191
|
+
if (hasAll && otherVendors.length > 0) {
|
|
192
|
+
throw validationError(
|
|
193
|
+
`--ide cannot mix "all" with other vendors: "${raw}"`,
|
|
194
|
+
{ hint: "Use --ide all OR --ide claude,cursor,... — not both." },
|
|
195
|
+
);
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
if (hasAll) {
|
|
199
|
+
return ["claudeCode", "cursor", "windsurf", "vscode"];
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
if (normalized.length === 0) {
|
|
203
|
+
throw validationError("--ide list is empty.");
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
// Validate each vendor token
|
|
207
|
+
for (const resolved of normalized) {
|
|
208
|
+
if (!VALID_VENDORS.has(resolved)) {
|
|
209
|
+
// Find the original (un-aliased) form for the error message
|
|
210
|
+
const original = pieces[normalized.indexOf(resolved)];
|
|
211
|
+
throw validationError(`Unknown --ide vendor: "${original}"`, {
|
|
212
|
+
hint: "Use claudeCode (or 'claude'), cursor, windsurf, vscode, or 'all'.",
|
|
213
|
+
});
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
return normalized;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
function readGlobalConfig() {
|
|
221
|
+
const path = globalConfigPath();
|
|
222
|
+
if (!existsSync(path)) return null;
|
|
223
|
+
try {
|
|
224
|
+
const parsed = JSON.parse(readFileSync(path, "utf-8"));
|
|
225
|
+
if (parsed && typeof parsed === "object") return parsed;
|
|
226
|
+
return null;
|
|
227
|
+
} catch {
|
|
228
|
+
return null;
|
|
229
|
+
}
|
|
230
|
+
}
|
|
@@ -0,0 +1,238 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Global skillrepo config reader/writer — ~/.claude/skillrepo/config.json
|
|
3
|
+
*
|
|
4
|
+
* Built for PR3b's `init` rewrite (#673). The config file is the
|
|
5
|
+
* single source of truth for credentials that every command resolves
|
|
6
|
+
* from when `--key`/`--url` are not provided. The existing
|
|
7
|
+
* `cli-config.mjs` helper already READS this file (via the
|
|
8
|
+
* `readGlobalConfig` helper inside `resolveFlags`); this module adds
|
|
9
|
+
* the WRITE side so `init` can persist credentials after a
|
|
10
|
+
* successful validation.
|
|
11
|
+
*
|
|
12
|
+
* Schema:
|
|
13
|
+
*
|
|
14
|
+
* {
|
|
15
|
+
* "schemaVersion": 1,
|
|
16
|
+
* "apiKey": "sk_live_...",
|
|
17
|
+
* "serverUrl": "https://skillrepo.dev",
|
|
18
|
+
* "accountSlug": "alice",
|
|
19
|
+
* "accountId": "acc_...",
|
|
20
|
+
* "userId": "user_...",
|
|
21
|
+
* "writtenAt": "2026-04-15T00:00:00Z"
|
|
22
|
+
* }
|
|
23
|
+
*
|
|
24
|
+
* Only `apiKey` and `serverUrl` are structurally required by the
|
|
25
|
+
* command layer (everything else is metadata for diagnostics). A
|
|
26
|
+
* missing field is treated as a corrupt-but-recoverable state — the
|
|
27
|
+
* reader returns null and the command layer falls back to env vars
|
|
28
|
+
* or prompts.
|
|
29
|
+
*
|
|
30
|
+
* File permissions: the config file contains a live access key.
|
|
31
|
+
* chmod 0600 on POSIX (owner read/write only). Skipped on Windows
|
|
32
|
+
* (where 0600 is a no-op anyway and chmod doesn't prevent
|
|
33
|
+
* Administrator reads).
|
|
34
|
+
*/
|
|
35
|
+
|
|
36
|
+
import {
|
|
37
|
+
existsSync,
|
|
38
|
+
mkdirSync,
|
|
39
|
+
readFileSync,
|
|
40
|
+
writeFileSync,
|
|
41
|
+
chmodSync,
|
|
42
|
+
renameSync,
|
|
43
|
+
unlinkSync,
|
|
44
|
+
} from "node:fs";
|
|
45
|
+
import { dirname } from "node:path";
|
|
46
|
+
import { platform } from "node:os";
|
|
47
|
+
|
|
48
|
+
import { globalConfigPath } from "./paths.mjs";
|
|
49
|
+
import { diskError, validationError } from "./errors.mjs";
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Current schema version. Bump this on any structural change.
|
|
53
|
+
* Readers treat unknown future versions as "ignore this file,
|
|
54
|
+
* do a full re-init" rather than crashing.
|
|
55
|
+
*/
|
|
56
|
+
export const CONFIG_SCHEMA_VERSION = 1;
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* @typedef {Object} GlobalConfig
|
|
60
|
+
* @property {number} schemaVersion
|
|
61
|
+
* @property {string} apiKey
|
|
62
|
+
* @property {string} serverUrl
|
|
63
|
+
* @property {string} [accountSlug]
|
|
64
|
+
* @property {string} [accountId]
|
|
65
|
+
* @property {string} [userId]
|
|
66
|
+
* @property {string} [writtenAt]
|
|
67
|
+
*/
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Read the global config file. Returns null if the file does not
|
|
71
|
+
* exist, is corrupt, or has an unrecognized schema version.
|
|
72
|
+
*
|
|
73
|
+
* Corrupt cases return null rather than throwing because the command
|
|
74
|
+
* layer has a well-defined fallback (env vars, interactive prompt)
|
|
75
|
+
* and we don't want a garbled config file to crash the CLI.
|
|
76
|
+
*
|
|
77
|
+
* @returns {GlobalConfig | null}
|
|
78
|
+
*/
|
|
79
|
+
export function readConfig() {
|
|
80
|
+
const path = globalConfigPath();
|
|
81
|
+
if (!existsSync(path)) return null;
|
|
82
|
+
|
|
83
|
+
let raw;
|
|
84
|
+
try {
|
|
85
|
+
raw = readFileSync(path, "utf-8");
|
|
86
|
+
} catch {
|
|
87
|
+
return null;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
let parsed;
|
|
91
|
+
try {
|
|
92
|
+
parsed = JSON.parse(raw);
|
|
93
|
+
} catch {
|
|
94
|
+
return null;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
if (!parsed || typeof parsed !== "object") return null;
|
|
98
|
+
|
|
99
|
+
// Reject unknown future schema versions. A null return here
|
|
100
|
+
// triggers the caller's prompt-for-new-config path — acceptable
|
|
101
|
+
// degradation for an old CLI reading a new-format file.
|
|
102
|
+
if (parsed.schemaVersion !== undefined && parsed.schemaVersion !== CONFIG_SCHEMA_VERSION) {
|
|
103
|
+
return null;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// Legacy fallback: the v2.0.0 CLI wrote config files WITHOUT a
|
|
107
|
+
// schemaVersion field. Accept those as schemaVersion 1 so the new
|
|
108
|
+
// CLI can transparently upgrade. The next writeConfig() call will
|
|
109
|
+
// add the explicit version field.
|
|
110
|
+
if (parsed.schemaVersion === undefined) {
|
|
111
|
+
parsed.schemaVersion = CONFIG_SCHEMA_VERSION;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// Required fields
|
|
115
|
+
if (typeof parsed.apiKey !== "string" || !parsed.apiKey) return null;
|
|
116
|
+
if (typeof parsed.serverUrl !== "string" || !parsed.serverUrl) return null;
|
|
117
|
+
|
|
118
|
+
return parsed;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Write the global config file atomically. Creates the parent
|
|
123
|
+
* directory if missing. Chmods the file to 0600 on POSIX.
|
|
124
|
+
*
|
|
125
|
+
* Throws `diskError` on any filesystem failure so the init command
|
|
126
|
+
* can surface a clean error with exit code 3.
|
|
127
|
+
*
|
|
128
|
+
* @param {Partial<GlobalConfig>} config - Caller-provided fields.
|
|
129
|
+
* Required: apiKey, serverUrl. Optional: everything else.
|
|
130
|
+
* @returns {"created" | "updated"} Whether the file existed before.
|
|
131
|
+
*/
|
|
132
|
+
export function writeConfig(config) {
|
|
133
|
+
if (!config || typeof config !== "object") {
|
|
134
|
+
throw validationError("writeConfig: config object is required");
|
|
135
|
+
}
|
|
136
|
+
if (typeof config.apiKey !== "string" || !config.apiKey) {
|
|
137
|
+
throw validationError("writeConfig: apiKey is required");
|
|
138
|
+
}
|
|
139
|
+
if (typeof config.serverUrl !== "string" || !config.serverUrl) {
|
|
140
|
+
throw validationError("writeConfig: serverUrl is required");
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
const path = globalConfigPath();
|
|
144
|
+
const existed = existsSync(path);
|
|
145
|
+
const dir = dirname(path);
|
|
146
|
+
|
|
147
|
+
// Ensure the parent dir exists — the dir itself holds other
|
|
148
|
+
// CLI state (.last-sync, maybe more in the future), so creating
|
|
149
|
+
// it is expected.
|
|
150
|
+
try {
|
|
151
|
+
if (!existsSync(dir)) {
|
|
152
|
+
mkdirSync(dir, { recursive: true });
|
|
153
|
+
}
|
|
154
|
+
} catch (err) {
|
|
155
|
+
throw diskError(`Cannot create ${dir}: ${err.message}`, { cause: err });
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// Build the persisted shape. Preserve unknown fields from the
|
|
159
|
+
// existing file if any — a future CLI version might add fields
|
|
160
|
+
// that this writer doesn't know about.
|
|
161
|
+
let existing = {};
|
|
162
|
+
if (existed) {
|
|
163
|
+
const current = readConfig();
|
|
164
|
+
if (current) existing = current;
|
|
165
|
+
}
|
|
166
|
+
const merged = {
|
|
167
|
+
...existing,
|
|
168
|
+
schemaVersion: CONFIG_SCHEMA_VERSION,
|
|
169
|
+
apiKey: config.apiKey,
|
|
170
|
+
serverUrl: config.serverUrl,
|
|
171
|
+
writtenAt: new Date().toISOString(),
|
|
172
|
+
};
|
|
173
|
+
// Copy optional fields if provided
|
|
174
|
+
for (const key of ["accountSlug", "accountId", "userId"]) {
|
|
175
|
+
if (config[key] !== undefined) merged[key] = config[key];
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
// Atomic write via temp-file + rename. Matches the file-write.mjs
|
|
179
|
+
// pattern: the config file is never in a half-written state, so
|
|
180
|
+
// a crash mid-write can't leave it corrupted.
|
|
181
|
+
const tmpPath = `${path}.tmp`;
|
|
182
|
+
try {
|
|
183
|
+
writeFileSync(tmpPath, JSON.stringify(merged, null, 2) + "\n", "utf-8");
|
|
184
|
+
} catch (err) {
|
|
185
|
+
throw diskError(`Cannot write ${tmpPath}: ${err.message}`, { cause: err });
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
// chmod the temp file before renaming so the destination never
|
|
189
|
+
// exists with world-readable perms (which would be a brief
|
|
190
|
+
// credential leak window on a shared system).
|
|
191
|
+
if (platform() !== "win32") {
|
|
192
|
+
try {
|
|
193
|
+
chmodSync(tmpPath, 0o600);
|
|
194
|
+
} catch {
|
|
195
|
+
// Non-fatal — chmod failure doesn't corrupt the file, it just
|
|
196
|
+
// means permissions are looser than we'd like. We intentionally
|
|
197
|
+
// DON'T emit a warning here: the round-1 review caught that
|
|
198
|
+
// the warning used the tmp path (which doesn't exist after
|
|
199
|
+
// rename), and mixing stream output into a pure I/O helper
|
|
200
|
+
// leaks concerns across layers. Callers that care about
|
|
201
|
+
// permissions can stat the file themselves after the write.
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
try {
|
|
206
|
+
// renameSync on POSIX is atomic on the same filesystem
|
|
207
|
+
renameSync(tmpPath, path);
|
|
208
|
+
} catch (err) {
|
|
209
|
+
// Round-1 review fix: clean up the stale .tmp file on rename
|
|
210
|
+
// failure so a live access key isn't left behind on disk. The
|
|
211
|
+
// unlink is best-effort — if IT also fails, the original
|
|
212
|
+
// rename error is still the one we surface to the caller.
|
|
213
|
+
try {
|
|
214
|
+
unlinkSync(tmpPath);
|
|
215
|
+
} catch {
|
|
216
|
+
/* best-effort */
|
|
217
|
+
}
|
|
218
|
+
throw diskError(`Cannot install ${path}: ${err.message}`, { cause: err });
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
return existed ? "updated" : "created";
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/**
|
|
225
|
+
* Delete the global config file. Returns true if the file existed
|
|
226
|
+
* and was removed, false if it wasn't there. Used by tests and
|
|
227
|
+
* future `skillrepo logout` (not yet implemented).
|
|
228
|
+
*/
|
|
229
|
+
export function clearConfig() {
|
|
230
|
+
const path = globalConfigPath();
|
|
231
|
+
if (!existsSync(path)) return false;
|
|
232
|
+
try {
|
|
233
|
+
unlinkSync(path);
|
|
234
|
+
return true;
|
|
235
|
+
} catch (err) {
|
|
236
|
+
throw diskError(`Cannot delete ${path}: ${err.message}`, { cause: err });
|
|
237
|
+
}
|
|
238
|
+
}
|
package/src/lib/detect-ides.mjs
CHANGED
|
@@ -42,22 +42,3 @@ export function formatDetectedIdes(detected) {
|
|
|
42
42
|
{ name: "VS Code + Copilot", key: "vscode", detected: detected.vscode },
|
|
43
43
|
];
|
|
44
44
|
}
|
|
45
|
-
|
|
46
|
-
/**
|
|
47
|
-
* Get the list of IDE keys that were detected.
|
|
48
|
-
* If none detected, defaults to Claude Code + Cursor (most common pair).
|
|
49
|
-
* @param {DetectedIdes} detected
|
|
50
|
-
* @returns {string[]}
|
|
51
|
-
*/
|
|
52
|
-
export function getDetectedIdeKeys(detected) {
|
|
53
|
-
const keys = Object.entries(detected)
|
|
54
|
-
.filter(([, v]) => v)
|
|
55
|
-
.map(([k]) => k);
|
|
56
|
-
|
|
57
|
-
// Default to Claude Code + Cursor if nothing detected
|
|
58
|
-
if (keys.length === 0) {
|
|
59
|
-
return ["claudeCode", "cursor"];
|
|
60
|
-
}
|
|
61
|
-
|
|
62
|
-
return keys;
|
|
63
|
-
}
|