@goodfolder/cli 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 +661 -0
- package/README.md +31 -0
- package/dist/index.js +2416 -0
- package/package.json +33 -0
package/dist/index.js
ADDED
|
@@ -0,0 +1,2416 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
var __defProp = Object.defineProperty;
|
|
3
|
+
var __getOwnPropNames = Object.getOwnPropertyNames;
|
|
4
|
+
var __esm = (fn, res, err) => function __init() {
|
|
5
|
+
if (err) throw err[0];
|
|
6
|
+
try {
|
|
7
|
+
return fn && (res = (0, fn[__getOwnPropNames(fn)[0]])(fn = 0)), res;
|
|
8
|
+
} catch (e) {
|
|
9
|
+
throw err = [e], e;
|
|
10
|
+
}
|
|
11
|
+
};
|
|
12
|
+
var __export = (target, all) => {
|
|
13
|
+
for (var name in all)
|
|
14
|
+
__defProp(target, name, { get: all[name], enumerable: true });
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
// src/credentials.ts
|
|
18
|
+
import { execFileSync } from "node:child_process";
|
|
19
|
+
import { readFileSync, writeFileSync, mkdirSync } from "node:fs";
|
|
20
|
+
import { homedir } from "node:os";
|
|
21
|
+
import { join } from "node:path";
|
|
22
|
+
function accountMeta() {
|
|
23
|
+
try {
|
|
24
|
+
return JSON.parse(readFileSync(META_FILE(), "utf8"));
|
|
25
|
+
} catch {
|
|
26
|
+
return {};
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
function saveMeta(patch) {
|
|
30
|
+
mkdirSync(CONFIG_DIR(), { recursive: true });
|
|
31
|
+
writeFileSync(
|
|
32
|
+
META_FILE(),
|
|
33
|
+
JSON.stringify({ ...accountMeta(), ...patch }, null, 2) + "\n",
|
|
34
|
+
{ mode: 384 }
|
|
35
|
+
);
|
|
36
|
+
}
|
|
37
|
+
function loadAccountToken() {
|
|
38
|
+
if (cached !== void 0) return cached;
|
|
39
|
+
const env = process.env.GF_ACCOUNT_TOKEN?.trim();
|
|
40
|
+
if (env) {
|
|
41
|
+
cached = env;
|
|
42
|
+
return env;
|
|
43
|
+
}
|
|
44
|
+
if (process.platform === "darwin") {
|
|
45
|
+
try {
|
|
46
|
+
const out = execFileSync("security", ["find-generic-password", "-s", SERVICE, "-w"], {
|
|
47
|
+
stdio: ["ignore", "pipe", "ignore"]
|
|
48
|
+
}).toString().trim();
|
|
49
|
+
if (out) {
|
|
50
|
+
cached = out;
|
|
51
|
+
return out;
|
|
52
|
+
}
|
|
53
|
+
} catch {
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
try {
|
|
57
|
+
const raw = readFileSync(TOKEN_FILE(), "utf8").trim();
|
|
58
|
+
cached = raw || null;
|
|
59
|
+
} catch {
|
|
60
|
+
cached = null;
|
|
61
|
+
}
|
|
62
|
+
return cached;
|
|
63
|
+
}
|
|
64
|
+
function saveAccountToken(token) {
|
|
65
|
+
let stored = "file";
|
|
66
|
+
if (process.platform === "darwin") {
|
|
67
|
+
try {
|
|
68
|
+
execFileSync(
|
|
69
|
+
"security",
|
|
70
|
+
["add-generic-password", "-U", "-s", SERVICE, "-a", "goodfolder", "-w", token],
|
|
71
|
+
{ stdio: ["ignore", "pipe", "ignore"] }
|
|
72
|
+
);
|
|
73
|
+
stored = "keychain";
|
|
74
|
+
} catch {
|
|
75
|
+
stored = "file";
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
if (stored === "file") {
|
|
79
|
+
mkdirSync(CONFIG_DIR(), { recursive: true });
|
|
80
|
+
writeFileSync(TOKEN_FILE(), `${token.trim()}
|
|
81
|
+
`, { mode: 384 });
|
|
82
|
+
}
|
|
83
|
+
saveMeta({ savedAt: (/* @__PURE__ */ new Date()).toISOString() });
|
|
84
|
+
cached = token.trim();
|
|
85
|
+
return stored;
|
|
86
|
+
}
|
|
87
|
+
function readFolderTokens() {
|
|
88
|
+
try {
|
|
89
|
+
const parsed = JSON.parse(readFileSync(FOLDER_TOKENS_FILE(), "utf8"));
|
|
90
|
+
return parsed && typeof parsed === "object" ? parsed : {};
|
|
91
|
+
} catch {
|
|
92
|
+
return {};
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
function writeFolderTokens(all) {
|
|
96
|
+
mkdirSync(CONFIG_DIR(), { recursive: true, mode: 448 });
|
|
97
|
+
writeFileSync(FOLDER_TOKENS_FILE(), JSON.stringify(all, null, 2) + "\n", { mode: 384 });
|
|
98
|
+
}
|
|
99
|
+
function loadFolderToken(gitDir) {
|
|
100
|
+
const env = process.env.GF_FOLDER_TOKEN?.trim();
|
|
101
|
+
if (env) return { token: env };
|
|
102
|
+
return readFolderTokens()[gitDir] ?? null;
|
|
103
|
+
}
|
|
104
|
+
function saveFolderToken(gitDir, credential) {
|
|
105
|
+
const all = readFolderTokens();
|
|
106
|
+
all[gitDir] = credential;
|
|
107
|
+
writeFolderTokens(all);
|
|
108
|
+
}
|
|
109
|
+
var SERVICE, CONFIG_DIR, TOKEN_FILE, META_FILE, cached, FOLDER_TOKENS_FILE;
|
|
110
|
+
var init_credentials = __esm({
|
|
111
|
+
"src/credentials.ts"() {
|
|
112
|
+
"use strict";
|
|
113
|
+
SERVICE = "goodfolder-account";
|
|
114
|
+
CONFIG_DIR = () => join(homedir(), ".config", "goodfolder");
|
|
115
|
+
TOKEN_FILE = () => join(CONFIG_DIR(), "account-token");
|
|
116
|
+
META_FILE = () => join(CONFIG_DIR(), "account.json");
|
|
117
|
+
FOLDER_TOKENS_FILE = () => join(CONFIG_DIR(), "folder-tokens.json");
|
|
118
|
+
}
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
// src/config.ts
|
|
122
|
+
import { chmodSync, readFileSync as readFileSync2, writeFileSync as writeFileSync2 } from "node:fs";
|
|
123
|
+
import { join as join2 } from "node:path";
|
|
124
|
+
function withCredentials(base, token) {
|
|
125
|
+
const m = /^(https?:\/\/)(.*)$/.exec(base);
|
|
126
|
+
const scheme = m?.[1];
|
|
127
|
+
const rest = m?.[2];
|
|
128
|
+
if (!scheme || !rest) return base;
|
|
129
|
+
return `${scheme}x:${token}@${rest}`;
|
|
130
|
+
}
|
|
131
|
+
function transportUrl(cfg) {
|
|
132
|
+
return `${cfg.apiUrl}/git/${cfg.projectId}`;
|
|
133
|
+
}
|
|
134
|
+
function largeFileUrl(cfg) {
|
|
135
|
+
return `${cfg.apiUrl}/lfs/${cfg.projectId}`;
|
|
136
|
+
}
|
|
137
|
+
function transportEnv(cfg) {
|
|
138
|
+
return {
|
|
139
|
+
GIT_CONFIG_COUNT: "4",
|
|
140
|
+
GIT_CONFIG_KEY_0: `url.${withCredentials(cfg.apiUrl, cfg.token)}/git/${cfg.projectId}.insteadOf`,
|
|
141
|
+
GIT_CONFIG_VALUE_0: transportUrl(cfg),
|
|
142
|
+
GIT_CONFIG_KEY_1: "lfs.url",
|
|
143
|
+
GIT_CONFIG_VALUE_1: `${withCredentials(cfg.apiUrl, cfg.token)}/lfs/${cfg.projectId}`,
|
|
144
|
+
// The engine offers a credential that worked to the system's password
|
|
145
|
+
// store afterwards. Ours is renewed on its own and must not pile up there.
|
|
146
|
+
GIT_CONFIG_KEY_2: "credential.helper",
|
|
147
|
+
GIT_CONFIG_VALUE_2: "",
|
|
148
|
+
// Unless told, the large-file helper probes for a locking service the
|
|
149
|
+
// first time and writes its finding into the folder's settings under the
|
|
150
|
+
// full address — credential included. Telling it up front keeps that out.
|
|
151
|
+
GIT_CONFIG_KEY_3: "lfs.locksverify",
|
|
152
|
+
GIT_CONFIG_VALUE_3: "false",
|
|
153
|
+
GIT_TERMINAL_PROMPT: "0"
|
|
154
|
+
};
|
|
155
|
+
}
|
|
156
|
+
function configPath(gitDir) {
|
|
157
|
+
return join2(gitDir, "goodfolder.json");
|
|
158
|
+
}
|
|
159
|
+
function loadConfig(gitDir) {
|
|
160
|
+
let stored;
|
|
161
|
+
try {
|
|
162
|
+
stored = JSON.parse(readFileSync2(configPath(gitDir), "utf8"));
|
|
163
|
+
} catch {
|
|
164
|
+
return null;
|
|
165
|
+
}
|
|
166
|
+
if (!stored || typeof stored.projectId !== "string" || typeof stored.apiUrl !== "string") return null;
|
|
167
|
+
const { token: legacyToken, ...rest } = stored;
|
|
168
|
+
let credential = loadFolderToken(gitDir);
|
|
169
|
+
if (!credential && legacyToken) {
|
|
170
|
+
credential = { token: legacyToken };
|
|
171
|
+
saveFolderToken(gitDir, credential);
|
|
172
|
+
}
|
|
173
|
+
const cfg = { ...rest, token: credential?.token ?? "" };
|
|
174
|
+
if (credential?.expiresAt) cfg.tokenExpiresAt = credential.expiresAt;
|
|
175
|
+
if (legacyToken) saveConfig(gitDir, cfg);
|
|
176
|
+
return cfg;
|
|
177
|
+
}
|
|
178
|
+
function saveConfig(gitDir, cfg) {
|
|
179
|
+
const { token, tokenExpiresAt, ...written } = cfg;
|
|
180
|
+
if (token) saveFolderToken(gitDir, tokenExpiresAt ? { token, expiresAt: tokenExpiresAt } : { token });
|
|
181
|
+
writeFileSync2(configPath(gitDir), JSON.stringify(written, null, 2), { mode: 384 });
|
|
182
|
+
try {
|
|
183
|
+
chmodSync(configPath(gitDir), 384);
|
|
184
|
+
} catch {
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
var configuredApiUrl, DEFAULT_API_URL;
|
|
188
|
+
var init_config = __esm({
|
|
189
|
+
"src/config.ts"() {
|
|
190
|
+
"use strict";
|
|
191
|
+
init_credentials();
|
|
192
|
+
configuredApiUrl = process.env.GF_API_URL?.trim().replace(/\/+$/, "");
|
|
193
|
+
DEFAULT_API_URL = configuredApiUrl || "https://api.trygoodfolder.com";
|
|
194
|
+
}
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
// src/git.ts
|
|
198
|
+
import { spawn, spawnSync } from "node:child_process";
|
|
199
|
+
import { closeSync, openSync, readFileSync as readFileSync3, unlinkSync, writeFileSync as writeFileSync3 } from "node:fs";
|
|
200
|
+
import { tmpdir } from "node:os";
|
|
201
|
+
import { join as join3 } from "node:path";
|
|
202
|
+
function tempCapture() {
|
|
203
|
+
const base = join3(
|
|
204
|
+
tmpdir(),
|
|
205
|
+
`gf-git-${process.pid}-${Math.random().toString(36).slice(2)}`
|
|
206
|
+
);
|
|
207
|
+
const paths = [`${base}.out`, `${base}.err`];
|
|
208
|
+
writeFileSync3(paths[0], "");
|
|
209
|
+
writeFileSync3(paths[1], "");
|
|
210
|
+
return { fds: [openSync(paths[0], "r+"), openSync(paths[1], "r+")], paths };
|
|
211
|
+
}
|
|
212
|
+
function finishCapture(cap) {
|
|
213
|
+
for (const fd of cap.fds) {
|
|
214
|
+
try {
|
|
215
|
+
closeSync(fd);
|
|
216
|
+
} catch {
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
for (const p of cap.paths) {
|
|
220
|
+
try {
|
|
221
|
+
unlinkSync(p);
|
|
222
|
+
} catch {
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
function git(cwd, args, input, env) {
|
|
227
|
+
const cap = tempCapture();
|
|
228
|
+
try {
|
|
229
|
+
const r = spawnSync("git", args, {
|
|
230
|
+
cwd,
|
|
231
|
+
stdio: [input === void 0 ? "ignore" : "pipe", ...cap.fds],
|
|
232
|
+
...input === void 0 ? {} : { input },
|
|
233
|
+
...env ? { env: { ...process.env, ...env } } : {}
|
|
234
|
+
});
|
|
235
|
+
const stdout = readFileSync3(cap.paths[0], "utf8");
|
|
236
|
+
let stderr = readFileSync3(cap.paths[1], "utf8");
|
|
237
|
+
if (r.error && !stderr) stderr = String(r.error.message ?? "spawn failed");
|
|
238
|
+
return { code: r.status ?? 1, stdout, stderr };
|
|
239
|
+
} finally {
|
|
240
|
+
finishCapture(cap);
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
function gitOk(cwd, args) {
|
|
244
|
+
return git(cwd, args).code === 0;
|
|
245
|
+
}
|
|
246
|
+
function findGitDir(folder) {
|
|
247
|
+
const r = git(folder, ["rev-parse", "--absolute-git-dir"]);
|
|
248
|
+
return r.code === 0 ? r.stdout.trim() : null;
|
|
249
|
+
}
|
|
250
|
+
function readFileNow(path) {
|
|
251
|
+
try {
|
|
252
|
+
return readFileSync3(path, "utf8");
|
|
253
|
+
} catch {
|
|
254
|
+
return "";
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
function gitAsync(cwd, args) {
|
|
258
|
+
const cap = tempCapture();
|
|
259
|
+
return new Promise((resolve3) => {
|
|
260
|
+
const child = spawn("git", args, { cwd, stdio: ["ignore", ...cap.fds] });
|
|
261
|
+
child.on("close", (code) => {
|
|
262
|
+
const result = {
|
|
263
|
+
code: code ?? 1,
|
|
264
|
+
stdout: readFileNow(cap.paths[0]),
|
|
265
|
+
stderr: readFileNow(cap.paths[1])
|
|
266
|
+
};
|
|
267
|
+
finishCapture(cap);
|
|
268
|
+
resolve3(result);
|
|
269
|
+
});
|
|
270
|
+
child.on("error", () => {
|
|
271
|
+
const result = {
|
|
272
|
+
code: 1,
|
|
273
|
+
stdout: readFileNow(cap.paths[0]),
|
|
274
|
+
stderr: readFileNow(cap.paths[1]) || "spawn failed"
|
|
275
|
+
};
|
|
276
|
+
finishCapture(cap);
|
|
277
|
+
resolve3(result);
|
|
278
|
+
});
|
|
279
|
+
});
|
|
280
|
+
}
|
|
281
|
+
function gitStream(cwd, args, onProgress, env) {
|
|
282
|
+
const base = join3(
|
|
283
|
+
tmpdir(),
|
|
284
|
+
`gf-git-${process.pid}-${Math.random().toString(36).slice(2)}`
|
|
285
|
+
);
|
|
286
|
+
const outPath = `${base}.out`;
|
|
287
|
+
const errPath = `${base}.err`;
|
|
288
|
+
writeFileSync3(outPath, "");
|
|
289
|
+
writeFileSync3(errPath, "");
|
|
290
|
+
const outFd = openSync(outPath, "r+");
|
|
291
|
+
const errFd = openSync(errPath, "r+");
|
|
292
|
+
let errCursor = 0;
|
|
293
|
+
const child = spawn("git", args, {
|
|
294
|
+
cwd,
|
|
295
|
+
stdio: ["ignore", outFd, errFd],
|
|
296
|
+
...env ? { env: { ...process.env, ...env } } : {}
|
|
297
|
+
});
|
|
298
|
+
const kill = () => child.kill("SIGTERM");
|
|
299
|
+
const poller = onProgress && process.platform !== "win32" ? setInterval(() => {
|
|
300
|
+
const text = readFileNow(errPath);
|
|
301
|
+
if (text.length <= errCursor) return;
|
|
302
|
+
const fresh = text.slice(errCursor);
|
|
303
|
+
errCursor = text.length;
|
|
304
|
+
for (const frag of fresh.split(/[\r\n]/)) {
|
|
305
|
+
const t = frag.trim();
|
|
306
|
+
if (t) onProgress(t);
|
|
307
|
+
}
|
|
308
|
+
}, 120) : null;
|
|
309
|
+
const done = new Promise((resolve3) => {
|
|
310
|
+
child.on("close", (code) => {
|
|
311
|
+
if (poller) clearInterval(poller);
|
|
312
|
+
const stderr = readFileNow(errPath);
|
|
313
|
+
if (poller && onProgress) {
|
|
314
|
+
const rest = stderr.slice(errCursor);
|
|
315
|
+
for (const frag of rest.split(/[\r\n]/)) {
|
|
316
|
+
const t = frag.trim();
|
|
317
|
+
if (t) onProgress(t);
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
const result = {
|
|
321
|
+
code: code ?? 1,
|
|
322
|
+
stdout: readFileNow(outPath),
|
|
323
|
+
stderr,
|
|
324
|
+
kill
|
|
325
|
+
};
|
|
326
|
+
try {
|
|
327
|
+
closeSync(outFd);
|
|
328
|
+
closeSync(errFd);
|
|
329
|
+
unlinkSync(outPath);
|
|
330
|
+
unlinkSync(errPath);
|
|
331
|
+
} catch {
|
|
332
|
+
}
|
|
333
|
+
resolve3(result);
|
|
334
|
+
});
|
|
335
|
+
child.on("error", () => {
|
|
336
|
+
if (poller) clearInterval(poller);
|
|
337
|
+
const result = {
|
|
338
|
+
code: 1,
|
|
339
|
+
stdout: readFileNow(outPath),
|
|
340
|
+
stderr: readFileNow(errPath) || "spawn failed",
|
|
341
|
+
kill
|
|
342
|
+
};
|
|
343
|
+
try {
|
|
344
|
+
closeSync(outFd);
|
|
345
|
+
closeSync(errFd);
|
|
346
|
+
unlinkSync(outPath);
|
|
347
|
+
unlinkSync(errPath);
|
|
348
|
+
} catch {
|
|
349
|
+
}
|
|
350
|
+
resolve3(result);
|
|
351
|
+
});
|
|
352
|
+
});
|
|
353
|
+
return { done, kill };
|
|
354
|
+
}
|
|
355
|
+
var init_git = __esm({
|
|
356
|
+
"src/git.ts"() {
|
|
357
|
+
"use strict";
|
|
358
|
+
}
|
|
359
|
+
});
|
|
360
|
+
|
|
361
|
+
// src/perf.ts
|
|
362
|
+
async function trace(name, fn) {
|
|
363
|
+
if (!enabled()) return fn();
|
|
364
|
+
const t0 = performance.now();
|
|
365
|
+
try {
|
|
366
|
+
return await fn();
|
|
367
|
+
} finally {
|
|
368
|
+
marks.push([name, Math.round(performance.now() - t0)]);
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
function traceSync(name, fn) {
|
|
372
|
+
if (!enabled()) return fn();
|
|
373
|
+
const t0 = performance.now();
|
|
374
|
+
try {
|
|
375
|
+
return fn();
|
|
376
|
+
} finally {
|
|
377
|
+
marks.push([name, Math.round(performance.now() - t0)]);
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
function renderTrace() {
|
|
381
|
+
if (!enabled() || marks.length === 0) return "";
|
|
382
|
+
const parts = marks.map(([n, ms]) => `${n} ${ms}ms`);
|
|
383
|
+
const total = marks.reduce((a, [, ms]) => a + ms, 0);
|
|
384
|
+
marks.length = 0;
|
|
385
|
+
return `\u23F1 ${parts.join(" \xB7 ")} \u2014 total ${total}ms`;
|
|
386
|
+
}
|
|
387
|
+
function snapshotMarks() {
|
|
388
|
+
return [...marks];
|
|
389
|
+
}
|
|
390
|
+
function configureRepo(folder) {
|
|
391
|
+
git(folder, ["config", "core.fsmonitor", "true"]);
|
|
392
|
+
git(folder, ["config", "core.untrackedcache", "true"]);
|
|
393
|
+
git(folder, ["config", "feature.manyfiles", "true"]);
|
|
394
|
+
git(folder, ["config", "core.preloadindex", "true"]);
|
|
395
|
+
git(folder, ["config", "gc.auto", "0"]);
|
|
396
|
+
git(folder, ["config", "gc.autodetach", "false"]);
|
|
397
|
+
git(folder, ["config", "status.renames", "false"]);
|
|
398
|
+
const gitDir = findGitDir(folder);
|
|
399
|
+
if (!gitDir) return;
|
|
400
|
+
git(folder, ["fsmonitor--daemon", "start"]);
|
|
401
|
+
}
|
|
402
|
+
var enabled, marks;
|
|
403
|
+
var init_perf = __esm({
|
|
404
|
+
"src/perf.ts"() {
|
|
405
|
+
"use strict";
|
|
406
|
+
init_git();
|
|
407
|
+
enabled = () => process.env.GOODFOLDER_TRACE === "1";
|
|
408
|
+
marks = [];
|
|
409
|
+
}
|
|
410
|
+
});
|
|
411
|
+
|
|
412
|
+
// ../../packages/shared/src/index.ts
|
|
413
|
+
function extensionOf(path) {
|
|
414
|
+
const base = path.split("/").pop() ?? path;
|
|
415
|
+
const idx = base.lastIndexOf(".");
|
|
416
|
+
return idx > 0 ? base.slice(idx + 1).toLowerCase() : "";
|
|
417
|
+
}
|
|
418
|
+
function routeFile(path, sizeBytes) {
|
|
419
|
+
if (sizeBytes < ROUTING_FLOOR_BYTES) {
|
|
420
|
+
return { path, sizeBytes, target: "git", reason: "under-floor" };
|
|
421
|
+
}
|
|
422
|
+
if (sizeBytes > ROUTING_CEILING_BYTES) {
|
|
423
|
+
return { path, sizeBytes, target: "lfs", reason: "over-ceiling" };
|
|
424
|
+
}
|
|
425
|
+
const ext = extensionOf(path);
|
|
426
|
+
if (INCOMPRESSIBLE_EXTENSIONS.has(ext)) {
|
|
427
|
+
return { path, sizeBytes, target: "lfs", reason: "incompressible-type" };
|
|
428
|
+
}
|
|
429
|
+
if (COMPRESSIBLE_EXTENSIONS.has(ext)) {
|
|
430
|
+
return { path, sizeBytes, target: "git", reason: "compressible-type" };
|
|
431
|
+
}
|
|
432
|
+
return { path, sizeBytes, target: "lfs", reason: "unknown-type-size-fallback" };
|
|
433
|
+
}
|
|
434
|
+
function categoryOfPattern(pattern) {
|
|
435
|
+
return SKIP_RULES.find((r) => r.pattern === pattern)?.category ?? null;
|
|
436
|
+
}
|
|
437
|
+
function findCaseCollisions(paths) {
|
|
438
|
+
const seen = /* @__PURE__ */ new Map();
|
|
439
|
+
const collisions = [];
|
|
440
|
+
for (const p of [...paths].sort()) {
|
|
441
|
+
const key = p.toLowerCase();
|
|
442
|
+
const prior = seen.get(key);
|
|
443
|
+
if (prior !== void 0 && prior !== p) {
|
|
444
|
+
collisions.push({ a: prior, b: p });
|
|
445
|
+
} else {
|
|
446
|
+
seen.set(key, p);
|
|
447
|
+
}
|
|
448
|
+
}
|
|
449
|
+
return collisions;
|
|
450
|
+
}
|
|
451
|
+
function friendlyHarness(name) {
|
|
452
|
+
if (!name) return null;
|
|
453
|
+
const n = String(name).toLowerCase();
|
|
454
|
+
const known = [
|
|
455
|
+
[/claude/, "Claude Code"],
|
|
456
|
+
[/codex/, "Codex"],
|
|
457
|
+
[/cursor/, "Cursor"],
|
|
458
|
+
[/opencode/, "OpenCode"],
|
|
459
|
+
[/gemini/, "Gemini CLI"],
|
|
460
|
+
[/cline/, "Cline"],
|
|
461
|
+
[/windsurf/, "Windsurf"],
|
|
462
|
+
[/vscode/, "VS Code"]
|
|
463
|
+
];
|
|
464
|
+
for (const [re, label] of known) if (re.test(n)) return label;
|
|
465
|
+
const trimmed = String(name).trim().slice(0, 40);
|
|
466
|
+
return trimmed ? trimmed.charAt(0).toUpperCase() + trimmed.slice(1) : null;
|
|
467
|
+
}
|
|
468
|
+
var ROUTING_FLOOR_BYTES, ROUTING_CEILING_BYTES, INCOMPRESSIBLE_EXTENSIONS, COMPRESSIBLE_EXTENSIONS, SKIP_CATEGORY_LABEL, SKIP_RULES, KEEP_PATTERNS, CREDENTIAL_PATHSPECS, LABEL_EXCERPT_CHAR_BUDGET;
|
|
469
|
+
var init_src = __esm({
|
|
470
|
+
"../../packages/shared/src/index.ts"() {
|
|
471
|
+
"use strict";
|
|
472
|
+
ROUTING_FLOOR_BYTES = 1 * 1024 * 1024;
|
|
473
|
+
ROUTING_CEILING_BYTES = 100 * 1024 * 1024;
|
|
474
|
+
INCOMPRESSIBLE_EXTENSIONS = /* @__PURE__ */ new Set([
|
|
475
|
+
// images
|
|
476
|
+
"jpg",
|
|
477
|
+
"jpeg",
|
|
478
|
+
"png",
|
|
479
|
+
"gif",
|
|
480
|
+
"webp",
|
|
481
|
+
"heic",
|
|
482
|
+
"heif",
|
|
483
|
+
"avif",
|
|
484
|
+
"tif",
|
|
485
|
+
"tiff",
|
|
486
|
+
"ico",
|
|
487
|
+
"bmp",
|
|
488
|
+
"psd",
|
|
489
|
+
"ai",
|
|
490
|
+
"raw",
|
|
491
|
+
"dng",
|
|
492
|
+
// audio
|
|
493
|
+
"mp3",
|
|
494
|
+
"wav",
|
|
495
|
+
"aac",
|
|
496
|
+
"ogg",
|
|
497
|
+
"flac",
|
|
498
|
+
"m4a",
|
|
499
|
+
"aiff",
|
|
500
|
+
// video
|
|
501
|
+
"mp4",
|
|
502
|
+
"mov",
|
|
503
|
+
"avi",
|
|
504
|
+
"mkv",
|
|
505
|
+
"webm",
|
|
506
|
+
"m4v",
|
|
507
|
+
"wmv",
|
|
508
|
+
"prores",
|
|
509
|
+
"braw",
|
|
510
|
+
// zip containers
|
|
511
|
+
"zip",
|
|
512
|
+
"docx",
|
|
513
|
+
"xlsx",
|
|
514
|
+
"pptx",
|
|
515
|
+
"pdf",
|
|
516
|
+
"7z",
|
|
517
|
+
"rar",
|
|
518
|
+
"jar",
|
|
519
|
+
"apk",
|
|
520
|
+
"ipa",
|
|
521
|
+
"epub",
|
|
522
|
+
"odt",
|
|
523
|
+
"ods",
|
|
524
|
+
"odp",
|
|
525
|
+
// archives / disk
|
|
526
|
+
"tar",
|
|
527
|
+
"gz",
|
|
528
|
+
"tgz",
|
|
529
|
+
"bz2",
|
|
530
|
+
"xz",
|
|
531
|
+
"zst",
|
|
532
|
+
"iso",
|
|
533
|
+
"dmg"
|
|
534
|
+
]);
|
|
535
|
+
COMPRESSIBLE_EXTENSIONS = /* @__PURE__ */ new Set([
|
|
536
|
+
"txt",
|
|
537
|
+
"md",
|
|
538
|
+
"markdown",
|
|
539
|
+
"csv",
|
|
540
|
+
"tsv",
|
|
541
|
+
"json",
|
|
542
|
+
"jsonl",
|
|
543
|
+
"ndjson",
|
|
544
|
+
"yaml",
|
|
545
|
+
"yml",
|
|
546
|
+
"xml",
|
|
547
|
+
"svg",
|
|
548
|
+
"html",
|
|
549
|
+
"htm",
|
|
550
|
+
"css",
|
|
551
|
+
"scss",
|
|
552
|
+
"js",
|
|
553
|
+
"mjs",
|
|
554
|
+
"cjs",
|
|
555
|
+
"ts",
|
|
556
|
+
"tsx",
|
|
557
|
+
"jsx",
|
|
558
|
+
"py",
|
|
559
|
+
"rb",
|
|
560
|
+
"go",
|
|
561
|
+
"rs",
|
|
562
|
+
"java",
|
|
563
|
+
"kt",
|
|
564
|
+
"swift",
|
|
565
|
+
"c",
|
|
566
|
+
"h",
|
|
567
|
+
"cpp",
|
|
568
|
+
"hpp",
|
|
569
|
+
"cs",
|
|
570
|
+
"php",
|
|
571
|
+
"sh",
|
|
572
|
+
"bash",
|
|
573
|
+
"zsh",
|
|
574
|
+
"fish",
|
|
575
|
+
"sql",
|
|
576
|
+
"toml",
|
|
577
|
+
"ini",
|
|
578
|
+
"cfg",
|
|
579
|
+
"conf",
|
|
580
|
+
"env",
|
|
581
|
+
"gitignore",
|
|
582
|
+
"log",
|
|
583
|
+
"srt",
|
|
584
|
+
"vtt",
|
|
585
|
+
"fountain"
|
|
586
|
+
]);
|
|
587
|
+
SKIP_CATEGORY_LABEL = {
|
|
588
|
+
installed: "packages the project downloaded",
|
|
589
|
+
rebuildable: "files the project's own tools rebuild",
|
|
590
|
+
credentials: "files that look like they hold passwords or keys",
|
|
591
|
+
noise: "files the computer writes on its own"
|
|
592
|
+
};
|
|
593
|
+
SKIP_RULES = [
|
|
594
|
+
// Downloaded packages — restored by re-running the project's own installer.
|
|
595
|
+
{ pattern: "node_modules/", category: "installed" },
|
|
596
|
+
{ pattern: "bower_components/", category: "installed" },
|
|
597
|
+
{ pattern: ".pnpm-store/", category: "installed" },
|
|
598
|
+
{ pattern: ".yarn/cache/", category: "installed" },
|
|
599
|
+
{ pattern: ".venv/", category: "installed" },
|
|
600
|
+
{ pattern: "venv/", category: "installed", needs: "venv/pyvenv.cfg" },
|
|
601
|
+
// Build output and tool caches.
|
|
602
|
+
{ pattern: ".next/", category: "rebuildable" },
|
|
603
|
+
{ pattern: ".nuxt/", category: "rebuildable" },
|
|
604
|
+
{ pattern: ".svelte-kit/", category: "rebuildable" },
|
|
605
|
+
{ pattern: ".astro/", category: "rebuildable" },
|
|
606
|
+
{ pattern: ".turbo/", category: "rebuildable" },
|
|
607
|
+
{ pattern: ".parcel-cache/", category: "rebuildable" },
|
|
608
|
+
{ pattern: ".vite/", category: "rebuildable" },
|
|
609
|
+
{ pattern: ".gradle/", category: "rebuildable" },
|
|
610
|
+
{ pattern: ".terraform/", category: "rebuildable" },
|
|
611
|
+
{ pattern: "__pycache__/", category: "rebuildable" },
|
|
612
|
+
{ pattern: "*.pyc", category: "rebuildable" },
|
|
613
|
+
{ pattern: "*.pyo", category: "rebuildable" },
|
|
614
|
+
{ pattern: ".pytest_cache/", category: "rebuildable" },
|
|
615
|
+
{ pattern: ".mypy_cache/", category: "rebuildable" },
|
|
616
|
+
{ pattern: ".ruff_cache/", category: "rebuildable" },
|
|
617
|
+
{ pattern: "dist/", category: "rebuildable", needs: "package.json" },
|
|
618
|
+
{ pattern: "build/", category: "rebuildable", needs: "package.json" },
|
|
619
|
+
{ pattern: "out/", category: "rebuildable", needs: "package.json" },
|
|
620
|
+
{ pattern: "target/", category: "rebuildable", needs: "Cargo.toml" },
|
|
621
|
+
// Credentials. Never `*.key`: that is a Keynote presentation, and dropping
|
|
622
|
+
// someone's slides to catch a private key would be the worse trade.
|
|
623
|
+
{ pattern: ".env", category: "credentials" },
|
|
624
|
+
{ pattern: ".env.*", category: "credentials" },
|
|
625
|
+
{ pattern: "*.pem", category: "credentials" },
|
|
626
|
+
{ pattern: "id_rsa", category: "credentials" },
|
|
627
|
+
{ pattern: "id_dsa", category: "credentials" },
|
|
628
|
+
{ pattern: "id_ecdsa", category: "credentials" },
|
|
629
|
+
{ pattern: "id_ed25519", category: "credentials" },
|
|
630
|
+
{ pattern: ".npmrc", category: "credentials" },
|
|
631
|
+
// What the operating system leaves behind.
|
|
632
|
+
{ pattern: ".DS_Store", category: "noise" },
|
|
633
|
+
{ pattern: "Thumbs.db", category: "noise" },
|
|
634
|
+
{ pattern: "desktop.ini", category: "noise" },
|
|
635
|
+
{ pattern: "~$*", category: "noise" },
|
|
636
|
+
{ pattern: "npm-debug.log*", category: "noise" },
|
|
637
|
+
{ pattern: "yarn-error.log*", category: "noise" }
|
|
638
|
+
];
|
|
639
|
+
KEEP_PATTERNS = [
|
|
640
|
+
"!.env.example",
|
|
641
|
+
"!.env.sample",
|
|
642
|
+
"!.env.template",
|
|
643
|
+
"!.env.defaults"
|
|
644
|
+
];
|
|
645
|
+
CREDENTIAL_PATHSPECS = SKIP_RULES.filter(
|
|
646
|
+
(r) => r.category === "credentials"
|
|
647
|
+
).map((r) => `:(glob)**/${r.pattern}`);
|
|
648
|
+
for (const pattern of [...SKIP_RULES.map((r) => r.pattern), ...KEEP_PATTERNS]) {
|
|
649
|
+
const body = pattern.replace(/^!/, "").replace(/\/$/, "");
|
|
650
|
+
if (!body || /[?\[\]{}!\\]/.test(body) || body.includes("**")) {
|
|
651
|
+
throw new Error(`skip rule "${pattern}" is not a shape skipRuleFor can match`);
|
|
652
|
+
}
|
|
653
|
+
}
|
|
654
|
+
LABEL_EXCERPT_CHAR_BUDGET = 8e3;
|
|
655
|
+
}
|
|
656
|
+
});
|
|
657
|
+
|
|
658
|
+
// src/skip.ts
|
|
659
|
+
import { mkdirSync as mkdirSync2, readFileSync as readFileSync4, writeFileSync as writeFileSync4 } from "node:fs";
|
|
660
|
+
import { existsSync } from "node:fs";
|
|
661
|
+
import { dirname, join as join4 } from "node:path";
|
|
662
|
+
function excludeFilePath(gitDir) {
|
|
663
|
+
return join4(gitDir, "info", "exclude");
|
|
664
|
+
}
|
|
665
|
+
function activePatterns(folder) {
|
|
666
|
+
const patterns = [];
|
|
667
|
+
for (const rule of SKIP_RULES) {
|
|
668
|
+
if (rule.needs !== void 0 && !existsSync(join4(folder, rule.needs))) continue;
|
|
669
|
+
patterns.push(rule.pattern);
|
|
670
|
+
}
|
|
671
|
+
patterns.push(...KEEP_PATTERNS);
|
|
672
|
+
return patterns;
|
|
673
|
+
}
|
|
674
|
+
function applySkipRules(folder, gitDir) {
|
|
675
|
+
const path = excludeFilePath(gitDir);
|
|
676
|
+
let existing = "";
|
|
677
|
+
try {
|
|
678
|
+
existing = readFileSync4(path, "utf8");
|
|
679
|
+
} catch {
|
|
680
|
+
}
|
|
681
|
+
const stripped = existing.replace(
|
|
682
|
+
new RegExp(`${escapeRe(BEGIN)}[\\s\\S]*?${escapeRe(END)}\\n?`, "g"),
|
|
683
|
+
""
|
|
684
|
+
);
|
|
685
|
+
const block = [BEGIN, ...activePatterns(folder), END, ""].join("\n");
|
|
686
|
+
const head = stripped.length && !stripped.endsWith("\n") ? stripped + "\n" : stripped;
|
|
687
|
+
mkdirSync2(dirname(path), { recursive: true });
|
|
688
|
+
writeFileSync4(path, head + block);
|
|
689
|
+
}
|
|
690
|
+
function escapeRe(s) {
|
|
691
|
+
return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
692
|
+
}
|
|
693
|
+
function credentialFilesLeftOut(folder, alsoProtect = []) {
|
|
694
|
+
const r = git(folder, [
|
|
695
|
+
"ls-files",
|
|
696
|
+
"-o",
|
|
697
|
+
"-i",
|
|
698
|
+
"--exclude-standard",
|
|
699
|
+
"-z",
|
|
700
|
+
"--",
|
|
701
|
+
...CREDENTIAL_PATHSPECS
|
|
702
|
+
]);
|
|
703
|
+
if (r.code !== 0) return [];
|
|
704
|
+
const opted = new Set(alsoProtect);
|
|
705
|
+
return r.stdout.split("\0").filter((p) => p && !opted.has(p));
|
|
706
|
+
}
|
|
707
|
+
function skippedGroups(folder, alsoProtect = []) {
|
|
708
|
+
const status = git(folder, ["status", "--porcelain", "--ignored", "-z"]);
|
|
709
|
+
if (status.code !== 0) return [];
|
|
710
|
+
const opted = new Set(alsoProtect);
|
|
711
|
+
const paths = status.stdout.split("\0").filter((entry) => entry.startsWith("!! ")).map((entry) => entry.slice(3)).filter((p) => p && !opted.has(p));
|
|
712
|
+
if (paths.length === 0) return [];
|
|
713
|
+
const check = git(folder, ["check-ignore", "-v", "--no-index", "--stdin"], paths.join("\n"));
|
|
714
|
+
const byCategory = /* @__PURE__ */ new Map();
|
|
715
|
+
for (const line of check.stdout.split("\n")) {
|
|
716
|
+
if (!line.trim()) continue;
|
|
717
|
+
const tab = line.lastIndexOf(" ");
|
|
718
|
+
if (tab < 0) continue;
|
|
719
|
+
const path = line.slice(tab + 1);
|
|
720
|
+
const source = line.slice(0, tab);
|
|
721
|
+
const pattern = source.slice(source.lastIndexOf(":") + 1);
|
|
722
|
+
const category = categoryOfPattern(pattern) ?? "their-own";
|
|
723
|
+
const list = byCategory.get(category) ?? [];
|
|
724
|
+
list.push(path);
|
|
725
|
+
byCategory.set(category, list);
|
|
726
|
+
}
|
|
727
|
+
const order = [
|
|
728
|
+
"credentials",
|
|
729
|
+
"installed",
|
|
730
|
+
"rebuildable",
|
|
731
|
+
"noise",
|
|
732
|
+
"their-own"
|
|
733
|
+
];
|
|
734
|
+
const groups = [];
|
|
735
|
+
for (const category of order) {
|
|
736
|
+
const list = byCategory.get(category);
|
|
737
|
+
if (!list?.length) continue;
|
|
738
|
+
groups.push({
|
|
739
|
+
category,
|
|
740
|
+
label: category === "their-own" ? THEIR_OWN_LABEL : SKIP_CATEGORY_LABEL[category],
|
|
741
|
+
paths: [...list].sort()
|
|
742
|
+
});
|
|
743
|
+
}
|
|
744
|
+
return groups;
|
|
745
|
+
}
|
|
746
|
+
var BEGIN, END, THEIR_OWN_LABEL;
|
|
747
|
+
var init_skip = __esm({
|
|
748
|
+
"src/skip.ts"() {
|
|
749
|
+
"use strict";
|
|
750
|
+
init_src();
|
|
751
|
+
init_git();
|
|
752
|
+
BEGIN = "# --- GoodFolder: what it leaves out (managed automatically) ---";
|
|
753
|
+
END = "# --- end GoodFolder ---";
|
|
754
|
+
THEIR_OWN_LABEL = "files this project's own settings leave out";
|
|
755
|
+
}
|
|
756
|
+
});
|
|
757
|
+
|
|
758
|
+
// src/repo-setup.ts
|
|
759
|
+
function ensureSaveAuthor(folder) {
|
|
760
|
+
const name = git(folder, ["config", "--get", "user.name"]);
|
|
761
|
+
if (name.code !== 0 || !name.stdout.trim()) {
|
|
762
|
+
git(folder, ["config", "user.name", DEFAULT_SAVE_AUTHOR]);
|
|
763
|
+
}
|
|
764
|
+
const email = git(folder, ["config", "--get", "user.email"]);
|
|
765
|
+
if (email.code !== 0 || !email.stdout.trim()) {
|
|
766
|
+
git(folder, ["config", "user.email", DEFAULT_SAVE_EMAIL]);
|
|
767
|
+
}
|
|
768
|
+
}
|
|
769
|
+
function pushCurrentHistory(folder, cfg) {
|
|
770
|
+
return git(folder, ["push", GF_REMOTE, "HEAD:main"], void 0, transportEnv(cfg));
|
|
771
|
+
}
|
|
772
|
+
function fetchHistory(folder, cfg) {
|
|
773
|
+
return git(folder, ["fetch", GF_REMOTE], void 0, transportEnv(cfg));
|
|
774
|
+
}
|
|
775
|
+
function bindRepo(folder, gitDir, cfg) {
|
|
776
|
+
saveConfig(gitDir, cfg);
|
|
777
|
+
ensureRemote(folder, cfg);
|
|
778
|
+
applySkipRules(folder, gitDir);
|
|
779
|
+
ensureSaveAuthor(folder);
|
|
780
|
+
configureRepo(folder);
|
|
781
|
+
}
|
|
782
|
+
function ensureRemote(folder, cfg) {
|
|
783
|
+
const url = transportUrl(cfg);
|
|
784
|
+
const existing = git(folder, ["remote", "get-url", GF_REMOTE]);
|
|
785
|
+
if (existing.code === 0) {
|
|
786
|
+
if (existing.stdout.trim() !== url) {
|
|
787
|
+
git(folder, ["remote", "set-url", GF_REMOTE, url]);
|
|
788
|
+
}
|
|
789
|
+
} else {
|
|
790
|
+
git(folder, ["remote", "add", GF_REMOTE, url]);
|
|
791
|
+
}
|
|
792
|
+
const lfs = git(folder, ["config", "--get", "lfs.url"]);
|
|
793
|
+
if (lfs.code !== 0 || lfs.stdout.trim() !== largeFileUrl(cfg)) {
|
|
794
|
+
git(folder, ["config", "lfs.url", largeFileUrl(cfg)]);
|
|
795
|
+
}
|
|
796
|
+
const leaked = git(folder, ["config", "--local", "--name-only", "--get-regexp", "^lfs\\.https?://[^/]*@"]);
|
|
797
|
+
for (const name of leaked.stdout.split("\n").map((l) => l.trim()).filter(Boolean)) {
|
|
798
|
+
const section = name.slice(0, name.lastIndexOf("."));
|
|
799
|
+
git(folder, ["config", "--local", "--remove-section", section]);
|
|
800
|
+
}
|
|
801
|
+
if (git(folder, ["config", "--local", "--get", "lfs.locksverify"]).stdout.trim() !== "false") {
|
|
802
|
+
git(folder, ["config", "--local", "lfs.locksverify", "false"]);
|
|
803
|
+
}
|
|
804
|
+
const legacy = git(folder, ["remote", "get-url", "origin"]);
|
|
805
|
+
if (legacy.code === 0 && legacy.stdout.includes(`/git/${cfg.projectId}`)) {
|
|
806
|
+
git(folder, ["remote", "remove", "origin"]);
|
|
807
|
+
}
|
|
808
|
+
}
|
|
809
|
+
var GF_REMOTE, DEFAULT_SAVE_AUTHOR, DEFAULT_SAVE_EMAIL;
|
|
810
|
+
var init_repo_setup = __esm({
|
|
811
|
+
"src/repo-setup.ts"() {
|
|
812
|
+
"use strict";
|
|
813
|
+
init_config();
|
|
814
|
+
init_git();
|
|
815
|
+
init_perf();
|
|
816
|
+
init_skip();
|
|
817
|
+
GF_REMOTE = "goodfolder";
|
|
818
|
+
DEFAULT_SAVE_AUTHOR = "GoodFolder";
|
|
819
|
+
DEFAULT_SAVE_EMAIL = "goodfolder@local";
|
|
820
|
+
}
|
|
821
|
+
});
|
|
822
|
+
|
|
823
|
+
// src/cli-error.ts
|
|
824
|
+
var CliError;
|
|
825
|
+
var init_cli_error = __esm({
|
|
826
|
+
"src/cli-error.ts"() {
|
|
827
|
+
"use strict";
|
|
828
|
+
CliError = class extends Error {
|
|
829
|
+
exitCode;
|
|
830
|
+
constructor(message, exitCode = 1) {
|
|
831
|
+
super(message);
|
|
832
|
+
this.name = "CliError";
|
|
833
|
+
this.exitCode = exitCode;
|
|
834
|
+
}
|
|
835
|
+
};
|
|
836
|
+
}
|
|
837
|
+
});
|
|
838
|
+
|
|
839
|
+
// src/api.ts
|
|
840
|
+
var api_exports = {};
|
|
841
|
+
__export(api_exports, {
|
|
842
|
+
accountCall: () => accountCall,
|
|
843
|
+
createProject: () => createProject,
|
|
844
|
+
listProjects: () => listProjects,
|
|
845
|
+
listSaves: () => listSaves,
|
|
846
|
+
mintProjectToken: () => mintProjectToken,
|
|
847
|
+
preflightSave: () => preflightSave,
|
|
848
|
+
recordSave: () => recordSave,
|
|
849
|
+
renewFolderToken: () => renewFolderToken
|
|
850
|
+
});
|
|
851
|
+
async function call(cfg, method, path, body) {
|
|
852
|
+
const init = {
|
|
853
|
+
method,
|
|
854
|
+
headers: {
|
|
855
|
+
"content-type": "application/json",
|
|
856
|
+
authorization: `Bearer ${cfg.token}`
|
|
857
|
+
},
|
|
858
|
+
signal: AbortSignal.timeout(3e4)
|
|
859
|
+
};
|
|
860
|
+
if (body !== void 0) init.body = JSON.stringify(body);
|
|
861
|
+
const res = await fetch(`${cfg.apiUrl}${path}`, init);
|
|
862
|
+
let json = null;
|
|
863
|
+
try {
|
|
864
|
+
json = await res.json();
|
|
865
|
+
} catch {
|
|
866
|
+
}
|
|
867
|
+
return { ok: res.ok, status: res.status, json };
|
|
868
|
+
}
|
|
869
|
+
async function accountCall(apiUrl, accountToken, method, path, body, subscriptionAction) {
|
|
870
|
+
const init = {
|
|
871
|
+
method,
|
|
872
|
+
headers: {
|
|
873
|
+
"content-type": "application/json",
|
|
874
|
+
authorization: `Bearer ${accountToken}`
|
|
875
|
+
},
|
|
876
|
+
signal: AbortSignal.timeout(3e4)
|
|
877
|
+
};
|
|
878
|
+
if (body !== void 0) init.body = JSON.stringify(body);
|
|
879
|
+
const res = await fetch(`${apiUrl}${path}`, init);
|
|
880
|
+
let json = null;
|
|
881
|
+
try {
|
|
882
|
+
json = await res.json();
|
|
883
|
+
} catch {
|
|
884
|
+
}
|
|
885
|
+
if (!res.ok) {
|
|
886
|
+
const billing = billingErrorMessage(json?.error?.code, subscriptionAction);
|
|
887
|
+
if (billing) throw new CliError(`\u2717 ${billing}`);
|
|
888
|
+
if (res.status === 401 || res.status === 403) throw new CliError(`\u2717 ${authHint()}`);
|
|
889
|
+
throw new CliError(
|
|
890
|
+
`\u2717 ${json?.error?.message ?? `GoodFolder request failed (${res.status})`}`
|
|
891
|
+
);
|
|
892
|
+
}
|
|
893
|
+
return json;
|
|
894
|
+
}
|
|
895
|
+
function billingErrorMessage(code, subscriptionAction = "saving new work") {
|
|
896
|
+
if (code === "subscription-required") return `Start your GoodFolder Hosted trial before ${subscriptionAction}.`;
|
|
897
|
+
if (code === "read-only") return "This account is in read and export mode. Your existing files and earlier versions are still available.";
|
|
898
|
+
if (code === "quota-exceeded") return "You have reached your protected-data limit. Nothing was removed; increase your limit or free some capacity before saving again.";
|
|
899
|
+
if (code === "billing-unavailable") return "Hosted billing is unavailable right now. Try again shortly.";
|
|
900
|
+
return null;
|
|
901
|
+
}
|
|
902
|
+
async function preflightSave(cfg) {
|
|
903
|
+
const result = await call(cfg, "GET", "/api/save/preflight");
|
|
904
|
+
if (result.ok) return;
|
|
905
|
+
const message = billingErrorMessage(result.json?.error?.code);
|
|
906
|
+
throw new CliError(`\u2717 ${message ?? result.json?.error?.message ?? `GoodFolder request failed (${result.status})`}`);
|
|
907
|
+
}
|
|
908
|
+
function createProject(apiUrl, name, accountToken, deviceName) {
|
|
909
|
+
return accountCall(apiUrl, accountToken, "POST", "/api/projects", {
|
|
910
|
+
name,
|
|
911
|
+
...deviceName ? { deviceName } : {}
|
|
912
|
+
}, "connecting a folder");
|
|
913
|
+
}
|
|
914
|
+
function listProjects(apiUrl, accountToken) {
|
|
915
|
+
return accountCall(apiUrl, accountToken, "GET", "/api/projects");
|
|
916
|
+
}
|
|
917
|
+
function mintProjectToken(apiUrl, projectId, accountToken, deviceName) {
|
|
918
|
+
return accountCall(apiUrl, accountToken, "POST", `/api/projects/${projectId}/token`, deviceName ? { deviceName } : void 0);
|
|
919
|
+
}
|
|
920
|
+
async function renewFolderToken(cfg) {
|
|
921
|
+
const result = await call(cfg, "POST", "/api/folder-token/renew");
|
|
922
|
+
if (result.status === 401 || result.status === 403 || result.status === 404) return null;
|
|
923
|
+
if (!result.ok || typeof result.json?.token !== "string") {
|
|
924
|
+
throw new CliError(`\u2717 ${result.json?.error?.message ?? `GoodFolder request failed (${result.status})`}`);
|
|
925
|
+
}
|
|
926
|
+
return { token: result.json.token, expiresAt: String(result.json.expiresAt ?? "") };
|
|
927
|
+
}
|
|
928
|
+
function recordSave(cfg, input) {
|
|
929
|
+
return call(cfg, "POST", "/api/saves", {
|
|
930
|
+
label: input.label,
|
|
931
|
+
labelSource: input.label ? "user" : "agent",
|
|
932
|
+
changedPaths: input.changedPaths,
|
|
933
|
+
commitSha: input.commitSha,
|
|
934
|
+
collision: input.collision,
|
|
935
|
+
ai: input.ai ?? void 0,
|
|
936
|
+
counts: input.counts,
|
|
937
|
+
topPaths: input.topPaths,
|
|
938
|
+
harness: input.harness ?? void 0
|
|
939
|
+
}).then((r) => {
|
|
940
|
+
if (!r.ok) throw new Error(r.json?.error?.message ?? `save failed (${r.status})`);
|
|
941
|
+
return r.json;
|
|
942
|
+
});
|
|
943
|
+
}
|
|
944
|
+
async function listSaves(cfg) {
|
|
945
|
+
const r = await call(cfg, "GET", "/api/saves");
|
|
946
|
+
if (!r.ok) throw new Error(`could not load timeline (${r.status})`);
|
|
947
|
+
const rows = r.json;
|
|
948
|
+
return rows.map((row) => ({
|
|
949
|
+
...row,
|
|
950
|
+
createdAt: row.created_at ?? row.createdAt,
|
|
951
|
+
// The timeline endpoint returns camelCase `commitSha` since the
|
|
952
|
+
// save-receipts change (2026-08-26); older shapes used `commit_sha`.
|
|
953
|
+
// Normalize here so restore/undo have one field to read.
|
|
954
|
+
commit_sha: row.commit_sha ?? row.commitSha,
|
|
955
|
+
harness: row.harness ?? null
|
|
956
|
+
}));
|
|
957
|
+
}
|
|
958
|
+
var init_api = __esm({
|
|
959
|
+
"src/api.ts"() {
|
|
960
|
+
"use strict";
|
|
961
|
+
init_cli_error();
|
|
962
|
+
init_auth();
|
|
963
|
+
}
|
|
964
|
+
});
|
|
965
|
+
|
|
966
|
+
// src/auth.ts
|
|
967
|
+
import { spawn as spawn2 } from "node:child_process";
|
|
968
|
+
function authHint() {
|
|
969
|
+
return "GoodFolder can't act for your account on this computer.\n Fix: run goodfolder login\n (this opens a one-time browser approval)";
|
|
970
|
+
}
|
|
971
|
+
async function friendlyDeviceName() {
|
|
972
|
+
const os = await import("node:os");
|
|
973
|
+
return os.hostname().replace(/\.local$/i, "").trim() || "This computer";
|
|
974
|
+
}
|
|
975
|
+
function openBrowser(url) {
|
|
976
|
+
if (process.env.GF_NO_OPEN) return;
|
|
977
|
+
try {
|
|
978
|
+
if (process.platform === "win32") {
|
|
979
|
+
spawn2("cmd", ["/c", "start", "", url], { stdio: "ignore", detached: true }).unref();
|
|
980
|
+
} else {
|
|
981
|
+
const cmd = process.platform === "darwin" ? "open" : "xdg-open";
|
|
982
|
+
spawn2(cmd, [url], { stdio: "ignore", detached: true }).unref();
|
|
983
|
+
}
|
|
984
|
+
} catch {
|
|
985
|
+
}
|
|
986
|
+
}
|
|
987
|
+
async function ensureAccount(apiUrl = DEFAULT_API_URL) {
|
|
988
|
+
const existing = loadAccountToken();
|
|
989
|
+
if (existing) return existing;
|
|
990
|
+
const paired = await pairDevice(apiUrl);
|
|
991
|
+
return paired.token;
|
|
992
|
+
}
|
|
993
|
+
async function pairDevice(apiUrl = DEFAULT_API_URL) {
|
|
994
|
+
const deviceName = await friendlyDeviceName();
|
|
995
|
+
const startRes = await fetch(`${apiUrl}/api/pair/start`, {
|
|
996
|
+
method: "POST",
|
|
997
|
+
headers: { "content-type": "application/json" },
|
|
998
|
+
body: JSON.stringify({ deviceName }),
|
|
999
|
+
signal: AbortSignal.timeout(2e4)
|
|
1000
|
+
});
|
|
1001
|
+
const start = await startRes.json().catch(() => ({}));
|
|
1002
|
+
if (!startRes.ok || !start.code || !start.url) {
|
|
1003
|
+
throw new CliError(
|
|
1004
|
+
`\u2717 Could not start the approval: ${start.error?.message ?? startRes.status}`
|
|
1005
|
+
);
|
|
1006
|
+
}
|
|
1007
|
+
console.log(`
|
|
1008
|
+
One-time setup \u2014 approve "${deviceName}" to connect this computer.`);
|
|
1009
|
+
console.log("Your browser should open in a moment. Sign in, then choose Approve.");
|
|
1010
|
+
console.log(`The page will show the code ${pairingCheckCode(start.code)} \u2014 approve only if it matches.`);
|
|
1011
|
+
console.log(`If nothing opened, visit:
|
|
1012
|
+
${start.url}
|
|
1013
|
+
`);
|
|
1014
|
+
openBrowser(start.url);
|
|
1015
|
+
const deadline = Date.now() + 10 * 6e4;
|
|
1016
|
+
while (Date.now() < deadline) {
|
|
1017
|
+
await sleep(2e3);
|
|
1018
|
+
let status = "";
|
|
1019
|
+
let token;
|
|
1020
|
+
try {
|
|
1021
|
+
const res = await fetch(`${apiUrl}/api/pair/${start.code}/wait`, {
|
|
1022
|
+
signal: AbortSignal.timeout(15e3)
|
|
1023
|
+
});
|
|
1024
|
+
const j = await res.json().catch(() => ({}));
|
|
1025
|
+
status = j.status ?? "";
|
|
1026
|
+
token = j.token;
|
|
1027
|
+
} catch {
|
|
1028
|
+
continue;
|
|
1029
|
+
}
|
|
1030
|
+
if (status === "approved" && token) {
|
|
1031
|
+
const where = saveAccountToken(token);
|
|
1032
|
+
console.log(
|
|
1033
|
+
where === "keychain" ? "\u2713 This computer is approved (credential stored in your Keychain)." : "\u2713 This computer is approved."
|
|
1034
|
+
);
|
|
1035
|
+
return { token };
|
|
1036
|
+
}
|
|
1037
|
+
if (status === "expired") {
|
|
1038
|
+
throw new CliError(
|
|
1039
|
+
"\u2717 The approval window closed before anyone approved.\n Start again: goodfolder login"
|
|
1040
|
+
);
|
|
1041
|
+
}
|
|
1042
|
+
if (status === "denied") {
|
|
1043
|
+
throw new CliError("\u2717 This approval was declined.");
|
|
1044
|
+
}
|
|
1045
|
+
if (status === "consumed") {
|
|
1046
|
+
throw new CliError(
|
|
1047
|
+
"\u2717 That approval was already collected by another process."
|
|
1048
|
+
);
|
|
1049
|
+
}
|
|
1050
|
+
}
|
|
1051
|
+
throw new CliError(
|
|
1052
|
+
"\u2717 Gave up waiting for approval after 10 minutes.\n Start again: goodfolder login"
|
|
1053
|
+
);
|
|
1054
|
+
}
|
|
1055
|
+
function pairingCheckCode(code) {
|
|
1056
|
+
return code.slice(0, 6).toUpperCase();
|
|
1057
|
+
}
|
|
1058
|
+
async function cmdDevices(action, target) {
|
|
1059
|
+
const accountToken = loadAccountToken();
|
|
1060
|
+
if (!accountToken) throw new CliError(`\u2717 ${authHint()}`);
|
|
1061
|
+
const { accountCall: accountCall2 } = await Promise.resolve().then(() => (init_api(), api_exports));
|
|
1062
|
+
const listed = await accountCall2(DEFAULT_API_URL, accountToken, "GET", "/api/account/devices");
|
|
1063
|
+
const devices = listed.devices ?? [];
|
|
1064
|
+
if (action === "forget") {
|
|
1065
|
+
const wanted = (target ?? "").trim().toLowerCase();
|
|
1066
|
+
const match = devices.filter((d) => d.id === wanted || d.name.toLowerCase() === wanted);
|
|
1067
|
+
if (!wanted || match.length === 0) {
|
|
1068
|
+
throw new CliError(`\u2717 No approved computer called "${target ?? ""}". Run: goodfolder devices`);
|
|
1069
|
+
}
|
|
1070
|
+
if (match.length > 1) {
|
|
1071
|
+
throw new CliError(`\u2717 More than one computer is called "${target}". Use its id from: goodfolder devices`);
|
|
1072
|
+
}
|
|
1073
|
+
const [device] = match;
|
|
1074
|
+
await accountCall2(DEFAULT_API_URL, accountToken, "DELETE", `/api/account/devices/${device.id}`);
|
|
1075
|
+
console.log(`\u2713 "${device.name}" can no longer act for your account.`);
|
|
1076
|
+
if (device.thisOne) console.log(" That was this computer. Run goodfolder login to approve it again.");
|
|
1077
|
+
return;
|
|
1078
|
+
}
|
|
1079
|
+
if (devices.length === 0) {
|
|
1080
|
+
console.log("No computers are approved on this account.");
|
|
1081
|
+
return;
|
|
1082
|
+
}
|
|
1083
|
+
console.log("Computers approved on your account:\n");
|
|
1084
|
+
for (const d of devices) {
|
|
1085
|
+
const used = d.lastUsedAt ? `last used ${d.lastUsedAt.slice(0, 10)}` : "not used yet";
|
|
1086
|
+
console.log(` ${d.name}${d.thisOne ? " (this computer)" : ""}`);
|
|
1087
|
+
console.log(` approved ${d.approvedAt.slice(0, 10)} \xB7 ${used} \xB7 id ${d.id}`);
|
|
1088
|
+
}
|
|
1089
|
+
console.log("\nTo take one back: goodfolder devices forget <name or id>");
|
|
1090
|
+
}
|
|
1091
|
+
async function cmdLogin() {
|
|
1092
|
+
if (loadAccountToken()) {
|
|
1093
|
+
console.log("This computer already has an approval. Approving again replaces it.");
|
|
1094
|
+
}
|
|
1095
|
+
const meta = accountMeta();
|
|
1096
|
+
void meta;
|
|
1097
|
+
await pairDevice(DEFAULT_API_URL);
|
|
1098
|
+
}
|
|
1099
|
+
var sleep;
|
|
1100
|
+
var init_auth = __esm({
|
|
1101
|
+
"src/auth.ts"() {
|
|
1102
|
+
"use strict";
|
|
1103
|
+
init_config();
|
|
1104
|
+
init_cli_error();
|
|
1105
|
+
init_credentials();
|
|
1106
|
+
sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
1107
|
+
}
|
|
1108
|
+
});
|
|
1109
|
+
|
|
1110
|
+
// src/nested.ts
|
|
1111
|
+
import { lstatSync, readlinkSync } from "node:fs";
|
|
1112
|
+
import { join as join5 } from "node:path";
|
|
1113
|
+
function foreignHistories(folder) {
|
|
1114
|
+
const r = git(folder, ["ls-files", "-s", "-z"]);
|
|
1115
|
+
if (r.code !== 0) return [];
|
|
1116
|
+
const dirs = [];
|
|
1117
|
+
for (const entry of r.stdout.split("\0")) {
|
|
1118
|
+
if (!entry.startsWith("160000 ")) continue;
|
|
1119
|
+
const tab = entry.indexOf(" ");
|
|
1120
|
+
if (tab < 0) continue;
|
|
1121
|
+
dirs.push(entry.slice(tab + 1));
|
|
1122
|
+
}
|
|
1123
|
+
return dirs;
|
|
1124
|
+
}
|
|
1125
|
+
function filesWorthTaking(folder, dir) {
|
|
1126
|
+
const r = git(join5(folder, dir), [
|
|
1127
|
+
"ls-files",
|
|
1128
|
+
"--cached",
|
|
1129
|
+
"--others",
|
|
1130
|
+
"--exclude-standard",
|
|
1131
|
+
"-z"
|
|
1132
|
+
]);
|
|
1133
|
+
if (r.code !== 0) return [];
|
|
1134
|
+
return r.stdout.split("\0").filter(Boolean);
|
|
1135
|
+
}
|
|
1136
|
+
function absorbForeignHistories(folder, dirs) {
|
|
1137
|
+
const taken = [];
|
|
1138
|
+
for (const dir of dirs) {
|
|
1139
|
+
git(folder, ["update-index", "--force-remove", "--", dir]);
|
|
1140
|
+
for (const rel of filesWorthTaking(folder, dir)) {
|
|
1141
|
+
const path = `${dir}/${rel}`;
|
|
1142
|
+
let st;
|
|
1143
|
+
try {
|
|
1144
|
+
st = lstatSync(join5(folder, path));
|
|
1145
|
+
} catch {
|
|
1146
|
+
continue;
|
|
1147
|
+
}
|
|
1148
|
+
let mode;
|
|
1149
|
+
let blob;
|
|
1150
|
+
if (st.isSymbolicLink()) {
|
|
1151
|
+
mode = "120000";
|
|
1152
|
+
const target = readlinkSync(join5(folder, path));
|
|
1153
|
+
blob = git(folder, ["hash-object", "-w", "--stdin"], target).stdout.trim();
|
|
1154
|
+
} else if (st.isFile()) {
|
|
1155
|
+
mode = st.mode & 73 ? "100755" : "100644";
|
|
1156
|
+
blob = git(folder, ["hash-object", "-w", "--path", path, "--", path]).stdout.trim();
|
|
1157
|
+
} else {
|
|
1158
|
+
continue;
|
|
1159
|
+
}
|
|
1160
|
+
if (!blob) continue;
|
|
1161
|
+
const add = git(folder, ["update-index", "--add", "--cacheinfo", mode, blob, path]);
|
|
1162
|
+
if (add.code === 0) taken.push(path);
|
|
1163
|
+
}
|
|
1164
|
+
}
|
|
1165
|
+
return taken;
|
|
1166
|
+
}
|
|
1167
|
+
function pathsInside(folder, dirs) {
|
|
1168
|
+
return dirs.flatMap((dir) => filesWorthTaking(folder, dir).map((rel) => `${dir}/${rel}`));
|
|
1169
|
+
}
|
|
1170
|
+
var init_nested = __esm({
|
|
1171
|
+
"src/nested.ts"() {
|
|
1172
|
+
"use strict";
|
|
1173
|
+
init_git();
|
|
1174
|
+
}
|
|
1175
|
+
});
|
|
1176
|
+
|
|
1177
|
+
// src/save-core.ts
|
|
1178
|
+
import { appendFileSync, readFileSync as readFileSync5, statSync } from "node:fs";
|
|
1179
|
+
import { join as join6 } from "node:path";
|
|
1180
|
+
function scanChanges(folder) {
|
|
1181
|
+
const r = git(folder, ["status", "--porcelain", "-z", "-uall"]);
|
|
1182
|
+
const added = [];
|
|
1183
|
+
const modified = [];
|
|
1184
|
+
const deleted = [];
|
|
1185
|
+
const all = [];
|
|
1186
|
+
const entries = r.stdout.split("\0").filter(Boolean);
|
|
1187
|
+
for (let i = 0; i < entries.length; i++) {
|
|
1188
|
+
const entry = entries[i];
|
|
1189
|
+
const xy = entry.slice(0, 2);
|
|
1190
|
+
let path = entry.slice(3);
|
|
1191
|
+
if (xy.includes("R") || xy.includes("C")) {
|
|
1192
|
+
i++;
|
|
1193
|
+
}
|
|
1194
|
+
if (path.startsWith('"')) continue;
|
|
1195
|
+
all.push(path);
|
|
1196
|
+
if (xy.includes("D") && !xy.includes("?")) deleted.push(path);
|
|
1197
|
+
else if (xy.includes("A") || xy.includes("?")) added.push(path);
|
|
1198
|
+
else modified.push(path);
|
|
1199
|
+
}
|
|
1200
|
+
return { added, modified, deleted, all };
|
|
1201
|
+
}
|
|
1202
|
+
function hasHead(folder) {
|
|
1203
|
+
return gitOk(folder, ["rev-parse", "-q", "--verify", "HEAD"]);
|
|
1204
|
+
}
|
|
1205
|
+
async function enforceCaseGate(folder, changes, trackedPromise, extraPaths = []) {
|
|
1206
|
+
const tracked = (await trackedPromise).stdout.split("\n").filter(Boolean);
|
|
1207
|
+
const collisions = findCaseCollisions([
|
|
1208
|
+
.../* @__PURE__ */ new Set([...changes.all, ...tracked, ...extraPaths])
|
|
1209
|
+
]);
|
|
1210
|
+
if (collisions.length === 0) return;
|
|
1211
|
+
console.error("\u2717 This save was refused.\n");
|
|
1212
|
+
console.error(
|
|
1213
|
+
"Two files differ only by capitalization, which would silently overwrite"
|
|
1214
|
+
);
|
|
1215
|
+
console.error("one of them on Windows or macOS:");
|
|
1216
|
+
for (const c of collisions.slice(0, 10)) {
|
|
1217
|
+
console.error(` \u2022 ${c.a} \u2194 ${c.b}`);
|
|
1218
|
+
}
|
|
1219
|
+
throw new CliError("\nRename one of them, then save again.", 1);
|
|
1220
|
+
}
|
|
1221
|
+
function applyRouting(folder, paths) {
|
|
1222
|
+
const attrPath = join6(folder, ".gitattributes");
|
|
1223
|
+
let existing = "";
|
|
1224
|
+
try {
|
|
1225
|
+
existing = readFileSync5(attrPath, "utf8");
|
|
1226
|
+
} catch {
|
|
1227
|
+
}
|
|
1228
|
+
const lines = new Set(existing.split("\n").map((l) => l.trim()));
|
|
1229
|
+
let dirty = false;
|
|
1230
|
+
for (const p of paths) {
|
|
1231
|
+
let size = 0;
|
|
1232
|
+
try {
|
|
1233
|
+
size = statSync(join6(folder, p)).size;
|
|
1234
|
+
} catch {
|
|
1235
|
+
continue;
|
|
1236
|
+
}
|
|
1237
|
+
if (routeFile(p, size).target !== "lfs") continue;
|
|
1238
|
+
const entry = `${p.includes(" ") ? `"${p}"` : p} filter=lfs diff=lfs merge=lfs -text`;
|
|
1239
|
+
if (!lines.has(entry)) {
|
|
1240
|
+
lines.add(entry);
|
|
1241
|
+
dirty = true;
|
|
1242
|
+
}
|
|
1243
|
+
}
|
|
1244
|
+
if (dirty) {
|
|
1245
|
+
const header = existing.includes("# goodfolder-managed") ? "" : "# goodfolder-managed large-file routing\n";
|
|
1246
|
+
appendFileSync(
|
|
1247
|
+
attrPath,
|
|
1248
|
+
(existing.endsWith("\n") || existing === "" ? "" : "\n") + header + [...lines].filter((l) => l && !existing.split("\n").includes(l)).join("\n") + "\n"
|
|
1249
|
+
);
|
|
1250
|
+
}
|
|
1251
|
+
}
|
|
1252
|
+
function buildAiContext(folder, changes) {
|
|
1253
|
+
const diff = git(folder, ["diff", "--cached"]);
|
|
1254
|
+
const mediaPaths = /* @__PURE__ */ new Set();
|
|
1255
|
+
for (const p of [...changes.added, ...changes.modified]) {
|
|
1256
|
+
try {
|
|
1257
|
+
if (routeFile(p, statSync(join6(folder, p)).size).target === "lfs") mediaPaths.add(p);
|
|
1258
|
+
} catch {
|
|
1259
|
+
}
|
|
1260
|
+
}
|
|
1261
|
+
const parts = [];
|
|
1262
|
+
let used = 0;
|
|
1263
|
+
let truncated = false;
|
|
1264
|
+
for (const line of diff.stdout.split("\n")) {
|
|
1265
|
+
if (line.startsWith("Binary files") || /^GIT binary patch/.test(line)) {
|
|
1266
|
+
continue;
|
|
1267
|
+
}
|
|
1268
|
+
if (used + line.length + 1 > LABEL_EXCERPT_CHAR_BUDGET) {
|
|
1269
|
+
truncated = true;
|
|
1270
|
+
break;
|
|
1271
|
+
}
|
|
1272
|
+
parts.push(line);
|
|
1273
|
+
used += line.length + 1;
|
|
1274
|
+
}
|
|
1275
|
+
if (!changes.all.length) return null;
|
|
1276
|
+
const mediaNote = mediaPaths.size > 0 ? ` Media files (contents not shown): ${[...mediaPaths].slice(0, 20).map((p) => {
|
|
1277
|
+
try {
|
|
1278
|
+
return `${p} (${Math.round(statSync(join6(folder, p)).size / 1024)} KB)`;
|
|
1279
|
+
} catch {
|
|
1280
|
+
return p;
|
|
1281
|
+
}
|
|
1282
|
+
}).join(", ")}.` : "";
|
|
1283
|
+
return {
|
|
1284
|
+
summary: `${changes.added.length} added, ${changes.modified.length} modified, ${changes.deleted.length} removed.${mediaNote}`,
|
|
1285
|
+
excerpt: parts.join("\n"),
|
|
1286
|
+
truncated
|
|
1287
|
+
};
|
|
1288
|
+
}
|
|
1289
|
+
function pickTopPaths(changes, limit = 8) {
|
|
1290
|
+
const clean = (list) => list.filter((p) => !NOISE_FILES.has(p.split(/[\\/]/).pop() ?? ""));
|
|
1291
|
+
const ordered = [...clean(changes.modified), ...clean(changes.added), ...clean(changes.deleted)];
|
|
1292
|
+
return [...new Set(ordered)].slice(0, limit);
|
|
1293
|
+
}
|
|
1294
|
+
function renderImportProgress(fragment) {
|
|
1295
|
+
if (!process.stdout.isTTY) return;
|
|
1296
|
+
const pct = /(\d+)%/.exec(fragment);
|
|
1297
|
+
const nums = /(\d[\d,]*)\s*\/\s*(\d[\d,]*)/.exec(fragment.replace(/,/g, ""));
|
|
1298
|
+
let line;
|
|
1299
|
+
if (pct?.[1] && nums?.[1] && nums[2]) {
|
|
1300
|
+
line = `Importing your folder\u2026 ${pct[1]}% (${fmt(Number(nums[1].replace(/,/g, "")))} / ${fmt(Number(nums[2].replace(/,/g, "")))} files)`;
|
|
1301
|
+
} else if (nums?.[1] && nums[2]) {
|
|
1302
|
+
line = `Importing your folder\u2026 ${fmt(Number(nums[1].replace(/,/g, "")))} / ${fmt(Number(nums[2].replace(/,/g, "")))} files`;
|
|
1303
|
+
} else if (pct?.[1]) {
|
|
1304
|
+
line = `Importing your folder\u2026 ${pct[1]}%`;
|
|
1305
|
+
} else {
|
|
1306
|
+
return;
|
|
1307
|
+
}
|
|
1308
|
+
process.stdout.write(`\r\x1B[2K${line}`);
|
|
1309
|
+
}
|
|
1310
|
+
function reportWhatStayedOut(folder, alsoProtect, wasImport) {
|
|
1311
|
+
if (wasImport) {
|
|
1312
|
+
const groups = skippedGroups(folder, alsoProtect);
|
|
1313
|
+
if (groups.length === 0) return;
|
|
1314
|
+
console.log(" Left out, because your own tools remake them or they hold secrets:");
|
|
1315
|
+
for (const group of groups) {
|
|
1316
|
+
const shown = group.paths.slice(0, 3).join(", ");
|
|
1317
|
+
const rest = group.paths.length - 3;
|
|
1318
|
+
const more = rest > 0 ? `, and ${fmt(rest)} more` : "";
|
|
1319
|
+
console.log(` \u2022 ${group.label}: ${shown}${more}`);
|
|
1320
|
+
}
|
|
1321
|
+
console.log(" To see the whole list, or protect one anyway: goodfolder skipped");
|
|
1322
|
+
return;
|
|
1323
|
+
}
|
|
1324
|
+
const secrets = credentialFilesLeftOut(folder, alsoProtect);
|
|
1325
|
+
if (secrets.length === 0) return;
|
|
1326
|
+
const many = secrets.length === 1 ? "file that looks" : "files that look";
|
|
1327
|
+
console.log(
|
|
1328
|
+
` ${secrets.length} ${many} like passwords or keys stayed out \u2014 goodfolder skipped`
|
|
1329
|
+
);
|
|
1330
|
+
}
|
|
1331
|
+
async function runSavePipeline(folder, cfg, opts = {}) {
|
|
1332
|
+
const gitDir = findGitDir(folder);
|
|
1333
|
+
if (!gitDir) throw new CliError("\u2717 This folder is not connected.", 1);
|
|
1334
|
+
const wasImport = !traceSync("head-check", () => hasHead(folder));
|
|
1335
|
+
const trackedPromise = gitAsync(folder, ["ls-files"]);
|
|
1336
|
+
const changes = traceSync("scan", () => scanChanges(folder));
|
|
1337
|
+
if (wasImport) {
|
|
1338
|
+
console.log(
|
|
1339
|
+
"First save on this folder \u2014 bringing everything into GoodFolder."
|
|
1340
|
+
);
|
|
1341
|
+
console.log("Large folders take a moment; you can keep working meanwhile.");
|
|
1342
|
+
} else if (changes.all.length === 0) {
|
|
1343
|
+
void trackedPromise;
|
|
1344
|
+
console.log("Nothing new to save \u2014 your folder matches the last save.");
|
|
1345
|
+
const nothing = { sha: "", changedCount: 0, wasImport: false, seq: void 0, label: "", truncated: false, pushSkipped: opts.skipPush ?? false, timings: {}, counts: { added: 0, changed: 0, removed: 0 }, topPaths: [] };
|
|
1346
|
+
return nothing;
|
|
1347
|
+
}
|
|
1348
|
+
if (wasImport && changes.all.length === 0) {
|
|
1349
|
+
void trackedPromise;
|
|
1350
|
+
console.log("Connected. The folder is empty right now \u2014 anything you add and save is protected from then on.");
|
|
1351
|
+
const empty = { sha: "", changedCount: 0, wasImport: true, seq: void 0, label: "", truncated: false, pushSkipped: opts.skipPush ?? false, timings: {}, counts: { added: 0, changed: 0, removed: 0 }, topPaths: [] };
|
|
1352
|
+
return empty;
|
|
1353
|
+
}
|
|
1354
|
+
if (!opts.skipPush) await trace("access-preflight", () => preflightSave(cfg));
|
|
1355
|
+
traceSync(
|
|
1356
|
+
"routing",
|
|
1357
|
+
() => applyRouting(folder, [...changes.added, ...changes.modified])
|
|
1358
|
+
);
|
|
1359
|
+
let interrupted = false;
|
|
1360
|
+
const staged = await trace("stage", async () => {
|
|
1361
|
+
if (!wasImport) {
|
|
1362
|
+
const ok = gitOk(folder, ["add", "-A"]);
|
|
1363
|
+
if (!ok) throw new CliError("\u2717 Could not prepare your changes.", 1);
|
|
1364
|
+
return;
|
|
1365
|
+
}
|
|
1366
|
+
let seen = 0;
|
|
1367
|
+
const total = Math.max(1, changes.all.length);
|
|
1368
|
+
const tick = () => {
|
|
1369
|
+
seen++;
|
|
1370
|
+
if (seen % 200 === 0 || seen === total) {
|
|
1371
|
+
renderImportProgress(`${seen} / ${total} files`);
|
|
1372
|
+
}
|
|
1373
|
+
};
|
|
1374
|
+
const { done, kill } = gitStream(folder, ["add", "-A", "--verbose"], () => tick());
|
|
1375
|
+
const sigint = () => {
|
|
1376
|
+
interrupted = true;
|
|
1377
|
+
kill();
|
|
1378
|
+
};
|
|
1379
|
+
process.on("SIGINT", sigint);
|
|
1380
|
+
try {
|
|
1381
|
+
const r = await done;
|
|
1382
|
+
if (process.stdout.isTTY) process.stdout.write("\r\x1B[2K");
|
|
1383
|
+
if (interrupted || r.code !== 0) {
|
|
1384
|
+
if (interrupted) {
|
|
1385
|
+
throw new CliError(
|
|
1386
|
+
"\nImport paused \u2014 nothing is lost. Run goodfolder save again to pick up where it left off.",
|
|
1387
|
+
130
|
|
1388
|
+
);
|
|
1389
|
+
}
|
|
1390
|
+
throw new CliError(`\u2717 Could not prepare your changes: ${r.stderr.trim()}`, 1);
|
|
1391
|
+
}
|
|
1392
|
+
} finally {
|
|
1393
|
+
process.off("SIGINT", sigint);
|
|
1394
|
+
}
|
|
1395
|
+
});
|
|
1396
|
+
void staged;
|
|
1397
|
+
let dirsWithForeignHistory = [];
|
|
1398
|
+
const absorbed = traceSync("nested", () => {
|
|
1399
|
+
dirsWithForeignHistory = foreignHistories(folder);
|
|
1400
|
+
const dirs = dirsWithForeignHistory;
|
|
1401
|
+
if (dirs.length === 0) return [];
|
|
1402
|
+
const inside = pathsInside(folder, dirs);
|
|
1403
|
+
applyRouting(folder, inside);
|
|
1404
|
+
gitOk(folder, ["add", "-A", "--", ".gitattributes"]);
|
|
1405
|
+
return absorbForeignHistories(folder, dirs);
|
|
1406
|
+
});
|
|
1407
|
+
if (absorbed.length > 0) {
|
|
1408
|
+
const placeholders = new Set(
|
|
1409
|
+
dirsWithForeignHistory.flatMap((dir) => [dir, `${dir}/`])
|
|
1410
|
+
);
|
|
1411
|
+
const drop = (list) => {
|
|
1412
|
+
const kept = list.filter((p) => !placeholders.has(p));
|
|
1413
|
+
list.length = 0;
|
|
1414
|
+
list.push(...kept);
|
|
1415
|
+
};
|
|
1416
|
+
drop(changes.added);
|
|
1417
|
+
drop(changes.modified);
|
|
1418
|
+
drop(changes.all);
|
|
1419
|
+
changes.added.push(...absorbed);
|
|
1420
|
+
changes.all.push(...absorbed);
|
|
1421
|
+
}
|
|
1422
|
+
const alsoProtect = cfg.alsoProtect ?? [];
|
|
1423
|
+
if (alsoProtect.length > 0) {
|
|
1424
|
+
gitOk(folder, ["add", "-f", "--", ...alsoProtect]);
|
|
1425
|
+
}
|
|
1426
|
+
await trace(
|
|
1427
|
+
"case-gate",
|
|
1428
|
+
() => enforceCaseGate(folder, changes, trackedPromise, absorbed)
|
|
1429
|
+
);
|
|
1430
|
+
const ai = opts.recorder ? traceSync("label-context", () => buildAiContext(folder, changes)) : null;
|
|
1431
|
+
const commitMsg = opts.message ?? (wasImport ? "First save" : "Save");
|
|
1432
|
+
const commit = await trace("commit", async () => {
|
|
1433
|
+
ensureSaveAuthor(folder);
|
|
1434
|
+
const r = git(folder, ["commit", "-m", commitMsg]);
|
|
1435
|
+
if (r.code !== 0) {
|
|
1436
|
+
const detail = r.stderr.trim();
|
|
1437
|
+
throw new CliError(
|
|
1438
|
+
detail ? `\u2717 Could not record this Save: ${detail}` : "\u2717 Could not record this Save right now.",
|
|
1439
|
+
1
|
|
1440
|
+
);
|
|
1441
|
+
}
|
|
1442
|
+
return git(folder, ["rev-parse", "HEAD"]).stdout.trim();
|
|
1443
|
+
});
|
|
1444
|
+
let pushSkipped = opts.skipPush ?? false;
|
|
1445
|
+
if (!pushSkipped) {
|
|
1446
|
+
await trace("push", async () => {
|
|
1447
|
+
const push = pushCurrentHistory(folder, cfg);
|
|
1448
|
+
if (push.code !== 0) {
|
|
1449
|
+
if (/non-fast-forward|rejected/i.test(push.stderr)) {
|
|
1450
|
+
throw new CliError("\u2717 Another device saved first. Run: goodfolder sync");
|
|
1451
|
+
}
|
|
1452
|
+
throw new CliError(`\u2717 Could not reach GoodFolder: ${push.stderr.trim()}`);
|
|
1453
|
+
}
|
|
1454
|
+
});
|
|
1455
|
+
}
|
|
1456
|
+
let label = commitMsg;
|
|
1457
|
+
let seq;
|
|
1458
|
+
let truncated = false;
|
|
1459
|
+
if (opts.recorder) {
|
|
1460
|
+
try {
|
|
1461
|
+
const input = {
|
|
1462
|
+
changedPaths: changes.all,
|
|
1463
|
+
commitSha: commit,
|
|
1464
|
+
counts: {
|
|
1465
|
+
added: changes.added.length,
|
|
1466
|
+
changed: changes.modified.length,
|
|
1467
|
+
removed: changes.deleted.length
|
|
1468
|
+
},
|
|
1469
|
+
topPaths: pickTopPaths(changes)
|
|
1470
|
+
};
|
|
1471
|
+
if (opts.message) input.label = opts.message;
|
|
1472
|
+
if (ai) input.ai = ai;
|
|
1473
|
+
const res = await opts.recorder(input);
|
|
1474
|
+
seq = res.seq;
|
|
1475
|
+
label = res.label ?? label;
|
|
1476
|
+
} catch (e) {
|
|
1477
|
+
console.warn(
|
|
1478
|
+
`\u26A0 Saved locally and uploaded, but the timeline update failed (${e.message}).`
|
|
1479
|
+
);
|
|
1480
|
+
}
|
|
1481
|
+
}
|
|
1482
|
+
truncated = ai?.truncated ?? false;
|
|
1483
|
+
const counts = {
|
|
1484
|
+
added: changes.added.length,
|
|
1485
|
+
changed: changes.modified.length,
|
|
1486
|
+
removed: changes.deleted.length
|
|
1487
|
+
};
|
|
1488
|
+
if (wasImport) {
|
|
1489
|
+
console.log(`\u2713 Imported and safe \u2014 ${fmt(changes.all.length)} files are now protected.`);
|
|
1490
|
+
} else {
|
|
1491
|
+
const what = counts.added + counts.removed > 0 ? `${counts.added} added \xB7 ${counts.changed} changed \xB7 ${counts.removed} removed` : `${counts.changed} file${counts.changed === 1 ? "" : "s"} updated`;
|
|
1492
|
+
console.log(`\u2713 Saved${seq !== void 0 ? ` #${seq}` : ""}`);
|
|
1493
|
+
console.log(` ${what}`);
|
|
1494
|
+
}
|
|
1495
|
+
console.log(` ${label}`);
|
|
1496
|
+
if (truncated) console.log(" (large change \u2014 the label saw a partial preview)");
|
|
1497
|
+
reportWhatStayedOut(folder, alsoProtect, wasImport);
|
|
1498
|
+
const timings = {};
|
|
1499
|
+
for (const [name, ms] of snapshotMarks()) {
|
|
1500
|
+
timings[name] = (timings[name] ?? 0) + ms;
|
|
1501
|
+
}
|
|
1502
|
+
const tr = renderTrace();
|
|
1503
|
+
if (tr) console.log(tr);
|
|
1504
|
+
const outcome = { sha: commit, changedCount: changes.all.length, wasImport, seq, label, truncated, pushSkipped, timings, counts, topPaths: pickTopPaths(changes) };
|
|
1505
|
+
return outcome;
|
|
1506
|
+
}
|
|
1507
|
+
var fmt, NOISE_FILES;
|
|
1508
|
+
var init_save_core = __esm({
|
|
1509
|
+
"src/save-core.ts"() {
|
|
1510
|
+
"use strict";
|
|
1511
|
+
init_src();
|
|
1512
|
+
init_cli_error();
|
|
1513
|
+
init_git();
|
|
1514
|
+
init_perf();
|
|
1515
|
+
init_api();
|
|
1516
|
+
init_repo_setup();
|
|
1517
|
+
init_nested();
|
|
1518
|
+
init_skip();
|
|
1519
|
+
fmt = (n) => n.toLocaleString("en-US");
|
|
1520
|
+
NOISE_FILES = /* @__PURE__ */ new Set([".DS_Store", "Thumbs.db", "desktop.ini"]);
|
|
1521
|
+
}
|
|
1522
|
+
});
|
|
1523
|
+
|
|
1524
|
+
// src/save.ts
|
|
1525
|
+
var save_exports = {};
|
|
1526
|
+
__export(save_exports, {
|
|
1527
|
+
cmdSave: () => cmdSave
|
|
1528
|
+
});
|
|
1529
|
+
async function cmdSave(folder, cfg, opts) {
|
|
1530
|
+
await runSavePipeline(folder, cfg, {
|
|
1531
|
+
message: opts.message,
|
|
1532
|
+
...opts.harness ? { harness: opts.harness } : {},
|
|
1533
|
+
async recorder(input) {
|
|
1534
|
+
const res = await recordSave(cfg, {
|
|
1535
|
+
changedPaths: input.changedPaths,
|
|
1536
|
+
commitSha: input.commitSha,
|
|
1537
|
+
counts: input.counts,
|
|
1538
|
+
topPaths: input.topPaths,
|
|
1539
|
+
harness: opts.harness ?? null,
|
|
1540
|
+
...input.label !== void 0 ? { label: input.label } : {},
|
|
1541
|
+
...input.ai !== void 0 ? { ai: input.ai } : {}
|
|
1542
|
+
});
|
|
1543
|
+
return { seq: res.seq, label: res.label };
|
|
1544
|
+
}
|
|
1545
|
+
});
|
|
1546
|
+
}
|
|
1547
|
+
var init_save = __esm({
|
|
1548
|
+
"src/save.ts"() {
|
|
1549
|
+
"use strict";
|
|
1550
|
+
init_api();
|
|
1551
|
+
init_save_core();
|
|
1552
|
+
}
|
|
1553
|
+
});
|
|
1554
|
+
|
|
1555
|
+
// src/index.ts
|
|
1556
|
+
import { resolve as resolve2 } from "node:path";
|
|
1557
|
+
|
|
1558
|
+
// src/connect.ts
|
|
1559
|
+
init_config();
|
|
1560
|
+
init_repo_setup();
|
|
1561
|
+
init_cli_error();
|
|
1562
|
+
init_git();
|
|
1563
|
+
init_auth();
|
|
1564
|
+
init_api();
|
|
1565
|
+
init_credentials();
|
|
1566
|
+
import { existsSync as existsSync2 } from "node:fs";
|
|
1567
|
+
import { basename, resolve } from "node:path";
|
|
1568
|
+
var RENEW_WHEN_LEFT_MS = 30 * 864e5;
|
|
1569
|
+
async function ensureFreshToken(folder, gitDir, cfg) {
|
|
1570
|
+
const left = cfg.tokenExpiresAt ? Date.parse(cfg.tokenExpiresAt) - Date.now() : Number.NaN;
|
|
1571
|
+
if (cfg.token && Number.isFinite(left) && left > RENEW_WHEN_LEFT_MS) return;
|
|
1572
|
+
let fresh = null;
|
|
1573
|
+
try {
|
|
1574
|
+
if (cfg.token) fresh = await renewFolderToken(cfg);
|
|
1575
|
+
if (!fresh) {
|
|
1576
|
+
const accountToken = loadAccountToken();
|
|
1577
|
+
if (!accountToken) {
|
|
1578
|
+
throw new CliError(
|
|
1579
|
+
"\u2717 This folder's connection to GoodFolder needs approving again.\n Fix: run goodfolder login and then try again."
|
|
1580
|
+
);
|
|
1581
|
+
}
|
|
1582
|
+
fresh = await mintProjectToken(cfg.apiUrl, cfg.projectId, accountToken, await friendlyDeviceName());
|
|
1583
|
+
}
|
|
1584
|
+
} catch (error) {
|
|
1585
|
+
if (error instanceof CliError) throw error;
|
|
1586
|
+
return;
|
|
1587
|
+
}
|
|
1588
|
+
cfg.token = fresh.token;
|
|
1589
|
+
if (fresh.expiresAt) cfg.tokenExpiresAt = fresh.expiresAt;
|
|
1590
|
+
else delete cfg.tokenExpiresAt;
|
|
1591
|
+
saveConfig(gitDir, cfg);
|
|
1592
|
+
}
|
|
1593
|
+
async function requireConnection(folder) {
|
|
1594
|
+
const gitDir = findGitDir(folder);
|
|
1595
|
+
const cfg = gitDir ? loadConfig(gitDir) : null;
|
|
1596
|
+
if (!gitDir || !cfg) {
|
|
1597
|
+
throw new CliError(
|
|
1598
|
+
"\u2717 This folder isn't connected to GoodFolder yet. Run:\n goodfolder connect"
|
|
1599
|
+
);
|
|
1600
|
+
}
|
|
1601
|
+
await ensureFreshToken(folder, gitDir, cfg);
|
|
1602
|
+
ensureRemote(folder, cfg);
|
|
1603
|
+
return { gitDir, cfg };
|
|
1604
|
+
}
|
|
1605
|
+
function enclosingManagedFolder(folder) {
|
|
1606
|
+
const prefix = git(folder, ["rev-parse", "--show-prefix"]);
|
|
1607
|
+
if (prefix.code !== 0) return null;
|
|
1608
|
+
if (prefix.stdout.trim() === "") return null;
|
|
1609
|
+
const root = git(folder, ["rev-parse", "--show-toplevel"]).stdout.trim();
|
|
1610
|
+
return root || null;
|
|
1611
|
+
}
|
|
1612
|
+
function projectNameForFolder(folder) {
|
|
1613
|
+
return basename(resolve(folder));
|
|
1614
|
+
}
|
|
1615
|
+
async function cmdConnect(folder) {
|
|
1616
|
+
if (!existsSync2(folder)) {
|
|
1617
|
+
throw new CliError(`\u2717 No such folder: ${folder}`, 1);
|
|
1618
|
+
}
|
|
1619
|
+
const enclosing = enclosingManagedFolder(folder);
|
|
1620
|
+
if (enclosing !== null) {
|
|
1621
|
+
throw new CliError(
|
|
1622
|
+
`\u2717 This folder sits inside "${enclosing}", which another tool already looks after.
|
|
1623
|
+
Connecting it here would protect that whole outer folder instead of this one.
|
|
1624
|
+
Connect the outer folder instead, or move this one somewhere of its own.`,
|
|
1625
|
+
1
|
|
1626
|
+
);
|
|
1627
|
+
}
|
|
1628
|
+
let gitDir = findGitDir(folder);
|
|
1629
|
+
const fresh = gitDir === null;
|
|
1630
|
+
if (fresh) {
|
|
1631
|
+
if (!gitOk(folder, ["init", "-b", "main"])) {
|
|
1632
|
+
throw new CliError("\u2717 Could not initialize the folder.", 1);
|
|
1633
|
+
}
|
|
1634
|
+
gitDir = findGitDir(folder);
|
|
1635
|
+
}
|
|
1636
|
+
if (loadConfig(gitDir)) {
|
|
1637
|
+
console.log("Already connected \u2014 nothing to do.");
|
|
1638
|
+
return;
|
|
1639
|
+
}
|
|
1640
|
+
const name = projectNameForFolder(folder);
|
|
1641
|
+
console.log("Connecting\u2026");
|
|
1642
|
+
const accountToken = await ensureAccount(DEFAULT_API_URL);
|
|
1643
|
+
const boot = await createProject(DEFAULT_API_URL, name, accountToken, await friendlyDeviceName());
|
|
1644
|
+
if (!boot.projectId || !boot.token) {
|
|
1645
|
+
throw new CliError("\u2717 Could not create your project. Try again shortly.", 1);
|
|
1646
|
+
}
|
|
1647
|
+
const cfg = {
|
|
1648
|
+
projectId: boot.projectId,
|
|
1649
|
+
apiUrl: DEFAULT_API_URL,
|
|
1650
|
+
token: boot.token,
|
|
1651
|
+
connectedAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
1652
|
+
};
|
|
1653
|
+
if (boot.expiresAt) cfg.tokenExpiresAt = boot.expiresAt;
|
|
1654
|
+
bindRepo(folder, gitDir, cfg);
|
|
1655
|
+
console.log(`\u2713 Connected "${name}" at ${resolve(folder)} to GoodFolder.`);
|
|
1656
|
+
if (fresh) console.log(" (nothing visible changed \u2014 your folder just became protected)");
|
|
1657
|
+
const status = git(folder, ["status", "--porcelain"]);
|
|
1658
|
+
if (status.stdout.trim() !== "") {
|
|
1659
|
+
console.log("Saving everything in this folder for the first time\u2026");
|
|
1660
|
+
await new Promise((r) => setTimeout(r, 100));
|
|
1661
|
+
const { cmdSave: cmdSave2 } = await Promise.resolve().then(() => (init_save(), save_exports));
|
|
1662
|
+
await cmdSave2(folder, cfg, {});
|
|
1663
|
+
} else {
|
|
1664
|
+
console.log("Folder is empty of changes; run goodfolder save when ready.");
|
|
1665
|
+
}
|
|
1666
|
+
}
|
|
1667
|
+
|
|
1668
|
+
// src/index.ts
|
|
1669
|
+
init_save();
|
|
1670
|
+
|
|
1671
|
+
// src/sync.ts
|
|
1672
|
+
init_cli_error();
|
|
1673
|
+
init_git();
|
|
1674
|
+
init_api();
|
|
1675
|
+
init_repo_setup();
|
|
1676
|
+
async function cmdSync(folder, opts = {}) {
|
|
1677
|
+
const { cfg } = await requireConnection(folder);
|
|
1678
|
+
const fetchRes = fetchHistory(folder, cfg);
|
|
1679
|
+
if (fetchRes.code !== 0) {
|
|
1680
|
+
throw new CliError(`\u2717 Could not reach GoodFolder: ${fetchRes.stderr.trim()}`, 1);
|
|
1681
|
+
}
|
|
1682
|
+
if (!gitOk(folder, ["rev-parse", "-q", "--verify", `${GF_REMOTE}/main`])) {
|
|
1683
|
+
console.log("Everything is already up to date.");
|
|
1684
|
+
return;
|
|
1685
|
+
}
|
|
1686
|
+
const count = git(folder, [
|
|
1687
|
+
"rev-list",
|
|
1688
|
+
"--left-right",
|
|
1689
|
+
"--count",
|
|
1690
|
+
`HEAD...${GF_REMOTE}/main`
|
|
1691
|
+
]);
|
|
1692
|
+
const [ahead, behind] = count.stdout.trim().split(/\s+/).map(Number);
|
|
1693
|
+
if (ahead === void 0 || behind === void 0) {
|
|
1694
|
+
throw new CliError("\u2717 Could not compare with your other devices.", 1);
|
|
1695
|
+
}
|
|
1696
|
+
if (ahead === 0 && behind === 0) {
|
|
1697
|
+
console.log("Everything is already up to date.");
|
|
1698
|
+
return;
|
|
1699
|
+
}
|
|
1700
|
+
if (ahead > 0 && behind > 0) {
|
|
1701
|
+
console.log("You and another device both made changes \u2014 combining them\u2026");
|
|
1702
|
+
const merge = git(folder, ["merge", `${GF_REMOTE}/main`, "-m", "Sync changes"]);
|
|
1703
|
+
if (merge.code !== 0) {
|
|
1704
|
+
const conflicted = git(folder, ["diff", "--name-only", "--diff-filter=U"]).stdout.split("\n").filter(Boolean);
|
|
1705
|
+
throw new CliError(
|
|
1706
|
+
"\nBoth versions are kept \u2014 nothing was lost.\n" + conflicted.map((f) => ` \u2022 ${f}`).join("\n") + '\n\nOpen those files, keep what you want, then run:\n goodfolder save -m "Resolved changes"',
|
|
1707
|
+
2
|
|
1708
|
+
);
|
|
1709
|
+
}
|
|
1710
|
+
const sha = git(folder, ["rev-parse", "HEAD"]).stdout.trim();
|
|
1711
|
+
const push = pushCurrentHistory(folder, cfg);
|
|
1712
|
+
if (push.code !== 0) {
|
|
1713
|
+
throw new CliError("\u2717 Combined locally but could not upload. Try again.", 1);
|
|
1714
|
+
}
|
|
1715
|
+
try {
|
|
1716
|
+
await recordSave(cfg, {
|
|
1717
|
+
label: `Synced changes from another device`,
|
|
1718
|
+
changedPaths: git(folder, ["diff", "--name-only", "HEAD^", "HEAD"]).stdout.split("\n").filter(Boolean),
|
|
1719
|
+
commitSha: sha,
|
|
1720
|
+
collision: "auto-merged",
|
|
1721
|
+
harness: opts.harness ?? null
|
|
1722
|
+
});
|
|
1723
|
+
} catch {
|
|
1724
|
+
}
|
|
1725
|
+
console.log("\u2713 Combined and saved.");
|
|
1726
|
+
return;
|
|
1727
|
+
}
|
|
1728
|
+
if (behind > 0) {
|
|
1729
|
+
const ff = git(folder, ["merge", "--ff-only", `${GF_REMOTE}/main`]);
|
|
1730
|
+
if (ff.code !== 0) {
|
|
1731
|
+
throw new CliError("\u2717 Update failed mid-way \u2014 your work is untouched.", 1);
|
|
1732
|
+
}
|
|
1733
|
+
console.log(`\u2713 Brought in ${behind} change${behind === 1 ? "" : "s"} from your other device${behind === 1 ? "" : "s"}.`);
|
|
1734
|
+
return;
|
|
1735
|
+
}
|
|
1736
|
+
console.log("Your folder has unsaved local changes ahead \u2014 run: goodfolder save");
|
|
1737
|
+
}
|
|
1738
|
+
|
|
1739
|
+
// src/restore.ts
|
|
1740
|
+
init_cli_error();
|
|
1741
|
+
init_git();
|
|
1742
|
+
init_api();
|
|
1743
|
+
init_repo_setup();
|
|
1744
|
+
async function cmdRestore(folder, seqArg, opts = {}) {
|
|
1745
|
+
const { cfg } = await requireConnection(folder);
|
|
1746
|
+
const seq = Number(seqArg);
|
|
1747
|
+
if (!Number.isInteger(seq)) {
|
|
1748
|
+
throw new CliError("\u2717 Use a save number from the timeline. Try: goodfolder log", 1);
|
|
1749
|
+
}
|
|
1750
|
+
const saves = await listSaves(cfg);
|
|
1751
|
+
const target = saves.find((s) => s.seq === seq);
|
|
1752
|
+
if (!target) {
|
|
1753
|
+
throw new CliError(`\u2717 No save #${seq} in your timeline. Try: goodfolder log`, 1);
|
|
1754
|
+
}
|
|
1755
|
+
const haveObjects = gitOk(folder, ["cat-file", "-e", `${target.commit_sha}^{commit}`]);
|
|
1756
|
+
if (!haveObjects) {
|
|
1757
|
+
console.log("That save lives deeper than this device keeps copies \u2014 downloading its contents\u2026");
|
|
1758
|
+
const fetchRes = fetchHistory(folder, cfg);
|
|
1759
|
+
if (fetchRes.code !== 0 || !gitOk(folder, ["cat-file", "-e", `${target.commit_sha}^{commit}`])) {
|
|
1760
|
+
throw new CliError("\u2717 Could not download that save's contents. Check your connection.", 1);
|
|
1761
|
+
}
|
|
1762
|
+
}
|
|
1763
|
+
if (!gitOk(folder, ["restore", "--source", target.commit_sha, "--worktree", "--staged", "."])) {
|
|
1764
|
+
throw new CliError("\u2717 Could not apply that save.", 1);
|
|
1765
|
+
}
|
|
1766
|
+
const sourceFiles = new Set(
|
|
1767
|
+
git(folder, ["ls-tree", "-r", "--name-only", target.commit_sha]).stdout.split("\n").filter(Boolean)
|
|
1768
|
+
);
|
|
1769
|
+
const current = git(folder, ["ls-files"]).stdout.split("\n").filter(Boolean);
|
|
1770
|
+
const extras = current.filter((f) => !sourceFiles.has(f));
|
|
1771
|
+
for (const f of extras) git(folder, ["rm", "-q", "--cached", f]);
|
|
1772
|
+
if (!gitOk(folder, ["commit", "-m", `Restore of save #${seq}`])) {
|
|
1773
|
+
console.log("Already identical to that save \u2014 nothing to do.");
|
|
1774
|
+
return;
|
|
1775
|
+
}
|
|
1776
|
+
const sha = git(folder, ["rev-parse", "HEAD"]).stdout.trim();
|
|
1777
|
+
const push = pushCurrentHistory(folder, cfg);
|
|
1778
|
+
if (push.code !== 0) {
|
|
1779
|
+
throw new CliError("\u2717 Restored locally but could not upload. Run: goodfolder sync", 1);
|
|
1780
|
+
}
|
|
1781
|
+
try {
|
|
1782
|
+
await recordSave(cfg, {
|
|
1783
|
+
label: `Restored save #${seq}: ${target.label}`,
|
|
1784
|
+
changedPaths: [],
|
|
1785
|
+
commitSha: sha,
|
|
1786
|
+
harness: opts.harness ?? null
|
|
1787
|
+
});
|
|
1788
|
+
} catch {
|
|
1789
|
+
}
|
|
1790
|
+
console.log(`\u2713 Your folder now matches save #${seq}.`);
|
|
1791
|
+
console.log(" Changed your mind? Restore the newest number to undo this.");
|
|
1792
|
+
}
|
|
1793
|
+
|
|
1794
|
+
// src/undo.ts
|
|
1795
|
+
init_src();
|
|
1796
|
+
init_cli_error();
|
|
1797
|
+
import { createInterface } from "node:readline/promises";
|
|
1798
|
+
init_git();
|
|
1799
|
+
init_api();
|
|
1800
|
+
init_repo_setup();
|
|
1801
|
+
var NOISE_FILES2 = /* @__PURE__ */ new Set([".DS_Store", "Thumbs.db", "desktop.ini"]);
|
|
1802
|
+
function detectAgentRun(saves) {
|
|
1803
|
+
const top = saves[0]?.harness;
|
|
1804
|
+
if (!top || saves.length < 2) return 1;
|
|
1805
|
+
let n = 1;
|
|
1806
|
+
while (n < saves.length - 1 && saves[n]?.harness === top) n++;
|
|
1807
|
+
return n;
|
|
1808
|
+
}
|
|
1809
|
+
function parseNameStatus(out) {
|
|
1810
|
+
let added = 0;
|
|
1811
|
+
let changed = 0;
|
|
1812
|
+
let removed = 0;
|
|
1813
|
+
const paths = [];
|
|
1814
|
+
for (const line of out.split("\n")) {
|
|
1815
|
+
if (!line.trim()) continue;
|
|
1816
|
+
const tab = line.lastIndexOf(" ");
|
|
1817
|
+
if (tab < 0) continue;
|
|
1818
|
+
const code = line[0];
|
|
1819
|
+
paths.push(line.slice(tab + 1));
|
|
1820
|
+
if (code === "A") added++;
|
|
1821
|
+
else if (code === "D") removed++;
|
|
1822
|
+
else changed++;
|
|
1823
|
+
}
|
|
1824
|
+
return { paths, counts: { added, changed, removed } };
|
|
1825
|
+
}
|
|
1826
|
+
function summarizeReverseEffect(nameStatus) {
|
|
1827
|
+
let back = 0;
|
|
1828
|
+
let remove = 0;
|
|
1829
|
+
let roll = 0;
|
|
1830
|
+
for (const line of nameStatus.split("\n")) {
|
|
1831
|
+
if (!line.trim()) continue;
|
|
1832
|
+
const code = line[0];
|
|
1833
|
+
if (code === "A") remove++;
|
|
1834
|
+
else if (code === "D") back++;
|
|
1835
|
+
else roll++;
|
|
1836
|
+
}
|
|
1837
|
+
const parts = [];
|
|
1838
|
+
if (back) parts.push(`brings back ${back} file${back === 1 ? "" : "s"}`);
|
|
1839
|
+
if (remove) {
|
|
1840
|
+
parts.push(
|
|
1841
|
+
`removes ${remove} file${remove === 1 ? "" : "s"} that ${remove === 1 ? "was" : "were"} added`
|
|
1842
|
+
);
|
|
1843
|
+
}
|
|
1844
|
+
if (roll) parts.push(`rolls back ${roll} change${roll === 1 ? "" : "s"}`);
|
|
1845
|
+
if (parts.length === 0) {
|
|
1846
|
+
return "It changes nothing \u2014 your folder already matches that state.";
|
|
1847
|
+
}
|
|
1848
|
+
if (parts.length === 1) return `It ${parts[0]}.`;
|
|
1849
|
+
return `It ${parts.slice(0, -1).join(", ")} and ${parts[parts.length - 1]}.`;
|
|
1850
|
+
}
|
|
1851
|
+
var clip = (s, n = 90) => s.length > n ? `${s.slice(0, n - 1)}\u2026` : s;
|
|
1852
|
+
function buildUndoLabel(scope, harnessName) {
|
|
1853
|
+
if (scope.length === 1) {
|
|
1854
|
+
return clip(`Undid save #${scope[0].seq} \u2014 ${scope[0].label}`, 110);
|
|
1855
|
+
}
|
|
1856
|
+
const who = harnessName ? ` from ${harnessName}` : "";
|
|
1857
|
+
return `Undid ${scope.length} saves${who} (#${scope[scope.length - 1].seq}\u2013#${scope[0].seq})`;
|
|
1858
|
+
}
|
|
1859
|
+
function buildPreview(input) {
|
|
1860
|
+
const { scope, harnessName, effect, runLen } = input;
|
|
1861
|
+
const lines = [];
|
|
1862
|
+
if (scope.length === 1) {
|
|
1863
|
+
const who = harnessName ? `${harnessName}'s last save` : "the last save";
|
|
1864
|
+
lines.push(
|
|
1865
|
+
`This undoes ${who} (#${scope[0].seq})${scope[0].label ? `: \u201C${clip(scope[0].label)}\u201D` : ""}.`
|
|
1866
|
+
);
|
|
1867
|
+
} else {
|
|
1868
|
+
const who = harnessName ? ` from ${harnessName}` : "";
|
|
1869
|
+
lines.push(
|
|
1870
|
+
`This undoes the ${scope.length} most recent saves${who} (#${scope[scope.length - 1].seq} through #${scope[0].seq}).`
|
|
1871
|
+
);
|
|
1872
|
+
}
|
|
1873
|
+
lines.push(effect);
|
|
1874
|
+
lines.push(
|
|
1875
|
+
scope.length === 1 ? "The undone save stays visible in your timeline." : "The undone saves stay visible in your timeline."
|
|
1876
|
+
);
|
|
1877
|
+
if (scope.length === 1 && runLen >= 2) {
|
|
1878
|
+
lines.push("");
|
|
1879
|
+
lines.push(
|
|
1880
|
+
`The ${runLen} most recent saves are all ${harnessName ? `from ${harnessName}` : "part of one run"}.`
|
|
1881
|
+
);
|
|
1882
|
+
}
|
|
1883
|
+
return lines.join("\n");
|
|
1884
|
+
}
|
|
1885
|
+
function meaningfulUnsaved(folder) {
|
|
1886
|
+
const out = git(folder, ["status", "--porcelain", "-uall"]).stdout;
|
|
1887
|
+
const paths = [];
|
|
1888
|
+
for (const line of out.split("\n")) {
|
|
1889
|
+
if (!line.trim()) continue;
|
|
1890
|
+
const path = line.slice(3).split(" -> ").pop() ?? "";
|
|
1891
|
+
const base = path.split(/[\\/]/).pop() ?? "";
|
|
1892
|
+
if (!NOISE_FILES2.has(base)) paths.push(path);
|
|
1893
|
+
}
|
|
1894
|
+
return paths;
|
|
1895
|
+
}
|
|
1896
|
+
function ensureObjects(folder, cfg, sha) {
|
|
1897
|
+
if (gitOk(folder, ["cat-file", "-e", `${sha}^{commit}`])) return;
|
|
1898
|
+
console.log("Getting that save's contents\u2026");
|
|
1899
|
+
fetchHistory(folder, cfg);
|
|
1900
|
+
if (!gitOk(folder, ["cat-file", "-e", `${sha}^{commit}`])) {
|
|
1901
|
+
throw new CliError(
|
|
1902
|
+
"\u2717 Could not download that save's contents. Check your connection.",
|
|
1903
|
+
1
|
|
1904
|
+
);
|
|
1905
|
+
}
|
|
1906
|
+
}
|
|
1907
|
+
async function confirm(question, choices, fallback) {
|
|
1908
|
+
if (!process.stdin.isTTY) return fallback;
|
|
1909
|
+
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
|
1910
|
+
try {
|
|
1911
|
+
const answer = (await rl.question(`${question} `)).trim().toLowerCase();
|
|
1912
|
+
return choices.find((c) => c === answer) ?? fallback;
|
|
1913
|
+
} finally {
|
|
1914
|
+
rl.close();
|
|
1915
|
+
}
|
|
1916
|
+
}
|
|
1917
|
+
async function cmdUndo(folder, opts = {}) {
|
|
1918
|
+
const { cfg } = await requireConnection(folder);
|
|
1919
|
+
const saves = await listSaves(cfg);
|
|
1920
|
+
if (saves.length === 0) {
|
|
1921
|
+
console.log("No saves yet \u2014 there's nothing to undo.");
|
|
1922
|
+
return;
|
|
1923
|
+
}
|
|
1924
|
+
if (saves.length === 1) {
|
|
1925
|
+
console.log("This is your only save. There's no earlier state to go back to.");
|
|
1926
|
+
console.log("You can still look through it with: goodfolder log");
|
|
1927
|
+
return;
|
|
1928
|
+
}
|
|
1929
|
+
const last = saves[0];
|
|
1930
|
+
if (!last.commit_sha) {
|
|
1931
|
+
throw new CliError("\u2717 Could not read your timeline. Try again in a moment.", 1);
|
|
1932
|
+
}
|
|
1933
|
+
const harnessName = friendlyHarness(last.harness ?? null);
|
|
1934
|
+
const runLen = detectAgentRun(saves);
|
|
1935
|
+
const wantRun = opts.session === true;
|
|
1936
|
+
if (wantRun && runLen < 2) {
|
|
1937
|
+
console.log("The last save isn't part of a same-agent run \u2014 undoing just that one.");
|
|
1938
|
+
}
|
|
1939
|
+
const effectiveLen = wantRun && runLen >= 2 ? runLen : 1;
|
|
1940
|
+
const scope = saves.slice(0, effectiveLen);
|
|
1941
|
+
const target = saves[effectiveLen];
|
|
1942
|
+
const effect = summarizeReverseEffect(
|
|
1943
|
+
git(folder, ["diff", "--name-status", target.commit_sha, last.commit_sha]).stdout
|
|
1944
|
+
);
|
|
1945
|
+
const preview = buildPreview({ scope, harnessName, effect, runLen });
|
|
1946
|
+
console.log(preview);
|
|
1947
|
+
if (opts.previewOnly) {
|
|
1948
|
+
console.log("");
|
|
1949
|
+
console.log("Run this again to confirm the undo.");
|
|
1950
|
+
return;
|
|
1951
|
+
}
|
|
1952
|
+
let proceedScope = scope;
|
|
1953
|
+
let proceedTarget = target;
|
|
1954
|
+
if (!opts.yes && !wantRun) {
|
|
1955
|
+
if (!process.stdin.isTTY) {
|
|
1956
|
+
console.log("");
|
|
1957
|
+
console.log(
|
|
1958
|
+
runLen >= 2 ? "Re-run with --yes to undo this save, or --session to undo the whole run." : "Re-run with --yes to undo this save."
|
|
1959
|
+
);
|
|
1960
|
+
return;
|
|
1961
|
+
}
|
|
1962
|
+
if (runLen >= 2) {
|
|
1963
|
+
const pick = await confirm(
|
|
1964
|
+
`Undo one save (o), the whole run of ${runLen} (r), or cancel (c)?`,
|
|
1965
|
+
["o", "r", "c"],
|
|
1966
|
+
"c"
|
|
1967
|
+
);
|
|
1968
|
+
if (pick === "c") {
|
|
1969
|
+
console.log("Nothing changed.");
|
|
1970
|
+
return;
|
|
1971
|
+
}
|
|
1972
|
+
if (pick === "r") {
|
|
1973
|
+
proceedScope = saves.slice(0, runLen);
|
|
1974
|
+
proceedTarget = saves[runLen];
|
|
1975
|
+
console.log(`Undoing ${runLen} saves (#${proceedScope[proceedScope.length - 1].seq}\u2013#${proceedScope[0].seq}).`);
|
|
1976
|
+
}
|
|
1977
|
+
} else {
|
|
1978
|
+
const pick = await confirm("Undo this save? (y/N)", ["y", "n"], "n");
|
|
1979
|
+
if (pick !== "y") {
|
|
1980
|
+
console.log("Nothing changed.");
|
|
1981
|
+
return;
|
|
1982
|
+
}
|
|
1983
|
+
}
|
|
1984
|
+
}
|
|
1985
|
+
const unsaved = meaningfulUnsaved(folder);
|
|
1986
|
+
if (unsaved.length > 0) {
|
|
1987
|
+
throw new CliError(
|
|
1988
|
+
`\u2717 You have unsaved changes (${unsaved.slice(0, 3).join(", ")}${unsaved.length > 3 ? `, +${unsaved.length - 3} more` : ""}).
|
|
1989
|
+
Save them first: goodfolder save
|
|
1990
|
+
Then run: goodfolder undo`,
|
|
1991
|
+
1
|
|
1992
|
+
);
|
|
1993
|
+
}
|
|
1994
|
+
ensureObjects(folder, cfg, proceedTarget.commit_sha);
|
|
1995
|
+
if (!gitOk(folder, ["restore", "--source", proceedTarget.commit_sha, "--worktree", "--staged", "."])) {
|
|
1996
|
+
throw new CliError("\u2717 Could not undo right now \u2014 your folder is untouched.", 1);
|
|
1997
|
+
}
|
|
1998
|
+
const targetFiles = new Set(
|
|
1999
|
+
git(folder, ["ls-tree", "-r", "--name-only", proceedTarget.commit_sha]).stdout.split("\n").filter(Boolean)
|
|
2000
|
+
);
|
|
2001
|
+
for (const f of git(folder, ["ls-files"]).stdout.split("\n").filter(Boolean)) {
|
|
2002
|
+
if (!targetFiles.has(f)) git(folder, ["rm", "-q", "--cached", f]);
|
|
2003
|
+
}
|
|
2004
|
+
const label = buildUndoLabel(proceedScope, harnessName);
|
|
2005
|
+
if (!gitOk(folder, ["commit", "-m", label])) {
|
|
2006
|
+
console.log("Your folder already matches that state \u2014 nothing to undo.");
|
|
2007
|
+
return;
|
|
2008
|
+
}
|
|
2009
|
+
const sha = git(folder, ["rev-parse", "HEAD"]).stdout.trim();
|
|
2010
|
+
const push = pushCurrentHistory(folder, cfg);
|
|
2011
|
+
if (push.code !== 0) {
|
|
2012
|
+
if (/non-fast-forward|rejected/i.test(push.stderr)) {
|
|
2013
|
+
throw new CliError(
|
|
2014
|
+
"\u2717 Another device saved first. Run: goodfolder sync\n then undo again.",
|
|
2015
|
+
1
|
|
2016
|
+
);
|
|
2017
|
+
}
|
|
2018
|
+
throw new CliError("\u2717 Undone here, but the upload failed. Run: goodfolder sync", 1);
|
|
2019
|
+
}
|
|
2020
|
+
const changed = parseNameStatus(
|
|
2021
|
+
git(folder, ["diff", "--name-status", "HEAD^", "HEAD"]).stdout
|
|
2022
|
+
);
|
|
2023
|
+
try {
|
|
2024
|
+
await recordSave(cfg, {
|
|
2025
|
+
label,
|
|
2026
|
+
changedPaths: changed.paths,
|
|
2027
|
+
commitSha: sha,
|
|
2028
|
+
counts: changed.counts,
|
|
2029
|
+
topPaths: changed.paths.slice(0, 8),
|
|
2030
|
+
harness: opts.harness ?? null
|
|
2031
|
+
});
|
|
2032
|
+
} catch {
|
|
2033
|
+
}
|
|
2034
|
+
console.log(
|
|
2035
|
+
proceedScope.length === 1 ? `\u2713 Undone. Your folder is back to how it was before save #${proceedScope[0].seq}.` : `\u2713 Undone. Your folder is back to how it was before those ${proceedScope.length} saves.`
|
|
2036
|
+
);
|
|
2037
|
+
console.log(` Changed your mind? Run goodfolder undo again, or: goodfolder restore ${proceedTarget.seq}`);
|
|
2038
|
+
}
|
|
2039
|
+
|
|
2040
|
+
// src/log.ts
|
|
2041
|
+
init_src();
|
|
2042
|
+
init_cli_error();
|
|
2043
|
+
init_api();
|
|
2044
|
+
function countSummary(added = 0, changed = 0, removed = 0) {
|
|
2045
|
+
const parts = [];
|
|
2046
|
+
if (added > 0) parts.push(`${added} added`);
|
|
2047
|
+
if (changed > 0) parts.push(`${changed} changed`);
|
|
2048
|
+
if (removed > 0) parts.push(`${removed} removed`);
|
|
2049
|
+
return parts.join(" \xB7 ");
|
|
2050
|
+
}
|
|
2051
|
+
async function cmdLog(folder) {
|
|
2052
|
+
const { cfg } = await requireConnection(folder);
|
|
2053
|
+
const saves = await listSaves(cfg);
|
|
2054
|
+
if (saves.length === 0) {
|
|
2055
|
+
console.log("No saves yet. Your first one is one command away: goodfolder save");
|
|
2056
|
+
return;
|
|
2057
|
+
}
|
|
2058
|
+
console.log("\nTimeline (newest first):\n");
|
|
2059
|
+
for (const s of saves) {
|
|
2060
|
+
const when = new Date(s.createdAt).toLocaleString(void 0, {
|
|
2061
|
+
month: "short",
|
|
2062
|
+
day: "numeric",
|
|
2063
|
+
hour: "2-digit",
|
|
2064
|
+
minute: "2-digit"
|
|
2065
|
+
});
|
|
2066
|
+
const tag = s.collision && s.collision !== "none" ? ` [needs attention]` : "";
|
|
2067
|
+
console.log(` #${String(s.seq).padStart(3)} ${when} ${s.label}${tag}`);
|
|
2068
|
+
const bits = [];
|
|
2069
|
+
const by = s.harness ? friendlyHarness(s.harness) : null;
|
|
2070
|
+
if (by) bits.push(`saved by ${by}`);
|
|
2071
|
+
else if (s.deviceName) bits.push(`saved on ${s.deviceName}`);
|
|
2072
|
+
const counts = countSummary(
|
|
2073
|
+
Number(s.addedCount ?? 0),
|
|
2074
|
+
Number(s.changedCount ?? 0),
|
|
2075
|
+
Number(s.removedCount ?? 0)
|
|
2076
|
+
);
|
|
2077
|
+
if (counts) bits.push(counts);
|
|
2078
|
+
const paths = Array.isArray(s.topPaths) ? s.topPaths : [];
|
|
2079
|
+
if (paths.length > 0) {
|
|
2080
|
+
const shown = paths.slice(0, 3).join(", ");
|
|
2081
|
+
bits.push(paths.length > 3 ? `${shown} +${paths.length - 3} more` : shown);
|
|
2082
|
+
}
|
|
2083
|
+
if (bits.length > 0) console.log(` ${bits.join(" \xB7 ")}`);
|
|
2084
|
+
}
|
|
2085
|
+
console.log(`
|
|
2086
|
+
${saves.length} save${saves.length === 1 ? "" : "s"}. Restore with: goodfolder restore <number>`);
|
|
2087
|
+
}
|
|
2088
|
+
|
|
2089
|
+
// src/create.ts
|
|
2090
|
+
init_config();
|
|
2091
|
+
init_cli_error();
|
|
2092
|
+
init_git();
|
|
2093
|
+
init_repo_setup();
|
|
2094
|
+
init_api();
|
|
2095
|
+
init_auth();
|
|
2096
|
+
import { existsSync as existsSync3, mkdirSync as mkdirSync3 } from "node:fs";
|
|
2097
|
+
import { homedir as homedir2 } from "node:os";
|
|
2098
|
+
import { join as join7 } from "node:path";
|
|
2099
|
+
function defaultParent(cwd) {
|
|
2100
|
+
const home = homedir2();
|
|
2101
|
+
const landing = ["Desktop", "Documents", "Downloads"].map(
|
|
2102
|
+
(d) => join7(home, d)
|
|
2103
|
+
);
|
|
2104
|
+
if (cwd === home || landing.includes(cwd)) return join7(home, "Desktop");
|
|
2105
|
+
return cwd;
|
|
2106
|
+
}
|
|
2107
|
+
function sanitizeName(name) {
|
|
2108
|
+
const cleaned = name.replace(/[/\\:]+/g, "-").trim().replace(/\s+/g, " ");
|
|
2109
|
+
return cleaned || "My Folder";
|
|
2110
|
+
}
|
|
2111
|
+
function dedupePath(base) {
|
|
2112
|
+
if (!existsSync3(base)) return base;
|
|
2113
|
+
for (let i = 2; i < 100; i++) {
|
|
2114
|
+
const candidate = `${base}-${i}`;
|
|
2115
|
+
if (!existsSync3(candidate)) return candidate;
|
|
2116
|
+
}
|
|
2117
|
+
throw new CliError(`\u2717 Too many folders named "${base}" already exist.`);
|
|
2118
|
+
}
|
|
2119
|
+
async function cmdCreate(name, opts) {
|
|
2120
|
+
const clean = sanitizeName(name);
|
|
2121
|
+
const parent = opts.dest ?? defaultParent(process.cwd());
|
|
2122
|
+
if (!existsSync3(parent)) {
|
|
2123
|
+
throw new CliError(`\u2717 Destination folder does not exist: ${parent}`);
|
|
2124
|
+
}
|
|
2125
|
+
const dir = dedupePath(join7(parent, clean));
|
|
2126
|
+
mkdirSync3(dir, { recursive: true });
|
|
2127
|
+
if (!gitOk(dir, ["init", "-b", "main"])) {
|
|
2128
|
+
throw new CliError("\u2717 Could not set up the folder internally.");
|
|
2129
|
+
}
|
|
2130
|
+
const gitDir = findGitDir(dir);
|
|
2131
|
+
const accountToken = await ensureAccount(DEFAULT_API_URL);
|
|
2132
|
+
const boot = await createProject(
|
|
2133
|
+
DEFAULT_API_URL,
|
|
2134
|
+
clean,
|
|
2135
|
+
accountToken,
|
|
2136
|
+
`${clean} \xB7 ${await friendlyDeviceName()}`
|
|
2137
|
+
);
|
|
2138
|
+
if (!boot.projectId || !boot.token) {
|
|
2139
|
+
throw new CliError(
|
|
2140
|
+
"\u2717 Could not create the project on GoodFolder. Try again shortly."
|
|
2141
|
+
);
|
|
2142
|
+
}
|
|
2143
|
+
const cfg = {
|
|
2144
|
+
projectId: boot.projectId,
|
|
2145
|
+
apiUrl: DEFAULT_API_URL,
|
|
2146
|
+
token: boot.token,
|
|
2147
|
+
connectedAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
2148
|
+
};
|
|
2149
|
+
if (boot.expiresAt) cfg.tokenExpiresAt = boot.expiresAt;
|
|
2150
|
+
bindRepo(dir, gitDir, cfg);
|
|
2151
|
+
console.log(`\u2713 Created "${clean}" at ${dir}`);
|
|
2152
|
+
console.log(" Empty and ready. Saves stay protected while this account has access and capacity.");
|
|
2153
|
+
return { path: dir, projectId: cfg.projectId };
|
|
2154
|
+
}
|
|
2155
|
+
|
|
2156
|
+
// src/clone.ts
|
|
2157
|
+
init_config();
|
|
2158
|
+
init_cli_error();
|
|
2159
|
+
init_git();
|
|
2160
|
+
init_repo_setup();
|
|
2161
|
+
import { existsSync as existsSync4 } from "node:fs";
|
|
2162
|
+
import { join as join8 } from "node:path";
|
|
2163
|
+
init_api();
|
|
2164
|
+
init_auth();
|
|
2165
|
+
var UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
2166
|
+
async function cmdClone(query, opts) {
|
|
2167
|
+
const accountToken = await ensureAccount(DEFAULT_API_URL);
|
|
2168
|
+
const projects = await listProjects(DEFAULT_API_URL, accountToken);
|
|
2169
|
+
let project;
|
|
2170
|
+
if (UUID_RE.test(query)) {
|
|
2171
|
+
project = projects.find((p) => p.id === query);
|
|
2172
|
+
} else {
|
|
2173
|
+
const matches = projects.filter(
|
|
2174
|
+
(p) => p.name.toLowerCase() === query.toLowerCase()
|
|
2175
|
+
);
|
|
2176
|
+
if (matches.length > 1) {
|
|
2177
|
+
throw new CliError(
|
|
2178
|
+
`\u2717 Several folders share that name:
|
|
2179
|
+
` + matches.map((m) => ` \u2022 ${m.name} (${m.id})`).join("\n") + `
|
|
2180
|
+
Open by id instead.`
|
|
2181
|
+
);
|
|
2182
|
+
}
|
|
2183
|
+
project = matches[0];
|
|
2184
|
+
}
|
|
2185
|
+
if (!project) {
|
|
2186
|
+
const available = projects.length === 0 ? "No GoodFolders exist yet." : `Available: ${projects.slice(0, 10).map((p) => `"${p.name}"`).join(", ")}`;
|
|
2187
|
+
throw new CliError(`\u2717 No GoodFolder called "${query}". ${available}`);
|
|
2188
|
+
}
|
|
2189
|
+
console.log(`Getting "${project.name}" ready on this computer\u2026`);
|
|
2190
|
+
const minted = await mintProjectToken(
|
|
2191
|
+
DEFAULT_API_URL,
|
|
2192
|
+
project.id,
|
|
2193
|
+
accountToken,
|
|
2194
|
+
`${project.name} \xB7 ${await friendlyDeviceName()}`
|
|
2195
|
+
);
|
|
2196
|
+
const parent = opts.dest ?? defaultParent(process.cwd());
|
|
2197
|
+
if (!existsSync4(parent)) {
|
|
2198
|
+
throw new CliError(`\u2717 Destination folder does not exist: ${parent}`);
|
|
2199
|
+
}
|
|
2200
|
+
const dir = dedupePath(join8(parent, sanitizeName(project.name)));
|
|
2201
|
+
console.log(`Downloading "${project.name}"\u2026`);
|
|
2202
|
+
const cfg = {
|
|
2203
|
+
projectId: project.id,
|
|
2204
|
+
apiUrl: DEFAULT_API_URL,
|
|
2205
|
+
token: minted.token,
|
|
2206
|
+
connectedAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
2207
|
+
};
|
|
2208
|
+
if (minted.expiresAt) cfg.tokenExpiresAt = minted.expiresAt;
|
|
2209
|
+
const clone = git(parent, ["clone", "--origin", GF_REMOTE, transportUrl(cfg), dir], void 0, transportEnv(cfg));
|
|
2210
|
+
if (clone.code !== 0 && !/empty repository/i.test(clone.stderr)) {
|
|
2211
|
+
throw new CliError(`\u2717 Download failed: ${clone.stderr.trim()}`);
|
|
2212
|
+
}
|
|
2213
|
+
const gitDir = findGitDir(dir);
|
|
2214
|
+
bindRepo(dir, gitDir, cfg);
|
|
2215
|
+
console.log(
|
|
2216
|
+
/empty repository/i.test(clone.stderr) ? `\u2713 Connected to the empty "${project.name}" at ${dir}` : `\u2713 "${project.name}" is ready at ${dir} \u2014 fully up to date.`
|
|
2217
|
+
);
|
|
2218
|
+
return { path: dir, projectId: project.id };
|
|
2219
|
+
}
|
|
2220
|
+
|
|
2221
|
+
// src/index.ts
|
|
2222
|
+
init_auth();
|
|
2223
|
+
|
|
2224
|
+
// src/protect.ts
|
|
2225
|
+
init_config();
|
|
2226
|
+
init_cli_error();
|
|
2227
|
+
import { existsSync as existsSync5 } from "node:fs";
|
|
2228
|
+
import { join as join9 } from "node:path";
|
|
2229
|
+
init_skip();
|
|
2230
|
+
async function cmdSkipped(folder) {
|
|
2231
|
+
const { cfg } = await requireConnection(folder);
|
|
2232
|
+
const alsoProtect = cfg.alsoProtect ?? [];
|
|
2233
|
+
const groups = skippedGroups(folder, alsoProtect);
|
|
2234
|
+
if (groups.length === 0 && alsoProtect.length === 0) {
|
|
2235
|
+
console.log("Everything in this folder is protected.");
|
|
2236
|
+
return;
|
|
2237
|
+
}
|
|
2238
|
+
if (groups.length > 0) {
|
|
2239
|
+
console.log("Not protected, and why:\n");
|
|
2240
|
+
for (const group of groups) {
|
|
2241
|
+
console.log(` ${group.label}`);
|
|
2242
|
+
for (const path of group.paths.slice(0, 12)) {
|
|
2243
|
+
console.log(` \u2022 ${path}`);
|
|
2244
|
+
}
|
|
2245
|
+
const rest = group.paths.length - 12;
|
|
2246
|
+
if (rest > 0) console.log(` \u2026and ${rest.toLocaleString("en-US")} more`);
|
|
2247
|
+
console.log("");
|
|
2248
|
+
}
|
|
2249
|
+
console.log("To protect one of them anyway:");
|
|
2250
|
+
console.log(" goodfolder protect <name>");
|
|
2251
|
+
}
|
|
2252
|
+
if (alsoProtect.length > 0) {
|
|
2253
|
+
console.log("\nProtected because you asked for it:");
|
|
2254
|
+
for (const path of alsoProtect) console.log(` \u2022 ${path}`);
|
|
2255
|
+
}
|
|
2256
|
+
}
|
|
2257
|
+
async function cmdProtect(folder, paths) {
|
|
2258
|
+
const { gitDir, cfg } = await requireConnection(folder);
|
|
2259
|
+
if (paths.length === 0) {
|
|
2260
|
+
throw new CliError(
|
|
2261
|
+
"Which one? Run goodfolder skipped to see what is being left out.",
|
|
2262
|
+
1
|
|
2263
|
+
);
|
|
2264
|
+
}
|
|
2265
|
+
const already = new Set(cfg.alsoProtect ?? []);
|
|
2266
|
+
const added = [];
|
|
2267
|
+
for (const raw of paths) {
|
|
2268
|
+
const path = raw.replace(/^\.\//, "").replace(/\/+$/, "");
|
|
2269
|
+
if (!existsSync5(join9(folder, path))) {
|
|
2270
|
+
throw new CliError(`\u2717 There is nothing called "${path}" in this folder.`, 1);
|
|
2271
|
+
}
|
|
2272
|
+
if (already.has(path)) {
|
|
2273
|
+
console.log(`Already protected: ${path}`);
|
|
2274
|
+
continue;
|
|
2275
|
+
}
|
|
2276
|
+
already.add(path);
|
|
2277
|
+
added.push(path);
|
|
2278
|
+
}
|
|
2279
|
+
if (added.length === 0) return;
|
|
2280
|
+
cfg.alsoProtect = [...already].sort();
|
|
2281
|
+
saveConfig(gitDir, cfg);
|
|
2282
|
+
for (const path of added) console.log(`\u2713 ${path} will be protected from now on.`);
|
|
2283
|
+
const secretish = added.filter((p) => /(^|\/)\.env($|\.)|\.pem$|(^|\/)id_(rsa|dsa|ecdsa|ed25519)$/.test(p));
|
|
2284
|
+
if (secretish.length > 0) {
|
|
2285
|
+
console.log(
|
|
2286
|
+
"\n Note: this file looks like it holds passwords or keys. It will be"
|
|
2287
|
+
);
|
|
2288
|
+
console.log(" uploaded and kept in this folder's history from the next save on.");
|
|
2289
|
+
}
|
|
2290
|
+
console.log("\nRun goodfolder save to include it.");
|
|
2291
|
+
}
|
|
2292
|
+
|
|
2293
|
+
// src/rename.ts
|
|
2294
|
+
init_auth();
|
|
2295
|
+
init_api();
|
|
2296
|
+
init_cli_error();
|
|
2297
|
+
async function cmdRename(folder, name) {
|
|
2298
|
+
if (name.length === 0 || name.trim().length === 0) {
|
|
2299
|
+
throw new CliError("\u2717 Enter a name for this folder.");
|
|
2300
|
+
}
|
|
2301
|
+
const { cfg } = await requireConnection(folder);
|
|
2302
|
+
const accountToken = await ensureAccount(cfg.apiUrl);
|
|
2303
|
+
const result = await accountCall(
|
|
2304
|
+
cfg.apiUrl,
|
|
2305
|
+
accountToken,
|
|
2306
|
+
"PATCH",
|
|
2307
|
+
`/api/projects/${cfg.projectId}`,
|
|
2308
|
+
{ name }
|
|
2309
|
+
);
|
|
2310
|
+
console.log(`\u2713 Renamed this GoodFolder to "${result.name}".`);
|
|
2311
|
+
}
|
|
2312
|
+
|
|
2313
|
+
// src/index.ts
|
|
2314
|
+
var HELP = `goodfolder \u2014 keep your folder safe
|
|
2315
|
+
|
|
2316
|
+
goodfolder create <name> Start a brand-new GoodFolder on this machine
|
|
2317
|
+
goodfolder clone <name> Download an existing GoodFolder to here
|
|
2318
|
+
goodfolder connect [folder] Connect an existing folder (first time)
|
|
2319
|
+
goodfolder rename <name> Change the folder name shown in GoodFolder
|
|
2320
|
+
goodfolder save [-m note] Save a point you can come back to
|
|
2321
|
+
goodfolder sync Bring in changes from your other devices
|
|
2322
|
+
goodfolder log Show the timeline
|
|
2323
|
+
goodfolder undo Undo the last save (shows what changes first)
|
|
2324
|
+
goodfolder restore <number> Go back to an earlier save
|
|
2325
|
+
goodfolder skipped Show what isn't being protected, and why
|
|
2326
|
+
goodfolder protect <name> Protect something that is being left out
|
|
2327
|
+
goodfolder login Approve this computer (one-time setup)
|
|
2328
|
+
goodfolder devices Show the computers approved on your account
|
|
2329
|
+
goodfolder devices forget <n> Take an approval back
|
|
2330
|
+
|
|
2331
|
+
Set GF_API_URL to use a GoodFolder server you run yourself. Folders remember
|
|
2332
|
+
the server they were set up against, so this only affects new ones.
|
|
2333
|
+
`;
|
|
2334
|
+
async function main() {
|
|
2335
|
+
const argv = process.argv.slice(2);
|
|
2336
|
+
const cmd = argv[0];
|
|
2337
|
+
const flags = {};
|
|
2338
|
+
const bools = /* @__PURE__ */ new Set();
|
|
2339
|
+
const positional = [];
|
|
2340
|
+
for (let i = 1; i < argv.length; i++) {
|
|
2341
|
+
if (argv[i] === "-m" || argv[i] === "--message") flags.message = argv[++i] ?? "";
|
|
2342
|
+
else if (argv[i] === "--dest") flags.dest = argv[++i] ?? "";
|
|
2343
|
+
else if (argv[i] === "-y" || argv[i] === "--yes") bools.add("yes");
|
|
2344
|
+
else if (argv[i] === "--session") bools.add("session");
|
|
2345
|
+
else positional.push(argv[i]);
|
|
2346
|
+
}
|
|
2347
|
+
const folder = process.cwd();
|
|
2348
|
+
switch (cmd) {
|
|
2349
|
+
case "create":
|
|
2350
|
+
if (!positional[0]) {
|
|
2351
|
+
console.error('What should it be called? e.g.: goodfolder create "Trip Planning"');
|
|
2352
|
+
process.exit(1);
|
|
2353
|
+
}
|
|
2354
|
+
await cmdCreate(positional[0], { dest: flags.dest });
|
|
2355
|
+
break;
|
|
2356
|
+
case "clone":
|
|
2357
|
+
if (!positional[0]) {
|
|
2358
|
+
console.error('Which GoodFolder? e.g.: goodfolder clone "Recipe Book"');
|
|
2359
|
+
process.exit(1);
|
|
2360
|
+
}
|
|
2361
|
+
await cmdClone(positional[0], { dest: flags.dest });
|
|
2362
|
+
break;
|
|
2363
|
+
case "connect":
|
|
2364
|
+
await cmdConnect(resolve2(positional[0] ?? folder));
|
|
2365
|
+
break;
|
|
2366
|
+
case "skipped":
|
|
2367
|
+
await cmdSkipped(folder);
|
|
2368
|
+
break;
|
|
2369
|
+
case "protect":
|
|
2370
|
+
await cmdProtect(folder, positional);
|
|
2371
|
+
break;
|
|
2372
|
+
case "login":
|
|
2373
|
+
await cmdLogin();
|
|
2374
|
+
break;
|
|
2375
|
+
case "devices":
|
|
2376
|
+
await cmdDevices(positional[0], positional[1]);
|
|
2377
|
+
break;
|
|
2378
|
+
case "save":
|
|
2379
|
+
await cmdSave(folder, (await requireConnection(folder)).cfg, flags);
|
|
2380
|
+
break;
|
|
2381
|
+
case "sync":
|
|
2382
|
+
await cmdSync(folder);
|
|
2383
|
+
break;
|
|
2384
|
+
case "rename":
|
|
2385
|
+
if (!positional[0]) {
|
|
2386
|
+
console.error('What should this folder be called? e.g.: goodfolder rename "Trip Planning"');
|
|
2387
|
+
process.exit(1);
|
|
2388
|
+
}
|
|
2389
|
+
await cmdRename(folder, positional[0]);
|
|
2390
|
+
break;
|
|
2391
|
+
case "log":
|
|
2392
|
+
await cmdLog(folder);
|
|
2393
|
+
break;
|
|
2394
|
+
case "restore":
|
|
2395
|
+
if (!positional[0]) {
|
|
2396
|
+
console.error("Which save? Pick a number from: goodfolder log");
|
|
2397
|
+
process.exit(1);
|
|
2398
|
+
}
|
|
2399
|
+
await cmdRestore(folder, positional[0]);
|
|
2400
|
+
break;
|
|
2401
|
+
case "undo":
|
|
2402
|
+
await cmdUndo(folder, { yes: bools.has("yes"), session: bools.has("session") });
|
|
2403
|
+
break;
|
|
2404
|
+
default:
|
|
2405
|
+
console.log(HELP);
|
|
2406
|
+
if (cmd !== void 0 && cmd !== "help" && cmd !== "--help") {
|
|
2407
|
+
console.error(`Unknown command: ${cmd}`);
|
|
2408
|
+
process.exit(1);
|
|
2409
|
+
}
|
|
2410
|
+
}
|
|
2411
|
+
}
|
|
2412
|
+
main().catch((e) => {
|
|
2413
|
+
const err = e;
|
|
2414
|
+
console.error(err.message ?? "Something went wrong.");
|
|
2415
|
+
process.exit(err.exitCode ?? 1);
|
|
2416
|
+
});
|