agent-notify 0.2.5 → 0.3.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.
@@ -1,56 +1,96 @@
1
1
  #!/usr/bin/env node
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');
2
12
 
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 getBinaryDir() {
10
- // Try to get npm global bin directory
11
- // npm bin -g is deprecated, use npm prefix -g instead
12
- try {
13
- const prefix = execSync('npm prefix -g', { encoding: 'utf8' }).trim();
14
- return path.join(prefix, 'bin');
15
- } catch (e) {
16
- // Fallback to ~/.local/bin
17
- return path.join(os.homedir(), '.local', 'bin');
18
- }
13
+ function getDesiredVersion() {
14
+ return require('../package.json').version;
19
15
  }
20
16
 
21
- const BINARY_DIR = getBinaryDir();
17
+ function getInstalledVersion(binaryPath) {
18
+ const result = spawnSync(binaryPath, ['--version'], {
19
+ encoding: 'utf8',
20
+ stdio: ['ignore', 'pipe', 'pipe'],
21
+ });
22
+
23
+ if (result.error || result.status !== 0) {
24
+ return null;
25
+ }
22
26
 
23
- function getBinaryName() {
24
- return os.platform() === 'win32' ? 'agent-notify.exe' : 'agent-notify';
27
+ return extractSemver(result.stdout);
25
28
  }
26
29
 
27
- function getBinaryPath() {
28
- return path.join(BINARY_DIR, getBinaryName());
30
+ async function downloadAndInstall(version, target, binaryPath) {
31
+ fs.mkdirSync(TMP_DIR, { recursive: true });
32
+
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}`;
36
+
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
+ });
29
44
  }
30
45
 
31
- function run() {
32
- const binaryPath = getBinaryPath();
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));
33
56
 
34
- if (!fs.existsSync(binaryPath)) {
35
- console.error(`Binary not found at ${binaryPath}`);
36
- console.error('Please run: npm install or npx @hellolib/agent-notify');
37
- process.exit(1);
57
+ let installedVersion = null;
58
+ const hasInstalledBinary = pathExists(binaryPath);
59
+ if (hasInstalledBinary) {
60
+ installedVersion = getVersion(binaryPath);
38
61
  }
62
+ const canFallbackToInstalledBinary = Boolean(installedVersion);
39
63
 
40
- const args = process.argv.slice(2);
41
- const child = spawn(binaryPath, args, {
42
- stdio: 'inherit',
43
- env: process.env
44
- });
64
+ const needsInstall = !hasInstalledBinary || !installedVersion || compare(installedVersion, desiredVersion) < 0;
45
65
 
46
- child.on('exit', (code) => {
47
- process.exit(code || 0);
48
- });
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}`);
74
+ }
75
+ }
49
76
 
50
- child.on('error', (err) => {
51
- console.error(`Failed to run binary: ${err.message}`);
52
- process.exit(1);
53
- });
77
+ return run(binaryPath, argv);
78
+ }
79
+
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
+ });
54
89
  }
55
90
 
