@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.
@@ -70,6 +70,22 @@ export interface DeployProvisionTerraform {
70
70
  tokenEnv: string;
71
71
  /** Path to public SSH key uploaded to the provider. Default: ~/.ssh/id_ed25519.pub */
72
72
  sshPublicKey?: string;
73
+ /**
74
+ * Server name in Hetzner Console + prefix for the managed SSH key.
75
+ * Must be unique per Hetzner project. Fallback when absent: `arc-<first env>`
76
+ * (legacy — kept so existing single-app deploys don't rename their server).
77
+ * Set this per app to run several apps in one Hetzner project without name
78
+ * collisions (e.g. "arc-bmf-prod").
79
+ */
80
+ serverName?: string;
81
+ /**
82
+ * How the operator public key reaches the fresh VM.
83
+ * - "resource" (default, legacy): creates an `hcloud_ssh_key` object.
84
+ * - "cloud-init": injects the key via `user_data`, creating NO key object.
85
+ * Pick this when multiple apps share one Hetzner project with the same
86
+ * local public key — a key object would collide on name AND fingerprint.
87
+ */
88
+ sshKeyStrategy?: "resource" | "cloud-init";
73
89
  }
74
90
 
75
91
  export interface DeployProvisionAnsible {
@@ -261,9 +277,29 @@ export function validateDeployConfig(input: unknown): DeployConfig {
261
277
  );
262
278
  }
263
279
 
