@limecloud/agent-app-studio 0.1.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.
- package/LICENSE +201 -0
- package/README.md +21 -0
- package/app/app.js +27 -0
- package/app/index.html +41 -0
- package/app/styles.css +129 -0
- package/bin/lime-agent-app-studio.mjs +7 -0
- package/docs/v1/README.md +451 -0
- package/package.json +35 -0
- package/src/cli.mjs +91 -0
- package/src/core/api.mjs +68 -0
- package/src/core/args.mjs +40 -0
- package/src/core/config.mjs +44 -0
- package/src/core/packager.mjs +107 -0
- package/src/core/project.mjs +106 -0
- package/src/core/publisher.mjs +62 -0
- package/src/server.mjs +70 -0
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
// input: 环境变量、本机配置文件与 CLI 显式参数
|
|
2
|
+
// output: Studio API base、tenantId 与 token
|
|
3
|
+
|
|
4
|
+
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
|
5
|
+
import { join } from "node:path";
|
|
6
|
+
import { homedir } from "node:os";
|
|
7
|
+
|
|
8
|
+
const defaultConfigDir = join(homedir(), ".lime", "agent-app-studio");
|
|
9
|
+
const defaultConfigPath = join(defaultConfigDir, "config.json");
|
|
10
|
+
|
|
11
|
+
export function resolveApiBase(options = {}) {
|
|
12
|
+
return trimTrailingSlash(
|
|
13
|
+
options.apiBase || process.env.LIMECORE_API_BASE_URL || "https://api.limecloud.run/api"
|
|
14
|
+
);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export async function loadStudioConfig() {
|
|
18
|
+
try {
|
|
19
|
+
return JSON.parse(await readFile(defaultConfigPath, "utf8"));
|
|
20
|
+
} catch (error) {
|
|
21
|
+
return {};
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export async function saveStudioConfig(nextConfig) {
|
|
26
|
+
const current = await loadStudioConfig();
|
|
27
|
+
const merged = { ...current, ...nextConfig };
|
|
28
|
+
await mkdir(defaultConfigDir, { recursive: true });
|
|
29
|
+
await writeFile(defaultConfigPath, `${JSON.stringify(merged, null, 2)}\n`);
|
|
30
|
+
return merged;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export async function resolveAuthContext(options = {}) {
|
|
34
|
+
const config = await loadStudioConfig();
|
|
35
|
+
return {
|
|
36
|
+
apiBase: resolveApiBase(options.apiBase ? options : config),
|
|
37
|
+
tenantId: options.tenantId || process.env.LIMECORE_TENANT_ID || config.tenantId || "",
|
|
38
|
+
token: options.token || process.env.LIME_AGENT_APP_STUDIO_TOKEN || config.token || "",
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function trimTrailingSlash(value) {
|
|
43
|
+
return String(value || "").replace(/\/+$/, "");
|
|
44
|
+
}
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
// input: 本地 Agent App 目录与输出目录
|
|
2
|
+
// output: .lapp 安装包、sha256 与 manifest hash
|
|
3
|
+
|
|
4
|
+
import { createHash } from "node:crypto";
|
|
5
|
+
import { createReadStream, createWriteStream } from "node:fs";
|
|
6
|
+
import { mkdir, readdir, readFile, stat } from "node:fs/promises";
|
|
7
|
+
import { basename, join, relative, resolve } from "node:path";
|
|
8
|
+
import yazl from "yazl";
|
|
9
|
+
import { inspectProject } from "./project.mjs";
|
|
10
|
+
|
|
11
|
+
const defaultExcludes = new Set([
|
|
12
|
+
".git",
|
|
13
|
+
"node_modules",
|
|
14
|
+
".DS_Store",
|
|
15
|
+
".lime",
|
|
16
|
+
".local",
|
|
17
|
+
"coverage",
|
|
18
|
+
]);
|
|
19
|
+
|
|
20
|
+
export async function packageProject(options = {}) {
|
|
21
|
+
const appDir = resolve(options.appDir || ".");
|
|
22
|
+
const inspection = await inspectProject(appDir);
|
|
23
|
+
if (!inspection.publishable) {
|
|
24
|
+
throw new Error(`项目不可发布:${inspection.issues.join(";")}`);
|
|
25
|
+
}
|
|
26
|
+
const outDir = resolve(options.outDir || join(appDir, "dist-package"));
|
|
27
|
+
await mkdir(outDir, { recursive: true });
|
|
28
|
+
const packageName = `${inspection.appId}-${inspection.version}.lapp`;
|
|
29
|
+
const packagePath = join(outDir, packageName);
|
|
30
|
+
const files = await collectPackageFiles(appDir);
|
|
31
|
+
await writeZip(appDir, files, packagePath);
|
|
32
|
+
|
|
33
|
+
const packageHash = await sha256File(packagePath);
|
|
34
|
+
const manifestPath = await resolveManifestPath(appDir);
|
|
35
|
+
const manifestHash = manifestPath ? await sha256File(manifestPath) : "";
|
|
36
|
+
return {
|
|
37
|
+
...inspection,
|
|
38
|
+
packagePath,
|
|
39
|
+
packageName,
|
|
40
|
+
packageHash,
|
|
41
|
+
manifestHash,
|
|
42
|
+
sizeBytes: (await stat(packagePath)).size,
|
|
43
|
+
fileCount: files.length,
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export async function collectPackageFiles(appDir) {
|
|
48
|
+
const root = resolve(appDir);
|
|
49
|
+
const result = [];
|
|
50
|
+
await walk(root, root, result);
|
|
51
|
+
result.sort((a, b) => a.localeCompare(b));
|
|
52
|
+
return result;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
async function walk(root, current, result) {
|
|
56
|
+
const entries = await readdir(current, { withFileTypes: true });
|
|
57
|
+
for (const entry of entries) {
|
|
58
|
+
if (defaultExcludes.has(entry.name)) continue;
|
|
59
|
+
if (entry.name === "dist-package") continue;
|
|
60
|
+
const fullPath = join(current, entry.name);
|
|
61
|
+
const rel = relative(root, fullPath).replace(/\\/g, "/");
|
|
62
|
+
if (entry.isDirectory()) {
|
|
63
|
+
await walk(root, fullPath, result);
|
|
64
|
+
continue;
|
|
65
|
+
}
|
|
66
|
+
if (entry.isFile()) result.push(rel);
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function writeZip(root, files, packagePath) {
|
|
71
|
+
return new Promise((resolvePromise, reject) => {
|
|
72
|
+
const zip = new yazl.ZipFile();
|
|
73
|
+
const output = createWriteStream(packagePath);
|
|
74
|
+
output.on("close", resolvePromise);
|
|
75
|
+
output.on("error", reject);
|
|
76
|
+
zip.outputStream.on("error", reject);
|
|
77
|
+
zip.outputStream.pipe(output);
|
|
78
|
+
for (const file of files) {
|
|
79
|
+
zip.addFile(join(root, file), file);
|
|
80
|
+
}
|
|
81
|
+
zip.end();
|
|
82
|
+
});
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
async function sha256File(path) {
|
|
86
|
+
return new Promise((resolvePromise, reject) => {
|
|
87
|
+
const hash = createHash("sha256");
|
|
88
|
+
const stream = createReadStream(path);
|
|
89
|
+
stream.on("data", (chunk) => hash.update(chunk));
|
|
90
|
+
stream.on("error", reject);
|
|
91
|
+
stream.on("end", () => resolvePromise(`sha256:${hash.digest("hex")}`));
|
|
92
|
+
});
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
async function resolveManifestPath(appDir) {
|
|
96
|
+
const candidates = ["APP.md", "app.manifest.json", "app.manifest.yaml"];
|
|
97
|
+
for (const candidate of candidates) {
|
|
98
|
+
const target = join(appDir, candidate);
|
|
99
|
+
try {
|
|
100
|
+
await readFile(target);
|
|
101
|
+
return target;
|
|
102
|
+
} catch (error) {
|
|
103
|
+
// 继续尝试下一个标准清单文件。
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
return "";
|
|
107
|
+
}
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
// input: 本地 Agent App 目录
|
|
2
|
+
// output: appId、版本、manifest、构建产物与发布前诊断
|
|
3
|
+
|
|
4
|
+
import { access, readFile, stat } from "node:fs/promises";
|
|
5
|
+
import { join, resolve } from "node:path";
|
|
6
|
+
|
|
7
|
+
export async function inspectProject(appDirInput = ".") {
|
|
8
|
+
const appDir = resolve(appDirInput);
|
|
9
|
+
const packageJson = await readOptionalJson(join(appDir, "package.json"));
|
|
10
|
+
const appMd = await readOptionalText(join(appDir, "APP.md"));
|
|
11
|
+
const capabilityYaml = await readOptionalText(join(appDir, "app.capabilities.yaml"));
|
|
12
|
+
const distStatus = await pathStatus(join(appDir, "dist"));
|
|
13
|
+
const distUiStatus = await pathStatus(join(appDir, "dist", "ui"));
|
|
14
|
+
const vendorStatus = await pathStatus(join(appDir, "dist", "ui", "vendor"));
|
|
15
|
+
|
|
16
|
+
const manifest = extractFrontmatter(appMd) || {};
|
|
17
|
+
const appId = firstString(
|
|
18
|
+
manifest.id,
|
|
19
|
+
manifest.appId,
|
|
20
|
+
manifest.name,
|
|
21
|
+
packageJson?.name
|
|
22
|
+
);
|
|
23
|
+
const version = firstString(manifest.version, packageJson?.version);
|
|
24
|
+
const manifestVersion = firstString(manifest.manifestVersion, manifest.standardVersion);
|
|
25
|
+
const issues = [];
|
|
26
|
+
const warnings = [];
|
|
27
|
+
|
|
28
|
+
if (!appMd) issues.push("缺少 APP.md");
|
|
29
|
+
if (!capabilityYaml) warnings.push("缺少 app.capabilities.yaml,云端入口可能不完整");
|
|
30
|
+
if (!appId) issues.push("无法识别 appId / name");
|
|
31
|
+
if (!version) issues.push("无法识别版本号");
|
|
32
|
+
if (!distStatus.exists) warnings.push("缺少 dist/,发布前通常需要先执行构建");
|
|
33
|
+
if (!distUiStatus.exists) warnings.push("缺少 dist/ui,UI App 可能无法运行");
|
|
34
|
+
if (distUiStatus.exists && !vendorStatus.exists) {
|
|
35
|
+
warnings.push("缺少 dist/ui/vendor;如果 UI import map 依赖 vendor,发布包会不可运行");
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
return {
|
|
39
|
+
appDir,
|
|
40
|
+
appId,
|
|
41
|
+
version,
|
|
42
|
+
manifestVersion,
|
|
43
|
+
packageName: packageJson?.name || "",
|
|
44
|
+
packageVersion: packageJson?.version || "",
|
|
45
|
+
hasBuildScript: Boolean(packageJson?.scripts?.build),
|
|
46
|
+
hasValidateScript: Boolean(packageJson?.scripts?.["validate:app"]),
|
|
47
|
+
files: {
|
|
48
|
+
appMd: Boolean(appMd),
|
|
49
|
+
capabilities: Boolean(capabilityYaml),
|
|
50
|
+
dist: distStatus.exists,
|
|
51
|
+
distUi: distUiStatus.exists,
|
|
52
|
+
vendor: vendorStatus.exists,
|
|
53
|
+
},
|
|
54
|
+
publishable: issues.length === 0,
|
|
55
|
+
issues,
|
|
56
|
+
warnings,
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
async function readOptionalText(path) {
|
|
61
|
+
try {
|
|
62
|
+
return await readFile(path, "utf8");
|
|
63
|
+
} catch (error) {
|
|
64
|
+
return "";
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
async function readOptionalJson(path) {
|
|
69
|
+
try {
|
|
70
|
+
return JSON.parse(await readFile(path, "utf8"));
|
|
71
|
+
} catch (error) {
|
|
72
|
+
return null;
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
async function pathStatus(path) {
|
|
77
|
+
try {
|
|
78
|
+
const item = await stat(path);
|
|
79
|
+
return { exists: true, directory: item.isDirectory(), file: item.isFile() };
|
|
80
|
+
} catch (error) {
|
|
81
|
+
return { exists: false, directory: false, file: false };
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function extractFrontmatter(markdown) {
|
|
86
|
+
const match = String(markdown || "").match(/^---\n([\s\S]*?)\n---/);
|
|
87
|
+
if (!match) return null;
|
|
88
|
+
const result = {};
|
|
89
|
+
for (const line of match[1].split(/\r?\n/)) {
|
|
90
|
+
const kv = line.match(/^([A-Za-z0-9_.-]+):\s*(.+?)\s*$/);
|
|
91
|
+
if (!kv) continue;
|
|
92
|
+
result[kv[1]] = kv[2].replace(/^['"]|['"]$/g, "");
|
|
93
|
+
}
|
|
94
|
+
return result;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function firstString(...values) {
|
|
98
|
+
for (const value of values) {
|
|
99
|
+
if (typeof value === "string" && value.trim()) return value.trim();
|
|
100
|
+
}
|
|
101
|
+
return "";
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
export async function ensureReadableAppDir(appDir) {
|
|
105
|
+
await access(resolve(appDir));
|
|
106
|
+
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
// input: 本地项目、认证上下文与发布参数
|
|
2
|
+
// output: dry-run 计划或真实云端 release 结果
|
|
3
|
+
|
|
4
|
+
import { packageProject } from "./packager.mjs";
|
|
5
|
+
import { inspectProject } from "./project.mjs";
|
|
6
|
+
import {
|
|
7
|
+
createDeveloperAgentAppRelease,
|
|
8
|
+
getDeveloperProfile,
|
|
9
|
+
uploadDeveloperAgentAppPackage,
|
|
10
|
+
} from "./api.mjs";
|
|
11
|
+
|
|
12
|
+
export async function buildPublishPlan(options = {}) {
|
|
13
|
+
const inspection = await inspectProject(options.appDir || ".");
|
|
14
|
+
const appId = options.appId || inspection.appId;
|
|
15
|
+
const channel = options.channel || "beta";
|
|
16
|
+
return {
|
|
17
|
+
appDir: inspection.appDir,
|
|
18
|
+
appId,
|
|
19
|
+
version: options.version || inspection.version,
|
|
20
|
+
manifestVersion: inspection.manifestVersion || "0.6.0",
|
|
21
|
+
channel,
|
|
22
|
+
publishable: inspection.publishable && Boolean(appId),
|
|
23
|
+
issues: inspection.issues,
|
|
24
|
+
warnings: inspection.warnings,
|
|
25
|
+
apiBase: options.apiBase,
|
|
26
|
+
tenantId: options.tenantId,
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export async function publishProject(options = {}) {
|
|
31
|
+
const plan = await buildPublishPlan(options);
|
|
32
|
+
if (!plan.publishable) {
|
|
33
|
+
throw new Error(`发布计划不可执行:${plan.issues.join(";") || "缺少 appId"}`);
|
|
34
|
+
}
|
|
35
|
+
if (options.dryRun || !options.publish) {
|
|
36
|
+
return { mode: "dry-run", plan };
|
|
37
|
+
}
|
|
38
|
+
if (!options.token) throw new Error("缺少开发者 token,请先执行 auth login 或设置 LIME_AGENT_APP_STUDIO_TOKEN");
|
|
39
|
+
if (!options.tenantId) throw new Error("缺少 tenantId,请传入 --tenant-id 或设置 LIMECORE_TENANT_ID");
|
|
40
|
+
|
|
41
|
+
const profile = await getDeveloperProfile(options);
|
|
42
|
+
if (profile.status !== "approved") {
|
|
43
|
+
throw new Error(`当前账号未完成开发者认证:${profile.status}`);
|
|
44
|
+
}
|
|
45
|
+
const packaged = await packageProject({ appDir: options.appDir, outDir: options.outDir });
|
|
46
|
+
const upload = await uploadDeveloperAgentAppPackage({ ...options, appId: plan.appId, packagePath: packaged.packagePath });
|
|
47
|
+
const releasePayload = {
|
|
48
|
+
version: options.version || upload.version || packaged.version,
|
|
49
|
+
manifestVersion: upload.manifestVersion || packaged.manifestVersion || "0.6.0",
|
|
50
|
+
channel: plan.channel,
|
|
51
|
+
packageUrl: upload.packageUrl,
|
|
52
|
+
packageHash: upload.packageHash,
|
|
53
|
+
manifestHash: upload.manifestHash || packaged.manifestHash,
|
|
54
|
+
signatureRef: upload.signatureRef,
|
|
55
|
+
runtimeTargets: upload.runtimeTargets,
|
|
56
|
+
capabilityRequirements: upload.capabilityRequirements || {},
|
|
57
|
+
manifestSummary: upload.manifestSummary || {},
|
|
58
|
+
status: options.status || "ready",
|
|
59
|
+
};
|
|
60
|
+
const release = await createDeveloperAgentAppRelease({ ...options, appId: plan.appId, payload: releasePayload });
|
|
61
|
+
return { mode: "publish", plan, profile, packaged, upload, release };
|
|
62
|
+
}
|
package/src/server.mjs
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
// input: 本地 HTTP 请求
|
|
2
|
+
// output: 可视化 Studio 工作台与发布 API
|
|
3
|
+
|
|
4
|
+
import { createServer } from "node:http";
|
|
5
|
+
import { readFile } from "node:fs/promises";
|
|
6
|
+
import { extname, join } from "node:path";
|
|
7
|
+
import { fileURLToPath } from "node:url";
|
|
8
|
+
import { inspectProject } from "./core/project.mjs";
|
|
9
|
+
import { publishProject } from "./core/publisher.mjs";
|
|
10
|
+
import { resolveAuthContext } from "./core/config.mjs";
|
|
11
|
+
|
|
12
|
+
const root = fileURLToPath(new URL("..", import.meta.url));
|
|
13
|
+
const appRoot = join(root, "app");
|
|
14
|
+
|
|
15
|
+
export async function startStudioServer(options = {}) {
|
|
16
|
+
const port = Number(options.port || 4177);
|
|
17
|
+
const server = createServer(async (req, res) => {
|
|
18
|
+
try {
|
|
19
|
+
if (req.method === "POST" && req.url === "/api/inspect") {
|
|
20
|
+
const body = await readJson(req);
|
|
21
|
+
return sendJson(res, await inspectProject(body.appDir || "."));
|
|
22
|
+
}
|
|
23
|
+
if (req.method === "POST" && req.url === "/api/publish") {
|
|
24
|
+
const body = await readJson(req);
|
|
25
|
+
const auth = await resolveAuthContext(body);
|
|
26
|
+
return sendJson(res, await publishProject({ ...body, ...auth }));
|
|
27
|
+
}
|
|
28
|
+
return serveStatic(req, res);
|
|
29
|
+
} catch (error) {
|
|
30
|
+
return sendJson(res, { error: error?.message || String(error) }, 500);
|
|
31
|
+
}
|
|
32
|
+
});
|
|
33
|
+
await new Promise((resolve) => server.listen(port, resolve));
|
|
34
|
+
return { server, url: `http://127.0.0.1:${port}` };
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
async function serveStatic(req, res) {
|
|
38
|
+
const pathname = req.url === "/" ? "/index.html" : req.url.split("?")[0];
|
|
39
|
+
const safePath = pathname.replace(/\.\./g, "");
|
|
40
|
+
const filePath = join(appRoot, safePath);
|
|
41
|
+
const content = await readFile(filePath);
|
|
42
|
+
const type = contentType(filePath);
|
|
43
|
+
res.writeHead(200, { "Content-Type": type });
|
|
44
|
+
res.end(content);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
async function readJson(req) {
|
|
48
|
+
const chunks = [];
|
|
49
|
+
for await (const chunk of req) chunks.push(chunk);
|
|
50
|
+
if (chunks.length === 0) return {};
|
|
51
|
+
return JSON.parse(Buffer.concat(chunks).toString("utf8"));
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function sendJson(res, payload, status = 200) {
|
|
55
|
+
res.writeHead(status, { "Content-Type": "application/json" });
|
|
56
|
+
res.end(JSON.stringify(payload, null, 2));
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function contentType(path) {
|
|
60
|
+
switch (extname(path)) {
|
|
61
|
+
case ".js":
|
|
62
|
+
return "text/javascript; charset=utf-8";
|
|
63
|
+
case ".css":
|
|
64
|
+
return "text/css; charset=utf-8";
|
|
65
|
+
case ".html":
|
|
66
|
+
return "text/html; charset=utf-8";
|
|
67
|
+
default:
|
|
68
|
+
return "application/octet-stream";
|
|
69
|
+
}
|
|
70
|
+
}
|