@eduardbar/drift 1.1.0 → 1.3.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/.github/workflows/publish-vscode.yml +3 -3
- package/.github/workflows/publish.yml +3 -3
- package/.github/workflows/review-pr.yml +153 -0
- package/AGENTS.md +6 -0
- package/README.md +192 -4
- package/ROADMAP.md +6 -5
- package/dist/analyzer.d.ts +2 -2
- package/dist/analyzer.js +420 -159
- package/dist/benchmark.d.ts +2 -0
- package/dist/benchmark.js +185 -0
- package/dist/cli.js +509 -23
- package/dist/diff.js +74 -10
- package/dist/git.js +12 -0
- package/dist/index.d.ts +5 -1
- package/dist/index.js +3 -0
- package/dist/map.d.ts +3 -2
- package/dist/map.js +98 -10
- package/dist/plugins.d.ts +2 -1
- package/dist/plugins.js +177 -28
- package/dist/printer.js +4 -0
- package/dist/review.js +2 -2
- package/dist/rules/comments.js +2 -2
- package/dist/rules/complexity.js +2 -7
- package/dist/rules/nesting.js +3 -13
- package/dist/rules/phase0-basic.js +10 -10
- package/dist/rules/shared.d.ts +2 -0
- package/dist/rules/shared.js +27 -3
- package/dist/saas.d.ts +219 -0
- package/dist/saas.js +762 -0
- package/dist/trust-kpi.d.ts +9 -0
- package/dist/trust-kpi.js +445 -0
- package/dist/trust.d.ts +65 -0
- package/dist/trust.js +571 -0
- package/dist/types.d.ts +160 -0
- package/docs/PRD.md +199 -172
- package/docs/plugin-contract.md +61 -0
- package/docs/trust-core-release-checklist.md +55 -0
- package/package.json +5 -3
- package/packages/vscode-drift/src/code-actions.ts +53 -0
- package/packages/vscode-drift/src/extension.ts +11 -0
- package/src/analyzer.ts +484 -155
- package/src/benchmark.ts +244 -0
- package/src/cli.ts +628 -36
- package/src/diff.ts +75 -10
- package/src/git.ts +16 -0
- package/src/index.ts +63 -0
- package/src/map.ts +112 -10
- package/src/plugins.ts +354 -26
- package/src/printer.ts +4 -0
- package/src/review.ts +2 -2
- package/src/rules/comments.ts +2 -2
- package/src/rules/complexity.ts +2 -7
- package/src/rules/nesting.ts +3 -13
- package/src/rules/phase0-basic.ts +11 -12
- package/src/rules/shared.ts +31 -3
- package/src/saas.ts +1031 -0
- package/src/trust-kpi.ts +518 -0
- package/src/trust.ts +774 -0
- package/src/types.ts +177 -0
- package/tests/diff.test.ts +124 -0
- package/tests/new-features.test.ts +98 -0
- package/tests/plugins.test.ts +219 -0
- package/tests/rules.test.ts +23 -1
- package/tests/saas-foundation.test.ts +464 -0
- package/tests/trust-kpi.test.ts +120 -0
- package/tests/trust.test.ts +584 -0
package/src/cli.ts
CHANGED
|
@@ -1,9 +1,11 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
// drift-ignore-file
|
|
3
3
|
import { Command } from 'commander'
|
|
4
|
-
import { writeFileSync } from 'node:fs'
|
|
5
|
-
import { resolve } from 'node:path'
|
|
4
|
+
import { readFileSync, writeFileSync } from 'node:fs'
|
|
5
|
+
import { basename, relative, resolve } from 'node:path'
|
|
6
6
|
import { createRequire } from 'node:module'
|
|
7
|
+
import { createInterface } from 'node:readline/promises'
|
|
8
|
+
import { stdin as input, stdout as output } from 'node:process'
|
|
7
9
|
const require = createRequire(import.meta.url)
|
|
8
10
|
const { version: VERSION } = require('../package.json') as { version: string }
|
|
9
11
|
import { analyzeProject, analyzeFile, TrendAnalyzer, BlameAnalyzer } from './analyzer.js'
|
|
@@ -19,28 +21,130 @@ import { applyFixes, type FixResult } from './fix.js'
|
|
|
19
21
|
import { loadHistory, saveSnapshot, printHistory, printSnapshotDiff } from './snapshot.js'
|
|
20
22
|
import { generateReview } from './review.js'
|
|
21
23
|
import { generateArchitectureMap } from './map.js'
|
|
24
|
+
import {
|
|
25
|
+
changeOrganizationPlan,
|
|
26
|
+
generateSaasDashboardHtml,
|
|
27
|
+
getOrganizationEffectiveLimits,
|
|
28
|
+
getOrganizationUsageSnapshot,
|
|
29
|
+
getSaasSummary,
|
|
30
|
+
ingestSnapshotFromReport,
|
|
31
|
+
listOrganizationPlanChanges,
|
|
32
|
+
} from './saas.js'
|
|
33
|
+
import {
|
|
34
|
+
buildTrustReport,
|
|
35
|
+
explainTrustGatePolicy,
|
|
36
|
+
formatTrustGatePolicyExplanation,
|
|
37
|
+
formatTrustJson,
|
|
38
|
+
renderTrustOutput,
|
|
39
|
+
shouldFailTrustGate,
|
|
40
|
+
normalizeMergeRiskLevel,
|
|
41
|
+
MERGE_RISK_ORDER,
|
|
42
|
+
detectBranchName,
|
|
43
|
+
} from './trust.js'
|
|
44
|
+
import { computeTrustKpis, formatTrustKpiConsole, formatTrustKpiJson } from './trust-kpi.js'
|
|
45
|
+
import type { DriftDiff, DriftTrustReport, DriftAnalysisOptions, MergeRiskLevel } from './types.js'
|
|
46
|
+
import type { TrustGatePolicyExplanation } from './trust.js'
|
|
47
|
+
import type { SnapshotHistory } from './snapshot.js'
|
|
22
48
|
|
|
23
49
|
const program = new Command()
|
|
24
50
|
|
|
51
|
+
type ResourceOptionFlags = {
|
|
52
|
+
lowMemory?: boolean
|
|
53
|
+
chunkSize?: string
|
|
54
|
+
maxFiles?: string
|
|
55
|
+
maxFileSizeKb?: string
|
|
56
|
+
withSemanticDuplication?: boolean
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function parseOptionalPositiveInt(rawValue: string | undefined, flagName: string): number | undefined {
|
|
60
|
+
if (rawValue == null) return undefined
|
|
61
|
+
const value = Number(rawValue)
|
|
62
|
+
if (!Number.isInteger(value) || value < 0) {
|
|
63
|
+
throw new Error(`${flagName} must be a non-negative integer`)
|
|
64
|
+
}
|
|
65
|
+
return value
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function resolveAnalysisOptions(options: ResourceOptionFlags): DriftAnalysisOptions {
|
|
69
|
+
return {
|
|
70
|
+
lowMemory: options.lowMemory,
|
|
71
|
+
chunkSize: parseOptionalPositiveInt(options.chunkSize, '--chunk-size'),
|
|
72
|
+
maxFiles: parseOptionalPositiveInt(options.maxFiles, '--max-files'),
|
|
73
|
+
maxFileSizeKb: parseOptionalPositiveInt(options.maxFileSizeKb, '--max-file-size-kb'),
|
|
74
|
+
includeSemanticDuplication: options.withSemanticDuplication ? true : undefined,
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function addResourceOptions(command: Command): Command {
|
|
79
|
+
return command
|
|
80
|
+
.option('--low-memory', 'Reduce peak memory usage by chunking AST analysis')
|
|
81
|
+
.option('--chunk-size <n>', 'Files per chunk in low-memory mode (default: 40)')
|
|
82
|
+
.option('--max-files <n>', 'Maximum files to analyze before soft-skipping extras')
|
|
83
|
+
.option('--max-file-size-kb <n>', 'Skip files above this size and report diagnostics')
|
|
84
|
+
.option('--with-semantic-duplication', 'Keep semantic-duplication rule enabled in low-memory mode')
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function parseTrustGateOverrides(options: { minTrust?: string; maxRisk?: string }): { minTrust?: number; maxRisk?: MergeRiskLevel } {
|
|
88
|
+
const cliMinTrust = options.minTrust ? Number(options.minTrust) : undefined
|
|
89
|
+
if (options.minTrust && Number.isNaN(cliMinTrust)) {
|
|
90
|
+
process.stderr.write('\n Error: --min-trust must be a valid number\n\n')
|
|
91
|
+
process.exit(1)
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
let cliMaxRisk: MergeRiskLevel | undefined
|
|
95
|
+
if (options.maxRisk) {
|
|
96
|
+
cliMaxRisk = normalizeMergeRiskLevel(options.maxRisk)
|
|
97
|
+
if (!cliMaxRisk) {
|
|
98
|
+
process.stderr.write(`\n Error: --max-risk must be one of ${MERGE_RISK_ORDER.join(', ')}\n\n`)
|
|
99
|
+
process.exit(1)
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
return {
|
|
104
|
+
minTrust: typeof cliMinTrust === 'number' ? cliMinTrust : undefined,
|
|
105
|
+
maxRisk: cliMaxRisk,
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function resolveBranchFromOption(branch?: string): string | undefined {
|
|
110
|
+
const normalized = branch?.trim()
|
|
111
|
+
if (normalized) return normalized
|
|
112
|
+
return detectBranchName()
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function printTrustGatePolicyDebug(explanation: TrustGatePolicyExplanation): void {
|
|
116
|
+
process.stderr.write(`${formatTrustGatePolicyExplanation(explanation)}\n`)
|
|
117
|
+
if (explanation.invalidPolicyPack) {
|
|
118
|
+
process.stderr.write(`Warning: policy pack '${explanation.invalidPolicyPack}' was not found. Falling back to base/preset policy.\n`)
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function printSaasErrorAndExit(error: unknown): never {
|
|
123
|
+
const message = error instanceof Error ? error.message : String(error)
|
|
124
|
+
process.stderr.write(`\n Error: ${message}\n\n`)
|
|
125
|
+
process.exit(1)
|
|
126
|
+
}
|
|
127
|
+
|
|
25
128
|
program
|
|
26
129
|
.name('drift')
|
|
27
|
-
.description('
|
|
130
|
+
.description('AI Code Audit CLI for merge trust in AI-assisted PRs')
|
|
28
131
|
.version(VERSION)
|
|
29
132
|
|
|
30
|
-
|
|
31
|
-
|
|
133
|
+
addResourceOptions(
|
|
134
|
+
program
|
|
135
|
+
.command('scan [path]', { isDefault: true })
|
|
32
136
|
.description('Scan a directory for vibe coding drift')
|
|
33
137
|
.option('-o, --output <file>', 'Write report to a Markdown file')
|
|
34
138
|
.option('--json', 'Output raw JSON report')
|
|
35
139
|
.option('--ai', 'Output AI-optimized JSON for LLM consumption')
|
|
36
140
|
.option('--fix', 'Show fix suggestions for each issue')
|
|
37
141
|
.option('--min-score <n>', 'Exit with code 1 if overall score exceeds this threshold', '0')
|
|
38
|
-
.action(async (targetPath: string | undefined, options: { output?: string; json?: boolean; ai?: boolean; fix?: boolean; minScore: string }) => {
|
|
142
|
+
.action(async (targetPath: string | undefined, options: { output?: string; json?: boolean; ai?: boolean; fix?: boolean; minScore: string } & ResourceOptionFlags) => {
|
|
39
143
|
const resolvedPath = resolve(targetPath ?? '.')
|
|
40
144
|
|
|
41
145
|
process.stderr.write(`\nScanning ${resolvedPath}...\n`)
|
|
42
146
|
const config = await loadConfig(resolvedPath)
|
|
43
|
-
const files = analyzeProject(resolvedPath, config)
|
|
147
|
+
const files = analyzeProject(resolvedPath, config, resolveAnalysisOptions(options))
|
|
44
148
|
process.stderr.write(` Found ${files.length} TypeScript file(s)\n\n`)
|
|
45
149
|
const report = buildReport(resolvedPath, files)
|
|
46
150
|
|
|
@@ -69,15 +173,18 @@ program
|
|
|
69
173
|
if (minScore > 0 && report.totalScore > minScore) {
|
|
70
174
|
process.exit(1)
|
|
71
175
|
}
|
|
72
|
-
})
|
|
176
|
+
}),
|
|
177
|
+
)
|
|
73
178
|
|
|
74
|
-
|
|
75
|
-
|
|
179
|
+
addResourceOptions(
|
|
180
|
+
program
|
|
181
|
+
.command('diff [ref]')
|
|
76
182
|
.description('Compare current state against a git ref (default: HEAD~1)')
|
|
77
183
|
.option('--json', 'Output raw JSON diff')
|
|
78
|
-
.action(async (ref: string | undefined, options: { json?: boolean }) => {
|
|
184
|
+
.action(async (ref: string | undefined, options: { json?: boolean } & ResourceOptionFlags) => {
|
|
79
185
|
const baseRef = ref ?? 'HEAD~1'
|
|
80
186
|
const projectPath = resolve('.')
|
|
187
|
+
const analysisOptions = resolveAnalysisOptions(options)
|
|
81
188
|
|
|
82
189
|
let tempDir: string | undefined
|
|
83
190
|
|
|
@@ -86,12 +193,12 @@ program
|
|
|
86
193
|
|
|
87
194
|
// Scan current state
|
|
88
195
|
const config = await loadConfig(projectPath)
|
|
89
|
-
const currentFiles = analyzeProject(projectPath, config)
|
|
196
|
+
const currentFiles = analyzeProject(projectPath, config, analysisOptions)
|
|
90
197
|
const currentReport = buildReport(projectPath, currentFiles)
|
|
91
198
|
|
|
92
199
|
// Extract base state from git
|
|
93
200
|
tempDir = extractFilesAtRef(projectPath, baseRef)
|
|
94
|
-
const baseFiles = analyzeProject(tempDir, config)
|
|
201
|
+
const baseFiles = analyzeProject(tempDir, config, analysisOptions)
|
|
95
202
|
|
|
96
203
|
// Remap base file paths to match current project paths
|
|
97
204
|
// (temp dir paths → project paths for accurate comparison)
|
|
@@ -100,7 +207,7 @@ program
|
|
|
100
207
|
...baseReport,
|
|
101
208
|
files: baseReport.files.map(f => ({
|
|
102
209
|
...f,
|
|
103
|
-
path:
|
|
210
|
+
path: resolve(projectPath, relative(tempDir!, f.path)),
|
|
104
211
|
})),
|
|
105
212
|
}
|
|
106
213
|
|
|
@@ -118,7 +225,8 @@ program
|
|
|
118
225
|
} finally {
|
|
119
226
|
if (tempDir) cleanupTempDir(tempDir)
|
|
120
227
|
}
|
|
121
|
-
})
|
|
228
|
+
}),
|
|
229
|
+
)
|
|
122
230
|
|
|
123
231
|
program
|
|
124
232
|
.command('review')
|
|
@@ -148,6 +256,239 @@ program
|
|
|
148
256
|
}
|
|
149
257
|
})
|
|
150
258
|
|
|
259
|
+
addResourceOptions(
|
|
260
|
+
program
|
|
261
|
+
.command('trust [path]')
|
|
262
|
+
.description('Compute merge trust baseline from drift signals')
|
|
263
|
+
.option('--base <ref>', 'Git base ref for diff-aware trust scoring')
|
|
264
|
+
.option('--json', 'Output structured trust JSON')
|
|
265
|
+
.option('--markdown', 'Output trust report as markdown (PR comment ready)')
|
|
266
|
+
.option('-o, --output <file>', 'Write trust output to file')
|
|
267
|
+
.option('--json-output <file>', 'Write structured trust JSON to file without changing stdout format')
|
|
268
|
+
.option('--min-trust <n>', 'Exit with code 1 if trust score is below threshold')
|
|
269
|
+
.option('--max-risk <level>', 'Exit with code 1 if merge risk exceeds level (LOW|MEDIUM|HIGH|CRITICAL)')
|
|
270
|
+
.option('--branch <name>', 'Branch name for trust policy matching (default: auto-detect from CI env)')
|
|
271
|
+
.option('--policy-pack <name>', 'Trust policy pack from drift.config trustGate.policyPacks')
|
|
272
|
+
.option('--explain-policy', 'Print effective trust gate policy resolution to stderr')
|
|
273
|
+
.option('--advanced-trust', 'Enable advanced trust mode with historical comparison and team guidance')
|
|
274
|
+
.option('--previous-trust <file>', 'Previous trust JSON file to compare against (used in advanced mode)')
|
|
275
|
+
.option('--history-file <file>', 'Snapshot history JSON file (default: <path>/drift-history.json) for advanced mode')
|
|
276
|
+
.action(async (
|
|
277
|
+
targetPath: string | undefined,
|
|
278
|
+
options: {
|
|
279
|
+
base?: string
|
|
280
|
+
json?: boolean
|
|
281
|
+
markdown?: boolean
|
|
282
|
+
output?: string
|
|
283
|
+
jsonOutput?: string
|
|
284
|
+
minTrust?: string
|
|
285
|
+
maxRisk?: string
|
|
286
|
+
branch?: string
|
|
287
|
+
policyPack?: string
|
|
288
|
+
explainPolicy?: boolean
|
|
289
|
+
advancedTrust?: boolean
|
|
290
|
+
previousTrust?: string
|
|
291
|
+
historyFile?: string
|
|
292
|
+
} & ResourceOptionFlags,
|
|
293
|
+
) => {
|
|
294
|
+
let tempDir: string | undefined
|
|
295
|
+
|
|
296
|
+
try {
|
|
297
|
+
const resolvedPath = resolve(targetPath ?? '.')
|
|
298
|
+
const analysisOptions = resolveAnalysisOptions(options)
|
|
299
|
+
|
|
300
|
+
process.stderr.write(`\nScanning ${resolvedPath} for trust signals...\n`)
|
|
301
|
+
const config = await loadConfig(resolvedPath)
|
|
302
|
+
const files = analyzeProject(resolvedPath, config, analysisOptions)
|
|
303
|
+
process.stderr.write(` Found ${files.length} TypeScript file(s)\n\n`)
|
|
304
|
+
|
|
305
|
+
const report = buildReport(resolvedPath, files)
|
|
306
|
+
const branchName = resolveBranchFromOption(options.branch)
|
|
307
|
+
const policyExplanation = explainTrustGatePolicy(config, {
|
|
308
|
+
branchName,
|
|
309
|
+
policyPack: options.policyPack,
|
|
310
|
+
overrides: parseTrustGateOverrides(options),
|
|
311
|
+
})
|
|
312
|
+
const policy = policyExplanation.effectivePolicy
|
|
313
|
+
|
|
314
|
+
if (options.explainPolicy) {
|
|
315
|
+
printTrustGatePolicyDebug(policyExplanation)
|
|
316
|
+
} else if (policyExplanation.invalidPolicyPack) {
|
|
317
|
+
process.stderr.write(`Warning: policy pack '${policyExplanation.invalidPolicyPack}' was not found. Falling back to base/preset policy.\n`)
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
let diff: DriftDiff | undefined
|
|
321
|
+
if (options.base) {
|
|
322
|
+
process.stderr.write(`Computing diff signals against ${options.base}...\n`)
|
|
323
|
+
tempDir = extractFilesAtRef(resolvedPath, options.base)
|
|
324
|
+
const baseFiles = analyzeProject(tempDir, config, analysisOptions)
|
|
325
|
+
const baseReport = buildReport(tempDir, baseFiles)
|
|
326
|
+
const remappedBase = {
|
|
327
|
+
...baseReport,
|
|
328
|
+
files: baseReport.files.map((file) => ({
|
|
329
|
+
...file,
|
|
330
|
+
path: resolve(resolvedPath, relative(tempDir!, file.path)),
|
|
331
|
+
})),
|
|
332
|
+
}
|
|
333
|
+
diff = computeDiff(remappedBase, report, options.base)
|
|
334
|
+
process.stderr.write(` Diff: ${diff.totalDelta >= 0 ? '+' : ''}${diff.totalDelta} score, +${diff.newIssuesCount} new / -${diff.resolvedIssuesCount} resolved\n\n`)
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
let previousTrustReport: Partial<DriftTrustReport> | undefined
|
|
338
|
+
let snapshots: SnapshotHistory['snapshots'] | undefined
|
|
339
|
+
if (options.advancedTrust) {
|
|
340
|
+
if (options.previousTrust) {
|
|
341
|
+
const previousTrustPath = resolve(options.previousTrust)
|
|
342
|
+
const rawPreviousTrust = readFileSync(previousTrustPath, 'utf8')
|
|
343
|
+
previousTrustReport = JSON.parse(rawPreviousTrust) as Partial<DriftTrustReport>
|
|
344
|
+
process.stderr.write(`Advanced trust: loaded previous trust JSON from ${previousTrustPath}\n`)
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
if (options.historyFile) {
|
|
348
|
+
const historyPath = resolve(options.historyFile)
|
|
349
|
+
const rawHistory = readFileSync(historyPath, 'utf8')
|
|
350
|
+
const history = JSON.parse(rawHistory) as SnapshotHistory
|
|
351
|
+
snapshots = history.snapshots
|
|
352
|
+
process.stderr.write(`Advanced trust: loaded snapshot history from ${historyPath}\n`)
|
|
353
|
+
} else {
|
|
354
|
+
snapshots = loadHistory(resolvedPath).snapshots
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
const trust = buildTrustReport(report, {
|
|
359
|
+
diff,
|
|
360
|
+
advanced: {
|
|
361
|
+
enabled: options.advancedTrust,
|
|
362
|
+
previousTrust: previousTrustReport,
|
|
363
|
+
snapshots,
|
|
364
|
+
},
|
|
365
|
+
})
|
|
366
|
+
|
|
367
|
+
const rendered = `${renderTrustOutput(trust, options)}\n`
|
|
368
|
+
|
|
369
|
+
process.stdout.write(rendered)
|
|
370
|
+
|
|
371
|
+
if (options.output) {
|
|
372
|
+
const outPath = resolve(options.output)
|
|
373
|
+
writeFileSync(outPath, rendered, 'utf8')
|
|
374
|
+
process.stderr.write(`Trust output saved to ${outPath}\n`)
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
if (options.jsonOutput) {
|
|
378
|
+
const jsonOutPath = resolve(options.jsonOutput)
|
|
379
|
+
writeFileSync(jsonOutPath, `${formatTrustJson(trust)}\n`, 'utf8')
|
|
380
|
+
process.stderr.write(`Trust JSON saved to ${jsonOutPath}\n`)
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
if (policy.enabled === false) {
|
|
384
|
+
process.stderr.write(`Trust gate skipped by policy${branchName ? ` (branch: ${branchName})` : ''}\n`)
|
|
385
|
+
return
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
if (shouldFailTrustGate(trust, policy)) {
|
|
389
|
+
process.exit(1)
|
|
390
|
+
}
|
|
391
|
+
} catch (err) {
|
|
392
|
+
const message = err instanceof Error ? err.message : String(err)
|
|
393
|
+
process.stderr.write(`\n Error: ${message}\n\n`)
|
|
394
|
+
process.exit(1)
|
|
395
|
+
} finally {
|
|
396
|
+
if (tempDir) cleanupTempDir(tempDir)
|
|
397
|
+
}
|
|
398
|
+
}),
|
|
399
|
+
)
|
|
400
|
+
|
|
401
|
+
program
|
|
402
|
+
.command('trust-gate <trustJsonFile>')
|
|
403
|
+
.description('Evaluate trust gate thresholds from an existing trust JSON file')
|
|
404
|
+
.option('--min-trust <n>', 'Fail if trust score is below threshold')
|
|
405
|
+
.option('--max-risk <level>', 'Fail if merge risk exceeds level (LOW|MEDIUM|HIGH|CRITICAL)')
|
|
406
|
+
.option('--branch <name>', 'Branch name for trust policy matching (default: auto-detect from CI env)')
|
|
407
|
+
.option('--policy-pack <name>', 'Trust policy pack from drift.config trustGate.policyPacks')
|
|
408
|
+
.option('--explain-policy', 'Print effective trust gate policy resolution to stderr')
|
|
409
|
+
.action(async (trustJsonFile: string, options: { minTrust?: string; maxRisk?: string; branch?: string; policyPack?: string; explainPolicy?: boolean }) => {
|
|
410
|
+
try {
|
|
411
|
+
const filePath = resolve(trustJsonFile)
|
|
412
|
+
const raw = readFileSync(filePath, 'utf8')
|
|
413
|
+
const parsed = JSON.parse(raw) as Partial<DriftTrustReport>
|
|
414
|
+
const config = await loadConfig(resolve('.'))
|
|
415
|
+
const branchName = resolveBranchFromOption(options.branch)
|
|
416
|
+
const policyExplanation = explainTrustGatePolicy(config, {
|
|
417
|
+
branchName,
|
|
418
|
+
policyPack: options.policyPack,
|
|
419
|
+
overrides: parseTrustGateOverrides(options),
|
|
420
|
+
})
|
|
421
|
+
const policy = policyExplanation.effectivePolicy
|
|
422
|
+
|
|
423
|
+
if (options.explainPolicy) {
|
|
424
|
+
printTrustGatePolicyDebug(policyExplanation)
|
|
425
|
+
} else if (policyExplanation.invalidPolicyPack) {
|
|
426
|
+
process.stderr.write(`Warning: policy pack '${policyExplanation.invalidPolicyPack}' was not found. Falling back to base/preset policy.\n`)
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
if (typeof parsed.trust_score !== 'number') {
|
|
430
|
+
process.stderr.write('\n Error: trust JSON is missing numeric trust_score\n\n')
|
|
431
|
+
process.exit(1)
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
if (typeof parsed.merge_risk !== 'string') {
|
|
435
|
+
process.stderr.write('\n Error: trust JSON is missing merge_risk\n\n')
|
|
436
|
+
process.exit(1)
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
const actualRisk = normalizeMergeRiskLevel(parsed.merge_risk)
|
|
440
|
+
if (!actualRisk) {
|
|
441
|
+
process.stderr.write(`\n Error: trust JSON merge_risk must be one of ${MERGE_RISK_ORDER.join(', ')}\n\n`)
|
|
442
|
+
process.exit(1)
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
const trust: DriftTrustReport = {
|
|
446
|
+
scannedAt: parsed.scannedAt ?? new Date().toISOString(),
|
|
447
|
+
targetPath: parsed.targetPath ?? '.',
|
|
448
|
+
trust_score: parsed.trust_score,
|
|
449
|
+
merge_risk: actualRisk,
|
|
450
|
+
top_reasons: parsed.top_reasons ?? [],
|
|
451
|
+
fix_priorities: parsed.fix_priorities ?? [],
|
|
452
|
+
diff_context: parsed.diff_context,
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
if (policy.enabled === false) {
|
|
456
|
+
process.stdout.write(`Trust gate skipped by policy${branchName ? ` (branch: ${branchName})` : ''}\n`)
|
|
457
|
+
return
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
if (shouldFailTrustGate(trust, policy)) {
|
|
461
|
+
process.exit(1)
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
process.stdout.write(`Trust gate passed: trust=${trust.trust_score} risk=${trust.merge_risk}\n`)
|
|
465
|
+
} catch (err) {
|
|
466
|
+
const message = err instanceof Error ? err.message : String(err)
|
|
467
|
+
process.stderr.write(`\n Error: ${message}\n\n`)
|
|
468
|
+
process.exit(1)
|
|
469
|
+
}
|
|
470
|
+
})
|
|
471
|
+
|
|
472
|
+
program
|
|
473
|
+
.command('kpi <path>')
|
|
474
|
+
.description('Aggregate trust KPIs from trust JSON artifacts')
|
|
475
|
+
.option('--no-summary', 'Disable console KPI summary in stderr')
|
|
476
|
+
.action((targetPath: string, options: { summary?: boolean }) => {
|
|
477
|
+
try {
|
|
478
|
+
const kpi = computeTrustKpis(targetPath)
|
|
479
|
+
|
|
480
|
+
if (options.summary !== false) {
|
|
481
|
+
process.stderr.write(`${formatTrustKpiConsole(kpi)}\n`)
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
process.stdout.write(`${formatTrustKpiJson(kpi)}\n`)
|
|
485
|
+
} catch (err) {
|
|
486
|
+
const message = err instanceof Error ? err.message : String(err)
|
|
487
|
+
process.stderr.write(`\n Error: ${message}\n\n`)
|
|
488
|
+
process.exit(1)
|
|
489
|
+
}
|
|
490
|
+
})
|
|
491
|
+
|
|
151
492
|
program
|
|
152
493
|
.command('map [path]')
|
|
153
494
|
.description('Generate architecture.svg with simple layer dependencies')
|
|
@@ -155,52 +496,58 @@ program
|
|
|
155
496
|
.action(async (targetPath: string | undefined, options: { output: string }) => {
|
|
156
497
|
const resolvedPath = resolve(targetPath ?? '.')
|
|
157
498
|
process.stderr.write(`\nBuilding architecture map for ${resolvedPath}...\n`)
|
|
158
|
-
const
|
|
499
|
+
const config = await loadConfig(resolvedPath)
|
|
500
|
+
const out = generateArchitectureMap(resolvedPath, options.output, config)
|
|
159
501
|
process.stderr.write(` Architecture map saved to ${out}\n\n`)
|
|
160
502
|
})
|
|
161
503
|
|
|
162
|
-
|
|
163
|
-
|
|
504
|
+
addResourceOptions(
|
|
505
|
+
program
|
|
506
|
+
.command('report [path]')
|
|
164
507
|
.description('Generate a self-contained HTML report')
|
|
165
508
|
.option('-o, --output <file>', 'Output file path (default: drift-report.html)', 'drift-report.html')
|
|
166
|
-
.action(async (targetPath: string | undefined, options: { output: string }) => {
|
|
509
|
+
.action(async (targetPath: string | undefined, options: { output: string } & ResourceOptionFlags) => {
|
|
167
510
|
const resolvedPath = resolve(targetPath ?? '.')
|
|
168
511
|
process.stderr.write(`\nScanning ${resolvedPath}...\n`)
|
|
169
512
|
const config = await loadConfig(resolvedPath)
|
|
170
|
-
const files = analyzeProject(resolvedPath, config)
|
|
513
|
+
const files = analyzeProject(resolvedPath, config, resolveAnalysisOptions(options))
|
|
171
514
|
process.stderr.write(` Found ${files.length} TypeScript file(s)\n\n`)
|
|
172
515
|
const report = buildReport(resolvedPath, files)
|
|
173
516
|
const html = generateHtmlReport(report)
|
|
174
517
|
const outPath = resolve(options.output)
|
|
175
518
|
writeFileSync(outPath, html, 'utf8')
|
|
176
519
|
process.stderr.write(` Report saved to ${outPath}\n\n`)
|
|
177
|
-
})
|
|
520
|
+
}),
|
|
521
|
+
)
|
|
178
522
|
|
|
179
|
-
|
|
180
|
-
|
|
523
|
+
addResourceOptions(
|
|
524
|
+
program
|
|
525
|
+
.command('badge [path]')
|
|
181
526
|
.description('Generate a badge.svg with the current drift score')
|
|
182
527
|
.option('-o, --output <file>', 'Output file path (default: badge.svg)', 'badge.svg')
|
|
183
|
-
.action(async (targetPath: string | undefined, options: { output: string }) => {
|
|
528
|
+
.action(async (targetPath: string | undefined, options: { output: string } & ResourceOptionFlags) => {
|
|
184
529
|
const resolvedPath = resolve(targetPath ?? '.')
|
|
185
530
|
process.stderr.write(`\nScanning ${resolvedPath}...\n`)
|
|
186
531
|
const config = await loadConfig(resolvedPath)
|
|
187
|
-
const files = analyzeProject(resolvedPath, config)
|
|
532
|
+
const files = analyzeProject(resolvedPath, config, resolveAnalysisOptions(options))
|
|
188
533
|
const report = buildReport(resolvedPath, files)
|
|
189
534
|
const svg = generateBadge(report.totalScore)
|
|
190
535
|
const outPath = resolve(options.output)
|
|
191
536
|
writeFileSync(outPath, svg, 'utf8')
|
|
192
537
|
process.stderr.write(` Badge saved to ${outPath}\n`)
|
|
193
538
|
process.stderr.write(` Score: ${report.totalScore}/100\n\n`)
|
|
194
|
-
})
|
|
539
|
+
}),
|
|
540
|
+
)
|
|
195
541
|
|
|
196
|
-
|
|
197
|
-
|
|
542
|
+
addResourceOptions(
|
|
543
|
+
program
|
|
544
|
+
.command('ci [path]')
|
|
198
545
|
.description('Emit GitHub Actions annotations and step summary')
|
|
199
546
|
.option('--min-score <n>', 'Exit with code 1 if overall score exceeds this threshold', '0')
|
|
200
|
-
.action(async (targetPath: string | undefined, options: { minScore: string }) => {
|
|
547
|
+
.action(async (targetPath: string | undefined, options: { minScore: string } & ResourceOptionFlags) => {
|
|
201
548
|
const resolvedPath = resolve(targetPath ?? '.')
|
|
202
549
|
const config = await loadConfig(resolvedPath)
|
|
203
|
-
const files = analyzeProject(resolvedPath, config)
|
|
550
|
+
const files = analyzeProject(resolvedPath, config, resolveAnalysisOptions(options))
|
|
204
551
|
const report = buildReport(resolvedPath, files)
|
|
205
552
|
emitCIAnnotations(report)
|
|
206
553
|
printCISummary(report)
|
|
@@ -208,7 +555,8 @@ program
|
|
|
208
555
|
if (minScore > 0 && report.totalScore > minScore) {
|
|
209
556
|
process.exit(1)
|
|
210
557
|
}
|
|
211
|
-
})
|
|
558
|
+
}),
|
|
559
|
+
)
|
|
212
560
|
|
|
213
561
|
program
|
|
214
562
|
.command('trend [period]')
|
|
@@ -259,12 +607,38 @@ program
|
|
|
259
607
|
.option('--preview', 'Preview changes without writing files')
|
|
260
608
|
.option('--write', 'Write fixes to disk')
|
|
261
609
|
.option('--dry-run', 'Show what would change without writing files')
|
|
262
|
-
.
|
|
610
|
+
.option('-y, --yes', 'Skip interactive confirmation for --write')
|
|
611
|
+
.action(async (targetPath: string | undefined, options: { rule?: string; dryRun?: boolean; preview?: boolean; write?: boolean; yes?: boolean }) => {
|
|
263
612
|
const resolvedPath = resolve(targetPath ?? '.')
|
|
264
613
|
const config = await loadConfig(resolvedPath)
|
|
265
614
|
const previewMode = Boolean(options.preview || options.dryRun)
|
|
266
615
|
const writeMode = options.write ?? !previewMode
|
|
267
616
|
|
|
617
|
+
if (writeMode && !options.yes) {
|
|
618
|
+
const previewResults = await applyFixes(resolvedPath, config, {
|
|
619
|
+
rule: options.rule,
|
|
620
|
+
dryRun: true,
|
|
621
|
+
preview: true,
|
|
622
|
+
write: false,
|
|
623
|
+
})
|
|
624
|
+
|
|
625
|
+
if (previewResults.length === 0) {
|
|
626
|
+
console.log('No fixable issues found.')
|
|
627
|
+
return
|
|
628
|
+
}
|
|
629
|
+
|
|
630
|
+
const files = new Set(previewResults.map((result) => result.file)).size
|
|
631
|
+
const prompt = `Apply ${previewResults.length} fix(es) across ${files} file(s)? [y/N] `
|
|
632
|
+
const rl = createInterface({ input, output })
|
|
633
|
+
const answer = (await rl.question(prompt)).trim().toLowerCase()
|
|
634
|
+
rl.close()
|
|
635
|
+
|
|
636
|
+
if (answer !== 'y' && answer !== 'yes') {
|
|
637
|
+
console.log('Aborted. No files were modified.')
|
|
638
|
+
return
|
|
639
|
+
}
|
|
640
|
+
}
|
|
641
|
+
|
|
268
642
|
const results = await applyFixes(resolvedPath, config, {
|
|
269
643
|
rule: options.rule,
|
|
270
644
|
dryRun: previewMode,
|
|
@@ -310,15 +684,16 @@ program
|
|
|
310
684
|
}
|
|
311
685
|
})
|
|
312
686
|
|
|
313
|
-
|
|
314
|
-
|
|
687
|
+
addResourceOptions(
|
|
688
|
+
program
|
|
689
|
+
.command('snapshot [path]')
|
|
315
690
|
.description('Record a score snapshot to drift-history.json')
|
|
316
691
|
.option('-l, --label <label>', 'label for this snapshot (e.g. sprint name, version)')
|
|
317
692
|
.option('--history', 'show all recorded snapshots')
|
|
318
693
|
.option('--diff', 'compare current score vs last snapshot')
|
|
319
694
|
.action(async (
|
|
320
695
|
targetPath: string | undefined,
|
|
321
|
-
opts: { label?: string; history?: boolean; diff?: boolean },
|
|
696
|
+
opts: { label?: string; history?: boolean; diff?: boolean } & ResourceOptionFlags,
|
|
322
697
|
) => {
|
|
323
698
|
const resolvedPath = resolve(targetPath ?? '.')
|
|
324
699
|
|
|
@@ -330,7 +705,7 @@ program
|
|
|
330
705
|
|
|
331
706
|
process.stderr.write(`\nScanning ${resolvedPath}...\n`)
|
|
332
707
|
const config = await loadConfig(resolvedPath)
|
|
333
|
-
const files = analyzeProject(resolvedPath, config)
|
|
708
|
+
const files = analyzeProject(resolvedPath, config, resolveAnalysisOptions(opts))
|
|
334
709
|
process.stderr.write(` Found ${files.length} TypeScript file(s)\n\n`)
|
|
335
710
|
const report = buildReport(resolvedPath, files)
|
|
336
711
|
|
|
@@ -346,6 +721,223 @@ program
|
|
|
346
721
|
` Snapshot recorded${labelStr}: score ${entry.score} (${entry.grade}) — ${entry.totalIssues} issues across ${entry.files} files\n`,
|
|
347
722
|
)
|
|
348
723
|
process.stdout.write(` Saved to drift-history.json\n\n`)
|
|
724
|
+
}),
|
|
725
|
+
)
|
|
726
|
+
|
|
727
|
+
const cloud = program
|
|
728
|
+
.command('cloud')
|
|
729
|
+
.description('Local SaaS foundations: ingest, summary, and dashboard')
|
|
730
|
+
|
|
731
|
+
addResourceOptions(
|
|
732
|
+
cloud
|
|
733
|
+
.command('ingest [path]')
|
|
734
|
+
.description('Scan path, build report, and store cloud snapshot')
|
|
735
|
+
.option('--org <id>', 'Organization id (default: default-org)', 'default-org')
|
|
736
|
+
.requiredOption('--workspace <id>', 'Workspace id')
|
|
737
|
+
.requiredOption('--user <id>', 'User id')
|
|
738
|
+
.option('--role <role>', 'Role hint (owner|member|viewer)')
|
|
739
|
+
.option('--plan <plan>', 'Organization plan (free|sponsor|team|business)')
|
|
740
|
+
.option('--repo <name>', 'Repo name (default: basename of scanned path)')
|
|
741
|
+
.option('--actor <user>', 'Actor user id for permission checks (local-only authz context)')
|
|
742
|
+
.option('--store <file>', 'Store file path (default: .drift-cloud/store.json)')
|
|
743
|
+
.action(async (targetPath: string | undefined, options: { org: string; workspace: string; user: string; role?: string; plan?: string; repo?: string; actor?: string; store?: string } & ResourceOptionFlags) => {
|
|
744
|
+
try {
|
|
745
|
+
const resolvedPath = resolve(targetPath ?? '.')
|
|
746
|
+
process.stderr.write(`\nScanning ${resolvedPath} for cloud ingest...\n`)
|
|
747
|
+
const config = await loadConfig(resolvedPath)
|
|
748
|
+
const files = analyzeProject(resolvedPath, config, resolveAnalysisOptions(options))
|
|
749
|
+
const report = buildReport(resolvedPath, files)
|
|
750
|
+
|
|
751
|
+
const snapshot = ingestSnapshotFromReport(report, {
|
|
752
|
+
organizationId: options.org,
|
|
753
|
+
workspaceId: options.workspace,
|
|
754
|
+
userId: options.user,
|
|
755
|
+
role: options.role as 'owner' | 'member' | 'viewer' | undefined,
|
|
756
|
+
plan: options.plan as 'free' | 'sponsor' | 'team' | 'business' | undefined,
|
|
757
|
+
repoName: options.repo ?? basename(resolvedPath),
|
|
758
|
+
actorUserId: options.actor,
|
|
759
|
+
storeFile: options.store,
|
|
760
|
+
policy: config?.saas,
|
|
761
|
+
})
|
|
762
|
+
|
|
763
|
+
process.stdout.write(`Ingested snapshot ${snapshot.id}\n`)
|
|
764
|
+
process.stdout.write(`Organization: ${snapshot.organizationId} Workspace: ${snapshot.workspaceId} Repo: ${snapshot.repoName}\n`)
|
|
765
|
+
process.stdout.write(`Role: ${snapshot.role} Plan: ${snapshot.plan}\n`)
|
|
766
|
+
process.stdout.write(`Score: ${snapshot.totalScore}/100 Issues: ${snapshot.totalIssues}\n\n`)
|
|
767
|
+
} catch (error) {
|
|
768
|
+
printSaasErrorAndExit(error)
|
|
769
|
+
}
|
|
770
|
+
}),
|
|
771
|
+
)
|
|
772
|
+
|
|
773
|
+
cloud
|
|
774
|
+
.command('summary')
|
|
775
|
+
.description('Show SaaS usage metrics and free threshold status')
|
|
776
|
+
.option('--json', 'Output raw JSON summary')
|
|
777
|
+
.option('--org <id>', 'Filter summary by organization id')
|
|
778
|
+
.option('--workspace <id>', 'Filter summary by workspace id')
|
|
779
|
+
.option('--actor <user>', 'Actor user id for permission checks (local-only authz context)')
|
|
780
|
+
.option('--store <file>', 'Store file path (default: .drift-cloud/store.json)')
|
|
781
|
+
.action((options: { json?: boolean; org?: string; workspace?: string; actor?: string; store?: string }) => {
|
|
782
|
+
try {
|
|
783
|
+
const summary = getSaasSummary({
|
|
784
|
+
storeFile: options.store,
|
|
785
|
+
organizationId: options.org,
|
|
786
|
+
workspaceId: options.workspace,
|
|
787
|
+
actorUserId: options.actor,
|
|
788
|
+
})
|
|
789
|
+
|
|
790
|
+
if (options.json) {
|
|
791
|
+
process.stdout.write(JSON.stringify(summary, null, 2) + '\n')
|
|
792
|
+
return
|
|
793
|
+
}
|
|
794
|
+
|
|
795
|
+
process.stdout.write('\n')
|
|
796
|
+
process.stdout.write(`Phase: ${summary.phase.toUpperCase()}\n`)
|
|
797
|
+
process.stdout.write(`Users registered: ${summary.usersRegistered}\n`)
|
|
798
|
+
process.stdout.write(`Active workspaces (30d): ${summary.workspacesActive}\n`)
|
|
799
|
+
process.stdout.write(`Active repos (30d): ${summary.reposActive}\n`)
|
|
800
|
+
process.stdout.write(`Total snapshots: ${summary.totalSnapshots}\n`)
|
|
801
|
+
process.stdout.write(`Free user threshold: ${summary.policy.freeUserThreshold}\n`)
|
|
802
|
+
process.stdout.write(`Threshold reached: ${summary.thresholdReached ? 'yes' : 'no'}\n`)
|
|
803
|
+
process.stdout.write(`Free users remaining: ${summary.freeUsersRemaining}\n`)
|
|
804
|
+
process.stdout.write('Runs per month:\n')
|
|
805
|
+
|
|
806
|
+
const monthly = Object.entries(summary.runsPerMonth).sort(([a], [b]) => a.localeCompare(b))
|
|
807
|
+
if (monthly.length === 0) {
|
|
808
|
+
process.stdout.write(' - none\n\n')
|
|
809
|
+
return
|
|
810
|
+
}
|
|
811
|
+
|
|
812
|
+
for (const [month, runs] of monthly) {
|
|
813
|
+
process.stdout.write(` - ${month}: ${runs}\n`)
|
|
814
|
+
}
|
|
815
|
+
process.stdout.write('\n')
|
|
816
|
+
} catch (error) {
|
|
817
|
+
printSaasErrorAndExit(error)
|
|
818
|
+
}
|
|
819
|
+
})
|
|
820
|
+
|
|
821
|
+
cloud
|
|
822
|
+
.command('plan-set')
|
|
823
|
+
.description('Set organization plan (owner role required when actor is provided)')
|
|
824
|
+
.requiredOption('--org <id>', 'Organization id')
|
|
825
|
+
.requiredOption('--plan <plan>', 'New organization plan (free|sponsor|team|business)')
|
|
826
|
+
.requiredOption('--actor <user>', 'Actor user id used for owner-gated billing writes')
|
|
827
|
+
.option('--reason <text>', 'Optional reason for audit trail')
|
|
828
|
+
.option('--store <file>', 'Store file path (default: .drift-cloud/store.json)')
|
|
829
|
+
.option('--json', 'Output raw JSON plan change')
|
|
830
|
+
.action((options: { org: string; plan: string; actor: string; reason?: string; store?: string; json?: boolean }) => {
|
|
831
|
+
try {
|
|
832
|
+
const change = changeOrganizationPlan({
|
|
833
|
+
organizationId: options.org,
|
|
834
|
+
actorUserId: options.actor,
|
|
835
|
+
newPlan: options.plan as 'free' | 'sponsor' | 'team' | 'business',
|
|
836
|
+
reason: options.reason,
|
|
837
|
+
storeFile: options.store,
|
|
838
|
+
})
|
|
839
|
+
|
|
840
|
+
if (options.json) {
|
|
841
|
+
process.stdout.write(JSON.stringify(change, null, 2) + '\n')
|
|
842
|
+
return
|
|
843
|
+
}
|
|
844
|
+
|
|
845
|
+
process.stdout.write(`Plan updated for org '${change.organizationId}': ${change.fromPlan} -> ${change.toPlan}\n`)
|
|
846
|
+
process.stdout.write(`Changed by: ${change.changedByUserId} at ${change.changedAt}\n`)
|
|
847
|
+
if (change.reason) process.stdout.write(`Reason: ${change.reason}\n`)
|
|
848
|
+
} catch (error) {
|
|
849
|
+
printSaasErrorAndExit(error)
|
|
850
|
+
}
|
|
851
|
+
})
|
|
852
|
+
|
|
853
|
+
cloud
|
|
854
|
+
.command('plan-changes')
|
|
855
|
+
.description('List organization plan change audit trail')
|
|
856
|
+
.requiredOption('--org <id>', 'Organization id')
|
|
857
|
+
.requiredOption('--actor <user>', 'Actor user id used for billing read permissions')
|
|
858
|
+
.option('--store <file>', 'Store file path (default: .drift-cloud/store.json)')
|
|
859
|
+
.option('--json', 'Output raw JSON plan changes')
|
|
860
|
+
.action((options: { org: string; actor: string; store?: string; json?: boolean }) => {
|
|
861
|
+
try {
|
|
862
|
+
const changes = listOrganizationPlanChanges({
|
|
863
|
+
organizationId: options.org,
|
|
864
|
+
actorUserId: options.actor,
|
|
865
|
+
storeFile: options.store,
|
|
866
|
+
})
|
|
867
|
+
|
|
868
|
+
if (options.json) {
|
|
869
|
+
process.stdout.write(JSON.stringify(changes, null, 2) + '\n')
|
|
870
|
+
return
|
|
871
|
+
}
|
|
872
|
+
|
|
873
|
+
if (changes.length === 0) {
|
|
874
|
+
process.stdout.write(`No plan changes found for org '${options.org}'.\n`)
|
|
875
|
+
return
|
|
876
|
+
}
|
|
877
|
+
|
|
878
|
+
process.stdout.write(`Plan changes for org '${options.org}':\n`)
|
|
879
|
+
for (const change of changes) {
|
|
880
|
+
const reasonSuffix = change.reason ? ` reason='${change.reason}'` : ''
|
|
881
|
+
process.stdout.write(`- ${change.changedAt}: ${change.fromPlan} -> ${change.toPlan} by ${change.changedByUserId}${reasonSuffix}\n`)
|
|
882
|
+
}
|
|
883
|
+
} catch (error) {
|
|
884
|
+
printSaasErrorAndExit(error)
|
|
885
|
+
}
|
|
886
|
+
})
|
|
887
|
+
|
|
888
|
+
cloud
|
|
889
|
+
.command('usage')
|
|
890
|
+
.description('Show organization usage and effective limits')
|
|
891
|
+
.requiredOption('--org <id>', 'Organization id')
|
|
892
|
+
.requiredOption('--actor <user>', 'Actor user id used for billing read permissions')
|
|
893
|
+
.option('--month <yyyy-mm>', 'Month filter for runCountThisMonth (default: current UTC month)')
|
|
894
|
+
.option('--store <file>', 'Store file path (default: .drift-cloud/store.json)')
|
|
895
|
+
.option('--json', 'Output usage and limits as raw JSON')
|
|
896
|
+
.action((options: { org: string; actor: string; month?: string; store?: string; json?: boolean }) => {
|
|
897
|
+
try {
|
|
898
|
+
const usage = getOrganizationUsageSnapshot({
|
|
899
|
+
organizationId: options.org,
|
|
900
|
+
actorUserId: options.actor,
|
|
901
|
+
month: options.month,
|
|
902
|
+
storeFile: options.store,
|
|
903
|
+
})
|
|
904
|
+
const limits = getOrganizationEffectiveLimits({
|
|
905
|
+
organizationId: options.org,
|
|
906
|
+
storeFile: options.store,
|
|
907
|
+
})
|
|
908
|
+
|
|
909
|
+
if (options.json) {
|
|
910
|
+
process.stdout.write(JSON.stringify({ usage, limits }, null, 2) + '\n')
|
|
911
|
+
return
|
|
912
|
+
}
|
|
913
|
+
|
|
914
|
+
process.stdout.write(`Organization: ${usage.organizationId}\n`)
|
|
915
|
+
process.stdout.write(`Plan: ${usage.plan}\n`)
|
|
916
|
+
process.stdout.write(`Captured at: ${usage.capturedAt}\n`)
|
|
917
|
+
process.stdout.write(`Workspace count: ${usage.workspaceCount}\n`)
|
|
918
|
+
process.stdout.write(`Repo count: ${usage.repoCount}\n`)
|
|
919
|
+
process.stdout.write(`Runs total: ${usage.runCount}\n`)
|
|
920
|
+
process.stdout.write(`Runs this month: ${usage.runCountThisMonth}\n`)
|
|
921
|
+
process.stdout.write('Effective limits:\n')
|
|
922
|
+
process.stdout.write(` - maxWorkspaces: ${limits.maxWorkspaces}\n`)
|
|
923
|
+
process.stdout.write(` - maxReposPerWorkspace: ${limits.maxReposPerWorkspace}\n`)
|
|
924
|
+
process.stdout.write(` - maxRunsPerWorkspacePerMonth: ${limits.maxRunsPerWorkspacePerMonth}\n`)
|
|
925
|
+
process.stdout.write(` - retentionDays: ${limits.retentionDays}\n`)
|
|
926
|
+
} catch (error) {
|
|
927
|
+
printSaasErrorAndExit(error)
|
|
928
|
+
}
|
|
929
|
+
})
|
|
930
|
+
|
|
931
|
+
cloud
|
|
932
|
+
.command('dashboard')
|
|
933
|
+
.description('Generate an HTML dashboard with trends and hotspots')
|
|
934
|
+
.option('-o, --output <file>', 'Output HTML file', 'drift-cloud-dashboard.html')
|
|
935
|
+
.option('--store <file>', 'Store file path (default: .drift-cloud/store.json)')
|
|
936
|
+
.action((options: { output: string; store?: string }) => {
|
|
937
|
+
const html = generateSaasDashboardHtml({ storeFile: options.store })
|
|
938
|
+
const outPath = resolve(options.output)
|
|
939
|
+
writeFileSync(outPath, html, 'utf8')
|
|
940
|
+
process.stdout.write(`Dashboard saved to ${outPath}\n`)
|
|
349
941
|
})
|
|
350
942
|
|
|
351
943
|
program.parse()
|