@hunsu/core 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +157 -0
- package/dist/artifact-actions.d.ts +83 -0
- package/dist/artifact-actions.js +415 -0
- package/dist/board.d.ts +15 -0
- package/dist/board.js +223 -0
- package/dist/domain-store.d.ts +268 -0
- package/dist/domain-store.js +951 -0
- package/dist/errors.d.ts +17 -0
- package/dist/errors.js +34 -0
- package/dist/git.d.ts +105 -0
- package/dist/git.js +323 -0
- package/dist/index.d.ts +10 -0
- package/dist/index.js +10 -0
- package/dist/lookup.d.ts +7 -0
- package/dist/lookup.js +57 -0
- package/dist/model.d.ts +71 -0
- package/dist/model.js +18 -0
- package/dist/port.d.ts +56 -0
- package/dist/port.js +186 -0
- package/dist/primitives.d.ts +48 -0
- package/dist/primitives.js +108 -0
- package/dist/trailer.d.ts +7 -0
- package/dist/trailer.js +67 -0
- package/package.json +33 -0
- package/src/artifact-actions.ts +531 -0
- package/src/board.ts +272 -0
- package/src/domain-store.ts +1263 -0
- package/src/errors.ts +39 -0
- package/src/git.ts +445 -0
- package/src/index.ts +10 -0
- package/src/lookup.ts +72 -0
- package/src/model.ts +109 -0
- package/src/port.ts +263 -0
- package/src/primitives.ts +160 -0
- package/src/trailer.ts +79 -0
|
@@ -0,0 +1,531 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { spawnSync } from "node:child_process";
|
|
3
|
+
import { existsSync, mkdirSync, readFileSync, readdirSync, rmSync, writeFileSync } from "node:fs";
|
|
4
|
+
import { tmpdir } from "node:os";
|
|
5
|
+
import { basename, join, resolve } from "node:path";
|
|
6
|
+
import { loadDomainStore } from "./domain-store.ts";
|
|
7
|
+
import { ensureGitRepository, git } from "./git.ts";
|
|
8
|
+
import type { ArtifactActionDefinition } from "@hunsu/protocol";
|
|
9
|
+
|
|
10
|
+
export type ArtifactActionRunStatus = "queued" | "running" | "succeeded" | "failed" | "stopped";
|
|
11
|
+
|
|
12
|
+
export type ArtifactActionRunSource = {
|
|
13
|
+
moveId?: string;
|
|
14
|
+
commit: string;
|
|
15
|
+
ref: string;
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
export type ArtifactActionAliasRecord = {
|
|
19
|
+
alias: string;
|
|
20
|
+
service?: string;
|
|
21
|
+
containerPort?: number;
|
|
22
|
+
healthPath?: string;
|
|
23
|
+
target?: string;
|
|
24
|
+
internalUrl?: string;
|
|
25
|
+
externalPath: string;
|
|
26
|
+
directUrl?: string;
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
export type ArtifactActionRunRecord = {
|
|
30
|
+
runId: string;
|
|
31
|
+
actionId: string;
|
|
32
|
+
roadmapId?: string;
|
|
33
|
+
action: ArtifactActionDefinition;
|
|
34
|
+
source: ArtifactActionRunSource;
|
|
35
|
+
status: ArtifactActionRunStatus;
|
|
36
|
+
sourceWorktree?: {
|
|
37
|
+
path: string;
|
|
38
|
+
commit: string;
|
|
39
|
+
createdAt: string;
|
|
40
|
+
removedAt?: string;
|
|
41
|
+
};
|
|
42
|
+
env: Record<string, string>;
|
|
43
|
+
aliases?: Record<string, ArtifactActionAliasRecord>;
|
|
44
|
+
exitCode?: number;
|
|
45
|
+
stdout?: string;
|
|
46
|
+
stderr?: string;
|
|
47
|
+
createdAt: string;
|
|
48
|
+
updatedAt: string;
|
|
49
|
+
stoppedAt?: string;
|
|
50
|
+
error?: string;
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
export type ArtifactActionRunInput = {
|
|
54
|
+
cwd?: string;
|
|
55
|
+
actionId: string;
|
|
56
|
+
moveId?: string;
|
|
57
|
+
commit?: string;
|
|
58
|
+
roadmapId?: string;
|
|
59
|
+
env?: Record<string, string>;
|
|
60
|
+
ambientEnv?: Record<string, string | undefined>;
|
|
61
|
+
processEnv?: Record<string, string | undefined>;
|
|
62
|
+
worktreeRoot?: string;
|
|
63
|
+
baseActionPath?: string;
|
|
64
|
+
now?: string;
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
export type ArtifactActionRunPlan = {
|
|
68
|
+
action: ArtifactActionDefinition;
|
|
69
|
+
run: ArtifactActionRunRecord;
|
|
70
|
+
commands: ArtifactActionCommand[];
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
export type ArtifactActionCommand = {
|
|
74
|
+
command: string;
|
|
75
|
+
args: string[];
|
|
76
|
+
cwd: string;
|
|
77
|
+
env: Record<string, string>;
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
export type ArtifactActionCommandRunner = (
|
|
81
|
+
command: string,
|
|
82
|
+
args: string[],
|
|
83
|
+
options: { cwd: string; env: Record<string, string> }
|
|
84
|
+
) => { status: number; stdout: string; stderr: string };
|
|
85
|
+
|
|
86
|
+
export type ArtifactActionRunOptions = {
|
|
87
|
+
runner?: ArtifactActionCommandRunner;
|
|
88
|
+
store?: boolean;
|
|
89
|
+
processEnv?: Record<string, string | undefined>;
|
|
90
|
+
};
|
|
91
|
+
|
|
92
|
+
type ResolvedActionSource = {
|
|
93
|
+
source: ArtifactActionRunSource;
|
|
94
|
+
actions: ArtifactActionDefinition[];
|
|
95
|
+
};
|
|
96
|
+
|
|
97
|
+
export function listArtifactActions(cwd = process.cwd(), moveId?: string): ArtifactActionDefinition[] {
|
|
98
|
+
const root = ensureGitRepository(cwd);
|
|
99
|
+
const store = loadDomainStore(root);
|
|
100
|
+
if (moveId) {
|
|
101
|
+
const move = store.board.moves.find(candidate => candidate.id === moveId);
|
|
102
|
+
if (!move) {
|
|
103
|
+
throw new Error(`No MOVE found for Artifact Action source: ${moveId}`);
|
|
104
|
+
}
|
|
105
|
+
const node = store.board.nodes.find(candidate => candidate.id === move.toNodeId);
|
|
106
|
+
return cloneActions(move.snapshot?.artifactActions ?? node?.artifactActions ?? []);
|
|
107
|
+
}
|
|
108
|
+
return cloneActions(store.board.artifactActions);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
export function planArtifactActionRun(input: ArtifactActionRunInput): ArtifactActionRunPlan {
|
|
112
|
+
const root = ensureGitRepository(input.cwd ?? process.cwd());
|
|
113
|
+
const resolved = resolveActionSource(root, input);
|
|
114
|
+
const action = resolved.actions.find(candidate => candidate.id === input.actionId);
|
|
115
|
+
if (!action) {
|
|
116
|
+
throw new Error(`Unknown Artifact Action for selected source: ${input.actionId}`);
|
|
117
|
+
}
|
|
118
|
+
validateActionSourceScope(action, resolved.source);
|
|
119
|
+
const now = input.now ?? new Date().toISOString();
|
|
120
|
+
const runId = artifactActionRunId(action.id, resolved.source, now);
|
|
121
|
+
const externalBase = input.baseActionPath ?? actionRunBasePath(input.roadmapId, runId);
|
|
122
|
+
const aliases = action.kind === "host"
|
|
123
|
+
? aliasesForAction(action, externalBase)
|
|
124
|
+
: undefined;
|
|
125
|
+
const env = resolveActionEnv(action, input.env, input.ambientEnv);
|
|
126
|
+
const run: ArtifactActionRunRecord = {
|
|
127
|
+
runId,
|
|
128
|
+
actionId: action.id,
|
|
129
|
+
roadmapId: input.roadmapId,
|
|
130
|
+
action: cloneAction(action),
|
|
131
|
+
source: resolved.source,
|
|
132
|
+
status: "queued",
|
|
133
|
+
env: sanitizeEnvForStorage(action, env),
|
|
134
|
+
aliases,
|
|
135
|
+
createdAt: now,
|
|
136
|
+
updatedAt: now
|
|
137
|
+
};
|
|
138
|
+
return {
|
|
139
|
+
action: cloneAction(action),
|
|
140
|
+
run,
|
|
141
|
+
commands: actionCommands(root, action, run, env)
|
|
142
|
+
};
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
export function startArtifactActionRun(input: ArtifactActionRunInput, options: ArtifactActionRunOptions = {}): ArtifactActionRunRecord {
|
|
146
|
+
const root = ensureGitRepository(input.cwd ?? process.cwd());
|
|
147
|
+
const plan = planArtifactActionRun(input);
|
|
148
|
+
const now = input.now ?? new Date().toISOString();
|
|
149
|
+
const sourceWorktree = createActionSourceWorktree(root, plan.run.runId, plan.run.source.commit, now, input.worktreeRoot);
|
|
150
|
+
const processEnv = input.processEnv ?? options.processEnv;
|
|
151
|
+
const runner = options.runner ?? ((command, args, commandOptions) => spawnCommand(command, args, { ...commandOptions, processEnv }));
|
|
152
|
+
let run: ArtifactActionRunRecord = {
|
|
153
|
+
...plan.run,
|
|
154
|
+
status: plan.action.kind === "host" ? "running" : "running",
|
|
155
|
+
sourceWorktree,
|
|
156
|
+
updatedAt: now
|
|
157
|
+
};
|
|
158
|
+
const resolvedEnv = resolveActionEnv(plan.action, input.env, input.ambientEnv);
|
|
159
|
+
const commands = actionCommands(root, plan.action, run, resolvedEnv);
|
|
160
|
+
try {
|
|
161
|
+
if (plan.action.runner.type === "docker_compose") {
|
|
162
|
+
runActionCommand(runner, commands[0]);
|
|
163
|
+
runActionCommand(runner, commands[1]);
|
|
164
|
+
run.aliases = attachDirectAliasUrls(root, run, runner, resolvedEnv);
|
|
165
|
+
run.status = "running";
|
|
166
|
+
} else {
|
|
167
|
+
const result = runActionCommand(runner, commands[0]);
|
|
168
|
+
run.exitCode = result.status;
|
|
169
|
+
run.stdout = result.stdout;
|
|
170
|
+
run.stderr = result.stderr;
|
|
171
|
+
run.status = plan.action.kind === "host" ? "running" : "succeeded";
|
|
172
|
+
if (plan.action.kind === "check") {
|
|
173
|
+
const removed = removeActionSourceWorktree(root, sourceWorktree.path);
|
|
174
|
+
run.sourceWorktree = { ...sourceWorktree, removedAt: removed };
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
run.updatedAt = new Date().toISOString();
|
|
178
|
+
} catch (error) {
|
|
179
|
+
run.status = "failed";
|
|
180
|
+
run.updatedAt = new Date().toISOString();
|
|
181
|
+
run.error = error instanceof Error ? error.message : String(error);
|
|
182
|
+
const removed = removeActionSourceWorktree(root, sourceWorktree.path);
|
|
183
|
+
run.sourceWorktree = { ...sourceWorktree, removedAt: removed };
|
|
184
|
+
}
|
|
185
|
+
if (options.store !== false) {
|
|
186
|
+
writeArtifactActionRun(root, run);
|
|
187
|
+
}
|
|
188
|
+
if (run.status === "failed") {
|
|
189
|
+
throw new Error(run.error ?? `Artifact Action ${run.runId} failed`);
|
|
190
|
+
}
|
|
191
|
+
return run;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
export function listArtifactActionRuns(cwd = process.cwd()): ArtifactActionRunRecord[] {
|
|
195
|
+
const root = ensureGitRepository(cwd);
|
|
196
|
+
const dir = actionRunStoreDir(root);
|
|
197
|
+
if (!existsSync(dir)) {
|
|
198
|
+
return [];
|
|
199
|
+
}
|
|
200
|
+
return readdirSync(dir)
|
|
201
|
+
.filter(file => file.endsWith(".json"))
|
|
202
|
+
.map(file => readArtifactActionRun(root, file.slice(0, -".json".length)))
|
|
203
|
+
.sort((left, right) => right.updatedAt.localeCompare(left.updatedAt));
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
export function readArtifactActionRun(cwd: string, runId: string): ArtifactActionRunRecord {
|
|
207
|
+
const root = ensureGitRepository(cwd);
|
|
208
|
+
const path = actionRunRecordPath(root, runId);
|
|
209
|
+
if (!existsSync(path)) {
|
|
210
|
+
throw new Error(`Unknown Artifact Action Run: ${runId}`);
|
|
211
|
+
}
|
|
212
|
+
return JSON.parse(readFileSync(path, "utf8")) as ArtifactActionRunRecord;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
export function stopArtifactActionRun(cwd: string, runId: string, options: ArtifactActionRunOptions = {}): ArtifactActionRunRecord {
|
|
216
|
+
const root = ensureGitRepository(cwd);
|
|
217
|
+
const run = readArtifactActionRun(root, runId);
|
|
218
|
+
const runner = options.runner ?? ((command, args, commandOptions) => spawnCommand(command, args, { ...commandOptions, processEnv: options.processEnv }));
|
|
219
|
+
const env = resolveActionEnv(run.action, run.env, options.processEnv);
|
|
220
|
+
if (run.action.runner.type === "docker_compose") {
|
|
221
|
+
runActionCommand(runner, composeCommand(root, run, ["down"], env));
|
|
222
|
+
} else if (run.action.runner.stopCommand) {
|
|
223
|
+
runActionCommand(runner, shellCommand(root, run, run.action.runner.stopCommand, env));
|
|
224
|
+
}
|
|
225
|
+
const removedAt = run.sourceWorktree?.path ? removeActionSourceWorktree(root, run.sourceWorktree.path) : undefined;
|
|
226
|
+
const stopped: ArtifactActionRunRecord = {
|
|
227
|
+
...run,
|
|
228
|
+
sourceWorktree: run.sourceWorktree ? { ...run.sourceWorktree, removedAt } : undefined,
|
|
229
|
+
status: "stopped",
|
|
230
|
+
stoppedAt: new Date().toISOString(),
|
|
231
|
+
updatedAt: new Date().toISOString()
|
|
232
|
+
};
|
|
233
|
+
if (options.store !== false) {
|
|
234
|
+
writeArtifactActionRun(root, stopped);
|
|
235
|
+
}
|
|
236
|
+
return stopped;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
function resolveActionSource(root: string, input: ArtifactActionRunInput): ResolvedActionSource {
|
|
240
|
+
if (input.moveId && input.commit) {
|
|
241
|
+
throw new Error("Artifact Action source accepts either moveId or commit, not both");
|
|
242
|
+
}
|
|
243
|
+
const store = loadDomainStore(root);
|
|
244
|
+
if (input.moveId) {
|
|
245
|
+
const move = store.board.moves.find(candidate => candidate.id === input.moveId);
|
|
246
|
+
if (!move) {
|
|
247
|
+
throw new Error(`No MOVE found for Artifact Action source: ${input.moveId}`);
|
|
248
|
+
}
|
|
249
|
+
const node = store.board.nodes.find(candidate => candidate.id === move.toNodeId);
|
|
250
|
+
return {
|
|
251
|
+
source: {
|
|
252
|
+
moveId: move.id,
|
|
253
|
+
commit: git(["rev-parse", "--verify", `${move.commit}^{commit}`], { cwd: root }).trim(),
|
|
254
|
+
ref: move.commit
|
|
255
|
+
},
|
|
256
|
+
actions: cloneActions(move.snapshot?.artifactActions ?? node?.artifactActions ?? [])
|
|
257
|
+
};
|
|
258
|
+
}
|
|
259
|
+
const ref = input.commit ?? "HEAD";
|
|
260
|
+
return {
|
|
261
|
+
source: {
|
|
262
|
+
commit: git(["rev-parse", "--verify", `${ref}^{commit}`], { cwd: root }).trim(),
|
|
263
|
+
ref
|
|
264
|
+
},
|
|
265
|
+
actions: cloneActions(store.board.artifactActions)
|
|
266
|
+
};
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
function validateActionSourceScope(action: ArtifactActionDefinition, source: ArtifactActionRunSource): void {
|
|
270
|
+
if (action.sourceScope === "move" && !source.moveId) {
|
|
271
|
+
throw new Error(`Artifact Action ${action.id} can only run against a MOVE source`);
|
|
272
|
+
}
|
|
273
|
+
if (action.sourceScope === "commit" && source.moveId) {
|
|
274
|
+
throw new Error(`Artifact Action ${action.id} can only run against a commit source`);
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
function resolveActionEnv(
|
|
279
|
+
action: ArtifactActionDefinition,
|
|
280
|
+
overrides: Record<string, string> = {},
|
|
281
|
+
ambientEnv: Record<string, string | undefined> = {}
|
|
282
|
+
): Record<string, string> {
|
|
283
|
+
const env: Record<string, string> = {};
|
|
284
|
+
const declared = new Set<string>();
|
|
285
|
+
for (const [name, spec] of Object.entries(action.env ?? {})) {
|
|
286
|
+
declared.add(name);
|
|
287
|
+
if ("default" in spec) {
|
|
288
|
+
env[name] = overrides[name] ?? String(spec.default);
|
|
289
|
+
} else if ("value" in spec) {
|
|
290
|
+
env[name] = overrides[name] ?? String(spec.value);
|
|
291
|
+
} else if ("alias" in spec) {
|
|
292
|
+
const alias = action.aliases?.[spec.alias];
|
|
293
|
+
if (!alias) {
|
|
294
|
+
throw new Error(`Artifact Action env ${name} references unknown alias: ${spec.alias}`);
|
|
295
|
+
}
|
|
296
|
+
env[name] = String(alias.containerPort ?? alias.target ?? "");
|
|
297
|
+
} else if ("fromAliasUrl" in spec) {
|
|
298
|
+
const alias = action.aliases?.[spec.fromAliasUrl];
|
|
299
|
+
if (!alias) {
|
|
300
|
+
throw new Error(`Artifact Action env ${name} references unknown alias: ${spec.fromAliasUrl}`);
|
|
301
|
+
}
|
|
302
|
+
env[name] = alias.service && alias.containerPort ? `http://${alias.service}:${alias.containerPort}` : String(alias.target ?? "");
|
|
303
|
+
} else if (spec.required) {
|
|
304
|
+
const value = overrides[name] ?? ambientEnv[name];
|
|
305
|
+
if (!value) {
|
|
306
|
+
throw new Error(`Artifact Action env ${name} is required but not set`);
|
|
307
|
+
}
|
|
308
|
+
env[name] = value;
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
for (const [name, value] of Object.entries(overrides)) {
|
|
312
|
+
if (!declared.has(name)) {
|
|
313
|
+
env[name] = value;
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
return env;
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
function sanitizeEnvForStorage(action: ArtifactActionDefinition, env: Record<string, string>): Record<string, string> {
|
|
320
|
+
const sanitized: Record<string, string> = {};
|
|
321
|
+
for (const [name, value] of Object.entries(env)) {
|
|
322
|
+
const spec = action.env?.[name];
|
|
323
|
+
sanitized[name] = spec && "required" in spec && spec.secret ? "<secret>" : value;
|
|
324
|
+
}
|
|
325
|
+
return sanitized;
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
function actionCommands(root: string, action: ArtifactActionDefinition, run: ArtifactActionRunRecord, env: Record<string, string>): ArtifactActionCommand[] {
|
|
329
|
+
if (action.runner.type === "docker_compose") {
|
|
330
|
+
return [
|
|
331
|
+
composeCommand(root, run, ["build"], env),
|
|
332
|
+
composeCommand(root, run, ["up", "-d"], env)
|
|
333
|
+
];
|
|
334
|
+
}
|
|
335
|
+
return [shellCommand(root, run, action.runner.command, env)];
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
function composeCommand(root: string, run: ArtifactActionRunRecord, args: string[], env: Record<string, string>): ArtifactActionCommand {
|
|
339
|
+
if (run.action.runner.type !== "docker_compose") {
|
|
340
|
+
throw new Error(`Artifact Action ${run.actionId} does not use docker compose`);
|
|
341
|
+
}
|
|
342
|
+
const shortCommit = run.source.commit.slice(0, 12);
|
|
343
|
+
const projectName = sanitizeProjectName(expandTemplate(run.action.runner.projectName ?? `hunsu-${run.actionId}-${shortCommit}`, {
|
|
344
|
+
actionId: run.actionId,
|
|
345
|
+
runId: run.runId,
|
|
346
|
+
commit: run.source.commit,
|
|
347
|
+
shortCommit,
|
|
348
|
+
moveId: run.source.moveId ?? ""
|
|
349
|
+
}));
|
|
350
|
+
return {
|
|
351
|
+
command: "docker",
|
|
352
|
+
args: ["compose", "-f", resolve(run.sourceWorktree?.path ?? root, run.action.runner.file), "-p", projectName, ...args],
|
|
353
|
+
cwd: run.sourceWorktree?.path ?? root,
|
|
354
|
+
env
|
|
355
|
+
};
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
function shellCommand(root: string, run: ArtifactActionRunRecord, command: string, env: Record<string, string>): ArtifactActionCommand {
|
|
359
|
+
return {
|
|
360
|
+
command: "sh",
|
|
361
|
+
args: ["-lc", command],
|
|
362
|
+
cwd: run.sourceWorktree?.path ?? root,
|
|
363
|
+
env
|
|
364
|
+
};
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
function runActionCommand(runner: ArtifactActionCommandRunner, command: ArtifactActionCommand): { status: number; stdout: string; stderr: string } {
|
|
368
|
+
const result = runner(command.command, command.args, { cwd: command.cwd, env: command.env });
|
|
369
|
+
if (result.status !== 0) {
|
|
370
|
+
throw new Error(`${command.command} ${command.args.join(" ")} failed\n${result.stderr || result.stdout}`);
|
|
371
|
+
}
|
|
372
|
+
return result;
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
function aliasesForAction(action: ArtifactActionDefinition, externalBase: string): Record<string, ArtifactActionAliasRecord> | undefined {
|
|
376
|
+
if (!action.aliases) {
|
|
377
|
+
return undefined;
|
|
378
|
+
}
|
|
379
|
+
return Object.fromEntries(Object.entries(action.aliases).map(([alias, value]) => [
|
|
380
|
+
alias,
|
|
381
|
+
{
|
|
382
|
+
alias,
|
|
383
|
+
service: value.service,
|
|
384
|
+
containerPort: value.containerPort,
|
|
385
|
+
healthPath: value.healthPath,
|
|
386
|
+
target: value.target,
|
|
387
|
+
internalUrl: value.service && value.containerPort ? `http://${value.service}:${value.containerPort}` : value.target,
|
|
388
|
+
externalPath: `${externalBase}/${encodeURIComponent(alias)}/`
|
|
389
|
+
}
|
|
390
|
+
]));
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
function attachDirectAliasUrls(root: string, run: ArtifactActionRunRecord, runner: ArtifactActionCommandRunner, env: Record<string, string>): Record<string, ArtifactActionAliasRecord> | undefined {
|
|
394
|
+
if (!run.aliases || run.action.runner.type !== "docker_compose") {
|
|
395
|
+
return run.aliases;
|
|
396
|
+
}
|
|
397
|
+
const runnerDefinition = run.action.runner;
|
|
398
|
+
return Object.fromEntries(Object.entries(run.aliases).map(([alias, value]) => {
|
|
399
|
+
if (!value.service || !value.containerPort) {
|
|
400
|
+
return [alias, value];
|
|
401
|
+
}
|
|
402
|
+
const result = runner("docker", ["compose", "-f", resolve(run.sourceWorktree?.path ?? root, runnerDefinition.file), "-p", sanitizeProjectName(expandTemplate(runnerDefinition.projectName ?? `hunsu-${run.actionId}-${run.source.commit.slice(0, 12)}`, {
|
|
403
|
+
actionId: run.actionId,
|
|
404
|
+
runId: run.runId,
|
|
405
|
+
commit: run.source.commit,
|
|
406
|
+
shortCommit: run.source.commit.slice(0, 12),
|
|
407
|
+
moveId: run.source.moveId ?? ""
|
|
408
|
+
})), "port", value.service, String(value.containerPort)], {
|
|
409
|
+
cwd: run.sourceWorktree?.path ?? root,
|
|
410
|
+
env
|
|
411
|
+
});
|
|
412
|
+
if (result.status !== 0 || !result.stdout.trim()) {
|
|
413
|
+
return [alias, value];
|
|
414
|
+
}
|
|
415
|
+
return [alias, { ...value, directUrl: formatDirectUrl(result.stdout.trim()) }];
|
|
416
|
+
}));
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
function createActionSourceWorktree(root: string, runId: string, commit: string, createdAt: string, requestedWorktreeRoot?: string): NonNullable<ArtifactActionRunRecord["sourceWorktree"]> {
|
|
420
|
+
const worktreeRoot = resolve(requestedWorktreeRoot ?? join(tmpdir(), "hunsu-action-runs", `${basename(root)}-${hashPath(root)}`));
|
|
421
|
+
const path = join(worktreeRoot, sanitizeRunId(runId));
|
|
422
|
+
mkdirSync(worktreeRoot, { recursive: true });
|
|
423
|
+
if (existsSync(path)) {
|
|
424
|
+
removeActionSourceWorktree(root, path);
|
|
425
|
+
rmSync(path, { recursive: true, force: true });
|
|
426
|
+
}
|
|
427
|
+
git(["worktree", "add", "--detach", path, commit], { cwd: root });
|
|
428
|
+
return { path, commit, createdAt };
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
function removeActionSourceWorktree(root: string, path: string): string {
|
|
432
|
+
const removedAt = new Date().toISOString();
|
|
433
|
+
if (!existsSync(path)) {
|
|
434
|
+
return removedAt;
|
|
435
|
+
}
|
|
436
|
+
try {
|
|
437
|
+
git(["worktree", "remove", "--force", path], { cwd: root });
|
|
438
|
+
} catch (_error) {
|
|
439
|
+
rmSync(path, { recursive: true, force: true });
|
|
440
|
+
git(["worktree", "prune"], { cwd: root });
|
|
441
|
+
}
|
|
442
|
+
return removedAt;
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
function writeArtifactActionRun(root: string, run: ArtifactActionRunRecord): void {
|
|
446
|
+
mkdirSync(actionRunStoreDir(root), { recursive: true });
|
|
447
|
+
writeFileSync(actionRunRecordPath(root, run.runId), `${JSON.stringify(run, null, 2)}\n`, "utf8");
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
function actionRunStoreDir(root: string): string {
|
|
451
|
+
return join(root, ".hunsu", "action-runs");
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
function actionRunRecordPath(root: string, runId: string): string {
|
|
455
|
+
return join(actionRunStoreDir(root), `${sanitizeRunId(runId)}.json`);
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
function actionRunBasePath(roadmapId: string | undefined, runId: string): string {
|
|
459
|
+
return roadmapId
|
|
460
|
+
? `/api/roadmaps/${encodeURIComponent(roadmapId)}/action-runs/${encodeURIComponent(runId)}/proxy`
|
|
461
|
+
: `/api/action-runs/${encodeURIComponent(runId)}/proxy`;
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
function artifactActionRunId(actionId: string, source: ArtifactActionRunSource, now: string): string {
|
|
465
|
+
return sanitizeRunId(`${actionId}-${source.moveId ?? source.commit.slice(0, 12)}-${now.replace(/[^0-9A-Za-z]/g, "").slice(0, 14)}`);
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
function sanitizeRunId(value: string): string {
|
|
469
|
+
return value.toLowerCase().replace(/[^a-z0-9_.-]+/g, "-").replace(/^-+|-+$/g, "") || "action-run";
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
function sanitizeProjectName(value: string): string {
|
|
473
|
+
return value.toLowerCase().replace(/[^a-z0-9_-]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 63) || "hunsu-action";
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
function expandTemplate(value: string, variables: Record<string, string>): string {
|
|
477
|
+
return value.replace(/\{([a-zA-Z][a-zA-Z0-9]*)\}/g, (_, key: string) => variables[key] ?? "");
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
function hashPath(path: string): string {
|
|
481
|
+
return createHash("sha256").update(path).digest("hex").slice(0, 12);
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
function formatDirectUrl(output: string): string {
|
|
485
|
+
const line = output.split(/\r?\n/).find(Boolean) ?? output;
|
|
486
|
+
const match = line.match(/(?:0\.0\.0\.0|127\.0\.0\.1|localhost|\[::\]|::):(\d+)$/);
|
|
487
|
+
if (match) {
|
|
488
|
+
return `http://127.0.0.1:${match[1]}`;
|
|
489
|
+
}
|
|
490
|
+
if (/^https?:\/\//.test(line)) {
|
|
491
|
+
return line;
|
|
492
|
+
}
|
|
493
|
+
return `http://${line}`;
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
function spawnCommand(command: string, args: string[], options: { cwd: string; env: Record<string, string>; processEnv?: Record<string, string | undefined> }): { status: number; stdout: string; stderr: string } {
|
|
497
|
+
const result = spawnSync(command, args, {
|
|
498
|
+
cwd: options.cwd,
|
|
499
|
+
env: { ...filterProcessEnv(options.processEnv), ...options.env },
|
|
500
|
+
encoding: "utf8",
|
|
501
|
+
maxBuffer: 16 * 1024 * 1024
|
|
502
|
+
});
|
|
503
|
+
return { status: result.status ?? 1, stdout: result.stdout, stderr: result.stderr };
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
function filterProcessEnv(env: Record<string, string | undefined> | undefined): Record<string, string> | undefined {
|
|
507
|
+
if (env === undefined) {
|
|
508
|
+
return undefined;
|
|
509
|
+
}
|
|
510
|
+
const next: Record<string, string> = {};
|
|
511
|
+
for (const [key, value] of Object.entries(env)) {
|
|
512
|
+
if (value !== undefined) {
|
|
513
|
+
next[key] = value;
|
|
514
|
+
}
|
|
515
|
+
}
|
|
516
|
+
return next;
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
function cloneActions(actions: ArtifactActionDefinition[]): ArtifactActionDefinition[] {
|
|
520
|
+
return actions.map(cloneAction).sort((left, right) => left.displayOrder - right.displayOrder || String(left.id).localeCompare(String(right.id)));
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
function cloneAction(action: ArtifactActionDefinition): ArtifactActionDefinition {
|
|
524
|
+
return {
|
|
525
|
+
...action,
|
|
526
|
+
env: action.env ? Object.fromEntries(Object.entries(action.env).map(([key, value]) => [key, { ...value }])) : undefined,
|
|
527
|
+
runner: { ...action.runner },
|
|
528
|
+
aliases: action.aliases ? Object.fromEntries(Object.entries(action.aliases).map(([key, value]) => [key, { ...value }])) : undefined,
|
|
529
|
+
evidence: action.evidence ? { ...action.evidence, paths: action.evidence.paths?.slice() } : undefined
|
|
530
|
+
};
|
|
531
|
+
}
|