magic-builder 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,320 @@
1
+ 'use strict';
2
+
3
+ const { existsSync, readFileSync } = require('fs');
4
+ const { resolve } = require('path');
5
+ const { success, fail } = require('../lib/output');
6
+
7
+ const DEFAULT_TARGET_URL = 'https://bytedance.larkoffice.com/wiki/Ia5xwTdUmiQu8skc3C3cSQgEnmb?table=tblyfZ7z5J0Ujqs4&view=vewql5Ifjo';
8
+ const DEFAULT_HOST = 'https://open.feishu.cn';
9
+ const ALLOWED_OPENAPI_HOSTS = new Set(['open.feishu.cn', 'open.larksuite.com']);
10
+
11
+ let cachedToken = '';
12
+ let cachedTokenExpiresAt = 0;
13
+ let authConfig = {
14
+ openapiHost: DEFAULT_HOST,
15
+ appId: '',
16
+ appSecret: '',
17
+ };
18
+
19
+ async function run(args, opts) {
20
+ const sub = args[0];
21
+ if (sub !== 'create') fail('Usage: magic-builder feedback create --feedback <text>', 'E_INVALID_ARGS');
22
+
23
+ const fileConfig = loadEnvJson();
24
+ configureAuth(opts, fileConfig);
25
+ const stdin = readStdin();
26
+ const feedback = firstNonEmpty(
27
+ opts.feedback,
28
+ opts.feedbackFile ? readFileSync(resolve(opts.feedbackFile), 'utf8') : '',
29
+ stdin,
30
+ );
31
+ if (!feedback) fail('Missing feedback text. Use --feedback, --feedback-file, or stdin.', 'E_INVALID_ARGS');
32
+
33
+ const title = firstNonEmpty(opts.title, inferTitle(feedback));
34
+ const summary = firstNonEmpty(opts.summary, feedback);
35
+ const category = firstNonEmpty(opts.category, inferCategory(`${title}\n${summary}\n${feedback}`));
36
+ const priority = firstNonEmpty(opts.priority, inferPriority(`${title}\n${summary}\n${feedback}`, category));
37
+
38
+ const parsedTarget = parseTargetUrl(opts.targetUrl);
39
+ const tableId = firstNonEmpty(opts.tableId, parsedTarget.tableId);
40
+ const viewId = firstNonEmpty(opts.viewId, parsedTarget.viewId);
41
+ const appToken = firstNonEmpty(opts.appToken, parsedTarget.appToken) || await resolveWikiToken(parsedTarget.wikiToken);
42
+ if (!appToken) fail('Unable to resolve bitable app token from target URL. Pass --app-token explicitly.', 'E_INVALID_ARGS');
43
+ if (!tableId) fail('Missing table id. Pass --table-id explicitly.', 'E_INVALID_ARGS');
44
+
45
+ const data = {
46
+ title,
47
+ summary,
48
+ feedback,
49
+ category,
50
+ priority,
51
+ status: opts.status || '待处理',
52
+ source: opts.source || '飞书用户反馈',
53
+ product: opts.product || '妙笔',
54
+ reporterName: opts.reporterName,
55
+ reporterOpenId: opts.reporterOpenId,
56
+ sessionId: opts.sessionId,
57
+ sourceUrl: opts.sourceUrl,
58
+ createdAt: new Date().toISOString(),
59
+ };
60
+
61
+ const fieldDefs = await listFields(appToken, tableId);
62
+ const fields = buildFieldPayload(fieldDefs, data);
63
+ if (!Object.keys(fields).length) {
64
+ fail(`No matching writable fields found. Available fields: ${fieldDefs.map((x) => x.field_name || x.name).join(', ')}`, 'E_FEEDBACK_FAILED');
65
+ }
66
+
67
+ const result = { targetUrl: parsedTarget.raw, appToken, tableId, viewId, fields };
68
+ if (opts.dryRun) {
69
+ success({ dryRun: true, ...result }, opts);
70
+ return;
71
+ }
72
+
73
+ const created = await createRecord(appToken, tableId, fields);
74
+ success({
75
+ ...result,
76
+ recordId: created?.data?.record?.record_id || created?.data?.record_id || '',
77
+ response: created,
78
+ }, opts);
79
+ }
80
+
81
+ function loadEnvJson() {
82
+ const config = {};
83
+ for (const p of [resolve(process.cwd(), 'env.json'), resolve(process.cwd(), '.env.json')]) {
84
+ if (!existsSync(p)) continue;
85
+ const json = JSON.parse(readFileSync(p, 'utf8'));
86
+ for (const [key, value] of Object.entries(json)) {
87
+ if (config[key] === undefined && value !== undefined && value !== null) {
88
+ config[key] = typeof value === 'string' ? value : JSON.stringify(value);
89
+ }
90
+ }
91
+ }
92
+ return config;
93
+ }
94
+
95
+ function readStdin() {
96
+ if (process.stdin.isTTY) return '';
97
+ return readFileSync(0, 'utf8');
98
+ }
99
+
100
+ function getText(value) {
101
+ return String(value || '').trim();
102
+ }
103
+
104
+ function firstNonEmpty(...values) {
105
+ return values.map(getText).find(Boolean) || '';
106
+ }
107
+
108
+ function compactText(value) {
109
+ return getText(value)
110
+ .replace(/<at\b[^>]*>.*?<\/at>/g, '')
111
+ .replace(/@\S+/g, '')
112
+ .replace(/https?:\/\/\S+/g, '')
113
+ .replace(/\s+/g, ' ')
114
+ .trim();
115
+ }
116
+
117
+ function clip(value, max) {
118
+ const text = getText(value);
119
+ return text.length > max ? `${text.slice(0, max - 1)}...` : text;
120
+ }
121
+
122
+ function inferTitle(feedback) {
123
+ const lines = getText(feedback).split(/\r?\n/).map(compactText).filter(Boolean);
124
+ return clip(lines[0] || '妙笔用户反馈', 40);
125
+ }
126
+
127
+ function inferCategory(text) {
128
+ const s = getText(text);
129
+ if (/权限|登录|登陆|授权|token|Token|401|403/.test(s)) return '权限/登录';
130
+ if (/慢|卡|超时|timeout|Timeout|延迟|性能/.test(s)) return '性能/稳定性';
131
+ if (/报错|错误|失败|打不开|无法|不能|崩溃|异常|error|Error|failed|Failed/.test(s)) return 'Bug/报错';
132
+ if (/希望|建议|能否|能不能|支持|新增|优化/.test(s)) return '功能建议';
133
+ if (/怎么|如何|为什么|是否|能不能用/.test(s)) return '使用咨询';
134
+ return '体验问题';
135
+ }
136
+
137
+ function inferPriority(text, category) {
138
+ const s = getText(text);
139
+ if (/数据丢失|无法发布|发布失败|全部|所有人|不可用|崩溃|打不开|P0|紧急|严重/.test(s)) return 'P1';
140
+ if (category === '使用咨询' || /轻微|建议|优化|体验/.test(s)) return 'P3';
141
+ return 'P2';
142
+ }
143
+
144
+ function parseTargetUrl(rawUrl) {
145
+ const raw = getText(rawUrl || DEFAULT_TARGET_URL);
146
+ const url = new URL(raw);
147
+ const tableId = url.searchParams.get('table') || '';
148
+ const viewId = url.searchParams.get('view') || '';
149
+ const wikiMatch = url.pathname.match(/\/wiki\/([^/?#]+)/);
150
+ const baseMatch = url.pathname.match(/\/base\/([^/?#]+)/);
151
+ return { raw, wikiToken: wikiMatch?.[1] || '', appToken: baseMatch?.[1] || '', tableId, viewId };
152
+ }
153
+
154
+ function normalizeOpenapiHost(rawHost) {
155
+ const raw = getText(rawHost || DEFAULT_HOST).replace(/\/+$/, '');
156
+ const withProtocol = /^https?:\/\//i.test(raw) ? raw : `https://${raw}`;
157
+ const url = new URL(withProtocol);
158
+ if (url.protocol !== 'https:') fail('OpenAPI host must use https', 'E_INVALID_ARGS');
159
+ if (!ALLOWED_OPENAPI_HOSTS.has(url.hostname)) fail(`Unsupported OpenAPI host: ${url.hostname}`, 'E_INVALID_ARGS');
160
+ return url.origin;
161
+ }
162
+
163
+ function configureAuth(opts, fileConfig) {
164
+ authConfig = {
165
+ openapiHost: normalizeOpenapiHost(firstNonEmpty(opts.openapiHost, fileConfig.LARK_OPENAPI_HOST, fileConfig.FEISHU_OPENAPI_HOST, DEFAULT_HOST)),
166
+ appId: firstNonEmpty(opts.appId, fileConfig.LARK_APP_ID, fileConfig.FEISHU_APP_ID),
167
+ appSecret: firstNonEmpty(opts.appSecret, fileConfig.LARK_APP_SECRET, fileConfig.FEISHU_APP_SECRET),
168
+ };
169
+ }
170
+
171
+ function host() {
172
+ return authConfig.openapiHost;
173
+ }
174
+
175
+ async function larkFetch(method, apiPath, body, query) {
176
+ const token = await getTenantToken();
177
+ const url = new URL(`${host()}${apiPath}`);
178
+ for (const [key, value] of Object.entries(query || {})) {
179
+ if (value !== undefined && value !== null && value !== '') url.searchParams.set(key, String(value));
180
+ }
181
+ const resp = await fetch(url, {
182
+ method,
183
+ headers: {
184
+ Authorization: `Bearer ${token}`,
185
+ 'Content-Type': 'application/json',
186
+ },
187
+ body: body === undefined ? undefined : JSON.stringify(body),
188
+ cache: 'no-store',
189
+ });
190
+ const text = await resp.text();
191
+ let data;
192
+ try { data = text ? JSON.parse(text) : {}; } catch { throw new Error(`Invalid JSON from ${method} ${url.pathname}: ${text}`); }
193
+ if (!resp.ok || data?.code !== 0) throw new Error(`${method} ${url.pathname} failed: ${JSON.stringify(data)}`);
194
+ return data;
195
+ }
196
+
197
+ async function getTenantToken() {
198
+ if (cachedToken && Date.now() < cachedTokenExpiresAt) return cachedToken;
199
+ const id = authConfig.appId;
200
+ const secret = authConfig.appSecret;
201
+ if (!id || !secret) {
202
+ fail('Missing app credentials. Pass --app-id/--app-secret or configure LARK_APP_ID/LARK_APP_SECRET in env.json.', 'E_AUTH_FAILED', 2);
203
+ }
204
+ const resp = await fetch(`${host()}/open-apis/auth/v3/tenant_access_token/internal`, {
205
+ method: 'POST',
206
+ headers: { 'Content-Type': 'application/json' },
207
+ body: JSON.stringify({ app_id: id, app_secret: secret }),
208
+ cache: 'no-store',
209
+ });
210
+ const data = await resp.json();
211
+ if (!resp.ok || data?.code !== 0 || !data?.tenant_access_token) {
212
+ throw new Error(`tenant token failed: ${JSON.stringify(data)}`);
213
+ }
214
+ cachedToken = data.tenant_access_token;
215
+ cachedTokenExpiresAt = Date.now() + Math.max(Number(data.expire || 7200) - 60, 60) * 1000;
216
+ return cachedToken;
217
+ }
218
+
219
+ async function resolveWikiToken(wikiToken) {
220
+ if (!wikiToken) return '';
221
+ const data = await larkFetch('GET', '/open-apis/wiki/v2/spaces/get_node', undefined, { token: wikiToken });
222
+ const node = data?.data?.node || {};
223
+ if (node.obj_type && node.obj_type !== 'bitable') throw new Error(`wiki node is ${node.obj_type}, not bitable`);
224
+ return getText(node.obj_token);
225
+ }
226
+
227
+ async function listFields(appToken, tableId) {
228
+ const data = await larkFetch('GET', `/open-apis/bitable/v1/apps/${encodeURIComponent(appToken)}/tables/${encodeURIComponent(tableId)}/fields`);
229
+ return data?.data?.items || [];
230
+ }
231
+
232
+ async function createRecord(appToken, tableId, fields) {
233
+ return larkFetch('POST', `/open-apis/bitable/v1/apps/${encodeURIComponent(appToken)}/tables/${encodeURIComponent(tableId)}/records`, { fields });
234
+ }
235
+
236
+ function normalizeFieldType(field) {
237
+ return Number(field?.type || field?.ui_type || 0);
238
+ }
239
+
240
+ function asDateValue(value) {
241
+ const text = getText(value);
242
+ if (!text) return Date.now();
243
+ const ms = Date.parse(text);
244
+ return Number.isFinite(ms) ? ms : Date.now();
245
+ }
246
+
247
+ function asPersonValue(openId) {
248
+ const id = getText(openId);
249
+ return id ? [{ id }] : undefined;
250
+ }
251
+
252
+ function asUrlValue(url, title) {
253
+ const link = getText(url);
254
+ if (!link) return undefined;
255
+ return { text: getText(title) || link, link };
256
+ }
257
+
258
+ function convertValue(field, value, allData) {
259
+ const raw = value;
260
+ if (raw === undefined || raw === null || raw === '') return undefined;
261
+ const type = normalizeFieldType(field);
262
+ if (type === 5) return asDateValue(raw);
263
+ if (type === 11 || type === 23) return asPersonValue(raw);
264
+ if (type === 15) return asUrlValue(raw, allData.title);
265
+ if (type === 7) return Boolean(raw);
266
+ if (type === 2) {
267
+ const n = Number(raw);
268
+ return Number.isFinite(n) ? n : undefined;
269
+ }
270
+ return String(raw);
271
+ }
272
+
273
+ function findField(fields, candidates) {
274
+ const normalized = new Map(fields.map((field) => [getText(field.field_name || field.name).toLowerCase(), field]));
275
+ for (const candidate of candidates) {
276
+ const found = normalized.get(candidate.toLowerCase());
277
+ if (found) return found;
278
+ }
279
+ for (const field of fields) {
280
+ const name = getText(field.field_name || field.name);
281
+ if (candidates.some((candidate) => name.includes(candidate))) return field;
282
+ }
283
+ return null;
284
+ }
285
+
286
+ function buildFieldPayload(fieldDefs, data) {
287
+ const mapping = [
288
+ { key: 'title', candidates: ['标题', '反馈标题', '问题标题', '需求标题', '名称', '反馈摘要', '摘要', '问题'] },
289
+ { key: 'summary', candidates: ['整理后问题', '问题整理', '反馈整理', '问题摘要', '整理摘要', '需求描述', '用户反馈', '描述'] },
290
+ { key: 'feedback', candidates: ['原始反馈', '用户原话', '原始问题', '反馈内容', '内容', '详细描述', '问题描述', '详情'] },
291
+ { key: 'category', candidates: ['分类', '问题分类', '反馈分类', '类型', '反馈类型', '问题类型'] },
292
+ { key: 'priority', candidates: ['优先级', '严重程度', '影响程度', 'P级', '级别', 'Priority'] },
293
+ { key: 'status', candidates: ['状态', '处理状态', '跟进状态'] },
294
+ { key: 'reporterName', candidates: ['反馈人', '用户', '提交人', '上报人', '提出人', '用户名称', '姓名'] },
295
+ { key: 'reporterOpenId', candidates: ['反馈人 open_id', '用户 open_id', 'open_id', 'Open ID', '用户ID', '用户标识'] },
296
+ { key: 'sessionId', candidates: ['session_id', 'Session ID', '会话ID', '会话 id', '会话'] },
297
+ { key: 'source', candidates: ['来源', '反馈来源', '渠道', 'Source'] },
298
+ { key: 'sourceUrl', candidates: ['来源链接', '会话链接', '相关链接', '链接', 'URL', 'url'] },
299
+ { key: 'product', candidates: ['产品', '模块', '应用', '系统'] },
300
+ { key: 'createdAt', candidates: ['反馈时间', '提交时间', '创建时间', '时间', '日期'] },
301
+ ];
302
+ const payload = {};
303
+ const usedFieldIds = new Set();
304
+ for (const item of mapping) {
305
+ const field = findField(fieldDefs.filter((x) => !usedFieldIds.has(x.field_id)), item.candidates);
306
+ if (!field) continue;
307
+ const fieldName = getText(field.field_name || field.name);
308
+ let rawValue = data[item.key];
309
+ if ((item.key === 'reporterName' || item.key === 'reporterOpenId') && [11, 23].includes(normalizeFieldType(field))) {
310
+ rawValue = data.reporterOpenId;
311
+ }
312
+ const converted = convertValue(field, rawValue, data);
313
+ if (converted === undefined) continue;
314
+ payload[fieldName] = converted;
315
+ if (field.field_id) usedFieldIds.add(field.field_id);
316
+ }
317
+ return payload;
318
+ }
319
+
320
+ module.exports = { run };
@@ -0,0 +1,25 @@
1
+ 'use strict';
2
+
3
+ const { getBaseUrl } = require('../lib/config');
4
+ const { success, fail } = require('../lib/output');
5
+
6
+ async function run(args, opts) {
7
+ const sub = args[0];
8
+ if (sub !== 'create') fail('Usage: magic-builder link create --title <text> or --fid <id>', 'E_INVALID_ARGS');
9
+ const baseUrl = getBaseUrl(opts);
10
+
11
+ if (opts.fid) {
12
+ success({ url: `${baseUrl}/r?fid=${opts.fid}` }, opts);
13
+ return;
14
+ }
15
+
16
+ if (opts.title) {
17
+ const encoded = encodeURIComponent(opts.title);
18
+ success({ url: `${baseUrl}/r?title=${encoded}` }, opts);
19
+ return;
20
+ }
21
+
22
+ fail('Provide --title or --fid. Usage: magic-builder link create --title <text>', 'E_INVALID_ARGS');
23
+ }
24
+
25
+ module.exports = { run };
@@ -0,0 +1,243 @@
1
+ 'use strict';
2
+
3
+ const fs = require('fs');
4
+ const path = require('path');
5
+ const { getToken } = require('../lib/auth');
6
+ const { getBaseUrl, loadAppsConfig, saveAppsConfig } = require('../lib/config');
7
+ const { request } = require('../lib/http');
8
+ const { success, fail, failFromHttpError } = require('../lib/output');
9
+
10
+ const HTML_CODE_FIELD_COUNT = 10;
11
+ const HTML_CODE_FIELD_LIMIT = 90000;
12
+ const HTML_LIMIT = HTML_CODE_FIELD_COUNT * HTML_CODE_FIELD_LIMIT;
13
+
14
+ async function run(args, opts) {
15
+ const sub = args[0];
16
+ if (sub === 'publish') return publish(args.slice(1), opts);
17
+ if (sub === 'list') return list(opts);
18
+ if (sub === 'export') return exportApps(opts);
19
+ fail('Usage: magic-builder page <publish|list|export>', 'E_INVALID_ARGS');
20
+ }
21
+
22
+ async function publish(args, opts) {
23
+ const file = args[0];
24
+ if (!file) fail('Missing file argument. Usage: magic-builder page publish <file.html>', 'E_INVALID_ARGS');
25
+ if (!fs.existsSync(file)) fail(`File not found: ${file}`, 'E_NOT_FOUND');
26
+
27
+ const html = fs.readFileSync(file, 'utf8');
28
+ if (!html.trim()) fail('HTML file is empty', 'E_EMPTY_FILE');
29
+ if (html.length > HTML_LIMIT) fail(formatHtmlLimitMessage(html), 'E_PAYLOAD_TOO_LARGE');
30
+
31
+ let token;
32
+ try { token = await getToken(opts); } catch (e) { fail(e.message, 'E_NO_TOKEN', 2); }
33
+
34
+ const baseUrl = getBaseUrl(opts);
35
+ const title = opts.title || path.basename(file, path.extname(file));
36
+ const config = loadAppsConfig();
37
+ const relPath = path.relative(process.cwd(), path.resolve(file));
38
+ const existingId = opts.id || config[relPath]?.id || config[relPath]?.remoteId;
39
+
40
+ const body = { html, title };
41
+ if (opts.openSource) body.is_open_source = true;
42
+
43
+ if (!opts.quiet) process.stderr.write(`${existingId ? 'Updating' : 'Publishing'} ${relPath}... `);
44
+
45
+ let res;
46
+ try {
47
+ const endpoint = existingId
48
+ ? `${baseUrl}/api/html-box/${encodeURIComponent(existingId)}`
49
+ : `${baseUrl}/api/html-box`;
50
+ res = await request(endpoint, { method: 'POST', token, body });
51
+ } catch (e) { failFromHttpError(e); }
52
+
53
+ if (res.code !== 0) fail(res.msg || 'Publish failed', 'E_PUBLISH_FAILED');
54
+
55
+ const data = res.data || res;
56
+ const id = normalizeId(data.id || data.record_id || existingId);
57
+ const htmlBoxUrl = data.html_box_url || `${baseUrl}/html-box/${id}`;
58
+ const dashboardUrl = data.dashboard_url || `${baseUrl}/dashboard/${id}`;
59
+
60
+ config[relPath] = {
61
+ id,
62
+ remoteId: id,
63
+ title,
64
+ urls: {
65
+ html_box: htmlBoxUrl,
66
+ dashboard: dashboardUrl,
67
+ panel: data.panel_url || '',
68
+ tab: data.tab_url || '',
69
+ },
70
+ updatedAt: new Date().toISOString(),
71
+ };
72
+ saveAppsConfig(config);
73
+
74
+ if (!opts.quiet) process.stderr.write('done\n');
75
+ success({
76
+ id,
77
+ title,
78
+ html_box_url: htmlBoxUrl,
79
+ dashboard_url: dashboardUrl,
80
+ panel_url: data.panel_url || '',
81
+ tab_url: data.tab_url || '',
82
+ }, opts);
83
+ }
84
+
85
+ async function list(opts) {
86
+ const result = await searchApps(opts);
87
+ if (opts.format === 'json') {
88
+ success(result.raw, opts);
89
+ return;
90
+ }
91
+ if (opts.format === 'plain') {
92
+ success(result.records.map((record) => summarizeRecord(result.baseUrl, record)).join('\n'), opts);
93
+ return;
94
+ }
95
+ success({
96
+ count: result.count,
97
+ scope: result.scope,
98
+ title: result.title,
99
+ records: result.records.map((record) => normalizeRecord(result.baseUrl, record)),
100
+ }, opts);
101
+ }
102
+
103
+ async function exportApps(opts) {
104
+ if (!opts.out) fail('Missing --out', 'E_INVALID_ARGS');
105
+ if (!opts.id && !opts.title) fail('Missing --id or --title', 'E_INVALID_ARGS');
106
+
107
+ const searchOpts = { ...opts };
108
+ if (opts.id && !opts.title) delete searchOpts.title;
109
+ const result = await searchApps(searchOpts);
110
+ const targetId = normalizeId(opts.id);
111
+ let matches = result.records.filter((record) => {
112
+ if (targetId) return shortIdFromRecord(record) === targetId;
113
+ return titleMatches(record, opts.title);
114
+ });
115
+
116
+ if (!matches.length && opts.id && normalizeScope(opts.scope) === 'public') {
117
+ const html = await fetchOpenSourceHtml(opts, opts.id);
118
+ matches = [{ id: targetId, title: targetId, isOpenSource: true, html_preview: html }];
119
+ }
120
+
121
+ if (!matches.length) fail('No matching app found', 'E_NOT_FOUND');
122
+ if (!opts.all) matches = matches.slice(0, 1);
123
+
124
+ const exports = [];
125
+ for (const record of matches) {
126
+ let html = String(record?.html_preview || '');
127
+ if (!html && record?.isOpenSource) html = await fetchOpenSourceHtml(opts, shortIdFromRecord(record));
128
+ if (!html) {
129
+ fail(`App ${shortIdFromRecord(record)} did not include exportable HTML. Use scope=mine with a valid token for private apps, or export an open-source app.`, 'E_EXPORT_FAILED');
130
+ }
131
+ exports.push({ record, html });
132
+ }
133
+
134
+ success({ written: writeExport(opts.out, exports) }, opts);
135
+ }
136
+
137
+ async function searchApps(opts) {
138
+ const baseUrl = getBaseUrl(opts);
139
+ const scope = normalizeScope(opts.scope);
140
+ let token = '';
141
+ if (scope === 'mine') {
142
+ try { token = await getToken(opts); } catch (e) { fail(e.message, 'E_NO_TOKEN', 2); }
143
+ }
144
+ const url = new URL('/api/html-box/apps', baseUrl);
145
+ url.searchParams.set('scope', scope);
146
+ if (opts.title) url.searchParams.set('title', opts.title);
147
+ const json = await request(String(url), { token });
148
+ const records = Array.isArray(json?.data?.records) ? json.data.records : [];
149
+ return { baseUrl, scope, title: opts.title || '', count: records.length, records, raw: json };
150
+ }
151
+
152
+ async function fetchOpenSourceHtml(opts, id) {
153
+ const baseUrl = getBaseUrl(opts);
154
+ const json = await request(`${baseUrl}/api/html-box/${encodeURIComponent(normalizeId(id))}`);
155
+ return String(json?.data?.html || '');
156
+ }
157
+
158
+ function normalizeScope(scope) {
159
+ const value = String(scope || 'mine').trim().toLowerCase();
160
+ if (value === 'my' || value === 'private' || value === 'current_user') return 'mine';
161
+ if (value === 'open' || value === 'opensourced') return 'public';
162
+ if (value === 'mine' || value === 'public') return value;
163
+ fail(`Unsupported scope: ${scope}`, 'E_INVALID_ARGS');
164
+ }
165
+
166
+ function normalizeId(id) {
167
+ const value = String(id || '').trim();
168
+ return value.startsWith('rec') ? value.slice(3) : value;
169
+ }
170
+
171
+ function shortIdFromRecord(record) {
172
+ return normalizeId(record?.id || record?.record_id || record?.recordId || '');
173
+ }
174
+
175
+ function appUrl(baseUrl, id) {
176
+ return `${baseUrl}/html-box/${encodeURIComponent(normalizeId(id))}`;
177
+ }
178
+
179
+ function normalizeRecord(baseUrl, record) {
180
+ const id = shortIdFromRecord(record);
181
+ return {
182
+ id,
183
+ title: String(record?.title || '').trim() || '(untitled)',
184
+ open_source: !!record?.isOpenSource,
185
+ modified: record?.modify_time || '',
186
+ url: appUrl(baseUrl, id),
187
+ };
188
+ }
189
+
190
+ function summarizeRecord(baseUrl, record) {
191
+ const item = normalizeRecord(baseUrl, record);
192
+ return `${item.id} ${item.title} ${item.open_source ? 'open-source' : 'private'}${item.modified ? ` modified=${item.modified}` : ''} ${item.url}`;
193
+ }
194
+
195
+ function titleMatches(record, keyword) {
196
+ if (!keyword) return true;
197
+ return String(record?.title || '').toLowerCase().includes(String(keyword).toLowerCase());
198
+ }
199
+
200
+ function safeFileName(record) {
201
+ const id = shortIdFromRecord(record) || 'unknown';
202
+ const title = String(record?.title || 'magic-app')
203
+ .trim()
204
+ .replace(/[<>:"/\\|?*\x00-\x1F]/g, '_')
205
+ .replace(/\s+/g, '_')
206
+ .slice(0, 80) || 'magic-app';
207
+ return `${title}-${id}.html`;
208
+ }
209
+
210
+ function isDirectoryDestination(out, multiple) {
211
+ if (multiple) return true;
212
+ if (fs.existsSync(out) && fs.statSync(out).isDirectory()) return true;
213
+ return !path.extname(out);
214
+ }
215
+
216
+ function writeExport(out, exports) {
217
+ const multiple = exports.length > 1;
218
+ const dest = path.resolve(out);
219
+ const directoryDest = isDirectoryDestination(dest, multiple);
220
+ if (directoryDest) fs.mkdirSync(dest, { recursive: true });
221
+ if (!directoryDest && multiple) fail('--out must be a directory when exporting multiple apps', 'E_INVALID_ARGS');
222
+
223
+ return exports.map((item) => {
224
+ const filePath = directoryDest ? path.join(dest, safeFileName(item.record)) : dest;
225
+ fs.mkdirSync(path.dirname(filePath), { recursive: true });
226
+ fs.writeFileSync(filePath, item.html, 'utf8');
227
+ return { id: shortIdFromRecord(item.record), title: item.record?.title || '', file: filePath };
228
+ });
229
+ }
230
+
231
+ function hasLikelyInlineLargeResource(html) {
232
+ return /data:[^"'()\s>]+;base64,/i.test(html) || /base64,[A-Za-z0-9+/=]{1024,}/.test(html);
233
+ }
234
+
235
+ function formatHtmlLimitMessage(html) {
236
+ const overflow = Math.max(0, html.length - HTML_LIMIT);
237
+ const hint = hasLikelyInlineLargeResource(html)
238
+ ? 'Detected likely data:/Base64 inline resources. Upload those resources first, then reference their URLs in HTML.'
239
+ : 'If the HTML contains images, Base64, large JSON/CSV, or other large resources, upload them first, then reference their URLs in HTML.';
240
+ 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}`;
241
+ }
242
+
243
+ module.exports = { run };