magic-builder 1.3.2 → 1.4.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/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # magic-builder
2
2
 
3
- CLI for Magic Builder (妙笔): publish HTML pages, deploy FaaS functions, upload files, generate links, and install the Magic Builder Codex skill.
3
+ CLI for Magic Builder (妙笔): publish HTML pages, create Feishu app bots, deploy FaaS functions, upload files, generate links, and install the Magic Builder Codex skill.
4
4
 
5
5
  ## Install
6
6
 
@@ -103,6 +103,18 @@ Deploy a FaaS function:
103
103
  magic-builder faas publish handler.js --name report-api
104
104
  ```
105
105
 
106
+ Create a Feishu app bot backed by Magic FaaS:
107
+
108
+ ```bash
109
+ chmod 600 ./bot.credentials.json
110
+ magic-builder bot create \
111
+ --handler ./bot.js \
112
+ --name my_bot \
113
+ --credentials ./bot.credentials.json
114
+ ```
115
+
116
+ Pass `--faas-id <existing-id>` when updating an existing bot so its event callback URL stays unchanged.
117
+
106
118
  Publish a Tools-only MCP service (ordinary HTTP `handler` may coexist):
107
119
 
108
120
  ```bash
@@ -8,6 +8,7 @@ const COMMANDS = {
8
8
  auth: require('../src/commands/auth'),
9
9
  page: require('../src/commands/page'),
10
10
  faas: require('../src/commands/faas'),
11
+ bot: require('../src/commands/bot'),
11
12
  file: require('../src/commands/file'),
12
13
  link: require('../src/commands/link'),
13
14
  doc: require('../src/commands/doc'),
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "magic-builder",
3
- "version": "1.3.2",
4
- "description": "CLI for Magic Builder — publish pages, functions, files, and performance reviews",
3
+ "version": "1.4.0",
4
+ "description": "CLI for Magic Builder — publish pages, bots, functions, files, and performance reviews",
5
5
  "bin": {
6
6
  "magic-builder": "bin/magic-builder.js",
7
7
  "magic-cli": "bin/magic-builder.js",
@@ -26,6 +26,7 @@
26
26
  "miaobi",
27
27
  "cli",
28
28
  "faas",
29
+ "bot",
29
30
  "publish"
30
31
  ],
31
32
  "license": "MIT",
@@ -29,6 +29,7 @@ async function login(opts) {
29
29
  const requestId = String(data.request_id || '');
30
30
  const pollToken = String(data.poll_token || '');
31
31
  const authUrl = String(data.auth_url || '');
32
+ const pollUrl = String(data.poll_url || '').trim() || `${baseUrl}/api/dev-token/auth/token`;
32
33
  if (!requestId || !pollToken || !authUrl) {
33
34
  fail(`Invalid auth start response: ${JSON.stringify(start)}`, 'E_AUTH_FAILED', 2);
34
35
  }
@@ -45,7 +46,7 @@ async function login(opts) {
45
46
 
46
47
  const deadline = Date.now() + timeoutSeconds * 1000;
47
48
  while (Date.now() < deadline) {
48
- const tokenJson = await request(`${baseUrl}/api/dev-token/auth/token`, {
49
+ const tokenJson = await request(pollUrl, {
49
50
  method: 'POST',
50
51
  body: {
51
52
  request_id: requestId,
@@ -0,0 +1,170 @@
1
+ 'use strict';
2
+
3
+ const fs = require('fs');
4
+ const { getBaseUrl } = require('../lib/config');
5
+ const { request } = require('../lib/http');
6
+ const { formatOutput, success, fail, failFromHttpError } = require('../lib/output');
7
+ const { publishFaas } = require('./faas');
8
+
9
+ const REQUIRED_CREDENTIAL_FIELDS = ['app_id', 'app_secret', 'verification_token', 'app_name'];
10
+ const ALLOWED_CREDENTIAL_FIELDS = [...REQUIRED_CREDENTIAL_FIELDS, 'encrypted_key', 'developer_open_id'];
11
+
12
+ async function run(args, opts) {
13
+ if (args[0] !== 'create') {
14
+ fail('Usage: magic-builder bot create --handler <file.js> --name <name> --credentials <file.json> [--faas-id <id>]', 'E_INVALID_ARGS');
15
+ }
16
+ for (const required of ['handler', 'name', 'credentials']) {
17
+ if (!String(opts[required] || '').trim()) fail(`--${required} is required`, 'E_INVALID_ARGS');
18
+ }
19
+
20
+ try {
21
+ const result = await createMagicBot({
22
+ handler: opts.handler,
23
+ name: opts.name,
24
+ credentials: readCredentials(opts.credentials),
25
+ faasId: opts.faasId,
26
+ baseUrl: opts.baseUrl,
27
+ }, { cliOptions: opts });
28
+
29
+ if (!result.ok) {
30
+ process.stdout.write(formatOutput(result, opts.format) + '\n');
31
+ process.exitCode = 1;
32
+ return;
33
+ }
34
+ success(result, opts);
35
+ } catch (error) {
36
+ if (error.code === 'E_NO_TOKEN') fail(error.message, error.code, 2);
37
+ if (error.code === 'E_PUBLISH_FAILED') fail(error.message, error.code);
38
+ if (error.code) failFromHttpError(error);
39
+ throw error;
40
+ }
41
+ }
42
+
43
+ function readCredentials(filePath, { enforcePermissions = true } = {}) {
44
+ if (!fs.existsSync(filePath)) throw new Error(`Credentials file not found: ${filePath}`);
45
+ const stat = fs.statSync(filePath);
46
+ if (!stat.isFile()) throw new Error(`Credentials path is not a file: ${filePath}`);
47
+ if (enforcePermissions && process.platform !== 'win32' && (stat.mode & 0o077) !== 0) {
48
+ throw new Error('credentials file permissions must be 0600 or stricter');
49
+ }
50
+
51
+ let parsed;
52
+ try {
53
+ parsed = JSON.parse(fs.readFileSync(filePath, 'utf8'));
54
+ } catch (error) {
55
+ throw new Error(`Invalid credentials JSON: ${error.message}`);
56
+ }
57
+ for (const field of REQUIRED_CREDENTIAL_FIELDS) {
58
+ if (!String(parsed?.[field] || '').trim()) throw new Error(`credentials.${field} is required`);
59
+ }
60
+ return Object.fromEntries(ALLOWED_CREDENTIAL_FIELDS.flatMap((field) => (
61
+ parsed[field] === undefined ? [] : [[field, String(parsed[field]).trim()]]
62
+ )));
63
+ }
64
+
65
+ function validateHandler(filePath) {
66
+ if (!fs.existsSync(filePath)) throw new Error(`Handler file not found: ${filePath}`);
67
+ const source = fs.readFileSync(filePath, 'utf8');
68
+ if (!source.trim()) throw new Error('Handler file is empty');
69
+ if (!/module\.exports\s*=/.test(source)) throw new Error('handler must use module.exports');
70
+ Function(source);
71
+ return source;
72
+ }
73
+
74
+ function normalizeFaasId(value) {
75
+ return String(value || '').trim().replace(/^rec/, '');
76
+ }
77
+
78
+ function buildCallbackUrl(baseUrl, appId, faasId) {
79
+ const url = new URL(`/api/event/${encodeURIComponent(appId)}`, baseUrl);
80
+ url.searchParams.set('faas_id', normalizeFaasId(faasId));
81
+ return url.toString();
82
+ }
83
+
84
+ async function createMagicBot(options, dependencies = {}) {
85
+ const publishFaasImpl = dependencies.publishFaasImpl || publishFaas;
86
+ const requestImpl = dependencies.requestImpl || request;
87
+ const fetchImpl = dependencies.fetchImpl || globalThis.fetch;
88
+ const cliOptions = dependencies.cliOptions || {};
89
+ const baseUrl = String(options.baseUrl || getBaseUrl(cliOptions)).replace(/\/+$/, '');
90
+ const source = validateHandler(options.handler);
91
+
92
+ const published = await publishFaasImpl({
93
+ code: source,
94
+ name: options.name,
95
+ id: options.faasId,
96
+ }, { ...cliOptions, baseUrl });
97
+ const publishedId = published?.id || published?.record_id;
98
+ const faasId = normalizeFaasId(publishedId || options.faasId);
99
+ if (!faasId) throw new Error('FaaS publish response is missing id');
100
+ const faasUrl = published?.faas_url || `${baseUrl}/api/faas/${faasId}`;
101
+ const callbackUrl = buildCallbackUrl(baseUrl, options.credentials.app_id, faasId);
102
+
103
+ let registration;
104
+ try {
105
+ registration = await requestImpl(`${baseUrl}/api/app`, {
106
+ method: 'POST',
107
+ body: options.credentials,
108
+ });
109
+ } catch (error) {
110
+ return partialFailure('app_registration', error.message, faasId, faasUrl, callbackUrl);
111
+ }
112
+ if (registration?.code !== 0) {
113
+ return partialFailure('app_registration', registration?.msg || 'App registration failed', faasId, faasUrl, callbackUrl);
114
+ }
115
+
116
+ const challengeValue = `magic-bot-${Date.now().toString(36)}`;
117
+ let callbackVerified = false;
118
+ let challengeError = '';
119
+ try {
120
+ const response = await fetchImpl(callbackUrl, {
121
+ method: 'POST',
122
+ headers: { 'content-type': 'application/json' },
123
+ body: JSON.stringify({ challenge: challengeValue }),
124
+ });
125
+ const challenge = await response.json().catch(() => ({}));
126
+ callbackVerified = response.ok && challenge?.challenge === challengeValue;
127
+ if (!callbackVerified) challengeError = challenge?.msg || `HTTP ${response.status}`;
128
+ } catch (error) {
129
+ challengeError = error.message || 'Challenge request failed';
130
+ }
131
+
132
+ const result = {
133
+ ok: callbackVerified,
134
+ stage: callbackVerified ? 'complete' : 'challenge_verification',
135
+ app_id: options.credentials.app_id,
136
+ app_name: options.credentials.app_name,
137
+ faas_id: faasId,
138
+ faas_url: faasUrl,
139
+ event_callback_url: callbackUrl,
140
+ registration_action: registration?.data?.action || 'unknown',
141
+ callback_verified: callbackVerified,
142
+ next_steps: [
143
+ '在飞书开放平台事件订阅中填写 event_callback_url',
144
+ '勾选 handler 实际处理的事件并发布应用版本',
145
+ '把机器人加入目标会话并发送真实消息验收',
146
+ ],
147
+ };
148
+ if (!callbackVerified) result.error = challengeError || 'Challenge verification failed';
149
+ return result;
150
+ }
151
+
152
+ function partialFailure(stage, error, faasId, faasUrl, callbackUrl) {
153
+ return {
154
+ ok: false,
155
+ stage,
156
+ faas_id: faasId,
157
+ faas_url: faasUrl,
158
+ event_callback_url: callbackUrl,
159
+ error: String(error || 'Unknown error'),
160
+ };
161
+ }
162
+
163
+ module.exports = {
164
+ run,
165
+ buildCallbackUrl,
166
+ createMagicBot,
167
+ normalizeFaasId,
168
+ readCredentials,
169
+ validateHandler,
170
+ };
@@ -29,53 +29,77 @@ async function publish(args, opts) {
29
29
  if (!code.trim()) fail('JS file is empty', 'E_EMPTY_FILE');
30
30
  }
31
31
 
32
+ const name = opts.name || generateName(args[0]);
33
+
34
+ if (!opts.quiet) process.stderr.write(`Deploying FaaS${opts.name ? ` "${opts.name}"` : ''}... `);
35
+
36
+ try {
37
+ const result = await publishFaas({
38
+ code,
39
+ name,
40
+ id: opts.id,
41
+ mcp: opts.mcp,
42
+ auth: opts.auth,
43
+ oauthUser: opts.oauthUser,
44
+ oauthPolicy: opts.oauthPolicy,
45
+ }, opts);
46
+
47
+ if (!opts.quiet) process.stderr.write('done\n');
48
+ success(result, opts);
49
+ } catch (e) {
50
+ if (e.code === 'E_NO_TOKEN') fail(e.message, e.code, 2);
51
+ if (e.code === 'E_PUBLISH_FAILED') fail(e.message, e.code);
52
+ failFromHttpError(e);
53
+ }
54
+ }
55
+
56
+ async function publishFaas(input, opts = {}, dependencies = {}) {
57
+ const getTokenImpl = dependencies.getTokenImpl || getToken;
58
+ const requestImpl = dependencies.requestImpl || request;
32
59
  let token;
33
- try { token = await getToken(opts); } catch (e) { fail(e.message, 'E_NO_TOKEN', 2); }
60
+ try {
61
+ token = await getTokenImpl(opts);
62
+ } catch (error) {
63
+ error.code = error.code || 'E_NO_TOKEN';
64
+ throw error;
65
+ }
34
66
 
35
67
  const baseUrl = getBaseUrl(opts);
36
- const name = opts.name || generateName(args[0]);
37
-
38
- const body = { code, name };
39
- if (opts.id) body.id = opts.id;
40
- if (opts.mcp) {
68
+ const body = { code: input.code, name: input.name };
69
+ if (input.id) body.id = input.id;
70
+ if (input.mcp) {
41
71
  body.mcp = true;
42
- body.auth = String(opts.auth || 'key').split(',').map((item) => item.trim()).filter(Boolean);
43
- if (opts.oauthUser !== undefined) {
44
- body.oauth_users = String(opts.oauthUser || '').split(',').map((item) => item.trim()).filter(Boolean);
72
+ body.auth = String(input.auth || 'key').split(',').map((item) => item.trim()).filter(Boolean);
73
+ if (input.oauthUser !== undefined) {
74
+ body.oauth_users = String(input.oauthUser || '').split(',').map((item) => item.trim()).filter(Boolean);
45
75
  }
46
- if (opts.oauthPolicy !== undefined) body.oauth_policy = String(opts.oauthPolicy).trim().toLowerCase();
76
+ if (input.oauthPolicy !== undefined) body.oauth_policy = String(input.oauthPolicy).trim().toLowerCase();
47
77
  else if (body.oauth_users?.length) body.oauth_policy = 'whitelist';
48
78
  }
49
79
 
50
- if (!opts.quiet) process.stderr.write(`Deploying FaaS${opts.name ? ` "${opts.name}"` : ''}... `);
51
-
52
- let res;
53
- try {
54
- res = await request(`${baseUrl}/api/faas`, {
55
- method: 'POST',
56
- token,
57
- body,
58
- });
59
- } catch (e) { failFromHttpError(e); }
60
-
61
- if (res.code !== 0) fail(res.msg || 'FaaS publish failed', 'E_PUBLISH_FAILED');
80
+ const res = await requestImpl(`${baseUrl}/api/faas`, {
81
+ method: 'POST',
82
+ token,
83
+ body,
84
+ });
85
+ if (res.code !== 0) {
86
+ const error = new Error(res.msg || 'FaaS publish failed');
87
+ error.code = 'E_PUBLISH_FAILED';
88
+ throw error;
89
+ }
62
90
 
63
91
  const data = res.data || res;
64
92
  const id = data.id || data.record_id;
65
93
  const faasUrl = data.faas_url || `/api/faas/${id}`;
66
94
  const mcpUrl = data.mcp_url || `/api/faas/${id}/mcp`;
67
-
68
- if (!opts.quiet) process.stderr.write('done\n');
69
-
70
95
  const wsBaseUrl = baseUrl.replace(/^https?/, 'wss');
71
-
72
- success({
96
+ return {
73
97
  id: data.record_id || id,
74
98
  faas_url: `${baseUrl}${faasUrl}`,
75
99
  preview_url: `${baseUrl}/r?fid=${id}`,
76
100
  wss_url: `${wsBaseUrl}${faasUrl}`,
77
- ...(opts.mcp ? { mcp_url: `${baseUrl}${mcpUrl}`, mcp_auth: data.mcp_auth || body.auth } : {}),
78
- }, opts);
101
+ ...(input.mcp ? { mcp_url: `${baseUrl}${mcpUrl}`, mcp_auth: data.mcp_auth || body.auth } : {}),
102
+ };
79
103
  }
80
104
 
81
105
  async function mcp(args, opts) {
@@ -179,4 +203,4 @@ function generateName(filePath) {
179
203
  return base.replace(/[^a-zA-Z0-9_]/g, '_').slice(0, 50);
180
204
  }
181
205
 
182
- module.exports = { run };
206
+ module.exports = { run, publishFaas };
package/src/index.js CHANGED
@@ -9,6 +9,7 @@ module.exports = {
9
9
  auth: require('./commands/auth'),
10
10
  page: require('./commands/page'),
11
11
  faas: require('./commands/faas'),
12
+ bot: require('./commands/bot'),
12
13
  file: require('./commands/file'),
13
14
  link: require('./commands/link'),
14
15
  doc: require('./commands/doc'),
package/src/lib/help.js CHANGED
@@ -17,6 +17,7 @@ COMMANDS:
17
17
  auth Login and manage Magic developer tokens
18
18
  page Publish and manage Magic pages, collaborators, and access
19
19
  faas Publish and manage Magic FaaS HTTP/WSS/MCP functions
20
+ bot Create or update a Feishu app bot backed by Magic FaaS
20
21
  file Upload, list, and delete TOS files
21
22
  link Generate Magic share links
22
23
  doc Create or append Feishu Doc HTML Box apps
@@ -45,6 +46,7 @@ EXAMPLES:
45
46
  magic-builder faas publish server.js --name weather-mcp --mcp --auth key,oauth
46
47
  magic-builder faas list
47
48
  magic-builder faas delete --id recxxx
49
+ magic-builder bot create --handler bot.js --name echo-bot --credentials ./bot.credentials.json
48
50
  magic-builder file upload logo.png
49
51
  magic-builder file list
50
52
  magic-builder file delete --id recxxx
@@ -112,6 +114,8 @@ COMMANDS:
112
114
  faas mcp auth get --id <id>
113
115
  faas mcp auth update --id <id> [--auth key,oauth] [--oauth-policy all|whitelist] [--oauth-user ou_xxx]
114
116
 
117
+ bot create --handler <file.js> --name <name> --credentials <file.json> [--faas-id <id>]
118
+
115
119
  file upload <file> [--key <key>] [--content-type <mime>]
116
120
  file list [--title <keyword>]
117
121
  file delete --id <id>
@@ -209,6 +213,21 @@ SYNTAX:
209
213
  magic-builder faas mcp key revoke --id <id> --key-id <key_id>
210
214
  magic-builder faas mcp auth get --id <id>
211
215
  magic-builder faas mcp auth update --id <id> --oauth-policy whitelist --oauth-user ou_xxx
216
+ `,
217
+ bot: `@HELP magic-builder/bot
218
+
219
+ BRIEF: Create or update a Feishu app bot backed by Magic FaaS.
220
+
221
+ SYNTAX:
222
+ magic-builder bot create --handler <file.js> --name <name> --credentials <file.json> [--faas-id <id>]
223
+
224
+ CREDENTIALS:
225
+ The JSON file must contain app_id, app_secret, verification_token, and app_name.
226
+ encrypted_key and developer_open_id are optional. On POSIX systems the file
227
+ permissions must be 0600 or stricter.
228
+
229
+ UPDATE:
230
+ Pass the existing --faas-id to update a bot without changing its callback URL.
212
231
  `,
213
232
  file: `@HELP magic-builder/file
214
233