@lucasschirm/devinp 0.1.13 → 0.1.14

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 (3) hide show
  1. package/README.md +14 -6
  2. package/bin/command.js +52 -18
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -31,24 +31,32 @@ when a script calls it: the model list comes back paged ("click to show more")
31
31
  and the JSON never reaches stdout, so `devinp` used to die with *"returned
32
32
  invalid JSON (… 0 bytes)"*. It now works harder to get the whole document:
33
33
 
34
- - Prices are captured through a temp file in `os.tmpdir()` — a pipe stream from
35
- `devin` can be truncated mid-write, so `devinp` points `devin`'s stdout at a
36
- real file first. The temp file is always removed after the run.
34
+ - **A pipe first, so `TMPDIR` cannot break anything.** The happy path captures
35
+ `devin`'s stdout over a pipe — no scratch file, no permissions, no sandbox.
36
+ Measured against the real CLI, the piped document is byte-identical to the
37
+ file capture (471 855 bytes, 46 families), so the old "pipes truncate" worry
38
+ that put a temp file first was misplaced; an unwritable or missing `TMPDIR`
39
+ used to cost two extra `devin` runs, and a missing one even reported *"`devin`
40
+ CLI not found in PATH"*.
37
41
  - **Retries that take the terminal away from `devin`.** If an attempt yields no
38
42
  usable JSON, `devinp` runs `devin` again, each time stripping more of the
39
43
  interactive terminal off it:
40
44
 
41
45
  | # | stdout goes to | environment | controlling terminal |
42
46
  | - | ------------ | -------------------------------------------------------- | --------------------- |
43
- | 1 | temp file | yours, untouched | yes — the healthy path, one `devin` call |
44
- | 2 | temp file | `PAGER=cat`, `GIT_PAGER=cat`, `TERM=dumb`, `NO_COLOR=1`, `CI=1` | **none** |
45
- | 3 | pipe | same | **none** |
47
+ | 1 | pipe | yours, untouched | yes — the healthy path, one `devin` call |
48
+ | 2 | pipe | `PAGER=cat`, `GIT_PAGER=cat`, `TERM=dumb`, `NO_COLOR=1`, `CI=1` | **none** |
49
+ | 3 | scratch file | same | **none** |
46
50
 
47
51
  Spawned in its own session, `devin` cannot open `/dev/tty`: a pager or a TUI
48
52
  that paints a "click to show more" view onto the terminal has nowhere to
49
53
  draw, so the document has to come out of stdout. Nothing lands on the screen
50
54
  you are typing in, and Ctrl-C there no longer kills the lookup. The first
51
55
  attempt that parses wins, so the healthy path is unchanged.
56
+ - **The scratch file (attempt 3) walks candidate directories** — `DEVINP_TMP`,
57
+ `os.tmpdir()`, `~/.devinp`, then the current directory — and uses the first
58
+ one it can actually write, removing the file afterwards. Set `DEVINP_TMP` to
59
+ pin it somewhere you control.
52
60
  - Noise around the document is tolerated: ANSI colours, an update banner in
53
61
  front, or the `pass a slug to --model` hint behind are stripped before the
54
62
  JSON is parsed.
