kiriya 0.1.2 → 0.2.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.
Files changed (27) hide show
  1. package/CHANGELOG.md +32 -0
  2. package/README.md +2 -0
  3. package/dist/src/config/modules.js +4 -0
  4. package/dist/src/{modules/files → core}/application/text-files.js +7 -4
  5. package/dist/src/core/domain/secrets.js +52 -1
  6. package/dist/src/core/infrastructure/node/node-network.adapter.js +55 -0
  7. package/dist/src/core/infrastructure/platform/windows/windows-trash.adapter.js +37 -29
  8. package/dist/src/i18n/locales/en.js +38 -0
  9. package/dist/src/main.js +7 -1
  10. package/dist/src/modules/doctor/presentation/doctor.view.js +3 -0
  11. package/dist/src/modules/files/application/compare-paths.use-case.js +1 -1
  12. package/dist/src/modules/files/application/find-files.use-case.js +1 -1
  13. package/dist/src/modules/files/application/read-text.use-case.js +2 -2
  14. package/dist/src/modules/files/application/replace-text.use-case.js +3 -3
  15. package/dist/src/modules/files/application/search-text.use-case.js +3 -3
  16. package/dist/src/modules/secrets/application/scan-secrets.use-case.js +108 -0
  17. package/dist/src/modules/secrets/domain/allow-marker.js +23 -0
  18. package/dist/src/modules/secrets/presentation/secrets.view.js +29 -0
  19. package/dist/src/modules/secrets/secrets.module.js +13 -0
  20. package/dist/src/modules/self-update/application/update-self.use-case.js +86 -0
  21. package/dist/src/modules/self-update/domain/install-source.js +17 -0
  22. package/dist/src/modules/self-update/domain/versions.js +52 -0
  23. package/dist/src/modules/self-update/presentation/self-update.view.js +19 -0
  24. package/dist/src/modules/self-update/self-update.module.js +13 -0
  25. package/package.json +1 -1
  26. /package/dist/src/{modules/files → core}/domain/filters.js +0 -0
  27. /package/dist/src/{modules/files → core}/domain/text-encoding.js +0 -0
package/CHANGELOG.md CHANGED
@@ -7,6 +7,38 @@ output shapes are kiriya's public API.
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.2.0] - 2026-10-04
11
+
12
+ ### Added
13
+
14
+ - `secrets scan` finds secrets committed into a folder's files: a token whose issuer gives it
15
+ a recognisable prefix, a PEM private key, a password inside a URL, or a JWT. It exits 1 when
16
+ anything is found, so a git hook or a CI step needs no output parsing. **A finding never
17
+ carries the secret** — `ghp_****`, `postgres://me:****@` — because a report that quoted the
18
+ value would copy it into scrollback and CI logs. Unlike every other command it reads hidden
19
+ files by default, since `.env` and `.npmrc` are where secrets sit, and it skips dependency
20
+ folders instead; `--all` scans those too. A line saying `kiriya:allow-secret`, on it or above
21
+ it, declares one deliberate, which is how test fixtures and documentation stay quiet.
22
+ Over MCP it needs `mcp.allowWrite`, as `clip paste` does.
23
+
24
+ It deliberately does not flag a line that merely assigns something named like a secret.
25
+ That rule serves `env show`, where the name and the value are already separate, and over
26
+ source code it is useless: against kiriya itself it matched 42 lines and every one was
27
+ syntax, such as `if (token === "--")` in the argv parser.
28
+
29
+ ## [0.1.3] - 2026-10-03
30
+
31
+ ### Added
32
+
33
+ - `self-update` asks npm's registry whether a newer kiriya exists, and installs it with
34
+ `--apply`. Without `--apply` nothing changes, so it is safe to run just to find out.
35
+ It names the exact version the registry answered with rather than `latest`, never offers
36
+ a prerelease, and refuses to run npm over an install npm did not make, such as a source
37
+ checkout or a future Homebrew tap. It is never offered to AI agents: an agent must not
38
+ upgrade, mid-session, the tool it is calling.
39
+ - `doctor` ends with a line naming `self-update`. It still reaches no network itself —
40
+ until now nothing told anyone that a newer version existed at all.
41
+
10
42
  ## [0.1.2] - 2026-09-24
11
43
 
12
44
  ### Changed
package/README.md CHANGED
@@ -87,6 +87,8 @@ option, what each does, and whether it can be undone.
87
87
  | [`open`](docs/modules/open.md) | Open a file or folder in its default application, or a web address in the browser. |
88
88
  | [`port`](docs/modules/port.md) | See which process listens on a TCP port, end it, or find a free port, the same on every OS. |
89
89
  | [`proc`](docs/modules/proc.md) | List, find and end processes, and show them as a tree, the same on every OS. |
90
+ | [`secrets`](docs/modules/secrets.md) | Find secrets committed into a folder's files: tokens, private keys and passwords in plain sight. |
91
+ | [`self-update`](docs/modules/self-update.md) | Ask the registry whether a newer kiriya exists, and install it with --apply. |
90
92
  | [`sys`](docs/modules/sys.md) | This machine and the developer tools on it, described the same way on every OS. |
91
93
  | [`wait`](docs/modules/wait.md) | Wait until a port listens, a web address answers or a file appears, the same way on every OS. |
92
94
  <!-- /kiriya:modules -->
@@ -13,6 +13,8 @@ import { netModule } from "../modules/net/net.module.js";
13
13
  import { openModule } from "../modules/open/open.module.js";
14
14
  import { portModule } from "../modules/port/port.module.js";
15
15
  import { procModule } from "../modules/proc/proc.module.js";
16
+ import { secretsModule } from "../modules/secrets/secrets.module.js";
17
+ import { selfUpdateModule } from "../modules/self-update/self-update.module.js";
16
18
  import { sysModule } from "../modules/sys/sys.module.js";
17
19
  import { waitModule } from "../modules/wait/wait.module.js";
