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

electron / fiddle / 26301488499

22 May 2026 05:08PM UTC coverage: 88.201% (-0.1%) from 88.301%
26301488499

Pull #1942

github

web-flow
Merge 04242d5d7 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%)

16 existing lines in 3 files 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 {
10
  GIST_MAX_FILE_COUNT,
11
  GIST_MAX_FILE_SIZE,
12
  GITHUB_TOKEN_PATTERN,
13
} from '../constants';
14
import {
15
  EditorValues,
16
  GistFile,
17
  GistLoadResult,
18
  GistRevision,
19
  GistWriteResult,
20
  GitHubCheckAuthResult,
21
  GitHubSignInResult,
22
} from '../interfaces';
23
import { IpcEvents } from '../ipc-events';
24
import { isSupportedFile } from '../utils/editor-utils';
25

26
// --- Input validation ---
27

28
const ELECTRON_ORG = 'electron';
2✔
29

30
const ELECTRON_REPO = 'electron';
2✔
31

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

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

36
const MAX_DESCRIPTION_LENGTH = 256;
2✔
37

38
function isValidToken(token: unknown): token is string {
39
  return typeof token === 'string' && GITHUB_TOKEN_PATTERN.test(token);
45✔
40
}
41

42
function isValidGistId(gistId: unknown): gistId is string {
43
  return typeof gistId === 'string' && GIST_ID_PATTERN.test(gistId);
53✔
44
}
45

46
function isValidSha(sha: unknown): sha is string {
47
  return typeof sha === 'string' && SHA_PATTERN.test(sha);
7✔
48
}
49

50
function isValidDescription(description: unknown): description is string {
51
  return (
14✔
52
    typeof description === 'string' &&
53
    description.length > 0 &&
54
    description.length <= MAX_DESCRIPTION_LENGTH
55
  );
56
}
57

58
function areValidGistFiles(
59
  files: unknown,
60
): files is Record<string, GistFile | null> {
61
  if (typeof files !== 'object' || files === null || Array.isArray(files))
15✔
62
    return false;
1✔
63

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

66
  if (entries.length === 0 || entries.length > GIST_MAX_FILE_COUNT)
14✔
67
    return false;
3✔
68

69
  for (const [key, value] of entries) {
11✔
70
    // null entries are used to delete files during update
71
    if (value === null) continue;
12✔
72

73
    if (typeof value !== 'object') return false;
11✔
74

75
    const { filename, content } = value as Record<string, unknown>;
11✔
76
    if (typeof filename !== 'string') return false;
11✔
77
    if (filename.length === 0) return false;
11✔
78
    if (filename !== key) return false;
10✔
79
    if (typeof content !== 'string') return false;
8✔
80
    if (content.length > GIST_MAX_FILE_SIZE) return false;
6✔
81
  }
82

83
  return true;
5✔
84
}
85

86
// --- Token storage ---
87

88
function getCredentialsPath(): string {
89
  const CREDENTIALS_FILE = '.github-credentials';
194✔
90
  return pathJoin(app.getPath('userData'), CREDENTIALS_FILE);
194✔
91
}
92

93
function saveToken(token: string): void {
94
  const encrypted = safeStorage.encryptString(token);
35✔
95
  fs.writeFileSync(getCredentialsPath(), encrypted, { mode: 0o600 });
35✔
96
}
97

98
function loadToken(): string | null {
99
  const credPath = getCredentialsPath();
58✔
100
  try {
58✔
101
    const encrypted = fs.readFileSync(credPath);
58✔
102
    return safeStorage.decryptString(encrypted);
58✔
103
  } catch {
104
    return null;
51✔
105
  }
106
}
107

108
function deleteToken(): void {
109
  const credPath = getCredentialsPath();
52✔
110
  if (fs.existsSync(credPath)) fs.unlinkSync(credPath);
52✔
111
}
112

113
// --- Octokit management ---
114

115
let octokit_: Octokit | null = null;
2✔
116

117
function getAuthenticatedOctokit(): Octokit {
118
  if (!octokit_) throw new Error('Not authenticated. Please sign in first.');
6✔
119
  return octokit_;
5✔
120
}
121

