@kendoo.agentdesk/agentdesk 0.26.0 → 0.28.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/CHANGELOG.md +32 -1
- package/bin/agentdesk.mjs +35 -45
- package/cli/agents.mjs +4 -256
- package/cli/bootstrap.mjs +40 -59
- package/cli/config.mjs +29 -4
- package/cli/daemon.mjs +148 -66
- package/cli/dotenv.mjs +96 -13
- package/cli/engine/agents/index.mjs +151 -0
- package/cli/engine/claude-auth.mjs +72 -0
- package/cli/engine/env.mjs +56 -0
- package/cli/engine/events.mjs +214 -0
- package/cli/engine/hooks.mjs +112 -0
- package/cli/engine/phases/EXECUTION.md +45 -0
- package/cli/engine/phases/INTAKE.md +34 -0
- package/cli/engine/phases/PLAN.md +26 -0
- package/cli/engine/phases/REVIEW.md +21 -0
- package/cli/engine/phases/SOLO.md +115 -0
- package/cli/engine/phases/SUMMARY.md +23 -0
- package/cli/engine/prompts.mjs +181 -0
- package/cli/engine/query.mjs +63 -0
- package/cli/engine/schemas.mjs +180 -0
- package/cli/engine/session.mjs +285 -0
- package/cli/engine/spawn.mjs +83 -0
- package/cli/engine/tracker/github.md +19 -0
- package/cli/engine/tracker/jira.md +23 -0
- package/cli/engine/tracker/linear.md +24 -0
- package/cli/engine/verdict.mjs +83 -0
- package/cli/init.mjs +295 -149
- package/cli/login.mjs +52 -6
- package/cli/phase-loop.mjs +78 -0
- package/cli/proc.mjs +131 -0
- package/cli/project-key.mjs +56 -0
- package/cli/projects.mjs +41 -6
- package/cli/prompt.mjs +9 -503
- package/cli/prompts.mjs +20 -1
- package/cli/security-check.mjs +1 -1
- package/cli/session-isolation.mjs +65 -9
- package/cli/session-sandbox.mjs +13 -1
- package/cli/setup-helpers.mjs +83 -36
- package/cli/team.mjs +41 -34
- package/cli/tracker-check.mjs +12 -2
- package/cli/tracker-project.mjs +93 -0
- package/cli/update-check.mjs +62 -0
- package/package.json +12 -3
- package/cli/orchestrator.mjs +0 -461
- package/cli/stream-parser.mjs +0 -216
- package/prompts/phased.md +0 -549
- package/prompts/team.md +0 -505
package/cli/login.mjs
CHANGED
|
@@ -17,13 +17,42 @@ function getEnvApiKey() {
|
|
|
17
17
|
}
|
|
18
18
|
|
|
19
19
|
export function getStoredApiKey() {
|
|
20
|
+
return getStoredCredentials()?.apiKey || null;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function getStoredCredentials() {
|
|
20
24
|
if (!existsSync(CREDENTIALS_PATH)) return null;
|
|
21
25
|
try {
|
|
22
|
-
|
|
23
|
-
|
|
26
|
+
return JSON.parse(readFileSync(CREDENTIALS_PATH, "utf-8"));
|
|
27
|
+
} catch { return null; }
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// AD-64: resolve which account an API key belongs to, so local project
|
|
31
|
+
// registry entries can be scoped per account.
|
|
32
|
+
async function fetchAccountIdentity(apiKey) {
|
|
33
|
+
try {
|
|
34
|
+
const res = await fetch(`${AGENTDESK_SERVER}/api/me`, {
|
|
35
|
+
headers: { "x-api-key": apiKey },
|
|
36
|
+
signal: AbortSignal.timeout(5000),
|
|
37
|
+
});
|
|
38
|
+
if (!res.ok) return null;
|
|
39
|
+
const me = await res.json();
|
|
40
|
+
return me?.id ? { accountId: me.id, email: me.email || null } : null;
|
|
24
41
|
} catch { return null; }
|
|
25
42
|
}
|
|
26
43
|
|
|
44
|
+
// Backfill accountId into credentials saved by pre-AD-64 versions.
|
|
45
|
+
// Returns the (possibly updated) credentials object.
|
|
46
|
+
export async function ensureAccountIdentity() {
|
|
47
|
+
const creds = getStoredCredentials();
|
|
48
|
+
if (!creds?.apiKey || creds.accountId) return creds;
|
|
49
|
+
const identity = await fetchAccountIdentity(creds.apiKey);
|
|
50
|
+
if (!identity) return creds;
|
|
51
|
+
const updated = { ...creds, ...identity };
|
|
52
|
+
writeFileSync(CREDENTIALS_PATH, JSON.stringify(updated, null, 2), { mode: 0o600 });
|
|
53
|
+
return updated;
|
|
54
|
+
}
|
|
55
|
+
|
|
27
56
|
export async function runLogin() {
|
|
28
57
|
console.log("");
|
|
29
58
|
console.log(" AgentDesk — Login");
|
|
@@ -120,7 +149,16 @@ export async function runLogin() {
|
|
|
120
149
|
console.log(" agentdesk team TASK-123");
|
|
121
150
|
console.log("");
|
|
122
151
|
|
|
123
|
-
|
|
152
|
+
// AD-64: attach the account identity so the project registry can be
|
|
153
|
+
// scoped per account. Best-effort — login still succeeds without it.
|
|
154
|
+
fetchAccountIdentity(apiKey)
|
|
155
|
+
.then(identity => {
|
|
156
|
+
if (identity) {
|
|
157
|
+
writeFileSync(CREDENTIALS_PATH, JSON.stringify({ apiKey, name, ...identity, savedAt: Date.now() }, null, 2), { mode: 0o600 });
|
|
158
|
+
}
|
|
159
|
+
})
|
|
160
|
+
.catch(() => {})
|
|
161
|
+
.finally(() => { server.close(); process.exit(0); });
|
|
124
162
|
} else {
|
|
125
163
|
res.writeHead(400, { "Content-Type": "text/html" });
|
|
126
164
|
res.end("<html><body><h2>Login failed. No API key received.</h2></body></html>");
|
|
@@ -147,11 +185,19 @@ export async function runLogin() {
|
|
|
147
185
|
console.log("");
|
|
148
186
|
console.log(" Waiting for authentication...");
|
|
149
187
|
|
|
150
|
-
// Open browser (use execFile to avoid shell injection)
|
|
188
|
+
// Open browser (use execFile to avoid shell injection).
|
|
189
|
+
//
|
|
190
|
+
// On Windows `start` is a cmd.exe builtin, not an executable, so
|
|
191
|
+
// execFile("start", …) failed silently and the browser never opened. Go
|
|
192
|
+
// through cmd, with the empty "" title argument `start` requires when the
|
|
193
|
+
// next argument is a quoted URL.
|
|
151
194
|
const { execFile } = await import("child_process");
|
|
152
195
|
const platform = process.platform;
|
|
153
|
-
|
|
154
|
-
|
|
196
|
+
if (platform === "win32") {
|
|
197
|
+
execFile("cmd", ["/c", "start", "", loginUrl], () => {});
|
|
198
|
+
} else {
|
|
199
|
+
execFile(platform === "darwin" ? "open" : "xdg-open", [loginUrl], () => {});
|
|
200
|
+
}
|
|
155
201
|
|
|
156
202
|
// Timeout after 5 minutes
|
|
157
203
|
setTimeout(() => {
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
// Pure decision logic for the phased orchestrator loop.
|
|
2
|
+
//
|
|
3
|
+
// Extracted from orchestrator.mjs so the control flow that decides "did this
|
|
4
|
+
// session actually succeed" is unit-testable without spawning Claude. Every
|
|
5
|
+
// function here is pure or filesystem-read-only; nothing spawns, emits, or
|
|
6
|
+
// mutates session state.
|
|
7
|
+
|
|
8
|
+
import { existsSync, readFileSync, renameSync, unlinkSync } from "fs";
|
|
9
|
+
|
|
10
|
+
export const PHASES = ["INTAKE", "PLAN", "EXECUTION", "REVIEW", "SUMMARY"];
|
|
11
|
+
|
|
12
|
+
// One execution redo after a failed review, then the session ends unresolved.
|
|
13
|
+
export const MAX_REVIEW_RETRIES = 1;
|
|
14
|
+
|
|
15
|
+
// Absolute ceiling on phase runs. The review loop re-enqueues phases, so a bug
|
|
16
|
+
// in verdict parsing must not be able to spin forever burning tokens. With
|
|
17
|
+
// MAX_REVIEW_RETRIES=1 a legitimate run tops out at 7 (5 + EXECUTION + REVIEW).
|
|
18
|
+
export const MAX_PHASE_RUNS = 10;
|
|
19
|
+
|
|
20
|
+
// Classify the first line of `.agentdesk/review-verdict.md`.
|
|
21
|
+
//
|
|
22
|
+
// FAIL CLOSED: anything that is not an explicit APPROVED is treated as
|
|
23
|
+
// unapproved. A REVIEW phase that crashed, hit a limit, or simply forgot to
|
|
24
|
+
// write the file must never be indistinguishable from one that passed.
|
|
25
|
+
export function reviewOutcome(raw) {
|
|
26
|
+
if (raw == null) return "MISSING";
|
|
27
|
+
const firstLine = String(raw).trim().split(/\r?\n/)[0]?.trim().toUpperCase() ?? "";
|
|
28
|
+
if (!firstLine) return "MISSING";
|
|
29
|
+
if (/^NEEDS_MORE_WORK\b/.test(firstLine)) return "NEEDS_MORE_WORK";
|
|
30
|
+
if (/^APPROVED\b/.test(firstLine)) return "APPROVED";
|
|
31
|
+
// Unrecognised content is not approval.
|
|
32
|
+
return "MISSING";
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function readReviewVerdict(path) {
|
|
36
|
+
try {
|
|
37
|
+
if (!existsSync(path)) return "MISSING";
|
|
38
|
+
return reviewOutcome(readFileSync(path, "utf-8"));
|
|
39
|
+
} catch {
|
|
40
|
+
return "MISSING";
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// A phase run failed if the process exited non-zero, full stop.
|
|
45
|
+
//
|
|
46
|
+
// This used to be gated on `!existsSync(session-memory.md)`, which made it
|
|
47
|
+
// dead code: INTAKE writes that file, so from phase one onward no crash was
|
|
48
|
+
// ever detected and every broken session reported "complete".
|
|
49
|
+
export function phaseFailed({ exitCode, aborted }) {
|
|
50
|
+
if (aborted) return false; // cancellation is not a crash — reported separately
|
|
51
|
+
return exitCode !== 0;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// Terminal status for the session, in precedence order.
|
|
55
|
+
export function finalStatus({ aborted, crashed, reviewResolved }) {
|
|
56
|
+
if (aborted) return "stopped";
|
|
57
|
+
if (crashed) return "handoff";
|
|
58
|
+
// Review never reached APPROVED — a human has to pick this up. Reuses the
|
|
59
|
+
// existing "handoff" status rather than inventing one the dashboard and
|
|
60
|
+
// server store would not recognise.
|
|
61
|
+
if (!reviewResolved) return "handoff";
|
|
62
|
+
return "complete";
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// Move a previous run's session memory aside so it cannot leak into this one.
|
|
66
|
+
//
|
|
67
|
+
// Kept (as .prev.md) rather than deleted: it is the only artifact explaining
|
|
68
|
+
// what a crashed prior session believed it had done.
|
|
69
|
+
export function archiveStaleMemory(sessionMemoryPath) {
|
|
70
|
+
try {
|
|
71
|
+
if (!existsSync(sessionMemoryPath)) return false;
|
|
72
|
+
renameSync(sessionMemoryPath, `${sessionMemoryPath.replace(/\.md$/, "")}.prev.md`);
|
|
73
|
+
return true;
|
|
74
|
+
} catch {
|
|
75
|
+
try { unlinkSync(sessionMemoryPath); return true; } catch {}
|
|
76
|
+
return false;
|
|
77
|
+
}
|
|
78
|
+
}
|
package/cli/proc.mjs
ADDED
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
// Process-tree termination for spawned Claude children.
|
|
2
|
+
//
|
|
3
|
+
// Why this exists: `claude` is not a leaf process. It spawns Bash
|
|
4
|
+
// grandchildren that write to the project working tree. Signalling only the
|
|
5
|
+
// direct child leaves those grandchildren running — which is precisely the
|
|
6
|
+
// state a cancel is meant to prevent. So children are spawned detached (their
|
|
7
|
+
// own process group) and torn down by group.
|
|
8
|
+
//
|
|
9
|
+
// Every spawned child is also tracked here so that an unexpected parent exit
|
|
10
|
+
// (SIGINT from the terminal, daemon shutdown) does not orphan a Claude process
|
|
11
|
+
// that keeps committing and pushing after the dashboard says the session is
|
|
12
|
+
// over. This matters more than usual because the scratch HOME is deleted on
|
|
13
|
+
// exit — an orphaned child would keep running with its credentials yanked
|
|
14
|
+
// out from under it, mid-push.
|
|
15
|
+
|
|
16
|
+
const live = new Set();
|
|
17
|
+
|
|
18
|
+
// Send `sig` to the child's whole process group, falling back to the single
|
|
19
|
+
// pid when the child was not detached or the group is already gone.
|
|
20
|
+
function signalGroup(pid, sig) {
|
|
21
|
+
try {
|
|
22
|
+
process.kill(-pid, sig);
|
|
23
|
+
return true;
|
|
24
|
+
} catch {}
|
|
25
|
+
try {
|
|
26
|
+
process.kill(pid, sig);
|
|
27
|
+
return true;
|
|
28
|
+
} catch {}
|
|
29
|
+
return false;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function hasExited(child) {
|
|
33
|
+
return !child || child.exitCode !== null || child.signalCode !== null;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// SIGTERM the tree, then SIGKILL anything still alive after `graceMs`.
|
|
37
|
+
// Returns true when a signal was actually delivered.
|
|
38
|
+
export function killTree(child, { graceMs = 5000 } = {}) {
|
|
39
|
+
if (hasExited(child) || !child.pid) return false;
|
|
40
|
+
|
|
41
|
+
const pid = child.pid;
|
|
42
|
+
const delivered = signalGroup(pid, "SIGTERM");
|
|
43
|
+
if (!delivered) return false;
|
|
44
|
+
|
|
45
|
+
const timer = setTimeout(() => {
|
|
46
|
+
if (!hasExited(child)) signalGroup(pid, "SIGKILL");
|
|
47
|
+
}, graceMs);
|
|
48
|
+
// Never let the force-kill timer hold the event loop open.
|
|
49
|
+
timer.unref?.();
|
|
50
|
+
child.once("close", () => clearTimeout(timer));
|
|
51
|
+
|
|
52
|
+
return true;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// Register a child for teardown-on-parent-exit. Returns an untrack function.
|
|
56
|
+
export function trackChild(child) {
|
|
57
|
+
if (!child) return () => {};
|
|
58
|
+
live.add(child);
|
|
59
|
+
const drop = () => live.delete(child);
|
|
60
|
+
child.once("close", drop);
|
|
61
|
+
return drop;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export function killAllTracked({ graceMs = 0 } = {}) {
|
|
65
|
+
for (const child of [...live]) {
|
|
66
|
+
live.delete(child);
|
|
67
|
+
// On a hard parent exit there is no time to wait out a grace period —
|
|
68
|
+
// the exit handler runs synchronously and the timer would never fire.
|
|
69
|
+
if (graceMs === 0) {
|
|
70
|
+
if (!hasExited(child) && child.pid) signalGroup(child.pid, "SIGKILL");
|
|
71
|
+
} else {
|
|
72
|
+
killTree(child, { graceMs });
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export function trackedCount() {
|
|
78
|
+
return live.size;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// Cleanups that must run before the process goes away — currently the scratch
|
|
82
|
+
// HOME teardown, which holds live credentials on disk.
|
|
83
|
+
//
|
|
84
|
+
// Centralised here so there is exactly one set of signal handlers. Registering
|
|
85
|
+
// them in several modules means whichever one calls process.exit() first wins
|
|
86
|
+
// and the others silently never run.
|
|
87
|
+
const terminators = new Set();
|
|
88
|
+
|
|
89
|
+
export function onTerminate(fn) {
|
|
90
|
+
terminators.add(fn);
|
|
91
|
+
return () => terminators.delete(fn);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function runTerminators() {
|
|
95
|
+
for (const fn of [...terminators]) {
|
|
96
|
+
terminators.delete(fn);
|
|
97
|
+
try { fn(); } catch {}
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
let installed = false;
|
|
102
|
+
// Idempotent: multiple orchestrator runs in one process share one set of hooks.
|
|
103
|
+
export function installExitGuards() {
|
|
104
|
+
if (installed) return;
|
|
105
|
+
installed = true;
|
|
106
|
+
|
|
107
|
+
process.once("exit", () => {
|
|
108
|
+
// Children first: they must not outlive the credentials they are using.
|
|
109
|
+
killAllTracked({ graceMs: 0 });
|
|
110
|
+
runTerminators();
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
for (const sig of ["SIGINT", "SIGTERM", "SIGHUP"]) {
|
|
114
|
+
process.on(sig, () => {
|
|
115
|
+
// Defer to the app's own handler when there is one: `agentdesk team`
|
|
116
|
+
// and the daemon both shut down gracefully on SIGINT (abort the run,
|
|
117
|
+
// report the real status, close the socket). Exiting from here would
|
|
118
|
+
// preempt that and leave the server showing a session that never ended.
|
|
119
|
+
//
|
|
120
|
+
// This listener is a last resort for the case where nothing else is
|
|
121
|
+
// handling the signal — without it, Node's default disposition
|
|
122
|
+
// terminates the process without running `exit` handlers at all, and
|
|
123
|
+
// every spawned Claude is orphaned.
|
|
124
|
+
if (process.listenerCount(sig) > 1) return;
|
|
125
|
+
|
|
126
|
+
killAllTracked({ graceMs: 0 });
|
|
127
|
+
runTerminators();
|
|
128
|
+
process.exit(sig === "SIGINT" ? 130 : 143);
|
|
129
|
+
});
|
|
130
|
+
}
|
|
131
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
// Project key derivation and collision handling for `agentdesk init`.
|
|
2
|
+
//
|
|
3
|
+
// A project key is the server-side id and the URL segment for a project. New
|
|
4
|
+
// mode derives it from the tracker team/project (e.g. Linear team "KEN" →
|
|
5
|
+
// "ken"), which reads well but is not unique: two repos mapped to the same
|
|
6
|
+
// team derive the same key, and the second `PUT /settings` silently
|
|
7
|
+
// overwrote the first repo's configuration. resolveProjectKey detects that
|
|
8
|
+
// and qualifies the key with the repo name instead.
|
|
9
|
+
//
|
|
10
|
+
// Pure functions; the caller fetches `existing` from the server.
|
|
11
|
+
|
|
12
|
+
export function slugify(s) {
|
|
13
|
+
return String(s ?? "").toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "");
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function deriveProjectKey({ trackerId, repoShort }) {
|
|
17
|
+
return slugify(trackerId || repoShort || "project");
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const norm = r => (r ? String(r).toLowerCase() : null);
|
|
21
|
+
|
|
22
|
+
// existing: [{ id, repo }] — every project on the account with its
|
|
23
|
+
// configured github.repo (null when unknown).
|
|
24
|
+
//
|
|
25
|
+
// A key is only a collision when it is taken by a project whose repo is
|
|
26
|
+
// known AND different from ours. Same repo means this IS our project being
|
|
27
|
+
// re-initialised (`init --force-full` to re-push settings), and reusing the
|
|
28
|
+
// key is the intended behaviour. Unknown repo on either side is treated as
|
|
29
|
+
// "ours" too: a project with no GitHub repo cannot run sessions, so there is
|
|
30
|
+
// nothing there to clobber.
|
|
31
|
+
export function resolveProjectKey({ baseKey, repo, repoShort, existing = [] }) {
|
|
32
|
+
const ours = norm(repo);
|
|
33
|
+
const byId = new Map(existing.map(p => [p.id, { id: p.id, repo: norm(p.repo) }]));
|
|
34
|
+
|
|
35
|
+
const takenByOtherRepo = key => {
|
|
36
|
+
const hit = byId.get(key);
|
|
37
|
+
if (!hit) return null;
|
|
38
|
+
if (!ours || !hit.repo || hit.repo === ours) return null;
|
|
39
|
+
return hit;
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
const first = takenByOtherRepo(baseKey);
|
|
43
|
+
if (!first) return { key: baseKey, renamed: false };
|
|
44
|
+
|
|
45
|
+
const qualified = slugify(`${baseKey}-${repoShort || "repo"}`);
|
|
46
|
+
if (!takenByOtherRepo(qualified)) {
|
|
47
|
+
return { key: qualified, renamed: true, collidedWith: first };
|
|
48
|
+
}
|
|
49
|
+
for (let n = 2; n < 100; n++) {
|
|
50
|
+
const candidate = `${qualified}-${n}`;
|
|
51
|
+
if (!takenByOtherRepo(candidate)) {
|
|
52
|
+
return { key: candidate, renamed: true, collidedWith: first };
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
return { key: `${qualified}-${Date.now()}`, renamed: true, collidedWith: first };
|
|
56
|
+
}
|
package/cli/projects.mjs
CHANGED
|
@@ -1,4 +1,9 @@
|
|
|
1
1
|
// Local project registry — tracks which projects have been initialized with `agentdesk init`
|
|
2
|
+
//
|
|
3
|
+
// AD-64: entries are tagged with the accountId they were registered under so
|
|
4
|
+
// the daemon can scope its project list to the logged-in account. Entries
|
|
5
|
+
// written by older versions have no accountId ("untagged") — the daemon
|
|
6
|
+
// claims them for the active account when the server confirms ownership.
|
|
2
7
|
|
|
3
8
|
import { existsSync, readFileSync, writeFileSync, mkdirSync } from "fs";
|
|
4
9
|
import { join } from "path";
|
|
@@ -6,7 +11,7 @@ import { join } from "path";
|
|
|
6
11
|
const CONFIG_DIR = join(process.env.HOME || process.env.USERPROFILE, ".agentdesk");
|
|
7
12
|
const PROJECTS_PATH = join(CONFIG_DIR, "projects.json");
|
|
8
13
|
|
|
9
|
-
|
|
14
|
+
function readRegistry() {
|
|
10
15
|
try {
|
|
11
16
|
if (!existsSync(PROJECTS_PATH)) return [];
|
|
12
17
|
const data = JSON.parse(readFileSync(PROJECTS_PATH, "utf-8"));
|
|
@@ -16,10 +21,28 @@ export function getRegisteredProjects() {
|
|
|
16
21
|
}
|
|
17
22
|
}
|
|
18
23
|
|
|
19
|
-
|
|
20
|
-
|
|
24
|
+
function writeRegistry(projects) {
|
|
25
|
+
if (!existsSync(CONFIG_DIR)) {
|
|
26
|
+
mkdirSync(CONFIG_DIR, { recursive: true, mode: 0o700 });
|
|
27
|
+
}
|
|
28
|
+
writeFileSync(PROJECTS_PATH, JSON.stringify({ projects }, null, 2) + "\n", { mode: 0o600 });
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// accountId: return only that account's entries plus legacy untagged ones
|
|
32
|
+
// (callers that can reach the server should claimLocalProjects() first so
|
|
33
|
+
// untagged entries get resolved rather than leaking across accounts).
|
|
34
|
+
export function getRegisteredProjects(accountId) {
|
|
35
|
+
const projects = readRegistry();
|
|
36
|
+
if (!accountId) return projects;
|
|
37
|
+
return projects.filter(p => !p.accountId || p.accountId === accountId);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function registerLocalProject(id, name, path, accountId) {
|
|
41
|
+
const projects = readRegistry();
|
|
21
42
|
const existing = projects.findIndex(p => p.id === id);
|
|
22
43
|
const entry = { id, name, path, registeredAt: Date.now() };
|
|
44
|
+
if (accountId) entry.accountId = accountId;
|
|
45
|
+
else if (existing >= 0 && projects[existing].accountId) entry.accountId = projects[existing].accountId;
|
|
23
46
|
|
|
24
47
|
if (existing >= 0) {
|
|
25
48
|
projects[existing] = entry;
|
|
@@ -27,8 +50,20 @@ export function registerLocalProject(id, name, path) {
|
|
|
27
50
|
projects.push(entry);
|
|
28
51
|
}
|
|
29
52
|
|
|
30
|
-
|
|
31
|
-
|
|
53
|
+
writeRegistry(projects);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// Tag untagged entries whose ids the server confirmed belong to accountId.
|
|
57
|
+
export function claimLocalProjects(ids, accountId) {
|
|
58
|
+
if (!accountId || !ids?.length) return;
|
|
59
|
+
const idSet = new Set(ids);
|
|
60
|
+
const projects = readRegistry();
|
|
61
|
+
let changed = false;
|
|
62
|
+
for (const p of projects) {
|
|
63
|
+
if (!p.accountId && idSet.has(p.id)) {
|
|
64
|
+
p.accountId = accountId;
|
|
65
|
+
changed = true;
|
|
66
|
+
}
|
|
32
67
|
}
|
|
33
|
-
|
|
68
|
+
if (changed) writeRegistry(projects);
|
|
34
69
|
}
|