midsummer-sol 0.1.2 → 0.1.3

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.
Files changed (3) hide show
  1. package/index.js +177 -0
  2. package/package.json +1 -1
  3. package/sol.js +617 -119
package/sol.js CHANGED
@@ -3,9 +3,9 @@
3
3
 
4
4
  // src/bin/sol.ts
5
5
  import { execFileSync as execFileSync2 } from "child_process";
6
- import { existsSync as existsSync9, mkdirSync as mkdirSync7, mkdtempSync, readdirSync as readdirSync7, readFileSync as readFileSync9, rmSync, unlinkSync as unlinkSync6, watch, writeFileSync as writeFileSync9 } from "fs";
6
+ import { existsSync as existsSync10, mkdirSync as mkdirSync7, mkdtempSync, readdirSync as readdirSync7, readFileSync as readFileSync9, rmSync, unlinkSync as unlinkSync6, watch, writeFileSync as writeFileSync9 } from "fs";
7
7
  import { homedir, hostname, platform as platform2, tmpdir } from "os";
8
- import { basename, dirname as dirname5, join as join9, resolve as resolve3, sep as sep3 } from "path";
8
+ import { basename, dirname as dirname5, join as join10, resolve as resolve4, sep as sep3 } from "path";
9
9
 
10
10
  // src/attest.ts
11
11
  function canonicalOp(op) {
@@ -615,6 +615,49 @@ class FileStore {
615
615
  }
616
616
  }
617
617
 
618
+ class FileOpLog {
619
+ opsFile;
620
+ headFile;
621
+ constructor(solDir) {
622
+ this.opsFile = join(solDir, "ops.jsonl");
623
+ this.headFile = join(solDir, "HEAD");
624
+ }
625
+ readHead() {
626
+ return existsSync(this.headFile) ? JSON.parse(readFileSync(this.headFile, "utf8")) : { seq: 0 };
627
+ }
628
+ async head() {
629
+ return this.readHead().head;
630
+ }
631
+ async seq() {
632
+ return this.readHead().seq ?? 0;
633
+ }
634
+ async logTip() {
635
+ return this.readHead().logTip;
636
+ }
637
+ async append(entry) {
638
+ const chained = chainOp(this.readHead().logTip, entry);
639
+ appendFileSync(this.opsFile, JSON.stringify(chained) + `
640
+ `);
641
+ writeFileSync(this.headFile, JSON.stringify({ head: chained.rootAfter, seq: chained.seq, logTip: chained.entryHash }));
642
+ }
643
+ async history(opts) {
644
+ if (!existsSync(this.opsFile))
645
+ return [];
646
+ const all = [];
647
+ for (const l of readFileSync(this.opsFile, "utf8").split(`
648
+ `)) {
649
+ if (!l)
650
+ continue;
651
+ try {
652
+ all.push(JSON.parse(l));
653
+ } catch {
654
+ break;
655
+ }
656
+ }
657
+ return pageOps(all, opts);
658
+ }
659
+ }
660
+
618
661
  // src/crypto.ts
619
662
  import { createCipheriv, createDecipheriv, createPublicKey, diffieHellman, generateKeyPairSync, hkdfSync, randomBytes, timingSafeEqual } from "node:crypto";
620
663
  var UNREADABLE = "<<unreadable>>";
@@ -785,7 +828,7 @@ class FileStore2 {
785
828
  }
786
829
  }
787
830
 
