mmt-testlight 0.4.4 → 0.4.5
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 +1 -1
- package/dist/cli.js +26648 -9539
- package/dist/guides/agent-workflow.md +75 -0
- package/dist/guides/general.md +64 -0
- package/dist/guides/generate-api.md +221 -0
- package/dist/guides/generate-doc.md +103 -0
- package/dist/guides/generate-env.md +147 -0
- package/dist/guides/generate-loadtest.md +55 -0
- package/dist/guides/generate-suite.md +167 -0
- package/dist/guides/generate-test-skill.md +60 -0
- package/dist/guides/generate-test.md +335 -0
- package/dist/guides/generate.md +60 -0
- package/dist/guides/golden-smoke.md +52 -0
- package/dist/guides/min/api.md +29 -0
- package/dist/guides/min/constraints.md +9 -0
- package/dist/guides/min/doc.md +17 -0
- package/dist/guides/min/env.md +26 -0
- package/dist/guides/min/loadtest.md +17 -0
- package/dist/guides/min/overview.md +25 -0
- package/dist/guides/min/suite.md +18 -0
- package/dist/guides/min/test.md +42 -0
- package/dist/guides/min/workflow.md +16 -0
- package/dist/guides/offline-agent.md +46 -0
- package/esbuild.mjs +25 -0
- package/package.json +2 -1
- package/src/aiDocs.ts +88 -0
- package/src/cli.ts +283 -7
- package/src/pathNormalize.cjs +16 -0
- package/src/pathNormalize.test.ts +8 -0
- package/src/selfUpdate.test.ts +79 -0
- package/src/selfUpdate.ts +471 -0
- package/src/validateMmt.ts +45 -0
|
@@ -0,0 +1,471 @@
|
|
|
1
|
+
import axios from 'axios';
|
|
2
|
+
import {execFileSync} from 'child_process';
|
|
3
|
+
import fs from 'fs';
|
|
4
|
+
import os from 'os';
|
|
5
|
+
import path from 'path';
|
|
6
|
+
import {pipeline} from 'stream/promises';
|
|
7
|
+
import {createWriteStream} from 'fs';
|
|
8
|
+
|
|
9
|
+
export type InstallChannel = 'standalone' | 'npm' | 'homebrew' | 'unknown';
|
|
10
|
+
|
|
11
|
+
export type UpdatePlatform =
|
|
12
|
+
'macos-x64'|'macos-arm64'|'linux-x64'|'linux-arm64'|'win-x64';
|
|
13
|
+
|
|
14
|
+
export interface UpdateOptions {
|
|
15
|
+
currentVersion: string;
|
|
16
|
+
version?: string;
|
|
17
|
+
channel?: string;
|
|
18
|
+
checkOnly?: boolean;
|
|
19
|
+
force?: boolean;
|
|
20
|
+
/** Override process.execPath (tests). */
|
|
21
|
+
execPath?: string;
|
|
22
|
+
/** Override process.argv[1] (tests). */
|
|
23
|
+
scriptPath?: string;
|
|
24
|
+
/** GitHub owner/repo (default mshobeyri/multimeter). */
|
|
25
|
+
repo?: string;
|
|
26
|
+
/**
|
|
27
|
+
* Download base for portal mirrors.
|
|
28
|
+
* Assets: `${base}/v${version}/testlight-${platform}.tar.gz|zip`
|
|
29
|
+
*/
|
|
30
|
+
releaseBaseUrl?: string;
|
|
31
|
+
/** Injected fetch for tests. */
|
|
32
|
+
fetchJson?: (url: string) => Promise<any>;
|
|
33
|
+
downloadToFile?: (url: string, dest: string) => Promise<void>;
|
|
34
|
+
log?: (message: string) => void;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export interface UpdatePlan {
|
|
38
|
+
channel: InstallChannel;
|
|
39
|
+
platform: UpdatePlatform;
|
|
40
|
+
currentVersion: string;
|
|
41
|
+
targetVersion: string;
|
|
42
|
+
installDir: string;
|
|
43
|
+
binaryName: string;
|
|
44
|
+
downloadUrl: string;
|
|
45
|
+
action: 'download' | 'advise-npm' | 'advise-homebrew' | 'noop';
|
|
46
|
+
advice?: string;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export interface UpdateResult {
|
|
50
|
+
ok: boolean;
|
|
51
|
+
plan: UpdatePlan;
|
|
52
|
+
message: string;
|
|
53
|
+
updated?: boolean;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const DEFAULT_REPO = 'mshobeyri/multimeter';
|
|
57
|
+
|
|
58
|
+
export function normalizeVersion(version: string): string {
|
|
59
|
+
return String(version || '').trim().replace(/^v/i, '');
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** Compare semver-ish versions. Returns -1 / 0 / 1. */
|
|
63
|
+
export function compareVersions(a: string, b: string): number {
|
|
64
|
+
const na = normalizeVersion(a);
|
|
65
|
+
const nb = normalizeVersion(b);
|
|
66
|
+
const parse = (v: string) => {
|
|
67
|
+
const [core, pre] = v.split('-', 2);
|
|
68
|
+
const parts = core.split('.').map(p => Number.parseInt(p, 10) || 0);
|
|
69
|
+
while (parts.length < 3) {
|
|
70
|
+
parts.push(0);
|
|
71
|
+
}
|
|
72
|
+
return {parts, pre: pre || ''};
|
|
73
|
+
};
|
|
74
|
+
const aa = parse(na);
|
|
75
|
+
const bb = parse(nb);
|
|
76
|
+
for (let i = 0; i < 3; i++) {
|
|
77
|
+
if (aa.parts[i] !== bb.parts[i]) {
|
|
78
|
+
return aa.parts[i] < bb.parts[i] ? -1 : 1;
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
if (aa.pre === bb.pre) {
|
|
82
|
+
return 0;
|
|
83
|
+
}
|
|
84
|
+
if (!aa.pre) {
|
|
85
|
+
return 1;
|
|
86
|
+
}
|
|
87
|
+
if (!bb.pre) {
|
|
88
|
+
return -1;
|
|
89
|
+
}
|
|
90
|
+
return aa.pre < bb.pre ? -1 : 1;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export function detectPlatform(
|
|
94
|
+
unameOs = process.platform, unameArch = process.arch): UpdatePlatform {
|
|
95
|
+
let osName: string;
|
|
96
|
+
if (unameOs === 'darwin') {
|
|
97
|
+
osName = 'macos';
|
|
98
|
+
} else if (unameOs === 'win32') {
|
|
99
|
+
osName = 'win';
|
|
100
|
+
} else {
|
|
101
|
+
osName = 'linux';
|
|
102
|
+
}
|
|
103
|
+
let arch = unameArch === 'arm64' ? 'arm64' : 'x64';
|
|
104
|
+
if (osName === 'win' && arch === 'arm64') {
|
|
105
|
+
arch = 'x64';
|
|
106
|
+
}
|
|
107
|
+
return `${osName}-${arch}` as UpdatePlatform;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
export function detectInstallChannel(execPath: string, scriptPath?: string):
|
|
111
|
+
InstallChannel {
|
|
112
|
+
const exec = execPath.replace(/\\/g, '/');
|
|
113
|
+
const script = (scriptPath || '').replace(/\\/g, '/');
|
|
114
|
+
if ((process as any).pkg) {
|
|
115
|
+
return 'standalone';
|
|
116
|
+
}
|
|
117
|
+
if (/\/Cellar\/mmt-testlight\//i.test(exec) ||
|
|
118
|
+
/\/homebrew\/.*(mmt-testlight|testlight)/i.test(exec)) {
|
|
119
|
+
return 'homebrew';
|
|
120
|
+
}
|
|
121
|
+
if (/\/node_modules\/mmt-testlight\//i.test(exec) ||
|
|
122
|
+
/\/node_modules\/mmt-testlight\//i.test(script)) {
|
|
123
|
+
return 'npm';
|
|
124
|
+
}
|
|
125
|
+
const base = path.basename(exec).toLowerCase();
|
|
126
|
+
if (base === 'testlight' || base === 'testlight.exe' || base === 'mmt' ||
|
|
127
|
+
base === 'mmt.exe') {
|
|
128
|
+
return 'standalone';
|
|
129
|
+
}
|
|
130
|
+
// node running bundled cli.js from a global npm link / local dist
|
|
131
|
+
if (/\/mmtcli\/dist\/cli\.js$/i.test(script) ||
|
|
132
|
+
/\/mmt-testlight\/dist\/cli\.js$/i.test(script)) {
|
|
133
|
+
return 'npm';
|
|
134
|
+
}
|
|
135
|
+
return 'unknown';
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
export function buildDownloadUrl(opts: {
|
|
139
|
+
version: string;
|
|
140
|
+
platform: UpdatePlatform;
|
|
141
|
+
repo?: string;
|
|
142
|
+
releaseBaseUrl?: string;
|
|
143
|
+
}): string {
|
|
144
|
+
const version = normalizeVersion(opts.version);
|
|
145
|
+
const ext = opts.platform.startsWith('win-') ? 'zip' : 'tar.gz';
|
|
146
|
+
const file = `testlight-${opts.platform}.${ext}`;
|
|
147
|
+
if (opts.releaseBaseUrl) {
|
|
148
|
+
const base = opts.releaseBaseUrl.replace(/\/+$/, '');
|
|
149
|
+
return `${base}/v${version}/${file}`;
|
|
150
|
+
}
|
|
151
|
+
const repo = opts.repo || DEFAULT_REPO;
|
|
152
|
+
return `https://github.com/${repo}/releases/download/v${version}/${file}`;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
async function defaultFetchJson(url: string): Promise<any> {
|
|
156
|
+
const headers: Record<string, string> = {
|
|
157
|
+
Accept: 'application/vnd.github+json',
|
|
158
|
+
'User-Agent': 'testlight-update',
|
|
159
|
+
};
|
|
160
|
+
const token = process.env.GITHUB_TOKEN || process.env.GH_TOKEN;
|
|
161
|
+
if (token) {
|
|
162
|
+
headers.Authorization = `Bearer ${token}`;
|
|
163
|
+
}
|
|
164
|
+
const res = await axios.get(url, {
|
|
165
|
+
headers,
|
|
166
|
+
timeout: 30000,
|
|
167
|
+
validateStatus: () => true,
|
|
168
|
+
});
|
|
169
|
+
if (res.status === 403) {
|
|
170
|
+
throw new Error(
|
|
171
|
+
`GitHub API rate limit or access denied (HTTP 403) for ${url}. ` +
|
|
172
|
+
`Pass --version explicitly, set GITHUB_TOKEN, or use --base-url for a portal mirror.`);
|
|
173
|
+
}
|
|
174
|
+
if (res.status >= 400) {
|
|
175
|
+
throw new Error(`HTTP ${res.status} fetching ${url}`);
|
|
176
|
+
}
|
|
177
|
+
return res.data;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
async function defaultDownloadToFile(url: string, dest: string): Promise<void> {
|
|
181
|
+
const res = await axios.get(url, {
|
|
182
|
+
responseType: 'stream',
|
|
183
|
+
timeout: 120000,
|
|
184
|
+
headers: {'User-Agent': 'testlight-update'},
|
|
185
|
+
validateStatus: () => true,
|
|
186
|
+
});
|
|
187
|
+
if (res.status >= 400) {
|
|
188
|
+
throw new Error(`HTTP ${res.status} downloading ${url}`);
|
|
189
|
+
}
|
|
190
|
+
await pipeline(res.data, createWriteStream(dest));
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
export async function resolveTargetVersion(opts: {
|
|
194
|
+
version?: string;
|
|
195
|
+
channel?: string;
|
|
196
|
+
repo?: string;
|
|
197
|
+
fetchJson?: (url: string) => Promise<any>;
|
|
198
|
+
}): Promise<string> {
|
|
199
|
+
if (opts.version) {
|
|
200
|
+
return normalizeVersion(opts.version);
|
|
201
|
+
}
|
|
202
|
+
const repo = opts.repo || DEFAULT_REPO;
|
|
203
|
+
const fetchJson = opts.fetchJson || defaultFetchJson;
|
|
204
|
+
const channel = (opts.channel || '').trim().toLowerCase();
|
|
205
|
+
|
|
206
|
+
if (channel) {
|
|
207
|
+
const releases = await fetchJson(
|
|
208
|
+
`https://api.github.com/repos/${repo}/releases?per_page=30`);
|
|
209
|
+
if (!Array.isArray(releases)) {
|
|
210
|
+
throw new Error('Unexpected GitHub releases response');
|
|
211
|
+
}
|
|
212
|
+
for (const release of releases) {
|
|
213
|
+
const tag = normalizeVersion(String(release?.tag_name || ''));
|
|
214
|
+
if (!tag) {
|
|
215
|
+
continue;
|
|
216
|
+
}
|
|
217
|
+
if (channel === 'prerelease' || channel === 'pre') {
|
|
218
|
+
if (release?.prerelease || tag.includes('-')) {
|
|
219
|
+
return tag;
|
|
220
|
+
}
|
|
221
|
+
continue;
|
|
222
|
+
}
|
|
223
|
+
if (tag.toLowerCase().includes(channel) ||
|
|
224
|
+
String(release?.name || '').toLowerCase().includes(channel)) {
|
|
225
|
+
return tag;
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
throw new Error(`No GitHub release found for channel "${channel}"`);
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
const latest = await fetchJson(
|
|
232
|
+
`https://api.github.com/repos/${repo}/releases/latest`);
|
|
233
|
+
const tag = normalizeVersion(String(latest?.tag_name || ''));
|
|
234
|
+
if (!tag) {
|
|
235
|
+
throw new Error('Could not resolve latest GitHub release tag');
|
|
236
|
+
}
|
|
237
|
+
return tag;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
export async function planUpdate(options: UpdateOptions): Promise<UpdatePlan> {
|
|
241
|
+
const execPath = options.execPath || process.execPath;
|
|
242
|
+
const scriptPath = options.scriptPath || process.argv[1];
|
|
243
|
+
const channel = detectInstallChannel(execPath, scriptPath);
|
|
244
|
+
const platform = detectPlatform();
|
|
245
|
+
const currentVersion = normalizeVersion(options.currentVersion);
|
|
246
|
+
const repo = options.repo || process.env.TESTLIGHT_REPO || DEFAULT_REPO;
|
|
247
|
+
const releaseBaseUrl =
|
|
248
|
+
options.releaseBaseUrl || process.env.TESTLIGHT_RELEASE_BASE_URL ||
|
|
249
|
+
undefined;
|
|
250
|
+
|
|
251
|
+
const targetVersion = await resolveTargetVersion({
|
|
252
|
+
version: options.version,
|
|
253
|
+
channel: options.channel,
|
|
254
|
+
repo,
|
|
255
|
+
fetchJson: options.fetchJson,
|
|
256
|
+
});
|
|
257
|
+
|
|
258
|
+
const installDir = path.dirname(execPath);
|
|
259
|
+
const binaryName =
|
|
260
|
+
platform.startsWith('win-') ? 'testlight.exe' : 'testlight';
|
|
261
|
+
const downloadUrl = buildDownloadUrl({
|
|
262
|
+
version: targetVersion,
|
|
263
|
+
platform,
|
|
264
|
+
repo,
|
|
265
|
+
releaseBaseUrl,
|
|
266
|
+
});
|
|
267
|
+
|
|
268
|
+
if (channel === 'npm') {
|
|
269
|
+
return {
|
|
270
|
+
channel,
|
|
271
|
+
platform,
|
|
272
|
+
currentVersion,
|
|
273
|
+
targetVersion,
|
|
274
|
+
installDir,
|
|
275
|
+
binaryName,
|
|
276
|
+
downloadUrl,
|
|
277
|
+
action: 'advise-npm',
|
|
278
|
+
advice:
|
|
279
|
+
`npm install -g mmt-testlight@${targetVersion}`,
|
|
280
|
+
};
|
|
281
|
+
}
|
|
282
|
+
if (channel === 'homebrew') {
|
|
283
|
+
return {
|
|
284
|
+
channel,
|
|
285
|
+
platform,
|
|
286
|
+
currentVersion,
|
|
287
|
+
targetVersion,
|
|
288
|
+
installDir,
|
|
289
|
+
binaryName,
|
|
290
|
+
downloadUrl,
|
|
291
|
+
action: 'advise-homebrew',
|
|
292
|
+
advice: 'brew upgrade mmt-testlight',
|
|
293
|
+
};
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
if (!options.force && compareVersions(currentVersion, targetVersion) >= 0) {
|
|
297
|
+
return {
|
|
298
|
+
channel: channel === 'unknown' ? 'standalone' : channel,
|
|
299
|
+
platform,
|
|
300
|
+
currentVersion,
|
|
301
|
+
targetVersion,
|
|
302
|
+
installDir,
|
|
303
|
+
binaryName,
|
|
304
|
+
downloadUrl,
|
|
305
|
+
action: 'noop',
|
|
306
|
+
advice: `Already up to date (v${currentVersion}).`,
|
|
307
|
+
};
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
return {
|
|
311
|
+
channel: channel === 'unknown' ? 'standalone' : channel,
|
|
312
|
+
platform,
|
|
313
|
+
currentVersion,
|
|
314
|
+
targetVersion,
|
|
315
|
+
installDir,
|
|
316
|
+
binaryName,
|
|
317
|
+
downloadUrl,
|
|
318
|
+
action: 'download',
|
|
319
|
+
};
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
function extractArchive(archivePath: string, destDir: string, platform: UpdatePlatform) {
|
|
323
|
+
if (platform.startsWith('win-')) {
|
|
324
|
+
if (process.platform === 'win32') {
|
|
325
|
+
execFileSync(
|
|
326
|
+
'powershell.exe',
|
|
327
|
+
[
|
|
328
|
+
'-NoProfile', '-Command',
|
|
329
|
+
`Expand-Archive -Path '${archivePath.replace(/'/g, "''")}' -DestinationPath '${destDir.replace(/'/g, "''")}' -Force`,
|
|
330
|
+
],
|
|
331
|
+
{stdio: 'ignore'});
|
|
332
|
+
return;
|
|
333
|
+
}
|
|
334
|
+
execFileSync('unzip', ['-q', archivePath, '-d', destDir], {stdio: 'ignore'});
|
|
335
|
+
return;
|
|
336
|
+
}
|
|
337
|
+
execFileSync('tar', ['-xzf', archivePath, '-C', destDir], {stdio: 'ignore'});
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
function findExtractedBinary(dir: string, binaryName: string): string {
|
|
341
|
+
const direct = path.join(dir, binaryName);
|
|
342
|
+
if (fs.existsSync(direct)) {
|
|
343
|
+
return direct;
|
|
344
|
+
}
|
|
345
|
+
const stack = [dir];
|
|
346
|
+
while (stack.length > 0) {
|
|
347
|
+
const cur = stack.pop()!;
|
|
348
|
+
for (const entry of fs.readdirSync(cur, {withFileTypes: true})) {
|
|
349
|
+
const full = path.join(cur, entry.name);
|
|
350
|
+
if (entry.isDirectory()) {
|
|
351
|
+
stack.push(full);
|
|
352
|
+
} else if (entry.name === binaryName) {
|
|
353
|
+
return full;
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
throw new Error(`Extracted archive did not contain ${binaryName}`);
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
function replaceBinary(source: string, dest: string, platform: UpdatePlatform) {
|
|
361
|
+
const destDir = path.dirname(dest);
|
|
362
|
+
fs.mkdirSync(destDir, {recursive: true});
|
|
363
|
+
if (platform.startsWith('win-')) {
|
|
364
|
+
const staging = `${dest}.new`;
|
|
365
|
+
const bak = `${dest}.bak`;
|
|
366
|
+
fs.copyFileSync(source, staging);
|
|
367
|
+
try {
|
|
368
|
+
if (fs.existsSync(dest)) {
|
|
369
|
+
try {
|
|
370
|
+
fs.unlinkSync(bak);
|
|
371
|
+
} catch {
|
|
372
|
+
}
|
|
373
|
+
fs.renameSync(dest, bak);
|
|
374
|
+
}
|
|
375
|
+
fs.renameSync(staging, dest);
|
|
376
|
+
try {
|
|
377
|
+
fs.unlinkSync(bak);
|
|
378
|
+
} catch {
|
|
379
|
+
// Windows may keep a lock on the running binary; leave .bak
|
|
380
|
+
}
|
|
381
|
+
} catch (error) {
|
|
382
|
+
throw new Error(
|
|
383
|
+
`Could not replace ${dest} while testlight is running. ` +
|
|
384
|
+
`Downloaded to ${staging}. Close other testlight processes and retry, ` +
|
|
385
|
+
`or copy manually. (${(error as Error).message})`);
|
|
386
|
+
}
|
|
387
|
+
const mmtCmd = path.join(destDir, 'mmt.cmd');
|
|
388
|
+
if (!fs.existsSync(mmtCmd)) {
|
|
389
|
+
fs.writeFileSync(
|
|
390
|
+
mmtCmd,
|
|
391
|
+
'@echo off\r\nsetlocal\r\n"%~dp0testlight.exe" %*\r\nexit /b %ERRORLEVEL%\r\n');
|
|
392
|
+
}
|
|
393
|
+
return;
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
const staging = `${dest}.new`;
|
|
397
|
+
fs.copyFileSync(source, staging);
|
|
398
|
+
fs.chmodSync(staging, 0o755);
|
|
399
|
+
fs.renameSync(staging, dest);
|
|
400
|
+
const mmt = path.join(destDir, 'mmt');
|
|
401
|
+
try {
|
|
402
|
+
fs.unlinkSync(mmt);
|
|
403
|
+
} catch {
|
|
404
|
+
}
|
|
405
|
+
fs.symlinkSync(dest, mmt);
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
export async function runUpdate(options: UpdateOptions): Promise<UpdateResult> {
|
|
409
|
+
const log = options.log || ((message: string) => console.error(message));
|
|
410
|
+
const plan = await planUpdate(options);
|
|
411
|
+
|
|
412
|
+
if (plan.action === 'advise-npm' || plan.action === 'advise-homebrew') {
|
|
413
|
+
return {
|
|
414
|
+
ok: true,
|
|
415
|
+
plan,
|
|
416
|
+
updated: false,
|
|
417
|
+
message:
|
|
418
|
+
`This testlight was installed via ${plan.channel}. Update with:\n ${plan.advice}`,
|
|
419
|
+
};
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
if (plan.action === 'noop') {
|
|
423
|
+
return {
|
|
424
|
+
ok: true,
|
|
425
|
+
plan,
|
|
426
|
+
updated: false,
|
|
427
|
+
message: plan.advice || `Already up to date (v${plan.currentVersion}).`,
|
|
428
|
+
};
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
if (options.checkOnly) {
|
|
432
|
+
return {
|
|
433
|
+
ok: true,
|
|
434
|
+
plan,
|
|
435
|
+
updated: false,
|
|
436
|
+
message:
|
|
437
|
+
`Update available: v${plan.currentVersion} → v${plan.targetVersion}\n` +
|
|
438
|
+
` ${plan.downloadUrl}`,
|
|
439
|
+
};
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
const download = options.downloadToFile || defaultDownloadToFile;
|
|
443
|
+
const tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'testlight-update-'));
|
|
444
|
+
const archiveExt = plan.platform.startsWith('win-') ? 'zip' : 'tar.gz';
|
|
445
|
+
const archivePath = path.join(tmpRoot, `testlight.${archiveExt}`);
|
|
446
|
+
const extractDir = path.join(tmpRoot, 'out');
|
|
447
|
+
fs.mkdirSync(extractDir, {recursive: true});
|
|
448
|
+
|
|
449
|
+
try {
|
|
450
|
+
log(`Downloading testlight v${plan.targetVersion} (${plan.platform})...`);
|
|
451
|
+
log(` ${plan.downloadUrl}`);
|
|
452
|
+
await download(plan.downloadUrl, archivePath);
|
|
453
|
+
log('Extracting...');
|
|
454
|
+
extractArchive(archivePath, extractDir, plan.platform);
|
|
455
|
+
const extracted = findExtractedBinary(extractDir, plan.binaryName);
|
|
456
|
+
const dest = path.join(plan.installDir, plan.binaryName);
|
|
457
|
+
log(`Installing to ${dest}`);
|
|
458
|
+
replaceBinary(extracted, dest, plan.platform);
|
|
459
|
+
return {
|
|
460
|
+
ok: true,
|
|
461
|
+
plan,
|
|
462
|
+
updated: true,
|
|
463
|
+
message: `Updated testlight v${plan.currentVersion} → v${plan.targetVersion} in ${plan.installDir}`,
|
|
464
|
+
};
|
|
465
|
+
} finally {
|
|
466
|
+
try {
|
|
467
|
+
fs.rmSync(tmpRoot, {recursive: true, force: true});
|
|
468
|
+
} catch {
|
|
469
|
+
}
|
|
470
|
+
}
|
|
471
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import fs from 'fs';
|
|
2
|
+
import path from 'path';
|
|
3
|
+
import * as mmtcore from 'mmt-core';
|
|
4
|
+
|
|
5
|
+
export function validateMmtFile(filePath: string, expectedType?: string): {
|
|
6
|
+
valid: boolean;
|
|
7
|
+
detectedType: string|null;
|
|
8
|
+
errors: string[];
|
|
9
|
+
} {
|
|
10
|
+
const content = fs.readFileSync(filePath, 'utf8');
|
|
11
|
+
const detectedType = mmtcore.JSer.fileType(filePath, content);
|
|
12
|
+
if (expectedType && detectedType && detectedType !== expectedType) {
|
|
13
|
+
return {
|
|
14
|
+
valid: false,
|
|
15
|
+
detectedType,
|
|
16
|
+
errors: [`Expected type "${expectedType}" but detected "${detectedType}"`],
|
|
17
|
+
};
|
|
18
|
+
}
|
|
19
|
+
const type = expectedType || detectedType;
|
|
20
|
+
try {
|
|
21
|
+
if (type === 'api') {
|
|
22
|
+
mmtcore.apiParsePack.yamlToAPIStrict(content);
|
|
23
|
+
} else if (type === 'test' || !type) {
|
|
24
|
+
mmtcore.testParsePack.yamlToTestStrict(content);
|
|
25
|
+
} else {
|
|
26
|
+
return {
|
|
27
|
+
valid: false,
|
|
28
|
+
detectedType,
|
|
29
|
+
errors: [`Validation for type "${type}" is not implemented yet`],
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
return {valid: true, detectedType: type || detectedType, errors: []};
|
|
33
|
+
} catch (error: any) {
|
|
34
|
+
const message = error?.message || String(error);
|
|
35
|
+
return {
|
|
36
|
+
valid: false,
|
|
37
|
+
detectedType,
|
|
38
|
+
errors: message.split('\n').filter(Boolean),
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function resolveValidatePath(file: string, cwd = process.cwd()): string {
|
|
44
|
+
return path.isAbsolute(file) ? file : path.resolve(cwd, file);
|
|
45
|
+
}
|