@fieldwangai/agentflow 0.1.25
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 +201 -0
- package/README.zh-CN.md +201 -0
- package/agents/agentflow-node-executor-code.md +32 -0
- package/agents/agentflow-node-executor-planning.md +32 -0
- package/agents/agentflow-node-executor-requirement.md +32 -0
- package/agents/agentflow-node-executor-test.md +32 -0
- package/agents/agentflow-node-executor-ui.md +32 -0
- package/agents/agentflow-node-executor.md +32 -0
- package/agents/agents.json +8 -0
- package/agents/en/agentflow-node-executor.md +32 -0
- package/agents/zh/agentflow-node-executor.md +32 -0
- package/bin/agentflow.mjs +52 -0
- package/bin/ensure-workspace-reference.mjs +35 -0
- package/bin/lib/agent-runners.mjs +1199 -0
- package/bin/lib/agents-path.mjs +61 -0
- package/bin/lib/api-runner.mjs +361 -0
- package/bin/lib/apply.mjs +852 -0
- package/bin/lib/catalog-agents.mjs +300 -0
- package/bin/lib/catalog-flows.mjs +532 -0
- package/bin/lib/composer-agent.mjs +884 -0
- package/bin/lib/composer-flow-instances.mjs +68 -0
- package/bin/lib/composer-flow-skeleton.mjs +334 -0
- package/bin/lib/composer-flow-validate.mjs +47 -0
- package/bin/lib/composer-log.mjs +197 -0
- package/bin/lib/composer-model-router.mjs +160 -0
- package/bin/lib/composer-node-schema.mjs +299 -0
- package/bin/lib/composer-planner.mjs +749 -0
- package/bin/lib/composer-script-ops.mjs +233 -0
- package/bin/lib/composer-skill-router.mjs +384 -0
- package/bin/lib/flow-import.mjs +305 -0
- package/bin/lib/flow-normalize.mjs +71 -0
- package/bin/lib/flow-write.mjs +395 -0
- package/bin/lib/help.mjs +139 -0
- package/bin/lib/hub-login.mjs +54 -0
- package/bin/lib/hub-publish.mjs +159 -0
- package/bin/lib/hub-remote.mjs +189 -0
- package/bin/lib/hub.mjs +299 -0
- package/bin/lib/i18n.mjs +233 -0
- package/bin/lib/locales/en.json +344 -0
- package/bin/lib/locales/zh.json +344 -0
- package/bin/lib/log.mjs +37 -0
- package/bin/lib/main.mjs +611 -0
- package/bin/lib/model-config.mjs +118 -0
- package/bin/lib/model-lists.mjs +188 -0
- package/bin/lib/node-exec-context.mjs +336 -0
- package/bin/lib/node-execute.mjs +513 -0
- package/bin/lib/normalize-node-tool-command.mjs +97 -0
- package/bin/lib/paths.mjs +216 -0
- package/bin/lib/pipeline-scripts.mjs +41 -0
- package/bin/lib/recent-runs.mjs +173 -0
- package/bin/lib/run-apply-active-lock.mjs +82 -0
- package/bin/lib/run-events.mjs +85 -0
- package/bin/lib/run-node-statuses-from-disk.mjs +85 -0
- package/bin/lib/schedule-config.mjs +227 -0
- package/bin/lib/scheduler.mjs +312 -0
- package/bin/lib/table.mjs +4 -0
- package/bin/lib/terminal.mjs +42 -0
- package/bin/lib/ui-print.mjs +94 -0
- package/bin/lib/ui-server.mjs +2113 -0
- package/bin/lib/workspace-tree.mjs +266 -0
- package/bin/lib/workspace.mjs +180 -0
- package/bin/pipeline/build-node-prompt.mjs +179 -0
- package/bin/pipeline/check-cache.mjs +191 -0
- package/bin/pipeline/check-flow.mjs +543 -0
- package/bin/pipeline/collect-nodes.mjs +212 -0
- package/bin/pipeline/compute-cache-md5.mjs +177 -0
- package/bin/pipeline/ensure-run-dir.mjs +71 -0
- package/bin/pipeline/extract-thinking.mjs +308 -0
- package/bin/pipeline/gc.mjs +129 -0
- package/bin/pipeline/get-env.mjs +83 -0
- package/bin/pipeline/get-exec-id.mjs +145 -0
- package/bin/pipeline/get-ready-nodes.mjs +435 -0
- package/bin/pipeline/get-resolved-values.mjs +337 -0
- package/bin/pipeline/load-key.mjs +62 -0
- package/bin/pipeline/parse-bool.mjs +33 -0
- package/bin/pipeline/parse-flow.mjs +698 -0
- package/bin/pipeline/post-process-control-if.mjs +23 -0
- package/bin/pipeline/post-process-node.mjs +490 -0
- package/bin/pipeline/pre-process-node.mjs +449 -0
- package/bin/pipeline/resolve-inputs.mjs +201 -0
- package/bin/pipeline/run-log.mjs +34 -0
- package/bin/pipeline/run-tool-nodejs.mjs +160 -0
- package/bin/pipeline/save-key.mjs +93 -0
- package/bin/pipeline/snapshot-prior-round.mjs +70 -0
- package/bin/pipeline/validate-flow.mjs +825 -0
- package/bin/pipeline/validate-for-ui.mjs +226 -0
- package/bin/pipeline/validate-script-output.mjs +130 -0
- package/bin/pipeline/write-result.mjs +182 -0
- package/builtin/nodes/agent_subAgent.md +14 -0
- package/builtin/nodes/control_agent_toBool.md +20 -0
- package/builtin/nodes/control_anyOne.md +17 -0
- package/builtin/nodes/control_end.md +11 -0
- package/builtin/nodes/control_if.md +20 -0
- package/builtin/nodes/control_start.md +11 -0
- package/builtin/nodes/control_toBool.md +21 -0
- package/builtin/nodes/provide_file.md +11 -0
- package/builtin/nodes/provide_str.md +11 -0
- package/builtin/nodes/tool_get_env.md +14 -0
- package/builtin/nodes/tool_load_key.md +20 -0
- package/builtin/nodes/tool_nodejs.md +40 -0
- package/builtin/nodes/tool_print.md +14 -0
- package/builtin/nodes/tool_save_key.md +20 -0
- package/builtin/nodes/tool_user_ask.md +23 -0
- package/builtin/nodes/tool_user_check.md +22 -0
- package/builtin/pipelines/module-migrate/flow.yaml +819 -0
- package/builtin/pipelines/module-migrate/scripts/check_imports.mjs +700 -0
- package/builtin/pipelines/module-migrate/scripts/node_modules/tree-sitter-kotlin/build/Makefile +362 -0
- package/builtin/pipelines/module-migrate/scripts/node_modules/tree-sitter-kotlin/build/Release/.deps/Release/obj.target/node_modules/node-addon-api/node_addon_api_except.stamp.d +1 -0
- package/builtin/pipelines/module-migrate/scripts/node_modules/tree-sitter-kotlin/build/Release/.deps/Release/obj.target/tree_sitter_kotlin_binding/bindings/node/binding.o.d +17 -0
- package/builtin/pipelines/module-migrate/scripts/node_modules/tree-sitter-kotlin/build/Release/.deps/Release/obj.target/tree_sitter_kotlin_binding/src/parser.o.d +5 -0
- package/builtin/pipelines/module-migrate/scripts/node_modules/tree-sitter-kotlin/build/Release/.deps/Release/obj.target/tree_sitter_kotlin_binding/src/scanner.o.d +8 -0
- package/builtin/pipelines/module-migrate/scripts/node_modules/tree-sitter-kotlin/build/Release/.deps/Release/tree_sitter_kotlin_binding.node.d +1 -0
- package/builtin/pipelines/module-migrate/scripts/node_modules/tree-sitter-kotlin/build/Release/obj.target/node_modules/node-addon-api/node_addon_api_except.stamp +0 -0
- package/builtin/pipelines/module-migrate/scripts/node_modules/tree-sitter-kotlin/build/Release/obj.target/tree_sitter_kotlin_binding/bindings/node/binding.o +0 -0
- package/builtin/pipelines/module-migrate/scripts/node_modules/tree-sitter-kotlin/build/Release/obj.target/tree_sitter_kotlin_binding/src/parser.o +0 -0
- package/builtin/pipelines/module-migrate/scripts/node_modules/tree-sitter-kotlin/build/Release/obj.target/tree_sitter_kotlin_binding/src/scanner.o +0 -0
- package/builtin/pipelines/module-migrate/scripts/node_modules/tree-sitter-kotlin/build/Release/tree_sitter_kotlin_binding.node +0 -0
- package/builtin/pipelines/module-migrate/scripts/node_modules/tree-sitter-kotlin/build/binding.Makefile +6 -0
- package/builtin/pipelines/module-migrate/scripts/node_modules/tree-sitter-kotlin/build/gyp-mac-tool +768 -0
- package/builtin/pipelines/module-migrate/scripts/node_modules/tree-sitter-kotlin/build/node_modules/node-addon-api/node_addon_api.Makefile +6 -0
- package/builtin/pipelines/module-migrate/scripts/node_modules/tree-sitter-kotlin/build/node_modules/node-addon-api/node_addon_api.target.mk +122 -0
- package/builtin/pipelines/module-migrate/scripts/node_modules/tree-sitter-kotlin/build/node_modules/node-addon-api/node_addon_api_except.target.mk +126 -0
- package/builtin/pipelines/module-migrate/scripts/node_modules/tree-sitter-kotlin/build/node_modules/node-addon-api/node_addon_api_maybe.target.mk +122 -0
- package/builtin/pipelines/module-migrate/scripts/node_modules/tree-sitter-kotlin/build/tree_sitter_kotlin_binding.target.mk +203 -0
- package/builtin/pipelines/new/flow.yaml +545 -0
- package/builtin/pipelines/new/scripts/check-flow.mjs +9 -0
- package/builtin/pipelines/new/scripts/collect-nodes.mjs +211 -0
- package/builtin/pipelines/scripts/adjust-node-positions.mjs +113 -0
- package/builtin/web-ui/dist/agentflow-icon.svg +23 -0
- package/builtin/web-ui/dist/assets/index-CZkUPcXE.css +1 -0
- package/builtin/web-ui/dist/assets/index-DkkhNESc.js +190 -0
- package/builtin/web-ui/dist/index.html +24 -0
- package/package.json +67 -0
- package/reference/flow-control-capabilities.md +274 -0
- package/reference/flow-layout.md +84 -0
- package/reference/flow-prompt-handler-check.md +12 -0
- package/reference/flow-result-semantics.md +14 -0
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* agentflow publish <FlowName> [--title <title>] [--description <desc>] [--tags <t1,t2>]
|
|
3
|
+
*
|
|
4
|
+
* Reads flow.yaml (or zips the flow directory if scripts/ exists),
|
|
5
|
+
* uploads to Hub, and inserts the flow record.
|
|
6
|
+
*/
|
|
7
|
+
import fs from "fs";
|
|
8
|
+
import path from "path";
|
|
9
|
+
import { execSync } from "child_process";
|
|
10
|
+
import chalk from "chalk";
|
|
11
|
+
import yaml from "js-yaml";
|
|
12
|
+
import { log } from "./log.mjs";
|
|
13
|
+
import {
|
|
14
|
+
getStoredSession,
|
|
15
|
+
getUserProfile,
|
|
16
|
+
uploadToStorage,
|
|
17
|
+
insertFlow,
|
|
18
|
+
findFlowByAuthorAndTitle,
|
|
19
|
+
updateFlow,
|
|
20
|
+
deleteStorageObject,
|
|
21
|
+
} from "./hub.mjs";
|
|
22
|
+
import { getFlowDir } from "./workspace.mjs";
|
|
23
|
+
|
|
24
|
+
function slugify(text) {
|
|
25
|
+
return text
|
|
26
|
+
.toLowerCase()
|
|
27
|
+
.replace(/[^a-z0-9]+/g, "-")
|
|
28
|
+
.replace(/^-+|-+$/g, "")
|
|
29
|
+
.slice(0, 60);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function countNodes(yamlContent) {
|
|
33
|
+
try {
|
|
34
|
+
const doc = yaml.load(yamlContent);
|
|
35
|
+
if (doc?.nodes && Array.isArray(doc.nodes)) return doc.nodes.length;
|
|
36
|
+
if (doc?.pipeline?.nodes && Array.isArray(doc.pipeline.nodes)) return doc.pipeline.nodes.length;
|
|
37
|
+
return 0;
|
|
38
|
+
} catch {
|
|
39
|
+
return 0;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export async function hubPublish(workspaceRoot, argv) {
|
|
44
|
+
// Auth check
|
|
45
|
+
const session = await getStoredSession();
|
|
46
|
+
if (!session?.access_token) {
|
|
47
|
+
throw new Error("Not logged in. Run: agentflow login");
|
|
48
|
+
}
|
|
49
|
+
const user = await getUserProfile(session.access_token);
|
|
50
|
+
if (!user?.id) {
|
|
51
|
+
throw new Error("Session expired. Run: agentflow login");
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// Parse args
|
|
55
|
+
const flowName = argv.find((a) => !a.startsWith("--"));
|
|
56
|
+
if (!flowName) {
|
|
57
|
+
throw new Error("Usage: agentflow publish <FlowName> [--title <title>] [--description <desc>] [--tags <t1,t2>]");
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
let titleOpt, descOpt, tagsOpt;
|
|
61
|
+
const titleIdx = argv.indexOf("--title");
|
|
62
|
+
if (titleIdx >= 0 && argv[titleIdx + 1]) titleOpt = argv[titleIdx + 1];
|
|
63
|
+
const descIdx = argv.indexOf("--description");
|
|
64
|
+
if (descIdx >= 0 && argv[descIdx + 1]) descOpt = argv[descIdx + 1];
|
|
65
|
+
const tagsIdx = argv.indexOf("--tags");
|
|
66
|
+
if (tagsIdx >= 0 && argv[tagsIdx + 1]) tagsOpt = argv[tagsIdx + 1];
|
|
67
|
+
|
|
68
|
+
// Find flow directory
|
|
69
|
+
const flowDir = getFlowDir(workspaceRoot, flowName);
|
|
70
|
+
if (!flowDir) {
|
|
71
|
+
throw new Error("Flow not found: " + flowName);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const flowYamlPath = path.join(flowDir, "flow.yaml");
|
|
75
|
+
if (!fs.existsSync(flowYamlPath)) {
|
|
76
|
+
throw new Error("flow.yaml not found in " + flowDir);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const yamlContent = fs.readFileSync(flowYamlPath, "utf8");
|
|
80
|
+
const nodeCount = countNodes(yamlContent);
|
|
81
|
+
|
|
82
|
+
// Auto-read metadata from flow.yaml
|
|
83
|
+
let flowDesc = null;
|
|
84
|
+
try {
|
|
85
|
+
const doc = yaml.load(yamlContent);
|
|
86
|
+
if (doc?.ui?.description) flowDesc = doc.ui.description;
|
|
87
|
+
} catch {}
|
|
88
|
+
|
|
89
|
+
const title = titleOpt || flowName;
|
|
90
|
+
const description = descOpt || flowDesc || null;
|
|
91
|
+
const tags = tagsOpt ? tagsOpt.split(",").map((s) => s.trim()).filter(Boolean) : [];
|
|
92
|
+
|
|
93
|
+
// Check if flow directory has scripts/ or other files beyond flow.yaml
|
|
94
|
+
const entries = fs.readdirSync(flowDir);
|
|
95
|
+
const hasExtras = entries.some((e) => e !== "flow.yaml" && e !== ".DS_Store");
|
|
96
|
+
|
|
97
|
+
// Check if this author already published a flow with this title — update instead of insert.
|
|
98
|
+
const existing = await findFlowByAuthorAndTitle(session.access_token, user.id, title);
|
|
99
|
+
|
|
100
|
+
let fileBuffer, fileKey, contentType;
|
|
101
|
+
const ext = hasExtras ? ".zip" : ".yaml";
|
|
102
|
+
const slug = existing?.slug || slugify(title) + "-" + Date.now().toString(36);
|
|
103
|
+
fileKey = `${user.id}/${slug}${ext}`;
|
|
104
|
+
|
|
105
|
+
if (hasExtras) {
|
|
106
|
+
log.info("Flow has scripts/extras — creating zip...");
|
|
107
|
+
const zipPath = path.join(flowDir, ".hub-upload.zip");
|
|
108
|
+
try {
|
|
109
|
+
execSync(`cd "${flowDir}" && zip -r "${zipPath}" . -x ".*"`, { stdio: "pipe" });
|
|
110
|
+
fileBuffer = fs.readFileSync(zipPath);
|
|
111
|
+
contentType = "application/zip";
|
|
112
|
+
} finally {
|
|
113
|
+
try { fs.unlinkSync(zipPath); } catch {}
|
|
114
|
+
}
|
|
115
|
+
} else {
|
|
116
|
+
fileBuffer = Buffer.from(yamlContent, "utf8");
|
|
117
|
+
contentType = "text/yaml";
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// Updating:先删老 artifact 再 INSERT 新文件。
|
|
121
|
+
// Supabase Storage 的 bucket 策略多数只放行 INSERT;x-upsert=true 走 UPDATE 路径
|
|
122
|
+
// 会被拒成 "new row violates row-level security policy"。DELETE + INSERT 最稳:
|
|
123
|
+
// 不论扩展名是否变,老对象先清掉,再走纯 INSERT。
|
|
124
|
+
if (existing && existing.yaml_key) {
|
|
125
|
+
log.info("Removing old artifact: " + existing.yaml_key);
|
|
126
|
+
await deleteStorageObject(session.access_token, existing.yaml_key);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
log.info("Uploading " + (hasExtras ? "zip" : "flow.yaml") + " (" + (fileBuffer.length / 1024).toFixed(1) + " KB)...");
|
|
130
|
+
await uploadToStorage(session.access_token, fileKey, fileBuffer, contentType);
|
|
131
|
+
|
|
132
|
+
if (existing) {
|
|
133
|
+
log.info("Updating existing flow record...");
|
|
134
|
+
await updateFlow(session.access_token, existing.id, {
|
|
135
|
+
description,
|
|
136
|
+
tags,
|
|
137
|
+
yaml_key: fileKey,
|
|
138
|
+
node_count: nodeCount,
|
|
139
|
+
});
|
|
140
|
+
log.info(chalk.green("✓") + " Updated: " + chalk.bold(title));
|
|
141
|
+
} else {
|
|
142
|
+
log.info("Publishing flow record...");
|
|
143
|
+
await insertFlow(session.access_token, {
|
|
144
|
+
slug,
|
|
145
|
+
author_id: user.id,
|
|
146
|
+
title,
|
|
147
|
+
description,
|
|
148
|
+
tags,
|
|
149
|
+
yaml_key: fileKey,
|
|
150
|
+
node_count: nodeCount,
|
|
151
|
+
});
|
|
152
|
+
log.info(chalk.green("✓") + " Published: " + chalk.bold(title));
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
log.info(" slug: " + slug);
|
|
156
|
+
log.info(" nodes: " + nodeCount);
|
|
157
|
+
if (hasExtras) log.info(" type: zip (includes scripts)");
|
|
158
|
+
log.info(" " + chalk.dim("View at: https://agentflow-hub.com/flows/" + slug));
|
|
159
|
+
}
|
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* agentflow list-remote [--search <query>] [--sort popular|trending] [--json]
|
|
3
|
+
* agentflow download <slug|title> [--as <flowId>] [--raw [--output <dir>]]
|
|
4
|
+
*/
|
|
5
|
+
import fs from "fs";
|
|
6
|
+
import path from "path";
|
|
7
|
+
import chalk from "chalk";
|
|
8
|
+
import { log } from "./log.mjs";
|
|
9
|
+
import { Table } from "./table.mjs";
|
|
10
|
+
import { queryFlows, queryFlowBySlug, downloadFlowFile, incrementDownload } from "./hub.mjs";
|
|
11
|
+
import {
|
|
12
|
+
unzipAndNormalizePipelineZip,
|
|
13
|
+
suggestFlowIdFromZip,
|
|
14
|
+
writePipelineTree,
|
|
15
|
+
validateImportedFlowYaml,
|
|
16
|
+
} from "./flow-import.mjs";
|
|
17
|
+
import { resolveFlowDirForWrite, validateUserPipelineId } from "./flow-write.mjs";
|
|
18
|
+
|
|
19
|
+
export async function hubListRemote(argv) {
|
|
20
|
+
let search = "";
|
|
21
|
+
let sort = "popular";
|
|
22
|
+
const jsonMode = argv.includes("--json");
|
|
23
|
+
|
|
24
|
+
const searchIdx = argv.indexOf("--search");
|
|
25
|
+
if (searchIdx >= 0 && argv[searchIdx + 1]) search = argv[searchIdx + 1];
|
|
26
|
+
const sortIdx = argv.indexOf("--sort");
|
|
27
|
+
if (sortIdx >= 0 && argv[sortIdx + 1]) sort = argv[sortIdx + 1];
|
|
28
|
+
|
|
29
|
+
const flows = await queryFlows({ sort, search });
|
|
30
|
+
|
|
31
|
+
if (jsonMode) {
|
|
32
|
+
process.stdout.write(JSON.stringify(flows) + "\n");
|
|
33
|
+
return;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
if (flows.length === 0) {
|
|
37
|
+
log.info("No flows found." + (search ? " (search: " + search + ")" : ""));
|
|
38
|
+
return;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const table = new Table({
|
|
42
|
+
head: ["Slug", "Title", "Author", "Downloads", "Nodes", "Tags"],
|
|
43
|
+
style: { head: [] },
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
for (const f of flows) {
|
|
47
|
+
table.push([
|
|
48
|
+
f.slug,
|
|
49
|
+
(f.title || "").slice(0, 30),
|
|
50
|
+
f.profiles?.username || "-",
|
|
51
|
+
String(f.downloads || 0),
|
|
52
|
+
String(f.node_count || 0),
|
|
53
|
+
(f.tags || []).join(", "),
|
|
54
|
+
]);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
log.info("\n" + chalk.bold("AgentFlow Hub — " + (sort === "trending" ? "Trending" : "Popular")) + "\n");
|
|
58
|
+
log.info(table.toString());
|
|
59
|
+
log.info("\n" + chalk.dim("Download: agentflow download <slug>"));
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Derive a filesystem-safe user pipeline id from a free-form string.
|
|
64
|
+
* Replaces non-alnum with underscore, strips leading digits/underscores/hyphens.
|
|
65
|
+
* Returns null if nothing usable remains.
|
|
66
|
+
* @param {string} s
|
|
67
|
+
* @returns {string | null}
|
|
68
|
+
*/
|
|
69
|
+
function sanitizeToPipelineId(s) {
|
|
70
|
+
if (!s || typeof s !== "string") return null;
|
|
71
|
+
const cleaned = s.trim().replace(/[^a-zA-Z0-9_-]+/g, "_").replace(/^[-_]+|[-_]+$/g, "");
|
|
72
|
+
if (!cleaned) return null;
|
|
73
|
+
const leadDigitsStripped = cleaned.replace(/^[0-9]+/, "");
|
|
74
|
+
const candidate = leadDigitsStripped || "flow_" + cleaned;
|
|
75
|
+
const v = validateUserPipelineId(candidate);
|
|
76
|
+
return v.ok ? v.flowId : null;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export async function hubDownload(argv) {
|
|
80
|
+
const positional = argv.find((a) => !a.startsWith("--"));
|
|
81
|
+
if (!positional) {
|
|
82
|
+
throw new Error(
|
|
83
|
+
"Usage: agentflow download <slug|title> [--user|--workspace] [--as <flowId>] [--raw [--output <dir>]]",
|
|
84
|
+
);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const raw = argv.includes("--raw");
|
|
88
|
+
let outputDir = process.cwd();
|
|
89
|
+
const outIdx = argv.indexOf("--output");
|
|
90
|
+
if (outIdx >= 0 && argv[outIdx + 1]) outputDir = path.resolve(argv[outIdx + 1]);
|
|
91
|
+
|
|
92
|
+
let overrideId = null;
|
|
93
|
+
const asIdx = argv.indexOf("--as");
|
|
94
|
+
if (asIdx >= 0 && argv[asIdx + 1]) overrideId = argv[asIdx + 1];
|
|
95
|
+
|
|
96
|
+
const wantWorkspace = argv.includes("--workspace");
|
|
97
|
+
const wantUser = argv.includes("--user");
|
|
98
|
+
if (wantWorkspace && wantUser) {
|
|
99
|
+
throw new Error("--user and --workspace are mutually exclusive");
|
|
100
|
+
}
|
|
101
|
+
const flowSource = wantWorkspace ? "workspace" : "user";
|
|
102
|
+
|
|
103
|
+
log.info("Looking up flow: " + chalk.bold(positional) + "...");
|
|
104
|
+
const flow = await queryFlowBySlug(positional);
|
|
105
|
+
if (!flow) {
|
|
106
|
+
throw new Error("Flow not found: " + positional);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
log.info("Downloading " + chalk.bold(flow.title) + " (" + flow.node_count + " nodes)...");
|
|
110
|
+
const buffer = await downloadFlowFile(flow.yaml_key);
|
|
111
|
+
const isZip = flow.yaml_key.endsWith(".zip");
|
|
112
|
+
await incrementDownload(flow.slug);
|
|
113
|
+
|
|
114
|
+
// ─── Raw mode: just save the artifact to outputDir. ───
|
|
115
|
+
if (raw) {
|
|
116
|
+
const ext = isZip ? ".zip" : ".yaml";
|
|
117
|
+
const filename = flow.slug + ext;
|
|
118
|
+
const outputPath = path.join(outputDir, filename);
|
|
119
|
+
fs.mkdirSync(outputDir, { recursive: true });
|
|
120
|
+
fs.writeFileSync(outputPath, buffer);
|
|
121
|
+
log.info(chalk.green("✓") + " Saved: " + outputPath + " (" + (buffer.length / 1024).toFixed(1) + " KB)");
|
|
122
|
+
if (isZip) {
|
|
123
|
+
log.info(chalk.dim(" Unzip to use: unzip " + filename + " -d " + flow.slug));
|
|
124
|
+
}
|
|
125
|
+
return;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// ─── Default: install into pipelines dir (user or workspace). ───
|
|
129
|
+
let files;
|
|
130
|
+
if (isZip) {
|
|
131
|
+
const un = unzipAndNormalizePipelineZip(buffer);
|
|
132
|
+
if (!un.ok) throw new Error("Unzip failed: " + un.error);
|
|
133
|
+
files = un.files;
|
|
134
|
+
} else {
|
|
135
|
+
const text = buffer.toString("utf8");
|
|
136
|
+
const v = validateImportedFlowYaml(text);
|
|
137
|
+
if (!v.ok) throw new Error("Invalid flow.yaml: " + v.error);
|
|
138
|
+
files = new Map([["flow.yaml", buffer]]);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// ─── Determine target flowId. ───
|
|
142
|
+
const candidates = [];
|
|
143
|
+
if (overrideId) candidates.push(overrideId);
|
|
144
|
+
if (isZip) {
|
|
145
|
+
const s = suggestFlowIdFromZip(buffer);
|
|
146
|
+
if (s.ok && s.suggestedFlowId) candidates.push(s.suggestedFlowId);
|
|
147
|
+
}
|
|
148
|
+
const fromTitle = sanitizeToPipelineId(flow.title);
|
|
149
|
+
if (fromTitle) candidates.push(fromTitle);
|
|
150
|
+
const fromSlug = sanitizeToPipelineId(flow.slug);
|
|
151
|
+
if (fromSlug) candidates.push(fromSlug);
|
|
152
|
+
|
|
153
|
+
let flowId = null;
|
|
154
|
+
let lastError = null;
|
|
155
|
+
for (const c of candidates) {
|
|
156
|
+
const v = validateUserPipelineId(c);
|
|
157
|
+
if (!v.ok) {
|
|
158
|
+
lastError = v.error;
|
|
159
|
+
continue;
|
|
160
|
+
}
|
|
161
|
+
flowId = v.flowId;
|
|
162
|
+
break;
|
|
163
|
+
}
|
|
164
|
+
if (!flowId) {
|
|
165
|
+
throw new Error("Could not derive a valid pipeline id" + (lastError ? ": " + lastError : ""));
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
const res = writePipelineTree(process.cwd(), flowId, flowSource, files);
|
|
169
|
+
if (!res.success) {
|
|
170
|
+
if (/已存在/.test(res.error || "")) {
|
|
171
|
+
throw new Error(
|
|
172
|
+
"Target pipeline already exists: " + flowId +
|
|
173
|
+
". Use `--as <newId>` to install under a different name, or `--raw` to just save the file.",
|
|
174
|
+
);
|
|
175
|
+
}
|
|
176
|
+
throw new Error("Install failed: " + res.error);
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
const dirInfo = resolveFlowDirForWrite(process.cwd(), flowId, flowSource);
|
|
180
|
+
const scopeLabel = flowSource === "workspace" ? "workspace" : "user";
|
|
181
|
+
log.info(
|
|
182
|
+
chalk.green("✓") + " Installed [" + scopeLabel + "]: " + chalk.bold(flowId) +
|
|
183
|
+
" (" + (buffer.length / 1024).toFixed(1) + " KB)",
|
|
184
|
+
);
|
|
185
|
+
if (dirInfo.flowDir) {
|
|
186
|
+
log.info(chalk.dim(" Path: " + dirInfo.flowDir));
|
|
187
|
+
}
|
|
188
|
+
log.info(chalk.dim(" Run with: agentflow apply " + flowId));
|
|
189
|
+
}
|
package/bin/lib/hub.mjs
ADDED
|
@@ -0,0 +1,299 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* AgentFlow Hub — Supabase client, token persistence, and shared helpers.
|
|
3
|
+
*/
|
|
4
|
+
import fs from "fs";
|
|
5
|
+
import http from "http";
|
|
6
|
+
import os from "os";
|
|
7
|
+
import path from "path";
|
|
8
|
+
import { spawn } from "child_process";
|
|
9
|
+
import { log } from "./log.mjs";
|
|
10
|
+
import { t } from "./i18n.mjs";
|
|
11
|
+
|
|
12
|
+
const HUB_URL = "https://hvgnpjuiumxtymksqgki.supabase.co";
|
|
13
|
+
const HUB_ANON_KEY = "sb_publishable_DIiFQno26UCIsN24Xpiotw_MxIwWCb8";
|
|
14
|
+
|
|
15
|
+
function hubConfigPath() {
|
|
16
|
+
return path.join(os.homedir(), ".agentflow", "hub.json");
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function readHubConfig() {
|
|
20
|
+
try {
|
|
21
|
+
return JSON.parse(fs.readFileSync(hubConfigPath(), "utf8"));
|
|
22
|
+
} catch {
|
|
23
|
+
return null;
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function writeHubConfig(config) {
|
|
28
|
+
const dir = path.dirname(hubConfigPath());
|
|
29
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
30
|
+
fs.writeFileSync(hubConfigPath(), JSON.stringify(config, null, 2));
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Create a Supabase-like REST client (no dependency needed).
|
|
35
|
+
* Uses fetch() against PostgREST and Storage endpoints.
|
|
36
|
+
*/
|
|
37
|
+
function supabaseHeaders(accessToken) {
|
|
38
|
+
const headers = {
|
|
39
|
+
apikey: HUB_ANON_KEY,
|
|
40
|
+
"Content-Type": "application/json",
|
|
41
|
+
};
|
|
42
|
+
if (accessToken) {
|
|
43
|
+
headers.Authorization = "Bearer " + accessToken;
|
|
44
|
+
} else {
|
|
45
|
+
headers.Authorization = "Bearer " + HUB_ANON_KEY;
|
|
46
|
+
}
|
|
47
|
+
return headers;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// ──── Public REST helpers ────
|
|
51
|
+
|
|
52
|
+
export async function queryFlows({ sort = "popular", search = "", limit = 50 } = {}) {
|
|
53
|
+
const order = sort === "trending" ? "created_at.desc" : "downloads.desc";
|
|
54
|
+
let url = `${HUB_URL}/rest/v1/flows?select=*,profiles!flows_author_id_fkey(username)&published=eq.true&order=${order}&limit=${limit}`;
|
|
55
|
+
if (search) {
|
|
56
|
+
url += `&title=ilike.*${encodeURIComponent(search)}*`;
|
|
57
|
+
}
|
|
58
|
+
const res = await fetch(url, { headers: supabaseHeaders() });
|
|
59
|
+
if (!res.ok) throw new Error("Hub query failed: " + res.status);
|
|
60
|
+
return res.json();
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export async function queryFlowBySlug(slug) {
|
|
64
|
+
const baseSelect = `select=*,profiles!flows_author_id_fkey(username)`;
|
|
65
|
+
const bySlug = `${HUB_URL}/rest/v1/flows?${baseSelect}&slug=eq.${encodeURIComponent(slug)}&limit=1`;
|
|
66
|
+
const slugRes = await fetch(bySlug, { headers: supabaseHeaders() });
|
|
67
|
+
if (!slugRes.ok) throw new Error("Hub query failed: " + slugRes.status);
|
|
68
|
+
const slugData = await slugRes.json();
|
|
69
|
+
if (slugData[0]) return slugData[0];
|
|
70
|
+
|
|
71
|
+
// Fallback: exact title match (case-insensitive) among published flows.
|
|
72
|
+
// If multiple share the same title, pick the most downloaded.
|
|
73
|
+
const titleEsc = encodeURIComponent(slug.replace(/[,%*()]/g, ""));
|
|
74
|
+
const byTitle = `${HUB_URL}/rest/v1/flows?${baseSelect}&published=eq.true&title=ilike.${titleEsc}&order=downloads.desc&limit=1`;
|
|
75
|
+
const titleRes = await fetch(byTitle, { headers: supabaseHeaders() });
|
|
76
|
+
if (!titleRes.ok) throw new Error("Hub query failed: " + titleRes.status);
|
|
77
|
+
const titleData = await titleRes.json();
|
|
78
|
+
return titleData[0] || null;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export async function downloadFlowFile(yamlKey) {
|
|
82
|
+
const url = `${HUB_URL}/storage/v1/object/public/flows/${encodeURIComponent(yamlKey)}`;
|
|
83
|
+
const res = await fetch(url);
|
|
84
|
+
if (!res.ok) throw new Error("Download failed: " + res.status);
|
|
85
|
+
return Buffer.from(await res.arrayBuffer());
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export async function incrementDownload(slug) {
|
|
89
|
+
const url = `${HUB_URL}/rest/v1/rpc/increment_download`;
|
|
90
|
+
await fetch(url, {
|
|
91
|
+
method: "POST",
|
|
92
|
+
headers: supabaseHeaders(),
|
|
93
|
+
body: JSON.stringify({ flow_slug: slug }),
|
|
94
|
+
}).catch(() => {});
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// ──── Auth (login) ────
|
|
98
|
+
|
|
99
|
+
export async function getStoredSession() {
|
|
100
|
+
const config = readHubConfig();
|
|
101
|
+
if (!config?.access_token) return null;
|
|
102
|
+
// Try refresh if expired
|
|
103
|
+
if (config.expires_at && Date.now() / 1000 > config.expires_at - 60) {
|
|
104
|
+
const refreshed = await refreshSession(config.refresh_token);
|
|
105
|
+
if (refreshed) return refreshed;
|
|
106
|
+
return null;
|
|
107
|
+
}
|
|
108
|
+
return config;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
async function refreshSession(refreshToken) {
|
|
112
|
+
if (!refreshToken) return null;
|
|
113
|
+
const url = `${HUB_URL}/auth/v1/token?grant_type=refresh_token`;
|
|
114
|
+
const res = await fetch(url, {
|
|
115
|
+
method: "POST",
|
|
116
|
+
headers: { apikey: HUB_ANON_KEY, "Content-Type": "application/json" },
|
|
117
|
+
body: JSON.stringify({ refresh_token: refreshToken }),
|
|
118
|
+
});
|
|
119
|
+
if (!res.ok) return null;
|
|
120
|
+
const data = await res.json();
|
|
121
|
+
const config = {
|
|
122
|
+
access_token: data.access_token,
|
|
123
|
+
refresh_token: data.refresh_token,
|
|
124
|
+
user_id: data.user?.id,
|
|
125
|
+
expires_at: Math.floor(Date.now() / 1000) + (data.expires_in || 3600),
|
|
126
|
+
};
|
|
127
|
+
writeHubConfig(config);
|
|
128
|
+
return config;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Login via OAuth: opens browser → captures token via localhost callback.
|
|
133
|
+
*/
|
|
134
|
+
export async function loginWithBrowser(provider = "github") {
|
|
135
|
+
return new Promise((resolve, reject) => {
|
|
136
|
+
const server = http.createServer();
|
|
137
|
+
server.listen(0, "127.0.0.1", () => {
|
|
138
|
+
const port = server.address().port;
|
|
139
|
+
const redirectTo = `http://127.0.0.1:${port}/callback`;
|
|
140
|
+
|
|
141
|
+
// The callback page needs to extract hash fragment and send to server
|
|
142
|
+
server.on("request", (req, res) => {
|
|
143
|
+
if (req.url === "/callback" || req.url?.startsWith("/callback?")) {
|
|
144
|
+
// Supabase returns tokens in hash fragment (#access_token=...)
|
|
145
|
+
// Serve a page that extracts hash and POSTs it
|
|
146
|
+
res.writeHead(200, { "Content-Type": "text/html" });
|
|
147
|
+
res.end(`<!DOCTYPE html><html><body>
|
|
148
|
+
<script>
|
|
149
|
+
const h = window.location.hash.substring(1);
|
|
150
|
+
if (h) {
|
|
151
|
+
fetch('/token', { method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify(Object.fromEntries(new URLSearchParams(h))) })
|
|
152
|
+
.then(() => { document.body.innerHTML = '<h2>Login successful! You can close this tab.</h2>'; });
|
|
153
|
+
} else {
|
|
154
|
+
document.body.innerHTML = '<h2>Login failed. No token received.</h2>';
|
|
155
|
+
}
|
|
156
|
+
</script>
|
|
157
|
+
<p>Processing login...</p></body></html>`);
|
|
158
|
+
return;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
if (req.url === "/token" && req.method === "POST") {
|
|
162
|
+
let body = "";
|
|
163
|
+
req.on("data", (c) => (body += c));
|
|
164
|
+
req.on("end", () => {
|
|
165
|
+
res.writeHead(200);
|
|
166
|
+
res.end("ok");
|
|
167
|
+
server.close();
|
|
168
|
+
try {
|
|
169
|
+
const data = JSON.parse(body);
|
|
170
|
+
if (!data.access_token) {
|
|
171
|
+
reject(new Error("No access_token in response"));
|
|
172
|
+
return;
|
|
173
|
+
}
|
|
174
|
+
const config = {
|
|
175
|
+
access_token: data.access_token,
|
|
176
|
+
refresh_token: data.refresh_token || null,
|
|
177
|
+
user_id: null,
|
|
178
|
+
expires_at: data.expires_in
|
|
179
|
+
? Math.floor(Date.now() / 1000) + parseInt(data.expires_in, 10)
|
|
180
|
+
: null,
|
|
181
|
+
};
|
|
182
|
+
writeHubConfig(config);
|
|
183
|
+
resolve(config);
|
|
184
|
+
} catch (e) {
|
|
185
|
+
reject(e);
|
|
186
|
+
}
|
|
187
|
+
});
|
|
188
|
+
return;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
res.writeHead(404);
|
|
192
|
+
res.end("Not found");
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
// Open browser to Supabase OAuth
|
|
196
|
+
const authUrl =
|
|
197
|
+
`${HUB_URL}/auth/v1/authorize?provider=${provider}&redirect_to=${encodeURIComponent(redirectTo)}`;
|
|
198
|
+
|
|
199
|
+
log.info("Opening browser for " + provider + " login...");
|
|
200
|
+
openBrowser(authUrl);
|
|
201
|
+
|
|
202
|
+
// Timeout after 120 seconds
|
|
203
|
+
setTimeout(() => {
|
|
204
|
+
server.close();
|
|
205
|
+
reject(new Error("Login timed out (120s). Try again."));
|
|
206
|
+
}, 120_000);
|
|
207
|
+
});
|
|
208
|
+
});
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
function openBrowser(url) {
|
|
212
|
+
if (process.platform === "darwin") {
|
|
213
|
+
spawn("open", [url], { detached: true, stdio: "ignore" }).unref();
|
|
214
|
+
} else if (process.platform === "win32") {
|
|
215
|
+
spawn("cmd", ["/c", "start", "", url], { detached: true, stdio: "ignore" }).unref();
|
|
216
|
+
} else {
|
|
217
|
+
spawn("xdg-open", [url], { detached: true, stdio: "ignore" }).unref();
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
// ──── Upload (publish) ────
|
|
222
|
+
|
|
223
|
+
export async function uploadToStorage(accessToken, fileKey, buffer, contentType) {
|
|
224
|
+
const url = `${HUB_URL}/storage/v1/object/flows/${fileKey}`;
|
|
225
|
+
const res = await fetch(url, {
|
|
226
|
+
method: "POST",
|
|
227
|
+
headers: {
|
|
228
|
+
apikey: HUB_ANON_KEY,
|
|
229
|
+
Authorization: "Bearer " + accessToken,
|
|
230
|
+
"Content-Type": contentType,
|
|
231
|
+
"x-upsert": "true",
|
|
232
|
+
},
|
|
233
|
+
body: buffer,
|
|
234
|
+
});
|
|
235
|
+
if (!res.ok) {
|
|
236
|
+
const text = await res.text();
|
|
237
|
+
throw new Error("Storage upload failed: " + res.status + " " + text);
|
|
238
|
+
}
|
|
239
|
+
return res.json();
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
export async function insertFlow(accessToken, flowData) {
|
|
243
|
+
const url = `${HUB_URL}/rest/v1/flows`;
|
|
244
|
+
const res = await fetch(url, {
|
|
245
|
+
method: "POST",
|
|
246
|
+
headers: { ...supabaseHeaders(accessToken), Prefer: "return=representation" },
|
|
247
|
+
body: JSON.stringify(flowData),
|
|
248
|
+
});
|
|
249
|
+
if (!res.ok) {
|
|
250
|
+
const text = await res.text();
|
|
251
|
+
throw new Error("Insert failed: " + res.status + " " + text);
|
|
252
|
+
}
|
|
253
|
+
return res.json();
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
export async function findFlowByAuthorAndTitle(accessToken, authorId, title) {
|
|
257
|
+
const url = `${HUB_URL}/rest/v1/flows?select=*&author_id=eq.${encodeURIComponent(authorId)}&title=eq.${encodeURIComponent(title)}&limit=1`;
|
|
258
|
+
const res = await fetch(url, { headers: supabaseHeaders(accessToken) });
|
|
259
|
+
if (!res.ok) return null;
|
|
260
|
+
const data = await res.json();
|
|
261
|
+
return data[0] || null;
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
export async function updateFlow(accessToken, flowId, patch) {
|
|
265
|
+
const url = `${HUB_URL}/rest/v1/flows?id=eq.${encodeURIComponent(flowId)}`;
|
|
266
|
+
const res = await fetch(url, {
|
|
267
|
+
method: "PATCH",
|
|
268
|
+
headers: { ...supabaseHeaders(accessToken), Prefer: "return=representation" },
|
|
269
|
+
body: JSON.stringify(patch),
|
|
270
|
+
});
|
|
271
|
+
if (!res.ok) {
|
|
272
|
+
const text = await res.text();
|
|
273
|
+
throw new Error("Update failed: " + res.status + " " + text);
|
|
274
|
+
}
|
|
275
|
+
return res.json();
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
export async function deleteStorageObject(accessToken, fileKey) {
|
|
279
|
+
const url = `${HUB_URL}/storage/v1/object/flows/${fileKey}`;
|
|
280
|
+
const res = await fetch(url, {
|
|
281
|
+
method: "DELETE",
|
|
282
|
+
headers: {
|
|
283
|
+
apikey: HUB_ANON_KEY,
|
|
284
|
+
Authorization: "Bearer " + accessToken,
|
|
285
|
+
},
|
|
286
|
+
});
|
|
287
|
+
return res.ok;
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
export async function getUserProfile(accessToken) {
|
|
291
|
+
const url = `${HUB_URL}/auth/v1/user`;
|
|
292
|
+
const res = await fetch(url, {
|
|
293
|
+
headers: { apikey: HUB_ANON_KEY, Authorization: "Bearer " + accessToken },
|
|
294
|
+
});
|
|
295
|
+
if (!res.ok) return null;
|
|
296
|
+
return res.json();
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
export { HUB_URL };
|