@instafy/cli 0.5.0 → 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.
@@ -4,8 +4,8 @@
4
4
  // every string in its data files (data.json, data/**/*.json, records included). Items come from --expect and from
5
5
  // an "expect" array in data.json. Text that only says an item is unavailable ("Not found yet: Tokyo", a data file's
6
6
  // "missing" list) does not count as showing it: builders are told to say on the page what they could not add, and
7
- // that line must not pass the check. On a page of options it also checks the values (below). Pure helpers are
8
- // exported for the tests.
7
+ // that line must not pass the check. On a page of options it also checks the values and where each row comes from
8
+ // (below). Pure helpers are exported for the tests.
9
9
  // SPDX-License-Identifier: AGPL-3.0-only
10
10
  import fs from "node:fs";
11
11
  import path from "node:path";
@@ -162,7 +162,9 @@ export function checkSite(dir, expect = []) {
162
162
  // Each value on such a page names the page it was read on and the day ("checked 27 Oct on maker.com"), and a value
163
163
  // nobody found says so with why; a dash or "n/a" is not a value. A filter from a stated limit ("under €600") counts
164
164
  // only values the page shows: the starters' site.byFilter lists a row whose value is unknown apart ("Price
165
- // unknown"), never as within the limit. These rules read the data the same way site.js draws it.
165
+ // unknown"), never as within the limit. A row of a comparison is a candidate a page read names (a value's or a
166
+ // "sources" page), or only a search result ("found": its site, drawn "Not checked"). These rules read the data the
167
+ // same way site.js and the compare starter draw it.
166
168
 
167
169
  /** A value that only says nothing is known: blank, dashes, "?", "n/a", "unknown", "TBD" (site.js: no value). */
168
170
  export const NO_VALUE = /^(?:[\s\-\u2010-\u2015?.\u00b7\u2026/]*|n\s*\/\s*a|unknown|tb[ad])$/i;
@@ -269,7 +271,38 @@ function liveValue(records, key) {
269
271
  return NO_VALUE.test(String(newest.value).trim()) ? null : numberIn(String(newest.value));
270
272
  }
271
273
 
272
- /** The values of a site's page of options: problems to fix before the card, and notes for the reply. */
274
+ /** A page a row cites: a value with its source and https page, or a "sources" entry with an https page. */
275
+ const citesPage = (fact) => !!(fact && typeof fact === "object" && !fact.est && fact.src && httpsUrl(fact.url));
276
+
277
+ /** A site as a search result names it ("reviews.example.net"): a host name, not a word or an address. */
278
+ const SITE_NAME = /^(?=.{1,253}$)(?:[\p{L}\p{N}](?:[\p{L}\p{N}-]*[\p{L}\p{N}])?\.)+\p{L}[\p{L}\p{N}-]*$/u;
279
+
280
+ /** The rows of a comparison (the compare starter's data: "columns" and "items") that say nowhere where they come from:
281
+ * no value or "sources" entry cites a page that was read, and no "found" names the site of the search result that
282
+ * alone names them. Such a row is a guess or a placeholder ("Model 1"): [{item, problem}]. Judged by the data's
283
+ * shape, never by the row's words. A list of names the same turn writes (`--expect`, data.json's "expect") only says
284
+ * what the page must show; it never vouches for a row. */
285
+ export function uncitedRows(data) {
286
+ if (!isOptionsData(data) || !Array.isArray(data.columns)) return [];
287
+ const out = [];
288
+ for (const item of data.items) {
289
+ if (!item || typeof item !== "object") continue;
290
+ const name = String(item.name ?? item.ref ?? "");
291
+ const facts = [...Object.values(item.facts && typeof item.facts === "object" ? item.facts : {}), ...(Array.isArray(item.more) ? item.more.map((m) => m?.fact) : [])];
292
+ const sources = Array.isArray(item.sources) ? item.sources : [];
293
+ if (facts.some(citesPage) || sources.some((s) => s && typeof s === "object" && s.name && httpsUrl(s.url))) continue;
294
+ if (typeof item.found === "string" && SITE_NAME.test(item.found.trim())) continue;
295
+ const label = name || "a row";
296
+ out.push({
297
+ item: label,
298
+ problem: `${label}: nothing says where this row comes from. Cite the page you read that names it (a value's "src" and "url", or "sources": [{"name", "url"}]). When only a search result names it, put that result's site in "found" ("found": "example.com"): the page then says it is not checked. Naming it in --expect or "expect" does not count. A row nothing named (a guess, "Model 1") does not belong on the page.`,
299
+ });
300
+ }
301
+ return out;
302
+ }
303
+
304
+ /** The values of a site's page of options, and where its rows come from: problems to fix before the card, and notes
305
+ * for the reply. */
273
306
  export function checkValues(dir) {
274
307
  const root = path.resolve(dir);
275
308
  const problems = [];
@@ -294,6 +327,7 @@ export function checkValues(dir) {
294
327
  const list = lacks.length > 1 ? `${lacks.slice(0, -1).join(", ")} or ${lacks[lacks.length - 1]}` : lacks[0];
295
328
  if (lacks.length) problems.push(`${where}: "${valueOf(fact)}" has no ${list}: add where you read it and when ("checked 27 Oct on maker.com"), or leave the value out.${fact.src ? "" : " Without a source the page shows it as not checked."}`);
296
329
  }
330
+ for (const row of uncitedRows(data)) problems.push(row.problem);
297
331
 
298
332
  const filter = data.filter;
299
333
  if (filter && typeof filter === "object") {
@@ -14,10 +14,13 @@
14
14
  * unknown?: "Price unknown"}. Only a value the page shows counts: a row whose value is
15
15
  * unknown is never listed as under $160; it is listed apart, under "Price unknown".
16
16
  * items: [{ref: "option:a", name, why (one short line), badge?: "Previous model", note?, facts: {key: fact},
17
- * store: {name, url, readable}, more?: [{label, fact}], sources?: [{name, url}]}]
17
+ * store: {name, url, readable}, more?: [{label, fact}], sources?: [{name, url}], found?: "example.com"}]
18
18
  * every candidate a page you read named, also one nothing could be read for: its cells say
