@ggcode-cli/ggcode 1.1.2 → 1.1.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 CHANGED
@@ -1,10 +1,16 @@
1
1
  # @ggcode-cli/ggcode
2
2
 
3
- `@ggcode-cli/ggcode` is a thin npm wrapper for the `ggcode` terminal agent.
3
+ `@ggcode-cli/ggcode` installs the native `ggcode` binary from GitHub Releases.
4
4
 
5
- It does not bundle the native binary in the package. Instead, the wrapper downloads the latest
6
- `ggcode` GitHub Release for your platform on install or first run, caches it locally, and then
7
- launches it for you.
5
+ During package installation it downloads the matching release archive, verifies it, and places the
6
+ real `ggcode` executable in a stable CLI location:
7
+
8
+ - macOS / Linux: prefers `/usr/local/bin`, falls back to `~/.local/bin`
9
+ - Windows: prefers `%USERPROFILE%\\AppData\\Local\\Programs\\ggcode\\bin`, falls back to `%USERPROFILE%\\.local\\bin`
10
+
11
+ If that directory is not on `PATH`, the installer updates your shell/user PATH and asks you to
12
+ reopen the terminal. The npm package keeps a separate `ggcode-bootstrap` helper command for manual
13
+ repair, but normal usage should be the real `ggcode` binary.
8
14
 
9
15
  ## Install
10
16
 
@@ -20,14 +26,19 @@ Then run:
20
26
  ggcode
21
27
  ```
22
28
 
23
- If you install it locally, use `npx ggcode` or `./node_modules/.bin/ggcode`.
29
+ If the native install needs to be retried manually, run:
30
+
31
+ ```bash
32
+ ggcode-bootstrap
33
+ ```
24
34
 
25
35
  ## What it does
26
36
 
27
37
  - Detects your platform and architecture
28
38
  - Downloads the latest matching `ggcode` binary from GitHub Releases
29
39
  - Verifies the downloaded archive with `checksums.txt`
30
- - Extracts and caches the binary for reuse
40
+ - Installs the real binary into a stable PATH location
41
+ - Updates `PATH` when needed so future `ggcode` launches bypass the wrapper
31
42
 
32
43
  ## Pin a specific ggcode release
33
44
 
@@ -4,8 +4,12 @@ const { spawnSync } = require("child_process");
4
4
  const { ensureInstalled } = require("../lib/install");
5
5
 
6
6
  async function main() {
7
- const binary = await ensureInstalled(process.env.GGCODE_INSTALL_VERSION, true);
8
- const result = spawnSync(binary, process.argv.slice(2), {
7
+ const install = await ensureInstalled(process.env.GGCODE_INSTALL_VERSION, true);
8
+ if (install.needsRestart) {
9
+ console.error(`ggcode was installed to ${install.installDir}.`);
10
+ console.error("Reopen your terminal, then run `ggcode` directly.");
11
+ }
12
+ const result = spawnSync(install.binaryPath, process.argv.slice(2), {
9
13
  stdio: "inherit",
10
14
  env: { ...process.env, GGCODE_WRAPPER_KIND: "npm" },
11
15
  });
@@ -16,6 +20,6 @@ async function main() {
16
20
  }
17
21
 
18
22
  main().catch((err) => {
19
- console.error(`ggcode npm wrapper failed: ${err.message}`);
23
+ console.error(`ggcode npm bootstrap failed: ${err.message}`);
20
24
  process.exit(1);
21
25
  });
package/lib/install.js CHANGED
@@ -8,6 +8,9 @@ const { execFileSync } = require("child_process");
8
8
  const OWNER = "topcheer";
9
9
  const REPO = "ggcode";
10
10
  const BINARY = process.platform === "win32" ? "ggcode.exe" : "ggcode";
11
+ const MARKER_START = "# >>> ggcode PATH >>>";
12
+ const MARKER_END = "# <<< ggcode PATH <<<";
13
+ const METADATA = ".ggcode-wrapper.json";
11
14
 
12
15
  function normalizeVersion(version) {
13
16
  const selected = (version || "").trim();
@@ -50,30 +53,195 @@ function releaseBase(version) {
50
53
  return `https://github.com/${OWNER}/${REPO}/releases/download/${version}`;
51
54
  }
52
55
 
