@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.
- package/README.md +5 -3
- package/bin/cli-router.js +54 -72
- package/bin/cli.js +2 -3
- package/dist/assets/main-GRbDNSIa.css +1 -0
- package/dist/assets/main-TDlmPBlt.js +1 -0
- package/dist/assets/main-rFvAfxK4.js +1 -0
- package/dist/cli/src/auth/index.js +47 -34
- package/dist/cli/src/deploy/index.js +209 -115
- package/dist/cli/src/dev/index.js +46 -40
- package/dist/cli/src/info/index.js +12 -36
- package/dist/cli/src/list/index.js +12 -36
- package/dist/cli/src/login/index.js +37 -42
- package/dist/cli/src/logout/index.js +34 -33
- package/dist/cli/src/simulator/index.js +54 -39
- package/dist/cli/src/utils/api.js +45 -0
- package/dist/cli/src/utils/error.js +9 -0
- package/dist/cli/src/utils/is-record.js +11 -0
- package/dist/cli/src/whoami/index.js +37 -33
- package/dist/cli/tsconfig.build.tsbuildinfo +1 -1
- package/dist/index.html +5 -2
- package/dist/types/src/auth/index.d.ts +17 -0
- package/dist/types/src/auth/index.d.ts.map +1 -1
- package/dist/types/src/deploy/index.d.ts +12 -0
- package/dist/types/src/deploy/index.d.ts.map +1 -1
- package/dist/types/src/dev/index.d.ts.map +1 -1
- package/dist/types/src/info/index.d.ts.map +1 -1
- package/dist/types/src/list/index.d.ts.map +1 -1
- package/dist/types/src/login/index.d.ts.map +1 -1
- package/dist/types/src/logout/index.d.ts.map +1 -1
- package/dist/types/src/simulator/index.d.ts.map +1 -1
- package/dist/types/src/utils/api.d.ts +27 -0
- package/dist/types/src/utils/api.d.ts.map +1 -0
- package/dist/types/src/utils/error.d.ts +8 -0
- package/dist/types/src/utils/error.d.ts.map +1 -0
- package/dist/types/src/utils/is-record.d.ts +10 -0
- package/dist/types/src/utils/is-record.d.ts.map +1 -0
- package/dist/types/src/whoami/index.d.ts.map +1 -1
- 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(
|
|
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
|
-
//
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
*
|
|
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
|
-
|
|
115
|
-
const buildDir = options.buildDir;
|
|
116
|
-
console.log('📦 Juuno CLI - Deploy');
|
|
117
|
-
console.log('');
|
|
182
|
+
function loadAndValidateManifest(buildDir) {
|
|
118
183
|
if (!existsSync(buildDir)) {
|
|
119
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
143
|
-
console.log(' ✓ Authenticated');
|
|
144
|
-
console.log('');
|
|
192
|
+
parsed = JSON.parse(readFileSync(manifestPath, 'utf-8'));
|
|
145
193
|
}
|
|
146
194
|
catch (error) {
|
|
147
|
-
|
|
148
|
-
process.exit(1);
|
|
195
|
+
throw new Error(`Invalid manifest.json in ${buildDir}: ${getErrorMessage(error)}`);
|
|
149
196
|
}
|
|
150
|
-
|
|
151
|
-
|
|
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
|
-
|
|
168
|
-
|
|
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
|
-
|
|
171
|
-
|
|
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
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
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
|
-
|
|
182
|
-
|
|
183
|
-
|
|
219
|
+
const translationsProblem = checkTranslations(declared ?? [], listBundledLocales(buildDir));
|
|
220
|
+
if (translationsProblem) {
|
|
221
|
+
throw new Error(translationsProblem);
|
|
184
222
|
}
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
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
|
-
|
|
193
|
-
|
|
194
|
-
|
|
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
|
-
*
|
|
45
|
+
* Lockfile lookup table. first hit wins. Adding a manager is one entry.
|
|
46
46
|
*/
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
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
|
-
|
|
63
|
-
|
|
64
|
-
|
|
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
|
-
|
|
67
|
-
|
|
79
|
+
try {
|
|
80
|
+
const packageJson = JSON.parse(readFileSync(path, 'utf-8'));
|
|
81
|
+
return !!packageJson.scripts?.dev;
|
|
68
82
|
}
|
|
69
|
-
|
|
70
|
-
|
|
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
|
-
|
|
75
|
-
|
|
76
|
-
|
|
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
|
-
|
|
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
|
|
9
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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(`❌ ${
|
|
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
|
|
9
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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(`❌ ${
|
|
75
|
+
console.error(`❌ ${getErrorMessage(error)}`);
|
|
99
76
|
process.exit(1);
|
|
100
77
|
}
|
|
101
|
-
displayAppList(apps);
|
|
102
78
|
}
|