@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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@arcote.tech/arc-cli",
3
- "version": "0.7.32",
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.7.32",
16
- "@arcote.tech/arc-ds": "^0.7.32",
17
- "@arcote.tech/arc-react": "^0.7.32",
18
- "@arcote.tech/arc-host": "^0.7.32",
19
- "@arcote.tech/arc-adapter-db-sqlite": "^0.7.32",
20
- "@arcote.tech/arc-adapter-db-postgres": "^0.7.32",
21
- "@arcote.tech/arc-otel": "^0.7.32",
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.7.32",
35
- "@arcote.tech/arc-map": "^0.7.32",
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<SerializedAccessMap> {
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 SerializedAccessMap;
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 result = {};
130
- for (const [name, access] of platform.getAllModuleAccess()) {
131
- result[name] = {
132
- rules: (access.rules ?? []).map((r) => ({
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(result, null, 2) + "\\n");
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
- "react/jsx-dev-runtime",
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
- contents: `import { jsx, jsxs, Fragment } from "react/jsx-runtime";
251
- export const jsxDEV = jsx;
252
- export const jsxsDEV = jsxs;
253
- export { Fragment };
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
- `${importLines(publicMembers)}\nexport { startApp } from "@arcote.tech/platform";\n`,
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
- // No externals. Framework peers (react, @arcote.tech/*) get bundled and
924
- // deduped into shared chunks. No importmap needed in the HTML.
925
- external: [],
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(rootDir),
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({ ws, devMode: true, map: opts.map });
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
+ }
@@ -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 = `---
@@ -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: `arc-${Object.keys(cfg.envs)[0] ?? "host"}`,
95
+ serverName,
96
+ useCloudInitKey,
89
97
  workspaceDir: rootDir,
90
98
  });
91
99
  ok(`Server provisioned: ${tfOut.serverIp}`);