@liebstoeckel/cli 0.3.7 → 0.3.9
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +0 -2
- package/package.json +5 -5
- package/skill/SKILL.md +17 -0
- package/skill/references/authoring.md +3 -0
- package/skill/references/brands.md +141 -0
- package/skill/references/troubleshooting.md +16 -0
- package/src/add.ts +2 -1
- package/src/bun.ts +53 -0
- package/src/cli.ts +16 -0
- package/src/cloud.ts +2 -1
- package/src/config.ts +38 -0
- package/src/doctor.ts +124 -0
- package/src/skill.ts +119 -20
- package/src/update.ts +3 -2
package/README.md
CHANGED
|
@@ -10,8 +10,6 @@ This is the umbrella CLI. One binary scaffolds a deck, runs a hot-reloading dev
|
|
|
10
10
|
|
|
11
11
|
## Install
|
|
12
12
|
|
|
13
|
-
> It isn't on the public npm registry yet. The commands below are how it will install once it ships. For now, run it from a checkout of the repo (`bun run live|relay|thumbs …`) or from a workspace that links the packages.
|
|
14
|
-
|
|
15
13
|
```sh
|
|
16
14
|
bun add -d @liebstoeckel/cli
|
|
17
15
|
# …or run it without installing:
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@liebstoeckel/cli",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.9",
|
|
4
4
|
"description": "The liebstoeckel CLI to scaffold, develop, build, and present code-first decks.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"liebstoeckel",
|
|
@@ -45,12 +45,12 @@
|
|
|
45
45
|
"./new": "./src/new.ts"
|
|
46
46
|
},
|
|
47
47
|
"dependencies": {
|
|
48
|
-
"@liebstoeckel/engine": "^0.3.
|
|
49
|
-
"@liebstoeckel/live-server": "^0.3.
|
|
50
|
-
"@liebstoeckel/present-relay": "^0.3.
|
|
48
|
+
"@liebstoeckel/engine": "^0.3.7",
|
|
49
|
+
"@liebstoeckel/live-server": "^0.3.7",
|
|
50
|
+
"@liebstoeckel/present-relay": "^0.3.7",
|
|
51
51
|
"@liebstoeckel/registry": "^0.3.4",
|
|
52
52
|
"@liebstoeckel/theme": "^0.3.3",
|
|
53
|
-
"@liebstoeckel/thumbnails": "^0.3.
|
|
53
|
+
"@liebstoeckel/thumbnails": "^0.3.7",
|
|
54
54
|
"citty": "^0.2.2"
|
|
55
55
|
},
|
|
56
56
|
"peerDependencies": {
|
package/skill/SKILL.md
CHANGED
|
@@ -101,6 +101,22 @@ is untrusted and `build`/`build --check` on it fails with an `untrusted deck` er
|
|
|
101
101
|
trust question to the user and only proceed once they explicitly confirm. See the trust
|
|
102
102
|
note in `references/editing.md`.
|
|
103
103
|
|
|
104
|
+
## Brand a deck (custom theme / fonts)
|
|
105
|
+
|
|
106
|
+
Pick a built-in brand with `liebstoeckel new <name> --brand <liebstoeckel|nocturne|acme|sunset>`,
|
|
107
|
+
or create a **custom** one. A brand is a typed token object (colors, fonts, chart
|
|
108
|
+
palette); define it with `defineTheme` in `brands/<name>.ts` and wire it via
|
|
109
|
+
`<Present brands={["<name>"]} brandThemes={[brand]}>` + `<body data-brand="<name>">`.
|
|
110
|
+
**Don't hand-write a raw `[data-brand]` CSS block, and don't hardcode hex in slides**
|
|
111
|
+
— use role tokens (`text-primary`, `bg-surface`, `font-heading`).
|
|
112
|
+
|
|
113
|
+
Fonts are the one real trap: a brand names a font, but glyphs only ship if a matching
|
|
114
|
+
`@font-face` is **bundled**. Naming a `"… Variable"` font with no bundled face
|
|
115
|
+
**silently falls back to a system font** (build prints `⚠ brand font won't render`).
|
|
116
|
+
Use a house family (already bundled) or add one latin variable `@font-face` — never
|
|
117
|
+
`import "@fontsource-variable/<id>/index.css"`. Full recipe + verification in
|
|
118
|
+
`references/brands.md`.
|
|
119
|
+
|
|
104
120
|
## Make a deck interactive (live plugins)
|
|
105
121
|
|
|
106
122
|
Plugins add live, synced audience interaction; offline the deck still builds and shows
|
|
@@ -136,6 +152,7 @@ plugin, see `references/build-plugins.md`.
|
|
|
136
152
|
|
|
137
153
|
- `references/components.md` — component types in the registry, using scaffolded components/charts, data shapes, the `add` workflow.
|
|
138
154
|
- `references/authoring.md` — slide file conventions (MDX/TSX, notes, steps, layout, brands).
|
|
155
|
+
- `references/brands.md` — create a custom brand (typed `defineTheme` + `brandThemes`) and bundle fonts correctly (the silent-fallback trap).
|
|
139
156
|
- `references/editing.md` — editing decks: add/replace slides, swap charts, re-theme, eject.
|
|
140
157
|
- `references/plugins.md` — add live plugins (poll/qa/reactions): register, place, present live.
|
|
141
158
|
- `references/build-plugins.md` — author a custom plugin (`definePlugin`, state, surfaces, server).
|
|
@@ -74,6 +74,9 @@ import { Step } from "@liebstoeckel/engine";
|
|
|
74
74
|
--brand <brand>` sets the default. Re-theming a deck = change the brand; every
|
|
75
75
|
component and token follows. Don't hard-code hex colors — use tokens / `useBrandColors`.
|
|
76
76
|
|
|
77
|
+
To **create a custom brand** (typed `defineTheme` in `brands/<name>.ts` wired via
|
|
78
|
+
`brandThemes`) and bundle its fonts, see `references/brands.md`.
|
|
79
|
+
|
|
77
80
|
## MDX slides
|
|
78
81
|
|
|
79
82
|
A slide can be `.mdx` instead of `.tsx` for prose-heavy content; it still
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
# Brands (custom themes & fonts)
|
|
2
|
+
|
|
3
|
+
A **brand** is one typed token object — colors, fonts, a chart palette — and the
|
|
4
|
+
whole deck derives its look from it. Slides use role tokens (`text-primary`,
|
|
5
|
+
`bg-surface`, `text-accent`, `font-heading`), never literal hex/hue, so the same
|
|
6
|
+
slide is correct under any brand.
|
|
7
|
+
|
|
8
|
+
Do **not** hand-roll a raw `[data-brand="…"] { --brand-*: … }` CSS block. That
|
|
9
|
+
re-implements, by hand, what `defineTheme` + `brandThemes` generate for you (and
|
|
10
|
+
is how a past session drifted). Use the typed path below.
|
|
11
|
+
|
|
12
|
+
## Pick a brand
|
|
13
|
+
|
|
14
|
+
- **Built-in** — `liebstoeckel`, `nocturne`, `acme`, `sunset`. Scaffold with one:
|
|
15
|
+
```bash
|
|
16
|
+
liebstoeckel new <name> --dir presentations --brand nocturne
|
|
17
|
+
```
|
|
18
|
+
`liebstoeckel` and `nocturne` bundle their fonts — zero font work. `acme` and
|
|
19
|
+
`sunset` use `"Inter"` / `"JetBrains Mono"`, which are **not** bundled: they
|
|
20
|
+
render only where those fonts are installed and otherwise fall back to
|
|
21
|
+
`system-ui` / `monospace` (e.g. PDF export under headless Chromium). To bundle a
|
|
22
|
+
font for a brand, see **Fonts** below.
|
|
23
|
+
|
|
24
|
+
- **Custom** — define your own typed brand as owned source in the deck (below).
|
|
25
|
+
`--brand <id>` alone does **not** create the brand; an unknown id silently
|
|
26
|
+
renders with the default tokens. To brand a deck in something custom, add a
|
|
27
|
+
`brands/<name>.ts` and wire it in `main.tsx` as shown next.
|
|
28
|
+
|
|
29
|
+
## Create a custom brand
|
|
30
|
+
|
|
31
|
+
1. **Write `brands/<name>.ts`** with `defineTheme` (full token list in the table):
|
|
32
|
+
```ts
|
|
33
|
+
import { defineTheme } from "@liebstoeckel/theme";
|
|
34
|
+
|
|
35
|
+
export default defineTheme({
|
|
36
|
+
name: "acme", // must match data-brand + the brands={[…]} entry
|
|
37
|
+
colors: {
|
|
38
|
+
bg: "#0b0e14", surface: "#141925", border: "#222a3a",
|
|
39
|
+
text: "#e6eaf2", muted: "#8a93a6",
|
|
40
|
+
primary: "#3b82f6", // brand
|
|
41
|
+
accent: "#22d3ee", // accent
|
|
42
|
+
accent2: "#f0abfc", // optional secondary accent
|
|
43
|
+
onPrimary: "#ffffff", // text/icons on a primary fill
|
|
44
|
+
},
|
|
45
|
+
fonts: {
|
|
46
|
+
// House families (already bundled, zero font work): "Schibsted Grotesk Variable",
|
|
47
|
+
// "Hanken Grotesk Variable", "Fraunces Variable", "JetBrains Mono Variable".
|
|
48
|
+
heading: '"Schibsted Grotesk Variable", system-ui, sans-serif',
|
|
49
|
+
body: '"Schibsted Grotesk Variable", system-ui, sans-serif',
|
|
50
|
+
mono: '"JetBrains Mono Variable", ui-monospace, monospace',
|
|
51
|
+
},
|
|
52
|
+
viz: ["#3b82f6", "#22d3ee", "#f0abfc", "#a3e635", "#fbbf24"], // optional chart palette
|
|
53
|
+
glow: { a: "#10233f", b: "#0b2530" }, // optional atmosphere gradient
|
|
54
|
+
});
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
2. **Wire it in `main.tsx`** — import the theme, pass it via `brandThemes`, and
|
|
58
|
+
name it the active brand:
|
|
59
|
+
```tsx
|
|
60
|
+
import "@liebstoeckel/theme/styles.css";
|
|
61
|
+
import { Present } from "@liebstoeckel/engine";
|
|
62
|
+
import acme from "./brands/acme";
|
|
63
|
+
|
|
64
|
+
<Present title="…" brands={["acme"]} brandThemes={[acme]} slides={[…]} />
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
3. **Match `index.html`** — `<body data-brand="acme">`.
|
|
68
|
+
|
|
69
|
+
4. Run the `build --check` loop as always.
|
|
70
|
+
|
|
71
|
+
### Tokens
|
|
72
|
+
|
|
73
|
+
| Token (`--brand-*`) | Tailwind | Meaning | Required |
|
|
74
|
+
| --- | --- | --- | --- |
|
|
75
|
+
| `bg` / `surface` | `bg-bg` / `bg-surface` | page + panel backgrounds | ✓ |
|
|
76
|
+
| `text` / `muted` | `text-text` / `text-muted` | primary + secondary text | ✓ |
|
|
77
|
+
| `primary` / `accent` | `text-primary` / `text-accent` | brand + accent | ✓ |
|
|
78
|
+
| `on-primary` | `text-on-primary` | content on a primary fill | ✓ |
|
|
79
|
+
| `font-heading/body/mono` | `font-heading/body/mono` | typefaces | ✓ |
|
|
80
|
+
| `border` | `border-border` | hairlines | optional |
|
|
81
|
+
| `accent2` | `text-accent2` | secondary accent | optional |
|
|
82
|
+
| `viz-0…n` | (read by charts) | chart-series palette (`viz`) | optional |
|
|
83
|
+
| `glow-a/b` | (none) | atmosphere gradient stops (`glow`) | optional |
|
|
84
|
+
|
|
85
|
+
Charts read `viz` via `useBrandColors()` — set it for on-brand series instead of
|
|
86
|
+
hardcoding colors in the chart.
|
|
87
|
+
|
|
88
|
+
## Fonts — bundle them or they silently fall back
|
|
89
|
+
|
|
90
|
+
A brand's `fonts.*` values are just `font-family` **strings**. The glyphs only
|
|
91
|
+
ship if a matching `@font-face` is **bundled** (the build inlines its woff2 into
|
|
92
|
+
the single file). Naming a font in the brand does **not** load it.
|
|
93
|
+
|
|
94
|
+
The trap: if you name a `"… Variable"` family that nothing bundles, the browser
|
|
95
|
+
**silently** falls back to a system font — no error, and the deck won't match what
|
|
96
|
+
you saw in dev (a past session shipped Noto Sans this way). The build now warns:
|
|
97
|
+
|
|
98
|
+
```
|
|
99
|
+
⚠ brand font won't render (text will fall back to a system font):
|
|
100
|
+
named by the brand but no @font-face bundles them: "Nunito Sans Variable"
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
Treat that warning as **must-fix**. Two correct options:
|
|
104
|
+
|
|
105
|
+
- **Use a house family** (no font work): `"Schibsted Grotesk Variable"`,
|
|
106
|
+
`"Hanken Grotesk Variable"`, `"Fraunces Variable"`, `"JetBrains Mono Variable"`
|
|
107
|
+
are already bundled by `@liebstoeckel/theme/styles.css`.
|
|
108
|
+
|
|
109
|
+
- **Add another font** — install its Fontsource package and bundle **one latin
|
|
110
|
+
variable face**, mirroring `@liebstoeckel/theme`'s `fonts.css`:
|
|
111
|
+
```bash
|
|
112
|
+
bun add @fontsource-variable/<id> # e.g. nunito-sans, inter, manrope
|
|
113
|
+
```
|
|
114
|
+
Create `brands/<name>.css`:
|
|
115
|
+
```css
|
|
116
|
+
@font-face {
|
|
117
|
+
font-family: "Nunito Sans Variable"; /* exact family used in the brand, incl. "Variable" */
|
|
118
|
+
src: url("@fontsource-variable/nunito-sans/files/nunito-sans-latin-wght-normal.woff2") format("woff2");
|
|
119
|
+
font-weight: 200 1000; /* the package's variable range */
|
|
120
|
+
font-style: normal;
|
|
121
|
+
font-display: swap;
|
|
122
|
+
}
|
|
123
|
+
```
|
|
124
|
+
Import it in `main.tsx` (above the slides): `import "./brands/<name>.css";`. Bun
|
|
125
|
+
resolves the bare `@fontsource-variable/…` `url()` and base64-inlines the woff2 —
|
|
126
|
+
no CDN, no `<link>`, still one self-contained file.
|
|
127
|
+
|
|
128
|
+
**Do not** `import "@fontsource-variable/<id>"` or its `/index.css`. That pulls
|
|
129
|
+
~5 `unicode-range`-subset faces that do **not** survive the build's inlining and
|
|
130
|
+
silently fall back. Reference the single `…-latin-wght-normal.woff2` directly.
|
|
131
|
+
|
|
132
|
+
### Gotchas
|
|
133
|
+
|
|
134
|
+
- **Family name must match exactly**, including a `Variable` suffix. `"Nunito
|
|
135
|
+
Sans Variable"` ≠ the system `"Nunito Sans"`; a mismatch falls back.
|
|
136
|
+
- **Verify** after building, especially before exporting a PDF:
|
|
137
|
+
```bash
|
|
138
|
+
liebstoeckel export <deck> --format pdf -o /tmp/d.pdf
|
|
139
|
+
pdffonts /tmp/d.pdf # the brand family should appear; no Noto/system fallback
|
|
140
|
+
```
|
|
141
|
+
- Fonts/PNG/PDF export need a Chromium — see `references/troubleshooting.md`.
|
|
@@ -37,3 +37,19 @@ export a slide to PNG:
|
|
|
37
37
|
```bash
|
|
38
38
|
liebstoeckel export <deck> --slides 1 -o ./out # PNG of slide 1 at 2560×1440
|
|
39
39
|
```
|
|
40
|
+
|
|
41
|
+
## No Chromium (thumbnails / PNG / PDF export)
|
|
42
|
+
|
|
43
|
+
PNG/PDF export and overview thumbnails need a Chrome/Chromium. `build` skips
|
|
44
|
+
thumbnails gracefully without one, but `export`/`thumbs` fail. Diagnose and fix
|
|
45
|
+
with `doctor` (it auto-detects an existing system/Puppeteer Chrome, so often
|
|
46
|
+
nothing needs installing):
|
|
47
|
+
|
|
48
|
+
```bash
|
|
49
|
+
liebstoeckel doctor --json # { chromium: { path, ok }, bun: {…} }
|
|
50
|
+
liebstoeckel doctor --install-chromium # if ok:false — installs + remembers it
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
`--install-chromium` records the path in `~/.config/liebstoeckel/config.json`, so
|
|
54
|
+
later builds reuse it. You can also set `LIEBSTOECKEL_CHROMIUM=<path>` to a binary
|
|
55
|
+
directly. Both are non-interactive — safe to run unattended.
|
package/src/add.ts
CHANGED
|
@@ -7,6 +7,7 @@ import {
|
|
|
7
7
|
CATEGORIES,
|
|
8
8
|
type RegistryItem,
|
|
9
9
|
} from "@liebstoeckel/registry/schema";
|
|
10
|
+
import { bunBin } from "./bun";
|
|
10
11
|
|
|
11
12
|
/**
|
|
12
13
|
* `liebstoeckel add`, scaffold registry items into a deck as owned source
|
|
@@ -334,7 +335,7 @@ async function runAdd(args: {
|
|
|
334
335
|
if (deps.size && !noInstall) {
|
|
335
336
|
const { $ } = await import("bun");
|
|
336
337
|
// pin the interpreter; --ignore-scripts per the registry trust model (ADR 0041)
|
|
337
|
-
const proc = $`${
|
|
338
|
+
const proc = $`${bunBin} add --ignore-scripts ${dependencies}`.cwd(deckDir);
|
|
338
339
|
if (json) await proc.quiet();
|
|
339
340
|
else {
|
|
340
341
|
console.log(`\n ✓ wrote ${writes.length} file(s)` + (writes.length < files.length ? ` (${files.length - writes.length} skipped, use --force to overwrite)` : ""));
|
package/src/bun.ts
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { fileURLToPath } from "node:url";
|
|
2
|
+
|
|
3
|
+
/** The Bun binary actually running this CLI.
|
|
4
|
+
*
|
|
5
|
+
* Always resolve Bun by interrogating the running process (`process.execPath`) */
|
|
6
|
+
export const bunBin = process.execPath;
|
|
7
|
+
|
|
8
|
+
const PKG_JSON = fileURLToPath(new URL("../package.json", import.meta.url));
|
|
9
|
+
|
|
10
|
+
/** Fallback range if package.json can't be read; kept in sync with `engines.bun`. */
|
|
11
|
+
const DEFAULT_RANGE = ">=1.3";
|
|
12
|
+
|
|
13
|
+
/** The Bun range the CLI declares it needs (package.json `engines.bun`). */
|
|
14
|
+
export async function requiredBunRange(): Promise<string> {
|
|
15
|
+
try {
|
|
16
|
+
const pkg = (await Bun.file(PKG_JSON).json()) as { engines?: { bun?: string } };
|
|
17
|
+
return pkg.engines?.bun ?? DEFAULT_RANGE;
|
|
18
|
+
} catch {
|
|
19
|
+
return DEFAULT_RANGE;
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** Drop prerelease/build metadata, judging a build by its release version.
|
|
24
|
+
* `Bun.semver.satisfies("1.5.0-canary.X", ">=1.3")` is `false`. npm-semver only
|
|
25
|
+
* lets a prerelease satisfy a range that pins the *same* major.minor.patch, so a
|
|
26
|
+
* perfectly capable canary/nightly Bun would otherwise be rejected. */
|
|
27
|
+
function releaseVersion(version: string): string {
|
|
28
|
+
return version.split("+")[0]!.split("-")[0]!;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** Pure decision core (unit-tested): the error to print if `current` doesn't
|
|
32
|
+
* satisfy `range`, else null. `binPath` is named in the message so the user can
|
|
33
|
+
* see *which* Bun is running, the one that an upgrade must actually move. */
|
|
34
|
+
export function bunVersionError(current: string, range: string, binPath: string): string | null {
|
|
35
|
+
if (Bun.semver.satisfies(releaseVersion(current), range)) return null;
|
|
36
|
+
return (
|
|
37
|
+
`liebstoeckel needs Bun ${range}, but this CLI is running on Bun ${current}.\n` +
|
|
38
|
+
` binary: ${binPath}\n` +
|
|
39
|
+
` fix: bun upgrade (then re-run)\n`
|
|
40
|
+
);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** Preflight, run on every CLI invocation: confirm the Bun interpreting us (and
|
|
44
|
+
* therefore every `bunBin` shell-out) satisfies `engines.bun`. A too-old Bun
|
|
45
|
+
* otherwise fails deep inside a command with an opaque error, so fail fast here
|
|
46
|
+
* with an actionable message instead. */
|
|
47
|
+
export async function assertBunVersion(): Promise<void> {
|
|
48
|
+
const msg = bunVersionError(Bun.version, await requiredBunRange(), bunBin);
|
|
49
|
+
if (msg) {
|
|
50
|
+
process.stderr.write(msg);
|
|
51
|
+
process.exit(1);
|
|
52
|
+
}
|
|
53
|
+
}
|
package/src/cli.ts
CHANGED
|
@@ -21,6 +21,7 @@ const rootCommand = defineCommand({
|
|
|
21
21
|
thumbs: () => import("@liebstoeckel/thumbnails/cli").then((m) => m.thumbsCommand),
|
|
22
22
|
export: () => import("@liebstoeckel/thumbnails/cli").then((m) => m.exportCommand),
|
|
23
23
|
skill: () => import("./skill").then((m) => m.skillCommand),
|
|
24
|
+
doctor: () => import("./doctor").then((m) => m.doctorCommand),
|
|
24
25
|
// cloud (coming soon, the hosted service is not generally available yet):
|
|
25
26
|
login: () => import("./cloud").then((m) => m.loginCommand),
|
|
26
27
|
push: () => import("./cloud").then((m) => m.pushCommand),
|
|
@@ -48,6 +49,21 @@ const KNOWN_COMMANDS = new Set(Object.keys((rootCommand.subCommands as Record<st
|
|
|
48
49
|
async function main() {
|
|
49
50
|
const argv = process.argv.slice(2);
|
|
50
51
|
|
|
52
|
+
// Preflight on every run: confirm the Bun interpreting this CLI satisfies
|
|
53
|
+
// engines.bun. The same binary (bunBin === process.execPath) backs every
|
|
54
|
+
// shell-out below, so this gate covers exactly what `build` etc. will use,
|
|
55
|
+
// a too-old Bun otherwise fails deep inside a command with an opaque error.
|
|
56
|
+
const { assertBunVersion } = await import("./bun");
|
|
57
|
+
await assertBunVersion();
|
|
58
|
+
|
|
59
|
+
// Feed a `doctor`-recorded Chromium into the resolution order when the user
|
|
60
|
+
// hasn't set LIEBSTOECKEL_CHROMIUM, so a browser configured once is reused.
|
|
61
|
+
try {
|
|
62
|
+
await (await import("./config")).hydrateChromiumEnv();
|
|
63
|
+
} catch {
|
|
64
|
+
// never block a command on config
|
|
65
|
+
}
|
|
66
|
+
|
|
51
67
|
// Best-effort reminders (stderr-only; off for --json/pipes/CI, see update.ts):
|
|
52
68
|
// a cached "new CLI version" note and a "deck skill older than the CLI" note.
|
|
53
69
|
try {
|
package/src/cloud.ts
CHANGED
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
import { defineCommand } from "citty";
|
|
7
7
|
import { existsSync, readdirSync } from "node:fs";
|
|
8
8
|
import { basename, dirname, join, resolve, sep } from "node:path";
|
|
9
|
+
import { bunBin } from "./bun";
|
|
9
10
|
import { loadCreds, saveCreds } from "./creds";
|
|
10
11
|
|
|
11
12
|
const CLIENT_ID = "liebstoeckel-cli";
|
|
@@ -486,7 +487,7 @@ const brandPullCommand = defineCommand({
|
|
|
486
487
|
const { $ } = await import("bun");
|
|
487
488
|
console.log(` installing fonts: bun add --ignore-scripts ${deps.join(" ")}`);
|
|
488
489
|
// pin the interpreter; --ignore-scripts per the registry trust model (ADR 0041)
|
|
489
|
-
await $`${
|
|
490
|
+
await $`${bunBin} add --ignore-scripts ${deps}`.cwd(deckDir);
|
|
490
491
|
console.log(` ✓ fonts installed\n`);
|
|
491
492
|
} else if (deps.length) {
|
|
492
493
|
console.log(` → install its fonts: bun add --ignore-scripts ${deps.join(" ")}\n`);
|
package/src/config.ts
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
// Non-secret CLI preferences in the user config dir (sibling of credentials.json,
|
|
2
|
+
// which holds cloud tokens). Today just the resolved Chromium path, so a browser
|
|
3
|
+
// found or installed once via `doctor` is reused by every later build/export
|
|
4
|
+
// without re-detecting or re-exporting LIEBSTOECKEL_CHROMIUM.
|
|
5
|
+
import { mkdir } from "node:fs/promises";
|
|
6
|
+
import { join } from "node:path";
|
|
7
|
+
import { CONFIG_DIR } from "./creds";
|
|
8
|
+
|
|
9
|
+
export const CONFIG_FILE = join(CONFIG_DIR, "config.json");
|
|
10
|
+
|
|
11
|
+
export interface Config {
|
|
12
|
+
/** Path to a Chrome/Chromium binary, recorded by `doctor` (detect or install). */
|
|
13
|
+
chromium?: string;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export async function loadConfig(): Promise<Config> {
|
|
17
|
+
try {
|
|
18
|
+
return JSON.parse(await Bun.file(CONFIG_FILE).text()) as Config;
|
|
19
|
+
} catch {
|
|
20
|
+
return {};
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export async function saveConfig(patch: Config): Promise<void> {
|
|
25
|
+
const next = { ...(await loadConfig()), ...patch };
|
|
26
|
+
await mkdir(CONFIG_DIR, { recursive: true });
|
|
27
|
+
await Bun.write(CONFIG_FILE, JSON.stringify(next, null, 2));
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** Hydrate `LIEBSTOECKEL_CHROMIUM` from saved config when the user hasn't set it,
|
|
31
|
+
* so a once-configured/installed browser feeds the existing resolution order
|
|
32
|
+
* (explicit env still wins). Best-effort; a stale path just falls through to the
|
|
33
|
+
* system/Playwright probes in `resolveChromium`. */
|
|
34
|
+
export async function hydrateChromiumEnv(): Promise<void> {
|
|
35
|
+
if (process.env.LIEBSTOECKEL_CHROMIUM) return;
|
|
36
|
+
const { chromium } = await loadConfig();
|
|
37
|
+
if (chromium) process.env.LIEBSTOECKEL_CHROMIUM = chromium;
|
|
38
|
+
}
|
package/src/doctor.ts
ADDED
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
import { defineCommand } from "citty";
|
|
2
|
+
import { resolveChromium, playwrightCoreVersion } from "@liebstoeckel/thumbnails";
|
|
3
|
+
import { bunBin, bunVersionError, requiredBunRange } from "./bun";
|
|
4
|
+
import { loadConfig, saveConfig, CONFIG_FILE } from "./config";
|
|
5
|
+
|
|
6
|
+
/** Resolve a Chrome/Chromium path through the same order builds use, or null. */
|
|
7
|
+
function findChromium(): string | null {
|
|
8
|
+
try {
|
|
9
|
+
return resolveChromium();
|
|
10
|
+
} catch {
|
|
11
|
+
return null;
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export interface DoctorReport {
|
|
16
|
+
bun: { version: string; required: string; ok: boolean };
|
|
17
|
+
chromium: { path: string | null; ok: boolean };
|
|
18
|
+
configFile: string;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** Pure: assemble the environment report from the raw probes (unit-tested). */
|
|
22
|
+
export function buildReport(parts: {
|
|
23
|
+
bunVersion: string;
|
|
24
|
+
bunRange: string;
|
|
25
|
+
chromium: string | null;
|
|
26
|
+
}): DoctorReport {
|
|
27
|
+
return {
|
|
28
|
+
bun: {
|
|
29
|
+
version: parts.bunVersion,
|
|
30
|
+
required: parts.bunRange,
|
|
31
|
+
ok: bunVersionError(parts.bunVersion, parts.bunRange, bunBin) === null,
|
|
32
|
+
},
|
|
33
|
+
chromium: { path: parts.chromium, ok: parts.chromium !== null },
|
|
34
|
+
configFile: CONFIG_FILE,
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** Exit code for the diagnostic path (unit-tested). Non-zero only when a hard
|
|
39
|
+
* requirement is unmet: Bun is hard, Chromium is optional (`build` skips
|
|
40
|
+
* thumbnails without it), so a missing browser reports but does not fail. */
|
|
41
|
+
export function diagnosticExitCode(report: DoctorReport): number {
|
|
42
|
+
return report.bun.ok ? 0 : 1;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** The shell-out that installs Playwright's Chromium. Pinned two ways: the Bun
|
|
46
|
+
* interpreter is `bunBin` (the one running this CLI), and `playwright@<version>`
|
|
47
|
+
* matches the `playwright-core` the capturer resolves browsers through. An
|
|
48
|
+
* unpinned `playwright install` would fetch registry-latest and drop a revision
|
|
49
|
+
* `chromium.executablePath()` can't find (a "successful" install that still fails). */
|
|
50
|
+
export function installChromiumArgs(): string[] {
|
|
51
|
+
return [bunBin, "x", `playwright@${playwrightCoreVersion}`, "install", "chromium"];
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** Install Playwright's Chromium (the capturer launches via playwright-core).
|
|
55
|
+
* Streams progress; returns success. */
|
|
56
|
+
async function installChromium(): Promise<boolean> {
|
|
57
|
+
const proc = Bun.spawn(installChromiumArgs(), {
|
|
58
|
+
stdin: "inherit",
|
|
59
|
+
stdout: "inherit",
|
|
60
|
+
stderr: "inherit",
|
|
61
|
+
});
|
|
62
|
+
return (await proc.exited) === 0;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export const doctorCommand = defineCommand({
|
|
66
|
+
meta: { name: "doctor", description: "check the build environment (Bun, Chromium) and optionally install Chromium" },
|
|
67
|
+
args: {
|
|
68
|
+
"install-chromium": {
|
|
69
|
+
type: "boolean",
|
|
70
|
+
description: "download Playwright's Chromium and record it for future builds",
|
|
71
|
+
},
|
|
72
|
+
json: { type: "boolean", description: "machine-readable JSON output (default when piped)" },
|
|
73
|
+
},
|
|
74
|
+
async run({ args }) {
|
|
75
|
+
const json = !!args.json || !process.stdout.isTTY;
|
|
76
|
+
|
|
77
|
+
if (args["install-chromium"]) {
|
|
78
|
+
// Skip if a usable browser is already resolvable, so a re-run is a no-op.
|
|
79
|
+
let path = findChromium();
|
|
80
|
+
if (!path) {
|
|
81
|
+
if (!json) console.error("Installing Chromium via Playwright…");
|
|
82
|
+
const ok = await installChromium();
|
|
83
|
+
if (!ok) {
|
|
84
|
+
const msg = "Chromium install failed (try `bunx playwright install chromium` and check the output).";
|
|
85
|
+
if (json) console.log(JSON.stringify({ ok: false, error: msg }));
|
|
86
|
+
else console.error(msg);
|
|
87
|
+
process.exit(1);
|
|
88
|
+
}
|
|
89
|
+
path = findChromium();
|
|
90
|
+
}
|
|
91
|
+
if (path) await saveConfig({ chromium: path });
|
|
92
|
+
if (json) console.log(JSON.stringify({ ok: !!path, chromium: path, configFile: CONFIG_FILE }));
|
|
93
|
+
else console.error(path ? `✓ Chromium ready: ${path}\n recorded in ${CONFIG_FILE}` : "Chromium still not found after install.");
|
|
94
|
+
process.exit(path ? 0 : 1);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const report = buildReport({
|
|
98
|
+
bunVersion: Bun.version,
|
|
99
|
+
bunRange: await requiredBunRange(),
|
|
100
|
+
chromium: findChromium(),
|
|
101
|
+
});
|
|
102
|
+
const stored = (await loadConfig()).chromium;
|
|
103
|
+
|
|
104
|
+
if (json) {
|
|
105
|
+
console.log(JSON.stringify({ ...report, storedChromium: stored ?? null }));
|
|
106
|
+
} else {
|
|
107
|
+
const ok = (b: boolean) => (b ? "✓" : "✗");
|
|
108
|
+
console.error(`${ok(report.bun.ok)} Bun ${report.bun.version} (needs ${report.bun.required})`);
|
|
109
|
+
console.error(
|
|
110
|
+
report.chromium.ok
|
|
111
|
+
? `${ok(true)} Chromium ${report.chromium.path}`
|
|
112
|
+
: `${ok(false)} Chromium not found, run \`liebstoeckel doctor --install-chromium\` or set LIEBSTOECKEL_CHROMIUM\n` +
|
|
113
|
+
` (only \`export\`/\`thumbs\` require it; \`build\` skips thumbnails without it)`,
|
|
114
|
+
);
|
|
115
|
+
console.error(` config: ${CONFIG_FILE}`);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// Exit non-zero so an agent/CI can gate on the check (the umbrella's preflight
|
|
119
|
+
// already enforces Bun before any command, so this is belt-and-suspenders for
|
|
120
|
+
// direct/programmatic use).
|
|
121
|
+
const code = diagnosticExitCode(report);
|
|
122
|
+
if (code) process.exit(code);
|
|
123
|
+
},
|
|
124
|
+
});
|
package/src/skill.ts
CHANGED
|
@@ -2,7 +2,8 @@ import { defineCommand } from "citty";
|
|
|
2
2
|
import { fileURLToPath } from "node:url";
|
|
3
3
|
import { existsSync, readdirSync } from "node:fs";
|
|
4
4
|
import { mkdir } from "node:fs/promises";
|
|
5
|
-
import {
|
|
5
|
+
import { homedir } from "node:os";
|
|
6
|
+
import { dirname, join, resolve } from "node:path";
|
|
6
7
|
|
|
7
8
|
/**
|
|
8
9
|
* `liebstoeckel skill install`, materialize the bundled `liebstoeckel-deck` Skill
|
|
@@ -21,7 +22,9 @@ const PKG_JSON = fileURLToPath(new URL("../package.json", import.meta.url));
|
|
|
21
22
|
type Target = "claude" | "codex" | "cursor" | "gemini";
|
|
22
23
|
const ALL_TARGETS: Target[] = ["claude", "codex", "cursor", "gemini"];
|
|
23
24
|
|
|
24
|
-
// Where each agent looks for a skill folder, relative to the
|
|
25
|
+
// Where each agent looks for a skill folder, relative to the root (a project dir
|
|
26
|
+
// for a `project` install, the home dir for a `user` install). Agents read the
|
|
27
|
+
// same `.<agent>/skills/...` layout under either root.
|
|
25
28
|
export const SKILL_DIR: Record<Target, string> = {
|
|
26
29
|
claude: join(".claude", "skills", SKILL_NAME),
|
|
27
30
|
codex: join(".agents", "skills", SKILL_NAME), // also read by Gemini's shared path
|
|
@@ -29,6 +32,45 @@ export const SKILL_DIR: Record<Target, string> = {
|
|
|
29
32
|
gemini: join(".gemini", "skills", SKILL_NAME),
|
|
30
33
|
};
|
|
31
34
|
|
|
35
|
+
/** Where a skill install lands: a single project/deck, or the user account (every
|
|
36
|
+
* project). `project` writes the per-agent dirs + the AGENTS.md fallback into the
|
|
37
|
+
* deck root; `user` writes the per-agent dirs under `~` (e.g. `~/.claude/skills/`). */
|
|
38
|
+
export type Scope = "project" | "user";
|
|
39
|
+
|
|
40
|
+
/** Decide the install scope from the flags and context, or signal that the caller
|
|
41
|
+
* must prompt / error. Pure (no IO), so the policy is unit-tested:
|
|
42
|
+
* - `--global` or `--scope user|project` is taken as given;
|
|
43
|
+
* - an explicit `--dir` means "this project";
|
|
44
|
+
* - otherwise a TTY prompts, and a non-interactive run defaults to the project
|
|
45
|
+
* only when the cwd is actually a deck, never silently writing skill files
|
|
46
|
+
* into a random directory (the home-dir pollution from the field report). */
|
|
47
|
+
export function resolveScope(opts: {
|
|
48
|
+
scopeArg?: string;
|
|
49
|
+
global?: boolean;
|
|
50
|
+
dirGiven: boolean;
|
|
51
|
+
interactive: boolean;
|
|
52
|
+
cwdIsDeck: boolean;
|
|
53
|
+
}): { scope: Scope } | { prompt: true } | { error: string } {
|
|
54
|
+
if (opts.global) return { scope: "user" };
|
|
55
|
+
if (opts.scopeArg) {
|
|
56
|
+
if (opts.scopeArg === "project" || opts.scopeArg === "user") return { scope: opts.scopeArg };
|
|
57
|
+
return { error: `unknown --scope "${opts.scopeArg}", use: project | user` };
|
|
58
|
+
}
|
|
59
|
+
if (opts.dirGiven) return { scope: "project" };
|
|
60
|
+
if (opts.interactive) return { prompt: true };
|
|
61
|
+
if (opts.cwdIsDeck) return { scope: "project" };
|
|
62
|
+
return {
|
|
63
|
+
error:
|
|
64
|
+
"not inside a deck and no scope given. Pass `--scope user` to install for your account (~), " +
|
|
65
|
+
"or `--dir <deck>` / `--scope project` to install into a project.",
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** A directory is a deck/project if it has a package.json (decks are npm packages). */
|
|
70
|
+
function isDeckDir(dir: string): boolean {
|
|
71
|
+
return existsSync(join(dir, "package.json"));
|
|
72
|
+
}
|
|
73
|
+
|
|
32
74
|
export async function cliVersion(): Promise<string> {
|
|
33
75
|
try {
|
|
34
76
|
return ((await Bun.file(PKG_JSON).json()) as { version?: string }).version ?? "0.0.0";
|
|
@@ -88,14 +130,24 @@ Discover components with \`liebstoeckel registry list --json\`; validate with \`
|
|
|
88
130
|
await Bun.write(dest, mdc);
|
|
89
131
|
}
|
|
90
132
|
|
|
91
|
-
async function applySkill(
|
|
133
|
+
export async function applySkill(
|
|
134
|
+
sub: "install" | "update",
|
|
135
|
+
root: string,
|
|
136
|
+
scope: Scope,
|
|
137
|
+
targetArg: string | undefined,
|
|
138
|
+
): Promise<void> {
|
|
139
|
+
// AGENTS.md is a project-root convention (the fallback an agent reads when it
|
|
140
|
+
// opens a deck). A `user` install skips it. A bare `~/AGENTS.md` is the home-dir
|
|
141
|
+
// pollution the field report flagged, and isn't a reliable global instruction path.
|
|
142
|
+
const writeAgents = scope === "project";
|
|
92
143
|
let targets: Target[];
|
|
93
144
|
if (sub === "update") {
|
|
94
|
-
// Refresh whatever is already installed (the AGENTS.md block is
|
|
95
|
-
//
|
|
96
|
-
targets = ALL_TARGETS.filter((t) => existsSync(join(
|
|
97
|
-
|
|
98
|
-
|
|
145
|
+
// Refresh whatever is already installed (the AGENTS.md block is rewritten when
|
|
146
|
+
// present); installing NEW agent paths stays `install`'s job.
|
|
147
|
+
targets = ALL_TARGETS.filter((t) => existsSync(join(root, SKILL_DIR[t])));
|
|
148
|
+
const agentsPresent = writeAgents && existsSync(join(root, "AGENTS.md"));
|
|
149
|
+
if (targets.length === 0 && !agentsPresent) {
|
|
150
|
+
console.error(`✕ no liebstoeckel skill installed in ${root}, run: liebstoeckel skill install`);
|
|
99
151
|
process.exit(1);
|
|
100
152
|
}
|
|
101
153
|
} else {
|
|
@@ -114,32 +166,77 @@ async function applySkill(sub: "install" | "update", deckDir: string, targetArg:
|
|
|
114
166
|
// de-dupe destination dirs (some agents share a path)
|
|
115
167
|
const seen = new Set<string>();
|
|
116
168
|
for (const t of targets) {
|
|
117
|
-
const destRoot = join(
|
|
169
|
+
const destRoot = join(root, SKILL_DIR[t]);
|
|
118
170
|
if (!seen.has(destRoot)) {
|
|
119
171
|
await writeSkill(destRoot, version);
|
|
120
172
|
seen.add(destRoot);
|
|
121
173
|
written.push(SKILL_DIR[t] + "/");
|
|
122
174
|
}
|
|
123
175
|
if (t === "cursor") {
|
|
124
|
-
await writeCursorRule(
|
|
176
|
+
await writeCursorRule(root);
|
|
125
177
|
written.push(join(".cursor", "rules", "liebstoeckel.mdc"));
|
|
126
178
|
}
|
|
127
179
|
}
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
180
|
+
if (writeAgents) {
|
|
181
|
+
await writeAgentsBlock(root);
|
|
182
|
+
written.push("AGENTS.md");
|
|
183
|
+
}
|
|
131
184
|
|
|
132
|
-
|
|
185
|
+
const where = scope === "user" ? `your user account (${root})` : root;
|
|
186
|
+
console.log(`\n✓ ${sub === "update" ? "updated" : "installed"} the liebstoeckel-deck skill (v${version}) → ${where}\n`);
|
|
133
187
|
for (const w of written) console.log(` ${w}`);
|
|
134
|
-
console.log(`\n targets: ${targets.join(", ")}\n`);
|
|
188
|
+
console.log(`\n scope: ${scope} · targets: ${targets.join(", ")}\n`);
|
|
135
189
|
} catch (e) {
|
|
136
190
|
console.error(`✕ ${(e as Error).message}`);
|
|
137
191
|
process.exit(1);
|
|
138
192
|
}
|
|
139
193
|
}
|
|
140
194
|
|
|
195
|
+
/** Resolve scope (prompting on a TTY when ambiguous) then run the install/update.
|
|
196
|
+
* `dir` is the explicit `--dir` value (undefined = none given). */
|
|
197
|
+
async function runSkill(
|
|
198
|
+
sub: "install" | "update",
|
|
199
|
+
args: { dir?: string; scope?: string; global?: boolean; target?: string },
|
|
200
|
+
): Promise<void> {
|
|
201
|
+
const interactive = !!process.stdin.isTTY && !!process.stdout.isTTY;
|
|
202
|
+
const decision = resolveScope({
|
|
203
|
+
scopeArg: args.scope,
|
|
204
|
+
global: args.global,
|
|
205
|
+
dirGiven: args.dir !== undefined,
|
|
206
|
+
interactive,
|
|
207
|
+
cwdIsDeck: isDeckDir(args.dir ?? "."),
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
let scope: Scope;
|
|
211
|
+
if ("error" in decision) {
|
|
212
|
+
console.error(`✕ ${decision.error}`);
|
|
213
|
+
process.exit(1);
|
|
214
|
+
} else if ("prompt" in decision) {
|
|
215
|
+
// TTY-only (resolveScope returns `prompt` only when interactive). Bun's prompt
|
|
216
|
+
// reads a line; default = project (the contained, safe choice).
|
|
217
|
+
const ans = (prompt("Install the liebstoeckel-deck skill for [P]roject (./) or [u]ser account (~)?", "p") ?? "p")
|
|
218
|
+
.trim()
|
|
219
|
+
.toLowerCase();
|
|
220
|
+
scope = ans.startsWith("u") ? "user" : "project";
|
|
221
|
+
} else {
|
|
222
|
+
scope = decision.scope;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
const root = scope === "user" ? homedir() : resolve(args.dir ?? ".");
|
|
226
|
+
await applySkill(sub, root, scope, args.target);
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
const scopeArgs = {
|
|
230
|
+
scope: {
|
|
231
|
+
type: "string",
|
|
232
|
+
description: "install location: project (a deck) or user (your account ~, every project)",
|
|
233
|
+
valueHint: "project|user",
|
|
234
|
+
},
|
|
235
|
+
global: { type: "boolean", description: "shorthand for --scope user (install for your account, ~)" },
|
|
236
|
+
} as const;
|
|
237
|
+
|
|
141
238
|
const skillInstallCommand = defineCommand({
|
|
142
|
-
meta: { name: "install", description: "install the agent skill (
|
|
239
|
+
meta: { name: "install", description: "install the agent skill into a deck (--scope project) or your account (--scope user)" },
|
|
143
240
|
args: {
|
|
144
241
|
target: {
|
|
145
242
|
type: "string",
|
|
@@ -147,17 +244,19 @@ const skillInstallCommand = defineCommand({
|
|
|
147
244
|
description: "agents to target: claude, codex, cursor, gemini, all (comma-separated)",
|
|
148
245
|
valueHint: "claude|codex|cursor|gemini|all",
|
|
149
246
|
},
|
|
150
|
-
dir: { type: "string", description: "target deck directory (default: cwd)", valueHint: "deck" },
|
|
247
|
+
dir: { type: "string", description: "target deck directory (project scope; default: cwd)", valueHint: "deck" },
|
|
248
|
+
...scopeArgs,
|
|
151
249
|
},
|
|
152
|
-
run: ({ args }) =>
|
|
250
|
+
run: ({ args }) => runSkill("install", args),
|
|
153
251
|
});
|
|
154
252
|
|
|
155
253
|
const skillUpdateCommand = defineCommand({
|
|
156
254
|
meta: { name: "update", description: "refresh the installed agent skill to the running CLI version" },
|
|
157
255
|
args: {
|
|
158
|
-
dir: { type: "string", description: "target deck directory (default: cwd)", valueHint: "deck" },
|
|
256
|
+
dir: { type: "string", description: "target deck directory (project scope; default: cwd)", valueHint: "deck" },
|
|
257
|
+
...scopeArgs,
|
|
159
258
|
},
|
|
160
|
-
run: ({ args }) =>
|
|
259
|
+
run: ({ args }) => runSkill("update", args),
|
|
161
260
|
});
|
|
162
261
|
|
|
163
262
|
/** `liebstoeckel skill install|update`, manage the bundled deck-authoring Skill. */
|
package/src/update.ts
CHANGED
|
@@ -12,6 +12,7 @@ import { existsSync, mkdirSync } from "node:fs";
|
|
|
12
12
|
import { homedir } from "node:os";
|
|
13
13
|
import { dirname, join } from "node:path";
|
|
14
14
|
import { fileURLToPath } from "node:url";
|
|
15
|
+
import { bunBin } from "./bun";
|
|
15
16
|
import { cliVersion, SKILL_DIR } from "./skill";
|
|
16
17
|
|
|
17
18
|
const PKG = "@liebstoeckel/cli";
|
|
@@ -87,7 +88,7 @@ export async function updateReminder(argv: string[]): Promise<void> {
|
|
|
87
88
|
if (shouldRefresh(state, Date.now())) {
|
|
88
89
|
// Detached child re-runs THIS file (import.meta.main → refresh()). It inherits
|
|
89
90
|
// the cwd, so `bun pm view` sees the same .npmrc/bunfig the user's installs use.
|
|
90
|
-
const child = Bun.spawn([
|
|
91
|
+
const child = Bun.spawn([bunBin, fileURLToPath(import.meta.url)], {
|
|
91
92
|
stdin: "ignore",
|
|
92
93
|
stdout: "ignore",
|
|
93
94
|
stderr: "ignore",
|
|
@@ -131,7 +132,7 @@ async function refresh(): Promise<void> {
|
|
|
131
132
|
process.on("SIGHUP", () => {});
|
|
132
133
|
let latest: string | null = null;
|
|
133
134
|
try {
|
|
134
|
-
const proc = Bun.spawnSync([
|
|
135
|
+
const proc = Bun.spawnSync([bunBin, "pm", "view", PKG, "dist-tags.latest"], {
|
|
135
136
|
stdout: "pipe",
|
|
136
137
|
stderr: "ignore",
|
|
137
138
|
timeout: 15_000,
|