tracksiy 0.1.0 → 0.2.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/README.md CHANGED
@@ -128,10 +128,14 @@ Labels: `bug`, `feature`, `question`, `blocker`. `npx tracksiy support` prints t
128
128
  | `performance` | Is this project slow, and did it get slower? Transactions compared with the previous window, plus uptime. | read | `performance` |
129
129
  | `issues list` | Recent issues of a project, newest activity first, with the frames that matter inlined. | read | `issues_list` |
130
130
  | `issues view <id>` | One issue in enough depth to act on: latest event, stack frames, tags, recent volume. | read | `issues_view` |
131
- | `issues resolve <ids...>` | Mark issues resolved. A new event on a resolved issue reopens it as a regression. | write | `issues_resolve` |
132
- | `issues ignore <ids...>` | Mark issues ignored: they stop alerting until unignored. | write | `issues_ignore` |
131
+ | `issues resolve <ids...>` | Mark issues resolved, optionally fixed in a release so old clients do not reopen them. Records why. | write | `issues_resolve` |
132
+ | `issues ignore <ids...>` | Mark issues ignored: they stop alerting until unignored. Records why. | write | `issues_ignore` |
133
133
  | `issues unresolve <ids...>` | Reopen issues. | write | `issues_unresolve` |
134
134
  | `issues bulk-status` | Change the status of every issue matching a filter. Dry run unless --apply. | write | `issues_bulk_status` |
135
+ | `issues comment <id>` | Leave a comment on an issue for the next agent or human: what you found, what you tried. | write | `issues_comment` |
136
+ | `issues activity <id>` | Who did what to an issue and why: resolutions (and in which release), regressions, comments, claims. | read | `issues_activity` |
137
+ | `issues claim <id>` | Take a lease on an issue so no other agent works on it meanwhile. Lapses on its own. | write | `issues_claim` |
138
+ | `issues unclaim <id>` | Give back your lease on an issue. | write | `issues_unclaim` |
135
139
  | `projects create <name>` | Everything a new app needs in one idempotent call: the project, an ingest key per surface, an upload token, and the next steps. | write | `projects_create` |
136
140
  | `projects verify` | Did the integration actually work? Checks events, releases, environments, source maps, privacy and alerting. Exit 10 if not. | read | `projects_verify` |
137
141
  | `onboarding` | What to know before integrating an app. No token needed. | read | `onboarding` |
package/dist/tracksiy.mjs CHANGED
@@ -4,7 +4,7 @@
4
4
  import { readFileSync as readFileSync2 } from "node:fs";
5
5
 
6
6
  // src/version.ts
7
- var VERSION = true ? "0.1.0" : "0.0.0-dev";
7
+ var VERSION = true ? "0.2.0" : "0.0.0-dev";
8
8
  var USER_AGENT = `tracksiy-cli/${VERSION} (node ${process.versions.node}; ${process.platform})`;
9
9
 
10
10
  // src/core/errors.ts
