@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 +2 -0
- package/dist/bind/bin/instafy-bind.mjs +56 -8
- package/dist/bind/src/check.mjs +38 -4
- package/dist/bind/src/server.mjs +4 -0
- package/dist/bind/src/starters/compare/app.js +13 -6
- package/dist/cli.js +964 -369
- package/package.json +1 -1
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
|
|
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
|
-
|
|
138
|
-
|
|
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
|
-
|
|
142
|
-
|
|
143
|
-
|
|
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)
|
|
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`);
|
package/dist/bind/src/check.mjs
CHANGED
|
@@ -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
|
|
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.
|
|
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
|
-
/**
|
|
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") {
|
package/dist/bind/src/server.mjs
CHANGED
|
@@ -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
|
-
*
|
|
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
|
-
//
|
|
239
|
-
site.notes(
|
|
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,
|