@secondlayer/sentinel 0.3.0 → 0.5.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";
@@ -211,6 +218,7 @@ function createClient(opts) {
211
218
  }
212
219
  const plan = (id) => `/v1/plans/${encodeURIComponent(id)}`;
213
220
  const appPlan = (id) => `/plan/${encodeURIComponent(id)}`;
221
+ const appProject = (id) => `/projects/${encodeURIComponent(id)}`;
214
222
  return {
215
223
  me: () => call("GET", "/v1/me"),
216
224
  plans: async () => (await call("GET", "/v1/plans")).plans,
@@ -226,6 +234,8 @@ function createClient(opts) {
226
234
  testsFromRepo: (id) => call("POST", `${appPlan(id)}/tests`, { fromRepo: true }),
227
235
  previewPlan: (contractId) => call("POST", "/plan/preview", { contractId }),
228
236
  createPlan: (contractId) => call("POST", "/plan", { contractId }),
237
+ previewImport: (url) => call("POST", "/projects/import/preview", { url }),
238
+ importRepo: (input) => call("POST", "/projects/import", input),
229
239
  billing: () => call("GET", "/billing"),
230
240
  watches: (planId) => call("GET", `${appPlan(planId)}/watches`),
231
241
  setRule: (planId, watchId, rule) => call("POST", `${appPlan(planId)}/watches/${encodeURIComponent(watchId)}/rule`, { rule }),
@@ -242,9 +252,69 @@ function createClient(opts) {
242
252
  watchContract: (contractId) => call("POST", "/contracts/watch", {
243
253
  contractId
244
254
  }),
245
- stopMonitoring: (planId) => call("DELETE", `${appPlan(planId)}/monitor`)
255
+ stopMonitoring: (planId) => call("DELETE", `${appPlan(planId)}/monitor`),
256
+ watchActivity: (planId, intentId, opts = {}) => call("GET", `${appPlan(planId)}/watches/${encodeURIComponent(intentId)}/activity${query(opts)}`),
257
+ functions: async (planId) => (await call("GET", `${appPlan(planId)}/functions`)).functions,
258
+ functionActivity: (planId, fn) => call("GET", `${appPlan(planId)}/functions/${encodeURIComponent(fn)}/activity`),
259
+ alertPreview: (planId, example) => call("GET", `${appPlan(planId)}/alert-preview${query({ example })}`),
260
+ threshold: (planId, asset) => call("GET", `${appPlan(planId)}/watches/asset/${encodeURIComponent(asset)}/threshold`),
261
+ setThreshold: (planId, asset, amount) => call("POST", `${appPlan(planId)}/watches/asset/${encodeURIComponent(asset)}/threshold`, { amount }),
262
+ retryHistory: (planId) => call("POST", `${appPlan(planId)}/history/retry`, {}),
263
+ readAlert: (alertId) => call("POST", `/alerts/${encodeURIComponent(alertId)}/read`),
264
+ readAllAlerts: () => call("POST", "/alerts/read-all"),
265
+ ask: (q) => call("POST", "/ask", { q }),
266
+ acknowledge: (planId, finding, title, reason, note = null) => call("POST", `${appPlan(planId)}/acknowledge`, {
267
+ finding,
268
+ title,
269
+ reason,
270
+ note
271
+ }),
272
+ reopen: (planId, finding, title) => call("POST", `${appPlan(planId)}/reopen`, { finding, title }),
273
+ ackHistory: async (planId) => (await call("GET", `${appPlan(planId)}/acks`)).events,
274
+ notes: async (planId, finding) => (await call("GET", `${appPlan(planId)}/findings/${finding}/notes`)).notes,
275
+ addNote: (planId, finding, title, body) => call("POST", `${appPlan(planId)}/findings/${finding}/notes`, {
276
+ title,
277
+ body
278
+ }),
279
+ tests: (planId, files) => call("POST", `${appPlan(planId)}/tests`, { files }),
280
+ fuzzCheck: (planId, harness) => call("POST", `${appPlan(planId)}/fuzzcheck`, { harness }),
281
+ fuzz: (planId, invariants) => call("POST", `${appPlan(planId)}/fuzz`, { invariants }),
282
+ latestCommit: (planId) => call("GET", `${appPlan(planId)}/latest`),
283
+ updatePlan: (planId, commit) => call("POST", `${appPlan(planId)}/update`, commit ? { commit } : {}),
284
+ setAutoPlan: (planId, auto) => call("POST", `${appPlan(planId)}/wait`, { auto }),
285
+ setLink: (planId, dependency, url) => call("POST", `${appPlan(planId)}/link`, { id: dependency, url }),
286
+ planLinked: (planId) => call("POST", `${appPlan(planId)}/plan-linked`, {}),
287
+ renameContract: (planId, name) => call("POST", `${appPlan(planId)}/settings`, { name }),
288
+ removeContract: (planId, confirm) => call("POST", `${appPlan(planId)}/remove`, {
289
+ confirm
290
+ }),
291
+ projects: () => call("GET", "/projects"),
292
+ project: (projectId) => call("GET", appProject(projectId)),
293
+ projectOverview: (projectId, opts = {}) => call("GET", `${appProject(projectId)}/overview${opts.watches ? "?watches=1" : ""}`),
294
+ renameProject: (projectId, name) => call("POST", `${appProject(projectId)}/rename`, { name }),
295
+ removeProject: (projectId, confirm) => call("POST", `${appProject(projectId)}/remove`, {
296
+ confirm
297
+ }),
298
+ answerMergeSuggestion: (from, into, accept) => call("POST", "/projects/merge", { from, into, accept }),
299
+ mergeProject: (projectId, into) => call("POST", `${appProject(projectId)}/merge-into`, { into }),
300
+ moveProjectContract: (projectId, contractId, into) => call("POST", `${appProject(projectId)}/contracts/${encodeURIComponent(contractId)}/move`, { into }),
301
+ removeProjectContract: (projectId, contractId, confirm) => call("POST", `${appProject(projectId)}/contracts/${encodeURIComponent(contractId)}/remove`, { confirm }),
302
+ previewProjectContract: (projectId, contractId) => call("POST", `${appProject(projectId)}/contracts/preview`, { contractId }),
303
+ addProjectContract: (projectId, contractId, opts = {}) => call("POST", `${appProject(projectId)}/contracts`, { contractId, ...opts }),
304
+ setProjectDestinations: (projectId, dest) => call("POST", `${appProject(projectId)}/destinations`, dest),
305
+ setProjectMonitoring: (projectId, on) => call("POST", `${appProject(projectId)}/monitoring`, { on }),
306
+ projectWatches: (projectId) => call("GET", `${appProject(projectId)}/watches`),
307
+ projectActivity: (projectId, intentId, opts = {}) => call("GET", `${appProject(projectId)}/watches/${encodeURIComponent(intentId)}/activity${query(opts)}`)
246
308
  };
247
309
  }
310
+ function query(params) {
311
+ const q = new URLSearchParams;
312
+ for (const [k, v] of Object.entries(params))
313
+ if (v !== undefined && v !== null)
314
+ q.set(k, String(v));
315
+ const s = q.toString();
316
+ return s ? `?${s}` : "";
317
+ }
248
318
  var HOUR_MS = 3600000;
249
319
  var muteEnd = (hours) => new Date(Date.now() + Math.min(hours * HOUR_MS, 7 * 24 * HOUR_MS - 60000)).toISOString();
250
320
  async function watchContext(client, planId) {
@@ -418,6 +488,9 @@ async function whoami(client, io) {
418
488
  return 0;
419
489
  }
420
490
 
491
+ // cli/sentinel.ts
492
+ import { readFileSync } from "node:fs";
493
+
421
494
  // client/kind.ts
422
495
  var isGreen = (f) => f.poc === "green";
423
496
  function findingKind(f) {
@@ -432,7 +505,7 @@ function findingKind(f) {
432
505
  }
433
506
  var URGENT_CONSEQUENCES = new Set(["authority", "code", "value"]);
434
507
  var KIND_LABEL = {
435
- exploitable: "Exploitable",
508
+ exploitable: "Reproducible",
436
509
  trust: "Trust assumption",
437
510
  worth_a_look: "Worth a look",
438
511
  refuted: "refuted"
@@ -494,9 +567,9 @@ function assertionsOf(output) {
494
567
  var FENCE = "```";
495
568
  var FOOTER = "Sentinel's findings are a starting point for your own review, not an audit sign-off.";
496
569
  var KIND_LINE = {
497
- exploitable: "Exploitable (verified, re-verified, reproduced in the sandbox)",
570
+ exploitable: "Reproducible (verified, re-verified, reproduced in the sandbox)",
498
571
  trust: "Trust assumption (watched: Sentinel alerts when it's used)",
499
- worth_a_look: "Worth a look (not proven exploitable)",
572
+ worth_a_look: "Worth a look (not reproduced)",
500
573
  refuted: "Refuted"
501
574
  };
502
575
  var tick = (s) => `\`${s}\``;
@@ -587,16 +660,16 @@ function issueMd(f, withCondition) {
587
660
  "## The issue",
588
661
  f.headline && f.headline !== f.title ? `${f.headline} (${f.title})` : f.title,
589
662
  ...f.blastRadius ? ["", `**Impact:** ${f.blastRadius}`] : [],
590
- ...withCondition && f.precondition ? ["", `**Exploitable when:** ${f.precondition}`] : [],
663
+ ...withCondition && f.precondition ? ["", `**Reproduces when:** ${f.precondition}`] : [],
591
664
  ""
592
665
  ];
593
666
  }
594
667
  function prelaunchBrief(f, ctx, kind) {
595
- const kindLine = kind === "exploitable" ? "Exploitable · look before launch" : kind === "trust" ? "Trust assumption" : "Worth a look before launch";
668
+ const kindLine = kind === "exploitable" ? "Reproducible · look before launch" : kind === "trust" ? "Trust assumption" : "Worth a look before launch";
596
669
  return [
597
670
  "# Task: review a Sentinel finding on pre-launch code",
598
671
  "",
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.`,
672
+ `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
673
  "",
601
674
  "## Start here",
602
675
  `1. ${where(f, ctx)}`,
@@ -628,7 +701,7 @@ function trustBrief(f, ctx) {
628
701
  `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
702
  "3. Only then discuss options that fit this codebase's design.",
630
703
  "",
631
- ...reportBackMd(ctx, "- There is no PoC for a trust assumption. Don't write an exploit for it."),
704
+ ...reportBackMd(ctx, "- There is no PoC for a trust assumption. Don't write a PoC for it."),
632
705
  ...contextMd(f, ctx, "trust"),
633
706
  "- Class: trust assumption (a power held by design)",
634
707
  "- Evidence: checked by Sentinel's adversarial verifier. No PoC; nothing to run.",
@@ -750,6 +823,9 @@ var USAGE = `sentinel <command>
750
823
  logout forget the saved login (also signs the secondlayer CLI out)
751
824
  whoami who you are and where the credential came from
752
825
  new <contract> [--yes] plan a contract: see what it would do and cost; --yes starts it
826
+ import <repo-url> [--only a,b] [--all] [--project <id>|new] [--yes]
827
+ plan a public Clarinet repo's contracts into one project: see each one and the
828
+ cost (the suggested ones, unless --only or --all); --yes imports them
753
829
  balance your balance and what a plan, a deep run and a monitored event cost
754
830
  alerts [--contract <id>] your alerts, newest first
755
831
 
@@ -758,7 +834,7 @@ var USAGE = `sentinel <command>
758
834
  finding <id> <n> [--poc] [--run] [--brief] [--json] one finding; --poc adds the PoC source, --run the command
759
835
  to re-run it, --brief the agent brief (the finding page's "Copy agent brief")
760
836
  runs <id> follow-up runs on a plan
761
- watch <id> follow running runs until none are left
837
+ watch <id> follow running (and queued) runs until none are left
762
838
  reverify <id> [--yes] check a plan's leads again (stronger second pass, $3); only starts with --yes
763
839
 
764
840
  paid runs on a plan you own (each starts only with --yes; without it you see what it would do):
@@ -766,10 +842,43 @@ paid runs on a plan you own (each starts only with --yes; without it you see wha
766
842
  challenge <plan> <n> "<reason>" [--yes] re-verify finding n against your argument
767
843
  replay <plan> <n> [--yes] re-run finding n's stored PoC
768
844
  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
845
+ tests <plan> --from-repo|<file>... [--yes] run the Clarinet suite from a pre-launch plan's repo, or your test files
846
+ fuzz <plan> <invariants.clar> [--yes] compile-check the invariants (free); --yes fuzzes them in the sandbox
847
+
848
+ your plans and findings (owners; a write only happens with --yes):
849
+ latest <plan> whether a pre-launch plan's branch moved on, and what an update reviews
850
+ update <plan> [--commit <sha>] [--yes] plan the latest commit (or --commit): free while monitoring is on
851
+ auto-plan <plan> on|off [--yes] plan a waiting plan on its own once the contracts it waits on deploy
852
+ link <plan> <contract> <url>|--clear [--yes] link code for a contract the plan waits on
853
+ plan-linked <plan> [--yes] plan now with the linked code (a paid plan)
854
+ name <plan> "<name>" [--yes] your own name for the contract ("" clears it)
855
+ remove <plan> --confirm <name> [--yes] take the contract off your account: monitoring, alerts and claim
856
+ ack <plan> <n> accepted|fixed|disagree [--note "<text>"] [--yes] label finding n for your team
857
+ reopen <plan> <n> [--yes] put an acknowledged finding back on the list
858
+ acks <plan> every acknowledgement change on the contract
859
+ notes <plan> <n> your notes on finding n
860
+ note <plan> <n> "<text>" [--yes] add a note to finding n
861
+ ask "<question>" an answer from Sentinel's docs
862
+
863
+ projects (the contracts of one protocol; a write only happens with --yes):
864
+ projects [--json] your projects and the merges Sentinel suggests
865
+ projects show <project> [--json] its contracts, each one's plan and monitoring
866
+ projects overview <project> [--watches] the project, its plans and (--watches) watches, as JSON
867
+ projects add <project> <contract> [--into <project>|new] [--yes] where it would go; --yes plans it (paid)
868
+ projects rename <project> "<name>" [--yes]
869
+ projects merge <project> <into> [--yes] move every contract into another of your projects
870
+ projects suggestion <from> <into> accept|dismiss [--yes] answer a merge Sentinel suggested
871
+ projects move <project> <contract> <into>|new [--yes]
872
+ projects remove-contract <project> <contract> [--confirm <name>] [--yes] a contract you added needs --confirm
873
+ projects remove <project> --confirm "<name>" [--yes] every contract you added in it, and the project
874
+ projects watches <project> [--json] the project's watches as one list
875
+ projects activity <project> <watch> [--contract <id>] [--window 7|90] [--json]
876
+ projects destinations <project> [--email on|off] [--webhook URL] [--slack URL] [--yes] a URL left out is cleared
877
+ projects monitoring <project> on|off [--yes]
770
878
 
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.
879
+ A key needs Plans & findings (read for the above, write for new, the runs and changes above, and project changes);
880
+ monitoring commands and a project's watches, destinations and monitoring need Monitoring (read to look, write to
881
+ change); mute-alert and mark-read need Alerts: write.
773
882
 
774
883
  monitoring (a write only happens with --yes; without it you see what would change):
775
884
  watches <plan> [--json] subjects, rules, status, mutes
@@ -783,23 +892,39 @@ monitoring (a write only happens with --yes; without it you see what would chang
783
892
  test-alert <plan> [--yes] a sample alert to every saved destination
784
893
  follow <contract> [--yes] start monitoring a deployed contract
785
894
  unfollow <contract> [--yes]
895
+ activity <plan> <watch> [--window 7|90] [--json] what a watch saw (<watch>: its title or id) and what alerted
896
+ functions <plan> [<fn>] [--json] every public function and its watch; with <fn>, what a watch
897
+ on it would have seen
898
+ alert-preview <plan> [--example money_out|finding] a sample alert as email and Slack get it
899
+ threshold <plan> <asset> [<amount>] [--yes] an asset's Money out limit; with <amount> (base units), set it
900
+ retry-history <plan> [--yes] read the watches' history again after a failed read
901
+ mark-read <alert>|--all [--yes] mark one alert, or every alert, read
786
902
 
787
903
  <subject> is a function name or a token symbol, as the watches command shows it.
788
904
 
789
905
  credential: SENTINEL_API_KEY, then --token <key>, then sentinel login. env: SENTINEL_API_URL
790
906
  `;
791
907
  var POLL_MS = 5000;
792
- var VALUE_FLAGS = new Set(["--email", "--webhook", "--slack", "--contract"]);
908
+ var VALUE_FLAGS = new Set([
909
+ "--email",
910
+ "--webhook",
911
+ "--slack",
912
+ "--contract",
913
+ "--only",
914
+ "--project",
915
+ "--confirm",
916
+ "--note",
917
+ "--commit",
918
+ "--into",
919
+ "--window",
920
+ "--example"
921
+ ]);
793
922
  var cell2 = (v, empty = "—") => v === null || v === undefined || v === "" ? empty : String(v);
794
923
  var table = (rows) => {
795
924
  const text = rows.map((r) => r.map((c) => cell2(c)));
796
925
  const w = text[0]?.map((_, i) => Math.max(...text.map((r) => r[i].length))) ?? [];
797
926
  return text.map((r) => r.map((c, i) => c.padEnd(w[i])).join(" ").trimEnd());
798
927
  };
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
928
  var verdictLine = (p) => {
804
929
  const r = p.report;
805
930
  const head = `${p.contractId} ${p.status}`;
@@ -837,7 +962,10 @@ ${USAGE}` : USAGE);
837
962
  "--from-repo",
838
963
  "--poc",
839
964
  "--run",
840
- "--brief"
965
+ "--brief",
966
+ "--all",
967
+ "--clear",
968
+ "--watches"
841
969
  ]);
842
970
  if (flags.some((f) => !known.has(f)))
843
971
  return usage(`Unknown flag ${flags.find((f) => !known.has(f))}`);
@@ -949,7 +1077,7 @@ No run command: no PoC is stored for this finding.`);
949
1077
  r.id,
950
1078
  r.kind,
951
1079
  r.status,
952
- r.status === "running" ? phaseOf(r) : r.finishedAt ?? ""
1080
+ runInProgress(r.status) ? runPhase(r) : r.finishedAt ?? ""
953
1081
  ])))
954
1082
  io.out(l);
955
1083
  return 0;
@@ -964,9 +1092,9 @@ No run command: no PoC is stored for this finding.`);
964
1092
  const sleep = io.sleep ?? ((ms) => new Promise((r) => setTimeout(r, ms)));
965
1093
  const seen = new Map;
966
1094
  for (;; ) {
967
- const running = (await io.client.runs(id)).filter((r) => r.status === "running");
1095
+ const running = (await io.client.runs(id)).filter((r) => runInProgress(r.status));
968
1096
  for (const r of running) {
969
- const phase = phaseOf(r);
1097
+ const phase = runPhase(r);
970
1098
  if (seen.get(r.id) !== phase) {
971
1099
  seen.set(r.id, phase);
972
1100
  io.out(`${r.kind} ${r.id}: ${phase}`);
@@ -1019,6 +1147,8 @@ No run command: no PoC is stored for this finding.`);
1019
1147
  io.out(`Follow it with: sentinel runs ${r.planId}`);
1020
1148
  return 0;
1021
1149
  }
1150
+ case "import":
1151
+ return await importRepo(args.slice(1), flags, values, io, usage);
1022
1152
  case "balance": {
1023
1153
  const b = await io.client.billing();
1024
1154
  if (json)
@@ -1051,6 +1181,39 @@ No run command: no PoC is stored for this finding.`);
1051
1181
  case "directed":
1052
1182
  case "tests":
1053
1183
  return await ownerRun(cmd, args.slice(1), flags, io, usage);
1184
+ case "fuzz":
1185
+ return await fuzz(args.slice(1), flags, io, usage);
1186
+ case "latest":
1187
+ case "update":
1188
+ case "auto-plan":
1189
+ case "link":
1190
+ case "plan-linked":
1191
+ case "name":
1192
+ case "remove":
1193
+ case "ack":
1194
+ case "reopen":
1195
+ case "acks":
1196
+ case "notes":
1197
+ case "note":
1198
+ return await upkeep(cmd, args.slice(1), flags, values, io, usage);
1199
+ case "ask": {
1200
+ if (!id)
1201
+ return usage("sentinel ask needs a question, in quotes");
1202
+ const a = await io.client.ask(args.slice(1).join(" "));
1203
+ io.out(a.answer);
1204
+ if (a.sources.length)
1205
+ io.out(`Sources: ${a.sources.join(", ")}`);
1206
+ return 0;
1207
+ }
1208
+ case "projects":
1209
+ return await projects(args.slice(1), flags, values, io, usage);
1210
+ case "activity":
1211
+ case "functions":
1212
+ case "alert-preview":
1213
+ case "threshold":
1214
+ case "retry-history":
1215
+ case "mark-read":
1216
+ return await monitoringMore(cmd, args.slice(1), flags, values, io, usage);
1054
1217
  case "watches":
1055
1218
  case "rule":
1056
1219
  case "mute":
@@ -1071,6 +1234,64 @@ No run command: no PoC is stored for this finding.`);
1071
1234
  }
