@sibylline-labs/eris 0.0.0-stage → 0.3.1
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/LICENSE +202 -0
- package/NOTICE +2 -0
- package/README.md +146 -2
- package/bin/eris.ts +42 -0
- package/docs/ARCHITECTURE.md +188 -0
- package/docs/COMPLETION.md +106 -0
- package/docs/ERRORS.md +632 -0
- package/docs/OUTPUT-CONTRACT.md +219 -0
- package/docs/README.md +30 -0
- package/docs/UPDATE.md +159 -0
- package/docs/YAK.md +395 -0
- package/docs/dev/ADDING-A-COMMAND.md +79 -0
- package/docs/dev/DEVELOPMENT.md +88 -0
- package/docs/dev/LAYOUT.md +74 -0
- package/docs/dev/PLUGINS.md +382 -0
- package/docs/dev/RELEASING.md +157 -0
- package/docs/dev/TESTING.md +84 -0
- package/docs/dev/surfaces/secrets.md +95 -0
- package/package.json +55 -4
- package/packages/yak-contract/package.json +28 -0
- package/packages/yak-contract/schema/yak-v2.schema.json +416 -0
- package/packages/yak-contract/src/index.ts +1 -0
- package/packages/yak-contract/src/v2.ts +375 -0
- package/plugins/index.ts +12 -0
- package/plugins/yak/handlers.ts +128 -0
- package/plugins/yak/index.ts +51 -0
- package/plugins/yak/login.ts +300 -0
- package/plugins/yak/yak-context.ts +280 -0
- package/plugins/yak/yak-identity.ts +399 -0
- package/plugins/yak/yak-redact.ts +686 -0
- package/plugins/yak/yak-setup.ts +133 -0
- package/plugins/yak/yak-submit.ts +1166 -0
- package/plugins/yak/yak.ts +485 -0
- package/src/build.ts +248 -0
- package/src/cli/args.ts +434 -0
- package/src/cli/commands/completion/bash.ts +24 -0
- package/src/cli/commands/completion/domain.ts +203 -0
- package/src/cli/commands/completion/emit.ts +246 -0
- package/src/cli/commands/completion/shell.ts +24 -0
- package/src/cli/commands/completion/zsh.ts +24 -0
- package/src/cli/commands/completion.ts +48 -0
- package/src/cli/commands/update/github.ts +328 -0
- package/src/cli/commands/update/opt-in.ts +32 -0
- package/src/cli/commands/update/refusal.ts +21 -0
- package/src/cli/commands/update/release.ts +351 -0
- package/src/cli/commands/update/replace.ts +143 -0
- package/src/cli/commands/update/semver.ts +48 -0
- package/src/cli/commands/update.ts +47 -0
- package/src/cli/commands/usage.ts +68 -0
- package/src/cli/commands/version.ts +29 -0
- package/src/cli/compose.ts +273 -0
- package/src/cli/output.ts +81 -0
- package/src/cli/plugin.ts +85 -0
- package/src/cli/registry.ts +180 -0
- package/src/cli/router.ts +31 -0
- package/src/cli/ts-verb.ts +126 -0
- package/src/main.ts +223 -0
- package/src/runtime/env.ts +16 -0
- package/src/runtime/eris-assets.d.ts +5 -0
- package/src/runtime/home.ts +306 -0
- package/src/runtime/logging.ts +43 -0
- package/src/runtime/process.ts +388 -0
- package/src/runtime/settings.ts +191 -0
- package/src/sdk.ts +62 -0
- package/testing/build-child.ts +55 -0
- package/testing/fake-runner.ts +23 -0
- package/testing/index.ts +16 -0
- package/ts/lib/eris-fail.ts +180 -0
- package/ts/lib/pool.ts +63 -0
|
@@ -0,0 +1,485 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
/**
|
|
3
|
+
* yak.ts — the `eris yak` CLI core.
|
|
4
|
+
*
|
|
5
|
+
* `plugins/yak/handlers.ts` is the thin dispatcher in front of it (group
|
|
6
|
+
* usage, the unknown-verb block, the per-call env). Every actual
|
|
7
|
+
* behaviour — argv parsing, input resolution, identity/provenance,
|
|
8
|
+
* redaction, submission, and the receipt/failure/warn rendering — lives
|
|
9
|
+
* here and in the modules this file composes
|
|
10
|
+
* (`plugins/yak/yak-identity.ts`, `plugins/yak/yak-context.ts`,
|
|
11
|
+
* `plugins/yak/yak-submit.ts`, `ts/lib/eris-fail.ts`).
|
|
12
|
+
*
|
|
13
|
+
* The whole orchestration is `runYak()`, a PURE function of its inputs
|
|
14
|
+
* (argv, env, and a handful of injectable I/O dependencies) that returns
|
|
15
|
+
* `{stdout, stderr, exitCode}` as plain strings rather than writing to the
|
|
16
|
+
* real streams or calling `process.exit` itself. That is what makes the
|
|
17
|
+
* full outcome table (fresh 200 / replay / 400 / 401 / 404 / timeout /
|
|
18
|
+
* connection-refused / every misuse case) testable in `bun:test` with an
|
|
19
|
+
* injected `fetchImpl`, without spawning a real `bun` subprocess per case
|
|
20
|
+
* — and it is what lets a test assert directly that stdout carries only
|
|
21
|
+
* the payload (`docs/OUTPUT-CONTRACT.md` §1), because the two streams are
|
|
22
|
+
* genuinely separate strings here, not shared process fds a test would
|
|
23
|
+
* have to intercept. The `import.meta.main` guard below is the only place
|
|
24
|
+
* that touches the real world.
|
|
25
|
+
*
|
|
26
|
+
* The misuse-vs-environment exit split is enforced entirely in this file's outcome
|
|
27
|
+
* mapping: an agent-misuse outcome (bad argv, unreadable/missing
|
|
28
|
+
* input, a stdin TTY with no pipe, an over-bound field) renders the
|
|
29
|
+
* `eris-fail.ts` four-line block and exits 1; every environment/config/
|
|
30
|
+
* transport outcome (unresolved token/url/device-id, an unreachable or
|
|
31
|
+
* non-2xx endpoint, a timeout) renders one concise `WARN` and exits 0 — the
|
|
32
|
+
* operator's problem, never the task's.
|
|
33
|
+
*/
|
|
34
|
+
|
|
35
|
+
import { homedir } from "node:os";
|
|
36
|
+
import {
|
|
37
|
+
readYaksFileConfig,
|
|
38
|
+
resolveToken,
|
|
39
|
+
resolveUrl,
|
|
40
|
+
resolveDeviceId,
|
|
41
|
+
resolveExecutionId,
|
|
42
|
+
} from "./yak-identity";
|
|
43
|
+
import { erisHome, groupArgs, readArgs, renderFailureBlock, renderWarnLines } from "../../src/cli/plugin";
|
|
44
|
+
import plugin from "./index";
|
|
45
|
+
import { gatherProvenance } from "./yak-context";
|
|
46
|
+
import {
|
|
47
|
+
resolveTextInputs,
|
|
48
|
+
submitYak,
|
|
49
|
+
toOutputRecord,
|
|
50
|
+
type RawTextInputs,
|
|
51
|
+
type RedactionSummary,
|
|
52
|
+
type SubmitResult,
|
|
53
|
+
type TextInputDeps,
|
|
54
|
+
type YakOutputRecord,
|
|
55
|
+
} from "./yak-submit";
|
|
56
|
+
import { formatRedactionSummary } from "./yak-redact";
|
|
57
|
+
import type { YakVerb } from "eris/yak-contract";
|
|
58
|
+
|
|
59
|
+
// ---------------------------------------------------------------------------
|
|
60
|
+
// Argv parsing
|
|
61
|
+
// ---------------------------------------------------------------------------
|
|
62
|
+
|
|
63
|
+
const VERBS: readonly YakVerb[] = ["issue", "suggest", "fatal"];
|
|
64
|
+
|
|
65
|
+
/** The manifest's subs are exactly the contract's verbs; this narrows one. */
|
|
66
|
+
const isVerb = (s: string): s is YakVerb => (VERBS as readonly string[]).includes(s);
|
|
67
|
+
|
|
68
|
+
/** A refusal `readArgs` hands to `refuse`: `parseArgv` returns it as an
|
|
69
|
+
* outcome instead of writing it, and `runYak` renders it (block or record). */
|
|
70
|
+
class Refused {
|
|
71
|
+
constructor(readonly outcome: ArgvOutcome) {}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** Where `readArgs` writes the help it prints: `runYak` writes yak's own. */
|
|
75
|
+
const DISCARD = { erisDir: "", stdout: { write: () => true }, stderr: { write: () => true } };
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
// A `…Slug`-named union: `tests/errors-doc.test.ts` harvests its members and
|
|
79
|
+
// fails when a member has no docs/ERRORS.md heading. Keep the type's name
|
|
80
|
+
// ending in `Slug`; a union named otherwise is invisible to it.
|
|
81
|
+
export type ArgvMisuseSlug =
|
|
82
|
+
| "yak-unknown-verb"
|
|
83
|
+
| "yak-unknown-flag"
|
|
84
|
+
| "yak-positional-arg"
|
|
85
|
+
| "yak-flag-missing-value";
|
|
86
|
+
|
|
87
|
+
export interface ParsedFlags {
|
|
88
|
+
summary?: string;
|
|
89
|
+
bodyInline?: string;
|
|
90
|
+
bodyFile?: string;
|
|
91
|
+
evidenceInline?: string;
|
|
92
|
+
evidenceFile?: string;
|
|
93
|
+
/** a repro command to attach to this yak, stored with the
|
|
94
|
+
* cluster it lands on (`provenance.repro.command` on the wire — see
|
|
95
|
+
* `plugins/yak/yak-submit.ts`'s `SubmitParams.repro`). Inline text only, like
|
|
96
|
+
* `--summary` — no `--repro-file`/stdin variant, since a repro is a
|
|
97
|
+
* command, not a document. Parsed uniformly across all three verbs
|
|
98
|
+
* (like every other flag here) rather than gated to `issue` alone: the
|
|
99
|
+
* parser has no per-verb flag sets today, and an improvement
|
|
100
|
+
* `suggest` or a harness `fatal` can just as usefully carry one. */
|
|
101
|
+
repro?: string;
|
|
102
|
+
/** zero or more `--repo <owner/name>` target-repo hints, in the
|
|
103
|
+
* order given — repeatable, unlike every other flag here, so a report
|
|
104
|
+
* that spans repos can name each one (`provenance.repo.target_repos` on
|
|
105
|
+
* the wire — see `plugins/yak/yak-submit.ts`'s `SubmitParams.repoHints`).
|
|
106
|
+
* `undefined` (never `[]`) when `--repo` was never passed at all — that
|
|
107
|
+
* absence is what lets `submitYak` fall back to auto-deriving a hint
|
|
108
|
+
* from the invoking process's own git remote instead. */
|
|
109
|
+
repoHints?: string[];
|
|
110
|
+
noRedact: boolean;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
export type ArgvOutcome =
|
|
114
|
+
| { kind: "help" }
|
|
115
|
+
// `verb` is OPTIONAL, not absent-by-design —
|
|
116
|
+
// it's populated on every misuse this parser can produce AFTER `verbArg`
|
|
117
|
+
// has already been matched against the registry's yak subs (an unknown flag, a flag missing its
|
|
118
|
+
// value), so `runYak` can still emit the documented `--output json`
|
|
119
|
+
// record on those outcomes. It stays undefined for the one misuse this
|
|
120
|
+
// parser produces BEFORE that point (`yak-unknown-verb` itself) — a case
|
|
121
|
+
// the dispatcher (`plugins/yak/handlers.ts`) never actually forwards to
|
|
122
|
+
// this file in production (it answers an unknown verb itself),
|
|
123
|
+
// so there is no real verb to report there.
|
|
124
|
+
| { kind: "misuse"; reason: string; slug: ArgvMisuseSlug; verb?: YakVerb; fix?: string }
|
|
125
|
+
| { kind: "ok"; verb: YakVerb; flags: ParsedFlags };
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* `--output` is deliberately NOT a flag this parser knows: it is the
|
|
129
|
+
* global `--output json|text` selector, parsed once and STRIPPED before
|
|
130
|
+
* dispatch by `src/cli/output.ts` in the real `eris` entrypoint
|
|
131
|
+
* (`docs/OUTPUT-CONTRACT.md` §2) — by the time `plugins/yak/handlers.ts`
|
|
132
|
+
* calls `runYak`, it is already gone from argv. The resolved format instead
|
|
133
|
+
* reaches the core as `ERIS_OUTPUT` in THIS SINGLE invocation's environment
|
|
134
|
+
* (never a persistent exported var — `docs/OUTPUT-CONTRACT.md` §2 is
|
|
135
|
+
* explicit that there is deliberately no such env var for a user's shell
|
|
136
|
+
* to set; the dispatcher sets it per call). Passing `--output` here would
|
|
137
|
+
* therefore either shadow a flag with no local meaning or silently do
|
|
138
|
+
* nothing on the one path that matters (through `eris`) while working only
|
|
139
|
+
* when yak.ts is invoked directly — worse than refusing it as unknown.
|
|
140
|
+
*/
|
|
141
|
+
export function parseArgv(argv: string[]): ArgvOutcome {
|
|
142
|
+
const g = groupArgs(plugin.rows, "yak", argv);
|
|
143
|
+
if (g.kind === "bare" || g.kind === "help") return { kind: "help" };
|
|
144
|
+
if (g.kind === "unknown" || !isVerb(g.sub)) {
|
|
145
|
+
return {
|
|
146
|
+
kind: "misuse",
|
|
147
|
+
reason: `unknown verb '${g.sub}' — expected one of: ${VERBS.join(", ")}`,
|
|
148
|
+
slug: "yak-unknown-verb",
|
|
149
|
+
};
|
|
150
|
+
}
|
|
151
|
+
const verbArg = g.sub;
|
|
152
|
+
let p;
|
|
153
|
+
try {
|
|
154
|
+
p = readArgs(plugin.rows, `yak ${verbArg}`, g.rest, DISCARD, {
|
|
155
|
+
refuse: (e, what) => {
|
|
156
|
+
// A bare word is the most common first guess (`eris yak issue "text"`), so it names
|
|
157
|
+
// the two flags the text has to ride on rather than reading as an unknown flag.
|
|
158
|
+
if (e.kind === "unexpected-arg") {
|
|
159
|
+
throw new Refused({
|
|
160
|
+
kind: "misuse",
|
|
161
|
+
reason: `${what}; eris yak takes its text through flags: --summary and --body (or --body-file)`,
|
|
162
|
+
slug: "yak-positional-arg",
|
|
163
|
+
verb: verbArg,
|
|
164
|
+
fix: `eris yak ${verbArg} --summary "<one line>" --body "<what happened>"`,
|
|
165
|
+
});
|
|
166
|
+
}
|
|
167
|
+
const slug = e.kind === "missing-value" ? "yak-flag-missing-value" : "yak-unknown-flag";
|
|
168
|
+
throw new Refused({ kind: "misuse", reason: what, slug, verb: verbArg });
|
|
169
|
+
},
|
|
170
|
+
});
|
|
171
|
+
} catch (e) {
|
|
172
|
+
if (e instanceof Refused) return e.outcome;
|
|
173
|
+
throw e;
|
|
174
|
+
}
|
|
175
|
+
if (p === null) return { kind: "help" };
|
|
176
|
+
const f = p.flags;
|
|
177
|
+
const flags: ParsedFlags = { noRedact: f["--no-redact"] };
|
|
178
|
+
if (f["--summary"] !== undefined) flags.summary = f["--summary"];
|
|
179
|
+
if (f["--body"] !== undefined) flags.bodyInline = f["--body"];
|
|
180
|
+
if (f["--body-file"] !== undefined) flags.bodyFile = f["--body-file"];
|
|
181
|
+
if (f["--evidence"] !== undefined) flags.evidenceInline = f["--evidence"];
|
|
182
|
+
if (f["--evidence-file"] !== undefined) flags.evidenceFile = f["--evidence-file"];
|
|
183
|
+
if (f["--repro"] !== undefined) flags.repro = f["--repro"];
|
|
184
|
+
if (f["--repo"].length > 0) flags.repoHints = [...f["--repo"]];
|
|
185
|
+
return { kind: "ok", verb: verbArg, flags };
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
const USAGE = `usage: eris yak <issue|suggest|fatal> [flags]
|
|
189
|
+
|
|
190
|
+
Land a durable, deduplicated observation with the Yaks endpoint. For a
|
|
191
|
+
harness/tooling problem hit mid-task — never for the task's own outcome.
|
|
192
|
+
|
|
193
|
+
verbs:
|
|
194
|
+
issue a harness/tooling defect, confirmed or suspected
|
|
195
|
+
suggest an improvement to the harness: a missing capability or a better way of working
|
|
196
|
+
fatal the harness itself is broken
|
|
197
|
+
|
|
198
|
+
flags:
|
|
199
|
+
--summary <text> required, <= 200 chars
|
|
200
|
+
--body <text> required (mutually exclusive with --body-file)
|
|
201
|
+
--body-file <path|-> required (mutually exclusive with --body); - reads stdin
|
|
202
|
+
--evidence <text> optional, <= 20000 chars
|
|
203
|
+
--evidence-file <path|-> optional (mutually exclusive with --evidence); - reads stdin
|
|
204
|
+
--repro <command> optional, <= 2000 chars — a command that reproduces this,
|
|
205
|
+
stored with the cluster
|
|
206
|
+
--repo <owner/name> optional, repeatable, <= 200 chars each, <= 20 total — a
|
|
207
|
+
target repo this report concerns; auto-derived from this
|
|
208
|
+
process's own git remote when omitted
|
|
209
|
+
--no-redact skip the secret-scrub pass (loud warning)
|
|
210
|
+
--help, -h show this message
|
|
211
|
+
|
|
212
|
+
Setup: 'eris login' writes ~/.eris/yaks.json ({"url", "token"}, mode 0600;
|
|
213
|
+
the url defaults to https://api.sibylline.group); or export ERIS_YAKS_URL /
|
|
214
|
+
ERIS_YAKS_TOKEN.
|
|
215
|
+
`;
|
|
216
|
+
|
|
217
|
+
// ---------------------------------------------------------------------------
|
|
218
|
+
// Orchestration
|
|
219
|
+
// ---------------------------------------------------------------------------
|
|
220
|
+
|
|
221
|
+
export interface RunYakOptions {
|
|
222
|
+
argv: string[];
|
|
223
|
+
env?: Record<string, string | undefined>;
|
|
224
|
+
cwd?: string;
|
|
225
|
+
ppid?: number;
|
|
226
|
+
/** Home directory identity/config files resolve under — defaults to the
|
|
227
|
+
* real `homedir()`. Overridden in tests so nothing ever touches this
|
|
228
|
+
* machine's real `~/.eris`. */
|
|
229
|
+
home?: string;
|
|
230
|
+
fetchImpl?: typeof fetch;
|
|
231
|
+
timeoutMs?: number;
|
|
232
|
+
isStdinTTY?: TextInputDeps["isStdinTTY"];
|
|
233
|
+
readStdin?: TextInputDeps["readStdin"];
|
|
234
|
+
readFile?: TextInputDeps["readFile"];
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
export interface RunYakResult {
|
|
238
|
+
stdout: string;
|
|
239
|
+
stderr: string;
|
|
240
|
+
exitCode: number;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
// `skipped` mirrors `flags.noRedact` for the warns raised BEFORE `submitYak`
|
|
244
|
+
// is ever called (config/token/url/device-id resolution) — those never
|
|
245
|
+
// build a `RedactionSummary` of their own, so this is the only way the
|
|
246
|
+
// record can say whether `--no-redact` was set (stderr's `yak-no-redact` WARN is unconditionally suppressed under
|
|
247
|
+
// `--output json` now, same as every other diagnostic — see `runYak`).
|
|
248
|
+
function warnOutputRecord(verb: YakVerb, slug: string, reason: string, skipped: boolean): YakOutputRecord {
|
|
249
|
+
return { verb, delivered: false, redactions: { count: 0, classes: [], skipped }, error: { slug, reason } };
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
// output-contract fix: under `--output json`, `eris yak` writes
|
|
253
|
+
// EXACTLY one JSON record to stdout and NOTHING to stderr — `prefix`
|
|
254
|
+
// (the `--no-redact` WARN, when set) used to reach stderr unconditionally
|
|
255
|
+
// here, even in json mode, which is the exact leak this fix closes. Text
|
|
256
|
+
// mode is byte-identical to before.
|
|
257
|
+
function warnResult(
|
|
258
|
+
verb: YakVerb,
|
|
259
|
+
slug: string,
|
|
260
|
+
reason: string,
|
|
261
|
+
jsonOutput: boolean,
|
|
262
|
+
prefix: string,
|
|
263
|
+
skipped: boolean,
|
|
264
|
+
): RunYakResult {
|
|
265
|
+
const stdout = jsonOutput ? `${JSON.stringify(warnOutputRecord(verb, slug, reason, skipped))}\n` : "";
|
|
266
|
+
const stderr = jsonOutput ? "" : prefix + renderWarnLines(reason, slug);
|
|
267
|
+
return { stdout, stderr, exitCode: 0 };
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
// A misuse outcome used to leave stdout
|
|
271
|
+
// empty UNCONDITIONALLY — docs/YAK.md and docs/OUTPUT-CONTRACT.md both
|
|
272
|
+
// promise "one bare record on stdout regardless of outcome" and tell a
|
|
273
|
+
// consumer to branch on `delivered`, never `id == null`, so a `--output
|
|
274
|
+
// json` caller got nothing to parse on the one outcome class (agent
|
|
275
|
+
// misuse) it's most likely to need to detect programmatically.
|
|
276
|
+
//
|
|
277
|
+
// `verb` is optional because ONE of this function's three call sites
|
|
278
|
+
// (`parseArgv`'s own `yak-unknown-verb`) can fire before a verb is known
|
|
279
|
+
// at all — and that specific case is unreachable through the real `eris`
|
|
280
|
+
// entrypoint (`plugins/yak/handlers.ts` only ever forwards `issue|suggest|fatal`),
|
|
281
|
+
// so there's nothing dishonest about `stdout` staying empty there: no
|
|
282
|
+
// `verb` value would be true. Every OTHER misuse this file produces has a
|
|
283
|
+
// real verb by the time it reaches here (threaded through `ArgvOutcome`'s
|
|
284
|
+
// `verb?` field, or already destructured from `parsed.verb`) and gets the
|
|
285
|
+
// documented record.
|
|
286
|
+
//
|
|
287
|
+
// `redaction` mirrors `formatRedactionSummary`'s wiring on the warn/
|
|
288
|
+
// delivered paths: when it's present and non-empty, the count/class
|
|
289
|
+
// breakdown prints on stderr in text mode (ahead of the failure block, same
|
|
290
|
+
// order as `--no-redact`'s warning) and rides along in the JSON record's
|
|
291
|
+
// `redactions` field either way — never silently dropped just because the
|
|
292
|
+
// outcome was a failure rather than a success.
|
|
293
|
+
//
|
|
294
|
+
// `skipped` is the fallback used ONLY when `redaction` itself is absent
|
|
295
|
+
// (the argv-level and resolveTextInputs misuses, which never build a
|
|
296
|
+
// `RedactionSummary` of their own) — when `redaction` IS present (the
|
|
297
|
+
// submitYak-derived misuses), it already carries the correct `skipped`
|
|
298
|
+
// value from `params.noRedact` and this parameter is ignored.
|
|
299
|
+
//
|
|
300
|
+
// output-contract fix: under `--output json`, stderr is now
|
|
301
|
+
// EMPTY on every misuse — the four-line failure block used to reach
|
|
302
|
+
// stderr unconditionally, even in json mode, which is the exact leak
|
|
303
|
+
// this fix closes (`ERROR .../slug:.../fix:.../docs:...` alongside a
|
|
304
|
+
// stdout JSON record the caller was told to parse instead). Text mode is
|
|
305
|
+
// byte-identical to before.
|
|
306
|
+
function misuseResult(
|
|
307
|
+
slug: string,
|
|
308
|
+
reason: string,
|
|
309
|
+
erisHomeDir: string,
|
|
310
|
+
prefix: string,
|
|
311
|
+
jsonOutput: boolean,
|
|
312
|
+
verb?: YakVerb,
|
|
313
|
+
redaction?: RedactionSummary,
|
|
314
|
+
skipped = false,
|
|
315
|
+
fix?: string,
|
|
316
|
+
): RunYakResult {
|
|
317
|
+
const redactions = redaction ?? { count: 0, classes: [], skipped };
|
|
318
|
+
const redactionLine = !jsonOutput && redactions.count > 0 ? formatRedactionSummary(redactions) : "";
|
|
319
|
+
const stdout =
|
|
320
|
+
jsonOutput && verb !== undefined
|
|
321
|
+
? `${JSON.stringify({ verb, delivered: false, redactions, error: { slug, reason } } satisfies YakOutputRecord)}\n`
|
|
322
|
+
: "";
|
|
323
|
+
const stderr = jsonOutput ? "" : prefix + redactionLine + renderFailureBlock({ slug, what: reason, ...(fix ? { fix } : {}) }, erisHomeDir);
|
|
324
|
+
return { stdout, stderr, exitCode: 1 };
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
/**
|
|
328
|
+
* Run the full `eris yak` pipeline and return `{stdout, stderr, exitCode}`
|
|
329
|
+
* — never writes to a real stream, never calls `process.exit`. Every
|
|
330
|
+
* dependency that touches the outside world (env, fs, stdin, network,
|
|
331
|
+
* `~/.eris`) is injectable via `opts`, defaulting to the real thing.
|
|
332
|
+
*/
|
|
333
|
+
export async function runYak(opts: RunYakOptions): Promise<RunYakResult> {
|
|
334
|
+
const env = opts.env ?? process.env;
|
|
335
|
+
const jsonOutput = env.ERIS_OUTPUT === "json";
|
|
336
|
+
const erisHomeDir = erisHome(env);
|
|
337
|
+
const home = opts.home ?? homedir();
|
|
338
|
+
|
|
339
|
+
const parsed = parseArgv(opts.argv);
|
|
340
|
+
|
|
341
|
+
if (parsed.kind === "help") {
|
|
342
|
+
return { stdout: USAGE, stderr: "", exitCode: 0 };
|
|
343
|
+
}
|
|
344
|
+
if (parsed.kind === "misuse") {
|
|
345
|
+
return misuseResult(parsed.slug, parsed.reason, erisHomeDir, "", jsonOutput, parsed.verb, undefined, false, parsed.fix);
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
const { verb, flags } = parsed;
|
|
349
|
+
const noRedactWarning = flags.noRedact
|
|
350
|
+
? renderWarnLines(
|
|
351
|
+
"--no-redact is set — the secret-scrub pass will NOT run on this submission",
|
|
352
|
+
"yak-no-redact",
|
|
353
|
+
)
|
|
354
|
+
: "";
|
|
355
|
+
|
|
356
|
+
const rawInputs: RawTextInputs = {
|
|
357
|
+
summary: flags.summary,
|
|
358
|
+
bodyInline: flags.bodyInline,
|
|
359
|
+
bodyFile: flags.bodyFile,
|
|
360
|
+
evidenceInline: flags.evidenceInline,
|
|
361
|
+
evidenceFile: flags.evidenceFile,
|
|
362
|
+
};
|
|
363
|
+
const textDeps: Partial<TextInputDeps> = {};
|
|
364
|
+
if (opts.readFile) textDeps.readFile = opts.readFile;
|
|
365
|
+
if (opts.readStdin) textDeps.readStdin = opts.readStdin;
|
|
366
|
+
if (opts.isStdinTTY) textDeps.isStdinTTY = opts.isStdinTTY;
|
|
367
|
+
|
|
368
|
+
const inputsOutcome = await resolveTextInputs(rawInputs, textDeps);
|
|
369
|
+
if ("reason" in inputsOutcome) {
|
|
370
|
+
return misuseResult(
|
|
371
|
+
inputsOutcome.slug,
|
|
372
|
+
inputsOutcome.reason,
|
|
373
|
+
erisHomeDir,
|
|
374
|
+
noRedactWarning,
|
|
375
|
+
jsonOutput,
|
|
376
|
+
verb,
|
|
377
|
+
undefined,
|
|
378
|
+
flags.noRedact,
|
|
379
|
+
);
|
|
380
|
+
}
|
|
381
|
+
const { summary, body, evidence } = inputsOutcome.value;
|
|
382
|
+
|
|
383
|
+
// --- identity: config file, token, url, device id ------------------------
|
|
384
|
+
const fileConfigOutcome = await readYaksFileConfig(home);
|
|
385
|
+
if ("reason" in fileConfigOutcome) {
|
|
386
|
+
return warnResult(verb, fileConfigOutcome.slug, fileConfigOutcome.reason, jsonOutput, noRedactWarning, flags.noRedact);
|
|
387
|
+
}
|
|
388
|
+
const fileConfig = fileConfigOutcome.value;
|
|
389
|
+
|
|
390
|
+
const tokenOutcome = resolveToken(fileConfig, env);
|
|
391
|
+
if ("reason" in tokenOutcome) {
|
|
392
|
+
return warnResult(verb, tokenOutcome.slug, tokenOutcome.reason, jsonOutput, noRedactWarning, flags.noRedact);
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
const urlOutcome = resolveUrl(fileConfig, env);
|
|
396
|
+
|
|
397
|
+
const deviceIdOutcome = await resolveDeviceId(home);
|
|
398
|
+
if ("reason" in deviceIdOutcome) {
|
|
399
|
+
return warnResult(verb, deviceIdOutcome.slug, deviceIdOutcome.reason, jsonOutput, noRedactWarning, flags.noRedact);
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
const executionId = resolveExecutionId(env, opts.ppid ?? process.ppid).value;
|
|
403
|
+
const provenance = await gatherProvenance({ env, cwd: opts.cwd, ppid: opts.ppid });
|
|
404
|
+
|
|
405
|
+
// --- submit ---------------------------------------------------------------
|
|
406
|
+
const result: SubmitResult = await submitYak({
|
|
407
|
+
verb,
|
|
408
|
+
summary,
|
|
409
|
+
body,
|
|
410
|
+
evidence,
|
|
411
|
+
repro: flags.repro,
|
|
412
|
+
repoHints: flags.repoHints,
|
|
413
|
+
noRedact: flags.noRedact,
|
|
414
|
+
deviceId: deviceIdOutcome.value,
|
|
415
|
+
executionId,
|
|
416
|
+
provenance,
|
|
417
|
+
url: urlOutcome.value,
|
|
418
|
+
token: tokenOutcome.value,
|
|
419
|
+
fetchImpl: opts.fetchImpl,
|
|
420
|
+
timeoutMs: opts.timeoutMs,
|
|
421
|
+
});
|
|
422
|
+
|
|
423
|
+
if (result.status === "misuse") {
|
|
424
|
+
return misuseResult(result.slug, result.reason, erisHomeDir, noRedactWarning, jsonOutput, verb, result.redaction);
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
if (result.status === "warn") {
|
|
428
|
+
const stdout = jsonOutput ? `${JSON.stringify(toOutputRecord(verb, result))}\n` : "";
|
|
429
|
+
// The count/class breakdown belongs on stderr in text mode too, not
|
|
430
|
+
// only inside `--output json`'s `redactions` field (docs/YAK.md) —
|
|
431
|
+
// `result.redaction` is only present once redaction has
|
|
432
|
+
// actually run (a network-outcome warn, not an earlier config warn).
|
|
433
|
+
//
|
|
434
|
+
// output-contract fix: `stderr` is now EMPTY under
|
|
435
|
+
// `--output json` — `noRedactWarning`/`redactionLine`/the WARN line
|
|
436
|
+
// used to reach stderr unconditionally, even in json mode. Every one
|
|
437
|
+
// of those facts is already recoverable from `stdout`'s record: the
|
|
438
|
+
// WARN's own slug/reason are `error.{slug,reason}`, the redaction
|
|
439
|
+
// count/classes/skip are `redactions`.
|
|
440
|
+
const redactionLine =
|
|
441
|
+
!jsonOutput && result.redaction && result.redaction.count > 0 ? formatRedactionSummary(result.redaction) : "";
|
|
442
|
+
const stderr = jsonOutput ? "" : noRedactWarning + redactionLine + renderWarnLines(result.reason, result.slug);
|
|
443
|
+
return { stdout, stderr, exitCode: 0 };
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
// delivered
|
|
447
|
+
let stdout: string;
|
|
448
|
+
if (jsonOutput) {
|
|
449
|
+
stdout = `${JSON.stringify(toOutputRecord(verb, result))}\n`;
|
|
450
|
+
} else {
|
|
451
|
+
stdout = `${result.receipt.replay ? "already recorded" : "recorded"}: ${result.receipt.id}\n`;
|
|
452
|
+
}
|
|
453
|
+
// output-contract fix: `stderr` is EMPTY under `--output json`.
|
|
454
|
+
// The `--no-redact` WARN is recoverable from `redactions.skipped`, the
|
|
455
|
+
// redaction count/class breakdown from `redactions.{count,classes}`, and
|
|
456
|
+
// the key-mismatch WARN from `key_mismatch` — all on the one stdout
|
|
457
|
+
// record (`toOutputRecord`) rather than a second, silently-dropped
|
|
458
|
+
// stream.
|
|
459
|
+
let stderr = "";
|
|
460
|
+
if (!jsonOutput) {
|
|
461
|
+
stderr = noRedactWarning;
|
|
462
|
+
// Same rule (see above) on the successful-delivery path.
|
|
463
|
+
if (result.redaction.count > 0) {
|
|
464
|
+
stderr += formatRedactionSummary(result.redaction);
|
|
465
|
+
}
|
|
466
|
+
if (result.keyMismatch) {
|
|
467
|
+
stderr += renderWarnLines(
|
|
468
|
+
"locally-derived idempotency key does not match the receipt's — possible canonicalization drift between this CLI and the Yaks endpoint",
|
|
469
|
+
"yak-key-mismatch",
|
|
470
|
+
);
|
|
471
|
+
}
|
|
472
|
+
}
|
|
473
|
+
return { stdout, stderr, exitCode: 0 };
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
// ---------------------------------------------------------------------------
|
|
477
|
+
// Real entry point
|
|
478
|
+
// ---------------------------------------------------------------------------
|
|
479
|
+
|
|
480
|
+
if (import.meta.main) {
|
|
481
|
+
const result = await runYak({ argv: process.argv.slice(2) });
|
|
482
|
+
if (result.stdout.length > 0) process.stdout.write(result.stdout);
|
|
483
|
+
if (result.stderr.length > 0) process.stderr.write(result.stderr);
|
|
484
|
+
process.exit(result.exitCode);
|
|
485
|
+
}
|