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

electron / fiddle / 26301040957

22 May 2026 04:59PM UTC coverage: 88.201% (-0.1%) from 88.301%
26301040957

Pull #1942

github

web-flow
Merge 813c174ee into c4a48887a
Pull Request #1942: refactor: move GitHub tokens to main

569 of 625 branches covered (91.04%)

Branch coverage included in aggregate %.

31 of 39 new or added lines in 9 files covered. (79.49%)

6 existing lines in 1 file now uncovered.

3565 of 4062 relevant lines covered (87.76%)

46.33 hits per line

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

95.54
/src/main/github.ts
1
import * as fs from 'node:fs';
2
import { join as pathJoin } from 'node:path';
1✔
3

4
import { Octokit, RestEndpointMethodTypes } from '@octokit/rest';
5
import { IpcMainInvokeEvent, app, safeStorage } from 'electron';
6

7
import { getTemplate } from './content';
8
import { ipcMainManager } from './ipc';
9
import { GIST_MAX_FILE_COUNT, GIST_MAX_FILE_SIZE, GITHUB_TOKEN_PATTERN } from '../constants';
10
import { EditorValues, GistFile, GistLoadResult, GistRevision, GistWriteResult, GitHubCheckAuthResult, GitHubSignInResult } from '../interfaces';
11
import { IpcEvents } from '../ipc-events';
12
import { isSupportedFile } from '../utils/editor-utils';
13

14
// --- Input validation ---
15

16
const ELECTRON_ORG = 'electron';
2✔
17

18
const ELECTRON_REPO = 'electron';
2✔
19

20
const GIST_ID_PATTERN = /^[0-9a-fA-F]{32}$/;
2✔
21

22
const SHA_PATTERN = /^[0-9a-f]{40}$/;
2✔
23

24
const MAX_DESCRIPTION_LENGTH = 256;
2✔
25

26
function isValidToken(token: unknown): token is string {
27
  return typeof token === 'string' && GITHUB_TOKEN_PATTERN.test(token);
45✔
28
}
29

30
function isValidGistId(gistId: unknown): gistId is string {
31
  return typeof gistId === 'string' && GIST_ID_PATTERN.test(gistId);
53✔
32
}
33

34
function isValidSha(sha: unknown): sha is string {
35
  return typeof sha === 'string' && SHA_PATTERN.test(sha);
7✔
36
}
37

38
function isValidDescription(description: unknown): description is string {
39
  return (
14✔
40
    typeof description === 'string' &&
41
    description.length > 0 &&
42
    description.length <= MAX_DESCRIPTION_LENGTH
43
  );
44
}
45

46
function areValidGistFiles(
47
  files: unknown,
48
): files is Record<string, GistFile | null> {
49
  if (typeof files !== 'object' || files === null || Array.isArray(files))
15✔
50
    return false;
1✔
51

52
  const entries = Object.entries(files as Record<string, unknown>);
14✔
53

54
  if (entries.length === 0 || entries.length > GIST_MAX_FILE_COUNT)
14✔
55
    return false;
3✔
56

57
  for (const [key, value] of entries) {
11✔
58
    // null entries are used to delete files during update
59
    if (value === null) continue;
12✔
60

61
    if (typeof value !== 'object') return false;
11✔
62

63
    const { filename, content } = value as Record<string, unknown>;
11✔
64
    if (typeof filename !== 'string') return false;
11✔
65
    if (filename.length === 0) return false;
11✔
66
    if (filename !== key) return false;
10✔
67
    if (typeof content !== 'string') return false;
8✔
68
    if (content.length > GIST_MAX_FILE_SIZE) return false;
6✔
69
  }
70

71
  return true;
5✔
72
}
73

74
// --- Token storage ---
75

76
function getCredentialsPath(): string {
77
  const CREDENTIALS_FILE = '.github-credentials';
194✔
78
  return pathJoin(app.getPath('userData'), CREDENTIALS_FILE);
194✔
79
}
80

81
function saveToken(token: string): void {
82
  const encrypted = safeStorage.encryptString(token);
35✔
83
  fs.writeFileSync(getCredentialsPath(), encrypted, { mode: 0o600 });
35✔
84
}
85

86
function loadToken(): string | null {
87
  const credPath = getCredentialsPath();
58✔
88
  try {
58✔
89
    const encrypted = fs.readFileSync(credPath);
58✔
90
    return safeStorage.decryptString(encrypted);
58✔
91
  } catch {
92
    return null;
51✔
93
  }
94
}
95

