magic-builder 1.2.0 → 1.3.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
@@ -72,6 +72,7 @@ This includes:
72
72
  ```text
73
73
  magic-token
74
74
  magic-apps.json
75
+ widget-publish.json
75
76
  ```
76
77
 
77
78
  ## Common Commands
@@ -145,6 +146,15 @@ magic-builder performance --review-url <people-review-url> --markdown ./performa
145
146
 
146
147
  With `--review-url`, the CLI loads the operator and tenant from `/perf/api/user/settings`, then reuses the current draft version, template, units, fields, source ids, and root review id from `/perf/api/foundation/draft`. Business ids are not hard-coded into the generated request.
147
148
 
149
+ Formally submit the latest draft to the current review stage:
150
+
151
+ ```bash
152
+ magic-builder perf submit --review-url <people-review-url> --template-group-id <id> --dry-run
153
+ magic-builder perf submit --review-url <people-review-url> --template-group-id <id> --yes
154
+ ```
155
+
156
+ The command posts to `/perf/api/review/v2/stage`. It always reloads the latest draft, derives the root review, template, stage, and work units from it, and serializes rich-text values into the stage payload. `--template-group-id` is optional when the draft already contains it. A real submission requires `--yes`; use `--dry-run` to inspect the payload first.
157
+
148
158
  Extract Cookie from a copied curl command. This is a top-level general-purpose command. The default output is `./cookie.txt`; the file is created with mode `0600`:
149
159
 
150
160
  ```bash
@@ -160,6 +170,43 @@ magic-builder doc create --html app.html --title "Demo"
160
170
  magic-builder doc append --html app.html --doc-token <docx-token>
161
171
  ```
162
172
 
173
+ Publish a document widget draft as a new app version:
174
+
175
+ ```bash
176
+ magic-builder widget-publish \
177
+ --app-id cli_a98afe875979500d \
178
+ --block-type-id blk_6900429af84180025ce76527 \
179
+ --change-log "修复 HTML Box 高度反馈循环"
180
+ ```
181
+
182
+ The command reads Open Platform browser authentication from
183
+ `~/.magic-builder/widget-publish.json` by default:
184
+
185
+ ```json
186
+ {
187
+ "cookie": "open_locale=zh-CN; session=...",
188
+ "csrfToken": "...",
189
+ "timezoneOffset": -480
190
+ }
191
+ ```
192
+
193
+ Keep this file local and set its mode to `0600`. Use `--config <file>` to select
194
+ another file. The command fetches the widget detail, selects the latest uploaded
195
+ test package before the existing draft/current package, updates the widget pkgId,
196
+ increments the latest patch version, creates the app
197
+ version with the application's actual default abilities and audit settings, and
198
+ submits it for publishing. Use `--version` to override the generated
199
+ version or `--dry-run` to inspect the payload without updating or publishing.
200
+
201
+ You can generate the local config directly from a browser-copied curl command:
202
+
203
+ ```bash
204
+ magic-builder extract-cookie --curl-file ./widget-request.curl --widget-publish
205
+ ```
206
+
207
+ This extracts only the Cookie and `x-csrf-token`, writes them to the default
208
+ widget publish config with mode `0600`, and does not print either secret.
209
+
163
210
  Install or update the Magic Builder Codex skill:
164
211
 
