@arcote.tech/arc-cli 0.6.2 → 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.
@@ -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
+ }