@sequenceholdings/studio-cli 0.1.10 → 0.1.12

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.
@@ -0,0 +1,200 @@
1
+ import { spawn } from 'node:child_process';
2
+ import { mkdir, readFile, rm, stat, writeFile } from 'node:fs/promises';
3
+ import { readFileSync } from 'node:fs';
4
+ import { homedir } from 'node:os';
5
+ import { dirname, join } from 'node:path';
6
+ import { fileURLToPath } from 'node:url';
7
+ /**
8
+ * Update notice: tell interactive users when a newer version of the CLI is
9
+ * on npm, and strongly recommend upgrading.
10
+ *
11
+ * Zero foreground latency is the design constraint. The command path only
12
+ * ever *reads* the on-disk cache (~/.config/lattice/update-check.json) and
13
+ * prints; the registry poll runs in a detached background process spawned
14
+ * at most once per CHECK_INTERVAL_MS — including after failures, which
15
+ * record their attempt time so a downed registry doesn't retry on every
16
+ * command. An exclusive claim file elects a single refresher even across
17
+ * concurrent CLI invocations. The notice is therefore at most one
18
+ * invocation stale, which is the standard update-notifier trade-off.
19
+ *
20
+ * The notice goes to stderr and only when stderr is a TTY, so scripted /
21
+ * piped / CI invocations never see it. Opt out entirely with
22
+ * SEQ_STUDIO_NO_UPDATE_CHECK=1. Everything is best-effort: failures are
23
+ * silent and never affect the command's exit code.
24
+ */
25
+ const PACKAGE_NAME = '@sequenceholdings/studio-cli';
26
+ const CHECK_INTERVAL_MS = 24 * 60 * 60 * 1000;
27
+ const FETCH_TIMEOUT_MS = 5000;
28
+ /** A claim older than this belongs to a refresher that died — take it over. */
29
+ const CLAIM_TTL_MS = 10 * 60 * 1000;
30
+ /** The version of this installed package (dist/../package.json). */
31
+ export function currentVersion() {
32
+ const packageJsonPath = join(dirname(fileURLToPath(import.meta.url)), '..', 'package.json');
33
+ const parsed = JSON.parse(readFileSync(packageJsonPath, 'utf8'));
34
+ const version = typeof parsed === 'object' && parsed !== null ? Reflect.get(parsed, 'version') : null;
35
+ return typeof version === 'string' ? version : '0.0.0';
36
+ }
37
+ export function updateCheckCachePath() {
38
+ return join(homedir(), '.config', 'lattice', 'update-check.json');
39
+ }
40
+ /** Exclusive-create marker that makes "who spawns the refresh?" atomic. */
41
+ export function updateCheckClaimPath() {
42
+ return updateCheckCachePath() + '.claim';
43
+ }
44
+ /**
45
+ * True when `candidate` is a strictly newer semver than `current`.
46
+ * Prerelease suffixes are ignored — the published channel is plain
47
+ * major.minor.patch and this only drives a notice, not resolution.
48
+ */
49
+ export function isNewerVersion(candidate, current) {
50
+ const parse = (v) => v
51
+ .replace(/^v/, '')
52
+ .split('-')[0]
53
+ .split('.')
54
+ .map((part) => Number.parseInt(part, 10) || 0);
55
+ const [a, b] = [parse(candidate), parse(current)];
56
+ for (let i = 0; i < 3; i++) {
57
+ const diff = (a[i] ?? 0) - (b[i] ?? 0);
58
+ if (diff !== 0)
59
+ return diff > 0;
60
+ }
61
+ return false;
62
+ }
63
+ async function readCache() {
64
+ try {
65
+ const parsed = JSON.parse(await readFile(updateCheckCachePath(), 'utf8'));
66
+ if (typeof parsed !== 'object' || parsed === null)
67
+ return null;
68
+ const candidate = parsed;
69
+ if (typeof candidate.lastCheckedAt !== 'number')
70
+ return null;
71
+ if (typeof candidate.latestVersion !== 'string' && candidate.latestVersion !== null) {
72
+ return null;
73
+ }
74
+ return { lastCheckedAt: candidate.lastCheckedAt, latestVersion: candidate.latestVersion };
75
+ }
76
+ catch {
77
+ return null;
78
+ }
79
+ }
80
+ async function writeCache(cache) {
81
+ const path = updateCheckCachePath();
82
+ await mkdir(dirname(path), { recursive: true });
83
+ await writeFile(path, JSON.stringify(cache, null, 2) + '\n', 'utf8');
84
+ }
85
+ async function fetchLatestVersion() {
86
+ const controller = new AbortController();
87
+ const timer = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);
88
+ try {
89
+ const response = await fetch(`https://registry.npmjs.org/${PACKAGE_NAME}/latest`, { signal: controller.signal });
90
+ if (!response.ok)
91
+ return null;
92
+ const body = (await response.json());
93
+ return typeof body.version === 'string' ? body.version : null;
94
+ }
95
+ catch {
96
+ return null;
97
+ }
98
+ finally {
99
+ clearTimeout(timer);
100
+ }
101
+ }
102
+ /**
103
+ * Poll the registry and persist the result. Runs in the detached background
104
+ * process (see update-check-refresh.ts) — never on a command's exit path.
105
+ * Failed fetches still record `lastCheckedAt` (keeping any previously known
106
+ * version) so attempts are throttled to once per interval either way. The
107
+ * cache file is written *only* here, so a foreground path can never clobber
108
+ * a fresher result. Releases the spawn claim when done.
109
+ */
110
+ export async function refreshUpdateCache() {
111
+ try {
112
+ const previous = await readCache();
113
+ const fetched = await fetchLatestVersion();
114
+ await writeCache({
115
+ lastCheckedAt: Date.now(),
116
+ latestVersion: fetched ?? previous?.latestVersion ?? null,
117
+ });
118
+ }
119
+ finally {
120
+ await rm(updateCheckClaimPath(), { force: true }).catch(() => { });
121
+ }
122
+ }
123
+ /**
124
+ * Atomically claim the right to spawn a refresh. The `wx` flag makes the
125
+ * create exclusive at the filesystem level, so concurrent CLI processes
126
+ * racing past a stale cache elect exactly one refresher. A claim whose
127
+ * mtime exceeds CLAIM_TTL_MS is from a refresher that died before releasing
128
+ * it — take it over so refreshes can't wedge forever.
129
+ */
130
+ async function tryClaimRefresh() {
131
+ const path = updateCheckClaimPath();
132
+ try {
133
+ await mkdir(dirname(path), { recursive: true });
134
+ await writeFile(path, String(process.pid), { flag: 'wx' });
135
+ return true;
136
+ }
137
+ catch {
138
+ try {
139
+ const { mtimeMs } = await stat(path);
140
+ if (Date.now() - mtimeMs < CLAIM_TTL_MS)
141
+ return false;
142
+ await rm(path, { force: true });
143
+ await writeFile(path, String(process.pid), { flag: 'wx' });
144
+ return true;
145
+ }
146
+ catch {
147
+ return false;
148
+ }
149
+ }
150
+ }
151
+ /** CI conventionally sets CI=true; some environments set CI=false to mean "not CI". */
152
+ function isCiEnvironment() {
153
+ const value = process.env['CI']?.trim().toLowerCase();
154
+ return value !== undefined && value !== '' && value !== 'false' && value !== '0';
155
+ }
156
+ function spawnBackgroundRefresh() {
157
+ const script = join(dirname(fileURLToPath(import.meta.url)), 'update-check-refresh.js');
158
+ const child = spawn(process.execPath, [script], { detached: true, stdio: 'ignore' });
159
+ // Spawn failures surface as an async 'error' event; without a listener that
160
+ // becomes an unhandled event that could crash the CLI after its command
161
+ // already succeeded. The refresh is best-effort — swallow it.
162
+ child.on('error', () => { });
163
+ child.unref();
164
+ }
165
+ /**
166
+ * Print a strong upgrade recommendation to stderr when the cached registry
167
+ * state says a newer version is published, and kick off a detached refresh
168
+ * when the cache is stale. Reads only local state — adds no network latency
169
+ * to the command. Never throws.
170
+ */
171
+ export async function maybeNotifyUpdate() {
172
+ try {
173
+ if (process.env['SEQ_STUDIO_NO_UPDATE_CHECK']?.trim())
174
+ return;
175
+ if (isCiEnvironment())
176
+ return;
177
+ if (!process.stderr.isTTY)
178
+ return;
179
+ const cache = await readCache();
180
+ if (!cache || Date.now() - cache.lastCheckedAt > CHECK_INTERVAL_MS) {
181
+ // Exactly one process refreshes: the exclusive claim file elects a
182
+ // single refresher even across concurrent CLI invocations, and the
183
+ // foreground never writes the cache itself (so it can't clobber a
184
+ // fresher background result).
185
+ if (await tryClaimRefresh())
186
+ spawnBackgroundRefresh();
187
+ }
188
+ const latest = cache?.latestVersion;
189
+ const current = currentVersion();
190
+ if (!latest || !isNewerVersion(latest, current))
191
+ return;
192
+ process.stderr.write('\n' +
193
+ `seq-studio ${current} is out of date — ${latest} is available.\n` +
194
+ 'We highly recommend updating: fixes and environment changes land there first.\n' +
195
+ ` pnpm add -g ${PACKAGE_NAME}@latest (or: npm install -g ${PACKAGE_NAME}@latest)\n`);
196
+ }
197
+ catch {
198
+ // Best-effort by design — an update notice must never break a command.
199
+ }
200
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sequenceholdings/studio-cli",
3
- "version": "0.1.10",
3
+ "version": "0.1.12",
4
4
  "description": "Unified Sequence Studio CLI — `seq-studio process` (Lattice), `seq-studio artifact` (Artifact Studio), `seq-studio functions` / `secrets`, `seq-studio repos` (platform git-service), and `seq-studio auth pat` (git-service PATs). Includes Auth0 browser login shared with seqapi.",
5
5
  "license": "UNLICENSED",
6
6
  "repository": {
@@ -40,11 +40,11 @@
40
40
  "smol-toml": "^1.4.2",
41
41
  "tsx": "^4.20.3",
42
42
  "zod": "^4.1.13",
43
- "@sequenceholdings/artifact-studio": "0.1.10",
44
- "@sequenceholdings/lattice": "0.1.0"
43
+ "@sequenceholdings/artifact-studio": "0.1.11",
44
+ "@sequenceholdings/lattice": "0.1.1"
45
45
  },
46
46
  "peerDependencies": {
47
- "@sequenceholdings/orm": "0.1.0"
47
+ "@sequenceholdings/orm": "0.1.1"
48
48
  },
49
49
  "peerDependenciesMeta": {
50
50
  "@sequenceholdings/orm": {
@@ -56,7 +56,7 @@
56
56
  "@types/node": "^22.0.0",
57
57
  "typescript": "^5.6.0",
58
58
  "vitest": "^4.1.5",
59
- "@sequenceholdings/orm": "0.1.0"
59
+ "@sequenceholdings/orm": "0.1.1"
60
60
  },
61
61
  "engines": {
62
62
  "node": ">=20"