122
function getOctokit(): Octokit {
123
  // Returns an authenticated instance if available, otherwise unauthenticated.
124
  // Unauthenticated requests have lower rate limits but work for public gists.
125
  return octokit_ || new Octokit();
16✔
126
}
127

128
// --- IPC handlers ---
129

130
async function handleTokenSignIn(
131
  _event: IpcMainInvokeEvent,
132
  token: unknown,
133
): Promise<GitHubSignInResult> {
134
  if (!isValidToken(token))
45✔
135
    return { success: false, error: 'Invalid token format.' };
12✔
136

137
  if (!safeStorage.isEncryptionAvailable()) {
33✔
138
    return {
1✔
139
      success: false,
140
      error:
141
        'Encryption is not available on this system. Cannot securely store token.',
142
    };
143
  }
144

145
  try {
32✔
146
    const testOctokit = new Octokit({ auth: token });
32✔
147
    const response = await testOctokit.users.getAuthenticated();
32✔
148

149
    const scopes = response.headers['x-oauth-scopes']?.split(', ') || [];
31✔
150
    if (!scopes.includes('gist'))
45✔
151
      return {
1✔
152
        success: false,
153
        error:
154
          'Token is missing the "gist" scope. Please generate a new token with gist permissions.',
155
      };
156

157
    saveToken(token);
30✔
158
    octokit_ = testOctokit;
30✔
159

160
    return { success: true, login: response.data.login };
30✔
161
  } catch (error: any) {
162
    console.warn('GitHub token sign-in failed', error);
1✔
163
    return {
1✔
164
      success: false,
165
      error: 'Invalid GitHub token. Please check your token and try again.',
166
    };
167
  }
168
}
169

170
async function handleTokenSignOut(_event: IpcMainInvokeEvent): Promise<void> {
171
  deleteToken();
51✔
172
  octokit_ = null;
51✔
173
}
174

175
async function handleTokenCheckAuth(
176
  _event: IpcMainInvokeEvent,
177
): Promise<GitHubCheckAuthResult> {
178
  const token = loadToken();
5✔
179
  if (!token) return { login: null };
5✔
180

181
  try {
3✔
182
    octokit_ = new Octokit({ auth: token });
3✔
183
    const response = await octokit_.users.getAuthenticated();
3✔
184
    return { login: response.data.login };
1✔
185
  } catch (error: any) {
186
    octokit_ = null;
2✔
187

188
    if (error?.status === 401 || error?.status === 403) {
2✔
189
      deleteToken();
1✔
190
    }
191

192
    return { login: null };
2✔
193
  }
194
}
195

196
async function handleGistCreate(
197
  _event: IpcMainInvokeEvent,
198
  params: unknown,
199
): Promise<GistWriteResult> {
200
  if (typeof params !== 'object' || params === null)
16✔
201
    throw new Error('Invalid parameters.');
2✔
202

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

205
  if (!isValidDescription(description))
14✔
206
    throw new Error(
4✔
207
      `Invalid description. Must be 1-${MAX_DESCRIPTION_LENGTH} characters.`,
208
    );
209
  if (!areValidGistFiles(files)) throw new Error('Invalid files payload.');
10✔
210
  if (typeof isPublic !== 'boolean')
4✔
211
    throw new Error('isPublic must be a boolean.');
4✔
212

213
  const octo = getAuthenticatedOctokit();
3✔
214
  const gist = await octo.gists.create({
3✔
215
    public: isPublic,
216
    description,
217
    files: files as any,
218
  });
219

220
  return {
2✔
221
    id: gist.data.id!,
222
    url: gist.data.html_url ?? '',
223
    revision: gist.data.history?.[0]?.version,
224
  };
225
}
226

