modelos-secure 0.3.2 → 0.3.4

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.
@@ -49,11 +49,26 @@ function step(name, detail, state = "done") {
49
49
  const right = state === "done" ? "done" : "";
50
50
  say(` ${bold(name.padEnd(STEP_WIDTH))}${detail}${right ? ` ${grey(right)}` : ""}`);
51
51
  }
52
+ var RECOMMENDED = " (recommended)";
53
+ /**
54
+ * The choices as the lines they take on screen, one each and none wider than the window. The
55
+ * picker redraws by moving up exactly this many lines, so a line that wrapped would leave the one
56
+ * above it standing: seen on an 80-column window, where "Yes" appeared twice.
57
+ */
58
+ function choiceLines(choices, index, columns) {
59
+ return choices.map((choice, i) => {
60
+ const tail = choice.recommended ? RECOMMENDED : "";
61
+ const room = Math.max(8, columns - 1 - 4 - tail.length);
62
+ const label = choice.label.length > room ? `${choice.label.slice(0, room - 1)}${unicode ? "…" : "~"}` : choice.label;
63
+ return ` ${i === index ? blue(pointer) : " "} ${label}${tail ? grey(tail) : ""}`;
64
+ });
65
+ }
52
66
  /** One question, one reason, a default. Arrows, Enter and the first letter of each choice. */
