@juuno-sdk/cli 1.0.5

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 ADDED
@@ -0,0 +1,245 @@
1
+ # Juuno CLI
2
+
3
+ Official CLI for Juuno external app development - simulator, deployment, and tools.
4
+
5
+ ## What This Does
6
+
7
+ The App Simulator loads your external app and displays it in a split-screen view:
8
+
9
+ - **Left**: Player view (how your app appears on screens)
10
+ - **Right**: Configuration UI (your app's settings interface)
11
+
12
+ This simulates the real Juuno platform environment using import maps, just like production.
13
+
14
+ ## Installation
15
+
16
+ ```bash
17
+ npm install --save-dev @juuno-sdk/cli
18
+ ```
19
+
20
+ ## Commands
21
+
22
+ ### Testing: App Simulator
23
+
24
+ Test your app locally before deployment.
25
+
26
+ ```bash
27
+ # Point to your dev server
28
+ npx juuno-cli dev --app http://localhost:3000
29
+
30
+ # Or point to a deployed URL
31
+ npx juuno-cli dev --app https://cdn.example.com/my-app/
32
+ ```
33
+
34
+ ### Deployment
35
+
36
+ Deploy your app to Juuno CDN.
37
+
38
+ ```bash
39
+ # Deploy from default ./dist directory
40
+ npx juuno-cli deploy
41
+
42
+ # Specify build directory
43
+ npx juuno-cli deploy --build-dir ./build
44
+
45
+ # Provide API key inline (otherwise will prompt)
46
+ npx juuno-cli deploy --api-key your-juuno-api-key
47
+ ```
48
+
49
+ ### npm Scripts
50
+
51
+ Add to your `package.json`:
52
+
53
+ ```json
54
+ {
55
+ "scripts": {
56
+ "dev": "vite",
57
+ "build": "vite build",
58
+ "test:player": "juuno-cli dev --app http://localhost:3000",
59
+ "deploy": "juuno-cli deploy"
60
+ }
61
+ }
62
+ ```
63
+
64
+ Then run:
65
+
66
+ ```bash
67
+ # Terminal 1: Your app dev server
68
+ npm run dev
69
+
70
+ # Terminal 2: Simulator
71
+ npm run test:player
72
+
73
+ # Deploy to production
74
+ npm run build
75
+ npm run deploy
76
+ ```
77
+
78
+ ## How It Works
79
+
80
+ 1. **Your App**: Runs on your dev server (e.g., `localhost:3000`)
81
+ 2. **Simulator**: Loads at `localhost:5004`
82
+ 3. **Import Maps**: Simulator loads your app via ES module import
83
+ 4. **Live Updates**: Changes in your app reflect immediately
84
+
85
+ ## Requirements
86
+
87
+ Your app must:
88
+
89
+ - Export ES modules
90
+ - Provide player and config components
91
+ - Build with externalized dependencies:
92
+ ```js
93
+ // vite.config.ts
94
+ export default {
95
+ build: {
96
+ lib: {
97
+ entry: './src/index.ts',
98
+ formats: ['es'],
99
+ fileName: 'index',
100
+ },
101
+ rollupOptions: {
102
+ external: ['vue', '@juuno-sdk/app-sdk'],
103
+ },
104
+ },
105
+ };
106
+ ```
107
+
108
+ ## Development Workflow
109
+
110
+ The simulator works with built app bundles to match the production architecture.
111
+
112
+ **Terminal 1: Auto-rebuild your app**
113
+
114
+ ```bash
115
+ cd my-juuno-app
116
+ npm run build:watch
117
+ # Watches for changes and rebuilds automatically
118
+ ```
119
+
120
+ **Terminal 2: Serve your app bundle**
121
+
122
+ ```bash
123
+ cd my-juuno-app
124
+ npx serve dist -l 3000 --cors
125
+ # Serves the built bundle at localhost:3000
126
+ ```
127
+
128
+ **Terminal 3: Start the simulator**
129
+
130
+ ```bash
131
+ npx juuno-cli dev --app http://localhost:3000/index.js
132
+ # → localhost:5004
133
+ ```
134
+
135
+ **Development Flow:**
136
+
137
+ 1. Edit your app's source code
138
+ 2. Auto-rebuild happens in background (100-500ms)
139
+ 3. Manually refresh browser at http://localhost:5004
140
+ 4. See changes reflected in the simulator
141
+
142
+ **Note:** This workflow matches production closely (import maps + static bundles) but doesn't support HMR.
143
+
144
+ ## CLI Options
145
+
146
+ ### Simulator (dev command)
147
+
148
+ ```bash
149
+ juuno-cli dev [options]
150
+
151
+ Options:
152
+ --app <url> URL of your external app (required)
153
+ --port <number> Port for simulator (default: 5004)
154
+ --help Show help
155
+ ```
156
+
157
+ ### Deploy Command
158
+
159
+ ```bash
160
+ juuno-cli deploy [options]
161
+
162
+ Options:
163
+ --build-dir <dir> Build directory to deploy (default: ./dist)
164
+ --api-key <key> Juuno API key (will prompt if not provided)
165
+ --help Show help
166
+ ```
167
+
168
+ ## Deployment Workflow
169
+
170
+ 1. **Build your app**: Run your build command to generate the dist bundle
171
+ 2. **Deploy to CDN**: Use `juuno-cli deploy` to upload your app
172
+ 3. **Get API key**: Obtain a Juuno API key from your account settings
173
+ 4. **Configure in Juuno**: Add the returned manifest URL to your Juuno account
174
+
175
+ ```bash
176
+ # Complete deployment flow
177
+ npm run build
178
+ npx juuno-cli deploy
179
+
180
+ # Follow the prompts:
181
+ # 1. Enter your Juuno API key
182
+ # 2. CLI uploads to S3 and returns manifest URL
183
+ # 3. Add manifest URL to Juuno account (SDK > Custom Apps)
184
+ ```
185
+
186
+ Your app will be hosted at:
187
+
188
+ ```
189
+ https://[bucket].s3.[region].amazonaws.com/[account]/[app-id]/[version]/manifest.json
190
+ ```
191
+
192
+ ## Troubleshooting
193
+
194
+ ### CORS Errors
195
+
196
+ If you see CORS errors, your dev server needs to enable CORS:
197
+
198
+ ```js
199
+ // vite.config.ts
200
+ export default {
201
+ server: {
202
+ cors: true,
203
+ },
204
+ };
205
+ ```
206
+
207
+ ### Module Not Found
208
+
209
+ Ensure your app exports the required components:
210
+
211
+ ```ts
212
+ // src/index.ts
213
+ export { MyAppSlide } from './MyAppSlide.vue';
214
+ export { MyAppConfig } from './MyAppConfig.vue';
215
+ export { defaultMeta } from './settings';
216
+ ```
217
+
218
+ ### Import Map Issues
219
+
220
+ The simulator uses import maps to load dependencies. If you see import errors, check that your app properly externalizes `vue` and `@juuno-sdk/app-sdk`.
221
+
222
+ ## Example
223
+
224
+ See [@juuno-sdk/app-sdk-example](../app-sdk-example) for a complete working example.
225
+
226
+ ## Related Packages
227
+
228
+ - **@juuno-sdk/app-sdk**: SDK for building external apps
229
+ - **@juuno-sdk/app-sdk-example**: Example external app demonstrating the workflow
230
+
231
+ ## Package Exports
232
+
233
+ The CLI provides both programmatic and command-line interfaces:
234
+
235
+ - **CLI Commands**: `juuno-cli dev`, `juuno-cli deploy`
236
+ - **Programmatic API**: Import deployment functions for custom workflows
237
+
238
+ ```typescript
239
+ import { deployApp } from '@juuno-sdk/cli';
240
+
241
+ await deployApp({
242
+ buildDir: './dist',
243
+ apiKey: process.env.JUUNO_API_KEY,
244
+ });
245
+ ```
package/bin/cli.js ADDED
@@ -0,0 +1,237 @@
1
+ #!/usr/bin/env node
2
+
3
+ import { spawn } from 'child_process';
4
+ import { fileURLToPath } from 'url';
5
+ import { dirname, join } from 'path';
6
+ import {
7
+ readFileSync,
8
+ writeFileSync,
9
+ mkdirSync,
10
+ copyFileSync,
11
+ readdirSync,
12
+ rmSync,
13
+ } from 'fs';
14
+ import { tmpdir } from 'os';
15
+
16
+ const __filename = fileURLToPath(import.meta.url);
17
+ const __dirname = dirname(__filename);
18
+
19
+ // Parse command line arguments
20
+ const args = process.argv.slice(2);
21
+
22
+ // Check if first argument is a command
23
+ const command = args[0];
24
+
25
+ // Handle deploy command
26
+ if (command === 'deploy') {
27
+ const { deployApp } = await import('../src/deploy/index.ts');
28
+
29
+ // Parse deploy options
30
+ const buildDirIndex = args.indexOf('--build-dir');
31
+ const apiKeyIndex = args.indexOf('--api-key');
32
+
33
+ const buildDir = buildDirIndex !== -1 ? args[buildDirIndex + 1] : './dist';
34
+ const apiKey = apiKeyIndex !== -1 ? args[apiKeyIndex + 1] : undefined;
35
+
36
+ await deployApp({ buildDir, apiKey });
37
+ process.exit(0);
38
+ }
39
+
40
+ // Handle simulator command (default behavior)
41
+ // Remove the command from args if it was 'dev'
42
+ if (command === 'dev') {
43
+ args.shift();
44
+ }
45
+ const appUrlIndex = args.indexOf('--app');
46
+ const portIndex = args.indexOf('--port');
47
+
48
+ if (args.includes('--help') || args.includes('-h') || command === 'help') {
49
+ console.log(`
50
+ Juuno CLI - Official CLI for Juuno external app development
51
+
52
+ Usage:
53
+ juuno-cli <command> [options]
54
+
55
+ Commands:
56
+ dev Launch the app simulator (default)
57
+ deploy Deploy your app to Juuno CDN
58
+ help Show this help message
59
+
60
+ Simulator Options:
61
+ --app <url> URL of your external app (required)
62
+ --port <number> Port for simulator (default: 5004)
63
+
64
+ Deploy Options:
65
+ --build-dir <dir> Build directory to deploy (default: ./dist)
66
+ --api-key <key> Juuno API key (will prompt if not provided)
67
+
68
+ Examples:
69
+ # Start simulator
70
+ juuno-cli dev --app http://localhost:3000
71
+ juuno-cli --app http://localhost:3000
72
+
73
+ # Deploy app
74
+ juuno-cli deploy
75
+ juuno-cli deploy --build-dir ./dist --api-key your-api-key
76
+ `);
77
+ process.exit(0);
78
+ }
79
+
80
+ if (appUrlIndex === -1) {
81
+ console.error('Error: --app parameter is required for simulator');
82
+ console.error('Usage: juuno-cli dev --app <url>');
83
+ console.error('Run with --help for more information');
84
+ process.exit(1);
85
+ }
86
+
87
+ const appUrl = args[appUrlIndex + 1];
88
+ const port = portIndex !== -1 ? args[portIndex + 1] : '5004';
89
+
90
+ if (!appUrl) {
91
+ console.error('Error: --app parameter requires a URL value');
92
+ process.exit(1);
93
+ }
94
+
95
+ // Path to the built dist directory
96
+ const distPath = join(__dirname, '..', 'dist');
97
+
98
+ // Create a temporary directory for the modified dist
99
+ const tempDir = join(tmpdir(), `juuno-simulator-${Date.now()}`);
100
+ mkdirSync(tempDir, { recursive: true });
101
+
102
+ // Copy all files from dist to temp directory
103
+ function copyDir(src, dest) {
104
+ const entries = readdirSync(src, { withFileTypes: true });
105
+
106
+ for (const entry of entries) {
107
+ const srcPath = join(src, entry.name);
108
+ const destPath = join(dest, entry.name);
109
+
110
+ if (entry.isDirectory()) {
111
+ mkdirSync(destPath, { recursive: true });
112
+ copyDir(srcPath, destPath);
113
+ } else {
114
+ copyFileSync(srcPath, destPath);
115
+ }
116
+ }
117
+ }
118
+
119
+ copyDir(distPath, tempDir);
120
+
121
+ // Inject the app URL and import map CDN URLs into index.html
122
+ const indexPath = join(tempDir, 'index.html');
123
+ let indexHtml = readFileSync(indexPath, 'utf-8');
124
+
125
+ // Fill in the import map with CDN URLs for vue and @juuno-sdk/app-sdk
126
+ indexHtml = indexHtml.replace(
127
+ '"vue": ""',
128
+ '"vue": "https://cdn.jsdelivr.net/npm/vue@3.5.22/dist/vue.esm-browser.prod.js"',
129
+ );
130
+ indexHtml = indexHtml.replace(
131
+ '"@juuno-sdk/app-sdk": ""',
132
+ '"@juuno-sdk/app-sdk": "https://cdn.jsdelivr.net/npm/@juuno-sdk/app-sdk@1.0.2/dist/index.js"',
133
+ );
134
+
135
+ // Fill in CDN URLs for externalized dependencies from the SDK
136
+ indexHtml = indexHtml.replace(
137
+ '"@vueuse/core": ""',
138
+ '"@vueuse/core": "https://cdn.jsdelivr.net/npm/@vueuse/core@11.4.1/index.mjs"',
139
+ );
140
+ indexHtml = indexHtml.replace(
141
+ '"@tiptap/vue-3": ""',
142
+ '"@tiptap/vue-3": "https://cdn.jsdelivr.net/npm/@tiptap/vue-3@2.10.5/dist/index.js"',
143
+ );
144
+ indexHtml = indexHtml.replace(
145
+ '"@tiptap/starter-kit": ""',
146
+ '"@tiptap/starter-kit": "https://cdn.jsdelivr.net/npm/@tiptap/starter-kit@2.10.5/dist/index.js"',
147
+ );
148
+ indexHtml = indexHtml.replace(
149
+ '"@tiptap/extension-underline": ""',
150
+ '"@tiptap/extension-underline": "https://cdn.jsdelivr.net/npm/@tiptap/extension-underline@2.10.5/dist/index.js"',
151
+ );
152
+ indexHtml = indexHtml.replace(
153
+ '"lodash-es": ""',
154
+ '"lodash-es": "https://cdn.jsdelivr.net/npm/lodash-es@4.17.21/lodash.js"',
155
+ );
156
+ indexHtml = indexHtml.replace(
157
+ '"date-fns": ""',
158
+ '"date-fns": "https://cdn.jsdelivr.net/npm/date-fns@4.1.0/index.mjs"',
159
+ );
160
+ indexHtml = indexHtml.replace(
161
+ '"url-parse": ""',
162
+ '"url-parse": "https://cdn.jsdelivr.net/npm/url-parse@1.5.10/dist/url-parse.min.js"',
163
+ );
164
+
165
+ // Fill in the import map with external app entrypoints
166
+ indexHtml = indexHtml.replace(
167
+ '"external-app/player": ""',
168
+ `"external-app/player": "${appUrl}/player/index.js"`,
169
+ );
170
+ indexHtml = indexHtml.replace(
171
+ '"external-app/config": ""',
172
+ `"external-app/config": "${appUrl}/config/index.js"`,
173
+ );
174
+
175
+ // Inject the app URL as a global variable before any scripts load
176
+ const injection = `
177
+ <script>
178
+ window.__JUUNO_EXTERNAL_APP_URL__ = '${appUrl}';
179
+ </script>
180
+ `;
181
+
182
+ indexHtml = indexHtml.replace('</head>', `${injection}</head>`);
183
+ writeFileSync(indexPath, indexHtml);
184
+
185
+ console.log(`
186
+ 🚀 Juuno App Simulator Starting...
187
+
188
+ External App: ${appUrl}
189
+ Simulator URL: http://localhost:${port}
190
+
191
+ Press Ctrl+C to stop
192
+ `);
193
+
194
+ // Serve the modified temp directory
195
+ const server = spawn(
196
+ 'npx',
197
+ ['sirv-cli', tempDir, '--port', port, '--cors', '--single'],
198
+ {
199
+ stdio: 'inherit',
200
+ shell: true,
201
+ },
202
+ );
203
+
204
+ server.on('error', (err) => {
205
+ console.error('Failed to start server:', err);
206
+ process.exit(1);
207
+ });
208
+
209
+ server.on('exit', (code) => {
210
+ if (code !== 0) {
211
+ console.error(`Server exited with code ${code}`);
212
+ }
213
+ process.exit(code);
214
+ });
215
+
216
+ // Cleanup function
217
+ function cleanup() {
218
+ try {
219
+ rmSync(tempDir, { recursive: true, force: true });
220
+ } catch (err) {
221
+ // Ignore cleanup errors
222
+ }
223
+ }
224
+
225
+ // Handle termination
226
+ process.on('SIGINT', () => {
227
+ console.log('\n\n👋 Stopping simulator...');
228
+ server.kill();
229
+ cleanup();
230
+ process.exit(0);
231
+ });
232
+
233
+ process.on('SIGTERM', () => {
234
+ server.kill();
235
+ cleanup();
236
+ process.exit(0);
237
+ });
@@ -0,0 +1 @@
1
+ [data-v-6107adb1]{box-sizing:border-box}.juuno-cli[data-v-6107adb1]{min-height:100vh;display:flex;flex-direction:column;font-family:-apple-system,BlinkMacSystemFont,Segoe UI,Roboto,Oxygen,Ubuntu,Cantarell,sans-serif;background-color:#f5f5f5}.header[data-v-6107adb1]{background:linear-gradient(135deg,#667eea,#764ba2);color:#fff;padding:1.5rem 2rem;box-shadow:0 2px 4px #0000001a}.header-content[data-v-6107adb1]{max-width:1600px;margin:0 auto}.header h1[data-v-6107adb1]{margin:0 0 .5rem;font-size:1.75rem;font-weight:600}.header-info[data-v-6107adb1]{display:flex;justify-content:space-between;align-items:center;gap:1rem}.app-url[data-v-6107adb1]{font-size:.875rem;opacity:.9;font-family:Courier New,monospace}.loading-state[data-v-6107adb1],.error-state[data-v-6107adb1]{flex:1;display:flex;flex-direction:column;align-items:center;justify-content:center;padding:3rem;text-align:center}.spinner[data-v-6107adb1]{width:50px;height:50px;border:4px solid #f3f3f3;border-top:4px solid #667eea;border-radius:50%;animation:spin-6107adb1 1s linear infinite}@keyframes spin-6107adb1{0%{transform:rotate(0)}to{transform:rotate(360deg)}}.url-info[data-v-6107adb1]{margin-top:1rem;font-size:.875rem;color:#666;font-family:Courier New,monospace}.error-icon[data-v-6107adb1]{font-size:4rem;margin-bottom:1rem}.error-message[data-v-6107adb1]{color:#d32f2f;font-weight:500;margin:1rem 0}.error-details[data-v-6107adb1]{background:#fff;border-radius:8px;padding:1.5rem;margin-top:2rem;text-align:left;max-width:600px}.error-details ul[data-v-6107adb1]{margin:.5rem 0 0 1.5rem;padding:0}.error-details li[data-v-6107adb1]{margin:.25rem 0}.container[data-v-6107adb1]{flex:1;display:grid;grid-template-columns:1fr 1fr;gap:2rem;padding:2rem;max-width:1600px;margin:0 auto;width:100%}.player-section[data-v-6107adb1],.config-section[data-v-6107adb1]{background:#fff;border-radius:8px;padding:1.5rem;box-shadow:0 1px 3px #0000001a}.player-section h2[data-v-6107adb1],.config-section h2[data-v-6107adb1]{margin:0 0 1rem;font-size:1.25rem;font-weight:600;color:#333}.player-preview[data-v-6107adb1]{aspect-ratio:16 / 9;background:#000;border-radius:4px;overflow:hidden;box-shadow:0 4px 6px #0000001a}.config-panel[data-v-6107adb1]{background:#fafafa;border-radius:4px;padding:1rem;min-height:400px}.placeholder[data-v-6107adb1]{display:flex;align-items:center;justify-content:center;height:100%;color:#999;font-style:italic}.footer[data-v-6107adb1]{background:#fff;padding:1.5rem;text-align:center;border-top:1px solid #e0e0e0;color:#666;font-size:.875rem}.footer p[data-v-6107adb1]{margin:0}@media (max-width: 1200px){.container[data-v-6107adb1]{grid-template-columns:1fr}}
@@ -0,0 +1 @@
1
+ import{defineComponent as P,ref as g,shallowRef as y,reactive as k,onMounted as S,createElementBlock as f,openBlock as d,createElementVNode as o,toDisplayString as _,createTextVNode as h,createBlock as E,resolveDynamicComponent as C,createApp as O}from"vue";import{provideAppConfigContext as b}from"@juuno-sdk/app-sdk";(function(){const a=document.createElement("link").relList;if(a&&a.supports&&a.supports("modulepreload"))return;for(const t of document.querySelectorAll('link[rel="modulepreload"]'))c(t);new MutationObserver(t=>{for(const r of t)if(r.type==="childList")for(const l of r.addedNodes)l.tagName==="LINK"&&l.rel==="modulepreload"&&c(l)}).observe(document,{childList:!0,subtree:!0});function i(t){const r={};return t.integrity&&(r.integrity=t.integrity),t.referrerPolicy&&(r.referrerPolicy=t.referrerPolicy),t.crossOrigin==="use-credentials"?r.credentials="include":t.crossOrigin==="anonymous"?r.credentials="omit":r.credentials="same-origin",r}function c(t){if(t.ep)return;t.ep=!0;const r=i(t);fetch(t.href,r)}})();const I="modulepreload",R=function(p){return"/"+p},L={},x=function(a,i,c){let t=Promise.resolve();if(i&&i.length>0){let l=function(n){return Promise.all(n.map(e=>Promise.resolve(e).then(m=>({status:"fulfilled",value:m}),m=>({status:"rejected",reason:m}))))};document.getElementsByTagName("link");const s=document.querySelector("meta[property=csp-nonce]"),v=(s==null?void 0:s.nonce)||(s==null?void 0:s.getAttribute("nonce"));t=l(i.map(n=>{if(n=R(n),n in L)return;L[n]=!0;const e=n.endsWith(".css"),m=e?'[rel="stylesheet"]':"";if(document.querySelector(`link[href="${n}"]${m}`))return;const u=document.createElement("link");if(u.rel=e?"stylesheet":I,e||(u.as="script"),u.crossOrigin="",u.href=n,v&&u.setAttribute("nonce",v),document.head.appendChild(u),e)return new Promise((w,A)=>{u.addEventListener("load",w),u.addEventListener("error",()=>A(new Error(`Unable to preload CSS for ${n}`)))})}))}function r(l){const s=new Event("vite:preloadError",{cancelable:!0});if(s.payload=l,window.dispatchEvent(s),!s.defaultPrevented)throw l}return t.then(l=>{for(const s of l||[])s.status==="rejected"&&r(s.reason);return a().catch(r)})},U={class:"juuno-cli"},N={class:"header"},T={class:"header-content"},j={class:"header-info"},V={class:"app-url"},q={key:0,class:"loading-state"},B={class:"url-info"},D={key:1,class:"error-state"},F={class:"error-message"},J={class:"error-details"},$={key:2,class:"container"},M={class:"player-section"},K={class:"player-preview"},W={key:1,class:"placeholder"},X={class:"config-section"},z={class:"config-panel"},G={key:1,class:"placeholder"},H={name:"JuunoCLI"},Q=P({...H,setup(p){const a=g(window.__JUUNO_EXTERNAL_APP_URL__||"http://localhost:5002/index.js"),i=y(null),c=y(null),t=g(!0),r=g(null),l=k({idx:"simulator-scene-1",name:"Simulator Test Scene",app:null,meta:{title:"Test App",backgroundColor:"#2c3e50",textColor:"#ecf0f1"},mediaAssets:[]}),v=b({async save(n){console.log("Simulator: Scene updated",n),Object.assign(l,n)}},{scene:l});return S(async()=>{try{t.value=!0,r.value=null,console.log(`Loading external app from: ${a.value}`);const n=await x(()=>import("external-app/player"),[]);if(i.value=n.default,!i.value)throw new Error("External app must export a default component from /player");try{const e=await x(()=>import("external-app/config"),[]);c.value=e.default||null}catch(e){console.warn("Config component not found or failed to load:",e),c.value=null}v.load(l),console.log("✅ External app loaded successfully"),t.value=!1}catch(n){console.error("Failed to load external app:",n),r.value=n instanceof Error?n.message:"Failed to load external app",t.value=!1}}),(n,e)=>(d(),f("div",U,[o("header",N,[o("div",T,[e[0]||(e[0]=o("h1",null,"Juuno CLI",-1)),o("div",j,[o("span",V,_(a.value),1)])])]),t.value?(d(),f("div",q,[e[1]||(e[1]=o("div",{class:"spinner"},null,-1)),e[2]||(e[2]=o("p",null,"Loading external app...",-1)),o("p",B,_(a.value),1)])):r.value?(d(),f("div",D,[e[6]||(e[6]=o("div",{class:"error-icon"},"⚠️",-1)),e[7]||(e[7]=o("h2",null,"Failed to Load App",-1)),o("p",F,_(r.value),1),o("div",J,[o("p",null,[e[3]||(e[3]=o("strong",null,"App URL:",-1)),h(" "+_(a.value),1)]),e[4]||(e[4]=o("p",null,[o("strong",null,"Common Issues:")],-1)),e[5]||(e[5]=o("ul",null,[o("li",null,"App dev server not running"),o("li",null,"CORS not enabled on app dev server"),o("li",null," App doesn't export required components from /player and /config "),o("li",null,"Incorrect URL provided"),o("li",null,"Missing exports field in package.json")],-1))])])):(d(),f("div",$,[o("div",M,[e[8]||(e[8]=o("h2",null,"Player View",-1)),o("div",K,[i.value?(d(),E(C(i.value),{key:0,scene:l},null,8,["scene"])):(d(),f("div",W,"No player component found"))])]),o("div",X,[e[9]||(e[9]=o("h2",null,"Configuration UI",-1)),o("div",z,[c.value?(d(),E(C(c.value),{key:0})):(d(),f("div",G,"No config component found"))])])])),e[10]||(e[10]=o("footer",{class:"footer"},[o("p",null,[o("strong",null,"Juuno CLI"),h(" - Test your external app in a production-like environment ")])],-1))]))}}),Y=(p,a)=>{const i=p.__vccOpts||p;for(const[c,t]of a)i[c]=t;return i},Z=Y(Q,[["__scopeId","data-v-6107adb1"]]),ee=O(Z);ee.mount("#app");
@@ -0,0 +1,32 @@
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8" />
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
6
+ <title>Juuno App Simulator</title>
7
+ <script type="importmap">
8
+ {
9
+ "imports": {
10
+ "vue": "",
11
+ "@vueuse/core": "",
12
+ "@tiptap/vue-3": "",
13
+ "@tiptap/starter-kit": "",
14
+ "@tiptap/extension-underline": "",
15
+ "lodash-es": "",
16
+ "date-fns": "",
17
+ "url-parse": "",
18
+ "@juuno-sdk/app-sdk": "",
19
+ "external-app/player": "",
20
+ "external-app/config": ""
21
+ }
22
+ }
23
+ </script>
24
+ <script type="module" crossorigin src="/assets/main-BYDwbwR9.js"></script>
25
+ <link rel="stylesheet" crossorigin href="/assets/main-BIR58A5i.css">
26
+
27
+
28
+ </head>
29
+ <body>
30
+ <div id="app"></div>
31
+ </body>
32
+ </html>
package/package.json ADDED
@@ -0,0 +1,61 @@
1
+ {
2
+ "name": "@juuno-sdk/cli",
3
+ "version": "1.0.5",
4
+ "description": "Official CLI for Juuno external app development - simulator, deployment, and tools",
5
+ "private": false,
6
+ "type": "module",
7
+ "bin": {
8
+ "juuno-cli": "./bin/cli.js"
9
+ },
10
+ "files": [
11
+ "dist",
12
+ "bin",
13
+ "src"
14
+ ],
15
+ "exports": {
16
+ ".": {
17
+ "types": "./dist/index.d.ts",
18
+ "import": "./dist/index.js"
19
+ }
20
+ },
21
+ "scripts": {
22
+ "dev": "vite",
23
+ "build": "vite build",
24
+ "type-check": "vue-tsc --project tsconfig.build.json --noEmit --composite false"
25
+ },
26
+ "dependencies": {
27
+ "@aws-sdk/client-s3": "^3.709.0",
28
+ "@juuno-sdk/app-sdk": "^1.0.2",
29
+ "commander": "^12.1.0",
30
+ "inquirer": "^11.2.0",
31
+ "mime-types": "^2.1.35",
32
+ "sirv-cli": "^2.0.2"
33
+ },
34
+ "peerDependencies": {
35
+ "vue": "^3.5.22"
36
+ },
37
+ "devDependencies": {
38
+ "@juuno/service-vitejs-plugins": "workspace:*",
39
+ "@tsconfig/node22": "^22.0.2",
40
+ "@types/node": "^22.15.30",
41
+ "@vitejs/plugin-vue": "^6.0.1",
42
+ "@vue/tsconfig": "^0.7.0",
43
+ "typescript": "^5.8.3",
44
+ "vite": "^6.3.5",
45
+ "vue": "^3.5.22",
46
+ "vue-tsc": "^2.2.10"
47
+ },
48
+ "keywords": [
49
+ "juuno",
50
+ "app",
51
+ "simulator",
52
+ "testing",
53
+ "digital-signage"
54
+ ],
55
+ "author": "Juuno",
56
+ "license": "MIT",
57
+ "publishConfig": {
58
+ "registry": "https://registry.npmjs.org/",
59
+ "access": "public"
60
+ }
61
+ }
package/src/App.vue ADDED
@@ -0,0 +1,350 @@
1
+ <script lang="ts">
2
+ /**
3
+ * Juuno CLI Development Tool.
4
+ *
5
+ * Loads external apps and displays them in a split-screen view.
6
+ */
7
+ export default {
8
+ name: 'JuunoCLI',
9
+ };
10
+ </script>
11
+
12
+ <script setup lang="ts">
13
+ import { ref, reactive, onMounted, shallowRef } from 'vue';
14
+ import type { Component } from 'vue';
15
+ import type { Scene } from '@juuno-sdk/app-sdk';
16
+ import { provideAppConfigContext } from '@juuno-sdk/app-sdk';
17
+
18
+ // Get external app URL from Vite define or fallback to default.
19
+ const externalAppUrl = ref(
20
+ (window as any).__JUUNO_EXTERNAL_APP_URL__ ||
21
+ 'http://localhost:5002/index.js',
22
+ );
23
+
24
+ // Dynamically loaded components from external app (use shallowRef to avoid reactive overhead)
25
+ const PlayerComponent = shallowRef<Component | null>(null);
26
+ const ConfigComponent = shallowRef<Component | null>(null);
27
+ const isLoading = ref(true);
28
+ const error = ref<string | null>(null);
29
+
30
+ // Create scene (matching production pattern)
31
+ const scene = reactive({
32
+ idx: 'simulator-scene-1',
33
+ name: 'Simulator Test Scene',
34
+ app: null,
35
+ meta: {
36
+ title: 'Test App',
37
+ backgroundColor: '#2c3e50',
38
+ textColor: '#ecf0f1',
39
+ },
40
+ mediaAssets: [],
41
+ }) as Scene;
42
+
43
+ // Create mock service for saving scene data
44
+ const mockService = {
45
+ async save(updatedScene: Scene) {
46
+ console.log('Simulator: Scene updated', updatedScene);
47
+ // Sync changes back to the original scene object
48
+ Object.assign(scene, updatedScene);
49
+ },
50
+ };
51
+
52
+ // Provide context using production pattern (matches SceneAppConfig.vue)
53
+ // NOTE: Passing original scene directly for live-sync - config changes immediately
54
+ // reflect in player. Production clones the scene for draft/commit workflow.
55
+ const appConfigContext = provideAppConfigContext(mockService, {
56
+ scene: scene,
57
+ });
58
+
59
+ // Load external app components
60
+ onMounted(async () => {
61
+ try {
62
+ isLoading.value = true;
63
+ error.value = null;
64
+
65
+ console.log(`Loading external app from: ${externalAppUrl.value}`);
66
+
67
+ // Load player component from import map
68
+ const playerModule = await import('external-app/player');
69
+ PlayerComponent.value = playerModule.default;
70
+
71
+ if (!PlayerComponent.value) {
72
+ throw new Error(
73
+ 'External app must export a default component from /player',
74
+ );
75
+ }
76
+
77
+ // Load config component from import map (optional)
78
+ try {
79
+ const configModule = await import('external-app/config');
80
+ ConfigComponent.value = configModule.default || null;
81
+ } catch (configErr) {
82
+ console.warn('Config component not found or failed to load:', configErr);
83
+ ConfigComponent.value = null;
84
+ }
85
+
86
+ // Load the scene into the app config context
87
+ appConfigContext.load(scene);
88
+
89
+ console.log('✅ External app loaded successfully');
90
+ isLoading.value = false;
91
+ } catch (err) {
92
+ console.error('Failed to load external app:', err);
93
+ error.value =
94
+ err instanceof Error ? err.message : 'Failed to load external app';
95
+ isLoading.value = false;
96
+ }
97
+ });
98
+ </script>
99
+
100
+ <template>
101
+ <div class="juuno-cli">
102
+ <header class="header">
103
+ <div class="header-content">
104
+ <h1>Juuno CLI</h1>
105
+ <div class="header-info">
106
+ <span class="app-url">{{ externalAppUrl }}</span>
107
+ </div>
108
+ </div>
109
+ </header>
110
+
111
+ <div v-if="isLoading" class="loading-state">
112
+ <div class="spinner"></div>
113
+ <p>Loading external app...</p>
114
+ <p class="url-info">{{ externalAppUrl }}</p>
115
+ </div>
116
+
117
+ <div v-else-if="error" class="error-state">
118
+ <div class="error-icon">⚠️</div>
119
+ <h2>Failed to Load App</h2>
120
+ <p class="error-message">{{ error }}</p>
121
+ <div class="error-details">
122
+ <p><strong>App URL:</strong> {{ externalAppUrl }}</p>
123
+ <p><strong>Common Issues:</strong></p>
124
+ <ul>
125
+ <li>App dev server not running</li>
126
+ <li>CORS not enabled on app dev server</li>
127
+ <li>
128
+ App doesn't export required components from /player and /config
129
+ </li>
130
+ <li>Incorrect URL provided</li>
131
+ <li>Missing exports field in package.json</li>
132
+ </ul>
133
+ </div>
134
+ </div>
135
+
136
+ <div v-else class="container">
137
+ <!-- Player View -->
138
+ <div class="player-section">
139
+ <h2>Player View</h2>
140
+ <div class="player-preview">
141
+ <component
142
+ :is="PlayerComponent"
143
+ v-if="PlayerComponent"
144
+ :scene="scene"
145
+ />
146
+ <div v-else class="placeholder">No player component found</div>
147
+ </div>
148
+ </div>
149
+
150
+ <!-- Config View -->
151
+ <div class="config-section">
152
+ <h2>Configuration UI</h2>
153
+ <div class="config-panel">
154
+ <component :is="ConfigComponent" v-if="ConfigComponent" />
155
+ <div v-else class="placeholder">No config component found</div>
156
+ </div>
157
+ </div>
158
+ </div>
159
+
160
+ <footer class="footer">
161
+ <p>
162
+ <strong>Juuno CLI</strong> - Test your external app in a
163
+ production-like environment
164
+ </p>
165
+ </footer>
166
+ </div>
167
+ </template>
168
+
169
+ <style scoped>
170
+ * {
171
+ box-sizing: border-box;
172
+ }
173
+
174
+ .juuno-cli {
175
+ min-height: 100vh;
176
+ display: flex;
177
+ flex-direction: column;
178
+ font-family:
179
+ -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu,
180
+ Cantarell, sans-serif;
181
+ background-color: #f5f5f5;
182
+ }
183
+
184
+ .header {
185
+ background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
186
+ color: white;
187
+ padding: 1.5rem 2rem;
188
+ box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
189
+ }
190
+
191
+ .header-content {
192
+ max-width: 1600px;
193
+ margin: 0 auto;
194
+ }
195
+
196
+ .header h1 {
197
+ margin: 0 0 0.5rem 0;
198
+ font-size: 1.75rem;
199
+ font-weight: 600;
200
+ }
201
+
202
+ .header-info {
203
+ display: flex;
204
+ justify-content: space-between;
205
+ align-items: center;
206
+ gap: 1rem;
207
+ }
208
+
209
+ .app-url {
210
+ font-size: 0.875rem;
211
+ opacity: 0.9;
212
+ font-family: 'Courier New', monospace;
213
+ }
214
+
215
+ .loading-state,
216
+ .error-state {
217
+ flex: 1;
218
+ display: flex;
219
+ flex-direction: column;
220
+ align-items: center;
221
+ justify-content: center;
222
+ padding: 3rem;
223
+ text-align: center;
224
+ }
225
+
226
+ .spinner {
227
+ width: 50px;
228
+ height: 50px;
229
+ border: 4px solid #f3f3f3;
230
+ border-top: 4px solid #667eea;
231
+ border-radius: 50%;
232
+ animation: spin 1s linear infinite;
233
+ }
234
+
235
+ @keyframes spin {
236
+ 0% {
237
+ transform: rotate(0deg);
238
+ }
239
+ 100% {
240
+ transform: rotate(360deg);
241
+ }
242
+ }
243
+
244
+ .url-info {
245
+ margin-top: 1rem;
246
+ font-size: 0.875rem;
247
+ color: #666;
248
+ font-family: 'Courier New', monospace;
249
+ }
250
+
251
+ .error-icon {
252
+ font-size: 4rem;
253
+ margin-bottom: 1rem;
254
+ }
255
+
256
+ .error-message {
257
+ color: #d32f2f;
258
+ font-weight: 500;
259
+ margin: 1rem 0;
260
+ }
261
+
262
+ .error-details {
263
+ background: white;
264
+ border-radius: 8px;
265
+ padding: 1.5rem;
266
+ margin-top: 2rem;
267
+ text-align: left;
268
+ max-width: 600px;
269
+ }
270
+
271
+ .error-details ul {
272
+ margin: 0.5rem 0 0 1.5rem;
273
+ padding: 0;
274
+ }
275
+
276
+ .error-details li {
277
+ margin: 0.25rem 0;
278
+ }
279
+
280
+ .container {
281
+ flex: 1;
282
+ display: grid;
283
+ grid-template-columns: 1fr 1fr;
284
+ gap: 2rem;
285
+ padding: 2rem;
286
+ max-width: 1600px;
287
+ margin: 0 auto;
288
+ width: 100%;
289
+ }
290
+
291
+ .player-section,
292
+ .config-section {
293
+ background: white;
294
+ border-radius: 8px;
295
+ padding: 1.5rem;
296
+ box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
297
+ }
298
+
299
+ .player-section h2,
300
+ .config-section h2 {
301
+ margin: 0 0 1rem 0;
302
+ font-size: 1.25rem;
303
+ font-weight: 600;
304
+ color: #333;
305
+ }
306
+
307
+ .player-preview {
308
+ aspect-ratio: 16 / 9;
309
+ background: #000;
310
+ border-radius: 4px;
311
+ overflow: hidden;
312
+ box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
313
+ }
314
+
315
+ .config-panel {
316
+ background: #fafafa;
317
+ border-radius: 4px;
318
+ padding: 1rem;
319
+ min-height: 400px;
320
+ }
321
+
322
+ .placeholder {
323
+ display: flex;
324
+ align-items: center;
325
+ justify-content: center;
326
+ height: 100%;
327
+ color: #999;
328
+ font-style: italic;
329
+ }
330
+
331
+ .footer {
332
+ background: white;
333
+ padding: 1.5rem;
334
+ text-align: center;
335
+ border-top: 1px solid #e0e0e0;
336
+ color: #666;
337
+ font-size: 0.875rem;
338
+ }
339
+
340
+ .footer p {
341
+ margin: 0;
342
+ }
343
+
344
+ /* Responsive layout */
345
+ @media (max-width: 1200px) {
346
+ .container {
347
+ grid-template-columns: 1fr;
348
+ }
349
+ }
350
+ </style>
@@ -0,0 +1,202 @@
1
+ import { readFileSync, existsSync, readdirSync } from 'fs';
2
+ import { join, basename } from 'path';
3
+ import { S3Client, PutObjectCommand } from '@aws-sdk/client-s3';
4
+ import { lookup } from 'mime-types';
5
+ import inquirer from 'inquirer';
6
+
7
+ interface ExternalAppManifest {
8
+ id: string;
9
+ version: string;
10
+ name: string;
11
+ resources: Record<string, string[]>;
12
+ }
13
+
14
+ interface DeployOptions {
15
+ buildDir: string;
16
+ apiKey?: string;
17
+ }
18
+
19
+ interface S3Credentials {
20
+ s3Token: {
21
+ accessKeyId: string;
22
+ secretAccessKey: string;
23
+ sessionToken?: string;
24
+ };
25
+ region: string;
26
+ bucket: string;
27
+ namespace: string;
28
+ }
29
+
30
+ /**
31
+ * Exchange Juuno API key for temporary S3 credentials.
32
+ */
33
+ async function exchangeApiKey(apiKey: string): Promise<S3Credentials> {
34
+ // TODO: Replace with actual backend endpoint when ready.
35
+ const apiUrl = process.env.JUUNO_API_URL || 'https://api.juuno.co';
36
+
37
+ try {
38
+ const response = await fetch(
39
+ `${apiUrl}/api/external-apps/auth/exchange-key`,
40
+ {
41
+ method: 'POST',
42
+ headers: { 'Content-Type': 'application/json' },
43
+ body: JSON.stringify({ apiKey }),
44
+ },
45
+ );
46
+
47
+ if (!response.ok) {
48
+ throw new Error(`Authentication failed: ${response.statusText}`);
49
+ }
50
+
51
+ return await response.json();
52
+ } catch (error) {
53
+ throw new Error(
54
+ `Failed to authenticate with Juuno API. The backend endpoint may not be ready yet. Error: ${error instanceof Error ? error.message : String(error)}`,
55
+ );
56
+ }
57
+ }
58
+
59
+ /**
60
+ * Upload a file to S3.
61
+ */
62
+ async function uploadFile(
63
+ s3Client: S3Client,
64
+ bucket: string,
65
+ key: string,
66
+ filePath: string,
67
+ ): Promise<void> {
68
+ const fileContent = readFileSync(filePath);
69
+ const contentType = lookup(filePath) || 'application/octet-stream';
70
+
71
+ const command = new PutObjectCommand({
72
+ Bucket: bucket,
73
+ Key: key,
74
+ Body: fileContent,
75
+ ContentType: contentType,
76
+ });
77
+
78
+ await s3Client.send(command);
79
+ }
80
+
81
+ /**
82
+ * Deploy external app to S3.
83
+ */
84
+ export async function deployApp(options: DeployOptions): Promise<void> {
85
+ const buildDir = options.buildDir;
86
+
87
+ // 1. Validate build directory exists.
88
+ if (!existsSync(buildDir)) {
89
+ console.error(`❌ Build directory not found: ${buildDir}`);
90
+ console.error(' Run your build command first (e.g., npm run build)');
91
+ process.exit(1);
92
+ }
93
+
94
+ // 2. Load and validate manifest.
95
+ const manifestPath = join(buildDir, 'manifest.json');
96
+ if (!existsSync(manifestPath)) {
97
+ console.error(`❌ manifest.json not found in ${buildDir}`);
98
+ console.error(' Your build should generate a manifest.json file');
99
+ process.exit(1);
100
+ }
101
+
102
+ const manifest: ExternalAppManifest = JSON.parse(
103
+ readFileSync(manifestPath, 'utf-8'),
104
+ );
105
+
106
+ if (!manifest.id || !manifest.version || !manifest.name) {
107
+ console.error(
108
+ '❌ Invalid manifest.json - missing required fields (id, version, name)',
109
+ );
110
+ process.exit(1);
111
+ }
112
+
113
+ console.log(`📦 Deploying ${manifest.name} v${manifest.version}...`);
114
+ console.log('');
115
+
116
+ // 3. Prompt for API key if not provided.
117
+ let apiKey = options.apiKey;
118
+ if (!apiKey) {
119
+ const answers = await inquirer.prompt([
120
+ {
121
+ type: 'password',
122
+ name: 'apiKey',
123
+ message: 'Enter your Juuno API key:',
124
+ validate: (input: string) => {
125
+ if (!input || input.trim().length === 0) {
126
+ return 'API key is required';
127
+ }
128
+ return true;
129
+ },
130
+ },
131
+ ]);
132
+ apiKey = answers.apiKey;
133
+ }
134
+
135
+ // 4. Exchange API key for S3 credentials.
136
+ console.log('🔑 Authenticating...');
137
+ let credentials: S3Credentials;
138
+ try {
139
+ credentials = await exchangeApiKey(apiKey);
140
+ console.log(`✓ Authenticated as account: ${credentials.namespace}`);
141
+ console.log('');
142
+ } catch (error) {
143
+ console.error(
144
+ `❌ ${error instanceof Error ? error.message : String(error)}`,
145
+ );
146
+ process.exit(1);
147
+ }
148
+
149
+ // 5. Create S3 client with temporary credentials.
150
+ const s3Client = new S3Client({
151
+ region: credentials.region,
152
+ credentials: {
153
+ accessKeyId: credentials.s3Token.accessKeyId,
154
+ secretAccessKey: credentials.s3Token.secretAccessKey,
155
+ ...(credentials.s3Token.sessionToken && {
156
+ sessionToken: credentials.s3Token.sessionToken,
157
+ }),
158
+ },
159
+ });
160
+
161
+ // 6. Upload all files from build directory.
162
+ const s3Path = `${credentials.namespace}/${manifest.id}/${manifest.version}`;
163
+ const files = readdirSync(buildDir).filter((file) =>
164
+ existsSync(join(buildDir, file)),
165
+ );
166
+
167
+ console.log(`☁️ Uploading to s3://${credentials.bucket}/${s3Path}/`);
168
+ console.log('');
169
+
170
+ for (const file of files) {
171
+ const filePath = join(buildDir, file);
172
+ const s3Key = `${s3Path}/${file}`;
173
+
174
+ process.stdout.write(` Uploading ${file}... `);
175
+
176
+ try {
177
+ await uploadFile(s3Client, credentials.bucket, s3Key, filePath);
178
+ process.stdout.write('✓\n');
179
+ } catch (error) {
180
+ process.stdout.write('✗\n');
181
+ console.error(
182
+ ` Error: ${error instanceof Error ? error.message : String(error)}`,
183
+ );
184
+ process.exit(1);
185
+ }
186
+ }
187
+
188
+ // 7. Generate manifest URL and display success message.
189
+ const manifestUrl = `https://${credentials.bucket}.s3.${credentials.region}.amazonaws.com/${s3Path}/manifest.json`;
190
+
191
+ console.log('');
192
+ console.log('✅ Deployment successful!');
193
+ console.log('');
194
+ console.log('📋 Manifest URL:');
195
+ console.log(` ${manifestUrl}`);
196
+ console.log('');
197
+ console.log('→ Next steps:');
198
+ console.log(' 1. Go to your Juuno account');
199
+ console.log(' 2. Navigate to SDK > Custom Apps');
200
+ console.log(' 3. Add a new custom app with the manifest URL above');
201
+ console.log('');
202
+ }
package/src/main.ts ADDED
@@ -0,0 +1,5 @@
1
+ import { createApp } from 'vue';
2
+ import App from './App.vue';
3
+
4
+ const app = createApp(App);
5
+ app.mount('#app');