magic-builder 1.2.0 → 1.3.1

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
@@ -119,6 +120,8 @@ magic-builder file list --title logo
119
120
  magic-builder file delete --id <id>
120
121
  ```
121
122
 
123
+ 文件上传采用 `sign -> PUT -> audit confirm` 流程。预签名成功后会返回 `audit_id`,CLI 在对象上传完成后自动确认审计;确认失败时命令返回失败,重复执行确认不会新增审计记录。
124
+
122
125
  Generate a Magic link:
123
126
 
124
127
  ```bash
@@ -145,6 +148,27 @@ magic-builder performance --review-url <people-review-url> --markdown ./performa
145
148
 
146
149
  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
150
 
151
+ Update the text fields in the self-review draft while preserving both rating fields and any omitted text:
152
+
153
+ ```bash
154
+ magic-builder perf self-review --review-url <people-review-url> \
155
+ --good "What went well" \
156
+ --improve-file ./improvements.md \
157
+ --values-comment-file ./values-comment.md
158
+ magic-builder perf self-review --review-url <people-review-url> --good-file ./good.md --dry-run
159
+ ```
160
+
161
+ Each field can be provided as literal text or through its matching `--*-file` option. At least one field is required. The command reloads the latest `self_review` draft before every run and writes by default; `--dry-run` prints the payload without saving it.
162
+
163
+ Formally submit the latest draft to the current review stage:
164
+
165
+ ```bash
166
+ magic-builder perf submit --review-url <people-review-url> --template-group-id <id> --dry-run
167
+ magic-builder perf submit --review-url <people-review-url> --template-group-id <id> --yes
168
+ ```
169
+
170
+ 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.
171
+
148
172
  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
173
 
150
174
  ```bash
@@ -156,10 +180,48 @@ cat ./request.curl | magic-builder extract-cookie --out-dir ./secrets
156
180
  Create or append a Feishu document HTML Box:
157
181
 
158
182
  ```bash
159
- magic-builder doc create --html app.html --title "Demo"
183
+ magic-builder doc create --html app.html --title "Demo" # defaults to --edition external
160
184
  magic-builder doc append --html app.html --doc-token <docx-token>
185
+ magic-builder doc create --html app.html --title "ByteDance Demo" --edition bytedance
161
186
  ```
162
187
 
188
+ Publish a document widget draft as a new app version:
189
+
190
+ ```bash
191
+ magic-builder widget-publish \
192
+ --app-id cli_a98afe875979500d \
193
+ --block-type-id blk_6900429af84180025ce76527 \
194
+ --change-log "修复 HTML Box 高度反馈循环"
195
+ ```
196
+
197
+ The command reads Open Platform browser authentication from
198
+ `~/.magic-builder/widget-publish.json` by default:
199
+
200
+ ```json
201
+ {
202
+ "cookie": "open_locale=zh-CN; session=...",
203
+ "csrfToken": "...",
204
+ "timezoneOffset": -480
205
+ }
206
+ ```
207
+
208
+ Keep this file local and set its mode to `0600`. Use `--config <file>` to select
209
+ another file. The command fetches the widget detail, selects the latest uploaded
210
+ test package before the existing draft/current package, updates the widget pkgId,
211
+ increments the latest patch version, creates the app
212
+ version with the application's actual default abilities and audit settings, and
213
+ submits it for publishing. Use `--version` to override the generated
214
+ version or `--dry-run` to inspect the payload without updating or publishing.
215
+
216
+ You can generate the local config directly from a browser-copied curl command:
217
+
218
+ ```bash
219
+ magic-builder extract-cookie --curl-file ./widget-request.curl --widget-publish
220
+ ```
221
+
222
+ This extracts only the Cookie and `x-csrf-token`, writes them to the default
223
+ widget publish config with mode `0600`, and does not print either secret.
224
+
163
225
  Install or update the Magic Builder Codex skill:
164
226
 
165
227
  ```bash
@@ -168,6 +230,10 @@ magic-builder skill check-update
168
230
  magic-builder skill update
