• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

box / boxcli / 19504729128

19 Nov 2025 02:27PM UTC coverage: 85.591% (+0.01%) from 85.581%
19504729128

Pull #603

github

web-flow
Merge 7c8937cd2 into b65d3b937
Pull Request #603: feat: support auto update using Github releases

1329 of 1751 branches covered (75.9%)

Branch coverage included in aggregate %.

142 of 163 new or added lines in 4 files covered. (87.12%)

1 existing line in 1 file now uncovered.

4736 of 5335 relevant lines covered (88.77%)

616.34 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

92.42
/src/github-updater.js
1
'use strict';
2

3
const { ux } = require('@oclif/core');
9✔
4
const makeDebug = require('debug');
9✔
5
const { Updater } = require('@oclif/plugin-update/lib/update');
9✔
6

7
const debug = makeDebug('oclif:update:github');
9✔
8

9
let octokitInstance = null;
9✔
10

11
async function getOctokit() {
12
        if (!octokitInstance) {
9!
13
                const { Octokit } = await import('@octokit/rest');
9✔
14
                octokitInstance = new Octokit({
9✔
15
                        auth: process.env.GITHUB_TOKEN || process.env.GH_TOKEN,
18✔
16
                });
17
        }
18
        return octokitInstance;
9✔
19
}
20

21
function checkGitHubConfig(config) {
22
        const githubConfig = config.pjson.oclif.update?.github;
513✔
23

24
        if (
513✔
25
                githubConfig &&
1,719✔
26
                typeof githubConfig === 'object' &&
27
                'owner' in githubConfig &&
28
                'repo' in githubConfig
29
        ) {
30
                return {
387✔
31
                        owner: githubConfig.owner,
32
                        repo: githubConfig.repo,
33
                };
34
        }
35

36
        // Try to parse from repository field
37
        const repo = config.pjson.repository;
126✔
38
        if (typeof repo === 'string') {
126✔
39
                // Handle formats like "owner/repo" or "github:owner/repo" or "https://github.com/owner/repo"
40
                const match =
41
                        repo.match(/^(?:github:)?([^/]+)\/([^/]+?)(?:\.git)?$/u) ||
99✔
42
                        repo.match(/github\.com\/([^/]+)\/([^/]+?)(?:\.git)?$/u);
43

44
                if (match) {
99✔
45
                        return {
90✔
46
                                owner: match[1],
47
                                repo: match[2],
48
                        };
49
                }
50
        }
51

52
        return null;
36✔
53
}
54

