@arcote.tech/arc-cli 0.6.1 → 0.7.0
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 +1214 -1217
- package/package.json +7 -7
- package/src/builder/access-extractor.ts +79 -47
- package/src/builder/build-cache.ts +3 -1
- package/src/builder/chunk-planner.ts +107 -0
- package/src/builder/dependency-collector.ts +86 -32
- package/src/builder/framework-peers.ts +81 -0
- package/src/builder/module-builder.ts +186 -110
- package/src/commands/platform-deploy.ts +103 -55
- package/src/commands/platform-dev.ts +11 -100
- package/src/commands/platform-start.ts +4 -90
- package/src/deploy/bootstrap.ts +157 -6
- package/src/deploy/caddyfile.ts +19 -23
- package/src/deploy/compose.ts +43 -27
- package/src/deploy/config.ts +29 -0
- package/src/deploy/deploy-env.ts +129 -0
- package/src/deploy/htpasswd.ts +28 -0
- package/src/deploy/image-template.ts +74 -0
- package/src/deploy/image.ts +237 -0
- package/src/deploy/registry.ts +79 -0
- package/src/deploy/ssh.ts +5 -124
- package/src/deploy/survey.ts +64 -0
- package/src/index.ts +15 -13
- package/src/platform/server.ts +69 -44
- package/src/platform/shared.ts +124 -65
- package/src/platform/startup.ts +160 -0
- package/runtime/Dockerfile +0 -29
- package/runtime/build-and-push.sh +0 -23
- package/runtime/entrypoint.sh +0 -58
- package/src/commands/build-shell.ts +0 -152
- package/src/deploy/remote-sync.ts +0 -323
- package/src/platform/deploy-api.ts +0 -396
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
import type { DeployConfig, DeployTarget } from "./config";
|
|
2
|
+
import { assertExec, sshExec } from "./ssh";
|
|
3
|
+
|
|
4
|
+
// ---------------------------------------------------------------------------
|
|
5
|
+
// deploy-env — per-env update flow. Called once per env after the image is
|
|
6
|
+
// pushed to the private registry.
|
|
7
|
+
//
|
|
8
|
+
// Sequence:
|
|
9
|
+
// 1. Atomically update /opt/arc/.env line `ARC_IMAGE_<ENV>=<fullRef>` so
|
|
10
|
+
// docker compose pulls the new ref on next up.
|
|
11
|
+
// 2. `docker compose pull arc-<env>` — fetches new image layers.
|
|
12
|
+
// 3. `docker compose up -d arc-<env>` — recreates the container.
|
|
13
|
+
// 4. Prune older image tags on the host (keep N most recent).
|
|
14
|
+
// 5. Health-check via SSH (curl localhost via internal Docker network).
|
|
15
|
+
//
|
|
16
|
+
// No SSH tunnel, no rsync, no HTTP API. Compose.yml on the host stays static
|
|
17
|
+
// — only `.env` changes per deploy. Idempotent: running twice with the same
|
|
18
|
+
// fullRef is a no-op once docker detects the image is up to date.
|
|
19
|
+
// ---------------------------------------------------------------------------
|
|
20
|
+
|
|
21
|
+
export interface UpdateEnvDeploymentOptions {
|
|
22
|
+
target: DeployTarget;
|
|
23
|
+
cfg: DeployConfig;
|
|
24
|
+
env: string;
|
|
25
|
+
fullRef: string;
|
|
26
|
+
/** How many image tags to keep per workspace (oldest pruned). Default 3. */
|
|
27
|
+
retainImages?: number;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export interface UpdateEnvDeploymentResult {
|
|
31
|
+
env: string;
|
|
32
|
+
fullRef: string;
|
|
33
|
+
/** True if `docker compose up -d` actually recreated the container (i.e. image changed). */
|
|
34
|
+
redeployed: boolean;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const HEALTH_RETRIES = 10;
|
|
38
|
+
const HEALTH_DELAY_MS = 1000;
|
|
39
|
+
|
|
40
|
+
export async function updateEnvDeployment(
|
|
41
|
+
opts: UpdateEnvDeploymentOptions,
|
|
42
|
+
): Promise<UpdateEnvDeploymentResult> {
|
|
43
|
+
const { target, cfg, env, fullRef } = opts;
|
|
44
|
+
const upperEnv = env.toUpperCase().replace(/-/g, "_");
|
|
45
|
+
const envVarName = `ARC_IMAGE_${upperEnv}`;
|
|
46
|
+
|
|
47
|
+
// Step 1 — atomic .env line update. Use awk to either replace the existing
|
|
48
|
+
// line or append a new one, write to .env.tmp, then mv. mv on the same fs
|
|
49
|
+
// is atomic, so concurrent reads see either the old or new file, never a
|
|
50
|
+
// partial write.
|
|
51
|
+
const envPath = `${cfg.target.remoteDir}/.env`;
|
|
52
|
+
const escapedRef = fullRef.replace(/"/g, '\\"');
|
|
53
|
+
const updateScript = [
|
|
54
|
+
`touch ${envPath}`,
|
|
55
|
+
`awk -v line="${envVarName}=${escapedRef}" -v key="${envVarName}=" '`,
|
|
56
|
+
` BEGIN { replaced=0 } `,
|
|
57
|
+
` $0 ~ "^"key { print line; replaced=1; next } `,
|
|
58
|
+
` { print } `,
|
|
59
|
+
` END { if (!replaced) print line } `,
|
|
60
|
+
`' ${envPath} > ${envPath}.tmp && mv ${envPath}.tmp ${envPath}`,
|
|
61
|
+
].join("");
|
|
62
|
+
await assertExec(target, updateScript);
|
|
63
|
+
|
|
64
|
+
// Step 2 — pull. May be a no-op if `fullRef` was already pulled previously.
|
|
65
|
+
await assertExec(
|
|
66
|
+
target,
|
|
67
|
+
`cd ${cfg.target.remoteDir} && docker compose pull arc-${env}`,
|
|
68
|
+
);
|
|
69
|
+
|
|
70
|
+
// Step 3 — recreate. `up -d` is a no-op if the container is already running
|
|
71
|
+
// with the requested image; otherwise it recreates. Either way the container
|
|
72
|
+
// ends in "running" state with the desired image.
|
|
73
|
+
await assertExec(
|
|
74
|
+
target,
|
|
75
|
+
`cd ${cfg.target.remoteDir} && docker compose up -d arc-${env}`,
|
|
76
|
+
);
|
|
77
|
+
|
|
78
|
+
// Step 4 — retention. Find image tags for this workspace, sort by created
|
|
79
|
+
// time (newest first), drop the top N, delete the rest. `:latest` is moved
|
|
80
|
+
// by docker push and stays — we never explicitly delete it.
|
|
81
|
+
const retain = opts.retainImages ?? 3;
|
|
82
|
+
const imageBaseName = imageBaseFromRef(fullRef);
|
|
83
|
+
if (imageBaseName) {
|
|
84
|
+
const pruneScript = [
|
|
85
|
+
`docker images "${imageBaseName}" --format "{{.Repository}}:{{.Tag}} {{.CreatedAt}}" `,
|
|
86
|
+
`| grep -v ":latest " `,
|
|
87
|
+
`| sort -k2,3 -r `,
|
|
88
|
+
`| tail -n +${retain + 1} `,
|
|
89
|
+
`| awk '{print $1}' `,
|
|
90
|
+
`| xargs -r docker rmi 2>/dev/null || true`,
|
|
91
|
+
].join("");
|
|
92
|
+
await sshExec(target, pruneScript, { quiet: true });
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// Step 5 — health check. arc-<env> exposes 5005 inside the docker network;
|
|
96
|
+
// from the host we can reach it via `docker exec caddy curl -fsS ...`
|
|
97
|
+
// (no port mapped to host). Cheap, requires no public DNS.
|
|
98
|
+
const ok = await healthCheck(target, env);
|
|
99
|
+
|
|
100
|
+
return { env, fullRef, redeployed: ok };
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Extract the `<registry>/<workspace>` prefix from a full image ref so we can
|
|
105
|
+
* list its tags for retention. `registry.example.com/app:abc123` → `registry.example.com/app`.
|
|
106
|
+
*/
|
|
107
|
+
function imageBaseFromRef(fullRef: string): string | null {
|
|
108
|
+
const colonIdx = fullRef.lastIndexOf(":");
|
|
109
|
+
return colonIdx > 0 ? fullRef.slice(0, colonIdx) : null;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
async function healthCheck(target: DeployTarget, env: string): Promise<boolean> {
|
|
113
|
+
for (let i = 0; i < HEALTH_RETRIES; i++) {
|
|
114
|
+
const res = await sshExec(
|
|
115
|
+
target,
|
|
116
|
+
`docker exec arc-${env} wget -qO- http://localhost:5005/health 2>&1 || echo HEALTHCHECK_FAILED`,
|
|
117
|
+
{ quiet: true },
|
|
118
|
+
);
|
|
119
|
+
if (res.exitCode === 0 && !res.stdout.includes("HEALTHCHECK_FAILED")) {
|
|
120
|
+
return true;
|
|
121
|
+
}
|
|
122
|
+
await sleep(HEALTH_DELAY_MS);
|
|
123
|
+
}
|
|
124
|
+
return false;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function sleep(ms: number): Promise<void> {
|
|
128
|
+
return new Promise((r) => setTimeout(r, ms));
|
|
129
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
// ---------------------------------------------------------------------------
|
|
2
|
+
// htpasswd — bcrypt-hashed line for the registry's basic-auth file.
|
|
3
|
+
//
|
|
4
|
+
// Format: `<user>:<bcrypt-hash>` (one entry per line in `/auth/htpasswd`).
|
|
5
|
+
// The `registry:2` container reads this file when REGISTRY_AUTH=htpasswd.
|
|
6
|
+
//
|
|
7
|
+
// Zero external binaries: Bun.password.hash supports bcrypt natively, so dev
|
|
8
|
+
// machines don't need apache2-utils / htpasswd CLI installed.
|
|
9
|
+
// ---------------------------------------------------------------------------
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Generate one htpasswd line for the given user/password pair. Throws on
|
|
13
|
+
* empty input — guard against silent misconfiguration that would lock
|
|
14
|
+
* everyone out of the registry.
|
|
15
|
+
*/
|
|
16
|
+
export async function generateHtpasswd(
|
|
17
|
+
user: string,
|
|
18
|
+
password: string,
|
|
19
|
+
): Promise<string> {
|
|
20
|
+
if (!user || !password) {
|
|
21
|
+
throw new Error("htpasswd: user and password must both be non-empty");
|
|
22
|
+
}
|
|
23
|
+
const hash = await Bun.password.hash(password, {
|
|
24
|
+
algorithm: "bcrypt",
|
|
25
|
+
cost: 10,
|
|
26
|
+
});
|
|
27
|
+
return `${user}:${hash}\n`;
|
|
28
|
+
}
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
// ---------------------------------------------------------------------------
|
|
2
|
+
// image-template — Dockerfile generation for `arc platform deploy`.
|
|
3
|
+
//
|
|
4
|
+
// Image layout:
|
|
5
|
+
// /app/.arc/platform/ ← module chunks, shell, styles, framework manifest,
|
|
6
|
+
// server bundles, AND `host.js` (the arc-cli bundle
|
|
7
|
+
// copied from the same CLI that produced the build)
|
|
8
|
+
// /app/node_modules/ ← framework peers installed at image build time
|
|
9
|
+
// /app/public/ ← workspace public assets (if present)
|
|
10
|
+
// /app/manifest.json ← webmanifest (if present)
|
|
11
|
+
// /app/locales/ ← compiled translation catalogs (if present)
|
|
12
|
+
//
|
|
13
|
+
// The arc-cli bundle ships INSIDE the image as `host.js` — no `bun add arc-cli`
|
|
14
|
+
// from npm, no `ARG ARC_CLI_VERSION`. The CLI that runs `arc platform deploy`
|
|
15
|
+
// embeds its own bundled bytes, so what the user installed locally is exactly
|
|
16
|
+
// what the container executes. Eliminates the npm/local skew entirely.
|
|
17
|
+
//
|
|
18
|
+
// Layer order is chosen for cache efficiency: framework peers change rarely
|
|
19
|
+
// (per release of those peers), user artifacts change every deploy.
|
|
20
|
+
// ---------------------------------------------------------------------------
|
|
21
|
+
|
|
22
|
+
export interface DockerfileInputs {
|
|
23
|
+
/** True if `<rootDir>/public/` exists and should be copied into the image. */
|
|
24
|
+
hasPublicDir: boolean;
|
|
25
|
+
/** True if a webmanifest file (`manifest.json` or `manifest.webmanifest`) lives at workspace root. */
|
|
26
|
+
hasManifest: boolean;
|
|
27
|
+
/** Path of the webmanifest relative to workspace root, if `hasManifest` is true. */
|
|
28
|
+
manifestPath?: string;
|
|
29
|
+
/** True if `<rootDir>/locales/` exists. Compiled .json catalogs from buildTranslations live there. */
|
|
30
|
+
hasLocales: boolean;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function generateDockerfile(inputs: DockerfileInputs): string {
|
|
34
|
+
const conditionalCopies: string[] = [];
|
|
35
|
+
if (inputs.hasPublicDir) {
|
|
36
|
+
conditionalCopies.push("COPY public/ /app/public/");
|
|
37
|
+
}
|
|
38
|
+
if (inputs.hasManifest && inputs.manifestPath) {
|
|
39
|
+
conditionalCopies.push(`COPY ${inputs.manifestPath} /app/${inputs.manifestPath}`);
|
|
40
|
+
}
|
|
41
|
+
if (inputs.hasLocales) {
|
|
42
|
+
conditionalCopies.push("COPY locales/ /app/locales/");
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
return [
|
|
46
|
+
"# Generated by `arc platform deploy` — do not edit by hand.",
|
|
47
|
+
"FROM oven/bun:1-alpine",
|
|
48
|
+
"",
|
|
49
|
+
"RUN apk add --no-cache tini ca-certificates",
|
|
50
|
+
"",
|
|
51
|
+
"WORKDIR /app",
|
|
52
|
+
"",
|
|
53
|
+
"# Layer 1 — framework peers as /app/package.json + install into /app/node_modules.",
|
|
54
|
+
"# resolveWorkspace() walks up looking for the first package.json (finds /app/),",
|
|
55
|
+
"# loadServerContext resolves @arcote.tech/platform from /app/node_modules.",
|
|
56
|
+
"# Cached, invalidates only on .arc/platform/package.json change.",
|
|
57
|
+
"COPY .arc/platform/package.json /app/package.json",
|
|
58
|
+
"RUN cd /app && bun install --production --no-save",
|
|
59
|
+
"",
|
|
60
|
+
"# Layer 2 — user artifacts (chunks, shell, styles, server bundles, host.js).",
|
|
61
|
+
"# host.js IS the arc-cli bundle — `arc platform deploy` copied it here from",
|
|
62
|
+
"# whatever arc-cli was on the user's PATH. No npm dependency at runtime.",
|
|
63
|
+
"COPY .arc/platform/ /app/.arc/platform/",
|
|
64
|
+
...conditionalCopies,
|
|
65
|
+
"",
|
|
66
|
+
"ENV PORT=5005",
|
|
67
|
+
"ENV NODE_ENV=production",
|
|
68
|
+
"EXPOSE 5005",
|
|
69
|
+
"",
|
|
70
|
+
'ENTRYPOINT ["tini", "--"]',
|
|
71
|
+
'CMD ["bun", "run", "/app/.arc/platform/host.js", "platform", "start"]',
|
|
72
|
+
"",
|
|
73
|
+
].join("\n");
|
|
74
|
+
}
|
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
import { spawn } from "bun";
|
|
2
|
+
import { createHash } from "node:crypto";
|
|
3
|
+
import {
|
|
4
|
+
copyFileSync,
|
|
5
|
+
existsSync,
|
|
6
|
+
mkdirSync,
|
|
7
|
+
readFileSync,
|
|
8
|
+
realpathSync,
|
|
9
|
+
writeFileSync,
|
|
10
|
+
} from "fs";
|
|
11
|
+
import { tmpdir } from "os";
|
|
12
|
+
import { dirname, join } from "path";
|
|
13
|
+
import { fileURLToPath } from "url";
|
|
14
|
+
import type { WorkspaceInfo } from "../platform/shared";
|
|
15
|
+
import { generateDockerfile, type DockerfileInputs } from "./image-template";
|
|
16
|
+
|
|
17
|
+
// ---------------------------------------------------------------------------
|
|
18
|
+
// image — local Docker image build for `arc platform deploy`.
|
|
19
|
+
//
|
|
20
|
+
// Tag scheme:
|
|
21
|
+
// <imageName>:<contentHash> — deterministic content-addressable tag
|
|
22
|
+
// <imageName>:latest — movable convenience tag
|
|
23
|
+
//
|
|
24
|
+
// `contentHash` = sha256(manifest.json bytes, with `buildTime` field stripped).
|
|
25
|
+
// The first 12 hex chars become the tag. Stripping buildTime guarantees the
|
|
26
|
+
// same source produces the same hash on every build — so reruns push a no-op
|
|
27
|
+
// (registry detects "already pushed"), and rollback can address an old image
|
|
28
|
+
// by its hash without needing a metadata lookup.
|
|
29
|
+
//
|
|
30
|
+
// The arc-cli bundle (`dist/index.js`) is copied INTO the image at
|
|
31
|
+
// `.arc/platform/host.js`. Whatever CLI ran `arc platform deploy` becomes the
|
|
32
|
+
// CLI that runs `arc platform start` inside the container. No npm dependency,
|
|
33
|
+
// no version skew between the host that built the image and the image itself.
|
|
34
|
+
// ---------------------------------------------------------------------------
|
|
35
|
+
|
|
36
|
+
export interface BuildImageOptions {
|
|
37
|
+
/** Image name (typically the workspace package name, sanitized). */
|
|
38
|
+
imageName: string;
|
|
39
|
+
/** Optional registry hostname for `fullRef`. If absent, fullRef = imageTag. */
|
|
40
|
+
registryDomain?: string;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export interface BuildImageResult {
|
|
44
|
+
/** `<imageName>:<contentHash>` — local tag. */
|
|
45
|
+
imageTag: string;
|
|
46
|
+
/** `<registry>/<imageName>:<contentHash>` if `registryDomain` was set, else equal to `imageTag`. */
|
|
47
|
+
fullRef: string;
|
|
48
|
+
/** Truncated sha256 of the manifest content (12 hex chars). */
|
|
49
|
+
contentHash: string;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export async function buildImage(
|
|
53
|
+
ws: WorkspaceInfo,
|
|
54
|
+
opts: BuildImageOptions,
|
|
55
|
+
): Promise<BuildImageResult> {
|
|
56
|
+
await ensureDocker();
|
|
57
|
+
|
|
58
|
+
const manifestPath = join(ws.modulesDir, "manifest.json");
|
|
59
|
+
if (!existsSync(manifestPath)) {
|
|
60
|
+
throw new Error(
|
|
61
|
+
`No build manifest at ${manifestPath}. Run \`arc platform build\` first or omit --skip-build.`,
|
|
62
|
+
);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// Embed THIS CLI's own bundle inside the image. `host.js` becomes the
|
|
66
|
+
// runtime entry point — the image runs whatever bytes the user has locally,
|
|
67
|
+
// no npm install dance, no version skew.
|
|
68
|
+
embedCliBundle(ws);
|
|
69
|
+
|
|
70
|
+
// contentHash MUST be computed AFTER embedCliBundle. host.js bytes flow
|
|
71
|
+
// into the manifest indirectly via the docker layer, but our hash is over
|
|
72
|
+
// manifest.json alone — embedding doesn't touch that. Order kept for
|
|
73
|
+
// safety if we later widen the hash inputs.
|
|
74
|
+
const contentHash = computeContentHash(manifestPath);
|
|
75
|
+
const imageTag = `${opts.imageName}:${contentHash}`;
|
|
76
|
+
const fullRef = opts.registryDomain
|
|
77
|
+
? `${opts.registryDomain}/${imageTag}`
|
|
78
|
+
: imageTag;
|
|
79
|
+
|
|
80
|
+
const dockerfileInputs = collectDockerfileInputs(ws);
|
|
81
|
+
const dockerfile = generateDockerfile(dockerfileInputs);
|
|
82
|
+
|
|
83
|
+
// Write Dockerfile to a temp location and pass via -f. Cannot drop it into
|
|
84
|
+
// the workspace root because users may have their own Dockerfile, and we
|
|
85
|
+
// never want to clutter the repo with generated artifacts.
|
|
86
|
+
const buildContextDir = ws.rootDir;
|
|
87
|
+
const dockerfileDir = join(tmpdir(), `arc-image-${Date.now()}`);
|
|
88
|
+
mkdirSync(dockerfileDir, { recursive: true });
|
|
89
|
+
const dockerfilePath = join(dockerfileDir, "Dockerfile");
|
|
90
|
+
writeFileSync(dockerfilePath, dockerfile);
|
|
91
|
+
|
|
92
|
+
const buildArgs = [
|
|
93
|
+
"build",
|
|
94
|
+
"-f",
|
|
95
|
+
dockerfilePath,
|
|
96
|
+
"-t",
|
|
97
|
+
fullRef,
|
|
98
|
+
"-t",
|
|
99
|
+
`${opts.imageName}:latest`,
|
|
100
|
+
buildContextDir,
|
|
101
|
+
];
|
|
102
|
+
|
|
103
|
+
const proc = spawn({
|
|
104
|
+
cmd: ["docker", ...buildArgs],
|
|
105
|
+
stdout: "inherit",
|
|
106
|
+
stderr: "inherit",
|
|
107
|
+
});
|
|
108
|
+
const exit = await proc.exited;
|
|
109
|
+
if (exit !== 0) {
|
|
110
|
+
throw new Error(`docker build failed (exit ${exit})`);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
return { imageTag, fullRef, contentHash };
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// ---------------------------------------------------------------------------
|
|
117
|
+
// CLI bundle embedding
|
|
118
|
+
// ---------------------------------------------------------------------------
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Copy this CLI's `dist/index.js` into the workspace's `.arc/platform/host.js`.
|
|
122
|
+
* The Dockerfile then COPYs `.arc/platform/` whole-cloth, so the bundle lands
|
|
123
|
+
* in the image at `/app/.arc/platform/host.js` — that's what CMD runs.
|
|
124
|
+
*
|
|
125
|
+
* "This CLI" = whichever arc-cli is executing right now (resolved via
|
|
126
|
+
* `import.meta.url` walking up to the package.json with name
|
|
127
|
+
* `@arcote.tech/arc-cli`). The image therefore runs identical bytes to the
|
|
128
|
+
* CLI that produced the build — no npm registry roundtrip, no version skew.
|
|
129
|
+
*/
|
|
130
|
+
function embedCliBundle(ws: WorkspaceInfo): void {
|
|
131
|
+
const source = locateCliBundle();
|
|
132
|
+
const target = join(ws.arcDir, "host.js");
|
|
133
|
+
copyFileSync(source, target);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* Walk up from this module's location until we find a package.json with
|
|
138
|
+
* `name: "@arcote.tech/arc-cli"`. Return `<that dir>/dist/index.js`.
|
|
139
|
+
*
|
|
140
|
+
* Works in both source (`packages/cli/src/deploy/image.ts`) and bundled
|
|
141
|
+
* (`packages/cli/dist/index.js`) execution contexts: the bundle path itself
|
|
142
|
+
* is `dist/index.js` so locateCliBundle returns it directly.
|
|
143
|
+
*/
|
|
144
|
+
function locateCliBundle(): string {
|
|
145
|
+
const here = fileURLToPath(import.meta.url);
|
|
146
|
+
let cur = dirname(here);
|
|
147
|
+
while (cur !== "/" && cur !== "") {
|
|
148
|
+
const candidate = join(cur, "package.json");
|
|
149
|
+
if (existsSync(candidate)) {
|
|
150
|
+
try {
|
|
151
|
+
const pkg = JSON.parse(readFileSync(candidate, "utf-8"));
|
|
152
|
+
if (pkg.name === "@arcote.tech/arc-cli") {
|
|
153
|
+
const distIndex = join(realpathSync(cur), "dist", "index.js");
|
|
154
|
+
if (!existsSync(distIndex)) {
|
|
155
|
+
throw new Error(
|
|
156
|
+
`arc-cli bundle missing at ${distIndex}. Run \`bun run build\` in packages/cli/.`,
|
|
157
|
+
);
|
|
158
|
+
}
|
|
159
|
+
return distIndex;
|
|
160
|
+
}
|
|
161
|
+
} catch (e) {
|
|
162
|
+
if (e instanceof Error && e.message.startsWith("arc-cli bundle")) throw e;
|
|
163
|
+
// Malformed package.json — keep walking.
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
const parent = dirname(cur);
|
|
167
|
+
if (parent === cur) break;
|
|
168
|
+
cur = parent;
|
|
169
|
+
}
|
|
170
|
+
throw new Error(
|
|
171
|
+
"Could not locate @arcote.tech/arc-cli package.json walking up from " + here,
|
|
172
|
+
);
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
// ---------------------------------------------------------------------------
|
|
176
|
+
// Helpers
|
|
177
|
+
// ---------------------------------------------------------------------------
|
|
178
|
+
|
|
179
|
+
export async function ensureDocker(): Promise<void> {
|
|
180
|
+
try {
|
|
181
|
+
const proc = spawn({
|
|
182
|
+
cmd: ["docker", "--version"],
|
|
183
|
+
stdout: "pipe",
|
|
184
|
+
stderr: "pipe",
|
|
185
|
+
});
|
|
186
|
+
const exit = await proc.exited;
|
|
187
|
+
if (exit !== 0) throw new Error("docker --version exited non-zero");
|
|
188
|
+
} catch {
|
|
189
|
+
throw new Error(
|
|
190
|
+
"Docker is not available on PATH. Install Docker Desktop (or docker engine + buildx) before running deploy.",
|
|
191
|
+
);
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
function computeContentHash(manifestPath: string): string {
|
|
196
|
+
const raw = JSON.parse(readFileSync(manifestPath, "utf-8"));
|
|
197
|
+
// Strip the only non-deterministic field. The hash needs to be stable across
|
|
198
|
+
// identical builds so the registry can dedupe and rollback can address a
|
|
199
|
+
// specific build by tag.
|
|
200
|
+
delete raw.buildTime;
|
|
201
|
+
const canonical = JSON.stringify(raw);
|
|
202
|
+
return createHash("sha256").update(canonical).digest("hex").slice(0, 12);
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
function collectDockerfileInputs(ws: WorkspaceInfo): DockerfileInputs {
|
|
206
|
+
const hasPublicDir = existsSync(join(ws.rootDir, "public"));
|
|
207
|
+
const hasLocales = existsSync(join(ws.rootDir, "locales"));
|
|
208
|
+
|
|
209
|
+
let manifestPath: string | undefined;
|
|
210
|
+
for (const name of ["manifest.webmanifest", "manifest.json"]) {
|
|
211
|
+
if (existsSync(join(ws.rootDir, name))) {
|
|
212
|
+
manifestPath = name;
|
|
213
|
+
break;
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
return {
|
|
218
|
+
hasPublicDir,
|
|
219
|
+
hasManifest: !!manifestPath,
|
|
220
|
+
manifestPath,
|
|
221
|
+
hasLocales,
|
|
222
|
+
};
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/**
|
|
226
|
+
* Sanitize a workspace package name into a Docker-safe image name.
|
|
227
|
+
* Replaces forbidden chars (`@`, `/`, uppercase) with dashes. Empty input
|
|
228
|
+
* falls back to "arc-app".
|
|
229
|
+
*/
|
|
230
|
+
export function sanitizeImageName(name: string): string {
|
|
231
|
+
const cleaned = name
|
|
232
|
+
.toLowerCase()
|
|
233
|
+
.replace(/^@/, "")
|
|
234
|
+
.replace(/[^a-z0-9._-]+/g, "-")
|
|
235
|
+
.replace(/^-+|-+$/g, "");
|
|
236
|
+
return cleaned || "arc-app";
|
|
237
|
+
}
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import { spawn } from "bun";
|
|
2
|
+
import type { DeployRegistry } from "./config";
|
|
3
|
+
|
|
4
|
+
// ---------------------------------------------------------------------------
|
|
5
|
+
// registry — Docker CLI helpers for talking to our private registry.
|
|
6
|
+
//
|
|
7
|
+
// `docker login` stores credentials in `~/.docker/config.json` (local CLI host
|
|
8
|
+
// or remote target). We never embed passwords in commands — always pipe via
|
|
9
|
+
// stdin so they don't show up in `ps` or shell history.
|
|
10
|
+
// ---------------------------------------------------------------------------
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Log in to a private Docker registry. Reads password from the env var named
|
|
14
|
+
* in `registry.passwordEnv` to keep the secret off the command line. Returns
|
|
15
|
+
* once `docker login` exits cleanly; throws with the captured stderr otherwise.
|
|
16
|
+
*/
|
|
17
|
+
export async function dockerLogin(registry: DeployRegistry): Promise<void> {
|
|
18
|
+
const password = process.env[registry.passwordEnv];
|
|
19
|
+
if (!password) {
|
|
20
|
+
throw new Error(
|
|
21
|
+
`Registry password env var ${registry.passwordEnv} is not set. ` +
|
|
22
|
+
`Set it in your shell before running deploy.`,
|
|
23
|
+
);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const proc = spawn({
|
|
27
|
+
cmd: [
|
|
28
|
+
"docker",
|
|
29
|
+
"login",
|
|
30
|
+
registry.domain,
|
|
31
|
+
"-u",
|
|
32
|
+
registry.username,
|
|
33
|
+
"--password-stdin",
|
|
34
|
+
],
|
|
35
|
+
stdin: "pipe",
|
|
36
|
+
stdout: "pipe",
|
|
37
|
+
stderr: "pipe",
|
|
38
|
+
});
|
|
39
|
+
proc.stdin.write(password);
|
|
40
|
+
await proc.stdin.end();
|
|
41
|
+
|
|
42
|
+
const exit = await proc.exited;
|
|
43
|
+
if (exit !== 0) {
|
|
44
|
+
const stderr = await new Response(proc.stderr).text();
|
|
45
|
+
throw new Error(
|
|
46
|
+
`docker login ${registry.domain} failed (exit ${exit}): ${stderr.trim()}`,
|
|
47
|
+
);
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Logout from a registry — removes its credentials from
|
|
53
|
+
* `~/.docker/config.json`. Useful in CI teardown; rarely needed locally.
|
|
54
|
+
*/
|
|
55
|
+
export async function dockerLogout(domain: string): Promise<void> {
|
|
56
|
+
const proc = spawn({
|
|
57
|
+
cmd: ["docker", "logout", domain],
|
|
58
|
+
stdout: "ignore",
|
|
59
|
+
stderr: "ignore",
|
|
60
|
+
});
|
|
61
|
+
await proc.exited;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Push a pre-tagged image. Caller is responsible for tagging the local image
|
|
66
|
+
* with the full registry-qualified ref (e.g. `registry.example.com/app:hash`)
|
|
67
|
+
* via `docker tag` before this call — `buildImage` already does that.
|
|
68
|
+
*/
|
|
69
|
+
export async function dockerPush(fullRef: string): Promise<void> {
|
|
70
|
+
const proc = spawn({
|
|
71
|
+
cmd: ["docker", "push", fullRef],
|
|
72
|
+
stdout: "inherit",
|
|
73
|
+
stderr: "inherit",
|
|
74
|
+
});
|
|
75
|
+
const exit = await proc.exited;
|
|
76
|
+
if (exit !== 0) {
|
|
77
|
+
throw new Error(`docker push ${fullRef} failed (exit ${exit})`);
|
|
78
|
+
}
|
|
79
|
+
}
|