@nitra/cursor 1.13.69 → 1.13.73
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/CHANGELOG.md +24 -0
- package/bin/n-cursor.js +11 -1
- package/package.json +1 -1
- package/scripts/skills-cli.mjs +219 -0
- package/scripts/utils/package-manifest.mjs +3 -3
- package/scripts/utils/workspaces.mjs +36 -4
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,30 @@
|
|
|
4
4
|
|
|
5
5
|
Формат — [Keep a Changelog](https://keepachangelog.com/uk/1.1.0/), нумерація — [SemVer](https://semver.org/lang/uk/).
|
|
6
6
|
|
|
7
|
+
## [1.13.73] - 2026-05-21
|
|
8
|
+
|
|
9
|
+
### Fixed
|
|
10
|
+
|
|
11
|
+
- **Збір workspace-коренів** — `getMonorepoPackageRootDirs` / `getMonorepoProjectRootDirs` більше не трактують `package.json` у `node_modules/`, `.git/`, `.venv/`, `venv/` як воркспейси (glob ignore + `isIgnoredWorkspaceRoot`). Усуває хибні `check changelog` на транзитивних залежностях (наприклад `node-gyp/gyp`).
|
|
12
|
+
|
|
13
|
+
## [1.13.72] - 2026-05-21
|
|
14
|
+
|
|
15
|
+
### Changed
|
|
16
|
+
|
|
17
|
+
- **CLI скілів спрощено** — лише `npx @nitra/cursor skill list`, `skill <id> ["task"]` (промпт на stdout), `skill cursor <id> ["task"]`, `skill claude <id> ["task"]`. Прибрано `skill prompt`, bins `n-skills` / `n-claude`, підкоманду `claude` у `n-cursor`. Зачеплено: [skills-cli.mjs](scripts/skills-cli.mjs).
|
|
18
|
+
|
|
19
|
+
## [1.13.71] - 2026-05-21
|
|
20
|
+
|
|
21
|
+
### Added
|
|
22
|
+
|
|
23
|
+
- **Claude-first UX для скілів** — `npx @nitra/cursor claude taze "task"` і bin **`n-claude`** (замінено спрощеним `skill` у 1.13.72).
|
|
24
|
+
|
|
25
|
+
## [1.13.70] - 2026-05-21
|
|
26
|
+
|
|
27
|
+
### Added
|
|
28
|
+
|
|
29
|
+
- **CLI скілів без синку в проєкт** — `npx @nitra/cursor skill list|prompt|claude|cursor <id> "task"` і bin **`n-skills`** (`npx -p @nitra/cursor n-skills …`). Читає `skills/<id>/SKILL.md` з установленого пакета, збирає промпт із CWD (`package.json`, `tsconfig.json`, `.n-cursor.json`) і виводить на stdout або делегує в `claude -p` / `cursor-agent -p`. Id скілу — каталог у пакеті (`lint`, `fix`, …) або з префіксом `n-` (`n-lint` → `lint`). Зачеплено: [skills-cli.mjs](scripts/skills-cli.mjs), [n-skills.js](bin/n-skills.js), [n-cursor.js](bin/n-cursor.js).
|
|
30
|
+
|
|
7
31
|
## [1.13.69] - 2026-05-21
|
|
8
32
|
|
|
9
33
|
### Changed
|
package/bin/n-cursor.js
CHANGED
|
@@ -19,6 +19,10 @@
|
|
|
19
19
|
* `npx \@nitra/cursor lint-docker` — канонічний lint-docker (docker.mdc): `hadolint` по `Dockerfile`/`*.Dockerfile`
|
|
20
20
|
* `npx \@nitra/cursor lint-text` — канонічний lint-text (text.mdc): `cspell` → `shellcheck` (з auto-fix) →
|
|
21
21
|
* `markdownlint-cli2 --fix` → `v8r` (json/json5/yaml/yml/toml)
|
|
22
|
+
* `npx \@nitra/cursor skill list` — скіли пакета без синку в проєкт
|
|
23
|
+
* `npx \@nitra/cursor skill taze` — промпт на stdout
|
|
24
|
+
* `npx \@nitra/cursor skill cursor taze ["task"]` — Cursor CLI (`cursor-agent -p`)
|
|
25
|
+
* `npx \@nitra/cursor skill claude taze ["task"]` — Claude Code CLI (`claude -p`)
|
|
22
26
|
*
|
|
23
27
|
* Agent інтеграція: під час синку, окрім `.cursor/rules` і `.claude/commands` (з skills), CLI ще раз
|
|
24
28
|
* синхронізує `.claude/settings.json` (hooks + permissions; merge — користувацькі поля зберігаються),
|
|
@@ -90,6 +94,7 @@ import { runRule } from '../scripts/utils/run-rule.mjs'
|
|
|
90
94
|
import { syncClaudeConfig } from '../scripts/sync-claude-config.mjs'
|
|
91
95
|
import { upgradeNitraCursorToLatestAndBunInstall } from '../scripts/upgrade-nitra-cursor-and-install.mjs'
|
|
92
96
|
import { runRenameYamlExtensionsCli } from './rename-yaml-extensions.mjs'
|
|
97
|
+
import { runSkillsCli } from '../scripts/skills-cli.mjs'
|
|
93
98
|
import { syncSetupBunDepsAction } from '../scripts/sync-setup-bun-deps-action.mjs'
|
|
94
99
|
|
|
95
100
|
const PACKAGE_NAME = '@nitra/cursor'
|
|
@@ -1304,6 +1309,11 @@ try {
|
|
|
1304
1309
|
|
|
1305
1310
|
break
|
|
1306
1311
|
}
|
|
1312
|
+
case 'skill': {
|
|
1313
|
+
process.exitCode = await runSkillsCli(args)
|
|
1314
|
+
|
|
1315
|
+
break
|
|
1316
|
+
}
|
|
1307
1317
|
case undefined:
|
|
1308
1318
|
case '': {
|
|
1309
1319
|
await runSync()
|
|
@@ -1313,7 +1323,7 @@ try {
|
|
|
1313
1323
|
default: {
|
|
1314
1324
|
console.error(`❌ Невідома команда: ${command}`)
|
|
1315
1325
|
console.error(
|
|
1316
|
-
` Очікується: (без аргументів) синхронізація правил, check, rename-yaml-extensions, stop-hook, lint-ga, lint-rego, lint-k8s, lint-docker, lint-text`
|
|
1326
|
+
` Очікується: (без аргументів) синхронізація правил, check, rename-yaml-extensions, stop-hook, lint-ga, lint-rego, lint-k8s, lint-docker, lint-text, skill`
|
|
1317
1327
|
)
|
|
1318
1328
|
process.exitCode = 1
|
|
1319
1329
|
}
|
package/package.json
CHANGED
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* CLI запуску скілів пакета `@nitra/cursor` без синку правил у проєкт.
|
|
3
|
+
*
|
|
4
|
+
* Скіли читаються з `npm/skills/<id>/SKILL.md` установленого пакета (або кешу `npx`).
|
|
5
|
+
* Промпт збирає інструкцію скілу + контекст поточного CWD (`package.json`, `tsconfig.json`,
|
|
6
|
+
* `.n-cursor.json`) — далі stdout або делегування в `cursor-agent` / `claude`.
|
|
7
|
+
*
|
|
8
|
+
* Підтримувані формати:
|
|
9
|
+
* `npx @nitra/cursor skill list`
|
|
10
|
+
* `npx @nitra/cursor skill taze`
|
|
11
|
+
* `npx @nitra/cursor skill cursor taze`
|
|
12
|
+
* `npx @nitra/cursor skill cursor taze "онови залежності"`
|
|
13
|
+
* `npx @nitra/cursor skill claude taze` — те саме через Claude Code CLI
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { spawnSync } from 'node:child_process'
|
|
17
|
+
import { existsSync, readFileSync, readdirSync } from 'node:fs'
|
|
18
|
+
import { dirname, join } from 'node:path'
|
|
19
|
+
import { cwd } from 'node:process'
|
|
20
|
+
import { fileURLToPath } from 'node:url'
|
|
21
|
+
|
|
22
|
+
const RUNNERS = new Set(['cursor', 'claude'])
|
|
23
|
+
|
|
24
|
+
const USAGE_LINES = [
|
|
25
|
+
'Usage:',
|
|
26
|
+
' npx @nitra/cursor skill list',
|
|
27
|
+
' npx @nitra/cursor skill <skill-id> ["task"]',
|
|
28
|
+
' npx @nitra/cursor skill cursor <skill-id> ["task"]',
|
|
29
|
+
' npx @nitra/cursor skill claude <skill-id> ["task"]',
|
|
30
|
+
'',
|
|
31
|
+
'Skill id: каталог у пакеті (lint, taze, …) або з префіксом n- (n-lint → lint).'
|
|
32
|
+
]
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* @param {string} name
|
|
36
|
+
* @returns {boolean}
|
|
37
|
+
*/
|
|
38
|
+
function isBinaryInPath(name) {
|
|
39
|
+
const probe = spawnSync('command', ['-v', name], { shell: true, encoding: 'utf8' })
|
|
40
|
+
return probe.status === 0
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* @param {string} name ім'я скілу з CLI або каталогу `.cursor/skills/n-*`
|
|
45
|
+
* @returns {string} id каталогу в `npm/skills/<id>/`
|
|
46
|
+
*/
|
|
47
|
+
export function normalizeSkillId(name) {
|
|
48
|
+
if (!name || typeof name !== 'string') {
|
|
49
|
+
return ''
|
|
50
|
+
}
|
|
51
|
+
return name.startsWith('n-') ? name.slice(2) : name
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* @param {string} skillsRoot абсолютний шлях до `skills/` пакета
|
|
56
|
+
* @returns {string[]}
|
|
57
|
+
*/
|
|
58
|
+
export function listSkillIds(skillsRoot) {
|
|
59
|
+
if (!existsSync(skillsRoot)) {
|
|
60
|
+
return []
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
return readdirSync(skillsRoot, { withFileTypes: true })
|
|
64
|
+
.filter(entry => entry.isDirectory())
|
|
65
|
+
.map(entry => entry.name)
|
|
66
|
+
.filter(name => existsSync(join(skillsRoot, name, 'SKILL.md')))
|
|
67
|
+
.sort((a, b) => a.localeCompare(b))
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* @param {string} skillsRoot
|
|
72
|
+
* @param {string} skillId нормалізований id (без префікса n-)
|
|
73
|
+
* @returns {string}
|
|
74
|
+
*/
|
|
75
|
+
function getSkillMdPath(skillsRoot, skillId) {
|
|
76
|
+
return join(skillsRoot, skillId, 'SKILL.md')
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* @param {string} path
|
|
81
|
+
* @returns {string | null}
|
|
82
|
+
*/
|
|
83
|
+
function readIfExists(path) {
|
|
84
|
+
return existsSync(path) ? readFileSync(path, 'utf8') : null
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* @param {string} skillsRoot
|
|
89
|
+
* @param {string} rawSkillName
|
|
90
|
+
* @param {string} task
|
|
91
|
+
* @param {string} [projectDir]
|
|
92
|
+
* @returns {string}
|
|
93
|
+
*/
|
|
94
|
+
export function buildSkillPrompt(skillsRoot, rawSkillName, task, projectDir = cwd()) {
|
|
95
|
+
const skillId = normalizeSkillId(rawSkillName)
|
|
96
|
+
const skillPath = getSkillMdPath(skillsRoot, skillId)
|
|
97
|
+
|
|
98
|
+
if (!skillId || !existsSync(skillPath)) {
|
|
99
|
+
const available = listSkillIds(skillsRoot).join(', ')
|
|
100
|
+
throw new Error(`Unknown skill "${rawSkillName}". Available skills: ${available || '(none)'}`)
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
const skill = readFileSync(skillPath, 'utf8')
|
|
104
|
+
const packageJson = readIfExists(join(projectDir, 'package.json'))
|
|
105
|
+
const tsconfig = readIfExists(join(projectDir, 'tsconfig.json'))
|
|
106
|
+
const nCursorJson = readIfExists(join(projectDir, '.n-cursor.json'))
|
|
107
|
+
|
|
108
|
+
return [
|
|
109
|
+
'# Task',
|
|
110
|
+
task || 'Execute the skill instructions for this project.',
|
|
111
|
+
'',
|
|
112
|
+
'# Skill',
|
|
113
|
+
skill,
|
|
114
|
+
'',
|
|
115
|
+
'# Current project',
|
|
116
|
+
`Directory: ${projectDir}`,
|
|
117
|
+
'',
|
|
118
|
+
packageJson ? `## package.json\n\n\`\`\`json\n${packageJson}\n\`\`\`` : '',
|
|
119
|
+
tsconfig ? `## tsconfig.json\n\n\`\`\`json\n${tsconfig}\n\`\`\`` : '',
|
|
120
|
+
nCursorJson ? `## .n-cursor.json\n\n\`\`\`json\n${nCursorJson}\n\`\`\`` : ''
|
|
121
|
+
]
|
|
122
|
+
.filter(Boolean)
|
|
123
|
+
.join('\n\n')
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* @param {'claude' | 'cursor'} kind
|
|
128
|
+
* @param {string} prompt
|
|
129
|
+
* @param {string} projectDir
|
|
130
|
+
* @returns {number}
|
|
131
|
+
*/
|
|
132
|
+
function runLlmCli(kind, prompt, projectDir) {
|
|
133
|
+
if (kind === 'claude') {
|
|
134
|
+
if (!isBinaryInPath('claude')) {
|
|
135
|
+
throw new Error('`claude` not found in PATH. Install Claude Code CLI or use `skill cursor`.')
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
const result = spawnSync('claude', ['-p'], {
|
|
139
|
+
input: prompt,
|
|
140
|
+
cwd: projectDir,
|
|
141
|
+
stdio: ['pipe', 'inherit', 'inherit'],
|
|
142
|
+
encoding: 'utf8'
|
|
143
|
+
})
|
|
144
|
+
return result.status ?? 1
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
if (!isBinaryInPath('cursor-agent')) {
|
|
148
|
+
throw new Error('`cursor-agent` not found in PATH. Install Cursor CLI or use `skill claude`.')
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
const result = spawnSync('cursor-agent', ['-p'], {
|
|
152
|
+
input: prompt,
|
|
153
|
+
cwd: projectDir,
|
|
154
|
+
stdio: ['pipe', 'inherit', 'inherit'],
|
|
155
|
+
encoding: 'utf8'
|
|
156
|
+
})
|
|
157
|
+
return result.status ?? 1
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* Корінь пакета `@nitra/cursor` (каталог з `skills/`, `rules/`, …).
|
|
162
|
+
* @param {string} [fromModuleUrl] для тестів — `import.meta.url` викликача
|
|
163
|
+
* @returns {string}
|
|
164
|
+
*/
|
|
165
|
+
export function resolveBundledPackageRoot(fromModuleUrl = import.meta.url) {
|
|
166
|
+
return join(dirname(fileURLToPath(fromModuleUrl)), '..')
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* @param {string[]} argv аргументи після `skill` у `n-cursor`
|
|
171
|
+
* @param {{ packageRoot?: string, projectDir?: string, log?: (line: string) => void, logError?: (line: string) => void }} [options]
|
|
172
|
+
* @returns {Promise<number>} exit code
|
|
173
|
+
*/
|
|
174
|
+
export async function runSkillsCli(argv, options = {}) {
|
|
175
|
+
const log = options.log ?? (line => console.log(line))
|
|
176
|
+
const logError = options.logError ?? (line => console.error(line))
|
|
177
|
+
const packageRoot = options.packageRoot ?? resolveBundledPackageRoot()
|
|
178
|
+
const skillsRoot = join(packageRoot, 'skills')
|
|
179
|
+
const projectDir = options.projectDir ?? cwd()
|
|
180
|
+
|
|
181
|
+
const [first, second, ...rest] = argv
|
|
182
|
+
const skillIds = listSkillIds(skillsRoot)
|
|
183
|
+
|
|
184
|
+
try {
|
|
185
|
+
if (!first) {
|
|
186
|
+
logError(USAGE_LINES.join('\n'))
|
|
187
|
+
return 1
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
if (first === 'list') {
|
|
191
|
+
log('Available skills:')
|
|
192
|
+
for (const id of skillIds) {
|
|
193
|
+
log(`- ${id}`)
|
|
194
|
+
}
|
|
195
|
+
return 0
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
if (RUNNERS.has(first)) {
|
|
199
|
+
if (!second) {
|
|
200
|
+
throw new Error(`Skill name is required after "${first}"`)
|
|
201
|
+
}
|
|
202
|
+
const task = rest.join(' ')
|
|
203
|
+
const prompt = buildSkillPrompt(skillsRoot, second, task, projectDir)
|
|
204
|
+
return runLlmCli(/** @type {'claude' | 'cursor'} */ (first), prompt, projectDir)
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
if (skillIds.includes(normalizeSkillId(first))) {
|
|
208
|
+
const task = [second, ...rest].filter(Boolean).join(' ')
|
|
209
|
+
log(buildSkillPrompt(skillsRoot, first, task, projectDir))
|
|
210
|
+
return 0
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
logError(USAGE_LINES.join('\n'))
|
|
214
|
+
return 1
|
|
215
|
+
} catch (error) {
|
|
216
|
+
logError(error instanceof Error ? error.message : String(error))
|
|
217
|
+
return 1
|
|
218
|
+
}
|
|
219
|
+
}
|
|
@@ -8,7 +8,7 @@ import { dirname, join, relative } from 'node:path'
|
|
|
8
8
|
|
|
9
9
|
import { parse as parseToml } from 'smol-toml'
|
|
10
10
|
|
|
11
|
-
import { getMonorepoPackageRootDirs } from './workspaces.mjs'
|
|
11
|
+
import { getMonorepoPackageRootDirs, isIgnoredWorkspaceRoot } from './workspaces.mjs'
|
|
12
12
|
|
|
13
13
|
/**
|
|
14
14
|
* @typedef {'npm' | 'python'} PackageKind
|
|
@@ -132,12 +132,12 @@ export async function getMonorepoProjectRootDirs(repoRoot = '.') {
|
|
|
132
132
|
const absDir = dirname(join(repoRoot, relPy))
|
|
133
133
|
const relRoot = relative(repoRoot, absDir)
|
|
134
134
|
const ws = relRoot === '' ? '.' : relRoot
|
|
135
|
-
if (!existsSync(join(repoRoot, ws, 'package.json'))) {
|
|
135
|
+
if (!isIgnoredWorkspaceRoot(ws) && !existsSync(join(repoRoot, ws, 'package.json'))) {
|
|
136
136
|
roots.add(ws)
|
|
137
137
|
}
|
|
138
138
|
}
|
|
139
139
|
|
|
140
|
-
const list = [...roots]
|
|
140
|
+
const list = [...roots].filter(ws => !isIgnoredWorkspaceRoot(ws))
|
|
141
141
|
list.sort((a, b) => {
|
|
142
142
|
if (a === '.') return -1
|
|
143
143
|
if (b === '.') return 1
|
|
@@ -9,6 +9,32 @@ import { glob, readFile } from 'node:fs/promises'
|
|
|
9
9
|
import { dirname, join, relative } from 'node:path'
|
|
10
10
|
|
|
11
11
|
const TRAILING_SLASH_RE = /\/$/
|
|
12
|
+
const LEADING_DOTSLASH_RE = /^\.\//
|
|
13
|
+
|
|
14
|
+
/** Glob-ігнор для workspace-патернів із `*` (узгоджено з `package-manifest.mjs`). */
|
|
15
|
+
export const WORKSPACE_GLOB_IGNORE = Object.freeze([
|
|
16
|
+
'**/node_modules/**',
|
|
17
|
+
'**/.git/**',
|
|
18
|
+
'**/.venv/**',
|
|
19
|
+
'**/venv/**'
|
|
20
|
+
])
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Чи слід виключити каталог зі списку workspace-коренів (не стосується `.`).
|
|
24
|
+
* @param {string} ws відносний шлях воркспейсу
|
|
25
|
+
* @returns {boolean} true — пропустити
|
|
26
|
+
*/
|
|
27
|
+
export function isIgnoredWorkspaceRoot(ws) {
|
|
28
|
+
if (ws === '.') return false
|
|
29
|
+
const p = ws.replaceAll('\\', '/').replace(LEADING_DOTSLASH_RE, '')
|
|
30
|
+
const segments = p.split('/')
|
|
31
|
+
return (
|
|
32
|
+
segments.includes('node_modules') ||
|
|
33
|
+
segments.includes('.git') ||
|
|
34
|
+
segments.includes('.venv') ||
|
|
35
|
+
segments.includes('venv')
|
|
36
|
+
)
|
|
37
|
+
}
|
|
12
38
|
|
|
13
39
|
/**
|
|
14
40
|
* Нормалізує workspace-патерн до POSIX-формату і прибирає хвостові `/`.
|
|
@@ -33,16 +59,22 @@ function normalizeWorkspacePattern(pattern) {
|
|
|
33
59
|
async function addWorkspaceRootsByPattern(roots, repoRoot, workspacePattern) {
|
|
34
60
|
if (workspacePattern.includes('*')) {
|
|
35
61
|
const globPat = `${workspacePattern}/package.json`
|
|
36
|
-
for await (const relPkgJsonPath of glob(globPat, {
|
|
62
|
+
for await (const relPkgJsonPath of glob(globPat, {
|
|
63
|
+
cwd: repoRoot,
|
|
64
|
+
ignore: [...WORKSPACE_GLOB_IGNORE]
|
|
65
|
+
})) {
|
|
37
66
|
const absPkgJsonPath = join(repoRoot, relPkgJsonPath)
|
|
38
67
|
const relRoot = relative(repoRoot, dirname(absPkgJsonPath))
|
|
39
|
-
|
|
68
|
+
const ws = relRoot === '' ? '.' : relRoot
|
|
69
|
+
if (!isIgnoredWorkspaceRoot(ws)) {
|
|
70
|
+
roots.add(ws)
|
|
71
|
+
}
|
|
40
72
|
}
|
|
41
73
|
return
|
|
42
74
|
}
|
|
43
75
|
|
|
44
76
|
const pkgJsonPath = join(repoRoot, workspacePattern, 'package.json')
|
|
45
|
-
if (existsSync(pkgJsonPath)) {
|
|
77
|
+
if (existsSync(pkgJsonPath) && !isIgnoredWorkspaceRoot(workspacePattern)) {
|
|
46
78
|
roots.add(workspacePattern)
|
|
47
79
|
}
|
|
48
80
|
}
|
|
@@ -77,7 +109,7 @@ export async function getMonorepoPackageRootDirs(repoRoot = '.') {
|
|
|
77
109
|
const workspacePattern = normalizeWorkspacePattern(raw)
|
|
78
110
|
await addWorkspaceRootsByPattern(roots, repoRoot, workspacePattern)
|
|
79
111
|
}
|
|
80
|
-
const list = [...roots]
|
|
112
|
+
const list = [...roots].filter(ws => !isIgnoredWorkspaceRoot(ws))
|
|
81
113
|
list.sort((a, b) => {
|
|
82
114
|
if (a === '.') return -1
|
|
83
115
|
if (b === '.') return 1
|