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 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
- # Temporary Holding Version
1
+ # since-dev
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
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.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
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
+ }