@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.
- package/README.md +223 -32
- package/package.json +7 -5
- package/src/api/loader.js +229 -0
- package/src/api/registry.json +62 -0
- package/src/cli.js +305 -0
- package/src/commands/ai/index.js +16 -0
- package/src/commands/ai/launch.js +112 -0
- package/src/commands/ai/list.js +54 -0
- package/src/commands/ai/resume.js +70 -0
- package/src/commands/ai/sessions.js +121 -0
- package/src/commands/ai/set.js +131 -0
- package/src/commands/ai/show.js +74 -0
- package/src/commands/ai/tools.js +46 -0
- package/src/commands/alias/add.js +93 -0
- package/src/commands/alias/helpers.js +107 -0
- package/src/commands/alias/index.js +14 -0
- package/src/commands/alias/list.js +55 -0
- package/src/commands/alias/remove.js +62 -0
- package/src/commands/alias/sync.js +109 -0
- package/src/commands/api/disable.js +73 -0
- package/src/commands/api/enable.js +148 -0
- package/src/commands/api/index.js +15 -0
- package/src/commands/api/list.js +66 -0
- package/src/commands/api/update.js +87 -0
- package/src/commands/auth/index.js +15 -0
- package/src/commands/auth/list.js +49 -0
- package/src/commands/auth/login.js +384 -0
- package/src/commands/auth/logout.js +111 -0
- package/src/commands/auth/refresh.js +184 -0
- package/src/commands/auth/services.js +169 -0
- package/src/commands/auth/status.js +104 -0
- package/src/commands/config/export.js +224 -0
- package/src/commands/config/get.js +52 -0
- package/src/commands/config/import.js +308 -0
- package/src/commands/config/index.js +17 -0
- package/src/commands/config/init.js +143 -0
- package/src/commands/config/reset.js +57 -0
- package/src/commands/config/set.js +93 -0
- package/src/commands/config/show.js +35 -0
- package/src/commands/help.js +338 -0
- package/src/commands/identity/add.js +133 -0
- package/src/commands/identity/index.js +17 -0
- package/src/commands/identity/link.js +76 -0
- package/src/commands/identity/list.js +48 -0
- package/src/commands/identity/remove.js +72 -0
- package/src/commands/identity/show.js +65 -0
- package/src/commands/identity/sync.js +172 -0
- package/src/commands/identity/unlink.js +57 -0
- package/src/commands/ignore/add.js +165 -0
- package/src/commands/ignore/index.js +14 -0
- package/src/commands/ignore/list.js +89 -0
- package/src/commands/ignore/markers.js +43 -0
- package/src/commands/ignore/remove.js +164 -0
- package/src/commands/ignore/show.js +169 -0
- package/src/commands/machine/detect.js +122 -0
- package/src/commands/machine/index.js +14 -0
- package/src/commands/machine/list.js +74 -0
- package/src/commands/machine/set.js +106 -0
- package/src/commands/machine/show.js +35 -0
- package/src/commands/schema.js +152 -0
- package/src/commands/search/collections.js +134 -0
- package/src/commands/search/get.js +71 -0
- package/src/commands/search/index-cmd.js +54 -0
- package/src/commands/search/index.js +21 -0
- package/src/commands/search/keyword.js +60 -0
- package/src/commands/search/qmd.js +70 -0
- package/src/commands/search/query.js +64 -0
- package/src/commands/search/semantic.js +62 -0
- package/src/commands/search/status.js +46 -0
- package/src/commands/status.js +276 -0
- package/src/commands/tools/check.js +79 -0
- package/src/commands/tools/index.js +14 -0
- package/src/commands/tools/install.js +110 -0
- package/src/commands/tools/list.js +91 -0
- package/src/commands/tools/search.js +60 -0
- package/src/commands/update.js +113 -0
- package/src/commands/util/add.js +151 -0
- package/src/commands/util/index.js +15 -0
- package/src/commands/util/list.js +97 -0
- package/src/commands/util/remove.js +76 -0
- package/src/commands/util/run.js +79 -0
- package/src/commands/util/show.js +67 -0
- package/src/commands/version.js +33 -0
- package/src/installers/_template.js +104 -0
- package/src/installers/git.js +150 -0
- package/src/installers/homebrew.js +190 -0
- package/src/installers/node.js +223 -0
- package/src/installers/registry.json +29 -0
- package/src/lib/config.js +125 -0
- package/src/lib/detect.js +74 -0
- package/src/lib/errors.js +114 -0
- package/src/lib/github.js +315 -0
- package/src/lib/installer.js +225 -0
- package/src/lib/output.js +239 -0
- package/src/lib/platform.js +112 -0
- package/src/lib/platforms/amazon-linux.js +41 -0
- package/src/lib/platforms/gitbash.js +46 -0
- package/src/lib/platforms/macos.js +45 -0
- package/src/lib/platforms/raspbian.js +41 -0
- package/src/lib/platforms/ubuntu.js +39 -0
- package/src/lib/platforms/windows.js +45 -0
- package/src/lib/prompt.js +161 -0
- package/src/lib/schema.js +211 -0
- package/src/lib/shell.js +75 -0
- package/src/patterns/gitignore/claude-code.txt +25 -0
- package/src/patterns/gitignore/docker.txt +15 -0
- package/src/patterns/gitignore/go.txt +24 -0
- package/src/patterns/gitignore/java.txt +38 -0
- package/src/patterns/gitignore/jetbrains.txt +26 -0
- package/src/patterns/gitignore/linux.txt +18 -0
- package/src/patterns/gitignore/macos.txt +27 -0
- package/src/patterns/gitignore/node.txt +51 -0
- package/src/patterns/gitignore/python.txt +55 -0
- package/src/patterns/gitignore/rust.txt +14 -0
- package/src/patterns/gitignore/terraform.txt +30 -0
- package/src/patterns/gitignore/vscode.txt +15 -0
- package/src/patterns/gitignore/windows.txt +25 -0
- package/src/utils/clone/index.js +165 -0
- package/src/utils/git-push/index.js +230 -0
- package/src/utils/git-status/index.js +116 -0
- package/src/utils/git-status/unix.sh +75 -0
- package/src/utils/registry.json +41 -0
|
@@ -0,0 +1,338 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* help command.
|
|
5
|
+
* Lists all services and top-level commands, or shows detailed help
|
|
6
|
+
* for a specific service or command when given arguments.
|
|
7
|
+
*
|
|
8
|
+
* Examples:
|
|
9
|
+
* dev help - List all services and top-level commands
|
|
10
|
+
* dev help config - List all commands in the config service
|
|
11
|
+
* dev help config set - Show detailed help for config set
|
|
12
|
+
* dev help version - Show help for the version command
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
const path = require('path');
|
|
16
|
+
|
|
17
|
+
const meta = {
|
|
18
|
+
description: 'Show usage information',
|
|
19
|
+
arguments: [
|
|
20
|
+
{ name: 'command', description: 'Command path to get help for (e.g., config set)', required: false, variadic: true }
|
|
21
|
+
],
|
|
22
|
+
flags: []
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Known service directory names. Each one has an index.js that exports
|
|
27
|
+
* name, description, and a commands map.
|
|
28
|
+
* @type {string[]}
|
|
29
|
+
*/
|
|
30
|
+
const SERVICE_NAMES = [
|
|
31
|
+
'config', 'machine', 'identity', 'tools', 'ignore',
|
|
32
|
+
'util', 'alias', 'auth', 'api', 'ai', 'search'
|
|
33
|
+
];
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Top-level commands (not inside a service folder). Each entry has a
|
|
37
|
+
* name and a description. Descriptions are read from the command's
|
|
38
|
+
* meta export when available, with fallbacks for stubs that haven't
|
|
39
|
+
* been filled in yet.
|
|
40
|
+
* @type {Array<{ name: string, fallback: string }>}
|
|
41
|
+
*/
|
|
42
|
+
const TOP_LEVEL_COMMANDS = [
|
|
43
|
+
{ name: 'status', fallback: 'Overall health check' },
|
|
44
|
+
{ name: 'update', fallback: 'Update DevUtils CLI' },
|
|
45
|
+
{ name: 'version', fallback: 'Show the current installed version' },
|
|
46
|
+
{ name: 'schema', fallback: 'Show or validate config schema' },
|
|
47
|
+
{ name: 'help', fallback: 'Show usage information' },
|
|
48
|
+
];
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Loads a service index.js and returns { name, description, commands }.
|
|
52
|
+
* Returns null if the service cannot be loaded.
|
|
53
|
+
*
|
|
54
|
+
* @param {string} serviceName - The service directory name (e.g., 'config').
|
|
55
|
+
* @returns {object|null}
|
|
56
|
+
*/
|
|
57
|
+
function loadService(serviceName) {
|
|
58
|
+
try {
|
|
59
|
+
return require(path.join(__dirname, serviceName, 'index'));
|
|
60
|
+
} catch {
|
|
61
|
+
return null;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Loads a top-level command module and returns it.
|
|
67
|
+
* Returns null if the module cannot be loaded.
|
|
68
|
+
*
|
|
69
|
+
* @param {string} commandName - The command file name (e.g., 'version').
|
|
70
|
+
* @returns {object|null}
|
|
71
|
+
*/
|
|
72
|
+
function loadTopLevelCommand(commandName) {
|
|
73
|
+
try {
|
|
74
|
+
return require(path.join(__dirname, commandName));
|
|
75
|
+
} catch {
|
|
76
|
+
return null;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Gets the description for a top-level command. Reads from the command's
|
|
82
|
+
* meta.description if available, otherwise uses the fallback.
|
|
83
|
+
*
|
|
84
|
+
* @param {{ name: string, fallback: string }} entry - The top-level command entry.
|
|
85
|
+
* @returns {string}
|
|
86
|
+
*/
|
|
87
|
+
function getTopLevelDescription(entry) {
|
|
88
|
+
const cmd = loadTopLevelCommand(entry.name);
|
|
89
|
+
if (cmd && cmd.meta && cmd.meta.description) {
|
|
90
|
+
return cmd.meta.description;
|
|
91
|
+
}
|
|
92
|
+
return entry.fallback;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Builds a list of all services with their names and descriptions.
|
|
97
|
+
*
|
|
98
|
+
* @returns {Array<{ name: string, description: string }>}
|
|
99
|
+
*/
|
|
100
|
+
function buildServiceList() {
|
|
101
|
+
const services = [];
|
|
102
|
+
for (const name of SERVICE_NAMES) {
|
|
103
|
+
const svc = loadService(name);
|
|
104
|
+
if (svc) {
|
|
105
|
+
services.push({ name: svc.name, description: svc.description });
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
return services;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Builds a list of all top-level commands with their names and descriptions.
|
|
113
|
+
*
|
|
114
|
+
* @returns {Array<{ name: string, description: string }>}
|
|
115
|
+
*/
|
|
116
|
+
function buildTopLevelCommandList() {
|
|
117
|
+
return TOP_LEVEL_COMMANDS.map(entry => ({
|
|
118
|
+
name: entry.name,
|
|
119
|
+
description: getTopLevelDescription(entry),
|
|
120
|
+
}));
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Pads a string to the given width with trailing spaces.
|
|
125
|
+
*
|
|
126
|
+
* @param {string} str - The string to pad.
|
|
127
|
+
* @param {number} width - The target width.
|
|
128
|
+
* @returns {string}
|
|
129
|
+
*/
|
|
130
|
+
function pad(str, width) {
|
|
131
|
+
return str + ' '.repeat(Math.max(0, width - str.length));
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* Shows the top-level help listing: all services and top-level commands.
|
|
136
|
+
*
|
|
137
|
+
* @param {object} context - The CLI context object.
|
|
138
|
+
*/
|
|
139
|
+
function showTopLevelHelp(context) {
|
|
140
|
+
const services = buildServiceList();
|
|
141
|
+
const commands = buildTopLevelCommandList();
|
|
142
|
+
|
|
143
|
+
if (context.flags.format === 'json') {
|
|
144
|
+
context.output.out({ services, commands });
|
|
145
|
+
return;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// Find the longest name for alignment
|
|
149
|
+
const allNames = [...services.map(s => s.name), ...commands.map(c => c.name)];
|
|
150
|
+
const maxLen = Math.max(...allNames.map(n => n.length));
|
|
151
|
+
const colWidth = maxLen + 4;
|
|
152
|
+
|
|
153
|
+
context.output.info('Usage: dev <service> <command> [arguments] [flags]');
|
|
154
|
+
context.output.info('');
|
|
155
|
+
context.output.info('Services:');
|
|
156
|
+
for (const svc of services) {
|
|
157
|
+
context.output.info(` ${pad(svc.name, colWidth)}${svc.description}`);
|
|
158
|
+
}
|
|
159
|
+
context.output.info('');
|
|
160
|
+
context.output.info('Commands:');
|
|
161
|
+
for (const cmd of commands) {
|
|
162
|
+
context.output.info(` ${pad(cmd.name, colWidth)}${cmd.description}`);
|
|
163
|
+
}
|
|
164
|
+
context.output.info('');
|
|
165
|
+
context.output.info('Run "dev help <service>" to see commands within a service.');
|
|
166
|
+
context.output.info('Run "dev help <service> <command>" for detailed command help.');
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* Shows help for a specific service, listing all its commands.
|
|
171
|
+
*
|
|
172
|
+
* @param {object} service - The service module (with name, description, commands).
|
|
173
|
+
* @param {object} context - The CLI context object.
|
|
174
|
+
*/
|
|
175
|
+
function showServiceHelp(service, context) {
|
|
176
|
+
const commandNames = Object.keys(service.commands || {});
|
|
177
|
+
const commandList = [];
|
|
178
|
+
|
|
179
|
+
for (const name of commandNames) {
|
|
180
|
+
let description = '';
|
|
181
|
+
try {
|
|
182
|
+
const cmd = service.commands[name]();
|
|
183
|
+
if (cmd && cmd.meta && cmd.meta.description) {
|
|
184
|
+
description = cmd.meta.description;
|
|
185
|
+
}
|
|
186
|
+
} catch {
|
|
187
|
+
// Command module may not be fully implemented yet
|
|
188
|
+
}
|
|
189
|
+
commandList.push({ name, description });
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
if (context.flags.format === 'json') {
|
|
193
|
+
context.output.out({
|
|
194
|
+
service: service.name,
|
|
195
|
+
description: service.description,
|
|
196
|
+
commands: commandList,
|
|
197
|
+
});
|
|
198
|
+
return;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
const maxLen = Math.max(...commandList.map(c => c.name.length));
|
|
202
|
+
const colWidth = maxLen + 4;
|
|
203
|
+
|
|
204
|
+
context.output.info(`${service.name}: ${service.description}`);
|
|
205
|
+
context.output.info('');
|
|
206
|
+
context.output.info('Commands:');
|
|
207
|
+
for (const cmd of commandList) {
|
|
208
|
+
const desc = cmd.description ? `${pad(cmd.name, colWidth)}${cmd.description}` : cmd.name;
|
|
209
|
+
context.output.info(` dev ${service.name} ${desc}`);
|
|
210
|
+
}
|
|
211
|
+
context.output.info('');
|
|
212
|
+
context.output.info(`Run "dev help ${service.name} <command>" for detailed help.`);
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/**
|
|
216
|
+
* Shows detailed help for a specific command, including its description,
|
|
217
|
+
* arguments, and flags.
|
|
218
|
+
*
|
|
219
|
+
* @param {object} command - The command module (with meta.description, meta.arguments, meta.flags).
|
|
220
|
+
* @param {string} commandPath - The full command path for display (e.g., "config set").
|
|
221
|
+
* @param {object} context - The CLI context object.
|
|
222
|
+
*/
|
|
223
|
+
function showCommandHelp(command, commandPath, context) {
|
|
224
|
+
const m = command.meta || {};
|
|
225
|
+
const args = m.arguments || [];
|
|
226
|
+
const flags = m.flags || [];
|
|
227
|
+
|
|
228
|
+
if (context.flags.format === 'json') {
|
|
229
|
+
context.output.out({
|
|
230
|
+
command: commandPath,
|
|
231
|
+
description: m.description || '',
|
|
232
|
+
arguments: args,
|
|
233
|
+
flags: flags,
|
|
234
|
+
});
|
|
235
|
+
return;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
context.output.info(`dev ${commandPath}`);
|
|
239
|
+
context.output.info('');
|
|
240
|
+
if (m.description) {
|
|
241
|
+
context.output.info(` ${m.description}`);
|
|
242
|
+
context.output.info('');
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
if (args.length > 0) {
|
|
246
|
+
context.output.info('Arguments:');
|
|
247
|
+
const maxLen = Math.max(...args.map(a => a.name.length));
|
|
248
|
+
const colWidth = maxLen + 4;
|
|
249
|
+
for (const arg of args) {
|
|
250
|
+
const req = arg.required ? '(required)' : '(optional)';
|
|
251
|
+
context.output.info(` ${pad(arg.name, colWidth)}${arg.description || ''} ${req}`);
|
|
252
|
+
}
|
|
253
|
+
context.output.info('');
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
if (flags.length > 0) {
|
|
257
|
+
context.output.info('Flags:');
|
|
258
|
+
const maxLen = Math.max(...flags.map(f => `--${f.name}`.length));
|
|
259
|
+
const colWidth = maxLen + 4;
|
|
260
|
+
for (const flag of flags) {
|
|
261
|
+
const name = `--${flag.name}`;
|
|
262
|
+
const parts = [flag.description || ''];
|
|
263
|
+
if (flag.type) {
|
|
264
|
+
parts.push(`(${flag.type})`);
|
|
265
|
+
}
|
|
266
|
+
if (flag.default !== undefined) {
|
|
267
|
+
parts.push(`[default: ${flag.default}]`);
|
|
268
|
+
}
|
|
269
|
+
context.output.info(` ${pad(name, colWidth)}${parts.join(' ')}`);
|
|
270
|
+
}
|
|
271
|
+
context.output.info('');
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
/**
|
|
276
|
+
* Main entry point for the help command.
|
|
277
|
+
*
|
|
278
|
+
* @param {object} args - Parsed command arguments. Positional args are the command path.
|
|
279
|
+
* @param {object} context - The CLI context object.
|
|
280
|
+
*/
|
|
281
|
+
async function run(args, context) {
|
|
282
|
+
const positional = args.positional || [];
|
|
283
|
+
|
|
284
|
+
// No arguments: show top-level help
|
|
285
|
+
if (positional.length === 0) {
|
|
286
|
+
showTopLevelHelp(context);
|
|
287
|
+
return;
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
const firstName = positional[0];
|
|
291
|
+
const secondName = positional[1];
|
|
292
|
+
|
|
293
|
+
// Check if the first argument is a service name
|
|
294
|
+
const service = loadService(firstName);
|
|
295
|
+
if (service) {
|
|
296
|
+
if (!secondName) {
|
|
297
|
+
// Show service-level help (list all commands in the service)
|
|
298
|
+
showServiceHelp(service, context);
|
|
299
|
+
return;
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
// Show help for a specific command within the service
|
|
303
|
+
if (service.commands && service.commands[secondName]) {
|
|
304
|
+
try {
|
|
305
|
+
const cmd = service.commands[secondName]();
|
|
306
|
+
showCommandHelp(cmd, `${firstName} ${secondName}`, context);
|
|
307
|
+
return;
|
|
308
|
+
} catch {
|
|
309
|
+
// Fall through to unknown command error
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
// Unknown command within the service
|
|
314
|
+
const methods = Object.keys(service.commands || {}).join(', ');
|
|
315
|
+
context.errors.throwError(
|
|
316
|
+
404,
|
|
317
|
+
`Unknown command "${secondName}" for service "${firstName}". Available commands: ${methods}`,
|
|
318
|
+
'help'
|
|
319
|
+
);
|
|
320
|
+
return;
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
// Check if the first argument is a top-level command
|
|
324
|
+
const topCmd = loadTopLevelCommand(firstName);
|
|
325
|
+
if (topCmd) {
|
|
326
|
+
showCommandHelp(topCmd, firstName, context);
|
|
327
|
+
return;
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
// Unknown command
|
|
331
|
+
context.errors.throwError(
|
|
332
|
+
404,
|
|
333
|
+
`Unknown command "${firstName}". Run "dev help" to see available commands.`,
|
|
334
|
+
'help'
|
|
335
|
+
);
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
module.exports = { meta, run };
|
|
@@ -0,0 +1,133 @@
|
|
|
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: 'Create a new identity profile.',
|
|
11
|
+
arguments: [
|
|
12
|
+
{ name: 'name', description: 'Short name for this identity (e.g., personal, work)', required: true },
|
|
13
|
+
],
|
|
14
|
+
flags: [
|
|
15
|
+
{ name: 'email', description: 'Git author email (required)' },
|
|
16
|
+
{ name: 'ssh-key', description: 'Path to SSH private key file' },
|
|
17
|
+
{ name: 'gpg-key', description: 'GPG key ID for commit signing' },
|
|
18
|
+
{ name: 'generate-key', description: 'Generate a new SSH key pair for this identity' },
|
|
19
|
+
],
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
async function run(args, context) {
|
|
23
|
+
const identityName = args.positional[0];
|
|
24
|
+
if (!identityName) {
|
|
25
|
+
context.errors.throwError(400, 'Missing identity name. Usage: dev identity add <name> --email <email>', 'identity');
|
|
26
|
+
return;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// Validate name format
|
|
30
|
+
if (!/^[a-z0-9][a-z0-9-]*$/.test(identityName)) {
|
|
31
|
+
context.errors.throwError(400, 'Identity name must be lowercase letters, numbers, and hyphens only.', 'identity');
|
|
32
|
+
return;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const email = args.flags.email;
|
|
36
|
+
if (!email) {
|
|
37
|
+
context.errors.throwError(400, 'Email is required. Use --email your@email.com.', 'identity');
|
|
38
|
+
return;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// Load config
|
|
42
|
+
if (!fs.existsSync(CONFIG_FILE)) {
|
|
43
|
+
context.errors.throwError(404, 'Config not found. Run "dev config init" first.', 'identity');
|
|
44
|
+
return;
|
|
45
|
+
}
|
|
46
|
+
const config = JSON.parse(fs.readFileSync(CONFIG_FILE, 'utf8'));
|
|
47
|
+
const identities = config.identities || [];
|
|
48
|
+
|
|
49
|
+
// Check for duplicates (case-insensitive)
|
|
50
|
+
if (identities.some(id => id.name.toLowerCase() === identityName.toLowerCase())) {
|
|
51
|
+
context.errors.throwError(400, `Identity '${identityName}' already exists. Use a different name or remove it first.`, 'identity');
|
|
52
|
+
return;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// Handle SSH key
|
|
56
|
+
let sshKeyPath = null;
|
|
57
|
+
const sshKeyFlag = args.flags['ssh-key'];
|
|
58
|
+
const generateKey = args.flags['generate-key'];
|
|
59
|
+
|
|
60
|
+
if (sshKeyFlag && generateKey) {
|
|
61
|
+
context.errors.throwError(400, 'Provide either --ssh-key or --generate-key, not both.', 'identity');
|
|
62
|
+
return;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
if (sshKeyFlag) {
|
|
66
|
+
const resolved = path.resolve(sshKeyFlag);
|
|
67
|
+
if (!fs.existsSync(resolved)) {
|
|
68
|
+
context.errors.throwError(404, `SSH key file not found: ${resolved}`, 'identity');
|
|
69
|
+
return;
|
|
70
|
+
}
|
|
71
|
+
sshKeyPath = resolved;
|
|
72
|
+
} else if (generateKey) {
|
|
73
|
+
sshKeyPath = await generateSshKey(identityName, email, context);
|
|
74
|
+
if (!sshKeyPath) return; // Error already reported
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// Handle GPG key
|
|
78
|
+
const gpgKey = args.flags['gpg-key'] || null;
|
|
79
|
+
|
|
80
|
+
// Build identity
|
|
81
|
+
const identity = {
|
|
82
|
+
name: identityName,
|
|
83
|
+
email: email,
|
|
84
|
+
sshKey: sshKeyPath,
|
|
85
|
+
gpgKey: gpgKey,
|
|
86
|
+
folders: [],
|
|
87
|
+
};
|
|
88
|
+
|
|
89
|
+
// Save
|
|
90
|
+
identities.push(identity);
|
|
91
|
+
config.identities = identities;
|
|
92
|
+
fs.writeFileSync(CONFIG_FILE, JSON.stringify(config, null, 2) + '\n');
|
|
93
|
+
|
|
94
|
+
context.output.info(`Identity '${identityName}' created.`);
|
|
95
|
+
context.output.out(identity);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Generates an ED25519 SSH key pair for the given identity.
|
|
100
|
+
* @param {string} name - The identity name (used in the key filename).
|
|
101
|
+
* @param {string} email - The email (used as the key comment).
|
|
102
|
+
* @param {object} context - The command context.
|
|
103
|
+
* @returns {Promise<string|null>} The key path, or null on failure.
|
|
104
|
+
*/
|
|
105
|
+
async function generateSshKey(name, email, context) {
|
|
106
|
+
const sshDir = path.join(os.homedir(), '.ssh');
|
|
107
|
+
if (!fs.existsSync(sshDir)) {
|
|
108
|
+
fs.mkdirSync(sshDir, { mode: 0o700 });
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
const keyPath = path.join(sshDir, `id_ed25519_${name}`);
|
|
112
|
+
if (fs.existsSync(keyPath)) {
|
|
113
|
+
context.errors.throwError(400, `SSH key already exists at ${keyPath}. Use --ssh-key ${keyPath} to reference it.`, 'identity');
|
|
114
|
+
return null;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
const result = await context.shell.exec(
|
|
118
|
+
`ssh-keygen -t ed25519 -C "${email}" -f "${keyPath}" -N ""`
|
|
119
|
+
);
|
|
120
|
+
|
|
121
|
+
if (result.exitCode !== 0) {
|
|
122
|
+
context.errors.throwError(500, `Failed to generate SSH key: ${result.stderr}`, 'identity');
|
|
123
|
+
return null;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
context.output.info(`SSH key generated: ${keyPath}`);
|
|
127
|
+
context.output.info(`Public key: ${keyPath}.pub`);
|
|
128
|
+
context.output.info('Add the public key to your GitHub account at https://github.com/settings/keys');
|
|
129
|
+
|
|
130
|
+
return keyPath;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
module.exports = { meta, run };
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Identity service registration.
|
|
3
|
+
* Git identities, SSH keys, GPG signing.
|
|
4
|
+
*/
|
|
5
|
+
module.exports = {
|
|
6
|
+
name: 'identity',
|
|
7
|
+
description: 'Git identities, SSH keys, GPG signing',
|
|
8
|
+
commands: {
|
|
9
|
+
add: () => require('./add'),
|
|
10
|
+
remove: () => require('./remove'),
|
|
11
|
+
list: () => require('./list'),
|
|
12
|
+
show: () => require('./show'),
|
|
13
|
+
link: () => require('./link'),
|
|
14
|
+
unlink: () => require('./unlink'),
|
|
15
|
+
sync: () => require('./sync'),
|
|
16
|
+
}
|
|
17
|
+
};
|
|
@@ -0,0 +1,76 @@
|
|
|
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: 'Link an identity to a folder path.',
|
|
11
|
+
arguments: [
|
|
12
|
+
{ name: 'name', description: 'Identity name', required: true },
|
|
13
|
+
{ name: 'folder', description: 'Folder path to link (absolute or relative)', required: true },
|
|
14
|
+
],
|
|
15
|
+
flags: [
|
|
16
|
+
{ name: 'remote', description: 'Git remote hostname (default: github.com)' },
|
|
17
|
+
],
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
async function run(args, context) {
|
|
21
|
+
const name = args.positional[0];
|
|
22
|
+
const folder = args.positional[1];
|
|
23
|
+
|
|
24
|
+
if (!name || !folder) {
|
|
25
|
+
context.errors.throwError(400, 'Usage: dev identity link <name> <folder> [--remote <host>]', 'identity');
|
|
26
|
+
return;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const absolutePath = path.resolve(folder);
|
|
30
|
+
if (!fs.existsSync(absolutePath)) {
|
|
31
|
+
context.errors.throwError(404, `Folder not found: ${absolutePath}`, 'identity');
|
|
32
|
+
return;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
if (!fs.existsSync(CONFIG_FILE)) {
|
|
36
|
+
context.errors.throwError(404, 'Config not found. Run "dev config init" first.', 'identity');
|
|
37
|
+
return;
|
|
38
|
+
}
|
|
39
|
+
const config = JSON.parse(fs.readFileSync(CONFIG_FILE, 'utf8'));
|
|
40
|
+
const identities = config.identities || [];
|
|
41
|
+
|
|
42
|
+
// Find the target identity
|
|
43
|
+
const identity = identities.find(id => id.name.toLowerCase() === name.toLowerCase());
|
|
44
|
+
if (!identity) {
|
|
45
|
+
context.errors.throwError(404, `Identity '${name}' not found.`, 'identity');
|
|
46
|
+
return;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const remote = args.flags.remote || 'github.com';
|
|
50
|
+
|
|
51
|
+
// Check if folder is linked to a different identity
|
|
52
|
+
for (const id of identities) {
|
|
53
|
+
const existing = (id.folders || []).find(f => f.path === absolutePath);
|
|
54
|
+
if (existing && id.name.toLowerCase() !== name.toLowerCase()) {
|
|
55
|
+
context.output.info(`Warning: Folder '${absolutePath}' is currently linked to identity '${id.name}'. Re-linking to '${identity.name}'.`);
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// Remove folder from all identities (avoid duplicates)
|
|
60
|
+
for (const id of identities) {
|
|
61
|
+
id.folders = (id.folders || []).filter(f => f.path !== absolutePath);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// Add the link
|
|
65
|
+
identity.folders = identity.folders || [];
|
|
66
|
+
identity.folders.push({ path: absolutePath, remote });
|
|
67
|
+
|
|
68
|
+
// Save
|
|
69
|
+
config.identities = identities;
|
|
70
|
+
fs.writeFileSync(CONFIG_FILE, JSON.stringify(config, null, 2) + '\n');
|
|
71
|
+
|
|
72
|
+
context.output.info(`Linked '${absolutePath}' to identity '${identity.name}' (remote: ${remote}).`);
|
|
73
|
+
context.output.info("Run 'dev identity sync' to apply changes to SSH and git config.");
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
module.exports = { meta, run };
|
|
@@ -0,0 +1,48 @@
|
|
|
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: 'List all configured identities.',
|
|
11
|
+
arguments: [],
|
|
12
|
+
flags: [],
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
async function run(args, context) {
|
|
16
|
+
if (!fs.existsSync(CONFIG_FILE)) {
|
|
17
|
+
context.errors.throwError(404, 'Config not found. Run "dev config init" first.', 'identity');
|
|
18
|
+
return;
|
|
19
|
+
}
|
|
20
|
+
const config = JSON.parse(fs.readFileSync(CONFIG_FILE, 'utf8'));
|
|
21
|
+
const identities = config.identities || [];
|
|
22
|
+
|
|
23
|
+
if (identities.length === 0) {
|
|
24
|
+
context.output.info('No identities configured.');
|
|
25
|
+
context.output.info('');
|
|
26
|
+
context.output.info('Create one with:');
|
|
27
|
+
context.output.info(' dev identity add <name> --email <email>');
|
|
28
|
+
context.output.info('');
|
|
29
|
+
context.output.info('Example:');
|
|
30
|
+
context.output.info(' dev identity add personal --email fred@example.com');
|
|
31
|
+
return;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// Build table rows
|
|
35
|
+
const rows = identities.map(id => ({
|
|
36
|
+
Name: id.name,
|
|
37
|
+
Email: id.email,
|
|
38
|
+
'SSH Key': id.sshKey ? 'yes' : 'no',
|
|
39
|
+
'GPG Key': id.gpgKey ? 'yes' : 'no',
|
|
40
|
+
'Linked Folders': (id.folders || []).length,
|
|
41
|
+
}));
|
|
42
|
+
|
|
43
|
+
context.output.out(rows);
|
|
44
|
+
const count = identities.length;
|
|
45
|
+
context.output.info(`\n${count} ${count === 1 ? 'identity' : 'identities'} configured.`);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
module.exports = { meta, run };
|
|
@@ -0,0 +1,72 @@
|
|
|
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: 'Remove an identity profile.',
|
|
11
|
+
arguments: [
|
|
12
|
+
{ name: 'name', description: 'Name of the identity to remove', required: true },
|
|
13
|
+
],
|
|
14
|
+
flags: [
|
|
15
|
+
{ name: 'confirm', description: 'Skip the confirmation prompt' },
|
|
16
|
+
{ name: 'force', description: 'Remove even if the identity has linked folders' },
|
|
17
|
+
],
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
async function run(args, context) {
|
|
21
|
+
const name = args.positional[0];
|
|
22
|
+
if (!name) {
|
|
23
|
+
context.errors.throwError(400, 'Missing identity name. Usage: dev identity remove <name>', 'identity');
|
|
24
|
+
return;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
if (!fs.existsSync(CONFIG_FILE)) {
|
|
28
|
+
context.errors.throwError(404, 'Config not found. Run "dev config init" first.', 'identity');
|
|
29
|
+
return;
|
|
30
|
+
}
|
|
31
|
+
const config = JSON.parse(fs.readFileSync(CONFIG_FILE, 'utf8'));
|
|
32
|
+
const identities = config.identities || [];
|
|
33
|
+
|
|
34
|
+
// Find the identity
|
|
35
|
+
const identity = identities.find(id => id.name.toLowerCase() === name.toLowerCase());
|
|
36
|
+
if (!identity) {
|
|
37
|
+
context.errors.throwError(404, `Identity '${name}' not found.`, 'identity');
|
|
38
|
+
return;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// Check for linked folders
|
|
42
|
+
const folders = identity.folders || [];
|
|
43
|
+
if (folders.length > 0 && !args.flags.force) {
|
|
44
|
+
const folderList = folders.map(f => ` ${f.path || f}`).join('\n');
|
|
45
|
+
context.errors.throwError(
|
|
46
|
+
400,
|
|
47
|
+
`Cannot remove identity '${identity.name}' because it has ${folders.length} linked folder(s):\n${folderList}\nUse --force to remove anyway, or unlink the folders first.`,
|
|
48
|
+
'identity'
|
|
49
|
+
);
|
|
50
|
+
return;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// Confirm
|
|
54
|
+
if (!args.flags.confirm) {
|
|
55
|
+
const proceed = await context.prompt.confirm(`Remove identity '${identity.name}'? This cannot be undone.`, false);
|
|
56
|
+
if (!proceed) {
|
|
57
|
+
context.output.info('Cancelled.');
|
|
58
|
+
return;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// Remove from array
|
|
63
|
+
config.identities = identities.filter(id => id.name.toLowerCase() !== name.toLowerCase());
|
|
64
|
+
fs.writeFileSync(CONFIG_FILE, JSON.stringify(config, null, 2) + '\n');
|
|
65
|
+
|
|
66
|
+
context.output.info(`Identity '${identity.name}' removed.`);
|
|
67
|
+
if (identity.sshKey) {
|
|
68
|
+
context.output.info(`Note: SSH key at ${identity.sshKey} was not deleted. Remove it manually if no longer needed.`);
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
module.exports = { meta, run };
|