@arcote.tech/arc-cli 0.7.6 → 0.7.8

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.6",
3
+ "version": "0.7.8",
4
4
  "description": "CLI tool for Arc framework",
5
5
  "module": "index.ts",
6
6
  "main": "dist/index.js",
@@ -9,17 +9,29 @@
9
9
  "arc": "./dist/index.js"
10
10
  },
11
11
  "scripts": {
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"
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 '@opentelemetry/*' && chmod +x dist/index.js"
13
13
  },
14
14
  "dependencies": {
15
- "@arcote.tech/arc": "^0.7.6",
16
- "@arcote.tech/arc-ds": "^0.7.6",
17
- "@arcote.tech/arc-react": "^0.7.6",
18
- "@arcote.tech/arc-host": "^0.7.6",
19
- "@arcote.tech/arc-adapter-db-sqlite": "^0.7.6",
20
- "@arcote.tech/arc-adapter-db-postgres": "^0.7.6",
21
- "@arcote.tech/arc-otel": "^0.7.6",
22
- "@arcote.tech/platform": "^0.7.6",
15
+ "@arcote.tech/arc": "^0.7.8",
16
+ "@arcote.tech/arc-ds": "^0.7.8",
17
+ "@arcote.tech/arc-react": "^0.7.8",
18
+ "@arcote.tech/arc-host": "^0.7.8",
19
+ "@arcote.tech/arc-adapter-db-sqlite": "^0.7.8",
20
+ "@arcote.tech/arc-adapter-db-postgres": "^0.7.8",
21
+ "@arcote.tech/arc-otel": "^0.7.8",
22
+ "@opentelemetry/api": "^1.9.0",
23
+ "@opentelemetry/api-logs": "^0.57.0",
24
+ "@opentelemetry/core": "^1.30.0",
25
+ "@opentelemetry/exporter-logs-otlp-http": "^0.57.0",
26
+ "@opentelemetry/exporter-metrics-otlp-http": "^0.57.0",
27
+ "@opentelemetry/exporter-trace-otlp-http": "^0.57.0",
28
+ "@opentelemetry/resources": "^1.30.0",
29
+ "@opentelemetry/sdk-logs": "^0.57.0",
30
+ "@opentelemetry/sdk-metrics": "^1.30.0",
31
+ "@opentelemetry/sdk-trace-base": "^1.30.0",
32
+ "@opentelemetry/sdk-trace-node": "^1.30.0",
33
+ "@opentelemetry/semantic-conventions": "^1.27.0",
34
+ "@arcote.tech/platform": "^0.7.8",
23
35
  "@clack/prompts": "^0.9.0",
24
36
  "commander": "^11.1.0",
25
37
  "chokidar": "^3.5.3",
@@ -1,6 +1,7 @@
1
1
  import { createHash } from "node:crypto";
2
2
  import { existsSync, mkdirSync, readFileSync, writeFileSync } from "fs";
3
- import { basename, join } from "path";
3
+ import { basename, dirname, join } from "path";
4
+ import { fileURLToPath } from "node:url";
4
5
  import { FRAMEWORK_PEERS, FRAMEWORK_PEER_SET } from "./framework-peers";
5
6
  import type { WorkspacePackage } from "./module-builder";
6
7
 
@@ -46,6 +47,38 @@ export function collectFrameworkDeps(
46
47
  for (const { name, version } of sharedDeps) {
47
48
  versions[name] = version;
48
49
  }
50
+
51
+ // OpenTelemetry deps — included unconditionally so deployed images can
52
+ // resolve them at runtime when telemetry is enabled. Cheap install
53
+ // (~5MB) and avoids per-deploy branching on `observability.enabled`.
54
+ // CLI's bundle externalizes @opentelemetry/* to dodge a Bun bundling
55
+ // bug that mixes ES5/ES6 class semantics across these packages.
56
+ // Resolve arc-otel from the CLI bundle's own location: arc-otel is a
57
+ // dep of arc-cli, not of the consumer app, so it isn't reachable from
58
+ // `rootDir`. Bun installs it nested under `.bun/<arc-cli-id>/...`,
59
+ // visible to a resolve call rooted in arc-cli's own dist directory.
60
+ try {
61
+ const cliDir = dirname(fileURLToPath(import.meta.url));
62
+ const arcOtelPkgPath = Bun.resolveSync(
63
+ "@arcote.tech/arc-otel/package.json",
64
+ cliDir,
65
+ );
66
+ const arcOtelPkg = JSON.parse(readFileSync(arcOtelPkgPath, "utf-8"));
67
+ for (const [name, spec] of Object.entries(arcOtelPkg.dependencies ?? {})) {
68
+ if (name.startsWith("@opentelemetry/")) {
69
+ versions[name] = spec as string;
70
+ }
71
+ }
72
+ versions["@arcote.tech/arc-otel"] = `^${arcOtelPkg.version}`;
73
+ } catch (e) {
74
+ // arc-otel not installed alongside the CLI — older CLI release without
75
+ // observability support, or a bundle that stripped the dep. Log once
76
+ // so a misconfigured deploy doesn't silently disable telemetry libs
77
+ // when the operator was expecting them.
78
+ console.warn(
79
+ `[arc-otel] could not resolve @arcote.tech/arc-otel — image will run without telemetry deps: ${(e as Error).message}`,
80
+ );
81
+ }
49
82
  const manifest = {
50
83
  name: "arc-platform-framework",
51
84
  private: true,
@@ -235,5 +235,11 @@ async function hashDeployConfig(rootDir: string): Promise<string> {
235
235
  const content = readFileSync(p);
236
236
  const hasher = new Bun.CryptoHasher("sha256");
237
237
  hasher.update(content);
238
+ // Mix in the CLI version so a new CLI release that changes compose
239
+ // / caddyfile / observability templates forces upStack to re-run even
240
+ // when the user's deploy.arc.json is byte-identical. Without this,
241
+ // template changes ship in the npm package but never reach the host
242
+ // because the marker says "no config drift, skipping bootstrap".
243
+ hasher.update(readCliVersion());
238
244
  return hasher.digest("hex").slice(0, 16);
239
245
  }
@@ -1,12 +1,12 @@
1
1
  import { spawn } from "bun";
2
2
  import { mkdirSync, writeFileSync } from "fs";
3
3
  import { tmpdir } from "os";
4
- import { join } from "path";
4
+ import { dirname, join } from "path";
5
5
  import { runAnsible } from "./ansible";
6
6
  import { generateCaddyfile } from "./caddyfile";
7
7
  import { generateCompose } from "./compose";
8
8
  import type { DeployConfig } from "./config";
9
- import { generateHtpasswd } from "./htpasswd";
9
+ import { generateCaddyBasicAuthLine, generateHtpasswd } from "./htpasswd";
10
10
  import { generateObservabilityConfigs } from "./observability-configs";
11
11
  import { runTerraform } from "./terraform";
12
12
  import { saveDeployConfig } from "./config";
@@ -148,6 +148,22 @@ export async function bootstrap(inputs: BootstrapInputs): Promise<void> {
148
148
  ok("Docker stack up");
149
149
  }
150
150
 
151
+ // Observability sidecars — always reconcile state, independent of
152
+ // upStack. `docker compose up -d` is idempotent: when the containers
153
+ // already match the current compose definition, nothing happens. When
154
+ // the user just turned observability on (config changed but upStack
155
+ // ran in a previous deploy), this brings the new services to life
156
+ // without forcing a full bootstrap.
157
+ if (cfg.observability?.enabled) {
158
+ log("Ensuring observability sidecars are running...");
159
+ const obsServices = ["otel-collector", "tempo", "loki", "prometheus", "grafana"];
160
+ await assertExec(
161
+ cfg.target,
162
+ `cd ${cfg.target.remoteDir} && docker compose pull --ignore-pull-failures ${obsServices.join(" ")} && docker compose up -d ${obsServices.join(" ")}`,
163
+ );
164
+ ok("Observability stack up");
165
+ }
166
+
151
167
  // Keep marker fresh
152
168
  await writeStateMarker(cfg.target, {
153
169
  cliVersion: inputs.cliVersion,
@@ -241,11 +257,13 @@ async function upStack(inputs: BootstrapInputs): Promise<void> {
241
257
  `bootstrap: ${adminPasswordEnv} not set — observability needs a Grafana admin password.`,
242
258
  );
243
259
  }
244
- // Caddy basic_auth import file uses `username bcrypt_hash` syntax.
245
- observabilityHtpasswd = await generateHtpasswd("admin", adminPassword);
246
- mkdirSync(join(workDir, "observability"), { recursive: true });
260
+ // Caddy basic_auth import file uses `username bcrypt_hash` syntax
261
+ // (space-separated), NOT the registry-style htpasswd colon format.
262
+ observabilityHtpasswd = await generateCaddyBasicAuthLine("admin", adminPassword);
247
263
  for (const [relPath, contents] of Object.entries(observabilityFiles)) {
248
- writeFileSync(join(workDir, relPath), contents);
264
+ const fullPath = join(workDir, relPath);
265
+ mkdirSync(dirname(fullPath), { recursive: true });
266
+ writeFileSync(fullPath, contents);
249
267
  }
250
268
  writeFileSync(join(workDir, "observability-htpasswd"), observabilityHtpasswd);
251
269
  }
@@ -288,10 +306,19 @@ async function upStack(inputs: BootstrapInputs): Promise<void> {
288
306
  // tempo / loki / prometheus / grafana configs go into a sibling
289
307
  // `observability/` directory referenced by compose bind-mounts.
290
308
  if (observabilityFiles && observabilityHtpasswd) {
309
+ // Make sure both the top-level `observability/` dir and the nested
310
+ // `grafana-dashboards/` exist before scp tries to land files there.
291
311
  await assertExec(
292
312
  cfg.target,
293
- `mkdir -p ${cfg.target.remoteDir}/observability`,
313
+ `mkdir -p ${cfg.target.remoteDir}/observability/grafana-dashboards`,
294
314
  );
315
+ // Make sure local nested dir exists too — `generateObservabilityConfigs`
316
+ // returns relative paths like `observability/grafana-dashboards/x.json`
317
+ // which mkdirSync below uses for writeFileSync's directory.
318
+ for (const relPath of Object.keys(observabilityFiles)) {
319
+ const localDir = dirname(join(workDir, relPath));
320
+ mkdirSync(localDir, { recursive: true });
321
+ }
295
322
  for (const relPath of Object.keys(observabilityFiles)) {
296
323
  await scpUpload(
297
324
  cfg.target,
@@ -371,6 +398,7 @@ async function upStack(inputs: BootstrapInputs): Promise<void> {
371
398
  .join(" ")}`,
372
399
  );
373
400
  }
401
+
374
402
  }
375
403
 
376
404
  /**
@@ -224,6 +224,7 @@ export function generateCompose({ cfg }: ComposeOptions): string {
224
224
  lines.push(" - \"3100\" # HTTP API");
225
225
  lines.push("");
226
226
 
227
+ const metricsRetention = cfg.observability.retention?.metrics ?? "30d";
227
228
  lines.push(" prometheus:");
228
229
  lines.push(" image: prom/prometheus:v2.55.1");
229
230
  lines.push(" container_name: arc-prometheus");
@@ -231,6 +232,7 @@ export function generateCompose({ cfg }: ComposeOptions): string {
231
232
  lines.push(" command:");
232
233
  lines.push(" - \"--config.file=/etc/prometheus/prometheus.yml\"");
233
234
  lines.push(" - \"--storage.tsdb.path=/prometheus\"");
235
+ lines.push(` - "--storage.tsdb.retention.time=${metricsRetention}"`);
234
236
  lines.push(" - \"--web.enable-remote-write-receiver\"");
235
237
  lines.push(" - \"--enable-feature=exemplar-storage\"");
236
238
  lines.push(" volumes:");
@@ -259,6 +261,16 @@ export function generateCompose({ cfg }: ComposeOptions): string {
259
261
  lines.push(
260
262
  " - ./observability/grafana-datasources.yaml:/etc/grafana/provisioning/datasources/datasources.yaml:ro",
261
263
  );
264
+ // Dashboard auto-provisioning: a single provider config points at the
265
+ // bind-mounted /arc directory; each JSON in that dir becomes a live
266
+ // dashboard inside the "Arc" folder. Updates land within the
267
+ // provider's `updateIntervalSeconds` (30s).
268
+ lines.push(
269
+ " - ./observability/grafana-dashboards.yaml:/etc/grafana/provisioning/dashboards/dashboards.yaml:ro",
270
+ );
271
+ lines.push(
272
+ " - ./observability/grafana-dashboards:/etc/grafana/provisioning/dashboards/arc:ro",
273
+ );
262
274
  lines.push(" - grafana_data:/var/lib/grafana");
263
275
  lines.push(" networks: [arc-net]");
264
276
  lines.push(" expose:");
@@ -26,3 +26,23 @@ export async function generateHtpasswd(
26
26
  });
27
27
  return `${user}:${hash}\n`;
28
28
  }
29
+
30
+ /**
31
+ * Caddy's `basic_auth` directive takes a different format than the
32
+ * registry-style htpasswd file: `<user> <bcrypt-hash>` (space-separated,
33
+ * no colon). Newer Caddy syntax also accepts a quoted hash, but the
34
+ * unquoted bcrypt string works in both 2.7 and 2.8+.
35
+ */
36
+ export async function generateCaddyBasicAuthLine(
37
+ user: string,
38
+ password: string,
39
+ ): Promise<string> {
40
+ if (!user || !password) {
41
+ throw new Error("caddy basic_auth: user and password must both be non-empty");
42
+ }
43
+ const hash = await Bun.password.hash(password, {
44
+ algorithm: "bcrypt",
45
+ cost: 10,
46
+ });
47
+ return `${user} ${hash}\n`;
48
+ }