@gitset-dev/cli 1.2.1 → 2.0.1
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/{LICENSE.md → LICENSE} +1 -1
- package/README.md +80 -159
- package/index.js +230 -0
- package/lib/ai/capabilities.js +81 -0
- package/lib/ai/errors.js +115 -0
- package/lib/ai/index.js +98 -0
- package/lib/ai/providers/anthropic.js +62 -0
- package/lib/ai/providers/gemini.js +77 -0
- package/lib/ai/providers/mock.js +41 -0
- package/lib/ai/providers/openai-compatible.js +72 -0
- package/lib/ai/retry.js +31 -0
- package/lib/cli-commit.js +143 -0
- package/lib/cli-config.js +207 -0
- package/lib/cli-issue.js +144 -0
- package/lib/cli-pr.js +168 -0
- package/lib/cli-readme.js +153 -0
- package/lib/commit-local.js +67 -0
- package/lib/config.js +134 -0
- package/lib/generate-local.js +59 -0
- package/lib/labels-ai.js +41 -0
- package/lib/prompts/defaults.js +191 -0
- package/lib/prompts/index.js +79 -0
- package/lib/setup-wizard.js +115 -0
- package/package.json +34 -35
- package/src/commands/dependabot-resolver.js +314 -0
- package/src/commands/gitignore.js +110 -0
- package/src/commands/init.js +38 -0
- package/src/commands/labelspack.js +94 -0
- package/src/commands/license.js +208 -0
- package/src/commands/release.js +180 -0
- package/src/commands/repo.js +176 -0
- package/src/commands/status.js +56 -0
- package/src/commands/template.js +92 -0
- package/src/commands/tree.js +77 -0
- package/src/utils/dependabot-analyzer.js +101 -0
- package/src/utils/labels.js +102 -0
- package/src/utils/theme.js +70 -0
- package/src/utils/ui.js +173 -0
- package/.github/workflows/npm_publish.yml +0 -38
- package/src/index.js +0 -505
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* `gitset template` — manage LOCAL templates in ~/.gitset/templates.
|
|
5
|
+
* No remote template library (that was the old hosted system).
|
|
6
|
+
* gitset template list
|
|
7
|
+
* gitset template show <name>
|
|
8
|
+
* gitset template edit <name>
|
|
9
|
+
* gitset template path
|
|
10
|
+
*/
|
|
11
|
+
const fs = require('fs');
|
|
12
|
+
const os = require('os');
|
|
13
|
+
const path = require('path');
|
|
14
|
+
const { spawnSync } = require('child_process');
|
|
15
|
+
const theme = require('../utils/theme');
|
|
16
|
+
|
|
17
|
+
const DIR = process.env.GITSET_CONFIG_DIR || path.join(os.homedir(), '.gitset');
|
|
18
|
+
const TPL_DIR = path.join(DIR, 'templates');
|
|
19
|
+
const KNOWN_TOOLS = ['commit', 'pr', 'issue', 'readme', 'release'];
|
|
20
|
+
|
|
21
|
+
const DEFAULTS = {
|
|
22
|
+
'commit.md': `# Commit style\nUse Conventional Commits. Imperative subject <= 72 chars.\nBody explains the WHY for non-trivial changes.\n`,
|
|
23
|
+
'pr.md': `## Summary\n\n## Changes\n\n## Testing\n`,
|
|
24
|
+
'issue.md': `## Summary\n\n## Steps / Acceptance criteria\n\n## Scope\n`,
|
|
25
|
+
'readme.md': `# {{project}}\n\n> short description\n\n## Install\n\n## Usage\n`,
|
|
26
|
+
'release.md': `## Highlights\n\n## Features\n\n## Fixes\n`,
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
function listFiles() {
|
|
30
|
+
try { return fs.readdirSync(TPL_DIR).filter((f) => f.endsWith('.md')).sort(); }
|
|
31
|
+
catch { return []; }
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function runTemplateCommand(argv) {
|
|
35
|
+
const sub = argv[0] || 'list';
|
|
36
|
+
|
|
37
|
+
if (sub === 'path') { console.log(TPL_DIR); return 0; }
|
|
38
|
+
|
|
39
|
+
if (sub === 'list') {
|
|
40
|
+
const files = listFiles();
|
|
41
|
+
if (files.length === 0) {
|
|
42
|
+
console.log(`No local templates. Run ${theme.accent('gitset init')} to scaffold them, or ${theme.accent('gitset template edit <tool>')} to create one now.`);
|
|
43
|
+
return 0;
|
|
44
|
+
}
|
|
45
|
+
console.log(theme.bold(`Local templates (${TPL_DIR}):`));
|
|
46
|
+
for (const f of files) console.log(` ${f.replace(/\.md$/, '')}`);
|
|
47
|
+
console.log();
|
|
48
|
+
console.log(theme.dim('gitset template edit <name> open one in your editor'));
|
|
49
|
+
return 0;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
if (sub === 'show') {
|
|
53
|
+
const name = argv[1];
|
|
54
|
+
if (!name) { console.error(`${theme.error('✗')} Usage: gitset template show <name>`); return 1; }
|
|
55
|
+
const f = path.join(TPL_DIR, name.endsWith('.md') ? name : `${name}.md`);
|
|
56
|
+
if (!fs.existsSync(f)) { console.error(`${theme.error('✗')} No template "${name}". Try: gitset template list`); return 1; }
|
|
57
|
+
process.stdout.write(fs.readFileSync(f, 'utf8'));
|
|
58
|
+
return 0;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
if (sub === 'edit') {
|
|
62
|
+
const name = argv[1];
|
|
63
|
+
if (!name) {
|
|
64
|
+
console.error(`${theme.error('✗')} Usage: gitset template edit <${KNOWN_TOOLS.join('|')}>`);
|
|
65
|
+
return 1;
|
|
66
|
+
}
|
|
67
|
+
const base = name.endsWith('.md') ? name : `${name}.md`;
|
|
68
|
+
const f = path.join(TPL_DIR, base);
|
|
69
|
+
if (!fs.existsSync(f)) {
|
|
70
|
+
fs.mkdirSync(TPL_DIR, { recursive: true, mode: 0o700 });
|
|
71
|
+
fs.writeFileSync(f, DEFAULTS[base] || `# ${name} template\n`);
|
|
72
|
+
console.log(theme.dim(`Created ${f} from the default.`));
|
|
73
|
+
}
|
|
74
|
+
const editor = process.env.VISUAL || process.env.EDITOR;
|
|
75
|
+
if (!editor) {
|
|
76
|
+
console.log(`No $EDITOR set. Open it yourself:\n ${f}`);
|
|
77
|
+
return 0;
|
|
78
|
+
}
|
|
79
|
+
const result = spawnSync(editor, [f], { stdio: 'inherit' });
|
|
80
|
+
if (result.error) {
|
|
81
|
+
console.error(`${theme.error('✗')} Could not launch "${editor}". Edit the file directly:\n ${f}`);
|
|
82
|
+
return 1;
|
|
83
|
+
}
|
|
84
|
+
console.log(`${theme.success('✓')} Saved ${base.replace(/\.md$/, '')} template.`);
|
|
85
|
+
return 0;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
console.error(`${theme.error('✗')} Unknown subcommand "${sub}". Use: list | show <name> | edit <name> | path`);
|
|
89
|
+
return 1;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
module.exports = { runTemplateCommand };
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* `gitset tree` — local repo structure visualizer. No backend, no AI.
|
|
5
|
+
* Flags: --all (include dotfiles) --depth <n> --exclude <pat> (repeatable)
|
|
6
|
+
* --no-gitignore (don't honor .gitignore)
|
|
7
|
+
*/
|
|
8
|
+
const fs = require('fs');
|
|
9
|
+
const path = require('path');
|
|
10
|
+
|
|
11
|
+
const tty = () => process.stdout.isTTY;
|
|
12
|
+
const c = (code, s) => (tty() ? `\x1b[${code}m${s}\x1b[0m` : s);
|
|
13
|
+
|
|
14
|
+
function loadGitignore(root) {
|
|
15
|
+
const f = path.join(root, '.gitignore');
|
|
16
|
+
if (!fs.existsSync(f)) return [];
|
|
17
|
+
return fs.readFileSync(f, 'utf8').split('\n')
|
|
18
|
+
.map((l) => l.trim()).filter((l) => l && !l.startsWith('#'))
|
|
19
|
+
.map((l) => l.replace(/\/$/, ''));
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function makeExcluder(patterns) {
|
|
23
|
+
const set = new Set(['.git', ...patterns]);
|
|
24
|
+
return (name) => {
|
|
25
|
+
if (set.has(name)) return true;
|
|
26
|
+
for (const p of patterns) {
|
|
27
|
+
if (p.startsWith('*.') && name.endsWith(p.slice(1))) return true;
|
|
28
|
+
if (p === name) return true;
|
|
29
|
+
}
|
|
30
|
+
return false;
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function walk(dir, prefix, opts, depth, stats) {
|
|
35
|
+
if (opts.maxDepth >= 0 && depth > opts.maxDepth) return;
|
|
36
|
+
let entries;
|
|
37
|
+
try { entries = fs.readdirSync(dir, { withFileTypes: true }); } catch { return; }
|
|
38
|
+
entries = entries
|
|
39
|
+
.filter((e) => opts.all || !e.name.startsWith('.') || e.name === '.github' || e.name === '.gitignore')
|
|
40
|
+
.filter((e) => !opts.exclude(e.name))
|
|
41
|
+
.sort((a, b) => (a.isDirectory() === b.isDirectory() ? a.name.localeCompare(b.name) : a.isDirectory() ? -1 : 1));
|
|
42
|
+
|
|
43
|
+
entries.forEach((e, i) => {
|
|
44
|
+
const last = i === entries.length - 1;
|
|
45
|
+
const branch = last ? '└── ' : '├── ';
|
|
46
|
+
if (e.isDirectory()) {
|
|
47
|
+
stats.dirs++;
|
|
48
|
+
console.log(prefix + branch + c('36', e.name + '/'));
|
|
49
|
+
walk(path.join(dir, e.name), prefix + (last ? ' ' : '│ '), opts, depth + 1, stats);
|
|
50
|
+
} else {
|
|
51
|
+
stats.files++;
|
|
52
|
+
console.log(prefix + branch + e.name);
|
|
53
|
+
}
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function runTreeCommand(argv) {
|
|
58
|
+
const root = process.cwd();
|
|
59
|
+
const exclude = [];
|
|
60
|
+
for (let i = 0; i < argv.length; i++) {
|
|
61
|
+
if (argv[i] === '--exclude' && argv[i + 1]) { exclude.push(argv[i + 1]); i++; }
|
|
62
|
+
}
|
|
63
|
+
const depthIdx = argv.indexOf('--depth');
|
|
64
|
+
const maxDepth = depthIdx !== -1 && argv[depthIdx + 1] ? parseInt(argv[depthIdx + 1], 10) : -1;
|
|
65
|
+
const useGitignore = !argv.includes('--no-gitignore');
|
|
66
|
+
const patterns = [...exclude, ...(useGitignore ? loadGitignore(root) : [])];
|
|
67
|
+
|
|
68
|
+
const opts = { all: argv.includes('--all'), maxDepth, exclude: makeExcluder(patterns) };
|
|
69
|
+
const stats = { dirs: 0, files: 0 };
|
|
70
|
+
|
|
71
|
+
console.log(c('1', path.basename(root) + '/'));
|
|
72
|
+
walk(root, '', opts, 0, stats);
|
|
73
|
+
console.log(c('90', `\n${stats.dirs} directories, ${stats.files} files`));
|
|
74
|
+
return 0;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
module.exports = { runTreeCommand };
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
class DependabotAnalyzer {
|
|
2
|
+
constructor() {
|
|
3
|
+
this.RISK_LEVELS = {
|
|
4
|
+
NONE: 'RISK_NONE',
|
|
5
|
+
LOW: 'RISK_LOW_BEHAVIORAL',
|
|
6
|
+
MODERATE: 'RISK_MODERATE_API',
|
|
7
|
+
HIGH: 'RISK_HIGH_BREAKING'
|
|
8
|
+
};
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
parseSemver(version) {
|
|
12
|
+
const cleanVersion = version.replace(/^v/, '');
|
|
13
|
+
const parts = cleanVersion.split('.').map(Number);
|
|
14
|
+
|
|
15
|
+
while (parts.length < 3) {
|
|
16
|
+
parts.push(0);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
return {
|
|
20
|
+
major: parts[0],
|
|
21
|
+
minor: parts[1],
|
|
22
|
+
patch: parts[2],
|
|
23
|
+
original: version
|
|
24
|
+
};
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
getUpdateType(current, target) {
|
|
28
|
+
const v1 = this.parseSemver(current);
|
|
29
|
+
const v2 = this.parseSemver(target);
|
|
30
|
+
|
|
31
|
+
if (v1.major === v2.major && v1.minor === v2.minor && v1.patch === v2.patch) return 'EQUAL';
|
|
32
|
+
if (v2.major > v1.major) return 'MAJOR';
|
|
33
|
+
if (v2.minor > v1.minor) return 'MINOR';
|
|
34
|
+
if (v2.patch > v1.patch) return 'PATCH';
|
|
35
|
+
return 'UNKNOWN';
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
analyzeRisk(alert) {
|
|
39
|
+
const dependency = alert.dependency.package.name;
|
|
40
|
+
const ecosystem = alert.dependency.package.ecosystem;
|
|
41
|
+
|
|
42
|
+
const patchedVersion = alert.security_advisory.patched_versions
|
|
43
|
+
? alert.security_advisory.patched_versions[0]
|
|
44
|
+
: null;
|
|
45
|
+
|
|
46
|
+
if (!patchedVersion) {
|
|
47
|
+
return {
|
|
48
|
+
level: this.RISK_LEVELS.HIGH,
|
|
49
|
+
reason: 'Could not determine patched version',
|
|
50
|
+
updateType: 'UNKNOWN'
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
return {
|
|
55
|
+
level: this.RISK_LEVELS.HIGH,
|
|
56
|
+
targetVersion: patchedVersion.identifier,
|
|
57
|
+
reason: 'Pending local file analysis'
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
calculateRisk(currentVersion, targetVersion, ecosystem) {
|
|
62
|
+
const updateType = this.getUpdateType(currentVersion, targetVersion);
|
|
63
|
+
let risk = this.RISK_LEVELS.HIGH;
|
|
64
|
+
let reason = '';
|
|
65
|
+
|
|
66
|
+
switch (updateType) {
|
|
67
|
+
case 'EQUAL':
|
|
68
|
+
risk = this.RISK_LEVELS.NONE;
|
|
69
|
+
reason = 'Already on patched version';
|
|
70
|
+
break;
|
|
71
|
+
case 'MAJOR':
|
|
72
|
+
risk = this.RISK_LEVELS.HIGH;
|
|
73
|
+
reason = 'Major version update - likely breaking changes';
|
|
74
|
+
break;
|
|
75
|
+
case 'MINOR':
|
|
76
|
+
|
|
77
|
+
if (ecosystem === 'npm' || ecosystem === 'pip') {
|
|
78
|
+
risk = this.RISK_LEVELS.LOW;
|
|
79
|
+
} else {
|
|
80
|
+
risk = this.RISK_LEVELS.MODERATE;
|
|
81
|
+
}
|
|
82
|
+
reason = 'Minor version update - check changelog for behavioral changes';
|
|
83
|
+
break;
|
|
84
|
+
case 'PATCH':
|
|
85
|
+
risk = this.RISK_LEVELS.NONE;
|
|
86
|
+
reason = 'Patch update - bug fixes only';
|
|
87
|
+
break;
|
|
88
|
+
default:
|
|
89
|
+
risk = this.RISK_LEVELS.HIGH;
|
|
90
|
+
reason = 'Unknown update type';
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
return {
|
|
94
|
+
level: risk,
|
|
95
|
+
reason,
|
|
96
|
+
updateType
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
module.exports = DependabotAnalyzer;
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Local label-pack util. No backend, no gitset_key — the old remote
|
|
5
|
+
* /api/issue label CRUD has been removed. Source of truth is a local
|
|
6
|
+
* ~/.gitset/labels.md (a fenced ```yaml block) or a built-in default set.
|
|
7
|
+
* Live repo labels are read via the user's authenticated `gh` CLI.
|
|
8
|
+
*/
|
|
9
|
+
const fs = require('fs');
|
|
10
|
+
const os = require('os');
|
|
11
|
+
const path = require('path');
|
|
12
|
+
const { execFileSync } = require('child_process');
|
|
13
|
+
|
|
14
|
+
const DIR = process.env.GITSET_CONFIG_DIR || path.join(os.homedir(), '.gitset');
|
|
15
|
+
const LABELS_FILE = path.join(DIR, 'labels.md');
|
|
16
|
+
|
|
17
|
+
const DEFAULT_LABELS = [
|
|
18
|
+
{ name: 'bug', color: 'd73a4a', description: "Something isn't working" },
|
|
19
|
+
{ name: 'enhancement', color: 'a2eeef', description: 'New feature or request' },
|
|
20
|
+
{ name: 'documentation', color: '0075ca', description: 'Documentation changes' },
|
|
21
|
+
{ name: 'question', color: 'd876e3', description: 'Further information requested' },
|
|
22
|
+
{ name: 'good first issue', color: '7057ff', description: 'Good for newcomers' },
|
|
23
|
+
{ name: 'help wanted', color: '008672', description: 'Extra attention is needed' },
|
|
24
|
+
{ name: 'dependencies', color: '0366d6', description: 'Dependency updates' },
|
|
25
|
+
{ name: 'security', color: 'b60205', description: 'Security-related' },
|
|
26
|
+
{ name: 'refactor', color: 'fbca04', description: 'Code refactor, no behavior change' },
|
|
27
|
+
{ name: 'wontfix', color: 'ffffff', description: 'This will not be worked on' },
|
|
28
|
+
{ name: 'duplicate', color: 'cfd3d7', description: 'Already exists' },
|
|
29
|
+
];
|
|
30
|
+
|
|
31
|
+
function parseLabelsYaml(content) {
|
|
32
|
+
const labels = [];
|
|
33
|
+
let cur = null;
|
|
34
|
+
for (let line of String(content).split('\n')) {
|
|
35
|
+
line = line.trim();
|
|
36
|
+
if (!line || line.startsWith('#')) continue;
|
|
37
|
+
if (line.startsWith('- name:')) {
|
|
38
|
+
if (cur) labels.push(cur);
|
|
39
|
+
cur = { name: unquote(line.replace('- name:', '').trim()) };
|
|
40
|
+
} else if (cur && line.startsWith('color:')) {
|
|
41
|
+
cur.color = unquote(line.replace('color:', '').trim());
|
|
42
|
+
} else if (cur && line.startsWith('description:')) {
|
|
43
|
+
cur.description = unquote(line.replace('description:', '').trim());
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
if (cur) labels.push(cur);
|
|
47
|
+
return labels;
|
|
48
|
+
}
|
|
49
|
+
function unquote(s) {
|
|
50
|
+
return (s.startsWith('"') && s.endsWith('"')) || (s.startsWith("'") && s.endsWith("'")) ? s.slice(1, -1) : s;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function serializePack(labels) {
|
|
54
|
+
const body = labels.map((l) =>
|
|
55
|
+
` - name: "${l.name}"\n color: "${(l.color || '').replace('#', '')}"\n description: "${l.description || ''}"`).join('\n');
|
|
56
|
+
return `# Gitset label pack — edit freely.\n\n\`\`\`yaml\n${body}\n\`\`\`\n`;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function getLabels() {
|
|
60
|
+
if (fs.existsSync(LABELS_FILE)) {
|
|
61
|
+
const content = fs.readFileSync(LABELS_FILE, 'utf8');
|
|
62
|
+
const m = content.match(/```ya?ml([\s\S]*?)```/);
|
|
63
|
+
const labels = m ? parseLabelsYaml(m[1]) : parseLabelsYaml(content);
|
|
64
|
+
if (labels.length) return { source: `local ${path.relative(os.homedir(), LABELS_FILE) || LABELS_FILE}`, labels };
|
|
65
|
+
}
|
|
66
|
+
return { source: 'built-in default pack', labels: DEFAULT_LABELS };
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function writeDefaultPack() {
|
|
70
|
+
fs.mkdirSync(DIR, { recursive: true, mode: 0o700 });
|
|
71
|
+
fs.writeFileSync(LABELS_FILE, serializePack(DEFAULT_LABELS));
|
|
72
|
+
return LABELS_FILE;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function addLabel(label) {
|
|
76
|
+
if (!label || !label.name) return false;
|
|
77
|
+
const { labels } = fs.existsSync(LABELS_FILE) ? getLabels() : { labels: [...DEFAULT_LABELS] };
|
|
78
|
+
if (labels.some((l) => l.name === label.name)) {
|
|
79
|
+
const i = labels.findIndex((l) => l.name === label.name);
|
|
80
|
+
labels[i] = { ...labels[i], ...label };
|
|
81
|
+
} else {
|
|
82
|
+
labels.push({ name: label.name, color: (label.color || '').replace('#', ''), description: label.description || '' });
|
|
83
|
+
}
|
|
84
|
+
fs.mkdirSync(DIR, { recursive: true, mode: 0o700 });
|
|
85
|
+
fs.writeFileSync(LABELS_FILE, serializePack(labels));
|
|
86
|
+
return true;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function getRepoLabels() {
|
|
90
|
+
try {
|
|
91
|
+
const out = execFileSync('gh', ['label', 'list', '--limit', '200', '--json', 'name,color,description'],
|
|
92
|
+
{ encoding: 'utf8', stdio: ['pipe', 'pipe', 'ignore'] });
|
|
93
|
+
return JSON.parse(out);
|
|
94
|
+
} catch {
|
|
95
|
+
return [];
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
module.exports = {
|
|
100
|
+
LABELS_FILE, DEFAULT_LABELS,
|
|
101
|
+
getLabels, addLabel, writeDefaultPack, getRepoLabels, parseLabelsYaml,
|
|
102
|
+
};
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Shared terminal color system — same teal accent as the Gitset web app
|
|
5
|
+
* (see gitset-web/DESIGN.md), split into a dark-terminal tone and a
|
|
6
|
+
* light-terminal tone because a bright cyan is unreadable on a white
|
|
7
|
+
* background. Respects NO_COLOR (https://no-color.org) and non-TTY output.
|
|
8
|
+
*/
|
|
9
|
+
const config = require('../../lib/config');
|
|
10
|
+
|
|
11
|
+
const tty = () => Boolean(process.stdout.isTTY) && !process.env.NO_COLOR;
|
|
12
|
+
|
|
13
|
+
// Same hue family (~178°) as the web's --brand token, at two lightness
|
|
14
|
+
// levels per mode so there's still hierarchy (primary vs. quieter accent)
|
|
15
|
+
// even in a small terminal palette.
|
|
16
|
+
const PALETTE = {
|
|
17
|
+
dark: {
|
|
18
|
+
accent: [108, 224, 219], // #6CE0DB
|
|
19
|
+
accentDim: [79, 173, 168],
|
|
20
|
+
},
|
|
21
|
+
light: {
|
|
22
|
+
accent: [25, 118, 114], // #197672
|
|
23
|
+
accentDim: [15, 83, 80],
|
|
24
|
+
},
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
function detectDefaultMode() {
|
|
28
|
+
// Many terminals set COLORFGBG as "fg;bg" (0-15 ANSI index). A light
|
|
29
|
+
// background index means a light theme; anything else, assume dark —
|
|
30
|
+
// by far the more common terminal default.
|
|
31
|
+
const fgbg = process.env.COLORFGBG;
|
|
32
|
+
if (fgbg) {
|
|
33
|
+
const bg = parseInt(fgbg.split(';').pop(), 10);
|
|
34
|
+
if (!Number.isNaN(bg) && bg >= 7) return 'light';
|
|
35
|
+
}
|
|
36
|
+
return 'dark';
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function mode() {
|
|
40
|
+
const stored = config.getTheme();
|
|
41
|
+
return stored === 'light' ? 'light' : stored === 'dark' ? 'dark' : detectDefaultMode();
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function rgb(triplet, s) {
|
|
45
|
+
if (!tty()) return s;
|
|
46
|
+
const [r, g, b] = triplet;
|
|
47
|
+
return `\x1b[38;2;${r};${g};${b}m${s}\x1b[0m`;
|
|
48
|
+
}
|
|
49
|
+
function code(n, s) {
|
|
50
|
+
return tty() ? `\x1b[${n}m${s}\x1b[0m` : s;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const accent = (s) => rgb(PALETTE[mode()].accent, s);
|
|
54
|
+
const accentDim = (s) => rgb(PALETTE[mode()].accentDim, s);
|
|
55
|
+
const bold = (s) => code('1', s);
|
|
56
|
+
const dim = (s) => code('2', s);
|
|
57
|
+
const error = (s) => code('31', s);
|
|
58
|
+
const warn = (s) => code('33', s);
|
|
59
|
+
// No separate "success" hue, mirroring the web decision: the brand accent
|
|
60
|
+
// IS the affirmative color everywhere, not a second green.
|
|
61
|
+
const success = (s) => accent(s);
|
|
62
|
+
|
|
63
|
+
// Highlights the bracketed letter of a menu hint in accent+bold and leaves
|
|
64
|
+
// the rest of the word plain, e.g. key('a', 'ccept') -> "[a]ccept" with
|
|
65
|
+
// only "[a]" colored — makes the actionable keystroke pop at a glance.
|
|
66
|
+
function key(letter, rest) {
|
|
67
|
+
return `${accent(bold(`[${letter}]`))}${rest}`;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
module.exports = { mode, accent, accentDim, bold, dim, error, warn, success, key, tty };
|
package/src/utils/ui.js
ADDED
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
const readline = require('readline');
|
|
2
|
+
const theme = require('./theme');
|
|
3
|
+
|
|
4
|
+
const tty = () => process.stdout.isTTY;
|
|
5
|
+
|
|
6
|
+
// Legacy color names kept so existing call sites don't need to change, now
|
|
7
|
+
// routed through the shared teal theme (see theme.js) instead of a fixed
|
|
8
|
+
// palette that only worked on dark backgrounds. 'green' and 'cyan' both
|
|
9
|
+
// resolve to the same brand hue (matching the web decision: no separate
|
|
10
|
+
// "success" color) at two lightness tiers, which also keeps the severity
|
|
11
|
+
// ladder in dependabot-resolver.js (red > yellow > cyan > green) visually
|
|
12
|
+
// distinct without a fifth hue. Each value is a paint FUNCTION (not a raw
|
|
13
|
+
// ANSI prefix string) since the color depends on the live theme setting.
|
|
14
|
+
const colors = {
|
|
15
|
+
reset: (s) => s,
|
|
16
|
+
green: theme.accent,
|
|
17
|
+
yellow: theme.warn,
|
|
18
|
+
red: theme.error,
|
|
19
|
+
cyan: theme.accentDim,
|
|
20
|
+
blue: theme.dim,
|
|
21
|
+
pink: theme.warn,
|
|
22
|
+
dim: theme.dim,
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
function log(msg, color = 'reset') {
|
|
26
|
+
const paint = colors[color] || ((s) => s);
|
|
27
|
+
console.log(paint(msg));
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function askQuestion(query) {
|
|
31
|
+
if (!process.stdin.isTTY) {
|
|
32
|
+
return Promise.reject(new Error(
|
|
33
|
+
'This step needs an interactive terminal. Re-run in a terminal, or pass explicit flags to skip prompts.',
|
|
34
|
+
));
|
|
35
|
+
}
|
|
36
|
+
const rl = readline.createInterface({
|
|
37
|
+
input: process.stdin,
|
|
38
|
+
output: process.stdout,
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
return new Promise((resolve, reject) => {
|
|
42
|
+
let answered = false;
|
|
43
|
+
rl.question(query, (ans) => {
|
|
44
|
+
answered = true;
|
|
45
|
+
rl.close();
|
|
46
|
+
resolve(ans.trim());
|
|
47
|
+
});
|
|
48
|
+
rl.on('close', () => {
|
|
49
|
+
if (!answered) reject(new Error('Input closed before answering.'));
|
|
50
|
+
});
|
|
51
|
+
});
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// Masks input as it's typed (for API keys) instead of echoing it in plain
|
|
55
|
+
// text. Falls back to visible input if the terminal doesn't support raw
|
|
56
|
+
// mode (e.g. some CI wrappers) rather than failing outright.
|
|
57
|
+
function askSecret(query) {
|
|
58
|
+
if (!process.stdin.isTTY || typeof process.stdin.setRawMode !== 'function') {
|
|
59
|
+
return askQuestion(query);
|
|
60
|
+
}
|
|
61
|
+
return new Promise((resolve, reject) => {
|
|
62
|
+
process.stdout.write(query);
|
|
63
|
+
const stdin = process.stdin;
|
|
64
|
+
const wasRaw = stdin.isRaw;
|
|
65
|
+
stdin.setRawMode(true);
|
|
66
|
+
stdin.resume();
|
|
67
|
+
stdin.setEncoding('utf8');
|
|
68
|
+
|
|
69
|
+
let value = '';
|
|
70
|
+
function cleanup() {
|
|
71
|
+
stdin.removeListener('data', onData);
|
|
72
|
+
stdin.setRawMode(wasRaw || false);
|
|
73
|
+
stdin.pause();
|
|
74
|
+
}
|
|
75
|
+
function onData(chunk) {
|
|
76
|
+
const ch = String(chunk);
|
|
77
|
+
if (ch === '\r' || ch === '\n') {
|
|
78
|
+
cleanup();
|
|
79
|
+
process.stdout.write('\n');
|
|
80
|
+
resolve(value.trim());
|
|
81
|
+
return;
|
|
82
|
+
}
|
|
83
|
+
if (ch === '\u0003') { // Ctrl+C
|
|
84
|
+
cleanup();
|
|
85
|
+
process.stdout.write('\n');
|
|
86
|
+
reject(new Error('Cancelled.'));
|
|
87
|
+
return;
|
|
88
|
+
}
|
|
89
|
+
if (ch === '\u007f' || ch === '\b') { // backspace (DEL on most terminals, BS on some)
|
|
90
|
+
if (value.length) {
|
|
91
|
+
value = value.slice(0, -1);
|
|
92
|
+
process.stdout.write('\b \b');
|
|
93
|
+
}
|
|
94
|
+
return;
|
|
95
|
+
}
|
|
96
|
+
if (ch.charCodeAt(0) < 32) return; // ignore other control/escape bytes
|
|
97
|
+
value += ch;
|
|
98
|
+
process.stdout.write('*');
|
|
99
|
+
}
|
|
100
|
+
stdin.on('data', onData);
|
|
101
|
+
});
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
async function selectOption(title, options) {
|
|
105
|
+
log(`\n${title}`, 'reset');
|
|
106
|
+
options.forEach((opt, i) => {
|
|
107
|
+
log(`${i + 1}. ${opt.label}`, 'cyan');
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
while (true) {
|
|
111
|
+
const answer = await askQuestion('\nSelect an option (number): ');
|
|
112
|
+
const index = parseInt(answer) - 1;
|
|
113
|
+
if (index >= 0 && index < options.length) {
|
|
114
|
+
return options[index].value;
|
|
115
|
+
}
|
|
116
|
+
log('Invalid selection', 'red');
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
async function selectMultipleOptions(title, options) {
|
|
121
|
+
log(`\n${title}`, 'reset');
|
|
122
|
+
options.forEach((opt, i) => {
|
|
123
|
+
log(`${i + 1}. ${opt.label}`, 'cyan');
|
|
124
|
+
});
|
|
125
|
+
log('0. Done / Confirm Selection', 'green');
|
|
126
|
+
log('C. Cancel', 'red');
|
|
127
|
+
|
|
128
|
+
const selectedValues = new Set();
|
|
129
|
+
|
|
130
|
+
while (true) {
|
|
131
|
+
const currentSelection = options
|
|
132
|
+
.filter(opt => selectedValues.has(opt.value))
|
|
133
|
+
.map(opt => opt.label)
|
|
134
|
+
.join(', ');
|
|
135
|
+
|
|
136
|
+
if (currentSelection) {
|
|
137
|
+
log(`\nCurrent selection: ${currentSelection}`, 'yellow');
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
const answer = await askQuestion('\nSelect options (enter number to toggle, 0 to confirm, C to cancel): ');
|
|
141
|
+
const index = parseInt(answer) - 1;
|
|
142
|
+
|
|
143
|
+
if (answer === '0') {
|
|
144
|
+
return Array.from(selectedValues);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
if (answer.toLowerCase() === 'c') {
|
|
148
|
+
return null;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
if (index >= 0 && index < options.length) {
|
|
152
|
+
const value = options[index].value;
|
|
153
|
+
if (selectedValues.has(value)) {
|
|
154
|
+
selectedValues.delete(value);
|
|
155
|
+
log(`Removed: ${options[index].label}`, 'red');
|
|
156
|
+
} else {
|
|
157
|
+
selectedValues.add(value);
|
|
158
|
+
log(`Added: ${options[index].label}`, 'green');
|
|
159
|
+
}
|
|
160
|
+
} else {
|
|
161
|
+
log('Invalid selection', 'red');
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
module.exports = {
|
|
167
|
+
log,
|
|
168
|
+
askQuestion,
|
|
169
|
+
askSecret,
|
|
170
|
+
selectOption,
|
|
171
|
+
selectMultipleOptions,
|
|
172
|
+
colors
|
|
173
|
+
};
|
|
@@ -1,38 +0,0 @@
|
|
|
1
|
-
name: Publish to GitHub Packages
|
|
2
|
-
|
|
3
|
-
on:
|
|
4
|
-
workflow_dispatch:
|
|
5
|
-
push:
|
|
6
|
-
tags:
|
|
7
|
-
- 'v*'
|
|
8
|
-
|
|
9
|
-
jobs:
|
|
10
|
-
publish-gpr:
|
|
11
|
-
runs-on: ubuntu-latest
|
|
12
|
-
permissions:
|
|
13
|
-
contents: read
|
|
14
|
-
packages: write
|
|
15
|
-
steps:
|
|
16
|
-
- uses: actions/checkout@v4
|
|
17
|
-
|
|
18
|
-
- uses: actions/setup-node@v4
|
|
19
|
-
with:
|
|
20
|
-
node-version: '18'
|
|
21
|
-
registry-url: 'https://npm.pkg.github.com'
|
|
22
|
-
scope: '@gitset-dev'
|
|
23
|
-
|
|
24
|
-
- name: Update package name for GitHub Packages
|
|
25
|
-
run: |
|
|
26
|
-
cp package.json package.json.backup
|
|
27
|
-
npm pkg set name=@gitset-dev/gitset-cli
|
|
28
|
-
|
|
29
|
-
- name: Publish to GitHub Packages
|
|
30
|
-
run: |
|
|
31
|
-
echo "registry=https://npm.pkg.github.com" > .npmrc
|
|
32
|
-
echo "//npm.pkg.github.com/:_authToken=${NODE_AUTH_TOKEN}" >> .npmrc
|
|
33
|
-
npm publish --registry=https://npm.pkg.github.com
|
|
34
|
-
env:
|
|
35
|
-
NODE_AUTH_TOKEN: ${{ secrets.PAT_GITHUB }}
|
|
36
|
-
|
|
37
|
-
- name: Restore original package.json
|
|
38
|
-
run: mv package.json.backup package.json
|