@@ -115,6 +115,7 @@ var Api = class {
115
115
  const headers = { "User-Agent": USER_AGENT, Accept: "application/json" };
116
116
  const useAuth = opts.auth !== false;
117
117
  if (useAuth && this.opts.token) headers.Authorization = `Bearer ${this.opts.token}`;
118
+ if (this.opts.agent) headers["X-Tracksiy-Agent"] = this.opts.agent;
118
119
  let body;
119
120
  if (opts.form) body = opts.form;
120
121
  else if (opts.body !== void 0) {
@@ -375,6 +376,8 @@ function resolveConfig(globals, env = process.env, cwd = process.cwd()) {
375
376
  verbose: Boolean(globals.verbose),
376
377
  quiet: Boolean(globals.quiet)
377
378
  };
379
+ const agent = (globals.as ?? env.TRACKSIY_AGENT ?? "").trim();
380
+ if (agent) out.agent = agent.slice(0, 100);
378
381
  if (pf) out.projectFile = pf.path;
379
382
  if (globals.token) [out.token, out.tokenSource] = [globals.token, "--token"];
380
383
  else if (env.TRACKSIY_TOKEN) [out.token, out.tokenSource] = [env.TRACKSIY_TOKEN, "TRACKSIY_TOKEN"];
@@ -641,6 +644,7 @@ function missingToken(url) {
641
644
  var auth_default = [login, status, logout];
642
645
 
643
646
  // src/commands/_util.ts
647
+ import { readFileSync as readFileSync3 } from "node:fs";
644
648
  async function projectPath(ctx, suffix = "") {
645
649
  const { org, project: project2 } = await ctx.project();
646
650
  return `projects/${seg(org)}/${seg(project2)}${suffix ? `/${suffix}` : ""}`;
@@ -679,6 +683,26 @@ function rate(value, flag) {
679
683
  function compact(obj) {
680
684
  return Object.fromEntries(Object.entries(obj).filter(([, v]) => v !== void 0));
681
685
  }
686
+ function textArg(value, flag) {
687
+ if (value === void 0 || value === null) return void 0;
688
+ const raw = String(value);
689
+ if (raw === "-") return readFileSync3(0, "utf8");
690
+ if (raw.startsWith("@")) {
691
+ try {
692
+ return readFileSync3(raw.slice(1), "utf8");
693
+ } catch (err) {
694
+ throw usageError("bad_value", `--${flag}: cannot read ${raw.slice(1)}: ${err.message}`);
695
+ }
696
+ }
697
+ return raw;
698
+ }
699
+ function duration(value, flag) {
700
+ if (value === void 0 || value === null || value === "") return void 0;
701
+ const s = String(value).trim();
702
+ const m = /^(\d+)\s*([smhdw])?$/.exec(s);
703
+ if (!m) throw usageError("bad_value", `--${flag} expects a duration like 30m / 2h / 1d, got ${JSON.stringify(s)}`);
704
+ return Number(m[1]) * (m[2] ? UNITS[m[2]] : 1);
705
+ }
682
706
 
683
707
  // src/commands/fleet.ts
684
708
  var orgs = {
@@ -920,6 +944,26 @@ var FILTERS = {
920
944
  "first-seen-since": { type: "string", value: TIME, description: "First seen at or after this: new issues only." },
921
945
  "first-seen-until": { type: "string", value: TIME, description: "First seen before this." }
922
946
  };
947
+ var WHY = {
948
+ reason: { type: "string", enum: ["fixed", "duplicate", "third_party", "expected", "wont_fix"], description: "Why, recorded on the issue's activity log." },
949
+ note: { type: "string", value: "<md|@file|->", description: "A note for the next agent (markdown), kept on the activity log." },
950
+ ref: { type: "string", value: "<sha|url>", description: "The evidence: a commit, PR or URL." }
951
+ };
952
+ var FIXED_IN = {
953
+ "in-release": { type: "string", value: "<version>", description: "Fixed in this release: events from older releases count as stale, not as a regression. Must exist (releases declare)." },
954
+ "in-commit": { type: "string", value: "<sha>", description: "Fixed in the release that carries this commit (its ref, or a version ending @<sha>); bound when that release appears." },
955
+ "in-next-release": { type: "boolean", description: "Fixed in whatever is released next: only newer releases can reopen it." }
956
+ };
957
+ function whyBody(input) {
958
+ return compact({
959
+ inRelease: input.inRelease,
960
+ inCommit: input.inCommit,
961
+ inNextRelease: input.inNextRelease || void 0,
962
+ reason: input.reason,
963
+ note: textArg(input.note, "note"),
964
+ ref: input.ref
965
+ });
966
+ }
923
967
  function filterQuery(input) {
924
968
  return compact({
925
969
  status: input.status,
@@ -952,14 +996,17 @@ var list = {
952
996
  ...FILTERS,
953
997
  sort: { type: "string", enum: ["lastSeen", "firstSeen", "count"], description: "Order. Default lastSeen." },
954
998
  limit: { type: "integer", value: "<n>", description: "How many issues (server default 25)." },
955
- frames: { type: "integer", value: "<n>", description: "In-app stack frames inlined per issue (default 3, 0 for none)." }
999
+ frames: { type: "integer", value: "<n>", description: "In-app stack frames inlined per issue (default 3, 0 for none)." },
1000
+ unclaimed: { type: "boolean", description: "Only issues nobody holds a live claim on: what you can pick up." },
1001
+ "claimed-by": { type: "string", value: "<handle>", description: "Only issues claimed by this handle." }
956
1002
  },
957
1003
  examples: [
958
1004
  { command: "tracksiy issues list --since 24h", description: "Unresolved issues seen in the last day." },
1005
+ { command: "tracksiy issues list --unclaimed --limit 5", description: "Five issues no other agent is working on." },
959
1006
  { command: "tracksiy issues list --status regressed --select issues.id,issues.title", description: "Only regressions, only ids and titles." },
960
1007
  { command: "tracksiy issues list --first-seen-since 2h --environment production", description: "What is new in production." }
961
1008
  ],
962
- output: "{project, issueCount, total, hasMore, issues:[{id, shortId, title, culprit, level, status, count, firstSeen, lastSeen, regressedAt, timesRegressed, permalink, frames?}]}",
1009
+ output: "{project, issueCount, total, hasMore, issues:[{id, shortId, title, culprit, level, status, count, firstSeen, lastSeen, regressedAt, timesRegressed, resolution?, staleEvents, claimedBy?, claimedUntil?, permalink, frames?}]}",
963
1010
  text: (d) => `${d.issueCount ?? d.issues?.length ?? 0} of ${d.total ?? "?"} issues${d.hasMore ? " (more: raise --limit)" : ""}
964
1011
  ${issueRows(d.issues)}`,
965
1012
  async run(ctx) {
@@ -967,7 +1014,9 @@ ${issueRows(d.issues)}`,
967
1014
  ...filterQuery(ctx.input),
968
1015
  sort: ctx.input.sort,
969
1016
  limit: ctx.input.limit,
970
- frames: ctx.input.frames
1017
+ frames: ctx.input.frames,
1018
+ unclaimed: ctx.input.unclaimed || void 0,
1019
+ claimedBy: ctx.input.claimedBy
971
1020
  });
972
1021
  }
973
1022
  };
@@ -980,7 +1029,7 @@ var view = {
980
1029
  args: [{ name: "id", required: true, description: "Issue id (numeric, from `issues list`)." }],
981
1030
  flags: { frames: { type: "integer", value: "<n>", description: "How many frames to include." } },
982
1031
  examples: [{ command: "tracksiy issues view 4812", description: "Everything needed to start fixing issue 4812." }],
983
- output: "{id, title, culprit, level, status, count, firstSeen, lastSeen, latestEvent{release, environment, exceptionType, exceptionValue, contexts}, frames[{filename, function, lineno, contextLine, inApp}], tags[], eventsLast24h, eventsLast7d, permalink}",
1032
+ output: "{id, title, culprit, level, status, count, firstSeen, lastSeen, resolution?, staleEvents, claimedBy?, latestEvent{release, environment, exceptionType, exceptionValue, contexts}, frames[{filename, function, lineno, contextLine, inApp}], tags[], eventsLast24h, eventsLast7d, activity[], recentComments[], permalink}",
984
1033
  text: (d) => {
985
1034
  const head = kv(d, ["id", "shortId", "title", "culprit", "level", "status", "count", "eventsLast24h", "eventsLast7d", "firstSeen", "lastSeen", "regressedAt", "permalink"]);
986
1035
  const ev = d.latestEvent ? `
@@ -988,15 +1037,22 @@ latest event:
988
1037
  ${kv(d.latestEvent, ["release", "environment", "exceptionType", "exceptionValue", "transaction", "platform"]).replace(/^/gm, " ")}` : "";
989
1038
  const frames = (d.frames ?? []).map((f) => ` ${f.inApp ? "*" : " "} ${f.filename ?? f.module ?? "?"}:${f.lineno ?? "?"} in ${f.function ?? "?"}${f.contextLine ? `
990
1039
  ${String(f.contextLine).trim()}` : ""}`).join("\n");
991
- return `${head}${ev}${frames ? `
1040
+ const res = d.resolution ? `
1041
+ resolved ${d.resolution.type.replace(/_/g, " ")}: ${d.resolution.release ?? d.resolution.commit ?? "-"}${d.resolution.pending ? " (pending)" : ""}; ${d.staleEvents ?? 0} stale event(s)` : "";
1042
+ const claim2 = d.claimedBy ? `
1043
+ claimed by ${d.claimedBy} until ${d.claimedUntil}` : "";
1044
+ const act = (d.activity ?? []).slice(0, 5).map((a) => ` ${ago(a.at)} ${a.action}${a.reason ? ` (${a.reason})` : ""} by ${a.actor ?? a.user ?? a.via}${a.note ? `: ${String(a.note).split("\n")[0].slice(0, 100)}` : ""}`).join("\n");
1045
+ return `${head}${res}${claim2}${ev}${frames ? `
992
1046
  frames (* = in app):
993
- ${frames}` : ""}`;
1047
+ ${frames}` : ""}${act ? `
1048
+ activity:
1049
+ ${act}` : ""}`;
994
1050
  },
995
1051
  async run(ctx) {
996
1052
  return ctx.api.get(await projectPath(ctx, `issues/${encodeURIComponent(ctx.input.id)}`), { frames: ctx.input.frames });
997
1053
  }
998
1054
  };
999
- function statusCommand(name, status2, summary) {
1055
+ function statusCommand(name, status2, summary, examples) {
1000
1056
  return {
1001
1057
  path: ["issues", name],
1002
1058
  summary,
@@ -1005,19 +1061,105 @@ function statusCommand(name, status2, summary) {
1005
1061
  idempotent: true,
1006
1062
  endpoint: { method: "POST", path: "/api/agent/v1/projects/{org}/{project}/issues/{issue_id}/status" },
1007
1063
  args: [{ name: "ids", required: true, variadic: true, description: "One or more issue ids." }],
1008
- examples: [{ command: `tracksiy issues ${name} 4812 4813`, description: `Mark two issues ${status2}.` }],
1009
- output: "One id: the issue's new state. Several: {matched, changed, ids, dryRun:false}.",
1064
+ flags: name === "resolve" ? { ...FIXED_IN, ...WHY } : { ...WHY },
1065
+ examples,
1066
+ output: "One id: the issue's new state with changed, requested, applied. Several: {matched, updated, alreadyInStatus, resolution, dryRun:false}.",
1010
1067
  async run(ctx) {
1011
1068
  const ids = ctx.input.ids;
1012
1069
  for (const id of ids) if (!/^\d+$/.test(id)) throw usageError("bad_value", `Issue ids are numeric; got ${JSON.stringify(id)}`);
1013
- if (ids.length === 1) return ctx.api.send("POST", await projectPath(ctx, `issues/${ids[0]}/status`), { status: status2 });
1014
- return ctx.api.send("POST", await projectPath(ctx, "issues/status"), { status: status2, ids: ids.map(Number), dryRun: false });
1070
+ const why = whyBody(ctx.input);
1071
+ if (ids.length === 1) return ctx.api.send("POST", await projectPath(ctx, `issues/${ids[0]}/status`), { status: status2, ...why });
1072
+ return ctx.api.send("POST", await projectPath(ctx, "issues/status"), { status: status2, ids, dryRun: false, ...why });
1015
1073
  }
1016
1074
  };
1017
1075
  }
1018
- var resolve2 = statusCommand("resolve", "resolved", "Mark issues resolved. A new event on a resolved issue reopens it as a regression.");
1019
- var ignore = statusCommand("ignore", "ignored", "Mark issues ignored: they stop alerting until unignored.");
1020
- var unresolve = statusCommand("unresolve", "unresolved", "Reopen issues.");
1076
+ var resolve2 = statusCommand(
1077
+ "resolve",
1078
+ "resolved",
1079
+ "Mark issues resolved, optionally fixed in a release so old clients do not reopen them. Records why.",
1080
+ [
1081
+ { command: "tracksiy issues resolve 4812 --in-release web@$(git rev-parse HEAD) --reason fixed --ref $(git rev-parse HEAD)", description: "Fixed in this deploy; old bundles count as stale." },
1082
+ { command: "tracksiy issues resolve 4812 --in-next-release --note @why.md", description: "Fixed in whatever ships next, with the reasoning." },
1083
+ { command: "tracksiy issues resolve 5562 --reason third_party --note 'WebView injects addEL_hook'", description: "Not our code: closed with the reason on record." }
1084
+ ]
1085
+ );
1086
+ var ignore = statusCommand("ignore", "ignored", "Mark issues ignored: they stop alerting until unignored. Records why.", [
1087
+ { command: "tracksiy issues ignore 4812 --reason expected --note 'Bots hitting a removed URL'", description: "Ignore, and say why for the next agent." }
1088
+ ]);
1089
+ var unresolve = statusCommand("unresolve", "unresolved", "Reopen issues.", [{ command: "tracksiy issues unresolve 4812 --note 'Came back on Android'", description: "Reopen with a note." }]);
1090
+ var comment = {
1091
+ path: ["issues", "comment"],
1092
+ summary: "Leave a comment on an issue for the next agent or human: what you found, what you tried.",
1093
+ context: "project",
1094
+ effect: "write",
1095
+ endpoint: { method: "POST", path: "/api/agent/v1/projects/{org}/{project}/issues/{issue_id}/comments" },
1096
+ args: [{ name: "id", required: true, description: "Issue id." }],
1097
+ flags: { text: { type: "string", required: true, value: "<md|@file|->", description: "The comment (markdown). @file reads a file, - reads stdin." } },
1098
+ examples: [{ command: "tracksiy issues comment 4812 --text @findings.md", description: "Attach what you found." }],
1099
+ output: "{id, created, user, text}",
1100
+ async run(ctx) {
1101
+ return ctx.api.send("POST", await projectPath(ctx, `issues/${encodeURIComponent(ctx.input.id)}/comments`), { text: textArg(ctx.input.text, "text") });
1102
+ }
1103
+ };
1104
+ var activity = {
1105
+ path: ["issues", "activity"],
1106
+ summary: "Who did what to an issue and why: resolutions (and in which release), regressions, comments, claims.",
1107
+ context: "project",
1108
+ effect: "read",
1109
+ endpoint: { method: "GET", path: "/api/agent/v1/projects/{org}/{project}/issues/{issue_id}/activity" },
1110
+ args: [{ name: "id", required: true, description: "Issue id." }],
1111
+ flags: { limit: { type: "integer", value: "<n>", description: "How many entries (1..500, default 100)." } },
1112
+ examples: [{ command: "tracksiy issues activity 4812", description: "The issue's history, newest first." }],
1113
+ output: "{issue, activity:[{at, action, via, actor, user, reason, note, ref, data}], comments:[{id, created, user, text}]}",
1114
+ text: (d) => table(d.activity ?? [], [
1115
+ ["when", (r) => ago(r.at)],
1116
+ "action",
1117
+ ["by", (r) => r.actor ?? r.user ?? r.via],
1118
+ ["reason", (r) => r.reason ?? ""],
1119
+ ["note", (r) => String(r.note ?? (r.data?.release ? `release ${r.data.release}` : "")).split("\n")[0].slice(0, 80)]
1120
+ ]),
1121
+ async run(ctx) {
1122
+ return ctx.api.get(await projectPath(ctx, `issues/${encodeURIComponent(ctx.input.id)}/activity`), { limit: ctx.input.limit });
1123
+ }
1124
+ };
1125
+ var claim = {
1126
+ path: ["issues", "claim"],
1127
+ summary: "Take a lease on an issue so no other agent works on it meanwhile. Lapses on its own.",
1128
+ description: "The holder is your handle: --as, TRACKSIY_AGENT, else the token's label. Claiming what you hold extends it. Someone else's live claim is an error naming them, unless --force. Resolving or ignoring releases it.",
1129
+ context: "project",
1130
+ effect: "write",
1131
+ endpoint: { method: "POST", path: "/api/agent/v1/projects/{org}/{project}/issues/{issue_id}/claim" },
1132
+ args: [{ name: "id", required: true, description: "Issue id." }],
1133
+ flags: {
1134
+ for: { type: "string", value: "<30m|2h|1d>", description: "Lease length (60 s to 7 days). Default 2h." },
1135
+ force: { type: "boolean", description: "Take it from another live holder." }
1136
+ },
1137
+ examples: [{ command: "TRACKSIY_AGENT=logistivo-app tracksiy issues claim 4812 --for 2h", description: "Work on 4812 for two hours." }],
1138
+ output: "The issue with claimedBy, claimedUntil, changed, previousClaimedBy.",
1139
+ async run(ctx) {
1140
+ return ctx.api.send(
1141
+ "POST",
1142
+ await projectPath(ctx, `issues/${encodeURIComponent(ctx.input.id)}/claim`),
1143
+ compact({ seconds: duration(ctx.input.for, "for"), force: ctx.input.force || void 0 })
1144
+ );
1145
+ }
1146
+ };
1147
+ var unclaim = {
1148
+ path: ["issues", "unclaim"],
1149
+ summary: "Give back your lease on an issue.",
1150
+ context: "project",
1151
+ effect: "write",
1152
+ idempotent: true,
1153
+ endpoint: { method: "DELETE", path: "/api/agent/v1/projects/{org}/{project}/issues/{issue_id}/claim" },
1154
+ args: [{ name: "id", required: true, description: "Issue id." }],
1155
+ flags: { force: { type: "boolean", description: "Release someone else's claim." } },
1156
+ examples: [{ command: "tracksiy issues unclaim 4812", description: "Done, or giving up: let others take it." }],
1157
+ output: "The issue with changed and previousClaimedBy.",
1158
+ async run(ctx) {
1159
+ const path = await projectPath(ctx, `issues/${encodeURIComponent(ctx.input.id)}/claim`);
1160
+ return (await ctx.api.request("DELETE", path, { query: ctx.input.force ? { force: true } : void 0 })).data;
1161
+ }
1162
+ };
1021
1163
  var bulk = {
1022
1164
  path: ["issues", "bulk-status"],
1023
1165
  summary: "Change the status of every issue matching a filter. Dry run unless --apply.",
@@ -1030,7 +1172,9 @@ var bulk = {
1030
1172
  ids: { type: "list", value: "<id,...>", description: "Exactly these issues." },
1031
1173
  ...FILTERS,
1032
1174
  all: { type: "boolean", description: "Every issue of the project (required when no other selector is given)." },
1033
- apply: { type: "boolean", description: "Actually change them. Default is a dry run." }
1175
+ apply: { type: "boolean", description: "Actually change them. Default is a dry run." },
1176
+ ...FIXED_IN,
1177
+ ...WHY
1034
1178
  },
1035
1179
  examples: [
1036
1180
  { command: "tracksiy issues bulk-status --to resolved --until 30d", description: "Preview resolving everything not seen for 30 days." },
@@ -1042,15 +1186,16 @@ var bulk = {
1042
1186
  const hasFilter = Object.keys(filter).length > 0;
1043
1187
  const body = compact({
1044
1188
  status: ctx.input.to,
1045
- ids: ctx.input.ids?.map(Number),
1189
+ ids: ctx.input.ids?.map(String),
1046
1190
  filter: hasFilter ? filter : void 0,
1047
1191
  all: ctx.input.all || void 0,
1048
- dryRun: !ctx.input.apply
1192
+ dryRun: !ctx.input.apply,
1193
+ ...whyBody(ctx.input)
1049
1194
  });
1050
1195
  return ctx.api.send("POST", await projectPath(ctx, "issues/status"), body);
1051
1196
  }
1052
1197
  };
1053
- var issues_default = [list, view, resolve2, ignore, unresolve, bulk];
1198
+ var issues_default = [list, view, resolve2, ignore, unresolve, bulk, comment, activity, claim, unclaim];
1054
1199
 
1055
1200
  // src/commands/projects.ts
1056
1201
  var create = {
@@ -1245,9 +1390,14 @@ var test = {
1245
1390
  context: "project",
1246
1391
  effect: "write",
1247
1392
  endpoint: { method: "POST", path: "/api/agent/v1/projects/{org}/{project}/alerts/test" },
1393
+ description: "By default only the recipients of the agent-managed (`agent:`) rules are tested; the note says how many rules made in the dashboard were left out. --all-rules tests every rule's recipients. An email recipient with no address goes to the project's team members, and is a failure when nobody is eligible.",
1394
+ flags: {
1395
+ "all-rules": { type: "boolean", description: "Also test the recipients of rules made outside the agent API (e.g. in the dashboard)." }
1396
+ },
1397
+ examples: [{ command: "tracksiy alerts test --all-rules", description: "Prove every rule on the project can reach someone." }],
1248
1398
  output: "Per-recipient delivery results with ok/status/error.",
1249
1399
  async run(ctx) {
1250
- const data = await ctx.api.send("POST", await projectPath(ctx, "alerts/test"));
1400
+ const data = await ctx.api.send("POST", await projectPath(ctx, "alerts/test"), ctx.input.allRules ? { allRules: true } : void 0);
1251
1401
  const results = data?.results ?? [];
1252
1402
  if (!results.length) {
1253
1403
  throw new CheckFailed("This project has no recipients, so nobody would be told.", data, "Declare one with `tracksiy alerts set --recipient <type>:<target>`.");
@@ -1461,7 +1611,7 @@ var releases_default = [list3, health2, declare];
1461
1611
 
1462
1612
  // src/commands/artifacts.ts
1463
1613
  import { readFile as readFile2, writeFile as writeFile2 } from "node:fs/promises";
1464
- import { existsSync as existsSync3, readFileSync as readFileSync3 } from "node:fs";
1614
+ import { existsSync as existsSync3, readFileSync as readFileSync4 } from "node:fs";
1465
1615
 
1466
1616
  // src/lib/sourcemaps.ts
1467
1617
  import { createHash } from "node:crypto";
@@ -1580,6 +1730,30 @@ function manifestFor(files, chunkSize) {
1580
1730
  return { manifest, chunks };
1581
1731
  }
1582
1732
  var sleep3 = (ms) => new Promise((r) => setTimeout(r, ms));
1733
+ function packManifest(manifest, maxFiles, maxBytes) {
1734
+ const units = [];
1735
+ for (const entry of manifest) {
1736
+ const last = units[units.length - 1];
1737
+ if (entry.kind === "sourcemap" && last && last.length === 1 && last[0].sourcemap === entry.name) last.push(entry);
1738
+ else units.push([entry]);
1739
+ }
1740
+ const parts = [];
1741
+ let part = [];
1742
+ let bytes = 0;
1743
+ for (const unit of units) {
1744
+ const size = unit.reduce((n, f) => n + f.size, 0);
1745
+ if (part.length && (part.length + unit.length > maxFiles || bytes + size > maxBytes)) {
1746
+ parts.push(part);
1747
+ part = [];
1748
+ bytes = 0;
1749
+ }
1750
+ part.push(...unit);
1751
+ bytes += size;
1752
+ }
1753
+ if (part.length) parts.push(part);
1754
+ return parts;
1755
+ }
1756
+ var STATE_RANK = { error: 4, missing_chunks: 3, pending: 2 };
1583
1757
  var UPLOAD_TIMEOUT_MS = 12e4;
1584
1758
  async function uploadArtifacts(ctx, files, opts) {
1585
1759
  const { org, project: project2 } = await ctx.project();
@@ -1588,12 +1762,14 @@ async function uploadArtifacts(ctx, files, opts) {
1588
1762
  const chunkSize = options.chunkSize ?? 8 * 1024 * 1024;
1589
1763
  const perRequest = options.maxChunksPerRequest ?? 1;
1590
1764
  const maxRequest = options.maxRequestSize ?? chunkSize;
1765
+ const maxFiles = options.maxFilesPerRequest ?? 1e3;
1766
+ const maxAssemble = options.maxAssembleSize ?? Number.MAX_SAFE_INTEGER;
1591
1767
  const { manifest, chunks } = manifestFor(files, chunkSize);
1592
1768
  const all = [...chunks.keys()];
1593
1769
  const missing = /* @__PURE__ */ new Set();
1594
1770
  for (let i = 0; i < all.length; i += 1e3) {
1595
- const res2 = await api2.send("POST", `organizations/${seg(org)}/chunks/missing`, { checksums: all.slice(i, i + 1e3) }, { retry: true });
1596
- for (const id of res2.missing ?? []) missing.add(id);
1771
+ const res = await api2.send("POST", `organizations/${seg(org)}/chunks/missing`, { checksums: all.slice(i, i + 1e3) }, { retry: true });
1772
+ for (const id of res.missing ?? []) missing.add(id);
1597
1773
  }
1598
1774
  let uploadedBytes = 0;
1599
1775
  const send = async (ids) => {
@@ -1619,31 +1795,43 @@ async function uploadArtifacts(ctx, files, opts) {
1619
1795
  };
1620
1796
  await send([...missing]);
1621
1797
  const path = `projects/${seg(org)}/${seg(project2)}/artifacts`;
1622
- const body = { release: opts.release ?? null, dist: opts.dist ?? null, files: manifest };
1623
1798
  const slow = { retry: true, timeoutMs: Math.max(UPLOAD_TIMEOUT_MS, ctx.config.timeoutMs) };
1624
- let res = await api2.send("POST", path, body, slow);
1625
- if (res?.state === "missing_chunks") {
1626
- const again = (res.missingChunks ?? []).filter((id) => chunks.has(id));
1627
- for (const id of again) missing.add(id);
1628
- await send(again);
1629
- res = await api2.send("POST", path, body, slow);
1630
- }
1631
1799
  const deadline = Date.now() + opts.waitSeconds * 1e3;
1632
- let delay = 1e3;
1633
- while (res?.state === "pending" && res.id && Date.now() < deadline) {
1634
- await sleep3(delay);
1635
- delay = Math.min(delay * 2, 5e3);
1636
- res = await api2.get(`${path}/assemblies/${seg(res.id)}`);
1637
- }
1800
+ const assemble = async (part) => {
1801
+ const body = { release: opts.release ?? null, dist: opts.dist ?? null, files: part };
1802
+ let res = await api2.send("POST", path, body, slow);
1803
+ if (res?.state === "missing_chunks") {
1804
+ const again = (res.missingChunks ?? []).filter((id) => chunks.has(id));
1805
+ for (const id of again) missing.add(id);
1806
+ await send(again);
1807
+ res = await api2.send("POST", path, body, slow);
1808
+ }
1809
+ let delay = 1e3;
1810
+ while (res?.state === "pending" && res.id && Date.now() < deadline) {
1811
+ await sleep3(delay);
1812
+ delay = Math.min(delay * 2, 5e3);
1813
+ res = await api2.get(`${path}/assemblies/${seg(res.id)}`);
1814
+ }
1815
+ return { res, part };
1816
+ };
1817
+ const parts = packManifest(manifest, maxFiles, maxAssemble);
1818
+ const done = [];
1819
+ for (const [n, part] of parts.entries()) {
1820
+ if (parts.length > 1) ctx.log(`assembling part ${n + 1}/${parts.length}: ${part.length} file(s)`);
1821
+ done.push(await assemble(part));
1822
+ }
1823
+ const states = done.map(({ res }) => res?.state ?? "unknown");
1824
+ const state = states.reduce((worst, s) => (STATE_RANK[s] ?? 0) > (STATE_RANK[worst] ?? 0) ? s : worst, states[0]);
1638
1825
  const total = [...chunks.values()].reduce((n, b) => n + b.length, 0);
1639
1826
  const result = {
1640
- id: res?.id,
1641
- state: res?.state ?? "unknown",
1827
+ id: done[0]?.res?.id,
1828
+ state,
1642
1829
  release: opts.release ?? null,
1643
- files: res?.files ?? manifest.map((m) => ({ name: m.name, kind: m.kind })),
1830
+ files: done.flatMap(({ res, part }) => res?.files ?? part.map((m) => ({ name: m.name, kind: m.kind }))),
1644
1831
  chunks: { total: all.length, uploaded: missing.size, alreadyOnServer: all.length - missing.size },
1645
1832
  bytes: { total, uploaded: uploadedBytes }
1646
1833
  };
1834
+ if (parts.length > 1) result.parts = done.map(({ res, part }) => ({ id: res?.id, state: res?.state ?? "unknown", files: part.length }));
1647
1835
  if (result.state === "error" || result.state === "missing_chunks") {
1648
1836
  const failed = result.files.filter((f) => f?.state === "error");
1649
1837
  const what = failed.length ? `${failed.length} of ${result.files.length} file(s) were refused: ${failed.slice(0, 5).map((f) => `${f.name} (${f.detail ?? "error"})`).join("; ")}${failed.length > 5 ? "; ..." : ""}` : `Assembly ended in state ${result.state}.`;
@@ -1736,7 +1924,7 @@ ${d.injected} injected, ${d.alreadyInjected} already, ${d.withoutMap} without a
1736
1924
  var sourcemapsUpload = {
1737
1925
  path: ["sourcemaps", "upload"],
1738
1926
  summary: "Upload built JavaScript and source maps so stack traces show original files and lines. Only missing bytes are sent.",
1739
- description: "Files are matched to events by debug ID (run `sourcemaps inject` first, or pass --inject) or, without debug IDs, by --release plus the file's URL. --url-prefix must match how the browser loads the files (~/ matches any host).",
1927
+ description: "Files are matched to events by debug ID (run `sourcemaps inject` first, or pass --inject) or, without debug IDs, by --release plus the file's URL. --url-prefix must match how the browser loads the files (~/ matches any host). A file's name is its path relative to the directory given, so pass the build directory: a file given on its own is named by its basename alone. Any number of files: past the server's per-assembly limit (maxFilesPerRequest, 1000) the upload is assembled in parts, each source file with its map.",
1740
1928
  context: "project",
1741
1929
  effect: "write",
1742
1930
  idempotent: true,
@@ -1755,7 +1943,7 @@ var sourcemapsUpload = {
1755
1943
  { command: "tracksiy sourcemaps upload dist --inject", description: "Inject and upload a Vite build." },
1756
1944
  { command: "tracksiy sourcemaps upload public/build --release shop@$(git rev-parse --short HEAD) --url-prefix ~/build/", description: "Release-based, for apps that set a release in the SDK." }
1757
1945
  ],
1758
- output: "{id, state, release, files[{name, kind, state, detail}], chunks{total, uploaded, alreadyOnServer}, bytes{total, uploaded}, skipped[] (minified files without a map)}",
1946
+ output: "{id, state, release, files[{name, kind, state, detail}], chunks{total, uploaded, alreadyOnServer}, bytes{total, uploaded}, parts[{id, state, files}] (only when assembled in parts), skipped[] (minified files without a map)}",
1759
1947
  text: (d) => d.dryRun ? table(d.files, ["kind", ["debug id", (r) => r.debugId ?? "-"], "name", "size"]) : `${d.state}: ${d.files.length} file(s), ${d.chunks.uploaded}/${d.chunks.total} chunks sent (${d.chunks.alreadyOnServer} already on the server)
1760
1948
  ${table(d.files, ["kind", "state", "name", "detail"])}${d.skipped?.length ? `
1761
1949
 
@@ -1798,7 +1986,7 @@ skipped, no source map: ${d.skipped.join(", ")}` : ""}`,
1798
1986
  };
1799
1987
  function uuidFromDebugMeta(path) {
1800
1988
  if (!existsSync3(path)) throw usageError("bad_path", `No such file: ${path}`);
1801
- const m = /ProguardUuids\s*[=:]\s*([0-9a-fA-F-]{36})/.exec(readFileSync3(path, "utf8"));
1989
+ const m = /ProguardUuids\s*[=:]\s*([0-9a-fA-F-]{36})/.exec(readFileSync4(path, "utf8"));
1802
1990
  if (!m) throw usageError("bad_value", `No ProguardUuids entry in ${path}`);
1803
1991
  return m[1].toLowerCase();
1804
1992
  }
@@ -2010,16 +2198,16 @@ var support = {
2010
2198
  var support_default = [support];
2011
2199
 
2012
2200
  // src/commands/init.ts
2013
- import { existsSync as existsSync5, readFileSync as readFileSync5 } from "node:fs";
2201
+ import { existsSync as existsSync5, readFileSync as readFileSync6 } from "node:fs";
2014
2202
  import { appendFile, writeFile as writeFile3 } from "node:fs/promises";
2015
2203
  import { join as join4, resolve as resolve4 } from "node:path";
2016
2204
 
2017
2205
  // src/lib/detect.ts
2018
- import { existsSync as existsSync4, readFileSync as readFileSync4, readdirSync } from "node:fs";
2206
+ import { existsSync as existsSync4, readFileSync as readFileSync5, readdirSync } from "node:fs";
2019
2207
  import { basename, join as join3 } from "node:path";
2020
2208
  function readJson(path) {
2021
2209
  try {
2022
- return JSON.parse(readFileSync4(path, "utf8"));
2210
+ return JSON.parse(readFileSync5(path, "utf8"));
2023
2211
  } catch {
2024
2212
  return void 0;
2025
2213
  }
@@ -2091,7 +2279,7 @@ function detect(dir, guideBase) {
2091
2279
  }
2092
2280
  const py = has(dir, "pyproject.toml", "requirements.txt", "Pipfile", "manage.py");
2093
2281
  if (py) {
2094
- const text = ["pyproject.toml", "requirements.txt", "Pipfile"].map((f) => existsSync4(join3(dir, f)) ? readFileSync4(join3(dir, f), "utf8") : "").join("\n").toLowerCase();
2282
+ const text = ["pyproject.toml", "requirements.txt", "Pipfile"].map((f) => existsSync4(join3(dir, f)) ? readFileSync5(join3(dir, f), "utf8") : "").join("\n").toLowerCase();
2095
2283
  const fw = existsSync4(join3(dir, "manage.py")) || text.includes("django") ? "django" : text.includes("fastapi") ? "fastapi" : text.includes("flask") ? "flask" : "python";
2096
2284
  evidence.push(`${py} (${fw})`);
2097
2285
  return { platform: fw === "python" ? "python" : `python-${fw}`, framework: fw, surfaces: ["backend"], env: { backend: "SENTRY_DSN" }, guide: guide("python-sentry-sdk"), name, evidence };
@@ -2187,7 +2375,7 @@ ${d.steps.map((s, i) => `${i + 1}. ${s.detail}${s.run ? `
2187
2375
  if (!existsSync5(envPath)) {
2188
2376
  throw usageError("no_env_file", `${envPath} does not exist; nothing was written to it.`, "Create it (and make sure it is gitignored), or set the variables yourself from `keys` in the output.");
2189
2377
  }
2190
- const current = readFileSync5(envPath, "utf8");
2378
+ const current = readFileSync6(envPath, "utf8");
2191
2379
  const added = [];
2192
2380
  const kept = [];
2193
2381
  let block = "";
@@ -2242,12 +2430,13 @@ function camel(name) {
2242
2430
  }
2243
2431
 
2244
2432
  // src/core/args.ts
2245
- import { readFileSync as readFileSync6 } from "node:fs";
2433
+ import { readFileSync as readFileSync7 } from "node:fs";
2246
2434
  var GLOBAL_FLAGS = {
2247
2435
  org: { type: "string", value: "<slug>", description: "Organization slug. Env: TRACKSIY_ORG. Default: .tracksiy.json, saved login, or your only organization." },
2248
2436
  project: { type: "string", value: "<slug>", description: "Project slug, or org/project. Env: TRACKSIY_PROJECT. Default: .tracksiy.json." },
2249
2437
  url: { type: "string", value: "<url>", description: "Server root. Env: TRACKSIY_URL. Default: https://tracksiy.com." },
2250
2438
  token: { type: "string", value: "<token>", description: "API token. Env: TRACKSIY_TOKEN (preferred: flags show up in process lists)." },
2439
+ as: { type: "string", value: "<handle>", description: "Your agent handle, recorded on issue activity and claims (X-Tracksiy-Agent). Env: TRACKSIY_AGENT. Default: the token's label." },
2251
2440
  json: { type: "boolean", description: "Print JSON. This is the default whenever stdout is not a terminal." },
2252
2441
  format: { type: "string", enum: ["json", "text"], description: "Output format. Env: TRACKSIY_FORMAT." },
2253
2442
  select: { type: "list", value: "<fields>", description: "Keep only these fields of `data`, comma-separated; dot paths reach into objects and lists (e.g. issues.id,issues.title)." },
@@ -2258,7 +2447,7 @@ var GLOBAL_FLAGS = {
2258
2447
  };
2259
2448
  var stdinCache;
2260
2449
  function readStdin() {
2261
- stdinCache ??= readFileSync6(0, "utf8");
2450
+ stdinCache ??= readFileSync7(0, "utf8");
2262
2451
  return stdinCache;
2263
2452
  }
2264
2453
  function parseJsonValue(name, raw) {
@@ -2266,7 +2455,7 @@ function parseJsonValue(name, raw) {
2266
2455
  if (raw === "-") text = readStdin();
2267
2456
  else if (raw.startsWith("@")) {
2268
2457
  try {
2269
- text = readFileSync6(raw.slice(1), "utf8");
2458
+ text = readFileSync7(raw.slice(1), "utf8");
2270
2459
  } catch (err) {
2271
2460
  throw usageError("bad_value", `--${name}: cannot read ${raw.slice(1)}: ${err.message}`);
2272
2461
  }
@@ -2606,7 +2795,7 @@ function makeContext(spec, input, globals, surface, io) {
2606
2795
  if (!quiet || surface === "mcp" && config.verbose) io.stderr(`${message}
2607
2796
  `);
2608
2797
  };
2609
- const api2 = new Api({ url: config.url, token: config.token, timeoutMs: config.timeoutMs, verbose: config.verbose, log, fetch: io.fetch });
2798
+ const api2 = new Api({ url: config.url, token: config.token, agent: config.agent, timeoutMs: config.timeoutMs, verbose: config.verbose, log, fetch: io.fetch });
2610
2799
  let orgs2;
2611
2800
  const visible = () => {
2612
2801
  orgs2 ??= api2.get("organizations").catch((err) => {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "tracksiy",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "description": "Agent-first CLI and MCP server for Tracksiy: errors, uptime, alerts, releases and source maps, from one token.",
5
5
  "type": "module",
6
6
  "bin": {