53
- function cacheRoot() {
56
+ function preferredInstallDirs() {
57
+ const home = os.homedir();
54
58
  if (process.platform === "win32") {
55
- return path.join(process.env.LOCALAPPDATA || os.tmpdir(), "ggcode", "npm");
59
+ return [
60
+ path.join(home, "AppData", "Local", "Programs", "ggcode", "bin"),
61
+ path.join(home, ".local", "bin"),
62
+ ];
56
63
  }
57
- return path.join(os.homedir(), ".cache", "ggcode", "npm");
64
+ return ["/usr/local/bin", path.join(home, ".local", "bin")];
58
65
  }
59
66
 
60
- function installRoot(version, target) {
61
- return path.join(cacheRoot(), version, `${target.platform}-${process.arch}`);
67
+ function metadataPath(dir) {
68
+ return path.join(dir, METADATA);
62
69
  }
63
70
 
64
- function binaryPath(version, target) {
65
- return path.join(installRoot(version, target), target.binaryName);
71
+ function pathEntries(value) {
72
+ return (value || "")
73
+ .split(path.delimiter)
74
+ .map((entry) => entry.trim())
75
+ .filter(Boolean);
76
+ }
77
+
78
+ function samePath(a, b) {
79
+ const normalize = (value) => {
80
+ let result = path.resolve(value);
81
+ if (process.platform === "win32") {
82
+ result = result.replace(/[\\/]+$/, "").toLowerCase();
83
+ }
84
+ return result;
85
+ };
86
+ return normalize(a) === normalize(b);
87
+ }
88
+
89
+ function isPermissionError(err) {
90
+ return Boolean(err && ["EACCES", "EPERM"].includes(err.code));
91
+ }
92
+
93
+ function readMetadata(dir) {
94
+ try {
95
+ return JSON.parse(fs.readFileSync(metadataPath(dir), "utf8"));
96
+ } catch {
97
+ return null;
98
+ }
99
+ }
100
+
101
+ function writeMetadata(dir, version) {
102
+ fs.writeFileSync(metadataPath(dir), JSON.stringify({ version }, null, 2) + "\n", "utf8");
103
+ }
104
+
105
+ function findInstalledBinary(requestedVersion, target) {
106
+ for (const dir of preferredInstallDirs()) {
107
+ const binary = path.join(dir, target.binaryName);
108
+ if (!fs.existsSync(binary)) {
109
+ continue;
110
+ }
111
+ if (requestedVersion === "latest") {
112
+ return { dir, binary, version: readMetadata(dir)?.version || "unknown" };
113
+ }
114
+ const metadata = readMetadata(dir);
115
+ if (metadata && metadata.version === requestedVersion) {
116
+ return { dir, binary, version: metadata.version };
117
+ }
118
+ }
119
+ return null;
120
+ }
121
+
122
+ function installBinary(dir, target, extracted, version) {
123
+ fs.mkdirSync(dir, { recursive: true });
124
+ const dest = path.join(dir, target.binaryName);
125
+ const temp = `${dest}.tmp-${process.pid}`;
126
+ fs.copyFileSync(extracted, temp);
127
+ if (process.platform !== "win32") {
128
+ fs.chmodSync(temp, 0o755);
129
+ }
130
+ fs.rmSync(dest, { force: true });
131
+ fs.renameSync(temp, dest);
132
+ writeMetadata(dir, version);
133
+ return dest;
134
+ }
135
+
136
+ function ensureInstalledPath(dir) {
137
+ const currentEntries = pathEntries(process.env.PATH);
138
+ if (currentEntries.some((entry) => samePath(entry, dir))) {
139
+ return false;
140
+ }
141
+ if (process.platform === "win32") {
142
+ return ensureWindowsUserPath(dir);
143
+ }
144
+ return ensureUnixPath(dir);
145
+ }
146
+
147
+ function ensureWindowsUserPath(dir) {
148
+ const script = [
149
+ `$dir = ${powershellString(dir)}`,
150
+ "$current = [Environment]::GetEnvironmentVariable('Path', 'User')",
151
+ "$parts = @()",
152
+ "if ($current) { $parts = $current -split ';' | Where-Object { $_ -and $_.Trim() -ne '' } }",
153
+ "$exists = $parts | Where-Object { $_.TrimEnd('\\\\') -ieq $dir.TrimEnd('\\\\') }",
154
+ "if (-not $exists) {",
155
+ " $new = @($dir) + $parts",
156
+ " [Environment]::SetEnvironmentVariable('Path', ($new -join ';'), 'User')",
157
+ " Write-Output 'updated'",
158
+ "} else {",
159
+ " Write-Output 'unchanged'",
160
+ "}",
161
+ ].join("; ");
162
+ const output = execFileSync("powershell", ["-NoProfile", "-Command", script], {
163
+ encoding: "utf8",
164
+ stdio: ["ignore", "pipe", "pipe"],
165
+ }).trim();
166
+ process.env.PATH = [dir, ...pathEntries(process.env.PATH)].join(path.delimiter);
167
+ return output === "updated";
168
+ }
169
+
170
+ function ensureUnixPath(dir) {
171
+ const files = profileTargets();
172
+ let changed = false;
173
+ for (const file of files) {
174
+ const before = fs.existsSync(file) ? fs.readFileSync(file, "utf8") : "";
175
+ const after = upsertPathBlock(before, dir);
176
+ if (after !== before) {
177
+ fs.mkdirSync(path.dirname(file), { recursive: true });
178
+ fs.writeFileSync(file, after, "utf8");
179
+ changed = true;
180
+ }
181
+ }
182
+ process.env.PATH = [dir, ...pathEntries(process.env.PATH)].join(path.delimiter);
183
+ return changed;
184
+ }
185
+
186
+ function profileTargets() {
187
+ const home = os.homedir();
188
+ const shell = path.basename(process.env.SHELL || "");
189
+ const preferred = [];
190
+ if (shell === "zsh") {
191
+ preferred.push(".zshrc", ".zprofile");
192
+ } else if (shell === "bash") {
193
+ preferred.push(".bashrc", ".bash_profile");
194
+ }
195
+ preferred.push(".profile");
196
+
197
+ const existing = [".zshrc", ".zprofile", ".bashrc", ".bash_profile", ".profile"].filter((name) =>
198
+ fs.existsSync(path.join(home, name)),
199
+ );
200
+ const targets = [];
201
+ for (const name of [...preferred, ...existing]) {
202
+ const file = path.join(home, name);
203
+ if (!targets.some((entry) => samePath(entry, file))) {
204
+ targets.push(file);
205
+ }
206
+ }
207
+ return targets;
208
+ }
209
+
210
+ function upsertPathBlock(content, dir) {
211
+ const block = `${MARKER_START}\nexport PATH="${dir}:$PATH"\n${MARKER_END}\n`;
212
+ const pattern = new RegExp(`${escapeRegExp(MARKER_START)}[\\s\\S]*?${escapeRegExp(MARKER_END)}\\n?`, "m");
213
+ if (pattern.test(content)) {
214
+ return content.replace(pattern, block);
215
+ }
216
+ const suffix = content && !content.endsWith("\n") ? "\n" : "";
217
+ return `${content}${suffix}${block}`;
218
+ }
219
+
220
+ function escapeRegExp(value) {
221
+ return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
222
+ }
223
+
224
+ function powershellString(value) {
225
+ return `'${String(value).replace(/'/g, "''")}'`;
66
226
  }
67
227
 
68
228
  async function ensureInstalled(version, quiet) {
69
229
  const requestedVersion = normalizeVersion(version);
70
- const resolvedVersion = await resolveReleaseVersion(requestedVersion);
71
230
  const target = resolveTarget();
72
- const dest = binaryPath(resolvedVersion, target);
73
- if (fs.existsSync(dest)) {
74
- return dest;
231
+ const existing = findInstalledBinary(requestedVersion, target);
232
+ if (existing) {
233
+ const pathUpdated = ensureInstalledPath(existing.dir);
234
+ return {
235
+ binaryPath: existing.binary,
236
+ installDir: existing.dir,
237
+ version: existing.version,
238
+ pathUpdated,
239
+ installedNow: false,
240
+ needsRestart: pathUpdated,
241
+ };
75
242
  }
76
243
 
244
+ const resolvedVersion = await resolveReleaseVersion(requestedVersion);
77
245
  const base = releaseBase(resolvedVersion);
78
246
  const archiveURL = `${base}/${target.archiveName}`;
79
247
  const checksumsURL = `${base}/checksums.txt`;
@@ -88,7 +256,7 @@ async function ensureInstalled(version, quiet) {
88
256
  fs.writeFileSync(archivePath, archive);
89
257
 
90
258
  if (!quiet) {
91
- process.stderr.write(`Downloading ggcode ${resolvedVersion} from GitHub Releases...\n`);
259
+ process.stderr.write(`Installing ggcode ${resolvedVersion}...\n`);
92
260
  }
93
261
 
94
262
  extractArchive(target, archivePath, extractDir);
@@ -97,13 +265,34 @@ async function ensureInstalled(version, quiet) {
97
265
  throw new Error(`Could not find ${target.binaryName} inside ${target.archiveName}`);
98
266
  }
99
267
 
100
- const root = installRoot(resolvedVersion, target);
101
- fs.mkdirSync(root, { recursive: true });
102
- fs.copyFileSync(extracted, dest);
103
- if (process.platform !== "win32") {
104
- fs.chmodSync(dest, 0o755);
268
+ let installDir = null;
269
+ let usedFallback = false;
270
+ let lastErr = null;
271
+ const installDirs = preferredInstallDirs();
272
+ for (const [index, dir] of installDirs.entries()) {
273
+ try {
274
+ const binaryPath = installBinary(dir, target, extracted, resolvedVersion);
275
+ installDir = dir;
276
+ usedFallback = index > 0;
277
+ const pathUpdated = ensureInstalledPath(dir);
278
+ return {
279
+ binaryPath,
280
+ installDir,
281
+ version: resolvedVersion,
282
+ pathUpdated,
283
+ installedNow: true,
284
+ needsRestart: pathUpdated,
285
+ usedFallback,
286
+ };
287
+ } catch (err) {
288
+ if (!isPermissionError(err) || index === installDirs.length - 1) {
289
+ lastErr = err;
290
+ break;
291
+ }
292
+ }
105
293
  }
106
- return dest;
294
+
295
+ throw lastErr || new Error("Failed to install ggcode");
107
296
  }
108
297
 
109
298
  async function resolveReleaseVersion(version) {
@@ -140,7 +329,7 @@ function verifyChecksum(assetName, archive, checksumsText) {
140
329
 
141
330
  function extractArchive(target, archivePath, extractDir) {
142
331
  if (target.archiveExt === ".zip") {
143
- const command = `Expand-Archive -LiteralPath '${archivePath.replace(/'/g, "''")}' -DestinationPath '${extractDir.replace(/'/g, "''")}' -Force`;
332
+ const command = `Expand-Archive -LiteralPath ${powershellString(archivePath)} -DestinationPath ${powershellString(extractDir)} -Force`;
144
333
  execFileSync("powershell", ["-NoProfile", "-Command", command], { stdio: "ignore" });
145
334
  return;
146
335
  }
@@ -175,7 +364,7 @@ function downloadBuffer(url) {
175
364
  https
176
365
  .get(target, (res) => {
177
366
  if (res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) {
178
- get(res.headers.location);
367
+ get(new URL(res.headers.location, target).toString());
179
368
  return;
180
369
  }
181
370
  if (res.statusCode !== 200) {
@@ -220,4 +409,6 @@ module.exports = {
220
409
  normalizeVersion,
221
410
  resolveReleaseVersion,
222
411
  resolveTarget,
412
+ upsertPathBlock,
413
+ preferredInstallDirs,
223
414
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ggcode-cli/ggcode",
3
- "version": "1.1.2",
3
+ "version": "1.1.4",
4
4
  "description": "Thin npm wrapper that installs the ggcode GitHub Release binary",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -16,7 +16,7 @@
16
16
  "provenance": true
17
17
  },
18
18
  "bin": {
19
- "ggcode": "bin/ggcode.js"
19
+ "ggcode-bootstrap": "bin/ggcode-bootstrap.js"
20
20
  },
21
21
  "files": [
22
22
  "bin",
@@ -2,7 +2,16 @@
2
2
 
3
3
  const { ensureInstalled } = require("../lib/install");
4
4
 
5
- ensureInstalled(process.env.GGCODE_INSTALL_VERSION, false).catch((err) => {
6
- console.warn(`ggcode postinstall warning: ${err.message}`);
7
- console.warn("The wrapper will try again on first run.");
8
- });
5
+ ensureInstalled(process.env.GGCODE_INSTALL_VERSION, false)
6
+ .then((install) => {
7
+ const action = install.installedNow ? "installed" : "already available";
8
+ console.warn(`ggcode ${install.version} ${action} at ${install.binaryPath}`);
9
+ if (install.needsRestart) {
10
+ console.warn("Reopen your terminal, then run `ggcode` directly.");
11
+ }
12
+ console.warn("If you ever need to repair the bootstrap flow, run `ggcode-bootstrap`.");
13
+ })
14
+ .catch((err) => {
15
+ console.warn(`ggcode postinstall warning: ${err.message}`);
16
+ console.warn("Run `ggcode-bootstrap` to retry the native binary installation.");
17
+ });