@sns-myagent/cli 0.3.0 → 0.3.2

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/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "type": "module",
3
3
  "name": "@sns-myagent/cli",
4
- "version": "0.3.0",
4
+ "version": "0.3.2",
5
5
  "description": "SNS-MyAgent — Pi Agent size, full features. BYOK coding agent CLI.",
6
6
  "homepage": "https://github.com/Reihantt6/sns-myagent",
7
7
  "author": "Reihantt6",
@@ -28,12 +28,13 @@
28
28
  "main": "./src/index.ts",
29
29
  "types": "./dist/types/index.d.ts",
30
30
  "bin": {
31
- "snscoder": "bin/snscoder.js"
31
+ "snscoder": "bin/snscoder.js",
32
+ "snsagent": "bin/snscoder.js"
32
33
  },
33
34
  "scripts": {
34
35
  "fetch-binary": "node scripts/fetch-binary.mjs",
35
36
  "postinstall": "node scripts/fetch-binary.mjs && node scripts/apply-pi-natives-patch.js",
36
- "build": "bun build --compile --define process.env.PI_COMPILED='\"true\"' --define process.env.PKG_VERSION='\"0.3.0\"' --no-compile-autoload-bunfig --no-compile-autoload-dotenv --no-compile-autoload-tsconfig --no-compile-autoload-package-json --keep-names --outfile bin/snscoder-linux-x64 ./src/cli/entry.ts && cp bin/snscoder-linux-x64 bin/snscoder",
37
+ "build": "bun build --compile --define process.env.PI_COMPILED='\"true\"' --define process.env.PKG_VERSION='\"0.3.1\"' --no-compile-autoload-bunfig --no-compile-autoload-dotenv --no-compile-autoload-tsconfig --no-compile-autoload-package-json --keep-names --outfile bin/snscoder-linux-x64 ./src/cli/entry.ts && cp bin/snscoder-linux-x64 bin/snscoder",
37
38
  "check": "biome check . && bun run check:types",
38
39
  "check:types": "tsgo -p tsconfig.json --noEmit",
39
40
  "lint": "biome lint .",
@@ -106,6 +107,7 @@
106
107
  },
