@letterstory/cli 0.2.1 → 0.2.2

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 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
- # from a checkout of this repo
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 resolved and
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,
@@ -27,7 +28,7 @@ import {
27
28
  } from "./commands.mjs";
28
29
 
29
30
  // Keep in sync with cli/package.json.
30
- export const VERSION = "0.2.1";
31
+ export const VERSION = "0.2.2";
31
32
 
32
33
  // Flags that never take a value. Listing them explicitly means `deploy get --json <id>`
33
34
  // can't accidentally swallow the id as --json's value.
@@ -244,6 +245,9 @@ export function defaultIo() {
244
245
  sleep: (ms) => new Promise((resolve) => setTimeout(resolve, ms)),
245
246
  pollIntervalMs: envInt("LETTERSTORY_POLL_INTERVAL_MS", 4000),
246
247
  maxPolls: envInt("LETTERSTORY_MAX_POLLS", 90),
248
+ // Only a *real* CLI invocation (never a test, which always builds its own io)
249
+ // gets the background update-version check — see the `finally` block in run().
250
+ updateCheck: true,
247
251
  };
248
252
  }
249
253
 
@@ -298,5 +302,18 @@ export async function run(argv, io = defaultIo()) {
298
302
  return 1;
299
303
  }
300
304
  throw err;
305
+ } finally {
306
+ // Fire-and-await (bounded by checkForUpdate's own short fetch timeout) so the
307
+ // nudge, if any, is printed before the bin's process.exit() — but a warm cache
308
+ // (the common case) resolves with no network call at all, so this is normally
309
+ // instant. --json output stays machine-clean; real errors above already returned.
310
+ if (io.updateCheck && !flags.json) {
311
+ try {
312
+ const notice = await checkForUpdate({ currentVersion: VERSION });
313
+ if (notice) io.error(notice);
314
+ } catch {
315
+ // Never let the update nudge itself become the reason a command fails.
316
+ }
317
+ }
301
318
  }
302
319
  }
@@ -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
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@letterstory/cli",
3
- "version": "0.2.1",
3
+ "version": "0.2.2",
4
4
  "description": "Spin up and manage Letterstory phantom blogs from your terminal.",
5
5
  "type": "module",
6
6
  "license": "UNLICENSED",