@fredlackey/devutils 0.0.19 → 0.1.0

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.
Files changed (122) hide show
  1. package/README.md +223 -32
  2. package/package.json +7 -5
  3. package/src/api/loader.js +229 -0
  4. package/src/api/registry.json +62 -0
  5. package/src/cli.js +305 -0
  6. package/src/commands/ai/index.js +16 -0
  7. package/src/commands/ai/launch.js +112 -0
  8. package/src/commands/ai/list.js +54 -0
  9. package/src/commands/ai/resume.js +70 -0
  10. package/src/commands/ai/sessions.js +121 -0
  11. package/src/commands/ai/set.js +131 -0
  12. package/src/commands/ai/show.js +74 -0
  13. package/src/commands/ai/tools.js +46 -0
  14. package/src/commands/alias/add.js +93 -0
  15. package/src/commands/alias/helpers.js +107 -0
  16. package/src/commands/alias/index.js +14 -0
  17. package/src/commands/alias/list.js +55 -0
  18. package/src/commands/alias/remove.js +62 -0
  19. package/src/commands/alias/sync.js +109 -0
  20. package/src/commands/api/disable.js +73 -0
  21. package/src/commands/api/enable.js +148 -0
  22. package/src/commands/api/index.js +15 -0
  23. package/src/commands/api/list.js +66 -0
  24. package/src/commands/api/update.js +87 -0
  25. package/src/commands/auth/index.js +15 -0
  26. package/src/commands/auth/list.js +49 -0
  27. package/src/commands/auth/login.js +384 -0
  28. package/src/commands/auth/logout.js +111 -0
  29. package/src/commands/auth/refresh.js +184 -0
  30. package/src/commands/auth/services.js +169 -0
  31. package/src/commands/auth/status.js +104 -0
  32. package/src/commands/config/export.js +224 -0
  33. package/src/commands/config/get.js +52 -0
  34. package/src/commands/config/import.js +308 -0
  35. package/src/commands/config/index.js +17 -0
  36. package/src/commands/config/init.js +143 -0
  37. package/src/commands/config/reset.js +57 -0
  38. package/src/commands/config/set.js +93 -0
  39. package/src/commands/config/show.js +35 -0
  40. package/src/commands/help.js +338 -0
  41. package/src/commands/identity/add.js +133 -0
  42. package/src/commands/identity/index.js +17 -0
  43. package/src/commands/identity/link.js +76 -0
  44. package/src/commands/identity/list.js +48 -0
  45. package/src/commands/identity/remove.js +72 -0
  46. package/src/commands/identity/show.js +65 -0
  47. package/src/commands/identity/sync.js +172 -0
  48. package/src/commands/identity/unlink.js +57 -0
  49. package/src/commands/ignore/add.js +165 -0
  50. package/src/commands/ignore/index.js +14 -0
  51. package/src/commands/ignore/list.js +89 -0
  52. package/src/commands/ignore/markers.js +43 -0
  53. package/src/commands/ignore/remove.js +164 -0
  54. package/src/commands/ignore/show.js +169 -0
  55. package/src/commands/machine/detect.js +122 -0
  56. package/src/commands/machine/index.js +14 -0
  57. package/src/commands/machine/list.js +74 -0
  58. package/src/commands/machine/set.js +106 -0
  59. package/src/commands/machine/show.js +35 -0
  60. package/src/commands/schema.js +152 -0
  61. package/src/commands/search/collections.js +134 -0
  62. package/src/commands/search/get.js +71 -0
  63. package/src/commands/search/index-cmd.js +54 -0
  64. package/src/commands/search/index.js +21 -0
  65. package/src/commands/search/keyword.js +60 -0
  66. package/src/commands/search/qmd.js +70 -0
  67. package/src/commands/search/query.js +64 -0
  68. package/src/commands/search/semantic.js +62 -0
  69. package/src/commands/search/status.js +46 -0
  70. package/src/commands/status.js +276 -0
  71. package/src/commands/tools/check.js +79 -0
  72. package/src/commands/tools/index.js +14 -0
  73. package/src/commands/tools/install.js +110 -0
  74. package/src/commands/tools/list.js +91 -0
  75. package/src/commands/tools/search.js +60 -0
  76. package/src/commands/update.js +113 -0
  77. package/src/commands/util/add.js +151 -0
  78. package/src/commands/util/index.js +15 -0
  79. package/src/commands/util/list.js +97 -0
  80. package/src/commands/util/remove.js +76 -0
  81. package/src/commands/util/run.js +79 -0
  82. package/src/commands/util/show.js +67 -0
  83. package/src/commands/version.js +33 -0
  84. package/src/installers/_template.js +104 -0
  85. package/src/installers/git.js +150 -0
  86. package/src/installers/homebrew.js +190 -0
  87. package/src/installers/node.js +223 -0
  88. package/src/installers/registry.json +29 -0
  89. package/src/lib/config.js +125 -0
  90. package/src/lib/detect.js +74 -0
  91. package/src/lib/errors.js +114 -0
  92. package/src/lib/github.js +315 -0
  93. package/src/lib/installer.js +225 -0
  94. package/src/lib/output.js +239 -0
  95. package/src/lib/platform.js +112 -0
  96. package/src/lib/platforms/amazon-linux.js +41 -0
  97. package/src/lib/platforms/gitbash.js +46 -0
  98. package/src/lib/platforms/macos.js +45 -0
  99. package/src/lib/platforms/raspbian.js +41 -0
  100. package/src/lib/platforms/ubuntu.js +39 -0
  101. package/src/lib/platforms/windows.js +45 -0
  102. package/src/lib/prompt.js +161 -0
  103. package/src/lib/schema.js +211 -0
  104. package/src/lib/shell.js +75 -0
  105. package/src/patterns/gitignore/claude-code.txt +25 -0
  106. package/src/patterns/gitignore/docker.txt +15 -0
  107. package/src/patterns/gitignore/go.txt +24 -0
  108. package/src/patterns/gitignore/java.txt +38 -0
  109. package/src/patterns/gitignore/jetbrains.txt +26 -0
  110. package/src/patterns/gitignore/linux.txt +18 -0
  111. package/src/patterns/gitignore/macos.txt +27 -0
  112. package/src/patterns/gitignore/node.txt +51 -0
  113. package/src/patterns/gitignore/python.txt +55 -0
  114. package/src/patterns/gitignore/rust.txt +14 -0
  115. package/src/patterns/gitignore/terraform.txt +30 -0
  116. package/src/patterns/gitignore/vscode.txt +15 -0
  117. package/src/patterns/gitignore/windows.txt +25 -0
  118. package/src/utils/clone/index.js +165 -0
  119. package/src/utils/git-push/index.js +230 -0
  120. package/src/utils/git-status/index.js +116 -0
  121. package/src/utils/git-status/unix.sh +75 -0
  122. package/src/utils/registry.json +41 -0
