@arcote.tech/arc-cli 0.5.2 → 0.5.5

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.5.2",
3
+ "version": "0.5.5",
4
4
  "description": "CLI tool for Arc framework",
5
5
  "module": "index.ts",
6
6
  "main": "dist/index.js",
@@ -12,12 +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 && chmod +x dist/index.js"
13
13
  },
14
14
  "dependencies": {
15
- "@arcote.tech/arc": "^0.5.2",
16
- "@arcote.tech/arc-ds": "^0.5.2",
17
- "@arcote.tech/arc-react": "^0.5.2",
18
- "@arcote.tech/arc-host": "^0.5.2",
19
- "@arcote.tech/arc-adapter-db-sqlite": "^0.5.2",
20
- "@arcote.tech/platform": "^0.5.2",
15
+ "@arcote.tech/arc": "^0.5.5",
16
+ "@arcote.tech/arc-ds": "^0.5.5",
17
+ "@arcote.tech/arc-react": "^0.5.5",
18
+ "@arcote.tech/arc-host": "^0.5.5",
19
+ "@arcote.tech/arc-adapter-db-sqlite": "^0.5.5",
20
+ "@arcote.tech/platform": "^0.5.5",
21
+ "@clack/prompts": "^0.9.0",
21
22
  "commander": "^11.1.0",
22
23
  "chokidar": "^3.5.3",
23
24
  "glob": "^10.3.10",
@@ -7,12 +7,16 @@ import {
7
7
  writeFileSync,
8
8
  } from "fs";
9
9
  import { dirname, join, relative } from "path";
10
+ import type { BuildManifest, ModuleDescriptor } from "@arcote.tech/platform";
10
11
  import {
11
12
  buildTypeDeclarations,
12
13
  type DeclarationResult,
13
14
  } from "../utils/build";
14
15
  import { i18nExtractPlugin, finalizeTranslations } from "../i18n";
15
16
 
17
+ /** Re-export for internal CLI consumers (avoid direct platform dependency in consumers). */
18
+ export type { BuildManifest, ModuleDescriptor };
19
+
16
20
  /** Clients that a context package is built for. */
17
21
  const CONTEXT_CLIENTS = [
18
22
  { name: "server", target: "bun" as const, defines: { ONLY_SERVER: "true", ONLY_BROWSER: "false", ONLY_CLIENT: "false" } },
@@ -97,14 +101,28 @@ export interface WorkspacePackage {
97
101
  packageJson: Record<string, any>;
98
102
  }
99
103
 
100
- export interface ModuleEntry {
101
- file: string;
102
- name: string;
104
+ /** @deprecated use ModuleDescriptor from @arcote.tech/platform */
105
+ export type ModuleEntry = ModuleDescriptor;
106
+
107
+ /**
108
+ * sha256 hex of bytes. Uses Bun.CryptoHasher which is always available in the CLI runtime.
109
+ */
110
+ export function sha256Hex(bytes: Uint8Array | ArrayBuffer | string): string {
111
+ const hasher = new Bun.CryptoHasher("sha256");
112
+ hasher.update(bytes as any);
113
+ return hasher.digest("hex");
103
114
  }
104
115
 
105
- export interface BuildManifest {
106
- modules: ModuleEntry[];
107
- buildTime: string;
116
+ /** Hash of concatenated file contents in a directory (stable order). */
117
+ export function sha256OfFiles(paths: readonly string[]): string {
118
+ const hasher = new Bun.CryptoHasher("sha256");
119
+ const sorted = [...paths].sort();
120
+ for (const p of sorted) {
121
+ if (!existsSync(p)) continue;
122
+ hasher.update(readFileSync(p));
123
+ hasher.update("\0");
124
+ }
125
+ return hasher.digest("hex");
108
126
  }
109
127
 
110
128
  /**
@@ -270,17 +288,25 @@ export async function buildPackages(
270
288
  const { rmSync } = await import("fs");
271
289
  rmSync(tmpDir, { recursive: true, force: true });
272
290
 
273
- // Build manifest
274
- const moduleEntries: ModuleEntry[] = result.outputs
291
+ // Build manifest — hash each module file for deploy diffing & client cache-bust
292
+ const moduleEntries: ModuleDescriptor[] = result.outputs
275
293
  .filter((o) => o.kind === "entry-point")
276
294
  .map((o) => {
277
295
  const file = o.path.split("/").pop()!;
278
296
  const safeName = file.replace(/\.js$/, "");
279
- return { file, name: fileToName.get(safeName) ?? safeName };
297
+ const bytes = readFileSync(o.path);
298
+ return {
299
+ file,
300
+ name: fileToName.get(safeName) ?? safeName,
301
+ hash: sha256Hex(bytes),
302
+ };
280
303
  });
281
304
 
305
+ // shellHash / stylesHash are filled in by buildAll() after shell and styles are built.
282
306
  const manifest: BuildManifest = {
283
307
  modules: moduleEntries,
308
+ shellHash: "",
309
+ stylesHash: "",
284
310
  buildTime: new Date().toISOString(),
285
311
  };
286
312
 
@@ -0,0 +1,143 @@
1
+ import { existsSync, readFileSync } from "fs";
2
+ import { join } from "path";
3
+ import { bootstrap } from "../deploy/bootstrap";
4
+ import {
5
+ deployConfigExists,
6
+ loadDeployConfig,
7
+ saveDeployConfig,
8
+ } from "../deploy/config";
9
+ import { detectRemoteState } from "../deploy/remote-state";
10
+ import { syncEnv } from "../deploy/remote-sync";
11
+ import { runSurvey } from "../deploy/survey";
12
+ import {
13
+ buildAll,
14
+ err,
15
+ log,
16
+ ok,
17
+ resolveWorkspace,
18
+ } from "../platform/shared";
19
+
20
+ interface PlatformDeployOptions {
21
+ /** Optional env filter from positional arg. */
22
+ env?: string;
23
+ /** Skip local build if artifacts already exist. */
24
+ skipBuild?: boolean;
25
+ /** Force rebuild before deploy. */
26
+ rebuild?: boolean;
27
+ }
28
+
29
+ // ---------------------------------------------------------------------------
30
+ // Entry point for `arc platform deploy [env] [--skip-build] [--rebuild]`.
31
+ //
32
+ // High-level flow:
33
+ // 1. resolveWorkspace
34
+ // 2. load or survey deploy.arc.json
35
+ // 3. ensure local build (buildAll unless --skip-build)
36
+ // 4. detectRemoteState → bootstrap if needed
37
+ // 5. for each env (or the one passed as arg): syncEnv
38
+ // ---------------------------------------------------------------------------
39
+
40
+ export async function platformDeploy(
41
+ envArg: string | undefined,
42
+ options: PlatformDeployOptions = {},
43
+ ): Promise<void> {
44
+ const ws = resolveWorkspace();
45
+
46
+ // 1. Load or survey config
47
+ let cfg;
48
+ if (!deployConfigExists(ws.rootDir)) {
49
+ log("No deploy.arc.json found — launching survey.");
50
+ cfg = await runSurvey();
51
+ saveDeployConfig(ws.rootDir, cfg);
52
+ ok("Saved deploy.arc.json");
53
+ }
54
+ cfg = loadDeployConfig(ws.rootDir);
55
+
56
+ // Filter envs if an arg was provided
57
+ const targetEnvs = envArg
58
+ ? envArg in cfg.envs
59
+ ? [envArg]
60
+ : (() => {
61
+ err(`Unknown env "${envArg}". Known: ${Object.keys(cfg.envs).join(", ")}`);
62
+ process.exit(1);
63
+ })()
64
+ : Object.keys(cfg.envs);
65
+
66
+ // 2. Ensure local build
67
+ const manifestPath = join(ws.modulesDir, "manifest.json");
68
+ const needBuild = options.rebuild || !existsSync(manifestPath);
69
+ if (needBuild && !options.skipBuild) {
70
+ log("Building platform...");
71
+ await buildAll(ws);
72
+ ok("Build complete");
73
+ } else if (!existsSync(manifestPath)) {
74
+ err("No build found and --skip-build was set.");
75
+ process.exit(1);
76
+ }
77
+
78
+ // 3. Detect remote state
79
+ log("Inspecting remote server...");
80
+ const state = await detectRemoteState(cfg);
81
+ log(`Remote state: ${state.kind}`);
82
+
83
+ // 4. Bootstrap if needed
84
+ const cliVersion = readCliVersion();
85
+ const configHash = await hashDeployConfig(ws.rootDir);
86
+ if (state.kind !== "ready") {
87
+ await bootstrap({
88
+ cfg,
89
+ rootDir: ws.rootDir,
90
+ state,
91
+ cliVersion,
92
+ configHash,
93
+ });
94
+ }
95
+
96
+ // 5. Sync each env
97
+ for (const env of targetEnvs) {
98
+ log(`Syncing env "${env}"...`);
99
+ const outcome = await syncEnv({
100
+ cfg,
101
+ env,
102
+ ws,
103
+ projectDir: ws.rootDir,
104
+ });
105
+ if (
106
+ outcome.changedModules.length === 0 &&
107
+ !outcome.shellChanged &&
108
+ !outcome.stylesChanged
109
+ ) {
110
+ ok(`${env}: already up to date`);
111
+ } else {
112
+ const parts: string[] = [];
113
+ if (outcome.changedModules.length > 0) {
114
+ parts.push(`${outcome.changedModules.length} module(s): ${outcome.changedModules.join(", ")}`);
115
+ }
116
+ if (outcome.shellChanged) parts.push("shell");
117
+ if (outcome.stylesChanged) parts.push("styles");
118
+ ok(`${env}: updated ${parts.join(", ")}`);
119
+ }
120
+ }
121
+ }
122
+
123
+ // ---------------------------------------------------------------------------
124
+ // Helpers
125
+ // ---------------------------------------------------------------------------
126
+
127
+ function readCliVersion(): string {
128
+ try {
129
+ const pkgPath = join(import.meta.dir, "..", "..", "package.json");
130
+ const pkg = JSON.parse(readFileSync(pkgPath, "utf-8"));
131
+ return pkg.version ?? "unknown";
132
+ } catch {
133
+ return "unknown";
134
+ }
135
+ }
136
+
137
+ async function hashDeployConfig(rootDir: string): Promise<string> {
138
+ const p = join(rootDir, "deploy.arc.json");
139
+ const content = readFileSync(p);
140
+ const hasher = new Bun.CryptoHasher("sha256");
141
+ hasher.update(content);
142
+ return hasher.digest("hex").slice(0, 16);
143
+ }
@@ -34,8 +34,10 @@ export async function platformStart(): Promise<void> {
34
34
  log("No context — server endpoints skipped");
35
35
  }
36
36
 
37
- // Start server (production mode = no SSE reload, aggressive caching)
37
+ // Start server (production mode = aggressive caching, SSE only used for deploy hot-swap)
38
38
  const arcEntries = collectArcPeerDeps(ws.packages);
39
+ const deployApi = process.env.ARC_DEPLOY_API === "1";
40
+ if (deployApi) ok("Deploy API enabled (/api/deploy/*)");
39
41
  const platform = await startPlatformServer({
40
42
  ws,
41
43
  port,
@@ -44,6 +46,7 @@ export async function platformStart(): Promise<void> {
44
46
  moduleAccess,
45
47
  dbPath: join(ws.rootDir, ".arc", "data", "prod.db"),
46
48
  devMode: false,
49
+ deployApi,
47
50
  arcEntries,
48
51
  });
49
52
 
@@ -0,0 +1,69 @@
1
+ import { spawn } from "bun";
2
+ import { mkdirSync, writeFileSync } from "fs";
3
+ import { tmpdir } from "os";
4
+ import { join } from "path";
5
+ import { ASSETS, materializeAssets } from "./assets";
6
+ import type { DeployProvisionAnsible, DeployTarget } from "./config";
7
+
8
+ // ---------------------------------------------------------------------------
9
+ // Runs Ansible from embedded assets. Inventory is generated on the fly,
10
+ // targeting the single host described by DeployTarget. Runs as root on the
11
+ // first bootstrap (via `ansible_user=root`); subsequent runs reuse the
12
+ // `deploy` user created by the playbook.
13
+ // ---------------------------------------------------------------------------
14
+
15
+ export interface AnsibleInputs {
16
+ target: DeployTarget;
17
+ ansible?: DeployProvisionAnsible;
18
+ /** On first bootstrap, the fresh VM only has root — so override username. */
19
+ asRoot?: boolean;
20
+ }
21
+
22
+ export async function runAnsible(inputs: AnsibleInputs): Promise<void> {
23
+ const workDir = join(tmpdir(), "arc-deploy", `ansible-${Date.now()}`);
24
+ mkdirSync(workDir, { recursive: true });
25
+ await materializeAssets(workDir, ASSETS.ansible);
26
+
27
+ const user = inputs.asRoot ? "root" : inputs.target.user;
28
+ const port = inputs.ansible?.sshPort ?? inputs.target.port;
29
+
30
+ const inventory = [
31
+ "[arc]",
32
+ `${inputs.target.host} ansible_user=${user} ansible_port=${port}`,
33
+ "",
34
+ "[arc:vars]",
35
+ "ansible_ssh_common_args='-o StrictHostKeyChecking=accept-new -o BatchMode=yes'",
36
+ "ansible_python_interpreter=/usr/bin/python3",
37
+ "",
38
+ ].join("\n");
39
+ writeFileSync(join(workDir, "inventory.ini"), inventory);
40
+
41
+ const extraVars = [
42
+ `username=${inputs.target.user}`,
43
+ `ssh_port=${port}`,
44
+ ];
45
+ if (inputs.ansible?.extraAllowedIps?.length) {
46
+ extraVars.push(
47
+ `extra_allowed_ips=${JSON.stringify(inputs.ansible.extraAllowedIps)}`,
48
+ );
49
+ }
50
+
51
+ const proc = spawn({
52
+ cmd: [
53
+ "ansible-playbook",
54
+ "-i",
55
+ "inventory.ini",
56
+ "site.yml",
57
+ "-e",
58
+ extraVars.join(" "),
59
+ ],
60
+ cwd: workDir,
61
+ stdout: "inherit",
62
+ stderr: "inherit",
63
+ env: { ...process.env, ANSIBLE_HOST_KEY_CHECKING: "False" },
64
+ });
65
+ const exit = await proc.exited;
66
+ if (exit !== 0) {
67
+ throw new Error(`ansible-playbook failed (exit ${exit})`);
68
+ }
69
+ }
@@ -0,0 +1,169 @@
1
+ ---
2
+ # Arc platform bootstrap playbook — minimal hardened Docker host.
3
+ # No Portainer / Watchtower / private registry; images travel via docker save|load.
4
+ - name: Bootstrap Arc host
5
+ hosts: all
6
+ become: true
7
+ gather_facts: true
8
+ vars:
9
+ deploy_user: "{{ username | default('deploy') }}"
10
+ ssh_port: "{{ ssh_port | default(22) }}"
11
+ extra_allowed_ips: "{{ extra_allowed_ips | default([]) }}"
12
+
13
+ tasks:
14
+ - name: Update apt cache
15
+ apt:
16
+ update_cache: true
17
+ cache_valid_time: 3600
18
+
19
+ - name: Install base packages
20
+ apt:
21
+ name:
22
+ - ca-certificates
23
+ - curl
24
+ - gnupg
25
+ - ufw
26
+ - fail2ban
27
+ - unattended-upgrades
28
+ - python3-docker
29
+ state: present
30
+
31
+ - name: Create deploy user
32
+ user:
33
+ name: "{{ deploy_user }}"
34
+ shell: /bin/bash
35
+ groups: sudo
36
+ append: true
37
+ create_home: true
38
+ state: present
39
+
40
+ - name: Copy SSH key from root to deploy user
41
+ shell: |
42
+ mkdir -p /home/{{ deploy_user }}/.ssh
43
+ cp /root/.ssh/authorized_keys /home/{{ deploy_user }}/.ssh/authorized_keys
44
+ chown -R {{ deploy_user }}:{{ deploy_user }} /home/{{ deploy_user }}/.ssh
45
+ chmod 700 /home/{{ deploy_user }}/.ssh
46
+ chmod 600 /home/{{ deploy_user }}/.ssh/authorized_keys
47
+ args:
48
+ creates: "/home/{{ deploy_user }}/.ssh/authorized_keys"
49
+
50
+ - name: Passwordless sudo for deploy user
51
+ copy:
52
+ dest: /etc/sudoers.d/99-{{ deploy_user }}
53
+ content: "{{ deploy_user }} ALL=(ALL) NOPASSWD:ALL\n"
54
+ mode: "0440"
55
+ validate: "visudo -cf %s"
56
+
57
+ - name: Harden sshd
58
+ lineinfile:
59
+ path: /etc/ssh/sshd_config
60
+ regexp: "{{ item.re }}"
61
+ line: "{{ item.line }}"
62
+ state: present
63
+ loop:
64
+ - { re: "^#?PermitRootLogin", line: "PermitRootLogin no" }
65
+ - { re: "^#?PasswordAuthentication", line: "PasswordAuthentication no" }
66
+ - { re: "^#?PubkeyAuthentication", line: "PubkeyAuthentication yes" }
67
+ - { re: "^#?MaxAuthTries", line: "MaxAuthTries 3" }
68
+ notify: restart ssh
69
+
70
+ - name: Install Docker via official convenience script
71
+ shell: |
72
+ curl -fsSL https://get.docker.com | sh
73
+ args:
74
+ creates: /usr/bin/docker
75
+
76
+ - name: Enable and start docker
77
+ systemd:
78
+ name: docker
79
+ enabled: true
80
+ state: started
81
+
82
+ - name: Add deploy user to docker group
83
+ user:
84
+ name: "{{ deploy_user }}"
85
+ groups: docker
86
+ append: true
87
+
88
+ - name: Configure docker log rotation
89
+ copy:
90
+ dest: /etc/docker/daemon.json
91
+ content: |
92
+ {
93
+ "log-driver": "json-file",
94
+ "log-opts": {
95
+ "max-size": "10m",
96
+ "max-file": "3"
97
+ }
98
+ }
99
+ mode: "0644"
100
+ notify: restart docker
101
+
102
+ - name: Ensure /opt/arc exists
103
+ file:
104
+ path: /opt/arc
105
+ state: directory
106
+ owner: "{{ deploy_user }}"
107
+ group: "{{ deploy_user }}"
108
+ mode: "0755"
109
+
110
+ - name: Configure ufw defaults
111
+ ufw:
112
+ policy: "{{ item.policy }}"
113
+ direction: "{{ item.dir }}"
114
+ loop:
115
+ - { policy: deny, dir: incoming }
116
+ - { policy: allow, dir: outgoing }
117
+
118
+ - name: Open firewall ports
119
+ ufw:
120
+ rule: allow
121
+ port: "{{ item }}"
122
+ proto: tcp
123
+ loop:
124
+ - "{{ ssh_port }}"
125
+ - "80"
126
+ - "443"
127
+
128
+ - name: Enable ufw
129
+ ufw:
130
+ state: enabled
131
+
132
+ - name: Configure fail2ban for sshd
133
+ copy:
134
+ dest: /etc/fail2ban/jail.local
135
+ content: |
136
+ [sshd]
137
+ enabled = true
138
+ port = {{ ssh_port }}
139
+ maxretry = 5
140
+ findtime = 600
141
+ bantime = 3600
142
+ ignoreip = 127.0.0.1/8 ::1 {{ extra_allowed_ips | join(' ') }}
143
+ mode: "0644"
144
+ notify: restart fail2ban
145
+
146
+ - name: Enable unattended upgrades
147
+ copy:
148
+ dest: /etc/apt/apt.conf.d/20auto-upgrades
149
+ content: |
150
+ APT::Periodic::Update-Package-Lists "1";
151
+ APT::Periodic::Unattended-Upgrade "1";
152
+ APT::Periodic::AutocleanInterval "7";
153
+ mode: "0644"
154
+
155
+ handlers:
156
+ - name: restart ssh
157
+ systemd:
158
+ name: ssh
159
+ state: restarted
160
+
161
+ - name: restart docker
162
+ systemd:
163
+ name: docker
164
+ state: restarted
165
+
166
+ - name: restart fail2ban
167
+ systemd:
168
+ name: fail2ban
169
+ state: restarted
@@ -0,0 +1,38 @@
1
+ terraform {
2
+ required_providers {
3
+ hcloud = {
4
+ source = "hetznercloud/hcloud"
5
+ version = "~> 1.51"
6
+ }
7
+ }
8
+ }
9
+
10
+ provider "hcloud" {
11
+ token = var.hcloud_token
12
+ }
13
+
14
+ resource "hcloud_ssh_key" "deploy" {
15
+ name = "${var.server_name}-deploy"
16
+ public_key = file(var.ssh_public_key)
17
+ }
18
+
19
+ resource "hcloud_server" "arc" {
20
+ name = var.server_name
21
+ image = var.server_image
22
+ server_type = var.server_type
23
+ location = var.server_location
24
+ ssh_keys = [hcloud_ssh_key.deploy.id]
25
+
26
+ public_net {
27
+ ipv4_enabled = true
28
+ ipv6_enabled = true
29
+ }
30
+ }
31
+
32
+ output "server_ip" {
33
+ value = hcloud_server.arc.ipv4_address
34
+ }
35
+
36
+ output "server_name" {
37
+ value = hcloud_server.arc.name
38
+ }
@@ -0,0 +1,35 @@
1
+ variable "hcloud_token" {
2
+ description = "Hetzner Cloud API token"
3
+ type = string
4
+ sensitive = true
5
+ }
6
+
7
+ variable "server_name" {
8
+ description = "Name of the Hetzner server (shown in the console)"
9
+ type = string
10
+ default = "arc-platform"
11
+ }
12
+
13
+ variable "server_type" {
14
+ description = "Hetzner server type (cx22, cx32, cx42, ...)"
15
+ type = string
16
+ default = "cx32"
17
+ }
18
+
19
+ variable "server_location" {
20
+ description = "Hetzner datacenter location (nbg1, fsn1, hel1, ...)"
21
+ type = string
22
+ default = "nbg1"
23
+ }
24
+
25
+ variable "server_image" {
26
+ description = "OS image"
27
+ type = string
28
+ default = "ubuntu-22.04"
29
+ }
30
+
31
+ variable "ssh_public_key" {
32
+ description = "Path to the public key uploaded to the server"
33
+ type = string
34
+ default = "~/.ssh/id_ed25519.pub"
35
+ }