insta 0.0.33 → 0.0.34
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 +1 -0
- package/dist/commands/feedback.js +232 -0
- package/dist/index.js +18 -0
- package/dist/redact.js +78 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -184,6 +184,7 @@ build never reaches a production installer.
|
|
|
184
184
|
| `insta approvals` | `list` · `approve` · `deny` |
|
|
185
185
|
| `insta policy` | `get` · `set <action> <decision>` |
|
|
186
186
|
| `insta observe` | `install` · `uninstall` · `report` · `sync` — local credential audit |
|
|
187
|
+
| `insta feedback` | Report an InstaCloud-side hurdle (bug / feature-request / friction) to the team — never for the app you are building; works logged-out |
|
|
187
188
|
| `insta upgrade` · `autoupdate` | Update the CLI; show or set auto-update |
|
|
188
189
|
|
|
189
190
|
## Configuration
|
|
@@ -0,0 +1,232 @@
|
|
|
1
|
+
// `insta feedback` — report an InstaCloud-side hurdle to the InstaCloud team.
|
|
2
|
+
//
|
|
3
|
+
// Scope rule (also stated in the skill): this is for problems in OUR toolkit — the CLI, the MCP
|
|
4
|
+
// server, the platform, the skills, the docs. Never for problems in the app the user is building.
|
|
5
|
+
//
|
|
6
|
+
// The backend is InstaCloud dogfooding itself: the "InstaCloud Agent Feedback" project runs the
|
|
7
|
+
// ingest service (InsForge/insta-feedback repo) on a postgres + compute pair. It is NOT the
|
|
8
|
+
// control-plane API on purpose — feedback must work logged-out, unlinked, and from insta-oss,
|
|
9
|
+
// and a control-plane outage is exactly when we most want reports to still arrive.
|
|
10
|
+
import { readFileSync, statSync } from 'node:fs';
|
|
11
|
+
import os from 'node:os';
|
|
12
|
+
import * as clack from '@clack/prompts';
|
|
13
|
+
import { readGlobal, readProject } from '../config.js';
|
|
14
|
+
import { envForApiUrl } from '../env.js';
|
|
15
|
+
import { info, printJson } from '../util.js';
|
|
16
|
+
import { clean } from '../redact.js';
|
|
17
|
+
export const TYPES = ['bug', 'feature-request', 'friction', 'other'];
|
|
18
|
+
export const COMPONENTS = ['cli', 'mcp', 'platform', 'skills', 'docs', 'other'];
|
|
19
|
+
export const SEVERITIES = ['blocker', 'major', 'minor'];
|
|
20
|
+
// Field caps mirror the ingest service's LIMITS (insta-feedback src/app.ts) — the server
|
|
21
|
+
// truncates again, so a mismatch degrades gracefully instead of rejecting.
|
|
22
|
+
export const LIMITS = {
|
|
23
|
+
title: 200,
|
|
24
|
+
detail: 4000,
|
|
25
|
+
area: 100,
|
|
26
|
+
command: 500,
|
|
27
|
+
error: 2000,
|
|
28
|
+
expected: 1000,
|
|
29
|
+
workaround: 1000,
|
|
30
|
+
doc: 300,
|
|
31
|
+
};
|
|
32
|
+
// Hardcoded in source, not injected at build time: a build-time credential silently no-ops in
|
|
33
|
+
// local/tsx and fork builds, and feedback would appear to work while reports vanish. The token is
|
|
34
|
+
// public by design (it ships in this file); it only deflects drive-by scanners — real abuse
|
|
35
|
+
// control is server-side (per-IP rate limit + weekly dedup). Env overrides are for tests and
|
|
36
|
+
// emergency rotation.
|
|
37
|
+
const FEEDBACK_ENDPOINT = process.env.INSTA_FEEDBACK_URL ||
|
|
38
|
+
'https://insta-main-api-cdad9b6c.compute.instacloud.com/v1/feedback';
|
|
39
|
+
const FEEDBACK_INGEST_TOKEN = process.env.INSTA_FEEDBACK_TOKEN || 'insta-feedback-public-v1';
|
|
40
|
+
const FEEDBACK_TIMEOUT_MS = 10_000;
|
|
41
|
+
const MAX_FILE_BYTES = 256 * 1024;
|
|
42
|
+
function resolveCliVersion() {
|
|
43
|
+
// Same resolution as index.ts: the standalone binary bakes INSTA_CLI_VERSION via --define;
|
|
44
|
+
// npm/node reads the installed package.json next to dist/.
|
|
45
|
+
if (process.env.INSTA_CLI_VERSION)
|
|
46
|
+
return process.env.INSTA_CLI_VERSION;
|
|
47
|
+
try {
|
|
48
|
+
return JSON.parse(readFileSync(new URL('../../package.json', import.meta.url), 'utf8')).version;
|
|
49
|
+
}
|
|
50
|
+
catch {
|
|
51
|
+
return '0.0.0';
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
function requireEnum(value, allowed, flag) {
|
|
55
|
+
if (!allowed.includes(value)) {
|
|
56
|
+
throw new Error(`${flag} must be one of: ${allowed.join(', ')}`);
|
|
57
|
+
}
|
|
58
|
+
return value;
|
|
59
|
+
}
|
|
60
|
+
async function promptMissing(opts) {
|
|
61
|
+
clack.intro('insta feedback — report an InstaCloud-side hurdle');
|
|
62
|
+
if (!opts.type) {
|
|
63
|
+
const answer = await clack.select({
|
|
64
|
+
message: 'What kind of hurdle did you hit?',
|
|
65
|
+
options: [
|
|
66
|
+
{ value: 'bug', label: 'bug — something InstaCloud should do, but does not' },
|
|
67
|
+
{ value: 'feature-request', label: 'feature-request — something InstaCloud does not support yet' },
|
|
68
|
+
{ value: 'friction', label: 'friction — works, but confusing or awkward' },
|
|
69
|
+
{ value: 'other', label: 'other' },
|
|
70
|
+
],
|
|
71
|
+
});
|
|
72
|
+
if (clack.isCancel(answer))
|
|
73
|
+
process.exit(0);
|
|
74
|
+
opts.type = answer;
|
|
75
|
+
}
|
|
76
|
+
if (!opts.component) {
|
|
77
|
+
const answer = await clack.select({
|
|
78
|
+
message: 'Where in the InstaCloud toolkit is the issue?',
|
|
79
|
+
options: COMPONENTS.map((c) => ({ value: c, label: c })),
|
|
80
|
+
});
|
|
81
|
+
if (clack.isCancel(answer))
|
|
82
|
+
process.exit(0);
|
|
83
|
+
opts.component = answer;
|
|
84
|
+
}
|
|
85
|
+
if (!opts.title) {
|
|
86
|
+
const answer = await clack.text({
|
|
87
|
+
message: 'One-line summary:',
|
|
88
|
+
validate: (v) => (v.trim() ? undefined : 'required'),
|
|
89
|
+
});
|
|
90
|
+
if (clack.isCancel(answer))
|
|
91
|
+
process.exit(0);
|
|
92
|
+
opts.title = answer.trim();
|
|
93
|
+
}
|
|
94
|
+
if (!opts.detail && !opts.file) {
|
|
95
|
+
const answer = await clack.text({
|
|
96
|
+
message: 'What happened, and what did you expect?',
|
|
97
|
+
validate: (v) => (v.trim() ? undefined : 'required'),
|
|
98
|
+
});
|
|
99
|
+
if (clack.isCancel(answer))
|
|
100
|
+
process.exit(0);
|
|
101
|
+
opts.detail = answer.trim();
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
/** Pure payload assembly (unit-tested): validation, redaction, caps, and ambient context. */
|
|
105
|
+
export async function buildPayload(opts, ctx) {
|
|
106
|
+
const type = requireEnum(opts.type ?? '', TYPES, '--type');
|
|
107
|
+
const component = requireEnum(opts.component ?? '', COMPONENTS, '--component');
|
|
108
|
+
const severity = opts.severity ? requireEnum(opts.severity, SEVERITIES, '--severity') : 'minor';
|
|
109
|
+
let detail = opts.detail;
|
|
110
|
+
if (!detail && opts.file) {
|
|
111
|
+
try {
|
|
112
|
+
// detail is capped at 4000 chars — a file far beyond that is a mistake (wrong path, a log
|
|
113
|
+
// archive, a binary), so refuse before allocating it rather than truncating garbage.
|
|
114
|
+
// Regular files only: a FIFO/device node (e.g. /dev/zero) stats as size 0, sails past the
|
|
115
|
+
// byte ceiling, and then readFileSync reads unbounded.
|
|
116
|
+
const stat = statSync(opts.file);
|
|
117
|
+
if (!stat.isFile())
|
|
118
|
+
throw new Error(`--file ${opts.file} is not a regular file`);
|
|
119
|
+
const size = stat.size;
|
|
120
|
+
if (size > MAX_FILE_BYTES) {
|
|
121
|
+
throw new Error(`--file ${opts.file} is ${size} bytes — max ${MAX_FILE_BYTES} (detail is capped at ${LIMITS.detail} chars; trim the file first)`);
|
|
122
|
+
}
|
|
123
|
+
detail = readFileSync(opts.file, 'utf8');
|
|
124
|
+
if (detail.includes('\0'))
|
|
125
|
+
throw new Error(`--file ${opts.file} looks binary — feedback detail must be text`);
|
|
126
|
+
}
|
|
127
|
+
catch (e) {
|
|
128
|
+
const msg = e instanceof Error ? e.message : String(e);
|
|
129
|
+
throw new Error(msg.startsWith('--file') ? msg : `--file ${opts.file}: ${msg}`);
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
const title = clean(opts.title, LIMITS.title);
|
|
133
|
+
if (!title)
|
|
134
|
+
throw new Error('--title is required (one-line summary, ≤200 chars)');
|
|
135
|
+
const cleanedDetail = clean(detail, LIMITS.detail);
|
|
136
|
+
if (!cleanedDetail)
|
|
137
|
+
throw new Error('--detail (or --file <path>) is required: what happened vs what you expected');
|
|
138
|
+
const project = await readProject();
|
|
139
|
+
const { apiUrl } = await readGlobal();
|
|
140
|
+
// envForApiUrl → null means a custom host: insta-oss or a preview deployment (see env.ts).
|
|
141
|
+
const target = envForApiUrl(apiUrl) ? 'cloud' : 'oss';
|
|
142
|
+
return {
|
|
143
|
+
type,
|
|
144
|
+
component,
|
|
145
|
+
severity,
|
|
146
|
+
title,
|
|
147
|
+
detail: cleanedDetail,
|
|
148
|
+
area: clean(opts.area, LIMITS.area),
|
|
149
|
+
command: clean(opts.command, LIMITS.command),
|
|
150
|
+
error: clean(opts.error, LIMITS.error),
|
|
151
|
+
expected: clean(opts.expected, LIMITS.expected),
|
|
152
|
+
workaround: clean(opts.workaround, LIMITS.workaround),
|
|
153
|
+
doc_ref: clean(opts.doc, LIMITS.doc),
|
|
154
|
+
source: 'cli',
|
|
155
|
+
target,
|
|
156
|
+
client_version: ctx.cliVersion,
|
|
157
|
+
node_version: process.version,
|
|
158
|
+
os: `${os.platform()} ${os.release()}`,
|
|
159
|
+
project_id: project?.projectId,
|
|
160
|
+
org_id: project?.orgId,
|
|
161
|
+
branch: project?.branch,
|
|
162
|
+
};
|
|
163
|
+
}
|
|
164
|
+
/** One POST, 10s timeout, zero retries — feedback is a side quest and must never hang the CLI.
|
|
165
|
+
* Transport and server failures come back as a result, not an exception: the caller downgrades
|
|
166
|
+
* them to a warning so a broken feedback backend can't fail the user's actual task. */
|
|
167
|
+
export async function submit(payload, fetchImpl) {
|
|
168
|
+
let res;
|
|
169
|
+
try {
|
|
170
|
+
res = await fetchImpl(FEEDBACK_ENDPOINT, {
|
|
171
|
+
method: 'POST',
|
|
172
|
+
headers: {
|
|
173
|
+
'Content-Type': 'application/json',
|
|
174
|
+
Authorization: `Bearer ${FEEDBACK_INGEST_TOKEN}`,
|
|
175
|
+
},
|
|
176
|
+
body: JSON.stringify(payload),
|
|
177
|
+
signal: AbortSignal.timeout(FEEDBACK_TIMEOUT_MS),
|
|
178
|
+
});
|
|
179
|
+
}
|
|
180
|
+
catch (e) {
|
|
181
|
+
const timedOut = e instanceof Error && e.name === 'TimeoutError';
|
|
182
|
+
return { status: 'error', error: timedOut ? `timed out after ${FEEDBACK_TIMEOUT_MS / 1000}s` : `network error: ${e instanceof Error ? e.message : String(e)}` };
|
|
183
|
+
}
|
|
184
|
+
let body = {};
|
|
185
|
+
try {
|
|
186
|
+
body = await res.json();
|
|
187
|
+
}
|
|
188
|
+
catch { /* non-JSON body — fall through to status handling */ }
|
|
189
|
+
if (!res.ok)
|
|
190
|
+
return { status: 'error', error: body?.error ?? `HTTP ${res.status}` };
|
|
191
|
+
return { status: body?.status === 'duplicate' ? 'duplicate' : 'received', id: body?.id ?? null };
|
|
192
|
+
}
|
|
193
|
+
export async function feedback(opts, deps = {}) {
|
|
194
|
+
const interactive = deps.interactive ?? (!opts.json && !!process.stdin.isTTY && !!process.stdout.isTTY);
|
|
195
|
+
const missingRequired = !opts.type || !opts.component || !opts.title || (!opts.detail && !opts.file);
|
|
196
|
+
if (missingRequired && interactive)
|
|
197
|
+
await promptMissing(opts);
|
|
198
|
+
// Bad/missing input exits 1 either way — an agent CAN fix its flags, so the error must be loud
|
|
199
|
+
// and self-teaching (it lists the exact enum values). But it must arrive on the channel the
|
|
200
|
+
// caller chose: --json gets a machine-readable object on stdout (uniform with the success and
|
|
201
|
+
// transport-failure shapes) instead of guard()'s plaintext stderr line.
|
|
202
|
+
let payload;
|
|
203
|
+
try {
|
|
204
|
+
payload = await buildPayload(opts, { cliVersion: deps.cliVersion ?? resolveCliVersion() });
|
|
205
|
+
}
|
|
206
|
+
catch (e) {
|
|
207
|
+
if (!opts.json)
|
|
208
|
+
throw e;
|
|
209
|
+
printJson({ status: 'error', submitted: false, error: e instanceof Error ? e.message : String(e) });
|
|
210
|
+
process.exitCode = 1;
|
|
211
|
+
return;
|
|
212
|
+
}
|
|
213
|
+
const result = await submit(payload, deps.fetchImpl ?? fetch);
|
|
214
|
+
if (result.status === 'error') {
|
|
215
|
+
// Deliberate exit 0: an agent CANNOT fix a down/rate-limited backend, and feedback must never
|
|
216
|
+
// fail or distract from the task the user actually asked for. Do not retry.
|
|
217
|
+
if (opts.json)
|
|
218
|
+
return printJson({ status: 'error', submitted: false, error: result.error });
|
|
219
|
+
process.stderr.write(`warning: feedback not submitted (${result.error}) — continue with your task, do not retry\n`);
|
|
220
|
+
return;
|
|
221
|
+
}
|
|
222
|
+
if (opts.json)
|
|
223
|
+
return printJson({ status: result.status, id: result.id });
|
|
224
|
+
if (result.status === 'duplicate') {
|
|
225
|
+
info(`already reported this week — bumped its count instead (id: ${result.id})`);
|
|
226
|
+
}
|
|
227
|
+
else {
|
|
228
|
+
info(`feedback submitted (id: ${result.id}) — thank you!`);
|
|
229
|
+
}
|
|
230
|
+
info('PII (emails, tokens, keys, home paths) was redacted before sending.');
|
|
231
|
+
}
|
|
232
|
+
//# sourceMappingURL=feedback.js.map
|
package/dist/index.js
CHANGED
|
@@ -26,6 +26,7 @@ import * as observe from './commands/observe.js';
|
|
|
26
26
|
import * as obs from './commands/metrics.js';
|
|
27
27
|
import { billing, billingUpgrade, billingPortal } from './commands/billing.js';
|
|
28
28
|
import * as selfUpdate from './commands/upgrade.js';
|
|
29
|
+
import * as feedbackCmd from './commands/feedback.js';
|
|
29
30
|
function onError(e) {
|
|
30
31
|
if (e instanceof ApiError)
|
|
31
32
|
die(`${e.message} (HTTP ${e.status})`);
|
|
@@ -259,6 +260,23 @@ ob.command('sync').description('Upload findings into the project timeline').acti
|
|
|
259
260
|
const pol = program.command('policy').description('Governance policy');
|
|
260
261
|
pol.command('get').option('--json').action(guard((o) => govern.policyGet(o)));
|
|
261
262
|
pol.command('set <action> <decision>').description('action: secrets.read|secrets.write|deploy|project.delete|branch.delete|service.add|service.remove|service.scale|service.upgrade|service.setAccess|storage.read|storage.write|storage.delete; decision: allow|deny|approve').action(guard((a, d) => govern.policySet(a, d)));
|
|
263
|
+
// ---- feedback (agent + human hurdle reports → the InstaCloud team) ----
|
|
264
|
+
program.command('feedback')
|
|
265
|
+
.description('Report an InstaCloud-side hurdle (bug / missing feature / friction) to the InstaCloud team — about the insta toolkit itself, NEVER about the app you are building. Works logged-out and unlinked.')
|
|
266
|
+
.option('--type <type>', `what kind of hurdle: ${feedbackCmd.TYPES.join(' | ')}`)
|
|
267
|
+
.option('--component <component>', `which part of the toolkit: ${feedbackCmd.COMPONENTS.join(' | ')}`)
|
|
268
|
+
.option('--title <title>', 'one-line summary (≤200 chars)')
|
|
269
|
+
.option('--detail <text>', 'what happened vs what you expected (≤4000 chars)')
|
|
270
|
+
.option('--file <path>', 'read the detail from a file instead of --detail')
|
|
271
|
+
.option('--area <area>', 'product area, free text: deploy, branch, secrets, db, storage, compute, governance, billing, …')
|
|
272
|
+
.option('--command <cmd>', 'the insta command that hit the issue')
|
|
273
|
+
.option('--error <text>', 'error output (redacted + truncated locally before sending)')
|
|
274
|
+
.option('--expected <text>', 'what the docs/skill said should happen')
|
|
275
|
+
.option('--workaround <text>', 'what you did instead, if anything worked')
|
|
276
|
+
.option('--doc <ref>', 'doc or skill file that led you here (for stale-instruction reports)')
|
|
277
|
+
.option('--severity <severity>', `${feedbackCmd.SEVERITIES.join(' | ')} (default: minor)`)
|
|
278
|
+
.option('--json')
|
|
279
|
+
.action(guard((o) => feedbackCmd.feedback(o)));
|
|
262
280
|
// ---- self-update ----
|
|
263
281
|
program.command('upgrade').description('Update the insta CLI to the latest release (binary or npm install)')
|
|
264
282
|
.action(guard(() => selfUpdate.upgrade()));
|
package/dist/redact.js
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
// Local PII redaction for outbound feedback text. Pattern-based: a safety net, not a license to
|
|
2
|
+
// paste credentials. The ingest service re-scrubs server-side with the same patterns
|
|
3
|
+
// (insta-feedback repo), so a drift here is caught by the second pass. IPv6 and phone numbers are
|
|
4
|
+
// deliberately not matched — the false-positive rate against UUIDs and hashes destroys the
|
|
5
|
+
// diagnostic value of error text.
|
|
6
|
+
const PATTERNS = [
|
|
7
|
+
// URL-embedded credentials: scheme://user:pass@host (DATABASE_URLs pasted into error output)
|
|
8
|
+
[/(\w+:\/\/)[^\s/@:]+:[^\s/@]+@/g, '$1[REDACTED]@'],
|
|
9
|
+
// JWTs
|
|
10
|
+
[/eyJ[\w-]{10,}\.[\w-]{10,}\.[\w-]{5,}/g, '[REDACTED_JWT]'],
|
|
11
|
+
// Bearer tokens
|
|
12
|
+
[/\b[Bb]earer\s+[\w~+/.=-]{8,}/g, 'Bearer [REDACTED]'],
|
|
13
|
+
// insta_ platform tokens are insta_ + a ≥24-char Better Auth apiKey. The tail must stay ≥24:
|
|
14
|
+
// MCP tool names (insta_feedback, insta_storage_download_url, …) share the prefix with tails
|
|
15
|
+
// up to 20 chars, and they are exactly what feedback text quotes most.
|
|
16
|
+
[/\binsta_[\w-]{24,}/g, '[REDACTED_KEY]'],
|
|
17
|
+
// Common third-party key prefixes
|
|
18
|
+
[/\b(?:uak_|sk_live_|sk_test_|whsec_|ghp_|github_pat_|npm_|AIza|xox[a-z]-)[\w-]{6,}/g, '[REDACTED_KEY]'],
|
|
19
|
+
[/\bsk-[\w-]{16,}/g, '[REDACTED_KEY]'],
|
|
20
|
+
[/\bAKIA[0-9A-Z]{12,}/g, '[REDACTED_KEY]'],
|
|
21
|
+
// Generic assignments: password=..., api_key: "..."
|
|
22
|
+
[/\b(password|passwd|pwd|secret|api[_-]?key|access[_-]?token|refresh[_-]?token)\b(\s*[:=]\s*)["']?[^\s"'&,;]{4,}["']?/gi, '$1$2[REDACTED]'],
|
|
23
|
+
// Emails
|
|
24
|
+
[/[\w.+-]+@[\w-]+\.[\w.-]+/g, '[REDACTED_EMAIL]'],
|
|
25
|
+
// Home directories carry the username (unix + windows)
|
|
26
|
+
[/\/(?:Users|home)\/[\w.-]+/g, '~'],
|
|
27
|
+
[/[A-Z]:[\\/]Users[\\/][\w.-]+/g, '~'],
|
|
28
|
+
];
|
|
29
|
+
// Public IPv4 only — private/loopback ranges are kept for their debug value.
|
|
30
|
+
// Known asymmetry with the IPv6/phone exemption above: a 4-part version or build string whose
|
|
31
|
+
// octets all fit 0–255 (e.g. "25.2.0.100") gets over-scrubbed. Accepted deliberately — dotted
|
|
32
|
+
// quads in error text are overwhelmingly real addresses, an over-scrub is recoverable noise,
|
|
33
|
+
// and an under-scrub is a leak; anchoring on surrounding context would trade that for misses.
|
|
34
|
+
const IPV4 = /\b(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})\b/g;
|
|
35
|
+
function isPrivateIp(a, b) {
|
|
36
|
+
if (a === 10 || a === 127 || a === 0)
|
|
37
|
+
return true;
|
|
38
|
+
if (a === 192 && b === 168)
|
|
39
|
+
return true;
|
|
40
|
+
if (a === 172 && b >= 16 && b <= 31)
|
|
41
|
+
return true;
|
|
42
|
+
if (a === 169 && b === 254)
|
|
43
|
+
return true;
|
|
44
|
+
return false;
|
|
45
|
+
}
|
|
46
|
+
export function redactSensitive(text) {
|
|
47
|
+
let out = text;
|
|
48
|
+
for (const [re, sub] of PATTERNS)
|
|
49
|
+
out = out.replace(re, sub);
|
|
50
|
+
out = out.replace(IPV4, (m, a, b, c, d) => {
|
|
51
|
+
const [na, nb, nc, nd] = [Number(a), Number(b), Number(c), Number(d)];
|
|
52
|
+
if (na > 255 || nb > 255 || nc > 255 || nd > 255)
|
|
53
|
+
return m;
|
|
54
|
+
return isPrivateIp(na, nb) ? m : '[REDACTED_IP]';
|
|
55
|
+
});
|
|
56
|
+
return out;
|
|
57
|
+
}
|
|
58
|
+
/** Middle truncation keeping 60% head + 40% tail — the start of an error names the failure, the
|
|
59
|
+
* end carries the actual cause; the middle is usually a stack. */
|
|
60
|
+
export function truncateMiddle(text, max) {
|
|
61
|
+
if (text.length <= max)
|
|
62
|
+
return text;
|
|
63
|
+
const marker = `…[${text.length - max} chars truncated]…`;
|
|
64
|
+
const head = Math.floor(max * 0.6);
|
|
65
|
+
const tail = max - head;
|
|
66
|
+
return text.slice(0, head) + marker + text.slice(text.length - tail);
|
|
67
|
+
}
|
|
68
|
+
/** Redact BEFORE truncating: truncating first could leave half a token visible at the cut and the
|
|
69
|
+
* redaction pattern would no longer match it. */
|
|
70
|
+
export function clean(value, max) {
|
|
71
|
+
if (typeof value !== 'string')
|
|
72
|
+
return undefined;
|
|
73
|
+
const trimmed = value.trim();
|
|
74
|
+
if (!trimmed)
|
|
75
|
+
return undefined;
|
|
76
|
+
return truncateMiddle(redactSensitive(trimmed), max);
|
|
77
|
+
}
|
|
78
|
+
//# sourceMappingURL=redact.js.map
|