@77-zhike/app-sdk 0.2.0-d7b1.2
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/package.json +57 -0
- package/src/bin.js +15 -0
- package/src/cli/api-client.js +245 -0
- package/src/cli/browser-auth.js +212 -0
- package/src/cli/credential-store.js +101 -0
- package/src/cli/development-app.js +77 -0
- package/src/cli/development-logs.js +180 -0
- package/src/cli/development-watch.js +205 -0
- package/src/cli/index.js +253 -0
- package/src/cli/local-commands.js +78 -0
- package/src/cli/project-init.js +371 -0
- package/src/cli/upload-bundle.js +73 -0
- package/src/cli/validate-bundle.js +36 -0
- package/src/client/index.d.ts +52 -0
- package/src/client/index.js +22 -0
- package/src/client/interactions.d.ts +8 -0
- package/src/client/interactions.js +23 -0
- package/src/client/points.generated.js +19 -0
- package/src/config.js +64 -0
- package/src/development-workspace.js +154 -0
- package/src/errors.js +20 -0
- package/src/forms/components.generated.js +4 -0
- package/src/forms/factory.js +27 -0
- package/src/forms/index.d.ts +54 -0
- package/src/forms/index.js +3 -0
- package/src/forms/normalize-options.js +19 -0
- package/src/forms/options.d.ts +25 -0
- package/src/forms/options.js +48 -0
- package/src/forms/schema.d.ts +18 -0
- package/src/index.d.ts +13 -0
- package/src/index.js +2 -0
- package/src/project-binding.js +84 -0
- package/src/server/index.d.ts +268 -0
- package/src/server/index.js +201 -0
- package/src/toolchain/canonical-json.js +17 -0
- package/src/toolchain/index.js +103 -0
- package/src/toolchain/real-build.js +371 -0
- package/src/toolchain/source-discovery.js +710 -0
- package/src/toolchain/transport-tar.js +168 -0
- package/src/toolchain/webhook-response.js +50 -0
- package/src/ui/components.generated.js +2 -0
- package/src/ui/factory.js +19 -0
- package/src/ui/icons.generated.js +2 -0
- package/src/ui/index.d.ts +111 -0
- package/src/ui/index.js +4 -0
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import { readFile } from "node:fs/promises";
|
|
2
|
+
import { createRequire } from "node:module";
|
|
3
|
+
import { resolve } from "node:path";
|
|
4
|
+
|
|
5
|
+
import { loadCliConfig } from "../config.js";
|
|
6
|
+
import { ZhikeCliError } from "../errors.js";
|
|
7
|
+
import { readProjectBinding } from "../project-binding.js";
|
|
8
|
+
|
|
9
|
+
const metadata = JSON.parse(await readFile(new URL("../../package.json", import.meta.url), "utf8"));
|
|
10
|
+
export const commandHelp = {
|
|
11
|
+
help: "help [command] [--json]",
|
|
12
|
+
version: "version [--json]",
|
|
13
|
+
doctor: "doctor [--json]",
|
|
14
|
+
login: "login",
|
|
15
|
+
init: "init <app-slug>",
|
|
16
|
+
build: "build",
|
|
17
|
+
upload: "upload",
|
|
18
|
+
dev: "dev [--workspace <workspace-id>]",
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
export function localEnvelope(data, status = "completed") {
|
|
22
|
+
return { schemaVersion: 1, status, target: null, data,
|
|
23
|
+
effects: { business: "none", authentication: "none" }, error: null, nextAction: null };
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** 本地诊断不调用认证、网络或项目脚本;依赖仅解析路径,不执行包代码。 */
|
|
27
|
+
export async function runLocalCommand(command, args, { cwd, environment }) {
|
|
28
|
+
if (command === "help") {
|
|
29
|
+
if (args.length > 1 || (args.length && !Object.hasOwn(commandHelp, args[0]))) {
|
|
30
|
+
throw new ZhikeCliError("zhike_help_usage_invalid", "用法:help [command] [--json]");
|
|
31
|
+
}
|
|
32
|
+
return localEnvelope({ commands: args.length ? { [args[0]]: commandHelp[args[0]] } : commandHelp,
|
|
33
|
+
jsonCommands: ["help", "version", "doctor"] });
|
|
34
|
+
}
|
|
35
|
+
if (args.length) throw new ZhikeCliError("zhike_arguments_invalid", "当前命令不接受额外参数");
|
|
36
|
+
if (command === "version") return localEnvelope({ package: metadata.name, version: metadata.version,
|
|
37
|
+
node: process.version, requiredNode: metadata.engines.node });
|
|
38
|
+
|
|
39
|
+
const checks = [];
|
|
40
|
+
checks.push({ name: "node", ok: Number(process.versions.node.split(".")[0]) >= 20,
|
|
41
|
+
required: metadata.engines.node, actual: process.version });
|
|
42
|
+
let config;
|
|
43
|
+
try {
|
|
44
|
+
config = loadCliConfig(environment);
|
|
45
|
+
checks.push({ name: "environment", ok: true, source: "configuration", ...config });
|
|
46
|
+
} catch (error) {
|
|
47
|
+
checks.push({ name: "environment", ok: false, code: error.code });
|
|
48
|
+
}
|
|
49
|
+
let binding;
|
|
50
|
+
try {
|
|
51
|
+
binding = await readProjectBinding(cwd, config?.apiBaseUrl);
|
|
52
|
+
checks.push({ name: "binding", ok: Boolean(config), appId: binding.appId });
|
|
53
|
+
} catch (error) {
|
|
54
|
+
checks.push({ name: "binding", ok: false, code: error.code });
|
|
55
|
+
}
|
|
56
|
+
try {
|
|
57
|
+
const pkg = JSON.parse(await readFile(resolve(cwd, "package.json"), "utf8"));
|
|
58
|
+
const require = createRequire(resolve(cwd, "package.json"));
|
|
59
|
+
const dependencies = { ...pkg.dependencies, ...pkg.devDependencies };
|
|
60
|
+
const sdkDeclared = Boolean(dependencies["@77-zhike/app-sdk"]);
|
|
61
|
+
checks.push({ name: "sdkDeclaration", ok: sdkDeclared });
|
|
62
|
+
for (const name of Object.keys(dependencies)) {
|
|
63
|
+
try {
|
|
64
|
+
require.resolve(name);
|
|
65
|
+
checks.push({ name: "dependency", package: name, ok: true });
|
|
66
|
+
} catch {
|
|
67
|
+
checks.push({ name: "dependency", package: name, ok: false, code: "dependency_unresolved" });
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
} catch {
|
|
71
|
+
checks.push({ name: "package", ok: false, code: "package_missing_or_invalid" });
|
|
72
|
+
}
|
|
73
|
+
checks.push({ name: "secureStore", ok: process.platform === "darwin", platform: process.platform });
|
|
74
|
+
const result = localEnvelope({ localReady: checks.every((check) => check.ok),
|
|
75
|
+
authentication: "not_checked", installation: "not_checked", checks });
|
|
76
|
+
result.target = config ? { environment: config.apiBaseUrl, appId: binding?.appId ?? null } : null;
|
|
77
|
+
return result;
|
|
78
|
+
}
|
|
@@ -0,0 +1,371 @@
|
|
|
1
|
+
import { createHash, randomBytes } from "node:crypto";
|
|
2
|
+
import {
|
|
3
|
+
access,
|
|
4
|
+
mkdir,
|
|
5
|
+
rename,
|
|
6
|
+
rm,
|
|
7
|
+
stat,
|
|
8
|
+
writeFile,
|
|
9
|
+
} from "node:fs/promises";
|
|
10
|
+
import { constants } from "node:fs";
|
|
11
|
+
import { basename, dirname, resolve } from "node:path";
|
|
12
|
+
|
|
13
|
+
import { TOOLING_VERSION } from "../config.js";
|
|
14
|
+
import { ZhikeCliError } from "../errors.js";
|
|
15
|
+
import { serializeProjectBinding } from "../project-binding.js";
|
|
16
|
+
|
|
17
|
+
export async function initializeProject({
|
|
18
|
+
targetDirectory,
|
|
19
|
+
appSlug,
|
|
20
|
+
apiBaseUrl,
|
|
21
|
+
authenticatedApi,
|
|
22
|
+
scaffold = scaffoldProject,
|
|
23
|
+
}) {
|
|
24
|
+
validateAppSlug(appSlug);
|
|
25
|
+
const target = resolve(targetDirectory);
|
|
26
|
+
await assertEmptyTarget(target);
|
|
27
|
+
|
|
28
|
+
const organizations = await authenticatedApi.listOrganizations();
|
|
29
|
+
if (!Array.isArray(organizations) || organizations.length !== 1) {
|
|
30
|
+
throw new ZhikeCliError(
|
|
31
|
+
"zhike_developer_organization_unavailable",
|
|
32
|
+
organizations?.length === 0
|
|
33
|
+
? "请先在 Developer Console 激活开发者组织"
|
|
34
|
+
: "当前账号存在多个开发者组织,D2-2 暂不支持隐式选择",
|
|
35
|
+
);
|
|
36
|
+
}
|
|
37
|
+
const organization = organizations[0];
|
|
38
|
+
let app = await authenticatedApi.getAppBySlug(organization.id, appSlug);
|
|
39
|
+
let created = false;
|
|
40
|
+
if (!app) {
|
|
41
|
+
app = await authenticatedApi.createApp(organization.id, {
|
|
42
|
+
name: displayNameFromSlug(appSlug),
|
|
43
|
+
slug: appSlug,
|
|
44
|
+
idempotencyKey: createInitIdempotencyKey({
|
|
45
|
+
apiBaseUrl,
|
|
46
|
+
organizationId: organization.id,
|
|
47
|
+
appSlug,
|
|
48
|
+
}),
|
|
49
|
+
});
|
|
50
|
+
created = true;
|
|
51
|
+
}
|
|
52
|
+
const binding = {
|
|
53
|
+
schemaVersion: 1,
|
|
54
|
+
platformEnvironment: apiBaseUrl,
|
|
55
|
+
appId: app.id,
|
|
56
|
+
organizationId: organization.id,
|
|
57
|
+
appSlug: app.slug,
|
|
58
|
+
};
|
|
59
|
+
await scaffold({ targetDirectory: target, binding });
|
|
60
|
+
return { app, organization, binding, created, targetDirectory: target };
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export async function assertEmptyTarget(targetDirectory) {
|
|
64
|
+
try {
|
|
65
|
+
await stat(targetDirectory);
|
|
66
|
+
throw new ZhikeCliError(
|
|
67
|
+
"zhike_target_exists",
|
|
68
|
+
`目标路径已存在:${basename(targetDirectory)}`,
|
|
69
|
+
);
|
|
70
|
+
} catch (error) {
|
|
71
|
+
if (error instanceof ZhikeCliError) {
|
|
72
|
+
throw error;
|
|
73
|
+
}
|
|
74
|
+
if (error?.code !== "ENOENT") {
|
|
75
|
+
throw new ZhikeCliError(
|
|
76
|
+
"zhike_target_check_failed",
|
|
77
|
+
"无法检查项目目标路径",
|
|
78
|
+
{ cause: error },
|
|
79
|
+
);
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
try {
|
|
83
|
+
await access(dirname(targetDirectory), constants.W_OK);
|
|
84
|
+
} catch (error) {
|
|
85
|
+
throw new ZhikeCliError(
|
|
86
|
+
"zhike_target_parent_not_writable",
|
|
87
|
+
"项目目标路径的父目录不可写",
|
|
88
|
+
{ cause: error },
|
|
89
|
+
);
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export async function scaffoldProject({ targetDirectory, binding }) {
|
|
94
|
+
const temporaryDirectory = `${targetDirectory}.zhike-tmp-${randomBytes(6).toString("hex")}`;
|
|
95
|
+
try {
|
|
96
|
+
await mkdir(resolve(temporaryDirectory, ".zhike"), { recursive: true });
|
|
97
|
+
await mkdir(
|
|
98
|
+
resolve(temporaryDirectory, "src", "app", "extensions", "customer-action"),
|
|
99
|
+
{ recursive: true },
|
|
100
|
+
);
|
|
101
|
+
await mkdir(resolve(temporaryDirectory, "src", "app", "webhooks"), {
|
|
102
|
+
recursive: true,
|
|
103
|
+
});
|
|
104
|
+
await mkdir(resolve(temporaryDirectory, "src", "app", "events"), {
|
|
105
|
+
recursive: true,
|
|
106
|
+
});
|
|
107
|
+
await mkdir(resolve(temporaryDirectory, "src", "server", "functions"), {
|
|
108
|
+
recursive: true,
|
|
109
|
+
});
|
|
110
|
+
await writeFile(
|
|
111
|
+
resolve(temporaryDirectory, ".zhike", "project.json"),
|
|
112
|
+
serializeProjectBinding(binding),
|
|
113
|
+
{ flag: "wx" },
|
|
114
|
+
);
|
|
115
|
+
await writeFile(
|
|
116
|
+
resolve(temporaryDirectory, "package.json"),
|
|
117
|
+
`${JSON.stringify(
|
|
118
|
+
{
|
|
119
|
+
name: binding.appSlug,
|
|
120
|
+
private: true,
|
|
121
|
+
version: "0.0.0",
|
|
122
|
+
type: "module",
|
|
123
|
+
scripts: {
|
|
124
|
+
zhike: "zhike",
|
|
125
|
+
build: "zhike build",
|
|
126
|
+
dev: "zhike dev",
|
|
127
|
+
upload: "zhike upload",
|
|
128
|
+
"validate:mock": "zhike experimental validate-bundle",
|
|
129
|
+
},
|
|
130
|
+
devDependencies: {
|
|
131
|
+
"@77-zhike/app-sdk": TOOLING_VERSION,
|
|
132
|
+
},
|
|
133
|
+
},
|
|
134
|
+
null,
|
|
135
|
+
2,
|
|
136
|
+
)}\n`,
|
|
137
|
+
{ flag: "wx" },
|
|
138
|
+
);
|
|
139
|
+
await writeFile(
|
|
140
|
+
resolve(
|
|
141
|
+
temporaryDirectory,
|
|
142
|
+
"src",
|
|
143
|
+
"app",
|
|
144
|
+
"events",
|
|
145
|
+
"app-installed.event.ts",
|
|
146
|
+
),
|
|
147
|
+
`import { createWebhookHandler, ZHIKE_API_TOKEN, kv } from "@77-zhike/app-sdk/server";
|
|
148
|
+
|
|
149
|
+
export default async function appInstalled() {
|
|
150
|
+
const handler = await createWebhookHandler({ fileName: "qiqi-customer" });
|
|
151
|
+
const existing = await fetch("https://api-clm.77hub.com/v1/webhooks", {
|
|
152
|
+
headers: { authorization: \`Bearer \${ZHIKE_API_TOKEN}\` },
|
|
153
|
+
});
|
|
154
|
+
if (!existing.ok) throw new Error("webhook_list_failed");
|
|
155
|
+
const { items } = await existing.json() as { items: Array<{ subscriptions: Array<{ eventType: string }> }> };
|
|
156
|
+
const requiredEventTypes = ["record.created", "activity.updated"];
|
|
157
|
+
if (items.some((item) => requiredEventTypes.every((eventType) =>
|
|
158
|
+
item.subscriptions.some((subscription) => subscription.eventType === eventType)
|
|
159
|
+
))) return;
|
|
160
|
+
|
|
161
|
+
const response = await fetch("https://api-clm.77hub.com/v1/webhooks", {
|
|
162
|
+
method: "POST",
|
|
163
|
+
headers: {
|
|
164
|
+
authorization: \`Bearer \${ZHIKE_API_TOKEN}\`,
|
|
165
|
+
"content-type": "application/json",
|
|
166
|
+
},
|
|
167
|
+
body: JSON.stringify({
|
|
168
|
+
targetUrl: handler.url,
|
|
169
|
+
subscriptions: [{
|
|
170
|
+
eventType: "record.created",
|
|
171
|
+
filter: { field: "id.objectSlug", operator: "equals", value: "accounts" },
|
|
172
|
+
}, {
|
|
173
|
+
eventType: "activity.updated",
|
|
174
|
+
filter: null,
|
|
175
|
+
}],
|
|
176
|
+
}),
|
|
177
|
+
});
|
|
178
|
+
if (!response.ok) throw new Error("webhook_create_failed");
|
|
179
|
+
const created = await response.json() as { webhook: { id: string }; secret: string };
|
|
180
|
+
await kv.set(
|
|
181
|
+
\`webhook:\${created.webhook.id}\`,
|
|
182
|
+
JSON.stringify({ webhookId: created.webhook.id, secret: created.secret }),
|
|
183
|
+
);
|
|
184
|
+
}
|
|
185
|
+
`,
|
|
186
|
+
{ flag: "wx" },
|
|
187
|
+
);
|
|
188
|
+
await writeFile(
|
|
189
|
+
resolve(
|
|
190
|
+
temporaryDirectory,
|
|
191
|
+
"src",
|
|
192
|
+
"app",
|
|
193
|
+
"events",
|
|
194
|
+
"app-uninstalled.event.ts",
|
|
195
|
+
),
|
|
196
|
+
`import { deleteWebhookHandler, listWebhookHandlers, ZHIKE_API_TOKEN, kv } from "@77-zhike/app-sdk/server";\n\nexport default async function appUninstalled() {\n const response = await fetch("https://api-clm.77hub.com/v1/webhooks", {\n headers: { authorization: \`Bearer \${ZHIKE_API_TOKEN}\` },\n });\n if (!response.ok) throw new Error("webhook_list_failed");\n const { items } = await response.json() as { items: Array<{ id: string }> };\n for (const webhook of items) {\n const deleted = await fetch(\`https://api-clm.77hub.com/v1/webhooks/\${webhook.id}\`, {\n method: "DELETE",\n headers: { authorization: \`Bearer \${ZHIKE_API_TOKEN}\` },\n });\n if (deleted.status !== 204 && deleted.status !== 404) throw new Error("webhook_delete_failed");\n await kv.delete(\`webhook:\${webhook.id}\`);\n }\n for (const handler of await listWebhookHandlers()) {\n await deleteWebhookHandler(handler.id);\n }\n}\n`,
|
|
197
|
+
{ flag: "wx" },
|
|
198
|
+
);
|
|
199
|
+
await writeFile(
|
|
200
|
+
resolve(
|
|
201
|
+
temporaryDirectory,
|
|
202
|
+
"src",
|
|
203
|
+
"app",
|
|
204
|
+
"events",
|
|
205
|
+
"connection-added.event.ts",
|
|
206
|
+
),
|
|
207
|
+
`import { kv } from "@77-zhike/app-sdk/server";\nimport type { ConnectionAddedParams } from "@77-zhike/app-sdk/server";\n\nexport default async function connectionAdded({ connection }: ConnectionAddedParams) {\n const stateKey = \`connection:\${connection.id}:\${connection.generation}:initialized\`;\n if (await kv.get(stateKey)) return;\n // 在这里调用外部 OpenAPI 创建订阅,再持久化供应商返回的引用。\n await kv.set(stateKey, "initialized");\n}\n`,
|
|
208
|
+
{ flag: "wx" },
|
|
209
|
+
);
|
|
210
|
+
await writeFile(
|
|
211
|
+
resolve(
|
|
212
|
+
temporaryDirectory,
|
|
213
|
+
"src",
|
|
214
|
+
"app",
|
|
215
|
+
"events",
|
|
216
|
+
"connection-removed.event.ts",
|
|
217
|
+
),
|
|
218
|
+
`import { kv } from "@77-zhike/app-sdk/server";\nimport type { ConnectionRemovedParams } from "@77-zhike/app-sdk/server";\n\nexport default async function connectionRemoved({ connection }: ConnectionRemovedParams) {\n const stateKey = \`connection:\${connection.id}:\${connection.generation}:initialized\`;\n // 在这里读取供应商引用并调用外部 OpenAPI 删除订阅。\n await kv.delete(stateKey);\n}\n`,
|
|
219
|
+
{ flag: "wx" },
|
|
220
|
+
);
|
|
221
|
+
await writeFile(
|
|
222
|
+
resolve(
|
|
223
|
+
temporaryDirectory,
|
|
224
|
+
"src",
|
|
225
|
+
"app",
|
|
226
|
+
"extensions",
|
|
227
|
+
"customer-action",
|
|
228
|
+
"extension.tsx",
|
|
229
|
+
),
|
|
230
|
+
`import { defineFrontendExtension } from "@77-zhike/app-sdk/client";\nimport createFollowUpTask from "../../../server/functions/create-follow-up-task.server";\n\nexport default defineFrontendExtension({\n key: "customer-action",\n type: "record-action",\n label: "创建跟进任务",\n icon: "refresh-cw",\n objects: "accounts",\n async onTrigger({ recordId }: { recordId: string }) {\n await createFollowUpTask({ customerId: recordId });\n },\n});\n`,
|
|
231
|
+
{ flag: "wx" },
|
|
232
|
+
);
|
|
233
|
+
await writeFile(
|
|
234
|
+
resolve(
|
|
235
|
+
temporaryDirectory,
|
|
236
|
+
"src",
|
|
237
|
+
"server",
|
|
238
|
+
"functions",
|
|
239
|
+
"create-follow-up-task.server.ts",
|
|
240
|
+
),
|
|
241
|
+
`import { ZHIKE_API_TOKEN } from "@77-zhike/app-sdk/server";
|
|
242
|
+
|
|
243
|
+
export default async function(input: { customerId: string }) {
|
|
244
|
+
const response = await fetch("https://api-clm.77hub.com/v1/tasks", {
|
|
245
|
+
method: "POST",
|
|
246
|
+
headers: {
|
|
247
|
+
authorization: \`Bearer \${ZHIKE_API_TOKEN}\`,
|
|
248
|
+
"content-type": "application/json",
|
|
249
|
+
},
|
|
250
|
+
body: JSON.stringify({
|
|
251
|
+
title: "跟进客户",
|
|
252
|
+
relatedRecords: [{ objectSlug: "accounts", recordId: input.customerId }],
|
|
253
|
+
}),
|
|
254
|
+
});
|
|
255
|
+
if (!response.ok) throw new Error("task_create_failed");
|
|
256
|
+
const task = await response.json() as { id: { taskId: string } };
|
|
257
|
+
return { taskId: task.id.taskId };
|
|
258
|
+
}
|
|
259
|
+
`,
|
|
260
|
+
{ flag: "wx" },
|
|
261
|
+
);
|
|
262
|
+
await writeFile(
|
|
263
|
+
resolve(
|
|
264
|
+
temporaryDirectory,
|
|
265
|
+
"src",
|
|
266
|
+
"app",
|
|
267
|
+
"webhooks",
|
|
268
|
+
"qiqi-customer.webhook.ts",
|
|
269
|
+
),
|
|
270
|
+
`import { ZHIKE_API_TOKEN, kv } from "@77-zhike/app-sdk/server";
|
|
271
|
+
import type { WorkspaceWebhookBody } from "@77-zhike/app-sdk/server";
|
|
272
|
+
|
|
273
|
+
export default async function qiqiCustomer(request: Request) {
|
|
274
|
+
const idempotencyKey = request.headers.get("idempotency-key");
|
|
275
|
+
if (!idempotencyKey) throw new Error("webhook_idempotency_key_missing");
|
|
276
|
+
if (await kv.get(\`delivery:\${idempotencyKey}\`)) {
|
|
277
|
+
return new Response(null, { status: 200 });
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
const { events } = await request.json() as WorkspaceWebhookBody;
|
|
281
|
+
for (const event of events) {
|
|
282
|
+
if (event.eventType === "activity.updated") {
|
|
283
|
+
const response = await fetch(
|
|
284
|
+
\`https://api-clm.77hub.com/v1/activities/\${encodeURIComponent(event.id.activityId)}\`,
|
|
285
|
+
{ headers: { authorization: \`Bearer \${ZHIKE_API_TOKEN}\` } },
|
|
286
|
+
);
|
|
287
|
+
if (!response.ok) throw new Error("activity_read_failed");
|
|
288
|
+
const activity = await response.json();
|
|
289
|
+
console.log("[qiqi-customer] current activity", activity);
|
|
290
|
+
continue;
|
|
291
|
+
}
|
|
292
|
+
if (event.eventType !== "record.created" || event.id.objectSlug !== "accounts") continue;
|
|
293
|
+
const response = await fetch(
|
|
294
|
+
\`https://api-clm.77hub.com/v1/objects/\${event.id.objectSlug}/records/\${event.id.recordId}\`,
|
|
295
|
+
{ headers: { authorization: \`Bearer \${ZHIKE_API_TOKEN}\` } },
|
|
296
|
+
);
|
|
297
|
+
if (!response.ok) throw new Error("record_read_failed");
|
|
298
|
+
const record = await response.json();
|
|
299
|
+
console.log("[qiqi-customer] current record values", record.values);
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
await kv.set(\`delivery:\${idempotencyKey}\`, "completed", { ttlInSeconds: 172800 });
|
|
303
|
+
return new Response(null, { status: 200 });
|
|
304
|
+
}
|
|
305
|
+
`,
|
|
306
|
+
{ flag: "wx" },
|
|
307
|
+
);
|
|
308
|
+
await writeFile(
|
|
309
|
+
resolve(temporaryDirectory, "tsconfig.json"),
|
|
310
|
+
`${JSON.stringify(
|
|
311
|
+
{
|
|
312
|
+
compilerOptions: {
|
|
313
|
+
lib: ["ES2022", "DOM"],
|
|
314
|
+
module: "ESNext",
|
|
315
|
+
moduleResolution: "Bundler",
|
|
316
|
+
noEmit: true,
|
|
317
|
+
strict: true,
|
|
318
|
+
target: "ES2022",
|
|
319
|
+
},
|
|
320
|
+
include: ["src"],
|
|
321
|
+
},
|
|
322
|
+
null,
|
|
323
|
+
2,
|
|
324
|
+
)}\n`,
|
|
325
|
+
{ flag: "wx" },
|
|
326
|
+
);
|
|
327
|
+
await writeFile(
|
|
328
|
+
resolve(temporaryDirectory, ".gitignore"),
|
|
329
|
+
"dist/\n.zhike/development.json\n",
|
|
330
|
+
{ flag: "wx" },
|
|
331
|
+
);
|
|
332
|
+
await rename(temporaryDirectory, targetDirectory);
|
|
333
|
+
} catch (error) {
|
|
334
|
+
await rm(temporaryDirectory, { recursive: true, force: true });
|
|
335
|
+
if (error instanceof ZhikeCliError) {
|
|
336
|
+
throw error;
|
|
337
|
+
}
|
|
338
|
+
throw new ZhikeCliError(
|
|
339
|
+
"zhike_scaffold_failed",
|
|
340
|
+
"App 已确认,但本地项目生成失败;修复文件系统问题后可安全重试",
|
|
341
|
+
{ cause: error, retryable: true },
|
|
342
|
+
);
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
export function createInitIdempotencyKey({
|
|
347
|
+
apiBaseUrl,
|
|
348
|
+
organizationId,
|
|
349
|
+
appSlug,
|
|
350
|
+
}) {
|
|
351
|
+
return `init-${createHash("sha256")
|
|
352
|
+
.update(`${apiBaseUrl}\n${organizationId}\n${appSlug}`, "utf8")
|
|
353
|
+
.digest("hex")}`;
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
export function displayNameFromSlug(slug) {
|
|
357
|
+
return slug
|
|
358
|
+
.split("-")
|
|
359
|
+
.map((part) => `${part[0].toUpperCase()}${part.slice(1)}`)
|
|
360
|
+
.join(" ");
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
export function validateAppSlug(value) {
|
|
364
|
+
if (!/^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(value || "") || value.length > 100) {
|
|
365
|
+
throw new ZhikeCliError(
|
|
366
|
+
"zhike_app_slug_invalid",
|
|
367
|
+
"App slug 只能包含小写字母、数字和中划线",
|
|
368
|
+
);
|
|
369
|
+
}
|
|
370
|
+
return value;
|
|
371
|
+
}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
|
|
3
|
+
import { ZhikeCliError } from "../errors.js";
|
|
4
|
+
import { readProjectBinding } from "../project-binding.js";
|
|
5
|
+
import { buildAppBundle } from "../toolchain/real-build.js";
|
|
6
|
+
|
|
7
|
+
export async function uploadAppBundle({
|
|
8
|
+
projectDirectory,
|
|
9
|
+
apiBaseUrl,
|
|
10
|
+
authenticatedApi,
|
|
11
|
+
maxAttempts = 2,
|
|
12
|
+
expectedScopeRevision,
|
|
13
|
+
}) {
|
|
14
|
+
const bundle = await buildAppBundle({
|
|
15
|
+
projectDirectory,
|
|
16
|
+
expectedEnvironment: apiBaseUrl,
|
|
17
|
+
writeToDist: true,
|
|
18
|
+
development: true,
|
|
19
|
+
});
|
|
20
|
+
const binding = await readProjectBinding(projectDirectory, apiBaseUrl);
|
|
21
|
+
const idempotencyKey = `bundle-${randomUUID()}`;
|
|
22
|
+
let attempt = 0;
|
|
23
|
+
let upload = null;
|
|
24
|
+
while (attempt < maxAttempts) {
|
|
25
|
+
attempt += 1;
|
|
26
|
+
try {
|
|
27
|
+
upload = await authenticatedApi.uploadBundle(
|
|
28
|
+
binding.appId,
|
|
29
|
+
bundle,
|
|
30
|
+
idempotencyKey,
|
|
31
|
+
);
|
|
32
|
+
break;
|
|
33
|
+
} catch (error) {
|
|
34
|
+
if (!error?.retryable || attempt >= maxAttempts) throw error;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
if (!upload || upload.status !== "accepted" || !upload.app_bundle_id) {
|
|
38
|
+
throw new ZhikeCliError(
|
|
39
|
+
"zhike_bundle_acceptance_invalid",
|
|
40
|
+
"Developer Server 未返回可激活的 App Bundle",
|
|
41
|
+
);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
let developmentManifest = null;
|
|
45
|
+
let manifestAttempts = 0;
|
|
46
|
+
while (manifestAttempts < maxAttempts) {
|
|
47
|
+
manifestAttempts += 1;
|
|
48
|
+
try {
|
|
49
|
+
developmentManifest = await authenticatedApi.updateDevelopmentManifest(
|
|
50
|
+
binding.appId, upload.app_bundle_id, expectedScopeRevision,
|
|
51
|
+
);
|
|
52
|
+
break;
|
|
53
|
+
} catch (error) {
|
|
54
|
+
if (error?.retryable && manifestAttempts < maxAttempts) continue;
|
|
55
|
+
const partial = new ZhikeCliError(
|
|
56
|
+
"zhike_bundle_accepted_manifest_update_failed",
|
|
57
|
+
`Bundle ${upload.id} 已受理,但当前 Development Manifest 更新失败:${error.message}`,
|
|
58
|
+
{ cause: error, retryable: error.retryable ?? false },
|
|
59
|
+
);
|
|
60
|
+
partial.acceptedUploadId = upload.id;
|
|
61
|
+
partial.acceptedAppBundleId = upload.app_bundle_id;
|
|
62
|
+
throw partial;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
return {
|
|
66
|
+
artifact: bundle,
|
|
67
|
+
binding,
|
|
68
|
+
upload,
|
|
69
|
+
developmentManifest,
|
|
70
|
+
attempts: attempt,
|
|
71
|
+
manifestAttempts,
|
|
72
|
+
};
|
|
73
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
|
|
3
|
+
import { readProjectBinding } from "../project-binding.js";
|
|
4
|
+
import { buildMockArtifact } from "../toolchain/index.js";
|
|
5
|
+
|
|
6
|
+
export async function validateBundleUpload({
|
|
7
|
+
projectDirectory,
|
|
8
|
+
apiBaseUrl,
|
|
9
|
+
authenticatedApi,
|
|
10
|
+
maxAttempts = 2,
|
|
11
|
+
}) {
|
|
12
|
+
const artifact = await buildMockArtifact({
|
|
13
|
+
projectDirectory,
|
|
14
|
+
expectedEnvironment: apiBaseUrl,
|
|
15
|
+
writeToDist: true,
|
|
16
|
+
});
|
|
17
|
+
const binding = await readProjectBinding(projectDirectory, apiBaseUrl);
|
|
18
|
+
const idempotencyKey = `validate-${randomUUID()}`;
|
|
19
|
+
let attempt = 0;
|
|
20
|
+
while (attempt < maxAttempts) {
|
|
21
|
+
attempt += 1;
|
|
22
|
+
try {
|
|
23
|
+
const upload = await authenticatedApi.uploadBundle(
|
|
24
|
+
binding.appId,
|
|
25
|
+
artifact,
|
|
26
|
+
idempotencyKey,
|
|
27
|
+
);
|
|
28
|
+
return { artifact, binding, upload, attempts: attempt };
|
|
29
|
+
} catch (error) {
|
|
30
|
+
if (!error?.retryable || attempt >= maxAttempts) {
|
|
31
|
+
throw error;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
throw new Error("unreachable");
|
|
36
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import type { ComponentType } from "react";
|
|
2
|
+
import type { AppIconName } from "../ui/index.js";
|
|
3
|
+
|
|
4
|
+
/** Settings 只声明组件,表单状态不代表持久化配置。 */
|
|
5
|
+
export interface SettingsExtensionDefinition {
|
|
6
|
+
key: string;
|
|
7
|
+
type: "settings";
|
|
8
|
+
component: ComponentType;
|
|
9
|
+
handler?: never;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/** 当前对象详情提供的公开记录身份;不能作为服务端授权凭据。 */
|
|
13
|
+
export interface RecordActionContext {
|
|
14
|
+
/** 当前记录的公开 ID。 */
|
|
15
|
+
recordId: string;
|
|
16
|
+
/** 当前对象的公开 API slug。 */
|
|
17
|
+
object: string;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** 在通用记录详情菜单中贡献一个操作。 */
|
|
21
|
+
export interface RecordActionDefinition {
|
|
22
|
+
/** App 内唯一 lowercase kebab-case key。 */
|
|
23
|
+
key: string;
|
|
24
|
+
type: "record-action";
|
|
25
|
+
/** 菜单中展示的操作名称。 */
|
|
26
|
+
label: string;
|
|
27
|
+
/** 统一 SDK 图标目录中的名称。 */
|
|
28
|
+
icon?: AppIconName;
|
|
29
|
+
/** 省略时匹配所有能提供公开身份的记录对象。 */
|
|
30
|
+
objects?: string | readonly string[];
|
|
31
|
+
/** 用户触发操作时执行;成功无需返回值。 */
|
|
32
|
+
onTrigger(context: RecordActionContext): void | Promise<void>;
|
|
33
|
+
component?: never;
|
|
34
|
+
handler?: never;
|
|
35
|
+
}
|
|
36
|
+
export type FrontendExtensionDefinition =
|
|
37
|
+
| SettingsExtensionDefinition
|
|
38
|
+
| RecordActionDefinition;
|
|
39
|
+
|
|
40
|
+
export function defineFrontendExtension(
|
|
41
|
+
definition: SettingsExtensionDefinition,
|
|
42
|
+
): Readonly<SettingsExtensionDefinition>;
|
|
43
|
+
export function defineFrontendExtension(
|
|
44
|
+
definition: RecordActionDefinition,
|
|
45
|
+
): Readonly<RecordActionDefinition>;
|
|
46
|
+
|
|
47
|
+
export type { AppIconName } from "../ui/index.js";
|
|
48
|
+
export type { ToastOptions, ConfirmOptions } from './interactions.js';
|
|
49
|
+
/** 受管结果提示;不接收 HTML,回调完成后可继续显示,Workspace 会话结束时清理。 */
|
|
50
|
+
export function showToast(options: import('./interactions.js').ToastOptions): Promise<void>;
|
|
51
|
+
/** 用户取消返回 false;所属页面关闭时拒绝,不能当作确认。 */
|
|
52
|
+
export function confirm(options: import('./interactions.js').ConfirmOptions): Promise<boolean>;
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { frontendPoint } from './points.generated.js';
|
|
2
|
+
const FRONTEND_EXTENSION_MARKER = Symbol.for("zhike.frontend-extension.v1");
|
|
3
|
+
|
|
4
|
+
export function defineFrontendExtension(definition) {
|
|
5
|
+
if (!definition || typeof definition !== "object" || Array.isArray(definition)) {
|
|
6
|
+
throw new TypeError("Frontend Extension definition 必须是对象");
|
|
7
|
+
}
|
|
8
|
+
const point = frontendPoint(definition.type);
|
|
9
|
+
if (!point) {
|
|
10
|
+
throw new TypeError("Frontend Extension type 不受支持");
|
|
11
|
+
}
|
|
12
|
+
const property = point.kind === "view" ? "component" : (point.callbackName || "handler");
|
|
13
|
+
const forbidden = point.kind === "view" ? ["handler", "onTrigger"] : ["component", ...(property === "handler" ? ["onTrigger"] : ["handler"])];
|
|
14
|
+
if (typeof definition[property] !== "function" || forbidden.some(name => name in definition)) {
|
|
15
|
+
throw new TypeError(`Frontend Extension 必须只提供 ${property} 函数`);
|
|
16
|
+
}
|
|
17
|
+
return Object.freeze({ ...definition, [FRONTEND_EXTENSION_MARKER]: true });
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
// 受管 Guest 注入实际请求出口;脱离前端运行环境不能调用产品交互。
|
|
21
|
+
export function showToast() { return Promise.reject(new Error('runtime_host_interaction_unavailable')); }
|
|
22
|
+
export function confirm() { return Promise.reject(new Error('runtime_host_interaction_unavailable')); }
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
export interface ToastOptions { message: string; tone?: 'info' | 'success' | 'error' }
|
|
2
|
+
export interface ConfirmOptions { title: string; description?: string; confirmLabel?: string; cancelLabel?: string }
|
|
3
|
+
export type HostInteraction = {operation:'showToast';input:ToastOptions} | {operation:'confirm';input:ConfirmOptions};
|
|
4
|
+
export function assertHostInteraction(operation: unknown, input: unknown): Readonly<ToastOptions | ConfirmOptions>;
|
|
5
|
+
export function createClientInteractions(invoke: (operation: string, input: ToastOptions | ConfirmOptions) => Promise<unknown>): {
|
|
6
|
+
showToast(input: ToastOptions): Promise<void>;
|
|
7
|
+
confirm(input: ConfirmOptions): Promise<boolean>;
|
|
8
|
+
};
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
const fail = () => { throw new Error('runtime_interaction_invalid'); };
|
|
2
|
+
const text = (value, max, required = false) => value === undefined ? !required : typeof value === 'string' && value.length <= max && (!required || value.length > 0);
|
|
3
|
+
export function assertHostInteraction(operation, input) {
|
|
4
|
+
if (!input || typeof input !== 'object' || Array.isArray(input)) fail();
|
|
5
|
+
const allowed = operation === 'showToast' ? ['message','tone'] : operation === 'confirm' ? ['title','description','confirmLabel','cancelLabel'] : [];
|
|
6
|
+
if (!allowed.length || Object.keys(input).some(key=>!allowed.includes(key))) fail();
|
|
7
|
+
if (operation === 'showToast') {
|
|
8
|
+
if (!text(input.message,500,true) || (input.tone !== undefined && !['info','success','error'].includes(input.tone))) fail();
|
|
9
|
+
} else if (!text(input.title,200,true) || !text(input.description,2000) || !text(input.confirmLabel,60) || !text(input.cancelLabel,60)) fail();
|
|
10
|
+
return Object.freeze(Object.fromEntries(Object.entries(input).filter(([,value])=>value!==undefined)));
|
|
11
|
+
}
|
|
12
|
+
export function createClientInteractions(invoke) {
|
|
13
|
+
return Object.freeze({
|
|
14
|
+
showToast(input) {
|
|
15
|
+
const request = assertHostInteraction('showToast',input);
|
|
16
|
+
return invoke('showToast',request).then(value=>{if(value!==null)fail();});
|
|
17
|
+
},
|
|
18
|
+
confirm(input) {
|
|
19
|
+
const request = assertHostInteraction('confirm',input);
|
|
20
|
+
return invoke('confirm',request).then(value=>{if(typeof value!=='boolean')fail();return value;});
|
|
21
|
+
},
|
|
22
|
+
});
|
|
23
|
+
}
|