project-compass 2.0.2 → 2.2.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 +24 -80
- package/package.json +1 -1
- package/src/cli.js +29 -26
- package/src/projectDetection.js +55 -2
package/README.md
CHANGED
|
@@ -1,18 +1,15 @@
|
|
|
1
|
-
# Project Compass
|
|
1
|
+
# Project Compass (v2.2.0)
|
|
2
2
|
|
|
3
|
-
Project Compass is a futuristic CLI navigator built with [Ink](https://github.com/vadimdemedes/ink) that scans your current folder tree for familiar code projects and gives you one
|
|
3
|
+
Project Compass is a futuristic CLI navigator built with [Ink](https://github.com/vadimdemedes/ink) that scans your current folder tree for familiar code projects and gives you one-keystroke access to build, test, or run them.
|
|
4
4
|
|
|
5
5
|
## Highlights
|
|
6
6
|
|
|
7
|
-
- 🔍 Scans directories for Node.js, Python, Rust, Go, Java, and Scala projects
|
|
8
|
-
- 🎨
|
|
9
|
-
- 🚀
|
|
10
|
-
- 💡
|
|
11
|
-
-
|
|
12
|
-
-
|
|
13
|
-
- 🧠 Add bespoke commands via **C** in detail view and store them globally (`~/.project-compass/config.json`) so every workspace remembers your favorite invocations.
|
|
14
|
-
- 🔌 Extend detection via plugins (JSON specs under `~/.project-compass/plugins.json`) to teach Project Compass about extra frameworks or command sets.
|
|
15
|
-
- 📦 Install globally and invoke `project-compass` from any folder to activate the UI instantly.
|
|
7
|
+
- 🔍 Scans directories for Node.js, Python, Rust, Go, Java, and Scala projects.
|
|
8
|
+
- 🎨 Futuristic layout with glyph-based art board and split Projects/Details rows.
|
|
9
|
+
- 🚀 **New Keyboard-Centric UX**: Shortcuts now use **Shift** instead of Ctrl to avoid terminal interference.
|
|
10
|
+
- 💡 **Refined Output**: Improved stdin buffer with proper spacing and reliable scrolling (Shift+↑/↓).
|
|
11
|
+
- 🧠 **Smart Detection**: Support for 15+ frameworks (Vite, Prisma, Tailwind, etc.) with specialized build/run commands and setup hints.
|
|
12
|
+
- 🔌 **Extensible**: Add custom commands with **Shift+C** and frameworks via `plugins.json`.
|
|
16
13
|
|
|
17
14
|
## Installation
|
|
18
15
|
|
|
@@ -26,83 +23,30 @@ npm install -g project-compass
|
|
|
26
23
|
project-compass [--dir /path/to/workspace]
|
|
27
24
|
```
|
|
28
25
|
|
|
29
|
-
### Keyboard
|
|
26
|
+
### Keyboard Guide
|
|
30
27
|
|
|
31
28
|
| Key | Action |
|
|
32
29
|
| --- | --- |
|
|
33
|
-
| ↑ / ↓ | Move
|
|
34
|
-
| B / T / R |
|
|
35
|
-
| 1‑9 | Execute
|
|
36
|
-
| C | Add a custom command (`label|cmd`)
|
|
37
|
-
| Shift ↑ /
|
|
38
|
-
|
|
|
39
|
-
|
|
|
40
|
-
|
|
|
41
|
-
|
|
|
42
|
-
|
|
|
43
|
-
|
|
|
44
|
-
| Ctrl+C | Interrupt a running command (works while streaming output) |
|
|
30
|
+
| ↑ / ↓ | Move focus, **Enter**: toggle details |
|
|
31
|
+
| B / T / R | Build / Test / Run |
|
|
32
|
+
| 1‑9 | Execute numbered detail commands |
|
|
33
|
+
| **Shift+C** | Add a custom command (`label|cmd`) |
|
|
34
|
+
| **Shift ↑ / ↓** | Scroll output buffer |
|
|
35
|
+
| **Shift+L** | Rerun last command |
|
|
36
|
+
| **Shift+H** | Toggle help cards |
|
|
37
|
+
| **Shift+S** | Toggle structure guide |
|
|
38
|
+
| **Shift+Q** | Quit app |
|
|
39
|
+
| ? | Toggle help overlay |
|
|
40
|
+
| Ctrl+C | Interrupt running command |
|
|
45
41
|
|
|
46
|
-
##
|
|
42
|
+
## Layout & UX
|
|
47
43
|
|
|
48
|
-
Project Compass
|
|
44
|
+
Project Compass features a split layout where Projects and Details stay paired while Output takes a full-width band. The stdin buffer (at the bottom) now has a clear distinction between the label and your input for better readability. The help cards (Shift+H) have been refactored for a cleaner, more readable look.
|
|
49
45
|
|
|
50
|
-
|
|
46
|
+
## Frameworks
|
|
51
47
|
|
|
52
|
-
|
|
53
|
-
{
|
|
54
|
-
"plugins": [
|
|
55
|
-
{
|
|
56
|
-
"name": "Remix",
|
|
57
|
-
"languages": ["Node.js"],
|
|
58
|
-
"files": ["remix.config.js"],
|
|
59
|
-
"dependencies": ["@remix-run/node"],
|
|
60
|
-
"commands": {
|
|
61
|
-
"run": "npm run dev",
|
|
62
|
-
"build": "npm run build"
|
|
63
|
-
}
|
|
64
|
-
}
|
|
65
|
-
]
|
|
66
|
-
}
|
|
67
|
-
```
|
|
68
|
-
|
|
69
|
-
Each command value can be a string or an array of tokens. When a plugin matches a project, its commands appear in the detail view with a `framework` badge, and the shortcut keys (B/T/R or numeric) can execute them.
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
## Art board & detail view
|
|
73
|
-
|
|
74
|
-
Project Compass now opens with a rounded art board that shuffles your glyph row (▁▃▄▅▇ with neon accents) and three branded tiles showing workspace pulse, the selected project focus, and the rhythm of commands. The detail view sits beside the project list as a gallery; border colors, badges, and the ambient header hint keep it feeling like a living installation rather than a vanilla CLI.
|
|
75
|
-
|
|
76
|
-
## Layout, output & help
|
|
77
|
-
|
|
78
|
-
Projects and details now occupy the same row, while the output panel takes its own full-width band beneath so long logs no longer stretch the rest of the UI. The output pane scrolls independently (Shift+↑/↓), feeds keystrokes into stdin while a command runs (type like normal, Enter submits, Ctrl+C aborts), and the buffer below shows exactly what you are typing. A trio of help tiles highlights navigation cues, command flow, and recent runs; they stay hidden by default—press Ctrl+H to reveal the cards, Ctrl+S to show the structure guide that lists the manifest files for each language detection, `?` to open the overlay with extra tips, Ctrl+L to rerun the previous command, and Ctrl+Q to quit.
|
|
79
|
-
|
|
80
|
-
## Structure guide
|
|
81
|
-
|
|
82
|
-
Press `Ctrl+S` to reveal the structure guide that lists which manifest files trigger each language detection (Node.js looks for `package.json`, Python looks for `pyproject.toml` or `requirements.txt`, Rust needs `Cargo.toml`, etc.). Use `Ctrl+H` to hide the help cards if you need every pixel for your output, then bring them back when you want a refresher.
|
|
83
|
-
|
|
84
|
-
## Detection & setup hints
|
|
85
|
-
|
|
86
|
-
Project Compass now has a modular detection engine (`src/projectDetection.js`) that looks at manifests, frameworks, and optional plugins to identify what kind of project you are standing in. Every schema fills `extra.setupHints` (npm install, pip install, go mod tidy, etc.) and those hints show up under the detail view whenever a project needs bootstrapping.
|
|
87
|
-
|
|
88
|
-
Try the sample Python project at `/mnt/ramdisk/daily_builds/test_python_proj` (includes `pyproject.toml`, `requirements.txt`, and `app.py`) so you can run it via Project Compass and confirm stdin/input behavior.
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
## Developer notes
|
|
93
|
-
|
|
94
|
-
- `npm start` launches the Ink UI in the current directory.
|
|
95
|
-
- `npm test` runs `node src/cli.js --mode test` to verify the scanner output.
|
|
96
|
-
- Extend support for more languages by editing `SCHEMAS` or add plugin definitions under `~/.project-compass/plugins.json`.
|
|
97
|
-
- Config lives at `~/.project-compass/config.json`. Drop custom commands there if you want to preseed them or share with teammates.
|
|
48
|
+
Detects **Next.js**, **React**, **Vue**, **NestJS**, **FastAPI**, **Django**, **Vite**, **Prisma**, **Tailwind**, and more. Recognizes frameworks and injects specialized commands automatically.
|
|
98
49
|
|
|
99
50
|
## License
|
|
100
51
|
|
|
101
52
|
MIT © 2026 Satyaa & Clawdy
|
|
102
|
-
|
|
103
|
-
## Release & packaging
|
|
104
|
-
|
|
105
|
-
- Bump `package.json`/`package-lock.json` versions (e.g., `npm version 1.0.1 --no-git-tag-version`).
|
|
106
|
-
- Run `npm run lint` and `npm run test` to validate the workspace before publishing.
|
|
107
|
-
- Create the release artifact with `npm pack` (produces `project-compass-<version>.tgz` for uploading to GitHub Releases or npm).
|
|
108
|
-
- Tag the repo `git tag v<version>` and push both commits and tags to publish the release.
|
package/package.json
CHANGED
package/src/cli.js
CHANGED
|
@@ -265,7 +265,7 @@ function Compass({rootPath}) {
|
|
|
265
265
|
const normalizedInput = input?.toLowerCase();
|
|
266
266
|
const ctrlCombo = (char) => key.ctrl && normalizedInput === char;
|
|
267
267
|
const shiftCombo = (char) => key.shift && normalizedInput === char;
|
|
268
|
-
const toggleShortcut = (char) =>
|
|
268
|
+
const toggleShortcut = (char) => shiftCombo(char);
|
|
269
269
|
if (toggleShortcut('h')) {
|
|
270
270
|
setShowHelpCards((prev) => !prev);
|
|
271
271
|
return;
|
|
@@ -318,7 +318,7 @@ function Compass({rootPath}) {
|
|
|
318
318
|
setShowHelp((prev) => !prev);
|
|
319
319
|
return;
|
|
320
320
|
}
|
|
321
|
-
if (
|
|
321
|
+
if (shiftCombo('l') && lastCommandRef.current) {
|
|
322
322
|
runProjectCommand(lastCommandRef.current.commandMeta, lastCommandRef.current.project);
|
|
323
323
|
return;
|
|
324
324
|
}
|
|
@@ -338,11 +338,11 @@ function Compass({rootPath}) {
|
|
|
338
338
|
setViewMode((prev) => (prev === 'detail' ? 'list' : 'detail'));
|
|
339
339
|
return;
|
|
340
340
|
}
|
|
341
|
-
if (
|
|
341
|
+
if (shiftCombo('q')) {
|
|
342
342
|
exit();
|
|
343
343
|
return;
|
|
344
344
|
}
|
|
345
|
-
if (
|
|
345
|
+
if (shiftCombo('c') && viewMode === 'detail' && selectedProject) {
|
|
346
346
|
setCustomMode(true);
|
|
347
347
|
setCustomInput('');
|
|
348
348
|
return;
|
|
@@ -412,19 +412,19 @@ const projectRows = [];
|
|
|
412
412
|
detailContent.push(create(Text, {bold: true, marginTop: 1}, 'Commands'));
|
|
413
413
|
detailedIndexed.forEach((command) => {
|
|
414
414
|
detailContent.push(
|
|
415
|
-
create(Text, {key: `detail-${command.shortcut}-${command.label}`}, `${command.shortcut}. ${command.label} ${command.source === 'custom' ? kleur.magenta('(custom)') : command.source === 'framework' ? kleur.cyan('(framework)') : ''}`)
|
|
415
|
+
create(Text, {key: `detail-${command.shortcut}-${command.label}`}, `${command.shortcut}. ${command.label} ${command.source === 'custom' ? kleur.magenta('(custom)') : command.source === 'framework' ? kleur.cyan('(framework)') : command.source === 'plugin' ? kleur.green('(plugin)') : ''}`)
|
|
416
416
|
);
|
|
417
417
|
detailContent.push(create(Text, {dimColor: true}, ` ↳ ${command.command.join(' ')}`));
|
|
418
418
|
});
|
|
419
419
|
if (!detailedIndexed.length) {
|
|
420
|
-
detailContent.push(create(Text, {dimColor: true}, 'No built-in commands yet. Add a custom command with C.'));
|
|
420
|
+
detailContent.push(create(Text, {dimColor: true}, 'No built-in commands yet. Add a custom command with Shift+C.'));
|
|
421
421
|
}
|
|
422
422
|
const setupHints = selectedProject.extra?.setupHints || [];
|
|
423
423
|
if (setupHints.length) {
|
|
424
424
|
detailContent.push(create(Text, {dimColor: true, marginTop: 1}, 'Setup hints:'));
|
|
425
425
|
setupHints.forEach((hint) => detailContent.push(create(Text, {dimColor: true}, ` • ${hint}`)));
|
|
426
426
|
}
|
|
427
|
-
detailContent.push(create(Text, {dimColor: true}, 'Press C → label|cmd to save custom actions, Enter to close detail view.'));
|
|
427
|
+
detailContent.push(create(Text, {dimColor: true}, 'Press Shift+C → label|cmd to save custom actions, Enter to close detail view.'));
|
|
428
428
|
} else {
|
|
429
429
|
detailContent.push(create(Text, {dimColor: true}, 'Press Enter on a project to reveal details (icons, commands, frameworks, custom actions).'));
|
|
430
430
|
}
|
|
@@ -524,19 +524,20 @@ const projectRows = [];
|
|
|
524
524
|
label: 'Navigation',
|
|
525
525
|
color: 'magenta',
|
|
526
526
|
body: [
|
|
527
|
-
'↑ / ↓ move the project focus
|
|
528
|
-
'
|
|
529
|
-
'
|
|
527
|
+
'↑ / ↓ move the project focus',
|
|
528
|
+
'Enter toggles details view',
|
|
529
|
+
'Shift+↑ / ↓ scroll output buffer',
|
|
530
|
+
'Shift+H toggles help cards'
|
|
530
531
|
]
|
|
531
532
|
},
|
|
532
533
|
{
|
|
533
534
|
label: 'Command flow',
|
|
534
535
|
color: 'cyan',
|
|
535
536
|
body: [
|
|
536
|
-
'B / T / R
|
|
537
|
-
'1-9 execute detail commands
|
|
538
|
-
'
|
|
539
|
-
'Ctrl+C aborts;
|
|
537
|
+
'B / T / R run build/test/run',
|
|
538
|
+
'1-9 execute detail commands',
|
|
539
|
+
'Shift+L reruns last command',
|
|
540
|
+
'Ctrl+C aborts; type feeds stdin'
|
|
540
541
|
]
|
|
541
542
|
},
|
|
542
543
|
{
|
|
@@ -544,8 +545,9 @@ const projectRows = [];
|
|
|
544
545
|
color: 'yellow',
|
|
545
546
|
body: [
|
|
546
547
|
recentRuns.length ? `${recentRuns.length} runs recorded` : 'No runs yet · start with B/T/R',
|
|
547
|
-
'
|
|
548
|
-
'C
|
|
548
|
+
'Shift+S toggles structure guide',
|
|
549
|
+
'Shift+C save custom action',
|
|
550
|
+
'Shift+Q quit application'
|
|
549
551
|
]
|
|
550
552
|
}
|
|
551
553
|
];
|
|
@@ -565,16 +567,17 @@ const projectRows = [];
|
|
|
565
567
|
marginBottom: 1,
|
|
566
568
|
borderStyle: 'round',
|
|
567
569
|
borderColor: card.color,
|
|
568
|
-
padding: 1
|
|
570
|
+
padding: 1,
|
|
571
|
+
flexDirection: 'column'
|
|
569
572
|
},
|
|
570
|
-
create(Text, {color: card.color, bold: true}, card.label),
|
|
573
|
+
create(Text, {color: card.color, bold: true, marginBottom: 1}, card.label),
|
|
571
574
|
...card.body.map((line, lineIndex) =>
|
|
572
575
|
create(Text, {key: `${card.label}-${lineIndex}`, dimColor: card.color === 'yellow'}, line)
|
|
573
576
|
)
|
|
574
577
|
)
|
|
575
578
|
)
|
|
576
579
|
)
|
|
577
|
-
: create(Text, {dimColor: true, marginTop: 1}, 'Help cards hidden · press
|
|
580
|
+
: create(Text, {dimColor: true, marginTop: 1}, 'Help cards hidden · press Shift+H to show navigation, command flow, and recent runs.');
|
|
578
581
|
|
|
579
582
|
const structureGuide = showStructureGuide
|
|
580
583
|
? create(
|
|
@@ -586,7 +589,7 @@ const projectRows = [];
|
|
|
586
589
|
marginTop: 1,
|
|
587
590
|
padding: 1
|
|
588
591
|
},
|
|
589
|
-
create(Text, {color: 'cyan', bold: true}, 'Project structure guide · press
|
|
592
|
+
create(Text, {color: 'cyan', bold: true}, 'Project structure guide · press Shift+S to hide'),
|
|
590
593
|
...SCHEMA_GUIDE.map((entry) =>
|
|
591
594
|
create(Text, {key: entry.type, dimColor: true}, `• ${entry.icon} ${entry.label}: ${entry.files.join(', ')}`)
|
|
592
595
|
),
|
|
@@ -613,10 +616,10 @@ const projectRows = [];
|
|
|
613
616
|
)
|
|
614
617
|
: null;
|
|
615
618
|
|
|
616
|
-
const toggleHint = showHelpCards ? '
|
|
619
|
+
const toggleHint = showHelpCards ? 'Shift+H hides the help cards' : 'Shift+H shows the help cards';
|
|
617
620
|
const headerHint = viewMode === 'detail'
|
|
618
|
-
? `Detail mode · 1-${Math.max(detailedIndexed.length, 1)} to execute, C: add custom commands, Enter: back to list,
|
|
619
|
-
: `Quick run · B/T/R to build/test/run, Enter: view details,
|
|
621
|
+
? `Detail mode · 1-${Math.max(detailedIndexed.length, 1)} to execute, Shift+C: add custom commands, Enter: back to list, Shift+Q: quit · ${toggleHint}, Shift+S toggles structure guide`
|
|
622
|
+
: `Quick run · B/T/R to build/test/run, Enter: view details, Shift+Q: quit · ${toggleHint}, Shift+S toggles structure guide`;
|
|
620
623
|
|
|
621
624
|
return create(
|
|
622
625
|
Box,
|
|
@@ -701,7 +704,7 @@ const projectRows = [];
|
|
|
701
704
|
Box,
|
|
702
705
|
{marginTop: 1, flexDirection: 'row', justifyContent: 'space-between'},
|
|
703
706
|
create(Text, {dimColor: true}, running ? 'Type to feed stdin; Enter submits, Ctrl+C aborts.' : 'Run a command or press ? for extra help.'),
|
|
704
|
-
create(Text, {dimColor: true}, `${toggleHint},
|
|
707
|
+
create(Text, {dimColor: true}, `${toggleHint}, Shift+S toggles the structure guide`)
|
|
705
708
|
),
|
|
706
709
|
create(
|
|
707
710
|
Box,
|
|
@@ -710,9 +713,9 @@ const projectRows = [];
|
|
|
710
713
|
flexDirection: 'row',
|
|
711
714
|
borderStyle: 'round',
|
|
712
715
|
borderColor: running ? 'green' : 'gray',
|
|
713
|
-
|
|
716
|
+
paddingX: 1
|
|
714
717
|
},
|
|
715
|
-
create(Text, {bold: true, color: running ? 'green' : 'white'}, running ? 'Stdin buffer' : 'Input ready'),
|
|
718
|
+
create(Text, {bold: true, color: running ? 'green' : 'white'}, running ? ' Stdin buffer ' : ' Input ready '),
|
|
716
719
|
create(Text, {dimColor: true, marginLeft: 1}, running ? (stdinBuffer || '(type to send)') : 'Start a command to feed stdin')
|
|
717
720
|
)
|
|
718
721
|
),
|
package/src/projectDetection.js
CHANGED
|
@@ -323,7 +323,7 @@ const builtInFrameworks = [
|
|
|
323
323
|
priority: 105,
|
|
324
324
|
match(project) {
|
|
325
325
|
const entry = findPythonEntry(project.path);
|
|
326
|
-
return Boolean(entry && dependencyMatches(project, 'flask'));
|
|
326
|
+
return Boolean(entry && (dependencyMatches(project, 'flask') || dependencyMatches(project, 'flask-restful') || dependencyMatches(project, 'flask-cors')));
|
|
327
327
|
},
|
|
328
328
|
commands(project) {
|
|
329
329
|
const entry = findPythonEntry(project.path);
|
|
@@ -345,7 +345,7 @@ const builtInFrameworks = [
|
|
|
345
345
|
priority: 105,
|
|
346
346
|
match(project) {
|
|
347
347
|
const entry = findPythonEntry(project.path);
|
|
348
|
-
return Boolean(entry && dependencyMatches(project, 'fastapi'));
|
|
348
|
+
return Boolean(entry && (dependencyMatches(project, 'fastapi') || dependencyMatches(project, 'pydantic') || dependencyMatches(project, 'uvicorn')));
|
|
349
349
|
},
|
|
350
350
|
commands(project) {
|
|
351
351
|
const entry = findPythonEntry(project.path);
|
|
@@ -359,6 +359,59 @@ const builtInFrameworks = [
|
|
|
359
359
|
};
|
|
360
360
|
}
|
|
361
361
|
},
|
|
362
|
+
{
|
|
363
|
+
id: 'vite',
|
|
364
|
+
name: 'Vite',
|
|
365
|
+
icon: '⚡',
|
|
366
|
+
description: 'Vite-powered frontend',
|
|
367
|
+
languages: ['Node.js'],
|
|
368
|
+
priority: 100,
|
|
369
|
+
match(project) {
|
|
370
|
+
return hasProjectFile(project.path, 'vite.config.js') || hasProjectFile(project.path, 'vite.config.ts') || dependencyMatches(project, 'vite');
|
|
371
|
+
},
|
|
372
|
+
commands(project) {
|
|
373
|
+
const commands = {};
|
|
374
|
+
const add = (key, label, fallback) => {
|
|
375
|
+
const tokens = resolveScriptCommand(project, key, fallback);
|
|
376
|
+
if (tokens) {
|
|
377
|
+
commands[key] = {label, command: tokens, source: 'framework'};
|
|
378
|
+
}
|
|
379
|
+
};
|
|
380
|
+
add('run', 'Vite dev', () => ['npx', 'vite']);
|
|
381
|
+
add('build', 'Vite build', () => ['npx', 'vite', 'build']);
|
|
382
|
+
add('preview', 'Vite preview', () => ['npx', 'vite', 'preview']);
|
|
383
|
+
return commands;
|
|
384
|
+
}
|
|
385
|
+
},
|
|
386
|
+
{
|
|
387
|
+
id: 'tailwind',
|
|
388
|
+
name: 'Tailwind CSS',
|
|
389
|
+
icon: '🎨',
|
|
390
|
+
description: 'Tailwind utility-first CSS',
|
|
391
|
+
languages: ['Node.js'],
|
|
392
|
+
priority: 50,
|
|
393
|
+
match(project) {
|
|
394
|
+
return hasProjectFile(project.path, 'tailwind.config.js') || hasProjectFile(project.path, 'tailwind.config.ts') || dependencyMatches(project, 'tailwindcss');
|
|
395
|
+
},
|
|
396
|
+
commands() { return {}; }
|
|
397
|
+
},
|
|
398
|
+
{
|
|
399
|
+
id: 'prisma',
|
|
400
|
+
name: 'Prisma',
|
|
401
|
+
icon: '◮',
|
|
402
|
+
description: 'Prisma ORM',
|
|
403
|
+
languages: ['Node.js'],
|
|
404
|
+
priority: 50,
|
|
405
|
+
match(project) {
|
|
406
|
+
return hasProjectFile(project.path, 'prisma/schema.prisma') || dependencyMatches(project, '@prisma/client');
|
|
407
|
+
},
|
|
408
|
+
commands() {
|
|
409
|
+
return {
|
|
410
|
+
generate: {label: 'Prisma generate', command: ['npx', 'prisma', 'generate'], source: 'framework'},
|
|
411
|
+
studio: {label: 'Prisma studio', command: ['npx', 'prisma', 'studio'], source: 'framework'}
|
|
412
|
+
};
|
|
413
|
+
}
|
|
414
|
+
},
|
|
362
415
|
{
|
|
363
416
|
id: 'spring',
|
|
364
417
|
name: 'Spring Boot',
|