@levr-one/cli 0.10.20 → 0.10.22

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.
Files changed (23) hide show
  1. package/dist/{addHandler-r0Yw1vu-.js → addHandler-DUulKXOX.js} +68 -68
  2. package/dist/appendHandler-u-NvHPDW.js +80 -0
  3. package/dist/artifact-upload-CF2iXUiR.js +296 -0
  4. package/dist/attachHandler-DgVCvhz7.js +151 -0
  5. package/dist/cli.js +237 -17
  6. package/dist/connect-DX41Im8c.js +36 -0
  7. package/dist/{currentHandler-BBHqC26D.js → currentHandler-B_sWXNeW.js} +1 -1
  8. package/dist/{importHandler-DAXXM42X.js → importHandler-CUUroX_n.js} +8 -8
  9. package/dist/{listHandler-qFOGgW2z.js → listHandler-CB_9cISi.js} +5 -5
  10. package/dist/{loginHandler-DMjKtKj5.js → loginHandler-DSznhtmk.js} +5 -10
  11. package/dist/{logoutHandler-BFutKash.js → logoutHandler-TSQlZGpU.js} +2 -2
  12. package/dist/{pushHandler-C7nxljuh.js → pushHandler-Q9Cbo2HC.js} +142 -9
  13. package/dist/{resolve-token-DbQsmn03.js → resolve-token-7xb7kG7h.js} +2 -2
  14. package/dist/{resolve-workspace--4K7N4VU.js → resolve-workspace-B3Gz3-Vh.js} +2 -2
  15. package/dist/{runHandler-gaYkPS79.js → runHandler-DmRAoyJc.js} +8 -8
  16. package/dist/{sdk-client-vQsoMCez.js → sdk-client-CTvYVr7D.js} +310 -70
  17. package/dist/{selectHandler-CPeH8ILF.js → selectHandler-CdOVuuTa.js} +5 -5
  18. package/dist/sleep-BpJ39Ypm.js +7 -0
  19. package/dist/{statusHandler-WkN652yu.js → statusHandler-p6CmwgjS.js} +3 -3
  20. package/dist/{token-refresh-Cu5RpkLJ.js → token-refresh-DeusLK8H.js} +1 -1
  21. package/package.json +1 -1
  22. /package/dist/{env-CHeKHu5S.js → env-CdwyPHGV.js} +0 -0
  23. /package/dist/{workspace-store-DDOxnut1.js → workspace-store-CnrxyYRB.js} +0 -0