56
- 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,57 @@
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, {
13
+ timeout: 300000, // 300 seconds
14
+ }, (response) => {
15
+ if (response.statusCode >= 300 && response.statusCode < 400 && response.headers.location) {
16
+ const redirectUrl = new URL(response.headers.location, url).toString();
17
+ file.close(() => {
18
+ removeFileIfExists(destinationPath, () => {
19
+ downloadToFile(redirectUrl, destinationPath, client).then(resolve, reject);
20
+ });
21
+ });
22
+ return;
23
+ }
24
+
25
+ if (response.statusCode !== 200) {
26
+ file.close(() => {
27
+ removeFileIfExists(destinationPath, () => {
28
+ reject(new Error(`download failed: ${response.statusCode} ${url}`));
29
+ });
30
+ });
31
+ return;
32
+ }
33
+
34
+ response.pipe(file);
35
+ file.on('finish', () => file.close(() => resolve(destinationPath)));
36
+ });
37
+
38
+ request.on('error', (err) => {
39
+ file.close(() => {
40
+ removeFileIfExists(destinationPath, () => reject(err));
41
+ });
42
+ });
43
+
44
+ request.on('timeout', () => {
45
+ request.destroy();
46
+ file.close(() => {
47
+ removeFileIfExists(destinationPath, () => {
48
+ reject(new Error(`download timeout after 300s: ${url}`));
49
+ });
50
+ });
51
+ });
52
+ });
53
+ }
54
+
55
+ module.exports = {
56
+ downloadToFile,
57
+ };
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,35 +1,23 @@
1
1
  {
2
2
  "name": "agent-notify",
3
- "version": "0.2.5",
4
- "agentnotifyversion": "0.2.4",
5
- "description": "Agent notification tool with Feishu integration",
3
+ "version": "0.3.0",
4
+ "description": "NPX launcher for the agent-notify binary",
6
5
  "bin": {
7
6
  "agent-notify": "./bin/agent-notify.js"
8
7
  },
9
- "scripts": {
10
- "postinstall": "node scripts/install.js"
11
- },
8
+ "type": "commonjs",
12
9
  "files": [
13
- "bin/",
14
- "scripts/"
10
+ "bin",
11
+ "lib",
12
+ "test"
15
13
  ],
16
- "keywords": [
17
- "claude",
18
- "notification",
19
- "feishu",
20
- "agent"
21
- ],
22
- "author": "hellolib",
23
- "license": "MIT",
24
- "repository": {
25
- "type": "git",
26
- "url": "git+https://github.com/hellolib/agent-notify.git"
14
+ "scripts": {
15
+ "test": "node --test ./test/*.test.js"
27
16
  },
28
- "bugs": {
29
- "url": "https://github.com/hellolib/agent-notify/issues"
17
+ "dependencies": {
18
+ "tar": "^7.4.3"
30
19
  },
31
- "homepage": "https://github.com/hellolib/agent-notify#readme",
32
20
  "engines": {
33
- "node": ">=14"
21
+ "node": ">=18"
34
22
  }
35
23
  }
@@ -0,0 +1,152 @@
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, options, 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, _options, 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
+ });
131
+
132
+ test('rejects with timeout error when download takes too long', async (t) => {
133
+ const root = fs.mkdtempSync(path.join(os.tmpdir(), 'agent-notify-download-'));
134
+ t.after(() => fs.rmSync(root, { recursive: true, force: true }));
135
+ const destinationPath = path.join(root, 'archive.tar.gz');
136
+
137
+ const client = {
138
+ get(_url, _options, handler) {
139
+ const request = new PassThrough();
140
+ process.nextTick(() => {
141
+ request.emit('timeout');
142
+ });
143
+ return request;
144
+ },
145
+ };
146
+
147
+ await assert.rejects(
148
+ downloadToFile('https://example.com/releases/archive.tar.gz', destinationPath, client),
149
+ /download timeout after 300s/,
150
+ );
151
+ assert.equal(fs.existsSync(destinationPath), false);
152
+ });
@@ -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,291 +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').agentnotifyversion;
11
- const GITHUB_REPO = 'hellolib/agent-notify';
12
-
13
- // Possible locations where agent-notify might be installed
14
- function getCommonBinaryDirs() {
15
- const dirs = [];
16
- const binaryName = os.platform() === 'win32' ? 'agent-notify.exe' : 'agent-notify';
17
-
18
- // GOPATH/bin
19
- if (process.env.GOPATH) {
20
- dirs.push(path.join(process.env.GOPATH, 'bin'));
21
- }
22
- // GOBIN
23
- if (process.env.GOBIN) {
24
- dirs.push(process.env.GOBIN);
25
- }
26
- // Default Go bin directory
27
- dirs.push(path.join(os.homedir(), 'go', 'bin'));
28
- // ~/.local/bin
29
- dirs.push(path.join(os.homedir(), '.local', 'bin'));
30
- // /usr/local/bin
31
- dirs.push('/usr/local/bin');
32
-
33
- return dirs;
34
- }
35
-
36
- function findExistingBinary(binaryName) {
37
- const dirs = getCommonBinaryDirs();
38
- for (const dir of dirs) {
39
- const binaryPath = path.join(dir, binaryName);
40
- if (fs.existsSync(binaryPath)) {
41
- return binaryPath;
42
- }
43
- }
44
- return null;
45
- }
46
-
47
- function getBinaryDir() {
48
- // Try to get npm global bin directory
49
- // npm bin -g is deprecated, use npm prefix -g instead
50
- try {
51
- const prefix = execSync('npm prefix -g', { encoding: 'utf8' }).trim();
52
- // On Windows, npm prefix -g returns the bin directory directly
53
- // On Unix, it returns the parent and bin is in {prefix}/bin
54
- if (os.platform() === 'win32') {
55
- return prefix;
56
- }
57
- return path.join(prefix, 'bin');
58
- } catch (e) {
59
- // Fallback to ~/.local/bin
60
- return path.join(os.homedir(), '.local', 'bin');
61
- }
62
- }
63
-
64
- const BINARY_DIR = getBinaryDir();
65
-
66
- function getInstalledVersion(binaryPath) {
67
- try {
68
- const version = execSync(`"${binaryPath}" --version`, { encoding: 'utf8', timeout: 5000 }).trim();
69
- // version output like: agent-notify version 0.1.0
70
- const match = version.match(/(\d+\.\d+\.\d+)/);
71
- return match ? match[1] : null;
72
- } catch (e) {
73
- return null;
74
- }
75
- }
76
-
77
- function getRemoteArchiveName(version) {
78
- const platform = os.platform();
79
- const arch = os.arch();
80
-
81
- const platformMap = {
82
- darwin: 'darwin',
83
- linux: 'linux',
84
- win32: 'windows'
85
- };
86
-
87
- const archMap = {
88
- x64: 'amd64',
89
- arm64: 'arm64'
90
- };
91
-
92
- const mappedPlatform = platformMap[platform];
93
- const mappedArch = archMap[arch];
94
-
95
- if (!mappedPlatform || !mappedArch) {
96
- throw new Error(`Unsupported platform: ${platform} ${arch}`);
97
- }
98
-
99
- return `agent-notify-v${version}-${mappedPlatform}-${mappedArch}.tar.gz`;
100
- }
101
-
102
- function getBinaryNameInArchive(version) {
103
- const platform = os.platform();
104
- const arch = os.arch();
105
-
106
- const platformMap = {
107
- darwin: 'darwin',
108
- linux: 'linux',
109
- win32: 'windows'
110
- };
111
-
112
- const archMap = {
113
- x64: 'amd64',
114
- arm64: 'arm64'
115
- };
116
-
117
- const mappedPlatform = platformMap[platform];
118
- const mappedArch = archMap[arch];
119
-
120
- if (platform === 'win32') {
121
- return `agent-notify-v${version}-${mappedPlatform}-${mappedArch}.exe`;
122
- }
123
- return `agent-notify-v${version}-${mappedPlatform}-${mappedArch}`;
124
- }
125
-
126
- function getLocalBinaryName() {
127
- return os.platform() === 'win32' ? 'agent-notify.exe' : 'agent-notify';
128
- }
129
-
130
- function getDownloadUrl(archiveName, version) {
131
- return `https://github.com/${GITHUB_REPO}/releases/download/v${version}/${archiveName}`;
132
- }
133
-
134
- function extractTarGz(archivePath, destDir) {
135
- // Use tar command (available on macOS, Linux, and Windows 10+)
136
- execSync(`tar -xzf "${archivePath}" -C "${destDir}"`, { stdio: 'inherit' });
137
- }
138
-
139
- async function install() {
140
- const archiveName = getRemoteArchiveName(PACKAGE_VERSION);
141
- const binaryNameInArchive = getBinaryNameInArchive(PACKAGE_VERSION);
142
- const localBinaryName = getLocalBinaryName();
143
- const binaryPath = path.join(BINARY_DIR, localBinaryName);
144
-
145
- // Check if binary already exists in npm bin directory with correct version
146
- if (fs.existsSync(binaryPath)) {
147
- const installedVersion = getInstalledVersion(binaryPath);
148
- if (installedVersion === PACKAGE_VERSION) {
149
- console.log(`Binary already exists at ${binaryPath} (v${PACKAGE_VERSION})`);
150
- return;
151
- }
152
- console.log(`Updating binary from v${installedVersion || 'unknown'} to v${PACKAGE_VERSION}...`);
153
- } else {
154
- // Check if binary exists in other common directories
155
- const existingBinary = findExistingBinary(localBinaryName);
156
- if (existingBinary) {
157
- const existingVersion = getInstalledVersion(existingBinary);
158
- if (existingVersion === PACKAGE_VERSION) {
159
- // Same version, copy/link to npm bin directory
160
- console.log(`Found existing binary at ${existingBinary} (v${PACKAGE_VERSION})`);
161
- console.log(`Copying to npm bin directory: ${binaryPath}`);
162
-
163
- if (!fs.existsSync(BINARY_DIR)) {
164
- fs.mkdirSync(BINARY_DIR, { recursive: true });
165
- }
166
-
167
- fs.copyFileSync(existingBinary, binaryPath);
168
- if (os.platform() !== 'win32') {
169
- fs.chmodSync(binaryPath, 0o755);
170
- }
171
- console.log('Done!');
172
- return;
173
- } else if (existingVersion) {
174
- console.log(`Found existing binary at ${existingBinary} (v${existingVersion}), but need v${PACKAGE_VERSION}`);
175
- console.log('Installing new version to npm bin directory...');
176
- } else {
177
- console.log(`Found existing binary at ${existingBinary}, but version unknown`);
178
- console.log('Installing to npm bin directory...');
179
- }
180
- }
181
- }
182
-
183
- // Create binary directory
184
- if (!fs.existsSync(BINARY_DIR)) {
185
- fs.mkdirSync(BINARY_DIR, { recursive: true });
186
- }
187
-
188
- const url = getDownloadUrl(archiveName, PACKAGE_VERSION);
189
- const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'agent-notify-'));
190
- const archivePath = path.join(tempDir, archiveName);
191
-
192
- try {
193
- // Download archive
194
- await downloadFile(url, archivePath);
195
-
196
- // Extract archive
197
- console.log('Extracting...');
198
- extractTarGz(archivePath, tempDir);
199
-
200
- // Move binary to destination (use copy + unlink for cross-device support)
201
- const extractedBinaryPath = path.join(tempDir, binaryNameInArchive);
202
- if (fs.existsSync(binaryPath)) {
203
- fs.unlinkSync(binaryPath);
204
- }
205
- fs.copyFileSync(extractedBinaryPath, binaryPath);
206
- fs.unlinkSync(extractedBinaryPath);
207
-
208
- // Make binary executable (Unix)
209
- if (os.platform() !== 'win32') {
210
- fs.chmodSync(binaryPath, 0o755);
211
- }
212
-
213
- console.log(`Successfully installed binary to ${binaryPath}`);
214
- console.log(`Ensure ${BINARY_DIR} is in your PATH.`);
215
- } catch (err) {
216
- console.error(`Failed to download binary: ${err.message}`);
217
- console.error('');
218
- console.error('Please ensure the release exists at:');
219
- console.error(` ${url}`);
220
-
221
- // Don't fail the install, let user download manually
222
- process.exit(0);
223
- } finally {
224
- // Cleanup temp directory
225
- try {
226
- fs.rmSync(tempDir, { recursive: true, force: true });
227
- } catch (e) {
228
- // Ignore cleanup errors
229
- }
230
- }
231
- }
232
-
233
- function downloadFile(url, dest) {
234
- return new Promise((resolve, reject) => {
235
- const protocol = url.startsWith('https') ? https : http;
236
-
237
- console.log(`Downloading: ${url}`);
238
-
239
- const request = protocol.get(url, (response) => {
240
- if (response.statusCode === 302 || response.statusCode === 301) {
241
- downloadFile(response.headers.location, dest).then(resolve).catch(reject);
242
- return;
243
- }
244
-
245
- if (response.statusCode !== 200) {
246
- reject(new Error(`Failed to download: HTTP ${response.statusCode}`));
247
- return;
248
- }
249
-
250
- const totalSize = parseInt(response.headers['content-length'], 10);
251
- let downloadedSize = 0;
252
- let lastPercent = 0;
253
-
254
- const file = fs.createWriteStream(dest);
255
-
256
- response.on('data', (chunk) => {
257
- downloadedSize += chunk.length;
258
- if (totalSize) {
259
- const percent = Math.floor((downloadedSize / totalSize) * 100);
260
- if (percent >= lastPercent + 10) {
261
- process.stdout.write(`\rDownloading: ${percent}%`);
262
- lastPercent = percent;
263
- }
264
- }
265
- });
266
-
267
- response.pipe(file);
268
-
269
- file.on('finish', () => {
270
- file.close();
271
- process.stdout.write('\n');
272
- resolve();
273
- });
274
- });
275
-
276
- request.on('error', (err) => {
277
- fs.unlink(dest, () => {});
278
- reject(err);
279
- });
280
-
281
- request.setTimeout(120000, () => {
282
- request.destroy();
283
- reject(new Error('Download timeout'));
284
- });
285
- });
286
- }
287
-
288
- install().catch((err) => {
289
- console.error(err.message);
290
- process.exit(1);
291
- });