@brainervirus/workit-core 1.0.3 → 1.0.5
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/package.json +1 -1
- package/src/core/doctor.ts +196 -17
- package/src/core/registration.ts +3 -0
- package/src/core/setup.ts +38 -9
package/package.json
CHANGED
package/src/core/doctor.ts
CHANGED
|
@@ -9,7 +9,16 @@
|
|
|
9
9
|
// header for a login lookup — token bytes never enter the report, any fix
|
|
10
10
|
// text, or any log event.
|
|
11
11
|
import { spawnSync } from "node:child_process";
|
|
12
|
-
import {
|
|
12
|
+
import {
|
|
13
|
+
existsSync,
|
|
14
|
+
lstatSync,
|
|
15
|
+
mkdirSync,
|
|
16
|
+
readFileSync,
|
|
17
|
+
readlinkSync,
|
|
18
|
+
statSync,
|
|
19
|
+
unlinkSync,
|
|
20
|
+
writeFileSync,
|
|
21
|
+
} from "node:fs";
|
|
13
22
|
import os from "node:os";
|
|
14
23
|
import path from "node:path";
|
|
15
24
|
import { SUPPORT_MATRIX } from "./support-matrix";
|
|
@@ -18,6 +27,7 @@ import { getDiagnosticLogger, isConfigObject } from "./config";
|
|
|
18
27
|
import { packageRoot } from "./package-root";
|
|
19
28
|
import {
|
|
20
29
|
CURSOR_RUNTIME_PACKAGE,
|
|
30
|
+
OPENCODE_NPM_PIN,
|
|
21
31
|
cursorHookDrift,
|
|
22
32
|
cursorHooksEntry,
|
|
23
33
|
cursorMcpServerEntry,
|
|
@@ -102,6 +112,8 @@ export type DoctorOptions = {
|
|
|
102
112
|
dev?: string;
|
|
103
113
|
cwd?: string;
|
|
104
114
|
opencodeConfig?: string;
|
|
115
|
+
/** OpenCode npm `@latest` package cache root (test seam). */
|
|
116
|
+
opencodePackageCacheDir?: string;
|
|
105
117
|
cursorSettings?: string;
|
|
106
118
|
cursorMcp?: string;
|
|
107
119
|
cursorPluginDir?: string;
|
|
@@ -120,6 +132,7 @@ type Resolved = {
|
|
|
120
132
|
cwd: string;
|
|
121
133
|
dev: string | null;
|
|
122
134
|
opencodeConfig: string;
|
|
135
|
+
opencodePackageCacheDir: string;
|
|
123
136
|
cursorSettings: string;
|
|
124
137
|
cursorMcp: string;
|
|
125
138
|
cursorPluginDir: string;
|
|
@@ -170,6 +183,9 @@ const resolve = (options: DoctorOptions): Resolved => {
|
|
|
170
183
|
dev,
|
|
171
184
|
opencodeConfig:
|
|
172
185
|
options.opencodeConfig ?? path.join(home, ".config", "opencode", "opencode.json"),
|
|
186
|
+
opencodePackageCacheDir:
|
|
187
|
+
options.opencodePackageCacheDir ??
|
|
188
|
+
path.join(home, ".cache", "opencode", "packages", "@brainervirus", "workit-opencode@latest"),
|
|
173
189
|
cursorSettings: options.cursorSettings ?? path.join(home, ".cursor", "settings.json"),
|
|
174
190
|
cursorMcp: options.cursorMcp ?? path.join(home, ".cursor", "mcp.json"),
|
|
175
191
|
cursorPluginDir:
|
|
@@ -661,15 +677,28 @@ const checkUtility = (res: Resolved): DoctorCheck => {
|
|
|
661
677
|
};
|
|
662
678
|
};
|
|
663
679
|
|
|
664
|
-
|
|
680
|
+
/** file:// pins into package-manager ephemeral caches (pnpm dlx, npx). */
|
|
681
|
+
const isFragileCachePin = (entry: string): boolean => {
|
|
682
|
+
const n = entry.replaceAll("\\", "/").toLowerCase();
|
|
683
|
+
if (!n.startsWith("file:")) return false;
|
|
684
|
+
return (
|
|
685
|
+
n.includes("/caches/pnpm/dlx/") ||
|
|
686
|
+
n.includes("/_npx/") ||
|
|
687
|
+
n.includes("/.pacquet/") ||
|
|
688
|
+
n.includes("/library/caches/pnpm/")
|
|
689
|
+
);
|
|
690
|
+
};
|
|
691
|
+
|
|
692
|
+
const staleEntry = (entry: string): "ok" | "stale" | "missing-file" | "fragile-cache" => {
|
|
665
693
|
if (!entry) return "stale";
|
|
666
694
|
if (entry.includes("git+file")) return "stale";
|
|
695
|
+
if (isFragileCachePin(entry)) return "fragile-cache";
|
|
667
696
|
if (entry.startsWith("file:")) {
|
|
668
697
|
const target = entry.replace(/^file:\/\//, "").replace(/^file:/, "");
|
|
669
698
|
return existsSync(target) ? "ok" : "missing-file";
|
|
670
699
|
}
|
|
671
|
-
//
|
|
672
|
-
//
|
|
700
|
+
// Registry package names (e.g. @brainervirus/workit-opencode) are the
|
|
701
|
+
// published pin shape — OpenCode resolves them; not labelled stale.
|
|
673
702
|
return "ok";
|
|
674
703
|
};
|
|
675
704
|
|
|
@@ -695,7 +724,7 @@ const checkStalePin = (res: Resolved): DoctorCheck => {
|
|
|
695
724
|
id: "stale_pin",
|
|
696
725
|
status: "fail",
|
|
697
726
|
detail: "no workit plugin registered in the opencode config",
|
|
698
|
-
fix:
|
|
727
|
+
fix: 'Run `workit init` and select OpenCode — pin "@brainervirus/workit-opencode"',
|
|
699
728
|
};
|
|
700
729
|
}
|
|
701
730
|
const first = staleEntry(entries[0]);
|
|
@@ -704,7 +733,15 @@ const checkStalePin = (res: Resolved): DoctorCheck => {
|
|
|
704
733
|
id: "stale_pin",
|
|
705
734
|
status: "fail",
|
|
706
735
|
detail: `stale workit pin: ${entries[0]}`,
|
|
707
|
-
fix: "
|
|
736
|
+
fix: "Run `workit init` (or install-opencode-plugin.sh for a checkout) — replace git+file pins",
|
|
737
|
+
};
|
|
738
|
+
}
|
|
739
|
+
if (first === "fragile-cache") {
|
|
740
|
+
return {
|
|
741
|
+
id: "stale_pin",
|
|
742
|
+
status: "fail",
|
|
743
|
+
detail: `workit pin points at a package-manager cache path: ${entries[0]}`,
|
|
744
|
+
fix: 'Run `workit init` and select OpenCode — pin should be "@brainervirus/workit-opencode"',
|
|
708
745
|
};
|
|
709
746
|
}
|
|
710
747
|
if (first === "missing-file") {
|
|
@@ -712,7 +749,7 @@ const checkStalePin = (res: Resolved): DoctorCheck => {
|
|
|
712
749
|
id: "stale_pin",
|
|
713
750
|
status: "fail",
|
|
714
751
|
detail: `workit pin points at a missing file: ${entries[0]}`,
|
|
715
|
-
fix: "
|
|
752
|
+
fix: "Run `workit init` (or restore the checkout and re-run install-opencode-plugin.sh)",
|
|
716
753
|
};
|
|
717
754
|
}
|
|
718
755
|
return { id: "stale_pin", status: "pass", detail: "opencode pin resolves" };
|
|
@@ -741,6 +778,13 @@ const checkStalePin = (res: Resolved): DoctorCheck => {
|
|
|
741
778
|
// `@latest` installs skip the probe entirely: the selector resolves fresh
|
|
742
779
|
// at launch, so the installed package.json version is metadata, not a
|
|
743
780
|
// freshness signal.
|
|
781
|
+
// OpenCode npm pins (`@brainervirus/workit-opencode` / `@…@latest`) are different:
|
|
782
|
+
// OpenCode freezes the first resolved version under
|
|
783
|
+
// `~/.cache/opencode/packages/@brainervirus/workit-opencode@latest` and does not
|
|
784
|
+
// re-resolve on later launches. Doctor compares that cache to the published
|
|
785
|
+
// package (same CA-04 fail-open seam) and tells the user to delete the cache
|
|
786
|
+
// directory so OpenCode can resolve `@latest` again. Exact `@version` pins and
|
|
787
|
+
// `file://` checkout pins skip the comparison.
|
|
744
788
|
// Test seams (no spawns on the canonical path): WORKIT_DOCTOR_STALE_REGISTRY_VERSION
|
|
745
789
|
// short-circuits the probe with the resolved latest version; the optional
|
|
746
790
|
// WORKIT_DOCTOR_STALE_REGISTRY_CMD replaces the `npm view` binary (a
|
|
@@ -756,7 +800,15 @@ const pluginMcpSelectors = (res: Resolved): string[] | null => {
|
|
|
756
800
|
return args.map(String).filter((a) => a.startsWith("--package="));
|
|
757
801
|
};
|
|
758
802
|
|
|
759
|
-
const
|
|
803
|
+
const isFloatingOpenCodeNpmPin = (entry: string): boolean => {
|
|
804
|
+
const s = entry.trim();
|
|
805
|
+
return s === OPENCODE_NPM_PIN || s === `${OPENCODE_NPM_PIN}@latest`;
|
|
806
|
+
};
|
|
807
|
+
|
|
808
|
+
const registryLatestVersion = (
|
|
809
|
+
res: Resolved,
|
|
810
|
+
pkgName: string = CURSOR_RUNTIME_PACKAGE,
|
|
811
|
+
): string | null => {
|
|
760
812
|
const seam = res.env.WORKIT_DOCTOR_STALE_REGISTRY_VERSION;
|
|
761
813
|
if (typeof seam === "string" && seam) {
|
|
762
814
|
return /^\d+(?:\.\d+){1,2}$/.test(seam) ? seam : null;
|
|
@@ -765,7 +817,7 @@ const registryLatestVersion = (res: Resolved): string | null => {
|
|
|
765
817
|
res.env.WORKIT_DOCTOR_STALE_REGISTRY_CMD ?? (commandOnPath("npm", res.env) ? "npm" : null);
|
|
766
818
|
if (!cmd) return null;
|
|
767
819
|
try {
|
|
768
|
-
const r = spawnSync(cmd, ["view",
|
|
820
|
+
const r = spawnSync(cmd, ["view", pkgName, "version"], {
|
|
769
821
|
encoding: "utf8",
|
|
770
822
|
timeout: 20_000,
|
|
771
823
|
env: res.env,
|
|
@@ -782,12 +834,120 @@ const installedPluginVersion = (res: Resolved): string | null => {
|
|
|
782
834
|
return typeof pkg?.version === "string" && pkg.version ? pkg.version : null;
|
|
783
835
|
};
|
|
784
836
|
|
|
837
|
+
const installedOpenCodeCacheVersion = (res: Resolved): string | null => {
|
|
838
|
+
const pkg = readJson(
|
|
839
|
+
path.join(
|
|
840
|
+
res.opencodePackageCacheDir,
|
|
841
|
+
"node_modules",
|
|
842
|
+
"@brainervirus",
|
|
843
|
+
"workit-opencode",
|
|
844
|
+
"package.json",
|
|
845
|
+
),
|
|
846
|
+
);
|
|
847
|
+
return typeof pkg?.version === "string" && pkg.version ? pkg.version : null;
|
|
848
|
+
};
|
|
849
|
+
|
|
850
|
+
/** OpenCode freezes the first `@latest` resolve in its package cache — detect lag. */
|
|
851
|
+
const checkOpenCodePackageCache = (res: Resolved): DoctorCheck & { registryProbed?: boolean } => {
|
|
852
|
+
if (!existsSync(res.opencodeConfig)) {
|
|
853
|
+
return {
|
|
854
|
+
id: "stale_install",
|
|
855
|
+
status: "pass",
|
|
856
|
+
detail: "no opencode config — package cache not inspected",
|
|
857
|
+
};
|
|
858
|
+
}
|
|
859
|
+
const entries = pluginEntries(readJson(res.opencodeConfig));
|
|
860
|
+
if (entries.length === 0) {
|
|
861
|
+
return {
|
|
862
|
+
id: "stale_install",
|
|
863
|
+
status: "pass",
|
|
864
|
+
detail: "no workit opencode pin — package cache not inspected",
|
|
865
|
+
};
|
|
866
|
+
}
|
|
867
|
+
const pin = entries[0]!;
|
|
868
|
+
if (pin.startsWith("file:") || pin.startsWith("git+file:")) {
|
|
869
|
+
return {
|
|
870
|
+
id: "stale_install",
|
|
871
|
+
status: "pass",
|
|
872
|
+
detail: "opencode checkout pin — package cache not used for npm resolution",
|
|
873
|
+
};
|
|
874
|
+
}
|
|
875
|
+
if (!isFloatingOpenCodeNpmPin(pin)) {
|
|
876
|
+
return {
|
|
877
|
+
id: "stale_install",
|
|
878
|
+
status: "pass",
|
|
879
|
+
detail: `opencode pin ${pin} is an exact version — not compared to registry latest`,
|
|
880
|
+
};
|
|
881
|
+
}
|
|
882
|
+
const installed = installedOpenCodeCacheVersion(res);
|
|
883
|
+
if (installed === null) {
|
|
884
|
+
return {
|
|
885
|
+
id: "stale_install",
|
|
886
|
+
status: "pass",
|
|
887
|
+
detail: "opencode @latest package cache empty — OpenCode will resolve on next launch",
|
|
888
|
+
};
|
|
889
|
+
}
|
|
890
|
+
const expected = registryLatestVersion(res, OPENCODE_NPM_PIN);
|
|
891
|
+
if (expected === null) {
|
|
892
|
+
return {
|
|
893
|
+
id: "registry_unreachable",
|
|
894
|
+
status: "warn",
|
|
895
|
+
registryProbed: true,
|
|
896
|
+
detail:
|
|
897
|
+
"registry_unreachable: cannot compare OpenCode @latest cache against published workit-opencode",
|
|
898
|
+
fix: `Retry when npm is reachable, or remove ${res.opencodePackageCacheDir} and restart OpenCode to re-resolve`,
|
|
899
|
+
};
|
|
900
|
+
}
|
|
901
|
+
if (!semverAtLeast(installed, expected)) {
|
|
902
|
+
return {
|
|
903
|
+
id: "stale_install",
|
|
904
|
+
status: "fail",
|
|
905
|
+
registryProbed: true,
|
|
906
|
+
detail: `stale_install: OpenCode @latest cache has workit-opencode ${installed} behind published ${expected}`,
|
|
907
|
+
fix: `Remove ${res.opencodePackageCacheDir} and restart OpenCode — it re-resolves @latest on the next launch`,
|
|
908
|
+
};
|
|
909
|
+
}
|
|
910
|
+
return {
|
|
911
|
+
id: "stale_install",
|
|
912
|
+
status: "pass",
|
|
913
|
+
registryProbed: true,
|
|
914
|
+
detail: `OpenCode @latest cache workit-opencode ${installed} matches published ${expected}`,
|
|
915
|
+
};
|
|
916
|
+
};
|
|
917
|
+
|
|
785
918
|
const checkStaleInstall = (res: Resolved): DoctorCheck & { registryProbed?: boolean } => {
|
|
919
|
+
if (res.host === "opencode") {
|
|
920
|
+
return checkOpenCodePackageCache(res);
|
|
921
|
+
}
|
|
786
922
|
if (res.host !== "cursor" && res.host !== "cli") {
|
|
787
923
|
return {
|
|
788
924
|
id: "stale_install",
|
|
789
925
|
status: "pass",
|
|
790
|
-
detail: "cursor plugin not inspected on
|
|
926
|
+
detail: "cursor plugin not inspected on this host",
|
|
927
|
+
};
|
|
928
|
+
}
|
|
929
|
+
// Symlink installs into pnpm dlx / _npx caches break when the cache is cleared.
|
|
930
|
+
// MCP launchers may still be canonical; the plugin dir itself must be real.
|
|
931
|
+
if (existsSync(res.cursorPluginDir) && lstatSync(res.cursorPluginDir).isSymbolicLink()) {
|
|
932
|
+
let target = "";
|
|
933
|
+
try {
|
|
934
|
+
target = readlinkSync(res.cursorPluginDir);
|
|
935
|
+
} catch {
|
|
936
|
+
target = "(unreadable)";
|
|
937
|
+
}
|
|
938
|
+
const n = target.replaceAll("\\", "/").toLowerCase();
|
|
939
|
+
const fragile =
|
|
940
|
+
n.includes("/caches/pnpm/dlx/") ||
|
|
941
|
+
n.includes("/_npx/") ||
|
|
942
|
+
n.includes("/.pacquet/") ||
|
|
943
|
+
n.includes("/library/caches/pnpm/");
|
|
944
|
+
return {
|
|
945
|
+
id: "stale_install",
|
|
946
|
+
status: "fail",
|
|
947
|
+
detail: fragile
|
|
948
|
+
? `stale_install: cursor plugin dir is a symlink into a package-manager cache (${target})`
|
|
949
|
+
: `stale_install: cursor plugin dir is a symlink (${target}) — expected a real directory copy`,
|
|
950
|
+
fix: "Run `workit init` and select Cursor — install must copy into ~/.cursor/plugins/local/workit",
|
|
791
951
|
};
|
|
792
952
|
}
|
|
793
953
|
const canonicalMcp = cursorMcpServerEntry("").args.find((a) => a.startsWith("--package="));
|
|
@@ -876,21 +1036,31 @@ const checkStaleInstall = (res: Resolved): DoctorCheck & { registryProbed?: bool
|
|
|
876
1036
|
fix: "Re-run install-cursor-plugin.sh — it refreshes the plugin directory with the current build",
|
|
877
1037
|
};
|
|
878
1038
|
}
|
|
879
|
-
|
|
880
|
-
id: "stale_install",
|
|
881
|
-
status: "pass",
|
|
1039
|
+
const cursorOk = {
|
|
1040
|
+
id: "stale_install" as const,
|
|
1041
|
+
status: "pass" as const,
|
|
882
1042
|
registryProbed: true,
|
|
883
1043
|
detail: `installed local-dist workit-cursor ${installed} matches the published runtime ${expected}`,
|
|
884
1044
|
};
|
|
1045
|
+
if (res.host === "cli") {
|
|
1046
|
+
const oc = checkOpenCodePackageCache(res);
|
|
1047
|
+
if (oc.status !== "pass") return oc;
|
|
1048
|
+
}
|
|
1049
|
+
return cursorOk;
|
|
885
1050
|
}
|
|
886
|
-
|
|
887
|
-
id: "stale_install",
|
|
888
|
-
status: "pass",
|
|
1051
|
+
const cursorOk = {
|
|
1052
|
+
id: "stale_install" as const,
|
|
1053
|
+
status: "pass" as const,
|
|
889
1054
|
detail:
|
|
890
1055
|
installed === null
|
|
891
1056
|
? "installed workit-cursor selectors are canonical"
|
|
892
1057
|
: `installed workit-cursor ${installed} is metadata on the canonical @latest install (fresh at launch)`,
|
|
893
1058
|
};
|
|
1059
|
+
if (res.host === "cli") {
|
|
1060
|
+
const oc = checkOpenCodePackageCache(res);
|
|
1061
|
+
if (oc.status !== "pass") return oc;
|
|
1062
|
+
}
|
|
1063
|
+
return cursorOk;
|
|
894
1064
|
};
|
|
895
1065
|
|
|
896
1066
|
const checkDuplicateRegistration = (res: Resolved): DoctorCheck => {
|
|
@@ -1035,7 +1205,16 @@ const checkCredentialMetadata = (res: Resolved): DoctorCheck => {
|
|
|
1035
1205
|
}
|
|
1036
1206
|
|
|
1037
1207
|
const vcsJson = readJson(path.join(res.configDir, "vcs.json"));
|
|
1038
|
-
|
|
1208
|
+
// Only the active VCS provider's token is required. vcs.json scaffolds both
|
|
1209
|
+
// github and gitlab blocks for switching later — inactive tokenFile paths
|
|
1210
|
+
// must not fail the doctor when provider is set to the other host.
|
|
1211
|
+
const active =
|
|
1212
|
+
typeof vcsJson?.provider === "string" ? vcsJson.provider.trim().toLowerCase() : null;
|
|
1213
|
+
const vcsKeys =
|
|
1214
|
+
active === "gitlab" || active === "github"
|
|
1215
|
+
? ([active] as const)
|
|
1216
|
+
: (["gitlab", "github"] as const);
|
|
1217
|
+
for (const key of vcsKeys) {
|
|
1039
1218
|
const provider = vcsJson?.[key];
|
|
1040
1219
|
if (provider && typeof provider === "object") {
|
|
1041
1220
|
const tf = provider.tokenFile;
|
package/src/core/registration.ts
CHANGED
|
@@ -6,6 +6,9 @@
|
|
|
6
6
|
// there is exactly one source of truth for registration merging.
|
|
7
7
|
import path from "node:path";
|
|
8
8
|
|
|
9
|
+
/** Canonical published OpenCode plugin identity (OpenCode resolves from npm). */
|
|
10
|
+
export const OPENCODE_NPM_PIN = "@brainervirus/workit-opencode";
|
|
11
|
+
|
|
9
12
|
export interface MergeResult<T> {
|
|
10
13
|
config: T;
|
|
11
14
|
changed: string[];
|
package/src/core/setup.ts
CHANGED
|
@@ -2,10 +2,12 @@ import {
|
|
|
2
2
|
copyFileSync,
|
|
3
3
|
cpSync,
|
|
4
4
|
existsSync,
|
|
5
|
+
lstatSync,
|
|
5
6
|
mkdirSync,
|
|
6
7
|
mkdtempSync,
|
|
7
8
|
readFileSync,
|
|
8
9
|
readdirSync,
|
|
10
|
+
realpathSync,
|
|
9
11
|
renameSync,
|
|
10
12
|
rmSync,
|
|
11
13
|
statSync,
|
|
@@ -38,6 +40,7 @@ import {
|
|
|
38
40
|
mergeCursorMcp,
|
|
39
41
|
mergeCursorSettings,
|
|
40
42
|
mergeOpenCodeConfig,
|
|
43
|
+
OPENCODE_NPM_PIN,
|
|
41
44
|
} from "./registration";
|
|
42
45
|
import { runDoctor, type DoctorReport } from "./doctor";
|
|
43
46
|
import { writeFileExclusive } from "./safe-write";
|
|
@@ -750,7 +753,19 @@ function adapterRoot(platform: Platform, res: ResolvedApply): string | null {
|
|
|
750
753
|
return null;
|
|
751
754
|
}
|
|
752
755
|
|
|
753
|
-
|
|
756
|
+
/** True when the adapter root is a monorepo checkout (dev pin), not a packed node_modules install. */
|
|
757
|
+
export const isDevOpenCodeAdapterRoot = (root: string, dev: string | null | undefined): boolean => {
|
|
758
|
+
if (dev) return true;
|
|
759
|
+
const norm = root.replaceAll("\\", "/");
|
|
760
|
+
return /\/packages\/workit-opencode\/?$/.test(norm);
|
|
761
|
+
};
|
|
762
|
+
|
|
763
|
+
/** Published installs pin the npm package; checkout installs pin file:// entry. */
|
|
764
|
+
export const resolveOpenCodePin = (
|
|
765
|
+
root: string,
|
|
766
|
+
opts: { dev?: string | null } = {},
|
|
767
|
+
): string | null => {
|
|
768
|
+
if (!isDevOpenCodeAdapterRoot(root, opts.dev)) return OPENCODE_NPM_PIN;
|
|
754
769
|
for (const rel of ["src/plugin.ts", "dist/plugin.js"]) {
|
|
755
770
|
const entry = path.join(root, rel);
|
|
756
771
|
if (existsSync(entry)) return `file://${entry}`;
|
|
@@ -758,8 +773,11 @@ const opencodePin = (root: string): string | null => {
|
|
|
758
773
|
return null;
|
|
759
774
|
};
|
|
760
775
|
|
|
776
|
+
const opencodePin = (root: string, res: ResolvedApply): string | null =>
|
|
777
|
+
resolveOpenCodePin(root, { dev: res.dev });
|
|
778
|
+
|
|
761
779
|
function applyOpenCode(root: string, res: ResolvedApply): SetupResultEntry {
|
|
762
|
-
const pin = opencodePin(root);
|
|
780
|
+
const pin = opencodePin(root, res);
|
|
763
781
|
if (!pin) {
|
|
764
782
|
return {
|
|
765
783
|
platform: "opencode",
|
|
@@ -853,28 +871,39 @@ const preservedCursorRules = (src: string, dest: string): Map<string, Buffer> =>
|
|
|
853
871
|
return preserved;
|
|
854
872
|
};
|
|
855
873
|
|
|
856
|
-
|
|
874
|
+
/** Install Cursor plugin files as a real directory (never a symlink into a package cache). */
|
|
875
|
+
export function copyPluginDir(src: string, dest: string): SetupResultStatus {
|
|
876
|
+
// pnpm dlx/pacquet (and similar) expose packages as symlinks into a content
|
|
877
|
+
// store. Node's cpSync copies a symlink *root* as a symlink, so the installed
|
|
878
|
+
// plugin would point into an ephemeral cache. Always materialize from the
|
|
879
|
+
// realpath so ~/.cursor/plugins/local/workit is a real directory.
|
|
880
|
+
const realSrc = realpathSync(src);
|
|
857
881
|
const marker = path.join(dest, ".workit-root");
|
|
858
|
-
const
|
|
882
|
+
const destIsLink = existsSync(dest) && lstatSync(dest).isSymbolicLink();
|
|
883
|
+
const synced =
|
|
884
|
+
!destIsLink && readFileSafe(marker)?.trim() === realSrc && samePluginContent(realSrc, dest);
|
|
859
885
|
if (synced) return "Skipped";
|
|
860
886
|
const hadDir = existsSync(dest);
|
|
861
|
-
const rules = preservedCursorRules(
|
|
887
|
+
const rules = preservedCursorRules(realSrc, dest);
|
|
862
888
|
const parent = path.dirname(dest);
|
|
863
889
|
mkdirSync(parent, { recursive: true });
|
|
864
890
|
const swap = mkdtempSync(path.join(parent, `.${path.basename(dest)}.swap-`));
|
|
865
891
|
const stage = path.join(swap, "stage");
|
|
866
892
|
const backup = path.join(swap, "backup");
|
|
867
893
|
try {
|
|
868
|
-
cpSync(
|
|
894
|
+
cpSync(realSrc, stage, {
|
|
869
895
|
recursive: true,
|
|
870
|
-
filter: (entry) => !path.relative(
|
|
896
|
+
filter: (entry) => !path.relative(realSrc, entry).split(path.sep).includes("node_modules"),
|
|
871
897
|
});
|
|
872
898
|
for (const [name, content] of rules) {
|
|
873
899
|
mkdirSync(path.join(stage, "rules"), { recursive: true });
|
|
874
900
|
writeFileSync(path.join(stage, "rules", name), content);
|
|
875
901
|
}
|
|
876
|
-
writeFileSync(path.join(stage, ".workit-root"),
|
|
877
|
-
if (!samePluginContent(
|
|
902
|
+
writeFileSync(path.join(stage, ".workit-root"), realSrc + "\n", "utf8");
|
|
903
|
+
if (!samePluginContent(realSrc, stage)) throw new Error("staged adapter content is incomplete");
|
|
904
|
+
if (lstatSync(stage).isSymbolicLink()) {
|
|
905
|
+
throw new Error("staged adapter resolved to a symlink — refusing fragile cache install");
|
|
906
|
+
}
|
|
878
907
|
if (hadDir) renameSync(dest, backup);
|
|
879
908
|
try {
|
|
880
909
|
renameSync(stage, dest);
|