@tasksai/install 0.1.2 → 0.1.3

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.
Files changed (3) hide show
  1. package/README.md +13 -0
  2. package/package.json +1 -1
  3. package/src/index.js +126 -13
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,7 @@ 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
18
31
  - runs a local health check
19
32
 
20
33
  TasksAI servers handle authentication, credits, catalog/search metadata, and
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tasksai/install",
3
- "version": "0.1.2",
3
+ "version": "0.1.3",
4
4
  "description": "Shared TasksAI MCP installer CLI",
5
5
  "type": "module",
6
6
  "bin": {
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.2";
12
+ const INSTALLER_VERSION = "0.1.3";
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 = {
@@ -84,6 +85,7 @@ function parseArgs(argv) {
84
85
  ref: "main",
85
86
  client: "claude-desktop",
86
87
  auth: "browser",
88
+ installDir: process.env.TASKSAI_INSTALL_DIR || null,
87
89
  noBrowser: false,
88
90
  yes: false,
89
91
  skipPythonDeps: false
@@ -96,6 +98,7 @@ function parseArgs(argv) {
96
98
  else if (arg === "--ref") options.ref = argv[++i];
97
99
  else if (arg === "--client") options.client = argv[++i];
98
100
  else if (arg === "--auth") options.auth = argv[++i];
101
+ else if (arg === "--install-dir") options.installDir = argv[++i];
99
102
  else if (arg === "--no-browser") options.noBrowser = true;
100
103
  else if (arg === "--yes" || arg === "-y") options.yes = true;
101
104
  else if (arg === "--skip-python-deps") options.skipPythonDeps = true;
@@ -118,7 +121,7 @@ function parseArgs(argv) {
118
121
  throw new Error(`Unsupported auth mode: ${options.auth}. Supported modes: browser, license-key`);
119
122
  }
120
123
  options.source ||= DEFAULT_SOURCES[options.productId];
121
- if (!options.source) {
124
+ if (!options.source && ["install", "update"].includes(options.command)) {
122
125
  throw new Error(`No default source is known for product: ${options.productId}`);
123
126
  }
124
127
  return options;
@@ -126,15 +129,19 @@ function parseArgs(argv) {
126
129
 
127
130
  function printUsage() {
128
131
  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]
132
+ tasksai-install <product-id> [install] [--source <repo-url>] [--ref <branch>] [--client claude-desktop|cursor|windsurf|all] [--auth browser|license-key] [--install-dir <path>]
133
+ tasksai-install <product-id> doctor [--client claude-desktop|cursor|windsurf|all] [--install-dir <path>]
134
+ tasksai-install <product-id> update [--install-dir <path>]
135
+ tasksai-install <product-id> uninstall [--client claude-desktop|cursor|windsurf|all] [--install-dir <path>]
133
136
 
134
137
  Examples:
135
138
  tasksai-install lawtasksai --source https://github.com/laudoluxDev/lawtasksai-mcp
136
139
  tasksai-install farmer --source https://github.com/laudoluxDev/farmertasksai-mcp
140
+ tasksai-install farmer --install-dir /tmp/tasksai/farmer
137
141
  tasksai-install lawtasksai doctor
142
+
143
+ Environment:
144
+ TASKSAI_INSTALL_DIR may be used instead of --install-dir.
138
145
  `);
139
146
  }
140
147
 
@@ -144,9 +151,12 @@ async function install(options, { updateOnly = false } = {}) {
144
151
  const vertical = await loadJson(source, "vertical.json");
145
152
  verifySource({ options, source, manifest, vertical });
146
153
 
147
- const installDir = getInstallDir(options.productId);
154
+ const installDir = getInstallDir(options.productId, options);
148
155
  const runtimeDir = path.join(installDir, "runtime");
149
156
  const vendorDir = path.join(installDir, "python");
157
+ const clients = updateOnly ? [] : resolveClients(options.client);
158
+ await preflightWriteAccess({ operation: updateOnly ? "update" : "install", installDir, clients });
159
+
150
160
  await fsp.mkdir(runtimeDir, { recursive: true });
151
161
  await fsp.mkdir(path.join(installDir, "logs"), { recursive: true });
152
162
  await logEvent(installDir, "install_start", {
@@ -181,7 +191,6 @@ async function install(options, { updateOnly = false } = {}) {
181
191
  return;
182
192
  }
183
193
 
184
- const clients = resolveClients(options.client);
185
194
  for (const client of clients) {
186
195
  await configureMcpClient({ client, vertical, installDir, runtimeDir, vendorDir });
187
196
  }
@@ -196,7 +205,7 @@ async function install(options, { updateOnly = false } = {}) {
196
205
  }
197
206
 
198
207
  async function doctor(options, { quietSuccess = false } = {}) {
199
- const installDir = getInstallDir(options.productId);
208
+ const installDir = getInstallDir(options.productId, options);
200
209
  const verticalPath = path.join(installDir, "vertical.json");
201
210
  const envPath = path.join(installDir, ".env");
202
211
  const serverPath = path.join(installDir, "runtime", "server.py");
@@ -224,7 +233,7 @@ async function doctor(options, { quietSuccess = false } = {}) {
224
233
  throw new Error(`TasksAI doctor found issues:\n- ${problems.join("\n- ")}`);
225
234
  }
226
235
 
227
- await logEvent(installDir, "doctor_passed", {
236
+ await safeLogEvent(installDir, "doctor_passed", {
228
237
  productId: options.productId,
229
238
  clients: clients.map((client) => client.id)
230
239
  });
@@ -232,8 +241,9 @@ async function doctor(options, { quietSuccess = false } = {}) {
232
241
  }
233
242
 
234
243
  async function uninstall(options) {
235
- const installDir = getInstallDir(options.productId);
244
+ const installDir = getInstallDir(options.productId, options);
236
245
  const clients = resolveClients(options.client);
246
+ await preflightWriteAccess({ operation: "uninstall", installDir, clients });
237
247
 
238
248
  for (const client of clients) {
239
249
  const configPath = client.configPath();
@@ -299,6 +309,79 @@ function resolveClients(clientOption) {
299
309
  return [client];
300
310
  }
301
311
 
312
+ async function preflightWriteAccess({ operation, installDir, clients }) {
313
+ const checks = [
314
+ {
315
+ label: "TasksAI install directory",
316
+ targetPath: installDir,
317
+ kind: "directory"
318
+ }
319
+ ];
320
+
321
+ for (const client of clients) {
322
+ const configPath = client.configPath();
323
+ checks.push({
324
+ label: `${client.displayName} config directory`,
325
+ targetPath: path.dirname(configPath),
326
+ kind: "directory"
327
+ });
328
+ if (fs.existsSync(configPath)) {
329
+ checks.push({
330
+ label: `${client.displayName} config file`,
331
+ targetPath: configPath,
332
+ kind: "file"
333
+ });
334
+ }
335
+ }
336
+
337
+ const failures = [];
338
+ for (const check of checks) {
339
+ const problem = await checkWritable(check);
340
+ if (problem) failures.push(problem);
341
+ }
342
+
343
+ if (!failures.length) return;
344
+
345
+ const detail = failures
346
+ .map((failure) => `- ${failure.label}: ${failure.targetPath} (${failure.reason})`)
347
+ .join("\n");
348
+ throw new Error(
349
+ `TasksAI cannot ${operation} until it has write access to the required local paths:\n${detail}\n` +
350
+ "Grant write access to these paths, or use --install-dir /path/to/product-dir for the TasksAI runtime."
351
+ );
352
+ }
353
+
354
+ async function checkWritable({ label, targetPath, kind }) {
355
+ const resolvedPath = resolveUserPath(targetPath);
356
+ try {
357
+ if (fs.existsSync(resolvedPath)) {
358
+ const stat = await fsp.stat(resolvedPath);
359
+ if (kind === "directory" && !stat.isDirectory()) {
360
+ return { label, targetPath: resolvedPath, reason: "expected a directory" };
361
+ }
362
+ if (kind === "file" && !stat.isFile()) {
363
+ return { label, targetPath: resolvedPath, reason: "expected a file" };
364
+ }
365
+ await fsp.access(resolvedPath, fs.constants.W_OK);
366
+ if (kind === "file") {
367
+ await fsp.access(path.dirname(resolvedPath), fs.constants.W_OK);
368
+ }
369
+ return null;
370
+ }
371
+
372
+ const basePath = kind === "file" ? path.dirname(resolvedPath) : resolvedPath;
373
+ const ancestor = findExistingAncestor(basePath);
374
+ await fsp.access(ancestor, fs.constants.W_OK);
375
+ return null;
376
+ } catch (error) {
377
+ return {
378
+ label,
379
+ targetPath: resolvedPath,
380
+ reason: error.code === "EACCES" || error.code === "EPERM" ? "permission denied" : error.message
381
+ };
382
+ }
383
+ }
384
+
302
385
  async function downloadRuntime(source, runtimeDir) {
303
386
  const serverText = await loadText(source, "server.py");
304
387
  const requirementsText = await loadText(source, "requirements.txt");
@@ -552,7 +635,10 @@ function delay(ms) {
552
635
  });
553
636
  }
554
637
 
555
- function getInstallDir(productId) {
638
+ function getInstallDir(productId, options = {}) {
639
+ if (options.installDir) {
640
+ return resolveUserPath(options.installDir);
641
+ }
556
642
  if (process.platform === "darwin") {
557
643
  return path.join(os.homedir(), "Library", "Application Support", "TasksAI", productId);
558
644
  }
@@ -562,6 +648,25 @@ function getInstallDir(productId) {
562
648
  return path.join(os.homedir(), ".local", "share", "TasksAI", productId);
563
649
  }
564
650
 
651
+ function resolveUserPath(value) {
652
+ if (!value) return value;
653
+ if (value === "~") return os.homedir();
654
+ if (value.startsWith("~/") || value.startsWith("~\\")) {
655
+ return path.join(os.homedir(), value.slice(2));
656
+ }
657
+ return path.resolve(value);
658
+ }
659
+
660
+ function findExistingAncestor(targetPath) {
661
+ let current = resolveUserPath(targetPath);
662
+ while (!fs.existsSync(current)) {
663
+ const parent = path.dirname(current);
664
+ if (parent === current) return current;
665
+ current = parent;
666
+ }
667
+ return current;
668
+ }
669
+
565
670
  async function withLock(lockPath, callback) {
566
671
  let handle;
567
672
  try {
@@ -626,6 +731,14 @@ async function logEvent(installDir, event, details = {}) {
626
731
  await fsp.appendFile(path.join(logDir, "installer.log"), line, "utf8");
627
732
  }
628
733
 
734
+ async function safeLogEvent(installDir, event, details = {}) {
735
+ try {
736
+ await logEvent(installDir, event, details);
737
+ } catch {
738
+ // Health checks should report installation health, not fail on log writes.
739
+ }
740
+ }
741
+
629
742
  function formatError(error) {
630
743
  const message = error?.message || String(error);
631
744
  return `TasksAI installer failed: ${redact(message)}`;