@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 +8 -2
- package/dist/main.js +8 -0
- package/dist/update-check-refresh.d.ts +1 -0
- package/dist/update-check-refresh.js +9 -0
- package/dist/update-check.d.ts +27 -0
- package/dist/update-check.js +200 -0
- package/package.json +1 -1
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
|
-
|
|
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.
|
|
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": {
|