@phystack/device-simulator 6.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,518 @@
1
+ import chalk from "chalk";
2
+ import path from "path";
3
+ import fs from "fs-extra";
4
+ import { spawn, spawnSync, execSync } from "child_process";
5
+ import { io as socketIOClient } from "socket.io-client";
6
+ import { getAppConfigByPath, saveAppConfig } from "../utils/simulator-config";
7
+ import { AppConfig, AppTypeEnum, TwinTypeEnum } from "../simulator/types";
8
+
9
+ const SIMULATOR_URL = "http://localhost:55000";
10
+
11
+ const APP_TYPE_TO_TWIN_TYPE: Record<
12
+ AppTypeEnum,
13
+ TwinTypeEnum.Screen | TwinTypeEnum.Edge
14
+ > = {
15
+ [AppTypeEnum.Screen]: TwinTypeEnum.Screen,
16
+ [AppTypeEnum.Edge]: TwinTypeEnum.Edge,
17
+ };
18
+
19
+ const SUPPORTED_APP_TYPES = new Set<string>(Object.values(AppTypeEnum));
20
+
21
+ // Test seam: these pure helpers are covered by unit tests in __tests__/.
22
+ // They carry no side effects and are exported solely so the test suite can
23
+ // assert arg-parsing/type-detection/docker-arg behavior without spawning a
24
+ // dev server or Docker. Runtime behavior is unchanged.
25
+ export function resolveAppType(
26
+ optionType?: string,
27
+ pkgType?: string,
28
+ ): AppTypeEnum {
29
+ const raw = (optionType || pkgType || "").toLowerCase();
30
+ if (!raw) {
31
+ throw new Error(
32
+ 'Cannot detect app type. Set "application-type" in package.json or pass --type.',
33
+ );
34
+ }
35
+ if (!SUPPORTED_APP_TYPES.has(raw)) {
36
+ throw new Error(
37
+ `Unsupported app type "${raw}". Supported types: ${[...SUPPORTED_APP_TYPES].join(", ")}`,
38
+ );
39
+ }
40
+ return raw as AppTypeEnum;
41
+ }
42
+
43
+ async function tryReadSettingsFiles(
44
+ appPath: string,
45
+ files: string[],
46
+ ): Promise<Record<string, any> | null> {
47
+ for (const settingsPath of files) {
48
+ if (await fs.pathExists(settingsPath)) {
49
+ try {
50
+ const data = await fs.readJSON(settingsPath);
51
+ const settings = data?.app?.gridApp?.settings;
52
+ if (settings) {
53
+ console.log(
54
+ chalk.dim(
55
+ `Read settings from ${path.relative(appPath, settingsPath)}`,
56
+ ),
57
+ );
58
+ return settings;
59
+ }
60
+ } catch (err) {
61
+ console.log(
62
+ `[simulator] Failed to read ${path.relative(appPath, settingsPath)}: ${err.message}`,
63
+ );
64
+ }
65
+ }
66
+ }
67
+ return null;
68
+ }
69
+
70
+ async function readLocalSettings(
71
+ appPath: string,
72
+ settingsDir = "src/settings",
73
+ ): Promise<Record<string, any>> {
74
+ const settingsFiles = [path.join(appPath, settingsDir, "index.json")];
75
+
76
+ const settings = await tryReadSettingsFiles(appPath, settingsFiles);
77
+ if (settings) return settings;
78
+
79
+ // If neither exists, try running init-settings.js then retry
80
+ const initScript = path.join(appPath, "scripts", "init-settings.js");
81
+ if (await fs.pathExists(initScript)) {
82
+ console.log(chalk.dim("No settings found, running init-settings.js..."));
83
+ try {
84
+ execSync("node scripts/init-settings.js", {
85
+ cwd: appPath,
86
+ stdio: "pipe",
87
+ });
88
+ } catch (err) {
89
+ console.log(`[simulator] init-settings.js failed: ${err.message}`);
90
+ }
91
+
92
+ const retrySettings = await tryReadSettingsFiles(appPath, settingsFiles);
93
+ if (retrySettings) return retrySettings;
94
+ }
95
+
96
+ return {};
97
+ }
98
+
99
+ function connectSimulator(): Promise<ReturnType<typeof socketIOClient>> {
100
+ return new Promise((resolve, reject) => {
101
+ const socket = socketIOClient(SIMULATOR_URL, {
102
+ reconnectionAttempts: 1,
103
+ timeout: 3000,
104
+ });
105
+ socket.on("connect", () => resolve(socket));
106
+ socket.on("connect_error", (err: Error) => {
107
+ reject(new Error(`Cannot connect to simulator: ${err.message}`));
108
+ });
109
+ });
110
+ }
111
+
112
+ function sendSimulatorMessage(
113
+ socket: ReturnType<typeof socketIOClient>,
114
+ payload: Record<string, any>,
115
+ ): Promise<any> {
116
+ return new Promise((resolve, reject) => {
117
+ const timer = setTimeout(() => {
118
+ reject(new Error("Timeout waiting for simulator response"));
119
+ }, 10000);
120
+
121
+ socket.emit("simulator", payload, (response: any) => {
122
+ clearTimeout(timer);
123
+ resolve(response);
124
+ });
125
+ });
126
+ }
127
+
128
+ async function createTwinViaSimulator(
129
+ type: TwinTypeEnum.Screen | TwinTypeEnum.Edge,
130
+ desiredProperties: Record<string, any>,
131
+ reuseId?: string,
132
+ ): Promise<{ id: string }> {
133
+ const socket = await connectSimulator();
134
+ try {
135
+ const response = await sendSimulatorMessage(socket, {
136
+ method: "createInstanceTwin",
137
+ data: { type, desiredProperties, id: reuseId },
138
+ });
139
+ if (response?.status === "success" && response?.twin) {
140
+ return response.twin;
141
+ }
142
+ throw new Error(response?.message || "Failed to create twin");
143
+ } finally {
144
+ socket.disconnect();
145
+ }
146
+ }
147
+
148
+ export default async (
149
+ appPath: string,
150
+ options: { type?: string; devCommand?: string; settingsDir?: string } = {},
151
+ ): Promise<void> => {
152
+ // 1. Resolve path
153
+ const resolvedPath = path.resolve(appPath);
154
+ if (!(await fs.pathExists(resolvedPath))) {
155
+ console.error(chalk.red(`Path does not exist: ${resolvedPath}`));
156
+ process.exit(1);
157
+ }
158
+
159
+ // 2. Read package.json
160
+ const pkgPath = path.join(resolvedPath, "package.json");
161
+ if (!(await fs.pathExists(pkgPath))) {
162
+ console.error(
163
+ chalk.red(
164
+ `No package.json found at ${resolvedPath}. If this is a Python edge app, ensure the scaffold includes a package.json with "application-type": "edge".`,
165
+ ),
166
+ );
167
+ process.exit(1);
168
+ }
169
+
170
+ const pkg = await fs.readJSON(pkgPath);
171
+ const appName = pkg.name || path.basename(resolvedPath);
172
+
173
+ // 3. Resolve app type
174
+ const appType = resolveAppType(options.type, pkg["application-type"]);
175
+
176
+ const startCommand = resolveStartCommand(options, pkg, appType);
177
+
178
+ // 4. Check simulator is running
179
+ let isRunning = false;
180
+ try {
181
+ const socket = await connectSimulator();
182
+ socket.disconnect();
183
+ isRunning = true;
184
+ } catch (err) {
185
+ console.log(`[simulator] Connection check failed: ${err.message}`);
186
+ isRunning = false;
187
+ }
188
+
189
+ if (!isRunning) {
190
+ console.error(
191
+ chalk.red(
192
+ "Simulator is not running. Start it first with `phy simulator start`.",
193
+ ),
194
+ );
195
+ process.exit(1);
196
+ }
197
+
198
+ // 5. Twin reconciliation (use "local" as tenantId for config storage)
199
+ const localTenantId = "local";
200
+ const appConfig = await getAppConfigByPath(localTenantId, resolvedPath);
201
+ let twinId: string | undefined = appConfig?.twinId;
202
+
203
+ const persistedTwinId = twinId;
204
+
205
+ const localSettings = await readLocalSettings(
206
+ resolvedPath,
207
+ options.settingsDir,
208
+ );
209
+ if (Object.keys(localSettings).length > 0) {
210
+ console.log(
211
+ chalk.green(
212
+ `Loaded ${Object.keys(localSettings).length} settings from local files`,
213
+ ),
214
+ );
215
+ }
216
+
217
+ // Always (re)create twin with fresh settings — reuse persisted ID for stable twin across restarts
218
+ const twinType = APP_TYPE_TO_TWIN_TYPE[appType];
219
+ if (persistedTwinId) {
220
+ console.log(
221
+ chalk.dim(`Reusing instance ID ${persistedTwinId} for ${appName}`),
222
+ );
223
+ } else {
224
+ console.log(
225
+ chalk.dim(`Creating new ${appType} instance for ${appName}...`),
226
+ );
227
+ }
228
+ const twin = await createTwinViaSimulator(
229
+ twinType,
230
+ { settings: localSettings, appName, appVersion: pkg.version || "0.0.0" },
231
+ persistedTwinId,
232
+ );
233
+ twinId = twin.id;
234
+ if (persistedTwinId) {
235
+ console.log(chalk.green(`Recreated twin with existing ID ${twinId}`));
236
+ } else {
237
+ console.log(chalk.green(`Created twin with new ID ${twinId}`));
238
+ }
239
+
240
+ // Save/update app config
241
+ const newAppConfig: AppConfig = {
242
+ name: appName,
243
+ type: appType,
244
+ path: resolvedPath,
245
+ twinId,
246
+ devCommand: startCommand || "docker",
247
+ };
248
+ await saveAppConfig(localTenantId, newAppConfig);
249
+
250
+ // 6. Type-specific launch
251
+ if (appType === AppTypeEnum.Screen) {
252
+ await launchWithStartCommand(
253
+ resolvedPath,
254
+ pkg,
255
+ startCommand!,
256
+ twinId,
257
+ appName,
258
+ appType,
259
+ );
260
+ } else if (appType === AppTypeEnum.Edge) {
261
+ const dockerfilePath = path.join(resolvedPath, "Dockerfile");
262
+ if (await fs.pathExists(dockerfilePath)) {
263
+ await launchWithDocker(resolvedPath, pkg, twinId, appName);
264
+ } else if (startCommand) {
265
+ await launchWithStartCommand(
266
+ resolvedPath,
267
+ pkg,
268
+ startCommand,
269
+ twinId,
270
+ appName,
271
+ appType,
272
+ );
273
+ } else {
274
+ throw new Error(
275
+ 'No Dockerfile or start script found. Add a Dockerfile or a "start" script in package.json.',
276
+ );
277
+ }
278
+ }
279
+ };
280
+
281
+ export function resolveStartCommand(
282
+ options: { devCommand?: string },
283
+ pkg: any,
284
+ appType: AppTypeEnum,
285
+ ): string | undefined {
286
+ if (options.devCommand) return options.devCommand;
287
+ if (pkg.scripts?.start) return "npm start";
288
+
289
+ // For edge apps with a Dockerfile, start command is optional
290
+ if (appType === AppTypeEnum.Edge) return undefined;
291
+
292
+ throw new Error(
293
+ "No start script found in package.json. Pass --dev-command to specify one.",
294
+ );
295
+ }
296
+
297
+ async function launchWithStartCommand(
298
+ resolvedPath: string,
299
+ pkg: any,
300
+ startCommand: string,
301
+ twinId: string,
302
+ appName: string,
303
+ appType: AppTypeEnum,
304
+ ): Promise<void> {
305
+ console.log("");
306
+ console.log(chalk.green.bold(`Starting ${appName}`));
307
+ console.log(chalk.dim("─".repeat(40)));
308
+ console.log(` ${chalk.cyan("Type:")} ${appType}`);
309
+ console.log(` ${chalk.cyan("Twin ID:")} ${twinId}`);
310
+ console.log(` ${chalk.cyan("Path:")} ${resolvedPath}`);
311
+ console.log(` ${chalk.cyan("Command:")} ${startCommand}`);
312
+ console.log(chalk.dim("─".repeat(40)));
313
+ console.log("");
314
+
315
+ const env = {
316
+ ...process.env,
317
+ TWIN_ID: twinId,
318
+ PHYSTACK_SIMULATOR_URL: SIMULATOR_URL,
319
+ };
320
+
321
+ const child = spawn(startCommand, {
322
+ cwd: resolvedPath,
323
+ stdio: "inherit",
324
+ env,
325
+ shell: true,
326
+ });
327
+
328
+ await new Promise<void>((resolve, reject) => {
329
+ child.on("close", (code) => {
330
+ if (code === 0) {
331
+ resolve();
332
+ } else {
333
+ reject(new Error(`Start command exited with code ${code}`));
334
+ }
335
+ });
336
+
337
+ child.on("error", (err) => {
338
+ reject(err);
339
+ });
340
+ });
341
+ }
342
+
343
+ async function readContainerConfig(appPath: string): Promise<any | null> {
344
+ const settingsPath = path.join(appPath, "settings.json");
345
+ if (!(await fs.pathExists(settingsPath))) return null;
346
+ return fs.readJSON(settingsPath);
347
+ }
348
+
349
+ export function buildDockerRunArgs(
350
+ config: any,
351
+ image: string,
352
+ twinId: string,
353
+ ): string[] {
354
+ const args = ["run", "--rm", "--name", `simulator-${Date.now()}`];
355
+
356
+ // --- Environment variables (mirrors device-phyos: TWIN_ID, TZ, custom Env) ---
357
+ args.push("-e", `TWIN_ID=${twinId}`);
358
+ args.push("-e", `PHYSTACK_SIMULATOR_URL=http://host.docker.internal:55000`);
359
+ if (process.env.TZ) {
360
+ args.push("-e", `TZ=${process.env.TZ}`);
361
+ }
362
+
363
+ // --- Network: mirrors device-phyos NetworkMode + ExtraHosts ---
364
+ const isHostNetwork = config?.HostConfig?.NetworkMode === "host";
365
+ if (isHostNetwork) {
366
+ args.push("--network", "host");
367
+ // In host mode, production maps phyos → 127.0.0.1
368
+ args.push("--add-host", "phyos:127.0.0.1");
369
+ } else {
370
+ // Bridge mode (default): simulator needs host.docker.internal for callback
371
+ args.push("--add-host=host.docker.internal:host-gateway");
372
+ // In bridge mode, production maps phyos → 172.26.128.1
373
+ args.push("--add-host", "phyos:host-gateway");
374
+ }
375
+
376
+ if (config) {
377
+ // Custom env vars from settings.json
378
+ for (const env of config.Env || []) {
379
+ args.push("-e", env);
380
+ }
381
+
382
+ // --- Entrypoint (production passes Entrypoint from createOptions) ---
383
+ if (config.Entrypoint) {
384
+ const ep = Array.isArray(config.Entrypoint)
385
+ ? config.Entrypoint
386
+ : [config.Entrypoint];
387
+ args.push("--entrypoint", ep[0]);
388
+ }
389
+
390
+ // --- Port bindings ---
391
+ const ports = config.HostConfig?.PortBindings || {};
392
+ for (const [containerPort, bindings] of Object.entries(ports)) {
393
+ for (const binding of bindings as any[]) {
394
+ args.push(
395
+ "-p",
396
+ `${binding.HostPort}:${containerPort.replace("/tcp", "")}`,
397
+ );
398
+ }
399
+ }
400
+
401
+ // --- Volume binds ---
402
+ for (const bind of config.HostConfig?.Binds || []) {
403
+ args.push("-v", bind);
404
+ }
405
+
406
+ // --- Devices ---
407
+ for (const device of config.HostConfig?.Devices || []) {
408
+ args.push("--device", device.PathOnHost);
409
+ }
410
+
411
+ // --- GPU / DeviceRequests (production detects nvidia runtime) ---
412
+ for (const req of config.HostConfig?.DeviceRequests || []) {
413
+ const isGpu =
414
+ req.Capabilities &&
415
+ req.Capabilities.some((cap: string[]) => cap.includes("gpu"));
416
+ if (isGpu) {
417
+ args.push("--gpus", "all");
418
+ }
419
+ }
420
+
421
+ // --- Privileged ---
422
+ if (config.HostConfig?.Privileged) {
423
+ args.push("--privileged");
424
+ }
425
+
426
+ // --- Resource limits ---
427
+ if (config.HostConfig?.Memory > 0) {
428
+ args.push("--memory", String(config.HostConfig.Memory));
429
+ }
430
+ if (config.HostConfig?.CpuShares > 0) {
431
+ args.push("--cpu-shares", String(config.HostConfig.CpuShares));
432
+ }
433
+
434
+ // --- Ulimits ---
435
+ for (const ulimit of config.HostConfig?.Ulimits || []) {
436
+ args.push("--ulimit", `${ulimit.Name}=${ulimit.Soft}:${ulimit.Hard}`);
437
+ }
438
+
439
+ // --- Read-only root filesystem ---
440
+ if (config.HostConfig?.ReadonlyRootfs) {
441
+ args.push("--read-only");
442
+ }
443
+ }
444
+
445
+ args.push(image);
446
+
447
+ // --- Custom CMD (production passes Cmd from createOptions) ---
448
+ if (config?.Cmd) {
449
+ args.push(...config.Cmd);
450
+ }
451
+
452
+ return args;
453
+ }
454
+
455
+ async function launchWithDocker(
456
+ appPath: string,
457
+ pkg: any,
458
+ twinId: string,
459
+ appName: string,
460
+ ): Promise<void> {
461
+ const imageName = pkg.name.replace(/[^a-z0-9_.-]/g, "-");
462
+ const imageTag = `${imageName}:${pkg.version || "latest"}`;
463
+
464
+ // Build
465
+ console.log("");
466
+ console.log(chalk.green.bold(`Building Docker image for ${appName}`));
467
+ console.log(chalk.dim(`Image: ${imageTag}`));
468
+ console.log("");
469
+
470
+ const buildResult = spawnSync("docker", ["build", "-t", imageTag, "."], {
471
+ cwd: appPath,
472
+ stdio: "inherit",
473
+ });
474
+ if (buildResult.status !== 0) {
475
+ throw new Error(`Docker build failed with exit code ${buildResult.status}`);
476
+ }
477
+
478
+ // Read container config from settings.json
479
+ const containerConfig = await readContainerConfig(appPath);
480
+
481
+ // Run
482
+ const dockerArgs = buildDockerRunArgs(containerConfig, imageTag, twinId);
483
+
484
+ console.log("");
485
+ console.log(chalk.green.bold(`Starting ${appName}`));
486
+ console.log(chalk.dim("─".repeat(40)));
487
+ console.log(` ${chalk.cyan("Type:")} edge (Docker)`);
488
+ console.log(` ${chalk.cyan("Twin ID:")} ${twinId}`);
489
+ console.log(` ${chalk.cyan("Path:")} ${appPath}`);
490
+ console.log(` ${chalk.cyan("Image:")} ${imageTag}`);
491
+ console.log(chalk.dim("─".repeat(40)));
492
+ console.log(chalk.dim(`docker ${dockerArgs.join(" ")}`));
493
+ console.log("");
494
+
495
+ const child = spawn("docker", dockerArgs, {
496
+ cwd: appPath,
497
+ stdio: "inherit",
498
+ });
499
+
500
+ // Graceful shutdown
501
+ const containerName = dockerArgs[dockerArgs.indexOf("--name") + 1];
502
+ const cleanup = () => {
503
+ spawnSync("docker", ["stop", containerName], { stdio: "pipe" });
504
+ };
505
+ process.on("SIGINT", cleanup);
506
+ process.on("SIGTERM", cleanup);
507
+
508
+ await new Promise<void>((resolve, reject) => {
509
+ child.on("close", (code) => {
510
+ if (code === 0) {
511
+ resolve();
512
+ } else {
513
+ reject(new Error(`Docker exited with code ${code}`));
514
+ }
515
+ });
516
+ child.on("error", reject);
517
+ });
518
+ }