@tasksai/install 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +21 -0
- package/package.json +31 -0
- package/src/index.js +638 -0
package/README.md
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
# @tasksai/install
|
|
2
|
+
|
|
3
|
+
Official installer CLI for TasksAI MCP verticals.
|
|
4
|
+
|
|
5
|
+
Users normally run this through a product-specific GitHub manifest, for example:
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
npx @tasksai/install lawtasksai --source https://github.com/laudoluxDev/lawtasksai-mcp
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
The installer:
|
|
12
|
+
|
|
13
|
+
- verifies the official vertical manifest
|
|
14
|
+
- installs the local MCP runtime
|
|
15
|
+
- connects the user's TasksAI account through browser approval when available
|
|
16
|
+
- stores credentials outside MCP client config
|
|
17
|
+
- configures supported MCP clients
|
|
18
|
+
- runs a local health check
|
|
19
|
+
|
|
20
|
+
TasksAI servers handle authentication, credits, catalog/search metadata, and
|
|
21
|
+
licensed skill delivery. TasksAI does not process user task content.
|
package/package.json
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@tasksai/install",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Shared TasksAI MCP installer CLI",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"bin": {
|
|
7
|
+
"tasksai-install": "src/index.js"
|
|
8
|
+
},
|
|
9
|
+
"files": [
|
|
10
|
+
"src/",
|
|
11
|
+
"README.md"
|
|
12
|
+
],
|
|
13
|
+
"keywords": [
|
|
14
|
+
"mcp",
|
|
15
|
+
"installer",
|
|
16
|
+
"tasksai",
|
|
17
|
+
"lawtasksai"
|
|
18
|
+
],
|
|
19
|
+
"license": "UNLICENSED",
|
|
20
|
+
"repository": {
|
|
21
|
+
"type": "git",
|
|
22
|
+
"url": "git+https://github.com/laudoluxDev/tasksai-mcp.git",
|
|
23
|
+
"directory": "packages/installer"
|
|
24
|
+
},
|
|
25
|
+
"publishConfig": {
|
|
26
|
+
"access": "public"
|
|
27
|
+
},
|
|
28
|
+
"scripts": {
|
|
29
|
+
"check": "node --check src/index.js"
|
|
30
|
+
}
|
|
31
|
+
}
|
package/src/index.js
ADDED
|
@@ -0,0 +1,638 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
import fs from "node:fs";
|
|
4
|
+
import fsp from "node:fs/promises";
|
|
5
|
+
import https from "node:https";
|
|
6
|
+
import os from "node:os";
|
|
7
|
+
import path from "node:path";
|
|
8
|
+
import readline from "node:readline/promises";
|
|
9
|
+
import { spawnSync } from "node:child_process";
|
|
10
|
+
import { stdin as input, stdout as output } from "node:process";
|
|
11
|
+
|
|
12
|
+
const INSTALLER_VERSION = "0.1.0";
|
|
13
|
+
const DEFAULT_SOURCES = {
|
|
14
|
+
lawtasksai: "https://github.com/laudoluxDev/lawtasksai-mcp",
|
|
15
|
+
priorauthai: "https://github.com/laudoluxDev/priorauthai-mcp"
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
const CLIENTS = {
|
|
19
|
+
"claude-desktop": {
|
|
20
|
+
id: "claude-desktop",
|
|
21
|
+
displayName: "Claude Desktop",
|
|
22
|
+
configPath() {
|
|
23
|
+
if (process.platform === "darwin") {
|
|
24
|
+
return path.join(os.homedir(), "Library", "Application Support", "Claude", "claude_desktop_config.json");
|
|
25
|
+
}
|
|
26
|
+
if (process.platform === "win32") {
|
|
27
|
+
return path.join(process.env.APPDATA || path.join(os.homedir(), "AppData", "Roaming"), "Claude", "claude_desktop_config.json");
|
|
28
|
+
}
|
|
29
|
+
return path.join(os.homedir(), ".config", "Claude", "claude_desktop_config.json");
|
|
30
|
+
}
|
|
31
|
+
},
|
|
32
|
+
cursor: {
|
|
33
|
+
id: "cursor",
|
|
34
|
+
displayName: "Cursor",
|
|
35
|
+
configPath() {
|
|
36
|
+
return path.join(os.homedir(), ".cursor", "mcp.json");
|
|
37
|
+
}
|
|
38
|
+
},
|
|
39
|
+
windsurf: {
|
|
40
|
+
id: "windsurf",
|
|
41
|
+
displayName: "Windsurf",
|
|
42
|
+
configPath() {
|
|
43
|
+
return path.join(os.homedir(), ".codeium", "windsurf", "mcp_config.json");
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
main().catch((error) => {
|
|
49
|
+
console.error(formatError(error));
|
|
50
|
+
process.exit(1);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
async function main() {
|
|
54
|
+
const options = parseArgs(process.argv.slice(2));
|
|
55
|
+
|
|
56
|
+
if (!options.productId) {
|
|
57
|
+
printUsage();
|
|
58
|
+
process.exit(1);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
if (options.command === "doctor") {
|
|
62
|
+
await doctor(options);
|
|
63
|
+
return;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
if (options.command === "update") {
|
|
67
|
+
await install(options, { updateOnly: true });
|
|
68
|
+
return;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
if (options.command === "uninstall") {
|
|
72
|
+
await uninstall(options);
|
|
73
|
+
return;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
await install(options);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function parseArgs(argv) {
|
|
80
|
+
const options = {
|
|
81
|
+
productId: null,
|
|
82
|
+
command: "install",
|
|
83
|
+
source: null,
|
|
84
|
+
ref: "main",
|
|
85
|
+
client: "claude-desktop",
|
|
86
|
+
auth: "browser",
|
|
87
|
+
noBrowser: false,
|
|
88
|
+
yes: false,
|
|
89
|
+
skipPythonDeps: false
|
|
90
|
+
};
|
|
91
|
+
|
|
92
|
+
const positionals = [];
|
|
93
|
+
for (let i = 0; i < argv.length; i += 1) {
|
|
94
|
+
const arg = argv[i];
|
|
95
|
+
if (arg === "--source") options.source = argv[++i];
|
|
96
|
+
else if (arg === "--ref") options.ref = argv[++i];
|
|
97
|
+
else if (arg === "--client") options.client = argv[++i];
|
|
98
|
+
else if (arg === "--auth") options.auth = argv[++i];
|
|
99
|
+
else if (arg === "--no-browser") options.noBrowser = true;
|
|
100
|
+
else if (arg === "--yes" || arg === "-y") options.yes = true;
|
|
101
|
+
else if (arg === "--skip-python-deps") options.skipPythonDeps = true;
|
|
102
|
+
else if (arg === "--help" || arg === "-h") {
|
|
103
|
+
printUsage();
|
|
104
|
+
process.exit(0);
|
|
105
|
+
} else if (arg.startsWith("--")) {
|
|
106
|
+
throw new Error(`Unsupported option: ${arg}`);
|
|
107
|
+
} else {
|
|
108
|
+
positionals.push(arg);
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
options.productId = positionals[0] || null;
|
|
113
|
+
if (positionals[1]) options.command = positionals[1];
|
|
114
|
+
if (!["install", "doctor", "update", "uninstall"].includes(options.command)) {
|
|
115
|
+
throw new Error(`Unsupported command: ${options.command}`);
|
|
116
|
+
}
|
|
117
|
+
if (!["browser", "license-key"].includes(options.auth)) {
|
|
118
|
+
throw new Error(`Unsupported auth mode: ${options.auth}. Supported modes: browser, license-key`);
|
|
119
|
+
}
|
|
120
|
+
options.source ||= DEFAULT_SOURCES[options.productId];
|
|
121
|
+
if (!options.source) {
|
|
122
|
+
throw new Error(`No default source is known for product: ${options.productId}`);
|
|
123
|
+
}
|
|
124
|
+
return options;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function printUsage() {
|
|
128
|
+
console.log(`Usage:
|
|
129
|
+
tasksai-install <product-id> [install] [--source <repo-url>] [--ref <branch>] [--client claude-desktop|cursor|windsurf|all] [--auth browser|license-key]
|
|
130
|
+
tasksai-install <product-id> doctor [--client claude-desktop|cursor|windsurf|all]
|
|
131
|
+
tasksai-install <product-id> update
|
|
132
|
+
tasksai-install <product-id> uninstall [--client claude-desktop|cursor|windsurf|all]
|
|
133
|
+
|
|
134
|
+
Examples:
|
|
135
|
+
tasksai-install lawtasksai --source https://github.com/laudoluxDev/lawtasksai-mcp
|
|
136
|
+
tasksai-install lawtasksai doctor
|
|
137
|
+
`);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
async function install(options, { updateOnly = false } = {}) {
|
|
141
|
+
const source = parseSource(options.source, options.ref);
|
|
142
|
+
const manifest = await loadJson(source, "agent-install.json");
|
|
143
|
+
const vertical = await loadJson(source, "vertical.json");
|
|
144
|
+
verifySource({ options, source, manifest, vertical });
|
|
145
|
+
|
|
146
|
+
const installDir = getInstallDir(options.productId);
|
|
147
|
+
const runtimeDir = path.join(installDir, "runtime");
|
|
148
|
+
const vendorDir = path.join(installDir, "python");
|
|
149
|
+
await fsp.mkdir(runtimeDir, { recursive: true });
|
|
150
|
+
await fsp.mkdir(path.join(installDir, "logs"), { recursive: true });
|
|
151
|
+
await logEvent(installDir, "install_start", {
|
|
152
|
+
productId: options.productId,
|
|
153
|
+
source: source.repoUrl,
|
|
154
|
+
client: options.client,
|
|
155
|
+
updateOnly
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
await writeJson(path.join(installDir, "agent-install.json"), manifest);
|
|
159
|
+
await writeJson(path.join(installDir, "vertical.json"), vertical);
|
|
160
|
+
await downloadRuntime(source, runtimeDir);
|
|
161
|
+
|
|
162
|
+
if (!options.skipPythonDeps) {
|
|
163
|
+
installPythonDeps(runtimeDir, vendorDir);
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
const licenseKey = await resolveLicenseKey(options, vertical, installDir);
|
|
167
|
+
const envEntries = {
|
|
168
|
+
TASKSAI_LICENSE_KEY: licenseKey,
|
|
169
|
+
LAWTASKSAI_LICENSE_KEY: licenseKey,
|
|
170
|
+
TASKSAI_PRODUCT_ID: vertical.product_id,
|
|
171
|
+
TASKSAI_API_BASE: vertical.api_base_url,
|
|
172
|
+
LAWTASKSAI_API_BASE: vertical.api_base_url
|
|
173
|
+
};
|
|
174
|
+
await writeEnvFile(path.join(installDir, ".env"), envEntries);
|
|
175
|
+
await writeEnvFile(path.join(runtimeDir, ".env"), envEntries);
|
|
176
|
+
|
|
177
|
+
if (updateOnly) {
|
|
178
|
+
await logEvent(installDir, "update_complete", { productId: options.productId });
|
|
179
|
+
console.log(`${vertical.display_name} runtime updated at ${installDir}`);
|
|
180
|
+
return;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
const clients = resolveClients(options.client);
|
|
184
|
+
for (const client of clients) {
|
|
185
|
+
await configureMcpClient({ client, vertical, installDir, runtimeDir, vendorDir });
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
await doctor(options, { quietSuccess: true });
|
|
189
|
+
await logEvent(installDir, "install_complete", {
|
|
190
|
+
productId: options.productId,
|
|
191
|
+
clients: clients.map((client) => client.id)
|
|
192
|
+
});
|
|
193
|
+
console.log(`${vertical.display_name} is installed. Restart ${clients.map((client) => client.displayName).join(", ")} to see the tools.`);
|
|
194
|
+
console.log(`After restart, ask: "${vertical.first_prompt}"`);
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
async function doctor(options, { quietSuccess = false } = {}) {
|
|
198
|
+
const installDir = getInstallDir(options.productId);
|
|
199
|
+
const verticalPath = path.join(installDir, "vertical.json");
|
|
200
|
+
const envPath = path.join(installDir, ".env");
|
|
201
|
+
const serverPath = path.join(installDir, "runtime", "server.py");
|
|
202
|
+
const clients = resolveClients(options.client);
|
|
203
|
+
|
|
204
|
+
const problems = [];
|
|
205
|
+
if (!fs.existsSync(verticalPath)) problems.push(`Missing ${verticalPath}`);
|
|
206
|
+
if (!fs.existsSync(envPath)) problems.push(`Missing ${envPath}`);
|
|
207
|
+
if (!fs.existsSync(serverPath)) problems.push(`Missing ${serverPath}`);
|
|
208
|
+
const vertical = fs.existsSync(verticalPath) ? await readJson(verticalPath) : { product_id: options.productId };
|
|
209
|
+
|
|
210
|
+
for (const client of clients) {
|
|
211
|
+
const configPath = client.configPath();
|
|
212
|
+
if (fs.existsSync(configPath)) {
|
|
213
|
+
const config = await readJson(configPath);
|
|
214
|
+
if (!config.mcpServers?.[vertical.product_id]) {
|
|
215
|
+
problems.push(`${client.displayName} config does not contain mcpServers.${vertical.product_id}`);
|
|
216
|
+
}
|
|
217
|
+
} else {
|
|
218
|
+
problems.push(`${client.displayName} config not found at ${configPath}`);
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
if (problems.length) {
|
|
223
|
+
throw new Error(`TasksAI doctor found issues:\n- ${problems.join("\n- ")}`);
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
await logEvent(installDir, "doctor_passed", {
|
|
227
|
+
productId: options.productId,
|
|
228
|
+
clients: clients.map((client) => client.id)
|
|
229
|
+
});
|
|
230
|
+
if (!quietSuccess) console.log("TasksAI doctor passed.");
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
async function uninstall(options) {
|
|
234
|
+
const installDir = getInstallDir(options.productId);
|
|
235
|
+
const clients = resolveClients(options.client);
|
|
236
|
+
|
|
237
|
+
for (const client of clients) {
|
|
238
|
+
const configPath = client.configPath();
|
|
239
|
+
if (!fs.existsSync(configPath)) {
|
|
240
|
+
console.log(`${client.displayName} config not found; nothing to remove.`);
|
|
241
|
+
continue;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
await withLock(`${configPath}.lock`, async () => {
|
|
245
|
+
const config = await readJson(configPath);
|
|
246
|
+
const key = options.productId;
|
|
247
|
+
if (!config.mcpServers?.[key]) {
|
|
248
|
+
console.log(`${client.displayName} does not have a ${key} MCP entry.`);
|
|
249
|
+
return;
|
|
250
|
+
}
|
|
251
|
+
await backupFile(configPath);
|
|
252
|
+
delete config.mcpServers[key];
|
|
253
|
+
await atomicWriteJson(configPath, config);
|
|
254
|
+
});
|
|
255
|
+
|
|
256
|
+
console.log(`${options.productId} was removed from ${client.displayName}.`);
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
await logEvent(installDir, "uninstall_complete", {
|
|
260
|
+
productId: options.productId,
|
|
261
|
+
clients: clients.map((client) => client.id)
|
|
262
|
+
});
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
async function configureMcpClient({ client, vertical, installDir, runtimeDir, vendorDir }) {
|
|
266
|
+
const configPath = client.configPath();
|
|
267
|
+
await fsp.mkdir(path.dirname(configPath), { recursive: true });
|
|
268
|
+
|
|
269
|
+
await withLock(`${configPath}.lock`, async () => {
|
|
270
|
+
const config = fs.existsSync(configPath) ? await readJson(configPath) : {};
|
|
271
|
+
if (!config.mcpServers || typeof config.mcpServers !== "object") config.mcpServers = {};
|
|
272
|
+
|
|
273
|
+
await backupFile(configPath);
|
|
274
|
+
config.mcpServers[vertical.product_id] = {
|
|
275
|
+
command: "python3",
|
|
276
|
+
args: [path.join(runtimeDir, "server.py")],
|
|
277
|
+
env: {
|
|
278
|
+
TASKSAI_PRODUCT_ID: vertical.product_id,
|
|
279
|
+
TASKSAI_API_BASE: vertical.api_base_url,
|
|
280
|
+
TASKSAI_CLIENT: client.id,
|
|
281
|
+
PYTHONPATH: vendorDir,
|
|
282
|
+
DOTENV_PATH: path.join(installDir, ".env")
|
|
283
|
+
}
|
|
284
|
+
};
|
|
285
|
+
|
|
286
|
+
await atomicWriteJson(configPath, config);
|
|
287
|
+
});
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
function resolveClients(clientOption) {
|
|
291
|
+
if (clientOption === "all") {
|
|
292
|
+
return Object.values(CLIENTS);
|
|
293
|
+
}
|
|
294
|
+
const client = CLIENTS[clientOption];
|
|
295
|
+
if (!client) {
|
|
296
|
+
throw new Error(`Unsupported client: ${clientOption}. Supported clients: ${Object.keys(CLIENTS).join(", ")}, all`);
|
|
297
|
+
}
|
|
298
|
+
return [client];
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
async function downloadRuntime(source, runtimeDir) {
|
|
302
|
+
const serverText = await loadText(source, "server.py");
|
|
303
|
+
const requirementsText = await loadText(source, "requirements.txt");
|
|
304
|
+
await fsp.writeFile(path.join(runtimeDir, "server.py"), serverText, "utf8");
|
|
305
|
+
await fsp.writeFile(path.join(runtimeDir, "requirements.txt"), requirementsText, "utf8");
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
function installPythonDeps(runtimeDir, vendorDir) {
|
|
309
|
+
const python = findPython();
|
|
310
|
+
if (!python) throw new Error("Python 3 is required but was not found on PATH.");
|
|
311
|
+
fs.mkdirSync(vendorDir, { recursive: true });
|
|
312
|
+
const result = spawnSync(python, [
|
|
313
|
+
"-m",
|
|
314
|
+
"pip",
|
|
315
|
+
"install",
|
|
316
|
+
"--upgrade",
|
|
317
|
+
"--target",
|
|
318
|
+
vendorDir,
|
|
319
|
+
"-r",
|
|
320
|
+
path.join(runtimeDir, "requirements.txt")
|
|
321
|
+
], { stdio: "inherit" });
|
|
322
|
+
if (result.status !== 0) {
|
|
323
|
+
throw new Error("Python dependency installation failed.");
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
function findPython() {
|
|
328
|
+
for (const candidate of ["python3", "python"]) {
|
|
329
|
+
const result = spawnSync(candidate, ["--version"], { encoding: "utf8" });
|
|
330
|
+
if (result.status === 0 && /Python 3\./.test(`${result.stdout}${result.stderr}`)) {
|
|
331
|
+
return candidate;
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
return null;
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
async function resolveLicenseKey(options, vertical, installDir) {
|
|
338
|
+
const envNames = ["TASKSAI_LICENSE_KEY", "LAWTASKSAI_LICENSE_KEY"];
|
|
339
|
+
for (const name of envNames) {
|
|
340
|
+
if (process.env[name]) return process.env[name].trim();
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
const envPath = path.join(installDir, ".env");
|
|
344
|
+
if (fs.existsSync(envPath)) {
|
|
345
|
+
const existing = await fsp.readFile(envPath, "utf8");
|
|
346
|
+
for (const name of envNames) {
|
|
347
|
+
const match = existing.match(new RegExp(`^${name}=(.+)$`, "m"));
|
|
348
|
+
if (match?.[1]) return match[1].trim();
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
if (!process.stdin.isTTY) {
|
|
353
|
+
throw new Error(`No license key found. Set TASKSAI_LICENSE_KEY before installing ${vertical.display_name}.`);
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
if (options.auth === "browser" && !process.env.TASKSAI_DISABLE_BROWSER_AUTH) {
|
|
357
|
+
try {
|
|
358
|
+
const browserKey = await resolveLicenseKeyWithBrowser(vertical, options);
|
|
359
|
+
if (browserKey) return browserKey;
|
|
360
|
+
} catch (error) {
|
|
361
|
+
console.log(`Browser connection was not completed: ${redact(error.message)}`);
|
|
362
|
+
console.log("Falling back to license-key entry.");
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
const rl = readline.createInterface({ input, output });
|
|
367
|
+
try {
|
|
368
|
+
const answer = await rl.question(`Enter your ${vertical.display_name} license key: `);
|
|
369
|
+
if (!answer.trim()) throw new Error("No license key provided.");
|
|
370
|
+
return answer.trim();
|
|
371
|
+
} finally {
|
|
372
|
+
rl.close();
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
async function resolveLicenseKeyWithBrowser(vertical, options) {
|
|
377
|
+
const apiBase = (vertical.api_base_url || "").replace(/\/$/, "");
|
|
378
|
+
if (!apiBase) throw new Error("No API base URL is configured for browser connection.");
|
|
379
|
+
|
|
380
|
+
const started = await postJson(`${apiBase}/v1/mcp/connect/start`, {
|
|
381
|
+
product_id: vertical.product_id,
|
|
382
|
+
client_name: options.client
|
|
383
|
+
});
|
|
384
|
+
|
|
385
|
+
console.log("");
|
|
386
|
+
console.log(`Connect your ${vertical.display_name} account in the browser.`);
|
|
387
|
+
console.log(`Code: ${started.user_code}`);
|
|
388
|
+
console.log(`URL: ${started.verification_url}`);
|
|
389
|
+
console.log("");
|
|
390
|
+
if (!options.noBrowser) {
|
|
391
|
+
openExternalUrl(started.verification_url);
|
|
392
|
+
}
|
|
393
|
+
console.log("Waiting for browser approval. Press Ctrl+C to cancel.");
|
|
394
|
+
|
|
395
|
+
const intervalMs = Math.max(1, Number(started.interval || 2)) * 1000;
|
|
396
|
+
const expiresAt = Date.now() + Math.max(30, Number(started.expires_in || 600)) * 1000;
|
|
397
|
+
|
|
398
|
+
while (Date.now() < expiresAt) {
|
|
399
|
+
await delay(intervalMs);
|
|
400
|
+
try {
|
|
401
|
+
const token = await postJson(`${apiBase}/v1/mcp/connect/token`, {
|
|
402
|
+
device_code: started.device_code
|
|
403
|
+
});
|
|
404
|
+
if (token.license_key) {
|
|
405
|
+
console.log(`${vertical.display_name} account connected.`);
|
|
406
|
+
return token.license_key;
|
|
407
|
+
}
|
|
408
|
+
} catch (error) {
|
|
409
|
+
if (error.statusCode === 428 || /authorization_pending/.test(error.message)) {
|
|
410
|
+
continue;
|
|
411
|
+
}
|
|
412
|
+
throw error;
|
|
413
|
+
}
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
throw new Error("Browser approval timed out.");
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
function verifySource({ options, source, manifest, vertical }) {
|
|
420
|
+
if (manifest.product_id !== options.productId) {
|
|
421
|
+
throw new Error(`Manifest product_id ${manifest.product_id} does not match requested product ${options.productId}.`);
|
|
422
|
+
}
|
|
423
|
+
if (vertical.product_id !== options.productId) {
|
|
424
|
+
throw new Error(`Vertical product_id ${vertical.product_id} does not match requested product ${options.productId}.`);
|
|
425
|
+
}
|
|
426
|
+
if (manifest.official_package !== "@tasksai/install") {
|
|
427
|
+
throw new Error(`Unexpected installer package: ${manifest.official_package}`);
|
|
428
|
+
}
|
|
429
|
+
if (source.kind === "github" && manifest.official_github_repo !== source.repoUrl) {
|
|
430
|
+
throw new Error(`Source repo ${source.repoUrl} does not match manifest official repo ${manifest.official_github_repo}.`);
|
|
431
|
+
}
|
|
432
|
+
if (!manifest.official_domain || !vertical.website_url?.includes(manifest.official_domain)) {
|
|
433
|
+
throw new Error("Manifest official domain does not match vertical website URL.");
|
|
434
|
+
}
|
|
435
|
+
const npxInstaller = getNpxInstaller(manifest);
|
|
436
|
+
const command = npxInstaller?.command;
|
|
437
|
+
const args = npxInstaller?.args || [];
|
|
438
|
+
if (command !== "npx" || !Array.isArray(args) || args[0] !== "@tasksai/install") {
|
|
439
|
+
throw new Error("Manifest installer command is not the approved @tasksai/install npx command.");
|
|
440
|
+
}
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
function getNpxInstaller(manifest) {
|
|
444
|
+
if (manifest.installer?.npx) return manifest.installer.npx;
|
|
445
|
+
return manifest.installer;
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
function parseSource(source, ref) {
|
|
449
|
+
if (source.startsWith("file://")) {
|
|
450
|
+
return { kind: "file", root: source.slice("file://".length), ref, repoUrl: source };
|
|
451
|
+
}
|
|
452
|
+
if (source.startsWith("/") || source.startsWith(".")) {
|
|
453
|
+
return { kind: "file", root: path.resolve(source), ref, repoUrl: source };
|
|
454
|
+
}
|
|
455
|
+
const match = source.match(/^https:\/\/github\.com\/([^/]+)\/([^/#?]+)(?:[/?#].*)?$/);
|
|
456
|
+
if (!match) throw new Error(`Unsupported source URL: ${source}`);
|
|
457
|
+
const [, owner, repo] = match;
|
|
458
|
+
return {
|
|
459
|
+
kind: "github",
|
|
460
|
+
owner,
|
|
461
|
+
repo,
|
|
462
|
+
ref,
|
|
463
|
+
repoUrl: `https://github.com/${owner}/${repo}`
|
|
464
|
+
};
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
async function loadJson(source, filePath) {
|
|
468
|
+
return JSON.parse(await loadText(source, filePath));
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
async function loadText(source, filePath) {
|
|
472
|
+
if (source.kind === "file") {
|
|
473
|
+
return fsp.readFile(path.join(source.root, filePath), "utf8");
|
|
474
|
+
}
|
|
475
|
+
const rawUrl = `https://raw.githubusercontent.com/${source.owner}/${source.repo}/${encodeURIComponent(source.ref)}/${filePath}`;
|
|
476
|
+
return fetchText(rawUrl);
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
function fetchText(url) {
|
|
480
|
+
return new Promise((resolve, reject) => {
|
|
481
|
+
https.get(url, { headers: { "User-Agent": `tasksai-install/${INSTALLER_VERSION}` } }, (response) => {
|
|
482
|
+
if (response.statusCode >= 300 && response.statusCode < 400 && response.headers.location) {
|
|
483
|
+
fetchText(response.headers.location).then(resolve, reject);
|
|
484
|
+
return;
|
|
485
|
+
}
|
|
486
|
+
if (response.statusCode !== 200) {
|
|
487
|
+
reject(new Error(`GET ${url} failed with HTTP ${response.statusCode}`));
|
|
488
|
+
response.resume();
|
|
489
|
+
return;
|
|
490
|
+
}
|
|
491
|
+
let body = "";
|
|
492
|
+
response.setEncoding("utf8");
|
|
493
|
+
response.on("data", (chunk) => {
|
|
494
|
+
body += chunk;
|
|
495
|
+
});
|
|
496
|
+
response.on("end", () => resolve(body));
|
|
497
|
+
}).on("error", reject);
|
|
498
|
+
});
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
function postJson(url, body) {
|
|
502
|
+
const payload = JSON.stringify(body || {});
|
|
503
|
+
return new Promise((resolve, reject) => {
|
|
504
|
+
const request = https.request(url, {
|
|
505
|
+
method: "POST",
|
|
506
|
+
headers: {
|
|
507
|
+
"Content-Type": "application/json",
|
|
508
|
+
"Content-Length": Buffer.byteLength(payload),
|
|
509
|
+
"User-Agent": `tasksai-install/${INSTALLER_VERSION}`
|
|
510
|
+
}
|
|
511
|
+
}, (response) => {
|
|
512
|
+
let responseBody = "";
|
|
513
|
+
response.setEncoding("utf8");
|
|
514
|
+
response.on("data", (chunk) => {
|
|
515
|
+
responseBody += chunk;
|
|
516
|
+
});
|
|
517
|
+
response.on("end", () => {
|
|
518
|
+
let data = {};
|
|
519
|
+
if (responseBody) {
|
|
520
|
+
try {
|
|
521
|
+
data = JSON.parse(responseBody);
|
|
522
|
+
} catch {
|
|
523
|
+
data = { detail: responseBody };
|
|
524
|
+
}
|
|
525
|
+
}
|
|
526
|
+
if (response.statusCode < 200 || response.statusCode >= 300) {
|
|
527
|
+
const detail = typeof data.detail === "string" ? data.detail : `HTTP ${response.statusCode}`;
|
|
528
|
+
const error = new Error(detail);
|
|
529
|
+
error.statusCode = response.statusCode;
|
|
530
|
+
reject(error);
|
|
531
|
+
return;
|
|
532
|
+
}
|
|
533
|
+
resolve(data);
|
|
534
|
+
});
|
|
535
|
+
});
|
|
536
|
+
request.on("error", reject);
|
|
537
|
+
request.write(payload);
|
|
538
|
+
request.end();
|
|
539
|
+
});
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
function openExternalUrl(url) {
|
|
543
|
+
const command = process.platform === "darwin" ? "open" : process.platform === "win32" ? "cmd" : "xdg-open";
|
|
544
|
+
const args = process.platform === "win32" ? ["/c", "start", "", url] : [url];
|
|
545
|
+
spawnSync(command, args, { stdio: "ignore", detached: true });
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
function delay(ms) {
|
|
549
|
+
return new Promise((resolve) => {
|
|
550
|
+
setTimeout(resolve, ms);
|
|
551
|
+
});
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
function getInstallDir(productId) {
|
|
555
|
+
if (process.platform === "darwin") {
|
|
556
|
+
return path.join(os.homedir(), "Library", "Application Support", "TasksAI", productId);
|
|
557
|
+
}
|
|
558
|
+
if (process.platform === "win32") {
|
|
559
|
+
return path.join(process.env.LOCALAPPDATA || path.join(os.homedir(), "AppData", "Local"), "TasksAI", productId);
|
|
560
|
+
}
|
|
561
|
+
return path.join(os.homedir(), ".local", "share", "TasksAI", productId);
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
async function withLock(lockPath, callback) {
|
|
565
|
+
let handle;
|
|
566
|
+
try {
|
|
567
|
+
handle = await fsp.open(lockPath, "wx");
|
|
568
|
+
await handle.writeFile(String(process.pid));
|
|
569
|
+
return await callback();
|
|
570
|
+
} catch (error) {
|
|
571
|
+
if (error.code === "EEXIST") {
|
|
572
|
+
throw new Error(`Another TasksAI installer is already editing this config (${lockPath}).`);
|
|
573
|
+
}
|
|
574
|
+
throw error;
|
|
575
|
+
} finally {
|
|
576
|
+
if (handle) await handle.close();
|
|
577
|
+
await fsp.rm(lockPath, { force: true });
|
|
578
|
+
}
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
async function backupFile(filePath) {
|
|
582
|
+
if (!fs.existsSync(filePath)) return null;
|
|
583
|
+
const stamp = new Date().toISOString().replace(/[-:]/g, "").replace(/\..+/, "").replace("T", "-");
|
|
584
|
+
const backupPath = `${filePath}.tasksai-backup-${stamp}`;
|
|
585
|
+
await fsp.copyFile(filePath, backupPath);
|
|
586
|
+
return backupPath;
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
async function readJson(filePath) {
|
|
590
|
+
try {
|
|
591
|
+
return JSON.parse(await fsp.readFile(filePath, "utf8"));
|
|
592
|
+
} catch (error) {
|
|
593
|
+
throw new Error(`${filePath} could not be read as JSON. No changes were made. ${error.message}`);
|
|
594
|
+
}
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
async function writeJson(filePath, value) {
|
|
598
|
+
await fsp.mkdir(path.dirname(filePath), { recursive: true });
|
|
599
|
+
await atomicWriteJson(filePath, value);
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
async function atomicWriteJson(filePath, value) {
|
|
603
|
+
const text = `${JSON.stringify(value, null, 2)}\n`;
|
|
604
|
+
JSON.parse(text);
|
|
605
|
+
const tmpPath = `${filePath}.tmp-${process.pid}`;
|
|
606
|
+
await fsp.writeFile(tmpPath, text, { encoding: "utf8", mode: 0o600 });
|
|
607
|
+
await fsp.rename(tmpPath, filePath);
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
async function writeEnvFile(filePath, entries) {
|
|
611
|
+
const lines = Object.entries(entries).map(([key, value]) => `${key}=${String(value).replace(/\n/g, "")}`);
|
|
612
|
+
await fsp.writeFile(filePath, `${lines.join("\n")}\n`, { encoding: "utf8", mode: 0o600 });
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
async function logEvent(installDir, event, details = {}) {
|
|
616
|
+
const logDir = path.join(installDir, "logs");
|
|
617
|
+
await fsp.mkdir(logDir, { recursive: true });
|
|
618
|
+
const entry = {
|
|
619
|
+
timestamp: new Date().toISOString(),
|
|
620
|
+
installerVersion: INSTALLER_VERSION,
|
|
621
|
+
event,
|
|
622
|
+
details
|
|
623
|
+
};
|
|
624
|
+
const line = `${redact(JSON.stringify(entry))}\n`;
|
|
625
|
+
await fsp.appendFile(path.join(logDir, "installer.log"), line, "utf8");
|
|
626
|
+
}
|
|
627
|
+
|
|
628
|
+
function formatError(error) {
|
|
629
|
+
const message = error?.message || String(error);
|
|
630
|
+
return `TasksAI installer failed: ${redact(message)}`;
|
|
631
|
+
}
|
|
632
|
+
|
|
633
|
+
function redact(text) {
|
|
634
|
+
return String(text)
|
|
635
|
+
.replace(/\b[a-z]{2,4}_[A-Za-z0-9_-]{8,}\b/g, (match) => `${match.split("_")[0]}_[REDACTED]`)
|
|
636
|
+
.replace(/Bearer\s+[A-Za-z0-9._-]+/gi, "Bearer [REDACTED]")
|
|
637
|
+
.replace(/gh[opsu]_[A-Za-z0-9_]+/g, "gh_[REDACTED]");
|
|
638
|
+
}
|