@arcote.tech/arc-cli 0.8.1 → 0.8.3

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,53 @@
1
+ import { randomBytes } from "crypto";
2
+ import { join } from "path";
3
+ import { initContextHandler } from "../platform/server";
4
+ import { err, loadServerContext, resolveWorkspace } from "../platform/shared";
5
+
6
+ /**
7
+ * `arc platform pairing-token` — generuje JEDNORAZOWY kod parowania federacji
8
+ * i zapisuje go do bazy (agregat `pairingCode`), po czym wypisuje go operatorowi.
9
+ *
10
+ * Kod podaje się w HOŚCIE przy instalacji aplikacji; host rejestruje nim swój
11
+ * klucz publiczny (komenda `federationHost.register`), a kod zostaje zużyty.
12
+ * Uruchamiane po SSH na serwerze aplikacji — czyta ten sam kontekst i DB co
13
+ * serwer (postgres przez `DATABASE_URL`, inaczej sqlite w `.arc/data`).
14
+ */
15
+ export async function platformPairingToken(): Promise<void> {
16
+ const ws = resolveWorkspace();
17
+ const { context } = await loadServerContext(ws);
18
+ if (!context) {
19
+ err("Brak zbudowanego kontekstu. Uruchom najpierw `arc platform build`.");
20
+ process.exit(1);
21
+ }
22
+
23
+ // TEN SAM plik/baza co serwer platformy (startup.ts): sqlite
24
+ // `.arc/data/<mode>.db` w dev/prod-sqlite, albo postgres przez DATABASE_URL
25
+ // (wtedy dbPath jest ignorowany). Bez tego kod trafiłby do innej bazy niż
26
+ // serwer i rejestracja by go nie znalazła.
27
+ const isProd = process.env.NODE_ENV === "production";
28
+ const dbPath = join(ws.rootDir, ".arc", "data", isProd ? "prod.db" : "dev.db");
29
+ const handler = await initContextHandler(context, dbPath);
30
+
31
+ // Kod = sekret pokazywany raz operatorowi. `_id` agregatu generuje handler.
32
+ const code = randomBytes(24).toString("base64url");
33
+ const res = (await handler.executeCommand(
34
+ "pairingCode.issue",
35
+ { code },
36
+ null,
37
+ { internal: true },
38
+ )) as { ok?: boolean; error?: string } | undefined;
39
+
40
+ if (!res?.ok) {
41
+ err(
42
+ `Nie udało się zapisać kodu parowania${res?.error ? `: ${res.error}` : ""}. ` +
43
+ "Czy aplikacja wystawia agregat `pairingCode` (federacja)?",
44
+ );
45
+ process.exit(1);
46
+ }
47
+
48
+ process.stdout.write(
49
+ `\n Kod parowania (jednorazowy):\n\n ${code}\n\n` +
50
+ " Podaj go w hoście przy instalacji aplikacji. Wygasa po pierwszym użyciu.\n\n",
51
+ );
52
+ process.exit(0);
53
+ }
@@ -61,6 +61,9 @@ export function generateCaddyfile(cfg: DeployConfig): string {
61
61
  for (const [name, env] of Object.entries(cfg.envs)) {
62
62
  lines.push(`${env.domain} {${tlsDirective}`);
63
63
  lines.push(...logDirective);
64
+ // Kompresja odpowiedzi (JS/CSS/JSON z hosta idą plaintext — host nie
65
+ // kompresuje, więc bez tej dyrektywy przeglądarka pobiera pełne bajty).
66
+ lines.push(" encode zstd gzip");
64
67
  if (observability) {
65
68
  lines.push(" handle_path /otel/* {");
66
69
  lines.push(" reverse_proxy otel-collector:4318");
package/src/index.ts CHANGED
@@ -6,6 +6,7 @@ import { dev } from "./commands/dev";
6
6
  import { platformBuild } from "./commands/platform-build";
7
7
  import { platformDeploy } from "./commands/platform-deploy";
8
8
  import { platformDev } from "./commands/platform-dev";
9
+ import { platformPairingToken } from "./commands/platform-pairing-token";
9
10
  import { platformStart } from "./commands/platform-start";
10
11
 
11
12
  // Create the program
@@ -51,8 +52,12 @@ platform
51
52
  .command("build")
52
53
  .description("Build platform for production")
53
54
  .option("--no-cache", "Force full rebuild")
54
- .action((opts: { cache?: boolean }) =>
55
- platformBuild({ noCache: opts.cache === false }),
55
+ .option(
56
+ "--stats",
57
+ "Wypisz rozbicie chunków na biblioteki (per-wejście raw/gzip + najcięższe liby, ext/wewn). Wymusza pełny build.",
58
+ )
59
+ .action((opts: { cache?: boolean; stats?: boolean }) =>
60
+ platformBuild({ noCache: opts.cache === false, stats: opts.stats }),
56
61
  );
57
62
 
58
63
  platform
@@ -60,6 +65,13 @@ platform
60
65
  .description("Start platform in production mode (requires prior build)")
61
66
  .action(platformStart);
62
67
 
68
+ platform
69
+ .command("pairing-token")
70
+ .description(
71
+ "Generate a one-time federation pairing code (writes to DB, prints it)",
72
+ )
73
+ .action(platformPairingToken);
74
+
63
75
  platform
64
76
  .command("deploy [env]")
65
77
  .description(
@@ -573,14 +573,12 @@ function handleDiscovery(
573
573
  if (!federation) {
574
574
  return new Response("Not Found", { status: 404, headers: ctx.corsHeaders });
575
575
  }
576
- const presented = req.headers.get("X-Arc-Pairing-Token");
577
- if (!presented || !safeEqual(presented, federation.registrationToken)) {
578
- return new Response("Unauthorized", {
579
- status: 401,
580
- headers: ctx.corsHeaders,
581
- });
582
- }
583
-
576
+ // Discovery jest PUBLICZNE (bez bramy tokenem). Zwraca wyłącznie metadane
577
+ // federacji (slug, moduły, uprawnienia, peers) — a te i tak wskazują na
578
+ // publiczne chunki `/browser-fed/`. Bramą PAROWANIA jest osobny JEDNORAZOWY
579
+ // kod sprawdzany przy rejestracji klucza (`federationHost.register`), nie ten
580
+ // odczyt. Host dzięki temu może pokazać ekran zgody (co instaluje), zanim
581
+ // operator poda kod.
584
582
  const fed = manifest.federation;
585
583
  // Adres własny: za proxy `req.url` jest wewnętrzny, więc pozwalamy nadpisać
586
584
  // przez env. Konsument i tak zna nas z adresu, którego użył — to pole jest
@@ -25,7 +25,11 @@ import {
25
25
  import { extractAccessMap } from "../builder/access-extractor";
26
26
  import { buildPeerShell, type PeerShellResult } from "../builder/peer-shell";
27
27
  import type { FederationConfig } from "@arcote.tech/platform";
28
- import { assertOneModulePerPackage, planChunks } from "../builder/chunk-planner";
28
+ import {
29
+ assertOneModulePerPackage,
30
+ packageDeclaresModule,
31
+ planChunks,
32
+ } from "../builder/chunk-planner";
29
33
  import { collectFrameworkDeps } from "../builder/dependency-collector";
30
34
  import {
31
35
  mtimeOf,
@@ -144,6 +148,11 @@ export function resolveWorkspace(): WorkspaceInfo {
144
148
 
145
149
  export interface BuildOptions {
146
150
  noCache?: boolean;
151
+ /**
152
+ * Minifikacja bundli przeglądarki (domyślnie true — build/deploy).
153
+ * Dev watcher podaje false: czytelny kod w devtools, szybszy rebuild.
154
+ */
155
+ minify?: boolean;
147
156
  }
148
157
 
149
158
  /**
@@ -157,6 +166,7 @@ export async function buildAll(
157
166
  ): Promise<BuildManifest> {
158
167
  const cache = loadBuildCache(ws.arcDir);
159
168
  const noCache = opts.noCache ?? false;
169
+ const minify = opts.minify ?? true;
160
170
  const themePath = ws.rootPkg.arc?.theme as string | undefined;
161
171
  const federation = getFederationConfig(ws);
162
172
 
@@ -201,13 +211,20 @@ export async function buildAll(
201
211
  const moduleNameOf = (name: string) => name.split("/").pop() ?? name;
202
212
  const exposureOf = (pkgName: string): "local" | "external" | "both" =>
203
213
  accessMap.exposure[moduleNameOf(pkgName)]?.exposure ?? "local";
214
+ // Tylko pakiety DEKLARUJĄCE moduł Arc są członami wchodzącymi do initial/grup.
215
+ // Czyste biblioteki (np. @ndt/email — szablony maili) NIE mogą być tu, bo
216
+ // initial robiłoby `import "@pkg"` i przy `sideEffects:true` (patch buildu)
217
+ // wciągałoby całe ich drzewo zależności do pierwszego wczytania. Taka libka
218
+ // trafia do bundla NORMALNIE — dopiero gdy realnie zaimportuje ją moduł
219
+ // (wtedy podlega tree-shakingowi / lazy-split).
220
+ const modulePackages = ws.packages.filter(packageDeclaresModule);
204
221
  // Normalna platforma: moduły local/both (external-only ukryte w macierzystej
205
222
  // platformie). Host bierze wszystko (nie ukrywa niczego przed sobą).
206
223
  const localPackages = isHost
207
- ? ws.packages
208
- : ws.packages.filter((p) => exposureOf(p.name) !== "external");
224
+ ? modulePackages
225
+ : modulePackages.filter((p) => exposureOf(p.name) !== "external");
209
226
  // Build federowany: moduły external/both → browser-fed/.
210
- const fedPackages = ws.packages.filter((p) =>
227
+ const fedPackages = modulePackages.filter((p) =>
211
228
  ["external", "both"].includes(exposureOf(p.name)),
212
229
  );
213
230
  const hasFederatedModules = fedPackages.length > 0;
@@ -238,31 +255,47 @@ export async function buildAll(
238
255
  // zawsze external peers) + peer-shell.
239
256
  const i18nCollector = new Map<string, Set<string>>();
240
257
 
241
- const [browserResult, , , , shellResult, fedResult] = await Promise.all([
258
+ // Dwa buildy przeglądarkowe (bundled + federated) biegną SZEREGOWO, nie w
259
+ // Promise.all: oba mutują TE SAME package.json (patch sideEffects na czas
260
+ // builda) — przy równoległym biegu `finally` szybszego builda przywraca
261
+ // oryginał, zanim wolniejszy skończy bundlować, i tree-shaking wolniejszego
262
+ // biegnie na złych metadanych. Koszt: +czas builda fed; zysk: deterministyka.
263
+ const browserSequence = (async () => {
242
264
  // HOST buduje się BUNDLED (React inline, eager — bez lazy-race), a initial
243
265
  // 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
- }),
248
- buildStyles(ws.rootDir, ws.arcDir, ws.packages, themePath, cache, noCache),
249
- copyBrowserAssets(ws, cache, noCache),
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(
266
+ const bundled = await buildBrowserApp(
267
+ ws.rootDir,
268
+ ws.browserDir,
269
+ plan,
270
+ cache,
271
+ noCache,
272
+ i18nCollector,
273
+ { federated: false, exposeRuntime: isHost, minify },
274
+ );
275
+ const fed = fedPlan
276
+ ? await buildBrowserApp(
256
277
  ws.rootDir,
257
278
  join(ws.arcDir, "browser-fed"),
258
279
  fedPlan,
259
280
  cache,
260
281
  noCache,
261
282
  i18nCollector,
262
- { federated: true, unitId: "browser-app-fed" },
283
+ { federated: true, unitId: "browser-app-fed", minify },
263
284
  )
264
- : Promise.resolve(undefined),
265
- ]);
285
+ : undefined;
286
+ return { bundled, fed };
287
+ })();
288
+
289
+ const [{ bundled: browserResult, fed: fedResult }, , , , shellResult] =
290
+ await Promise.all([
291
+ browserSequence,
292
+ buildStyles(ws.rootDir, ws.arcDir, ws.packages, themePath, cache, noCache),
293
+ copyBrowserAssets(ws, cache, noCache),
294
+ buildTranslations(ws.rootDir, ws.arcDir, cache, noCache),
295
+ needsPeerShell
296
+ ? buildPeerShell(ws.rootDir, join(ws.arcDir, "shell"), cache, noCache)
297
+ : Promise.resolve(undefined),
298
+ ]);
266
299
 
267
300
  // Finalize i18n catalogs once after all chunks + initial bundle collected.
268
301
  const { finalizeTranslations } = await import("../i18n");
@@ -493,7 +526,18 @@ async function copyBrowserAssets(
493
526
 
494
527
  export async function loadServerContext(
495
528
  ws: WorkspaceInfo,
496
- ): Promise<{ context: any | null; moduleAccess: Map<string, any> }> {
529
+ ): Promise<{
530
+ context: any | null;
531
+ moduleAccess: Map<string, any>;
532
+ /**
533
+ * Set gdy import bundla serwerowego RZUCIŁ (np. fail-fast na brakującym env).
534
+ * Kontekst jest wtedy CZĘŚCIOWY — część modułów się nie zarejestrowała, więc
535
+ * ich query rzucają `Element not found`. Caller uruchamiający serwer MUSI to
536
+ * potraktować jako twardy błąd startu (patrz startup.ts), inaczej aplikacja
537
+ * wstaje połowiczna i cicho. Build/access-extractor może to zignorować.
538
+ */
539
+ serverError?: unknown;
540
+ }> {
497
541
  // Set globals for server context — framework packages (arc-auth etc.)
498
542
  // use these at runtime to tree-shake browser/server code paths.
499
543
  (globalThis as any).ONLY_SERVER = true;
@@ -519,15 +563,18 @@ export async function loadServerContext(
519
563
  // No build yet (or a static-only workspace) — nothing to register.
520
564
  return { context: null, moduleAccess: new Map() };
521
565
  }
566
+ let serverError: unknown;
522
567
  try {
523
568
  await import(serverEntry);
524
569
  } catch (e) {
525
570
  err(`Failed to load server bundle ${SERVER_ENTRY_FILE}: ${e}`);
571
+ serverError = e;
526
572
  }
527
573
 
528
574
  const { getContext, getAllModuleAccess } = await import(platformEntry);
529
575
  return {
530
576
  context: getContext() ?? null,
531
577
  moduleAccess: getAllModuleAccess(),
578
+ serverError,
532
579
  };
533
580
  }
@@ -60,7 +60,7 @@ export async function startPlatform(
60
60
  // missing manifest is a hard error.
61
61
  let manifest: BuildManifest;
62
62
  if (devMode) {
63
- manifest = await buildAll(ws);
63
+ manifest = await buildAll(ws, { minify: false });
64
64
  } else {
65
65
  const manifestPath = join(ws.arcDir, "manifest.json");
66
66
  if (!existsSync(manifestPath)) {
@@ -88,7 +88,21 @@ export async function startPlatform(
88
88
  // imports the combined server bundle at .arc/platform/server/_server.js
89
89
  // (produced by the buildServerApp step in buildAll).
90
90
  log("Loading server context...");
91
- const { context, moduleAccess } = await loadServerContext(ws);
91
+ const { context, moduleAccess, serverError } = await loadServerContext(ws);
92
+ // TWARDY STOP: import bundla serwerowego rzucił (np. fail-fast na brakującym
93
+ // env). Kontekst jest wtedy CZĘŚCIOWY — część agregatów się nie
94
+ // zarejestrowała, więc ich query rzucałyby `Element not found`, a aplikacja
95
+ // wyglądałaby na działającą (login OK), tyle że połowiczną. Przerywamy start
96
+ // GŁOŚNO, zamiast wstawać po cichu — to była realna produkcyjna pułapka.
97
+ if (serverError) {
98
+ err(
99
+ "Serwer NIE wstanie: moduł serwerowy nie załadował się (błąd wyżej). " +
100
+ "Częściowy kontekst = połowiczna aplikacja (query rzucają " +
101
+ "'Element not found'). Napraw przyczynę (np. brakujące zmienne " +
102
+ "środowiskowe) i wystartuj ponownie.",
103
+ );
104
+ process.exit(1);
105
+ }
92
106
  if (context) {
93
107
  ok("Context loaded");
94
108
  } else {
@@ -232,37 +246,33 @@ async function startMapTool(
232
246
  }
233
247
 
234
248
  /**
235
- * PROD + federacja → sekrety MUSZĄ pochodzić z env. Twarda odmowa startu.
249
+ * PROD + federacja → token parowania MUSI pochodzić z env. Twarda odmowa startu.
236
250
  *
237
251
  * DLACZEGO tak ostro: `.arc/` nie trafia ani do obrazu (deploy kopiuje tylko
238
- * `.arc/platform/`), ani na wolumen (montowane jest tylko `.arc/data`). Plik
252
+ * `.arc/platform/`), ani na wolumen (montowane jest tylko `.arc/data`). Token
239
253
  * 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.
254
+ * `recreate`** a wtedy nowy token parowania unieważnia istniejące parowania,
255
+ * bez żadnego błędu przy starcie. Cicha awaria dzień po deployu jest gorsza niż
256
+ * głośna odmowa startu.
245
257
  *
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.
258
+ * `ARC_APP_SECRET` NIE jest już wymagany: tożsamość federacji jest asymetryczna
259
+ * (host podpisuje kluczem prywatnym, partner weryfikuje publicznym z rejestru w
260
+ * bazie — patrz app-identity.md), więc partner nie trzyma współdzielonego
261
+ * sekretu HMAC. Klucze publiczne żyją w projekcji (wolumen `.arc/data`), nie w
262
+ * ulotnym pliku.
248
263
  */
249
264
  function assertProdFederationSecrets(
250
265
  devMode: boolean,
251
266
  hasFederation: boolean,
252
267
  ): void {
253
268
  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;
269
+ if (process.env.ARC_PAIRING_TOKEN || process.env.ARC_MAP_TOKEN) return;
260
270
 
261
271
  err(
262
- `Federacja w produkcji wymaga zmiennych środowiskowych: ${missing.join(", ")}.\n` +
263
- ` Bez nich sekret i token 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).`,
272
+ `Federacja w produkcji wymaga zmiennej środowiskowej ARC_PAIRING_TOKEN.\n` +
273
+ ` Bez niej token parowania jest generowany w kontenerze i GINIE przy\n` +
274
+ ` pierwszym recreate — parowania hostów stają się martwe, bez błędu.\n` +
275
+ ` Ustaw w deployu (deploy.arc.json → envVars + deploy.arc.<env>.env).`,
266
276
  );
267
277
  process.exit(1);
268
278
  }
@@ -385,7 +395,7 @@ function attachDevWatcher(
385
395
  isRebuilding = true;
386
396
  log("Rebuilding...");
387
397
  try {
388
- const next = await buildAll(ws);
398
+ const next = await buildAll(ws, { minify: false });
389
399
  platform.setManifest(next);
390
400
  platform.notifyReload(next);
391
401
  onReload?.();