@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,169 @@
1
+ 'use strict';
2
+
3
+ const fs = require('fs');
4
+ const path = require('path');
5
+ const os = require('os');
6
+
7
+ const AUTH_DIR = path.join(os.homedir(), '.devutils', 'auth');
8
+ const CLIENTS_DIR = path.join(AUTH_DIR, 'clients');
9
+
10
+ /**
11
+ * Registry of all supported authentication services.
12
+ * Each entry describes how the service authenticates and what credentials it needs.
13
+ *
14
+ * - OAuth services use a browser-based consent flow (e.g., Google).
15
+ * - API key services prompt the user for static credentials (e.g., AWS, Cloudflare).
16
+ *
17
+ * This object is the single source of truth for all auth commands (login, logout,
18
+ * list, status, refresh). Add new services here -- all commands will pick them up.
19
+ */
20
+ const AUTH_SERVICES = {
21
+ google: {
22
+ type: 'oauth',
23
+ authUrl: 'https://accounts.google.com/o/oauth2/v2/auth',
24
+ tokenUrl: 'https://oauth2.googleapis.com/token',
25
+ revokeUrl: 'https://oauth2.googleapis.com/revoke',
26
+ defaultScopes: ['openid', 'email', 'profile'],
27
+ clientFile: 'google.json'
28
+ },
29
+ aws: {
30
+ type: 'api-key',
31
+ fields: ['accessKeyId', 'secretAccessKey', 'region'],
32
+ fieldLabels: ['AWS Access Key ID', 'AWS Secret Access Key', 'Default Region']
33
+ },
34
+ cloudflare: {
35
+ type: 'api-key',
36
+ fields: ['apiToken'],
37
+ fieldLabels: ['Cloudflare API Token']
38
+ },
39
+ dokploy: {
40
+ type: 'api-key',
41
+ fields: ['apiUrl', 'apiToken'],
42
+ fieldLabels: ['Dokploy API URL', 'Dokploy API Token']
43
+ },
44
+ namecheap: {
45
+ type: 'api-key',
46
+ fields: ['apiUser', 'apiKey', 'clientIp'],
47
+ fieldLabels: ['Namecheap API User', 'API Key', 'Whitelisted Client IP']
48
+ },
49
+ flowroute: {
50
+ type: 'api-key',
51
+ fields: ['accessKey', 'secretKey'],
52
+ fieldLabels: ['Flowroute Access Key', 'Secret Key']
53
+ },
54
+ mailu: {
55
+ type: 'api-key',
56
+ fields: ['apiUrl', 'apiKey'],
57
+ fieldLabels: ['Mailu API URL', 'API Key']
58
+ }
59
+ };
60
+
61
+ /**
62
+ * Read a credential file from ~/.devutils/auth/<service>.json.
63
+ * Returns the parsed JSON content, or null if the file is missing or invalid.
64
+ *
65
+ * @param {string} service - The service name (e.g., 'google', 'aws').
66
+ * @returns {object|null} The parsed credential data, or null.
67
+ */
68
+ function readCredential(service) {
69
+ const filePath = path.join(AUTH_DIR, `${service}.json`);
70
+ try {
71
+ const raw = fs.readFileSync(filePath, 'utf8');
72
+ return JSON.parse(raw);
73
+ } catch {
74
+ return null;
75
+ }
76
+ }
77
+
78
+ /**
79
+ * Determine the status of a credential.
80
+ * - 'missing' means no credential file exists.
81
+ * - 'valid' means the credential exists and (for OAuth) is not expired.
82
+ * - 'expired' means the OAuth token's expiresAt is in the past.
83
+ * - 'unknown' means the credential exists but can't be evaluated.
84
+ *
85
+ * @param {object|null} credential - The parsed credential data from readCredential().
86
+ * @returns {string} One of 'missing', 'valid', 'expired', 'unknown'.
87
+ */
88
+ function getTokenStatus(credential) {
89
+ if (!credential) return 'missing';
90
+ if (credential.type === 'api-key') return 'valid';
91
+ if (credential.type === 'oauth') {
92
+ if (!credential.expiresAt) return 'unknown';
93
+ return new Date(credential.expiresAt) > new Date() ? 'valid' : 'expired';
94
+ }
95
+ return 'unknown';
96
+ }
97
+
98
+ /**
99
+ * Check if a field name looks like it holds sensitive data.
100
+ * Fields with "secret", "key", "token", or "password" in the name
101
+ * should be masked in output and use password-style input.
102
+ *
103
+ * @param {string} fieldName - The field name to check.
104
+ * @returns {boolean} True if the field is sensitive.
105
+ */
106
+ function isSensitiveField(fieldName) {
107
+ const lower = fieldName.toLowerCase();
108
+ return lower.includes('secret') ||
109
+ lower.includes('key') ||
110
+ lower.includes('token') ||
111
+ lower.includes('password');
112
+ }
113
+
114
+ /**
115
+ * Mask a sensitive value for display.
116
+ * Shows the first 4 and last 3 characters for partially-sensitive values,
117
+ * or '****' for fully-sensitive ones.
118
+ *
119
+ * @param {string} value - The value to mask.
120
+ * @param {boolean} fullMask - If true, mask the entire value with '****'.
121
+ * @returns {string} The masked value.
122
+ */
123
+ function maskValue(value, fullMask) {
124
+ if (!value || typeof value !== 'string') return '****';
125
+ if (fullMask) return '****';
126
+ if (value.length <= 8) return '****';
127
+ return value.substring(0, 4) + '...' + value.substring(value.length - 3);
128
+ }
129
+
130
+ /**
131
+ * Format a time difference as a human-readable string.
132
+ * Handles both future ("47 minutes") and past ("expired 12 minutes ago") times.
133
+ *
134
+ * @param {Date} target - The target time (e.g., expiresAt).
135
+ * @param {Date} now - The current time.
136
+ * @returns {string} Human-readable duration string.
137
+ */
138
+ function formatTimeDiff(target, now) {
139
+ const diffMs = target.getTime() - now.getTime();
140
+ const absDiffMs = Math.abs(diffMs);
141
+ const isPast = diffMs < 0;
142
+
143
+ let value;
144
+ if (absDiffMs < 60000) {
145
+ value = 'less than a minute';
146
+ } else if (absDiffMs < 3600000) {
147
+ const mins = Math.floor(absDiffMs / 60000);
148
+ value = `${mins} minute${mins === 1 ? '' : 's'}`;
149
+ } else if (absDiffMs < 86400000) {
150
+ const hours = Math.floor(absDiffMs / 3600000);
151
+ value = `${hours} hour${hours === 1 ? '' : 's'}`;
152
+ } else {
153
+ const days = Math.floor(absDiffMs / 86400000);
154
+ value = `${days} day${days === 1 ? '' : 's'}`;
155
+ }
156
+
157
+ return isPast ? `expired ${value} ago` : value;
158
+ }
159
+
160
+ module.exports = {
161
+ AUTH_SERVICES,
162
+ AUTH_DIR,
163
+ CLIENTS_DIR,
164
+ readCredential,
165
+ getTokenStatus,
166
+ isSensitiveField,
167
+ maskValue,
168
+ formatTimeDiff
169
+ };
@@ -0,0 +1,104 @@
1
+ 'use strict';
2
+
3
+ const {
4
+ AUTH_SERVICES,
5
+ readCredential,
6
+ getTokenStatus,
7
+ isSensitiveField,
8
+ maskValue,
9
+ formatTimeDiff
10
+ } = require('./services');
11
+
12
+ const meta = {
13
+ description: 'Show detailed auth state for one service',
14
+ arguments: [
15
+ { name: 'service', description: 'Service name to check', required: true }
16
+ ],
17
+ flags: []
18
+ };
19
+
20
+ /**
21
+ * Run the auth status command.
22
+ * Shows detailed credential information for a single service, including
23
+ * scopes and expiry for OAuth, or masked field values for API key services.
24
+ *
25
+ * @param {object} args - Parsed CLI arguments (positional, flags).
26
+ * @param {object} context - CLI context (output, prompt, errors).
27
+ */
28
+ async function run(args, context) {
29
+ const service = args.positional[0];
30
+
31
+ if (!service) {
32
+ context.errors.throwError(400, 'Missing required argument: <service>. Example: dev auth status google', 'auth');
33
+ return;
34
+ }
35
+
36
+ const serviceConfig = AUTH_SERVICES[service];
37
+ if (!serviceConfig) {
38
+ const supported = Object.keys(AUTH_SERVICES).join(', ');
39
+ context.errors.throwError(400, `Unknown service '${service}'. Supported services: ${supported}`, 'auth');
40
+ return;
41
+ }
42
+
43
+ const credential = readCredential(service);
44
+ if (!credential) {
45
+ context.output.info(`Not authenticated with ${service}. Run "dev auth login ${service}" to connect.`);
46
+ return;
47
+ }
48
+
49
+ const status = getTokenStatus(credential);
50
+
51
+ if (credential.type === 'oauth') {
52
+ // Build detailed OAuth status
53
+ const now = new Date();
54
+ let expiresIn = null;
55
+
56
+ if (credential.expiresAt) {
57
+ expiresIn = formatTimeDiff(new Date(credential.expiresAt), now);
58
+ }
59
+
60
+ const result = {
61
+ service: service,
62
+ type: 'oauth',
63
+ status: status,
64
+ scopes: credential.scopes || [],
65
+ expiresAt: credential.expiresAt || '-',
66
+ expiresIn: expiresIn || '-',
67
+ authenticatedAt: credential.authenticatedAt || '-',
68
+ hasRefreshToken: !!credential.refreshToken
69
+ };
70
+
71
+ context.output.out(result);
72
+ } else if (credential.type === 'api-key') {
73
+ // Build detailed API key status with masked values
74
+ const fields = credential.credentials ? Object.keys(credential.credentials) : [];
75
+ const maskedValues = {};
76
+
77
+ for (const field of fields) {
78
+ const val = credential.credentials[field];
79
+ if (isSensitiveField(field)) {
80
+ // Fully mask sensitive fields
81
+ maskedValues[field] = maskValue(val, true);
82
+ } else {
83
+ // Show non-sensitive values in full (like region)
84
+ maskedValues[field] = val;
85
+ }
86
+ }
87
+
88
+ const result = {
89
+ service: service,
90
+ type: 'api-key',
91
+ status: status,
92
+ fields: fields,
93
+ maskedValues: maskedValues,
94
+ authenticatedAt: credential.authenticatedAt || '-'
95
+ };
96
+
97
+ context.output.out(result);
98
+ } else {
99
+ // Unknown credential type
100
+ context.output.info(`Credential file exists for ${service} but has an unrecognized type: ${credential.type}`);
101
+ }
102
+ }
103
+
104
+ module.exports = { meta, run };
@@ -0,0 +1,224 @@
1
+ 'use strict';
2
+
3
+ const fs = require('fs');
4
+ const path = require('path');
5
+ const os = require('os');
6
+
7
+ const DEVUTILS_DIR = path.join(os.homedir(), '.devutils');
8
+ const CONFIG_FILE = path.join(DEVUTILS_DIR, 'config.json');
9
+
10
+ const meta = {
11
+ description: 'Push the current config profile to the remote backup (repo or gist), or export to a local file.',
12
+ arguments: [],
13
+ flags: [
14
+ { name: 'file', type: 'string', description: 'Export to a local file instead of remote backup' },
15
+ { name: 'profile', type: 'string', description: 'Export a specific profile (defaults to the active profile)' }
16
+ ]
17
+ };
18
+
19
+ /**
20
+ * The list of config files that are included in an export.
21
+ * Auth tokens are NOT included -- they are sensitive and should be
22
+ * re-created on new machines via "dev auth login".
23
+ */
24
+ const FILES_TO_EXPORT = ['config.json', 'aliases.json', 'ai.json', 'plugins.json'];
25
+
26
+ /**
27
+ * Read a config file from ~/.devutils/ if it exists.
28
+ * Returns the parsed content, or null if the file is missing or invalid JSON.
29
+ *
30
+ * @param {string} filename - The filename to read (e.g., 'config.json').
31
+ * @returns {object|null} The parsed JSON content, or null.
32
+ */
33
+ function readConfigFile(filename) {
34
+ const filePath = path.join(DEVUTILS_DIR, filename);
35
+ if (!fs.existsSync(filePath)) return null;
36
+ try {
37
+ return JSON.parse(fs.readFileSync(filePath, 'utf8'));
38
+ } catch {
39
+ return null;
40
+ }
41
+ }
42
+
43
+ /**
44
+ * Write the sync timestamp after a successful export.
45
+ * This is used by future sync-check features to know when the last
46
+ * export or import happened.
47
+ *
48
+ * @param {string} profileName - The profile that was exported.
49
+ */
50
+ function writeSyncTimestamp(profileName) {
51
+ const syncPath = path.join(DEVUTILS_DIR, 'sync.json');
52
+ const syncData = {
53
+ lastSync: new Date().toISOString(),
54
+ direction: 'export',
55
+ profile: profileName
56
+ };
57
+ fs.writeFileSync(syncPath, JSON.stringify(syncData, null, 2) + '\n');
58
+ }
59
+
60
+ /**
61
+ * Run the config export command.
62
+ * Supports two modes:
63
+ * - File mode (--file): Bundles all config files into a single JSON file on disk.
64
+ * - Remote mode (default): Pushes config to a GitHub repo or gist based on backup settings.
65
+ *
66
+ * @param {object} args - Parsed CLI arguments (positional, flags).
67
+ * @param {object} context - CLI context (output, prompt, errors).
68
+ */
69
+ async function run(args, context) {
70
+ const github = require('../../lib/github');
71
+
72
+ // Read current config to get profile and backup settings
73
+ const config = readConfigFile('config.json');
74
+ const profileName = args.flags.profile || (config && config.profile) || 'default';
75
+
76
+ // --- File export mode ---
77
+ if (args.flags.file) {
78
+ const bundle = {
79
+ exportedAt: new Date().toISOString(),
80
+ profile: profileName,
81
+ config: readConfigFile('config.json'),
82
+ aliases: readConfigFile('aliases.json'),
83
+ ai: readConfigFile('ai.json'),
84
+ plugins: readConfigFile('plugins.json')
85
+ };
86
+
87
+ const outputPath = path.resolve(args.flags.file);
88
+ fs.writeFileSync(outputPath, JSON.stringify(bundle, null, 2) + '\n');
89
+ context.output.info(`Config exported to ${outputPath}`);
90
+
91
+ writeSyncTimestamp(profileName);
92
+ return;
93
+ }
94
+
95
+ // --- Remote export mode ---
96
+ if (!config || !config.backup) {
97
+ context.output.info('No backup storage configured.');
98
+ context.output.info('Run "dev config init" to set up backup storage.');
99
+ return;
100
+ }
101
+
102
+ const backend = config.backup.backend;
103
+ const location = config.backup.location;
104
+
105
+ if (backend === 'repo') {
106
+ // Check gh authentication
107
+ const isAuth = await github.isAuthenticated();
108
+ if (!isAuth) {
109
+ context.output.info('Not authenticated with GitHub. Run: gh auth login');
110
+ return;
111
+ }
112
+
113
+ const cacheDir = path.join(DEVUTILS_DIR, 'cache');
114
+ const repoDir = path.join(cacheDir, 'config-backup');
115
+ fs.mkdirSync(cacheDir, { recursive: true });
116
+
117
+ // Clone or pull the repo
118
+ if (fs.existsSync(path.join(repoDir, '.git'))) {
119
+ // Already cloned -- pull latest
120
+ const pullResult = await github.pullRepo(repoDir);
121
+ if (!pullResult.success) {
122
+ context.output.error('Failed to pull latest config: ' + pullResult.error);
123
+ return;
124
+ }
125
+ } else {
126
+ // First time -- need repo location
127
+ if (!location) {
128
+ context.output.info('No backup repository configured.');
129
+ context.output.info('Run "dev config init --force" to set up a backup repository.');
130
+ return;
131
+ }
132
+
133
+ const cloneResult = await github.cloneRepo(location, repoDir);
134
+ if (!cloneResult.success) {
135
+ context.output.error('Failed to clone backup repo: ' + cloneResult.error);
136
+ return;
137
+ }
138
+ }
139
+
140
+ // Copy config files into the profile directory
141
+ const profileDir = path.join(repoDir, 'profiles', profileName);
142
+ fs.mkdirSync(profileDir, { recursive: true });
143
+
144
+ for (const filename of FILES_TO_EXPORT) {
145
+ const sourcePath = path.join(DEVUTILS_DIR, filename);
146
+ if (fs.existsSync(sourcePath)) {
147
+ fs.copyFileSync(sourcePath, path.join(profileDir, filename));
148
+ }
149
+ }
150
+
151
+ // Push
152
+ const pushResult = await github.pushRepo(
153
+ repoDir,
154
+ `Update profile "${profileName}" - ${new Date().toISOString()}`
155
+ );
156
+
157
+ if (!pushResult.success) {
158
+ context.output.error('Failed to push config: ' + pushResult.error);
159
+ return;
160
+ }
161
+
162
+ context.output.info(`Config exported to repo (profile: ${profileName}).`);
163
+ writeSyncTimestamp(profileName);
164
+
165
+ } else if (backend === 'gist') {
166
+ // Check gh authentication
167
+ const isAuth = await github.isAuthenticated();
168
+ if (!isAuth) {
169
+ context.output.info('Not authenticated with GitHub. Run: gh auth login');
170
+ return;
171
+ }
172
+
173
+ // Bundle config files into a single JSON for the gist
174
+ const bundle = {
175
+ config: readConfigFile('config.json'),
176
+ aliases: readConfigFile('aliases.json'),
177
+ ai: readConfigFile('ai.json'),
178
+ plugins: readConfigFile('plugins.json')
179
+ };
180
+
181
+ const filename = `${profileName}.json`;
182
+ const content = JSON.stringify(bundle, null, 2);
183
+
184
+ if (location) {
185
+ // Update existing gist
186
+ const result = await github.updateGist(location, { [filename]: content });
187
+ if (!result.success) {
188
+ context.output.error('Failed to update gist: ' + result.error);
189
+ return;
190
+ }
191
+ context.output.info(`Config exported to gist (profile: ${profileName}).`);
192
+ } else {
193
+ // Create new gist
194
+ const result = await github.createGist(
195
+ { [filename]: content },
196
+ 'DevUtils CLI configuration backup',
197
+ true
198
+ );
199
+
200
+ if (!result.success) {
201
+ context.output.error('Failed to create gist: ' + result.error);
202
+ return;
203
+ }
204
+
205
+ // Save the gist ID back to config so future exports update the same gist
206
+ config.backup.location = result.id;
207
+ fs.writeFileSync(
208
+ CONFIG_FILE,
209
+ JSON.stringify(config, null, 2) + '\n'
210
+ );
211
+
212
+ context.output.info(`Config exported to new gist (profile: ${profileName}).`);
213
+ context.output.info(`Gist URL: ${result.url}`);
214
+ }
215
+
216
+ writeSyncTimestamp(profileName);
217
+
218
+ } else {
219
+ context.output.info(`Unknown backup backend: ${backend}`);
220
+ context.output.info('Run "dev config init --force" to reconfigure backup storage.');
221
+ }
222
+ }
223
+
224
+ module.exports = { meta, run };
@@ -0,0 +1,52 @@
1
+ 'use strict';
2
+
3
+ const fs = require('fs');
4
+ const path = require('path');
5
+ const os = require('os');
6
+
7
+ const CONFIG_FILE = path.join(os.homedir(), '.devutils', 'config.json');
8
+
9
+ const meta = {
10
+ description: 'Read a specific config value by dot-notation key.',
11
+ arguments: [
12
+ { name: 'key', required: true, description: 'Dot-notation path to the config value (e.g., user.email, defaults.license)' },
13
+ ],
14
+ flags: [],
15
+ };
16
+
17
+ async function run(args, context) {
18
+ const key = args.positional[0];
19
+
20
+ if (!key) {
21
+ context.errors.throwError(400, 'Missing required argument: <key>. Example: dev config get user.email', 'config');
22
+ return;
23
+ }
24
+
25
+ if (!fs.existsSync(CONFIG_FILE)) {
26
+ context.errors.throwError(404, 'Config not found. Run "dev config init" first.', 'config');
27
+ return;
28
+ }
29
+
30
+ const raw = fs.readFileSync(CONFIG_FILE, 'utf8');
31
+ const config = JSON.parse(raw);
32
+
33
+ // Resolve dot-notation key
34
+ const parts = key.split('.');
35
+ let value = config;
36
+
37
+ for (const part of parts) {
38
+ if (value === null || value === undefined || typeof value !== 'object') {
39
+ context.errors.throwError(404, `Key "${key}" not found in config.`, 'config');
40
+ return;
41
+ }
42
+ if (!(part in value)) {
43
+ context.errors.throwError(404, `Key "${key}" not found in config.`, 'config');
44
+ return;
45
+ }
46
+ value = value[part];
47
+ }
48
+
49
+ context.output.out(value);
50
+ }
51
+
52
+ module.exports = { meta, run };