mbflash 0.0.0-stage → 1.2.1
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/LICENSE +21 -0
- package/README.md +264 -2
- package/mbflash.mjs +1451 -0
- package/package.json +42 -4
package/mbflash.mjs
ADDED
|
@@ -0,0 +1,1451 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* mbflash — load, build and flash a micro:bit MakeCode project over CDP.
|
|
4
|
+
*
|
|
5
|
+
* Requirements: Node 22+ (built-in WebSocket), a headed Chromium started with
|
|
6
|
+
* --remote-debugging-port=9222, and the micro:bit mounted as MICROBIT.
|
|
7
|
+
*
|
|
8
|
+
* This is deliberately dependency-free: it speaks CDP directly and writes the
|
|
9
|
+
* .hex to the DAPLink MSC volume, so it does not depend on WebUSB or pairing.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import fs from "node:fs";
|
|
13
|
+
import path from "node:path";
|
|
14
|
+
import os from "node:os";
|
|
15
|
+
import { execFileSync } from "node:child_process";
|
|
16
|
+
|
|
17
|
+
const [nodeMajor, nodeMinor] = process.versions.node.split(".").map(Number);
|
|
18
|
+
if (nodeMajor < 22 || (nodeMajor === 22 && nodeMinor < 4) || typeof globalThis.WebSocket !== "function") {
|
|
19
|
+
console.error("mbflash requires Node.js 22.4+ (built-in WebSocket).");
|
|
20
|
+
process.exit(1);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const VERSION = "1.2.0";
|
|
24
|
+
const DEFAULT_HOST = "makecode.microbit.org";
|
|
25
|
+
const DEFAULT_CDP = "127.0.0.1:9222";
|
|
26
|
+
|
|
27
|
+
// ---------------------------------------------------------------------------
|
|
28
|
+
// logging
|
|
29
|
+
// ---------------------------------------------------------------------------
|
|
30
|
+
|
|
31
|
+
const ui = { json: false, quiet: false, verbose: false, warnings: [] };
|
|
32
|
+
|
|
33
|
+
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
34
|
+
const fmt = (ms) => (ms >= 1000 ? `${(ms / 1000).toFixed(1)}s` : `${ms}ms`);
|
|
35
|
+
|
|
36
|
+
function out(...a) {
|
|
37
|
+
if (!ui.json) console.log(...a);
|
|
38
|
+
}
|
|
39
|
+
function step(...a) {
|
|
40
|
+
if (!ui.json && !ui.quiet) console.error(...a);
|
|
41
|
+
}
|
|
42
|
+
function debug(...a) {
|
|
43
|
+
if (!ui.json && ui.verbose) console.error(...a);
|
|
44
|
+
}
|
|
45
|
+
function warn(msg) {
|
|
46
|
+
ui.warnings.push(String(msg));
|
|
47
|
+
if (!ui.json && !ui.quiet) console.error(`warning: ${msg}`);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// ---------------------------------------------------------------------------
|
|
51
|
+
// arguments
|
|
52
|
+
// ---------------------------------------------------------------------------
|
|
53
|
+
|
|
54
|
+
function usage(msg) {
|
|
55
|
+
console.error(`mbflash: ${msg}`);
|
|
56
|
+
console.error("Try 'mbflash --help'.");
|
|
57
|
+
process.exit(2);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function defaults() {
|
|
61
|
+
const cache = process.env.XDG_CACHE_HOME || path.join(os.homedir(), ".cache");
|
|
62
|
+
return {
|
|
63
|
+
cdp: process.env.MBFLASH_CDP || DEFAULT_CDP,
|
|
64
|
+
host: undefined,
|
|
65
|
+
url: undefined,
|
|
66
|
+
title: undefined,
|
|
67
|
+
target: undefined,
|
|
68
|
+
index: undefined,
|
|
69
|
+
project: undefined,
|
|
70
|
+
label: undefined, // deprecated alias for --project
|
|
71
|
+
switch: false,
|
|
72
|
+
create: true,
|
|
73
|
+
retitle: false,
|
|
74
|
+
focus: true,
|
|
75
|
+
source: undefined,
|
|
76
|
+
loadOnly: false,
|
|
77
|
+
hexOnly: false,
|
|
78
|
+
flashOnly: false,
|
|
79
|
+
flashHex: undefined,
|
|
80
|
+
noBlocks: false,
|
|
81
|
+
roundtrip: false,
|
|
82
|
+
downloadDir: process.env.MBFLASH_DOWNLOAD_DIR || path.join(cache, "mbflash", "hex"),
|
|
83
|
+
hexOut: undefined,
|
|
84
|
+
device: process.env.MBFLASH_DEVICE || undefined,
|
|
85
|
+
waitSec: 60,
|
|
86
|
+
timeoutSec: 120,
|
|
87
|
+
json: false,
|
|
88
|
+
quiet: false,
|
|
89
|
+
verbose: false,
|
|
90
|
+
list: false,
|
|
91
|
+
help: false,
|
|
92
|
+
version: false,
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function parseArgs(argv) {
|
|
97
|
+
const o = defaults();
|
|
98
|
+
let source;
|
|
99
|
+
|
|
100
|
+
for (let i = 0; i < argv.length; i++) {
|
|
101
|
+
const a = argv[i];
|
|
102
|
+
|
|
103
|
+
if (a === "--") {
|
|
104
|
+
for (const rest of argv.slice(i + 1)) {
|
|
105
|
+
if (source !== undefined) usage("only one source file is supported");
|
|
106
|
+
source = rest;
|
|
107
|
+
}
|
|
108
|
+
break;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
if (a === "-" || !a.startsWith("-")) {
|
|
112
|
+
if (source !== undefined) usage("only one source file is supported");
|
|
113
|
+
source = a;
|
|
114
|
+
continue;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
const eq = a.indexOf("=");
|
|
118
|
+
const key = eq >= 0 ? a.slice(0, eq) : a;
|
|
119
|
+
const inline = eq >= 0 ? a.slice(eq + 1) : undefined;
|
|
120
|
+
const value = () => {
|
|
121
|
+
if (inline !== undefined) return inline;
|
|
122
|
+
if (i + 1 >= argv.length) usage(`${key} requires a value`);
|
|
123
|
+
return argv[++i];
|
|
124
|
+
};
|
|
125
|
+
const number = (name) => {
|
|
126
|
+
const n = Number(value());
|
|
127
|
+
if (!Number.isFinite(n) || n <= 0) usage(`${name} must be a positive number`);
|
|
128
|
+
return n;
|
|
129
|
+
};
|
|
130
|
+
|
|
131
|
+
switch (key) {
|
|
132
|
+
case "-h":
|
|
133
|
+
case "--help":
|
|
134
|
+
o.help = true;
|
|
135
|
+
break;
|
|
136
|
+
case "-V":
|
|
137
|
+
case "--version":
|
|
138
|
+
o.version = true;
|
|
139
|
+
break;
|
|
140
|
+
case "-j":
|
|
141
|
+
case "--json":
|
|
142
|
+
o.json = true;
|
|
143
|
+
break;
|
|
144
|
+
case "-q":
|
|
145
|
+
case "--quiet":
|
|
146
|
+
o.quiet = true;
|
|
147
|
+
break;
|
|
148
|
+
case "-v":
|
|
149
|
+
case "--verbose":
|
|
150
|
+
o.verbose = true;
|
|
151
|
+
break;
|
|
152
|
+
case "--list":
|
|
153
|
+
o.list = true;
|
|
154
|
+
break;
|
|
155
|
+
case "--cdp":
|
|
156
|
+
o.cdp = value();
|
|
157
|
+
break;
|
|
158
|
+
case "--host":
|
|
159
|
+
o.host = value();
|
|
160
|
+
break;
|
|
161
|
+
case "--url":
|
|
162
|
+
o.url = value();
|
|
163
|
+
break;
|
|
164
|
+
case "--title":
|
|
165
|
+
o.title = value();
|
|
166
|
+
break;
|
|
167
|
+
case "--target":
|
|
168
|
+
o.target = value();
|
|
169
|
+
break;
|
|
170
|
+
case "--project":
|
|
171
|
+
o.project = value();
|
|
172
|
+
break;
|
|
173
|
+
case "--label":
|
|
174
|
+
// Deprecated alias; matching is by MakeCode project name.
|
|
175
|
+
o.label = value();
|
|
176
|
+
break;
|
|
177
|
+
case "--set-label":
|
|
178
|
+
usage("--set-label is gone: tabs are identified by the MakeCode project name; use --project <name>");
|
|
179
|
+
break;
|
|
180
|
+
case "--switch":
|
|
181
|
+
o.switch = true;
|
|
182
|
+
break;
|
|
183
|
+
case "--no-create":
|
|
184
|
+
o.create = false;
|
|
185
|
+
break;
|
|
186
|
+
case "--retitle":
|
|
187
|
+
o.retitle = true;
|
|
188
|
+
break;
|
|
189
|
+
case "--no-focus":
|
|
190
|
+
o.focus = false;
|
|
191
|
+
break;
|
|
192
|
+
case "--index": {
|
|
193
|
+
const n = Number(value());
|
|
194
|
+
if (!Number.isInteger(n) || n < 1) usage("--index must be a positive integer");
|
|
195
|
+
o.index = n;
|
|
196
|
+
break;
|
|
197
|
+
}
|
|
198
|
+
case "--load-only":
|
|
199
|
+
case "--load":
|
|
200
|
+
o.loadOnly = true;
|
|
201
|
+
break;
|
|
202
|
+
case "--hex-only":
|
|
203
|
+
case "--build-only":
|
|
204
|
+
case "--no-flash":
|
|
205
|
+
o.hexOnly = true;
|
|
206
|
+
break;
|
|
207
|
+
case "--flash-only":
|
|
208
|
+
o.flashOnly = true;
|
|
209
|
+
break;
|
|
210
|
+
case "--flash-hex":
|
|
211
|
+
o.flashHex = value();
|
|
212
|
+
break;
|
|
213
|
+
case "--no-blocks":
|
|
214
|
+
o.noBlocks = true;
|
|
215
|
+
break;
|
|
216
|
+
case "--roundtrip":
|
|
217
|
+
o.roundtrip = true;
|
|
218
|
+
break;
|
|
219
|
+
case "--download-dir":
|
|
220
|
+
o.downloadDir = value();
|
|
221
|
+
break;
|
|
222
|
+
case "--hex-out":
|
|
223
|
+
o.hexOut = value();
|
|
224
|
+
break;
|
|
225
|
+
case "--device":
|
|
226
|
+
o.device = value();
|
|
227
|
+
break;
|
|
228
|
+
case "--wait":
|
|
229
|
+
o.waitSec = number("--wait");
|
|
230
|
+
break;
|
|
231
|
+
case "--timeout":
|
|
232
|
+
o.timeoutSec = number("--timeout");
|
|
233
|
+
break;
|
|
234
|
+
default:
|
|
235
|
+
usage(`unknown option ${key}`);
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
o.source = source;
|
|
240
|
+
if (o.project && o.label && o.project !== o.label) {
|
|
241
|
+
usage("--project and --label must name the same project");
|
|
242
|
+
}
|
|
243
|
+
o.project = o.project || o.label;
|
|
244
|
+
o.timeoutMs = Math.round(o.timeoutSec * 1000);
|
|
245
|
+
o.waitMs = Math.round(o.waitSec * 1000);
|
|
246
|
+
return o;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
function resolveMode(o) {
|
|
250
|
+
const explicit = [o.loadOnly, o.hexOnly, o.flashOnly, !!o.flashHex].filter(Boolean);
|
|
251
|
+
if (explicit.length > 1) {
|
|
252
|
+
usage("choose only one of --load-only, --hex-only, --flash-only, --flash-hex");
|
|
253
|
+
}
|
|
254
|
+
if (o.retitle && !o.project) usage("--retitle needs --project (or the --label alias)");
|
|
255
|
+
|
|
256
|
+
if (o.switch) {
|
|
257
|
+
if (o.source !== undefined || o.loadOnly || o.hexOnly || o.flashOnly || o.flashHex) {
|
|
258
|
+
usage("--switch only selects/focuses a tab; do not combine it with a source or build/flash mode");
|
|
259
|
+
}
|
|
260
|
+
if (o.noBlocks || o.roundtrip) usage("--no-blocks/--roundtrip only apply when loading a source");
|
|
261
|
+
return "switch";
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
if (o.flashHex) {
|
|
265
|
+
if (o.source !== undefined) usage("--flash-hex does not take a source file");
|
|
266
|
+
if (o.project || o.retitle) usage("--project/--retitle require a browser tab");
|
|
267
|
+
if (o.noBlocks || o.roundtrip) usage("--no-blocks/--roundtrip only apply when loading a source");
|
|
268
|
+
return "flash-hex";
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
if (o.loadOnly) {
|
|
272
|
+
if (!o.source) usage("--load-only requires a source file");
|
|
273
|
+
return "load";
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
if (o.flashOnly) {
|
|
277
|
+
if (o.source !== undefined) {
|
|
278
|
+
usage("--flash-only builds the code already in the editor; drop the source file");
|
|
279
|
+
}
|
|
280
|
+
if (o.noBlocks || o.roundtrip) usage("--no-blocks/--roundtrip only apply when loading a source");
|
|
281
|
+
return "build+flash";
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
if (o.hexOnly) {
|
|
285
|
+
if (o.noBlocks || o.roundtrip) {
|
|
286
|
+
if (!o.source) usage("--no-blocks/--roundtrip require a source file");
|
|
287
|
+
}
|
|
288
|
+
return o.source ? "load+build" : "build";
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
if (o.source) return "load+build+flash";
|
|
292
|
+
usage(
|
|
293
|
+
"provide a source file (e.g. mbflash main.ts) or a mode: " +
|
|
294
|
+
"--load-only, --hex-only, --flash-only, --flash-hex",
|
|
295
|
+
);
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
function printHelp() {
|
|
299
|
+
console.log(`mbflash ${VERSION} — load, build and flash a micro:bit MakeCode project over CDP
|
|
300
|
+
|
|
301
|
+
Usage:
|
|
302
|
+
mbflash [options] [main.ts]
|
|
303
|
+
|
|
304
|
+
Modes:
|
|
305
|
+
(default, with file) load main.ts -> Blocks -> build -> flash
|
|
306
|
+
--load-only load main.ts -> Blocks (no build/flash)
|
|
307
|
+
--hex-only [file] load [file] -> build/download (no flash)
|
|
308
|
+
--flash-only build/flash the code already in the editor
|
|
309
|
+
--flash-hex <file> flash an existing .hex (no CDP)
|
|
310
|
+
--switch focus the selected tab and exit (no build)
|
|
311
|
+
|
|
312
|
+
Target selection:
|
|
313
|
+
--cdp <[ip:]port> CDP endpoint (default 127.0.0.1:9222)
|
|
314
|
+
--host <hostname> tab hostname match (default ${DEFAULT_HOST})
|
|
315
|
+
--url <substr|/re/> tab URL match
|
|
316
|
+
--title <substr> tab title match
|
|
317
|
+
--label <name> deprecated alias for --project
|
|
318
|
+
--project <name> select by MakeCode project name (creates if missing)
|
|
319
|
+
--retitle set the tab title to "MakeCode — <project>"
|
|
320
|
+
--no-create with --project, fail instead of creating a project
|
|
321
|
+
--target <id> exact CDP page target id (from --list)
|
|
322
|
+
--index <n> 1-based choice among matches
|
|
323
|
+
--no-focus do not bring the selected tab to front
|
|
324
|
+
--list list targets, ids and project names, then exit
|
|
325
|
+
|
|
326
|
+
Output and device:
|
|
327
|
+
--download-dir <dir> where the browser writes the .hex
|
|
328
|
+
--hex-out <path> copy the built .hex here
|
|
329
|
+
--device <mount> MICROBIT mount (default: auto-detect)
|
|
330
|
+
--wait <seconds> flash timeout (default 60)
|
|
331
|
+
--timeout <seconds> build/download timeout (default 120)
|
|
332
|
+
|
|
333
|
+
Loading:
|
|
334
|
+
--no-blocks load TS only; do not convert to Blocks
|
|
335
|
+
--roundtrip JS -> Blocks -> JS check after loading
|
|
336
|
+
|
|
337
|
+
Other:
|
|
338
|
+
-j, --json machine-readable summary on stdout
|
|
339
|
+
-q, --quiet suppress progress
|
|
340
|
+
-v, --verbose more detail
|
|
341
|
+
-h, --help this help
|
|
342
|
+
-V, --version print the version
|
|
343
|
+
|
|
344
|
+
Examples:
|
|
345
|
+
mbflash main.ts
|
|
346
|
+
mbflash --load-only main.ts
|
|
347
|
+
mbflash --hex-only --download-dir ./build
|
|
348
|
+
mbflash --flash-only
|
|
349
|
+
mbflash --flash-hex demo.hex
|
|
350
|
+
mbflash --cdp 192.168.1.50:9222 --url makecode main.ts
|
|
351
|
+
mbflash --list
|
|
352
|
+
mbflash --project Test1 --switch --retitle
|
|
353
|
+
mbflash --project Test2 main.ts
|
|
354
|
+
mbflash --project Test3 main.ts # creates Test3 if it does not exist
|
|
355
|
+
mbflash --label Test1 --switch # deprecated alias for --project
|
|
356
|
+
`);
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
// ---------------------------------------------------------------------------
|
|
360
|
+
// CDP discovery and transport
|
|
361
|
+
// ---------------------------------------------------------------------------
|
|
362
|
+
|
|
363
|
+
function parseCdpTarget(value) {
|
|
364
|
+
let s = String(value || DEFAULT_CDP).trim();
|
|
365
|
+
if (/^\d+$/.test(s)) s = `127.0.0.1:${s}`;
|
|
366
|
+
if (!/^[a-z][a-z0-9+.-]*:\/\//i.test(s)) s = `http://${s}`;
|
|
367
|
+
|
|
368
|
+
let u;
|
|
369
|
+
try {
|
|
370
|
+
u = new URL(s);
|
|
371
|
+
} catch {
|
|
372
|
+
throw new Error(`invalid CDP target: ${value}`);
|
|
373
|
+
}
|
|
374
|
+
if (u.protocol !== "http:" && u.protocol !== "https:") {
|
|
375
|
+
throw new Error(`CDP target must be http(s):// or [ip:]port: ${value}`);
|
|
376
|
+
}
|
|
377
|
+
return {
|
|
378
|
+
base: u.origin,
|
|
379
|
+
host: u.hostname,
|
|
380
|
+
port: u.port || (u.protocol === "https:" ? "443" : "80"),
|
|
381
|
+
secure: u.protocol === "https:",
|
|
382
|
+
};
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
async function fetchPages(target) {
|
|
386
|
+
let res;
|
|
387
|
+
try {
|
|
388
|
+
res = await fetch(`${target.base}/json/list`, { signal: AbortSignal.timeout(5000) });
|
|
389
|
+
} catch (e) {
|
|
390
|
+
throw new Error(`cannot reach CDP at ${target.base}: ${e.message}`);
|
|
391
|
+
}
|
|
392
|
+
if (!res.ok) throw new Error(`CDP ${target.base}/json/list returned ${res.status}`);
|
|
393
|
+
const all = await res.json();
|
|
394
|
+
return all.filter((t) => t.type === "page");
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
function matchPattern(pattern, text) {
|
|
398
|
+
if (pattern.length > 2 && pattern.startsWith("/") && pattern.lastIndexOf("/") > 0) {
|
|
399
|
+
const end = pattern.lastIndexOf("/");
|
|
400
|
+
try {
|
|
401
|
+
return new RegExp(pattern.slice(1, end), pattern.slice(end + 1)).test(text);
|
|
402
|
+
} catch {
|
|
403
|
+
// fall through to substring
|
|
404
|
+
}
|
|
405
|
+
}
|
|
406
|
+
return text.toLowerCase().includes(pattern.toLowerCase());
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
function metadataMatches(p, o, useHost) {
|
|
410
|
+
if (useHost) {
|
|
411
|
+
try {
|
|
412
|
+
if (new URL(p.url).hostname !== useHost) return false;
|
|
413
|
+
} catch {
|
|
414
|
+
return false;
|
|
415
|
+
}
|
|
416
|
+
}
|
|
417
|
+
if (o.url && !matchPattern(o.url, p.url)) return false;
|
|
418
|
+
if (o.title && !String(p.title || "").toLowerCase().includes(o.title.toLowerCase())) {
|
|
419
|
+
return false;
|
|
420
|
+
}
|
|
421
|
+
return true;
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
async function pageProjectName(page) {
|
|
425
|
+
let c;
|
|
426
|
+
try {
|
|
427
|
+
c = await Cdp.connect(page.webSocketDebuggerUrl, 5000);
|
|
428
|
+
const v = await c.ev(
|
|
429
|
+
`(() => {
|
|
430
|
+
const el = document.querySelector('#fileNameInput2');
|
|
431
|
+
if (el && el.value && el.value.trim()) return el.value.trim();
|
|
432
|
+
// Fallback: an open pxt.json model (normally not loaded).
|
|
433
|
+
if (typeof monaco !== 'undefined' && monaco.editor) {
|
|
434
|
+
const m = monaco.editor.getModels().find(x => x.uri.toString().includes('pxt.json'));
|
|
435
|
+
if (m) {
|
|
436
|
+
try {
|
|
437
|
+
const n = JSON.parse(m.getValue()).name;
|
|
438
|
+
if (n) return String(n).trim();
|
|
439
|
+
} catch (e) {}
|
|
440
|
+
}
|
|
441
|
+
}
|
|
442
|
+
return '';
|
|
443
|
+
})()`,
|
|
444
|
+
5000,
|
|
445
|
+
);
|
|
446
|
+
return v || null;
|
|
447
|
+
} catch {
|
|
448
|
+
return null;
|
|
449
|
+
} finally {
|
|
450
|
+
c?.close();
|
|
451
|
+
}
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
async function selectPage(pages, o, { allowMissingProject = false } = {}) {
|
|
455
|
+
if (!pages.length) {
|
|
456
|
+
if (allowMissingProject && o.project) return null;
|
|
457
|
+
throw new Error("no page targets at the CDP endpoint");
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
if (o.target) {
|
|
461
|
+
const p = pages.find((x) => x.id === o.target);
|
|
462
|
+
if (!p) throw new Error(`target ${o.target} not found; use --list`);
|
|
463
|
+
return p;
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
const useHost = o.host || (!o.url && !o.title ? DEFAULT_HOST : null);
|
|
467
|
+
let matches = pages.filter((p) => metadataMatches(p, o, useHost));
|
|
468
|
+
|
|
469
|
+
if (o.project) {
|
|
470
|
+
const projects = await Promise.all(matches.map((p) => pageProjectName(p)));
|
|
471
|
+
matches = matches.filter((p, i) => projects[i] === o.project);
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
if (!matches.length) {
|
|
475
|
+
if (allowMissingProject && o.project) return null;
|
|
476
|
+
throw new Error(o.project ? `no tab has project "${o.project}"; use --list` : "no tab matched; use --list to see page targets");
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
if (o.index) {
|
|
480
|
+
if (o.index > matches.length) {
|
|
481
|
+
throw new Error(`--index ${o.index} is out of range (${matches.length} match${matches.length === 1 ? "" : "es"})`);
|
|
482
|
+
}
|
|
483
|
+
return matches[o.index - 1];
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
if (matches.length === 1) return matches[0];
|
|
487
|
+
|
|
488
|
+
const list = matches.map((p) => ` [${p.id}] ${p.url}`).join("\n");
|
|
489
|
+
throw new Error(`${matches.length} tabs matched; use --target <id>, --project <name>, or --index <n>:\n${list}`);
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
function makecodeHomeUrl(target, pages, o) {
|
|
493
|
+
const host = o.host || DEFAULT_HOST;
|
|
494
|
+
const existing = pages.find((p) => {
|
|
495
|
+
try {
|
|
496
|
+
return new URL(p.url).hostname === host;
|
|
497
|
+
} catch {
|
|
498
|
+
return false;
|
|
499
|
+
}
|
|
500
|
+
});
|
|
501
|
+
if (existing) {
|
|
502
|
+
try {
|
|
503
|
+
return new URL(existing.url).origin + "/";
|
|
504
|
+
} catch {
|
|
505
|
+
/* fall through */
|
|
506
|
+
}
|
|
507
|
+
}
|
|
508
|
+
return `https://${host}/`;
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
async function createNewProjectTab(target, name, o, pages) {
|
|
512
|
+
const version = await fetch(`${target.base}/json/version`, { signal: AbortSignal.timeout(5000) }).then((r) => r.json());
|
|
513
|
+
if (!version.webSocketDebuggerUrl) throw new Error("CDP endpoint did not advertise a browser websocket");
|
|
514
|
+
|
|
515
|
+
const browser = await Cdp.connect(version.webSocketDebuggerUrl, 10000);
|
|
516
|
+
let targetId;
|
|
517
|
+
try {
|
|
518
|
+
const url = makecodeHomeUrl(target, pages, o);
|
|
519
|
+
debug(`creating tab at ${url}`);
|
|
520
|
+
const r = await browser.send("Target.createTarget", { url }, 15000);
|
|
521
|
+
targetId = r.targetId;
|
|
522
|
+
} finally {
|
|
523
|
+
browser.close();
|
|
524
|
+
}
|
|
525
|
+
if (!targetId) throw new Error("Target.createTarget returned no targetId");
|
|
526
|
+
|
|
527
|
+
const deadline = Date.now() + 20000;
|
|
528
|
+
for (;;) {
|
|
529
|
+
const now = await fetchPages(target);
|
|
530
|
+
const page = now.find((p) => p.id === targetId);
|
|
531
|
+
if (page) return page;
|
|
532
|
+
if (Date.now() > deadline) throw new Error("new tab did not appear at the CDP endpoint");
|
|
533
|
+
await sleep(250);
|
|
534
|
+
}
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
async function initNewProject(cdp, name) {
|
|
538
|
+
const deadline = Date.now() + 30000;
|
|
539
|
+
let openedDialog = false;
|
|
540
|
+
|
|
541
|
+
// Wait for the home page (or a dialog already open), then open New Project.
|
|
542
|
+
for (;;) {
|
|
543
|
+
const s = await cdp
|
|
544
|
+
.ev(`(() => ({
|
|
545
|
+
href: location.href,
|
|
546
|
+
card: !!document.querySelector('.newprojectcard'),
|
|
547
|
+
menu: !!document.querySelector('[aria-label="New Project"]'),
|
|
548
|
+
input: !!document.querySelector('#projectNameInput'),
|
|
549
|
+
editor: !!document.querySelector('[aria-label="Convert code to Blocks"]'),
|
|
550
|
+
}))()`)
|
|
551
|
+
.catch(() => ({ href: "", card: false, menu: false, input: false, editor: false }));
|
|
552
|
+
|
|
553
|
+
if (s.input) break;
|
|
554
|
+
if (s.editor) {
|
|
555
|
+
// Already in an editor (unexpected for a fresh tab, but harmless).
|
|
556
|
+
const projectName = await cdp.ev(`document.querySelector('#fileNameInput2')?.value || ''`).catch(() => "");
|
|
557
|
+
return { projectName: projectName || null, alreadyEditor: true };
|
|
558
|
+
}
|
|
559
|
+
if ((s.card || s.menu) && !openedDialog) {
|
|
560
|
+
const clicked = await cdp
|
|
561
|
+
.ev(`(() => {
|
|
562
|
+
const b = document.querySelector('.newprojectcard') || document.querySelector('[aria-label="New Project"]');
|
|
563
|
+
if (!b) return false;
|
|
564
|
+
b.click();
|
|
565
|
+
return true;
|
|
566
|
+
})()`)
|
|
567
|
+
.catch(() => false);
|
|
568
|
+
debug(`clicked New Project: ${clicked}`);
|
|
569
|
+
if (clicked) openedDialog = true;
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
if (Date.now() > deadline) throw new Error("New Project dialog did not appear");
|
|
573
|
+
await sleep(300);
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
const setValue = await cdp.ev(`(() => {
|
|
577
|
+
const input = document.querySelector('#projectNameInput');
|
|
578
|
+
if (!input) return false;
|
|
579
|
+
const setter = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value').set;
|
|
580
|
+
setter.call(input, ${JSON.stringify(name)});
|
|
581
|
+
input.dispatchEvent(new Event('input', { bubbles: true }));
|
|
582
|
+
input.dispatchEvent(new Event('change', { bubbles: true }));
|
|
583
|
+
return input.value;
|
|
584
|
+
})()`);
|
|
585
|
+
debug(`project name input: ${setValue}`);
|
|
586
|
+
if (!setValue) throw new Error("could not set the new project name");
|
|
587
|
+
|
|
588
|
+
await sleep(250);
|
|
589
|
+
const create = await cdp.ev(`(() => {
|
|
590
|
+
const b = [...document.querySelectorAll('.ReactModal__Content button, .ui.modal button')]
|
|
591
|
+
.find(x => (x.textContent || '').trim() === 'Create');
|
|
592
|
+
if (!b) return 'no-button';
|
|
593
|
+
if (b.disabled) return 'disabled';
|
|
594
|
+
b.click();
|
|
595
|
+
return 'clicked';
|
|
596
|
+
})()`).catch(() => "navigated");
|
|
597
|
+
if (create !== "clicked" && create !== "navigated") {
|
|
598
|
+
throw new Error(`could not submit the New Project dialog (${create})`);
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
// Clicking Create navigates the tab to #editor; the target id stays the same.
|
|
602
|
+
await waitForEditor(cdp);
|
|
603
|
+
|
|
604
|
+
// The hash can settle a moment after the editor DOM appears.
|
|
605
|
+
for (let i = 0; i < 20; i++) {
|
|
606
|
+
const href = await cdp.ev(`location.href`).catch(() => "");
|
|
607
|
+
if (href.includes("#editor")) break;
|
|
608
|
+
await sleep(100);
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
const projectName = await cdp.ev(`document.querySelector('#fileNameInput2')?.value || ''`).catch(() => "");
|
|
612
|
+
if (projectName && projectName !== name) {
|
|
613
|
+
warn(`project name is "${projectName}", expected "${name}"`);
|
|
614
|
+
}
|
|
615
|
+
return { projectName: projectName || name };
|
|
616
|
+
}
|
|
617
|
+
|
|
618
|
+
function normalizeWsUrl(page, target) {
|
|
619
|
+
if (!page.webSocketDebuggerUrl) throw new Error("selected target has no webSocketDebuggerUrl");
|
|
620
|
+
const ws = new URL(page.webSocketDebuggerUrl);
|
|
621
|
+
const t = new URL(target.base);
|
|
622
|
+
const loop = ["127.0.0.1", "localhost", "[::1]"];
|
|
623
|
+
if (loop.includes(ws.hostname) && !loop.includes(t.hostname)) ws.host = t.host;
|
|
624
|
+
if (target.secure && ws.protocol === "ws:") ws.protocol = "wss:";
|
|
625
|
+
return ws.toString();
|
|
626
|
+
}
|
|
627
|
+
|
|
628
|
+
class Cdp {
|
|
629
|
+
constructor(ws) {
|
|
630
|
+
this.ws = ws;
|
|
631
|
+
this.seq = 0;
|
|
632
|
+
this.pending = new Map();
|
|
633
|
+
this.closed = false;
|
|
634
|
+
|
|
635
|
+
ws.onmessage = (e) => {
|
|
636
|
+
let m;
|
|
637
|
+
try {
|
|
638
|
+
m = JSON.parse(e.data);
|
|
639
|
+
} catch {
|
|
640
|
+
return;
|
|
641
|
+
}
|
|
642
|
+
if (!m.id || !this.pending.has(m.id)) return;
|
|
643
|
+
const { resolve, reject, timer } = this.pending.get(m.id);
|
|
644
|
+
this.pending.delete(m.id);
|
|
645
|
+
clearTimeout(timer);
|
|
646
|
+
if (m.error) reject(new Error(m.error.message || JSON.stringify(m.error)));
|
|
647
|
+
else resolve(m.result);
|
|
648
|
+
};
|
|
649
|
+
|
|
650
|
+
ws.onclose = () => {
|
|
651
|
+
this.closed = true;
|
|
652
|
+
for (const { reject, timer } of this.pending.values()) {
|
|
653
|
+
clearTimeout(timer);
|
|
654
|
+
reject(new Error("CDP connection closed"));
|
|
655
|
+
}
|
|
656
|
+
this.pending.clear();
|
|
657
|
+
};
|
|
658
|
+
}
|
|
659
|
+
|
|
660
|
+
static async connect(url, timeoutMs = 10000) {
|
|
661
|
+
const ws = new WebSocket(url);
|
|
662
|
+
await new Promise((resolve, reject) => {
|
|
663
|
+
const timer = setTimeout(() => reject(new Error("CDP websocket connect timed out")), timeoutMs);
|
|
664
|
+
ws.onopen = () => {
|
|
665
|
+
clearTimeout(timer);
|
|
666
|
+
resolve();
|
|
667
|
+
};
|
|
668
|
+
ws.onerror = () => {
|
|
669
|
+
clearTimeout(timer);
|
|
670
|
+
reject(new Error("CDP websocket connection failed"));
|
|
671
|
+
};
|
|
672
|
+
});
|
|
673
|
+
return new Cdp(ws);
|
|
674
|
+
}
|
|
675
|
+
|
|
676
|
+
send(method, params = {}, timeoutMs = 30000) {
|
|
677
|
+
if (this.closed) return Promise.reject(new Error("CDP connection closed"));
|
|
678
|
+
const id = ++this.seq;
|
|
679
|
+
return new Promise((resolve, reject) => {
|
|
680
|
+
const timer = setTimeout(() => {
|
|
681
|
+
this.pending.delete(id);
|
|
682
|
+
reject(new Error(`${method} timed out after ${timeoutMs}ms`));
|
|
683
|
+
}, timeoutMs);
|
|
684
|
+
this.pending.set(id, { resolve, reject, timer });
|
|
685
|
+
try {
|
|
686
|
+
this.ws.send(JSON.stringify({ id, method, params }));
|
|
687
|
+
} catch (e) {
|
|
688
|
+
clearTimeout(timer);
|
|
689
|
+
this.pending.delete(id);
|
|
690
|
+
reject(e);
|
|
691
|
+
}
|
|
692
|
+
});
|
|
693
|
+
}
|
|
694
|
+
|
|
695
|
+
async ev(expression, timeoutMs = 30000) {
|
|
696
|
+
const r = await this.send(
|
|
697
|
+
"Runtime.evaluate",
|
|
698
|
+
{ expression, returnByValue: true, awaitPromise: true, userGesture: true },
|
|
699
|
+
timeoutMs,
|
|
700
|
+
);
|
|
701
|
+
if (r.exceptionDetails) {
|
|
702
|
+
const d = r.exceptionDetails;
|
|
703
|
+
throw new Error(d.exception?.description || d.text || "evaluation failed");
|
|
704
|
+
}
|
|
705
|
+
return r.result?.value;
|
|
706
|
+
}
|
|
707
|
+
|
|
708
|
+
close() {
|
|
709
|
+
try {
|
|
710
|
+
this.ws.close();
|
|
711
|
+
} catch {
|
|
712
|
+
/* ignore */
|
|
713
|
+
}
|
|
714
|
+
}
|
|
715
|
+
}
|
|
716
|
+
|
|
717
|
+
async function listTargets(o) {
|
|
718
|
+
const target = parseCdpTarget(o.cdp);
|
|
719
|
+
const pages = await fetchPages(target);
|
|
720
|
+
const projects = await Promise.all(pages.map((p) => pageProjectName(p)));
|
|
721
|
+
|
|
722
|
+
const useHost = o.host || (!o.url && !o.title ? DEFAULT_HOST : null);
|
|
723
|
+
let matches = pages.filter((p) => metadataMatches(p, o, useHost));
|
|
724
|
+
if (o.target) matches = matches.filter((p) => p.id === o.target);
|
|
725
|
+
if (o.project) {
|
|
726
|
+
matches = matches.filter((p) => projects[pages.indexOf(p)] === o.project);
|
|
727
|
+
}
|
|
728
|
+
|
|
729
|
+
if (ui.json) {
|
|
730
|
+
console.log(
|
|
731
|
+
JSON.stringify(
|
|
732
|
+
{
|
|
733
|
+
ok: true,
|
|
734
|
+
cdp: target.base,
|
|
735
|
+
pages: pages.map((p, i) => ({ ...p, projectName: projects[i] })),
|
|
736
|
+
matches: matches.map((p) => p.id),
|
|
737
|
+
},
|
|
738
|
+
null,
|
|
739
|
+
2,
|
|
740
|
+
),
|
|
741
|
+
);
|
|
742
|
+
return 0;
|
|
743
|
+
}
|
|
744
|
+
|
|
745
|
+
out(`Pages on ${target.base}:`);
|
|
746
|
+
if (!pages.length) out(" (none)");
|
|
747
|
+
pages.forEach((p, i) => {
|
|
748
|
+
const mark = matches.includes(p) ? "*" : " ";
|
|
749
|
+
const project = projects[i] ? ` (${projects[i]})` : "";
|
|
750
|
+
out(`${mark} ${i + 1}. [${p.id}]${project} ${p.url}`);
|
|
751
|
+
out(` ${String(p.title || "").trim()}`);
|
|
752
|
+
});
|
|
753
|
+
if (matches.length > 1) {
|
|
754
|
+
out(`\n${matches.length} tabs match; use --target <id>, --project <name>, or --index <n>.`);
|
|
755
|
+
}
|
|
756
|
+
return 0;
|
|
757
|
+
}
|
|
758
|
+
|
|
759
|
+
// ---------------------------------------------------------------------------
|
|
760
|
+
// editor helpers
|
|
761
|
+
// ---------------------------------------------------------------------------
|
|
762
|
+
|
|
763
|
+
async function waitForEditor(cdp) {
|
|
764
|
+
const deadline = Date.now() + 30000;
|
|
765
|
+
for (;;) {
|
|
766
|
+
// Do not require `monaco`: it is not defined until the JS view is opened.
|
|
767
|
+
const ok = await cdp
|
|
768
|
+
.ev(`(() =>
|
|
769
|
+
!!document.querySelector('[aria-label="Convert code to Blocks"]') ||
|
|
770
|
+
!!document.querySelector('[aria-label="Convert code to JavaScript"]') ||
|
|
771
|
+
!!document.querySelector('.blocklyDraggable'))()`)
|
|
772
|
+
.catch(() => false);
|
|
773
|
+
if (ok) return;
|
|
774
|
+
if (Date.now() > deadline) {
|
|
775
|
+
throw new Error("MakeCode editor not ready; open https://makecode.microbit.org/#editor");
|
|
776
|
+
}
|
|
777
|
+
await sleep(250);
|
|
778
|
+
}
|
|
779
|
+
}
|
|
780
|
+
|
|
781
|
+
async function dismissDialogs(cdp) {
|
|
782
|
+
const closed = await cdp.ev(`(async () => {
|
|
783
|
+
const done = [...document.querySelectorAll('.ui.modal button, .ui.modal .ui.button, .ReactModal__Content button')]
|
|
784
|
+
.find(b => {
|
|
785
|
+
const r = b.getBoundingClientRect();
|
|
786
|
+
return r.width > 0 && r.height > 0 && (b.textContent || '').trim() === 'Done';
|
|
787
|
+
});
|
|
788
|
+
if (done) done.click();
|
|
789
|
+
|
|
790
|
+
const rm = document.querySelector('.ReactModal__Content');
|
|
791
|
+
if (rm) {
|
|
792
|
+
const key = Object.keys(rm).find(k => k.startsWith('__reactFiber$'));
|
|
793
|
+
if (key) {
|
|
794
|
+
let f = rm[key];
|
|
795
|
+
for (let i = 0; f && i < 30; i++, f = f.return) {
|
|
796
|
+
const p = f.memoizedProps;
|
|
797
|
+
if (p && typeof p.onRequestClose === 'function') {
|
|
798
|
+
try { p.onRequestClose(); } catch (e) {}
|
|
799
|
+
break;
|
|
800
|
+
}
|
|
801
|
+
}
|
|
802
|
+
}
|
|
803
|
+
}
|
|
804
|
+
|
|
805
|
+
for (let i = 0; i < 25; i++) {
|
|
806
|
+
const m = [...document.querySelectorAll('.ui.modal, .ReactModal__Content')]
|
|
807
|
+
.some(e => { const r = e.getBoundingClientRect(); return r.width > 0 && r.height > 0; });
|
|
808
|
+
if (!m) return true;
|
|
809
|
+
await new Promise(r => setTimeout(r, 100));
|
|
810
|
+
}
|
|
811
|
+
return false;
|
|
812
|
+
})()`);
|
|
813
|
+
return closed;
|
|
814
|
+
}
|
|
815
|
+
|
|
816
|
+
async function modalText(cdp) {
|
|
817
|
+
return cdp.ev(`(() => {
|
|
818
|
+
const m = [...document.querySelectorAll('.ui.modal, .ReactModal__Content')]
|
|
819
|
+
.find(e => { const r = e.getBoundingClientRect(); return r.width > 0 && r.height > 0; });
|
|
820
|
+
return m ? (m.textContent || '').replace(/\\s+/g, ' ').trim().slice(0, 500) : null;
|
|
821
|
+
})()`);
|
|
822
|
+
}
|
|
823
|
+
|
|
824
|
+
async function ensureCodeView(cdp) {
|
|
825
|
+
const state = () =>
|
|
826
|
+
cdp.ev(`(() => {
|
|
827
|
+
const m = typeof monaco !== 'undefined' && monaco.editor
|
|
828
|
+
? monaco.editor.getModels().find(x => x.uri.toString().includes('main.ts'))
|
|
829
|
+
: null;
|
|
830
|
+
return {
|
|
831
|
+
visible: !!document.querySelector('.monaco-editor')?.getBoundingClientRect().width,
|
|
832
|
+
value: m ? m.getValue() : null,
|
|
833
|
+
};
|
|
834
|
+
})()`);
|
|
835
|
+
|
|
836
|
+
let s = await state();
|
|
837
|
+
if (s.visible && s.value !== null) return false;
|
|
838
|
+
|
|
839
|
+
const clicked = await cdp.ev(`(() => {
|
|
840
|
+
const b = document.querySelector('[aria-label="Convert code to JavaScript"]');
|
|
841
|
+
if (!b) return false;
|
|
842
|
+
b.click();
|
|
843
|
+
return true;
|
|
844
|
+
})()`);
|
|
845
|
+
if (!clicked) throw new Error("JavaScript view toggle not found");
|
|
846
|
+
|
|
847
|
+
// Wait for the model to exist AND stop changing: `.monaco-editor` becomes
|
|
848
|
+
// visible before a Blocks -> JS conversion has finished rewriting main.ts.
|
|
849
|
+
const deadline = Date.now() + 15000;
|
|
850
|
+
let last = null;
|
|
851
|
+
let stable = 0;
|
|
852
|
+
while (Date.now() < deadline) {
|
|
853
|
+
await sleep(200);
|
|
854
|
+
s = await state();
|
|
855
|
+
if (s.visible && s.value !== null) {
|
|
856
|
+
if (s.value === last) {
|
|
857
|
+
if (++stable >= 2) return true;
|
|
858
|
+
} else {
|
|
859
|
+
last = s.value;
|
|
860
|
+
stable = 0;
|
|
861
|
+
}
|
|
862
|
+
}
|
|
863
|
+
}
|
|
864
|
+
throw new Error("code view did not appear");
|
|
865
|
+
}
|
|
866
|
+
|
|
867
|
+
async function loadSource(cdp, source) {
|
|
868
|
+
await ensureCodeView(cdp);
|
|
869
|
+
|
|
870
|
+
const res = await cdp.ev(`(async () => {
|
|
871
|
+
const SRC = ${JSON.stringify(source)};
|
|
872
|
+
const model = monaco.editor.getModels().find(m => m.uri.toString().includes('main.ts'));
|
|
873
|
+
if (!model) throw new Error('main.ts model not found');
|
|
874
|
+
|
|
875
|
+
const before = model.getValue();
|
|
876
|
+
model.setValue(SRC);
|
|
877
|
+
model.pushStackElement?.();
|
|
878
|
+
|
|
879
|
+
// 1 s settle, then three consecutive empty marker samples (max 10 s).
|
|
880
|
+
let clean = 0;
|
|
881
|
+
for (let i = 0; i < 50; i++) {
|
|
882
|
+
await new Promise(r => setTimeout(r, 200));
|
|
883
|
+
if (i >= 5 && monaco.editor.getModelMarkers({}).length === 0) {
|
|
884
|
+
if (++clean >= 3) break;
|
|
885
|
+
} else {
|
|
886
|
+
clean = 0;
|
|
887
|
+
}
|
|
888
|
+
}
|
|
889
|
+
|
|
890
|
+
return {
|
|
891
|
+
beforeLen: before.length,
|
|
892
|
+
after: model.getValue(),
|
|
893
|
+
markers: monaco.editor.getModelMarkers({}).map(m => m.message),
|
|
894
|
+
};
|
|
895
|
+
})()`);
|
|
896
|
+
|
|
897
|
+
if (res.markers.length) {
|
|
898
|
+
throw new Error(`TypeScript markers: ${res.markers.join(" | ")}`);
|
|
899
|
+
}
|
|
900
|
+
if (res.after.trim() !== source.trim()) {
|
|
901
|
+
throw new Error("read-back did not match the source");
|
|
902
|
+
}
|
|
903
|
+
return res;
|
|
904
|
+
}
|
|
905
|
+
|
|
906
|
+
async function toBlocks(cdp) {
|
|
907
|
+
for (let attempt = 0; attempt < 2; attempt++) {
|
|
908
|
+
const r = await cdp.ev(`(async () => {
|
|
909
|
+
const codeVisible = () => !!document.querySelector('.monaco-editor')?.getBoundingClientRect().width;
|
|
910
|
+
const blockCount = () => document.querySelectorAll('.blocklyDraggable').length;
|
|
911
|
+
|
|
912
|
+
// Idempotent: already in Blocks.
|
|
913
|
+
if (!codeVisible() && blockCount() > 0) return { ok: true, blocks: blockCount(), waitedMs: 0 };
|
|
914
|
+
|
|
915
|
+
const b = document.querySelector('[aria-label="Convert code to Blocks"]');
|
|
916
|
+
if (!b) return { ok: false, noButton: true };
|
|
917
|
+
|
|
918
|
+
b.click();
|
|
919
|
+
for (let i = 0; i < 32; i++) {
|
|
920
|
+
await new Promise(r => setTimeout(r, 250));
|
|
921
|
+
if (!codeVisible() && blockCount() > 0) {
|
|
922
|
+
return { ok: true, blocks: blockCount(), waitedMs: (i + 1) * 250 };
|
|
923
|
+
}
|
|
924
|
+
}
|
|
925
|
+
|
|
926
|
+
const m = [...document.querySelectorAll('.ui.modal, .ReactModal__Content')]
|
|
927
|
+
.find(e => { const r = e.getBoundingClientRect(); return r.width > 0 && r.height > 0; });
|
|
928
|
+
return {
|
|
929
|
+
ok: false,
|
|
930
|
+
blocks: blockCount(),
|
|
931
|
+
codeVisible: codeVisible(),
|
|
932
|
+
modal: m ? (m.textContent || '').replace(/\\s+/g, ' ').trim().slice(0, 300) : null,
|
|
933
|
+
};
|
|
934
|
+
})()`);
|
|
935
|
+
|
|
936
|
+
if (r.ok) return r.blocks;
|
|
937
|
+
if (r.noButton) throw new Error("Blocks toggle not found");
|
|
938
|
+
if (attempt === 0) {
|
|
939
|
+
// The JS -> Blocks click can land while the previous conversion is still
|
|
940
|
+
// settling; give it a moment and try once more.
|
|
941
|
+
debug("Blocks conversion did not complete; retrying once");
|
|
942
|
+
await sleep(600);
|
|
943
|
+
} else {
|
|
944
|
+
throw new Error(`Blocks conversion failed${r.modal ? `: ${r.modal}` : ""}`);
|
|
945
|
+
}
|
|
946
|
+
}
|
|
947
|
+
}
|
|
948
|
+
|
|
949
|
+
async function roundtrip(cdp, source) {
|
|
950
|
+
await ensureCodeView(cdp);
|
|
951
|
+
const r = await cdp.ev(`(() => {
|
|
952
|
+
const m = monaco.editor.getModels().find(x => x.uri.toString().includes('main.ts'));
|
|
953
|
+
if (!m) throw new Error('main.ts model not found');
|
|
954
|
+
return {
|
|
955
|
+
ts: m.getValue(),
|
|
956
|
+
markers: monaco.editor.getModelMarkers({}).map(x => x.message),
|
|
957
|
+
};
|
|
958
|
+
})()`);
|
|
959
|
+
|
|
960
|
+
if (r.markers.length) throw new Error(`round-trip markers: ${r.markers.join(" | ")}`);
|
|
961
|
+
|
|
962
|
+
const norm = (s) => s.replace(/\s+/g, " ").trim();
|
|
963
|
+
const identical = norm(r.ts) === norm(source);
|
|
964
|
+
await sleep(250);
|
|
965
|
+
const blocks = await toBlocks(cdp);
|
|
966
|
+
return { identical, blocks, ts: r.ts };
|
|
967
|
+
}
|
|
968
|
+
|
|
969
|
+
// ---------------------------------------------------------------------------
|
|
970
|
+
// build / download
|
|
971
|
+
// ---------------------------------------------------------------------------
|
|
972
|
+
|
|
973
|
+
function snapshotHex(dir) {
|
|
974
|
+
const map = new Map();
|
|
975
|
+
if (!fs.existsSync(dir)) return map;
|
|
976
|
+
for (const f of fs.readdirSync(dir)) {
|
|
977
|
+
if (!f.toLowerCase().endsWith(".hex")) continue;
|
|
978
|
+
try {
|
|
979
|
+
const s = fs.statSync(path.join(dir, f));
|
|
980
|
+
map.set(f, { mtimeMs: s.mtimeMs, size: s.size });
|
|
981
|
+
} catch {
|
|
982
|
+
/* ignore */
|
|
983
|
+
}
|
|
984
|
+
}
|
|
985
|
+
return map;
|
|
986
|
+
}
|
|
987
|
+
|
|
988
|
+
function findNewHex(dir, before) {
|
|
989
|
+
let best = null;
|
|
990
|
+
if (!fs.existsSync(dir)) return null;
|
|
991
|
+
for (const f of fs.readdirSync(dir)) {
|
|
992
|
+
if (!f.toLowerCase().endsWith(".hex")) continue;
|
|
993
|
+
const p = path.join(dir, f);
|
|
994
|
+
let s;
|
|
995
|
+
try {
|
|
996
|
+
s = fs.statSync(p);
|
|
997
|
+
} catch {
|
|
998
|
+
continue;
|
|
999
|
+
}
|
|
1000
|
+
if (s.size < 100_000) continue;
|
|
1001
|
+
const b = before.get(f);
|
|
1002
|
+
if (!b || s.mtimeMs > b.mtimeMs + 1 || s.size !== b.size) {
|
|
1003
|
+
if (!best || s.mtimeMs > best.mtimeMs) best = { file: p, mtimeMs: s.mtimeMs };
|
|
1004
|
+
}
|
|
1005
|
+
}
|
|
1006
|
+
return best?.file || null;
|
|
1007
|
+
}
|
|
1008
|
+
|
|
1009
|
+
async function openHardwareMenu(cdp) {
|
|
1010
|
+
return cdp.ev(`(async () => {
|
|
1011
|
+
const vis = e => { const r = e.getBoundingClientRect(); return r.width > 0 && r.height > 0 && r.y > 0; };
|
|
1012
|
+
const rows = [...document.querySelectorAll('.ui.item, .item, [role=menuitem]')];
|
|
1013
|
+
if (rows.some(e => vis(e) && /^(download|download as file)$/i.test((e.textContent || '').trim()))) {
|
|
1014
|
+
return 'already';
|
|
1015
|
+
}
|
|
1016
|
+
const caret = [...document.querySelectorAll('.ui.dropdown')]
|
|
1017
|
+
.find(e => vis(e) && /download|help|connect/i.test(e.textContent || ''));
|
|
1018
|
+
if (!caret) return 'missing';
|
|
1019
|
+
caret.click();
|
|
1020
|
+
return 'opened';
|
|
1021
|
+
})()`);
|
|
1022
|
+
}
|
|
1023
|
+
|
|
1024
|
+
async function clickDownloadRow(cdp) {
|
|
1025
|
+
const clicked = await cdp.ev(`(() => {
|
|
1026
|
+
const i = [...document.querySelectorAll('.ui.item, .item, [role=menuitem]')]
|
|
1027
|
+
.find(e => {
|
|
1028
|
+
const r = e.getBoundingClientRect();
|
|
1029
|
+
return r.width > 0 && r.height > 0 && r.y > 0 &&
|
|
1030
|
+
/^(download|download as file)$/i.test((e.textContent || '').trim());
|
|
1031
|
+
});
|
|
1032
|
+
if (!i) return false;
|
|
1033
|
+
i.click();
|
|
1034
|
+
return true;
|
|
1035
|
+
})()`);
|
|
1036
|
+
if (!clicked) throw new Error("Download row not found (modal open or menu not open)");
|
|
1037
|
+
}
|
|
1038
|
+
|
|
1039
|
+
function validateHex(file) {
|
|
1040
|
+
if (!fs.existsSync(file)) throw new Error(`hex not found: ${file}`);
|
|
1041
|
+
const st = fs.statSync(file);
|
|
1042
|
+
if (st.size < 100_000) throw new Error(`hex looks too small: ${file} (${st.size} bytes)`);
|
|
1043
|
+
|
|
1044
|
+
const fd = fs.openSync(file, "r");
|
|
1045
|
+
try {
|
|
1046
|
+
const head = Buffer.alloc(1);
|
|
1047
|
+
fs.readSync(fd, head, 0, 1, 0);
|
|
1048
|
+
if (head.toString("ascii") !== ":") throw new Error(`invalid hex header: ${file}`);
|
|
1049
|
+
|
|
1050
|
+
const tailLen = Math.min(64, st.size);
|
|
1051
|
+
const tail = Buffer.alloc(tailLen);
|
|
1052
|
+
fs.readSync(fd, tail, 0, tailLen, st.size - tailLen);
|
|
1053
|
+
const last = tail.toString("utf8").trim().split(/\r?\n/).pop().trim();
|
|
1054
|
+
if (last !== ":00000001FF") throw new Error(`invalid hex terminator: ${file} (${last})`);
|
|
1055
|
+
} finally {
|
|
1056
|
+
fs.closeSync(fd);
|
|
1057
|
+
}
|
|
1058
|
+
}
|
|
1059
|
+
|
|
1060
|
+
async function buildHex(cdp, o) {
|
|
1061
|
+
const dir = path.resolve(o.downloadDir);
|
|
1062
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
1063
|
+
const before = snapshotHex(dir);
|
|
1064
|
+
|
|
1065
|
+
let overridden = false;
|
|
1066
|
+
try {
|
|
1067
|
+
try {
|
|
1068
|
+
await cdp.send("Browser.setDownloadBehavior", {
|
|
1069
|
+
behavior: "allow",
|
|
1070
|
+
downloadPath: dir,
|
|
1071
|
+
eventsEnabled: false,
|
|
1072
|
+
});
|
|
1073
|
+
} catch {
|
|
1074
|
+
await cdp.send("Page.setDownloadBehavior", { behavior: "allow", downloadPath: dir });
|
|
1075
|
+
}
|
|
1076
|
+
|
|
1077
|
+
if (!(await dismissDialogs(cdp))) {
|
|
1078
|
+
throw new Error(`could not close an open dialog${(await modalText(cdp)) ? `: ${await modalText(cdp)}` : ""}`);
|
|
1079
|
+
}
|
|
1080
|
+
|
|
1081
|
+
let menu = await openHardwareMenu(cdp);
|
|
1082
|
+
if (menu === "missing") {
|
|
1083
|
+
// Narrow/mobile layout hides the download control; render a desktop viewport.
|
|
1084
|
+
await cdp.send("Emulation.setDeviceMetricsOverride", {
|
|
1085
|
+
width: 1400,
|
|
1086
|
+
height: 900,
|
|
1087
|
+
deviceScaleFactor: 1,
|
|
1088
|
+
mobile: false,
|
|
1089
|
+
});
|
|
1090
|
+
overridden = true;
|
|
1091
|
+
await sleep(700);
|
|
1092
|
+
menu = await openHardwareMenu(cdp);
|
|
1093
|
+
}
|
|
1094
|
+
if (menu === "missing") {
|
|
1095
|
+
throw new Error("download control not found; is the MakeCode editor visible?");
|
|
1096
|
+
}
|
|
1097
|
+
|
|
1098
|
+
await sleep(900);
|
|
1099
|
+
await clickDownloadRow(cdp);
|
|
1100
|
+
|
|
1101
|
+
const t0 = Date.now();
|
|
1102
|
+
let file = null;
|
|
1103
|
+
while (Date.now() - t0 < o.timeoutMs) {
|
|
1104
|
+
file = findNewHex(dir, before);
|
|
1105
|
+
if (file) break;
|
|
1106
|
+
|
|
1107
|
+
const text = await modalText(cdp);
|
|
1108
|
+
if (text && /error|fail|invalid|syntax|cannot|problem/i.test(text)) {
|
|
1109
|
+
throw new Error(`build failed: ${text}`);
|
|
1110
|
+
}
|
|
1111
|
+
// An unfamiliar error dialog should not stall the full timeout.
|
|
1112
|
+
if (text && !/download completed/i.test(text) && Date.now() - t0 > 15000) {
|
|
1113
|
+
throw new Error(`build did not produce a hex: ${text}`);
|
|
1114
|
+
}
|
|
1115
|
+
await sleep(250);
|
|
1116
|
+
}
|
|
1117
|
+
if (!file) throw new Error(`no .hex after ${o.timeoutMs}ms`);
|
|
1118
|
+
|
|
1119
|
+
// Let the "Download completed" modal appear, then dismiss it.
|
|
1120
|
+
await sleep(1800);
|
|
1121
|
+
await dismissDialogs(cdp);
|
|
1122
|
+
|
|
1123
|
+
validateHex(file);
|
|
1124
|
+
|
|
1125
|
+
let final = file;
|
|
1126
|
+
if (o.hexOut) {
|
|
1127
|
+
let dest = path.resolve(o.hexOut);
|
|
1128
|
+
if (fs.existsSync(dest) && fs.statSync(dest).isDirectory()) {
|
|
1129
|
+
dest = path.join(dest, path.basename(file));
|
|
1130
|
+
}
|
|
1131
|
+
fs.mkdirSync(path.dirname(dest), { recursive: true });
|
|
1132
|
+
if (dest !== path.resolve(file)) fs.copyFileSync(file, dest);
|
|
1133
|
+
final = dest;
|
|
1134
|
+
}
|
|
1135
|
+
|
|
1136
|
+
return { downloaded: file, final };
|
|
1137
|
+
} finally {
|
|
1138
|
+
if (overridden) {
|
|
1139
|
+
await cdp.send("Emulation.clearDeviceMetricsOverride").catch(() => {});
|
|
1140
|
+
}
|
|
1141
|
+
}
|
|
1142
|
+
}
|
|
1143
|
+
|
|
1144
|
+
// ---------------------------------------------------------------------------
|
|
1145
|
+
// flash
|
|
1146
|
+
// ---------------------------------------------------------------------------
|
|
1147
|
+
|
|
1148
|
+
function resolveDevice(explicit) {
|
|
1149
|
+
if (explicit) {
|
|
1150
|
+
const p = path.resolve(explicit);
|
|
1151
|
+
if (!fs.existsSync(p)) throw new Error(`device mount not found: ${p}`);
|
|
1152
|
+
if (!fs.statSync(p).isDirectory()) throw new Error(`device path is not a directory: ${p}`);
|
|
1153
|
+
if (!fs.existsSync(path.join(p, "DETAILS.TXT")) && !fs.existsSync(path.join(p, "MICROBIT.HTM"))) {
|
|
1154
|
+
warn(`--device has no DETAILS.TXT/MICROBIT.HTM; assuming it is the MICROBIT mount`);
|
|
1155
|
+
}
|
|
1156
|
+
return p;
|
|
1157
|
+
}
|
|
1158
|
+
|
|
1159
|
+
const user = os.userInfo().username;
|
|
1160
|
+
const candidates = [
|
|
1161
|
+
`/run/media/${user}/MICROBIT`,
|
|
1162
|
+
`/media/${user}/MICROBIT`,
|
|
1163
|
+
`/mnt/MICROBIT`,
|
|
1164
|
+
`/media/MICROBIT`,
|
|
1165
|
+
`/Volumes/MICROBIT`,
|
|
1166
|
+
];
|
|
1167
|
+
|
|
1168
|
+
const marked = candidates.filter(
|
|
1169
|
+
(p) =>
|
|
1170
|
+
fs.existsSync(p) &&
|
|
1171
|
+
fs.statSync(p).isDirectory() &&
|
|
1172
|
+
(fs.existsSync(path.join(p, "DETAILS.TXT")) || fs.existsSync(path.join(p, "MICROBIT.HTM"))),
|
|
1173
|
+
);
|
|
1174
|
+
if (marked.length === 1) return marked[0];
|
|
1175
|
+
if (marked.length > 1) {
|
|
1176
|
+
throw new Error(`multiple MICROBIT mounts found (${marked.join(", ")}); use --device`);
|
|
1177
|
+
}
|
|
1178
|
+
|
|
1179
|
+
const dirs = candidates.filter((p) => fs.existsSync(p) && fs.statSync(p).isDirectory());
|
|
1180
|
+
if (dirs.length === 1) return dirs[0];
|
|
1181
|
+
|
|
1182
|
+
throw new Error("MICROBIT volume not found; connect the board or use --device <mount>");
|
|
1183
|
+
}
|
|
1184
|
+
|
|
1185
|
+
async function flashHexFile(hex, device, timeoutMs, progress = step) {
|
|
1186
|
+
validateHex(hex);
|
|
1187
|
+
|
|
1188
|
+
const name = path.basename(hex);
|
|
1189
|
+
const dest = path.join(device, name);
|
|
1190
|
+
const failFile = path.join(device, "FAIL.TXT");
|
|
1191
|
+
const hasMarkerFile = () =>
|
|
1192
|
+
fs.existsSync(path.join(device, "DETAILS.TXT")) || fs.existsSync(path.join(device, "MICROBIT.HTM"));
|
|
1193
|
+
|
|
1194
|
+
// A custom mount may not expose DETAILS.TXT; then path existence is all we have.
|
|
1195
|
+
const markerBased = hasMarkerFile();
|
|
1196
|
+
if (!markerBased) warn("device has no DETAILS.TXT/MICROBIT.HTM; using file disappearance as the flash signal");
|
|
1197
|
+
|
|
1198
|
+
fs.rmSync(failFile, { force: true });
|
|
1199
|
+
fs.copyFileSync(hex, dest);
|
|
1200
|
+
|
|
1201
|
+
// Flush the file, then ask the OS to flush the device cache.
|
|
1202
|
+
try {
|
|
1203
|
+
const fd = fs.openSync(dest, "r");
|
|
1204
|
+
try {
|
|
1205
|
+
fs.fsyncSync(fd);
|
|
1206
|
+
} finally {
|
|
1207
|
+
fs.closeSync(fd);
|
|
1208
|
+
}
|
|
1209
|
+
} catch {
|
|
1210
|
+
/* non-fatal */
|
|
1211
|
+
}
|
|
1212
|
+
try {
|
|
1213
|
+
execFileSync("sync", { stdio: "ignore" });
|
|
1214
|
+
} catch {
|
|
1215
|
+
/* non-fatal */
|
|
1216
|
+
}
|
|
1217
|
+
|
|
1218
|
+
const t0 = Date.now();
|
|
1219
|
+
let state = "";
|
|
1220
|
+
let seenUnmounted = false;
|
|
1221
|
+
|
|
1222
|
+
for (;;) {
|
|
1223
|
+
const ms = Date.now() - t0;
|
|
1224
|
+
|
|
1225
|
+
if (fs.existsSync(failFile)) {
|
|
1226
|
+
const reason = fs.readFileSync(failFile, "utf8").trim();
|
|
1227
|
+
throw new Error(`DAPLink flashed FAIL.TXT: ${reason || "(empty)"}`);
|
|
1228
|
+
}
|
|
1229
|
+
|
|
1230
|
+
const mounted = markerBased ? hasMarkerFile() : fs.existsSync(device);
|
|
1231
|
+
const present = fs.existsSync(dest);
|
|
1232
|
+
const s = mounted ? (present ? "mounted, hex present" : "mounted, hex gone") : "unmounted";
|
|
1233
|
+
|
|
1234
|
+
if (s !== state) {
|
|
1235
|
+
progress(`${ms}ms ${s}`);
|
|
1236
|
+
state = s;
|
|
1237
|
+
}
|
|
1238
|
+
|
|
1239
|
+
if (!mounted) seenUnmounted = true;
|
|
1240
|
+
|
|
1241
|
+
const settled =
|
|
1242
|
+
mounted && !present && (seenUnmounted || ms > (markerBased ? 5000 : 8000));
|
|
1243
|
+
if (settled) {
|
|
1244
|
+
progress(`${ms}ms done: hex consumed, volume remounted`);
|
|
1245
|
+
return { ms, seenUnmounted };
|
|
1246
|
+
}
|
|
1247
|
+
|
|
1248
|
+
if (ms > timeoutMs) throw new Error(`flash timed out after ${timeoutMs}ms`);
|
|
1249
|
+
await sleep(200);
|
|
1250
|
+
}
|
|
1251
|
+
}
|
|
1252
|
+
|
|
1253
|
+
// ---------------------------------------------------------------------------
|
|
1254
|
+
// main
|
|
1255
|
+
// ---------------------------------------------------------------------------
|
|
1256
|
+
|
|
1257
|
+
function readSource(src) {
|
|
1258
|
+
if (src === "-") {
|
|
1259
|
+
const s = fs.readFileSync(0, "utf8").replace(/^\uFEFF/, "");
|
|
1260
|
+
if (!s.trim()) throw new Error("stdin source is empty");
|
|
1261
|
+
return s;
|
|
1262
|
+
}
|
|
1263
|
+
const p = path.resolve(src);
|
|
1264
|
+
if (!fs.existsSync(p)) throw new Error(`source not found: ${p}`);
|
|
1265
|
+
const s = fs.readFileSync(p, "utf8").replace(/^\uFEFF/, "");
|
|
1266
|
+
if (!s.trim()) throw new Error(`source is empty: ${p}`);
|
|
1267
|
+
return s;
|
|
1268
|
+
}
|
|
1269
|
+
|
|
1270
|
+
async function main() {
|
|
1271
|
+
const o = parseArgs(process.argv.slice(2));
|
|
1272
|
+
Object.assign(ui, { json: o.json, quiet: o.quiet, verbose: o.verbose });
|
|
1273
|
+
|
|
1274
|
+
if (o.help) {
|
|
1275
|
+
printHelp();
|
|
1276
|
+
return 0;
|
|
1277
|
+
}
|
|
1278
|
+
if (o.version) {
|
|
1279
|
+
out(`mbflash ${VERSION}`);
|
|
1280
|
+
return 0;
|
|
1281
|
+
}
|
|
1282
|
+
if (o.list) return await listTargets(o);
|
|
1283
|
+
|
|
1284
|
+
const mode = resolveMode(o);
|
|
1285
|
+
const t0 = Date.now();
|
|
1286
|
+
const result = { ok: true, mode, warnings: ui.warnings, timings: {} };
|
|
1287
|
+
let cdp;
|
|
1288
|
+
|
|
1289
|
+
try {
|
|
1290
|
+
if (mode === "flash-hex") {
|
|
1291
|
+
const hex = path.resolve(o.flashHex);
|
|
1292
|
+
const device = resolveDevice(o.device);
|
|
1293
|
+
result.hex = hex;
|
|
1294
|
+
result.device = device;
|
|
1295
|
+
step(`Flash ${hex} -> ${device}`);
|
|
1296
|
+
const t = Date.now();
|
|
1297
|
+
await flashHexFile(hex, device, o.waitMs);
|
|
1298
|
+
result.timings.flash = Date.now() - t;
|
|
1299
|
+
result.timings.total = Date.now() - t0;
|
|
1300
|
+
} else {
|
|
1301
|
+
const target = parseCdpTarget(o.cdp);
|
|
1302
|
+
const pages = await fetchPages(target);
|
|
1303
|
+
let page = await selectPage(pages, o, { allowMissingProject: !!o.project && o.create });
|
|
1304
|
+
let created = false;
|
|
1305
|
+
let createStart = 0;
|
|
1306
|
+
|
|
1307
|
+
if (!page) {
|
|
1308
|
+
createStart = Date.now();
|
|
1309
|
+
step(`No tab has project "${o.project}"; creating it...`);
|
|
1310
|
+
page = await createNewProjectTab(target, o.project, o, pages);
|
|
1311
|
+
created = true;
|
|
1312
|
+
result.created = true;
|
|
1313
|
+
}
|
|
1314
|
+
|
|
1315
|
+
const wsUrl = normalizeWsUrl(page, target);
|
|
1316
|
+
|
|
1317
|
+
debug(`CDP ${target.base}, browser ${await fetch(`${target.base}/json/version`)
|
|
1318
|
+
.then((r) => r.json())
|
|
1319
|
+
.then((v) => v.Browser)
|
|
1320
|
+
.catch(() => "unknown")}`);
|
|
1321
|
+
debug(`tab ${page.id} ${page.url}`);
|
|
1322
|
+
|
|
1323
|
+
cdp = await Cdp.connect(wsUrl);
|
|
1324
|
+
if (o.focus) await cdp.send("Page.bringToFront").catch(() => {});
|
|
1325
|
+
|
|
1326
|
+
if (created) {
|
|
1327
|
+
await initNewProject(cdp, o.project);
|
|
1328
|
+
result.timings.create = Date.now() - createStart;
|
|
1329
|
+
step(`Created ${o.project} (${fmt(result.timings.create)})`);
|
|
1330
|
+
} else {
|
|
1331
|
+
await waitForEditor(cdp);
|
|
1332
|
+
}
|
|
1333
|
+
|
|
1334
|
+
if (o.retitle) {
|
|
1335
|
+
await cdp.ev(`document.title = 'MakeCode — ' + ${JSON.stringify(o.project)}`).catch(() => {});
|
|
1336
|
+
}
|
|
1337
|
+
|
|
1338
|
+
const currentUrl = await cdp.ev(`location.href`).catch(() => page.url);
|
|
1339
|
+
const projectName = await cdp
|
|
1340
|
+
.ev(`document.querySelector('#fileNameInput2')?.value || ''`)
|
|
1341
|
+
.catch(() => "");
|
|
1342
|
+
const tabProject = projectName || o.project || null;
|
|
1343
|
+
result.tab = { id: page.id, project: tabProject, url: currentUrl };
|
|
1344
|
+
|
|
1345
|
+
if (mode === "switch") {
|
|
1346
|
+
out(`switched to ${tabProject || page.id} (${page.id})`);
|
|
1347
|
+
result.timings.total = Date.now() - t0;
|
|
1348
|
+
if (ui.json) console.log(JSON.stringify(result, null, 2));
|
|
1349
|
+
return 0;
|
|
1350
|
+
}
|
|
1351
|
+
|
|
1352
|
+
let blocks = null;
|
|
1353
|
+
let markers = [];
|
|
1354
|
+
let loaded = false;
|
|
1355
|
+
|
|
1356
|
+
if (mode === "load" || mode === "load+build" || mode === "load+build+flash") {
|
|
1357
|
+
loaded = true;
|
|
1358
|
+
const source = readSource(o.source);
|
|
1359
|
+
result.source = o.source === "-" ? "<stdin>" : path.resolve(o.source);
|
|
1360
|
+
step(`Loading ${result.source} (${source.length} chars)`);
|
|
1361
|
+
|
|
1362
|
+
const tl = Date.now();
|
|
1363
|
+
const r = await loadSource(cdp, source);
|
|
1364
|
+
result.timings.load = Date.now() - tl;
|
|
1365
|
+
markers = r.markers;
|
|
1366
|
+
debug(`loaded ${r.beforeLen} -> ${source.length} chars, markers ${JSON.stringify(markers)}`);
|
|
1367
|
+
|
|
1368
|
+
if (!o.noBlocks) {
|
|
1369
|
+
const tb = Date.now();
|
|
1370
|
+
blocks = await toBlocks(cdp);
|
|
1371
|
+
result.timings.blocks = Date.now() - tb;
|
|
1372
|
+
step(`Blocks rebuilt: ${blocks} (${fmt(result.timings.blocks)})`);
|
|
1373
|
+
|
|
1374
|
+
if (o.roundtrip) {
|
|
1375
|
+
const tr = Date.now();
|
|
1376
|
+
const rt = await roundtrip(cdp, source);
|
|
1377
|
+
result.timings.roundtrip = Date.now() - tr;
|
|
1378
|
+
result.roundtrip = { identical: rt.identical, blocks: rt.blocks };
|
|
1379
|
+
if (!rt.identical) warn("round-trip differs textually; inspect icons/enums");
|
|
1380
|
+
step(`Round-trip ${rt.identical ? "identical" : "differs"} (${fmt(result.timings.roundtrip)})`);
|
|
1381
|
+
}
|
|
1382
|
+
} else {
|
|
1383
|
+
step("Skipped Blocks conversion (--no-blocks)");
|
|
1384
|
+
}
|
|
1385
|
+
}
|
|
1386
|
+
|
|
1387
|
+
if (loaded) result.markers = markers;
|
|
1388
|
+
if (blocks !== null) result.blocks = blocks;
|
|
1389
|
+
|
|
1390
|
+
if (mode !== "load") {
|
|
1391
|
+
const tb = Date.now();
|
|
1392
|
+
const built = await buildHex(cdp, o);
|
|
1393
|
+
result.timings.build = Date.now() - tb;
|
|
1394
|
+
result.hex = built.final;
|
|
1395
|
+
result.downloaded = built.downloaded;
|
|
1396
|
+
if (built.final !== built.downloaded) result.hexOut = built.final;
|
|
1397
|
+
step(`Built ${built.final} (${fmt(result.timings.build)})`);
|
|
1398
|
+
|
|
1399
|
+
if (mode === "load+build+flash" || mode === "build+flash") {
|
|
1400
|
+
const device = resolveDevice(o.device);
|
|
1401
|
+
result.device = device;
|
|
1402
|
+
const tf = Date.now();
|
|
1403
|
+
const f = await flashHexFile(built.final, device, o.waitMs);
|
|
1404
|
+
result.timings.flash = Date.now() - tf;
|
|
1405
|
+
step(`Flashed ${device} (${fmt(result.timings.flash)}, remounted ${f.seenUnmounted ? "after unmount" : "without observed unmount"})`);
|
|
1406
|
+
}
|
|
1407
|
+
}
|
|
1408
|
+
}
|
|
1409
|
+
|
|
1410
|
+
if (o.retitle && o.project && cdp) {
|
|
1411
|
+
await cdp.ev(`document.title = 'MakeCode — ' + ${JSON.stringify(o.project)}`).catch(() => {});
|
|
1412
|
+
}
|
|
1413
|
+
|
|
1414
|
+
result.timings.total = Date.now() - t0;
|
|
1415
|
+
|
|
1416
|
+
if (ui.json) {
|
|
1417
|
+
console.log(JSON.stringify(result, null, 2));
|
|
1418
|
+
} else if (ui.quiet) {
|
|
1419
|
+
console.log(result.hex || "ok");
|
|
1420
|
+
} else {
|
|
1421
|
+
out("");
|
|
1422
|
+
if (result.source) out(`source: ${result.source}`);
|
|
1423
|
+
if (result.downloaded && result.downloaded !== result.hex) out(`downloaded: ${result.downloaded}`);
|
|
1424
|
+
if (result.hex) out(`hex: ${result.hex}`);
|
|
1425
|
+
if (result.device) out(`device: ${result.device}`);
|
|
1426
|
+
out(`total: ${fmt(result.timings.total)}`);
|
|
1427
|
+
for (const w of ui.warnings) out(`warning: ${w}`);
|
|
1428
|
+
}
|
|
1429
|
+
return 0;
|
|
1430
|
+
} catch (e) {
|
|
1431
|
+
result.ok = false;
|
|
1432
|
+
result.error = e.message;
|
|
1433
|
+
result.timings.total = Date.now() - t0;
|
|
1434
|
+
|
|
1435
|
+
if (ui.json) {
|
|
1436
|
+
console.log(JSON.stringify(result, null, 2));
|
|
1437
|
+
} else {
|
|
1438
|
+
console.error(`error: ${e.message}`);
|
|
1439
|
+
}
|
|
1440
|
+
return 1;
|
|
1441
|
+
} finally {
|
|
1442
|
+
if (cdp) cdp.close();
|
|
1443
|
+
}
|
|
1444
|
+
}
|
|
1445
|
+
|
|
1446
|
+
main()
|
|
1447
|
+
.then((code) => process.exit(code))
|
|
1448
|
+
.catch((e) => {
|
|
1449
|
+
console.error(`error: ${e.stack || e.message}`);
|
|
1450
|
+
process.exit(1);
|
|
1451
|
+
});
|