scenri 0.9.2 → 0.9.3

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/CHANGELOG.md CHANGED
@@ -1,5 +1,67 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.9.3](https://github.com/tonygorb/Scenri/compare/v0.9.2...v0.9.3) (2026-09-14)
4
+
5
+
6
+ ### Features
7
+
8
+ * **catalog:** look for a shop without reading the whole catalog ([65774cd](https://github.com/tonygorb/Scenri/commit/65774cdcc7e45dc5468b564bfb7596d2fcbb1500))
9
+ * **catalog:** make a whole-catalogue import something a machine can hold ([9c44842](https://github.com/tonygorb/Scenri/commit/9c44842e2f421671543a418cb2a884b12e02f26e))
10
+ * **cli:** scan a site for a shop, and import only what was chosen ([3f02cc5](https://github.com/tonygorb/Scenri/commit/3f02cc592ec025ba920de6e63683977113e5854f))
11
+ * **composer:** the insert menu pages, and stops moving under the caret ([d3fa697](https://github.com/tonygorb/Scenri/commit/d3fa69744e02cd69ae6ab708b095ef7c45933f48))
12
+ * one website, a brand and its products ([ced5f4c](https://github.com/tonygorb/Scenri/commit/ced5f4cccbb5aedc500332b166d4e42c95d344ac))
13
+ * **studio:** reach every product, take them all, and watch it happen ([3f19576](https://github.com/tonygorb/Scenri/commit/3f195767d59204c68fdd8c0870f2faf2349585a5))
14
+ * **studio:** say what a website had on it, products included ([45665e7](https://github.com/tonygorb/Scenri/commit/45665e70c10a8f307bf57ee22f4a22ee936d6870))
15
+ * the composer insert menu pages, and stops moving under the caret ([1c36667](https://github.com/tonygorb/Scenri/commit/1c3666772b46552b4c4a76123f4a00fd7b651c69))
16
+
17
+
18
+ ### Bug Fixes
19
+
20
+ * **brand:** a website is a brand source, and "Invalid URL" is not a sentence ([cde0841](https://github.com/tonygorb/Scenri/commit/cde0841bcd79c49f8d0d3ed29da4c3429718f284))
21
+ * **brand:** find the logo and the colours a site actually has, safely ([a6e7d3b](https://github.com/tonygorb/Scenri/commit/a6e7d3be87196fd0386448c91f6dca712dfc0b94))
22
+ * **brand:** never crown a logo the evidence does not support ([07b427d](https://github.com/tonygorb/Scenri/commit/07b427d7e7b4acd4d4c03ac3f23ed4722f297b61))
23
+ * **brand:** pick the wordmark out of the nav, not a photo two elements later ([55cb444](https://github.com/tonygorb/Scenri/commit/55cb444b1c00abdd4ec28640375dc5dad518f8f0))
24
+ * **brand:** read the palette a site declares, for the theme it is wearing ([323b722](https://github.com/tonygorb/Scenri/commit/323b7229c8f89c2dac9b58c60d596eb157362715))
25
+ * **brand:** show the kit that was just built, instead of flashing it ([5f5672f](https://github.com/tonygorb/Scenri/commit/5f5672ff811f54e83fa44442e2f7db9a40f272cb))
26
+ * **brand:** stop inventing products, and stop crawling sites nobody asked about ([5a70c7d](https://github.com/tonygorb/Scenri/commit/5a70c7d9cff539ae4c8cae6a8b40f4c219ee5e49))
27
+ * **catalog:** a finished import says finished ([640a8a1](https://github.com/tonygorb/Scenri/commit/640a8a1fa8f08d1920fe83078f054303958646cd))
28
+ * **catalog:** a slow catalog is not a shut one ([06c8ba4](https://github.com/tonygorb/Scenri/commit/06c8ba491a4c2e65d585c33d2543cda286c5aba9))
29
+ * **catalog:** four pictures at once too, and count a refusal that threw ([c773029](https://github.com/tonygorb/Scenri/commit/c77302904c933f3b4b1c220a7ff2c8d529536b5f))
30
+ * **catalog:** import in batches, and stop the setup screen losing the products ([4257515](https://github.com/tonygorb/Scenri/commit/4257515a46bb07594dd88657d87046daa665a850))
31
+ * **catalog:** read a shop at a rate it will tolerate, and say when it stops ([98c2e53](https://github.com/tonygorb/Scenri/commit/98c2e53d573d037ba4f1f72e5212b97083f5afab))
32
+ * **catalog:** read the product pages a blocked storefront API still serves ([21ab82b](https://github.com/tonygorb/Scenri/commit/21ab82b0427f0cc30a6884637663439981bd1c29))
33
+ * **catalog:** stop reading the same catalog once per language ([1a870aa](https://github.com/tonygorb/Scenri/commit/1a870aabd1fd5b627d36e253787d6ef08b33337f))
34
+ * **catalog:** stop the crawler being pointed anywhere a caller likes ([d4a0204](https://github.com/tonygorb/Scenri/commit/d4a0204892b3cb7c6f94e03153505a9bad8d97e4))
35
+ * **catalog:** two things eight real websites found ([1c938f6](https://github.com/tonygorb/Scenri/commit/1c938f6babc0da62b55fa1fce6d04de069e04ae4))
36
+ * **codex:** ready now means a real codex run authenticated ([33cf286](https://github.com/tonygorb/Scenri/commit/33cf28653d63cdfeab267db9557ae0b696197bd0))
37
+ * **composer:** type the insert menu's page state as a number ([86ec924](https://github.com/tonygorb/Scenri/commit/86ec924f70c56e7d96e0bc5772331883ee64ef88))
38
+ * **deps:** take the patched vitest line, closing three Dependabot alerts ([9ec12d0](https://github.com/tonygorb/Scenri/commit/9ec12d0989b3cb9b033a61bb00c1b3c7e891bd32))
39
+ * **desktop:** say why the icon failed, not which PowerShell command failed ([8108827](https://github.com/tonygorb/Scenri/commit/81088274944b8df1042e2095451552d09a33c6df))
40
+ * **desktop:** the icon offer can no longer stop Scenri from starting ([dacdbba](https://github.com/tonygorb/Scenri/commit/dacdbba142709d11e7fac0c57828cbdd9acf0a2d))
41
+ * **install:** stop sending Windows users to an administrator they do not need ([42c43ea](https://github.com/tonygorb/Scenri/commit/42c43ea36f6b9d22bc4dae0d296a0cee7634cf18))
42
+ * **notifications:** the Tasks tab holds work in flight, not every import ever run ([8d67c75](https://github.com/tonygorb/Scenri/commit/8d67c7598b6a217478021274d91184a5d5bb957b))
43
+ * **products:** a hand-made product keeps all of its pictures ([14e6120](https://github.com/tonygorb/Scenri/commit/14e6120da46ee78cabf6e86fc0f2661255037259))
44
+ * **studio:** a building card is the wait, not a caption ([7ddc983](https://github.com/tonygorb/Scenri/commit/7ddc98337e8d98452e408798e0ec7cc88a561b33))
45
+ * **studio:** keep the store's half of the library as the server sorted it ([11bb05f](https://github.com/tonygorb/Scenri/commit/11bb05fd75edf5a0c4d8efed8227f7e0357b8f2a))
46
+ * **studio:** show the products arriving, and stop asking for more than can land ([62ebca0](https://github.com/tonygorb/Scenri/commit/62ebca0a0cff29bf0a5e914499363d750ecd0ee0))
47
+ * **studio:** your own work, newest first ([4bbfda2](https://github.com/tonygorb/Scenri/commit/4bbfda2d3e17bf586d974a93cad822af2e84d09f))
48
+ * Windows first run, Codex readiness and website brand import ([0831502](https://github.com/tonygorb/Scenri/commit/0831502163385fb12a04919d26acf5677d761427))
49
+ * your own work newest first, and a quieter building card ([20c4e1c](https://github.com/tonygorb/Scenri/commit/20c4e1c1cfd629ef88e4aa512e855103ad3e502a))
50
+
51
+
52
+ ### Performance Improvements
53
+
54
+ * **catalog:** crawl a whole store in batches, and close jobs a crash left open ([98f1ce9](https://github.com/tonygorb/Scenri/commit/98f1ce9daaa5fa70e97ac308c075269fdcbe3254))
55
+ * **catalog:** import one product at a time, pictures alongside ([a86abc8](https://github.com/tonygorb/Scenri/commit/a86abc886ae644ba265cdf5c6d316dc15dbad09b))
56
+ * **catalog:** make a large import progressive, bounded and watchable ([fff18dc](https://github.com/tonygorb/Scenri/commit/fff18dca84156b794d9eb67a582709f55b3f94b3))
57
+ * **catalog:** read twelve product pages at once, and stop cutting them short ([c137cfd](https://github.com/tonygorb/Scenri/commit/c137cfd3dc319093f8484e2b719f5aaae607ee87))
58
+ * **products:** a waiting card holds its space, and its picture is asked for early ([5a20f91](https://github.com/tonygorb/Scenri/commit/5a20f91c960ceeecfa813d503c437965449091f7))
59
+
60
+
61
+ ### Miscellaneous Chores
62
+
63
+ * **release:** pin the next version to 0.9.3 ([1b019ba](https://github.com/tonygorb/Scenri/commit/1b019bae1ab357421287c10d1765111e843134ae))
64
+
3
65
  ## [0.9.2](https://github.com/tonygorb/Scenri/compare/v0.9.1...v0.9.2) (2026-09-07)
4
66
 
5
67
 
package/README.md CHANGED
@@ -63,7 +63,9 @@ Scenri runs on [Node.js](https://nodejs.org), version 22 or newer. If you are no
63
63
  npx scenri
64
64
  ```
65
65
 
66
- That is the whole install. npm asks once whether to proceed, downloads the current release, and opens `http://127.0.0.1:4747`. Keep the terminal window open while you work; closing it stops Scenri. Tomorrow the same command opens it again, with everything where you left it.
66
+ That is the whole install. npm asks once whether to proceed, downloads the current release, and opens `http://127.0.0.1:4747`. Keep the terminal window open while you work; closing it stops Scenri. Tomorrow the same command opens it again, with everything where you left it. Nothing here needs an administrator.
67
+
68
+ On Windows, if PowerShell answers `npx.ps1 cannot be loaded because running scripts is disabled on this system`, that is a stock Windows setting, not a fault: type `npx.cmd scenri` instead, or use Command Prompt. Still no administrator required.
67
69
 
68
70
  On macOS and Windows, Scenri then asks once whether to put a **Scenri icon on your desktop**. Say yes and from then on a double-click starts Scenri and opens it in your browser, no terminal needed; the icon works offline and keeps working across updates. Said no? Settings, then About, then **Add to desktop**, or `npx scenri desktop` in a terminal. Scenri stays what it is, a local server and your browser: the icon is a launcher, not an app.
69
71
 
@@ -146,12 +146,35 @@ async function writeLnk(runImpl, opts) {
146
146
  // src/desktop/install.ts
147
147
  var UNSUPPORTED = "Desktop shortcuts are not available on this system yet.";
148
148
  var NO_DESKTOP = "Your Desktop folder could not be found.";
149
+ var PROBE_FAILED = "This computer did not answer where your Desktop folder is.";
149
150
  var COLLISION = "Something else named Scenri is already on your desktop. Move or rename it, then try again.";
150
151
  var DENIED = "macOS did not allow writing to your Desktop. Allow it under System Settings > Privacy & Security > Files and Folders, then try again.";
151
152
  var SUPPORT_FILES = ["launch.mjs", "Scenri.icns", "scenri.ico"];
153
+ function failureDetail(err) {
154
+ const e = err;
155
+ const lineOf = (text) => text.split("\n").map((l) => l.trim()).filter(Boolean)[0] ?? "";
156
+ const stderr = typeof e.stderr === "string" ? lineOf(e.stderr) : "";
157
+ if (stderr) return stderr.slice(0, 200);
158
+ if (e.killed === true || e.code === "ETIMEDOUT") return "it did not answer in time";
159
+ const message = err instanceof Error ? err.message : String(err);
160
+ const useful = message.split("\n").map((l) => l.trim()).filter(Boolean).find((l) => !/^Command failed:/i.test(l));
161
+ return (useful ?? "the command did not finish").slice(0, 200);
162
+ }
152
163
  async function installDesktop(deps) {
164
+ try {
165
+ return await install(deps);
166
+ } catch (err) {
167
+ return { ok: false, reason: "failed", message: `Scenri could not add the desktop icon (${failureDetail(err)}).` };
168
+ }
169
+ }
170
+ async function install(deps) {
153
171
  if (!isSupportedPlatform(deps.platform)) return { ok: false, reason: "unsupported", message: UNSUPPORTED };
154
- const desktop = await desktopDir(deps);
172
+ let desktop;
173
+ try {
174
+ desktop = await desktopDir(deps);
175
+ } catch (err) {
176
+ return { ok: false, reason: "probe-failed", message: `${PROBE_FAILED} (${failureDetail(err)})` };
177
+ }
155
178
  if (!desktop) return { ok: false, reason: "no-desktop", message: NO_DESKTOP };
156
179
  const darwin = deps.platform === "darwin";
157
180
  const support = launcherDir(deps.homedir);
@@ -164,7 +187,15 @@ async function installDesktop(deps) {
164
187
  }
165
188
  const nodeMajor = runningNodeMajor();
166
189
  const nodePath = stableExecPath(deps.execPath, deps.platform);
167
- writeSupportFiles({ ...deps, execPath: nodePath, nodeMajor }, support);
190
+ try {
191
+ writeSupportFiles({ ...deps, execPath: nodePath, nodeMajor }, support);
192
+ } catch (err) {
193
+ return {
194
+ ok: false,
195
+ reason: "failed",
196
+ message: `Scenri could not write its launcher files to ${support} (${failureDetail(err)}).`
197
+ };
198
+ }
168
199
  try {
169
200
  if (darwin) {
170
201
  writeMacBundle({
@@ -186,18 +217,25 @@ async function installDesktop(deps) {
186
217
  const code = err.code;
187
218
  if (darwin && (code === "EPERM" || code === "EACCES"))
188
219
  return { ok: false, reason: "desktop-denied", message: DENIED };
189
- const detail = err instanceof Error ? err.message.split("\n")[0] : String(err);
190
- return { ok: false, reason: "failed", message: `Scenri could not write ${artifact} (${detail}).` };
220
+ return { ok: false, reason: "failed", message: `Scenri could not write ${artifact} (${failureDetail(err)}).` };
221
+ }
222
+ try {
223
+ writeLauncherRecord(deps.homedir, {
224
+ schema: LAUNCHER_SCHEMA,
225
+ createdBy: deps.version,
226
+ home: deps.home,
227
+ nodePath,
228
+ nodeMajor,
229
+ env: recordedEnv(deps.env),
230
+ artifact: { kind, path: artifact }
231
+ });
232
+ } catch (err) {
233
+ return {
234
+ ok: false,
235
+ reason: "failed",
236
+ message: `Scenri added the icon but could not record where it points (${failureDetail(err)}).`
237
+ };
191
238
  }
192
- writeLauncherRecord(deps.homedir, {
193
- schema: LAUNCHER_SCHEMA,
194
- createdBy: deps.version,
195
- home: deps.home,
196
- nodePath,
197
- nodeMajor,
198
- env: recordedEnv(deps.env),
199
- artifact: { kind, path: artifact }
200
- });
201
239
  return { ok: true, kind, path: artifact };
202
240
  }
203
241
  var runningNodeMajor = () => Number(process.versions.node.split(".")[0]);
@@ -250,6 +288,6 @@ async function desktopStatus(deps) {
250
288
  return { supported, platform: deps.platform, installed, path, current, record };
251
289
  }
252
290
 
253
- export { MAC_SCRIPT, POWERSHELL_ARGS, UNSUPPORTED, desktopStatus, installDesktop, isOurMacBundle, macPlist, removeDesktop, runningNodeMajor, stableExecPath, writeLnk, writeMacBundle, writeSupportFiles };
254
- //# sourceMappingURL=chunk-SDVL5OJF.js.map
255
- //# sourceMappingURL=chunk-SDVL5OJF.js.map
291
+ export { MAC_SCRIPT, POWERSHELL_ARGS, UNSUPPORTED, desktopStatus, failureDetail, installDesktop, isOurMacBundle, macPlist, removeDesktop, runningNodeMajor, stableExecPath, writeLnk, writeMacBundle, writeSupportFiles };
292
+ //# sourceMappingURL=chunk-3BGDI4HI.js.map
293
+ //# sourceMappingURL=chunk-3BGDI4HI.js.map
@@ -551,23 +551,32 @@ function openDb(homeDir) {
551
551
  function createImageStore(homeDir) {
552
552
  const dir = join(homeDir, "images");
553
553
  mkdirSync(dir, { recursive: true, mode: 448 });
554
- const fileFor = (hash) => join(dir, `${hash}.png`);
554
+ const EXTS = ["png", "jpg", "jpeg", "webp", "gif", "avif"];
555
+ const fileFor = (hash, ext = "png") => join(dir, `${hash}.${ext}`);
556
+ const resolve = (hash) => {
557
+ for (const ext of EXTS) {
558
+ const file = fileFor(hash, ext);
559
+ if (existsSync(file)) return file;
560
+ }
561
+ return fileFor(hash);
562
+ };
555
563
  return {
556
- save(buf) {
564
+ save(buf, ext = "png") {
565
+ const clean = /^[a-z0-9]{2,5}$/i.test(ext) ? ext.toLowerCase() : "png";
557
566
  const hash = createHash("sha256").update(buf).digest("hex").slice(0, 32);
558
- const file = fileFor(hash);
567
+ const file = fileFor(hash, clean);
559
568
  if (!existsSync(file)) writeFileSync(file, buf);
560
569
  return hash;
561
570
  },
562
571
  pathFor(hash) {
563
572
  if (!/^[a-f0-9]{32}$/.test(hash)) throw new Error("invalid image hash");
564
- return fileFor(hash);
573
+ return resolve(hash);
565
574
  },
566
575
  read(hash) {
567
576
  return readFileSync(this.pathFor(hash));
568
577
  },
569
578
  has(hash) {
570
- return /^[a-f0-9]{32}$/.test(hash) && existsSync(fileFor(hash));
579
+ return /^[a-f0-9]{32}$/.test(hash) && EXTS.some((ext) => existsSync(fileFor(hash, ext)));
571
580
  }
572
581
  };
573
582
  }
@@ -1335,8 +1344,9 @@ var productById = (db, id) => {
1335
1344
  const r = db.prepare("SELECT * FROM catalog_products WHERE id=?").get(id);
1336
1345
  return r ? rowProduct(r) : null;
1337
1346
  };
1347
+ var LIST_COLS = "id, source_id, brand_id, external_key, title, description_html, url, handle, vendor, product_type, tags, category, price, compare_at_price, currency, available, status, variant, material, dimensions, created_at, updated_at";
1338
1348
  var productsFor = (db, brandId) => db.prepare(
1339
- "SELECT * FROM catalog_products WHERE brand_id=? AND status!='unavailable' ORDER BY title COLLATE NOCASE"
1349
+ `SELECT ${LIST_COLS} FROM catalog_products WHERE brand_id=? AND status!='unavailable' ORDER BY created_at DESC, rowid DESC`
1340
1350
  ).all(brandId).map(rowProduct);
1341
1351
  var rowVariant = (r) => ({
1342
1352
  id: r.id,
@@ -1469,14 +1479,58 @@ function jobMethods(db) {
1469
1479
  const r = db.prepare("SELECT * FROM import_jobs WHERE id=?").get(id);
1470
1480
  return r ? rowJob(r) : null;
1471
1481
  },
1482
+ /**
1483
+ * What the notifications bell needs: work in flight, and what has just
1484
+ * stopped.
1485
+ *
1486
+ * `listJobs` answers with every import the brand has ever run, and the
1487
+ * bell rendered all of them as tasks - so one finished import sat in the
1488
+ * Tasks tab, and in its count, for ever, and a brand with a few imports
1489
+ * behind it opened onto a list of things that were over. An hour is long
1490
+ * enough for the panel to still be showing you what landed while you were
1491
+ * on another screen, and short enough that it is never a history.
1492
+ */
1493
+ listRecentJobs(brandId) {
1494
+ return db.prepare(
1495
+ `SELECT * FROM import_jobs
1496
+ WHERE brand_id=?
1497
+ AND (finished_at IS NULL OR finished_at > datetime('now','-1 hour'))
1498
+ ORDER BY created_at DESC`
1499
+ ).all(brandId).map(rowJob);
1500
+ },
1472
1501
  listJobs(brandId) {
1473
1502
  return db.prepare("SELECT * FROM import_jobs WHERE brand_id=? ORDER BY created_at DESC").all(brandId).map(rowJob);
1474
1503
  },
1504
+ /**
1505
+ * Close every job that was still running when the process last stopped.
1506
+ *
1507
+ * A job lives in the database and its worker lives in the process, so a
1508
+ * quit, a crash or a restart leaves a row that says `fetching_products`
1509
+ * with no `finished_at` and nothing on earth still working on it. The bell
1510
+ * then shows a task running for ever, and no later write can correct it
1511
+ * because nothing is left to do the writing. Nothing is in flight at
1512
+ * startup by definition, so anything unfinished here was interrupted.
1513
+ *
1514
+ * Whatever it had already saved stays saved; only the row is closed.
1515
+ */
1516
+ reconcileInterruptedJobs() {
1517
+ const { changes } = db.prepare(
1518
+ `UPDATE import_jobs
1519
+ SET stage='cancelled',
1520
+ finished_at=datetime('now'),
1521
+ message=CASE WHEN upserted > 0
1522
+ THEN 'Interrupted when Scenri stopped, after saving ' || upserted || ' products'
1523
+ ELSE 'Interrupted when Scenri stopped' END
1524
+ WHERE finished_at IS NULL`
1525
+ ).run();
1526
+ return changes;
1527
+ },
1475
1528
  updateJob(id, patch) {
1476
1529
  const cur = jobById(db, id);
1477
1530
  if (!cur) return null;
1531
+ if (cur.finishedAt) return cur;
1478
1532
  const stage = patch.stage ?? cur.stage;
1479
- const finished = patch.finished || stage === "completed" || stage === "partial" || stage === "failed";
1533
+ const finished = patch.finished || stage === "completed" || stage === "partial" || stage === "no_catalog" || stage === "cancelled" || stage === "failed";
1480
1534
  db.prepare(
1481
1535
  `UPDATE import_jobs SET
1482
1536
  source_id=?, platform=?, stage=?, discovered=?, fetched=?, upserted=?,
@@ -1779,6 +1833,18 @@ function mutationMethods(db) {
1779
1833
  }
1780
1834
 
1781
1835
  // ../core/src/catalog/library.ts
1836
+ var lighten = (p) => ({
1837
+ id: p.id,
1838
+ name: p.name,
1839
+ origin: p.origin,
1840
+ category: p.category ?? null,
1841
+ variant: p.variant ?? null,
1842
+ material: p.material ?? null,
1843
+ dimensions: p.dimensions ?? null,
1844
+ status: p.status,
1845
+ shots: p.shots.slice(0, 1),
1846
+ shotCount: p.shots.length
1847
+ });
1782
1848
  function libraryMethods(db) {
1783
1849
  return {
1784
1850
  listLibraryProducts(brandId, brandJson) {
@@ -1836,6 +1902,29 @@ function libraryMethods(db) {
1836
1902
  };
1837
1903
  });
1838
1904
  return [...manual, ...catalog];
1905
+ },
1906
+ /**
1907
+ * The whole library, in the shape the studio reads.
1908
+ *
1909
+ * Newest first, so an import lands where someone is watching and nothing
1910
+ * already on screen moves under them. Manual products keep their place at
1911
+ * the head - they are the ones a person made by hand.
1912
+ */
1913
+ listLibraryIndex(brandId, brandJson) {
1914
+ return this.listLibraryProducts(brandId, brandJson).map(
1915
+ (p) => (
1916
+ // Only what came from a store. A manual product's pictures are already
1917
+ // in the brand document this was built from, so trimming them saves
1918
+ // nothing - and the product page asks the server for the whole record
1919
+ // only for catalog products, so a hand-made product with four
1920
+ // references was left showing one, everywhere, for good.
1921
+ p.origin === "catalog" ? lighten(p) : { ...lighten(p), shots: p.shots, shotCount: p.shots.length }
1922
+ )
1923
+ );
1924
+ },
1925
+ /** One product, whole, for the page that shows all of its pictures. */
1926
+ libraryProduct(brandId, brandJson, productId) {
1927
+ return this.listLibraryProducts(brandId, brandJson).find((p) => p.id === productId) ?? null;
1839
1928
  }
1840
1929
  };
1841
1930
  }
@@ -1936,5 +2025,5 @@ function createCore(homeDir = defaultHome()) {
1936
2025
  }
1937
2026
 
1938
2027
  export { ASPECT_TOLERANCE, BUDGET_EXHAUSTED, EDIT_REFERENCE_ROLE_DIRECTIVE, REFERENCE_ROLE_DIRECTIVE, SCHEMA_VERSION, STEM_MIN, SchemaTooNewError, SpendCapError, TRIGRAM_MIN, budgetSize, createCatalogStore, createCore, createStore, defaultHome, fold, ftsMatch, matchesQuery, ratioLabel, searchTerms, termMatches, uniqueProjectSlug, uniqueSetSlug, uniqueSlug };
1939
- //# sourceMappingURL=chunk-OJAG3FRX.js.map
1940
- //# sourceMappingURL=chunk-OJAG3FRX.js.map
2028
+ //# sourceMappingURL=chunk-Y5L3KLX5.js.map
2029
+ //# sourceMappingURL=chunk-Y5L3KLX5.js.map
@@ -2,10 +2,10 @@ import { adoptRunningInstall } from './chunk-ECS5LRHB.js';
2
2
  import { detectInstallKind } from './chunk-PAIAXRAC.js';
3
3
  import { readMeta } from './chunk-Y3ZPBPLP.js';
4
4
  import { defaultHome, newestStaged, listStaged, compareSemver, entryOf } from './chunk-4MAFHYAD.js';
5
- import { removeDesktop, installDesktop, POWERSHELL_ARGS } from './chunk-SDVL5OJF.js';
5
+ import { removeDesktop, failureDetail, installDesktop, POWERSHELL_ARGS } from './chunk-3BGDI4HI.js';
6
6
  import { launcherDir, logsDir, powershellPath } from './chunk-2IGINCPU.js';
7
7
  import { execFile, spawn } from 'child_process';
8
- import { existsSync, closeSync, readFileSync, writeFileSync, mkdirSync, openSync, rmSync, statSync, renameSync, appendFileSync } from 'fs';
8
+ import { closeSync, existsSync, readFileSync, writeFileSync, mkdirSync, openSync, rmSync, statSync, renameSync, appendFileSync } from 'fs';
9
9
  import { homedir } from 'os';
10
10
  import { join, dirname, delimiter, win32 } from 'path';
11
11
  import { createServer } from 'http';
@@ -419,6 +419,15 @@ async function runDesktopCommand(opts, ownEntry) {
419
419
  return (await addToDesktop(ownEntry)).ok ? 0 : 1;
420
420
  }
421
421
  async function addToDesktop(ownEntry, say = console.log, over = {}) {
422
+ try {
423
+ return await addOrRepair(ownEntry, say, over);
424
+ } catch (err) {
425
+ const message = `Scenri could not add the desktop icon (${failureDetail(err)}).`;
426
+ say(` ${message}`);
427
+ return { ok: false, reason: "failed", message };
428
+ }
429
+ }
430
+ async function addOrRepair(ownEntry, say, over) {
422
431
  const deps = { ...installDeps(ownEntry), ...over };
423
432
  const pkg = over.pkg ?? readMeta().name;
424
433
  const installKind = over.installKind ?? detectInstallKind(ownEntry, deps.home);
@@ -454,7 +463,7 @@ async function addToDesktop(ownEntry, say = console.log, over = {}) {
454
463
  }
455
464
  async function rememberDecline(home) {
456
465
  try {
457
- const { createCore } = await import('./src-CSR34EBZ.js');
466
+ const { createCore } = await import('./src-F3KJZNZT.js');
458
467
  const core = createCore(home);
459
468
  try {
460
469
  core.store.setSetting("desktop.prompt", "declined");
@@ -519,8 +528,7 @@ function tail(path, bytes = 4096) {
519
528
  return "";
520
529
  }
521
530
  }
522
- var supportInstalled = () => existsSync(join(launcherDir(homedir()), "launcher.json"));
523
531
 
524
- export { addToDesktop, assetsDirFor, installDeps, runDesktopCommand, runExecFile, runOpenCommand, supportInstalled };
525
- //# sourceMappingURL=cli-FYWM4HBN.js.map
526
- //# sourceMappingURL=cli-FYWM4HBN.js.map
532
+ export { addToDesktop, assetsDirFor, installDeps, runDesktopCommand, runExecFile, runOpenCommand };
533
+ //# sourceMappingURL=cli-SX46UQO6.js.map
534
+ //# sourceMappingURL=cli-SX46UQO6.js.map
package/dist/index.js CHANGED
@@ -86,12 +86,12 @@ try {
86
86
  break;
87
87
  }
88
88
  case "desktop": {
89
- const { runDesktopCommand } = await import('./cli-FYWM4HBN.js');
89
+ const { runDesktopCommand } = await import('./cli-SX46UQO6.js');
90
90
  process.exit(await runDesktopCommand(command, fileURLToPath(import.meta.url)));
91
91
  break;
92
92
  }
93
93
  case "open": {
94
- const { runOpenCommand } = await import('./cli-FYWM4HBN.js');
94
+ const { runOpenCommand } = await import('./cli-SX46UQO6.js');
95
95
  process.exit(await runOpenCommand(fileURLToPath(import.meta.url)));
96
96
  break;
97
97
  }
@@ -0,0 +1,4 @@
1
+ export { UNSUPPORTED, desktopStatus, failureDetail, installDesktop, removeDesktop, runningNodeMajor, stableExecPath, writeSupportFiles } from './chunk-3BGDI4HI.js';
2
+ import './chunk-2IGINCPU.js';
3
+ //# sourceMappingURL=install-VXJPLYWM.js.map
4
+ //# sourceMappingURL=install-VXJPLYWM.js.map
@@ -0,0 +1,71 @@
1
+ import { createInterface } from 'readline';
2
+
3
+ // src/desktop/offer.ts
4
+ function shouldOfferDesktop(i) {
5
+ if (i.env.SCENRI_NO_DESKTOP === "1") return false;
6
+ if (!i.stdinTTY || !i.stdoutTTY) return false;
7
+ if (i.env.CI) return false;
8
+ if (i.env.SSH_TTY) return false;
9
+ if (i.platform !== "darwin" && i.platform !== "win32") return false;
10
+ if (i.installKind === "dev") return false;
11
+ if (i.launcherInstalled) return false;
12
+ if (i.declined) return false;
13
+ return true;
14
+ }
15
+ var OFFER_QUESTION = " Add Scenri to your desktop? Then you can open it without a terminal. [Y/n] ";
16
+ var LATER_LINE = " Not now. Add it later with: npx scenri desktop, or from Settings > About.";
17
+ var STILL_RUNNING_LINE = " Scenri is running anyway. Add the icon later from Settings > About.";
18
+ var ASK_TIMEOUT_MS = 12e4;
19
+ function parseOfferAnswer(raw) {
20
+ const answer = raw.trim().toLowerCase();
21
+ if (answer === "" || answer.startsWith("y")) return "yes";
22
+ return "no";
23
+ }
24
+ async function offerDesktop(deps) {
25
+ let answer;
26
+ try {
27
+ answer = await deps.ask(OFFER_QUESTION);
28
+ } catch {
29
+ deps.say("");
30
+ deps.say(LATER_LINE);
31
+ return;
32
+ }
33
+ if (parseOfferAnswer(answer) === "no") {
34
+ deps.decline();
35
+ deps.say(LATER_LINE);
36
+ return;
37
+ }
38
+ try {
39
+ const res = await deps.add();
40
+ if (!res.ok) deps.say(STILL_RUNNING_LINE);
41
+ } catch {
42
+ deps.say(" Could not add the desktop icon.");
43
+ deps.say(STILL_RUNNING_LINE);
44
+ }
45
+ }
46
+ function askOnTerminal(question, timeoutMs = ASK_TIMEOUT_MS, io = {
47
+ input: process.stdin,
48
+ output: process.stdout
49
+ }) {
50
+ return new Promise((resolve, reject) => {
51
+ const rl = createInterface({ input: io.input, output: io.output, terminal: false });
52
+ let done = false;
53
+ const finish = (fn) => {
54
+ if (done) return;
55
+ done = true;
56
+ clearTimeout(timer);
57
+ rl.close();
58
+ fn();
59
+ };
60
+ const timer = setTimeout(() => finish(() => reject(new Error("nobody answered"))), timeoutMs);
61
+ timer.unref?.();
62
+ rl.question(question, (answer) => finish(() => resolve(answer)));
63
+ rl.once("close", () => {
64
+ if (!done) finish(() => reject(new Error("stdin closed")));
65
+ });
66
+ });
67
+ }
68
+
69
+ export { OFFER_QUESTION, askOnTerminal, offerDesktop, parseOfferAnswer, shouldOfferDesktop };
70
+ //# sourceMappingURL=offer-W6NJXMWT.js.map
71
+ //# sourceMappingURL=offer-W6NJXMWT.js.map
@@ -1,6 +1,6 @@
1
1
  import { adoptRunningInstall } from './chunk-ECS5LRHB.js';
2
2
  import { newestStaged, compareSemver } from './chunk-4MAFHYAD.js';
3
- import { runningNodeMajor, stableExecPath, isOurMacBundle, MAC_SCRIPT, macPlist, writeSupportFiles, writeMacBundle, writeLnk } from './chunk-SDVL5OJF.js';
3
+ import { runningNodeMajor, stableExecPath, isOurMacBundle, MAC_SCRIPT, macPlist, writeSupportFiles, writeMacBundle, writeLnk } from './chunk-3BGDI4HI.js';
4
4
  import { readLauncherRecord, launcherDir, LAUNCHER_SCHEMA, writeLauncherRecord } from './chunk-2IGINCPU.js';
5
5
  import { existsSync, readFileSync } from 'fs';
6
6
  import { join } from 'path';
@@ -101,5 +101,5 @@ function sameFile(a, b) {
101
101
  }
102
102
 
103
103
  export { refreshLauncher };
104
- //# sourceMappingURL=refresh-FKQABJOC.js.map
105
- //# sourceMappingURL=refresh-FKQABJOC.js.map
104
+ //# sourceMappingURL=refresh-5HBDLLLB.js.map
105
+ //# sourceMappingURL=refresh-5HBDLLLB.js.map