|
| 1 | +const fs = require("node:fs"); |
| 2 | +const os = require("node:os"); |
| 3 | +const path = require("node:path"); |
| 4 | +const { spawnSync } = require("node:child_process"); |
| 5 | +const { cliBinPath } = require("./config-paths"); |
| 6 | + |
| 7 | +const MARKER_START = "# >>> clovapi >>>"; |
| 8 | +const MARKER_END = "# <<< clovapi <<<"; |
| 9 | + |
| 10 | +function cliBinDir() { |
| 11 | + return path.dirname(cliBinPath()); |
| 12 | +} |
| 13 | + |
| 14 | +function shellPathExportLine(dir) { |
| 15 | + const normalized = String(dir || "").trim(); |
| 16 | + if (!normalized) return ""; |
| 17 | + if (process.platform === "win32") return ""; |
| 18 | + return `export PATH="${normalized}:$PATH"`; |
| 19 | +} |
| 20 | + |
| 21 | +function buildPathBlock(dir) { |
| 22 | + const line = shellPathExportLine(dir); |
| 23 | + return [MARKER_START, line, MARKER_END, ""].join("\n"); |
| 24 | +} |
| 25 | + |
| 26 | +function shellProfileCandidates() { |
| 27 | + const home = os.homedir(); |
| 28 | + if (!home || process.platform === "win32") return []; |
| 29 | + if (process.platform === "darwin") { |
| 30 | + return [ |
| 31 | + path.join(home, ".zprofile"), |
| 32 | + path.join(home, ".zshrc"), |
| 33 | + path.join(home, ".bash_profile"), |
| 34 | + path.join(home, ".bashrc"), |
| 35 | + ]; |
| 36 | + } |
| 37 | + return [path.join(home, ".profile"), path.join(home, ".bashrc"), path.join(home, ".zshrc")]; |
| 38 | +} |
| 39 | + |
| 40 | +function pickShellProfile(candidates) { |
| 41 | + for (const file of candidates) { |
| 42 | + try { |
| 43 | + if (fs.existsSync(file)) return file; |
| 44 | + } catch { |
| 45 | + /* ignore */ |
| 46 | + } |
| 47 | + } |
| 48 | + return candidates[0] || ""; |
| 49 | +} |
| 50 | + |
| 51 | +function upsertMarkedBlock(content, block) { |
| 52 | + const text = String(content || ""); |
| 53 | + const start = text.indexOf(MARKER_START); |
| 54 | + const end = text.indexOf(MARKER_END); |
| 55 | + if (start >= 0 && end > start) { |
| 56 | + const before = text.slice(0, start); |
| 57 | + const after = text.slice(end + MARKER_END.length); |
| 58 | + const prefix = before.endsWith("\n") || before.length === 0 ? before : `${before}\n`; |
| 59 | + const suffix = after.startsWith("\n") ? after : `\n${after}`; |
| 60 | + return `${prefix}${block}${suffix}`.replace(/\n{3,}/g, "\n\n"); |
| 61 | + } |
| 62 | + if (text.length === 0) return block; |
| 63 | + return `${text.endsWith("\n") ? text : `${text}\n`}${block}`; |
| 64 | +} |
| 65 | + |
| 66 | +function ensureUnixShellPath(binDir) { |
| 67 | + const candidates = shellProfileCandidates(); |
| 68 | + const target = pickShellProfile(candidates); |
| 69 | + if (!target) { |
| 70 | + return { ok: false, changed: false, path: binDir, error: "no shell profile target" }; |
| 71 | + } |
| 72 | + |
| 73 | + let existing = ""; |
| 74 | + try { |
| 75 | + if (fs.existsSync(target)) existing = fs.readFileSync(target, "utf8"); |
| 76 | + } catch (error) { |
| 77 | + return { |
| 78 | + ok: false, |
| 79 | + changed: false, |
| 80 | + path: binDir, |
| 81 | + profile: target, |
| 82 | + error: error instanceof Error ? error.message : "read shell profile failed", |
| 83 | + }; |
| 84 | + } |
| 85 | + |
| 86 | + if (existing.includes(MARKER_START) || existing.includes(`${binDir}:`)) { |
| 87 | + return { ok: true, changed: false, path: binDir, profile: target, already: true }; |
| 88 | + } |
| 89 | + |
| 90 | + const block = buildPathBlock(binDir); |
| 91 | + const next = upsertMarkedBlock(existing, block); |
| 92 | + try { |
| 93 | + fs.mkdirSync(path.dirname(target), { recursive: true }); |
| 94 | + fs.writeFileSync(target, next, { mode: 0o600 }); |
| 95 | + } catch (error) { |
| 96 | + return { |
| 97 | + ok: false, |
| 98 | + changed: false, |
| 99 | + path: binDir, |
| 100 | + profile: target, |
| 101 | + error: error instanceof Error ? error.message : "write shell profile failed", |
| 102 | + }; |
| 103 | + } |
| 104 | + return { ok: true, changed: true, path: binDir, profile: target }; |
| 105 | +} |
| 106 | + |
| 107 | +function readWindowsUserPath() { |
| 108 | + const result = spawnSync( |
| 109 | + "powershell", |
| 110 | + [ |
| 111 | + "-NoProfile", |
| 112 | + "-Command", |
| 113 | + "[Environment]::GetEnvironmentVariable('Path', 'User')", |
| 114 | + ], |
| 115 | + { encoding: "utf8", windowsHide: true }, |
| 116 | + ); |
| 117 | + if (result.status !== 0) return ""; |
| 118 | + return String(result.stdout || "").trim(); |
| 119 | +} |
| 120 | + |
| 121 | +function writeWindowsUserPath(value) { |
| 122 | + const result = spawnSync( |
| 123 | + "powershell", |
| 124 | + [ |
| 125 | + "-NoProfile", |
| 126 | + "-Command", |
| 127 | + `[Environment]::SetEnvironmentVariable('Path', '${String(value || "").replace(/'/g, "''")}', 'User')`, |
| 128 | + ], |
| 129 | + { encoding: "utf8", windowsHide: true }, |
| 130 | + ); |
| 131 | + return result.status === 0; |
| 132 | +} |
| 133 | + |
| 134 | +function ensureWindowsUserPath(binDir) { |
| 135 | + const current = readWindowsUserPath(); |
| 136 | + const parts = current |
| 137 | + .split(";") |
| 138 | + .map((part) => part.trim()) |
| 139 | + .filter(Boolean); |
| 140 | + const normalized = path.normalize(binDir); |
| 141 | + if (parts.some((part) => path.normalize(part) === normalized)) { |
| 142 | + return { ok: true, changed: false, path: binDir, already: true }; |
| 143 | + } |
| 144 | + const next = [normalized, ...parts].join(";"); |
| 145 | + if (!writeWindowsUserPath(next)) { |
| 146 | + return { ok: false, changed: false, path: binDir, error: "failed to update Windows user PATH" }; |
| 147 | + } |
| 148 | + return { ok: true, changed: true, path: binDir }; |
| 149 | +} |
| 150 | + |
| 151 | +/** |
| 152 | + * Idempotently register ~/.config/clovapi/bin (or %APPDATA%\\clovapi\\bin) on the user PATH. |
| 153 | + * Desktop and npm both install the canonical binary here. |
| 154 | + */ |
| 155 | +function ensureCliBinOnPath() { |
| 156 | + const binDir = cliBinPath(); |
| 157 | + try { |
| 158 | + if (!fs.existsSync(binDir)) { |
| 159 | + return { ok: false, changed: false, path: path.dirname(binDir), error: "clovapi binary not installed yet" }; |
| 160 | + } |
| 161 | + } catch (error) { |
| 162 | + return { |
| 163 | + ok: false, |
| 164 | + changed: false, |
| 165 | + path: path.dirname(binDir), |
| 166 | + error: error instanceof Error ? error.message : "stat clovapi binary failed", |
| 167 | + }; |
| 168 | + } |
| 169 | + |
| 170 | + const dir = path.dirname(binDir); |
| 171 | + if (process.platform === "win32") { |
| 172 | + return ensureWindowsUserPath(dir); |
| 173 | + } |
| 174 | + return ensureUnixShellPath(dir); |
| 175 | +} |
| 176 | + |
| 177 | +function cliSpawnEnv(baseEnv = process.env) { |
| 178 | + const binDir = cliBinDir(); |
| 179 | + const parts = [binDir]; |
| 180 | + const seen = new Set([binDir]); |
| 181 | + for (const entry of String(baseEnv.PATH || "").split(path.delimiter)) { |
| 182 | + const value = String(entry || "").trim(); |
| 183 | + if (!value || seen.has(value)) continue; |
| 184 | + seen.add(value); |
| 185 | + parts.push(value); |
| 186 | + } |
| 187 | + return { ...baseEnv, PATH: parts.join(path.delimiter) }; |
| 188 | +} |
| 189 | + |
| 190 | +module.exports = { |
| 191 | + MARKER_END, |
| 192 | + MARKER_START, |
| 193 | + buildPathBlock, |
| 194 | + cliBinDir, |
| 195 | + cliSpawnEnv, |
| 196 | + ensureCliBinOnPath, |
| 197 | + shellProfileCandidates, |
| 198 | + upsertMarkedBlock, |
| 199 | +}; |
0 commit comments