@instafy/cli 0.2.2 → 0.2.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.
- package/README.md +60 -3
- package/dist/bind/LICENSE +661 -0
- package/dist/bind/bin/instafy-bind.mjs +542 -0
- package/dist/bind/client/bind-client.js +1219 -0
- package/dist/bind/src/apply.mjs +293 -0
- package/dist/bind/src/bound.mjs +202 -0
- package/dist/bind/src/budget.mjs +58 -0
- package/dist/bind/src/check.mjs +176 -0
- package/dist/bind/src/config.mjs +180 -0
- package/dist/bind/src/controller.mjs +244 -0
- package/dist/bind/src/executors/agent.mjs +336 -0
- package/dist/bind/src/executors/script.mjs +163 -0
- package/dist/bind/src/handler-kit.mjs +150 -0
- package/dist/bind/src/init.mjs +238 -0
- package/dist/bind/src/jobs.mjs +316 -0
- package/dist/bind/src/net.mjs +281 -0
- package/dist/bind/src/page-fetch.mjs +43 -0
- package/dist/bind/src/preview-host.mjs +114 -0
- package/dist/bind/src/protocol.mjs +103 -0
- package/dist/bind/src/records-schema.mjs +580 -0
- package/dist/bind/src/records.mjs +161 -0
- package/dist/bind/src/save-owner.mjs +492 -0
- package/dist/bind/src/schema-lite.mjs +212 -0
- package/dist/bind/src/server.mjs +908 -0
- package/dist/bind/src/sse.mjs +119 -0
- package/dist/bind/src/starters/blank/app.js +17 -0
- package/dist/bind/src/starters/blank/index.html +22 -0
- package/dist/bind/src/starters/checklist/app.js +171 -0
- package/dist/bind/src/starters/checklist/bound.json +29 -0
- package/dist/bind/src/starters/checklist/data.json +26 -0
- package/dist/bind/src/starters/checklist/index.html +23 -0
- package/dist/bind/src/starters/compare/app.js +316 -0
- package/dist/bind/src/starters/compare/bound.json +26 -0
- package/dist/bind/src/starters/compare/data.json +170 -0
- package/dist/bind/src/starters/compare/index.html +23 -0
- package/dist/bind/src/starters/handlers/read_offer.mjs +55 -0
- package/dist/bind/src/starters/handlers/set_check.mjs +41 -0
- package/dist/bind/src/starters/ranked/app.js +166 -0
- package/dist/bind/src/starters/ranked/bound.json +26 -0
- package/dist/bind/src/starters/ranked/data.json +142 -0
- package/dist/bind/src/starters/ranked/index.html +23 -0
- package/dist/bind/src/starters/site.js +888 -0
- package/dist/bind/src/starters/theme.css +492 -0
- package/dist/bind/src/starters/timeline/app.js +143 -0
- package/dist/bind/src/starters/timeline/data.json +41 -0
- package/dist/bind/src/starters/timeline/index.html +22 -0
- package/dist/bind/src/static.mjs +141 -0
- package/dist/bind/src/tokens.mjs +102 -0
- package/dist/bind/src/try.mjs +139 -0
- package/dist/bind/src/web.mjs +191 -0
- package/dist/cli.js +5031 -2446
- package/package.json +5 -4
|
@@ -0,0 +1,542 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// instafy-bind: serve a bound site (a website plus the generic binder backend).
|
|
3
|
+
// SPDX-License-Identifier: AGPL-3.0-only
|
|
4
|
+
import fs from "node:fs";
|
|
5
|
+
import os from "node:os";
|
|
6
|
+
import path from "node:path";
|
|
7
|
+
import { spawn } from "node:child_process";
|
|
8
|
+
import readline from "node:readline";
|
|
9
|
+
import { createBinder, discoverSites, trimEnd } from "../src/server.mjs";
|
|
10
|
+
import { initSite, STARTER_NAMES } from "../src/init.mjs";
|
|
11
|
+
import { checkBound, resolveIntent, executorOf } from "../src/bound.mjs";
|
|
12
|
+
import { formatErrors } from "../src/schema-lite.mjs";
|
|
13
|
+
import { resolveControllerConfig, agentExecutorRefusal, agentUnavailableReason, detachedBinderEnv } from "../src/config.mjs";
|
|
14
|
+
import { findServerState, readAdminToken, writeServerState, removeServerState } from "../src/tokens.mjs";
|
|
15
|
+
import { COMMAND_NAMES, safeText } from "../src/protocol.mjs";
|
|
16
|
+
import { envNameRefusal } from "../src/executors/script.mjs";
|
|
17
|
+
import { DEFAULT_AGENT_BUDGET_PER_HOUR } from "../src/budget.mjs";
|
|
18
|
+
import { handlerHash, readTried, tryIntent } from "../src/try.mjs";
|
|
19
|
+
import { checkReport } from "../src/check.mjs";
|
|
20
|
+
|
|
21
|
+
const HELP = `instafy-bind: a website plus a generic backend that turns clicks into typed intents.
|
|
22
|
+
|
|
23
|
+
Usage:
|
|
24
|
+
instafy-bind serve <dir|gallery> [--port 4180] [--host 127.0.0.1] [--preview | --no-token]
|
|
25
|
+
[--agent-executor [--space <id>] [--conversation <id>] [--agent-conversation <id>]
|
|
26
|
+
[--server-url <url>] [--agent-budget ${DEFAULT_AGENT_BUDGET_PER_HOUR}]] [--dry-run] [--confirm] [--allow-local]
|
|
27
|
+
[--allow-env NAME] [--public-url <url>] [--trust-proxy]
|
|
28
|
+
[--no-save] [--json] [--verbose] [--detach [--log <file>]]
|
|
29
|
+
instafy-bind init <dir> [--starter compare|ranked|timeline|checklist|blank] [--force]
|
|
30
|
+
instafy-bind command <dir> <name> [json-args] names: ${COMMAND_NAMES.join(", ")}
|
|
31
|
+
instafy-bind confirm <dir> <job|all> [--no]
|
|
32
|
+
instafy-bind doctor [dir]
|
|
33
|
+
instafy-bind try <dir> <intent> [json-params] [--save] [--allow-local]
|
|
34
|
+
instafy-bind check <dir> --expect "<item>,<item>,…" [--json]
|
|
35
|
+
|
|
36
|
+
serve Serve the site (or every subfolder with a bound.json) with the binder endpoints under
|
|
37
|
+
__bind/. Prints share URLs with the viewer token: #k=... for pages that load __bind/client.js,
|
|
38
|
+
?k=... (sets a cookie, then redirects) for other pages.
|
|
39
|
+
--preview: for Studio's preview card. No viewer token; besides localhost the binder answers only
|
|
40
|
+
the private preview that \`instafy preview open --port <port>\` opens for this port (space members
|
|
41
|
+
only, checked by Instafy before a request reaches the binder). Any other Host gets 421.
|
|
42
|
+
Agent intents are "Ask Octo" buttons: the page hands the click to Studio, which sends it as the
|
|
43
|
+
clicker's own chat message (billed like typing it). The binder answers them 409 ask_via_host and
|
|
44
|
+
holds no Instafy login.
|
|
45
|
+
--agent-executor: the owner's opt-in to run agent intents as Octo turns on their own login (\`instafy
|
|
46
|
+
login\`), for clicks made on this machine only (loopback, no proxy); every other click is still an
|
|
47
|
+
ask. The turns go to the site's own thread, created in --space (a child of --conversation when
|
|
48
|
+
given). --agent-conversation <id> uses an existing conversation instead. --agent-budget caps Octo
|
|
49
|
+
turns per site per hour (repairs included). --dry-run prints those prompts instead.
|
|
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
|
|
52
|
+
started in an agent turn without it stops when the turn ends.
|
|
53
|
+
In a folder of an Instafy space, the binder saves the data files it writes for clicks (records,
|
|
54
|
+
data/version.json): it commits them to the space's checkout a few seconds after the last write, and
|
|
55
|
+
the space's next save publishes them. --no-save leaves them as changed files.
|
|
56
|
+
init Write a starter bound.json, index.html and handlers/hello.mjs. --starter <name>: a themed page instead,
|
|
57
|
+
with theme.css and site.js (compare 2-8 options; ranked a top-N; timeline days or steps in time;
|
|
58
|
+
checklist ticks; blank anything else). You design the content in data.json and app.js. Existing
|
|
59
|
+
files are kept; --force overwrites them.
|
|
60
|
+
check Does the site show every item the request named (each product, stop or entry)? Reads the text of its
|
|
61
|
+
HTML pages and every value in its data files (data.json, data/**/*.json), and lists the items it does
|
|
62
|
+
not show; exit 1 when one is missing, 2 when there is nothing to check. Items: --expect, plus an
|
|
63
|
+
"expect" array in data.json. Run it before telling anyone the page is done.
|
|
64
|
+
command Push a command (select, highlight, toast, reload) to every open viewer. Uses the
|
|
65
|
+
admin token in .bind/admin-token.
|
|
66
|
+
confirm Approve (or --no: decline) jobs waiting for confirmation.
|
|
67
|
+
doctor Check Node, bound.json, handlers and the Instafy controller settings (never prints tokens).
|
|
68
|
+
try Run one script intent as a viewer's click would (same input, environment and record checks), print
|
|
69
|
+
its records, and exit 1 when it crashes, reports an error, sends nothing or every record is rejected.
|
|
70
|
+
Nothing is written to the site. Run it for every script intent before telling anyone the site works.
|
|
71
|
+
--save: also store the accepted records in the site, exactly as a click's would be (same checks,
|
|
72
|
+
provenance and merge), so the page opens with the values, each with its source and age. Use it to fill
|
|
73
|
+
a new page (one try per item); a served page shows them after \`command <dir> reload '{}'\`.
|
|
74
|
+
`;
|
|
75
|
+
|
|
76
|
+
function parseArgs(argv) {
|
|
77
|
+
const positional = [];
|
|
78
|
+
const flags = {};
|
|
79
|
+
const repeatable = new Set(["public-url", "allow-env"]);
|
|
80
|
+
const booleans = new Set(["no-token", "preview", "dry-run", "confirm", "allow-local", "json", "verbose", "force", "no", "help", "trust-proxy", "detach", "save", "no-save", "agent-executor"]);
|
|
81
|
+
for (let i = 0; i < argv.length; i += 1) {
|
|
82
|
+
const arg = argv[i];
|
|
83
|
+
if (arg === "-h") flags.help = true;
|
|
84
|
+
else if (arg.startsWith("--")) {
|
|
85
|
+
const [key, inline] = arg.slice(2).split(/=(.*)/s, 2);
|
|
86
|
+
if (booleans.has(key)) flags[key] = inline === undefined ? true : inline !== "false";
|
|
87
|
+
else {
|
|
88
|
+
const value = inline ?? argv[++i];
|
|
89
|
+
if (value === undefined) throw new Error(`--${key} needs a value`);
|
|
90
|
+
if (repeatable.has(key)) (flags[key] ||= []).push(value);
|
|
91
|
+
else flags[key] = value;
|
|
92
|
+
}
|
|
93
|
+
} else positional.push(arg);
|
|
94
|
+
}
|
|
95
|
+
return { positional, flags };
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function fail(message, code = 1) {
|
|
99
|
+
process.stderr.write(`instafy-bind: ${message}\n`);
|
|
100
|
+
process.exit(code);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** argv for the background binder: the same command without --detach/--log. */
|
|
104
|
+
function detachedServeArgs(argv) {
|
|
105
|
+
const out = [];
|
|
106
|
+
for (let i = 0; i < argv.length; i += 1) {
|
|
107
|
+
const arg = argv[i];
|
|
108
|
+
if (arg === "--detach" || arg.startsWith("--detach=")) continue;
|
|
109
|
+
if (arg === "--log") {
|
|
110
|
+
i += 1;
|
|
111
|
+
continue;
|
|
112
|
+
}
|
|
113
|
+
if (arg.startsWith("--log=")) continue;
|
|
114
|
+
out.push(arg);
|
|
115
|
+
}
|
|
116
|
+
return out;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
async function serveDetached(dir, flags) {
|
|
120
|
+
const port = flags.port === undefined ? 4180 : Number(flags.port);
|
|
121
|
+
if (!Number.isInteger(port) || port < 1 || port > 65535) fail("--detach needs a fixed --port");
|
|
122
|
+
const logFile = path.resolve(flags.log || path.join(os.tmpdir(), `instafy-bind-${port}.log`));
|
|
123
|
+
const fd = fs.openSync(logFile, "a");
|
|
124
|
+
const child = spawn(process.execPath, [process.argv[1], ...detachedServeArgs(process.argv.slice(2))], {
|
|
125
|
+
detached: true,
|
|
126
|
+
stdio: ["ignore", fd, fd],
|
|
127
|
+
env: detachedBinderEnv(process.env, [].concat(flags["allow-env"] || []), { keepLogin: Boolean(flags["agent-executor"]) }),
|
|
128
|
+
});
|
|
129
|
+
fs.closeSync(fd);
|
|
130
|
+
child.unref();
|
|
131
|
+
const host = flags.host || "127.0.0.1";
|
|
132
|
+
const deadline = Date.now() + 20_000;
|
|
133
|
+
let up = false;
|
|
134
|
+
while (Date.now() < deadline) {
|
|
135
|
+
if (child.exitCode !== null) break;
|
|
136
|
+
try {
|
|
137
|
+
const response = await fetch(`http://${host.includes(":") ? `[${host}]` : host}:${port}/__bind/health`);
|
|
138
|
+
if (response.status < 500) {
|
|
139
|
+
up = true;
|
|
140
|
+
break;
|
|
141
|
+
}
|
|
142
|
+
} catch {
|
|
143
|
+
// not listening yet
|
|
144
|
+
}
|
|
145
|
+
await new Promise((resolve) => setTimeout(resolve, 300));
|
|
146
|
+
}
|
|
147
|
+
const tail = () => {
|
|
148
|
+
try {
|
|
149
|
+
return fs.readFileSync(logFile, "utf8").split("\n").slice(-12).join("\n");
|
|
150
|
+
} catch {
|
|
151
|
+
return "";
|
|
152
|
+
}
|
|
153
|
+
};
|
|
154
|
+
if (!up) fail(`the background binder did not start (log ${logFile}):\n${tail()}`);
|
|
155
|
+
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`);
|
|
156
|
+
const shown = tail().split("\n").filter((line) => /show it in Studio|public link|agent executor|intents:/.test(line));
|
|
157
|
+
for (const line of shown) process.stdout.write(`${line}\n`);
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
async function serve(dir, flags) {
|
|
161
|
+
if (!dir) fail("serve needs a folder");
|
|
162
|
+
if (flags["agent-executor"] && agentExecutorRefusal(process.env)) fail(agentExecutorRefusal(process.env));
|
|
163
|
+
if (flags.detach) return serveDetached(dir, flags);
|
|
164
|
+
const json = Boolean(flags.json);
|
|
165
|
+
const verbose = Boolean(flags.verbose);
|
|
166
|
+
const out = (event) => process.stdout.write(`${JSON.stringify({ at: new Date().toISOString(), ...event })}\n`);
|
|
167
|
+
const log = (level, raw) => {
|
|
168
|
+
if (level === "debug" && !verbose) return;
|
|
169
|
+
const text = safeText(raw);
|
|
170
|
+
if (json) out({ event: "log", level, text });
|
|
171
|
+
else process.stderr.write(`${new Date().toISOString().slice(11, 19)} ${level.padEnd(5)} ${text}\n`);
|
|
172
|
+
};
|
|
173
|
+
const onDryRun = (prompt, job) => {
|
|
174
|
+
if (json) out({ event: "dry_run", job: job.id, intent: job.name, prompt });
|
|
175
|
+
else process.stdout.write(`\n----- dry run: prompt for ${job.name} (${job.id}) -----\n${prompt}\n----- end of prompt -----\n\n`);
|
|
176
|
+
};
|
|
177
|
+
const port = flags.port === undefined ? 4180 : Number(flags.port);
|
|
178
|
+
if (!Number.isInteger(port) || port < 0 || port > 65535) fail("--port must be a number");
|
|
179
|
+
const agentBudget = flags["agent-budget"] === undefined ? DEFAULT_AGENT_BUDGET_PER_HOUR : Number(flags["agent-budget"]);
|
|
180
|
+
if (!Number.isInteger(agentBudget) || agentBudget < 0 || agentBudget > 1000) fail("--agent-budget must be a whole number of turns per hour (0-1000)");
|
|
181
|
+
|
|
182
|
+
let binder;
|
|
183
|
+
try {
|
|
184
|
+
binder = createBinder({
|
|
185
|
+
root: dir,
|
|
186
|
+
port,
|
|
187
|
+
host: flags.host || "127.0.0.1",
|
|
188
|
+
noToken: Boolean(flags["no-token"]),
|
|
189
|
+
preview: Boolean(flags.preview),
|
|
190
|
+
agentExecutor: Boolean(flags["agent-executor"]),
|
|
191
|
+
conversation: flags.conversation,
|
|
192
|
+
agentConversation: flags["agent-conversation"],
|
|
193
|
+
space: flags.space,
|
|
194
|
+
serverUrl: flags["server-url"],
|
|
195
|
+
agentBudget,
|
|
196
|
+
allowEnv: flags["allow-env"] || [],
|
|
197
|
+
trustProxy: Boolean(flags["trust-proxy"]),
|
|
198
|
+
dryRun: Boolean(flags["dry-run"]),
|
|
199
|
+
confirm: Boolean(flags.confirm),
|
|
200
|
+
allowLocal: Boolean(flags["allow-local"]),
|
|
201
|
+
publicOrigins: flags["public-url"] || [],
|
|
202
|
+
save: !flags["no-save"],
|
|
203
|
+
log,
|
|
204
|
+
onDryRun,
|
|
205
|
+
});
|
|
206
|
+
await binder.listen();
|
|
207
|
+
} catch (error) {
|
|
208
|
+
fail(error.message);
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
writeServerState(binder.root, {
|
|
212
|
+
pid: process.pid,
|
|
213
|
+
url: binder.url,
|
|
214
|
+
started_at: new Date().toISOString(),
|
|
215
|
+
sites: binder.sites.map((s) => ({ name: s.name, prefix: s.prefix, dir: s.dir })),
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
// Jobs that need confirmation are announced here; answer with "y <job>" or "n <job>". The job id is required:
|
|
219
|
+
// a bare "y" never approves a batch, and the params are printed again (JSON-encoded) when you decide.
|
|
220
|
+
const announced = new Set();
|
|
221
|
+
for (const site of binder.sites) {
|
|
222
|
+
const broadcast = site.hub.broadcast.bind(site.hub);
|
|
223
|
+
site.hub.broadcast = (msg) => {
|
|
224
|
+
if (msg.type === "ack" && msg.status === "needs_confirm" && !msg.coalesced && !announced.has(msg.job)) {
|
|
225
|
+
announced.add(msg.job);
|
|
226
|
+
const job = site.jobs.jobs.find((j) => j.id === msg.job);
|
|
227
|
+
const line = `confirm? ${msg.job} ${job?.name} ${JSON.stringify(job?.intent.params)} (type "y ${msg.job}" or "n ${msg.job}")`;
|
|
228
|
+
if (json) out({ event: "needs_confirm", site: site.name, job: msg.job, intent: job?.name, params: job?.intent.params });
|
|
229
|
+
else process.stderr.write(`${safeText(line)}\n`);
|
|
230
|
+
}
|
|
231
|
+
return broadcast(msg);
|
|
232
|
+
};
|
|
233
|
+
}
|
|
234
|
+
if (process.stdin.isTTY) {
|
|
235
|
+
const rl = readline.createInterface({ input: process.stdin });
|
|
236
|
+
rl.on("line", (line) => {
|
|
237
|
+
const [answer, jobId] = line.trim().split(/\s+/);
|
|
238
|
+
if (!/^[yn]$/i.test(answer || "")) return;
|
|
239
|
+
if (!jobId) {
|
|
240
|
+
const waiting = binder.sites.flatMap((site) => site.jobs.pendingConfirmations().map((j) => j.id));
|
|
241
|
+
log("info", waiting.length ? `name the job: "${answer} <job>" (waiting: ${waiting.join(", ")})` : "no job is waiting for confirmation");
|
|
242
|
+
return;
|
|
243
|
+
}
|
|
244
|
+
for (const site of binder.sites) {
|
|
245
|
+
const job = site.jobs.pendingConfirmations().find((j) => j.id === jobId);
|
|
246
|
+
if (!job) continue;
|
|
247
|
+
const yes = answer.toLowerCase() === "y";
|
|
248
|
+
site.jobs.confirm(jobId, yes);
|
|
249
|
+
log("info", `${jobId} ${yes ? "confirmed" : "declined"}: ${job.name} ${JSON.stringify(job.intent.params)}`);
|
|
250
|
+
}
|
|
251
|
+
});
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
const listenPort = new URL(binder.url).port;
|
|
255
|
+
const sites = binder.sites.map((site) => ({
|
|
256
|
+
name: site.name,
|
|
257
|
+
url: binder.shareUrl(site),
|
|
258
|
+
intents: [...site.runnable],
|
|
259
|
+
unavailable: site.unavailable,
|
|
260
|
+
...(binder.preview ? { preview_command: previewCommand(listenPort, site) } : {}),
|
|
261
|
+
...(flags["public-url"]?.length ? { public_urls: flags["public-url"].map((origin) => binder.shareUrl(site, trimEnd(origin, "/"))) } : {}),
|
|
262
|
+
}));
|
|
263
|
+
if (json) {
|
|
264
|
+
out({ event: "listening", url: binder.url, preview: binder.preview, agent: binder.agentState, agent_reason: binder.agentReason, sites });
|
|
265
|
+
} else {
|
|
266
|
+
process.stdout.write(`instafy-bind serving ${binder.root}${binder.preview ? " (preview mode: no viewer token)" : ""}\n`);
|
|
267
|
+
for (const site of sites) {
|
|
268
|
+
process.stdout.write(` ${site.name}: ${site.url}\n intents: ${site.intents.join(", ") || "(none)"}\n`);
|
|
269
|
+
if (site.preview_command) process.stdout.write(` show it in Studio: ${site.preview_command}\n`);
|
|
270
|
+
for (const link of site.public_urls || []) process.stdout.write(` public link (share this whole link; it carries the viewer token): ${link}\n`);
|
|
271
|
+
}
|
|
272
|
+
if (binder.agentState === "host") {
|
|
273
|
+
process.stdout.write(" agent executor: host (Ask Octo clicks go through Studio as the clicker's own message; this binder holds no Instafy login)\n");
|
|
274
|
+
} else {
|
|
275
|
+
process.stdout.write(` agent executor: ${binder.agentState}${binder.agentReason ? ` (${binder.agentReason})` : ""}${binder.agentState === "ready" || binder.agentState === "dry_run" ? " for clicks on this machine; other clicks ask through Studio" : ""}\n`);
|
|
276
|
+
}
|
|
277
|
+
if (binder.agentState === "ready") {
|
|
278
|
+
const target = binder.controller.agentConversationId ? `conversation ${binder.controller.agentConversationId}` : `the site's own thread in space ${binder.controller.spaceId}`;
|
|
279
|
+
process.stdout.write(` agent turns: ${target}, at most ${agentBudget} per site per hour\n`);
|
|
280
|
+
}
|
|
281
|
+
process.stdout.write(` commands: instafy-bind command ${dir} toast '{"text":"Hi"}' (admin token in ${path.join(binder.root, ".bind", "admin-token")})\n`);
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
let closing = false;
|
|
285
|
+
const stop = async () => {
|
|
286
|
+
if (closing) return;
|
|
287
|
+
closing = true;
|
|
288
|
+
removeServerState(binder.root);
|
|
289
|
+
await binder.close();
|
|
290
|
+
process.exit(0);
|
|
291
|
+
};
|
|
292
|
+
process.on("SIGINT", stop);
|
|
293
|
+
process.on("SIGTERM", stop);
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
/** The `instafy preview open` command that shows one site of this binder in Studio's preview card. */
|
|
297
|
+
function previewCommand(port, site) {
|
|
298
|
+
// Printed for a shell: keep the title inside double quotes without anything the shell would expand.
|
|
299
|
+
const title = String(site.title || site.name).replace(/["$`\\\s]+/g, " ").trim().slice(0, 120) || site.name;
|
|
300
|
+
return `instafy preview open --port ${port}${site.prefix ? ` --path ${site.prefix}/` : ""} --title "${title}"`;
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
function locate(dir) {
|
|
304
|
+
const found = findServerState(dir);
|
|
305
|
+
if (!found) fail(`no running binder for ${dir} (start one with: instafy-bind serve <dir>)`);
|
|
306
|
+
const abs = fs.realpathSync(path.resolve(dir));
|
|
307
|
+
const sites = found.state.sites || [];
|
|
308
|
+
const site = sites.find((s) => fs.existsSync(s.dir) && fs.realpathSync(s.dir) === abs) || (sites.length === 1 ? sites[0] : null);
|
|
309
|
+
if (!site) fail(`${dir} is a gallery; name one site folder (${sites.map((s) => s.name).join(", ")})`);
|
|
310
|
+
return { url: found.state.url, root: found.root, site };
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
async function postAdmin(dir, endpoint, body) {
|
|
314
|
+
const { url, root, site } = locate(dir);
|
|
315
|
+
const token = readAdminToken(root);
|
|
316
|
+
let response;
|
|
317
|
+
try {
|
|
318
|
+
response = await fetch(`${url}${site.prefix}/__bind/${endpoint}`, {
|
|
319
|
+
method: "POST",
|
|
320
|
+
headers: { "content-type": "application/json", authorization: `Bearer ${token}` },
|
|
321
|
+
body: JSON.stringify(body),
|
|
322
|
+
});
|
|
323
|
+
} catch (error) {
|
|
324
|
+
fail(`binder at ${url} is not reachable (${error.message})`);
|
|
325
|
+
}
|
|
326
|
+
const text = await response.text();
|
|
327
|
+
process.stdout.write(`${text}\n`);
|
|
328
|
+
if (!response.ok) process.exit(1);
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
async function command(dir, name, rawArgs) {
|
|
332
|
+
if (!dir || !name) fail(`usage: instafy-bind command <dir> <name> [json-args] names: ${COMMAND_NAMES.join(", ")}`);
|
|
333
|
+
if (!COMMAND_NAMES.includes(name)) fail(`unknown command "${name}" (use ${COMMAND_NAMES.join(", ")})`);
|
|
334
|
+
let args = {};
|
|
335
|
+
if (rawArgs) {
|
|
336
|
+
try {
|
|
337
|
+
args = JSON.parse(rawArgs);
|
|
338
|
+
} catch {
|
|
339
|
+
fail("json-args must be a JSON object, e.g. '{\"ref\":\"products:a\"}'");
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
await postAdmin(dir, "command", { name, args });
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
async function doctor(dir, flags = {}) {
|
|
346
|
+
const lines = [];
|
|
347
|
+
let bad = false;
|
|
348
|
+
const ok = (text) => lines.push(`ok ${text}`);
|
|
349
|
+
const warn = (text) => lines.push(`warn ${text}`);
|
|
350
|
+
const error = (text) => {
|
|
351
|
+
bad = true;
|
|
352
|
+
lines.push(`error ${text}`);
|
|
353
|
+
};
|
|
354
|
+
const major = Number(process.versions.node.split(".")[0]);
|
|
355
|
+
if (major >= 18) ok(`node ${process.versions.node}`);
|
|
356
|
+
else error(`node ${process.versions.node} (need >= 18)`);
|
|
357
|
+
|
|
358
|
+
// Only --agent-executor reads the owner's login; without it Ask Octo goes through Studio and needs none.
|
|
359
|
+
const ownerExecutor = Boolean(flags["agent-executor"]);
|
|
360
|
+
let agentReady = !ownerExecutor;
|
|
361
|
+
if (ownerExecutor) {
|
|
362
|
+
try {
|
|
363
|
+
agentReady = !agentUnavailableReason(resolveControllerConfig({ cwd: dir ? path.resolve(dir) : process.cwd() }));
|
|
364
|
+
} catch {
|
|
365
|
+
agentReady = false;
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
if (dir) {
|
|
369
|
+
const sites = fs.existsSync(dir) ? discoverSites(dir) : [];
|
|
370
|
+
if (!sites.length) error(`${dir}: no bound.json here or in a subfolder`);
|
|
371
|
+
if (sites.length && sites[0].prefix !== "") {
|
|
372
|
+
for (const marker of [".git", ".env", "package.json"]) {
|
|
373
|
+
if (fs.existsSync(path.join(dir, marker))) warn(`${dir} has ${marker}: serve only a folder of sites, not a workspace (only site folders are served, but keep secrets out of them)`);
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
for (const site of sites) {
|
|
377
|
+
let bound;
|
|
378
|
+
try {
|
|
379
|
+
bound = JSON.parse(fs.readFileSync(path.join(site.dir, "bound.json"), "utf8"));
|
|
380
|
+
} catch (e) {
|
|
381
|
+
error(`${site.name}: bound.json: ${e.message}`);
|
|
382
|
+
continue;
|
|
383
|
+
}
|
|
384
|
+
const problems = checkBound(bound);
|
|
385
|
+
if (problems.length) {
|
|
386
|
+
error(`${site.name}: bound.json: ${formatErrors(problems, 5)}`);
|
|
387
|
+
continue;
|
|
388
|
+
}
|
|
389
|
+
ok(`${site.name}: bound.json (${Object.keys(bound.intents).length} intents)`);
|
|
390
|
+
for (const [name, def] of Object.entries(bound.intents)) {
|
|
391
|
+
const r = resolveIntent(def, { siteDir: site.dir });
|
|
392
|
+
if (r.ok && executorOf(def) === "script" && r.handler) {
|
|
393
|
+
const last = readTried(site.dir)[name];
|
|
394
|
+
if (!last || last.hash !== handlerHash(r.handler)) warn(`${site.name}: ${name} has not been tried since its handler changed: run \`instafy bind try ${dir} ${name} '<params>'\` for one real item`);
|
|
395
|
+
else if (!last.ok) warn(`${site.name}: ${name} failed its last try: fix the handler, then \`instafy bind try\` again`);
|
|
396
|
+
}
|
|
397
|
+
if (r.ok) ok(`${site.name}: ${name} -> ${executorOf(def)}${r.handler ? ` ${r.handler.startsWith(site.dir + path.sep) ? path.relative(site.dir, r.handler) : r.handler}` : ""}${def.env?.length ? ` (env: ${def.env.join(", ")})` : ""}`);
|
|
398
|
+
else error(`${site.name}: ${name}: ${r.reason}`);
|
|
399
|
+
}
|
|
400
|
+
for (const line of lintSite(site, bound, { agentReady })) warn(line);
|
|
401
|
+
}
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
if (!ownerExecutor) {
|
|
405
|
+
ok("Ask Octo: clicks go through Studio as the clicker's own message (no Instafy login needed; --agent-executor checks the owner's)");
|
|
406
|
+
} else try {
|
|
407
|
+
const config = resolveControllerConfig({ cwd: dir ? path.resolve(dir) : process.cwd() });
|
|
408
|
+
ok(`controller ${config.controllerUrl}`);
|
|
409
|
+
if (config.hasToken) ok(`access token from ${config.tokenSource}`);
|
|
410
|
+
else warn("no Instafy access token: agent intents only work with --dry-run (run `instafy login`)");
|
|
411
|
+
if (config.note) warn(config.note);
|
|
412
|
+
if (config.agentConversationId) {
|
|
413
|
+
ok(`agent conversation ${config.agentConversationId}`);
|
|
414
|
+
if (config.agentConversationId === config.conversationId) warn("agent turns would go into your own conversation; prefer a space so the binder creates the site's own thread");
|
|
415
|
+
} else if (config.spaceId) ok(`space ${config.spaceId} (the binder creates the site's own thread on the first agent intent)`);
|
|
416
|
+
else warn("no space: pass --space so agent intents get their own thread");
|
|
417
|
+
const reason = agentUnavailableReason(config);
|
|
418
|
+
if (!reason) ok("agent executor ready");
|
|
419
|
+
} catch (e) {
|
|
420
|
+
error(`controller settings: ${e.message}`);
|
|
421
|
+
}
|
|
422
|
+
process.stdout.write(`${lines.join("\n")}\n`);
|
|
423
|
+
if (bad) process.exit(1);
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
/** Lint checks for mistakes a model writing a bound site tends to make. Returns warning lines. */
|
|
427
|
+
function lintSite(site, bound, { agentReady }) {
|
|
428
|
+
const warnings = [];
|
|
429
|
+
const w = (text) => warnings.push(`${site.name}: ${text}`);
|
|
430
|
+
let html = "";
|
|
431
|
+
try {
|
|
432
|
+
html = fs.readFileSync(path.join(site.dir, "index.html"), "utf8");
|
|
433
|
+
} catch {
|
|
434
|
+
w("index.html is missing");
|
|
435
|
+
}
|
|
436
|
+
if (html && !/__bind\/client\.js/.test(html)) w('index.html does not load "__bind/client.js"; viewers get no live updates');
|
|
437
|
+
const files = bound.results?.files;
|
|
438
|
+
// A site with only agent intents does nothing where the binder has no owner login: in an Octo turn, or for a
|
|
439
|
+
// Studio viewer. Its buttons then just say the service is not connected.
|
|
440
|
+
if (!Object.values(bound.intents).some((def) => executorOf(def) === "script")) {
|
|
441
|
+
w('no script intent: agent intents are "Ask Octo" buttons that only work where Instafy can send them as the clicker\'s message, so anywhere else every button stays dead; add a script intent (when it cannot read a page it returns status "uncertain" records with a note) and keep agent intents as an extra "Ask Octo"');
|
|
442
|
+
}
|
|
443
|
+
for (const [name, def] of Object.entries(bound.intents)) {
|
|
444
|
+
const target = def.target?.records || `data/records/${name}.json`;
|
|
445
|
+
if (Array.isArray(files) && !files.includes(target)) w(`${name}: ${target} is not in results.files, so static viewers never load it`);
|
|
446
|
+
for (const env of def.env || []) {
|
|
447
|
+
const refusal = envNameRefusal(env);
|
|
448
|
+
if (refusal) w(`${name}: env ${env} ${refusal}`);
|
|
449
|
+
}
|
|
450
|
+
if (executorOf(def) !== "agent") continue;
|
|
451
|
+
const props = def.params?.properties || {};
|
|
452
|
+
const prompt = def.agent?.prompt || "";
|
|
453
|
+
for (const [, key] of prompt.matchAll(/["'\u201c\u2018]\{\{\s*([A-Za-z0-9_]+)\s*\}\}["'\u201d\u2019]/g)) {
|
|
454
|
+
w(`${name}: agent.prompt quotes {{${key}}}; placeholders are already JSON-quoted (it renders as ""value"")`);
|
|
455
|
+
}
|
|
456
|
+
if (!props.ref) w(`${name}: agent intents need a "ref" param; records Octo returns are kept only for the clicked ref`);
|
|
457
|
+
const uriParams = Object.entries(props).filter(([, schema]) => schema?.format === "uri").map(([key]) => key);
|
|
458
|
+
const origins = def.agent?.allowed_origins || [];
|
|
459
|
+
if (uriParams.length && (!origins.length || origins.includes("https://*"))) {
|
|
460
|
+
w(`${name}: ${uriParams.join(", ")} can point Octo's browser at any site; list the stores in agent.allowed_origins`);
|
|
461
|
+
}
|
|
462
|
+
for (const [key, schema] of Object.entries(props)) {
|
|
463
|
+
const free = schema?.type === "string" && !schema.pattern && !schema.enum && !schema.format && !schema.const;
|
|
464
|
+
if (free && key !== "ref" && new RegExp(`\\{\\{\\s*${key}\\s*\\}\\}`).test(prompt)) {
|
|
465
|
+
w(`${name}: {{${key}}} is free text in the task; give it a pattern or enum so a viewer cannot write instructions there`);
|
|
466
|
+
}
|
|
467
|
+
}
|
|
468
|
+
for (const source of def.sources || []) {
|
|
469
|
+
if (/^www\./i.test(source)) w(`${name}: sources entry ${source}: the binder drops "www." from agent sources; write ${source.slice(4)}`);
|
|
470
|
+
}
|
|
471
|
+
if (!agentReady) w(`${name}: --agent-executor has no owner login here, so this intent runs only as an ask through Studio (or with --dry-run)`);
|
|
472
|
+
}
|
|
473
|
+
return warnings;
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
async function main() {
|
|
477
|
+
let parsed;
|
|
478
|
+
try {
|
|
479
|
+
parsed = parseArgs(process.argv.slice(2));
|
|
480
|
+
} catch (error) {
|
|
481
|
+
fail(error.message);
|
|
482
|
+
}
|
|
483
|
+
const { positional, flags } = parsed;
|
|
484
|
+
const [cmd, ...rest] = positional;
|
|
485
|
+
if (!cmd || flags.help || cmd === "help") {
|
|
486
|
+
process.stdout.write(HELP);
|
|
487
|
+
return;
|
|
488
|
+
}
|
|
489
|
+
switch (cmd) {
|
|
490
|
+
case "serve":
|
|
491
|
+
return serve(rest[0], flags);
|
|
492
|
+
case "init": {
|
|
493
|
+
if (!rest[0]) fail("init needs a folder");
|
|
494
|
+
if (flags.starter === true || flags.starter === "") fail(`--starter needs a name (${STARTER_NAMES.join(", ")})`);
|
|
495
|
+
let result;
|
|
496
|
+
try {
|
|
497
|
+
result = initSite(rest[0], { force: Boolean(flags.force), starter: flags.starter ?? null });
|
|
498
|
+
} catch (error) {
|
|
499
|
+
fail(error.message);
|
|
500
|
+
}
|
|
501
|
+
for (const file of result.written) process.stdout.write(`wrote ${path.join(rest[0], file)}\n`);
|
|
502
|
+
for (const file of result.skipped) process.stdout.write(`kept ${path.join(rest[0], file)} (exists; --force overwrites)\n`);
|
|
503
|
+
if (flags.starter) process.stdout.write(`next: put the real content in ${path.join(rest[0], flags.starter === "blank" ? "app.js" : "data.json")}, then instafy-bind serve ${rest[0]}\n`);
|
|
504
|
+
else process.stdout.write(`next: instafy-bind serve ${rest[0]} (add --dry-run to see agent prompts without an Instafy login)\n`);
|
|
505
|
+
return;
|
|
506
|
+
}
|
|
507
|
+
case "command":
|
|
508
|
+
return command(rest[0], rest[1], rest[2]);
|
|
509
|
+
case "confirm":
|
|
510
|
+
if (!rest[0] || !rest[1]) fail("usage: instafy-bind confirm <dir> <job|all> [--no]");
|
|
511
|
+
return postAdmin(rest[0], "confirm", { job: rest[1], ok: !flags.no });
|
|
512
|
+
case "doctor":
|
|
513
|
+
return doctor(rest[0], flags);
|
|
514
|
+
case "check": {
|
|
515
|
+
if (!rest[0]) fail('usage: instafy-bind check <dir> --expect "<item>,<item>" [--json]', 64);
|
|
516
|
+
let report;
|
|
517
|
+
try {
|
|
518
|
+
report = checkReport(rest[0], flags.expect ?? [], { json: Boolean(flags.json) });
|
|
519
|
+
} catch (error) {
|
|
520
|
+
fail(error.message, 64);
|
|
521
|
+
}
|
|
522
|
+
process.stdout.write(`${report.lines.join("\n")}\n`);
|
|
523
|
+
process.exitCode = report.exit;
|
|
524
|
+
return;
|
|
525
|
+
}
|
|
526
|
+
case "try": {
|
|
527
|
+
if (!rest[0] || !rest[1]) fail("usage: instafy-bind try <dir> <intent> [json-params]", 64);
|
|
528
|
+
const result = await tryIntent(rest[0], rest[1], rest[2] ?? "{}", {
|
|
529
|
+
allowLocal: Boolean(flags["allow-local"]),
|
|
530
|
+
allowEnv: flags["allow-env"] || [],
|
|
531
|
+
save: Boolean(flags.save),
|
|
532
|
+
});
|
|
533
|
+
process.stdout.write(`${result.lines.join("\n")}\n`);
|
|
534
|
+
process.exitCode = result.exit;
|
|
535
|
+
return;
|
|
536
|
+
}
|
|
537
|
+
default:
|
|
538
|
+
fail(`unknown command "${cmd}"\n\n${HELP}`);
|
|
539
|
+
}
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
main().catch((error) => fail(error.stack || error.message));
|