@instafy/cli 0.4.1 → 0.5.1

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/README.md CHANGED
@@ -59,6 +59,8 @@ Run Instafy spaces locally and connect them back to Instafy Studio — from any
59
59
  - `instafy web read <url…> --json` — read up to 6 public pages over plain HTTP and get their text, links and structured data with provenance, wrapped as untrusted page content. robots.txt (including AI-agent user agents), the web-research skill's `no-agents` link-only sites (built in, plus the space's additions) and translators', caches' and archives' copies of them, private-address checks at every redirect, stop rules and per-turn caps are enforced; a `no-reuse` site is read and marked `noReuse`; see [docs/CLI.md](https://github.com/instafy-dev/instafy/blob/main/docs/CLI.md#reading-web-pages-instafy-web-read).
60
60
  - `instafy web search "<query>" --json` — find links to read, in Octo's turns only, with the space's own search key (the web-research skill's providers: Brave, then Kagi). Results are untrusted leads: a link-only site's result keeps only its link and site name, keys go only to their provider's host, nothing is cached, and the room keeps only the query and the count; see [docs/CLI.md](https://github.com/instafy-dev/instafy/blob/main/docs/CLI.md#searching-for-links-instafy-web-search).
61
61
  - `instafy web link <site> [name=value…] --json` — the link the person opens in their own tab for a `no-agents` site (guided mode): the site's search template from the web-research skill's list, filled with checked, URL-encoded values, or its home page and what to type there when it bars deep links. Reads nothing.
62
+ - `instafy web budget --json` — inspect remaining fresh-page reads shared by the current task and its helpers. Cached pages are free.
63
+ - `instafy web settings --interactive` — space managers configure total and per-site page allowances for new tasks; running tasks retain their original budget. Use `--max-page-loads` and `--max-page-loads-per-site` for scripts.
62
64
  - `instafy secrets list` — list space secret metadata (names/descriptions, never values).
63
65
  - `instafy secrets get <name-or-id>` — inspect one space secret metadata entry.
64
66
  - `instafy secrets put <name> --value ...` — create/update a space secret.
@@ -48,7 +48,8 @@ serve Serve the site (or every subfolder with a bound.json) with the binder
48
48
  given). --agent-conversation <id> uses an existing conversation instead. --agent-budget caps Octo
49
49
  turns per site per hour (repairs included). --dry-run prints those prompts instead.
50
50
  --detach: start the binder in the background (its own session, output to --log, default
51
- <tmp>/instafy-bind-<port>.log), wait until it answers, print where it serves and exit. A server
51
+ <tmp>/instafy-bind-<port>.log), wait until it answers as itself, print where it serves and exit. A
52
+ binder of this folder already serving on the port is kept; another server there is refused. A server
52
53
  started in an agent turn without it stops when the turn ends.
53
54
  In a folder of an Instafy space, the binder saves the data files it writes for clicks (records,
54
55
  data/version.json): it commits them to the space's checkout a few seconds after the last write, and
@@ -119,6 +120,15 @@ function detachedServeArgs(argv) {
119
120
  return out;
120
121
  }
121
122
 
