tissues 0.6.1 → 0.6.2
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 +78 -1
- package/package.json +4 -2
- package/src/cli.js +10 -1
- package/src/commands/ai.js +147 -173
- package/src/commands/create.js +6 -6
- package/src/commands/create.test.js +381 -0
- package/src/commands/flush.test.js +299 -0
- package/src/commands/list.js +3 -2
- package/src/commands/providers.js +347 -0
- package/src/commands/providers.test.js +28 -0
- package/src/commands/storage.js +167 -0
- package/src/commands/sync.js +225 -0
- package/src/daemon/sync.js +189 -0
- package/src/lib/ai/adapters/claude-cli.js +55 -0
- package/src/lib/ai/adapters/cli-adapters.test.js +133 -0
- package/src/lib/ai/adapters/codex-cli.js +77 -0
- package/src/lib/ai/adapters/gemini-cli.js +55 -0
- package/src/lib/ai/adapters/openclaw.js +91 -0
- package/src/lib/ai/agent-actions.js +271 -0
- package/src/lib/ai/agent.js +323 -0
- package/src/lib/ai/discovery.js +89 -0
- package/src/lib/ai/discovery.test.js +74 -0
- package/src/lib/ai/enhancement-adapter.test.js +188 -0
- package/src/lib/ai/pipeline.test.js +257 -0
- package/src/lib/ai/prompt.test.js +30 -0
- package/src/lib/ai/router.js +23 -0
- package/src/lib/ai/router.test.js +481 -0
- package/src/lib/ai/steps.test.js +335 -0
- package/src/lib/attribution.test.js +64 -0
- package/src/lib/cache.js +408 -0
- package/src/lib/db.js +42 -0
- package/src/lib/dedup.js +8 -18
- package/src/lib/dedup.test.js +227 -0
- package/src/lib/defaults.js +20 -0
- package/src/lib/defaults.test.js +217 -0
- package/src/lib/drafts-perf.test.js +203 -0
- package/src/lib/drafts.test.js +300 -0
- package/src/lib/enhancements.test.js +294 -0
- package/src/lib/gh.js +60 -0
- package/src/lib/safety.test.js +217 -0
- package/src/lib/storage.js +298 -0
- package/src/lib/templates.test.js +207 -0
|
@@ -0,0 +1,347 @@
|
|
|
1
|
+
import { Command } from 'commander'
|
|
2
|
+
import fs from 'node:fs'
|
|
3
|
+
import path from 'node:path'
|
|
4
|
+
import { bold, dim, green, yellow, red, cyan } from '../lib/color.js'
|
|
5
|
+
import { select, input } from '@inquirer/prompts'
|
|
6
|
+
import { loadConfig, userConfigPath } from '../lib/defaults.js'
|
|
7
|
+
import { theme } from '../lib/theme.js'
|
|
8
|
+
import { listProviders, listAllProviders, resolveProviderAdapter, migrateProviderConfig } from '../lib/ai/router.js'
|
|
9
|
+
import { discoverProviders } from '../lib/ai/discovery.js'
|
|
10
|
+
|
|
11
|
+
// ---------------------------------------------------------------------------
|
|
12
|
+
// Helpers
|
|
13
|
+
// ---------------------------------------------------------------------------
|
|
14
|
+
|
|
15
|
+
function readUserConfig() {
|
|
16
|
+
try {
|
|
17
|
+
return JSON.parse(fs.readFileSync(userConfigPath(), 'utf8'))
|
|
18
|
+
} catch {
|
|
19
|
+
return {}
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function writeUserConfig(obj) {
|
|
24
|
+
const filePath = userConfigPath()
|
|
25
|
+
fs.mkdirSync(path.dirname(filePath), { recursive: true, mode: 0o700 })
|
|
26
|
+
fs.writeFileSync(filePath, JSON.stringify(obj, null, 2) + '\n', { encoding: 'utf8', mode: 0o600 })
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const PROVIDER_TYPES = {
|
|
30
|
+
anthropic: 'api',
|
|
31
|
+
openai: 'api',
|
|
32
|
+
gemini: 'api',
|
|
33
|
+
ollama: 'api',
|
|
34
|
+
'openai-compat': 'api',
|
|
35
|
+
command: 'cli',
|
|
36
|
+
'gemini-cli': 'cli',
|
|
37
|
+
'claude-cli': 'cli',
|
|
38
|
+
'codex-cli': 'cli',
|
|
39
|
+
openclaw: 'openclaw',
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function providerType(name) {
|
|
43
|
+
return PROVIDER_TYPES[name] || 'custom'
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function statusIcon(configured) {
|
|
47
|
+
return configured ? green('●') : dim('○')
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// ---------------------------------------------------------------------------
|
|
51
|
+
// Dashboard
|
|
52
|
+
// ---------------------------------------------------------------------------
|
|
53
|
+
|
|
54
|
+
async function showDashboard() {
|
|
55
|
+
const config = loadConfig()
|
|
56
|
+
const ai = config.ai || {}
|
|
57
|
+
const activeProvider = ai.provider || 'anthropic'
|
|
58
|
+
|
|
59
|
+
// Discover available providers on this system
|
|
60
|
+
const discovered = await discoverProviders()
|
|
61
|
+
const discoveredNames = new Set(discovered.map((d) => d.name))
|
|
62
|
+
|
|
63
|
+
// All known providers (built-in + custom)
|
|
64
|
+
const allProviders = listAllProviders(config)
|
|
65
|
+
|
|
66
|
+
console.log(bold('\n AI Providers\n'))
|
|
67
|
+
|
|
68
|
+
// Active provider
|
|
69
|
+
console.log(` ${green('●')} ${bold(activeProvider)} ${dim('(active)')}\n`)
|
|
70
|
+
|
|
71
|
+
// Built-in providers
|
|
72
|
+
console.log(dim(' Built-in:'))
|
|
73
|
+
const builtIn = listProviders()
|
|
74
|
+
for (const name of builtIn) {
|
|
75
|
+
if (name === 'command') continue // skip generic command adapter
|
|
76
|
+
try {
|
|
77
|
+
const { adapter } = resolveProviderAdapter(config, name)
|
|
78
|
+
const configured = adapter.isConfigured()
|
|
79
|
+
const type = providerType(name)
|
|
80
|
+
const active = name === activeProvider ? ` ${green('← active')}` : ''
|
|
81
|
+
const disc = discoveredNames.has(name) ? ` ${cyan('discovered')}` : ''
|
|
82
|
+
console.log(` ${statusIcon(configured)} ${name} ${dim(`[${type}]`)}${active}${disc}`)
|
|
83
|
+
} catch {
|
|
84
|
+
console.log(` ${dim('○')} ${name} ${dim('[unavailable]')}`)
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// Custom providers
|
|
89
|
+
const customNames = allProviders.filter((n) => !builtIn.includes(n))
|
|
90
|
+
if (customNames.length > 0) {
|
|
91
|
+
console.log(dim('\n Custom:'))
|
|
92
|
+
for (const name of customNames) {
|
|
93
|
+
try {
|
|
94
|
+
const { adapter } = resolveProviderAdapter(config, name)
|
|
95
|
+
const configured = adapter.isConfigured()
|
|
96
|
+
const active = name === activeProvider ? ` ${green('← active')}` : ''
|
|
97
|
+
console.log(` ${statusIcon(configured)} ${name} ${dim('[custom]')}${active}`)
|
|
98
|
+
} catch {
|
|
99
|
+
console.log(` ${dim('○')} ${name} ${dim('[error]')}`)
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// Discovered but not yet configured
|
|
105
|
+
const unconfigured = discovered.filter((d) => !allProviders.includes(d.name))
|
|
106
|
+
if (unconfigured.length > 0) {
|
|
107
|
+
console.log(dim('\n Discovered (not configured):'))
|
|
108
|
+
for (const d of unconfigured) {
|
|
109
|
+
console.log(` ${yellow('◌')} ${d.name} ${dim(`[${d.type}]`)} — ${dim(`run: tissues providers add ${d.name}`)}`)
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// Routes
|
|
114
|
+
if (ai.routes?.length) {
|
|
115
|
+
console.log(dim('\n Routes:'))
|
|
116
|
+
for (const rule of ai.routes) {
|
|
117
|
+
const match = rule.match?.template
|
|
118
|
+
? `template:${rule.match.template}`
|
|
119
|
+
: rule.match?.labels
|
|
120
|
+
? `labels:${rule.match.labels.join(',')}`
|
|
121
|
+
: '?'
|
|
122
|
+
console.log(` ${dim('→')} ${match} ${dim('→')} ${rule.provider}${rule.model ? ` (${rule.model})` : ''}`)
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
console.log()
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// ---------------------------------------------------------------------------
|
|
130
|
+
// Add provider
|
|
131
|
+
// ---------------------------------------------------------------------------
|
|
132
|
+
|
|
133
|
+
async function addProvider(name) {
|
|
134
|
+
const config = loadConfig()
|
|
135
|
+
const ai = config.ai || {}
|
|
136
|
+
const builtIn = listProviders()
|
|
137
|
+
const activeProvider = ai.provider || 'anthropic'
|
|
138
|
+
|
|
139
|
+
if (!name) {
|
|
140
|
+
// Interactive: discover + let user pick (exclude already-active provider)
|
|
141
|
+
const discovered = await discoverProviders()
|
|
142
|
+
const choices = discovered
|
|
143
|
+
.filter((d) => d.name !== activeProvider) // Don't show the already-active provider
|
|
144
|
+
.map((d) => ({
|
|
145
|
+
name: `${d.name} [${d.type}]${d.status === 'configured' ? dim(' (configured)') : ''}`,
|
|
146
|
+
value: d.name,
|
|
147
|
+
}))
|
|
148
|
+
if (choices.length === 0) {
|
|
149
|
+
console.log(yellow('No new providers discovered. Install a CLI (gemini, claude, codex) or set API keys.'))
|
|
150
|
+
return
|
|
151
|
+
}
|
|
152
|
+
name = await select({
|
|
153
|
+
message: 'Select a provider to configure:',
|
|
154
|
+
choices,
|
|
155
|
+
theme,
|
|
156
|
+
})
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
if (builtIn.includes(name)) {
|
|
160
|
+
// Built-in provider — just set as active
|
|
161
|
+
const current = readUserConfig()
|
|
162
|
+
if (!current.ai) current.ai = {}
|
|
163
|
+
current.ai.provider = name
|
|
164
|
+
writeUserConfig(current)
|
|
165
|
+
console.log(green(`Set ${bold(name)} as active provider.`))
|
|
166
|
+
return
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
// CLI or OpenClaw provider that was discovered
|
|
170
|
+
const discovered = await discoverProviders()
|
|
171
|
+
const match = discovered.find((d) => d.name === name)
|
|
172
|
+
|
|
173
|
+
if (match?.type === 'openclaw') {
|
|
174
|
+
const current = readUserConfig()
|
|
175
|
+
if (!current.ai) current.ai = {}
|
|
176
|
+
current.ai.openclaw = {
|
|
177
|
+
gatewayUrl: match.config.gatewayUrl,
|
|
178
|
+
token: match.config.token,
|
|
179
|
+
}
|
|
180
|
+
current.ai.provider = 'openclaw'
|
|
181
|
+
writeUserConfig(current)
|
|
182
|
+
console.log(green(`Configured OpenClaw gateway and set as active provider.`))
|
|
183
|
+
return
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
if (match?.type === 'cli') {
|
|
187
|
+
const current = readUserConfig()
|
|
188
|
+
if (!current.ai) current.ai = {}
|
|
189
|
+
current.ai.provider = name
|
|
190
|
+
writeUserConfig(current)
|
|
191
|
+
console.log(green(`Set ${bold(name)} as active provider.`))
|
|
192
|
+
return
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
// Custom provider — prompt for command
|
|
196
|
+
const command = await input({
|
|
197
|
+
message: `Shell command for ${name}:`,
|
|
198
|
+
theme,
|
|
199
|
+
})
|
|
200
|
+
if (!command) return
|
|
201
|
+
|
|
202
|
+
const current = readUserConfig()
|
|
203
|
+
if (!current.ai) current.ai = {}
|
|
204
|
+
if (!current.ai.providers) current.ai.providers = {}
|
|
205
|
+
current.ai.providers[name] = { command }
|
|
206
|
+
writeUserConfig(current)
|
|
207
|
+
console.log(green(`Added custom provider ${bold(name)}.`))
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
// ---------------------------------------------------------------------------
|
|
211
|
+
// Test provider
|
|
212
|
+
// ---------------------------------------------------------------------------
|
|
213
|
+
|
|
214
|
+
async function testProvider(name) {
|
|
215
|
+
const config = loadConfig()
|
|
216
|
+
|
|
217
|
+
if (!name) {
|
|
218
|
+
// Interactive: let user pick from configured providers
|
|
219
|
+
const allProviders = listAllProviders(config)
|
|
220
|
+
const discovered = await discoverProviders()
|
|
221
|
+
const configured = discovered.filter((d) => d.status === 'configured' || allProviders.includes(d.name))
|
|
222
|
+
|
|
223
|
+
if (configured.length === 0) {
|
|
224
|
+
console.log(yellow('No configured providers to test. Run `tissues providers add` first.'))
|
|
225
|
+
return
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
const choices = configured.map((d) => ({
|
|
229
|
+
name: `${d.name} [${d.type}]`,
|
|
230
|
+
value: d.name,
|
|
231
|
+
}))
|
|
232
|
+
|
|
233
|
+
name = await select({
|
|
234
|
+
message: 'Select a provider to test:',
|
|
235
|
+
choices,
|
|
236
|
+
theme,
|
|
237
|
+
})
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
console.log(dim(`Testing ${name}...`))
|
|
241
|
+
|
|
242
|
+
try {
|
|
243
|
+
const { adapter, model } = resolveProviderAdapter(config, name)
|
|
244
|
+
if (!adapter.isConfigured()) {
|
|
245
|
+
console.error(red(`Provider ${name} is not configured.`))
|
|
246
|
+
process.exitCode = 1
|
|
247
|
+
return
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
const messages = [
|
|
251
|
+
{ role: 'system', content: 'You are a helpful assistant. Reply in one short sentence.' },
|
|
252
|
+
{ role: 'user', content: 'Say "hello from tissues" and nothing else.' },
|
|
253
|
+
]
|
|
254
|
+
const response = await adapter.complete(messages, { model, maxTokens: 100 })
|
|
255
|
+
console.log(green('✓ Response:'), response)
|
|
256
|
+
} catch (err) {
|
|
257
|
+
console.error(red(`✗ ${err.message}`))
|
|
258
|
+
process.exitCode = 1
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
// ---------------------------------------------------------------------------
|
|
263
|
+
// Remove provider
|
|
264
|
+
// ---------------------------------------------------------------------------
|
|
265
|
+
|
|
266
|
+
async function removeProvider(name) {
|
|
267
|
+
const config = loadConfig()
|
|
268
|
+
const builtIn = listProviders()
|
|
269
|
+
|
|
270
|
+
if (!name) {
|
|
271
|
+
// Interactive: let user pick from custom providers only
|
|
272
|
+
const customProviders = migrateProviderConfig(config.ai || {})
|
|
273
|
+
const customNames = Object.keys(customProviders)
|
|
274
|
+
|
|
275
|
+
if (customNames.length === 0) {
|
|
276
|
+
console.log(yellow('No custom providers to remove.'))
|
|
277
|
+
return
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
const choices = customNames.map((n) => ({
|
|
281
|
+
name: n,
|
|
282
|
+
value: n,
|
|
283
|
+
}))
|
|
284
|
+
|
|
285
|
+
name = await select({
|
|
286
|
+
message: 'Select a provider to remove:',
|
|
287
|
+
choices,
|
|
288
|
+
theme,
|
|
289
|
+
})
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
if (builtIn.includes(name)) {
|
|
293
|
+
console.error(red(`Cannot remove built-in provider ${name}. Use 'tissues config set ai.provider <other>' to switch.`))
|
|
294
|
+
process.exitCode = 1
|
|
295
|
+
return
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
const current = readUserConfig()
|
|
299
|
+
if (current.ai?.providers?.[name]) {
|
|
300
|
+
delete current.ai.providers[name]
|
|
301
|
+
if (current.ai.provider === name) {
|
|
302
|
+
current.ai.provider = 'anthropic'
|
|
303
|
+
}
|
|
304
|
+
writeUserConfig(current)
|
|
305
|
+
console.log(green(`Removed provider ${bold(name)}.`))
|
|
306
|
+
} else if (current.ai?.commands?.[name]) {
|
|
307
|
+
delete current.ai.commands[name]
|
|
308
|
+
if (current.ai.provider === name) {
|
|
309
|
+
current.ai.provider = 'anthropic'
|
|
310
|
+
}
|
|
311
|
+
writeUserConfig(current)
|
|
312
|
+
console.log(green(`Removed legacy provider ${bold(name)}.`))
|
|
313
|
+
} else {
|
|
314
|
+
console.error(yellow(`Provider ${name} not found in user config.`))
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
// ---------------------------------------------------------------------------
|
|
319
|
+
// Command
|
|
320
|
+
// ---------------------------------------------------------------------------
|
|
321
|
+
|
|
322
|
+
export const providersCommand = new Command('providers')
|
|
323
|
+
.description('Manage AI providers — discover, configure, and test')
|
|
324
|
+
.action(async () => {
|
|
325
|
+
await showDashboard()
|
|
326
|
+
})
|
|
327
|
+
|
|
328
|
+
providersCommand
|
|
329
|
+
.command('add [name]')
|
|
330
|
+
.description('Add or auto-configure a provider')
|
|
331
|
+
.action(async (name) => {
|
|
332
|
+
await addProvider(name)
|
|
333
|
+
})
|
|
334
|
+
|
|
335
|
+
providersCommand
|
|
336
|
+
.command('test [name]')
|
|
337
|
+
.description('Send a test prompt to verify a provider works')
|
|
338
|
+
.action(async (name) => {
|
|
339
|
+
await testProvider(name)
|
|
340
|
+
})
|
|
341
|
+
|
|
342
|
+
providersCommand
|
|
343
|
+
.command('remove [name]')
|
|
344
|
+
.description('Remove a custom provider from config')
|
|
345
|
+
.action(async (name) => {
|
|
346
|
+
await removeProvider(name)
|
|
347
|
+
})
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { describe, it } from 'node:test'
|
|
2
|
+
import assert from 'node:assert/strict'
|
|
3
|
+
import { providersCommand } from './providers.js'
|
|
4
|
+
|
|
5
|
+
describe('providersCommand', () => {
|
|
6
|
+
it('is a Commander command', () => {
|
|
7
|
+
assert.equal(providersCommand.name(), 'providers')
|
|
8
|
+
})
|
|
9
|
+
|
|
10
|
+
it('has description', () => {
|
|
11
|
+
assert.ok(providersCommand.description().length > 0)
|
|
12
|
+
})
|
|
13
|
+
|
|
14
|
+
it('has add subcommand', () => {
|
|
15
|
+
const sub = providersCommand.commands.find((c) => c.name() === 'add')
|
|
16
|
+
assert.ok(sub, 'add subcommand exists')
|
|
17
|
+
})
|
|
18
|
+
|
|
19
|
+
it('has test subcommand', () => {
|
|
20
|
+
const sub = providersCommand.commands.find((c) => c.name() === 'test')
|
|
21
|
+
assert.ok(sub, 'test subcommand exists')
|
|
22
|
+
})
|
|
23
|
+
|
|
24
|
+
it('has remove subcommand', () => {
|
|
25
|
+
const sub = providersCommand.commands.find((c) => c.name() === 'remove')
|
|
26
|
+
assert.ok(sub, 'remove subcommand exists')
|
|
27
|
+
})
|
|
28
|
+
})
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Storage manager command — inspect and manage all cached/stored data.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { Command } from 'commander'
|
|
6
|
+
import { confirm } from '@inquirer/prompts'
|
|
7
|
+
import { store } from '../lib/config.js'
|
|
8
|
+
import { theme } from '../lib/theme.js'
|
|
9
|
+
import {
|
|
10
|
+
getStorageOverview,
|
|
11
|
+
getIssueBreakdown,
|
|
12
|
+
clearCachedData,
|
|
13
|
+
exportStorageJson,
|
|
14
|
+
formatBytes,
|
|
15
|
+
timeAgo,
|
|
16
|
+
} from '../lib/storage.js'
|
|
17
|
+
import { bold, dim, cyan, green, yellow, red } from '../lib/color.js'
|
|
18
|
+
|
|
19
|
+
// ---------------------------------------------------------------------------
|
|
20
|
+
// Display helpers
|
|
21
|
+
// ---------------------------------------------------------------------------
|
|
22
|
+
|
|
23
|
+
function padRight(str, len) {
|
|
24
|
+
return (str + ' '.repeat(len)).slice(0, len)
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function printOverview(overview) {
|
|
28
|
+
const { database, files, totalDiskUsage } = overview
|
|
29
|
+
|
|
30
|
+
console.log(bold('\nDatabase'))
|
|
31
|
+
console.log(` Path: ${dim(database.path)}`)
|
|
32
|
+
console.log(` Size: ${formatBytes(database.size)}`)
|
|
33
|
+
|
|
34
|
+
const tableOrder = [
|
|
35
|
+
'fingerprints', 'idempotency_keys',
|
|
36
|
+
'cached_issues', 'cached_labels', 'cached_repos',
|
|
37
|
+
'circuit_breaker', 'rate_events', 'sync_meta',
|
|
38
|
+
]
|
|
39
|
+
|
|
40
|
+
for (const name of tableOrder) {
|
|
41
|
+
const info = database.tables[name]
|
|
42
|
+
if (!info) continue
|
|
43
|
+
const rowStr = padRight(`${info.rows} rows`, 14)
|
|
44
|
+
const syncStr = info.lastSync ? `last: ${timeAgo(info.lastSync)}` : ''
|
|
45
|
+
console.log(` ${padRight(name, 18)} ${rowStr} ${dim(syncStr)}`)
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
console.log(bold('\nFiles'))
|
|
49
|
+
if (files.drafts.count > 0) {
|
|
50
|
+
console.log(` Drafts: ${files.drafts.count} file${files.drafts.count === 1 ? '' : 's'} ${formatBytes(files.drafts.totalSize)}`)
|
|
51
|
+
}
|
|
52
|
+
if (files.images.count > 0) {
|
|
53
|
+
console.log(` Images: ${files.images.count} file${files.images.count === 1 ? '' : 's'} ${formatBytes(files.images.totalSize)}`)
|
|
54
|
+
}
|
|
55
|
+
if (files.templates.count > 0) {
|
|
56
|
+
console.log(` Templates: ${files.templates.count} file${files.templates.count === 1 ? '' : 's'}`)
|
|
57
|
+
}
|
|
58
|
+
if (files.enhancements.count > 0) {
|
|
59
|
+
console.log(` Enhancements: ${files.enhancements.count} file${files.enhancements.count === 1 ? '' : 's'}`)
|
|
60
|
+
}
|
|
61
|
+
if (files.drafts.count + files.images.count + files.templates.count + files.enhancements.count === 0) {
|
|
62
|
+
console.log(dim(' (none)'))
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
console.log(`\n ${bold('Total disk usage:')} ${formatBytes(totalDiskUsage)}`)
|
|
66
|
+
console.log()
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function printIssueBreakdown(breakdown) {
|
|
70
|
+
if (breakdown.length === 0) {
|
|
71
|
+
console.log(dim('\n No cached issues.\n'))
|
|
72
|
+
return
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
console.log(bold('\nCached Issues'))
|
|
76
|
+
for (const entry of breakdown) {
|
|
77
|
+
const total = entry.open + entry.closed
|
|
78
|
+
const sync = entry.lastSync ? timeAgo(entry.lastSync) : 'never'
|
|
79
|
+
console.log(` ${cyan(entry.repo)}`)
|
|
80
|
+
console.log(` ${entry.open} open, ${entry.closed} closed (${total} total) last sync: ${dim(sync)}`)
|
|
81
|
+
}
|
|
82
|
+
console.log()
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// ---------------------------------------------------------------------------
|
|
86
|
+
// Command
|
|
87
|
+
// ---------------------------------------------------------------------------
|
|
88
|
+
|
|
89
|
+
export const storageCommand = new Command('storage')
|
|
90
|
+
.description('Inspect and manage cached data')
|
|
91
|
+
.argument('[category]', 'Drill-down category (issues, labels, fingerprints)')
|
|
92
|
+
.option('--repo <repo>', 'Scope to a specific repo')
|
|
93
|
+
.option('--json', 'Output as JSON')
|
|
94
|
+
.action(async (category, opts) => {
|
|
95
|
+
const repo = opts.repo || store.get('activeRepo') || undefined
|
|
96
|
+
|
|
97
|
+
if (opts.json) {
|
|
98
|
+
const data = exportStorageJson({ repo })
|
|
99
|
+
console.log(JSON.stringify(data, null, 2))
|
|
100
|
+
return
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
if (category === 'issues') {
|
|
104
|
+
const breakdown = getIssueBreakdown()
|
|
105
|
+
printIssueBreakdown(breakdown)
|
|
106
|
+
return
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
const overview = getStorageOverview({ repo })
|
|
110
|
+
printOverview(overview)
|
|
111
|
+
})
|
|
112
|
+
|
|
113
|
+
// ---------------------------------------------------------------------------
|
|
114
|
+
// Subcommands
|
|
115
|
+
// ---------------------------------------------------------------------------
|
|
116
|
+
|
|
117
|
+
const clearCommand = new Command('clear')
|
|
118
|
+
.description('Clear cached data')
|
|
119
|
+
.argument('<target>', 'What to clear (issues, labels, repos, fingerprints, all)')
|
|
120
|
+
.option('--repo <repo>', 'Scope to a specific repo')
|
|
121
|
+
.option('--include-safety', 'Also reset circuit breaker and rate events')
|
|
122
|
+
.option('--force', 'Skip confirmation')
|
|
123
|
+
.action(async (target, opts) => {
|
|
124
|
+
const validTargets = ['issues', 'labels', 'repos', 'fingerprints', 'all']
|
|
125
|
+
if (!validTargets.includes(target)) {
|
|
126
|
+
console.error(red(`Invalid target: ${target}`))
|
|
127
|
+
console.error(dim(`Valid targets: ${validTargets.join(', ')}`))
|
|
128
|
+
process.exit(1)
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
const repo = opts.repo || undefined
|
|
132
|
+
const scope = repo ? ` for ${cyan(repo)}` : ''
|
|
133
|
+
|
|
134
|
+
if (!opts.force) {
|
|
135
|
+
const msg = target === 'all'
|
|
136
|
+
? `Clear ALL cached data${scope}?${opts.includeSafety ? ' (including safety data)' : ''}`
|
|
137
|
+
: `Clear cached ${target}${scope}?`
|
|
138
|
+
const ok = await confirm({ message: msg, default: false, theme })
|
|
139
|
+
if (!ok) {
|
|
140
|
+
console.log(dim('Aborted.'))
|
|
141
|
+
return
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
const { cleared } = clearCachedData(target, {
|
|
146
|
+
repo,
|
|
147
|
+
includeSafety: opts.includeSafety || false,
|
|
148
|
+
})
|
|
149
|
+
|
|
150
|
+
if (cleared.length > 0) {
|
|
151
|
+
console.log(green('Cleared: ') + cleared.join(', '))
|
|
152
|
+
} else {
|
|
153
|
+
console.log(dim('Nothing to clear.'))
|
|
154
|
+
}
|
|
155
|
+
})
|
|
156
|
+
|
|
157
|
+
const exportCommand = new Command('export')
|
|
158
|
+
.description('Export storage metadata as JSON')
|
|
159
|
+
.option('--repo <repo>', 'Scope to a specific repo')
|
|
160
|
+
.action((opts) => {
|
|
161
|
+
const repo = opts.repo || store.get('activeRepo') || undefined
|
|
162
|
+
const data = exportStorageJson({ repo })
|
|
163
|
+
console.log(JSON.stringify(data, null, 2))
|
|
164
|
+
})
|
|
165
|
+
|
|
166
|
+
storageCommand.addCommand(clearCommand)
|
|
167
|
+
storageCommand.addCommand(exportCommand)
|