@agentprojectcontext/apx 1.59.0 → 1.60.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (29) hide show
  1. package/package.json +1 -1
  2. package/src/core/agent/tools/handlers/_asana.js +34 -0
  3. package/src/core/agent/tools/handlers/asana-create-task.js +38 -0
  4. package/src/core/agent/tools/handlers/asana-list-projects.js +19 -0
  5. package/src/core/agent/tools/handlers/asana-list-tasks.js +27 -0
  6. package/src/core/agent/tools/handlers/asana-update-task.js +32 -0
  7. package/src/core/agent/tools/names.js +10 -0
  8. package/src/core/agent/tools/registry.js +11 -0
  9. package/src/core/integrations/catalog.js +66 -0
  10. package/src/core/integrations/index.js +10 -0
  11. package/src/core/integrations/plugins/asana.js +231 -0
  12. package/src/core/integrations/sources.js +56 -0
  13. package/src/core/integrations/store.js +118 -0
  14. package/src/host/daemon/api/integrations.js +191 -0
  15. package/src/host/daemon/api.js +2 -0
  16. package/src/interfaces/web/dist/assets/{index-CnQb4N6C.js → index-DFNV6BWh.js} +193 -163
  17. package/src/interfaces/web/dist/assets/index-DFNV6BWh.js.map +1 -0
  18. package/src/interfaces/web/dist/assets/index-HU-Wt2l9.css +1 -0
  19. package/src/interfaces/web/dist/index.html +2 -2
  20. package/src/interfaces/web/src/components/integrations/AsanaPlugin.tsx +275 -0
  21. package/src/interfaces/web/src/components/integrations/ComingSoonPlugin.tsx +44 -0
  22. package/src/interfaces/web/src/components/integrations/PluginCard.tsx +61 -0
  23. package/src/interfaces/web/src/components/integrations/PluginToolsSection.tsx +39 -0
  24. package/src/interfaces/web/src/lib/api/integrations.ts +106 -0
  25. package/src/interfaces/web/src/lib/api.ts +1 -0
  26. package/src/interfaces/web/src/screens/ProjectScreen.tsx +6 -2
  27. package/src/interfaces/web/src/screens/project/IntegrationsTab.tsx +146 -0
  28. package/src/interfaces/web/dist/assets/index-CnQb4N6C.js.map +0 -1
  29. package/src/interfaces/web/dist/assets/index-Dv3X-zpx.css +0 -1
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@agentprojectcontext/apx",
3
- "version": "1.59.0",
3
+ "version": "1.60.0",
4
4
  "description": "APX — unified CLI + daemon for the Agent Project Context (APC) standard.",
