@secondlayer/sentinel 0.4.0 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/cli.js CHANGED
@@ -85,6 +85,13 @@ class SentinelError extends Error {
85
85
  this.name = "SentinelError";
86
86
  }
87
87
  }
88
+ var runInProgress = (status) => status === "queued" || status === "running";
89
+ function runPhase(run) {
90
+ if (run.status === "queued")
91
+ return "queued";
92
+ const last = run.events.at(-1);
93
+ return last ? [last.name ?? last.kind, last.state].filter(Boolean).join(" ") : "starting";
94
+ }
88
95
  var rowsOf = (intents) => intents.flatMap((i) => i.rows);
89
96
  var subjectKey = (s) => s.kind === "fn" ? `fn:${s.fn}` : s.kind === "group" ? `group:${s.group}` : `asset:${s.asset}`;
90
97
  var CONTROL_NAME = "Control changes";
@@ -191,10 +198,30 @@ function mutedUntilOf(w, now = Date.now()) {
191
198
  const at = w.delivery.mutedUntil;
192
199
  return at && Date.parse(at) > now ? at : null;
193
200
  }
201
+ function changeText(c) {
202
+ const name = c.subject.kind === "asset" ? subjectName(c.subject, c.units) : c.watch.title;
203
+ const rule = (r) => ruleSummary(r, c.units);
204
+ switch (c.action) {
205
+ case "on":
206
+ return `${c.watch.title} turned on`;
207
+ case "off":
208
+ return `${c.watch.title} turned off`;
209
+ case "muted":
210
+ case "cooldown":
211
+ return `${c.watch.title}: ${c.action === "muted" ? "mute" : "cooldown"} ${c.note ?? ""}`.trim();
212
+ case "applied":
213
+ if (c.prevRule && c.rule)
214
+ return `${name} rule changed: ${rule(c.prevRule)} → ${rule(c.rule)}`;
215
+ return c.rule ? `${name} rule set: ${rule(c.rule)}` : `${name} rule set`;
216
+ }
217
+ }
194
218
  function createClient(opts) {
195
219
  const baseUrl = (opts.baseUrl ?? DEFAULT_BASE_URL).replace(/\/+$/, "");
196
220
  const doFetch = opts.fetch ?? globalThis.fetch;
197
- const authHeader = () => ({ authorization: `Bearer ${opts.token}` });
221
+ const authHeader = () => ({
222
+ authorization: `Bearer ${opts.token}`,
223
+ ...opts.client ? { "x-sentinel-client": opts.client } : {}
224
+ });
198
225
  async function call(method, path, body) {
199
226
  const res = await doFetch(`${baseUrl}${path}`, {
200
227
  method,
@@ -211,6 +238,7 @@ function createClient(opts) {
211
238
  }
212
239
  const plan = (id) => `/v1/plans/${encodeURIComponent(id)}`;
213
240
  const appPlan = (id) => `/plan/${encodeURIComponent(id)}`;
241
+ const appProject = (id) => `/projects/${encodeURIComponent(id)}`;
214
242
  return {
215
243
  me: () => call("GET", "/v1/me"),
216
244
  plans: async () => (await call("GET", "/v1/plans")).plans,
@@ -226,6 +254,8 @@ function createClient(opts) {
226
254
  testsFromRepo: (id) => call("POST", `${appPlan(id)}/tests`, { fromRepo: true }),
227
255
  previewPlan: (contractId) => call("POST", "/plan/preview", { contractId }),
228
256
  createPlan: (contractId) => call("POST", "/plan", { contractId }),
257
+ previewImport: (url) => call("POST", "/projects/import/preview", { url }),
258
+ importRepo: (input) => call("POST", "/projects/import", input),
229
259
  billing: () => call("GET", "/billing"),
230
260
  watches: (planId) => call("GET", `${appPlan(planId)}/watches`),
231
261
  setRule: (planId, watchId, rule) => call("POST", `${appPlan(planId)}/watches/${encodeURIComponent(watchId)}/rule`, { rule }),
@@ -242,9 +272,70 @@ function createClient(opts) {
242
272
  watchContract: (contractId) => call("POST", "/contracts/watch", {
243
273
  contractId
244
274
  }),
245
- stopMonitoring: (planId) => call("DELETE", `${appPlan(planId)}/monitor`)
275
+ stopMonitoring: (planId) => call("DELETE", `${appPlan(planId)}/monitor`),
276
+ watchActivity: (planId, intentId, opts = {}) => call("GET", `${appPlan(planId)}/watches/${encodeURIComponent(intentId)}/activity${query(opts)}`),
277
+ functions: async (planId) => (await call("GET", `${appPlan(planId)}/functions`)).functions,
278
+ functionActivity: (planId, fn) => call("GET", `${appPlan(planId)}/functions/${encodeURIComponent(fn)}/activity`),
279
+ alertPreview: (planId, example) => call("GET", `${appPlan(planId)}/alert-preview${query({ example })}`),
280
+ threshold: (planId, asset) => call("GET", `${appPlan(planId)}/watches/asset/${encodeURIComponent(asset)}/threshold`),
281
+ setThreshold: (planId, asset, amount) => call("POST", `${appPlan(planId)}/watches/asset/${encodeURIComponent(asset)}/threshold`, { amount }),
282
+ retryHistory: (planId) => call("POST", `${appPlan(planId)}/history/retry`, {}),
283
+ readAlert: (alertId) => call("POST", `/alerts/${encodeURIComponent(alertId)}/read`),
284
+ readAllAlerts: () => call("POST", "/alerts/read-all"),
285
+ ask: (q) => call("POST", "/ask", { q }),
286
+ acknowledge: (planId, finding, title, reason, note = null) => call("POST", `${appPlan(planId)}/acknowledge`, {
287
+ finding,
288
+ title,
289
+ reason,
290
+ note
291
+ }),
292
+ reopen: (planId, finding, title) => call("POST", `${appPlan(planId)}/reopen`, { finding, title }),
293
+ ackHistory: async (planId) => (await call("GET", `${appPlan(planId)}/acks`)).events,
294
+ notes: async (planId, finding) => (await call("GET", `${appPlan(planId)}/findings/${finding}/notes`)).notes,
295
+ addNote: (planId, finding, title, body) => call("POST", `${appPlan(planId)}/findings/${finding}/notes`, {
296
+ title,
297
+ body
298
+ }),
299
+ tests: (planId, files) => call("POST", `${appPlan(planId)}/tests`, { files }),
300
+ fuzzCheck: (planId, harness) => call("POST", `${appPlan(planId)}/fuzzcheck`, { harness }),
301
+ fuzz: (planId, invariants) => call("POST", `${appPlan(planId)}/fuzz`, { invariants }),
302
+ latestCommit: (planId) => call("GET", `${appPlan(planId)}/latest`),
303
+ updatePlan: (planId, commit) => call("POST", `${appPlan(planId)}/update`, commit ? { commit } : {}),
304
+ setAutoPlan: (planId, auto) => call("POST", `${appPlan(planId)}/wait`, { auto }),
305
+ setLink: (planId, dependency, url) => call("POST", `${appPlan(planId)}/link`, { id: dependency, url }),
306
+ planLinked: (planId) => call("POST", `${appPlan(planId)}/plan-linked`, {}),
307
+ renameContract: (planId, name) => call("POST", `${appPlan(planId)}/settings`, { name }),
308
+ removeContract: (planId, confirm) => call("POST", `${appPlan(planId)}/remove`, {
309
+ confirm
310
+ }),
311
+ projects: () => call("GET", "/projects"),
312
+ project: (projectId) => call("GET", appProject(projectId)),
313
+ projectOverview: (projectId, opts = {}) => call("GET", `${appProject(projectId)}/overview${opts.watches ? "?watches=1" : ""}`),
314
+ renameProject: (projectId, name) => call("POST", `${appProject(projectId)}/rename`, { name }),
315
+ removeProject: (projectId, confirm) => call("POST", `${appProject(projectId)}/remove`, {
316
+ confirm
317
+ }),
318
+ answerMergeSuggestion: (from, into, accept) => call("POST", "/projects/merge", { from, into, accept }),
319
+ mergeProject: (projectId, into) => call("POST", `${appProject(projectId)}/merge-into`, { into }),
320
+ moveProjectContract: (projectId, contractId, into) => call("POST", `${appProject(projectId)}/contracts/${encodeURIComponent(contractId)}/move`, { into }),
321
+ removeProjectContract: (projectId, contractId, confirm) => call("POST", `${appProject(projectId)}/contracts/${encodeURIComponent(contractId)}/remove`, { confirm }),
322
+ previewProjectContract: (projectId, contractId) => call("POST", `${appProject(projectId)}/contracts/preview`, { contractId }),
323
+ addProjectContract: (projectId, contractId, opts = {}) => call("POST", `${appProject(projectId)}/contracts`, { contractId, ...opts }),
324
+ setProjectDestinations: (projectId, dest) => call("POST", `${appProject(projectId)}/destinations`, dest),
325
+ setProjectMonitoring: (projectId, on) => call("POST", `${appProject(projectId)}/monitoring`, { on }),
326
+ projectWatches: (projectId) => call("GET", `${appProject(projectId)}/watches`),
327
+ projectActivity: (projectId, intentId, opts = {}) => call("GET", `${appProject(projectId)}/watches/${encodeURIComponent(intentId)}/activity${query(opts)}`),
328
+ projectChanges: (projectId, opts = {}) => call("GET", `${appProject(projectId)}/changes${query(opts)}`)
246
329
  };
247
330
  }
331
+ function query(params) {
332
+ const q = new URLSearchParams;
333
+ for (const [k, v] of Object.entries(params))
334
+ if (v !== undefined && v !== null)
335
+ q.set(k, String(v));
336
+ const s = q.toString();
337
+ return s ? `?${s}` : "";
338
+ }
248
339
  var HOUR_MS = 3600000;
249
340
  var muteEnd = (hours) => new Date(Date.now() + Math.min(hours * HOUR_MS, 7 * 24 * HOUR_MS - 60000)).toISOString();
250
341
  async function watchContext(client, planId) {
@@ -418,6 +509,9 @@ async function whoami(client, io) {
418
509
  return 0;
419
510
  }
420
511
 
512
+ // cli/sentinel.ts
513
+ import { readFileSync } from "node:fs";
514
+
421
515
  // client/kind.ts
422
516
  var isGreen = (f) => f.poc === "green";
423
517
  function findingKind(f) {
@@ -432,7 +526,7 @@ function findingKind(f) {
432
526
  }
433
527
  var URGENT_CONSEQUENCES = new Set(["authority", "code", "value"]);
434
528
  var KIND_LABEL = {
435
- exploitable: "Exploitable",
529
+ exploitable: "Reproducible",
436
530
  trust: "Trust assumption",
437
531
  worth_a_look: "Worth a look",
438
532
  refuted: "refuted"
@@ -494,9 +588,9 @@ function assertionsOf(output) {
494
588
  var FENCE = "```";
495
589
  var FOOTER = "Sentinel's findings are a starting point for your own review, not an audit sign-off.";
496
590
  var KIND_LINE = {
497
- exploitable: "Exploitable (verified, re-verified, reproduced in the sandbox)",
591
+ exploitable: "Reproducible (verified, re-verified, reproduced in the sandbox)",
498
592
  trust: "Trust assumption (watched: Sentinel alerts when it's used)",
499
- worth_a_look: "Worth a look (not proven exploitable)",
593
+ worth_a_look: "Worth a look (not reproduced)",
500
594
  refuted: "Refuted"
501
595
  };
502
596
  var tick = (s) => `\`${s}\``;
@@ -587,16 +681,16 @@ function issueMd(f, withCondition) {
587
681
  "## The issue",
588
682
  f.headline && f.headline !== f.title ? `${f.headline} (${f.title})` : f.title,
589
683
  ...f.blastRadius ? ["", `**Impact:** ${f.blastRadius}`] : [],
590
- ...withCondition && f.precondition ? ["", `**Exploitable when:** ${f.precondition}`] : [],
684
+ ...withCondition && f.precondition ? ["", `**Reproduces when:** ${f.precondition}`] : [],
591
685
  ""
592
686
  ];
593
687
  }
594
688
  function prelaunchBrief(f, ctx, kind) {
595
- const kindLine = kind === "exploitable" ? "Exploitable · look before launch" : kind === "trust" ? "Trust assumption" : "Worth a look before launch";
689
+ const kindLine = kind === "exploitable" ? "Reproducible · look before launch" : kind === "trust" ? "Trust assumption" : "Worth a look before launch";
596
690
  return [
597
691
  "# Task: review a Sentinel finding on pre-launch code",
598
692
  "",
599
- `Sentinel found an issue${f.targetFn ? ` in ${tick(f.targetFn)}` : ""}: ${f.title}. PoCs and exploit detail open once Sentinel confirms this code isn't already live on mainnet; possible approaches are shown now, so this brief carries the finding and a direction, not the PoC. Look at the code before launch: the watch arms at launch, but it isn't a substitute for the review.`,
693
+ `Sentinel found an issue${f.targetFn ? ` in ${tick(f.targetFn)}` : ""}: ${f.title}. PoCs and reproduction detail open once Sentinel confirms this code isn't already live on mainnet; possible approaches are shown now, so this brief carries the finding and a direction, not the PoC. Look at the code before launch: the watch arms at launch, but it isn't a substitute for the review.`,
600
694
  "",
601
695
  "## Start here",
602
696
  `1. ${where(f, ctx)}`,
@@ -628,7 +722,7 @@ function trustBrief(f, ctx) {
628
722
  `2. Find every place that reads what ${f.targetFn ? tick(f.targetFn) : "this power"} changes, and show each one, so the reach of this power is clear.`,
629
723
  "3. Only then discuss options that fit this codebase's design.",
630
724
  "",
631
- ...reportBackMd(ctx, "- There is no PoC for a trust assumption. Don't write an exploit for it."),
725
+ ...reportBackMd(ctx, "- There is no PoC for a trust assumption. Don't write a PoC for it."),
632
726
  ...contextMd(f, ctx, "trust"),
633
727
  "- Class: trust assumption (a power held by design)",
634
728
  "- Evidence: checked by Sentinel's adversarial verifier. No PoC; nothing to run.",
@@ -750,6 +844,9 @@ var USAGE = `sentinel <command>
750
844
  logout forget the saved login (also signs the secondlayer CLI out)
751
845
  whoami who you are and where the credential came from
752
846
  new <contract> [--yes] plan a contract: see what it would do and cost; --yes starts it
847
+ import <repo-url> [--only a,b] [--all] [--project <id>|new] [--yes]
848
+ plan a public Clarinet repo's contracts into one project: see each one and the
849
+ cost (the suggested ones, unless --only or --all); --yes imports them
753
850
  balance your balance and what a plan, a deep run and a monitored event cost
754
851
  alerts [--contract <id>] your alerts, newest first
755
852
 
@@ -758,7 +855,7 @@ var USAGE = `sentinel <command>
758
855
  finding <id> <n> [--poc] [--run] [--brief] [--json] one finding; --poc adds the PoC source, --run the command
759
856
  to re-run it, --brief the agent brief (the finding page's "Copy agent brief")
760
857
  runs <id> follow-up runs on a plan
761
- watch <id> follow running runs until none are left
858
+ watch <id> follow running (and queued) runs until none are left
762
859
  reverify <id> [--yes] check a plan's leads again (stronger second pass, $3); only starts with --yes
763
860
 
764
861
  paid runs on a plan you own (each starts only with --yes; without it you see what it would do):
@@ -766,10 +863,44 @@ paid runs on a plan you own (each starts only with --yes; without it you see wha
766
863
  challenge <plan> <n> "<reason>" [--yes] re-verify finding n against your argument
767
864
  replay <plan> <n> [--yes] re-run finding n's stored PoC
768
865
  directed <plan> "<what to watch>" [--yes] re-plan around what you want watched
769
- tests <plan> --from-repo [--yes] run the Clarinet suite from a pre-launch plan's repo
866
+ tests <plan> --from-repo|<file>... [--yes] run the Clarinet suite from a pre-launch plan's repo, or your test files
867
+ fuzz <plan> <invariants.clar> [--yes] compile-check the invariants (free); --yes fuzzes them in the sandbox
868
+
869
+ your plans and findings (owners; a write only happens with --yes):
870
+ latest <plan> whether a pre-launch plan's branch moved on, and what an update reviews
871
+ update <plan> [--commit <sha>] [--yes] plan the latest commit (or --commit): free while monitoring is on
872
+ auto-plan <plan> on|off [--yes] plan a waiting plan on its own once the contracts it waits on deploy
873
+ link <plan> <contract> <url>|--clear [--yes] link code for a contract the plan waits on
874
+ plan-linked <plan> [--yes] plan now with the linked code (a paid plan)
875
+ name <plan> "<name>" [--yes] your own name for the contract ("" clears it)
876
+ remove <plan> --confirm <name> [--yes] take the contract off your account: monitoring, alerts and claim
877
+ ack <plan> <n> accepted|fixed|disagree [--note "<text>"] [--yes] label finding n for your team
878
+ reopen <plan> <n> [--yes] put an acknowledged finding back on the list
879
+ acks <plan> every acknowledgement change on the contract
880
+ notes <plan> <n> your notes on finding n
881
+ note <plan> <n> "<text>" [--yes] add a note to finding n
882
+ ask "<question>" an answer from Sentinel's docs
883
+
884
+ projects (the contracts of one protocol; a write only happens with --yes):
885
+ projects [--json] your projects and the merges Sentinel suggests
886
+ projects show <project> [--json] its contracts, each one's plan and monitoring
887
+ projects overview <project> [--watches] the project, its plans and (--watches) watches, as JSON
888
+ projects add <project> <contract> [--into <project>|new] [--yes] where it would go; --yes plans it (paid)
889
+ projects rename <project> "<name>" [--yes]
890
+ projects merge <project> <into> [--yes] move every contract into another of your projects
891
+ projects suggestion <from> <into> accept|dismiss [--yes] answer a merge Sentinel suggested
892
+ projects move <project> <contract> <into>|new [--yes]
893
+ projects remove-contract <project> <contract> [--confirm <name>] [--yes] a contract you added needs --confirm
894
+ projects remove <project> --confirm "<name>" [--yes] every contract you added in it, and the project
895
+ projects watches <project> [--json] the project's watches as one list
896
+ projects activity <project> <watch> [--contract <id>] [--window 7|90] [--json]
897
+ projects changes <project> [--watch <watch>] [--mutes] [--limit N] [--cursor C] [--json] who turned watches on/off or changed rules
898
+ projects destinations <project> [--email on|off] [--webhook URL] [--slack URL] [--yes] a URL left out is cleared
899
+ projects monitoring <project> on|off [--yes]
770
900
 
771
- A key needs Plans & findings (read for the above, write for new and the runs above); monitoring commands need Monitoring
772
- (read to look, write to change); mute-alert needs Alerts: write.
901
+ A key needs Plans & findings (read for the above, write for new, the runs and changes above, and project changes);
902
+ monitoring commands and a project's watches, destinations and monitoring need Monitoring (read to look, write to
903
+ change); mute-alert and mark-read need Alerts: write.
773
904
 
774
905
  monitoring (a write only happens with --yes; without it you see what would change):
775
906
  watches <plan> [--json] subjects, rules, status, mutes
@@ -783,23 +914,42 @@ monitoring (a write only happens with --yes; without it you see what would chang
783
914
  test-alert <plan> [--yes] a sample alert to every saved destination
784
915
  follow <contract> [--yes] start monitoring a deployed contract
785
916
  unfollow <contract> [--yes]
917
+ activity <plan> <watch> [--window 7|90] [--json] what a watch saw (<watch>: its title or id) and what alerted
918
+ functions <plan> [<fn>] [--json] every public function and its watch; with <fn>, what a watch
919
+ on it would have seen
920
+ alert-preview <plan> [--example money_out|finding] a sample alert as email and Slack get it
921
+ threshold <plan> <asset> [<amount>] [--yes] an asset's Money out limit; with <amount> (base units), set it
922
+ retry-history <plan> [--yes] read the watches' history again after a failed read
923
+ mark-read <alert>|--all [--yes] mark one alert, or every alert, read
786
924
 
787
925
  <subject> is a function name or a token symbol, as the watches command shows it.
788
926
 
789
927
  credential: SENTINEL_API_KEY, then --token <key>, then sentinel login. env: SENTINEL_API_URL
790
928
  `;
791
929
  var POLL_MS = 5000;
792
- var VALUE_FLAGS = new Set(["--email", "--webhook", "--slack", "--contract"]);
930
+ var VALUE_FLAGS = new Set([
931
+ "--email",
932
+ "--webhook",
933
+ "--slack",
934
+ "--contract",
935
+ "--only",
936
+ "--project",
937
+ "--confirm",
938
+ "--note",
939
+ "--commit",
940
+ "--into",
941
+ "--window",
942
+ "--example",
943
+ "--watch",
944
+ "--limit",
945
+ "--cursor"
946
+ ]);
793
947
  var cell2 = (v, empty = "—") => v === null || v === undefined || v === "" ? empty : String(v);
794
948
  var table = (rows) => {
795
949
  const text = rows.map((r) => r.map((c) => cell2(c)));
796
950
  const w = text[0]?.map((_, i) => Math.max(...text.map((r) => r[i].length))) ?? [];
797
951
  return text.map((r) => r.map((c, i) => c.padEnd(w[i])).join(" ").trimEnd());
798
952
  };
799
- var phaseOf = (r) => {
800
- const last = r.events.at(-1);
801
- return last ? [last.name ?? last.kind, last.state].filter(Boolean).join(" ") : "starting";
802
- };
803
953
  var verdictLine = (p) => {
804
954
  const r = p.report;
805
955
  const head = `${p.contractId} ${p.status}`;
@@ -837,7 +987,10 @@ ${USAGE}` : USAGE);
837
987
  "--from-repo",
838
988
  "--poc",
839
989
  "--run",
840
- "--brief"
990
+ "--brief",
991
+ "--all",
992
+ "--clear",
993
+ "--watches"
841
994
  ]);
842
995
  if (flags.some((f) => !known.has(f)))
843
996
  return usage(`Unknown flag ${flags.find((f) => !known.has(f))}`);
@@ -949,7 +1102,7 @@ No run command: no PoC is stored for this finding.`);
949
1102
  r.id,
950
1103
  r.kind,
951
1104
  r.status,
952
- r.status === "running" ? phaseOf(r) : r.finishedAt ?? ""
1105
+ runInProgress(r.status) ? runPhase(r) : r.finishedAt ?? ""
953
1106
  ])))
954
1107
  io.out(l);
955
1108
  return 0;
@@ -964,9 +1117,9 @@ No run command: no PoC is stored for this finding.`);
964
1117
  const sleep = io.sleep ?? ((ms) => new Promise((r) => setTimeout(r, ms)));
965
1118
  const seen = new Map;
966
1119
  for (;; ) {
967
- const running = (await io.client.runs(id)).filter((r) => r.status === "running");
1120
+ const running = (await io.client.runs(id)).filter((r) => runInProgress(r.status));
968
1121
  for (const r of running) {
969
- const phase = phaseOf(r);
1122
+ const phase = runPhase(r);
970
1123
  if (seen.get(r.id) !== phase) {
971
1124
  seen.set(r.id, phase);
972
1125
  io.out(`${r.kind} ${r.id}: ${phase}`);
@@ -1019,6 +1172,8 @@ No run command: no PoC is stored for this finding.`);
1019
1172
  io.out(`Follow it with: sentinel runs ${r.planId}`);
1020
1173
  return 0;
1021
1174
  }
1175
+ case "import":
1176
+ return await importRepo(args.slice(1), flags, values, io, usage);
1022
1177
  case "balance": {
1023
1178
  const b = await io.client.billing();
1024
1179
  if (json)
@@ -1051,6 +1206,39 @@ No run command: no PoC is stored for this finding.`);
1051
1206
  case "directed":
1052
1207
  case "tests":
1053
1208
  return await ownerRun(cmd, args.slice(1), flags, io, usage);
1209
+ case "fuzz":
1210
+ return await fuzz(args.slice(1), flags, io, usage);
1211
+ case "latest":
1212
+ case "update":
1213
+ case "auto-plan":
1214
+ case "link":
1215
+ case "plan-linked":
1216
+ case "name":
1217
+ case "remove":
1218
+ case "ack":
1219
+ case "reopen":
1220
+ case "acks":
1221
+ case "notes":
1222
+ case "note":
1223
+ return await upkeep(cmd, args.slice(1), flags, values, io, usage);
1224
+ case "ask": {
1225
+ if (!id)
1226
+ return usage("sentinel ask needs a question, in quotes");
1227
+ const a = await io.client.ask(args.slice(1).join(" "));
1228
+ io.out(a.answer);
1229
+ if (a.sources.length)
1230
+ io.out(`Sources: ${a.sources.join(", ")}`);
1231
+ return 0;
1232
+ }
1233
+ case "projects":
1234
+ return await projects(args.slice(1), flags, values, io, usage);
1235
+ case "activity":
1236
+ case "functions":
1237
+ case "alert-preview":
1238
+ case "threshold":
1239
+ case "retry-history":
1240
+ case "mark-read":
1241
+ return await monitoringMore(cmd, args.slice(1), flags, values, io, usage);
1054
1242
  case "watches":
1055
1243
  case "rule":
1056
1244
  case "mute":
@@ -1071,6 +1259,64 @@ No run command: no PoC is stored for this finding.`);
1071
1259
  }
1072
1260
  var usd = (n) => `$${n.toFixed(2)}`;
1073
1261
  var RUN_NOTE = "A paid run from your balance, charged only if it finishes.";
1262
+ async function importRepo(pos, flags, values, io, usage) {
1263
+ const [url] = pos;
1264
+ if (!url)
1265
+ return usage("sentinel import needs a GitHub repo link");
1266
+ const only = values["--only"]?.split(",").map((n) => n.trim()).filter(Boolean);
1267
+ if (only && flags.includes("--all"))
1268
+ return usage("Use --only or --all, not both");
1269
+ const p = await io.client.previewImport(url);
1270
+ const picked = only ? only : p.contracts.filter((c) => flags.includes("--all") || c.suggested).map((c) => c.name);
1271
+ const missing = picked.filter((n) => !p.contracts.some((c) => c.name === n));
1272
+ if (missing.length)
1273
+ return usage(`Not in ${p.repo} at ${p.commit.slice(0, 7)}: ${missing.join(", ")}`);
1274
+ io.out(`${p.owner}/${p.repo}${p.root ? `/${p.root}` : ""} @ ${p.commit.slice(0, 7)} (${p.ref})`);
1275
+ for (const l of table([
1276
+ [" ", "NAME", "DEPS", "STATE", "SUGGESTED"],
1277
+ ...p.contracts.map((c) => [
1278
+ picked.includes(c.name) ? "+" : " ",
1279
+ c.name,
1280
+ c.deps.join(",") || "—",
1281
+ c.state,
1282
+ c.suggested ? "yes" : `no (${c.why})`
1283
+ ])
1284
+ ]))
1285
+ io.out(l);
1286
+ if (p.cycle)
1287
+ io.out("These contracts call each other in a loop: listed in manifest order.");
1288
+ for (const s of p.skipped)
1289
+ io.out(`Skipped ${s.path}: ${s.reason}`);
1290
+ for (const d of p.mainnetMatch.deployers)
1291
+ io.out(`Live on mainnet under ${d.deployer}: ${d.files.map((f) => `${f.name} (${f.kind} match of ${f.contractId})`).join(", ")}`);
1292
+ if (picked.length === 0) {
1293
+ io.out("Nothing to import. Pick contracts with --only a,b or --all.");
1294
+ return 0;
1295
+ }
1296
+ const fresh = p.contracts.filter((c) => picked.includes(c.name) && c.state === "new").length;
1297
+ io.out(fresh === 0 ? `Importing ${picked.length}: no new plan, nothing to pay.` : p.cost.runUsdMicros === null ? `Importing ${picked.length}: ${fresh} new plan${fresh === 1 ? "" : "s"}. Billing isn't linked: the free daily plan limit applies.` : `Importing ${picked.length}: ${fresh} new plan${fresh === 1 ? "" : "s"}, ${usd(p.cost.runUsdMicros * fresh / 1e6)}, each charged only if it finishes.`);
1298
+ if (fresh > p.runsAtOnce)
1299
+ io.out(`Plans run ${p.runsAtOnce} at a time; the rest wait their turn (queued).`);
1300
+ const project = values["--project"];
1301
+ if (!flags.includes("--yes")) {
1302
+ const tail = [
1303
+ only ? `--only ${only.join(",")}` : flags.includes("--all") ? "--all" : "",
1304
+ project ? `--project ${project}` : ""
1305
+ ].filter(Boolean).join(" ");
1306
+ io.out(`Run again with --yes to import: sentinel import ${url}${tail ? ` ${tail}` : ""} --yes`);
1307
+ return 0;
1308
+ }
1309
+ const r = await io.client.importRepo({ url, commit: p.commit, contracts: picked, project });
1310
+ io.out(`Project: ${r.projectId ?? "—"}`);
1311
+ for (const x of r.plans)
1312
+ io.out(`${x.name} ${x.status === "kept" ? "already in the project" : x.cached ? "already planned" : x.status === "queued" ? "queued" : "started"} ${x.planId ?? "—"}`);
1313
+ for (const x of r.notStarted ?? [])
1314
+ io.out(`${x.name} not started: ${x.error}`);
1315
+ const first = r.plans.find((x) => x.planId)?.planId;
1316
+ if (first)
1317
+ io.out(`Follow a plan with: sentinel runs ${first}`);
1318
+ return 0;
1319
+ }
1074
1320
  async function ownerRun(cmd, pos, flags, io, usage) {
1075
1321
  const [planId, second, third] = pos;
1076
1322
  if (!planId)
@@ -1085,16 +1331,19 @@ async function ownerRun(cmd, pos, flags, io, usage) {
1085
1331
  return usage("sentinel challenge needs a reason, in quotes");
1086
1332
  if (cmd === "directed" && !second)
1087
1333
  return usage("sentinel directed needs what to watch, in quotes");
1088
- if (cmd === "tests" && !flags.includes("--from-repo"))
1089
- return usage("sentinel tests needs --from-repo (a pre-launch plan's repo tests)");
1334
+ const fromRepo = flags.includes("--from-repo");
1335
+ const testPaths = cmd === "tests" ? pos.slice(1) : [];
1336
+ if (cmd === "tests" && fromRepo === testPaths.length > 0)
1337
+ return usage("sentinel tests needs --from-repo (a pre-launch plan's repo tests) or your test files");
1338
+ const files = testPaths.map((path) => ({ path, content: readLocal(io, path) }));
1090
1339
  const willDo = {
1091
1340
  reproduce: `Write and run a PoC for finding ${finding} in the airgapped sandbox (never touches mainnet).`,
1092
1341
  replay: `Re-run finding ${finding}'s stored PoC in the airgapped sandbox.`,
1093
1342
  challenge: `Re-verify finding ${finding} against your argument.`,
1094
1343
  directed: "Re-plan this contract around what you asked to have watched.",
1095
- tests: "Run the Clarinet suite from this pre-launch plan's repo in the airgapped sandbox."
1344
+ tests: fromRepo ? "Run the Clarinet suite from this pre-launch plan's repo in the airgapped sandbox." : `Run ${files.length} test file(s) against this plan's contract in the airgapped sandbox.`
1096
1345
  }[cmd];
1097
- const flagTail = cmd === "tests" ? " --from-repo" : "";
1346
+ const flagTail = cmd === "tests" ? fromRepo ? " --from-repo" : ` ${testPaths.join(" ")}` : "";
1098
1347
  const quoted = (v) => v ? ` ${JSON.stringify(v)}` : "";
1099
1348
  const argTail = {
1100
1349
  reproduce: ` ${finding}`,
@@ -1114,7 +1363,7 @@ async function ownerRun(cmd, pos, flags, io, usage) {
1114
1363
  replay: () => io.client.replay(planId, finding),
1115
1364
  challenge: () => io.client.challenge(planId, finding, third),
1116
1365
  directed: () => io.client.directed(planId, second),
1117
- tests: () => io.client.testsFromRepo(planId)
1366
+ tests: () => fromRepo ? io.client.testsFromRepo(planId) : io.client.tests(planId, files)
1118
1367
  }[cmd]();
1119
1368
  io.out(`${r.existing ? "Already running" : "Started"}: ${r.planId}`);
1120
1369
  return 0;
@@ -1276,6 +1525,571 @@ async function monitoring(cmd, pos, on, flags, values, io, usage) {
1276
1525
  io.out(mute ? `Muted ${g.name} until ${mutedUntil}` : `Unmuted ${g.name}.`);
1277
1526
  return 0;
1278
1527
  }
1528
+ function readLocal(io, path) {
1529
+ try {
1530
+ return (io.readFile ?? ((p) => readFileSync(p, "utf8")))(path);
1531
+ } catch (e) {
1532
+ throw new SentinelError(0, `Can't read ${path}: ${e.message}`);
1533
+ }
1534
+ }
1535
+ var SHA = /^[0-9a-f]{40}$/;
1536
+ var short = (sha) => sha.slice(0, 7);
1537
+ var onOff = (v) => v === "on" ? true : v === "off" ? false : null;
1538
+ async function fuzz(pos, flags, io, usage) {
1539
+ const [planId, file] = pos;
1540
+ if (!planId || !file)
1541
+ return usage("sentinel fuzz needs a plan id and an invariants file");
1542
+ const harness = readLocal(io, file);
1543
+ const check = await io.client.fuzzCheck(planId, harness);
1544
+ if (!check.ok) {
1545
+ io.err(`The invariants don't compile: ${check.message ?? "no detail"}`);
1546
+ return 1;
1547
+ }
1548
+ io.out("The invariants compile.");
1549
+ if (!flags.includes("--yes")) {
1550
+ io.out("Fuzz the contract against them in the airgapped sandbox (never touches mainnet).");
1551
+ io.out(RUN_NOTE);
1552
+ io.out(`Run again with --yes to start: sentinel fuzz ${planId} ${file} --yes`);
1553
+ return 0;
1554
+ }
1555
+ const r = await io.client.fuzz(planId, harness);
1556
+ io.out(`Started: ${r.planId}`);
1557
+ return 0;
1558
+ }
1559
+ async function findingTitle(io, planId, n, usage) {
1560
+ if (n === undefined || !/^\d+$/.test(n))
1561
+ return usage("It needs a plan id and a finding number");
1562
+ return (await io.client.finding(planId, Number(n))).title;
1563
+ }
1564
+ async function upkeep(cmd, pos, flags, values, io, usage) {
1565
+ const [planId, second, third] = pos;
1566
+ if (!planId)
1567
+ return usage(`sentinel ${cmd} needs a plan id`);
1568
+ const yes = flags.includes("--yes");
1569
+ switch (cmd) {
1570
+ case "latest": {
1571
+ const l = await io.client.latestCommit(planId);
1572
+ if (l.status === "unsupported")
1573
+ io.out("Only a plan made from a GitHub link follows newer commits.");
1574
+ else if (l.status === "current")
1575
+ io.out(`On the latest commit of ${l.ref}: ${short(l.commit)}.`);
1576
+ else if (l.status === "gone")
1577
+ io.out(`The file isn't on ${l.ref} anymore (head ${short(l.head)}).`);
1578
+ else {
1579
+ io.out(`${l.ahead} commit(s) behind ${l.ref}: ${short(l.commit)} → ${short(l.head)}. An update would be ${l.mode}${l.affected.length ? ` (${l.affected.join(", ")})` : ""}.`);
1580
+ io.out(l.free ? "Free: monitoring is on." : "Priced as a new plan: monitoring is off.");
1581
+ io.out(`Plan it with: sentinel update ${planId} --yes`);
1582
+ }
1583
+ return 0;
1584
+ }
1585
+ case "update": {
1586
+ const commit = values["--commit"];
1587
+ if (commit !== undefined && !SHA.test(commit))
1588
+ return usage("--commit takes a full 40-character sha");
1589
+ const willDo = `Plan ${commit ? `commit ${short(commit)}` : "the branch's latest commit"}; the new plan replaces this one when it finishes.`;
1590
+ if (previewOnly(io, flags, willDo, "Free while monitoring is on, else a paid plan."))
1591
+ return 0;
1592
+ const r = await io.client.updatePlan(planId, commit);
1593
+ io.out(`${r.existing ? "Already running" : "Started"}: ${r.planId}${r.mode ? ` (${r.mode})` : ""}`);
1594
+ return 0;
1595
+ }
1596
+ case "auto-plan": {
1597
+ const on = onOff(second);
1598
+ if (on === null)
1599
+ return usage("sentinel auto-plan takes on or off");
1600
+ const willDo = on ? "Plan this on its own once every contract it waits on is deployed (a paid plan)." : "Don't plan this on its own when its contracts deploy.";
1601
+ if (previewOnly(io, flags, willDo, "Takes effect now."))
1602
+ return 0;
1603
+ await io.client.setAutoPlan(planId, on);
1604
+ io.out(`Auto-plan is ${on ? "on" : "off"}.`);
1605
+ return 0;
1606
+ }
1607
+ case "link": {
1608
+ const clear = flags.includes("--clear");
1609
+ if (!second || clear === Boolean(third))
1610
+ return usage("sentinel link needs a plan id, the contract it waits on, and a link or --clear");
1611
+ const willDo = clear ? `Clear the code linked for ${second}.` : `Link ${third} as the code of ${second} (a repo link also fills the others it holds).`;
1612
+ if (previewOnly(io, flags, willDo, "Takes effect now."))
1613
+ return 0;
1614
+ const r = await io.client.setLink(planId, second, clear ? null : third);
1615
+ io.out(`Linked: ${r.links.map((l) => l.id).join(", ") || "nothing"}`);
1616
+ return 0;
1617
+ }
1618
+ case "plan-linked": {
1619
+ if (!yes) {
1620
+ io.out("Plan this contract now, with the linked code loaded at its real principals.");
1621
+ io.out(RUN_NOTE);
1622
+ io.out(`Run again with --yes to start: sentinel plan-linked ${planId} --yes`);
1623
+ return 0;
1624
+ }
1625
+ const r = await io.client.planLinked(planId);
1626
+ io.out(`Started: ${r.planId}`);
1627
+ return 0;
1628
+ }
1629
+ case "name": {
1630
+ if (second === undefined)
1631
+ return usage('sentinel name needs a plan id and a name ("" clears it)');
1632
+ const willDo = second.trim() ? `Call this contract "${second.trim()}".` : "Clear your name for this contract.";
1633
+ if (previewOnly(io, flags, willDo, "Takes effect now."))
1634
+ return 0;
1635
+ const r = await io.client.renameContract(planId, second);
1636
+ io.out(r.name ? `Named: ${r.name}` : "Name cleared.");
1637
+ return 0;
1638
+ }
1639
+ case "remove": {
1640
+ const p = await io.client.plan(planId);
1641
+ const name = contractName(p.contractId);
1642
+ const confirm = values["--confirm"];
1643
+ if (confirm === undefined)
1644
+ return usage(`sentinel remove needs --confirm ${name} (the contract's name, typed)`);
1645
+ const willDo = `Take ${p.contractId} off your account: its monitoring, alerts and claim go.`;
1646
+ if (previewOnly(io, flags, willDo, "Can't be undone."))
1647
+ return 0;
1648
+ const r = await io.client.removeContract(planId, confirm);
1649
+ io.out(`Removed ${p.contractId}${r.monitoringOff ? "; monitoring is off" : ""}.`);
1650
+ return 0;
1651
+ }
1652
+ case "ack":
1653
+ case "reopen": {
1654
+ const title = await findingTitle(io, planId, second, usage);
1655
+ if (typeof title === "number")
1656
+ return title;
1657
+ if (cmd === "reopen") {
1658
+ if (previewOnly(io, flags, `Put "${title}" back on the list.`, "Takes effect now."))
1659
+ return 0;
1660
+ await io.client.reopen(planId, Number(second), title);
1661
+ io.out(`Reopened: ${title}`);
1662
+ return 0;
1663
+ }
1664
+ if (third !== "accepted" && third !== "fixed" && third !== "disagree")
1665
+ return usage("sentinel ack takes a reason: accepted, fixed or disagree");
1666
+ const note = values["--note"] ?? null;
1667
+ const willDo = `Label "${title}" ${third}${note ? `, noting: ${note}` : ""}. It changes no verdict.`;
1668
+ if (previewOnly(io, flags, willDo, "Your team sees it now."))
1669
+ return 0;
1670
+ const r = await io.client.acknowledge(planId, Number(second), title, third, note);
1671
+ io.out(`Labelled ${third}: ${title}`);
1672
+ if (r.watchId)
1673
+ io.out(`A watch on it is suggested (${r.watchId}): turn it on to keep watching.`);
1674
+ return 0;
1675
+ }
1676
+ case "acks": {
1677
+ const events = await io.client.ackHistory(planId);
1678
+ if (events.length === 0)
1679
+ io.out("No acknowledgements yet.");
1680
+ for (const l of table(events.map((e) => [e.at, e.action, e.reason, e.by ?? "Sentinel", e.title, e.note])))
1681
+ io.out(l);
1682
+ return 0;
1683
+ }
1684
+ case "notes": {
1685
+ if (second === undefined || !/^\d+$/.test(second))
1686
+ return usage("sentinel notes needs a plan id and a finding number");
1687
+ const notes = await io.client.notes(planId, Number(second));
1688
+ if (notes.length === 0)
1689
+ io.out("No notes yet.");
1690
+ for (const n of notes)
1691
+ io.out(`${n.at} ${n.system ? "Sentinel" : n.by ?? ""}
1692
+ ${n.body}`);
1693
+ return 0;
1694
+ }
1695
+ default: {
1696
+ const title = await findingTitle(io, planId, second, usage);
1697
+ if (typeof title === "number")
1698
+ return title;
1699
+ if (!third?.trim())
1700
+ return usage("sentinel note needs the note, in quotes");
1701
+ if (previewOnly(io, flags, `Add a note to "${title}": ${third.trim()}`, "Your team sees it now."))
1702
+ return 0;
1703
+ await io.client.addNote(planId, Number(second), title, third);
1704
+ io.out("Noted.");
1705
+ return 0;
1706
+ }
1707
+ }
1708
+ }
1709
+ function pickIntent(intents, name) {
1710
+ const q = name.trim().toLowerCase();
1711
+ const hit = intents.find((i) => i.id === name) ?? intents.find((i) => i.title.toLowerCase() === q);
1712
+ if (hit)
1713
+ return hit;
1714
+ throw new SubjectError(`No watch called "${name}". Watches: ${intents.map((i) => i.title).join(", ") || "none yet"}`, intents.map((i) => i.title));
1715
+ }
1716
+ function windowOf(values) {
1717
+ const w = values["--window"];
1718
+ if (w === undefined)
1719
+ return;
1720
+ return w === "7" ? 7 : w === "90" ? 90 : null;
1721
+ }
1722
+ function printActivity(io, title, a) {
1723
+ io.out(`${title}: ${a.counts.total} event(s), ${a.counts.over} over a rule's line, last ${a.window} days${a.history === "ready" ? "" : ` (history ${a.history})`}`);
1724
+ for (const l of table(a.events.map((e) => [
1725
+ e.t,
1726
+ e.fn,
1727
+ e.value !== null && a.unit ? `${toUnits(e.value, a.unit.decimals)} ${a.unit.symbol}` : e.value,
1728
+ e.caller,
1729
+ e.result
1730
+ ])))
1731
+ io.out(l);
1732
+ }
1733
+ async function monitoringMore(cmd, pos, flags, values, io, usage) {
1734
+ const [first, second, third] = pos;
1735
+ const json = flags.includes("--json");
1736
+ if (cmd === "mark-read") {
1737
+ const all = flags.includes("--all");
1738
+ if (all === Boolean(first))
1739
+ return usage("sentinel mark-read needs an alert id or --all");
1740
+ if (previewOnly(io, flags, all ? "Mark every alert read." : `Mark alert ${first} read.`, ""))
1741
+ return 0;
1742
+ if (all)
1743
+ io.out(`Marked ${(await io.client.readAllAlerts()).count} read.`);
1744
+ else {
1745
+ await io.client.readAlert(first);
1746
+ io.out("Marked read.");
1747
+ }
1748
+ return 0;
1749
+ }
1750
+ if (!first)
1751
+ return usage(`sentinel ${cmd} needs a plan id`);
1752
+ const planId = first;
1753
+ switch (cmd) {
1754
+ case "activity": {
1755
+ if (!second)
1756
+ return usage("sentinel activity needs a plan id and a watch (its title or id)");
1757
+ const window = windowOf(values);
1758
+ if (window === null)
1759
+ return usage("--window takes 7 or 90");
1760
+ const intent = pickIntent((await io.client.watches(planId)).intents, second);
1761
+ const a = await io.client.watchActivity(planId, intent.id, { window });
1762
+ if (json)
1763
+ io.out(JSON.stringify(a, null, 2));
1764
+ else
1765
+ printActivity(io, intent.title, a);
1766
+ return 0;
1767
+ }
1768
+ case "functions": {
1769
+ if (second) {
1770
+ const r = await io.client.functionActivity(planId, second);
1771
+ if (json)
1772
+ io.out(JSON.stringify(r, null, 2));
1773
+ else if (!r.collected)
1774
+ io.out(`Sentinel is still reading ${second}'s history. Try again in a few minutes.`);
1775
+ else
1776
+ printActivity(io, `${second} (if watched)`, r.activity);
1777
+ return 0;
1778
+ }
1779
+ const fns = await io.client.functions(planId);
1780
+ if (json)
1781
+ io.out(JSON.stringify(fns, null, 2));
1782
+ else
1783
+ for (const l of table([
1784
+ ["FUNCTION", "DOES", "WATCH", "DESCRIPTION"],
1785
+ ...fns.map((f) => [f.fn, f.consequence, f.intentId ?? "not watched", f.description])
1786
+ ]))
1787
+ io.out(l);
1788
+ return 0;
1789
+ }
1790
+ case "alert-preview": {
1791
+ const example = values["--example"];
1792
+ if (example !== undefined && example !== "money_out" && example !== "finding")
1793
+ return usage("--example takes money_out or finding");
1794
+ const p = await io.client.alertPreview(planId, example);
1795
+ if (json)
1796
+ io.out(JSON.stringify(p, null, 2));
1797
+ else
1798
+ io.out(`${p.message.subject}
1799
+
1800
+ ${p.message.text}`);
1801
+ return 0;
1802
+ }
1803
+ case "threshold": {
1804
+ if (!second)
1805
+ return usage("sentinel threshold needs a plan id and an asset (its symbol)");
1806
+ const ctx = await watchContext(io.client, planId);
1807
+ const g = resolveSubject(ctx.watches.filter((w) => w.subject.kind === "asset"), second);
1808
+ const subject = g.watches[0].subject;
1809
+ if (subject.kind !== "asset")
1810
+ return usage(`${second} isn't an asset`);
1811
+ const units = g.watches[0].units;
1812
+ const inUnits = (base) => units ? `${toUnits(base, units.decimals)} ${units.symbol}` : `${base} base units`;
1813
+ const { threshold } = await io.client.threshold(planId, subject.asset);
1814
+ io.out(`${g.name}: ${threshold.amount === null ? "every outflow alerts" : `alerts over ${inUnits(threshold.amount)}`}${threshold.confirmed ? "" : " (not confirmed yet)"}`);
1815
+ if (third === undefined)
1816
+ return 0;
1817
+ if (!/^\d{1,30}$/.test(third))
1818
+ return usage("The amount is in base units, digits only");
1819
+ if (previewOnly(io, flags, `Alert on ${g.name} outflows over ${inUnits(third)}.`, "Live now."))
1820
+ return 0;
1821
+ await io.client.setThreshold(planId, subject.asset, third);
1822
+ io.out(`${g.name} alerts over ${inUnits(third)}.`);
1823
+ return 0;
1824
+ }
1825
+ default: {
1826
+ if (previewOnly(io, flags, "Read the history of this plan's watches again.", "Starts now."))
1827
+ return 0;
1828
+ await io.client.retryHistory(planId);
1829
+ io.out("Reading again; activity fills in over the next few minutes.");
1830
+ return 0;
1831
+ }
1832
+ }
1833
+ }
1834
+ async function projects(pos, flags, values, io, usage) {
1835
+ const [sub, id, second, third] = pos;
1836
+ const json = flags.includes("--json");
1837
+ if (!sub) {
1838
+ const r = await io.client.projects();
1839
+ if (json) {
1840
+ io.out(JSON.stringify(r, null, 2));
1841
+ return 0;
1842
+ }
1843
+ for (const l of table([
1844
+ ["PROJECT", "CONTRACTS", "MONITORING", "FINDINGS", "ALERTS 7D", "ID"],
1845
+ ...r.projects.map((p) => [
1846
+ p.name,
1847
+ p.contracts.length,
1848
+ p.status,
1849
+ p.findings,
1850
+ p.alerts7d,
1851
+ p.id
1852
+ ])
1853
+ ]))
1854
+ io.out(l);
1855
+ const nameOf = (pid) => r.projects.find((p) => p.id === pid)?.name ?? pid;
1856
+ for (const s of r.suggestions)
1857
+ io.out(`Suggested: merge ${nameOf(s.from)} into ${nameOf(s.into)} (${s.evidence.kind}). Answer with: sentinel projects suggestion ${s.from} ${s.into} accept|dismiss`);
1858
+ return 0;
1859
+ }
1860
+ const subs = [
1861
+ "show",
1862
+ "overview",
1863
+ "add",
1864
+ "rename",
1865
+ "merge",
1866
+ "suggestion",
1867
+ "move",
1868
+ "remove-contract",
1869
+ "remove",
1870
+ "watches",
1871
+ "activity",
1872
+ "changes",
1873
+ "destinations",
1874
+ "monitoring"
1875
+ ];
1876
+ if (!subs.includes(sub))
1877
+ return usage(`Unknown command projects ${sub}`);
1878
+ if (!id)
1879
+ return usage(`sentinel projects ${sub} needs a project id (sentinel projects lists them)`);
1880
+ const projectNamed = async (pid) => {
1881
+ const { projects: all } = await io.client.projects();
1882
+ const p = all.find((x) => x.id === pid);
1883
+ if (!p)
1884
+ throw new SentinelError(404, `No project ${pid}. Yours: ${all.map((x) => `${x.name} (${x.id})`).join(", ") || "none"}`);
1885
+ return p;
1886
+ };
1887
+ switch (sub) {
1888
+ case "show": {
1889
+ const p = await io.client.project(id);
1890
+ if (json) {
1891
+ io.out(JSON.stringify(p, null, 2));
1892
+ return 0;
1893
+ }
1894
+ io.out(`${p.name} ${p.status} findings ${p.findings} alerts 7d ${p.alerts7d}`);
1895
+ for (const l of table([
1896
+ ["CONTRACT", "HOW", "ROLE", "PLAN"],
1897
+ ...p.contracts.map((c) => [c.contractId, c.origin, c.role, c.planId])
1898
+ ]))
1899
+ io.out(l);
1900
+ return 0;
1901
+ }
1902
+ case "overview":
1903
+ io.out(JSON.stringify(await io.client.projectOverview(id, { watches: flags.includes("--watches") }), null, 2));
1904
+ return 0;
1905
+ case "add": {
1906
+ if (!second)
1907
+ return usage("sentinel projects add needs a project id and a contract (SP….name or a GitHub link)");
1908
+ const into = values["--into"] ?? id;
1909
+ const p = await io.client.previewProjectContract(id, second);
1910
+ const s = p.suggestion;
1911
+ const target = into === "new" ? "a new project" : into;
1912
+ io.out(`${p.contractId} goes into ${target}.`);
1913
+ if (s.project !== into && s.kind !== "none")
1914
+ io.out(`Sentinel suggests ${s.project === "new" ? "a new project" : s.project} (${s.kind}${s.with ? ` via ${s.with}` : ""}): add --into ${s.project} to put it there.`);
1915
+ const b = await io.client.billing();
1916
+ io.out(b.prices ? `A new plan costs ${usd(b.prices.run)}, charged only if it finishes.` : "Billing isn't linked: the free daily plan limit applies.");
1917
+ if (!flags.includes("--yes")) {
1918
+ io.out(`Run again with --yes to add it: sentinel projects add ${id} ${second}${values["--into"] ? ` --into ${into}` : ""} --yes`);
1919
+ return 0;
1920
+ }
1921
+ const r = await io.client.addProjectContract(id, second, into === id ? {} : { project: into });
1922
+ io.out(`${r.cached ? "Already planned" : "Started"}: ${r.planId} in project ${r.projectId}`);
1923
+ return 0;
1924
+ }
1925
+ case "rename": {
1926
+ if (!second?.trim())
1927
+ return usage("sentinel projects rename needs a project id and a name");
1928
+ if (previewOnly(io, flags, `Rename ${id} to "${second.trim()}".`, "Takes effect now."))
1929
+ return 0;
1930
+ io.out(`Renamed: ${(await io.client.renameProject(id, second)).name}`);
1931
+ return 0;
1932
+ }
1933
+ case "merge": {
1934
+ if (!second)
1935
+ return usage("sentinel projects merge needs a project id and the project to merge it into");
1936
+ const [from, to] = [await projectNamed(id), await projectNamed(second)];
1937
+ const willDo = `Move every contract of ${from.name} (${from.contracts.length}) into ${to.name}; ${from.name} goes. Monitoring, watches and verification stay as they are.`;
1938
+ if (previewOnly(io, flags, willDo, "Takes effect now."))
1939
+ return 0;
1940
+ const r = await io.client.mergeProject(id, second);
1941
+ io.out(`Merged ${r.contracts} contract(s) into ${to.name}.`);
1942
+ return 0;
1943
+ }
1944
+ case "suggestion": {
1945
+ const accept = third === "accept" ? true : third === "dismiss" ? false : null;
1946
+ if (!second || accept === null)
1947
+ return usage("sentinel projects suggestion needs <from> <into> accept|dismiss");
1948
+ const r = await io.client.projects();
1949
+ if (!r.suggestions.some((s) => s.from === id && s.into === second))
1950
+ throw new SentinelError(404, `No open suggestion to merge ${id} into ${second}.`);
1951
+ const nameOf = (pid) => r.projects.find((p) => p.id === pid)?.name ?? pid;
1952
+ const willDo = accept ? `Merge ${nameOf(id)} into ${nameOf(second)}: every contract moves.` : `Dismiss the suggestion to merge ${nameOf(id)} into ${nameOf(second)}.`;
1953
+ if (previewOnly(io, flags, willDo, "Takes effect now."))
1954
+ return 0;
1955
+ await io.client.answerMergeSuggestion(id, second, accept);
1956
+ io.out(accept ? "Merged." : "Dismissed.");
1957
+ return 0;
1958
+ }
1959
+ case "move": {
1960
+ if (!second || !third)
1961
+ return usage("sentinel projects move needs a project id, a contract and where to (a project id or new)");
1962
+ const to = third === "new" ? "a new project named after it" : (await projectNamed(third)).name;
1963
+ if (previewOnly(io, flags, `Move ${second} to ${to}.`, "Takes effect now."))
1964
+ return 0;
1965
+ const r = await io.client.moveProjectContract(id, second, third);
1966
+ io.out(`Moved to ${r.projectId}${r.projectRemoved ? `; ${id} had nothing left and is gone` : ""}.`);
1967
+ return 0;
1968
+ }
1969
+ case "remove-contract": {
1970
+ if (!second)
1971
+ return usage("sentinel projects remove-contract needs a project id and a contract");
1972
+ const member = (await io.client.project(id)).contracts.find((c) => c.contractId === second);
1973
+ if (!member)
1974
+ throw new SentinelError(404, `${second} isn't in project ${id}.`);
1975
+ const added = member.origin === "added";
1976
+ const confirm = values["--confirm"];
1977
+ if (added && confirm === undefined)
1978
+ return usage(`${second} is one you added: removing it takes its monitoring, alerts and claim. Type its name: --confirm ${contractName(second)}`);
1979
+ const willDo = added ? `Remove ${second}: its monitoring, alerts and claim go.` : `Take ${second} out of the project (Sentinel found it; you never claimed it).`;
1980
+ if (previewOnly(io, flags, willDo, added ? "Can't be undone." : ""))
1981
+ return 0;
1982
+ const r = await io.client.removeProjectContract(id, second, added ? confirm : undefined);
1983
+ io.out(`Removed ${second}${r.projectRemoved ? `; ${id} had nothing left and is gone` : ""}.`);
1984
+ return 0;
1985
+ }
1986
+ case "remove": {
1987
+ const p = await io.client.project(id);
1988
+ const confirm = values["--confirm"];
1989
+ if (confirm === undefined)
1990
+ return usage(`sentinel projects remove needs --confirm "${p.name}" (the project's name, typed)`);
1991
+ const added = p.contracts.filter((c) => c.origin === "added").length;
1992
+ const willDo = `Remove ${p.name} and the ${added} contract(s) you added in it: their monitoring, alerts and claims go.`;
1993
+ if (previewOnly(io, flags, willDo, "Can't be undone."))
1994
+ return 0;
1995
+ const r = await io.client.removeProject(id, confirm);
1996
+ io.out(`Removed ${p.name} (${r.contracts} contract(s)).`);
1997
+ return 0;
1998
+ }
1999
+ case "watches": {
2000
+ const w = await io.client.projectWatches(id);
2001
+ if (json) {
2002
+ io.out(JSON.stringify(w, null, 2));
2003
+ return 0;
2004
+ }
2005
+ io.out(`${w.counts.on} of ${w.counts.total} on`);
2006
+ for (const i of w.intents)
2007
+ io.out([i.title, i.on ? "on" : "off", i.summary].join(" "));
2008
+ return 0;
2009
+ }
2010
+ case "activity": {
2011
+ if (!second)
2012
+ return usage("sentinel projects activity needs a project id and a watch (its title or id)");
2013
+ const window = windowOf(values);
2014
+ if (window === null)
2015
+ return usage("--window takes 7 or 90");
2016
+ const intent = pickIntent((await io.client.projectWatches(id)).intents, second);
2017
+ const a = await io.client.projectActivity(id, intent.id, {
2018
+ window,
2019
+ contract: values["--contract"]
2020
+ });
2021
+ if (json)
2022
+ io.out(JSON.stringify(a, null, 2));
2023
+ else
2024
+ printActivity(io, intent.title, a);
2025
+ return 0;
2026
+ }
2027
+ case "changes": {
2028
+ const limit = values["--limit"] === undefined ? undefined : Number(values["--limit"]);
2029
+ if (limit !== undefined && !(Number.isInteger(limit) && limit > 0))
2030
+ return usage("--limit takes a whole number above 0");
2031
+ const watchName = values["--watch"];
2032
+ const watch = watchName ? pickIntent((await io.client.projectWatches(id)).intents, watchName).id : undefined;
2033
+ const r = await io.client.projectChanges(id, {
2034
+ limit,
2035
+ cursor: values["--cursor"],
2036
+ watch,
2037
+ ...flags.includes("--mutes") ? { filter: "mutes" } : {}
2038
+ });
2039
+ if (json) {
2040
+ io.out(JSON.stringify(r, null, 2));
2041
+ return 0;
2042
+ }
2043
+ if (!r.events.length)
2044
+ io.out("No changes yet.");
2045
+ for (const c of r.events)
2046
+ io.out([c.at, changeText(c), c.source ?? ""].join(" ").trimEnd());
2047
+ if (r.next)
2048
+ io.out(`More: --cursor ${r.next}`);
2049
+ return 0;
2050
+ }
2051
+ case "destinations": {
2052
+ const email = values["--email"];
2053
+ if (email !== undefined && onOff(email) === null)
2054
+ return usage("--email takes on or off");
2055
+ if (email === undefined && !("--webhook" in values) && !("--slack" in values))
2056
+ return usage("sentinel projects destinations needs --email, --webhook or --slack");
2057
+ const dest = {
2058
+ ...email === undefined ? {} : { email: email === "on" },
2059
+ ...values["--webhook"] ? { webhookUrl: values["--webhook"] } : {},
2060
+ ...values["--slack"] ? { slackUrl: values["--slack"] } : {}
2061
+ };
2062
+ const places = [
2063
+ dest.email === true ? "email" : null,
2064
+ dest.webhookUrl ? "webhook" : null,
2065
+ dest.slackUrl ? "Slack" : null
2066
+ ].filter(Boolean).join(", ") || "nowhere new";
2067
+ const willDo = `Alerts for every contract you added in ${id} go to: ${places}. A webhook or Slack URL left out is cleared.`;
2068
+ if (previewOnly(io, flags, willDo, "Takes effect now."))
2069
+ return 0;
2070
+ const r = await io.client.setProjectDestinations(id, dest);
2071
+ for (const c of r.contracts) {
2072
+ io.out(`${c.contractId}: ${c.result}${c.error ? ` (${c.error})` : ""}`);
2073
+ if (c.signingSecret)
2074
+ io.out(` webhook signing secret (shown once): ${c.signingSecret}`);
2075
+ }
2076
+ return 0;
2077
+ }
2078
+ default: {
2079
+ const on = onOff(second);
2080
+ if (on === null)
2081
+ return usage("sentinel projects monitoring takes on or off");
2082
+ const willDo = `Turn monitoring ${on ? "on" : "off"} for every contract you added in ${id}.`;
2083
+ if (previewOnly(io, flags, willDo, on ? "Live now; events are billed." : "Off now."))
2084
+ return 0;
2085
+ const r = await io.client.setProjectMonitoring(id, on);
2086
+ for (const c of r.contracts)
2087
+ io.out(`${c.contractId}: ${c.result}${c.error ? ` (${c.error})` : ""}`);
2088
+ io.out(`Project monitoring: ${r.status}`);
2089
+ return 0;
2090
+ }
2091
+ }
2092
+ }
1279
2093
 
1280
2094
  // cli/main.ts
1281
2095
  function takeToken(argv) {
@@ -1311,7 +2125,7 @@ async function main() {
1311
2125
  return 2;
1312
2126
  }
1313
2127
  const baseUrl = process.env.SENTINEL_API_URL;
1314
- const clientFor = (token) => createClient({ token, baseUrl });
2128
+ const clientFor = (token) => createClient({ token, baseUrl, client: "cli" });
1315
2129
  return run(argv, {
1316
2130
  client: clientFor(credential?.token ?? ""),
1317
2131
  out: console.log,