53
67
  async function ask(question, why, choices) {
54
68
  const start = Math.max(0, choices.findIndex((c) => c.recommended));
55
69
  say();
56
70
  say(` ${question}`);
71
+ if (why) say(` ${grey(`Why we ask: ${why}`)}`);
57
72
  say();
58
73
  if (!interactive()) {
59
74
  const chosen = choices[start];
@@ -63,14 +78,10 @@ async function ask(question, why, choices) {
63
78
  let index = start;
64
79
  const render = (first) => {
65
80
  if (!first) process.stdout.write(`\u001b[${choices.length.toString()}A`);
66
- for (const [i, choice] of choices.entries()) {
67
- const mark = i === index ? blue(pointer) : " ";
68
- process.stdout.write(`\u001b[2K ${mark} ${choice.label}${choice.recommended ? grey(" (recommended)") : ""}\n`);
69
- }
81
+ const columns = process.stdout.columns || 80;
82
+ for (const line of choiceLines(choices, index, columns)) process.stdout.write(`\u001b[2K${line}\n`);
70
83
  };
71
84
  render(true);
72
- if (why) say(`\n ${grey(`Why we ask: ${why}`)}`);
73
- if (why) process.stdout.write(`\u001b[2A`);
74
85
  return new Promise((resolve, reject) => {
75
86
  emitKeypressEvents(process.stdin);
76
87
  process.stdin.setRawMode(true);
@@ -79,7 +90,6 @@ async function ask(question, why, choices) {
79
90
  process.stdin.off("keypress", onKey);
80
91
  process.stdin.setRawMode(false);
81
92
  process.stdin.pause();
82
- if (why) process.stdout.write(`\u001b[2B\n`);
83
93
  };
84
94
  const onKey = (text, key) => {
85
95
  if (key.ctrl && key.name === "c") {
@@ -89,7 +99,7 @@ async function ask(question, why, choices) {
89
99
  }
90
100
  if (key.name === "up") index = (index - 1 + choices.length) % choices.length;
91
101
  else if (key.name === "down") index = (index + 1) % choices.length;
92
- else if (key.name === "return") {
102
+ else if (key.name === "return" || key.name === "enter") {
93
103
  done();
94
104
  resolve(choices[index].value);
95
105
  return;
@@ -120,7 +130,7 @@ async function askText(question, secret = false) {
120
130
  reject(new Interrupted());
121
131
  return;
122
132
  }
123
- if (key.name === "return") {
133
+ if (key.name === "return" || key.name === "enter") {
124
134
  process.stdin.off("keypress", onKey);
125
135
  process.stdin.setRawMode(false);
126
136
  process.stdin.pause();
@@ -0,0 +1 @@
1
+ export { t as loginCommand } from "./login-DeGa63BQ.js";
package/cli.js CHANGED
@@ -1,11 +1,11 @@
1
1
  #!/usr/bin/env node
2
- import { _ as lastFinishedStep, a as apiClient, b as status, c as Interrupted, d as beforeStopping, f as blue, g as interactive, h as grey, i as IapRefusal, l as ask, m as finishBeforeStopping, o as atAddress, p as bold, r as ApiError$1, s as readConnection, t as loginCommand, u as askText, v as red, x as step, y as say } from "./assets/login-DeAJGhiN.js";
2
+ import { _ as lastFinishedStep, a as apiClient, b as status, c as Interrupted, d as beforeStopping, f as blue, g as interactive, h as grey, i as IapRefusal, l as ask, m as finishBeforeStopping, o as atAddress, p as bold, r as ApiError$1, s as readConnection, t as loginCommand, u as askText, v as red, x as step, y as say } from "./assets/login-DeGa63BQ.js";
3
3
  import { createRequire } from "node:module";
4
4
  import { existsSync } from "node:fs";
5
5
  import { fileURLToPath, pathToFileURL } from "node:url";
6
- import { basename, delimiter, dirname, extname, join, relative, resolve, sep } from "node:path";
6
+ import { basename, delimiter, dirname, extname, join, posix, relative, resolve, sep } from "node:path";
7
7
  import { parseArgs } from "node:util";
8
- import { access, appendFile, constants, glob, mkdir, readFile, readdir, realpath, rename, stat, writeFile } from "node:fs/promises";
8
+ import { access, appendFile, constants, glob, lstat, mkdir, readFile, readdir, realpath, rename, stat, writeFile } from "node:fs/promises";
9
9
  import { tmpdir } from "node:os";
10
10
  import { spawn } from "node:child_process";
11
11
  import { createHash, randomUUID } from "node:crypto";
@@ -23,7 +23,7 @@ if (existsSync(resolve(here, "wasm"))) {
23
23
  }
24
24
  // package.json
25
25
  var name = "modelos-secure";
26
- var version$1 = "0.3.2";
26
+ var version$1 = "0.3.4";
27
27
  var type = "module";
28
28
  var bin = { "modelos-secure": "dist/cli.js" };
29
29
  var exports$1 = { "./hook": "./dist/hook/hook.mjs" };
@@ -335,7 +335,17 @@ var SKIPPED = /* @__PURE__ */ new Set([
335
335
  "e2e",
336
336
  "coverage",
337
337
  ".turbo",
338
- ".cache"
338
+ ".cache",
339
+ ".idea",
340
+ ".vscode",
341
+ ".vs",
342
+ ".terraform",
343
+ ".ruff_cache",
344
+ ".nox",
345
+ ".gradle",
346
+ ".yarn",
347
+ ".svelte-kit",
348
+ ".angular"
339
349
  ]);
340
350
  var MAX_FILE_BYTES = 1048576;
341
351
  var DOCUMENT = /* @__PURE__ */ new Set([
@@ -414,8 +424,10 @@ async function walk$1(root) {
414
424
  };
415
425
  }
416
426
  /**
417
- * The folders whose manifests describe this one: itself, then each folder above it up to the
418
- * repository's root, for an app kept in a folder of a larger repository.
427
+ * The folders whose manifests describe this one: itself, each folder above it up to the
428
+ * repository's root, for an app kept in a folder of a larger repository, and the folders below
429
+ * it that hold a manifest, for a repository whose app sits in `backend/` or `sidecar/`. Without
430
+ * the ones below, such a repository showed no library, no framework and no route. ADR 0094.
419
431
  */
420
432
  async function manifestFolders(root) {
421
433
  const folders = [root];
@@ -427,6 +439,23 @@ async function manifestFolders(root) {
427
439
  folders.push(up);
428
440
  at = up;
429
441
  }
442
+ const below = async (dir, depth) => {
443
+ if (depth > 3) return;
444
+ for (const entry of await readdir(dir, { withFileTypes: true }).catch(() => [])) {
445
+ if (!entry.isDirectory() || SKIPPED.has(entry.name) || entry.name.startsWith(".")) continue;
446
+ const full = join(dir, entry.name);
447
+ for (const manifest of [
448
+ "package.json",
449
+ "pyproject.toml",
450
+ "Pipfile"
451
+ ]) if (await access(join(full, manifest)).then(() => true, () => false)) {
452
+ folders.push(full);
453
+ break;
454
+ }
455
+ await below(full, depth + 1);
456
+ }
457
+ };
458
+ await below(root, 1);
430
459
  return folders;
431
460
  }
432
461
  /** A TOML file's sections by name, each with its body. Enough for the manifests read here. */
@@ -532,15 +561,25 @@ async function look(root) {
532
561
  command: web,
533
562
  from: "Procfile"
534
563
  });
535
- const composed = (await read("docker-compose.yml"))?.match(/^\s*command:\s*(.+)$/m)?.[1];
536
- if (composed) startCommands.push({
537
- command: composed.replace(/^["']|["']$/g, ""),
538
- from: "docker-compose.yml"
539
- });
540
- const made = (await read("Makefile"))?.match(/^(?:run|start|serve):[^\n]*\n\t(.+)$/m)?.[1];
541
- if (made) startCommands.push({
542
- command: made,
543
- from: "Makefile"
564
+ for (const name of [
565
+ "docker-compose.yml",
566
+ "docker-compose.yaml",
567
+ "compose.yml",
568
+ "compose.yaml"
569
+ ]) {
570
+ const composed = (await read(name))?.match(/^\s*command:\s*(.+)$/m)?.[1];
571
+ if (composed) {
572
+ startCommands.push({
573
+ command: composed.replace(/^["']|["']$/g, ""),
574
+ from: name
575
+ });
576
+ break;
577
+ }
578
+ }
579
+ const target = (await read("Makefile"))?.match(/^(run|start|serve|dev|dev-api|api|server):[^\n]*\n\t(.+)$/m);
580
+ if (target) startCommands.push({
581
+ command: `make ${target[1]}`,
582
+ from: `Makefile, whose ${target[1]} target runs ${target[2].trim().split(/\s+/).slice(0, 3).join(" ")}`
544
583
  });
545
584
  const settingsFiles = [];
546
585
  const settingNames = /* @__PURE__ */ new Set();
@@ -5621,46 +5660,128 @@ function docOf(node, file) {
5621
5660
  // ../../packages/scan/src/start-candidates.ts
5622
5661
  /** Words a README command starts with that start an app, not install or test one. */
5623
5662
  var STARTS = /^(?:\$\s*)?((?:uvicorn|gunicorn|hypercorn|fastapi (?:dev|run)|flask (?:--app \S+ )?run|streamlit run|chainlit run|gradio|python3? (?:-m )?\S+\.py|python3? -m \S+|(?:npm|pnpm|yarn|bun) (?:run )?(?:dev|start|serve)|node \S+\.(?:m?js|ts)|npx tsx \S+|docker compose up|docker-compose up)\b.*)$/;
5663
+ /** The words of an instruction in its list form, or null when it is in the shell form. */
5664
+ function listForm(body) {
5665
+ try {
5666
+ const parts = JSON.parse(body);
5667
+ return Array.isArray(parts) ? parts.map(String) : null;
5668
+ } catch {
5669
+ return null;
5670
+ }
5671
+ }
5672
+ /**
5673
+ * What a Dockerfile runs, as Docker reads it: the last stage's entrypoint with its command as the
5674
+ * arguments, unless the entrypoint is in the shell form, which ignores them. A health check's own
5675
+ * CMD is not it. Only arguments, starting with a dash, go to a program in the base image, which is
5676
+ * not here to name, so they are no command at all.
5677
+ */
5678
+ function dockerCommand(dockerfile) {
5679
+ const lines = dockerfile.replace(/\\\r?\n/g, " ").split(/\r?\n/).map((l) => l.trim());
5680
+ const stage = lines.slice(lines.findLastIndex((l) => /^FROM\s/i.test(l)) + 1);
5681
+ const last = (word) => stage.findLast((l) => new RegExp(`^${word}\\s`, "i").test(l))?.slice(word.length).trim();
5682
+ const entrypoint = last("ENTRYPOINT");
5683
+ if (entrypoint && !listForm(entrypoint)) return entrypoint;
5684
+ const full = [entrypoint, last("CMD")].filter((body) => body !== void 0).map((body) => listForm(body)?.join(" ") ?? body).join(" ");
5685
+ return full && !full.startsWith("-") ? full : null;
5686
+ }
5687
+ /**
5688
+ * Scripts beside the app, not the app: a main block in scripts/, tests/ or eval/, or in a file
5689
+ * named for a check, a benchmark or a one-off job, starts nothing a review could talk to.
5690
+ */
5691
+ var ASIDE_FOLDERS = /(^|\/)(scripts?|tests?|evals?|benchmarks?|training|tools|migrations|examples?|notebooks?|docs?|bin|hooks|experiments?|utils?)(\/|$)/;
5692
+ var ASIDE_NAMES = /(^|\/)(benchmark|check|backfill|stress|verify|test|dump|audit|import|export|migrate|seed|setup|install|build|deploy|smoke|demo|debug|fixture|generate|convert|download|upload|sync|clean|classify|investigate|analy[sz]e|report)([_-][^/]*)?\.py$|(^|\/)conftest\.py$|_(test|tests|audit|smoke|check|benchmark|demo|gates?)\.py$/;
5693
+ function isAside(path) {
5694
+ return ASIDE_FOLDERS.test(path.replace(/\/[^/]+$/, "")) || ASIDE_NAMES.test(path);
5695
+ }
5696
+ /**
5697
+ * The folder a Python file's absolute imports resolve from, which is where the app runs: for
5698
+ * `from app.db import x` in backend/app/main.py, that is backend. Empty for the repository's
5699
+ * root, or when nothing it imports is here.
5700
+ */
5701
+ function importRoot(path, imports, files) {
5702
+ const parts = path.split("/").slice(0, -1);
5703
+ const own = imports.filter((name) => !name.startsWith("."));
5704
+ for (let i = parts.length; i >= 0; i--) {
5705
+ const dir = parts.slice(0, i).join("/");
5706
+ const under = (rest) => dir ? `${dir}/${rest}` : rest;
5707
+ if (own.some((name) => {
5708
+ const head = name.split(".")[0];
5709
+ return files.has(under(`${head}.py`)) || files.has(under(`${head}/__init__.py`));
5710
+ })) return dir;
5711
+ }
5712
+ return "";
5713
+ }
5714
+ /** What is offered first: the framework's own way, then the build, the README, the scripts. */
5715
+ var RANK = [
5716
+ [/^the (FastAPI|Starlette|Flask) (app|factory) /, 0],
5717
+ [/^the Dockerfile's command$/, 1],
5718
+ [/^README/i, 2],
5719
+ [/^(package\.json|pyproject\.toml|Procfile|docker-compose|compose\.|Makefile)/, 3],
5720
+ [/^the main block at ([^/]+\/)*(main|app|server|api|cli|run|serve|wsgi|asgi)\.py:/, 4]
5721
+ ];
5722
+ function rankStart(candidates) {
5723
+ const rank = (c) => RANK.find(([pattern]) => pattern.test(c.from))?.[1] ?? 5;
5724
+ return candidates.map((c, i) => ({
5725
+ c,
5726
+ i
5727
+ })).sort((a, b) => rank(a.c) - rank(b.c) || a.i - b.i).map(({ c }) => c);
5728
+ }
5624
5729
  async function startCandidates(look, project) {
5625
5730
  const files = new Set(project.files.map((f) => f.path));
5626
5731
  const read = (file) => readFile(join(look.root, file), "utf8").catch(() => null);
5627
5732
  const found = [];
5628
5733
  for (const file of project.files) {
5629
5734
  if (file.language !== "python") continue;
5630
- const module = file.path.replace(/\.py$/, "").replace(/\//g, ".");
5735
+ const symbols = symbolsOf(file);
5736
+ const appDir = importRoot(file.path, symbols.imports, files);
5737
+ const module = file.path.slice(appDir ? appDir.length + 1 : 0).replace(/\.py$/, "").replace(/\//g, ".");
5738
+ const dirOption = appDir ? ` --app-dir ${appDir}` : "";
5631
5739
  const standsAlone = module.includes(".") || !/^from \.+\w*\s+import/m.test(file.text);
5632
- for (const d of symbolsOf(file).declarations) {
5740
+ const made = /* @__PURE__ */ new Set();
5741
+ for (const d of symbols.declarations) {
5633
5742
  const callee = d.callee.split(".").at(-1) ?? "";
5634
5743
  if (!d.name) continue;
5635
5744
  if (!standsAlone) continue;
5636
- if (/^(FastAPI|Starlette)$/.test(callee)) found.push({
5637
- command: `uvicorn ${module}:${d.name}`,
5638
- from: `the ${callee} app ${d.name} at ${file.path}:${d.line.toString()}`
5639
- });
5640
- else if (callee === "Flask") found.push({
5641
- command: `flask --app ${module}:${d.name} run`,
5642
- from: `the Flask app ${d.name} at ${file.path}:${d.line.toString()}`
5745
+ if (/^(FastAPI|Starlette)$/.test(callee)) {
5746
+ made.add(callee);
5747
+ found.push({
5748
+ command: `uvicorn ${module}:${d.name}${dirOption}`,
5749
+ from: `the ${callee} app ${d.name} at ${file.path}:${d.line.toString()}`
5750
+ });
5751
+ } else if (callee === "Flask") {
5752
+ made.add(callee);
5753
+ found.push({
5754
+ command: `flask --app ${module}:${d.name} run${dirOption}`,
5755
+ from: `the Flask app ${d.name} at ${file.path}:${d.line.toString()}`
5756
+ });
5757
+ }
5758
+ }
5759
+ const lines = file.text.split("\n");
5760
+ if (standsAlone) for (const s of symbols.symbols) {
5761
+ if (s.kind !== "function") continue;
5762
+ const body = lines.slice(s.line - 1, s.end).join("\n");
5763
+ const callee = /\b(FastAPI|Starlette|Flask)\(/.exec(body)?.[1];
5764
+ if (!callee || made.has(callee) || !/^\s+return\s/m.test(body)) continue;
5765
+ found.push(callee === "Flask" ? {
5766
+ command: `flask --app ${module}:${s.name} run${dirOption}`,
5767
+ from: `the Flask factory ${s.name} at ${file.path}:${s.line.toString()}`
5768
+ } : {
5769
+ command: `uvicorn ${module}:${s.name} --factory${dirOption}`,
5770
+ from: `the ${callee} factory ${s.name} at ${file.path}:${s.line.toString()}`
5643
5771
  });
5644
5772
  }
5645
- const main = file.text.split("\n").findIndex((l) => /^if __name__\s*==\s*["']__main__["']/.test(l));
5646
- if (main >= 0) found.push({
5773
+ const main = lines.findIndex((l) => /^if __name__\s*==\s*["']__main__["']/.test(l));
5774
+ if (main >= 0 && !isAside(file.path)) found.push({
5647
5775
  command: `python ${file.path}`,
5648
5776
  from: `the main block at ${file.path}:${(main + 1).toString()}`
5649
5777
  });
5650
5778
  }
5651
- const cmd = (await read("Dockerfile"))?.split("\n").reverse().find((l) => /^\s*(CMD|ENTRYPOINT)\s/i.test(l));
5652
- if (cmd) {
5653
- const body = cmd.replace(/^\s*(CMD|ENTRYPOINT)\s+/i, "").trim();
5654
- let command = body;
5655
- try {
5656
- const parts = JSON.parse(body);
5657
- if (Array.isArray(parts)) command = parts.map(String).join(" ");
5658
- } catch {}
5659
- if (command) found.push({
5660
- command,
5661
- from: "the Dockerfile's command"
5662
- });
5663
- }
5779
+ const dockerfile = await read("Dockerfile");
5780
+ const command = dockerfile ? dockerCommand(dockerfile) : null;
5781
+ if (command) found.push({
5782
+ command,
5783
+ from: "the Dockerfile's command"
5784
+ });
5664
5785
  for (const name of [
5665
5786
  "README.md",
5666
5787
  "readme.md",
@@ -5680,8 +5801,10 @@ async function startCandidates(look, project) {
5680
5801
  return found.filter((c) => {
5681
5802
  if (seen.has(c.command)) return false;
5682
5803
  seen.add(c.command);
5683
- const file = c.command.split(/\s+/).find((w) => /\.(py|m?js|ts)$/.test(w));
5684
- return !file || files.has(file.replace(/^\.\//, ""));
5804
+ const file = c.command.split(/\s+/).find((w) => /\.(py|m?js|ts)$/.test(w))?.replace(/^\.\//, "");
5805
+ if (!file) return true;
5806
+ if (/^README/i.test(c.from) && isAside(file)) return false;
5807
+ return files.has(file);
5685
5808
  });
5686
5809
  }
5687
5810
  // ../../packages/scan/src/outline.ts
@@ -5722,7 +5845,8 @@ var where$1 = (f) => ({
5722
5845
  file: f.file,
5723
5846
  line: f.line
5724
5847
  });
5725
- async function buildOutline(look, facts, project) {
5848
+ async function buildOutline(look, facts, project, options = {}) {
5849
+ const budget = options.budget ?? BUDGET;
5726
5850
  const outlines = project.files.map(symbolsOf);
5727
5851
  const known = await localValues(look.root, look.settingsFiles);
5728
5852
  const ranked = [...outlines].sort((a, b) => priority(b) - priority(a));
@@ -5731,7 +5855,7 @@ async function buildOutline(look, facts, project) {
5731
5855
  let size = 0;
5732
5856
  for (const file of ranked) {
5733
5857
  const bytes = JSON.stringify(file).length;
5734
- if (size + bytes > BUDGET) omitted.push(file.file);
5858
+ if (size + bytes > budget) omitted.push(file.file);
5735
5859
  else {
5736
5860
  code.push(file);
5737
5861
  size += bytes;
@@ -6135,7 +6259,7 @@ function draftConfig(input) {
6135
6259
  trigger: previous?.ways_in.find((w) => w.kind === "conversation")?.trigger ?? "Hello, can you help me?"
6136
6260
  });
6137
6261
  for (const fact of wayFacts) {
6138
- const before = previous?.ways_in.find((w) => w.name === fact.name || w.function === fact.function);
6262
+ const before = previous?.ways_in.find((w) => w.name === fact.name || !!fact.function && w.function === fact.function);
6139
6263
  const understood = understanding?.waysIn.find((w) => w.name === fact.name);
6140
6264
  if (before) {
6141
6265
  waysIn.push({
@@ -6368,11 +6492,15 @@ function resolveImport(from, name, known) {
6368
6492
  base = up.slice(0, Math.max(0, up.length - (dots - 1))).join("/");
6369
6493
  } else base = "";
6370
6494
  const path = rest ? base ? `${base}/${rest}` : rest : base;
6371
- return first((dots > 0 ? [""] : [
6495
+ const parts = dir.split("/").filter(Boolean);
6496
+ const ancestors = parts.map((_, i) => `${parts.slice(0, parts.length - i).join("/")}/`);
6497
+ return first((dots > 0 ? [""] : [.../* @__PURE__ */ new Set([
6498
+ ...ancestors,
6372
6499
  "",
6373
6500
  "src/",
6374
- "app/"
6375
- ]).flatMap((root) => [`${root}${path}.py`, `${root}${path}/__init__.py`]));
6501
+ "app/",
6502
+ ...ancestors.map((a) => `${a}src/`)
6503
+ ])]).flatMap((root) => [`${root}${path}.py`, `${root}${path}/__init__.py`]));
6376
6504
  }
6377
6505
  function normalise(path) {
6378
6506
  const out = [];
@@ -6395,15 +6523,36 @@ var LIMITS = {
6395
6523
  maxFileBytes: 1e6
6396
6524
  };
6397
6525
  var CODE_TOOLS = ["grep", "read"];
6398
- /** Every file in the repository the tools could see, skipping what the scan never walks. */
6526
+ var NOT_OPENED = "the scan never opens that folder";
6527
+ /** Whether the scan opens a folder at all: no part of its path is one it never walks. */
6528
+ var opensFolder = (dir) => !dir.split("/").some((part) => SKIPPED.has(part.toLowerCase()));
6529
+ /**
6530
+ * Every file in the repository the tools could see, and so what --explain counts. All of it by hand
6531
+ * rather than by glob, whose `*` leaves out names starting with a dot, which is where settings files
6532
+ * are. A link is not a file here: it could lead outside the repository. ADR 0093.
6533
+ */
6399
6534
  async function repositoryFiles(root, pattern = "**/*", most = 5e3) {
6400
6535
  const found = [];
6536
+ if (pattern === "**/*") {
6537
+ const walk = async (dir) => {
6538
+ const entries = await readdir(join(root, dir), { withFileTypes: true }).catch(() => []);
6539
+ for (const entry of entries) {
6540
+ if (found.length >= most) return;
6541
+ const path = posix.join(dir, entry.name);
6542
+ if (entry.isFile()) found.push(path);
6543
+ else if (entry.isDirectory() && opensFolder(entry.name)) await walk(path);
6544
+ }
6545
+ };
6546
+ await walk("");
6547
+ return found.sort();
6548
+ }
6401
6549
  for await (const entry of glob(pattern, {
6402
6550
  cwd: root,
6403
6551
  exclude: (name) => SKIPPED.has(String(name).split(/[\\/]/).at(-1) ?? "")
6404
6552
  })) {
6405
6553
  const path = String(entry).split("\\").join("/");
6406
- if ((await stat(join(root, path)).catch(() => null))?.isFile()) found.push(path);
6554
+ if (!opensFolder(posix.dirname(path))) continue;
6555
+ if ((await lstat(join(root, path)).catch(() => null))?.isFile()) found.push(path);
6407
6556
  if (found.length >= most) break;
6408
6557
  }
6409
6558
  return found.sort();
@@ -6419,6 +6568,7 @@ function createTools(input) {
6419
6568
  const dir = String(args.dir ?? ".");
6420
6569
  const inRepo = await insideRepository(root, dir);
6421
6570
  if (inRepo === null) return refused(dir, "it is not a folder in this repository");
6571
+ if (!opensFolder(inRepo)) return refused(inRepo, NOT_OPENED);
6422
6572
  const entries = await readdir(join(root, inRepo), { withFileTypes: true }).catch(() => null);
6423
6573
  if (!entries) return refused(dir, "it is not a folder in this repository");
6424
6574
  return {
@@ -6463,6 +6613,7 @@ function createTools(input) {
6463
6613
  if (inRepo === null) return refused(path, "it is not a file in this repository");
6464
6614
  const why = neverRead(inRepo);
6465
6615
  if (why) return refused(inRepo, why);
6616
+ if (!opensFolder(posix.dirname(inRepo))) return refused(inRepo, NOT_OPENED);
6466
6617
  if ((await stat(join(root, inRepo))).size > LIMITS.maxFileBytes) return refused(inRepo, "it is over 1 MB");
6467
6618
  const all = (await readFile(join(root, inRepo), "utf8")).split(/\r?\n/);
6468
6619
  const from = Math.max(1, Number(args.from ?? (args.to === void 0 ? 1 : args.to - LIMITS.readLines + 1)));
@@ -6924,7 +7075,7 @@ async function answerQuestion(input) {
6924
7075
  async function scanFolder(root) {
6925
7076
  const found = await look(root);
6926
7077
  const { facts, adapters, project } = await readFacts(found);
6927
- found.startCommands.push(...await startCandidates(found, project));
7078
+ found.startCommands = rankStart([...found.startCommands, ...await startCandidates(found, project)]);
6928
7079
  return {
6929
7080
  look: found,
6930
7081
  facts,
@@ -20713,6 +20864,23 @@ async function scriptsIn(dir) {
20713
20864
  return {};
20714
20865
  }
20715
20866
  }
20867
+ var SAME_INTERPRETER = {
20868
+ python: "python3",
20869
+ python3: "python",
20870
+ pip: "pip3",
20871
+ pip3: "pip"
20872
+ };
20873
+ /**
20874
+ * The same command under the interpreter's name this machine has: `python main.py` where only
20875
+ * `python3` is installed, as on most Linux and Mac machines, or the reverse on Windows. A command
20876
+ * whose first word is here already is returned as it is.
20877
+ */
20878
+ async function interpreterHere(command) {
20879
+ const [first = "", ...rest] = command.trim().split(/\s+/);
20880
+ const other = SAME_INTERPRETER[first];
20881
+ if (!other || await onPath(first) || !await onPath(other)) return command;
20882
+ return [other, ...rest].join(" ");
20883
+ }
20716
20884
  /**
20717
20885
  * Why `command` cannot start the app in `dir`, in one sentence, or null when it can as far as can
20718
20886
  * be told without running it.
@@ -21305,7 +21473,7 @@ function toFix(ctx, fix) {
21305
21473
  }
21306
21474
  };
21307
21475
  case "run-command": return {
21308
- label: `${fix.label}: ${fix.command}`,
21476
+ label: fix.label.includes(fix.command) ? fix.label : `${fix.label}: ${fix.command}`,
21309
21477
  needsPerson: true,
21310
21478
  apply: async () => {
21311
21479
  if (!interactive()) return;
@@ -21350,7 +21518,7 @@ var DONT_KNOW = /^(i\s+)?(don'?t|do not)\s+know|^not\s+sure|^no\s+idea|^\?$/i;
21350
21518
  */
21351
21519
  async function typeTheCommand(ctx) {
21352
21520
  for (let tries = 0; tries < 3; tries++) {
21353
- const typed = (await askText("How do you start the app?")).trim();
21521
+ const typed = await interpreterHere((await askText("How do you start the app?")).trim());
21354
21522
  if (!typed || DONT_KNOW.test(typed)) return;
21355
21523
  const why = await whyNotRunnable(ctx.dir, typed);
21356
21524
  if (!why) {
@@ -21367,13 +21535,14 @@ async function typeTheCommand(ctx) {
21367
21535
  async function candidateFixes(ctx) {
21368
21536
  const fixes = [];
21369
21537
  for (const c of ctx.startCommands) {
21370
- if (c.command === ctx.config.start?.command) continue;
21371
- if (await whyNotRunnable(ctx.dir, c.command)) continue;
21538
+ const command = await interpreterHere(c.command);
21539
+ if (command === ctx.config.start?.command) continue;
21540
+ if (await whyNotRunnable(ctx.dir, command)) continue;
21372
21541
  fixes.push({
21373
- label: `${c.command} (from ${c.from})`,
21542
+ label: `${command} (from ${c.from})`,
21374
21543
  apply: () => void (ctx.config.start = {
21375
21544
  ...ctx.config.start,
21376
- command: c.command
21545
+ command
21377
21546
  })
21378
21547
  });
21379
21548
  }
@@ -21389,7 +21558,11 @@ var notKnown = {
21389
21558
  /** Starts the pool, or fails with what happened and how to fix it. */
21390
21559
  async function startApp(ctx, instances) {
21391
21560
  const { config } = ctx;
21392
- const command = config.start?.command;
21561
+ const command = config.start?.command ? await interpreterHere(config.start.command) : void 0;
21562
+ if (command && config.start && command !== config.start.command) config.start = {
21563
+ ...config.start,
21564
+ command
21565
+ };
21393
21566
  const typeIt = {
21394
21567
  label: "Type the command",
21395
21568
  apply: () => typeTheCommand(ctx)
@@ -22103,6 +22276,7 @@ async function scanCommand(dir, options) {
22103
22276
  say(`\nNever sent, whatever is asked (${neverSent.length.toString()}):`);
22104
22277
  for (const n of neverSent) say(` ${n.path}, because ${n.why}`);
22105
22278
  if (neverSent.length === 0) say(" nothing here matches");
22279
+ say("\nThe scan never opens folders such as .git, node_modules and tests, so nothing in them is listed or read.");
22106
22280
  say(`\nWhat was actually sent on earlier runs is in .modelos-secure/sent.jsonl.`);
22107
22281
  return;
22108
22282
  }
@@ -22157,7 +22331,7 @@ async function scanCommand(dir, options) {
22157
22331
  label: "Enter a token now",
22158
22332
  recommended: true,
22159
22333
  apply: async () => {
22160
- const { loginCommand } = await import("./assets/login-CvPyhFJ7.js");
22334
+ const { loginCommand } = await import("./assets/login-Dz4o-1cM.js");
22161
22335
  await loginCommand();
22162
22336
  const fresh = await readConnection();
22163
22337
  api = fresh ? apiClient(fresh) : null;
@@ -22275,7 +22449,7 @@ async function scanCommand(dir, options) {
22275
22449
  config = treatToolAs(config, tool.name, state.answers[id]);
22276
22450
  }
22277
22451
  if (drafted.config.decided_for_you?.length) say(` ${grey(`I hit the limit of 7 questions, so I made the safer choice on ${plural(drafted.config.decided_for_you.length, "tool")} and recorded it for review in modelos-secure.yaml.`)}`);
22278
- const candidates = [...new Map([...reading?.verified.start ?? [], ...result.look.startCommands].map((c) => [c.command, c])).values()];
22452
+ const candidates = [...new Map([...reading?.verified.start ?? [], ...result.look.startCommands].map((c) => [c.command, c])).values()].slice(0, 6);
22279
22453
  if (!previous?.start?.command && candidates.length > 1 && !options.ci) {
22280
22454
  const command = (state.answers.start ?? (options.yes ? candidates[0].command : await ask("How should the scan start the app?", "It starts the app on this machine, with the hooks attached, to check the connection.", [...candidates.map((c, i) => ({
22281
22455
  label: `${c.command} (${c.from})`,
@@ -22327,12 +22501,13 @@ async function scanCommand(dir, options) {
22327
22501
  const paths = (config.ways_in.length || 1) * (config.rules.length || 1);
22328
22502
  say(`\n ${bold(`${config.system} is connected.`)}\n`);
22329
22503
  say(` ${`${plural(config.ways_in.length, "way")} in`.padEnd(14)} ${config.ways_in.map((w) => w.name).join(", ")}`);
22330
- say(` ${plural(config.tools.length, "tool").padEnd(14)} ${plural(held(config).length, "tool")} held during reviews: ${held(config).join(", ")}`);
22504
+ const heldNow = held(config);
22505
+ say(` ${plural(config.tools.length, "tool").padEnd(14)} ${config.tools.length === 0 ? "none to hold" : heldNow.length === 0 ? "none held during reviews" : `${heldNow.length.toString()} held during reviews: ${heldNow.join(", ")}`}`);
22331
22506
  say(` ${plural(paths, "path").padEnd(14)} none tested yet\n`);
22332
22507
  const url = connection ? `${connection.url}/systems/${system.id}` : "";
22333
22508
  say(` Map: ${url}`);
22334
- if (options.noReview || options.ci) return;
22335
- if (options.yes ? false : await ask("Start a quick first review now? About 15 minutes, 1,200 attempts.", "It starts the app on this machine with the hooks attached, checks they hold what they should, then reviews it.", [{
22509
+ if (options.noReview || options.ci || options.yes || !interactive()) return;
22510
+ if (await ask("Start a quick first review now? About 15 minutes, 1,200 attempts.", "It starts the app on this machine with the hooks attached, checks they hold what they should, then reviews it.", [{
22336
22511
  label: "Yes, start it",
22337
22512
  value: true,
22338
22513
  recommended: true
@@ -22401,8 +22576,8 @@ async function sessionCommand(dir, run) {
22401
22576
  for (;;) {
22402
22577
  say();
22403
22578
  const line = await askText(">");
22404
- const word = line.toLowerCase();
22405
- if (!line || word === "quit" || word === "exit") return;
22579
+ const word = line.toLowerCase().replace(/\(\)$/, "");
22580
+ if (!line || word === "quit" || word === "exit" || word === "q") return;
22406
22581
  if (word === "help") {
22407
22582
  run.help();
22408
22583
  continue;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "modelos-secure",
3
- "version": "0.3.2",
3
+ "version": "0.3.4",
4
4
  "description": "Tests AI systems for Security, Safety and Quality. One command connects a system from its code.",
5
5
  "license": "UNLICENSED",
6
6
  "repository": {
@@ -1 +0,0 @@
1
- export { t as loginCommand } from "./login-DeAJGhiN.js";