@alien-protocol/cannon 2.2.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/bin/cli.js ADDED
@@ -0,0 +1,410 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * bin/cli.js — @alien-protocol/cannon CLI
4
+ *
5
+ * ─── Auth ───────────────────────────────────────────────────────
6
+ * cannon auth login OAuth login via GitHub (no token needed)
7
+ * cannon auth status Show who is currently logged in
8
+ * cannon auth logout Remove saved token
9
+ *
10
+ * ─── Setup ──────────────────────────────────────────────────────
11
+ * cannon init Create a cannon.config.json with defaults
12
+ *
13
+ * ─── Run ────────────────────────────────────────────────────────
14
+ * cannon fire Fire using cannon.config.json
15
+ * cannon fire --dry-run Preview without creating
16
+ * cannon fire --unsafe Skip delays (risky!)
17
+ * cannon fire --source csv --file ./issues.csv (override config)
18
+ *
19
+ * ─── Validate ───────────────────────────────────────────────────
20
+ * cannon validate Validate your config + issues file
21
+ */
22
+
23
+ import { program, Command } from 'commander';
24
+ import { IssueCannon } from '../src/cannon.js';
25
+ import { login, status, logout } from '../src/auth.js';
26
+ import { readFileSync, writeFileSync, existsSync } from 'fs';
27
+ import { createRequire } from 'module';
28
+ import path from 'path';
29
+
30
+ const require = createRequire(import.meta.url);
31
+ const pkg = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf-8'));
32
+
33
+ const c = {
34
+ reset: '\x1b[0m',
35
+ bold: '\x1b[1m',
36
+ dim: '\x1b[2m',
37
+ green: '\x1b[32m',
38
+ yellow: '\x1b[33m',
39
+ red: '\x1b[31m',
40
+ cyan: '\x1b[36m',
41
+ blue: '\x1b[34m',
42
+ magenta: '\x1b[35m',
43
+ };
44
+
45
+ // ─────────────────────────────────────────────────────────────────
46
+ // cannon auth
47
+ // ─────────────────────────────────────────────────────────────────
48
+ const auth = new Command('auth').description(
49
+ 'Manage GitHub authentication (login / status / logout)'
50
+ );
51
+
52
+ auth
53
+ .command('login')
54
+ .description('Login via GitHub OAuth — no token or .env file needed')
55
+ .action(async () => {
56
+ try {
57
+ await login();
58
+ } catch (e) {
59
+ console.error(`\n ${c.red}✖${c.reset} ${e.message}\n`);
60
+ process.exit(1);
61
+ }
62
+ });
63
+
64
+ auth
65
+ .command('status')
66
+ .description('Show who is currently logged in')
67
+ .action(async () => {
68
+ try {
69
+ await status();
70
+ } catch (e) {
71
+ console.error(`\n ${c.red}✖${c.reset} ${e.message}\n`);
72
+ process.exit(1);
73
+ }
74
+ });
75
+
76
+ auth
77
+ .command('logout')
78
+ .description('Remove saved GitHub token')
79
+ .action(() => logout());
80
+
81
+ // ─────────────────────────────────────────────────────────────────
82
+ // cannon init — scaffolds a default cannon.config.json
83
+ // ─────────────────────────────────────────────────────────────────
84
+ program
85
+ .command('init')
86
+ .description('Create a cannon.config.json with all default settings')
87
+ .option('--force', 'Overwrite existing cannon.config.json', false)
88
+ .action((opts) => {
89
+ const dest = path.join(process.cwd(), 'cannon.config.json');
90
+
91
+ if (existsSync(dest) && !opts.force) {
92
+ console.log(`\n ${c.yellow}⚠${c.reset} cannon.config.json already exists.`);
93
+ console.log(` Use ${c.bold}cannon init --force${c.reset} to overwrite it.\n`);
94
+ process.exit(0);
95
+ }
96
+
97
+ const config = {
98
+ source: {
99
+ type: 'csv',
100
+ file: './issues.csv',
101
+ },
102
+ mode: {
103
+ safeMode: true,
104
+ dryRun: false,
105
+ resumable: true,
106
+ },
107
+ delay: {
108
+ min: 4,
109
+ max: 8,
110
+ },
111
+ };
112
+
113
+ writeFileSync(dest, JSON.stringify(config, null, 2));
114
+
115
+ console.log(`\n ${c.green}✔${c.reset} Created ${c.bold}cannon.config.json${c.reset}\n`);
116
+ console.log(` ${c.dim}Next steps:${c.reset}`);
117
+ console.log(
118
+ ` 1. Set ${c.bold}source.file${c.reset} to your issues file (or change ${c.bold}source.type${c.reset} for other formats)`
119
+ );
120
+ console.log(` 2. Run ${c.bold}cannon auth login${c.reset}`);
121
+ console.log(` 3. Run ${c.bold}cannon fire --preview${c.reset} to test`);
122
+ console.log(` 4. Run ${c.bold}cannon fire${c.reset} to create issues\n`);
123
+ });
124
+
125
+ // ─────────────────────────────────────────────────────────────────
126
+ // cannon validate — check config + source file before firing
127
+ // ─────────────────────────────────────────────────────────────────
128
+ program
129
+ .command('validate')
130
+ .description('Check your config and issues file for problems')
131
+ .action(async () => {
132
+ console.log(`\n${c.bold}${c.blue}🔍 Validating…${c.reset}\n`);
133
+ let ok = true;
134
+
135
+ // 1. Config file
136
+ const configPath = path.join(process.cwd(), 'cannon.config.json');
137
+ if (!existsSync(configPath)) {
138
+ console.log(` ${c.red}✖${c.reset} cannon.config.json not found`);
139
+ console.log(` Run: ${c.bold}cannon init${c.reset} to create it\n`);
140
+ ok = false;
141
+ } else {
142
+ try {
143
+ const raw = readFileSync(configPath, 'utf-8');
144
+ JSON.parse(raw);
145
+ console.log(` ${c.green}✔${c.reset} cannon.config.json ${c.dim}(valid JSON)${c.reset}`);
146
+ } catch (e) {
147
+ console.log(` ${c.red}✖${c.reset} cannon.config.json has invalid JSON: ${e.message}`);
148
+ ok = false;
149
+ }
150
+ }
151
+
152
+ // 2. Token
153
+ try {
154
+ const { loadConfig } = await import('../src/config.js');
155
+ const cfg = loadConfig();
156
+ if (!cfg.github.token) {
157
+ console.log(` ${c.yellow}⚠${c.reset} No GitHub token found`);
158
+ console.log(` Run: ${c.bold}cannon auth login${c.reset} or set GITHUB_TOKEN in .env`);
159
+ ok = false;
160
+ } else {
161
+ console.log(` ${c.green}✔${c.reset} GitHub token ${c.dim}(found)${c.reset}`);
162
+ }
163
+
164
+ // 3. Source file exists
165
+ if (['csv', 'json', 'pdf', 'docx', 'sqlite'].includes(cfg.source?.type)) {
166
+ const fp = cfg.source?.file;
167
+ if (!fp) {
168
+ console.log(` ${c.red}✖${c.reset} source.file is empty in cannon.config.json`);
169
+ ok = false;
170
+ } else if (!existsSync(path.resolve(fp))) {
171
+ console.log(` ${c.red}✖${c.reset} source.file not found: ${fp}`);
172
+ ok = false;
173
+ } else {
174
+ console.log(` ${c.green}✔${c.reset} source.file ${c.dim}${fp}${c.reset}`);
175
+ }
176
+ }
177
+
178
+ // 4. Safe mode warning
179
+ if (!cfg.mode?.safeMode) {
180
+ console.log(` ${c.yellow}⚠${c.reset} safeMode is OFF — issues will fire with no delays`);
181
+ console.log(
182
+ ` ${c.dim}Set mode.safeMode = true in cannon.config.json for safety${c.reset}`
183
+ );
184
+ } else {
185
+ console.log(
186
+ ` ${c.green}✔${c.reset} safeMode ${c.dim}(on — random delays enabled)${c.reset}`
187
+ );
188
+ }
189
+
190
+ // 5. Load + count issues
191
+ if (ok) {
192
+ try {
193
+ const { loadIssues } = await import('../src/loaders/index.js');
194
+ const issues = await loadIssues({
195
+ source: cfg.source.type,
196
+ file: cfg.source.file,
197
+ query: cfg.source.query,
198
+ connectionString: cfg.source.connectionString,
199
+ });
200
+ console.log(
201
+ ` ${c.green}✔${c.reset} Issues loaded ${c.dim}(${issues.length} issue(s) found)${c.reset}`
202
+ );
203
+ } catch (e) {
204
+ console.log(` ${c.red}✖${c.reset} Could not load issues: ${e.message}`);
205
+ ok = false;
206
+ }
207
+ }
208
+ } catch (e) {
209
+ console.log(` ${c.red}✖${c.reset} Config error: ${e.message}`);
210
+ ok = false;
211
+ }
212
+
213
+ console.log('');
214
+ if (ok) {
215
+ console.log(
216
+ ` ${c.green}${c.bold}All checks passed!${c.reset} Run ${c.bold}cannon fire --dry-run${c.reset} to preview.\n`
217
+ );
218
+ } else {
219
+ console.log(
220
+ ` ${c.red}${c.bold}Issues found.${c.reset} Fix the errors above and run ${c.bold}cannon validate${c.reset} again.\n`
221
+ );
222
+ process.exit(1);
223
+ }
224
+ });
225
+
226
+ // ─────────────────────────────────────────────────────────────────
227
+ // cannon fire — main command
228
+ // ─────────────────────────────────────────────────────────────────
229
+ program
230
+ .command('fire')
231
+ .description('Create GitHub issues from your cannon.config.json')
232
+
233
+ // ── What to fire ──────────────────────────────────────────────
234
+ .option('-s, --source <type>', 'Source type: csv | json | pdf | docx | sqlite | postgres | mysql')
235
+ .option('-f, --file <path>', 'Path to your issues file')
236
+ .option('-q, --query <sql>', 'SQL query (database sources only)')
237
+
238
+ // ── How to fire ───────────────────────────────────────────────
239
+ .option('--preview', 'Dry run — show what would be created, nothing is actually made')
240
+ .option('--unsafe', 'No delays between issues (fast but risky — GitHub may flag as spam)')
241
+ .option('--delay <minutes>', 'Fixed delay between issues in minutes, e.g. --delay 2')
242
+ .option('--fresh', 'Ignore saved progress and start from the beginning')
243
+
244
+ .action(async (opts) => {
245
+ const overrides = {};
246
+
247
+ // --preview = dry run, also skips sleep timer
248
+ if (opts.preview) {
249
+ overrides.dryRun = true;
250
+ overrides.preview = true; // passed through to cannon to skip countdown
251
+ }
252
+
253
+ // --unsafe = no delays
254
+ if (opts.unsafe) overrides.safeMode = false;
255
+
256
+ // --fresh = ignore resume state
257
+ if (opts.fresh) overrides.resumable = false;
258
+
259
+ // --delay <minutes> = fixed delay override
260
+ if (opts.delay) {
261
+ const mins = parseFloat(opts.delay);
262
+ if (isNaN(mins) || mins < 0) {
263
+ console.error(
264
+ `\n ${c.red}✖${c.reset} --delay must be a number in minutes, e.g. --delay 2\n`
265
+ );
266
+ process.exit(1);
267
+ }
268
+ overrides.delay = { mode: 'fixed', fixedMs: Math.round(mins * 60_000) };
269
+ }
270
+
271
+ const cannon = new IssueCannon(overrides);
272
+
273
+ // Source overrides (CLI flags beat cannon.config.json)
274
+ const sourceOpts = {};
275
+ if (opts.source) sourceOpts.source = opts.source;
276
+ if (opts.file) sourceOpts.file = opts.file;
277
+ if (opts.query) sourceOpts.query = opts.query;
278
+
279
+ try {
280
+ await cannon.fire(sourceOpts);
281
+ } catch (err) {
282
+ console.error(`\n ${c.red}✖${c.reset} ${err.message}\n`);
283
+ process.exit(1);
284
+ }
285
+ });
286
+
287
+ // ─────────────────────────────────────────────────────────────────
288
+ // Main program (root)
289
+ // ─────────────────────────────────────────────────────────────────
290
+ program
291
+ .name('cannon')
292
+ .description(
293
+ `${c.bold}${c.magenta}🛸 @alien-protocol/cannon${c.reset} — Bulk-create GitHub issues`
294
+ )
295
+ .version(pkg.version)
296
+ .addCommand(auth);
297
+
298
+ // Show a friendly welcome when run with no arguments
299
+ program.action(() => {
300
+ console.log(`
301
+ ${c.bold}${c.magenta}🛸 @alien-protocol/cannon${c.reset} v${pkg.version}
302
+
303
+ ${c.bold}First time? 4 steps:${c.reset}
304
+
305
+ ${c.cyan}1.${c.reset} ${c.bold}cannon init${c.reset} ${c.dim}Create your config file${c.reset}
306
+ ${c.cyan}2.${c.reset} ${c.bold}cannon auth login${c.reset} ${c.dim}Login with GitHub${c.reset}
307
+ ${c.cyan}3.${c.reset} ${c.bold}cannon fire --preview${c.reset} ${c.dim}Preview what will be created${c.reset}
308
+ ${c.cyan}4.${c.reset} ${c.bold}cannon fire${c.reset} ${c.dim}Create your issues!${c.reset}
309
+
310
+ ${c.bold}Common flags:${c.reset}
311
+
312
+ ${c.bold}--preview${c.reset} ${c.dim}Dry run — nothing created${c.reset}
313
+ ${c.bold}--unsafe${c.reset} ${c.dim}No delays (fast, risky)${c.reset}
314
+ ${c.bold}--delay 2${c.reset} ${c.dim}Wait 2 minutes between issues${c.reset}
315
+ ${c.bold}--fresh${c.reset} ${c.dim}Ignore saved progress, start over${c.reset}
316
+
317
+ ${c.bold}Use a different file without editing config:${c.reset}
318
+
319
+ ${c.bold}cannon fire -s csv -f ./issues.csv${c.reset}
320
+ ${c.bold}cannon fire -s json -f ./issues.json --preview${c.reset}
321
+
322
+ ${c.dim}Help:${c.reset} cannon fire --help | ${c.dim}Docs:${c.reset} https://github.com/Alien-Protocol/Cannon
323
+ `);
324
+ });
325
+
326
+ program.parse(process.argv);
327
+
328
+ // dead code kept only so the module doesn't error if imported elsewhere
329
+ function getDefaultConfig() {
330
+ return {
331
+ _readme:
332
+ "Cannon config. Edit this file then run 'cannon fire'. Docs: https://github.com/Alien-Protocol/Cannon",
333
+
334
+ github: {
335
+ token: '',
336
+ _tokenNote:
337
+ "LEAVE BLANK — use 'cannon auth login' (OAuth) or GITHUB_TOKEN env var. Never put your real token here.",
338
+ },
339
+
340
+ source: {
341
+ type: 'csv',
342
+ _typeNote: 'Options: csv | json | pdf | docx | postgres | mysql | sqlite',
343
+ file: './issues.csv',
344
+ _fileNote: 'Path to your issues file (csv, json, pdf, docx, sqlite).',
345
+ query: '',
346
+ _queryNote: 'SQL query string (postgres, mysql, sqlite).',
347
+ connectionString: '',
348
+ _connectionStringNote: 'DB URL. Use env vars like ${POSTGRES_URL} — never hardcode secrets.',
349
+ },
350
+
351
+ mode: {
352
+ dryRun: false,
353
+ _dryRunNote: 'true = preview only, nothing is created. Useful for testing.',
354
+ safeMode: true,
355
+ _safeModeNote:
356
+ 'true = random delays between issues (RECOMMENDED). false = fire immediately (risky).',
357
+ resumable: true,
358
+ _resumableNote: 'true = saves progress so you can stop and restart without duplicates.',
359
+ },
360
+
361
+ delay: {
362
+ _note: 'Only applies when mode.safeMode = true',
363
+ mode: 'random',
364
+ _modeNote: 'random = between minMs and maxMs | fixed = always fixedMs',
365
+ minMs: 240000,
366
+ _minMsNote: 'Minimum delay ms. Default 240000 (4 min). Do not go below 60000.',
367
+ maxMs: 480000,
368
+ _maxMsNote: 'Maximum delay ms. Default 480000 (8 min).',
369
+ fixedMs: 300000,
370
+ _fixedMsNote: "Fixed delay ms when mode = 'fixed'. Default 300000 (5 min).",
371
+ },
372
+
373
+ labels: {
374
+ autoCreate: false,
375
+ _autoCreateNote: 'true = auto-create any missing labels in GitHub.',
376
+ colorMap: {},
377
+ _colorMapNote: "Map label names to hex colors. e.g. { 'bug': 'ee0701', 'feature': '0075ca' }",
378
+ },
379
+
380
+ output: {
381
+ logFile: '',
382
+ _logFileNote: "Optional path for a JSON log of results. e.g. './cannon-log.json'",
383
+ showTable: true,
384
+ _showTableNote: 'true = show a summary table after completion.',
385
+ },
386
+
387
+ _examples: {
388
+ _note: "Copy one block into 'source' above to change your data source.",
389
+ csv: { type: 'csv', file: './issues.csv' },
390
+ json: { type: 'json', file: './issues.json' },
391
+ pdf: { type: 'pdf', file: './issues.pdf' },
392
+ docx: { type: 'docx', file: './issues.docx' },
393
+ postgres: {
394
+ type: 'postgres',
395
+ connectionString: '${POSTGRES_URL}',
396
+ query: 'SELECT repo, title, body, labels, milestone FROM backlog WHERE exported = false',
397
+ },
398
+ mysql: {
399
+ type: 'mysql',
400
+ connectionString: '${MYSQL_URL}',
401
+ query: "SELECT repo, title, body, labels, milestone FROM issues WHERE status = 'pending'",
402
+ },
403
+ sqlite: {
404
+ type: 'sqlite',
405
+ file: './backlog.db',
406
+ query: 'SELECT repo, title, body, labels, milestone FROM issues',
407
+ },
408
+ },
409
+ };
410
+ }
@@ -0,0 +1,17 @@
1
+ {
2
+ "source": {
3
+ "type": "csv",
4
+ "file": "./issues.csv"
5
+ },
6
+
7
+ "mode": {
8
+ "safeMode": true,
9
+ "dryRun": false,
10
+ "resumable": true
11
+ },
12
+
13
+ "delay": {
14
+ "min": 1,
15
+ "max": 3
16
+ }
17
+ }
package/package.json ADDED
@@ -0,0 +1,72 @@
1
+ {
2
+ "name": "@alien-protocol/cannon",
3
+ "version": "2.2.2",
4
+ "publishConfig": {
5
+ "access": "public"
6
+ },
7
+ "description": "Bulk-create GitHub issues from CSV, PDF, DOCX, JSON or any database — with OAuth login — by Alien Protocol",
8
+ "type": "module",
9
+ "main": "./src/index.js",
10
+ "exports": {
11
+ ".": "./src/index.js"
12
+ },
13
+ "bin": {
14
+ "cannon": "bin/cli.js"
15
+ },
16
+ "files": [
17
+ "bin/",
18
+ "src/",
19
+ "README.md",
20
+ "cannon.config.example.json",
21
+ ".env.example"
22
+ ],
23
+ "scripts": {
24
+ "start": "node bin/cli.js",
25
+ "format": "prettier --write .",
26
+ "format:check": "prettier --check ."
27
+ },
28
+ "keywords": [
29
+ "github",
30
+ "issues",
31
+ "bulk",
32
+ "automation",
33
+ "csv",
34
+ "oauth",
35
+ "cli",
36
+ "alien-protocol"
37
+ ],
38
+ "author": "Alien Protocol <https://github.com/Alien-Protocol>",
39
+ "license": "MIT",
40
+ "repository": {
41
+ "type": "git",
42
+ "url": "git+https://github.com/Alien-Protocol/Cannon.git"
43
+ },
44
+ "homepage": "https://github.com/Alien-Protocol/Cannon#readme",
45
+ "bugs": {
46
+ "url": "https://github.com/Alien-Protocol/Cannon/issues"
47
+ },
48
+ "dependencies": {
49
+ "chalk": "^5.3.0",
50
+ "commander": "^12.0.0",
51
+ "conf": "^13.0.0",
52
+ "csv-parse": "^5.5.5",
53
+ "dotenv": "^16.4.5",
54
+ "mammoth": "^1.7.2",
55
+ "mysql2": "^3.9.7",
56
+ "ora": "^8.0.1",
57
+ "pdf-parse": "^1.1.1",
58
+ "pg": "^8.11.5",
59
+ "sql.js": "^1.12.0"
60
+ },
61
+ "engines": {
62
+ "node": ">=18.0.0"
63
+ },
64
+ "devDependencies": {
65
+ "@semantic-release/commit-analyzer": "^13.0.1",
66
+ "@semantic-release/github": "^12.0.6",
67
+ "@semantic-release/npm": "^13.1.5",
68
+ "@semantic-release/release-notes-generator": "^14.1.0",
69
+ "prettier": "^3.8.1",
70
+ "semantic-release": "^25.0.3"
71
+ }
72
+ }
package/src/auth.js ADDED
@@ -0,0 +1,188 @@
1
+ import fs from 'fs';
2
+ import path from 'path';
3
+ import os from 'os';
4
+
5
+ // ─────────────────────────────────────────────
6
+ // GitHub OAuth App client_id
7
+ // This is a PUBLIC value — safe to ship in source.
8
+ // Create your own app at: github.com/settings/developers → OAuth Apps → New
9
+ // Homepage URL: https://github.com/Alien-Protocol/Cannon
10
+ // Callback URL: http://localhost (not used — Device Flow doesn't redirect)
11
+ // Device Flow: ✅ Enable Device Flow
12
+ // Then set CANNON_CLIENT_ID env var or replace the fallback string below.
13
+ // ─────────────────────────────────────────────
14
+ const CLIENT_ID = 'Ov23li9tDhpIemxGcKs6';
15
+ const SCOPES = 'repo';
16
+
17
+ const CONFIG_DIR = path.join(os.homedir(), '.cannon');
18
+ const CREDS_FILE = path.join(CONFIG_DIR, 'credentials.json');
19
+
20
+ const c = {
21
+ reset: '\x1b[0m',
22
+ bold: '\x1b[1m',
23
+ dim: '\x1b[2m',
24
+ green: '\x1b[32m',
25
+ yellow: '\x1b[33m',
26
+ red: '\x1b[31m',
27
+ cyan: '\x1b[36m',
28
+ blue: '\x1b[34m',
29
+ magenta: '\x1b[35m',
30
+ };
31
+
32
+ export async function login() {
33
+ console.log(`\n${c.bold}${c.magenta}🛸 Cannon — GitHub Login${c.reset}\n`);
34
+
35
+ if (CLIENT_ID === 'YOUR_GITHUB_OAUTH_CLIENT_ID') {
36
+ console.log(` ${c.red}✖${c.reset} OAuth App not configured.\n`);
37
+ console.log(` ${c.bold}Option A — Set env var:${c.reset}`);
38
+ console.log(` export CANNON_CLIENT_ID=your_client_id\n`);
39
+ console.log(` ${c.bold}Option B — Create a GitHub OAuth App:${c.reset}`);
40
+ console.log(` https://github.com/settings/developers → OAuth Apps → New\n`);
41
+ console.log(` ${c.bold}Option C — Use a token directly (no OAuth needed):${c.reset}`);
42
+ console.log(` echo "GITHUB_TOKEN=ghp_xxx" > .env\n`);
43
+ process.exit(1);
44
+ }
45
+
46
+ // 1. Request device + user codes
47
+ const deviceRes = await fetch('https://github.com/login/device/code', {
48
+ method: 'POST',
49
+ headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
50
+ body: JSON.stringify({ client_id: CLIENT_ID, scope: SCOPES }),
51
+ });
52
+
53
+ if (!deviceRes.ok) {
54
+ throw new Error(`GitHub device flow failed: ${deviceRes.status}`);
55
+ }
56
+
57
+ const { device_code, user_code, verification_uri, expires_in, interval } = await deviceRes.json();
58
+
59
+ // 2. Show code to user
60
+ console.log(` ${c.bold}Step 1${c.reset} — Open this URL:\n`);
61
+ console.log(` ${c.blue}${c.bold}${verification_uri}${c.reset}\n`);
62
+ console.log(` ${c.bold}Step 2${c.reset} — Enter this code:\n`);
63
+ console.log(` ${c.cyan}${c.bold} ${user_code} ${c.reset}\n`);
64
+ console.log(` ${c.dim}Expires in ${Math.floor(expires_in / 60)} minutes${c.reset}\n`);
65
+
66
+ // Try auto-open browser
67
+ _openBrowser(verification_uri);
68
+
69
+ // 3. Poll for token
70
+ process.stdout.write(` ${c.dim}⏳ Waiting for authorization…${c.reset}`);
71
+ const token = await _poll(device_code, interval || 5);
72
+ process.stdout.write('\r' + ' '.repeat(55) + '\r');
73
+
74
+ // 4. Get GitHub username
75
+ const user = await _getUser(token);
76
+
77
+ // 5. Save securely
78
+ _saveToken(token, user.login);
79
+
80
+ console.log(` ${c.green}✔${c.reset} Logged in as ${c.bold}${user.login}${c.reset}`);
81
+ console.log(` ${c.green}✔${c.reset} Token saved → ${c.dim}${CREDS_FILE}${c.reset}\n`);
82
+ console.log(
83
+ ` ${c.dim}No need to set GITHUB_TOKEN — cannon will use this automatically.${c.reset}\n`
84
+ );
85
+
86
+ return { token, username: user.login };
87
+ }
88
+
89
+ export async function status() {
90
+ const creds = _loadToken();
91
+
92
+ if (!creds) {
93
+ console.log(`\n ${c.yellow}⚠${c.reset} Not logged in.\n`);
94
+ console.log(` Run: ${c.bold}cannon auth login${c.reset} — OAuth (recommended)\n`);
95
+ console.log(` Or: echo "GITHUB_TOKEN=ghp_xxx" > .env\n`);
96
+ return null;
97
+ }
98
+
99
+ try {
100
+ const user = await _getUser(creds.token);
101
+ console.log(`\n ${c.green}✔${c.reset} Logged in as ${c.bold}${user.login}${c.reset}`);
102
+ console.log(` ${c.dim}Credentials: ${CREDS_FILE}${c.reset}`);
103
+ console.log(` ${c.dim}Saved at: ${creds.savedAt}${c.reset}\n`);
104
+ return user;
105
+ } catch {
106
+ console.log(`\n ${c.red}✖${c.reset} Token is invalid or expired.`);
107
+ console.log(` Run: ${c.bold}cannon auth login${c.reset}\n`);
108
+ return null;
109
+ }
110
+ }
111
+
112
+ export function logout() {
113
+ if (fs.existsSync(CREDS_FILE)) {
114
+ fs.unlinkSync(CREDS_FILE);
115
+ console.log(`\n ${c.green}✔${c.reset} Logged out.\n`);
116
+ } else {
117
+ console.log(`\n ${c.dim}Not logged in.${c.reset}\n`);
118
+ }
119
+ }
120
+
121
+ export function getSavedToken() {
122
+ return _loadToken()?.token ?? null;
123
+ }
124
+
125
+ async function _poll(deviceCode, intervalSec) {
126
+ const wait = (s) => new Promise((r) => setTimeout(r, s * 1000));
127
+ while (true) {
128
+ await wait(intervalSec);
129
+ const res = await fetch('https://github.com/login/oauth/access_token', {
130
+ method: 'POST',
131
+ headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
132
+ body: JSON.stringify({
133
+ client_id: CLIENT_ID,
134
+ device_code: deviceCode,
135
+ grant_type: 'urn:ietf:params:oauth:grant-type:device_code',
136
+ }),
137
+ });
138
+ const data = await res.json();
139
+ if (data.access_token) return data.access_token;
140
+ if (data.error === 'authorization_pending') continue;
141
+ if (data.error === 'slow_down') {
142
+ await wait(5);
143
+ continue;
144
+ }
145
+ if (data.error === 'expired_token') throw new Error('Code expired. Run: cannon auth login');
146
+ if (data.error === 'access_denied') throw new Error('Authorization denied.');
147
+ throw new Error(`OAuth error: ${data.error}`);
148
+ }
149
+ }
150
+
151
+ async function _getUser(token) {
152
+ const res = await fetch('https://api.github.com/user', {
153
+ headers: { Authorization: `Bearer ${token}`, Accept: 'application/vnd.github+json' },
154
+ });
155
+ if (!res.ok) throw new Error(`GitHub API ${res.status}`);
156
+ return res.json();
157
+ }
158
+
159
+ function _saveToken(token, username) {
160
+ if (!fs.existsSync(CONFIG_DIR)) fs.mkdirSync(CONFIG_DIR, { recursive: true });
161
+ fs.writeFileSync(
162
+ CREDS_FILE,
163
+ JSON.stringify({ token, username, savedAt: new Date().toISOString() }, null, 2),
164
+ { mode: 0o600 }
165
+ );
166
+ }
167
+
168
+ function _loadToken() {
169
+ try {
170
+ if (fs.existsSync(CREDS_FILE)) return JSON.parse(fs.readFileSync(CREDS_FILE, 'utf-8'));
171
+ } catch {}
172
+ return null;
173
+ }
174
+
175
+ function _openBrowser(url) {
176
+ try {
177
+ const { execSync } = require('child_process');
178
+ const cmd =
179
+ process.platform === 'darwin'
180
+ ? `open "${url}"`
181
+ : process.platform === 'win32'
182
+ ? `start "" "${url}"`
183
+ : `xdg-open "${url}"`;
184
+ execSync(cmd);
185
+ } catch {
186
+ /* user opens manually */
187
+ }
188
+ }