@tasksai/install 0.1.39 → 0.1.40
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 +34 -0
- package/bootstrap/Install RealtorTasksAI.command +37 -0
- package/bootstrap/Install RealtorTasksAI.ps1 +42 -0
- package/package.json +7 -2
- package/runtime/document_renderer.py +882 -0
- package/runtime/server.py +55 -581
- package/runtime/software_use.py +39 -0
- package/runtime/workflow-requirements.txt +5 -0
- package/runtime/workflows/__init__.py +0 -0
- package/runtime/workflows/account_snapshot.py +33 -0
- package/runtime/workflows/attachments.py +45 -0
- package/runtime/workflows/authority_guides.py +81 -0
- package/runtime/workflows/catalog.py +51 -0
- package/runtime/workflows/catalog_app.py +174 -0
- package/runtime/workflows/catalog_generation.py +186 -0
- package/runtime/workflows/catalog_output.py +312 -0
- package/runtime/workflows/catalog_suggestions.py +51 -0
- package/runtime/workflows/catalog_workspace.py +152 -0
- package/runtime/workflows/cli.py +66 -0
- package/runtime/workflows/document_answers.py +96 -0
- package/runtime/workflows/document_selection.py +50 -0
- package/runtime/workflows/documents.py +243 -0
- package/runtime/workflows/embedded/catalog.html +83 -0
- package/runtime/workflows/embedded/offer-review.html +516 -0
- package/runtime/workflows/embedded/preview.html +36 -0
- package/runtime/workflows/embedded_actions.py +96 -0
- package/runtime/workflows/embedded_demo.py +424 -0
- package/runtime/workflows/folders.py +120 -0
- package/runtime/workflows/gateway.py +157 -0
- package/runtime/workflows/generation_lock.py +26 -0
- package/runtime/workflows/launcher.py +61 -0
- package/runtime/workflows/licensed_delivery.py +46 -0
- package/runtime/workflows/mcp_dev.py +52 -0
- package/runtime/workflows/meeting_plan.py +92 -0
- package/runtime/workflows/model_client.py +26 -0
- package/runtime/workflows/numeric_consistency.py +72 -0
- package/runtime/workflows/public_source_fetch.py +66 -0
- package/runtime/workflows/realtor/__init__.py +0 -0
- package/runtime/workflows/realtor/adapter.py +179 -0
- package/runtime/workflows/realtor/seller_offer/ORIGIN.json +16 -0
- package/runtime/workflows/realtor/seller_offer/__init__.py +0 -0
- package/runtime/workflows/realtor/seller_offer/examples/demo-input.json +414 -0
- package/runtime/workflows/realtor/seller_offer/examples/make_demo.py +44 -0
- package/runtime/workflows/realtor/seller_offer/references/input-contract.md +65 -0
- package/runtime/workflows/realtor/seller_offer/references/source-boundaries.md +16 -0
- package/runtime/workflows/realtor/seller_offer/scripts/__init__.py +0 -0
- package/runtime/workflows/realtor/seller_offer/scripts/build_workbook.mjs +233 -0
- package/runtime/workflows/realtor/seller_offer/scripts/build_workbook.py +163 -0
- package/runtime/workflows/realtor/seller_offer/scripts/offer_engine.py +370 -0
- package/runtime/workflows/realtor/seller_offer/scripts/presentation.py +52 -0
- package/runtime/workflows/realtor/seller_offer/scripts/render_outputs.py +228 -0
- package/runtime/workflows/realtor/seller_offer/scripts/run_package.py +95 -0
- package/runtime/workflows/realtor/seller_offer/tests/test_engine.py +252 -0
- package/runtime/workflows/realtor/seller_offer/tests/test_workbook.mjs +28 -0
- package/runtime/workflows/realtor-release-registry.json +705 -0
- package/runtime/workflows/released_catalog.py +91 -0
- package/runtime/workflows/source_capture.py +82 -0
- package/runtime/workflows/store.py +492 -0
- package/runtime/workflows/table_calculations.py +132 -0
- package/runtime/workflows/template.py +65 -0
- package/runtime/workflows/workspace_launch.py +76 -0
- package/src/index.js +205 -118
- package/src/managed-python.js +63 -0
- package/src/operation-lock.js +22 -0
- package/src/prepared-update.js +86 -0
- package/src/private-workflow.js +116 -0
- package/src/python-runtime.js +50 -0
- package/src/recover-installation.js +30 -0
- package/src/recovery-lock.js +30 -0
- package/src/software-hash.js +24 -0
- package/src/software-use.js +32 -0
- package/src/update-journal.js +24 -0
- package/src/update-recovery.js +72 -0
- package/src/workspace-runtime.js +45 -0
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
"""Shared presentation and input contract, independent of hosting and exporters.
|
|
2
|
+
|
|
3
|
+
Only server-owned definitions may select brands and outputs. This module does
|
|
4
|
+
not select a tenant, authorize access, generate content, or set credit prices.
|
|
5
|
+
"""
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
from .store import JobError
|
|
8
|
+
|
|
9
|
+
BRANDS = {
|
|
10
|
+
"realtor": {"name": "RealtorTasksAI", "accent": "#347462"},
|
|
11
|
+
"law": {"name": "LawTasksAI", "accent": "#375b88"},
|
|
12
|
+
"farmer": {"name": "FarmerTasksAI", "accent": "#657a39"},
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
@dataclass(frozen=True)
|
|
16
|
+
class InputField:
|
|
17
|
+
key: str
|
|
18
|
+
label: str
|
|
19
|
+
required: bool = True
|
|
20
|
+
|
|
21
|
+
@dataclass(frozen=True)
|
|
22
|
+
class OutputFile:
|
|
23
|
+
kind: str
|
|
24
|
+
label: str
|
|
25
|
+
|
|
26
|
+
@dataclass(frozen=True)
|
|
27
|
+
class WorkflowTemplate:
|
|
28
|
+
workflow_id: str
|
|
29
|
+
vertical: str
|
|
30
|
+
title: str
|
|
31
|
+
inputs: tuple[InputField, ...] | None
|
|
32
|
+
outputs: tuple[OutputFile, ...]
|
|
33
|
+
|
|
34
|
+
def __post_init__(self):
|
|
35
|
+
if self.vertical not in BRANDS:
|
|
36
|
+
raise JobError("Unknown workflow vertical")
|
|
37
|
+
kinds = [output.kind for output in self.outputs]
|
|
38
|
+
if "word" not in kinds or len(kinds) != len(set(kinds)) or set(kinds) - {"word", "excel"}:
|
|
39
|
+
raise JobError("Every workflow needs one Word document; Excel is optional")
|
|
40
|
+
keys = [field.key for field in (self.inputs or ())]
|
|
41
|
+
if len(keys) != len(set(keys)):
|
|
42
|
+
raise JobError("Input names must be unique")
|
|
43
|
+
|
|
44
|
+
def input_state(self, answers=None):
|
|
45
|
+
if self.inputs is None:
|
|
46
|
+
raise JobError("This workflow uses its own structured input adapter")
|
|
47
|
+
answers = {} if answers is None else answers
|
|
48
|
+
known = {field.key for field in self.inputs}
|
|
49
|
+
if not isinstance(answers, dict) or set(answers) - known:
|
|
50
|
+
raise JobError("Answers must match this workflow's inputs")
|
|
51
|
+
if any(not isinstance(value, str) for value in answers.values()):
|
|
52
|
+
raise JobError("Answers must be text")
|
|
53
|
+
values = {field.key: answers.get(field.key, "").strip() for field in self.inputs}
|
|
54
|
+
missing = [{"key": field.key, "label": field.label} for field in self.inputs
|
|
55
|
+
if field.required and not values[field.key]]
|
|
56
|
+
return {"answers": values, "missing": missing, "ready": not missing}
|
|
57
|
+
|
|
58
|
+
def presentation(self):
|
|
59
|
+
return {"workflow_id": self.workflow_id, "brand": dict(BRANDS[self.vertical]),
|
|
60
|
+
"title": self.title, "outputs": [{"kind": out.kind, "label": out.label} for out in self.outputs]}
|
|
61
|
+
|
|
62
|
+
SELLER_OFFER = WorkflowTemplate("realtor.seller_offer", "realtor", "Offer comparison", None, (
|
|
63
|
+
OutputFile("word", "Download Word briefing"), OutputFile("excel", "Download Excel comparison")))
|
|
64
|
+
# None delegates input validation to the existing structured offer adapter.
|
|
65
|
+
# An empty tuple represents a workflow requiring no inputs.
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
"""Start or reuse the installed local workspace after licensed delivery."""
|
|
2
|
+
import hashlib
|
|
3
|
+
from contextlib import contextmanager
|
|
4
|
+
import json
|
|
5
|
+
import os
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
import subprocess
|
|
8
|
+
import sys
|
|
9
|
+
import time
|
|
10
|
+
from urllib.parse import urlparse
|
|
11
|
+
from urllib.request import urlopen
|
|
12
|
+
from .licensed_delivery import safe_directory
|
|
13
|
+
from .store import JobError
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def workspace_identity(root,owner):
|
|
17
|
+
return hashlib.sha256((str(Path(root).resolve())+'\n'+owner).encode()).hexdigest()
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def runtime_fingerprint():
|
|
21
|
+
runtime=Path(__file__).resolve().parents[1]
|
|
22
|
+
paths=[runtime/'document_renderer.py',runtime/'software_use.py']+sorted((runtime/'workflows').rglob('*.py'))+sorted((runtime/'workflows'/'embedded').glob('*.html'))+[runtime/'workflows'/'realtor-release-registry.json']
|
|
23
|
+
return hashlib.sha256(json.dumps({str(p.relative_to(runtime)):hashlib.sha256(p.read_bytes()).hexdigest() for p in paths},sort_keys=True).encode()).hexdigest()
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def existing_url(root,owner):
|
|
27
|
+
try:
|
|
28
|
+
record=json.loads((Path(root)/'workspace-connection.json').read_text(encoding="utf-8"));url=record['url'];parsed=urlparse(url)
|
|
29
|
+
if parsed.scheme!='http' or parsed.hostname!='127.0.0.1' or not parsed.port or not parsed.path.startswith('/workspace/') or parsed.query or parsed.fragment:return None
|
|
30
|
+
with urlopen(url+'/health',timeout=1) as response: health=json.load(response)
|
|
31
|
+
return url if not health.get('closing') and health.get('workspace_id')==workspace_identity(root,owner) and health.get('runtime_fingerprint')==runtime_fingerprint() else None
|
|
32
|
+
except (OSError,ValueError,KeyError):return None
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _launch(root,owner):
|
|
36
|
+
root=safe_directory(root)
|
|
37
|
+
url=existing_url(root,owner)
|
|
38
|
+
if url:return url
|
|
39
|
+
# Each spawned app uses a distinct ephemeral port. Concurrent starts cannot
|
|
40
|
+
# overwrite customer data; a fresh health check only accepts this workspace.
|
|
41
|
+
runtime=Path(__file__).resolve().parents[1]
|
|
42
|
+
env={**os.environ,'PYTHONPATH':str(runtime)+os.pathsep+os.environ.get('PYTHONPATH','')}
|
|
43
|
+
flags={'creationflags':subprocess.CREATE_NO_WINDOW} if os.name=='nt' else {'start_new_session':True}
|
|
44
|
+
log=root/'workspace-server.log'
|
|
45
|
+
if log.is_symlink():raise JobError('Workspace log must not be a symbolic link')
|
|
46
|
+
with log.open('ab') as output:
|
|
47
|
+
process=subprocess.Popen([sys.executable,'-m','workflows.catalog_app','--root',str(root),'--owner',owner],cwd=runtime,env=env,stdin=subprocess.DEVNULL,stdout=output,stderr=output,**flags)
|
|
48
|
+
for _ in range(50):
|
|
49
|
+
url=existing_url(root,owner)
|
|
50
|
+
if url:return url
|
|
51
|
+
if process.poll() is not None:break
|
|
52
|
+
time.sleep(.1)
|
|
53
|
+
raise JobError('The local workspace could not start; the delivered skill remains available')
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
@contextmanager
|
|
57
|
+
def startup_lock(root):
|
|
58
|
+
path=root/'workspace-startup.lock'
|
|
59
|
+
if path.is_symlink():raise JobError('Workspace lock must not be a symbolic link')
|
|
60
|
+
with path.open('a+b') as stream:
|
|
61
|
+
if os.name=='nt':
|
|
62
|
+
import msvcrt
|
|
63
|
+
if path.stat().st_size==0:stream.write(b'0');stream.flush()
|
|
64
|
+
stream.seek(0);msvcrt.locking(stream.fileno(),msvcrt.LK_LOCK,1)
|
|
65
|
+
else:
|
|
66
|
+
import fcntl
|
|
67
|
+
fcntl.flock(stream.fileno(),fcntl.LOCK_EX)
|
|
68
|
+
try:yield
|
|
69
|
+
finally:
|
|
70
|
+
if os.name=='nt':stream.seek(0);msvcrt.locking(stream.fileno(),msvcrt.LK_UNLCK,1)
|
|
71
|
+
else:fcntl.flock(stream.fileno(),fcntl.LOCK_UN)
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def launch(root,owner):
|
|
75
|
+
root=safe_directory(root)
|
|
76
|
+
with startup_lock(root):return _launch(root,owner)
|
package/src/index.js
CHANGED
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
|
|
3
3
|
import fs from "node:fs";
|
|
4
|
+
import { requirePython, ensurePython, setPythonInstallRoot } from "./python-runtime.js";
|
|
5
|
+
import { withLock } from "./operation-lock.js";
|
|
4
6
|
import fsp from "node:fs/promises";
|
|
5
|
-
import { randomUUID } from "node:crypto";
|
|
7
|
+
import { randomUUID, createHash } from "node:crypto";
|
|
6
8
|
import https from "node:https";
|
|
7
9
|
import os from "node:os";
|
|
8
10
|
import path from "node:path";
|
|
@@ -123,18 +125,31 @@ function isMainModule() {
|
|
|
123
125
|
}
|
|
124
126
|
|
|
125
127
|
async function main() {
|
|
128
|
+
if (process.argv[2] === "private-workflow") {
|
|
129
|
+
const { installPrivateWorkflow } = await import("./private-workflow.js");
|
|
130
|
+
await installPrivateWorkflow(process.argv.slice(3));
|
|
131
|
+
return;
|
|
132
|
+
}
|
|
126
133
|
const options = parseArgs(process.argv.slice(2));
|
|
127
134
|
|
|
128
135
|
if (!options.productId) {
|
|
129
136
|
printUsage();
|
|
130
137
|
process.exit(1);
|
|
131
138
|
}
|
|
139
|
+
setPythonInstallRoot(getInstallDir(options.productId, options));
|
|
132
140
|
|
|
133
141
|
if (options.command === "doctor") {
|
|
134
142
|
await doctor(options);
|
|
135
143
|
return;
|
|
136
144
|
}
|
|
137
145
|
|
|
146
|
+
if (options.command === "recover") {
|
|
147
|
+
const { recoverInstallation } = await import("./recover-installation.js");
|
|
148
|
+
const result = await recoverInstallation(getInstallDir(options.productId, options));
|
|
149
|
+
console.log(result.status === "not_needed" ? "No unfinished software update was found." : "Previous TasksAI software restored. Restart your AI app, then run the loader doctor check before resuming work. Recovery copies have been retained.");
|
|
150
|
+
return;
|
|
151
|
+
}
|
|
152
|
+
|
|
138
153
|
if (options.command === "update") {
|
|
139
154
|
await install(options, { updateOnly: true });
|
|
140
155
|
return;
|
|
@@ -187,7 +202,7 @@ function parseArgs(argv) {
|
|
|
187
202
|
|
|
188
203
|
options.productId = positionals[0] || null;
|
|
189
204
|
if (positionals[1]) options.command = positionals[1];
|
|
190
|
-
if (!["install", "doctor", "update", "uninstall"].includes(options.command)) {
|
|
205
|
+
if (!["install", "doctor", "update", "uninstall", "recover"].includes(options.command)) {
|
|
191
206
|
throw new Error(`Unsupported command: ${options.command}`);
|
|
192
207
|
}
|
|
193
208
|
if (!["browser", "license-key"].includes(options.auth)) {
|
|
@@ -205,6 +220,7 @@ function printUsage() {
|
|
|
205
220
|
tasksai-install <product-id> [install] [--source <repo-url>] [--ref <branch>] [--client claude-desktop|cursor|windsurf|codex|all] [--auth browser|license-key] [--install-dir <path>]
|
|
206
221
|
tasksai-install <product-id> doctor [--client claude-desktop|cursor|windsurf|codex|all] [--install-dir <path>]
|
|
207
222
|
tasksai-install <product-id> update [--install-dir <path>]
|
|
223
|
+
tasksai-install <product-id> recover [--install-dir <path>]
|
|
208
224
|
tasksai-install <product-id> uninstall [--client claude-desktop|cursor|windsurf|codex|all] [--install-dir <path>]
|
|
209
225
|
|
|
210
226
|
Examples:
|
|
@@ -239,58 +255,79 @@ async function install(options, { updateOnly = false } = {}) {
|
|
|
239
255
|
const clients = updateOnly ? [] : resolveClients(options.client);
|
|
240
256
|
await preflightWriteAccess({ operation: updateOnly ? "update" : "install", installDir, clients, options, vertical, source });
|
|
241
257
|
|
|
242
|
-
await fsp.mkdir(
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
await writeJson(path.join(installDir, "vertical.json"), vertical);
|
|
253
|
-
await downloadRuntime(source, runtimeDir, manifest);
|
|
254
|
-
|
|
255
|
-
const licenseKey = await resolveLicenseKey(options, vertical, installDir);
|
|
256
|
-
const existingEnvPath = path.join(installDir, ".env");
|
|
257
|
-
const existingEnv = fs.existsSync(existingEnvPath)
|
|
258
|
-
? parseEnv(await fsp.readFile(existingEnvPath, "utf8"))
|
|
259
|
-
: {};
|
|
260
|
-
const installId = firstValue(existingEnv.TASKSAI_INSTALL_ID, process.env.TASKSAI_INSTALL_ID) || randomUUID();
|
|
261
|
-
|
|
262
|
-
if (!options.skipPythonDeps) {
|
|
263
|
-
installPythonDeps(runtimeDir, vendorDir);
|
|
264
|
-
}
|
|
258
|
+
await fsp.mkdir(installDir, { recursive: true });
|
|
259
|
+
return withLock(path.join(installDir, ".installer-operation.lock"), async () => {
|
|
260
|
+
await fsp.mkdir(runtimeDir, { recursive: true });
|
|
261
|
+
await fsp.mkdir(path.join(installDir, "logs"), { recursive: true });
|
|
262
|
+
await logEvent(installDir, "install_start", {
|
|
263
|
+
productId: options.productId,
|
|
264
|
+
source: source.repoUrl,
|
|
265
|
+
client: options.client,
|
|
266
|
+
updateOnly
|
|
267
|
+
});
|
|
265
268
|
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
TASKSAI_INSTALL_ID
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
269
|
+
const licenseKey = await resolveLicenseKey(options, vertical, installDir);
|
|
270
|
+
await ensurePython(installDir);
|
|
271
|
+
const existingEnvPath = path.join(installDir, ".env");
|
|
272
|
+
const existingEnv = fs.existsSync(existingEnvPath)
|
|
273
|
+
? parseEnv(await fsp.readFile(existingEnvPath, "utf8"))
|
|
274
|
+
: {};
|
|
275
|
+
const installId = firstValue(existingEnv.TASKSAI_INSTALL_ID, process.env.TASKSAI_INSTALL_ID) || randomUUID();
|
|
276
|
+
|
|
277
|
+
const envEntries = {
|
|
278
|
+
TASKSAI_LICENSE_KEY: licenseKey,
|
|
279
|
+
LAWTASKSAI_LICENSE_KEY: licenseKey,
|
|
280
|
+
TASKSAI_PRODUCT_ID: vertical.product_id,
|
|
281
|
+
TASKSAI_API_BASE: vertical.api_base_url,
|
|
282
|
+
LAWTASKSAI_API_BASE: vertical.api_base_url,
|
|
283
|
+
TASKSAI_INSTALL_ID: installId
|
|
284
|
+
};
|
|
285
|
+
if (manifest.local_workspace === true && vertical.product_id === "realtor") {
|
|
286
|
+
const accountScope = createHash("sha256").update(licenseKey).digest("hex").slice(0, 24);
|
|
287
|
+
envEntries.TASKSAI_WORKSPACE_DIR = path.join(installDir, "workspaces", accountScope);
|
|
288
|
+
}
|
|
289
|
+
const { preparedUpdate } = await import("./prepared-update.js");
|
|
290
|
+
const prepared = await preparedUpdate(installDir, {
|
|
291
|
+
skipDependencies: options.skipPythonDeps,
|
|
292
|
+
installDependencies: installPythonDeps,
|
|
293
|
+
validate: async (stagedRuntime, stagedVendor) => {
|
|
294
|
+
if (!options.skipPythonDeps) await verifyRuntimeHealth({
|
|
295
|
+
serverPath: path.join(stagedRuntime, "server.py"), vendorDir: stagedVendor,
|
|
296
|
+
isolated: true, workspace: manifest.local_workspace === true
|
|
297
|
+
});
|
|
298
|
+
if (updateOnly && !options.skipPythonDeps) await verifyUpdateClientPython({
|
|
299
|
+
productId: vertical.product_id, serverPath: path.join(stagedRuntime, "server.py"),
|
|
300
|
+
vendorDir: stagedVendor, workspace: manifest.local_workspace === true
|
|
301
|
+
});
|
|
302
|
+
},
|
|
303
|
+
prepare: async (staging, stagedRuntime) => {
|
|
304
|
+
await downloadRuntime(source, stagedRuntime, manifest);
|
|
305
|
+
await writeJson(path.join(staging, "agent-install.json"), manifest);
|
|
306
|
+
await writeJson(path.join(staging, "vertical.json"), vertical);
|
|
307
|
+
await writeEnvFile(path.join(staging, ".env"), envEntries);
|
|
308
|
+
await writeEnvFile(path.join(stagedRuntime, ".env"), envEntries);
|
|
309
|
+
}
|
|
310
|
+
});
|
|
311
|
+
await logEvent(installDir, "software_prepared", { previousSoftware: prepared.previousSoftware });
|
|
276
312
|
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
313
|
+
if (updateOnly) {
|
|
314
|
+
await logEvent(installDir, "update_complete", { productId: options.productId });
|
|
315
|
+
console.log(`${vertical.display_name} runtime updated at ${installDir}`);
|
|
316
|
+
return;
|
|
317
|
+
}
|
|
282
318
|
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
319
|
+
for (const client of clients) {
|
|
320
|
+
await configureMcpClient({ client, vertical, installDir, runtimeDir, vendorDir });
|
|
321
|
+
}
|
|
286
322
|
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
323
|
+
await doctor(options, { quietSuccess: true });
|
|
324
|
+
await logEvent(installDir, "install_complete", {
|
|
325
|
+
productId: options.productId,
|
|
326
|
+
clients: clients.map((client) => client.id)
|
|
327
|
+
});
|
|
328
|
+
console.log(`${vertical.display_name} is installed. Restart ${clients.map((client) => client.displayName).join(", ")} to see the tools.`);
|
|
329
|
+
console.log(`After restart, ask: "${vertical.first_prompt}"`);
|
|
291
330
|
});
|
|
292
|
-
console.log(`${vertical.display_name} is installed. Restart ${clients.map((client) => client.displayName).join(", ")} to see the tools.`);
|
|
293
|
-
console.log(`After restart, ask: "${vertical.first_prompt}"`);
|
|
294
331
|
}
|
|
295
332
|
|
|
296
333
|
async function doctor(options, {
|
|
@@ -308,6 +345,7 @@ async function doctor(options, {
|
|
|
308
345
|
const clients = clientsOverride || resolveClients(options.client);
|
|
309
346
|
|
|
310
347
|
const problems = [];
|
|
348
|
+
const configuredCommands = new Map();
|
|
311
349
|
if (!fs.existsSync(verticalPath)) problems.push(`Missing ${verticalPath}`);
|
|
312
350
|
if (!fs.existsSync(envPath)) problems.push(`Missing ${envPath}`);
|
|
313
351
|
if (!fs.existsSync(serverPath)) problems.push(`Missing ${serverPath}`);
|
|
@@ -322,6 +360,11 @@ async function doctor(options, {
|
|
|
322
360
|
? await codexConfigHasServer(configPath, vertical.product_id)
|
|
323
361
|
: await jsonConfigHasServer(configPath, vertical.product_id);
|
|
324
362
|
if (!hasServer) problems.push(`${client.displayName} config does not contain mcpServers.${vertical.product_id}`);
|
|
363
|
+
else {
|
|
364
|
+
const command = await configuredPythonCommand(configPath, client.configFormat, vertical.product_id);
|
|
365
|
+
if (!command) problems.push(`${client.displayName} Python connection could not be verified; rerun the installer for this client`);
|
|
366
|
+
else configuredCommands.set(command, client.displayName);
|
|
367
|
+
}
|
|
325
368
|
} else {
|
|
326
369
|
problems.push(`${client.displayName} config not found at ${configPath}`);
|
|
327
370
|
}
|
|
@@ -333,6 +376,13 @@ async function doctor(options, {
|
|
|
333
376
|
} catch (error) {
|
|
334
377
|
problems.push(`Runtime health check failed (${healthErrorSummary(error)})`);
|
|
335
378
|
}
|
|
379
|
+
for (const [python, clientName] of configuredCommands) {
|
|
380
|
+
try {
|
|
381
|
+
await runtimeHealthImpl({ serverPath, vendorDir, python });
|
|
382
|
+
} catch (error) {
|
|
383
|
+
problems.push(`${clientName} configured Python connection failed (${healthErrorSummary(error)}); rerun the installer for this client`);
|
|
384
|
+
}
|
|
385
|
+
}
|
|
336
386
|
}
|
|
337
387
|
|
|
338
388
|
const productId = String(vertical.product_id || options.productId || "").trim().toLowerCase();
|
|
@@ -425,42 +475,45 @@ async function uninstall(options) {
|
|
|
425
475
|
const installDir = getInstallDir(options.productId, options);
|
|
426
476
|
const clients = resolveClients(options.client);
|
|
427
477
|
await preflightWriteAccess({ operation: "uninstall", installDir, clients, options });
|
|
478
|
+
await fsp.mkdir(installDir, { recursive: true });
|
|
479
|
+
return withLock(path.join(installDir, ".installer-operation.lock"), async () => {
|
|
428
480
|
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
481
|
+
for (const client of clients) {
|
|
482
|
+
const configPath = client.configPath();
|
|
483
|
+
if (!fs.existsSync(configPath)) {
|
|
484
|
+
console.log(`${client.displayName} config not found; nothing to remove.`);
|
|
485
|
+
continue;
|
|
486
|
+
}
|
|
435
487
|
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
488
|
+
await withLock(`${configPath}.lock`, async () => {
|
|
489
|
+
const keys = mcpServerKeysForProduct(options.productId);
|
|
490
|
+
if (client.configFormat === "toml") {
|
|
491
|
+
const text = await fsp.readFile(configPath, "utf8");
|
|
492
|
+
if (!tomlHasAnyMcpServer(text, keys)) {
|
|
493
|
+
console.log(`${client.displayName} does not have a ${options.productId} MCP entry.`);
|
|
494
|
+
return;
|
|
495
|
+
}
|
|
496
|
+
await backupFile(configPath);
|
|
497
|
+
await atomicWriteText(configPath, removeTomlSections(text, tomlSectionNamesForProduct(options.productId)));
|
|
498
|
+
} else {
|
|
499
|
+
const config = await readJson(configPath);
|
|
500
|
+
if (!keys.some((key) => config.mcpServers?.[key])) {
|
|
501
|
+
console.log(`${client.displayName} does not have a ${options.productId} MCP entry.`);
|
|
502
|
+
return;
|
|
503
|
+
}
|
|
504
|
+
await backupFile(configPath);
|
|
505
|
+
for (const key of keys) delete config.mcpServers[key];
|
|
506
|
+
await atomicWriteJson(configPath, config);
|
|
451
507
|
}
|
|
452
|
-
|
|
453
|
-
for (const key of keys) delete config.mcpServers[key];
|
|
454
|
-
await atomicWriteJson(configPath, config);
|
|
455
|
-
}
|
|
456
|
-
});
|
|
508
|
+
});
|
|
457
509
|
|
|
458
|
-
|
|
459
|
-
|
|
510
|
+
console.log(`${options.productId} was removed from ${client.displayName}.`);
|
|
511
|
+
}
|
|
460
512
|
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
513
|
+
await logEvent(installDir, "uninstall_complete", {
|
|
514
|
+
productId: options.productId,
|
|
515
|
+
clients: clients.map((client) => client.id)
|
|
516
|
+
});
|
|
464
517
|
});
|
|
465
518
|
}
|
|
466
519
|
|
|
@@ -487,7 +540,7 @@ async function configureMcpClient({ client, vertical, installDir, runtimeDir, ve
|
|
|
487
540
|
|
|
488
541
|
function buildMcpServerConfig({ client, vertical, installDir, runtimeDir, vendorDir }) {
|
|
489
542
|
return {
|
|
490
|
-
command:
|
|
543
|
+
command: requirePython(),
|
|
491
544
|
args: [path.join(runtimeDir, "server.py")],
|
|
492
545
|
env: {
|
|
493
546
|
TASKSAI_PRODUCT_ID: vertical.product_id,
|
|
@@ -513,6 +566,57 @@ function pythonVendorPaths(vendorDir, platform = process.platform) {
|
|
|
513
566
|
];
|
|
514
567
|
}
|
|
515
568
|
|
|
569
|
+
async function configuredPythonCommand(configPath, format, productId) {
|
|
570
|
+
let command;
|
|
571
|
+
if (format === "toml") {
|
|
572
|
+
let selected = false;
|
|
573
|
+
const commands = [];
|
|
574
|
+
for (const line of (await fsp.readFile(configPath, "utf8")).split(/\r?\n/)) {
|
|
575
|
+
const section = line.match(/^\s*\[([^\]]+)\]\s*(?:#.*)?$/);
|
|
576
|
+
if (section) { selected = section[1] === `mcp_servers.${productId}`; continue; }
|
|
577
|
+
if (!selected) continue;
|
|
578
|
+
const match = line.match(/^\s*command\s*=\s*("(?:\\.|[^"\\])*"|'[^']*')\s*(?:#.*)?$/);
|
|
579
|
+
if (match) {
|
|
580
|
+
try { commands.push(match[1].startsWith("'") ? match[1].slice(1, -1) : JSON.parse(match[1])); }
|
|
581
|
+
catch { return null; }
|
|
582
|
+
}
|
|
583
|
+
}
|
|
584
|
+
if (commands.length !== 1) return null;
|
|
585
|
+
command = commands[0];
|
|
586
|
+
} else {
|
|
587
|
+
command = (await readJson(configPath)).mcpServers?.[productId]?.command;
|
|
588
|
+
}
|
|
589
|
+
// Inspect direct Python connections only. Do not execute arbitrary wrappers
|
|
590
|
+
// from an unrecognized client configuration as part of a health check.
|
|
591
|
+
if (typeof command !== "string" || !/^(?:python(?:3(?:\.\d+)?)?)(?:\.exe)?$/i.test(command.split(/[\\/]/).pop())) return null;
|
|
592
|
+
return command;
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
async function verifyUpdateClientPython({ productId, serverPath, vendorDir, workspace,
|
|
596
|
+
clients = Object.values(CLIENTS), runtimeHealthImpl = verifyRuntimeHealth }) {
|
|
597
|
+
// Check every existing connection for this product, including legacy names.
|
|
598
|
+
// Read only: unrelated connections and user configuration are never rewritten.
|
|
599
|
+
const verified = new Set();
|
|
600
|
+
for (const client of clients) {
|
|
601
|
+
const configPath = client.configPath();
|
|
602
|
+
if (!fs.existsSync(configPath)) continue;
|
|
603
|
+
for (const key of mcpServerKeysForProduct(productId)) {
|
|
604
|
+
const present = client.configFormat === "toml"
|
|
605
|
+
? await codexConfigHasServer(configPath, key) : await jsonConfigHasServer(configPath, key);
|
|
606
|
+
if (!present) continue;
|
|
607
|
+
const python = await configuredPythonCommand(configPath, client.configFormat, key);
|
|
608
|
+
if (!python) throw new Error(`${client.displayName} connection cannot be checked before updating; rerun the installer for this client. The previous software remains in place.`);
|
|
609
|
+
if (verified.has(python)) continue;
|
|
610
|
+
try {
|
|
611
|
+
await runtimeHealthImpl({ serverPath, vendorDir, python, isolated: true, workspace });
|
|
612
|
+
} catch (error) {
|
|
613
|
+
throw new Error(`${client.displayName} connection cannot run the updated software (${healthErrorSummary(error)}); rerun the installer for this client. The previous software remains in place.`);
|
|
614
|
+
}
|
|
615
|
+
verified.add(python);
|
|
616
|
+
}
|
|
617
|
+
}
|
|
618
|
+
}
|
|
619
|
+
|
|
516
620
|
async function jsonConfigHasServer(configPath, productId) {
|
|
517
621
|
const config = await readJson(configPath);
|
|
518
622
|
return Boolean(config.mcpServers?.[productId]);
|
|
@@ -768,10 +872,17 @@ async function downloadRuntime(source, runtimeDir, manifest = {}) {
|
|
|
768
872
|
const useBundledRuntime = manifest.runtime?.source === "shared_installer";
|
|
769
873
|
const serverText = await loadRuntimeText(source, "server.py", { useBundledRuntime });
|
|
770
874
|
const matcherText = await loadRuntimeText(source, "skill_matcher.py", { useBundledRuntime });
|
|
875
|
+
const rendererText = await loadRuntimeText(source, "document_renderer.py", { useBundledRuntime });
|
|
771
876
|
const requirementsText = await loadRuntimeText(source, "requirements.txt", { useBundledRuntime });
|
|
877
|
+
await fsp.copyFile(path.join(BUNDLED_RUNTIME_DIR, "software_use.py"), path.join(runtimeDir, "software_use.py"));
|
|
772
878
|
await fsp.writeFile(path.join(runtimeDir, "server.py"), serverText, "utf8");
|
|
773
879
|
await fsp.writeFile(path.join(runtimeDir, "skill_matcher.py"), matcherText, "utf8");
|
|
880
|
+
await fsp.writeFile(path.join(runtimeDir, "document_renderer.py"), rendererText, "utf8");
|
|
774
881
|
await fsp.writeFile(path.join(runtimeDir, "requirements.txt"), requirementsText, "utf8");
|
|
882
|
+
if (useBundledRuntime && manifest.local_workspace === true) {
|
|
883
|
+
const { installWorkspaceRuntime } = await import("./workspace-runtime.js");
|
|
884
|
+
await installWorkspaceRuntime(BUNDLED_RUNTIME_DIR, runtimeDir);
|
|
885
|
+
}
|
|
775
886
|
}
|
|
776
887
|
|
|
777
888
|
async function loadRuntimeText(source, filePath, { useBundledRuntime = false } = {}) {
|
|
@@ -789,8 +900,7 @@ async function loadRuntimeText(source, filePath, { useBundledRuntime = false } =
|
|
|
789
900
|
}
|
|
790
901
|
|
|
791
902
|
function installPythonDeps(runtimeDir, vendorDir) {
|
|
792
|
-
const python =
|
|
793
|
-
if (!python) throw new Error("Python 3 is required but was not found on PATH.");
|
|
903
|
+
const python = requirePython();
|
|
794
904
|
fs.mkdirSync(vendorDir, { recursive: true });
|
|
795
905
|
const result = spawnSync(python, [
|
|
796
906
|
"-m",
|
|
@@ -807,16 +917,6 @@ function installPythonDeps(runtimeDir, vendorDir) {
|
|
|
807
917
|
}
|
|
808
918
|
}
|
|
809
919
|
|
|
810
|
-
function findPython() {
|
|
811
|
-
for (const candidate of ["python3", "python"]) {
|
|
812
|
-
const result = spawnSync(candidate, ["--version"], { encoding: "utf8" });
|
|
813
|
-
if (result.status === 0 && /Python 3\./.test(`${result.stdout}${result.stderr}`)) {
|
|
814
|
-
return candidate;
|
|
815
|
-
}
|
|
816
|
-
}
|
|
817
|
-
return null;
|
|
818
|
-
}
|
|
819
|
-
|
|
820
920
|
async function resolveLicenseKey(options, vertical, installDir) {
|
|
821
921
|
const envNames = ["TASKSAI_LICENSE_KEY", "LAWTASKSAI_LICENSE_KEY"];
|
|
822
922
|
for (const name of envNames) {
|
|
@@ -988,9 +1088,9 @@ function getInstaller(manifest) {
|
|
|
988
1088
|
|
|
989
1089
|
function parseSource(source, ref) {
|
|
990
1090
|
if (source.startsWith("file://")) {
|
|
991
|
-
return { kind: "file", root: source
|
|
1091
|
+
return { kind: "file", root: fileURLToPath(source), ref, repoUrl: source };
|
|
992
1092
|
}
|
|
993
|
-
if (
|
|
1093
|
+
if (path.isAbsolute(source) || source.startsWith(".")) {
|
|
994
1094
|
return { kind: "file", root: path.resolve(source), ref, repoUrl: source };
|
|
995
1095
|
}
|
|
996
1096
|
const rawMatch = source.match(/^https:\/\/raw\.githubusercontent\.com\/([^/]+)\/([^/]+)\/([^/]+)\/(.+)$/);
|
|
@@ -1194,18 +1294,19 @@ async function safeReportDoctorPassed({
|
|
|
1194
1294
|
}
|
|
1195
1295
|
}
|
|
1196
1296
|
|
|
1197
|
-
async function verifyRuntimeHealth({ serverPath, vendorDir }) {
|
|
1198
|
-
const
|
|
1199
|
-
if (!python) throw new Error("Python 3 is unavailable");
|
|
1200
|
-
const pythonPath = [...pythonVendorPaths(vendorDir), process.env.PYTHONPATH]
|
|
1297
|
+
async function verifyRuntimeHealth({ serverPath, vendorDir, isolated = false, workspace = false, python = requirePython() }) {
|
|
1298
|
+
const pythonPath = [...pythonVendorPaths(vendorDir), ...(isolated ? [path.dirname(serverPath)] : [process.env.PYTHONPATH])]
|
|
1201
1299
|
.filter(Boolean)
|
|
1202
1300
|
.join(path.delimiter);
|
|
1203
1301
|
const check = [
|
|
1204
1302
|
"import ast, pathlib, sys",
|
|
1303
|
+
"assert sys.version_info >= (3, 10), 'Python 3.10 or newer is required'",
|
|
1205
1304
|
"ast.parse(pathlib.Path(sys.argv[1]).read_text(encoding='utf-8'), filename=sys.argv[1])",
|
|
1206
|
-
"import httpx, dotenv, mcp, docx"
|
|
1305
|
+
"import httpx, dotenv, mcp, docx",
|
|
1306
|
+
...(isolated ? ["[ast.parse(p.read_text(encoding='utf-8'), filename=str(p)) for p in pathlib.Path(sys.argv[1]).parent.rglob('*.py')]"] : []),
|
|
1307
|
+
...(workspace ? ["import jsonschema, pypdf, xlsxwriter"] : [])
|
|
1207
1308
|
].join("; ");
|
|
1208
|
-
const result = spawnSync(python, ["-c", check, serverPath], {
|
|
1309
|
+
const result = spawnSync(python, [...(isolated ? ["-S"] : []), "-c", check, serverPath], {
|
|
1209
1310
|
encoding: "utf8",
|
|
1210
1311
|
timeout: 10000,
|
|
1211
1312
|
env: { ...process.env, PYTHONPATH: pythonPath }
|
|
@@ -1289,23 +1390,6 @@ function findExistingAncestor(targetPath) {
|
|
|
1289
1390
|
return current;
|
|
1290
1391
|
}
|
|
1291
1392
|
|
|
1292
|
-
async function withLock(lockPath, callback) {
|
|
1293
|
-
let handle;
|
|
1294
|
-
try {
|
|
1295
|
-
handle = await fsp.open(lockPath, "wx");
|
|
1296
|
-
await handle.writeFile(String(process.pid));
|
|
1297
|
-
return await callback();
|
|
1298
|
-
} catch (error) {
|
|
1299
|
-
if (error.code === "EEXIST") {
|
|
1300
|
-
throw new Error(`Another TasksAI installer is already editing this config (${lockPath}).`);
|
|
1301
|
-
}
|
|
1302
|
-
throw error;
|
|
1303
|
-
} finally {
|
|
1304
|
-
if (handle) await handle.close();
|
|
1305
|
-
await fsp.rm(lockPath, { force: true });
|
|
1306
|
-
}
|
|
1307
|
-
}
|
|
1308
|
-
|
|
1309
1393
|
async function backupFile(filePath) {
|
|
1310
1394
|
if (!fs.existsSync(filePath)) return null;
|
|
1311
1395
|
const stamp = new Date().toISOString().replace(/[-:]/g, "").replace(/\..+/, "").replace("T", "-");
|
|
@@ -1380,6 +1464,7 @@ function redact(text) {
|
|
|
1380
1464
|
export {
|
|
1381
1465
|
INSTALLER_VERSION,
|
|
1382
1466
|
authenticatedHeaders,
|
|
1467
|
+
buildMcpServerConfig,
|
|
1383
1468
|
doctor,
|
|
1384
1469
|
parseArgs,
|
|
1385
1470
|
parseEnv,
|
|
@@ -1389,5 +1474,7 @@ export {
|
|
|
1389
1474
|
safeReportDoctorPassed,
|
|
1390
1475
|
verifyProductionSource,
|
|
1391
1476
|
verifyRuntimeHealth,
|
|
1477
|
+
configuredPythonCommand,
|
|
1478
|
+
verifyUpdateClientPython,
|
|
1392
1479
|
verifySource
|
|
1393
1480
|
};
|