@juuno-sdk/cli 1.0.12 → 2.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.
Files changed (38) hide show
  1. package/README.md +5 -3
  2. package/bin/cli-router.js +54 -72
  3. package/bin/cli.js +2 -3
  4. package/dist/assets/main-GRbDNSIa.css +1 -0
  5. package/dist/assets/main-TDlmPBlt.js +1 -0
  6. package/dist/assets/main-rFvAfxK4.js +1 -0
  7. package/dist/cli/src/auth/index.js +47 -34
  8. package/dist/cli/src/deploy/index.js +209 -115
  9. package/dist/cli/src/dev/index.js +46 -40
  10. package/dist/cli/src/info/index.js +12 -36
  11. package/dist/cli/src/list/index.js +12 -36
  12. package/dist/cli/src/login/index.js +37 -42
  13. package/dist/cli/src/logout/index.js +34 -33
  14. package/dist/cli/src/simulator/index.js +54 -39
  15. package/dist/cli/src/utils/api.js +45 -0
  16. package/dist/cli/src/utils/error.js +9 -0
  17. package/dist/cli/src/utils/is-record.js +11 -0
  18. package/dist/cli/src/whoami/index.js +37 -33
  19. package/dist/cli/tsconfig.build.tsbuildinfo +1 -1
  20. package/dist/index.html +5 -2
  21. package/dist/types/src/auth/index.d.ts +17 -0
  22. package/dist/types/src/auth/index.d.ts.map +1 -1
  23. package/dist/types/src/deploy/index.d.ts +12 -0
  24. package/dist/types/src/deploy/index.d.ts.map +1 -1
  25. package/dist/types/src/dev/index.d.ts.map +1 -1
  26. package/dist/types/src/info/index.d.ts.map +1 -1
  27. package/dist/types/src/list/index.d.ts.map +1 -1
  28. package/dist/types/src/login/index.d.ts.map +1 -1
  29. package/dist/types/src/logout/index.d.ts.map +1 -1
  30. package/dist/types/src/simulator/index.d.ts.map +1 -1
  31. package/dist/types/src/utils/api.d.ts +27 -0
  32. package/dist/types/src/utils/api.d.ts.map +1 -0
  33. package/dist/types/src/utils/error.d.ts +8 -0
  34. package/dist/types/src/utils/error.d.ts.map +1 -0
  35. package/dist/types/src/utils/is-record.d.ts +10 -0
  36. package/dist/types/src/utils/is-record.d.ts.map +1 -0
  37. package/dist/types/src/whoami/index.d.ts.map +1 -1
  38. package/package.json +28 -26
@@ -1,78 +1,142 @@
1
1
  import { readFileSync, existsSync, readdirSync, statSync, realpathSync, } from 'fs';
2
- import { join, relative } from 'path';
2
+ import { isAbsolute, join, relative } from 'path';
3
3
  import JSZip from 'jszip';
4
4
  import { getAuthToken, getApiUrl } from '../auth/index.js';
5
+ import { getErrorMessage } from '../utils/error.js';
6
+ import { wrapError } from '../utils/api.js';
7
+ import { isRecord } from '../utils/is-record.js';
5
8
  /**
6
9
  * Fetches the currently deployed version of an app.
7
- * Returns null if the app doesn't exist yet.
10
+ * Returns null if the app doesn't exist yet, or on any error. the caller
11
+ * treats null as "no current version" so deploys can proceed when the
12
+ * app-info endpoint is briefly unreachable.
8
13
  */