96
function deleteToken(): void {
97
  const credPath = getCredentialsPath();
52✔
98
  if (fs.existsSync(credPath)) fs.unlinkSync(credPath);
52✔
99
}
100

101
// --- Octokit management ---
102

103
let octokit_: Octokit | null = null;
2✔
104

105
function getAuthenticatedOctokit(): Octokit {
106
  if (!octokit_) throw new Error('Not authenticated. Please sign in first.');
6✔
107
  return octokit_;
5✔
108
}
109

110
function getOctokit(): Octokit {
111
  // Returns an authenticated instance if available, otherwise unauthenticated.
112
  // Unauthenticated requests have lower rate limits but work for public gists.
113
  return octokit_ || new Octokit();
16✔
114
}
115

116
// --- IPC handlers ---
117

118
async function handleTokenSignIn(
119
  _event: IpcMainInvokeEvent,
120
  token: unknown,
121
): Promise<GitHubSignInResult> {
122
  if (!isValidToken(token))
45✔
123
    return { success: false, error: 'Invalid token format.' };
12✔
124

125
  if (!safeStorage.isEncryptionAvailable()) {
33✔
126
    return {
1✔
127
      success: false,
128
      error:
129
        'Encryption is not available on this system. Cannot securely store token.',
130
    };
131
  }
132

133
  try {
32✔
134
    const testOctokit = new Octokit({ auth: token });
32✔
135
    const response = await testOctokit.users.getAuthenticated();
32✔
136

137
    const scopes = response.headers['x-oauth-scopes']?.split(', ') || [];
31✔
138
    if (!scopes.includes('gist'))
45✔
139
      return {
1✔
140
        success: false,
141
        error:
142
          'Token is missing the "gist" scope. Please generate a new token with gist permissions.',
143
      };
144

145
    saveToken(token);
30✔
146
    octokit_ = testOctokit;
30✔
147

148
    return { success: true, login: response.data.login };
30✔
149
  } catch (error: any) {
150
    console.warn('GitHub token sign-in failed', error);
1✔
151
    return {
1✔
152
      success: false,
153
      error: 'Invalid GitHub token. Please check your token and try again.',
154
    };
155
  }
156
}
157

158
async function handleTokenSignOut(_event: IpcMainInvokeEvent): Promise<void> {
159
  deleteToken();
51✔
160
  octokit_ = null;
51✔
161
}
162

163
async function handleTokenCheckAuth(
164
  _event: IpcMainInvokeEvent,
165
): Promise<GitHubCheckAuthResult> {
166
  const token = loadToken();
5✔
167
  if (!token) return { login: null };
5✔
168

169
  try {
3✔
170
    octokit_ = new Octokit({ auth: token });
3✔
171
    const response = await octokit_.users.getAuthenticated();
3✔
172
    return { login: response.data.login };
1✔
173
  } catch (error: any) {
174
    octokit_ = null;
2✔
175

176
    if (error?.status === 401 || error?.status === 403) {
2✔
177
      deleteToken();
1✔
178
    }
179

180
    return { login: null };
2✔
181
  }
182
}
183

184
async function handleGistCreate(
185
  _event: IpcMainInvokeEvent,
186
  params: unknown,
187
): Promise<GistWriteResult> {
188
  if (typeof params !== 'object' || params === null)
16✔
189
    throw new Error('Invalid parameters.');
2✔
190

191
  const { description, files, isPublic } = params as Record<string, unknown>;
14✔
192

193
  if (!isValidDescription(description))
14✔
194
    throw new Error(
4✔
195
      `Invalid description. Must be 1-${MAX_DESCRIPTION_LENGTH} characters.`,
196
    );
197
  if (!areValidGistFiles(files)) throw new Error('Invalid files payload.');
10✔
198
  if (typeof isPublic !== 'boolean')
4✔
199
    throw new Error('isPublic must be a boolean.');
4✔
200

201
  const octo = getAuthenticatedOctokit();
3✔
202
  const gist = await octo.gists.create({
3✔
203
    public: isPublic,
204
    description,
205
    files: files as any,
206
  });
207

208
  return {
2✔
209
    id: gist.data.id!,
210
    url: gist.data.html_url ?? '',
211
    revision: gist.data.history?.[0]?.version,
212
  };
213
}
214