169
231
  ```
170
232
 
233
+ Skills are installed to `~/.agents/skills` by default so Codex, Trae, Doubao,
234
+ and other compatible agents can share them. Use `--skills-root <dir>` to
235
+ override the destination.
236
+
171
237
  Check local environment:
172
238
 
173
239
  ```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.1",
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, getPerformanceConfigPath } = 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,8 @@ 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(),
14
+ performance_config_file: getPerformanceConfigPath(),
13
15
  token_file: TOKEN_FILE,
14
16
  apps_config_file: getAppsConfigPath(),
15
17
  token: showToken() || '',
@@ -4,7 +4,10 @@ const { readFileSync } = require('fs');
4
4
  const { execFileSync } = require('child_process');
5
5
  const { success, fail } = require('../lib/output');
6
6
 
7
- const HTML_BOX_COMPONENT_TYPE_ID = 'blk_6900429af84180025ce76527';
7
+ const HTML_BOX_COMPONENT_TYPE_IDS = {
8
+ external: 'blk_6358a421bca0001c190a9805',
9
+ bytedance: 'blk_6900429af84180025ce76527',
10
+ };
8
11
 
9
12
  async function run(args, opts) {
10
13
  const sub = args[0];
@@ -16,14 +19,17 @@ async function run(args, opts) {
16
19
  const html = readFileSync(opts.html, 'utf8');
17
20
  const record = buildRecord(opts, html);
18
21
  const identity = resolveIdentity(opts.as);
22
+ const { edition, componentTypeId } = resolveEdition(opts.edition);
19
23
  const created = sub === 'append'
20
24
  ? { docToken: opts.docToken, url: `https://bytedance.larkoffice.com/docx/${opts.docToken}` }
21
25
  : createDocument(opts.title, opts.summary, identity);
22
- const htmlBoxBlockId = insertHtmlBox(created.docToken, record, identity);
26
+ const htmlBoxBlockId = insertHtmlBox(created.docToken, record, identity, componentTypeId);
23
27
 
24
28
  success({
25
29
  ok: true,
26
30
  identity,
31
+ edition,
32
+ component_type_id: componentTypeId,
27
33
  doc_token: created.docToken,
28
34
  doc_url: created.url,
29
35
  html_box_block_id: htmlBoxBlockId,
@@ -51,6 +57,16 @@ function resolveIdentity(requestedIdentity) {
51
57
  return 'bot';
52
58
  }
53
59
 
60
+ function resolveEdition(requestedEdition) {
61
+ const edition = requestedEdition || 'external';
62
+ if (!Object.prototype.hasOwnProperty.call(HTML_BOX_COMPONENT_TYPE_IDS, edition)) {
63
+ const error = new Error('--edition must be external or bytedance');
64
+ error.code = 'E_INVALID_ARGS';
65
+ throw error;
66
+ }
67
+ return { edition, componentTypeId: HTML_BOX_COMPONENT_TYPE_IDS[edition] };
68
+ }
69
+
54
70
  function createDocument(title, summary, identity) {
55
71
  const intro = summary || `这个妙笔应用以交互式网页的形式展示「${title}」,可直接在文档中查看和体验。`;
56
72
  const content = `<title>${escapeXml(title)}</title><p>${escapeXml(intro)}</p>`;
@@ -73,7 +89,7 @@ function createDocument(title, summary, identity) {
73
89
  };
74
90
  }
75
91
 
76
- function insertHtmlBox(docToken, recordObject, identity) {
92
+ function insertHtmlBox(docToken, recordObject, identity, componentTypeId) {
77
93
  const record = JSON.stringify(recordObject);
78
94
  const resp = runJson([
79
95
  'api',
@@ -87,15 +103,18 @@ function insertHtmlBox(docToken, recordObject, identity) {
87
103
  block_type: 40,
88
104
  add_ons: {
89
105
  component_id: '',
90
- component_type_id: HTML_BOX_COMPONENT_TYPE_ID,
106
+ component_type_id: componentTypeId,
91
107
  record,
92
108
  },
93
109
  }],
94
110
  index: -1,
95
111
  }),
96
112
  ]);
97
- if (resp?.code !== 0) throw new Error(`failed to insert HTML Box: ${JSON.stringify(resp)}`);
113
+ if (!isSuccessfulLarkResponse(resp)) throw new Error(`failed to insert HTML Box: ${JSON.stringify(resp)}`);
98
114
  const block = resp?.data?.children?.[0];
115
+ if (block?.add_ons?.component_type_id && block.add_ons.component_type_id !== componentTypeId) {
116
+ throw new Error(`inserted HTML Box component type mismatch: ${JSON.stringify(block?.add_ons)}`);
117
+ }
99
118
  const returnedRecord = parseRecordValue(block?.add_ons?.record);
100
119
  if (!recordIncludes(recordObject, returnedRecord)) {
101
120
  throw new Error(`inserted HTML Box record mismatch: ${JSON.stringify(block?.add_ons)}`);
@@ -103,6 +122,10 @@ function insertHtmlBox(docToken, recordObject, identity) {
103
122
  return block?.block_id || '';
104
123
  }
105
124
 
125
+ function isSuccessfulLarkResponse(resp) {
126
+ return resp?.ok === true || resp?.code === 0;
127
+ }
128
+
106
129
  function runJson(commandArgs) {
107
130
  const out = execFileSync('lark-cli', commandArgs, {
108
131
  encoding: 'utf8',
@@ -149,4 +172,4 @@ function escapeXml(value) {
149
172
  .replaceAll("'", '&apos;');
150
173
  }
151
174
 
152
- module.exports = { run };
175
+ module.exports = { HTML_BOX_COMPONENT_TYPE_IDS, resolveEdition, isSuccessfulLarkResponse, run };
@@ -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 };
@@ -17,7 +17,103 @@ async function run(args, opts) {
17
17
  if (sub === 'list') return list(opts);
18
18
  if (sub === 'export') return exportApps(opts);
19
19
  if (sub === 'delete') return deletePage(opts);
20
- fail('Usage: magic-builder page <publish|list|export|delete>', 'E_INVALID_ARGS');
20
+ if (sub === 'collaborators') return collaborators(args.slice(1), opts);
21
+ if (sub === 'access') return access(args.slice(1), opts);
22
+ fail('Usage: magic-builder page <publish|list|export|delete|collaborators|access>', 'E_INVALID_ARGS');
23
+ }
24
+
25
+ async function authenticatedRequest(opts, endpoint, requestOpts = {}) {
26
+ let token;
27
+ try { token = await getToken(opts); } catch (e) { fail(e.message, 'E_NO_TOKEN', 2); }
28
+ try { return await request(`${getBaseUrl(opts)}${endpoint}`, { token, ...requestOpts }); }
29
+ catch (e) { failFromHttpError(e); }
30
+ }
31
+
32
+ function requiredPageId(opts, command) {
33
+ const id = normalizeId(opts.id);
34
+ if (!id) fail(`Missing --id. Usage: magic-builder page ${command} --id <id>`, 'E_INVALID_ARGS');
35
+ return id;
36
+ }
37
+
38
+ function readJsonFile(file) {
39
+ if (!file) return null;
40
+ if (!fs.existsSync(file)) fail(`File not found: ${file}`, 'E_NOT_FOUND');
41
+ try { return JSON.parse(fs.readFileSync(file, 'utf8')); }
42
+ catch (e) { fail(`Invalid JSON file: ${e.message}`, 'E_INVALID_ARGS'); }
43
+ }
44
+
45
+ function parseIds(value, pattern, label, allowEmpty = false) {
46
+ const raw = Array.isArray(value) ? value : String(value ?? '').split(/[,,;;\n\r\t|]/);
47
+ const values = Array.from(new Set(raw.map((item) => String(item || '').trim()).filter(Boolean)));
48
+ const invalid = values.filter((id) => !pattern.test(id));
49
+ if (invalid.length) fail(`Invalid ${label}: ${invalid.join(', ')}`, 'E_INVALID_ARGS');
50
+ if (!allowEmpty && !values.length) fail(`Missing ${label}`, 'E_INVALID_ARGS');
51
+ return values;
52
+ }
53
+
54
+ async function collaborators(args, opts) {
55
+ const action = String(args[0] || '').toLowerCase();
56
+ const id = requiredPageId(opts, `collaborators ${action || '<list|add|remove|set>'}`);
57
+ const endpoint = `/api/html-box/${encodeURIComponent(id)}/collaborators`;
58
+ if (action === 'list') {
59
+ const res = await authenticatedRequest(opts, endpoint);
60
+ if (res.code !== 0) fail(res.msg || 'Collaborator query failed', 'E_COLLABORATORS_FAILED');
61
+ return success(res.data || res, opts);
62
+ }
63
+ if (!['add', 'remove', 'set'].includes(action)) fail('Usage: magic-builder page collaborators <list|add|remove|set> --id <id>', 'E_INVALID_ARGS');
64
+ const file = readJsonFile(opts.file);
65
+ const openIds = parseIds(file?.open_ids ?? file?.openIds ?? opts.openIds, /^ou_[A-Za-z0-9_-]+$/, 'open_id');
66
+ const res = await authenticatedRequest(opts, endpoint, { method: 'PATCH', body: { operation: action, open_ids: openIds } });
67
+ if (res.code !== 0) fail(res.msg || 'Collaborator update failed', 'E_COLLABORATORS_FAILED');
68
+ success(res.data || res, opts);
69
+ }
70
+
71
+ function parseBoolean(value, label) {
72
+ if (typeof value === 'boolean') return value;
73
+ const normalized = String(value ?? '').trim().toLowerCase();
74
+ if (['true', '1', 'yes', 'on', 'enabled'].includes(normalized)) return true;
75
+ if (['false', '0', 'no', 'off', 'disabled'].includes(normalized)) return false;
76
+ fail(`${label} must be true or false`, 'E_INVALID_ARGS');
77
+ }
78
+
79
+ async function access(args, opts) {
80
+ const action = String(args[0] || '').toLowerCase();
81
+ const id = requiredPageId(opts, `access ${action || '<get|enable|disable|require-login|users|groups|set>'}`);
82
+ const endpoint = `/api/html-box/${encodeURIComponent(id)}/access`;
83
+ if (action === 'get') {
84
+ const res = await authenticatedRequest(opts, endpoint);
85
+ if (res.code !== 0) fail(res.msg || 'Access query failed', 'E_ACCESS_FAILED');
86
+ return success(res.data || res, opts);
87
+ }
88
+ let body;
89
+ if (action === 'enable' || action === 'disable') {
90
+ body = { access_control_enabled: action === 'enable' };
91
+ } else if (action === 'require-login') {
92
+ if (opts.enabled === undefined) fail('Missing --enabled true|false', 'E_INVALID_ARGS');
93
+ body = { require_feishu_login: parseBoolean(opts.enabled, '--enabled') };
94
+ } else if (action === 'users' || action === 'groups') {
95
+ const operation = String(args[1] || '').toLowerCase();
96
+ if (!['add', 'remove', 'set'].includes(operation)) fail(`Usage: magic-builder page access ${action} <add|remove|set> --id <id>`, 'E_INVALID_ARGS');
97
+ const file = readJsonFile(opts.file);
98
+ const isUsers = action === 'users';
99
+ const source = isUsers ? (file?.open_ids ?? file?.user_open_ids ?? opts.openIds) : (file?.chat_ids ?? file?.group_chat_ids ?? opts.chatIds);
100
+ const ids = parseIds(source, isUsers ? /^ou_[A-Za-z0-9_-]+$/ : /^oc_[A-Za-z0-9_-]+$/, isUsers ? 'open_id' : 'chat_id', operation === 'set');
101
+ body = { operation, target: action, [isUsers ? 'access_users' : 'access_groups']: ids.map((value) => ({ id: value })) };
102
+ } else if (action === 'set') {
103
+ const file = readJsonFile(opts.file);
104
+ if (!file || typeof file !== 'object' || Array.isArray(file)) fail('Missing --file <access.json>', 'E_INVALID_ARGS');
105
+ body = {};
106
+ if (Object.prototype.hasOwnProperty.call(file, 'access_control_enabled')) body.access_control_enabled = parseBoolean(file.access_control_enabled, 'access_control_enabled');
107
+ if (Object.prototype.hasOwnProperty.call(file, 'require_feishu_login')) body.require_feishu_login = parseBoolean(file.require_feishu_login, 'require_feishu_login');
108
+ if (Object.prototype.hasOwnProperty.call(file, 'user_open_ids')) body.access_users = parseIds(file.user_open_ids, /^ou_[A-Za-z0-9_-]+$/, 'open_id', true).map((value) => ({ id: value }));
109
+ if (Object.prototype.hasOwnProperty.call(file, 'group_chat_ids')) body.access_groups = parseIds(file.group_chat_ids, /^oc_[A-Za-z0-9_-]+$/, 'chat_id', true).map((value) => ({ id: value }));
110
+ if (!Object.keys(body).length) fail('Access JSON contains no supported settings', 'E_INVALID_ARGS');
111
+ } else {
112
+ fail('Usage: magic-builder page access <get|enable|disable|require-login|users|groups|set> --id <id>', 'E_INVALID_ARGS');
113
+ }
114
+ const res = await authenticatedRequest(opts, endpoint, { method: 'PATCH', body });
115
+ if (res.code !== 0) fail(res.msg || 'Access update failed', 'E_ACCESS_FAILED');
116
+ success(res.data || res, opts);
21
117
  }
22
118
 
23
119
  async function publish(args, opts) {
@@ -39,7 +135,9 @@ async function publish(args, opts) {
39
135
  const existingId = opts.id || config[relPath]?.id || config[relPath]?.remoteId;
40
136
 
41
137
  const body = { html, title };
138
+ if (opts.customId !== undefined) body.custom_id = String(opts.customId).trim();
42
139
  if (opts.openSource) body.is_open_source = true;
140
+ if (opts.comments !== undefined) body.comments_enabled = opts.comments === true || String(opts.comments).toLowerCase() !== 'false';
43
141
 
44
142
  if (!opts.quiet) process.stderr.write(`${existingId ? 'Updating' : 'Publishing'} ${relPath}... `);
45
143
 
@@ -56,13 +154,17 @@ async function publish(args, opts) {
56
154
  const data = res.data || res;
57
155
  const id = normalizeId(data.id || data.record_id || existingId);
58
156
  const htmlBoxUrl = data.html_box_url || `${baseUrl}/html-box/${id}`;
157
+ const appUrl = data.app_url || `${baseUrl}/app/${id}`;
158
+ const url = data.url || (data.runtime === 'html-box' ? htmlBoxUrl : appUrl);
59
159
  const dashboardUrl = data.dashboard_url || `${baseUrl}/dashboard/${id}`;
60
160
 
61
161
  config[relPath] = {
62
162
  id,
63
163
  remoteId: id,
64
164
  title,
165
+ customId: opts.customId !== undefined ? String(opts.customId).trim() : (config[relPath]?.customId || ''),
65
166
  urls: {
167
+ app: appUrl,
66
168
  html_box: htmlBoxUrl,
67
169
  dashboard: dashboardUrl,
68
170
  panel: data.panel_url || '',
@@ -76,6 +178,10 @@ async function publish(args, opts) {
76
178
  success({
77
179
  id,
78
180
  title,
181
+ custom_id: data.custom_id || body.custom_id || '',
182
+ url,
183
+ runtime: data.runtime || (url === htmlBoxUrl ? 'html-box' : 'app'),
184
+ app_url: appUrl,
79
185
  html_box_url: htmlBoxUrl,
80
186
  dashboard_url: dashboardUrl,
81
187
  panel_url: data.panel_url || '',
@@ -273,4 +379,4 @@ function formatHtmlLimitMessage(html) {
273
379
  return `HTML exceeds publish limit (${HTML_LIMIT} characters; ${HTML_CODE_FIELD_COUNT} HTML code fields x ${HTML_CODE_FIELD_LIMIT} characters). Current: ${html.length}; over by: ${overflow}. ${hint}`;
274
380
  }
275
381
 
276
- module.exports = { run };
382
+ module.exports = { run, parseIds, parseBoolean };