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

electron / fiddle / 25563613700

08 May 2026 03:17PM UTC coverage: 88.514% (-0.1%) from 88.623%
25563613700

Pull #1940

github

web-flow
Merge b1a6a631d into 34c21579a
Pull Request #1940: feat: add `GITHUB_FETCH_EXAMPLE` to main

567 of 627 branches covered (90.43%)

Branch coverage included in aggregate %.

23 of 30 new or added lines in 4 files covered. (76.67%)

3556 of 4031 relevant lines covered (88.22%)

46.25 hits per line

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

95.57
/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 { EditorValues, GistRevision } from '../interfaces';
10
import { IpcEvents } from '../ipc-events';
11
import { isSupportedFile } from '../utils/editor-utils';
12

13
// --- Input validation ---
14

15
const TOKEN_PATTERN =
16
  /^(ghp_[a-zA-Z0-9]{36}|github_pat_[a-zA-Z0-9]{22}_[a-zA-Z0-9]{59})$/;
2✔
17

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

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

22
const MAX_DESCRIPTION_LENGTH = 256;
2✔
23

24
const MAX_FILE_SIZE = 10 * 1024 * 1024; // 10 MB per file — GitHub's gist limit
2✔
25

26
const MAX_FILE_COUNT = 300; // GitHub's gist file limit
2✔
27

28
function isValidToken(token: unknown): token is string {
29
  return typeof token === 'string' && TOKEN_PATTERN.test(token);
41✔
30
}
31

32
function isValidGistId(gistId: unknown): gistId is string {
33
  return typeof gistId === 'string' && GIST_ID_PATTERN.test(gistId);
51✔
34
}
35

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

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

48
interface GistFile {
49
  filename: string;
50
  content: string;
51
}
52

53
function areValidGistFiles(
54
  files: unknown,
55
): files is Record<string, GistFile | null> {
56
  if (typeof files !== 'object' || files === null || Array.isArray(files))
15✔
57
    return false;
1✔
58

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

61
  if (entries.length === 0 || entries.length > MAX_FILE_COUNT) return false;
14✔
62

63
  for (const [key, value] of entries) {
11✔
64
    // null entries are used to delete files during update
65
    if (value === null) continue;
12✔
66

67
    if (typeof value !== 'object') return false;
11✔
68

69
    const { filename, content } = value as Record<string, unknown>;
11✔
70
    if (typeof filename !== 'string') return false;
11✔
71
    if (filename.length === 0) return false;
11✔
72
    if (filename !== key) return false;
10✔
73
    if (typeof content !== 'string') return false;
8✔
74
    if (content.length > MAX_FILE_SIZE) return false;
6✔
75
  }
76

77
  return true;
5✔
78
}
79

80
// --- Token storage ---
81

82
function getCredentialsPath(): string {
83
  const CREDENTIALS_FILE = '.github-credentials';
81✔
84
  return pathJoin(app.getPath('userData'), CREDENTIALS_FILE);
81✔
85
}
86

87
function saveToken(token: string): void {
88
  const encrypted = safeStorage.encryptString(token);
26✔
89
  fs.writeFileSync(getCredentialsPath(), encrypted, { mode: 0o600 });
26✔
90
}
91

92
function loadToken(): string | null {
93
  const credPath = getCredentialsPath();
5✔
94
  try {
5✔
95
    const encrypted = fs.readFileSync(credPath);
5✔
96
    return safeStorage.decryptString(encrypted);
5✔
97
  } catch {
98
    return null;
1✔
99
  }
100
}
101

102
function deleteToken(): void {
103
  const credPath = getCredentialsPath();
50✔
104
  if (fs.existsSync(credPath)) fs.unlinkSync(credPath);
50✔
105
}
106

107
// --- Octokit management ---
108

109
let octokit_: Octokit | null = null;
2✔
110

111
function getAuthenticatedOctokit(): Octokit {
112
  if (!octokit_) throw new Error('Not authenticated. Please sign in first.');
6✔
113
  return octokit_;
5✔
114
}
115