55
class GitHubUpdater extends Updater {
56
        constructor(config) {
57
                super(config);
450✔
58
                this.githubConfig = this.getGitHubConfig();
450✔
59
                this.octokit = null;
414✔
60
        }
61

62
        async ensureOctokit() {
63
                if (!this.octokit) {
189✔
64
                        this.octokit = await getOctokit();
9✔
65
                }
66
        }
67

68
        // Override runUpdate to use GitHub-specific methods
69
        // Since the base class has private methods for fetching manifests,
70
        // we need to override the entire runUpdate to use GitHub APIs
71
        async runUpdate(options) {
72
                const { autoUpdate, version, force = false } = options;
63!
73

74
                if (autoUpdate) {
63✔
75
                        await this.debounce();
9✔
76
                }
77

78
                ux.action.start(`${this.config.name}: Updating CLI`);
63✔
79

80
                if (this.notUpdatable()) {
63✔
81
                        ux.action.stop('not updatable');
18✔
82
                        return;
18✔
83
                }
84

85
                const channel = 'stable';
45✔
86
                const current = await this.determineCurrentVersion();
45✔
87

88
                if (version) {
45✔
89
                        const localVersion = force
27✔
90
                                ? null
91
                                : await this.findLocalVersion(version);
92

93
                        if (this.alreadyOnVersion(current, localVersion || null)) {
27!
NEW
94
                                ux.action.stop(
×
95
                                        this.config.scopedEnvVar('HIDE_UPDATED_MESSAGE')
×
96
                                                ? 'done'
97
                                                : `already on version ${current}`
98
                                );
NEW
99
                                return;
×
100
                        }
101

102
                        await this.config.runHook('preupdate', { channel, version });
27✔
103

104
                        if (localVersion) {
27✔
105
                                await this.updateToExistingVersion(current, localVersion);
9✔
106
                        } else {
107
                                const index = await this.fetchVersionIndex();
18✔
108
                                const url = index[version];
18✔
109
                                if (!url) {
18✔
110
                                        throw new Error(
9✔
111
                                                `${version} not found in index:\n${Object.keys(index).join(', ')}`
112
                                        );
113
                                }
114

115
                                const manifest = await this.fetchGitHubManifest(version, url);
9✔
116
                                const updated = manifest.sha
9!
117
                                        ? `${manifest.version}-${manifest.sha}`
118
                                        : manifest.version;
119
                                await this.update(manifest, current, updated, force, channel);
9✔
120
                        }
121

122
                        await this.config.runHook('update', { channel, version });
18✔
123
                        ux.action.stop();
18✔
124
                        ux.stdout();
18✔
125
                        ux.stdout(
18✔
126
                                `Updating to a specific version will not update the channel. If autoupdate is enabled, the CLI will eventually be updated back to ${channel}.`
127
                        );
128
                } else {
129
                        const manifest = await this.fetchGitHubManifest();
18✔
130
                        const updated = manifest.sha
18!
131
                                ? `${manifest.version}-${manifest.sha}`
132
                                : manifest.version;
133

134
                        if (!force && this.alreadyOnVersion(current, updated)) {
18!
135
                                ux.action.stop(
18✔
136
                                        this.config.scopedEnvVar('HIDE_UPDATED_MESSAGE')
18✔
137
                                                ? 'done'
138
                                                : `already on version ${current}`
139
                                );
140
                        } else {
NEW
141
                                await this.config.runHook('preupdate', {
×
142
                                        channel,
143
                                        version: updated,
144
                                });
NEW
145
                                await this.update(manifest, current, updated, force, channel);
×
146
                        }
147

148
                        await this.config.runHook('update', { channel, version: updated });
18✔
149
                        ux.action.stop();
18✔
150
                }
151

152
                await this.touch();
36✔
153
                await this.tidy();
36✔
154
        }
155

156
        // Override fetchVersionIndex to use GitHub releases
157
        async fetchVersionIndex() {
158
                return this.fetchGitHubVersionIndex();
9✔
159
        }
160

161
        // GitHub-specific implementation
162
        async fetchGitHubVersionIndex() {
163
                await this.ensureOctokit();
54✔
164
                ux.action.status = 'fetching version index from GitHub';
54✔
165

166
                const { owner, repo } = this.githubConfig;
54✔
167

168
                try {
54✔
169
                        debug(`Fetching releases for ${owner}/${repo}`);
54✔
170
                        const { data: releases } = await this.octokit.repos.listReleases({
54✔
171
                                owner,
172
                                per_page: 100,
173
                                repo,
174
                        });
175

176
                        const versionIndex = {};
45✔
177

178
                        for (const release of releases) {
45✔
179
                                // Extract version from tag_name (remove 'v' prefix if present)
180
                                const version = release.tag_name.replace(/^v/u, '');
36✔
181

182
                                // Find the appropriate asset for this platform/arch
183
                                const assetName = this.determineAssetName(version);
36✔
184
                                const asset = release.assets.find((a) => a.name === assetName);
153✔
185

186
                                if (asset) {
36!
187
                                        versionIndex[version] = asset.browser_download_url;
36✔
188
                                }
189
                        }
190

191
                        debug(`Found ${Object.keys(versionIndex).length} versions`);
45✔
192
                        return versionIndex;
45✔
193
                } catch (error) {
194
                        debug('Failed to fetch GitHub releases', error);
9✔
195
                        throw new Error(
9✔
196
                                `Failed to fetch releases from GitHub repository ${owner}/${repo}`
197
                        );
198
                }
199
        }
200

201
        // GitHub-specific manifest fetching
202
        // Fetches latest release if no version specified, or specific version if provided
203
        async fetchGitHubManifest(version = null, fallbackUrl = null) {
153✔
204
                await this.ensureOctokit();
117✔
205
                ux.action.status = 'fetching manifest from GitHub';
117✔
206

207
                const { owner, repo } = this.githubConfig;
117✔
208

209
                try {
117✔
210
                        let release;
211

212
                        if (version) {
117✔
213
                                debug(`Fetching release v${version} for ${owner}/${repo}`);
45✔
214
                                const { data } = await this.octokit.repos.getReleaseByTag({
45✔
215
                                        owner,
216
                                        repo,
217
                                        tag: `v${version}`,
218
                                });
219
                                release = data;
27✔
220
                        } else {
221
                                debug(`Fetching latest release for ${owner}/${repo}`);
72✔
222
                                const { data } = await this.octokit.repos.getLatestRelease({
72✔
223
                                        owner,
224
                                        repo,
225
                                });
226
                                release = data;
63✔
227
                        }
228

229
                        const releaseVersion = release.tag_name.replace(/^v/u, '');
90✔
230
                        const assetName = this.determineAssetName(releaseVersion);
90✔
231
                        const asset = release.assets.find((a) => a.name === assetName);
468✔
232

233
                        if (!asset) {
90✔
234
                                // If we have a fallback URL (for specific version), use it
235
                                if (fallbackUrl) {
18✔
236
                                        return {
9✔
237
                                                gz: fallbackUrl,
238
                                                version: version || releaseVersion,
9!
239
                                        };
240
                                }
241

242
                                // Otherwise, throw an error
243
                                const config = this.config;
9✔
244
                                throw new Error(
9✔
245
                                        `No suitable asset found for ${config.platform}-${config.arch} in release ${release.tag_name}`
246
                                );
247
                        }
248

249
                        // Return manifest with the asset URL and SHA256 digest
250
                        const manifest = {
72✔
251
                                gz: asset.browser_download_url,
252
                                version: releaseVersion,
253
                        };
254

255
                        // Add SHA256 digest if available (format: "sha256:hash")
256
                        if (asset.digest) {
72✔
257
                                const sha256Match = asset.digest.match(/^sha256:(.+)$/);
63✔
258
                                if (sha256Match) {
63✔
259
                                        manifest.sha256gz = sha256Match[1];
54✔
260
                                }
261
                        }
262

263
                        return manifest;
72✔
264
                } catch (error) {
265
                        // If we have a fallback URL, use it
266
                        if (fallbackUrl && version) {
36✔
267
                                debug(
9✔
268
                                        'Failed to fetch version manifest, using fallback URL',
269
                                        error
270
                                );
271
                                return {
9✔
272
                                        gz: fallbackUrl,
273
                                        version,
274
                                };
275
                        }
276

277
                        const statusCode = error.status;
27✔
278
                        if (statusCode === 404) {
27✔
279
                                throw new Error(
18✔
280
                                        version
18✔
281
                                                ? `Release v${version} not found in ${owner}/${repo}`
282
                                                : `Release not found in ${owner}/${repo}`
283
                                );
284
                        }
285

286
                        throw error;
9✔
287
                }
288
        }
289

290
        determineAssetName(version) {
291
                const config = this.config;
189✔
292
                const platform = config.platform === 'wsl' ? 'linux' : config.platform;
189✔
293
                const ext = config.windows ? 'tar.gz' : 'tar.gz';
189!
294
                return `${config.bin}-v${version}-${platform}-${config.arch}.${ext}`;
189✔
295
        }
296

297
        getGitHubConfig() {
298
                const oclifConfig = this.config;
513✔
299
                const config = checkGitHubConfig(oclifConfig);
513✔
300
                if (!config) {
513✔
301
                        throw new Error(
36✔
302
                                'GitHub repository not configured. Add "oclif.update.github" with "owner" and "repo" fields to package.json'
303
                        );
304
                }
305

306
                return config;
477✔
307
        }
308
}
309

310
module.exports = GitHubUpdater;
9✔
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc