hilos-agent 0.1.16 → 0.5.1
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/README.md +129 -4
- package/bin/hilos-agent.mjs +31 -6
- package/package.json +1 -1
- package/src/cli.mjs +97 -1
- package/src/config.mjs +38 -3
- package/src/daemon.mjs +49 -0
- package/src/deploy.mjs +234 -0
- package/src/handler.mjs +963 -45
- package/src/hook.mjs +427 -0
- package/src/memory.mjs +26 -2
- package/src/progress-emitter.mjs +28 -3
- package/src/redact.mjs +54 -0
- package/src/resume.mjs +1 -1
- package/src/run.mjs +130 -14
package/src/deploy.mjs
ADDED
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
// Local-folder deploys through the user's own Vercel or Netlify CLI session.
|
|
2
|
+
// No OAuth or credentials are stored by hilos. Every captured string returned
|
|
3
|
+
// to callers is redacted before it can reach a report, transcript, or log.
|
|
4
|
+
|
|
5
|
+
import { constants as fsConstants } from "node:fs";
|
|
6
|
+
import { accessSync, existsSync } from "node:fs";
|
|
7
|
+
import { delimiter, join } from "node:path";
|
|
8
|
+
|
|
9
|
+
import { runCli, scrubHilosEnv } from "./cli.mjs";
|
|
10
|
+
import { redactSecrets } from "./redact.mjs";
|
|
11
|
+
|
|
12
|
+
export const DEPLOY_PROVIDERS = ["vercel", "netlify"];
|
|
13
|
+
export const DEFAULT_DEPLOY_TIMEOUT_MS = 5 * 60_000;
|
|
14
|
+
|
|
15
|
+
function isProvider(value) {
|
|
16
|
+
return DEPLOY_PROVIDERS.includes(value);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function findDeployCli(provider, env = process.env) {
|
|
20
|
+
if (!isProvider(provider)) return null;
|
|
21
|
+
for (const entry of String(env.PATH || "").split(delimiter)) {
|
|
22
|
+
if (!entry) continue;
|
|
23
|
+
const candidate = join(entry, provider);
|
|
24
|
+
try {
|
|
25
|
+
accessSync(candidate, fsConstants.X_OK);
|
|
26
|
+
return candidate;
|
|
27
|
+
} catch {
|
|
28
|
+
// Continue through PATH.
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
return null;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** Resolve explicit channel config first, then provider-owned project markers. */
|
|
35
|
+
export function resolveDeployTarget({ cfg, channelId, folderPath, env = process.env, pathExists = existsSync }) {
|
|
36
|
+
const configured = cfg?.deploy?.[channelId];
|
|
37
|
+
let provider = isProvider(configured?.provider) ? configured.provider : null;
|
|
38
|
+
let prod = configured?.prod === true;
|
|
39
|
+
let source = provider ? "config" : null;
|
|
40
|
+
|
|
41
|
+
if (!provider) {
|
|
42
|
+
if (pathExists(join(folderPath, ".vercel")) || pathExists(join(folderPath, "vercel.json"))) {
|
|
43
|
+
provider = "vercel";
|
|
44
|
+
source = "detected";
|
|
45
|
+
} else if (pathExists(join(folderPath, ".netlify")) || pathExists(join(folderPath, "netlify.toml"))) {
|
|
46
|
+
provider = "netlify";
|
|
47
|
+
source = "detected";
|
|
48
|
+
}
|
|
49
|
+
prod = false;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
if (!provider) return { enabled: false, reason: "not-configured" };
|
|
53
|
+
const cliPath = findDeployCli(provider, env);
|
|
54
|
+
return {
|
|
55
|
+
enabled: true,
|
|
56
|
+
available: Boolean(cliPath),
|
|
57
|
+
provider,
|
|
58
|
+
prod,
|
|
59
|
+
source,
|
|
60
|
+
cliPath,
|
|
61
|
+
reason: cliPath ? null : "cli-missing",
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export function deployArgs(provider, prod) {
|
|
66
|
+
if (provider === "vercel") return prod ? ["--prod", "--yes"] : ["--yes"];
|
|
67
|
+
if (provider === "netlify") {
|
|
68
|
+
return prod ? ["deploy", "--prod", "--json"] : ["deploy", "--json"];
|
|
69
|
+
}
|
|
70
|
+
throw new Error("Unsupported deploy provider.");
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function stripAnsi(value) {
|
|
74
|
+
return String(value || "").replace(/\x1b\[[0-?]*[ -/]*[@-~]/g, "");
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function vercelUrlCandidates(stdout, stderr) {
|
|
78
|
+
const lines = `${stripAnsi(stdout)}\n${stripAnsi(stderr)}`.split(/\r?\n/);
|
|
79
|
+
const candidates = [];
|
|
80
|
+
for (const line of lines) {
|
|
81
|
+
for (const match of line.matchAll(/https:\/\/[^\s"'<>]+/gi)) {
|
|
82
|
+
// Vercel's aligned output has no colon and may have a gutter glyph:
|
|
83
|
+
// `▲ Aliased https://example.com`. Older releases used colons.
|
|
84
|
+
const beforeUrl = line.slice(0, match.index ?? 0);
|
|
85
|
+
const label = /\b(preview|production|aliased)\b\s*:?\s*$/i
|
|
86
|
+
.exec(beforeUrl)?.[1]?.toLowerCase() ?? null;
|
|
87
|
+
const raw = match[0].replace(/[),.;\]]+$/g, "");
|
|
88
|
+
try {
|
|
89
|
+
const parsed = new URL(raw);
|
|
90
|
+
const host = parsed.hostname.toLowerCase();
|
|
91
|
+
// `Inspect: https://vercel.com/<team>/<project>/...` is a dashboard
|
|
92
|
+
// link, not the deployment. Never put it on a report card.
|
|
93
|
+
if (host === "vercel.com" || host.endsWith(".vercel.com")) continue;
|
|
94
|
+
candidates.push({ raw, host, label });
|
|
95
|
+
} catch {
|
|
96
|
+
// Ignore malformed URL-shaped CLI output.
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
return candidates;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export function parseDeployUrl(provider, stdout, stderr = "", prod = false) {
|
|
104
|
+
if (provider === "vercel") {
|
|
105
|
+
const candidates = vercelUrlCandidates(stdout, stderr);
|
|
106
|
+
if (prod) {
|
|
107
|
+
// Production deploys may finish by printing only a custom alias. Prefer
|
|
108
|
+
// the explicit alias, then the CLI's Production line, before falling back
|
|
109
|
+
// to the canonical *.vercel.app deployment URL.
|
|
110
|
+
return (
|
|
111
|
+
candidates.find((candidate) => candidate.label === "aliased")?.raw ??
|
|
112
|
+
candidates.find((candidate) => candidate.label === "production")?.raw ??
|
|
113
|
+
candidates.find((candidate) => candidate.host.endsWith(".vercel.app"))?.raw ??
|
|
114
|
+
null
|
|
115
|
+
);
|
|
116
|
+
}
|
|
117
|
+
// Preview output remains deliberately strict: an arbitrary URL printed by
|
|
118
|
+
// a build is not evidence that Vercel deployed there.
|
|
119
|
+
return candidates.find((candidate) => candidate.host.endsWith(".vercel.app"))?.raw ?? null;
|
|
120
|
+
}
|
|
121
|
+
if (provider === "netlify") {
|
|
122
|
+
const clean = stripAnsi(stdout).trim();
|
|
123
|
+
const candidates = [clean, clean.slice(clean.indexOf("{"), clean.lastIndexOf("}") + 1)];
|
|
124
|
+
for (const candidate of candidates) {
|
|
125
|
+
if (!candidate) continue;
|
|
126
|
+
try {
|
|
127
|
+
const parsed = JSON.parse(candidate);
|
|
128
|
+
const value = prod
|
|
129
|
+
? parsed.url || parsed.deploy_url
|
|
130
|
+
: parsed.deploy_url || parsed.url;
|
|
131
|
+
if (typeof value === "string" && /^https?:\/\//i.test(value)) return value;
|
|
132
|
+
} catch {
|
|
133
|
+
// Netlify should emit one JSON object; try the bounded object fallback.
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
return null;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function loginCaveat(provider, output) {
|
|
141
|
+
const text = String(output || "").toLowerCase();
|
|
142
|
+
const authFailure =
|
|
143
|
+
/not (?:logged|signed) in|not authenticated|authentication required|unauthorized|no auth token|login required|token is not valid|invalid (?:auth )?token/.test(text);
|
|
144
|
+
if (!authFailure) return null;
|
|
145
|
+
return provider === "vercel"
|
|
146
|
+
? "Vercel is not logged in on this Mac. Run `vercel login` in a terminal once, then try again."
|
|
147
|
+
: "Netlify is not logged in on this Mac. Run `netlify login` in a terminal once, then try again.";
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
function safeDeploymentUrl(value) {
|
|
151
|
+
try {
|
|
152
|
+
const parsed = new URL(value);
|
|
153
|
+
if (parsed.protocol !== "https:" && parsed.protocol !== "http:") return null;
|
|
154
|
+
parsed.username = "";
|
|
155
|
+
parsed.password = "";
|
|
156
|
+
parsed.search = "";
|
|
157
|
+
parsed.hash = "";
|
|
158
|
+
return redactSecrets(parsed.toString());
|
|
159
|
+
} catch {
|
|
160
|
+
return null;
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
export async function deployFolder({
|
|
165
|
+
folderPath,
|
|
166
|
+
target,
|
|
167
|
+
timeoutMs = DEFAULT_DEPLOY_TIMEOUT_MS,
|
|
168
|
+
env = process.env,
|
|
169
|
+
signal = undefined,
|
|
170
|
+
run = runCli,
|
|
171
|
+
}) {
|
|
172
|
+
const provider = target?.provider;
|
|
173
|
+
const prod = target?.prod === true;
|
|
174
|
+
if (!isProvider(provider)) {
|
|
175
|
+
return { ok: false, provider: null, prod, caveat: "Choose Vercel or Netlify before deploying." };
|
|
176
|
+
}
|
|
177
|
+
if (!target.available || !target.cliPath) {
|
|
178
|
+
const label = provider === "vercel" ? "Vercel" : "Netlify";
|
|
179
|
+
return {
|
|
180
|
+
ok: false,
|
|
181
|
+
provider,
|
|
182
|
+
prod,
|
|
183
|
+
caveat: `Install the ${label} CLI, sign in, then try again.`,
|
|
184
|
+
};
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
const result = await run({
|
|
188
|
+
cmd: target.cliPath,
|
|
189
|
+
args: deployArgs(provider, prod),
|
|
190
|
+
cwd: folderPath,
|
|
191
|
+
timeoutMs,
|
|
192
|
+
heartbeatMs: 0,
|
|
193
|
+
label: "deploying",
|
|
194
|
+
signal,
|
|
195
|
+
// Defense in depth: runCli strips HILOS_* too, but an injected runner sees
|
|
196
|
+
// the same scrubbed environment the real child receives.
|
|
197
|
+
env: scrubHilosEnv(env),
|
|
198
|
+
});
|
|
199
|
+
const stdout = redactSecrets(String(result?.stdout || ""));
|
|
200
|
+
const stderr = redactSecrets(String(result?.stderr || ""));
|
|
201
|
+
const error = redactSecrets(String(result?.error?.message || ""));
|
|
202
|
+
const combined = [stdout, stderr, error].filter(Boolean).join("\n");
|
|
203
|
+
|
|
204
|
+
if (result?.aborted) {
|
|
205
|
+
return { ok: false, provider, prod, aborted: true, stdout, stderr, caveat: "Deployment was stopped." };
|
|
206
|
+
}
|
|
207
|
+
if (result?.status !== 0) {
|
|
208
|
+
return {
|
|
209
|
+
ok: false,
|
|
210
|
+
provider,
|
|
211
|
+
prod,
|
|
212
|
+
stdout,
|
|
213
|
+
stderr,
|
|
214
|
+
caveat:
|
|
215
|
+
loginCaveat(provider, combined) ||
|
|
216
|
+
(error.includes("timed out")
|
|
217
|
+
? `The ${provider === "vercel" ? "Vercel" : "Netlify"} deployment timed out after ${Math.round(timeoutMs / 1000)} seconds.`
|
|
218
|
+
: `${provider === "vercel" ? "Vercel" : "Netlify"} deployment failed: ${(stderr || error || "unknown error").slice(0, 240)}`),
|
|
219
|
+
};
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
const url = safeDeploymentUrl(parseDeployUrl(provider, result.stdout, result.stderr, prod));
|
|
223
|
+
if (!url) {
|
|
224
|
+
return {
|
|
225
|
+
ok: false,
|
|
226
|
+
provider,
|
|
227
|
+
prod,
|
|
228
|
+
stdout,
|
|
229
|
+
stderr,
|
|
230
|
+
caveat: `${provider === "vercel" ? "Vercel" : "Netlify"} finished without returning a deployment URL.`,
|
|
231
|
+
};
|
|
232
|
+
}
|
|
233
|
+
return { ok: true, provider, prod, url, stdout, stderr };
|
|
234
|
+
}
|