@trazum/cli 1.45.0 → 1.47.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/dist/i18n/en.d.ts.map +1 -1
- package/dist/i18n/en.js +79 -1
- package/dist/i18n/en.js.map +1 -1
- package/dist/i18n/es.d.ts.map +1 -1
- package/dist/i18n/es.js +79 -1
- package/dist/i18n/es.js.map +1 -1
- package/dist/i18n/types.d.ts +74 -1
- package/dist/i18n/types.d.ts.map +1 -1
- package/dist/index.js +423 -17
- package/dist/index.js.map +1 -1
- package/package.json +2 -2
- package/src/i18n/en.ts +107 -2
- package/src/i18n/es.ts +107 -2
- package/src/i18n/types.ts +73 -1
- package/src/index.ts +477 -15
package/dist/index.js
CHANGED
|
@@ -1,19 +1,19 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
import { readdir, readFile, stat, writeFile } from 'node:fs/promises';
|
|
2
|
+
import { open, readdir, readFile, stat, writeFile } from 'node:fs/promises';
|
|
3
3
|
import { join, resolve as resolvePath } from 'node:path';
|
|
4
4
|
import { gunzipSync } from 'node:zlib';
|
|
5
|
-
import { applyRewrites, BASELINE_FILENAME, BASELINE_VERSION, breaches, cacheableMinimum, analyzeCachePrefix, billLevers, bucketedCacheEconomics, bucketedProfile, buildHistory, buildPlan, connectorFor, CONNECTORS, normalizeAnthropicUsage, normalizeOpenAIUsage, bucketsFromRecords, evaluateWatch, firedKey, pruneRecords, recordsFromBuckets, storeInventory, storedReportFrom, verifyPlan, cacheEconomics, cacheHitRate, contextPressure, comparePrompts, compareToBaseline, computeSavings, countTokensAnthropic, DEFAULT_USAGE, detectFromSource, coverageDrift, driversBetween, explainGateFailure, assignSources, fleetRollup, labelCoverage, measuredUsage, gateMargin, GATE_MARGIN_TIGHT, estimateTokens, evaluate, extractPrompts, findExamples, formatBaseline, formatSignedUsd, formatUsd, getMessages, getModel, hasMarker, LOCALES, MAX_BASELINE_BYTES, moneyIsComparable, mostSpecificMatch, nearestName, optimize, parseBaseline, PHRASE_LANGUAGES, plannedCalls, profilePrompt, profileToCsv, profileUsage, promptId, providerFromEnv, pruneExamples, refineWithLlm, rejectionText, reorderForCache, repriceProfile, reviewAgeDays, reviewExamples, RULES, sharedPrefixes, sharesOf, SOURCE_EXTENSIONS, suggestRewrites, toOtlpMetrics, toPromptfoo, TTL_1H_MS, UNLABELLED, withExactTokenCounts, } from '@trazum/core';
|
|
5
|
+
import { applyRewrites, BASELINE_FILENAME, BASELINE_VERSION, breaches, cacheableMinimum, analyzeCachePrefix, billLevers, bucketedCacheEconomics, bucketedProfile, buildHistory, buildPlan, connectorFor, CONNECTORS, normalizeAnthropicUsage, normalizeOpenAIUsage, bucketsFromRecords, evaluateWatch, firedKey, pruneRecords, recordsFromBuckets, storeInventory, storedReportFrom, verifyPlan, cacheEconomics, cacheHitRate, contextPressure, comparePrompts, compareToBaseline, computeSavings, countTokensAnthropic, DEFAULT_USAGE, detectFromSource, matchLocale, parsePlanDocument, proposeInit, MIN_RATE_DAYS, parseConfig, coverageDrift, driversBetween, explainGateFailure, assignSources, fleetRollup, labelCoverage, measuredUsage, gateMargin, GATE_MARGIN_TIGHT, estimateTokens, evaluate, extractPrompts, findExamples, formatBaseline, formatSignedUsd, formatUsd, getMessages, getModel, hasMarker, LOCALES, MAX_BASELINE_BYTES, moneyIsComparable, mostSpecificMatch, nearestName, optimize, parseBaseline, PHRASE_LANGUAGES, plannedCalls, profilePrompt, profileToCsv, profileUsage, promptId, providerFromEnv, pruneExamples, refineWithLlm, rejectionText, reorderForCache, repriceProfile, reviewAgeDays, reviewExamples, RULES, sharedPrefixes, sharesOf, SOURCE_EXTENSIONS, suggestRewrites, toOtlpMetrics, toPromptfoo, TTL_1H_MS, UNLABELLED, withExactTokenCounts, } from '@trazum/core';
|
|
6
6
|
import { cacheDir, cacheStats, cachingProvider, clearCache } from './suggest-cache.js';
|
|
7
7
|
import { dayOf, formatGap, median, spanDays } from './time.js';
|
|
8
8
|
// Everything that reads the filesystem, on its own entry point so the web
|
|
9
9
|
// bundle cannot reach it. See packages/core/src/node.ts.
|
|
10
10
|
import { CONFIG_FILENAME, DEFAULT_EXTENSIONS, budgetFor, BUNDLED_CATALOGUE, SAFE_FETCH_INIT, applyPricingOverlay, catalogueFromOverlay, checkedEndpoint, openrouterOverlay, detectHost, loadConfig, walkPrompts, } from '@trazum/core/node';
|
|
11
11
|
import { contentAt, gitAvailable, namesByRevision, pathInRepository, repositoryRoot, revisionsFor, } from './git.js';
|
|
12
|
-
import { fetchProviderUsage } from './connect.js';
|
|
12
|
+
import { fetchProviderUsage, findCredential } from './connect.js';
|
|
13
13
|
import { STORE_DIR, appendRecords, readStore, rewriteStore } from './store-fs.js';
|
|
14
14
|
import { DEFAULT_PORT, buildServer, listen } from './serve.js';
|
|
15
15
|
import { WATCH_STATE_VERSION, checkWebhook, postWebhook, readWatchState, writeWatchState, } from './watch-run.js';
|
|
16
|
-
import { detectLocale, getCliMessages } from './i18n/index.js';
|
|
16
|
+
import { LOCALE_ENV_VARS, detectLocale, getCliMessages } from './i18n/index.js';
|
|
17
17
|
import { MAX_SUMMARY_CHARS, fitWithin, renderBlameMarkdown, renderCheckMarkdown, renderDiffMarkdown, renderRankMarkdown, renderProfileMarkdown, } from './markdown.js';
|
|
18
18
|
// --------------------------------------------------------------------------
|
|
19
19
|
// Presentation
|
|
@@ -309,6 +309,7 @@ const COMMAND_FLAGS = {
|
|
|
309
309
|
diff: ['level', 'model', 'calls', 'output-tokens', 'batch', 'max-growth', 'optimized', 'markdown-out', 'all', 'prompt'],
|
|
310
310
|
models: [],
|
|
311
311
|
rank: ['level', 'model', 'calls', 'output-tokens', 'batch', 'disable', 'prompt', 'markdown-out'],
|
|
312
|
+
init: ['dry-run', 'yes', 'json', 'pricing', 'pricing-live'],
|
|
312
313
|
where: [],
|
|
313
314
|
rules: [],
|
|
314
315
|
blame: ['limit', 'model', 'calls', 'output-tokens', 'batch', 'prompt', 'markdown-out'],
|
|
@@ -1007,6 +1008,378 @@ async function commandWhere(args, config, pricing, t) {
|
|
|
1007
1008
|
console.log(` ${getModel(effective).displayName} ${c.dim(reason)}`);
|
|
1008
1009
|
console.log();
|
|
1009
1010
|
}
|
|
1011
|
+
/**
|
|
1012
|
+
* Where `init` looks for a usage log before it gives up and says so.
|
|
1013
|
+
*
|
|
1014
|
+
* A short list of the names people actually use, checked in order — not a
|
|
1015
|
+
* glob over the whole tree. A first run that finds a log by searching two
|
|
1016
|
+
* thousand directories has spent the patience it was given, and a log found
|
|
1017
|
+
* in `vendor/fixtures/` is more likely to be somebody's test data than their
|
|
1018
|
+
* bill.
|
|
1019
|
+
*/
|
|
1020
|
+
const INIT_LOG_CANDIDATES = [
|
|
1021
|
+
'usage.jsonl',
|
|
1022
|
+
'usage.ndjson',
|
|
1023
|
+
'usage.log',
|
|
1024
|
+
'logs/usage.jsonl',
|
|
1025
|
+
'logs',
|
|
1026
|
+
'.trazum/usage.jsonl',
|
|
1027
|
+
];
|
|
1028
|
+
/**
|
|
1029
|
+
* Extensions worth reading for a provider sighting.
|
|
1030
|
+
*
|
|
1031
|
+
* `SOURCE_EXTENSIONS`, the same list `rank` and `doctor` walk, rather than a
|
|
1032
|
+
* second copy that drifts — a language added for extraction is a language
|
|
1033
|
+
* `init` should be able to detect a provider in, and one list is how that
|
|
1034
|
+
* stays true. Documentation is deliberately not on it: a `.md` file quoting
|
|
1035
|
+
* `from 'openai'` inside a code fence would be read as evidence, and `where`
|
|
1036
|
+
* answers for a file somebody named while this answers for a repository
|
|
1037
|
+
* nobody has vouched for.
|
|
1038
|
+
*/
|
|
1039
|
+
/** How many source files, and how large each may be. Both reported when they bite. */
|
|
1040
|
+
const INIT_MAX_SOURCE_FILES = 400;
|
|
1041
|
+
const INIT_MAX_SOURCE_BYTES = 256 * 1024;
|
|
1042
|
+
/**
|
|
1043
|
+
* The first run, printed.
|
|
1044
|
+
*
|
|
1045
|
+
* **The arithmetic comes before the figure**, everywhere below. A tool that
|
|
1046
|
+
* opens with a dollar amount nobody can check gets closed, and the reader has
|
|
1047
|
+
* no reason yet to believe anything this command says — so the headline shows
|
|
1048
|
+
* the calls, the model and the rate it is being compared against, and only
|
|
1049
|
+
* then the money.
|
|
1050
|
+
*/
|
|
1051
|
+
function renderInit(proposal, ctx) {
|
|
1052
|
+
const { t } = ctx;
|
|
1053
|
+
const n = (value) => value.toLocaleString(t.numberLocale);
|
|
1054
|
+
console.log();
|
|
1055
|
+
console.log(c.bold(t.init.heading()));
|
|
1056
|
+
console.log();
|
|
1057
|
+
// 1. Where this is running.
|
|
1058
|
+
console.log(` ${t.init.host(ctx.host.displayName)}`);
|
|
1059
|
+
if (ctx.host.billing === 'subscription') {
|
|
1060
|
+
console.log(` ${c.yellow(t.where.subscription(ctx.host.displayName))}`);
|
|
1061
|
+
}
|
|
1062
|
+
// 2. The prompts.
|
|
1063
|
+
console.log(` ${ctx.prompts.files.length === 0 ? c.dim(t.init.noPrompts()) : t.init.prompts(ctx.prompts.files.length)}`);
|
|
1064
|
+
if (ctx.truncated)
|
|
1065
|
+
console.log(` ${c.dim(t.init.sourcesTruncated(INIT_MAX_SOURCE_FILES))}`);
|
|
1066
|
+
// 3. The usage, or the two ways there is none.
|
|
1067
|
+
if (ctx.unreadable !== null) {
|
|
1068
|
+
console.log(` ${c.red(t.init.usageUnreadable(ctx.unreadable.where, ctx.unreadable.because))}`);
|
|
1069
|
+
}
|
|
1070
|
+
else if (ctx.usage.length === 0) {
|
|
1071
|
+
console.log(` ${c.dim(t.init.noUsage())}`);
|
|
1072
|
+
}
|
|
1073
|
+
else {
|
|
1074
|
+
for (const sighting of ctx.usage) {
|
|
1075
|
+
console.log(` ${t.init.usageFound(sighting.kind, sighting.where)}`);
|
|
1076
|
+
}
|
|
1077
|
+
}
|
|
1078
|
+
console.log();
|
|
1079
|
+
// 4. What the config would say, and what it will not.
|
|
1080
|
+
console.log(c.bold(t.init.configHeading()));
|
|
1081
|
+
if (proposal.justified.length === 0) {
|
|
1082
|
+
console.log(` ${c.dim(t.init.nothingJustified())}`);
|
|
1083
|
+
}
|
|
1084
|
+
for (const why of proposal.justified) {
|
|
1085
|
+
console.log(` ${c.green('+')} ${c.bold(why.key)} ${initJustification(why, t)}`);
|
|
1086
|
+
}
|
|
1087
|
+
for (const decline of proposal.declined) {
|
|
1088
|
+
console.log(` ${c.dim('·')} ${c.dim(decline.key)} ${c.dim(initDecline(decline, t))}`);
|
|
1089
|
+
}
|
|
1090
|
+
if (proposal.overwrites !== null && proposal.overwrites.keys.length > 0) {
|
|
1091
|
+
console.log();
|
|
1092
|
+
console.log(` ${c.yellow(t.init.wouldOverwrite(proposal.overwrites.keys.join(', ')))}`);
|
|
1093
|
+
}
|
|
1094
|
+
console.log();
|
|
1095
|
+
// 5. The single most valuable thing found — arithmetic first.
|
|
1096
|
+
console.log(c.bold(t.init.findingHeading()));
|
|
1097
|
+
if (proposal.headline === null) {
|
|
1098
|
+
console.log(` ${c.dim(t.init.noFinding(proposal.noHeadline ?? 'nothing-measured'))}`);
|
|
1099
|
+
console.log();
|
|
1100
|
+
return;
|
|
1101
|
+
}
|
|
1102
|
+
const { slice, lever, savingUsd, days } = proposal.headline;
|
|
1103
|
+
console.log(` ${t.init.findingCalls(n(slice.calls), slice.label, slice.modelName, days)}`);
|
|
1104
|
+
console.log(` ${t.init.findingSpent(slice.spentUsd.toFixed(2))}`);
|
|
1105
|
+
if (lever !== 'batch' && slice.route !== null) {
|
|
1106
|
+
console.log(` ${t.init.findingRoute(slice.route.candidate.displayName)}`);
|
|
1107
|
+
}
|
|
1108
|
+
if (lever !== 'route' && slice.batch !== null) {
|
|
1109
|
+
console.log(` ${t.init.findingBatch()}`);
|
|
1110
|
+
}
|
|
1111
|
+
console.log(` ${c.bold(t.init.findingTotal(savingUsd.toFixed(2), days))}`);
|
|
1112
|
+
console.log(` ${c.dim(t.init.findingNext())}`);
|
|
1113
|
+
console.log();
|
|
1114
|
+
}
|
|
1115
|
+
/** Why a key was written, in one line a person reads. */
|
|
1116
|
+
function initJustification(why, t) {
|
|
1117
|
+
switch (why.key) {
|
|
1118
|
+
case 'locale':
|
|
1119
|
+
return c.dim(t.init.whyLocale(why.value));
|
|
1120
|
+
case 'extensions':
|
|
1121
|
+
return c.dim(t.init.whyExtensions(why.value.join(' '), why.files));
|
|
1122
|
+
case 'usage.model':
|
|
1123
|
+
return c.dim(why.from === 'measured'
|
|
1124
|
+
? t.init.whyModelMeasured(why.value, Math.round(why.share * 100))
|
|
1125
|
+
: t.init.whyModelSource(why.value, why.file, why.line));
|
|
1126
|
+
case 'usage.callsPerMonth':
|
|
1127
|
+
return c.dim(t.init.whyCalls(why.value, why.calls, why.days));
|
|
1128
|
+
case 'usage.avgOutputTokens':
|
|
1129
|
+
return c.dim(t.init.whyOutput(why.value, why.outputTokens, why.calls));
|
|
1130
|
+
case 'usage.cacheHitRate':
|
|
1131
|
+
return c.dim(t.init.whyCache(why.value, why.cacheReadTokens, why.inputTokens));
|
|
1132
|
+
}
|
|
1133
|
+
}
|
|
1134
|
+
/** Why a key was not written, and what would settle it. */
|
|
1135
|
+
function initDecline(decline, t) {
|
|
1136
|
+
switch (decline.why) {
|
|
1137
|
+
case 'no-evidence':
|
|
1138
|
+
return t.init.noModelEvidence();
|
|
1139
|
+
case 'conflicting-evidence':
|
|
1140
|
+
return t.init.modelConflict(decline.files.join(', '));
|
|
1141
|
+
case 'provider-only':
|
|
1142
|
+
return t.init.modelProviderOnly(decline.provider, decline.file);
|
|
1143
|
+
case 'nothing-measured':
|
|
1144
|
+
return t.init.nothingMeasured();
|
|
1145
|
+
case 'window-too-short':
|
|
1146
|
+
return t.init.windowTooShort(decline.days, MIN_RATE_DAYS);
|
|
1147
|
+
case 'undated-calls':
|
|
1148
|
+
return t.init.undatedCalls(decline.undated, decline.calls);
|
|
1149
|
+
case 'not-recorded':
|
|
1150
|
+
return t.init.cacheNotRecorded();
|
|
1151
|
+
case 'only-you-know':
|
|
1152
|
+
return t.init.batchOnlyYouKnow();
|
|
1153
|
+
case 'unprovable':
|
|
1154
|
+
return t.init.labelsUnprovable(decline.labels);
|
|
1155
|
+
case 'a-budget-is-a-policy':
|
|
1156
|
+
return decline.measuredUsd === null
|
|
1157
|
+
? t.init.budgetIsPolicy()
|
|
1158
|
+
: t.init.budgetIsPolicyMeasured(decline.measuredUsd.toFixed(2), decline.days ?? 0);
|
|
1159
|
+
}
|
|
1160
|
+
}
|
|
1161
|
+
/**
|
|
1162
|
+
* `trazum init [dir]` — the first five minutes.
|
|
1163
|
+
*
|
|
1164
|
+
* The floor, not the ceiling. Everything else in this tool assumes you know
|
|
1165
|
+
* which of twenty-two commands answers your question; this one assumes you
|
|
1166
|
+
* have just typed `npx @trazum/cli` and have thirty seconds of patience left.
|
|
1167
|
+
*
|
|
1168
|
+
* It is a **detection, not a wizard**. Nothing is asked. Each step reports
|
|
1169
|
+
* what it found and moves on, and the only decision is whether to write the
|
|
1170
|
+
* file — which `--yes` skips and `--dry-run` refuses. A first run that
|
|
1171
|
+
* interrogates somebody is a first run that gets abandoned halfway.
|
|
1172
|
+
*
|
|
1173
|
+
* The judgement lives in `proposeInit`, in the core, with no filesystem
|
|
1174
|
+
* anywhere near it. This function's whole job is to *look*: walk for prompts,
|
|
1175
|
+
* read a few source files, notice a log or a credential, and hand the lot over
|
|
1176
|
+
* as data. That split is why `--dry-run` cannot drift from the real thing —
|
|
1177
|
+
* they are the same call, and one of them stops before `writeFile`.
|
|
1178
|
+
*/
|
|
1179
|
+
async function commandInit(args, config, pricing, t) {
|
|
1180
|
+
const root = args.positional[0] ?? '.';
|
|
1181
|
+
const dryRun = args.flags.get('dry-run') === true;
|
|
1182
|
+
const asJson = args.flags.get('json') === true;
|
|
1183
|
+
// --- what is here -------------------------------------------------------
|
|
1184
|
+
const host = detectHost();
|
|
1185
|
+
const prompts = await walkPrompts(root, {
|
|
1186
|
+
extensions: config.extensions ?? DEFAULT_EXTENSIONS,
|
|
1187
|
+
});
|
|
1188
|
+
/**
|
|
1189
|
+
* Source files, read only to be asked which provider they call.
|
|
1190
|
+
*
|
|
1191
|
+
* Capped hard and deliberately low. `where` reads one file because somebody
|
|
1192
|
+
* named it; this reads whatever is lying around, and a first run that spends
|
|
1193
|
+
* forty seconds walking a monorepo has already lost. The cap is reported
|
|
1194
|
+
* when it bites, because "no provider found" and "stopped looking" are
|
|
1195
|
+
* different sentences.
|
|
1196
|
+
*/
|
|
1197
|
+
const sourceWalk = await walkPrompts(root, {
|
|
1198
|
+
extensions: SOURCE_EXTENSIONS,
|
|
1199
|
+
maxFiles: INIT_MAX_SOURCE_FILES,
|
|
1200
|
+
});
|
|
1201
|
+
const sightings = [];
|
|
1202
|
+
for (const relative of sourceWalk.files) {
|
|
1203
|
+
const path = join(root, relative);
|
|
1204
|
+
let source;
|
|
1205
|
+
/**
|
|
1206
|
+
* Measured and read through **one open handle**, not by path twice.
|
|
1207
|
+
*
|
|
1208
|
+
* A bundle or a lockfile named `.js` is not worth reading, and reading it
|
|
1209
|
+
* is how this command becomes slow on exactly the repositories that need
|
|
1210
|
+
* it most — so the size is checked first. Checking it with `stat(path)`
|
|
1211
|
+
* and then reading `path` is two lookups of the same name, and what
|
|
1212
|
+
* arrives the second time need not be what was measured the first: the
|
|
1213
|
+
* bound would be enforced against a file that is no longer there. One
|
|
1214
|
+
* handle, stat'ed and read, is the same inode by construction.
|
|
1215
|
+
*/
|
|
1216
|
+
let handle;
|
|
1217
|
+
try {
|
|
1218
|
+
handle = await open(path, 'r');
|
|
1219
|
+
}
|
|
1220
|
+
catch {
|
|
1221
|
+
continue;
|
|
1222
|
+
}
|
|
1223
|
+
try {
|
|
1224
|
+
const info = await handle.stat();
|
|
1225
|
+
if (info.size > INIT_MAX_SOURCE_BYTES)
|
|
1226
|
+
continue;
|
|
1227
|
+
source = await handle.readFile('utf8');
|
|
1228
|
+
}
|
|
1229
|
+
catch {
|
|
1230
|
+
continue;
|
|
1231
|
+
}
|
|
1232
|
+
finally {
|
|
1233
|
+
await handle.close();
|
|
1234
|
+
}
|
|
1235
|
+
const detection = detectFromSource(source, { models: pricing.models });
|
|
1236
|
+
if (detection.provider !== null || detection.model !== null || detection.conflicts.length > 0) {
|
|
1237
|
+
sightings.push({ file: relative, detection });
|
|
1238
|
+
}
|
|
1239
|
+
}
|
|
1240
|
+
// --- where the usage is, if it is anywhere ------------------------------
|
|
1241
|
+
const usage = [];
|
|
1242
|
+
for (const candidate of INIT_LOG_CANDIDATES) {
|
|
1243
|
+
/**
|
|
1244
|
+
* An existence check and nothing more — what is recorded is the *name*
|
|
1245
|
+
* that was tried, and whether it is a file or a directory. Anything read
|
|
1246
|
+
* later is opened then, on its own terms, so there is no measurement here
|
|
1247
|
+
* for a later read to disagree with.
|
|
1248
|
+
*/
|
|
1249
|
+
try {
|
|
1250
|
+
const info = await stat(join(root, candidate));
|
|
1251
|
+
usage.push({
|
|
1252
|
+
kind: info.isDirectory() ? 'log-directory' : 'log-file',
|
|
1253
|
+
where: candidate,
|
|
1254
|
+
provider: null,
|
|
1255
|
+
});
|
|
1256
|
+
}
|
|
1257
|
+
catch {
|
|
1258
|
+
// Absent is the common case and not an error.
|
|
1259
|
+
}
|
|
1260
|
+
}
|
|
1261
|
+
try {
|
|
1262
|
+
const info = await stat(join(root, STORE_DIR));
|
|
1263
|
+
if (info.isDirectory()) {
|
|
1264
|
+
usage.push({ kind: 'store', where: STORE_DIR, provider: null });
|
|
1265
|
+
}
|
|
1266
|
+
}
|
|
1267
|
+
catch {
|
|
1268
|
+
// No store yet.
|
|
1269
|
+
}
|
|
1270
|
+
/**
|
|
1271
|
+
* A credential is named by its **variable**, never read.
|
|
1272
|
+
*
|
|
1273
|
+
* `findCredential` returns the value as well because the connector needs it;
|
|
1274
|
+
* this takes the name and drops the rest on the floor. A first-run summary
|
|
1275
|
+
* is the single most likely output in this product to be pasted into a chat
|
|
1276
|
+
* window, and the rule that has held since 1.41 holds here.
|
|
1277
|
+
*/
|
|
1278
|
+
for (const connector of CONNECTORS) {
|
|
1279
|
+
const found = findCredential(connector, process.env);
|
|
1280
|
+
if (found !== null) {
|
|
1281
|
+
usage.push({
|
|
1282
|
+
kind: 'connector-credential',
|
|
1283
|
+
where: found.source.variable,
|
|
1284
|
+
provider: connector.id,
|
|
1285
|
+
});
|
|
1286
|
+
}
|
|
1287
|
+
}
|
|
1288
|
+
// --- read what can be read ----------------------------------------------
|
|
1289
|
+
let measured = null;
|
|
1290
|
+
let unreadable = null;
|
|
1291
|
+
const readable = usage.find((u) => u.kind === 'log-file' || u.kind === 'log-directory');
|
|
1292
|
+
if (readable !== undefined) {
|
|
1293
|
+
try {
|
|
1294
|
+
const files = readable.kind === 'log-file'
|
|
1295
|
+
? [join(root, readable.where)]
|
|
1296
|
+
: (await readdir(join(root, readable.where)))
|
|
1297
|
+
.filter((name) => LOG_EXTENSIONS.some((extension) => name.endsWith(extension)))
|
|
1298
|
+
.sort()
|
|
1299
|
+
.map((name) => join(root, readable.where, name));
|
|
1300
|
+
if (files.length > 0) {
|
|
1301
|
+
const texts = await Promise.all(files.map((file) => readUsageLog(file, t)));
|
|
1302
|
+
measured = profileUsage(texts.join('\n'), { catalogue: pricing });
|
|
1303
|
+
}
|
|
1304
|
+
}
|
|
1305
|
+
catch (error) {
|
|
1306
|
+
// Named, never swallowed. A log that is there and cannot be read is the
|
|
1307
|
+
// single most useful thing this command can tell somebody, and treating
|
|
1308
|
+
// it as "no usage found" would send them to configure a connector they
|
|
1309
|
+
// do not need.
|
|
1310
|
+
unreadable = {
|
|
1311
|
+
where: readable.where,
|
|
1312
|
+
because: error instanceof Error ? error.message : String(error),
|
|
1313
|
+
};
|
|
1314
|
+
}
|
|
1315
|
+
}
|
|
1316
|
+
// --- the config already there -------------------------------------------
|
|
1317
|
+
const configPath = join(root, CONFIG_FILENAME);
|
|
1318
|
+
let existing = null;
|
|
1319
|
+
try {
|
|
1320
|
+
existing = { path: configPath, config: parseConfig(await readFile(configPath, 'utf8'), configPath) };
|
|
1321
|
+
}
|
|
1322
|
+
catch {
|
|
1323
|
+
// Absent, or unparseable. Either way there is nothing to compare against,
|
|
1324
|
+
// and `init` refuses to overwrite below rather than reasoning about it.
|
|
1325
|
+
}
|
|
1326
|
+
let unparseable = false;
|
|
1327
|
+
if (existing === null) {
|
|
1328
|
+
try {
|
|
1329
|
+
await stat(configPath);
|
|
1330
|
+
unparseable = true;
|
|
1331
|
+
}
|
|
1332
|
+
catch {
|
|
1333
|
+
// Genuinely absent.
|
|
1334
|
+
}
|
|
1335
|
+
}
|
|
1336
|
+
const askedLocale = matchLocale(LOCALE_ENV_VARS.map((name) => process.env[name]).find((value) => matchLocale(value)));
|
|
1337
|
+
const proposal = proposeInit({
|
|
1338
|
+
host,
|
|
1339
|
+
sightings,
|
|
1340
|
+
promptFiles: prompts.files,
|
|
1341
|
+
usage,
|
|
1342
|
+
measured,
|
|
1343
|
+
locale: askedLocale ?? null,
|
|
1344
|
+
existing,
|
|
1345
|
+
}, { catalogue: pricing });
|
|
1346
|
+
if (asJson) {
|
|
1347
|
+
console.log(JSON.stringify({ ...proposal, unreadable, truncated: sourceWalk.truncated }, null, 2));
|
|
1348
|
+
return;
|
|
1349
|
+
}
|
|
1350
|
+
renderInit(proposal, { host, prompts, usage, unreadable, truncated: sourceWalk.truncated, t, pricing });
|
|
1351
|
+
// --- writing it ---------------------------------------------------------
|
|
1352
|
+
//
|
|
1353
|
+
// Three ways this ends and they are kept apart: nothing to write, refused to
|
|
1354
|
+
// overwrite, written. "Nothing happened" with no reason is the output that
|
|
1355
|
+
// makes somebody run the command twice.
|
|
1356
|
+
if (Object.keys(proposal.config).length === 0) {
|
|
1357
|
+
console.log(c.dim(t.init.nothingToWrite()));
|
|
1358
|
+
console.log();
|
|
1359
|
+
return;
|
|
1360
|
+
}
|
|
1361
|
+
const body = `${JSON.stringify(proposal.config, null, 2)}\n`;
|
|
1362
|
+
if (dryRun) {
|
|
1363
|
+
console.log(c.bold(t.init.wouldWrite(configPath)));
|
|
1364
|
+
console.log();
|
|
1365
|
+
console.log(body.trimEnd());
|
|
1366
|
+
console.log();
|
|
1367
|
+
return;
|
|
1368
|
+
}
|
|
1369
|
+
if (unparseable) {
|
|
1370
|
+
console.log(c.yellow(t.init.existingUnparseable(configPath)));
|
|
1371
|
+
console.log();
|
|
1372
|
+
return;
|
|
1373
|
+
}
|
|
1374
|
+
if (existing !== null && args.flags.get('yes') !== true) {
|
|
1375
|
+
console.log(c.yellow(t.init.existingRefused(configPath)));
|
|
1376
|
+
console.log();
|
|
1377
|
+
return;
|
|
1378
|
+
}
|
|
1379
|
+
await writeFile(configPath, body, 'utf8');
|
|
1380
|
+
console.log(c.green(t.init.wrote(configPath)));
|
|
1381
|
+
console.log();
|
|
1382
|
+
}
|
|
1010
1383
|
function commandModels(t, pricing) {
|
|
1011
1384
|
const n = (value) => value.toLocaleString(t.numberLocale);
|
|
1012
1385
|
const col = t.models.columns;
|
|
@@ -2322,19 +2695,21 @@ async function commandVerify(args, pricing, t) {
|
|
|
2322
2695
|
const againstPath = stringFlag(args, 'against');
|
|
2323
2696
|
if (againstPath === undefined)
|
|
2324
2697
|
throw new Error(t.verify.needsAgainst());
|
|
2325
|
-
|
|
2326
|
-
|
|
2327
|
-
|
|
2328
|
-
|
|
2329
|
-
|
|
2330
|
-
|
|
2331
|
-
|
|
2332
|
-
|
|
2333
|
-
|
|
2334
|
-
|
|
2335
|
-
|
|
2336
|
-
|
|
2698
|
+
/**
|
|
2699
|
+
* One validator, shared with the browser since 1.47.
|
|
2700
|
+
*
|
|
2701
|
+
* The check here used to be `schemaVersion === 1 && Array.isArray(actions)`
|
|
2702
|
+
* and nothing more, which accepts a file whose actions are arbitrary
|
|
2703
|
+
* objects — `verifyPlan` would then read `label` off `undefined`, match it
|
|
2704
|
+
* against no slice, and report `cannot-tell: workload-vanished` for every
|
|
2705
|
+
* one. A verification of a document that was never a plan, rendered exactly
|
|
2706
|
+
* like a real one.
|
|
2707
|
+
*/
|
|
2708
|
+
const parsed = parsePlanDocument(await readFile(planPath, 'utf8'));
|
|
2709
|
+
if (!parsed.ok) {
|
|
2710
|
+
throw new Error(t.verify.badPlan(planPath, t.verify.planRefusal(parsed.why)));
|
|
2337
2711
|
}
|
|
2712
|
+
const plan = parsed.plan;
|
|
2338
2713
|
const GZ = LOG_EXTENSIONS.map((ext) => `${ext}.gz`);
|
|
2339
2714
|
const READABLE = [...LOG_EXTENSIONS, ...GZ];
|
|
2340
2715
|
const target = await stat(againstPath).catch(() => null);
|
|
@@ -5651,7 +6026,35 @@ async function main() {
|
|
|
5651
6026
|
// flag validation so a typo is reported before any file is touched. An
|
|
5652
6027
|
// invalid config throws here rather than quietly reverting to defaults —
|
|
5653
6028
|
// "defaults" for a budget means "no budget", which means a green build.
|
|
5654
|
-
|
|
6029
|
+
/**
|
|
6030
|
+
* `init` is the exception, and finding out why was worth the release.
|
|
6031
|
+
*
|
|
6032
|
+
* A malformed `trazum.config.json` throws here — correctly, for every other
|
|
6033
|
+
* command, because "defaults" for a budget means "no budget" and a silent
|
|
6034
|
+
* revert to defaults is a green build that should have been red. But `init`
|
|
6035
|
+
* is the command somebody runs *because* their setup is broken, and it was
|
|
6036
|
+
* the one command a broken setup could stop from running. The refusal to
|
|
6037
|
+
* overwrite an unparseable config, written two hours earlier in this same
|
|
6038
|
+
* release, was unreachable code standing behind a throw.
|
|
6039
|
+
*
|
|
6040
|
+
* So `init` loads the config the same way and survives the failure, with
|
|
6041
|
+
* nothing carried forward: no keys, no budgets, no locale. It then refuses
|
|
6042
|
+
* to write over the file it could not read, and says so.
|
|
6043
|
+
*/
|
|
6044
|
+
let loaded;
|
|
6045
|
+
try {
|
|
6046
|
+
loaded = await loadConfig({ explicit: stringFlag(args, 'config') });
|
|
6047
|
+
}
|
|
6048
|
+
catch (error) {
|
|
6049
|
+
if (args.command !== 'init')
|
|
6050
|
+
throw error;
|
|
6051
|
+
loaded = {
|
|
6052
|
+
config: {},
|
|
6053
|
+
path: null,
|
|
6054
|
+
pricing: BUNDLED_CATALOGUE,
|
|
6055
|
+
pricingPath: null,
|
|
6056
|
+
};
|
|
6057
|
+
}
|
|
5655
6058
|
const { config } = loaded;
|
|
5656
6059
|
const pricing = await pricingFor(args, loaded, t);
|
|
5657
6060
|
// The config only gets to choose the locale when nothing more explicit did.
|
|
@@ -5708,6 +6111,9 @@ async function main() {
|
|
|
5708
6111
|
case 'models':
|
|
5709
6112
|
commandModels(t, pricing);
|
|
5710
6113
|
break;
|
|
6114
|
+
case 'init':
|
|
6115
|
+
await commandInit(args, config, pricing, t);
|
|
6116
|
+
break;
|
|
5711
6117
|
case 'where':
|
|
5712
6118
|
await commandWhere(args, config, pricing, t);
|
|
5713
6119
|
break;
|