227
async function handleGistUpdate(
228
  _event: IpcMainInvokeEvent,
229
  params: unknown,
230
): Promise<GistWriteResult> {
231
  if (typeof params !== 'object' || params === null)
14✔
232
    throw new Error('Invalid parameters.');
1✔
233

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

236
  if (!isValidGistId(gistId)) throw new Error('Invalid gist ID.');
13✔
237
  if (!areValidGistFiles(files)) throw new Error('Invalid files payload.');
5✔
238

239
  const octo = getAuthenticatedOctokit();
1✔
240

241
  // Fetch existing files to detect deletions
242
  const { data: existing } = await octo.gists.get({ gist_id: gistId });
1✔
243
  const updateFiles: Record<string, GistFile | null> = { ...files };
1✔
244
  for (const fileId of Object.keys(existing.files ?? {})) {
1✔
245
    if (!(fileId in updateFiles)) updateFiles[fileId] = null;
1✔
246
  }
247

248
  const gist = await octo.gists.update({
1✔
249
    gist_id: gistId,
250
    // Octokit's generated types don't model file deletion (null), but the
251
    // REST API requires it. Cast only at the boundary.
252
    files: updateFiles as Record<string, GistFile>,
253
  });
254

255
  return {
1✔
256
    id: gist.data.id!,
257
    url: gist.data.html_url ?? '',
258
    revision: gist.data.history?.[0]?.version,
259
  };
260
}
261

262
async function handleGistDelete(
263
  _event: IpcMainInvokeEvent,
264
  gistId: unknown,
265
): Promise<void> {
266
  if (!isValidGistId(gistId)) throw new Error('Invalid gist ID.');
10✔
267

268
  const octo = getAuthenticatedOctokit();
2✔
269
  await octo.gists.delete({ gist_id: gistId });
2✔
270
}
271

272
async function handleGistLoad(
273
  _event: IpcMainInvokeEvent,
274
  params: unknown,
275
): Promise<GistLoadResult> {
276
  if (typeof params !== 'object' || params === null)
20✔
277
    throw new Error('Invalid parameters.');
1✔
278

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

281
  if (!isValidGistId(gistId)) throw new Error('Invalid gist ID.');
19✔
282
  if (revision !== undefined && !isValidSha(revision))
11✔
283
    throw new Error('Invalid revision SHA.');
5✔
284

285
  const octo = getOctokit();
6✔
286
  const gist = revision
6✔
287
    ? await octo.gists.getRevision({ gist_id: gistId, sha: revision })
288
    : await octo.gists.get({ gist_id: gistId });
289

290
  const files: GistLoadResult['files'] = {};
4✔
291
  for (const [fileId, data] of Object.entries(gist.data.files ?? {})) {
4✔
292
    if (!data) continue;
6✔
293

294
    // When GitHub truncates a large file, data.content is incomplete.
295
    // Fetch the full content from raw_url instead.
296
    let content = data.content ?? '';
6✔
297
    if (data.truncated && data.raw_url) {
6✔
298
      const response = await fetch(data.raw_url);
1✔
299
      if (response.ok) {
1✔
300
        content = await response.text();
1✔
301
      }
302
    }
303

304
    files[fileId] = {
6✔
305
      filename: data.filename ?? fileId,
306
      content,
307
    };
308
  }
309

310
  return {
6✔
311
    files,
312
    revision: gist.data.history?.[0]?.version,
313
  };
314
}
315

316
async function handleGistListCommits(
317
  _event: IpcMainInvokeEvent,
318
  gistId: unknown,
319
): Promise<GistRevision[]> {
320
  if (!isValidGistId(gistId)) throw new Error('Invalid gist ID.');
11✔
321

322
  const octo = getOctokit();
3✔
323
  const { data: revisions } = await octo.gists.listCommits({
3✔
324
    gist_id: gistId,
325
  });
326

327
  const oldestRevision = revisions[revisions.length - 1];
3✔
328
  const nonEmptyRevisions = revisions.filter(
3✔
329
    (r) =>
330
      r === oldestRevision ||
7✔
331
      (r.change_status.additions ?? 0) > 0 ||
332
      (r.change_status.deletions ?? 0) > 0,
333
  );
334

335
  return nonEmptyRevisions.reverse().map((r, i) => ({
6✔
336
    sha: r.version,
337
    date: r.committed_at,
338
    changes: {
339
      total: r.change_status.total ?? 0,
340
      additions: r.change_status.additions ?? 0,
341
      deletions: r.change_status.deletions ?? 0,
342
    },
343
    title: i === 0 ? 'Created' : `Revision ${i}`,
344
  }));
345
}
346