107
108
  "files": [
108
109
  "src",
110
+ "bin",
109
111
  "dist/cli.js",
110
112
  "dist/*.node",
111
113
  "scripts",
@@ -1,34 +1,22 @@
1
1
  #!/usr/bin/env node
2
- // fetch-binary.mjs — Node.js postinstall for @sns-myagent/cli (Opsi B: dual-runtime npm support)
2
+ // fetch-binary.mjs — Node.js postinstall for @sns-myagent/cli
3
3
  //
4
4
  // Downloads the prebuilt `snscoder` binary from the latest GitHub release
5
- // and unpacks it to <repo>/bin/. Runs under plain Node 18+ (no `npm install`
6
- // dependencies allowed npm postinstall executes BEFORE package deps install).
5
+ // into <prefix>/bin/, then symlinks the active one to <prefix>/bin/snscoder
6
+ // so `npm install -g @sns-myagent/cli` puts a working `snscoder` on PATH.
7
7
  //
8
- // Strategy:
9
- // 1. Detect platform + arch (process.platform, process.arch).
10
- // 2. On linux, attempt a musl probe via `ldd /proc/self/exe` — if no glibc
11
- // dependency is reported we pick the musl asset, otherwise the glibc one.
12
- // If the probe fails or is inconclusive, fall back to glibc.
13
- // 3. GET https://api.github.com/repos/Reihantt6/sns-myagent/releases/latest
14
- // (honors GITHUB_TOKEN env var for 60-req/hr anonymous → 5000-req/hr auth).
15
- // 4. Find the matching asset, download its zip, and unpack into bin/.
16
- // Unix uses `unzip -o`; Windows uses `Expand-Archive` via PowerShell.
17
- // 5. chmod 755 the resulting binary on unix.
18
- // 6. On any failure path (no release, missing asset, network down) print a
19
- // friendly warning and exit 0 — we must NOT break `npm install`.
8
+ // Asset layout matches what .github/workflows/build-release.yml produces:
9
+ // snscoder-linux-x64 (raw, not zipped)
10
+ // snscoder-linux-arm64
11
+ // snscoder-darwin-x64
12
+ // snscoder-darwin-arm64
13
+ // snscoder-windows-x64.exe
20
14
  //
21
- // Spec conventions assumed for release assets (Bun-style):
22
- // snscoder-linux-x64.zip (glibc fallback)
23
- // snscoder-linux-x64-musl.zip (musl/alpine)
24
- // snscoder-linux-arm64.zip
25
- // snscoder-darwin-x64.zip
26
- // snscoder-darwin-arm64.zip
27
- // snscoder-windows-x64.zip
28
- // Each archive contains a single file named `snscoder` (or `snscoder.exe`).
15
+ // Falls back to musl variant on Linux if glibc asset unavailable.
16
+ // Never breaks `npm install` — on any failure prints warning + exits 0.
29
17
 
30
18
  import { execFileSync } from "node:child_process";
31
- import { createWriteStream, existsSync, mkdirSync } from "node:fs";
19
+ import { createWriteStream, existsSync, lstatSync, mkdirSync, symlinkSync, unlinkSync } from "node:fs";
32
20
  import { chmod, mkdtemp, readdir, rm, stat } from "node:fs/promises";
33
21
  import { tmpdir } from "node:os";
34
22
  import { join, dirname, resolve } from "node:path";
@@ -37,147 +25,89 @@ import https from "node:https";
37
25
 
38
26
  const REPO = "Reihantt6/sns-myagent";
39
27
  const RELEASE_API = `https://api.github.com/repos/${REPO}/releases/latest`;
40
- const UA = "sns-myagent-fetch-binary/0.1.0 (npm postinstall)";
28
+ const UA = "sns-myagent-fetch-binary/0.2.0 (npm postinstall)";
41
29
 
42
- // Resolve repo root: this script lives at <repo>/scripts/fetch-binary.mjs
43
30
  const __filename = fileURLToPath(import.meta.url);
44
31
  const __dirname = dirname(__filename);
32
+ // npm install -g layout: <prefix>/lib/node_modules/@sns-myagent/cli/scripts/fetch-binary.mjs
33
+ // <prefix>/lib/node_modules/@sns-myagent/cli/node_modules/.bin/snscoder (npm symlinks bin)
34
+ // We want to download into <prefix>/lib/node_modules/@sns-myagent/cli/bin/
35
+ // so npm auto-symlinks it into <prefix>/bin/snscoder.
45
36
  const REPO_ROOT = resolve(__dirname, "..");
46
37
  const BIN_DIR = join(REPO_ROOT, "bin");
47
38
 
48
39
  const isWin = process.platform === "win32";
49
40
 
50
- // --- Pretty logging ---------------------------------------------------------
51
-
52
41
  const c = {
53
42
  reset: "\u001b[0m",
54
- dim: "\u001b[2m",
55
43
  red: "\u001b[31m",
56
44
  green: "\u001b[32m",
57
45
  yellow: "\u001b[33m",
58
46
  cyan: "\u001b[36m",
59
- bold: "\u001b[1m",
60
47
  };
48
+ const info = (m) => process.stdout.write(`${c.cyan}info${c.reset} ${m}\n`);
49
+ const warn = (m) => process.stdout.write(`${c.yellow}warn${c.reset} ${m}\n`);
50
+ const err = (m) => process.stdout.write(`${c.red}error${c.reset} ${m}\n`);
51
+ const ok = (m) => process.stdout.write(`${c.green}ok${c.reset} ${m}\n`);
61
52
 
62
- function info(msg) {
63
- process.stdout.write(`${c.cyan}info${c.reset} ${msg}\n`);
64
- }
65
- function warn(msg) {
66
- process.stdout.write(`${c.yellow}warn${c.reset} ${msg}\n`);
67
- }
68
- function error(msg) {
69
- process.stdout.write(`${c.red}error${c.reset} ${msg}\n`);
70
- }
71
- function ok(msg) {
72
- process.stdout.write(`${c.green}ok${c.reset} ${msg}\n`);
73
- }
74
-
75
- // --- Platform / asset mapping ----------------------------------------------
76
-
77
- /**
78
- * Map (platform, arch, isMusl) → asset filename.
79
- * Returns null when the platform/arch combo is unsupported.
80
- */
81
53
  function pickAssetName(platform, arch, isMusl) {
54
+ const x = (a) => (a === "x64" ? "x64" : a === "arm64" ? "arm64" : null);
82
55
  if (platform === "linux") {
83
- if (arch === "x64") return isMusl ? "snscoder-linux-x64-musl.zip" : "snscoder-linux-x64.zip";
84
- if (arch === "arm64") return "snscoder-linux-arm64.zip";
85
- return null;
56
+ const a = x(arch);
57
+ if (!a) return null;
58
+ // Release ships glibc variant by default. If user is on musl/alpine,
59
+ // try musl fallback (may 404 on releases that don't have it).
60
+ return isMusl ? [`snscoder-linux-${a}-musl`, `snscoder-linux-${a}`] : [`snscoder-linux-${a}`];
86
61
  }
87
62
  if (platform === "darwin") {
88
- if (arch === "x64") return "snscoder-darwin-x64.zip";
89
- if (arch === "arm64") return "snscoder-darwin-arm64.zip";
90
- return null;
63
+ const a = x(arch);
64
+ if (!a) return null;
65
+ return [`snscoder-darwin-${a}`];
91
66
  }
92
67
  if (platform === "win32") {
93
- if (arch === "x64") return "snscoder-windows-x64.zip";
94
- return null;
68
+ return arch === "x64" ? [`snscoder-windows-x64.exe`] : null;
95
69
  }
96
70
  return null;
97
71
  }
98
72
 
99
- /**
100
- * Musl heuristic: run `ldd /proc/self/exe` and look for `libc.so.*` (glibc).
101
- * - musl systems (Alpine, Void with musl, etc.) print "Not a valid dynamic
102
- * program" or `ldd: can't find dynamic linker info` AND no `libc.so.6` line.
103
- * - glibc systems show `libc.so.6 => /lib64/libc.so.6` etc.
104
- *
105
- * Heuristic limitations:
106
- * - Static binaries report "not a dynamic executable" — we conservatively
107
- * treat that as glibc (more common on developer machines).
108
- * - BusyBox `ldd` is a shell wrapper; it sometimes prints to stdout but not
109
- * stderr. We merge both streams before scanning.
110
- */
111
73
  function detectMuslLinux() {
112
74
  if (process.platform !== "linux") return false;
113
75
  try {
114
- const out = execFileSync("ldd", ["/proc/self/exe"], {
115
- stdio: ["ignore", "pipe", "pipe"],
116
- encoding: "utf8",
117
- timeout: 3000,
118
- });
119
- // glibc leaves a libc.so.6 line; musl leaves musl libc.so line.
76
+ const out = execFileSync("ldd", ["/proc/self/exe"], { stdio: ["ignore", "pipe", "pipe"], encoding: "utf8", timeout: 3000 });
120
77
  if (/libc\.so\.6/.test(out)) return false;
121
78
  if (/musl/.test(out)) return true;
122
- // No glibc marker found and not explicitly musl → assume musl
123
- // (Alpine, Void-musl, etc.). Falls back wrong on static binaries
124
- // — see heuristic note above.
125
- return true;
79
+ return true; // alpine/void default to musl
126
80
  } catch {
127
- // ldd missing OR failed → can't tell. Default to glibc (most common).
128
- return false;
81
+ return false; // can't tell glibc default
129
82
  }
130
83
  }
131
84
 
132
- // --- HTTP helpers -----------------------------------------------------------
133
-
134
- /**
135
- * https.get wrapper that returns a Buffer and follows up to 3 redirects
136
- * (GitHub release redirects to S3, which redirects to Cloudfront).
137
- */
138
- function fetchBuffer(url, redirectsLeft = 3) {
85
+ function fetchBuffer(url, redirectsLeft = 5) {
139
86
  return new Promise((resolveP, rejectP) => {
140
- const headers = {
141
- "User-Agent": UA,
142
- Accept: "application/octet-stream",
143
- };
144
- if (process.env.GITHUB_TOKEN) {
145
- headers.Authorization = `Bearer ${process.env.GITHUB_TOKEN}`;
146
- }
147
-
87
+ const headers = { "User-Agent": UA, Accept: "application/octet-stream" };
88
+ if (process.env.GITHUB_TOKEN) headers.Authorization = `Bearer ${process.env.GITHUB_TOKEN}`;
148
89
  const req = https.get(url, { headers }, (res) => {
149
90
  const status = res.statusCode ?? 0;
150
- // Follow redirects (GitHub release downloads 302 to S3 → Cloudfront)
151
91
  if (status >= 300 && status < 400 && res.headers.location) {
152
92
  res.resume();
153
- if (redirectsLeft <= 0) {
154
- rejectP(new Error(`too many redirects for ${url}`));
155
- return;
156
- }
157
- resolveP(fetchBuffer(res.headers.location, redirectsLeft - 1));
158
- return;
93
+ if (redirectsLeft <= 0) return rejectP(new Error("too many redirects"));
94
+ return resolveP(fetchBuffer(res.headers.location, redirectsLeft - 1));
159
95
  }
160
96
  if (status === 404) {
161
97
  res.resume();
162
- rejectP(Object.assign(new Error("not found"), { httpStatus: 404 }));
163
- return;
98
+ return rejectP(Object.assign(new Error("not found"), { httpStatus: 404 }));
164
99
  }
165
100
  if (status !== 200) {
166
101
  res.resume();
167
- rejectP(new Error(`HTTP ${status} for ${url}`));
168
- return;
102
+ return rejectP(new Error(`HTTP ${status} for ${url}`));
169
103
  }
170
-
171
104
  const chunks = [];
172
105
  res.on("data", (c2) => chunks.push(c2));
173
106
  res.on("end", () => resolveP(Buffer.concat(chunks)));
174
107
  res.on("error", rejectP);
175
108
  });
176
-
177
109
  req.on("error", rejectP);
178
- req.setTimeout(30_000, () => {
179
- req.destroy(new Error(`timeout after 30s: ${url}`));
180
- });
110
+ req.setTimeout(60_000, () => req.destroy(new Error(`timeout 60s: ${url}`)));
181
111
  });
182
112
  }
183
113
 
@@ -188,172 +118,131 @@ function fetchJson(url) {
188
118
  Accept: "application/vnd.github+json",
189
119
  "X-GitHub-Api-Version": "2022-11-28",
190
120
  };
191
- if (process.env.GITHUB_TOKEN) {
192
- headers.Authorization = `Bearer ${process.env.GITHUB_TOKEN}`;
193
- }
194
-
121
+ if (process.env.GITHUB_TOKEN) headers.Authorization = `Bearer ${process.env.GITHUB_TOKEN}`;
195
122
  const req = https.get(url, { headers }, (res) => {
196
123
  const status = res.statusCode ?? 0;
197
124
  const chunks = [];
198
125
  res.on("data", (c2) => chunks.push(c2));
199
126
  res.on("end", () => {
200
127
  const body = Buffer.concat(chunks).toString("utf8");
201
- if (status === 404) {
202
- rejectP(Object.assign(new Error("release not found"), { httpStatus: 404 }));
203
- return;
204
- }
205
- if (status !== 200) {
206
- rejectP(new Error(`GitHub API HTTP ${status}: ${body.slice(0, 200)}`));
207
- return;
208
- }
128
+ if (status === 404) return rejectP(Object.assign(new Error("release not found"), { httpStatus: 404 }));
129
+ if (status === 403) return rejectP(Object.assign(new Error("rate limited"), { httpStatus: 403 }));
130
+ if (status !== 200) return rejectP(new Error(`GitHub API HTTP ${status}: ${body.slice(0, 200)}`));
209
131
  try {
210
132
  resolveP(JSON.parse(body));
211
133
  } catch (e) {
212
- rejectP(new Error(`invalid JSON from GitHub: ${e.message}`));
134
+ rejectP(new Error(`invalid JSON: ${e.message}`));
213
135
  }
214
136
  });
215
- res.on("error", rejectP);
216
137
  });
217
-
218
138
  req.on("error", rejectP);
219
- req.setTimeout(30_000, () => {
220
- req.destroy(new Error(`timeout after 30s: ${url}`));
221
- });
139
+ req.setTimeout(30_000, () => req.destroy(new Error(`timeout 30s: ${url}`)));
222
140
  });
