@model-go/cli 0.1.1
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/LICENSE +21 -0
- package/README.md +42 -0
- package/checksums.txt +6 -0
- package/package.json +51 -0
- package/scripts/install-wizard.js +231 -0
- package/scripts/install.js +308 -0
- package/scripts/run.js +35 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 modelgo
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
# modelgo-cli
|
|
2
|
+
|
|
3
|
+
The official CLI for modelgo. Pairs with AI agent skills (Claude Code, Codex, Gemini CLI, etc.) so AI agents can operate modelgo on your behalf.
|
|
4
|
+
|
|
5
|
+
> **v0 framework stage.** Business APIs are not wired up yet; the only command is `hello` (a demo to verify the install pipeline).
|
|
6
|
+
|
|
7
|
+
## Install
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
npx @model-go/cli@latest install
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
This runs an interactive wizard that:
|
|
14
|
+
|
|
15
|
+
1. Installs `@model-go/cli` globally via npm (which downloads the Go binary from GitHub Releases).
|
|
16
|
+
2. Distributes `modelgo-*` skills to every AI agent installed on your machine (Claude Code, Trae, Trae CN, and other agents in the [skills](https://www.npmjs.com/package/skills) ecosystem).
|
|
17
|
+
|
|
18
|
+
After install, restart your AI agent (open a new chat / session) and try:
|
|
19
|
+
|
|
20
|
+
> "Have modelgo-cli say hello to me."
|
|
21
|
+
|
|
22
|
+
Your AI should find the `modelgo-hello` skill and run `modelgo-cli hello`.
|
|
23
|
+
|
|
24
|
+
## Direct commands
|
|
25
|
+
|
|
26
|
+
```bash
|
|
27
|
+
modelgo-cli --version
|
|
28
|
+
modelgo-cli hello [--name NAME]
|
|
29
|
+
modelgo-cli --help
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
## Upgrade
|
|
33
|
+
|
|
34
|
+
Re-run the installer; it detects an out-of-date install and upgrades in place:
|
|
35
|
+
|
|
36
|
+
```bash
|
|
37
|
+
npx @model-go/cli@latest install
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
## License
|
|
41
|
+
|
|
42
|
+
MIT — see [LICENSE](./LICENSE).
|
package/checksums.txt
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
769fac4456815aeabc8517827df7d174b650e2d6d127eb7805aec3477179cd23 modelgo-cli-0.1.1-darwin-amd64.tar.gz
|
|
2
|
+
5720ad47e70b7ad5a99688f47c7c345ee8a3e6306fbea7510276398011815801 modelgo-cli-0.1.1-darwin-arm64.tar.gz
|
|
3
|
+
f4f46f11be9a485e8e12e26bb1c8f9b67c02d67466b46c96be32a4557c951ca4 modelgo-cli-0.1.1-linux-amd64.tar.gz
|
|
4
|
+
7482afffe11cf8bf3c8798d77166ce5a6c0b79acdd53c030712b9b4fc69a0521 modelgo-cli-0.1.1-linux-arm64.tar.gz
|
|
5
|
+
c86b6da335e95354b0cfe2b8bc11cd4b2fbeff5a32231c9fdef0a55440659bf1 modelgo-cli-0.1.1-windows-amd64.zip
|
|
6
|
+
1f101449e834ff876ccc418d246e8b06b2a21031904d8c638066e0d6e1c52eb2 modelgo-cli-0.1.1-windows-arm64.zip
|
package/package.json
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@model-go/cli",
|
|
3
|
+
"version": "0.1.1",
|
|
4
|
+
"description": "The official CLI for modelgo. Pairs with AI agent skills so AI agents can operate modelgo on your behalf.",
|
|
5
|
+
"bin": {
|
|
6
|
+
"modelgo-cli": "scripts/run.js"
|
|
7
|
+
},
|
|
8
|
+
"scripts": {
|
|
9
|
+
"postinstall": "node scripts/install.js",
|
|
10
|
+
"test": "node --test scripts/__tests__/*.test.mjs",
|
|
11
|
+
"lint:skills": "node scripts/lint-skills.mjs"
|
|
12
|
+
},
|
|
13
|
+
"files": [
|
|
14
|
+
"scripts/install.js",
|
|
15
|
+
"scripts/install-wizard.js",
|
|
16
|
+
"scripts/run.js",
|
|
17
|
+
"checksums.txt",
|
|
18
|
+
"LICENSE",
|
|
19
|
+
"README.md"
|
|
20
|
+
],
|
|
21
|
+
"os": [
|
|
22
|
+
"darwin",
|
|
23
|
+
"linux",
|
|
24
|
+
"win32"
|
|
25
|
+
],
|
|
26
|
+
"cpu": [
|
|
27
|
+
"x64",
|
|
28
|
+
"arm64"
|
|
29
|
+
],
|
|
30
|
+
"engines": {
|
|
31
|
+
"node": ">=20.12"
|
|
32
|
+
},
|
|
33
|
+
"dependencies": {
|
|
34
|
+
"@clack/prompts": "^1.2.0"
|
|
35
|
+
},
|
|
36
|
+
"repository": {
|
|
37
|
+
"type": "git",
|
|
38
|
+
"url": "git+https://github.com/modelgo/modelgo-cli.git"
|
|
39
|
+
},
|
|
40
|
+
"homepage": "https://github.com/modelgo/modelgo-cli#readme",
|
|
41
|
+
"bugs": {
|
|
42
|
+
"url": "https://github.com/modelgo/modelgo-cli/issues"
|
|
43
|
+
},
|
|
44
|
+
"license": "MIT",
|
|
45
|
+
"publishConfig": {
|
|
46
|
+
"access": "public"
|
|
47
|
+
},
|
|
48
|
+
"devDependencies": {
|
|
49
|
+
"yaml": "^2.9.0"
|
|
50
|
+
}
|
|
51
|
+
}
|
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Copyright (c) 2026 modelgo
|
|
3
|
+
// SPDX-License-Identifier: MIT
|
|
4
|
+
|
|
5
|
+
const { execFileSync, execFile } = require("child_process");
|
|
6
|
+
const p = require("@clack/prompts");
|
|
7
|
+
const { semverLessThan } = require("./install.js");
|
|
8
|
+
|
|
9
|
+
const PKG = "@model-go/cli";
|
|
10
|
+
const SKILLS_REPO = "modelgo/modelgo-cli";
|
|
11
|
+
const isWindows = process.platform === "win32";
|
|
12
|
+
|
|
13
|
+
// ---------------------------------------------------------------------------
|
|
14
|
+
// i18n
|
|
15
|
+
// ---------------------------------------------------------------------------
|
|
16
|
+
|
|
17
|
+
const messages = {
|
|
18
|
+
zh: {
|
|
19
|
+
setup: "正在设置 modelgo CLI...",
|
|
20
|
+
step1: "正在安装 %s...",
|
|
21
|
+
step1Upgrade: "正在升级 %s (v%s → v%s)...",
|
|
22
|
+
step1Skip: "已安装 (v%s),跳过",
|
|
23
|
+
step1Done: "已全局安装",
|
|
24
|
+
step1Upgraded: "已升级到 v%s",
|
|
25
|
+
step1Fail: "全局安装失败。运行以下命令重试: npm install -g %s",
|
|
26
|
+
step2Spinner: "正在安装 Skills...",
|
|
27
|
+
step2Done: "Skills 已安装",
|
|
28
|
+
step2Fail: "Skills 安装失败。运行以下命令重试: npx skills add %s -y -g",
|
|
29
|
+
done: "安装完成!\n试试跟你的 AI 工具(Claude Code、Codex 等)说:\"用 modelgo-cli 跟我打个招呼\"",
|
|
30
|
+
cancelled: "安装已取消",
|
|
31
|
+
nonTtyHint: "非交互模式,已完成 npm 全局安装 + skills 安装。",
|
|
32
|
+
},
|
|
33
|
+
en: {
|
|
34
|
+
setup: "Setting up modelgo CLI...",
|
|
35
|
+
step1: "Installing %s globally...",
|
|
36
|
+
step1Upgrade: "Upgrading %s (v%s → v%s)...",
|
|
37
|
+
step1Skip: "Already installed (v%s). Skipped",
|
|
38
|
+
step1Done: "Installed globally",
|
|
39
|
+
step1Upgraded: "Upgraded to v%s",
|
|
40
|
+
step1Fail: "Failed to install globally. Run manually: npm install -g %s",
|
|
41
|
+
step2Spinner: "Installing skills...",
|
|
42
|
+
step2Done: "Skills installed",
|
|
43
|
+
step2Fail: "Failed to install skills. Run manually: npx skills add %s -y -g",
|
|
44
|
+
done: "You are all set!\nTry asking your AI tool (Claude Code, Codex, etc.): \"Have modelgo-cli say hello to me\"",
|
|
45
|
+
cancelled: "Installation cancelled",
|
|
46
|
+
nonTtyHint: "Non-interactive mode. Completed global install + skills install.",
|
|
47
|
+
},
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
// ---------------------------------------------------------------------------
|
|
51
|
+
// Helpers
|
|
52
|
+
// ---------------------------------------------------------------------------
|
|
53
|
+
|
|
54
|
+
function execCmd(cmd, args, opts) {
|
|
55
|
+
if (isWindows) {
|
|
56
|
+
return execFileSync("cmd.exe", ["/c", cmd, ...args], opts);
|
|
57
|
+
}
|
|
58
|
+
return execFileSync(cmd, args, opts);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function runSilent(cmd, args, opts = {}) {
|
|
62
|
+
return execCmd(cmd, args, { stdio: ["ignore", "pipe", "pipe"], ...opts });
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function runSilentAsync(cmd, args, opts = {}) {
|
|
66
|
+
const actualCmd = isWindows ? "cmd.exe" : cmd;
|
|
67
|
+
const actualArgs = isWindows ? ["/c", cmd, ...args] : args;
|
|
68
|
+
return new Promise((resolve, reject) => {
|
|
69
|
+
execFile(actualCmd, actualArgs, { stdio: ["ignore", "pipe", "pipe"], ...opts }, (err, stdout) => {
|
|
70
|
+
if (err) reject(err);
|
|
71
|
+
else resolve(stdout);
|
|
72
|
+
});
|
|
73
|
+
});
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function fmt(template, ...values) {
|
|
77
|
+
let i = 0;
|
|
78
|
+
return template.replace(/%s/g, () => values[i++] ?? "");
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function getLatestVersion() {
|
|
82
|
+
try {
|
|
83
|
+
const out = runSilent("npm", ["view", PKG, "version"], { timeout: 15000 });
|
|
84
|
+
const ver = out.toString().trim();
|
|
85
|
+
return /^\d+\.\d+\.\d+/.test(ver) ? ver : null;
|
|
86
|
+
} catch (_) {
|
|
87
|
+
return null;
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function getGloballyInstalledVersion() {
|
|
92
|
+
try {
|
|
93
|
+
const out = runSilent("npm", ["list", "-g", PKG], { timeout: 15000 });
|
|
94
|
+
const match = out.toString().match(/@(\d+\.\d+\.\d+[^\s]*)/);
|
|
95
|
+
return match ? match[1] : null;
|
|
96
|
+
} catch (_) {
|
|
97
|
+
return null;
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function parseLangArg() {
|
|
102
|
+
const args = process.argv.slice(2);
|
|
103
|
+
for (let i = 0; i < args.length; i++) {
|
|
104
|
+
if (args[i] === "--lang" && args[i + 1]) {
|
|
105
|
+
const val = args[i + 1].toLowerCase();
|
|
106
|
+
if (val === "zh" || val === "en") return val;
|
|
107
|
+
}
|
|
108
|
+
if (args[i].startsWith("--lang=")) {
|
|
109
|
+
const val = args[i].split("=")[1].toLowerCase();
|
|
110
|
+
if (val === "zh" || val === "en") return val;
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
return null;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function handleCancel(value, msg) {
|
|
117
|
+
if (p.isCancel(value)) {
|
|
118
|
+
p.cancel(msg.cancelled);
|
|
119
|
+
process.exit(0);
|
|
120
|
+
}
|
|
121
|
+
return value;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// ---------------------------------------------------------------------------
|
|
125
|
+
// Steps
|
|
126
|
+
// ---------------------------------------------------------------------------
|
|
127
|
+
|
|
128
|
+
async function stepSelectLang() {
|
|
129
|
+
const fromArg = parseLangArg();
|
|
130
|
+
if (fromArg) return fromArg;
|
|
131
|
+
const lang = await p.select({
|
|
132
|
+
message: "请选择语言 / Select language",
|
|
133
|
+
options: [
|
|
134
|
+
{ value: "zh", label: "中文" },
|
|
135
|
+
{ value: "en", label: "English" },
|
|
136
|
+
],
|
|
137
|
+
});
|
|
138
|
+
return handleCancel(lang, messages.zh);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function reportStart(isInteractive, message) {
|
|
142
|
+
if (!isInteractive) {
|
|
143
|
+
console.log(message);
|
|
144
|
+
return null;
|
|
145
|
+
}
|
|
146
|
+
const s = p.spinner();
|
|
147
|
+
s.start(message);
|
|
148
|
+
return s;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function reportStop(isInteractive, spinner, message) {
|
|
152
|
+
if (isInteractive && spinner) spinner.stop(message);
|
|
153
|
+
else console.log(message);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function reportFail(isInteractive, spinner, message, err) {
|
|
157
|
+
if (isInteractive && spinner) spinner.stop(message);
|
|
158
|
+
else console.error(message);
|
|
159
|
+
const stderr = err && err.stderr ? err.stderr.toString().trim() : "";
|
|
160
|
+
if (stderr) console.error("\n" + stderr);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
async function stepInstallGlobally(msg, isInteractive) {
|
|
164
|
+
const installedVer = getGloballyInstalledVersion();
|
|
165
|
+
const latestVer = getLatestVersion();
|
|
166
|
+
const needsUpgrade = installedVer && latestVer && semverLessThan(installedVer, latestVer);
|
|
167
|
+
|
|
168
|
+
if (installedVer && !needsUpgrade) {
|
|
169
|
+
const skipMsg = fmt(msg.step1Skip, installedVer);
|
|
170
|
+
if (isInteractive) p.log.info(skipMsg);
|
|
171
|
+
else console.log(skipMsg);
|
|
172
|
+
return;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
const startMsg = needsUpgrade
|
|
176
|
+
? fmt(msg.step1Upgrade, PKG, installedVer, latestVer)
|
|
177
|
+
: fmt(msg.step1, PKG);
|
|
178
|
+
const doneMsg = needsUpgrade ? fmt(msg.step1Upgraded, latestVer) : msg.step1Done;
|
|
179
|
+
|
|
180
|
+
const s = reportStart(isInteractive, startMsg);
|
|
181
|
+
try {
|
|
182
|
+
await runSilentAsync("npm", ["install", "-g", PKG], { timeout: 120000 });
|
|
183
|
+
reportStop(isInteractive, s, doneMsg);
|
|
184
|
+
} catch (err) {
|
|
185
|
+
reportFail(isInteractive, s, fmt(msg.step1Fail, PKG), err);
|
|
186
|
+
process.exit(1);
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
async function stepInstallSkills(msg, isInteractive) {
|
|
191
|
+
// Always re-run `skills add` so new skills added in newer modelgo-cli
|
|
192
|
+
// versions actually land on the user's machine. The skills CLI is
|
|
193
|
+
// idempotent for existing skills (re-copies content) and additive for
|
|
194
|
+
// new ones; the slight extra GitHub fetch is worth keeping skill
|
|
195
|
+
// distribution in sync with the latest published bundle.
|
|
196
|
+
const s = reportStart(isInteractive, msg.step2Spinner);
|
|
197
|
+
try {
|
|
198
|
+
await runSilentAsync("npx", ["-y", "skills", "add", SKILLS_REPO, "-y", "-g"], { timeout: 120000 });
|
|
199
|
+
reportStop(isInteractive, s, msg.step2Done);
|
|
200
|
+
} catch (err) {
|
|
201
|
+
reportFail(isInteractive, s, fmt(msg.step2Fail, SKILLS_REPO), err);
|
|
202
|
+
process.exit(1);
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
// ---------------------------------------------------------------------------
|
|
207
|
+
// Main
|
|
208
|
+
// ---------------------------------------------------------------------------
|
|
209
|
+
|
|
210
|
+
async function main() {
|
|
211
|
+
const isInteractive = !!process.stdin.isTTY;
|
|
212
|
+
const lang = isInteractive ? await stepSelectLang() : (parseLangArg() || "en");
|
|
213
|
+
const msg = messages[lang];
|
|
214
|
+
|
|
215
|
+
if (isInteractive) {
|
|
216
|
+
p.intro(msg.setup);
|
|
217
|
+
await stepInstallGlobally(msg, isInteractive);
|
|
218
|
+
await stepInstallSkills(msg, isInteractive);
|
|
219
|
+
p.outro(msg.done);
|
|
220
|
+
} else {
|
|
221
|
+
console.log(msg.setup);
|
|
222
|
+
await stepInstallGlobally(msg, isInteractive);
|
|
223
|
+
await stepInstallSkills(msg, isInteractive);
|
|
224
|
+
console.log(msg.nonTtyHint);
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
main().catch((err) => {
|
|
229
|
+
p.cancel("Unexpected error: " + (err.message || err));
|
|
230
|
+
process.exit(1);
|
|
231
|
+
});
|
|
@@ -0,0 +1,308 @@
|
|
|
1
|
+
// Copyright (c) 2026 modelgo
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
|
|
4
|
+
const fs = require("fs");
|
|
5
|
+
const path = require("path");
|
|
6
|
+
const { execFileSync } = require("child_process");
|
|
7
|
+
const os = require("os");
|
|
8
|
+
const crypto = require("crypto");
|
|
9
|
+
|
|
10
|
+
const VERSION = require("../package.json").version;
|
|
11
|
+
const REPO = "modelgo/modelgo-cli";
|
|
12
|
+
const NAME = "modelgo-cli";
|
|
13
|
+
const DEFAULT_MIRROR_HOST = "https://registry.npmmirror.com";
|
|
14
|
+
|
|
15
|
+
// Allowlist gates the initial request host. curl --location follows redirects
|
|
16
|
+
// up to --max-redirs without re-checking; checksum verification is the
|
|
17
|
+
// primary integrity control, this allowlist is defense-in-depth.
|
|
18
|
+
const ALLOWED_HOSTS = new Set([
|
|
19
|
+
"github.com",
|
|
20
|
+
"objects.githubusercontent.com",
|
|
21
|
+
"registry.npmmirror.com",
|
|
22
|
+
]);
|
|
23
|
+
|
|
24
|
+
const PLATFORM_MAP = { darwin: "darwin", linux: "linux", win32: "windows" };
|
|
25
|
+
const ARCH_MAP = { x64: "amd64", arm64: "arm64" };
|
|
26
|
+
|
|
27
|
+
const platform = PLATFORM_MAP[process.platform];
|
|
28
|
+
const arch = ARCH_MAP[process.arch];
|
|
29
|
+
const isWindows = process.platform === "win32";
|
|
30
|
+
const ext = isWindows ? ".zip" : ".tar.gz";
|
|
31
|
+
const archiveName = `${NAME}-${VERSION}-${platform}-${arch}${ext}`;
|
|
32
|
+
const GITHUB_URL = `https://github.com/${REPO}/releases/download/v${VERSION}/${archiveName}`;
|
|
33
|
+
|
|
34
|
+
const binDir = path.join(__dirname, "..", "bin");
|
|
35
|
+
const dest = path.join(binDir, NAME + (isWindows ? ".exe" : ""));
|
|
36
|
+
|
|
37
|
+
function joinUrl(base, suffix) {
|
|
38
|
+
return base.replace(/\/+$/, "") + suffix;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function isValidDownloadBase(raw) {
|
|
42
|
+
try {
|
|
43
|
+
const parsed = new URL(raw);
|
|
44
|
+
return parsed.protocol === "https:" && !!parsed.hostname;
|
|
45
|
+
} catch (_) {
|
|
46
|
+
return false;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function isDefaultNpmjsRegistry(url) {
|
|
51
|
+
try {
|
|
52
|
+
const { hostname } = new URL(url);
|
|
53
|
+
return hostname === "registry.npmjs.org";
|
|
54
|
+
} catch (_) {
|
|
55
|
+
return false;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function assertAllowedHost(url) {
|
|
60
|
+
const { hostname } = new URL(url);
|
|
61
|
+
if (!ALLOWED_HOSTS.has(hostname)) {
|
|
62
|
+
throw new Error(`Download host not allowed: ${hostname}`);
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function resolveMirrorUrls(env, archive, version) {
|
|
67
|
+
const binaryPath = `/-/binary/${NAME}/v${version}/${archive}`;
|
|
68
|
+
const defaultUrl = joinUrl(DEFAULT_MIRROR_HOST, binaryPath);
|
|
69
|
+
|
|
70
|
+
const urls = [];
|
|
71
|
+
const registry = (env.npm_config_registry || "").trim();
|
|
72
|
+
if (registry && !isDefaultNpmjsRegistry(registry) && isValidDownloadBase(registry)) {
|
|
73
|
+
const base = new URL(registry);
|
|
74
|
+
urls.push(joinUrl(base.origin + base.pathname, binaryPath));
|
|
75
|
+
}
|
|
76
|
+
if (!urls.includes(defaultUrl)) urls.push(defaultUrl);
|
|
77
|
+
return urls;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function getExpectedChecksum(archiveName, checksumsDir) {
|
|
81
|
+
const dir = checksumsDir || path.join(__dirname, "..");
|
|
82
|
+
const checksumsPath = path.join(dir, "checksums.txt");
|
|
83
|
+
|
|
84
|
+
if (!fs.existsSync(checksumsPath)) {
|
|
85
|
+
console.error("[WARN] checksums.txt not found, skipping checksum verification");
|
|
86
|
+
return null;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const content = fs.readFileSync(checksumsPath, "utf8");
|
|
90
|
+
for (const line of content.split("\n")) {
|
|
91
|
+
const trimmed = line.trim();
|
|
92
|
+
if (!trimmed) continue;
|
|
93
|
+
const idx = trimmed.indexOf(" ");
|
|
94
|
+
if (idx === -1) continue;
|
|
95
|
+
const hash = trimmed.slice(0, idx);
|
|
96
|
+
const name = trimmed.slice(idx + 2);
|
|
97
|
+
if (name === archiveName) return hash;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
throw new Error(`Checksum entry not found for ${archiveName}`);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function verifyChecksum(archivePath, expectedHash) {
|
|
104
|
+
if (expectedHash === null) return;
|
|
105
|
+
|
|
106
|
+
const hash = crypto.createHash("sha256");
|
|
107
|
+
const fd = fs.openSync(archivePath, "r");
|
|
108
|
+
try {
|
|
109
|
+
const buf = Buffer.alloc(64 * 1024);
|
|
110
|
+
let bytesRead;
|
|
111
|
+
while ((bytesRead = fs.readSync(fd, buf, 0, buf.length, null)) > 0) {
|
|
112
|
+
hash.update(buf.subarray(0, bytesRead));
|
|
113
|
+
}
|
|
114
|
+
} finally {
|
|
115
|
+
fs.closeSync(fd);
|
|
116
|
+
}
|
|
117
|
+
const actual = hash.digest("hex");
|
|
118
|
+
|
|
119
|
+
if (actual.toLowerCase() !== expectedHash.toLowerCase()) {
|
|
120
|
+
throw new Error(
|
|
121
|
+
`[SECURITY] Checksum mismatch for ${path.basename(archivePath)}: ` +
|
|
122
|
+
`expected ${expectedHash} but got ${actual}`
|
|
123
|
+
);
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function semverLessThan(a, b) {
|
|
128
|
+
const split = (v) => {
|
|
129
|
+
const idx = v.indexOf("-");
|
|
130
|
+
return idx === -1 ? [v, ""] : [v.slice(0, idx), v.slice(idx + 1)];
|
|
131
|
+
};
|
|
132
|
+
const [coreA, preA] = split(a);
|
|
133
|
+
const [coreB, preB] = split(b);
|
|
134
|
+
const pa = coreA.split(".").map(Number);
|
|
135
|
+
const pb = coreB.split(".").map(Number);
|
|
136
|
+
for (let i = 0; i < 3; i++) {
|
|
137
|
+
if ((pa[i] || 0) < (pb[i] || 0)) return true;
|
|
138
|
+
if ((pa[i] || 0) > (pb[i] || 0)) return false;
|
|
139
|
+
}
|
|
140
|
+
if (preA === preB) return false;
|
|
141
|
+
if (preA === "") return false;
|
|
142
|
+
if (preB === "") return true;
|
|
143
|
+
const idsA = preA.split(".");
|
|
144
|
+
const idsB = preB.split(".");
|
|
145
|
+
const n = Math.max(idsA.length, idsB.length);
|
|
146
|
+
for (let i = 0; i < n; i++) {
|
|
147
|
+
const x = idsA[i];
|
|
148
|
+
const y = idsB[i];
|
|
149
|
+
if (x === undefined) return true;
|
|
150
|
+
if (y === undefined) return false;
|
|
151
|
+
const numX = /^\d+$/.test(x) ? parseInt(x, 10) : null;
|
|
152
|
+
const numY = /^\d+$/.test(y) ? parseInt(y, 10) : null;
|
|
153
|
+
if (numX !== null && numY !== null) {
|
|
154
|
+
if (numX < numY) return true;
|
|
155
|
+
if (numX > numY) return false;
|
|
156
|
+
} else if (numX !== null) {
|
|
157
|
+
return true;
|
|
158
|
+
} else if (numY !== null) {
|
|
159
|
+
return false;
|
|
160
|
+
} else {
|
|
161
|
+
if (x < y) return true;
|
|
162
|
+
if (x > y) return false;
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
return false;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
function getMirrorUrls(env) {
|
|
169
|
+
const urls = resolveMirrorUrls(env, archiveName, VERSION);
|
|
170
|
+
for (const u of urls) ALLOWED_HOSTS.add(new URL(u).hostname);
|
|
171
|
+
return urls;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
function download(url, destPath) {
|
|
175
|
+
assertAllowedHost(url);
|
|
176
|
+
const args = [
|
|
177
|
+
"--fail", "--location", "--silent", "--show-error",
|
|
178
|
+
"--connect-timeout", "10", "--max-time", "120",
|
|
179
|
+
"--max-redirs", "3",
|
|
180
|
+
"--output", destPath,
|
|
181
|
+
];
|
|
182
|
+
if (isWindows) args.unshift("--ssl-revoke-best-effort");
|
|
183
|
+
args.push(url);
|
|
184
|
+
execFileSync("curl", args, { stdio: ["ignore", "ignore", "pipe"] });
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
function extractZipWindows(archivePath, destDir) {
|
|
188
|
+
const psOpts = ["-NoProfile", "-ExecutionPolicy", "Bypass", "-Command"];
|
|
189
|
+
const psStdio = ["ignore", "inherit", "inherit"];
|
|
190
|
+
const psEnv = {
|
|
191
|
+
...process.env,
|
|
192
|
+
MODELGO_CLI_ARCHIVE: archivePath,
|
|
193
|
+
MODELGO_CLI_DEST: destDir,
|
|
194
|
+
};
|
|
195
|
+
try {
|
|
196
|
+
const dotnet =
|
|
197
|
+
"$ErrorActionPreference='Stop';" +
|
|
198
|
+
"Add-Type -AssemblyName System.IO.Compression.FileSystem;" +
|
|
199
|
+
"[System.IO.Compression.ZipFile]::ExtractToDirectory($env:MODELGO_CLI_ARCHIVE,$env:MODELGO_CLI_DEST)";
|
|
200
|
+
execFileSync("powershell.exe", [...psOpts, dotnet], { stdio: psStdio, env: psEnv });
|
|
201
|
+
} catch (primaryErr) {
|
|
202
|
+
try {
|
|
203
|
+
const cmdlet =
|
|
204
|
+
"$ErrorActionPreference='Stop';" +
|
|
205
|
+
"Expand-Archive -LiteralPath $env:MODELGO_CLI_ARCHIVE -DestinationPath $env:MODELGO_CLI_DEST -Force";
|
|
206
|
+
execFileSync("powershell.exe", [...psOpts, cmdlet], { stdio: psStdio, env: psEnv });
|
|
207
|
+
} catch (secondErr) {
|
|
208
|
+
try {
|
|
209
|
+
execFileSync("tar", ["-xf", archivePath, "-C", destDir], { stdio: psStdio });
|
|
210
|
+
} catch (fallbackErr) {
|
|
211
|
+
throw new Error(
|
|
212
|
+
`Failed to extract ${archivePath}. ` +
|
|
213
|
+
`.NET ZipFile: ${primaryErr.message}. ` +
|
|
214
|
+
`Expand-Archive: ${secondErr.message}. ` +
|
|
215
|
+
`tar: ${fallbackErr.message}`
|
|
216
|
+
);
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
function install() {
|
|
223
|
+
const mirrorUrls = getMirrorUrls(process.env);
|
|
224
|
+
const downloadUrls = [GITHUB_URL, ...mirrorUrls];
|
|
225
|
+
|
|
226
|
+
fs.mkdirSync(binDir, { recursive: true });
|
|
227
|
+
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "modelgo-cli-"));
|
|
228
|
+
const archivePath = path.join(tmpDir, archiveName);
|
|
229
|
+
|
|
230
|
+
try {
|
|
231
|
+
let lastErr;
|
|
232
|
+
let downloaded = false;
|
|
233
|
+
for (const url of downloadUrls) {
|
|
234
|
+
try {
|
|
235
|
+
download(url, archivePath);
|
|
236
|
+
downloaded = true;
|
|
237
|
+
break;
|
|
238
|
+
} catch (e) {
|
|
239
|
+
lastErr = e;
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
if (!downloaded) throw lastErr;
|
|
243
|
+
|
|
244
|
+
const expectedHash = getExpectedChecksum(archiveName);
|
|
245
|
+
verifyChecksum(archivePath, expectedHash);
|
|
246
|
+
|
|
247
|
+
if (isWindows) {
|
|
248
|
+
extractZipWindows(archivePath, tmpDir);
|
|
249
|
+
} else {
|
|
250
|
+
execFileSync("tar", ["-xzf", archivePath, "-C", tmpDir], { stdio: "ignore" });
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
const binaryName = NAME + (isWindows ? ".exe" : "");
|
|
254
|
+
const extractedBinary = path.join(tmpDir, binaryName);
|
|
255
|
+
fs.copyFileSync(extractedBinary, dest);
|
|
256
|
+
fs.chmodSync(dest, 0o755);
|
|
257
|
+
console.log(`${NAME} v${VERSION} installed successfully`);
|
|
258
|
+
} finally {
|
|
259
|
+
fs.rmSync(tmpDir, { recursive: true, force: true });
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
if (require.main === module) {
|
|
264
|
+
if (!platform || !arch) {
|
|
265
|
+
console.error(`Unsupported platform: ${process.platform}-${process.arch}`);
|
|
266
|
+
process.exit(1);
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
// Skip binary download when triggered as postinstall under `npx <pkg> install`.
|
|
270
|
+
// The wizard does not need the binary; run.js will be called next, and it
|
|
271
|
+
// dispatches to install-wizard.js for the "install" arg.
|
|
272
|
+
const isNpxPostinstall = process.env.npm_command === "exec";
|
|
273
|
+
if (isNpxPostinstall) {
|
|
274
|
+
process.exit(0);
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
try {
|
|
278
|
+
install();
|
|
279
|
+
} catch (err) {
|
|
280
|
+
console.error(`Failed to install ${NAME}:`, err.message);
|
|
281
|
+
console.error(
|
|
282
|
+
`\nIf you are behind a firewall or in a restricted network, try one of:\n` +
|
|
283
|
+
` # 1. Use a proxy:\n` +
|
|
284
|
+
` export https_proxy=http://your-proxy:port\n` +
|
|
285
|
+
` npm install -g @model-go/cli\n\n` +
|
|
286
|
+
` # 2. Point to a corporate npm mirror that proxies /-/binary/${NAME}/...:\n` +
|
|
287
|
+
` npm install -g @model-go/cli --registry=https://your-corp-mirror/`
|
|
288
|
+
);
|
|
289
|
+
process.exit(1);
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
module.exports = {
|
|
294
|
+
// exported for testing
|
|
295
|
+
ALLOWED_HOSTS,
|
|
296
|
+
resolveMirrorUrls,
|
|
297
|
+
isValidDownloadBase,
|
|
298
|
+
isDefaultNpmjsRegistry,
|
|
299
|
+
assertAllowedHost,
|
|
300
|
+
getExpectedChecksum,
|
|
301
|
+
verifyChecksum,
|
|
302
|
+
semverLessThan,
|
|
303
|
+
// exported for install-wizard
|
|
304
|
+
archiveName,
|
|
305
|
+
VERSION,
|
|
306
|
+
binDir,
|
|
307
|
+
dest,
|
|
308
|
+
};
|
package/scripts/run.js
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Copyright (c) 2026 modelgo
|
|
3
|
+
// SPDX-License-Identifier: MIT
|
|
4
|
+
//
|
|
5
|
+
// npm bin entrypoint. Routes:
|
|
6
|
+
// - `modelgo-cli install` → install-wizard.js (interactive setup)
|
|
7
|
+
// - everything else → exec bin/modelgo-cli (the downloaded Go binary)
|
|
8
|
+
|
|
9
|
+
const { execFileSync } = require("child_process");
|
|
10
|
+
const path = require("path");
|
|
11
|
+
const fs = require("fs");
|
|
12
|
+
|
|
13
|
+
const args = process.argv.slice(2);
|
|
14
|
+
|
|
15
|
+
if (args[0] === "install") {
|
|
16
|
+
require("./install-wizard.js");
|
|
17
|
+
} else {
|
|
18
|
+
const isWindows = process.platform === "win32";
|
|
19
|
+
const binName = "modelgo-cli" + (isWindows ? ".exe" : "");
|
|
20
|
+
const binPath = path.join(__dirname, "..", "bin", binName);
|
|
21
|
+
|
|
22
|
+
if (!fs.existsSync(binPath)) {
|
|
23
|
+
console.error(
|
|
24
|
+
`modelgo-cli binary not found at ${binPath}\n` +
|
|
25
|
+
`Please reinstall: npm install -g @model-go/cli`
|
|
26
|
+
);
|
|
27
|
+
process.exit(1);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
try {
|
|
31
|
+
execFileSync(binPath, args, { stdio: "inherit" });
|
|
32
|
+
} catch (e) {
|
|
33
|
+
process.exit(typeof e.status === "number" ? e.status : 1);
|
|
34
|
+
}
|
|
35
|
+
}
|