123
+ /** The folder's real path, or the path itself when it cannot be resolved. */
124
+ function realOr(dir) {
125
+ try {
126
+ return fs.realpathSync(path.resolve(String(dir)));
127
+ } catch {
128
+ return path.resolve(String(dir));
129
+ }
130
+ }
131
+
122
132
  async function serveDetached(dir, flags) {
123
133
  const port = flags.port === undefined ? 4180 : Number(flags.port);
124
134
  if (!Number.isInteger(port) || port < 1 || port > 65535) fail("--detach needs a fixed --port");
@@ -132,16 +142,39 @@ async function serveDetached(dir, flags) {
132
142
  fs.closeSync(fd);
133
143
  child.unref();
134
144
  const host = flags.host || "127.0.0.1";
145
+ const folder = realOr(dir);
146
+ // A gallery answers under each site's folder: ask the first one.
147
+ let prefix = "";
148
+ try {
149
+ prefix = discoverSites(dir, { plain: true })[0]?.prefix ?? "";
150
+ } catch {
151
+ // the binder says why in its log
152
+ }
153
+ const running = () => child.exitCode === null && child.signalCode === null;
135
154
  const deadline = Date.now() + 20_000;
136
155
  let up = false;
137
- while (Date.now() < deadline) {
138
- if (child.exitCode !== null) break;
156
+ // Whether something else answered on the port: another site's server, or another binder.
157
+ let other = false;
158
+ // A binder of this same folder that was already serving on the port (an earlier --detach): it is kept.
159
+ let earlier = null;
160
+ while (Date.now() < deadline && running()) {
139
161
  try {
140
- const response = await fetch(`http://${host.includes(":") ? `[${host}]` : host}:${port}/__bind/health`);
141
- if (response.status < 500) {
142
- up = true;
143
- break;
162
+ const response = await fetch(`http://${host.includes(":") ? `[${host}]` : host}:${port}${prefix}/__bind/health`, { signal: AbortSignal.timeout(2_000) });
163
+ // Only this site's own binder counts: the folder it serves and the process it runs in. The child started here
164
+ // while it still runs, or a binder of this same folder that already held the port, whose answer is never taken
165
+ // for the child's. Any other answer on the port is another server, never this site being served.
166
+ const binder = (await response.json().catch(() => null))?.binder;
167
+ if (response.ok && binder && realOr(binder.root) === folder && Number.isInteger(binder.pid)) {
168
+ if (binder.pid === child.pid && running()) {
169
+ up = true;
170
+ break;
171
+ }
172
+ if (binder.pid !== child.pid) {
173
+ earlier = binder.pid;
174
+ break;
175
+ }
144
176
  }
177
+ other = true;
145
178
  } catch {
146
179
  // not listening yet
147
180
  }
@@ -154,7 +187,22 @@ async function serveDetached(dir, flags) {
154
187
  return "";
155
188
  }
156
189
  };
157
- if (!up) fail(`the background binder did not start (log ${logFile}):\n${tail()}`);
190
+ if (!up) {
191
+ // A binder that never answered as itself is not left running in the background.
192
+ if (running()) {
193
+ try {
194
+ process.kill(child.pid, "SIGTERM");
195
+ } catch {
196
+ // already gone
197
+ }
198
+ }
199
+ if (earlier !== null) {
200
+ process.stdout.write(`instafy-bind already serves ${dir} on port ${port} in the background (pid ${earlier}); it keeps running after this turn.\n`);
201
+ return;
202
+ }
203
+ if (other) fail(`port ${port} is in use by another server, so ${dir} is not served there; pick another --port (log ${logFile}):\n${tail()}`);
204
+ fail(`the background binder did not start (log ${logFile}):\n${tail()}`);
205
+ }
158
206
  process.stdout.write(`instafy-bind serving ${dir} on port ${port} in the background (pid ${child.pid}; log ${logFile}); it keeps running after this turn.\n`);
159
207
  const shown = tail().split("\n").filter((line) => /show it in Studio|public link|agent executor|intents:/.test(line));
160
208
  for (const line of shown) process.stdout.write(`${line}\n`);
@@ -4,8 +4,8 @@
4
4
  // every string in its data files (data.json, data/**/*.json, records included). Items come from --expect and from
5
5
  // an "expect" array in data.json. Text that only says an item is unavailable ("Not found yet: Tokyo", a data file's
6
6
  // "missing" list) does not count as showing it: builders are told to say on the page what they could not add, and
7
- // that line must not pass the check. On a page of options it also checks the values (below). Pure helpers are
8
- // exported for the tests.
7
+ // that line must not pass the check. On a page of options it also checks the values and where each row comes from
8
+ // (below). Pure helpers are exported for the tests.
9
9
  // SPDX-License-Identifier: AGPL-3.0-only
10
10
  import fs from "node:fs";
11
11
  import path from "node:path";
@@ -162,7 +162,9 @@ export function checkSite(dir, expect = []) {
162
162
  // Each value on such a page names the page it was read on and the day ("checked 27 Oct on maker.com"), and a value
163
163
  // nobody found says so with why; a dash or "n/a" is not a value. A filter from a stated limit ("under €600") counts
164
164
  // only values the page shows: the starters' site.byFilter lists a row whose value is unknown apart ("Price
165
- // unknown"), never as within the limit. These rules read the data the same way site.js draws it.
165
+ // unknown"), never as within the limit. A row of a comparison is a candidate a page read names (a value's or a
166
+ // "sources" page), or only a search result ("found": its site, drawn "Not checked"). These rules read the data the
167
+ // same way site.js and the compare starter draw it.
166
168
 
167
169
  /** A value that only says nothing is known: blank, dashes, "?", "n/a", "unknown", "TBD" (site.js: no value). */
168
170
  export const NO_VALUE = /^(?:[\s\-\u2010-\u2015?.\u00b7\u2026/]*|n\s*\/\s*a|unknown|tb[ad])$/i;
@@ -269,7 +271,38 @@ function liveValue(records, key) {
269
271
  return NO_VALUE.test(String(newest.value).trim()) ? null : numberIn(String(newest.value));
270
272
  }
271
273
 
272
- /** The values of a site's page of options: problems to fix before the card, and notes for the reply. */
274
+ /** A page a row cites: a value with its source and https page, or a "sources" entry with an https page. */
275
+ const citesPage = (fact) => !!(fact && typeof fact === "object" && !fact.est && fact.src && httpsUrl(fact.url));
276
+
277
+ /** A site as a search result names it ("reviews.example.net"): a host name, not a word or an address. */
278
+ const SITE_NAME = /^(?=.{1,253}$)(?:[\p{L}\p{N}](?:[\p{L}\p{N}-]*[\p{L}\p{N}])?\.)+\p{L}[\p{L}\p{N}-]*$/u;
279
+
280
+ /** The rows of a comparison (the compare starter's data: "columns" and "items") that say nowhere where they come from:
281
+ * no value or "sources" entry cites a page that was read, and no "found" names the site of the search result that
282
+ * alone names them. Such a row is a guess or a placeholder ("Model 1"): [{item, problem}]. Judged by the data's
283
+ * shape, never by the row's words. A list of names the same turn writes (`--expect`, data.json's "expect") only says
284
+ * what the page must show; it never vouches for a row. */
285
+ export function uncitedRows(data) {
286
+ if (!isOptionsData(data) || !Array.isArray(data.columns)) return [];
287
+ const out = [];
288
+ for (const item of data.items) {
289
+ if (!item || typeof item !== "object") continue;
290
+ const name = String(item.name ?? item.ref ?? "");
291
+ const facts = [...Object.values(item.facts && typeof item.facts === "object" ? item.facts : {}), ...(Array.isArray(item.more) ? item.more.map((m) => m?.fact) : [])];
292
+ const sources = Array.isArray(item.sources) ? item.sources : [];
293
+ if (facts.some(citesPage) || sources.some((s) => s && typeof s === "object" && s.name && httpsUrl(s.url))) continue;
294
+ if (typeof item.found === "string" && SITE_NAME.test(item.found.trim())) continue;
295
+ const label = name || "a row";
296
+ out.push({
297
+ item: label,
298
+ problem: `${label}: nothing says where this row comes from. Cite the page you read that names it (a value's "src" and "url", or "sources": [{"name", "url"}]). When only a search result names it, put that result's site in "found" ("found": "example.com"): the page then says it is not checked. Naming it in --expect or "expect" does not count. A row nothing named (a guess, "Model 1") does not belong on the page.`,
299
+ });
300
+ }
301
+ return out;
302
+ }
303
+
304
+ /** The values of a site's page of options, and where its rows come from: problems to fix before the card, and notes
305
+ * for the reply. */
273
306
  export function checkValues(dir) {
274
307
  const root = path.resolve(dir);
275
308
  const problems = [];
@@ -294,6 +327,7 @@ export function checkValues(dir) {
294
327
  const list = lacks.length > 1 ? `${lacks.slice(0, -1).join(", ")} or ${lacks[lacks.length - 1]}` : lacks[0];
295
328
  if (lacks.length) problems.push(`${where}: "${valueOf(fact)}" has no ${list}: add where you read it and when ("checked 27 Oct on maker.com"), or leave the value out.${fact.src ? "" : " Without a source the page shows it as not checked."}`);
296
329
  }
330
+ for (const row of uncitedRows(data)) problems.push(row.problem);
297
331
 
298
332
  const filter = data.filter;
299
333
  if (filter && typeof filter === "object") {
@@ -698,6 +698,10 @@ export function createBinder(options) {
698
698
  site: { title: site.title }, token: tokenNeeded(site, req),
699
699
  viewer: viewerOk(site, req, url), parentOrigins: parentOriginsFor(site, req),
700
700
  executors: { script: "ready", agent: agentState },
701
+ // Which folder this binder serves and its process, told only to this machine (a loopback socket and name, no
702
+ // proxy; a tunnel's client connects from here but keeps the viewer's host): `serve --detach` checks that the
703
+ // answer on the port is its own binder's, not another server's.
704
+ ...(adminLocal(req) ? { binder: { root, pid: process.pid } } : {}),
701
705
  });
702
706
  case "session": {
703
707
  // The client trades #k= for an HttpOnly cookie once, so EventSource, polling and the page's own fetches
@@ -14,10 +14,13 @@
14
14
  * unknown?: "Price unknown"}. Only a value the page shows counts: a row whose value is
15
15
  * unknown is never listed as under $160; it is listed apart, under "Price unknown".
16
16
  * items: [{ref: "option:a", name, why (one short line), badge?: "Previous model", note?, facts: {key: fact},
17
- * store: {name, url, readable}, more?: [{label, fact}], sources?: [{name, url}]}]
17
+ * store: {name, url, readable}, more?: [{label, fact}], sources?: [{name, url}], found?: "example.com"}]
18
18
  * every candidate a page you read named, also one nothing could be read for: its cells say
19
19
  * "Unknown" (a table of unknowns). Empty only when no page named any: it reads "No options yet".
20
- * unchecked: ["what was not checked", …] listed once in the footer
20
+ * A row cites a page you read that names it (a value's or a "sources" page). When only a
21
+ * search result names it, "found" is that result's site instead, and the row says "Not
22
+ * checked". `instafy bind check` fails a row with neither: a guess or a placeholder.
23
+ * unchecked: ["what was not checked", …] listed once in the footer, on an empty page too
21
24
  * fact: {"v": "$145", "src": "Example Store", "url": "https://…", "date": "2026-10-17", "asof"?: "2026-08-05", "est": false}
22
25
  * Each value cites its page and day ("checked 17 Oct on maker.com ↗"), and no cell is blank: a source you read that
23
26
  * does not give the value is the fact without "v" ({"src", "url", "date"}): "Not listed · checked 17 Oct on
@@ -126,7 +129,9 @@
126
129
  }
127
130
  return site.trust(valueOf(item, col), { shared: isShared(src.cols, item, col) || ownRow(src.rows, item, col), label: label, unknown: "Unknown" });
128
131
  }
129
- function hasMore(item) { return !!(item.note || (item.more && item.more.length) || (item.sources && item.sources.length)); }
132
+ function hasMore(item) { return !!(item.note || found(item) || (item.more && item.more.length) || (item.sources && item.sources.length)); }
133
+ /** The site of the search result that alone names this row ("found"), or "". */
134
+ function found(item) { return typeof item.found === "string" ? item.found.trim().slice(0, 100) : ""; }
130
135
 
131
136
  /** The "Sort by" select shows (and rows are sorted) only with 5 or more options and more than one way to sort. */
132
137
  function sortShown(data) { return (data.sort || []).length > 1 && (data.items || []).length >= 5; }
@@ -235,8 +240,8 @@
235
240
  sources = sources.filter(function (s) {
236
241
  return !named.some(function (f) { return f.src === s.name || (site.host(f.url) && site.host(f.url) === site.host(s.url)); });
237
242
  });
238
- // the footer belongs to a list: an empty page has nothing to confirm
239
- site.notes(items.length ? data.unchecked : [], items.length ? sources : []);
243
+ // what was not checked is said on an empty page too: it is why the page is empty
244
+ site.notes(data.unchecked, sources);
240
245
  }
241
246
 
242
247
  /** "2 without a price are listed last." when the sort key is missing for some rows. */
@@ -275,7 +280,8 @@
275
280
 
276
281
  function nameBlock(item) {
277
282
  return [
278
- h("div", { "class": "name-line" }, h("span", { "class": "name", text: item.name }), item.badge ? h("span", { "class": "badge", text: item.badge }) : null),
283
+ h("div", { "class": "name-line" }, h("span", { "class": "name", text: item.name }), item.badge ? h("span", { "class": "badge", text: item.badge }) : null,
284
+ found(item) ? h("span", { "class": "badge", text: "Not checked" }) : null),
279
285
  item.why ? h("p", { "class": "cap", text: item.why }) : null
280
286
  ];
281
287
  }
@@ -296,6 +302,7 @@
296
302
  var sources = (item.sources || []).filter(function (s) { return s && !linked[s.url]; });
297
303
  return h("div", { "class": "more" },
298
304
  item.note ? h("p", { text: item.note }) : null,
305
+ found(item) ? h("p", { "class": "cap", text: "Named in a search result on " + found(item) + "." }) : null,
299
306
  facts.length ? h("dl", { "class": "facts" }, facts.map(function (f) {
300
307
  return h("div", null, h("dt", { text: f.label }), h("dd", null, site.trust(f.fact)));
301
308
  })) : null,