165
212
  ```bash
@@ -14,6 +14,7 @@ const COMMANDS = {
14
14
  feedback: require('../src/commands/feedback'),
15
15
  performance: require('../src/commands/performance'),
16
16
  perf: require('../src/commands/performance'),
17
+ 'widget-publish': require('../src/commands/widget-publish'),
17
18
  'extract-cookie': require('../src/commands/extract-cookie'),
18
19
  skill: require('../src/commands/skill'),
19
20
  version: require('../src/commands/version'),
package/package.json CHANGED
@@ -1,13 +1,16 @@
1
1
  {
2
2
  "name": "magic-builder",
3
- "version": "1.2.0",
4
- "description": "CLI for Magic Builder — publish pages, functions, files, and performance drafts",
3
+ "version": "1.3.0",
4
+ "description": "CLI for Magic Builder — publish pages, functions, files, and performance reviews",
5
5
  "bin": {
6
6
  "magic-builder": "bin/magic-builder.js",
7
7
  "magic-cli": "bin/magic-builder.js",
8
8
  "miaobi": "bin/magic-builder.js"
9
9
  },
10
10
  "main": "./src/index.js",
11
+ "scripts": {
12
+ "test": "node --test test/*.test.js"
13
+ },
11
14
  "engines": {
12
15
  "node": ">=18.0.0"
13
16
  },
@@ -1,6 +1,6 @@
1
1
  'use strict';
2
2
 
3
- const { CONFIG_DIR, getAppsConfigPath, getBaseUrl } = require('../lib/config');
3
+ const { CONFIG_DIR, getAppsConfigPath, getBaseUrl, getWidgetPublishConfigPath } = require('../lib/config');
4
4
  const { TOKEN_FILE, showToken } = require('../lib/auth');
5
5
  const { success, fail } = require('../lib/output');
6
6
 
@@ -10,6 +10,7 @@ async function run(args, opts) {
10
10
  success({
11
11
  base_url: getBaseUrl(opts),
12
12
  config_dir: CONFIG_DIR,
13
+ widget_publish_config_file: getWidgetPublishConfigPath(),
13
14
  token_file: TOKEN_FILE,
14
15
  apps_config_file: getAppsConfigPath(),
15
16
  token: showToken() || '',
@@ -1,7 +1,8 @@
1
1
  'use strict';
2
2
 
3
- const { existsSync, mkdirSync, readFileSync, writeFileSync } = require('fs');
3
+ const { chmodSync, existsSync, mkdirSync, readFileSync, writeFileSync } = require('fs');
4
4
  const { basename, dirname, resolve } = require('path');
5
+ const { getWidgetPublishConfigPath } = require('../lib/config');
5
6
  const { success, fail } = require('../lib/output');
6
7
 
7
8
  function run(args, opts) {
@@ -11,9 +12,24 @@ function run(args, opts) {
11
12
  const cookie = extractCookieFromCurl(curl);
12
13
  if (!cookie) fail('No Cookie found in curl. Expected -b/--cookie or a Cookie request header.', 'E_INVALID_ARGS');
13
14
 
15
+ if (opts.widgetPublish) {
16
+ const csrfToken = extractHeaderFromCurl(curl, 'x-csrf-token');
17
+ if (!csrfToken) fail('No x-csrf-token header found in curl.', 'E_INVALID_ARGS');
18
+ const output = resolve(String(opts.out || getWidgetPublishConfigPath()));
19
+ mkdirSync(dirname(output), { recursive: true });
20
+ writeFileSync(output, `${JSON.stringify({ cookie, csrfToken, timezoneOffset: -480 }, null, 2)}\n`, {
21
+ encoding: 'utf8',
22
+ mode: 0o600,
23
+ });
24
+ chmodSync(output, 0o600);
25
+ success({ path: output, filename: basename(output), widgetPublish: true }, opts);
26
+ return;
27
+ }
28
+
14
29
  const output = resolveOutput(opts.out, opts.outDir);
15
30
  mkdirSync(dirname(output), { recursive: true });
16
31
  writeFileSync(output, `${cookie}\n`, { encoding: 'utf8', mode: 0o600 });
32
+ chmodSync(output, 0o600);
17
33
  success({ path: output, filename: basename(output), cookieCount: cookie.split(';').filter(Boolean).length }, opts);
18
34
  }
19
35
 
@@ -43,6 +59,21 @@ function extractCookieFromCurl(curl) {
43
59
  return '';
44
60
  }
45
61
 
62
+ function extractHeaderFromCurl(curl, headerName) {
63
+ const expected = String(headerName || '').toLowerCase();
64
+ const tokens = tokenizeShell(String(curl || '').replace(/\\\r?\n/g, ' '));
65
+ for (let i = 0; i < tokens.length; i++) {
66
+ let header = '';
67
+ if (tokens[i] === '-H' || tokens[i] === '--header') header = tokens[i + 1] || '';
68
+ else if (tokens[i].startsWith('--header=')) header = tokens[i].slice('--header='.length);
69
+ if (!header) continue;
70
+ const separator = header.indexOf(':');
71
+ if (separator < 0 || header.slice(0, separator).trim().toLowerCase() !== expected) continue;
72
+ return header.slice(separator + 1).trim();
73
+ }
74
+ return '';
75
+ }
76
+
46
77
  function normalizeCookie(raw) {
47
78
  return String(raw || '').replace(/^cookie\s*:\s*/i, '').trim();
48
79
  }
@@ -82,4 +113,4 @@ function resolveOutput(out, outDir) {
82
113
  return resolve(String(outDir || process.cwd()), 'cookie.txt');
83
114
  }
84
115
 
85
- module.exports = { run, extractCookieFromCurl, resolveOutput };
116
+ module.exports = { run, extractCookieFromCurl, extractHeaderFromCurl, resolveOutput };
@@ -6,23 +6,41 @@ const { success, fail } = require('../lib/output');
6
6
 
7
7
  const DEFAULTS = {
8
8
  endpoint: 'https://people.bytedance.net/perf/api/foundation/v2/draft',
9
+ submitEndpoint: 'https://people.bytedance.net/perf/api/review/v2/stage',
9
10
  settingsEndpoint: 'https://people.bytedance.net/perf/api/user/settings',
10
11
  draftEndpoint: 'https://people.bytedance.net/perf/api/foundation/draft',
11
12
  };
12
13
 
13
14
  async function run(args, opts) {
14
- if (args.length) fail('Usage: magic-builder performance --cookie <content|file> --markdown <content|file|url>', 'E_INVALID_ARGS');
15
+ const action = args[0] || 'draft';
16
+ if (args.length > 1 || !['draft', 'submit'].includes(action)) {
17
+ fail('Usage: magic-builder performance [submit] --review-url <url> --cookie <content|file>', 'E_INVALID_ARGS');
18
+ }
15
19
 
16
20
  const cookie = readCookie(opts.cookie, opts.cookieFile);
17
21
  if (!cookie) fail('Missing Cookie. Use --cookie <content|file> or --cookie-file <file>.', 'E_INVALID_ARGS');
18
22
 
19
- const markdown = await readMarkdown(opts.markdown, opts.markdownFile);
20
- if (!markdown.trim()) fail('Missing Markdown. Use --markdown <content|file|url>, --markdown-file <file>, or stdin.', 'E_INVALID_ARGS');
21
-
22
23
  const csrf = getCookie(cookie, 'x-f-csrf');
23
24
  if (!csrf) fail('Cookie does not contain x-f-csrf.', 'E_INVALID_ARGS');
24
25
 
25
26
  const config = await resolveConfig(opts, cookie);
27
+ if (action === 'submit') {
28
+ const payload = buildStagePayloadFromDraft(config.draft, config);
29
+ if (opts.dryRun) {
30
+ success({ dryRun: true, action: 'submit', endpoint: config.submitEndpoint, payload }, opts);
31
+ return;
32
+ }
33
+ if (!opts.yes) {
34
+ fail('Formal performance submission requires --yes. Run with --dry-run first to review the payload.', 'E_CONFIRM_REQUIRED');
35
+ }
36
+ const data = await postPerformance(config.submitEndpoint, payload, cookie, csrf, config);
37
+ success(data, opts);
38
+ return;
39
+ }
40
+
41
+ const markdown = await readMarkdown(opts.markdown, opts.markdownFile);
42
+ if (!markdown.trim()) fail('Missing Markdown. Use --markdown <content|file|url>, --markdown-file <file>, or stdin.', 'E_INVALID_ARGS');
43
+
26
44
  const payload = config.draft
27
45
  ? buildPayloadFromDraft(markdown, config.draft, config)
28
46
  : buildPayload(markdown, config);
@@ -31,7 +49,13 @@ async function run(args, opts) {
31
49
  return;
32
50
  }
33
51
 
34
- const response = await fetch(config.endpoint, {
52
+ const data = await postPerformance(config.endpoint, payload, cookie, csrf, config);
53
+ success(data, opts);
54
+ }
55
+
56
+ async function postPerformance(endpoint, payload, cookie, csrf, config) {
57
+ assertPeoplePerfEndpoint(endpoint);
58
+ const response = await fetch(endpoint, {
35
59
  method: 'POST',
36
60
  headers: {
37
61
  accept: 'application/json, text/plain, */*',
@@ -55,7 +79,7 @@ async function run(args, opts) {
55
79
  error.code = response.status === 401 || response.status === 403 ? 'E_AUTH_FAILED' : 'E_REQUEST_FAILED';
56
80
  throw error;
57
81
  }
58
- success(data === null ? text : data, opts);
82
+ return data === null ? text : data;
59
83
  }
60
84
 
61
85
  function readCookie(value, file) {
@@ -119,6 +143,7 @@ async function resolveConfig(opts, cookie) {
119
143
  if (!draftResponse?.success || !draftResponse?.data) fail('Unable to load the current performance draft.', 'E_REQUEST_FAILED');
120
144
  return {
121
145
  endpoint: String(opts.endpoint || DEFAULTS.endpoint),
146
+ submitEndpoint: String(opts.submitEndpoint || DEFAULTS.submitEndpoint),
122
147
  reviewId: review.reviewId,
123
148
  formId: review.formId,
124
149
  operatorId,
@@ -126,6 +151,7 @@ async function resolveConfig(opts, cookie) {
126
151
  referer: review.referer,
127
152
  uid: String(opts.uid || `magic-builder-${Date.now()}`),
128
153
  version: opts.draftVersion === undefined ? undefined : Number(opts.draftVersion),
154
+ templateGroupId: String(opts.templateGroupId || ''),
129
155
  draft: draftResponse.data,
130
156
  };
131
157
  }
@@ -137,6 +163,7 @@ async function resolveConfig(opts, cookie) {
137
163
  }
138
164
  return {
139
165
  endpoint: String(opts.endpoint || DEFAULTS.endpoint),
166
+ submitEndpoint: String(opts.submitEndpoint || DEFAULTS.submitEndpoint),
140
167
  reviewId: String(opts.reviewId),
141
168
  formId: String(opts.formId),
142
169
  templateId: String(opts.templateId),
@@ -147,6 +174,7 @@ async function resolveConfig(opts, cookie) {
147
174
  sourceId: String(opts.sourceId),
148
175
  fieldSourceId: String(opts.fieldSourceId),
149
176
  rootReviewId: String(opts.rootReviewId),
177
+ templateGroupId: String(opts.templateGroupId || ''),
150
178
  version: Number(opts.draftVersion || opts.version),
151
179
  tenantId: String(opts.tenantId || ''),
152
180
  referer: String(opts.referer || `https://people.bytedance.net/performance/perf/review/${opts.reviewId}/${opts.formId}?mode=editable`),