@@ -0,0 +1,296 @@
1
+ import { uploadAttachment } from "./sdk-client-CTvYVr7D.js";
2
+ import { sleep } from "./sleep-BpJ39Ypm.js";
3
+ import { constants, promises, readlinkSync, realpathSync, statSync } from "node:fs";
4
+ import path from "node:path";
5
+ import process from "node:process";
6
+
7
+ //#region src/utils/artifact-upload.ts
8
+ /** The per-file ceiling the server enforces (DD5): bigger files are skipped. */
9
+ const MAX_ARTIFACT_BYTES = 50 * 1024 * 1024;
10
+ /** Retries after the first attempt, for failures that are safe to repeat. */
11
+ const MAX_UPLOAD_RETRIES = 2;
12
+ /** Related types with an attachment table (the upload route accepts these). */
13
+ const TYPED_TARGET_TYPES = [
14
+ "issue",
15
+ "test",
16
+ "run",
17
+ "run_result",
18
+ "run_result_variant",
19
+ "import_job",
20
+ "project",
21
+ "project_update",
22
+ "automation_run_result"
23
+ ];
24
+ /** A target string the CLI cannot interpret; a usage error, exit 1. */
25
+ var TargetParseError = class extends Error {
26
+ constructor(input) {
27
+ super(`Invalid target "${input}". Use an identifier (ENG-42, TC-5, TR-3) or <related_type>:<uuid|identifier> with related_type one of ${TYPED_TARGET_TYPES.join(", ")}.`);
28
+ this.name = "TargetParseError";
29
+ }
30
+ };
31
+ const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
32
+ const IDENTIFIER_RE = /^([A-Z][A-Z0-9]*)(?:-[A-Z0-9]{2,10})?-(\d+)$/;
33
+ /**
34
+ * `ENG-42` → issue, `TC-5` → test, `TR-3` → run (the server resolves the
35
+ * identifier); `<related_type>:<uuid>` for any typed target.
36
+ */
37
+ function parseTarget(input) {
38
+ const trimmed = input.trim();
39
+ const ident = IDENTIFIER_RE.exec(trimmed);
40
+ if (ident) {
41
+ const prefix = ident[1];
42
+ return {
43
+ relatedType: prefix === "TC" ? "test" : prefix === "TR" ? "run" : "issue",
44
+ relatedId: trimmed
45
+ };
46
+ }
47
+ const sep = trimmed.indexOf(":");
48
+ if (sep > 0) {
49
+ const type = trimmed.slice(0, sep);
50
+ const id = trimmed.slice(sep + 1);
51
+ if (TYPED_TARGET_TYPES.includes(type) && (UUID_RE.test(id) || IDENTIFIER_RE.test(id))) return {
52
+ relatedType: type,
53
+ relatedId: id
54
+ };
55
+ }
56
+ throw new TargetParseError(input);
57
+ }
58
+ /**
59
+ * Resolve a path a REPORT supplied (untrusted: a fork PR controls its own
60
+ * JUnit) against `baseDir`, confined to `root` (audit P1). An absolute path
61
+ * is taken as-is and accepted when it lies inside `root` (R1-23).
62
+ *
63
+ * fail_closed: returns null — never read, counted as rejected — when the
64
+ * real path (symlinks followed) is outside `root`. A path that does not
65
+ * exist inside `root` is `{ status: 'missing' }`, never a throw.
66
+ */
67
+ function resolveArtifactPath(ref, baseDir, root = process.cwd()) {
68
+ const realRoot = safeRealpath(root) ?? path.resolve(root);
69
+ const candidate = path.isAbsolute(ref) ? ref : path.resolve(baseDir, ref);
70
+ const real = safeRealpath(candidate);
71
+ if (real === void 0) return isInside(realRoot, realpathOfNearest(path.resolve(candidate))) ? { status: "missing" } : null;
72
+ if (!isInside(realRoot, real)) return null;
73
+ let st;
74
+ try {
75
+ st = statSync(real);
76
+ } catch {
77
+ return { status: "missing" };
78
+ }
79
+ if (!st.isFile()) return { status: "missing" };
80
+ return {
81
+ status: "ok",
82
+ path: real,
83
+ dev: st.dev,
84
+ ino: st.ino,
85
+ root: realRoot
86
+ };
87
+ }
88
+ function safeRealpath(p) {
89
+ try {
90
+ return realpathSync(p);
91
+ } catch {
92
+ return;
93
+ }
94
+ }
95
+ /** `p` with its longest existing prefix replaced by that prefix's real path. */
96
+ function realpathOfNearest(p) {
97
+ const missing = [];
98
+ let current = p;
99
+ for (;;) {
100
+ const real = safeRealpath(current);
101
+ if (real !== void 0) return path.join(real, ...missing.reverse());
102
+ const parent = path.dirname(current);
103
+ if (parent === current) return p;
104
+ missing.push(path.basename(current));
105
+ current = parent;
106
+ }
107
+ }
108
+ function isInside(root, p) {
109
+ const rel = path.relative(root, p);
110
+ return rel === "" || rel !== ".." && !rel.startsWith(`..${path.sep}`) && !path.isAbsolute(rel);
111
+ }
112
+ /**
113
+ * Upload one file. Never throws: every outcome is a status.
114
+ *
115
+ * - The file is opened ONCE, with O_NOFOLLOW (omitted on win32, where the
116
+ * dev/inode comparison alone guards the read), and the open handle's
117
+ * dev/inode must match the validated path's; otherwise the file was
118
+ * swapped after validation and the result is `rejected` (R1-07). The size
119
+ * check and the bytes sent come from that same handle.
120
+ * - ENOENT/ENOTDIR → `missing`; over 50 MiB → `skipped` (DD5).
121
+ * - Fields go before the file part; kind/attempt_index only for an
122
+ * automation_run_result target (the server rejects them elsewhere).
123
+ * - Retries split by idempotency (R1-06), fail_retry(2) with backoff: a
124
+ * failure the server cannot have received (429, connection refused, DNS)
125
+ * is retried for any target; an ambiguous one (timeout, reset, 5xx) only
126
+ * for automation_run_result, whose uploads the server deduplicates. Any
127
+ * other target gets `failed` ("outcome unknown") instead of a duplicate.
128
+ */
129
+ async function uploadArtifactFile(options) {
130
+ const { target, filePath } = options;
131
+ const sleep$1 = options.sleep ?? sleep;
132
+ const isAutomation = target.relatedType === "automation_run_result";
133
+ let validated = options.validated;
134
+ if (!validated) {
135
+ const real = safeRealpath(filePath);
136
+ if (real === void 0) return { status: "missing" };
137
+ try {
138
+ const st = statSync(real);
139
+ if (!st.isFile()) return { status: "missing" };
140
+ validated = {
141
+ path: real,
142
+ dev: st.dev,
143
+ ino: st.ino
144
+ };
145
+ } catch {
146
+ return { status: "missing" };
147
+ }
148
+ }
149
+ const flags = process.platform === "win32" ? constants.O_RDONLY : constants.O_RDONLY | constants.O_NOFOLLOW;
150
+ let handle;
151
+ try {
152
+ handle = await promises.open(validated.path, flags);
153
+ } catch (err) {
154
+ const code = err.code;
155
+ if (code === "ENOENT" || code === "ENOTDIR") return { status: "missing" };
156
+ if (code === "ELOOP") return {
157
+ status: "rejected",
158
+ message: "the path became a symlink"
159
+ };
160
+ return {
161
+ status: "failed",
162
+ message: err.message
163
+ };
164
+ }
165
+ let bytes;
166
+ try {
167
+ const st = await handle.stat();
168
+ if (st.dev !== validated.dev || st.ino !== validated.ino) return {
169
+ status: "rejected",
170
+ message: "the file changed after it was validated"
171
+ };
172
+ if (validated.root && !openedInside(handle.fd, validated, validated.root)) return {
173
+ status: "rejected",
174
+ message: "the opened file is outside the working directory"
175
+ };
176
+ if (st.size > MAX_ARTIFACT_BYTES) return {
177
+ status: "skipped",
178
+ reason: "too_large",
179
+ size: st.size
180
+ };
181
+ bytes = await handle.readFile();
182
+ } catch (err) {
183
+ return {
184
+ status: "failed",
185
+ message: err.message
186
+ };
187
+ } finally {
188
+ await handle.close().catch(() => void 0);
189
+ }
190
+ const name = options.name ?? path.basename(filePath);
191
+ const body = {
192
+ related_type: target.relatedType,
193
+ related_id: target.relatedId
194
+ };
195
+ if (isAutomation) {
196
+ if (options.kind) body.kind = options.kind;
197
+ if (options.attemptIndex !== void 0 && options.attemptIndex !== null) body.attempt_index = options.attemptIndex;
198
+ }
199
+ if (options.isPublic !== void 0) body.is_public = options.isPublic;
200
+ body.file = new File([new Uint8Array(bytes)], name);
201
+ for (let attempt = 0;; attempt++) {
202
+ let outcome;
203
+ let thrown;
204
+ try {
205
+ outcome = await uploadAttachment(body);
206
+ } catch (err) {
207
+ thrown = err;
208
+ }
209
+ if (outcome && outcome.status >= 200 && outcome.status < 300) {
210
+ const data = outcome.data;
211
+ if (!data?.id) return {
212
+ status: "failed",
213
+ message: "upload returned no attachment"
214
+ };
215
+ return {
216
+ status: data.deduplicated || outcome.status === 200 ? "deduplicated" : "uploaded",
217
+ id: data.id,
218
+ url: data.url ?? null,
219
+ target_id: data.target_id ?? null
220
+ };
221
+ }
222
+ const kind = classifyFailure(outcome, thrown);
223
+ if ((kind === "not_received" || kind === "ambiguous" && isAutomation) && attempt < MAX_UPLOAD_RETRIES) {
224
+ await sleep$1(500 * 2 ** attempt);
225
+ continue;
226
+ }
227
+ if (kind === "ambiguous" && !isAutomation) return {
228
+ status: "failed",
229
+ message: "outcome unknown, not retried to avoid a duplicate attachment"
230
+ };
231
+ return {
232
+ status: "failed",
233
+ message: describeFailure(outcome, thrown)
234
+ };
235
+ }
236
+ }
237
+ /**
238
+ * Where the OPEN descriptor really lives (ENG-6165 code review F-005).
239
+ * O_NOFOLLOW guards only the last component, and the dev/inode recorded at
240
+ * validation was itself read through a path whose parent could have been
241
+ * swapped for a symlink in between. On Linux `/proc/self/fd/<fd>` names the
242
+ * opened file authoritatively. Elsewhere Node has no fd → path call, so the
243
+ * path is resolved again and must still be the validated, in-root path
244
+ * holding the same file: this narrows the window to the open itself rather
245
+ * than closing it.
246
+ */
247
+ function openedInside(fd, validated, root) {
248
+ if (process.platform === "linux") try {
249
+ return isInside(root, readlinkSync(`/proc/self/fd/${fd}`));
250
+ } catch {
251
+ return false;
252
+ }
253
+ const again = safeRealpath(validated.path);
254
+ if (again !== validated.path || !isInside(root, again)) return false;
255
+ try {
256
+ const st = statSync(again);
257
+ return st.dev === validated.dev && st.ino === validated.ino;
258
+ } catch {
259
+ return false;
260
+ }
261
+ }
262
+ /**
263
+ * not_received: the server cannot have acted (429, connection refused, DNS).
264
+ * ambiguous: the request may have landed (timeout, reset, 5xx, unknown).
265
+ * rejected: a definite 4xx answer — never retried.
266
+ */
267
+ function classifyFailure(outcome, thrown) {
268
+ if (outcome) {
269
+ if (outcome.status === 429) return "not_received";
270
+ if (outcome.status >= 500 || outcome.status === 0) return "ambiguous";
271
+ return "rejected";
272
+ }
273
+ const code = errorCode(thrown);
274
+ if (code === "ECONNREFUSED" || code === "ENOTFOUND" || code === "EAI_AGAIN") return "not_received";
275
+ return "ambiguous";
276
+ }
277
+ function errorCode(err) {
278
+ const e = err;
279
+ const code = e?.code ?? e?.cause?.code;
280
+ return typeof code === "string" ? code : void 0;
281
+ }
282
+ function describeFailure(outcome, thrown) {
283
+ if (outcome) {
284
+ const msg = outcome.error?.message;
285
+ const text = Array.isArray(msg) ? msg.join("; ") : typeof msg === "string" ? msg : JSON.stringify(outcome.error ?? {});
286
+ return `HTTP ${outcome.status}: ${text}`;
287
+ }
288
+ return thrown instanceof Error ? thrown.message : String(thrown);
289
+ }
290
+ /** Markdown that shows an uploaded file: an image inline, anything else as a link. */
291
+ function embedMarkdown(name, url) {
292
+ return /\.(png|jpe?g|gif|webp)$/i.test(name) ? `![${name}](${url})` : `[${name}](${url})`;
293
+ }
294
+
295
+ //#endregion
296
+ export { TargetParseError, embedMarkdown, parseTarget, resolveArtifactPath, uploadArtifactFile };
@@ -0,0 +1,151 @@
1
+ import { appendActualResult, createComment } from "./sdk-client-CTvYVr7D.js";
2
+ import "./env-CdwyPHGV.js";
3
+ import "./token-refresh-DeusLK8H.js";
4
+ import "./resolve-token-7xb7kG7h.js";
5
+ import "./workspace-store-CnrxyYRB.js";
6
+ import "./resolve-workspace-B3Gz3-Vh.js";
7
+ import { connect } from "./connect-DX41Im8c.js";
8
+ import "./sleep-BpJ39Ypm.js";
9
+ import { TargetParseError, embedMarkdown, parseTarget, uploadArtifactFile } from "./artifact-upload-CF2iXUiR.js";
10
+ import { readFileSync } from "node:fs";
11
+ import { basename } from "node:path";
12
+
13
+ //#region src/commands/attachHandler.ts
14
+ /**
15
+ * `levr attach` (ENG-6164 D4). fail_closed: any file that fails or is
16
+ * skipped, and any failed embed write, exits 1 — an explicit attach is a
17
+ * request, unlike `levr push --artifacts`.
18
+ *
19
+ * Visibility: every upload is private, --embed included (ENG-6165 code
20
+ * review F-006). Levr serves a private attachment's url to any signed-in
21
+ * workspace member, so the embedded link renders in Levr without making the
22
+ * file readable by anyone holding the link. --embed writes
23
+ * append-only — a NEW comment on an issue, test or run, or a server-side
24
+ * `append_actual_result` on an execution result — never a rewrite of
25
+ * entity text (audit A3).
26
+ */
27
+ async function attachHandler(flags, ...args) {
28
+ if (flags.verbose) this.logger.setVerbose(true);
29
+ let jobs;
30
+ try {
31
+ jobs = flags.manifest ? readManifest(flags.manifest) : [singleJob(args, flags)];
32
+ } catch (err) {
33
+ this.logger.error(err instanceof Error ? err.message : String(err));
34
+ this.process.exitCode = 1;
35
+ return;
36
+ }
37
+ if (flags.embed) {
38
+ for (const job of jobs) if (!embedWriter(job.target)) {
39
+ this.logger.error(`--embed is not supported for ${job.target.relatedType} targets (issue, test, run or run_result_variant only).`);
40
+ this.process.exitCode = 1;
41
+ return;
42
+ }
43
+ }
44
+ if (!await connect(this, flags["workspace-id"])) return;
45
+ let failed = false;
46
+ for (const job of jobs) {
47
+ const uploaded = [];
48
+ for (const file of job.files) {
49
+ const result = await uploadArtifactFile({
50
+ target: job.target,
51
+ filePath: file,
52
+ kind: job.kind,
53
+ attemptIndex: job.attempt
54
+ });
55
+ report(this, file, result);
56
+ if (result.status === "uploaded" || result.status === "deduplicated") {
57
+ if (result.url && result.target_id) uploaded.push({
58
+ name: basename(file),
59
+ url: result.url,
60
+ targetId: result.target_id
61
+ });
62
+ } else failed = true;
63
+ }
64
+ const first = uploaded[0];
65
+ if (flags.embed && first) {
66
+ const markdown = uploaded.map((u) => embedMarkdown(u.name, u.url)).join("\n");
67
+ try {
68
+ await writeEmbed(job.target, first.targetId, markdown);
69
+ this.process.stdout.write(` embedded ${uploaded.length} file(s) on ${job.target.relatedId}\n`);
70
+ } catch (err) {
71
+ this.logger.error(`Embed failed for ${job.target.relatedId}: ${err instanceof Error ? err.message : String(err)}`);
72
+ failed = true;
73
+ }
74
+ }
75
+ }
76
+ if (failed) this.process.exitCode = 1;
77
+ }
78
+ function singleJob(args, flags) {
79
+ const [targetArg, ...files] = args;
80
+ if (!targetArg || files.length === 0) throw new Error("Usage: levr attach <target> <files...> (or levr attach --manifest <json>)");
81
+ return {
82
+ target: parseTarget(targetArg),
83
+ files,
84
+ kind: flags.kind,
85
+ attempt: flags.attempt
86
+ };
87
+ }
88
+ /** `[{ target, files[], kind?, attempt? }]` — validated before any upload. */
89
+ function readManifest(file) {
90
+ let raw;
91
+ try {
92
+ raw = JSON.parse(readFileSync(file, "utf8"));
93
+ } catch (err) {
94
+ throw new Error(`Cannot read manifest ${file}: ${err instanceof Error ? err.message : String(err)}`);
95
+ }
96
+ if (!Array.isArray(raw)) throw new Error(`Manifest ${file} must be a JSON array`);
97
+ return raw.map((entry, i) => {
98
+ const e = entry;
99
+ if (typeof e?.target !== "string") throw new Error(`Manifest entry ${i}: "target" must be a string`);
100
+ if (!Array.isArray(e.files) || e.files.length === 0 || !e.files.every((f) => typeof f === "string")) throw new Error(`Manifest entry ${i}: "files" must be a string array`);
101
+ if (e.attempt !== void 0 && !(Number.isInteger(e.attempt) && e.attempt >= 0)) throw new Error(`Manifest entry ${i}: "attempt" must be an integer >= 0`);
102
+ try {
103
+ return {
104
+ target: parseTarget(e.target),
105
+ files: e.files,
106
+ kind: typeof e.kind === "string" ? e.kind : void 0,
107
+ attempt: e.attempt
108
+ };
109
+ } catch (err) {
110
+ if (err instanceof TargetParseError) throw new Error(`Manifest entry ${i}: ${err.message}`);
111
+ throw err;
112
+ }
113
+ });
114
+ }
115
+ function embedWriter(target) {
116
+ if (target.relatedType === "run_result_variant") return "actual_result";
117
+ if (target.relatedType === "issue" || target.relatedType === "test" || target.relatedType === "run") return "comment";
118
+ }
119
+ /** Append-only: a new comment, or the server-side actual-result append. */
120
+ async function writeEmbed(target, targetId, markdown) {
121
+ if (embedWriter(target) === "actual_result") {
122
+ await appendActualResult(targetId, markdown);
123
+ return;
124
+ }
125
+ await createComment(target.relatedType, targetId, markdown);
126
+ }
127
+ function report(ctx, file, result) {
128
+ switch (result.status) {
129
+ case "uploaded":
130
+ ctx.process.stdout.write(` attached ${file} (${result.id})\n`);
131
+ return;
132
+ case "deduplicated":
133
+ ctx.process.stdout.write(` already attached ${file} (${result.id})\n`);
134
+ return;
135
+ case "skipped":
136
+ ctx.logger.warning(`skipped ${file}: larger than 50 MiB`);
137
+ return;
138
+ case "missing":
139
+ ctx.logger.error(`not found: ${file}`);
140
+ return;
141
+ case "rejected":
142
+ ctx.logger.error(`refused ${file}: ${result.message}`);
143
+ return;
144
+ case "failed":
145
+ ctx.logger.error(`failed ${file}: ${result.message}`);
146
+ return;
147
+ }
148
+ }
149
+
150
+ //#endregion
151
+ export { attachHandler };