openxiangda-devkit-core 2.0.0-alpha.16 → 2.0.0-alpha.19

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,802 @@
1
+ import { spawn, spawnSync } from "node:child_process";
2
+ import { createHash, randomBytes, scryptSync } from "node:crypto";
3
+ import { createWriteStream, existsSync, mkdirSync, readFileSync, realpathSync, renameSync, rmSync, statSync, writeFileSync, } from "node:fs";
4
+ import { createServer } from "node:net";
5
+ import { join } from "node:path";
6
+ import { resetLocalPostgres, startLocalPostgres, stopOwnedLocalPostgres, stopLocalPostgres, } from "./local-postgres.js";
7
+ const LOOPBACK_HOST = "127.0.0.1";
8
+ const DEFAULT_WEB_PORT = 5173;
9
+ const DEFAULT_APP_PORT = 3000;
10
+ const DEFAULT_PLATFORM_PORT = 7001;
11
+ const DEFAULT_DATABASE_PORT = 54329;
12
+ export async function runLocalDevelopment(input) {
13
+ const databaseRuntime = input.databaseRuntime || {
14
+ start: startLocalPostgres,
15
+ stop: stopLocalPostgres,
16
+ reset: resetLocalPostgres,
17
+ };
18
+ const root = realpathSync(input.root);
19
+ const paths = localPaths(root);
20
+ mkdirSync(paths.root, { recursive: true, mode: 0o700 });
21
+ mkdirSync(paths.logs, { recursive: true, mode: 0o700 });
22
+ const existing = await acquireSessionLock(paths, root);
23
+ if (existing) {
24
+ input.onStatus?.(`本地开发会话已运行:${existing.urls.web}`);
25
+ if (!input.noOpen)
26
+ openBrowser(existing.urls.web);
27
+ return {
28
+ exitCode: 0,
29
+ reused: true,
30
+ session: existing,
31
+ diagnosticsPath: paths.diagnostics,
32
+ logPath: paths.log,
33
+ };
34
+ }
35
+ const { databaseResetPrepared, webPort, appPort, platformPort, databasePort, startedAt, session, runtimeOAuthCredential, eventCredential, localManifest, } = await prepareLocalDevelopmentSession(input, databaseRuntime, root, paths);
36
+ const children = [];
37
+ const exitPromises = [];
38
+ let log;
39
+ let postgres;
40
+ let stopping = false;
41
+ let stopTimer;
42
+ const signalHandlers = new Map();
43
+ const cleanup = () => {
44
+ for (const [signal, handler] of signalHandlers) {
45
+ process.off(signal, handler);
46
+ }
47
+ if (stopTimer)
48
+ clearTimeout(stopTimer);
49
+ log?.end();
50
+ rmSync(paths.lockDirectory, { recursive: true, force: true });
51
+ };
52
+ const stop = (signal) => {
53
+ if (stopping)
54
+ return;
55
+ stopping = true;
56
+ session.status = "stopping";
57
+ writeJsonAtomic(paths.diagnostics, session);
58
+ terminateChildren(children, signal);
59
+ stopTimer = setTimeout(() => terminateChildren(children, "SIGKILL"), 5_000);
60
+ stopTimer.unref();
61
+ };
62
+ try {
63
+ log = createWriteStream(paths.log, { flags: "a", mode: 0o600 });
64
+ log.write(`\n[${startedAt}] openxiangda dev ${session.workspaceId}\n`);
65
+ if (databasePort !== null) {
66
+ postgres = await databaseRuntime.start({
67
+ workspaceId: session.workspaceId,
68
+ port: databasePort,
69
+ credentialPath: paths.databaseCredential,
70
+ ...(input.reset === undefined
71
+ ? {}
72
+ : { reset: input.reset && !databaseResetPrepared }),
73
+ ...(input.onStatus ? { onStatus: input.onStatus } : {}),
74
+ });
75
+ session.database = {
76
+ engine: "postgresql",
77
+ containerName: postgres.containerName,
78
+ volumeName: postgres.volumeName,
79
+ image: postgres.image,
80
+ };
81
+ writeJsonAtomic(paths.lockMetadata, session);
82
+ writeJsonAtomic(paths.diagnostics, session);
83
+ }
84
+ const environment = {
85
+ ...process.env,
86
+ PORT: appPort === null ? undefined : String(appPort),
87
+ OPENXIANGDA_APP_CODE: input.appCode,
88
+ OPENXIANGDA_APP_PORT: appPort === null ? "" : String(appPort),
89
+ OPENXIANGDA_WEB_PORT: String(webPort),
90
+ OPENXIANGDA_PLATFORM_PORT: platformPort === null ? "" : String(platformPort),
91
+ OPENXIANGDA_DEV_HOST: LOOPBACK_HOST,
92
+ OPENXIANGDA_ENVIRONMENT_KEY: "local",
93
+ OPENXIANGDA_ENVIRONMENT_ID: "local",
94
+ OPENXIANGDA_APP_VERSION_ID: "local-worktree",
95
+ OPENXIANGDA_DEPLOYMENT_RUN_ID: "local-development",
96
+ OPENXIANGDA_ENVIRONMENT_HEAD_REVISION: "1",
97
+ OPENXIANGDA_BACKEND_REVISION_ID: "local-worktree",
98
+ OPENXIANGDA_RUNTIME_MODE: "local",
99
+ OPENXIANGDA_PLATFORM_BASE_URL: session.urls.platform,
100
+ OPENXIANGDA_LOCAL_MOCK: input.uiOnly ? "true" : "false",
101
+ OPENXIANGDA_PLATFORM_PROXY: platformPort === null ? "" : `http://${LOOPBACK_HOST}:${platformPort}`,
102
+ OPENXIANGDA_UI_ONLY: input.uiOnly ? "true" : "false",
103
+ VITE_OPENXIANGDA_UI_ONLY: input.uiOnly ? "true" : "false",
104
+ OPENXIANGDA_LOCAL_MANIFEST_PATH: localManifest ? paths.manifest : "",
105
+ };
106
+ const eventEnvironment = Object.fromEntries(Object.entries(eventCredential?.secrets || {}).map(([code, secret]) => [
107
+ `OPENXIANGDA_EVENT_SECRET_${environmentCode(code)}`,
108
+ secret,
109
+ ]));
110
+ const spawnManaged = (command, extraEnvironment = {}) => {
111
+ const child = spawn("pnpm", ["run", command], {
112
+ cwd: root,
113
+ detached: process.platform !== "win32",
114
+ stdio: ["inherit", "pipe", "pipe"],
115
+ env: { ...environment, ...extraEnvironment },
116
+ });
117
+ children.push(child);
118
+ exitPromises.push(childExit(child));
119
+ pipeOutput(child.stdout, process.stdout, log);
120
+ pipeOutput(child.stderr, process.stderr, log);
121
+ return child;
122
+ };
123
+ const applicationChild = input.uiOnly
124
+ ? spawnManaged("dev:ui-only")
125
+ : spawnManaged("dev:web");
126
+ if (!input.uiOnly) {
127
+ spawnManaged("dev:server", {
128
+ ...eventEnvironment,
129
+ ...(runtimeOAuthCredential
130
+ ? {
131
+ OPENXIANGDA_OAUTH_CLIENT_ID: runtimeOAuthCredential.clientId,
132
+ OPENXIANGDA_OAUTH_CLIENT_SECRET: runtimeOAuthCredential.clientSecret,
133
+ OPENXIANGDA_OAUTH_SCOPES: runtimeOAuthCredential.scopes.join(" "),
134
+ }
135
+ : {}),
136
+ });
137
+ spawnManaged("dev:platform", {
138
+ ...eventEnvironment,
139
+ OPENXIANGDA_LOCAL_DATABASE_URL: postgres?.connectionString || "",
140
+ OPENXIANGDA_LOCAL_APP_BACKEND_URL: `http://${LOOPBACK_HOST}:${appPort}`,
141
+ });
142
+ }
143
+ if (applicationChild.pid !== undefined) {
144
+ session.childPid = applicationChild.pid;
145
+ }
146
+ session.childPids = children.flatMap(child => child.pid === undefined ? [] : [child.pid]);
147
+ writeJsonAtomic(paths.lockMetadata, session);
148
+ writeJsonAtomic(paths.diagnostics, session);
149
+ for (const signal of ["SIGINT", "SIGTERM"]) {
150
+ const handler = () => stop(signal);
151
+ signalHandlers.set(signal, handler);
152
+ process.once(signal, handler);
153
+ }
154
+ input.onStatus?.(`正在启动本地应用:${session.urls.web}`);
155
+ const startup = await Promise.race([
156
+ waitForLocalReadiness(session, children, input.readinessTimeoutMs).then(() => ({ kind: "ready" })),
157
+ Promise.race(exitPromises).then(exit => ({ kind: "exit", exit })),
158
+ ]);
159
+ if (startup.kind === "exit") {
160
+ throw new Error(startup.exit.error?.message ||
161
+ `OPENXIANGDA_LOCAL_PROCESS_EXITED: ${startup.exit.code}`);
162
+ }
163
+ session.status = "ready";
164
+ session.readyAt = new Date().toISOString();
165
+ writeJsonAtomic(paths.lockMetadata, session);
166
+ writeJsonAtomic(paths.diagnostics, session);
167
+ input.onStatus?.(input.uiOnly
168
+ ? `UI-only 预览已就绪(不能作为发布验证):${session.urls.web}`
169
+ : `本地应用已就绪:${session.urls.web}`);
170
+ if (!input.noOpen)
171
+ openBrowser(session.urls.web);
172
+ const exit = await Promise.race(exitPromises);
173
+ terminateChildren(children, "SIGTERM");
174
+ const exits = await waitForChildrenExit(children, exitPromises);
175
+ const exitCode = stopping
176
+ ? 0
177
+ : exits.find(result => result.code !== 0)?.code ?? exit.code;
178
+ session.exitCode = exitCode;
179
+ session.stoppedAt = new Date().toISOString();
180
+ session.status = exitCode === 0 ? "stopped" : "failed";
181
+ const failure = exits.find(result => result.error)?.error;
182
+ if (failure)
183
+ session.failure = failure.message;
184
+ writeJsonAtomic(paths.diagnostics, session);
185
+ return {
186
+ exitCode,
187
+ reused: false,
188
+ session,
189
+ diagnosticsPath: paths.diagnostics,
190
+ logPath: paths.log,
191
+ };
192
+ }
193
+ catch (error) {
194
+ session.status = "failed";
195
+ session.exitCode = 1;
196
+ session.stoppedAt = new Date().toISOString();
197
+ session.failure = error instanceof Error ? error.message : String(error);
198
+ writeJsonAtomic(paths.diagnostics, session);
199
+ terminateChildren(children, "SIGTERM");
200
+ await waitForChildrenExit(children, exitPromises);
201
+ throw error;
202
+ }
203
+ finally {
204
+ try {
205
+ await databaseRuntime.stop(postgres);
206
+ }
207
+ finally {
208
+ cleanup();
209
+ }
210
+ }
211
+ }
212
+ export function localDevelopmentSession(root) {
213
+ const canonical = realpathSync(root);
214
+ return readActiveSession(localPaths(canonical).lockMetadata, canonical);
215
+ }
216
+ export function localDevelopmentStatus(root) {
217
+ const canonical = realpathSync(root);
218
+ const paths = localPaths(canonical);
219
+ const session = readActiveSession(paths.lockMetadata, canonical);
220
+ return {
221
+ active: Boolean(session),
222
+ session,
223
+ lastSession: readSession(paths.diagnostics, canonical),
224
+ };
225
+ }
226
+ export async function stopLocalDevelopment(root) {
227
+ const canonical = realpathSync(root);
228
+ const paths = localPaths(canonical);
229
+ assertSessionMetadataIsSafe(paths, canonical);
230
+ const session = readActiveSession(paths.lockMetadata, canonical);
231
+ if (!session) {
232
+ if (existsSync(paths.lockDirectory)) {
233
+ if (!existsSync(paths.lockMetadata) &&
234
+ Date.now() - statSync(paths.lockDirectory).mtimeMs < 10_000) {
235
+ throw new Error("OPENXIANGDA_LOCAL_SESSION_TRANSITION_IN_PROGRESS");
236
+ }
237
+ rmSync(paths.lockDirectory, { recursive: true, force: true });
238
+ }
239
+ const workspaceId = createHash("sha256")
240
+ .update(canonical)
241
+ .digest("hex")
242
+ .slice(0, 16);
243
+ const stoppedDatabase = await stopOwnedLocalPostgres(workspaceId).catch(error => {
244
+ if (/No such|not found/i.test(error.message))
245
+ return false;
246
+ throw error;
247
+ });
248
+ return {
249
+ stopped: stoppedDatabase,
250
+ stoppedDatabase,
251
+ session: readSession(paths.diagnostics, canonical),
252
+ };
253
+ }
254
+ if (process.platform !== "win32" && !session.ownerIdentity) {
255
+ throw new Error("OPENXIANGDA_LOCAL_SESSION_IDENTITY_REQUIRED");
256
+ }
257
+ if (session.ownerIdentity &&
258
+ processIdentity(session.ownerPid) !== session.ownerIdentity) {
259
+ throw new Error("OPENXIANGDA_LOCAL_SESSION_OWNER_CHANGED");
260
+ }
261
+ if (process.platform === "win32") {
262
+ const child = spawn("taskkill", ["/pid", String(session.ownerPid), "/t"], {
263
+ detached: true,
264
+ stdio: "ignore",
265
+ });
266
+ child.unref();
267
+ }
268
+ else {
269
+ process.kill(session.ownerPid, "SIGTERM");
270
+ }
271
+ const stopped = await waitForProcessStop(session.ownerPid, session.ownerIdentity, 15_000);
272
+ if (!stopped) {
273
+ throw new Error(`OPENXIANGDA_LOCAL_STOP_TIMEOUT:${session.ownerPid}`);
274
+ }
275
+ const completed = {
276
+ ...session,
277
+ status: "stopped",
278
+ exitCode: 0,
279
+ stoppedAt: new Date().toISOString(),
280
+ };
281
+ writeJsonAtomic(paths.diagnostics, completed);
282
+ rmSync(paths.lockDirectory, { recursive: true, force: true });
283
+ return {
284
+ stopped: true,
285
+ stoppedDatabase: Boolean(session.database),
286
+ session: completed,
287
+ };
288
+ }
289
+ export async function resetLocalDevelopmentData(root) {
290
+ const canonical = realpathSync(root);
291
+ const paths = localPaths(canonical);
292
+ assertSessionMetadataIsSafe(paths, canonical);
293
+ const existing = await acquireSessionLock(paths, canonical);
294
+ if (existing) {
295
+ throw new Error("OPENXIANGDA_LOCAL_RESET_SESSION_ACTIVE");
296
+ }
297
+ try {
298
+ const workspaceId = createHash("sha256")
299
+ .update(canonical)
300
+ .digest("hex")
301
+ .slice(0, 16);
302
+ const removedState = existsSync(paths.state);
303
+ const database = await resetLocalPostgres({
304
+ workspaceId,
305
+ credentialPath: paths.databaseCredential,
306
+ });
307
+ rmSync(paths.state, { recursive: true, force: true });
308
+ return { workspaceId, database, removedState };
309
+ }
310
+ finally {
311
+ rmSync(paths.lockDirectory, { recursive: true, force: true });
312
+ }
313
+ }
314
+ async function prepareLocalDevelopmentSession(input, databaseRuntime, root, paths) {
315
+ try {
316
+ const workspaceId = createHash("sha256")
317
+ .update(root)
318
+ .digest("hex")
319
+ .slice(0, 16);
320
+ const requestedWebPort = input.webPort ?? DEFAULT_WEB_PORT;
321
+ if (!Number.isSafeInteger(requestedWebPort) ||
322
+ requestedWebPort < 1 ||
323
+ requestedWebPort > 65535) {
324
+ throw new Error(`OPENXIANGDA_LOCAL_WEB_PORT_INVALID:${requestedWebPort}`);
325
+ }
326
+ const webPort = input.webPort === undefined
327
+ ? await availablePort(requestedWebPort)
328
+ : await requiredPort(requestedWebPort);
329
+ const appPort = input.uiOnly
330
+ ? null
331
+ : await availablePort(DEFAULT_APP_PORT, new Set([webPort]));
332
+ const platformPort = input.uiOnly
333
+ ? null
334
+ : await availablePort(DEFAULT_PLATFORM_PORT, new Set([webPort, appPort].filter((item) => item !== null)));
335
+ const databasePort = input.uiOnly
336
+ ? null
337
+ : await availablePort(DEFAULT_DATABASE_PORT, new Set([webPort, appPort, platformPort].filter((item) => item !== null)));
338
+ const startedAt = new Date().toISOString();
339
+ const ownerIdentity = currentProcessIdentity();
340
+ if (process.platform !== "win32" && !ownerIdentity) {
341
+ throw new Error("OPENXIANGDA_LOCAL_SESSION_IDENTITY_UNAVAILABLE");
342
+ }
343
+ let databaseResetPrepared = false;
344
+ // Complete non-destructive preflight before honoring reset-then-start.
345
+ if (input.reset && !input.uiOnly && databaseRuntime.reset) {
346
+ await databaseRuntime.reset({
347
+ workspaceId,
348
+ credentialPath: paths.databaseCredential,
349
+ });
350
+ databaseResetPrepared = true;
351
+ }
352
+ if (input.reset)
353
+ rmSync(paths.state, { recursive: true, force: true });
354
+ mkdirSync(paths.state, { recursive: true, mode: 0o700 });
355
+ const session = {
356
+ schemaVersion: 1,
357
+ workspaceId,
358
+ root,
359
+ appCode: input.appCode,
360
+ mode: "local",
361
+ uiOnly: input.uiOnly === true,
362
+ ownerPid: process.pid,
363
+ ...(ownerIdentity ? { ownerIdentity } : {}),
364
+ status: "starting",
365
+ urls: {
366
+ web: `http://${LOOPBACK_HOST}:${webPort}`,
367
+ app: appPort === null ? null : `http://${LOOPBACK_HOST}:${appPort}`,
368
+ platform: platformPort === null
369
+ ? `http://${LOOPBACK_HOST}:${webPort}/service`
370
+ : `http://${LOOPBACK_HOST}:${platformPort}/service`,
371
+ },
372
+ ports: {
373
+ web: webPort,
374
+ app: appPort,
375
+ platform: platformPort,
376
+ database: databasePort,
377
+ },
378
+ database: null,
379
+ startedAt,
380
+ };
381
+ const runtimeOAuthCredential = input.uiOnly
382
+ ? null
383
+ : loadOrCreateRuntimeOAuthCredential(paths.oauthCredential, workspaceId, input.appCode, input.runtimeOAuthScopes, input.reset === true);
384
+ const eventCredential = input.uiOnly
385
+ ? null
386
+ : loadOrCreateEventCredential(paths.eventCredential, workspaceId, input.appCode, input.eventSubscriptionCodes, input.reset === true);
387
+ const localManifest = input.localManifest
388
+ ? {
389
+ ...input.localManifest,
390
+ ...(runtimeOAuthCredential
391
+ ? {
392
+ runtimeOAuthClient: {
393
+ clientId: runtimeOAuthCredential.clientId,
394
+ clientSecretHash: runtimeOAuthCredential.clientSecretHash,
395
+ scopes: runtimeOAuthCredential.scopes,
396
+ },
397
+ }
398
+ : {}),
399
+ }
400
+ : undefined;
401
+ writeJsonAtomic(paths.lockMetadata, session);
402
+ writeJsonAtomic(paths.diagnostics, session);
403
+ if (localManifest)
404
+ writeJsonAtomic(paths.manifest, localManifest);
405
+ return {
406
+ databaseResetPrepared,
407
+ webPort,
408
+ appPort,
409
+ platformPort,
410
+ databasePort,
411
+ startedAt,
412
+ session,
413
+ runtimeOAuthCredential,
414
+ eventCredential,
415
+ localManifest,
416
+ };
417
+ }
418
+ catch (error) {
419
+ // A failed preflight must not leave a young lock that blocks the next run.
420
+ rmSync(paths.lockDirectory, { recursive: true, force: true });
421
+ throw error;
422
+ }
423
+ }
424
+ function localPaths(root) {
425
+ const localRoot = join(root, ".openxiangda", "local");
426
+ return {
427
+ root: localRoot,
428
+ lockDirectory: join(localRoot, "dev.lock"),
429
+ lockMetadata: join(localRoot, "dev.lock", "session.json"),
430
+ diagnostics: join(localRoot, "diagnostics.json"),
431
+ logs: join(localRoot, "logs"),
432
+ log: join(localRoot, "logs", "dev.log"),
433
+ state: join(localRoot, "state"),
434
+ databaseCredential: join(localRoot, "state", "postgres.json"),
435
+ oauthCredential: join(localRoot, "state", "oauth-runtime.json"),
436
+ eventCredential: join(localRoot, "state", "event-runtime.json"),
437
+ manifest: join(localRoot, "state", "manifest.json"),
438
+ };
439
+ }
440
+ function loadOrCreateEventCredential(path, workspaceId, appCode, requestedCodes, reset) {
441
+ const codes = [...new Set((requestedCodes || []).map(String))].sort();
442
+ let existing = null;
443
+ if (!reset && existsSync(path)) {
444
+ existing = JSON.parse(readFileSync(path, "utf8"));
445
+ if (existing.schemaVersion !== 1 ||
446
+ existing.workspaceId !== workspaceId ||
447
+ existing.appCode !== appCode ||
448
+ !existing.secrets ||
449
+ typeof existing.secrets !== "object") {
450
+ throw new Error("OPENXIANGDA_LOCAL_EVENT_CREDENTIAL_INVALID");
451
+ }
452
+ }
453
+ const secrets = Object.fromEntries(codes.map(code => {
454
+ if (!/^[a-z][a-z0-9]*(?:-[a-z0-9]+)*$/.test(code)) {
455
+ throw new Error(`OPENXIANGDA_LOCAL_EVENT_CODE_INVALID:${code}`);
456
+ }
457
+ const current = existing?.secrets[code];
458
+ return [
459
+ code,
460
+ typeof current === "string" && current.length >= 32
461
+ ? current
462
+ : `oxev_local_${randomBytes(32).toString("base64url")}`,
463
+ ];
464
+ }));
465
+ const value = {
466
+ schemaVersion: 1,
467
+ workspaceId,
468
+ appCode,
469
+ secrets,
470
+ };
471
+ if (!existing || JSON.stringify(existing.secrets) !== JSON.stringify(secrets)) {
472
+ writeJsonAtomic(path, value);
473
+ }
474
+ return value;
475
+ }
476
+ function environmentCode(code) {
477
+ return code.toUpperCase().replace(/[^A-Z0-9]+/g, "_");
478
+ }
479
+ function loadOrCreateRuntimeOAuthCredential(path, workspaceId, appCode, requestedScopes, reset) {
480
+ const scopes = normalizeRuntimeOAuthScopes(requestedScopes);
481
+ if (!reset && existsSync(path)) {
482
+ const value = JSON.parse(readFileSync(path, "utf8"));
483
+ if (value.schemaVersion !== 1 ||
484
+ value.workspaceId !== workspaceId ||
485
+ value.appCode !== appCode ||
486
+ !/^oxa_runtime_[A-Za-z0-9_-]+$/.test(value.clientId) ||
487
+ typeof value.clientSecret !== "string" ||
488
+ value.clientSecret.length < 32 ||
489
+ !/^scrypt\$[^$]+\$[^$]+$/.test(value.clientSecretHash)) {
490
+ throw new Error("OPENXIANGDA_LOCAL_OAUTH_CREDENTIAL_INVALID");
491
+ }
492
+ if (JSON.stringify(value.scopes) !== JSON.stringify(scopes)) {
493
+ const next = { ...value, scopes };
494
+ writeJsonAtomic(path, next);
495
+ return next;
496
+ }
497
+ return value;
498
+ }
499
+ const clientSecret = `oxs_local_${randomBytes(32).toString("base64url")}`;
500
+ const salt = randomBytes(16);
501
+ const value = {
502
+ schemaVersion: 1,
503
+ workspaceId,
504
+ appCode,
505
+ clientId: `oxa_runtime_${workspaceId}`,
506
+ clientSecret,
507
+ clientSecretHash: `scrypt$${salt.toString("base64url")}$${scryptSync(clientSecret, salt, 32).toString("base64url")}`,
508
+ scopes,
509
+ };
510
+ writeJsonAtomic(path, value);
511
+ return value;
512
+ }
513
+ function normalizeRuntimeOAuthScopes(scopes) {
514
+ return [
515
+ ...new Set([
516
+ "app:invoke",
517
+ "data:read",
518
+ "data:write",
519
+ "data:transaction",
520
+ "runtime:lease",
521
+ "secret:read",
522
+ ...(scopes || []),
523
+ ].map(scope => String(scope).trim()).filter(Boolean)),
524
+ ].sort();
525
+ }
526
+ async function acquireSessionLock(paths, workspaceRoot) {
527
+ for (let attempt = 0; attempt < 30; attempt += 1) {
528
+ try {
529
+ mkdirSync(paths.lockDirectory, { mode: 0o700 });
530
+ return null;
531
+ }
532
+ catch (error) {
533
+ if (error.code !== "EEXIST")
534
+ throw error;
535
+ const existing = readActiveSession(paths.lockMetadata, workspaceRoot);
536
+ if (existing)
537
+ return existing;
538
+ if (!existsSync(paths.lockMetadata) &&
539
+ Date.now() - statSync(paths.lockDirectory).mtimeMs < 10_000) {
540
+ await delay(100);
541
+ continue;
542
+ }
543
+ const stale = `${paths.lockDirectory}.stale-${process.pid}-${Date.now()}`;
544
+ try {
545
+ renameSync(paths.lockDirectory, stale);
546
+ rmSync(stale, { recursive: true, force: true });
547
+ }
548
+ catch (reclaimError) {
549
+ if (reclaimError.code !== "ENOENT") {
550
+ throw reclaimError;
551
+ }
552
+ }
553
+ }
554
+ }
555
+ throw new Error("OPENXIANGDA_LOCAL_LOCK_CONTENTION");
556
+ }
557
+ function readActiveSession(path, expectedRoot) {
558
+ const session = readSession(path, expectedRoot);
559
+ if (!session)
560
+ return null;
561
+ const ownerMatches = session.ownerIdentity
562
+ ? processIdentity(session.ownerPid) === session.ownerIdentity
563
+ : pidIsRunning(session.ownerPid);
564
+ return ownerMatches && ["starting", "ready"].includes(session.status)
565
+ ? session
566
+ : null;
567
+ }
568
+ function readSession(path, expectedRoot) {
569
+ if (!existsSync(path))
570
+ return null;
571
+ try {
572
+ return readSessionStrict(path, expectedRoot);
573
+ }
574
+ catch {
575
+ return null;
576
+ }
577
+ }
578
+ function readSessionStrict(path, expectedRoot) {
579
+ const session = JSON.parse(readFileSync(path, "utf8"));
580
+ const expectedWorkspaceId = createHash("sha256")
581
+ .update(expectedRoot)
582
+ .digest("hex")
583
+ .slice(0, 16);
584
+ if (session.schemaVersion !== 1 ||
585
+ session.root !== expectedRoot ||
586
+ session.workspaceId !== expectedWorkspaceId ||
587
+ !Number.isSafeInteger(session.ownerPid) ||
588
+ session.ownerPid <= 0) {
589
+ throw new Error("OPENXIANGDA_LOCAL_SESSION_OWNERSHIP_MISMATCH");
590
+ }
591
+ return session;
592
+ }
593
+ function assertSessionMetadataIsSafe(paths, expectedRoot) {
594
+ if (!existsSync(paths.lockMetadata))
595
+ return;
596
+ try {
597
+ readSessionStrict(paths.lockMetadata, expectedRoot);
598
+ }
599
+ catch (error) {
600
+ throw new Error(`OPENXIANGDA_LOCAL_SESSION_OWNERSHIP_MISMATCH: ${error.message}`);
601
+ }
602
+ }
603
+ function processIdentity(pid) {
604
+ if (process.platform === "win32")
605
+ return undefined;
606
+ const result = spawnSync("ps", ["-p", String(pid), "-o", "lstart=", "-o", "command="], {
607
+ encoding: "utf8",
608
+ });
609
+ if (result.status !== 0)
610
+ return undefined;
611
+ const value = String(result.stdout || "").trim();
612
+ return value ? createHash("sha256").update(value).digest("hex") : undefined;
613
+ }
614
+ function currentProcessIdentity() {
615
+ return processIdentity(process.pid);
616
+ }
617
+ async function waitForProcessStop(pid, ownerIdentity, timeoutMs) {
618
+ const deadline = Date.now() + timeoutMs;
619
+ while (Date.now() < deadline) {
620
+ if (!pidIsRunning(pid))
621
+ return true;
622
+ if (ownerIdentity && processIdentity(pid) !== ownerIdentity)
623
+ return true;
624
+ await delay(100);
625
+ }
626
+ return false;
627
+ }
628
+ function pidIsRunning(pid) {
629
+ try {
630
+ process.kill(pid, 0);
631
+ return true;
632
+ }
633
+ catch (error) {
634
+ return error.code === "EPERM";
635
+ }
636
+ }
637
+ async function availablePort(preferred, excluded = new Set()) {
638
+ if (!excluded.has(preferred) && (await portIsAvailable(preferred))) {
639
+ return preferred;
640
+ }
641
+ for (let attempt = 0; attempt < 20; attempt += 1) {
642
+ const port = await ephemeralPort();
643
+ if (!excluded.has(port))
644
+ return port;
645
+ }
646
+ throw new Error("OPENXIANGDA_LOCAL_PORT_ALLOCATION_FAILED");
647
+ }
648
+ async function requiredPort(port) {
649
+ if (await portIsAvailable(port))
650
+ return port;
651
+ throw new Error(`OPENXIANGDA_LOCAL_WEB_PORT_IN_USE:${port}`);
652
+ }
653
+ function ephemeralPort() {
654
+ return new Promise((resolve, reject) => {
655
+ const server = createServer();
656
+ server.unref();
657
+ server.once("error", reject);
658
+ server.listen({ host: LOOPBACK_HOST, port: 0, exclusive: true }, () => {
659
+ const address = server.address();
660
+ const port = typeof address === "object" && address ? address.port : 0;
661
+ server.close(error => (error ? reject(error) : resolve(port)));
662
+ });
663
+ });
664
+ }
665
+ async function portIsAvailable(port) {
666
+ return await new Promise(resolve => {
667
+ const server = createServer();
668
+ server.unref();
669
+ server.once("error", () => resolve(false));
670
+ server.listen({ host: LOOPBACK_HOST, port, exclusive: true }, () => {
671
+ server.close(() => resolve(true));
672
+ });
673
+ });
674
+ }
675
+ async function waitForLocalReadiness(session, children, timeoutMs = 120_000) {
676
+ const urls = [
677
+ session.urls.web,
678
+ `${session.urls.platform}/openxiangda-api/v2/capabilities`,
679
+ ...(session.urls.app
680
+ ? [`${session.urls.app}/__platform/ready`]
681
+ : []),
682
+ ];
683
+ const deadline = Date.now() + timeoutMs;
684
+ while (Date.now() < deadline) {
685
+ const exited = children.find(child => child.exitCode !== null);
686
+ if (exited) {
687
+ throw new Error(`OPENXIANGDA_LOCAL_PROCESS_EXITED: ${exited.exitCode}`);
688
+ }
689
+ const checks = await Promise.all(urls.map(url => httpReady(url)));
690
+ if (checks.every(Boolean))
691
+ return;
692
+ await delay(200);
693
+ }
694
+ throw new Error(`OPENXIANGDA_LOCAL_READINESS_TIMEOUT: ${urls.join(", ")}`);
695
+ }
696
+ async function httpReady(url) {
697
+ const controller = new AbortController();
698
+ const timeout = setTimeout(() => controller.abort(), 1_000);
699
+ try {
700
+ const response = await fetch(url, { signal: controller.signal });
701
+ return response.ok;
702
+ }
703
+ catch {
704
+ return false;
705
+ }
706
+ finally {
707
+ clearTimeout(timeout);
708
+ }
709
+ }
710
+ function childExit(child) {
711
+ return new Promise(resolve => {
712
+ let spawnError;
713
+ child.once("error", error => {
714
+ spawnError = error;
715
+ });
716
+ child.once("close", (code, signal) => {
717
+ resolve({
718
+ code: code ?? (signal ? 0 : 1),
719
+ ...(spawnError ? { error: spawnError } : {}),
720
+ });
721
+ });
722
+ });
723
+ }
724
+ function terminateChild(child, signal) {
725
+ if (!child?.pid || child.exitCode !== null)
726
+ return;
727
+ try {
728
+ if (process.platform === "win32") {
729
+ spawn("taskkill", ["/pid", String(child.pid), "/t", "/f"], {
730
+ detached: true,
731
+ stdio: "ignore",
732
+ }).unref();
733
+ }
734
+ else {
735
+ process.kill(-child.pid, signal);
736
+ }
737
+ }
738
+ catch {
739
+ try {
740
+ child.kill(signal);
741
+ }
742
+ catch {
743
+ // The child already exited between the status check and termination.
744
+ }
745
+ }
746
+ }
747
+ function terminateChildren(children, signal) {
748
+ for (const child of children)
749
+ terminateChild(child, signal);
750
+ }
751
+ async function waitForChildrenExit(children, exits) {
752
+ if (exits.length === 0)
753
+ return [];
754
+ const completed = Promise.all(exits);
755
+ const exited = await Promise.race([
756
+ completed.then(() => true),
757
+ delay(5_000).then(() => false),
758
+ ]);
759
+ if (!exited) {
760
+ terminateChildren(children, "SIGKILL");
761
+ return await Promise.race([completed, delay(1_000).then(() => [])]);
762
+ }
763
+ return await completed;
764
+ }
765
+ function pipeOutput(source, target, log) {
766
+ if (!source)
767
+ return;
768
+ source.on("data", chunk => {
769
+ target.write(chunk);
770
+ log.write(chunk);
771
+ });
772
+ }
773
+ function writeJsonAtomic(path, value) {
774
+ const temporary = `${path}.${process.pid}.tmp`;
775
+ writeFileSync(temporary, `${JSON.stringify(value, null, 2)}\n`, {
776
+ encoding: "utf8",
777
+ mode: 0o600,
778
+ });
779
+ renameSync(temporary, path);
780
+ }
781
+ function openBrowser(url) {
782
+ const command = process.platform === "darwin"
783
+ ? { file: "open", args: [url] }
784
+ : process.platform === "win32"
785
+ ? { file: "cmd", args: ["/c", "start", "", url] }
786
+ : { file: "xdg-open", args: [url] };
787
+ try {
788
+ const child = spawn(command.file, command.args, {
789
+ detached: true,
790
+ stdio: "ignore",
791
+ });
792
+ child.on("error", () => undefined);
793
+ child.unref();
794
+ }
795
+ catch {
796
+ // The URL is always printed, so headless environments remain usable.
797
+ }
798
+ }
799
+ function delay(milliseconds) {
800
+ return new Promise(resolve => setTimeout(resolve, milliseconds));
801
+ }
802
+ //# sourceMappingURL=local-development.js.map