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

box / boxcli / 19472088451

18 Nov 2025 03:46PM UTC coverage: 84.547% (-1.0%) from 85.581%
19472088451

Pull #603

github

web-flow
Merge cbdcd665d into 6a32aa52c
Pull Request #603: feat: support auto update using Github releases

1298 of 1751 branches covered (74.13%)

Branch coverage included in aggregate %.

99 of 163 new or added lines in 4 files covered. (60.74%)

1 existing line in 1 file now uncovered.

4693 of 5335 relevant lines covered (87.97%)

615.48 hits per line

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

55.05
/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() {
NEW
12
        if (!octokitInstance) {
×
NEW
13
                const { Octokit } = await import('@octokit/rest');
×
NEW
14
                octokitInstance = new Octokit({
×
15
                        auth: process.env.GITHUB_TOKEN || process.env.GH_TOKEN,
×
16
                });
17
        }
NEW
18
        return octokitInstance;
×
19
}
20

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

24
        if (
324✔
25
                githubConfig &&
963✔
26
                typeof githubConfig === 'object' &&
27
                'owner' in githubConfig &&
28
                'repo' in githubConfig
29
        ) {
30
                return {
198✔
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);
261✔
58
                this.githubConfig = this.getGitHubConfig();
261✔
59
                this.octokit = null;
225✔
60
        }
61

62
        async ensureOctokit() {
63
                if (!this.octokit) {
81!
NEW
64
                        this.octokit = await getOctokit();
×
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) {
NEW
72
                const { autoUpdate, version, force = false } = options;
×
73

NEW
74
                if (autoUpdate) {
×
NEW
75
                        await this.debounce();
×
76
                }
77

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

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

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

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

NEW
93
                        if (this.alreadyOnVersion(current, localVersion || null)) {
×
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

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

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

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

NEW
122
                        await this.config.runHook('update', { channel, version });
×
NEW
123
                        ux.action.stop();
×
NEW
124
                        ux.stdout();
×
NEW
125
                        ux.stdout(
×
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 {
NEW
129
                        const manifest = await this.fetchGitHubManifest();
×
NEW
130
                        const updated = manifest.sha
×
131
                                ? `${manifest.version}-${manifest.sha}`
132
                                : manifest.version;
133

NEW
134
                        if (!force && this.alreadyOnVersion(current, updated)) {
×
NEW
135
                                ux.action.stop(
×
136
                                        this.config.scopedEnvVar('HIDE_UPDATED_MESSAGE')
×
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

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

NEW
152
                await this.touch();
×
NEW
153
                await this.tidy();
×
154
        }
155

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

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

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

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

176
                        const versionIndex = {};
27✔
177

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

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

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

191
                        debug(`Found ${Object.keys(versionIndex).length} versions`);
27✔
192
                        return versionIndex;
27✔
193
                } catch (error) {
NEW
194
                        debug('Failed to fetch GitHub releases', error);
×
NEW
195
                        throw new Error(
×
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) {
54✔
204
                await this.ensureOctokit();
54✔
205
                ux.action.status = 'fetching manifest from GitHub';
54✔
206

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

209
                try {
54✔
210
                        let release;
211

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

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

233
                        if (!asset) {
45✔
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 = {
27✔
251
                                gz: asset.browser_download_url,
252
                                version: releaseVersion,
253
                        };
254

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

263
                        return manifest;
27✔
264
                } catch (error) {
265
                        // If we have a fallback URL, use it
266
                        if (fallbackUrl && version) {
18✔
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;
9✔
278
                        if (statusCode === 404) {
9!
NEW
279
                                throw new Error(
×
280
                                        version
×
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;
135✔
292
                const platform = config.platform === 'wsl' ? 'linux' : config.platform;
135✔
293
                const ext = config.windows ? 'tar.gz' : 'tar.gz';
135!
294
                return `${config.bin}-v${version}-${platform}-${config.arch}.${ext}`;
135✔
295
        }
296

297
        getGitHubConfig() {
298
                const oclifConfig = this.config;
324✔
299
                const config = checkGitHubConfig(oclifConfig);
324✔
300
                if (!config) {
324✔
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;
288✔
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