347
async function handleFetchExample(
348
  _event: IpcMainInvokeEvent,
349
  params: unknown,
350
): Promise<EditorValues> {
UNCOV
351
  if (typeof params !== 'object' || params === null)
×
UNCOV
352
    throw new Error('Invalid parameters.');
×
353
  const { ref, path } = params as Record<string, unknown>;
×
354
  if (typeof ref !== 'string') throw new Error('Invalid ref.');
×
355
  if (typeof path !== 'string') throw new Error('Invalid path.');
×
356
  return fetchExample(ref, path);
×
357
}
358

359
async function fetchExample(ref: string, path: string): Promise<EditorValues> {
360
  if (!ref) throw new Error('Invalid ref.');
9✔
361
  if (!path) throw new Error('Invalid path.');
8✔
362

363
  // `repos.getContent` returns a union; the directory variant is the array form.
364
  type RepoContentEntry = Extract<
365
    RestEndpointMethodTypes['repos']['getContent']['response']['data'],
366
    readonly unknown[]
367
  >[number];
368

369
  const owner = ELECTRON_ORG;
7✔
370
  const repo = ELECTRON_REPO;
7✔
371
  const octo = getOctokit();
7✔
372

373
  // Fetch the example folder listing.
374
  const folder = await octo.repos.getContent({ owner, path, ref, repo });
7✔
375
  if (!Array.isArray(folder.data))
6✔
376
    throw new Error(`${owner}:${repo}/${path}:${ref} is not a valid example`);
6✔
377
  const files = (folder.data as RepoContentEntry[]).filter(
5✔
378
    (file) =>
379
      typeof file.download_url === 'string' &&
9✔
380
      typeof file.name === 'string' &&
381
      isSupportedFile(file.name),
382
  );
383

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

388
  // Download each supported file and overlay onto the template.
389
  await Promise.all(
5✔
390
    files.map(async (file) => {
391
      const resp = await fetch(file.download_url as string);
6✔
392
      if (!resp.ok)
6✔
393
        throw new Error(`Failed to download ${file.name}: ${resp.status}`);
1✔
394
      values[file.name as keyof EditorValues] = await resp.text();
5✔
395
    }),
396
  );
397

398
  return values;
4✔
399
}
400

401
// --- Setup ---
402

403
export function setupGitHub() {
404
  ipcMainManager.handle(IpcEvents.GITHUB_FETCH_EXAMPLE, handleFetchExample);
3✔
405
  ipcMainManager.handle(IpcEvents.GITHUB_GIST_CREATE, handleGistCreate);
3✔
406
  ipcMainManager.handle(IpcEvents.GITHUB_GIST_DELETE, handleGistDelete);
3✔
407
  ipcMainManager.handle(
3✔
408
    IpcEvents.GITHUB_GIST_LIST_COMMITS,
409
    handleGistListCommits,
410
  );
411
  ipcMainManager.handle(IpcEvents.GITHUB_GIST_LOAD, handleGistLoad);
3✔
412
  ipcMainManager.handle(IpcEvents.GITHUB_GIST_UPDATE, handleGistUpdate);
3✔
413
  ipcMainManager.handle(
3✔
414
    IpcEvents.GITHUB_TOKEN_CHECK_AUTH,
415
    handleTokenCheckAuth,
416
  );
417
  ipcMainManager.handle(IpcEvents.GITHUB_TOKEN_SIGN_IN, handleTokenSignIn);
3✔
418
  ipcMainManager.handle(IpcEvents.GITHUB_TOKEN_SIGN_OUT, handleTokenSignOut);
3✔
419
}
420

421
// Exported for testing
422
export const testing = {
2✔
423
  fetchExample,
424
  getCredentialsPath,
425
  handleGistCreate,
426
  handleGistDelete,
427
  handleGistListCommits,
428
  handleGistLoad,
429
  handleGistUpdate,
430
  handleTokenCheckAuth,
431
  handleTokenSignIn,
432
  handleTokenSignOut,
433
  loadToken,
434
  saveToken,
435
};
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