@@ -0,0 +1,384 @@
1
+ 'use strict';
2
+
3
+ const fs = require('fs');
4
+ const path = require('path');
5
+ const os = require('os');
6
+ const http = require('http');
7
+ const https = require('https');
8
+ const url = require('url');
9
+ const { AUTH_SERVICES, AUTH_DIR, CLIENTS_DIR, readCredential, isSensitiveField } = require('./services');
10
+ const platform = require('../../lib/platform');
11
+
12
+ const OAUTH_PORT = 9876;
13
+ const OAUTH_REDIRECT_URI = `http://localhost:${OAUTH_PORT}/callback`;
14
+ const OAUTH_TIMEOUT_MS = 60000;
15
+
16
+ const meta = {
17
+ description: 'Authenticate with an external service (OAuth browser flow or API key prompt)',
18
+ arguments: [
19
+ { name: 'service', description: 'Service name to authenticate with (e.g., google, aws, cloudflare)', required: true }
20
+ ],
21
+ flags: [
22
+ { name: 'scopes', type: 'string', description: 'Comma-separated OAuth scopes to request (OAuth services only)' }
23
+ ]
24
+ };
25
+
26
+ /**
27
+ * Open a URL in the user's default browser.
28
+ * Uses the appropriate command for the detected OS.
29
+ *
30
+ * @param {string} targetUrl - The URL to open.
31
+ * @returns {Promise<void>}
32
+ */
33
+ async function openBrowser(targetUrl) {
34
+ const shell = require('../../lib/shell');
35
+ const plat = platform.detect();
36
+ let cmd;
37
+
38
+ if (plat.type === 'macos') {
39
+ cmd = `open "${targetUrl}"`;
40
+ } else if (plat.type === 'windows' || plat.type === 'gitbash') {
41
+ cmd = `start "" "${targetUrl}"`;
42
+ } else {
43
+ // Linux variants
44
+ cmd = `xdg-open "${targetUrl}"`;
45
+ }
46
+
47
+ await shell.exec(cmd);
48
+ }
49
+
50
+ /**
51
+ * Make an HTTPS POST request with form-encoded data.
52
+ * Uses Node.js built-in https module (no external dependencies).
53
+ *
54
+ * @param {string} targetUrl - The URL to POST to.
55
+ * @param {Object<string, string>} data - Key/value pairs to send as form data.
56
+ * @returns {Promise<{ statusCode: number, body: string }>}
57
+ */
58
+ function httpsPost(targetUrl, data) {
59
+ return new Promise((resolve, reject) => {
60
+ const postData = new URLSearchParams(data).toString();
61
+ const parsed = new URL(targetUrl);
62
+
63
+ const options = {
64
+ hostname: parsed.hostname,
65
+ port: parsed.port || 443,
66
+ path: parsed.pathname + parsed.search,
67
+ method: 'POST',
68
+ headers: {
69
+ 'Content-Type': 'application/x-www-form-urlencoded',
70
+ 'Content-Length': Buffer.byteLength(postData)
71
+ }
72
+ };
73
+
74
+ const req = https.request(options, (res) => {
75
+ let body = '';
76
+ res.on('data', (chunk) => { body += chunk; });
77
+ res.on('end', () => {
78
+ resolve({ statusCode: res.statusCode, body });
79
+ });
80
+ });
81
+
82
+ req.on('error', reject);
83
+ req.write(postData);
84
+ req.end();
85
+ });
86
+ }
87
+
88
+ /**
89
+ * Start a temporary local HTTP server to capture the OAuth callback.
90
+ * The server listens on OAUTH_PORT, waits for the authorization code,
91
+ * and shuts down after receiving it (or after a timeout).
92
+ *
93
+ * @returns {Promise<string>} The authorization code from the callback.
94
+ */
95
+ function waitForOAuthCallback() {
96
+ return new Promise((resolve, reject) => {
97
+ const server = http.createServer((req, res) => {
98
+ const parsed = url.parse(req.url, true);
99
+
100
+ if (parsed.pathname === '/callback') {
101
+ const code = parsed.query.code;
102
+ const error = parsed.query.error;
103
+
104
+ if (error) {
105
+ res.writeHead(200, { 'Content-Type': 'text/html' });
106
+ res.end('<html><body><h2>Authentication failed</h2><p>You can close this window.</p></body></html>');
107
+ server.close();
108
+ reject(new Error(`OAuth error: ${error}`));
109
+ return;
110
+ }
111
+
112
+ if (code) {
113
+ res.writeHead(200, { 'Content-Type': 'text/html' });
114
+ res.end('<html><body><h2>Authentication successful!</h2><p>You can close this window and return to the terminal.</p></body></html>');
115
+ server.close();
116
+ resolve(code);
117
+ return;
118
+ }
119
+
120
+ res.writeHead(400, { 'Content-Type': 'text/plain' });
121
+ res.end('Missing authorization code.');
122
+ } else {
123
+ res.writeHead(404, { 'Content-Type': 'text/plain' });
124
+ res.end('Not found.');
125
+ }
126
+ });
127
+
128
+ // Set a timeout so the server doesn't hang forever
129
+ const timeout = setTimeout(() => {
130
+ server.close();
131
+ reject(new Error('OAuth callback timed out. Please try again.'));
132
+ }, OAUTH_TIMEOUT_MS);
133
+
134
+ server.on('close', () => {
135
+ clearTimeout(timeout);
136
+ });
137
+
138
+ server.listen(OAUTH_PORT, () => {
139
+ // Server is ready; the caller will open the browser
140
+ });
141
+ });
142
+ }
143
+
144
+ /**
145
+ * Handle OAuth login flow for a service (e.g., Google).
146
+ * Opens the browser for consent, waits for the callback, exchanges the
147
+ * authorization code for tokens, and saves them to disk.
148
+ *
149
+ * @param {string} service - The service name.
150
+ * @param {object} serviceConfig - The service config from AUTH_SERVICES.
151
+ * @param {object} args - Parsed CLI arguments.
152
+ * @param {object} context - CLI context (output, prompt, errors).
153
+ */
154
+ async function handleOAuthLogin(service, serviceConfig, args, context) {
155
+ // Read client credentials
156
+ const clientFile = path.join(CLIENTS_DIR, serviceConfig.clientFile);
157
+ if (!fs.existsSync(clientFile)) {
158
+ context.output.info(`Client credentials not found: ${clientFile}`);
159
+ context.output.info('');
160
+ context.output.info('To set up OAuth for this service:');
161
+ context.output.info(` 1. Create a client credentials file at ${clientFile}`);
162
+ context.output.info(' 2. Include "clientId" and "clientSecret" fields');
163
+ context.output.info(' 3. Run this command again');
164
+ return;
165
+ }
166
+
167
+ let clientCreds;
168
+ try {
169
+ clientCreds = JSON.parse(fs.readFileSync(clientFile, 'utf8'));
170
+ } catch {
171
+ context.errors.throwError(500, `Invalid client credentials file: ${clientFile}`, 'auth');
172
+ return;
173
+ }
174
+
175
+ if (!clientCreds.clientId || !clientCreds.clientSecret) {
176
+ context.errors.throwError(400, 'Client credentials file must contain "clientId" and "clientSecret".', 'auth');
177
+ return;
178
+ }
179
+
180
+ // Build scopes: start with defaults, add any from --scopes flag
181
+ const scopes = [...serviceConfig.defaultScopes];
182
+ if (args.flags.scopes) {
183
+ const extra = args.flags.scopes.split(',').map(s => s.trim()).filter(Boolean);
184
+ for (const scope of extra) {
185
+ if (!scopes.includes(scope)) {
186
+ scopes.push(scope);
187
+ }
188
+ }
189
+ }
190
+
191
+ // Determine redirect URI (use client file value if set, otherwise default)
192
+ const redirectUri = clientCreds.redirectUri || OAUTH_REDIRECT_URI;
193
+
194
+ // Build authorization URL
195
+ const authParams = new URLSearchParams({
196
+ client_id: clientCreds.clientId,
197
+ redirect_uri: redirectUri,
198
+ response_type: 'code',
199
+ scope: scopes.join(' '),
200
+ access_type: 'offline',
201
+ prompt: 'consent'
202
+ });
203
+
204
+ const authUrl = `${serviceConfig.authUrl}?${authParams.toString()}`;
205
+
206
+ context.output.info('Opening browser for authentication...');
207
+ context.output.info('If the browser does not open, visit this URL:');
208
+ context.output.info('');
209
+ context.output.info(` ${authUrl}`);
210
+ context.output.info('');
211
+
212
+ // Start the callback server and open the browser
213
+ const callbackPromise = waitForOAuthCallback();
214
+ await openBrowser(authUrl);
215
+
216
+ let code;
217
+ try {
218
+ code = await callbackPromise;
219
+ } catch (err) {
220
+ context.errors.throwError(500, err.message, 'auth');
221
+ return;
222
+ }
223
+
224
+ // Exchange authorization code for tokens
225
+ context.output.info('Exchanging authorization code for tokens...');
226
+
227
+ let tokenResponse;
228
+ try {
229
+ tokenResponse = await httpsPost(serviceConfig.tokenUrl, {
230
+ code: code,
231
+ client_id: clientCreds.clientId,
232
+ client_secret: clientCreds.clientSecret,
233
+ redirect_uri: redirectUri,
234
+ grant_type: 'authorization_code'
235
+ });
236
+ } catch (err) {
237
+ context.errors.throwError(500, `Token exchange failed: ${err.message}`, 'auth');
238
+ return;
239
+ }
240
+
241
+ let tokenData;
242
+ try {
243
+ tokenData = JSON.parse(tokenResponse.body);
244
+ } catch {
245
+ context.errors.throwError(500, 'Failed to parse token response.', 'auth');
246
+ return;
247
+ }
248
+
249
+ if (tokenData.error) {
250
+ context.errors.throwError(500, `Token error: ${tokenData.error_description || tokenData.error}`, 'auth');
251
+ return;
252
+ }
253
+
254
+ // Calculate expiry time
255
+ const now = new Date();
256
+ const expiresAt = tokenData.expires_in
257
+ ? new Date(now.getTime() + tokenData.expires_in * 1000).toISOString()
258
+ : null;
259
+
260
+ // Save tokens
261
+ const credential = {
262
+ type: 'oauth',
263
+ accessToken: tokenData.access_token,
264
+ refreshToken: tokenData.refresh_token || null,
265
+ expiresAt: expiresAt,
266
+ scopes: scopes,
267
+ authenticatedAt: now.toISOString()
268
+ };
269
+
270
+ fs.mkdirSync(AUTH_DIR, { recursive: true });
271
+ fs.writeFileSync(
272
+ path.join(AUTH_DIR, `${service}.json`),
273
+ JSON.stringify(credential, null, 2) + '\n'
274
+ );
275
+
276
+ context.output.info('');
277
+ context.output.info(`Successfully authenticated with ${service}.`);
278
+ context.output.info(`Credentials stored in ~/.devutils/auth/${service}.json`);
279
+ }
280
+
281
+ /**
282
+ * Handle API key login flow for a service (e.g., AWS, Cloudflare).
283
+ * Prompts the user for each required field and saves them to disk.
284
+ *
285
+ * @param {string} service - The service name.
286
+ * @param {object} serviceConfig - The service config from AUTH_SERVICES.
287
+ * @param {object} context - CLI context (output, prompt, errors).
288
+ */
289
+ async function handleApiKeyLogin(service, serviceConfig, context) {
290
+ const credentials = {};
291
+
292
+ for (let i = 0; i < serviceConfig.fields.length; i++) {
293
+ const field = serviceConfig.fields[i];
294
+ const label = serviceConfig.fieldLabels[i];
295
+
296
+ if (isSensitiveField(field)) {
297
+ credentials[field] = await context.prompt.password(label);
298
+ } else {
299
+ credentials[field] = await context.prompt.ask(label, '');
300
+ }
301
+
302
+ if (!credentials[field]) {
303
+ context.errors.throwError(400, `${label} is required.`, 'auth');
304
+ return;
305
+ }
306
+ }
307
+
308
+ const credential = {
309
+ type: 'api-key',
310
+ credentials: credentials,
311
+ authenticatedAt: new Date().toISOString()
312
+ };
313
+
314
+ fs.mkdirSync(AUTH_DIR, { recursive: true });
315
+ fs.writeFileSync(
316
+ path.join(AUTH_DIR, `${service}.json`),
317
+ JSON.stringify(credential, null, 2) + '\n'
318
+ );
319
+
320
+ context.output.info('');
321
+ context.output.info(`Successfully authenticated with ${service}.`);
322
+ context.output.info(`Credentials stored in ~/.devutils/auth/${service}.json`);
323
+ }
324
+
325
+ /**
326
+ * Run the auth login command.
327
+ * Validates the service name, checks for existing credentials, and branches
328
+ * into the appropriate auth flow (OAuth or API key).
329
+ *
330
+ * @param {object} args - Parsed CLI arguments (positional, flags).
331
+ * @param {object} context - CLI context (output, prompt, errors).
332
+ */
333
+ async function run(args, context) {
334
+ const service = args.positional[0];
335
+
336
+ if (!service) {
337
+ context.errors.throwError(400, 'Missing required argument: <service>. Example: dev auth login google', 'auth');
338
+ return;
339
+ }
340
+
341
+ const serviceConfig = AUTH_SERVICES[service];
342
+ if (!serviceConfig) {
343
+ const supported = Object.keys(AUTH_SERVICES).join(', ');
344
+ context.errors.throwError(400, `Unknown service '${service}'. Supported services: ${supported}`, 'auth');
345
+ return;
346
+ }
347
+
348
+ // Check for existing credentials
349
+ const existing = readCredential(service);
350
+ if (existing) {
351
+ context.output.info(`Already authenticated with ${service}.`);
352
+
353
+ if (existing.type === 'api-key' && existing.credentials) {
354
+ // Show a masked preview of the existing credentials
355
+ const fields = Object.keys(existing.credentials);
356
+ for (const field of fields) {
357
+ const val = existing.credentials[field];
358
+ if (isSensitiveField(field)) {
359
+ context.output.info(` ${field}: ****`);
360
+ } else {
361
+ context.output.info(` ${field}: ${val}`);
362
+ }
363
+ }
364
+ }
365
+
366
+ if (existing.authenticatedAt) {
367
+ context.output.info(` Authenticated: ${existing.authenticatedAt}`);
368
+ }
369
+
370
+ const reauth = await context.prompt.confirm('Do you want to re-authenticate?', false);
371
+ if (!reauth) {
372
+ return;
373
+ }
374
+ }
375
+
376
+ // Branch by auth type
377
+ if (serviceConfig.type === 'oauth') {
378
+ await handleOAuthLogin(service, serviceConfig, args, context);
379
+ } else if (serviceConfig.type === 'api-key') {
380
+ await handleApiKeyLogin(service, serviceConfig, context);
381
+ }
382
+ }
383
+
384
+ module.exports = { meta, run };
@@ -0,0 +1,111 @@
1
+ 'use strict';
2
+
3
+ const fs = require('fs');
4
+ const path = require('path');
5
+ const https = require('https');
6
+ const { AUTH_SERVICES, AUTH_DIR, readCredential } = require('./services');
7
+
8
+ const meta = {
9
+ description: 'Revoke and remove stored credentials for a service',
10
+ arguments: [
11
+ { name: 'service', description: 'Service name to log out from', required: true }
12
+ ],
13
+ flags: []
14
+ };
15
+
16
+ /**
17
+ * Attempt to revoke an OAuth token by POSTing to the provider's revocation endpoint.
18
+ * This is best-effort: if revocation fails (network error, token already expired, etc.),
19
+ * we log a warning but continue with local cleanup.
20
+ *
21
+ * @param {string} revokeUrl - The revocation endpoint URL.
22
+ * @param {string} token - The access token to revoke.
23
+ * @returns {Promise<boolean>} True if revocation succeeded, false otherwise.
24
+ */
25
+ function revokeToken(revokeUrl, token) {
26
+ return new Promise((resolve) => {
27
+ const postData = new URLSearchParams({ token }).toString();
28
+ const parsed = new URL(revokeUrl);
29
+
30
+ const options = {
31
+ hostname: parsed.hostname,
32
+ port: parsed.port || 443,
33
+ path: parsed.pathname + parsed.search,
34
+ method: 'POST',
35
+ headers: {
36
+ 'Content-Type': 'application/x-www-form-urlencoded',
37
+ 'Content-Length': Buffer.byteLength(postData)
38
+ }
39
+ };
40
+
41
+ const req = https.request(options, (res) => {
42
+ // Consume the response body so the socket is released
43
+ res.on('data', () => {});
44
+ res.on('end', () => {
45
+ resolve(res.statusCode >= 200 && res.statusCode < 300);
46
+ });
47
+ });
48
+
49
+ req.on('error', () => {
50
+ resolve(false);
51
+ });
52
+
53
+ req.write(postData);
54
+ req.end();
55
+ });
56
+ }
57
+
58
+ /**
59
+ * Run the auth logout command.
60
+ * Validates the service, attempts token revocation for OAuth services,
61
+ * and deletes the local credential file.
62
+ *
63
+ * @param {object} args - Parsed CLI arguments (positional, flags).
64
+ * @param {object} context - CLI context (output, prompt, errors).
65
+ */
66
+ async function run(args, context) {
67
+ const service = args.positional[0];
68
+
69
+ if (!service) {
70
+ context.errors.throwError(400, 'Missing required argument: <service>. Example: dev auth logout google', 'auth');
71
+ return;
72
+ }
73
+
74
+ const serviceConfig = AUTH_SERVICES[service];
75
+ if (!serviceConfig) {
76
+ const supported = Object.keys(AUTH_SERVICES).join(', ');
77
+ context.errors.throwError(400, `Unknown service '${service}'. Supported services: ${supported}`, 'auth');
78
+ return;
79
+ }
80
+
81
+ // Check if credentials exist
82
+ const credential = readCredential(service);
83
+ if (!credential) {
84
+ context.output.info(`Not logged into ${service}.`);
85
+ return;
86
+ }
87
+
88
+ // Attempt token revocation for OAuth services
89
+ if (credential.type === 'oauth' && serviceConfig.revokeUrl && credential.accessToken) {
90
+ context.output.info(`Revoking ${service} token...`);
91
+ const revoked = await revokeToken(serviceConfig.revokeUrl, credential.accessToken);
92
+ if (revoked) {
93
+ context.output.info('Token revoked successfully.');
94
+ } else {
95
+ context.output.info('Warning: Token revocation failed. The token may still be valid on the provider side.');
96
+ context.output.info('Continuing with local cleanup.');
97
+ }
98
+ }
99
+
100
+ // Delete the credential file
101
+ const credFile = path.join(AUTH_DIR, `${service}.json`);
102
+ try {
103
+ fs.unlinkSync(credFile);
104
+ } catch {
105
+ // File may have already been deleted -- not an error
106
+ }
107
+
108
+ context.output.info(`Logged out of ${service}.`);
109
+ }
110
+
111
+ module.exports = { meta, run };
@@ -0,0 +1,184 @@
1
+ 'use strict';
2
+
3
+ const fs = require('fs');
4
+ const path = require('path');
5
+ const https = require('https');
6
+ const { AUTH_SERVICES, AUTH_DIR, CLIENTS_DIR, readCredential } = require('./services');
7
+
8
+ const meta = {
9
+ description: 'Force a token refresh for an OAuth service without re-authenticating',
10
+ arguments: [
11
+ { name: 'service', description: 'Service name to refresh', required: true }
12
+ ],
13
+ flags: []
14
+ };
15
+
16
+ /**
17
+ * Make an HTTPS POST request with form-encoded data.
18
+ * Uses Node.js built-in https module (no external dependencies).
19
+ *
20
+ * @param {string} targetUrl - The URL to POST to.
21
+ * @param {Object<string, string>} data - Key/value pairs to send as form data.
22
+ * @returns {Promise<{ statusCode: number, body: string }>}
23
+ */
24
+ function httpsPost(targetUrl, data) {
25
+ return new Promise((resolve, reject) => {
26
+ const postData = new URLSearchParams(data).toString();
27
+ const parsed = new URL(targetUrl);
28
+
29
+ const options = {
30
+ hostname: parsed.hostname,
31
+ port: parsed.port || 443,
32
+ path: parsed.pathname + parsed.search,
33
+ method: 'POST',
34
+ headers: {
35
+ 'Content-Type': 'application/x-www-form-urlencoded',
36
+ 'Content-Length': Buffer.byteLength(postData)
37
+ }
38
+ };
39
+
40
+ const req = https.request(options, (res) => {
41
+ let body = '';
42
+ res.on('data', (chunk) => { body += chunk; });
43
+ res.on('end', () => {
44
+ resolve({ statusCode: res.statusCode, body });
45
+ });
46
+ });
47
+
48
+ req.on('error', reject);
49
+ req.write(postData);
50
+ req.end();
51
+ });
52
+ }
53
+
54
+ /**
55
+ * Run the auth refresh command.
56
+ * Exchanges the stored refresh token for a new access token and updates
57
+ * the credential file. Only works for OAuth services.
58
+ *
59
+ * @param {object} args - Parsed CLI arguments (positional, flags).
60
+ * @param {object} context - CLI context (output, prompt, errors).
61
+ */
62
+ async function run(args, context) {
63
+ const service = args.positional[0];
64
+
65
+ if (!service) {
66
+ context.errors.throwError(400, 'Missing required argument: <service>. Example: dev auth refresh google', 'auth');
67
+ return;
68
+ }
69
+
70
+ const serviceConfig = AUTH_SERVICES[service];
71
+ if (!serviceConfig) {
72
+ const supported = Object.keys(AUTH_SERVICES).join(', ');
73
+ context.errors.throwError(400, `Unknown service '${service}'. Supported services: ${supported}`, 'auth');
74
+ return;
75
+ }
76
+
77
+ // Read existing credentials
78
+ const credential = readCredential(service);
79
+ if (!credential) {
80
+ context.output.info(`Not authenticated with ${service}. Run "dev auth login ${service}" to authenticate first.`);
81
+ return;
82
+ }
83
+
84
+ // API key services don't support refresh
85
+ if (credential.type === 'api-key') {
86
+ context.output.info('Refresh is not applicable for API key services. API keys don\'t expire through DevUtils.');
87
+ return;
88
+ }
89
+
90
+ // OAuth services need a refresh token
91
+ if (credential.type !== 'oauth') {
92
+ context.output.info(`Unrecognized credential type: ${credential.type}`);
93
+ return;
94
+ }
95
+
96
+ if (!credential.refreshToken) {
97
+ context.output.info(`No refresh token available for ${service}. Run "dev auth login ${service}" to re-authenticate.`);
98
+ return;
99
+ }
100
+
101
+ // Read client credentials
102
+ if (!serviceConfig.clientFile) {
103
+ context.errors.throwError(500, `No client credentials file configured for ${service}.`, 'auth');
104
+ return;
105
+ }
106
+
107
+ const clientFile = path.join(CLIENTS_DIR, serviceConfig.clientFile);
108
+ if (!fs.existsSync(clientFile)) {
109
+ context.output.info(`Client credentials not found: ${clientFile}`);
110
+ context.output.info(`Cannot refresh without client credentials. Run "dev auth login ${service}" to re-authenticate.`);
111
+ return;
112
+ }
113
+
114
+ let clientCreds;
115
+ try {
116
+ clientCreds = JSON.parse(fs.readFileSync(clientFile, 'utf8'));
117
+ } catch {
118
+ context.errors.throwError(500, `Invalid client credentials file: ${clientFile}`, 'auth');
119
+ return;
120
+ }
121
+
122
+ if (!clientCreds.clientId || !clientCreds.clientSecret) {
123
+ context.errors.throwError(400, 'Client credentials file must contain "clientId" and "clientSecret".', 'auth');
124
+ return;
125
+ }
126
+
127
+ // Exchange refresh token for new access token
128
+ context.output.info(`Refreshing ${service} token...`);
129
+
130
+ let tokenResponse;
131
+ try {
132
+ tokenResponse = await httpsPost(serviceConfig.tokenUrl, {
133
+ grant_type: 'refresh_token',
134
+ refresh_token: credential.refreshToken,
135
+ client_id: clientCreds.clientId,
136
+ client_secret: clientCreds.clientSecret
137
+ });
138
+ } catch (err) {
139
+ context.errors.throwError(500, `Token refresh failed: ${err.message}`, 'auth');
140
+ return;
141
+ }
142
+
143
+ let tokenData;
144
+ try {
145
+ tokenData = JSON.parse(tokenResponse.body);
146
+ } catch {
147
+ context.errors.throwError(500, 'Failed to parse token refresh response.', 'auth');
148
+ return;
149
+ }
150
+
151
+ if (tokenData.error) {
152
+ context.errors.throwError(500, `Token refresh error: ${tokenData.error_description || tokenData.error}`, 'auth');
153
+ return;
154
+ }
155
+
156
+ // Update the credential file with the new token
157
+ const now = new Date();
158
+ const expiresAt = tokenData.expires_in
159
+ ? new Date(now.getTime() + tokenData.expires_in * 1000).toISOString()
160
+ : credential.expiresAt;
161
+
162
+ const updated = {
163
+ type: 'oauth',
164
+ accessToken: tokenData.access_token,
165
+ // Some providers return a new refresh token, some don't.
166
+ // If a new one is returned, use it. Otherwise, keep the old one.
167
+ refreshToken: tokenData.refresh_token || credential.refreshToken,
168
+ expiresAt: expiresAt,
169
+ scopes: credential.scopes,
170
+ authenticatedAt: credential.authenticatedAt
171
+ };
172
+
173
+ fs.writeFileSync(
174
+ path.join(AUTH_DIR, `${service}.json`),
175
+ JSON.stringify(updated, null, 2) + '\n'
176
+ );
177
+
178
+ context.output.info(`Token refreshed successfully for ${service}.`);
179
+ if (expiresAt) {
180
+ context.output.info(`New expiry: ${expiresAt}`);
181
+ }
182
+ }
183
+
184
+ module.exports = { meta, run };