hillclimb 0.8.4 → 0.8.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,309 @@
1
+ #!/usr/bin/env node
2
+
3
+ // src/platform/log.ts
4
+ import fs2 from "fs";
5
+ import path2 from "path";
6
+
7
+ // src/config.ts
8
+ import { execFile } from "child_process";
9
+ import fs from "fs";
10
+ import os from "os";
11
+ import path from "path";
12
+ import { promisify } from "util";
13
+ var execFileAsync = promisify(execFile);
14
+ var MAX_SNAPSHOT_LIMIT_OVERRIDE_BYTES = 100 * 1024 * 1024;
15
+ var DEFAULT_CONFIG_DIR = path.join(os.homedir(), ".hillclimb");
16
+ function normalizeProjectConfig(raw) {
17
+ if (!raw || typeof raw !== "object") return null;
18
+ const config = raw;
19
+ const projectId = config.projectId ?? config.workspaceId;
20
+ const projectSlug = config.projectSlug ?? config.workspaceSlug;
21
+ const projectName = config.projectName ?? config.workspaceName;
22
+ if (!config.apiBaseUrl || !projectId || !projectSlug || !projectName || !config.contributionTypeSlug || !config.contributionTypeName || typeof config.autoSubmit !== "boolean") {
23
+ return null;
24
+ }
25
+ const normalized = {
26
+ ...config,
27
+ apiBaseUrl: config.apiBaseUrl,
28
+ projectId,
29
+ projectSlug,
30
+ projectName,
31
+ workspaceId: config.workspaceId ?? projectId,
32
+ workspaceSlug: config.workspaceSlug ?? projectSlug,
33
+ workspaceName: config.workspaceName ?? projectName,
34
+ contributionTypeSlug: config.contributionTypeSlug,
35
+ contributionTypeName: config.contributionTypeName,
36
+ autoSubmit: config.autoSubmit,
37
+ snapshotLimits: normalizeSnapshotLimits(config.snapshotLimits)
38
+ };
39
+ if (typeof normalized.rootCommit !== "string") delete normalized.rootCommit;
40
+ if (typeof normalized.remoteUrl !== "string") delete normalized.remoteUrl;
41
+ return normalized;
42
+ }
43
+ function normalizeSnapshotLimits(raw) {
44
+ if (!raw || typeof raw !== "object") return void 0;
45
+ const limits = { ...raw };
46
+ if (!isValidSnapshotLimit(limits.maxTrackedFileBytes))
47
+ delete limits.maxTrackedFileBytes;
48
+ if (!isValidSnapshotLimit(limits.maxUntrackedFileBytes))
49
+ delete limits.maxUntrackedFileBytes;
50
+ if (!isValidSnapshotLimit(limits.maxBinaryFileBytes))
51
+ delete limits.maxBinaryFileBytes;
52
+ return Object.keys(limits).length > 0 ? limits : void 0;
53
+ }
54
+ function isValidSnapshotLimit(value) {
55
+ return typeof value === "number" && Number.isInteger(value) && value > 0 && value <= MAX_SNAPSHOT_LIMIT_OVERRIDE_BYTES;
56
+ }
57
+ function configDir() {
58
+ return process.env.HILLCLIMB_CONFIG_DIR ?? DEFAULT_CONFIG_DIR;
59
+ }
60
+ function configPath() {
61
+ return path.join(configDir(), "projects.json");
62
+ }
63
+ async function loadProjects() {
64
+ try {
65
+ const raw = await fs.promises.readFile(configPath(), "utf-8");
66
+ const parsed = JSON.parse(raw);
67
+ if (!parsed.projects || typeof parsed.projects !== "object") {
68
+ return { projects: {} };
69
+ }
70
+ const projects = {};
71
+ for (const [repoRoot, config] of Object.entries(parsed.projects)) {
72
+ const normalized = normalizeProjectConfig(config);
73
+ if (normalized) projects[repoRoot] = normalized;
74
+ }
75
+ return { projects };
76
+ } catch {
77
+ return { projects: {} };
78
+ }
79
+ }
80
+ async function saveProjects(file) {
81
+ await fs.promises.mkdir(configDir(), { recursive: true, mode: 448 });
82
+ const tmp = `${configPath()}.tmp`;
83
+ await fs.promises.writeFile(tmp, JSON.stringify(file, null, 2), {
84
+ mode: 384
85
+ });
86
+ await fs.promises.rename(tmp, configPath());
87
+ }
88
+ async function upsertProject(repoRoot, config) {
89
+ const file = await loadProjects();
90
+ const normalized = normalizeProjectConfig(config);
91
+ if (!normalized) throw new Error("Invalid project config.");
92
+ const resolvedRepoRoot = path.resolve(repoRoot);
93
+ const existing = file.projects[resolvedRepoRoot];
94
+ if (!normalized.snapshotLimits && existing?.snapshotLimits) {
95
+ normalized.snapshotLimits = existing.snapshotLimits;
96
+ }
97
+ const identity = await repoIdentity(resolvedRepoRoot);
98
+ if (identity.rootCommit) normalized.rootCommit = identity.rootCommit;
99
+ if (identity.remoteUrl) normalized.remoteUrl = identity.remoteUrl;
100
+ file.projects[resolvedRepoRoot] = normalized;
101
+ await saveProjects(file);
102
+ }
103
+ async function gitOut(args, cwd) {
104
+ try {
105
+ const { stdout } = await execFileAsync("git", args, {
106
+ cwd,
107
+ timeout: 5e3,
108
+ maxBuffer: 1024 * 1024
109
+ });
110
+ const out = stdout.trim();
111
+ return out.length > 0 ? out : null;
112
+ } catch {
113
+ return null;
114
+ }
115
+ }
116
+ async function repoIdentity(repoRoot) {
117
+ const [roots, remote] = await Promise.all([
118
+ gitOut(["rev-list", "--max-parents=0", "HEAD"], repoRoot),
119
+ gitOut(["config", "--get", "remote.origin.url"], repoRoot)
120
+ ]);
121
+ const rootCommit = roots?.split("\n").map((line) => line.trim()).filter(Boolean).sort()[0];
122
+ return {
123
+ rootCommit: rootCommit || void 0,
124
+ remoteUrl: remote || void 0
125
+ };
126
+ }
127
+ async function logHeal(level, message) {
128
+ try {
129
+ const { appendLog: appendLog2 } = await import("./log-W7QJJF46.js");
130
+ appendLog2(level, message);
131
+ } catch {
132
+ }
133
+ }
134
+ function matchByPath(projects, resolved) {
135
+ let best = null;
136
+ let bestLen = -1;
137
+ for (const [key, config] of Object.entries(projects)) {
138
+ const canonical = path.resolve(key);
139
+ if (resolved === canonical || resolved.startsWith(canonical + path.sep)) {
140
+ if (canonical.length > bestLen) {
141
+ bestLen = canonical.length;
142
+ best = { key, repoRoot: canonical, config };
143
+ }
144
+ }
145
+ }
146
+ return best;
147
+ }
148
+ var healMisses = /* @__PURE__ */ new Set();
149
+ async function healRenamedProject(file, resolved) {
150
+ const dead = Object.entries(file.projects).filter(
151
+ ([key]) => !fs.existsSync(path.resolve(key))
152
+ );
153
+ if (dead.length === 0) return null;
154
+ const missKey = `${configPath()}|${resolved}`;
155
+ if (healMisses.has(missKey)) return null;
156
+ const toplevel = await gitOut(["rev-parse", "--show-toplevel"], resolved);
157
+ if (!toplevel) {
158
+ healMisses.add(missKey);
159
+ return null;
160
+ }
161
+ const repoRoot = path.resolve(toplevel);
162
+ const identity = await repoIdentity(repoRoot);
163
+ const strong = dead.filter(([, config2]) => {
164
+ if (config2.rootCommit && identity.rootCommit) {
165
+ return config2.rootCommit === identity.rootCommit;
166
+ }
167
+ return Boolean(
168
+ config2.remoteUrl && identity.remoteUrl && config2.remoteUrl === identity.remoteUrl
169
+ );
170
+ });
171
+ const legacy = dead.filter(
172
+ ([key, config2]) => !config2.rootCommit && !config2.remoteUrl && path.basename(path.resolve(key)) === path.basename(repoRoot)
173
+ );
174
+ const candidates = strong.length > 0 ? strong : legacy;
175
+ if (candidates.length !== 1) {
176
+ if (candidates.length > 1) {
177
+ await logHeal(
178
+ "warn",
179
+ `config heal: ${candidates.length} dead entries match the repo at ${repoRoot}; not migrating`
180
+ );
181
+ }
182
+ healMisses.add(missKey);
183
+ return null;
184
+ }
185
+ const [oldKey, config] = candidates[0];
186
+ const updated = {
187
+ ...config,
188
+ rootCommit: identity.rootCommit ?? config.rootCommit,
189
+ remoteUrl: identity.remoteUrl ?? config.remoteUrl
190
+ };
191
+ if (updated.rootCommit === void 0) delete updated.rootCommit;
192
+ if (updated.remoteUrl === void 0) delete updated.remoteUrl;
193
+ delete file.projects[oldKey];
194
+ file.projects[repoRoot] = updated;
195
+ await saveProjects(file);
196
+ await logHeal(
197
+ "info",
198
+ `config heal: migrated project ${config.projectSlug} from ${oldKey} to ${repoRoot} (directory renamed)`
199
+ );
200
+ return { repoRoot, config: updated };
201
+ }
202
+ async function backfillIdentity(file, match) {
203
+ if (match.config.rootCommit || match.config.remoteUrl) return;
204
+ const missKey = `${configPath()}|backfill|${match.repoRoot}`;
205
+ if (healMisses.has(missKey)) return;
206
+ healMisses.add(missKey);
207
+ const identity = await repoIdentity(match.repoRoot);
208
+ if (!identity.rootCommit && !identity.remoteUrl) return;
209
+ const stored = file.projects[match.key];
210
+ if (!stored) return;
211
+ if (identity.rootCommit) stored.rootCommit = identity.rootCommit;
212
+ if (identity.remoteUrl) stored.remoteUrl = identity.remoteUrl;
213
+ await saveProjects(file);
214
+ }
215
+ async function findProjectForCwd(cwd) {
216
+ const file = await loadProjects();
217
+ const resolved = path.resolve(cwd);
218
+ const match = matchByPath(file.projects, resolved);
219
+ if (match) {
220
+ await backfillIdentity(file, match);
221
+ return { repoRoot: match.repoRoot, config: match.config };
222
+ }
223
+ return healRenamedProject(file, resolved);
224
+ }
225
+
226
+ // src/platform/log.ts
227
+ var PT_TIME_ZONE = "America/Los_Angeles";
228
+ function pacificParts(date) {
229
+ const parts = new Intl.DateTimeFormat("en-US", {
230
+ timeZone: PT_TIME_ZONE,
231
+ year: "numeric",
232
+ month: "2-digit",
233
+ day: "2-digit",
234
+ hour: "2-digit",
235
+ minute: "2-digit",
236
+ second: "2-digit",
237
+ hourCycle: "h23"
238
+ }).formatToParts(date);
239
+ const lookup = {};
240
+ for (const p of parts) if (p.type !== "literal") lookup[p.type] = p.value;
241
+ return {
242
+ year: lookup.year ?? "0000",
243
+ month: lookup.month ?? "00",
244
+ day: lookup.day ?? "00",
245
+ hour: lookup.hour ?? "00",
246
+ minute: lookup.minute ?? "00",
247
+ second: lookup.second ?? "00"
248
+ };
249
+ }
250
+ function pacificOffsetMinutes(date) {
251
+ const p = pacificParts(date);
252
+ const asIfUtc = Date.UTC(
253
+ Number(p.year),
254
+ Number(p.month) - 1,
255
+ Number(p.day),
256
+ Number(p.hour),
257
+ Number(p.minute),
258
+ Number(p.second)
259
+ );
260
+ return Math.round((asIfUtc - date.getTime()) / 6e4);
261
+ }
262
+ function pacificTimestamp(date) {
263
+ const p = pacificParts(date);
264
+ const ms = String(date.getMilliseconds()).padStart(3, "0");
265
+ const off = pacificOffsetMinutes(date);
266
+ const sign = off >= 0 ? "+" : "-";
267
+ const abs = Math.abs(off);
268
+ const oh = String(Math.floor(abs / 60)).padStart(2, "0");
269
+ const om = String(abs % 60).padStart(2, "0");
270
+ return `${p.year}-${p.month}-${p.day}T${p.hour}:${p.minute}:${p.second}.${ms}${sign}${oh}:${om}`;
271
+ }
272
+ function pacificDateString(date) {
273
+ const p = pacificParts(date);
274
+ return `${p.year}-${p.month}-${p.day}`;
275
+ }
276
+ function logsDir() {
277
+ return path2.join(configDir(), "logs");
278
+ }
279
+ function todayLogPath() {
280
+ return path2.join(logsDir(), `${pacificDateString(/* @__PURE__ */ new Date())}.log`);
281
+ }
282
+ var logPrefix = "";
283
+ function setLogPrefix(prefix) {
284
+ logPrefix = prefix;
285
+ }
286
+ function appendLog(level, message) {
287
+ const tag = logPrefix ? ` ${logPrefix}` : "";
288
+ const line = `[${pacificTimestamp(/* @__PURE__ */ new Date())}] [${level}]${tag} ${message}
289
+ `;
290
+ try {
291
+ fs2.mkdirSync(logsDir(), { recursive: true, mode: 448 });
292
+ fs2.appendFileSync(todayLogPath(), line);
293
+ } catch {
294
+ process.stderr.write(`hillclimb:${tag} ${level}: ${message}
295
+ `);
296
+ }
297
+ }
298
+
299
+ export {
300
+ todayLogPath,
301
+ setLogPrefix,
302
+ appendLog,
303
+ MAX_SNAPSHOT_LIMIT_OVERRIDE_BYTES,
304
+ configDir,
305
+ configPath,
306
+ loadProjects,
307
+ upsertProject,
308
+ findProjectForCwd
309
+ };
@@ -0,0 +1,11 @@
1
+ #!/usr/bin/env node
2
+ import {
3
+ appendLog,
4
+ setLogPrefix,
5
+ todayLogPath
6
+ } from "./chunk-543ASWNO.js";
7
+ export {
8
+ appendLog,
9
+ setLogPrefix,
10
+ todayLogPath
11
+ };