@arcote.tech/arc-cli 0.7.32 → 0.8.1
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/.arc/platform/_input.css +61 -0
- package/.arc/platform/access.json +1 -0
- package/.arc/platform/browser/_entries/initial.ts +2 -0
- package/.arc/platform/server/_externals.json +1 -0
- package/.arc/platform/server/_server.js +9 -0
- package/.arc/platform/server/_server.js.map +9 -0
- package/dist/index.js +925 -365
- package/package.json +10 -10
- package/src/builder/access-extractor.ts +34 -7
- package/src/builder/framework-peers.ts +7 -1
- package/src/builder/module-builder.ts +49 -11
- package/src/builder/peer-shell.ts +146 -0
- package/src/commands/platform-deploy.ts +42 -1
- package/src/commands/platform-dev.ts +7 -2
- package/src/deploy/assets/terraform/main.tf +35 -1
- package/src/deploy/assets/terraform/variables.tf +6 -0
- package/src/deploy/assets.ts +41 -1
- package/src/deploy/bootstrap.ts +9 -1
- package/src/deploy/config.ts +61 -1
- package/src/deploy/survey.ts +15 -1
- package/src/deploy/terraform.ts +3 -0
- package/src/index.ts +10 -2
- package/src/platform/server.ts +448 -45
- package/src/platform/shared.ts +119 -8
- package/src/platform/startup.ts +208 -4
package/src/platform/shared.ts
CHANGED
|
@@ -23,6 +23,8 @@ import {
|
|
|
23
23
|
type BuildCache,
|
|
24
24
|
} from "../builder/build-cache";
|
|
25
25
|
import { extractAccessMap } from "../builder/access-extractor";
|
|
26
|
+
import { buildPeerShell, type PeerShellResult } from "../builder/peer-shell";
|
|
27
|
+
import type { FederationConfig } from "@arcote.tech/platform";
|
|
26
28
|
import { assertOneModulePerPackage, planChunks } from "../builder/chunk-planner";
|
|
27
29
|
import { collectFrameworkDeps } from "../builder/dependency-collector";
|
|
28
30
|
import {
|
|
@@ -156,6 +158,7 @@ export async function buildAll(
|
|
|
156
158
|
const cache = loadBuildCache(ws.arcDir);
|
|
157
159
|
const noCache = opts.noCache ?? false;
|
|
158
160
|
const themePath = ws.rootPkg.arc?.theme as string | undefined;
|
|
161
|
+
const federation = getFederationConfig(ws);
|
|
159
162
|
|
|
160
163
|
log(`Building (concurrency parallel${noCache ? ", no-cache" : ""})...`);
|
|
161
164
|
|
|
@@ -188,27 +191,77 @@ export async function buildAll(
|
|
|
188
191
|
mkdirSync(ws.arcDir, { recursive: true });
|
|
189
192
|
writeFileSync(
|
|
190
193
|
join(ws.arcDir, "access.json"),
|
|
191
|
-
JSON.stringify(accessMap, null, 2) + "\n",
|
|
194
|
+
JSON.stringify(accessMap.access, null, 2) + "\n",
|
|
195
|
+
);
|
|
196
|
+
|
|
197
|
+
// Federacja per-moduł (spec/module-federation.md): rola HOSTA (arc.federation
|
|
198
|
+
// .host — importuje cudze moduły) i rola PARTNERA (moduły z exposeToHosts —
|
|
199
|
+
// eksportowane) są niezależne.
|
|
200
|
+
const isHost = Boolean(federation?.host);
|
|
201
|
+
const moduleNameOf = (name: string) => name.split("/").pop() ?? name;
|
|
202
|
+
const exposureOf = (pkgName: string): "local" | "external" | "both" =>
|
|
203
|
+
accessMap.exposure[moduleNameOf(pkgName)]?.exposure ?? "local";
|
|
204
|
+
// Normalna platforma: moduły local/both (external-only ukryte w macierzystej
|
|
205
|
+
// platformie). Host bierze wszystko (nie ukrywa niczego przed sobą).
|
|
206
|
+
const localPackages = isHost
|
|
207
|
+
? ws.packages
|
|
208
|
+
: ws.packages.filter((p) => exposureOf(p.name) !== "external");
|
|
209
|
+
// Build federowany: moduły external/both → browser-fed/.
|
|
210
|
+
const fedPackages = ws.packages.filter((p) =>
|
|
211
|
+
["external", "both"].includes(exposureOf(p.name)),
|
|
212
|
+
);
|
|
213
|
+
const hasFederatedModules = fedPackages.length > 0;
|
|
214
|
+
// Peer-shell (import mapa) potrzebny, gdy współdzielimy runtime: host
|
|
215
|
+
// (ładuje gości) lub partner (jego chunki są external-peers).
|
|
216
|
+
const needsPeerShell = isHost || hasFederatedModules;
|
|
217
|
+
log(
|
|
192
218
|
);
|
|
193
219
|
|
|
194
220
|
// Phase 3 — group modules into chunks (one Bun.build per group).
|
|
195
|
-
const plan = planChunks(
|
|
221
|
+
const plan = planChunks(localPackages, accessMap.access);
|
|
196
222
|
ok(
|
|
197
223
|
`Chunks: ${plan.chunks
|
|
198
224
|
.map((c) => `${c}(${plan.groups.get(c)?.length ?? 0})`)
|
|
199
225
|
.join(", ")}`,
|
|
200
226
|
);
|
|
227
|
+
const fedPlan = hasFederatedModules
|
|
228
|
+
? planChunks(fedPackages, accessMap.access)
|
|
229
|
+
: null;
|
|
230
|
+
if (fedPlan) {
|
|
231
|
+
ok(
|
|
232
|
+
`Federated modules: ${fedPackages.map((p) => moduleNameOf(p.name)).join(", ")}`,
|
|
233
|
+
);
|
|
234
|
+
}
|
|
201
235
|
|
|
202
|
-
// Phase 4 — independent parallel build units.
|
|
203
|
-
//
|
|
204
|
-
//
|
|
236
|
+
// Phase 4 — independent parallel build units. Normalny build (bundled, chyba
|
|
237
|
+
// że host → external peers) + opcjonalny build federowany (browser-fed/,
|
|
238
|
+
// zawsze external peers) + peer-shell.
|
|
205
239
|
const i18nCollector = new Map<string, Set<string>>();
|
|
206
240
|
|
|
207
|
-
const [browserResult] = await Promise.all([
|
|
208
|
-
|
|
241
|
+
const [browserResult, , , , shellResult, fedResult] = await Promise.all([
|
|
242
|
+
// HOST buduje się BUNDLED (React inline, eager — bez lazy-race), a initial
|
|
243
|
+
// eksponuje runtime na globalThis; gościa obsłuży peer-shell (globalThis).
|
|
244
|
+
buildBrowserApp(ws.rootDir, ws.browserDir, plan, cache, noCache, i18nCollector, {
|
|
245
|
+
federated: false,
|
|
246
|
+
exposeRuntime: isHost,
|
|
247
|
+
}),
|
|
209
248
|
buildStyles(ws.rootDir, ws.arcDir, ws.packages, themePath, cache, noCache),
|
|
210
249
|
copyBrowserAssets(ws, cache, noCache),
|
|
211
250
|
buildTranslations(ws.rootDir, ws.arcDir, cache, noCache),
|
|
251
|
+
needsPeerShell
|
|
252
|
+
? buildPeerShell(ws.rootDir, join(ws.arcDir, "shell"), cache, noCache)
|
|
253
|
+
: Promise.resolve(undefined),
|
|
254
|
+
fedPlan
|
|
255
|
+
? buildBrowserApp(
|
|
256
|
+
ws.rootDir,
|
|
257
|
+
join(ws.arcDir, "browser-fed"),
|
|
258
|
+
fedPlan,
|
|
259
|
+
cache,
|
|
260
|
+
noCache,
|
|
261
|
+
i18nCollector,
|
|
262
|
+
{ federated: true, unitId: "browser-app-fed" },
|
|
263
|
+
)
|
|
264
|
+
: Promise.resolve(undefined),
|
|
212
265
|
]);
|
|
213
266
|
|
|
214
267
|
// Finalize i18n catalogs once after all chunks + initial bundle collected.
|
|
@@ -222,7 +275,43 @@ export async function buildAll(
|
|
|
222
275
|
|
|
223
276
|
saveBuildCache(ws.arcDir, cache);
|
|
224
277
|
|
|
225
|
-
const
|
|
278
|
+
const federationBuild =
|
|
279
|
+
fedResult && hasFederatedModules
|
|
280
|
+
? {
|
|
281
|
+
config: {
|
|
282
|
+
slug: federation?.slug,
|
|
283
|
+
auth: federation?.auth,
|
|
284
|
+
} as FederationConfig,
|
|
285
|
+
modules: fedPackages.map((p) => moduleNameOf(p.name)),
|
|
286
|
+
permissions: [
|
|
287
|
+
...new Set(
|
|
288
|
+
fedPackages.flatMap(
|
|
289
|
+
(p) => accessMap.exposure[moduleNameOf(p.name)]?.permissions ?? [],
|
|
290
|
+
),
|
|
291
|
+
),
|
|
292
|
+
],
|
|
293
|
+
slots: [
|
|
294
|
+
...new Set(
|
|
295
|
+
fedPackages.flatMap(
|
|
296
|
+
(p) => accessMap.exposure[moduleNameOf(p.name)]?.slots ?? [],
|
|
297
|
+
),
|
|
298
|
+
),
|
|
299
|
+
],
|
|
300
|
+
build: {
|
|
301
|
+
initial: fedResult.initial,
|
|
302
|
+
groups: fedResult.groups,
|
|
303
|
+
sharedChunks: fedResult.sharedChunks,
|
|
304
|
+
},
|
|
305
|
+
}
|
|
306
|
+
: undefined;
|
|
307
|
+
|
|
308
|
+
const finalManifest = assembleManifest(
|
|
309
|
+
ws,
|
|
310
|
+
browserResult,
|
|
311
|
+
cache,
|
|
312
|
+
shellResult,
|
|
313
|
+
federationBuild,
|
|
314
|
+
);
|
|
226
315
|
writeFileSync(
|
|
227
316
|
join(ws.arcDir, "manifest.json"),
|
|
228
317
|
JSON.stringify(finalManifest, null, 2),
|
|
@@ -237,6 +326,8 @@ function assembleManifest(
|
|
|
237
326
|
ws: WorkspaceInfo,
|
|
238
327
|
browser: BrowserAppResult,
|
|
239
328
|
cache: BuildCache,
|
|
329
|
+
shell?: PeerShellResult,
|
|
330
|
+
federation?: BuildManifest["federation"],
|
|
240
331
|
): BuildManifest {
|
|
241
332
|
const stylesHash = cache.units["styles"]?.outputHash ?? "";
|
|
242
333
|
|
|
@@ -246,9 +337,29 @@ function assembleManifest(
|
|
|
246
337
|
sharedChunks: browser.sharedChunks,
|
|
247
338
|
stylesHash,
|
|
248
339
|
buildTime: new Date().toISOString(),
|
|
340
|
+
...(shell ? { shell: { imports: shell.imports, peers: shell.peers } } : {}),
|
|
341
|
+
...(federation ? { federation } : {}),
|
|
249
342
|
};
|
|
250
343
|
}
|
|
251
344
|
|
|
345
|
+
/**
|
|
346
|
+
* Konfiguracja federacji z root package.json (`arc.federation`) — tożsamość
|
|
347
|
+
* aplikacji (slug/auth) + rola hosta. Które MODUŁY są wystawione, decyduje
|
|
348
|
+
* `module().exposeToHosts()` (spec/module-federation.md). Waliduje slug.
|
|
349
|
+
*/
|
|
350
|
+
export function getFederationConfig(
|
|
351
|
+
ws: WorkspaceInfo,
|
|
352
|
+
): FederationConfig | undefined {
|
|
353
|
+
const raw = ws.rootPkg.arc?.federation as FederationConfig | undefined;
|
|
354
|
+
if (!raw) return undefined;
|
|
355
|
+
if (raw.slug && !/^[a-z0-9-]+$/.test(raw.slug)) {
|
|
356
|
+
throw new Error(
|
|
357
|
+
`arc.federation.slug "${raw.slug}" — dozwolone tylko [a-z0-9-]+`,
|
|
358
|
+
);
|
|
359
|
+
}
|
|
360
|
+
return raw;
|
|
361
|
+
}
|
|
362
|
+
|
|
252
363
|
// ---------------------------------------------------------------------------
|
|
253
364
|
// Browser assets — @arcote.tech/* deps deklarują w `arc.browserAssets` jakie
|
|
254
365
|
// pliki muszą być dostępne w przeglądarce (np. SQLite WASM worker + .wasm).
|
package/src/platform/startup.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { randomBytes } from "crypto";
|
|
2
|
+
import { existsSync, mkdirSync, readFileSync, watch, writeFileSync } from "fs";
|
|
2
3
|
import { join } from "path";
|
|
3
4
|
import { startPlatformServer, type PlatformServer } from "./server";
|
|
4
5
|
import type { BuildManifest } from "./shared";
|
|
@@ -37,6 +38,12 @@ export interface StartPlatformOptions {
|
|
|
37
38
|
devMode: boolean;
|
|
38
39
|
/** Dev-only: also start the Arc Context Map tool on a separate port. */
|
|
39
40
|
map?: boolean;
|
|
41
|
+
/**
|
|
42
|
+
* Headless (spec/platform-headless.md): pełny host API + chunki federowane
|
|
43
|
+
* + mapa, ale BEZ shella SPA — dla aplikacji rozwijanych jako moduły
|
|
44
|
+
* konsumowane przez hosta federacji (BMF). Implikuje `map`.
|
|
45
|
+
*/
|
|
46
|
+
headless?: boolean;
|
|
40
47
|
}
|
|
41
48
|
|
|
42
49
|
export async function startPlatform(
|
|
@@ -63,6 +70,20 @@ export async function startPlatform(
|
|
|
63
70
|
manifest = JSON.parse(readFileSync(manifestPath, "utf-8"));
|
|
64
71
|
}
|
|
65
72
|
|
|
73
|
+
// 1b. Tożsamość federacji — MUSI być gotowa przed `loadServerContext`, bo kod
|
|
74
|
+
// aplikacji czyta `ARC_APP_SECRET` na poziomie modułu (`token().secret(...)`).
|
|
75
|
+
// Jeden token parowania obsługuje mapę (discovery) i rejestrację hosta na
|
|
76
|
+
// platformie — deweloper wkleja hostowi JEDEN token, nie dwa.
|
|
77
|
+
assertProdFederationSecrets(devMode, !!manifest.federation);
|
|
78
|
+
const needsPairing =
|
|
79
|
+
!!manifest.federation || (devMode && (opts.map || opts.headless));
|
|
80
|
+
const pairingToken = needsPairing
|
|
81
|
+
? resolvePairingToken(ws.rootDir)
|
|
82
|
+
: undefined;
|
|
83
|
+
if (manifest.federation) {
|
|
84
|
+
process.env.ARC_APP_SECRET = resolveAppSecret(ws.rootDir);
|
|
85
|
+
}
|
|
86
|
+
|
|
66
87
|
// 2. Server context — same code path in both modes; loadServerContext
|
|
67
88
|
// imports the combined server bundle at .arc/platform/server/_server.js
|
|
68
89
|
// (produced by the buildServerApp step in buildAll).
|
|
@@ -90,6 +111,14 @@ export async function startPlatform(
|
|
|
90
111
|
moduleAccess,
|
|
91
112
|
dbPath,
|
|
92
113
|
devMode,
|
|
114
|
+
headless: opts.headless,
|
|
115
|
+
federation:
|
|
116
|
+
manifest.federation && pairingToken
|
|
117
|
+
? {
|
|
118
|
+
registrationToken: pairingToken,
|
|
119
|
+
appSecret: process.env.ARC_APP_SECRET!,
|
|
120
|
+
}
|
|
121
|
+
: undefined,
|
|
93
122
|
});
|
|
94
123
|
break;
|
|
95
124
|
} catch (e) {
|
|
@@ -115,9 +144,26 @@ export async function startPlatform(
|
|
|
115
144
|
|
|
116
145
|
// 4a. Dev-only: optional Arc Context Map tool on a separate port, starting
|
|
117
146
|
// just above the platform's actual port (auto-increments if busy).
|
|
147
|
+
// Headless implikuje mapę (parowanie + discovery to jego interfejs).
|
|
118
148
|
let onReload: (() => void) | undefined;
|
|
119
|
-
if (devMode && opts.map) {
|
|
120
|
-
|
|
149
|
+
if (devMode && (opts.map || opts.headless)) {
|
|
150
|
+
// Mapa startuje PO platformie, więc jej adres ogłaszamy w `/api/discovery`
|
|
151
|
+
// dopiero teraz (setMapUrl). W prod mapy nie ma i pole zostaje `null`.
|
|
152
|
+
onReload = await startMapTool(ws, actualPort + 1, pairingToken!, platform, {
|
|
153
|
+
platformUrl: `http://localhost:${actualPort}`,
|
|
154
|
+
// Discovery: meta modułów wystawionych (federation.build pominięte —
|
|
155
|
+
// konsument bierze artefakty z /api/federation/manifest).
|
|
156
|
+
federation: manifest.federation
|
|
157
|
+
? {
|
|
158
|
+
slug: manifest.federation.config.slug,
|
|
159
|
+
auth: manifest.federation.config.auth,
|
|
160
|
+
modules: manifest.federation.modules,
|
|
161
|
+
permissions: manifest.federation.permissions,
|
|
162
|
+
slots: manifest.federation.slots,
|
|
163
|
+
}
|
|
164
|
+
: undefined,
|
|
165
|
+
peers: manifest.shell?.peers,
|
|
166
|
+
});
|
|
121
167
|
}
|
|
122
168
|
|
|
123
169
|
// 4. Dev-only: file watcher + debounced rebuild + SSE notify.
|
|
@@ -142,22 +188,42 @@ export async function startPlatform(
|
|
|
142
188
|
async function startMapTool(
|
|
143
189
|
ws: WorkspaceInfo,
|
|
144
190
|
port: number,
|
|
191
|
+
/** Token parowania — ten sam, którym host rejestruje się na platformie. */
|
|
192
|
+
authToken: string,
|
|
193
|
+
/** Serwer platformy — dostaje adres mapy do ogłoszenia w `/api/discovery`. */
|
|
194
|
+
platformServer: PlatformServer,
|
|
195
|
+
platform?: {
|
|
196
|
+
/** Adres serwera platformy (discovery: gdzie żyją komendy/query/WS). */
|
|
197
|
+
platformUrl: string;
|
|
198
|
+
federation?: unknown;
|
|
199
|
+
peers?: Readonly<Record<string, string>>;
|
|
200
|
+
},
|
|
145
201
|
): Promise<(() => void) | undefined> {
|
|
146
202
|
try {
|
|
147
|
-
const { startMapServer } = await import(
|
|
203
|
+
const { startMapServer, MAP_SCHEMA_VERSION } = await import(
|
|
204
|
+
"@arcote.tech/arc-map"
|
|
205
|
+
);
|
|
148
206
|
const { getContext } = await import("@arcote.tech/platform");
|
|
149
207
|
const globs = ws.packages
|
|
150
208
|
.map((p) => join(p.path, "src", "**", "*.{ts,tsx}"));
|
|
151
209
|
const packageOf = (absFile: string): string | undefined =>
|
|
152
210
|
ws.packages.find((p) => absFile.startsWith(join(p.path, "src")))?.name;
|
|
153
211
|
|
|
212
|
+
// Tożsamość aplikacji dla zewnętrznych konsumentów (import do BMF).
|
|
213
|
+
const meta = readAppMeta(ws.rootDir, MAP_SCHEMA_VERSION);
|
|
214
|
+
|
|
154
215
|
const map = await startMapServer({
|
|
155
216
|
getContext: () => getContext(),
|
|
156
217
|
scan: { globs, workspaceRoot: ws.rootDir, packageOf },
|
|
157
218
|
useCasePackages: ws.packages.map((p) => ({ name: p.name, path: p.path })),
|
|
158
219
|
port,
|
|
220
|
+
authToken,
|
|
221
|
+
meta,
|
|
222
|
+
platform,
|
|
159
223
|
});
|
|
224
|
+
platformServer.setMapUrl(map.url);
|
|
160
225
|
ok(`Context map → ${map.url}`);
|
|
226
|
+
ok(`Pairing token: ${authToken}`);
|
|
161
227
|
return map.notifyReload;
|
|
162
228
|
} catch (e) {
|
|
163
229
|
err(`Context map failed to start: ${(e as Error).message}`);
|
|
@@ -165,6 +231,144 @@ async function startMapTool(
|
|
|
165
231
|
}
|
|
166
232
|
}
|
|
167
233
|
|
|
234
|
+
/**
|
|
235
|
+
* PROD + federacja → sekrety MUSZĄ pochodzić z env. Twarda odmowa startu.
|
|
236
|
+
*
|
|
237
|
+
* DLACZEGO tak ostro: `.arc/` nie trafia ani do obrazu (deploy kopiuje tylko
|
|
238
|
+
* `.arc/platform/`), ani na wolumen (montowane jest tylko `.arc/data`). Plik
|
|
239
|
+
* wygenerowany wewnątrz kontenera przeżywa `restart`, ale **ginie przy pierwszym
|
|
240
|
+
* `recreate`** (nowy obraz = nowy kontener). Wtedy:
|
|
241
|
+
* - nowy `ARC_APP_SECRET` → hosty trzymają STARY sekret, więc żadna asercja
|
|
242
|
+
* tożsamości się nie zweryfikuje i aplikacja przestaje działać u wszystkich,
|
|
243
|
+
* bez jednego błędu przy starcie,
|
|
244
|
+
* - nowy token parowania → istniejące parowania martwe.
|
|
245
|
+
*
|
|
246
|
+
* Cicha awaria dzień po deployu jest dużo gorsza niż głośna odmowa startu.
|
|
247
|
+
* Wymieniamy WSZYSTKIE braki naraz — inaczej operator poprawia po jednej na deploy.
|
|
248
|
+
*/
|
|
249
|
+
function assertProdFederationSecrets(
|
|
250
|
+
devMode: boolean,
|
|
251
|
+
hasFederation: boolean,
|
|
252
|
+
): void {
|
|
253
|
+
if (devMode || !hasFederation) return;
|
|
254
|
+
const missing: string[] = [];
|
|
255
|
+
if (!process.env.ARC_APP_SECRET) missing.push("ARC_APP_SECRET");
|
|
256
|
+
if (!process.env.ARC_PAIRING_TOKEN && !process.env.ARC_MAP_TOKEN) {
|
|
257
|
+
missing.push("ARC_PAIRING_TOKEN");
|
|
258
|
+
}
|
|
259
|
+
if (missing.length === 0) return;
|
|
260
|
+
|
|
261
|
+
err(
|
|
262
|
+
`Federacja w produkcji wymaga zmiennych środowiskowych: ${missing.join(", ")}.\n` +
|
|
263
|
+
` Bez nich sekret i token są generowane w kontenerze i GINĄ przy pierwszym\n` +
|
|
264
|
+
` recreate — hosty musiałyby przeinstalować aplikację, bez żadnego błędu.\n` +
|
|
265
|
+
` Ustaw je w deployu (deploy.arc.json → envVars + deploy.arc.<env>.env).`,
|
|
266
|
+
);
|
|
267
|
+
process.exit(1);
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
/**
|
|
271
|
+
* Sekret aplikacji — WŁASNOŚĆ PARTNERA, stały między restartami. Kolejność:
|
|
272
|
+
* 1. `ARC_APP_SECRET` (env) — jawna kontrola (prod, rotacja)
|
|
273
|
+
* 2. `.arc/app-secret` — wygenerowany raz, przeżywa restart
|
|
274
|
+
* 3. wygeneruj i zapisz
|
|
275
|
+
*
|
|
276
|
+
* Tym sekretem host federacji (BMF) podpisuje asercje tożsamości, a aplikacja
|
|
277
|
+
* je weryfikuje (`token("hostIdentity", …).secret(process.env.ARC_APP_SECRET)`).
|
|
278
|
+
* Sekret NALEŻY do aplikacji i host go POBIERA przy rejestracji
|
|
279
|
+
* (`POST /api/federation/register`) — nie odwrotnie. Dzięki temu aplikacja ma
|
|
280
|
+
* go już przy starcie i instalacja u hosta nie wymaga wklejania niczego do env
|
|
281
|
+
* ani restartu partnera (to było źródłem błędnego koła: instalacja → restart →
|
|
282
|
+
* nowy token parowania → parowanie martwe).
|
|
283
|
+
*
|
|
284
|
+
* Ustawiany do `process.env` PRZED `loadServerContext`, bo kod aplikacji czyta
|
|
285
|
+
* go na poziomie modułu. `.arc/` jest gitignorowane.
|
|
286
|
+
*/
|
|
287
|
+
function resolveAppSecret(rootDir: string): string {
|
|
288
|
+
const fromEnv = process.env.ARC_APP_SECRET;
|
|
289
|
+
if (fromEnv) return fromEnv;
|
|
290
|
+
|
|
291
|
+
const file = join(rootDir, ".arc", "app-secret");
|
|
292
|
+
try {
|
|
293
|
+
const saved = readFileSync(file, "utf-8").trim();
|
|
294
|
+
if (saved) return saved;
|
|
295
|
+
} catch {
|
|
296
|
+
// brak pliku lub nieczytelny — generujemy nowy poniżej
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
const secret = randomBytes(32).toString("base64url");
|
|
300
|
+
try {
|
|
301
|
+
mkdirSync(join(rootDir, ".arc"), { recursive: true });
|
|
302
|
+
writeFileSync(file, secret, { mode: 0o600 });
|
|
303
|
+
} catch (e) {
|
|
304
|
+
// Nie fatal, ale sekret zmieni się po restarcie → hosty stracą ważność.
|
|
305
|
+
err(
|
|
306
|
+
`App secret not persisted (${(e as Error).message}) — hosts will need re-install after restart`,
|
|
307
|
+
);
|
|
308
|
+
}
|
|
309
|
+
return secret;
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
/**
|
|
313
|
+
* Token parowania — STAŁY między restartami. Kolejność:
|
|
314
|
+
* 1. `ARC_PAIRING_TOKEN` (env), alias `ARC_MAP_TOKEN` — jawna kontrola
|
|
315
|
+
* (prod, CI, wiele instancji obok siebie)
|
|
316
|
+
* 2. `.arc/pairing-token` — wygenerowany raz, przeżywa restart
|
|
317
|
+
* 3. wygeneruj i zapisz
|
|
318
|
+
*
|
|
319
|
+
* Nazwa `ARC_MAP_TOKEN` jest historyczna: token bramkuje dziś także
|
|
320
|
+
* `/api/discovery` i `/api/federation/register` NA PLATFORMIE, nie tylko mapę.
|
|
321
|
+
* Zostaje jako alias, żeby nie łamać istniejących deployów.
|
|
322
|
+
*
|
|
323
|
+
* Token losowany przy każdym starcie unieważniał parowanie z hostem (host
|
|
324
|
+
* trzyma go po swojej stronie), przez co instalacja aplikacji federowanej
|
|
325
|
+
* wpadała w błędne koło: instalacja wymagała restartu partnera, a restart
|
|
326
|
+
* zabijał parowanie. `.arc/` jest gitignorowane, więc token nie wycieka do repo.
|
|
327
|
+
*/
|
|
328
|
+
function resolvePairingToken(rootDir: string): string {
|
|
329
|
+
const fromEnv = process.env.ARC_PAIRING_TOKEN || process.env.ARC_MAP_TOKEN;
|
|
330
|
+
if (fromEnv) return fromEnv;
|
|
331
|
+
|
|
332
|
+
const file = join(rootDir, ".arc", "pairing-token");
|
|
333
|
+
try {
|
|
334
|
+
const saved = readFileSync(file, "utf-8").trim();
|
|
335
|
+
if (saved) return saved;
|
|
336
|
+
} catch {
|
|
337
|
+
// brak pliku lub nieczytelny — generujemy nowy poniżej
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
const token = randomBytes(16).toString("base64url");
|
|
341
|
+
try {
|
|
342
|
+
mkdirSync(join(rootDir, ".arc"), { recursive: true });
|
|
343
|
+
writeFileSync(file, token, { mode: 0o600 });
|
|
344
|
+
} catch (e) {
|
|
345
|
+
// Nie fatal, ale użytkownik MUSI wiedzieć — inaczej wraca błędne koło.
|
|
346
|
+
err(
|
|
347
|
+
`Pairing token not persisted (${(e as Error).message}) — it will change on restart`,
|
|
348
|
+
);
|
|
349
|
+
}
|
|
350
|
+
return token;
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
/** Nazwa+wersja aplikacji z root package.json (best effort). */
|
|
354
|
+
function readAppMeta(
|
|
355
|
+
rootDir: string,
|
|
356
|
+
schemaVersion: number,
|
|
357
|
+
): { schemaVersion: number; name: string; version?: string } {
|
|
358
|
+
try {
|
|
359
|
+
const pkg = JSON.parse(
|
|
360
|
+
readFileSync(join(rootDir, "package.json"), "utf-8"),
|
|
361
|
+
) as { name?: string; version?: string };
|
|
362
|
+
return {
|
|
363
|
+
schemaVersion,
|
|
364
|
+
name: pkg.name ?? rootDir.split("/").pop() ?? "arc-app",
|
|
365
|
+
version: pkg.version,
|
|
366
|
+
};
|
|
367
|
+
} catch {
|
|
368
|
+
return { schemaVersion, name: rootDir.split("/").pop() ?? "arc-app" };
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
|
|
168
372
|
function attachDevWatcher(
|
|
169
373
|
ws: WorkspaceInfo,
|
|
170
374
|
platform: PlatformServer,
|