@11agents/cli 0.1.37 → 0.1.38
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/mobile-runtime/data-templates/README.md +2 -1
- package/mobile-runtime/data-templates/devices.example.csv +11 -2
- package/mobile-runtime/python/src/device_control/cli.py +92 -29
- package/mobile-runtime/python/src/device_control/models.py +1 -0
- package/mobile-runtime/python/src/device_control/store.py +1 -0
- package/package.json +1 -1
- package/src/commands/mobile.js +99 -1
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
# 11agents Mobile Data Templates
|
|
2
2
|
|
|
3
3
|
These files seed `~/.11agents/mobile/data` during `11agents mobile setup`.
|
|
4
|
-
|
|
4
|
+
`devices.example.csv` contains the stable D01-D10 device registry. Keep those hardware ids in sync across runtime machines so a physical device keeps the same Dxx id even when it is attached through a different USB port or ADB transport.
|
|
5
|
+
Replace example accounts with company-owned, authorized Android accounts before publishing.
|
|
@@ -1,2 +1,11 @@
|
|
|
1
|
-
device_id,adb_serial,ip_address,brand,model,android_version,status,notes
|
|
2
|
-
D01,
|
|
1
|
+
device_id,adb_serial,hardware_id,ip_address,brand,model,android_version,status,notes
|
|
2
|
+
D01,e6209341,e6209341,,,,,active,stable device registry slot 1
|
|
3
|
+
D02,7441ce65,7441ce65,,,,,active,stable device registry slot 2
|
|
4
|
+
D03,2eecd64c,2eecd64c,,,,,active,stable device registry slot 3
|
|
5
|
+
D04,bfa3e5ba,bfa3e5ba,,,,,active,stable device registry slot 4
|
|
6
|
+
D05,78d99cb0,78d99cb0,,,,,active,stable device registry slot 5
|
|
7
|
+
D06,2c461593,2c461593,,,,,active,stable device registry slot 6
|
|
8
|
+
D07,2cd62d93,2cd62d93,,,,,active,stable device registry slot 7
|
|
9
|
+
D08,f8cabef1,f8cabef1,,,,,active,stable device registry slot 8
|
|
10
|
+
D09,8b427827,8b427827,,,,,active,stable device registry slot 9
|
|
11
|
+
D10,3185bee8,3185bee8,,,,,active,stable device registry slot 10
|
|
@@ -4,7 +4,7 @@ import argparse
|
|
|
4
4
|
import csv
|
|
5
5
|
import json
|
|
6
6
|
from concurrent.futures import ThreadPoolExecutor, as_completed
|
|
7
|
-
from dataclasses import asdict
|
|
7
|
+
from dataclasses import asdict, replace
|
|
8
8
|
from pathlib import Path
|
|
9
9
|
|
|
10
10
|
from .adb import AdbClient, resolve_adb_bin
|
|
@@ -349,7 +349,7 @@ def main() -> None:
|
|
|
349
349
|
return
|
|
350
350
|
|
|
351
351
|
if args.command == "ensure-appium":
|
|
352
|
-
udids = _resolve_appium_udids(args.devices, args.device, args.udid)
|
|
352
|
+
udids = _resolve_appium_udids(args.devices, args.device, args.udid, adb)
|
|
353
353
|
try:
|
|
354
354
|
result = ensure_appium_server(
|
|
355
355
|
args.server,
|
|
@@ -387,12 +387,13 @@ def main() -> None:
|
|
|
387
387
|
return
|
|
388
388
|
|
|
389
389
|
if args.command == "list-devices":
|
|
390
|
-
devices =
|
|
390
|
+
devices = _load_devices(args.devices, adb, resolve_transports=args.health)
|
|
391
391
|
connected = set(adb.list_devices()) if args.health else set()
|
|
392
392
|
for device in devices.values():
|
|
393
393
|
row = {
|
|
394
394
|
"device_id": device.device_id,
|
|
395
395
|
"adb_serial": device.adb_serial,
|
|
396
|
+
"hardware_id": device.hardware_id,
|
|
396
397
|
"ip_address": device.ip_address,
|
|
397
398
|
"brand": device.brand,
|
|
398
399
|
"model": device.model,
|
|
@@ -413,7 +414,7 @@ def main() -> None:
|
|
|
413
414
|
if args.health:
|
|
414
415
|
suffix = f" online={row['online']} battery={row.get('battery', '')} focus={row.get('focus', '')}"
|
|
415
416
|
print(
|
|
416
|
-
f"{device.device_id} serial={device.adb_serial} ip={device.ip_address} "
|
|
417
|
+
f"{device.device_id} serial={device.adb_serial} hardware_id={device.hardware_id} ip={device.ip_address} "
|
|
417
418
|
f"model={device.model} status={device.status}{suffix}"
|
|
418
419
|
)
|
|
419
420
|
return
|
|
@@ -428,6 +429,7 @@ def main() -> None:
|
|
|
428
429
|
row = {
|
|
429
430
|
"device_id": args.device_id,
|
|
430
431
|
"adb_serial": serial,
|
|
432
|
+
"hardware_id": _device_hardware_id(adb, serial) or serial,
|
|
431
433
|
"ip_address": _ip_from_serial(serial),
|
|
432
434
|
"brand": adb.getprop(serial, "ro.product.brand"),
|
|
433
435
|
"model": adb.getprop(serial, "ro.product.model"),
|
|
@@ -443,33 +445,34 @@ def main() -> None:
|
|
|
443
445
|
return
|
|
444
446
|
|
|
445
447
|
if args.command == "connect":
|
|
446
|
-
devices =
|
|
448
|
+
devices = _load_devices(args.devices, adb, resolve_transports=False)
|
|
447
449
|
for device in devices.values():
|
|
448
450
|
if device.status != "active":
|
|
449
451
|
print(f"{device.device_id}: skipped status={device.status}")
|
|
450
452
|
continue
|
|
451
|
-
|
|
453
|
+
target = device.ip_address or device.adb_serial
|
|
454
|
+
result = adb.connect(target)
|
|
452
455
|
status = "ok" if result.ok else "fail"
|
|
453
|
-
print(f"{device.device_id} {
|
|
456
|
+
print(f"{device.device_id} {target}: {status} {result.stdout or result.stderr}")
|
|
454
457
|
return
|
|
455
458
|
|
|
456
459
|
if args.command == "health":
|
|
457
|
-
devices =
|
|
460
|
+
devices = _load_devices(args.devices, adb)
|
|
458
461
|
connected = set(adb.list_devices())
|
|
459
462
|
for device in devices.values():
|
|
460
463
|
online = device.adb_serial in connected
|
|
461
464
|
if not online:
|
|
462
|
-
print(f"{device.device_id}: offline serial={device.adb_serial}")
|
|
465
|
+
print(f"{device.device_id}: offline serial={device.adb_serial} hardware_id={device.hardware_id}")
|
|
463
466
|
continue
|
|
464
467
|
battery = adb.battery_level(device.adb_serial)
|
|
465
468
|
model = adb.getprop(device.adb_serial, "ro.product.model")
|
|
466
469
|
android = adb.getprop(device.adb_serial, "ro.build.version.release")
|
|
467
470
|
focus = adb.current_focus(device.adb_serial)
|
|
468
|
-
print(f"{device.device_id}: online battery={battery} model={model} android={android} focus={focus}")
|
|
471
|
+
print(f"{device.device_id}: online battery={battery} serial={device.adb_serial} hardware_id={device.hardware_id} model={model} android={android} focus={focus}")
|
|
469
472
|
return
|
|
470
473
|
|
|
471
474
|
if args.command == "screenshot":
|
|
472
|
-
device = _get_device(args.devices, args.device_id)
|
|
475
|
+
device = _get_device(args.devices, args.device_id, adb)
|
|
473
476
|
out = Path(args.out_dir) / f"{device.device_id}.png"
|
|
474
477
|
result = adb.screenshot(device.adb_serial, out)
|
|
475
478
|
if result.ok:
|
|
@@ -479,7 +482,7 @@ def main() -> None:
|
|
|
479
482
|
return
|
|
480
483
|
|
|
481
484
|
if args.command == "dump-ui":
|
|
482
|
-
device = _get_device(args.devices, args.device_id)
|
|
485
|
+
device = _get_device(args.devices, args.device_id, adb)
|
|
483
486
|
out = Path(args.out_dir) / f"{device.device_id}.xml"
|
|
484
487
|
result = adb.dump_ui(device.adb_serial, out)
|
|
485
488
|
if result.ok:
|
|
@@ -490,25 +493,25 @@ def main() -> None:
|
|
|
490
493
|
return
|
|
491
494
|
|
|
492
495
|
if args.command == "tap":
|
|
493
|
-
device = _get_device(args.devices, args.device_id)
|
|
496
|
+
device = _get_device(args.devices, args.device_id, adb)
|
|
494
497
|
result = adb.tap(device.adb_serial, args.x, args.y)
|
|
495
498
|
print(result.stdout or result.stderr)
|
|
496
499
|
return
|
|
497
500
|
|
|
498
501
|
if args.command == "keyevent":
|
|
499
|
-
device = _get_device(args.devices, args.device_id)
|
|
502
|
+
device = _get_device(args.devices, args.device_id, adb)
|
|
500
503
|
result = adb.keyevent(device.adb_serial, args.keycode)
|
|
501
504
|
print(result.stdout or result.stderr)
|
|
502
505
|
return
|
|
503
506
|
|
|
504
507
|
if args.command == "input-text":
|
|
505
|
-
device = _get_device(args.devices, args.device_id)
|
|
508
|
+
device = _get_device(args.devices, args.device_id, adb)
|
|
506
509
|
result = adb.input_text(device.adb_serial, args.text)
|
|
507
510
|
print(result.stdout or result.stderr)
|
|
508
511
|
return
|
|
509
512
|
|
|
510
513
|
if args.command == "launch":
|
|
511
|
-
device = _get_device(args.devices, args.device_id)
|
|
514
|
+
device = _get_device(args.devices, args.device_id, adb)
|
|
512
515
|
result = adb.launch_package(device.adb_serial, args.package)
|
|
513
516
|
print(result.stdout or result.stderr)
|
|
514
517
|
return
|
|
@@ -632,7 +635,7 @@ def main() -> None:
|
|
|
632
635
|
return
|
|
633
636
|
|
|
634
637
|
if args.command == "collect-tiktok-account-metrics-adb":
|
|
635
|
-
device = _get_device(args.devices, args.device)
|
|
638
|
+
device = _get_device(args.devices, args.device, adb)
|
|
636
639
|
account_id = args.account_id or f"{args.account_prefix}_{device.device_id.lower()}"
|
|
637
640
|
manual_values = _load_tiktok_account_metric_values(args.values_json, args.values_file)
|
|
638
641
|
collector = TikTokAccountMetricsAdbCollector(adb, artifact_root=args.artifact_root)
|
|
@@ -673,7 +676,7 @@ def main() -> None:
|
|
|
673
676
|
return
|
|
674
677
|
|
|
675
678
|
if args.command == "collect-tiktok-video-metrics-adb":
|
|
676
|
-
device = _get_device(args.devices, args.device)
|
|
679
|
+
device = _get_device(args.devices, args.device, adb)
|
|
677
680
|
account_id = args.account_id or f"{args.account_prefix}_{device.device_id.lower()}"
|
|
678
681
|
manual_values = _load_tiktok_video_metric_values(args.values_json, args.values_file)
|
|
679
682
|
collector = TikTokVideoMetricsAdbCollector(adb, artifact_root=args.artifact_root)
|
|
@@ -715,7 +718,7 @@ def main() -> None:
|
|
|
715
718
|
return
|
|
716
719
|
|
|
717
720
|
if args.command == "publish-tiktok-adb":
|
|
718
|
-
devices =
|
|
721
|
+
devices = _load_devices(args.devices, adb)
|
|
719
722
|
selected = _select_devices(devices, args.device, args.ranges)
|
|
720
723
|
if not selected:
|
|
721
724
|
raise SystemExit("No target devices. Use --device D01 or --range 1-10.")
|
|
@@ -751,7 +754,7 @@ def main() -> None:
|
|
|
751
754
|
return
|
|
752
755
|
|
|
753
756
|
if args.command in {"publish-tiktok", "publish-tiktok-appium"}:
|
|
754
|
-
devices =
|
|
757
|
+
devices = _load_devices(args.devices, adb)
|
|
755
758
|
selected = _select_devices(devices, args.device, args.ranges)
|
|
756
759
|
if not selected:
|
|
757
760
|
raise SystemExit("No target devices. Use --device D03 or --range 1-5.")
|
|
@@ -787,7 +790,7 @@ def main() -> None:
|
|
|
787
790
|
return
|
|
788
791
|
|
|
789
792
|
if args.command in {"publish-reddit", "publish-reddit-adb"}:
|
|
790
|
-
devices =
|
|
793
|
+
devices = _load_devices(args.devices, adb)
|
|
791
794
|
selected = _select_devices(devices, args.device, args.ranges)
|
|
792
795
|
if not selected:
|
|
793
796
|
raise SystemExit("No target devices. Use --device D01 or --range 1-10.")
|
|
@@ -832,7 +835,7 @@ def main() -> None:
|
|
|
832
835
|
return
|
|
833
836
|
|
|
834
837
|
if args.command == "publish-facebook":
|
|
835
|
-
devices =
|
|
838
|
+
devices = _load_devices(args.devices, adb)
|
|
836
839
|
selected = _select_devices(devices, args.device, args.ranges)
|
|
837
840
|
if not selected:
|
|
838
841
|
raise SystemExit("No target devices. Use --device D03 or --range 1-5.")
|
|
@@ -871,7 +874,7 @@ def main() -> None:
|
|
|
871
874
|
|
|
872
875
|
if args.command == "publish-instagram":
|
|
873
876
|
_apply_instagram_publish_policy(args)
|
|
874
|
-
devices =
|
|
877
|
+
devices = _load_devices(args.devices, adb)
|
|
875
878
|
selected = _select_devices(devices, args.device, args.ranges)
|
|
876
879
|
if not selected:
|
|
877
880
|
raise SystemExit("No target devices. Use --device D03 or --range 1-5.")
|
|
@@ -911,7 +914,7 @@ def main() -> None:
|
|
|
911
914
|
return
|
|
912
915
|
|
|
913
916
|
if args.command == "publish-x":
|
|
914
|
-
devices =
|
|
917
|
+
devices = _load_devices(args.devices, adb)
|
|
915
918
|
selected = _select_devices(devices, args.device, args.ranges)
|
|
916
919
|
if not selected:
|
|
917
920
|
raise SystemExit("No target devices. Use --device D03 or --range 1-5.")
|
|
@@ -955,7 +958,7 @@ def main() -> None:
|
|
|
955
958
|
if args.command == "publish-xiaohongshu":
|
|
956
959
|
_apply_xiaohongshu_publish_package(args)
|
|
957
960
|
_apply_xiaohongshu_live_publish_policy(args)
|
|
958
|
-
devices =
|
|
961
|
+
devices = _load_devices(args.devices, adb)
|
|
959
962
|
selected = _select_devices(devices, args.device, args.ranges)
|
|
960
963
|
if not selected:
|
|
961
964
|
raise SystemExit("No target devices. Use --device Dxx or --range 1-5.")
|
|
@@ -1011,7 +1014,7 @@ def main() -> None:
|
|
|
1011
1014
|
device_id = args.device or (record.device_id if record else "")
|
|
1012
1015
|
if not device_id:
|
|
1013
1016
|
raise SystemExit("copy-xiaohongshu-link requires --device or --record-id for a record with device_id")
|
|
1014
|
-
device = _get_device(args.devices, device_id)
|
|
1017
|
+
device = _get_device(args.devices, device_id, adb)
|
|
1015
1018
|
_ensure_appium_preflight(args, [device])
|
|
1016
1019
|
publisher = XiaohongshuAdbPublisher(
|
|
1017
1020
|
adb,
|
|
@@ -1048,17 +1051,77 @@ def main() -> None:
|
|
|
1048
1051
|
return
|
|
1049
1052
|
|
|
1050
1053
|
|
|
1051
|
-
|
|
1054
|
+
DEVICE_IDENTITY_PROPS = (
|
|
1055
|
+
"ro.serialno",
|
|
1056
|
+
"ro.boot.serialno",
|
|
1057
|
+
"ro.vendor.boot.serialno",
|
|
1058
|
+
"ro.product.serialno",
|
|
1059
|
+
)
|
|
1060
|
+
|
|
1061
|
+
|
|
1062
|
+
def _load_devices(devices_path: str, adb: AdbClient, *, resolve_transports: bool = True):
|
|
1052
1063
|
devices = load_devices(devices_path)
|
|
1064
|
+
if not resolve_transports:
|
|
1065
|
+
return devices
|
|
1066
|
+
return _resolve_device_transports(devices, adb)
|
|
1067
|
+
|
|
1068
|
+
|
|
1069
|
+
def _resolve_device_transports(devices: dict[str, object], adb: AdbClient):
|
|
1070
|
+
transport_by_identity = _connected_transport_by_identity(adb)
|
|
1071
|
+
if not transport_by_identity:
|
|
1072
|
+
return devices
|
|
1073
|
+
resolved = {}
|
|
1074
|
+
for device_id, device in devices.items():
|
|
1075
|
+
transport = ""
|
|
1076
|
+
for identity in _device_identity_candidates(device):
|
|
1077
|
+
transport = transport_by_identity.get(identity)
|
|
1078
|
+
if transport:
|
|
1079
|
+
break
|
|
1080
|
+
resolved[device_id] = replace(device, adb_serial=transport) if transport and transport != device.adb_serial else device
|
|
1081
|
+
return resolved
|
|
1082
|
+
|
|
1083
|
+
|
|
1084
|
+
def _connected_transport_by_identity(adb: AdbClient) -> dict[str, str]:
|
|
1085
|
+
out: dict[str, str] = {}
|
|
1086
|
+
for transport in adb.list_devices():
|
|
1087
|
+
for identity in {transport, _device_hardware_id(adb, transport)}:
|
|
1088
|
+
clean = _clean_device_identity(identity)
|
|
1089
|
+
if clean:
|
|
1090
|
+
out[clean] = transport
|
|
1091
|
+
return out
|
|
1092
|
+
|
|
1093
|
+
|
|
1094
|
+
def _device_hardware_id(adb: AdbClient, transport: str) -> str:
|
|
1095
|
+
for prop in DEVICE_IDENTITY_PROPS:
|
|
1096
|
+
value = adb.getprop(transport, prop).strip()
|
|
1097
|
+
if value and value.lower() not in {"unknown", "null", "none"}:
|
|
1098
|
+
return value
|
|
1099
|
+
return ""
|
|
1100
|
+
|
|
1101
|
+
|
|
1102
|
+
def _device_identity_candidates(device) -> list[str]:
|
|
1103
|
+
candidates = [
|
|
1104
|
+
getattr(device, "hardware_id", ""),
|
|
1105
|
+
getattr(device, "adb_serial", ""),
|
|
1106
|
+
]
|
|
1107
|
+
return [clean for clean in (_clean_device_identity(item) for item in candidates) if clean]
|
|
1108
|
+
|
|
1109
|
+
|
|
1110
|
+
def _clean_device_identity(value: str) -> str:
|
|
1111
|
+
return str(value or "").strip().lower()
|
|
1112
|
+
|
|
1113
|
+
|
|
1114
|
+
def _get_device(devices_path: str, device_id: str, adb: AdbClient):
|
|
1115
|
+
devices = _load_devices(devices_path, adb)
|
|
1053
1116
|
if device_id not in devices:
|
|
1054
1117
|
raise SystemExit(f"Unknown device_id={device_id}")
|
|
1055
1118
|
return devices[device_id]
|
|
1056
1119
|
|
|
1057
1120
|
|
|
1058
|
-
def _resolve_appium_udids(devices_path: str, device_ids: list[str], udids: list[str]) -> list[str]:
|
|
1121
|
+
def _resolve_appium_udids(devices_path: str, device_ids: list[str], udids: list[str], adb: AdbClient) -> list[str]:
|
|
1059
1122
|
resolved = [item for item in udids if item]
|
|
1060
1123
|
if device_ids:
|
|
1061
|
-
devices =
|
|
1124
|
+
devices = _load_devices(devices_path, adb)
|
|
1062
1125
|
for device_id in device_ids:
|
|
1063
1126
|
if device_id not in devices:
|
|
1064
1127
|
raise SystemExit(f"Unknown device_id={device_id}")
|
|
@@ -23,6 +23,7 @@ def load_devices(path: str | Path) -> dict[str, Device]:
|
|
|
23
23
|
device_id=row["device_id"],
|
|
24
24
|
adb_serial=row["adb_serial"],
|
|
25
25
|
ip_address=row.get("ip_address", ""),
|
|
26
|
+
hardware_id=row.get("hardware_id", "") or row.get("physical_id", "") or row["adb_serial"],
|
|
26
27
|
brand=row.get("brand", ""),
|
|
27
28
|
model=row.get("model", ""),
|
|
28
29
|
android_version=row.get("android_version", ""),
|
package/package.json
CHANGED
package/src/commands/mobile.js
CHANGED
|
@@ -235,6 +235,104 @@ async function writeFileIfMissing(filePath, content) {
|
|
|
235
235
|
}
|
|
236
236
|
}
|
|
237
237
|
|
|
238
|
+
async function mergeDeviceRegistry(filePath, templateContent) {
|
|
239
|
+
let currentContent = ''
|
|
240
|
+
try {
|
|
241
|
+
currentContent = await readFile(filePath, 'utf8')
|
|
242
|
+
} catch {
|
|
243
|
+
await mkdir(path.dirname(filePath), { recursive: true })
|
|
244
|
+
await writeFile(filePath, templateContent)
|
|
245
|
+
return
|
|
246
|
+
}
|
|
247
|
+
const current = parseCsv(currentContent)
|
|
248
|
+
const template = parseCsv(templateContent)
|
|
249
|
+
const headers = [...current.headers]
|
|
250
|
+
for (const header of template.headers) {
|
|
251
|
+
if (!headers.includes(header)) headers.push(header)
|
|
252
|
+
}
|
|
253
|
+
const rows = current.rows.map(row => ({ ...row }))
|
|
254
|
+
const rowByDeviceId = new Map(rows.map(row => [row.device_id, row]))
|
|
255
|
+
for (const seed of template.rows) {
|
|
256
|
+
const deviceId = seed.device_id
|
|
257
|
+
const existing = rowByDeviceId.get(deviceId)
|
|
258
|
+
if (!existing) {
|
|
259
|
+
rows.push({ ...seed })
|
|
260
|
+
rowByDeviceId.set(deviceId, rows[rows.length - 1])
|
|
261
|
+
continue
|
|
262
|
+
}
|
|
263
|
+
if (isCanonicalDeviceId(deviceId)) {
|
|
264
|
+
const managedCanonicalRow = /^stable device registry slot/i.test(existing.notes || '')
|
|
265
|
+
const serialChanged = existing.adb_serial && existing.adb_serial !== seed.adb_serial
|
|
266
|
+
existing.adb_serial = seed.adb_serial
|
|
267
|
+
existing.hardware_id = seed.hardware_id || seed.adb_serial
|
|
268
|
+
if (managedCanonicalRow || serialChanged) {
|
|
269
|
+
existing.brand = seed.brand || ''
|
|
270
|
+
existing.model = seed.model || ''
|
|
271
|
+
existing.android_version = seed.android_version || ''
|
|
272
|
+
}
|
|
273
|
+
if (!existing.status) existing.status = seed.status || 'active'
|
|
274
|
+
if (!existing.notes || /replace with|single-device POC/i.test(existing.notes)) existing.notes = seed.notes || ''
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
await writeFile(filePath, formatCsv(headers, rows))
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
function isCanonicalDeviceId(deviceId = '') {
|
|
281
|
+
return /^D(?:0[1-9]|10)$/.test(String(deviceId))
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
function parseCsv(text = '') {
|
|
285
|
+
const lines = String(text || '').split(/\r?\n/).filter(line => line.length)
|
|
286
|
+
if (!lines.length) return { headers: [], rows: [] }
|
|
287
|
+
const headers = parseCsvLine(lines[0])
|
|
288
|
+
return {
|
|
289
|
+
headers,
|
|
290
|
+
rows: lines.slice(1).map(line => {
|
|
291
|
+
const values = parseCsvLine(line)
|
|
292
|
+
return Object.fromEntries(headers.map((header, index) => [header, values[index] || '']))
|
|
293
|
+
}),
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
function parseCsvLine(line = '') {
|
|
298
|
+
const out = []
|
|
299
|
+
let value = ''
|
|
300
|
+
let quoted = false
|
|
301
|
+
for (let i = 0; i < line.length; i += 1) {
|
|
302
|
+
const ch = line[i]
|
|
303
|
+
if (ch === '"') {
|
|
304
|
+
if (quoted && line[i + 1] === '"') {
|
|
305
|
+
value += '"'
|
|
306
|
+
i += 1
|
|
307
|
+
} else {
|
|
308
|
+
quoted = !quoted
|
|
309
|
+
}
|
|
310
|
+
continue
|
|
311
|
+
}
|
|
312
|
+
if (ch === ',' && !quoted) {
|
|
313
|
+
out.push(value)
|
|
314
|
+
value = ''
|
|
315
|
+
continue
|
|
316
|
+
}
|
|
317
|
+
value += ch
|
|
318
|
+
}
|
|
319
|
+
out.push(value)
|
|
320
|
+
return out
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
function formatCsv(headers, rows) {
|
|
324
|
+
return [
|
|
325
|
+
headers.join(','),
|
|
326
|
+
...rows.map(row => headers.map(header => csvCell(row[header] || '')).join(',')),
|
|
327
|
+
].join('\n') + '\n'
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
function csvCell(value) {
|
|
331
|
+
const text = String(value ?? '')
|
|
332
|
+
if (!/[",\n\r]/.test(text)) return text
|
|
333
|
+
return `"${text.replaceAll('"', '""')}"`
|
|
334
|
+
}
|
|
335
|
+
|
|
238
336
|
async function scaffoldMobileHome(home) {
|
|
239
337
|
await mkdir(home, { recursive: true })
|
|
240
338
|
await mkdir(path.join(home, 'data'), { recursive: true })
|
|
@@ -248,7 +346,7 @@ async function scaffoldMobileHome(home) {
|
|
|
248
346
|
path.join(home, 'data', 'accounts.csv'),
|
|
249
347
|
await readFile(path.join(MOBILE_DATA_TEMPLATE_ROOT, 'accounts.example.csv'), 'utf8')
|
|
250
348
|
)
|
|
251
|
-
await
|
|
349
|
+
await mergeDeviceRegistry(
|
|
252
350
|
path.join(home, 'data', 'devices.csv'),
|
|
253
351
|
await readFile(path.join(MOBILE_DATA_TEMPLATE_ROOT, 'devices.example.csv'), 'utf8')
|
|
254
352
|
)
|