@join-pantheon/cli 0.29.0 → 0.30.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/README.md +45 -22
- package/bin/pantheon.js +12 -0
- package/dist/accounts.js +151 -26
- package/dist/agents.js +197 -14
- package/dist/claude-confine.js +283 -0
- package/dist/cli.js +725 -119
- package/dist/client.js +37 -2
- package/dist/command-state.js +79 -0
- package/dist/config.js +211 -10
- package/dist/doctor.js +103 -13
- package/dist/grants.js +1 -1
- package/dist/history-view.js +2 -1
- package/dist/home-guard.js +210 -0
- package/dist/hooks-install.js +2 -1
- package/dist/identity.js +24 -0
- package/dist/install.js +76 -26
- package/dist/kiro-files.js +2 -2
- package/dist/ledger.js +111 -28
- package/dist/machine.js +507 -38
- package/dist/mcp-supervisor.js +25 -6
- package/dist/mcp-windows.js +47 -25
- package/dist/mcp.js +25 -10
- package/dist/network.js +39 -8
- package/dist/notify.js +17 -6
- package/dist/pending.js +8 -14
- package/dist/persona.js +21 -0
- package/dist/profile.js +4 -1
- package/dist/quickstart.js +88 -15
- package/dist/reads.js +30 -0
- package/dist/refusal.js +14 -1
- package/dist/relay.js +1193 -185
- package/dist/roster-file.js +169 -23
- package/dist/run-failure.js +83 -3
- package/dist/secret-guard.js +141 -0
- package/dist/service.js +105 -36
- package/dist/setup-guide.js +28 -9
- package/dist/spend.js +6 -2
- package/dist/store.js +94 -10
- package/dist/text-file.js +74 -0
- package/dist/tray.js +11 -2
- package/dist/update-check.js +2 -0
- package/dist/which.js +69 -12
- package/dist/worker-policy.js +137 -3
- package/dist/worker.js +702 -81
- package/helper/macos/menubar.swift +15 -1
- package/package.json +2 -2
package/dist/client.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import WebSocket from "ws";
|
|
2
|
+
import { updateMembershipToken } from "./config.js";
|
|
2
3
|
import { signChallenge } from "./identity.js";
|
|
3
4
|
import { newId } from "./protocol.js";
|
|
4
5
|
import { VERSION } from "./version.js";
|
|
@@ -23,6 +24,11 @@ export class PantheonClient {
|
|
|
23
24
|
return !this.connected && !this.closed && this.everConnected;
|
|
24
25
|
}
|
|
25
26
|
cid = 0;
|
|
27
|
+
static REFUSED_RETRY_MS = 5 * 60_000;
|
|
28
|
+
static REFUSED_GRACE_MS = 2 * 60_000;
|
|
29
|
+
get refused() { return this.connected ? null : this.refusedCode; }
|
|
30
|
+
refusedCode = null;
|
|
31
|
+
refusedSince = 0;
|
|
26
32
|
seq = 0;
|
|
27
33
|
readSeq = 0;
|
|
28
34
|
head = 0;
|
|
@@ -46,13 +52,24 @@ export class PantheonClient {
|
|
|
46
52
|
enroll = null;
|
|
47
53
|
inviteUrl = null;
|
|
48
54
|
invitesList = null;
|
|
55
|
+
persistToken;
|
|
49
56
|
constructor(cfg, onEvent, opts) {
|
|
50
57
|
this.cfg = cfg;
|
|
51
58
|
this.onEvent = onEvent;
|
|
59
|
+
this.persistToken = opts?.persistToken ?? ((space, oldToken, newToken) => { updateMembershipToken(space, oldToken, newToken); });
|
|
52
60
|
this.enroll = opts?.enroll ?? null;
|
|
53
61
|
this.session = opts?.session ?? newId("s");
|
|
54
62
|
this.role = opts?.role ?? "interactive";
|
|
55
63
|
}
|
|
64
|
+
adoptToken(token) {
|
|
65
|
+
const old = this.cfg.token;
|
|
66
|
+
if (!token || token === old)
|
|
67
|
+
return;
|
|
68
|
+
try {
|
|
69
|
+
this.persistToken(this.cfg.space, old, token);
|
|
70
|
+
}
|
|
71
|
+
catch { }
|
|
72
|
+
}
|
|
56
73
|
async loopbackFallback() {
|
|
57
74
|
let u;
|
|
58
75
|
try {
|
|
@@ -217,6 +234,7 @@ export class PantheonClient {
|
|
|
217
234
|
case "welcome":
|
|
218
235
|
this.connected = true;
|
|
219
236
|
this.pending = null;
|
|
237
|
+
this.refusedCode = null;
|
|
220
238
|
this.session = msg.session;
|
|
221
239
|
this.everConnected = true;
|
|
222
240
|
this.head = msg.seq;
|
|
@@ -228,7 +246,10 @@ export class PantheonClient {
|
|
|
228
246
|
this.readSeq = msg.seq;
|
|
229
247
|
this.everReadMarked = true;
|
|
230
248
|
if (msg.token) {
|
|
231
|
-
this.
|
|
249
|
+
if (this.enroll)
|
|
250
|
+
this.enrolledToken = msg.token;
|
|
251
|
+
else
|
|
252
|
+
this.adoptToken(msg.token);
|
|
232
253
|
this.cfg.token = msg.token;
|
|
233
254
|
}
|
|
234
255
|
if (msg.space_id) {
|
|
@@ -250,6 +271,12 @@ export class PantheonClient {
|
|
|
250
271
|
resolve();
|
|
251
272
|
}
|
|
252
273
|
break;
|
|
274
|
+
case "token":
|
|
275
|
+
if (typeof msg.token === "string" && msg.token) {
|
|
276
|
+
this.adoptToken(msg.token);
|
|
277
|
+
this.cfg.token = msg.token;
|
|
278
|
+
}
|
|
279
|
+
break;
|
|
253
280
|
case "repos":
|
|
254
281
|
this.repos = msg.repos;
|
|
255
282
|
this.adoptKnownRepo();
|
|
@@ -285,6 +312,12 @@ export class PantheonClient {
|
|
|
285
312
|
}
|
|
286
313
|
case "error": {
|
|
287
314
|
this.lastError = `${msg.code}: ${msg.message}`;
|
|
315
|
+
if (!msg.cid) {
|
|
316
|
+
const code = ["REVOKED", "KEY_MISMATCH", "BAD_TOKEN"].includes(msg.code) ? msg.code : null;
|
|
317
|
+
if (code && !this.refusedCode)
|
|
318
|
+
this.refusedSince = Date.now();
|
|
319
|
+
this.refusedCode = code;
|
|
320
|
+
}
|
|
288
321
|
if (msg.code === "SPACE_DELETED")
|
|
289
322
|
this.closed = true;
|
|
290
323
|
const fn = msg.cid ? this.acks.get(msg.cid) : undefined;
|
|
@@ -325,8 +358,10 @@ export class PantheonClient {
|
|
|
325
358
|
}
|
|
326
359
|
reject(new Error(this.lastError ?? "relay connection failed"));
|
|
327
360
|
}
|
|
361
|
+
const slow = !!this.refused && Date.now() - this.refusedSince >= PantheonClient.REFUSED_GRACE_MS;
|
|
362
|
+
const wait = slow ? PantheonClient.REFUSED_RETRY_MS : Math.round(this.backoff * (0.5 + Math.random()));
|
|
328
363
|
setTimeout(() => { if (!this.closed)
|
|
329
|
-
this.connect().catch(() => { }); },
|
|
364
|
+
this.connect().catch(() => { }); }, wait);
|
|
330
365
|
this.backoff = Math.min(this.backoff * 2, 15_000);
|
|
331
366
|
};
|
|
332
367
|
ws.on("close", down);
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import { execFileSync } from "node:child_process";
|
|
2
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
3
|
+
import { platform } from "node:os";
|
|
4
|
+
import { parseRepoCommands, REPO_COMMANDS_FILE } from "./agents.js";
|
|
5
|
+
import { loadPersonas } from "./persona.js";
|
|
6
|
+
import { plistPath, existingUnitPath } from "./service.js";
|
|
7
|
+
import { loadPolicy } from "./worker-policy.js";
|
|
8
|
+
export function workerServiceAllowsBash(plat = platform()) {
|
|
9
|
+
try {
|
|
10
|
+
if (plat === "darwin") {
|
|
11
|
+
const p = plistPath("worker");
|
|
12
|
+
if (!existsSync(p))
|
|
13
|
+
return null;
|
|
14
|
+
const xml = readFileSync(p, "utf8");
|
|
15
|
+
const args = [...(/<key>ProgramArguments<\/key>\s*<array>([\s\S]*?)<\/array>/.exec(xml)?.[1] ?? "").matchAll(/<string>([^<]*)<\/string>/g)].map((m) => m[1]);
|
|
16
|
+
return args.includes("--allow-bash");
|
|
17
|
+
}
|
|
18
|
+
if (plat === "win32")
|
|
19
|
+
return null;
|
|
20
|
+
const u = existingUnitPath("worker");
|
|
21
|
+
if (!u)
|
|
22
|
+
return null;
|
|
23
|
+
const exec = /^ExecStart=(.*)$/m.exec(readFileSync(u, "utf8"))?.[1] ?? "";
|
|
24
|
+
return /(^|\s|")--allow-bash("|\s|$)/.test(exec);
|
|
25
|
+
}
|
|
26
|
+
catch {
|
|
27
|
+
return null;
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
export function commandState(o = {}) {
|
|
31
|
+
const allowBash = o.allowBash ?? workerServiceAllowsBash(o.plat) ?? false;
|
|
32
|
+
let grant = null;
|
|
33
|
+
try {
|
|
34
|
+
grant = loadPolicy(o.home).grants.run_commands ?? null;
|
|
35
|
+
}
|
|
36
|
+
catch { }
|
|
37
|
+
let personasOff = [];
|
|
38
|
+
try {
|
|
39
|
+
personasOff = loadPersonas(o.project ?? process.cwd(), o.home).filter((p) => p.allowBash === false).map((p) => p.name);
|
|
40
|
+
}
|
|
41
|
+
catch { }
|
|
42
|
+
const level = allowBash || grant?.mode === "always" ? "always" : grant ? "once" : "off";
|
|
43
|
+
const except = personasOff.length ? `, except runs handled by ${personasOff.length === 1 ? "persona" : "personas"} ${personasOff.join(", ")} (allow_bash: false)` : "";
|
|
44
|
+
const summary = level === "off"
|
|
45
|
+
? "off — woken runs edit files only; a run that needs more asks here (pantheon policy grant run-commands)"
|
|
46
|
+
: allowBash
|
|
47
|
+
? `on — the worker runs with --allow-bash${grant ? ", and run-commands is granted here too" : ""}${except}`
|
|
48
|
+
: grant.mode === "always"
|
|
49
|
+
? `on — run-commands granted on this machine (${grant.by}, ${new Date(grant.at).toLocaleDateString()})${except}`
|
|
50
|
+
: `on for the next run only — run-commands granted once on this machine${except}`;
|
|
51
|
+
return { level, allowBash, grant, personasOff, summary };
|
|
52
|
+
}
|
|
53
|
+
export function repoCommandsAtBase(project, base) {
|
|
54
|
+
const git = (...a) => execFileSync("git", ["-C", project, ...a], { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"], timeout: 5_000 }).trim();
|
|
55
|
+
const exists = (ref) => { try {
|
|
56
|
+
git("rev-parse", "--verify", "--quiet", `${ref}^{commit}`);
|
|
57
|
+
return true;
|
|
58
|
+
}
|
|
59
|
+
catch {
|
|
60
|
+
return false;
|
|
61
|
+
} };
|
|
62
|
+
let remoteHead = null;
|
|
63
|
+
try {
|
|
64
|
+
remoteHead = git("symbolic-ref", "--quiet", "--short", "refs/remotes/origin/HEAD") || null;
|
|
65
|
+
}
|
|
66
|
+
catch { }
|
|
67
|
+
const ref = [base ? `origin/${base}` : null, base ?? null, remoteHead, "HEAD"].filter((r) => !!r).find(exists) ?? null;
|
|
68
|
+
if (!ref)
|
|
69
|
+
return { ref: null, ok: [], refused: [] };
|
|
70
|
+
let text = "";
|
|
71
|
+
try {
|
|
72
|
+
text = git("show", `${ref}:${REPO_COMMANDS_FILE}`);
|
|
73
|
+
}
|
|
74
|
+
catch {
|
|
75
|
+
return { ref, ok: [], refused: [] };
|
|
76
|
+
}
|
|
77
|
+
return { ref, ...parseRepoCommands(text) };
|
|
78
|
+
}
|
|
79
|
+
//# sourceMappingURL=command-state.js.map
|
package/dist/config.js
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
|
-
import { readFileSync,
|
|
1
|
+
import { readFileSync, mkdirSync, existsSync, openSync, writeSync, fsyncSync, closeSync, renameSync, rmSync, statSync, chmodSync, realpathSync } from "node:fs";
|
|
2
2
|
import { execFileSync } from "node:child_process";
|
|
3
|
-
import { createHash } from "node:crypto";
|
|
3
|
+
import { createHash, randomBytes } from "node:crypto";
|
|
4
4
|
import { homedir } from "node:os";
|
|
5
|
-
import { join, resolve } from "node:path";
|
|
5
|
+
import { join, resolve, sep } from "node:path";
|
|
6
|
+
import { VERSION } from "./version.js";
|
|
6
7
|
export const CONFIG_DIR = process.env.PANTHEON_HOME ?? join(homedir(), ".pantheon");
|
|
7
8
|
export const CONFIG_PATH = join(CONFIG_DIR, "config.json");
|
|
8
9
|
export const DEFAULT_RELAY = process.env.PANTHEON_DEFAULT_RELAY
|
|
@@ -60,13 +61,34 @@ function sameRepo(host, path) {
|
|
|
60
61
|
return [host, path];
|
|
61
62
|
}
|
|
62
63
|
const repoCache = new Map();
|
|
64
|
+
const gitUnanswered = new Set();
|
|
65
|
+
export function repoUnknown(dir = process.cwd()) {
|
|
66
|
+
const key = resolve(dir);
|
|
67
|
+
if (!gitUnanswered.has(key))
|
|
68
|
+
return false;
|
|
69
|
+
for (let d = key;; d = resolve(d, "..")) {
|
|
70
|
+
if (existsSync(join(d, ".git")))
|
|
71
|
+
return true;
|
|
72
|
+
if (resolve(d, "..") === d)
|
|
73
|
+
return false;
|
|
74
|
+
}
|
|
75
|
+
}
|
|
63
76
|
export function repoInfo(dir = process.cwd()) {
|
|
64
77
|
const key = resolve(dir);
|
|
65
78
|
if (repoCache.has(key))
|
|
66
79
|
return repoCache.get(key);
|
|
67
80
|
let info = null;
|
|
68
81
|
try {
|
|
69
|
-
|
|
82
|
+
let root;
|
|
83
|
+
try {
|
|
84
|
+
root = execFileSync("git", ["-C", key, "rev-parse", "--show-toplevel"], { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"], timeout: 10_000 }).trim();
|
|
85
|
+
}
|
|
86
|
+
catch (e) {
|
|
87
|
+
if (e.status !== 128)
|
|
88
|
+
gitUnanswered.add(key);
|
|
89
|
+
throw e;
|
|
90
|
+
}
|
|
91
|
+
gitUnanswered.delete(key);
|
|
70
92
|
let origin = null;
|
|
71
93
|
try {
|
|
72
94
|
origin = normalizeOrigin(execFileSync("git", ["-C", root, "remote", "get-url", "origin"], { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"], timeout: 3000 }));
|
|
@@ -103,7 +125,7 @@ export function migrate(raw) {
|
|
|
103
125
|
export function loadAll() {
|
|
104
126
|
if (!existsSync(CONFIG_PATH))
|
|
105
127
|
return null;
|
|
106
|
-
return migrate(JSON.parse(readFileSync(CONFIG_PATH, "utf8")));
|
|
128
|
+
return migrate(JSON.parse(readFileSync(CONFIG_PATH, "utf8").replace(/^\uFEFF/, "")));
|
|
107
129
|
}
|
|
108
130
|
export function saveAll(f) {
|
|
109
131
|
const out = { ...f, version: 2, spaces: f.spaces ?? {}, bindings: f.bindings ?? {} };
|
|
@@ -114,10 +136,113 @@ export function saveAll(f) {
|
|
|
114
136
|
const def = out.default_space ? out.spaces[out.default_space] : undefined;
|
|
115
137
|
out.space = def?.name ?? "";
|
|
116
138
|
out.token = def?.token ?? "";
|
|
117
|
-
|
|
118
|
-
writeFileSync(CONFIG_PATH, JSON.stringify(out, null, 2) + "\n", { mode: 0o600 });
|
|
139
|
+
writeConfigAtomic(JSON.stringify(out, null, 2) + "\n");
|
|
119
140
|
return CONFIG_PATH;
|
|
120
141
|
}
|
|
142
|
+
function privateDir() {
|
|
143
|
+
mkdirSync(CONFIG_DIR, { recursive: true, mode: 0o700 });
|
|
144
|
+
if (process.platform === "win32")
|
|
145
|
+
return;
|
|
146
|
+
try {
|
|
147
|
+
if ((statSync(CONFIG_DIR).mode & 0o077) !== 0)
|
|
148
|
+
chmodSync(CONFIG_DIR, 0o700);
|
|
149
|
+
}
|
|
150
|
+
catch { }
|
|
151
|
+
}
|
|
152
|
+
function writeConfigAtomic(data) {
|
|
153
|
+
privateDir();
|
|
154
|
+
const tmp = `${CONFIG_PATH}.${process.pid}.${randomBytes(4).toString("hex")}.tmp`;
|
|
155
|
+
try {
|
|
156
|
+
const fd = openSync(tmp, "w", 0o600);
|
|
157
|
+
try {
|
|
158
|
+
writeSync(fd, data);
|
|
159
|
+
fsyncSync(fd);
|
|
160
|
+
}
|
|
161
|
+
finally {
|
|
162
|
+
closeSync(fd);
|
|
163
|
+
}
|
|
164
|
+
if (process.platform !== "win32")
|
|
165
|
+
chmodSync(tmp, 0o600);
|
|
166
|
+
for (let i = 0;; i++) {
|
|
167
|
+
try {
|
|
168
|
+
renameSync(tmp, CONFIG_PATH);
|
|
169
|
+
break;
|
|
170
|
+
}
|
|
171
|
+
catch (e) {
|
|
172
|
+
if (i >= 20 || !["EPERM", "EACCES", "EBUSY"].includes(e.code ?? ""))
|
|
173
|
+
throw e;
|
|
174
|
+
sleepSync(10);
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
catch (e) {
|
|
179
|
+
try {
|
|
180
|
+
rmSync(tmp, { force: true });
|
|
181
|
+
}
|
|
182
|
+
catch { }
|
|
183
|
+
throw e;
|
|
184
|
+
}
|
|
185
|
+
let dfd = null;
|
|
186
|
+
try {
|
|
187
|
+
dfd = openSync(CONFIG_DIR, "r");
|
|
188
|
+
fsyncSync(dfd);
|
|
189
|
+
}
|
|
190
|
+
catch { }
|
|
191
|
+
finally {
|
|
192
|
+
if (dfd !== null)
|
|
193
|
+
try {
|
|
194
|
+
closeSync(dfd);
|
|
195
|
+
}
|
|
196
|
+
catch { }
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
const CONFIG_LOCK_STALE_MS = 5_000;
|
|
200
|
+
const CONFIG_LOCK_WAIT_MS = 15_000;
|
|
201
|
+
const sleepSync = (ms) => { try {
|
|
202
|
+
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
|
|
203
|
+
}
|
|
204
|
+
catch { } };
|
|
205
|
+
export function withConfigLock(fn) {
|
|
206
|
+
privateDir();
|
|
207
|
+
const lock = `${CONFIG_PATH}.lock`;
|
|
208
|
+
const t0 = Date.now();
|
|
209
|
+
let fd = null;
|
|
210
|
+
while (fd === null) {
|
|
211
|
+
try {
|
|
212
|
+
fd = openSync(lock, "wx", 0o600);
|
|
213
|
+
}
|
|
214
|
+
catch (e) {
|
|
215
|
+
const code = e.code;
|
|
216
|
+
if (code !== "EEXIST" && !(process.platform === "win32" && (code === "EPERM" || code === "EACCES" || code === "EBUSY")))
|
|
217
|
+
throw e;
|
|
218
|
+
try {
|
|
219
|
+
if (Date.now() - statSync(lock).mtimeMs > CONFIG_LOCK_STALE_MS) {
|
|
220
|
+
rmSync(lock, { force: true });
|
|
221
|
+
continue;
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
catch {
|
|
225
|
+
continue;
|
|
226
|
+
}
|
|
227
|
+
if (Date.now() - t0 > CONFIG_LOCK_WAIT_MS)
|
|
228
|
+
throw new Error(`config.json is locked by another pantheon process (${lock})`);
|
|
229
|
+
sleepSync(2 + Math.floor(Math.random() * 8));
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
try {
|
|
233
|
+
return fn();
|
|
234
|
+
}
|
|
235
|
+
finally {
|
|
236
|
+
try {
|
|
237
|
+
closeSync(fd);
|
|
238
|
+
}
|
|
239
|
+
catch { }
|
|
240
|
+
try {
|
|
241
|
+
rmSync(lock, { force: true });
|
|
242
|
+
}
|
|
243
|
+
catch { }
|
|
244
|
+
}
|
|
245
|
+
}
|
|
121
246
|
export function configFor(f, m, repo) { return view(f, m, repo); }
|
|
122
247
|
function view(f, m, repo) {
|
|
123
248
|
return {
|
|
@@ -134,14 +259,19 @@ function cwdRepo(repo) {
|
|
|
134
259
|
}
|
|
135
260
|
function envView(onDisk) {
|
|
136
261
|
const e = process.env;
|
|
137
|
-
if (!(e.PANTHEON_RELAY && e.PANTHEON_SPACE && e.
|
|
262
|
+
if (!(e.PANTHEON_RELAY && e.PANTHEON_SPACE && e.PANTHEON_MEMBER))
|
|
263
|
+
return null;
|
|
264
|
+
const token = e.PANTHEON_TOKEN ?? (onDisk?.member === e.PANTHEON_MEMBER
|
|
265
|
+
? Object.values(onDisk.spaces ?? {}).find((m) => (e.PANTHEON_SPACE_ID && m.id === e.PANTHEON_SPACE_ID) || (m.relay === e.PANTHEON_RELAY && m.name === e.PANTHEON_SPACE))?.token
|
|
266
|
+
: undefined);
|
|
267
|
+
if (!token)
|
|
138
268
|
return null;
|
|
139
269
|
const pub = e.PANTHEON_PUBKEY ?? onDisk?.publicKey;
|
|
140
270
|
const priv = e.PANTHEON_PRIVKEY ?? onDisk?.privateKey;
|
|
141
271
|
if (!pub || !priv)
|
|
142
272
|
return null;
|
|
143
273
|
return {
|
|
144
|
-
relay: e.PANTHEON_RELAY, space: e.PANTHEON_SPACE, token
|
|
274
|
+
relay: e.PANTHEON_RELAY, space: e.PANTHEON_SPACE, token, member: e.PANTHEON_MEMBER,
|
|
145
275
|
publicKey: pub, privateKey: priv, display: e.PANTHEON_DISPLAY, agent: e.PANTHEON_AGENT,
|
|
146
276
|
...(e.PANTHEON_SPACE_ID ? { spaceId: e.PANTHEON_SPACE_ID } : {}),
|
|
147
277
|
...(e.PANTHEON_REPO ? { repo: e.PANTHEON_REPO } : {}),
|
|
@@ -166,6 +296,17 @@ export function resolveFor(cwd = process.cwd(), opts = {}) {
|
|
|
166
296
|
return { cfg: null, reason: "stale", memberships: n, binding: { ...b, fingerprint: repo.fingerprint }, repo };
|
|
167
297
|
}
|
|
168
298
|
}
|
|
299
|
+
if (!repo) {
|
|
300
|
+
const fb = folderBinding(f, cwd);
|
|
301
|
+
if (fb) {
|
|
302
|
+
const m = spaces[fb.space];
|
|
303
|
+
if (m)
|
|
304
|
+
return { cfg: applyAgentEnv(view(f, m, null)), reason: "binding", memberships: n, binding: { ...fb, fingerprint: fb.key }, repo };
|
|
305
|
+
return { cfg: null, reason: "stale", memberships: n, binding: { ...fb, fingerprint: fb.key }, repo };
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
if (!repo && n > 1 && repoUnknown(cwd))
|
|
309
|
+
return { cfg: null, reason: "unbound", memberships: n, repo, repoUnknown: true };
|
|
169
310
|
if (n === 1) {
|
|
170
311
|
const m = Object.values(spaces)[0];
|
|
171
312
|
return { cfg: applyAgentEnv({ ...view(f, m, null), ...cwdRepo(repo) }), reason: "single", memberships: 1, repo };
|
|
@@ -255,12 +396,40 @@ export function save(cfg) {
|
|
|
255
396
|
}
|
|
256
397
|
else {
|
|
257
398
|
const id = cfg.spaceId ?? legacySpaceId(cfg.relay, cfg.space);
|
|
258
|
-
f.spaces[id] = { id, name: cfg.space, relay: cfg.relay, token: cfg.token, joined_at: Date.now() };
|
|
399
|
+
f.spaces[id] = { id, name: cfg.space, relay: cfg.relay, token: cfg.token, joined_at: Date.now(), since: VERSION };
|
|
259
400
|
f.default_space = id;
|
|
260
401
|
}
|
|
261
402
|
}
|
|
262
403
|
return saveAll(f);
|
|
263
404
|
}
|
|
405
|
+
export function updateMembershipToken(space, oldToken, newToken) {
|
|
406
|
+
if (!space || !oldToken || !newToken || oldToken === newToken)
|
|
407
|
+
return false;
|
|
408
|
+
if (!existsSync(CONFIG_PATH))
|
|
409
|
+
return false;
|
|
410
|
+
return withConfigLock(() => {
|
|
411
|
+
let f;
|
|
412
|
+
try {
|
|
413
|
+
f = loadAll();
|
|
414
|
+
}
|
|
415
|
+
catch {
|
|
416
|
+
return false;
|
|
417
|
+
}
|
|
418
|
+
if (!f)
|
|
419
|
+
return false;
|
|
420
|
+
let hit = false;
|
|
421
|
+
for (const m of Object.values(f.spaces ?? {})) {
|
|
422
|
+
if (m.name === space && m.token === oldToken) {
|
|
423
|
+
m.token = newToken;
|
|
424
|
+
hit = true;
|
|
425
|
+
}
|
|
426
|
+
}
|
|
427
|
+
if (!hit)
|
|
428
|
+
return false;
|
|
429
|
+
saveAll(f);
|
|
430
|
+
return true;
|
|
431
|
+
});
|
|
432
|
+
}
|
|
264
433
|
function adoptId(f, oldId, newId) {
|
|
265
434
|
for (const b of Object.values(f.bindings ?? {}))
|
|
266
435
|
if (b.space === oldId)
|
|
@@ -268,6 +437,38 @@ function adoptId(f, oldId, newId) {
|
|
|
268
437
|
if (f.default_space === oldId)
|
|
269
438
|
f.default_space = newId;
|
|
270
439
|
}
|
|
440
|
+
export function bindFolder(dir, spaceId) {
|
|
441
|
+
const f = loadAll();
|
|
442
|
+
if (!f)
|
|
443
|
+
throw new Error("not configured");
|
|
444
|
+
f.bindings ??= {};
|
|
445
|
+
const path = canonDir(dir);
|
|
446
|
+
const key = `d_${sha16(path)}`;
|
|
447
|
+
const prev = f.bindings[key] ?? null;
|
|
448
|
+
f.bindings[key] = { space: spaceId, path, origin: null, bound_at: Date.now() };
|
|
449
|
+
saveAll(f);
|
|
450
|
+
return { key, replaced: prev && prev.space !== spaceId ? prev : null };
|
|
451
|
+
}
|
|
452
|
+
function canonDir(dir) {
|
|
453
|
+
try {
|
|
454
|
+
return realpathSync.native(dir);
|
|
455
|
+
}
|
|
456
|
+
catch {
|
|
457
|
+
return resolve(dir);
|
|
458
|
+
}
|
|
459
|
+
}
|
|
460
|
+
export function folderBinding(f, dir) {
|
|
461
|
+
const here = canonDir(dir);
|
|
462
|
+
let best = null;
|
|
463
|
+
for (const [key, b] of Object.entries(f.bindings ?? {})) {
|
|
464
|
+
if (!key.startsWith("d_"))
|
|
465
|
+
continue;
|
|
466
|
+
const inside = here === b.path || here.startsWith(b.path.endsWith(sep) ? b.path : b.path + sep);
|
|
467
|
+
if (inside && (!best || b.path.length > best.path.length))
|
|
468
|
+
best = { ...b, key };
|
|
469
|
+
}
|
|
470
|
+
return best;
|
|
471
|
+
}
|
|
271
472
|
export function bindRepo(repo, spaceId) {
|
|
272
473
|
const f = loadAll();
|
|
273
474
|
if (!f)
|