skillrepo 3.1.0 → 3.1.2
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 +6 -2
- package/package.json +1 -1
- package/src/commands/init-session-sync.mjs +307 -0
- package/src/commands/init.mjs +111 -101
- package/src/commands/session-sync-actions.mjs +92 -0
- package/src/lib/artifact-registry.mjs +43 -3
- package/src/lib/binary-locator.mjs +99 -0
- package/src/lib/cli-config.mjs +16 -3
- package/src/lib/cli-version.mjs +56 -0
- package/src/lib/config.mjs +6 -3
- package/src/lib/file-write.mjs +8 -3
- package/src/lib/fs-utils.mjs +9 -10
- package/src/lib/global-install.mjs +387 -0
- package/src/lib/mcp-merge.mjs +16 -5
- package/src/lib/mergers/session-hook.mjs +125 -33
- package/src/lib/platform.mjs +124 -0
- package/src/lib/sync.mjs +26 -0
- package/src/lib/transient-runners.mjs +204 -0
- package/src/test/commands/add.test.mjs +10 -4
- package/src/test/commands/get.test.mjs +10 -4
- package/src/test/commands/init.test.mjs +889 -15
- package/src/test/commands/list.test.mjs +10 -4
- package/src/test/commands/remove.test.mjs +10 -4
- package/src/test/commands/search.test.mjs +10 -4
- package/src/test/commands/session-sync-actions.test.mjs +74 -0
- package/src/test/commands/session-sync.test.mjs +25 -23
- package/src/test/commands/uninstall.test.mjs +20 -14
- package/src/test/commands/update.test.mjs +10 -4
- package/src/test/helpers/mock-spawn.mjs +121 -0
- package/src/test/helpers/sandbox-home.mjs +161 -0
- package/src/test/helpers/skillrepo-shim.mjs +133 -0
- package/src/test/integration/file-write.integration.test.mjs +10 -4
- package/src/test/lib/cli-config.test.mjs +182 -4
- package/src/test/lib/cli-version.test.mjs +47 -0
- package/src/test/lib/config.test.mjs +10 -4
- package/src/test/lib/file-write.test.mjs +24 -10
- package/src/test/lib/global-install.test.mjs +424 -0
- package/src/test/lib/mcp-merge.test.mjs +13 -7
- package/src/test/lib/paths.test.mjs +10 -4
- package/src/test/lib/platform.test.mjs +135 -0
- package/src/test/lib/sync.test.mjs +20 -4
- package/src/test/lib/transient-runners.test.mjs +270 -0
- package/src/test/mergers/session-hook.test.mjs +722 -22
- package/src/test/mergers/uninstall-settings.test.mjs +12 -1
- package/src/test/mergers/uninstall-windsurf-mcp.test.mjs +10 -4
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Single source of truth for the `sessionSync.action` enum surfaced
|
|
3
|
+
* by `skillrepo init` (#894 / v3.1.2).
|
|
4
|
+
*
|
|
5
|
+
* The enum appears in three places that MUST stay in sync:
|
|
6
|
+
* - the `--json` output's `sessionSync.action` field (consumer
|
|
7
|
+
* contract for CI scripts and programmatic callers)
|
|
8
|
+
* - the human-readable summary at the end of init
|
|
9
|
+
* - the README's `init` flag table
|
|
10
|
+
*
|
|
11
|
+
* Before this module existed, the values were declared inline in a
|
|
12
|
+
* JSDoc comment block and re-typed as string literals at every
|
|
13
|
+
* assignment site — drift risk. Centralizing here means adding a
|
|
14
|
+
* new value is one edit (this file + the consumer that needs it),
|
|
15
|
+
* not a grep-and-pray pass across init.mjs.
|
|
16
|
+
*
|
|
17
|
+
* The enum is `Object.freeze`d so callers can't mutate it. JS
|
|
18
|
+
* doesn't have first-class enums; this is the standard idiom.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* @typedef {"installed" | "updated" | "unchanged" | "opted-out" | "declined" | "not-applicable" | "skipped" | "failed"} SessionSyncActionValue
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Action enum constants. Use these instead of string literals at
|
|
27
|
+
* assignment sites. Values:
|
|
28
|
+
*
|
|
29
|
+
* - `Installed` — fresh hook write
|
|
30
|
+
* - `Updated` — existing hook found, command differed,
|
|
31
|
+
* replaced in place (e.g. binary path changed)
|
|
32
|
+
* - `Unchanged` — identical hook already present, no write
|
|
33
|
+
* - `OptedOut` — `--no-session-sync` was passed
|
|
34
|
+
* - `Declined` — user said no at the prompt
|
|
35
|
+
* - `NotApplicable` — Claude Code wasn't a vendor target (the hook
|
|
36
|
+
* is Claude-specific, would never fire)
|
|
37
|
+
* - `Skipped` — install offer was accepted but the install
|
|
38
|
+
* failed (npm error, version-read error,
|
|
39
|
+
* prerequisite missing). Distinct from
|
|
40
|
+
* `OptedOut` (user explicit) and `Declined`
|
|
41
|
+
* (user said no).
|
|
42
|
+
* - `Failed` — disk error while writing the settings file
|
|
43
|
+
* (corrupt JSON, permissions, etc.)
|
|
44
|
+
*/
|
|
45
|
+
export const SessionSyncAction = Object.freeze({
|
|
46
|
+
Installed: "installed",
|
|
47
|
+
Updated: "updated",
|
|
48
|
+
Unchanged: "unchanged",
|
|
49
|
+
OptedOut: "opted-out",
|
|
50
|
+
Declined: "declined",
|
|
51
|
+
NotApplicable: "not-applicable",
|
|
52
|
+
Skipped: "skipped",
|
|
53
|
+
Failed: "failed",
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* True when the action represents a state where the SessionStart
|
|
58
|
+
* hook is currently in place on disk (and therefore the global
|
|
59
|
+
* `skillrepo` binary referenced by the hook is also active on PATH,
|
|
60
|
+
* because `mergeSessionHook` only emits these actions when it
|
|
61
|
+
* successfully wrote/found a working hook).
|
|
62
|
+
*
|
|
63
|
+
* Used by `init` to suppress the now-stale "install globally"
|
|
64
|
+
* Next-Steps tip after step 6 has put a hook in place.
|
|
65
|
+
*
|
|
66
|
+
* @param {string} action
|
|
67
|
+
* @returns {boolean}
|
|
68
|
+
*/
|
|
69
|
+
export function isHookActive(action) {
|
|
70
|
+
return (
|
|
71
|
+
action === SessionSyncAction.Installed ||
|
|
72
|
+
action === SessionSyncAction.Updated ||
|
|
73
|
+
action === SessionSyncAction.Unchanged
|
|
74
|
+
);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Frozen array of all valid `sessionSync.action` string values.
|
|
79
|
+
* Useful for runtime validation in tests and for the JSON-schema-
|
|
80
|
+
* style documentation generator.
|
|
81
|
+
*
|
|
82
|
+
* Built from `Object.values(SessionSyncAction)` so it stays in sync
|
|
83
|
+
* automatically. We use a frozen array (rather than a Set) because
|
|
84
|
+
* `Object.freeze` on a Set does NOT prevent `.add()` from mutating
|
|
85
|
+
* the internal storage — JS frozen-Set semantics are surprising.
|
|
86
|
+
* Frozen arrays ARE immutable end-to-end. Membership checks are
|
|
87
|
+
* `.includes()` instead of `.has()`; the values list is small (8)
|
|
88
|
+
* so the linear scan is irrelevant.
|
|
89
|
+
*/
|
|
90
|
+
export const SESSION_SYNC_ACTION_VALUES = Object.freeze(
|
|
91
|
+
Object.values(SessionSyncAction),
|
|
92
|
+
);
|
|
@@ -88,8 +88,48 @@ export const VSCODE_INPUT_ID = "skillrepo-api-key";
|
|
|
88
88
|
/**
|
|
89
89
|
* Substring that identifies a SessionStart hook command entry as
|
|
90
90
|
* SkillRepo-owned. The #884 installer writes a hook whose `command`
|
|
91
|
-
* field
|
|
92
|
-
* command contains
|
|
91
|
+
* field ends with `<binary-path> update --session-hook ...`; any
|
|
92
|
+
* entry whose command contains ` update --session-hook` (with the
|
|
93
|
+
* leading space) is removed by the uninstall path.
|
|
94
|
+
*
|
|
95
|
+
* The leading space is a lightweight word boundary — it requires
|
|
96
|
+
* that `update` is preceded by whitespace (i.e. it's an argv token
|
|
97
|
+
* after the binary path), not a suffix of a longer identifier like
|
|
98
|
+
* `toolupdate` or `postupdate`. Without the space, a hypothetical
|
|
99
|
+
* binary at `/usr/local/bin/myapp-update` invoked with
|
|
100
|
+
* `--session-hook` as `/usr/local/bin/myapp-update --session-hook`
|
|
101
|
+
* would NOT match (because the substring would be `-update
|
|
102
|
+
* --session-hook`, not ` update --session-hook`), whereas a naive
|
|
103
|
+
* `update --session-hook` fingerprint would have.
|
|
104
|
+
*
|
|
105
|
+
* The leading space does NOT eliminate all false-positive classes.
|
|
106
|
+
* A command like `brew update --session-hook` DOES match the
|
|
107
|
+
* fingerprint — the space between `brew` and `update` is exactly
|
|
108
|
+
* what we key on. The primary protection against real-world false
|
|
109
|
+
* positives is the specificity of the two-token combination
|
|
110
|
+
* `update --session-hook` itself: `--session-hook` is not a
|
|
111
|
+
* conventional flag name used by tools other than SkillRepo, so the
|
|
112
|
+
* chance of a coincidental match is astronomically low. The test
|
|
113
|
+
* at `session-hook.test.mjs` "the fingerprint is specific enough
|
|
114
|
+
* that innocuous user hooks do NOT match it" enumerates plausible
|
|
115
|
+
* user-hook commands and confirms none trip the predicate.
|
|
116
|
+
*
|
|
117
|
+
* The fingerprint is also deliberately platform-neutral. Earlier
|
|
118
|
+
* versions matched the longer `skillrepo update --session-hook`
|
|
119
|
+
* substring, but that pattern silently fails to match Windows hook
|
|
120
|
+
* commands because npm installs the CLI as a `.cmd` shim — the
|
|
121
|
+
* absolute path on Windows ends `...\skillrepo.cmd`, which puts the
|
|
122
|
+
* `.cmd` extension between `skillrepo` and `update` in the command
|
|
123
|
+
* string. The shorter ` update --session-hook` substring is present
|
|
124
|
+
* on both:
|
|
125
|
+
* POSIX: `/usr/local/bin/skillrepo update --session-hook 2>&1 || true`
|
|
126
|
+
* Windows: `C:\path\skillrepo.cmd update --session-hook 2>&1`
|
|
127
|
+
*
|
|
128
|
+
* Backward-compat: any v3.1.0 hook contains
|
|
129
|
+
* `skillrepo update --session-hook`, which is a strict superset of
|
|
130
|
+
* ` update --session-hook` (the space between `skillrepo` and `update`
|
|
131
|
+
* is the space we're matching). So upgrades still correctly identify
|
|
132
|
+
* and update the old entry in place.
|
|
93
133
|
*
|
|
94
134
|
* Exported so #884's installer can import and use the same constant —
|
|
95
135
|
* this is the module boundary that makes #884 depend on #885 rather
|
|
@@ -97,7 +137,7 @@ export const VSCODE_INPUT_ID = "skillrepo-api-key";
|
|
|
97
137
|
* 5.3) notes the bidirectional-fingerprint requirement; centralizing
|
|
98
138
|
* it here enforces it at the language level.
|
|
99
139
|
*/
|
|
100
|
-
export const SESSION_HOOK_FINGERPRINT = "
|
|
140
|
+
export const SESSION_HOOK_FINGERPRINT = " update --session-hook";
|
|
101
141
|
|
|
102
142
|
// ── Artifact descriptors ────────────────────────────────────────────
|
|
103
143
|
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Cross-platform binary locator with optional transient-runner
|
|
3
|
+
* filtering (#894 / v3.1.2).
|
|
4
|
+
*
|
|
5
|
+
* Wraps `where` (Windows) / `which` (POSIX) to find a named binary
|
|
6
|
+
* on PATH and returns its absolute path. The two flag knobs
|
|
7
|
+
* `skipIfTransient` and `filterTransient` were extracted from the
|
|
8
|
+
* v3.1.2 first cleanup pass: previously two near-identical functions
|
|
9
|
+
* (`resolveSkillrepoBinary` in `mergers/session-hook.mjs`,
|
|
10
|
+
* `resolveGlobalBinary` in `lib/global-install.mjs`) duplicated this
|
|
11
|
+
* logic with the only differences being:
|
|
12
|
+
*
|
|
13
|
+
* - `resolveSkillrepoBinary` had an early-return guard against
|
|
14
|
+
* `isNpxInvocation()` (now `isTransientRunnerInvocation`) — the
|
|
15
|
+
* `skipIfTransient` flag captures that.
|
|
16
|
+
*
|
|
17
|
+
* - `resolveGlobalBinary` filtered `_npx`-cache results from the
|
|
18
|
+
* locator output — the `filterTransient` flag (powered by
|
|
19
|
+
* `isTransientCachePath` from `lib/transient-runners.mjs`)
|
|
20
|
+
* captures that and extends it to all package runners (pnpm
|
|
21
|
+
* dlx, yarn berry dlx, bunx).
|
|
22
|
+
*
|
|
23
|
+
* Both flags default to false so the function behaves like a plain
|
|
24
|
+
* `which`/`where` wrapper unless the caller opts into the extra
|
|
25
|
+
* semantics.
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
import { execFileSync } from "node:child_process";
|
|
29
|
+
import { isAbsolute } from "node:path";
|
|
30
|
+
import { platformConventions } from "./platform.mjs";
|
|
31
|
+
import {
|
|
32
|
+
isTransientCachePath,
|
|
33
|
+
isTransientRunnerInvocation,
|
|
34
|
+
} from "./transient-runners.mjs";
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Resolve the absolute path of `binaryName` on PATH.
|
|
38
|
+
*
|
|
39
|
+
* @param {string} binaryName - The bare command name (e.g.
|
|
40
|
+
* `"skillrepo"`). Resolved via the platform's binary locator
|
|
41
|
+
* (`which` on POSIX, `where` on Windows).
|
|
42
|
+
* @param {object} [options]
|
|
43
|
+
* @param {boolean} [options.skipIfTransient=false] - When true,
|
|
44
|
+
* return `null` immediately if the current process is itself
|
|
45
|
+
* a transient-runner invocation. Used by callers that bake
|
|
46
|
+
* the resolved path into long-lived state (e.g. a SessionStart
|
|
47
|
+
* hook command) and must not bind to a transient cache path.
|
|
48
|
+
* @param {boolean} [options.filterTransient=false] - When true,
|
|
49
|
+
* ignore locator output lines that point inside a transient
|
|
50
|
+
* runner's cache directory. Used by callers that explicitly
|
|
51
|
+
* want a STABLE global install at a non-cache path.
|
|
52
|
+
* @param {NodeJS.Platform} [options.platform] - Override for tests.
|
|
53
|
+
* Production callers leave unset.
|
|
54
|
+
* @returns {string | null} The absolute path of the first matching
|
|
55
|
+
* non-filtered locator output line, or `null` if no match.
|
|
56
|
+
*/
|
|
57
|
+
export function resolveBinaryOnPath(
|
|
58
|
+
binaryName,
|
|
59
|
+
{ skipIfTransient = false, filterTransient = false, platform: platformOverride } = {},
|
|
60
|
+
) {
|
|
61
|
+
if (skipIfTransient && isTransientRunnerInvocation()) {
|
|
62
|
+
return null;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const conv = platformConventions({ platform: platformOverride });
|
|
66
|
+
|
|
67
|
+
let raw;
|
|
68
|
+
try {
|
|
69
|
+
// 3-second cap — `which`/`where` typically return in
|
|
70
|
+
// milliseconds, but a pathological PATH (network filesystem,
|
|
71
|
+
// hung shell alias) could otherwise stall the whole CLI.
|
|
72
|
+
raw = execFileSync(conv.binaryLocator, [binaryName], {
|
|
73
|
+
encoding: "utf-8",
|
|
74
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
75
|
+
timeout: 3000,
|
|
76
|
+
});
|
|
77
|
+
} catch {
|
|
78
|
+
// Locator exits non-zero when the binary isn't on PATH, OR
|
|
79
|
+
// throws ENOENT when the locator itself doesn't exist (rare —
|
|
80
|
+
// a Windows install missing `where.exe`, or a minimal POSIX
|
|
81
|
+
// image without `which`). Both collapse to "binary not
|
|
82
|
+
// resolvable" from the caller's perspective.
|
|
83
|
+
return null;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// Windows `where.exe` returns one match per line; POSIX `which`
|
|
87
|
+
// returns a single line. Take the first match that survives the
|
|
88
|
+
// absolute-path and (optional) transient-cache filters.
|
|
89
|
+
const lines = raw
|
|
90
|
+
.split(/\r?\n/)
|
|
91
|
+
.map((s) => s.trim())
|
|
92
|
+
.filter(Boolean);
|
|
93
|
+
for (const line of lines) {
|
|
94
|
+
if (!isAbsolute(line)) continue;
|
|
95
|
+
if (filterTransient && isTransientCachePath(line)) continue;
|
|
96
|
+
return line;
|
|
97
|
+
}
|
|
98
|
+
return null;
|
|
99
|
+
}
|
package/src/lib/cli-config.mjs
CHANGED
|
@@ -8,9 +8,13 @@
|
|
|
8
8
|
* 4. Hard-error with an actionable hint pointing at `init` if no
|
|
9
9
|
* key is configured
|
|
10
10
|
*
|
|
11
|
-
* Centralizing this here keeps the
|
|
12
|
-
*
|
|
13
|
-
*
|
|
11
|
+
* Centralizing this here keeps the command modules thin and means
|
|
12
|
+
* a future change to credential resolution (e.g., adding a keychain
|
|
13
|
+
* backend) is a single edit instead of N.
|
|
14
|
+
*
|
|
15
|
+
* Process-environment helpers for transient package-runner detection
|
|
16
|
+
* (npx, pnpm dlx, yarn dlx, bunx) live in `lib/transient-runners.mjs`
|
|
17
|
+
* and `lib/binary-locator.mjs` — import from there directly.
|
|
14
18
|
*/
|
|
15
19
|
|
|
16
20
|
import { existsSync, readFileSync } from "node:fs";
|
|
@@ -85,6 +89,15 @@ export function resolveFlags(argv, opts = {}) {
|
|
|
85
89
|
} else if (arg === "--help" || arg === "-h") {
|
|
86
90
|
// Dispatcher should have intercepted this. Defensive no-op.
|
|
87
91
|
continue;
|
|
92
|
+
} else if (arg === "--verbose") {
|
|
93
|
+
// Global flag set by the dispatcher into SKILLREPO_VERBOSE=1
|
|
94
|
+
// so http.mjs's retry logger can honor it. It's a first-class
|
|
95
|
+
// flag, not an unknown arg — accept it silently in every
|
|
96
|
+
// command that passes through resolveFlags. Before this
|
|
97
|
+
// branch existed, any command that consumed argv via
|
|
98
|
+
// resolveFlags rejected `--verbose` with "Unknown argument",
|
|
99
|
+
// breaking the flag documented in the top-level --help.
|
|
100
|
+
continue;
|
|
88
101
|
} else {
|
|
89
102
|
// Allow the caller to consume a positional arg before we treat
|
|
90
103
|
// it as unknown. This is how `get @owner/name` and
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Read the running CLI's own version from the package.json that ships
|
|
3
|
+
* with the tarball.
|
|
4
|
+
*
|
|
5
|
+
* Why a dedicated module
|
|
6
|
+
* ----------------------
|
|
7
|
+
* v3.1.2's init auto-install feature needs to run
|
|
8
|
+
* `npm install -g skillrepo@<version>` with the SAME version as the
|
|
9
|
+
* CLI currently running init. Pinning is what keeps the fresh global
|
|
10
|
+
* install in lockstep with the npx-cache copy that just executed init,
|
|
11
|
+
* so there's no silent version drift between the two.
|
|
12
|
+
*
|
|
13
|
+
* `process.env.npm_package_version` is NOT reliable here — it's only
|
|
14
|
+
* set when npm itself spawns the process (e.g. `npm run`), not under
|
|
15
|
+
* a plain `npx skillrepo ...` or `node ./bin/skillrepo.mjs` run. So
|
|
16
|
+
* we read the package.json off disk.
|
|
17
|
+
*
|
|
18
|
+
* The read is relative to `import.meta.url`, which resolves to this
|
|
19
|
+
* module's own file URL. From `src/lib/cli-version.mjs`, two levels
|
|
20
|
+
* up is the package root — the `"files"` array in package.json
|
|
21
|
+
* includes `bin/` and `src/` alongside the package.json itself, so
|
|
22
|
+
* the file is always present in the shipped tarball.
|
|
23
|
+
*
|
|
24
|
+
* A failed read is a programming error (missing or malformed
|
|
25
|
+
* package.json in a shipped tarball means the build pipeline broke
|
|
26
|
+
* invariants). The function throws rather than soft-handling; there
|
|
27
|
+
* is no meaningful user recovery path.
|
|
28
|
+
*/
|
|
29
|
+
|
|
30
|
+
import { readFileSync } from "node:fs";
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Return the CLI's own semver version as a string (e.g. "3.1.2").
|
|
34
|
+
*
|
|
35
|
+
* @returns {string}
|
|
36
|
+
* @throws {Error} if the package.json cannot be read or parsed, or
|
|
37
|
+
* if the `version` field is missing or not a string.
|
|
38
|
+
*/
|
|
39
|
+
export function getCliVersion() {
|
|
40
|
+
// Resolve `../../package.json` relative to THIS file. This module
|
|
41
|
+
// lives at `packages/cli/src/lib/cli-version.mjs`, so two `..`
|
|
42
|
+
// segments land at the package root. Using `import.meta.url` means
|
|
43
|
+
// the resolution is invariant under tarball/symlink layout — it
|
|
44
|
+
// always points to where THIS source file actually lives on disk
|
|
45
|
+
// at runtime.
|
|
46
|
+
const pkgUrl = new URL("../../package.json", import.meta.url);
|
|
47
|
+
const raw = readFileSync(pkgUrl, "utf-8");
|
|
48
|
+
const pkg = JSON.parse(raw);
|
|
49
|
+
if (typeof pkg.version !== "string" || pkg.version.length === 0) {
|
|
50
|
+
throw new Error(
|
|
51
|
+
"cli-version: package.json has no valid `version` string. " +
|
|
52
|
+
"This indicates a broken build/publish pipeline.",
|
|
53
|
+
);
|
|
54
|
+
}
|
|
55
|
+
return pkg.version;
|
|
56
|
+
}
|
package/src/lib/config.mjs
CHANGED
|
@@ -43,10 +43,10 @@ import {
|
|
|
43
43
|
unlinkSync,
|
|
44
44
|
} from "node:fs";
|
|
45
45
|
import { dirname } from "node:path";
|
|
46
|
-
import { platform } from "node:os";
|
|
47
46
|
|
|
48
47
|
import { globalConfigPath } from "./paths.mjs";
|
|
49
48
|
import { diskError, validationError } from "./errors.mjs";
|
|
49
|
+
import { platformConventions } from "./platform.mjs";
|
|
50
50
|
|
|
51
51
|
/**
|
|
52
52
|
* Current schema version. Bump this on any structural change.
|
|
@@ -187,8 +187,11 @@ export function writeConfig(config) {
|
|
|
187
187
|
|
|
188
188
|
// chmod the temp file before renaming so the destination never
|
|
189
189
|
// exists with world-readable perms (which would be a brief
|
|
190
|
-
// credential leak window on a shared system).
|
|
191
|
-
|
|
190
|
+
// credential leak window on a shared system). Windows callers
|
|
191
|
+
// route through platformConventions().supportsPosixPermissions —
|
|
192
|
+
// see platform.mjs for why we skip chmod there instead of pretend-
|
|
193
|
+
// applying it.
|
|
194
|
+
if (platformConventions().supportsPosixPermissions) {
|
|
192
195
|
try {
|
|
193
196
|
chmodSync(tmpPath, 0o600);
|
|
194
197
|
} catch {
|
package/src/lib/file-write.mjs
CHANGED
|
@@ -50,7 +50,6 @@ import {
|
|
|
50
50
|
statSync,
|
|
51
51
|
} from "node:fs";
|
|
52
52
|
import { dirname, join, isAbsolute, relative } from "node:path";
|
|
53
|
-
import { platform } from "node:os";
|
|
54
53
|
|
|
55
54
|
import { readFileSafe, writeFileSafe } from "./fs-utils.mjs";
|
|
56
55
|
import {
|
|
@@ -63,6 +62,7 @@ import {
|
|
|
63
62
|
gitignorePath,
|
|
64
63
|
} from "./paths.mjs";
|
|
65
64
|
import { CliError, validationError, diskError } from "./errors.mjs";
|
|
65
|
+
import { platformConventions } from "./platform.mjs";
|
|
66
66
|
|
|
67
67
|
// ── Constants (mirror the server-side validators in src/lib/skills/) ────
|
|
68
68
|
|
|
@@ -562,8 +562,13 @@ function writeSkillToDir(skill, targetDir) {
|
|
|
562
562
|
}
|
|
563
563
|
}
|
|
564
564
|
|
|
565
|
-
// 2 + 3 + 4: rename dance
|
|
566
|
-
|
|
565
|
+
// 2 + 3 + 4: rename dance. POSIX is atomic on the same filesystem;
|
|
566
|
+
// Windows has to do remove-then-rename because renameSync fails on
|
|
567
|
+
// existing directory targets. The split is named via
|
|
568
|
+
// platformConventions().supportsAtomicDirectoryRename so the intent
|
|
569
|
+
// reads as a capability check, not a platform check. See
|
|
570
|
+
// platform.mjs for the rationale.
|
|
571
|
+
if (!platformConventions().supportsAtomicDirectoryRename) {
|
|
567
572
|
// Windows: rename fails on existing destinations and locked files,
|
|
568
573
|
// so we fall back to remove-then-rename. There is a window where
|
|
569
574
|
// the live target is gone but the rename has not yet completed.
|
package/src/lib/fs-utils.mjs
CHANGED
|
@@ -14,6 +14,7 @@ import {
|
|
|
14
14
|
unlinkSync,
|
|
15
15
|
} from "node:fs";
|
|
16
16
|
import { dirname } from "node:path";
|
|
17
|
+
import { platformConventions } from "./platform.mjs";
|
|
17
18
|
|
|
18
19
|
/**
|
|
19
20
|
* Read a file as UTF-8, returning null if it doesn't exist.
|
|
@@ -44,15 +45,6 @@ export function writeFileSafe(filePath, content) {
|
|
|
44
45
|
writeFileSync(filePath, content, "utf-8");
|
|
45
46
|
}
|
|
46
47
|
|
|
47
|
-
/**
|
|
48
|
-
* Write a file and mark it executable (0o755).
|
|
49
|
-
* Used for the Cursor session hook which is invoked directly via shebang.
|
|
50
|
-
*/
|
|
51
|
-
export function writeExecutable(filePath, content) {
|
|
52
|
-
writeFileSafe(filePath, content);
|
|
53
|
-
chmodSync(filePath, 0o755);
|
|
54
|
-
}
|
|
55
|
-
|
|
56
48
|
/**
|
|
57
49
|
* Check if a path exists (file or directory).
|
|
58
50
|
*/
|
|
@@ -105,7 +97,7 @@ export function writeFileAtomic(filePath, content, { mode } = {}) {
|
|
|
105
97
|
throw new Error(`Cannot write ${tmpPath}: ${err.message}`, { cause: err });
|
|
106
98
|
}
|
|
107
99
|
|
|
108
|
-
if (mode !== undefined &&
|
|
100
|
+
if (mode !== undefined && platformConventions().supportsPosixPermissions) {
|
|
109
101
|
try {
|
|
110
102
|
chmodSync(tmpPath, mode);
|
|
111
103
|
} catch {
|
|
@@ -115,6 +107,13 @@ export function writeFileAtomic(filePath, content, { mode } = {}) {
|
|
|
115
107
|
// file after the write.
|
|
116
108
|
}
|
|
117
109
|
}
|
|
110
|
+
// On Windows we deliberately skip chmod entirely. Node lets the call
|
|
111
|
+
// succeed on Windows but the mode bits don't map to anything the
|
|
112
|
+
// ACL layer enforces, so a "success" return would mislead the caller
|
|
113
|
+
// into thinking the credential file is access-restricted when it
|
|
114
|
+
// isn't. Windows users needing per-user protection should rely on
|
|
115
|
+
// %APPDATA%'s inherited ACLs (which default to the current user) or
|
|
116
|
+
// apply DACL restrictions at the OS level — outside this CLI's scope.
|
|
118
117
|
|
|
119
118
|
try {
|
|
120
119
|
renameSync(tmpPath, filePath);
|