@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/dist/cli.js CHANGED
@@ -1330,6 +1330,8 @@ var require_redaction = __commonJS({
1330
1330
  const opaqueCandidatePattern = /[A-Za-z0-9][A-Za-z0-9_\-+/=~]{18,}[A-Za-z0-9=]/g;
1331
1331
  const digitCodePattern = /(?<![\d.,][ \u00a0]?)(?:\d{6,8}|\d{3}[ \u00a0]\d{3}|\d{4}[ \u00a0]\d{4})(?![\d]|[.,]\d)/g;
1332
1332
  const codeWordPattern = /(?<![\p{L}])(?:codes?|kode|otp|passcode|pin|tan|2fa|mfa|(?:best(?:ä|ae)tigungs|sicherheits|verifizierungs|verifikations|zugangs|anmelde|freigabe|authentifizierungs|wiederherstellungs|einmal)code)(?![\p{L}])/iu;
1333
+ const DIGIT_CODE_SOURCE = digitCodePattern.source;
1334
+ const CODE_WORD_SOURCE = codeWordPattern.source;
1333
1335
  const NEARBY_CHARACTERS = 120;
1334
1336
  const CODE_NEARBY_CHARACTERS = 32;
1335
1337
  const HEX_NEARBY_CHARACTERS = 32;
@@ -1424,21 +1426,21 @@ var require_redaction = __commonJS({
1424
1426
  return TEXT_MASK;
1425
1427
  });
1426
1428
  }
1427
- text2 = text2.replace(digitCodePattern, (match, offset, whole) => {
1428
- const before = whole.slice(Math.max(0, offset - CODE_NEARBY_CHARACTERS), offset);
1429
+ text2 = text2.replace(digitCodePattern, (match, offset, whole2) => {
1430
+ const before = whole2.slice(Math.max(0, offset - CODE_NEARBY_CHARACTERS), offset);
1429
1431
  if (!codeWordPattern.test(before)) return match;
1430
1432
  masked += 1;
1431
1433
  matches += 1;
1432
1434
  return TEXT_MASK;
1433
1435
  });
1434
1436
  opaqueCandidatePattern.lastIndex = 0;
1435
- text2 = text2.replace(opaqueCandidatePattern, (match, offset, whole) => {
1437
+ text2 = text2.replace(opaqueCandidatePattern, (match, offset, whole2) => {
1436
1438
  const hexLike = uuidPattern.test(match) || longHexPattern.test(match);
1437
1439
  if (!hexLike && !looksRandom3(match)) return match;
1438
1440
  if (!strict && !credentialContext) {
1439
- if (hexLike && identifierLabelPattern.test(whole.slice(Math.max(0, offset - 16), offset))) return match;
1441
+ if (hexLike && identifierLabelPattern.test(whole2.slice(Math.max(0, offset - 16), offset))) return match;
1440
1442
  const window = hexLike ? HEX_NEARBY_CHARACTERS : NEARBY_CHARACTERS;
1441
- const nearby = whole.slice(Math.max(0, offset - window), offset) + " " + whole.slice(offset + match.length, offset + match.length + window);
1443
+ const nearby = whole2.slice(Math.max(0, offset - window), offset) + " " + whole2.slice(offset + match.length, offset + match.length + window);
1442
1444
  if (!credentialProximityPattern.test(nearby)) return match;
1443
1445
  }
1444
1446
  count(match);
@@ -1461,8 +1463,8 @@ var require_redaction = __commonJS({
1461
1463
  return value.length >= 16 && (looksRandom3(value, 16) || uuidPattern.test(value) || /^[0-9a-f]{16,}$/i.test(value));
1462
1464
  }
1463
1465
  function redactPairs(value, onMask) {
1464
- return value.replace(/([^&#?=/]+)=([^&#]*)/g, (whole, key, rawValue) => {
1465
- if (!sensitiveUrlValue(key, rawValue)) return whole;
1466
+ return value.replace(/([^&#?=/]+)=([^&#]*)/g, (whole2, key, rawValue) => {
1467
+ if (!sensitiveUrlValue(key, rawValue)) return whole2;
1466
1468
  onMask(decodeComponent(rawValue));
1467
1469
  return `${key}=${URL_MASK}`;
1468
1470
  });
@@ -1556,6 +1558,9 @@ var require_redaction = __commonJS({
1556
1558
  CREDENTIAL_CONTEXT_SOURCE,
1557
1559
  PLACEHOLDER_SOURCE,
1558
1560
  MASKED_TEXT_SOURCE,
1561
+ DIGIT_CODE_SOURCE,
1562
+ CODE_WORD_SOURCE,
1563
+ CODE_NEARBY_CHARACTERS,
1559
1564
  SECRET_SCREEN_MESSAGE,
1560
1565
  hasCredentialWord: hasCredentialWord2,
1561
1566
  looksRandom: looksRandom3,
@@ -1807,8 +1812,8 @@ var require_linkOnly = __commonJS({
1807
1812
  if (url.username || url.password || url.port || url.hash) return { error: "has user info, a port or a fragment" };
1808
1813
  if (url.hostname.includes(MARK) || !hostOnSite(hostOfUrl2(url), patterns)) return { error: "is not on the site's own hosts" };
1809
1814
  const placeholderIn = (text2) => {
1810
- const whole = new RegExp(`^${MARK}(\\d+)x$`).exec(text2);
1811
- if (whole) return { name: names[Number(whole[1])] };
1815
+ const whole2 = new RegExp(`^${MARK}(\\d+)x$`).exec(text2);
1816
+ if (whole2) return { name: names[Number(whole2[1])] };
1812
1817
  return text2.includes(MARK) ? void 0 : null;
1813
1818
  };
1814
1819
  const segments = [];
@@ -1965,8 +1970,8 @@ var require_linkOnly = __commonJS({
1965
1970
  for (const run of decoded(text2).split(HOST_BREAK)) {
1966
1971
  if (names.size > MAX_EMBEDDED_NAMES) break;
1967
1972
  if (!run) continue;
1968
- const whole = embeddedName(run);
1969
- if (whole) names.add(whole);
1973
+ const whole2 = embeddedName(run);
1974
+ if (whole2) names.add(whole2);
1970
1975
  for (const piece of run.split(NOT_NAME_CHARACTER)) {
1971
1976
  const name = piece && piece !== run ? embeddedName(piece) : null;
1972
1977
  if (name) names.add(name);
@@ -6795,7 +6800,9 @@ async function tailTunnelLogs(options) {
6795
6800
 
6796
6801
  // src/preview.ts
6797
6802
  import { spawn as spawn6 } from "node:child_process";
6803
+ import { createHash as createHash2 } from "node:crypto";
6798
6804
  import fs12 from "node:fs";
6805
+ import net5 from "node:net";
6799
6806
  import os7 from "node:os";
6800
6807
  import path13 from "node:path";
6801
6808
  import kleur8 from "kleur";
@@ -7110,6 +7117,7 @@ var MAX_HEADER_BYTES = 128 * 1024;
7110
7117
 
7111
7118
  // src/web-read.ts
7112
7119
  var redaction = __toESM(require_redaction(), 1);
7120
+ import { randomUUID as randomUUID2 } from "node:crypto";
7113
7121
  import dns2 from "node:dns";
7114
7122
  import http3 from "node:http";
7115
7123
  import https from "node:https";
@@ -8110,12 +8118,109 @@ function loadWebFixtures(folder) {
8110
8118
  };
8111
8119
  }
8112
8120
 
8121
+ // src/web-budget.ts
8122
+ var ResearchBudgetError = class extends Error {
8123
+ };
8124
+ function invalidResponse() {
8125
+ throw new ResearchBudgetError("The controller returned an invalid research allowance. No further pages were loaded.");
8126
+ }
8127
+ function object(value) {
8128
+ return value !== null && typeof value === "object" && !Array.isArray(value);
8129
+ }
8130
+ function whole(value, min, max) {
8131
+ return typeof value === "number" && Number.isSafeInteger(value) && value >= min && value <= max;
8132
+ }
8133
+ function readBudget(value) {
8134
+ if (!object(value) || value.scope !== "this task" || !whole(value.loadsPerTurn, 1, 200) || !whole(value.loadsPerHost, 1, value.loadsPerTurn) || !whole(value.loadsUsed, 0, value.loadsPerTurn) || value.loadsLeft !== value.loadsPerTurn - value.loadsUsed || value.hostSpacingSeconds !== 2 || !object(value.hosts) || Object.keys(value.hosts).length > 1200) invalidResponse();
8135
+ for (const entry of Object.values(value.hosts)) {
8136
+ if (!object(entry) || !whole(entry.loads, 0, value.loadsPerHost) || entry.lastAt !== void 0 && (typeof entry.lastAt !== "number" || !Number.isFinite(entry.lastAt))) invalidResponse();
8137
+ }
8138
+ return value;
8139
+ }
8140
+ function sharedResearchBudget(env, fetcher = globalThis.fetch) {
8141
+ const enabled = env.INSTAFY_RESEARCH_SHARED?.trim();
8142
+ const rawUrl = env.INSTAFY_RESEARCH_CONTROLLER_URL?.trim();
8143
+ const token = env.INSTAFY_RESEARCH_ACCESS_TOKEN?.trim();
8144
+ if (enabled === void 0 && rawUrl === void 0 && token === void 0) return null;
8145
+ if (enabled !== "1" || !rawUrl || !token || /[\r\n]/.test(token)) {
8146
+ throw new ResearchBudgetError("This task requires a shared research allowance, but its runtime binding is incomplete. No pages were loaded.");
8147
+ }
8148
+ let url;
8149
+ try {
8150
+ url = new URL(rawUrl);
8151
+ if (!/^https?:$/.test(url.protocol) || url.username || url.password || url.search || url.hash || url.pathname !== "/") throw new Error();
8152
+ } catch {
8153
+ throw new ResearchBudgetError("The task's research controller binding is invalid. No pages were loaded.");
8154
+ }
8155
+ return new SharedResearchBudget(new URL("/agent/research-budget", url.origin), token, fetcher);
8156
+ }
8157
+ var SharedResearchBudget = class {
8158
+ constructor(endpoint, token, fetcher) {
8159
+ this.endpoint = endpoint;
8160
+ this.token = token;
8161
+ this.fetcher = fetcher;
8162
+ this.current = null;
8163
+ }
8164
+ async request(body, remainingMs = 5e3) {
8165
+ const deadline = Date.now() + Math.max(1, Math.min(5e3, body?.remainingMs ?? remainingMs));
8166
+ for (let attempt2 = 0; attempt2 < 2; attempt2 += 1) {
8167
+ const abort = new AbortController();
8168
+ const timer = setTimeout(() => abort.abort(), Math.max(1, deadline - Date.now()));
8169
+ try {
8170
+ let response;
8171
+ try {
8172
+ response = await this.fetcher(this.endpoint.href, {
8173
+ method: body ? "POST" : "GET",
8174
+ headers: {
8175
+ Authorization: `Bearer ${this.token}`,
8176
+ Accept: "application/json",
8177
+ ...body ? { "Content-Type": "application/json" } : {}
8178
+ },
8179
+ ...body ? { body: JSON.stringify(body) } : {},
8180
+ redirect: "error",
8181
+ signal: abort.signal
8182
+ });
8183
+ } catch {
8184
+ if (attempt2 === 0 && !abort.signal.aborted && Date.now() < deadline) continue;
8185
+ throw new ResearchBudgetError("The task's shared research allowance is unavailable. No further pages were loaded; do not switch to a local allowance.");
8186
+ }
8187
+ if (!response.ok) {
8188
+ throw new ResearchBudgetError(`The controller refused the shared research allowance (HTTP ${response.status}). No further pages were loaded.`);
8189
+ }
8190
+ let value;
8191
+ try {
8192
+ value = await response.json();
8193
+ } catch {
8194
+ invalidResponse();
8195
+ }
8196
+ if (!object(value)) invalidResponse();
8197
+ return value;
8198
+ } finally {
8199
+ clearTimeout(timer);
8200
+ }
8201
+ }
8202
+ throw new ResearchBudgetError("The task's shared research allowance is unavailable.");
8203
+ }
8204
+ async status(remainingMs = 5e3) {
8205
+ const response = await this.request(void 0, remainingMs);
8206
+ this.current = readBudget(response.budget);
8207
+ return this.current;
8208
+ }
8209
+ async reserve(loadId, requestId, host, remainingMs) {
8210
+ const response = await this.request({ loadId, requestId, host, remainingMs: Math.max(0, Math.min(6e5, Math.trunc(remainingMs))) });
8211
+ const budget = readBudget(response.budget);
8212
+ if (typeof response.allowed !== "boolean" || !whole(response.waitMs, 0, 6e5) || (response.allowed ? response.code !== void 0 : !["turn_limit", "host_limit", "time_limit"].includes(String(response.code)))) invalidResponse();
8213
+ this.current = budget;
8214
+ return { allowed: response.allowed, ...response.code ? { code: response.code } : {}, waitMs: response.waitMs, budget };
8215
+ }
8216
+ };
8217
+
8113
8218
  // src/web-read.ts
8114
8219
  var ROBOTS_AGENTS = ["*", "instafy-agent", ...AI_AGENTS];
8115
8220
  var LIMITS = Object.freeze({
8116
8221
  urlsPerCall: 6,
8117
- loadsPerTurn: 12,
8118
- loadsPerHost: 4,
8222
+ loadsPerTurn: 100,
8223
+ loadsPerHost: 25,
8119
8224
  hostSpacingMs: 2e3,
8120
8225
  callMs: 6e4,
8121
8226
  pageTimeoutMs: 2e4,
@@ -8377,6 +8482,7 @@ var TELL = {
8377
8482
  http_error: "The site answered with an error. Use another source rather than retrying.",
8378
8483
  unreachable: "The site did not answer. Use another source rather than retrying.",
8379
8484
  timeout: "The site did not answer in time. Use another source rather than retrying.",
8485
+ budget_unavailable: "Research stopped because its shared allowance could not be checked. Keep the evidence already collected; further fresh reads require controller approval.",
8380
8486
  needs_browser: "This page builds its text with JavaScript, so a plain read sees almost nothing. Use another source, or ask for a visible browser turn for this page.",
8381
8487
  no_reuse: `This site's terms bar reusing its content. Summarize it in your own words, cite "seen <date> on <host>" and link back; never copy its reviews, photos or price lists, and keep its content out of anything published or shared by public link (links only there).`
8382
8488
  };
@@ -8499,7 +8605,7 @@ async function readOne(rawUrl, options, ctx) {
8499
8605
  status: "skipped",
8500
8606
  code,
8501
8607
  message,
8502
- tell: TELL[code]
8608
+ tell: ctx.sharedBudget?.current && code !== "time_limit" ? code === "turn_limit" ? `This task's shared allowance of ${ctx.sharedBudget.current.loadsPerTurn} page loads is used. Work with the evidence already collected, or ask the person.` : `This task has used its ${ctx.sharedBudget.current.loadsPerHost} page loads for this site. Use another source.` : TELL[code]
8503
8609
  });
8504
8610
  let sibling = wwwSibling(requestedUrl);
8505
8611
  const fail = (code, message, extra2 = {}) => ({
@@ -8531,15 +8637,24 @@ async function readOne(rawUrl, options, ctx) {
8531
8637
  return present(page, cached, true, options, ctx.guard);
8532
8638
  }
8533
8639
  }
8534
- if (earlier.loads >= LIMITS.loadsPerTurn) {
8535
- return skip("turn_limit", `this turn already loaded ${earlier.loads} pages (limit ${LIMITS.loadsPerTurn})`);
8536
- }
8537
- if (earlier.hostLoads >= LIMITS.loadsPerHost) {
8538
- return skip("host_limit", `this turn already read ${firstSite} ${earlier.hostLoads} times (limit ${LIMITS.loadsPerHost})`);
8640
+ if (ctx.sharedBudget) {
8641
+ if (ctx.deadline - ctx.now() < 1e3) return skip("time_limit", "this call reached its time limit before this URL");
8642
+ const budget = await ctx.sharedBudget.status(ctx.deadline - ctx.now());
8643
+ if (budget.loadsLeft === 0) return skip("turn_limit", `this task already loaded ${budget.loadsUsed} pages (limit ${budget.loadsPerTurn})`);
8644
+ const used = budget.hosts[firstSite]?.loads ?? 0;
8645
+ if (used >= budget.loadsPerHost) return skip("host_limit", `this task already read ${firstSite} ${used} times (limit ${budget.loadsPerHost})`);
8646
+ } else {
8647
+ if (earlier.loads >= LIMITS.loadsPerTurn) {
8648
+ return skip("turn_limit", `this turn already loaded ${earlier.loads} pages (limit ${LIMITS.loadsPerTurn})`);
8649
+ }
8650
+ if (earlier.hostLoads >= LIMITS.loadsPerHost) {
8651
+ return skip("host_limit", `this turn already read ${firstSite} ${earlier.hostLoads} times (limit ${LIMITS.loadsPerHost})`);
8652
+ }
8539
8653
  }
8540
8654
  if (ctx.deadline - ctx.now() < 1e3) return skip("time_limit", "this call reached its time limit before this URL");
8541
8655
  const redirects = [];
8542
8656
  const chainSites = /* @__PURE__ */ new Set();
8657
+ const loadId = randomUUID2();
8543
8658
  let current = new URL(requested);
8544
8659
  let response = null;
8545
8660
  for (let hop = 0; ; hop += 1) {
@@ -8564,7 +8679,21 @@ async function readOne(rawUrl, options, ctx) {
8564
8679
  const robots = await robotsVerdict(current, ctx);
8565
8680
  if ("failed" in robots) return fail(robots.failed, via + robots.message, hopExtra);
8566
8681
  if (robots.refused) return stop("robots", via + robots.refused, host2, hopExtra);
8567
- if (!chainSites.has(site)) {
8682
+ if (ctx.sharedBudget) {
8683
+ const remainingMs = ctx.deadline - ctx.now();
8684
+ if (remainingMs < 1e3) return skip("time_limit", `${via}this call reached its time limit before reserving a load`, hopExtra);
8685
+ const reservation = await ctx.sharedBudget.reserve(loadId, randomUUID2(), site, remainingMs - 1e3);
8686
+ if (!reservation.allowed) {
8687
+ const code = reservation.code;
8688
+ const budget = reservation.budget;
8689
+ const message = code === "turn_limit" ? `this task already loaded ${budget.loadsUsed} pages (limit ${budget.loadsPerTurn})` : code === "host_limit" ? `this task already read ${site} ${budget.hosts[site]?.loads ?? 0} times (limit ${budget.loadsPerHost})` : `no time left in this call for the next paced read of ${host2}`;
8690
+ return skip(code, via + message, hopExtra);
8691
+ }
8692
+ if (reservation.waitMs + 1e3 > ctx.deadline - ctx.now()) {
8693
+ return skip("time_limit", `${via}this call reached its time limit while reserving the next paced read`, hopExtra);
8694
+ }
8695
+ if (reservation.waitMs > 0) await ctx.sleep(reservation.waitMs);
8696
+ } else if (!chainSites.has(site)) {
8568
8697
  const now = ctx.now();
8569
8698
  const reservation = await ctx.turn.update((state) => {
8570
8699
  if (hop === 0 && state.loads >= LIMITS.loadsPerTurn) {
@@ -8775,6 +8904,7 @@ async function webRead(options, deps = {}) {
8775
8904
  now,
8776
8905
  sleep: deps.sleep ?? realSleep,
8777
8906
  turn,
8907
+ sharedBudget: sharedResearchBudget(env, deps.budgetFetch),
8778
8908
  cache: new Cache(cacheDir === null ? null : apart(cacheDir)),
8779
8909
  guard,
8780
8910
  viewer,
@@ -8782,32 +8912,38 @@ async function webRead(options, deps = {}) {
8782
8912
  robotsMemo: /* @__PURE__ */ new Map()
8783
8913
  };
8784
8914
  const pages = [];
8785
- for (const url of urls) {
8786
- const page = await readOne(url, { maxChars, links, fresh: Boolean(options.fresh), terms }, ctx);
8915
+ let interruptedBudget = null;
8916
+ for (const [index, url] of urls.entries()) {
8917
+ let page;
8918
+ try {
8919
+ page = await readOne(url, { maxChars, links, fresh: Boolean(options.fresh), terms }, ctx);
8920
+ } catch (error) {
8921
+ if (!(error instanceof ResearchBudgetError) || !ctx.sharedBudget?.current || !pages.length) throw error;
8922
+ interruptedBudget = ctx.sharedBudget.current;
8923
+ pages.push(...urls.slice(index).map((remaining) => ({
8924
+ ...basePage(remaining, new Date(now()).toISOString(), viewer),
8925
+ code: "budget_unavailable",
8926
+ message: error.message,
8927
+ tell: TELL.budget_unavailable
8928
+ })));
8929
+ break;
8930
+ }
8787
8931
  if (page.status === "failed" && unanswered(page.code, page)) {
8788
8932
  const key = unansweredKey(new URL(url));
8789
- await turn.update((state2) => {
8790
- (state2.unanswered ??= {})[key] = now();
8933
+ await turn.update((state) => {
8934
+ (state.unanswered ??= {})[key] = now();
8791
8935
  });
8792
8936
  }
8793
8937
  pages.push(page);
8794
8938
  }
8795
- const state = await turn.update((s) => ({ loads: s.loads, hosts: Object.fromEntries(Object.entries(s.hosts).map(([h, r]) => [h, r.loads])) }));
8939
+ const budget = interruptedBudget ? lastKnownBudget(interruptedBudget, warnings) : ctx.sharedBudget ? await finalSharedBudget(ctx.sharedBudget, deadline - now(), warnings) : await localBudget(turn, scope.label);
8796
8940
  return {
8797
8941
  ok: true,
8798
8942
  viewer,
8799
8943
  userAgent: USER_AGENT,
8800
8944
  notice: NOTICE,
8801
8945
  pages,
8802
- budget: {
8803
- scope: scope.label,
8804
- loadsUsed: state.loads,
8805
- loadsLeft: Math.max(0, LIMITS.loadsPerTurn - state.loads),
8806
- loadsPerTurn: LIMITS.loadsPerTurn,
8807
- loadsPerHost: LIMITS.loadsPerHost,
8808
- hostSpacingSeconds: LIMITS.hostSpacingMs / 1e3,
8809
- hosts: state.hosts
8810
- },
8946
+ budget,
8811
8947
  guards: {
8812
8948
  linkOnly: {
8813
8949
  sites: guard.list.sites.length,
@@ -8821,6 +8957,63 @@ async function webRead(options, deps = {}) {
8821
8957
  warnings
8822
8958
  };
8823
8959
  }
8960
+ function reportBudget(budget) {
8961
+ return { ...budget, hosts: Object.fromEntries(Object.entries(budget.hosts).map(([host, value]) => [host, value.loads])) };
8962
+ }
8963
+ function lastKnownBudget(budget, warnings) {
8964
+ warnings.push("The shared research allowance could not be refreshed. Budget counts are the last validated controller snapshot and may have changed; another fresh read still requires controller approval.");
8965
+ return { ...reportBudget(budget), lastKnown: true };
8966
+ }
8967
+ async function finalSharedBudget(shared, remainingMs, warnings) {
8968
+ try {
8969
+ if (remainingMs <= 0) throw new ResearchBudgetError("The call deadline elapsed before the shared research allowance could be refreshed.");
8970
+ return reportBudget(await shared.status(remainingMs));
8971
+ } catch (error) {
8972
+ if (!(error instanceof ResearchBudgetError) || !shared.current) throw error;
8973
+ return lastKnownBudget(shared.current, warnings);
8974
+ }
8975
+ }
8976
+ async function localBudget(turn, scope) {
8977
+ return turn.update((state) => ({
8978
+ scope,
8979
+ loadsUsed: state.loads,
8980
+ loadsLeft: Math.max(0, LIMITS.loadsPerTurn - state.loads),
8981
+ loadsPerTurn: LIMITS.loadsPerTurn,
8982
+ loadsPerHost: LIMITS.loadsPerHost,
8983
+ hostSpacingSeconds: LIMITS.hostSpacingMs / 1e3,
8984
+ hosts: Object.fromEntries(Object.entries(state.hosts).map(([host, value]) => [host, value.loads]))
8985
+ }));
8986
+ }
8987
+ async function webBudget(deps = {}) {
8988
+ const env = deps.env ?? process.env;
8989
+ const cwd = deps.cwd ?? process.cwd();
8990
+ const shared = sharedResearchBudget(env, deps.budgetFetch);
8991
+ if (shared) return { budget: reportBudget(await shared.status()) };
8992
+ const fixtureFolder = webFixturesFolder(env, cwd);
8993
+ const fixtures = fixtureFolder ? loadWebFixtures(fixtureFolder) : null;
8994
+ const dirs = webDirs("web-read", env, cwd);
8995
+ const stateDir = deps.stateDir ?? dirs.stateDir;
8996
+ const scope = turnScope(env);
8997
+ const turn = new TurnStore(
8998
+ fixtures ? path11.join(stateDir, "fixtures", sha256(fixtures.folder).slice(0, 16)) : stateDir,
8999
+ scope,
9000
+ deps.now ?? Date.now
9001
+ );
9002
+ return { budget: await localBudget(turn, scope.label) };
9003
+ }
9004
+ async function runWebBudgetCli(opts, io = { out: console.log, err: console.error }, deps = {}) {
9005
+ try {
9006
+ const report = await webBudget(deps);
9007
+ const b = report.budget;
9008
+ io.out(opts.json ? JSON.stringify(report, null, 1) : `${b.loadsLeft} page loads left in ${b.scope} (${b.loadsUsed} of ${b.loadsPerTurn} used; ${b.loadsPerHost} per site, ${b.hostSpacingSeconds} s apart).`);
9009
+ return WEB_READ_EXIT_OK;
9010
+ } catch (error) {
9011
+ const message = error instanceof Error ? error.message : String(error);
9012
+ if (opts.json) io.out(JSON.stringify({ ok: false, exitCode: WEB_READ_EXIT_INTERNAL, error: message }));
9013
+ else io.err(message);
9014
+ return WEB_READ_EXIT_INTERNAL;
9015
+ }
9016
+ }
8824
9017
  function clamp(value, min, max) {
8825
9018
  return Math.min(max, Math.max(min, Math.trunc(value)));
8826
9019
  }
@@ -8848,13 +9041,13 @@ function formatWebRead(report) {
8848
9041
  }
8849
9042
  out.push("");
8850
9043
  }
8851
- out.push(`${report.budget.loadsUsed} of ${report.budget.loadsPerTurn} page loads used in ${report.budget.scope}.`);
9044
+ out.push(`${report.budget.lastKnown ? "Last known controller count: " : ""}${report.budget.loadsUsed} of ${report.budget.loadsPerTurn} page loads used in ${report.budget.scope}.`);
8852
9045
  for (const warning of report.warnings) out.push(`warning: ${warning}`);
8853
9046
  out.push(report.notice);
8854
9047
  return out.join("\n");
8855
9048
  }
8856
9049
  async function runWebReadCli(urls, opts, io = { out: console.log, err: console.error }, deps = {}) {
8857
- const whole = (raw, name, min, max) => {
9050
+ const whole2 = (raw, name, min, max) => {
8858
9051
  if (raw === void 0) return void 0;
8859
9052
  const n = Number(raw);
8860
9053
  if (!Number.isInteger(n) || n < min || n > max) throw new WebReadUsageError(`${name} must be a whole number from ${min} to ${max}.`);
@@ -8865,8 +9058,8 @@ async function runWebReadCli(urls, opts, io = { out: console.log, err: console.e
8865
9058
  {
8866
9059
  urls,
8867
9060
  find: opts.find,
8868
- maxChars: whole(opts.maxChars, "--max-chars", LIMITS.minChars, LIMITS.maxChars),
8869
- links: whole(opts.links, "--links", 0, LIMITS.maxLinks),
9061
+ maxChars: whole2(opts.maxChars, "--max-chars", LIMITS.minChars, LIMITS.maxChars),
9062
+ links: whole2(opts.links, "--links", 0, LIMITS.maxLinks),
8870
9063
  fresh: opts.fresh
8871
9064
  },
8872
9065
  deps
@@ -8876,7 +9069,7 @@ async function runWebReadCli(urls, opts, io = { out: console.log, err: console.e
8876
9069
  } catch (error) {
8877
9070
  const usage = error instanceof WebReadUsageError;
8878
9071
  const code = usage ? WEB_READ_EXIT_USAGE : WEB_READ_EXIT_INTERNAL;
8879
- const message = usage || error instanceof LinkOnlyListUnavailableError || error instanceof WebFixturesError ? error.message : `web read failed, nothing was read: ${error instanceof Error ? error.message : String(error)}`;
9072
+ const message = usage || error instanceof LinkOnlyListUnavailableError || error instanceof WebFixturesError || error instanceof ResearchBudgetError ? error.message : `web read failed: ${error instanceof Error ? error.message : String(error)}`;
8880
9073
  if (opts.json) io.out(JSON.stringify({ ok: false, exitCode: code, error: message }));
8881
9074
  else io.err(message);
8882
9075
  return code;
@@ -8887,10 +9080,13 @@ function registerWebCommand(program2) {
8887
9080
  "Read public web pages as untrusted data with their source (robots.txt, link-only sites and stop rules enforced), search for links to read (Octo's turns only), and give the person links to link-only sites (guided mode)"
8888
9081
  );
8889
9082
  web.command("read").description(
8890
- `Read 1-6 public pages over plain HTTP and print their text, links and structured data with provenance (exit 2: nothing read, 64: bad options). Per turn: ${LIMITS.loadsPerTurn} loads, ${LIMITS.loadsPerHost} per site, ${LIMITS.hostSpacingMs / 1e3} s apart. Stopped URLs (login, CAPTCHA, paywall, 401/403/429, download, secret screen, robots.txt, link-only, private address) are never read another way.`
9083
+ `Read 1-6 public pages over plain HTTP and print their text, links and structured data with provenance (exit 2: nothing read, 64: bad options). Uses the task's shared allowance; standalone: ${LIMITS.loadsPerTurn} loads, ${LIMITS.loadsPerHost} per site, ${LIMITS.hostSpacingMs / 1e3} s apart. Stopped URLs (login, CAPTCHA, paywall, 401/403/429, download, secret screen, robots.txt, link-only, private address) are never read another way.`
8891
9084
  ).argument("<url...>", "http(s) URLs to read").option("--find <terms>", 'Return only the passages that mention these words ("quoted phrases" kept whole)').option("--max-chars <n>", `Most characters of text per page (default ${LIMITS.defaultChars}, at most ${LIMITS.maxChars})`).option("--links <n>", `Most links per page (default ${LIMITS.defaultLinks}, at most ${LIMITS.maxLinks})`).option("--fresh", "Read again even if this workspace's cache has the page (never re-reads a stopped URL)").option("--json", "Output JSON").action(async (urls, opts) => {
8892
9085
  process.exitCode = await runWebReadCli(urls, opts);
8893
9086
  });
9087
+ web.command("budget").description("Show this task's remaining shared page allowance without spending it (or the standalone session allowance)").option("--json", "Output JSON").action(async (opts) => {
9088
+ process.exitCode = await runWebBudgetCli(opts);
9089
+ });
8894
9090
  return web;
8895
9091
  }
8896
9092
 
@@ -9314,6 +9510,18 @@ async function readPageDescription(frame) {
9314
9510
  const raw = await frame.evaluate(() => document.querySelector('meta[name="description" i]')?.getAttribute("content") ?? null).catch(() => null);
9315
9511
  return cleanDescription(raw);
9316
9512
  }
9513
+ var READY_MARK_ATTRIBUTE = "data-instafy-ready";
9514
+ var READY_MARK_WAIT_MS = 3e4;
9515
+ async function waitForReadyMark(frame, timeoutMs) {
9516
+ const mark = await frame.evaluate((name) => document.documentElement?.getAttribute(name) ?? null, READY_MARK_ATTRIBUTE).catch(() => null);
9517
+ if (mark === null) return "none";
9518
+ if (mark === "true") return "ready";
9519
+ if (timeoutMs <= 0) return "late";
9520
+ return frame.waitForFunction((name) => document.documentElement?.getAttribute(name) === "true", READY_MARK_ATTRIBUTE, {
9521
+ timeout: timeoutMs,
9522
+ polling: 100
9523
+ }).then(() => "ready").catch(() => "late");
9524
+ }
9317
9525
  async function pageThumbnail(page, context) {
9318
9526
  const cdp = await context.newCDPSession(page);
9319
9527
  try {
@@ -9519,10 +9727,19 @@ async function checkViewport(browser, target, vp, siteOrigin, outDir, timeoutMs,
9519
9727
  (ours(u) ? errors : warnings).push(`HTTP ${s}: ${shortUrl(u)}`);
9520
9728
  });
9521
9729
  await page.goto(target.pageUrl, { waitUntil: "load", timeout: timeoutMs });
9522
- await page.waitForLoadState("networkidle", { timeout: Math.min(8e3, timeoutMs) }).catch(() => {
9523
- warnings.push("the network was still busy after 8 s (screenshot taken anyway)");
9524
- });
9525
- const frame = target.framed ? page.frames().find((f) => f !== page.mainFrame() && isSiteUrl(String(f.url()), siteOrigin)) : page.mainFrame();
9730
+ const siteFrame = () => target.framed ? page.frames().find((f) => f !== page.mainFrame() && isSiteUrl(String(f.url()), siteOrigin)) : page.mainFrame();
9731
+ const readiness = await waitForReadyMark(
9732
+ siteFrame() ?? page.mainFrame(),
9733
+ Math.max(0, Math.min(READY_MARK_WAIT_MS, timeoutMs) - (Date.now() - t0))
9734
+ );
9735
+ if (readiness === "none") {
9736
+ await page.waitForLoadState("networkidle", { timeout: Math.min(8e3, timeoutMs) }).catch(() => {
9737
+ warnings.push("the network was still busy after 8 s (screenshot taken anyway)");
9738
+ });
9739
+ } else if (readiness === "late") {
9740
+ warnings.push(`the page still said it was drawing (${READY_MARK_ATTRIBUTE}) after ${Math.round((Date.now() - t0) / 1e3)} s; screenshot taken anyway`);
9741
+ }
9742
+ const frame = siteFrame();
9526
9743
  let framed = "ok";
9527
9744
  let snap = { text: 0, media: 0, elements: 0, title: "" };
9528
9745
  if (refused) framed = "refused";
@@ -9557,7 +9774,7 @@ async function checkViewport(browser, target, vp, siteOrigin, outDir, timeoutMs,
9557
9774
  let thumbnail;
9558
9775
  if (takeThumbnail) {
9559
9776
  const [firstUnstopped] = unstopped;
9560
- thumbnail = firstUnstopped ? { skipped: `a redirect led to ${firstUnstopped[0].slice(0, 200)} (${firstUnstopped[1]}), which a picture never shows` } : blank.blank ? { skipped: "the page looked blank" } : await pageThumbnail(page, context).then(async (shot) => "image" in shot ? { image: shot.image, description: await readPageDescription(page.mainFrame()) } : shot).catch((error) => ({ skipped: `the picture could not be taken: ${firstLine(error)}` }));
9777
+ thumbnail = firstUnstopped ? { skipped: `a redirect led to ${firstUnstopped[0].slice(0, 200)} (${firstUnstopped[1]}), which a picture never shows` } : blank.blank ? { skipped: "the page looked blank" } : readiness === "late" ? { skipped: "the page never said it was ready, so the card keeps its picture" } : await pageThumbnail(page, context).then(async (shot) => "image" in shot ? { image: shot.image, description: await readPageDescription(page.mainFrame()) } : shot).catch((error) => ({ skipped: `the picture could not be taken: ${firstLine(error)}` }));
9561
9778
  }
9562
9779
  return {
9563
9780
  name: vp.name,
@@ -9599,7 +9816,9 @@ async function runSiteCheck(opts) {
9599
9816
  const port = parsePreviewPort(opts.port, env);
9600
9817
  const sitePath = parseCheckPath(opts.path);
9601
9818
  const timeoutMs = opts.timeoutMs ?? 3e4;
9602
- const viewports = VIEWPORTS.filter((v) => !opts.phone || v.name === "phone");
9819
+ const viewports = VIEWPORTS.filter(
9820
+ (v) => !opts.phone && !opts.desktop ? true : opts.phone && v.name === "phone" || opts.desktop && v.name === "desktop"
9821
+ );
9603
9822
  let outDir = null;
9604
9823
  if (opts.outDir !== void 0) {
9605
9824
  outDir = path12.resolve(opts.outDir);
@@ -9713,12 +9932,14 @@ async function runPreviewCheckCli(opts, io = { out: console.log, err: console.er
9713
9932
  const port = parsePreviewPort(opts.port, env);
9714
9933
  const sitePath = parseCheckPath(opts.path);
9715
9934
  const wantsThumbnail = Boolean(io.uploadThumbnail && io.hasOpenPreview?.(port, sitePath));
9935
+ const takenAt = Date.now();
9716
9936
  const result = await runSiteCheck({
9717
9937
  port,
9718
9938
  path: opts.path,
9719
9939
  thumbnail: wantsThumbnail,
9720
9940
  proxyServer: io.proxyServer,
9721
9941
  phone: opts.phone,
9942
+ desktop: opts.desktop,
9722
9943
  framed: opts.framed,
9723
9944
  workspacePlaywright: opts.workspacePlaywright,
9724
9945
  outDir: opts.out,
@@ -9733,7 +9954,9 @@ async function runPreviewCheckCli(opts, io = { out: console.log, err: console.er
9733
9954
  }
9734
9955
  if (wantsThumbnail && result.thumbnail && io.uploadThumbnail) {
9735
9956
  const shot = result.thumbnail;
9736
- const outcome = "skipped" in shot ? { status: "skipped", reason: shot.skipped } : await io.uploadThumbnail({ port, path: sitePath, image: shot.image, description: shot.description }).catch((error) => ({ status: "failed", reason: firstLine(error) }));
9957
+ if ("skipped" in shot) await io.pictureTried?.({ port, path: sitePath, takenAt }).catch(() => {
9958
+ });
9959
+ const outcome = "skipped" in shot ? { status: "skipped", reason: shot.skipped } : await io.uploadThumbnail({ port, path: sitePath, image: shot.image, description: shot.description, takenAt }).catch((error) => ({ status: "failed", reason: firstLine(error) }));
9737
9960
  if (outcome) {
9738
9961
  result.report.thumbnail = outcome.status;
9739
9962
  if (outcome.reason) result.report.thumbnailReason = outcome.reason;
@@ -9753,7 +9976,7 @@ async function runPreviewCheckCli(opts, io = { out: console.log, err: console.er
9753
9976
  function registerPreviewCheckCommand(preview, card = {}) {
9754
9977
  return preview.command("check").description(
9755
9978
  "Load a local site in a headless browser and report its title, errors, failed requests and a blank page; writes desktop and phone screenshots, and refreshes the picture on the card of a preview open on that port (exit 1: problems, 2: nothing answering, 3: port not allowed, 4: no browser, 64: bad options, 70: the check itself failed)"
9756
- ).option("--port <port>", "Local port the site listens on (1024-65535)").option("--path <path>", "Path to load (default /); a ?query and #hash are fine").option("--phone", "Only the phone size (390\xD7844); default: desktop 1280\xD7800 and phone").option("--framed", "Load the site inside a frame on another origin, and report a site that refuses to be framed").option("--workspace-playwright", "Also look for Playwright in this project's node_modules (runs the project's own copy)").option("--out <dir>", "Folder for desktop.png and phone.png (default: a new private folder under the temp folder each run; the report names it)").option("--timeout <ms>", "Page load timeout in ms (default 30000)").option("--wait <ms>", "Extra settle time after load in ms (default 600)").option("--json", "Output JSON").action(async (opts) => {
9979
+ ).option("--port <port>", "Local port the site listens on (1024-65535)").option("--path <path>", "Path to load (default /); a ?query and #hash are fine").option("--phone", "Only the phone size (390\xD7844); default: desktop 1280\xD7800 and phone").option("--desktop", "Only the desktop size (1280\xD7800), the one the card's picture is taken from").option("--framed", "Load the site inside a frame on another origin, and report a site that refuses to be framed").option("--workspace-playwright", "Also look for Playwright in this project's node_modules (runs the project's own copy)").option("--out <dir>", "Folder for desktop.png and phone.png (default: a new private folder under the temp folder each run; the report names it)").option("--timeout <ms>", "Page load timeout in ms (default 30000)").option("--wait <ms>", "Extra settle time after load in ms (default 600)").option("--json", "Output JSON").action(async (opts) => {
9757
9980
  process.exitCode = await runPreviewCheckCli(opts, { out: console.log, err: console.error, ...card });
9758
9981
  });
9759
9982
  }
@@ -9767,6 +9990,8 @@ function thumbnailBudgetMs(env = process.env) {
9767
9990
  }
9768
9991
  var NETWORK_IDLE_MS = 6e3;
9769
9992
  var SETTLE_MS = 1e3;
9993
+ var READY_SETTLE_MS = 300;
9994
+ var SHOT_RESERVE_MS = 3e3;
9770
9995
  var MAX_THUMBNAIL_FILE_BYTES = 512 * 1024;
9771
9996
  var MAX_HTML_BYTES = 512 * 1024;
9772
9997
  var MIN_SIDE = 64;
@@ -9923,7 +10148,8 @@ function errorLine(error) {
9923
10148
  async function captureSiteThumbnail(options) {
9924
10149
  const env = options.env ?? process.env;
9925
10150
  const budget = options.budgetMs ?? thumbnailBudgetMs(env);
9926
- const deadline = Date.now() + budget;
10151
+ const began = Date.now();
10152
+ let deadline = began + budget;
9927
10153
  const host = options.host === "::1" ? "[::1]" : "127.0.0.1";
9928
10154
  const siteOrigin = `http://${host}:${options.port}`;
9929
10155
  const left = () => Math.max(0, deadline - Date.now());
@@ -9985,9 +10211,18 @@ async function captureSiteThumbnail(options) {
9985
10211
  if (response && response.status() >= 400) {
9986
10212
  return { ok: false, status: "skipped", reason: `the page answered HTTP ${response.status()}` };
9987
10213
  }
9988
- await page.waitForLoadState("networkidle", { timeout: Math.max(1, Math.min(NETWORK_IDLE_MS, left())) }).catch(() => {
9989
- });
9990
- await page.waitForTimeout(Math.min(SETTLE_MS, left()));
10214
+ const mark = await waitForReadyMark(page.mainFrame(), 0);
10215
+ if (mark === "none") {
10216
+ await page.waitForLoadState("networkidle", { timeout: Math.max(1, Math.min(NETWORK_IDLE_MS, left())) }).catch(() => {
10217
+ });
10218
+ await page.waitForTimeout(Math.min(SETTLE_MS, left()));
10219
+ } else {
10220
+ deadline = Math.max(deadline, began + (options.readyWaitMs ?? READY_MARK_WAIT_MS));
10221
+ if (mark === "late" && await waitForReadyMark(page.mainFrame(), Math.max(0, left() - SHOT_RESERVE_MS)) !== "ready") {
10222
+ return { ok: false, status: "skipped", reason: `the page still said it was drawing after ${Math.round((Date.now() - began) / 1e3)} s` };
10223
+ }
10224
+ await page.waitForTimeout(Math.min(READY_SETTLE_MS, left()));
10225
+ }
9991
10226
  const description = await readPageDescription(page.mainFrame());
9992
10227
  const shot = await pageThumbnail(page, context);
9993
10228
  if ("skipped" in shot) return { ok: false, status: "skipped", reason: shot.skipped };
@@ -10002,7 +10237,16 @@ async function captureSiteThumbnail(options) {
10002
10237
  };
10003
10238
  })();
10004
10239
  const outOfTime = new Promise((resolve) => {
10005
- timer = setTimeout(() => resolve({ ok: false, status: "failed", reason: `the picture took longer than ${Math.round(budget / 1e3)} s` }), budget);
10240
+ const arm = () => {
10241
+ timer = setTimeout(() => {
10242
+ if (Date.now() < deadline) {
10243
+ arm();
10244
+ return;
10245
+ }
10246
+ resolve({ ok: false, status: "failed", reason: `the picture took longer than ${Math.round((deadline - began) / 1e3)} s` });
10247
+ }, Math.max(0, deadline - Date.now()));
10248
+ };
10249
+ arm();
10006
10250
  });
10007
10251
  try {
10008
10252
  return await Promise.race([work.catch((error) => ({ ok: false, status: "failed", reason: errorLine(error) })), outOfTime]);
@@ -10026,6 +10270,12 @@ var MAX_PATH_LENGTH2 = 512;
10026
10270
  var THUMBNAIL_UPLOAD_TIMEOUT_MS = 15e3;
10027
10271
  var STATE_LOCK_WAIT_MS = 12e4;
10028
10272
  var STATE_LOCK_STALE_MS = 10 * 6e4;
10273
+ var PORT_PLACEHOLDER = "{port}";
10274
+ var PORT_TAKEN_HINT = "Run it with --start and without --port, so this site gets a free port of its own, or pass --replace to show this site on that port instead.";
10275
+ var PICKED_PORT_TAKEN_HINT = "Pass --replace if this site replaces that one (this same site under a new title, for example); otherwise give this site a title of its own.";
10276
+ var AUTO_PORT_FIRST = 2e4;
10277
+ var AUTO_PORT_COUNT = 1e4;
10278
+ var AUTO_PORT_TRIES = 64;
10029
10279
  function instafyDir() {
10030
10280
  return path13.join(os7.homedir(), ".instafy");
10031
10281
  }
@@ -10156,10 +10406,10 @@ function lockHolderIsGone(lockFile) {
10156
10406
  return false;
10157
10407
  }
10158
10408
  }
10159
- async function withStateLock(fn) {
10409
+ async function withStateLock(fn, waitMs = STATE_LOCK_WAIT_MS) {
10160
10410
  const lockFile = previewStateLockFile();
10161
10411
  fs12.mkdirSync(instafyDir(), { recursive: true });
10162
- const deadline = Date.now() + STATE_LOCK_WAIT_MS;
10412
+ const deadline = Date.now() + waitMs;
10163
10413
  for (; ; ) {
10164
10414
  try {
10165
10415
  const fd = fs12.openSync(lockFile, "wx", 384);
@@ -10249,6 +10499,12 @@ async function controllerRequest(params) {
10249
10499
  PREVIEW_EXIT_PORT_NOT_ALLOWED
10250
10500
  );
10251
10501
  }
10502
+ if (response.status === 409 && code === "port_in_use") {
10503
+ throw new PreviewCommandError(
10504
+ `${extractControllerErrorMessage(text2) ?? "Another site's preview holds this port."} ${params.portTakenHint ?? PORT_TAKEN_HINT}`,
10505
+ PREVIEW_EXIT_PORT_NOT_ALLOWED
10506
+ );
10507
+ }
10252
10508
  const message = extractControllerErrorMessage(text2) ?? response.statusText;
10253
10509
  throw new Error(`Preview request failed (${response.status}): ${message}`);
10254
10510
  }
@@ -10457,12 +10713,12 @@ function listeningSocketInodes(procRoot, port) {
10457
10713
  continue;
10458
10714
  }
10459
10715
  for (const line of text2.split("\n").slice(1)) {
10460
- const fields = line.trim().split(/\s+/);
10461
- if (fields.length < 10) continue;
10462
- const local = fields[1] ?? "";
10716
+ const fields2 = line.trim().split(/\s+/);
10717
+ if (fields2.length < 10) continue;
10718
+ const local = fields2[1] ?? "";
10463
10719
  const portHex = local.slice(local.lastIndexOf(":") + 1);
10464
- if (fields[3] === "0A" && Number.parseInt(portHex, 16) === port && fields[9] !== "0") {
10465
- inodes.add(fields[9]);
10720
+ if (fields2[3] === "0A" && Number.parseInt(portHex, 16) === port && fields2[9] !== "0") {
10721
+ inodes.add(fields2[9]);
10466
10722
  }
10467
10723
  }
10468
10724
  }
@@ -10638,8 +10894,159 @@ async function startPreviewSite(params) {
10638
10894
  await new Promise((resolve) => setTimeout(resolve, START_POLL_MS));
10639
10895
  }
10640
10896
  }
10897
+ function withPort(argv, port) {
10898
+ return argv.map((arg) => arg.split(PORT_PLACEHOLDER).join(String(port)));
10899
+ }
10900
+ function sameArgv(a, b) {
10901
+ return a.length === b.length && a.every((arg, index) => arg === b[index]);
10902
+ }
10903
+ function sameStart(a, b) {
10904
+ return Boolean(a && Array.isArray(a.argv) && a.cwd === b.cwd && sameArgv(a.argv, b.argv));
10905
+ }
10906
+ function preferredSitePort(start) {
10907
+ const digest2 = createHash2("sha256").update(`${start.cwd}\0${start.argv.join("\0")}`).digest();
10908
+ return AUTO_PORT_FIRST + digest2.readUInt32BE(0) % AUTO_PORT_COUNT;
10909
+ }
10910
+ async function portIsFree(port) {
10911
+ for (const host of ["127.0.0.1", "0.0.0.0", "::1", "::"]) {
10912
+ const result = await new Promise((resolve) => {
10913
+ const server = net5.createServer();
10914
+ server.once(
10915
+ "error",
10916
+ (error) => resolve(error.code === "EADDRNOTAVAIL" || error.code === "EAFNOSUPPORT" ? "absent" : "taken")
10917
+ );
10918
+ server.listen({ port, host, ...host === "::" ? { ipv6Only: false } : {} }, () => server.close(() => resolve("free")));
10919
+ });
10920
+ if (result === "taken") return false;
10921
+ }
10922
+ return true;
10923
+ }
10924
+ function startedSiteRuns(entry) {
10925
+ return Boolean(entry.startedPid && entry.startedPid > 0 && entry.startedCommand) && readProcessCommandLine(entry.startedPid) === entry.startedCommand;
10926
+ }
10927
+ async function siteOnPort(params) {
10928
+ const { port, start, root, projectId: projectId2, previews, procRoot } = params;
10929
+ if (await portIsFree(port)) return "none";
10930
+ if (previews.some(
10931
+ (entry) => entry.projectId === projectId2 && entry.localPort === port && sameStart(entry.start, start) && startedSiteRuns(entry)
10932
+ )) {
10933
+ return "same";
10934
+ }
10935
+ const listening = detectListeningProcess(port, procRoot);
10936
+ if (listening && workspaceRelativeDir(root, listening.cwd) === start.cwd && sameArgv(listening.argv, withPort(start.argv, port))) {
10937
+ return "same";
10938
+ }
10939
+ return "other";
10940
+ }
10941
+ function portTakenError(port, previews, projectId2) {
10942
+ const holder = previews.find((entry) => entry.projectId === projectId2 && entry.localPort === port && entry.title);
10943
+ const what = holder ? `Port ${port} already shows '${holder.title}'.` : `Port ${port} is in use by another server.`;
10944
+ return new PreviewCommandError(`${what} Leave out --port: --start picks a free port for this site.`, PREVIEW_EXIT_PORT_NOT_ALLOWED);
10945
+ }
10946
+ async function startOrFindSite(params) {
10947
+ const { explicitPort, start, root, projectId: projectId2, title, sitePath, procRoot, log } = params;
10948
+ const { previews } = readStateFile2();
10949
+ const onPort = (port) => siteOnPort({ port, start, root, projectId: projectId2, previews, procRoot });
10950
+ const launch = async (port) => {
10951
+ const serve = { argv: withPort(start.argv, port), cwd: start.cwd };
10952
+ const started = await startPreviewSite({ port, sitePath, serve, root, timeoutMs: params.timeoutMs });
10953
+ log(`Started the site on port ${port} (pid ${started.pid}); log: ${displayPath(started.logFile)}`);
10954
+ return { port, start, started: { ...started, command: readProcessCommandLine(started.pid) } };
10955
+ };
10956
+ if (explicitPort !== null) {
10957
+ const found = await onPort(explicitPort);
10958
+ if (found === "same") return { port: explicitPort, start, started: null };
10959
+ if (found === "other") throw portTakenError(explicitPort, previews, projectId2);
10960
+ return launch(explicitPort);
10961
+ }
10962
+ const tried = /* @__PURE__ */ new Set();
10963
+ const known = previews.find((entry) => entry.projectId === projectId2 && sameStart(entry.start, start));
10964
+ if (known) {
10965
+ tried.add(known.localPort);
10966
+ const found = await onPort(known.localPort);
10967
+ if (found === "same") return { port: known.localPort, start, started: null };
10968
+ if (found === "none") return launch(known.localPort);
10969
+ }
10970
+ const reserved = reservedPreviewPorts();
10971
+ const held = new Set(
10972
+ previews.filter((entry) => entry.projectId !== projectId2 || !sameStart(entry.start, start)).map((entry) => entry.localPort)
10973
+ );
10974
+ for (const [port, holder] of await params.openPorts()) {
10975
+ if (holder !== title) held.add(port);
10976
+ }
10977
+ const first = preferredSitePort(start);
10978
+ for (let index = 0; index < AUTO_PORT_TRIES; index += 1) {
10979
+ const port = AUTO_PORT_FIRST + (first - AUTO_PORT_FIRST + index) % AUTO_PORT_COUNT;
10980
+ if (tried.has(port) || reserved.has(port) || held.has(port)) continue;
10981
+ tried.add(port);
10982
+ const found = await onPort(port);
10983
+ if (found === "same") return { port, start, started: null };
10984
+ if (found === "none") return launch(port);
10985
+ }
10986
+ throw new PreviewCommandError(
10987
+ `No free port was found for the site: ${tried.size} ports from ${first} are taken. Stop a site this space no longer needs (instafy preview close <previewId>), then retry.`,
10988
+ PREVIEW_EXIT_NOT_LISTENING
10989
+ );
10990
+ }
10991
+ async function stopSite(pid, command) {
10992
+ if (!pid || pid <= 0 || !command) return false;
10993
+ const ours = () => readProcessCommandLine(pid) === command;
10994
+ if (!ours()) return false;
10995
+ const signal = (name) => {
10996
+ try {
10997
+ process.kill(-pid, name);
10998
+ } catch {
10999
+ try {
11000
+ process.kill(pid, name);
11001
+ } catch {
11002
+ }
11003
+ }
11004
+ };
11005
+ signal("SIGTERM");
11006
+ if (!await waitForProcessExit(pid, 4e3) && ours()) signal("SIGKILL");
11007
+ return true;
11008
+ }
11009
+ function siteRecordFor(site, previews, port, projectId2) {
11010
+ if (site?.started) {
11011
+ return { start: site.start, startedPid: site.started.pid, ...site.started.command ? { startedCommand: site.started.command } : {} };
11012
+ }
11013
+ const earlier = previews.find(
11014
+ (entry) => entry.projectId === projectId2 && entry.localPort === port && (!site || sameStart(entry.start, site.start)) && startedSiteRuns(entry)
11015
+ );
11016
+ if (earlier) return { start: earlier.start, startedPid: earlier.startedPid, startedCommand: earlier.startedCommand };
11017
+ return site ? { start: site.start } : {};
11018
+ }
11019
+ async function openPreviewPorts(params) {
11020
+ const ports = /* @__PURE__ */ new Map();
11021
+ let rows = [];
11022
+ try {
11023
+ const listed = await controllerRequest({
11024
+ controllerUrl: params.controllerUrl,
11025
+ auth: params.auth,
11026
+ method: "GET",
11027
+ route: previewsRoute(params.projectId),
11028
+ retryCommand: "instafy preview list",
11029
+ notFoundMeansUnavailable: true,
11030
+ timeoutMs: 1e4
11031
+ });
11032
+ rows = Array.isArray(listed.body) ? listed.body : [];
11033
+ } catch (error) {
11034
+ if (error instanceof PreviewCommandError && error.exitCode === PREVIEW_EXIT_UNAVAILABLE) throw error;
11035
+ return ports;
11036
+ }
11037
+ const runtimeId = readEnv("RUNTIME_ID");
11038
+ for (const raw of rows) {
11039
+ if (!raw || typeof raw !== "object") continue;
11040
+ const record2 = raw;
11041
+ const onRuntime = stringField(record2, "runtimeId");
11042
+ if (runtimeId && onRuntime && onRuntime !== runtimeId) continue;
11043
+ const port = numberField(record2, "port");
11044
+ if (port) ports.set(port, stringField(record2, "title") ?? "");
11045
+ }
11046
+ return ports;
11047
+ }
10641
11048
  async function createAndStartClient(params) {
10642
- const { opts, port, title, sitePath, projectId: projectId2, retryCommand, auth, controllerUrl, serverUrl, log, serve } = params;
11049
+ const { opts, port, title, sitePath, projectId: projectId2, retryCommand, auth, controllerUrl, serverUrl, log, serve, site, portPicked } = params;
10643
11050
  const conversationId = opts.conversationId?.trim() || void 0;
10644
11051
  const created = await controllerRequest({
10645
11052
  controllerUrl,
@@ -10655,10 +11062,12 @@ async function createAndStartClient(params) {
10655
11062
  title,
10656
11063
  ...opts.summary ? { summary: opts.summary } : {},
10657
11064
  metadata: { source: "instafy-cli" },
10658
- ...serve ? { serve } : {}
11065
+ ...serve ? { serve } : {},
11066
+ ...opts.replace ? { replace: true } : {}
10659
11067
  },
10660
11068
  retryCommand,
10661
- notFoundMeansUnavailable: true
11069
+ notFoundMeansUnavailable: true,
11070
+ ...portPicked ? { portTakenHint: PICKED_PORT_TAKEN_HINT } : {}
10662
11071
  });
10663
11072
  const grant = created.body && !Array.isArray(created.body) ? created.body : {};
10664
11073
  const previewId = stringField(grant, "previewId");
@@ -10672,6 +11081,7 @@ async function createAndStartClient(params) {
10672
11081
  const studioOrigin = parseStudioOrigin(grant["studioOrigin"]);
10673
11082
  const state = readStateFile2();
10674
11083
  const current = state.previews.find((preview) => preview.previewId === previewId) ?? null;
11084
+ const siteRecord = siteRecordFor(site, state.previews, port, projectId2);
10675
11085
  let pid = null;
10676
11086
  let binary = current?.binary;
10677
11087
  try {
@@ -10735,47 +11145,88 @@ ${started.logTail}` : "";
10735
11145
  startedAt: current?.pid === pid ? current.startedAt : (/* @__PURE__ */ new Date()).toISOString(),
10736
11146
  ...binary ? { binary } : {},
10737
11147
  ...hostSuffix ? { hostSuffix } : {},
10738
- ...studioOrigin ? { studioOrigin } : {}
11148
+ ...studioOrigin ? { studioOrigin } : {},
11149
+ ...siteRecord,
11150
+ ...serve ? { siteDir: serve.cwd } : {},
11151
+ // The same preview opened again keeps its picture until a new one is uploaded.
11152
+ ...current?.pictureAt ? { pictureAt: current.pictureAt } : {},
11153
+ ...opts.thumbnail === false ? { noThumbnail: true } : {}
10739
11154
  };
10740
11155
  upsertPreviewState(entry);
10741
11156
  return { grant, previewId, entry, current };
10742
11157
  }
10743
11158
  async function previewOpen(opts) {
10744
- const port = parsePreviewPort(opts.port);
11159
+ const explicitPort = opts.port === void 0 || String(opts.port).trim() === "" ? null : parsePreviewPort(opts.port);
10745
11160
  const title = parsePreviewTitle(opts.title);
10746
11161
  const sitePath = parsePreviewPath(opts.path);
10747
11162
  const summary = parsePreviewSummary(opts.summary);
10748
- if (opts.serveCmd?.trim()) parseExplicitServeCommand(opts.serveCmd);
11163
+ const serveArgv = opts.serveCmd?.trim() ? parseExplicitServeCommand(opts.serveCmd) : null;
11164
+ if (opts.start && !serveArgv) {
11165
+ throw new PreviewCommandError('--start needs --serve-cmd "<command>" to start the site.', PREVIEW_EXIT_NOT_LISTENING);
11166
+ }
11167
+ if (explicitPort === null) {
11168
+ if (!opts.start) {
11169
+ throw new Error(`A port is required: --port <port>. Or start the site with --start and --serve-cmd, which picks a free port.`);
11170
+ }
11171
+ if (!serveArgv.some((arg) => arg.includes(PORT_PLACEHOLDER))) {
11172
+ throw new Error(
11173
+ `--serve-cmd needs ${PORT_PLACEHOLDER} where the site's port goes, e.g. --serve-cmd "python3 -m http.server ${PORT_PLACEHOLDER} --bind 127.0.0.1": --start picks the port.`
11174
+ );
11175
+ }
11176
+ }
10749
11177
  const projectId2 = resolveProject({ project: opts.project });
10750
- const retryCommand = `instafy preview open --port ${port} --title ${shellQuote2(title)}`;
11178
+ const retryCommand = `instafy preview open${explicitPort === null ? "" : ` --port ${explicitPort}`} --title ${shellQuote2(title)}`;
10751
11179
  const { auth, controllerUrl } = resolveAuth(opts, retryCommand);
10752
11180
  const serverUrl = cleanUrl(controllerUrl);
10753
11181
  const log = (message) => console.error(kleur8.cyan(message));
10754
- const explicitServe = opts.start && opts.serveCmd?.trim() ? resolvePreviewServe({ port, serveCmd: opts.serveCmd, projectId: projectId2, cwd: opts.cwd }).serve : null;
10755
- let probe = await probeLocalSite(port, sitePath, void 0, RATHOLE_LOCAL_HOSTS);
10756
- let started = null;
10757
- if (!probe.listening && opts.start && probe.status === null && !probe.timedOut) {
10758
- const ipv6 = await probeLocalSite(port, sitePath, void 0, ["::1"]);
10759
- if (ipv6.status === null && !ipv6.timedOut) {
10760
- if (!explicitServe) {
10761
- throw new PreviewCommandError(
10762
- `Nothing answers on 127.0.0.1:${port}, and --start needs --serve-cmd "<command>" to start the site.`,
10763
- PREVIEW_EXIT_NOT_LISTENING
10764
- );
11182
+ const start = opts.start ? resolvePreviewServe({ port: explicitPort ?? 0, serveCmd: opts.serveCmd, projectId: projectId2, cwd: opts.cwd }).serve : null;
11183
+ const { grant, previewId, entry, port, site, serve, restartNote } = await withStateLock(async () => {
11184
+ const site2 = start ? await startOrFindSite({
11185
+ explicitPort,
11186
+ start,
11187
+ root: resolveWorkspaceRoot(process.env, projectId2),
11188
+ projectId: projectId2,
11189
+ title,
11190
+ sitePath,
11191
+ timeoutMs: opts.startTimeoutMs,
11192
+ procRoot: opts.procRoot,
11193
+ openPorts: () => openPreviewPorts({ controllerUrl, auth, projectId: projectId2 }),
11194
+ log
11195
+ }) : null;
11196
+ const port2 = site2?.port ?? explicitPort;
11197
+ try {
11198
+ const probe = await probeLocalSite(port2, sitePath, void 0, RATHOLE_LOCAL_HOSTS);
11199
+ if (!probe.listening) {
11200
+ throw await notListeningError(port2, sitePath, probe);
10765
11201
  }
10766
- const root = resolveWorkspaceRoot(process.env, projectId2);
10767
- started = await startPreviewSite({ port, sitePath, serve: explicitServe, root, timeoutMs: opts.startTimeoutMs });
10768
- log(`Started the site (pid ${started.pid}); log: ${displayPath(started.logFile)}`);
10769
- probe = await probeLocalSite(port, sitePath, void 0, RATHOLE_LOCAL_HOSTS);
11202
+ const { serve: serve2, note } = resolvePreviewServe({
11203
+ port: port2,
11204
+ serveCmd: opts.serveCmd?.split(PORT_PLACEHOLDER).join(String(port2)),
11205
+ projectId: projectId2,
11206
+ cwd: opts.cwd,
11207
+ procRoot: opts.procRoot
11208
+ });
11209
+ const created = await createAndStartClient({
11210
+ opts: { ...opts, summary },
11211
+ port: port2,
11212
+ title,
11213
+ sitePath,
11214
+ projectId: projectId2,
11215
+ retryCommand,
11216
+ auth,
11217
+ controllerUrl,
11218
+ serverUrl,
11219
+ log,
11220
+ serve: serve2,
11221
+ site: site2,
11222
+ portPicked: explicitPort === null
11223
+ });
11224
+ return { ...created, port: port2, site: site2, serve: serve2, restartNote: note };
11225
+ } catch (error) {
11226
+ if (site2?.started) await stopSite(site2.started.pid, site2.started.command ?? readProcessCommandLine(site2.started.pid));
11227
+ throw error;
10770
11228
  }
10771
- }
10772
- if (!probe.listening) {
10773
- throw await notListeningError(port, sitePath, probe);
10774
- }
10775
- const { serve, note: restartNote } = resolvePreviewServe({ port, serveCmd: opts.serveCmd, projectId: projectId2, cwd: opts.cwd });
10776
- const { grant, previewId, entry } = await withStateLock(
10777
- () => createAndStartClient({ opts: { ...opts, summary }, port, title, sitePath, projectId: projectId2, retryCommand, auth, controllerUrl, serverUrl, log, serve })
10778
- );
11229
+ });
10779
11230
  let ready;
10780
11231
  try {
10781
11232
  ready = await controllerRequest({
@@ -10805,8 +11256,10 @@ async function previewOpen(opts) {
10805
11256
  thumbnail = { status: "skipped", reason: "the site is not live through the preview yet" };
10806
11257
  } else {
10807
11258
  if (!opts.thumbnailFile) log("Taking a picture of the site for its card\u2026");
11259
+ const takenAt = Date.now();
10808
11260
  const attempt2 = opts.thumbnailFile ? readThumbnailFile(opts.thumbnailFile) : await captureSiteThumbnail({ port, path: sitePath, host: "127.0.0.1" });
10809
11261
  thumbnail = "thumbnail" in attempt2 ? await uploadPreviewThumbnail({ controllerUrl, auth, projectId: projectId2, previewId, thumbnail: attempt2.thumbnail, retryCommand }) : { status: attempt2.status, reason: attempt2.reason };
11262
+ if (thumbnail.status === "uploaded" || !opts.thumbnailFile) await recordPictured(previewId, takenAt);
10810
11263
  }
10811
11264
  return {
10812
11265
  previewId,
@@ -10819,7 +11272,7 @@ async function previewOpen(opts) {
10819
11272
  viewerHostSuffix: parseViewerHostSuffix(grant["viewerHostSuffix"]) ?? null,
10820
11273
  restartable,
10821
11274
  ...restartable ? {} : { restartNote: restartNote ?? "the server did not record a start command" },
10822
- ...opts.start ? { startedPid: started?.pid ?? null, logFile: started?.logFile ?? null } : {},
11275
+ ...opts.start ? { startedPid: site?.started?.pid ?? null, logFile: site?.started?.logFile ?? null } : {},
10823
11276
  thumbnail: thumbnail.status,
10824
11277
  ...thumbnail.reason ? { thumbnailReason: thumbnail.reason } : {}
10825
11278
  };
@@ -10850,12 +11303,30 @@ async function uploadPreviewThumbnail(params) {
10850
11303
  return { status: "failed", reason: message.slice(0, 300) };
10851
11304
  }
10852
11305
  }
11306
+ var PICTURE_RECORD_LOCK_WAIT_MS = 5e3;
11307
+ async function recordPictured(previewId, takenAt) {
11308
+ try {
11309
+ await withStateLock(async () => {
11310
+ const state = readStateFile2();
11311
+ const entry = state.previews.find((preview) => preview.previewId === previewId);
11312
+ if (!entry || (entry.pictureAt ?? 0) >= takenAt) return;
11313
+ entry.pictureAt = takenAt;
11314
+ writeStateFile2(state);
11315
+ }, PICTURE_RECORD_LOCK_WAIT_MS);
11316
+ } catch {
11317
+ }
11318
+ }
10853
11319
  function openPreviewFor(port, sitePath) {
10854
- return readStateFile2().previews.find((entry) => entry.localPort === port && entry.path === sitePath && entry.pid > 0) ?? null;
11320
+ const entry = readStateFile2().previews.find((preview) => preview.localPort === port && preview.path === sitePath && preview.pid > 0);
11321
+ return entry && !entry.noThumbnail ? entry : null;
10855
11322
  }
10856
11323
  function hasOpenPreviewFor(port, sitePath) {
10857
11324
  return openPreviewFor(port, sitePath) !== null;
10858
11325
  }
11326
+ async function recordCheckPictureTried(input3) {
11327
+ const entry = openPreviewFor(input3.port, input3.path);
11328
+ if (entry) await recordPictured(entry.previewId, input3.takenAt);
11329
+ }
10859
11330
  async function uploadCheckThumbnail(input3) {
10860
11331
  const entry = openPreviewFor(input3.port, input3.path);
10861
11332
  if (!entry) return null;
@@ -10870,9 +11341,13 @@ async function uploadCheckThumbnail(input3) {
10870
11341
  if (cleanUrl(controllerUrl) !== cleanUrl(entry.controllerUrl)) {
10871
11342
  return { status: "skipped", reason: "the preview was opened on another server" };
10872
11343
  }
11344
+ const takenAt = input3.takenAt ?? Date.now();
10873
11345
  const facts = sniffImage(input3.image);
10874
- if (!facts) return { status: "skipped", reason: "the picture is not a JPEG" };
10875
- return uploadPreviewThumbnail({
11346
+ if (!facts) {
11347
+ await recordPictured(entry.previewId, takenAt);
11348
+ return { status: "skipped", reason: "the picture is not a JPEG" };
11349
+ }
11350
+ const outcome = await uploadPreviewThumbnail({
10876
11351
  controllerUrl,
10877
11352
  auth,
10878
11353
  projectId: entry.projectId,
@@ -10880,6 +11355,8 @@ async function uploadCheckThumbnail(input3) {
10880
11355
  thumbnail: { image: input3.image, ...facts, source: "capture", description: input3.description },
10881
11356
  retryCommand
10882
11357
  });
11358
+ await recordPictured(entry.previewId, takenAt);
11359
+ return outcome;
10883
11360
  }
10884
11361
  function toPreviewSummary(raw) {
10885
11362
  if (!raw || typeof raw !== "object") return null;
@@ -10914,9 +11391,13 @@ async function previewClose(opts) {
10914
11391
  if (!previewId) {
10915
11392
  throw new Error("Preview id is required. Use `instafy preview list` to find it.");
10916
11393
  }
11394
+ let siteStopped = false;
10917
11395
  const entry = await withStateLock(async () => {
10918
11396
  const removed = removePreviewState(previewId);
10919
- if (removed) await stopClient(removed);
11397
+ if (removed) {
11398
+ await stopClient(removed);
11399
+ siteStopped = await stopSite(removed.startedPid, removed.startedCommand);
11400
+ }
10920
11401
  return removed;
10921
11402
  });
10922
11403
  const projectId2 = entry?.projectId ?? resolveProject({ project: opts.project });
@@ -10927,11 +11408,14 @@ async function previewClose(opts) {
10927
11408
  );
10928
11409
  await closePreviewOnServer(controllerUrl, auth, projectId2, previewId);
10929
11410
  } catch (error) {
10930
- if (entry) await withStateLock(async () => upsertPreviewState({ ...entry, pid: -1 }));
11411
+ if (entry) {
11412
+ const { startedPid: _pid, startedCommand: _command, ...rest } = entry;
11413
+ await withStateLock(async () => upsertPreviewState({ ...siteStopped ? rest : entry, pid: -1 }));
11414
+ }
10931
11415
  throw error;
10932
11416
  }
10933
11417
  removeWorkdir(previewId);
10934
- return { ok: true, previewId };
11418
+ return { ok: true, previewId, siteStopped };
10935
11419
  }
10936
11420
  function printPreviewOpen(result, json) {
10937
11421
  if (json) {
@@ -10939,7 +11423,7 @@ function printPreviewOpen(result, json) {
10939
11423
  return;
10940
11424
  }
10941
11425
  const label = result.status === "live" ? kleur8.green("Ready to preview") : result.status === "not_responding" ? kleur8.yellow("Not responding yet") : kleur8.yellow("Preview opened (the server did not report whether the site answers)");
10942
- console.log(`${label}: ${result.title} (port ${result.port}). Open it from the card in the conversation.`);
11426
+ console.log(`${label}: ${result.title} (port ${result.port}). Its card is in the conversation.`);
10943
11427
  if (result.thumbnail === "uploaded") {
10944
11428
  console.log(kleur8.gray("The card shows a picture of the site; `instafy preview check` takes a new one after a change."));
10945
11429
  } else {
@@ -10948,7 +11432,7 @@ function printPreviewOpen(result, json) {
10948
11432
  if (result.startedPid) {
10949
11433
  console.log(kleur8.gray(`Started the site in the background (pid ${result.startedPid}); log: ${displayPath(result.logFile ?? "")}`));
10950
11434
  } else if (result.startedPid === null) {
10951
- console.log(kleur8.gray("The site was already running; nothing was started."));
11435
+ console.log(kleur8.gray("This site was already running on its port; nothing was started."));
10952
11436
  }
10953
11437
  if (result.restartable) {
10954
11438
  console.log(kleur8.gray("Open starts the site again when the process that started it exits, or the space sleeps."));
@@ -11115,7 +11599,7 @@ init_config();
11115
11599
 
11116
11600
  // src/git-wrapper.ts
11117
11601
  import { spawnSync as spawnSync5 } from "node:child_process";
11118
- import { randomUUID as randomUUID2 } from "node:crypto";
11602
+ import { randomUUID as randomUUID3 } from "node:crypto";
11119
11603
  import fs13 from "node:fs";
11120
11604
  import path14 from "node:path";
11121
11605
  import kleur10 from "kleur";
@@ -11329,10 +11813,10 @@ function hideEmbeddedGitDirs(context, pathSpecs) {
11329
11813
  fs13.mkdirSync(stagingBase, { recursive: true });
11330
11814
  const renames = [];
11331
11815
  for (const candidate of candidates) {
11332
- let hidden = path14.join(stagingBase, randomUUID2());
11816
+ let hidden = path14.join(stagingBase, randomUUID3());
11333
11817
  for (let attempt2 = 0; attempt2 < 8; attempt2 += 1) {
11334
11818
  if (!pathExists(hidden)) break;
11335
- hidden = path14.join(stagingBase, randomUUID2());
11819
+ hidden = path14.join(stagingBase, randomUUID3());
11336
11820
  }
11337
11821
  fs13.renameSync(candidate, hidden);
11338
11822
  renames.push({ original: candidate, hidden });
@@ -11888,19 +12372,324 @@ function notSavedSentences(report, options) {
11888
12372
  sentences.push(report.recoveryRef ? `Not saved: ${trimmed} (kept at ${report.recoveryRef})` : `Not saved: ${trimmed}`);
11889
12373
  }
11890
12374
  }
11891
- return sentences;
11892
- }
11893
- function saveReportJson(report, notSaved) {
11894
- return {
11895
- rev: report.rev,
11896
- baseRev: report.baseRev,
11897
- gitSyncStatus: report.gitSyncStatus,
11898
- recoveryRef: report.recoveryRef,
11899
- conflictedPaths: report.conflictedPaths,
11900
- rejectedPaths: report.rejectedPaths,
11901
- unpushedRefs: report.unpushedRefs,
11902
- notSaved
11903
- };
12375
+ return sentences;
12376
+ }
12377
+ function saveReportJson(report, notSaved) {
12378
+ return {
12379
+ rev: report.rev,
12380
+ baseRev: report.baseRev,
12381
+ gitSyncStatus: report.gitSyncStatus,
12382
+ recoveryRef: report.recoveryRef,
12383
+ conflictedPaths: report.conflictedPaths,
12384
+ rejectedPaths: report.rejectedPaths,
12385
+ unpushedRefs: report.unpushedRefs,
12386
+ notSaved
12387
+ };
12388
+ }
12389
+
12390
+ // src/api.ts
12391
+ init_config();
12392
+ init_errors();
12393
+ init_controller_fetch();
12394
+ import fs14 from "node:fs";
12395
+ function normalizeUrl5(raw) {
12396
+ const value = (raw ?? "").trim();
12397
+ if (!value) return "http://127.0.0.1:8788";
12398
+ return value.replace(/\/$/, "");
12399
+ }
12400
+ function normalizeToken6(raw) {
12401
+ if (!raw) return null;
12402
+ const trimmed = raw.trim();
12403
+ return trimmed.length ? trimmed : null;
12404
+ }
12405
+ function resolveBearerTokenWithSource(options) {
12406
+ const cwd = process.cwd();
12407
+ const profile = resolveActiveProfileName({ cwd });
12408
+ const stored = resolveConfiguredAccessToken({ profile, cwd });
12409
+ const explicit = normalizeToken6(options.accessToken);
12410
+ if (explicit) {
12411
+ return { token: explicit, source: "explicit", profile, runtimeControllerUrl: null };
12412
+ }
12413
+ const runtimeCredential = resolveRuntimeControllerCredential();
12414
+ if (runtimeCredential) {
12415
+ return {
12416
+ token: runtimeCredential.token,
12417
+ source: "env",
12418
+ profile: null,
12419
+ runtimeControllerUrl: runtimeCredential.controllerUrl
12420
+ };
12421
+ }
12422
+ const envKeys = [
12423
+ "INSTAFY_ACCESS_TOKEN",
12424
+ "SUPABASE_ACCESS_TOKEN"
12425
+ ];
12426
+ for (const key of envKeys) {
12427
+ const value = normalizeToken6(process.env[key]);
12428
+ if (value) {
12429
+ return { token: value, source: "env", profile, runtimeControllerUrl: null };
12430
+ }
12431
+ }
12432
+ if (stored) {
12433
+ return { token: stored, source: "config", profile, runtimeControllerUrl: null };
12434
+ }
12435
+ return { token: null, source: "none", profile, runtimeControllerUrl: null };
12436
+ }
12437
+ function parseKeyValue(raw) {
12438
+ const trimmed = raw.trim();
12439
+ if (!trimmed) {
12440
+ throw new Error("Key/value pair cannot be empty");
12441
+ }
12442
+ const equals = trimmed.indexOf("=");
12443
+ if (equals === -1) {
12444
+ return { key: trimmed, value: "" };
12445
+ }
12446
+ const key = trimmed.slice(0, equals).trim();
12447
+ const value = trimmed.slice(equals + 1).trim();
12448
+ if (!key) {
12449
+ throw new Error(`Invalid pair "${raw}" (missing key)`);
12450
+ }
12451
+ return { key, value };
12452
+ }
12453
+ function parseHeader(raw) {
12454
+ const trimmed = raw.trim();
12455
+ if (!trimmed) {
12456
+ throw new Error("Header cannot be empty");
12457
+ }
12458
+ const colon = trimmed.indexOf(":");
12459
+ if (colon !== -1) {
12460
+ const key = trimmed.slice(0, colon).trim();
12461
+ const value = trimmed.slice(colon + 1).trim();
12462
+ if (!key) {
12463
+ throw new Error(`Invalid header "${raw}" (missing name)`);
12464
+ }
12465
+ return { key, value };
12466
+ }
12467
+ return parseKeyValue(trimmed);
12468
+ }
12469
+ function parseJsonBody(options) {
12470
+ if (options.jsonFile) {
12471
+ const raw = fs14.readFileSync(options.jsonFile, "utf8");
12472
+ const parsed = JSON.parse(raw);
12473
+ return JSON.stringify(parsed);
12474
+ }
12475
+ if (options.json) {
12476
+ const parsed = JSON.parse(options.json);
12477
+ return JSON.stringify(parsed);
12478
+ }
12479
+ return void 0;
12480
+ }
12481
+ function buildRequestUrl(options, runtimeControllerUrl) {
12482
+ const selectedControllerUrl = options.controllerUrl ?? runtimeControllerUrl;
12483
+ const base = new URL(
12484
+ `${normalizeUrl5(resolveControllerUrl({ controllerUrl: selectedControllerUrl ?? null }))}/`
12485
+ );
12486
+ if (base.protocol !== "http:" && base.protocol !== "https:") {
12487
+ throw new Error("The configured controller URL must use http or https.");
12488
+ }
12489
+ if (base.username || base.password) {
12490
+ throw new Error("The configured controller URL must not contain embedded credentials.");
12491
+ }
12492
+ if (runtimeControllerUrl) {
12493
+ resolveRuntimeBoundControllerUrl(
12494
+ { controllerUrl: runtimeControllerUrl },
12495
+ base.toString()
12496
+ );
12497
+ }
12498
+ const rawPath = options.path.trim();
12499
+ if (!rawPath) {
12500
+ throw new Error("Path is required");
12501
+ }
12502
+ const isAbsoluteReference = /^[a-z][a-z\d+.-]*:/i.test(rawPath) || rawPath.startsWith("//");
12503
+ const url = new URL(
12504
+ isAbsoluteReference ? rawPath : rawPath.startsWith("/") ? rawPath : `/${rawPath}`,
12505
+ base
12506
+ );
12507
+ if (url.protocol !== "http:" && url.protocol !== "https:") {
12508
+ throw new Error("Controller API URLs must use http or https.");
12509
+ }
12510
+ if (url.username || url.password) {
12511
+ throw new Error("Controller API URLs must not contain embedded credentials.");
12512
+ }
12513
+ const crossOrigin = url.origin !== base.origin;
12514
+ if (crossOrigin && !options.allowCrossOrigin) {
12515
+ throw new Error(
12516
+ "Refusing a cross-origin controller API request. Set --server-url to that origin and use a relative path, or pass --allow-cross-origin only when you explicitly intend to send credentials and request data there."
12517
+ );
12518
+ }
12519
+ for (const pair of options.query ?? []) {
12520
+ const { key, value } = parseKeyValue(pair);
12521
+ url.searchParams.set(key, value);
12522
+ }
12523
+ return { url, crossOrigin };
12524
+ }
12525
+ function maybePrettyPrintJson(text2, pretty) {
12526
+ if (!pretty) return text2;
12527
+ if (!text2.trim()) return text2;
12528
+ try {
12529
+ return JSON.stringify(JSON.parse(text2), null, 2);
12530
+ } catch {
12531
+ return text2;
12532
+ }
12533
+ }
12534
+ async function executeControllerApiRequest(options, bodyOverride) {
12535
+ const resolved = resolveBearerTokenWithSource(options);
12536
+ const { url, crossOrigin } = buildRequestUrl(options, resolved.runtimeControllerUrl);
12537
+ const bearer = resolved.token;
12538
+ if (crossOrigin && (!bearer || resolved.source !== "explicit")) {
12539
+ throw new Error(
12540
+ "Cross-origin controller API requests require --allow-cross-origin and an explicit --access-token."
12541
+ );
12542
+ }
12543
+ const headers = new Headers();
12544
+ headers.set("accept", "application/json");
12545
+ for (const header of options.headers ?? []) {
12546
+ const { key, value } = parseHeader(header);
12547
+ headers.set(key, value);
12548
+ }
12549
+ const body = bodyOverride ?? parseJsonBody(options);
12550
+ if (body !== void 0 && !headers.has("content-type")) {
12551
+ headers.set("content-type", "application/json");
12552
+ }
12553
+ const init = {
12554
+ method: options.method,
12555
+ headers,
12556
+ body,
12557
+ redirect: "error"
12558
+ };
12559
+ const response = bearer ? (await fetchWithControllerAuth({
12560
+ url: url.toString(),
12561
+ init,
12562
+ accessToken: bearer,
12563
+ tokenSource: resolved.source,
12564
+ profile: resolved.profile,
12565
+ cwd: process.cwd()
12566
+ })).response : await fetch(url, init);
12567
+ const responseText = await response.text().catch(() => "");
12568
+ const contentType = response.headers.get("content-type") ?? "";
12569
+ const isJson = contentType.includes("application/json") || contentType.includes("+json");
12570
+ if (!response.ok) {
12571
+ if (response.status === 401 || response.status === 403) {
12572
+ throw formatAuthRejectedError({
12573
+ status: response.status,
12574
+ responseBody: responseText,
12575
+ retryCommand: "instafy login",
12576
+ advancedHint: "pass --access-token, or set INSTAFY_ACCESS_TOKEN / SUPABASE_ACCESS_TOKEN"
12577
+ });
12578
+ }
12579
+ const formattedBody = isJson ? maybePrettyPrintJson(responseText, true) : responseText;
12580
+ const prefix = `Request failed (${response.status} ${response.statusText})`;
12581
+ const suffix = formattedBody.trim() ? `: ${formattedBody}` : "";
12582
+ throw new Error(`${prefix}${suffix}`);
12583
+ }
12584
+ return { response, responseText, isJson };
12585
+ }
12586
+ async function requestControllerApiJson(options) {
12587
+ const body = options.jsonBody === void 0 ? void 0 : JSON.stringify(options.jsonBody);
12588
+ const { responseText, isJson } = await executeControllerApiRequest(
12589
+ {
12590
+ method: options.method,
12591
+ path: options.path,
12592
+ controllerUrl: options.controllerUrl,
12593
+ accessToken: options.accessToken,
12594
+ serviceToken: options.serviceToken,
12595
+ query: options.query,
12596
+ headers: options.headers,
12597
+ allowCrossOrigin: options.allowCrossOrigin
12598
+ },
12599
+ body
12600
+ );
12601
+ if (!isJson) {
12602
+ throw new Error("Expected JSON response from controller API");
12603
+ }
12604
+ return JSON.parse(responseText);
12605
+ }
12606
+ async function requestControllerApi(options) {
12607
+ const { responseText, isJson } = await executeControllerApiRequest(options);
12608
+ const pretty = options.pretty !== false;
12609
+ const formattedBody = isJson ? maybePrettyPrintJson(responseText, pretty) : responseText;
12610
+ if (formattedBody) console.log(formattedBody);
12611
+ }
12612
+
12613
+ // src/research-settings.ts
12614
+ init_project_manifest();
12615
+ var fields = [
12616
+ { key: "maxPageLoads", label: "Fresh pages per task" },
12617
+ { key: "maxPageLoadsPerSite", label: "Fresh pages per site per task" }
12618
+ ];
12619
+ function integer(raw, label) {
12620
+ if (!/^[1-9]\d*$/.test(raw.trim()) || !Number.isSafeInteger(Number(raw))) {
12621
+ throw new Error(`${label} must be a positive whole number.`);
12622
+ }
12623
+ return Number(raw);
12624
+ }
12625
+ async function researchSettings(options) {
12626
+ const patch = {};
12627
+ for (const field of fields) {
12628
+ if (options[field.key] !== void 0) patch[field.key] = integer(options[field.key], field.label);
12629
+ }
12630
+ if (options.interactive) {
12631
+ if (options.json || Object.keys(patch).length) throw new Error("Use --interactive without --json or setting flags.");
12632
+ if (!process.stdin.isTTY || !process.stdout.isTTY || process.env.CI === "true") {
12633
+ throw new Error("--interactive requires a terminal. Use --max-page-loads and --max-page-loads-per-site instead.");
12634
+ }
12635
+ }
12636
+ const project = [options.project, process.env.SPACE_ID, process.env.INSTAFY_SPACE_ID, process.env.PROJECT_ID, process.env.INSTAFY_PROJECT_ID].map((value) => value?.trim()).find(Boolean) ?? findProjectManifest(process.cwd()).manifest?.spaceId;
12637
+ if (!project) throw new Error("No space configured. Pass --space, set SPACE_ID, or run `instafy space init`.");
12638
+ const request = {
12639
+ path: `/projects/${encodeURIComponent(project)}/research-settings`,
12640
+ controllerUrl: options.controllerUrl,
12641
+ accessToken: options.accessToken
12642
+ };
12643
+ let settings = await requestControllerApiJson({ ...request, method: "GET" });
12644
+ if ((options.interactive || Object.keys(patch).length) && !settings.canManage) {
12645
+ throw new Error("Only space managers can change research settings.");
12646
+ }
12647
+ for (const field of fields) {
12648
+ if (!settings.limits?.[field.key] || !Number.isInteger(settings[field.key])) {
12649
+ throw new Error("This controller does not support research settings. Update it before changing the budget.");
12650
+ }
12651
+ }
12652
+ if (options.interactive) {
12653
+ const { isCancel, text: text2 } = await import("@clack/prompts");
12654
+ console.log("The parent and its helpers share one budget. Changes apply to new tasks; running tasks retain their allowance.");
12655
+ for (const field of fields) {
12656
+ const bounds = settings.limits[field.key];
12657
+ const entered = await text2({
12658
+ message: `${field.label} (${bounds.min}\u2013${bounds.max}; default ${bounds.default})`,
12659
+ defaultValue: String(settings[field.key]),
12660
+ placeholder: String(settings[field.key]),
12661
+ validate(raw) {
12662
+ try {
12663
+ const value2 = integer(raw || String(settings[field.key]), field.label);
12664
+ if (value2 < bounds.min || value2 > bounds.max) return `Enter a whole number from ${bounds.min} to ${bounds.max}.`;
12665
+ if (field.key === "maxPageLoadsPerSite" && value2 > (patch.maxPageLoads ?? settings.maxPageLoads)) return "The per-site limit cannot exceed the task limit.";
12666
+ } catch (error) {
12667
+ return error.message;
12668
+ }
12669
+ }
12670
+ });
12671
+ if (isCancel(entered)) throw new Error("Cancelled. No settings were changed.");
12672
+ const value = integer(String(entered), field.label);
12673
+ if (value !== settings[field.key]) patch[field.key] = value;
12674
+ }
12675
+ }
12676
+ for (const field of fields) {
12677
+ const value = patch[field.key];
12678
+ const bounds = settings.limits[field.key];
12679
+ if (value !== void 0 && (value < bounds.min || value > bounds.max)) throw new Error(`${field.label} must be between ${bounds.min} and ${bounds.max}.`);
12680
+ }
12681
+ if ((patch.maxPageLoadsPerSite ?? settings.maxPageLoadsPerSite) > (patch.maxPageLoads ?? settings.maxPageLoads)) {
12682
+ throw new Error("The per-site limit cannot exceed the task limit.");
12683
+ }
12684
+ if (Object.keys(patch).length) settings = await requestControllerApiJson({ ...request, method: "PATCH", jsonBody: patch });
12685
+ if (options.json) {
12686
+ console.log(JSON.stringify(settings, null, 2));
12687
+ return;
12688
+ }
12689
+ console.log(`Research budget for space ${project}`);
12690
+ for (const field of fields) console.log(`${field.label}: ${settings[field.key]}`);
12691
+ console.log("Shared by the parent and all helpers, including continued worker jobs. Cached reads are free. Changes apply to new tasks.");
12692
+ if (!settings.canManage) console.log("Read-only: only space managers can change this budget.");
11904
12693
  }
11905
12694
 
11906
12695
  // src/web-link.ts
@@ -11971,9 +12760,9 @@ function registerWebLinkCommand(web) {
11971
12760
  // src/web-search.ts
11972
12761
  var redaction2 = __toESM(require_redaction(), 1);
11973
12762
  import dns3 from "node:dns";
11974
- import fs14 from "node:fs";
12763
+ import fs15 from "node:fs";
11975
12764
  import https2 from "node:https";
11976
- import net5 from "node:net";
12765
+ import net6 from "node:net";
11977
12766
  import path15 from "node:path";
11978
12767
  import zlib2 from "node:zlib";
11979
12768
 
@@ -12180,7 +12969,7 @@ function compileProvider(id, raw) {
12180
12969
  if (url.protocol !== "https:") return `${id}: request.url must be https`;
12181
12970
  if (url.username || url.password || url.port && url.port !== "443" || url.hash) return `${id}: request.url must be a plain https address`;
12182
12971
  const host = langInHost ? url.hostname.toLowerCase().replace(/^xx\./, "") : url.hostname.toLowerCase();
12183
- if (net5.isIP(host.replace(/^\[|\]$/g, "")) || isLocalHostName(host)) return `${id}: request.url must name a public host`;
12972
+ if (net6.isIP(host.replace(/^\[|\]$/g, "")) || isLocalHostName(host)) return `${id}: request.url must name a public host`;
12184
12973
  const never = NEVER_HOSTS.find((entry) => host === entry.host || host.endsWith(`.${entry.host}`));
12185
12974
  if (never) return `${id}: ${never.host} is never used (${never.why})`;
12186
12975
  if (/[{}]/.test(decodeURIComponent(url.href))) return `${id}: request.url may not hold placeholders`;
@@ -12303,9 +13092,9 @@ function compileProvider(id, raw) {
12303
13092
  }
12304
13093
  function resultLink(value) {
12305
13094
  if (typeof value === "string" && value.startsWith("https://")) {
12306
- const fields = [...value.matchAll(/\{([\w-]+(?:\.[\w-]+)*)\}/g)];
13095
+ const fields2 = [...value.matchAll(/\{([\w-]+(?:\.[\w-]+)*)\}/g)];
12307
13096
  const rest = value.replace(/\{([\w-]+(?:\.[\w-]+)*)\}/g, "");
12308
- if (!fields.length || /[{}]/.test(rest)) return void 0;
13097
+ if (!fields2.length || /[{}]/.test(rest)) return void 0;
12309
13098
  try {
12310
13099
  new URL(value.replace(/\{[^}]*\}/g, "x"));
12311
13100
  } catch {
@@ -12363,8 +13152,8 @@ function loadSearchProviders(bundled = BUNDLED_SEARCH_PROVIDERS, copies = []) {
12363
13152
  return { providers, chains, off: [...off].filter((id) => id in bundled.providers).sort(), keyEnvs: [...keyEnvs], warnings };
12364
13153
  }
12365
13154
  function fill(template, vars) {
12366
- const whole = /^\{([a-z]+)\}$/.exec(template);
12367
- if (whole && whole[1] === "count") return vars.count;
13155
+ const whole2 = /^\{([a-z]+)\}$/.exec(template);
13156
+ if (whole2 && whole2[1] === "count") return vars.count;
12368
13157
  let missing = false;
12369
13158
  const out = template.replace(/\{\{|\}\}|\{([a-z]+)(?::([a-z]+))?\}/g, (match, name, encoder) => {
12370
13159
  if (!name) return match[0];
@@ -12453,7 +13242,7 @@ function sendOnce(request, target, timeoutMs) {
12453
13242
  port: target.port,
12454
13243
  method: request.method,
12455
13244
  path: `${request.url.pathname}${request.url.search}`,
12456
- servername: net5.isIP(hostname) ? void 0 : hostname,
13245
+ servername: net6.isIP(hostname) ? void 0 : hostname,
12457
13246
  agent: false,
12458
13247
  maxHeaderSize: 64 * 1024,
12459
13248
  headers: { Host: request.url.host, ...request.headers, ...payload ? { "Content-Length": String(payload.length) } : {} }
@@ -12503,8 +13292,8 @@ function at(value, field) {
12503
13292
  }
12504
13293
  return current;
12505
13294
  }
12506
- function firstText(item, fields) {
12507
- for (const field of fields) {
13295
+ function firstText(item, fields2) {
13296
+ for (const field of fields2) {
12508
13297
  const value = at(item, field);
12509
13298
  if (typeof value === "string" && value.trim()) return value;
12510
13299
  }
@@ -12520,10 +13309,10 @@ function withoutTags(text2) {
12520
13309
  return out;
12521
13310
  }
12522
13311
  function cleanText(raw, max) {
12523
- const text2 = withoutTags(raw).replace(/&(#x[0-9a-f]{1,6}|#\d{1,7}|[a-z]{2,6}|#39);/gi, (whole, name) => {
13312
+ const text2 = withoutTags(raw).replace(/&(#x[0-9a-f]{1,6}|#\d{1,7}|[a-z]{2,6}|#39);/gi, (whole2, name) => {
12524
13313
  if (ENTITIES2[name.toLowerCase()]) return ENTITIES2[name.toLowerCase()];
12525
13314
  const code = name[0] === "#" ? name[1]?.toLowerCase() === "x" ? parseInt(name.slice(2), 16) : parseInt(name.slice(1), 10) : NaN;
12526
- return Number.isFinite(code) && code > 31 && code < 1114112 ? String.fromCodePoint(code) : whole;
13315
+ return Number.isFinite(code) && code > 31 && code < 1114112 ? String.fromCodePoint(code) : whole2;
12527
13316
  }).replace(/</g, "\u2039").replace(/[\u0000-\u001f\u007f-\u009f\u200b-\u200f\u202a-\u202e\u2066-\u2069\ufeff]/g, " ").replace(/\s+/g, " ").trim();
12528
13317
  const redacted = redaction2.redactText(text2).text;
12529
13318
  return redacted.length > max ? `${redacted.slice(0, max - 1)}\u2026` : redacted;
@@ -12540,7 +13329,7 @@ function resultAddress(raw) {
12540
13329
  if (url.username || url.password) return null;
12541
13330
  if (url.port && url.port !== "80" && url.port !== "443") return null;
12542
13331
  const host = url.hostname.replace(/^\[|\]$/g, "");
12543
- if (net5.isIP(host) ? isPrivateAddress(host) : isLocalHostName(host)) return null;
13332
+ if (net6.isIP(host) ? isPrivateAddress(host) : isLocalHostName(host)) return null;
12544
13333
  url.hash = "";
12545
13334
  return url;
12546
13335
  }
@@ -12692,10 +13481,10 @@ var SearchState = class {
12692
13481
  /** Removes turn and day files older than two days. */
12693
13482
  prune() {
12694
13483
  try {
12695
- for (const name of fs14.readdirSync(this.dir)) {
13484
+ for (const name of fs15.readdirSync(this.dir)) {
12696
13485
  if (!/^(?:search-turn|day)-[\w-]+\.json$/.test(name)) continue;
12697
13486
  const file = path15.join(this.dir, name);
12698
- if (Date.now() - fs14.statSync(file).mtimeMs > 2 * 864e5) fs14.rmSync(file, { force: true });
13487
+ if (Date.now() - fs15.statSync(file).mtimeMs > 2 * 864e5) fs15.rmSync(file, { force: true });
12699
13488
  }
12700
13489
  } catch {
12701
13490
  }
@@ -12710,7 +13499,7 @@ function liveTurn(env, now) {
12710
13499
  }
12711
13500
  function keyless(chain) {
12712
13501
  const find = chain === DEFAULT_CHAIN ? 'find official sites with `instafy web search "<name>" --chain reference-search` (no key) and read them' : "read the official sites you know of";
12713
- return `Go on without it: ${find}, and the pages of publishers you know cover this, with \`instafy web read\`. Then answer or build the page with what you read, each missing fact marked unknown with why. The person may send more pages with Send page to Octo.`;
13502
+ return `Go on without it: ${find}, and for reviews a reviewer's home page and the pages it links, with \`instafy web read\`. Then answer or build the page with what you read, each missing fact marked unknown with why. The person may send more pages with Send page to Octo.`;
12714
13503
  }
12715
13504
  function tellFor(status, chain) {
12716
13505
  switch (status) {
@@ -13043,229 +13832,6 @@ function registerWebSearchCommand(web) {
13043
13832
  return web;
13044
13833
  }
13045
13834
 
13046
- // src/api.ts
13047
- init_config();
13048
- init_errors();
13049
- init_controller_fetch();
13050
- import fs15 from "node:fs";
13051
- function normalizeUrl5(raw) {
13052
- const value = (raw ?? "").trim();
13053
- if (!value) return "http://127.0.0.1:8788";
13054
- return value.replace(/\/$/, "");
13055
- }
13056
- function normalizeToken6(raw) {
13057
- if (!raw) return null;
13058
- const trimmed = raw.trim();
13059
- return trimmed.length ? trimmed : null;
13060
- }
13061
- function resolveBearerTokenWithSource(options) {
13062
- const cwd = process.cwd();
13063
- const profile = resolveActiveProfileName({ cwd });
13064
- const stored = resolveConfiguredAccessToken({ profile, cwd });
13065
- const explicit = normalizeToken6(options.accessToken);
13066
- if (explicit) {
13067
- return { token: explicit, source: "explicit", profile, runtimeControllerUrl: null };
13068
- }
13069
- const runtimeCredential = resolveRuntimeControllerCredential();
13070
- if (runtimeCredential) {
13071
- return {
13072
- token: runtimeCredential.token,
13073
- source: "env",
13074
- profile: null,
13075
- runtimeControllerUrl: runtimeCredential.controllerUrl
13076
- };
13077
- }
13078
- const envKeys = [
13079
- "INSTAFY_ACCESS_TOKEN",
13080
- "SUPABASE_ACCESS_TOKEN"
13081
- ];
13082
- for (const key of envKeys) {
13083
- const value = normalizeToken6(process.env[key]);
13084
- if (value) {
13085
- return { token: value, source: "env", profile, runtimeControllerUrl: null };
13086
- }
13087
- }
13088
- if (stored) {
13089
- return { token: stored, source: "config", profile, runtimeControllerUrl: null };
13090
- }
13091
- return { token: null, source: "none", profile, runtimeControllerUrl: null };
13092
- }
13093
- function parseKeyValue(raw) {
13094
- const trimmed = raw.trim();
13095
- if (!trimmed) {
13096
- throw new Error("Key/value pair cannot be empty");
13097
- }
13098
- const equals = trimmed.indexOf("=");
13099
- if (equals === -1) {
13100
- return { key: trimmed, value: "" };
13101
- }
13102
- const key = trimmed.slice(0, equals).trim();
13103
- const value = trimmed.slice(equals + 1).trim();
13104
- if (!key) {
13105
- throw new Error(`Invalid pair "${raw}" (missing key)`);
13106
- }
13107
- return { key, value };
13108
- }
13109
- function parseHeader(raw) {
13110
- const trimmed = raw.trim();
13111
- if (!trimmed) {
13112
- throw new Error("Header cannot be empty");
13113
- }
13114
- const colon = trimmed.indexOf(":");
13115
- if (colon !== -1) {
13116
- const key = trimmed.slice(0, colon).trim();
13117
- const value = trimmed.slice(colon + 1).trim();
13118
- if (!key) {
13119
- throw new Error(`Invalid header "${raw}" (missing name)`);
13120
- }
13121
- return { key, value };
13122
- }
13123
- return parseKeyValue(trimmed);
13124
- }
13125
- function parseJsonBody(options) {
13126
- if (options.jsonFile) {
13127
- const raw = fs15.readFileSync(options.jsonFile, "utf8");
13128
- const parsed = JSON.parse(raw);
13129
- return JSON.stringify(parsed);
13130
- }
13131
- if (options.json) {
13132
- const parsed = JSON.parse(options.json);
13133
- return JSON.stringify(parsed);
13134
- }
13135
- return void 0;
13136
- }
13137
- function buildRequestUrl(options, runtimeControllerUrl) {
13138
- const selectedControllerUrl = options.controllerUrl ?? runtimeControllerUrl;
13139
- const base = new URL(
13140
- `${normalizeUrl5(resolveControllerUrl({ controllerUrl: selectedControllerUrl ?? null }))}/`
13141
- );
13142
- if (base.protocol !== "http:" && base.protocol !== "https:") {
13143
- throw new Error("The configured controller URL must use http or https.");
13144
- }
13145
- if (base.username || base.password) {
13146
- throw new Error("The configured controller URL must not contain embedded credentials.");
13147
- }
13148
- if (runtimeControllerUrl) {
13149
- resolveRuntimeBoundControllerUrl(
13150
- { controllerUrl: runtimeControllerUrl },
13151
- base.toString()
13152
- );
13153
- }
13154
- const rawPath = options.path.trim();
13155
- if (!rawPath) {
13156
- throw new Error("Path is required");
13157
- }
13158
- const isAbsoluteReference = /^[a-z][a-z\d+.-]*:/i.test(rawPath) || rawPath.startsWith("//");
13159
- const url = new URL(
13160
- isAbsoluteReference ? rawPath : rawPath.startsWith("/") ? rawPath : `/${rawPath}`,
13161
- base
13162
- );
13163
- if (url.protocol !== "http:" && url.protocol !== "https:") {
13164
- throw new Error("Controller API URLs must use http or https.");
13165
- }
13166
- if (url.username || url.password) {
13167
- throw new Error("Controller API URLs must not contain embedded credentials.");
13168
- }
13169
- const crossOrigin = url.origin !== base.origin;
13170
- if (crossOrigin && !options.allowCrossOrigin) {
13171
- throw new Error(
13172
- "Refusing a cross-origin controller API request. Set --server-url to that origin and use a relative path, or pass --allow-cross-origin only when you explicitly intend to send credentials and request data there."
13173
- );
13174
- }
13175
- for (const pair of options.query ?? []) {
13176
- const { key, value } = parseKeyValue(pair);
13177
- url.searchParams.set(key, value);
13178
- }
13179
- return { url, crossOrigin };
13180
- }
13181
- function maybePrettyPrintJson(text2, pretty) {
13182
- if (!pretty) return text2;
13183
- if (!text2.trim()) return text2;
13184
- try {
13185
- return JSON.stringify(JSON.parse(text2), null, 2);
13186
- } catch {
13187
- return text2;
13188
- }
13189
- }
13190
- async function executeControllerApiRequest(options, bodyOverride) {
13191
- const resolved = resolveBearerTokenWithSource(options);
13192
- const { url, crossOrigin } = buildRequestUrl(options, resolved.runtimeControllerUrl);
13193
- const bearer = resolved.token;
13194
- if (crossOrigin && (!bearer || resolved.source !== "explicit")) {
13195
- throw new Error(
13196
- "Cross-origin controller API requests require --allow-cross-origin and an explicit --access-token."
13197
- );
13198
- }
13199
- const headers = new Headers();
13200
- headers.set("accept", "application/json");
13201
- for (const header of options.headers ?? []) {
13202
- const { key, value } = parseHeader(header);
13203
- headers.set(key, value);
13204
- }
13205
- const body = bodyOverride ?? parseJsonBody(options);
13206
- if (body !== void 0 && !headers.has("content-type")) {
13207
- headers.set("content-type", "application/json");
13208
- }
13209
- const init = {
13210
- method: options.method,
13211
- headers,
13212
- body,
13213
- redirect: "error"
13214
- };
13215
- const response = bearer ? (await fetchWithControllerAuth({
13216
- url: url.toString(),
13217
- init,
13218
- accessToken: bearer,
13219
- tokenSource: resolved.source,
13220
- profile: resolved.profile,
13221
- cwd: process.cwd()
13222
- })).response : await fetch(url, init);
13223
- const responseText = await response.text().catch(() => "");
13224
- const contentType = response.headers.get("content-type") ?? "";
13225
- const isJson = contentType.includes("application/json") || contentType.includes("+json");
13226
- if (!response.ok) {
13227
- if (response.status === 401 || response.status === 403) {
13228
- throw formatAuthRejectedError({
13229
- status: response.status,
13230
- responseBody: responseText,
13231
- retryCommand: "instafy login",
13232
- advancedHint: "pass --access-token, or set INSTAFY_ACCESS_TOKEN / SUPABASE_ACCESS_TOKEN"
13233
- });
13234
- }
13235
- const formattedBody = isJson ? maybePrettyPrintJson(responseText, true) : responseText;
13236
- const prefix = `Request failed (${response.status} ${response.statusText})`;
13237
- const suffix = formattedBody.trim() ? `: ${formattedBody}` : "";
13238
- throw new Error(`${prefix}${suffix}`);
13239
- }
13240
- return { response, responseText, isJson };
13241
- }
13242
- async function requestControllerApiJson(options) {
13243
- const body = options.jsonBody === void 0 ? void 0 : JSON.stringify(options.jsonBody);
13244
- const { responseText, isJson } = await executeControllerApiRequest(
13245
- {
13246
- method: options.method,
13247
- path: options.path,
13248
- controllerUrl: options.controllerUrl,
13249
- accessToken: options.accessToken,
13250
- serviceToken: options.serviceToken,
13251
- query: options.query,
13252
- headers: options.headers,
13253
- allowCrossOrigin: options.allowCrossOrigin
13254
- },
13255
- body
13256
- );
13257
- if (!isJson) {
13258
- throw new Error("Expected JSON response from controller API");
13259
- }
13260
- return JSON.parse(responseText);
13261
- }
13262
- async function requestControllerApi(options) {
13263
- const { responseText, isJson } = await executeControllerApiRequest(options);
13264
- const pretty = options.pretty !== false;
13265
- const formattedBody = isJson ? maybePrettyPrintJson(responseText, pretty) : responseText;
13266
- if (formattedBody) console.log(formattedBody);
13267
- }
13268
-
13269
13835
  // src/conversations.ts
13270
13836
  init_project_manifest();
13271
13837
  import kleur11 from "kleur";
@@ -14816,10 +15382,10 @@ function projectId(explicit) {
14816
15382
  if (!value?.trim()) throw new Error("No space configured. Pass --space, set SPACE_ID, or run `instafy space init`.");
14817
15383
  return uuid(value, "Space ID");
14818
15384
  }
14819
- function object(value, fields, label) {
15385
+ function object2(value, fields2, label) {
14820
15386
  if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error(`${label} must be an object.`);
14821
15387
  const record2 = value;
14822
- if (Object.keys(record2).some((key) => !fields.includes(key))) throw new Error(`${label} contains an unsupported field.`);
15388
+ if (Object.keys(record2).some((key) => !fields2.includes(key))) throw new Error(`${label} contains an unsupported field.`);
14823
15389
  return record2;
14824
15390
  }
14825
15391
  function text(value, label, max) {
@@ -14874,7 +15440,7 @@ async function readProposal(file) {
14874
15440
  } catch {
14875
15441
  throw new Error("Recommendation file must contain valid JSON.");
14876
15442
  }
14877
- const input3 = object(parsed, ["key", "title", "reason", "prompt", "message", "evidence"], "Recommendation");
15443
+ const input3 = object2(parsed, ["key", "title", "reason", "prompt", "message", "evidence"], "Recommendation");
14878
15444
  const key = text(input3.key, "Recommendation key", 120);
14879
15445
  if (!/^[a-z0-9][a-z0-9_-]{0,119}$/.test(key)) throw new Error("Recommendation key must be a lowercase slug using letters, digits, hyphens or underscores.");
14880
15446
  if (!Array.isArray(input3.evidence) || input3.evidence.length < 1 || input3.evidence.length > 8) {
@@ -14887,7 +15453,7 @@ async function readProposal(file) {
14887
15453
  prompt: text(input3.prompt, "Recommendation prompt", 4e3),
14888
15454
  ...input3.message === void 0 ? {} : { message: text(input3.message, "Recommendation message", 4e3) },
14889
15455
  evidence: input3.evidence.map((value) => {
14890
- const entry = object(value, ["conversationId", "messageId"], "Evidence");
15456
+ const entry = object2(value, ["conversationId", "messageId"], "Evidence");
14891
15457
  return {
14892
15458
  conversationId: uuid(entry.conversationId, "Evidence conversationId"),
14893
15459
  ...entry.messageId === void 0 ? {} : { messageId: uuid(entry.messageId, "Evidence messageId") }
@@ -16440,7 +17006,7 @@ async function diagnosticsRunResult(options) {
16440
17006
 
16441
17007
  // src/support.ts
16442
17008
  import fs19 from "node:fs";
16443
- import { randomUUID as randomUUID3 } from "node:crypto";
17009
+ import { randomUUID as randomUUID4 } from "node:crypto";
16444
17010
  import path20 from "node:path";
16445
17011
  init_project_manifest();
16446
17012
  var MAX_SUMMARY_CHARS = 500;
@@ -16733,7 +17299,7 @@ async function supportReport(options) {
16733
17299
  printJson3(preview);
16734
17300
  return;
16735
17301
  }
16736
- const clientRequestId = cleanText4(options.clientRequestId) ?? randomUUID3();
17302
+ const clientRequestId = cleanText4(options.clientRequestId) ?? randomUUID4();
16737
17303
  if (!UUID2.test(clientRequestId)) {
16738
17304
  throw new Error("--client-request-id must be a UUID.");
16739
17305
  }
@@ -16948,7 +17514,7 @@ async function supportReply(options) {
16948
17514
  `A follow-up must be at most ${MAX_MESSAGE_CHARS} characters and ${MAX_MESSAGE_BYTES} bytes.`
16949
17515
  );
16950
17516
  }
16951
- const clientRequestId = cleanText4(options.clientRequestId) ?? randomUUID3();
17517
+ const clientRequestId = cleanText4(options.clientRequestId) ?? randomUUID4();
16952
17518
  if (!UUID2.test(clientRequestId)) {
16953
17519
  throw new Error("--client-request-id must be a UUID.");
16954
17520
  }
@@ -18391,20 +18957,48 @@ function exitWithPreviewError(error) {
18391
18957
  }
18392
18958
  var previewCommand = program.command("preview").description("Look at sites running in this space, and show them in Studio as a private preview card");
18393
18959
  failWithGroupHelp(previewCommand);
18394
- registerPreviewCheckCommand(previewCommand, { hasOpenPreview: hasOpenPreviewFor, uploadThumbnail: uploadCheckThumbnail });
18395
- failWithGroupHelp(registerWebSearchCommand(registerWebLinkCommand(registerWebCommand(program))));
18960
+ registerPreviewCheckCommand(previewCommand, {
18961
+ hasOpenPreview: hasOpenPreviewFor,
18962
+ uploadThumbnail: uploadCheckThumbnail,
18963
+ pictureTried: recordCheckPictureTried
18964
+ });
18965
+ var webCommand = registerWebSearchCommand(registerWebLinkCommand(registerWebCommand(program)));
18966
+ failWithGroupHelp(webCommand);
18967
+ var webSettingsCommand = webCommand.command("settings").description("Inspect or change the space's shared research budget for new tasks");
18968
+ addSpaceOption(webSettingsCommand, "Space UUID (defaults to .instafy/space.json or SPACE_ID)");
18969
+ addServerUrlOptions(webSettingsCommand);
18970
+ addAccessTokenOptions(webSettingsCommand, "Instafy access token");
18971
+ webSettingsCommand.option("--max-page-loads <n>", "Fresh pages allowed across the parent and all helpers").option("--max-page-loads-per-site <n>", "Fresh pages allowed from one site across the task").option("--interactive", "Interactively edit the shared research budget").option("--json", "Output JSON (noninteractive)").action(async (opts) => {
18972
+ try {
18973
+ await researchSettings({
18974
+ project: resolveSpaceIdOption(opts),
18975
+ controllerUrl: opts.serverUrl,
18976
+ accessToken: opts.accessToken,
18977
+ maxPageLoads: opts.maxPageLoads,
18978
+ maxPageLoadsPerSite: opts.maxPageLoadsPerSite,
18979
+ interactive: opts.interactive,
18980
+ json: opts.json
18981
+ });
18982
+ } catch (error) {
18983
+ console.error(kleur20.red(String(error)));
18984
+ process.exit(1);
18985
+ }
18986
+ });
18396
18987
  var previewOpenCommand = previewCommand.command("open").description(
18397
- "Open a private preview of a local port (exit 2: nothing listening, 3: port not allowed, 5: previews not available)"
18398
- ).requiredOption("--port <port>", "Local port the site listens on (1024-65535)").requiredOption("--title <title>", "Card title (at most 120 characters)").option("--path <path>", "Path the preview opens at (default /)").option("--summary <text>", `One line of facts under the card's title, e.g. "Trip \xB7 3 days \xB7 14 places" (at most 80 characters); default: the page's own meta description`).option("--thumbnail <file>", "Use this picture on the card (JPEG, PNG or WebP, at most 512 KiB and 2000\xD72000) instead of taking one").option("--no-thumbnail", "Put no picture of the site on the card");
18988
+ "Open a private preview of a local port (exit 2: nothing listening, 3: port not allowed or another site's, 5: previews not available)"
18989
+ ).option("--port <port>", "Local port the site listens on (1024-65535); with --start, leave it out and a free one is picked").requiredOption("--title <title>", "Card title (at most 120 characters)").option("--path <path>", "Path the preview opens at (default /)").option("--summary <text>", `One line of facts under the card's title, e.g. "Trip \xB7 3 days \xB7 14 places" (at most 80 characters); default: the page's own meta description`).option("--thumbnail <file>", "Use this picture on the card (JPEG, PNG or WebP, at most 512 KiB and 2000\xD72000) instead of taking one").option("--no-thumbnail", "Put no picture of the site on the card");
18399
18990
  addSpaceOption(previewOpenCommand, "Space UUID (defaults to .instafy/space.json or SPACE_ID)");
18400
18991
  addServerUrlOptions(previewOpenCommand);
18401
18992
  addAccessTokenOptions(previewOpenCommand, "Instafy access token (defaults to the job token, then the saved `instafy login` token)");
18402
18993
  previewOpenCommand.option("--conversation <id>", "Conversation for the card (only needed without a job token)").option("--rathole-bin <path>", "Path to rathole binary (or set RATHOLE_BIN)").option(
18403
18994
  "--serve-cmd <command>",
18404
- "How to start the site again when the process that started it exits, or the space sleeps, run from the current directory (one command, no shell); default: read from the process listening on the port"
18995
+ "How to start the site, and to start it again when the process that started it exits or the space sleeps, run from the current directory (one command, no shell); {port} in it is the site's port; default: read from the process listening on the port"
18405
18996
  ).option(
18406
18997
  "--start",
18407
- "Start --serve-cmd in the background, in its own session, without Instafy credentials, logging to .instafy/agent/logs, and wait for the port (nothing is started when the port already answers)"
18998
+ "Start --serve-cmd in the background, in its own session, without Instafy credentials, logging to .instafy/agent/logs, and wait for the port. Without --port it picks a free port (this same site's earlier one, when it has one) and puts it in {port}. A port where this same site already runs is used as it is; one where another site runs is refused"
18999
+ ).option(
19000
+ "--replace",
19001
+ "Show this site on the port even when the port's open preview shows another site (another title or folder); without it that open is refused"
18408
19002
  ).option("--json", "Output JSON").action(async (opts) => {
18409
19003
  try {
18410
19004
  const result = await previewOpen({
@@ -18418,6 +19012,7 @@ previewOpenCommand.option("--conversation <id>", "Conversation for the card (onl
18418
19012
  ratholeBin: opts.ratholeBin,
18419
19013
  serveCmd: opts.serveCmd,
18420
19014
  start: Boolean(opts.start),
19015
+ replace: Boolean(opts.replace),
18421
19016
  summary: opts.summary,
18422
19017
  thumbnail: opts.thumbnail !== false,
18423
19018
  thumbnailFile: typeof opts.thumbnail === "string" ? opts.thumbnail : void 0
@@ -18443,7 +19038,7 @@ previewListCommand.option("--json", "Output JSON").action(async (opts) => {
18443
19038
  exitWithPreviewError(error);
18444
19039
  }
18445
19040
  });
18446
- var previewCloseCommand = previewCommand.command("close").description("Close a preview: stop its local client and revoke it").argument("<previewId>", "Preview ID (from `instafy preview list`)");
19041
+ var previewCloseCommand = previewCommand.command("close").description("Close a preview: stop its local client and the site `open --start` started for it, and revoke it").argument("<previewId>", "Preview ID (from `instafy preview list`)");
18447
19042
  addSpaceOption(previewCloseCommand, "Space UUID (defaults to .instafy/space.json or SPACE_ID)");
18448
19043
  addServerUrlOptions(previewCloseCommand);
18449
19044
  addAccessTokenOptions(previewCloseCommand, "Instafy access token (defaults to the job token, then the saved `instafy login` token)");
@@ -18459,7 +19054,7 @@ previewCloseCommand.option("--json", "Output JSON").action(async (previewId, opt
18459
19054
  console.log(JSON.stringify(result, null, 2));
18460
19055
  return;
18461
19056
  }
18462
- console.log(kleur20.green(`Preview closed: ${result.previewId}`));
19057
+ console.log(kleur20.green(`Preview closed: ${result.previewId}${result.siteStopped ? "; its site was stopped" : ""}`));
18463
19058
  } catch (error) {
18464
19059
  exitWithPreviewError(error);
18465
19060
  }