forged-cli 0.7.0 â 0.8.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 +29 -3
- package/bin/forged.js +11 -1
- package/package.json +1 -1
- package/src/commands/init.js +242 -0
package/README.md
CHANGED
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
[](https://github.com/bkness/forged-cli/actions/workflows/test.yml)
|
|
8
8
|
[](https://github.com/bkness/forged-cli)
|
|
9
9
|
|
|
10
|
-
**Forged** is a CLI toolkit for developers: a dependency security scanner, a credential generator, and a README generator
|
|
10
|
+
**Forged** is a CLI toolkit for developers: a guided zsh setup, a dependency security scanner, a credential generator, and a README generator.
|
|
11
11
|
|
|
12
12
|
đ **[weballtech-brandon-kellys-projects.vercel.app](https://weballtech-brandon-kellys-projects.vercel.app/)** â full docs and feature overview
|
|
13
13
|
|
|
@@ -26,6 +26,9 @@ Requires Node.js 18 or newer. Published from GitHub Actions with [npm provenance
|
|
|
26
26
|
## Quick Start
|
|
27
27
|
|
|
28
28
|
```sh
|
|
29
|
+
# Set up the Forged shell on this machine (asks before each step)
|
|
30
|
+
forged init
|
|
31
|
+
|
|
29
32
|
# Scan a project's dependencies
|
|
30
33
|
forged scan ./my-app
|
|
31
34
|
|
|
@@ -38,6 +41,30 @@ forged readme
|
|
|
38
41
|
|
|
39
42
|
---
|
|
40
43
|
|
|
44
|
+
## đ Shell Setup
|
|
45
|
+
|
|
46
|
+
`forged init` sets up the Forged zsh environment from my [dotfiles](https://github.com/bkness/dotfiles): the prompt, the Ctrl+P command palette, the Ctrl+G GitHub dashboard (issue â branch â commit â PR, project boards), code finder, abbreviations, and a hook that scans dependencies when you `cd` into a project.
|
|
47
|
+
|
|
48
|
+
It's written for people new to the terminal. It shows the whole plan first, explains each step, and asks before doing it:
|
|
49
|
+
|
|
50
|
+
1. **Homebrew on your PATH.** If Homebrew is installed but `brew` isn't found (the installer's last step is easy to miss), adds its `shellenv` line to `~/.zprofile`
|
|
51
|
+
2. **Command-line tools.** Installs whichever of `git gh fzf eza bat fd zoxide starship` are missing, with Homebrew
|
|
52
|
+
3. **zinit**, the plugin manager, cloned to `~/.local/share/zinit`
|
|
53
|
+
4. **The dotfiles**, cloned to `~/dev/dotfiles`
|
|
54
|
+
5. **One line in `~/.zshrc`** that loads them. The file is backed up first, and nothing already in it is changed
|
|
55
|
+
|
|
56
|
+
Steps that are already done are skipped, so it's safe to run again after fixing an error.
|
|
57
|
+
|
|
58
|
+
| Command | What it does |
|
|
59
|
+
|---------|--------------|
|
|
60
|
+
| `forged init` | Guided setup |
|
|
61
|
+
| `forged init --dry-run` | Show the plan, change nothing |
|
|
62
|
+
| `forged init --yes` | Accept every step without asking |
|
|
63
|
+
|
|
64
|
+
Needs macOS or Linux. Without Homebrew (on Linux, say), init lists the missing tools for you to install yourself and does the rest.
|
|
65
|
+
|
|
66
|
+
---
|
|
67
|
+
|
|
41
68
|
## đ Security Scanner
|
|
42
69
|
|
|
43
70
|
`forged scan [path]` audits every package in your `package-lock.json`:
|
|
@@ -140,11 +167,10 @@ These are advertised in `forged help` and currently print "coming soon":
|
|
|
140
167
|
|
|
141
168
|
| Command | Plan |
|
|
142
169
|
|---------|------|
|
|
143
|
-
| `forged init` | Guided setup of a modular zsh environment â plugins, hooks, project auto-detection, and the GitHub workflow (Ctrl+G: issue â branch â commit â PR, project boards, label and template pickers) |
|
|
144
170
|
| `forged new` | Create a new project with GitHub setup |
|
|
145
171
|
| `forged install` | Add Forged to an existing shell config |
|
|
146
172
|
|
|
147
|
-
|
|
173
|
+
`forged init` already sets up the shell and GitHub workflow these build on.
|
|
148
174
|
|
|
149
175
|
---
|
|
150
176
|
|
package/bin/forged.js
CHANGED
|
@@ -10,6 +10,7 @@ const { version } = JSON.parse(readFileSync(join(__dirname, '../package.json'),
|
|
|
10
10
|
const [,, command, ...args] = process.argv;
|
|
11
11
|
|
|
12
12
|
const commands = {
|
|
13
|
+
init: 'Set up the Forged shell: tools, plugins, dotfiles',
|
|
13
14
|
scan: 'Audit dependencies â known malware, integrity, publisher changes, typosquats',
|
|
14
15
|
gen: 'Generate passwords, secrets, PINs, and UUIDs',
|
|
15
16
|
readme: 'Generate a README.md for the current project',
|
|
@@ -18,7 +19,6 @@ const commands = {
|
|
|
18
19
|
|
|
19
20
|
// Advertised but not built yet â say so instead of treating them as typos
|
|
20
21
|
const planned = {
|
|
21
|
-
init: 'Scaffold a new dev environment',
|
|
22
22
|
new: 'Create a new project with GitHub setup',
|
|
23
23
|
install: 'Install Forged into an existing shell config',
|
|
24
24
|
};
|
|
@@ -36,6 +36,11 @@ ${Object.entries(commands).map(([cmd, desc]) => ` ${cmd.padEnd(10)} ${desc}`)
|
|
|
36
36
|
Coming soon:
|
|
37
37
|
${Object.entries(planned).map(([cmd, desc]) => ` ${cmd.padEnd(10)} ${desc}`).join('\n')}
|
|
38
38
|
|
|
39
|
+
Init options:
|
|
40
|
+
forged init [--dry-run] [--yes|-y]
|
|
41
|
+
--dry-run show what would change, change nothing
|
|
42
|
+
--yes accept every step without asking
|
|
43
|
+
|
|
39
44
|
Scan options:
|
|
40
45
|
forged scan [path] [--verbose|-v] [--report|--report-md]
|
|
41
46
|
[--changed] [--quiet|-q] [--review]
|
|
@@ -58,6 +63,11 @@ if (command === 'readme') {
|
|
|
58
63
|
process.exit(0);
|
|
59
64
|
}
|
|
60
65
|
|
|
66
|
+
if (command === 'init') {
|
|
67
|
+
const { initCommand } = await import('../src/commands/init.js');
|
|
68
|
+
process.exit(await initCommand(args));
|
|
69
|
+
}
|
|
70
|
+
|
|
61
71
|
if (command === 'gen') {
|
|
62
72
|
const { genCommand } = await import('../src/commands/gen.js');
|
|
63
73
|
await genCommand(args);
|
package/package.json
CHANGED
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
import { spawnSync } from 'child_process';
|
|
2
|
+
import { existsSync, readFileSync, writeFileSync, appendFileSync, copyFileSync, lstatSync, realpathSync } from 'fs';
|
|
3
|
+
import { createInterface } from 'readline/promises';
|
|
4
|
+
import { homedir } from 'os';
|
|
5
|
+
import { join, delimiter, dirname } from 'path';
|
|
6
|
+
|
|
7
|
+
const green = '\x1b[32m';
|
|
8
|
+
const yellow = '\x1b[33m';
|
|
9
|
+
const red = '\x1b[31m';
|
|
10
|
+
const bold = '\x1b[1m';
|
|
11
|
+
const dim = '\x1b[2m';
|
|
12
|
+
const reset = '\x1b[0m';
|
|
13
|
+
|
|
14
|
+
// What the dotfiles expect on PATH, and what each one is for â shown to the
|
|
15
|
+
// user so a beginner knows why we're installing it
|
|
16
|
+
export const TOOLS = {
|
|
17
|
+
git: 'version control',
|
|
18
|
+
gh: 'GitHub from the terminal (Ctrl+G)',
|
|
19
|
+
fzf: 'fuzzy finder behind every picker',
|
|
20
|
+
eza: 'a nicer `ls`',
|
|
21
|
+
bat: 'a nicer `cat` with syntax colors',
|
|
22
|
+
fd: 'a faster `find`',
|
|
23
|
+
zoxide: 'jump to folders you visit often (Ctrl+J)',
|
|
24
|
+
starship: 'the prompt',
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
export const DOTFILES_REPO = 'https://github.com/bkness/dotfiles.git';
|
|
28
|
+
export const ZINIT_REPO = 'https://github.com/zdharma-continuum/zinit.git';
|
|
29
|
+
export const HOOK_MARKER = '# forged: load the Forged shell';
|
|
30
|
+
|
|
31
|
+
// Where Homebrew lives when it's installed but not on PATH yet (Apple
|
|
32
|
+
// Silicon, Intel, Linux). The installer prints a "Next steps" shellenv line
|
|
33
|
+
// that beginners skip, which leaves `brew: command not found`.
|
|
34
|
+
export const BREW_PATHS = ['/opt/homebrew/bin/brew', '/usr/local/bin/brew', '/home/linuxbrew/.linuxbrew/bin/brew'];
|
|
35
|
+
|
|
36
|
+
export function paths(home) {
|
|
37
|
+
return {
|
|
38
|
+
dotfiles: join(home, 'dev', 'dotfiles'),
|
|
39
|
+
zinit: join(home, '.local', 'share', 'zinit', 'zinit.git'),
|
|
40
|
+
zshrc: join(home, '.zshrc'),
|
|
41
|
+
zprofile: join(home, '.zprofile'),
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// The one block appended to ~/.zshrc. $HOME stays literal so the line still
|
|
46
|
+
// works if the home folder is renamed.
|
|
47
|
+
export function hookBlock() {
|
|
48
|
+
return `\n${HOOK_MARKER}\n[[ -f "$HOME/dev/dotfiles/zsh/.zshrc" ]] && source "$HOME/dev/dotfiles/zsh/.zshrc"\n`;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// Already loading the dotfiles: our marker, a hand-written source line, or
|
|
52
|
+
// ~/.zshrc symlinked straight into the repo (how Brandon's Mac is set up)
|
|
53
|
+
export function zshrcLoadsDotfiles(zshrcText, zshrcTarget) {
|
|
54
|
+
if (zshrcTarget && zshrcTarget.endsWith('/dev/dotfiles/zsh/.zshrc')) return true;
|
|
55
|
+
if (!zshrcText) return false;
|
|
56
|
+
return zshrcText.includes(HOOK_MARKER) || /source\s+\S*dev\/dotfiles\/zsh\/\.zshrc/.test(zshrcText);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// Everything init would do, in order, with `done` marking steps that are
|
|
60
|
+
// already in place. Pure: all probing comes in through `env`.
|
|
61
|
+
export function buildPlan(env) {
|
|
62
|
+
const p = paths(env.home);
|
|
63
|
+
const steps = [];
|
|
64
|
+
|
|
65
|
+
const brewOnPath = env.has('brew');
|
|
66
|
+
const brewPath = brewOnPath ? null : (env.brewPaths ?? BREW_PATHS).find(env.exists);
|
|
67
|
+
|
|
68
|
+
if (!brewOnPath && brewPath) {
|
|
69
|
+
steps.push({
|
|
70
|
+
id: 'brew-path',
|
|
71
|
+
title: 'Put Homebrew on your PATH',
|
|
72
|
+
why: `Homebrew is installed at ${brewPath}, but your shell can't find it yet. This adds one line to ~/.zprofile, the step the Homebrew installer asks you to run at the end.`,
|
|
73
|
+
line: `eval "$(${brewPath} shellenv)"`,
|
|
74
|
+
brewPath,
|
|
75
|
+
done: false,
|
|
76
|
+
});
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const brewAvailable = brewOnPath || Boolean(brewPath);
|
|
80
|
+
const missing = Object.keys(TOOLS).filter((t) => !env.has(t));
|
|
81
|
+
steps.push({
|
|
82
|
+
id: 'tools',
|
|
83
|
+
title: 'Install command-line tools',
|
|
84
|
+
why: missing.length
|
|
85
|
+
? `Missing: ${missing.map((t) => `${t} (${TOOLS[t]})`).join(', ')}.`
|
|
86
|
+
: 'All installed.',
|
|
87
|
+
missing,
|
|
88
|
+
brewAvailable,
|
|
89
|
+
// Full path when it isn't on PATH, so this works even if step 1 was skipped
|
|
90
|
+
brew: brewOnPath ? 'brew' : brewPath,
|
|
91
|
+
done: missing.length === 0,
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
steps.push({
|
|
95
|
+
id: 'zinit',
|
|
96
|
+
title: 'Install zinit, the zsh plugin manager',
|
|
97
|
+
why: 'It loads autosuggestions, syntax highlighting, fuzzy tab completion and abbreviations.',
|
|
98
|
+
dir: p.zinit,
|
|
99
|
+
done: env.exists(p.zinit),
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
steps.push({
|
|
103
|
+
id: 'dotfiles',
|
|
104
|
+
title: 'Download the Forged shell config',
|
|
105
|
+
why: `Clones ${DOTFILES_REPO} to ~/dev/dotfiles: the prompt, widgets (Ctrl+P, Ctrl+G, Ctrl+FâŚ), aliases and hooks.`,
|
|
106
|
+
dir: p.dotfiles,
|
|
107
|
+
done: env.exists(p.dotfiles),
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
const zshrcText = env.exists(p.zshrc) ? env.read(p.zshrc) : null;
|
|
111
|
+
steps.push({
|
|
112
|
+
id: 'zshrc',
|
|
113
|
+
title: 'Load it from ~/.zshrc',
|
|
114
|
+
why: zshrcText === null
|
|
115
|
+
? 'Creates ~/.zshrc with one line that loads the Forged shell.'
|
|
116
|
+
: 'Backs up ~/.zshrc, then adds one line at the end that loads the Forged shell. Nothing already in the file is changed.',
|
|
117
|
+
file: p.zshrc,
|
|
118
|
+
exists: zshrcText !== null,
|
|
119
|
+
done: zshrcLoadsDotfiles(zshrcText, env.symlinkTarget(p.zshrc)),
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
return steps;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
export function realEnv(home) {
|
|
126
|
+
return {
|
|
127
|
+
home,
|
|
128
|
+
brewPaths: BREW_PATHS,
|
|
129
|
+
has: (cmd) => (process.env.PATH || '').split(delimiter).some((dir) => dir && existsSync(join(dir, cmd))),
|
|
130
|
+
exists: existsSync,
|
|
131
|
+
read: (file) => readFileSync(file, 'utf8'),
|
|
132
|
+
symlinkTarget: (file) => {
|
|
133
|
+
try { return lstatSync(file).isSymbolicLink() ? realpathSync(file) : null; } catch { return null; }
|
|
134
|
+
},
|
|
135
|
+
};
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
const runCommand = (cmd, args) => spawnSync(cmd, args, { stdio: 'inherit' }).status === 0;
|
|
139
|
+
|
|
140
|
+
// Carries out one step: true when done, false when it failed (init stops so
|
|
141
|
+
// later steps don't build on a broken one), 'manual' when the user has to do
|
|
142
|
+
// it themselves.
|
|
143
|
+
function apply(step, home, run) {
|
|
144
|
+
switch (step.id) {
|
|
145
|
+
case 'brew-path': {
|
|
146
|
+
appendFileSync(paths(home).zprofile, `\n${step.line}\n`);
|
|
147
|
+
// Later steps in this same run need brew too
|
|
148
|
+
process.env.PATH = `${dirname(step.brewPath)}${delimiter}${process.env.PATH}`;
|
|
149
|
+
return true;
|
|
150
|
+
}
|
|
151
|
+
case 'tools': {
|
|
152
|
+
if (!step.brewAvailable) {
|
|
153
|
+
console.log(` ${yellow}Homebrew isn't installed, so install these yourself:${reset} ${bold}${step.missing.join(' ')}${reset}`);
|
|
154
|
+
console.log(` ${dim}On a Mac, get Homebrew from https://brew.sh and run forged init again. On Linux, use your package manager.${reset}`);
|
|
155
|
+
return 'manual';
|
|
156
|
+
}
|
|
157
|
+
return run(step.brew, ['install', ...step.missing]);
|
|
158
|
+
}
|
|
159
|
+
case 'zinit':
|
|
160
|
+
return run('git', ['clone', '--depth', '1', ZINIT_REPO, step.dir]);
|
|
161
|
+
case 'dotfiles':
|
|
162
|
+
return run('git', ['clone', DOTFILES_REPO, step.dir]);
|
|
163
|
+
case 'zshrc': {
|
|
164
|
+
if (step.exists) {
|
|
165
|
+
const backup = `${step.file}.forged-backup-${Date.now()}`;
|
|
166
|
+
copyFileSync(step.file, backup);
|
|
167
|
+
console.log(` ${dim}Backed up to ${backup}${reset}`);
|
|
168
|
+
appendFileSync(step.file, hookBlock());
|
|
169
|
+
} else {
|
|
170
|
+
writeFileSync(step.file, hookBlock().trimStart());
|
|
171
|
+
}
|
|
172
|
+
return true;
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
return false;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
// `env` and `run` are swappable so tests never touch the real Homebrew
|
|
179
|
+
export async function initCommand(args, { home = homedir(), env = realEnv(home), run = runCommand } = {}) {
|
|
180
|
+
const yes = args.includes('--yes') || args.includes('-y');
|
|
181
|
+
const dryRun = args.includes('--dry-run');
|
|
182
|
+
|
|
183
|
+
console.log(`\n ${green}â Forged init${reset} â set up the Forged shell on this machine\n`);
|
|
184
|
+
|
|
185
|
+
if (process.platform === 'win32') {
|
|
186
|
+
console.log(` ${red}The Forged shell is zsh-based and needs macOS or Linux.${reset}\n`);
|
|
187
|
+
return 1;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
const plan = buildPlan(env);
|
|
191
|
+
const todo = plan.filter((s) => !s.done);
|
|
192
|
+
|
|
193
|
+
plan.forEach((s, i) => {
|
|
194
|
+
const mark = s.done ? `${green}â${reset}` : `${yellow}â${reset}`;
|
|
195
|
+
console.log(` ${mark} ${i + 1}. ${s.title}${s.done ? ` ${dim}(already done)${reset}` : ''}`);
|
|
196
|
+
});
|
|
197
|
+
console.log();
|
|
198
|
+
|
|
199
|
+
if (todo.length === 0) {
|
|
200
|
+
console.log(` ${green}Everything's already set up.${reset} Open a new terminal tab to use it.\n`);
|
|
201
|
+
return 0;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
if (dryRun) {
|
|
205
|
+
for (const s of todo) console.log(` ${bold}${s.title}${reset}\n ${dim}${s.why}${reset}\n`);
|
|
206
|
+
console.log(` ${dim}Dry run: nothing was changed.${reset}\n`);
|
|
207
|
+
return 0;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
if (!yes && !process.stdin.isTTY) {
|
|
211
|
+
console.log(` No terminal to ask in. Run ${bold}forged init --yes${reset} to accept every step.\n`);
|
|
212
|
+
return 1;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
const rl = yes ? null : createInterface({ input: process.stdin, output: process.stdout });
|
|
216
|
+
try {
|
|
217
|
+
for (const s of todo) {
|
|
218
|
+
console.log(` ${bold}${s.title}${reset}\n ${dim}${s.why}${reset}`);
|
|
219
|
+
if (rl) {
|
|
220
|
+
const answer = (await rl.question(` Do it? ${dim}[Y/n]${reset} `)).trim().toLowerCase();
|
|
221
|
+
if (answer && answer !== 'y' && answer !== 'yes') {
|
|
222
|
+
console.log(` ${dim}Skipped.${reset}\n`);
|
|
223
|
+
continue;
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
const result = apply(s, home, run);
|
|
227
|
+
if (!result) {
|
|
228
|
+
console.log(`\n ${red}â ${s.title} didn't finish.${reset} Fix the error above, then run ${bold}forged init${reset} again; finished steps are skipped.\n`);
|
|
229
|
+
return 1;
|
|
230
|
+
}
|
|
231
|
+
console.log(result === 'manual' ? '' : ` ${green}â Done${reset}\n`);
|
|
232
|
+
}
|
|
233
|
+
} finally {
|
|
234
|
+
rl?.close();
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
console.log(` ${green}${bold}All set.${reset} Next:`);
|
|
238
|
+
console.log(` ${bold}exec zsh${reset} load the new shell in this window`);
|
|
239
|
+
console.log(` ${bold}gh auth login${reset} connect GitHub, for the Ctrl+G dashboard`);
|
|
240
|
+
console.log(` ${bold}Ctrl+P${reset} browse every command\n`);
|
|
241
|
+
return 0;
|
|
242
|
+
}
|