since-dev 0.0.0-stage → 0.1.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/LICENSE +21 -0
- package/README.md +125 -2
- package/bin/since.mjs +21 -0
- package/package.json +14 -4
- package/src/cli.mjs +526 -0
- package/src/init.mjs +102 -0
- package/src/io.mjs +87 -0
- package/src/local.mjs +66 -0
- package/src/render.mjs +257 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Since.dev
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,126 @@
|
|
|
1
|
-
#
|
|
1
|
+
# since-dev
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Find the code in a repository that is about to stop working: AI model IDs with a shutdown date, runtimes past end of life, dependencies with security advisories or breaking releases. Every finding comes with its file and line, its date, its replacement and its source.
|
|
4
|
+
|
|
5
|
+
```bash
|
|
6
|
+
npx since-dev
|
|
7
|
+
```
|
|
8
|
+
|
|
9
|
+
Run it inside any repository. A public GitHub repository needs no sign-in and no setup.
|
|
10
|
+
|
|
11
|
+
```
|
|
12
|
+
acme/shop main@4f2c1d9 scanned just now
|
|
13
|
+
3 with a date 1 undated 214 dependencies and 96 files read
|
|
14
|
+
Read from GitHub (main), not your working tree.
|
|
15
|
+
|
|
16
|
+
20 days claude-sonnet-4-5-20250929 on Claude API: retirement 2026-11-30
|
|
17
|
+
src/ai/client.ts:14 and 3 more use claude-sonnet-5-5
|
|
18
|
+
https://platform.claude.com/docs/en/about-claude/model-deprecations
|
|
19
|
+
|
|
20
|
+
31 days gpt-5 on OpenAI API: retirement 2026-12-11
|
|
21
|
+
src/ai/summarize.ts:8 use gpt-5.6-sol
|
|
22
|
+
https://developers.openai.com/api/docs/deprecations
|
|
23
|
+
|
|
24
|
+
passed Node.js 20: end of life 2026-04-30
|
|
25
|
+
.github/workflows/ci.yml use 24
|
|
26
|
+
https://endoflife.date/nodejs
|
|
27
|
+
|
|
28
|
+
Full report https://since.dev/scan/acme/shop?utm_source=cli
|
|
29
|
+
Next npx since-dev init your coding agent checks before it writes
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
(An example for a made-up repository. The dates are the real ones as of November 10, 2026.)
|
|
33
|
+
|
|
34
|
+
## Run it
|
|
35
|
+
|
|
36
|
+
| With | Command |
|
|
37
|
+
| --- | --- |
|
|
38
|
+
| Node 18 or newer | `npx since-dev` |
|
|
39
|
+
| uv | `uvx since-dev` |
|
|
40
|
+
| pipx | `pipx run since-dev` |
|
|
41
|
+
|
|
42
|
+
To keep it, `npm install -g since-dev` or `pip install since-dev` gives you the command `since`. The npm and PyPI packages are the same CLI: no dependencies in either, and one set of tests holds both to the same output, character for character.
|
|
43
|
+
|
|
44
|
+
## Commands
|
|
45
|
+
|
|
46
|
+
| Command | What it does |
|
|
47
|
+
| --- | --- |
|
|
48
|
+
| `since` | Checks the repository you are in. It reads the `origin` remote to know which one. |
|
|
49
|
+
| `since <owner/repo>` | Checks any public GitHub repository. A GitHub URL works too. |
|
|
50
|
+
| `since init` | Sets up your coding agent to check before it writes. See below. |
|
|
51
|
+
| `since calendar` | What ends in the next 30 days: model retirements, runtime end of life, API removals. `--within 90d`, `--provider openai`. |
|
|
52
|
+
| `since changed <package>` | Breaking changes and deprecations of a package. `--since 4.19.2` lists only what changed after that version, `--ecosystem pypi` picks the registry. Needs a key. |
|
|
53
|
+
| `since check` | The same check for CI: exit 1 when a finding fails. See below. |
|
|
54
|
+
| `since login`, `since logout` | Sign in for private repositories, and remove the stored key. |
|
|
55
|
+
|
|
56
|
+
`--all` shows every finding (10 by default), `--json` prints the answer as JSON, `--within 30d` keeps only dates within that many days.
|
|
57
|
+
|
|
58
|
+
Findings are listed with the next deadline first, then dates that have passed, then security advisories with no date, then the ones marked `review`: Since found the name in your code but could not prove the change reaches it.
|
|
59
|
+
|
|
60
|
+
## Set up your coding agent
|
|
61
|
+
|
|
62
|
+
Coding agents write the model IDs and versions that were current when they were trained. This makes yours check first:
|
|
63
|
+
|
|
64
|
+
```bash
|
|
65
|
+
npx since-dev init
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
It writes four files into the repository:
|
|
69
|
+
|
|
70
|
+
| File | For |
|
|
71
|
+
| --- | --- |
|
|
72
|
+
| `.mcp.json` | Claude Code |
|
|
73
|
+
| `.cursor/mcp.json` | Cursor |
|
|
74
|
+
| `.vscode/mcp.json` | VS Code |
|
|
75
|
+
| `AGENTS.md` | A short rule, between two markers, that tells any agent when to check |
|
|
76
|
+
|
|
77
|
+
Commit them and everyone who clones the repository gets the same setup. No key goes in any file: the first tool call opens a sign-in in the browser.
|
|
78
|
+
|
|
79
|
+
It only ever adds its own `since` entry. Other servers in those files stay as they are, a second run changes nothing, and a file it cannot read as plain JSON is left alone with the lines to add by hand. `--dry-run` shows what it would write.
|
|
80
|
+
|
|
81
|
+
## What it sends
|
|
82
|
+
|
|
83
|
+
- **A public GitHub repository:** only its name. Since reads the default branch from GitHub, so uncommitted work and other branches are not in the answer. It is free: 10 scans a day per address, and a scan is kept for an hour.
|
|
84
|
+
- **A private repository, after `since login`:** if the repository is connected to your Since workspace and your plan includes it, the answer comes from there. Otherwise the CLI sends the manifests it finds in the folder (`package.json`, `requirements*.txt`, `pyproject.toml`, `go.mod`, `Cargo.toml`, `Gemfile`, `composer.json`, `pom.xml`, `build.gradle`, `*.csproj`), 10 at most unless you raise `--max-files`. Source files are never sent. Each manifest is one call to the API, which uses credits.
|
|
85
|
+
- `--local` checks the manifests in the folder even when the repository is public, for work you have not pushed.
|
|
86
|
+
|
|
87
|
+
## In CI
|
|
88
|
+
|
|
89
|
+
```yaml
|
|
90
|
+
- run: npx since-dev check --fail-on high
|
|
91
|
+
env:
|
|
92
|
+
SINCE_DEV_KEY: ${{ secrets.SINCE_DEV_KEY }}
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
With a key it checks the manifests of the checkout. Without one it reads the default branch of a public repository from GitHub, and says so.
|
|
96
|
+
|
|
97
|
+
| Exit code | Meaning |
|
|
98
|
+
| --- | --- |
|
|
99
|
+
| 0 | Nothing proven at or above `--fail-on` (`low`, `medium`, `high` by default, `critical`, or `none`). |
|
|
100
|
+
| 1 | At least one finding fails. A finding marked `review` never fails a run. |
|
|
101
|
+
| 2 | Something for you to fix: a refused key, a bad option, no manifests. |
|
|
102
|
+
|
|
103
|
+
If Since cannot be reached, or the free scans for the day are used up, the run passes with a note. An outage on our side never fails your build.
|
|
104
|
+
|
|
105
|
+
On GitHub Actions, [since-dev/check](https://github.com/since-dev/check) does the same with annotations on the files and a table in the run summary.
|
|
106
|
+
|
|
107
|
+
## Sign in
|
|
108
|
+
|
|
109
|
+
```bash
|
|
110
|
+
since login
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
It asks for your email, prints a link, and waits while you approve it in the browser. The key is stored in `~/.config/since/credentials.json`, readable only by you. Setting `SINCE_DEV_KEY` works as well and wins over the stored key. `since logout` removes the stored key.
|
|
114
|
+
|
|
115
|
+
## Develop
|
|
116
|
+
|
|
117
|
+
```bash
|
|
118
|
+
node --test test.mjs
|
|
119
|
+
python3 -m unittest test_cli
|
|
120
|
+
```
|
|
121
|
+
|
|
122
|
+
Each file in `fixtures/` is one run: the arguments, the folder, and the answers the API gives. `expected/` holds what that run prints, requests and writes. Both test suites replay every fixture and compare with the same expected file. After a change to the Node side, `UPDATE_EXPECTED=1 node --test test.mjs` rewrites the expected files: read the diff, then make the Python side match.
|
|
123
|
+
|
|
124
|
+
## License
|
|
125
|
+
|
|
126
|
+
MIT. The data comes from [Since.dev](https://since.dev): the [calendar](https://since.dev/calendar) of what ends when, and the [API](https://since.dev/docs) behind this CLI.
|
package/bin/since.mjs
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { run } from "../src/cli.mjs"
|
|
3
|
+
import { realIo } from "../src/io.mjs"
|
|
4
|
+
|
|
5
|
+
// Node 18 warns that fetch is experimental on its first call. It works, and the warning would land in the middle of the output.
|
|
6
|
+
const emitWarning = process.emitWarning
|
|
7
|
+
process.emitWarning = (warning, ...rest) => {
|
|
8
|
+
const type = rest[0] && typeof rest[0] === "object" ? rest[0].type : rest[0]
|
|
9
|
+
if (type === "ExperimentalWarning") return
|
|
10
|
+
emitWarning.call(process, warning, ...rest)
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
run(process.argv.slice(2), realIo()).then(
|
|
14
|
+
(code) => {
|
|
15
|
+
process.exitCode = code
|
|
16
|
+
},
|
|
17
|
+
(error) => {
|
|
18
|
+
process.stderr.write(`since: ${error && error.message ? error.message : error}\n`)
|
|
19
|
+
process.exitCode = 1
|
|
20
|
+
},
|
|
21
|
+
)
|
package/package.json
CHANGED
|
@@ -1,6 +1,16 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "since-dev",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Find the code in a repository that is about to stop working: retired model IDs, runtimes past end of life, breaking dependency changes. Each with its date, its line and its replacement.",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"author": "Nabil A <support@since.dev>",
|
|
7
|
+
"homepage": "https://since.dev",
|
|
8
|
+
"repository": { "type": "git", "url": "git+https://github.com/Since-dev/cli.git" },
|
|
9
|
+
"bugs": { "url": "https://github.com/Since-dev/cli/issues" },
|
|
10
|
+
"keywords": ["deprecation", "end-of-life", "breaking-changes", "dependencies", "ai-models", "mcp", "cli", "continuous-maintenance"],
|
|
11
|
+
"type": "module",
|
|
12
|
+
"bin": { "since": "bin/since.mjs", "since-dev": "bin/since.mjs" },
|
|
13
|
+
"files": ["bin", "src", "README.md", "LICENSE"],
|
|
14
|
+
"engines": { "node": ">=18" },
|
|
15
|
+
"scripts": { "test": "node --test test.mjs" }
|
|
16
|
+
}
|
package/src/cli.mjs
ADDED
|
@@ -0,0 +1,526 @@
|
|
|
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 since_dev/cli.py 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
|
+
import { init } from "./init.mjs"
|
|
8
|
+
import { MAX_CHARACTERS, checkBody, findManifests, parseRepo } from "./local.mjs"
|
|
9
|
+
import * as text from "./render.mjs"
|
|
10
|
+
|
|
11
|
+
export const VERSION = "0.1.0"
|
|
12
|
+
const DEFAULT_API = "https://api.since.dev"
|
|
13
|
+
const SITE = "https://since.dev"
|
|
14
|
+
const APP = "https://app.since.dev"
|
|
15
|
+
const COMMANDS = ["check", "calendar", "changed", "init", "login", "logout", "help", "version"]
|
|
16
|
+
const SWITCHES = ["all", "json", "local", "dry-run", "no-color", "help", "version"]
|
|
17
|
+
const OPTIONS = ["fail-on", "within", "provider", "ecosystem", "since", "email", "max-files"]
|
|
18
|
+
const FAIL_ON = ["low", "medium", "high", "critical", "none"]
|
|
19
|
+
const MAX_PAGES = 10
|
|
20
|
+
const MAX_WAIT_MS = 60_000
|
|
21
|
+
|
|
22
|
+
export function parseArgs(argv) {
|
|
23
|
+
const args = { command: null, positional: [], flags: {}, error: null }
|
|
24
|
+
for (let index = 0; index < argv.length; index++) {
|
|
25
|
+
const word = argv[index]
|
|
26
|
+
if (word === "-h") args.flags.help = true
|
|
27
|
+
else if (word === "-v") args.flags.version = true
|
|
28
|
+
else if (word.startsWith("--")) {
|
|
29
|
+
const equals = word.indexOf("=")
|
|
30
|
+
const name = equals < 0 ? word.slice(2) : word.slice(2, equals)
|
|
31
|
+
if (SWITCHES.includes(name) && equals < 0) args.flags[name] = true
|
|
32
|
+
else if (OPTIONS.includes(name)) {
|
|
33
|
+
const value = equals < 0 ? argv[++index] : word.slice(equals + 1)
|
|
34
|
+
if (value === undefined || value === "") {
|
|
35
|
+
args.error = `--${name} needs a value.`
|
|
36
|
+
return args
|
|
37
|
+
}
|
|
38
|
+
args.flags[name] = value
|
|
39
|
+
} else {
|
|
40
|
+
args.error = `Unknown option: ${word}`
|
|
41
|
+
return args
|
|
42
|
+
}
|
|
43
|
+
} else if (args.command === null && args.positional.length === 0 && COMMANDS.includes(word)) args.command = word
|
|
44
|
+
else args.positional.push(word)
|
|
45
|
+
}
|
|
46
|
+
return args
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function help(p) {
|
|
50
|
+
const usage = [
|
|
51
|
+
["", "check this repository (reads the origin remote)"],
|
|
52
|
+
[" <owner/repo>", "check a public GitHub repository"],
|
|
53
|
+
[" check", "the same for CI: exit 1 when a finding fails"],
|
|
54
|
+
[" calendar", "what ends in the next 30 days"],
|
|
55
|
+
[" changed <package>", "breaking changes and deprecations of a package"],
|
|
56
|
+
[" init", "set up your coding agent to check before it writes"],
|
|
57
|
+
[" login", "sign in, for private repositories"],
|
|
58
|
+
[" logout", "remove the stored key"],
|
|
59
|
+
]
|
|
60
|
+
const options = [
|
|
61
|
+
["--all", "show every finding"],
|
|
62
|
+
["--json", "print the answer as JSON"],
|
|
63
|
+
["--local", "check the manifests in this folder, not GitHub"],
|
|
64
|
+
["--fail-on <level>", "check: low, medium, high (default), critical or none"],
|
|
65
|
+
["--within <days>", "only dates within this many days, as 30d"],
|
|
66
|
+
["--provider <name>", "calendar: one provider, as openai"],
|
|
67
|
+
["--ecosystem <name>", "changed: the registry, as npm (default) or pypi"],
|
|
68
|
+
["--since <version>", "changed: only what changed after this version"],
|
|
69
|
+
["--email <address>", "login: the email to sign in with"],
|
|
70
|
+
["--dry-run", "init: show what would be written"],
|
|
71
|
+
["--max-files <n>", "how many manifests to check (default 10)"],
|
|
72
|
+
["--no-color", "plain text"],
|
|
73
|
+
]
|
|
74
|
+
const width = Math.max(...usage.map(([words]) => p.length + words.length)) + 3
|
|
75
|
+
const lines = [`since-dev ${VERSION}: what in this repository is about to stop working`, "", "Usage"]
|
|
76
|
+
for (const [words, what] of usage) lines.push(` ${(p + words).padEnd(width)}${what}`)
|
|
77
|
+
lines.push("", "Options")
|
|
78
|
+
for (const [words, what] of options) lines.push(` ${words.padEnd(21)}${what}`)
|
|
79
|
+
lines.push("", `Docs ${SITE}/docs#cli`, "")
|
|
80
|
+
return lines.join("\n")
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
const isObject = (value) => value !== null && typeof value === "object" && !Array.isArray(value)
|
|
84
|
+
const clamp = (value, low, high) => Math.min(high, Math.max(low, value))
|
|
85
|
+
const wholeNumber = (value) => (typeof value === "number" && Number.isFinite(value) ? Math.trunc(value) : null)
|
|
86
|
+
|
|
87
|
+
function readJson(content) {
|
|
88
|
+
if (content === null) return null
|
|
89
|
+
try {
|
|
90
|
+
const data = JSON.parse(content)
|
|
91
|
+
return isObject(data) ? data : null
|
|
92
|
+
} catch {
|
|
93
|
+
return null
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function credentialsPath(io) {
|
|
98
|
+
if (io.platform === "win32" && io.env.APPDATA) return `${io.env.APPDATA}\\since\\credentials.json`
|
|
99
|
+
return `${io.env.XDG_CONFIG_HOME || `${io.home}/.config`}/since/credentials.json`
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** 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. */
|
|
103
|
+
function readKey(io, api) {
|
|
104
|
+
const fromEnv = String(io.env.SINCE_DEV_KEY ?? "").trim()
|
|
105
|
+
if (fromEnv) return { value: fromEnv, source: "env" }
|
|
106
|
+
const stored = readJson(io.readFile(credentialsPath(io)))
|
|
107
|
+
if (stored && typeof stored.api_key === "string" && stored.api_key && (stored.api || DEFAULT_API) === api) {
|
|
108
|
+
return { value: stored.api_key, source: "file" }
|
|
109
|
+
}
|
|
110
|
+
return null
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function encodeQuery(query) {
|
|
114
|
+
return Object.entries(query)
|
|
115
|
+
.filter(([, value]) => value !== undefined && value !== null && value !== "")
|
|
116
|
+
.map(([name, value]) => `${name}=${encodeURIComponent(String(value))}`)
|
|
117
|
+
.join("&")
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/** One request. status 0 means Since could not be reached; json is the answer when it is a JSON object. */
|
|
121
|
+
async function call(ctx, method, path, { query, body, key } = {}) {
|
|
122
|
+
const search = query ? encodeQuery(query) : ""
|
|
123
|
+
const headers = { "User-Agent": `since-cli/${VERSION} (${ctx.io.runtime})`, Accept: "application/json" }
|
|
124
|
+
if (body !== undefined) headers["Content-Type"] = "application/json"
|
|
125
|
+
if (key) headers.Authorization = `Bearer ${key.value}`
|
|
126
|
+
let answer
|
|
127
|
+
try {
|
|
128
|
+
answer = await ctx.io.fetch({
|
|
129
|
+
method,
|
|
130
|
+
url: `${ctx.api}${path}${search ? `?${search}` : ""}`,
|
|
131
|
+
headers,
|
|
132
|
+
body: body === undefined ? null : JSON.stringify(body),
|
|
133
|
+
})
|
|
134
|
+
} catch {
|
|
135
|
+
return { status: 0, json: null, retryAfter: null }
|
|
136
|
+
}
|
|
137
|
+
const retryAfter = Number.parseInt(String(answer.retryAfter ?? ""), 10)
|
|
138
|
+
return { status: answer.status, json: readJson(answer.text ?? null), retryAfter: Number.isFinite(retryAfter) ? retryAfter : null }
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
const errorOf = (answer) => (answer.json && typeof answer.json.error === "string" ? answer.json.error : null)
|
|
142
|
+
|
|
143
|
+
/** Say why a request did not answer, and return the exit code that goes with it. */
|
|
144
|
+
function refuse(ctx, answer) {
|
|
145
|
+
const { io, p, check } = ctx
|
|
146
|
+
const message = answer.json && typeof answer.json.message === "string" ? answer.json.message : null
|
|
147
|
+
if (answer.status === 0 || answer.status >= 500) {
|
|
148
|
+
const what = errorOf(answer) === "busy" ? "Since is still busy." : "Since could not be reached."
|
|
149
|
+
io.err(`${what} Try again in a minute.${check ? " Nothing was checked, so this run passes." : ""}\n`)
|
|
150
|
+
return check ? 0 : 1
|
|
151
|
+
}
|
|
152
|
+
if (answer.status === 401) {
|
|
153
|
+
io.err(
|
|
154
|
+
ctx.key && ctx.key.source === "env"
|
|
155
|
+
? `Since refused the key in SINCE_DEV_KEY. Create a new one at ${APP}/api-keys\n`
|
|
156
|
+
: `Since refused the stored key. Sign in again: ${p} login\n`,
|
|
157
|
+
)
|
|
158
|
+
} else if (answer.status === 429) {
|
|
159
|
+
const seconds = wholeNumber(answer.json ? answer.json.retryAfterSec : null) ?? answer.retryAfter
|
|
160
|
+
// 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.
|
|
161
|
+
io.err(`${message ?? "Too many requests."}${seconds ? ` Try again in ${text.wait(seconds)}.` : ""}${check ? " Nothing was checked, so this run passes." : ""}\n`)
|
|
162
|
+
return check ? 0 : 1
|
|
163
|
+
} else io.err(`${message ?? `Since answered ${answer.status}.`}\n`)
|
|
164
|
+
return check ? 2 : 1
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/** The free scan of a public GitHub repository. A busy answer is waited out up to three times. */
|
|
168
|
+
async function publicScan(ctx, fullName) {
|
|
169
|
+
for (let waits = 0; ; waits++) {
|
|
170
|
+
const answer = await call(ctx, "POST", "/v1/scan", { body: { repo: fullName } })
|
|
171
|
+
if (answer.status === 200 && answer.json && isObject(answer.json.repository)) return { result: answer.json }
|
|
172
|
+
if (answer.status === 503 && errorOf(answer) === "busy" && waits < 3) {
|
|
173
|
+
const seconds = clamp(answer.retryAfter ?? 20, 1, 60)
|
|
174
|
+
ctx.io.err(`Since is busy. Trying again in ${text.wait(seconds)}.\n`)
|
|
175
|
+
await ctx.io.sleep(seconds * 1000)
|
|
176
|
+
continue
|
|
177
|
+
}
|
|
178
|
+
return { answer }
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/** One keyed check, whole: waits while a connected repository is still being read, then follows every page (pages are free). */
|
|
183
|
+
async function checkAll(ctx, body) {
|
|
184
|
+
const started = ctx.io.now()
|
|
185
|
+
let answer = await call(ctx, "POST", "/v1/check", { body, key: ctx.key })
|
|
186
|
+
while (answer.status === 200 && answer.json && answer.json.scanning && Array.isArray(answer.json.findings) && !answer.json.findings.length) {
|
|
187
|
+
const seconds = clamp(wholeNumber(answer.json.scanning.retryAfterSec) ?? 10, 1, 30)
|
|
188
|
+
if (ctx.io.now() - started + seconds * 1000 > MAX_WAIT_MS) break
|
|
189
|
+
ctx.io.err(`Since is still reading this repository. Trying again in ${text.wait(seconds)}.\n`)
|
|
190
|
+
await ctx.io.sleep(seconds * 1000)
|
|
191
|
+
answer = await call(ctx, "POST", "/v1/check", { body, key: ctx.key })
|
|
192
|
+
}
|
|
193
|
+
if (answer.status !== 200 || !answer.json || !Array.isArray(answer.json.findings)) return answer
|
|
194
|
+
const whole = answer.json
|
|
195
|
+
for (let page = 1; page < MAX_PAGES && typeof whole.nextCursor === "string" && whole.nextCursor; page++) {
|
|
196
|
+
const next = await call(ctx, "POST", "/v1/check", { body: { ...body, cursor: whole.nextCursor }, key: ctx.key })
|
|
197
|
+
if (next.status !== 200 || !next.json || !Array.isArray(next.json.findings)) break
|
|
198
|
+
whole.findings = whole.findings.concat(next.json.findings)
|
|
199
|
+
whole.nextCursor = next.json.nextCursor ?? null
|
|
200
|
+
}
|
|
201
|
+
if (whole.nextCursor) whole.truncated = true
|
|
202
|
+
return answer
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/** A repository connected to the caller's workspace, checked where Since already has its code. */
|
|
206
|
+
async function connectedCheck(ctx, fullName) {
|
|
207
|
+
const repos = await call(ctx, "GET", "/v1/repos", { key: ctx.key })
|
|
208
|
+
if (repos.status !== 200 || !repos.json) return { exit: refuse(ctx, repos) }
|
|
209
|
+
const list = Array.isArray(repos.json.repos) ? repos.json.repos : []
|
|
210
|
+
const repo = list.find((entry) => String(entry.fullName).toLowerCase() === fullName.toLowerCase())
|
|
211
|
+
if (!repo) return {}
|
|
212
|
+
const answer = await checkAll(ctx, { repo: repo.id, detail: "summary" })
|
|
213
|
+
if (answer.status === 403 && errorOf(answer) === "impact_api_not_in_plan") return {}
|
|
214
|
+
if (answer.status !== 200 || !answer.json) return { exit: refuse(ctx, answer) }
|
|
215
|
+
return { answer: answer.json, repo }
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/** The manifests in this folder, sent one by one as the GitHub Action sends them. */
|
|
219
|
+
async function localCheck(ctx, noOrigin) {
|
|
220
|
+
const { io, p, check } = ctx
|
|
221
|
+
if (!ctx.key) {
|
|
222
|
+
io.err(
|
|
223
|
+
noOrigin
|
|
224
|
+
? `This folder has no GitHub origin. Name a public repository, as ${p} owner/repo, or sign in to check the files here: ${p} login\n`
|
|
225
|
+
: `Checking the files in this folder needs a key. Sign in first: ${p} login\n`,
|
|
226
|
+
)
|
|
227
|
+
return check ? 2 : 1
|
|
228
|
+
}
|
|
229
|
+
const found = findManifests(io)
|
|
230
|
+
if (!found.length) {
|
|
231
|
+
io.err("No manifests in this folder (package.json, pyproject.toml, go.mod and the like).\n")
|
|
232
|
+
return check ? 2 : 1
|
|
233
|
+
}
|
|
234
|
+
const files = []
|
|
235
|
+
const notes = []
|
|
236
|
+
let last = null
|
|
237
|
+
if (found.length > ctx.maxFiles) {
|
|
238
|
+
notes.push(`${text.plural(found.length, "manifest")} found, the first ${ctx.maxFiles} checked (each one costs credits). Raise it with --max-files.`)
|
|
239
|
+
}
|
|
240
|
+
for (const file of found.slice(0, ctx.maxFiles)) {
|
|
241
|
+
const content = io.readFile(`${io.cwd}/${file}`)
|
|
242
|
+
if (content === null) notes.push(`${file}: could not be read.`)
|
|
243
|
+
else if (content.length > MAX_CHARACTERS) notes.push(`${file}: over ${text.num(MAX_CHARACTERS)} characters, not sent.`)
|
|
244
|
+
else {
|
|
245
|
+
const answer = await checkAll(ctx, checkBody(file, content))
|
|
246
|
+
if (answer.status === 200 && answer.json && Array.isArray(answer.json.findings)) files.push({ path: file, answer: answer.json })
|
|
247
|
+
else if (answer.status === 401 || answer.status === 402) return refuse(ctx, answer)
|
|
248
|
+
else {
|
|
249
|
+
last = answer
|
|
250
|
+
const message = answer.json && typeof answer.json.message === "string" ? answer.json.message : null
|
|
251
|
+
notes.push(`${file}: ${answer.status === 0 || answer.status >= 500 ? "Since could not be reached." : message ?? `Since answered ${answer.status}.`}`)
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
if (!files.length && last) return refuse(ctx, last)
|
|
256
|
+
return report(ctx, { kind: "local", files, notes })
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
/** Print what was found and return the exit code. */
|
|
260
|
+
function report(ctx, data) {
|
|
261
|
+
const { io, args, p, c, check } = ctx
|
|
262
|
+
const all = Boolean(args.flags.all)
|
|
263
|
+
let items
|
|
264
|
+
if (data.kind === "scan") items = text.scanItems(Array.isArray(data.result.findings) ? data.result.findings : [])
|
|
265
|
+
else if (data.kind === "repo") items = text.checkItems(data.answer.findings, null, p)
|
|
266
|
+
else items = data.files.flatMap((file) => text.checkItems(file.answer.findings, file.path, p))
|
|
267
|
+
if (ctx.within !== null) items = items.filter((item) => item.daysLeft !== null && item.daysLeft <= ctx.within)
|
|
268
|
+
items = text.order(items)
|
|
269
|
+
const failing = check ? items.filter((item) => text.fails(item, ctx.failOn)) : []
|
|
270
|
+
const exit = failing.length ? 1 : 0
|
|
271
|
+
if (args.flags.json) {
|
|
272
|
+
const whole = data.kind === "scan" ? data.result : data.kind === "repo" ? data.answer : { files: data.files }
|
|
273
|
+
io.out(`${JSON.stringify(whole, null, 2)}\n`)
|
|
274
|
+
return exit
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
const lines = [""]
|
|
278
|
+
const notes = []
|
|
279
|
+
const footer = []
|
|
280
|
+
const counts = text.countsLine(items, ctx.within)
|
|
281
|
+
const more = `${p}${check ? " check" : ""}${args.positional.length ? ` ${args.positional[0]}` : ""} --all`
|
|
282
|
+
if (data.kind === "scan") {
|
|
283
|
+
const result = data.result
|
|
284
|
+
const repository = result.repository
|
|
285
|
+
const summary = isObject(result.summary) ? result.summary : {}
|
|
286
|
+
const sha = String(repository.headSha ?? "").slice(0, 7)
|
|
287
|
+
const branch = String(repository.defaultBranch ?? "")
|
|
288
|
+
const parsed = Date.parse(String(result.scannedAt ?? ""))
|
|
289
|
+
const scanned = text.ago(Number.isFinite(parsed) ? io.now() - parsed : null)
|
|
290
|
+
const head = [c.bold(String(repository.fullName)), sha ? `${branch}@${sha}` : branch, scanned ? `scanned ${scanned}` : ""]
|
|
291
|
+
lines.push(` ${head.filter(Boolean).join(" ")}`)
|
|
292
|
+
const dependencies = wholeNumber(summary.dependencies) ?? 0
|
|
293
|
+
const files = wholeNumber(summary.files) ?? 0
|
|
294
|
+
lines.push(` ${counts} ${text.num(dependencies)} ${dependencies === 1 ? "dependency" : "dependencies"} and ${text.plural(files, "file")} read`)
|
|
295
|
+
if (data.fromTree) lines.push(c.dim(` Read from GitHub (${branch}), not your working tree.`))
|
|
296
|
+
const coverage = isObject(result.coverage) && Array.isArray(result.coverage.notes) ? result.coverage.notes : []
|
|
297
|
+
for (const note of coverage) notes.push(String(note))
|
|
298
|
+
footer.push(` ${"Full report".padEnd(14)}${SITE}/scan/${repository.fullName}?utm_source=cli`)
|
|
299
|
+
} else if (data.kind === "repo") {
|
|
300
|
+
lines.push(` ${[c.bold(String(data.repo.fullName)), String(data.repo.defaultBranch ?? ""), "connected repository"].filter(Boolean).join(" ")}`)
|
|
301
|
+
lines.push(` ${counts} ${text.num(wholeNumber(data.answer.checkedCount) ?? 0)} checked`)
|
|
302
|
+
for (const note of text.answerNotes([data.answer])) notes.push(note)
|
|
303
|
+
} else {
|
|
304
|
+
const checked = data.files.reduce((sum, file) => sum + (wholeNumber(file.answer.checkedCount) ?? 0), 0)
|
|
305
|
+
lines.push(` ${c.bold("This folder")} ${text.plural(data.files.length, "manifest")} checked`)
|
|
306
|
+
lines.push(` ${counts} ${text.num(checked)} checked`)
|
|
307
|
+
for (const note of data.notes) notes.push(note)
|
|
308
|
+
for (const note of text.answerNotes(data.files.map((file) => file.answer))) notes.push(note)
|
|
309
|
+
notes.push(`Only manifests were checked. Connect the repository to check its code too: ${APP}?utm_source=cli`)
|
|
310
|
+
}
|
|
311
|
+
lines.push("")
|
|
312
|
+
for (const line of text.findingLines(items, all, c, more)) lines.push(line)
|
|
313
|
+
for (const note of notes) lines.push(` ${note}`)
|
|
314
|
+
if (notes.length) lines.push("")
|
|
315
|
+
for (const line of footer) lines.push(line)
|
|
316
|
+
if (!check) lines.push(` ${"Next".padEnd(14)}${p} init your coding agent checks before it writes`)
|
|
317
|
+
else if (ctx.failOn !== "none") {
|
|
318
|
+
const level = ctx.failOn === "critical" ? "at critical" : `at ${ctx.failOn} or above`
|
|
319
|
+
lines.push(failing.length ? ` ${"Failed".padEnd(14)}${text.plural(failing.length, "finding")} ${level}` : ` ${"Passed".padEnd(14)}nothing ${level}`)
|
|
320
|
+
}
|
|
321
|
+
lines.push("")
|
|
322
|
+
io.out(lines.join("\n"))
|
|
323
|
+
return exit
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
/** `since`, `since <owner/repo>` and `since check`: find the repository, then the cheapest way to read it. */
|
|
327
|
+
async function repoCommand(ctx) {
|
|
328
|
+
const { io, args, p, check } = ctx
|
|
329
|
+
const given = args.positional.length ? args.positional[0] : null
|
|
330
|
+
const fromTree = given === null
|
|
331
|
+
const repo = parseRepo(fromTree ? io.origin() : given)
|
|
332
|
+
const stop = check ? 2 : 1
|
|
333
|
+
if (!fromTree && !repo) {
|
|
334
|
+
io.err(`${given} is not a repository. Use owner/repo or a GitHub URL.\n`)
|
|
335
|
+
return 2
|
|
336
|
+
}
|
|
337
|
+
if (!fromTree && args.flags.local) {
|
|
338
|
+
io.err("--local checks the folder you are in, so it takes no repository name.\n")
|
|
339
|
+
return 2
|
|
340
|
+
}
|
|
341
|
+
// In CI with a key, and whenever --local asks: the checkout itself, not the default branch on GitHub.
|
|
342
|
+
if (fromTree && (args.flags.local || (check && ctx.key))) return localCheck(ctx, false)
|
|
343
|
+
if (!repo || repo.host !== "github.com") {
|
|
344
|
+
if (fromTree) return localCheck(ctx, true)
|
|
345
|
+
io.err(`${given} is not on GitHub. Run ${p} inside its folder to check its manifests.\n`)
|
|
346
|
+
return stop
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
const fullName = `${repo.owner}/${repo.name}`
|
|
350
|
+
// A first scan takes up to a minute, and a silent terminal reads as a hung one.
|
|
351
|
+
io.err(`Reading ${fullName} on GitHub. A first scan can take a minute.\n`)
|
|
352
|
+
const scan = await publicScan(ctx, fullName)
|
|
353
|
+
if (scan.result) return report(ctx, { kind: "scan", result: scan.result, fromTree })
|
|
354
|
+
// 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
|
|
355
|
+
// for the folder you are in, so say why and take it. Without one, or when Since itself is down, this is the end.
|
|
356
|
+
const refusal = errorOf(scan.answer)
|
|
357
|
+
const down = scan.answer.status === 0 || scan.answer.status >= 500
|
|
358
|
+
if (refusal !== "not_found") {
|
|
359
|
+
if (down || !ctx.key || !fromTree) return refuse(ctx, scan.answer)
|
|
360
|
+
const why = scan.answer.json && typeof scan.answer.json.message === "string" ? `${scan.answer.json.message} ` : ""
|
|
361
|
+
io.err(`${why}Checking with your key instead.\n`)
|
|
362
|
+
}
|
|
363
|
+
if (!ctx.key) {
|
|
364
|
+
io.err(
|
|
365
|
+
fromTree
|
|
366
|
+
? `${fullName} is not a public GitHub repository. For a private one, sign in and run this again: ${p} login\n`
|
|
367
|
+
: `GitHub serves no public repository named ${fullName}. For a private one, sign in first: ${p} login\n`,
|
|
368
|
+
)
|
|
369
|
+
return stop
|
|
370
|
+
}
|
|
371
|
+
const connected = await connectedCheck(ctx, fullName)
|
|
372
|
+
if (connected.answer) return report(ctx, { kind: "repo", answer: connected.answer, repo: connected.repo })
|
|
373
|
+
if (connected.exit !== undefined) return connected.exit
|
|
374
|
+
if (fromTree) return localCheck(ctx, false)
|
|
375
|
+
io.err(`${fullName} is private and Since cannot read it from here. Run ${p} inside its folder.\n`)
|
|
376
|
+
return stop
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
async function calendarCommand(ctx) {
|
|
380
|
+
const { io, args, p, c } = ctx
|
|
381
|
+
const within = ctx.within ?? 30
|
|
382
|
+
const provider = args.flags.provider ?? null
|
|
383
|
+
const answer = await call(ctx, "GET", "/v1/calendar", { query: { within: `${within}d`, provider, limit: args.flags.all ? 500 : 15 } })
|
|
384
|
+
if (answer.status !== 200 || !answer.json || !Array.isArray(answer.json.entries)) return refuse(ctx, answer)
|
|
385
|
+
if (args.flags.json) {
|
|
386
|
+
io.out(`${JSON.stringify(answer.json, null, 2)}\n`)
|
|
387
|
+
return 0
|
|
388
|
+
}
|
|
389
|
+
const entries = answer.json.entries
|
|
390
|
+
const lines = ["", ` ${c.bold(`Ending in the next ${text.plural(within, "day")}`)}${provider ? ` ${provider}` : ""}`, ""]
|
|
391
|
+
if (!entries.length) lines.push(" Nothing on the calendar in that time.")
|
|
392
|
+
for (const line of text.calendarLines(entries, SITE, c)) lines.push(line)
|
|
393
|
+
lines.push("")
|
|
394
|
+
if (answer.json.nextCursor) lines.push(` ${"More".padEnd(14)}${p} calendar${provider ? ` --provider ${provider}` : ""} --all`)
|
|
395
|
+
lines.push(` ${"Calendar".padEnd(14)}${SITE}/calendar?utm_source=cli`, "")
|
|
396
|
+
io.out(lines.join("\n"))
|
|
397
|
+
return 0
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
async function changedCommand(ctx) {
|
|
401
|
+
const { io, args, p, c } = ctx
|
|
402
|
+
const name = args.positional[0]
|
|
403
|
+
if (!ctx.key) {
|
|
404
|
+
io.err(`${p} changed needs a key. Sign in first: ${p} login\n`)
|
|
405
|
+
return 1
|
|
406
|
+
}
|
|
407
|
+
const ecosystem = args.flags.ecosystem ?? "npm"
|
|
408
|
+
const since = args.flags.since ?? null
|
|
409
|
+
const answer = await call(ctx, "GET", "/v1/changes", { query: { package: name, ecosystem, since, limit: args.flags.all ? 200 : 20 }, key: ctx.key })
|
|
410
|
+
if (answer.status !== 200 || !answer.json || !Array.isArray(answer.json.changes)) return refuse(ctx, answer)
|
|
411
|
+
if (args.flags.json) {
|
|
412
|
+
io.out(`${JSON.stringify(answer.json, null, 2)}\n`)
|
|
413
|
+
return 0
|
|
414
|
+
}
|
|
415
|
+
const changes = text.uniqueChanges(answer.json.changes)
|
|
416
|
+
const found = isObject(answer.json.package) ? answer.json.package : {}
|
|
417
|
+
const head = [c.bold(String(found.name ?? name)), String(found.ecosystem ?? ecosystem), found.latest ? `latest ${found.latest}` : ""]
|
|
418
|
+
const lines = ["", ` ${head.filter(Boolean).join(" ")}`]
|
|
419
|
+
lines.push(` ${changes.length ? text.plural(changes.length, "change") : "No changes recorded"}${since ? ` since ${since}` : ""}${answer.json.nextCursor ? ", and more" : ""}`, "")
|
|
420
|
+
for (const line of text.changeLines(changes, c)) lines.push(line)
|
|
421
|
+
if (changes.length) lines.push("")
|
|
422
|
+
const notes = []
|
|
423
|
+
if (answer.json.reading) notes.push("Since is reading newer releases of this package now. Run this again in a few minutes to include them.")
|
|
424
|
+
for (const note of notes) lines.push(` ${note}`)
|
|
425
|
+
if (notes.length) lines.push("")
|
|
426
|
+
if (answer.json.nextCursor) lines.push(` ${"More".padEnd(14)}${p} changed ${name}${since ? ` --since ${since}` : ""}${ecosystem !== "npm" ? ` --ecosystem ${ecosystem}` : ""} --all`, "")
|
|
427
|
+
io.out(lines.join("\n"))
|
|
428
|
+
return 0
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
/** Sign in without a password: Since emails nothing here, the person approves the link in a browser and the key comes back. */
|
|
432
|
+
async function loginCommand(ctx) {
|
|
433
|
+
const { io, args, p } = ctx
|
|
434
|
+
let email = String(args.flags.email ?? "").trim()
|
|
435
|
+
if (!email && io.tty) email = String(await io.prompt("Email: ")).trim()
|
|
436
|
+
if (!email) {
|
|
437
|
+
io.err(`Give the email to sign in with: ${p} login --email you@example.com\n`)
|
|
438
|
+
return 2
|
|
439
|
+
}
|
|
440
|
+
const start = await call(ctx, "POST", "/v1/agent/register", { body: { email, client_name: "since-cli" } })
|
|
441
|
+
const setup = start.json && isObject(start.json.setup) ? start.json.setup : null
|
|
442
|
+
if (start.status !== 200 || !setup || typeof start.json.claim_token !== "string") return refuse(ctx, start)
|
|
443
|
+
const link = String(setup.verification_uri_complete ?? setup.verification_uri)
|
|
444
|
+
// The approval page shows an eight-character code in two halves: print it the way the person will see it there.
|
|
445
|
+
const code = String(setup.user_code ?? "")
|
|
446
|
+
io.out(`\n Open this link to approve the sign-in:\n ${link}\n Code: ${code.length === 8 ? `${code.slice(0, 4)}-${code.slice(4)}` : code}\n\n`)
|
|
447
|
+
if (io.tty) io.open(link)
|
|
448
|
+
let interval = clamp(wholeNumber(setup.interval) ?? 5, 1, 60)
|
|
449
|
+
const deadline = io.now() + clamp(wholeNumber(setup.expires_in) ?? 900, 30, 3600) * 1000
|
|
450
|
+
while (io.now() + interval * 1000 <= deadline) {
|
|
451
|
+
await io.sleep(interval * 1000)
|
|
452
|
+
const answer = await call(ctx, "POST", "/v1/agent/token", { body: { claim_token: start.json.claim_token } })
|
|
453
|
+
if (answer.status === 200 && answer.json && typeof answer.json.api_key === "string") {
|
|
454
|
+
const file = credentialsPath(io)
|
|
455
|
+
const stored = { api: ctx.api, api_key: answer.json.api_key, key_name: answer.json.key_name ?? null, key_prefix: answer.json.key_prefix ?? null }
|
|
456
|
+
io.writeFile(file, `${JSON.stringify(stored, null, 2)}\n`, 0o600)
|
|
457
|
+
io.out(` Signed in. The key is stored in ${file}\n\n`)
|
|
458
|
+
return 0
|
|
459
|
+
}
|
|
460
|
+
const code = errorOf(answer)
|
|
461
|
+
if (code === "slow_down") interval += 5
|
|
462
|
+
else if (code === "expired_token") break
|
|
463
|
+
else if (code === "access_denied") {
|
|
464
|
+
io.err("The sign-in was declined.\n")
|
|
465
|
+
return 1
|
|
466
|
+
} else if (code !== "authorization_pending" && answer.status !== 0 && answer.status < 500) return refuse(ctx, answer)
|
|
467
|
+
}
|
|
468
|
+
io.err(`The link expired before it was approved. Run ${p} login again.\n`)
|
|
469
|
+
return 1
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
function logoutCommand(ctx) {
|
|
473
|
+
const { io } = ctx
|
|
474
|
+
const removed = io.remove(credentialsPath(io))
|
|
475
|
+
io.out(removed ? `Signed out. The key is gone from this computer; delete it for good at ${APP}/api-keys\n` : "No stored key.\n")
|
|
476
|
+
return 0
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
export async function run(argv, io) {
|
|
480
|
+
const args = parseArgs(argv)
|
|
481
|
+
const p = io.program
|
|
482
|
+
const usage = (message) => {
|
|
483
|
+
io.err(`${message}\nRun ${p} --help for the commands.\n`)
|
|
484
|
+
return 2
|
|
485
|
+
}
|
|
486
|
+
if (args.error) return usage(args.error)
|
|
487
|
+
if (args.flags.version || args.command === "version") {
|
|
488
|
+
io.out(`since-dev ${VERSION}\n`)
|
|
489
|
+
return 0
|
|
490
|
+
}
|
|
491
|
+
if (args.flags.help || args.command === "help") {
|
|
492
|
+
io.out(help(p))
|
|
493
|
+
return 0
|
|
494
|
+
}
|
|
495
|
+
const command = args.command
|
|
496
|
+
const words = args.positional
|
|
497
|
+
if (command === "changed") {
|
|
498
|
+
if (words.length !== 1) return usage(words.length ? `Too many words: ${words[1]}` : `${p} changed needs a package name, as ${p} changed react`)
|
|
499
|
+
} else if (command === null || command === "check") {
|
|
500
|
+
if (words.length > 1) return usage(`Too many words: ${words[1]}`)
|
|
501
|
+
if (command === null && words.length === 1 && !words[0].includes("/")) return usage(`Unknown command: ${words[0]}`)
|
|
502
|
+
} else if (words.length) return usage(`Too many words: ${words[0]}`)
|
|
503
|
+
|
|
504
|
+
const failOn = args.flags["fail-on"] ?? "high"
|
|
505
|
+
if (!FAIL_ON.includes(failOn)) return usage("--fail-on takes low, medium, high, critical or none.")
|
|
506
|
+
let within = null
|
|
507
|
+
if (args.flags.within !== undefined) {
|
|
508
|
+
if (!/^\d{1,4}d?$/.test(args.flags.within) || Number.parseInt(args.flags.within, 10) < 1) return usage("--within takes a number of days, as 30d.")
|
|
509
|
+
within = Number.parseInt(args.flags.within, 10)
|
|
510
|
+
}
|
|
511
|
+
let maxFiles = 10
|
|
512
|
+
if (args.flags["max-files"] !== undefined) {
|
|
513
|
+
if (!/^\d{1,3}$/.test(args.flags["max-files"]) || Number.parseInt(args.flags["max-files"], 10) < 1) return usage("--max-files takes a number from 1 to 999.")
|
|
514
|
+
maxFiles = Number.parseInt(args.flags["max-files"], 10)
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
const api = String(io.env.SINCE_API_URL || DEFAULT_API).replace(/\/+$/, "")
|
|
518
|
+
const color = Boolean(io.tty) && !args.flags["no-color"] && !io.env.NO_COLOR && io.env.TERM !== "dumb"
|
|
519
|
+
const ctx = { io, args, p, api, c: text.palette(color), check: command === "check", key: readKey(io, api), failOn, within, maxFiles }
|
|
520
|
+
if (command === "calendar") return calendarCommand(ctx)
|
|
521
|
+
if (command === "changed") return changedCommand(ctx)
|
|
522
|
+
if (command === "init") return init(io, p, Boolean(args.flags["dry-run"]))
|
|
523
|
+
if (command === "login") return loginCommand(ctx)
|
|
524
|
+
if (command === "logout") return logoutCommand(ctx)
|
|
525
|
+
return repoCommand(ctx)
|
|
526
|
+
}
|
package/src/init.mjs
ADDED
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
// `since init`: the files that put Since in front of a coding agent, written into the repository so a clone carries them.
|
|
2
|
+
// It adds its own entry and nothing else: other servers stay as they are, and a file it cannot read as JSON is left alone.
|
|
3
|
+
|
|
4
|
+
export const MCP_URL = "https://api.since.dev/mcp"
|
|
5
|
+
export const TARGETS = [
|
|
6
|
+
{ file: ".mcp.json", key: "mcpServers", entry: { type: "http", url: MCP_URL }, what: "Claude Code" },
|
|
7
|
+
{ file: ".cursor/mcp.json", key: "mcpServers", entry: { url: MCP_URL }, what: "Cursor" },
|
|
8
|
+
{ file: ".vscode/mcp.json", key: "servers", entry: { type: "http", url: MCP_URL }, what: "VS Code" },
|
|
9
|
+
]
|
|
10
|
+
export const RULE_FILE = "AGENTS.md"
|
|
11
|
+
export const START = "<!-- since:start -->"
|
|
12
|
+
export const END = "<!-- since:end -->"
|
|
13
|
+
// The rule every listing ships (distribution/cursor/rules/since.mdc); a test holds the two to the same words.
|
|
14
|
+
export const RULE = [
|
|
15
|
+
"When you change a dependency version, a manifest or lockfile, an AI model ID, a runtime version, a Docker base image or a CI runner label, check it with the Since.dev MCP tools first:",
|
|
16
|
+
"",
|
|
17
|
+
"- `check_code` with the manifest, lockfile, model ID or `surface` (`runtime:<product>@<version>`) reports what is affected, on which line, the days left and the replacement.",
|
|
18
|
+
"- `whats_changed` with the package and `since` set to the current version lists every breaking change and deprecation between that version and today, each with its source.",
|
|
19
|
+
"",
|
|
20
|
+
"Treat `needs_review` and `unknown` as not settled, never as safe. Use the replacement the answer names. `fix_code` opens a fix pull request for a connected repository, and the editor asks before it runs.",
|
|
21
|
+
].join("\n")
|
|
22
|
+
export const BLOCK = `${START}\n## Since.dev\n\n${RULE}\n${END}`
|
|
23
|
+
|
|
24
|
+
const isObject = (value) => value !== null && typeof value === "object" && !Array.isArray(value)
|
|
25
|
+
|
|
26
|
+
function sameEntry(have, want) {
|
|
27
|
+
if (!isObject(have)) return false
|
|
28
|
+
const keys = Object.keys(want)
|
|
29
|
+
return Object.keys(have).length === keys.length && keys.every((key) => have[key] === want[key])
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** The indent a JSON file already uses: its first indented line decides, two spaces when there is none. */
|
|
33
|
+
function indentOf(text) {
|
|
34
|
+
const match = /^([ \t]+)\S/m.exec(text)
|
|
35
|
+
if (!match) return 2
|
|
36
|
+
return match[1].includes("\t") ? "\t" : match[1].length
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** A config file with the Since entry in it. status: new, updated, same, or skipped when the file is not plain JSON. */
|
|
40
|
+
export function mergeConfig(text, key, entry) {
|
|
41
|
+
if (text === null || text.trim() === "") return { status: "new", text: `${JSON.stringify({ [key]: { since: entry } }, null, 2)}\n` }
|
|
42
|
+
let data
|
|
43
|
+
try {
|
|
44
|
+
data = JSON.parse(text)
|
|
45
|
+
} catch {
|
|
46
|
+
return { status: "skipped", text }
|
|
47
|
+
}
|
|
48
|
+
if (!isObject(data) || (data[key] !== undefined && !isObject(data[key]))) return { status: "skipped", text }
|
|
49
|
+
if (data[key] !== undefined && sameEntry(data[key].since, entry)) return { status: "same", text }
|
|
50
|
+
if (data[key] === undefined) data[key] = {}
|
|
51
|
+
data[key].since = entry
|
|
52
|
+
return { status: "updated", text: `${JSON.stringify(data, null, indentOf(text))}\n` }
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** AGENTS.md with the rule between its two markers: replaced when the markers are there, added at the end when they are not. */
|
|
56
|
+
export function mergeRule(text) {
|
|
57
|
+
if (text === null || text.trim() === "") return { status: "new", text: `${BLOCK}\n` }
|
|
58
|
+
const start = text.indexOf(START)
|
|
59
|
+
const end = start < 0 ? -1 : text.indexOf(END, start)
|
|
60
|
+
if (start < 0 || end < 0) return { status: "updated", text: `${text.replace(/\n+$/, "")}\n\n${BLOCK}\n` }
|
|
61
|
+
const next = text.slice(0, start) + BLOCK + text.slice(end + END.length)
|
|
62
|
+
return next === text ? { status: "same", text } : { status: "updated", text: next }
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const WORDS = {
|
|
66
|
+
new: ["Wrote", "Would write"],
|
|
67
|
+
updated: ["Updated", "Would update"],
|
|
68
|
+
same: ["Unchanged", "Unchanged"],
|
|
69
|
+
skipped: ["Skipped", "Skipped"],
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export function init(io, program, dryRun) {
|
|
73
|
+
const root = io.root()
|
|
74
|
+
const lines = [""]
|
|
75
|
+
const skipped = []
|
|
76
|
+
let changed = 0
|
|
77
|
+
const rows = TARGETS.map((target) => ({ ...target, result: mergeConfig(io.readFile(`${root}/${target.file}`), target.key, target.entry) }))
|
|
78
|
+
rows.push({ file: RULE_FILE, what: "the rule every coding agent reads", result: mergeRule(io.readFile(`${root}/${RULE_FILE}`)) })
|
|
79
|
+
for (const row of rows) {
|
|
80
|
+
const { status, text } = row.result
|
|
81
|
+
if (status === "new" || status === "updated") {
|
|
82
|
+
changed += 1
|
|
83
|
+
if (!dryRun) io.writeFile(`${root}/${row.file}`, text, null)
|
|
84
|
+
}
|
|
85
|
+
if (status === "skipped") skipped.push(row)
|
|
86
|
+
lines.push(` ${WORDS[status][dryRun ? 1 : 0].padEnd(14)}${row.file.padEnd(20)}${row.what}`)
|
|
87
|
+
}
|
|
88
|
+
lines.push("")
|
|
89
|
+
for (const row of skipped) {
|
|
90
|
+
lines.push(` ${row.file} is not plain JSON, so it was left alone. Add this under "${row.key}" by hand:`)
|
|
91
|
+
lines.push(` "since": ${JSON.stringify(row.entry)}`)
|
|
92
|
+
lines.push("")
|
|
93
|
+
}
|
|
94
|
+
if (dryRun) lines.push(changed ? ` Nothing was written. Run ${program} init to write ${changed === 1 ? "it" : "them"}.` : " Everything is already set up.")
|
|
95
|
+
else if (changed) {
|
|
96
|
+
lines.push(" Commit these files: everyone who clones this repository gets the same setup.")
|
|
97
|
+
lines.push(" The first tool call opens a sign-in in the browser. No key goes in any file.")
|
|
98
|
+
} else lines.push(" Everything is already set up.")
|
|
99
|
+
lines.push("")
|
|
100
|
+
io.out(lines.join("\n"))
|
|
101
|
+
return 0
|
|
102
|
+
}
|
package/src/io.mjs
ADDED
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
// The real world behind the CLI: the network, the folder, git, the terminal. The tests swap all of it for a fixture.
|
|
2
|
+
|
|
3
|
+
import { execFileSync, spawn } from "node:child_process"
|
|
4
|
+
import { chmodSync, mkdirSync, readFileSync, readdirSync, rmSync, writeFileSync } from "node:fs"
|
|
5
|
+
import { homedir } from "node:os"
|
|
6
|
+
import path from "node:path"
|
|
7
|
+
import { createInterface } from "node:readline"
|
|
8
|
+
|
|
9
|
+
function git(cwd, args) {
|
|
10
|
+
try {
|
|
11
|
+
return execFileSync("git", args, { cwd, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }).trim() || null
|
|
12
|
+
} catch {
|
|
13
|
+
return null
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function realIo() {
|
|
18
|
+
const cwd = process.cwd()
|
|
19
|
+
return {
|
|
20
|
+
runtime: "node",
|
|
21
|
+
// Run through npx there is no `since` on the PATH, so the hints name the command that works again.
|
|
22
|
+
program: /[\\/]_npx[\\/]/.test(process.argv[1] ?? "") ? "npx since-dev" : "since",
|
|
23
|
+
env: process.env,
|
|
24
|
+
cwd,
|
|
25
|
+
home: homedir(),
|
|
26
|
+
platform: process.platform,
|
|
27
|
+
tty: Boolean(process.stdout.isTTY),
|
|
28
|
+
out: (text) => void process.stdout.write(text),
|
|
29
|
+
err: (text) => void process.stderr.write(text),
|
|
30
|
+
now: () => Date.now(),
|
|
31
|
+
sleep: (ms) => new Promise((resolve) => setTimeout(resolve, ms)),
|
|
32
|
+
async fetch({ method, url, headers, body }) {
|
|
33
|
+
// The API never redirects, so a redirect is never followed: a key goes to the host it was meant for and nowhere else.
|
|
34
|
+
const response = await fetch(url, { method, headers, body: body ?? undefined, redirect: "manual", signal: AbortSignal.timeout(120_000) })
|
|
35
|
+
return { status: response.status, retryAfter: response.headers.get("retry-after"), text: await response.text() }
|
|
36
|
+
},
|
|
37
|
+
origin: () => git(cwd, ["remote", "get-url", "origin"]),
|
|
38
|
+
root: () => git(cwd, ["rev-parse", "--show-toplevel"]) ?? cwd,
|
|
39
|
+
open(url) {
|
|
40
|
+
if (process.env.CI) return
|
|
41
|
+
const [command, args] =
|
|
42
|
+
process.platform === "darwin" ? ["open", [url]] : process.platform === "win32" ? ["rundll32", ["url.dll,FileProtocolHandler", url]] : ["xdg-open", [url]]
|
|
43
|
+
try {
|
|
44
|
+
const child = spawn(command, args, { stdio: "ignore", detached: true })
|
|
45
|
+
child.on("error", () => {})
|
|
46
|
+
child.unref()
|
|
47
|
+
} catch {
|
|
48
|
+
// No browser to open: the link is printed, which is enough.
|
|
49
|
+
}
|
|
50
|
+
},
|
|
51
|
+
prompt(question) {
|
|
52
|
+
const terminal = createInterface({ input: process.stdin, output: process.stderr })
|
|
53
|
+
return new Promise((resolve) => terminal.question(question, (answer) => (terminal.close(), resolve(answer))))
|
|
54
|
+
},
|
|
55
|
+
readFile(file) {
|
|
56
|
+
try {
|
|
57
|
+
return readFileSync(file, "utf8")
|
|
58
|
+
} catch {
|
|
59
|
+
return null
|
|
60
|
+
}
|
|
61
|
+
},
|
|
62
|
+
/** mode is null for a file of the repository, or the permission bits for a private one (its folder is made private too). */
|
|
63
|
+
writeFile(file, text, mode) {
|
|
64
|
+
mkdirSync(path.dirname(file), mode === null ? { recursive: true } : { recursive: true, mode: 0o700 })
|
|
65
|
+
writeFileSync(file, text, mode === null ? {} : { mode })
|
|
66
|
+
if (mode !== null) chmodSync(file, mode)
|
|
67
|
+
},
|
|
68
|
+
remove(file) {
|
|
69
|
+
try {
|
|
70
|
+
rmSync(file)
|
|
71
|
+
return true
|
|
72
|
+
} catch {
|
|
73
|
+
return false
|
|
74
|
+
}
|
|
75
|
+
},
|
|
76
|
+
/** A folder's entries by name; a link is never followed into a folder. */
|
|
77
|
+
list(folder) {
|
|
78
|
+
try {
|
|
79
|
+
return readdirSync(folder, { withFileTypes: true })
|
|
80
|
+
.map((entry) => ({ name: entry.name, dir: entry.isDirectory() }))
|
|
81
|
+
.sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0))
|
|
82
|
+
} catch {
|
|
83
|
+
return []
|
|
84
|
+
}
|
|
85
|
+
},
|
|
86
|
+
}
|
|
87
|
+
}
|
package/src/local.mjs
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
// What the CLI reads from the folder it runs in: which repository this is, and which manifests to send.
|
|
2
|
+
// The manifest rules are the GitHub Action's (distribution/action/index.mjs): the same names, the same folders skipped.
|
|
3
|
+
|
|
4
|
+
export const PATTERNS = [
|
|
5
|
+
"**/package.json",
|
|
6
|
+
"**/requirements*.txt",
|
|
7
|
+
"**/pyproject.toml",
|
|
8
|
+
"**/go.mod",
|
|
9
|
+
"**/Cargo.toml",
|
|
10
|
+
"**/Gemfile",
|
|
11
|
+
"**/composer.json",
|
|
12
|
+
"**/pom.xml",
|
|
13
|
+
"**/build.gradle",
|
|
14
|
+
"**/build.gradle.kts",
|
|
15
|
+
"**/*.csproj",
|
|
16
|
+
]
|
|
17
|
+
const LOCKFILES = new Set([
|
|
18
|
+
"package-lock.json", "npm-shrinkwrap.json", "pnpm-lock.yaml", "yarn.lock", "bun.lock",
|
|
19
|
+
"poetry.lock", "Pipfile.lock", "uv.lock", "Cargo.lock", "Gemfile.lock", "composer.lock",
|
|
20
|
+
"packages.lock.json", "gradle.lockfile",
|
|
21
|
+
])
|
|
22
|
+
const SKIP = new Set([".git", "node_modules", "vendor", "dist", "build", ".venv", "venv", "target", ".next", "__pycache__"])
|
|
23
|
+
export const MAX_CHARACTERS = 500_000
|
|
24
|
+
|
|
25
|
+
/** owner and name from "owner/repo", a GitHub URL or a git remote; null for anything else. A bare "owner/repo" means GitHub. */
|
|
26
|
+
export function parseRepo(text) {
|
|
27
|
+
const value = String(text ?? "").trim()
|
|
28
|
+
if (!value) return null
|
|
29
|
+
const short = /^([A-Za-z0-9_-][A-Za-z0-9_.-]*)\/([A-Za-z0-9_.-]+)$/.exec(value)
|
|
30
|
+
if (short) return { host: "github.com", owner: short[1], name: short[2].replace(/\.git$/, "") }
|
|
31
|
+
const remote =
|
|
32
|
+
/^(?:https?:\/\/|ssh:\/\/|git:\/\/)?(?:[^@\/]+@)?([A-Za-z0-9.-]+\.[A-Za-z]+)(?::\d+)?[:\/]([A-Za-z0-9_.-]+)\/([A-Za-z0-9_.-]+?)(?:\.git)?(?:\/.*)?$/.exec(value)
|
|
33
|
+
if (!remote) return null
|
|
34
|
+
return { host: remote[1].toLowerCase(), owner: remote[2], name: remote[3] }
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function patternRegExp(pattern) {
|
|
38
|
+
const source = pattern
|
|
39
|
+
.replace(/^\.\//, "")
|
|
40
|
+
.split(/(\*\*\/|\*)/)
|
|
41
|
+
.map((part) => (part === "**/" ? "(?:.*/)?" : part === "*" ? "[^/]*" : part.replace(/[.+?^${}()|[\]\\]/g, "\\$&")))
|
|
42
|
+
.join("")
|
|
43
|
+
return new RegExp(`^${source}$`)
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** The manifests under the folder, nearest the top first, then by name. */
|
|
47
|
+
export function findManifests(io) {
|
|
48
|
+
const patterns = PATTERNS.map(patternRegExp)
|
|
49
|
+
const found = []
|
|
50
|
+
const walk = (folder) => {
|
|
51
|
+
for (const entry of io.list(folder ? `${io.cwd}/${folder}` : io.cwd)) {
|
|
52
|
+
const relative = folder ? `${folder}/${entry.name}` : entry.name
|
|
53
|
+
if (entry.dir) {
|
|
54
|
+
if (!SKIP.has(entry.name)) walk(relative)
|
|
55
|
+
} else if (patterns.some((pattern) => pattern.test(relative))) found.push(relative)
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
walk("")
|
|
59
|
+
const depth = (file) => file.split("/").length
|
|
60
|
+
return found.sort((a, b) => depth(a) - depth(b) || (a < b ? -1 : a > b ? 1 : 0))
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export function checkBody(file, content) {
|
|
64
|
+
const name = file.split("/").pop()
|
|
65
|
+
return { [LOCKFILES.has(name) ? "lockfile" : "manifest"]: { path: file, content }, detail: "summary" }
|
|
66
|
+
}
|
package/src/render.mjs
ADDED
|
@@ -0,0 +1,257 @@
|
|
|
1
|
+
// Text only: every function here turns an API answer into lines. No network, no files, no clock.
|
|
2
|
+
// since_dev/render.py is the same code in Python, and the shared fixtures hold both to the same output.
|
|
3
|
+
|
|
4
|
+
export const SEVERITIES = ["low", "medium", "high", "critical"]
|
|
5
|
+
const PAD = " ".repeat(12)
|
|
6
|
+
const SHOWN = 10
|
|
7
|
+
|
|
8
|
+
export function palette(on) {
|
|
9
|
+
const paint = (code) => (text) => (on ? `\x1b[${code}m${text}\x1b[0m` : text)
|
|
10
|
+
return { bold: paint(1), dim: paint(2), red: paint(31), yellow: paint(33), plain: (text) => text }
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export const num = (value) => String(value).replace(/\B(?=(\d{3})+(?!\d))/g, ",")
|
|
14
|
+
export const plural = (count, word) => `${num(count)} ${word}${count === 1 ? "" : "s"}`
|
|
15
|
+
|
|
16
|
+
/** How long to wait, in words: seconds under a minute and a half, then minutes, then hours, rounded up. */
|
|
17
|
+
export function wait(seconds) {
|
|
18
|
+
if (seconds < 90) return plural(Math.max(1, seconds), "second")
|
|
19
|
+
if (seconds < 5400) return plural(Math.ceil(seconds / 60), "minute")
|
|
20
|
+
return plural(Math.ceil(seconds / 3600), "hour")
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** How long ago, from milliseconds; "" when the time is not known. */
|
|
24
|
+
export function ago(ms) {
|
|
25
|
+
if (ms === null || !Number.isFinite(ms)) return ""
|
|
26
|
+
const minutes = Math.floor(ms / 60000)
|
|
27
|
+
if (minutes < 1) return "just now"
|
|
28
|
+
if (minutes < 60) return `${minutes} min ago`
|
|
29
|
+
const hours = Math.floor(minutes / 60)
|
|
30
|
+
if (hours < 48) return `${plural(hours, "hour")} ago`
|
|
31
|
+
return `${plural(Math.floor(hours / 24), "day")} ago`
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const placeOf = (path, line) => (typeof line === "number" && line > 0 ? `${path}:${line}` : String(path))
|
|
35
|
+
|
|
36
|
+
/** A public scan's findings, as the rows the CLI prints. */
|
|
37
|
+
export function scanItems(findings) {
|
|
38
|
+
return findings.map((finding) => {
|
|
39
|
+
const where = Array.isArray(finding.where) ? finding.where.filter((place) => place && place.path) : []
|
|
40
|
+
const source = finding.source && typeof finding.source === "object" ? finding.source.url : null
|
|
41
|
+
return {
|
|
42
|
+
title: String(finding.title ?? ""),
|
|
43
|
+
review: finding.state === "needs_review",
|
|
44
|
+
severity: finding.severity,
|
|
45
|
+
daysLeft: typeof finding.daysLeft === "number" ? finding.daysLeft : null,
|
|
46
|
+
place: where.length ? placeOf(where[0].path, where[0].line) : null,
|
|
47
|
+
extra: Math.max(0, where.length - 1) + (typeof finding.more === "number" ? finding.more : 0),
|
|
48
|
+
replacement: finding.replacement || null,
|
|
49
|
+
source: source || null,
|
|
50
|
+
}
|
|
51
|
+
})
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** Many undated findings about one dependency at one place, as one row: the count, the worst severity, and how to list them. */
|
|
55
|
+
function mergeRows(group, program) {
|
|
56
|
+
const proven = group.members.filter((row) => !row.review)
|
|
57
|
+
const pool = proven.length ? proven : group.members
|
|
58
|
+
let severity = pool[0].severity
|
|
59
|
+
for (const row of pool) if (SEVERITIES.indexOf(row.severity) > SEVERITIES.indexOf(severity)) severity = row.severity
|
|
60
|
+
const tally = [...SEVERITIES].reverse().map((level) => [level, group.members.filter((row) => row.severity === level).length]).filter(([, count]) => count)
|
|
61
|
+
const unrated = group.members.length - tally.reduce((sum, [, count]) => sum + count, 0)
|
|
62
|
+
if (unrated) tally.push(["unrated", unrated])
|
|
63
|
+
const breakdown = tally.length > 1 ? ` (${tally.map(([level, count]) => `${num(count)} ${level}`).join(", ")})` : ""
|
|
64
|
+
const since = group.version ? ` --since ${group.version}` : ""
|
|
65
|
+
const registry = group.ecosystem && group.ecosystem !== "npm" ? ` --ecosystem ${group.ecosystem}` : ""
|
|
66
|
+
return {
|
|
67
|
+
title: `${group.subject}: ${num(group.members.length)} changes between this version and the latest${breakdown}`,
|
|
68
|
+
review: !proven.length,
|
|
69
|
+
severity,
|
|
70
|
+
daysLeft: null,
|
|
71
|
+
place: group.members[0].place,
|
|
72
|
+
extra: 0,
|
|
73
|
+
replacement: null,
|
|
74
|
+
source: null,
|
|
75
|
+
hint: group.isPackage && group.name ? `${program} changed ${group.name}${since}${registry}` : null,
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* A keyed check's findings as the same rows. They carry no title, so one is built from the name, the version and the reason.
|
|
81
|
+
* A manifest check answers with every recorded change between the pinned version and the latest, hundreds for an old pin:
|
|
82
|
+
* the undated ones about one dependency at one place become one row, and `since changed` lists them.
|
|
83
|
+
*/
|
|
84
|
+
export function checkItems(findings, fallbackPath, program) {
|
|
85
|
+
const rows = []
|
|
86
|
+
const groups = new Map()
|
|
87
|
+
for (const finding of findings) {
|
|
88
|
+
const surface = finding.surface && typeof finding.surface === "object" ? finding.surface : {}
|
|
89
|
+
const name = String(surface.name || "")
|
|
90
|
+
const version = finding.version || null
|
|
91
|
+
const subject = [name, version].filter(Boolean).join(" ")
|
|
92
|
+
const reason = String(finding.reason ?? "").trim()
|
|
93
|
+
const where = Array.isArray(finding.where) ? finding.where.filter((place) => place && place.file) : []
|
|
94
|
+
const first = where.length ? placeOf(where[0].file, where[0].line) : fallbackPath
|
|
95
|
+
const source = finding.source && typeof finding.source === "object" ? finding.source.url : finding.source
|
|
96
|
+
const row = {
|
|
97
|
+
title: name && reason.toLowerCase().includes(name.toLowerCase()) ? reason : [subject, reason].filter(Boolean).join(": "),
|
|
98
|
+
review: finding.state === "needs_review",
|
|
99
|
+
severity: finding.severity,
|
|
100
|
+
daysLeft: typeof finding.daysLeft === "number" ? finding.daysLeft : null,
|
|
101
|
+
place: first || null,
|
|
102
|
+
extra: Math.max(0, (typeof finding.placeCount === "number" ? finding.placeCount : where.length) - 1),
|
|
103
|
+
replacement: finding.replacement || null,
|
|
104
|
+
source: typeof source === "string" && source ? source : null,
|
|
105
|
+
hint: null,
|
|
106
|
+
}
|
|
107
|
+
if (row.daysLeft !== null) {
|
|
108
|
+
rows.push(row)
|
|
109
|
+
continue
|
|
110
|
+
}
|
|
111
|
+
const key = `${surface.key || name}|${version || ""}|${row.place || ""}`
|
|
112
|
+
const group = groups.get(key)
|
|
113
|
+
if (group) group.members.push(row)
|
|
114
|
+
else {
|
|
115
|
+
const created = { members: [row], subject, name, version, isPackage: surface.kind === "package", ecosystem: surface.ecosystem || null }
|
|
116
|
+
groups.set(key, created)
|
|
117
|
+
rows.push(created)
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
return rows.map((row) => (row.members ? (row.members.length === 1 ? row.members[0] : mergeRows(row, program)) : row))
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** Dated findings first (the next deadline, then the ones already passed, newest first), then undated, then the ones to review. */
|
|
124
|
+
export function order(items) {
|
|
125
|
+
const group = (item) => (item.review ? 2 : item.daysLeft === null ? 1 : 0)
|
|
126
|
+
const within = (item) => (group(item) !== 0 ? 0 : item.daysLeft >= 0 ? item.daysLeft : 100000 - item.daysLeft)
|
|
127
|
+
return items
|
|
128
|
+
.map((item, index) => ({ item, index }))
|
|
129
|
+
.sort((a, b) => group(a.item) - group(b.item) || within(a.item) - within(b.item) || a.index - b.index)
|
|
130
|
+
.map((entry) => entry.item)
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/** The CI rule, the same as the GitHub Action's: proven, and at or above the severity asked for. */
|
|
134
|
+
export function fails(item, failOn) {
|
|
135
|
+
const threshold = SEVERITIES.indexOf(failOn)
|
|
136
|
+
return threshold >= 0 && !item.review && SEVERITIES.indexOf(item.severity) >= threshold
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
export function dayLabel(daysLeft) {
|
|
140
|
+
if (daysLeft < 0) return "passed"
|
|
141
|
+
if (daysLeft === 0) return "today"
|
|
142
|
+
return daysLeft === 1 ? "1 day" : `${num(daysLeft)} days`
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function label(item) {
|
|
146
|
+
if (item.review) return "review"
|
|
147
|
+
if (item.daysLeft === null) return SEVERITIES.includes(item.severity) ? item.severity : "no date"
|
|
148
|
+
return dayLabel(item.daysLeft)
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function dayTone(daysLeft, c) {
|
|
152
|
+
return daysLeft <= 30 ? c.red : daysLeft <= 90 ? c.yellow : c.plain
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
function tone(item, c) {
|
|
156
|
+
if (item.review) return c.dim
|
|
157
|
+
if (item.daysLeft === null) return item.severity === "critical" || item.severity === "high" ? c.yellow : c.plain
|
|
158
|
+
return dayTone(item.daysLeft, c)
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/** "3 with a date 2 undated 1 to review", without the counts that are zero. */
|
|
162
|
+
export function countsLine(items, within) {
|
|
163
|
+
const dated = items.filter((item) => !item.review && item.daysLeft !== null).length
|
|
164
|
+
const undated = items.filter((item) => !item.review && item.daysLeft === null).length
|
|
165
|
+
const review = items.filter((item) => item.review).length
|
|
166
|
+
const parts = []
|
|
167
|
+
if (dated) parts.push(`${num(dated)} with a date`)
|
|
168
|
+
if (undated) parts.push(`${num(undated)} undated`)
|
|
169
|
+
if (review) parts.push(`${num(review)} to review`)
|
|
170
|
+
if (parts.length) return parts.join(" ")
|
|
171
|
+
return within === null ? "No findings" : `Nothing dated within ${plural(within, "day")}`
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/** The findings, three lines each: when and what, where and the replacement, the source. */
|
|
175
|
+
export function findingLines(items, all, c, moreHint) {
|
|
176
|
+
const lines = []
|
|
177
|
+
const shown = all ? items : items.slice(0, SHOWN)
|
|
178
|
+
for (const item of shown) {
|
|
179
|
+
lines.push(` ${tone(item, c)(label(item).padEnd(10))}${item.title}`)
|
|
180
|
+
const place = item.place ? item.place + (item.extra ? ` and ${num(item.extra)} more` : "") : null
|
|
181
|
+
const detail = [place, item.replacement ? `use ${item.replacement}` : null, item.hint ? `list them: ${item.hint}` : null].filter(Boolean).join(" ")
|
|
182
|
+
if (detail) lines.push(c.dim(PAD + detail))
|
|
183
|
+
if (item.source) lines.push(c.dim(PAD + item.source))
|
|
184
|
+
lines.push("")
|
|
185
|
+
}
|
|
186
|
+
if (items.length > shown.length) lines.push(` and ${num(items.length - shown.length)} more ${moreHint}`, "")
|
|
187
|
+
return lines
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/** What keyed answers say about their own gaps: still being read, cut short, or names that did not resolve. */
|
|
191
|
+
export function answerNotes(answers) {
|
|
192
|
+
const notes = []
|
|
193
|
+
let scanning = 0
|
|
194
|
+
let reading = false
|
|
195
|
+
let releases = 0
|
|
196
|
+
let truncated = false
|
|
197
|
+
const unresolved = []
|
|
198
|
+
for (const answer of answers) {
|
|
199
|
+
if (answer.scanning) scanning = Math.max(scanning, Number(answer.scanning.retryAfterSec) || 60)
|
|
200
|
+
if (answer.reading) {
|
|
201
|
+
reading = true
|
|
202
|
+
releases += Number(answer.reading.releases) || 0
|
|
203
|
+
}
|
|
204
|
+
if (answer.truncated) truncated = true
|
|
205
|
+
for (const entry of Array.isArray(answer.unresolved) ? answer.unresolved : []) unresolved.push(entry)
|
|
206
|
+
}
|
|
207
|
+
if (scanning) notes.push(`Since is still reading this repository. Run this again in ${wait(scanning)}.`)
|
|
208
|
+
if (reading) {
|
|
209
|
+
const what = releases ? plural(releases, "newer release") : "newer releases"
|
|
210
|
+
notes.push(`Since is reading ${what} now. Run this again in a few minutes to include them.`)
|
|
211
|
+
}
|
|
212
|
+
if (truncated) notes.push("The answer was cut short: there are more findings than one answer holds.")
|
|
213
|
+
if (unresolved.length) {
|
|
214
|
+
const names = unresolved.slice(0, 3).map((entry) => String(entry.name)).join(", ")
|
|
215
|
+
notes.push(`Not fully checked yet: ${names}${unresolved.length > 3 ? ` and ${num(unresolved.length - 3)} more` : ""}. --json says why.`)
|
|
216
|
+
}
|
|
217
|
+
return notes
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/** One line per calendar entry, with its replacement and its page under it. */
|
|
221
|
+
export function calendarLines(entries, site, c) {
|
|
222
|
+
const lines = []
|
|
223
|
+
for (const entry of entries) {
|
|
224
|
+
const days = typeof entry.daysLeft === "number" ? entry.daysLeft : null
|
|
225
|
+
const when = days === null ? "no date" : dayLabel(days)
|
|
226
|
+
const paint = days === null ? c.plain : dayTone(days, c)
|
|
227
|
+
lines.push(` ${paint(when.padEnd(10))}${entry.title}`)
|
|
228
|
+
const link = typeof entry.path === "string" && entry.path.startsWith("/") ? `${site}${entry.path}?utm_source=cli` : null
|
|
229
|
+
const detail = [entry.replacement ? `use ${entry.replacement}` : null, link].filter(Boolean).join(" ")
|
|
230
|
+
if (detail) lines.push(c.dim(PAD + detail))
|
|
231
|
+
}
|
|
232
|
+
return lines
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
/** One line per change of a package, with the version, the date and the source under it. */
|
|
236
|
+
export function uniqueChanges(changes) {
|
|
237
|
+
// One change can be recorded once per function it touches, with the same words: say it once.
|
|
238
|
+
const seen = new Set()
|
|
239
|
+
return changes.filter((change) => {
|
|
240
|
+
const key = `${change.kind}|${change.title}|${change.introducedIn || change.removedIn}`
|
|
241
|
+
if (seen.has(key)) return false
|
|
242
|
+
seen.add(key)
|
|
243
|
+
return true
|
|
244
|
+
})
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
export function changeLines(changes, c) {
|
|
248
|
+
const lines = []
|
|
249
|
+
for (const change of changes) {
|
|
250
|
+
const version = change.introducedIn || change.removedIn
|
|
251
|
+
lines.push(` ${String(change.kind ?? "").replace(/_/g, " ").padEnd(19)}${change.title}`)
|
|
252
|
+
const evidence = Array.isArray(change.evidence) && change.evidence.length ? change.evidence[0].url : null
|
|
253
|
+
const detail = [version ? `in ${version}` : null, change.effectiveDate || null, evidence || null].filter(Boolean).join(" ")
|
|
254
|
+
if (detail) lines.push(c.dim(" ".repeat(21) + detail))
|
|
255
|
+
}
|
|
256
|
+
return lines
|
|
257
|
+
}
|