cortad 0.1.11 → 0.1.12

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/lib/start.mjs CHANGED
@@ -1,7 +1,7 @@
1
1
  // How their app is started, and from which folder, worked out from the repository itself so nobody
2
2
  // is asked. A monorepo root has no app of its own: the app is the workspace that serves the AI.
3
3
  import { existsSync, readdirSync, readFileSync, statSync } from "node:fs";
4
- import { join, relative } from "node:path";
4
+ import { dirname, join, relative } from "node:path";
5
5
 
6
6
  const read = (file) => { try { return readFileSync(file, "utf8"); } catch { return ""; } };
7
7
  const json = (file) => { try { return JSON.parse(read(file)); } catch { return null; } };
@@ -23,6 +23,18 @@ const SERVES = /uvicorn\.run|FastAPI\(|Flask\(|Starlette\(|Litestar\(|Quart\(|ap
23
23
  // The same question of a package script: does this command put something on a port, or run a task
24
24
  // and exit. `crewai run` and `python main.py` are tasks; `next dev` and `nodemon server.js` serve.
25
25
  const SERVES_JS = /\b(?:next|nuxt|vite|nest|remix|astro|sveltekit|serve|nodemon|ts-node-dev|uvicorn|gunicorn|rails|strapi)\b/;
26
+ // Where this workspace's install lives: the nearest folder at or above it holding a lockfile, never
27
+ // past the repository the customer ran the command in.
28
+ const LOCKS = ["pnpm-lock.yaml", "yarn.lock", "bun.lockb", "bun.lock", "package-lock.json"];
29
+ export function lockRoot(dir, root) {
30
+ const inside = (d) => d === root || d.startsWith(root.endsWith("/") ? root : `${root}/`);
31
+ for (let at = dir; inside(at); at = dirname(at)) {
32
+ if (LOCKS.some((l) => existsSync(join(at, l)))) return at;
33
+ if (at === dirname(at)) break;
34
+ }
35
+ return dir;
36
+ }
37
+
26
38
  // The manager this repository was installed with, named by its lockfile.
27
39
  const manager = (dir) => (existsSync(join(dir, "pnpm-lock.yaml")) ? "pnpm" : existsSync(join(dir, "yarn.lock")) ? "yarn" : existsSync(join(dir, "bun.lockb")) || existsSync(join(dir, "bun.lock")) ? "bun" : "npm");
28
40
 
@@ -121,13 +133,18 @@ export const missingDependency = (said) => MISSING.exec(String(said ?? ""))?.sli
121
133
  // One install, with the manager the project locked. Never a global one: a Python project without an
122
134
  // interpreter of its own gets a virtual environment beside its code, so nothing installed here
123
135
  // reaches the rest of the machine.
124
- export function installPlan(dir, onPath) {
136
+ export function installPlan(dir, onPath, root = dir) {
125
137
  if (json(join(dir, "package.json"))) {
138
+ // A workspace member holds no lockfile of its own: the manager, and the install, belong to the
139
+ // repository that owns it. novel's apps/web installed with npm here, and npm cannot read the
140
+ // `workspace:^` ranges its own packages are pinned with: "Unsupported URL Type".
141
+ const at = lockRoot(dir, root);
126
142
  // The manager the repository locked, fetched for the job when this machine has not got it: npm
127
143
  // refuses vercel's ai-chatbot outright over a peer range pnpm resolves without a word.
128
- const locked = manager(dir);
129
- if (locked !== "npm") return onPath(locked) ? `${locked} install` : `npx --yes ${locked} install`;
130
- return existsSync(join(dir, "package-lock.json")) ? "npm ci || npm install --legacy-peer-deps" : "npm install || npm install --legacy-peer-deps";
144
+ const locked = manager(at);
145
+ const where = at === dir ? "" : `cd ${JSON.stringify(at)} && `;
146
+ if (locked !== "npm") return `${where}${onPath(locked) ? `${locked} install` : `npx --yes ${locked} install`}`;
147
+ return `${where}${existsSync(join(at, "package-lock.json")) ? "npm ci || npm install --legacy-peer-deps" : "npm install || npm install --legacy-peer-deps"}`;
131
148
  }
132
149
  if (existsSync(join(dir, "uv.lock")) && onPath("uv")) return "uv sync";
133
150
  if (existsSync(join(dir, "poetry.lock")) && onPath("poetry")) return "poetry install";
@@ -0,0 +1,47 @@
1
+ // Switches an app reads to decide who it lets in, set for the app this command started and for
2
+ // nothing else. The customer's files are never written, and every name is printed before the app
3
+ // starts. A lockout, an attempt counter and a password rule are never touched.
4
+ import { readFileSync, statSync } from "node:fs";
5
+
6
+ const GUARDED = /(AUTH|LOGIN|PASSWORD|BREAKER|LOCKOUT|ATTEMPT|FAIL|BAN|BLOCK)/;
7
+ const ENV_READ = /(?:process\.env(?:\.|\[\s*['"])|os\.(?:environ\.get|getenv)\(\s*['"]|os\.environ\[\s*['"]|\benv\(\s*['"]|Deno\.env\.get\(\s*['"])([A-Z][A-Z0-9_]*)/g;
8
+
9
+ // A switch their own app reads to decide whether a caller has to be signed in at all. morphic's
10
+ // chat answers 401 to everyone until ENABLE_GUEST_CHAT is true; open-webui, librechat and a dozen
11
+ // others carry the same idea under their own name. Set for the app this command started and for
12
+ // nothing else: their files are not touched, and the name is printed before anything starts.
13
+ // A lockout, an attempt counter and a password rule carry the same words and are never touched.
14
+ const OPENS_AUTH = /^(?:ENABLE|REQUIRE)_AUTH(?:ENTICATION)?$|^AUTH(?:ENTICATION)?_(?:ENABLED|REQUIRED)$|^(?:REQUIRE|ENABLE)_(?:LOGIN|SIGN_?IN)$/;
15
+ const CLOSES_AUTH = /^(?:DISABLE|SKIP|NO)_AUTH(?:ENTICATION)?$|^AUTH(?:ENTICATION)?_DISABLED$/;
16
+ const OPENS_GUEST = /^(?:ENABLE|ALLOW)_(?:GUEST|ANONYMOUS|PUBLIC)(?:_[A-Z0-9_]+)?$|^(?:GUEST|ANONYMOUS|PUBLIC)_(?:MODE|ACCESS|CHAT|ENABLED|LOGIN)$/;
17
+ const CLOSES_GUEST = /^(?:DISABLE|BLOCK)_(?:GUEST|ANONYMOUS|PUBLIC)(?:_[A-Z0-9_]+)?$|^(?:GUEST|ANONYMOUS|PUBLIC)_(?:MODE|ACCESS|CHAT)_DISABLED$/;
18
+ const BOOLEAN = /^(?:true|false|1|0|yes|no|on|off)$/i;
19
+ export function openSwitches(envFiles, sources = []) {
20
+ const out = {};
21
+ const open = (name) => {
22
+ if (GUARDED.test(name) && !OPENS_AUTH.test(name) && !CLOSES_AUTH.test(name)) return null;
23
+ if (OPENS_AUTH.test(name) || CLOSES_GUEST.test(name)) return "false";
24
+ if (CLOSES_AUTH.test(name) || OPENS_GUEST.test(name)) return "true";
25
+ return null;
26
+ };
27
+ for (const file of envFiles) {
28
+ let text = "";
29
+ try { text = readFileSync(file, "utf8"); } catch { continue; }
30
+ for (const line of text.split("\n")) {
31
+ const m = /^\s*(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(.*)$/.exec(line);
32
+ if (!m) continue;
33
+ const value = m[2].trim().replace(/^(['"])(.*)\1$/, "$2");
34
+ const to = open(m[1]);
35
+ // Only a switch: a name of this shape holding a URL or a key is something else entirely.
36
+ if (to && (value === "" || BOOLEAN.test(value))) out[m[1]] = to;
37
+ }
38
+ }
39
+ for (const file of sources) {
40
+ if (!/\.(?:[cm]?[jt]sx?|py|go|rb|php|rs)$/.test(file)) continue;
41
+ let text = "";
42
+ try { if (statSync(file).size > 512_000) continue; text = readFileSync(file, "utf8"); } catch { continue; }
43
+ for (const m of text.matchAll(ENV_READ)) { const to = open(m[1]); if (to && !(m[1] in out)) out[m[1]] = to; }
44
+ }
45
+ return out;
46
+ }
47
+
package/local.mjs CHANGED
@@ -26,6 +26,7 @@ import { sampleHere } from "./lib/sample.mjs";
26
26
  import { mintAcross, originFor, waitForPort } from "./lib/service.mjs";
27
27
  import { listingUrl } from "./lib/listing.mjs";
28
28
  import { installPlan, missingDependency, startPlan, workspaces } from "./lib/start.mjs";
29
+ import { openSwitches } from "./lib/switches.mjs";
29
30
 
30
31
  const argv = process.argv.slice(2);
31
32
  const flag = (name) => { const i = argv.indexOf(name); return i >= 0 ? argv[i + 1] : undefined; };
@@ -510,15 +511,23 @@ async function startApp() {
510
511
  appDir = plan?.cwd ?? root;
511
512
  pinned = pinnedNode();
512
513
  if (plan?.within) say(`your app is in ${plan.within}, started there with: ${cmd}`);
513
- const lifted = liftedLimits(envFiles, files.map((f) => join(root, f)));
514
- if (Object.keys(lifted).length) say(`higher request limits for this session: ${Object.keys(lifted).join(", ")}`);
514
+ const sourceFiles = files.map((f) => join(root, f));
515
+ const lifted = { ...liftedLimits(envFiles, sourceFiles), ...openSwitches(envFiles, sourceFiles) };
516
+ const raised = Object.keys(liftedLimits(envFiles, sourceFiles));
517
+ const opened = Object.keys(openSwitches(envFiles, sourceFiles));
518
+ if (raised.length) say(`higher request limits for this session: ${raised.join(", ")}`);
519
+ // Said out loud, because it changes who their app lets in for as long as this command runs.
520
+ if (opened.length) say(`your app's own sign-in switch, for this session only: ${opened.map((n) => `${n}=${lifted[n]}`).join(", ")}`);
515
521
  launched = { cmd, lifted };
516
522
  step(`starting your app: ${cmd}`);
517
523
  const up = await launch(180_000);
518
524
  if (up.port) return { port: up.port, cmd: launched.cmd, lifted: Object.keys(lifted) };
519
525
  // Already running: a second start dies on the port the first one holds. The one that is running
520
526
  // is the app, so it is used as it stands rather than treated as a failure.
521
- if (up.exited !== null && /EADDRINUSE|address already in use|port.{0,40}(?:in use|already used|is taken|unavailable)/i.test(up.tail)) {
527
+ // "Another next dev server is already running" is the same fact in a framework's own words: their
528
+ // app is up, started from the terminal they were already working in, and the port it holds is not
529
+ // always the one we asked for. Their running app is the app.
530
+ if (up.exited !== null && /EADDRINUSE|address already in use|port.{0,40}(?:in use|already used|is taken|unavailable)|another .{0,20}(?:dev )?server is already running|already running (?:on|at) (?:http|port)/i.test(up.tail)) {
522
531
  const ports = [...up.tail.matchAll(/(?::|port\s*[:=]?\s*)(\d{4,5})\b/gi)].map((m) => Number(m[1]));
523
532
  for (const port of new Set(ports)) {
524
533
  if (await answers(port)) {
@@ -621,7 +630,7 @@ let installSaid = "";
621
630
  async function installOnce(said) {
622
631
  if (installed) return false;
623
632
  const name = missingDependency(said);
624
- const cmd = name && installPlan(appDir, onPath);
633
+ const cmd = name && installPlan(appDir, onPath, root);
625
634
  if (!cmd) return false;
626
635
  installed = true;
627
636
  stepDone(`your app needs ${name}, which is not installed here`);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cortad",
3
- "version": "0.1.11",
3
+ "version": "0.1.12",
4
4
  "description": "Connects the AI app on your machine to Cortad for test conversations. No dependencies.",
5
5
  "bin": {
6
6
  "cortad": "local.mjs"
@@ -18,6 +18,7 @@
18
18
  "lib/sample.mjs",
19
19
  "lib/service.mjs",
20
20
  "lib/start.mjs",
21
+ "lib/switches.mjs",
21
22
  "lib/trace.cjs",
22
23
  "local.mjs"
23
24
  ],