@akiojin/gwt 6.17.1 → 6.19.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@akiojin/gwt",
3
- "version": "6.17.1",
3
+ "version": "6.19.0",
4
4
  "description": "Interactive Git worktree manager with Coding Agent selection (Claude Code / Codex CLI / Gemini CLI)",
5
5
  "bin": {
6
6
  "gwt": "bin/gwt.js"
@@ -4,9 +4,16 @@
4
4
  * for the current platform from GitHub Releases.
5
5
  */
6
6
 
7
- import { createWriteStream, existsSync, mkdirSync, chmodSync, unlinkSync } from 'fs';
7
+ import {
8
+ createWriteStream,
9
+ existsSync,
10
+ mkdirSync,
11
+ chmodSync,
12
+ unlinkSync,
13
+ readFileSync,
14
+ } from 'fs';
8
15
  import { dirname, join } from 'path';
9
- import { fileURLToPath } from 'url';
16
+ import { fileURLToPath, pathToFileURL } from 'url';
10
17
  import { get } from 'https';
11
18
 
12
19
  const __filename = fileURLToPath(import.meta.url);
@@ -17,6 +24,15 @@ const BIN_DIR = join(__dirname, '..', 'bin');
17
24
  const BIN_NAME = process.platform === 'win32' ? 'gwt.exe' : 'gwt';
18
25
  const BIN_PATH = join(BIN_DIR, BIN_NAME);
19
26
 
27
+ const RETRY_CONFIG = {
28
+ maxAttempts: 5,
29
+ initialDelayMs: 500,
30
+ backoffFactor: 2,
31
+ maxDelayMs: 5000,
32
+ };
33
+
34
+ const MAX_REDIRECTS = 5;
35
+
20
36
  function getPlatformArtifact() {
21
37
  const platform = process.platform;
22
38
  const arch = process.arch;
@@ -41,71 +57,162 @@ function getPlatformArtifact() {
41
57
  return artifact;
42
58
  }
43
59
 
44
- async function getLatestReleaseUrl(artifact) {
45
- return new Promise((resolve, reject) => {
60
+ function getPackageVersion() {
61
+ const packagePath = join(__dirname, '..', 'package.json');
62
+ const pkg = JSON.parse(readFileSync(packagePath, 'utf8'));
63
+ if (!pkg.version) {
64
+ throw new Error('package.json does not contain a version field');
65
+ }
66
+ return pkg.version;
67
+ }
68
+
69
+ function buildReleaseDownloadUrl(version, artifact) {
70
+ return `https://github.com/${REPO}/releases/download/v${version}/${artifact}`;
71
+ }
72
+
73
+ function getRetryDelayMs(attempt, config = RETRY_CONFIG) {
74
+ const delay = config.initialDelayMs * Math.pow(config.backoffFactor, Math.max(0, attempt - 1));
75
+ return Math.min(delay, config.maxDelayMs);
76
+ }
77
+
78
+ function isRetryableStatus(statusCode) {
79
+ return statusCode === 403 || statusCode === 404 || statusCode >= 500;
80
+ }
81
+
82
+ function isRetryableError(error) {
83
+ if (error && typeof error.statusCode === 'number') {
84
+ return isRetryableStatus(error.statusCode);
85
+ }
86
+ return true;
87
+ }
88
+
89
+ function formatFailureGuidance(version, artifact) {
90
+ const versionedUrl = version && version !== 'unknown'
91
+ ? buildReleaseDownloadUrl(version, artifact)
92
+ : null;
93
+ return {
94
+ versionedUrl,
95
+ releasePageUrl: `https://github.com/${REPO}/releases`,
96
+ buildCommand: 'cargo build --release',
97
+ };
98
+ }
99
+
100
+ async function getReleaseAssetUrl(version, artifact, httpGet = get) {
101
+ return new Promise((resolve) => {
46
102
  const options = {
47
103
  hostname: 'api.github.com',
48
- path: `/repos/${REPO}/releases/latest`,
104
+ path: `/repos/${REPO}/releases/tags/v${version}`,
49
105
  headers: {
50
106
  'User-Agent': 'gwt-postinstall',
51
107
  'Accept': 'application/vnd.github.v3+json',
52
108
  },
53
109
  };
54
110
 
55
- get(options, (res) => {
111
+ const req = httpGet(options, (res) => {
56
112
  let data = '';
57
113
  res.on('data', (chunk) => data += chunk);
58
114
  res.on('end', () => {
115
+ if (res.statusCode !== 200) {
116
+ resolve(null);
117
+ return;
118
+ }
59
119
  try {
60
120
  const release = JSON.parse(data);
61
- const asset = release.assets?.find(a => a.name === artifact);
62
- if (asset) {
63
- resolve(asset.browser_download_url);
64
- } else {
65
- // Fallback to direct URL pattern
66
- resolve(`https://github.com/${REPO}/releases/latest/download/${artifact}`);
67
- }
68
- } catch (e) {
69
- // Fallback to direct URL pattern
70
- resolve(`https://github.com/${REPO}/releases/latest/download/${artifact}`);
121
+ const asset = release.assets?.find((entry) => entry.name === artifact);
122
+ resolve(asset?.browser_download_url ?? null);
123
+ } catch {
124
+ resolve(null);
71
125
  }
72
126
  });
73
- }).on('error', reject);
127
+ });
128
+
129
+ req.on('error', () => resolve(null));
74
130
  });
75
131
  }
76
132
 
77
- async function downloadFile(url, dest) {
133
+ async function getDownloadUrl(version, artifact) {
134
+ const assetUrl = await getReleaseAssetUrl(version, artifact);
135
+ return assetUrl ?? buildReleaseDownloadUrl(version, artifact);
136
+ }
137
+
138
+ async function downloadFile(url, dest, httpGet = get) {
78
139
  return new Promise((resolve, reject) => {
79
- const file = createWriteStream(dest);
140
+ const request = (currentUrl, redirectCount) => {
141
+ const req = httpGet(currentUrl, (res) => {
142
+ const statusCode = res.statusCode ?? 0;
80
143
 
81
- const request = (url) => {
82
- get(url, (res) => {
83
- // Handle redirects
84
- if (res.statusCode === 301 || res.statusCode === 302) {
85
- request(res.headers.location);
144
+ if ([301, 302, 303, 307, 308].includes(statusCode)) {
145
+ const location = res.headers.location;
146
+ res.resume();
147
+ if (!location) {
148
+ const error = new Error(`Redirect without location (HTTP ${statusCode})`);
149
+ error.statusCode = statusCode;
150
+ reject(error);
151
+ return;
152
+ }
153
+ if (redirectCount >= MAX_REDIRECTS) {
154
+ const error = new Error('Too many redirects');
155
+ error.statusCode = statusCode;
156
+ reject(error);
157
+ return;
158
+ }
159
+ const resolved = new URL(location, currentUrl).toString();
160
+ request(resolved, redirectCount + 1);
86
161
  return;
87
162
  }
88
163
 
89
- if (res.statusCode !== 200) {
90
- reject(new Error(`Failed to download: HTTP ${res.statusCode}`));
164
+ if (statusCode !== 200) {
165
+ res.resume();
166
+ const error = new Error(`Failed to download: HTTP ${statusCode}`);
167
+ error.statusCode = statusCode;
168
+ reject(error);
91
169
  return;
92
170
  }
93
171
 
172
+ const file = createWriteStream(dest);
94
173
  res.pipe(file);
95
- file.on('finish', () => {
96
- file.close();
97
- resolve();
98
- });
99
- }).on('error', (err) => {
174
+
175
+ const cleanup = (err) => {
176
+ file.close(() => {
177
+ if (existsSync(dest)) unlinkSync(dest);
178
+ reject(err);
179
+ });
180
+ };
181
+
182
+ file.on('finish', () => file.close(resolve));
183
+ file.on('error', cleanup);
184
+ res.on('error', cleanup);
185
+ });
186
+
187
+ req.on('error', (err) => {
100
188
  if (existsSync(dest)) unlinkSync(dest);
101
189
  reject(err);
102
190
  });
103
191
  };
104
192
 
105
- request(url);
193
+ request(url, 0);
106
194
  });
107
195
  }
108
196
 
197
+ async function downloadWithRetry(url, dest, config = RETRY_CONFIG) {
198
+ let attempt = 0;
199
+
200
+ while (attempt < config.maxAttempts) {
201
+ attempt += 1;
202
+ try {
203
+ await downloadFile(url, dest);
204
+ return;
205
+ } catch (error) {
206
+ if (!isRetryableError(error) || attempt >= config.maxAttempts) {
207
+ throw error;
208
+ }
209
+ const delayMs = getRetryDelayMs(attempt, config);
210
+ console.log(`Retrying download in ${delayMs}ms (attempt ${attempt}/${config.maxAttempts})...`);
211
+ await new Promise((resolve) => setTimeout(resolve, delayMs));
212
+ }
213
+ }
214
+ }
215
+
109
216
  async function main() {
110
217
  // Skip in CI environments where binary might be built differently
111
218
  if (process.env.CI || process.env.GITHUB_ACTIONS) {
@@ -120,17 +227,19 @@ async function main() {
120
227
  }
121
228
 
122
229
  const artifact = getPlatformArtifact();
230
+ let version = 'unknown';
123
231
  console.log(`Downloading gwt binary for ${process.platform}-${process.arch}...`);
124
232
 
125
233
  try {
126
- const url = await getLatestReleaseUrl(artifact);
234
+ version = getPackageVersion();
235
+ const url = await getDownloadUrl(version, artifact);
127
236
  console.log(`Downloading from: ${url}`);
128
237
 
129
238
  if (!existsSync(BIN_DIR)) {
130
239
  mkdirSync(BIN_DIR, { recursive: true });
131
240
  }
132
241
 
133
- await downloadFile(url, BIN_PATH);
242
+ await downloadWithRetry(url, BIN_PATH);
134
243
 
135
244
  // Make executable on Unix
136
245
  if (process.platform !== 'win32') {
@@ -139,14 +248,36 @@ async function main() {
139
248
 
140
249
  console.log('gwt binary installed successfully!');
141
250
  } catch (error) {
251
+ const guidance = formatFailureGuidance(version, artifact);
142
252
  console.error('Failed to download gwt binary:', error.message);
143
253
  console.error('');
144
254
  console.error('You can manually download the binary from:');
145
- console.error(`https://github.com/${REPO}/releases`);
255
+ if (guidance.versionedUrl) {
256
+ console.error(guidance.versionedUrl);
257
+ }
258
+ console.error(guidance.releasePageUrl);
146
259
  console.error('');
147
- console.error('Or build from source with: cargo build --release');
260
+ console.error(`Or build from source with: ${guidance.buildCommand}`);
148
261
  process.exit(1);
149
262
  }
150
263
  }
151
264
 
152
- main();
265
+ const isDirectRun = process.argv[1]
266
+ && pathToFileURL(process.argv[1]).href === import.meta.url;
267
+
268
+ if (isDirectRun) {
269
+ main();
270
+ }
271
+
272
+ export {
273
+ RETRY_CONFIG,
274
+ buildReleaseDownloadUrl,
275
+ formatFailureGuidance,
276
+ getRetryDelayMs,
277
+ getReleaseAssetUrl,
278
+ getDownloadUrl,
279
+ getPackageVersion,
280
+ getPlatformArtifact,
281
+ isRetryableError,
282
+ isRetryableStatus,
283
+ };
@@ -0,0 +1,57 @@
1
+ import { test } from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+
4
+ import {
5
+ RETRY_CONFIG,
6
+ buildReleaseDownloadUrl,
7
+ formatFailureGuidance,
8
+ getRetryDelayMs,
9
+ isRetryableError,
10
+ isRetryableStatus,
11
+ } from './postinstall.js';
12
+
13
+ test('buildReleaseDownloadUrl uses version tag', () => {
14
+ const url = buildReleaseDownloadUrl('6.17.0', 'gwt-linux-x86_64');
15
+ assert.equal(
16
+ url,
17
+ 'https://github.com/akiojin/gwt/releases/download/v6.17.0/gwt-linux-x86_64',
18
+ );
19
+ });
20
+
21
+ test('getRetryDelayMs applies exponential backoff with cap', () => {
22
+ assert.equal(getRetryDelayMs(1, RETRY_CONFIG), 500);
23
+ assert.equal(getRetryDelayMs(2, RETRY_CONFIG), 1000);
24
+ assert.equal(getRetryDelayMs(3, RETRY_CONFIG), 2000);
25
+ assert.equal(getRetryDelayMs(5, RETRY_CONFIG), 5000);
26
+ });
27
+
28
+ test('isRetryableStatus matches 403/404/5xx', () => {
29
+ assert.equal(isRetryableStatus(403), true);
30
+ assert.equal(isRetryableStatus(404), true);
31
+ assert.equal(isRetryableStatus(500), true);
32
+ assert.equal(isRetryableStatus(429), false);
33
+ assert.equal(isRetryableStatus(400), false);
34
+ });
35
+
36
+ test('isRetryableError uses statusCode or treats network errors as retryable', () => {
37
+ const notFound = new Error('not found');
38
+ notFound.statusCode = 404;
39
+ assert.equal(isRetryableError(notFound), true);
40
+
41
+ const badRequest = new Error('bad request');
42
+ badRequest.statusCode = 400;
43
+ assert.equal(isRetryableError(badRequest), false);
44
+
45
+ const networkError = new Error('socket hang up');
46
+ assert.equal(isRetryableError(networkError), true);
47
+ });
48
+
49
+ test('formatFailureGuidance includes versioned URL and release page', () => {
50
+ const guidance = formatFailureGuidance('1.2.3', 'gwt-linux-x86_64');
51
+ assert.equal(
52
+ guidance.versionedUrl,
53
+ 'https://github.com/akiojin/gwt/releases/download/v1.2.3/gwt-linux-x86_64',
54
+ );
55
+ assert.equal(guidance.releasePageUrl, 'https://github.com/akiojin/gwt/releases');
56
+ assert.equal(guidance.buildCommand, 'cargo build --release');
57
+ });