@kendoo.agentdesk/agentdesk 0.19.8 → 0.20.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 +10 -0
- package/README.md +23 -6
- package/bin/agentdesk.mjs +9 -10
- package/cli/bootstrap.mjs +11 -10
- package/cli/init.mjs +428 -548
- package/cli/setup-helpers.mjs +243 -0
- package/package.json +1 -1
|
@@ -0,0 +1,243 @@
|
|
|
1
|
+
// Shared helpers for `agentdesk init` (new + existing flows) and
|
|
2
|
+
// `agentdesk bootstrap`. Kept here so the two entry commands can compose
|
|
3
|
+
// the same building blocks without importing from each other.
|
|
4
|
+
|
|
5
|
+
import { existsSync, readFileSync, readdirSync, writeFileSync } from "fs";
|
|
6
|
+
import { join } from "path";
|
|
7
|
+
import { execSync, execFileSync } from "child_process";
|
|
8
|
+
import { checkTrackerPermissions, resolveCredentialsFromEnv } from "./tracker-check.mjs";
|
|
9
|
+
import { assertPushable, PreflightError, getGitRemoteUrl } from "./session-preflight.mjs";
|
|
10
|
+
|
|
11
|
+
// ---------- .env read/write ----------
|
|
12
|
+
|
|
13
|
+
export function loadDotEnv(dir) {
|
|
14
|
+
const envPath = join(dir, ".env");
|
|
15
|
+
const out = {};
|
|
16
|
+
if (!existsSync(envPath)) return out;
|
|
17
|
+
for (const line of readFileSync(envPath, "utf-8").split("\n")) {
|
|
18
|
+
const trimmed = line.trim();
|
|
19
|
+
if (!trimmed || trimmed.startsWith("#")) continue;
|
|
20
|
+
const eq = trimmed.indexOf("=");
|
|
21
|
+
if (eq !== -1) out[trimmed.slice(0, eq)] = trimmed.slice(eq + 1);
|
|
22
|
+
}
|
|
23
|
+
return out;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function saveEnvVar(dir, key, value) {
|
|
27
|
+
const envPath = join(dir, ".env");
|
|
28
|
+
let content = existsSync(envPath) ? readFileSync(envPath, "utf-8") : "";
|
|
29
|
+
const re = new RegExp(`^${key}=.*$`, "m");
|
|
30
|
+
if (re.test(content)) {
|
|
31
|
+
content = content.replace(re, `${key}=${value}`);
|
|
32
|
+
} else {
|
|
33
|
+
content += `${content && !content.endsWith("\n") ? "\n" : ""}${key}=${value}\n`;
|
|
34
|
+
}
|
|
35
|
+
writeFileSync(envPath, content);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// ---------- Git detection + parsing ----------
|
|
39
|
+
|
|
40
|
+
// Parse "owner/repo" or a GitHub URL. Rejects non-github URLs.
|
|
41
|
+
// Returns { owner, repo } or null.
|
|
42
|
+
export function parseGitHubRef(input) {
|
|
43
|
+
const s = String(input || "").trim();
|
|
44
|
+
if (!s) return null;
|
|
45
|
+
// Reject non-github hosts early.
|
|
46
|
+
if (/^(https?:\/\/|git@|ssh:\/\/)/.test(s) && !/github\.com/i.test(s)) return null;
|
|
47
|
+
// SSH / HTTPS / short form — pull out owner/repo from the tail.
|
|
48
|
+
const m = s.match(/(?:github\.com[:/])?([^/@:\s]+)\/([^/.\s]+?)(?:\.git)?\/?$/i);
|
|
49
|
+
if (!m) return null;
|
|
50
|
+
return { owner: m[1], repo: m[2] };
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function detectGitRepo(cwd) {
|
|
54
|
+
try {
|
|
55
|
+
execFileSync("git", ["-C", cwd, "rev-parse", "--is-inside-work-tree"], {
|
|
56
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
57
|
+
});
|
|
58
|
+
} catch { return { inRepo: false, remote: null, ownerRepo: null }; }
|
|
59
|
+
const url = getGitRemoteUrl(cwd);
|
|
60
|
+
if (!url) return { inRepo: true, remote: null, ownerRepo: null };
|
|
61
|
+
const parsed = parseGitHubRef(url);
|
|
62
|
+
return {
|
|
63
|
+
inRepo: true,
|
|
64
|
+
remote: url,
|
|
65
|
+
ownerRepo: parsed ? `${parsed.owner}/${parsed.repo}` : null,
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// ---------- GitHub API ----------
|
|
70
|
+
|
|
71
|
+
// Fetch the authenticated user's login via the REST API. No gh CLI dependency.
|
|
72
|
+
// Returns the login string or null.
|
|
73
|
+
export async function fetchGitHubLogin(token) {
|
|
74
|
+
try {
|
|
75
|
+
const res = await fetch("https://api.github.com/user", {
|
|
76
|
+
headers: {
|
|
77
|
+
Authorization: `Bearer ${token}`,
|
|
78
|
+
Accept: "application/vnd.github+json",
|
|
79
|
+
"User-Agent": "agentdesk-cli",
|
|
80
|
+
},
|
|
81
|
+
signal: AbortSignal.timeout(8000),
|
|
82
|
+
});
|
|
83
|
+
if (!res.ok) return null;
|
|
84
|
+
const data = await res.json();
|
|
85
|
+
return data?.login || null;
|
|
86
|
+
} catch { return null; }
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// Check push access on a specific repo. Returns { ok, canPush, error? }.
|
|
90
|
+
export async function checkGitHubRepoAccess(token, owner, repo) {
|
|
91
|
+
try {
|
|
92
|
+
const res = await fetch(`https://api.github.com/repos/${owner}/${repo}`, {
|
|
93
|
+
headers: {
|
|
94
|
+
Authorization: `Bearer ${token}`,
|
|
95
|
+
Accept: "application/vnd.github+json",
|
|
96
|
+
"User-Agent": "agentdesk-cli",
|
|
97
|
+
},
|
|
98
|
+
signal: AbortSignal.timeout(8000),
|
|
99
|
+
});
|
|
100
|
+
if (res.status === 404) return { ok: false, canPush: false, error: `Repo ${owner}/${repo} not found or not accessible` };
|
|
101
|
+
if (!res.ok) return { ok: false, canPush: false, error: `GitHub API returned ${res.status}` };
|
|
102
|
+
const data = await res.json();
|
|
103
|
+
const canPush = !!data?.permissions?.push;
|
|
104
|
+
return { ok: true, canPush, error: canPush ? null : "Token lacks push access to this repo" };
|
|
105
|
+
} catch (e) {
|
|
106
|
+
return { ok: false, canPush: false, error: e.message };
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// ---------- Clone ----------
|
|
111
|
+
|
|
112
|
+
// Clone github.com/<owner>/<repo> into <cwd>/<repo> using the provided token.
|
|
113
|
+
// Token is embedded in the URL via the x-access-token username (GitHub's
|
|
114
|
+
// documented pattern). Disables terminal prompts so a bad token fails fast
|
|
115
|
+
// instead of hanging waiting for a password.
|
|
116
|
+
export function cloneRepoWithToken({ cwd, owner, repo, token }) {
|
|
117
|
+
const targetDir = join(cwd, repo);
|
|
118
|
+
if (existsSync(targetDir)) {
|
|
119
|
+
const entries = readdirSync(targetDir);
|
|
120
|
+
if (entries.length > 0) {
|
|
121
|
+
throw new Error(`./${repo} already exists and is not empty — remove it or run from another directory`);
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
const url = `https://x-access-token:${token}@github.com/${owner}/${repo}.git`;
|
|
125
|
+
// Execute with env override so we don't echo the token into the user's
|
|
126
|
+
// shell history; stdio inherited so the user sees clone progress.
|
|
127
|
+
execFileSync("git", ["clone", url, targetDir], {
|
|
128
|
+
stdio: "inherit",
|
|
129
|
+
env: { ...process.env, GIT_TERMINAL_PROMPT: "0" },
|
|
130
|
+
});
|
|
131
|
+
return targetDir;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// ---------- Access checks (summary checklist) ----------
|
|
135
|
+
|
|
136
|
+
export async function checkAgentDeskAccess(apiKey, serverUrl) {
|
|
137
|
+
try {
|
|
138
|
+
const res = await fetch(`${serverUrl}/api/sessions`, {
|
|
139
|
+
headers: { "x-api-key": apiKey },
|
|
140
|
+
signal: AbortSignal.timeout(5000),
|
|
141
|
+
});
|
|
142
|
+
if (res.ok) return { ok: true };
|
|
143
|
+
return { ok: false, error: `Server returned ${res.status}` };
|
|
144
|
+
} catch (e) {
|
|
145
|
+
return { ok: false, error: e.message };
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// Run all three access checks for the final summary and return the results.
|
|
150
|
+
// No prompts — the caller decides whether to abort or let the user save
|
|
151
|
+
// anyway based on the outcome.
|
|
152
|
+
export async function runAccessChecks({ apiKey, serverUrl, cwd, config, creds }) {
|
|
153
|
+
const results = { agentdesk: null, github: null, tracker: null };
|
|
154
|
+
|
|
155
|
+
// agentdesk.live
|
|
156
|
+
results.agentdesk = await checkAgentDeskAccess(apiKey, serverUrl);
|
|
157
|
+
|
|
158
|
+
// GitHub — token presence + (if we know the repo) push access
|
|
159
|
+
if (creds?.GITHUB_TOKEN) {
|
|
160
|
+
const login = await fetchGitHubLogin(creds.GITHUB_TOKEN);
|
|
161
|
+
if (!login) {
|
|
162
|
+
results.github = { ok: false, error: "Token did not authenticate with GitHub" };
|
|
163
|
+
} else if (config?.github?.repo) {
|
|
164
|
+
const [owner, repo] = String(config.github.repo).split("/");
|
|
165
|
+
const access = await checkGitHubRepoAccess(creds.GITHUB_TOKEN, owner, repo);
|
|
166
|
+
results.github = access.ok && access.canPush
|
|
167
|
+
? { ok: true, login, repo: config.github.repo }
|
|
168
|
+
: { ok: false, error: access.error || "Insufficient GitHub repo access", login };
|
|
169
|
+
} else {
|
|
170
|
+
results.github = { ok: true, login };
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// Also fail early if the local git remote is SSH or if the token is
|
|
174
|
+
// missing — same preflight a session run would do.
|
|
175
|
+
try {
|
|
176
|
+
assertPushable({ cwd, creds: { GITHUB_TOKEN: creds.GITHUB_TOKEN }, projectName: config?.projectKey || "(project)" });
|
|
177
|
+
} catch (err) {
|
|
178
|
+
if (err instanceof PreflightError) {
|
|
179
|
+
results.github = { ok: false, error: err.message };
|
|
180
|
+
} else { throw err; }
|
|
181
|
+
}
|
|
182
|
+
} else {
|
|
183
|
+
results.github = { ok: false, error: "No GITHUB_TOKEN in .env" };
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
// Tracker
|
|
187
|
+
if (config?.tracker) {
|
|
188
|
+
const check = await checkTrackerPermissions({
|
|
189
|
+
tracker: config.tracker,
|
|
190
|
+
config,
|
|
191
|
+
credentials: resolveCredentialsFromEnv(creds),
|
|
192
|
+
});
|
|
193
|
+
results.tracker = check.ok
|
|
194
|
+
? { ok: true, identity: check.identity }
|
|
195
|
+
: { ok: false, error: (check.errors || ["Verification failed"])[0] };
|
|
196
|
+
} else {
|
|
197
|
+
results.tracker = { ok: true, skipped: true };
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
return results;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
// Pretty-print the checklist the user sees in the review/confirm step.
|
|
204
|
+
export function printAccessChecks(results, { tracker } = {}) {
|
|
205
|
+
console.log(" Access checks");
|
|
206
|
+
console.log(" ─────────────");
|
|
207
|
+
if (results.agentdesk?.ok) {
|
|
208
|
+
console.log(" ✓ agentdesk.live");
|
|
209
|
+
} else {
|
|
210
|
+
console.log(` ✗ agentdesk.live ${results.agentdesk?.error || "unknown error"}`);
|
|
211
|
+
}
|
|
212
|
+
if (results.github?.ok) {
|
|
213
|
+
const detail = [
|
|
214
|
+
results.github.login ? `@${results.github.login}` : null,
|
|
215
|
+
results.github.repo ? `push access to ${results.github.repo}` : null,
|
|
216
|
+
].filter(Boolean).join(", ");
|
|
217
|
+
console.log(` ✓ GitHub ${detail}`);
|
|
218
|
+
} else {
|
|
219
|
+
console.log(` ✗ GitHub ${results.github?.error || "unknown error"}`);
|
|
220
|
+
}
|
|
221
|
+
if (results.tracker?.skipped) {
|
|
222
|
+
// No tracker configured — omit the tracker line entirely.
|
|
223
|
+
} else if (results.tracker?.ok) {
|
|
224
|
+
const name = results.tracker.identity?.name || results.tracker.identity?.login || results.tracker.identity?.email || "";
|
|
225
|
+
const t = tracker || "tracker";
|
|
226
|
+
console.log(` ✓ ${t.padEnd(18)}${name ? `authenticated as ${name}` : "verified"}`);
|
|
227
|
+
} else {
|
|
228
|
+
const t = tracker || "tracker";
|
|
229
|
+
console.log(` ✗ ${t.padEnd(18)}${results.tracker?.error || "unknown error"}`);
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
// ---------- Local config read ----------
|
|
234
|
+
|
|
235
|
+
export function readLocalConfig(cwd) {
|
|
236
|
+
const path = join(cwd, ".agentdesk.json");
|
|
237
|
+
if (!existsSync(path)) return null;
|
|
238
|
+
try { return JSON.parse(readFileSync(path, "utf-8")); } catch { return null; }
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
export function writeMinimalLocalConfig(cwd, projectKey) {
|
|
242
|
+
writeFileSync(join(cwd, ".agentdesk.json"), JSON.stringify({ projectKey }, null, 2) + "\n");
|
|
243
|
+
}
|