@tasksai/install 0.1.2 → 0.1.4
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 +14 -0
- package/package.json +1 -1
- package/src/index.js +253 -38
package/README.md
CHANGED
|
@@ -8,6 +8,18 @@ Users normally run this through a product-specific GitHub manifest, for example:
|
|
|
8
8
|
npx @tasksai/install lawtasksai --source https://github.com/laudoluxDev/lawtasksai-mcp
|
|
9
9
|
```
|
|
10
10
|
|
|
11
|
+
In restricted agent environments, grant write access to the default TasksAI
|
|
12
|
+
application data folder and the selected MCP client config. If the runtime must
|
|
13
|
+
be installed elsewhere, pass an exact product install directory:
|
|
14
|
+
|
|
15
|
+
```bash
|
|
16
|
+
npx @tasksai/install farmer \
|
|
17
|
+
--source https://github.com/laudoluxDev/farmertasksai-mcp \
|
|
18
|
+
--install-dir /tmp/tasksai/farmer
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
`TASKSAI_INSTALL_DIR=/tmp/tasksai/farmer` is equivalent to `--install-dir`.
|
|
22
|
+
|
|
11
23
|
The installer:
|
|
12
24
|
|
|
13
25
|
- verifies the official vertical manifest
|
|
@@ -15,6 +27,8 @@ The installer:
|
|
|
15
27
|
- connects the user's TasksAI account through browser approval when available
|
|
16
28
|
- stores credentials outside MCP client config
|
|
17
29
|
- configures supported MCP clients
|
|
30
|
+
- checks required write access before changing local files
|
|
31
|
+
- supports Claude Desktop, Cursor, Windsurf, and Codex
|
|
18
32
|
- runs a local health check
|
|
19
33
|
|
|
20
34
|
TasksAI servers handle authentication, credits, catalog/search metadata, and
|
package/package.json
CHANGED
package/src/index.js
CHANGED
|
@@ -9,10 +9,11 @@ import readline from "node:readline/promises";
|
|
|
9
9
|
import { spawnSync } from "node:child_process";
|
|
10
10
|
import { stdin as input, stdout as output } from "node:process";
|
|
11
11
|
|
|
12
|
-
const INSTALLER_VERSION = "0.1.
|
|
12
|
+
const INSTALLER_VERSION = "0.1.4";
|
|
13
13
|
const DEFAULT_SOURCES = {
|
|
14
14
|
lawtasksai: "https://github.com/laudoluxDev/lawtasksai-mcp",
|
|
15
|
-
farmer: "https://github.com/laudoluxDev/farmertasksai-mcp"
|
|
15
|
+
farmer: "https://github.com/laudoluxDev/farmertasksai-mcp",
|
|
16
|
+
priorauthai: "https://github.com/laudoluxDev/priorauthai-mcp"
|
|
16
17
|
};
|
|
17
18
|
|
|
18
19
|
const CLIENTS = {
|
|
@@ -42,6 +43,14 @@ const CLIENTS = {
|
|
|
42
43
|
configPath() {
|
|
43
44
|
return path.join(os.homedir(), ".codeium", "windsurf", "mcp_config.json");
|
|
44
45
|
}
|
|
46
|
+
},
|
|
47
|
+
codex: {
|
|
48
|
+
id: "codex",
|
|
49
|
+
displayName: "Codex",
|
|
50
|
+
configFormat: "toml",
|
|
51
|
+
configPath() {
|
|
52
|
+
return path.join(os.homedir(), ".codex", "config.toml");
|
|
53
|
+
}
|
|
45
54
|
}
|
|
46
55
|
};
|
|
47
56
|
|
|
@@ -84,6 +93,7 @@ function parseArgs(argv) {
|
|
|
84
93
|
ref: "main",
|
|
85
94
|
client: "claude-desktop",
|
|
86
95
|
auth: "browser",
|
|
96
|
+
installDir: process.env.TASKSAI_INSTALL_DIR || null,
|
|
87
97
|
noBrowser: false,
|
|
88
98
|
yes: false,
|
|
89
99
|
skipPythonDeps: false
|
|
@@ -96,6 +106,7 @@ function parseArgs(argv) {
|
|
|
96
106
|
else if (arg === "--ref") options.ref = argv[++i];
|
|
97
107
|
else if (arg === "--client") options.client = argv[++i];
|
|
98
108
|
else if (arg === "--auth") options.auth = argv[++i];
|
|
109
|
+
else if (arg === "--install-dir") options.installDir = argv[++i];
|
|
99
110
|
else if (arg === "--no-browser") options.noBrowser = true;
|
|
100
111
|
else if (arg === "--yes" || arg === "-y") options.yes = true;
|
|
101
112
|
else if (arg === "--skip-python-deps") options.skipPythonDeps = true;
|
|
@@ -118,7 +129,7 @@ function parseArgs(argv) {
|
|
|
118
129
|
throw new Error(`Unsupported auth mode: ${options.auth}. Supported modes: browser, license-key`);
|
|
119
130
|
}
|
|
120
131
|
options.source ||= DEFAULT_SOURCES[options.productId];
|
|
121
|
-
if (!options.source) {
|
|
132
|
+
if (!options.source && ["install", "update"].includes(options.command)) {
|
|
122
133
|
throw new Error(`No default source is known for product: ${options.productId}`);
|
|
123
134
|
}
|
|
124
135
|
return options;
|
|
@@ -126,15 +137,19 @@ function parseArgs(argv) {
|
|
|
126
137
|
|
|
127
138
|
function printUsage() {
|
|
128
139
|
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]
|
|
140
|
+
tasksai-install <product-id> [install] [--source <repo-url>] [--ref <branch>] [--client claude-desktop|cursor|windsurf|codex|all] [--auth browser|license-key] [--install-dir <path>]
|
|
141
|
+
tasksai-install <product-id> doctor [--client claude-desktop|cursor|windsurf|codex|all] [--install-dir <path>]
|
|
142
|
+
tasksai-install <product-id> update [--install-dir <path>]
|
|
143
|
+
tasksai-install <product-id> uninstall [--client claude-desktop|cursor|windsurf|codex|all] [--install-dir <path>]
|
|
133
144
|
|
|
134
145
|
Examples:
|
|
135
146
|
tasksai-install lawtasksai --source https://github.com/laudoluxDev/lawtasksai-mcp
|
|
136
147
|
tasksai-install farmer --source https://github.com/laudoluxDev/farmertasksai-mcp
|
|
148
|
+
tasksai-install farmer --install-dir /tmp/tasksai/farmer
|
|
137
149
|
tasksai-install lawtasksai doctor
|
|
150
|
+
|
|
151
|
+
Environment:
|
|
152
|
+
TASKSAI_INSTALL_DIR may be used instead of --install-dir.
|
|
138
153
|
`);
|
|
139
154
|
}
|
|
140
155
|
|
|
@@ -144,9 +159,12 @@ async function install(options, { updateOnly = false } = {}) {
|
|
|
144
159
|
const vertical = await loadJson(source, "vertical.json");
|
|
145
160
|
verifySource({ options, source, manifest, vertical });
|
|
146
161
|
|
|
147
|
-
const installDir = getInstallDir(options.productId);
|
|
162
|
+
const installDir = getInstallDir(options.productId, options);
|
|
148
163
|
const runtimeDir = path.join(installDir, "runtime");
|
|
149
164
|
const vendorDir = path.join(installDir, "python");
|
|
165
|
+
const clients = updateOnly ? [] : resolveClients(options.client);
|
|
166
|
+
await preflightWriteAccess({ operation: updateOnly ? "update" : "install", installDir, clients });
|
|
167
|
+
|
|
150
168
|
await fsp.mkdir(runtimeDir, { recursive: true });
|
|
151
169
|
await fsp.mkdir(path.join(installDir, "logs"), { recursive: true });
|
|
152
170
|
await logEvent(installDir, "install_start", {
|
|
@@ -181,7 +199,6 @@ async function install(options, { updateOnly = false } = {}) {
|
|
|
181
199
|
return;
|
|
182
200
|
}
|
|
183
201
|
|
|
184
|
-
const clients = resolveClients(options.client);
|
|
185
202
|
for (const client of clients) {
|
|
186
203
|
await configureMcpClient({ client, vertical, installDir, runtimeDir, vendorDir });
|
|
187
204
|
}
|
|
@@ -196,7 +213,7 @@ async function install(options, { updateOnly = false } = {}) {
|
|
|
196
213
|
}
|
|
197
214
|
|
|
198
215
|
async function doctor(options, { quietSuccess = false } = {}) {
|
|
199
|
-
const installDir = getInstallDir(options.productId);
|
|
216
|
+
const installDir = getInstallDir(options.productId, options);
|
|
200
217
|
const verticalPath = path.join(installDir, "vertical.json");
|
|
201
218
|
const envPath = path.join(installDir, ".env");
|
|
202
219
|
const serverPath = path.join(installDir, "runtime", "server.py");
|
|
@@ -211,10 +228,10 @@ async function doctor(options, { quietSuccess = false } = {}) {
|
|
|
211
228
|
for (const client of clients) {
|
|
212
229
|
const configPath = client.configPath();
|
|
213
230
|
if (fs.existsSync(configPath)) {
|
|
214
|
-
const
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
}
|
|
231
|
+
const hasServer = client.configFormat === "toml"
|
|
232
|
+
? await codexConfigHasServer(configPath, vertical.product_id)
|
|
233
|
+
: await jsonConfigHasServer(configPath, vertical.product_id);
|
|
234
|
+
if (!hasServer) problems.push(`${client.displayName} config does not contain mcpServers.${vertical.product_id}`);
|
|
218
235
|
} else {
|
|
219
236
|
problems.push(`${client.displayName} config not found at ${configPath}`);
|
|
220
237
|
}
|
|
@@ -224,7 +241,7 @@ async function doctor(options, { quietSuccess = false } = {}) {
|
|
|
224
241
|
throw new Error(`TasksAI doctor found issues:\n- ${problems.join("\n- ")}`);
|
|
225
242
|
}
|
|
226
243
|
|
|
227
|
-
await
|
|
244
|
+
await safeLogEvent(installDir, "doctor_passed", {
|
|
228
245
|
productId: options.productId,
|
|
229
246
|
clients: clients.map((client) => client.id)
|
|
230
247
|
});
|
|
@@ -232,8 +249,9 @@ async function doctor(options, { quietSuccess = false } = {}) {
|
|
|
232
249
|
}
|
|
233
250
|
|
|
234
251
|
async function uninstall(options) {
|
|
235
|
-
const installDir = getInstallDir(options.productId);
|
|
252
|
+
const installDir = getInstallDir(options.productId, options);
|
|
236
253
|
const clients = resolveClients(options.client);
|
|
254
|
+
await preflightWriteAccess({ operation: "uninstall", installDir, clients });
|
|
237
255
|
|
|
238
256
|
for (const client of clients) {
|
|
239
257
|
const configPath = client.configPath();
|
|
@@ -243,15 +261,25 @@ async function uninstall(options) {
|
|
|
243
261
|
}
|
|
244
262
|
|
|
245
263
|
await withLock(`${configPath}.lock`, async () => {
|
|
246
|
-
const config = await readJson(configPath);
|
|
247
264
|
const key = options.productId;
|
|
248
|
-
if (
|
|
249
|
-
|
|
250
|
-
|
|
265
|
+
if (client.configFormat === "toml") {
|
|
266
|
+
const text = await fsp.readFile(configPath, "utf8");
|
|
267
|
+
if (!tomlHasMcpServer(text, key)) {
|
|
268
|
+
console.log(`${client.displayName} does not have a ${key} MCP entry.`);
|
|
269
|
+
return;
|
|
270
|
+
}
|
|
271
|
+
await backupFile(configPath);
|
|
272
|
+
await atomicWriteText(configPath, removeTomlSections(text, [`mcp_servers.${key}`, `mcp_servers.${key}.env`]));
|
|
273
|
+
} else {
|
|
274
|
+
const config = await readJson(configPath);
|
|
275
|
+
if (!config.mcpServers?.[key]) {
|
|
276
|
+
console.log(`${client.displayName} does not have a ${key} MCP entry.`);
|
|
277
|
+
return;
|
|
278
|
+
}
|
|
279
|
+
await backupFile(configPath);
|
|
280
|
+
delete config.mcpServers[key];
|
|
281
|
+
await atomicWriteJson(configPath, config);
|
|
251
282
|
}
|
|
252
|
-
await backupFile(configPath);
|
|
253
|
-
delete config.mcpServers[key];
|
|
254
|
-
await atomicWriteJson(configPath, config);
|
|
255
283
|
});
|
|
256
284
|
|
|
257
285
|
console.log(`${options.productId} was removed from ${client.displayName}.`);
|
|
@@ -268,26 +296,106 @@ async function configureMcpClient({ client, vertical, installDir, runtimeDir, ve
|
|
|
268
296
|
await fsp.mkdir(path.dirname(configPath), { recursive: true });
|
|
269
297
|
|
|
270
298
|
await withLock(`${configPath}.lock`, async () => {
|
|
271
|
-
const
|
|
272
|
-
if (!config.mcpServers || typeof config.mcpServers !== "object") config.mcpServers = {};
|
|
273
|
-
|
|
299
|
+
const serverConfig = buildMcpServerConfig({ client, vertical, installDir, runtimeDir, vendorDir });
|
|
274
300
|
await backupFile(configPath);
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
TASKSAI_API_BASE: vertical.api_base_url,
|
|
281
|
-
TASKSAI_CLIENT: client.id,
|
|
282
|
-
PYTHONPATH: vendorDir,
|
|
283
|
-
DOTENV_PATH: path.join(installDir, ".env")
|
|
284
|
-
}
|
|
285
|
-
};
|
|
301
|
+
if (client.configFormat === "toml") {
|
|
302
|
+
const text = fs.existsSync(configPath) ? await fsp.readFile(configPath, "utf8") : "";
|
|
303
|
+
await atomicWriteText(configPath, upsertCodexMcpServer(text, vertical.product_id, serverConfig));
|
|
304
|
+
return;
|
|
305
|
+
}
|
|
286
306
|
|
|
307
|
+
const config = fs.existsSync(configPath) ? await readJson(configPath) : {};
|
|
308
|
+
if (!config.mcpServers || typeof config.mcpServers !== "object") config.mcpServers = {};
|
|
309
|
+
config.mcpServers[vertical.product_id] = serverConfig;
|
|
287
310
|
await atomicWriteJson(configPath, config);
|
|
288
311
|
});
|
|
289
312
|
}
|
|
290
313
|
|
|
314
|
+
function buildMcpServerConfig({ client, vertical, installDir, runtimeDir, vendorDir }) {
|
|
315
|
+
return {
|
|
316
|
+
command: "python3",
|
|
317
|
+
args: [path.join(runtimeDir, "server.py")],
|
|
318
|
+
env: {
|
|
319
|
+
TASKSAI_PRODUCT_ID: vertical.product_id,
|
|
320
|
+
TASKSAI_API_BASE: vertical.api_base_url,
|
|
321
|
+
TASKSAI_CLIENT: client.id,
|
|
322
|
+
PYTHONPATH: vendorDir,
|
|
323
|
+
DOTENV_PATH: path.join(installDir, ".env")
|
|
324
|
+
}
|
|
325
|
+
};
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
async function jsonConfigHasServer(configPath, productId) {
|
|
329
|
+
const config = await readJson(configPath);
|
|
330
|
+
return Boolean(config.mcpServers?.[productId]);
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
async function codexConfigHasServer(configPath, productId) {
|
|
334
|
+
const text = await fsp.readFile(configPath, "utf8");
|
|
335
|
+
return tomlHasMcpServer(text, productId);
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
function tomlHasMcpServer(text, productId) {
|
|
339
|
+
return getTomlSectionNames(text).includes(`mcp_servers.${productId}`);
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
function getTomlSectionNames(text) {
|
|
343
|
+
return text
|
|
344
|
+
.split(/\r?\n/)
|
|
345
|
+
.map((line) => {
|
|
346
|
+
const match = line.match(/^\s*\[([^\]]+)\]\s*(?:#.*)?$/);
|
|
347
|
+
return match?.[1] || null;
|
|
348
|
+
})
|
|
349
|
+
.filter(Boolean);
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
function upsertCodexMcpServer(text, productId, serverConfig) {
|
|
353
|
+
const withoutServer = removeTomlSections(text, [`mcp_servers.${productId}`, `mcp_servers.${productId}.env`]).trimEnd();
|
|
354
|
+
const block = formatCodexMcpServer(productId, serverConfig);
|
|
355
|
+
return `${withoutServer ? `${withoutServer}\n\n` : ""}${block}\n`;
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
function removeTomlSections(text, sectionNames) {
|
|
359
|
+
const sections = new Set(sectionNames);
|
|
360
|
+
const lines = text.split(/\r?\n/);
|
|
361
|
+
const kept = [];
|
|
362
|
+
let skipping = false;
|
|
363
|
+
|
|
364
|
+
for (const line of lines) {
|
|
365
|
+
const match = line.match(/^\s*\[([^\]]+)\]\s*(?:#.*)?$/);
|
|
366
|
+
if (match) {
|
|
367
|
+
skipping = sections.has(match[1]);
|
|
368
|
+
}
|
|
369
|
+
if (!skipping) kept.push(line);
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
return kept.join("\n").replace(/\n{3,}/g, "\n\n");
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
function formatCodexMcpServer(productId, serverConfig) {
|
|
376
|
+
const lines = [
|
|
377
|
+
`[mcp_servers.${productId}]`,
|
|
378
|
+
`command = ${tomlString(serverConfig.command)}`,
|
|
379
|
+
`args = ${tomlArray(serverConfig.args)}`,
|
|
380
|
+
"",
|
|
381
|
+
`[mcp_servers.${productId}.env]`
|
|
382
|
+
];
|
|
383
|
+
|
|
384
|
+
for (const [key, value] of Object.entries(serverConfig.env || {})) {
|
|
385
|
+
lines.push(`${key} = ${tomlString(value)}`);
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
return lines.join("\n");
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
function tomlArray(values) {
|
|
392
|
+
return `[${values.map((value) => tomlString(value)).join(", ")}]`;
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
function tomlString(value) {
|
|
396
|
+
return `"${String(value).replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"`;
|
|
397
|
+
}
|
|
398
|
+
|
|
291
399
|
function resolveClients(clientOption) {
|
|
292
400
|
if (clientOption === "all") {
|
|
293
401
|
return Object.values(CLIENTS);
|
|
@@ -299,6 +407,79 @@ function resolveClients(clientOption) {
|
|
|
299
407
|
return [client];
|
|
300
408
|
}
|
|
301
409
|
|
|
410
|
+
async function preflightWriteAccess({ operation, installDir, clients }) {
|
|
411
|
+
const checks = [
|
|
412
|
+
{
|
|
413
|
+
label: "TasksAI install directory",
|
|
414
|
+
targetPath: installDir,
|
|
415
|
+
kind: "directory"
|
|
416
|
+
}
|
|
417
|
+
];
|
|
418
|
+
|
|
419
|
+
for (const client of clients) {
|
|
420
|
+
const configPath = client.configPath();
|
|
421
|
+
checks.push({
|
|
422
|
+
label: `${client.displayName} config directory`,
|
|
423
|
+
targetPath: path.dirname(configPath),
|
|
424
|
+
kind: "directory"
|
|
425
|
+
});
|
|
426
|
+
if (fs.existsSync(configPath)) {
|
|
427
|
+
checks.push({
|
|
428
|
+
label: `${client.displayName} config file`,
|
|
429
|
+
targetPath: configPath,
|
|
430
|
+
kind: "file"
|
|
431
|
+
});
|
|
432
|
+
}
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
const failures = [];
|
|
436
|
+
for (const check of checks) {
|
|
437
|
+
const problem = await checkWritable(check);
|
|
438
|
+
if (problem) failures.push(problem);
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
if (!failures.length) return;
|
|
442
|
+
|
|
443
|
+
const detail = failures
|
|
444
|
+
.map((failure) => `- ${failure.label}: ${failure.targetPath} (${failure.reason})`)
|
|
445
|
+
.join("\n");
|
|
446
|
+
throw new Error(
|
|
447
|
+
`TasksAI cannot ${operation} until it has write access to the required local paths:\n${detail}\n` +
|
|
448
|
+
"Grant write access to these paths, or use --install-dir /path/to/product-dir for the TasksAI runtime."
|
|
449
|
+
);
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
async function checkWritable({ label, targetPath, kind }) {
|
|
453
|
+
const resolvedPath = resolveUserPath(targetPath);
|
|
454
|
+
try {
|
|
455
|
+
if (fs.existsSync(resolvedPath)) {
|
|
456
|
+
const stat = await fsp.stat(resolvedPath);
|
|
457
|
+
if (kind === "directory" && !stat.isDirectory()) {
|
|
458
|
+
return { label, targetPath: resolvedPath, reason: "expected a directory" };
|
|
459
|
+
}
|
|
460
|
+
if (kind === "file" && !stat.isFile()) {
|
|
461
|
+
return { label, targetPath: resolvedPath, reason: "expected a file" };
|
|
462
|
+
}
|
|
463
|
+
await fsp.access(resolvedPath, fs.constants.W_OK);
|
|
464
|
+
if (kind === "file") {
|
|
465
|
+
await fsp.access(path.dirname(resolvedPath), fs.constants.W_OK);
|
|
466
|
+
}
|
|
467
|
+
return null;
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
const basePath = kind === "file" ? path.dirname(resolvedPath) : resolvedPath;
|
|
471
|
+
const ancestor = findExistingAncestor(basePath);
|
|
472
|
+
await fsp.access(ancestor, fs.constants.W_OK);
|
|
473
|
+
return null;
|
|
474
|
+
} catch (error) {
|
|
475
|
+
return {
|
|
476
|
+
label,
|
|
477
|
+
targetPath: resolvedPath,
|
|
478
|
+
reason: error.code === "EACCES" || error.code === "EPERM" ? "permission denied" : error.message
|
|
479
|
+
};
|
|
480
|
+
}
|
|
481
|
+
}
|
|
482
|
+
|
|
302
483
|
async function downloadRuntime(source, runtimeDir) {
|
|
303
484
|
const serverText = await loadText(source, "server.py");
|
|
304
485
|
const requirementsText = await loadText(source, "requirements.txt");
|
|
@@ -552,7 +733,10 @@ function delay(ms) {
|
|
|
552
733
|
});
|
|
553
734
|
}
|
|
554
735
|
|
|
555
|
-
function getInstallDir(productId) {
|
|
736
|
+
function getInstallDir(productId, options = {}) {
|
|
737
|
+
if (options.installDir) {
|
|
738
|
+
return resolveUserPath(options.installDir);
|
|
739
|
+
}
|
|
556
740
|
if (process.platform === "darwin") {
|
|
557
741
|
return path.join(os.homedir(), "Library", "Application Support", "TasksAI", productId);
|
|
558
742
|
}
|
|
@@ -562,6 +746,25 @@ function getInstallDir(productId) {
|
|
|
562
746
|
return path.join(os.homedir(), ".local", "share", "TasksAI", productId);
|
|
563
747
|
}
|
|
564
748
|
|
|
749
|
+
function resolveUserPath(value) {
|
|
750
|
+
if (!value) return value;
|
|
751
|
+
if (value === "~") return os.homedir();
|
|
752
|
+
if (value.startsWith("~/") || value.startsWith("~\\")) {
|
|
753
|
+
return path.join(os.homedir(), value.slice(2));
|
|
754
|
+
}
|
|
755
|
+
return path.resolve(value);
|
|
756
|
+
}
|
|
757
|
+
|
|
758
|
+
function findExistingAncestor(targetPath) {
|
|
759
|
+
let current = resolveUserPath(targetPath);
|
|
760
|
+
while (!fs.existsSync(current)) {
|
|
761
|
+
const parent = path.dirname(current);
|
|
762
|
+
if (parent === current) return current;
|
|
763
|
+
current = parent;
|
|
764
|
+
}
|
|
765
|
+
return current;
|
|
766
|
+
}
|
|
767
|
+
|
|
565
768
|
async function withLock(lockPath, callback) {
|
|
566
769
|
let handle;
|
|
567
770
|
try {
|
|
@@ -603,6 +806,10 @@ async function writeJson(filePath, value) {
|
|
|
603
806
|
async function atomicWriteJson(filePath, value) {
|
|
604
807
|
const text = `${JSON.stringify(value, null, 2)}\n`;
|
|
605
808
|
JSON.parse(text);
|
|
809
|
+
await atomicWriteText(filePath, text);
|
|
810
|
+
}
|
|
811
|
+
|
|
812
|
+
async function atomicWriteText(filePath, text) {
|
|
606
813
|
const tmpPath = `${filePath}.tmp-${process.pid}`;
|
|
607
814
|
await fsp.writeFile(tmpPath, text, { encoding: "utf8", mode: 0o600 });
|
|
608
815
|
await fsp.rename(tmpPath, filePath);
|
|
@@ -626,6 +833,14 @@ async function logEvent(installDir, event, details = {}) {
|
|
|
626
833
|
await fsp.appendFile(path.join(logDir, "installer.log"), line, "utf8");
|
|
627
834
|
}
|
|
628
835
|
|
|
836
|
+
async function safeLogEvent(installDir, event, details = {}) {
|
|
837
|
+
try {
|
|
838
|
+
await logEvent(installDir, event, details);
|
|
839
|
+
} catch {
|
|
840
|
+
// Health checks should report installation health, not fail on log writes.
|
|
841
|
+
}
|
|
842
|
+
}
|
|
843
|
+
|
|
629
844
|
function formatError(error) {
|
|
630
845
|
const message = error?.message || String(error);
|
|
631
846
|
return `TasksAI installer failed: ${redact(message)}`;
|