@sequenceholdings/studio-cli 0.1.11 → 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.
package/dist/bin.js CHANGED
@@ -1,8 +1,14 @@
1
1
  #!/usr/bin/env node
2
2
  import { run } from './main.js';
3
+ import { maybeNotifyUpdate } from './update-check.js';
3
4
  run()
4
- .then((code) => process.exit(code))
5
5
  .catch((error) => {
6
6
  console.error(error instanceof Error ? error.message : error);
7
- process.exit(1);
7
+ return 1;
8
+ })
9
+ // The update notice runs after the command so it never delays real output,
10
+ // and it is best-effort (never throws, never changes the exit code).
11
+ .then(async (code) => {
12
+ await maybeNotifyUpdate();
13
+ process.exit(code);
8
14
  });
package/dist/main.js CHANGED
@@ -30,6 +30,7 @@ const TOP_LEVEL_USAGE = `usage:
30
30
  seq-studio login authenticate in the browser
31
31
  seq-studio logout remove cached user tokens
32
32
  seq-studio doctor [-e <env>] diagnose config, auth, and writer gate
33
+ seq-studio version print the installed version
33
34
  seq-studio help show this message
34
35
 
35
36
  Authenticate with: seq-studio login
@@ -92,6 +93,13 @@ export async function run(argv = process.argv.slice(2)) {
92
93
  case 'login':
93
94
  case 'logout':
94
95
  return runSessionCommand({ argument: sub, command: namespace });
96
+ case 'version':
97
+ case '--version':
98
+ case '-v': {
99
+ const { currentVersion } = await import('./update-check.js');
100
+ console.log(currentVersion());
101
+ return 0;
102
+ }
95
103
  case 'doctor':
96
104
  return doctorCommand(parseArgs([sub, ...rest].filter(Boolean)));
97
105
  default:
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,9 @@
1
+ /**
2
+ * Detached background entry spawned by maybeNotifyUpdate() when the update
3
+ * cache is stale. Polls the npm registry once and persists the result so
4
+ * the *next* command can print the notice without any foreground network.
5
+ */
6
+ import { refreshUpdateCache } from './update-check.js';
7
+ refreshUpdateCache().catch(() => {
8
+ // Best-effort: nothing to report to — the parent process is long gone.
9
+ });
@@ -0,0 +1,27 @@
1
+ /** The version of this installed package (dist/../package.json). */
2
+ export declare function currentVersion(): string;
3
+ export declare function updateCheckCachePath(): string;
4
+ /** Exclusive-create marker that makes "who spawns the refresh?" atomic. */
5
+ export declare function updateCheckClaimPath(): string;
6
+ /**
7
+ * True when `candidate` is a strictly newer semver than `current`.
8
+ * Prerelease suffixes are ignored — the published channel is plain
9
+ * major.minor.patch and this only drives a notice, not resolution.
10
+ */
11
+ export declare function isNewerVersion(candidate: string, current: string): boolean;
12
+ /**
13
+ * Poll the registry and persist the result. Runs in the detached background
14
+ * process (see update-check-refresh.ts) — never on a command's exit path.
15
+ * Failed fetches still record `lastCheckedAt` (keeping any previously known
16
+ * version) so attempts are throttled to once per interval either way. The
17
+ * cache file is written *only* here, so a foreground path can never clobber
18
+ * a fresher result. Releases the spawn claim when done.
19
+ */
20
+ export declare function refreshUpdateCache(): Promise<void>;
21
+ /**
22
+ * Print a strong upgrade recommendation to stderr when the cached registry
23
+ * state says a newer version is published, and kick off a detached refresh
24
+ * when the cache is stale. Reads only local state — adds no network latency
25
+ * to the command. Never throws.
26
+ */
27
+ export declare function maybeNotifyUpdate(): Promise<void>;
@@ -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.11",
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": {