package/bin/command.js CHANGED
@@ -26885,7 +26885,7 @@ var require_sonic_boom = __commonJS({
26885
26885
  if (!(this instanceof SonicBoom)) {
26886
26886
  return new SonicBoom(opts);
26887
26887
  }
26888
- let { fd, dest, minLength, maxLength, maxWrite, periodicFlush, sync, append = true, mkdir, retryEAGAIN, fsync, contentMode, mode } = opts || {};
26888
+ let { fd, dest, minLength, maxLength, maxWrite, periodicFlush, sync, append = true, mkdir: mkdir2, retryEAGAIN, fsync, contentMode, mode } = opts || {};
26889
26889
  fd = fd || dest;
26890
26890
  this._len = 0;
26891
26891
  this.fd = -1;
@@ -26910,7 +26910,7 @@ var require_sonic_boom = __commonJS({
26910
26910
  this.append = append || false;
26911
26911
  this.mode = mode;
26912
26912
  this.retryEAGAIN = retryEAGAIN || (() => true);
26913
- this.mkdir = mkdir || false;
26913
+ this.mkdir = mkdir2 || false;
26914
26914
  let fsWriteSync;
26915
26915
  let fsWrite;
26916
26916
  if (contentMode === kContentModeBuffer) {
@@ -55024,8 +55024,8 @@ async function startServer({ rows, benchMeta, port, log }) {
55024
55024
 
55025
55025
  // src/lib/source.js
55026
55026
  import { spawn } from "node:child_process";
55027
- import { open, readFile as readFile2, unlink } from "node:fs/promises";
55028
- import { tmpdir } from "node:os";
55027
+ import { mkdir, open, readFile as readFile2, unlink } from "node:fs/promises";
55028
+ import { homedir, tmpdir } from "node:os";
55029
55029
  import { join as join2 } from "node:path";
55030
55030
 
55031
55031
  // src/lib/repair-json.js
@@ -55097,9 +55097,9 @@ var NON_INTERACTIVE_ENV = {
55097
55097
  };
55098
55098
  var DETACHED_SPAWN = { detached: true, windowsHide: true };
55099
55099
  var STRATEGIES = [
55100
- { label: "temp file", file: true, env: null },
55101
- { label: "temp file, paging disabled, no controlling terminal", file: true, env: NON_INTERACTIVE_ENV, detached: true },
55102
- { label: "pipe, paging disabled, no controlling terminal", file: false, env: NON_INTERACTIVE_ENV, detached: true }
55100
+ { label: "pipe", file: false, env: null },
55101
+ { label: "pipe, paging disabled, no controlling terminal", file: false, env: NON_INTERACTIVE_ENV, detached: true },
55102
+ { label: "temp file, paging disabled, no controlling terminal", file: true, env: NON_INTERACTIVE_ENV, detached: true }
55103
55103
  ];
55104
55104
  var ANSI = /\u001b\[[0-9;?]*[ -/]*[@-~]|\u001b\][\s\S]*?(?:\u0007|\u001b\\)|\u001b[@-Z\\-_]/g;
55105
55105
  function stripAnsi(text) {
@@ -55142,6 +55142,7 @@ function spawnDevin({ command, env, stdout, timeoutMs, detached = false }) {
55142
55142
  stderr += chunk;
55143
55143
  });
55144
55144
  child.on("error", (error) => {
55145
+ error.devinSpawn = true;
55145
55146
  clearTimeout(timer);
55146
55147
  reject(error);
55147
55148
  });
@@ -55151,9 +55152,39 @@ function spawnDevin({ command, env, stdout, timeoutMs, detached = false }) {
55151
55152
  });
55152
55153
  });
55153
55154
  }
55155
+ async function writableScratchDir() {
55156
+ const candidates = [];
55157
+ if (process.env.DEVINP_TMP) candidates.push(process.env.DEVINP_TMP);
55158
+ candidates.push(tmpdir(), join2(homedir(), ".devinp"), process.cwd());
55159
+ for (const dir of candidates) {
55160
+ try {
55161
+ await mkdir(dir, { recursive: true });
55162
+ const probe = join2(dir, `.devinp-probe-${process.pid}`);
55163
+ const handle = await open(probe, "w");
55164
+ await handle.close();
55165
+ await unlink(probe);
55166
+ return dir;
55167
+ } catch {
55168
+ }
55169
+ }
55170
+ return null;
55171
+ }
55154
55172
  async function captureToFile({ command, env, timeoutMs, detached }) {
55155
- const file = join2(tmpdir(), `devinp-models-${process.pid}-${Date.now()}.json`);
55156
- const handle = await open(file, "w");
55173
+ const dir = await writableScratchDir();
55174
+ if (!dir) {
55175
+ const error = new Error(`cannot write a scratch file \u2014 DEVINP_TMP, os.tmpdir() ("${tmpdir()}"), ~/.devinp and "${process.cwd()}" all refused`);
55176
+ error.scratchPath = dir;
55177
+ throw error;
55178
+ }
55179
+ const file = join2(dir, `devinp-models-${process.pid}-${Date.now()}.json`);
55180
+ let handle;
55181
+ try {
55182
+ handle = await open(file, "w");
55183
+ } catch (error) {
55184
+ error.message = `cannot write scratch file "${file}" (${error.code ?? error.message})`;
55185
+ error.scratchPath = file;
55186
+ throw error;
55187
+ }
55157
55188
  try {
55158
55189
  const result = await spawnDevin({ command, env, stdout: handle.fd, timeoutMs, detached });
55159
55190
  result.stdout = await readFile2(file, "utf8");
@@ -55171,7 +55202,7 @@ async function runDevinJson({ command = "devin", timeoutMs = DEVIN_TIMEOUT_MS }
55171
55202
  try {
55172
55203
  result = strategy.file ? await captureToFile({ command, env: strategy.env, timeoutMs, detached: strategy.detached }) : await spawnDevin({ command, env: strategy.env, stdout: "pipe", timeoutMs, detached: strategy.detached });
55173
55204
  } catch (error) {
55174
- if (error.code === "ENOENT") throw error;
55205
+ if (error.code === "ENOENT" && error.devinSpawn) throw error;
55175
55206
  attempts.push({ label: strategy.label, bytes: 0, code: null, stderr: "", timedOut: false, note: error.message });
55176
55207
  continue;
55177
55208
  }
@@ -55185,7 +55216,7 @@ async function runDevinJson({ command = "devin", timeoutMs = DEVIN_TIMEOUT_MS }
55185
55216
  timedOut: result.timedOut,
55186
55217
  raw
55187
55218
  });
55188
- if (parsed != null) return { raw, parsed, attempts, timeoutMs };
55219
+ if (parsed != null) return { raw, parsed, attempts, timeoutMs, via: strategy.label };
55189
55220
  if (index === 0 && result.code !== 0 && !result.timedOut && result.stderr.trim()) {
55190
55221
  throw new Error(`\`devin models list\` failed \u2014 ${result.stderr.trim()}`);
55191
55222
  }
@@ -55194,8 +55225,8 @@ async function runDevinJson({ command = "devin", timeoutMs = DEVIN_TIMEOUT_MS }
55194
55225
  if (explained && attempts.every((a2) => a2.timedOut || a2.code !== 0)) {
55195
55226
  throw new Error(`\`devin models list\` failed \u2014 ${explained.stderr}`);
55196
55227
  }
55197
- const longest = attempts.reduce((acc, a2) => (a2.raw?.length ?? 0) > acc.length ? a2.raw : acc, "");
55198
- return { raw: longest || "", parsed: null, attempts, timeoutMs };
55228
+ const longest = attempts.reduce((acc, a2) => (a2.raw?.length ?? 0) >= acc.length ? a2.raw : acc, "");
55229
+ return { raw: longest || "", parsed: null, attempts, timeoutMs, via: null };
55199
55230
  }
55200
55231
  function assertFamilies(data, sourceName) {
55201
55232
  if (data == null || typeof data !== "object" || !Array.isArray(data.families)) {
@@ -55238,6 +55269,9 @@ function describeFailure(attempts, timeoutMs) {
55238
55269
  );
55239
55270
  }
55240
55271
  lines.push(`Tried: ${attempts.map((a2) => describeAttempt(a2, timeoutMs)).join("; ")}.`);
55272
+ if (attempts.some((a2) => a2.note?.includes("scratch"))) {
55273
+ lines.push("devinp could not write its scratch file \u2014 point it somewhere writable with `DEVINP_TMP=/tmp devinp`.");
55274
+ }
55241
55275
  lines.push("Capture the JSON yourself and hand it to devinp \u2014 piping or redirecting makes `devin` write the whole document in one go:");
55242
55276
  lines.push(" devin models list --format json > models.json && devinp --source models.json");
55243
55277
  lines.push(" devin models list --format json | devinp --source=-");
@@ -55264,7 +55298,7 @@ async function loadModelData({
55264
55298
  throw new Error(`Unable to get model prices: cannot read ${source === "-" ? "stdin" : `source file "${source}"`} (${error.code ?? error.message})`);
55265
55299
  }
55266
55300
  try {
55267
- return { data: assertFamilies(JSON.parse(raw), label), retries: 0 };
55301
+ return { data: assertFamilies(JSON.parse(raw), label), retries: 0, via: source === "-" ? "stdin" : "file" };
55268
55302
  } catch (error) {
55269
55303
  if (error.message.startsWith("Unable to get model prices")) throw error;
55270
55304
  throw new Error(`Unable to get model prices: ${label} is not valid JSON (expected \`devin models list --format json\` output)`);
@@ -55274,17 +55308,17 @@ async function loadModelData({
55274
55308
  try {
55275
55309
  outcome = await runDevinJson({ command, timeoutMs });
55276
55310
  } catch (error) {
55277
- if (error.code === "ENOENT") {
55311
+ if (error.code === "ENOENT" && error.devinSpawn) {
55278
55312
  throw new Error("Unable to get model prices: `devin` CLI not found in PATH. Install it, or pass --source <file> / --source=- to supply the JSON yourself.");
55279
55313
  }
55280
55314
  throw new Error(`Unable to get model prices: ${error.message}`);
55281
55315
  }
55282
55316
  if (outcome.parsed != null) {
55283
- return { data: assertFamilies(outcome.parsed, "devin models list"), retries: outcome.attempts.length - 1 };
55317
+ return { data: assertFamilies(outcome.parsed, "devin models list"), retries: outcome.attempts.length - 1, via: outcome.via };
55284
55318
  }
55285
55319
  const recovered = recoverTruncatedJson(outcome.raw);
55286
55320
  if (recovered != null) {
55287
- return { data: assertFamilies(recovered, "devin models list (recovered)"), truncated: true, retries: outcome.attempts.length - 1 };
55321
+ return { data: assertFamilies(recovered, "devin models list (recovered)"), truncated: true, retries: outcome.attempts.length - 1, via: outcome.via ?? "repaired" };
55288
55322
  }
55289
55323
  throw new Error(describeFailure(outcome.attempts, outcome.timeoutMs));
55290
55324
  }
@@ -55345,7 +55379,7 @@ var Devinp = class _Devinp extends import_core.Command {
55345
55379
  this.warn("devin output was truncated mid-stream \u2014 showing partial data, re-run for the full list");
55346
55380
  }
55347
55381
  if (loaded.retries > 0) {
55348
- this.warn(`devin needed ${loaded.retries + 1} attempts to hand over the model list \u2014 something on this machine (pager, paged "show more" view, wrapper) interferes with \`devin models list\``);
55382
+ this.warn(`devin needed ${loaded.retries + 1} attempts to hand over the model list \u2014 the data arrived via ${loaded.via ?? "a fallback"} (something on this machine interferes with \`devin models list\`; DEVINP_TMP sets the scratch directory)`);
55349
55383
  }
55350
55384
  data = loaded.data;
55351
55385
  } catch (error) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lucasschirm/devinp",
3
- "version": "0.1.13",
3
+ "version": "0.1.14",
4
4
  "description": "Pretty pricing table for Devin models, powered by `devin models list`",
5
5
  "type": "module",
6
6
  "bin": {