@@ -172,6 +200,7 @@ function parseReviewLocation(value, reviewId, formId) {
172
200
  }
173
201
 
174
202
  async function requestJson(url, cookie, referer) {
203
+ assertPeoplePerfEndpoint(url);
175
204
  const response = await fetch(url, {
176
205
  headers: { accept: 'application/json, text/plain, */*', cookie, referer },
177
206
  });
@@ -181,6 +210,16 @@ async function requestJson(url, cookie, referer) {
181
210
  return data;
182
211
  }
183
212
 
213
+ function assertPeoplePerfEndpoint(value) {
214
+ const url = new URL(String(value || ''));
215
+ if (url.origin !== 'https://people.bytedance.net' || !url.pathname.startsWith('/perf/api/')) {
216
+ const error = new Error('Performance API endpoint must use https://people.bytedance.net/perf/api/.');
217
+ error.code = 'E_INVALID_ARGS';
218
+ throw error;
219
+ }
220
+ return url;
221
+ }
222
+
184
223
  function buildPayload(markdown, config) {
185
224
  const now = Date.now();
186
225
  const sections = splitMarkdownSections(markdown);
@@ -256,6 +295,56 @@ function buildPayloadFromDraft(markdown, draft, config) {
256
295
  };
257
296
  }
258
297
 
298
+ function buildStagePayloadFromDraft(draft, config = {}) {
299
+ if (!draft?.data?.keyWorks) fail('Current draft does not contain keyWorks.', 'E_REQUEST_FAILED');
300
+ const keyWorks = structuredClone(draft.data.keyWorks);
301
+ const stageId = String(config.stageId || config.formId || '');
302
+ const rootReviewId = String(config.rootReviewId || draft.data.rootReviewId || draft.data.root_review_id || '');
303
+ const templateId = String(config.templateId || keyWorks.template_id || keyWorks.templateId || '');
304
+ const templateGroupId = String(
305
+ config.templateGroupId
306
+ || draft.data.templateGroupId
307
+ || draft.data.template_group_id
308
+ || keyWorks.templateGroupId
309
+ || keyWorks.template_group_id
310
+ || ''
311
+ );
312
+ const missing = [
313
+ ['root_review_id', rootReviewId],
314
+ ['template_id', templateId],
315
+ ['template_group_id', templateGroupId],
316
+ ['stage_id', stageId],
317
+ ].filter(([, value]) => !value).map(([name]) => name);
318
+ if (missing.length) {
319
+ fail(`Unable to build formal submission payload; missing ${missing.join(', ')}. Pass --template-group-id when it is absent from the draft.`, 'E_INVALID_ARGS');
320
+ }
321
+ if (!Array.isArray(keyWorks.units) || !keyWorks.units.length) {
322
+ fail('Current draft does not contain keyWorks.units.', 'E_REQUEST_FAILED');
323
+ }
324
+ return {
325
+ root_review_id: rootReviewId,
326
+ template_id: templateId,
327
+ template_group_id: templateGroupId,
328
+ stage_id: stageId,
329
+ stage_ids: [stageId],
330
+ data: { units: keyWorks.units.filter(unit => unit?.type === 'object').map(serializeStageUnit) },
331
+ };
332
+ }
333
+
334
+ function serializeStageUnit(unit) {
335
+ const output = structuredClone(unit);
336
+ for (const field of output.fields || []) {
337
+ delete field.json_value;
338
+ for (const group of field.sub_units || []) {
339
+ for (const subUnit of group || []) {
340
+ if (subUnit?.value && typeof subUnit.value !== 'string') subUnit.value = JSON.stringify(subUnit.value);
341
+ delete subUnit.json_value;
342
+ }
343
+ }
344
+ }
345
+ return output;
346
+ }
347
+
259
348
  function createSubUnits(markdown, textFieldId, sourceId, now = Date.now()) {
260
349
  return splitMarkdownSections(markdown).map((section, index) => {
261
350
  const value = { '0': { ops: markdownToDelta(section), zoneId: '0', zoneType: 'Z' } };
@@ -273,17 +362,28 @@ function splitMarkdownSections(markdown) {
273
362
  for (const line of lines) {
274
363
  if (/^##\s+/.test(line)) {
275
364
  if (current.some(item => item.trim()) && !current.every(item => /^#\s+/.test(item) || !item.trim())) {
276
- sections.push(current.join('\n').trim());
365
+ sections.push(cleanMarkdownSection(current.join('\n')));
277
366
  }
278
367
  current = [`# ${line.replace(/^##\s+/, '')}`];
279
368
  } else if (current.length || !/^#\s+/.test(line)) {
280
369
  current.push(line);
281
370
  }
282
371
  }
283
- if (current.some(item => item.trim())) sections.push(current.join('\n').trim());
372
+ if (current.some(item => item.trim())) sections.push(cleanMarkdownSection(current.join('\n')));
284
373
  return sections.length ? sections : [text];
285
374
  }
286
375
 
376
+ function cleanMarkdownSection(value) {
377
+ const cleaned = String(value || '').trim().replace(/(?:\n\s*(?:---+|\*\*\*+|___+)\s*)+$/u, '').trim();
378
+ return cleaned.split('\n').map((line, index) => {
379
+ if (index === 0) return line.replace(/^#{1,6}\s+/, '# ');
380
+ if (!line.trim() || /^\s*(?:[-*+]\s+|\d+[.)]\s+)/u.test(line)) return line;
381
+ if (/^\s*[-*+]\S/u.test(line)) return line.replace(/^(\s*[-*+])\s*/u, '$1 ');
382
+ if (/^\s*\d+[.)]\S/u.test(line)) return line.replace(/^(\s*\d+[.)])\s*/u, '$1 ');
383
+ return `- ${line.trim()}`;
384
+ }).join('\n');
385
+ }
386
+
287
387
  function markdownToDelta(markdown) {
288
388
  const lines = String(markdown || '').replace(/\r\n?/g, '\n').split('\n');
289
389
  const ops = [];
@@ -298,18 +398,29 @@ function markdownToDelta(markdown) {
298
398
  ops.push({ insert: '\n', attributes: { 'code-block': true } });
299
399
  continue;
300
400
  }
401
+ if (!line.trim()) continue;
301
402
  const heading = line.match(/^(#{1,6})\s+(.+)$/);
302
- const unordered = line.match(/^\s*[-*+]\s+(.+)$/);
303
- const ordered = line.match(/^\s*\d+[.)]\s+(.+)$/);
403
+ const unordered = line.match(/^(\s*)[-*+]\s+(.+)$/);
404
+ const ordered = line.match(/^(\s*)(\d+)[.)]\s+(.+)$/);
304
405
  const quote = line.match(/^\s*>\s?(.*)$/);
305
- const content = heading?.[2] ?? unordered?.[1] ?? ordered?.[1] ?? quote?.[1] ?? line;
306
- appendInline(ops, content);
307
- const attributes = heading ? { header: heading[1].length }
308
- : unordered ? { list: 'bullet' }
309
- : ordered ? { list: 'ordered' }
310
- : quote ? { blockquote: true }
311
- : undefined;
312
- ops.push(attributes ? { insert: '\n', attributes } : { insert: '\n' });
406
+ if (heading) {
407
+ ops.push({ insert: `${heading[1]} ${heading[2]}`, attributes: { bold: true } });
408
+ ops.push({ insert: '\n' });
409
+ continue;
410
+ }
411
+ if (unordered || ordered) {
412
+ const indent = (unordered?.[1] || ordered?.[1] || '').replace(/\t/g, ' ').length;
413
+ const level = Math.max(1, Math.floor(indent / 2) + 1);
414
+ const list = `${unordered ? 'bullet' : 'ordered'}${level}`;
415
+ const marker = unordered ? '*' : `${ordered[2]}.`;
416
+ const content = unordered?.[2] ?? ordered?.[3] ?? '';
417
+ ops.push({ insert: marker, attributes: { list, lmkr: '1' } });
418
+ appendInline(ops, content);
419
+ ops.push({ insert: '\n' });
420
+ continue;
421
+ }
422
+ appendInline(ops, quote?.[1] ?? line);
423
+ ops.push(quote ? { insert: '\n', attributes: { blockquote: true } } : { insert: '\n' });
313
424
  }
314
425
  if (ops.length > 1 && lines.at(-1) === '') ops.pop();
315
426
  return ops.length ? ops : [{ insert: '\n' }];
@@ -334,4 +445,4 @@ function parseJson(text) {
334
445
  try { return text ? JSON.parse(text) : {}; } catch (_) { return null; }
335
446
  }
336
447
 
337
- module.exports = { run, readCookie, normalizeCookie, getCookie, parseReviewLocation, splitMarkdownSections, markdownToDelta, buildPayload, buildPayloadFromDraft };
448
+ module.exports = { run, readCookie, normalizeCookie, getCookie, parseReviewLocation, splitMarkdownSections, markdownToDelta, buildPayload, buildPayloadFromDraft, buildStagePayloadFromDraft, assertPeoplePerfEndpoint };
@@ -142,7 +142,7 @@ function readSkillMetadata(skillDir) {
142
142
  if (!fs.existsSync(skillPath)) return { exists: false, name: null, version: null, path: skillPath };
143
143
  const text = fs.readFileSync(skillPath, 'utf8');
144
144
  const nameMatch = text.match(/^name:\s*["']?([^"'\n]+)["']?\s*$/m);
145
- const versionMatch = text.match(/^version:\s*["']?([^"'\n]+)["']?\s*$/m);
145
+ const versionMatch = text.match(/^\s*version:\s*["']?([^"'\n]+)["']?\s*$/m);
146
146
  return {
147
147
  exists: true,
148
148
  name: nameMatch ? nameMatch[1].trim() : null,
@@ -0,0 +1,339 @@
1
+ 'use strict';
2
+
3
+ const { existsSync, readFileSync } = require('fs');
4
+ const { resolve } = require('path');
5
+ const { getWidgetPublishConfigPath } = require('../lib/config');
6
+ const { success, fail } = require('../lib/output');
7
+
8
+ const DEFAULT_ORIGIN = 'https://open.larkoffice.com';
9
+
10
+ async function run(args, opts) {
11
+ if (args.length) {
12
+ fail('Usage: magic-builder widget-publish --app-id <id> --block-type-id <id>', 'E_INVALID_ARGS');
13
+ }
14
+
15
+ const appId = String(opts.appId || '').trim();
16
+ const blockTypeId = String(opts.blockTypeId || '').trim();
17
+ if (!appId || !blockTypeId) {
18
+ fail('Missing --app-id or --block-type-id.', 'E_INVALID_ARGS');
19
+ }
20
+
21
+ const auth = loadPublishAuth(opts.config);
22
+ const client = createOpenPlatformClient(auth, opts.origin || DEFAULT_ORIGIN);
23
+ const detail = await client.post(`/developers/v2/block/detail/${appId}/${blockTypeId}`, {});
24
+ const block = extractBlock(detail);
25
+ const pkgId = extractDraftPkgId(detail, block);
26
+ if (!pkgId) throw commandError('Draft widget pkgId was not found in block detail.', 'E_INVALID_RESPONSE');
27
+
28
+ const updatePayload = { block: buildBlockUpdatePayload(block, pkgId, blockTypeId) };
29
+ if (!opts.dryRun) {
30
+ await client.post(`/developers/v2/block/detail/update/${appId}/${blockTypeId}`, updatePayload);
31
+ }
32
+ const versionChangeResponse = await client.post(`/developers/v1/app_version/change/${appId}`, {});
33
+ const versionChange = unwrapData(versionChangeResponse) || {};
34
+ const versionsResponse = await client.post(`/developers/v1/app_version/list/${appId}`, {});
35
+ const versions = extractVersionList(versionsResponse);
36
+ const appVersion = opts.version || getNextPatchVersion(versions);
37
+ const createPayload = buildCreateVersionPayload(
38
+ appVersion,
39
+ opts.changeLog,
40
+ getLatestVersion(versions),
41
+ versionChange
42
+ );
43
+
44
+ if (opts.dryRun) {
45
+ success({
46
+ dryRun: true,
47
+ appId,
48
+ blockTypeId,
49
+ pkgId,
50
+ appVersion,
51
+ updatePayload,
52
+ createPayload,
53
+ }, opts);
54
+ return;
55
+ }
56
+
57
+ const createResponse = await client.post(`/developers/v1/app_version/create/${appId}`, createPayload);
58
+ const appVersionId = extractAppVersionId(createResponse);
59
+ if (!appVersionId) throw commandError('Created app version id was not found in response.', 'E_INVALID_RESPONSE');
60
+
61
+ const publishResponse = await client.post(`/developers/v1/publish/commit/${appId}/${appVersionId}`, {});
62
+ success({
63
+ appId,
64
+ blockTypeId,
65
+ pkgId,
66
+ appVersion,
67
+ appVersionId,
68
+ published: true,
69
+ response: publishResponse,
70
+ }, opts);
71
+ }
72
+
73
+ function loadPublishAuth(configFile) {
74
+ const path = resolve(String(configFile || getWidgetPublishConfigPath()));
75
+ if (!existsSync(path)) {
76
+ throw commandError(`Widget publish config not found: ${path}`, 'E_NO_CONFIG');
77
+ }
78
+
79
+ let config;
80
+ try {
81
+ config = JSON.parse(readFileSync(path, 'utf8'));
82
+ } catch (error) {
83
+ throw commandError(`Invalid widget publish config: ${error.message}`, 'E_INVALID_CONFIG');
84
+ }
85
+
86
+ const cookie = String(config.cookie || '').trim();
87
+ const csrfToken = String(config.csrfToken || config.csrf_token || '').trim();
88
+ if (!cookie || !csrfToken) {
89
+ throw commandError('Widget publish config must contain cookie and csrfToken.', 'E_INVALID_CONFIG');
90
+ }
91
+ return {
92
+ cookie,
93
+ csrfToken,
94
+ timezoneOffset: Number.isFinite(Number(config.timezoneOffset)) ? Number(config.timezoneOffset) : -480,
95
+ locale: String(config.locale || 'zh-CN'),
96
+ };
97
+ }
98
+
99
+ function createOpenPlatformClient(auth, origin = DEFAULT_ORIGIN, fetchImpl = fetch) {
100
+ const baseUrl = String(origin || DEFAULT_ORIGIN).replace(/\/+$/, '');
101
+ return {
102
+ async post(path, body) {
103
+ const response = await fetchImpl(`${baseUrl}${path}`, {
104
+ method: 'POST',
105
+ headers: {
106
+ accept: '*/*',
107
+ 'accept-language': `${auth.locale},zh;q=0.9`,
108
+ 'cache-control': 'no-cache',
109
+ 'content-type': 'application/json',
110
+ cookie: auth.cookie,
111
+ origin: baseUrl,
112
+ pragma: 'no-cache',
113
+ referer: getRequestReferer(baseUrl, path),
114
+ 'x-csrf-token': auth.csrfToken,
115
+ 'x-timezone-offset': String(auth.timezoneOffset),
116
+ },
117
+ body: JSON.stringify(body || {}),
118
+ });
119
+ const text = await response.text();
120
+ const data = parseJson(text);
121
+ if (!response.ok || isApiFailure(data)) {
122
+ const reason = data?.msg || data?.message || `HTTP ${response.status}`;
123
+ throw commandError(
124
+ `Open Platform request failed at ${path}: ${reason}`,
125
+ response.status === 401 || response.status === 403 ? 'E_AUTH_FAILED' : 'E_REQUEST_FAILED'
126
+ );
127
+ }
128
+ return data ?? text;
129
+ },
130
+ };
131
+ }
132
+
133
+ function extractBlock(response) {
134
+ const data = unwrapData(response);
135
+ return data?.block || data?.detail?.block || data?.blockDetail?.block || data?.detail || data;
136
+ }
137
+
138
+ function extractDraftPkgId(response, block = {}) {
139
+ const candidates = [
140
+ response?.data?.blockVersions?.testVersion?.id,
141
+ response?.data?.blockVersions?.testVersion?.pkgId,
142
+ response?.data?.blockVersions?.draftVersion?.id,
143
+ response?.data?.blockVersions?.draftVersion?.pkgId,
144
+ block?.draftVersion?.pkgId,
145
+ block?.draftPkg?.pkgId,
146
+ block?.draftPackage?.pkgId,
147
+ block?.draft?.pkgId,
148
+ findDraftPkgId(response),
149
+ findValue(response, ['draftPkgId', 'draftPackageId']),
150
+ block?.pkgId,
151
+ ];
152
+ return String(candidates.find(Boolean) || '').trim();
153
+ }
154
+
155
+ function buildBlockUpdatePayload(block = {}, pkgId, blockTypeId) {
156
+ return {
157
+ pkgId: String(pkgId),
158
+ icon: block.icon || '',
159
+ i18nTitle: block.i18nTitle || {},
160
+ i18nDesc: block.i18nDesc || {},
161
+ status: block.status !== false,
162
+ blockTypeId: block.blockTypeId || block.blockTypeID || blockTypeId,
163
+ pkgUpdateType: Number.isFinite(Number(block.pkgUpdateType)) ? Number(block.pkgUpdateType) : 0,
164
+ docXHost: block.docXHost || block.docxHost || {},
165
+ };
166
+ }
167
+
168
+ function extractVersionList(response) {
169
+ const data = unwrapData(response);
170
+ if (Array.isArray(data)) return data;
171
+ for (const key of ['items', 'list', 'appVersions', 'versions']) {
172
+ if (Array.isArray(data?.[key])) return data[key];
173
+ }
174
+ return findArray(data, ['items', 'list', 'appVersions', 'versions']);
175
+ }
176
+
177
+ function getNextPatchVersion(versions) {
178
+ const parsed = versions
179
+ .map(item => String(item?.appVersion || item?.version || item?.versionName || item || ''))
180
+ .map(parseSemver)
181
+ .filter(Boolean)
182
+ .sort(compareSemver);
183
+ const latest = parsed.at(-1) || [1, 0, -1];
184
+ return `${latest[0]}.${latest[1]}.${latest[2] + 1}`;
185
+ }
186
+
187
+ function getLatestVersion(versions) {
188
+ return versions
189
+ .map(item => ({ item, version: parseSemver(item?.appVersion || item?.version || item?.versionName || item) }))
190
+ .filter(entry => entry.version)
191
+ .sort((a, b) => compareSemver(a.version, b.version))
192
+ .at(-1)?.item || {};
193
+ }
194
+
195
+ function buildCreateVersionPayload(
196
+ appVersion,
197
+ changeLog = '更新文档小组件版本',
198
+ latestVersion = {},
199
+ versionChange = {}
200
+ ) {
201
+ const shareConfig = versionChange.changeAppShareConfig?.b2cShareSplitConfigSuggest;
202
+ return {
203
+ appVersion: String(appVersion),
204
+ mobileDefaultAbility: versionChange.mobileDefaultAbility || latestVersion.mobileDefaultAbility || 'bot',
205
+ pcDefaultAbility: versionChange.pcDefaultAbility || latestVersion.pcDefaultAbility || 'bot',
206
+ changeLog: String(changeLog || '更新文档小组件版本'),
207
+ visibleSuggest: latestVersion.visibleSuggest || { departments: [], members: [], groups: [], isAll: 1 },
208
+ applyReasonConfig: versionChange.applyReasonConfig || latestVersion.applyReasonConfig || {
209
+ apiPrivilegeNeedReason: true,
210
+ contactPrivilegeNeedReason: true,
211
+ dataPrivilegeReasonMap: {},
212
+ visibleScopeNeedReason: true,
213
+ apiPrivilegeReasonMap: {},
214
+ contactPrivilegeReason: '',
215
+ isDataPrivilegeExpandMap: { vc: false },
216
+ visibleScopeReason: '',
217
+ dataPrivilegeNeedReason: true,
218
+ isAutoAudit: false,
219
+ isContactExpand: false,
220
+ },
221
+ b2cShareSplitConfigSuggest: shareConfig || latestVersion.b2cShareSplitConfigSuggest || {
222
+ b2cGroupChatShareEnable: false,
223
+ b2cP2PChatShareEnable: false,
224
+ b2cP2PChatNeedAudit: false,
225
+ },
226
+ autoPublish: false,
227
+ blackVisibleSuggest: latestVersion.blackVisibleSuggest || { departments: [], members: [], groups: [], isAll: 0 },
228
+ };
229
+ }
230
+
231
+ function extractAppVersionId(response) {
232
+ const data = unwrapData(response);
233
+ return String(
234
+ data?.appVersionId
235
+ || data?.versionId
236
+ || data?.appVersion?.id
237
+ || data?.version?.id
238
+ || data?.id
239
+ || findValue(data, ['appVersionId', 'versionId'])
240
+ || ''
241
+ ).trim();
242
+ }
243
+
244
+ function unwrapData(value) {
245
+ let current = value;
246
+ for (let i = 0; i < 3; i++) {
247
+ if (!current || typeof current !== 'object' || Array.isArray(current) || !('data' in current)) break;
248
+ current = current.data;
249
+ }
250
+ return current;
251
+ }
252
+
253
+ function findValue(value, names, predicate = () => true, path = '') {
254
+ if (!value || typeof value !== 'object') return '';
255
+ for (const [name, child] of Object.entries(value)) {
256
+ const childPath = path ? `${path}.${name}` : name;
257
+ if (names.includes(name) && child != null && predicate({ name, path: childPath })) return child;
258
+ const nested = findValue(child, names, predicate, childPath);
259
+ if (nested) return nested;
260
+ }
261
+ return '';
262
+ }
263
+
264
+ function findDraftPkgId(value, path = '') {
265
+ if (!value || typeof value !== 'object') return '';
266
+ const marker = [
267
+ path,
268
+ value.status,
269
+ value.versionStatus,
270
+ value.pkgStatus,
271
+ value.type,
272
+ value.name,
273
+ ].filter(Boolean).join(' ');
274
+ if (/draft|草稿/i.test(marker) && value.pkgId) return value.pkgId;
275
+ for (const [name, child] of Object.entries(value)) {
276
+ const found = findDraftPkgId(child, path ? `${path}.${name}` : name);
277
+ if (found) return found;
278
+ }
279
+ return '';
280
+ }
281
+
282
+ function findArray(value, names) {
283
+ if (!value || typeof value !== 'object') return [];
284
+ for (const [name, child] of Object.entries(value)) {
285
+ if (names.includes(name) && Array.isArray(child)) return child;
286
+ const found = findArray(child, names);
287
+ if (found.length) return found;
288
+ }
289
+ return [];
290
+ }
291
+
292
+ function getRequestReferer(baseUrl, path) {
293
+ const blockMatch = path.match(/\/block\/detail(?:\/update)?\/([^/]+)\/([^/]+)/);
294
+ if (blockMatch) return `${baseUrl}/app/${blockMatch[1]}/blocks/${blockMatch[2]}`;
295
+ const publishMatch = path.match(/\/publish\/commit\/([^/]+)\/([^/]+)/);
296
+ if (publishMatch) return `${baseUrl}/app/${publishMatch[1]}/version/${publishMatch[2]}`;
297
+ const appMatch = path.match(/\/app_version\/(?:list|create)\/([^/]+)/);
298
+ if (appMatch) return `${baseUrl}/app/${appMatch[1]}/version/create`;
299
+ return `${baseUrl}/`;
300
+ }
301
+
302
+ function parseSemver(value) {
303
+ const match = String(value || '').trim().match(/^(\d+)\.(\d+)\.(\d+)$/);
304
+ return match ? match.slice(1).map(Number) : null;
305
+ }
306
+
307
+ function compareSemver(a, b) {
308
+ return a[0] - b[0] || a[1] - b[1] || a[2] - b[2];
309
+ }
310
+
311
+ function parseJson(text) {
312
+ try { return JSON.parse(text); } catch (_) { return null; }
313
+ }
314
+
315
+ function isApiFailure(data) {
316
+ if (!data || typeof data !== 'object') return false;
317
+ if ('code' in data && ![0, '0', 200, '200'].includes(data.code)) return true;
318
+ return data.success === false;
319
+ }
320
+
321
+ function commandError(message, code) {
322
+ const error = new Error(message);
323
+ error.code = code;
324
+ return error;
325
+ }
326
+
327
+ module.exports = {
328
+ run,
329
+ loadPublishAuth,
330
+ createOpenPlatformClient,
331
+ extractBlock,
332
+ extractDraftPkgId,
333
+ buildBlockUpdatePayload,
334
+ extractVersionList,
335
+ getNextPatchVersion,
336
+ getLatestVersion,
337
+ buildCreateVersionPayload,
338
+ extractAppVersionId,
339
+ };
package/src/index.js CHANGED
@@ -15,6 +15,7 @@ module.exports = {
15
15
  feedback: require('./commands/feedback'),
16
16
  performance: require('./commands/performance'),
17
17
  perf: require('./commands/performance'),
18
+ widgetPublish: require('./commands/widget-publish'),
18
19
  extractCookie: require('./commands/extract-cookie'),
19
20
  skill: require('./commands/skill'),
20
21
  version: require('./commands/version'),
package/src/lib/config.js CHANGED
@@ -8,6 +8,7 @@ const DEFAULT_BASE_URL = 'https://magic.solutionsuite.cn';
8
8
  const CONFIG_DIR = path.join(os.homedir(), '.magic-builder');
9
9
  const APPS_CONFIG_FILE = 'magic-apps.json';
10
10
  const LEGACY_APPS_CONFIG_FILE = '.magic-apps.json';
11
+ const WIDGET_PUBLISH_CONFIG_FILE = 'widget-publish.json';
11
12
 
12
13
  function normalizeMagicBaseUrl(value) {
13
14
  const raw = String(value || DEFAULT_BASE_URL).trim().replace(/\/+$/, '');
@@ -23,6 +24,10 @@ function getAppsConfigPath() {
23
24
  return path.join(CONFIG_DIR, APPS_CONFIG_FILE);
24
25
  }
25
26
 
27
+ function getWidgetPublishConfigPath() {
28
+ return path.join(CONFIG_DIR, WIDGET_PUBLISH_CONFIG_FILE);
29
+ }
30
+
26
31
  function loadAppsConfig() {
27
32
  const p = fs.existsSync(getAppsConfigPath())
28
33
  ? getAppsConfigPath()
@@ -38,4 +43,13 @@ function saveAppsConfig(config) {
38
43
  fs.writeFileSync(getAppsConfigPath(), JSON.stringify(config, null, 2));
39
44
  }
40
45
 
41
- module.exports = { DEFAULT_BASE_URL, CONFIG_DIR, normalizeMagicBaseUrl, getBaseUrl, getAppsConfigPath, loadAppsConfig, saveAppsConfig };
46
+ module.exports = {
47
+ DEFAULT_BASE_URL,
48
+ CONFIG_DIR,
49
+ normalizeMagicBaseUrl,
50
+ getBaseUrl,
51
+ getAppsConfigPath,
52
+ getWidgetPublishConfigPath,
53
+ loadAppsConfig,
54
+ saveAppsConfig,
55
+ };
package/src/lib/help.js CHANGED
@@ -21,7 +21,8 @@ COMMANDS:
21
21
  link Generate Magic share links
22
22
  doc Create or append Feishu Doc HTML Box apps
23
23
  feedback Create Magic feedback records
24
- performance Submit Markdown content to a People performance draft (alias: perf)
24
+ performance Write or formally submit a People performance review (alias: perf)
25
+ widget-publish Publish a document widget draft as a new app version
25
26
  extract-cookie Extract Cookie data from a curl command
26
27
  skill Check or update the Magic Builder skill package
27
28
  version Show CLI version
@@ -48,6 +49,7 @@ EXAMPLES:
48
49
  magic-builder doc create --html app.html --title "Demo"
49
50
  magic-builder feedback create --feedback "打不开页面"
50
51
  magic-builder performance --cookie ./cookie.txt --markdown ./performance.md
52
+ magic-builder widget-publish --app-id cli_xxx --block-type-id blk_xxx
51
53
  magic-builder extract-cookie --curl-file ./request.curl
52
54
  magic-builder skill install
53
55
  magic-builder skill check-update
@@ -108,9 +110,13 @@ COMMANDS:
108
110
  feedback create --feedback-file <file> [--dry-run]
109
111
 
110
112
  performance --review-url <url> --cookie <content|file> --markdown <content|file|url> [--dry-run]
113
+ perf submit --review-url <url> --cookie-file <file> --template-group-id <id> [--dry-run|--yes]
111
114
  perf --review-url <url> --cookie-file <file> --markdown-file <file> [--dry-run]
112
115
 
113
- extract-cookie --curl <content|file> [--out <file>|--out-dir <dir>]
116
+ widget-publish --app-id <id> --block-type-id <id> [--version <x.y.z>] [--change-log <text>]
117
+ [--config <file>] [--dry-run]
118
+
119
+ extract-cookie --curl <content|file> [--out <file>|--out-dir <dir>] [--widget-publish]
114
120
 
115
121
  skill install [--environment auto|local|cloud] [--skills-root <dir>]
116
122
  skill check-update [--environment auto|local|cloud] [--skills-root <dir>]
@@ -198,6 +204,8 @@ SYNTAX:
198
204
 
199
205
  SYNTAX:
200
206
  magic-builder performance --review-url <url> --cookie <content|file> --markdown <content|file|url>
207
+ magic-builder perf submit --review-url <url> --cookie-file <file> --template-group-id <id> --dry-run
208
+ magic-builder perf submit --review-url <url> --cookie-file <file> --template-group-id <id> --yes
201
209
  magic-builder perf --review-url <url> --cookie-file <file> --markdown-file <file>
202
210
 
203
211
  COOKIE:
@@ -210,6 +218,9 @@ DRAFT:
210
218
  OPTIONS:
211
219
  --dry-run Print the generated payload without sending it
212
220
  --review-url <url> People performance review URL (recommended)
221
+ --template-group-id <id> Template group id (only needed when absent from the draft)
222
+ --submit-endpoint <url> Override the formal submission endpoint
223
+ --yes Confirm the irreversible formal submission
213
224
  --draft-version <number> Override the version loaded from the current draft
214
225
  --review-id <id> Override the performance review id
215
226
  --form-id <id> Override the invitation/form id used by the draft key
@@ -224,12 +235,31 @@ OPTIONS:
224
235
  perf: `@HELP magic-builder/performance
225
236
 
226
237
  Alias of "magic-builder performance".
238
+ `,
239
+ 'widget-publish': `@HELP magic-builder/widget-publish
240
+
241
+ SYNTAX:
242
+ magic-builder widget-publish --app-id <id> --block-type-id <id>
243
+ [--version <x.y.z>] [--change-log <text>] [--config <file>] [--dry-run]
244
+
245
+ AUTH CONFIG:
246
+ Defaults to ~/.magic-builder/widget-publish.json:
247
+ {
248
+ "cookie": "open_locale=zh-CN; session=...",
249
+ "csrfToken": "...",
250
+ "timezoneOffset": -480
251
+ }
252
+
253
+ FLOW:
254
+ Load widget detail and latest uploaded pkgId, update the widget package, load the app's
255
+ version defaults, calculate the next version, create it, then submit publishing.
227
256
  `,
228
257
  'extract-cookie': `@HELP magic-builder/extract-cookie
229
258
 
230
259
  SYNTAX:
231
260
  magic-builder extract-cookie --curl <content|file> [--out <file>]
232
261
  magic-builder extract-cookie --curl-file <file> [--out-dir <dir>]
262
+ magic-builder extract-cookie --curl-file <file> --widget-publish
233
263
  cat request.curl | magic-builder extract-cookie [--out <file>]
234
264
 
235
265
  OPTIONS:
@@ -237,6 +267,7 @@ OPTIONS:
237
267
  --curl-file <file> Read curl command from a local file
238
268
  --out <file> Output file (default: ./cookie.txt)
239
269
  --out-dir <dir> Output directory; created when missing
270
+ --widget-publish Write Cookie and x-csrf-token to the widget publish config
240
271
  `,
241
272
  skill: `@HELP magic-builder/skill
242
273