@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,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
|
+
}
|