223
141
  }
224
142
 
225
- // --- Zip unpack (delegates to system tools) --------------------------------
226
-
227
- /**
228
- * Unzip an archive into destDir. On unix uses `unzip -o`; on Windows uses
229
- * PowerShell's `Expand-Archive`. Both are preinstalled on the respective
230
- * platforms (unzip is on virtually every macOS/Linux dev box; Expand-Archive
231
- * ships with PowerShell 5+).
232
- *
233
- * Throws if neither tool is available — caller decides whether that's fatal.
234
- */
235
- function unzip(archivePath, destDir) {
236
- if (isWin) {
237
- // PowerShell Expand-Archive needs a non-existent OR empty dest dir.
238
- execFileSync(
239
- "powershell.exe",
240
- [
241
- "-NoProfile",
242
- "-NonInteractive",
243
- "-Command",
244
- `Expand-Archive -Path '${archivePath.replace(/'/g, "''")}' -DestinationPath '${destDir.replace(/'/g, "''")}' -Force`,
245
- ],
246
- { stdio: ["ignore", "inherit", "inherit"] },
247
- );
248
- } else {
249
- execFileSync("unzip", ["-o", archivePath, "-d", destDir], {
250
- stdio: ["ignore", "inherit", "inherit"],
251
- });
252
- }
253
- }
254
-
255
- // --- Main flow --------------------------------------------------------------
256
-
257
143
  async function main() {
258
144
  const platform = process.platform;
259
145
  const arch = process.arch;
260
146
  const isMusl = detectMuslLinux();
261
- const assetName = pickAssetName(platform, arch, isMusl);
147
+ const candidates = pickAssetName(platform, arch, isMusl);
262
148
 
263
- if (!assetName) {
264
- warn(
265
- `unsupported platform/arch combo: ${platform}/${arch}. ` +
266
- `Build from source instead: https://github.com/${REPO}#from-source`,
267
- );
149
+ if (!candidates) {
150
+ warn(`unsupported platform/arch: ${platform}/${arch}. Build from source: https://github.com/${REPO}#from-source`);
268
151
  return;
269
152
  }
270
153
 
271
- info(`target: ${platform}/${arch}${isMusl ? " (musl)" : ""} → ${assetName}`);
154
+ info(`target: ${platform}/${arch}${isMusl ? " (musl)" : ""}`);
272
155
 
273
156
  let release;
274
157
  try {
275
158
  release = await fetchJson(RELEASE_API);
276
159
  } catch (e) {
277
160
  if (e.httpStatus === 404) {
278
- warn(
279
- `no release found at github.com/${REPO}/releases ` +
280
- `maintainer must publish v0.1.0 first. Falling back to Bun install.`,
281
- );
282
- return;
161
+ warn(`no release at github.com/${REPO}/releases. Falling back to Bun install.`);
162
+ } else if (e.httpStatus === 403) {
163
+ warn(`GitHub API rate-limited. Set GITHUB_TOKEN env var to retry. Falling back to Bun install.`);
164
+ } else {
165
+ warn(`could not reach GitHub API (${e.message}). Skipping binary fetch.`);
283
166
  }
284
- warn(
285
- `could not reach GitHub API (${e.message}). ` +
286
- `Skipping binary fetch. Run 'npm rebuild' after going online.`,
287
- );
288
167
  return;
289
168
  }
290
169
 
291
- const asset = (release.assets ?? []).find((a) => a.name === assetName);
292
- if (!asset || !asset.browser_download_url) {
170
+ const available = release.assets ?? [];
171
+ const asset =
172
+ candidates.map((n) => available.find((a) => a.name === n)).find(Boolean) ?? null;
173
+
174
+ if (!asset?.browser_download_url) {
293
175
  warn(
294
- `release ${release.tag_name ?? "(unknown)"} has no asset '${assetName}'. ` +
295
- `Available: ${(release.assets ?? []).map((a) => a.name).join(", ") || "(none)"}. ` +
296
- `Falling back to Bun install.`,
176
+ `release ${release.tag_name ?? "(unknown)"} has no matching asset. ` +
177
+ `Wanted one of: ${candidates.join(", ")}. Available: ${available.map((a) => a.name).join(", ") || "(none)"}.`,
297
178
  );
298
179
  return;
299
180
  }
300
181
 
301
- // Download into a temp dir so a half-finished install never clobbers bin/.
302
182
  const tmp = await mkdtemp(join(tmpdir(), "snscoder-"));
303
- const archivePath = join(tmp, assetName);
304
- info(`downloading ${asset.browser_download_url}`);
183
+ const tmpFile = join(tmp, asset.name);
184
+ info(`downloading ${asset.name} (${(asset.size / 1024 / 1024).toFixed(2)} MB)`);
305
185
 
306
- let zipBuf;
186
+ let buf;
307
187
  try {
308
- zipBuf = await fetchBuffer(asset.browser_download_url);
188
+ buf = await fetchBuffer(asset.browser_download_url);
309
189
  } catch (e) {
310
- warn(`download failed (${e.message}). Skipping binary fetch.`);
190
+ warn(`download failed: ${e.message}`);
311
191
  await rm(tmp, { recursive: true, force: true });
312
192
  return;
313
193
  }
314
194
 
315
195
  await new Promise((resolveP, rejectP) => {
316
- const ws = createWriteStream(archivePath);
196
+ const ws = createWriteStream(tmpFile);
317
197
  ws.on("error", rejectP);
318
198
  ws.on("finish", resolveP);
319
- ws.end(zipBuf);
199
+ ws.end(buf);
320
200
  });
321
201
 
322
- info(`extracting → ${BIN_DIR}`);
323
202
  mkdirSync(BIN_DIR, { recursive: true });
324
- try {
325
- unzip(archivePath, BIN_DIR);
326
- } catch (e) {
327
- warn(`unzip failed (${e.message}). Binaries like 'unzip' or PowerShell Expand-Archive are required.`);
328
- await rm(tmp, { recursive: true, force: true });
329
- return;
203
+ const target = join(BIN_DIR, asset.name);
204
+ if (existsSync(target)) {
205
+ try {
206
+ unlinkSync(target);
207
+ } catch {}
330
208
  }
331
209
 
332
- const binaryName = isWin ? "snscoder.exe" : "snscoder";
333
- const binaryPath = join(BIN_DIR, binaryName);
210
+ // Move from tmp into bin/
211
+ const { copyFile } = await import("node:fs/promises");
212
+ await copyFile(tmpFile, target);
213
+ await rm(tmp, { recursive: true, force: true });
334
214
 
335
- if (!existsSync(binaryPath)) {
336
- // Sometimes archives use a different internal name — try to discover.
337
- const entries = await readdir(BIN_DIR);
338
- warn(
339
- `expected '${binaryName}' in archive but found: ${entries.join(", ") || "(empty)"}. ` +
340
- `Adjust scripts/fetch-binary.mjs to match the release asset layout.`,
341
- );
342
- await rm(tmp, { recursive: true, force: true });
343
- return;
215
+ if (!isWin) {
216
+ await chmod(target, 0o755);
344
217
  }
345
218
 
346
- if (!isWin) {
347
- await chmod(binaryPath, 0o755);
219
+ // Symlink: bin/snscoder → bin/<asset-name> (so `snscoder` works on PATH)
220
+ // npm install -g creates bin/<name> symlinks for the entries in package.json "bin"
221
+ // (which is "snscoder" → "bin/snscoder.js"). We don't have that shim; instead
222
+ // we create a wrapper file bin/snscoder.js that exec's the platform binary.
223
+ const shim = isWin ? "snscoder.cmd" : "snscoder.js";
224
+ const shimPath = join(BIN_DIR, shim);
225
+
226
+ if (isWin) {
227
+ const { writeFileSync } = await import("node:fs");
228
+ writeFileSync(
229
+ shimPath,
230
+ `@echo off\r\n"%~dp0${asset.name}" %*\r\n`,
231
+ );
232
+ } else {
233
+ const { writeFileSync } = await import("node:fs");
234
+ writeFileSync(
235
+ shimPath,
236
+ `#!/usr/bin/env node\n// Auto-generated by fetch-binary.mjs — execs the platform binary.\nimport { spawn } from "node:child_process";\nimport { fileURLToPath } from "node:url";\nimport { dirname, join } from "node:path";\nconst here = dirname(fileURLToPath(import.meta.url));\nconst bin = join(here, "${asset.name}");\nconst r = spawn(bin, process.argv.slice(2), { stdio: "inherit" });\nr.on("exit", (c) => process.exit(c ?? 0));\n`,
237
+ );
238
+ await chmod(shimPath, 0o755);
348
239
  }
349
- const st = await stat(binaryPath);
350
- ok(`${binaryName} ready (${(st.size / 1024 / 1024).toFixed(2)} MB)`);
351
240
 
352
- await rm(tmp, { recursive: true, force: true });
241
+ const st = await stat(target);
242
+ ok(`${asset.name} ready (${(st.size / 1024 / 1024).toFixed(2)} MB) → bin/${shim}`);
353
243
  }
354
244
 
355
245
  main().catch((e) => {
356
- // Catch-all: never break npm install. Print + exit 0.
357
- error(`unexpected: ${e?.stack ?? e?.message ?? e}`);
358
- process.exit(0);
246
+ err(`unexpected: ${e?.stack ?? e?.message ?? e}`);
247
+ process.exit(0); // never break npm install
359
248
  });
@@ -0,0 +1,92 @@
1
+ #!/usr/bin/env bash
2
+ # SNS-MyAgent E2E smoke test
3
+ # Runs against a freshly built binary to verify all critical paths work.
4
+ # Usage: ./scripts/smoke-test.sh [path-to-binary]
5
+ # Exit 0 on PASS, 1 on any FAIL.
6
+ #
7
+ # NOTE: Binary may exit 1 in JS-only fallback mode (pi_natives missing on CI
8
+ # runner). We check stdout content rather than strict exit code for those.
9
+
10
+ set -uo pipefail
11
+
12
+ BIN="${1:-./bin/snscoder-linux-x64}"
13
+ FAIL=0
14
+ PASS=0
15
+
16
+ # run_check <label> <expect-exit> <cmd...>
17
+ # - exit code MUST match expect-exit for PASS
18
+ run_check() {
19
+ local label="$1"
20
+ local expect_code="$2"
21
+ shift 2
22
+ local out
23
+ out=$("$BIN" "$@" 2>&1)
24
+ local actual=$?
25
+ if [[ $actual -eq $expect_code ]]; then
26
+ echo "✓ $label"
27
+ PASS=$((PASS + 1))
28
+ else
29
+ echo "✗ $label (expected exit $expect_code, got $actual)"
30
+ echo " output: ${out:0:200}"
31
+ FAIL=$((FAIL + 1))
32
+ fi
33
+ }
34
+
35
+ # run_loose <label> <expect-substring-in-stdout> <cmd...>
36
+ # - tolerates exit 0 OR 1 (JS-only fallback)
37
+ # - just checks stdout/stderr contains the substring
38
+ run_loose() {
39
+ local label="$1"
40
+ local needle="$2"
41
+ shift 2
42
+ local out
43
+ out=$("$BIN" "$@" 2>&1)
44
+ if [[ "$out" == *"$needle"* ]]; then
45
+ echo "✓ $label"
46
+ PASS=$((PASS + 1))
47
+ else
48
+ echo "✗ $label (missing '$needle' in output)"
49
+ echo " output: ${out:0:200}"
50
+ FAIL=$((FAIL + 1))
51
+ fi
52
+ }
53
+
54
+ echo "=== SNS-MyAgent E2E smoke test ==="
55
+ echo "Binary: $BIN"
56
+ echo ""
57
+
58
+ if [[ ! -f "$BIN" ]]; then
59
+ echo "✗ binary not found: $BIN"
60
+ exit 1
61
+ fi
62
+
63
+ # Version: pi_natives may be missing on CI runner → JS-only fallback may exit 1
64
+ # but version text still prints. Check substring rather than exit code.
65
+ run_loose "version flag prints version" "snscoder " --version
66
+ run_loose "version subcommand prints version" "snscoder " version
67
+
68
+ # Help: help uses only built-in code, should exit 0 reliably
69
+ run_check "help flag" 0 --help
70
+ run_check "help subcommand" 0 help
71
+
72
+ # Init (skips if config exists)
73
+ run_check "init (idempotent)" 0 init
74
+
75
+ # Config subcommands — may also be affected by js-only
76
+ run_loose "config show" "model" config show
77
+
78
+ # Orchestrate (stub returns exit 2 with clear message)
79
+ run_loose "orchestrate shows clear stub message" "agent executor not wired" orchestrate "test prompt"
80
+
81
+ # Unknown command — exit 1 guaranteed
82
+ run_check "unknown command (exit 1)" 1 nonexistent-command
83
+
84
+ echo ""
85
+ echo "=== Summary ==="
86
+ echo " PASS: $PASS"
87
+ echo " FAIL: $FAIL"
88
+
89
+ if [[ $FAIL -gt 0 ]]; then
90
+ exit 1
91
+ fi
92
+ exit 0