tuya-platform-cli 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,173 @@
1
+ import fs from "node:fs/promises";
2
+ import path from "node:path";
3
+ import { ensureDir, writeJson, writeText } from "../util.js";
4
+ import {
5
+ buildNetworkCapture,
6
+ debugOriginForRegion,
7
+ executeTrialRun,
8
+ getBrowserContext,
9
+ pageFetchJson,
10
+ requireApiSuccess,
11
+ uploadImageForTrialRun,
12
+ } from "./shared.js";
13
+
14
+ function buildSummaryMarkdown(artifact) {
15
+ return [
16
+ "# Trial Run Inputs Sample",
17
+ "",
18
+ `- Workflow ID: \`${artifact.workflowId}\``,
19
+ `- Region: \`${artifact.region}\``,
20
+ `- Endpoint: \`${artifact.config.endpointType}:${artifact.config.endpointId}\``,
21
+ `- Text run status: \`${artifact.textRun.latestProgress?.json?.result?.executeStatus ?? artifact.textRun.latestProgress?.json?.errorCode ?? "unknown"}\``,
22
+ `- Image run status: \`${artifact.imageRun?.latestProgress?.json?.result?.executeStatus ?? artifact.imageRun?.latestProgress?.json?.errorCode ?? "skipped"}\``,
23
+ "",
24
+ ].join("\n");
25
+ }
26
+
27
+ export async function runSampleTrialInputs(config) {
28
+ if (!config.workflowId) {
29
+ throw new Error("sample-trial-inputs requires --workflow-id");
30
+ }
31
+
32
+ await ensureDir(config.outputDir);
33
+
34
+ const { browser, context, page } = await getBrowserContext(config.remoteDebuggingPort);
35
+ const baseOrigin = debugOriginForRegion(config.region, config.baseUrl);
36
+ const capture = buildNetworkCapture(
37
+ context,
38
+ /run-pre-check|workflow\/test\/config|workflow\/test\/execute|workflow\/test\/progress|uploadSignV3|checkFileStatus|file\/signature/i,
39
+ );
40
+
41
+ try {
42
+ await page.bringToFront();
43
+ await page.goto(`${baseOrigin}/ai/workflow/debug?workflowId=${config.workflowId}&region=${config.region}`, {
44
+ waitUntil: "domcontentloaded",
45
+ }).catch(() => {});
46
+ await page.waitForTimeout(4_000);
47
+
48
+ const preCheck = await pageFetchJson(page, "/micro-app/ai/api/aigc/workflow/run-pre-check", {
49
+ body: {
50
+ params: {
51
+ workflowId: config.workflowId,
52
+ region: config.region,
53
+ },
54
+ },
55
+ baseOrigin,
56
+ });
57
+ requireApiSuccess("run-pre-check", preCheck);
58
+
59
+ const configResponse = await pageFetchJson(page, "/micro-app/ai/api/aigc/workflow/test/config", {
60
+ body: {
61
+ workflowId: config.workflowId,
62
+ },
63
+ baseOrigin,
64
+ });
65
+ requireApiSuccess("test/config", configResponse);
66
+
67
+ const endpointType = configResponse.json?.result?.endpointType;
68
+ const endpointId = configResponse.json?.result?.endpointId;
69
+ if (!endpointType || !endpointId) {
70
+ throw new Error("test/config failed: missing endpointType or endpointId");
71
+ }
72
+
73
+ const configSave = await pageFetchJson(page, "/micro-app/ai/api/aigc/workflow/test/config/save", {
74
+ body: {
75
+ params: {
76
+ workflowId: config.workflowId,
77
+ endpointType,
78
+ endpointId,
79
+ },
80
+ },
81
+ baseOrigin,
82
+ });
83
+ requireApiSuccess("test/config/save", configSave);
84
+
85
+ const textOnly = await executeTrialRun(page, {
86
+ baseOrigin,
87
+ workflowId: config.workflowId,
88
+ endpointType,
89
+ endpointId,
90
+ inputArgs: {
91
+ USER_TEXT: "你好",
92
+ },
93
+ });
94
+
95
+ const textLatestProgress = textOnly.progress[textOnly.progress.length - 1] ?? null;
96
+
97
+ let imageRun = null;
98
+ if (config.imagePath) {
99
+ const upload = await uploadImageForTrialRun(page, config.imagePath, {
100
+ baseOrigin,
101
+ readFile: fs.readFile,
102
+ basename: path.basename,
103
+ });
104
+
105
+ const run = await executeTrialRun(page, {
106
+ baseOrigin,
107
+ workflowId: config.workflowId,
108
+ endpointType,
109
+ endpointId,
110
+ inputArgs: {
111
+ USER_TEXT: "请描述这张图片",
112
+ USER_IMAGE: [upload.bizUrl],
113
+ },
114
+ });
115
+
116
+ imageRun = {
117
+ upload,
118
+ ...run,
119
+ latestProgress: run.progress[run.progress.length - 1] ?? null,
120
+ };
121
+ }
122
+
123
+ const artifact = {
124
+ generatedAt: new Date().toISOString(),
125
+ workflowId: config.workflowId,
126
+ region: config.region,
127
+ config: {
128
+ endpointType,
129
+ endpointId,
130
+ },
131
+ preCheck,
132
+ configResponse,
133
+ configSave,
134
+ textRun: {
135
+ ...textOnly,
136
+ latestProgress: textLatestProgress,
137
+ },
138
+ imageRun,
139
+ events: capture.events,
140
+ };
141
+
142
+ await writeJson(
143
+ path.join(config.outputDir, `trial-run-inputs-${config.workflowId}.json`),
144
+ artifact,
145
+ );
146
+ await writeText(
147
+ path.join(config.outputDir, "trial-run-inputs.md"),
148
+ buildSummaryMarkdown(artifact),
149
+ );
150
+
151
+ console.log(
152
+ JSON.stringify(
153
+ {
154
+ outputDir: config.outputDir,
155
+ workflowId: config.workflowId,
156
+ textRunStatus:
157
+ textLatestProgress?.json?.result?.executeStatus ??
158
+ textLatestProgress?.json?.errorCode ??
159
+ null,
160
+ imageRunStatus:
161
+ imageRun?.latestProgress?.json?.result?.executeStatus ??
162
+ imageRun?.latestProgress?.json?.errorCode ??
163
+ null,
164
+ },
165
+ null,
166
+ 2,
167
+ ),
168
+ );
169
+ } finally {
170
+ capture.dispose();
171
+ await browser.close();
172
+ }
173
+ }
@@ -0,0 +1,457 @@
1
+ import { chromium } from "playwright";
2
+ import { safeJsonParse } from "../util.js";
3
+
4
+ export function requireApiSuccess(label, response) {
5
+ if (!response) {
6
+ throw new Error(`${label} failed: empty response`);
7
+ }
8
+
9
+ if (response.error) {
10
+ throw new Error(`${label} failed: ${response.error}`);
11
+ }
12
+
13
+ if (response.status >= 400) {
14
+ throw new Error(`${label} failed: HTTP ${response.status}`);
15
+ }
16
+
17
+ if (response.json && response.json.success === false) {
18
+ throw new Error(
19
+ `${label} failed: ${response.json.errorCode ?? response.json.status ?? "business_error"} ${
20
+ response.json.errorMsg ?? ""
21
+ }`.trim(),
22
+ );
23
+ }
24
+
25
+ return response;
26
+ }
27
+
28
+ export async function getBrowserContext(
29
+ remoteDebuggingPort,
30
+ pageUrlIncludes = "platform.tuya.com/ai/workflow",
31
+ ) {
32
+ const browser = await chromium.connectOverCDP(`http://127.0.0.1:${remoteDebuggingPort}`);
33
+ const context = browser.contexts()[0] ?? (await browser.newContext());
34
+ const page =
35
+ context.pages().find((candidate) => candidate.url().includes(pageUrlIncludes)) ??
36
+ context.pages().find((candidate) => candidate.url().startsWith("http")) ??
37
+ (await context.newPage());
38
+
39
+ return { browser, context, page };
40
+ }
41
+
42
+ export function buildNetworkCapture(
43
+ context,
44
+ urlPattern = /workflow\/update|workflow\/get|workflow\/list|addBasic/i,
45
+ ) {
46
+ const events = [];
47
+
48
+ const onRequest = (request) => {
49
+ if (!urlPattern.test(request.url())) {
50
+ return;
51
+ }
52
+
53
+ events.push({
54
+ ts: new Date().toISOString(),
55
+ type: "request",
56
+ method: request.method(),
57
+ url: request.url(),
58
+ headers: request.headers(),
59
+ body: request.postData() ?? null,
60
+ });
61
+ };
62
+
63
+ const onResponse = async (response) => {
64
+ if (!urlPattern.test(response.url())) {
65
+ return;
66
+ }
67
+
68
+ let body = null;
69
+ try {
70
+ body = await response.text();
71
+ } catch {
72
+ body = null;
73
+ }
74
+
75
+ events.push({
76
+ ts: new Date().toISOString(),
77
+ type: "response",
78
+ status: response.status(),
79
+ url: response.url(),
80
+ headers: await response.allHeaders().catch(() => ({})),
81
+ body,
82
+ });
83
+ };
84
+
85
+ context.on("request", onRequest);
86
+ context.on("response", onResponse);
87
+
88
+ return {
89
+ events,
90
+ dispose() {
91
+ context.off("request", onRequest);
92
+ context.off("response", onResponse);
93
+ },
94
+ };
95
+ }
96
+
97
+ export async function pageFetchJson(
98
+ page,
99
+ pathOrUrl,
100
+ {
101
+ method = "POST",
102
+ body = null,
103
+ headers = {},
104
+ baseOrigin,
105
+ } = {},
106
+ ) {
107
+ const response = await page.evaluate(
108
+ async ({ pathOrUrl, method, body, headers, baseOrigin }) => {
109
+ const targetUrl = /^https?:\/\//i.test(pathOrUrl)
110
+ ? pathOrUrl
111
+ : new URL(pathOrUrl, baseOrigin ?? window.location.origin).toString();
112
+ const csrf =
113
+ globalThis.csrf ??
114
+ decodeURIComponent(
115
+ document.cookie.match(/(?:^|;\s*)csrf-token=([^;]+)/)?.[1] ?? "",
116
+ );
117
+
118
+ if (!csrf) {
119
+ return {
120
+ status: 0,
121
+ url: targetUrl,
122
+ headers: {},
123
+ text: "",
124
+ error: "Missing csrf-token in page context",
125
+ };
126
+ }
127
+
128
+ const finalHeaders = {
129
+ "content-type": "application/json; charset=UTF-8",
130
+ "x-requested-with": "XMLHttpRequest",
131
+ ...headers,
132
+ };
133
+
134
+ if (!Object.keys(finalHeaders).some((key) => key.toLowerCase() === "csrf-token")) {
135
+ finalHeaders["csrf-token"] = csrf;
136
+ }
137
+
138
+ const fetchResponse = await fetch(targetUrl, {
139
+ method,
140
+ headers: finalHeaders,
141
+ credentials: "include",
142
+ body: body === null ? undefined : JSON.stringify(body),
143
+ });
144
+
145
+ const responseHeaders = {};
146
+ fetchResponse.headers.forEach((value, key) => {
147
+ responseHeaders[key] = value;
148
+ });
149
+
150
+ return {
151
+ status: fetchResponse.status,
152
+ url: fetchResponse.url,
153
+ headers: responseHeaders,
154
+ text: await fetchResponse.text(),
155
+ };
156
+ },
157
+ { pathOrUrl, method, body, headers, baseOrigin },
158
+ );
159
+
160
+ return {
161
+ ...response,
162
+ json: safeJsonParse(response.text),
163
+ };
164
+ }
165
+
166
+ export async function createWorkflowViaPage(
167
+ page,
168
+ { workflowName, workflowDesc, workflowRegion, workflowType = 1 },
169
+ { baseOrigin } = {},
170
+ ) {
171
+ const response = await pageFetchJson(
172
+ page,
173
+ "/micro-app/exp/api/v2/aigc/workflow/addBasic",
174
+ {
175
+ body: {
176
+ params: {
177
+ workflowName,
178
+ workflowDesc,
179
+ workflowRegion,
180
+ workflowType,
181
+ },
182
+ },
183
+ baseOrigin,
184
+ },
185
+ );
186
+
187
+ requireApiSuccess("create workflow", response);
188
+
189
+ const workflowId = response.json?.result?.workflowId ?? null;
190
+ if (!workflowId) {
191
+ throw new Error("create workflow failed: missing workflowId");
192
+ }
193
+
194
+ return {
195
+ ...response,
196
+ workflowId,
197
+ };
198
+ }
199
+
200
+ export async function getWorkflowViaPage(page, workflowId, { baseOrigin } = {}) {
201
+ const response = await pageFetchJson(page, "/micro-app/ai/api/v2/aigc/workflow/get", {
202
+ body: {
203
+ params: {
204
+ workflowId,
205
+ },
206
+ },
207
+ baseOrigin,
208
+ });
209
+
210
+ requireApiSuccess("get workflow", response);
211
+ return response;
212
+ }
213
+
214
+ export async function updateWorkflowViaPage(
215
+ page,
216
+ { workflowId, editVersionNo = 1, schemaJson },
217
+ { baseOrigin } = {},
218
+ ) {
219
+ const response = await pageFetchJson(page, "/micro-app/ai/api/aigc/workflow/update", {
220
+ body: {
221
+ params: {
222
+ workflowId,
223
+ editVersionNo,
224
+ schemaJson: JSON.stringify(schemaJson),
225
+ },
226
+ },
227
+ baseOrigin,
228
+ });
229
+
230
+ requireApiSuccess("update workflow", response);
231
+ return response;
232
+ }
233
+
234
+ export function readWorkflowSchema(detailResponse) {
235
+ const schemaText = detailResponse?.json?.result?.schemaJson;
236
+ const schemaJson = safeJsonParse(schemaText);
237
+ if (!schemaJson) {
238
+ throw new Error("get workflow failed: schemaJson missing or invalid");
239
+ }
240
+ return schemaJson;
241
+ }
242
+
243
+ export function pickLatestUpdateRequest(events) {
244
+ for (let index = events.length - 1; index >= 0; index -= 1) {
245
+ const event = events[index];
246
+ if (event.type === "request" && /workflow\/update/.test(event.url) && event.body) {
247
+ return event;
248
+ }
249
+ }
250
+ return null;
251
+ }
252
+
253
+ export function extractSchemaNode(events, nodeType) {
254
+ const updateRequest = pickLatestUpdateRequest(events);
255
+ if (!updateRequest) {
256
+ return null;
257
+ }
258
+
259
+ const requestBody = safeJsonParse(updateRequest.body);
260
+ const schemaJson = safeJsonParse(requestBody?.params?.schemaJson);
261
+ const node = schemaJson?.nodes?.find((item) => item.type === nodeType) ?? null;
262
+
263
+ return {
264
+ updateRequest,
265
+ schemaJson,
266
+ node,
267
+ };
268
+ }
269
+
270
+ export function assertNodePresent(snapshot, nodeType) {
271
+ if (!snapshot?.node) {
272
+ throw new Error(`Expected node type ${nodeType} in workflow/update payload`);
273
+ }
274
+ return snapshot.node;
275
+ }
276
+
277
+ export async function discoverReleasedProjectCode(page, { baseOrigin } = {}) {
278
+ const response = await pageFetchJson(page, "/global/iotcommon/api/content/aigc/agent/list", {
279
+ body: {},
280
+ baseOrigin,
281
+ });
282
+
283
+ requireApiSuccess("list agents", response);
284
+
285
+ const projects = Array.isArray(response.json?.result) ? response.json.result : [];
286
+ const candidate = projects.find((item) => item.projectCode) ?? null;
287
+ if (!candidate) {
288
+ throw new Error("list agents failed: no projectCode available");
289
+ }
290
+
291
+ return {
292
+ response,
293
+ projectCode: candidate.projectCode,
294
+ projectName: candidate.projectName ?? null,
295
+ };
296
+ }
297
+
298
+ export async function publishAgentViaPage(page, { projectCode, region }, { baseOrigin } = {}) {
299
+ const response = await pageFetchJson(page, "/micro-app/ai/api/aigc/workflow/publish/agent", {
300
+ body: {
301
+ params: {
302
+ projectCode,
303
+ region,
304
+ },
305
+ },
306
+ baseOrigin,
307
+ });
308
+
309
+ requireApiSuccess("publish agent", response);
310
+ return response;
311
+ }
312
+
313
+ export async function listKnowledgeLibrariesViaPage(page, { baseOrigin } = {}) {
314
+ const response = await pageFetchJson(page, "/micro-app/exp/api/aigc/model/library/list", {
315
+ body: {},
316
+ baseOrigin,
317
+ });
318
+
319
+ requireApiSuccess("list knowledge libraries", response);
320
+ return response;
321
+ }
322
+
323
+ export function debugOriginForRegion(region, fallbackBaseUrl) {
324
+ if (String(region).toUpperCase() === "AZ") {
325
+ return "https://us.platform.tuya.com";
326
+ }
327
+ return new URL(fallbackBaseUrl).origin;
328
+ }
329
+
330
+ export async function pollProgress(page, { baseOrigin, workflowId, executeId, retries = 8, intervalMs = 1_500 }) {
331
+ const attempts = [];
332
+
333
+ for (let index = 0; index < retries; index += 1) {
334
+ const progress = await pageFetchJson(page, "/micro-app/ai/api/aigc/workflow/test/progress", {
335
+ body: {
336
+ workflowId,
337
+ executeId,
338
+ },
339
+ baseOrigin,
340
+ });
341
+
342
+ attempts.push(progress);
343
+
344
+ if (progress.json?.success === false) {
345
+ break;
346
+ }
347
+
348
+ const status = progress.json?.result?.executeStatus;
349
+ if (status === 2 || status === 3) {
350
+ break;
351
+ }
352
+
353
+ await page.waitForTimeout(intervalMs);
354
+ }
355
+
356
+ return attempts;
357
+ }
358
+
359
+ export async function executeTrialRun(page, { baseOrigin, workflowId, endpointType, endpointId, inputArgs }) {
360
+ const execute = await pageFetchJson(page, "/micro-app/ai/api/v2/aigc/workflow/test/execute", {
361
+ body: {
362
+ params: {
363
+ workflowId,
364
+ inputArgs,
365
+ endpointType,
366
+ endpointId,
367
+ },
368
+ },
369
+ baseOrigin,
370
+ });
371
+ requireApiSuccess("test/execute", execute);
372
+
373
+ const executeId = execute.json?.result?.executeId ?? execute.json?.result;
374
+ if (!executeId) {
375
+ throw new Error("test/execute failed: missing executeId");
376
+ }
377
+
378
+ const progress = await pollProgress(page, {
379
+ baseOrigin,
380
+ workflowId,
381
+ executeId,
382
+ });
383
+
384
+ return {
385
+ execute,
386
+ executeId,
387
+ progress,
388
+ };
389
+ }
390
+
391
+ export async function uploadImageForTrialRun(page, imagePath, { baseOrigin, readFile, basename }) {
392
+ const fileBuffer = await readFile(imagePath);
393
+ const fileName = basename(imagePath);
394
+
395
+ const uploadSign = await pageFetchJson(page, "/global/api/assets/uploadSignV3", {
396
+ body: {
397
+ biz: "AI_PRIVATE",
398
+ uploadFileName: fileName,
399
+ isIE: false,
400
+ },
401
+ baseOrigin,
402
+ });
403
+ requireApiSuccess("uploadSignV3", uploadSign);
404
+
405
+ const action = uploadSign.json?.result?.action;
406
+ const uploadHeaders = uploadSign.json?.result?.headers ?? {};
407
+ const uploadToken = uploadSign.json?.result?.token;
408
+ if (!action || !uploadToken) {
409
+ throw new Error("uploadSignV3 failed: missing action or token");
410
+ }
411
+
412
+ const putResponse = await fetch(action, {
413
+ method: "PUT",
414
+ headers: uploadHeaders,
415
+ body: fileBuffer,
416
+ });
417
+ if (!putResponse.ok) {
418
+ throw new Error(`signed upload failed: HTTP ${putResponse.status}`);
419
+ }
420
+
421
+ const checkFileStatus = await pageFetchJson(page, "/global/api/assets/checkFileStatus", {
422
+ body: {
423
+ uploadToken,
424
+ },
425
+ baseOrigin,
426
+ });
427
+ requireApiSuccess("checkFileStatus", checkFileStatus);
428
+
429
+ const bizUrl = checkFileStatus.json?.result?.bizUrl;
430
+ if (!bizUrl) {
431
+ throw new Error("checkFileStatus failed: missing bizUrl");
432
+ }
433
+
434
+ const signature = await pageFetchJson(
435
+ page,
436
+ "/micro-app/ai/global/iotcommon/api/file/signature",
437
+ {
438
+ body: {
439
+ biz: "AI_PRIVATE",
440
+ files: [{ path: bizUrl }],
441
+ },
442
+ baseOrigin,
443
+ },
444
+ );
445
+ requireApiSuccess("file/signature", signature);
446
+
447
+ return {
448
+ uploadSign,
449
+ put: {
450
+ status: putResponse.status,
451
+ url: action,
452
+ },
453
+ checkFileStatus,
454
+ signature,
455
+ bizUrl,
456
+ };
457
+ }