@dharta/cli 0.6.77
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/LICENSE +21 -0
- package/README.md +167 -0
- package/dist/agent.d.ts +17 -0
- package/dist/agent.js +134 -0
- package/dist/agent.js.map +1 -0
- package/dist/api-error.d.ts +3 -0
- package/dist/api-error.js +80 -0
- package/dist/api-error.js.map +1 -0
- package/dist/browser.d.ts +1 -0
- package/dist/browser.js +19 -0
- package/dist/browser.js.map +1 -0
- package/dist/cli.d.ts +5 -0
- package/dist/cli.js +1978 -0
- package/dist/cli.js.map +1 -0
- package/dist/commands/agents.d.ts +131 -0
- package/dist/commands/agents.js +439 -0
- package/dist/commands/agents.js.map +1 -0
- package/dist/commands/analytics.d.ts +25 -0
- package/dist/commands/analytics.js +166 -0
- package/dist/commands/analytics.js.map +1 -0
- package/dist/commands/api.d.ts +19 -0
- package/dist/commands/api.js +278 -0
- package/dist/commands/api.js.map +1 -0
- package/dist/commands/appearance.d.ts +19 -0
- package/dist/commands/appearance.js +63 -0
- package/dist/commands/appearance.js.map +1 -0
- package/dist/commands/artifact.d.ts +33 -0
- package/dist/commands/artifact.js +235 -0
- package/dist/commands/artifact.js.map +1 -0
- package/dist/commands/audit-log.d.ts +32 -0
- package/dist/commands/audit-log.js +102 -0
- package/dist/commands/audit-log.js.map +1 -0
- package/dist/commands/auth.d.ts +47 -0
- package/dist/commands/auth.js +117 -0
- package/dist/commands/auth.js.map +1 -0
- package/dist/commands/caps.d.ts +82 -0
- package/dist/commands/caps.js +213 -0
- package/dist/commands/caps.js.map +1 -0
- package/dist/commands/create.d.ts +22 -0
- package/dist/commands/create.js +76 -0
- package/dist/commands/create.js.map +1 -0
- package/dist/commands/deploy.d.ts +45 -0
- package/dist/commands/deploy.js +892 -0
- package/dist/commands/deploy.js.map +1 -0
- package/dist/commands/dev.d.ts +33 -0
- package/dist/commands/dev.js +655 -0
- package/dist/commands/dev.js.map +1 -0
- package/dist/commands/directories.d.ts +60 -0
- package/dist/commands/directories.js +83 -0
- package/dist/commands/directories.js.map +1 -0
- package/dist/commands/doctor.d.ts +13 -0
- package/dist/commands/doctor.js +123 -0
- package/dist/commands/doctor.js.map +1 -0
- package/dist/commands/embed-keys.d.ts +61 -0
- package/dist/commands/embed-keys.js +165 -0
- package/dist/commands/embed-keys.js.map +1 -0
- package/dist/commands/env.d.ts +59 -0
- package/dist/commands/env.js +300 -0
- package/dist/commands/env.js.map +1 -0
- package/dist/commands/files.d.ts +14 -0
- package/dist/commands/files.js +84 -0
- package/dist/commands/files.js.map +1 -0
- package/dist/commands/git-credential.d.ts +1 -0
- package/dist/commands/git-credential.js +70 -0
- package/dist/commands/git-credential.js.map +1 -0
- package/dist/commands/kartas.d.ts +101 -0
- package/dist/commands/kartas.js +289 -0
- package/dist/commands/kartas.js.map +1 -0
- package/dist/commands/keys.d.ts +33 -0
- package/dist/commands/keys.js +141 -0
- package/dist/commands/keys.js.map +1 -0
- package/dist/commands/login.d.ts +22 -0
- package/dist/commands/login.js +78 -0
- package/dist/commands/login.js.map +1 -0
- package/dist/commands/logs.d.ts +14 -0
- package/dist/commands/logs.js +89 -0
- package/dist/commands/logs.js.map +1 -0
- package/dist/commands/members.d.ts +32 -0
- package/dist/commands/members.js +77 -0
- package/dist/commands/members.js.map +1 -0
- package/dist/commands/model-keys.d.ts +22 -0
- package/dist/commands/model-keys.js +222 -0
- package/dist/commands/model-keys.js.map +1 -0
- package/dist/commands/open.d.ts +29 -0
- package/dist/commands/open.js +119 -0
- package/dist/commands/open.js.map +1 -0
- package/dist/commands/parity.d.ts +15 -0
- package/dist/commands/parity.js +73 -0
- package/dist/commands/parity.js.map +1 -0
- package/dist/commands/rollback.d.ts +21 -0
- package/dist/commands/rollback.js +26 -0
- package/dist/commands/rollback.js.map +1 -0
- package/dist/commands/schedules.d.ts +52 -0
- package/dist/commands/schedules.js +231 -0
- package/dist/commands/schedules.js.map +1 -0
- package/dist/commands/scopes.d.ts +3 -0
- package/dist/commands/scopes.js +21 -0
- package/dist/commands/scopes.js.map +1 -0
- package/dist/commands/sessions.d.ts +53 -0
- package/dist/commands/sessions.js +142 -0
- package/dist/commands/sessions.js.map +1 -0
- package/dist/commands/setup.d.ts +28 -0
- package/dist/commands/setup.js +949 -0
- package/dist/commands/setup.js.map +1 -0
- package/dist/commands/status.d.ts +19 -0
- package/dist/commands/status.js +63 -0
- package/dist/commands/status.js.map +1 -0
- package/dist/commands/update.d.ts +14 -0
- package/dist/commands/update.js +120 -0
- package/dist/commands/update.js.map +1 -0
- package/dist/commands/usage.d.ts +3 -0
- package/dist/commands/usage.js +37 -0
- package/dist/commands/usage.js.map +1 -0
- package/dist/commands/webhooks.d.ts +47 -0
- package/dist/commands/webhooks.js +156 -0
- package/dist/commands/webhooks.js.map +1 -0
- package/dist/commands/whoami.d.ts +7 -0
- package/dist/commands/whoami.js +17 -0
- package/dist/commands/whoami.js.map +1 -0
- package/dist/completions.d.ts +13 -0
- package/dist/completions.js +120 -0
- package/dist/completions.js.map +1 -0
- package/dist/config.d.ts +20 -0
- package/dist/config.js +98 -0
- package/dist/config.js.map +1 -0
- package/dist/context.d.ts +14 -0
- package/dist/context.js +38 -0
- package/dist/context.js.map +1 -0
- package/dist/device-flow.d.ts +9 -0
- package/dist/device-flow.js +127 -0
- package/dist/device-flow.js.map +1 -0
- package/dist/harness-preflight.d.ts +24 -0
- package/dist/harness-preflight.js +252 -0
- package/dist/harness-preflight.js.map +1 -0
- package/dist/harnesses.d.ts +11 -0
- package/dist/harnesses.js +112 -0
- package/dist/harnesses.js.map +1 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.js +3 -0
- package/dist/index.js.map +1 -0
- package/dist/login-offer.d.ts +9 -0
- package/dist/login-offer.js +36 -0
- package/dist/login-offer.js.map +1 -0
- package/dist/manifest.d.ts +15 -0
- package/dist/manifest.js +142 -0
- package/dist/manifest.js.map +1 -0
- package/dist/money.d.ts +1 -0
- package/dist/money.js +16 -0
- package/dist/money.js.map +1 -0
- package/dist/output.d.ts +2 -0
- package/dist/output.js +20 -0
- package/dist/output.js.map +1 -0
- package/dist/runtime.d.ts +19 -0
- package/dist/runtime.js +95 -0
- package/dist/runtime.js.map +1 -0
- package/dist/scaffold.d.ts +16 -0
- package/dist/scaffold.js +74 -0
- package/dist/scaffold.js.map +1 -0
- package/dist/setup-scan.d.ts +21 -0
- package/dist/setup-scan.js +127 -0
- package/dist/setup-scan.js.map +1 -0
- package/dist/templates.d.ts +14 -0
- package/dist/templates.js +65 -0
- package/dist/templates.js.map +1 -0
- package/dist/validators.d.ts +10 -0
- package/dist/validators.js +116 -0
- package/dist/validators.js.map +1 -0
- package/dist/version-check.d.ts +25 -0
- package/dist/version-check.js +68 -0
- package/dist/version-check.js.map +1 -0
- package/node_modules/@dharta/sdk/LICENSE +21 -0
- package/node_modules/@dharta/sdk/README.md +87 -0
- package/node_modules/@dharta/sdk/dist/errors.d.ts +20 -0
- package/node_modules/@dharta/sdk/dist/errors.d.ts.map +1 -0
- package/node_modules/@dharta/sdk/dist/errors.js +51 -0
- package/node_modules/@dharta/sdk/dist/errors.js.map +1 -0
- package/node_modules/@dharta/sdk/dist/http.d.ts +26 -0
- package/node_modules/@dharta/sdk/dist/http.d.ts.map +1 -0
- package/node_modules/@dharta/sdk/dist/http.js +136 -0
- package/node_modules/@dharta/sdk/dist/http.js.map +1 -0
- package/node_modules/@dharta/sdk/dist/index.d.ts +15 -0
- package/node_modules/@dharta/sdk/dist/index.d.ts.map +1 -0
- package/node_modules/@dharta/sdk/dist/index.js +21 -0
- package/node_modules/@dharta/sdk/dist/index.js.map +1 -0
- package/node_modules/@dharta/sdk/dist/resources.d.ts +48 -0
- package/node_modules/@dharta/sdk/dist/resources.d.ts.map +1 -0
- package/node_modules/@dharta/sdk/dist/resources.js +150 -0
- package/node_modules/@dharta/sdk/dist/resources.js.map +1 -0
- package/node_modules/@dharta/sdk/dist/types.d.ts +94 -0
- package/node_modules/@dharta/sdk/dist/types.d.ts.map +1 -0
- package/node_modules/@dharta/sdk/dist/types.js +59 -0
- package/node_modules/@dharta/sdk/dist/types.js.map +1 -0
- package/node_modules/@dharta/sdk/package.json +32 -0
- package/node_modules/@dharta/sdk/src/errors.ts +58 -0
- package/node_modules/@dharta/sdk/src/http.ts +166 -0
- package/node_modules/@dharta/sdk/src/index.ts +36 -0
- package/node_modules/@dharta/sdk/src/resources.ts +197 -0
- package/node_modules/@dharta/sdk/src/types.ts +179 -0
- package/package.json +38 -0
- package/src/agent.ts +147 -0
- package/src/api-error.ts +88 -0
- package/src/browser.ts +20 -0
- package/src/cli.ts +2339 -0
- package/src/commands/agents.ts +635 -0
- package/src/commands/analytics.ts +181 -0
- package/src/commands/api.ts +297 -0
- package/src/commands/appearance.ts +79 -0
- package/src/commands/artifact.ts +331 -0
- package/src/commands/audit-log.ts +125 -0
- package/src/commands/auth.ts +186 -0
- package/src/commands/caps.ts +302 -0
- package/src/commands/create.ts +114 -0
- package/src/commands/deploy.ts +1122 -0
- package/src/commands/dev.ts +792 -0
- package/src/commands/directories.ts +136 -0
- package/src/commands/doctor.ts +138 -0
- package/src/commands/embed-keys.ts +237 -0
- package/src/commands/env.ts +369 -0
- package/src/commands/files.ts +124 -0
- package/src/commands/git-credential.ts +78 -0
- package/src/commands/kartas.ts +439 -0
- package/src/commands/keys.ts +206 -0
- package/src/commands/login.ts +115 -0
- package/src/commands/logs.ts +111 -0
- package/src/commands/members.ts +109 -0
- package/src/commands/model-keys.ts +278 -0
- package/src/commands/open.ts +173 -0
- package/src/commands/parity.ts +96 -0
- package/src/commands/rollback.ts +45 -0
- package/src/commands/schedules.ts +290 -0
- package/src/commands/scopes.ts +22 -0
- package/src/commands/sessions.ts +225 -0
- package/src/commands/setup.ts +1176 -0
- package/src/commands/status.ts +94 -0
- package/src/commands/update.ts +157 -0
- package/src/commands/usage.ts +41 -0
- package/src/commands/webhooks.ts +200 -0
- package/src/commands/whoami.ts +24 -0
- package/src/completions.ts +168 -0
- package/src/config.ts +143 -0
- package/src/context.ts +59 -0
- package/src/device-flow.ts +174 -0
- package/src/harness-preflight.ts +293 -0
- package/src/harnesses.ts +117 -0
- package/src/index.ts +3 -0
- package/src/login-offer.ts +49 -0
- package/src/manifest.ts +153 -0
- package/src/money.ts +15 -0
- package/src/output.ts +22 -0
- package/src/runtime.ts +127 -0
- package/src/scaffold.ts +102 -0
- package/src/setup-scan.ts +154 -0
- package/src/templates.ts +85 -0
- package/src/validators.ts +124 -0
- package/src/version-check.ts +75 -0
- package/templates/blank/CLAUDE.md +21 -0
- package/templates/blank/dharta.toml +11 -0
- package/templates/faq-agent/CLAUDE.md +51 -0
- package/templates/faq-agent/dharta.toml +11 -0
|
@@ -0,0 +1,1122 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
import { createHash, randomBytes } from "node:crypto";
|
|
3
|
+
import { existsSync, lstatSync, realpathSync, statSync } from "node:fs";
|
|
4
|
+
import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
|
|
5
|
+
import { tmpdir } from "node:os";
|
|
6
|
+
import { basename, dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
|
|
7
|
+
import TOML from "@iarna/toml";
|
|
8
|
+
import type { CliContext } from "../context.js";
|
|
9
|
+
import { slugFromRemote, deriveSlug, validateAgentName, validateAgentSlug } from "../agent.js";
|
|
10
|
+
import { findGitRoot, scanAgentFolders } from "../setup-scan.js";
|
|
11
|
+
import { harnessPreflight } from "../harness-preflight.js";
|
|
12
|
+
import { cliFormatRawError } from "../api-error.js";
|
|
13
|
+
import { normalizeNonBlank } from "../validators.js";
|
|
14
|
+
|
|
15
|
+
// `dharta deploy [slug]` deploys the ONE agent folder at/above --dir (or cwd).
|
|
16
|
+
// The legacy slug argument may assert that folder's manifest identity, but it
|
|
17
|
+
// can no longer redirect a deploy to a different agent or source repo.
|
|
18
|
+
//
|
|
19
|
+
// git - push the repo's committed HEAD to an agent-specific ref, carrying
|
|
20
|
+
// the selected repo-relative folder as a receive-pack push option. Sibling
|
|
21
|
+
// agents are never implicit deploy targets.
|
|
22
|
+
//
|
|
23
|
+
// folder - deploy by tarballing only the selected agent working tree
|
|
24
|
+
// (honoring only .gitignore) and uploading it to the publish-by-upload
|
|
25
|
+
// endpoint (Api::PublishesController). No commit, no push, so a
|
|
26
|
+
// divergent or absent git history can't block the deploy. Last-write-
|
|
27
|
+
// wins by nature - the upload becomes a new release.
|
|
28
|
+
|
|
29
|
+
interface EnsureRepoResponse {
|
|
30
|
+
source_repo: { slug: string; deploy_branch: string };
|
|
31
|
+
clone_url: string;
|
|
32
|
+
created: boolean;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export interface GitResult {
|
|
36
|
+
code: number;
|
|
37
|
+
stdout: string;
|
|
38
|
+
stderr: string;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// Injectable for tests; the default shells out to `git`. `stream: true`
|
|
42
|
+
// inherits stdio so push progress + sideband reach the terminal live.
|
|
43
|
+
export type GitRunner = (
|
|
44
|
+
args: string[],
|
|
45
|
+
opts?: { env?: Record<string, string>; stream?: boolean },
|
|
46
|
+
) => Promise<GitResult>;
|
|
47
|
+
|
|
48
|
+
// Per-invocation credential helper for the push: the first empty `-c
|
|
49
|
+
// credential.helper=` clears any configured helpers, then this one
|
|
50
|
+
// answers with the API key read from the child process env. The key is
|
|
51
|
+
// never in argv (visible in `ps`) and never persisted - deploy works
|
|
52
|
+
// non-interactively even when `dharta login` couldn't install the
|
|
53
|
+
// global helper.
|
|
54
|
+
export const INLINE_CREDENTIAL_HELPER =
|
|
55
|
+
'!f() { echo username=karta; echo "password=$DHARTA_GIT_PASSWORD"; }; f';
|
|
56
|
+
|
|
57
|
+
export interface DeployOptions {
|
|
58
|
+
dir?: string;
|
|
59
|
+
mode?: "git" | "folder";
|
|
60
|
+
reconcileSource?: boolean;
|
|
61
|
+
gitRunner?: GitRunner; // for tests
|
|
62
|
+
log?: (line: string) => void; // for tests
|
|
63
|
+
// Injectable seams for the RFC 0035 harness version pre-flight (tests).
|
|
64
|
+
fetchImpl?: typeof fetch;
|
|
65
|
+
detectLocalVersion?: (type: string) => Promise<string | null>;
|
|
66
|
+
confirm?: (question: string) => Promise<boolean>;
|
|
67
|
+
requestId?: string; // deterministic one-shot git deploy ref (tests only)
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export interface DeployFolderResult {
|
|
71
|
+
accepted: true;
|
|
72
|
+
phase?: "publication";
|
|
73
|
+
state?: "pending";
|
|
74
|
+
agent: { id: number; slug: string; name: string };
|
|
75
|
+
release: {
|
|
76
|
+
version: number;
|
|
77
|
+
status: string;
|
|
78
|
+
harness_slug: string | null;
|
|
79
|
+
harness_version: string | null;
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
type DeployMode = "git" | "folder";
|
|
84
|
+
const MAX_SOURCE_REPO_AGENT_CANDIDATES = 256;
|
|
85
|
+
|
|
86
|
+
interface DeployTarget {
|
|
87
|
+
dir: string;
|
|
88
|
+
slug: string;
|
|
89
|
+
name: string;
|
|
90
|
+
deployMethod?: DeployMode;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export async function deploy(
|
|
94
|
+
ctx: CliContext,
|
|
95
|
+
slug?: string,
|
|
96
|
+
opts: DeployOptions = {},
|
|
97
|
+
): Promise<DeployFolderResult | undefined> {
|
|
98
|
+
const explicitSlug = slug === undefined ? undefined : validateAgentSlug(slug, "agent slug");
|
|
99
|
+
const requestedDir = resolve(normalizeNonBlank(opts.dir ?? ".", "--dir"));
|
|
100
|
+
validateDeployDir(requestedDir);
|
|
101
|
+
const git = opts.gitRunner ?? defaultGitRunner;
|
|
102
|
+
const log = opts.log ?? ((line: string) => console.log(line));
|
|
103
|
+
const target = await resolveDeployTarget(requestedDir, explicitSlug, git);
|
|
104
|
+
let mode = opts.mode ?? target.deployMethod;
|
|
105
|
+
if (!mode) {
|
|
106
|
+
mode = await inferLegacyMode(target.dir, git);
|
|
107
|
+
log(
|
|
108
|
+
`No deploy_method is configured in ${join(target.dir, "dharta.toml")}; ` +
|
|
109
|
+
`inferred ${mode}. Run \`dharta setup --method ${mode}\` from that agent folder to persist it.`,
|
|
110
|
+
);
|
|
111
|
+
}
|
|
112
|
+
if (opts.reconcileSource && mode !== "git") {
|
|
113
|
+
throw new Error(
|
|
114
|
+
"--reconcile-source is only valid for git deploys. " +
|
|
115
|
+
"Remove it, or use `--mode git` to reconcile Dharta's hosted source branch.",
|
|
116
|
+
);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// RFC 0035 D7: advisory harness-version pre-flight for the selected agent. A
|
|
120
|
+
// no-op unless [harness] declares a version; throws to abort on an unsatisfiable
|
|
121
|
+
// pin or a declined local-vs-cloud gap.
|
|
122
|
+
const preflight = (d: string) =>
|
|
123
|
+
harnessPreflight({
|
|
124
|
+
dir: d,
|
|
125
|
+
baseUrl: ctx.baseUrl,
|
|
126
|
+
log,
|
|
127
|
+
fetchImpl: opts.fetchImpl,
|
|
128
|
+
detectLocalVersion: opts.detectLocalVersion,
|
|
129
|
+
confirm: opts.confirm,
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
if (mode === "folder") {
|
|
133
|
+
await preflight(target.dir);
|
|
134
|
+
return deployFolder(ctx, target.dir, target.slug, target.name, git, log);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// Git mode operates on the repository while carrying the exact agent subtree
|
|
138
|
+
// independently. A forced git deploy of a plain folder initializes that
|
|
139
|
+
// folder as its own repo, preserving the previous first-deploy convenience.
|
|
140
|
+
const repoRoot = findGitRoot(target.dir) ?? target.dir;
|
|
141
|
+
const agentPath = gitAgentPath(repoRoot, target.dir);
|
|
142
|
+
if (agentPath !== "." && agentPath.length > 255) {
|
|
143
|
+
throw new Error(
|
|
144
|
+
`Agent directory '${agentPath}' is ${agentPath.length} characters; ` +
|
|
145
|
+
"git deploy supports repo-relative agent paths up to 255 characters.",
|
|
146
|
+
);
|
|
147
|
+
}
|
|
148
|
+
const manifestPath = agentPath === "." ? "dharta.toml" : `${agentPath}/dharta.toml`;
|
|
149
|
+
const gitRepo = await isGitRepo(git, repoRoot);
|
|
150
|
+
let makeInitialCommit = false;
|
|
151
|
+
|
|
152
|
+
if (gitRepo) {
|
|
153
|
+
// Validate every local condition before provisioning remote state. Only
|
|
154
|
+
// changes inside this agent block its deploy; dirty siblings are not part
|
|
155
|
+
// of committed HEAD and do not make an unrelated agent unusable.
|
|
156
|
+
const status = await git([
|
|
157
|
+
"-C", repoRoot, "status", "--porcelain", "--", literalPathspec(agentPath),
|
|
158
|
+
]);
|
|
159
|
+
if (status.code !== 0) {
|
|
160
|
+
throw new Error(`git status failed for ${target.dir}: ${gitFailure(status)}`);
|
|
161
|
+
}
|
|
162
|
+
const dirty = status.stdout.trim().length > 0;
|
|
163
|
+
const head = await git(["-C", repoRoot, "rev-parse", "--verify", "-q", "HEAD"]);
|
|
164
|
+
if (head.code === 0) {
|
|
165
|
+
if (dirty) {
|
|
166
|
+
throw new Error(
|
|
167
|
+
`Uncommitted changes in ${target.dir}. deploy ships the committed HEAD - ` +
|
|
168
|
+
`commit (or stash) first, then re-run. ` +
|
|
169
|
+
`(karta never auto-commits an existing repo's working tree.)`,
|
|
170
|
+
);
|
|
171
|
+
}
|
|
172
|
+
await requireManifestInHead(git, repoRoot, manifestPath);
|
|
173
|
+
} else {
|
|
174
|
+
// A git-inited-but-commitless repo (the setup -> deploy seam) needs its
|
|
175
|
+
// first commit. Ensure the selected manifest will actually be added;
|
|
176
|
+
// otherwise provisioning a hosted source would leave dangling state.
|
|
177
|
+
if (dirty) await rejectStagedFilesOutsideAgent(git, repoRoot, agentPath);
|
|
178
|
+
await rejectIgnoredManifest(git, repoRoot, manifestPath, "the initial commit");
|
|
179
|
+
makeInitialCommit = true;
|
|
180
|
+
}
|
|
181
|
+
} else {
|
|
182
|
+
const initialFiles = await listDeployFiles(repoRoot, git);
|
|
183
|
+
if (!initialFiles.includes(manifestPath)) {
|
|
184
|
+
throw new Error(
|
|
185
|
+
`${manifestPath} is excluded by git ignore rules and will not be present in the initial commit. ` +
|
|
186
|
+
"Unignore it, then re-run `dharta deploy`; no hosted source was provisioned.",
|
|
187
|
+
);
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
const sourceRepoSlug = await remoteSlug(git, repoRoot);
|
|
192
|
+
const sourceRepoBody = sourceRepoSlug
|
|
193
|
+
? { slug: sourceRepoSlug }
|
|
194
|
+
: {
|
|
195
|
+
name: await stableRepositoryHint(git, repoRoot),
|
|
196
|
+
selected_agent_slug: target.slug,
|
|
197
|
+
agent_slugs: await configuredAgentSlugs(repoRoot, target.slug),
|
|
198
|
+
};
|
|
199
|
+
await preflight(target.dir);
|
|
200
|
+
const res = await ctx.http.request<EnsureRepoResponse>({
|
|
201
|
+
method: "POST",
|
|
202
|
+
path: "/api/source_repos",
|
|
203
|
+
body: sourceRepoBody,
|
|
204
|
+
});
|
|
205
|
+
const branch = res.source_repo.deploy_branch;
|
|
206
|
+
log(`${res.created ? "Created" : "Found"} source repo ${res.source_repo.slug}`);
|
|
207
|
+
|
|
208
|
+
if (!gitRepo) {
|
|
209
|
+
// A plain folder forced into git mode becomes a repo - say exactly
|
|
210
|
+
// what ran. (Without --mode git, a non-repo deploys as a folder.)
|
|
211
|
+
log(`${repoRoot} is not a git repo yet. Initializing one:`);
|
|
212
|
+
log(` git init -b ${branch}`);
|
|
213
|
+
log(` git add -A -- ${agentPath}`);
|
|
214
|
+
log(` git commit -m "dharta deploy"`);
|
|
215
|
+
await must(git, ["-C", repoRoot, "init", "-b", branch], "git init");
|
|
216
|
+
await must(
|
|
217
|
+
git,
|
|
218
|
+
["-C", repoRoot, "add", "-A", "--", literalPathspec(agentPath)],
|
|
219
|
+
"git add",
|
|
220
|
+
);
|
|
221
|
+
await must(git, ["-C", repoRoot, "commit", "-m", "dharta deploy"], "git commit");
|
|
222
|
+
} else if (makeInitialCommit) {
|
|
223
|
+
log(`No commits yet in ${repoRoot} - making the initial commit for ${target.slug}:`);
|
|
224
|
+
log(` git add -A -- ${agentPath}`);
|
|
225
|
+
log(` git commit -m "dharta deploy"`);
|
|
226
|
+
await must(
|
|
227
|
+
git,
|
|
228
|
+
["-C", repoRoot, "add", "-A", "--", literalPathspec(agentPath)],
|
|
229
|
+
"git add",
|
|
230
|
+
);
|
|
231
|
+
await must(git, ["-C", repoRoot, "commit", "-m", "dharta deploy"], "git commit");
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
await ensureRemote(git, repoRoot, res.clone_url);
|
|
235
|
+
|
|
236
|
+
const requestId = deployRequestId(opts.requestId);
|
|
237
|
+
const targetRef = `refs/karta/agents/${target.slug}/requests/${requestId}`;
|
|
238
|
+
if (opts.reconcileSource) {
|
|
239
|
+
log(
|
|
240
|
+
`Reconciling Dharta's hosted ${branch} branch to local HEAD. ` +
|
|
241
|
+
"Only the hosted source branch is being rewritten; the agent request remains one-shot.",
|
|
242
|
+
);
|
|
243
|
+
}
|
|
244
|
+
log(`Pushing ${target.slug} (${agentPath}) to ${targetRef} on ${res.clone_url} ...`);
|
|
245
|
+
const pushArgs = [
|
|
246
|
+
"-C",
|
|
247
|
+
repoRoot,
|
|
248
|
+
"-c",
|
|
249
|
+
"credential.helper=",
|
|
250
|
+
"-c",
|
|
251
|
+
`credential.helper=${INLINE_CREDENTIAL_HELPER}`,
|
|
252
|
+
"push",
|
|
253
|
+
"--atomic",
|
|
254
|
+
`--push-option=dharta.agent-path=${agentPath}`,
|
|
255
|
+
"dharta",
|
|
256
|
+
`${opts.reconcileSource ? "+" : ""}HEAD:refs/heads/${branch}`,
|
|
257
|
+
`+HEAD:${targetRef}`,
|
|
258
|
+
];
|
|
259
|
+
const pushOpts = { env: { DHARTA_GIT_PASSWORD: ctx.apiKey }, stream: true };
|
|
260
|
+
let push = await git(pushArgs, pushOpts);
|
|
261
|
+
if (push.code !== 0 && retryableAtomicRefRace(push)) {
|
|
262
|
+
log("The hosted branch changed during this push; retrying the atomic agent update once ...");
|
|
263
|
+
push = await git(pushArgs, pushOpts);
|
|
264
|
+
}
|
|
265
|
+
if (push.code !== 0) {
|
|
266
|
+
throw new Error(
|
|
267
|
+
`git push failed (exit ${push.code}). The push output above names the cause; ` +
|
|
268
|
+
"fix it and re-run `dharta deploy`. If the hosted history diverged, first run " +
|
|
269
|
+
`\`git fetch karta ${branch}\` and inspect \`git log --oneline --left-right HEAD...FETCH_HEAD\`; ` +
|
|
270
|
+
"then intentionally align only Dharta's hosted source with " +
|
|
271
|
+
"`dharta deploy --reconcile-source`. Or deploy without git using " +
|
|
272
|
+
"`dharta deploy --mode folder` (working-tree upload, last-write-wins).",
|
|
273
|
+
);
|
|
274
|
+
}
|
|
275
|
+
const pushOutput = `${push.stdout}\n${push.stderr}`;
|
|
276
|
+
if (
|
|
277
|
+
/build\s+→\s+(?:not started|FAILED|nothing (?:deployed|to deploy))/i.test(pushOutput) ||
|
|
278
|
+
/superseded by a newer push/i.test(pushOutput) ||
|
|
279
|
+
/dharta-deploy-result:\s*failed/i.test(pushOutput)
|
|
280
|
+
) {
|
|
281
|
+
throw new Error(
|
|
282
|
+
"The git source update landed, but Dharta reported that this deployment failed. " +
|
|
283
|
+
"Fix the remote error shown above and retry the same commit with `dharta deploy`. " +
|
|
284
|
+
"To deploy the working tree instead, use `dharta deploy --mode folder`.",
|
|
285
|
+
);
|
|
286
|
+
}
|
|
287
|
+
if (/continuing in the background/i.test(pushOutput) || /dharta-deploy-result:\s*pending/i.test(pushOutput)) {
|
|
288
|
+
throw new Error(
|
|
289
|
+
"The git source update landed, but Dharta could not confirm an active release before the " +
|
|
290
|
+
"deploy timeout. Check `dharta agent show` or `dharta logs --tail`; do not assume it is live.",
|
|
291
|
+
);
|
|
292
|
+
}
|
|
293
|
+
if (!/dharta-deploy-result:\s*success/i.test(pushOutput)) {
|
|
294
|
+
throw new Error(
|
|
295
|
+
"The git source update landed, but Dharta did not return a successful deployment result. " +
|
|
296
|
+
"Review the push output and check `dharta agent show` or `dharta logs --tail`; do not assume it is live.",
|
|
297
|
+
);
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
function deployRequestId(explicit?: string): string {
|
|
302
|
+
const value = explicit ?? `${Date.now().toString(36)}-${randomBytes(6).toString("hex")}`;
|
|
303
|
+
if (!/^[a-z0-9][a-z0-9-]{0,63}$/.test(value)) {
|
|
304
|
+
throw new Error("Internal deploy request id must contain lowercase letters, digits, and hyphens only.");
|
|
305
|
+
}
|
|
306
|
+
return value;
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
// Transport inference used by cli.ts before it calls deploy. A configured
|
|
310
|
+
// deploy_method takes precedence; legacy manifests keep the repo-shape fallback.
|
|
311
|
+
export async function inferMode(
|
|
312
|
+
dir: string,
|
|
313
|
+
git: GitRunner = defaultGitRunner,
|
|
314
|
+
): Promise<"git" | "folder"> {
|
|
315
|
+
const resolved = resolve(dir);
|
|
316
|
+
const agentDir = await resolveAgentDir(resolved);
|
|
317
|
+
if (agentDir) {
|
|
318
|
+
const configured = await readConfiguredDeployMethod(agentDir);
|
|
319
|
+
if (configured) return configured;
|
|
320
|
+
return inferLegacyMode(agentDir, git);
|
|
321
|
+
}
|
|
322
|
+
return inferLegacyMode(resolved, git);
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
async function resolveDeployTarget(
|
|
326
|
+
requestedDir: string,
|
|
327
|
+
explicitSlug?: string,
|
|
328
|
+
git: GitRunner = defaultGitRunner,
|
|
329
|
+
): Promise<DeployTarget> {
|
|
330
|
+
const dir = await resolveAgentDir(requestedDir);
|
|
331
|
+
if (!dir) {
|
|
332
|
+
throw new Error(
|
|
333
|
+
`No dharta.toml at or above ${requestedDir} - not a harness application. ` +
|
|
334
|
+
"Run from the agent folder (or one of its subdirectories), or pass --dir.",
|
|
335
|
+
);
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
validateDeployRoot(dir);
|
|
339
|
+
|
|
340
|
+
const manifestPath = join(dir, "dharta.toml");
|
|
341
|
+
let manifest: Record<string, unknown>;
|
|
342
|
+
try {
|
|
343
|
+
manifest = TOML.parse(await readFile(manifestPath, "utf8")) as Record<string, unknown>;
|
|
344
|
+
} catch (error) {
|
|
345
|
+
throw new Error(`Could not read ${manifestPath}: ${(error as Error).message}`);
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
if (typeof manifest.name !== "string") {
|
|
349
|
+
throw new Error(`${manifestPath} must declare a non-blank top-level name.`);
|
|
350
|
+
}
|
|
351
|
+
const manifestName = validateAgentName(manifest.name, `${manifestPath} name`);
|
|
352
|
+
// Identity: an explicit top-level `slug` pins the agent identity so the
|
|
353
|
+
// display name can be free-form ("Chief Marketing Officer" on slug `cmo`).
|
|
354
|
+
// Without it the slug derives from the name - the original behavior, so
|
|
355
|
+
// existing manifests keep their identity.
|
|
356
|
+
if (manifest.slug !== undefined && typeof manifest.slug !== "string") {
|
|
357
|
+
throw new Error(`${manifestPath} slug must be a string when set.`);
|
|
358
|
+
}
|
|
359
|
+
const manifestSlug =
|
|
360
|
+
typeof manifest.slug === "string"
|
|
361
|
+
? validateAgentSlug(manifest.slug, `${manifestPath} slug`)
|
|
362
|
+
: validateAgentSlug(deriveSlug(manifestName), "dharta.toml name");
|
|
363
|
+
if (explicitSlug && explicitSlug !== manifestSlug) {
|
|
364
|
+
throw new Error(
|
|
365
|
+
`Agent slug '${explicitSlug}' does not match ${manifestPath} identity ` +
|
|
366
|
+
`'${manifestSlug}' (name '${manifestName}'). The manifest identity is authoritative; ` +
|
|
367
|
+
`set a top-level slug in dharta.toml to pin it explicitly.`,
|
|
368
|
+
);
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
if (manifest.deploy !== undefined && typeof manifest.deploy !== "boolean") {
|
|
372
|
+
throw new Error(`${manifestPath} deploy must be true or false.`);
|
|
373
|
+
}
|
|
374
|
+
if (manifest.deploy !== true) {
|
|
375
|
+
const state = manifest.deploy === false ? "deploy = false" : "deploy is absent";
|
|
376
|
+
throw new Error(
|
|
377
|
+
`${manifestSlug} is disabled by ${manifestPath} (${state}). ` +
|
|
378
|
+
"Set deploy = true before deploying.",
|
|
379
|
+
);
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
await rejectOverlappingAgentRoots(dir, git);
|
|
383
|
+
const deployMethod = parseDeployMethod(manifest.deploy_method, manifestPath);
|
|
384
|
+
return { dir, slug: manifestSlug, name: manifestName, deployMethod };
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
async function resolveAgentDir(start: string): Promise<string | null> {
|
|
388
|
+
const requestedDir = resolve(start);
|
|
389
|
+
const gitRoot = findGitRoot(requestedDir);
|
|
390
|
+
const boundary = gitRoot ? resolve(gitRoot) : null;
|
|
391
|
+
let dir = requestedDir;
|
|
392
|
+
for (;;) {
|
|
393
|
+
if (existsSync(join(dir, "dharta.toml"))) return dir;
|
|
394
|
+
if (boundary && dir === boundary) break;
|
|
395
|
+
const parent = dirname(dir);
|
|
396
|
+
if (parent === dir) return null;
|
|
397
|
+
dir = parent;
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
// A repository root is a useful compatibility entrypoint only when it has
|
|
401
|
+
// exactly one configured child agent. We never choose among several.
|
|
402
|
+
const configured = await configuredAgents(boundary as string);
|
|
403
|
+
if (requestedDir === boundary && configured.length === 1) return configured[0].dir;
|
|
404
|
+
if (configured.length > 0) {
|
|
405
|
+
const choices = configured.map((folder) => ` - ${folder.rel}`).join("\n");
|
|
406
|
+
throw new Error(
|
|
407
|
+
`${requestedDir === boundary ? "Multiple agent folders are configured in this repo." : `No agent folder contains ${requestedDir}.`} ` +
|
|
408
|
+
`Run from one agent folder or pass --dir:\n${choices}`,
|
|
409
|
+
);
|
|
410
|
+
}
|
|
411
|
+
return null;
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
async function configuredAgents(root: string) {
|
|
415
|
+
return (await scanAgentFolders(root, { isGitRepo: true }))
|
|
416
|
+
.filter((folder) => folder.configured);
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
export async function rejectOverlappingAgentRoots(agentDir: string, git: GitRunner): Promise<void> {
|
|
420
|
+
const gitRoot = findGitRoot(agentDir);
|
|
421
|
+
const ancestorManifests: string[] = [];
|
|
422
|
+
let parent = dirname(resolve(agentDir));
|
|
423
|
+
const boundary = gitRoot ? resolve(gitRoot) : undefined;
|
|
424
|
+
for (;;) {
|
|
425
|
+
if (boundary && parent !== boundary && !parent.startsWith(`${boundary}${sep}`)) break;
|
|
426
|
+
if (existsSync(join(parent, "dharta.toml"))) {
|
|
427
|
+
ancestorManifests.push(relative(parent, agentDir) || ".");
|
|
428
|
+
}
|
|
429
|
+
if ((boundary && parent === boundary) || parent === dirname(parent)) break;
|
|
430
|
+
parent = dirname(parent);
|
|
431
|
+
}
|
|
432
|
+
// Use the exact recursive file view folder packaging uses: files allowed by
|
|
433
|
+
// repository/ancestor .gitignore files. This catches deep children without
|
|
434
|
+
// following symlinked directories, and is deliberately independent of a
|
|
435
|
+
// developer's global excludes or .git/info/exclude.
|
|
436
|
+
const nested = (await listDeployFiles(agentDir, git))
|
|
437
|
+
.filter((file) => file !== "dharta.toml" && file.endsWith("/dharta.toml"));
|
|
438
|
+
if (nested.length === 0 && ancestorManifests.length === 0) return;
|
|
439
|
+
|
|
440
|
+
const paths = [
|
|
441
|
+
...ancestorManifests.map((relPath) => `ancestor:${relPath}`),
|
|
442
|
+
...nested.map((file) => dirname(file)),
|
|
443
|
+
].join(", ");
|
|
444
|
+
throw new Error(
|
|
445
|
+
`${join(agentDir, "dharta.toml")} contains nested agent folder(s): ${paths}. ` +
|
|
446
|
+
"Agent folders cannot overlap; move each dharta.toml into a sibling folder before deploying.",
|
|
447
|
+
);
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
// `git commit` always commits the whole index. On an unborn branch, setup's
|
|
451
|
+
// convenience commit may therefore include files a user staged for a sibling
|
|
452
|
+
// agent before running deploy, even though the preceding `git add` is scoped
|
|
453
|
+
// to the selected agent. Refuse that case before we touch the index. Staged
|
|
454
|
+
// files inside the selected root are safe: they are part of the one initial
|
|
455
|
+
// agent commit deploy is explicitly about to create.
|
|
456
|
+
async function rejectStagedFilesOutsideAgent(
|
|
457
|
+
git: GitRunner,
|
|
458
|
+
repoRoot: string,
|
|
459
|
+
agentPath: string,
|
|
460
|
+
): Promise<void> {
|
|
461
|
+
if (agentPath === ".") return;
|
|
462
|
+
|
|
463
|
+
const staged = await git(["-C", repoRoot, "diff", "--cached", "--name-only", "-z", "--"]);
|
|
464
|
+
if (staged.code !== 0) {
|
|
465
|
+
throw new Error(`git could not inspect staged files in ${repoRoot}: ${gitFailure(staged)}`);
|
|
466
|
+
}
|
|
467
|
+
const prefix = `${agentPath}/`;
|
|
468
|
+
const unrelated = parseNulList(staged.stdout)
|
|
469
|
+
.filter((file) => file !== agentPath && !file.startsWith(prefix));
|
|
470
|
+
if (unrelated.length === 0) return;
|
|
471
|
+
|
|
472
|
+
const listed = unrelated.slice(0, 3).map((file) => JSON.stringify(file)).join(", ");
|
|
473
|
+
const remainder = unrelated.length > 3 ? ` (and ${unrelated.length - 3} more)` : "";
|
|
474
|
+
throw new Error(
|
|
475
|
+
`Cannot make the initial commit for agent '${agentPath}': unrelated staged file(s) ` +
|
|
476
|
+
`${listed}${remainder} would also be committed. Commit or unstage those files first, then re-run; ` +
|
|
477
|
+
"Dharta left the index unchanged.",
|
|
478
|
+
);
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
async function requireManifestInHead(
|
|
482
|
+
git: GitRunner,
|
|
483
|
+
repoRoot: string,
|
|
484
|
+
manifestPath: string,
|
|
485
|
+
): Promise<void> {
|
|
486
|
+
const tree = await git([
|
|
487
|
+
"-C", repoRoot, "ls-tree", "-r", "--name-only", "-z", "HEAD", "--",
|
|
488
|
+
literalPathspec(manifestPath),
|
|
489
|
+
]);
|
|
490
|
+
if (tree.code !== 0) {
|
|
491
|
+
throw new Error(`git could not inspect committed ${manifestPath}: ${gitFailure(tree)}`);
|
|
492
|
+
}
|
|
493
|
+
if (parseNulList(tree.stdout).includes(manifestPath)) return;
|
|
494
|
+
|
|
495
|
+
await rejectIgnoredManifest(git, repoRoot, manifestPath, "HEAD");
|
|
496
|
+
throw new Error(
|
|
497
|
+
`${manifestPath} is not committed in HEAD. Add and commit the selected agent manifest, ` +
|
|
498
|
+
"then re-run `dharta deploy`; no hosted source was provisioned.",
|
|
499
|
+
);
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
async function rejectIgnoredManifest(
|
|
503
|
+
git: GitRunner,
|
|
504
|
+
repoRoot: string,
|
|
505
|
+
manifestPath: string,
|
|
506
|
+
destination: string,
|
|
507
|
+
): Promise<void> {
|
|
508
|
+
// `git check-ignore` rejects the `:(literal)` pathspec magic supported by
|
|
509
|
+
// status/add/ls-tree. Run it from the selected folder against a fixed,
|
|
510
|
+
// non-magical basename instead; the repository's ancestor rules still
|
|
511
|
+
// apply, while an agent directory named e.g. `:(top)` cannot inject magic.
|
|
512
|
+
const agentDir = resolve(repoRoot, dirname(manifestPath));
|
|
513
|
+
const ignored = await git([
|
|
514
|
+
"-C", agentDir, "check-ignore", "--no-index", "--", "./dharta.toml",
|
|
515
|
+
]);
|
|
516
|
+
if (ignored.code === 1) return;
|
|
517
|
+
if (ignored.code !== 0) {
|
|
518
|
+
throw new Error(`git could not check ignore rules for ${manifestPath}: ${gitFailure(ignored)}`);
|
|
519
|
+
}
|
|
520
|
+
if (!ignored.stdout.split(/\r?\n/).some((path) => path === "dharta.toml" || path === "./dharta.toml")) {
|
|
521
|
+
return;
|
|
522
|
+
}
|
|
523
|
+
throw new Error(
|
|
524
|
+
`${manifestPath} is excluded by .gitignore and will not be present in ${destination}. ` +
|
|
525
|
+
"Unignore it, add and commit it, then re-run `dharta deploy`; no hosted source was provisioned.",
|
|
526
|
+
);
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
async function readConfiguredDeployMethod(dir: string): Promise<DeployMode | undefined> {
|
|
530
|
+
const manifestPath = join(dir, "dharta.toml");
|
|
531
|
+
let manifest: Record<string, unknown>;
|
|
532
|
+
try {
|
|
533
|
+
manifest = TOML.parse(await readFile(manifestPath, "utf8")) as Record<string, unknown>;
|
|
534
|
+
} catch (error) {
|
|
535
|
+
throw new Error(`Could not read ${manifestPath}: ${(error as Error).message}`);
|
|
536
|
+
}
|
|
537
|
+
return parseDeployMethod(manifest.deploy_method, manifestPath);
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
function parseDeployMethod(value: unknown, manifestPath: string): DeployMode | undefined {
|
|
541
|
+
if (value === undefined) return undefined;
|
|
542
|
+
if (value === "git" || value === "folder") return value;
|
|
543
|
+
throw new Error(`${manifestPath} deploy_method must be "git" or "folder".`);
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
async function inferLegacyMode(dir: string, git: GitRunner): Promise<DeployMode> {
|
|
547
|
+
return (await isGitRepo(git, resolve(dir))) ? "git" : "folder";
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
function gitAgentPath(repoRoot: string, agentDir: string): string {
|
|
551
|
+
const native = relative(repoRoot, agentDir);
|
|
552
|
+
if (native === "") return ".";
|
|
553
|
+
if (native === ".." || native.startsWith(`..${sep}`) || resolve(repoRoot, native) !== resolve(agentDir)) {
|
|
554
|
+
throw new Error(`Agent directory ${agentDir} is outside git root ${repoRoot}.`);
|
|
555
|
+
}
|
|
556
|
+
const posix = native.split(sep).join("/");
|
|
557
|
+
if (
|
|
558
|
+
posix.startsWith("/") ||
|
|
559
|
+
posix.includes("\0") ||
|
|
560
|
+
posix.includes("\r") ||
|
|
561
|
+
posix.includes("\n") ||
|
|
562
|
+
posix.split("/").some((part) => part === "" || part === "." || part === "..")
|
|
563
|
+
) {
|
|
564
|
+
throw new Error(`Agent path '${posix}' cannot be sent safely to Dharta git.`);
|
|
565
|
+
}
|
|
566
|
+
return posix;
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
// Git treats a path beginning with `:(...)` as pathspec magic and also
|
|
570
|
+
// expands wildcard metacharacters elsewhere. The selected agent folder is a
|
|
571
|
+
// filesystem name, never a pattern, so every Git command accepting a pathspec
|
|
572
|
+
// must opt into literal matching explicitly.
|
|
573
|
+
function literalPathspec(path: string): string {
|
|
574
|
+
return `:(literal)${path}`;
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
function validateDeployRoot(dir: string): void {
|
|
578
|
+
const resolved = resolve(dir);
|
|
579
|
+
if (lstatSync(resolved).isSymbolicLink()) {
|
|
580
|
+
throw new Error(
|
|
581
|
+
`Cannot deploy ${resolved}: the agent folder is a symbolic link. ` +
|
|
582
|
+
"Deploy a real, self-contained agent directory instead.",
|
|
583
|
+
);
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
const repoRoot = findGitRoot(resolved);
|
|
587
|
+
if (!repoRoot) return;
|
|
588
|
+
|
|
589
|
+
// Reject a symlink in any component below the repository root. Comparing
|
|
590
|
+
// canonical roots as well prevents a renamed/moved link from escaping the
|
|
591
|
+
// repository even if the lexical path still appears contained.
|
|
592
|
+
const rel = relative(resolve(repoRoot), resolved);
|
|
593
|
+
let cursor = resolve(repoRoot);
|
|
594
|
+
for (const part of rel.split(sep).filter(Boolean)) {
|
|
595
|
+
cursor = join(cursor, part);
|
|
596
|
+
if (lstatSync(cursor).isSymbolicLink()) {
|
|
597
|
+
throw new Error(
|
|
598
|
+
`Cannot deploy ${resolved}: the agent folder passes through symbolic link ${cursor}. ` +
|
|
599
|
+
"Deploy a real, self-contained directory inside the repository instead.",
|
|
600
|
+
);
|
|
601
|
+
}
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
const canonicalRepo = realpathSync(repoRoot);
|
|
605
|
+
const canonicalAgent = realpathSync(resolved);
|
|
606
|
+
if (!pathIsInside(canonicalRepo, canonicalAgent)) {
|
|
607
|
+
throw new Error(
|
|
608
|
+
`Cannot deploy ${resolved}: its real path is outside repository ${repoRoot}. ` +
|
|
609
|
+
"Deploy a real, self-contained directory inside the repository instead.",
|
|
610
|
+
);
|
|
611
|
+
}
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
export function validateDeployDir(dir: string): void {
|
|
615
|
+
const resolved = resolve(normalizeNonBlank(dir, "--dir"));
|
|
616
|
+
try {
|
|
617
|
+
if (!statSync(resolved).isDirectory()) {
|
|
618
|
+
throw new Error(`Cannot deploy ${resolved}: expected a directory.`);
|
|
619
|
+
}
|
|
620
|
+
} catch (error) {
|
|
621
|
+
if ((error as NodeJS.ErrnoException).code === "ENOENT") {
|
|
622
|
+
throw new Error(`Cannot deploy ${resolved}: directory does not exist.`);
|
|
623
|
+
}
|
|
624
|
+
throw error;
|
|
625
|
+
}
|
|
626
|
+
}
|
|
627
|
+
|
|
628
|
+
async function isGitRepo(git: GitRunner, dir: string): Promise<boolean> {
|
|
629
|
+
const r = await git(["-C", dir, "rev-parse", "--is-inside-work-tree"]);
|
|
630
|
+
return r.code === 0;
|
|
631
|
+
}
|
|
632
|
+
|
|
633
|
+
async function ensureRemote(
|
|
634
|
+
git: GitRunner,
|
|
635
|
+
dir: string,
|
|
636
|
+
url: string,
|
|
637
|
+
): Promise<void> {
|
|
638
|
+
// add, or repoint if it already exists (same contract as before: re-
|
|
639
|
+
// running after a slug change must not strand the old URL).
|
|
640
|
+
const added = await git(["-C", dir, "remote", "add", "dharta", url]);
|
|
641
|
+
if (added.code !== 0) {
|
|
642
|
+
const updated = await git(["-C", dir, "remote", "set-url", "dharta", url]);
|
|
643
|
+
if (updated.code !== 0) {
|
|
644
|
+
throw new Error(
|
|
645
|
+
"Could not set the 'karta' git remote. Check `git remote -v` and add it " +
|
|
646
|
+
`yourself: git remote add dharta ${url}`,
|
|
647
|
+
);
|
|
648
|
+
}
|
|
649
|
+
}
|
|
650
|
+
}
|
|
651
|
+
|
|
652
|
+
async function must(
|
|
653
|
+
git: GitRunner,
|
|
654
|
+
args: string[],
|
|
655
|
+
label: string,
|
|
656
|
+
): Promise<void> {
|
|
657
|
+
const r = await git(args);
|
|
658
|
+
if (r.code !== 0) throw new Error(`${label} failed: ${gitFailure(r)}`);
|
|
659
|
+
}
|
|
660
|
+
|
|
661
|
+
function gitFailure(r: GitResult): string {
|
|
662
|
+
return r.stderr.trim() || `exit ${r.code}`;
|
|
663
|
+
}
|
|
664
|
+
|
|
665
|
+
function retryableAtomicRefRace(r: GitResult): boolean {
|
|
666
|
+
return /cannot lock ref|stale info|atomic transaction failed|reference already exists/i
|
|
667
|
+
.test(`${r.stdout}\n${r.stderr}`);
|
|
668
|
+
}
|
|
669
|
+
|
|
670
|
+
// Mirrors Api::PublishesController's contract.
|
|
671
|
+
const SLUG_FORMAT = /^[a-z0-9][a-z0-9-]{0,62}$/;
|
|
672
|
+
const MAX_TARBALL_BYTES = 200 * 1024 * 1024;
|
|
673
|
+
|
|
674
|
+
// `--mode folder`: pack the working tree (honoring only .gitignore) and
|
|
675
|
+
// upload it to the publish-by-upload endpoint. The HTTP response is the result.
|
|
676
|
+
// A 202 is durable acceptance, not activation:
|
|
677
|
+
// report it as incomplete so CI cannot mistake a recoverable background publish
|
|
678
|
+
// for a live deployment.
|
|
679
|
+
async function deployFolder(
|
|
680
|
+
ctx: CliContext,
|
|
681
|
+
dir: string,
|
|
682
|
+
slugCandidate: string,
|
|
683
|
+
displayName: string,
|
|
684
|
+
git: GitRunner,
|
|
685
|
+
log: (line: string) => void,
|
|
686
|
+
): Promise<DeployFolderResult> {
|
|
687
|
+
const agentSlug = slugCandidate.toLowerCase();
|
|
688
|
+
if (!SLUG_FORMAT.test(agentSlug)) {
|
|
689
|
+
throw new Error(
|
|
690
|
+
`"${agentSlug}" is not a valid agent slug (lowercase letters, digits, ` +
|
|
691
|
+
`hyphens; max 63). Pass one explicitly: dharta deploy <slug> --mode folder`,
|
|
692
|
+
);
|
|
693
|
+
}
|
|
694
|
+
|
|
695
|
+
// Enumerate via git so we honor exactly repository/ancestor .gitignore files
|
|
696
|
+
// and include the working tree as-is: tracked + untracked-not-ignored.
|
|
697
|
+
// Machine-local global excludes and .git/info/exclude are intentionally not
|
|
698
|
+
// release inputs, keeping local deploys and generated CI deterministic.
|
|
699
|
+
// A folder-mode setup deliberately does not `git init`, so the helper falls
|
|
700
|
+
// back to a temporary bare git dir for truly plain folders.
|
|
701
|
+
const files = (await listDeployFiles(dir, git))
|
|
702
|
+
.filter((f) => !isAppleMetadata(f)) // drop macOS sidecar cruft (._*, .DS_Store)
|
|
703
|
+
.filter((f) => existsSync(join(dir, f))); // skip tracked-but-deleted paths
|
|
704
|
+
validateDeployFiles(dir, files);
|
|
705
|
+
if (!files.includes("dharta.toml")) {
|
|
706
|
+
throw new Error(
|
|
707
|
+
`dharta.toml is excluded by Git ignore rules and will not be included in the folder deployment. ` +
|
|
708
|
+
"Unignore it, then re-run `dharta deploy`; no release was uploaded.",
|
|
709
|
+
);
|
|
710
|
+
}
|
|
711
|
+
if (files.length === 0) {
|
|
712
|
+
throw new Error(
|
|
713
|
+
`Nothing to deploy in ${dir} - the tree is empty or everything is gitignored.`,
|
|
714
|
+
);
|
|
715
|
+
}
|
|
716
|
+
|
|
717
|
+
// Honoring only .gitignore means a .env that isn't ignored ships to the
|
|
718
|
+
// server. Don't silently exclude it (that's the chosen contract) - warn
|
|
719
|
+
// so an accidental secret upload is at least visible.
|
|
720
|
+
for (const f of files.filter((p) => /(^|\/)\.env(\.[^/]+)?$/.test(p))) {
|
|
721
|
+
log(`Warning: ${f} is not gitignored and will be uploaded - add it to .gitignore to exclude it.`);
|
|
722
|
+
}
|
|
723
|
+
|
|
724
|
+
const tarball = await packTarball(dir, files);
|
|
725
|
+
try {
|
|
726
|
+
const bytes = await readFile(tarball);
|
|
727
|
+
if (bytes.length > MAX_TARBALL_BYTES) {
|
|
728
|
+
throw new Error(
|
|
729
|
+
`Packed tarball is ${formatBytes(bytes.length)}, over the ` +
|
|
730
|
+
`${formatBytes(MAX_TARBALL_BYTES)} limit. Trim the folder ` +
|
|
731
|
+
"(a .gitignore for build output / dependencies usually does it).",
|
|
732
|
+
);
|
|
733
|
+
}
|
|
734
|
+
// No commit means no SHA; the endpoint keys releases by a hex SHA, so a
|
|
735
|
+
// content hash of the tarball is the honest identifier (stable for an
|
|
736
|
+
// unchanged tree, and 40 hex chars satisfies the server's format).
|
|
737
|
+
const commitSha = createHash("sha256").update(bytes).digest("hex").slice(0, 40);
|
|
738
|
+
|
|
739
|
+
log(`Uploading ${files.length} files (${formatBytes(bytes.length)}) as ${agentSlug} ...`);
|
|
740
|
+
log("Building + activating on the server (this can take a minute) ...");
|
|
741
|
+
const form = new FormData();
|
|
742
|
+
form.set("commit_sha", commitSha);
|
|
743
|
+
form.set("name", displayName);
|
|
744
|
+
form.set("tarball", new Blob([bytes], { type: "application/gzip" }), `${agentSlug}.tar.gz`);
|
|
745
|
+
|
|
746
|
+
const res = await ctx.http.raw(
|
|
747
|
+
`/api/agents/${encodeURIComponent(agentSlug)}/publish`,
|
|
748
|
+
{ method: "POST", body: form },
|
|
749
|
+
);
|
|
750
|
+
if (!res.ok) throw await cliFormatRawError(res);
|
|
751
|
+
|
|
752
|
+
const text = await res.text();
|
|
753
|
+
let parsed: unknown = null;
|
|
754
|
+
if (text.length > 0) {
|
|
755
|
+
try {
|
|
756
|
+
parsed = JSON.parse(text);
|
|
757
|
+
} catch {
|
|
758
|
+
parsed = text;
|
|
759
|
+
}
|
|
760
|
+
}
|
|
761
|
+
const result = parseDeployFolderResult(parsed, agentSlug);
|
|
762
|
+
const release = result.release;
|
|
763
|
+
// Echo the frozen harness so the developer sees exactly what prod will run
|
|
764
|
+
// (RFC 0035 D7). Git mode streams the same line via the build sideband.
|
|
765
|
+
const harness =
|
|
766
|
+
release.harness_slug && release.harness_version
|
|
767
|
+
? ` on ${release.harness_slug} ${release.harness_version}`
|
|
768
|
+
: "";
|
|
769
|
+
if (res.status === 202 || result.state === "pending") {
|
|
770
|
+
log(
|
|
771
|
+
`Accepted ${agentSlug} release v${release.version}; publication is still pending${harness}.`,
|
|
772
|
+
);
|
|
773
|
+
throw new Error(
|
|
774
|
+
"Dharta retained the release but could not confirm activation before the deploy request ended. " +
|
|
775
|
+
`Check \`dharta agent show ${agentSlug}\` or \`dharta agent releases ${agentSlug}\`; ` +
|
|
776
|
+
"do not assume it is live or immediately upload the same source again.",
|
|
777
|
+
);
|
|
778
|
+
}
|
|
779
|
+
log(`Deployed ${agentSlug} release v${release.version} (${release.status})${harness}.`);
|
|
780
|
+
log(`Open it: dharta open -p ${agentSlug} · Details: dharta agent show ${agentSlug}`);
|
|
781
|
+
return result;
|
|
782
|
+
} finally {
|
|
783
|
+
await rm(dirname(tarball), { recursive: true, force: true });
|
|
784
|
+
}
|
|
785
|
+
}
|
|
786
|
+
|
|
787
|
+
function parseDeployFolderResult(value: unknown, expectedSlug: string): DeployFolderResult {
|
|
788
|
+
const body = asRecord(value);
|
|
789
|
+
const agent = asRecord(body?.agent);
|
|
790
|
+
const release = asRecord(body?.release);
|
|
791
|
+
const phase = body?.phase;
|
|
792
|
+
const state = body?.state;
|
|
793
|
+
const harnessSlug = release?.harness_slug;
|
|
794
|
+
const harnessVersion = release?.harness_version;
|
|
795
|
+
if (
|
|
796
|
+
body?.accepted !== true ||
|
|
797
|
+
typeof agent?.id !== "number" ||
|
|
798
|
+
!Number.isInteger(agent.id) ||
|
|
799
|
+
typeof agent.slug !== "string" ||
|
|
800
|
+
agent.slug !== expectedSlug ||
|
|
801
|
+
typeof agent.name !== "string" ||
|
|
802
|
+
typeof release?.version !== "number" ||
|
|
803
|
+
!Number.isInteger(release.version) ||
|
|
804
|
+
typeof release.status !== "string" ||
|
|
805
|
+
(phase !== undefined && phase !== "publication") ||
|
|
806
|
+
(state !== undefined && state !== "pending") ||
|
|
807
|
+
(harnessSlug !== undefined && harnessSlug !== null && typeof harnessSlug !== "string") ||
|
|
808
|
+
(harnessVersion !== undefined && harnessVersion !== null && typeof harnessVersion !== "string")
|
|
809
|
+
) {
|
|
810
|
+
throw new Error(
|
|
811
|
+
"Dharta returned an invalid folder deploy response. The release may have been created; " +
|
|
812
|
+
`check \`dharta agent show ${expectedSlug}\` before retrying.`,
|
|
813
|
+
);
|
|
814
|
+
}
|
|
815
|
+
return {
|
|
816
|
+
accepted: true,
|
|
817
|
+
...(phase === "publication" ? { phase } : {}),
|
|
818
|
+
...(state === "pending" ? { state } : {}),
|
|
819
|
+
agent: { id: agent.id, slug: agent.slug, name: agent.name },
|
|
820
|
+
release: {
|
|
821
|
+
version: release.version,
|
|
822
|
+
status: release.status,
|
|
823
|
+
harness_slug: typeof harnessSlug === "string" ? harnessSlug : null,
|
|
824
|
+
harness_version: typeof harnessVersion === "string" ? harnessVersion : null,
|
|
825
|
+
},
|
|
826
|
+
};
|
|
827
|
+
}
|
|
828
|
+
|
|
829
|
+
function asRecord(value: unknown): Record<string, unknown> | undefined {
|
|
830
|
+
return value !== null && typeof value === "object" && !Array.isArray(value)
|
|
831
|
+
? value as Record<string, unknown>
|
|
832
|
+
: undefined;
|
|
833
|
+
}
|
|
834
|
+
|
|
835
|
+
// Read the slug from the `dharta` git remote already wired in `dir`, or
|
|
836
|
+
// undefined when there is no repo / no such remote. Uses the injected
|
|
837
|
+
// runner so it shares deploy's test seam.
|
|
838
|
+
async function remoteSlug(git: GitRunner, dir: string): Promise<string | undefined> {
|
|
839
|
+
const r = await git(["-C", dir, "remote", "get-url", "dharta"]);
|
|
840
|
+
if (r.code !== 0) return undefined;
|
|
841
|
+
return slugFromRemote(r.stdout.trim()) ?? undefined;
|
|
842
|
+
}
|
|
843
|
+
|
|
844
|
+
// A fresh clone, generated CI checkout, or worktree may not have Dharta's
|
|
845
|
+
// hosted remote yet. Prefer a canonical upstream identity in that case so a
|
|
846
|
+
// differently named checkout does not accidentally provision a second
|
|
847
|
+
// SourceRepo—and two owners with the same repo basename do not collide.
|
|
848
|
+
// `origin` wins, then `upstream`, then any other non-Dharta remote; the local
|
|
849
|
+
// checkout basename remains a last resort when no network remote exists.
|
|
850
|
+
async function stableRepositoryHint(git: GitRunner, repoRoot: string): Promise<string> {
|
|
851
|
+
const listed = await git(["-C", repoRoot, "remote", "-v"]);
|
|
852
|
+
if (listed.code === 0) {
|
|
853
|
+
const remotes = new Map<string, string>();
|
|
854
|
+
for (const line of listed.stdout.split(/\r?\n/)) {
|
|
855
|
+
const match = line.match(/^(\S+)\s+(\S+)\s+\((fetch|push)\)$/);
|
|
856
|
+
if (!match || match[1] === "dharta") continue;
|
|
857
|
+
const [, name, url, direction] = match;
|
|
858
|
+
if (direction === "fetch" || !remotes.has(name)) remotes.set(name, url);
|
|
859
|
+
}
|
|
860
|
+
const ordered = [
|
|
861
|
+
"origin",
|
|
862
|
+
"upstream",
|
|
863
|
+
...[...remotes.keys()].filter((name) => name !== "origin" && name !== "upstream").sort(),
|
|
864
|
+
];
|
|
865
|
+
for (const name of ordered) {
|
|
866
|
+
const url = remotes.get(name);
|
|
867
|
+
if (!url) continue;
|
|
868
|
+
const repository = repositoryHintFromRemote(url);
|
|
869
|
+
if (repository) return repository;
|
|
870
|
+
}
|
|
871
|
+
}
|
|
872
|
+
return basename(repoRoot);
|
|
873
|
+
}
|
|
874
|
+
|
|
875
|
+
export function repositoryHintFromRemote(url: string): string | undefined {
|
|
876
|
+
const raw = url.trim();
|
|
877
|
+
if (!raw) return undefined;
|
|
878
|
+
let host: string | undefined;
|
|
879
|
+
let repositoryPath: string;
|
|
880
|
+
|
|
881
|
+
try {
|
|
882
|
+
const parsed = new URL(raw);
|
|
883
|
+
// A local file remote cannot provide clone-stable identity. Let the caller
|
|
884
|
+
// use its explicit local fallback instead.
|
|
885
|
+
if (parsed.protocol === "file:") return undefined;
|
|
886
|
+
host = parsed.host.toLowerCase();
|
|
887
|
+
repositoryPath = decodeURIComponent(parsed.pathname);
|
|
888
|
+
} catch {
|
|
889
|
+
// SCP-style SSH URLs (`git@github.com:org/repo.git`) are not WHATWG
|
|
890
|
+
// URLs. Normalize them to the same host/path identity as HTTPS and
|
|
891
|
+
// ssh:// clones. Plain filesystem paths are not clone-stable hints.
|
|
892
|
+
const scp = raw.match(/^(?:[^@/:]+@)?([^/:]+):(.+)$/);
|
|
893
|
+
if (!scp) return undefined;
|
|
894
|
+
host = scp[1]?.toLowerCase();
|
|
895
|
+
repositoryPath = scp[2] ?? "";
|
|
896
|
+
}
|
|
897
|
+
|
|
898
|
+
repositoryPath = repositoryPath
|
|
899
|
+
.replace(/[?#].*$/, "")
|
|
900
|
+
.replace(/^[/\\]+|[/\\]+$/g, "")
|
|
901
|
+
.replace(/\.git$/i, "")
|
|
902
|
+
.replace(/\\/g, "/");
|
|
903
|
+
if (!host || !repositoryPath) return undefined;
|
|
904
|
+
|
|
905
|
+
const identity = `${host}/${repositoryPath}`;
|
|
906
|
+
const digest = createHash("sha256").update(identity).digest("hex").slice(0, 10);
|
|
907
|
+
const readable = deriveSlug(identity.replace(/[./:@]+/g, "-"))
|
|
908
|
+
.slice(0, 52)
|
|
909
|
+
.replace(/-+$/, "") || "repo";
|
|
910
|
+
return `${readable}-${digest}`;
|
|
911
|
+
}
|
|
912
|
+
|
|
913
|
+
async function configuredAgentSlugs(repoRoot: string, selectedSlug: string): Promise<string[]> {
|
|
914
|
+
const folders = await scanAgentFolders(repoRoot, { isGitRepo: true });
|
|
915
|
+
const slugs = new Set<string>([selectedSlug]);
|
|
916
|
+
for (const folder of folders) {
|
|
917
|
+
if (!folder.configured || typeof folder.name !== "string" || folder.name.trim().length === 0) continue;
|
|
918
|
+
try {
|
|
919
|
+
const candidate = deriveSlug(validateAgentName(folder.name, "configured agent name"));
|
|
920
|
+
slugs.add(validateAgentSlug(candidate, "configured agent slug"));
|
|
921
|
+
} catch {
|
|
922
|
+
// A malformed sibling must not block deploying the selected agent. Its
|
|
923
|
+
// manifest will surface when that folder itself is selected.
|
|
924
|
+
}
|
|
925
|
+
}
|
|
926
|
+
const siblings = [...slugs]
|
|
927
|
+
.filter((slug) => slug !== selectedSlug)
|
|
928
|
+
.sort()
|
|
929
|
+
.slice(0, MAX_SOURCE_REPO_AGENT_CANDIDATES - 1);
|
|
930
|
+
return [selectedSlug, ...siblings];
|
|
931
|
+
}
|
|
932
|
+
|
|
933
|
+
// macOS sidecar cruft that `git ls-files --others` can surface on a Mac and that
|
|
934
|
+
// `tar` itself injects per xattr'd file: AppleDouble resource forks (`._X`) and
|
|
935
|
+
// Finder's `.DS_Store`. Never meaningful agent content - strip both so a
|
|
936
|
+
// Mac-built release isn't littered with them (they would otherwise land in the
|
|
937
|
+
// agent's runtime workspace AND every workspace-git clone, RFC 0043 D9).
|
|
938
|
+
export function isAppleMetadata(relPath: string): boolean {
|
|
939
|
+
const base = relPath.split("/").pop() ?? relPath;
|
|
940
|
+
return base.startsWith("._") || base === ".DS_Store";
|
|
941
|
+
}
|
|
942
|
+
|
|
943
|
+
// Pack exactly `files` (relative to dir) into a gzip tarball in a fresh temp
|
|
944
|
+
// dir. NUL-delimited file list so unusual filenames survive; shells to `tar`
|
|
945
|
+
// like the parity command does for untar.
|
|
946
|
+
async function packTarball(dir: string, files: string[]): Promise<string> {
|
|
947
|
+
const work = await mkdtemp(join(tmpdir(), "dharta-deploy-"));
|
|
948
|
+
try {
|
|
949
|
+
const listFile = join(work, "files.list");
|
|
950
|
+
const out = join(work, "app.tar.gz");
|
|
951
|
+
await writeFile(listFile, files.map((f) => `${f}\0`).join(""));
|
|
952
|
+
await runTar(["-czf", out, "-C", dir, "--null", "-T", listFile]);
|
|
953
|
+
return out;
|
|
954
|
+
} catch (err) {
|
|
955
|
+
await rm(work, { recursive: true, force: true });
|
|
956
|
+
throw err;
|
|
957
|
+
}
|
|
958
|
+
}
|
|
959
|
+
|
|
960
|
+
async function listDeployFiles(dir: string, git: GitRunner): Promise<string[]> {
|
|
961
|
+
const trackedAndUntracked = [
|
|
962
|
+
"ls-files", "-z", "--stage", "--cached", "--others", "--exclude-per-directory=.gitignore",
|
|
963
|
+
];
|
|
964
|
+
const listed = await git(["-C", dir, ...trackedAndUntracked]);
|
|
965
|
+
if (listed.code === 0) return parseDeployFileList(listed.stdout, dir);
|
|
966
|
+
|
|
967
|
+
const work = await mkdtemp(join(tmpdir(), "dharta-deploy-git-"));
|
|
968
|
+
const gitDir = join(work, "repo.git");
|
|
969
|
+
try {
|
|
970
|
+
const init = await git(["--git-dir", gitDir, "init", "--bare"]);
|
|
971
|
+
if (init.code !== 0) {
|
|
972
|
+
throw new Error(`git init failed for folder listing: ${gitFailure(init)}`);
|
|
973
|
+
}
|
|
974
|
+
const plain = await git(["--git-dir", gitDir, "--work-tree", dir, ...trackedAndUntracked]);
|
|
975
|
+
if (plain.code !== 0) {
|
|
976
|
+
throw new Error(`git ls-files failed for folder listing: ${gitFailure(plain)}`);
|
|
977
|
+
}
|
|
978
|
+
return parseDeployFileList(plain.stdout, dir);
|
|
979
|
+
} finally {
|
|
980
|
+
await rm(work, { recursive: true, force: true });
|
|
981
|
+
}
|
|
982
|
+
}
|
|
983
|
+
|
|
984
|
+
function validateDeployFiles(dir: string, files: string[]): void {
|
|
985
|
+
const canonicalRoot = realpathSync(dir);
|
|
986
|
+
for (const file of files) {
|
|
987
|
+
const parts = file.split("/");
|
|
988
|
+
if (
|
|
989
|
+
file.length === 0 ||
|
|
990
|
+
file.includes("\0") ||
|
|
991
|
+
file.startsWith("/") ||
|
|
992
|
+
parts.some((part) => part === "" || part === "." || part === "..")
|
|
993
|
+
) {
|
|
994
|
+
throw new Error(`Cannot package unsafe path ${JSON.stringify(file)} from ${dir}.`);
|
|
995
|
+
}
|
|
996
|
+
|
|
997
|
+
const absolute = resolve(dir, ...parts);
|
|
998
|
+
if (!pathIsInside(resolve(dir), absolute)) {
|
|
999
|
+
throw new Error(`Cannot package ${file}: it escapes agent folder ${dir}.`);
|
|
1000
|
+
}
|
|
1001
|
+
// Git normally reports files rather than directories. A directory here is
|
|
1002
|
+
// therefore a nested repository (or a future listing regression), and
|
|
1003
|
+
// passing it to tar would recursively bypass Git's ignore/index boundary.
|
|
1004
|
+
if (lstatSync(absolute).isDirectory()) {
|
|
1005
|
+
throw nestedRepositoryError(dir, file);
|
|
1006
|
+
}
|
|
1007
|
+
|
|
1008
|
+
let canonical: string;
|
|
1009
|
+
try {
|
|
1010
|
+
canonical = realpathSync(absolute);
|
|
1011
|
+
} catch (error) {
|
|
1012
|
+
throw new Error(`Cannot package ${file}: ${(error as Error).message}`);
|
|
1013
|
+
}
|
|
1014
|
+
if (!pathIsInside(canonicalRoot, canonical)) {
|
|
1015
|
+
throw new Error(
|
|
1016
|
+
`Cannot package ${file}: its real path is outside agent folder ${dir}. ` +
|
|
1017
|
+
"Agent folders must be self-contained.",
|
|
1018
|
+
);
|
|
1019
|
+
}
|
|
1020
|
+
}
|
|
1021
|
+
}
|
|
1022
|
+
|
|
1023
|
+
function pathIsInside(root: string, candidate: string): boolean {
|
|
1024
|
+
const rel = relative(root, candidate);
|
|
1025
|
+
return rel === "" || (rel !== ".." && !rel.startsWith(`..${sep}`) && !isAbsolute(rel));
|
|
1026
|
+
}
|
|
1027
|
+
|
|
1028
|
+
function parseNulList(stdout: string): string[] {
|
|
1029
|
+
return stdout.split("\0").filter((f) => f.length > 0);
|
|
1030
|
+
}
|
|
1031
|
+
|
|
1032
|
+
// With --stage, tracked paths carry their index mode while untracked paths are
|
|
1033
|
+
// still emitted as bare names. Mode 160000 is a gitlink (usually a submodule or
|
|
1034
|
+
// an embedded Git repository). Untracked embedded repositories are emitted as
|
|
1035
|
+
// a directory name ending in `/` because Git deliberately does not recurse
|
|
1036
|
+
// into them. Neither is a safe archive input: tar would recurse independently
|
|
1037
|
+
// of Git and could include the nested repository's metadata, ignored files,
|
|
1038
|
+
// and untracked files.
|
|
1039
|
+
function parseDeployFileList(stdout: string, dir: string): string[] {
|
|
1040
|
+
const files = new Set<string>();
|
|
1041
|
+
for (const entry of parseNulList(stdout)) {
|
|
1042
|
+
const indexed = /^([0-7]{6}) [0-9a-f]+ [0-3]\t([\s\S]+)$/.exec(entry);
|
|
1043
|
+
const mode = indexed?.[1];
|
|
1044
|
+
const file = indexed?.[2] ?? entry;
|
|
1045
|
+
if (mode === "160000" || file.endsWith("/")) {
|
|
1046
|
+
throw nestedRepositoryError(dir, file);
|
|
1047
|
+
}
|
|
1048
|
+
files.add(file);
|
|
1049
|
+
}
|
|
1050
|
+
return [...files];
|
|
1051
|
+
}
|
|
1052
|
+
|
|
1053
|
+
function nestedRepositoryError(dir: string, file: string): Error {
|
|
1054
|
+
const display = file.replace(/\/+$/, "") || file;
|
|
1055
|
+
return new Error(
|
|
1056
|
+
`Cannot package ${display} from ${dir}: it is a Git submodule or nested repository. ` +
|
|
1057
|
+
"An agent folder is a self-contained release boundary; vendor or materialize its contents " +
|
|
1058
|
+
"into the agent folder before deploying.",
|
|
1059
|
+
);
|
|
1060
|
+
}
|
|
1061
|
+
|
|
1062
|
+
function runTar(args: string[]): Promise<void> {
|
|
1063
|
+
return new Promise((resolvePromise, reject) => {
|
|
1064
|
+
// COPYFILE_DISABLE stops macOS/bsdtar from appending an AppleDouble (`._X`)
|
|
1065
|
+
// member for every xattr-carrying file (e.g. com.apple.provenance) - the real
|
|
1066
|
+
// source of the `._*` litter. Ignored by GNU tar, so it is a no-op on Linux/CI.
|
|
1067
|
+
const proc = spawn("tar", args, {
|
|
1068
|
+
stdio: ["ignore", "ignore", "pipe"],
|
|
1069
|
+
env: { ...process.env, COPYFILE_DISABLE: "1" },
|
|
1070
|
+
});
|
|
1071
|
+
const err: Buffer[] = [];
|
|
1072
|
+
proc.stderr?.on("data", (c) => err.push(c));
|
|
1073
|
+
proc.on("error", () =>
|
|
1074
|
+
reject(new Error("Could not run `tar` to pack the folder (is it installed?).")),
|
|
1075
|
+
);
|
|
1076
|
+
proc.on("close", (code) =>
|
|
1077
|
+
code === 0
|
|
1078
|
+
? resolvePromise()
|
|
1079
|
+
: reject(new Error(`tar exited ${code} packing the folder: ${Buffer.concat(err).toString().trim()}`)),
|
|
1080
|
+
);
|
|
1081
|
+
});
|
|
1082
|
+
}
|
|
1083
|
+
|
|
1084
|
+
function formatBytes(n: number): string {
|
|
1085
|
+
if (n < 1024) return `${n} B`;
|
|
1086
|
+
const units = ["KB", "MB", "GB"];
|
|
1087
|
+
let v = n / 1024;
|
|
1088
|
+
let i = 0;
|
|
1089
|
+
while (v >= 1024 && i < units.length - 1) {
|
|
1090
|
+
v /= 1024;
|
|
1091
|
+
i++;
|
|
1092
|
+
}
|
|
1093
|
+
return `${v.toFixed(1)} ${units[i]}`;
|
|
1094
|
+
}
|
|
1095
|
+
|
|
1096
|
+
export const defaultGitRunner: GitRunner = (args, opts = {}) =>
|
|
1097
|
+
new Promise((resolvePromise) => {
|
|
1098
|
+
const proc = spawn("git", args, {
|
|
1099
|
+
env: opts.env ? { ...process.env, ...opts.env } : process.env,
|
|
1100
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
1101
|
+
});
|
|
1102
|
+
const out: Buffer[] = [];
|
|
1103
|
+
const err: Buffer[] = [];
|
|
1104
|
+
proc.stdout?.on("data", (c: Buffer) => {
|
|
1105
|
+
out.push(c);
|
|
1106
|
+
if (opts.stream) process.stdout.write(c);
|
|
1107
|
+
});
|
|
1108
|
+
proc.stderr?.on("data", (c: Buffer) => {
|
|
1109
|
+
err.push(c);
|
|
1110
|
+
if (opts.stream) process.stderr.write(c);
|
|
1111
|
+
});
|
|
1112
|
+
proc.on("error", () =>
|
|
1113
|
+
resolvePromise({ code: 127, stdout: "", stderr: "could not run git" }),
|
|
1114
|
+
);
|
|
1115
|
+
proc.on("close", (code) =>
|
|
1116
|
+
resolvePromise({
|
|
1117
|
+
code: code ?? 1,
|
|
1118
|
+
stdout: Buffer.concat(out).toString(),
|
|
1119
|
+
stderr: Buffer.concat(err).toString(),
|
|
1120
|
+
}),
|
|
1121
|
+
);
|
|
1122
|
+
});
|