@ubox-tools/deploy-xperience 1.0.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 +119 -0
- package/deploy.js +1079 -0
- package/package.json +8 -0
package/README.md
ADDED
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
# deploy.js
|
|
2
|
+
|
|
3
|
+
Automated deployment script for Ubox experiences. Uses Puppeteer to drive the Ubox Studio web interface and deploy apps and Uboxes without manual browser interaction.
|
|
4
|
+
|
|
5
|
+
> **Run from the project root.** Always invoke `npx @ubox-tools/deploy-xperience` from the experience directory (the one containing `apps/` and `assets/`).
|
|
6
|
+
|
|
7
|
+
## Requirements
|
|
8
|
+
|
|
9
|
+
- Node.js 18+
|
|
10
|
+
- A clipboard utility — only needed when the Monaco/CodeMirror editor API is unavailable:
|
|
11
|
+
- **macOS**: `pbcopy` (built-in)
|
|
12
|
+
- **Windows**: PowerShell `Set-Clipboard` or `clip.exe` (both built-in)
|
|
13
|
+
- **Linux**: `wl-copy` (Wayland), `xclip` (X11), or `xsel` (X11)
|
|
14
|
+
|
|
15
|
+
## Usage
|
|
16
|
+
|
|
17
|
+
```
|
|
18
|
+
npx @ubox-tools/deploy-xperience [app1 app2 ...] [options]
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
## Options
|
|
22
|
+
|
|
23
|
+
| Flag | Description |
|
|
24
|
+
|---|---|
|
|
25
|
+
| `--email <email>` | Ubox Studio login email (or `UBOX_EMAIL` env var) |
|
|
26
|
+
| `--password <pass>` | Ubox Studio password (or `UBOX_PASSWORD` env var) |
|
|
27
|
+
| `--project-name <name>` | Override the project name (default: parent directory name) |
|
|
28
|
+
| `--noassets` | Skip uploading assets (images, fonts, files) |
|
|
29
|
+
| `--noplayer` | Skip Phase 3 — deploy apps only, do not create/update Uboxes |
|
|
30
|
+
| `--show` | Show the browser window (default: headless) |
|
|
31
|
+
| `--help` | Print usage and exit |
|
|
32
|
+
|
|
33
|
+
Credentials can also be supplied via environment variables:
|
|
34
|
+
|
|
35
|
+
```
|
|
36
|
+
UBOX_EMAIL=me@example.com UBOX_PASSWORD=secret npx @ubox-tools/deploy-xperience
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
If either credential is missing and the terminal is interactive, the script prompts for them.
|
|
40
|
+
|
|
41
|
+
## Examples
|
|
42
|
+
|
|
43
|
+
```bash
|
|
44
|
+
npx @ubox-tools/deploy-xperience # Deploy all apps (headless)
|
|
45
|
+
npx @ubox-tools/deploy-xperience --show # Deploy all apps, show browser
|
|
46
|
+
npx @ubox-tools/deploy-xperience mobile # Deploy only the mobile app
|
|
47
|
+
npx @ubox-tools/deploy-xperience main --noassets # Deploy main app, skip asset uploads
|
|
48
|
+
npx @ubox-tools/deploy-xperience --noplayer # Deploy apps only, skip Uboxes
|
|
49
|
+
npx @ubox-tools/deploy-xperience --project-name "MyProject"
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
## Deployment Phases
|
|
53
|
+
|
|
54
|
+
### Phase 1 — Login
|
|
55
|
+
|
|
56
|
+
Navigates to `studio.ubox.world` and signs in. Skipped automatically if a session is already active.
|
|
57
|
+
|
|
58
|
+
### Phase 2 — Application Deployment
|
|
59
|
+
|
|
60
|
+
Runs once per app (always `mobile` before `main`):
|
|
61
|
+
|
|
62
|
+
1. **Find or create** the application card by its full name (`<ProjectName> <appName>`).
|
|
63
|
+
2. **Upload resources** — groups assets by type (Images / Fonts / Files), deletes any existing file with the same name first, then uploads via CDP (bypasses the OS file picker dialog).
|
|
64
|
+
3. **Configure parameters** — reads `apps/<appName>/parameters.json`; only creates parameters that don't already exist.
|
|
65
|
+
4. **Inject source** — opens the Source editor, iterates the tab list (`HTML`, `CSS`, `Ubox Events`, `Workflow`, `Business logic`, `Data source`) and sets each file's content via the Monaco or CodeMirror API, or via clipboard paste as a fallback.
|
|
66
|
+
|
|
67
|
+
### Phase 3 — Ubox Deployment
|
|
68
|
+
|
|
69
|
+
Runs once per app:
|
|
70
|
+
|
|
71
|
+
1. **Find or create** the Ubox by its full name. Handles the Terms & Conditions modal on first creation.
|
|
72
|
+
2. **Check "Make public"** — enables the public checkbox if not already checked.
|
|
73
|
+
3. **Install app** — switches to the "Available applications" tab, searches for the app card, and clicks Install.
|
|
74
|
+
4. **Mobile virtual link** — for the `mobile` app, clicks "Open Virtual Ubox Link" and captures the URL from the newly opened tab.
|
|
75
|
+
5. **Set `mobileLink` parameter** — for the `main` Ubox (if the app defines a `mobileLink` parameter), opens "Change params" and writes the captured mobile virtual link.
|
|
76
|
+
|
|
77
|
+
If the Ubox already exists, installation and parameter changes are skipped; the mobile virtual link is still retrieved so the main Ubox can use it.
|
|
78
|
+
|
|
79
|
+
## Proxy / Source Transformation
|
|
80
|
+
|
|
81
|
+
Before injecting source files, `generateProxy()` applies three transforms to each file:
|
|
82
|
+
|
|
83
|
+
| Transform | Applies to | What it does |
|
|
84
|
+
|---|---|---|
|
|
85
|
+
| Parameter substitution | JS | Replaces `const KEY = "value"` with `const KEY = "{parameter:KEY}"` for every key in `parameters.json` |
|
|
86
|
+
| Asset path substitution | JS, CSS | Replaces `../assets/file.ext` references with `{resources:type/file.ext}` tokens |
|
|
87
|
+
| Emoji escaping | JS | Converts non-ASCII characters to `\uXXXX` Unicode escape sequences |
|
|
88
|
+
| Local tag stripping | HTML | Removes local `<link href>` and `<script src>` tags (CDN URLs are kept) |
|
|
89
|
+
|
|
90
|
+
## Project Layout Expected
|
|
91
|
+
|
|
92
|
+
```
|
|
93
|
+
<project-root>/
|
|
94
|
+
apps/
|
|
95
|
+
mobile/
|
|
96
|
+
index.html
|
|
97
|
+
style.css
|
|
98
|
+
ubox.js
|
|
99
|
+
workflow.js
|
|
100
|
+
logic.js
|
|
101
|
+
data.js
|
|
102
|
+
parameters.json (optional)
|
|
103
|
+
main/
|
|
104
|
+
...same files...
|
|
105
|
+
assets/
|
|
106
|
+
*.jpg / *.png / *.woff2 / ...
|
|
107
|
+
deploy.js
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
The project name defaults to the directory name of `<project-root>`. App full names are formed as `"<projectName> <appName>"` (e.g. `"Claude - Slide Puzzle mobile"`).
|
|
111
|
+
|
|
112
|
+
## Error Handling
|
|
113
|
+
|
|
114
|
+
On any unhandled error the script:
|
|
115
|
+
1. Prints the error message.
|
|
116
|
+
2. Saves a screenshot to `deploy_error.png` in the project root.
|
|
117
|
+
3. Exits with code 1.
|
|
118
|
+
|
|
119
|
+
The browser is kept open after both success and failure so you can inspect the final state. Press `Ctrl+C` to close it.
|
package/deploy.js
ADDED
|
@@ -0,0 +1,1079 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const puppeteer = require('puppeteer');
|
|
4
|
+
const path = require('path');
|
|
5
|
+
const fs = require('fs');
|
|
6
|
+
const readline = require('readline');
|
|
7
|
+
const { spawnSync } = require('child_process');
|
|
8
|
+
|
|
9
|
+
// ─── Constants ────────────────────────────────────────────────────────────────
|
|
10
|
+
|
|
11
|
+
const PROJECT_ROOT = process.cwd();
|
|
12
|
+
const APPS_DIR = path.join(PROJECT_ROOT, 'apps');
|
|
13
|
+
const ASSETS_DIR = path.join(PROJECT_ROOT, 'assets');
|
|
14
|
+
const BASE_URL = 'https://studio.ubox.world';
|
|
15
|
+
const PROJECT_NAME = path.basename(PROJECT_ROOT); // "Claude - Slide Puzzle"
|
|
16
|
+
const DEPLOY_ORDER = ['mobile', 'main'];
|
|
17
|
+
|
|
18
|
+
// File → editor tab label mapping
|
|
19
|
+
const TAB_MAP = {
|
|
20
|
+
'index.html' : 'HTML',
|
|
21
|
+
'style.css' : 'CSS',
|
|
22
|
+
'ubox.js' : 'Ubox Events',
|
|
23
|
+
'workflow.js': 'Workflow',
|
|
24
|
+
'logic.js' : 'Business logic',
|
|
25
|
+
'data.js' : 'Data source',
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
// Asset file extension → resource sub-tab
|
|
29
|
+
const ASSET_TYPES = {
|
|
30
|
+
jpg: 'images', jpeg: 'images', png: 'images', gif: 'images', webp: 'images', svg: 'images',
|
|
31
|
+
woff: 'fonts', woff2: 'fonts', ttf: 'fonts', otf: 'fonts', eot: 'fonts',
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
const sleep = ms => new Promise(r => setTimeout(r, ms));
|
|
35
|
+
|
|
36
|
+
// ─── CLI Parsing ──────────────────────────────────────────────────────────────
|
|
37
|
+
|
|
38
|
+
function parseArgs() {
|
|
39
|
+
const args = process.argv.slice(2);
|
|
40
|
+
|
|
41
|
+
if (args.includes('--help') || args.includes('-h')) {
|
|
42
|
+
console.log(`
|
|
43
|
+
Usage: npx @ubox-tools/deploy-xperience [app1 app2 ...] [options]
|
|
44
|
+
|
|
45
|
+
Apps (default: all apps in apps/, always mobile before main):
|
|
46
|
+
mobile Deploy mobile app
|
|
47
|
+
main Deploy main app
|
|
48
|
+
|
|
49
|
+
Options:
|
|
50
|
+
--email <email> Ubox studio email (or set UBOX_EMAIL env var)
|
|
51
|
+
--password <pass> Ubox studio password (or set UBOX_PASSWORD env var)
|
|
52
|
+
--project-name <name> Override project name (default: directory name)
|
|
53
|
+
--noassets Skip uploading assets (resources)
|
|
54
|
+
--noplayer Skip Ubox creation (Phase 3) — only deploy applications
|
|
55
|
+
--show Show the browser window (default: headless)
|
|
56
|
+
--help Show this help
|
|
57
|
+
|
|
58
|
+
Examples:
|
|
59
|
+
npx @ubox-tools/deploy-xperience # Deploy all apps
|
|
60
|
+
npx @ubox-tools/deploy-xperience mobile # Deploy only mobile
|
|
61
|
+
npx @ubox-tools/deploy-xperience main # Deploy only main
|
|
62
|
+
npx @ubox-tools/deploy-xperience --noassets # Deploy without re-uploading assets
|
|
63
|
+
npx @ubox-tools/deploy-xperience --noplayer # Deploy apps only, skip Uboxes
|
|
64
|
+
UBOX_EMAIL=me@x.com npx @ubox-tools/deploy-xperience
|
|
65
|
+
`.trim());
|
|
66
|
+
process.exit(0);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
let email = process.env.UBOX_EMAIL;
|
|
70
|
+
let password = process.env.UBOX_PASSWORD;
|
|
71
|
+
let projectName = null;
|
|
72
|
+
let noAssets = false;
|
|
73
|
+
let noPlayer = false;
|
|
74
|
+
let show = false;
|
|
75
|
+
const appArgs = [];
|
|
76
|
+
|
|
77
|
+
for (let i = 0; i < args.length; i++) {
|
|
78
|
+
if (args[i] === '--email') { email = args[++i]; }
|
|
79
|
+
else if (args[i] === '--password') { password = args[++i]; }
|
|
80
|
+
else if (args[i] === '--project-name') { projectName = args[++i]; }
|
|
81
|
+
else if (args[i] === '--noassets') { noAssets = true; }
|
|
82
|
+
else if (args[i] === '--noplayer') { noPlayer = true; }
|
|
83
|
+
else if (args[i] === '--show') { show = true; }
|
|
84
|
+
else if (!args[i].startsWith('--')) { appArgs.push(args[i]); }
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// Discover available apps from apps/ directory
|
|
88
|
+
const available = fs.existsSync(APPS_DIR)
|
|
89
|
+
? fs.readdirSync(APPS_DIR).filter(d => fs.statSync(path.join(APPS_DIR, d)).isDirectory())
|
|
90
|
+
: [];
|
|
91
|
+
|
|
92
|
+
// Respect DEPLOY_ORDER (mobile → main), then any extras
|
|
93
|
+
const base = DEPLOY_ORDER.filter(a => available.includes(a));
|
|
94
|
+
const extras = available.filter(a => !DEPLOY_ORDER.includes(a));
|
|
95
|
+
const allApps = [...base, ...extras];
|
|
96
|
+
const selected = appArgs.length > 0 ? appArgs : allApps;
|
|
97
|
+
|
|
98
|
+
// Always keep mobile before main regardless of user input order
|
|
99
|
+
const ordered = [
|
|
100
|
+
...DEPLOY_ORDER.filter(a => selected.includes(a)),
|
|
101
|
+
...selected.filter(a => !DEPLOY_ORDER.includes(a)),
|
|
102
|
+
];
|
|
103
|
+
|
|
104
|
+
return { apps: ordered, email, password, projectName, noAssets, noPlayer, show };
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// ─── Credentials ──────────────────────────────────────────────────────────────
|
|
108
|
+
|
|
109
|
+
async function promptCredentials(email, password) {
|
|
110
|
+
if (email && password) return { email, password };
|
|
111
|
+
|
|
112
|
+
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
|
113
|
+
|
|
114
|
+
if (!email) {
|
|
115
|
+
email = await new Promise(resolve => rl.question('Email: ', resolve));
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
if (!password) {
|
|
119
|
+
password = await new Promise(resolve => {
|
|
120
|
+
process.stdout.write('Password: ');
|
|
121
|
+
if (process.stdin.isTTY) {
|
|
122
|
+
process.stdin.setRawMode(true);
|
|
123
|
+
}
|
|
124
|
+
let input = '';
|
|
125
|
+
process.stdin.resume();
|
|
126
|
+
process.stdin.setEncoding('utf8');
|
|
127
|
+
const handler = char => {
|
|
128
|
+
if (char === '\r' || char === '\n') {
|
|
129
|
+
if (process.stdin.isTTY) process.stdin.setRawMode(false);
|
|
130
|
+
process.stdin.pause();
|
|
131
|
+
process.stdin.removeListener('data', handler);
|
|
132
|
+
process.stdout.write('\n');
|
|
133
|
+
resolve(input);
|
|
134
|
+
} else if (char === '\u0003') {
|
|
135
|
+
process.exit(1);
|
|
136
|
+
} else if (char === '\u007F') {
|
|
137
|
+
if (input.length > 0) { input = input.slice(0, -1); process.stdout.write('\b \b'); }
|
|
138
|
+
} else {
|
|
139
|
+
input += char;
|
|
140
|
+
process.stdout.write('*');
|
|
141
|
+
}
|
|
142
|
+
};
|
|
143
|
+
process.stdin.on('data', handler);
|
|
144
|
+
});
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
rl.close();
|
|
148
|
+
return { email, password };
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// ─── Proxy File Generation ────────────────────────────────────────────────────
|
|
152
|
+
|
|
153
|
+
function assetResourceType(ext) {
|
|
154
|
+
return ASSET_TYPES[ext.toLowerCase()] || 'files';
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
function escapeEmojiInJS(content) {
|
|
158
|
+
return content.replace(/[^\x00-\x7F]/g, char => {
|
|
159
|
+
const cp = char.codePointAt(0);
|
|
160
|
+
if (cp > 0xFFFF) {
|
|
161
|
+
// Encode as surrogate pair
|
|
162
|
+
const hi = Math.floor((cp - 0x10000) / 0x400) + 0xD800;
|
|
163
|
+
const lo = ((cp - 0x10000) % 0x400) + 0xDC00;
|
|
164
|
+
return `\\u${hi.toString(16).toUpperCase().padStart(4, '0')}\\u${lo.toString(16).toUpperCase().padStart(4, '0')}`;
|
|
165
|
+
}
|
|
166
|
+
return `\\u${cp.toString(16).toUpperCase().padStart(4, '0')}`;
|
|
167
|
+
});
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
function generateProxy(appName) {
|
|
171
|
+
const appDir = path.join(APPS_DIR, appName);
|
|
172
|
+
const paramFile = path.join(appDir, 'parameters.json');
|
|
173
|
+
const params = fs.existsSync(paramFile) ? JSON.parse(fs.readFileSync(paramFile, 'utf8')) : {};
|
|
174
|
+
|
|
175
|
+
const proxy = {}; // filename → transformed content
|
|
176
|
+
const assets = {}; // filename → { type, absPath }
|
|
177
|
+
|
|
178
|
+
for (const file of fs.readdirSync(appDir)) {
|
|
179
|
+
if (!TAB_MAP[file]) continue;
|
|
180
|
+
|
|
181
|
+
const filePath = path.join(appDir, file);
|
|
182
|
+
let content = fs.readFileSync(filePath, 'utf8');
|
|
183
|
+
const ext = path.extname(file).slice(1).toLowerCase();
|
|
184
|
+
const isJS = ext === 'js';
|
|
185
|
+
const isCSS = ext === 'css';
|
|
186
|
+
const isHTML = ext === 'html';
|
|
187
|
+
|
|
188
|
+
if (isJS || isCSS) {
|
|
189
|
+
// 1. Parameter token substitution (JS only — parameters live in JS files)
|
|
190
|
+
if (isJS) {
|
|
191
|
+
for (const key of Object.keys(params)) {
|
|
192
|
+
const re = new RegExp(`((?:const|let|var)\\s+${key}\\s*=\\s*)(?:"[^"]*"|'[^']*')`, 'g');
|
|
193
|
+
content = content.replace(re, `$1"{parameter:${key}}"`);
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
// 2. Asset path substitution: "...assets/file.ext" → "{resources:type/file.ext}"
|
|
198
|
+
const assetInStringRe = /(['"])(?:\.\.\/)*assets\/([^'"]+)\1/g;
|
|
199
|
+
content = content.replace(assetInStringRe, (match, quote, assetFile) => {
|
|
200
|
+
const assetExt = path.extname(assetFile).slice(1).toLowerCase();
|
|
201
|
+
const type = assetResourceType(assetExt);
|
|
202
|
+
const absPath = path.join(ASSETS_DIR, assetFile);
|
|
203
|
+
if (fs.existsSync(absPath)) assets[assetFile] = { type, absPath };
|
|
204
|
+
return `"{resources:${type}/${assetFile}}"`;
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
// CSS url() pattern
|
|
208
|
+
if (isCSS) {
|
|
209
|
+
const assetUrlRe = /url\((['"]?)(?:\.\.\/)*assets\/([^'")\s]+)\1\)/g;
|
|
210
|
+
content = content.replace(assetUrlRe, (match, quote, assetFile) => {
|
|
211
|
+
const assetExt = path.extname(assetFile).slice(1).toLowerCase();
|
|
212
|
+
const type = assetResourceType(assetExt);
|
|
213
|
+
const absPath = path.join(ASSETS_DIR, assetFile);
|
|
214
|
+
if (fs.existsSync(absPath)) assets[assetFile] = { type, absPath };
|
|
215
|
+
return `url("{resources:${type}/${assetFile}}")`;
|
|
216
|
+
});
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
// 3. Emoji → unicode escapes (JS only)
|
|
220
|
+
if (isJS) content = escapeEmojiInJS(content);
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
if (isHTML) {
|
|
224
|
+
// Strip local <link> tags (keep CDN ones with https://)
|
|
225
|
+
content = content.replace(/<link\b[^>]*\bhref=["'](?!https?:\/\/)[^"']*["'][^>]*\/?>/gi, '');
|
|
226
|
+
// Strip local <script src> tags (keep CDN ones)
|
|
227
|
+
content = content.replace(/<script\b[^>]*\bsrc=["'](?!https?:\/\/)[^"']*["'][^>]*>\s*<\/script>/gi, '');
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
proxy[file] = content;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
return { proxy, assets, params };
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
// ─── Clipboard ────────────────────────────────────────────────────────────────
|
|
237
|
+
|
|
238
|
+
function writeClipboard(text) {
|
|
239
|
+
const { platform } = process;
|
|
240
|
+
let attempts;
|
|
241
|
+
|
|
242
|
+
if (platform === 'darwin') {
|
|
243
|
+
attempts = [
|
|
244
|
+
() => spawnSync('pbcopy', [], { input: text, encoding: 'utf8' }),
|
|
245
|
+
];
|
|
246
|
+
} else if (platform === 'win32') {
|
|
247
|
+
attempts = [
|
|
248
|
+
() => spawnSync('powershell', ['-noprofile', '-command', 'Set-Clipboard -Value $input'], { input: text, encoding: 'utf8' }),
|
|
249
|
+
() => spawnSync('clip', [], { input: text, encoding: 'utf8' }),
|
|
250
|
+
];
|
|
251
|
+
} else {
|
|
252
|
+
// Linux / other Unix
|
|
253
|
+
attempts = [
|
|
254
|
+
() => spawnSync('wl-copy', [], { input: text, encoding: 'utf8' }),
|
|
255
|
+
() => spawnSync('xclip', ['-selection', 'clipboard'], { input: text, encoding: 'utf8' }),
|
|
256
|
+
() => spawnSync('xsel', ['--clipboard', '--input'], { input: text, encoding: 'utf8' }),
|
|
257
|
+
];
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
for (const attempt of attempts) {
|
|
261
|
+
if (attempt().status === 0) return;
|
|
262
|
+
}
|
|
263
|
+
throw new Error(`No clipboard utility found for platform: ${platform}`);
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
// ─── Puppeteer Helpers ────────────────────────────────────────────────────────
|
|
267
|
+
|
|
268
|
+
/** Click an element found by its exact visible text. Throws if not found. */
|
|
269
|
+
async function clickByText(page, text, tag) {
|
|
270
|
+
const pos = await page.evaluate((text, tag) => {
|
|
271
|
+
const els = Array.from(document.querySelectorAll(tag || '*'));
|
|
272
|
+
const el = els.find(e => {
|
|
273
|
+
const r = e.getBoundingClientRect();
|
|
274
|
+
return r.width > 0 && r.height > 0 && e.textContent.trim() === text;
|
|
275
|
+
});
|
|
276
|
+
if (!el) return null;
|
|
277
|
+
el.scrollIntoView({ block: 'nearest' });
|
|
278
|
+
const r = el.getBoundingClientRect();
|
|
279
|
+
return { x: Math.round(r.x + r.width / 2), y: Math.round(r.y + r.height / 2) };
|
|
280
|
+
}, text, tag || null);
|
|
281
|
+
if (!pos) throw new Error(`Element not found: "${text}"${tag ? ` (tag: ${tag})` : ''}`);
|
|
282
|
+
await page.mouse.click(pos.x, pos.y);
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
/** Like clickByText but returns false instead of throwing when not found. */
|
|
286
|
+
async function clickByTextSoft(page, text, tag) {
|
|
287
|
+
const pos = await page.evaluate((text, tag) => {
|
|
288
|
+
const els = Array.from(document.querySelectorAll(tag || '*'));
|
|
289
|
+
const el = els.find(e => {
|
|
290
|
+
const r = e.getBoundingClientRect();
|
|
291
|
+
return r.width > 0 && r.height > 0 && e.textContent.trim() === text;
|
|
292
|
+
});
|
|
293
|
+
if (!el) return null;
|
|
294
|
+
el.scrollIntoView({ block: 'nearest' });
|
|
295
|
+
const r = el.getBoundingClientRect();
|
|
296
|
+
return { x: Math.round(r.x + r.width / 2), y: Math.round(r.y + r.height / 2) };
|
|
297
|
+
}, text, tag || null);
|
|
298
|
+
if (!pos) return false;
|
|
299
|
+
await page.mouse.click(pos.x, pos.y);
|
|
300
|
+
return true;
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
/** Type into a field: click it, Ctrl+A, Delete, then type. */
|
|
304
|
+
async function typeInField(page, selector, value) {
|
|
305
|
+
const input = await page.$(selector);
|
|
306
|
+
if (!input) throw new Error(`Input not found: ${selector}`);
|
|
307
|
+
const rect = await page.evaluate(el => {
|
|
308
|
+
el.scrollIntoView({ block: 'nearest' });
|
|
309
|
+
const r = el.getBoundingClientRect();
|
|
310
|
+
return { x: Math.round(r.x + r.width / 2), y: Math.round(r.y + r.height / 2) };
|
|
311
|
+
}, input);
|
|
312
|
+
await page.mouse.click(rect.x, rect.y);
|
|
313
|
+
await page.keyboard.down('Control');
|
|
314
|
+
await page.keyboard.press('a');
|
|
315
|
+
await page.keyboard.up('Control');
|
|
316
|
+
await page.keyboard.press('Delete');
|
|
317
|
+
await page.keyboard.type(value, { delay: 30 });
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
/** Type in search input and optionally click a "Go" button. */
|
|
321
|
+
async function searchFor(page, name) {
|
|
322
|
+
const searchInput = await page.$('#searchInput, input[type="search"], input[placeholder*="earch" i]');
|
|
323
|
+
if (!searchInput) throw new Error('Search input not found');
|
|
324
|
+
const rect = await page.evaluate(el => {
|
|
325
|
+
el.scrollIntoView({ block: 'nearest' });
|
|
326
|
+
const r = el.getBoundingClientRect();
|
|
327
|
+
return { x: Math.round(r.x + r.width / 2), y: Math.round(r.y + r.height / 2) };
|
|
328
|
+
}, searchInput);
|
|
329
|
+
await page.mouse.click(rect.x, rect.y);
|
|
330
|
+
await page.keyboard.down('Control');
|
|
331
|
+
await page.keyboard.press('a');
|
|
332
|
+
await page.keyboard.up('Control');
|
|
333
|
+
await page.keyboard.type(name, { delay: 40 });
|
|
334
|
+
await sleep(400);
|
|
335
|
+
// Click Go if present (Uboxes page has it; apps page may filter automatically)
|
|
336
|
+
await clickByTextSoft(page, 'Go', 'button');
|
|
337
|
+
await sleep(2000);
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
/** Upload a file via CDP, bypassing the OS file dialog. */
|
|
341
|
+
async function uploadFile(page, absPath) {
|
|
342
|
+
const client = await page.createCDPSession();
|
|
343
|
+
await client.send('DOM.getDocument');
|
|
344
|
+
const result = await client.send('DOM.querySelector', {
|
|
345
|
+
nodeId: 1,
|
|
346
|
+
selector: 'input[type="file"].fileIn, input[type="file"]',
|
|
347
|
+
});
|
|
348
|
+
if (!result.nodeId) throw new Error('File input not found');
|
|
349
|
+
const { node } = await client.send('DOM.describeNode', { nodeId: result.nodeId });
|
|
350
|
+
await client.send('DOM.setFileInputFiles', {
|
|
351
|
+
backendNodeId: node.backendNodeId,
|
|
352
|
+
files: [absPath],
|
|
353
|
+
});
|
|
354
|
+
await client.detach();
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
/** Inject text content into the active Monaco/CodeMirror/Ace editor. */
|
|
358
|
+
async function injectToEditor(page, content) {
|
|
359
|
+
// Try Monaco API first (no clipboard needed)
|
|
360
|
+
const monacoOk = await page.evaluate(text => {
|
|
361
|
+
if (window.monaco?.editor) {
|
|
362
|
+
const models = window.monaco.editor.getModels();
|
|
363
|
+
if (models?.length > 0) { models[0].setValue(text); return true; }
|
|
364
|
+
}
|
|
365
|
+
return false;
|
|
366
|
+
}, content);
|
|
367
|
+
if (monacoOk) return;
|
|
368
|
+
|
|
369
|
+
// Try CodeMirror API
|
|
370
|
+
const cmOk = await page.evaluate(text => {
|
|
371
|
+
const cm = document.querySelector('.CodeMirror');
|
|
372
|
+
if (cm?.CodeMirror) { cm.CodeMirror.setValue(text); return true; }
|
|
373
|
+
return false;
|
|
374
|
+
}, content);
|
|
375
|
+
if (cmOk) return;
|
|
376
|
+
|
|
377
|
+
// Fallback: clipboard paste into Ace / generic textarea
|
|
378
|
+
writeClipboard(content);
|
|
379
|
+
const ed = await page.$('.ace_editor, .cm-editor, .monaco-editor, textarea');
|
|
380
|
+
if (!ed) throw new Error('No code editor found on page');
|
|
381
|
+
const edRect = await page.evaluate(el => {
|
|
382
|
+
el.scrollIntoView({ block: 'nearest' });
|
|
383
|
+
const r = el.getBoundingClientRect();
|
|
384
|
+
return { x: Math.round(r.x + r.width / 2), y: Math.round(r.y + r.height / 2) };
|
|
385
|
+
}, ed);
|
|
386
|
+
await page.mouse.click(edRect.x, edRect.y);
|
|
387
|
+
await sleep(200);
|
|
388
|
+
await page.keyboard.down('Control');
|
|
389
|
+
await page.keyboard.press('a');
|
|
390
|
+
await page.keyboard.up('Control');
|
|
391
|
+
await sleep(100);
|
|
392
|
+
await page.keyboard.down('Control');
|
|
393
|
+
await page.keyboard.press('v');
|
|
394
|
+
await page.keyboard.up('Control');
|
|
395
|
+
await sleep(800);
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
// ─── Phase 1: Login ───────────────────────────────────────────────────────────
|
|
399
|
+
|
|
400
|
+
async function login(page, email, password) {
|
|
401
|
+
console.log('[login] Navigating to studio.ubox.world...');
|
|
402
|
+
await page.goto(`${BASE_URL}/`, { waitUntil: 'networkidle2' });
|
|
403
|
+
|
|
404
|
+
if (!page.url().includes('#/login')) {
|
|
405
|
+
console.log('[login] Already logged in, skipping');
|
|
406
|
+
return;
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
console.log('[login] Filling credentials...');
|
|
410
|
+
await typeInField(page, 'input[type="email"], input[placeholder="Email"]', email);
|
|
411
|
+
await typeInField(page, 'input[type="password"], input[placeholder="Password"]', password);
|
|
412
|
+
await clickByText(page, 'Sign in', 'button');
|
|
413
|
+
await page.waitForFunction(() => !location.href.includes('#/login'), { timeout: 20000 });
|
|
414
|
+
console.log('[login] Logged in');
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
// ─── Phase 2: Application Deployment ─────────────────────────────────────────
|
|
418
|
+
|
|
419
|
+
async function findOrCreateApp(page, fullName) {
|
|
420
|
+
console.log(` [app] Searching for "${fullName}"...`);
|
|
421
|
+
await page.goto(`${BASE_URL}/#/apps`, { waitUntil: 'networkidle2' });
|
|
422
|
+
await sleep(1500);
|
|
423
|
+
await searchFor(page, fullName);
|
|
424
|
+
|
|
425
|
+
// Scan all app card links and match by card title text.
|
|
426
|
+
// The link <a href="#/application/XXX"> wraps the card image (outside card-body),
|
|
427
|
+
// so closest('[class*="card"]') on the title <p> would stop at card-body and miss it.
|
|
428
|
+
// Instead, iterate links directly and check their parent .card for the title.
|
|
429
|
+
const appHref = await page.evaluate(name => {
|
|
430
|
+
for (const link of document.querySelectorAll('a[href*="#/application/"]')) {
|
|
431
|
+
const card = link.closest('.card');
|
|
432
|
+
if (!card) continue;
|
|
433
|
+
const title = card.querySelector('p.card-text');
|
|
434
|
+
if (title && title.textContent.trim() === name) return link.href;
|
|
435
|
+
}
|
|
436
|
+
return null;
|
|
437
|
+
}, fullName);
|
|
438
|
+
|
|
439
|
+
if (appHref) {
|
|
440
|
+
console.log(` [app] Found: ${appHref}`);
|
|
441
|
+
await page.goto(appHref, { waitUntil: 'networkidle2' });
|
|
442
|
+
await sleep(2000);
|
|
443
|
+
return appHref;
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
// Create new application
|
|
447
|
+
console.log(` [app] Not found. Creating "${fullName}"...`);
|
|
448
|
+
await clickByText(page, 'New application', 'button');
|
|
449
|
+
await sleep(1200);
|
|
450
|
+
|
|
451
|
+
// Fill the name field in the modal
|
|
452
|
+
const nameInput = await page.$('#create-application-form input[type="text"], #create-application-form input[name="name"]');
|
|
453
|
+
if (!nameInput) throw new Error('App creation modal: name input not found');
|
|
454
|
+
await nameInput.click({ clickCount: 3 });
|
|
455
|
+
await page.keyboard.type(fullName, { delay: 30 });
|
|
456
|
+
await sleep(400);
|
|
457
|
+
|
|
458
|
+
await clickByText(page, 'Create', 'button');
|
|
459
|
+
await sleep(3000);
|
|
460
|
+
|
|
461
|
+
const newUrl = page.url();
|
|
462
|
+
console.log(` [app] Created: ${newUrl}`);
|
|
463
|
+
return newUrl;
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
async function uploadResources(page, assets) {
|
|
467
|
+
if (Object.keys(assets).length === 0) {
|
|
468
|
+
console.log(' [resources] No assets');
|
|
469
|
+
return;
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
console.log(` [resources] Uploading ${Object.keys(assets).length} asset(s)...`);
|
|
473
|
+
await clickByText(page, 'Resources');
|
|
474
|
+
await sleep(1500);
|
|
475
|
+
|
|
476
|
+
// Group assets by sub-tab
|
|
477
|
+
const byTab = {};
|
|
478
|
+
for (const [filename, info] of Object.entries(assets)) {
|
|
479
|
+
const label = info.type === 'images' ? 'Images' : info.type === 'fonts' ? 'Fonts' : 'Files';
|
|
480
|
+
if (!byTab[label]) byTab[label] = [];
|
|
481
|
+
byTab[label].push({ filename, ...info });
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
for (const [tabLabel, files] of Object.entries(byTab)) {
|
|
485
|
+
await clickByText(page, tabLabel);
|
|
486
|
+
await sleep(1000);
|
|
487
|
+
|
|
488
|
+
for (const { filename, absPath } of files) {
|
|
489
|
+
// Delete existing file with same name before uploading (avoid duplicates).
|
|
490
|
+
// Resource cards have <h5 class="card-title dcartitle" title="01.jpg">01.jpg</h5>
|
|
491
|
+
// with a <button class="btn btn-danger btn-sm"> delete button inside the same card.
|
|
492
|
+
const existingDeletePos = await page.evaluate(name => {
|
|
493
|
+
const h5 = Array.from(document.querySelectorAll('h5.dcartitle, h5.card-title'))
|
|
494
|
+
.find(el => el.getAttribute('title') === name || el.textContent.trim() === name);
|
|
495
|
+
if (!h5) return null;
|
|
496
|
+
const card = h5.closest('.card') || h5.parentElement;
|
|
497
|
+
if (!card) return null;
|
|
498
|
+
const deleteBtn = card.querySelector('.btn-danger, button.btn-danger');
|
|
499
|
+
if (!deleteBtn) return null;
|
|
500
|
+
deleteBtn.scrollIntoView({ block: 'nearest' });
|
|
501
|
+
const r = deleteBtn.getBoundingClientRect();
|
|
502
|
+
return { x: Math.round(r.x + r.width / 2), y: Math.round(r.y + r.height / 2) };
|
|
503
|
+
}, filename);
|
|
504
|
+
|
|
505
|
+
if (existingDeletePos) {
|
|
506
|
+
console.log(` ${filename} — deleting existing file first`);
|
|
507
|
+
page.once('dialog', d => d.accept());
|
|
508
|
+
await page.mouse.click(existingDeletePos.x, existingDeletePos.y);
|
|
509
|
+
await sleep(2000);
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
console.log(` ${filename} → ${tabLabel}`);
|
|
513
|
+
await uploadFile(page, absPath);
|
|
514
|
+
await sleep(3000);
|
|
515
|
+
}
|
|
516
|
+
}
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
async function configureParameters(page, params) {
|
|
520
|
+
const entries = Object.entries(params);
|
|
521
|
+
if (entries.length === 0) {
|
|
522
|
+
console.log(' [params] No parameters');
|
|
523
|
+
return;
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
console.log(` [params] Configuring ${entries.length} parameter(s)...`);
|
|
527
|
+
await clickByText(page, 'Parameters');
|
|
528
|
+
await sleep(1500);
|
|
529
|
+
|
|
530
|
+
// Check which parameters already exist in the read-only table.
|
|
531
|
+
// The read-only view has <th scope="row">key</th> for each parameter row.
|
|
532
|
+
const existingKeys = await page.evaluate(() => {
|
|
533
|
+
return Array.from(document.querySelectorAll('table tbody th[scope="row"]'))
|
|
534
|
+
.map(th => th.textContent.trim());
|
|
535
|
+
});
|
|
536
|
+
|
|
537
|
+
const toCreate = entries.filter(([key]) => !existingKeys.includes(key));
|
|
538
|
+
|
|
539
|
+
if (toCreate.length === 0) {
|
|
540
|
+
console.log(' [params] All parameters already exist, skipping');
|
|
541
|
+
return;
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
console.log(` [params] Creating ${toCreate.length} new parameter(s) (${existingKeys.length} already exist)...`);
|
|
545
|
+
|
|
546
|
+
// Accept any confirm() dialogs that Save triggers
|
|
547
|
+
page.on('dialog', async d => { await d.accept(); });
|
|
548
|
+
|
|
549
|
+
await clickByText(page, 'Edit', 'button');
|
|
550
|
+
await sleep(1000);
|
|
551
|
+
|
|
552
|
+
for (const [key, def] of toCreate) {
|
|
553
|
+
const added = await clickByTextSoft(page, 'Add One', 'button') ||
|
|
554
|
+
await clickByTextSoft(page, 'Add one', 'button');
|
|
555
|
+
if (!added) throw new Error('Could not find "Add One" button in Parameters');
|
|
556
|
+
await sleep(800);
|
|
557
|
+
|
|
558
|
+
// Find the empty row and fill fields left-to-right: KEY, DESCRIPTION, VALUE, DEFAULT VALUE
|
|
559
|
+
const fieldRects = await page.evaluate(() => {
|
|
560
|
+
const rows = Array.from(document.querySelectorAll('table tr'));
|
|
561
|
+
for (const row of rows) {
|
|
562
|
+
const inputs = Array.from(row.querySelectorAll('input, textarea'))
|
|
563
|
+
.filter(e => { const r = e.getBoundingClientRect(); return r.width > 0 && r.height > 0; });
|
|
564
|
+
if (inputs.length < 1) continue;
|
|
565
|
+
if (inputs[0].value.trim() === '') {
|
|
566
|
+
return inputs.slice(0, 4).map(el => {
|
|
567
|
+
el.scrollIntoView({ block: 'nearest' });
|
|
568
|
+
const r = el.getBoundingClientRect();
|
|
569
|
+
return { x: Math.round(r.x + r.width / 2), y: Math.round(r.y + r.height / 2) };
|
|
570
|
+
});
|
|
571
|
+
}
|
|
572
|
+
}
|
|
573
|
+
return null;
|
|
574
|
+
});
|
|
575
|
+
|
|
576
|
+
if (!fieldRects) throw new Error(`Could not find empty parameter row for key: "${key}"`);
|
|
577
|
+
|
|
578
|
+
const values = [
|
|
579
|
+
key,
|
|
580
|
+
def.description || '',
|
|
581
|
+
def.value || key,
|
|
582
|
+
def['default value'] || key,
|
|
583
|
+
];
|
|
584
|
+
|
|
585
|
+
for (let i = 0; i < Math.min(fieldRects.length, values.length); i++) {
|
|
586
|
+
await page.mouse.click(fieldRects[i].x, fieldRects[i].y);
|
|
587
|
+
await page.keyboard.down('Control');
|
|
588
|
+
await page.keyboard.press('a');
|
|
589
|
+
await page.keyboard.up('Control');
|
|
590
|
+
await page.keyboard.press('Delete');
|
|
591
|
+
await page.keyboard.type(values[i], { delay: 20 });
|
|
592
|
+
await sleep(100);
|
|
593
|
+
}
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
await clickByText(page, 'Save', 'button');
|
|
597
|
+
await sleep(1500);
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
async function injectSource(page, proxy) {
|
|
601
|
+
console.log(' [source] Injecting source files...');
|
|
602
|
+
await clickByText(page, 'Source');
|
|
603
|
+
await sleep(1500);
|
|
604
|
+
|
|
605
|
+
await clickByText(page, 'Edit source');
|
|
606
|
+
await sleep(3000);
|
|
607
|
+
|
|
608
|
+
// Tab label → filename
|
|
609
|
+
const tabs = [
|
|
610
|
+
['HTML', 'index.html' ],
|
|
611
|
+
['CSS', 'style.css' ],
|
|
612
|
+
['Ubox Events', 'ubox.js' ],
|
|
613
|
+
['Workflow', 'workflow.js'],
|
|
614
|
+
['Business logic', 'logic.js' ],
|
|
615
|
+
['Data source', 'data.js' ],
|
|
616
|
+
];
|
|
617
|
+
|
|
618
|
+
for (const [tabLabel, filename] of tabs) {
|
|
619
|
+
if (!proxy[filename]) continue;
|
|
620
|
+
console.log(` ${filename} → ${tabLabel}`);
|
|
621
|
+
await clickByText(page, tabLabel);
|
|
622
|
+
await sleep(1000);
|
|
623
|
+
await injectToEditor(page, proxy[filename]);
|
|
624
|
+
await sleep(500);
|
|
625
|
+
}
|
|
626
|
+
|
|
627
|
+
await clickByText(page, 'Save and back to app');
|
|
628
|
+
await sleep(3000);
|
|
629
|
+
}
|
|
630
|
+
|
|
631
|
+
async function deployApp(page, appName, projectName, { noAssets = false } = {}) {
|
|
632
|
+
const fullName = `${projectName} ${appName}`;
|
|
633
|
+
const { proxy, assets, params } = generateProxy(appName);
|
|
634
|
+
|
|
635
|
+
console.log(`\n[Phase 2] App: ${fullName}`);
|
|
636
|
+
console.log(` Files: ${Object.keys(proxy).length}, Assets: ${Object.keys(assets).length}, Params: ${Object.keys(params).length}`);
|
|
637
|
+
|
|
638
|
+
await findOrCreateApp(page, fullName);
|
|
639
|
+
if (noAssets) {
|
|
640
|
+
console.log(' [resources] Skipped (--noassets)');
|
|
641
|
+
} else {
|
|
642
|
+
await uploadResources(page, assets);
|
|
643
|
+
}
|
|
644
|
+
await configureParameters(page, params);
|
|
645
|
+
await injectSource(page, proxy);
|
|
646
|
+
|
|
647
|
+
// Extract app ID from current URL
|
|
648
|
+
const appId = page.url().match(/#\/application(?:-source)?\/(\d+)/)?.[1] || '?';
|
|
649
|
+
console.log(` [app] Done — ID: ${appId}`);
|
|
650
|
+
return appId;
|
|
651
|
+
}
|
|
652
|
+
|
|
653
|
+
// ─── Phase 3: Ubox Deployment ─────────────────────────────────────────────────
|
|
654
|
+
|
|
655
|
+
async function handleTCModal(page) {
|
|
656
|
+
console.log(' [ubox] Handling Terms & Conditions modal...');
|
|
657
|
+
// Scroll all overflow elements to bottom to reveal the checkbox
|
|
658
|
+
await page.evaluate(() => {
|
|
659
|
+
Array.from(document.querySelectorAll('*')).forEach(e => {
|
|
660
|
+
const s = window.getComputedStyle(e);
|
|
661
|
+
if ((s.overflowY === 'auto' || s.overflowY === 'scroll') && e.scrollHeight > e.clientHeight) {
|
|
662
|
+
e.scrollTop = e.scrollHeight;
|
|
663
|
+
}
|
|
664
|
+
});
|
|
665
|
+
});
|
|
666
|
+
await sleep(800);
|
|
667
|
+
|
|
668
|
+
// Check the checkbox
|
|
669
|
+
await page.evaluate(() => {
|
|
670
|
+
const cb = Array.from(document.querySelectorAll('input[type="checkbox"]'))
|
|
671
|
+
.find(e => { const r = e.getBoundingClientRect(); return r.width > 0 && r.height > 0; });
|
|
672
|
+
if (cb && !cb.checked) cb.click();
|
|
673
|
+
});
|
|
674
|
+
await sleep(400);
|
|
675
|
+
|
|
676
|
+
await clickByText(page, 'Accept', 'button');
|
|
677
|
+
await sleep(1500);
|
|
678
|
+
}
|
|
679
|
+
|
|
680
|
+
async function findOrCreateUbox(page, fullName) {
|
|
681
|
+
console.log(` [ubox] Searching for "${fullName}"...`);
|
|
682
|
+
await page.goto(`${BASE_URL}/#/uboxes`, { waitUntil: 'networkidle2' });
|
|
683
|
+
await sleep(1500);
|
|
684
|
+
await searchFor(page, fullName);
|
|
685
|
+
|
|
686
|
+
// Same pattern as apps: link is outside card-body, so scan links directly.
|
|
687
|
+
const uboxHref = await page.evaluate(name => {
|
|
688
|
+
for (const link of document.querySelectorAll('a[href*="#/ubox/"]')) {
|
|
689
|
+
// Exclude nav links like "#/uboxes" — only match "#/ubox/NNN"
|
|
690
|
+
if (!/\/#\/ubox\/\d+$/.test(link.href)) continue;
|
|
691
|
+
const card = link.closest('.card');
|
|
692
|
+
if (!card) continue;
|
|
693
|
+
const title = card.querySelector('p.card-text');
|
|
694
|
+
if (title && title.textContent.trim() === name) return link.href;
|
|
695
|
+
}
|
|
696
|
+
return null;
|
|
697
|
+
}, fullName);
|
|
698
|
+
|
|
699
|
+
if (uboxHref) {
|
|
700
|
+
console.log(` [ubox] Found: ${uboxHref}`);
|
|
701
|
+
await page.goto(uboxHref, { waitUntil: 'networkidle2' });
|
|
702
|
+
await sleep(2000);
|
|
703
|
+
return { url: uboxHref, created: false };
|
|
704
|
+
}
|
|
705
|
+
|
|
706
|
+
// Create new Ubox
|
|
707
|
+
console.log(` [ubox] Not found. Creating "${fullName}"...`);
|
|
708
|
+
await clickByText(page, 'New Ubox', 'button');
|
|
709
|
+
await sleep(1500);
|
|
710
|
+
|
|
711
|
+
// Check if T&C modal is showing (has "Accept" button)
|
|
712
|
+
const hasTCModal = await page.evaluate(() => {
|
|
713
|
+
return Array.from(document.querySelectorAll('button'))
|
|
714
|
+
.some(b => { const r = b.getBoundingClientRect(); return r.width > 0 && r.height > 0 && b.textContent.trim() === 'Accept'; });
|
|
715
|
+
});
|
|
716
|
+
if (hasTCModal) await handleTCModal(page);
|
|
717
|
+
|
|
718
|
+
// Fill Ubox form
|
|
719
|
+
const nameInput = await page.$('#ubox-form input[name="name"], #ubox-form input[placeholder*="name" i], .modal input[type="text"]');
|
|
720
|
+
if (!nameInput) throw new Error('Ubox creation modal: name input not found');
|
|
721
|
+
await nameInput.click({ clickCount: 3 });
|
|
722
|
+
await page.keyboard.type(fullName, { delay: 30 });
|
|
723
|
+
await sleep(300);
|
|
724
|
+
|
|
725
|
+
// Place select: find 1st visible select, choose "Claude"
|
|
726
|
+
await page.evaluate(() => {
|
|
727
|
+
const sels = Array.from(document.querySelectorAll('select'))
|
|
728
|
+
.filter(e => { const r = e.getBoundingClientRect(); return r.width > 0 && r.height > 0; });
|
|
729
|
+
if (sels[0]) {
|
|
730
|
+
const opt = Array.from(sels[0].options).find(o => /Claude/i.test(o.text));
|
|
731
|
+
if (opt) { sels[0].value = opt.value; sels[0].dispatchEvent(new Event('change', { bubbles: true })); }
|
|
732
|
+
}
|
|
733
|
+
});
|
|
734
|
+
await sleep(300);
|
|
735
|
+
|
|
736
|
+
// Config select: find 2nd visible select, choose "MyUboxConf"
|
|
737
|
+
await page.evaluate(() => {
|
|
738
|
+
const sels = Array.from(document.querySelectorAll('select'))
|
|
739
|
+
.filter(e => { const r = e.getBoundingClientRect(); return r.width > 0 && r.height > 0; });
|
|
740
|
+
if (sels[1]) {
|
|
741
|
+
const opt = Array.from(sels[1].options).find(o => /MyUboxConf/i.test(o.text));
|
|
742
|
+
if (opt) { sels[1].value = opt.value; sels[1].dispatchEvent(new Event('change', { bubbles: true })); }
|
|
743
|
+
}
|
|
744
|
+
});
|
|
745
|
+
await sleep(300);
|
|
746
|
+
|
|
747
|
+
await clickByText(page, 'Create', 'button');
|
|
748
|
+
await sleep(3000);
|
|
749
|
+
|
|
750
|
+
const newUrl = page.url();
|
|
751
|
+
console.log(` [ubox] Created: ${newUrl}`);
|
|
752
|
+
return { url: newUrl, created: true };
|
|
753
|
+
}
|
|
754
|
+
|
|
755
|
+
async function installApp(page, appFullName) {
|
|
756
|
+
console.log(` [ubox] Installing "${appFullName}"...`);
|
|
757
|
+
|
|
758
|
+
// Click "Available applications" tab
|
|
759
|
+
const tabPos = await page.evaluate(() => {
|
|
760
|
+
const tab = Array.from(document.querySelectorAll('a, li')).find(e => {
|
|
761
|
+
const r = e.getBoundingClientRect();
|
|
762
|
+
return r.width > 0 && r.height > 0 && e.textContent.trim() === 'Available applications';
|
|
763
|
+
});
|
|
764
|
+
if (!tab) return null;
|
|
765
|
+
tab.scrollIntoView({ block: 'nearest' });
|
|
766
|
+
const r = tab.getBoundingClientRect();
|
|
767
|
+
return { x: Math.round(r.x + r.width / 2), y: Math.round(r.y + r.height / 2) };
|
|
768
|
+
});
|
|
769
|
+
if (!tabPos) throw new Error('"Available applications" tab not found');
|
|
770
|
+
await page.mouse.click(tabPos.x, tabPos.y);
|
|
771
|
+
await sleep(1500);
|
|
772
|
+
|
|
773
|
+
await searchFor(page, appFullName);
|
|
774
|
+
|
|
775
|
+
// Find the Install <a> element. After search only one card is visible.
|
|
776
|
+
// The button has class "uboxBot" per the platform HTML; its textContent
|
|
777
|
+
// includes a Font Awesome icon character before "Install" so we match by
|
|
778
|
+
// class or by text ending with "Install" rather than exact equality.
|
|
779
|
+
const installPos = await page.evaluate(() => {
|
|
780
|
+
const el = document.querySelector('a.uboxBot') ||
|
|
781
|
+
Array.from(document.querySelectorAll('a')).find(e => {
|
|
782
|
+
const r = e.getBoundingClientRect();
|
|
783
|
+
return r.width > 0 && r.height > 0 && e.textContent.trim().endsWith('Install');
|
|
784
|
+
});
|
|
785
|
+
if (!el) return null;
|
|
786
|
+
el.scrollIntoView({ block: 'center' });
|
|
787
|
+
const r = el.getBoundingClientRect();
|
|
788
|
+
return { x: Math.round(r.x + r.width / 2), y: Math.round(r.y + r.height / 2) };
|
|
789
|
+
});
|
|
790
|
+
|
|
791
|
+
if (!installPos) throw new Error(`Install button for "${appFullName}" not found`);
|
|
792
|
+
await page.mouse.click(installPos.x, installPos.y);
|
|
793
|
+
await sleep(2500);
|
|
794
|
+
console.log(' [ubox] Installed');
|
|
795
|
+
}
|
|
796
|
+
|
|
797
|
+
async function checkMakePublic(page) {
|
|
798
|
+
// The checkbox is <input id="flexCheckChecked"> — a sibling of its label,
|
|
799
|
+
// both inside a .form-check div in the page header.
|
|
800
|
+
// Angular requires page.mouse.click(), NOT element.click() from evaluate().
|
|
801
|
+
const cbPos = await page.evaluate(() => {
|
|
802
|
+
// Primary: known ID from the DOM snapshot
|
|
803
|
+
const cb = document.querySelector('#flexCheckChecked') ||
|
|
804
|
+
Array.from(document.querySelectorAll('input[type="checkbox"]')).find(c => {
|
|
805
|
+
const r = c.getBoundingClientRect();
|
|
806
|
+
return r.width > 0 && r.height > 0;
|
|
807
|
+
});
|
|
808
|
+
if (!cb) return null;
|
|
809
|
+
if (cb.checked) return 'already';
|
|
810
|
+
cb.scrollIntoView({ block: 'nearest' });
|
|
811
|
+
const r = cb.getBoundingClientRect();
|
|
812
|
+
return { x: Math.round(r.x + r.width / 2), y: Math.round(r.y + r.height / 2) };
|
|
813
|
+
});
|
|
814
|
+
|
|
815
|
+
if (cbPos === null) { console.warn(' [ubox] WARNING: "make public" checkbox not found'); return; }
|
|
816
|
+
if (cbPos === 'already') { console.log(' [ubox] "make public" already checked'); return; }
|
|
817
|
+
|
|
818
|
+
await page.mouse.click(cbPos.x, cbPos.y);
|
|
819
|
+
await sleep(600);
|
|
820
|
+
console.log(' [ubox] Checked "make public"');
|
|
821
|
+
}
|
|
822
|
+
|
|
823
|
+
async function getVirtualLink(page) {
|
|
824
|
+
// Click "Open Virtual Ubox Link" — it opens a new tab; capture that tab's URL.
|
|
825
|
+
const browser = page.browser();
|
|
826
|
+
|
|
827
|
+
const newPagePromise = new Promise(resolve => {
|
|
828
|
+
browser.once('targetcreated', async target => {
|
|
829
|
+
const newPage = await target.page();
|
|
830
|
+
resolve(newPage);
|
|
831
|
+
});
|
|
832
|
+
});
|
|
833
|
+
|
|
834
|
+
// Button has an <i> icon child so textContent is e.g. " Open Virtual Ubox Link".
|
|
835
|
+
// Use includes() and page.mouse.click() (Angular SPA rule).
|
|
836
|
+
const btnPos = await page.evaluate(() => {
|
|
837
|
+
const btn = Array.from(document.querySelectorAll('button')).find(e => {
|
|
838
|
+
const r = e.getBoundingClientRect();
|
|
839
|
+
return r.width > 0 && r.height > 0 && e.textContent.includes('Open Virtual Ubox Link');
|
|
840
|
+
});
|
|
841
|
+
if (!btn) return null;
|
|
842
|
+
btn.scrollIntoView({ block: 'nearest' });
|
|
843
|
+
const r = btn.getBoundingClientRect();
|
|
844
|
+
return { x: Math.round(r.x + r.width / 2), y: Math.round(r.y + r.height / 2) };
|
|
845
|
+
});
|
|
846
|
+
if (!btnPos) {
|
|
847
|
+
console.warn(' [ubox] WARNING: "Open Virtual Ubox Link" button not found');
|
|
848
|
+
return null;
|
|
849
|
+
}
|
|
850
|
+
await page.mouse.click(btnPos.x, btnPos.y);
|
|
851
|
+
|
|
852
|
+
const newPage = await Promise.race([
|
|
853
|
+
newPagePromise,
|
|
854
|
+
sleep(8000).then(() => null),
|
|
855
|
+
]);
|
|
856
|
+
if (!newPage) {
|
|
857
|
+
console.warn(' [ubox] WARNING: New tab did not open');
|
|
858
|
+
return null;
|
|
859
|
+
}
|
|
860
|
+
|
|
861
|
+
// Wait for the virtual player page to fully load
|
|
862
|
+
await newPage.waitForNavigation({ waitUntil: 'networkidle2', timeout: 15000 }).catch(() => {});
|
|
863
|
+
await sleep(1000);
|
|
864
|
+
const virtualLink = newPage.url();
|
|
865
|
+
await newPage.close();
|
|
866
|
+
return virtualLink;
|
|
867
|
+
}
|
|
868
|
+
|
|
869
|
+
async function setMobileLink(page, appFullName, mobileVirtualLink) {
|
|
870
|
+
console.log(` [ubox] Setting mobileLink param for "${appFullName}"...`);
|
|
871
|
+
|
|
872
|
+
// Click "Installed applications" tab — search only <a> tags to avoid
|
|
873
|
+
// accidentally matching <li> wrappers with combined child text
|
|
874
|
+
const tabPos = await page.evaluate(() => {
|
|
875
|
+
const tab = Array.from(document.querySelectorAll('a')).find(e => {
|
|
876
|
+
const r = e.getBoundingClientRect();
|
|
877
|
+
return r.width > 0 && r.height > 0 && /^\s*Installed applications\s*$/.test(e.textContent);
|
|
878
|
+
});
|
|
879
|
+
if (!tab) return null;
|
|
880
|
+
tab.scrollIntoView({ block: 'nearest' });
|
|
881
|
+
const r = tab.getBoundingClientRect();
|
|
882
|
+
return { x: Math.round(r.x + r.width / 2), y: Math.round(r.y + r.height / 2) };
|
|
883
|
+
});
|
|
884
|
+
if (!tabPos) throw new Error('"Installed applications" tab not found');
|
|
885
|
+
await page.mouse.click(tabPos.x, tabPos.y);
|
|
886
|
+
await sleep(1500);
|
|
887
|
+
|
|
888
|
+
// Find "Change params" button directly — the page header has an <h1> with the same
|
|
889
|
+
// app name so searching by title text is unreliable. Use the known uboxBot class
|
|
890
|
+
// (same class the Install button uses) combined with btn-outline-info.
|
|
891
|
+
const changeParamsPos = await page.evaluate(() => {
|
|
892
|
+
const btn = document.querySelector('a.uboxBot.btn-outline-info') ||
|
|
893
|
+
Array.from(document.querySelectorAll('a, button')).find(e => {
|
|
894
|
+
const r = e.getBoundingClientRect();
|
|
895
|
+
return r.width > 0 && r.height > 0 && e.textContent.includes('Change params');
|
|
896
|
+
});
|
|
897
|
+
if (!btn) return null;
|
|
898
|
+
btn.scrollIntoView({ block: 'center' });
|
|
899
|
+
const r = btn.getBoundingClientRect();
|
|
900
|
+
return { x: Math.round(r.x + r.width / 2), y: Math.round(r.y + r.height / 2) };
|
|
901
|
+
});
|
|
902
|
+
|
|
903
|
+
if (!changeParamsPos) throw new Error(`"Change params" button not found`);
|
|
904
|
+
await page.mouse.click(changeParamsPos.x, changeParamsPos.y);
|
|
905
|
+
|
|
906
|
+
// Wait for the modal to open and the API to populate the params table rows
|
|
907
|
+
await page.waitForFunction(() => {
|
|
908
|
+
const modal = document.querySelector('#change-params-modal');
|
|
909
|
+
if (!modal) return false;
|
|
910
|
+
// Bootstrap adds "show" class when visible; params are loaded when tbody has tds
|
|
911
|
+
return modal.classList.contains('show') && modal.querySelectorAll('td').length > 0;
|
|
912
|
+
}, { timeout: 12000 }).catch(() => {});
|
|
913
|
+
await sleep(400);
|
|
914
|
+
|
|
915
|
+
// Find the value cell (column index 1) for the "mobileLink" key row
|
|
916
|
+
const valueInputPos = await page.evaluate(() => {
|
|
917
|
+
const modal = document.querySelector('#change-params-modal');
|
|
918
|
+
if (!modal) return null;
|
|
919
|
+
for (const row of modal.querySelectorAll('tr')) {
|
|
920
|
+
const cells = row.querySelectorAll('td');
|
|
921
|
+
if (cells[0]?.textContent.trim() === 'mobileLink') {
|
|
922
|
+
const input = cells[1]?.querySelector('input, textarea');
|
|
923
|
+
if (!input) return null;
|
|
924
|
+
input.scrollIntoView({ block: 'nearest' });
|
|
925
|
+
const r = input.getBoundingClientRect();
|
|
926
|
+
return { x: Math.round(r.x + r.width / 2), y: Math.round(r.y + r.height / 2) };
|
|
927
|
+
}
|
|
928
|
+
}
|
|
929
|
+
return null;
|
|
930
|
+
});
|
|
931
|
+
|
|
932
|
+
if (!valueInputPos) throw new Error('mobileLink value input not found in params modal');
|
|
933
|
+
await page.mouse.click(valueInputPos.x, valueInputPos.y);
|
|
934
|
+
await page.keyboard.down('Control');
|
|
935
|
+
await page.keyboard.press('a');
|
|
936
|
+
await page.keyboard.up('Control');
|
|
937
|
+
await page.keyboard.press('Delete');
|
|
938
|
+
await page.keyboard.type(mobileVirtualLink, { delay: 20 });
|
|
939
|
+
await sleep(300);
|
|
940
|
+
|
|
941
|
+
await clickByText(page, 'Save', 'button');
|
|
942
|
+
await sleep(1500);
|
|
943
|
+
console.log(` [ubox] mobileLink = ${mobileVirtualLink}`);
|
|
944
|
+
}
|
|
945
|
+
|
|
946
|
+
async function deployUbox(page, appName, projectName, appParams, mobileVirtualLink) {
|
|
947
|
+
const fullName = `${projectName} ${appName}`;
|
|
948
|
+
console.log(`\n[Phase 3] Ubox: ${fullName}`);
|
|
949
|
+
|
|
950
|
+
const { url: uboxUrl, created } = await findOrCreateUbox(page, fullName);
|
|
951
|
+
|
|
952
|
+
let virtualLink = null;
|
|
953
|
+
|
|
954
|
+
if (created) {
|
|
955
|
+
// Fresh ubox — need to make public, install app, get virtual link, set params
|
|
956
|
+
await checkMakePublic(page);
|
|
957
|
+
await installApp(page, fullName);
|
|
958
|
+
|
|
959
|
+
if (appName === 'mobile') {
|
|
960
|
+
await sleep(500);
|
|
961
|
+
virtualLink = await getVirtualLink(page);
|
|
962
|
+
if (virtualLink) {
|
|
963
|
+
console.log(` [ubox] Mobile Virtual Link: ${virtualLink}`);
|
|
964
|
+
} else {
|
|
965
|
+
console.warn(' [ubox] WARNING: Could not retrieve Mobile Virtual Link');
|
|
966
|
+
}
|
|
967
|
+
}
|
|
968
|
+
|
|
969
|
+
if (appParams.mobileLink !== undefined && mobileVirtualLink) {
|
|
970
|
+
await setMobileLink(page, fullName, mobileVirtualLink);
|
|
971
|
+
}
|
|
972
|
+
} else {
|
|
973
|
+
// Existing ubox — skip install, params, and virtual link retrieval
|
|
974
|
+
console.log(' [ubox] Already exists — skipping install, params, and virtual link');
|
|
975
|
+
|
|
976
|
+
// Still retrieve the virtual link for mobile so main can use it
|
|
977
|
+
if (appName === 'mobile') {
|
|
978
|
+
virtualLink = await getVirtualLink(page);
|
|
979
|
+
if (virtualLink) {
|
|
980
|
+
console.log(` [ubox] Mobile Virtual Link: ${virtualLink}`);
|
|
981
|
+
}
|
|
982
|
+
}
|
|
983
|
+
}
|
|
984
|
+
|
|
985
|
+
const uboxId = page.url().match(/#\/ubox\/(\d+)/)?.[1] ||
|
|
986
|
+
uboxUrl.match(/#\/ubox\/(\d+)/)?.[1] || '?';
|
|
987
|
+
return { uboxId, virtualLink };
|
|
988
|
+
}
|
|
989
|
+
|
|
990
|
+
// ─── Main ─────────────────────────────────────────────────────────────────────
|
|
991
|
+
|
|
992
|
+
async function main() {
|
|
993
|
+
const { apps, email: emailArg, password: passwordArg, projectName: projectNameArg, noAssets, noPlayer, show } = parseArgs();
|
|
994
|
+
|
|
995
|
+
if (apps.length === 0) {
|
|
996
|
+
console.error('No apps found in apps/ directory. Nothing to deploy.');
|
|
997
|
+
process.exit(1);
|
|
998
|
+
}
|
|
999
|
+
|
|
1000
|
+
const projectName = projectNameArg || PROJECT_NAME;
|
|
1001
|
+
|
|
1002
|
+
console.log(`Project : ${projectName}`);
|
|
1003
|
+
console.log(`Apps : ${apps.join(', ')}`);
|
|
1004
|
+
if (noAssets) console.log('Flags : --noassets (skip resource uploads)');
|
|
1005
|
+
if (noPlayer) console.log('Flags : --noplayer (skip Ubox creation)');
|
|
1006
|
+
console.log('');
|
|
1007
|
+
|
|
1008
|
+
const { email, password } = await promptCredentials(emailArg, passwordArg);
|
|
1009
|
+
|
|
1010
|
+
const browser = await puppeteer.launch({
|
|
1011
|
+
headless: !show,
|
|
1012
|
+
defaultViewport: null,
|
|
1013
|
+
args: ['--start-maximized'],
|
|
1014
|
+
});
|
|
1015
|
+
|
|
1016
|
+
const pages = await browser.pages();
|
|
1017
|
+
const page = pages[0] ?? await browser.newPage();
|
|
1018
|
+
page.setDefaultTimeout(20000);
|
|
1019
|
+
|
|
1020
|
+
const state = {};
|
|
1021
|
+
|
|
1022
|
+
try {
|
|
1023
|
+
// Phase 1 — Login
|
|
1024
|
+
await login(page, email, password);
|
|
1025
|
+
|
|
1026
|
+
// Phase 2 — Deploy apps (also collect params for Phase 3)
|
|
1027
|
+
const appParamsMap = {};
|
|
1028
|
+
for (const appName of apps) {
|
|
1029
|
+
state[appName] = {};
|
|
1030
|
+
const { params } = generateProxy(appName);
|
|
1031
|
+
appParamsMap[appName] = params;
|
|
1032
|
+
state[appName].appId = await deployApp(page, appName, projectName, { noAssets });
|
|
1033
|
+
}
|
|
1034
|
+
|
|
1035
|
+
// Phase 3 — Deploy Uboxes
|
|
1036
|
+
if (noPlayer) {
|
|
1037
|
+
console.log('\n[Phase 3] Skipped (--noplayer)');
|
|
1038
|
+
} else {
|
|
1039
|
+
let mobileVirtualLink = null;
|
|
1040
|
+
for (const appName of apps) {
|
|
1041
|
+
const { uboxId, virtualLink } = await deployUbox(
|
|
1042
|
+
page, appName, projectName, appParamsMap[appName], mobileVirtualLink
|
|
1043
|
+
);
|
|
1044
|
+
state[appName].uboxId = uboxId;
|
|
1045
|
+
if (appName === 'mobile' && virtualLink) {
|
|
1046
|
+
mobileVirtualLink = virtualLink;
|
|
1047
|
+
state[appName].virtualLink = virtualLink;
|
|
1048
|
+
}
|
|
1049
|
+
}
|
|
1050
|
+
}
|
|
1051
|
+
|
|
1052
|
+
// Summary
|
|
1053
|
+
console.log('\n--- Deployment complete ---\n');
|
|
1054
|
+
for (const appName of apps) {
|
|
1055
|
+
const s = state[appName];
|
|
1056
|
+
console.log(` ${appName} app → #/application/${s.appId}`);
|
|
1057
|
+
if (s.uboxId) console.log(` ${appName} Ubox → #/ubox/${s.uboxId}`);
|
|
1058
|
+
if (s.virtualLink) console.log(` Mobile Virtual Link: ${s.virtualLink}`);
|
|
1059
|
+
}
|
|
1060
|
+
console.log('');
|
|
1061
|
+
|
|
1062
|
+
} catch (err) {
|
|
1063
|
+
console.error('\n[ERROR]', err.message);
|
|
1064
|
+
const screenshotPath = path.join(PROJECT_ROOT, 'deploy_error.png');
|
|
1065
|
+
await page.screenshot({ path: screenshotPath });
|
|
1066
|
+
console.error(`Screenshot saved: ${screenshotPath}`);
|
|
1067
|
+
process.exitCode = 1;
|
|
1068
|
+
}
|
|
1069
|
+
|
|
1070
|
+
if (show) {
|
|
1071
|
+
console.log('Browser left open for inspection. Press Ctrl+C to close.');
|
|
1072
|
+
process.on('SIGINT', () => browser.close().then(() => process.exit()));
|
|
1073
|
+
await new Promise(() => {});
|
|
1074
|
+
} else {
|
|
1075
|
+
await browser.close();
|
|
1076
|
+
}
|
|
1077
|
+
}
|
|
1078
|
+
|
|
1079
|
+
main();
|
package/package.json
ADDED