280
+ // Host is optional when a terraform provision block is present — terraform
281
+ // fills it on first deploy (persisted back via saveDeployConfig). This lets
282
+ // a user hand-write a partial config and leave `target.host` empty. Without
283
+ // provision, a host is mandatory (there's nothing to create it).
284
+ const provisionRaw = (input as Record<string, unknown>).provision;
285
+ const hasProvisionTf =
286
+ isObject(provisionRaw) &&
287
+ isObject((provisionRaw as Record<string, unknown>).terraform);
288
+ const hostRaw = optionalString(target, "target.host");
289
+ let host: string;
290
+ if (hostRaw && hostRaw.length > 0) {
291
+ host = hostRaw;
292
+ } else if (hasProvisionTf) {
293
+ host = "PENDING_TERRAFORM";
294
+ } else {
295
+ throw new Error(
296
+ `deploy.arc.json: target.host is required unless provision.terraform is present (with provision, terraform fills it on first deploy).`,
297
+ );
298
+ }
299
+
264
300
  const validated: DeployConfig = {
265
301
  target: {
266
- host: requireString(target, "target.host"),
302
+ host,
267
303
  user: optionalString(target, "target.user") ?? "deploy",
268
304
  port: optionalNumber(target, "target.port") ?? 22,
269
305
  remoteDir: optionalString(target, "target.remoteDir") ?? "/opt/arc",
@@ -379,6 +415,25 @@ export function validateDeployConfig(input: unknown): DeployConfig {
379
415
  `deploy.arc.json: provision.terraform.provider must be "hcloud" (got "${providerVal}")`,
380
416
  );
381
417
  }
418
+ const serverName = optionalString(tf, "provision.terraform.serverName");
419
+ if (serverName !== undefined && !/^[a-z0-9-]{1,63}$/.test(serverName)) {
420
+ throw new Error(
421
+ `deploy.arc.json: provision.terraform.serverName "${serverName}" must match [a-z0-9-] and be at most 63 chars`,
422
+ );
423
+ }
424
+ const sshKeyStrategyRaw = optionalString(
425
+ tf,
426
+ "provision.terraform.sshKeyStrategy",
427
+ );
428
+ if (
429
+ sshKeyStrategyRaw !== undefined &&
430
+ sshKeyStrategyRaw !== "resource" &&
431
+ sshKeyStrategyRaw !== "cloud-init"
432
+ ) {
433
+ throw new Error(
434
+ `deploy.arc.json: provision.terraform.sshKeyStrategy must be "resource" or "cloud-init" (got "${sshKeyStrategyRaw}")`,
435
+ );
436
+ }
382
437
  const terraform: DeployProvisionTerraform = {
383
438
  provider: "hcloud",
384
439
  serverType: requireString(tf, "provision.terraform.serverType"),
@@ -386,6 +441,11 @@ export function validateDeployConfig(input: unknown): DeployConfig {
386
441
  image: optionalString(tf, "provision.terraform.image") ?? "ubuntu-22.04",
387
442
  tokenEnv: requireString(tf, "provision.terraform.tokenEnv"),
388
443
  sshPublicKey: optionalString(tf, "provision.terraform.sshPublicKey"),
444
+ serverName,
445
+ sshKeyStrategy: sshKeyStrategyRaw as
446
+ | "resource"
447
+ | "cloud-init"
448
+ | undefined,
389
449
  };
390
450
  let ansible: DeployProvisionAnsible | undefined;
391
451
  const ansibleRaw = (provision as Record<string, unknown>).ansible;
@@ -5,6 +5,7 @@ import type {
5
5
  DeployProvision,
6
6
  DeployProvisionTerraform,
7
7
  } from "./config";
8
+ import { sanitizeImageName } from "./image";
8
9
 
9
10
  // ---------------------------------------------------------------------------
10
11
  // Interactive init for deploy.arc.json. Grouped into minimal phases:
@@ -15,7 +16,7 @@ import type {
15
16
  // Returns a validated DeployConfig ready to save. On Ctrl-C, exits the process.
16
17
  // ---------------------------------------------------------------------------
17
18
 
18
- export async function runSurvey(): Promise<DeployConfig> {
19
+ export async function runSurvey(appName?: string): Promise<DeployConfig> {
19
20
  clack.intro("arc platform deploy — initial setup");
20
21
 
21
22
  // Phase 1: mode
@@ -129,12 +130,25 @@ export async function runSurvey(): Promise<DeployConfig> {
129
130
  })) as string;
130
131
  if (clack.isCancel(location)) cancel();
131
132
 
133
+ // Namespace the server per app and inject the SSH key via cloud-init, so
134
+ // several apps can share one Hetzner project without colliding on server
135
+ // name or SSH key name/fingerprint. Both are opt-in fields with legacy
136
+ // fallbacks, but new configs default to the collision-safe combo.
137
+ const firstEnv = Object.keys(envs)[0] ?? "host";
138
+ const appSlug = sanitizeImageName(appName ?? "arc-app").replace(
139
+ /[^a-z0-9-]+/g,
140
+ "-",
141
+ );
142
+ const serverName = `arc-${appSlug}-${firstEnv}`.slice(0, 63);
143
+
132
144
  const terraform: DeployProvisionTerraform = {
133
145
  provider: "hcloud",
134
146
  serverType,
135
147
  location,
136
148
  image: "ubuntu-22.04",
137
149
  tokenEnv,
150
+ serverName,
151
+ sshKeyStrategy: "cloud-init",
138
152
  };
139
153
  provision = { terraform };
140
154
  }
@@ -20,6 +20,8 @@ export interface TerraformInputs {
20
20
  token: string;
21
21
  /** Deterministic server name shown in Hetzner Console. */
22
22
  serverName: string;
23
+ /** Inject SSH key via cloud-init user_data instead of a managed key object. */
24
+ useCloudInitKey: boolean;
23
25
  /** Workspace root — used to scope state dir per project. */
24
26
  workspaceDir: string;
25
27
  }
@@ -61,6 +63,7 @@ export async function runTerraform(
61
63
  `server_location = "${inputs.tf.location}"`,
62
64
  `server_image = "${inputs.tf.image}"`,
63
65
  `ssh_public_key = "${expandHome(sshPubKey)}"`,
66
+ `use_cloud_init_key = ${inputs.useCloudInitKey}`,
64
67
  ].join("\n") + "\n";
65
68
 
66
69
  writeFileSync(join(workDir, "terraform.tfvars"), tfvars);
package/src/index.ts CHANGED
@@ -35,8 +35,16 @@ platform
35
35
  .description("Start platform in dev mode (Bun server + Vite HMR)")
36
36
  .option("--no-cache", "Force full rebuild on startup")
37
37
  .option("--map", "Also start the Arc Context Map dev tool on a separate port")
38
- .action((opts: { cache?: boolean; map?: boolean }) =>
39
- platformDev({ noCache: opts.cache === false, map: opts.map }),
38
+ .option(
39
+ "--headless",
40
+ "Host API + federated module chunks + map, WITHOUT the app's own SPA shell (implies --map)",
41
+ )
42
+ .action((opts: { cache?: boolean; map?: boolean; headless?: boolean }) =>
43
+ platformDev({
44
+ noCache: opts.cache === false,
45
+ map: opts.map,
46
+ headless: opts.headless,
47
+ }),
40
48
  );
41
49
 
42
50
  platform