@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/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@arcote.tech/arc-cli",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.8.1",
|
|
4
4
|
"description": "CLI tool for Arc framework",
|
|
5
5
|
"module": "index.ts",
|
|
6
6
|
"main": "dist/index.js",
|
|
@@ -12,13 +12,13 @@
|
|
|
12
12
|
"build": "bun build --target=bun ./src/index.ts --outdir=dist --external @arcote.tech/arc --external @arcote.tech/arc-ds --external @arcote.tech/arc-react --external @arcote.tech/platform --external @arcote.tech/arc-map --external '@opentelemetry/*' && chmod +x dist/index.js"
|
|
13
13
|
},
|
|
14
14
|
"dependencies": {
|
|
15
|
-
"@arcote.tech/arc": "^0.
|
|
16
|
-
"@arcote.tech/arc-ds": "^0.
|
|
17
|
-
"@arcote.tech/arc-react": "^0.
|
|
18
|
-
"@arcote.tech/arc-host": "^0.
|
|
19
|
-
"@arcote.tech/arc-adapter-db-sqlite": "^0.
|
|
20
|
-
"@arcote.tech/arc-adapter-db-postgres": "^0.
|
|
21
|
-
"@arcote.tech/arc-otel": "^0.
|
|
15
|
+
"@arcote.tech/arc": "^0.8.1",
|
|
16
|
+
"@arcote.tech/arc-ds": "^0.8.1",
|
|
17
|
+
"@arcote.tech/arc-react": "^0.8.1",
|
|
18
|
+
"@arcote.tech/arc-host": "^0.8.1",
|
|
19
|
+
"@arcote.tech/arc-adapter-db-sqlite": "^0.8.1",
|
|
20
|
+
"@arcote.tech/arc-adapter-db-postgres": "^0.8.1",
|
|
21
|
+
"@arcote.tech/arc-otel": "^0.8.1",
|
|
22
22
|
"@opentelemetry/api": "^1.9.0",
|
|
23
23
|
"@opentelemetry/api-logs": "^0.57.0",
|
|
24
24
|
"@opentelemetry/core": "^1.30.0",
|
|
@@ -31,8 +31,8 @@
|
|
|
31
31
|
"@opentelemetry/sdk-trace-base": "^1.30.0",
|
|
32
32
|
"@opentelemetry/sdk-trace-node": "^1.30.0",
|
|
33
33
|
"@opentelemetry/semantic-conventions": "^1.27.0",
|
|
34
|
-
"@arcote.tech/platform": "^0.
|
|
35
|
-
"@arcote.tech/arc-map": "^0.
|
|
34
|
+
"@arcote.tech/platform": "^0.8.1",
|
|
35
|
+
"@arcote.tech/arc-map": "^0.8.1",
|
|
36
36
|
"@clack/prompts": "^0.9.0",
|
|
37
37
|
"commander": "^11.1.0",
|
|
38
38
|
"chokidar": "^3.5.3",
|
|
@@ -41,10 +41,25 @@ export interface SerializedModuleAccess {
|
|
|
41
41
|
|
|
42
42
|
export type SerializedAccessMap = Record<string, SerializedModuleAccess>;
|
|
43
43
|
|
|
44
|
+
/** Deklaracja federacji modułu (module().exposeToHosts) odczytana build-time. */
|
|
45
|
+
export interface SerializedModuleExposure {
|
|
46
|
+
exposure: "local" | "external" | "both";
|
|
47
|
+
permissions?: string[];
|
|
48
|
+
slots?: string[];
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export type SerializedExposureMap = Record<string, SerializedModuleExposure>;
|
|
52
|
+
|
|
53
|
+
export interface ExtractedModuleMeta {
|
|
54
|
+
access: SerializedAccessMap;
|
|
55
|
+
/** Moduły z exposeToHosts() — sterują buildem federowanym per moduł. */
|
|
56
|
+
exposure: SerializedExposureMap;
|
|
57
|
+
}
|
|
58
|
+
|
|
44
59
|
export async function extractAccessMap(
|
|
45
60
|
rootDir: string,
|
|
46
61
|
serverBundlePath: string,
|
|
47
|
-
): Promise<
|
|
62
|
+
): Promise<ExtractedModuleMeta> {
|
|
48
63
|
// The combined server bundle (`<arcDir>/server/_server.js`) side-effect-
|
|
49
64
|
// registers every module via the platform singleton when imported. The
|
|
50
65
|
// worker imports just this entry; Bun resolves its `chunk-<hash>.js` siblings
|
|
@@ -80,7 +95,7 @@ export async function extractAccessMap(
|
|
|
80
95
|
if (exit !== 0) {
|
|
81
96
|
throw new Error(`access-extractor subprocess exited with ${exit}`);
|
|
82
97
|
}
|
|
83
|
-
return JSON.parse(readFileSync(outPath, "utf-8")) as
|
|
98
|
+
return JSON.parse(readFileSync(outPath, "utf-8")) as ExtractedModuleMeta;
|
|
84
99
|
} finally {
|
|
85
100
|
try {
|
|
86
101
|
unlinkSync(workerPath);
|
|
@@ -126,16 +141,28 @@ for (const { name, path } of bundles) {
|
|
|
126
141
|
}
|
|
127
142
|
}
|
|
128
143
|
|
|
129
|
-
const
|
|
130
|
-
for (const [name,
|
|
131
|
-
|
|
132
|
-
rules: (
|
|
144
|
+
const access = {};
|
|
145
|
+
for (const [name, a] of platform.getAllModuleAccess()) {
|
|
146
|
+
access[name] = {
|
|
147
|
+
rules: (a.rules ?? []).map((r) => ({
|
|
133
148
|
token: { name: r.token?.name ?? "" },
|
|
134
149
|
hasCheck: typeof r.check === "function",
|
|
135
150
|
})),
|
|
136
151
|
};
|
|
137
152
|
}
|
|
138
153
|
|
|
154
|
+
const exposure = {};
|
|
155
|
+
const getExposure = platform.getAllModuleExposure;
|
|
156
|
+
if (typeof getExposure === "function") {
|
|
157
|
+
for (const [name, e] of getExposure()) {
|
|
158
|
+
exposure[name] = {
|
|
159
|
+
exposure: e.exposure,
|
|
160
|
+
permissions: e.permissions ?? [],
|
|
161
|
+
slots: e.slots ?? [],
|
|
162
|
+
};
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
139
166
|
const { writeFileSync } = await import("node:fs");
|
|
140
|
-
writeFileSync(out, JSON.stringify(
|
|
167
|
+
writeFileSync(out, JSON.stringify({ access, exposure }, null, 2) + "\\n");
|
|
141
168
|
`.trim();
|
|
@@ -64,7 +64,13 @@ export const FRAMEWORK_PEERS = [...ARC_PEERS, ...REACT_PEERS] as const;
|
|
|
64
64
|
export const SHELL_EXTERNALS = [
|
|
65
65
|
...FRAMEWORK_PEERS,
|
|
66
66
|
"react/jsx-runtime",
|
|
67
|
-
|
|
67
|
+
// `react/jsx-dev-runtime` CELOWO NIE jest external — jsxDevShimPlugin
|
|
68
|
+
// przepisuje go na eager re-export jsx-runtime (jsxDEV=jsx). Robienie go
|
|
69
|
+
// external + peer-shell wprowadzałoby lazy binding.
|
|
70
|
+
// Subpath react-dom — startApp robi `import { createRoot } from
|
|
71
|
+
// "react-dom/client"`; goły "react-dom" w external nie pokrywa subpathu
|
|
72
|
+
// w import mapie (brak trailing-slash mapowania), więc musi być jawnie.
|
|
73
|
+
"react-dom/client",
|
|
68
74
|
] as const;
|
|
69
75
|
|
|
70
76
|
export const FRAMEWORK_PEER_SET = new Set<string>(FRAMEWORK_PEERS);
|
|
@@ -24,6 +24,7 @@ import {
|
|
|
24
24
|
FRAMEWORK_PEERS,
|
|
25
25
|
FRAMEWORK_PEER_SET,
|
|
26
26
|
} from "./framework-peers";
|
|
27
|
+
import { runtimeExposePrelude } from "./peer-shell";
|
|
27
28
|
import {
|
|
28
29
|
readInstalledVersion,
|
|
29
30
|
sha256Hex,
|
|
@@ -247,11 +248,12 @@ function jsxDevShimPlugin(): import("bun").BunPlugin {
|
|
|
247
248
|
namespace: "jsx-dev-shim",
|
|
248
249
|
}));
|
|
249
250
|
build.onLoad({ filter: /.*/, namespace: "jsx-dev-shim" }, () => ({
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
export
|
|
254
|
-
|
|
251
|
+
// RE-EXPORT (nie `const jsxDEV = jsx`): przy external jsx-runtime
|
|
252
|
+
// (build federowany) computed-const trafia do lazy `__esm` wrappera
|
|
253
|
+
// Bun, którego init nie odpala przed użyciem jsxDEV w top-level
|
|
254
|
+
// literałach (jsxDEV undefined). Czysty re-export jest eager — binding
|
|
255
|
+
// jsxDEV istnieje od razu. Działa identycznie w bundled.
|
|
256
|
+
contents: `export { jsx as jsxDEV, jsxs as jsxsDEV, Fragment } from "react/jsx-runtime";`,
|
|
255
257
|
loader: "ts",
|
|
256
258
|
}));
|
|
257
259
|
},
|
|
@@ -792,7 +794,26 @@ export async function buildBrowserApp(
|
|
|
792
794
|
cache: BuildCache,
|
|
793
795
|
noCache: boolean,
|
|
794
796
|
i18nCollector: Map<string, Set<string>>,
|
|
797
|
+
opts?: {
|
|
798
|
+
/**
|
|
799
|
+
* Build FEDEROWALNY (spec/module-federation.md): framework peers
|
|
800
|
+
* (react, @arcote.tech/*) zostają external i rozwiązuje je import mapa
|
|
801
|
+
* shella (peer-shell). Chunki takiej aplikacji może importować host
|
|
802
|
+
* z innego originu — dostają JEGO singletony Reacta/rejestru.
|
|
803
|
+
*/
|
|
804
|
+
federated?: boolean;
|
|
805
|
+
/** Cache unit id — inny dla drugiego (federowanego) buildu w tym samym workspace. */
|
|
806
|
+
unitId?: string;
|
|
807
|
+
/**
|
|
808
|
+
* HOST federacji: initial eksponuje bundled runtime na globalThis
|
|
809
|
+
* (peer-shell gościa go czyta). Build pozostaje BUNDLED (federated:false)
|
|
810
|
+
* — React inline, bez lazy-race. Patrz peer-shell.ts.
|
|
811
|
+
*/
|
|
812
|
+
exposeRuntime?: boolean;
|
|
813
|
+
},
|
|
795
814
|
): Promise<BrowserAppResult> {
|
|
815
|
+
const federated = opts?.federated ?? false;
|
|
816
|
+
const exposeRuntime = opts?.exposeRuntime ?? false;
|
|
796
817
|
mkdirSync(outDir, { recursive: true });
|
|
797
818
|
|
|
798
819
|
const publicMembers = plan.groups.get("public") ?? [];
|
|
@@ -801,7 +822,7 @@ export async function buildBrowserApp(
|
|
|
801
822
|
.map((c) => ({ name: c, members: plan.groups.get(c) ?? [] }))
|
|
802
823
|
.filter((g) => g.members.length > 0);
|
|
803
824
|
|
|
804
|
-
const unitId = "browser-app";
|
|
825
|
+
const unitId = opts?.unitId ?? "browser-app";
|
|
805
826
|
|
|
806
827
|
// Cache key spans every package's source plus the build config that
|
|
807
828
|
// matters. If anything changes, full rebuild.
|
|
@@ -821,6 +842,14 @@ export async function buildBrowserApp(
|
|
|
821
842
|
...protectedGroups.map((g) => g.name).sort(),
|
|
822
843
|
],
|
|
823
844
|
define: { ONLY_SERVER: "false", ONLY_BROWSER: "true", ONLY_CLIENT: "true" },
|
|
845
|
+
federated,
|
|
846
|
+
exposeRuntime,
|
|
847
|
+
// Zmiana listy peers (SHELL_EXTERNALS) musi unieważnić build federowany —
|
|
848
|
+
// stary bundle miałby zbundlowany peer, który teraz jest external.
|
|
849
|
+
externals: federated ? [...SHELL_EXTERNALS] : [],
|
|
850
|
+
// Podbij przy zmianie SEMANTYKI builda (pluginy/flagi), której nie widać
|
|
851
|
+
// w źródłach — inaczej cache odda bundle zbudowany starą logiką.
|
|
852
|
+
builderRev: 3,
|
|
824
853
|
});
|
|
825
854
|
|
|
826
855
|
if (!noCache && isCacheHit(cache, unitId, inputHash)) {
|
|
@@ -865,7 +894,10 @@ export async function buildBrowserApp(
|
|
|
865
894
|
const initialEntry = join(tmpDir, "initial.ts");
|
|
866
895
|
writeFileSync(
|
|
867
896
|
initialEntry,
|
|
868
|
-
|
|
897
|
+
// Prelude ekspozycji runtime MUSI być PIERWSZY (przed importami modułów) —
|
|
898
|
+
// globalThis.__ARC_RT gotowe zanim cokolwiek innego się załaduje.
|
|
899
|
+
(exposeRuntime ? runtimeExposePrelude() + "\n" : "") +
|
|
900
|
+
`${importLines(publicMembers)}\nexport { startApp } from "@arcote.tech/platform";\n`,
|
|
869
901
|
);
|
|
870
902
|
|
|
871
903
|
const entryPaths: string[] = [initialEntry];
|
|
@@ -920,11 +952,17 @@ export async function buildBrowserApp(
|
|
|
920
952
|
splitting: true,
|
|
921
953
|
format: "esm",
|
|
922
954
|
target: "browser",
|
|
923
|
-
//
|
|
924
|
-
//
|
|
925
|
-
|
|
955
|
+
// Bundled (default): no externals — framework peers trafiają do shared
|
|
956
|
+
// chunks, HTML bez importmapy. Federated: peers zostają external i
|
|
957
|
+
// rozwiązuje je import mapa shella (peer-shell) — warunek współdzielenia
|
|
958
|
+
// singletonów React/rejestru z hostem federacji.
|
|
959
|
+
external: federated ? [...SHELL_EXTERNALS] : [],
|
|
926
960
|
plugins: [
|
|
927
|
-
singleReactPlugin(
|
|
961
|
+
// Federated: singleReactPlugin MUSI zniknąć (jego onResolve wygrałby z
|
|
962
|
+
// `external` → React zbundlowany; singleton daje import mapa). Ale
|
|
963
|
+
// jsxDevShimPlugin ZOSTAJE (Bun i tak emituje jsxDEV) — jego shim jest
|
|
964
|
+
// teraz eager re-exportem, więc działa też przy external jsx-runtime.
|
|
965
|
+
...(federated ? [] : [singleReactPlugin(rootDir)]),
|
|
928
966
|
jsxDevShimPlugin(),
|
|
929
967
|
i18nExtractPlugin(i18nCollector, rootDir),
|
|
930
968
|
],
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Peer-shell — współdzielony runtime dla federacji modułów
|
|
3
|
+
* (spec/module-federation.md).
|
|
4
|
+
*
|
|
5
|
+
* MODEL globalThis (a nie bundlowanie Reacta w peer-shell):
|
|
6
|
+
* - HOST buduje się NORMALNIE (bundled — React inline, eager, bez lazy
|
|
7
|
+
* __esm race w cyklicznych modułach) i w prelude initiala eksponuje swój
|
|
8
|
+
* runtime: `globalThis.__ARC_RT[specifier] = <bundled module>`.
|
|
9
|
+
* - Peer-shell emituje per specifier trywialny bundel, który RE-EKSPORTUJE
|
|
10
|
+
* z `globalThis.__ARC_RT[specifier]` (eager — globalThis ustawia host
|
|
11
|
+
* initial, zanim gość się załaduje). Import mapa w shell HTML kieruje gołe
|
|
12
|
+
* specifiery gościa na te bundle.
|
|
13
|
+
*
|
|
14
|
+
* Dzięki temu chunk gościa (external peers) dostaje TEN SAM egzemplarz Reacta
|
|
15
|
+
* i rejestru platformy co host, a HOST nie płaci lazy-race (nie ma external
|
|
16
|
+
* Reacta — patrz historia buga „jsxDEV/jsx is not a function").
|
|
17
|
+
*
|
|
18
|
+
* Named exports wyliczamy BUILD-TIME (`import(specifier)` → Object.keys),
|
|
19
|
+
* bo `export * from globalThis` nie istnieje, a lista named React/@arcote/*
|
|
20
|
+
* jest za duża i zmienna, by wpisać ją ręcznie.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "fs";
|
|
24
|
+
import { basename, join } from "path";
|
|
25
|
+
import { SHELL_EXTERNALS, FRAMEWORK_PEERS, shortNameOf } from "./framework-peers";
|
|
26
|
+
import { isCacheHit, updateCache, type BuildCache } from "./build-cache";
|
|
27
|
+
import { sha256Hex, sha256OfJson } from "./hash";
|
|
28
|
+
|
|
29
|
+
/** Globalny slot runtime hosta — czytany przez peer-shell gościa. */
|
|
30
|
+
export const RUNTIME_GLOBAL = "__ARC_RT";
|
|
31
|
+
|
|
32
|
+
/** Wynik builda peer-shella — trafia do BuildManifest.shell. */
|
|
33
|
+
export interface PeerShellResult {
|
|
34
|
+
/** specifier → ścieżka względna (`/shell/<short>.<hash>.js`). */
|
|
35
|
+
readonly imports: Record<string, string>;
|
|
36
|
+
/** Wersje peers (z zainstalowanych package.json) — polityka kompatybilności. */
|
|
37
|
+
readonly peers: Record<string, string>;
|
|
38
|
+
readonly cached: boolean;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function peerVersion(rootDir: string, pkg: string): string {
|
|
42
|
+
try {
|
|
43
|
+
const p = JSON.parse(
|
|
44
|
+
readFileSync(join(rootDir, "node_modules", pkg, "package.json"), "utf-8"),
|
|
45
|
+
) as { version?: string };
|
|
46
|
+
return p.version ?? "0.0.0";
|
|
47
|
+
} catch {
|
|
48
|
+
return "0.0.0";
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** Named exports specifiera (build-time introspekcja). */
|
|
53
|
+
async function namedExportsOf(specifier: string): Promise<string[]> {
|
|
54
|
+
try {
|
|
55
|
+
const mod = (await import(specifier)) as Record<string, unknown>;
|
|
56
|
+
return Object.keys(mod).filter((k) => k !== "default" && /^[A-Za-z_$][\w$]*$/.test(k));
|
|
57
|
+
} catch {
|
|
58
|
+
return [];
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** Kod preludu ekspozycji runtime hosta — wstrzykiwany do initiala hosta. */
|
|
63
|
+
export function runtimeExposePrelude(): string {
|
|
64
|
+
const lines = SHELL_EXTERNALS.map(
|
|
65
|
+
(spec, i) => `import * as __rt${i} from ${JSON.stringify(spec)};`,
|
|
66
|
+
);
|
|
67
|
+
const assigns = SHELL_EXTERNALS.map(
|
|
68
|
+
(spec, i) =>
|
|
69
|
+
` ${JSON.stringify(spec)}: (__rt${i}.default ?? __rt${i}),\n` +
|
|
70
|
+
` ${JSON.stringify(spec + "#ns")}: __rt${i},`,
|
|
71
|
+
);
|
|
72
|
+
return (
|
|
73
|
+
lines.join("\n") +
|
|
74
|
+
`\nglobalThis.${RUNTIME_GLOBAL} = Object.assign(globalThis.${RUNTIME_GLOBAL} ?? {}, {\n` +
|
|
75
|
+
assigns.join("\n") +
|
|
76
|
+
`\n});\n`
|
|
77
|
+
);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export async function buildPeerShell(
|
|
81
|
+
rootDir: string,
|
|
82
|
+
outDir: string,
|
|
83
|
+
cache: BuildCache,
|
|
84
|
+
noCache: boolean,
|
|
85
|
+
): Promise<PeerShellResult> {
|
|
86
|
+
mkdirSync(outDir, { recursive: true });
|
|
87
|
+
|
|
88
|
+
const peers: Record<string, string> = {};
|
|
89
|
+
for (const pkg of FRAMEWORK_PEERS) peers[pkg] = peerVersion(rootDir, pkg);
|
|
90
|
+
|
|
91
|
+
// Named exports per specifier wchodzą do inputHash — zmiana wersji peera z
|
|
92
|
+
// nowym API unieważnia peer-shell.
|
|
93
|
+
const namedBySpec: Record<string, string[]> = {};
|
|
94
|
+
for (const spec of SHELL_EXTERNALS) namedBySpec[spec] = await namedExportsOf(spec);
|
|
95
|
+
|
|
96
|
+
const unitId = "peer-shell";
|
|
97
|
+
const inputHash = sha256OfJson({
|
|
98
|
+
peers,
|
|
99
|
+
externals: SHELL_EXTERNALS,
|
|
100
|
+
named: namedBySpec,
|
|
101
|
+
mode: "globalThis",
|
|
102
|
+
rev: 5,
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
if (!noCache && isCacheHit(cache, unitId, inputHash)) {
|
|
106
|
+
const cached = cache.units[unitId]?.outputHashes;
|
|
107
|
+
if (cached?._manifest) {
|
|
108
|
+
try {
|
|
109
|
+
const m = JSON.parse(cached._manifest) as PeerShellResult;
|
|
110
|
+
const files = Object.values(m.imports).map((p) => basename(p));
|
|
111
|
+
if (files.every((f) => existsSync(join(outDir, f)))) {
|
|
112
|
+
console.log(` ✓ cached: ${unitId}`);
|
|
113
|
+
return { ...m, cached: true };
|
|
114
|
+
}
|
|
115
|
+
} catch {
|
|
116
|
+
// fall through to rebuild
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
console.log(` building: ${unitId} (${SHELL_EXTERNALS.length} peers, globalThis)`);
|
|
122
|
+
|
|
123
|
+
const imports: Record<string, string> = {};
|
|
124
|
+
for (const specifier of SHELL_EXTERNALS) {
|
|
125
|
+
const short = shortNameOf(specifier).replace(/\//g, "-");
|
|
126
|
+
const named = namedBySpec[specifier] ?? [];
|
|
127
|
+
// Trywialny bundel czytający z globalThis — eager, żadnego CJS/lazy.
|
|
128
|
+
// `#ns` niesie pełny namespace (dla konsumentów `import *`).
|
|
129
|
+
const src =
|
|
130
|
+
`const M = globalThis.${RUNTIME_GLOBAL}[${JSON.stringify(specifier)}];\n` +
|
|
131
|
+
`const N = globalThis.${RUNTIME_GLOBAL}[${JSON.stringify(specifier + "#ns")}] ?? M;\n` +
|
|
132
|
+
`export default M;\n` +
|
|
133
|
+
named.map((n) => `export const ${n} = N[${JSON.stringify(n)}];`).join("\n") +
|
|
134
|
+
"\n";
|
|
135
|
+
const hash = sha256Hex(src).slice(0, 16);
|
|
136
|
+
const finalName = `${short}.${hash}.js`;
|
|
137
|
+
writeFileSync(join(outDir, finalName), src);
|
|
138
|
+
imports[specifier] = `/shell/${finalName}`;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
const manifest: PeerShellResult = { imports, peers, cached: false };
|
|
142
|
+
updateCache(cache, unitId, inputHash, {
|
|
143
|
+
outputHashes: { _manifest: JSON.stringify(manifest) },
|
|
144
|
+
});
|
|
145
|
+
return manifest;
|
|
146
|
+
}
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { randomBytes } from "crypto";
|
|
1
2
|
import { existsSync, readFileSync } from "fs";
|
|
2
3
|
import { dirname, join } from "path";
|
|
3
4
|
import { fileURLToPath } from "url";
|
|
@@ -61,6 +62,19 @@ interface PlatformDeployOptions {
|
|
|
61
62
|
// 7. For each env: updateEnvDeployment (atomic .env line + pull + up + health)
|
|
62
63
|
// ---------------------------------------------------------------------------
|
|
63
64
|
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Czy aplikacja jest PARTNEREM federacji (wystawia moduły hostom).
|
|
68
|
+
*
|
|
69
|
+
* Czytamy deklarację z `package.json` (`arc.federation.slug`), a nie z manifestu
|
|
70
|
+
* builda — na tym etapie build jeszcze nie ruszył, a sekrety muszą istnieć,
|
|
71
|
+
* zanim compose zostanie wyrenderowany i wysłany. Host federacji
|
|
72
|
+
* (`arc.federation.host`) ich nie potrzebuje.
|
|
73
|
+
*/
|
|
74
|
+
function federationExposesModules(ws: { rootPkg: Record<string, any> }): boolean {
|
|
75
|
+
return Boolean(ws.rootPkg?.arc?.federation?.slug);
|
|
76
|
+
}
|
|
77
|
+
|
|
64
78
|
export async function platformDeploy(
|
|
65
79
|
envArg: string | undefined,
|
|
66
80
|
options: PlatformDeployOptions = {},
|
|
@@ -71,7 +85,7 @@ export async function platformDeploy(
|
|
|
71
85
|
let cfg;
|
|
72
86
|
if (!deployConfigExists(ws.rootDir)) {
|
|
73
87
|
log("No deploy.arc.json found — launching survey.");
|
|
74
|
-
cfg = await runSurvey();
|
|
88
|
+
cfg = await runSurvey(ws.appName);
|
|
75
89
|
saveDeployConfig(ws.rootDir, cfg);
|
|
76
90
|
ok("Saved deploy.arc.json");
|
|
77
91
|
}
|
|
@@ -88,6 +102,33 @@ export async function platformDeploy(
|
|
|
88
102
|
);
|
|
89
103
|
}
|
|
90
104
|
|
|
105
|
+
// Federacja: aplikacja wystawiająca moduły potrzebuje w prod DWÓCH stałych
|
|
106
|
+
// sekretów — inaczej `arc platform start` odmówi startu (startup.ts).
|
|
107
|
+
// Generujemy je tak jak hasło Postgresa: raz, do `deploy.arc.<env>.env`
|
|
108
|
+
// (gitignored), i odtąd każdy deploy używa tych samych.
|
|
109
|
+
//
|
|
110
|
+
// MUSZĄ być stałe: `.arc/` nie trafia ani do obrazu, ani na wolumen, więc
|
|
111
|
+
// sekret wygenerowany w kontenerze ginie przy pierwszym recreate — a wtedy
|
|
112
|
+
// hosty trzymające stary sekret przestają weryfikować asercje, CICHO.
|
|
113
|
+
if (federationExposesModules(ws)) {
|
|
114
|
+
for (const name of Object.keys(cfg.envs)) {
|
|
115
|
+
ensurePersistedSecret(ws.rootDir, name, "ARC_APP_SECRET", () =>
|
|
116
|
+
randomBytes(32).toString("base64url"),
|
|
117
|
+
);
|
|
118
|
+
ensurePersistedSecret(ws.rootDir, name, "ARC_PAIRING_TOKEN", () =>
|
|
119
|
+
randomBytes(16).toString("base64url"),
|
|
120
|
+
);
|
|
121
|
+
}
|
|
122
|
+
// Przeładowanie jest KONIECZNE: `loadDeployConfig` wczytał sidecar i scalił
|
|
123
|
+
// go do `cfg.envs[].envVars` ZANIM powyższe dopisało do pliku, a compose
|
|
124
|
+
// renderuje `environment` właśnie z `envVars`. Bez tego kontener startuje
|
|
125
|
+
// bez sekretów i pada na fail-fascie — przy czym DRUGI deploy by przeszedł
|
|
126
|
+
// (sidecar ma już klucze), co daje błąd zależny od historii, najgorszy do
|
|
127
|
+
// zdiagnozowania. Inaczej niż hasło Postgresa, które trafia do compose
|
|
128
|
+
// przez `${ARC_PG_PASSWORD_*}` z `/opt/arc/.env`, nie przez `envVars`.
|
|
129
|
+
cfg = loadDeployConfig(ws.rootDir);
|
|
130
|
+
}
|
|
131
|
+
|
|
91
132
|
// Observability stack — one Grafana password per deployment (stack-wide,
|
|
92
133
|
// not per-env). Persisted in `deploy.arc.env` (globals) so subsequent
|
|
93
134
|
// deploys reuse the same login.
|
|
@@ -3,11 +3,16 @@ import { resolveWorkspace } from "../platform/shared";
|
|
|
3
3
|
|
|
4
4
|
/** `arc platform dev` — dev mode (watcher + SSE reload + no-cache headers). */
|
|
5
5
|
export async function platformDev(
|
|
6
|
-
opts: { noCache?: boolean; map?: boolean } = {},
|
|
6
|
+
opts: { noCache?: boolean; map?: boolean; headless?: boolean } = {},
|
|
7
7
|
): Promise<void> {
|
|
8
8
|
const ws = resolveWorkspace();
|
|
9
9
|
// noCache is consumed by buildAll inside startPlatform when devMode=true.
|
|
10
10
|
// For now the dev startup always uses the cache after the first build;
|
|
11
11
|
// explicit --no-cache here only matters if we wire it through later.
|
|
12
|
-
await startPlatform({
|
|
12
|
+
await startPlatform({
|
|
13
|
+
ws,
|
|
14
|
+
devMode: true,
|
|
15
|
+
map: opts.map,
|
|
16
|
+
headless: opts.headless,
|
|
17
|
+
});
|
|
13
18
|
}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
terraform {
|
|
2
|
+
required_version = ">= 1.1"
|
|
2
3
|
required_providers {
|
|
3
4
|
hcloud = {
|
|
4
5
|
source = "hetznercloud/hcloud"
|
|
@@ -11,17 +12,50 @@ provider "hcloud" {
|
|
|
11
12
|
token = var.hcloud_token
|
|
12
13
|
}
|
|
13
14
|
|
|
15
|
+
locals {
|
|
16
|
+
# cloud-init injects the operator public key into the default image user's
|
|
17
|
+
# authorized_keys (root on Hetzner Ubuntu images) — matching the root-first
|
|
18
|
+
# ansible bootstrap. Used only when use_cloud_init_key = true.
|
|
19
|
+
cloud_init_user_data = <<-EOT
|
|
20
|
+
#cloud-config
|
|
21
|
+
ssh_pwauth: false
|
|
22
|
+
chpasswd:
|
|
23
|
+
expire: false
|
|
24
|
+
ssh_authorized_keys:
|
|
25
|
+
- ${trimspace(file(var.ssh_public_key))}
|
|
26
|
+
# Hetzner marks the root password expired (must-change-on-login) when no
|
|
27
|
+
# hcloud_ssh_key object is attached — which blocks non-interactive key
|
|
28
|
+
# login ("password change required, no TTY"). Clear the expiry so the
|
|
29
|
+
# deploy user / ansible can SSH in as root right away.
|
|
30
|
+
runcmd:
|
|
31
|
+
- chage -d $(date +%Y-%m-%d) -M -1 root
|
|
32
|
+
EOT
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
# Legacy path: a managed SSH key object in the Hetzner project. Skipped when
|
|
36
|
+
# use_cloud_init_key = true, because several apps sharing one project with the
|
|
37
|
+
# same local public key would collide on both key NAME and FINGERPRINT.
|
|
14
38
|
resource "hcloud_ssh_key" "deploy" {
|
|
39
|
+
count = var.use_cloud_init_key ? 0 : 1
|
|
15
40
|
name = "${var.server_name}-deploy"
|
|
16
41
|
public_key = file(var.ssh_public_key)
|
|
17
42
|
}
|
|
18
43
|
|
|
44
|
+
# Re-home the pre-count address so an already-provisioned host (state created
|
|
45
|
+
# before count existed) plans as a no-op instead of destroy+recreate — which
|
|
46
|
+
# would ForceNew-replace the server. Ignored for fresh (empty) state.
|
|
47
|
+
moved {
|
|
48
|
+
from = hcloud_ssh_key.deploy
|
|
49
|
+
to = hcloud_ssh_key.deploy[0]
|
|
50
|
+
}
|
|
51
|
+
|
|
19
52
|
resource "hcloud_server" "arc" {
|
|
20
53
|
name = var.server_name
|
|
21
54
|
image = var.server_image
|
|
22
55
|
server_type = var.server_type
|
|
23
56
|
location = var.server_location
|
|
24
|
-
ssh_keys = [hcloud_ssh_key.deploy.id]
|
|
57
|
+
ssh_keys = var.use_cloud_init_key ? [] : [hcloud_ssh_key.deploy[0].id]
|
|
58
|
+
user_data = var.use_cloud_init_key ? local.cloud_init_user_data : null
|
|
25
59
|
|
|
26
60
|
public_net {
|
|
27
61
|
ipv4_enabled = true
|
|
@@ -33,3 +33,9 @@ variable "ssh_public_key" {
|
|
|
33
33
|
type = string
|
|
34
34
|
default = "~/.ssh/id_ed25519.pub"
|
|
35
35
|
}
|
|
36
|
+
|
|
37
|
+
variable "use_cloud_init_key" {
|
|
38
|
+
description = "Inject the SSH public key via cloud-init user_data instead of a managed hcloud_ssh_key object (avoids name/fingerprint collisions when multiple apps share one Hetzner project)."
|
|
39
|
+
type = bool
|
|
40
|
+
default = false
|
|
41
|
+
}
|
package/src/deploy/assets.ts
CHANGED
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
// - Ansible/Jinja variables use {{ var }} which is safe in template literals.
|
|
10
10
|
|
|
11
11
|
const TERRAFORM_MAIN_TF = `terraform {
|
|
12
|
+
required_version = ">= 1.1"
|
|
12
13
|
required_providers {
|
|
13
14
|
hcloud = {
|
|
14
15
|
source = "hetznercloud/hcloud"
|
|
@@ -21,17 +22,50 @@ provider "hcloud" {
|
|
|
21
22
|
token = var.hcloud_token
|
|
22
23
|
}
|
|
23
24
|
|
|
25
|
+
locals {
|
|
26
|
+
# cloud-init injects the operator public key into the default image user's
|
|
27
|
+
# authorized_keys (root on Hetzner Ubuntu images) — matching the root-first
|
|
28
|
+
# ansible bootstrap. Used only when use_cloud_init_key = true.
|
|
29
|
+
cloud_init_user_data = <<-EOT
|
|
30
|
+
#cloud-config
|
|
31
|
+
ssh_pwauth: false
|
|
32
|
+
chpasswd:
|
|
33
|
+
expire: false
|
|
34
|
+
ssh_authorized_keys:
|
|
35
|
+
- \${trimspace(file(var.ssh_public_key))}
|
|
36
|
+
# Hetzner marks the root password expired (must-change-on-login) when no
|
|
37
|
+
# hcloud_ssh_key object is attached — which blocks non-interactive key
|
|
38
|
+
# login ("password change required, no TTY"). Clear the expiry so the
|
|
39
|
+
# deploy user / ansible can SSH in as root right away.
|
|
40
|
+
runcmd:
|
|
41
|
+
- chage -d $(date +%Y-%m-%d) -M -1 root
|
|
42
|
+
EOT
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
# Legacy path: a managed SSH key object in the Hetzner project. Skipped when
|
|
46
|
+
# use_cloud_init_key = true, because several apps sharing one project with the
|
|
47
|
+
# same local public key would collide on both key NAME and FINGERPRINT.
|
|
24
48
|
resource "hcloud_ssh_key" "deploy" {
|
|
49
|
+
count = var.use_cloud_init_key ? 0 : 1
|
|
25
50
|
name = "\${var.server_name}-deploy"
|
|
26
51
|
public_key = file(var.ssh_public_key)
|
|
27
52
|
}
|
|
28
53
|
|
|
54
|
+
# Re-home the pre-count address so an already-provisioned host (state created
|
|
55
|
+
# before count existed) plans as a no-op instead of destroy+recreate — which
|
|
56
|
+
# would ForceNew-replace the server. Ignored for fresh (empty) state.
|
|
57
|
+
moved {
|
|
58
|
+
from = hcloud_ssh_key.deploy
|
|
59
|
+
to = hcloud_ssh_key.deploy[0]
|
|
60
|
+
}
|
|
61
|
+
|
|
29
62
|
resource "hcloud_server" "arc" {
|
|
30
63
|
name = var.server_name
|
|
31
64
|
image = var.server_image
|
|
32
65
|
server_type = var.server_type
|
|
33
66
|
location = var.server_location
|
|
34
|
-
ssh_keys = [hcloud_ssh_key.deploy.id]
|
|
67
|
+
ssh_keys = var.use_cloud_init_key ? [] : [hcloud_ssh_key.deploy[0].id]
|
|
68
|
+
user_data = var.use_cloud_init_key ? local.cloud_init_user_data : null
|
|
35
69
|
|
|
36
70
|
public_net {
|
|
37
71
|
ipv4_enabled = true
|
|
@@ -83,6 +117,12 @@ variable "ssh_public_key" {
|
|
|
83
117
|
type = string
|
|
84
118
|
default = "~/.ssh/id_ed25519.pub"
|
|
85
119
|
}
|
|
120
|
+
|
|
121
|
+
variable "use_cloud_init_key" {
|
|
122
|
+
description = "Inject the SSH public key via cloud-init user_data instead of a managed hcloud_ssh_key object (avoids name/fingerprint collisions when multiple apps share one Hetzner project)."
|
|
123
|
+
type = bool
|
|
124
|
+
default = false
|
|
125
|
+
}
|
|
86
126
|
`;
|
|
87
127
|
|
|
88
128
|
const ANSIBLE_SITE_YML = `---
|
package/src/deploy/bootstrap.ts
CHANGED
|
@@ -82,10 +82,18 @@ export async function bootstrap(inputs: BootstrapInputs): Promise<void> {
|
|
|
82
82
|
`Environment variable ${cfg.provision.terraform.tokenEnv} is not set`,
|
|
83
83
|
);
|
|
84
84
|
}
|
|
85
|
+
// Server name: explicit config wins; legacy fallback `arc-<first env>`
|
|
86
|
+
// keeps existing single-app deploys from renaming their server. Set
|
|
87
|
+
// provision.terraform.serverName per app to share one Hetzner project.
|
|
88
|
+
const firstEnv = Object.keys(cfg.envs)[0] ?? "host";
|
|
89
|
+
const serverName = cfg.provision.terraform.serverName ?? `arc-${firstEnv}`;
|
|
90
|
+
const useCloudInitKey =
|
|
91
|
+
cfg.provision.terraform.sshKeyStrategy === "cloud-init";
|
|
85
92
|
const tfOut = await runTerraform({
|
|
86
93
|
tf: cfg.provision.terraform,
|
|
87
94
|
token,
|
|
88
|
-
serverName
|
|
95
|
+
serverName,
|
|
96
|
+
useCloudInitKey,
|
|
89
97
|
workspaceDir: rootDir,
|
|
90
98
|
});
|
|
91
99
|
ok(`Server provisioned: ${tfOut.serverIp}`);
|