116
function getOctokit(): Octokit {
117
  // Returns an authenticated instance if available, otherwise unauthenticated.
118
  // Unauthenticated requests have lower rate limits but work for public gists.
119
  return octokit_ || new Octokit();
14✔
120
}
121

122
// --- IPC handlers ---
123

124
interface SignInResult {
125
  success: boolean;
126
  login?: string;
127
  error?: string;
128
}
129

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

137
  if (!safeStorage.isEncryptionAvailable()) {
29✔
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 {
28✔
146
    const testOctokit = new Octokit({ auth: token });
28✔
147
    const response = await testOctokit.users.getAuthenticated();
28✔
148

149
    const scopes = response.headers['x-oauth-scopes']?.split(', ') || [];
27✔
150
    if (!scopes.includes('gist'))
41✔
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);
26✔
158
    octokit_ = testOctokit;
26✔
159

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

169
async function handleTokenSignOut(
170
  _event: IpcMainInvokeEvent,
171
): Promise<{ success: boolean }> {
172
  deleteToken();
49✔
173
  octokit_ = null;
49✔
174
  return { success: true };
49✔
175
}
176

177
interface CheckAuthResult {
178
  login: string | null;
179
}
180

181
async function handleTokenCheckAuth(
182
  _event: IpcMainInvokeEvent,
183
): Promise<CheckAuthResult> {
184
  const token = loadToken();
5✔
185
  if (!token) return { login: null };
5✔
186

187
  try {
3✔
188
    octokit_ = new Octokit({ auth: token });
3✔
189
    const response = await octokit_.users.getAuthenticated();
3✔
190
    return { login: response.data.login };
1✔
191
  } catch (error: any) {
192
    octokit_ = null;
2✔
193

194
    if (error?.status === 401 || error?.status === 403) {
2✔
195
      deleteToken();
1✔
196
    }
197

198
    return { login: null };
2✔
199
  }
200
}
201

202
interface GistWriteResult {
203
  id: string;
204
  url: string;
205
  revision?: string;
206
}
207

208
async function handleGistCreate(
209
  _event: IpcMainInvokeEvent,
210
  params: unknown,
211
): Promise<GistWriteResult> {
212
  if (typeof params !== 'object' || params === null)
16✔
213
    throw new Error('Invalid parameters.');
2✔
214

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

217
  if (!isValidDescription(description))
14✔
218
    throw new Error(
4✔
219
      `Invalid description. Must be 1-${MAX_DESCRIPTION_LENGTH} characters.`,
220
    );
221
  if (!areValidGistFiles(files)) throw new Error('Invalid files payload.');
10✔
222
  if (typeof isPublic !== 'boolean')
4✔
223
    throw new Error('isPublic must be a boolean.');
4✔
224

225
  const octo = getAuthenticatedOctokit();
3✔
226
  const gist = await octo.gists.create({
3✔
227
    public: isPublic,
228
    description,
229
    files: files as any,
230
  });
231

232
  return {
2✔
233
    id: gist.data.id!,
234
    url: gist.data.html_url ?? '',
235
    revision: gist.data.history?.[0]?.version,
236
  };
237
}
238

239
async function handleGistUpdate(
240
  _event: IpcMainInvokeEvent,
241
  params: unknown,
242
): Promise<GistWriteResult> {
243
  if (typeof params !== 'object' || params === null)
14✔
244
    throw new Error('Invalid parameters.');
1✔
245

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

248
  if (!isValidGistId(gistId)) throw new Error('Invalid gist ID.');
13✔
249
  if (!areValidGistFiles(files)) throw new Error('Invalid files payload.');
5✔
250

251
  const octo = getAuthenticatedOctokit();
1✔
252

253
  // Fetch existing files to detect deletions
254
  const { data: existing } = await octo.gists.get({ gist_id: gistId });
1✔
255
  const updateFiles = { ...(files as Record<string, GistFile | null>) };
1✔
256
  for (const id of Object.keys(existing.files ?? {})) {
1✔
257
    if (!(id in updateFiles)) updateFiles[id] = null as any;
1✔
258
  }
259

260
  const gist = await octo.gists.update({
1✔
261
    gist_id: gistId,
262
    files: updateFiles as any,
263
  });
264

265
  return {
1✔
266
    id: gist.data.id!,
267
    url: gist.data.html_url ?? '',
268
    revision: gist.data.history?.[0]?.version,
269
  };
270
}
271

272
async function handleGistDelete(
273
  _event: IpcMainInvokeEvent,
274
  gistId: unknown,
275
): Promise<{ success: boolean }> {
276
  if (!isValidGistId(gistId)) throw new Error('Invalid gist ID.');
10✔
277

278
  const octo = getAuthenticatedOctokit();
2✔
279
  await octo.gists.delete({ gist_id: gistId });
2✔
280
  return { success: true };
2✔
281
}
282

283
interface GistLoadResult {
284
  files: Record<
285
    string,
286
    { filename: string; content: string; truncated: boolean; rawUrl?: string }
287
  >;
288
  id: string;
289
  revision?: string;
290
}
291

292
async function handleGistLoad(
293
  _event: IpcMainInvokeEvent,
294
  params: unknown,
295
): Promise<GistLoadResult> {
296
  if (typeof params !== 'object' || params === null)
20✔
297
    throw new Error('Invalid parameters.');
1✔
298

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

301
  if (!isValidGistId(gistId)) throw new Error('Invalid gist ID.');
19✔
302
  if (revision !== undefined && !isValidSha(revision))
11✔
303
    throw new Error('Invalid revision SHA.');
5✔
304

305
  const octo = getOctokit();
6✔
306
  const gist = revision
6✔
307
    ? await octo.gists.getRevision({ gist_id: gistId, sha: revision })
308
    : await octo.gists.get({ gist_id: gistId });
309

310
  const files: GistLoadResult['files'] = {};
4✔
311
  for (const [id, data] of Object.entries(gist.data.files ?? {})) {
4✔
312
    if (!data) continue;
6✔
313

314
    // When GitHub truncates a large file, data.content is incomplete.
315
    // Fetch the full content from raw_url instead.
316
    let content = data.content ?? '';
6✔
317
    if (data.truncated && data.raw_url) {
6✔
318
      const response = await fetch(data.raw_url);
1✔
319
      if (response.ok) {
1✔
320
        content = await response.text();
1✔
321
      }
322
    }
323

324
    files[id] = {
6✔
325
      filename: data.filename ?? id,
326
      content,
327
      truncated: false,
328
      rawUrl: data.raw_url ?? undefined,
329
    };
330
  }
331

332
  return {
6✔
333
    files,
334
    id: gist.data.id!,
335
    revision: gist.data.history?.[0]?.version,
336
  };
337
}
338

339
async function handleGistListCommits(
340
  _event: IpcMainInvokeEvent,
341
  gistId: unknown,
342
): Promise<GistRevision[]> {
343
  if (!isValidGistId(gistId)) throw new Error('Invalid gist ID.');
9✔
344

345
  const octo = getOctokit();
1✔
346
  const { data: revisions } = await octo.gists.listCommits({
1✔
347
    gist_id: gistId,
348
  });
349

350
  const oldestRevision = revisions[revisions.length - 1];
1✔
351
  const nonEmptyRevisions = revisions.filter(
1✔
352
    (r) =>
353
      r === oldestRevision ||
2✔
354
      (r.change_status.additions ?? 0) > 0 ||
355
      (r.change_status.deletions ?? 0) > 0,
356
  );
357

358
  return nonEmptyRevisions.reverse().map((r, i) => ({
2✔
359
    sha: r.version,
360
    date: r.committed_at,
361
    changes: {
362
      total: r.change_status.total ?? 0,
363
      additions: r.change_status.additions ?? 0,
364
      deletions: r.change_status.deletions ?? 0,
365
    },
366
    title: i === 0 ? 'Created' : `Revision ${i}`,
367
  }));
368
}
369

370
async function handleFetchExample(
371
  _event: IpcMainInvokeEvent,
372
  params: unknown,
373
): Promise<EditorValues> {
NEW
374
  if (typeof params !== 'object' || params === null)
×
NEW
375
    throw new Error('Invalid parameters.');
×
NEW
376
  const { ref, path } = params as Record<string, unknown>;
×
NEW
377
  if (typeof ref !== 'string') throw new Error('Invalid ref.');
×
NEW
378
  if (typeof path !== 'string') throw new Error('Invalid path.');
×
NEW
379
  return fetchExample(ref, path);
×
380
}
381

382
async function fetchExample(ref: string, path: string): Promise<EditorValues> {
383
  if (!ref) throw new Error('Invalid ref.');
9✔
384
  if (!path) throw new Error('Invalid path.');
8✔
385

386
  // `repos.getContent` returns a union; the directory variant is the array form.
387
  type RepoContentEntry = Extract<
388
    RestEndpointMethodTypes['repos']['getContent']['response']['data'],
389
    readonly unknown[]
390
  >[number];
391

392
  const owner = 'electron';
7✔
393
  const repo = 'electron';
7✔
394
  const octo = getOctokit();
7✔
395

396
  // Fetch the example folder listing.
397
  const folder = await octo.repos.getContent({ owner, path, ref, repo });
7✔
398
  if (!Array.isArray(folder.data))
6✔
399
    throw new Error(`${owner}:${repo}/${path}:${ref} is not a valid example`);
6✔
400
  const files = (folder.data as RepoContentEntry[]).filter(
5✔
401
    (file) =>
402
      typeof file.download_url === 'string' &&
9✔
403
      typeof file.name === 'string' &&
404
      isSupportedFile(file.name),
405
  );
406

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

411
  // Download each supported file and overlay onto the template.
412
  await Promise.all(
5✔
413
    files.map(async (file) => {
414
      const resp = await fetch(file.download_url as string);
6✔
415
      if (!resp.ok)
6✔
416
        throw new Error(`Failed to download ${file.name}: ${resp.status}`);
1✔
417
      values[file.name as keyof EditorValues] = await resp.text();
5✔
418
    }),
419
  );
420

421
  return values;
4✔
422
}
423

424
// --- Setup ---
425

426
export function setupGitHub() {
427
  ipcMainManager.handle(IpcEvents.GITHUB_FETCH_EXAMPLE, handleFetchExample);
3✔
428
  ipcMainManager.handle(IpcEvents.GITHUB_GIST_CREATE, handleGistCreate);
3✔
429
  ipcMainManager.handle(IpcEvents.GITHUB_GIST_DELETE, handleGistDelete);
3✔
430
  ipcMainManager.handle(
3✔
431
    IpcEvents.GITHUB_GIST_LIST_COMMITS,
432
    handleGistListCommits,
433
  );
434
  ipcMainManager.handle(IpcEvents.GITHUB_GIST_LOAD, handleGistLoad);
3✔
435
  ipcMainManager.handle(IpcEvents.GITHUB_GIST_UPDATE, handleGistUpdate);
3✔
436
  ipcMainManager.handle(
3✔
437
    IpcEvents.GITHUB_TOKEN_CHECK_AUTH,
438
    handleTokenCheckAuth,
439
  );
440
  ipcMainManager.handle(IpcEvents.GITHUB_TOKEN_SIGN_IN, handleTokenSignIn);
3✔
441
  ipcMainManager.handle(IpcEvents.GITHUB_TOKEN_SIGN_OUT, handleTokenSignOut);
3✔
442
}
443

444
// Exported for testing
445
export const testing = {
2✔
446
  fetchExample,
447
  handleGistCreate,
448
  handleGistDelete,
449
  handleGistListCommits,
450
  handleGistLoad,
451
  handleGistUpdate,
452
  handleTokenCheckAuth,
453
  handleTokenSignIn,
454
  handleTokenSignOut,
455
};
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