since-dev 0.1.0__py3-none-any.whl

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.
since_dev/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ """since-dev: what in a repository is about to stop working. The same CLI as the npm package of the same name."""
2
+
3
+ __version__ = "0.1.0"
since_dev/__main__.py ADDED
@@ -0,0 +1,23 @@
1
+ import sys
2
+
3
+ from .cli import run
4
+ from .realio import RealIo
5
+
6
+
7
+ def main():
8
+ for stream in (sys.stdout, sys.stderr):
9
+ # A Windows console in a legacy code page would fail on a title outside it; replace what it cannot show.
10
+ if hasattr(stream, "reconfigure"):
11
+ stream.reconfigure(encoding="utf-8", errors="replace")
12
+ try:
13
+ code = run(sys.argv[1:], RealIo())
14
+ except KeyboardInterrupt:
15
+ code = 130
16
+ except Exception as error: # noqa: BLE001 - the last line of defense: say what happened in one line
17
+ sys.stderr.write("since: %s\n" % error)
18
+ code = 1
19
+ sys.exit(code)
20
+
21
+
22
+ if __name__ == "__main__":
23
+ main()
since_dev/cli.py ADDED
@@ -0,0 +1,631 @@
1
+ """The whole CLI: run(argv, io) returns the exit code. Everything it touches (network, files, clock, terminal) comes in
2
+ through io, so the tests replay it from fixtures, and src/cli.mjs prints the same text from the same fixtures.
3
+
4
+ Exit codes: 0 done. `check`: 1 when a finding fails, 2 when the caller has something to fix, 0 when Since could not be
5
+ reached (an outage never fails a build). Every other command: 1 when it could not finish, 2 for a usage error.
6
+ """
7
+
8
+ import datetime
9
+ import json
10
+ import re
11
+ from urllib.parse import quote
12
+
13
+ from . import __version__ as VERSION
14
+ from . import render as text
15
+ from .init import dumps, init, loads
16
+ from .local import MAX_CHARACTERS, check_body, find_manifests, parse_repo
17
+
18
+ DEFAULT_API = "https://api.since.dev"
19
+ SITE = "https://since.dev"
20
+ APP = "https://app.since.dev"
21
+ COMMANDS = ["check", "calendar", "changed", "init", "login", "logout", "help", "version"]
22
+ SWITCHES = ["all", "json", "local", "dry-run", "no-color", "help", "version"]
23
+ OPTIONS = ["fail-on", "within", "provider", "ecosystem", "since", "email", "max-files"]
24
+ FAIL_ON = ["low", "medium", "high", "critical", "none"]
25
+ MAX_PAGES = 10
26
+ MAX_WAIT_MS = 60000
27
+
28
+
29
+ def parse_args(argv):
30
+ args = {"command": None, "positional": [], "flags": {}, "error": None}
31
+ index = 0
32
+ while index < len(argv):
33
+ word = argv[index]
34
+ index += 1
35
+ if word == "-h":
36
+ args["flags"]["help"] = True
37
+ elif word == "-v":
38
+ args["flags"]["version"] = True
39
+ elif word.startswith("--"):
40
+ equals = word.find("=")
41
+ name = word[2:] if equals < 0 else word[2:equals]
42
+ if name in SWITCHES and equals < 0:
43
+ args["flags"][name] = True
44
+ elif name in OPTIONS:
45
+ if equals >= 0:
46
+ value = word[equals + 1:]
47
+ elif index < len(argv):
48
+ value = argv[index]
49
+ index += 1
50
+ else:
51
+ value = None
52
+ if value is None or value == "":
53
+ args["error"] = "--%s needs a value." % name
54
+ return args
55
+ args["flags"][name] = value
56
+ else:
57
+ args["error"] = "Unknown option: %s" % word
58
+ return args
59
+ elif args["command"] is None and not args["positional"] and word in COMMANDS:
60
+ args["command"] = word
61
+ else:
62
+ args["positional"].append(word)
63
+ return args
64
+
65
+
66
+ def help_text(p):
67
+ usage = [
68
+ ["", "check this repository (reads the origin remote)"],
69
+ [" <owner/repo>", "check a public GitHub repository"],
70
+ [" check", "the same for CI: exit 1 when a finding fails"],
71
+ [" calendar", "what ends in the next 30 days"],
72
+ [" changed <package>", "breaking changes and deprecations of a package"],
73
+ [" init", "set up your coding agent to check before it writes"],
74
+ [" login", "sign in, for private repositories"],
75
+ [" logout", "remove the stored key"],
76
+ ]
77
+ options = [
78
+ ["--all", "show every finding"],
79
+ ["--json", "print the answer as JSON"],
80
+ ["--local", "check the manifests in this folder, not GitHub"],
81
+ ["--fail-on <level>", "check: low, medium, high (default), critical or none"],
82
+ ["--within <days>", "only dates within this many days, as 30d"],
83
+ ["--provider <name>", "calendar: one provider, as openai"],
84
+ ["--ecosystem <name>", "changed: the registry, as npm (default) or pypi"],
85
+ ["--since <version>", "changed: only what changed after this version"],
86
+ ["--email <address>", "login: the email to sign in with"],
87
+ ["--dry-run", "init: show what would be written"],
88
+ ["--max-files <n>", "how many manifests to check (default 10)"],
89
+ ["--no-color", "plain text"],
90
+ ]
91
+ width = max(len(p) + len(words) for words, _ in usage) + 3
92
+ lines = ["since-dev %s: what in this repository is about to stop working" % VERSION, "", "Usage"]
93
+ for words, what in usage:
94
+ lines.append(" %s%s" % ((p + words).ljust(width), what))
95
+ lines.extend(["", "Options"])
96
+ for words, what in options:
97
+ lines.append(" %s%s" % (words.ljust(21), what))
98
+ lines.extend(["", "Docs %s/docs#cli" % SITE, ""])
99
+ return "\n".join(lines)
100
+
101
+
102
+ def clamp(value, low, high):
103
+ return min(high, max(low, value))
104
+
105
+
106
+ def whole_number(value):
107
+ return int(value) if text.is_number(value) else None
108
+
109
+
110
+ def first(*values):
111
+ """The first value that is not None, as ?? reads in the JavaScript."""
112
+ for value in values:
113
+ if value is not None:
114
+ return value
115
+ return None
116
+
117
+
118
+ def read_json(content):
119
+ if content is None:
120
+ return None
121
+ try:
122
+ data = loads(content)
123
+ except ValueError:
124
+ return None
125
+ return data if isinstance(data, dict) else None
126
+
127
+
128
+ def credentials_path(io):
129
+ if io.platform == "win32" and io.env.get("APPDATA"):
130
+ return "%s\\since\\credentials.json" % io.env["APPDATA"]
131
+ return "%s/since/credentials.json" % (io.env.get("XDG_CONFIG_HOME") or "%s/.config" % io.home)
132
+
133
+
134
+ def read_key(io, api):
135
+ """The key to call with: SINCE_DEV_KEY, or the one `login` stored. A stored key only ever goes to the API it came from."""
136
+ from_env = str(io.env.get("SINCE_DEV_KEY") or "").strip()
137
+ if from_env:
138
+ return {"value": from_env, "source": "env"}
139
+ stored = read_json(io.read_file(credentials_path(io)))
140
+ if stored and isinstance(stored.get("api_key"), str) and stored["api_key"] and (stored.get("api") or DEFAULT_API) == api:
141
+ return {"value": stored["api_key"], "source": "file"}
142
+ return None
143
+
144
+
145
+ def encode_query(query):
146
+ # The characters encodeURIComponent leaves alone, so both languages ask for the same address.
147
+ return "&".join(
148
+ "%s=%s" % (name, quote(str(value), safe="-_.!~*'()"))
149
+ for name, value in query.items()
150
+ if value is not None and value != ""
151
+ )
152
+
153
+
154
+ def call(ctx, method, path, query=None, body=None, key=None):
155
+ """One request. status 0 means Since could not be reached; json is the answer when it is a JSON object."""
156
+ search = encode_query(query) if query else ""
157
+ headers = {"User-Agent": "since-cli/%s (%s)" % (VERSION, ctx["io"].runtime), "Accept": "application/json"}
158
+ if body is not None:
159
+ headers["Content-Type"] = "application/json"
160
+ if key:
161
+ headers["Authorization"] = "Bearer %s" % key["value"]
162
+ try:
163
+ answer = ctx["io"].fetch(method, "%s%s%s" % (ctx["api"], path, "?" + search if search else ""), headers, None if body is None else json.dumps(body))
164
+ except Exception as error: # noqa: BLE001 - any failure to reach Since reads the same to the caller
165
+ return {"status": 0, "json": None, "retryAfter": None, "certificate": "CERTIFICATE_VERIFY_FAILED" in str(error)}
166
+ retry = re.match(r"\s*([+-]?[0-9]+)", str(first(answer.get("retryAfter"), "")))
167
+ return {"status": answer["status"], "json": read_json(answer.get("text")), "retryAfter": int(retry.group(1)) if retry else None}
168
+
169
+
170
+ def error_of(answer):
171
+ return answer["json"]["error"] if answer["json"] is not None and isinstance(answer["json"].get("error"), str) else None
172
+
173
+
174
+ def message_of(answer):
175
+ return answer["json"]["message"] if answer["json"] is not None and isinstance(answer["json"].get("message"), str) else None
176
+
177
+
178
+ def refuse(ctx, answer):
179
+ """Say why a request did not answer, and return the exit code that goes with it."""
180
+ io, p, check = ctx["io"], ctx["p"], ctx["check"]
181
+ message = message_of(answer)
182
+ if answer["status"] == 0 or answer["status"] >= 500:
183
+ what = "Since is still busy." if error_of(answer) == "busy" else "Since could not be reached."
184
+ if answer.get("certificate"):
185
+ what = "This Python could not verify Since's certificate (on macOS, run its Install Certificates command)."
186
+ io.err("%s Try again in a minute.%s\n" % (what, " Nothing was checked, so this run passes." if check else ""))
187
+ return 0 if check else 1
188
+ if answer["status"] == 401:
189
+ if ctx["key"] and ctx["key"]["source"] == "env":
190
+ io.err("Since refused the key in SINCE_DEV_KEY. Create a new one at %s/api-keys\n" % APP)
191
+ else:
192
+ io.err("Since refused the stored key. Sign in again: %s login\n" % p)
193
+ elif answer["status"] == 429:
194
+ seconds = first(whole_number(answer["json"].get("retryAfterSec") if answer["json"] is not None else None), answer["retryAfter"])
195
+ # A shared CI address runs out of free scans through no fault of the build, so a limit passes a check like an outage does.
196
+ io.err("%s%s%s\n" % (
197
+ first(message, "Too many requests."),
198
+ " Try again in %s." % text.wait(seconds) if seconds else "",
199
+ " Nothing was checked, so this run passes." if check else "",
200
+ ))
201
+ return 0 if check else 1
202
+ else:
203
+ io.err("%s\n" % first(message, "Since answered %d." % answer["status"]))
204
+ return 2 if check else 1
205
+
206
+
207
+ def public_scan(ctx, full_name):
208
+ """The free scan of a public GitHub repository. A busy answer is waited out up to three times."""
209
+ waits = 0
210
+ while True:
211
+ answer = call(ctx, "POST", "/v1/scan", body={"repo": full_name})
212
+ if answer["status"] == 200 and answer["json"] is not None and isinstance(answer["json"].get("repository"), dict):
213
+ return {"result": answer["json"]}
214
+ if answer["status"] == 503 and error_of(answer) == "busy" and waits < 3:
215
+ seconds = clamp(first(answer["retryAfter"], 20), 1, 60)
216
+ ctx["io"].err("Since is busy. Trying again in %s.\n" % text.wait(seconds))
217
+ ctx["io"].sleep(seconds * 1000)
218
+ waits += 1
219
+ continue
220
+ return {"answer": answer}
221
+
222
+
223
+ def _has_findings(answer):
224
+ return answer["status"] == 200 and answer["json"] is not None and isinstance(answer["json"].get("findings"), list)
225
+
226
+
227
+ def check_all(ctx, body):
228
+ """One keyed check, whole: waits while a connected repository is still being read, then follows every page (pages are free)."""
229
+ io = ctx["io"]
230
+ started = io.now()
231
+ answer = call(ctx, "POST", "/v1/check", body=body, key=ctx["key"])
232
+ while _has_findings(answer) and answer["json"].get("scanning") and not answer["json"]["findings"]:
233
+ scanning = answer["json"]["scanning"]
234
+ seconds = clamp(first(whole_number(scanning.get("retryAfterSec") if isinstance(scanning, dict) else None), 10), 1, 30)
235
+ if io.now() - started + seconds * 1000 > MAX_WAIT_MS:
236
+ break
237
+ io.err("Since is still reading this repository. Trying again in %s.\n" % text.wait(seconds))
238
+ io.sleep(seconds * 1000)
239
+ answer = call(ctx, "POST", "/v1/check", body=body, key=ctx["key"])
240
+ if not _has_findings(answer):
241
+ return answer
242
+ whole = answer["json"]
243
+ page = 1
244
+ while page < MAX_PAGES and isinstance(whole.get("nextCursor"), str) and whole["nextCursor"]:
245
+ following = call(ctx, "POST", "/v1/check", body=dict(body, cursor=whole["nextCursor"]), key=ctx["key"])
246
+ if not _has_findings(following):
247
+ break
248
+ whole["findings"] = whole["findings"] + following["json"]["findings"]
249
+ whole["nextCursor"] = following["json"].get("nextCursor")
250
+ page += 1
251
+ if whole.get("nextCursor"):
252
+ whole["truncated"] = True
253
+ return answer
254
+
255
+
256
+ def connected_check(ctx, full_name):
257
+ """A repository connected to the caller's workspace, checked where Since already has its code."""
258
+ repos = call(ctx, "GET", "/v1/repos", key=ctx["key"])
259
+ if repos["status"] != 200 or repos["json"] is None:
260
+ return {"exit": refuse(ctx, repos)}
261
+ listed = repos["json"].get("repos")
262
+ repo = None
263
+ for entry in listed if isinstance(listed, list) else []:
264
+ if isinstance(entry, dict) and str(entry.get("fullName")).lower() == full_name.lower():
265
+ repo = entry
266
+ break
267
+ if repo is None:
268
+ return {}
269
+ answer = check_all(ctx, {"repo": repo.get("id"), "detail": "summary"})
270
+ if answer["status"] == 403 and error_of(answer) == "impact_api_not_in_plan":
271
+ return {}
272
+ if answer["status"] != 200 or answer["json"] is None:
273
+ return {"exit": refuse(ctx, answer)}
274
+ return {"answer": answer["json"], "repo": repo}
275
+
276
+
277
+ def local_check(ctx, no_origin):
278
+ """The manifests in this folder, sent one by one as the GitHub Action sends them."""
279
+ io, p, check = ctx["io"], ctx["p"], ctx["check"]
280
+ if not ctx["key"]:
281
+ if no_origin:
282
+ io.err("This folder has no GitHub origin. Name a public repository, as %s owner/repo, or sign in to check the files here: %s login\n" % (p, p))
283
+ else:
284
+ io.err("Checking the files in this folder needs a key. Sign in first: %s login\n" % p)
285
+ return 2 if check else 1
286
+ found = find_manifests(io)
287
+ if not found:
288
+ io.err("No manifests in this folder (package.json, pyproject.toml, go.mod and the like).\n")
289
+ return 2 if check else 1
290
+ files = []
291
+ notes = []
292
+ last = None
293
+ if len(found) > ctx["max_files"]:
294
+ notes.append("%s found, the first %d checked (each one costs credits). Raise it with --max-files." % (text.plural(len(found), "manifest"), ctx["max_files"]))
295
+ for file in found[:ctx["max_files"]]:
296
+ content = io.read_file("%s/%s" % (io.cwd, file))
297
+ if content is None:
298
+ notes.append("%s: could not be read." % file)
299
+ elif len(content) > MAX_CHARACTERS:
300
+ notes.append("%s: over %s characters, not sent." % (file, text.num(MAX_CHARACTERS)))
301
+ else:
302
+ answer = check_all(ctx, check_body(file, content))
303
+ if _has_findings(answer):
304
+ files.append({"path": file, "answer": answer["json"]})
305
+ elif answer["status"] in (401, 402):
306
+ return refuse(ctx, answer)
307
+ else:
308
+ last = answer
309
+ unreachable = answer["status"] == 0 or answer["status"] >= 500
310
+ notes.append("%s: %s" % (file, "Since could not be reached." if unreachable else first(message_of(answer), "Since answered %d." % answer["status"])))
311
+ if not files and last is not None:
312
+ return refuse(ctx, last)
313
+ return report(ctx, {"kind": "local", "files": files, "notes": notes})
314
+
315
+
316
+ def parse_time(value):
317
+ """Milliseconds since 1970 from an ISO time, or None."""
318
+ try:
319
+ moment = datetime.datetime.fromisoformat(str(value).replace("Z", "+00:00"))
320
+ except ValueError:
321
+ return None
322
+ if moment.tzinfo is None:
323
+ moment = moment.replace(tzinfo=datetime.timezone.utc)
324
+ return (moment - datetime.datetime(1970, 1, 1, tzinfo=datetime.timezone.utc)) // datetime.timedelta(milliseconds=1)
325
+
326
+
327
+ def report(ctx, data):
328
+ """Print what was found and return the exit code."""
329
+ io, args, p, c, check = ctx["io"], ctx["args"], ctx["p"], ctx["c"], ctx["check"]
330
+ show_all = bool(args["flags"].get("all"))
331
+ if data["kind"] == "scan":
332
+ findings = data["result"].get("findings")
333
+ items = text.scan_items(findings if isinstance(findings, list) else [])
334
+ elif data["kind"] == "repo":
335
+ items = text.check_items(data["answer"]["findings"], None, p)
336
+ else:
337
+ items = [item for file in data["files"] for item in text.check_items(file["answer"]["findings"], file["path"], p)]
338
+ if ctx["within"] is not None:
339
+ items = [item for item in items if item["daysLeft"] is not None and item["daysLeft"] <= ctx["within"]]
340
+ items = text.order(items)
341
+ failing = [item for item in items if text.fails(item, ctx["fail_on"])] if check else []
342
+ exit_code = 1 if failing else 0
343
+ if args["flags"].get("json"):
344
+ whole = data["result"] if data["kind"] == "scan" else data["answer"] if data["kind"] == "repo" else {"files": data["files"]}
345
+ io.out("%s\n" % dumps(whole, 2))
346
+ return exit_code
347
+
348
+ lines = [""]
349
+ notes = []
350
+ footer = []
351
+ counts = text.counts_line(items, ctx["within"])
352
+ more = "%s%s%s --all" % (p, " check" if check else "", " %s" % args["positional"][0] if args["positional"] else "")
353
+ if data["kind"] == "scan":
354
+ result = data["result"]
355
+ repository = result["repository"]
356
+ summary = result["summary"] if isinstance(result.get("summary"), dict) else {}
357
+ sha = str(first(repository.get("headSha"), ""))[:7]
358
+ branch = str(first(repository.get("defaultBranch"), ""))
359
+ parsed = parse_time(first(result.get("scannedAt"), ""))
360
+ scanned = text.ago(None if parsed is None else io.now() - parsed)
361
+ head = [c.bold(str(repository.get("fullName"))), "%s@%s" % (branch, sha) if sha else branch, "scanned %s" % scanned if scanned else ""]
362
+ lines.append(" %s" % " ".join(part for part in head if part))
363
+ dependencies = first(whole_number(summary.get("dependencies")), 0)
364
+ files = first(whole_number(summary.get("files")), 0)
365
+ lines.append(" %s %s %s and %s read" % (counts, text.num(dependencies), "dependency" if dependencies == 1 else "dependencies", text.plural(files, "file")))
366
+ if data["fromTree"]:
367
+ lines.append(c.dim(" Read from GitHub (%s), not your working tree." % branch))
368
+ coverage = result.get("coverage")
369
+ for note in coverage["notes"] if isinstance(coverage, dict) and isinstance(coverage.get("notes"), list) else []:
370
+ notes.append(str(note))
371
+ footer.append(" %s%s/scan/%s?utm_source=cli" % ("Full report".ljust(14), SITE, repository.get("fullName")))
372
+ elif data["kind"] == "repo":
373
+ head = [c.bold(str(data["repo"].get("fullName"))), str(first(data["repo"].get("defaultBranch"), "")), "connected repository"]
374
+ lines.append(" %s" % " ".join(part for part in head if part))
375
+ lines.append(" %s %s checked" % (counts, text.num(first(whole_number(data["answer"].get("checkedCount")), 0))))
376
+ notes.extend(text.answer_notes([data["answer"]]))
377
+ else:
378
+ checked = sum(first(whole_number(file["answer"].get("checkedCount")), 0) for file in data["files"])
379
+ lines.append(" %s %s checked" % (c.bold("This folder"), text.plural(len(data["files"]), "manifest")))
380
+ lines.append(" %s %s checked" % (counts, text.num(checked)))
381
+ notes.extend(data["notes"])
382
+ notes.extend(text.answer_notes([file["answer"] for file in data["files"]]))
383
+ notes.append("Only manifests were checked. Connect the repository to check its code too: %s?utm_source=cli" % APP)
384
+ lines.append("")
385
+ lines.extend(text.finding_lines(items, show_all, c, more))
386
+ for note in notes:
387
+ lines.append(" %s" % note)
388
+ if notes:
389
+ lines.append("")
390
+ lines.extend(footer)
391
+ if not check:
392
+ lines.append(" %s%s init your coding agent checks before it writes" % ("Next".ljust(14), p))
393
+ elif ctx["fail_on"] != "none":
394
+ level = "at critical" if ctx["fail_on"] == "critical" else "at %s or above" % ctx["fail_on"]
395
+ if failing:
396
+ lines.append(" %s%s %s" % ("Failed".ljust(14), text.plural(len(failing), "finding"), level))
397
+ else:
398
+ lines.append(" %snothing %s" % ("Passed".ljust(14), level))
399
+ lines.append("")
400
+ io.out("\n".join(lines))
401
+ return exit_code
402
+
403
+
404
+ def repo_command(ctx):
405
+ """`since`, `since <owner/repo>` and `since check`: find the repository, then the cheapest way to read it."""
406
+ io, args, p, check = ctx["io"], ctx["args"], ctx["p"], ctx["check"]
407
+ given = args["positional"][0] if args["positional"] else None
408
+ from_tree = given is None
409
+ repo = parse_repo(io.origin() if from_tree else given)
410
+ stop = 2 if check else 1
411
+ if not from_tree and not repo:
412
+ io.err("%s is not a repository. Use owner/repo or a GitHub URL.\n" % given)
413
+ return 2
414
+ if not from_tree and args["flags"].get("local"):
415
+ io.err("--local checks the folder you are in, so it takes no repository name.\n")
416
+ return 2
417
+ # In CI with a key, and whenever --local asks: the checkout itself, not the default branch on GitHub.
418
+ if from_tree and (args["flags"].get("local") or (check and ctx["key"])):
419
+ return local_check(ctx, False)
420
+ if not repo or repo["host"] != "github.com":
421
+ if from_tree:
422
+ return local_check(ctx, True)
423
+ io.err("%s is not on GitHub. Run %s inside its folder to check its manifests.\n" % (given, p))
424
+ return stop
425
+
426
+ full_name = "%s/%s" % (repo["owner"], repo["name"])
427
+ # A first scan takes up to a minute, and a silent terminal reads as a hung one.
428
+ io.err("Reading %s on GitHub. A first scan can take a minute.\n" % full_name)
429
+ scan = public_scan(ctx, full_name)
430
+ if "result" in scan:
431
+ return report(ctx, {"kind": "scan", "result": scan["result"], "fromTree": from_tree})
432
+ # The free scan said no (not public, too big, or today's scans are used up): with a key there is still a way to answer
433
+ # for the folder you are in, so say why and take it. Without one, or when Since itself is down, this is the end.
434
+ refusal = error_of(scan["answer"])
435
+ down = scan["answer"]["status"] == 0 or scan["answer"]["status"] >= 500
436
+ if refusal != "not_found":
437
+ if down or not ctx["key"] or not from_tree:
438
+ return refuse(ctx, scan["answer"])
439
+ why = message_of(scan["answer"])
440
+ io.err("%sChecking with your key instead.\n" % ("%s " % why if why is not None else ""))
441
+ if not ctx["key"]:
442
+ if from_tree:
443
+ io.err("%s is not a public GitHub repository. For a private one, sign in and run this again: %s login\n" % (full_name, p))
444
+ else:
445
+ io.err("GitHub serves no public repository named %s. For a private one, sign in first: %s login\n" % (full_name, p))
446
+ return stop
447
+ connected = connected_check(ctx, full_name)
448
+ if "answer" in connected:
449
+ return report(ctx, {"kind": "repo", "answer": connected["answer"], "repo": connected["repo"]})
450
+ if "exit" in connected:
451
+ return connected["exit"]
452
+ if from_tree:
453
+ return local_check(ctx, False)
454
+ io.err("%s is private and Since cannot read it from here. Run %s inside its folder.\n" % (full_name, p))
455
+ return stop
456
+
457
+
458
+ def calendar_command(ctx):
459
+ io, args, p, c = ctx["io"], ctx["args"], ctx["p"], ctx["c"]
460
+ within = first(ctx["within"], 30)
461
+ provider = args["flags"].get("provider")
462
+ answer = call(ctx, "GET", "/v1/calendar", query={"within": "%dd" % within, "provider": provider, "limit": 500 if args["flags"].get("all") else 15})
463
+ if answer["status"] != 200 or answer["json"] is None or not isinstance(answer["json"].get("entries"), list):
464
+ return refuse(ctx, answer)
465
+ if args["flags"].get("json"):
466
+ io.out("%s\n" % dumps(answer["json"], 2))
467
+ return 0
468
+ entries = answer["json"]["entries"]
469
+ lines = ["", " %s%s" % (c.bold("Ending in the next %s" % text.plural(within, "day")), " %s" % provider if provider else ""), ""]
470
+ if not entries:
471
+ lines.append(" Nothing on the calendar in that time.")
472
+ lines.extend(text.calendar_lines(entries, SITE, c))
473
+ lines.append("")
474
+ if answer["json"].get("nextCursor"):
475
+ lines.append(" %s%s calendar%s --all" % ("More".ljust(14), p, " --provider %s" % provider if provider else ""))
476
+ lines.extend([" %s%s/calendar?utm_source=cli" % ("Calendar".ljust(14), SITE), ""])
477
+ io.out("\n".join(lines))
478
+ return 0
479
+
480
+
481
+ def changed_command(ctx):
482
+ io, args, p, c = ctx["io"], ctx["args"], ctx["p"], ctx["c"]
483
+ name = args["positional"][0]
484
+ if not ctx["key"]:
485
+ io.err("%s changed needs a key. Sign in first: %s login\n" % (p, p))
486
+ return 1
487
+ ecosystem = first(args["flags"].get("ecosystem"), "npm")
488
+ since = args["flags"].get("since")
489
+ answer = call(ctx, "GET", "/v1/changes", query={"package": name, "ecosystem": ecosystem, "since": since, "limit": 200 if args["flags"].get("all") else 20}, key=ctx["key"])
490
+ if answer["status"] != 200 or answer["json"] is None or not isinstance(answer["json"].get("changes"), list):
491
+ return refuse(ctx, answer)
492
+ if args["flags"].get("json"):
493
+ io.out("%s\n" % dumps(answer["json"], 2))
494
+ return 0
495
+ changes = text.unique_changes(answer["json"]["changes"])
496
+ found = answer["json"]["package"] if isinstance(answer["json"].get("package"), dict) else {}
497
+ head = [c.bold(str(first(found.get("name"), name))), str(first(found.get("ecosystem"), ecosystem)), "latest %s" % found["latest"] if found.get("latest") else ""]
498
+ lines = ["", " %s" % " ".join(part for part in head if part)]
499
+ lines.extend([" %s%s%s" % (
500
+ text.plural(len(changes), "change") if changes else "No changes recorded",
501
+ " since %s" % since if since else "",
502
+ ", and more" if answer["json"].get("nextCursor") else "",
503
+ ), ""])
504
+ lines.extend(text.change_lines(changes, c))
505
+ if changes:
506
+ lines.append("")
507
+ notes = []
508
+ if answer["json"].get("reading"):
509
+ notes.append("Since is reading newer releases of this package now. Run this again in a few minutes to include them.")
510
+ for note in notes:
511
+ lines.append(" %s" % note)
512
+ if notes:
513
+ lines.append("")
514
+ if answer["json"].get("nextCursor"):
515
+ lines.extend([" %s%s changed %s%s%s --all" % ("More".ljust(14), p, name, " --since %s" % since if since else "", " --ecosystem %s" % ecosystem if ecosystem != "npm" else ""), ""])
516
+ io.out("\n".join(lines))
517
+ return 0
518
+
519
+
520
+ def login_command(ctx):
521
+ """Sign in without a password: the person approves the link in a browser and the key comes back."""
522
+ io, args, p = ctx["io"], ctx["args"], ctx["p"]
523
+ email = str(first(args["flags"].get("email"), "")).strip()
524
+ if not email and io.tty:
525
+ email = str(io.prompt("Email: ")).strip()
526
+ if not email:
527
+ io.err("Give the email to sign in with: %s login --email you@example.com\n" % p)
528
+ return 2
529
+ start = call(ctx, "POST", "/v1/agent/register", body={"email": email, "client_name": "since-cli"})
530
+ setup = start["json"].get("setup") if start["json"] is not None and isinstance(start["json"].get("setup"), dict) else None
531
+ if start["status"] != 200 or setup is None or not isinstance(start["json"].get("claim_token"), str):
532
+ return refuse(ctx, start)
533
+ link = str(first(setup.get("verification_uri_complete"), setup.get("verification_uri")))
534
+ # The approval page shows an eight-character code in two halves: print it the way the person will see it there.
535
+ code = str(first(setup.get("user_code"), ""))
536
+ io.out("\n Open this link to approve the sign-in:\n %s\n Code: %s\n\n" % (link, "%s-%s" % (code[:4], code[4:]) if len(code) == 8 else code))
537
+ if io.tty:
538
+ io.open(link)
539
+ interval = clamp(first(whole_number(setup.get("interval")), 5), 1, 60)
540
+ deadline = io.now() + clamp(first(whole_number(setup.get("expires_in")), 900), 30, 3600) * 1000
541
+ while io.now() + interval * 1000 <= deadline:
542
+ io.sleep(interval * 1000)
543
+ answer = call(ctx, "POST", "/v1/agent/token", body={"claim_token": start["json"]["claim_token"]})
544
+ if answer["status"] == 200 and answer["json"] is not None and isinstance(answer["json"].get("api_key"), str):
545
+ file = credentials_path(io)
546
+ stored = {"api": ctx["api"], "api_key": answer["json"]["api_key"], "key_name": answer["json"].get("key_name"), "key_prefix": answer["json"].get("key_prefix")}
547
+ io.write_file(file, "%s\n" % dumps(stored, 2), 0o600)
548
+ io.out(" Signed in. The key is stored in %s\n\n" % file)
549
+ return 0
550
+ code = error_of(answer)
551
+ if code == "slow_down":
552
+ interval += 5
553
+ elif code == "expired_token":
554
+ break
555
+ elif code == "access_denied":
556
+ io.err("The sign-in was declined.\n")
557
+ return 1
558
+ elif code != "authorization_pending" and answer["status"] != 0 and answer["status"] < 500:
559
+ return refuse(ctx, answer)
560
+ io.err("The link expired before it was approved. Run %s login again.\n" % p)
561
+ return 1
562
+
563
+
564
+ def logout_command(ctx):
565
+ io = ctx["io"]
566
+ removed = io.remove(credentials_path(io))
567
+ io.out("Signed out. The key is gone from this computer; delete it for good at %s/api-keys\n" % APP if removed else "No stored key.\n")
568
+ return 0
569
+
570
+
571
+ def run(argv, io):
572
+ args = parse_args(argv)
573
+ p = io.program
574
+
575
+ def usage(message):
576
+ io.err("%s\nRun %s --help for the commands.\n" % (message, p))
577
+ return 2
578
+
579
+ if args["error"]:
580
+ return usage(args["error"])
581
+ flags = args["flags"]
582
+ if flags.get("version") or args["command"] == "version":
583
+ io.out("since-dev %s\n" % VERSION)
584
+ return 0
585
+ if flags.get("help") or args["command"] == "help":
586
+ io.out(help_text(p))
587
+ return 0
588
+ command = args["command"]
589
+ words = args["positional"]
590
+ if command == "changed":
591
+ if len(words) != 1:
592
+ return usage("Too many words: %s" % words[1] if words else "%s changed needs a package name, as %s changed react" % (p, p))
593
+ elif command is None or command == "check":
594
+ if len(words) > 1:
595
+ return usage("Too many words: %s" % words[1])
596
+ if command is None and len(words) == 1 and "/" not in words[0]:
597
+ return usage("Unknown command: %s" % words[0])
598
+ elif words:
599
+ return usage("Too many words: %s" % words[0])
600
+
601
+ fail_on = first(flags.get("fail-on"), "high")
602
+ if fail_on not in FAIL_ON:
603
+ return usage("--fail-on takes low, medium, high, critical or none.")
604
+ within = None
605
+ if "within" in flags:
606
+ if not re.fullmatch(r"[0-9]{1,4}d?", flags["within"]) or int(flags["within"].rstrip("d")) < 1:
607
+ return usage("--within takes a number of days, as 30d.")
608
+ within = int(flags["within"].rstrip("d"))
609
+ max_files = 10
610
+ if "max-files" in flags:
611
+ if not re.fullmatch(r"[0-9]{1,3}", flags["max-files"]) or int(flags["max-files"]) < 1:
612
+ return usage("--max-files takes a number from 1 to 999.")
613
+ max_files = int(flags["max-files"])
614
+
615
+ api = re.sub(r"/+$", "", str(io.env.get("SINCE_API_URL") or DEFAULT_API))
616
+ color = bool(io.tty) and not flags.get("no-color") and not io.env.get("NO_COLOR") and io.env.get("TERM") != "dumb"
617
+ ctx = {
618
+ "io": io, "args": args, "p": p, "api": api, "c": text.Palette(color), "check": command == "check",
619
+ "key": read_key(io, api), "fail_on": fail_on, "within": within, "max_files": max_files,
620
+ }
621
+ if command == "calendar":
622
+ return calendar_command(ctx)
623
+ if command == "changed":
624
+ return changed_command(ctx)
625
+ if command == "init":
626
+ return init(io, p, bool(flags.get("dry-run")))
627
+ if command == "login":
628
+ return login_command(ctx)
629
+ if command == "logout":
630
+ return logout_command(ctx)
631
+ return repo_command(ctx)