18
20
  /** Built-in modules, in no particular order; help lists them sorted. Adding a module is one line here. */
@@ -34,5 +36,7 @@ export const BUILT_IN_MODULES = [
34
36
  openModule,
35
37
  completionModule,
36
38
  waitModule,
39
+ selfUpdateModule,
40
+ secretsModule,
37
41
  ];
38
42
  //# sourceMappingURL=modules.js.map
@@ -1,7 +1,7 @@
1
1
  import path from "node:path";
2
- import { walk } from "../../../core/application/walk.js";
3
- import { KiriyaError } from "../../../core/domain/errors.js";
4
- import { globToRegExp } from "../../../core/domain/glob.js";
2
+ import { walk } from "./walk.js";
3
+ import { KiriyaError } from "../domain/errors.js";
4
+ import { globToRegExp } from "../domain/glob.js";
5
5
  import { decodeText, MAX_TEXT_BYTES } from "../domain/text-encoding.js";
6
6
  /** A text file, or null when it is binary, larger than MAX_TEXT_BYTES, or unreadable. */
7
7
  export async function readTextFile(fileSystem, content, target) {
@@ -35,7 +35,10 @@ export async function selectFiles(fileSystem, paths, selection) {
35
35
  files.push(target);
36
36
  }
37
37
  else if (stat?.kind === "directory") {
38
- for await (const entry of walk(fileSystem, target, { all: selection.all })) {
38
+ const options = selection.skipDirectories === undefined
39
+ ? { all: selection.all }
40
+ : { all: selection.all, skipDirectories: selection.skipDirectories };
41
+ for await (const entry of walk(fileSystem, target, options)) {
39
42
  if (entry.kind === "file" && keep(entry.name, entry.rel))
40
43
  files.push(entry.path);
41
44
  }
@@ -1,7 +1,7 @@
1
1
  import { splitWords } from "./text-case.js";
2
2
  const SECRET_ASSIGNMENT = /(pass(word)?|pwd|secret|token|api[-_]?key)\s*[=:]/i;
3
3
  const PRIVATE_KEY = /-----BEGIN [A-Z ]*PRIVATE KEY-----/;
4
- /** `scheme://user:password@host`. */
4
+ /** `scheme://user:password@host`. kiriya:allow-secret */
5
5
  const URL_CREDENTIALS = /[a-z][a-z0-9+.-]*:\/\/[^\s/:@]+:[^\s/@]+@/i;
6
6
  /** A JSON Web Token: two base64url JSON parts, then the signature. */
7
7
  const JWT = /eyJ[A-Za-z0-9_-]{8,}\.eyJ[A-Za-z0-9_-]{8,}\./;
@@ -15,6 +15,57 @@ export function looksSecret(value) {
15
15
  JWT.test(value) ||
16
16
  PREFIXED_TOKEN.test(value.trim()));
17
17
  }
18
+ export const SECRET_KINDS = ["private-key", "url-credentials", "jwt", "token"];
19
+ /**
20
+ * Patterns that identify a secret by its own shape, for finding one inside a line of a file.
21
+ *
22
+ * `SECRET_ASSIGNMENT` is deliberately not here. It serves `looksSecret`, which judges a whole
23
+ * value where the name and the value are already separate, and it is useless over source code:
24
+ * run against kiriya itself it matched 42 lines and every one was wrong — `if (token === "--")`
25
+ * in the argv parser, `decodeJwt(token: string)`, `readonly secret: Uint8Array`. A scanner that
26
+ * cries wolf 42 times gets turned off, so it scans only for what cannot be mistaken for syntax.
27
+ */
28
+ const SCANNED = [
29
+ { kind: "private-key", pattern: /-----BEGIN [A-Z ]*PRIVATE KEY-----/g },
30
+ { kind: "url-credentials", pattern: /[a-z][a-z0-9+.-]*:\/\/[^\s/:@]+:[^\s/@]+@/gi },
31
+ { kind: "jwt", pattern: /eyJ[A-Za-z0-9_-]{8,}\.eyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]*/g },
32
+ {
33
+ kind: "token",
34
+ pattern: /(?:(?:ghp|gho|ghu|ghs|ghr)_|github_pat_|glpat-|xox[abprs]-|npm_|sk-)[A-Za-z0-9_-]{16,}|AKIA[0-9A-Z]{16}/g,
35
+ },
36
+ ];
37
+ /** Every secret in one piece of text, earliest first, without overlaps. */
38
+ export function findSecrets(text) {
39
+ const found = [];
40
+ const taken = [];
41
+ for (const { kind, pattern } of SCANNED) {
42
+ for (const match of text.matchAll(pattern)) {
43
+ const start = match.index;
44
+ const end = start + match[0].length;
45
+ if (taken.some((range) => start < range.end && end > range.start))
46
+ continue;
47
+ taken.push({ start, end });
48
+ found.push({ kind, start, length: match[0].length });
49
+ }
50
+ }
51
+ return found.sort((a, b) => a.start - b.start);
52
+ }
53
+ /**
54
+ * What may be printed about a match: enough to recognise whose secret it is, never enough to
55
+ * use it. A finding that carried the value would copy it into scrollback, CI logs and issue
56
+ * comments, which is the opposite of the point.
57
+ */
58
+ export function redactSecret(text, match) {
59
+ const value = text.slice(match.start, match.start + match.length);
60
+ // The marker line is not itself secret, and it is what tells you what you are looking at.
61
+ if (match.kind === "private-key")
62
+ return value;
63
+ const prefix = /^(?:(?:ghp|gho|ghu|ghs|ghr)_|github_pat_|glpat-|xox[abprs]-|npm_|sk-|AKIA|eyJ)/.exec(value);
64
+ if (prefix !== null)
65
+ return `${prefix[0]}****`;
66
+ const scheme = /^[a-z][a-z0-9+.-]*:\/\/[^\s/:@]+:/i.exec(value);
67
+ return scheme === null ? "****" : `${scheme[0]}****@`;
68
+ }
18
69
  const SECRET_WORDS = new Set([
19
70
  "password",
20
71
  "passwd",
@@ -141,6 +141,61 @@ export class NodeNetworkAdapter {
141
141
  outgoing.end();
142
142
  });
143
143
  }
144
+ fetchText(url, timeoutMs, maxBytes, signal) {
145
+ if (signal.aborted)
146
+ return Promise.reject(new InterruptedError("core.error.interrupted"));
147
+ return new Promise((resolve, reject) => {
148
+ const started = performance.now();
149
+ const elapsed = () => Math.round(performance.now() - started);
150
+ const target = new URL(url);
151
+ const send = target.protocol === "https:" ? httpsRequest : httpRequest;
152
+ const outgoing = send(target, {
153
+ method: "GET",
154
+ headers: { "user-agent": "kiriya", accept: "application/json" },
155
+ });
156
+ let settled = false;
157
+ const settle = (outcome) => {
158
+ if (settled)
159
+ return;
160
+ settled = true;
161
+ clearTimeout(timer);
162
+ signal.removeEventListener("abort", onAbort);
163
+ outgoing.destroy();
164
+ if (outcome === null)
165
+ reject(new InterruptedError("core.error.interrupted"));
166
+ else
167
+ resolve(outcome);
168
+ };
169
+ const onAbort = () => settle(null);
170
+ const timer = setTimeout(() => settle({ ok: false, failure: "timeout", status: null, code: null, ms: elapsed() }), timeoutMs);
171
+ signal.addEventListener("abort", onAbort, { once: true });
172
+ outgoing.once("response", (response) => {
173
+ const status = response.statusCode ?? 0;
174
+ const chunks = [];
175
+ let size = 0;
176
+ response.on("data", (chunk) => {
177
+ size += chunk.length;
178
+ // Stop at the limit instead of holding whatever the other end decides to send.
179
+ if (size > maxBytes) {
180
+ response.destroy();
181
+ settle({ ok: false, failure: "too-large", status, code: null, ms: elapsed() });
182
+ return;
183
+ }
184
+ chunks.push(chunk);
185
+ });
186
+ response.once("end", () => settle({ ok: true, status, body: Buffer.concat(chunks).toString("utf8"), ms: elapsed() }));
187
+ response.once("error", (error) => {
188
+ const code = errorCode(error);
189
+ settle({ ok: false, failure: httpFailure(code), status, code, ms: elapsed() });
190
+ });
191
+ });
192
+ outgoing.on("error", (error) => {
193
+ const code = errorCode(error);
194
+ settle({ ok: false, failure: httpFailure(code), status: null, code, ms: elapsed() });
195
+ });
196
+ outgoing.end();
197
+ });
198
+ }
144
199
  async lookup(name) {
145
200
  try {
146
201
  const found = await systemLookup(name, { all: true });
@@ -45,6 +45,42 @@ for ($i = 0; $i -lt $paths.Length; $i++) {
45
45
  } catch { "error|$i|$($_.Exception.Message)" }
46
46
  }
47
47
  `;
48
+ /**
49
+ * What the script's output means, separate from running it. Constrained Language Mode needs a
50
+ * WDAC or AppLocker policy to reproduce, which no test machine has, so that branch is reached
51
+ * here instead of being left to a machine nobody has.
52
+ */
53
+ export function readTrashResult(stdout, stderr, paths) {
54
+ const lines = stdout
55
+ .split(/\r?\n/)
56
+ .map((line) => line.trim())
57
+ .filter((line) => line !== "");
58
+ const mode = lines.find((line) => line.startsWith("mode|"))?.slice(5) ?? "unknown";
59
+ if (mode !== "FullLanguage")
60
+ throw new CapabilityUnavailableError("core.trash.windows.language-mode", { mode });
61
+ const byIndex = new Map();
62
+ for (const line of lines) {
63
+ const [status, indexText, ...rest] = line.split("|");
64
+ if (indexText === undefined || !/^[0-9]+$/.test(indexText))
65
+ continue;
66
+ const index = Number(indexText);
67
+ const path = paths[index] ?? "";
68
+ if (status === "ok")
69
+ byIndex.set(index, { path, ok: true });
70
+ else if (status === "network")
71
+ byIndex.set(index, { path, ok: false, reason: message("core.trash.windows.network") });
72
+ else if (status === "drive") {
73
+ byIndex.set(index, { path, ok: false, reason: message("core.trash.windows.drive", { drive: rest.join("|") }) });
74
+ }
75
+ else
76
+ byIndex.set(index, { path, ok: false, reason: message("core.trash.failed", { detail: rest.join("|") }) });
77
+ }
78
+ return paths.map((path, index) => byIndex.get(index) ?? {
79
+ path,
80
+ ok: false,
81
+ reason: message("core.trash.failed", { detail: stderr.trim().split(/\r?\n/)[0] ?? "no result" }),
82
+ });
83
+ }
48
84
  export class WindowsTrashAdapter {
49
85
  location = "core.trash.location.recycle-bin";
50
86
  async send(paths) {
@@ -52,35 +88,7 @@ export class WindowsTrashAdapter {
52
88
  env: { KIRIYA_TRASH_PATHS: paths.join(NEWLINE) },
53
89
  timeoutMs: 10 * 60_000,
54
90
  });
55
- const lines = result.stdout
56
- .split(/\r?\n/)
57
- .map((line) => line.trim())
58
- .filter((line) => line !== "");
59
- const mode = lines.find((line) => line.startsWith("mode|"))?.slice(5) ?? "unknown";
60
- if (mode !== "FullLanguage")
61
- throw new CapabilityUnavailableError("core.trash.windows.language-mode", { mode });
62
- const byIndex = new Map();
63
- for (const line of lines) {
64
- const [status, indexText, ...rest] = line.split("|");
65
- if (indexText === undefined || !/^[0-9]+$/.test(indexText))
66
- continue;
67
- const index = Number(indexText);
68
- const path = paths[index] ?? "";
69
- if (status === "ok")
70
- byIndex.set(index, { path, ok: true });
71
- else if (status === "network")
72
- byIndex.set(index, { path, ok: false, reason: message("core.trash.windows.network") });
73
- else if (status === "drive") {
74
- byIndex.set(index, { path, ok: false, reason: message("core.trash.windows.drive", { drive: rest.join("|") }) });
75
- }
76
- else
77
- byIndex.set(index, { path, ok: false, reason: message("core.trash.failed", { detail: rest.join("|") }) });
78
- }
79
- return paths.map((path, index) => byIndex.get(index) ?? {
80
- path,
81
- ok: false,
82
- reason: message("core.trash.failed", { detail: result.stderr.trim().split(/\r?\n/)[0] ?? "no result" }),
83
- });
91
+ return readTrashResult(result.stdout, result.stderr, paths);
84
92
  }
85
93
  }
86
94
  //# sourceMappingURL=windows-trash.adapter.js.map
@@ -390,6 +390,7 @@ export const en = {
390
390
  "doctor.status.warn": "warn",
391
391
  "doctor.status.fail": "fail",
392
392
  "doctor.check.kiriya": "{version}",
393
+ "doctor.newer": "doctor asks no network. Run kiriya self-update to see whether a newer version is out.",
393
394
  "doctor.check.node": "{version}",
394
395
  "doctor.check.node-old": "Node.js {version} is older than {minimum}, the oldest release kiriya supports",
395
396
  "doctor.check.os": "{os}",
@@ -666,6 +667,43 @@ export const en = {
666
667
  "wait.file.gone": "{path} is gone, after {seconds} s",
667
668
  "wait.file.timed-out": "{path} did not appear within {seconds} s",
668
669
  "wait.file.still-there": "{path} was still there after {seconds} s",
670
+ "secrets.summary": "Find secrets committed into a folder's files: tokens, private keys and passwords in plain sight",
671
+ "secrets.about": "Reads the files under a folder and reports anything that looks like a secret: a token " +
672
+ "whose issuer gives it a recognisable prefix, a private key, a password in a URL, a JWT, " +
673
+ "or a line that assigns one. It uses the same judgement as the rest of kiriya, so what " +
674
+ "env show hides and config set refuses is what this finds. Nothing is sent anywhere and " +
675
+ "nothing changes. A finding never carries the secret itself, only enough to recognise it, " +
676
+ "because a report that quoted it would copy it into scrollback and CI logs. Exits 1 when " +
677
+ "anything is found, so a hook or a CI step needs no output parsing. A line saying " +
678
+ "kiriya:allow-secret, on it or above it, declares that one deliberate.",
679
+ "secrets.scan.summary": "Scan a folder's files for tokens, private keys and passwords, without printing them",
680
+ "secrets.scan.arg.paths": "Folders or files to scan; the current folder by default",
681
+ "secrets.scan.option.all": "Also look inside dependency folders such as node_modules; hidden files are read either way",
682
+ "secrets.scan.option.limit": "Stop after this many findings; 500 by default",
683
+ "secrets.scan.clean": "No secrets found in {checked} file(s)",
684
+ "secrets.scan.counted": "{count} finding(s) in {checked} file(s)",
685
+ "secrets.scan.found": "{count} possible secret(s) found",
686
+ "secrets.scan.allowed": "{count} marked deliberate with kiriya:allow-secret",
687
+ "secrets.scan.truncated": "The list stopped at --limit; there may be more.",
688
+ "secrets.scan.how-allow": "A line with kiriya:allow-secret on it, or above it, is left alone.",
689
+ "self-update.summary": "Ask the registry whether a newer kiriya exists, and install it with --apply",
690
+ "self-update.about": "Asks npm's registry which version it serves as latest and compares it with this one. " +
691
+ "Without --apply it changes nothing, so it is safe to run just to find out. With --apply " +
692
+ "it installs the newer version with npm, and refuses when npm was not what installed " +
693
+ "kiriya in the first place. This is the only command that reaches the network for a " +
694
+ "reason other than the one you asked for, which is why it has to be run on purpose: " +
695
+ "nothing in kiriya checks for updates by itself. A prerelease is never offered.",
696
+ "self-update.option.apply": "Install the newer version; without it nothing changes",
697
+ "self-update.current": "kiriya {version} is the newest version",
698
+ "self-update.available": "kiriya {latest} is out; this is {installed}",
699
+ "self-update.how-apply": "Run kiriya self-update --apply to install it.",
700
+ "self-update.how-elsewhere": "npm did not install this kiriya, so update it the way you installed it, to {latest}.",
701
+ "self-update.applied": "Updated to kiriya {version}",
702
+ "self-update.unreachable": "The registry could not be reached ({failure}), so the newest version is unknown",
703
+ "self-update.unreadable": "The registry answered {status}, which does not say which version is latest",
704
+ "self-update.not-npm": "kiriya {latest} is out, but npm did not install this one, so npm will not replace it",
705
+ "self-update.no-npm": "npm is not on PATH, so the newer version cannot be installed",
706
+ "self-update.failed": "npm could not install the newer version (exit code {code}): {detail}",
669
707
  "core.processes.failed": "{program} could not list processes (exit code {code}): {detail}",
670
708
  "core.processes.unreadable": "Processes could not be read from {source}: {detail}",
671
709
  "core.ports.failed": "{program} could not list listening ports (exit code {code}): {detail}",
package/dist/src/main.js CHANGED
@@ -5,6 +5,7 @@
5
5
  */
6
6
  import { readFileSync } from "node:fs";
7
7
  import { dirname } from "node:path";
8
+ import { fileURLToPath } from "node:url";
8
9
  import { BUILT_IN_MODULES } from "./config/modules.js";
9
10
  import { CommandRegistry } from "./core/application/command-registry.js";
10
11
  import { pluginEntries } from "./core/application/config-values.js";
@@ -124,7 +125,12 @@ const ports = {
124
125
  protectedPaths: new PathGuard(environment),
125
126
  config,
126
127
  plugins,
127
- runtime: { kiriyaVersion: version, nodeVersion: process.version },
128
+ runtime: {
129
+ kiriyaVersion: version,
130
+ nodeVersion: process.version,
131
+ // dist/src/main.js, so two levels up is the folder holding package.json.
132
+ installDirectory: fileURLToPath(new URL("../../", import.meta.url)),
133
+ },
128
134
  };
129
135
  for (const module of BUILT_IN_MODULES)
130
136
  registry.register(module, ports);
@@ -16,6 +16,9 @@ export const doctorView = (output, format) => {
16
16
  const status = format.text(message(STATUS_KEYS[check.status])).padEnd(statusWidth);
17
17
  return ` ${paint(check.status, status, format)} ${check.name.padEnd(nameWidth)} ${format.text(check.detail)}`;
18
18
  });
19
+ // doctor reaches no network, so it cannot know whether this version is behind. It says where
20
+ // to find out instead, because nothing else told anyone that a newer kiriya exists.
21
+ lines.push("", format.dim(format.text(message("doctor.newer"))));
19
22
  if (output.plugins.length > 0) {
20
23
  lines.push("", format.bold(format.text(message("doctor.plugins"))));
21
24
  const idWidth = Math.max(...output.plugins.map((plugin) => plugin.id.length));
@@ -5,7 +5,7 @@ import { NotFoundError, UsageError } from "../../../core/domain/errors.js";
5
5
  import { RawReader } from "../../../core/domain/input-schema.js";
6
6
  import { message } from "../../../core/domain/message.js";
7
7
  import { byCodePoint } from "../../../core/domain/names.js";
8
- import { readTextFile } from "./text-files.js";
8
+ import { readTextFile } from "../../../core/application/text-files.js";
9
9
  export const compareSpec = {
10
10
  id: "files.compare",
11
11
  summary: "files.compare.summary",
@@ -3,7 +3,7 @@ import { done } from "../../../core/domain/command.js";
3
3
  import { NotFoundError } from "../../../core/domain/errors.js";
4
4
  import { RawReader } from "../../../core/domain/input-schema.js";
5
5
  import { globToRegExp } from "../../../core/domain/glob.js";
6
- import { parseExtensions } from "../domain/filters.js";
6
+ import { parseExtensions } from "../../../core/domain/filters.js";
7
7
  import { parseSize, parseTime } from "../domain/values.js";
8
8
  import { walk } from "../../../core/application/walk.js";
9
9
  export const FIND_TYPES = ["file", "dir"];
@@ -3,8 +3,8 @@ import { done } from "../../../core/domain/command.js";
3
3
  import { NotFoundError, OperationFailedError, UsageError } from "../../../core/domain/errors.js";
4
4
  import { RawReader } from "../../../core/domain/input-schema.js";
5
5
  import { formatBytes } from "../../../core/domain/values/bytes.js";
6
- import { MAX_TEXT_BYTES, splitLines } from "../domain/text-encoding.js";
7
- import { readTextFile } from "./text-files.js";
6
+ import { MAX_TEXT_BYTES, splitLines } from "../../../core/domain/text-encoding.js";
7
+ import { readTextFile } from "../../../core/application/text-files.js";
8
8
  export const readSpec = {
9
9
  id: "files.read",
10
10
  summary: "files.read.summary",
@@ -4,9 +4,9 @@ import { done, preview } from "../../../core/domain/command.js";
4
4
  import { KiriyaError, UsageError } from "../../../core/domain/errors.js";
5
5
  import { RawReader } from "../../../core/domain/input-schema.js";
6
6
  import { message } from "../../../core/domain/message.js";
7
- import { parseExtensions } from "../domain/filters.js";
8
- import { encodeText, escapeRegExp } from "../domain/text-encoding.js";
9
- import { readTextFile, selectFiles } from "./text-files.js";
7
+ import { parseExtensions } from "../../../core/domain/filters.js";
8
+ import { encodeText, escapeRegExp } from "../../../core/domain/text-encoding.js";
9
+ import { readTextFile, selectFiles } from "../../../core/application/text-files.js";
10
10
  /** Changed lines shown per file. */
11
11
  const SAMPLE_LINES = 3;
12
12
  export const replaceSpec = {
@@ -2,9 +2,9 @@ import { expandPaths } from "../../../core/application/paths.js";
2
2
  import { done } from "../../../core/domain/command.js";
3
3
  import { UsageError } from "../../../core/domain/errors.js";
4
4
  import { RawReader } from "../../../core/domain/input-schema.js";
5
- import { parseExtensions } from "../domain/filters.js";
6
- import { escapeRegExp } from "../domain/text-encoding.js";
7
- import { readTextFile, selectFiles } from "./text-files.js";
5
+ import { parseExtensions } from "../../../core/domain/filters.js";
6
+ import { escapeRegExp } from "../../../core/domain/text-encoding.js";
7
+ import { readTextFile, selectFiles } from "../../../core/application/text-files.js";
8
8
  /** Longer matching lines are cut here, so one minified file cannot flood the terminal. */
9
9
  const LINE_LIMIT = 240;
10
10
  export const grepSpec = {
@@ -0,0 +1,108 @@
1
+ import { DEPENDENCY_DIRECTORIES } from "../../../config/dependency-directories.js";
2
+ import { expandPaths } from "../../../core/application/paths.js";
3
+ import { readTextFile, selectFiles } from "../../../core/application/text-files.js";
4
+ import { done } from "../../../core/domain/command.js";
5
+ import { RawReader } from "../../../core/domain/input-schema.js";
6
+ import { message } from "../../../core/domain/message.js";
7
+ import { parseExtensions } from "../../../core/domain/filters.js";
8
+ import { findSecrets, redactSecret } from "../../../core/domain/secrets.js";
9
+ import { splitLines } from "../../../core/domain/text-encoding.js";
10
+ import { isAllowed } from "../domain/allow-marker.js";
11
+ export const scanSpec = {
12
+ id: "secrets.scan",
13
+ summary: "secrets.scan.summary",
14
+ examples: ["kiriya secrets scan", "kiriya secrets scan src --ext .ts", "kiriya secrets scan --json"],
15
+ safety: "read",
16
+ idempotent: true,
17
+ // Nothing is sent anywhere, which is most of why anyone would use it.
18
+ usesNetwork: false,
19
+ runsUserCommands: false,
20
+ // The findings point straight at secrets, as `proc list --full` and `clip paste` do.
21
+ sensitive: true,
22
+ input: {
23
+ positionals: [
24
+ { name: "paths", description: "secrets.scan.arg.paths", required: false, variadic: true, path: true },
25
+ ],
26
+ options: {
27
+ ext: { type: "string", description: "files.option.ext", valueName: "<extensions>", multiple: true },
28
+ name: { type: "string", description: "files.option.name", valueName: "<glob>" },
29
+ all: { type: "boolean", description: "secrets.scan.option.all" },
30
+ limit: { type: "string", description: "secrets.scan.option.limit", valueName: "<n>" },
31
+ },
32
+ parse(raw) {
33
+ const reader = new RawReader(raw);
34
+ return {
35
+ paths: reader.positionalsFrom(0),
36
+ extensions: parseExtensions(reader.strings("ext")),
37
+ name: reader.string("name"),
38
+ all: reader.flag("all"),
39
+ limit: reader.positiveInteger("limit", 500),
40
+ };
41
+ },
42
+ },
43
+ };
44
+ export class ScanSecrets {
45
+ fileSystem;
46
+ content;
47
+ spec = scanSpec;
48
+ constructor(fileSystem, content) {
49
+ this.fileSystem = fileSystem;
50
+ this.content = content;
51
+ }
52
+ async execute(input, context) {
53
+ const targets = input.paths.length === 0 ? ["."] : input.paths;
54
+ // Hidden files are where secrets live — .env, .npmrc, .git-credentials — so unlike the rest
55
+ // of kiriya this walks them by default, and keeps dependency folders out instead. --all
56
+ // drops that guard for someone who really does want to scan what they installed.
57
+ const roots = await expandPaths(this.fileSystem, targets, context.cwd, true);
58
+ const files = await selectFiles(this.fileSystem, roots, {
59
+ all: true,
60
+ extensions: input.extensions,
61
+ name: input.name,
62
+ ...(input.all ? {} : { skipDirectories: DEPENDENCY_DIRECTORIES }),
63
+ });
64
+ const findings = [];
65
+ let checked = 0;
66
+ let skipped = 0;
67
+ let allowed = 0;
68
+ let truncated = false;
69
+ for (const file of files) {
70
+ const text = await readTextFile(this.fileSystem, this.content, file);
71
+ if (text === null) {
72
+ skipped += 1;
73
+ continue;
74
+ }
75
+ checked += 1;
76
+ const lines = splitLines(text.text);
77
+ for (const [index, line] of lines.entries()) {
78
+ for (const match of findSecrets(line)) {
79
+ if (isAllowed(lines, index)) {
80
+ allowed += 1;
81
+ continue;
82
+ }
83
+ if (findings.length >= input.limit) {
84
+ truncated = true;
85
+ break;
86
+ }
87
+ findings.push({
88
+ path: file,
89
+ line: index + 1,
90
+ column: match.start + 1,
91
+ kind: match.kind,
92
+ sample: redactSecret(line, match),
93
+ });
94
+ }
95
+ if (truncated)
96
+ break;
97
+ }
98
+ if (truncated)
99
+ break;
100
+ }
101
+ const data = { findings, checked, skipped, allowed, truncated };
102
+ // Found means exit 1, so a hook or a CI step needs no output parsing, as files compare does.
103
+ if (findings.length === 0)
104
+ return done(data);
105
+ return done(data, { failures: [message("secrets.scan.found", { count: findings.length })] });
106
+ }
107
+ }
108
+ //# sourceMappingURL=scan-secrets.use-case.js.map
@@ -0,0 +1,23 @@
1
+ /**
2
+ * A line may say that the secret on it is deliberate, which test fixtures and documentation
3
+ * need: a scanner whose first run on its own repository is all false positives gets turned
4
+ * off and never run again. The marker sits next to what it excuses and shows up in review,
5
+ * which an ignore file in a corner does not.
6
+ */
7
+ const MARKER = /kiriya:allow-secret/;
8
+ /**
9
+ * Whether the secret on this line was declared deliberate: by a marker on the line, or on
10
+ * either neighbour.
11
+ *
12
+ * The line below counts because a formatter moves trailing comments. Prettier, which this
13
+ * repository runs, reflowed `for (… "-----BEGIN RSA PRIVATE KEY-----"]) { // kiriya:allow-secret`
14
+ * onto the next line and silently unmarked the fixture. A marker a formatter can move is no
15
+ * marker, so both neighbours count.
16
+ */
17
+ export function isAllowed(lines, index) {
18
+ return [index - 1, index, index + 1].some((at) => {
19
+ const line = lines[at];
20
+ return line !== undefined && MARKER.test(line);
21
+ });
22
+ }
23
+ //# sourceMappingURL=allow-marker.js.map
@@ -0,0 +1,29 @@
1
+ import { message } from "../../../core/domain/message.js";
2
+ /** Longer samples are cut here, so a minified line cannot flood the terminal. */
3
+ const SAMPLE_LIMIT = 60;
4
+ export const scanView = (output, format) => {
5
+ if (output.findings.length === 0) {
6
+ const clean = message("secrets.scan.clean", { checked: output.checked });
7
+ const lines = [format.green(format.text(clean))];
8
+ if (output.allowed > 0)
9
+ lines.push(format.dim(format.text(message("secrets.scan.allowed", { count: output.allowed }))));
10
+ return lines;
11
+ }
12
+ const kindWidth = Math.max(...output.findings.map((finding) => finding.kind.length));
13
+ const places = output.findings.map((finding) => `${format.path(finding.path)}:${finding.line}:${finding.column}`);
14
+ const placeWidth = Math.max(...places.map((place) => place.length));
15
+ const lines = output.findings.map((finding, index) => {
16
+ const place = (places[index] ?? "").padEnd(placeWidth);
17
+ const sample = finding.sample.length > SAMPLE_LIMIT ? `${finding.sample.slice(0, SAMPLE_LIMIT)}…` : finding.sample;
18
+ return ` ${format.yellow(place)} ${finding.kind.padEnd(kindWidth)} ${sample}`;
19
+ });
20
+ lines.push("", format.text(message("secrets.scan.counted", { count: output.findings.length, checked: output.checked })));
21
+ if (output.allowed > 0)
22
+ lines.push(format.dim(format.text(message("secrets.scan.allowed", { count: output.allowed }))));
23
+ if (output.truncated)
24
+ lines.push(format.dim(format.text(message("secrets.scan.truncated"))));
25
+ // Say how to excuse one, because a fixture flagged with no way out is why scanners get turned off.
26
+ lines.push(format.dim(format.text(message("secrets.scan.how-allow"))));
27
+ return lines;
28
+ };
29
+ //# sourceMappingURL=secrets.view.js.map
@@ -0,0 +1,13 @@
1
+ import { ScanSecrets } from "./application/scan-secrets.use-case.js";
2
+ import { scanView } from "./presentation/secrets.view.js";
3
+ export const secretsModule = {
4
+ id: "secrets",
5
+ summary: "secrets.summary",
6
+ about: "secrets.about",
7
+ examples: ["kiriya secrets scan", "kiriya secrets scan src --ext ts,js", "kiriya secrets scan --json"],
8
+ guide: "https://github.com/SatPaingOo/kiriya/blob/main/docs/modules/secrets.md",
9
+ register(registrar, ports) {
10
+ registrar.add(new ScanSecrets(ports.fileSystem, ports.fileContent), scanView);
11
+ },
12
+ };
13
+ //# sourceMappingURL=secrets.module.js.map
@@ -0,0 +1,86 @@
1
+ import { done } from "../../../core/domain/command.js";
2
+ import { CapabilityUnavailableError } from "../../../core/domain/errors.js";
3
+ import { RawReader } from "../../../core/domain/input-schema.js";
4
+ import { message } from "../../../core/domain/message.js";
5
+ import { installSource } from "../domain/install-source.js";
6
+ import { compareVersions, parseVersion, readLatestVersion } from "../domain/versions.js";
7
+ /** The registry entry for the version npm serves as `latest`. */
8
+ export const LATEST_URL = "https://registry.npmjs.org/kiriya/latest";
9
+ /** Long enough for a slow network, short enough that nobody waits on a broken one. */
10
+ export const ASK_TIMEOUT_MS = 10_000;
11
+ /** That endpoint answers a few kilobytes; a body past this is something else. */
12
+ export const MAX_BODY_BYTES = 262_144;
13
+ /** An npm install over a slow network outlasts the runner's default minute. */
14
+ export const INSTALL_TIMEOUT_MS = 300_000;
15
+ export const selfUpdateSpec = {
16
+ id: "self-update",
17
+ summary: "self-update.summary",
18
+ examples: ["kiriya self-update", "kiriya self-update --apply", "kiriya self-update --json"],
19
+ // It replaces an installed program, which a reinstall puts back.
20
+ safety: "write",
21
+ idempotent: true,
22
+ usesNetwork: true,
23
+ runsUserCommands: false,
24
+ // An agent must never upgrade, mid-session, the tool it is calling.
25
+ terminalOnly: true,
26
+ input: {
27
+ positionals: [],
28
+ options: {
29
+ apply: { type: "boolean", description: "self-update.option.apply" },
30
+ },
31
+ parse(raw) {
32
+ const reader = new RawReader(raw);
33
+ return { apply: reader.flag("apply") };
34
+ },
35
+ },
36
+ };
37
+ export class UpdateSelf {
38
+ network;
39
+ processRunner;
40
+ runtime;
41
+ spec = selfUpdateSpec;
42
+ constructor(network, processRunner, runtime) {
43
+ this.network = network;
44
+ this.processRunner = processRunner;
45
+ this.runtime = runtime;
46
+ }
47
+ async execute(input, context) {
48
+ const installed = this.runtime.kiriyaVersion;
49
+ const source = installSource(this.runtime.installDirectory);
50
+ const outcome = await this.network.fetchText(LATEST_URL, ASK_TIMEOUT_MS, MAX_BODY_BYTES, context.signal);
51
+ if (!outcome.ok) {
52
+ const data = { installed, latest: null, newer: false, source, applied: false };
53
+ return done(data, { failures: [message("self-update.unreachable", { failure: outcome.failure })] });
54
+ }
55
+ const latest = outcome.status === 200 ? readLatestVersion(outcome.body) : null;
56
+ if (latest === null) {
57
+ const data = { installed, latest: null, newer: false, source, applied: false };
58
+ return done(data, { failures: [message("self-update.unreadable", { status: outcome.status })] });
59
+ }
60
+ const here = parseVersion(installed);
61
+ const there = parseVersion(latest);
62
+ const newer = here !== null && there !== null && compareVersions(there, here) > 0;
63
+ const base = { installed, latest, newer, source };
64
+ if (!newer || !input.apply)
65
+ return done({ ...base, applied: false });
66
+ // Only npm put kiriya here, so only npm is asked to replace it.
67
+ if (source !== "npm") {
68
+ return done({ ...base, applied: false }, { failures: [message("self-update.not-npm", { latest })] });
69
+ }
70
+ const npm = await this.processRunner.find("npm");
71
+ if (npm === null)
72
+ throw new CapabilityUnavailableError("self-update.no-npm");
73
+ const result = await this.processRunner.run(npm, ["install", "--global", `kiriya@${latest}`], {
74
+ signal: context.signal,
75
+ // An install over a slow network outlasts the default minute.
76
+ timeoutMs: INSTALL_TIMEOUT_MS,
77
+ // Shown as it arrives, because this is the one command that can take a while.
78
+ onOutput: (text, stream) => context.passthrough.write(text, stream),
79
+ });
80
+ if (result.code !== 0) {
81
+ return done({ ...base, applied: false }, { failures: [message("self-update.failed", { code: result.code, detail: result.stderr.trim() })] });
82
+ }
83
+ return done({ ...base, applied: true });
84
+ }
85
+ }
86
+ //# sourceMappingURL=update-self.use-case.js.map
@@ -0,0 +1,17 @@
1
+ /** How this kiriya got onto the machine, which decides whether it may run a package manager. */
2
+ export const INSTALL_SOURCES = ["npm", "elsewhere"];
3
+ /**
4
+ * `npm` when kiriya's own files sit inside a `node_modules/kiriya` folder, which is where npm
5
+ * puts a package and nowhere else puts anything. A source checkout linked with `npm link`
6
+ * resolves to the repository instead, and a future Homebrew tap or single file would too, so
7
+ * both read as `elsewhere` and self-update refuses to run npm over them.
8
+ */
9
+ export function installSource(installDirectory) {
10
+ const parts = installDirectory.replace(/\\/g, "/").split("/").filter(Boolean);
11
+ for (let index = 0; index + 1 < parts.length; index += 1) {
12
+ if (parts[index] === "node_modules" && parts[index + 1] === "kiriya")
13
+ return "npm";
14
+ }
15
+ return "elsewhere";
16
+ }
17
+ //# sourceMappingURL=install-source.js.map
@@ -0,0 +1,52 @@
1
+ /** Comparing two released versions, and reading the newest one out of what a registry answers. */
2
+ const PATTERN = /^(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?(?:\+[0-9A-Za-z.-]+)?$/;
3
+ export function parseVersion(text) {
4
+ const match = PATTERN.exec(text.trim());
5
+ if (match === null)
6
+ return null;
7
+ const [, major, minor, patch, prerelease] = match;
8
+ return {
9
+ major: Number(major),
10
+ minor: Number(minor),
11
+ patch: Number(patch),
12
+ prerelease: prerelease ?? null,
13
+ };
14
+ }
15
+ /**
16
+ * Negative when `a` is older, positive when newer, zero when the same. A prerelease is older
17
+ * than the release it leads to, which is all kiriya needs: it never offers one as an upgrade.
18
+ */
19
+ export function compareVersions(a, b) {
20
+ for (const part of ["major", "minor", "patch"]) {
21
+ if (a[part] !== b[part])
22
+ return a[part] - b[part];
23
+ }
24
+ if (a.prerelease === b.prerelease)
25
+ return 0;
26
+ if (a.prerelease === null)
27
+ return 1;
28
+ if (b.prerelease === null)
29
+ return -1;
30
+ return a.prerelease < b.prerelease ? -1 : 1;
31
+ }
32
+ /**
33
+ * The version the registry calls `latest`, from the body of
34
+ * `https://registry.npmjs.org/<name>/latest`. null when the body is not what that endpoint
35
+ * answers, so a proxy returning a login page is a failure rather than a version.
36
+ */
37
+ export function readLatestVersion(body) {
38
+ let parsed;
39
+ try {
40
+ parsed = JSON.parse(body);
41
+ }
42
+ catch {
43
+ return null;
44
+ }
45
+ if (typeof parsed !== "object" || parsed === null)
46
+ return null;
47
+ const version = parsed.version;
48
+ if (typeof version !== "string")
49
+ return null;
50
+ return parseVersion(version) === null ? null : version;
51
+ }
52
+ //# sourceMappingURL=versions.js.map
@@ -0,0 +1,19 @@
1
+ import { message } from "../../../core/domain/message.js";
2
+ /** A version that could not be read arrives as a failure, so this prints nothing for it. */
3
+ export const selfUpdateView = (output, format) => {
4
+ if (output.latest === null)
5
+ return [];
6
+ if (output.applied) {
7
+ return [format.green(format.text(message("self-update.applied", { version: output.latest })))];
8
+ }
9
+ if (!output.newer) {
10
+ return [format.text(message("self-update.current", { version: output.installed }))];
11
+ }
12
+ const found = format.text(message("self-update.available", { installed: output.installed, latest: output.latest }));
13
+ // Say what to type next, because finding out is the whole point of running this.
14
+ const next = output.source === "npm"
15
+ ? message("self-update.how-apply")
16
+ : message("self-update.how-elsewhere", { latest: output.latest });
17
+ return [format.green(found), format.dim(format.text(next))];
18
+ };
19
+ //# sourceMappingURL=self-update.view.js.map
@@ -0,0 +1,13 @@
1
+ import { UpdateSelf } from "./application/update-self.use-case.js";
2
+ import { selfUpdateView } from "./presentation/self-update.view.js";
3
+ export const selfUpdateModule = {
4
+ id: "self-update",
5
+ summary: "self-update.summary",
6
+ about: "self-update.about",
7
+ // A module that is one command shows that command's examples, as doctor does.
8
+ guide: "https://github.com/SatPaingOo/kiriya/blob/main/docs/modules/self-update.md",
9
+ register(registrar, ports) {
10
+ registrar.add(new UpdateSelf(ports.network, ports.processRunner, ports.runtime), selfUpdateView);
11
+ },
12
+ };
13
+ //# sourceMappingURL=self-update.module.js.map
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "kiriya",
3
- "version": "0.1.2",
3
+ "version": "0.2.0",
4
4
  "description": "One command-line toolbox for everyday developer work that behaves the same on Windows, Linux and macOS.",
5
5
  "mcpName": "io.github.SatPaingOo/kiriya",
6
6
  "license": "MIT",