@tasksai/install 0.1.38 → 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 +14 -3
- 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 +217 -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";
|
|
@@ -21,6 +23,12 @@ const TRUSTED_PRODUCTION_SOURCES = new Map([
|
|
|
21
23
|
["farmer", { repo: OFFICIAL_WRAPPER_REPO, ref: "main", path: "verticals/farmer" }],
|
|
22
24
|
["realtor", { repo: OFFICIAL_WRAPPER_REPO, ref: "main", path: "verticals/realtor" }],
|
|
23
25
|
["teacher", { repo: OFFICIAL_WRAPPER_REPO, ref: "main", path: "verticals/teacher" }],
|
|
26
|
+
["electrician", { repo: OFFICIAL_WRAPPER_REPO, ref: "main", path: "verticals/electrician" }],
|
|
27
|
+
["hr", { repo: OFFICIAL_WRAPPER_REPO, ref: "main", path: "verticals/hr" }],
|
|
28
|
+
["insurance", { repo: OFFICIAL_WRAPPER_REPO, ref: "main", path: "verticals/insurance" }],
|
|
29
|
+
["mortgage", { repo: OFFICIAL_WRAPPER_REPO, ref: "main", path: "verticals/mortgage" }],
|
|
30
|
+
["mortuary", { repo: OFFICIAL_WRAPPER_REPO, ref: "main", path: "verticals/mortuary" }],
|
|
31
|
+
["restaurant", { repo: OFFICIAL_WRAPPER_REPO, ref: "main", path: "verticals/restaurant" }],
|
|
24
32
|
["contractor", { repo: "https://github.com/TasksAI-Official/contractortasksai-mcp", ref: "main", path: "", legacyManifestPathOptional: true }],
|
|
25
33
|
["therapist", { repo: "https://github.com/TasksAI-Official/therapisttasksai-mcp", ref: "main", path: "", legacyManifestPathOptional: true }],
|
|
26
34
|
["marketing", { repo: "https://github.com/TasksAI-Official/marketingtasksai-mcp", ref: "main", path: "", legacyManifestPathOptional: true }],
|
|
@@ -36,6 +44,12 @@ const DEFAULT_SOURCES = {
|
|
|
36
44
|
farmer: "https://github.com/TasksAI-Official/tasksai-mcp-wrappers/tree/main/verticals/farmer",
|
|
37
45
|
realtor: "https://github.com/TasksAI-Official/tasksai-mcp-wrappers/tree/main/verticals/realtor",
|
|
38
46
|
teacher: "https://github.com/TasksAI-Official/tasksai-mcp-wrappers/tree/main/verticals/teacher",
|
|
47
|
+
electrician: "https://github.com/TasksAI-Official/tasksai-mcp-wrappers/tree/main/verticals/electrician",
|
|
48
|
+
hr: "https://github.com/TasksAI-Official/tasksai-mcp-wrappers/tree/main/verticals/hr",
|
|
49
|
+
insurance: "https://github.com/TasksAI-Official/tasksai-mcp-wrappers/tree/main/verticals/insurance",
|
|
50
|
+
mortgage: "https://github.com/TasksAI-Official/tasksai-mcp-wrappers/tree/main/verticals/mortgage",
|
|
51
|
+
mortuary: "https://github.com/TasksAI-Official/tasksai-mcp-wrappers/tree/main/verticals/mortuary",
|
|
52
|
+
restaurant: "https://github.com/TasksAI-Official/tasksai-mcp-wrappers/tree/main/verticals/restaurant",
|
|
39
53
|
contractor: "https://github.com/TasksAI-Official/contractortasksai-mcp",
|
|
40
54
|
therapist: "https://github.com/TasksAI-Official/therapisttasksai-mcp",
|
|
41
55
|
marketing: "https://github.com/TasksAI-Official/marketingtasksai-mcp",
|
|
@@ -111,18 +125,31 @@ function isMainModule() {
|
|
|
111
125
|
}
|
|
112
126
|
|
|
113
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
|
+
}
|
|
114
133
|
const options = parseArgs(process.argv.slice(2));
|
|
115
134
|
|
|
116
135
|
if (!options.productId) {
|
|
117
136
|
printUsage();
|
|
118
137
|
process.exit(1);
|
|
119
138
|
}
|
|
139
|
+
setPythonInstallRoot(getInstallDir(options.productId, options));
|
|
120
140
|
|
|
121
141
|
if (options.command === "doctor") {
|
|
122
142
|
await doctor(options);
|
|
123
143
|
return;
|
|
124
144
|
}
|
|
125
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
|
+
|
|
126
153
|
if (options.command === "update") {
|
|
127
154
|
await install(options, { updateOnly: true });
|
|
128
155
|
return;
|
|
@@ -175,7 +202,7 @@ function parseArgs(argv) {
|
|
|
175
202
|
|
|
176
203
|
options.productId = positionals[0] || null;
|
|
177
204
|
if (positionals[1]) options.command = positionals[1];
|
|
178
|
-
if (!["install", "doctor", "update", "uninstall"].includes(options.command)) {
|
|
205
|
+
if (!["install", "doctor", "update", "uninstall", "recover"].includes(options.command)) {
|
|
179
206
|
throw new Error(`Unsupported command: ${options.command}`);
|
|
180
207
|
}
|
|
181
208
|
if (!["browser", "license-key"].includes(options.auth)) {
|
|
@@ -193,6 +220,7 @@ function printUsage() {
|
|
|
193
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>]
|
|
194
221
|
tasksai-install <product-id> doctor [--client claude-desktop|cursor|windsurf|codex|all] [--install-dir <path>]
|
|
195
222
|
tasksai-install <product-id> update [--install-dir <path>]
|
|
223
|
+
tasksai-install <product-id> recover [--install-dir <path>]
|
|
196
224
|
tasksai-install <product-id> uninstall [--client claude-desktop|cursor|windsurf|codex|all] [--install-dir <path>]
|
|
197
225
|
|
|
198
226
|
Examples:
|
|
@@ -227,58 +255,79 @@ async function install(options, { updateOnly = false } = {}) {
|
|
|
227
255
|
const clients = updateOnly ? [] : resolveClients(options.client);
|
|
228
256
|
await preflightWriteAccess({ operation: updateOnly ? "update" : "install", installDir, clients, options, vertical, source });
|
|
229
257
|
|
|
230
|
-
await fsp.mkdir(
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
await writeJson(path.join(installDir, "vertical.json"), vertical);
|
|
241
|
-
await downloadRuntime(source, runtimeDir, manifest);
|
|
242
|
-
|
|
243
|
-
const licenseKey = await resolveLicenseKey(options, vertical, installDir);
|
|
244
|
-
const existingEnvPath = path.join(installDir, ".env");
|
|
245
|
-
const existingEnv = fs.existsSync(existingEnvPath)
|
|
246
|
-
? parseEnv(await fsp.readFile(existingEnvPath, "utf8"))
|
|
247
|
-
: {};
|
|
248
|
-
const installId = firstValue(existingEnv.TASKSAI_INSTALL_ID, process.env.TASKSAI_INSTALL_ID) || randomUUID();
|
|
249
|
-
|
|
250
|
-
if (!options.skipPythonDeps) {
|
|
251
|
-
installPythonDeps(runtimeDir, vendorDir);
|
|
252
|
-
}
|
|
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
|
+
});
|
|
253
268
|
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
TASKSAI_INSTALL_ID
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
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 });
|
|
264
312
|
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
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
|
+
}
|
|
270
318
|
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
319
|
+
for (const client of clients) {
|
|
320
|
+
await configureMcpClient({ client, vertical, installDir, runtimeDir, vendorDir });
|
|
321
|
+
}
|
|
274
322
|
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
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}"`);
|
|
279
330
|
});
|
|
280
|
-
console.log(`${vertical.display_name} is installed. Restart ${clients.map((client) => client.displayName).join(", ")} to see the tools.`);
|
|
281
|
-
console.log(`After restart, ask: "${vertical.first_prompt}"`);
|
|
282
331
|
}
|
|
283
332
|
|
|
284
333
|
async function doctor(options, {
|
|
@@ -296,6 +345,7 @@ async function doctor(options, {
|
|
|
296
345
|
const clients = clientsOverride || resolveClients(options.client);
|
|
297
346
|
|
|
298
347
|
const problems = [];
|
|
348
|
+
const configuredCommands = new Map();
|
|
299
349
|
if (!fs.existsSync(verticalPath)) problems.push(`Missing ${verticalPath}`);
|
|
300
350
|
if (!fs.existsSync(envPath)) problems.push(`Missing ${envPath}`);
|
|
301
351
|
if (!fs.existsSync(serverPath)) problems.push(`Missing ${serverPath}`);
|
|
@@ -310,6 +360,11 @@ async function doctor(options, {
|
|
|
310
360
|
? await codexConfigHasServer(configPath, vertical.product_id)
|
|
311
361
|
: await jsonConfigHasServer(configPath, vertical.product_id);
|
|
312
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
|
+
}
|
|
313
368
|
} else {
|
|
314
369
|
problems.push(`${client.displayName} config not found at ${configPath}`);
|
|
315
370
|
}
|
|
@@ -321,6 +376,13 @@ async function doctor(options, {
|
|
|
321
376
|
} catch (error) {
|
|
322
377
|
problems.push(`Runtime health check failed (${healthErrorSummary(error)})`);
|
|
323
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
|
+
}
|
|
324
386
|
}
|
|
325
387
|
|
|
326
388
|
const productId = String(vertical.product_id || options.productId || "").trim().toLowerCase();
|
|
@@ -413,42 +475,45 @@ async function uninstall(options) {
|
|
|
413
475
|
const installDir = getInstallDir(options.productId, options);
|
|
414
476
|
const clients = resolveClients(options.client);
|
|
415
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 () => {
|
|
416
480
|
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
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
|
+
}
|
|
423
487
|
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
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);
|
|
439
507
|
}
|
|
440
|
-
|
|
441
|
-
for (const key of keys) delete config.mcpServers[key];
|
|
442
|
-
await atomicWriteJson(configPath, config);
|
|
443
|
-
}
|
|
444
|
-
});
|
|
508
|
+
});
|
|
445
509
|
|
|
446
|
-
|
|
447
|
-
|
|
510
|
+
console.log(`${options.productId} was removed from ${client.displayName}.`);
|
|
511
|
+
}
|
|
448
512
|
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
513
|
+
await logEvent(installDir, "uninstall_complete", {
|
|
514
|
+
productId: options.productId,
|
|
515
|
+
clients: clients.map((client) => client.id)
|
|
516
|
+
});
|
|
452
517
|
});
|
|
453
518
|
}
|
|
454
519
|
|
|
@@ -475,7 +540,7 @@ async function configureMcpClient({ client, vertical, installDir, runtimeDir, ve
|
|
|
475
540
|
|
|
476
541
|
function buildMcpServerConfig({ client, vertical, installDir, runtimeDir, vendorDir }) {
|
|
477
542
|
return {
|
|
478
|
-
command:
|
|
543
|
+
command: requirePython(),
|
|
479
544
|
args: [path.join(runtimeDir, "server.py")],
|
|
480
545
|
env: {
|
|
481
546
|
TASKSAI_PRODUCT_ID: vertical.product_id,
|
|
@@ -501,6 +566,57 @@ function pythonVendorPaths(vendorDir, platform = process.platform) {
|
|
|
501
566
|
];
|
|
502
567
|
}
|
|
503
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
|
+
|
|
504
620
|
async function jsonConfigHasServer(configPath, productId) {
|
|
505
621
|
const config = await readJson(configPath);
|
|
506
622
|
return Boolean(config.mcpServers?.[productId]);
|
|
@@ -756,10 +872,17 @@ async function downloadRuntime(source, runtimeDir, manifest = {}) {
|
|
|
756
872
|
const useBundledRuntime = manifest.runtime?.source === "shared_installer";
|
|
757
873
|
const serverText = await loadRuntimeText(source, "server.py", { useBundledRuntime });
|
|
758
874
|
const matcherText = await loadRuntimeText(source, "skill_matcher.py", { useBundledRuntime });
|
|
875
|
+
const rendererText = await loadRuntimeText(source, "document_renderer.py", { useBundledRuntime });
|
|
759
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"));
|
|
760
878
|
await fsp.writeFile(path.join(runtimeDir, "server.py"), serverText, "utf8");
|
|
761
879
|
await fsp.writeFile(path.join(runtimeDir, "skill_matcher.py"), matcherText, "utf8");
|
|
880
|
+
await fsp.writeFile(path.join(runtimeDir, "document_renderer.py"), rendererText, "utf8");
|
|
762
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
|
+
}
|
|
763
886
|
}
|
|
764
887
|
|
|
765
888
|
async function loadRuntimeText(source, filePath, { useBundledRuntime = false } = {}) {
|
|
@@ -777,8 +900,7 @@ async function loadRuntimeText(source, filePath, { useBundledRuntime = false } =
|
|
|
777
900
|
}
|
|
778
901
|
|
|
779
902
|
function installPythonDeps(runtimeDir, vendorDir) {
|
|
780
|
-
const python =
|
|
781
|
-
if (!python) throw new Error("Python 3 is required but was not found on PATH.");
|
|
903
|
+
const python = requirePython();
|
|
782
904
|
fs.mkdirSync(vendorDir, { recursive: true });
|
|
783
905
|
const result = spawnSync(python, [
|
|
784
906
|
"-m",
|
|
@@ -795,16 +917,6 @@ function installPythonDeps(runtimeDir, vendorDir) {
|
|
|
795
917
|
}
|
|
796
918
|
}
|
|
797
919
|
|
|
798
|
-
function findPython() {
|
|
799
|
-
for (const candidate of ["python3", "python"]) {
|
|
800
|
-
const result = spawnSync(candidate, ["--version"], { encoding: "utf8" });
|
|
801
|
-
if (result.status === 0 && /Python 3\./.test(`${result.stdout}${result.stderr}`)) {
|
|
802
|
-
return candidate;
|
|
803
|
-
}
|
|
804
|
-
}
|
|
805
|
-
return null;
|
|
806
|
-
}
|
|
807
|
-
|
|
808
920
|
async function resolveLicenseKey(options, vertical, installDir) {
|
|
809
921
|
const envNames = ["TASKSAI_LICENSE_KEY", "LAWTASKSAI_LICENSE_KEY"];
|
|
810
922
|
for (const name of envNames) {
|
|
@@ -976,9 +1088,9 @@ function getInstaller(manifest) {
|
|
|
976
1088
|
|
|
977
1089
|
function parseSource(source, ref) {
|
|
978
1090
|
if (source.startsWith("file://")) {
|
|
979
|
-
return { kind: "file", root: source
|
|
1091
|
+
return { kind: "file", root: fileURLToPath(source), ref, repoUrl: source };
|
|
980
1092
|
}
|
|
981
|
-
if (
|
|
1093
|
+
if (path.isAbsolute(source) || source.startsWith(".")) {
|
|
982
1094
|
return { kind: "file", root: path.resolve(source), ref, repoUrl: source };
|
|
983
1095
|
}
|
|
984
1096
|
const rawMatch = source.match(/^https:\/\/raw\.githubusercontent\.com\/([^/]+)\/([^/]+)\/([^/]+)\/(.+)$/);
|
|
@@ -1182,18 +1294,19 @@ async function safeReportDoctorPassed({
|
|
|
1182
1294
|
}
|
|
1183
1295
|
}
|
|
1184
1296
|
|
|
1185
|
-
async function verifyRuntimeHealth({ serverPath, vendorDir }) {
|
|
1186
|
-
const
|
|
1187
|
-
if (!python) throw new Error("Python 3 is unavailable");
|
|
1188
|
-
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])]
|
|
1189
1299
|
.filter(Boolean)
|
|
1190
1300
|
.join(path.delimiter);
|
|
1191
1301
|
const check = [
|
|
1192
1302
|
"import ast, pathlib, sys",
|
|
1303
|
+
"assert sys.version_info >= (3, 10), 'Python 3.10 or newer is required'",
|
|
1193
1304
|
"ast.parse(pathlib.Path(sys.argv[1]).read_text(encoding='utf-8'), filename=sys.argv[1])",
|
|
1194
|
-
"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"] : [])
|
|
1195
1308
|
].join("; ");
|
|
1196
|
-
const result = spawnSync(python, ["-c", check, serverPath], {
|
|
1309
|
+
const result = spawnSync(python, [...(isolated ? ["-S"] : []), "-c", check, serverPath], {
|
|
1197
1310
|
encoding: "utf8",
|
|
1198
1311
|
timeout: 10000,
|
|
1199
1312
|
env: { ...process.env, PYTHONPATH: pythonPath }
|
|
@@ -1277,23 +1390,6 @@ function findExistingAncestor(targetPath) {
|
|
|
1277
1390
|
return current;
|
|
1278
1391
|
}
|
|
1279
1392
|
|
|
1280
|
-
async function withLock(lockPath, callback) {
|
|
1281
|
-
let handle;
|
|
1282
|
-
try {
|
|
1283
|
-
handle = await fsp.open(lockPath, "wx");
|
|
1284
|
-
await handle.writeFile(String(process.pid));
|
|
1285
|
-
return await callback();
|
|
1286
|
-
} catch (error) {
|
|
1287
|
-
if (error.code === "EEXIST") {
|
|
1288
|
-
throw new Error(`Another TasksAI installer is already editing this config (${lockPath}).`);
|
|
1289
|
-
}
|
|
1290
|
-
throw error;
|
|
1291
|
-
} finally {
|
|
1292
|
-
if (handle) await handle.close();
|
|
1293
|
-
await fsp.rm(lockPath, { force: true });
|
|
1294
|
-
}
|
|
1295
|
-
}
|
|
1296
|
-
|
|
1297
1393
|
async function backupFile(filePath) {
|
|
1298
1394
|
if (!fs.existsSync(filePath)) return null;
|
|
1299
1395
|
const stamp = new Date().toISOString().replace(/[-:]/g, "").replace(/\..+/, "").replace("T", "-");
|
|
@@ -1368,6 +1464,7 @@ function redact(text) {
|
|
|
1368
1464
|
export {
|
|
1369
1465
|
INSTALLER_VERSION,
|
|
1370
1466
|
authenticatedHeaders,
|
|
1467
|
+
buildMcpServerConfig,
|
|
1371
1468
|
doctor,
|
|
1372
1469
|
parseArgs,
|
|
1373
1470
|
parseEnv,
|
|
@@ -1377,5 +1474,7 @@ export {
|
|
|
1377
1474
|
safeReportDoctorPassed,
|
|
1378
1475
|
verifyProductionSource,
|
|
1379
1476
|
verifyRuntimeHealth,
|
|
1477
|
+
configuredPythonCommand,
|
|
1478
|
+
verifyUpdateClientPython,
|
|
1380
1479
|
verifySource
|
|
1381
1480
|
};
|