@fluojs/cli 1.0.0-beta.1 → 1.0.0-beta.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.
@@ -0,0 +1,356 @@
1
+ import { spawnSync } from 'node:child_process';
2
+ import { mkdir, readFile, writeFile } from 'node:fs/promises';
3
+ import { homedir } from 'node:os';
4
+ import { dirname, join } from 'node:path';
5
+ import { createInterface } from 'node:readline/promises';
6
+ import { fileURLToPath } from 'node:url';
7
+ const DEFAULT_CACHE_TTL_MS = 24 * 60 * 60 * 1000;
8
+ const DEFAULT_PACKAGE_NAME = '@fluojs/cli';
9
+ const DEFAULT_REGISTRY_TIMEOUT_MS = 5_000;
10
+ const UPDATE_CHECK_FLAGS = new Set(['--no-update-check', '--no-update-notifier']);
11
+ function isRecord(value) {
12
+ return typeof value === 'object' && value !== null;
13
+ }
14
+ function isTruthyEnvValue(value) {
15
+ if (!value) {
16
+ return false;
17
+ }
18
+ return ['1', 'true', 'yes'].includes(value.toLowerCase());
19
+ }
20
+ function parseSemver(version) {
21
+ const match = /^v?(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?(?:\+[0-9A-Za-z.-]+)?$/.exec(version.trim());
22
+ if (!match) {
23
+ return undefined;
24
+ }
25
+ const [, major, minor, patch, prerelease] = match;
26
+ if (major === undefined || minor === undefined || patch === undefined) {
27
+ return undefined;
28
+ }
29
+ return {
30
+ major: Number.parseInt(major, 10),
31
+ minor: Number.parseInt(minor, 10),
32
+ patch: Number.parseInt(patch, 10),
33
+ prerelease: prerelease ? prerelease.split('.') : []
34
+ };
35
+ }
36
+ function compareNumericPart(left, right) {
37
+ if (left > right) {
38
+ return 1;
39
+ }
40
+ if (left < right) {
41
+ return -1;
42
+ }
43
+ return 0;
44
+ }
45
+ function comparePrereleaseIdentifier(left, right) {
46
+ if (left === undefined && right === undefined) {
47
+ return 0;
48
+ }
49
+ if (left === undefined) {
50
+ return -1;
51
+ }
52
+ if (right === undefined) {
53
+ return 1;
54
+ }
55
+ const leftNumber = /^\d+$/.test(left) ? Number.parseInt(left, 10) : undefined;
56
+ const rightNumber = /^\d+$/.test(right) ? Number.parseInt(right, 10) : undefined;
57
+ if (leftNumber !== undefined && rightNumber !== undefined) {
58
+ return compareNumericPart(leftNumber, rightNumber);
59
+ }
60
+ if (leftNumber !== undefined) {
61
+ return -1;
62
+ }
63
+ if (rightNumber !== undefined) {
64
+ return 1;
65
+ }
66
+ if (left > right) {
67
+ return 1;
68
+ }
69
+ if (left < right) {
70
+ return -1;
71
+ }
72
+ return 0;
73
+ }
74
+ function compareSemver(left, right) {
75
+ const major = compareNumericPart(left.major, right.major);
76
+ if (major !== 0) {
77
+ return major;
78
+ }
79
+ const minor = compareNumericPart(left.minor, right.minor);
80
+ if (minor !== 0) {
81
+ return minor;
82
+ }
83
+ const patch = compareNumericPart(left.patch, right.patch);
84
+ if (patch !== 0) {
85
+ return patch;
86
+ }
87
+ if (left.prerelease.length === 0 && right.prerelease.length === 0) {
88
+ return 0;
89
+ }
90
+ if (left.prerelease.length === 0) {
91
+ return 1;
92
+ }
93
+ if (right.prerelease.length === 0) {
94
+ return -1;
95
+ }
96
+ const maxLength = Math.max(left.prerelease.length, right.prerelease.length);
97
+ for (let index = 0; index < maxLength; index += 1) {
98
+ const comparison = comparePrereleaseIdentifier(left.prerelease[index], right.prerelease[index]);
99
+ if (comparison !== 0) {
100
+ return comparison;
101
+ }
102
+ }
103
+ return 0;
104
+ }
105
+ function isNewerVersion(latestVersion, currentVersion) {
106
+ const latest = parseSemver(latestVersion);
107
+ const current = parseSemver(currentVersion);
108
+ if (!latest || !current) {
109
+ return false;
110
+ }
111
+ return compareSemver(latest, current) > 0;
112
+ }
113
+ function parseCache(contents) {
114
+ const parsed = JSON.parse(contents);
115
+ if (!isRecord(parsed)) {
116
+ return undefined;
117
+ }
118
+ const checkedAt = parsed.checkedAt;
119
+ const latestVersion = parsed.latestVersion;
120
+ if (typeof checkedAt !== 'number' || typeof latestVersion !== 'string') {
121
+ return undefined;
122
+ }
123
+ return {
124
+ checkedAt,
125
+ latestVersion
126
+ };
127
+ }
128
+ function resolveCacheFile(env) {
129
+ const cacheRoot = env.XDG_CACHE_HOME ?? join(homedir(), '.cache');
130
+ return join(cacheRoot, 'fluo', 'cli-update-check.json');
131
+ }
132
+ async function readCachedLatestVersion(cacheFile, nowMs, cacheTtlMs) {
133
+ try {
134
+ const cache = parseCache(await readFile(cacheFile, 'utf8'));
135
+ if (!cache) {
136
+ return undefined;
137
+ }
138
+ if (nowMs - cache.checkedAt > cacheTtlMs) {
139
+ return undefined;
140
+ }
141
+ return cache.latestVersion;
142
+ } catch (error) {
143
+ if (error instanceof SyntaxError) {
144
+ return undefined;
145
+ }
146
+ return undefined;
147
+ }
148
+ }
149
+ async function writeLatestVersionCache(cacheFile, latestVersion, nowMs) {
150
+ await mkdir(dirname(cacheFile), {
151
+ recursive: true
152
+ });
153
+ await writeFile(cacheFile, `${JSON.stringify({
154
+ checkedAt: nowMs,
155
+ latestVersion
156
+ }, null, 2)}\n`, 'utf8');
157
+ }
158
+ async function fetchLatestDistTag(packageName) {
159
+ const controller = new AbortController();
160
+ const timeout = setTimeout(() => controller.abort(), DEFAULT_REGISTRY_TIMEOUT_MS);
161
+ try {
162
+ const encodedPackageName = encodeURIComponent(packageName);
163
+ const response = await fetch(`https://registry.npmjs.org/-/package/${encodedPackageName}/dist-tags`, {
164
+ headers: {
165
+ accept: 'application/json'
166
+ },
167
+ signal: controller.signal
168
+ });
169
+ if (!response.ok) {
170
+ return undefined;
171
+ }
172
+ const payload = await response.json();
173
+ if (!isRecord(payload) || typeof payload.latest !== 'string') {
174
+ return undefined;
175
+ }
176
+ return payload.latest;
177
+ } catch (_error) {
178
+ return undefined;
179
+ } finally {
180
+ clearTimeout(timeout);
181
+ }
182
+ }
183
+ async function resolveLatestVersion(packageName, cacheFile, cacheTtlMs, nowMs, fetchLatestVersion) {
184
+ const cachedLatestVersion = await readCachedLatestVersion(cacheFile, nowMs, cacheTtlMs);
185
+ if (cachedLatestVersion) {
186
+ return cachedLatestVersion;
187
+ }
188
+ let latestVersion;
189
+ try {
190
+ latestVersion = await fetchLatestVersion(packageName);
191
+ } catch (_error) {
192
+ return undefined;
193
+ }
194
+ if (!latestVersion) {
195
+ return undefined;
196
+ }
197
+ try {
198
+ await writeLatestVersionCache(cacheFile, latestVersion, nowMs);
199
+ } catch (_error) {
200
+ return latestVersion;
201
+ }
202
+ return latestVersion;
203
+ }
204
+ async function readOwnPackageVersion() {
205
+ const packageJsonPath = fileURLToPath(new URL('../package.json', import.meta.url));
206
+ try {
207
+ const manifest = JSON.parse(await readFile(packageJsonPath, 'utf8'));
208
+ if (!isRecord(manifest) || typeof manifest.version !== 'string') {
209
+ return undefined;
210
+ }
211
+ return manifest.version;
212
+ } catch (_error) {
213
+ return undefined;
214
+ }
215
+ }
216
+ function resolveInstallCommand(packageName, latestVersion) {
217
+ const packageSpecifier = `${packageName}@${latestVersion}`;
218
+ return {
219
+ args: ['add', '-g', packageSpecifier],
220
+ command: 'pnpm',
221
+ display: `pnpm add -g ${packageSpecifier}`
222
+ };
223
+ }
224
+ async function defaultPromptConfirm(message, defaultValue) {
225
+ const promptSuffix = defaultValue ? 'Y/n' : 'y/N';
226
+ const readline = createInterface({
227
+ input: process.stdin,
228
+ output: process.stdout
229
+ });
230
+ try {
231
+ const answer = (await readline.question(`${message} (${promptSuffix}) `)).trim().toLowerCase();
232
+ if (answer.length === 0) {
233
+ return defaultValue;
234
+ }
235
+ return answer === 'y' || answer === 'yes';
236
+ } finally {
237
+ readline.close();
238
+ }
239
+ }
240
+ async function defaultInstallPackage(installCommand, runtime) {
241
+ const result = spawnSync(installCommand.command, installCommand.args, {
242
+ env: runtime.env,
243
+ stdio: 'inherit'
244
+ });
245
+ if (result.error) {
246
+ runtime.stderr.write(`Failed to run ${installCommand.display}: ${result.error.message}\n`);
247
+ return 1;
248
+ }
249
+ return result.status ?? 1;
250
+ }
251
+ async function defaultRerunCli(argv, runtime) {
252
+ const command = process.platform === 'win32' ? 'fluo.cmd' : 'fluo';
253
+ const result = spawnSync(command, argv, {
254
+ env: {
255
+ ...runtime.env,
256
+ FLUO_UPDATE_CHECK_REEXEC: '1'
257
+ },
258
+ stdio: 'inherit'
259
+ });
260
+ if (result.error) {
261
+ runtime.stderr.write(`Failed to restart fluo after updating: ${result.error.message}\n`);
262
+ return 1;
263
+ }
264
+ return result.status ?? 1;
265
+ }
266
+ function shouldSkipForEnvironment(env, ci) {
267
+ return Boolean(ci) || isTruthyEnvValue(env.CI) || isTruthyEnvValue(env.GITHUB_ACTIONS) || isTruthyEnvValue(env.FLUO_NO_UPDATE_CHECK) || isTruthyEnvValue(env.NO_UPDATE_NOTIFIER) || isTruthyEnvValue(env.FLUO_UPDATE_CHECK_REEXEC) || Boolean(env.npm_lifecycle_event) || Boolean(env.npm_lifecycle_script);
268
+ }
269
+ function shouldRunInteractiveUpdateCheck(options, env) {
270
+ if (options.skip || options.interactive === false || shouldSkipForEnvironment(env, options.ci)) {
271
+ return false;
272
+ }
273
+ return Boolean(options.stdin?.isTTY ?? process.stdin.isTTY) && Boolean(options.stdout?.isTTY ?? process.stdout.isTTY);
274
+ }
275
+ export function removeUpdateCheckFlags(argv) {
276
+ const filteredArgv = [];
277
+ let skipUpdateCheck = false;
278
+ for (const arg of argv) {
279
+ if (UPDATE_CHECK_FLAGS.has(arg)) {
280
+ skipUpdateCheck = true;
281
+ continue;
282
+ }
283
+ filteredArgv.push(arg);
284
+ }
285
+ return {
286
+ argv: filteredArgv,
287
+ skipUpdateCheck
288
+ };
289
+ }
290
+ export async function runCliUpdateCheck(argv, options = {}) {
291
+ const env = options.env ?? {};
292
+ const stderr = options.stderr ?? process.stderr;
293
+ const stdout = options.stdout ?? process.stdout;
294
+ if (!shouldRunInteractiveUpdateCheck({
295
+ ...options,
296
+ stderr,
297
+ stdout
298
+ }, env)) {
299
+ return {
300
+ action: 'continue'
301
+ };
302
+ }
303
+ const packageName = options.packageName ?? DEFAULT_PACKAGE_NAME;
304
+ const now = options.now ?? (() => new Date());
305
+ const nowMs = now().getTime();
306
+ const cacheFile = options.cacheFile ?? resolveCacheFile(env);
307
+ const cacheTtlMs = options.cacheTtlMs ?? DEFAULT_CACHE_TTL_MS;
308
+ const currentVersion = options.currentVersion ?? (await readOwnPackageVersion());
309
+ if (!currentVersion) {
310
+ return {
311
+ action: 'continue'
312
+ };
313
+ }
314
+ const latestVersion = await resolveLatestVersion(packageName, cacheFile, cacheTtlMs, nowMs, options.fetchLatestVersion ?? fetchLatestDistTag);
315
+ if (!latestVersion || !isNewerVersion(latestVersion, currentVersion)) {
316
+ return {
317
+ action: 'continue'
318
+ };
319
+ }
320
+ stderr.write(`A newer ${packageName} version is available: ${currentVersion} -> ${latestVersion}.\n`);
321
+ const prompt = options.prompt ?? {
322
+ confirm: defaultPromptConfirm
323
+ };
324
+ const shouldInstall = await prompt.confirm(`Install ${packageName}@${latestVersion} now and restart this command?`, false);
325
+ if (!shouldInstall) {
326
+ stderr.write(`Continuing with ${packageName}@${currentVersion}.\n`);
327
+ return {
328
+ action: 'continue'
329
+ };
330
+ }
331
+ const installCommand = resolveInstallCommand(packageName, latestVersion);
332
+ stderr.write(`Installing ${packageName}@${latestVersion} with \`${installCommand.display}\`...\n`);
333
+ const commandRuntime = {
334
+ env,
335
+ stderr
336
+ };
337
+ const installExitCode = await (options.installPackage ?? defaultInstallPackage)(installCommand, commandRuntime);
338
+ if (installExitCode !== 0) {
339
+ stderr.write(`Update install failed with exit code ${installExitCode}; continuing with ${packageName}@${currentVersion}.\n`);
340
+ return {
341
+ action: 'continue'
342
+ };
343
+ }
344
+ stderr.write(`Updated ${packageName} to ${latestVersion}. Restarting fluo...\n`);
345
+ const rerunExitCode = await (options.rerunCli ?? defaultRerunCli)(argv, {
346
+ env: {
347
+ ...env,
348
+ FLUO_UPDATE_CHECK_REEXEC: '1'
349
+ },
350
+ stderr
351
+ });
352
+ return {
353
+ action: 'reran',
354
+ exitCode: rerunExitCode
355
+ };
356
+ }
package/package.json CHANGED
@@ -9,7 +9,7 @@
9
9
  "migration",
10
10
  "diagnostics"
11
11
  ],
12
- "version": "1.0.0-beta.1",
12
+ "version": "1.0.0-beta.2",
13
13
  "private": false,
14
14
  "license": "MIT",
15
15
  "repository": {
@@ -46,6 +46,14 @@
46
46
  "typescript": "^6.0.2",
47
47
  "@fluojs/runtime": "^1.0.0-beta.1"
48
48
  },
49
+ "peerDependencies": {
50
+ "@fluojs/studio": "^1.0.0-beta.2"
51
+ },
52
+ "peerDependenciesMeta": {
53
+ "@fluojs/studio": {
54
+ "optional": true
55
+ }
56
+ },
49
57
  "devDependencies": {
50
58
  "@types/ejs": "^3.1.5",
51
59
  "vitest": "^3.2.4"