215
async function handleGistUpdate(
216
  _event: IpcMainInvokeEvent,
217
  params: unknown,
218
): Promise<GistWriteResult> {
219
  if (typeof params !== 'object' || params === null)
14✔
220
    throw new Error('Invalid parameters.');
1✔
221

222
  const { gistId, files } = params as Record<string, unknown>;
13✔
223

224
  if (!isValidGistId(gistId)) throw new Error('Invalid gist ID.');
13✔
225
  if (!areValidGistFiles(files)) throw new Error('Invalid files payload.');
5✔
226

227
  const octo = getAuthenticatedOctokit();
1✔
228

229
  // Fetch existing files to detect deletions
230
  const { data: existing } = await octo.gists.get({ gist_id: gistId });
1✔
231
  const updateFiles: Record<string, GistFile | null> = { ...files };
1✔
232
  for (const fileId of Object.keys(existing.files ?? {})) {
1✔
233
    if (!(fileId in updateFiles)) updateFiles[fileId] = null;
1✔
234
  }
235

236
  const gist = await octo.gists.update({
1✔
237
    gist_id: gistId,
238
    // Octokit's generated types don't model file deletion (null), but the
239
    // REST API requires it. Cast only at the boundary.
240
    files: updateFiles as Record<string, GistFile>,
241
  });
242

243
  return {
1✔
244
    id: gist.data.id!,
245
    url: gist.data.html_url ?? '',
246
    revision: gist.data.history?.[0]?.version,
247
  };
248
}
249

250
async function handleGistDelete(
251
  _event: IpcMainInvokeEvent,
252
  gistId: unknown,
253
): Promise<void> {
254
  if (!isValidGistId(gistId)) throw new Error('Invalid gist ID.');
10✔
255

256
  const octo = getAuthenticatedOctokit();
2✔
257
  await octo.gists.delete({ gist_id: gistId });
2✔
258
}
259

260
async function handleGistLoad(
261
  _event: IpcMainInvokeEvent,
262
  params: unknown,
263
): Promise<GistLoadResult> {
264
  if (typeof params !== 'object' || params === null)
20✔
265
    throw new Error('Invalid parameters.');
1✔
266

267
  const { gistId, revision } = params as Record<string, unknown>;
19✔
268

269
  if (!isValidGistId(gistId)) throw new Error('Invalid gist ID.');
19✔
270
  if (revision !== undefined && !isValidSha(revision))
11✔
271
    throw new Error('Invalid revision SHA.');
5✔
272

273
  const octo = getOctokit();
6✔
274
  const gist = revision
6✔
275
    ? await octo.gists.getRevision({ gist_id: gistId, sha: revision })
276
    : await octo.gists.get({ gist_id: gistId });
277

278
  const files: GistLoadResult['files'] = {};
4✔
279
  for (const [fileId, data] of Object.entries(gist.data.files ?? {})) {
4✔
280
    if (!data) continue;
6✔
281

282
    // When GitHub truncates a large file, data.content is incomplete.
283
    // Fetch the full content from raw_url instead.
284
    let content = data.content ?? '';
6✔
285
    if (data.truncated && data.raw_url) {
6✔
286
      const response = await fetch(data.raw_url);
1✔
287
      if (response.ok) {
1✔
288
        content = await response.text();
1✔
289
      }
290
    }
291

292
    files[fileId] = {
6✔
293
      filename: data.filename ?? fileId,
294
      content,
295
    };
296
  }
297

298
  return {
6✔
299
    files,
300
    revision: gist.data.history?.[0]?.version,
301
  };
302
}
303

304
async function handleGistListCommits(
305
  _event: IpcMainInvokeEvent,
306
  gistId: unknown,
307
): Promise<GistRevision[]> {
308
  if (!isValidGistId(gistId)) throw new Error('Invalid gist ID.');
11✔
309

310
  const octo = getOctokit();
3✔
311
  const { data: revisions } = await octo.gists.listCommits({
3✔
312
    gist_id: gistId,
313
  });
314

315
  const oldestRevision = revisions[revisions.length - 1];
3✔
316
  const nonEmptyRevisions = revisions.filter(
3✔
317
    (r) =>
318
      r === oldestRevision ||
7✔
319
      (r.change_status.additions ?? 0) > 0 ||
320
      (r.change_status.deletions ?? 0) > 0,
321
  );
322

323
  return nonEmptyRevisions.reverse().map((r, i) => ({
6✔
324
    sha: r.version,
325
    date: r.committed_at,
326
    changes: {
327
      total: r.change_status.total ?? 0,
328
      additions: r.change_status.additions ?? 0,
329
      deletions: r.change_status.deletions ?? 0,
330
    },
331
    title: i === 0 ? 'Created' : `Revision ${i}`,
332
  }));
333
}
334

