agent-notify 0.2.4 → 0.2.6

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.
@@ -1,92 +1,96 @@
1
1
  #!/usr/bin/env node
2
-
3
- const { spawn } = require('child_process');
4
- const { execSync } = require('child_process');
5
- const path = require('path');
6
- const os = require('os');
7
- const fs = require('fs');
8
-
9
- function getBinaryName() {
10
- return os.platform() === 'win32' ? 'agent-notify.exe' : 'agent-notify';
2
+ const fs = require('node:fs');
3
+ const path = require('node:path');
4
+ const { spawnSync } = require('node:child_process');
5
+ const { getPlatformTarget } = require('../lib/platform');
6
+ const { extractSemver, compareVersions } = require('../lib/version');
7
+ const { getInstalledBinaryPath, TMP_DIR } = require('../lib/constants');
8
+ const { buildAssetName, buildDownloadUrl } = require('../lib/release');
9
+ const { downloadToFile } = require('../lib/download');
10
+ const { installFromArchive } = require('../lib/install');
11
+ const { runBinary } = require('../lib/run');
12
+
13
+ function getDesiredVersion() {
14
+ return require('../package.json').version;
11
15
  }
12
16
 
13
- function getBinaryPath() {
14
- const binaryName = getBinaryName();
15
- const searchPaths = [];
17
+ function getInstalledVersion(binaryPath) {
18
+ const result = spawnSync(binaryPath, ['--version'], {
19
+ encoding: 'utf8',
20
+ stdio: ['ignore', 'pipe', 'pipe'],
21
+ });
16
22
 
17
- // 1. npm global bin directory
18
- try {
19
- const prefix = execSync('npm prefix -g', { encoding: 'utf8' }).trim();
20
- if (os.platform() === 'win32') {
21
- searchPaths.push(prefix);
22
- } else {
23
- searchPaths.push(path.join(prefix, 'bin'));
24
- }
25
- } catch (e) {
26
- // npm not available
23
+ if (result.error || result.status !== 0) {
24
+ return null;
27
25
  }
28
26
 
29
- // 2. GOPATH/bin
30
- if (process.env.GOPATH) {
31
- searchPaths.push(path.join(process.env.GOPATH, 'bin'));
32
- }
27
+ return extractSemver(result.stdout);
28
+ }
33
29
 
34
- // 3. HOME/go/bin (default GOPATH)
35
- searchPaths.push(path.join(os.homedir(), 'go', 'bin'));
30
+ async function downloadAndInstall(version, target, binaryPath) {
31
+ fs.mkdirSync(TMP_DIR, { recursive: true });
36
32
 
37
- // 4. ~/.local/bin
38
- searchPaths.push(path.join(os.homedir(), '.local', 'bin'));
33
+ const assetName = buildAssetName(version, target);
34
+ const archivePath = path.join(TMP_DIR, assetName);
35
+ const binaryNameInArchive = `agent-notify-v${version}-${target.goos}-${target.goarch}${target.ext}`;
39
36
 
40
- // 5. Search in PATH
41
- try {
42
- const whichCmd = os.platform() === 'win32' ? 'where' : 'which';
43
- const result = execSync(`${whichCmd} ${binaryName}`, { encoding: 'utf8' }).trim();
44
- if (result) {
45
- return result.split('\n')[0].trim();
46
- }
47
- } catch (e) {
48
- // Not found in PATH
49
- }
37
+ await downloadToFile(buildDownloadUrl(version, assetName), archivePath);
38
+ return installFromArchive({
39
+ archivePath,
40
+ installDir: path.dirname(binaryPath),
41
+ binaryNameInArchive,
42
+ finalBinaryName: path.basename(binaryPath),
43
+ });
44
+ }
50
45
 
51
- // Return first existing path or first search path as default
52
- for (const searchPath of searchPaths) {
53
- const binaryPath = path.join(searchPath, binaryName);
54
- if (fs.existsSync(binaryPath)) {
55
- return binaryPath;
46
+ async function main(argv, deps = {}) {
47
+ const desiredVersion = (deps.getDesiredVersion || getDesiredVersion)();
48
+ const target = (deps.getPlatformTarget || getPlatformTarget)();
49
+ const binaryPath = (deps.getInstalledBinaryPath || getInstalledBinaryPath)(target);
50
+ const pathExists = deps.pathExists || fs.existsSync;
51
+ const getVersion = deps.getInstalledVersion || getInstalledVersion;
52
+ const compare = deps.compareVersions || compareVersions;
53
+ const install = deps.downloadAndInstall || ((version, releaseTarget) => downloadAndInstall(version, releaseTarget, binaryPath));
54
+ const run = deps.runBinary || runBinary;
55
+ const warn = deps.warn || ((message) => console.warn(message));
56
+
57
+ let installedVersion = null;
58
+ const hasInstalledBinary = pathExists(binaryPath);
59
+ if (hasInstalledBinary) {
60
+ installedVersion = getVersion(binaryPath);
61
+ }
62
+ const canFallbackToInstalledBinary = Boolean(installedVersion);
63
+
64
+ const needsInstall = !hasInstalledBinary || !installedVersion || compare(installedVersion, desiredVersion) < 0;
65
+
66
+ if (needsInstall) {
67
+ try {
68
+ await install(desiredVersion, target, binaryPath);
69
+ } catch (error) {
70
+ if (!canFallbackToInstalledBinary) {
71
+ throw error;
72
+ }
73
+ warn(`failed to update agent-notify: ${error.message}`);
56
74
  }
57
75
  }
58
76
 
59
- // Fallback to npm global bin
60
- return path.join(searchPaths[0] || path.join(os.homedir(), '.local', 'bin'), binaryName);
77
+ return run(binaryPath, argv);
61
78
  }
62
79
 
63
- function run() {
64
- const binaryPath = getBinaryPath();
65
-
66
- if (!fs.existsSync(binaryPath)) {
67
- console.error(`Binary not found at ${binaryPath}`);
68
- console.error('');
69
- console.error('Please install agent-notify first:');
70
- console.error(' npx agent-notify # auto-download');
71
- console.error(' or');
72
- console.error(' go install github.com/hellolib/agent-notify/cmd/agent-notify@latest');
73
- process.exit(1);
74
- }
75
-
76
- const args = process.argv.slice(2);
77
- const child = spawn(binaryPath, args, {
78
- stdio: 'inherit',
79
- env: process.env
80
- });
81
-
82
- child.on('exit', (code) => {
83
- process.exit(code || 0);
84
- });
85
-
86
- child.on('error', (err) => {
87
- console.error(`Failed to run binary: ${err.message}`);
88
- process.exit(1);
89
- });
80
+ if (require.main === module) {
81
+ main(process.argv.slice(2))
82
+ .then((code) => {
83
+ process.exitCode = code;
84
+ })
85
+ .catch((error) => {
86
+ console.error(error.message);
87
+ process.exitCode = 1;
88
+ });
90
89
  }
91
90
 
92
- run();
91
+ module.exports = {
92
+ main,
93
+ downloadAndInstall,
94
+ getDesiredVersion,
95
+ getInstalledVersion,
96
+ };
@@ -0,0 +1,19 @@
1
+ const path = require('node:path');
2
+ const os = require('node:os');
3
+
4
+ const REPO_OWNER = 'hellolib';
5
+ const REPO_NAME = 'agent-notify';
6
+ const INSTALL_DIR = path.join(os.homedir(), '.agent-notify');
7
+ const TMP_DIR = path.join(INSTALL_DIR, 'tmp');
8
+
9
+ function getInstalledBinaryPath(target) {
10
+ return path.join(INSTALL_DIR, `agent-notify${target.ext}`);
11
+ }
12
+
13
+ module.exports = {
14
+ REPO_OWNER,
15
+ REPO_NAME,
16
+ INSTALL_DIR,
17
+ TMP_DIR,
18
+ getInstalledBinaryPath,
19
+ };
@@ -0,0 +1,46 @@
1
+ const fs = require('node:fs');
2
+ const https = require('node:https');
3
+
4
+ function removeFileIfExists(filePath, callback) {
5
+ fs.rm(filePath, { force: true }, () => callback());
6
+ }
7
+
8
+ function downloadToFile(url, destinationPath, client = https) {
9
+ return new Promise((resolve, reject) => {
10
+ const file = fs.createWriteStream(destinationPath);
11
+
12
+ const request = client.get(url, (response) => {
13
+ if (response.statusCode >= 300 && response.statusCode < 400 && response.headers.location) {
14
+ const redirectUrl = new URL(response.headers.location, url).toString();
15
+ file.close(() => {
16
+ removeFileIfExists(destinationPath, () => {
17
+ downloadToFile(redirectUrl, destinationPath, client).then(resolve, reject);
18
+ });
19
+ });
20
+ return;
21
+ }
22
+
23
+ if (response.statusCode !== 200) {
24
+ file.close(() => {
25
+ removeFileIfExists(destinationPath, () => {
26
+ reject(new Error(`download failed: ${response.statusCode} ${url}`));
27
+ });
28
+ });
29
+ return;
30
+ }
31
+
32
+ response.pipe(file);
33
+ file.on('finish', () => file.close(() => resolve(destinationPath)));
34
+ });
35
+
36
+ request.on('error', (err) => {
37
+ file.close(() => {
38
+ removeFileIfExists(destinationPath, () => reject(err));
39
+ });
40
+ });
41
+ });
42
+ }
43
+
44
+ module.exports = {
45
+ downloadToFile,
46
+ };
package/lib/install.js ADDED
@@ -0,0 +1,61 @@
1
+ const fs = require('node:fs');
2
+ const path = require('node:path');
3
+ const os = require('node:os');
4
+ const tar = require('tar');
5
+
6
+ function isUnsafeArchivePath(entryPath) {
7
+ return path.isAbsolute(entryPath) || entryPath.split('/').includes('..');
8
+ }
9
+
10
+ async function installFromArchive({ archivePath, installDir, binaryNameInArchive, finalBinaryName }) {
11
+ fs.mkdirSync(installDir, { recursive: true });
12
+
13
+ const extractDir = fs.mkdtempSync(path.join(os.tmpdir(), 'agent-notify-extract-'));
14
+ const finalPath = path.join(installDir, finalBinaryName);
15
+ const tempFinalPath = `${finalPath}.tmp`;
16
+
17
+ try {
18
+ const entries = [];
19
+ await tar.t({
20
+ file: archivePath,
21
+ gzip: true,
22
+ onReadEntry: (entry) => entries.push({ path: entry.path, type: entry.type }),
23
+ });
24
+
25
+ const binaryEntry = entries.find((entry) => entry.path === binaryNameInArchive);
26
+ if (!binaryEntry) {
27
+ throw new Error(`binary not found in archive: ${binaryNameInArchive}`);
28
+ }
29
+
30
+ if (binaryEntry.type !== 'File' || entries.some((entry) => isUnsafeArchivePath(entry.path))) {
31
+ throw new Error('unsafe archive contents');
32
+ }
33
+
34
+ await tar.x({
35
+ file: archivePath,
36
+ cwd: extractDir,
37
+ gzip: true,
38
+ filter: (entryPath) => entryPath === binaryNameInArchive,
39
+ });
40
+
41
+ const extractedPath = path.join(extractDir, binaryNameInArchive);
42
+ if (!fs.existsSync(extractedPath)) {
43
+ throw new Error(`binary not found in archive: ${binaryNameInArchive}`);
44
+ }
45
+
46
+ fs.copyFileSync(extractedPath, tempFinalPath);
47
+ if (process.platform !== 'win32') {
48
+ fs.chmodSync(tempFinalPath, 0o755);
49
+ }
50
+ fs.renameSync(tempFinalPath, finalPath);
51
+
52
+ return finalPath;
53
+ } finally {
54
+ fs.rmSync(tempFinalPath, { force: true });
55
+ fs.rmSync(extractDir, { recursive: true, force: true });
56
+ }
57
+ }
58
+
59
+ module.exports = {
60
+ installFromArchive,
61
+ };
@@ -0,0 +1,29 @@
1
+ function getPlatformTarget({ platform = process.platform, arch = process.arch } = {}) {
2
+ const goosMap = {
3
+ darwin: 'darwin',
4
+ linux: 'linux',
5
+ win32: 'windows',
6
+ };
7
+
8
+ const goarchMap = {
9
+ x64: 'amd64',
10
+ arm64: 'arm64',
11
+ };
12
+
13
+ const goos = goosMap[platform];
14
+ const goarch = goarchMap[arch];
15
+
16
+ if (!goos || !goarch) {
17
+ throw new Error(`unsupported platform: ${platform}/${arch}`);
18
+ }
19
+
20
+ return {
21
+ goos,
22
+ goarch,
23
+ ext: goos === 'windows' ? '.exe' : '',
24
+ };
25
+ }
26
+
27
+ module.exports = {
28
+ getPlatformTarget,
29
+ };
package/lib/release.js ADDED
@@ -0,0 +1,21 @@
1
+ const { REPO_OWNER, REPO_NAME } = require('./constants');
2
+
3
+ function buildTag(version) {
4
+ return version.startsWith('v') ? version : `v${version}`;
5
+ }
6
+
7
+ function buildAssetName(version, target) {
8
+ const tag = buildTag(version);
9
+ return `agent-notify-${tag}-${target.goos}-${target.goarch}.tar.gz`;
10
+ }
11
+
12
+ function buildDownloadUrl(version, assetName) {
13
+ const tag = buildTag(version);
14
+ return `https://github.com/${REPO_OWNER}/${REPO_NAME}/releases/download/${tag}/${assetName}`;
15
+ }
16
+
17
+ module.exports = {
18
+ buildTag,
19
+ buildAssetName,
20
+ buildDownloadUrl,
21
+ };
package/lib/run.js ADDED
@@ -0,0 +1,17 @@
1
+ const { spawn } = require('node:child_process');
2
+
3
+ function runBinary(binaryPath, args) {
4
+ return new Promise((resolve, reject) => {
5
+ const child = spawn(binaryPath, args, {
6
+ stdio: 'inherit',
7
+ env: { ...process.env },
8
+ });
9
+
10
+ child.on('error', reject);
11
+ child.on('close', (code) => resolve(code ?? 1));
12
+ });
13
+ }
14
+
15
+ module.exports = {
16
+ runBinary,
17
+ };
package/lib/version.js ADDED
@@ -0,0 +1,21 @@
1
+ function extractSemver(output) {
2
+ const match = String(output).match(/v?(\d+\.\d+\.\d+)/);
3
+ return match ? match[1] : null;
4
+ }
5
+
6
+ function compareVersions(left, right) {
7
+ const l = left.split('.').map(Number);
8
+ const r = right.split('.').map(Number);
9
+
10
+ for (let i = 0; i < 3; i += 1) {
11
+ if (l[i] < r[i]) return -1;
12
+ if (l[i] > r[i]) return 1;
13
+ }
14
+
15
+ return 0;
16
+ }
17
+
18
+ module.exports = {
19
+ extractSemver,
20
+ compareVersions,
21
+ };
package/package.json CHANGED
@@ -1,34 +1,23 @@
1
1
  {
2
2
  "name": "agent-notify",
3
- "version": "0.2.4",
4
- "description": "Agent notification tool with Feishu integration",
3
+ "version": "0.2.6",
4
+ "description": "NPX launcher for the agent-notify binary",
5
5
  "bin": {
6
6
  "agent-notify": "./bin/agent-notify.js"
7
7
  },
8
- "scripts": {
9
- "postinstall": "node scripts/install.js"
10
- },
8
+ "type": "commonjs",
11
9
  "files": [
12
- "bin/",
13
- "scripts/"
10
+ "bin",
11
+ "lib",
12
+ "test"
14
13
  ],
15
- "keywords": [
16
- "claude",
17
- "notification",
18
- "feishu",
19
- "agent"
20
- ],
21
- "author": "hellolib",
22
- "license": "MIT",
23
- "repository": {
24
- "type": "git",
25
- "url": "git+https://github.com/hellolib/agent-notify.git"
14
+ "scripts": {
15
+ "test": "node --test ./test/*.test.js"
26
16
  },
27
- "bugs": {
28
- "url": "https://github.com/hellolib/agent-notify/issues"
17
+ "dependencies": {
18
+ "tar": "^7.4.3"
29
19
  },
30
- "homepage": "https://github.com/hellolib/agent-notify#readme",
31
20
  "engines": {
32
- "node": ">=14"
21
+ "node": ">=18"
33
22
  }
34
23
  }
@@ -0,0 +1,130 @@
1
+ const test = require('node:test');
2
+ const assert = require('node:assert/strict');
3
+ const fs = require('node:fs');
4
+ const os = require('node:os');
5
+ const path = require('node:path');
6
+ const { PassThrough } = require('node:stream');
7
+ const tar = require('tar');
8
+ const { installFromArchive } = require('../lib/install');
9
+ const { downloadToFile } = require('../lib/download');
10
+
11
+ test('replaces installed binary with extracted binary', async (t) => {
12
+ const root = fs.mkdtempSync(path.join(os.tmpdir(), 'agent-notify-install-'));
13
+ t.after(() => fs.rmSync(root, { recursive: true, force: true }));
14
+ const installDir = path.join(root, '.agent-notify');
15
+ const archivePath = path.join(root, 'agent-notify-v0.2.3-linux-amd64.tar.gz');
16
+ const extractedBinaryName = 'agent-notify-v0.2.3-linux-amd64';
17
+
18
+ fs.mkdirSync(path.join(root, 'src'));
19
+ fs.writeFileSync(path.join(root, 'src', extractedBinaryName), 'new-binary');
20
+
21
+ await tar.c(
22
+ {
23
+ gzip: true,
24
+ file: archivePath,
25
+ cwd: path.join(root, 'src'),
26
+ },
27
+ [extractedBinaryName],
28
+ );
29
+
30
+ fs.mkdirSync(installDir, { recursive: true });
31
+ fs.writeFileSync(path.join(installDir, 'agent-notify'), 'old-binary');
32
+
33
+ const installedPath = await installFromArchive({
34
+ archivePath,
35
+ installDir,
36
+ binaryNameInArchive: extractedBinaryName,
37
+ finalBinaryName: 'agent-notify',
38
+ });
39
+
40
+ assert.equal(installedPath, path.join(installDir, 'agent-notify'));
41
+ assert.equal(fs.readFileSync(installedPath, 'utf8'), 'new-binary');
42
+ });
43
+
44
+ test('throws when archive does not contain expected binary', async (t) => {
45
+ const root = fs.mkdtempSync(path.join(os.tmpdir(), 'agent-notify-install-'));
46
+ t.after(() => fs.rmSync(root, { recursive: true, force: true }));
47
+ const installDir = path.join(root, '.agent-notify');
48
+ const archivePath = path.join(root, 'empty.tar.gz');
49
+
50
+ fs.mkdirSync(path.join(root, 'src'));
51
+ fs.writeFileSync(path.join(root, 'src', 'other-file'), 'nope');
52
+
53
+ await tar.c(
54
+ {
55
+ gzip: true,
56
+ file: archivePath,
57
+ cwd: path.join(root, 'src'),
58
+ },
59
+ ['other-file'],
60
+ );
61
+
62
+ await assert.rejects(
63
+ installFromArchive({
64
+ archivePath,
65
+ installDir,
66
+ binaryNameInArchive: 'agent-notify-v0.2.3-linux-amd64',
67
+ finalBinaryName: 'agent-notify',
68
+ }),
69
+ /binary not found in archive/,
70
+ );
71
+ });
72
+
73
+ test('follows relative redirects when downloading', async (t) => {
74
+ const root = fs.mkdtempSync(path.join(os.tmpdir(), 'agent-notify-download-'));
75
+ t.after(() => fs.rmSync(root, { recursive: true, force: true }));
76
+ const destinationPath = path.join(root, 'archive.tar.gz');
77
+
78
+ const client = {
79
+ get(url, handler) {
80
+ const request = new PassThrough();
81
+ process.nextTick(() => {
82
+ if (url === 'https://example.com/releases/archive.tar.gz') {
83
+ const response = new PassThrough();
84
+ response.statusCode = 302;
85
+ response.headers = { location: '/download/archive.tar.gz' };
86
+ handler(response);
87
+ response.end();
88
+ return;
89
+ }
90
+
91
+ const response = new PassThrough();
92
+ response.statusCode = 200;
93
+ response.headers = {};
94
+ handler(response);
95
+ response.end('payload');
96
+ });
97
+ return request;
98
+ },
99
+ };
100
+
101
+ const filePath = await downloadToFile('https://example.com/releases/archive.tar.gz', destinationPath, client);
102
+ assert.equal(filePath, destinationPath);
103
+ assert.equal(fs.readFileSync(destinationPath, 'utf8'), 'payload');
104
+ });
105
+
106
+ test('removes destination file when download fails with non-200 response', async (t) => {
107
+ const root = fs.mkdtempSync(path.join(os.tmpdir(), 'agent-notify-download-'));
108
+ t.after(() => fs.rmSync(root, { recursive: true, force: true }));
109
+ const destinationPath = path.join(root, 'archive.tar.gz');
110
+
111
+ const client = {
112
+ get(_url, handler) {
113
+ const request = new PassThrough();
114
+ process.nextTick(() => {
115
+ const response = new PassThrough();
116
+ response.statusCode = 404;
117
+ response.headers = {};
118
+ handler(response);
119
+ response.end();
120
+ });
121
+ return request;
122
+ },
123
+ };
124
+
125
+ await assert.rejects(
126
+ downloadToFile('https://example.com/releases/archive.tar.gz', destinationPath, client),
127
+ /download failed: 404/,
128
+ );
129
+ assert.equal(fs.existsSync(destinationPath), false);
130
+ });
@@ -0,0 +1,151 @@
1
+ const test = require('node:test');
2
+ const assert = require('node:assert/strict');
3
+ const fs = require('node:fs');
4
+ const os = require('node:os');
5
+ const path = require('node:path');
6
+ const { main } = require('../bin/agent-notify');
7
+
8
+ test('downloads when installed binary is missing', async (t) => {
9
+ const calls = [];
10
+ const root = fs.mkdtempSync(path.join(os.tmpdir(), 'agent-notify-launcher-'));
11
+ t.after(() => fs.rmSync(root, { recursive: true, force: true }));
12
+ const binaryPath = path.join(root, 'agent-notify');
13
+
14
+ const exitCode = await main(['doctor'], {
15
+ getDesiredVersion: () => '0.2.3',
16
+ getPlatformTarget: () => ({ goos: 'linux', goarch: 'amd64', ext: '' }),
17
+ getInstalledBinaryPath: () => binaryPath,
18
+ getInstalledVersion: () => null,
19
+ downloadAndInstall: async () => {
20
+ calls.push('download');
21
+ fs.writeFileSync(binaryPath, 'binary');
22
+ return binaryPath;
23
+ },
24
+ runBinary: async (targetPath, args) => {
25
+ calls.push(['run', targetPath, args]);
26
+ return 0;
27
+ },
28
+ pathExists: (value) => fs.existsSync(value),
29
+ warn: () => {},
30
+ });
31
+
32
+ assert.equal(exitCode, 0);
33
+ assert.deepEqual(calls, ['download', ['run', binaryPath, ['doctor']]]);
34
+ });
35
+
36
+ test('reuses installed binary when version is current', async () => {
37
+ const calls = [];
38
+
39
+ const exitCode = await main(['doctor'], {
40
+ getDesiredVersion: () => '0.2.3',
41
+ getPlatformTarget: () => ({ goos: 'linux', goarch: 'amd64', ext: '' }),
42
+ getInstalledBinaryPath: () => '/tmp/agent-notify',
43
+ getInstalledVersion: () => '0.2.3',
44
+ downloadAndInstall: async () => {
45
+ throw new Error('should not download');
46
+ },
47
+ runBinary: async (targetPath, args) => {
48
+ calls.push(['run', targetPath, args]);
49
+ return 0;
50
+ },
51
+ pathExists: () => true,
52
+ compareVersions: (left, right) => (left === right ? 0 : -1),
53
+ warn: () => {},
54
+ });
55
+
56
+ assert.equal(exitCode, 0);
57
+ assert.deepEqual(calls, [['run', '/tmp/agent-notify', ['doctor']]]);
58
+ });
59
+
60
+ test('reuses installed binary when local version is newer', async () => {
61
+ const calls = [];
62
+
63
+ const exitCode = await main(['doctor'], {
64
+ getDesiredVersion: () => '0.2.3',
65
+ getPlatformTarget: () => ({ goos: 'linux', goarch: 'amd64', ext: '' }),
66
+ getInstalledBinaryPath: () => '/tmp/agent-notify',
67
+ getInstalledVersion: () => '0.2.4',
68
+ downloadAndInstall: async () => {
69
+ throw new Error('should not download');
70
+ },
71
+ runBinary: async (targetPath, args) => {
72
+ calls.push(['run', targetPath, args]);
73
+ return 0;
74
+ },
75
+ pathExists: () => true,
76
+ compareVersions: () => 1,
77
+ warn: () => {},
78
+ });
79
+
80
+ assert.equal(exitCode, 0);
81
+ assert.deepEqual(calls, [['run', '/tmp/agent-notify', ['doctor']]]);
82
+ });
83
+
84
+ test('falls back to old binary when download fails but installed binary exists', async () => {
85
+ const warnings = [];
86
+ const calls = [];
87
+
88
+ const exitCode = await main(['doctor'], {
89
+ getDesiredVersion: () => '0.2.3',
90
+ getPlatformTarget: () => ({ goos: 'linux', goarch: 'amd64', ext: '' }),
91
+ getInstalledBinaryPath: () => '/tmp/agent-notify',
92
+ getInstalledVersion: () => '0.2.2',
93
+ compareVersions: () => -1,
94
+ downloadAndInstall: async () => {
95
+ throw new Error('network down');
96
+ },
97
+ runBinary: async (targetPath, args) => {
98
+ calls.push(['run', targetPath, args]);
99
+ return 0;
100
+ },
101
+ pathExists: () => true,
102
+ warn: (message) => warnings.push(message),
103
+ });
104
+
105
+ assert.equal(exitCode, 0);
106
+ assert.equal(warnings.length, 1);
107
+ assert.match(warnings[0], /network down/);
108
+ assert.deepEqual(calls, [['run', '/tmp/agent-notify', ['doctor']]]);
109
+ });
110
+
111
+ test('fails when download fails and no installed binary exists', async () => {
112
+ await assert.rejects(
113
+ main(['doctor'], {
114
+ getDesiredVersion: () => '0.2.3',
115
+ getPlatformTarget: () => ({ goos: 'linux', goarch: 'amd64', ext: '' }),
116
+ getInstalledBinaryPath: () => '/tmp/agent-notify',
117
+ getInstalledVersion: () => null,
118
+ compareVersions: () => -1,
119
+ downloadAndInstall: async () => {
120
+ throw new Error('network down');
121
+ },
122
+ runBinary: async () => 0,
123
+ pathExists: () => false,
124
+ warn: () => {},
125
+ }),
126
+ /network down/,
127
+ );
128
+ });
129
+
130
+ test('fails when installed binary is unreadable and update also fails', async () => {
131
+ const warnings = [];
132
+
133
+ await assert.rejects(
134
+ main(['doctor'], {
135
+ getDesiredVersion: () => '0.2.3',
136
+ getPlatformTarget: () => ({ goos: 'linux', goarch: 'amd64', ext: '' }),
137
+ getInstalledBinaryPath: () => '/tmp/agent-notify',
138
+ getInstalledVersion: () => null,
139
+ compareVersions: () => -1,
140
+ downloadAndInstall: async () => {
141
+ throw new Error('network down');
142
+ },
143
+ runBinary: async () => 0,
144
+ pathExists: () => true,
145
+ warn: (message) => warnings.push(message),
146
+ }),
147
+ /network down/,
148
+ );
149
+
150
+ assert.deepEqual(warnings, []);
151
+ });
@@ -0,0 +1,48 @@
1
+ const test = require('node:test');
2
+ const assert = require('node:assert/strict');
3
+ const { getPlatformTarget } = require('../lib/platform');
4
+ const { buildAssetName, buildDownloadUrl } = require('../lib/release');
5
+
6
+ test('maps darwin arm64 to release target', () => {
7
+ assert.deepEqual(getPlatformTarget({ platform: 'darwin', arch: 'arm64' }), {
8
+ goos: 'darwin',
9
+ goarch: 'arm64',
10
+ ext: '',
11
+ });
12
+ });
13
+
14
+ test('maps win32 x64 to release target', () => {
15
+ assert.deepEqual(getPlatformTarget({ platform: 'win32', arch: 'x64' }), {
16
+ goos: 'windows',
17
+ goarch: 'amd64',
18
+ ext: '.exe',
19
+ });
20
+ });
21
+
22
+ test('maps win32 arm64 to release target', () => {
23
+ assert.deepEqual(getPlatformTarget({ platform: 'win32', arch: 'arm64' }), {
24
+ goos: 'windows',
25
+ goarch: 'arm64',
26
+ ext: '.exe',
27
+ });
28
+ });
29
+
30
+ test('throws on unsupported platform', () => {
31
+ assert.throws(() => getPlatformTarget({ platform: 'freebsd', arch: 'x64' }), /unsupported platform/);
32
+ });
33
+
34
+ test('throws on unsupported architecture', () => {
35
+ assert.throws(() => getPlatformTarget({ platform: 'linux', arch: 'ia32' }), /unsupported platform/);
36
+ });
37
+
38
+ test('builds release asset names from version and target', () => {
39
+ const target = { goos: 'linux', goarch: 'arm64', ext: '' };
40
+ assert.equal(buildAssetName('0.2.3', target), 'agent-notify-v0.2.3-linux-arm64.tar.gz');
41
+ });
42
+
43
+ test('builds download URL from version and asset name', () => {
44
+ assert.equal(
45
+ buildDownloadUrl('0.2.3', 'agent-notify-v0.2.3-linux-arm64.tar.gz'),
46
+ 'https://github.com/hellolib/agent-notify/releases/download/v0.2.3/agent-notify-v0.2.3-linux-arm64.tar.gz',
47
+ );
48
+ });
@@ -0,0 +1,21 @@
1
+ const test = require('node:test');
2
+ const assert = require('node:assert/strict');
3
+ const { extractSemver, compareVersions } = require('../lib/version');
4
+
5
+ test('extracts plain semver', () => {
6
+ assert.equal(extractSemver('v0.2.3\n'), '0.2.3');
7
+ });
8
+
9
+ test('extracts semver from prefixed output', () => {
10
+ assert.equal(extractSemver('agent-notify version v1.4.0\n'), '1.4.0');
11
+ });
12
+
13
+ test('returns null when output has no semver', () => {
14
+ assert.equal(extractSemver('development build\n'), null);
15
+ });
16
+
17
+ test('compares versions correctly', () => {
18
+ assert.equal(compareVersions('0.2.3', '0.2.3'), 0);
19
+ assert.equal(compareVersions('0.2.2', '0.2.3'), -1);
20
+ assert.equal(compareVersions('0.3.0', '0.2.9'), 1);
21
+ });
@@ -1,229 +0,0 @@
1
- #!/usr/bin/env node
2
-
3
- const https = require('https');
4
- const http = require('http');
5
- const fs = require('fs');
6
- const path = require('path');
7
- const os = require('os');
8
- const { execSync } = require('child_process');
9
-
10
- const PACKAGE_VERSION = require('../package.json').version;
11
- const GITHUB_REPO = 'hellolib/agent-notify';
12
-
13
- function getBinaryDir() {
14
- // Try to get npm global bin directory
15
- // npm bin -g is deprecated, use npm prefix -g instead
16
- try {
17
- const prefix = execSync('npm prefix -g', { encoding: 'utf8' }).trim();
18
- // On Windows, npm prefix -g returns the bin directory directly
19
- // On Unix, it returns the parent and bin is in {prefix}/bin
20
- if (os.platform() === 'win32') {
21
- return prefix;
22
- }
23
- return path.join(prefix, 'bin');
24
- } catch (e) {
25
- // Fallback to ~/.local/bin
26
- return path.join(os.homedir(), '.local', 'bin');
27
- }
28
- }
29
-
30
- const BINARY_DIR = getBinaryDir();
31
-
32
- function getInstalledVersion(binaryPath) {
33
- try {
34
- const version = execSync(`"${binaryPath}" --version`, { encoding: 'utf8', timeout: 5000 }).trim();
35
- // version output like: agent-notify version 0.1.0
36
- const match = version.match(/(\d+\.\d+\.\d+)/);
37
- return match ? match[1] : null;
38
- } catch (e) {
39
- return null;
40
- }
41
- }
42
-
43
- function getRemoteArchiveName(version) {
44
- const platform = os.platform();
45
- const arch = os.arch();
46
-
47
- const platformMap = {
48
- darwin: 'darwin',
49
- linux: 'linux',
50
- win32: 'windows'
51
- };
52
-
53
- const archMap = {
54
- x64: 'amd64',
55
- arm64: 'arm64'
56
- };
57
-
58
- const mappedPlatform = platformMap[platform];
59
- const mappedArch = archMap[arch];
60
-
61
- if (!mappedPlatform || !mappedArch) {
62
- throw new Error(`Unsupported platform: ${platform} ${arch}`);
63
- }
64
-
65
- return `agent-notify-v${version}-${mappedPlatform}-${mappedArch}.tar.gz`;
66
- }
67
-
68
- function getBinaryNameInArchive(version) {
69
- const platform = os.platform();
70
- const arch = os.arch();
71
-
72
- const platformMap = {
73
- darwin: 'darwin',
74
- linux: 'linux',
75
- win32: 'windows'
76
- };
77
-
78
- const archMap = {
79
- x64: 'amd64',
80
- arm64: 'arm64'
81
- };
82
-
83
- const mappedPlatform = platformMap[platform];
84
- const mappedArch = archMap[arch];
85
-
86
- if (platform === 'win32') {
87
- return `agent-notify-v${version}-${mappedPlatform}-${mappedArch}.exe`;
88
- }
89
- return `agent-notify-v${version}-${mappedPlatform}-${mappedArch}`;
90
- }
91
-
92
- function getLocalBinaryName() {
93
- return os.platform() === 'win32' ? 'agent-notify.exe' : 'agent-notify';
94
- }
95
-
96
- function getDownloadUrl(archiveName, version) {
97
- return `https://github.com/${GITHUB_REPO}/releases/download/v${version}/${archiveName}`;
98
- }
99
-
100
- function extractTarGz(archivePath, destDir) {
101
- // Use tar command (available on macOS, Linux, and Windows 10+)
102
- execSync(`tar -xzf "${archivePath}" -C "${destDir}"`, { stdio: 'inherit' });
103
- }
104
-
105
- async function install() {
106
- const archiveName = getRemoteArchiveName(PACKAGE_VERSION);
107
- const binaryNameInArchive = getBinaryNameInArchive(PACKAGE_VERSION);
108
- const localBinaryName = getLocalBinaryName();
109
- const binaryPath = path.join(BINARY_DIR, localBinaryName);
110
-
111
- // Check if binary already exists with correct version
112
- if (fs.existsSync(binaryPath)) {
113
- const installedVersion = getInstalledVersion(binaryPath);
114
- if (installedVersion === PACKAGE_VERSION) {
115
- console.log(`Binary already exists at ${binaryPath} (v${PACKAGE_VERSION})`);
116
- return;
117
- }
118
- console.log(`Updating binary from v${installedVersion || 'unknown'} to v${PACKAGE_VERSION}...`);
119
- }
120
-
121
- // Create binary directory
122
- if (!fs.existsSync(BINARY_DIR)) {
123
- fs.mkdirSync(BINARY_DIR, { recursive: true });
124
- }
125
-
126
- const url = getDownloadUrl(archiveName, PACKAGE_VERSION);
127
- const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'agent-notify-'));
128
- const archivePath = path.join(tempDir, archiveName);
129
-
130
- try {
131
- // Download archive
132
- await downloadFile(url, archivePath);
133
-
134
- // Extract archive
135
- console.log('Extracting...');
136
- extractTarGz(archivePath, tempDir);
137
-
138
- // Move binary to destination (use copy + unlink for cross-device support)
139
- const extractedBinaryPath = path.join(tempDir, binaryNameInArchive);
140
- if (fs.existsSync(binaryPath)) {
141
- fs.unlinkSync(binaryPath);
142
- }
143
- fs.copyFileSync(extractedBinaryPath, binaryPath);
144
- fs.unlinkSync(extractedBinaryPath);
145
-
146
- // Make binary executable (Unix)
147
- if (os.platform() !== 'win32') {
148
- fs.chmodSync(binaryPath, 0o755);
149
- }
150
-
151
- console.log(`Successfully installed binary to ${binaryPath}`);
152
- console.log(`Ensure ${BINARY_DIR} is in your PATH.`);
153
- } catch (err) {
154
- console.error(`Failed to download binary: ${err.message}`);
155
- console.error('');
156
- console.error('Please ensure the release exists at:');
157
- console.error(` ${url}`);
158
-
159
- // Don't fail the install, let user download manually
160
- process.exit(0);
161
- } finally {
162
- // Cleanup temp directory
163
- try {
164
- fs.rmSync(tempDir, { recursive: true, force: true });
165
- } catch (e) {
166
- // Ignore cleanup errors
167
- }
168
- }
169
- }
170
-
171
- function downloadFile(url, dest) {
172
- return new Promise((resolve, reject) => {
173
- const protocol = url.startsWith('https') ? https : http;
174
-
175
- console.log(`Downloading: ${url}`);
176
-
177
- const request = protocol.get(url, (response) => {
178
- if (response.statusCode === 302 || response.statusCode === 301) {
179
- downloadFile(response.headers.location, dest).then(resolve).catch(reject);
180
- return;
181
- }
182
-
183
- if (response.statusCode !== 200) {
184
- reject(new Error(`Failed to download: HTTP ${response.statusCode}`));
185
- return;
186
- }
187
-
188
- const totalSize = parseInt(response.headers['content-length'], 10);
189
- let downloadedSize = 0;
190
- let lastPercent = 0;
191
-
192
- const file = fs.createWriteStream(dest);
193
-
194
- response.on('data', (chunk) => {
195
- downloadedSize += chunk.length;
196
- if (totalSize) {
197
- const percent = Math.floor((downloadedSize / totalSize) * 100);
198
- if (percent >= lastPercent + 10) {
199
- process.stdout.write(`\rDownloading: ${percent}%`);
200
- lastPercent = percent;
201
- }
202
- }
203
- });
204
-
205
- response.pipe(file);
206
-
207
- file.on('finish', () => {
208
- file.close();
209
- process.stdout.write('\n');
210
- resolve();
211
- });
212
- });
213
-
214
- request.on('error', (err) => {
215
- fs.unlink(dest, () => {});
216
- reject(err);
217
- });
218
-
219
- request.setTimeout(120000, () => {
220
- request.destroy();
221
- reject(new Error('Download timeout'));
222
- });
223
- });
224
- }
225
-
226
- install().catch((err) => {
227
- console.error(err.message);
228
- process.exit(1);
229
- });