1072
1235
  var usd = (n) => `$${n.toFixed(2)}`;
1073
1236
  var RUN_NOTE = "A paid run from your balance, charged only if it finishes.";
1237
+ async function importRepo(pos, flags, values, io, usage) {
1238
+ const [url] = pos;
1239
+ if (!url)
1240
+ return usage("sentinel import needs a GitHub repo link");
1241
+ const only = values["--only"]?.split(",").map((n) => n.trim()).filter(Boolean);
1242
+ if (only && flags.includes("--all"))
1243
+ return usage("Use --only or --all, not both");
1244
+ const p = await io.client.previewImport(url);
1245
+ const picked = only ? only : p.contracts.filter((c) => flags.includes("--all") || c.suggested).map((c) => c.name);
1246
+ const missing = picked.filter((n) => !p.contracts.some((c) => c.name === n));
1247
+ if (missing.length)
1248
+ return usage(`Not in ${p.repo} at ${p.commit.slice(0, 7)}: ${missing.join(", ")}`);
1249
+ io.out(`${p.owner}/${p.repo}${p.root ? `/${p.root}` : ""} @ ${p.commit.slice(0, 7)} (${p.ref})`);
1250
+ for (const l of table([
1251
+ [" ", "NAME", "DEPS", "STATE", "SUGGESTED"],
1252
+ ...p.contracts.map((c) => [
1253
+ picked.includes(c.name) ? "+" : " ",
1254
+ c.name,
1255
+ c.deps.join(",") || "—",
1256
+ c.state,
1257
+ c.suggested ? "yes" : `no (${c.why})`
1258
+ ])
1259
+ ]))
1260
+ io.out(l);
1261
+ if (p.cycle)
1262
+ io.out("These contracts call each other in a loop: listed in manifest order.");
1263
+ for (const s of p.skipped)
1264
+ io.out(`Skipped ${s.path}: ${s.reason}`);
1265
+ for (const d of p.mainnetMatch.deployers)
1266
+ io.out(`Live on mainnet under ${d.deployer}: ${d.files.map((f) => `${f.name} (${f.kind} match of ${f.contractId})`).join(", ")}`);
1267
+ if (picked.length === 0) {
1268
+ io.out("Nothing to import. Pick contracts with --only a,b or --all.");
1269
+ return 0;
1270
+ }
1271
+ const fresh = p.contracts.filter((c) => picked.includes(c.name) && c.state === "new").length;
1272
+ 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.`);
1273
+ if (fresh > p.runsAtOnce)
1274
+ io.out(`Plans run ${p.runsAtOnce} at a time; the rest wait their turn (queued).`);
1275
+ const project = values["--project"];
1276
+ if (!flags.includes("--yes")) {
1277
+ const tail = [
1278
+ only ? `--only ${only.join(",")}` : flags.includes("--all") ? "--all" : "",
1279
+ project ? `--project ${project}` : ""
1280
+ ].filter(Boolean).join(" ");
1281
+ io.out(`Run again with --yes to import: sentinel import ${url}${tail ? ` ${tail}` : ""} --yes`);
1282
+ return 0;
1283
+ }
1284
+ const r = await io.client.importRepo({ url, commit: p.commit, contracts: picked, project });
1285
+ io.out(`Project: ${r.projectId ?? "—"}`);
1286
+ for (const x of r.plans)
1287
+ io.out(`${x.name} ${x.status === "kept" ? "already in the project" : x.cached ? "already planned" : x.status === "queued" ? "queued" : "started"} ${x.planId ?? "—"}`);
1288
+ for (const x of r.notStarted ?? [])
1289
+ io.out(`${x.name} not started: ${x.error}`);
1290
+ const first = r.plans.find((x) => x.planId)?.planId;
1291
+ if (first)
1292
+ io.out(`Follow a plan with: sentinel runs ${first}`);
1293
+ return 0;
1294
+ }
1074
1295
  async function ownerRun(cmd, pos, flags, io, usage) {
1075
1296
  const [planId, second, third] = pos;
1076
1297
  if (!planId)
@@ -1085,16 +1306,19 @@ async function ownerRun(cmd, pos, flags, io, usage) {
1085
1306
  return usage("sentinel challenge needs a reason, in quotes");
1086
1307
  if (cmd === "directed" && !second)
1087
1308
  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)");
1309
+ const fromRepo = flags.includes("--from-repo");
1310
+ const testPaths = cmd === "tests" ? pos.slice(1) : [];
1311
+ if (cmd === "tests" && fromRepo === testPaths.length > 0)
1312
+ return usage("sentinel tests needs --from-repo (a pre-launch plan's repo tests) or your test files");
1313
+ const files = testPaths.map((path) => ({ path, content: readLocal(io, path) }));
1090
1314
  const willDo = {
1091
1315
  reproduce: `Write and run a PoC for finding ${finding} in the airgapped sandbox (never touches mainnet).`,
1092
1316
  replay: `Re-run finding ${finding}'s stored PoC in the airgapped sandbox.`,
1093
1317
  challenge: `Re-verify finding ${finding} against your argument.`,
1094
1318
  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."
1319
+ 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
1320
  }[cmd];
