@arcote.tech/arc-cli 0.7.3 → 0.7.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/dist/index.js +7782 -308
- package/package.json +8 -7
- package/src/builder/build-cache.ts +27 -8
- package/src/commands/platform-deploy.ts +13 -0
- package/src/deploy/bootstrap.ts +42 -0
- package/src/deploy/compose.ts +87 -17
- package/src/deploy/config.ts +32 -1
- package/src/deploy/env-file.ts +35 -1
- package/src/platform/server.ts +32 -13
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@arcote.tech/arc-cli",
|
|
3
|
-
"version": "0.7.
|
|
3
|
+
"version": "0.7.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.7.
|
|
16
|
-
"@arcote.tech/arc-ds": "^0.7.
|
|
17
|
-
"@arcote.tech/arc-react": "^0.7.
|
|
18
|
-
"@arcote.tech/arc-host": "^0.7.
|
|
19
|
-
"@arcote.tech/arc-adapter-db-sqlite": "^0.7.
|
|
20
|
-
"@arcote.tech/
|
|
15
|
+
"@arcote.tech/arc": "^0.7.5",
|
|
16
|
+
"@arcote.tech/arc-ds": "^0.7.5",
|
|
17
|
+
"@arcote.tech/arc-react": "^0.7.5",
|
|
18
|
+
"@arcote.tech/arc-host": "^0.7.5",
|
|
19
|
+
"@arcote.tech/arc-adapter-db-sqlite": "^0.7.5",
|
|
20
|
+
"@arcote.tech/arc-adapter-db-postgres": "^0.7.5",
|
|
21
|
+
"@arcote.tech/platform": "^0.7.5",
|
|
21
22
|
"@clack/prompts": "^0.9.0",
|
|
22
23
|
"commander": "^11.1.0",
|
|
23
24
|
"chokidar": "^3.5.3",
|
|
@@ -1,11 +1,30 @@
|
|
|
1
1
|
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "fs";
|
|
2
|
-
import { join } from "path";
|
|
2
|
+
import { join, dirname } from "path";
|
|
3
|
+
import { fileURLToPath } from "url";
|
|
3
4
|
|
|
4
|
-
//
|
|
5
|
-
|
|
6
|
-
const CACHE_VERSION = 2;
|
|
5
|
+
// Schema version — bump if cache entry shape changes.
|
|
6
|
+
const CACHE_SCHEMA_VERSION = 3;
|
|
7
7
|
const CACHE_FILE = ".build-cache.json";
|
|
8
8
|
|
|
9
|
+
/**
|
|
10
|
+
* Combined cache fingerprint: schema version + CLI version. Lets a newer CLI
|
|
11
|
+
* automatically invalidate dist/ produced by an older CLI even when source
|
|
12
|
+
* hashes match. Without this, a build-logic change in the CLI (e.g.
|
|
13
|
+
* externalization rules in buildContextClient) silently kept stale dist/
|
|
14
|
+
* around — see the v0.7.3 "creatorWorkspaces not found in context" incident.
|
|
15
|
+
*/
|
|
16
|
+
function readCliVersion(): string {
|
|
17
|
+
try {
|
|
18
|
+
// Bundled CLI lives at `<pkg>/dist/index.js`; package.json is one level up.
|
|
19
|
+
const here = dirname(fileURLToPath(import.meta.url));
|
|
20
|
+
const pkg = JSON.parse(readFileSync(join(here, "..", "package.json"), "utf-8"));
|
|
21
|
+
return typeof pkg.version === "string" ? pkg.version : "unknown";
|
|
22
|
+
} catch {
|
|
23
|
+
return "unknown";
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
const CACHE_FINGERPRINT = `${CACHE_SCHEMA_VERSION}:${readCliVersion()}`;
|
|
27
|
+
|
|
9
28
|
export interface CacheEntry {
|
|
10
29
|
inputHash: string;
|
|
11
30
|
outputHash?: string;
|
|
@@ -13,22 +32,22 @@ export interface CacheEntry {
|
|
|
13
32
|
}
|
|
14
33
|
|
|
15
34
|
export interface BuildCache {
|
|
16
|
-
version:
|
|
35
|
+
version: string;
|
|
17
36
|
units: Record<string, CacheEntry>;
|
|
18
37
|
}
|
|
19
38
|
|
|
20
39
|
function emptyCache(): BuildCache {
|
|
21
|
-
return { version:
|
|
40
|
+
return { version: CACHE_FINGERPRINT, units: {} };
|
|
22
41
|
}
|
|
23
42
|
|
|
24
43
|
/** Loads cache from .arc/platform/.build-cache.json. Returns empty cache on
|
|
25
|
-
* missing file, parse error, or
|
|
44
|
+
* missing file, parse error, or fingerprint mismatch (schema OR CLI version). */
|
|
26
45
|
export function loadBuildCache(arcDir: string): BuildCache {
|
|
27
46
|
const path = join(arcDir, CACHE_FILE);
|
|
28
47
|
if (!existsSync(path)) return emptyCache();
|
|
29
48
|
try {
|
|
30
49
|
const raw = JSON.parse(readFileSync(path, "utf-8"));
|
|
31
|
-
if (raw?.version !==
|
|
50
|
+
if (raw?.version !== CACHE_FINGERPRINT || typeof raw.units !== "object") {
|
|
32
51
|
return emptyCache();
|
|
33
52
|
}
|
|
34
53
|
return raw as BuildCache;
|
|
@@ -2,12 +2,14 @@ import { existsSync, readFileSync } from "fs";
|
|
|
2
2
|
import { dirname, join } from "path";
|
|
3
3
|
import { fileURLToPath } from "url";
|
|
4
4
|
import { bootstrap } from "../deploy/bootstrap";
|
|
5
|
+
import { postgresEnvs } from "../deploy/compose";
|
|
5
6
|
import {
|
|
6
7
|
deployConfigExists,
|
|
7
8
|
loadDeployConfig,
|
|
8
9
|
saveDeployConfig,
|
|
9
10
|
} from "../deploy/config";
|
|
10
11
|
import { updateEnvDeployment } from "../deploy/deploy-env";
|
|
12
|
+
import { ensurePersistedSecret } from "../deploy/env-file";
|
|
11
13
|
import { buildImage, sanitizeImageName } from "../deploy/image";
|
|
12
14
|
import { detectRemoteState } from "../deploy/remote-state";
|
|
13
15
|
import { dockerLogin, dockerPush } from "../deploy/registry";
|
|
@@ -74,6 +76,17 @@ export async function platformDeploy(
|
|
|
74
76
|
}
|
|
75
77
|
cfg = loadDeployConfig(ws.rootDir);
|
|
76
78
|
|
|
79
|
+
// For every env opted into postgres, make sure the sidecar password is
|
|
80
|
+
// materialised in `deploy.arc.<env>.env` (gitignored) and in process.env.
|
|
81
|
+
// Compose generation interpolates `${ARC_PG_PASSWORD_<UPPER>}` later; this
|
|
82
|
+
// step has to happen before bootstrap renders + uploads compose.yml.
|
|
83
|
+
const pgEnvs = postgresEnvs(cfg);
|
|
84
|
+
for (const pg of pgEnvs) {
|
|
85
|
+
ensurePersistedSecret(ws.rootDir, pg.name, pg.passwordKey, () =>
|
|
86
|
+
crypto.randomUUID().replace(/-/g, ""),
|
|
87
|
+
);
|
|
88
|
+
}
|
|
89
|
+
|
|
77
90
|
// Filter envs if an arg was provided
|
|
78
91
|
const targetEnvs = envArg
|
|
79
92
|
? envArg in cfg.envs
|
package/src/deploy/bootstrap.ts
CHANGED
|
@@ -160,6 +160,31 @@ export async function bootstrap(inputs: BootstrapInputs): Promise<void> {
|
|
|
160
160
|
* Used by bootstrap to detect legacy v0.5 stacks that have no registry
|
|
161
161
|
* container and need a fresh stack write + restart.
|
|
162
162
|
*/
|
|
163
|
+
/**
|
|
164
|
+
* Atomically set `KEY=value` in a remote `.env`-style file. Replaces an
|
|
165
|
+
* existing line with the same key or appends one. Same awk pattern used by
|
|
166
|
+
* deploy-env.ts to update ARC_IMAGE_<ENV>; values are shell-escaped so
|
|
167
|
+
* passwords containing `$`, `"`, etc. survive intact.
|
|
168
|
+
*/
|
|
169
|
+
async function writeEnvLine(
|
|
170
|
+
target: DeployTarget,
|
|
171
|
+
envPath: string,
|
|
172
|
+
key: string,
|
|
173
|
+
value: string,
|
|
174
|
+
): Promise<void> {
|
|
175
|
+
const escapedValue = value.replace(/"/g, '\\"').replace(/\$/g, "\\$");
|
|
176
|
+
const script = [
|
|
177
|
+
`touch ${envPath} && `,
|
|
178
|
+
`awk -v line="${key}=${escapedValue}" -v key="${key}=" '`,
|
|
179
|
+
` BEGIN { replaced=0 } `,
|
|
180
|
+
` $0 ~ "^"key { print line; replaced=1; next } `,
|
|
181
|
+
` { print } `,
|
|
182
|
+
` END { if (!replaced) print line } `,
|
|
183
|
+
`' ${envPath} > ${envPath}.tmp && mv ${envPath}.tmp ${envPath}`,
|
|
184
|
+
].join("");
|
|
185
|
+
await assertExec(target, script);
|
|
186
|
+
}
|
|
187
|
+
|
|
163
188
|
async function isRegistryRunning(cfg: DeployConfig): Promise<boolean> {
|
|
164
189
|
const res = await sshExec(
|
|
165
190
|
cfg.target,
|
|
@@ -239,6 +264,23 @@ async function upStack(inputs: BootstrapInputs): Promise<void> {
|
|
|
239
264
|
`touch ${cfg.target.remoteDir}/.env`,
|
|
240
265
|
);
|
|
241
266
|
|
|
267
|
+
// Propagate postgres sidecar passwords to /opt/arc/.env so compose can
|
|
268
|
+
// interpolate `${ARC_PG_PASSWORD_<UPPER>}` for both the pg container and
|
|
269
|
+
// arc-<env>'s DATABASE_URL. Local source is `deploy.arc.<env>.env` /
|
|
270
|
+
// process.env (populated by `ensurePersistedSecret` in platform-deploy).
|
|
271
|
+
// Idempotent: re-runs with the same value are no-ops.
|
|
272
|
+
for (const [name, env] of Object.entries(cfg.envs)) {
|
|
273
|
+
if (env.db?.type !== "postgres") continue;
|
|
274
|
+
const key = `ARC_PG_PASSWORD_${name.toUpperCase().replace(/-/g, "_")}`;
|
|
275
|
+
const value = process.env[key];
|
|
276
|
+
if (!value) {
|
|
277
|
+
throw new Error(
|
|
278
|
+
`bootstrap: ${key} not set — postgres env "${name}" requires a password`,
|
|
279
|
+
);
|
|
280
|
+
}
|
|
281
|
+
await writeEnvLine(cfg.target, `${cfg.target.remoteDir}/.env`, key, value);
|
|
282
|
+
}
|
|
283
|
+
|
|
242
284
|
// Pre-register the deploy user with the private registry so containers can
|
|
243
285
|
// pull. We do this AFTER `docker compose up -d caddy registry` runs (below)
|
|
244
286
|
// — the registry needs to be reachable for `docker login` to succeed.
|
package/src/deploy/compose.ts
CHANGED
|
@@ -3,13 +3,18 @@ import type { DeployConfig } from "./config";
|
|
|
3
3
|
// ---------------------------------------------------------------------------
|
|
4
4
|
// docker-compose.yml generator
|
|
5
5
|
//
|
|
6
|
-
// Services:
|
|
7
|
-
// - caddy
|
|
8
|
-
// -
|
|
6
|
+
// Services emitted, in order:
|
|
7
|
+
// - caddy (public 80/443, loopback 127.0.0.1:2019 for deploy tunnel)
|
|
8
|
+
// - registry (private image store on arc-net, fronted by Caddy auth)
|
|
9
|
+
// - arc-<env> (one per entry in deploy.arc.json envs)
|
|
10
|
+
// - arc-db-<env> (one per env that opts into `db: { type: "postgres" }`)
|
|
9
11
|
//
|
|
10
|
-
//
|
|
11
|
-
//
|
|
12
|
-
//
|
|
12
|
+
// SQLite envs persist `/app/.arc/data` in a named volume per env. Postgres
|
|
13
|
+
// envs run a `postgres:16-alpine` sidecar on the docker network — the arc
|
|
14
|
+
// container connects via `DATABASE_URL`, no port is exposed to the host.
|
|
15
|
+
//
|
|
16
|
+
// No custom images: vanilla `caddy:2-alpine`, `registry:2`, `postgres:*`,
|
|
17
|
+
// and `oven/bun:1-alpine` from Docker Hub.
|
|
13
18
|
// ---------------------------------------------------------------------------
|
|
14
19
|
|
|
15
20
|
export interface ComposeOptions {
|
|
@@ -48,8 +53,6 @@ export function generateCompose({ cfg }: ComposeOptions): string {
|
|
|
48
53
|
lines.push(" REGISTRY_AUTH: htpasswd");
|
|
49
54
|
lines.push(' REGISTRY_AUTH_HTPASSWD_REALM: "Arc Registry"');
|
|
50
55
|
lines.push(" REGISTRY_AUTH_HTPASSWD_PATH: /auth/htpasswd");
|
|
51
|
-
// Large image layers (framework peers + arc-cli bundle + chunks) need a
|
|
52
|
-
// generous upload limit. Default 100MB triggers 413 on real apps.
|
|
53
56
|
lines.push(' REGISTRY_HTTP_HOST: "https://' + cfg.registry.domain + '"');
|
|
54
57
|
lines.push(" networks:");
|
|
55
58
|
lines.push(" - arc-net");
|
|
@@ -58,7 +61,8 @@ export function generateCompose({ cfg }: ComposeOptions): string {
|
|
|
58
61
|
lines.push("");
|
|
59
62
|
|
|
60
63
|
for (const [name, env] of Object.entries(cfg.envs)) {
|
|
61
|
-
const upperName = name
|
|
64
|
+
const upperName = upperEnvName(name);
|
|
65
|
+
const usePostgres = env.db?.type === "postgres";
|
|
62
66
|
lines.push(` arc-${name}:`);
|
|
63
67
|
// Image ref comes from /opt/arc/.env, written per-deploy with the content
|
|
64
68
|
// hash of the latest build. Default to a placeholder so `docker compose
|
|
@@ -69,18 +73,34 @@ export function generateCompose({ cfg }: ComposeOptions): string {
|
|
|
69
73
|
);
|
|
70
74
|
lines.push(` container_name: arc-${name}`);
|
|
71
75
|
lines.push(" restart: unless-stopped");
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
+
if (usePostgres) {
|
|
77
|
+
lines.push(" depends_on:");
|
|
78
|
+
lines.push(` arc-db-${name}:`);
|
|
79
|
+
lines.push(" condition: service_healthy");
|
|
80
|
+
}
|
|
81
|
+
// SQLite envs need a writable volume for `/app/.arc/data`. Postgres
|
|
82
|
+
// envs keep their state in the sidecar volume; no host-side bind needed.
|
|
83
|
+
if (!usePostgres) {
|
|
84
|
+
lines.push(" volumes:");
|
|
85
|
+
lines.push(` - arc-data-${name}:/app/.arc/data`);
|
|
86
|
+
}
|
|
76
87
|
lines.push(" environment:");
|
|
77
88
|
lines.push(" PORT: 5005");
|
|
78
89
|
const userEnv = env.envVars ?? {};
|
|
79
90
|
if (!("NODE_ENV" in userEnv)) {
|
|
80
91
|
lines.push(" NODE_ENV: production");
|
|
81
92
|
}
|
|
82
|
-
|
|
83
|
-
|
|
93
|
+
if (usePostgres) {
|
|
94
|
+
// Connection string follows the docker-internal DNS name + the
|
|
95
|
+
// password sourced from `deploy.arc.<env>.env`. `arc platform deploy`
|
|
96
|
+
// ensures `ARC_PG_PASSWORD_<UPPER>` exists in that file before
|
|
97
|
+
// assembling compose, so the `${VAR:?}` interpolation never fails.
|
|
98
|
+
lines.push(
|
|
99
|
+
` DATABASE_URL: "postgresql://arc:\${ARC_PG_PASSWORD_${upperName}:?missing ARC_PG_PASSWORD_${upperName}}@arc-db-${name}:5432/arc"`,
|
|
100
|
+
);
|
|
101
|
+
}
|
|
102
|
+
// PORT + DATABASE_URL are reserved — user envVars can't override them.
|
|
103
|
+
const reserved = new Set(["PORT", "DATABASE_URL"]);
|
|
84
104
|
for (const [k, v] of Object.entries(userEnv)) {
|
|
85
105
|
if (reserved.has(k)) continue;
|
|
86
106
|
lines.push(` ${k}: ${JSON.stringify(v)}`);
|
|
@@ -93,6 +113,36 @@ export function generateCompose({ cfg }: ComposeOptions): string {
|
|
|
93
113
|
lines.push("");
|
|
94
114
|
}
|
|
95
115
|
|
|
116
|
+
// Postgres sidecars (after the arc services so the YAML reads top-down:
|
|
117
|
+
// app -> its database). One service per opted-in env.
|
|
118
|
+
for (const [name, env] of Object.entries(cfg.envs)) {
|
|
119
|
+
if (env.db?.type !== "postgres") continue;
|
|
120
|
+
const upperName = upperEnvName(name);
|
|
121
|
+
const image = env.db.image ?? "postgres:16-alpine";
|
|
122
|
+
lines.push(` arc-db-${name}:`);
|
|
123
|
+
lines.push(` image: ${image}`);
|
|
124
|
+
lines.push(` container_name: arc-db-${name}`);
|
|
125
|
+
lines.push(" restart: unless-stopped");
|
|
126
|
+
lines.push(" environment:");
|
|
127
|
+
lines.push(" POSTGRES_USER: arc");
|
|
128
|
+
lines.push(" POSTGRES_DB: arc");
|
|
129
|
+
lines.push(
|
|
130
|
+
` POSTGRES_PASSWORD: \${ARC_PG_PASSWORD_${upperName}:?missing ARC_PG_PASSWORD_${upperName}}`,
|
|
131
|
+
);
|
|
132
|
+
lines.push(" volumes:");
|
|
133
|
+
lines.push(` - arc-pgdata-${name}:/var/lib/postgresql/data`);
|
|
134
|
+
lines.push(" healthcheck:");
|
|
135
|
+
lines.push(' test: ["CMD-SHELL", "pg_isready -U arc -d arc"]');
|
|
136
|
+
lines.push(" interval: 5s");
|
|
137
|
+
lines.push(" timeout: 3s");
|
|
138
|
+
lines.push(" retries: 20");
|
|
139
|
+
lines.push(" networks:");
|
|
140
|
+
lines.push(" - arc-net");
|
|
141
|
+
// No `ports:` — the database is only reachable from other containers
|
|
142
|
+
// on `arc-net`. Exposing 5432 to the host would defeat the isolation.
|
|
143
|
+
lines.push("");
|
|
144
|
+
}
|
|
145
|
+
|
|
96
146
|
lines.push("networks:");
|
|
97
147
|
lines.push(" arc-net:");
|
|
98
148
|
lines.push("");
|
|
@@ -100,9 +150,29 @@ export function generateCompose({ cfg }: ComposeOptions): string {
|
|
|
100
150
|
lines.push(" caddy_data:");
|
|
101
151
|
lines.push(" caddy_config:");
|
|
102
152
|
lines.push(" registry_data:");
|
|
103
|
-
for (const [name] of Object.entries(cfg.envs)) {
|
|
104
|
-
|
|
153
|
+
for (const [name, env] of Object.entries(cfg.envs)) {
|
|
154
|
+
if (env.db?.type === "postgres") {
|
|
155
|
+
lines.push(` arc-pgdata-${name}:`);
|
|
156
|
+
} else {
|
|
157
|
+
lines.push(` arc-data-${name}:`);
|
|
158
|
+
}
|
|
105
159
|
}
|
|
106
160
|
|
|
107
161
|
return lines.join("\n") + "\n";
|
|
108
162
|
}
|
|
163
|
+
|
|
164
|
+
function upperEnvName(name: string): string {
|
|
165
|
+
return name.toUpperCase().replace(/-/g, "_");
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/** Env names whose `deploy.arc.<env>.env` file must contain a generated
|
|
169
|
+
* `ARC_PG_PASSWORD_<UPPER>` before compose can be rendered. */
|
|
170
|
+
export function postgresEnvs(cfg: DeployConfig): { name: string; passwordKey: string }[] {
|
|
171
|
+
const out: { name: string; passwordKey: string }[] = [];
|
|
172
|
+
for (const [name, env] of Object.entries(cfg.envs)) {
|
|
173
|
+
if (env.db?.type !== "postgres") continue;
|
|
174
|
+
out.push({ name, passwordKey: `ARC_PG_PASSWORD_${upperEnvName(name)}` });
|
|
175
|
+
}
|
|
176
|
+
return out;
|
|
177
|
+
}
|
|
178
|
+
|
package/src/deploy/config.ts
CHANGED
|
@@ -22,11 +22,24 @@ export interface DeployTarget {
|
|
|
22
22
|
sshKey?: string;
|
|
23
23
|
}
|
|
24
24
|
|
|
25
|
+
/**
|
|
26
|
+
* Database choice per environment. Omit to keep the historical default
|
|
27
|
+
* (SQLite in a named volume at `/app/.arc/data`). Selecting `postgres`
|
|
28
|
+
* makes `arc platform deploy` provision a `postgres:16-alpine` sidecar
|
|
29
|
+
* service on the docker network, persist its data in
|
|
30
|
+
* `arc-pgdata-<envName>`, and inject `DATABASE_URL` into the arc container.
|
|
31
|
+
*/
|
|
32
|
+
export type DeployEnvDb =
|
|
33
|
+
| { type: "sqlite" }
|
|
34
|
+
| { type: "postgres"; image?: string };
|
|
35
|
+
|
|
25
36
|
export interface DeployEnv {
|
|
26
37
|
/** Subdomain or full domain routed by Caddy. */
|
|
27
38
|
domain: string;
|
|
28
39
|
/** Extra env vars passed to the arc container. */
|
|
29
40
|
envVars?: Record<string, string>;
|
|
41
|
+
/** Optional storage backend selector. Default = sqlite. */
|
|
42
|
+
db?: DeployEnvDb;
|
|
30
43
|
}
|
|
31
44
|
|
|
32
45
|
export interface DeployCaddy {
|
|
@@ -247,7 +260,25 @@ export function validateDeployConfig(input: unknown): DeployConfig {
|
|
|
247
260
|
envVars[k] = v;
|
|
248
261
|
}
|
|
249
262
|
}
|
|
250
|
-
|
|
263
|
+
const dbRaw = (env as Record<string, unknown>).db;
|
|
264
|
+
let db: DeployEnvDb | undefined;
|
|
265
|
+
if (dbRaw !== undefined) {
|
|
266
|
+
if (!isObject(dbRaw)) throw cfgErr(`envs.${name}.db`, "object");
|
|
267
|
+
const dbType = requireString(dbRaw, `envs.${name}.db.type`);
|
|
268
|
+
if (dbType === "sqlite") {
|
|
269
|
+
db = { type: "sqlite" };
|
|
270
|
+
} else if (dbType === "postgres") {
|
|
271
|
+
db = {
|
|
272
|
+
type: "postgres",
|
|
273
|
+
image: optionalString(dbRaw, `envs.${name}.db.image`),
|
|
274
|
+
};
|
|
275
|
+
} else {
|
|
276
|
+
throw new Error(
|
|
277
|
+
`deploy.arc.json: envs.${name}.db.type must be "sqlite" or "postgres" (got "${dbType}")`,
|
|
278
|
+
);
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
validated.envs[name] = { domain, envVars, db };
|
|
251
282
|
}
|
|
252
283
|
|
|
253
284
|
const provision = (input as Record<string, unknown>).provision;
|
package/src/deploy/env-file.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { existsSync, readFileSync } from "fs";
|
|
1
|
+
import { appendFileSync, existsSync, readFileSync } from "fs";
|
|
2
2
|
import { join } from "path";
|
|
3
3
|
|
|
4
4
|
// ---------------------------------------------------------------------------
|
|
@@ -57,6 +57,40 @@ export function applyDeployGlobals(globals: Record<string, string>): void {
|
|
|
57
57
|
}
|
|
58
58
|
}
|
|
59
59
|
|
|
60
|
+
/**
|
|
61
|
+
* Ensure a value exists for `key` in `deploy.arc.<envName>.env`. If absent,
|
|
62
|
+
* generate one via `generate()`, append it to the file (creating the file
|
|
63
|
+
* if needed), and return it. Pre-existing values (from the file or
|
|
64
|
+
* process.env) are returned unchanged.
|
|
65
|
+
*
|
|
66
|
+
* Used to bootstrap secrets the framework owns end-to-end (e.g. the
|
|
67
|
+
* Postgres sidecar password) without forcing the user to hand-edit the
|
|
68
|
+
* file before the first deploy.
|
|
69
|
+
*/
|
|
70
|
+
export function ensurePersistedSecret(
|
|
71
|
+
rootDir: string,
|
|
72
|
+
envName: string,
|
|
73
|
+
key: string,
|
|
74
|
+
generate: () => string,
|
|
75
|
+
): string {
|
|
76
|
+
if (process.env[key]) return process.env[key]!;
|
|
77
|
+
|
|
78
|
+
const path = join(rootDir, `deploy.arc.${envName}.env`);
|
|
79
|
+
if (existsSync(path)) {
|
|
80
|
+
const existing = parseEnvFile(readFileSync(path, "utf-8"), path);
|
|
81
|
+
if (existing[key]) {
|
|
82
|
+
process.env[key] = existing[key];
|
|
83
|
+
return existing[key];
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const value = generate();
|
|
88
|
+
const prefix = existsSync(path) && !readFileSync(path, "utf-8").endsWith("\n") ? "\n" : "";
|
|
89
|
+
appendFileSync(path, `${prefix}${key}=${value}\n`);
|
|
90
|
+
process.env[key] = value;
|
|
91
|
+
return value;
|
|
92
|
+
}
|
|
93
|
+
|
|
60
94
|
// ---------------------------------------------------------------------------
|
|
61
95
|
// Parser
|
|
62
96
|
// ---------------------------------------------------------------------------
|
package/src/platform/server.ts
CHANGED
|
@@ -52,17 +52,39 @@ export async function initContextHandler(
|
|
|
52
52
|
context: any,
|
|
53
53
|
dbPath: string,
|
|
54
54
|
): Promise<ContextHandler> {
|
|
55
|
+
const factory = await resolveDbAdapterFactory(dbPath);
|
|
56
|
+
const dbAdapter = factory(context);
|
|
57
|
+
const handler = new ContextHandler(context, dbAdapter);
|
|
58
|
+
await handler.init();
|
|
59
|
+
return handler;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Pick the database adapter factory based on runtime configuration.
|
|
64
|
+
*
|
|
65
|
+
* - When `DATABASE_URL` is set (Postgres sidecar generated by deploy, or any
|
|
66
|
+
* external Postgres the operator wired up themselves), connect over the
|
|
67
|
+
* network using the postgres adapter.
|
|
68
|
+
* - Otherwise fall back to the SQLite file at `dbPath`. The directory is
|
|
69
|
+
* created on demand so first-boot in a fresh data volume just works.
|
|
70
|
+
*
|
|
71
|
+
* Both adapters share the `DBAdapterFactory` contract from arc-core, so the
|
|
72
|
+
* caller never needs to know which backend is live.
|
|
73
|
+
*/
|
|
74
|
+
async function resolveDbAdapterFactory(dbPath: string) {
|
|
75
|
+
const databaseUrl = process.env.DATABASE_URL;
|
|
76
|
+
if (databaseUrl && databaseUrl.length > 0) {
|
|
77
|
+
const { createPostgreSQLAdapterFactoryFromUrl } = await import(
|
|
78
|
+
"@arcote.tech/arc-adapter-db-postgres"
|
|
79
|
+
);
|
|
80
|
+
return createPostgreSQLAdapterFactoryFromUrl(databaseUrl);
|
|
81
|
+
}
|
|
55
82
|
const { createBunSQLiteAdapterFactory } = await import(
|
|
56
83
|
"@arcote.tech/arc-adapter-db-sqlite"
|
|
57
84
|
);
|
|
58
|
-
|
|
59
85
|
const dbDir = dbPath.substring(0, dbPath.lastIndexOf("/"));
|
|
60
86
|
if (dbDir) mkdirSync(dbDir, { recursive: true });
|
|
61
|
-
|
|
62
|
-
const dbAdapter = createBunSQLiteAdapterFactory(dbPath)(context);
|
|
63
|
-
const handler = new ContextHandler(context, dbAdapter);
|
|
64
|
-
await handler.init();
|
|
65
|
-
return handler;
|
|
87
|
+
return createBunSQLiteAdapterFactory(dbPath);
|
|
66
88
|
}
|
|
67
89
|
|
|
68
90
|
// ---------------------------------------------------------------------------
|
|
@@ -536,17 +558,14 @@ export async function startPlatformServer(
|
|
|
536
558
|
};
|
|
537
559
|
}
|
|
538
560
|
|
|
539
|
-
// Context available — use createArcServer with platform handlers on top
|
|
540
|
-
|
|
541
|
-
"@arcote.tech/arc-adapter-db-sqlite"
|
|
542
|
-
);
|
|
561
|
+
// Context available — use createArcServer with platform handlers on top.
|
|
562
|
+
// `resolveDbAdapterFactory` picks SQLite or Postgres based on DATABASE_URL.
|
|
543
563
|
const dbPath = opts.dbPath || join(ws.arcDir, "data", "arc.db");
|
|
544
|
-
const
|
|
545
|
-
if (dbDir) mkdirSync(dbDir, { recursive: true });
|
|
564
|
+
const dbAdapterFactory = await resolveDbAdapterFactory(dbPath);
|
|
546
565
|
|
|
547
566
|
const arcServer = await createArcServer({
|
|
548
567
|
context,
|
|
549
|
-
dbAdapterFactory
|
|
568
|
+
dbAdapterFactory,
|
|
550
569
|
port,
|
|
551
570
|
httpHandlers: [
|
|
552
571
|
// Platform-specific handlers (checked AFTER arc handlers)
|