droid-mode 0.0.1
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/LICENSE +21 -0
- package/README.md +135 -0
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +103 -0
- package/package.json +51 -0
- package/templates/skills/droid-mode/SKILL.md +237 -0
- package/templates/skills/droid-mode/bin/dm +424 -0
- package/templates/skills/droid-mode/examples/hooks/README.md +13 -0
- package/templates/skills/droid-mode/examples/workflows/workflow.example.js +21 -0
- package/templates/skills/droid-mode/lib/config.mjs +135 -0
- package/templates/skills/droid-mode/lib/hydrate.mjs +164 -0
- package/templates/skills/droid-mode/lib/mcp_client.mjs +175 -0
- package/templates/skills/droid-mode/lib/mcp_http.mjs +132 -0
- package/templates/skills/droid-mode/lib/mcp_stdio.mjs +152 -0
- package/templates/skills/droid-mode/lib/run.mjs +213 -0
- package/templates/skills/droid-mode/lib/search.mjs +67 -0
- package/templates/skills/droid-mode/lib/tool_index.mjs +89 -0
- package/templates/skills/droid-mode/lib/util.mjs +160 -0
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import vm from "node:vm";
|
|
4
|
+
import { nowIsoCompact, sha256Hex, safeIdentifier, ensureDir, getDroidModeDataDir, writeJson } from "./util.mjs";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Static, best-effort disallow list for sandboxed workflows.
|
|
8
|
+
* This is not meant to be perfect security; it is intended to prevent accidental escalation.
|
|
9
|
+
*/
|
|
10
|
+
const DEFAULT_BANNED_PATTERNS = [
|
|
11
|
+
/\brequire\s*\(/,
|
|
12
|
+
/\bimport\s+/,
|
|
13
|
+
/\bprocess\b/,
|
|
14
|
+
/\bchild_process\b/,
|
|
15
|
+
/\bfs\b/,
|
|
16
|
+
/\bnet\b/,
|
|
17
|
+
/\bhttp\b/,
|
|
18
|
+
/\bhttps\b/,
|
|
19
|
+
/\bfetch\b/,
|
|
20
|
+
/\beval\s*\(/,
|
|
21
|
+
/\bFunction\s*\(/,
|
|
22
|
+
/\bWebAssembly\b/,
|
|
23
|
+
];
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* @param {string} source
|
|
27
|
+
* @param {{ allowUnsafe?: boolean }} opts
|
|
28
|
+
*/
|
|
29
|
+
export function validateWorkflowSource(source, opts = {}) {
|
|
30
|
+
if (opts.allowUnsafe) return;
|
|
31
|
+
for (const re of DEFAULT_BANNED_PATTERNS) {
|
|
32
|
+
if (re.test(source)) {
|
|
33
|
+
throw new Error(
|
|
34
|
+
`Workflow source contains a disallowed pattern (${re}).\n` +
|
|
35
|
+
`This runner executes workflows in a restricted sandbox; ` +
|
|
36
|
+
`use MCP tools via the provided 't' / 'tools' objects.`
|
|
37
|
+
);
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Create tool functions for the sandbox.
|
|
44
|
+
* @param {{
|
|
45
|
+
* client: import("./mcp_client.mjs").McpClient,
|
|
46
|
+
* toolNames: string[],
|
|
47
|
+
* perCallTimeoutMs?: number,
|
|
48
|
+
* maxRetries?: number,
|
|
49
|
+
* trace: any[],
|
|
50
|
+
* }} opts
|
|
51
|
+
*/
|
|
52
|
+
export function createToolApi(opts) {
|
|
53
|
+
const perCallTimeoutMs =
|
|
54
|
+
typeof opts.perCallTimeoutMs === "number" ? opts.perCallTimeoutMs : 60_000;
|
|
55
|
+
const maxRetries = typeof opts.maxRetries === "number" ? opts.maxRetries : 3;
|
|
56
|
+
|
|
57
|
+
const call = async (name, args) => {
|
|
58
|
+
const startedAt = new Date();
|
|
59
|
+
const argsStr = JSON.stringify(args ?? {});
|
|
60
|
+
const traceItem = {
|
|
61
|
+
tool: name,
|
|
62
|
+
argsKeys: args && typeof args === "object" ? Object.keys(args) : [],
|
|
63
|
+
argsSha256: sha256Hex(argsStr),
|
|
64
|
+
startedAt: startedAt.toISOString(),
|
|
65
|
+
durationMs: null,
|
|
66
|
+
isError: null,
|
|
67
|
+
retries: 0,
|
|
68
|
+
};
|
|
69
|
+
opts.trace.push(traceItem);
|
|
70
|
+
|
|
71
|
+
let lastError;
|
|
72
|
+
for (let attempt = 1; attempt <= maxRetries; attempt++) {
|
|
73
|
+
try {
|
|
74
|
+
const res = await opts.client.callTool({
|
|
75
|
+
name,
|
|
76
|
+
arguments: args ?? {},
|
|
77
|
+
timeoutMs: perCallTimeoutMs,
|
|
78
|
+
});
|
|
79
|
+
traceItem.isError = !!res?.isError;
|
|
80
|
+
traceItem.durationMs = new Date() - startedAt;
|
|
81
|
+
traceItem.retries = attempt - 1;
|
|
82
|
+
traceItem.resultSummary = {
|
|
83
|
+
hasStructured: !!res?.structured,
|
|
84
|
+
textLength: (res?.text || "").length,
|
|
85
|
+
};
|
|
86
|
+
return res.structured ?? (res.text ? { text: res.text } : res.raw);
|
|
87
|
+
} catch (err) {
|
|
88
|
+
lastError = err;
|
|
89
|
+
traceItem.retries = attempt;
|
|
90
|
+
if (attempt < maxRetries) {
|
|
91
|
+
const delay = Math.min(1000 * Math.pow(2, attempt - 1), 5000);
|
|
92
|
+
await new Promise((r) => setTimeout(r, delay));
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
traceItem.isError = true;
|
|
98
|
+
traceItem.durationMs = new Date() - startedAt;
|
|
99
|
+
traceItem.error = String(lastError?.message || lastError);
|
|
100
|
+
throw lastError;
|
|
101
|
+
};
|
|
102
|
+
|
|
103
|
+
// safe-name wrapper map
|
|
104
|
+
const t = {};
|
|
105
|
+
const toolmap = {};
|
|
106
|
+
|
|
107
|
+
for (const toolName of opts.toolNames) {
|
|
108
|
+
const safe = safeIdentifier(toolName);
|
|
109
|
+
toolmap[safe] = toolName;
|
|
110
|
+
t[safe] = (args) => call(toolName, args);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
const tools = {
|
|
114
|
+
call,
|
|
115
|
+
// raw mapping by original tool names as well (tools["tool-name"](args))
|
|
116
|
+
...Object.fromEntries(opts.toolNames.map((n) => [n, (args) => call(n, args)])),
|
|
117
|
+
};
|
|
118
|
+
|
|
119
|
+
return { t, tools, toolmap, call };
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Execute a workflow file inside a restricted vm context.
|
|
124
|
+
* Workflow file MUST set global `workflow = async () => { ... }`.
|
|
125
|
+
* @param {{
|
|
126
|
+
* workflowPath: string,
|
|
127
|
+
* toolApi: { t: any, tools: any, toolmap: any },
|
|
128
|
+
* timeoutMs?: number,
|
|
129
|
+
* allowUnsafe?: boolean,
|
|
130
|
+
* }} opts
|
|
131
|
+
*/
|
|
132
|
+
export async function executeWorkflow(opts) {
|
|
133
|
+
const timeoutMs = typeof opts.timeoutMs === "number" ? opts.timeoutMs : 5 * 60_000;
|
|
134
|
+
|
|
135
|
+
const source = fs.readFileSync(opts.workflowPath, "utf-8");
|
|
136
|
+
validateWorkflowSource(source, { allowUnsafe: opts.allowUnsafe });
|
|
137
|
+
|
|
138
|
+
const logs = [];
|
|
139
|
+
const log = (...args) => {
|
|
140
|
+
const line = args.map((a) => (typeof a === "string" ? a : JSON.stringify(a))).join(" ");
|
|
141
|
+
logs.push(line);
|
|
142
|
+
};
|
|
143
|
+
|
|
144
|
+
const sandbox = {
|
|
145
|
+
// Provide only the API surface we want.
|
|
146
|
+
t: opts.toolApi.t,
|
|
147
|
+
tools: opts.toolApi.tools,
|
|
148
|
+
toolmap: opts.toolApi.toolmap,
|
|
149
|
+
log,
|
|
150
|
+
console: { log }, // convenience
|
|
151
|
+
// Helpers
|
|
152
|
+
sleep: (ms) => new Promise((r) => setTimeout(r, ms)),
|
|
153
|
+
assert: (cond, msg) => {
|
|
154
|
+
if (!cond) throw new Error(msg || "Assertion failed");
|
|
155
|
+
},
|
|
156
|
+
workflow: undefined,
|
|
157
|
+
};
|
|
158
|
+
|
|
159
|
+
// Create context with code generation from strings disabled where supported.
|
|
160
|
+
const context = vm.createContext(sandbox, {
|
|
161
|
+
name: "droid-mode-workflow",
|
|
162
|
+
codeGeneration: { strings: false, wasm: false },
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
const script = new vm.Script(source, { filename: path.basename(opts.workflowPath) });
|
|
166
|
+
|
|
167
|
+
// This timeout only applies to synchronous execution of the top-level script.
|
|
168
|
+
script.runInContext(context, { timeout: 1_000 });
|
|
169
|
+
|
|
170
|
+
if (typeof sandbox.workflow !== "function") {
|
|
171
|
+
throw new Error(
|
|
172
|
+
`Workflow file must assign an async function to global variable 'workflow'.\n` +
|
|
173
|
+
`Example:\n workflow = async () => { return { ok: true } }`
|
|
174
|
+
);
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
const resultPromise = Promise.resolve().then(() => sandbox.workflow());
|
|
178
|
+
const timed = Promise.race([
|
|
179
|
+
resultPromise,
|
|
180
|
+
new Promise((_, reject) => setTimeout(() => reject(new Error(`Workflow timed out after ${timeoutMs}ms`)), timeoutMs)),
|
|
181
|
+
]);
|
|
182
|
+
|
|
183
|
+
const result = await timed;
|
|
184
|
+
return { result, logs };
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/**
|
|
188
|
+
* Persist a run artifact in the droid-mode data dir.
|
|
189
|
+
* @param {{
|
|
190
|
+
* serverName: string,
|
|
191
|
+
* workflowPath: string,
|
|
192
|
+
* tools: string[],
|
|
193
|
+
* output: any,
|
|
194
|
+
* trace: any[],
|
|
195
|
+
* }} opts
|
|
196
|
+
*/
|
|
197
|
+
export function writeRunArtifact(opts) {
|
|
198
|
+
const dataDir = getDroidModeDataDir();
|
|
199
|
+
const ts = nowIsoCompact();
|
|
200
|
+
const outDir = path.join(dataDir, "runs", opts.serverName, ts);
|
|
201
|
+
ensureDir(outDir);
|
|
202
|
+
const payload = {
|
|
203
|
+
serverName: opts.serverName,
|
|
204
|
+
workflowPath: opts.workflowPath,
|
|
205
|
+
tools: opts.tools,
|
|
206
|
+
finishedAt: new Date().toISOString(),
|
|
207
|
+
output: opts.output,
|
|
208
|
+
trace: opts.trace,
|
|
209
|
+
};
|
|
210
|
+
const file = path.join(outDir, "run.json");
|
|
211
|
+
writeJson(file, payload);
|
|
212
|
+
return { outDir, file };
|
|
213
|
+
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import { safeIdentifier } from "./util.mjs";
|
|
2
|
+
|
|
3
|
+
/** @param {string} s */
|
|
4
|
+
function tokenize(s) {
|
|
5
|
+
return (s || "")
|
|
6
|
+
.toLowerCase()
|
|
7
|
+
.split(/[^a-z0-9]+/g)
|
|
8
|
+
.filter(Boolean);
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Very lightweight ranking for tool search.
|
|
13
|
+
* @param {{name?:string,title?:string,description?:string}} tool
|
|
14
|
+
* @param {string[]} qTokens
|
|
15
|
+
* @param {string} qRaw
|
|
16
|
+
*/
|
|
17
|
+
function score(tool, qTokens, qRaw) {
|
|
18
|
+
const name = (tool?.name || "").toLowerCase();
|
|
19
|
+
const title = (tool?.title || "").toLowerCase();
|
|
20
|
+
const desc = (tool?.description || "").toLowerCase();
|
|
21
|
+
|
|
22
|
+
let s = 0;
|
|
23
|
+
|
|
24
|
+
if (qRaw && name.includes(qRaw)) s += 10;
|
|
25
|
+
if (qRaw && title.includes(qRaw)) s += 5;
|
|
26
|
+
if (qRaw && desc.includes(qRaw)) s += 3;
|
|
27
|
+
|
|
28
|
+
for (const tok of qTokens) {
|
|
29
|
+
if (name.includes(tok)) s += 4;
|
|
30
|
+
if (title.includes(tok)) s += 2;
|
|
31
|
+
if (desc.includes(tok)) s += 1;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// Prefer shorter tool names (minor)
|
|
35
|
+
s += Math.max(0, 2 - name.length / 30);
|
|
36
|
+
|
|
37
|
+
return s;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Search tools.
|
|
42
|
+
* @param {any[]} tools
|
|
43
|
+
* @param {string} query
|
|
44
|
+
* @param {{ limit?: number }} opts
|
|
45
|
+
*/
|
|
46
|
+
export function searchTools(tools, query, opts = {}) {
|
|
47
|
+
const limit = typeof opts.limit === "number" ? opts.limit : 8;
|
|
48
|
+
const qRaw = (query || "").trim().toLowerCase();
|
|
49
|
+
const qTokens = tokenize(query);
|
|
50
|
+
|
|
51
|
+
const ranked = (tools || [])
|
|
52
|
+
.map((t) => ({
|
|
53
|
+
tool: t,
|
|
54
|
+
score: score(t, qTokens, qRaw),
|
|
55
|
+
}))
|
|
56
|
+
.filter((x) => x.score > 0)
|
|
57
|
+
.sort((a, b) => b.score - a.score)
|
|
58
|
+
.slice(0, limit);
|
|
59
|
+
|
|
60
|
+
return ranked.map((r) => ({
|
|
61
|
+
name: r.tool?.name || "",
|
|
62
|
+
title: r.tool?.title || "",
|
|
63
|
+
description: (r.tool?.description || "").replace(/\s+/g, " ").trim(),
|
|
64
|
+
score: r.score,
|
|
65
|
+
safeName: safeIdentifier(r.tool?.name || ""),
|
|
66
|
+
}));
|
|
67
|
+
}
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
import { getDroidModeDataDir, readJsonFileIfExists, writeJson } from "./util.mjs";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* @param {import("./mcp_client.mjs").McpClient} client
|
|
6
|
+
*/
|
|
7
|
+
export async function fetchAllTools(client) {
|
|
8
|
+
const tools = [];
|
|
9
|
+
let cursor = undefined;
|
|
10
|
+
for (let i = 0; i < 10_000; i++) {
|
|
11
|
+
const res = await client.listTools({ cursor, timeoutMs: 60_000 });
|
|
12
|
+
if (Array.isArray(res?.tools)) tools.push(...res.tools);
|
|
13
|
+
cursor = res?.nextCursor;
|
|
14
|
+
if (!cursor) break;
|
|
15
|
+
}
|
|
16
|
+
return tools;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Load cached tools, optionally refresh if stale.
|
|
21
|
+
* @param {{
|
|
22
|
+
* serverName: string,
|
|
23
|
+
* client: import("./mcp_client.mjs").McpClient,
|
|
24
|
+
* refresh?: boolean,
|
|
25
|
+
* maxAgeMs?: number,
|
|
26
|
+
* }} opts
|
|
27
|
+
*/
|
|
28
|
+
export async function getToolsCached(opts) {
|
|
29
|
+
const dataDir = getDroidModeDataDir();
|
|
30
|
+
const cacheFile = path.join(dataDir, "cache", opts.serverName, "tools.json");
|
|
31
|
+
const maxAgeMs = typeof opts.maxAgeMs === "number" ? opts.maxAgeMs : 30 * 60 * 1000; // 30 minutes
|
|
32
|
+
|
|
33
|
+
if (!opts.refresh) {
|
|
34
|
+
const cached = readJsonFileIfExists(cacheFile);
|
|
35
|
+
if (cached?.fetchedAt && Array.isArray(cached?.tools)) {
|
|
36
|
+
const age = Date.now() - new Date(cached.fetchedAt).getTime();
|
|
37
|
+
if (!Number.isNaN(age) && age < maxAgeMs) {
|
|
38
|
+
return { tools: cached.tools, cacheFile, cached: true, meta: cached };
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// Refresh
|
|
44
|
+
await opts.client.init();
|
|
45
|
+
const tools = await fetchAllTools(opts.client);
|
|
46
|
+
const payload = {
|
|
47
|
+
fetchedAt: new Date().toISOString(),
|
|
48
|
+
serverName: opts.serverName,
|
|
49
|
+
protocolVersion: opts.client.negotiatedProtocolVersion,
|
|
50
|
+
serverInfo: opts.client.serverInfo,
|
|
51
|
+
capabilities: opts.client.serverCapabilities,
|
|
52
|
+
tools,
|
|
53
|
+
};
|
|
54
|
+
writeJson(cacheFile, payload);
|
|
55
|
+
return { tools, cacheFile, cached: false, meta: payload };
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Extract required parameters from a tool's inputSchema.
|
|
60
|
+
* @param {any} tool
|
|
61
|
+
* @returns {string[]}
|
|
62
|
+
*/
|
|
63
|
+
function getRequiredParams(tool) {
|
|
64
|
+
const schema = tool?.inputSchema;
|
|
65
|
+
if (!schema) return [];
|
|
66
|
+
const required = schema.required || [];
|
|
67
|
+
return Array.isArray(required) ? required : [];
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Return a compact tool list for display.
|
|
72
|
+
* @param {any[]} tools
|
|
73
|
+
* @param {{ includeParams?: boolean }} opts
|
|
74
|
+
*/
|
|
75
|
+
export function compactToolSummaries(tools, opts = {}) {
|
|
76
|
+
const includeParams = opts.includeParams !== false; // default true
|
|
77
|
+
return (tools || []).map((t) => {
|
|
78
|
+
const summary = {
|
|
79
|
+
name: t?.name || "",
|
|
80
|
+
title: t?.title || "",
|
|
81
|
+
description: (t?.description || "").replace(/\s+/g, " ").trim().slice(0, 200),
|
|
82
|
+
};
|
|
83
|
+
if (includeParams) {
|
|
84
|
+
const required = getRequiredParams(t);
|
|
85
|
+
summary.requires = required.length > 0 ? required.join(", ") : "-";
|
|
86
|
+
}
|
|
87
|
+
return summary;
|
|
88
|
+
});
|
|
89
|
+
}
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import os from "node:os";
|
|
4
|
+
import crypto from "node:crypto";
|
|
5
|
+
|
|
6
|
+
/** @returns {string} */
|
|
7
|
+
export function nowIsoCompact() {
|
|
8
|
+
const d = new Date();
|
|
9
|
+
const pad = (n) => String(n).padStart(2, "0");
|
|
10
|
+
return `${d.getFullYear()}${pad(d.getMonth() + 1)}${pad(d.getDate())}-${pad(
|
|
11
|
+
d.getHours()
|
|
12
|
+
)}${pad(d.getMinutes())}${pad(d.getSeconds())}`;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/** @param {string} s */
|
|
16
|
+
export function sha256Hex(s) {
|
|
17
|
+
return crypto.createHash("sha256").update(s).digest("hex");
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Convert a tool name (often snake_case or kebab-case) to a safe camelCase identifier.
|
|
22
|
+
* @param {string} toolName
|
|
23
|
+
*/
|
|
24
|
+
export function safeIdentifier(toolName) {
|
|
25
|
+
const cleaned = toolName
|
|
26
|
+
.replace(/[^a-zA-Z0-9_ -]/g, " ")
|
|
27
|
+
.trim()
|
|
28
|
+
.replace(/[-\s]+/g, "_");
|
|
29
|
+
const parts = cleaned.split("_").filter(Boolean);
|
|
30
|
+
if (!parts.length) return "tool";
|
|
31
|
+
const [first, ...rest] = parts;
|
|
32
|
+
const camel =
|
|
33
|
+
first.toLowerCase() +
|
|
34
|
+
rest.map((p) => p.charAt(0).toUpperCase() + p.slice(1).toLowerCase()).join("");
|
|
35
|
+
// Ensure identifier starts with a letter or underscore.
|
|
36
|
+
if (/^[a-zA-Z_]/.test(camel)) return camel;
|
|
37
|
+
return "_" + camel;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Lightweight CLI args parser (supports: --k=v, --k v, flags, positional args).
|
|
42
|
+
* Returns { _: string[], flags: Record<string, string|boolean> }.
|
|
43
|
+
* @param {string[]} argv
|
|
44
|
+
*/
|
|
45
|
+
export function parseArgs(argv) {
|
|
46
|
+
const out = { _: [], flags: {} };
|
|
47
|
+
for (let i = 0; i < argv.length; i++) {
|
|
48
|
+
const a = argv[i];
|
|
49
|
+
if (!a.startsWith("--")) {
|
|
50
|
+
out._.push(a);
|
|
51
|
+
continue;
|
|
52
|
+
}
|
|
53
|
+
const eq = a.indexOf("=");
|
|
54
|
+
if (eq !== -1) {
|
|
55
|
+
const k = a.slice(2, eq);
|
|
56
|
+
const v = a.slice(eq + 1);
|
|
57
|
+
out.flags[k] = v;
|
|
58
|
+
continue;
|
|
59
|
+
}
|
|
60
|
+
const k = a.slice(2);
|
|
61
|
+
const next = argv[i + 1];
|
|
62
|
+
if (next && !next.startsWith("--")) {
|
|
63
|
+
out.flags[k] = next;
|
|
64
|
+
i++;
|
|
65
|
+
} else {
|
|
66
|
+
out.flags[k] = true;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
return out;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** @param {string} p */
|
|
73
|
+
export function ensureDir(p) {
|
|
74
|
+
fs.mkdirSync(p, { recursive: true });
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** @param {string} filePath */
|
|
78
|
+
export function readJsonFileIfExists(filePath) {
|
|
79
|
+
try {
|
|
80
|
+
if (!fs.existsSync(filePath)) return null;
|
|
81
|
+
const txt = fs.readFileSync(filePath, "utf-8");
|
|
82
|
+
return JSON.parse(txt);
|
|
83
|
+
} catch (err) {
|
|
84
|
+
return null;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* @param {string} filePath
|
|
90
|
+
* @param {any} data
|
|
91
|
+
*/
|
|
92
|
+
export function writeJson(filePath, data) {
|
|
93
|
+
ensureDir(path.dirname(filePath));
|
|
94
|
+
fs.writeFileSync(filePath, JSON.stringify(data, null, 2) + "\n", "utf-8");
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Walk upward from cwd to find a directory containing ".factory".
|
|
99
|
+
* Returns null if not found.
|
|
100
|
+
* @param {string} startDir
|
|
101
|
+
*/
|
|
102
|
+
export function findProjectRoot(startDir = process.cwd()) {
|
|
103
|
+
let cur = path.resolve(startDir);
|
|
104
|
+
for (let i = 0; i < 50; i++) {
|
|
105
|
+
const candidate = path.join(cur, ".factory");
|
|
106
|
+
if (fs.existsSync(candidate) && fs.statSync(candidate).isDirectory()) return cur;
|
|
107
|
+
const parent = path.dirname(cur);
|
|
108
|
+
if (parent === cur) break;
|
|
109
|
+
cur = parent;
|
|
110
|
+
}
|
|
111
|
+
return null;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Returns the preferred working data dir:
|
|
116
|
+
* - <project>/.factory/droid-mode when in a project
|
|
117
|
+
* - ~/.factory/droid-mode otherwise
|
|
118
|
+
*/
|
|
119
|
+
export function getDroidModeDataDir() {
|
|
120
|
+
const root = findProjectRoot();
|
|
121
|
+
if (root) return path.join(root, ".factory", "droid-mode");
|
|
122
|
+
return path.join(os.homedir(), ".factory", "droid-mode");
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Print a compact table for humans.
|
|
127
|
+
* @param {Array<Record<string, string>>} rows
|
|
128
|
+
* @param {string[]} cols
|
|
129
|
+
*/
|
|
130
|
+
export function printTable(rows, cols) {
|
|
131
|
+
const widths = {};
|
|
132
|
+
for (const c of cols) widths[c] = c.length;
|
|
133
|
+
for (const r of rows) {
|
|
134
|
+
for (const c of cols) widths[c] = Math.max(widths[c], String(r[c] ?? "").length);
|
|
135
|
+
}
|
|
136
|
+
const sep =
|
|
137
|
+
cols.map((c) => "-".repeat(widths[c])).join(" ") + "\n";
|
|
138
|
+
const header =
|
|
139
|
+
cols.map((c) => String(c).padEnd(widths[c])).join(" ") + "\n";
|
|
140
|
+
const body = rows
|
|
141
|
+
.map((r) => cols.map((c) => String(r[c] ?? "").padEnd(widths[c])).join(" "))
|
|
142
|
+
.join("\n");
|
|
143
|
+
process.stdout.write(header);
|
|
144
|
+
process.stdout.write(sep);
|
|
145
|
+
process.stdout.write(body + (body ? "\n" : ""));
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* Best-effort: remove secrets from an env object for logging.
|
|
150
|
+
* @param {Record<string,string>|undefined|null} envObj
|
|
151
|
+
*/
|
|
152
|
+
export function redactEnvForDisplay(envObj) {
|
|
153
|
+
if (!envObj) return {};
|
|
154
|
+
const out = {};
|
|
155
|
+
for (const [k, v] of Object.entries(envObj)) {
|
|
156
|
+
const isSecret = /(key|token|secret|password|auth|bearer)/i.test(k);
|
|
157
|
+
out[k] = isSecret ? "***" : String(v);
|
|
158
|
+
}
|
|
159
|
+
return out;
|
|
160
|
+
}
|