9
14
  async function fetchDeployedVersion(token, appId, apiUrl) {
10
- const infoUrl = `${apiUrl}/api/v1/developer/apps/${appId}`;
11
15
  try {
12
- const response = await fetch(infoUrl, {
16
+ const response = await fetch(`${apiUrl}/api/v1/developer/apps/${appId}`, {
13
17
  method: 'GET',
14
18
  headers: {
15
19
  Authorization: `Bearer ${token}`,
16
20
  Accept: 'application/json',
17
21
  },
18
22
  });
19
- // App doesn't exist yet - that's fine for first deploy.
20
- if (response.status === 404) {
21
- return null;
22
- }
23
23
  if (!response.ok) {
24
- // Non-404 errors - log but don't block deploy.
24
+ // 404 = app doesn't exist yet (first deploy); other errors don't block.
25
25
  return null;
26
26
  }
27
27
  const data = (await response.json());
28
- if (!data.success || !data.data) {
29
- return null;
30
- }
31
- return data.data.version ?? null;
28
+ return data.success && data.data ? (data.data.version ?? null) : null;
32
29
  }
33
30
  catch {
34
- // Network errors - don't block deploy.
31
+ // Network errors. don't block deploy.
35
32
  return null;
36
33
  }
37
34
  }
35
+ /** Narrows a hand-written manifest value before it reaches array operations. */
36
+ export function isArrayOfStrings(value) {
37
+ return (Array.isArray(value) && value.every((item) => typeof item === 'string'));
38
+ }
39
+ /** Root-level JSON that is not the manifest is a locale file by convention. */
40
+ function listBundledLocales(buildDir) {
41
+ return readdirSync(buildDir, { withFileTypes: true })
42
+ .filter((entry) => entry.isFile() && entry.name.endsWith('.json'))
43
+ .map((entry) => entry.name)
44
+ .filter((name) => name !== 'manifest.json')
45
+ .map((name) => name.replace(/\.json$/, ''))
46
+ .sort();
47
+ }
48
+ /**
49
+ * Check a `translations` declaration against the locale files a bundle
50
+ * actually carries, returning the problem or null when they agree.
51
+ *
52
+ * Both directions are errors, because both are silent at runtime: a declared
53
+ * locale with no file makes the host request one that will 404, and a file
54
+ * nobody declared is never requested at all. English is rejected outright,
55
+ * since it ships inside the config bundle and never on the CDN.
56
+ */
57
+ export function checkTranslations(declared, bundled) {
58
+ if (declared.includes('en')) {
59
+ return ('Invalid manifest.json - "translations" must not list "en". English ' +
60
+ 'is compiled into your config entry as its `en` export, and is never ' +
61
+ 'fetched.');
62
+ }
63
+ if (bundled.includes('en')) {
64
+ return ('en.json must not be in the build. English belongs in the config ' +
65
+ 'bundle via `export { en }`, and a copy on the CDN is never ' +
66
+ 'requested. Narrow your copy target to `i18n/!(en).json`.');
67
+ }
68
+ const missing = declared.filter((locale) => !bundled.includes(locale));
69
+ if (missing.length > 0) {
70
+ return (`Declared in "translations" but not in the build: ${missing.join(', ')}. ` +
71
+ 'Every declared locale must ship a <locale>.json at the bundle root, ' +
72
+ 'or the host will request one that does not exist.');
73
+ }
74
+ const undeclared = bundled.filter((locale) => !declared.includes(locale));
75
+ if (undeclared.length > 0) {
76
+ return (`In the build but not declared in "translations": ${undeclared.join(', ')}. ` +
77
+ 'An undeclared locale is never requested, so these ship to nobody.');
78
+ }
79
+ return null;
80
+ }
81
+ /**
82
+ * Resolve a real path under a base directory. Returns 'unresolvable' when
83
+ * realpathSync throws (broken symlink, missing target), 'escapes' when the
84
+ * resolved path lives outside base (traversal attempt), or the resolved
85
+ * path string when safe to use.
86
+ *
87
+ * Containment uses path.relative + isAbsolute, not startsWith, so a base
88
+ * like /tmp/dist doesn't accidentally accept /tmp/dist-evil/secret.txt.
89
+ */
90
+ function safeResolveWithinBase(fullPath, base) {
91
+ let resolved;
92
+ try {
93
+ resolved = realpathSync(fullPath);
94
+ }
95
+ catch {
96
+ return 'unresolvable';
97
+ }
98
+ const rel = relative(base, resolved);
99
+ if (isAbsolute(rel) || rel.startsWith('..')) {
100
+ return 'escapes';
101
+ }
102
+ return resolved;
103
+ }
38
104
  /**
39
105
  * Creates a zip bundle from the build directory.
40
106
  * Recursively includes all files while protecting against path traversal.
41
107
  */
42
108
  async function createZipBundle(buildDir) {
43
- const zip = new JSZip();
44
- const resolvedBuildDir = realpathSync(buildDir);
45
- function addFilesRecursively(dir, zipPath) {
46
- const entries = readdirSync(dir, { withFileTypes: true });
47
- for (const entry of entries) {
48
- const fullPath = join(dir, entry.name);
49
- const entryZipPath = zipPath ? `${zipPath}/${entry.name}` : entry.name;
50
- // Resolve symlinks and verify the file is within buildDir.
51
- let resolvedPath;
52
- try {
53
- resolvedPath = realpathSync(fullPath);
54
- }
55
- catch {
56
- // Skip files that can't be resolved (broken symlinks, etc.).
57
- console.warn(` ⚠ Skipping unresolvable path: ${entry.name}`);
58
- continue;
59
- }
60
- // Protect against path traversal attacks.
61
- if (!resolvedPath.startsWith(resolvedBuildDir)) {
62
- console.warn(` ⚠ Skipping path outside build directory: ${entry.name}`);
63
- continue;
64
- }
65
- if (entry.isDirectory()) {
66
- addFilesRecursively(fullPath, entryZipPath);
67
- }
68
- else if (entry.isFile() || entry.isSymbolicLink()) {
69
- const content = readFileSync(resolvedPath);
70
- zip.file(entryZipPath, content);
109
+ try {
110
+ const zip = new JSZip();
111
+ const resolvedBuildDir = realpathSync(buildDir);
112
+ function addFilesRecursively(dir, zipPath) {
113
+ for (const entry of readdirSync(dir, { withFileTypes: true })) {
114
+ const fullPath = join(dir, entry.name);
115
+ const entryZipPath = zipPath ? `${zipPath}/${entry.name}` : entry.name;
116
+ const resolved = safeResolveWithinBase(fullPath, resolvedBuildDir);
117
+ if (resolved === 'unresolvable') {
118
+ console.warn(` ⚠ Skipping unresolvable path: ${entry.name}`);
119
+ continue;
120
+ }
121
+ if (resolved === 'escapes') {
122
+ console.warn(` ⚠ Skipping path outside build directory: ${entry.name}`);
123
+ continue;
124
+ }
125
+ if (entry.isDirectory()) {
126
+ addFilesRecursively(fullPath, entryZipPath);
127
+ continue;
128
+ }
129
+ if (entry.isFile() || entry.isSymbolicLink()) {
130
+ zip.file(entryZipPath, readFileSync(resolved));
131
+ }
71
132
  }
72
133
  }
134
+ addFilesRecursively(resolvedBuildDir, '');
135
+ return await zip.generateAsync({ type: 'nodebuffer' });
136
+ }
137
+ catch (error) {
138
+ throw wrapError('Failed to create bundle', error);
73
139
  }
74
- addFilesRecursively(resolvedBuildDir, '');
75
- return await zip.generateAsync({ type: 'nodebuffer' });
76
140
  }
77
141
  /**
78
142
  * Uploads the app bundle to the developer API.
@@ -80,7 +144,12 @@ async function createZipBundle(buildDir) {
80
144
  async function uploadBundle(token, zipBuffer, apiUrl) {
81
145
  const uploadUrl = `${apiUrl}/api/v1/developer/apps/upload`;
82
146
  const formData = new FormData();
83
- const blob = new Blob([zipBuffer], { type: 'application/zip' });
147
+ // @types/node 26 makes Buffer generic over ArrayBufferLike, which BlobPart
148
+ // rejects because it could be a SharedArrayBuffer. JSZip's 'nodebuffer'
149
+ // output is always backed by a plain ArrayBuffer, so narrowing is sound.
150
+ const blob = new Blob([zipBuffer], {
151
+ type: 'application/zip',
152
+ });
84
153
  formData.append('app_bundle', blob, 'app.zip');
85
154
  try {
86
155
  const response = await fetch(uploadUrl, {
@@ -102,97 +171,84 @@ async function uploadBundle(token, zipBuffer, apiUrl) {
102
171
  return data;
103
172
  }
104
173
  catch (error) {
105
- if (error instanceof Error) {
106
- throw new Error(`Upload failed: ${error.message}`);
107
- }
108
- throw new Error('Upload failed: Unknown error');
174
+ throw wrapError('Upload failed', error);
109
175
  }
110
176
  }
111
177
  /**
112
- * Deploys an external app to the Juuno platform.
178
+ * Read and validate the manifest.json from a build directory. Throws with
179
+ * actionable messages when the build is missing, the manifest is missing,
180
+ * or the manifest is malformed.
113
181
  */
114
- export async function deployApp(options) {
115
- const buildDir = options.buildDir;
116
- console.log('📦 Juuno CLI - Deploy');
117
- console.log('');
182
+ function loadAndValidateManifest(buildDir) {
118
183
  if (!existsSync(buildDir)) {
119
- console.error(`❌ Build directory not found: ${buildDir}`);
120
- console.error(' Run your build command first (e.g., npm run build)');
121
- process.exit(1);
184
+ throw new Error(`Build directory not found: ${buildDir}\n Run your build command first (e.g., npm run build)`);
122
185
  }
123
186
  const manifestPath = join(buildDir, 'manifest.json');
124
187
  if (!existsSync(manifestPath)) {
125
- console.error(`❌ manifest.json not found in ${buildDir}`);
126
- console.error(' Your build should generate a manifest.json file');
127
- process.exit(1);
188
+ throw new Error(`manifest.json not found in ${buildDir}\n Your build should generate a manifest.json file`);
128
189
  }
129
- const manifest = JSON.parse(readFileSync(manifestPath, 'utf-8'));
130
- if (!manifest.id || !manifest.version || !manifest.name) {
131
- console.error('❌ Invalid manifest.json - missing required fields (id, version, name)');
132
- process.exit(1);
133
- }
134
- console.log(` App: ${manifest.name}`);
135
- console.log(` Version: ${manifest.version}`);
136
- console.log(` ID: ${manifest.id}`);
137
- console.log('');
138
- const apiUrl = getApiUrl(options);
139
- console.log('🔑 Authenticating...');
140
- let token;
190
+ let parsed;
141
191
  try {
142
- token = await getAuthToken(options);
143
- console.log(' ✓ Authenticated');
144
- console.log('');
192
+ parsed = JSON.parse(readFileSync(manifestPath, 'utf-8'));
145
193
  }
146
194
  catch (error) {
147
- console.error(`❌ ${error instanceof Error ? error.message : String(error)}`);
148
- process.exit(1);
195
+ throw new Error(`Invalid manifest.json in ${buildDir}: ${getErrorMessage(error)}`);
149
196
  }
150
- // Check if this version is already deployed.
151
- console.log('🔍 Checking deployed version...');
152
- const deployedVersion = await fetchDeployedVersion(token, manifest.id, apiUrl);
153
- if (deployedVersion !== null && deployedVersion === manifest.version) {
154
- if (options.skipIfDeployed) {
155
- console.log('');
156
- console.log(`ℹ️ ${manifest.name}@${manifest.version} already deployed, skipping.`);
157
- console.log('');
158
- return;
159
- }
160
- console.error('');
161
- console.error(`❌ Version ${manifest.version} is already deployed for '${manifest.name}'`);
162
- console.error('');
163
- console.error(' Versions are immutable. Bump the version in your manifest and try again.');
164
- console.error('');
165
- process.exit(1);
197
+ if (!isRecord(parsed)) {
198
+ throw new Error(`Invalid manifest.json in ${buildDir}: expected a JSON object`);
166
199
  }
167
- if (deployedVersion !== null) {
168
- console.log(` Current: ${deployedVersion} → New: ${manifest.version}`);
200
+ const manifest = parsed;
201
+ if (!manifest.id || !manifest.version || !manifest.name) {
202
+ throw new Error('Invalid manifest.json - missing required fields (id, version, name)');
169
203
  }
170
- else {
171
- console.log(' First deploy for this app');
204
+ if (!manifest.sdk_version || !/^\d+\.\d+$/.test(manifest.sdk_version)) {
205
+ throw new Error('Invalid manifest.json - "sdk_version" must be the @juuno-sdk/app-sdk ' +
206
+ 'major.minor this app is built against, e.g. "2.0". The backend ' +
207
+ 'records it on the release and serves the release only to players ' +
208
+ 'that implement it.');
172
209
  }
173
- console.log('');
174
- console.log('📁 Creating bundle...');
175
- let zipBuffer;
176
- try {
177
- zipBuffer = await createZipBundle(buildDir);
178
- console.log(` ✓ Bundle created (${(zipBuffer.length / 1024).toFixed(1)} KB)`);
179
- console.log('');
210
+ // Validated before use rather than trusted from the parse: a third-party
211
+ // manifest is hand-written, and `"translations": "de"` would otherwise reach
212
+ // checkTranslations and fail as a TypeError rather than as a manifest error.
213
+ const declared = manifest.translations;
214
+ if (declared !== undefined && !isArrayOfStrings(declared)) {
215
+ throw new Error('Invalid manifest.json - "translations" must be an array of locale ' +
216
+ 'codes, e.g. ["nl", "de"]. Omit the key entirely if your app ships ' +
217
+ 'no translations.');
180
218
  }
181
- catch (error) {
182
- console.error(`❌ Failed to create bundle: ${error instanceof Error ? error.message : String(error)}`);
183
- process.exit(1);
219
+ const translationsProblem = checkTranslations(declared ?? [], listBundledLocales(buildDir));
220
+ if (translationsProblem) {
221
+ throw new Error(translationsProblem);
184
222
  }
185
- console.log('☁️ Uploading to Juuno...');
186
- let result;
187
- try {
188
- result = await uploadBundle(token, zipBuffer, apiUrl);
189
- console.log(' ✓ Upload complete');
223
+ return manifest;
224
+ }
225
+ /**
226
+ * Decide whether a deploy should be skipped because the version is already
227
+ * live. Returns true when the caller should return early; throws when the
228
+ * deploy should fail (version-already-deployed without skipIfDeployed).
229
+ */
230
+ function shouldSkipDeploy(deployedVersion, manifest, skipIfDeployed) {
231
+ if (deployedVersion === null || deployedVersion !== manifest.version) {
232
+ return false;
233
+ }
234
+ if (skipIfDeployed) {
190
235
  console.log('');
236
+ console.log(`ℹ️ ${manifest.name}@${manifest.version} already deployed, skipping.`);
237
+ console.log('');
238
+ return true;
191
239
  }
192
- catch (error) {
193
- console.error(`❌ ${error instanceof Error ? error.message : String(error)}`);
194
- process.exit(1);
240
+ throw new Error(`Version ${manifest.version} is already deployed for '${manifest.name}'\n\n Versions are immutable. Bump the version in your manifest and try again.`);
241
+ }
242
+ function printVersionDelta(deployedVersion, manifest) {
243
+ if (deployedVersion !== null) {
244
+ console.log(` Current: ${deployedVersion} → New: ${manifest.version}`);
245
+ }
246
+ else {
247
+ console.log(' First deploy for this app');
195
248
  }
249
+ console.log('');
250
+ }
251
+ function printDeploySuccess(result) {
196
252
  console.log('✅ Deployment successful!');
197
253
  console.log('');
198
254
  if (result.data) {
@@ -207,3 +263,41 @@ export async function deployApp(options) {
207
263
  console.log('');
208
264
  }
209
265
  }
266
+ /**
267
+ * Deploys an external app to the Juuno platform.
268
+ */
269
+ export async function deployApp(options) {
270
+ console.log('📦 Juuno CLI - Deploy');
271
+ console.log('');
272
+ try {
273
+ const manifest = loadAndValidateManifest(options.buildDir);
274
+ console.log(` App: ${manifest.name}`);
275
+ console.log(` Version: ${manifest.version}`);
276
+ console.log(` ID: ${manifest.id}`);
277
+ console.log('');
278
+ const apiUrl = getApiUrl(options);
279
+ console.log('🔑 Authenticating...');
280
+ const token = await getAuthToken(options);
281
+ console.log(' ✓ Authenticated');
282
+ console.log('');
283
+ console.log('🔍 Checking deployed version...');
284
+ const deployedVersion = await fetchDeployedVersion(token, manifest.id, apiUrl);
285
+ if (shouldSkipDeploy(deployedVersion, manifest, options.skipIfDeployed)) {
286
+ return;
287
+ }
288
+ printVersionDelta(deployedVersion, manifest);
289
+ console.log('📁 Creating bundle...');
290
+ const zipBuffer = await createZipBundle(options.buildDir);
291
+ console.log(` ✓ Bundle created (${(zipBuffer.length / 1024).toFixed(1)} KB)`);
292
+ console.log('');
293
+ console.log('☁️ Uploading to Juuno...');
294
+ const result = await uploadBundle(token, zipBuffer, apiUrl);
295
+ console.log(' ✓ Upload complete');
296
+ console.log('');
297
+ printDeploySuccess(result);
298
+ }
299
+ catch (error) {
300
+ console.error(`❌ ${getErrorMessage(error)}`);
301
+ process.exit(1);
302
+ }
303
+ }
@@ -42,38 +42,58 @@ async function waitForPort(port, timeoutMs = 30000) {
42
42
  return false;
43
43
  }
44
44
  /**
45
- * Detect if the current directory contains a Juuno external app.
45
+ * Lockfile lookup table. first hit wins. Adding a manager is one entry.
46
46
  */
47
- function detectApp(cwd) {
48
- const hasViteConfig = existsSync(join(cwd, 'vite.config.ts')) ||
49
- existsSync(join(cwd, 'vite.config.js'));
50
- const hasPackageJson = existsSync(join(cwd, 'package.json'));
51
- let hasDevScript = false;
52
- let packageManager = 'npm';
53
- if (hasPackageJson) {
54
- try {
55
- const packageJson = JSON.parse(readFileSync(join(cwd, 'package.json'), 'utf-8'));
56
- hasDevScript = !!packageJson.scripts?.dev;
57
- }
58
- catch {
59
- // Ignore parse errors.
47
+ const PACKAGE_MANAGER_LOCKFILES = [
48
+ { lockfile: 'pnpm-lock.yaml', manager: 'pnpm' },
49
+ { lockfile: 'yarn.lock', manager: 'yarn' },
50
+ { lockfile: 'package-lock.json', manager: 'npm' },
51
+ ];
52
+ function detectPackageManager(cwd) {
53
+ for (const { lockfile, manager } of PACKAGE_MANAGER_LOCKFILES) {
54
+ if (findFileUpward(cwd, lockfile)) {
55
+ return manager;
60
56
  }
61
57
  }
62
- // Detect package manager from lockfiles (check current dir and parent dirs for monorepo support).
63
- if (findFileUpward(cwd, 'pnpm-lock.yaml')) {
64
- packageManager = 'pnpm';
58
+ return 'npm';
59
+ }
60
+ /**
61
+ * Map a detected package manager to the (command, args) pair that runs
62
+ * vite with the configured port. Pure.
63
+ */
64
+ function buildDevCommand(pm, servePort) {
65
+ const args = ['vite', '--port', servePort, '--strictPort'];
66
+ if (pm === 'pnpm') {
67
+ return { command: 'pnpm', args: ['exec', ...args] };
68
+ }
69
+ if (pm === 'yarn') {
70
+ return { command: 'yarn', args };
71
+ }
72
+ return { command: 'npx', args };
73
+ }
74
+ function readDevScriptPresence(cwd) {
75
+ const path = join(cwd, 'package.json');
76
+ if (!existsSync(path)) {
77
+ return false;
65
78
  }
66
- else if (findFileUpward(cwd, 'yarn.lock')) {
67
- packageManager = 'yarn';
79
+ try {
80
+ const packageJson = JSON.parse(readFileSync(path, 'utf-8'));
81
+ return !!packageJson.scripts?.dev;
68
82
  }
69
- else if (findFileUpward(cwd, 'package-lock.json')) {
70
- packageManager = 'npm';
83
+ catch {
84
+ return false;
71
85
  }
86
+ }
87
+ /**
88
+ * Detect if the current directory contains a Juuno external app.
89
+ */
90
+ function detectApp(cwd) {
72
91
  return {
73
- hasViteConfig,
74
- hasPackageJson,
75
- hasDevScript,
76
- packageManager,
92
+ hasViteConfig: existsSync(join(cwd, 'vite.config.ts')) ||
93
+ existsSync(join(cwd, 'vite.config.js')),
94
+ hasPackageJson: existsSync(join(cwd, 'package.json')),
95
+ hasDevScript: readDevScriptPresence(cwd),
96
+ packageManager: detectPackageManager(cwd),
77
97
  };
78
98
  }
79
99
  /**
@@ -131,21 +151,7 @@ export async function runDev(options) {
131
151
  }
132
152
  // Step 1: Start Vite dev server.
133
153
  console.log('🚀 Starting Vite dev server...\n');
134
- // Build the command and args based on package manager.
135
- let devCommand;
136
- let devArgs;
137
- if (pm === 'pnpm') {
138
- devCommand = 'pnpm';
139
- devArgs = ['exec', 'vite', '--port', servePort, '--strictPort'];
140
- }
141
- else if (pm === 'yarn') {
142
- devCommand = 'yarn';
143
- devArgs = ['vite', '--port', servePort, '--strictPort'];
144
- }
145
- else {
146
- devCommand = 'npx';
147
- devArgs = ['vite', '--port', servePort, '--strictPort'];
148
- }
154
+ const { command: devCommand, args: devArgs } = buildDevCommand(pm, servePort);
149
155
  const devProcess = spawn(devCommand, devArgs, {
150
156
  cwd,
151
157
  stdio: ['ignore', 'pipe', 'pipe'],
@@ -1,32 +1,16 @@
1
1
  import { getAuthToken, getApiUrl } from '../auth/index.js';
2
+ import { getErrorMessage } from '../utils/error.js';
3
+ import { assertSuccessEnvelope, fetchAuthorisedJson, wrapError, } from '../utils/api.js';
2
4
  /**
3
5
  * Fetches detailed information about a specific app.
4
6
  */
5
7
  async function fetchAppInfo(token, appId, apiUrl) {
6
- const infoUrl = `${apiUrl}/api/v1/developer/apps/${appId}`;
7
8
  try {
8
- const response = await fetch(infoUrl, {
9
- method: 'GET',
10
- headers: {
11
- Authorization: `Bearer ${token}`,
12
- Accept: 'application/json',
13
- },
14
- });
15
- if (!response.ok) {
16
- const errorText = await response.text();
17
- throw new Error(`Failed to fetch app info: ${response.status} ${response.statusText}\n${errorText}`);
18
- }
19
- const data = (await response.json());
20
- if (!data.success || !data.data) {
21
- throw new Error(`Failed to fetch app info: ${data.message || 'Unknown error'}`);
22
- }
23
- return data.data;
9
+ const envelope = await fetchAuthorisedJson(`${apiUrl}/api/v1/developer/apps/${appId}`, token);
10
+ return assertSuccessEnvelope(envelope);
24
11
  }
25
12
  catch (error) {
26
- if (error instanceof Error) {
27
- throw new Error(`Failed to fetch app info: ${error.message}`);
28
- }
29
- throw new Error('Failed to fetch app info: Unknown error');
13
+ throw wrapError('Failed to fetch app info', error);
30
14
  }
31
15
  }
32
16
  /**
@@ -84,28 +68,20 @@ export async function getAppInfo(options) {
84
68
  console.error(' Usage: juuno-cli info <app-id>');
85
69
  process.exit(1);
86
70
  }
87
- const apiUrl = getApiUrl(options);
88
- console.log('🔑 Authenticating...');
89
- let token;
90
71
  try {
91
- token = await getAuthToken(options);
72
+ const apiUrl = getApiUrl(options);
73
+ console.log('🔑 Authenticating...');
74
+ const token = await getAuthToken(options);
92
75
  console.log(' ✓ Authenticated');
93
76
  console.log('');
94
- }
95
- catch (error) {
96
- console.error(`❌ ${error instanceof Error ? error.message : String(error)}`);
97
- process.exit(1);
98
- }
99
- console.log(`📡 Fetching app info for: ${options.appId}`);
100
- let appInfo;
101
- try {
102
- appInfo = await fetchAppInfo(token, options.appId, apiUrl);
77
+ console.log(`📡 Fetching app info for: ${options.appId}`);
78
+ const appInfo = await fetchAppInfo(token, options.appId, apiUrl);
103
79
  console.log(' ✓ Info retrieved');
104
80
  console.log('');
81
+ displayAppInfo(appInfo);
105
82
  }
106
83
  catch (error) {
107
- console.error(`❌ ${error instanceof Error ? error.message : String(error)}`);
84
+ console.error(`❌ ${getErrorMessage(error)}`);
108
85
  process.exit(1);
109
86
  }
110
- displayAppInfo(appInfo);
111
87
  }
@@ -1,32 +1,16 @@
1
1
  import { getAuthToken, getApiUrl } from '../auth/index.js';
2
+ import { getErrorMessage } from '../utils/error.js';
3
+ import { assertSuccessEnvelope, fetchAuthorisedJson, wrapError, } from '../utils/api.js';
2
4
  /**
3
5
  * Fetches the list of developer apps from the API.
4
6
  */
5
7
  async function fetchAppList(token, apiUrl) {
6
- const listUrl = `${apiUrl}/api/v1/developer/apps`;
7
8
  try {
8
- const response = await fetch(listUrl, {
9
- method: 'GET',
10
- headers: {
11
- Authorization: `Bearer ${token}`,
12
- Accept: 'application/json',
13
- },
14
- });
15
- if (!response.ok) {
16
- const errorText = await response.text();
17
- throw new Error(`Failed to fetch apps: ${response.status} ${response.statusText}\n${errorText}`);
18
- }
19
- const data = (await response.json());
20
- if (!data.success) {
21
- throw new Error(`Failed to fetch apps: ${data.message || 'Unknown error'}`);
22
- }
23
- return data.data || [];
9
+ const envelope = await fetchAuthorisedJson(`${apiUrl}/api/v1/developer/apps`, token);
10
+ return assertSuccessEnvelope(envelope);
24
11
  }
25
12
  catch (error) {
26
- if (error instanceof Error) {
27
- throw new Error(`Failed to fetch apps: ${error.message}`);
28
- }
29
- throw new Error('Failed to fetch apps: Unknown error');
13
+ throw wrapError('Failed to fetch apps', error);
30
14
  }
31
15
  }
32
16
  /**
@@ -75,28 +59,20 @@ function displayAppList(apps) {
75
59
  export async function listApps(options) {
76
60
  console.log('📦 Juuno CLI - List Apps');
77
61
  console.log('');
78
- const apiUrl = getApiUrl(options);
79
- console.log('🔑 Authenticating...');
80
- let token;
81
62
  try {
82
- token = await getAuthToken(options);
63
+ const apiUrl = getApiUrl(options);
64
+ console.log('🔑 Authenticating...');
65
+ const token = await getAuthToken(options);
83
66
  console.log(' ✓ Authenticated');
84
67
  console.log('');
85
- }
86
- catch (error) {
87
- console.error(`❌ ${error instanceof Error ? error.message : String(error)}`);
88
- process.exit(1);
89
- }
90
- console.log('📡 Fetching apps...');
91
- let apps;
92
- try {
93
- apps = await fetchAppList(token, apiUrl);
68
+ console.log('📡 Fetching apps...');
69
+ const apps = await fetchAppList(token, apiUrl);
94
70
  console.log(' ✓ Apps retrieved');
95
71
  console.log('');
72
+ displayAppList(apps);
96
73
  }
97
74
  catch (error) {
98
- console.error(`❌ ${error instanceof Error ? error.message : String(error)}`);
75
+ console.error(`❌ ${getErrorMessage(error)}`);
99
76
  process.exit(1);
100
77
  }
101
- displayAppList(apps);
102
78
  }