19
19
  * "Unknown" (a table of unknowns). Empty only when no page named any: it reads "No options yet".
20
- * unchecked: ["what was not checked", …] listed once in the footer
20
+ * A row cites a page you read that names it (a value's or a "sources" page). When only a
21
+ * search result names it, "found" is that result's site instead, and the row says "Not
22
+ * checked". `instafy bind check` fails a row with neither: a guess or a placeholder.
23
+ * unchecked: ["what was not checked", …] listed once in the footer, on an empty page too
21
24
  * fact: {"v": "$145", "src": "Example Store", "url": "https://…", "date": "2026-10-17", "asof"?: "2026-08-05", "est": false}
22
25
  * Each value cites its page and day ("checked 17 Oct on maker.com ↗"), and no cell is blank: a source you read that
23
26
  * does not give the value is the fact without "v" ({"src", "url", "date"}): "Not listed · checked 17 Oct on
@@ -126,7 +129,9 @@
126
129
  }
127
130
  return site.trust(valueOf(item, col), { shared: isShared(src.cols, item, col) || ownRow(src.rows, item, col), label: label, unknown: "Unknown" });
128
131
  }
129
- function hasMore(item) { return !!(item.note || (item.more && item.more.length) || (item.sources && item.sources.length)); }
132
+ function hasMore(item) { return !!(item.note || found(item) || (item.more && item.more.length) || (item.sources && item.sources.length)); }
133
+ /** The site of the search result that alone names this row ("found"), or "". */
134
+ function found(item) { return typeof item.found === "string" ? item.found.trim().slice(0, 100) : ""; }
130
135
 
131
136
  /** The "Sort by" select shows (and rows are sorted) only with 5 or more options and more than one way to sort. */
132
137
  function sortShown(data) { return (data.sort || []).length > 1 && (data.items || []).length >= 5; }
@@ -235,8 +240,8 @@
235
240
  sources = sources.filter(function (s) {
236
241
  return !named.some(function (f) { return f.src === s.name || (site.host(f.url) && site.host(f.url) === site.host(s.url)); });
237
242
  });
238
- // the footer belongs to a list: an empty page has nothing to confirm
239
- site.notes(items.length ? data.unchecked : [], items.length ? sources : []);
243
+ // what was not checked is said on an empty page too: it is why the page is empty
244
+ site.notes(data.unchecked, sources);
240
245
  }
241
246
 
242
247
  /** "2 without a price are listed last." when the sort key is missing for some rows. */
@@ -275,7 +280,8 @@
275
280
 