1097
- const flagTail = cmd === "tests" ? " --from-repo" : "";
1321
+ const flagTail = cmd === "tests" ? fromRepo ? " --from-repo" : ` ${testPaths.join(" ")}` : "";
1098
1322
  const quoted = (v) => v ? ` ${JSON.stringify(v)}` : "";
1099
1323
  const argTail = {
1100
1324
  reproduce: ` ${finding}`,
@@ -1114,7 +1338,7 @@ async function ownerRun(cmd, pos, flags, io, usage) {
1114
1338
  replay: () => io.client.replay(planId, finding),
1115
1339
  challenge: () => io.client.challenge(planId, finding, third),
1116
1340
  directed: () => io.client.directed(planId, second),
1117
- tests: () => io.client.testsFromRepo(planId)
1341
+ tests: () => fromRepo ? io.client.testsFromRepo(planId) : io.client.tests(planId, files)
1118
1342
  }[cmd]();
1119
1343
  io.out(`${r.existing ? "Already running" : "Started"}: ${r.planId}`);
1120
1344
  return 0;
@@ -1276,6 +1500,546 @@ async function monitoring(cmd, pos, on, flags, values, io, usage) {
1276
1500
  io.out(mute ? `Muted ${g.name} until ${mutedUntil}` : `Unmuted ${g.name}.`);
1277
1501
  return 0;
1278
1502
  }
1503
+ function readLocal(io, path) {
1504
+ try {
1505
+ return (io.readFile ?? ((p) => readFileSync(p, "utf8")))(path);
1506
+ } catch (e) {
1507
+ throw new SentinelError(0, `Can't read ${path}: ${e.message}`);
1508
+ }
1509
+ }
1510
+ var SHA = /^[0-9a-f]{40}$/;
1511
+ var short = (sha) => sha.slice(0, 7);
1512
+ var onOff = (v) => v === "on" ? true : v === "off" ? false : null;
1513
+ async function fuzz(pos, flags, io, usage) {
1514
+ const [planId, file] = pos;
1515
+ if (!planId || !file)
1516
+ return usage("sentinel fuzz needs a plan id and an invariants file");
1517
+ const harness = readLocal(io, file);
1518
+ const check = await io.client.fuzzCheck(planId, harness);
1519
+ if (!check.ok) {
1520
+ io.err(`The invariants don't compile: ${check.message ?? "no detail"}`);
1521
+ return 1;
1522
+ }
1523
+ io.out("The invariants compile.");
1524
+ if (!flags.includes("--yes")) {
1525
+ io.out("Fuzz the contract against them in the airgapped sandbox (never touches mainnet).");
1526
+ io.out(RUN_NOTE);
1527
+ io.out(`Run again with --yes to start: sentinel fuzz ${planId} ${file} --yes`);
1528
+ return 0;
1529
+ }
1530
+ const r = await io.client.fuzz(planId, harness);
1531
+ io.out(`Started: ${r.planId}`);
1532
+ return 0;
1533
+ }
1534
+ async function findingTitle(io, planId, n, usage) {
1535
+ if (n === undefined || !/^\d+$/.test(n))
1536
+ return usage("It needs a plan id and a finding number");
1537
+ return (await io.client.finding(planId, Number(n))).title;
1538
+ }
1539
+ async function upkeep(cmd, pos, flags, values, io, usage) {
1540
+ const [planId, second, third] = pos;
1541
+ if (!planId)
1542
+ return usage(`sentinel ${cmd} needs a plan id`);
1543
+ const yes = flags.includes("--yes");
1544
+ switch (cmd) {
1545
+ case "latest": {
1546
+ const l = await io.client.latestCommit(planId);
1547
+ if (l.status === "unsupported")
1548
+ io.out("Only a plan made from a GitHub link follows newer commits.");
1549
+ else if (l.status === "current")
1550
+ io.out(`On the latest commit of ${l.ref}: ${short(l.commit)}.`);
1551
+ else if (l.status === "gone")
1552
+ io.out(`The file isn't on ${l.ref} anymore (head ${short(l.head)}).`);
1553
+ else {
1554
+ 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(", ")})` : ""}.`);
1555
+ io.out(l.free ? "Free: monitoring is on." : "Priced as a new plan: monitoring is off.");
1556
+ io.out(`Plan it with: sentinel update ${planId} --yes`);
1557
+ }
1558
+ return 0;
1559
+ }
1560
+ case "update": {
1561
+ const commit = values["--commit"];
1562
+ if (commit !== undefined && !SHA.test(commit))
1563
+ return usage("--commit takes a full 40-character sha");
1564
+ const willDo = `Plan ${commit ? `commit ${short(commit)}` : "the branch's latest commit"}; the new plan replaces this one when it finishes.`;
1565
+ if (previewOnly(io, flags, willDo, "Free while monitoring is on, else a paid plan."))
1566
+ return 0;
1567
+ const r = await io.client.updatePlan(planId, commit);
1568
+ io.out(`${r.existing ? "Already running" : "Started"}: ${r.planId}${r.mode ? ` (${r.mode})` : ""}`);
1569
+ return 0;
1570
+ }
1571
+ case "auto-plan": {
1572
+ const on = onOff(second);
1573
+ if (on === null)
1574
+ return usage("sentinel auto-plan takes on or off");
1575
+ 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.";
1576
+ if (previewOnly(io, flags, willDo, "Takes effect now."))
1577
+ return 0;
1578
+ await io.client.setAutoPlan(planId, on);
1579
+ io.out(`Auto-plan is ${on ? "on" : "off"}.`);
1580
+ return 0;
1581
+ }
1582
+ case "link": {
1583
+ const clear = flags.includes("--clear");
1584
+ if (!second || clear === Boolean(third))
1585
+ return usage("sentinel link needs a plan id, the contract it waits on, and a link or --clear");
1586
+ 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).`;
1587
+ if (previewOnly(io, flags, willDo, "Takes effect now."))
1588
+ return 0;
1589
+ const r = await io.client.setLink(planId, second, clear ? null : third);
1590
+ io.out(`Linked: ${r.links.map((l) => l.id).join(", ") || "nothing"}`);
1591
+ return 0;
1592
+ }
1593
+ case "plan-linked": {
1594
+ if (!yes) {
1595
+ io.out("Plan this contract now, with the linked code loaded at its real principals.");
1596
+ io.out(RUN_NOTE);
1597
+ io.out(`Run again with --yes to start: sentinel plan-linked ${planId} --yes`);
1598
+ return 0;
1599
+ }
1600
+ const r = await io.client.planLinked(planId);
1601
+ io.out(`Started: ${r.planId}`);
1602
+ return 0;
1603
+ }
1604
+ case "name": {
1605
+ if (second === undefined)
1606
+ return usage('sentinel name needs a plan id and a name ("" clears it)');
1607
+ const willDo = second.trim() ? `Call this contract "${second.trim()}".` : "Clear your name for this contract.";
1608
+ if (previewOnly(io, flags, willDo, "Takes effect now."))
1609
+ return 0;
1610
+ const r = await io.client.renameContract(planId, second);
1611
+ io.out(r.name ? `Named: ${r.name}` : "Name cleared.");
1612
+ return 0;
1613
+ }
1614
+ case "remove": {
1615
+ const p = await io.client.plan(planId);
1616
+ const name = contractName(p.contractId);
1617
+ const confirm = values["--confirm"];
1618
+ if (confirm === undefined)
1619
+ return usage(`sentinel remove needs --confirm ${name} (the contract's name, typed)`);
1620
+ const willDo = `Take ${p.contractId} off your account: its monitoring, alerts and claim go.`;
1621
+ if (previewOnly(io, flags, willDo, "Can't be undone."))
1622
+ return 0;
1623
+ const r = await io.client.removeContract(planId, confirm);
1624
+ io.out(`Removed ${p.contractId}${r.monitoringOff ? "; monitoring is off" : ""}.`);
1625
+ return 0;
1626
+ }
1627
+ case "ack":
1628
+ case "reopen": {
1629
+ const title = await findingTitle(io, planId, second, usage);
1630
+ if (typeof title === "number")
1631
+ return title;
1632
+ if (cmd === "reopen") {
1633
+ if (previewOnly(io, flags, `Put "${title}" back on the list.`, "Takes effect now."))
1634
+ return 0;
1635
+ await io.client.reopen(planId, Number(second), title);
1636
+ io.out(`Reopened: ${title}`);
1637
+ return 0;
1638
+ }
1639
+ if (third !== "accepted" && third !== "fixed" && third !== "disagree")
1640
+ return usage("sentinel ack takes a reason: accepted, fixed or disagree");
1641
+ const note = values["--note"] ?? null;
1642
+ const willDo = `Label "${title}" ${third}${note ? `, noting: ${note}` : ""}. It changes no verdict.`;
1643
+ if (previewOnly(io, flags, willDo, "Your team sees it now."))
1644
+ return 0;
1645
+ const r = await io.client.acknowledge(planId, Number(second), title, third, note);
1646
+ io.out(`Labelled ${third}: ${title}`);
1647
+ if (r.watchId)
1648
+ io.out(`A watch on it is suggested (${r.watchId}): turn it on to keep watching.`);
1649
+ return 0;
1650
+ }
1651
+ case "acks": {
1652
+ const events = await io.client.ackHistory(planId);
1653
+ if (events.length === 0)
1654
+ io.out("No acknowledgements yet.");
1655
+ for (const l of table(events.map((e) => [e.at, e.action, e.reason, e.by ?? "Sentinel", e.title, e.note])))
1656
+ io.out(l);
1657
+ return 0;
1658
+ }
1659
+ case "notes": {
1660
+ if (second === undefined || !/^\d+$/.test(second))
1661
+ return usage("sentinel notes needs a plan id and a finding number");
1662
+ const notes = await io.client.notes(planId, Number(second));
1663
+ if (notes.length === 0)
1664
+ io.out("No notes yet.");
1665
+ for (const n of notes)
1666
+ io.out(`${n.at} ${n.system ? "Sentinel" : n.by ?? ""}
1667
+ ${n.body}`);
1668
+ return 0;
1669
+ }
1670
+ default: {
1671
+ const title = await findingTitle(io, planId, second, usage);
1672
+ if (typeof title === "number")
1673
+ return title;
1674
+ if (!third?.trim())
1675
+ return usage("sentinel note needs the note, in quotes");
1676
+ if (previewOnly(io, flags, `Add a note to "${title}": ${third.trim()}`, "Your team sees it now."))
1677
+ return 0;
1678
+ await io.client.addNote(planId, Number(second), title, third);
1679
+ io.out("Noted.");
1680
+ return 0;
1681
+ }
1682
+ }
1683
+ }
1684
+ function pickIntent(intents, name) {
1685
+ const q = name.trim().toLowerCase();
1686
+ const hit = intents.find((i) => i.id === name) ?? intents.find((i) => i.title.toLowerCase() === q);
1687
+ if (hit)
1688
+ return hit;
1689
+ throw new SubjectError(`No watch called "${name}". Watches: ${intents.map((i) => i.title).join(", ") || "none yet"}`, intents.map((i) => i.title));
1690
+ }
1691
+ function windowOf(values) {
1692
+ const w = values["--window"];
1693
+ if (w === undefined)
1694
+ return;
1695
+ return w === "7" ? 7 : w === "90" ? 90 : null;
1696
+ }
1697
+ function printActivity(io, title, a) {
1698
+ 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})`}`);
1699
+ for (const l of table(a.events.map((e) => [
1700
+ e.t,
1701
+ e.fn,
1702
+ e.value !== null && a.unit ? `${toUnits(e.value, a.unit.decimals)} ${a.unit.symbol}` : e.value,
1703
+ e.caller,
1704
+ e.result
1705
+ ])))
1706
+ io.out(l);
1707
+ }
1708
+ async function monitoringMore(cmd, pos, flags, values, io, usage) {
1709
+ const [first, second, third] = pos;
1710
+ const json = flags.includes("--json");
1711
+ if (cmd === "mark-read") {
1712
+ const all = flags.includes("--all");
1713
+ if (all === Boolean(first))
1714
+ return usage("sentinel mark-read needs an alert id or --all");
1715
+ if (previewOnly(io, flags, all ? "Mark every alert read." : `Mark alert ${first} read.`, ""))
1716
+ return 0;
1717
+ if (all)
1718
+ io.out(`Marked ${(await io.client.readAllAlerts()).count} read.`);
1719
+ else {
1720
+ await io.client.readAlert(first);
1721
+ io.out("Marked read.");
1722
+ }
1723
+ return 0;
1724
+ }
1725
+ if (!first)
1726
+ return usage(`sentinel ${cmd} needs a plan id`);
1727
+ const planId = first;
1728
+ switch (cmd) {
1729
+ case "activity": {
1730
+ if (!second)
1731
+ return usage("sentinel activity needs a plan id and a watch (its title or id)");
1732
+ const window = windowOf(values);
1733
+ if (window === null)
1734
+ return usage("--window takes 7 or 90");
1735
+ const intent = pickIntent((await io.client.watches(planId)).intents, second);
1736
+ const a = await io.client.watchActivity(planId, intent.id, { window });
1737
+ if (json)
1738
+ io.out(JSON.stringify(a, null, 2));
1739
+ else
1740
+ printActivity(io, intent.title, a);
1741
+ return 0;
1742
+ }
1743
+ case "functions": {
1744
+ if (second) {
1745
+ const r = await io.client.functionActivity(planId, second);
1746
+ if (json)
1747
+ io.out(JSON.stringify(r, null, 2));
1748
+ else if (!r.collected)
1749
+ io.out(`Sentinel is still reading ${second}'s history. Try again in a few minutes.`);
1750
+ else
1751
+ printActivity(io, `${second} (if watched)`, r.activity);
1752
+ return 0;
1753
+ }
1754
+ const fns = await io.client.functions(planId);
1755
+ if (json)
1756
+ io.out(JSON.stringify(fns, null, 2));
1757
+ else
1758
+ for (const l of table([
1759
+ ["FUNCTION", "DOES", "WATCH", "DESCRIPTION"],
1760
+ ...fns.map((f) => [f.fn, f.consequence, f.intentId ?? "not watched", f.description])
1761
+ ]))
1762
+ io.out(l);
1763
+ return 0;
1764
+ }
1765
+ case "alert-preview": {
1766
+ const example = values["--example"];
1767
+ if (example !== undefined && example !== "money_out" && example !== "finding")
1768
+ return usage("--example takes money_out or finding");
1769
+ const p = await io.client.alertPreview(planId, example);
1770
+ if (json)
1771
+ io.out(JSON.stringify(p, null, 2));
1772
+ else
1773
+ io.out(`${p.message.subject}
1774
+
1775
+ ${p.message.text}`);
1776
+ return 0;
1777
+ }
1778
+ case "threshold": {
1779
+ if (!second)
1780
+ return usage("sentinel threshold needs a plan id and an asset (its symbol)");
1781
+ const ctx = await watchContext(io.client, planId);
1782
+ const g = resolveSubject(ctx.watches.filter((w) => w.subject.kind === "asset"), second);
1783
+ const subject = g.watches[0].subject;
1784
+ if (subject.kind !== "asset")
1785
+ return usage(`${second} isn't an asset`);
1786
+ const units = g.watches[0].units;
1787
+ const inUnits = (base) => units ? `${toUnits(base, units.decimals)} ${units.symbol}` : `${base} base units`;
1788
+ const { threshold } = await io.client.threshold(planId, subject.asset);
1789
+ io.out(`${g.name}: ${threshold.amount === null ? "every outflow alerts" : `alerts over ${inUnits(threshold.amount)}`}${threshold.confirmed ? "" : " (not confirmed yet)"}`);
1790
+ if (third === undefined)
1791
+ return 0;
1792
+ if (!/^\d{1,30}$/.test(third))
1793
+ return usage("The amount is in base units, digits only");
1794
+ if (previewOnly(io, flags, `Alert on ${g.name} outflows over ${inUnits(third)}.`, "Live now."))
1795
+ return 0;
1796
+ await io.client.setThreshold(planId, subject.asset, third);
1797
+ io.out(`${g.name} alerts over ${inUnits(third)}.`);
1798
+ return 0;
1799
+ }
1800
+ default: {
1801
+ if (previewOnly(io, flags, "Read the history of this plan's watches again.", "Starts now."))
1802
+ return 0;
1803
+ await io.client.retryHistory(planId);
1804
+ io.out("Reading again; activity fills in over the next few minutes.");
1805
+ return 0;
1806
+ }
1807
+ }
1808
+ }
1809
+ async function projects(pos, flags, values, io, usage) {
1810
+ const [sub, id, second, third] = pos;
1811
+ const json = flags.includes("--json");
1812
+ if (!sub) {
1813
+ const r = await io.client.projects();
1814
+ if (json) {
1815
+ io.out(JSON.stringify(r, null, 2));
1816
+ return 0;
1817
+ }
1818
+ for (const l of table([
1819
+ ["PROJECT", "CONTRACTS", "MONITORING", "FINDINGS", "ALERTS 7D", "ID"],
1820
+ ...r.projects.map((p) => [
1821
+ p.name,
1822
+ p.contracts.length,
1823
+ p.status,
1824
+ p.findings,
1825
+ p.alerts7d,
1826
+ p.id
1827
+ ])
1828
+ ]))
1829
+ io.out(l);
1830
+ const nameOf = (pid) => r.projects.find((p) => p.id === pid)?.name ?? pid;
1831
+ for (const s of r.suggestions)
1832
+ 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`);
1833
+ return 0;
1834
+ }
1835
+ const subs = [
1836
+ "show",
1837
+ "overview",
1838
+ "add",
1839
+ "rename",
1840
+ "merge",
1841
+ "suggestion",
1842
+ "move",
1843
+ "remove-contract",
1844
+ "remove",
1845
+ "watches",
1846
+ "activity",
1847
+ "destinations",
1848
+ "monitoring"
1849
+ ];
1850
+ if (!subs.includes(sub))
1851
+ return usage(`Unknown command projects ${sub}`);
1852
+ if (!id)
1853
+ return usage(`sentinel projects ${sub} needs a project id (sentinel projects lists them)`);
1854
+ const projectNamed = async (pid) => {
1855
+ const { projects: all } = await io.client.projects();
1856
+ const p = all.find((x) => x.id === pid);
1857
+ if (!p)
1858
+ throw new SentinelError(404, `No project ${pid}. Yours: ${all.map((x) => `${x.name} (${x.id})`).join(", ") || "none"}`);
1859
+ return p;
1860
+ };
1861
+ switch (sub) {
1862
+ case "show": {
1863
+ const p = await io.client.project(id);
1864
+ if (json) {
1865
+ io.out(JSON.stringify(p, null, 2));
1866
+ return 0;
1867
+ }
1868
+ io.out(`${p.name} ${p.status} findings ${p.findings} alerts 7d ${p.alerts7d}`);
1869
+ for (const l of table([
1870
+ ["CONTRACT", "HOW", "ROLE", "PLAN"],
1871
+ ...p.contracts.map((c) => [c.contractId, c.origin, c.role, c.planId])
1872
+ ]))
1873
+ io.out(l);
1874
+ return 0;
1875
+ }
1876
+ case "overview":
1877
+ io.out(JSON.stringify(await io.client.projectOverview(id, { watches: flags.includes("--watches") }), null, 2));
1878
+ return 0;
1879
+ case "add": {
1880
+ if (!second)
1881
+ return usage("sentinel projects add needs a project id and a contract (SP….name or a GitHub link)");
1882
+ const into = values["--into"] ?? id;
1883
+ const p = await io.client.previewProjectContract(id, second);
1884
+ const s = p.suggestion;
1885
+ const target = into === "new" ? "a new project" : into;
1886
+ io.out(`${p.contractId} goes into ${target}.`);
1887
+ if (s.project !== into && s.kind !== "none")
1888
+ 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.`);
1889
+ const b = await io.client.billing();
1890
+ 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.");
1891
+ if (!flags.includes("--yes")) {
1892
+ io.out(`Run again with --yes to add it: sentinel projects add ${id} ${second}${values["--into"] ? ` --into ${into}` : ""} --yes`);
1893
+ return 0;
1894
+ }
1895
+ const r = await io.client.addProjectContract(id, second, into === id ? {} : { project: into });
1896
+ io.out(`${r.cached ? "Already planned" : "Started"}: ${r.planId} in project ${r.projectId}`);
1897
+ return 0;
1898
+ }
1899
+ case "rename": {
1900
+ if (!second?.trim())
1901
+ return usage("sentinel projects rename needs a project id and a name");
1902
+ if (previewOnly(io, flags, `Rename ${id} to "${second.trim()}".`, "Takes effect now."))
1903
+ return 0;
1904
+ io.out(`Renamed: ${(await io.client.renameProject(id, second)).name}`);
1905
+ return 0;
1906
+ }
1907
+ case "merge": {
1908
+ if (!second)
1909
+ return usage("sentinel projects merge needs a project id and the project to merge it into");
1910
+ const [from, to] = [await projectNamed(id), await projectNamed(second)];
1911
+ 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.`;
1912
+ if (previewOnly(io, flags, willDo, "Takes effect now."))
1913
+ return 0;
1914
+ const r = await io.client.mergeProject(id, second);
1915
+ io.out(`Merged ${r.contracts} contract(s) into ${to.name}.`);
1916
+ return 0;
1917
+ }
1918
+ case "suggestion": {
1919
+ const accept = third === "accept" ? true : third === "dismiss" ? false : null;
1920
+ if (!second || accept === null)
1921
+ return usage("sentinel projects suggestion needs <from> <into> accept|dismiss");
1922
+ const r = await io.client.projects();
1923
+ if (!r.suggestions.some((s) => s.from === id && s.into === second))
1924
+ throw new SentinelError(404, `No open suggestion to merge ${id} into ${second}.`);
1925
+ const nameOf = (pid) => r.projects.find((p) => p.id === pid)?.name ?? pid;
1926
+ const willDo = accept ? `Merge ${nameOf(id)} into ${nameOf(second)}: every contract moves.` : `Dismiss the suggestion to merge ${nameOf(id)} into ${nameOf(second)}.`;
1927
+ if (previewOnly(io, flags, willDo, "Takes effect now."))
1928
+ return 0;
1929
+ await io.client.answerMergeSuggestion(id, second, accept);
1930
+ io.out(accept ? "Merged." : "Dismissed.");
1931
+ return 0;
1932
+ }
1933
+ case "move": {
1934
+ if (!second || !third)
1935
+ return usage("sentinel projects move needs a project id, a contract and where to (a project id or new)");
1936
+ const to = third === "new" ? "a new project named after it" : (await projectNamed(third)).name;
1937
+ if (previewOnly(io, flags, `Move ${second} to ${to}.`, "Takes effect now."))
1938
+ return 0;
1939
+ const r = await io.client.moveProjectContract(id, second, third);
1940
+ io.out(`Moved to ${r.projectId}${r.projectRemoved ? `; ${id} had nothing left and is gone` : ""}.`);
1941
+ return 0;
1942
+ }
1943
+ case "remove-contract": {
1944
+ if (!second)
1945
+ return usage("sentinel projects remove-contract needs a project id and a contract");
1946
+ const member = (await io.client.project(id)).contracts.find((c) => c.contractId === second);
1947
+ if (!member)
1948
+ throw new SentinelError(404, `${second} isn't in project ${id}.`);
1949
+ const added = member.origin === "added";
1950
+ const confirm = values["--confirm"];
1951
+ if (added && confirm === undefined)
1952
+ return usage(`${second} is one you added: removing it takes its monitoring, alerts and claim. Type its name: --confirm ${contractName(second)}`);
1953
+ const willDo = added ? `Remove ${second}: its monitoring, alerts and claim go.` : `Take ${second} out of the project (Sentinel found it; you never claimed it).`;
1954
+ if (previewOnly(io, flags, willDo, added ? "Can't be undone." : ""))
1955
+ return 0;
1956
+ const r = await io.client.removeProjectContract(id, second, added ? confirm : undefined);
1957
+ io.out(`Removed ${second}${r.projectRemoved ? `; ${id} had nothing left and is gone` : ""}.`);
1958
+ return 0;
1959
+ }
1960
+ case "remove": {
1961
+ const p = await io.client.project(id);
1962
+ const confirm = values["--confirm"];
1963
+ if (confirm === undefined)
1964
+ return usage(`sentinel projects remove needs --confirm "${p.name}" (the project's name, typed)`);
1965
+ const added = p.contracts.filter((c) => c.origin === "added").length;
1966
+ const willDo = `Remove ${p.name} and the ${added} contract(s) you added in it: their monitoring, alerts and claims go.`;
1967
+ if (previewOnly(io, flags, willDo, "Can't be undone."))
1968
+ return 0;
1969
+ const r = await io.client.removeProject(id, confirm);
1970
+ io.out(`Removed ${p.name} (${r.contracts} contract(s)).`);
1971
+ return 0;
1972
+ }
1973
+ case "watches": {
1974
+ const w = await io.client.projectWatches(id);
1975
+ if (json) {
1976
+ io.out(JSON.stringify(w, null, 2));
1977
+ return 0;
1978
+ }
1979
+ io.out(`${w.counts.on} of ${w.counts.total} on`);
1980
+ for (const i of w.intents)
1981
+ io.out([i.title, i.on ? "on" : "off", i.summary].join(" "));
1982
+ return 0;
1983
+ }
1984
+ case "activity": {
1985
+ if (!second)
1986
+ return usage("sentinel projects activity needs a project id and a watch (its title or id)");
1987
+ const window = windowOf(values);
1988
+ if (window === null)
1989
+ return usage("--window takes 7 or 90");
1990
+ const intent = pickIntent((await io.client.projectWatches(id)).intents, second);
1991
+ const a = await io.client.projectActivity(id, intent.id, {
1992
+ window,
1993
+ contract: values["--contract"]
1994
+ });
1995
+ if (json)
1996
+ io.out(JSON.stringify(a, null, 2));
1997
+ else
1998
+ printActivity(io, intent.title, a);
1999
+ return 0;
2000
+ }
2001
+ case "destinations": {
2002
+ const email = values["--email"];
2003
+ if (email !== undefined && onOff(email) === null)
2004
+ return usage("--email takes on or off");
2005
+ if (email === undefined && !("--webhook" in values) && !("--slack" in values))
2006
+ return usage("sentinel projects destinations needs --email, --webhook or --slack");
2007
+ const dest = {
2008
+ ...email === undefined ? {} : { email: email === "on" },
2009
+ ...values["--webhook"] ? { webhookUrl: values["--webhook"] } : {},
2010
+ ...values["--slack"] ? { slackUrl: values["--slack"] } : {}
2011
+ };
2012
+ const places = [
2013
+ dest.email === true ? "email" : null,
2014
+ dest.webhookUrl ? "webhook" : null,
2015
+ dest.slackUrl ? "Slack" : null
2016
+ ].filter(Boolean).join(", ") || "nowhere new";
2017
+ const willDo = `Alerts for every contract you added in ${id} go to: ${places}. A webhook or Slack URL left out is cleared.`;
2018
+ if (previewOnly(io, flags, willDo, "Takes effect now."))
2019
+ return 0;
2020
+ const r = await io.client.setProjectDestinations(id, dest);
2021
+ for (const c of r.contracts) {
2022
+ io.out(`${c.contractId}: ${c.result}${c.error ? ` (${c.error})` : ""}`);
2023
+ if (c.signingSecret)
2024
+ io.out(` webhook signing secret (shown once): ${c.signingSecret}`);
2025
+ }
2026
+ return 0;
2027
+ }
2028
+ default: {
2029
+ const on = onOff(second);
2030
+ if (on === null)
2031
+ return usage("sentinel projects monitoring takes on or off");
2032
+ const willDo = `Turn monitoring ${on ? "on" : "off"} for every contract you added in ${id}.`;
2033
+ if (previewOnly(io, flags, willDo, on ? "Live now; events are billed." : "Off now."))
2034
+ return 0;
2035
+ const r = await io.client.setProjectMonitoring(id, on);
2036
+ for (const c of r.contracts)
2037
+ io.out(`${c.contractId}: ${c.result}${c.error ? ` (${c.error})` : ""}`);
2038
+ io.out(`Project monitoring: ${r.status}`);
2039
+ return 0;
2040
+ }
2041
+ }
2042
+ }
1279
2043
 
1280
2044
  // cli/main.ts
1281
2045
  function takeToken(argv) {