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 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
- The installer downloads a prebuilt release package from GitHub Releases for:
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
- - darwin-arm64
14
- - darwin-amd64
15
- - linux-arm64
16
- - linux-amd64
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 resolveInstalledBinary() {
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 pkgVersion = JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8")).version;
28
- const installRoot = path.join(homedir(), ".shellman", "versions", `v${pkgVersion}`, triple, "bin");
29
- const binPath = path.join(installRoot, "shellman");
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
- if (!existsSync(binPath)) {
32
- console.error("shellman: prebuilt binary is not installed. Reinstall package:");
33
- console.error(" npm install -g shellman");
34
- process.exit(1);
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
- return binPath;
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 bin = resolveInstalledBinary();
41
- const child = spawn(bin, process.argv.slice(2), {
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "shellman",
3
- "version": "0.1.0",
3
+ "version": "0.1.1-dev.0",
4
4
  "description": "Shellman CLI installer package",
5
5
  "license": "AGPL-3.0",
6
6
  "type": "module",
@@ -18,22 +18,39 @@ function resolveTargetTriple() {
18
18
  return "";
19
19
  }
20
20
 
21
- function getPackageVersion() {
21
+ function getPackageMeta() {
22
22
  const packageJSON = JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8"));
23
- return packageJSON.version;
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 || "cybersailor/shellman-project").trim();
28
- return raw || "cybersailor/shellman-project";
36
+ const raw = (process.env.SHELLMAN_GITHUB_REPO || "thecybersailor/shellman").trim();
37
+ return raw || "thecybersailor/shellman";
29
38
  }
30
39
 
31
- function getDownloadBase(version) {
32
- const customBase = (process.env.SHELLMAN_DOWNLOAD_BASE_URL || "").trim();
33
- if (customBase) {
34
- return customBase.replace(/\/+$/, "");
40
+ function getManifestURL(channel) {
41
+ const custom = String(process.env.SHELLMAN_RELEASE_MANIFEST_URL ?? "").trim();
42
+ if (custom) {
43
+ return custom;
35
44
  }
36
- return `https://github.com/${getRepo()}/releases/download/v${version}`;
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 parseSHA256Sums(filePath) {
54
- const lines = readFileSync(filePath, "utf8").split(/\r?\n/);
55
- const out = new Map();
56
- for (const line of lines) {
57
- const trimmed = line.trim();
58
- if (!trimmed) continue;
59
- const m = trimmed.match(/^([a-fA-F0-9]{64})\s+\*?(.+)$/);
60
- if (!m) continue;
61
- out.set(m[2].trim(), m[1].toLowerCase());
62
- }
63
- return out;
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 = getPackageVersion();
79
- const assetName = `shellman-v${version}-${triple}.zip`;
80
- const checksumsName = "SHA256SUMS";
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 tmpRoot = path.join(homedir(), ".shellman", "tmp", `v${version}-${triple}`);
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, assetName);
95
- const sumsPath = path.join(tmpRoot, checksumsName);
96
- const zipURL = `${base}/${assetName}`;
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 !== expected) {
110
- throw new Error(`checksum mismatch for ${assetName}`);
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 v${version} for ${triple}`);
159
+ console.log(`shellman: installed ${releaseAsset.version} (${channel}) for ${triple}`);
125
160
  }
126
161
 
127
162
  main().catch((error) => {