@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,309 @@
1
+ import chalk from "chalk";
2
+ import crypto from "crypto";
3
+ import { DeviceSimulator } from "../simulator";
4
+ import { resolveApiUrl } from "../services/env";
5
+ import { getAccessToken } from "../services/dev-token";
6
+ import { getTenant } from "../utils/tenant-storage";
7
+ import {
8
+ getSimulator,
9
+ saveSimulator,
10
+ type SimulatorConnectConfig,
11
+ } from "../utils/simulator-config";
12
+
13
+ async function resolveAuth(): Promise<Record<string, string>> {
14
+ const headers: Record<string, string> = {
15
+ "Content-Type": "application/json",
16
+ };
17
+ const sessionCookie = process.env.PHYSTACK_SESSION_COOKIE;
18
+ const apiKey = process.env.PHYSTACK_API_KEY;
19
+
20
+ if (sessionCookie) {
21
+ headers["Cookie"] = `GRIDSESSION=${sessionCookie}`;
22
+ } else if (apiKey) {
23
+ headers["Authorization"] = `ApiKey ${apiKey}`;
24
+ } else {
25
+ // Read-only cached-token resolution (PHYSTACK_DEVELOPER_TOKEN, stored
26
+ // developer token, or the `phy login` device-token file). The interactive
27
+ // device-grant/refresh flows are unreachable here, so they stay in the
28
+ // legacy CLI and the standalone binary remains openid-client-free. The
29
+ // try/catch mirrors the original: ANY failure surfaces the same guidance.
30
+ try {
31
+ const token = getAccessToken();
32
+ if (!token) throw new Error("not logged in");
33
+ headers["Authorization"] = `Bearer ${token}`;
34
+ } catch {
35
+ throw new Error(
36
+ 'No auth available. Set PHYSTACK_SESSION_COOKIE, PHYSTACK_API_KEY, or run "phy login".',
37
+ );
38
+ }
39
+ }
40
+ return headers;
41
+ }
42
+
43
+ async function provisionDevice(
44
+ name: string,
45
+ apiUrl: string,
46
+ ): Promise<SimulatorConnectConfig> {
47
+ const serialNumber = `SIM-${crypto.randomBytes(4).toString("hex")}`;
48
+ // CLI deployment env (LOCAL/DEV/QA/PROD), lowercased for the saved
49
+ // simulator config. Defaults to 'prod' when PHYSTACK_CLI_ENV is unset.
50
+ const environment = (process.env.PHYSTACK_CLI_ENV || "PROD").toLowerCase();
51
+ const spaceId = process.env.PHYSTACK_SPACE_ID || "";
52
+
53
+ // Tenant + phyhub URL resolution. Two well-defined modes:
54
+ //
55
+ // (A) Env-override mode: BOTH PHYSTACK_TENANT_ID and
56
+ // PHYSTACK_SIMULATOR_PHYHUB_URL set. Used by monorepo `make local`
57
+ // (via .env.dev) and CI scripts. They're paired so we never silently
58
+ // compute a bad URL when only TENANT_ID is set against a tenant
59
+ // whose residency we don't know.
60
+ //
61
+ // (B) Active-tenant mode: NEITHER env var set, falls back to the active
62
+ // tenant from `phy tenant select`. Residency comes from
63
+ // tenant.dataResidency; phyhubUrl is left undefined so hub-device
64
+ // synthesizes it from residency at connect time. Standard
65
+ // regular-user path.
66
+ //
67
+ // Hybrid: PHYSTACK_SIMULATOR_PHYHUB_URL alone is allowed when an active
68
+ // tenant exists — lets a user ad-hoc override the URL for one provisioning
69
+ // without also having to dig their tenant id out of tenant.json. Going the
70
+ // other way (TENANT_ID alone) is rejected because there's no safe way to
71
+ // pick a URL for an arbitrary tenant.
72
+ const envTenantId = process.env.PHYSTACK_TENANT_ID;
73
+ const envPhyhubUrl = process.env.PHYSTACK_SIMULATOR_PHYHUB_URL;
74
+
75
+ if (envTenantId && !envPhyhubUrl) {
76
+ throw new Error(
77
+ "PHYSTACK_TENANT_ID is set without PHYSTACK_SIMULATOR_PHYHUB_URL. " +
78
+ "These env vars are paired — supply both to override the active tenant " +
79
+ "(e.g. monorepo `make local` or CI flows), or unset PHYSTACK_TENANT_ID " +
80
+ "to use the active tenant from `phy tenant select`.",
81
+ );
82
+ }
83
+
84
+ const activeTenant = envTenantId ? null : await getTenant();
85
+ const tenantId = envTenantId || activeTenant?.id;
86
+
87
+ if (!tenantId) {
88
+ throw new Error(
89
+ "No tenant available. Either run `phy tenant select` first, or set both " +
90
+ "PHYSTACK_TENANT_ID and PHYSTACK_SIMULATOR_PHYHUB_URL.",
91
+ );
92
+ }
93
+
94
+ const provisionUrl = `${apiUrl}/2026-03/devices`;
95
+ console.log(chalk.cyan(`Provisioning "${name}" via ${provisionUrl}...`));
96
+
97
+ const headers = await resolveAuth();
98
+ const response = await fetch(provisionUrl, {
99
+ method: "POST",
100
+ headers,
101
+ body: JSON.stringify({
102
+ tenantId,
103
+ displayName: name,
104
+ deviceSerial: serialNumber,
105
+ spaceId: spaceId || undefined,
106
+ env: environment === "local" ? "LOCAL" : undefined,
107
+ }),
108
+ });
109
+
110
+ if (!response.ok) {
111
+ const text = await response.text();
112
+ throw new Error(`Device provisioning failed (${response.status}): ${text}`);
113
+ }
114
+
115
+ const data = (await response.json()) as any;
116
+ const deviceData = data.data || data;
117
+
118
+ const deviceId = deviceData.deviceId || deviceData.id || deviceData.uuid;
119
+ const accessKey = deviceData.accessKey;
120
+ if (!deviceId || !accessKey) {
121
+ throw new Error(
122
+ `Provisioning response missing required fields. Got: ${JSON.stringify(deviceData)}`,
123
+ );
124
+ }
125
+
126
+ // Residency drives the saved config's region hint. Active-tenant mode:
127
+ // tenant.dataResidency is the truth (LOCAL/DEV/QA/EU/US/AU/IN/UAE).
128
+ // Env-override mode: falls back to PHYSTACK_CLI_ENV uppercased, which is
129
+ // harmless because envPhyhubUrl will win at connect time regardless.
130
+ const dataResidency =
131
+ activeTenant?.dataResidency ||
132
+ (environment ? environment.toUpperCase() : undefined);
133
+
134
+ // phyhubUrl on the saved config: env override if provided (env-override
135
+ // mode, or the URL-only ad-hoc override). Otherwise undefined — hub-device
136
+ // synthesizes from dataResidency at connect time (LOCAL → localhost:14401,
137
+ // real regions → https://phyhub.${region}.omborigrid.net). Never derived
138
+ // from the provisioning response (phyhub's DeviceResponse doesn't carry
139
+ // phyhubUrl — see apps/phyhub/src/common/types/device.types.ts:129).
140
+ const config: SimulatorConnectConfig = {
141
+ name,
142
+ deviceId,
143
+ accessKey,
144
+ serialNumber,
145
+ phyhubUrl: envPhyhubUrl || undefined,
146
+ environment,
147
+ dataResidency,
148
+ provisionedAt: new Date().toISOString(),
149
+ tenantId: deviceData.tenantId || deviceData.organizationId || tenantId,
150
+ spaceId: deviceData.spaceId || spaceId,
151
+ };
152
+
153
+ saveSimulator(config);
154
+ console.log(chalk.green(`Device "${name}" provisioned: ${config.deviceId}`));
155
+ return config;
156
+ }
157
+
158
+ async function startConnected(name: string): Promise<void> {
159
+ let config = getSimulator(name);
160
+
161
+ if (!config) {
162
+ config = await provisionDevice(name, resolveApiUrl());
163
+ } else {
164
+ console.log(chalk.cyan(`Reconnecting "${name}" (${config.deviceId})...`));
165
+ }
166
+
167
+ const hubDeviceModule = (await import("@phystack/hub-device")).default;
168
+
169
+ // `region` drives hub-device's phyhub URL synthesis (LOCAL → localhost:14401,
170
+ // EU/US/AU/IN/UAE/DEV/QA → phyhub.{r}.omborigrid.net). Prefer the saved
171
+ // dataResidency (from the active tenant via `phy tenant select`); fall back
172
+ // to environment for back-compat with saved configs from before this field
173
+ // existed.
174
+ const region =
175
+ config.dataResidency ||
176
+ (config.environment ? config.environment.toUpperCase() : "PROD");
177
+
178
+ console.log(
179
+ chalk.cyan(
180
+ `Connecting (region=${region}${config.phyhubUrl ? `, phyhubUrl=${config.phyhubUrl}` : ", URL resolved by hub-device"})...`,
181
+ ),
182
+ );
183
+
184
+ // Pass storage namespace so node storage uses
185
+ // `~/.config/phystack-cli/hub-device/<name>/` instead of `/data/settings/phyhub`
186
+ // (the on-device PhyOS path, which doesn't exist / isn't writable on dev
187
+ // machines — caused EROFS on macOS).
188
+ const connectionStatus = await hubDeviceModule.connectToPhyHub(
189
+ config.environment === "local" ? "LOCAL" : "PROD",
190
+ "simulator",
191
+ {
192
+ deviceId: config.deviceId,
193
+ accessKey: config.accessKey,
194
+ deviceSerial: config.serialNumber,
195
+ phyhubUrl: config.phyhubUrl,
196
+ region,
197
+ },
198
+ `simulator-${name}`,
199
+ );
200
+
201
+ // `connectToPhyHub` returns `{ phyHub: HubDevice, deviceSerial }` — the
202
+ // outer wrapper has no `.emit` / `.connect`, so the inner instance is what
203
+ // we drive (mirrors apps/device-phyos/src/index.ts:534).
204
+ const phyHub = connectionStatus?.phyHub;
205
+ if (!phyHub) {
206
+ throw new Error(
207
+ `connectToPhyHub() returned no HubDevice instance for "${name}"`,
208
+ );
209
+ }
210
+
211
+ // The constructor inside connectToPhyHub does not open the socket. Must
212
+ // call .connect() before any authenticated emits are accepted.
213
+ await phyHub.connect();
214
+
215
+ // connectDevice() round-trips with phyhub, returning the device's full
216
+ // twin inventory. We need the Device-type twin's `id` (the instanceTwinId)
217
+ // for subsequent reportDeviceTwinProperties calls — without it, phyhub's
218
+ // emitAuth handler rejects the message.
219
+ let deviceTwinId: string | undefined;
220
+ phyHub.connectDevice((response: any) => {
221
+ if (response?.error) {
222
+ console.warn(chalk.yellow(`connectDevice rejected: ${response.error}`));
223
+ return;
224
+ }
225
+ const deviceTwin = response?.twins?.find(
226
+ (twin: any) => twin?.type === "Device",
227
+ );
228
+ if (deviceTwin?.id) {
229
+ deviceTwinId = deviceTwin.id;
230
+ console.log(
231
+ chalk.green(
232
+ `Device "${name}" connected to phyhub (twinId=${deviceTwinId})`,
233
+ ),
234
+ );
235
+ } else {
236
+ console.warn(
237
+ chalk.yellow(
238
+ `connectDevice() returned no Device-type twin — heartbeat will be skipped`,
239
+ ),
240
+ );
241
+ }
242
+ });
243
+
244
+ // Heartbeat every 30s. Uses the typed helper (not raw `.emit`), which
245
+ // routes through emitAuth so phyhub-side authorization runs. Drops the
246
+ // tick silently until the connectDevice callback supplies a twinId —
247
+ // same pattern as apps/example-cloud-app waiting for a Cloud twin.
248
+ const heartbeat = setInterval(() => {
249
+ if (!deviceTwinId) return;
250
+ phyHub.reportDeviceTwinProperties(
251
+ deviceTwinId,
252
+ {
253
+ time: {
254
+ current: Date.now(),
255
+ uptime: process.uptime(),
256
+ timezone: Intl.DateTimeFormat().resolvedOptions().timeZone,
257
+ },
258
+ },
259
+ (response: any) => {
260
+ if (response?.error) {
261
+ console.warn(
262
+ chalk.yellow(
263
+ `reportDeviceTwinProperties rejected: ${response.error}`,
264
+ ),
265
+ );
266
+ }
267
+ },
268
+ );
269
+ }, 30_000);
270
+
271
+ const shutdown = () => {
272
+ clearInterval(heartbeat);
273
+ console.log(chalk.yellow(`\nDevice "${name}" disconnecting...`));
274
+ process.exit(0);
275
+ };
276
+ process.on("SIGINT", shutdown);
277
+ process.on("SIGTERM", shutdown);
278
+
279
+ await new Promise(() => {});
280
+ }
281
+
282
+ export default async (
283
+ options: { port?: string; connect?: string } = {},
284
+ ): Promise<void> => {
285
+ const port = parseInt(options.port || "55000", 10);
286
+
287
+ if (options.connect) {
288
+ return startConnected(options.connect);
289
+ }
290
+
291
+ // Offline mode (default)
292
+ const simulator = new DeviceSimulator(port);
293
+
294
+ const shutdown = async () => {
295
+ await simulator.stop();
296
+ process.exit(0);
297
+ };
298
+ process.on("SIGINT", shutdown);
299
+ process.on("SIGTERM", shutdown);
300
+
301
+ try {
302
+ await simulator.start();
303
+ } catch (error: any) {
304
+ console.error(chalk.red(`Failed to start simulator: ${error.message}`));
305
+ process.exit(1);
306
+ }
307
+
308
+ await new Promise(() => {});
309
+ };
package/src/index.ts ADDED
@@ -0,0 +1,45 @@
1
+ import { Command } from "commander";
2
+ import { getPackageVersion, handleError } from "./utils";
3
+ import start from "./commands/start";
4
+ import run from "./commands/run";
5
+ import simulatorList from "./commands/list";
6
+ import simulatorRemove from "./commands/remove";
7
+
8
+ const program = new Command();
9
+ program.version(getPackageVersion());
10
+ program.description("Local device simulator for app development");
11
+
12
+ program
13
+ .command("start")
14
+ .description("Start the local simulator server")
15
+ .option("-p, --port <port>", "Port to listen on", "55000")
16
+ .option("--connect <name>", "Connect to phyhub as a provisioned device")
17
+ .action(handleError(start));
18
+
19
+ program
20
+ .command("run <path>")
21
+ .description(
22
+ "Run an app connected to the simulator (reconciles twin, spawns dev server)",
23
+ )
24
+ .option("--type <type>", "Override app type detection (screen or edge)")
25
+ .option(
26
+ "--dev-command <command>",
27
+ "Override dev command (default: npm run dev)",
28
+ )
29
+ .option(
30
+ "--settings-dir <path>",
31
+ "Settings directory path (default: src/settings)",
32
+ )
33
+ .action(handleError(run));
34
+
35
+ program
36
+ .command("list")
37
+ .description("List all provisioned simulator devices")
38
+ .action(simulatorList);
39
+
40
+ program
41
+ .command("remove <name>")
42
+ .description("Remove a saved simulator device")
43
+ .action(simulatorRemove);
44
+
45
+ program.parse(process.argv);
@@ -0,0 +1,156 @@
1
+ import { afterAll, 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
+ // dev-token transitively imports config-paths, which computes DEVELOPER_TOKEN_FILE
8
+ // and the auth-file paths from os.homedir() at import time. The test preload
9
+ // (bunfig.toml) redirects os.homedir() to SUITE_TEST_HOME before any module
10
+ // evaluates — on macOS/Linux os.homedir() ignores process.env.HOME, so the
11
+ // preload override is what keeps the real ~/.config/phystack-cli untouched.
12
+ const TEST_HOME = SUITE_TEST_HOME;
13
+ const CONFIG_ROOT = path.join(TEST_HOME, ".config", "phystack-cli");
14
+ fs.mkdirSync(CONFIG_ROOT, { recursive: true });
15
+
16
+ const { getAccessToken } = await import("../dev-token");
17
+
18
+ const DEVELOPER_TOKEN_FILE = path.join(CONFIG_ROOT, "developer-token.json");
19
+
20
+ // Snapshot the env keys we mutate so each test can reset to a clean slate via
21
+ // the named helper below (no beforeEach; cleanup-only afterAll for the temp dir).
22
+ const MANAGED_ENV_KEYS = [
23
+ "PHYSTACK_DEVELOPER_TOKEN",
24
+ "PHYSTACK_CLI_ENV",
25
+ "PHYSTACK_CLI_AUTH_FILE_PATH",
26
+ ] as const;
27
+
28
+ function clearTokenSources(): void {
29
+ for (const key of MANAGED_ENV_KEYS) {
30
+ delete process.env[key];
31
+ }
32
+ // Remove any on-disk token artifacts between scenarios.
33
+ fs.rmSync(DEVELOPER_TOKEN_FILE, { force: true });
34
+ for (const authName of ["auth", "auth-local", "auth-dev", "auth-qa"]) {
35
+ fs.rmSync(path.join(CONFIG_ROOT, authName), { force: true });
36
+ }
37
+ }
38
+
39
+ afterAll(() => {
40
+ // The suite temp dir itself is cleaned by the preload's exit handler; here we
41
+ // only clear the token artifacts so they don't bleed into other suites that
42
+ // share the same config root.
43
+ clearTokenSources();
44
+ });
45
+
46
+ describe("getAccessToken — 3-source resolution order", () => {
47
+ test("config dir resolves under the temp HOME", () => {
48
+ expect(CONFIG_ROOT.startsWith(TEST_HOME)).toBe(true);
49
+ });
50
+
51
+ test("PHYSTACK_DEVELOPER_TOKEN env wins over everything else", () => {
52
+ clearTokenSources();
53
+ // Also write a developer-token.json and an auth file to prove env precedence.
54
+ fs.writeFileSync(
55
+ DEVELOPER_TOKEN_FILE,
56
+ JSON.stringify({ token: "from-file" }),
57
+ );
58
+ fs.writeFileSync(
59
+ path.join(CONFIG_ROOT, "auth"),
60
+ JSON.stringify({ access_token: "from-auth" }),
61
+ );
62
+ process.env.PHYSTACK_DEVELOPER_TOKEN = " env-token ";
63
+
64
+ // Trimmed.
65
+ expect(getAccessToken()).toBe("env-token");
66
+ });
67
+
68
+ test("empty/whitespace env token is ignored and falls through to the file", () => {
69
+ clearTokenSources();
70
+ process.env.PHYSTACK_DEVELOPER_TOKEN = " ";
71
+ fs.writeFileSync(
72
+ DEVELOPER_TOKEN_FILE,
73
+ JSON.stringify({ token: "file-token" }),
74
+ );
75
+
76
+ expect(getAccessToken()).toBe("file-token");
77
+ });
78
+
79
+ test("developer-token.json `.token` is used when env is unset", () => {
80
+ clearTokenSources();
81
+ fs.writeFileSync(
82
+ DEVELOPER_TOKEN_FILE,
83
+ JSON.stringify({ token: "dev-file-token" }),
84
+ );
85
+
86
+ expect(getAccessToken()).toBe("dev-file-token");
87
+ });
88
+
89
+ test("falls back to the auth tokens file `.access_token` for the active env", () => {
90
+ clearTokenSources();
91
+ process.env.PHYSTACK_CLI_ENV = "QA";
92
+ // getAuthFilePath('QA') → <config>/auth-qa
93
+ fs.writeFileSync(
94
+ path.join(CONFIG_ROOT, "auth-qa"),
95
+ JSON.stringify({ access_token: "qa-access-token" }),
96
+ );
97
+
98
+ expect(getAccessToken()).toBe("qa-access-token");
99
+ });
100
+
101
+ test("default auth file (no PHYSTACK_CLI_ENV) is <config>/auth", () => {
102
+ clearTokenSources();
103
+ fs.writeFileSync(
104
+ path.join(CONFIG_ROOT, "auth"),
105
+ JSON.stringify({ access_token: "default-access-token" }),
106
+ );
107
+
108
+ expect(getAccessToken()).toBe("default-access-token");
109
+ });
110
+
111
+ test("PHYSTACK_CLI_AUTH_FILE_PATH overrides the env-derived auth path", () => {
112
+ clearTokenSources();
113
+ const overridePath = path.join(CONFIG_ROOT, "custom-auth.json");
114
+ fs.writeFileSync(
115
+ overridePath,
116
+ JSON.stringify({ access_token: "override-token" }),
117
+ );
118
+ process.env.PHYSTACK_CLI_AUTH_FILE_PATH = overridePath;
119
+
120
+ expect(getAccessToken()).toBe("override-token");
121
+ });
122
+
123
+ test("returns null when no source provides a token", () => {
124
+ clearTokenSources();
125
+ expect(getAccessToken()).toBeNull();
126
+ });
127
+ });
128
+
129
+ describe("getAccessToken — malformed JSON fall-through", () => {
130
+ test("malformed developer-token.json logs and falls through to the auth file", () => {
131
+ clearTokenSources();
132
+ fs.writeFileSync(DEVELOPER_TOKEN_FILE, "{ this is not json");
133
+ fs.writeFileSync(
134
+ path.join(CONFIG_ROOT, "auth"),
135
+ JSON.stringify({ access_token: "recovered-token" }),
136
+ );
137
+
138
+ expect(getAccessToken()).toBe("recovered-token");
139
+ });
140
+
141
+ test("malformed auth file logs and resolves to null", () => {
142
+ clearTokenSources();
143
+ fs.writeFileSync(path.join(CONFIG_ROOT, "auth"), "not-json-at-all");
144
+
145
+ expect(getAccessToken()).toBeNull();
146
+ });
147
+
148
+ test("developer-token.json present but missing `.token` yields undefined token", () => {
149
+ clearTokenSources();
150
+ // JSON parses fine but has no `token` field → returns data.token (undefined),
151
+ // which the function returns directly (faithful to the original behavior).
152
+ fs.writeFileSync(DEVELOPER_TOKEN_FILE, JSON.stringify({ notToken: "x" }));
153
+
154
+ expect(getAccessToken()).toBeUndefined();
155
+ });
156
+ });
@@ -0,0 +1,52 @@
1
+ import fs from "fs";
2
+ import { DEVELOPER_TOKEN_FILE, getAuthFilePath } from "../utils/config-paths";
3
+
4
+ /**
5
+ * Read-only access-token resolution, faithfully replicating
6
+ * `AuthService.getAccessToken()` from the legacy CLI's
7
+ * device-grant-auth.service (read paths only).
8
+ *
9
+ * The interactive device-grant and refresh flows (which require openid-client)
10
+ * are unreachable from the simulator — `start.ts` only ever reads a cached
11
+ * token — so they are deliberately not ported, keeping the standalone binary
12
+ * openid-client-free. The cached-token read paths are preserved verbatim so a
13
+ * user who ran `phy login` (token on disk) can still use `--connect` without
14
+ * setting PHYSTACK_SESSION_COOKIE / PHYSTACK_API_KEY.
15
+ */
16
+ export function getAccessToken(): string | null {
17
+ // First check environment variable
18
+ const envToken = process.env.PHYSTACK_DEVELOPER_TOKEN?.trim();
19
+ if (envToken) {
20
+ return envToken;
21
+ }
22
+
23
+ // Then check stored developer token
24
+ if (fs.existsSync(DEVELOPER_TOKEN_FILE)) {
25
+ try {
26
+ const data = JSON.parse(fs.readFileSync(DEVELOPER_TOKEN_FILE, "utf8"));
27
+ return data.token;
28
+ } catch (error) {
29
+ console.error(
30
+ `Failed to read developer token: ${error?.message ?? String(error)}`,
31
+ );
32
+ }
33
+ }
34
+
35
+ // Finally check device tokens. A TokenSet is just JSON on disk; read
36
+ // `.access_token` directly rather than reconstructing openid-client's
37
+ // TokenSet (which would pull the dependency back in).
38
+ const tokensPath =
39
+ process.env.PHYSTACK_CLI_AUTH_FILE_PATH ||
40
+ getAuthFilePath(process.env.PHYSTACK_CLI_ENV);
41
+ try {
42
+ if (fs.existsSync(tokensPath)) {
43
+ const tokens = JSON.parse(fs.readFileSync(tokensPath, "utf8"));
44
+ return tokens ? tokens.access_token : null;
45
+ }
46
+ } catch (error) {
47
+ console.error(
48
+ `Failed to read tokens at ${tokensPath}: ${error?.message ?? String(error)}`,
49
+ );
50
+ }
51
+ return null;
52
+ }
@@ -0,0 +1,10 @@
1
+ const DEFAULT_API_URL = "https://api.phystack.com";
2
+
3
+ export function resolveApiUrl(): string {
4
+ const { PHYSTACK_CLI_ENV } = process.env;
5
+ if (PHYSTACK_CLI_ENV) {
6
+ const url = process.env[`${PHYSTACK_CLI_ENV}_PHYSTACK_API`];
7
+ if (url) return url;
8
+ }
9
+ return process.env.PHYSTACK_API || DEFAULT_API_URL;
10
+ }