5
5
  "publishConfig": {
6
6
  "access": "public"
@@ -0,0 +1,34 @@
1
+ // Shared helpers for the Asana agent tools (asana-*.js). Kept in an underscore
2
+ // file — like _git.js — so it holds no tool `name:` of its own. Each tool file
3
+ // stays a thin adapter: resolve the project's effective Asana integration (its
4
+ // own record wins over the default project's — see resolveIntegration), then
5
+ // call the pure REST client in core/integrations/plugins/asana.js.
6
+ import { resolveProject } from "../helpers.js";
7
+ import { resolveIntegration } from "#core/integrations/index.js";
8
+
9
+ // Resolve the active Asana token + config for a project, or throw a message the
10
+ // model can act on (tell the user to connect Asana in the web panel).
11
+ export function resolveAsana(projects, project) {
12
+ const p = resolveProject(projects, project);
13
+ const resolved = resolveIntegration({ projectStorage: p.storagePath, slug: "asana" });
14
+ if (!resolved) {
15
+ throw new Error(
16
+ "Asana is not connected for this project. Ask the user to connect it in the web panel → Integrations → Plugins → Asana.",
17
+ );
18
+ }
19
+ const config = resolved.record.config || {};
20
+ const token = config.personal_access_token;
21
+ if (!token) throw new Error("Asana integration has no token configured");
22
+ return { token, config, scope: resolved.scope };
23
+ }
24
+
25
+ export function requireWorkspace(config) {
26
+ const gid = config.workspace_gid;
27
+ if (!gid) throw new Error("No Asana workspace selected. Configure it in the web panel first.");
28
+ return gid;
29
+ }
30
+
31
+ // The optional `project` arg every Asana tool accepts (defaults to current).
32
+ export const PROJECT_ARG = {
33
+ project: { type: "string", description: "APX project id/name (optional; defaults to current)" },
34
+ };
@@ -0,0 +1,38 @@
1
+ import * as asana from "#core/integrations/plugins/asana.js";
2
+ import { resolveAsana, requireWorkspace, PROJECT_ARG } from "./_asana.js";
3
+
4
+ export default {
5
+ name: "asana_create_task",
6
+ category: "integrations",
7
+ schema: {
8
+ type: "function",
9
+ function: {
10
+ name: "asana_create_task",
11
+ description: "Create an Asana task in the connected workspace.",
12
+ parameters: {
13
+ type: "object",
14
+ properties: {
15
+ name: { type: "string", description: "Task title" },
16
+ notes: { type: "string", description: "Task description/notes" },
17
+ project_gid: { type: "string", description: "Optional Asana project gid to add the task to" },
18
+ assignee: { type: "string", description: "Optional assignee (user gid or 'me')" },
19
+ due_on: { type: "string", description: "Optional due date (YYYY-MM-DD)" },
20
+ ...PROJECT_ARG,
21
+ },
22
+ required: ["name"],
23
+ },
24
+ },
25
+ },
26
+ makeHandler: ({ projects }) => async ({ project, name, notes, project_gid, assignee, due_on } = {}) => {
27
+ const { token, config } = resolveAsana(projects, project);
28
+ const task = await asana.createTask(token, {
29
+ workspaceGid: requireWorkspace(config),
30
+ name,
31
+ notes,
32
+ projectGid: project_gid,
33
+ assignee,
34
+ dueOn: due_on,
35
+ });
36
+ return { task };
37
+ },
38
+ };
@@ -0,0 +1,19 @@
1
+ import * as asana from "#core/integrations/plugins/asana.js";
2
+ import { resolveAsana, requireWorkspace, PROJECT_ARG } from "./_asana.js";
3
+
4
+ export default {
5
+ name: "asana_list_projects",
6
+ category: "integrations",
7
+ schema: {
8
+ type: "function",
9
+ function: {
10
+ name: "asana_list_projects",
11
+ description: "List Asana projects in the connected workspace.",
12
+ parameters: { type: "object", properties: { ...PROJECT_ARG } },
13
+ },
14
+ },
15
+ makeHandler: ({ projects }) => async ({ project } = {}) => {
16
+ const { token, config } = resolveAsana(projects, project);
17
+ return { projects: await asana.listProjects(token, requireWorkspace(config)) };
18
+ },
19
+ };
@@ -0,0 +1,27 @@
1
+ import * as asana from "#core/integrations/plugins/asana.js";
2
+ import { resolveAsana, PROJECT_ARG } from "./_asana.js";
3
+
4
+ export default {
5
+ name: "asana_list_tasks",
6
+ category: "integrations",
7
+ schema: {
8
+ type: "function",
9
+ function: {
10
+ name: "asana_list_tasks",
11
+ description: "List tasks in an Asana project.",
12
+ parameters: {
13
+ type: "object",
14
+ properties: {
15
+ project_gid: { type: "string", description: "Asana project gid (from asana_list_projects)" },
16
+ completed: { type: "boolean", description: "Include completed tasks (default false)" },
17
+ ...PROJECT_ARG,
18
+ },
19
+ required: ["project_gid"],
20
+ },
21
+ },
22
+ },
23
+ makeHandler: ({ projects }) => async ({ project, project_gid, completed = false } = {}) => {
24
+ const { token } = resolveAsana(projects, project);
25
+ return { tasks: await asana.listTasks(token, project_gid, completed) };
26
+ },
27
+ };
@@ -0,0 +1,32 @@
1
+ import * as asana from "#core/integrations/plugins/asana.js";
2
+ import { resolveAsana, PROJECT_ARG } from "./_asana.js";
3
+
4
+ export default {
5
+ name: "asana_update_task",
6
+ category: "integrations",
7
+ schema: {
8
+ type: "function",
9
+ function: {
10
+ name: "asana_update_task",
11
+ description: "Update an Asana task's fields (rename, complete, reschedule, reassign).",
12
+ parameters: {
13
+ type: "object",
14
+ properties: {
15
+ task_gid: { type: "string", description: "Asana task gid" },
16
+ name: { type: "string" },
17
+ notes: { type: "string" },
18
+ completed: { type: "boolean" },
19
+ due_on: { type: "string", description: "YYYY-MM-DD" },
20
+ assignee: { type: "string" },
21
+ ...PROJECT_ARG,
22
+ },
23
+ required: ["task_gid"],
24
+ },
25
+ },
26
+ },
27
+ makeHandler: ({ projects }) => async ({ project, task_gid, name, notes, completed, due_on, assignee } = {}) => {
28
+ const { token } = resolveAsana(projects, project);
29
+ const task = await asana.updateTask(token, task_gid, { name, notes, completed, dueOn: due_on, assignee });
30
+ return { task };
31
+ },
32
+ };
@@ -50,6 +50,12 @@ export const TOOLS = Object.freeze({
50
50
  CALL_MCP: "call_mcp",
51
51
  CALL_RUNTIME: "call_runtime",
52
52
 
53
+ // Integrations — Asana plugin (see core/integrations/plugins/asana.js)
54
+ ASANA_LIST_PROJECTS: "asana_list_projects",
55
+ ASANA_LIST_TASKS: "asana_list_tasks",
56
+ ASANA_CREATE_TASK: "asana_create_task",
57
+ ASANA_UPDATE_TASK: "asana_update_task",
58
+
53
59
  // Side-effects
54
60
  SEND_TELEGRAM: "send_telegram",
55
61
  SET_IDENTITY: "set_identity",
@@ -94,6 +100,10 @@ export const NATIVE_TOOL_NAMES = new Set([
94
100
  TOOLS.CALL_AGENT,
95
101
  TOOLS.CALL_MCP,
96
102
  TOOLS.CALL_RUNTIME,
103
+ TOOLS.ASANA_LIST_PROJECTS,
104
+ TOOLS.ASANA_LIST_TASKS,
105
+ TOOLS.ASANA_CREATE_TASK,
106
+ TOOLS.ASANA_UPDATE_TASK,
97
107
  TOOLS.SEND_TELEGRAM,
98
108
  TOOLS.SET_IDENTITY,
99
109
  TOOLS.SET_PERMISSION_MODE,
@@ -33,6 +33,10 @@ import gitStatus from "./handlers/git-status.js";
33
33
  import gitDiff from "./handlers/git-diff.js";
34
34
  import gitLog from "./handlers/git-log.js";
35
35
  import gitShow from "./handlers/git-show.js";
36
+ import asanaListProjects from "./handlers/asana-list-projects.js";
37
+ import asanaListTasks from "./handlers/asana-list-tasks.js";
38
+ import asanaCreateTask from "./handlers/asana-create-task.js";
39
+ import asanaUpdateTask from "./handlers/asana-update-task.js";
36
40
  import { createPermissionGuard } from "./helpers.js";
37
41
  import { buildBridgedTools, DEFAULT_CATEGORIES } from "./registry-bridge.js";
38
42
  import { TOOLS, CODE_CHANNEL_TOOLS } from "./names.js";
@@ -74,6 +78,13 @@ const NATIVE_TOOLS = [
74
78
  gitDiff,
75
79
  gitLog,
76
80
  gitShow,
81
+ // Integration plugin tools (Asana). Each carries its own `category`, so it
82
+ // lands in the "integrations" group of the discover_tools catalog and stays
83
+ // lazy on chat channels until activated.
84
+ asanaListProjects,
85
+ asanaListTasks,
86
+ asanaCreateTask,
87
+ asanaUpdateTask,
77
88
  ];
78
89
 
79
90
  // Registry-backed bridges. Categories can be overridden per-process via env
@@ -0,0 +1,66 @@
1
+ // Plugin catalog — the source of truth for which integration plugins exist and
2
+ // which are wired end-to-end. The daemon API + web "Plugins" tab read this.
3
+ //
4
+ // A plugin is "implemented" when it has a service module in ./plugins/ that
5
+ // satisfies the lifecycle contract (configure/validate/status/deactivate). The
6
+ // rest are declared here with `coming_soon: true` so the Integrations page shows
7
+ // the full roster (matching PandaProject) while remaining honest about what is
8
+ // actually connectable. Adding a new plugin = drop a module in ./plugins/ and
9
+ // register it in PLUGIN_SERVICES — no API or route changes (open/closed).
10
+ import { asanaPlugin } from "./plugins/asana.js";
11
+
12
+ // slug -> live plugin service (must implement the lifecycle contract).
13
+ export const PLUGIN_SERVICES = Object.freeze({
14
+ asana: asanaPlugin,
15
+ });
16
+
17
+ // Static descriptors for the catalog UI. Implemented plugins derive their
18
+ // metadata from the service module; coming-soon ones are declared inline.
19
+ // `coming_soon` entries in PandaProject depend on domain/channel infrastructure
20
+ // (GitHub outputs↔repos, a WhatsApp-Web bridge, Telegram-voice whisper) that
21
+ // APX models differently — they are placeholders until ported natively.
22
+ const COMING_SOON = [
23
+ {
24
+ slug: "github",
25
+ name: "GitHub",
26
+ type: "source_control",
27
+ description: "Vinculá repos, issues y PRs para que los agentes trabajen sobre tu código",
28
+ auth: "token",
29
+ coming_soon: true,
30
+ },
31
+ {
32
+ slug: "whatsapp",
33
+ name: "WhatsApp",
34
+ type: "channel",
35
+ description: "Conectá WhatsApp Web al orquestador — responde mensajes automáticamente",
36
+ auth: "qr",
37
+ coming_soon: true,
38
+ },
39
+ {
40
+ slug: "local-transcription",
41
+ name: "Transcripción Local",
42
+ type: "transcription",
43
+ description: "Transcripción de audio con faster-whisper local — sin depender de una API externa",
44
+ auth: "none",
45
+ coming_soon: true,
46
+ },
47
+ ];
48
+
49
+ // The full catalog: implemented plugins first, then coming-soon.
50
+ export function listCatalog() {
51
+ const implemented = Object.values(PLUGIN_SERVICES).map((p) => ({
52
+ slug: p.slug,
53
+ name: p.name,
54
+ type: p.type,
55
+ description: p.description,
56
+ auth: p.auth,
57
+ tools: p.tools || [],
58
+ coming_soon: false,
59
+ }));
60
+ return [...implemented, ...COMING_SOON];
61
+ }
62
+
63
+ // Resolve a live plugin service by slug (or null when not implemented).
64
+ export function getPluginService(slug) {
65
+ return PLUGIN_SERVICES[slug] || null;
66
+ }
@@ -0,0 +1,10 @@
1
+ // Public surface of the integrations core module. Daemon + agent tools import
2
+ // from here.
3
+ export {
4
+ integrationsPath,
5
+ defaultIntegrationsStorage,
6
+ readIntegrations,
7
+ writeIntegrations,
8
+ } from "./sources.js";
9
+ export { IntegrationStore, resolveIntegration, redactRecord } from "./store.js";
10
+ export { listCatalog, getPluginService, PLUGIN_SERVICES } from "./catalog.js";
@@ -0,0 +1,231 @@
1
+ // Asana integration plugin — ported from PandaProject's asana_service.py.
2
+ //
3
+ // Two halves:
4
+ // 1. A thin Asana REST client (Personal Access Token auth) — pure HTTP, no
5
+ // knowledge of storage. Used by both the lifecycle below and the agent
6
+ // tools in core/agent/tools/handlers/asana.js.
7
+ // 2. A plugin descriptor implementing the shared lifecycle contract
8
+ // (configure / validate / status / deactivate / actions) that the daemon
9
+ // API dispatches to. Lifecycle methods take a stored record and return a
10
+ // patch to persist — they never touch the filesystem themselves (SRP).
11
+ //
12
+ // Contract shared by every plugin (see catalog.js):
13
+ // configure(record, body) -> { patch } (sync)
14
+ // validate(record) -> Promise<{ patch, result }> (async, hits API)
15
+ // status(record) -> statusObject (sync, pure)
16
+ // deactivate(record) -> { patch } (sync)
17
+ // actions: { <name>(record) -> Promise<any> } (async reads)
18
+
19
+ const ASANA_API_BASE = "https://app.asana.com/api/1.0";
20
+ const REQUEST_TIMEOUT_MS = 15_000;
21
+
22
+ // ─── HTTP helpers ────────────────────────────────────────────────────────────
23
+
24
+ function headers(token) {
25
+ return {
26
+ Authorization: `Bearer ${token}`,
27
+ Accept: "application/json",
28
+ "Content-Type": "application/json",
29
+ };
30
+ }
31
+
32
+ async function request(token, method, apiPath, { params, payload } = {}) {
33
+ let url = `${ASANA_API_BASE}${apiPath}`;
34
+ if (params) {
35
+ const qs = new URLSearchParams();
36
+ for (const [k, v] of Object.entries(params)) {
37
+ if (v !== undefined && v !== null && v !== "") qs.set(k, String(v));
38
+ }
39
+ const s = qs.toString();
40
+ if (s) url += `?${s}`;
41
+ }
42
+ const controller = new AbortController();
43
+ const timer = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS);
44
+ let res;
45
+ try {
46
+ res = await fetch(url, {
47
+ method,
48
+ headers: headers(token),
49
+ body: payload !== undefined ? JSON.stringify({ data: payload }) : undefined,
50
+ signal: controller.signal,
51
+ });
52
+ } catch (e) {
53
+ clearTimeout(timer);
54
+ if (e.name === "AbortError") throw new Error("Asana request timed out");
55
+ throw new Error(`Asana request failed: ${e.message}`);
56
+ }
57
+ clearTimeout(timer);
58
+ if (!res.ok) {
59
+ let detail = `${res.status}`;
60
+ try {
61
+ const body = await res.json();
62
+ const first = Array.isArray(body?.errors) ? body.errors[0]?.message : null;
63
+ detail = first || JSON.stringify(body);
64
+ } catch {
65
+ /* non-JSON error body */
66
+ }
67
+ throw new Error(`Asana API ${res.status}: ${detail}`);
68
+ }
69
+ return res.json();
70
+ }
71
+
72
+ // ─── REST client (mirrors asana_service.py) ───────────────────────────────────
73
+
74
+ export async function validateToken(token) {
75
+ const result = await request(token, "GET", "/users/me");
76
+ const user = result?.data || {};
77
+ if (!user.gid) throw new Error("Asana returned an empty user response");
78
+ return user;
79
+ }
80
+
81
+ export async function getWorkspaces(token) {
82
+ const result = await request(token, "GET", "/workspaces");
83
+ return result?.data || [];
84
+ }
85
+
86
+ export async function listProjects(token, workspaceGid) {
87
+ const result = await request(token, "GET", "/projects", {
88
+ params: { workspace: workspaceGid, opt_fields: "gid,name,color,archived,permalink_url" },
89
+ });
90
+ return result?.data || [];
91
+ }
92
+
93
+ export async function listTasks(token, projectGid, completed = false) {
94
+ const result = await request(token, "GET", `/projects/${projectGid}/tasks`, {
95
+ params: {
96
+ opt_fields: "gid,name,completed,due_on,assignee.name,notes,permalink_url",
97
+ completed_since: completed ? "" : "now",
98
+ },
99
+ });
100
+ return result?.data || [];
101
+ }
102
+
103
+ export async function createTask(token, { workspaceGid, name, notes = "", projectGid, assignee, dueOn } = {}) {
104
+ const payload = { name, workspace: workspaceGid };
105
+ if (notes) payload.notes = notes;
106
+ if (projectGid) payload.projects = [projectGid];
107
+ if (assignee) payload.assignee = assignee;
108
+ if (dueOn) payload.due_on = dueOn;
109
+ const result = await request(token, "POST", "/tasks", { payload });
110
+ return result?.data || {};
111
+ }
112
+
113
+ export async function updateTask(token, taskGid, { name, notes, completed, dueOn, assignee } = {}) {
114
+ const payload = {};
115
+ if (name !== undefined) payload.name = name;
116
+ if (notes !== undefined) payload.notes = notes;
117
+ if (completed !== undefined) payload.completed = completed;
118
+ if (dueOn !== undefined) payload.due_on = dueOn;
119
+ if (assignee !== undefined) payload.assignee = assignee;
120
+ const result = await request(token, "PUT", `/tasks/${taskGid}`, { payload });
121
+ return result?.data || {};
122
+ }
123
+
124
+ // ─── Plugin descriptor + lifecycle ────────────────────────────────────────────
125
+
126
+ function safeToken(record) {
127
+ const token = record?.config?.personal_access_token || "";
128
+ if (!token) throw new Error("Asana token not configured");
129
+ return token;
130
+ }
131
+
132
+ export const asanaPlugin = {
133
+ slug: "asana",
134
+ name: "Asana",
135
+ type: "project_management",
136
+ description: "Conectá tu workspace de Asana para que los agentes creen, actualicen y consulten tareas",
137
+ auth: "token",
138
+ tools: [
139
+ { slug: "asana_list_projects", desc: "Listar proyectos del workspace" },
140
+ { slug: "asana_list_tasks", desc: "Listar tareas de un proyecto" },
141
+ { slug: "asana_create_task", desc: "Crear una tarea" },
142
+ { slug: "asana_update_task", desc: "Actualizar estado o campos de una tarea" },
143
+ ],
144
+
145
+ // Save the PAT and/or the target workspace. Returns a patch to persist.
146
+ configure(record, body = {}) {
147
+ const pat = (body.personal_access_token || "").trim();
148
+ const workspaceGid = (body.workspace_gid || "").trim();
149
+ if (!pat && !workspaceGid && !record) {
150
+ throw new Error("Provide personal_access_token or workspace_gid");
151
+ }
152
+ const config = {};
153
+ if (pat) config.personal_access_token = pat;
154
+ if (workspaceGid) config.workspace_gid = workspaceGid;
155
+ const patch = {
156
+ name: "Asana",
157
+ type: this.type,
158
+ description: this.description,
159
+ config,
160
+ };
161
+ // Saving a fresh token means the connection is not yet verified.
162
+ if (pat) patch.status = "pending_validation";
163
+ return { patch };
164
+ },
165
+
166
+ // Verify the PAT against Asana and resolve user + workspace metadata.
167
+ async validate(record) {
168
+ const token = safeToken(record);
169
+ let user;
170
+ try {
171
+ user = await validateToken(token);
172
+ } catch (e) {
173
+ return {
174
+ patch: { status: "error", is_enabled: false, config: { last_error: String(e.message || e) } },
175
+ result: { ok: false, error: String(e.message || e) },
176
+ };
177
+ }
178
+ const config = { user_name: user.name || null, user_email: user.email || null, last_error: null };
179
+ let workspaceGid = record?.config?.workspace_gid || null;
180
+ try {
181
+ const workspaces = await getWorkspaces(token);
182
+ if (workspaceGid) {
183
+ const match = workspaces.find((w) => w.gid === workspaceGid);
184
+ if (match) config.workspace_name = match.name;
185
+ } else if (workspaces.length === 1) {
186
+ // Auto-select when the token owner has exactly one workspace.
187
+ config.workspace_gid = workspaces[0].gid;
188
+ config.workspace_name = workspaces[0].name;
189
+ workspaceGid = workspaces[0].gid;
190
+ }
191
+ } catch {
192
+ /* workspace resolution is best-effort */
193
+ }
194
+ return {
195
+ patch: { status: "active", is_enabled: true, config },
196
+ result: {
197
+ ok: true,
198
+ user_name: config.user_name,
199
+ user_email: config.user_email,
200
+ workspace_gid: config.workspace_gid || workspaceGid,
201
+ workspace_name: config.workspace_name || null,
202
+ },
203
+ };
204
+ },
205
+
206
+ status(record) {
207
+ const config = record?.config || {};
208
+ return {
209
+ slug: this.slug,
210
+ status: record?.status || "disconnected",
211
+ is_enabled: !!record?.is_enabled,
212
+ user_name: config.user_name || null,
213
+ user_email: config.user_email || null,
214
+ workspace_gid: config.workspace_gid || null,
215
+ workspace_name: config.workspace_name || null,
216
+ };
217
+ },
218
+
219
+ deactivate() {
220
+ return { patch: { status: "inactive", is_enabled: false } };
221
+ },
222
+
223
+ actions: {
224
+ async workspaces(record) {
225
+ const token = safeToken(record);
226
+ return { workspaces: await getWorkspaces(token) };
227
+ },
228
+ },
229
+ };
230
+
231
+ export default asanaPlugin;
@@ -0,0 +1,56 @@
1
+ // Integration storage. Integrations are per-project records that hold plugin
2
+ // credentials + resolved metadata (Asana PAT, workspace gid, connected user…).
3
+ //
4
+ // Scoping model (see IntegrationStore + resolveIntegration in ./store.js):
5
+ // - Every integration lives in ONE project's integrations.json.
6
+ // - "Global" integrations are simply the ones stored under the DEFAULT project
7
+ // (~/.apx/projects/default/integrations.json). There is no separate global
8
+ // file: this keeps "which Asana does project X use?" unambiguous — a project
9
+ // uses its own record if present, otherwise it falls back to the default's.
10
+ //
11
+ // The file may contain tokens, so it is written chmod 0600 exactly like the
12
+ // runtime MCP + vars stores.
13
+ import fs from "node:fs";
14
+ import path from "node:path";
15
+ import { DEFAULT_PROJECT_STORE } from "#core/config/index.js";
16
+
17
+ const INTEGRATIONS_FILENAME = "integrations.json";
18
+
19
+ // Absolute path to a project's integrations.json given its storagePath
20
+ // (~/.apx/projects/<apxId>/). Returns null when no storagePath is available.
21
+ export function integrationsPath(storagePath) {
22
+ if (!storagePath) return null;
23
+ return path.join(storagePath, INTEGRATIONS_FILENAME);
24
+ }
25
+
26
+ // The storagePath of the DEFAULT project — the home of "global" integrations.
27
+ export function defaultIntegrationsStorage() {
28
+ return DEFAULT_PROJECT_STORE;
29
+ }
30
+
31
+ // Read the raw array of integration records for a project. Always returns an
32
+ // array (missing/corrupt file → []).
33
+ export function readIntegrations(storagePath) {
34
+ const p = integrationsPath(storagePath);
35
+ if (!p || !fs.existsSync(p)) return [];
36
+ try {
37
+ const json = JSON.parse(fs.readFileSync(p, "utf8"));
38
+ return Array.isArray(json) ? json : [];
39
+ } catch {
40
+ return [];
41
+ }
42
+ }
43
+
44
+ // Persist the array of integration records for a project. Creates the storage
45
+ // directory if needed and locks the file to 0600 (tokens live here).
46
+ export function writeIntegrations(storagePath, entries) {
47
+ const p = integrationsPath(storagePath);
48
+ if (!p) throw new Error("writeIntegrations: storagePath required");
49
+ fs.mkdirSync(path.dirname(p), { recursive: true });
50
+ fs.writeFileSync(p, JSON.stringify(entries, null, 2) + "\n");
51
+ try {
52
+ fs.chmodSync(p, 0o600);
53
+ } catch {
54
+ // best-effort on non-POSIX filesystems (Windows) — ignore.
55
+ }
56
+ }