@letterstory/cli 0.2.1 → 0.3.0
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 +46 -4
- package/lib/cli.mjs +35 -1
- package/lib/commands/kernel.mjs +50 -0
- package/lib/commands/phantom-job.mjs +41 -0
- package/lib/commands/strategy.mjs +42 -1
- package/lib/commands.mjs +2 -0
- package/lib/update-check.mjs +94 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -9,13 +9,38 @@ manifest an agent sees, so it never drifts from the API.
|
|
|
9
9
|
The CLI is plain ESM with **zero dependencies** and needs no build step (Node ≥ 20).
|
|
10
10
|
|
|
11
11
|
```bash
|
|
12
|
-
#
|
|
12
|
+
npm install -g @letterstory/cli # puts `letterstory` (and `phantom`) on your PATH
|
|
13
|
+
# …or run it without installing:
|
|
14
|
+
npx @letterstory/cli --help
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
Contributing to the CLI itself? Run it straight from a checkout instead:
|
|
18
|
+
|
|
19
|
+
```bash
|
|
13
20
|
cd cli
|
|
14
|
-
npm link # puts `letterstory` (and `phantom`) on your PATH
|
|
21
|
+
npm link # puts `letterstory` (and `phantom`) on your PATH, pointing at this checkout
|
|
15
22
|
# …or run it directly without linking:
|
|
16
23
|
node cli/bin/letterstory.mjs --help
|
|
17
24
|
```
|
|
18
25
|
|
|
26
|
+
## Updating
|
|
27
|
+
|
|
28
|
+
The CLI checks npm for a newer published version at most once every 24 hours (cached
|
|
29
|
+
alongside your config, so most runs make no extra network call) and prints a one-line
|
|
30
|
+
nudge on stderr when one's available:
|
|
31
|
+
|
|
32
|
+
```
|
|
33
|
+
Update available: 0.2.1 → 0.3.0. Run `npm install -g @letterstory/cli@latest` to upgrade.
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
To upgrade immediately:
|
|
37
|
+
|
|
38
|
+
```bash
|
|
39
|
+
npm install -g @letterstory/cli@latest
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
Set `LETTERSTORY_NO_UPDATE_CHECK=1` to disable the check entirely (e.g. in CI).
|
|
43
|
+
|
|
19
44
|
## `phantom` — the same CLI, Phantomstory-branded
|
|
20
45
|
|
|
21
46
|
`phantom` is a ghost-branded entry point for demos and presentations — same binary, same
|
|
@@ -32,6 +57,22 @@ otherwise runs the exact same code path as `letterstory`.
|
|
|
32
57
|
|
|
33
58
|
## Authenticate
|
|
34
59
|
|
|
60
|
+
Two ways in — pick whichever fits how you're running the CLI.
|
|
61
|
+
|
|
62
|
+
**Sign in via your browser** (the default — best for interactive use on your own machine):
|
|
63
|
+
|
|
64
|
+
```bash
|
|
65
|
+
letterstory login
|
|
66
|
+
# opens your browser to https://app.letterstory.com, or override with --url
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
This runs a browser-based OAuth 2.1 flow (PKCE, loopback redirect — RFC 8252) against your
|
|
70
|
+
account. It comes back with a full-access session and a refresh token, so there's no API key
|
|
71
|
+
to mint or scope up front. Tokens are saved to `~/.letterstory/config.json` (mode 600) and
|
|
72
|
+
refresh automatically. Run `letterstory logout` to revoke the session and forget it.
|
|
73
|
+
|
|
74
|
+
**Or use a static API key** (for CI/automation, or when you want scoped-down access):
|
|
75
|
+
|
|
35
76
|
You need a Letterstory API key (starts with `ls_`; legacy `lb_` keys still work) with the `deployment:read` and
|
|
36
77
|
`deployment:write` capabilities — mint one in the app under **Settings → API keys**.
|
|
37
78
|
Add `deployment:domain` too if you plan to buy custom domains.
|
|
@@ -44,8 +85,9 @@ letterstory login --key ls_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
|
|
|
44
85
|
Credentials resolve from `--key`/`--url` flags, then `LETTERSTORY_API_KEY` /
|
|
45
86
|
`LETTERSTORY_API_URL`, then `~/.letterstory/config.json` (written by `login`, mode 600).
|
|
46
87
|
|
|
47
|
-
Run `letterstory whoami` (alias `status`) any time to confirm which key/url
|
|
48
|
-
see your company profile.
|
|
88
|
+
Run `letterstory whoami` (alias `status`) any time to confirm which key/url (or OAuth session)
|
|
89
|
+
resolved and see your company profile. Run `letterstory config` to see the resolved url and
|
|
90
|
+
credential source without making a network call.
|
|
49
91
|
|
|
50
92
|
## Spin up a blog
|
|
51
93
|
|
package/lib/cli.mjs
CHANGED
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
// never throws for user-facing problems (those become a printed CliError + code 1).
|
|
4
4
|
|
|
5
5
|
import { LetterstoryClient, CliError, resolveConfig, readConfigFile, writeConfigFile } from "./client.mjs";
|
|
6
|
+
import { checkForUpdate } from "./update-check.mjs";
|
|
6
7
|
import {
|
|
7
8
|
cmdLogin,
|
|
8
9
|
cmdLogout,
|
|
@@ -24,10 +25,12 @@ import {
|
|
|
24
25
|
cmdStrategy,
|
|
25
26
|
cmdOnboarding,
|
|
26
27
|
cmdInsights,
|
|
28
|
+
cmdKernel,
|
|
29
|
+
cmdPhantomJob,
|
|
27
30
|
} from "./commands.mjs";
|
|
28
31
|
|
|
29
32
|
// Keep in sync with cli/package.json.
|
|
30
|
-
export const VERSION = "0.
|
|
33
|
+
export const VERSION = "0.3.0";
|
|
31
34
|
|
|
32
35
|
// Flags that never take a value. Listing them explicitly means `deploy get --json <id>`
|
|
33
36
|
// can't accidentally swallow the id as --json's value.
|
|
@@ -169,9 +172,22 @@ Strategy & onboarding:
|
|
|
169
172
|
[--clear-topics] [--clear-stances] [--clear-avoid]
|
|
170
173
|
strategy competitors list | add <name> <domain>
|
|
171
174
|
strategy sitemap --collection <uuid> --url <sitemap_url> [--sub <url> …] [--pattern <glob>]
|
|
175
|
+
strategy topics list --collection <uuid> List the topic map's clusters/spokes
|
|
176
|
+
strategy topics set --collection <uuid> (--topic <topic-id> | --suggestion <suggestion-id>)
|
|
172
177
|
onboarding status Show the onboarding checklist
|
|
173
178
|
onboarding step [--current <step>] [--complete <step>] [--skip <step>] [--status <status>]
|
|
174
179
|
|
|
180
|
+
Writing kernels:
|
|
181
|
+
kernel list List available writing kernels
|
|
182
|
+
kernel run --article <uuid> --kernel <uuid> [--brief <text>|--brief-file <path>]
|
|
183
|
+
Submit a run; takes 15-20 min
|
|
184
|
+
kernel status <kernel-job-id> Check a run's status
|
|
185
|
+
|
|
186
|
+
Phantom orchestrator:
|
|
187
|
+
phantom-job create --topic <text> --collection <uuid> [--kernel <uuid>]
|
|
188
|
+
Topic -> draft -> publish -> rebuild
|
|
189
|
+
phantom-job status <job-id> Check a job's stage
|
|
190
|
+
|
|
175
191
|
Insights:
|
|
176
192
|
insights site [--period 14d|30d|90d] [--collection <uuid>]
|
|
177
193
|
insights post <article-id> [--period 14d|30d|90d]
|
|
@@ -227,6 +243,8 @@ const CLIENT_COMMANDS = {
|
|
|
227
243
|
strategy: cmdStrategy,
|
|
228
244
|
onboarding: cmdOnboarding,
|
|
229
245
|
insights: cmdInsights,
|
|
246
|
+
kernel: cmdKernel,
|
|
247
|
+
"phantom-job": cmdPhantomJob,
|
|
230
248
|
};
|
|
231
249
|
|
|
232
250
|
// LETTERSTORY_POLL_INTERVAL_MS / LETTERSTORY_MAX_POLLS let an operator (or an
|
|
@@ -244,6 +262,9 @@ export function defaultIo() {
|
|
|
244
262
|
sleep: (ms) => new Promise((resolve) => setTimeout(resolve, ms)),
|
|
245
263
|
pollIntervalMs: envInt("LETTERSTORY_POLL_INTERVAL_MS", 4000),
|
|
246
264
|
maxPolls: envInt("LETTERSTORY_MAX_POLLS", 90),
|
|
265
|
+
// Only a *real* CLI invocation (never a test, which always builds its own io)
|
|
266
|
+
// gets the background update-version check — see the `finally` block in run().
|
|
267
|
+
updateCheck: true,
|
|
247
268
|
};
|
|
248
269
|
}
|
|
249
270
|
|
|
@@ -298,5 +319,18 @@ export async function run(argv, io = defaultIo()) {
|
|
|
298
319
|
return 1;
|
|
299
320
|
}
|
|
300
321
|
throw err;
|
|
322
|
+
} finally {
|
|
323
|
+
// Fire-and-await (bounded by checkForUpdate's own short fetch timeout) so the
|
|
324
|
+
// nudge, if any, is printed before the bin's process.exit() — but a warm cache
|
|
325
|
+
// (the common case) resolves with no network call at all, so this is normally
|
|
326
|
+
// instant. --json output stays machine-clean; real errors above already returned.
|
|
327
|
+
if (io.updateCheck && !flags.json) {
|
|
328
|
+
try {
|
|
329
|
+
const notice = await checkForUpdate({ currentVersion: VERSION });
|
|
330
|
+
if (notice) io.error(notice);
|
|
331
|
+
} catch {
|
|
332
|
+
// Never let the update nudge itself become the reason a command fails.
|
|
333
|
+
}
|
|
334
|
+
}
|
|
301
335
|
}
|
|
302
336
|
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
// `kernel` — list writing kernels, submit a run against an article, and check a
|
|
2
|
+
// run's status. A run can take 15-20 minutes, so unlike `deploy create` this does
|
|
3
|
+
// not block waiting for it: `run` returns as soon as the job is queued and `status`
|
|
4
|
+
// checks on demand, same shape as `flows run` / `flows status`.
|
|
5
|
+
|
|
6
|
+
import { CliError } from "../client.mjs";
|
|
7
|
+
import { flagStr, requireFlag, requirePositional, printResult, compact, ok, readBodyInput } from "./shared.mjs";
|
|
8
|
+
|
|
9
|
+
export async function cmdKernel(ctx) {
|
|
10
|
+
const sub = ctx.positionals[0];
|
|
11
|
+
const rest = { ...ctx, positionals: ctx.positionals.slice(1) };
|
|
12
|
+
switch (sub) {
|
|
13
|
+
case "list":
|
|
14
|
+
case "ls":
|
|
15
|
+
return kernelList(rest);
|
|
16
|
+
case "run":
|
|
17
|
+
return kernelRun(rest);
|
|
18
|
+
case "status":
|
|
19
|
+
return kernelStatus(rest);
|
|
20
|
+
default:
|
|
21
|
+
throw new CliError(`Unknown kernel subcommand: ${sub ?? "(none)"}. Try: list, run, status`);
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
async function kernelList(ctx) {
|
|
26
|
+
const { client, flags, io } = ctx;
|
|
27
|
+
const result = await client.callTool("list_kernels", {});
|
|
28
|
+
printResult(io, flags, result);
|
|
29
|
+
return 0;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
async function kernelRun(ctx) {
|
|
33
|
+
const { client, flags, io } = ctx;
|
|
34
|
+
const article = requireFlag(flags, "article");
|
|
35
|
+
const kernelId = requireFlag(flags, "kernel");
|
|
36
|
+
const brief = flagStr(flags.brief) ?? readBodyInput({ file: flagStr(flags["brief-file"]) });
|
|
37
|
+
const args = compact({ article_id: article, kernel_id: kernelId, brief });
|
|
38
|
+
const result = await client.callTool("submit_kernel_run", args);
|
|
39
|
+
ok(ctx, `Kernel run started. Check progress with: kernel status ${result?.kernel_job_id ?? "<kernel-job-id>"}`);
|
|
40
|
+
printResult(io, flags, result);
|
|
41
|
+
return 0;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
async function kernelStatus(ctx) {
|
|
45
|
+
const { client, positionals, flags, io } = ctx;
|
|
46
|
+
const jobId = requirePositional(positionals, 0, "kernel-job-id");
|
|
47
|
+
const result = await client.callTool("get_kernel_run_status", { kernel_job_id: jobId });
|
|
48
|
+
printResult(io, flags, result);
|
|
49
|
+
return 0;
|
|
50
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
// `phantom-job` — the topic-to-published-article orchestrator: kick off a job with
|
|
2
|
+
// a topic + collection, then check its stage (topic_set -> kernel_running ->
|
|
3
|
+
// publishing -> rebuilding -> done, or failed). The underlying kernel run alone can
|
|
4
|
+
// take 15-20 minutes, so `create` returns as soon as the job is queued rather than
|
|
5
|
+
// blocking — same shape as `flows run` / `flows status`, not `deploy create`'s
|
|
6
|
+
// wait-until-live loop.
|
|
7
|
+
|
|
8
|
+
import { CliError } from "../client.mjs";
|
|
9
|
+
import { flagStr, requireFlag, requirePositional, printResult, compact, ok } from "./shared.mjs";
|
|
10
|
+
|
|
11
|
+
export async function cmdPhantomJob(ctx) {
|
|
12
|
+
const sub = ctx.positionals[0];
|
|
13
|
+
const rest = { ...ctx, positionals: ctx.positionals.slice(1) };
|
|
14
|
+
switch (sub) {
|
|
15
|
+
case "create":
|
|
16
|
+
return phantomJobCreate(rest);
|
|
17
|
+
case "status":
|
|
18
|
+
return phantomJobStatus(rest);
|
|
19
|
+
default:
|
|
20
|
+
throw new CliError(`Unknown phantom-job subcommand: ${sub ?? "(none)"}. Try: create, status`);
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
async function phantomJobCreate(ctx) {
|
|
25
|
+
const { client, flags, io } = ctx;
|
|
26
|
+
const topic = requireFlag(flags, "topic");
|
|
27
|
+
const collection = requireFlag(flags, "collection");
|
|
28
|
+
const args = compact({ topic, collection_id: collection, kernel_id: flagStr(flags.kernel) });
|
|
29
|
+
const result = await client.callTool("create_phantom_from_topic", args);
|
|
30
|
+
ok(ctx, `Phantom job started. Check progress with: phantom-job status ${result?.job_id ?? "<job-id>"}`);
|
|
31
|
+
printResult(io, flags, result);
|
|
32
|
+
return 0;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
async function phantomJobStatus(ctx) {
|
|
36
|
+
const { client, positionals, flags, io } = ctx;
|
|
37
|
+
const jobId = requirePositional(positionals, 0, "job-id");
|
|
38
|
+
const result = await client.callTool("get_phantom_job_status", { job_id: jobId });
|
|
39
|
+
printResult(io, flags, result);
|
|
40
|
+
return 0;
|
|
41
|
+
}
|
|
@@ -27,9 +27,11 @@ export async function cmdStrategy(ctx) {
|
|
|
27
27
|
return strategyCompetitors(rest);
|
|
28
28
|
case "sitemap":
|
|
29
29
|
return strategySitemap(rest);
|
|
30
|
+
case "topics":
|
|
31
|
+
return strategyTopics(rest);
|
|
30
32
|
default:
|
|
31
33
|
throw new CliError(
|
|
32
|
-
`Unknown strategy subcommand: ${sub ?? "(none)"}. Try: company, positioning, competitors, sitemap`
|
|
34
|
+
`Unknown strategy subcommand: ${sub ?? "(none)"}. Try: company, positioning, competitors, sitemap, topics`
|
|
33
35
|
);
|
|
34
36
|
}
|
|
35
37
|
}
|
|
@@ -169,6 +171,45 @@ async function strategySitemap(ctx) {
|
|
|
169
171
|
return 0;
|
|
170
172
|
}
|
|
171
173
|
|
|
174
|
+
// -- topics ---------------------------------------------------------------
|
|
175
|
+
|
|
176
|
+
async function strategyTopics(ctx) {
|
|
177
|
+
const sub = ctx.positionals[0];
|
|
178
|
+
const rest = { ...ctx, positionals: ctx.positionals.slice(1) };
|
|
179
|
+
switch (sub) {
|
|
180
|
+
case "list":
|
|
181
|
+
case "ls":
|
|
182
|
+
return topicsList(rest);
|
|
183
|
+
case "set":
|
|
184
|
+
return topicsSet(rest);
|
|
185
|
+
default:
|
|
186
|
+
throw new CliError(`Unknown strategy topics subcommand: ${sub ?? "(none)"}. Try: list, set`);
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
async function topicsList(ctx) {
|
|
191
|
+
const { client, flags, io } = ctx;
|
|
192
|
+
const collection = requireFlag(flags, "collection");
|
|
193
|
+
const result = await client.callTool("list_topics", { collection_id: collection });
|
|
194
|
+
printResult(io, flags, result);
|
|
195
|
+
return 0;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
async function topicsSet(ctx) {
|
|
199
|
+
const { client, flags, io } = ctx;
|
|
200
|
+
const collection = requireFlag(flags, "collection");
|
|
201
|
+
const topicId = flagStr(flags.topic);
|
|
202
|
+
const suggestionId = flagStr(flags.suggestion);
|
|
203
|
+
if ((topicId === undefined) === (suggestionId === undefined)) {
|
|
204
|
+
throw new CliError("Pass exactly one of --topic <topic-id> or --suggestion <suggestion-id>.");
|
|
205
|
+
}
|
|
206
|
+
const args = compact({ collection_id: collection, topic_id: topicId, suggestion_id: suggestionId });
|
|
207
|
+
const result = await client.callTool("set_topic", args);
|
|
208
|
+
ok(ctx, "Topic queued.");
|
|
209
|
+
printResult(io, flags, result);
|
|
210
|
+
return 0;
|
|
211
|
+
}
|
|
212
|
+
|
|
172
213
|
// --- onboarding ---------------------------------------------------------
|
|
173
214
|
|
|
174
215
|
export async function cmdOnboarding(ctx) {
|
package/lib/commands.mjs
CHANGED
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
// Best-effort "a newer version is out" nudge. Checks the npm registry at most once
|
|
2
|
+
// every 24h and caches the result next to the config file, so a warm cache never
|
|
3
|
+
// touches the network and a cold/offline check never slows or breaks a command —
|
|
4
|
+
// every failure mode (no network, slow DNS, registry down, unwritable home) is
|
|
5
|
+
// swallowed and just means no nudge gets printed.
|
|
6
|
+
|
|
7
|
+
import { readFileSync, writeFileSync, mkdirSync, existsSync } from "node:fs";
|
|
8
|
+
import { homedir } from "node:os";
|
|
9
|
+
import { join, dirname } from "node:path";
|
|
10
|
+
|
|
11
|
+
const REGISTRY_URL = "https://registry.npmjs.org/@letterstory/cli/latest";
|
|
12
|
+
const CHECK_INTERVAL_MS = 24 * 60 * 60 * 1000;
|
|
13
|
+
const FETCH_TIMEOUT_MS = 500;
|
|
14
|
+
|
|
15
|
+
function cachePath() {
|
|
16
|
+
const home = process.env.LETTERSTORY_CONFIG_HOME || homedir();
|
|
17
|
+
return join(home, ".letterstory", "update-check.json");
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function readCache() {
|
|
21
|
+
try {
|
|
22
|
+
if (!existsSync(cachePath())) return null;
|
|
23
|
+
return JSON.parse(readFileSync(cachePath(), "utf8"));
|
|
24
|
+
} catch {
|
|
25
|
+
return null;
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function writeCache(data) {
|
|
30
|
+
try {
|
|
31
|
+
const path = cachePath();
|
|
32
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
33
|
+
writeFileSync(path, JSON.stringify(data));
|
|
34
|
+
} catch {
|
|
35
|
+
// Read-only home, no disk space, etc. — caching is an optimization, not a requirement.
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// Plain dotted-numeric semver compare — this CLI's versions are always X.Y.Z, no
|
|
40
|
+
// prerelease/build metadata to worry about.
|
|
41
|
+
function isNewer(latest, current) {
|
|
42
|
+
const a = String(latest).split(".").map(Number);
|
|
43
|
+
const b = String(current).split(".").map(Number);
|
|
44
|
+
for (let i = 0; i < Math.max(a.length, b.length); i++) {
|
|
45
|
+
const x = a[i] || 0;
|
|
46
|
+
const y = b[i] || 0;
|
|
47
|
+
if (x !== y) return x > y;
|
|
48
|
+
}
|
|
49
|
+
return false;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
async function fetchLatestVersion(fetchImpl) {
|
|
53
|
+
const controller = new AbortController();
|
|
54
|
+
const timer = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);
|
|
55
|
+
try {
|
|
56
|
+
const res = await fetchImpl(REGISTRY_URL, { signal: controller.signal });
|
|
57
|
+
if (!res.ok) return null;
|
|
58
|
+
const body = await res.json();
|
|
59
|
+
return typeof body.version === "string" ? body.version : null;
|
|
60
|
+
} catch {
|
|
61
|
+
return null; // offline, timed out, registry down, malformed response — no nudge, not an error
|
|
62
|
+
} finally {
|
|
63
|
+
clearTimeout(timer);
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Returns a one-line nudge string if a newer version is published on npm, else null.
|
|
69
|
+
* Never throws. `fetchImpl`/`now` are injectable for tests; real callers use the
|
|
70
|
+
* defaults (global fetch, current time).
|
|
71
|
+
*
|
|
72
|
+
* @param {{ currentVersion: string, fetchImpl?: (url: string, init?: { signal?: AbortSignal }) => Promise<{ ok: boolean, json: () => Promise<any> }>, now?: number }} opts
|
|
73
|
+
* @returns {Promise<string | null>}
|
|
74
|
+
*/
|
|
75
|
+
export async function checkForUpdate({ currentVersion, fetchImpl = globalThis.fetch, now = Date.now() }) {
|
|
76
|
+
if (process.env.LETTERSTORY_NO_UPDATE_CHECK) return null;
|
|
77
|
+
|
|
78
|
+
const cache = readCache();
|
|
79
|
+
let latest = cache?.latestVersion;
|
|
80
|
+
const stale = !cache || typeof cache.checkedAt !== "number" || now - cache.checkedAt > CHECK_INTERVAL_MS;
|
|
81
|
+
|
|
82
|
+
if (stale) {
|
|
83
|
+
const fetched = await fetchLatestVersion(fetchImpl);
|
|
84
|
+
if (fetched) {
|
|
85
|
+
latest = fetched;
|
|
86
|
+
writeCache({ checkedAt: now, latestVersion: fetched });
|
|
87
|
+
} else if (!cache) {
|
|
88
|
+
return null; // first-ever check failed (e.g. offline) — nothing to compare against yet
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
if (!latest || !isNewer(latest, currentVersion)) return null;
|
|
93
|
+
return `Update available: ${currentVersion} → ${latest}. Run \`npm install -g @letterstory/cli@latest\` to upgrade. (Set LETTERSTORY_NO_UPDATE_CHECK=1 to silence this.)`;
|
|
94
|
+
}
|