shellman 0.1.0 → 0.1.1-dev.0
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 +28 -5
- package/bin/shellman.js +152 -12
- package/package.json +1 -1
- package/scripts/postinstall.js +82 -47
package/README.md
CHANGED
|
@@ -4,13 +4,36 @@ Global installer package for Shellman.
|
|
|
4
4
|
|
|
5
5
|
## Install
|
|
6
6
|
|
|
7
|
+
Stable channel:
|
|
8
|
+
|
|
7
9
|
```bash
|
|
8
10
|
npm install -g shellman
|
|
9
11
|
```
|
|
10
12
|
|
|
11
|
-
|
|
13
|
+
Dev channel:
|
|
14
|
+
|
|
15
|
+
```bash
|
|
16
|
+
npm install -g shellman@dev
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
## How It Works
|
|
20
|
+
|
|
21
|
+
- `postinstall` reads `release.json` from GitHub Releases.
|
|
22
|
+
- It picks the matching artifact for the current platform and verifies SHA-256.
|
|
23
|
+
- Supported targets:
|
|
24
|
+
- darwin-arm64
|
|
25
|
+
- darwin-amd64
|
|
26
|
+
- linux-arm64
|
|
27
|
+
- linux-amd64
|
|
28
|
+
|
|
29
|
+
At runtime, `shellman` checks remote `release.json` periodically and prints an upgrade hint when a newer channel version is available.
|
|
30
|
+
|
|
31
|
+
## Environment Variables
|
|
12
32
|
|
|
13
|
-
-
|
|
14
|
-
-
|
|
15
|
-
-
|
|
16
|
-
-
|
|
33
|
+
- `SHELLMAN_RELEASE_CHANNEL=stable|dev`
|
|
34
|
+
- `SHELLMAN_GITHUB_REPO=owner/repo`
|
|
35
|
+
- `SHELLMAN_RELEASE_MANIFEST_URL=https://.../release.json`
|
|
36
|
+
- `SHELLMAN_SKIP_POSTINSTALL=1`
|
|
37
|
+
- `SHELLMAN_FORCE_POSTINSTALL=1`
|
|
38
|
+
- `SHELLMAN_NO_UPDATE_CHECK=1`
|
|
39
|
+
- `SHELLMAN_UPDATE_CHECK_INTERVAL_SEC=43200`
|
package/bin/shellman.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
|
|
3
3
|
import { spawn } from "node:child_process";
|
|
4
|
-
import { existsSync, readFileSync } from "node:fs";
|
|
4
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
5
5
|
import { homedir, platform, arch } from "node:os";
|
|
6
6
|
import path from "node:path";
|
|
7
7
|
import process from "node:process";
|
|
@@ -17,28 +17,168 @@ function resolveTargetTriple() {
|
|
|
17
17
|
return "";
|
|
18
18
|
}
|
|
19
19
|
|
|
20
|
-
function
|
|
20
|
+
function getPackageMeta() {
|
|
21
|
+
const pkg = JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8"));
|
|
22
|
+
const version = String(pkg.version ?? "").trim();
|
|
23
|
+
return { version };
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function getChannel(pkgVersion) {
|
|
27
|
+
const forced = String(process.env.SHELLMAN_RELEASE_CHANNEL ?? "").trim();
|
|
28
|
+
if (forced === "stable" || forced === "dev") {
|
|
29
|
+
return forced;
|
|
30
|
+
}
|
|
31
|
+
return pkgVersion.includes("-dev") ? "dev" : "stable";
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function getRepo() {
|
|
35
|
+
const raw = (process.env.SHELLMAN_GITHUB_REPO || "thecybersailor/shellman").trim();
|
|
36
|
+
return raw || "thecybersailor/shellman";
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function getManifestURL(channel) {
|
|
40
|
+
const custom = String(process.env.SHELLMAN_RELEASE_MANIFEST_URL ?? "").trim();
|
|
41
|
+
if (custom) {
|
|
42
|
+
return custom;
|
|
43
|
+
}
|
|
44
|
+
const repo = getRepo();
|
|
45
|
+
if (channel === "dev") {
|
|
46
|
+
return `https://github.com/${repo}/releases/download/dev-main/release.json`;
|
|
47
|
+
}
|
|
48
|
+
return `https://github.com/${repo}/releases/latest/download/release.json`;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function resolveInstalledBinary(channel, pkgVersion) {
|
|
21
52
|
const triple = resolveTargetTriple();
|
|
22
53
|
if (!triple) {
|
|
23
54
|
console.error(`shellman: unsupported platform ${platform()}/${arch()} (only darwin/linux + arm64/x64 supported)`);
|
|
24
55
|
process.exit(1);
|
|
25
56
|
}
|
|
26
57
|
|
|
27
|
-
const
|
|
28
|
-
const
|
|
29
|
-
|
|
58
|
+
const channelRoot = path.join(homedir(), ".shellman", "channels", channel, triple);
|
|
59
|
+
const channelBin = path.join(channelRoot, "bin", "shellman");
|
|
60
|
+
if (existsSync(channelBin)) {
|
|
61
|
+
return {
|
|
62
|
+
binPath: channelBin,
|
|
63
|
+
versionFile: path.join(channelRoot, "VERSION")
|
|
64
|
+
};
|
|
65
|
+
}
|
|
30
66
|
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
67
|
+
const legacyRoot = path.join(homedir(), ".shellman", "versions", `v${pkgVersion}`, triple);
|
|
68
|
+
const legacyBin = path.join(legacyRoot, "bin", "shellman");
|
|
69
|
+
if (existsSync(legacyBin)) {
|
|
70
|
+
return {
|
|
71
|
+
binPath: legacyBin,
|
|
72
|
+
versionFile: path.join(legacyRoot, "VERSION")
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
console.error("shellman: prebuilt binary is not installed. Reinstall package:");
|
|
77
|
+
console.error(` npm install -g ${channel === "dev" ? "shellman@dev" : "shellman"}`);
|
|
78
|
+
process.exit(1);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function readInstalledVersion(fallbackVersion, versionFile) {
|
|
82
|
+
if (!existsSync(versionFile)) {
|
|
83
|
+
return fallbackVersion;
|
|
84
|
+
}
|
|
85
|
+
const raw = String(readFileSync(versionFile, "utf8")).trim();
|
|
86
|
+
if (!raw) {
|
|
87
|
+
return fallbackVersion;
|
|
88
|
+
}
|
|
89
|
+
return raw.replace(/^v/, "");
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function shouldSkipUpdateCheck() {
|
|
93
|
+
return String(process.env.SHELLMAN_NO_UPDATE_CHECK ?? "").trim() === "1";
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function getUpdateCheckIntervalSec() {
|
|
97
|
+
const raw = String(process.env.SHELLMAN_UPDATE_CHECK_INTERVAL_SEC ?? "").trim();
|
|
98
|
+
const parsed = Number(raw);
|
|
99
|
+
if (Number.isFinite(parsed) && parsed > 0) {
|
|
100
|
+
return Math.floor(parsed);
|
|
101
|
+
}
|
|
102
|
+
return 43200;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function loadUpdateCache(cachePath) {
|
|
106
|
+
if (!existsSync(cachePath)) {
|
|
107
|
+
return { checkedAt: 0, lastNotifiedVersion: "" };
|
|
108
|
+
}
|
|
109
|
+
try {
|
|
110
|
+
const parsed = JSON.parse(readFileSync(cachePath, "utf8"));
|
|
111
|
+
return {
|
|
112
|
+
checkedAt: Number(parsed.checkedAt ?? 0),
|
|
113
|
+
lastNotifiedVersion: String(parsed.lastNotifiedVersion ?? "")
|
|
114
|
+
};
|
|
115
|
+
} catch {
|
|
116
|
+
return { checkedAt: 0, lastNotifiedVersion: "" };
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function saveUpdateCache(cachePath, payload) {
|
|
121
|
+
mkdirSync(path.dirname(cachePath), { recursive: true });
|
|
122
|
+
writeFileSync(cachePath, JSON.stringify(payload), "utf8");
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
async function checkForUpdateNotice({ channel, installedVersion }) {
|
|
126
|
+
if (shouldSkipUpdateCheck()) {
|
|
127
|
+
return;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
const cachePath = path.join(homedir(), ".shellman", "cache", `update-${channel}.json`);
|
|
131
|
+
const nowSec = Math.floor(Date.now() / 1000);
|
|
132
|
+
const intervalSec = getUpdateCheckIntervalSec();
|
|
133
|
+
const cache = loadUpdateCache(cachePath);
|
|
134
|
+
if (nowSec - cache.checkedAt < intervalSec) {
|
|
135
|
+
return;
|
|
35
136
|
}
|
|
36
137
|
|
|
37
|
-
|
|
138
|
+
try {
|
|
139
|
+
const manifestURL = getManifestURL(channel);
|
|
140
|
+
const controller = new AbortController();
|
|
141
|
+
const timer = setTimeout(() => controller.abort(), 2500);
|
|
142
|
+
const res = await fetch(manifestURL, {
|
|
143
|
+
headers: { Accept: "application/json" },
|
|
144
|
+
signal: controller.signal
|
|
145
|
+
});
|
|
146
|
+
clearTimeout(timer);
|
|
147
|
+
|
|
148
|
+
if (!res.ok) {
|
|
149
|
+
saveUpdateCache(cachePath, { checkedAt: nowSec, lastNotifiedVersion: cache.lastNotifiedVersion });
|
|
150
|
+
return;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
const manifest = await res.json();
|
|
154
|
+
const remoteVersion = String(manifest?.version ?? "").trim().replace(/^v/, "");
|
|
155
|
+
if (!remoteVersion) {
|
|
156
|
+
saveUpdateCache(cachePath, { checkedAt: nowSec, lastNotifiedVersion: cache.lastNotifiedVersion });
|
|
157
|
+
return;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
if (remoteVersion !== installedVersion && cache.lastNotifiedVersion !== remoteVersion) {
|
|
161
|
+
const upgradeCmd = channel === "dev" ? "npm install -g shellman@dev" : "npm install -g shellman";
|
|
162
|
+
console.error(`shellman: update available (${channel}) current=${installedVersion} latest=${remoteVersion}`);
|
|
163
|
+
console.error(`shellman: run \`${upgradeCmd}\``);
|
|
164
|
+
saveUpdateCache(cachePath, { checkedAt: nowSec, lastNotifiedVersion: remoteVersion });
|
|
165
|
+
return;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
saveUpdateCache(cachePath, { checkedAt: nowSec, lastNotifiedVersion: cache.lastNotifiedVersion });
|
|
169
|
+
} catch {
|
|
170
|
+
saveUpdateCache(cachePath, { checkedAt: nowSec, lastNotifiedVersion: cache.lastNotifiedVersion });
|
|
171
|
+
}
|
|
38
172
|
}
|
|
39
173
|
|
|
40
|
-
const
|
|
41
|
-
const
|
|
174
|
+
const { version: pkgVersion } = getPackageMeta();
|
|
175
|
+
const channel = getChannel(pkgVersion);
|
|
176
|
+
const resolved = resolveInstalledBinary(channel, pkgVersion);
|
|
177
|
+
const installedVersion = readInstalledVersion(pkgVersion, resolved.versionFile);
|
|
178
|
+
|
|
179
|
+
void checkForUpdateNotice({ channel, installedVersion });
|
|
180
|
+
|
|
181
|
+
const child = spawn(resolved.binPath, process.argv.slice(2), {
|
|
42
182
|
stdio: "inherit",
|
|
43
183
|
env: process.env,
|
|
44
184
|
});
|
package/package.json
CHANGED
package/scripts/postinstall.js
CHANGED
|
@@ -18,22 +18,39 @@ function resolveTargetTriple() {
|
|
|
18
18
|
return "";
|
|
19
19
|
}
|
|
20
20
|
|
|
21
|
-
function
|
|
21
|
+
function getPackageMeta() {
|
|
22
22
|
const packageJSON = JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8"));
|
|
23
|
-
|
|
23
|
+
const version = String(packageJSON.version ?? "").trim();
|
|
24
|
+
return { version };
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function getChannel(pkgVersion) {
|
|
28
|
+
const forced = String(process.env.SHELLMAN_RELEASE_CHANNEL ?? "").trim();
|
|
29
|
+
if (forced === "stable" || forced === "dev") {
|
|
30
|
+
return forced;
|
|
31
|
+
}
|
|
32
|
+
return pkgVersion.includes("-dev") ? "dev" : "stable";
|
|
24
33
|
}
|
|
25
34
|
|
|
26
35
|
function getRepo() {
|
|
27
|
-
const raw = (process.env.SHELLMAN_GITHUB_REPO || "
|
|
28
|
-
return raw || "
|
|
36
|
+
const raw = (process.env.SHELLMAN_GITHUB_REPO || "thecybersailor/shellman").trim();
|
|
37
|
+
return raw || "thecybersailor/shellman";
|
|
29
38
|
}
|
|
30
39
|
|
|
31
|
-
function
|
|
32
|
-
const
|
|
33
|
-
if (
|
|
34
|
-
return
|
|
40
|
+
function getManifestURL(channel) {
|
|
41
|
+
const custom = String(process.env.SHELLMAN_RELEASE_MANIFEST_URL ?? "").trim();
|
|
42
|
+
if (custom) {
|
|
43
|
+
return custom;
|
|
35
44
|
}
|
|
36
|
-
|
|
45
|
+
const repo = getRepo();
|
|
46
|
+
if (channel === "dev") {
|
|
47
|
+
return `https://github.com/${repo}/releases/download/dev-main/release.json`;
|
|
48
|
+
}
|
|
49
|
+
return `https://github.com/${repo}/releases/latest/download/release.json`;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function getInstallRoot(channel, triple) {
|
|
53
|
+
return path.join(homedir(), ".shellman", "channels", channel, triple);
|
|
37
54
|
}
|
|
38
55
|
|
|
39
56
|
async function download(url, outFile) {
|
|
@@ -44,23 +61,45 @@ async function download(url, outFile) {
|
|
|
44
61
|
await pipeline(Readable.fromWeb(res.body), createWriteStream(outFile));
|
|
45
62
|
}
|
|
46
63
|
|
|
64
|
+
async function fetchJSON(url) {
|
|
65
|
+
const res = await fetch(url, { headers: { Accept: "application/json" } });
|
|
66
|
+
if (!res.ok) {
|
|
67
|
+
throw new Error(`manifest request failed ${res.status} ${res.statusText}: ${url}`);
|
|
68
|
+
}
|
|
69
|
+
return res.json();
|
|
70
|
+
}
|
|
71
|
+
|
|
47
72
|
function sha256File(filePath) {
|
|
48
73
|
const hash = createHash("sha256");
|
|
49
74
|
hash.update(readFileSync(filePath));
|
|
50
75
|
return hash.digest("hex");
|
|
51
76
|
}
|
|
52
77
|
|
|
53
|
-
function
|
|
54
|
-
const
|
|
55
|
-
const
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
78
|
+
function resolveReleaseAsset(manifest, triple) {
|
|
79
|
+
const version = String(manifest?.version ?? "").trim();
|
|
80
|
+
const baseURL = String(manifest?.base_url ?? "").replace(/\/+$/, "");
|
|
81
|
+
const artifacts = Array.isArray(manifest?.artifacts) ? manifest.artifacts : [];
|
|
82
|
+
const hit = artifacts.find((item) => String(item?.target ?? "") === triple);
|
|
83
|
+
if (!version) {
|
|
84
|
+
throw new Error("invalid release manifest: missing version");
|
|
85
|
+
}
|
|
86
|
+
if (!baseURL) {
|
|
87
|
+
throw new Error("invalid release manifest: missing base_url");
|
|
88
|
+
}
|
|
89
|
+
if (!hit) {
|
|
90
|
+
throw new Error(`release manifest missing target: ${triple}`);
|
|
91
|
+
}
|
|
92
|
+
const file = String(hit.file ?? "").trim();
|
|
93
|
+
const sha256 = String(hit.sha256 ?? "").trim().toLowerCase();
|
|
94
|
+
if (!file || !sha256) {
|
|
95
|
+
throw new Error(`release manifest target incomplete: ${triple}`);
|
|
96
|
+
}
|
|
97
|
+
return {
|
|
98
|
+
version,
|
|
99
|
+
file,
|
|
100
|
+
sha256,
|
|
101
|
+
url: `${baseURL}/${file}`
|
|
102
|
+
};
|
|
64
103
|
}
|
|
65
104
|
|
|
66
105
|
async function main() {
|
|
@@ -68,6 +107,12 @@ async function main() {
|
|
|
68
107
|
console.log("shellman: skip postinstall by SHELLMAN_SKIP_POSTINSTALL=1");
|
|
69
108
|
return;
|
|
70
109
|
}
|
|
110
|
+
const forcePostinstall = String(process.env.SHELLMAN_FORCE_POSTINSTALL ?? "").trim() === "1";
|
|
111
|
+
const isGlobalInstall = String(process.env.npm_config_global ?? "").trim() === "true";
|
|
112
|
+
if (!forcePostinstall && !isGlobalInstall) {
|
|
113
|
+
console.log("shellman: skip postinstall for non-global install");
|
|
114
|
+
return;
|
|
115
|
+
}
|
|
71
116
|
|
|
72
117
|
const triple = resolveTargetTriple();
|
|
73
118
|
if (!triple) {
|
|
@@ -75,53 +120,43 @@ async function main() {
|
|
|
75
120
|
process.exit(1);
|
|
76
121
|
}
|
|
77
122
|
|
|
78
|
-
const version =
|
|
79
|
-
const
|
|
80
|
-
const
|
|
81
|
-
const base = getDownloadBase(version);
|
|
82
|
-
|
|
83
|
-
const installRoot = path.join(homedir(), ".shellman", "versions", `v${version}`, triple);
|
|
123
|
+
const { version: pkgVersion } = getPackageMeta();
|
|
124
|
+
const channel = getChannel(pkgVersion);
|
|
125
|
+
const installRoot = getInstallRoot(channel, triple);
|
|
84
126
|
const binPath = path.join(installRoot, "bin", "shellman");
|
|
127
|
+
|
|
85
128
|
if (existsSync(binPath)) {
|
|
86
129
|
console.log(`shellman: binary already installed at ${binPath}`);
|
|
87
130
|
return;
|
|
88
131
|
}
|
|
89
132
|
|
|
90
|
-
const
|
|
133
|
+
const manifestURL = getManifestURL(channel);
|
|
134
|
+
console.log(`shellman: reading release manifest (${channel}) ${manifestURL}`);
|
|
135
|
+
const manifest = await fetchJSON(manifestURL);
|
|
136
|
+
const releaseAsset = resolveReleaseAsset(manifest, triple);
|
|
137
|
+
|
|
138
|
+
const tmpRoot = path.join(homedir(), ".shellman", "tmp", `${channel}-${triple}`);
|
|
91
139
|
rmSync(tmpRoot, { recursive: true, force: true });
|
|
92
140
|
mkdirSync(tmpRoot, { recursive: true });
|
|
93
141
|
|
|
94
|
-
const zipPath = path.join(tmpRoot,
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
const sumsURL = `${base}/${checksumsName}`;
|
|
142
|
+
const zipPath = path.join(tmpRoot, releaseAsset.file);
|
|
143
|
+
console.log(`shellman: downloading ${releaseAsset.url}`);
|
|
144
|
+
await download(releaseAsset.url, zipPath);
|
|
98
145
|
|
|
99
|
-
console.log(`shellman: downloading ${zipURL}`);
|
|
100
|
-
await download(zipURL, zipPath);
|
|
101
|
-
await download(sumsURL, sumsPath);
|
|
102
|
-
|
|
103
|
-
const parsed = parseSHA256Sums(sumsPath);
|
|
104
|
-
const expected = parsed.get(assetName);
|
|
105
|
-
if (!expected) {
|
|
106
|
-
throw new Error(`missing checksum entry for ${assetName}`);
|
|
107
|
-
}
|
|
108
146
|
const actual = sha256File(zipPath);
|
|
109
|
-
if (actual !==
|
|
110
|
-
throw new Error(`checksum mismatch for ${
|
|
147
|
+
if (actual !== releaseAsset.sha256) {
|
|
148
|
+
throw new Error(`checksum mismatch for ${releaseAsset.file}`);
|
|
111
149
|
}
|
|
112
150
|
|
|
113
151
|
rmSync(installRoot, { recursive: true, force: true });
|
|
114
152
|
mkdirSync(installRoot, { recursive: true });
|
|
115
|
-
await pipeline(
|
|
116
|
-
createReadStream(zipPath),
|
|
117
|
-
unzipper.Extract({ path: installRoot }),
|
|
118
|
-
);
|
|
153
|
+
await pipeline(createReadStream(zipPath), unzipper.Extract({ path: installRoot }));
|
|
119
154
|
|
|
120
155
|
if (!existsSync(binPath)) {
|
|
121
156
|
throw new Error(`installed package missing binary: ${binPath}`);
|
|
122
157
|
}
|
|
123
158
|
|
|
124
|
-
console.log(`shellman: installed
|
|
159
|
+
console.log(`shellman: installed ${releaseAsset.version} (${channel}) for ${triple}`);
|
|
125
160
|
}
|
|
126
161
|
|
|
127
162
|
main().catch((error) => {
|