@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.
- package/CHANGELOG.md +14 -0
- package/build-binary.sh +37 -0
- package/dist/index.js +37 -0
- package/package.json +38 -0
- package/src/__tests__/e2e/binary.e2e.test.ts +394 -0
- package/src/__tests__/preload.ts +44 -0
- package/src/command.ts +66 -0
- package/src/commands/__tests__/run-helpers.test.ts +181 -0
- package/src/commands/list.ts +25 -0
- package/src/commands/remove.ts +16 -0
- package/src/commands/run.ts +518 -0
- package/src/commands/start.ts +309 -0
- package/src/index.ts +45 -0
- package/src/services/__tests__/dev-token.test.ts +156 -0
- package/src/services/dev-token.ts +52 -0
- package/src/services/env.ts +10 -0
- package/src/simulator/__tests__/message-router.test.ts +782 -0
- package/src/simulator/__tests__/twin-cache.test.ts +129 -0
- package/src/simulator/index.ts +200 -0
- package/src/simulator/local-server.ts +184 -0
- package/src/simulator/logger.ts +44 -0
- package/src/simulator/message-router.ts +525 -0
- package/src/simulator/twin-cache.ts +61 -0
- package/src/simulator/types.ts +53 -0
- package/src/utils/__tests__/simulator-config.test.ts +230 -0
- package/src/utils/config-paths.ts +38 -0
- package/src/utils/index.ts +41 -0
- package/src/utils/simulator-config.ts +185 -0
- package/src/utils/tenant-storage.ts +106 -0
- package/tsconfig.json +12 -0
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import fs from "fs";
|
|
3
|
+
import os from "os";
|
|
4
|
+
import path from "path";
|
|
5
|
+
import { SUITE_TEST_HOME } from "../../__tests__/preload";
|
|
6
|
+
|
|
7
|
+
// os.homedir() is redirected to SUITE_TEST_HOME by the test preload (wired via
|
|
8
|
+
// bunfig.toml) BEFORE any module — including config-paths.ts, which computes
|
|
9
|
+
// CONFIG_DIR = os.homedir()/.config/phystack-cli at import time — is evaluated.
|
|
10
|
+
// On macOS/Linux os.homedir() reads the OS user database and ignores
|
|
11
|
+
// process.env.HOME, so the preload override (not a mere HOME assignment) is what
|
|
12
|
+
// keeps the real ~/.config/phystack-cli untouched. We reuse the same root here
|
|
13
|
+
// so the on-disk-path assertions below match what the module under test uses.
|
|
14
|
+
const TEST_HOME = SUITE_TEST_HOME;
|
|
15
|
+
const CONFIG_ROOT = path.join(TEST_HOME, ".config", "phystack-cli");
|
|
16
|
+
|
|
17
|
+
const configModule = await import("../simulator-config");
|
|
18
|
+
const {
|
|
19
|
+
saveDeviceConfig,
|
|
20
|
+
getDeviceConfig,
|
|
21
|
+
saveAppConfig,
|
|
22
|
+
getAppConfig,
|
|
23
|
+
getAppConfigByPath,
|
|
24
|
+
listApps,
|
|
25
|
+
updateAppTwinId,
|
|
26
|
+
saveSimulator,
|
|
27
|
+
getSimulator,
|
|
28
|
+
listSimulators,
|
|
29
|
+
removeSimulator,
|
|
30
|
+
} = configModule;
|
|
31
|
+
|
|
32
|
+
describe("simulator-config — persistence isolation", () => {
|
|
33
|
+
test("config root resolves under the temp HOME, not the real home", () => {
|
|
34
|
+
expect(CONFIG_ROOT.startsWith(TEST_HOME)).toBe(true);
|
|
35
|
+
// The user's real home must NOT be the test root (proves isolation).
|
|
36
|
+
expect(TEST_HOME).not.toBe(os.userInfo().homedir);
|
|
37
|
+
});
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
describe("simulator-config — device config round-trip", () => {
|
|
41
|
+
test("saveDeviceConfig writes JSON under simulator/tenants/<id> and reads back identical", async () => {
|
|
42
|
+
const tenantId = "tenant-device-rt";
|
|
43
|
+
const config = {
|
|
44
|
+
deviceId: "dev-123",
|
|
45
|
+
tenantId: "tenant-obj-id",
|
|
46
|
+
deviceTwinId: "twin-456",
|
|
47
|
+
};
|
|
48
|
+
await saveDeviceConfig(tenantId, config);
|
|
49
|
+
|
|
50
|
+
const onDisk = path.join(
|
|
51
|
+
CONFIG_ROOT,
|
|
52
|
+
"simulator",
|
|
53
|
+
"tenants",
|
|
54
|
+
tenantId,
|
|
55
|
+
"device.json",
|
|
56
|
+
);
|
|
57
|
+
expect(fs.existsSync(onDisk)).toBe(true);
|
|
58
|
+
expect(JSON.parse(fs.readFileSync(onDisk, "utf8"))).toEqual(config);
|
|
59
|
+
// Exact serialization shape: 2-space indented JSON.
|
|
60
|
+
expect(fs.readFileSync(onDisk, "utf8")).toBe(
|
|
61
|
+
JSON.stringify(config, null, 2),
|
|
62
|
+
);
|
|
63
|
+
|
|
64
|
+
expect(await getDeviceConfig(tenantId)).toEqual(config);
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
test("getDeviceConfig returns undefined when none saved", async () => {
|
|
68
|
+
expect(await getDeviceConfig("tenant-never-saved")).toBeUndefined();
|
|
69
|
+
});
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
describe("simulator-config — app config round-trip", () => {
|
|
73
|
+
test("saveAppConfig stores by app name and reads back via getAppConfig", async () => {
|
|
74
|
+
const tenantId = "tenant-app-rt";
|
|
75
|
+
const appConfig = {
|
|
76
|
+
name: "my-app",
|
|
77
|
+
type: "screen" as any,
|
|
78
|
+
path: "/abs/path/to/my-app",
|
|
79
|
+
twinId: "twin-app-1",
|
|
80
|
+
devCommand: "npm run dev",
|
|
81
|
+
};
|
|
82
|
+
await saveAppConfig(tenantId, appConfig);
|
|
83
|
+
|
|
84
|
+
const onDisk = path.join(
|
|
85
|
+
CONFIG_ROOT,
|
|
86
|
+
"simulator",
|
|
87
|
+
"tenants",
|
|
88
|
+
tenantId,
|
|
89
|
+
"my-app.json",
|
|
90
|
+
);
|
|
91
|
+
expect(fs.existsSync(onDisk)).toBe(true);
|
|
92
|
+
expect(JSON.parse(fs.readFileSync(onDisk, "utf8"))).toEqual(appConfig);
|
|
93
|
+
|
|
94
|
+
expect(await getAppConfig(tenantId, "my-app")).toEqual(appConfig);
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
test("getAppConfigByPath resolves the app whose `path` matches", async () => {
|
|
98
|
+
const tenantId = "tenant-app-bypath";
|
|
99
|
+
const first = {
|
|
100
|
+
name: "app-one",
|
|
101
|
+
type: "edge" as any,
|
|
102
|
+
path: "/abs/one",
|
|
103
|
+
devCommand: "docker",
|
|
104
|
+
};
|
|
105
|
+
const second = {
|
|
106
|
+
name: "app-two",
|
|
107
|
+
type: "screen" as any,
|
|
108
|
+
path: "/abs/two",
|
|
109
|
+
devCommand: "npm start",
|
|
110
|
+
};
|
|
111
|
+
await saveAppConfig(tenantId, first);
|
|
112
|
+
await saveAppConfig(tenantId, second);
|
|
113
|
+
|
|
114
|
+
expect(await getAppConfigByPath(tenantId, "/abs/two")).toEqual(second);
|
|
115
|
+
expect(await getAppConfigByPath(tenantId, "/abs/missing")).toBeUndefined();
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
test("listApps returns every saved app config for the tenant", async () => {
|
|
119
|
+
const tenantId = "tenant-list-apps";
|
|
120
|
+
const appA = {
|
|
121
|
+
name: "a",
|
|
122
|
+
type: "screen" as any,
|
|
123
|
+
path: "/a",
|
|
124
|
+
devCommand: "npm start",
|
|
125
|
+
};
|
|
126
|
+
const appB = {
|
|
127
|
+
name: "b",
|
|
128
|
+
type: "edge" as any,
|
|
129
|
+
path: "/b",
|
|
130
|
+
devCommand: "docker",
|
|
131
|
+
};
|
|
132
|
+
await saveAppConfig(tenantId, appA);
|
|
133
|
+
await saveAppConfig(tenantId, appB);
|
|
134
|
+
|
|
135
|
+
const apps = await listApps(tenantId);
|
|
136
|
+
expect(apps.map((app) => app.name).sort()).toEqual(["a", "b"]);
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
test("listApps returns empty array for an unknown tenant", async () => {
|
|
140
|
+
expect(await listApps("tenant-no-apps")).toEqual([]);
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
test("updateAppTwinId rewrites only the twinId, preserving other fields", async () => {
|
|
144
|
+
const tenantId = "tenant-update-twin";
|
|
145
|
+
const appConfig = {
|
|
146
|
+
name: "updatable",
|
|
147
|
+
type: "screen" as any,
|
|
148
|
+
path: "/updatable",
|
|
149
|
+
twinId: "old-twin",
|
|
150
|
+
devCommand: "npm run dev",
|
|
151
|
+
};
|
|
152
|
+
await saveAppConfig(tenantId, appConfig);
|
|
153
|
+
|
|
154
|
+
await updateAppTwinId(tenantId, "updatable", "new-twin");
|
|
155
|
+
|
|
156
|
+
const reread = await getAppConfig(tenantId, "updatable");
|
|
157
|
+
expect(reread).toEqual({ ...appConfig, twinId: "new-twin" });
|
|
158
|
+
});
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
describe("simulator-config — simulator (provisioned device) store", () => {
|
|
162
|
+
test("saveSimulator writes simulators/<name>.json in 2-space JSON and round-trips", () => {
|
|
163
|
+
const config = {
|
|
164
|
+
name: "sim-roundtrip",
|
|
165
|
+
deviceId: "device-abc",
|
|
166
|
+
accessKey: "secret-key",
|
|
167
|
+
serialNumber: "SIM-0001",
|
|
168
|
+
environment: "prod",
|
|
169
|
+
provisionedAt: "2026-06-16T00:00:00.000Z",
|
|
170
|
+
tenantId: "tenant-1",
|
|
171
|
+
};
|
|
172
|
+
saveSimulator(config);
|
|
173
|
+
|
|
174
|
+
const onDisk = path.join(CONFIG_ROOT, "simulators", "sim-roundtrip.json");
|
|
175
|
+
expect(fs.existsSync(onDisk)).toBe(true);
|
|
176
|
+
expect(fs.readFileSync(onDisk, "utf8")).toBe(
|
|
177
|
+
JSON.stringify(config, null, 2),
|
|
178
|
+
);
|
|
179
|
+
|
|
180
|
+
expect(getSimulator("sim-roundtrip")).toEqual(config);
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
test("getSimulator returns null for an unknown name", () => {
|
|
184
|
+
expect(getSimulator("does-not-exist")).toBeNull();
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
test("listSimulators returns all saved configs; empty when dir absent", () => {
|
|
188
|
+
// Fresh isolated names so this assertion does not depend on other tests.
|
|
189
|
+
const alpha = {
|
|
190
|
+
name: "list-alpha",
|
|
191
|
+
deviceId: "dev-a",
|
|
192
|
+
accessKey: "k-a",
|
|
193
|
+
serialNumber: "SIM-A",
|
|
194
|
+
environment: "dev",
|
|
195
|
+
provisionedAt: "2026-06-16T00:00:00.000Z",
|
|
196
|
+
};
|
|
197
|
+
const beta = {
|
|
198
|
+
name: "list-beta",
|
|
199
|
+
deviceId: "dev-b",
|
|
200
|
+
accessKey: "k-b",
|
|
201
|
+
serialNumber: "SIM-B",
|
|
202
|
+
environment: "qa",
|
|
203
|
+
provisionedAt: "2026-06-16T00:00:00.000Z",
|
|
204
|
+
};
|
|
205
|
+
saveSimulator(alpha);
|
|
206
|
+
saveSimulator(beta);
|
|
207
|
+
|
|
208
|
+
const names = listSimulators().map((sim) => sim.name);
|
|
209
|
+
expect(names).toContain("list-alpha");
|
|
210
|
+
expect(names).toContain("list-beta");
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
test("removeSimulator deletes the file and returns true; false when absent", () => {
|
|
214
|
+
const config = {
|
|
215
|
+
name: "sim-to-remove",
|
|
216
|
+
deviceId: "dev-x",
|
|
217
|
+
accessKey: "k-x",
|
|
218
|
+
serialNumber: "SIM-X",
|
|
219
|
+
environment: "prod",
|
|
220
|
+
provisionedAt: "2026-06-16T00:00:00.000Z",
|
|
221
|
+
};
|
|
222
|
+
saveSimulator(config);
|
|
223
|
+
const onDisk = path.join(CONFIG_ROOT, "simulators", "sim-to-remove.json");
|
|
224
|
+
expect(fs.existsSync(onDisk)).toBe(true);
|
|
225
|
+
|
|
226
|
+
expect(removeSimulator("sim-to-remove")).toBe(true);
|
|
227
|
+
expect(fs.existsSync(onDisk)).toBe(false);
|
|
228
|
+
expect(removeSimulator("sim-to-remove")).toBe(false);
|
|
229
|
+
});
|
|
230
|
+
});
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import path from "path";
|
|
2
|
+
import os from "os";
|
|
3
|
+
|
|
4
|
+
export const CONFIG_DIR = path.join(os.homedir(), ".config", "phystack-cli");
|
|
5
|
+
|
|
6
|
+
export const TENANT_FILE = path.join(CONFIG_DIR, "tenant.json");
|
|
7
|
+
export const CREDENTIALS_FILE = path.join(
|
|
8
|
+
CONFIG_DIR,
|
|
9
|
+
"registry-credentials.json",
|
|
10
|
+
);
|
|
11
|
+
export const VALIDATION_CACHE_FILE = path.join(
|
|
12
|
+
CONFIG_DIR,
|
|
13
|
+
"credential-validation-cache.json",
|
|
14
|
+
);
|
|
15
|
+
export const EMULATED_DEVICE_FILE = path.join(CONFIG_DIR, "device.json");
|
|
16
|
+
export const DEVELOPER_TOKEN_FILE = path.join(
|
|
17
|
+
CONFIG_DIR,
|
|
18
|
+
"developer-token.json",
|
|
19
|
+
);
|
|
20
|
+
|
|
21
|
+
export const SIMULATORS_DIR = path.join(CONFIG_DIR, "simulators");
|
|
22
|
+
export const SIMULATOR_DIR = path.join(CONFIG_DIR, "simulator");
|
|
23
|
+
export const VMS_DIR = path.join(CONFIG_DIR, "vms");
|
|
24
|
+
export const CACHE_DIR = path.join(CONFIG_DIR, "cache");
|
|
25
|
+
|
|
26
|
+
export function getAuthFilePath(gridEnv = ""): string {
|
|
27
|
+
const formatted = gridEnv.toUpperCase();
|
|
28
|
+
switch (formatted) {
|
|
29
|
+
case "LOCAL":
|
|
30
|
+
return path.join(CONFIG_DIR, "auth-local");
|
|
31
|
+
case "DEV":
|
|
32
|
+
return path.join(CONFIG_DIR, "auth-dev");
|
|
33
|
+
case "QA":
|
|
34
|
+
return path.join(CONFIG_DIR, "auth-qa");
|
|
35
|
+
default:
|
|
36
|
+
return path.join(CONFIG_DIR, "auth");
|
|
37
|
+
}
|
|
38
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
/* eslint-disable @typescript-eslint/no-explicit-any */
|
|
2
|
+
import chalk from "chalk";
|
|
3
|
+
import path from "path";
|
|
4
|
+
import fs from "fs";
|
|
5
|
+
|
|
6
|
+
// Injected at compile time by `bun build --define`. In a compiled binary
|
|
7
|
+
// __dirname points at the embedded VFS root, so reading ../../package.json
|
|
8
|
+
// fails; in dev (tsc/bun --watch) the define is absent and we fall back to
|
|
9
|
+
// reading the sibling package.json off disk.
|
|
10
|
+
declare const __PKG_VERSION__: string | undefined;
|
|
11
|
+
|
|
12
|
+
function resolvePackageVersion(): string {
|
|
13
|
+
if (typeof __PKG_VERSION__ !== "undefined") {
|
|
14
|
+
return __PKG_VERSION__;
|
|
15
|
+
}
|
|
16
|
+
try {
|
|
17
|
+
const pkg = JSON.parse(
|
|
18
|
+
fs.readFileSync(path.join(__dirname, "../../package.json"), "utf8"),
|
|
19
|
+
);
|
|
20
|
+
return pkg.version || "0.0.0";
|
|
21
|
+
} catch (e) {
|
|
22
|
+
console.error(chalk.dim("Cannot read package version"));
|
|
23
|
+
return "0.0.0";
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const packageVersion = resolvePackageVersion();
|
|
28
|
+
|
|
29
|
+
export const getPackageVersion = () => packageVersion;
|
|
30
|
+
|
|
31
|
+
export const handleError =
|
|
32
|
+
<T = any>(cb) =>
|
|
33
|
+
async (...args) => {
|
|
34
|
+
try {
|
|
35
|
+
const result = (await cb(...args)) as any as T;
|
|
36
|
+
return result;
|
|
37
|
+
} catch (e) {
|
|
38
|
+
console.error(chalk.red(e.toString()));
|
|
39
|
+
return process.exit(1);
|
|
40
|
+
}
|
|
41
|
+
};
|
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
import fs from "fs-extra";
|
|
2
|
+
import path from "path";
|
|
3
|
+
import { AppConfig, DeviceConfig } from "../simulator/types";
|
|
4
|
+
import { SIMULATORS_DIR, SIMULATOR_DIR } from "./config-paths";
|
|
5
|
+
|
|
6
|
+
export interface SimulatorConnectConfig {
|
|
7
|
+
name: string;
|
|
8
|
+
deviceId: string;
|
|
9
|
+
accessKey: string;
|
|
10
|
+
serialNumber: string;
|
|
11
|
+
// Optional: when unset, the connect path lets @phystack/hub-device synthesize
|
|
12
|
+
// the URL from `dataResidency` (LOCAL maps to localhost:14401, real regions
|
|
13
|
+
// follow the public template). Only set when overriding via
|
|
14
|
+
// PHYSTACK_SIMULATOR_PHYHUB_URL or when a previous provisioning baked it in
|
|
15
|
+
// (back-compat with pre-fix saved configs).
|
|
16
|
+
phyhubUrl?: string;
|
|
17
|
+
// The CLI's deployment env (from PHYSTACK_CLI_ENV, lowercased). Drives the
|
|
18
|
+
// POST body's `env` field at provisioning time. NOT the same as residency.
|
|
19
|
+
environment: string;
|
|
20
|
+
// Tenant's data residency (LOCAL/DEV/QA/EU/US/AU/IN/UAE) — passed as
|
|
21
|
+
// `region` to hub-device at connect time so it can synthesize the correct
|
|
22
|
+
// phyhub Socket.IO URL. Resolved at provisioning time from the active
|
|
23
|
+
// tenant (`phy tenant select`) or derived from `environment` as a fallback.
|
|
24
|
+
// Optional for back-compat with pre-fix saved configs.
|
|
25
|
+
dataResidency?: string;
|
|
26
|
+
provisionedAt: string;
|
|
27
|
+
tenantId?: string;
|
|
28
|
+
spaceId?: string;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function listSimulators(): SimulatorConnectConfig[] {
|
|
32
|
+
if (!fs.pathExistsSync(SIMULATORS_DIR)) return [];
|
|
33
|
+
return fs
|
|
34
|
+
.readdirSync(SIMULATORS_DIR)
|
|
35
|
+
.filter((f) => f.endsWith(".json"))
|
|
36
|
+
.map((f) => {
|
|
37
|
+
try {
|
|
38
|
+
return JSON.parse(
|
|
39
|
+
fs.readFileSync(path.join(SIMULATORS_DIR, f), "utf8"),
|
|
40
|
+
);
|
|
41
|
+
} catch {
|
|
42
|
+
return null;
|
|
43
|
+
}
|
|
44
|
+
})
|
|
45
|
+
.filter(Boolean) as SimulatorConnectConfig[];
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function getSimulator(name: string): SimulatorConnectConfig | null {
|
|
49
|
+
const filePath = path.join(SIMULATORS_DIR, `${name}.json`);
|
|
50
|
+
if (!fs.pathExistsSync(filePath)) return null;
|
|
51
|
+
try {
|
|
52
|
+
return JSON.parse(fs.readFileSync(filePath, "utf8"));
|
|
53
|
+
} catch {
|
|
54
|
+
return null;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export function saveSimulator(config: SimulatorConnectConfig): void {
|
|
59
|
+
fs.ensureDirSync(SIMULATORS_DIR);
|
|
60
|
+
fs.writeFileSync(
|
|
61
|
+
path.join(SIMULATORS_DIR, `${config.name}.json`),
|
|
62
|
+
JSON.stringify(config, null, 2),
|
|
63
|
+
);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export function removeSimulator(name: string): boolean {
|
|
67
|
+
const filePath = path.join(SIMULATORS_DIR, `${name}.json`);
|
|
68
|
+
if (!fs.pathExistsSync(filePath)) return false;
|
|
69
|
+
fs.removeSync(filePath);
|
|
70
|
+
return true;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function deviceConfigPath(tenantId: string): string {
|
|
74
|
+
return path.join(tenantDir(tenantId), "device.json");
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export async function getDeviceConfig(
|
|
78
|
+
tenantId: string,
|
|
79
|
+
): Promise<DeviceConfig | undefined> {
|
|
80
|
+
const filePath = deviceConfigPath(tenantId);
|
|
81
|
+
try {
|
|
82
|
+
if (await fs.pathExists(filePath)) {
|
|
83
|
+
const data = await fs.readFile(filePath, "utf8");
|
|
84
|
+
return JSON.parse(data) as DeviceConfig;
|
|
85
|
+
}
|
|
86
|
+
return undefined;
|
|
87
|
+
} catch (err) {
|
|
88
|
+
console.log(`[simulator] Failed to read device config: ${err.message}`);
|
|
89
|
+
return undefined;
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export async function saveDeviceConfig(
|
|
94
|
+
tenantId: string,
|
|
95
|
+
config: DeviceConfig,
|
|
96
|
+
): Promise<void> {
|
|
97
|
+
const dir = tenantDir(tenantId);
|
|
98
|
+
await fs.ensureDir(dir);
|
|
99
|
+
await fs.writeFile(
|
|
100
|
+
deviceConfigPath(tenantId),
|
|
101
|
+
JSON.stringify(config, null, 2),
|
|
102
|
+
);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function tenantsDir(): string {
|
|
106
|
+
return path.join(SIMULATOR_DIR, "tenants");
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function tenantDir(tenantId: string): string {
|
|
110
|
+
return path.join(tenantsDir(), tenantId);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function appConfigPath(tenantId: string, appName: string): string {
|
|
114
|
+
return path.join(tenantDir(tenantId), `${appName}.json`);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
export async function getAppConfig(
|
|
118
|
+
tenantId: string,
|
|
119
|
+
appName: string,
|
|
120
|
+
): Promise<AppConfig | undefined> {
|
|
121
|
+
const filePath = appConfigPath(tenantId, appName);
|
|
122
|
+
try {
|
|
123
|
+
if (await fs.pathExists(filePath)) {
|
|
124
|
+
const data = await fs.readFile(filePath, "utf8");
|
|
125
|
+
return JSON.parse(data) as AppConfig;
|
|
126
|
+
}
|
|
127
|
+
return undefined;
|
|
128
|
+
} catch (err) {
|
|
129
|
+
console.log(
|
|
130
|
+
`[simulator] Failed to read app config for ${appName}: ${err.message}`,
|
|
131
|
+
);
|
|
132
|
+
return undefined;
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
export async function saveAppConfig(
|
|
137
|
+
tenantId: string,
|
|
138
|
+
config: AppConfig,
|
|
139
|
+
): Promise<void> {
|
|
140
|
+
const dir = tenantDir(tenantId);
|
|
141
|
+
await fs.ensureDir(dir);
|
|
142
|
+
const filePath = appConfigPath(tenantId, config.name);
|
|
143
|
+
await fs.writeFile(filePath, JSON.stringify(config, null, 2));
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
export async function listApps(tenantId: string): Promise<AppConfig[]> {
|
|
147
|
+
const dir = tenantDir(tenantId);
|
|
148
|
+
try {
|
|
149
|
+
if (!(await fs.pathExists(dir))) return [];
|
|
150
|
+
const files = await fs.readdir(dir);
|
|
151
|
+
const apps: AppConfig[] = [];
|
|
152
|
+
for (const file of files) {
|
|
153
|
+
if (file.endsWith(".json")) {
|
|
154
|
+
const data = await fs.readFile(path.join(dir, file), "utf8");
|
|
155
|
+
apps.push(JSON.parse(data) as AppConfig);
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
return apps;
|
|
159
|
+
} catch (err) {
|
|
160
|
+
console.log(
|
|
161
|
+
`[simulator] Failed to list apps for tenant ${tenantId}: ${err.message}`,
|
|
162
|
+
);
|
|
163
|
+
return [];
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
export async function getAppConfigByPath(
|
|
168
|
+
tenantId: string,
|
|
169
|
+
appPath: string,
|
|
170
|
+
): Promise<AppConfig | undefined> {
|
|
171
|
+
const apps = await listApps(tenantId);
|
|
172
|
+
return apps.find((a) => a.path === appPath);
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
export async function updateAppTwinId(
|
|
176
|
+
tenantId: string,
|
|
177
|
+
appName: string,
|
|
178
|
+
newTwinId: string,
|
|
179
|
+
): Promise<void> {
|
|
180
|
+
const config = await getAppConfig(tenantId, appName);
|
|
181
|
+
if (config) {
|
|
182
|
+
config.twinId = newTwinId;
|
|
183
|
+
await saveAppConfig(tenantId, config);
|
|
184
|
+
}
|
|
185
|
+
}
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
import fs from "fs-extra";
|
|
2
|
+
import chalk from "chalk";
|
|
3
|
+
import { CONFIG_DIR, TENANT_FILE } from "./config-paths";
|
|
4
|
+
|
|
5
|
+
export interface StoredTenant {
|
|
6
|
+
id: string;
|
|
7
|
+
slug: string;
|
|
8
|
+
displayName: string;
|
|
9
|
+
phyhubUrl: string;
|
|
10
|
+
// Tenant's data residency (LOCAL/DEV/QA/EU/US/AU/IN/UAE) — drives the
|
|
11
|
+
// simulator's phyhub region. Optional for back-compat with pre-fix saved
|
|
12
|
+
// configs (will be re-populated on next `phy tenant select` / `phy login`).
|
|
13
|
+
dataResidency?: string;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Save tenant information to the config directory
|
|
18
|
+
*
|
|
19
|
+
* @param tenant The tenant information to save
|
|
20
|
+
* @returns Promise that resolves when the save is complete
|
|
21
|
+
*/
|
|
22
|
+
export const saveTenant = async (tenant: StoredTenant): Promise<void> => {
|
|
23
|
+
try {
|
|
24
|
+
// Ensure config directory exists
|
|
25
|
+
await fs.ensureDir(CONFIG_DIR);
|
|
26
|
+
|
|
27
|
+
// Save the tenant information
|
|
28
|
+
await fs.writeFile(TENANT_FILE, JSON.stringify(tenant, null, 2));
|
|
29
|
+
console.log(chalk.dim(`Saved tenant information to ${TENANT_FILE}`));
|
|
30
|
+
} catch (error) {
|
|
31
|
+
console.error(
|
|
32
|
+
chalk.red(`Failed to save tenant information: ${error.message}`),
|
|
33
|
+
);
|
|
34
|
+
throw error;
|
|
35
|
+
}
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Get the saved tenant information.
|
|
40
|
+
*
|
|
41
|
+
* Pass `silent: true` to skip the "Using tenant: …" log — useful when the
|
|
42
|
+
* caller is going to print its own message about the tenant (e.g. the
|
|
43
|
+
* `phy tenant select` flow).
|
|
44
|
+
*
|
|
45
|
+
* @returns Promise that resolves to the tenant information or null if none saved
|
|
46
|
+
*/
|
|
47
|
+
export const getTenant = async (
|
|
48
|
+
options: { silent?: boolean } = {},
|
|
49
|
+
): Promise<StoredTenant | null> => {
|
|
50
|
+
try {
|
|
51
|
+
if (await fs.pathExists(TENANT_FILE)) {
|
|
52
|
+
const data = await fs.readFile(TENANT_FILE, "utf8");
|
|
53
|
+
const tenant = JSON.parse(data) as StoredTenant;
|
|
54
|
+
|
|
55
|
+
if (!options.silent) {
|
|
56
|
+
console.log(
|
|
57
|
+
chalk.dim(`Using tenant: ${tenant.displayName} (${tenant.slug})`),
|
|
58
|
+
);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
return tenant;
|
|
62
|
+
}
|
|
63
|
+
return null;
|
|
64
|
+
} catch (error) {
|
|
65
|
+
console.error(
|
|
66
|
+
chalk.red(`Failed to read tenant information: ${error.message}`),
|
|
67
|
+
);
|
|
68
|
+
return null;
|
|
69
|
+
}
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Check if a tenant is already saved
|
|
74
|
+
*
|
|
75
|
+
* @returns Promise that resolves to true if a tenant is saved, false otherwise
|
|
76
|
+
*/
|
|
77
|
+
export const hasTenant = async (): Promise<boolean> => {
|
|
78
|
+
try {
|
|
79
|
+
return await fs.pathExists(TENANT_FILE);
|
|
80
|
+
} catch (error) {
|
|
81
|
+
console.error(
|
|
82
|
+
chalk.red(`Failed to check for saved tenant: ${error.message}`),
|
|
83
|
+
);
|
|
84
|
+
return false;
|
|
85
|
+
}
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Clear the saved tenant.
|
|
90
|
+
*
|
|
91
|
+
* @returns Promise resolving to true if a tenant file was removed, false if
|
|
92
|
+
* there was nothing to clear.
|
|
93
|
+
*/
|
|
94
|
+
export const clearTenant = async (): Promise<boolean> => {
|
|
95
|
+
try {
|
|
96
|
+
if (await fs.pathExists(TENANT_FILE)) {
|
|
97
|
+
await fs.remove(TENANT_FILE);
|
|
98
|
+
console.log(chalk.dim(`Removed tenant information`));
|
|
99
|
+
return true;
|
|
100
|
+
}
|
|
101
|
+
return false;
|
|
102
|
+
} catch (error) {
|
|
103
|
+
console.error(chalk.red(`Failed to clear tenant: ${error.message}`));
|
|
104
|
+
throw error;
|
|
105
|
+
}
|
|
106
|
+
};
|
package/tsconfig.json
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
{
|
|
2
|
+
"extends": "@phystack/tsconfig/library",
|
|
3
|
+
"compilerOptions": {
|
|
4
|
+
"outDir": "dist",
|
|
5
|
+
"rootDir": "./src",
|
|
6
|
+
"lib": ["esnext", "dom"],
|
|
7
|
+
"isolatedModules": false,
|
|
8
|
+
"strict": false
|
|
9
|
+
},
|
|
10
|
+
"include": ["./src/**/*"],
|
|
11
|
+
"exclude": ["node_modules", "dist", "src/**/__tests__/**", "src/**/*.test.ts"]
|
|
12
|
+
}
|