@codesummary/cli 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +280 -0
- package/RELEASE.md +48 -0
- package/ROADMAP.md +242 -0
- package/bin/codesummary.js +2103 -0
- package/package.json +33 -0
- package/scripts/package-smoke.sh +58 -0
|
@@ -0,0 +1,2103 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { createHash, randomBytes } from 'node:crypto'
|
|
3
|
+
import { copyFileSync, createReadStream, existsSync, mkdirSync, readFileSync, readdirSync, statSync, writeFileSync } from 'node:fs'
|
|
4
|
+
import { mkdtempSync, rmSync } from 'node:fs'
|
|
5
|
+
import { createServer } from 'node:http'
|
|
6
|
+
import { basename, dirname, isAbsolute, join, relative, resolve } from 'node:path'
|
|
7
|
+
import { emitKeypressEvents } from 'node:readline'
|
|
8
|
+
import { spawnSync } from 'node:child_process'
|
|
9
|
+
import { homedir, tmpdir } from 'node:os'
|
|
10
|
+
|
|
11
|
+
const DEFAULT_API_URL = 'https://dashboard.codesummary.io'
|
|
12
|
+
const CONFIG_DIR = '.codesummary'
|
|
13
|
+
const CONFIG_FILE = 'config.json'
|
|
14
|
+
const MANIFEST_FILE = 'manifest.json'
|
|
15
|
+
const LAST_SUBMITTED_MANIFEST_FILE = 'last-submitted-manifest.json'
|
|
16
|
+
const LAST_SOURCE_CHECK_MANIFEST_FILE = 'last-source-check-manifest.json'
|
|
17
|
+
const LAST_GENERATION_ATTEMPT_MANIFEST_FILE = 'last-generation-attempt-manifest.json'
|
|
18
|
+
const LOCAL_GENERATION_SCHEMA_FILE = 'local-generation.schema.json'
|
|
19
|
+
const DEFAULT_LOCAL_CONTEXT_BYTES = 180_000
|
|
20
|
+
const DEFAULT_LOCAL_FILE_BYTES = 24_000
|
|
21
|
+
const ANSI = {
|
|
22
|
+
reset: '\x1b[0m',
|
|
23
|
+
inverse: '\x1b[7m',
|
|
24
|
+
bold: '\x1b[1m',
|
|
25
|
+
dim: '\x1b[2m',
|
|
26
|
+
}
|
|
27
|
+
const DEFAULT_EXCLUDES = new Set([
|
|
28
|
+
'.git',
|
|
29
|
+
'node_modules',
|
|
30
|
+
'.next',
|
|
31
|
+
'dist',
|
|
32
|
+
'dist-lambda',
|
|
33
|
+
'build',
|
|
34
|
+
'coverage',
|
|
35
|
+
'.turbo',
|
|
36
|
+
'.vercel',
|
|
37
|
+
'.cache',
|
|
38
|
+
CONFIG_DIR,
|
|
39
|
+
])
|
|
40
|
+
const SECRET_FILE_PATTERNS = [
|
|
41
|
+
/^\.env($|\.)/,
|
|
42
|
+
/^\.npmrc$/,
|
|
43
|
+
/^\.yarnrc$/,
|
|
44
|
+
/^id_rsa$/,
|
|
45
|
+
/^id_ed25519$/,
|
|
46
|
+
]
|
|
47
|
+
const GENERATED_DIR_PATTERNS = [
|
|
48
|
+
/^dist-.+/,
|
|
49
|
+
/^build-.+/,
|
|
50
|
+
/^\.serverless$/,
|
|
51
|
+
/^\.sst$/,
|
|
52
|
+
]
|
|
53
|
+
const GENERATED_FILE_PATTERNS = [
|
|
54
|
+
/\.zip$/i,
|
|
55
|
+
/\.tar$/i,
|
|
56
|
+
/\.tar\.gz$/i,
|
|
57
|
+
/\.tgz$/i,
|
|
58
|
+
]
|
|
59
|
+
|
|
60
|
+
function printHelp() {
|
|
61
|
+
console.log(`CodeSummary CLI
|
|
62
|
+
|
|
63
|
+
Usage:
|
|
64
|
+
codesummary
|
|
65
|
+
codesummary new [directory] [--name <source-name>] [--api-url <url>] [--provider codex|openai|anthropic|mock] [--output <dir>] [--force]
|
|
66
|
+
codesummary login [--api-url <url>] [--scope <scope>]
|
|
67
|
+
codesummary whoami [--api-url <url>]
|
|
68
|
+
codesummary logout
|
|
69
|
+
codesummary configure [--provider codex|openai|anthropic|mock] [--output <dir>] [--api-key-env <env>] [--model <model>]
|
|
70
|
+
codesummary config set <key> <value>
|
|
71
|
+
codesummary config get [key]
|
|
72
|
+
codesummary config clear <key>
|
|
73
|
+
codesummary link --site-token <token> [--api-url <url>] [--name <source-name>] [--no-verify]
|
|
74
|
+
codesummary manifest [--root <path>]
|
|
75
|
+
codesummary diff [--root <path>] [--against <manifest.json>]
|
|
76
|
+
codesummary export [--root <path>] [--output <dir|s3://bucket/prefix>] [--include-archive]
|
|
77
|
+
codesummary generate [--provider codex|openai|anthropic|mock] [--root <path>] [--output <dir>] [--api-key-env <env>] [--model <model>] [--dry-run]
|
|
78
|
+
codesummary submit [--token <token>] [--api-url <url>] [--site <site-id-or-slug>] [--name <source-name>] [--depth standard|deep] [--no-generate] [--wait] [--verbose]
|
|
79
|
+
codesummary status [--job-id <id>] [--token <token>] [--api-url <url>] [--wait] [--verbose]
|
|
80
|
+
codesummary version
|
|
81
|
+
codesummary update
|
|
82
|
+
|
|
83
|
+
Environment:
|
|
84
|
+
CODESUMMARY_API_KEY Product token or OAuth access token with write:knowledge scope; optional after codesummary login
|
|
85
|
+
CODESUMMARY_API_URL Dashboard API base URL
|
|
86
|
+
OPENAI_API_KEY Used by generate --provider openai unless --api-key-env is set
|
|
87
|
+
ANTHROPIC_API_KEY Used by generate --provider anthropic unless --api-key-env is set
|
|
88
|
+
`)
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function packageInfo() {
|
|
92
|
+
return JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8'))
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function parseArgs(argv) {
|
|
96
|
+
const out = { _: [] }
|
|
97
|
+
for (let i = 0; i < argv.length; i += 1) {
|
|
98
|
+
const arg = argv[i]
|
|
99
|
+
if (!arg.startsWith('--')) {
|
|
100
|
+
out._.push(arg)
|
|
101
|
+
continue
|
|
102
|
+
}
|
|
103
|
+
const key = arg.slice(2)
|
|
104
|
+
if (key.startsWith('no-')) {
|
|
105
|
+
out[key.slice(3)] = false
|
|
106
|
+
continue
|
|
107
|
+
}
|
|
108
|
+
const next = argv[i + 1]
|
|
109
|
+
if (!next || next.startsWith('--')) {
|
|
110
|
+
out[key] = true
|
|
111
|
+
continue
|
|
112
|
+
}
|
|
113
|
+
out[key] = next
|
|
114
|
+
i += 1
|
|
115
|
+
}
|
|
116
|
+
return out
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function configPath(root = process.cwd()) {
|
|
120
|
+
return join(root, CONFIG_DIR, CONFIG_FILE)
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function readConfig(root = process.cwd()) {
|
|
124
|
+
const path = configPath(root)
|
|
125
|
+
if (!existsSync(path)) return {}
|
|
126
|
+
return JSON.parse(readFileSync(path, 'utf8'))
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function writeConfig(config, root = process.cwd()) {
|
|
130
|
+
const dir = join(root, CONFIG_DIR)
|
|
131
|
+
mkdirSync(dir, { recursive: true })
|
|
132
|
+
writeFileSync(configPath(root), `${JSON.stringify(config, null, 2)}\n`)
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function configKeyAllowed(key) {
|
|
136
|
+
return [
|
|
137
|
+
'apiUrl',
|
|
138
|
+
'sourceName',
|
|
139
|
+
'depth',
|
|
140
|
+
'site.id',
|
|
141
|
+
'site.slug',
|
|
142
|
+
'site.name',
|
|
143
|
+
'localGeneration.provider',
|
|
144
|
+
'localGeneration.output',
|
|
145
|
+
'localGeneration.model',
|
|
146
|
+
'localGeneration.apiKeyEnv',
|
|
147
|
+
].includes(key)
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
function readConfigValue(config, key) {
|
|
151
|
+
return key.split('.').reduce((value, part) => value?.[part], config)
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function writeConfigValue(config, key, value) {
|
|
155
|
+
if (!configKeyAllowed(key)) {
|
|
156
|
+
throw new Error(`Unsupported config key: ${key}`)
|
|
157
|
+
}
|
|
158
|
+
const next = { ...config }
|
|
159
|
+
const parts = key.split('.')
|
|
160
|
+
let cursor = next
|
|
161
|
+
for (let i = 0; i < parts.length - 1; i += 1) {
|
|
162
|
+
const part = parts[i]
|
|
163
|
+
cursor[part] = { ...(cursor[part] || {}) }
|
|
164
|
+
cursor = cursor[part]
|
|
165
|
+
}
|
|
166
|
+
cursor[parts[parts.length - 1]] = value
|
|
167
|
+
return next
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
function clearConfigValue(config, key) {
|
|
171
|
+
if (!configKeyAllowed(key)) {
|
|
172
|
+
throw new Error(`Unsupported config key: ${key}`)
|
|
173
|
+
}
|
|
174
|
+
const next = { ...config }
|
|
175
|
+
const parts = key.split('.')
|
|
176
|
+
let cursor = next
|
|
177
|
+
for (let i = 0; i < parts.length - 1; i += 1) {
|
|
178
|
+
const part = parts[i]
|
|
179
|
+
if (!cursor[part] || typeof cursor[part] !== 'object') return next
|
|
180
|
+
cursor[part] = { ...cursor[part] }
|
|
181
|
+
cursor = cursor[part]
|
|
182
|
+
}
|
|
183
|
+
delete cursor[parts[parts.length - 1]]
|
|
184
|
+
return next
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
function authPath() {
|
|
188
|
+
return join(homedir(), CONFIG_DIR, 'auth.json')
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
function readAuth() {
|
|
192
|
+
const path = authPath()
|
|
193
|
+
if (!existsSync(path)) return { sessions: {}, defaultKey: null }
|
|
194
|
+
return JSON.parse(readFileSync(path, 'utf8'))
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
function writeAuth(auth) {
|
|
198
|
+
const dir = join(homedir(), CONFIG_DIR)
|
|
199
|
+
mkdirSync(dir, { recursive: true })
|
|
200
|
+
writeFileSync(authPath(), `${JSON.stringify(auth, null, 2)}\n`)
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
function sleep(ms) {
|
|
204
|
+
return new Promise((resolvePromise) => setTimeout(resolvePromise, ms))
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
function cliAuthHeaders(token) {
|
|
208
|
+
return { 'X-CodeSummary-Token': token }
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
function shouldExclude(name, relPath) {
|
|
212
|
+
if (DEFAULT_EXCLUDES.has(name)) return true
|
|
213
|
+
if (GENERATED_DIR_PATTERNS.some((pattern) => pattern.test(name))) return true
|
|
214
|
+
if (GENERATED_FILE_PATTERNS.some((pattern) => pattern.test(name))) return true
|
|
215
|
+
if (relPath.includes(`${CONFIG_DIR}/`)) return true
|
|
216
|
+
if (SECRET_FILE_PATTERNS.some((pattern) => pattern.test(name))) return true
|
|
217
|
+
if (relPath === join(CONFIG_DIR, CONFIG_FILE)) return true
|
|
218
|
+
return false
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
function pathIsInside(parent, child) {
|
|
222
|
+
const rel = relative(resolve(parent), resolve(child))
|
|
223
|
+
return rel === '' || (!!rel && !rel.startsWith('..'))
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
function walkFiles(root, dir = root, files = [], options = {}) {
|
|
227
|
+
for (const entry of readdirSync(dir, { withFileTypes: true })) {
|
|
228
|
+
const abs = join(dir, entry.name)
|
|
229
|
+
const rel = relative(root, abs)
|
|
230
|
+
if ((options.excludePaths || []).some((path) => pathIsInside(path, abs))) continue
|
|
231
|
+
if (shouldExclude(entry.name, rel)) continue
|
|
232
|
+
if (entry.isDirectory()) {
|
|
233
|
+
walkFiles(root, abs, files, options)
|
|
234
|
+
} else if (entry.isFile()) {
|
|
235
|
+
files.push(abs)
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
return files
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
function sha256File(path) {
|
|
242
|
+
const hash = createHash('sha256')
|
|
243
|
+
const stream = createReadStream(path)
|
|
244
|
+
return new Promise((resolvePromise, reject) => {
|
|
245
|
+
stream.on('data', (chunk) => hash.update(chunk))
|
|
246
|
+
stream.on('error', reject)
|
|
247
|
+
stream.on('end', () => resolvePromise(hash.digest('hex')))
|
|
248
|
+
})
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
async function writeManifest(root, options = {}) {
|
|
252
|
+
const files = walkFiles(root, root, [], options)
|
|
253
|
+
const manifestFiles = []
|
|
254
|
+
for (const file of files) {
|
|
255
|
+
const stats = statSync(file)
|
|
256
|
+
manifestFiles.push({
|
|
257
|
+
path: relative(root, file).replaceAll('\\', '/'),
|
|
258
|
+
bytes: stats.size,
|
|
259
|
+
sha256: await sha256File(file),
|
|
260
|
+
modifiedAt: stats.mtime.toISOString(),
|
|
261
|
+
})
|
|
262
|
+
}
|
|
263
|
+
manifestFiles.sort((a, b) => a.path.localeCompare(b.path))
|
|
264
|
+
const manifest = {
|
|
265
|
+
schemaVersion: 1,
|
|
266
|
+
generatedAt: new Date().toISOString(),
|
|
267
|
+
rootName: basename(root),
|
|
268
|
+
files: manifestFiles,
|
|
269
|
+
}
|
|
270
|
+
const dir = join(root, CONFIG_DIR)
|
|
271
|
+
mkdirSync(dir, { recursive: true })
|
|
272
|
+
writeFileSync(join(dir, MANIFEST_FILE), `${JSON.stringify(manifest, null, 2)}\n`)
|
|
273
|
+
return manifest
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
function writeLastSubmittedManifest(root, manifest) {
|
|
277
|
+
const dir = join(root, CONFIG_DIR)
|
|
278
|
+
mkdirSync(dir, { recursive: true })
|
|
279
|
+
writeFileSync(join(dir, LAST_SUBMITTED_MANIFEST_FILE), `${JSON.stringify(manifest, null, 2)}\n`)
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
function writeLastSourceCheckManifest(root, manifest) {
|
|
283
|
+
const dir = join(root, CONFIG_DIR)
|
|
284
|
+
mkdirSync(dir, { recursive: true })
|
|
285
|
+
writeFileSync(join(dir, LAST_SOURCE_CHECK_MANIFEST_FILE), `${JSON.stringify(manifest, null, 2)}\n`)
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
function writeLastGenerationAttemptManifest(root, manifest) {
|
|
289
|
+
const dir = join(root, CONFIG_DIR)
|
|
290
|
+
mkdirSync(dir, { recursive: true })
|
|
291
|
+
writeFileSync(join(dir, LAST_GENERATION_ATTEMPT_MANIFEST_FILE), `${JSON.stringify(manifest, null, 2)}\n`)
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
function readManifestFile(path) {
|
|
295
|
+
const manifest = JSON.parse(readFileSync(path, 'utf8'))
|
|
296
|
+
if (manifest?.schemaVersion !== 1 || !Array.isArray(manifest.files)) {
|
|
297
|
+
throw new Error(`Invalid CodeSummary manifest: ${path}`)
|
|
298
|
+
}
|
|
299
|
+
return manifest
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
function summarizeManifestDiff(previousManifest, currentManifest) {
|
|
303
|
+
const previous = new Map(previousManifest.files.map((file) => [file.path, file]))
|
|
304
|
+
const current = new Map(currentManifest.files.map((file) => [file.path, file]))
|
|
305
|
+
const added = []
|
|
306
|
+
const removed = []
|
|
307
|
+
const changed = []
|
|
308
|
+
const unchanged = []
|
|
309
|
+
|
|
310
|
+
for (const [path, file] of current.entries()) {
|
|
311
|
+
const old = previous.get(path)
|
|
312
|
+
if (!old) {
|
|
313
|
+
added.push(path)
|
|
314
|
+
} else if (old.sha256 !== file.sha256 || old.bytes !== file.bytes) {
|
|
315
|
+
changed.push(path)
|
|
316
|
+
} else {
|
|
317
|
+
unchanged.push(path)
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
for (const path of previous.keys()) {
|
|
322
|
+
if (!current.has(path)) removed.push(path)
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
return {
|
|
326
|
+
added: added.sort(),
|
|
327
|
+
changed: changed.sort(),
|
|
328
|
+
removed: removed.sort(),
|
|
329
|
+
unchanged: unchanged.length,
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
function createZip(root, options = {}) {
|
|
334
|
+
const tmp = mkdtempSync(join(tmpdir(), 'codesummary-'))
|
|
335
|
+
const archive = join(tmp, 'workspace.zip')
|
|
336
|
+
const excludes = [
|
|
337
|
+
'*.git/*',
|
|
338
|
+
'*node_modules/*',
|
|
339
|
+
'*.next/*',
|
|
340
|
+
'*dist/*',
|
|
341
|
+
'*dist-*/*',
|
|
342
|
+
'*build/*',
|
|
343
|
+
'*build-*/*',
|
|
344
|
+
'*coverage/*',
|
|
345
|
+
'*.serverless/*',
|
|
346
|
+
'*.sst/*',
|
|
347
|
+
'*.turbo/*',
|
|
348
|
+
'*.vercel/*',
|
|
349
|
+
'*.cache/*',
|
|
350
|
+
'*.codesummary/*',
|
|
351
|
+
'.codesummary/*',
|
|
352
|
+
'*.env',
|
|
353
|
+
'*.env.*',
|
|
354
|
+
'*.npmrc',
|
|
355
|
+
'*.yarnrc',
|
|
356
|
+
'*.zip',
|
|
357
|
+
'*.tar',
|
|
358
|
+
'*.tar.gz',
|
|
359
|
+
'*.tgz',
|
|
360
|
+
]
|
|
361
|
+
for (const path of options.excludePaths || []) {
|
|
362
|
+
const rel = relative(root, path).replaceAll('\\', '/')
|
|
363
|
+
if (!rel || rel.startsWith('..')) continue
|
|
364
|
+
excludes.push(rel, `${rel}/*`, `*${rel}/*`)
|
|
365
|
+
}
|
|
366
|
+
const args = ['-qr', archive, '.', ...excludes.flatMap((pattern) => ['-x', pattern])]
|
|
367
|
+
const result = spawnSync('zip', args, { cwd: root, encoding: 'utf8' })
|
|
368
|
+
if (result.status !== 0) {
|
|
369
|
+
rmSync(tmp, { recursive: true, force: true })
|
|
370
|
+
throw new Error(result.stderr || 'zip failed')
|
|
371
|
+
}
|
|
372
|
+
return { archive, cleanup: () => rmSync(tmp, { recursive: true, force: true }) }
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
function writeExportFiles(root, outputDir, manifest, options = {}) {
|
|
376
|
+
mkdirSync(outputDir, { recursive: true })
|
|
377
|
+
const generatedAt = new Date().toISOString()
|
|
378
|
+
const metadata = {
|
|
379
|
+
schemaVersion: 1,
|
|
380
|
+
generatedAt,
|
|
381
|
+
rootName: manifest.rootName,
|
|
382
|
+
fileCount: manifest.files.length,
|
|
383
|
+
totalBytes: manifest.files.reduce((sum, file) => sum + file.bytes, 0),
|
|
384
|
+
includesSourceArchive: Boolean(options.includeArchive),
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
writeFileSync(join(outputDir, 'codesummary-manifest.json'), `${JSON.stringify(manifest, null, 2)}\n`)
|
|
388
|
+
writeFileSync(join(outputDir, 'codesummary-export.json'), `${JSON.stringify(metadata, null, 2)}\n`)
|
|
389
|
+
writeFileSync(
|
|
390
|
+
join(outputDir, 'README.md'),
|
|
391
|
+
[
|
|
392
|
+
'# CodeSummary Export',
|
|
393
|
+
'',
|
|
394
|
+
'This bundle was generated by the CodeSummary CLI.',
|
|
395
|
+
'',
|
|
396
|
+
'- `codesummary-manifest.json` contains file paths, byte sizes, mtimes, and SHA-256 hashes.',
|
|
397
|
+
'- `codesummary-export.json` contains bundle metadata.',
|
|
398
|
+
options.includeArchive
|
|
399
|
+
? '- `workspace.zip` contains the submitted workspace with default secret/build exclusions applied.'
|
|
400
|
+
: '- Source contents are not included. Re-run with `--include-archive` only when exporting to infrastructure you control.',
|
|
401
|
+
'',
|
|
402
|
+
].join('\n')
|
|
403
|
+
)
|
|
404
|
+
return metadata
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
function syncDirectoryToS3(localDir, output) {
|
|
408
|
+
const result = spawnSync('aws', ['s3', 'sync', localDir, output], { encoding: 'utf8' })
|
|
409
|
+
if (result.status !== 0) {
|
|
410
|
+
throw new Error(result.stderr || 'aws s3 sync failed')
|
|
411
|
+
}
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
function localGenerationSchema() {
|
|
415
|
+
return {
|
|
416
|
+
type: 'object',
|
|
417
|
+
additionalProperties: false,
|
|
418
|
+
required: ['files'],
|
|
419
|
+
properties: {
|
|
420
|
+
files: {
|
|
421
|
+
type: 'array',
|
|
422
|
+
minItems: 1,
|
|
423
|
+
items: {
|
|
424
|
+
type: 'object',
|
|
425
|
+
additionalProperties: false,
|
|
426
|
+
required: ['path', 'content'],
|
|
427
|
+
properties: {
|
|
428
|
+
path: {
|
|
429
|
+
type: 'string',
|
|
430
|
+
pattern: '^[A-Za-z0-9][A-Za-z0-9._/ -]*\\.md$',
|
|
431
|
+
},
|
|
432
|
+
title: { type: 'string' },
|
|
433
|
+
content: { type: 'string', minLength: 20 },
|
|
434
|
+
},
|
|
435
|
+
},
|
|
436
|
+
},
|
|
437
|
+
},
|
|
438
|
+
}
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
function localGenerationSystemPrompt() {
|
|
442
|
+
return [
|
|
443
|
+
'You are CodeSummary local generation.',
|
|
444
|
+
'Generate concise, accurate Markdown documentation for the submitted codebase context.',
|
|
445
|
+
'Return only JSON matching this shape: {"files":[{"path":"README.md","title":"...","content":"# ..."}]}.',
|
|
446
|
+
'Do not include prose outside JSON.',
|
|
447
|
+
'Do not invent APIs, services, or deployment facts that are not supported by the context.',
|
|
448
|
+
'Prefer practical docs engineers can use immediately: overview, architecture, setup, important workflows, and operational notes.',
|
|
449
|
+
'Use relative Markdown links only when they point to files you are creating.',
|
|
450
|
+
'Never include secrets or credentials.',
|
|
451
|
+
].join('\n')
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
function isLikelyTextFile(filePath) {
|
|
455
|
+
const name = basename(filePath).toLowerCase()
|
|
456
|
+
if ([
|
|
457
|
+
'readme',
|
|
458
|
+
'makefile',
|
|
459
|
+
'dockerfile',
|
|
460
|
+
'package.json',
|
|
461
|
+
'tsconfig.json',
|
|
462
|
+
'terraform.tfvars.example',
|
|
463
|
+
].includes(name)) return true
|
|
464
|
+
return /\.(ts|tsx|js|jsx|mjs|cjs|json|md|mdx|yml|yaml|toml|tf|sh|sql|py|go|rs|java|kt|swift|rb|php|cs|css|scss|html|txt)$/i.test(filePath)
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
function priorityForFile(relPath) {
|
|
468
|
+
const lower = relPath.toLowerCase()
|
|
469
|
+
if (/(^|\/)(readme|agents|package|pnpm-workspace|turbo|next\.config|vite\.config|dockerfile)/.test(lower)) return 0
|
|
470
|
+
if (/(^|\/)(src|app|apps|packages|lib|server|api|routes)\//.test(lower)) return 1
|
|
471
|
+
if (/(^|\/)(infra|terraform|environments|modules|deploy|scripts|\.github)\//.test(lower)) return 2
|
|
472
|
+
if (/\.(md|mdx|yml|yaml|json|tf|ts|tsx|js|jsx)$/i.test(lower)) return 3
|
|
473
|
+
return 4
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
function buildLocalContext(root, manifest, options = {}) {
|
|
477
|
+
const maxBytes = Number(options['max-context-bytes'] || DEFAULT_LOCAL_CONTEXT_BYTES)
|
|
478
|
+
const maxFileBytes = Number(options['max-file-bytes'] || DEFAULT_LOCAL_FILE_BYTES)
|
|
479
|
+
const outputDir = options.outputDir ? resolve(options.outputDir) : null
|
|
480
|
+
const files = walkFiles(root, root, [], options)
|
|
481
|
+
.map((abs) => {
|
|
482
|
+
const rel = relative(root, abs).replaceAll('\\', '/')
|
|
483
|
+
return { abs, rel, priority: priorityForFile(rel), bytes: statSync(abs).size }
|
|
484
|
+
})
|
|
485
|
+
.filter((file) => {
|
|
486
|
+
if (!isLikelyTextFile(file.rel) || file.bytes <= 0) return false
|
|
487
|
+
if (!outputDir) return true
|
|
488
|
+
const relToOutput = relative(outputDir, file.abs)
|
|
489
|
+
return relToOutput.startsWith('..') || relToOutput === ''
|
|
490
|
+
})
|
|
491
|
+
.sort((a, b) => a.priority - b.priority || a.rel.localeCompare(b.rel))
|
|
492
|
+
|
|
493
|
+
const selected = []
|
|
494
|
+
let usedBytes = 0
|
|
495
|
+
for (const file of files) {
|
|
496
|
+
if (usedBytes >= maxBytes) break
|
|
497
|
+
const limit = Math.min(file.bytes, maxFileBytes, maxBytes - usedBytes)
|
|
498
|
+
let content = readFileSync(file.abs, 'utf8')
|
|
499
|
+
if (content.length > limit) {
|
|
500
|
+
content = `${content.slice(0, limit)}\n\n[truncated by CodeSummary local context pack]\n`
|
|
501
|
+
}
|
|
502
|
+
usedBytes += Buffer.byteLength(content)
|
|
503
|
+
selected.push({ path: file.rel, bytes: file.bytes, includedBytes: Buffer.byteLength(content), content })
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
const tree = manifest.files.map((file) => `${file.path} (${file.bytes} bytes)`).join('\n')
|
|
507
|
+
const contents = selected.map((file) => [
|
|
508
|
+
`--- FILE: ${file.path}`,
|
|
509
|
+
file.content,
|
|
510
|
+
].join('\n')).join('\n\n')
|
|
511
|
+
|
|
512
|
+
return {
|
|
513
|
+
selected,
|
|
514
|
+
prompt: [
|
|
515
|
+
localGenerationSystemPrompt(),
|
|
516
|
+
'',
|
|
517
|
+
`Repository root name: ${manifest.rootName}`,
|
|
518
|
+
`Manifest file count: ${manifest.files.length}`,
|
|
519
|
+
'',
|
|
520
|
+
'FULL FILE MANIFEST:',
|
|
521
|
+
tree,
|
|
522
|
+
'',
|
|
523
|
+
'SELECTED LOCAL FILE CONTENTS:',
|
|
524
|
+
contents || '(no text files selected)',
|
|
525
|
+
'',
|
|
526
|
+
'Generate a useful initial documentation set. Keep it grounded in this context.',
|
|
527
|
+
].join('\n'),
|
|
528
|
+
}
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
function extractJsonObject(text) {
|
|
532
|
+
const trimmed = String(text || '').trim()
|
|
533
|
+
if (!trimmed) throw new Error('Provider returned an empty response.')
|
|
534
|
+
try {
|
|
535
|
+
return JSON.parse(trimmed)
|
|
536
|
+
} catch {
|
|
537
|
+
const start = trimmed.indexOf('{')
|
|
538
|
+
const end = trimmed.lastIndexOf('}')
|
|
539
|
+
if (start === -1 || end === -1 || end <= start) {
|
|
540
|
+
throw new Error('Provider response did not contain a JSON object.')
|
|
541
|
+
}
|
|
542
|
+
return JSON.parse(trimmed.slice(start, end + 1))
|
|
543
|
+
}
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
function validateGeneratedDocs(payload) {
|
|
547
|
+
if (!payload || !Array.isArray(payload.files) || payload.files.length === 0) {
|
|
548
|
+
throw new Error('Generated output must include at least one file.')
|
|
549
|
+
}
|
|
550
|
+
const normalized = []
|
|
551
|
+
for (const file of payload.files) {
|
|
552
|
+
if (!file || typeof file.path !== 'string' || typeof file.content !== 'string') {
|
|
553
|
+
throw new Error('Each generated file must include path and content.')
|
|
554
|
+
}
|
|
555
|
+
const path = file.path.replaceAll('\\', '/').replace(/^\/+/, '')
|
|
556
|
+
if (path.includes('..') || !path.endsWith('.md')) {
|
|
557
|
+
throw new Error(`Unsafe generated path: ${file.path}`)
|
|
558
|
+
}
|
|
559
|
+
if (file.content.trim().length < 20) {
|
|
560
|
+
throw new Error(`Generated file is too small: ${path}`)
|
|
561
|
+
}
|
|
562
|
+
normalized.push({ path, title: file.title || null, content: file.content.trimEnd() + '\n' })
|
|
563
|
+
}
|
|
564
|
+
return { files: normalized }
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
function writeGeneratedDocs(outputDir, generated) {
|
|
568
|
+
mkdirSync(outputDir, { recursive: true })
|
|
569
|
+
for (const file of generated.files) {
|
|
570
|
+
const target = resolve(outputDir, file.path)
|
|
571
|
+
const safeOutputRoot = resolve(outputDir)
|
|
572
|
+
const rel = relative(safeOutputRoot, target)
|
|
573
|
+
if (rel.startsWith('..') || rel === '') {
|
|
574
|
+
throw new Error(`Generated path escapes output directory: ${file.path}`)
|
|
575
|
+
}
|
|
576
|
+
mkdirSync(dirname(target), { recursive: true })
|
|
577
|
+
writeFileSync(target, file.content)
|
|
578
|
+
}
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
function runCodexProvider(prompt, options) {
|
|
582
|
+
const tmp = mkdtempSync(join(tmpdir(), 'codesummary-codex-'))
|
|
583
|
+
const outputPath = join(tmp, 'last-message.txt')
|
|
584
|
+
const schemaPath = join(tmp, LOCAL_GENERATION_SCHEMA_FILE)
|
|
585
|
+
writeFileSync(schemaPath, `${JSON.stringify(localGenerationSchema(), null, 2)}\n`)
|
|
586
|
+
|
|
587
|
+
try {
|
|
588
|
+
const command = String(options['codex-command'] || 'codex')
|
|
589
|
+
const args = [
|
|
590
|
+
'exec',
|
|
591
|
+
'--skip-git-repo-check',
|
|
592
|
+
'--ephemeral',
|
|
593
|
+
'--sandbox',
|
|
594
|
+
'read-only',
|
|
595
|
+
'--ask-for-approval',
|
|
596
|
+
'never',
|
|
597
|
+
'--output-schema',
|
|
598
|
+
schemaPath,
|
|
599
|
+
'--output-last-message',
|
|
600
|
+
outputPath,
|
|
601
|
+
]
|
|
602
|
+
if (options.model) args.push('--model', String(options.model))
|
|
603
|
+
args.push('-')
|
|
604
|
+
const result = spawnSync(command, args, {
|
|
605
|
+
input: prompt,
|
|
606
|
+
encoding: 'utf8',
|
|
607
|
+
maxBuffer: 10 * 1024 * 1024,
|
|
608
|
+
})
|
|
609
|
+
if (result.status !== 0) {
|
|
610
|
+
throw new Error(result.stderr || result.stdout || `${command} exec failed`)
|
|
611
|
+
}
|
|
612
|
+
if (!existsSync(outputPath)) {
|
|
613
|
+
throw new Error('Codex did not write an output message.')
|
|
614
|
+
}
|
|
615
|
+
return readFileSync(outputPath, 'utf8')
|
|
616
|
+
} finally {
|
|
617
|
+
rmSync(tmp, { recursive: true, force: true })
|
|
618
|
+
}
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
async function callOpenAiCompatible(apiUrl, apiKey, model, prompt) {
|
|
622
|
+
const response = await fetch(apiUrl, {
|
|
623
|
+
method: 'POST',
|
|
624
|
+
headers: {
|
|
625
|
+
Authorization: `Bearer ${apiKey}`,
|
|
626
|
+
'Content-Type': 'application/json',
|
|
627
|
+
},
|
|
628
|
+
body: JSON.stringify({
|
|
629
|
+
model,
|
|
630
|
+
messages: [
|
|
631
|
+
{ role: 'system', content: localGenerationSystemPrompt() },
|
|
632
|
+
{ role: 'user', content: prompt },
|
|
633
|
+
],
|
|
634
|
+
temperature: 0.2,
|
|
635
|
+
response_format: { type: 'json_object' },
|
|
636
|
+
}),
|
|
637
|
+
})
|
|
638
|
+
const body = await response.json().catch(() => ({}))
|
|
639
|
+
if (!response.ok) {
|
|
640
|
+
throw new Error(body.error?.message || body.message || `OpenAI-compatible provider failed with HTTP ${response.status}`)
|
|
641
|
+
}
|
|
642
|
+
return body.choices?.[0]?.message?.content || ''
|
|
643
|
+
}
|
|
644
|
+
|
|
645
|
+
async function callAnthropic(apiUrl, apiKey, model, prompt) {
|
|
646
|
+
const response = await fetch(apiUrl, {
|
|
647
|
+
method: 'POST',
|
|
648
|
+
headers: {
|
|
649
|
+
'x-api-key': apiKey,
|
|
650
|
+
'anthropic-version': '2023-06-01',
|
|
651
|
+
'Content-Type': 'application/json',
|
|
652
|
+
},
|
|
653
|
+
body: JSON.stringify({
|
|
654
|
+
model,
|
|
655
|
+
max_tokens: 5000,
|
|
656
|
+
temperature: 0.2,
|
|
657
|
+
system: localGenerationSystemPrompt(),
|
|
658
|
+
messages: [{ role: 'user', content: prompt }],
|
|
659
|
+
}),
|
|
660
|
+
})
|
|
661
|
+
const body = await response.json().catch(() => ({}))
|
|
662
|
+
if (!response.ok) {
|
|
663
|
+
throw new Error(body.error?.message || body.message || `Anthropic provider failed with HTTP ${response.status}`)
|
|
664
|
+
}
|
|
665
|
+
return (body.content || []).map((part) => part.type === 'text' ? part.text : '').join('\n')
|
|
666
|
+
}
|
|
667
|
+
|
|
668
|
+
async function runLocalProvider(provider, prompt, options) {
|
|
669
|
+
if (provider === 'mock') {
|
|
670
|
+
return JSON.stringify({
|
|
671
|
+
files: [
|
|
672
|
+
{
|
|
673
|
+
path: 'README.md',
|
|
674
|
+
title: 'Local CodeSummary Documentation',
|
|
675
|
+
content: [
|
|
676
|
+
'# Local CodeSummary Documentation',
|
|
677
|
+
'',
|
|
678
|
+
'This mock documentation was generated without calling an inference provider.',
|
|
679
|
+
'',
|
|
680
|
+
'Use this mode to verify local packaging, schema validation, and file writing.',
|
|
681
|
+
].join('\n'),
|
|
682
|
+
},
|
|
683
|
+
],
|
|
684
|
+
})
|
|
685
|
+
}
|
|
686
|
+
|
|
687
|
+
if (provider === 'codex') {
|
|
688
|
+
return runCodexProvider(prompt, options)
|
|
689
|
+
}
|
|
690
|
+
|
|
691
|
+
if (provider === 'openai') {
|
|
692
|
+
const envName = String(options['api-key-env'] || 'OPENAI_API_KEY')
|
|
693
|
+
const apiKey = process.env[envName]
|
|
694
|
+
if (!apiKey) throw new Error(`Missing ${envName}. The key stays local; export it before running generate.`)
|
|
695
|
+
const apiUrl = String(options['api-url'] || 'https://api.openai.com/v1/chat/completions')
|
|
696
|
+
const model = String(options.model || 'gpt-4.1')
|
|
697
|
+
return callOpenAiCompatible(apiUrl, apiKey, model, prompt)
|
|
698
|
+
}
|
|
699
|
+
|
|
700
|
+
if (provider === 'anthropic') {
|
|
701
|
+
const envName = String(options['api-key-env'] || 'ANTHROPIC_API_KEY')
|
|
702
|
+
const apiKey = process.env[envName]
|
|
703
|
+
if (!apiKey) throw new Error(`Missing ${envName}. The key stays local; export it before running generate.`)
|
|
704
|
+
const apiUrl = String(options['api-url'] || 'https://api.anthropic.com/v1/messages')
|
|
705
|
+
const model = String(options.model || 'claude-sonnet-4-20250514')
|
|
706
|
+
return callAnthropic(apiUrl, apiKey, model, prompt)
|
|
707
|
+
}
|
|
708
|
+
|
|
709
|
+
throw new Error('Invalid provider. Use --provider codex, openai, anthropic, or mock.')
|
|
710
|
+
}
|
|
711
|
+
|
|
712
|
+
async function generateLocal(options) {
|
|
713
|
+
const root = resolve(options.root || process.cwd())
|
|
714
|
+
const config = readConfig(root)
|
|
715
|
+
const localConfig = localGenerationConfig(config)
|
|
716
|
+
const outputDir = resolveOutputDir(root, options.output || localConfig.output || 'docs')
|
|
717
|
+
const provider = normalizeProvider(options.provider || localConfig.provider || 'codex')
|
|
718
|
+
const providerOptions = {
|
|
719
|
+
...options,
|
|
720
|
+
model: options.model || localConfig.model,
|
|
721
|
+
'api-key-env': options['api-key-env'] || localConfig.apiKeyEnv,
|
|
722
|
+
}
|
|
723
|
+
const manifest = await writeManifest(root, { excludePaths: [outputDir] })
|
|
724
|
+
const context = buildLocalContext(root, manifest, { ...providerOptions, outputDir })
|
|
725
|
+
|
|
726
|
+
if (options['dry-run']) {
|
|
727
|
+
console.log(JSON.stringify({
|
|
728
|
+
provider,
|
|
729
|
+
root,
|
|
730
|
+
output: outputDir,
|
|
731
|
+
manifestFiles: manifest.files.length,
|
|
732
|
+
selectedFiles: context.selected.map((file) => ({
|
|
733
|
+
path: file.path,
|
|
734
|
+
bytes: file.bytes,
|
|
735
|
+
includedBytes: file.includedBytes,
|
|
736
|
+
})),
|
|
737
|
+
promptBytes: Buffer.byteLength(context.prompt),
|
|
738
|
+
sendsSourceToCodeSummary: false,
|
|
739
|
+
sendsProviderKeyToCodeSummary: false,
|
|
740
|
+
}, null, 2))
|
|
741
|
+
return
|
|
742
|
+
}
|
|
743
|
+
|
|
744
|
+
const raw = await runLocalProvider(provider, context.prompt, providerOptions)
|
|
745
|
+
const generated = validateGeneratedDocs(extractJsonObject(raw))
|
|
746
|
+
writeGeneratedDocs(outputDir, generated)
|
|
747
|
+
writeConfig({ ...config, lastLocalGenerate: { provider, output: outputDir, generatedAt: new Date().toISOString() } }, root)
|
|
748
|
+
console.log(JSON.stringify({
|
|
749
|
+
provider,
|
|
750
|
+
output: outputDir,
|
|
751
|
+
files: generated.files.map((file) => file.path),
|
|
752
|
+
sendsSourceToCodeSummary: false,
|
|
753
|
+
sendsProviderKeyToCodeSummary: false,
|
|
754
|
+
}, null, 2))
|
|
755
|
+
}
|
|
756
|
+
|
|
757
|
+
async function exportBundle(options) {
|
|
758
|
+
const root = resolve(options.root || process.cwd())
|
|
759
|
+
const output = options.output || join(root, CONFIG_DIR, 'export')
|
|
760
|
+
if (typeof output !== 'string') {
|
|
761
|
+
throw new Error('export requires --output <dir|s3://bucket/prefix> when --output is provided')
|
|
762
|
+
}
|
|
763
|
+
const includeArchive = options['include-archive'] === true
|
|
764
|
+
const manifest = await writeManifest(root)
|
|
765
|
+
const isS3Output = typeof output === 'string' && output.startsWith('s3://')
|
|
766
|
+
const targetDir = isS3Output ? mkdtempSync(join(tmpdir(), 'codesummary-export-')) : resolve(output)
|
|
767
|
+
let archiveCleanup = null
|
|
768
|
+
let archive = null
|
|
769
|
+
|
|
770
|
+
try {
|
|
771
|
+
if (includeArchive) {
|
|
772
|
+
const zipped = createZip(root, { excludePaths: [targetDir] })
|
|
773
|
+
archive = zipped.archive
|
|
774
|
+
archiveCleanup = zipped.cleanup
|
|
775
|
+
}
|
|
776
|
+
const metadata = writeExportFiles(root, targetDir, manifest, { includeArchive })
|
|
777
|
+
if (includeArchive) {
|
|
778
|
+
copyFileSync(archive, join(targetDir, 'workspace.zip'))
|
|
779
|
+
}
|
|
780
|
+
|
|
781
|
+
if (isS3Output) {
|
|
782
|
+
syncDirectoryToS3(targetDir, output)
|
|
783
|
+
console.log(JSON.stringify({ output, ...metadata }, null, 2))
|
|
784
|
+
} else {
|
|
785
|
+
console.log(JSON.stringify({ output: targetDir, ...metadata }, null, 2))
|
|
786
|
+
}
|
|
787
|
+
} finally {
|
|
788
|
+
if (archiveCleanup) archiveCleanup()
|
|
789
|
+
if (isS3Output) rmSync(targetDir, { recursive: true, force: true })
|
|
790
|
+
}
|
|
791
|
+
}
|
|
792
|
+
|
|
793
|
+
function resolveApiUrl(options, config) {
|
|
794
|
+
return (options['api-url'] || process.env.CODESUMMARY_API_URL || config.apiUrl || DEFAULT_API_URL).replace(/\/$/, '')
|
|
795
|
+
}
|
|
796
|
+
|
|
797
|
+
function base64Url(buffer) {
|
|
798
|
+
return Buffer.from(buffer).toString('base64').replace(/=/g, '').replace(/\+/g, '-').replace(/\//g, '_')
|
|
799
|
+
}
|
|
800
|
+
|
|
801
|
+
function createPkcePair() {
|
|
802
|
+
const verifier = base64Url(randomBytes(32))
|
|
803
|
+
const challenge = base64Url(createHash('sha256').update(verifier).digest())
|
|
804
|
+
return { verifier, challenge }
|
|
805
|
+
}
|
|
806
|
+
|
|
807
|
+
function tokenExpiryFromResponse(body) {
|
|
808
|
+
const expiresIn = Number(body.expires_in || 3600)
|
|
809
|
+
return new Date(Date.now() + Math.max(60, expiresIn - 60) * 1000).toISOString()
|
|
810
|
+
}
|
|
811
|
+
|
|
812
|
+
function oauthTokenIsFresh(oauth) {
|
|
813
|
+
if (!oauth?.accessToken || !oauth.expiresAt) return false
|
|
814
|
+
return new Date(oauth.expiresAt).getTime() > Date.now() + 30 * 1000
|
|
815
|
+
}
|
|
816
|
+
|
|
817
|
+
function oauthSessionKey(apiUrl, resource) {
|
|
818
|
+
return `${apiUrl}|${resource || 'default'}`
|
|
819
|
+
}
|
|
820
|
+
|
|
821
|
+
function resolveStoredOAuth(config) {
|
|
822
|
+
const auth = readAuth()
|
|
823
|
+
const key = config.oauthKey || auth.defaultKey
|
|
824
|
+
if (key && auth.sessions?.[key]) {
|
|
825
|
+
return { auth, key, session: auth.sessions[key], source: 'global' }
|
|
826
|
+
}
|
|
827
|
+
if (config.oauth?.accessToken) {
|
|
828
|
+
return { auth, key: null, session: config.oauth, source: 'workspace' }
|
|
829
|
+
}
|
|
830
|
+
return { auth, key: null, session: null, source: null }
|
|
831
|
+
}
|
|
832
|
+
|
|
833
|
+
async function refreshOAuthToken(apiUrl, session, storage, config, root) {
|
|
834
|
+
const refreshToken = session?.refreshToken
|
|
835
|
+
if (!refreshToken) {
|
|
836
|
+
throw new Error('OAuth session expired. Run `codesummary login` again.')
|
|
837
|
+
}
|
|
838
|
+
|
|
839
|
+
const params = new URLSearchParams()
|
|
840
|
+
params.set('grant_type', 'refresh_token')
|
|
841
|
+
params.set('refresh_token', refreshToken)
|
|
842
|
+
|
|
843
|
+
const tokenUrl = `${apiUrl}/api/oauth/token`
|
|
844
|
+
if (process.env.CODESUMMARY_DEBUG) console.error(`[debug] POST ${tokenUrl} grant_type=refresh_token`)
|
|
845
|
+
const response = await fetch(tokenUrl, {
|
|
846
|
+
method: 'POST',
|
|
847
|
+
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
|
848
|
+
body: params.toString(),
|
|
849
|
+
})
|
|
850
|
+
const body = await response.json().catch(() => ({}))
|
|
851
|
+
if (process.env.CODESUMMARY_DEBUG) console.error(`[debug] oauth refresh status=${response.status} body=${JSON.stringify(body).slice(0, 500)}`)
|
|
852
|
+
if (!response.ok) {
|
|
853
|
+
throw new Error(body.error_description || body.message || `OAuth refresh failed with HTTP ${response.status}. Run \`codesummary login\` again.`)
|
|
854
|
+
}
|
|
855
|
+
|
|
856
|
+
const nextSession = {
|
|
857
|
+
...session,
|
|
858
|
+
accessToken: body.access_token,
|
|
859
|
+
refreshToken: body.refresh_token || refreshToken,
|
|
860
|
+
expiresAt: tokenExpiryFromResponse(body),
|
|
861
|
+
scope: body.scope || session?.scope,
|
|
862
|
+
}
|
|
863
|
+
|
|
864
|
+
if (storage.source === 'global' && storage.key) {
|
|
865
|
+
const auth = {
|
|
866
|
+
...storage.auth,
|
|
867
|
+
sessions: {
|
|
868
|
+
...(storage.auth.sessions || {}),
|
|
869
|
+
[storage.key]: nextSession,
|
|
870
|
+
},
|
|
871
|
+
defaultKey: storage.auth.defaultKey || storage.key,
|
|
872
|
+
}
|
|
873
|
+
writeAuth(auth)
|
|
874
|
+
return { token: nextSession.accessToken, config }
|
|
875
|
+
}
|
|
876
|
+
|
|
877
|
+
const nextConfig = { ...config, oauth: nextSession }
|
|
878
|
+
writeConfig(nextConfig, root)
|
|
879
|
+
return { token: nextSession.accessToken, config: nextConfig }
|
|
880
|
+
}
|
|
881
|
+
|
|
882
|
+
async function resolveAuth(options, config, root) {
|
|
883
|
+
const staticToken = options.token || options['site-token'] || process.env.CODESUMMARY_API_KEY || config.siteToken
|
|
884
|
+
if (staticToken) return { token: staticToken, config }
|
|
885
|
+
|
|
886
|
+
const apiUrl = resolveApiUrl(options, config)
|
|
887
|
+
const storage = resolveStoredOAuth(config)
|
|
888
|
+
if (oauthTokenIsFresh(storage.session)) {
|
|
889
|
+
return { token: storage.session.accessToken, config }
|
|
890
|
+
}
|
|
891
|
+
if (storage.session?.refreshToken) {
|
|
892
|
+
return refreshOAuthToken(apiUrl, storage.session, storage, config, root)
|
|
893
|
+
}
|
|
894
|
+
|
|
895
|
+
throw new Error('Missing token. Run `codesummary login`, `codesummary link --site-token <token>`, or set CODESUMMARY_API_KEY.')
|
|
896
|
+
}
|
|
897
|
+
|
|
898
|
+
function normalizeOAuthResource(value, options = {}) {
|
|
899
|
+
const raw = value || options.site || options.target
|
|
900
|
+
if (!raw) return ''
|
|
901
|
+
if (/^https?:\/\//i.test(raw)) return raw
|
|
902
|
+
const trimmed = String(raw).replace(/^\/+|\/+$/g, '')
|
|
903
|
+
const parts = trimmed.split('/')
|
|
904
|
+
if (parts.length !== 2 || !parts[0] || !parts[1]) {
|
|
905
|
+
throw new Error('OAuth resource must be <owner>/<site> or a full /mcp/<owner>/<site> URL.')
|
|
906
|
+
}
|
|
907
|
+
const mcpOrigin = String(options['mcp-origin'] || 'https://webhooks.codesummary.io').replace(/\/$/, '')
|
|
908
|
+
return `${mcpOrigin}/mcp/${encodeURIComponent(parts[0])}/${encodeURIComponent(parts[1])}`
|
|
909
|
+
}
|
|
910
|
+
|
|
911
|
+
async function registerOAuthClient(apiUrl, redirectUri) {
|
|
912
|
+
const response = await fetch(`${apiUrl}/api/oauth/register`, {
|
|
913
|
+
method: 'POST',
|
|
914
|
+
headers: { 'Content-Type': 'application/json' },
|
|
915
|
+
body: JSON.stringify({
|
|
916
|
+
client_name: 'CodeSummary CLI',
|
|
917
|
+
redirect_uris: [redirectUri],
|
|
918
|
+
grant_types: ['authorization_code', 'refresh_token'],
|
|
919
|
+
response_types: ['code'],
|
|
920
|
+
token_endpoint_auth_method: 'none',
|
|
921
|
+
}),
|
|
922
|
+
})
|
|
923
|
+
const body = await response.json().catch(() => ({}))
|
|
924
|
+
if (!response.ok) {
|
|
925
|
+
throw new Error(body.error_description || body.message || `OAuth client registration failed with HTTP ${response.status}`)
|
|
926
|
+
}
|
|
927
|
+
return body
|
|
928
|
+
}
|
|
929
|
+
|
|
930
|
+
function openBrowser(url) {
|
|
931
|
+
const command = process.platform === 'darwin' ? 'open' : process.platform === 'win32' ? 'cmd' : 'xdg-open'
|
|
932
|
+
const args = process.platform === 'win32' ? ['/c', 'start', '', url] : [url]
|
|
933
|
+
const result = spawnSync(command, args, { stdio: 'ignore' })
|
|
934
|
+
return result.status === 0
|
|
935
|
+
}
|
|
936
|
+
|
|
937
|
+
function closeServer(server) {
|
|
938
|
+
if (!server.listening) return
|
|
939
|
+
server.close()
|
|
940
|
+
}
|
|
941
|
+
|
|
942
|
+
function waitForOAuthCallback(server, expectedState) {
|
|
943
|
+
return new Promise((resolvePromise, reject) => {
|
|
944
|
+
const timeout = setTimeout(() => {
|
|
945
|
+
closeServer(server)
|
|
946
|
+
reject(new Error('Timed out waiting for OAuth callback.'))
|
|
947
|
+
}, 10 * 60 * 1000)
|
|
948
|
+
|
|
949
|
+
server.on('request', (req, res) => {
|
|
950
|
+
try {
|
|
951
|
+
const url = new URL(req.url || '/', 'http://127.0.0.1')
|
|
952
|
+
if (url.pathname !== '/callback') {
|
|
953
|
+
res.writeHead(404)
|
|
954
|
+
res.end('Not found')
|
|
955
|
+
return
|
|
956
|
+
}
|
|
957
|
+
const error = url.searchParams.get('error')
|
|
958
|
+
const code = url.searchParams.get('code')
|
|
959
|
+
const state = url.searchParams.get('state')
|
|
960
|
+
if (state !== expectedState) {
|
|
961
|
+
throw new Error('OAuth state mismatch.')
|
|
962
|
+
}
|
|
963
|
+
if (error) {
|
|
964
|
+
throw new Error(url.searchParams.get('error_description') || error)
|
|
965
|
+
}
|
|
966
|
+
if (!code) {
|
|
967
|
+
throw new Error('OAuth callback did not include a code.')
|
|
968
|
+
}
|
|
969
|
+
clearTimeout(timeout)
|
|
970
|
+
res.writeHead(200, { 'Content-Type': 'text/html' })
|
|
971
|
+
res.end('<!doctype html><title>CodeSummary CLI</title><body style="font-family: system-ui; padding: 2rem;">CodeSummary CLI is logged in. You can close this window.</body>')
|
|
972
|
+
closeServer(server)
|
|
973
|
+
resolvePromise(code)
|
|
974
|
+
} catch (error) {
|
|
975
|
+
clearTimeout(timeout)
|
|
976
|
+
res.writeHead(400, { 'Content-Type': 'text/plain' })
|
|
977
|
+
res.end(error instanceof Error ? error.message : String(error))
|
|
978
|
+
closeServer(server)
|
|
979
|
+
reject(error)
|
|
980
|
+
}
|
|
981
|
+
})
|
|
982
|
+
})
|
|
983
|
+
}
|
|
984
|
+
|
|
985
|
+
async function exchangeOAuthCode(apiUrl, clientId, redirectUri, code, verifier) {
|
|
986
|
+
const params = new URLSearchParams()
|
|
987
|
+
params.set('grant_type', 'authorization_code')
|
|
988
|
+
params.set('client_id', clientId)
|
|
989
|
+
params.set('redirect_uri', redirectUri)
|
|
990
|
+
params.set('code', code)
|
|
991
|
+
params.set('code_verifier', verifier)
|
|
992
|
+
|
|
993
|
+
const response = await fetch(`${apiUrl}/api/oauth/token`, {
|
|
994
|
+
method: 'POST',
|
|
995
|
+
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
|
996
|
+
body: params.toString(),
|
|
997
|
+
})
|
|
998
|
+
const body = await response.json().catch(() => ({}))
|
|
999
|
+
if (!response.ok) {
|
|
1000
|
+
throw new Error(body.error_description || body.message || `OAuth token exchange failed with HTTP ${response.status}`)
|
|
1001
|
+
}
|
|
1002
|
+
return body
|
|
1003
|
+
}
|
|
1004
|
+
|
|
1005
|
+
async function login(options) {
|
|
1006
|
+
const root = resolve(options.root || process.cwd())
|
|
1007
|
+
const existing = readConfig(root)
|
|
1008
|
+
const apiUrl = resolveApiUrl(options, existing)
|
|
1009
|
+
const resource = normalizeOAuthResource(options.resource, options)
|
|
1010
|
+
const scope = String(options.scope || 'write:knowledge')
|
|
1011
|
+
const server = createServer()
|
|
1012
|
+
|
|
1013
|
+
await new Promise((resolvePromise, reject) => {
|
|
1014
|
+
server.once('error', reject)
|
|
1015
|
+
server.listen(0, '127.0.0.1', resolvePromise)
|
|
1016
|
+
})
|
|
1017
|
+
|
|
1018
|
+
const address = server.address()
|
|
1019
|
+
const port = typeof address === 'object' && address ? address.port : null
|
|
1020
|
+
if (!port) throw new Error('Failed to allocate OAuth callback port.')
|
|
1021
|
+
try {
|
|
1022
|
+
const redirectUri = `http://127.0.0.1:${port}/callback`
|
|
1023
|
+
const client = await registerOAuthClient(apiUrl, redirectUri)
|
|
1024
|
+
const { verifier, challenge } = createPkcePair()
|
|
1025
|
+
const state = base64Url(randomBytes(18))
|
|
1026
|
+
|
|
1027
|
+
const authorizeUrl = new URL(`${apiUrl}/oauth/authorize`)
|
|
1028
|
+
authorizeUrl.searchParams.set('response_type', 'code')
|
|
1029
|
+
authorizeUrl.searchParams.set('client_id', client.client_id)
|
|
1030
|
+
authorizeUrl.searchParams.set('redirect_uri', redirectUri)
|
|
1031
|
+
authorizeUrl.searchParams.set('code_challenge', challenge)
|
|
1032
|
+
authorizeUrl.searchParams.set('code_challenge_method', 'S256')
|
|
1033
|
+
authorizeUrl.searchParams.set('state', state)
|
|
1034
|
+
authorizeUrl.searchParams.set('scope', scope)
|
|
1035
|
+
if (resource) authorizeUrl.searchParams.set('resource', resource)
|
|
1036
|
+
|
|
1037
|
+
console.error(`Opening browser for CodeSummary login: ${authorizeUrl.toString()}`)
|
|
1038
|
+
if (!openBrowser(authorizeUrl.toString())) {
|
|
1039
|
+
console.error(`Open this URL to continue:\n${authorizeUrl.toString()}`)
|
|
1040
|
+
}
|
|
1041
|
+
|
|
1042
|
+
const code = await waitForOAuthCallback(server, state)
|
|
1043
|
+
const tokenBody = await exchangeOAuthCode(apiUrl, client.client_id, redirectUri, code, verifier)
|
|
1044
|
+
const oauth = {
|
|
1045
|
+
clientId: client.client_id,
|
|
1046
|
+
accessToken: tokenBody.access_token,
|
|
1047
|
+
refreshToken: tokenBody.refresh_token,
|
|
1048
|
+
expiresAt: tokenExpiryFromResponse(tokenBody),
|
|
1049
|
+
scope: tokenBody.scope || scope,
|
|
1050
|
+
resource: resource || null,
|
|
1051
|
+
}
|
|
1052
|
+
const key = oauthSessionKey(apiUrl, resource)
|
|
1053
|
+
const auth = readAuth()
|
|
1054
|
+
writeAuth({
|
|
1055
|
+
...auth,
|
|
1056
|
+
sessions: {
|
|
1057
|
+
...(auth.sessions || {}),
|
|
1058
|
+
[key]: oauth,
|
|
1059
|
+
},
|
|
1060
|
+
defaultKey: key,
|
|
1061
|
+
})
|
|
1062
|
+
|
|
1063
|
+
const sourceName = options.name || existing.sourceName || basename(root)
|
|
1064
|
+
const next = {
|
|
1065
|
+
...existing,
|
|
1066
|
+
apiUrl,
|
|
1067
|
+
sourceName,
|
|
1068
|
+
oauthKey: key,
|
|
1069
|
+
oauthResource: resource || null,
|
|
1070
|
+
}
|
|
1071
|
+
delete next.oauth
|
|
1072
|
+
|
|
1073
|
+
let linked = null
|
|
1074
|
+
try {
|
|
1075
|
+
linked = await fetchLinkedSite(apiUrl, oauth.accessToken)
|
|
1076
|
+
next.site = linked.site || existing.site
|
|
1077
|
+
} catch {
|
|
1078
|
+
linked = null
|
|
1079
|
+
}
|
|
1080
|
+
|
|
1081
|
+
writeConfig(next, root)
|
|
1082
|
+
if (linked?.site) {
|
|
1083
|
+
console.log(`Logged in and linked ${sourceName} to ${linked.site.name} (${linked.site.id}) at ${apiUrl}`)
|
|
1084
|
+
} else {
|
|
1085
|
+
console.log(`Logged in to CodeSummary at ${apiUrl}`)
|
|
1086
|
+
}
|
|
1087
|
+
} catch (error) {
|
|
1088
|
+
closeServer(server)
|
|
1089
|
+
throw error
|
|
1090
|
+
}
|
|
1091
|
+
}
|
|
1092
|
+
|
|
1093
|
+
async function logout(options) {
|
|
1094
|
+
const root = resolve(options.root || process.cwd())
|
|
1095
|
+
const existing = readConfig(root)
|
|
1096
|
+
const auth = readAuth()
|
|
1097
|
+
const key = existing.oauthKey || auth.defaultKey
|
|
1098
|
+
if (key && auth.sessions?.[key]) {
|
|
1099
|
+
const sessions = { ...auth.sessions }
|
|
1100
|
+
delete sessions[key]
|
|
1101
|
+
writeAuth({
|
|
1102
|
+
...auth,
|
|
1103
|
+
sessions,
|
|
1104
|
+
defaultKey: auth.defaultKey === key ? null : auth.defaultKey,
|
|
1105
|
+
})
|
|
1106
|
+
}
|
|
1107
|
+
const next = { ...existing }
|
|
1108
|
+
delete next.oauth
|
|
1109
|
+
delete next.oauthKey
|
|
1110
|
+
delete next.oauthResource
|
|
1111
|
+
delete next.siteToken
|
|
1112
|
+
writeConfig(next, root)
|
|
1113
|
+
console.log('Logged out of CodeSummary CLI for this workspace.')
|
|
1114
|
+
}
|
|
1115
|
+
|
|
1116
|
+
async function whoami(options) {
|
|
1117
|
+
const root = resolve(options.root || process.cwd())
|
|
1118
|
+
const config = readConfig(root)
|
|
1119
|
+
const apiUrl = resolveApiUrl(options, config)
|
|
1120
|
+
const auth = await resolveAuth(options, config, root)
|
|
1121
|
+
const storage = resolveStoredOAuth(auth.config)
|
|
1122
|
+
let linked = null
|
|
1123
|
+
try {
|
|
1124
|
+
linked = await fetchLinkedSite(apiUrl, auth.token)
|
|
1125
|
+
} catch {
|
|
1126
|
+
linked = null
|
|
1127
|
+
}
|
|
1128
|
+
|
|
1129
|
+
console.log(JSON.stringify({
|
|
1130
|
+
apiUrl,
|
|
1131
|
+
workspace: root,
|
|
1132
|
+
sourceName: auth.config.sourceName || basename(root),
|
|
1133
|
+
auth: {
|
|
1134
|
+
type: storage.session ? 'oauth' : 'token',
|
|
1135
|
+
scope: storage.session?.scope || null,
|
|
1136
|
+
resource: storage.session?.resource || null,
|
|
1137
|
+
expiresAt: storage.session?.expiresAt || null,
|
|
1138
|
+
storage: storage.source || (auth.token ? 'environment-or-config-token' : null),
|
|
1139
|
+
},
|
|
1140
|
+
site: linked?.site || auth.config.site || null,
|
|
1141
|
+
account: linked?.account || null,
|
|
1142
|
+
}, null, 2))
|
|
1143
|
+
}
|
|
1144
|
+
|
|
1145
|
+
function terminalStatus(status) {
|
|
1146
|
+
return status === 'completed' || status === 'failed' || status === 'cancelled'
|
|
1147
|
+
}
|
|
1148
|
+
|
|
1149
|
+
function normalizeDepth(value) {
|
|
1150
|
+
if (!value) return 'standard'
|
|
1151
|
+
if (value === 'standard' || value === 'deep') return value
|
|
1152
|
+
throw new Error('Invalid depth. Use --depth standard or --depth deep.')
|
|
1153
|
+
}
|
|
1154
|
+
|
|
1155
|
+
function normalizeProvider(value) {
|
|
1156
|
+
const provider = String(value || 'codex').toLowerCase()
|
|
1157
|
+
if (['codex', 'openai', 'anthropic', 'mock'].includes(provider)) return provider
|
|
1158
|
+
throw new Error('Invalid provider. Use codex, openai, anthropic, or mock.')
|
|
1159
|
+
}
|
|
1160
|
+
|
|
1161
|
+
function localGenerationConfig(config) {
|
|
1162
|
+
return config.localGeneration || {}
|
|
1163
|
+
}
|
|
1164
|
+
|
|
1165
|
+
function resolveOutputDir(root, output = 'docs') {
|
|
1166
|
+
const value = String(output)
|
|
1167
|
+
return isAbsolute(value) ? resolve(value) : resolve(root, value)
|
|
1168
|
+
}
|
|
1169
|
+
|
|
1170
|
+
async function configure(options) {
|
|
1171
|
+
const root = resolve(options.root || process.cwd())
|
|
1172
|
+
const existing = readConfig(root)
|
|
1173
|
+
const current = localGenerationConfig(existing)
|
|
1174
|
+
const nextLocal = { ...current }
|
|
1175
|
+
|
|
1176
|
+
if (options.provider) nextLocal.provider = normalizeProvider(options.provider)
|
|
1177
|
+
if (options.output) nextLocal.output = String(options.output)
|
|
1178
|
+
if (options.model) nextLocal.model = String(options.model)
|
|
1179
|
+
if (options['api-key-env']) nextLocal.apiKeyEnv = String(options['api-key-env'])
|
|
1180
|
+
if (options.default) {
|
|
1181
|
+
throw new Error('Bare `codesummary` now starts the hosted interactive flow. Use `codesummary generate` for local inference.')
|
|
1182
|
+
}
|
|
1183
|
+
|
|
1184
|
+
const next = {
|
|
1185
|
+
...existing,
|
|
1186
|
+
localGeneration: nextLocal,
|
|
1187
|
+
}
|
|
1188
|
+
writeConfig(next, root)
|
|
1189
|
+
console.log(JSON.stringify({
|
|
1190
|
+
path: configPath(root),
|
|
1191
|
+
localGeneration: nextLocal,
|
|
1192
|
+
storesProviderKey: false,
|
|
1193
|
+
}, null, 2))
|
|
1194
|
+
}
|
|
1195
|
+
|
|
1196
|
+
async function configCommand(options) {
|
|
1197
|
+
const root = resolve(options.root || process.cwd())
|
|
1198
|
+
const [subcommand, key, ...valueParts] = options._
|
|
1199
|
+
const existing = readConfig(root)
|
|
1200
|
+
|
|
1201
|
+
if (!subcommand || subcommand === 'list') {
|
|
1202
|
+
console.log(JSON.stringify(existing, null, 2))
|
|
1203
|
+
return
|
|
1204
|
+
}
|
|
1205
|
+
|
|
1206
|
+
if (subcommand === 'get') {
|
|
1207
|
+
if (!key) {
|
|
1208
|
+
console.log(JSON.stringify(existing, null, 2))
|
|
1209
|
+
return
|
|
1210
|
+
}
|
|
1211
|
+
if (!configKeyAllowed(key)) throw new Error(`Unsupported config key: ${key}`)
|
|
1212
|
+
const value = readConfigValue(existing, key)
|
|
1213
|
+
if (value === undefined) {
|
|
1214
|
+
process.exitCode = 1
|
|
1215
|
+
return
|
|
1216
|
+
}
|
|
1217
|
+
if (typeof value === 'object') {
|
|
1218
|
+
console.log(JSON.stringify(value, null, 2))
|
|
1219
|
+
} else {
|
|
1220
|
+
console.log(String(value))
|
|
1221
|
+
}
|
|
1222
|
+
return
|
|
1223
|
+
}
|
|
1224
|
+
|
|
1225
|
+
if (subcommand === 'set') {
|
|
1226
|
+
if (!key || valueParts.length === 0) {
|
|
1227
|
+
throw new Error('Usage: codesummary config set <key> <value>')
|
|
1228
|
+
}
|
|
1229
|
+
let value = valueParts.join(' ')
|
|
1230
|
+
if (key === 'depth') value = normalizeDepth(value)
|
|
1231
|
+
if (key === 'localGeneration.provider') value = normalizeProvider(value)
|
|
1232
|
+
const next = writeConfigValue(existing, key, value)
|
|
1233
|
+
writeConfig(next, root)
|
|
1234
|
+
console.log(`Set ${key}.`)
|
|
1235
|
+
return
|
|
1236
|
+
}
|
|
1237
|
+
|
|
1238
|
+
if (subcommand === 'clear') {
|
|
1239
|
+
if (!key) throw new Error('Usage: codesummary config clear <key>')
|
|
1240
|
+
const next = clearConfigValue(existing, key)
|
|
1241
|
+
writeConfig(next, root)
|
|
1242
|
+
console.log(`Cleared ${key}.`)
|
|
1243
|
+
return
|
|
1244
|
+
}
|
|
1245
|
+
|
|
1246
|
+
throw new Error('Usage: codesummary config [list|get|set|clear]')
|
|
1247
|
+
}
|
|
1248
|
+
|
|
1249
|
+
async function newWorkspace(options) {
|
|
1250
|
+
const target = resolve(options._[0] || options.root || process.cwd())
|
|
1251
|
+
const sourceName = options.name || basename(target)
|
|
1252
|
+
const existingConfigPath = configPath(target)
|
|
1253
|
+
const targetExists = existsSync(target)
|
|
1254
|
+
const targetEntries = targetExists ? readdirSync(target) : []
|
|
1255
|
+
const isCurrentDirectory = target === resolve(process.cwd())
|
|
1256
|
+
|
|
1257
|
+
if (targetExists && targetEntries.length > 0 && !isCurrentDirectory && !options.force) {
|
|
1258
|
+
throw new Error(`Directory is not empty: ${target}. Pass --force to initialize it without deleting files.`)
|
|
1259
|
+
}
|
|
1260
|
+
|
|
1261
|
+
mkdirSync(target, { recursive: true })
|
|
1262
|
+
const existing = readConfig(target)
|
|
1263
|
+
const next = {
|
|
1264
|
+
...existing,
|
|
1265
|
+
apiUrl: resolveApiUrl(options, existing),
|
|
1266
|
+
sourceName,
|
|
1267
|
+
}
|
|
1268
|
+
if (options.provider || options.output || options.model || options['api-key-env']) {
|
|
1269
|
+
next.localGeneration = {
|
|
1270
|
+
...(existing.localGeneration || {}),
|
|
1271
|
+
...(options.provider ? { provider: normalizeProvider(options.provider) } : {}),
|
|
1272
|
+
...(options.output ? { output: String(options.output) } : {}),
|
|
1273
|
+
...(options.model ? { model: String(options.model) } : {}),
|
|
1274
|
+
...(options['api-key-env'] ? { apiKeyEnv: String(options['api-key-env']) } : {}),
|
|
1275
|
+
}
|
|
1276
|
+
}
|
|
1277
|
+
writeConfig(next, target)
|
|
1278
|
+
|
|
1279
|
+
const readmePath = join(target, 'README.md')
|
|
1280
|
+
if (!targetExists || targetEntries.length === 0) {
|
|
1281
|
+
writeFileSync(
|
|
1282
|
+
readmePath,
|
|
1283
|
+
[
|
|
1284
|
+
`# ${sourceName}`,
|
|
1285
|
+
'',
|
|
1286
|
+
'This workspace is ready for CodeSummary.',
|
|
1287
|
+
'',
|
|
1288
|
+
'Run `codesummary login`, then `codesummary` to create or update hosted knowledge.',
|
|
1289
|
+
'',
|
|
1290
|
+
].join('\n')
|
|
1291
|
+
)
|
|
1292
|
+
}
|
|
1293
|
+
|
|
1294
|
+
console.log('Initialized CodeSummary workspace.')
|
|
1295
|
+
console.log(` Path: ${target}`)
|
|
1296
|
+
console.log(` Config: ${existingConfigPath}`)
|
|
1297
|
+
console.log(` Source: ${sourceName}`)
|
|
1298
|
+
console.log(` API: ${next.apiUrl}`)
|
|
1299
|
+
console.log('')
|
|
1300
|
+
console.log('Next: run `codesummary login`, then `codesummary`.')
|
|
1301
|
+
}
|
|
1302
|
+
|
|
1303
|
+
function printVersion() {
|
|
1304
|
+
const pkg = packageInfo()
|
|
1305
|
+
console.log(`${pkg.name} ${pkg.version}`)
|
|
1306
|
+
}
|
|
1307
|
+
|
|
1308
|
+
function printUpdateInstructions() {
|
|
1309
|
+
const pkg = packageInfo()
|
|
1310
|
+
const installName = pkg.name === 'codesummary' ? 'codesummary' : pkg.name
|
|
1311
|
+
console.log(`Update with: npm install -g ${installName}@latest`)
|
|
1312
|
+
}
|
|
1313
|
+
|
|
1314
|
+
async function fetchLinkedSite(apiUrl, token) {
|
|
1315
|
+
const response = await fetch(`${apiUrl}/api/cli/site`, {
|
|
1316
|
+
headers: cliAuthHeaders(token),
|
|
1317
|
+
})
|
|
1318
|
+
const body = await response.json().catch(() => ({}))
|
|
1319
|
+
if (!response.ok) {
|
|
1320
|
+
throw new Error(body.message || body.error || `Link verification failed with HTTP ${response.status}`)
|
|
1321
|
+
}
|
|
1322
|
+
return body
|
|
1323
|
+
}
|
|
1324
|
+
|
|
1325
|
+
async function fetchJobStatus(apiUrl, token, jobId) {
|
|
1326
|
+
const response = await fetch(`${apiUrl}/api/cli/jobs/${jobId}`, {
|
|
1327
|
+
headers: cliAuthHeaders(token),
|
|
1328
|
+
})
|
|
1329
|
+
const body = await response.json().catch(() => ({}))
|
|
1330
|
+
if (!response.ok) {
|
|
1331
|
+
throw new Error(body.message || body.error || `Status failed with HTTP ${response.status}`)
|
|
1332
|
+
}
|
|
1333
|
+
return body
|
|
1334
|
+
}
|
|
1335
|
+
|
|
1336
|
+
function jobStatusTitle(status) {
|
|
1337
|
+
if (status === 'pending') return 'Knowledge generation is queued.'
|
|
1338
|
+
if (status === 'processing') return 'Knowledge generation is running.'
|
|
1339
|
+
if (status === 'completed') return 'Knowledge generation completed.'
|
|
1340
|
+
if (status === 'failed') return 'Knowledge generation failed.'
|
|
1341
|
+
if (status === 'cancelled') return 'Knowledge generation was cancelled.'
|
|
1342
|
+
if (status === 'skipped') return 'Knowledge generation was skipped.'
|
|
1343
|
+
return 'Knowledge generation status.'
|
|
1344
|
+
}
|
|
1345
|
+
|
|
1346
|
+
function formatElapsed(value) {
|
|
1347
|
+
if (!value) return null
|
|
1348
|
+
const ms = Date.now() - new Date(value).getTime()
|
|
1349
|
+
if (!Number.isFinite(ms) || ms < 0) return null
|
|
1350
|
+
const seconds = Math.floor(ms / 1000)
|
|
1351
|
+
if (seconds < 60) return `${seconds}s ago`
|
|
1352
|
+
const minutes = Math.floor(seconds / 60)
|
|
1353
|
+
if (minutes < 60) return `${minutes}m ago`
|
|
1354
|
+
const hours = Math.floor(minutes / 60)
|
|
1355
|
+
return `${hours}h ago`
|
|
1356
|
+
}
|
|
1357
|
+
|
|
1358
|
+
function siteDashboardUrl(apiUrl, site) {
|
|
1359
|
+
if (!apiUrl || !site?.id) return null
|
|
1360
|
+
return `${String(apiUrl).replace(/\/$/, '')}/dashboard/sites/${site.id}`
|
|
1361
|
+
}
|
|
1362
|
+
|
|
1363
|
+
function isStagingApiUrl(apiUrl) {
|
|
1364
|
+
return /(^|[./-])staging([./-]|$)/i.test(String(apiUrl || ''))
|
|
1365
|
+
}
|
|
1366
|
+
|
|
1367
|
+
function isZeroSpendJob(job) {
|
|
1368
|
+
if (!job || job.status !== 'completed') return false
|
|
1369
|
+
const cost = Number(job.cost_usd ?? 0)
|
|
1370
|
+
const credits = Number(job.credits_used ?? 0)
|
|
1371
|
+
const input = Number(job.input_tokens ?? 0)
|
|
1372
|
+
const output = Number(job.output_tokens ?? 0)
|
|
1373
|
+
return cost === 0 && credits === 0 && input === 0 && output === 0
|
|
1374
|
+
}
|
|
1375
|
+
|
|
1376
|
+
function printJobStatus(body, options = {}) {
|
|
1377
|
+
const job = body.job
|
|
1378
|
+
if (options.verbose) {
|
|
1379
|
+
console.log(JSON.stringify(body, null, 2))
|
|
1380
|
+
if (job?.status === 'failed' && job.error) {
|
|
1381
|
+
console.error(`Job failed: ${job.error}`)
|
|
1382
|
+
}
|
|
1383
|
+
return
|
|
1384
|
+
}
|
|
1385
|
+
|
|
1386
|
+
if (!job) {
|
|
1387
|
+
console.log('No knowledge generation job found.')
|
|
1388
|
+
return
|
|
1389
|
+
}
|
|
1390
|
+
|
|
1391
|
+
console.log(jobStatusTitle(job.status))
|
|
1392
|
+
if (body.source?.name) console.log(` Source: ${body.source.name}`)
|
|
1393
|
+
if (body.site?.name) console.log(` Site: ${body.site.name}`)
|
|
1394
|
+
const siteUrl = siteDashboardUrl(options.apiUrl, body.site)
|
|
1395
|
+
if (siteUrl) console.log(` Open: ${siteUrl}`)
|
|
1396
|
+
console.log(` Job: ${job.id}`)
|
|
1397
|
+
console.log(` Status: ${job.status || 'unknown'}`)
|
|
1398
|
+
const createdAgo = formatElapsed(job.created_at)
|
|
1399
|
+
if (createdAgo) console.log(` Created: ${createdAgo}`)
|
|
1400
|
+
if (job.progress_message) console.log(` Progress: ${job.progress_message}`)
|
|
1401
|
+
if (job.credits_used) console.log(` Credits used: ${job.credits_used}`)
|
|
1402
|
+
if (job?.status === 'failed' && job.error) {
|
|
1403
|
+
console.log(` Error: ${job.error}`)
|
|
1404
|
+
}
|
|
1405
|
+
if (isStagingApiUrl(options.apiUrl) && isZeroSpendJob(job)) {
|
|
1406
|
+
console.log(' Staging: completed without provider spend. This verifies the pipeline, not final documentation quality.')
|
|
1407
|
+
}
|
|
1408
|
+
if (body.siteJob?.id) {
|
|
1409
|
+
console.log('')
|
|
1410
|
+
console.log(`Site update: ${body.siteJob.status || 'unknown'}`)
|
|
1411
|
+
console.log(` Job: ${body.siteJob.id}`)
|
|
1412
|
+
if (body.siteJob.progress_message) console.log(` Progress: ${body.siteJob.progress_message}`)
|
|
1413
|
+
if (isStagingApiUrl(options.apiUrl) && isZeroSpendJob(body.siteJob)) {
|
|
1414
|
+
console.log(' Staging: site docs were produced by the no-spend test path.')
|
|
1415
|
+
}
|
|
1416
|
+
}
|
|
1417
|
+
}
|
|
1418
|
+
|
|
1419
|
+
function printSubmitSummary(output, options = {}) {
|
|
1420
|
+
const site = output.site
|
|
1421
|
+
const source = output.source
|
|
1422
|
+
const job = output.job
|
|
1423
|
+
const generate = options.generate !== false
|
|
1424
|
+
const siteUrl = siteDashboardUrl(options.apiUrl, site)
|
|
1425
|
+
|
|
1426
|
+
if (job?.id) {
|
|
1427
|
+
console.log('Knowledge generation started.')
|
|
1428
|
+
console.log(` Source: ${source?.name || 'workspace'}`)
|
|
1429
|
+
if (site?.name) console.log(` Site: ${site.name}`)
|
|
1430
|
+
if (siteUrl) console.log(` Open: ${siteUrl}`)
|
|
1431
|
+
console.log(` Job: ${job.id}`)
|
|
1432
|
+
console.log(` Status: ${job.status || 'pending'}`)
|
|
1433
|
+
console.log('')
|
|
1434
|
+
console.log('Run `codesummary status --wait` to follow progress.')
|
|
1435
|
+
return
|
|
1436
|
+
}
|
|
1437
|
+
|
|
1438
|
+
if (generate) {
|
|
1439
|
+
console.log('Source uploaded, but no generation job was created.')
|
|
1440
|
+
if (site?.name) console.log(` Site: ${site.name}`)
|
|
1441
|
+
if (siteUrl) console.log(` Open: ${siteUrl}`)
|
|
1442
|
+
console.log('Run `codesummary status` if you already have a job id, or try again.')
|
|
1443
|
+
return
|
|
1444
|
+
}
|
|
1445
|
+
|
|
1446
|
+
const retainedArchive = source?.uploadRetained !== false
|
|
1447
|
+
console.log(retainedArchive ? 'Source uploaded.' : 'Setup verified.')
|
|
1448
|
+
console.log(` Files: ${output.manifestFileCount ?? source?.manifest?.fileCount ?? 'unknown'}`)
|
|
1449
|
+
if (site?.name) console.log(` Site: ${site.name}`)
|
|
1450
|
+
if (siteUrl) console.log(` Open: ${siteUrl}`)
|
|
1451
|
+
if (!retainedArchive) console.log('No source archive was retained.')
|
|
1452
|
+
console.log('No generation job was started.')
|
|
1453
|
+
}
|
|
1454
|
+
|
|
1455
|
+
function maybePromoteCompletedGenerationBaseline(root, config, body) {
|
|
1456
|
+
const job = body?.job
|
|
1457
|
+
if (!job?.id || job.status !== 'completed') return false
|
|
1458
|
+
const lastSubmit = config.lastSubmit || null
|
|
1459
|
+
if (!lastSubmit?.generated || lastSubmit.jobId !== job.id) return false
|
|
1460
|
+
|
|
1461
|
+
const attemptPath = join(root, CONFIG_DIR, LAST_GENERATION_ATTEMPT_MANIFEST_FILE)
|
|
1462
|
+
if (!existsSync(attemptPath)) return false
|
|
1463
|
+
const manifest = readManifestFile(attemptPath)
|
|
1464
|
+
writeLastSubmittedManifest(root, manifest)
|
|
1465
|
+
return true
|
|
1466
|
+
}
|
|
1467
|
+
|
|
1468
|
+
async function waitForJob(apiUrl, token, jobId, options = {}) {
|
|
1469
|
+
const intervalMs = Number(options['poll-interval-ms'] || 5000)
|
|
1470
|
+
const timeoutMs = Number(options['timeout-ms'] || 20 * 60 * 1000)
|
|
1471
|
+
const started = Date.now()
|
|
1472
|
+
let lastStatus = ''
|
|
1473
|
+
let lastSiteStatus = ''
|
|
1474
|
+
let lastHeartbeat = 0
|
|
1475
|
+
|
|
1476
|
+
for (;;) {
|
|
1477
|
+
const body = await fetchJobStatus(apiUrl, token, jobId)
|
|
1478
|
+
const status = body.job?.status || 'unknown'
|
|
1479
|
+
const now = Date.now()
|
|
1480
|
+
if (status !== lastStatus || now - lastHeartbeat >= Math.max(intervalMs, 15000)) {
|
|
1481
|
+
const message = body.job?.progress_message ? ` - ${body.job.progress_message}` : ''
|
|
1482
|
+
const elapsed = Math.floor((now - started) / 1000)
|
|
1483
|
+
console.error(`Job ${jobId}: ${status}${message} (${elapsed}s elapsed)`)
|
|
1484
|
+
lastStatus = status
|
|
1485
|
+
lastHeartbeat = now
|
|
1486
|
+
}
|
|
1487
|
+
const siteJob = body.siteJob
|
|
1488
|
+
const siteStatus = siteJob?.status || ''
|
|
1489
|
+
if (siteJob?.id && siteStatus !== lastSiteStatus) {
|
|
1490
|
+
const message = siteJob.progress_message ? ` - ${siteJob.progress_message}` : ''
|
|
1491
|
+
console.error(`Site job ${siteJob.id}: ${siteStatus}${message}`)
|
|
1492
|
+
lastSiteStatus = siteStatus
|
|
1493
|
+
}
|
|
1494
|
+
if (terminalStatus(status)) {
|
|
1495
|
+
if (status === 'completed' && siteJob?.id && !terminalStatus(siteStatus)) {
|
|
1496
|
+
if (Date.now() - started > timeoutMs) {
|
|
1497
|
+
throw new Error(`Timed out waiting for site job ${siteJob.id}`)
|
|
1498
|
+
}
|
|
1499
|
+
await sleep(intervalMs)
|
|
1500
|
+
continue
|
|
1501
|
+
}
|
|
1502
|
+
printJobStatus(body, { ...options, apiUrl })
|
|
1503
|
+
if (status !== 'completed' || (siteJob?.id && siteStatus !== 'completed')) process.exitCode = 1
|
|
1504
|
+
return body
|
|
1505
|
+
}
|
|
1506
|
+
if (Date.now() - started > timeoutMs) {
|
|
1507
|
+
throw new Error(`Timed out waiting for job ${jobId}`)
|
|
1508
|
+
}
|
|
1509
|
+
await sleep(intervalMs)
|
|
1510
|
+
}
|
|
1511
|
+
}
|
|
1512
|
+
|
|
1513
|
+
async function submit(options) {
|
|
1514
|
+
const root = resolve(options.root || process.cwd())
|
|
1515
|
+
const config = readConfig(root)
|
|
1516
|
+
const auth = await resolveAuth(options, config, root)
|
|
1517
|
+
const token = auth.token
|
|
1518
|
+
const activeConfig = auth.config
|
|
1519
|
+
const apiUrl = resolveApiUrl(options, activeConfig)
|
|
1520
|
+
const name = options.name || activeConfig.sourceName || basename(root)
|
|
1521
|
+
const depth = normalizeDepth(options.depth || activeConfig.depth)
|
|
1522
|
+
const generate = options.generate !== false
|
|
1523
|
+
const site = options.site || options['site-id'] || activeConfig.site?.id || activeConfig.site?.slug || null
|
|
1524
|
+
|
|
1525
|
+
const manifest = await writeManifest(root)
|
|
1526
|
+
const { archive, cleanup } = createZip(root)
|
|
1527
|
+
try {
|
|
1528
|
+
const buffer = readFileSync(archive)
|
|
1529
|
+
const form = new FormData()
|
|
1530
|
+
form.set('name', name)
|
|
1531
|
+
form.set('sourceRoot', root)
|
|
1532
|
+
form.set('depth', depth)
|
|
1533
|
+
form.set('generate', String(generate))
|
|
1534
|
+
form.set('manifest', JSON.stringify(manifest))
|
|
1535
|
+
if (site) form.set('site', String(site))
|
|
1536
|
+
form.set('file', new Blob([buffer], { type: 'application/zip' }), `${name}.zip`)
|
|
1537
|
+
|
|
1538
|
+
const submitUrl = `${apiUrl}/api/cli/submit`
|
|
1539
|
+
if (process.env.CODESUMMARY_DEBUG) console.error(`[debug] POST ${submitUrl} generate=${generate} fileBytes=${buffer.length}`)
|
|
1540
|
+
const response = await fetch(submitUrl, {
|
|
1541
|
+
method: 'POST',
|
|
1542
|
+
headers: cliAuthHeaders(token),
|
|
1543
|
+
body: form,
|
|
1544
|
+
})
|
|
1545
|
+
const body = await response.json().catch(() => ({}))
|
|
1546
|
+
if (process.env.CODESUMMARY_DEBUG) console.error(`[debug] submit status=${response.status} body=${JSON.stringify(body).slice(0, 500)}`)
|
|
1547
|
+
if (!response.ok) {
|
|
1548
|
+
throw new Error(body.message || body.error || `Submit failed with HTTP ${response.status}`)
|
|
1549
|
+
}
|
|
1550
|
+
const output = { ...body, manifestFileCount: manifest.files.length }
|
|
1551
|
+
const nextConfig = {
|
|
1552
|
+
...activeConfig,
|
|
1553
|
+
apiUrl,
|
|
1554
|
+
sourceName: name,
|
|
1555
|
+
depth,
|
|
1556
|
+
lastJobId: body.job?.id || activeConfig.lastJobId,
|
|
1557
|
+
lastSourceId: body.source?.id || activeConfig.lastSourceId,
|
|
1558
|
+
lastSiteId: body.site?.id || activeConfig.lastSiteId,
|
|
1559
|
+
site: body.site || activeConfig.site,
|
|
1560
|
+
lastSubmit: {
|
|
1561
|
+
generated: Boolean(generate && body.job?.id),
|
|
1562
|
+
jobId: body.job?.id || null,
|
|
1563
|
+
sourceId: body.source?.id || null,
|
|
1564
|
+
siteId: body.site?.id || null,
|
|
1565
|
+
manifestFileCount: manifest.files.length,
|
|
1566
|
+
submittedAt: new Date().toISOString(),
|
|
1567
|
+
},
|
|
1568
|
+
}
|
|
1569
|
+
writeConfig(nextConfig, root)
|
|
1570
|
+
if (generate && body.job?.id) {
|
|
1571
|
+
writeLastGenerationAttemptManifest(root, manifest)
|
|
1572
|
+
} else if (!generate) {
|
|
1573
|
+
writeLastSourceCheckManifest(root, manifest)
|
|
1574
|
+
}
|
|
1575
|
+
if (options.verbose) {
|
|
1576
|
+
console.log(JSON.stringify(output, null, 2))
|
|
1577
|
+
} else if (!options.silent) {
|
|
1578
|
+
printSubmitSummary(output, { generate, apiUrl })
|
|
1579
|
+
}
|
|
1580
|
+
if (options.wait && body.job?.id) {
|
|
1581
|
+
const completed = await waitForJob(apiUrl, token, body.job.id, options)
|
|
1582
|
+
if (completed.job?.status === 'completed') {
|
|
1583
|
+
writeLastSubmittedManifest(root, manifest)
|
|
1584
|
+
}
|
|
1585
|
+
}
|
|
1586
|
+
return output
|
|
1587
|
+
} finally {
|
|
1588
|
+
cleanup()
|
|
1589
|
+
}
|
|
1590
|
+
}
|
|
1591
|
+
|
|
1592
|
+
async function link(options) {
|
|
1593
|
+
const root = resolve(options.root || process.cwd())
|
|
1594
|
+
const existing = readConfig(root)
|
|
1595
|
+
const apiUrl = resolveApiUrl(options, existing)
|
|
1596
|
+
const siteToken = options['site-token'] || options.token || process.env.CODESUMMARY_API_KEY || existing.siteToken
|
|
1597
|
+
if (!siteToken) {
|
|
1598
|
+
throw new Error('link requires --site-token <token>')
|
|
1599
|
+
}
|
|
1600
|
+
|
|
1601
|
+
const linked = options.verify === false ? null : await fetchLinkedSite(apiUrl, siteToken)
|
|
1602
|
+
const sourceName = options.name || existing.sourceName || basename(root)
|
|
1603
|
+
const next = {
|
|
1604
|
+
...existing,
|
|
1605
|
+
apiUrl,
|
|
1606
|
+
sourceName,
|
|
1607
|
+
siteToken,
|
|
1608
|
+
site: linked?.site || existing.site,
|
|
1609
|
+
}
|
|
1610
|
+
writeConfig(next, root)
|
|
1611
|
+
|
|
1612
|
+
if (linked?.site) {
|
|
1613
|
+
console.log(`Linked ${sourceName} to ${linked.site.name} (${linked.site.id}) at ${apiUrl}`)
|
|
1614
|
+
} else {
|
|
1615
|
+
console.log(`Linked ${sourceName} to ${apiUrl}`)
|
|
1616
|
+
}
|
|
1617
|
+
}
|
|
1618
|
+
|
|
1619
|
+
async function status(options) {
|
|
1620
|
+
const root = resolve(options.root || process.cwd())
|
|
1621
|
+
const config = readConfig(root)
|
|
1622
|
+
const auth = await resolveAuth(options, config, root)
|
|
1623
|
+
const token = auth.token
|
|
1624
|
+
const activeConfig = auth.config
|
|
1625
|
+
const apiUrl = resolveApiUrl(options, activeConfig)
|
|
1626
|
+
const jobId = options['job-id'] || activeConfig.lastJobId
|
|
1627
|
+
if (!jobId) {
|
|
1628
|
+
throw new Error('Missing job id. Pass --job-id <id> or run submit first.')
|
|
1629
|
+
}
|
|
1630
|
+
|
|
1631
|
+
if (options.wait) {
|
|
1632
|
+
const body = await waitForJob(apiUrl, token, jobId, options)
|
|
1633
|
+
maybePromoteCompletedGenerationBaseline(root, config, body)
|
|
1634
|
+
return
|
|
1635
|
+
}
|
|
1636
|
+
|
|
1637
|
+
const body = await fetchJobStatus(apiUrl, token, jobId)
|
|
1638
|
+
maybePromoteCompletedGenerationBaseline(root, config, body)
|
|
1639
|
+
printJobStatus(body, { ...options, apiUrl })
|
|
1640
|
+
}
|
|
1641
|
+
|
|
1642
|
+
async function diff(options) {
|
|
1643
|
+
const root = resolve(options.root || process.cwd())
|
|
1644
|
+
const baselinePath = resolve(
|
|
1645
|
+
options.against || join(root, CONFIG_DIR, LAST_SUBMITTED_MANIFEST_FILE)
|
|
1646
|
+
)
|
|
1647
|
+
if (!existsSync(baselinePath)) {
|
|
1648
|
+
throw new Error(
|
|
1649
|
+
`Missing baseline manifest. Run submit first or pass --against <manifest.json>. Expected ${baselinePath}`
|
|
1650
|
+
)
|
|
1651
|
+
}
|
|
1652
|
+
|
|
1653
|
+
const previousManifest = readManifestFile(baselinePath)
|
|
1654
|
+
const currentManifest = await writeManifest(root)
|
|
1655
|
+
const summary = summarizeManifestDiff(previousManifest, currentManifest)
|
|
1656
|
+
console.log(
|
|
1657
|
+
JSON.stringify(
|
|
1658
|
+
{
|
|
1659
|
+
baseline: baselinePath,
|
|
1660
|
+
current: join(root, CONFIG_DIR, MANIFEST_FILE),
|
|
1661
|
+
counts: {
|
|
1662
|
+
added: summary.added.length,
|
|
1663
|
+
changed: summary.changed.length,
|
|
1664
|
+
removed: summary.removed.length,
|
|
1665
|
+
unchanged: summary.unchanged,
|
|
1666
|
+
},
|
|
1667
|
+
added: summary.added,
|
|
1668
|
+
changed: summary.changed,
|
|
1669
|
+
removed: summary.removed,
|
|
1670
|
+
},
|
|
1671
|
+
null,
|
|
1672
|
+
2
|
|
1673
|
+
)
|
|
1674
|
+
)
|
|
1675
|
+
}
|
|
1676
|
+
|
|
1677
|
+
function isInteractiveTerminal() {
|
|
1678
|
+
return Boolean(process.stdin.isTTY && process.stdout.isTTY)
|
|
1679
|
+
}
|
|
1680
|
+
|
|
1681
|
+
async function promptChoice(title, choices) {
|
|
1682
|
+
if (!isInteractiveTerminal()) {
|
|
1683
|
+
throw new Error('Interactive mode requires a terminal. Use an explicit command such as `codesummary submit`.')
|
|
1684
|
+
}
|
|
1685
|
+
|
|
1686
|
+
emitKeypressEvents(process.stdin)
|
|
1687
|
+
const wasRaw = process.stdin.isRaw
|
|
1688
|
+
if (process.stdin.setRawMode) process.stdin.setRawMode(true)
|
|
1689
|
+
|
|
1690
|
+
let selected = 0
|
|
1691
|
+
let renderedLines = 0
|
|
1692
|
+
const terminalWidth = () => Math.max(40, process.stdout.columns || 80)
|
|
1693
|
+
const padLine = (value) => {
|
|
1694
|
+
const visibleLength = value.replace(/\x1b\[[0-9;?]*[A-Za-z]/g, '').length
|
|
1695
|
+
return `${value}${' '.repeat(Math.max(0, terminalWidth() - visibleLength))}`
|
|
1696
|
+
}
|
|
1697
|
+
const render = () => {
|
|
1698
|
+
if (renderedLines > 0) {
|
|
1699
|
+
process.stdout.write(`\x1b[${renderedLines}A`)
|
|
1700
|
+
process.stdout.write('\x1b[0J')
|
|
1701
|
+
}
|
|
1702
|
+
const lines = [
|
|
1703
|
+
title,
|
|
1704
|
+
...choices.flatMap((choice, index) => {
|
|
1705
|
+
const isSelected = index === selected
|
|
1706
|
+
const marker = isSelected ? '›' : ' '
|
|
1707
|
+
const labelText = `${marker} ${choice.label}`
|
|
1708
|
+
const label = isSelected
|
|
1709
|
+
? `${ANSI.inverse}${ANSI.bold}${padLine(labelText)}${ANSI.reset}`
|
|
1710
|
+
: ` ${choice.label}`
|
|
1711
|
+
if (!choice.hint) return [label]
|
|
1712
|
+
const hint = isSelected
|
|
1713
|
+
? `${ANSI.inverse}${padLine(` ${choice.hint}`)}${ANSI.reset}`
|
|
1714
|
+
: `${ANSI.dim} ${choice.hint}${ANSI.reset}`
|
|
1715
|
+
return [label, hint]
|
|
1716
|
+
}),
|
|
1717
|
+
'',
|
|
1718
|
+
`${ANSI.dim}Use ↑/↓ to move, Enter to select, Ctrl+C to cancel.${ANSI.reset}`,
|
|
1719
|
+
]
|
|
1720
|
+
renderedLines = lines.length
|
|
1721
|
+
process.stdout.write(`${lines.join('\n')}\n`)
|
|
1722
|
+
}
|
|
1723
|
+
|
|
1724
|
+
return new Promise((resolvePromise, reject) => {
|
|
1725
|
+
const cleanup = () => {
|
|
1726
|
+
process.stdin.off('keypress', onKeypress)
|
|
1727
|
+
if (process.stdin.setRawMode) process.stdin.setRawMode(Boolean(wasRaw))
|
|
1728
|
+
process.stdout.write('\x1b[?25h')
|
|
1729
|
+
}
|
|
1730
|
+
const onKeypress = (_str, key = {}) => {
|
|
1731
|
+
if (key.ctrl && key.name === 'c') {
|
|
1732
|
+
cleanup()
|
|
1733
|
+
process.stdout.write('\n')
|
|
1734
|
+
reject(new Error('Cancelled.'))
|
|
1735
|
+
return
|
|
1736
|
+
}
|
|
1737
|
+
if (key.name === 'up' || key.name === 'k') {
|
|
1738
|
+
selected = (selected - 1 + choices.length) % choices.length
|
|
1739
|
+
render()
|
|
1740
|
+
return
|
|
1741
|
+
}
|
|
1742
|
+
if (key.name === 'down' || key.name === 'j') {
|
|
1743
|
+
selected = (selected + 1) % choices.length
|
|
1744
|
+
render()
|
|
1745
|
+
return
|
|
1746
|
+
}
|
|
1747
|
+
if (key.name === 'return' || key.name === 'enter') {
|
|
1748
|
+
const choice = choices[selected]
|
|
1749
|
+
cleanup()
|
|
1750
|
+
process.stdout.write('\n')
|
|
1751
|
+
resolvePromise(choice)
|
|
1752
|
+
}
|
|
1753
|
+
}
|
|
1754
|
+
|
|
1755
|
+
process.stdout.write('\x1b[?25l')
|
|
1756
|
+
render()
|
|
1757
|
+
process.stdin.on('keypress', onKeypress)
|
|
1758
|
+
})
|
|
1759
|
+
}
|
|
1760
|
+
|
|
1761
|
+
function printInteractiveSummary({
|
|
1762
|
+
apiUrl,
|
|
1763
|
+
root,
|
|
1764
|
+
sourceName,
|
|
1765
|
+
site,
|
|
1766
|
+
hasGeneratedBaseline,
|
|
1767
|
+
generatedDiffSummary,
|
|
1768
|
+
hasSourceCheckBaseline,
|
|
1769
|
+
sourceCheckDiffSummary,
|
|
1770
|
+
observedLastJob,
|
|
1771
|
+
}) {
|
|
1772
|
+
const activeDiff = generatedDiffSummary || sourceCheckDiffSummary
|
|
1773
|
+
const changed = activeDiff
|
|
1774
|
+
? activeDiff.added.length + activeDiff.changed.length + activeDiff.removed.length
|
|
1775
|
+
: null
|
|
1776
|
+
console.log('CodeSummary')
|
|
1777
|
+
console.log(` API: ${apiUrl}`)
|
|
1778
|
+
console.log(` Workspace: ${root}`)
|
|
1779
|
+
console.log(` Source: ${sourceName}`)
|
|
1780
|
+
console.log(` Site: ${site ? `${site.name} (${site.id})` : 'none yet; CodeSummary can create one'}`)
|
|
1781
|
+
if (!hasGeneratedBaseline && observedLastJob?.status === 'pending') {
|
|
1782
|
+
console.log(` State: generation queued; job ${observedLastJob.id}`)
|
|
1783
|
+
} else if (!hasGeneratedBaseline && observedLastJob?.status === 'processing') {
|
|
1784
|
+
console.log(` State: generation running; job ${observedLastJob.id}`)
|
|
1785
|
+
} else if (!hasGeneratedBaseline && observedLastJob?.status === 'failed') {
|
|
1786
|
+
console.log(` State: last generation failed; job ${observedLastJob.id}`)
|
|
1787
|
+
if (observedLastJob.error) console.log(` Error: ${observedLastJob.error}`)
|
|
1788
|
+
} else if (!hasGeneratedBaseline && !hasSourceCheckBaseline) {
|
|
1789
|
+
console.log(' State: fresh workspace; no previous CodeSummary submit baseline')
|
|
1790
|
+
} else if (!hasGeneratedBaseline) {
|
|
1791
|
+
if (changed === 0) {
|
|
1792
|
+
console.log(` State: setup checked; knowledge has not been generated yet (${activeDiff.unchanged} files unchanged since setup check)`)
|
|
1793
|
+
} else {
|
|
1794
|
+
console.log(
|
|
1795
|
+
` State: setup checked, but ${changed} local change${changed === 1 ? '' : 's'} exist before first generation ` +
|
|
1796
|
+
`(${activeDiff.added.length} added, ${activeDiff.changed.length} changed, ${activeDiff.removed.length} removed)`
|
|
1797
|
+
)
|
|
1798
|
+
}
|
|
1799
|
+
} else if (changed === 0) {
|
|
1800
|
+
console.log(` State: up to date locally; ${generatedDiffSummary.unchanged} files unchanged since last generated knowledge`)
|
|
1801
|
+
} else {
|
|
1802
|
+
console.log(
|
|
1803
|
+
` State: ${changed} local change${changed === 1 ? '' : 's'} since last generated knowledge ` +
|
|
1804
|
+
`(${generatedDiffSummary.added.length} added, ${generatedDiffSummary.changed.length} changed, ${generatedDiffSummary.removed.length} removed)`
|
|
1805
|
+
)
|
|
1806
|
+
}
|
|
1807
|
+
console.log('')
|
|
1808
|
+
}
|
|
1809
|
+
|
|
1810
|
+
async function ensureInteractiveAuth(options, root, config) {
|
|
1811
|
+
try {
|
|
1812
|
+
return await resolveAuth(options, config, root)
|
|
1813
|
+
} catch (error) {
|
|
1814
|
+
const choice = await promptChoice(
|
|
1815
|
+
'You are not logged in to CodeSummary for this workspace.',
|
|
1816
|
+
[
|
|
1817
|
+
{
|
|
1818
|
+
label: 'Log in with CodeSummary OAuth',
|
|
1819
|
+
hint: 'Opens the browser and stores a refreshable CLI token.',
|
|
1820
|
+
},
|
|
1821
|
+
{ label: 'Exit' },
|
|
1822
|
+
]
|
|
1823
|
+
)
|
|
1824
|
+
if (choice.label === 'Exit') return null
|
|
1825
|
+
await login(options)
|
|
1826
|
+
return resolveAuth(options, readConfig(root), root)
|
|
1827
|
+
}
|
|
1828
|
+
}
|
|
1829
|
+
|
|
1830
|
+
async function interactive(options = {}) {
|
|
1831
|
+
const root = resolve(options.root || process.cwd())
|
|
1832
|
+
let config = readConfig(root)
|
|
1833
|
+
const apiUrl = resolveApiUrl(options, config)
|
|
1834
|
+
const auth = await ensureInteractiveAuth(options, root, config)
|
|
1835
|
+
if (!auth) return
|
|
1836
|
+
|
|
1837
|
+
config = auth.config
|
|
1838
|
+
const token = auth.token
|
|
1839
|
+
const sourceName = options.name || config.sourceName || basename(root)
|
|
1840
|
+
let linked = null
|
|
1841
|
+
try {
|
|
1842
|
+
linked = await fetchLinkedSite(apiUrl, token)
|
|
1843
|
+
if (linked?.site && !config.site) {
|
|
1844
|
+
config = { ...config, site: linked.site }
|
|
1845
|
+
writeConfig(config, root)
|
|
1846
|
+
}
|
|
1847
|
+
} catch {
|
|
1848
|
+
linked = null
|
|
1849
|
+
}
|
|
1850
|
+
const site = linked?.site || config.site || null
|
|
1851
|
+
const hasSite = Boolean(site)
|
|
1852
|
+
let observedLastJob = null
|
|
1853
|
+
if (config.lastSubmit?.generated && config.lastSubmit?.jobId) {
|
|
1854
|
+
try {
|
|
1855
|
+
const body = await fetchJobStatus(apiUrl, token, config.lastSubmit.jobId)
|
|
1856
|
+
observedLastJob = body.job || null
|
|
1857
|
+
if (maybePromoteCompletedGenerationBaseline(root, config, body)) {
|
|
1858
|
+
observedLastJob = null
|
|
1859
|
+
}
|
|
1860
|
+
} catch {
|
|
1861
|
+
observedLastJob = null
|
|
1862
|
+
}
|
|
1863
|
+
}
|
|
1864
|
+
|
|
1865
|
+
const baselinePath = join(root, CONFIG_DIR, LAST_SUBMITTED_MANIFEST_FILE)
|
|
1866
|
+
const sourceCheckPath = join(root, CONFIG_DIR, LAST_SOURCE_CHECK_MANIFEST_FILE)
|
|
1867
|
+
const hasGeneratedBaseline = existsSync(baselinePath)
|
|
1868
|
+
const hasSourceCheckBaseline = existsSync(sourceCheckPath)
|
|
1869
|
+
const currentManifest = await writeManifest(root)
|
|
1870
|
+
const generatedDiffSummary = hasGeneratedBaseline
|
|
1871
|
+
? summarizeManifestDiff(readManifestFile(baselinePath), currentManifest)
|
|
1872
|
+
: null
|
|
1873
|
+
const sourceCheckDiffSummary = !hasGeneratedBaseline && hasSourceCheckBaseline
|
|
1874
|
+
? summarizeManifestDiff(readManifestFile(sourceCheckPath), currentManifest)
|
|
1875
|
+
: null
|
|
1876
|
+
const activeDiffSummary = generatedDiffSummary || sourceCheckDiffSummary
|
|
1877
|
+
const changed = activeDiffSummary
|
|
1878
|
+
? activeDiffSummary.added.length + activeDiffSummary.changed.length + activeDiffSummary.removed.length
|
|
1879
|
+
: null
|
|
1880
|
+
|
|
1881
|
+
printInteractiveSummary({
|
|
1882
|
+
apiUrl,
|
|
1883
|
+
root,
|
|
1884
|
+
sourceName,
|
|
1885
|
+
site,
|
|
1886
|
+
hasGeneratedBaseline,
|
|
1887
|
+
generatedDiffSummary,
|
|
1888
|
+
hasSourceCheckBaseline,
|
|
1889
|
+
sourceCheckDiffSummary,
|
|
1890
|
+
observedLastJob,
|
|
1891
|
+
})
|
|
1892
|
+
|
|
1893
|
+
const commonSubmitOptions = {
|
|
1894
|
+
...options,
|
|
1895
|
+
root,
|
|
1896
|
+
name: sourceName,
|
|
1897
|
+
'api-url': apiUrl,
|
|
1898
|
+
}
|
|
1899
|
+
const runInteractiveSubmit = async (submitOptions) => {
|
|
1900
|
+
const output = await submit({ ...submitOptions, silent: true })
|
|
1901
|
+
printSubmitSummary(output, { generate: submitOptions.generate, apiUrl })
|
|
1902
|
+
}
|
|
1903
|
+
|
|
1904
|
+
let choices
|
|
1905
|
+
if (!hasGeneratedBaseline && (observedLastJob?.status === 'pending' || observedLastJob?.status === 'processing')) {
|
|
1906
|
+
choices = [
|
|
1907
|
+
{
|
|
1908
|
+
label: 'Wait for current generation',
|
|
1909
|
+
hint: 'Follows the job that is already queued or running.',
|
|
1910
|
+
run: () => status({ ...options, root, 'api-url': apiUrl, 'job-id': observedLastJob.id, wait: true }),
|
|
1911
|
+
},
|
|
1912
|
+
{
|
|
1913
|
+
label: 'Check status once',
|
|
1914
|
+
hint: 'Shows the current job state without starting another run.',
|
|
1915
|
+
run: () => status({ ...options, root, 'api-url': apiUrl, 'job-id': observedLastJob.id }),
|
|
1916
|
+
},
|
|
1917
|
+
{ label: 'Exit', run: async () => {} },
|
|
1918
|
+
]
|
|
1919
|
+
} else if (!hasGeneratedBaseline) {
|
|
1920
|
+
choices = [
|
|
1921
|
+
{
|
|
1922
|
+
label: observedLastJob?.status === 'failed'
|
|
1923
|
+
? 'Retry knowledge generation'
|
|
1924
|
+
: hasSite ? 'Generate first knowledge run' : 'Create a private docs site and generate knowledge',
|
|
1925
|
+
hint: hasSourceCheckBaseline
|
|
1926
|
+
? 'Uses the checked setup and starts the first hosted generation job.'
|
|
1927
|
+
: 'Uploads this workspace and starts a hosted generation job.',
|
|
1928
|
+
run: () => runInteractiveSubmit({ ...commonSubmitOptions, generate: true }),
|
|
1929
|
+
},
|
|
1930
|
+
{
|
|
1931
|
+
label: hasSite ? 'Advanced: verify setup only' : 'Advanced: create site without generation',
|
|
1932
|
+
hint: 'Checks OAuth, manifest, and source/site linking without retaining source or spending credits.',
|
|
1933
|
+
run: () => runInteractiveSubmit({ ...commonSubmitOptions, generate: false }),
|
|
1934
|
+
},
|
|
1935
|
+
{ label: 'Exit', run: async () => {} },
|
|
1936
|
+
]
|
|
1937
|
+
} else if (changed === 0 && hasSite) {
|
|
1938
|
+
choices = [
|
|
1939
|
+
{
|
|
1940
|
+
label: 'Refresh knowledge anyway',
|
|
1941
|
+
hint: 'Starts a hosted generation job even though the local manifest matches the last submit.',
|
|
1942
|
+
run: () => runInteractiveSubmit({ ...commonSubmitOptions, generate: true }),
|
|
1943
|
+
},
|
|
1944
|
+
{
|
|
1945
|
+
label: 'Advanced: verify setup only',
|
|
1946
|
+
hint: 'Checks the manifest and source/site link without retaining source or spending credits.',
|
|
1947
|
+
run: () => runInteractiveSubmit({ ...commonSubmitOptions, generate: false }),
|
|
1948
|
+
},
|
|
1949
|
+
{ label: 'Exit', run: async () => {} },
|
|
1950
|
+
]
|
|
1951
|
+
} else if (changed === 0) {
|
|
1952
|
+
choices = [
|
|
1953
|
+
{
|
|
1954
|
+
label: 'Create a private docs site and generate knowledge',
|
|
1955
|
+
hint: 'The source manifest matches the last upload, but no site is linked locally.',
|
|
1956
|
+
run: () => runInteractiveSubmit({ ...commonSubmitOptions, generate: true }),
|
|
1957
|
+
},
|
|
1958
|
+
{
|
|
1959
|
+
label: 'Advanced: create site without generation',
|
|
1960
|
+
hint: 'Checks source/site linking without retaining source or spending credits.',
|
|
1961
|
+
run: () => runInteractiveSubmit({ ...commonSubmitOptions, generate: false }),
|
|
1962
|
+
},
|
|
1963
|
+
{ label: 'Exit', run: async () => {} },
|
|
1964
|
+
]
|
|
1965
|
+
} else {
|
|
1966
|
+
choices = [
|
|
1967
|
+
{
|
|
1968
|
+
label: 'Update knowledge',
|
|
1969
|
+
hint: 'Uploads local changes and starts a hosted generation job.',
|
|
1970
|
+
run: () => runInteractiveSubmit({ ...commonSubmitOptions, generate: true }),
|
|
1971
|
+
},
|
|
1972
|
+
{
|
|
1973
|
+
label: 'Advanced: verify setup only',
|
|
1974
|
+
hint: 'Checks the manifest and source/site link without retaining source or spending credits.',
|
|
1975
|
+
run: () => runInteractiveSubmit({ ...commonSubmitOptions, generate: false }),
|
|
1976
|
+
},
|
|
1977
|
+
{
|
|
1978
|
+
label: 'Show detailed diff',
|
|
1979
|
+
hint: 'Prints added, changed, and removed files without uploading.',
|
|
1980
|
+
run: () => diff({ ...options, root }),
|
|
1981
|
+
},
|
|
1982
|
+
{ label: 'Exit', run: async () => {} },
|
|
1983
|
+
]
|
|
1984
|
+
}
|
|
1985
|
+
|
|
1986
|
+
const choice = await promptChoice('What do you want to do?', choices)
|
|
1987
|
+
await choice.run()
|
|
1988
|
+
}
|
|
1989
|
+
|
|
1990
|
+
async function main() {
|
|
1991
|
+
const rawArgs = process.argv.slice(2)
|
|
1992
|
+
if (rawArgs.length === 0) {
|
|
1993
|
+
if (isInteractiveTerminal()) {
|
|
1994
|
+
await interactive({})
|
|
1995
|
+
return
|
|
1996
|
+
}
|
|
1997
|
+
printHelp()
|
|
1998
|
+
return
|
|
1999
|
+
}
|
|
2000
|
+
if (rawArgs[0] === '--help' || rawArgs[0] === '-h' || rawArgs[0] === 'help') {
|
|
2001
|
+
printHelp()
|
|
2002
|
+
return
|
|
2003
|
+
}
|
|
2004
|
+
if (rawArgs[0] === '--version' || rawArgs[0] === '-v' || rawArgs[0] === 'version') {
|
|
2005
|
+
printVersion()
|
|
2006
|
+
return
|
|
2007
|
+
}
|
|
2008
|
+
const command = rawArgs[0] && !rawArgs[0].startsWith('--') ? rawArgs[0] : null
|
|
2009
|
+
const rest = command ? rawArgs.slice(1) : rawArgs
|
|
2010
|
+
const options = parseArgs(rest)
|
|
2011
|
+
|
|
2012
|
+
if (!command) {
|
|
2013
|
+
if (options.help || options.h) {
|
|
2014
|
+
printHelp()
|
|
2015
|
+
return
|
|
2016
|
+
}
|
|
2017
|
+
if (!isInteractiveTerminal()) {
|
|
2018
|
+
printHelp()
|
|
2019
|
+
return
|
|
2020
|
+
}
|
|
2021
|
+
await interactive(options)
|
|
2022
|
+
return
|
|
2023
|
+
}
|
|
2024
|
+
|
|
2025
|
+
if (command === 'new' || command === 'init') {
|
|
2026
|
+
await newWorkspace(options)
|
|
2027
|
+
return
|
|
2028
|
+
}
|
|
2029
|
+
|
|
2030
|
+
if (command === 'configure') {
|
|
2031
|
+
await configure(options)
|
|
2032
|
+
return
|
|
2033
|
+
}
|
|
2034
|
+
|
|
2035
|
+
if (command === 'config') {
|
|
2036
|
+
await configCommand(options)
|
|
2037
|
+
return
|
|
2038
|
+
}
|
|
2039
|
+
|
|
2040
|
+
if (command === 'update') {
|
|
2041
|
+
printUpdateInstructions()
|
|
2042
|
+
return
|
|
2043
|
+
}
|
|
2044
|
+
|
|
2045
|
+
if (command === 'link') {
|
|
2046
|
+
await link(options)
|
|
2047
|
+
return
|
|
2048
|
+
}
|
|
2049
|
+
|
|
2050
|
+
if (command === 'login') {
|
|
2051
|
+
await login(options)
|
|
2052
|
+
return
|
|
2053
|
+
}
|
|
2054
|
+
|
|
2055
|
+
if (command === 'whoami') {
|
|
2056
|
+
await whoami(options)
|
|
2057
|
+
return
|
|
2058
|
+
}
|
|
2059
|
+
|
|
2060
|
+
if (command === 'logout') {
|
|
2061
|
+
await logout(options)
|
|
2062
|
+
return
|
|
2063
|
+
}
|
|
2064
|
+
|
|
2065
|
+
if (command === 'manifest') {
|
|
2066
|
+
const root = resolve(options.root || process.cwd())
|
|
2067
|
+
const manifest = await writeManifest(root)
|
|
2068
|
+
console.log(JSON.stringify({ path: join(root, CONFIG_DIR, MANIFEST_FILE), files: manifest.files.length }, null, 2))
|
|
2069
|
+
return
|
|
2070
|
+
}
|
|
2071
|
+
|
|
2072
|
+
if (command === 'diff') {
|
|
2073
|
+
await diff(options)
|
|
2074
|
+
return
|
|
2075
|
+
}
|
|
2076
|
+
|
|
2077
|
+
if (command === 'export') {
|
|
2078
|
+
await exportBundle(options)
|
|
2079
|
+
return
|
|
2080
|
+
}
|
|
2081
|
+
|
|
2082
|
+
if (command === 'generate') {
|
|
2083
|
+
await generateLocal(options)
|
|
2084
|
+
return
|
|
2085
|
+
}
|
|
2086
|
+
|
|
2087
|
+
if (command === 'submit') {
|
|
2088
|
+
await submit(options)
|
|
2089
|
+
return
|
|
2090
|
+
}
|
|
2091
|
+
|
|
2092
|
+
if (command === 'status') {
|
|
2093
|
+
await status(options)
|
|
2094
|
+
return
|
|
2095
|
+
}
|
|
2096
|
+
|
|
2097
|
+
throw new Error(`Unknown command: ${command}`)
|
|
2098
|
+
}
|
|
2099
|
+
|
|
2100
|
+
main().catch((error) => {
|
|
2101
|
+
console.error(error instanceof Error ? error.message : String(error))
|
|
2102
|
+
process.exit(1)
|
|
2103
|
+
})
|