335
async function handleFetchExample(
336
  _event: IpcMainInvokeEvent,
337
  params: unknown,
338
): Promise<EditorValues> {
UNCOV
339
  if (typeof params !== 'object' || params === null)
×
UNCOV
340
    throw new Error('Invalid parameters.');
×
UNCOV
341
  const { ref, path } = params as Record<string, unknown>;
×
UNCOV
342
  if (typeof ref !== 'string') throw new Error('Invalid ref.');
×
UNCOV
343
  if (typeof path !== 'string') throw new Error('Invalid path.');
×
UNCOV
344
  return fetchExample(ref, path);
×
345
}
346

347
async function fetchExample(ref: string, path: string): Promise<EditorValues> {
348
  if (!ref) throw new Error('Invalid ref.');
9✔
349
  if (!path) throw new Error('Invalid path.');
8✔
350

351
  // `repos.getContent` returns a union; the directory variant is the array form.
352
  type RepoContentEntry = Extract<
353
    RestEndpointMethodTypes['repos']['getContent']['response']['data'],
354
    readonly unknown[]
355
  >[number];
356

357
  const owner = ELECTRON_ORG;
7✔
358
  const repo = ELECTRON_REPO;
7✔
359
  const octo = getOctokit();
7✔
360

361
  // Fetch the example folder listing.
362
  const folder = await octo.repos.getContent({ owner, path, ref, repo });
7✔
363
  if (!Array.isArray(folder.data))
6✔
364
    throw new Error(`${owner}:${repo}/${path}:${ref} is not a valid example`);
6✔
365
  const files = (folder.data as RepoContentEntry[]).filter(
5✔
366
    (file) =>
367
      typeof file.download_url === 'string' &&
9✔
368
      typeof file.name === 'string' &&
369
      isSupportedFile(file.name),
370
  );
371

372
  // Get the base template for this version: 'v42.0.0' -> '42.0.0'.
373
  const version = ref.replace(/^v/, '');
5✔
374
  const values: EditorValues = { ...(await getTemplate(version)) };
5✔
375

376
  // Download each supported file and overlay onto the template.
377
  await Promise.all(
5✔
378
    files.map(async (file) => {
379
      const resp = await fetch(file.download_url as string);
6✔
380
      if (!resp.ok)
6✔
381
        throw new Error(`Failed to download ${file.name}: ${resp.status}`);
1✔
382
      values[file.name as keyof EditorValues] = await resp.text();
5✔
383
    }),
384
  );
385

386
  return values;
4✔
387
}
388

389
// --- Setup ---
390

391
export function setupGitHub() {
392
  ipcMainManager.handle(IpcEvents.GITHUB_FETCH_EXAMPLE, handleFetchExample);
3✔
393
  ipcMainManager.handle(IpcEvents.GITHUB_GIST_CREATE, handleGistCreate);
3✔
394
  ipcMainManager.handle(IpcEvents.GITHUB_GIST_DELETE, handleGistDelete);
3✔
395
  ipcMainManager.handle(
3✔
396
    IpcEvents.GITHUB_GIST_LIST_COMMITS,
397
    handleGistListCommits,
398
  );
399
  ipcMainManager.handle(IpcEvents.GITHUB_GIST_LOAD, handleGistLoad);
3✔
400
  ipcMainManager.handle(IpcEvents.GITHUB_GIST_UPDATE, handleGistUpdate);
3✔
401
  ipcMainManager.handle(
3✔
402
    IpcEvents.GITHUB_TOKEN_CHECK_AUTH,
403
    handleTokenCheckAuth,
404
  );
405
  ipcMainManager.handle(IpcEvents.GITHUB_TOKEN_SIGN_IN, handleTokenSignIn);
3✔
406
  ipcMainManager.handle(IpcEvents.GITHUB_TOKEN_SIGN_OUT, handleTokenSignOut);
3✔
407
}
408

409
// Exported for testing
410
export const testing = {
2✔
411
  fetchExample,
412
  getCredentialsPath,
413
  handleGistCreate,
414
  handleGistDelete,
415
  handleGistListCommits,
416
  handleGistLoad,
417
  handleGistUpdate,
418
  handleTokenCheckAuth,
419
  handleTokenSignIn,
420
  handleTokenSignOut,
421
  loadToken,
422
  saveToken,
423
};
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