276
281
  function nameBlock(item) {
277
282
  return [
278
- h("div", { "class": "name-line" }, h("span", { "class": "name", text: item.name }), item.badge ? h("span", { "class": "badge", text: item.badge }) : null),
283
+ h("div", { "class": "name-line" }, h("span", { "class": "name", text: item.name }), item.badge ? h("span", { "class": "badge", text: item.badge }) : null,
284
+ found(item) ? h("span", { "class": "badge", text: "Not checked" }) : null),
279
285
  item.why ? h("p", { "class": "cap", text: item.why }) : null
280
286
  ];
281
287
  }
@@ -296,6 +302,7 @@
296
302
  var sources = (item.sources || []).filter(function (s) { return s && !linked[s.url]; });
297
303
  return h("div", { "class": "more" },
298
304
  item.note ? h("p", { text: item.note }) : null,
305
+ found(item) ? h("p", { "class": "cap", text: "Named in a search result on " + found(item) + "." }) : null,
299
306
  facts.length ? h("dl", { "class": "facts" }, facts.map(function (f) {
300
307
  return h("div", null, h("dt", { text: f.label }), h("dd", null, site.trust(f.fact)));
301
308
  })) : null,
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;
@@ -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,
@@ -9811,7 +9816,9 @@ async function runSiteCheck(opts) {
9811
9816
  const port = parsePreviewPort(opts.port, env);
9812
9817
  const sitePath = parseCheckPath(opts.path);
9813
9818
  const timeoutMs = opts.timeoutMs ?? 3e4;
9814
- 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
+ );
9815
9822
  let outDir = null;
9816
9823
  if (opts.outDir !== void 0) {
9817
9824
  outDir = path12.resolve(opts.outDir);
@@ -9925,12 +9932,14 @@ async function runPreviewCheckCli(opts, io = { out: console.log, err: console.er
9925
9932
  const port = parsePreviewPort(opts.port, env);
9926
9933
  const sitePath = parseCheckPath(opts.path);
9927
9934
  const wantsThumbnail = Boolean(io.uploadThumbnail && io.hasOpenPreview?.(port, sitePath));
9935
+ const takenAt = Date.now();
9928
9936
  const result = await runSiteCheck({
9929
9937
  port,
9930
9938
  path: opts.path,
9931
9939
  thumbnail: wantsThumbnail,
9932
9940
  proxyServer: io.proxyServer,
9933
9941
  phone: opts.phone,
9942
+ desktop: opts.desktop,
9934
9943
  framed: opts.framed,
9935
9944
  workspacePlaywright: opts.workspacePlaywright,
9936
9945
  outDir: opts.out,
@@ -9945,7 +9954,9 @@ async function runPreviewCheckCli(opts, io = { out: console.log, err: console.er
9945
9954
  }
9946
9955
  if (wantsThumbnail && result.thumbnail && io.uploadThumbnail) {
9947
9956
  const shot = result.thumbnail;
9948
- 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) }));
9949
9960
  if (outcome) {
9950
9961
  result.report.thumbnail = outcome.status;
9951
9962
  if (outcome.reason) result.report.thumbnailReason = outcome.reason;
@@ -9965,7 +9976,7 @@ async function runPreviewCheckCli(opts, io = { out: console.log, err: console.er
9965
9976
  function registerPreviewCheckCommand(preview, card = {}) {
9966
9977
  return preview.command("check").description(
9967
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)"
9968
- ).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) => {
9969
9980
  process.exitCode = await runPreviewCheckCli(opts, { out: console.log, err: console.error, ...card });
9970
9981
  });
9971
9982
  }
@@ -10395,10 +10406,10 @@ function lockHolderIsGone(lockFile) {
10395
10406
  return false;
10396
10407
  }
10397
10408
  }
10398
- async function withStateLock(fn) {
10409
+ async function withStateLock(fn, waitMs = STATE_LOCK_WAIT_MS) {
10399
10410
  const lockFile = previewStateLockFile();
10400
10411
  fs12.mkdirSync(instafyDir(), { recursive: true });
10401
- const deadline = Date.now() + STATE_LOCK_WAIT_MS;
10412
+ const deadline = Date.now() + waitMs;
10402
10413
  for (; ; ) {
10403
10414
  try {
10404
10415
  const fd = fs12.openSync(lockFile, "wx", 384);
@@ -11135,7 +11146,11 @@ ${started.logTail}` : "";
11135
11146
  ...binary ? { binary } : {},
11136
11147
  ...hostSuffix ? { hostSuffix } : {},
11137
11148
  ...studioOrigin ? { studioOrigin } : {},
11138
- ...siteRecord
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 } : {}
11139
11154
  };
11140
11155
  upsertPreviewState(entry);
11141
11156
  return { grant, previewId, entry, current };
@@ -11241,8 +11256,10 @@ async function previewOpen(opts) {
11241
11256
  thumbnail = { status: "skipped", reason: "the site is not live through the preview yet" };
11242
11257
  } else {
11243
11258
  if (!opts.thumbnailFile) log("Taking a picture of the site for its card\u2026");
11259
+ const takenAt = Date.now();
11244
11260
  const attempt2 = opts.thumbnailFile ? readThumbnailFile(opts.thumbnailFile) : await captureSiteThumbnail({ port, path: sitePath, host: "127.0.0.1" });
11245
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);
11246
11263
  }
11247
11264
  return {
11248
11265
  previewId,
@@ -11286,12 +11303,30 @@ async function uploadPreviewThumbnail(params) {
11286
11303
  return { status: "failed", reason: message.slice(0, 300) };
11287
11304
  }
11288
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
+ }
11289
11319
  function openPreviewFor(port, sitePath) {
11290
- 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;
11291
11322
  }
11292
11323
  function hasOpenPreviewFor(port, sitePath) {
11293
11324
  return openPreviewFor(port, sitePath) !== null;
11294
11325
  }
11326
+ async function recordCheckPictureTried(input3) {
11327
+ const entry = openPreviewFor(input3.port, input3.path);
11328
+ if (entry) await recordPictured(entry.previewId, input3.takenAt);
11329
+ }
11295
11330
  async function uploadCheckThumbnail(input3) {
11296
11331
  const entry = openPreviewFor(input3.port, input3.path);
11297
11332
  if (!entry) return null;
@@ -11306,9 +11341,13 @@ async function uploadCheckThumbnail(input3) {
11306
11341
  if (cleanUrl(controllerUrl) !== cleanUrl(entry.controllerUrl)) {
11307
11342
  return { status: "skipped", reason: "the preview was opened on another server" };
11308
11343
  }
11344
+ const takenAt = input3.takenAt ?? Date.now();
11309
11345
  const facts = sniffImage(input3.image);
11310
- if (!facts) return { status: "skipped", reason: "the picture is not a JPEG" };
11311
- 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({
11312
11351
  controllerUrl,
11313
11352
  auth,
11314
11353
  projectId: entry.projectId,
@@ -11316,6 +11355,8 @@ async function uploadCheckThumbnail(input3) {
11316
11355
  thumbnail: { image: input3.image, ...facts, source: "capture", description: input3.description },
11317
11356
  retryCommand
11318
11357
  });
11358
+ await recordPictured(entry.previewId, takenAt);
11359
+ return outcome;
11319
11360
  }
11320
11361
  function toPreviewSummary(raw) {
11321
11362
  if (!raw || typeof raw !== "object") return null;
@@ -13458,7 +13499,7 @@ function liveTurn(env, now) {
13458
13499
  }
13459
13500
  function keyless(chain) {
13460
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";
13461
- 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.`;
13462
13503
  }
13463
13504
  function tellFor(status, chain) {
13464
13505
  switch (status) {
@@ -18916,7 +18957,11 @@ function exitWithPreviewError(error) {
18916
18957
  }
18917
18958
  var previewCommand = program.command("preview").description("Look at sites running in this space, and show them in Studio as a private preview card");
18918
18959
  failWithGroupHelp(previewCommand);
18919
- registerPreviewCheckCommand(previewCommand, { hasOpenPreview: hasOpenPreviewFor, uploadThumbnail: uploadCheckThumbnail });
18960
+ registerPreviewCheckCommand(previewCommand, {
18961
+ hasOpenPreview: hasOpenPreviewFor,
18962
+ uploadThumbnail: uploadCheckThumbnail,
18963
+ pictureTried: recordCheckPictureTried
18964
+ });
18920
18965
  var webCommand = registerWebSearchCommand(registerWebLinkCommand(registerWebCommand(program)));
18921
18966
  failWithGroupHelp(webCommand);
18922
18967
  var webSettingsCommand = webCommand.command("settings").description("Inspect or change the space's shared research budget for new tasks");
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@instafy/cli",
3
- "version": "0.5.0",
3
+ "version": "0.5.1",
4
4
  "license": "AGPL-3.0-only",
5
5
  "description": "Run Instafy spaces locally, link folders to Studio, and share previews/webhooks via tunnels.",
6
6
  "private": false,