788
- class FileOpLog {
831
+ class FileOpLog2 {
789
832
  opsFile;
790
833
  headFile;
791
834
  constructor(solDir) {
@@ -1003,7 +1046,7 @@ function setHead(fdir, head) {
1003
1046
  }
1004
1047
  async function importGitRepo(gitPath, fdir) {
1005
1048
  const store = new FileStore2(fdir);
1006
- const log = new FileOpLog(fdir);
1049
+ const log = new FileOpLog2(fdir);
1007
1050
  const empty = await emptyRoot2(store);
1008
1051
  const commitList = git(gitPath, "rev-list", "--reverse", "--topo-order", "--all").toString().trim().split(`
1009
1052
  `).filter(Boolean);
@@ -1394,7 +1437,7 @@ function die(msg) {
1394
1437
  function open() {
1395
1438
  if (!existsSync6(solDir2))
1396
1439
  die("not a sol repo — run `sol init` first");
1397
- return { repo: new AsyncRepo(new FileStore2(solDir2), new FileOpLog(solDir2), actor2), log: new FileOpLog(solDir2) };
1440
+ return { repo: new AsyncRepo(new FileStore2(solDir2), new FileOpLog2(solDir2), actor2), log: new FileOpLog2(solDir2) };
1398
1441
  }
1399
1442
  var DEFAULT_IGNORE2 = [".sol", ".git", "node_modules", "dist", "build", ".next", ".cache", ".DS_Store", "__pycache__", "*.pyc", ".venv", "venv", "target", ".gradle", "*.log"];
1400
1443
  function ignorePatterns2() {
@@ -1890,6 +1933,27 @@ function printWorkingDiff(store, base, only) {
1890
1933
  if (!added.length && !removed.length && !modified.length)
1891
1934
  console.log(only ? `no working changes in ${only}` : "no working changes");
1892
1935
  }
1936
+ function unresolvedConflictPaths() {
1937
+ const out = [];
1938
+ for (const f of walkFiles()) {
1939
+ const abs = join6(cwd2, f);
1940
+ let buf;
1941
+ try {
1942
+ const st = lstatSync3(abs);
1943
+ if (st.isSymbolicLink() || st.isDirectory())
1944
+ continue;
1945
+ buf = readFileSync6(abs);
1946
+ } catch {
1947
+ continue;
1948
+ }
1949
+ if (buf.includes(0))
1950
+ continue;
1951
+ const text = buf.toString("utf8");
1952
+ if (/^<{7}( |$)/m.test(text) && /^>{7}( |$)/m.test(text))
1953
+ out.push(f);
1954
+ }
1955
+ return out;
1956
+ }
1893
1957
  function isWorkingPath(store, head, arg) {
1894
1958
  const rel = repoRel(arg);
1895
1959
  if (lexists(join6(cwd2, rel)))
@@ -2054,10 +2118,275 @@ async function writeBundle(solDir3, bundle, from = 0) {
2054
2118
  return fresh.length;
2055
2119
  }
2056
2120
 
2121
+ // src/bin/local-peer.ts
2122
+ import { existsSync as existsSync8 } from "node:fs";
2123
+ import { join as join8, resolve as resolve3 } from "node:path";
2124
+ function localPeerSolDir(arg, base) {
2125
+ if (/^https?:\/\//.test(arg))
2126
+ return;
2127
+ const p = resolve3(base, arg);
2128
+ if (existsSync8(join8(p, ".sol")))
2129
+ return join8(p, ".sol");
2130
+ if (existsSync8(join8(p, "objects")) && existsSync8(join8(p, "HEAD")))
2131
+ return p;
2132
+ return;
2133
+ }
2134
+ function openPeer(peerSolDir) {
2135
+ return { store: new FileStore2(peerSolDir), log: new FileOpLog2(peerSolDir) };
2136
+ }
2137
+ async function peerNodes(rep, head) {
2138
+ if (!head)
2139
+ return [];
2140
+ const out = [];
2141
+ const seen = new Set;
2142
+ const stack = [head];
2143
+ while (stack.length) {
2144
+ const h = stack.pop();
2145
+ if (seen.has(h))
2146
+ continue;
2147
+ seen.add(h);
2148
+ const node = await rep.store.get(h);
2149
+ if (!node)
2150
+ continue;
2151
+ out.push(node);
2152
+ if (node.kind === "tree")
2153
+ for (const e of Object.values(node.entries))
2154
+ stack.push(e.hash);
2155
+ }
2156
+ return out;
2157
+ }
2158
+
2159
+ // src/merge.ts
2160
+ function resolvePath2(base, ours, theirs) {
2161
+ const oursChanged = ours !== base;
2162
+ const theirsChanged = theirs !== base;
2163
+ if (!oursChanged && !theirsChanged) {
2164
+ return { content: base };
2165
+ }
2166
+ if (oursChanged && !theirsChanged) {
2167
+ return { content: ours };
2168
+ }
2169
+ if (!oursChanged && theirsChanged) {
2170
+ return { content: theirs };
2171
+ }
2172
+ if (ours === theirs) {
2173
+ return { content: ours };
2174
+ }
2175
+ if (base !== undefined && ours !== undefined && theirs !== undefined) {
2176
+ const m = merge3(base, ours, theirs);
2177
+ if (m.clean)
2178
+ return { content: m.text };
2179
+ return { content: m.text, conflict: { path: "", ours, theirs } };
2180
+ }
2181
+ return {
2182
+ content: ours,
2183
+ conflict: { path: "", ours, theirs }
2184
+ };
2185
+ }
2186
+ function merge2(repo, baseHead, oursHead, theirsHead) {
2187
+ const store = repo.store;
2188
+ const paths = new Set([
2189
+ ...listAll(store, baseHead),
2190
+ ...listAll(store, oursHead),
2191
+ ...listAll(store, theirsHead)
2192
+ ]);
2193
+ const conflicts = [];
2194
+ let root = emptyRoot(store);
2195
+ for (const path of [...paths].sort()) {
2196
+ const base = readFile(store, baseHead, path);
2197
+ const ours = readFile(store, oursHead, path);
2198
+ const theirs = readFile(store, theirsHead, path);
2199
+ const { content, conflict } = resolvePath2(base, ours, theirs);
2200
+ if (conflict)
2201
+ conflicts.push({ ...conflict, path });
2202
+ if (content !== undefined) {
2203
+ root = writeFile(store, root, path, content);
2204
+ }
2205
+ }
2206
+ return { head: root, conflicts };
2207
+ }
2208
+
2209
+ // src/converge.ts
2210
+ var EMPTY_TREE3 = { kind: "tree", entries: {} };
2211
+ function buildAncestry(ops) {
2212
+ const parents = new Map;
2213
+ const add = (child, parent) => {
2214
+ if (!child || !parent || parent === child)
2215
+ return;
2216
+ let s = parents.get(child);
2217
+ if (!s)
2218
+ parents.set(child, s = new Set);
2219
+ s.add(parent);
2220
+ };
2221
+ let prevRoot;
2222
+ for (const op of ops) {
2223
+ if (op.type === "checkpoint") {
2224
+ add(op.rootAfter, op.parent);
2225
+ add(op.rootAfter, op.parent2);
2226
+ } else {
2227
+ add(op.rootAfter, op.parent ?? prevRoot);
2228
+ }
2229
+ prevRoot = op.rootAfter;
2230
+ }
2231
+ return parents;
2232
+ }
2233
+ function ancestorsOf(head, parents) {
2234
+ const seen = new Set;
2235
+ const stack = [head];
2236
+ while (stack.length) {
2237
+ const h = stack.pop();
2238
+ if (seen.has(h))
2239
+ continue;
2240
+ seen.add(h);
2241
+ for (const p of parents.get(h) ?? [])
2242
+ stack.push(p);
2243
+ }
2244
+ return seen;
2245
+ }
2246
+ function mergeBase(a, b, parents) {
2247
+ if (a === b)
2248
+ return a;
2249
+ const bAnc = ancestorsOf(b, parents);
2250
+ const seen = new Set;
2251
+ let frontier = [a];
2252
+ while (frontier.length) {
2253
+ const next = [];
2254
+ for (const h of frontier) {
2255
+ if (seen.has(h))
2256
+ continue;
2257
+ seen.add(h);
2258
+ if (bAnc.has(h))
2259
+ return h;
2260
+ for (const p of parents.get(h) ?? [])
2261
+ next.push(p);
2262
+ }
2263
+ frontier = next;
2264
+ }
2265
+ return;
2266
+ }
2267
+ async function hydrate2(src, dst, root) {
2268
+ if (!root)
2269
+ return dst.put(EMPTY_TREE3);
2270
+ const stack = [root];
2271
+ const seen = new Set;
2272
+ while (stack.length) {
2273
+ const h = stack.pop();
2274
+ if (seen.has(h))
2275
+ continue;
2276
+ seen.add(h);
2277
+ const node = await src.get(h);
2278
+ if (!node)
2279
+ continue;
2280
+ dst.put(node);
2281
+ if (node.kind === "tree")
2282
+ for (const e of Object.values(node.entries))
2283
+ stack.push(e.hash);
2284
+ }
2285
+ return root;
2286
+ }
2287
+ async function persist(sync, dst, root) {
2288
+ const stack = [root];
2289
+ const seen = new Set;
2290
+ while (stack.length) {
2291
+ const h = stack.pop();
2292
+ if (seen.has(h))
2293
+ continue;
2294
+ seen.add(h);
2295
+ const node = sync.get(h);
2296
+ if (!node)
2297
+ continue;
2298
+ if (!await dst.has(h))
2299
+ await dst.put(node);
2300
+ if (node.kind === "tree")
2301
+ for (const e of Object.values(node.entries))
2302
+ stack.push(e.hash);
2303
+ }
2304
+ }
2305
+ async function converge(local, input) {
2306
+ const at = input.at ?? Date.now();
2307
+ const actor3 = input.actor ?? "system";
2308
+ for (const node of input.nodes)
2309
+ await local.store.put(node);
2310
+ const priorHead = await local.log.head();
2311
+ const incomingHead = input.incomingHead ?? input.ops[input.ops.length - 1]?.rootAfter;
2312
+ const sinceSeq = await local.log.seq();
2313
+ const existing = await local.log.history();
2314
+ const priorAncestry = buildAncestry(existing);
2315
+ const knownByEntry = new Map(existing.filter((o) => o.entryHash).map((o) => [o.entryHash, o]));
2316
+ const knownByTuple = new Map(existing.map((o) => [`${o.type}\x00${o.path}\x00${o.rootAfter}`, o]));
2317
+ const incoming = [...input.ops].sort((a, b) => a.seq - b.seq);
2318
+ let forkBase = input.base ?? priorHead;
2319
+ if (input.base === undefined) {
2320
+ for (const op of incoming) {
2321
+ const k = op.entryHash ? knownByEntry.get(op.entryHash) : knownByTuple.get(`${op.type}\x00${op.path}\x00${op.rootAfter}`);
2322
+ if (k)
2323
+ forkBase = k.rootAfter;
2324
+ }
2325
+ }
2326
+ let applied = 0;
2327
+ for (const op of incoming) {
2328
+ if (op.entryHash && knownByEntry.has(op.entryHash))
2329
+ continue;
2330
+ if (!op.entryHash && knownByTuple.has(`${op.type}\x00${op.path}\x00${op.rootAfter}`))
2331
+ continue;
2332
+ const stamped = applied === 0 && op.type !== "checkpoint" && op.parent === undefined ? { ...op, parent: forkBase } : { ...op };
2333
+ await local.log.append({ ...stamped, seq: await local.log.seq() + 1 });
2334
+ applied++;
2335
+ }
2336
+ let head = await local.log.head() ?? priorHead ?? await local.store.put(EMPTY_TREE3);
2337
+ let merged = false;
2338
+ let conflicts = [];
2339
+ if (priorHead && incomingHead && priorHead !== incomingHead) {
2340
+ const parents = buildAncestry(await local.log.history());
2341
+ for (const [c, ps] of priorAncestry) {
2342
+ let s = parents.get(c);
2343
+ if (!s)
2344
+ parents.set(c, s = new Set);
2345
+ for (const p of ps)
2346
+ s.add(p);
2347
+ }
2348
+ const priorIsAncestor = ancestorsOf(incomingHead, parents).has(priorHead);
2349
+ const incomingIsAncestor = ancestorsOf(priorHead, parents).has(incomingHead);
2350
+ if (incomingIsAncestor) {
2351
+ if (await local.log.head() !== priorHead)
2352
+ await appendHeadMove(local, priorHead, priorHead, incomingHead, actor3, at, "converge: keep ahead-of-incoming head");
2353
+ head = priorHead;
2354
+ } else if (!priorIsAncestor) {
2355
+ const base = input.base ?? mergeBase(priorHead, incomingHead, parents);
2356
+ const sync = new Store;
2357
+ const baseRoot = await hydrate2(local.store, sync, base);
2358
+ const oursRoot = await hydrate2(local.store, sync, priorHead);
2359
+ const theirsRoot = await hydrate2(local.store, sync, incomingHead);
2360
+ const result = merge2({ store: sync }, baseRoot, oursRoot, theirsRoot);
2361
+ conflicts = result.conflicts;
2362
+ await persist(sync, local.store, result.head);
2363
+ await local.log.append({
2364
+ seq: await local.log.seq() + 1,
2365
+ type: "checkpoint",
2366
+ path: "",
2367
+ rootAfter: result.head,
2368
+ at,
2369
+ by: actor3,
2370
+ message: conflicts.length ? `converge merge (${conflicts.length} conflict(s) kept with markers)` : "converge merge",
2371
+ parent: priorHead,
2372
+ parent2: incomingHead
2373
+ });
2374
+ head = result.head;
2375
+ merged = true;
2376
+ }
2377
+ }
2378
+ const finalOps = await local.log.history();
2379
+ const missingOps = finalOps.filter((o) => o.seq > sinceSeq);
2380
+ return { head, applied, merged, conflicts, missingOps };
2381
+ }
2382
+ async function appendHeadMove(local, head, parent, parent2, by, at, message) {
2383
+ await local.log.append({ seq: await local.log.seq() + 1, type: "checkpoint", path: "", rootAfter: head, at, by, message, parent, parent2 });
2384
+ }
2385
+
2057
2386
  // src/bin/runtime.ts
2058
- import { chmodSync as chmodSync4, existsSync as existsSync8, lstatSync as lstatSync4, mkdirSync as mkdirSync6, readdirSync as readdirSync6, readFileSync as readFileSync8, readlinkSync as readlinkSync4, symlinkSync as symlinkSync4, unlinkSync as unlinkSync5, writeFileSync as writeFileSync8 } from "node:fs";
2387
+ import { chmodSync as chmodSync4, existsSync as existsSync9, lstatSync as lstatSync4, mkdirSync as mkdirSync6, readdirSync as readdirSync6, readFileSync as readFileSync8, readlinkSync as readlinkSync4, symlinkSync as symlinkSync4, unlinkSync as unlinkSync5, writeFileSync as writeFileSync8 } from "node:fs";
2059
2388
  import { platform } from "node:os";
2060
- import { dirname as dirname4, join as join8, relative as relative4 } from "node:path";
2389
+ import { dirname as dirname4, join as join9, relative as relative4 } from "node:path";
2061
2390
  var SYMLINK_MODE3 = 40960;
2062
2391
  var EXEC_MODE3 = 493;
2063
2392
  function isolateCommand(command, scratch) {
@@ -2073,7 +2402,7 @@ function isolateCommand(command, scratch) {
2073
2402
  }
2074
2403
  throw new Error(`--isolate not yet wired for platform ${platform()} (macOS sandbox-exec only); omit --isolate to run unconfined`);
2075
2404
  }
2076
- function hydrate2(store, head, dir) {
2405
+ function hydrate3(store, head, dir) {
2077
2406
  if (!head)
2078
2407
  return 0;
2079
2408
  let n = 0;
@@ -2081,7 +2410,7 @@ function hydrate2(store, head, dir) {
2081
2410
  const blob = fileAt(store, head, p);
2082
2411
  if (!blob)
2083
2412
  continue;
2084
- const abs = join8(dir, p);
2413
+ const abs = join9(dir, p);
2085
2414
  mkdirSync6(dirname4(abs), { recursive: true });
2086
2415
  const mode = modeAt(store, head, p);
2087
2416
  if (mode === SYMLINK_MODE3) {
@@ -2103,7 +2432,7 @@ function hydrate2(store, head, dir) {
2103
2432
  }
2104
2433
  function walkDir(dir, base, pats, out = []) {
2105
2434
  for (const name of readdirSync6(dir)) {
2106
- const p = join8(dir, name);
2435
+ const p = join9(dir, name);
2107
2436
  const rel = relative4(base, p);
2108
2437
  if (isIgnored(rel, pats))
2109
2438
  continue;
@@ -2121,11 +2450,11 @@ async function capture(repo, dir, keep = []) {
2121
2450
  const pats = ignorePatterns();
2122
2451
  const files = new Set(walkDir(dir, dir, pats));
2123
2452
  for (const k of keep)
2124
- if (existsSync8(join8(dir, k)))
2453
+ if (existsSync9(join9(dir, k)))
2125
2454
  files.add(k);
2126
2455
  const written = [];
2127
2456
  for (const f of files) {
2128
- const abs = join8(dir, f);
2457
+ const abs = join9(dir, f);
2129
2458
  const st = lstatSync4(abs);
2130
2459
  if (st.isSymbolicLink()) {
2131
2460
  const target = readlinkSync4(abs);
@@ -2153,7 +2482,7 @@ async function capture(repo, dir, keep = []) {
2153
2482
  }
2154
2483
  const deleted = [];
2155
2484
  for (const t of await repo.list()) {
2156
- if (!existsSync8(join8(dir, t))) {
2485
+ if (!existsSync9(join9(dir, t))) {
2157
2486
  await repo.deleteFile(t);
2158
2487
  deleted.push(t);
2159
2488
  }
@@ -2162,7 +2491,7 @@ async function capture(repo, dir, keep = []) {
2162
2491
  }
2163
2492
 
2164
2493
  // src/bin/sol.ts
2165
- var CRED_PATH = join9(homedir(), ".sol", "credentials");
2494
+ var CRED_PATH = join10(homedir(), ".sol", "credentials");
2166
2495
  function tokenClaims(token) {
2167
2496
  try {
2168
2497
  return JSON.parse(Buffer.from(token.split(".")[1] ?? "", "base64url").toString());
@@ -2171,7 +2500,7 @@ function tokenClaims(token) {
2171
2500
  }
2172
2501
  }
2173
2502
  async function loadStoredToken() {
2174
- if (!existsSync9(CRED_PATH))
2503
+ if (!existsSync10(CRED_PATH))
2175
2504
  return;
2176
2505
  let creds;
2177
2506
  try {
@@ -2198,6 +2527,15 @@ async function loadStoredToken() {
2198
2527
  }
2199
2528
  return creds.accessToken;
2200
2529
  }
2530
+ function authExpired() {
2531
+ return die("session expired \u2014 run `sol auth login` (or set SOL_TOKEN)");
2532
+ }
2533
+ function identityFromToken(token) {
2534
+ const c = tokenClaims(token);
2535
+ if (!c.handle && !c.email && !c.userId && !c.sub)
2536
+ return;
2537
+ return { handle: c.handle, email: c.email, userId: c.userId ?? c.sub };
2538
+ }
2201
2539
  function authHost() {
2202
2540
  if (process.env.SOL_AUTH)
2203
2541
  return process.env.SOL_AUTH.replace(/\/+$/, "");
@@ -2216,15 +2554,81 @@ function resolveRemote(solDir3) {
2216
2554
  return;
2217
2555
  return cfg.url ? cfg : { ...cfg, url: DEFAULT_REMOTE_URL };
2218
2556
  }
2557
+ function cliVersion() {
2558
+ try {
2559
+ return JSON.parse(readFileSync9(new URL("./package.json", import.meta.url), "utf8")).version || "dev";
2560
+ } catch {
2561
+ return "dev";
2562
+ }
2563
+ }
2219
2564
  async function main() {
2220
2565
  const argv = process.argv.slice(2);
2221
2566
  if (argv[0] === "--version" || argv[0] === "-v" || argv[0] === "version") {
2222
- console.log("sol 0.1.0");
2567
+ console.log(`sol ${cliVersion()}`);
2223
2568
  return;
2224
2569
  }
2225
2570
  const args = argv.slice(1);
2226
2571
  const wantsHelp = args.includes("--help") || args.includes("-h");
2227
2572
  const SUBHELP = {
2573
+ commit: `sol commit "<message>" commit the whole working tree (single author)
2574
+ sol commit -m "<message>" <file>... commit ONLY those paths (correct attribution for concurrent agents)
2575
+
2576
+ flags:
2577
+ -m <message> message when scoping to files
2578
+ --whole-tree allow a whole-tree commit even with SOL_ACTOR set (else refused)
2579
+ --force commit even with unresolved <<<<<<< conflict markers
2580
+
2581
+ examples:
2582
+ sol commit "add login route"
2583
+ sol commit -m "fix parser" src/parse.ts src/lex.ts`,
2584
+ push: `sol push push the current branch to the configured remote (converging \u2014 never FF-rejected)
2585
+ sol push <repo> if no remote is set, use the hosted Sol (${DEFAULT_REMOTE_URL}) + this repo name, then push
2586
+ sol push --create <repo> same, explicit form (creates the repo on first push)
2587
+
2588
+ notes:
2589
+ a remote already configured? the <repo>/--create arg is ignored \u2014 \`sol push\` just syncs.
2590
+ set SOL_TOKEN (or \`sol auth login\`) first; the push registers the current branch's head on the remote.
2591
+
2592
+ examples:
2593
+ sol push # remote already configured
2594
+ sol push alice/app # one step: configure hosted remote + push`,
2595
+ pull: `sol pull fetch + converge the current branch from the configured remote
2596
+ sol pull <dir> OFFLINE: converge another local repo's .sol on disk into this one (multi-agent, no network)
2597
+
2598
+ notes:
2599
+ refuses to run over a dirty tree \u2014 commit or discard first. conflicts land in the working tree with markers.
2600
+ refuses to run while UNRESOLVED <<<<<<< markers remain \u2014 resolve them and \`sol commit\`, then pull again.`,
2601
+ seal: `sol seal <path> [recipient...] encrypt a file so the HOST only ever sees ciphertext (per-path privacy)
2602
+
2603
+ the content becomes host-blind ciphertext committed into history; the keys stay LOCAL (~/.sol keystore).
2604
+ you are always a recipient of your own seals. add other actors to share read access. \`sol cat\` decrypts
2605
+ for a recipient; everyone else sees <<sealed>>.
2606
+
2607
+ example:
2608
+ sol seal secrets/.env alice bob # alice, bob (and you) can read it; the server cannot`,
2609
+ remote: `sol remote show the configured remote
2610
+ sol remote <url> <repo> set the remote (url + repo name)
2611
+
2612
+ tip: \`sol push <repo>\` configures the hosted remote for you in one step.`,
2613
+ branch: `sol branch list branches (* = current)
2614
+ sol branch <name> [<ref>] create a branch at <ref> (default: HEAD)`,
2615
+ switch: `sol switch <branch> switch branches (commits/keeps current work first; never loses it)`,
2616
+ merge: `sol merge <branch> 3-way merge <branch> into the current branch
2617
+ conflicts land in the working tree with <<<<<<< markers \u2014 resolve, then \`sol commit\`.`,
2618
+ log: `sol log commit history for the current branch
2619
+ sol log <branch|commit> history scoped to a ref
2620
+ sol log --all every op in the log (not just commits)`,
2621
+ diff: `sol diff working tree vs HEAD
2622
+ sol diff <ref> working tree vs a branch/commit
2623
+ sol diff <ref> <ref> tree vs tree
2624
+ sol diff <path> one file, working tree vs HEAD`,
2625
+ restore: `sol restore [--from <ref>] [<path>...] restore a file (or the whole tree) from a ref (default HEAD)`,
2626
+ run: `sol run [--keep <path>]... [--isolate] <command...>
2627
+ hydrate the current tree to a sandbox, run the command, capture produced files back into a commit.
2628
+ --isolate confines the run (no network, writes stay in the sandbox).`,
2629
+ promote: "sol promote [branch] point the remote's production branch at <branch> (default: current branch)",
2630
+ git: `sol git import <repo> [dir] import a git repo's HEAD into a new Sol repo
2631
+ sol git export <repo> [-m "msg"] write Sol's tree back as a git commit, then \`git push\``,
2228
2632
  mr: `sol mr open [--from <branch>] [--to <branch>] [--upstream <repo>] -t "title" [-m body]
2229
2633
  sol mr list | show <id> | review <id> --approve|--request-changes|--comment [-m msg]
2230
2634
  sol mr comment <id> -m msg [--path f --line N] | check <id> --run -- <cmd> | merge <id> [--force] | close <id>`,
@@ -2244,12 +2648,12 @@ async function main() {
2244
2648
  if (t)
2245
2649
  process.env.SOL_TOKEN = t;
2246
2650
  }
2247
- const release = new Set(["add", "track", "commit", "checkpoint", "rm", "gc", "branch", "tag", "switch", "merge", "pull", "run", "seal"]).has(cmd) && existsSync9(solDir2) ? acquireLock() : undefined;
2651
+ const release = new Set(["add", "track", "commit", "checkpoint", "rm", "gc", "branch", "tag", "switch", "merge", "pull", "run", "seal"]).has(cmd) && existsSync10(solDir2) ? acquireLock() : undefined;
2248
2652
  try {
2249
2653
  switch (cmd) {
2250
2654
  case "init": {
2251
- const here = join9(procCwd2, ".sol");
2252
- if (existsSync9(here))
2655
+ const here = join10(procCwd2, ".sol");
2656
+ if (existsSync10(here))
2253
2657
  die("already a sol repo: " + procCwd2);
2254
2658
  if (repoRoot2 && repoRoot2 !== procCwd2 && !args.includes("--force")) {
2255
2659
  die(`already inside a Sol repo at ${repoRoot2}
@@ -2263,7 +2667,7 @@ async function main() {
2263
2667
  }
2264
2668
  case "track":
2265
2669
  case "add": {
2266
- if (!existsSync9(solDir2))
2670
+ if (!existsSync10(solDir2))
2267
2671
  die("not a sol repo \u2014 run `sol init` first");
2268
2672
  const files = args.filter((a) => a !== "." && !a.startsWith("-"));
2269
2673
  if (!files.length) {
@@ -2274,7 +2678,7 @@ async function main() {
2274
2678
  let n = 0;
2275
2679
  for (const f of files) {
2276
2680
  const rf = repoRel(f);
2277
- if (!existsSync9(join9(cwd2, rf))) {
2681
+ if (!existsSync10(join10(cwd2, rf))) {
2278
2682
  console.error("skip (not on disk): " + f);
2279
2683
  continue;
2280
2684
  }
@@ -2303,14 +2707,14 @@ async function main() {
2303
2707
  if (!message)
2304
2708
  die('commit needs a message: sol commit "what you did" (scoped: sol commit -m "msg" file1 file2)');
2305
2709
  const parentHead = await repo.head();
2306
- const mergeHeadPath = join9(solDir2, "MERGE_HEAD");
2307
- const parent2 = existsSync9(mergeHeadPath) ? readFileSync9(mergeHeadPath, "utf8").trim() || undefined : undefined;
2710
+ const mergeHeadPath = join10(solDir2, "MERGE_HEAD");
2711
+ const parent2 = existsSync10(mergeHeadPath) ? readFileSync9(mergeHeadPath, "utf8").trim() || undefined : undefined;
2308
2712
  let changed = 0;
2309
2713
  let commitRoot = parentHead;
2310
2714
  if (paths.length) {
2311
2715
  for (const p of paths) {
2312
2716
  const rp = repoRel(p);
2313
- if (existsSync9(join9(cwd2, rp))) {
2717
+ if (existsSync10(join10(cwd2, rp))) {
2314
2718
  if (await snapshotFile(repo, rp))
2315
2719
  changed++;
2316
2720
  } else if ((await repo.list()).includes(rp)) {
@@ -2323,7 +2727,8 @@ async function main() {
2323
2727
  commitRoot = await repo.head();
2324
2728
  } else {
2325
2729
  const optedIn = wholeTreeOptIn || process.env.SOL_ALLOW_WHOLE_TREE === "1";
2326
- if (process.env.SOL_ACTOR && !optedIn) {
2730
+ const otherActorPresent = (await log.history()).some((o) => o.by && o.by !== actor2);
2731
+ if (process.env.SOL_ACTOR && !optedIn && otherActorPresent) {
2327
2732
  die(`refusing a whole-tree commit as "${actor2}" \u2014 it would attribute ALL pending files to you.
2328
2733
  concurrent agents: sol commit -m "${message}" <your files> (or a per-agent SolWorkspace/MCP)
2329
2734
  if you truly own the whole tree: add --whole-tree or set SOL_ALLOW_WHOLE_TREE=1`);
@@ -2360,7 +2765,7 @@ async function main() {
2360
2765
  }
2361
2766
  case "status": {
2362
2767
  const { repo, log } = open();
2363
- const refs = existsSync9(refsPath()) ? await loadRefs(log) : undefined;
2768
+ const refs = existsSync10(refsPath()) ? await loadRefs(log) : undefined;
2364
2769
  const head = await repo.head();
2365
2770
  console.log(`repo ${cwd2}`);
2366
2771
  console.log(`on ${refs ? refs.current : "main"} head ${head.slice(0, 14)} seq ${await log.seq()} actor ${actor2}`);
@@ -2416,7 +2821,7 @@ async function main() {
2416
2821
  byRoot.set(o.rootAfter, o);
2417
2822
  const live = await log.head() ?? "";
2418
2823
  const refArg = args.find((a) => !a.startsWith("-"));
2419
- const lrefs = existsSync9(refsPath()) ? JSON.parse(readFileSync9(refsPath(), "utf8")) : null;
2824
+ const lrefs = existsSync10(refsPath()) ? JSON.parse(readFileSync9(refsPath(), "utf8")) : null;
2420
2825
  let tip = live;
2421
2826
  if (refArg) {
2422
2827
  tip = lrefs?.branches[refArg]?.head ?? refArg;
@@ -2427,19 +2832,27 @@ async function main() {
2427
2832
  }
2428
2833
  const chain = [];
2429
2834
  const seen = new Set;
2430
- let cur = tip;
2431
- while (cur && byRoot.has(cur) && !seen.has(cur)) {
2835
+ const stack = [tip];
2836
+ while (stack.length) {
2837
+ const cur = stack.pop();
2838
+ if (!cur || seen.has(cur) || !byRoot.has(cur))
2839
+ continue;
2432
2840
  seen.add(cur);
2433
2841
  const c = byRoot.get(cur);
2434
2842
  chain.push(c);
2435
- cur = c.parent;
2843
+ if (c.parent)
2844
+ stack.push(c.parent);
2845
+ if (c.parent2)
2846
+ stack.push(c.parent2);
2436
2847
  }
2848
+ chain.sort((a, b) => b.seq - a.seq);
2437
2849
  if (!chain.length) {
2438
2850
  console.log(ops.length ? '(no commits on this branch yet \u2014 run `sol commit "msg"`)' : "(no history yet)");
2439
2851
  }
2440
2852
  for (const c of chain) {
2441
2853
  const n = countChanges(store, c.parent ?? "", c.rootAfter);
2442
- const tag = c.parent2 ? ` [merge ${c.parent2.slice(0, 8)}]` : "";
2854
+ const mergedIn = [c.parent2 && byRoot.get(c.parent2)?.by].filter(Boolean);
2855
+ const tag = c.parent2 ? ` [merge ${c.parent2.slice(0, 8)}${mergedIn.length ? ` <- ${mergedIn.join(", ")}` : ""}]` : "";
2443
2856
  console.log(`${c.rootAfter.slice(0, 14)} ${(c.by ?? "?").padEnd(14)} ${c.message ?? ""} (${n} change${n === 1 ? "" : "s"})${tag}`);
2444
2857
  }
2445
2858
  const wc = workingChanges(store, live);
@@ -2452,7 +2865,7 @@ async function main() {
2452
2865
  const path = args[0] || die("rm needs a path");
2453
2866
  let onDisk = false;
2454
2867
  try {
2455
- unlinkSync6(join9(cwd2, path));
2868
+ unlinkSync6(join10(cwd2, path));
2456
2869
  onDisk = true;
2457
2870
  } catch {}
2458
2871
  if (onDisk) {
@@ -2619,11 +3032,11 @@ async function main() {
2619
3032
  };
2620
3033
  for (const op of ops)
2621
3034
  walk(op.rootAfter);
2622
- const objDir = join9(solDir2, "objects");
3035
+ const objDir = join10(solDir2, "objects");
2623
3036
  let removed = 0;
2624
3037
  for (const name of readdirSync7(objDir)) {
2625
3038
  if (name.endsWith(".tmp") || !reachable.has(name)) {
2626
- unlinkSync6(join9(objDir, name));
3039
+ unlinkSync6(join10(objDir, name));
2627
3040
  removed++;
2628
3041
  }
2629
3042
  }
@@ -2637,8 +3050,8 @@ async function main() {
2637
3050
  console.log(p);
2638
3051
  break;
2639
3052
  }
2640
- const f = join9(cwd2, ".solignore");
2641
- const lead = existsSync9(f) && !readFileSync9(f, "utf8").endsWith(`
3053
+ const f = join10(cwd2, ".solignore");
3054
+ const lead = existsSync10(f) && !readFileSync9(f, "utf8").endsWith(`
2642
3055
  `) ? `
2643
3056
  ` : "";
2644
3057
  appendFileSync4(f, lead + pat + `
@@ -2656,8 +3069,8 @@ async function main() {
2656
3069
  if (content === SEALED2)
2657
3070
  die("already sealed: " + path);
2658
3071
  if (content === undefined) {
2659
- const abs = join9(cwd2, path);
2660
- if (!existsSync9(abs))
3072
+ const abs = join10(cwd2, path);
3073
+ if (!existsSync10(abs))
2661
3074
  die("no such file: " + path);
2662
3075
  content = readFileSync9(abs, "utf8");
2663
3076
  }
@@ -2723,7 +3136,7 @@ async function main() {
2723
3136
  refs.current = name;
2724
3137
  saveRefs(refs);
2725
3138
  const n = materializeDiff(loadStore(), fromHead, target.head);
2726
- console.log(`switched to ${name} (${(target.head || "empty").slice(0, 12)}); working tree = ${n} file(s)`);
3139
+ console.log(`switched to ${name} (${(target.head || "empty").slice(0, 12)}); ${n} file(s) changed`);
2727
3140
  break;
2728
3141
  }
2729
3142
  case "merge": {
@@ -2744,7 +3157,7 @@ async function main() {
2744
3157
  const result = merge({ store }, other.base, ours, other.head);
2745
3158
  if (result.conflicts.length) {
2746
3159
  materializeTree(store, result.head);
2747
- writeFileSync9(join9(solDir2, "MERGE_HEAD"), other.head);
3160
+ writeFileSync9(join10(solDir2, "MERGE_HEAD"), other.head);
2748
3161
  console.log(`merge ${name} -> ${result.conflicts.length} conflict(s), left in your working tree (uncommitted):`);
2749
3162
  for (const c of result.conflicts)
2750
3163
  console.log(" " + c.path);
@@ -2763,7 +3176,7 @@ async function main() {
2763
3176
  break;
2764
3177
  }
2765
3178
  case "remote": {
2766
- if (!existsSync9(solDir2))
3179
+ if (!existsSync10(solDir2))
2767
3180
  die("not a sol repo");
2768
3181
  if (args[0]) {
2769
3182
  const repoName = args[1] || die("usage: sol remote <url> <repo>");
@@ -2817,7 +3230,7 @@ async function main() {
2817
3230
  if (!c.handle)
2818
3231
  console.log(" (no handle yet \u2014 set one in the web app to get your <handle>/<repo> namespace)");
2819
3232
  } else if (sub === "logout") {
2820
- if (existsSync9(CRED_PATH))
3233
+ if (existsSync10(CRED_PATH))
2821
3234
  unlinkSync6(CRED_PATH);
2822
3235
  console.log("logged out");
2823
3236
  } else if (sub === "status" || !sub) {
@@ -2826,7 +3239,7 @@ async function main() {
2826
3239
  console.log(`authenticated via SOL_TOKEN (env)${c2.handle ? ` as @${c2.handle}` : ""}`);
2827
3240
  break;
2828
3241
  }
2829
- if (!existsSync9(CRED_PATH)) {
3242
+ if (!existsSync10(CRED_PATH)) {
2830
3243
  console.log("not logged in \u2014 run `sol auth login` (or set SOL_TOKEN)");
2831
3244
  break;
2832
3245
  }
@@ -2837,20 +3250,26 @@ async function main() {
2837
3250
  } else if (sub === "whoami") {
2838
3251
  const token = process.env.SOL_TOKEN || await loadStoredToken();
2839
3252
  if (!token)
2840
- die("not logged in \u2014 run `sol auth login` (or set SOL_TOKEN)");
2841
- const res = await fetch(`${authHost()}/api/auth/me`, { headers: { authorization: `Bearer ${token}` } });
2842
- if (res.status === 401)
2843
- die("not logged in \u2014 your session expired; run `sol auth login` again");
2844
- if (!res.ok)
2845
- die(`could not reach ${authHost()} (me -> ${res.status})`);
2846
- const me = await res.json();
2847
- console.log(me.handle ? `signed in as @${me.handle} ${me.email ?? ""}`.trimEnd() : `signed in${me.email ? ` as ${me.email}` : ""} \u2014 no handle yet (\`sol auth set-handle <name>\`)`);
3253
+ authExpired();
3254
+ let id = identityFromToken(token);
3255
+ try {
3256
+ const res = await fetch(`${authHost()}/api/auth/me`, { headers: { authorization: `Bearer ${token}` } });
3257
+ if (res.status === 401 && !id)
3258
+ authExpired();
3259
+ if (res.ok) {
3260
+ const me = await res.json();
3261
+ id = { handle: me.handle ?? id?.handle, email: me.email ?? id?.email, userId: id?.userId };
3262
+ }
3263
+ } catch {}
3264
+ if (!id)
3265
+ authExpired();
3266
+ console.log(id.handle ? `signed in as @${id.handle} ${id.email ?? ""}`.trimEnd() : `signed in${id.email ? ` as ${id.email}` : ""} \u2014 no handle yet (\`sol auth set-handle <name>\`)`);
2848
3267
  } else if (sub === "set-handle") {
2849
3268
  const want = args[1] || die("usage: sol auth set-handle <name>");
2850
3269
  const handle = want.toLowerCase();
2851
3270
  const token = process.env.SOL_TOKEN || await loadStoredToken();
2852
3271
  if (!token)
2853
- die("not logged in \u2014 run `sol auth login` (or set SOL_TOKEN)");
3272
+ authExpired();
2854
3273
  let hadHandle = false;
2855
3274
  try {
2856
3275
  const meRes = await fetch(`${authHost()}/api/auth/me`, { headers: { authorization: `Bearer ${token}` } });
@@ -2859,7 +3278,7 @@ async function main() {
2859
3278
  } catch {}
2860
3279
  const res = await fetch(`${authHost()}/api/auth/handle`, { method: "POST", headers: { authorization: `Bearer ${token}`, "content-type": "application/json" }, body: JSON.stringify({ handle }) });
2861
3280
  if (res.status === 401)
2862
- die("not logged in \u2014 your session expired; run `sol auth login` again");
3281
+ authExpired();
2863
3282
  if (!res.ok) {
2864
3283
  let msg = `could not set handle (${res.status})`;
2865
3284
  try {
@@ -2874,7 +3293,7 @@ async function main() {
2874
3293
  console.log("heads-up: changing your handle re-namespaces your repos under the new <handle>/<repo>.");
2875
3294
  console.log(`handle set to @${out.handle}`);
2876
3295
  } else if (sub === "pat") {
2877
- if (!existsSync9(CRED_PATH))
3296
+ if (!existsSync10(CRED_PATH))
2878
3297
  die("run `sol auth login` first");
2879
3298
  const creds = JSON.parse(readFileSync9(CRED_PATH, "utf8"));
2880
3299
  const token = await loadStoredToken();
@@ -2928,12 +3347,33 @@ async function main() {
2928
3347
  break;
2929
3348
  }
2930
3349
  case "clone": {
3350
+ const localSrc = args[0] ? localPeerSolDir(args[0], procCwd2) : undefined;
3351
+ if (localSrc) {
3352
+ const peer = openPeer(localSrc);
3353
+ const peerHead = await peer.log.head();
3354
+ const dest = resolve4(procCwd2, args[1] || (args[0].replace(/\/+$/, "").split("/").pop() || "clone") + "-clone");
3355
+ const ddir = join10(dest, ".sol");
3356
+ if (existsSync10(ddir))
3357
+ die("already a sol repo: " + dest);
3358
+ mkdirSync7(ddir, { recursive: true });
3359
+ const res = await converge({ store: new FileStore(ddir), log: new FileOpLog(ddir) }, { nodes: await peerNodes(peer, peerHead), ops: await peer.log.history(), incomingHead: peerHead, actor: actor2 });
3360
+ const dstore = new Store2;
3361
+ for (const n2 of await peerNodes({ store: new FileStore(ddir), log: new FileOpLog(ddir) }, res.head))
3362
+ dstore.put(n2);
3363
+ const files = res.head ? listAll3(dstore, res.head) : [];
3364
+ for (const f of files)
3365
+ materializeInto(dstore, res.head, dest, f);
3366
+ writeFileSync9(join10(ddir, "refs.json"), JSON.stringify({ current: "main", branches: { main: { head: res.head, base: res.head, remote: res.head } }, tags: {} }, null, 2));
3367
+ writeWorkingIndexAt(ddir, dest, files);
3368
+ console.log(`cloned local peer ${args[0]} -> ${dest} (${(await peer.log.history()).length} ops, ${files.length} files)`);
3369
+ break;
3370
+ }
2931
3371
  const [url, rest] = remoteUrlArg(args);
2932
3372
  const repoName = rest[0] || die("usage: sol clone [<url>] <owner>/<repo> [dir]");
2933
3373
  const token = process.env.SOL_TOKEN || die("set SOL_TOKEN to the backend bearer token");
2934
- const target = resolve3(cwd2, rest[1] || repoName.split("/").pop() || repoName);
2935
- const fdir = join9(target, ".sol");
2936
- if (existsSync9(fdir))
3374
+ const target = resolve4(cwd2, rest[1] || repoName.split("/").pop() || repoName);
3375
+ const fdir = join10(target, ".sol");
3376
+ if (existsSync10(fdir))
2937
3377
  die("already a sol repo: " + target);
2938
3378
  const cfg = { url, repo: repoName };
2939
3379
  const bundle = await remoteExport(cfg, token);
@@ -2949,8 +3389,8 @@ async function main() {
2949
3389
  cloneBranches[name] = { head: h, base: h, remote: h };
2950
3390
  if (!cloneBranches[onBranch])
2951
3391
  cloneBranches[onBranch] = { head: checkoutHead, base: checkoutHead, remote: checkoutHead };
2952
- writeFileSync9(join9(fdir, "refs.json"), JSON.stringify({ current: onBranch, branches: cloneBranches, tags: {} }, null, 2));
2953
- writeFileSync9(join9(fdir, "HEAD"), JSON.stringify({ head: checkoutHead, seq: bundle.seq, logTip: bundle.tip }));
3392
+ writeFileSync9(join10(fdir, "refs.json"), JSON.stringify({ current: onBranch, branches: cloneBranches, tags: {} }, null, 2));
3393
+ writeFileSync9(join10(fdir, "HEAD"), JSON.stringify({ head: checkoutHead, seq: bundle.seq, logTip: bundle.tip }));
2954
3394
  const store = new Store2;
2955
3395
  for (const node of bundle.nodes)
2956
3396
  store.put(node);
@@ -2967,13 +3407,21 @@ async function main() {
2967
3407
  }
2968
3408
  case "push": {
2969
3409
  const { log } = open();
2970
- const cfg = resolveRemote(solDir2) || die("no remote \u2014 set one with `sol remote <url> <repo>`");
2971
- const token = process.env.SOL_TOKEN || die("set SOL_TOKEN");
3410
+ if (!loadRemote(solDir2)) {
3411
+ const ci = args.indexOf("--create");
3412
+ const repoName = ci >= 0 ? args[ci + 1] : args.find((a) => !a.startsWith("-"));
3413
+ if (repoName) {
3414
+ saveRemote(solDir2, { url: DEFAULT_REMOTE_URL, repo: repoName });
3415
+ console.log(`remote set: ${DEFAULT_REMOTE_URL} (repo ${repoName})`);
3416
+ }
3417
+ }
3418
+ const cfg = resolveRemote(solDir2) || die("no remote \u2014 set one with `sol remote <url> <repo>`, or `sol push <repo>` to use the hosted Sol");
3419
+ const token = process.env.SOL_TOKEN || authExpired();
2972
3420
  const rh = await remoteHead(cfg, token);
2973
3421
  const ops = await log.history();
2974
3422
  const localSeq = ops.length ? ops[ops.length - 1].seq : 0;
2975
3423
  const localTip = await log.logTip();
2976
- const localRefs = existsSync9(refsPath()) ? JSON.parse(readFileSync9(refsPath(), "utf8")) : undefined;
3424
+ const localRefs = existsSync10(refsPath()) ? JSON.parse(readFileSync9(refsPath(), "utf8")) : undefined;
2977
3425
  const branch = localRefs?.current ?? "main";
2978
3426
  const branchHead = await log.head() ?? "";
2979
3427
  const remoteWasEmpty = rh.seq === 0 && !rh.tip;
@@ -2981,26 +3429,22 @@ async function main() {
2981
3429
  console.log(remoteWasEmpty ? "nothing to push \u2014 local repo is empty (commit something first)" : "everything up to date");
2982
3430
  break;
2983
3431
  }
2984
- if (rh.seq > 0) {
2985
- const atRemote = ops.find((o) => o.seq === rh.seq);
2986
- if (!atRemote || atRemote.entryHash !== rh.tip)
2987
- die("push rejected \u2014 the remote has changes you don't have (run `sol pull` first)");
2988
- }
2989
- if (localSeq <= rh.seq) {
2990
- console.log("nothing to push");
2991
- break;
2992
- }
2993
- let res;
2994
- try {
2995
- res = await remotePush(cfg, token, { nodes: allLocalNodes(), ops: ops.filter((o) => o.seq > rh.seq), branch, head: branchHead, expectedHead: localRefs?.branches[branch]?.remote });
2996
- } catch (e) {
2997
- if (String(e?.message ?? "").includes("409"))
2998
- die(`push rejected \u2014 branch '${branch}' moved on the remote. run \`sol pull\`, then push again.`);
2999
- throw e;
3000
- }
3432
+ if (localSeq <= rh.seq) {}
3433
+ const forkBase = localRefs?.branches[branch]?.remote;
3434
+ const baseOp = forkBase ? ops.find((o) => o.rootAfter === forkBase) : undefined;
3435
+ const fromSeq = baseOp ? baseOp.seq : rh.seq;
3436
+ const res = await remotePush(cfg, token, {
3437
+ nodes: allLocalNodes(),
3438
+ ops: ops.filter((o) => o.seq > fromSeq),
3439
+ branch,
3440
+ head: branchHead,
3441
+ expectedHead: forkBase
3442
+ });
3001
3443
  const canon = await remoteExport(cfg, token);
3444
+ const prevHead = await log.head() ?? "";
3002
3445
  await writeBundle(solDir2, canon, 0);
3003
- if (existsSync9(refsPath())) {
3446
+ const convergedHead = res.head ?? canon.refs?.branches?.[branch] ?? branchHead;
3447
+ if (existsSync10(refsPath())) {
3004
3448
  const refs = JSON.parse(readFileSync9(refsPath(), "utf8"));
3005
3449
  if (canon.refs) {
3006
3450
  for (const [name, h] of Object.entries(canon.refs.branches)) {
@@ -3008,32 +3452,76 @@ async function main() {
3008
3452
  }
3009
3453
  }
3010
3454
  if (refs.branches[branch]) {
3011
- refs.branches[branch].head = res.head ?? branchHead;
3012
- refs.branches[branch].remote = res.head ?? branchHead;
3455
+ refs.branches[branch].head = convergedHead;
3456
+ refs.branches[branch].remote = convergedHead;
3013
3457
  }
3014
3458
  saveRefs(refs);
3015
3459
  }
3016
- setOpLogHead(res.head ?? branchHead);
3460
+ setOpLogHead(convergedHead);
3461
+ if (convergedHead && convergedHead !== prevHead)
3462
+ materializeDiff(loadStore(), prevHead, convergedHead);
3017
3463
  const prod = res.refs?.production;
3464
+ const mergedNote = res.merged ? " (converged with a concurrent commit)" : "";
3465
+ const conflictNote = res.conflicts && res.conflicts.length ? ` \u2014 ${res.conflicts.length} conflict(s) kept with markers in: ${res.conflicts.map((c) => c.path).join(", ")}` : "";
3018
3466
  if (remoteWasEmpty) {
3019
- console.log(`created remote repo ${cfg.repo} \u2014 pushed ${res.applied} op(s) (branch ${branch} @ ${(res.head ?? "").slice(0, 12)})${prod ? `; production=${prod}` : ""}`);
3467
+ console.log(`created remote repo ${cfg.repo} \u2014 pushed ${res.applied} op(s) (branch ${branch} @ ${(convergedHead || "").slice(0, 12)})${prod ? `; production=${prod}` : ""}`);
3020
3468
  } else {
3021
- console.log(`pushed ${res.applied} op(s) -> remote (branch ${branch} @ ${(res.head ?? "").slice(0, 12)})${prod ? `; production=${prod}` : ""}`);
3469
+ console.log(`pushed ${res.applied} op(s) -> remote (branch ${branch} @ ${(convergedHead || "").slice(0, 12)})${mergedNote}${conflictNote}${prod ? `; production=${prod}` : ""}`);
3022
3470
  }
3023
3471
  break;
3024
3472
  }
3025
3473
  case "pull": {
3026
3474
  const { log } = open();
3475
+ {
3476
+ const marked = unresolvedConflictPaths();
3477
+ if (marked.length)
3478
+ die(`unresolved conflict markers in ${marked.join(", ")} \u2014 resolve them and \`sol commit\`, then pull again.`);
3479
+ }
3480
+ const peerArg = args[0];
3481
+ const peerSolDir = peerArg ? localPeerSolDir(peerArg, cwd2) : undefined;
3482
+ if (peerSolDir) {
3483
+ const wcLocal = workingChanges(loadStore(), await log.head() ?? "");
3484
+ if (wcLocal.added.length + wcLocal.modified.length + wcLocal.removed.length > 0) {
3485
+ die("you have uncommitted changes \u2014 commit (`sol commit`) or discard them before pulling.");
3486
+ }
3487
+ const peer = openPeer(peerSolDir);
3488
+ const peerHead = await peer.log.head();
3489
+ if (!peerHead) {
3490
+ console.log("peer repo is empty \u2014 nothing to pull");
3491
+ break;
3492
+ }
3493
+ const prevHead = await log.head() ?? "";
3494
+ const res = await converge({ store: new FileStore(solDir2), log }, { nodes: await peerNodes(peer, peerHead), ops: await peer.log.history(), incomingHead: peerHead, actor: actor2 });
3495
+ if (existsSync10(refsPath())) {
3496
+ const refs = JSON.parse(readFileSync9(refsPath(), "utf8"));
3497
+ if (refs.branches[refs.current])
3498
+ refs.branches[refs.current].head = res.head;
3499
+ saveRefs(refs);
3500
+ }
3501
+ setOpLogHead(res.head);
3502
+ if (res.head !== prevHead)
3503
+ materializeDiff(loadStore(), prevHead, res.head);
3504
+ if (res.conflicts.length) {
3505
+ console.log(`pulled + merged from ${peerArg} WITH ${res.conflicts.length} conflict(s) \u2014 both sides kept with markers in:`);
3506
+ for (const c of res.conflicts)
3507
+ console.log(" " + c.path);
3508
+ console.log("resolve the <<<<<<< markers, then `sol commit`");
3509
+ process.exitCode = 1;
3510
+ } else {
3511
+ console.log(`pulled from ${peerArg} -> ${res.applied} new op(s)${res.merged ? " (converged by merge)" : ""}, now at ${(res.head || "").slice(0, 12)}`);
3512
+ }
3513
+ break;
3514
+ }
3027
3515
  const cfg = resolveRemote(solDir2) || die("no remote \u2014 set one with `sol remote <url> <repo>`");
3028
- const token = process.env.SOL_TOKEN || die("set SOL_TOKEN");
3516
+ const token = process.env.SOL_TOKEN || authExpired();
3029
3517
  const bundle = await remoteExport(cfg, token);
3030
3518
  const ops = await log.history();
3031
3519
  const localSeq = ops.length ? ops[ops.length - 1].seq : 0;
3032
3520
  const localTip = await log.logTip();
3033
- const curBranch = existsSync9(refsPath()) ? JSON.parse(readFileSync9(refsPath(), "utf8")).current : bundle.refs?.production || "main";
3521
+ const curBranch = existsSync10(refsPath()) ? JSON.parse(readFileSync9(refsPath(), "utf8")).current : bundle.refs?.production || "main";
3034
3522
  const remoteCurHead = bundle.refs?.branches?.[curBranch] ?? bundle.head ?? "";
3035
3523
  if (bundle.seq === localSeq && bundle.tip === localTip) {
3036
- if (bundle.refs && existsSync9(refsPath())) {
3524
+ if (bundle.refs && existsSync10(refsPath())) {
3037
3525
  const lr = JSON.parse(readFileSync9(refsPath(), "utf8"));
3038
3526
  const before = lr.branches[lr.current]?.head;
3039
3527
  for (const [name, h] of Object.entries(bundle.refs.branches))
@@ -3058,7 +3546,7 @@ async function main() {
3058
3546
  break;
3059
3547
  }
3060
3548
  const syncRefHead = (h) => {
3061
- if (!existsSync9(refsPath()))
3549
+ if (!existsSync10(refsPath()))
3062
3550
  return;
3063
3551
  const refs = JSON.parse(readFileSync9(refsPath(), "utf8"));
3064
3552
  if (refs.branches[refs.current])
@@ -3078,7 +3566,7 @@ async function main() {
3078
3566
  syncRefHead(remoteCurHead);
3079
3567
  setOpLogHead(remoteCurHead);
3080
3568
  materializeTree(loadStore(), remoteCurHead);
3081
- if (bundle.refs && existsSync9(refsPath())) {
3569
+ if (bundle.refs && existsSync10(refsPath())) {
3082
3570
  const lr = JSON.parse(readFileSync9(refsPath(), "utf8"));
3083
3571
  for (const [name, h] of Object.entries(bundle.refs.branches))
3084
3572
  lr.branches[name] = { head: h, base: lr.branches[name]?.base ?? h, remote: h };
@@ -3127,9 +3615,9 @@ async function main() {
3127
3615
  for (const c of result.conflicts) {
3128
3616
  const blob = fileAt3(store, result.head, c.path);
3129
3617
  if (blob)
3130
- writeFileSync9(join9(cwd2, c.path), blob.encoding === "base64" ? Buffer.from(blob.content, "base64") : blob.content);
3618
+ writeFileSync9(join10(cwd2, c.path), blob.encoding === "base64" ? Buffer.from(blob.content, "base64") : blob.content);
3131
3619
  }
3132
- writeFileSync9(join9(solDir2, "MERGE_HEAD"), remoteHead2);
3620
+ writeFileSync9(join10(solDir2, "MERGE_HEAD"), remoteHead2);
3133
3621
  console.log(`pulled + merged WITH ${result.conflicts.length} conflict(s), left uncommitted in your working tree:`);
3134
3622
  for (const c of result.conflicts)
3135
3623
  console.log(" " + c.path);
@@ -3141,11 +3629,11 @@ async function main() {
3141
3629
  break;
3142
3630
  }
3143
3631
  case "promote": {
3144
- if (!existsSync9(solDir2))
3632
+ if (!existsSync10(solDir2))
3145
3633
  die("not a sol repo");
3146
3634
  const cfg = resolveRemote(solDir2) || die("no remote \u2014 set one with `sol remote <url> <repo>`");
3147
- const token = process.env.SOL_TOKEN || die("set SOL_TOKEN");
3148
- const cur = existsSync9(refsPath()) ? JSON.parse(readFileSync9(refsPath(), "utf8")).current : "main";
3635
+ const token = process.env.SOL_TOKEN || authExpired();
3636
+ const cur = existsSync10(refsPath()) ? JSON.parse(readFileSync9(refsPath(), "utf8")).current : "main";
3149
3637
  const branch = args[0] || cur;
3150
3638
  const refs = await remotePromote(cfg, token, branch);
3151
3639
  console.log(`promoted '${branch}' -> production '${refs.production}' now at ${(refs.branches[refs.production] ?? "").slice(0, 12)}`);
@@ -3155,10 +3643,10 @@ async function main() {
3155
3643
  const [url, frest] = remoteUrlArg(args);
3156
3644
  const parent = frest[0] || die("usage: sol fork [<url>] <parent-repo> <new-repo> [dir]");
3157
3645
  const newRepo = frest[1] || die("usage: sol fork [<url>] <parent-repo> <new-repo> [dir]");
3158
- const token = process.env.SOL_TOKEN || die("set SOL_TOKEN");
3159
- const target = resolve3(cwd2, frest[2] || newRepo);
3160
- const fdir = join9(target, ".sol");
3161
- if (existsSync9(fdir))
3646
+ const token = process.env.SOL_TOKEN || authExpired();
3647
+ const target = resolve4(cwd2, frest[2] || newRepo);
3648
+ const fdir = join10(target, ".sol");
3649
+ if (existsSync10(fdir))
3162
3650
  die("already a sol repo: " + target);
3163
3651
  const parentCfg = { url, repo: parent };
3164
3652
  const newCfg = { url, repo: newRepo, forkParent: parent };
@@ -3183,8 +3671,8 @@ async function main() {
3183
3671
  const cloneBranches = {};
3184
3672
  for (const [name, h] of Object.entries(srvRefs.branches))
3185
3673
  cloneBranches[name] = { head: h, base: h, remote: h };
3186
- writeFileSync9(join9(fdir, "refs.json"), JSON.stringify({ current: onBranch, branches: cloneBranches, tags: {} }, null, 2));
3187
- writeFileSync9(join9(fdir, "HEAD"), JSON.stringify({ head: checkoutHead, seq: canon.seq, logTip: canon.tip }));
3674
+ writeFileSync9(join10(fdir, "refs.json"), JSON.stringify({ current: onBranch, branches: cloneBranches, tags: {} }, null, 2));
3675
+ writeFileSync9(join10(fdir, "HEAD"), JSON.stringify({ head: checkoutHead, seq: canon.seq, logTip: canon.tip }));
3188
3676
  saveRemote(fdir, newCfg);
3189
3677
  const store = new Store2;
3190
3678
  for (const node of canon.nodes)
@@ -3201,8 +3689,8 @@ async function main() {
3201
3689
  break;
3202
3690
  }
3203
3691
  case "access": {
3692
+ const token = process.env.SOL_TOKEN || authExpired();
3204
3693
  const cfg = resolveRemote(solDir2) || die("no remote \u2014 set one with `sol remote <url> <repo>`");
3205
- const token = process.env.SOL_TOKEN || die("set SOL_TOKEN");
3206
3694
  const sub = args[0];
3207
3695
  if (!sub || sub === "show") {
3208
3696
  const a = await accessGet(cfg, token);
@@ -3237,7 +3725,7 @@ async function main() {
3237
3725
  }
3238
3726
  case "forks": {
3239
3727
  const cfg = resolveRemote(solDir2) || die("no remote \u2014 set one with `sol remote <url> <repo>`");
3240
- const token = process.env.SOL_TOKEN || die("set SOL_TOKEN");
3728
+ const token = process.env.SOL_TOKEN || authExpired();
3241
3729
  const { forks } = await forksList(cfg, token);
3242
3730
  if (!forks.length)
3243
3731
  console.log(`(no forks of ${cfg.repo})`);
@@ -3250,9 +3738,9 @@ async function main() {
3250
3738
  }
3251
3739
  case "mr": {
3252
3740
  const cfg = resolveRemote(solDir2) || die("no remote \u2014 set one with `sol remote <url> <repo>`");
3253
- const token = process.env.SOL_TOKEN || die("set SOL_TOKEN");
3741
+ const token = process.env.SOL_TOKEN || authExpired();
3254
3742
  const sub = args[0];
3255
- const localRefs = () => existsSync9(refsPath()) ? JSON.parse(readFileSync9(refsPath(), "utf8")) : { current: "main", branches: {}, tags: {} };
3743
+ const localRefs = () => existsSync10(refsPath()) ? JSON.parse(readFileSync9(refsPath(), "utf8")) : { current: "main", branches: {}, tags: {} };
3256
3744
  const flag = (name) => {
3257
3745
  const i = args.indexOf(name);
3258
3746
  return i >= 0 ? args[i + 1] : undefined;
@@ -3429,9 +3917,9 @@ ${mrSummary(pr)}`);
3429
3917
  if (!command.length)
3430
3918
  die("usage: sol run [--keep <path>] [--isolate] <command...>");
3431
3919
  const head = await repo.head();
3432
- const dir = mkdtempSync(join9(tmpdir(), "sol-run-"));
3920
+ const dir = mkdtempSync(join10(tmpdir(), "sol-run-"));
3433
3921
  try {
3434
- const hn = hydrate2(loadStore(), head, dir);
3922
+ const hn = hydrate3(loadStore(), head, dir);
3435
3923
  console.log(`hydrated ${hn} file(s) -> sandbox${isolate ? " (isolated: no network, writes confined to the sandbox)" : ""}`);
3436
3924
  const argv2 = isolate ? isolateCommand(command, dir) : command;
3437
3925
  console.log(`$ ${command.join(" ")}`);
@@ -3449,7 +3937,7 @@ ${mrSummary(pr)}`);
3449
3937
  const { written, deleted } = await capture(repo, dir, keep);
3450
3938
  if (written.length || deleted.length) {
3451
3939
  await appendCommit(log, await repo.head(), `run: ${command.join(" ")}`, head);
3452
- if (existsSync9(refsPath())) {
3940
+ if (existsSync10(refsPath())) {
3453
3941
  const refs = JSON.parse(readFileSync9(refsPath(), "utf8"));
3454
3942
  if (refs.branches[refs.current]) {
3455
3943
  refs.branches[refs.current].head = await repo.head();
@@ -3462,7 +3950,7 @@ ${mrSummary(pr)}`);
3462
3950
  materialize(synced, nh, f);
3463
3951
  for (const f of deleted) {
3464
3952
  try {
3465
- unlinkSync6(join9(cwd2, f));
3953
+ unlinkSync6(join10(cwd2, f));
3466
3954
  } catch {}
3467
3955
  }
3468
3956
  console.log(`captured ${written.length} written, ${deleted.length} deleted file(s):`);
@@ -3481,10 +3969,10 @@ ${mrSummary(pr)}`);
3481
3969
  case "git": {
3482
3970
  const sub = args[0];
3483
3971
  if (sub === "import") {
3484
- const gitPath = resolve3(cwd2, args[1] || die("usage: sol git import <git-repo> [dir]"));
3485
- const target = resolve3(cwd2, args[2] || basename(gitPath));
3486
- const fdir = join9(target, ".sol");
3487
- if (existsSync9(fdir))
3972
+ const gitPath = resolve4(cwd2, args[1] || die("usage: sol git import <git-repo> [dir]"));
3973
+ const target = resolve4(cwd2, args[2] || basename(gitPath));
3974
+ const fdir = join10(target, ".sol");
3975
+ if (existsSync10(fdir))
3488
3976
  die("already a sol repo: " + target);
3489
3977
  mkdirSync7(fdir, { recursive: true });
3490
3978
  const { commits, branches, head, current } = await importGitRepo(gitPath, fdir);
@@ -3494,20 +3982,20 @@ ${mrSummary(pr)}`);
3494
3982
  if (!refsBranches[current])
3495
3983
  refsBranches[current] = { head, base: head };
3496
3984
  refsBranches[current].head = head;
3497
- writeFileSync9(join9(fdir, "refs.json"), JSON.stringify({ current, branches: refsBranches, tags: {} }, null, 2));
3985
+ writeFileSync9(join10(fdir, "refs.json"), JSON.stringify({ current, branches: refsBranches, tags: {} }, null, 2));
3498
3986
  const store = new Store2;
3499
- for (const name of readdirSync7(join9(fdir, "objects"))) {
3987
+ for (const name of readdirSync7(join10(fdir, "objects"))) {
3500
3988
  if (name.endsWith(".tmp"))
3501
3989
  continue;
3502
3990
  try {
3503
- store.put(decodeObject(readFileSync9(join9(fdir, "objects", name))));
3991
+ store.put(decodeObject(readFileSync9(join10(fdir, "objects", name))));
3504
3992
  } catch {}
3505
3993
  }
3506
- const onDisk = hydrate2(store, head, target);
3994
+ const onDisk = hydrate3(store, head, target);
3507
3995
  console.log(`imported ${commits} commit(s), ${branches.length} branch(es) from git -> ${args[2] || basename(gitPath)} (${onDisk} files; on branch ${current})`);
3508
3996
  } else if (sub === "export") {
3509
3997
  const { log } = open();
3510
- const gitPath = resolve3(cwd2, args[1] || die('usage: sol git export <git-repo> [-m "msg"]'));
3998
+ const gitPath = resolve4(cwd2, args[1] || die('usage: sol git export <git-repo> [-m "msg"]'));
3511
3999
  const mi = args.indexOf("-m");
3512
4000
  const store = loadStore();
3513
4001
  const head = await log.head() ?? "";
@@ -3547,6 +4035,10 @@ everyday (examples are copy-safe \u2014 use real filenames):
3547
4035
  sol ignore "*.tmp" add an ignore pattern (no arg lists the active patterns)
3548
4036
  sol fsck / sol gc verify integrity / drop unreachable objects
3549
4037
 
4038
+ privacy (native per-path encryption \u2014 the host only ever stores ciphertext):
4039
+ sol seal secrets/.env alice encrypt a file to recipients (you + alice); keys stay LOCAL, history is host-blind
4040
+ \`sol cat\` decrypts for a recipient; everyone else sees <<sealed>>. (sol seal --help)
4041
+
3550
4042
  branches & tags:
3551
4043
  sol branch list branches (sol branch feature creates one at HEAD)
3552
4044
  sol switch feature switch to a branch (captures current work first, never loses it)
@@ -3563,6 +4055,7 @@ auth (sign in once; remote commands then use the cached token, no SOL_TOKEN need
3563
4055
  remotes (self-hostable backend; token in SOL_TOKEN or via sol auth login):
3564
4056
  sol clone [<url>] <owner>/<repo> [dir] clone a remote repo (checks out PRODUCTION); default dir = <repo>
3565
4057
  sol push / sol pull sync your commits with the remote (push registers your branch's head)
4058
+ sol push <repo> one-step share: no remote set? use the hosted Sol + <repo>, then push
3566
4059
  sol promote [branch] point the remote's production branch at <branch> (default: current)
3567
4060
  sol remote <url> <repo> set the remote (no arg: show it; url defaults to the hosted Sol)
3568
4061
  sol fork [<url>] <parent> <new> [dir] make your own copy of a repo (all branches + history + a parent link)
@@ -3592,4 +4085,9 @@ addressed; history is a tamper-evident hash-chained op-log. attribute changes wi
3592
4085
  release?.();
3593
4086
  }
3594
4087
  }
3595
- main().catch((e) => die(e?.message || String(e)));
4088
+ main().catch((e) => {
4089
+ const msg = e?.message || String(e);
4090
+ if (/ -> 401\b/.test(msg) || /\b401 unauthorized\b/i.test(msg))
4091
+ authExpired();
4092
+ die(msg);
4093
+ });