@litfamily/litgrok 1.0.35 → 1.0.36
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/.grok/hooks/lit-status-line.mjs +2 -1
- package/.grok/rules/00-litgrok.md +1 -1
- package/.grok/skills/frontend-ui-ux/SKILL.md +12 -4
- package/.grok/skills/lit-diagram-drawer/SKILL.md +12 -4
- package/.grok/skills/lit-docx/SKILL.md +12 -4
- package/.grok/skills/lit-pptx/SKILL.md +12 -4
- package/.grok/skills/lit-schematic/SKILL.md +12 -4
- package/.grok/skills/lit-scientific-visualization/SKILL.md +16 -8
- package/.grok/skills/lit-typographic-motion/SKILL.md +14 -6
- package/.grok/skills/readme-studio/SKILL.md +12 -4
- package/.grok/skills/visual-qa/SKILL.md +12 -4
- package/.grok-plugin/plugin.json +1 -1
- package/CHANGELOG.md +9 -0
- package/README.md +8 -8
- package/README_ko-KR.md +8 -8
- package/bin/extensions.mjs +686 -0
- package/bin/litgrok.mjs +23 -9
- package/docs/assets/readme/badge-version.svg +1 -1
- package/docs/reference.md +1 -1
- package/docs/reference_ko-KR.md +1 -1
- package/package.json +1 -1
- package/plugin.json +1 -1
|
@@ -0,0 +1,686 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import { spawnSync } from 'node:child_process';
|
|
3
|
+
import { cpSync, lstatSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, readlinkSync, renameSync, rmSync } from 'node:fs';
|
|
4
|
+
import { dirname, isAbsolute, join, relative, resolve, sep } from 'node:path';
|
|
5
|
+
|
|
6
|
+
// `litgrok extensions` manages the nine LitFamily extensions through Grok Build's own plugin store. Each extension
|
|
7
|
+
// is a one-skill plugin folder, `variants/litgrok/<id>/`, of the wjgoarxiv-skills shelf. LitGrok writes no receipt
|
|
8
|
+
// of its own: Grok Build's `plugin list --json` and `installed-plugins/registry.json` record the source, the store
|
|
9
|
+
// copy and the install time. On Grok Build 1.0.13 a second install of the same folder stops with
|
|
10
|
+
// `already installed`, `grok plugin update` copies nothing for a local folder, and each `grok plugin uninstall <id>`
|
|
11
|
+
// removes one copy, so install and update go through uninstall plus install, after a backup of each store copy.
|
|
12
|
+
export const SHELF_URL = 'https://github.com/wjgoarxiv/wjgoarxiv-skills';
|
|
13
|
+
export const EXTENSION_IDS = Object.freeze(['lit-pptx', 'lit-docx', 'lit-scientific-visualization', 'lit-diagram-drawer', 'lit-schematic', 'frontend-ui-ux', 'visual-qa', 'readme-studio', 'lit-typographic-motion']);
|
|
14
|
+
const FAMILIES = Object.freeze({
|
|
15
|
+
decks: ['lit-pptx'],
|
|
16
|
+
documents: ['lit-docx'],
|
|
17
|
+
figures: ['lit-diagram-drawer', 'lit-schematic', 'lit-scientific-visualization'],
|
|
18
|
+
design: ['frontend-ui-ux', 'visual-qa'],
|
|
19
|
+
'video-covers': ['lit-typographic-motion', 'readme-studio'],
|
|
20
|
+
});
|
|
21
|
+
// The eight skills folders Grok Build reads, project first. A same-name skill in one of them takes the bare /<id>.
|
|
22
|
+
const SKILL_ROOTS = ['.grok', '.agents', '.claude', '.cursor'];
|
|
23
|
+
const VARIANT = join('variants', 'litgrok');
|
|
24
|
+
const HOST_TIMEOUT_MS = 120_000;
|
|
25
|
+
// doctor only reads the plugin list; a slow host must not hold it for two minutes.
|
|
26
|
+
const DOCTOR_TIMEOUT_MS = 5_000;
|
|
27
|
+
const FETCH_TIMEOUT_MS = 300_000;
|
|
28
|
+
|
|
29
|
+
export const EXTENSIONS_USAGE = [
|
|
30
|
+
'Usage: litgrok extensions list [--json]',
|
|
31
|
+
' litgrok extensions check [--from <clone>] [--json]',
|
|
32
|
+
' litgrok extensions install <id...>|--family <name>|--all [--from <clone>] [--allow-shadow] [--yes]',
|
|
33
|
+
' litgrok extensions update [<id...>|--all] [--from <clone>] [--yes]',
|
|
34
|
+
' litgrok extensions remove <id...> [--yes]',
|
|
35
|
+
' litgrok extensions prune-backups [--older-than <days>] [--yes]',
|
|
36
|
+
`Extensions: ${EXTENSION_IDS.join(', ')}`,
|
|
37
|
+
`Families: ${Object.keys(FAMILIES).join(', ')}`,
|
|
38
|
+
'Every verb that changes something prints its plan and changes nothing until you add --yes.',
|
|
39
|
+
'',
|
|
40
|
+
].join('\n');
|
|
41
|
+
|
|
42
|
+
class ExtensionsError extends Error {}
|
|
43
|
+
|
|
44
|
+
function parse(args) {
|
|
45
|
+
const verbs = ['list', 'check', 'install', 'update', 'remove', 'prune-backups'];
|
|
46
|
+
const options = { verb: args[0], ids: [], all: false, family: null, from: null, yes: false, dryRun: false, json: false, allowShadow: false, olderThan: null };
|
|
47
|
+
if (!verbs.includes(options.verb)) return null;
|
|
48
|
+
const flags = {
|
|
49
|
+
list: ['--json'],
|
|
50
|
+
check: ['--json', '--from'],
|
|
51
|
+
install: ['--all', '--family', '--from', '--allow-shadow', '--yes', '--dry-run'],
|
|
52
|
+
update: ['--all', '--from', '--yes', '--dry-run'],
|
|
53
|
+
remove: ['--yes', '--dry-run'],
|
|
54
|
+
'prune-backups': ['--older-than', '--yes', '--dry-run'],
|
|
55
|
+
}[options.verb];
|
|
56
|
+
const rest = args.slice(1);
|
|
57
|
+
for (let index = 0; index < rest.length; index += 1) {
|
|
58
|
+
const argument = rest[index];
|
|
59
|
+
if (argument === '--no-color') continue;
|
|
60
|
+
if (argument.startsWith('--')) {
|
|
61
|
+
if (!flags.includes(argument)) return null;
|
|
62
|
+
if (['--family', '--from', '--older-than'].includes(argument)) {
|
|
63
|
+
const value = rest[index + 1];
|
|
64
|
+
if (value === undefined || value.startsWith('--')) return null;
|
|
65
|
+
index += 1;
|
|
66
|
+
if (argument === '--family') options.family = value;
|
|
67
|
+
if (argument === '--from') options.from = value;
|
|
68
|
+
if (argument === '--older-than') {
|
|
69
|
+
if (!/^\d+$/u.test(value)) return null;
|
|
70
|
+
options.olderThan = Number(value);
|
|
71
|
+
}
|
|
72
|
+
} else if (argument === '--all') options.all = true;
|
|
73
|
+
else if (argument === '--yes') options.yes = true;
|
|
74
|
+
else if (argument === '--dry-run') options.dryRun = true;
|
|
75
|
+
else if (argument === '--json') options.json = true;
|
|
76
|
+
else if (argument === '--allow-shadow') options.allowShadow = true;
|
|
77
|
+
continue;
|
|
78
|
+
}
|
|
79
|
+
if (!['install', 'update', 'remove'].includes(options.verb) || !EXTENSION_IDS.includes(argument)) return null;
|
|
80
|
+
if (!options.ids.includes(argument)) options.ids.push(argument);
|
|
81
|
+
}
|
|
82
|
+
if (options.family !== null) {
|
|
83
|
+
if (!Object.hasOwn(FAMILIES, options.family)) return null;
|
|
84
|
+
for (const id of FAMILIES[options.family]) if (!options.ids.includes(id)) options.ids.push(id);
|
|
85
|
+
}
|
|
86
|
+
if (options.all) {
|
|
87
|
+
if (options.ids.length > 0) return null;
|
|
88
|
+
options.ids = [...EXTENSION_IDS];
|
|
89
|
+
}
|
|
90
|
+
if (['install', 'remove'].includes(options.verb) && options.ids.length === 0) return null;
|
|
91
|
+
return options;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function paths(env, cwd) {
|
|
95
|
+
const home = env.HOME || env.USERPROFILE;
|
|
96
|
+
if (!home) throw new ExtensionsError('HOME is not set, so the Grok Build folders cannot be found.');
|
|
97
|
+
const cacheHome = typeof env.XDG_CACHE_HOME === 'string' && isAbsolute(env.XDG_CACHE_HOME) ? env.XDG_CACHE_HOME : join(home, '.cache');
|
|
98
|
+
return {
|
|
99
|
+
home,
|
|
100
|
+
cwd,
|
|
101
|
+
backups: join(home, '.grok', 'extension-backups'),
|
|
102
|
+
cache: join(cacheHome, 'litgrok', 'extensions-src'),
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function lstatOrNull(path) {
|
|
107
|
+
try {
|
|
108
|
+
return lstatSync(path);
|
|
109
|
+
} catch (error) {
|
|
110
|
+
if (error?.code === 'ENOENT' || error?.code === 'ENOTDIR') return null;
|
|
111
|
+
throw error;
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function tool(command, args, { env, cwd, timeout = HOST_TIMEOUT_MS }) {
|
|
116
|
+
const result = spawnSync(command, args, { cwd, env, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'], timeout });
|
|
117
|
+
if (result.error?.code === 'ENOENT') return { missing: true, status: null, stdout: '', stderr: '' };
|
|
118
|
+
if (result.error?.code === 'ETIMEDOUT') return { missing: false, timedOut: true, status: null, stdout: '', stderr: `no answer within ${Math.round(timeout / 1000)} s` };
|
|
119
|
+
if (result.error) return { missing: false, status: null, stdout: result.stdout ?? '', stderr: String(result.error.message) };
|
|
120
|
+
return { missing: false, status: result.status, stdout: result.stdout, stderr: result.stderr };
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function grok(args, context) {
|
|
124
|
+
const result = tool('grok', args, context);
|
|
125
|
+
if (result.missing) throw new ExtensionsError('grok was not found on PATH. Install Grok Build, or put its grok command on PATH, and run this again.');
|
|
126
|
+
return result;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function registryTimes(path) {
|
|
130
|
+
try {
|
|
131
|
+
const registry = JSON.parse(readFileSync(join(dirname(path), 'registry.json'), 'utf8'));
|
|
132
|
+
for (const repo of Object.values(registry?.repos ?? {})) {
|
|
133
|
+
if (repo?.path === path) return { installed_at: repo.installed_at ?? null, updated_at: repo.updated_at ?? null };
|
|
134
|
+
}
|
|
135
|
+
} catch {
|
|
136
|
+
// The registry is Grok Build's file; when it is missing or unreadable the times stay unknown.
|
|
137
|
+
}
|
|
138
|
+
return { installed_at: null, updated_at: null };
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function installedPlugins(context) {
|
|
142
|
+
const result = grok(['plugin', 'list', '--json'], { ...context, timeout: context.hostTimeout ?? HOST_TIMEOUT_MS });
|
|
143
|
+
if (result.timedOut) throw new ExtensionsError(`grok plugin list --json gave ${result.stderr}`, { cause: 'timeout' });
|
|
144
|
+
if (result.status !== 0) throw new ExtensionsError(`grok plugin list --json failed: ${(result.stderr || result.stdout).trim()}`);
|
|
145
|
+
let rows;
|
|
146
|
+
try {
|
|
147
|
+
rows = JSON.parse(result.stdout);
|
|
148
|
+
} catch {
|
|
149
|
+
throw new ExtensionsError('grok plugin list --json printed something that is not JSON; nothing was changed.');
|
|
150
|
+
}
|
|
151
|
+
if (!Array.isArray(rows)) throw new ExtensionsError('grok plugin list --json did not print a list; nothing was changed.');
|
|
152
|
+
return rows.filter((row) => EXTENSION_IDS.includes(row?.name)).map((row) => {
|
|
153
|
+
const source = typeof row.source === 'string' ? row.source : null;
|
|
154
|
+
return {
|
|
155
|
+
name: row.name,
|
|
156
|
+
repo_key: row.repo_key ?? null,
|
|
157
|
+
version: row.version ?? null,
|
|
158
|
+
path: typeof row.path === 'string' ? row.path : null,
|
|
159
|
+
source,
|
|
160
|
+
managed: source !== null && source.endsWith(`${sep}${join(VARIANT, row.name)}`),
|
|
161
|
+
source_exists: source !== null && lstatOrNull(source) !== null,
|
|
162
|
+
...(typeof row.path === 'string' ? registryTimes(row.path) : { installed_at: null, updated_at: null }),
|
|
163
|
+
};
|
|
164
|
+
});
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
function isLitGrokNote(skillFile, id) {
|
|
168
|
+
try {
|
|
169
|
+
const head = readFileSync(skillFile, 'utf8').slice(0, 4096);
|
|
170
|
+
return head.split('\n').some((line) => line.startsWith(`description: "Moved: ${id} `));
|
|
171
|
+
} catch {
|
|
172
|
+
return false;
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
function competingCopies(id, { cwd, home }) {
|
|
177
|
+
const found = [];
|
|
178
|
+
const seen = new Set();
|
|
179
|
+
for (const [base, scope] of [[cwd, 'project'], [home, 'user']]) {
|
|
180
|
+
for (const folder of SKILL_ROOTS) {
|
|
181
|
+
const path = resolve(base, folder, 'skills', id);
|
|
182
|
+
if (seen.has(path)) continue;
|
|
183
|
+
seen.add(path);
|
|
184
|
+
if (lstatOrNull(join(path, 'SKILL.md')) === null || isLitGrokNote(join(path, 'SKILL.md'), id)) continue;
|
|
185
|
+
const oldLitGrok = folder === '.grok' && lstatOrNull(join(base, '.grok', '.litgrok-install-manifest.json')) !== null;
|
|
186
|
+
found.push({ path, scope, kind: oldLitGrok ? 'old-litgrok' : 'personal' });
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
return found;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
function moveAsideLine(path, backups) {
|
|
193
|
+
return `mkdir -p "${backups}" && mv "${path}" "$(mktemp -d "${backups}/$(date +%Y%m%d-%H%M%S)-XXXXXX")/"`;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
function competingFix(copy, backups) {
|
|
197
|
+
if (copy.kind === 'old-litgrok') return `litgrok install --${copy.scope} (an earlier LitGrok copy; the install turns it into the note)`;
|
|
198
|
+
return moveAsideLine(copy.path, backups);
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
// A digest of a folder tree: relative paths, file bytes and link targets. The store copy is a plain copy of the
|
|
202
|
+
// source folder, so equal digests mean the installed plugin is current.
|
|
203
|
+
function treeDigest(root) {
|
|
204
|
+
const hash = createHash('sha256');
|
|
205
|
+
const visit = (folder) => {
|
|
206
|
+
for (const name of readdirSync(folder).sort()) {
|
|
207
|
+
const path = join(folder, name);
|
|
208
|
+
const status = lstatSync(path);
|
|
209
|
+
const rel = relative(root, path).split(sep).join('/');
|
|
210
|
+
if (status.isSymbolicLink()) hash.update(`L ${rel} ${readlinkSync(path)}\n`);
|
|
211
|
+
else if (status.isDirectory()) {
|
|
212
|
+
hash.update(`D ${rel}\n`);
|
|
213
|
+
visit(path);
|
|
214
|
+
} else if (status.isFile()) hash.update(`F ${rel} ${createHash('sha256').update(readFileSync(path)).digest('hex')}\n`);
|
|
215
|
+
}
|
|
216
|
+
};
|
|
217
|
+
visit(root);
|
|
218
|
+
return hash.digest('hex');
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
function sourceFolder(root, id) {
|
|
222
|
+
return join(root, VARIANT, id);
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
function usableShelf(root) {
|
|
226
|
+
const status = lstatOrNull(join(root, VARIANT));
|
|
227
|
+
return status !== null && status.isDirectory();
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
function fetchHint(cache) {
|
|
231
|
+
return `Clone ${SHELF_URL} yourself and pass --from "<path to your wjgoarxiv-skills clone>", or retry when online. LitGrok keeps its own copy at ${cache}.`;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
// Where the extension folders come from: --from names a clone the user keeps; otherwise LitGrok keeps a shallow,
|
|
235
|
+
// sparse clone of variants/litgrok/ in its own cache folder. `refresh` fetches or pulls it; without it, nothing is
|
|
236
|
+
// fetched and a missing cache is reported as not fetched yet.
|
|
237
|
+
function resolveSource(options, context, { refresh }) {
|
|
238
|
+
if (options.from !== null) {
|
|
239
|
+
const root = resolve(context.cwd, options.from);
|
|
240
|
+
if (!usableShelf(root)) throw new ExtensionsError(`${root} has no variants/litgrok folder. Pass --from the root of a wjgoarxiv-skills clone.`);
|
|
241
|
+
return { root, kind: 'from', ready: true, note: refresh ? `using ${root} as it is; pull it yourself to get newer extensions` : null };
|
|
242
|
+
}
|
|
243
|
+
const { cache } = context.paths;
|
|
244
|
+
const own = lstatOrNull(dirname(cache));
|
|
245
|
+
if (own !== null && (own.isSymbolicLink() || !own.isDirectory())) throw new ExtensionsError(`Refusing ${dirname(cache)}: it is not a plain folder. Move it out of the way, or pass --from.`);
|
|
246
|
+
const status = lstatOrNull(cache);
|
|
247
|
+
if (status !== null) {
|
|
248
|
+
if (status.isSymbolicLink() || !status.isDirectory() || lstatOrNull(join(cache, '.git')) === null || !usableShelf(cache)) {
|
|
249
|
+
throw new ExtensionsError(`Refusing ${cache}: it is not the clone LitGrok keeps (a link, a file, or a folder without .git and variants/litgrok). Move it out of the way, or pass --from.`);
|
|
250
|
+
}
|
|
251
|
+
if (refresh === 'pull' && context.write) {
|
|
252
|
+
const pulled = tool('git', ['-C', cache, 'pull', '--ff-only'], { ...context, timeout: FETCH_TIMEOUT_MS });
|
|
253
|
+
if (pulled.missing) throw new ExtensionsError(`git was not found on PATH, so the extensions could not be fetched. ${fetchHint(cache)}`);
|
|
254
|
+
if (pulled.status !== 0) throw new ExtensionsError(`could not fetch the extensions: ${(pulled.stderr || pulled.stdout).trim()}\n${fetchHint(cache)}`);
|
|
255
|
+
return { root: cache, kind: 'cache', ready: true, note: `pulled ${cache}` };
|
|
256
|
+
}
|
|
257
|
+
return { root: cache, kind: 'cache', ready: true, note: refresh === 'pull' ? `compared with the cached copy in ${cache}; --yes pulls it first, so the result may differ` : null };
|
|
258
|
+
}
|
|
259
|
+
if (!refresh) return { root: cache, kind: 'cache', ready: false, note: null };
|
|
260
|
+
if (!context.write) return { root: cache, kind: 'cache', ready: false, note: `would clone ${SHELF_URL} (variants/litgrok only) into ${cache}; installed extensions are compared with it after the fetch` };
|
|
261
|
+
const parent = dirname(cache);
|
|
262
|
+
mkdirSync(parent, { recursive: true });
|
|
263
|
+
const staging = mkdtempSync(join(parent, '.extensions-src-'));
|
|
264
|
+
const target = join(staging, 'repo');
|
|
265
|
+
const fail = (message) => {
|
|
266
|
+
rmSync(staging, { recursive: true, force: true });
|
|
267
|
+
throw new ExtensionsError(message);
|
|
268
|
+
};
|
|
269
|
+
const cloned = tool('git', ['clone', '--depth', '1', '--filter=blob:none', '--sparse', SHELF_URL, target], { ...context, timeout: FETCH_TIMEOUT_MS });
|
|
270
|
+
if (cloned.missing) fail(`git was not found on PATH, so the extensions could not be fetched. ${fetchHint(cache)}`);
|
|
271
|
+
if (cloned.status !== 0) fail(`could not fetch the extensions: ${(cloned.stderr || cloned.stdout).trim()}\n${fetchHint(cache)}`);
|
|
272
|
+
const sparse = tool('git', ['-C', target, 'sparse-checkout', 'set', VARIANT.split(sep).join('/')], context);
|
|
273
|
+
if (sparse.status !== 0 || !usableShelf(target)) fail(`the fetched shelf has no variants/litgrok folder. ${fetchHint(cache)}`);
|
|
274
|
+
renameSync(target, cache);
|
|
275
|
+
rmSync(staging, { recursive: true, force: true });
|
|
276
|
+
return { root: cache, kind: 'cache', ready: true, note: `fetched ${SHELF_URL} (variants/litgrok only) into ${cache}` };
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
function stamp(now) {
|
|
280
|
+
const pad = (value) => String(value).padStart(2, '0');
|
|
281
|
+
return `${now.getFullYear()}${pad(now.getMonth() + 1)}${pad(now.getDate())}-${pad(now.getHours())}${pad(now.getMinutes())}${pad(now.getSeconds())}-`;
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
// Copies each store copy into one dated folder under ~/.grok/extension-backups, which Grok Build does not scan.
|
|
285
|
+
// Grok Build's uninstall deletes the store copy, so the copy is made first and nothing is lost.
|
|
286
|
+
function backUp(copies, context) {
|
|
287
|
+
const { backups } = context.paths;
|
|
288
|
+
const status = lstatOrNull(backups);
|
|
289
|
+
if (status !== null && (status.isSymbolicLink() || !status.isDirectory())) throw new ExtensionsError(`Refusing ${backups}: it is not a plain folder.`);
|
|
290
|
+
mkdirSync(backups, { recursive: true });
|
|
291
|
+
const dated = mkdtempSync(join(backups, stamp(context.now())));
|
|
292
|
+
const used = new Map();
|
|
293
|
+
for (const copy of copies) {
|
|
294
|
+
const store = lstatOrNull(copy.path);
|
|
295
|
+
if (store === null) continue;
|
|
296
|
+
if (store.isSymbolicLink() || !store.isDirectory()) throw new ExtensionsError(`Refusing the plugin store copy ${copy.path}: it is not a plain folder.`);
|
|
297
|
+
const count = (used.get(copy.name) ?? 0) + 1;
|
|
298
|
+
used.set(copy.name, count);
|
|
299
|
+
cpSync(copy.path, join(dated, count === 1 ? copy.name : `${copy.name}-${count}`), { recursive: true, verbatimSymlinks: true, errorOnExist: true, force: false });
|
|
300
|
+
}
|
|
301
|
+
return dated;
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
function uninstallCopies(id, count, context) {
|
|
305
|
+
for (let index = 0; index < count; index += 1) {
|
|
306
|
+
const result = grok(['plugin', 'uninstall', id], context);
|
|
307
|
+
if (result.status !== 0) throw new ExtensionsError(`grok plugin uninstall ${id} failed: ${(result.stderr || result.stdout).trim()}`);
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
function installFolder(id, folder, context) {
|
|
312
|
+
const result = grok(['plugin', 'install', folder, '--trust'], context);
|
|
313
|
+
if (result.status !== 0) throw new ExtensionsError(`grok plugin install "${folder}" --trust failed: ${(result.stderr || result.stdout).trim()}`);
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
function copiesWord(count) {
|
|
317
|
+
return count === 1 ? '1 copy' : `${count} copies`;
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
function previewBanner(stdout) {
|
|
321
|
+
stdout.write('DRY RUN — nothing changed. Add --yes to do this:\n');
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
// install and update share one plan: an extension whose single store copy matches its source folder is current;
|
|
325
|
+
// any other state (no copy, an older copy, several copies) becomes backup, uninstall each copy, install once.
|
|
326
|
+
// install also wants Grok Build to record this folder as the source, so a copy installed from another (perhaps gone)
|
|
327
|
+
// folder is reinstalled even when its content matches; update compares content only.
|
|
328
|
+
function planReplace(id, copies, source, { sameSource = false } = {}) {
|
|
329
|
+
const folder = sourceFolder(source.root, id);
|
|
330
|
+
if (source.ready && lstatOrNull(folder) === null) throw new ExtensionsError(`${folder} is missing from the extensions source; nothing was changed.`);
|
|
331
|
+
const current = source.ready && copies.length === 1 && copies[0].path !== null && lstatOrNull(copies[0].path) !== null && (!sameSource || copies[0].source === folder) && treeDigest(copies[0].path) === treeDigest(folder);
|
|
332
|
+
return { id, folder, copies, current };
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
function describeReplace(plan, stdout, verb) {
|
|
336
|
+
if (plan.copies.length === 0) {
|
|
337
|
+
stdout.write(`${plan.id}: would ${verb} from ${plan.folder}\n grok plugin install "${plan.folder}" --trust\n`);
|
|
338
|
+
return;
|
|
339
|
+
}
|
|
340
|
+
stdout.write(`${plan.id}: would ${verb}: back up ${copiesWord(plan.copies.length)} from the plugin store (${plan.copies.map(({ path }) => path).join(', ')}), run grok plugin uninstall ${plan.id} ${plan.copies.length === 1 ? 'once' : `${plan.copies.length} times`}, then install from ${plan.folder}\n grok plugin install "${plan.folder}" --trust\n`);
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
function applyReplace(plan, context) {
|
|
344
|
+
const backup = plan.copies.length > 0 ? backUp(plan.copies, context) : null;
|
|
345
|
+
uninstallCopies(plan.id, plan.copies.length, context);
|
|
346
|
+
try {
|
|
347
|
+
installFolder(plan.id, plan.folder, context);
|
|
348
|
+
} catch (error) {
|
|
349
|
+
if (backup) error.message += `\nThe earlier copy is saved in ${backup}.`;
|
|
350
|
+
throw error;
|
|
351
|
+
}
|
|
352
|
+
return backup;
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
function userManagedLine(id, { path, source }, verb) {
|
|
356
|
+
return `${id}: a user-managed plugin of this name is installed from ${source ?? 'an unknown source'} (${path}); ${verb} leaves it alone. If the shelf copy should replace it, remove it yourself with grok plugin uninstall ${id} (its source folder stays), then rerun.`;
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
function runInstallOrUpdate(options, context) {
|
|
360
|
+
const { stdout, stderr } = context;
|
|
361
|
+
const update = options.verb === 'update';
|
|
362
|
+
const plugins = installedPlugins(context);
|
|
363
|
+
// A bare update or --all collects the ids itself, so a user-managed plugin among them is skipped, not refused.
|
|
364
|
+
const collected = update && (options.ids.length === 0 || options.all);
|
|
365
|
+
const ids = update && options.ids.length === 0 ? EXTENSION_IDS.filter((id) => plugins.some(({ name }) => name === id)) : options.ids;
|
|
366
|
+
const blocked = [];
|
|
367
|
+
const wanted = [];
|
|
368
|
+
const skipped = [];
|
|
369
|
+
for (const id of ids) {
|
|
370
|
+
const copies = plugins.filter(({ name }) => name === id);
|
|
371
|
+
if (update && copies.length === 0) {
|
|
372
|
+
skipped.push(`${id}: not installed, left alone (litgrok extensions install ${id})`);
|
|
373
|
+
continue;
|
|
374
|
+
}
|
|
375
|
+
const foreign = copies.filter(({ managed }) => !managed);
|
|
376
|
+
if (foreign.length > 0) {
|
|
377
|
+
const lines = foreign.map((copy) => userManagedLine(id, copy, options.verb));
|
|
378
|
+
if (collected) skipped.push(...lines);
|
|
379
|
+
else blocked.push({ id, kind: 'user-managed', lines });
|
|
380
|
+
continue;
|
|
381
|
+
}
|
|
382
|
+
const competing = competingCopies(id, context.paths);
|
|
383
|
+
if (competing.length > 0) {
|
|
384
|
+
const lines = competing.map((copy) => `${id}: a same-name skill at ${copy.path} takes the bare /${id} name. ${copy.kind === 'old-litgrok' ? 'Fix' : 'Move it aside'}: ${competingFix(copy, context.paths.backups)}`);
|
|
385
|
+
if (!options.allowShadow && !update) {
|
|
386
|
+
blocked.push({ id, kind: 'competing', lines });
|
|
387
|
+
continue;
|
|
388
|
+
}
|
|
389
|
+
for (const line of lines) stderr.write(`Warning: ${line}\n`);
|
|
390
|
+
}
|
|
391
|
+
wanted.push({ id, copies });
|
|
392
|
+
}
|
|
393
|
+
if (blocked.length > 0) {
|
|
394
|
+
for (const { lines } of blocked) for (const line of lines) stderr.write(`${line}\n`);
|
|
395
|
+
if (update) stderr.write('Nothing was updated.\n');
|
|
396
|
+
else if (blocked.some(({ kind }) => kind === 'competing')) stderr.write('Nothing was installed. Move the copies named above, or rerun with --allow-shadow to install beside them; LitGrok never moves them itself.\n');
|
|
397
|
+
else stderr.write('Nothing was installed.\n');
|
|
398
|
+
return 1;
|
|
399
|
+
}
|
|
400
|
+
// The source is fetched only once nothing blocks the run.
|
|
401
|
+
const source = resolveSource(options, context, { refresh: update ? 'pull' : 'fetch' });
|
|
402
|
+
const plans = wanted.map(({ id, copies }) => planReplace(id, copies, source, { sameSource: !update }));
|
|
403
|
+
if (!context.write) {
|
|
404
|
+
previewBanner(stdout);
|
|
405
|
+
if (source.note) stdout.write(`${source.note}\n`);
|
|
406
|
+
for (const line of skipped) stdout.write(`${line}\n`);
|
|
407
|
+
for (const plan of plans) {
|
|
408
|
+
if (plan.current) stdout.write(`${plan.id}: current (${plan.copies[0].path})\n`);
|
|
409
|
+
else if (!source.ready && plan.copies.length > 0) stdout.write(`${plan.id}: installed (${plan.copies.map(({ path }) => path).join(', ')}); compared after the fetch, and reinstalled only if it differs\n`);
|
|
410
|
+
else describeReplace(plan, stdout, update ? 'update' : 'install');
|
|
411
|
+
}
|
|
412
|
+
return 0;
|
|
413
|
+
}
|
|
414
|
+
if (source.note) stdout.write(`${source.note}\n`);
|
|
415
|
+
for (const line of skipped) stdout.write(`${line}\n`);
|
|
416
|
+
let changed = 0;
|
|
417
|
+
for (const plan of plans) {
|
|
418
|
+
if (plan.current) {
|
|
419
|
+
stdout.write(`${plan.id}: current (${plan.copies[0].path})\n`);
|
|
420
|
+
continue;
|
|
421
|
+
}
|
|
422
|
+
const backup = applyReplace(plan, context);
|
|
423
|
+
changed += 1;
|
|
424
|
+
const word = update ? 'updated' : 'installed';
|
|
425
|
+
stdout.write(`${plan.id}: ${word} from ${plan.folder}${backup ? `; the earlier ${copiesWord(plan.copies.length)} saved in ${backup}` : ''}\n`);
|
|
426
|
+
}
|
|
427
|
+
if (changed > 0) stdout.write('Start a new Grok Build session (or press r in the Plugins tab) to load the change.\n');
|
|
428
|
+
return 0;
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
function runRemove(options, context) {
|
|
432
|
+
const { stdout, stderr } = context;
|
|
433
|
+
const plugins = installedPlugins(context);
|
|
434
|
+
const plans = [];
|
|
435
|
+
const refused = [];
|
|
436
|
+
for (const id of options.ids) {
|
|
437
|
+
const copies = plugins.filter(({ name }) => name === id);
|
|
438
|
+
if (copies.length === 0) {
|
|
439
|
+
plans.push({ id, copies });
|
|
440
|
+
continue;
|
|
441
|
+
}
|
|
442
|
+
const foreign = copies.filter(({ managed }) => !managed);
|
|
443
|
+
if (foreign.length > 0) refused.push(...foreign.map(({ path, source }) => `${id}: refusing a user-managed plugin installed from ${source ?? 'an unknown source'} (${path}); it does not come from a wjgoarxiv-skills variants/litgrok folder. Remove it yourself with grok plugin uninstall ${id} if you mean to.`));
|
|
444
|
+
else plans.push({ id, copies });
|
|
445
|
+
}
|
|
446
|
+
if (refused.length > 0) {
|
|
447
|
+
for (const line of refused) stderr.write(`${line}\n`);
|
|
448
|
+
stderr.write('Nothing was removed.\n');
|
|
449
|
+
return 1;
|
|
450
|
+
}
|
|
451
|
+
if (!context.write) previewBanner(stdout);
|
|
452
|
+
for (const { id, copies } of plans) {
|
|
453
|
+
if (copies.length === 0) {
|
|
454
|
+
stdout.write(`${id}: not installed\n`);
|
|
455
|
+
continue;
|
|
456
|
+
}
|
|
457
|
+
if (!context.write) {
|
|
458
|
+
stdout.write(`${id}: would back up ${copiesWord(copies.length)} (${copies.map(({ path }) => path).join(', ')}) to ${context.paths.backups}, then run grok plugin uninstall ${id}${copies.length > 1 ? ` ${copies.length} times` : ''}\n`);
|
|
459
|
+
continue;
|
|
460
|
+
}
|
|
461
|
+
const backup = backUp(copies, context);
|
|
462
|
+
uninstallCopies(id, copies.length, context);
|
|
463
|
+
stdout.write(`${id}: removed; the store copy is saved in ${backup}\n`);
|
|
464
|
+
}
|
|
465
|
+
if (context.write && plans.some(({ copies }) => copies.length > 0)) stdout.write('LitGrok keeps its note under each name, so /<skill> answers again with the install line.\n');
|
|
466
|
+
return 0;
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
function folderBytes(path) {
|
|
470
|
+
const status = lstatSync(path);
|
|
471
|
+
if (!status.isDirectory()) return status.size;
|
|
472
|
+
return readdirSync(path).reduce((total, name) => total + folderBytes(join(path, name)), 0);
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
function humanBytes(bytes) {
|
|
476
|
+
if (bytes < 1024) return `${bytes} B`;
|
|
477
|
+
const units = ['KB', 'MB', 'GB'];
|
|
478
|
+
let value = bytes / 1024;
|
|
479
|
+
let unit = 0;
|
|
480
|
+
while (value >= 1024 && unit < units.length - 1) {
|
|
481
|
+
value /= 1024;
|
|
482
|
+
unit += 1;
|
|
483
|
+
}
|
|
484
|
+
return `${value.toFixed(1)} ${units[unit]}`;
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
function listBackups(context) {
|
|
488
|
+
const { backups } = context.paths;
|
|
489
|
+
const status = lstatOrNull(backups);
|
|
490
|
+
if (status === null) return [];
|
|
491
|
+
if (status.isSymbolicLink() || !status.isDirectory()) throw new ExtensionsError(`Refusing ${backups}: it is not a plain folder.`);
|
|
492
|
+
return readdirSync(backups).sort().map((name) => {
|
|
493
|
+
const path = join(backups, name);
|
|
494
|
+
const entry = lstatSync(path);
|
|
495
|
+
return { path, link: entry.isSymbolicLink(), bytes: entry.isSymbolicLink() ? 0 : folderBytes(path), modified: entry.mtime };
|
|
496
|
+
});
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
function runPrune(options, context) {
|
|
500
|
+
const { stdout } = context;
|
|
501
|
+
const all = listBackups(context);
|
|
502
|
+
if (all.length === 0) {
|
|
503
|
+
stdout.write(`No backups in ${context.paths.backups}.\n`);
|
|
504
|
+
return 0;
|
|
505
|
+
}
|
|
506
|
+
const cutoff = options.olderThan === null ? null : context.now().getTime() - options.olderThan * 86_400_000;
|
|
507
|
+
const chosen = all.filter(({ modified }) => cutoff === null || modified.getTime() < cutoff);
|
|
508
|
+
const total = chosen.reduce((sum, { bytes }) => sum + bytes, 0);
|
|
509
|
+
if (!context.write) {
|
|
510
|
+
previewBanner(stdout);
|
|
511
|
+
if (chosen.length > 0) stdout.write(`A backup may also hold your own skill: check and the extension notes suggest moving a same-name personal skill into ${context.paths.backups}. Look inside before --yes.\n`);
|
|
512
|
+
}
|
|
513
|
+
for (const backup of all) {
|
|
514
|
+
const picked = chosen.includes(backup);
|
|
515
|
+
if (backup.link) stdout.write(`skip ${backup.path} (a link, never followed)\n`);
|
|
516
|
+
else stdout.write(`${picked ? (context.write ? 'delete' : 'would delete') : 'keep'} ${backup.path} ${humanBytes(backup.bytes)}\n`);
|
|
517
|
+
}
|
|
518
|
+
if (context.write) {
|
|
519
|
+
for (const backup of chosen) {
|
|
520
|
+
if (backup.link || dirname(backup.path) !== context.paths.backups) continue;
|
|
521
|
+
rmSync(backup.path, { recursive: true });
|
|
522
|
+
}
|
|
523
|
+
}
|
|
524
|
+
stdout.write(`${context.write ? 'Deleted' : 'Would delete'} ${chosen.filter(({ link }) => !link).length} of ${all.length} backups, ${humanBytes(total)}.\n`);
|
|
525
|
+
if (chosen.length === 0) stdout.write('Nothing matched; --older-than <days> picks by age.\n');
|
|
526
|
+
return 0;
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
// The read-only survey that list, check and doctor share. A missing grok is reported, not thrown.
|
|
530
|
+
function survey(context, { withPlugins = true } = {}) {
|
|
531
|
+
let plugins = [];
|
|
532
|
+
let hostError = null;
|
|
533
|
+
let hostTimedOut = false;
|
|
534
|
+
if (withPlugins) {
|
|
535
|
+
try {
|
|
536
|
+
plugins = installedPlugins(context);
|
|
537
|
+
} catch (error) {
|
|
538
|
+
if (!(error instanceof ExtensionsError)) throw error;
|
|
539
|
+
hostError = error.message;
|
|
540
|
+
hostTimedOut = error.cause === 'timeout';
|
|
541
|
+
}
|
|
542
|
+
}
|
|
543
|
+
const extensions = EXTENSION_IDS.map((id) => {
|
|
544
|
+
const copies = plugins.filter(({ name }) => name === id);
|
|
545
|
+
let status = 'not-installed';
|
|
546
|
+
if (copies.length > 0) status = copies.every(({ managed }) => managed) ? 'installed' : 'user-managed';
|
|
547
|
+
return { id, family: Object.keys(FAMILIES).find((name) => FAMILIES[name].includes(id)), status, plugins: copies, competing: competingCopies(id, context.paths) };
|
|
548
|
+
});
|
|
549
|
+
return { extensions, hostError, hostTimedOut };
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
// The source check compares with: --from when given, otherwise LitGrok's cache when it is there. Nothing is fetched.
|
|
553
|
+
function currentSource(from, context) {
|
|
554
|
+
try {
|
|
555
|
+
const source = resolveSource({ from }, context, { refresh: false });
|
|
556
|
+
return source.ready ? source : null;
|
|
557
|
+
} catch {
|
|
558
|
+
return null;
|
|
559
|
+
}
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
function sourceMissing(id, plugin, source) {
|
|
563
|
+
const fix = `litgrok extensions install ${id} --yes${source?.kind === 'from' ? ` --from "${source.root}"` : ''}`;
|
|
564
|
+
const folder = source ? sourceFolder(source.root, id) : null;
|
|
565
|
+
const matches = folder !== null && lstatOrNull(folder) !== null && plugin.path !== null && lstatOrNull(plugin.path) !== null && treeDigest(plugin.path) === treeDigest(folder);
|
|
566
|
+
if (matches) return { kind: 'source-moved', id, action: false, message: `${id}: its recorded source ${plugin.source} no longer exists; the installed copy matches ${folder}`, fix: `${fix} (re-records the source, if you want it recorded)` };
|
|
567
|
+
return { kind: 'source-missing', id, action: true, message: `${id}: its source ${plugin.source} no longer exists${folder ? ` and the installed copy differs from ${folder}` : ''}`, fix };
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
function findings(context, report, { from = null } = {}) {
|
|
571
|
+
const { backups } = context.paths;
|
|
572
|
+
let source;
|
|
573
|
+
const items = [];
|
|
574
|
+
if (report.hostError) items.push({ kind: 'host', id: null, action: true, message: `plugin copies not checked: ${report.hostError}`, fix: 'put grok on PATH, then rerun litgrok extensions check' });
|
|
575
|
+
for (const extension of report.extensions) {
|
|
576
|
+
const { id, plugins } = extension;
|
|
577
|
+
if (plugins.length > 1) items.push({ kind: 'duplicate', id, action: true, message: `${id}: ${plugins.length} plugin copies (${plugins.map(({ source }) => source).join(', ')})`, fix: plugins.some(({ managed }) => !managed) ? `grok plugin uninstall ${id} once per copy (${plugins.length} times; each source folder stays), then reinstall the one you want, e.g. litgrok extensions install ${id} --yes` : `litgrok extensions update ${id} --yes` });
|
|
578
|
+
for (const plugin of plugins) {
|
|
579
|
+
if (!plugin.managed) items.push({ kind: 'user-managed', id, action: false, message: `${id}: a user-managed plugin from ${plugin.source}; LitGrok leaves it alone`, fix: null });
|
|
580
|
+
else if (!plugin.source_exists) {
|
|
581
|
+
if (source === undefined) source = currentSource(from, context);
|
|
582
|
+
items.push(sourceMissing(id, plugin, source));
|
|
583
|
+
}
|
|
584
|
+
}
|
|
585
|
+
for (const copy of extension.competing) {
|
|
586
|
+
items.push({ kind: copy.kind === 'old-litgrok' ? 'old-litgrok-copy' : 'competing', id, action: true, message: `${id}: ${copy.kind === 'old-litgrok' ? 'an earlier LitGrok copy' : 'a same-name skill'} at ${copy.path} takes the bare /${id} name`, fix: competingFix(copy, backups) });
|
|
587
|
+
}
|
|
588
|
+
}
|
|
589
|
+
const saved = listBackups(context);
|
|
590
|
+
if (saved.length > 0) {
|
|
591
|
+
const bytes = saved.reduce((sum, { bytes: size }) => sum + size, 0);
|
|
592
|
+
items.push({ kind: 'backups', id: null, action: false, message: `${saved.length} backup${saved.length === 1 ? '' : 's'} in ${backups}, ${humanBytes(bytes)}`, fix: 'litgrok extensions prune-backups' });
|
|
593
|
+
}
|
|
594
|
+
return items;
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
function runList(options, context) {
|
|
598
|
+
const { stdout } = context;
|
|
599
|
+
const report = survey(context);
|
|
600
|
+
const source = resolveSource({ from: null }, context, { refresh: false });
|
|
601
|
+
if (options.json) {
|
|
602
|
+
stdout.write(`${JSON.stringify({ source: { path: source.root, fetched: source.ready }, host_error: report.hostError, extensions: report.extensions }, null, 2)}\n`);
|
|
603
|
+
return 0;
|
|
604
|
+
}
|
|
605
|
+
stdout.write(`LitFamily extensions for Grok Build (shelf ${SHELF_URL}; LitGrok's copy ${source.ready ? source.root : 'not fetched yet'})\n`);
|
|
606
|
+
if (report.hostError) stdout.write(`plugin copies not checked: ${report.hostError}\n`);
|
|
607
|
+
const width = Math.max(...EXTENSION_IDS.map((id) => id.length));
|
|
608
|
+
for (const extension of report.extensions) {
|
|
609
|
+
const label = { 'not-installed': 'not installed', installed: 'installed', 'user-managed': 'user-managed' }[extension.status];
|
|
610
|
+
const detail = extension.plugins.map((plugin) => `${plugin.source}${plugin.installed_at ? ` · ${plugin.installed_at}` : ''}`).join('; ');
|
|
611
|
+
stdout.write(` ${extension.id.padEnd(width)} ${label}${extension.plugins.length > 1 ? ` ×${extension.plugins.length}` : ''}${detail ? ` · ${detail}` : ''}\n`);
|
|
612
|
+
for (const copy of extension.competing) stdout.write(` ${''.padEnd(width)} competing: ${copy.path}\n`);
|
|
613
|
+
}
|
|
614
|
+
stdout.write('Install with: litgrok extensions install <id> (add --yes to do it). Problems and fixes: litgrok extensions check\n');
|
|
615
|
+
return 0;
|
|
616
|
+
}
|
|
617
|
+
|
|
618
|
+
function runCheck(options, context) {
|
|
619
|
+
const { stdout } = context;
|
|
620
|
+
if (options.from !== null) resolveSource(options, context, { refresh: false });
|
|
621
|
+
const items = findings(context, survey(context), { from: options.from });
|
|
622
|
+
const clean = !items.some(({ action }) => action);
|
|
623
|
+
if (options.json) {
|
|
624
|
+
stdout.write(`${JSON.stringify({ clean, findings: items }, null, 2)}\n`);
|
|
625
|
+
return clean ? 0 : 1;
|
|
626
|
+
}
|
|
627
|
+
for (const item of items) stdout.write(`${item.action ? 'FIX' : 'note'} ${item.message}${item.fix ? `\n ${item.fix}` : ''}\n`);
|
|
628
|
+
stdout.write(clean ? 'Extensions: clean, nothing needs action.\n' : `Extensions: ${items.filter(({ action }) => action).length} to fix; run the line under each FIX.\n`);
|
|
629
|
+
return clean ? 0 : 1;
|
|
630
|
+
}
|
|
631
|
+
|
|
632
|
+
function contextFrom({ env, cwd, stdout, stderr, now }, write) {
|
|
633
|
+
return { env, cwd, stdout, stderr, write, now: now ?? (() => new Date()), paths: paths(env, cwd) };
|
|
634
|
+
}
|
|
635
|
+
|
|
636
|
+
// One line for `litgrok doctor`: the check logic, summarised.
|
|
637
|
+
export function extensionsDoctorLine({ env, cwd }) {
|
|
638
|
+
try {
|
|
639
|
+
const context = { ...contextFrom({ env, cwd }, false), hostTimeout: DOCTOR_TIMEOUT_MS };
|
|
640
|
+
const report = survey(context);
|
|
641
|
+
const items = findings(context, report);
|
|
642
|
+
const installed = report.extensions.filter(({ status }) => status !== 'not-installed').length;
|
|
643
|
+
const toFix = items.filter(({ action }) => action).length;
|
|
644
|
+
if (report.hostTimedOut) return `EXTENSIONS: host listing unavailable (${report.hostError}) · run: litgrok extensions check`;
|
|
645
|
+
if (report.hostError) return `EXTENSIONS: grok not checked · ${toFix} to fix (run: litgrok extensions check)`;
|
|
646
|
+
return toFix === 0
|
|
647
|
+
? `EXTENSIONS: ${installed} of ${EXTENSION_IDS.length} installed · nothing to fix (litgrok extensions list)`
|
|
648
|
+
: `EXTENSIONS: ${installed} of ${EXTENSION_IDS.length} installed · ${toFix} to fix (run: litgrok extensions check)`;
|
|
649
|
+
} catch (error) {
|
|
650
|
+
return `EXTENSIONS: not checked (${error instanceof Error ? error.message : String(error)})`;
|
|
651
|
+
}
|
|
652
|
+
}
|
|
653
|
+
|
|
654
|
+
// For the install card: the file-system half of check (no grok call), so an install never starts Grok Build.
|
|
655
|
+
export function competingSummary({ env, cwd }) {
|
|
656
|
+
try {
|
|
657
|
+
const context = contextFrom({ env, cwd }, false);
|
|
658
|
+
return EXTENSION_IDS.flatMap((id) => competingCopies(id, context.paths).map(({ path }) => path));
|
|
659
|
+
} catch {
|
|
660
|
+
return [];
|
|
661
|
+
}
|
|
662
|
+
}
|
|
663
|
+
|
|
664
|
+
export async function runExtensions(args, { env, cwd, stdout, stderr, now }) {
|
|
665
|
+
if (args.length > 0 && args.every((argument) => ['--help', '-h', '--no-color'].includes(argument)) && args.some((argument) => argument !== '--no-color')) {
|
|
666
|
+
stdout.write(EXTENSIONS_USAGE);
|
|
667
|
+
return 0;
|
|
668
|
+
}
|
|
669
|
+
const options = parse(args);
|
|
670
|
+
if (!options) {
|
|
671
|
+
stderr.write(EXTENSIONS_USAGE);
|
|
672
|
+
return 1;
|
|
673
|
+
}
|
|
674
|
+
try {
|
|
675
|
+
const context = contextFrom({ env, cwd, stdout, stderr, now }, options.yes && !options.dryRun);
|
|
676
|
+
if (options.verb === 'list') return runList(options, context);
|
|
677
|
+
if (options.verb === 'check') return runCheck(options, context);
|
|
678
|
+
if (options.verb === 'remove') return runRemove(options, context);
|
|
679
|
+
if (options.verb === 'prune-backups') return runPrune(options, context);
|
|
680
|
+
return runInstallOrUpdate(options, context);
|
|
681
|
+
} catch (error) {
|
|
682
|
+
if (!(error instanceof ExtensionsError)) throw error;
|
|
683
|
+
stderr.write(`litgrok extensions: ${error.message}\n`);
|
|
684
|
+
return 1;
|
|
685
|
+
}
|
|
686
|
+
}
|