cloudron 8.1.1 → 8.2.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/CHANGELOG.md CHANGED
@@ -94,3 +94,34 @@
94
94
  * Add `cloudron sync push` and `cloudron sync pull` subcommands
95
95
  * Merge all binaries into single `cloudron` command
96
96
 
97
+ [7.1.1]
98
+ * Prettier login success page
99
+ * oidc: explicitly listen on ipv4 which is always available
100
+
101
+ [7.1.2]
102
+ * versions: implement revoke --version
103
+ * Make the login page slightly more like the dashboard style and add dark mode
104
+
105
+ [8.0.0]
106
+ * Implement device authorization grant flow
107
+
108
+ [8.0.1]
109
+ * device codes only needs to be confirmed not entered
110
+ * Add fallback in case the overloaded cloudron build --help is called
111
+
112
+ [8.0.2]
113
+ * install/update: -f and --build-arg
114
+ * wait even in debug mode, but just not for healthy
115
+ * add cloudron init --template <template>
116
+ * Fix cloudron pull as newer nodejs versions do not have resume() on stdout anymore
117
+
118
+ [8.1.0]
119
+ * Continue cloudron build even if run outside a git repository
120
+ * Remove old legacy appstore login/logout
121
+
122
+ [8.1.1]
123
+ * token is not required. verify-manifest does not need it
124
+
125
+ [8.1.2]
126
+ * add appstore login/logout and also forum post logs
127
+
package/bin/cloudron CHANGED
@@ -29,6 +29,14 @@ program.option('--server <server>', 'Cloudron domain')
29
29
  const appstoreCommand = program.command('appstore').description('Commands for publishing to the Appstore')
30
30
  .option('--appstore-token <token>', 'AppStore token');
31
31
 
32
+ appstoreCommand.command('login')
33
+ .description('Login to the appstore')
34
+ .action(appstoreActions.login);
35
+
36
+ appstoreCommand.command('logout')
37
+ .description('Logout from the appstore')
38
+ .action(appstoreActions.logout);
39
+
32
40
  appstoreCommand.command('info')
33
41
  .description('List info of published app')
34
42
  .option('--appstore-id <appid@version>', 'Appstore id and version')
@@ -429,4 +437,8 @@ versionsCommand.command('update')
429
437
  .option('--state <state>', 'Publish state (published or testing)')
430
438
  .action(versionsActions.addOrUpdate);
431
439
 
440
+ versionsCommand.command('verify')
441
+ .description('Verify if versions file is valid for publishing')
442
+ .action(versionsActions.verify);
443
+
432
444
  program.parse(process.argv);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cloudron",
3
- "version": "8.1.1",
3
+ "version": "8.2.0",
4
4
  "license": "MIT",
5
5
  "description": "Cloudron Commandline Tool",
6
6
  "type": "module",
@@ -23,7 +23,7 @@
23
23
  "commander": "^14.0.3",
24
24
  "debug": "^4.4.3",
25
25
  "easy-table": "^1.2.0",
26
- "ejs": "^5.0.1",
26
+ "ejs": "^5.0.2",
27
27
  "eventsource": "^4.1.0",
28
28
  "micromatch": "^4.0.8",
29
29
  "open": "^11.0.0",
@@ -35,8 +35,8 @@
35
35
  },
36
36
  "devDependencies": {
37
37
  "@eslint/js": "^10.0.1",
38
- "eslint": "^10.0.3",
39
- "globals": "^17.4.0",
38
+ "eslint": "^10.2.0",
39
+ "globals": "^17.5.0",
40
40
  "mocha": "^11.7.5"
41
41
  }
42
42
  }
@@ -2,7 +2,7 @@ import assert from 'assert';
2
2
  import * as config from './config.js';
3
3
  import { execSync } from 'child_process';
4
4
  import fs from 'fs';
5
- import { exit, locateManifest, parseChangelog } from './helper.js';
5
+ import { exit, locateManifest, parseChangelog, performOidcLogin } from './helper.js';
6
6
  import manifestFormat from '@cloudron/manifest-format';
7
7
  import path from 'path';
8
8
  import safe from '@cloudron/safetydance';
@@ -14,7 +14,7 @@ const NO_MANIFEST_FOUND_ERROR_STRING = 'No CloudronManifest.json found';
14
14
  function requestError(response) {
15
15
  if (response.status === 401) return 'Invalid token.';
16
16
 
17
- return `${response.status} message: ${response.body?.message || response.text || JSON.stringify(response.body)}`; // body is sometimes just a string like in 401
17
+ return `${response.status} message: ${response.body?.message || JSON.stringify(response.body)}`;
18
18
  }
19
19
 
20
20
  function createRequest(method, apiPath, options) {
@@ -392,38 +392,67 @@ async function notify() {
392
392
 
393
393
  if (!manifest.forumUrl) return exit(new Error('CloudronManifest.json does not have a forumUrl'));
394
394
  const categoryMatch = manifest.forumUrl.match(/category\/(.*)\//);
395
- if (!categoryMatch) return exit('Unable to detect category id');
395
+ if (!categoryMatch) return exit(`Unable to detect category id from forumUrl: ${manifest.forumUrl}`);
396
396
  const categoryId = categoryMatch[1];
397
397
 
398
+ console.log(`[notify] appId: ${manifest.id} version: ${manifest.version} forumUrl: ${manifest.forumUrl} categoryId: ${categoryId}`);
399
+
398
400
  const categoryResponse = await superagent.get(`https://forum.cloudron.io/api/v3/categories/${categoryId}/topics`).set('Authorization', `Bearer ${apiToken}`).ok(() => true);
399
- if (categoryResponse.status !== 200) return exit(`Unable to get topics of category: ${requestError(categoryResponse)}`);
400
- const topic = categoryResponse.body.response.topics.find(t => t.title.toLowerCase().includes('Package Updates'.toLowerCase()));
401
+ if (categoryResponse.status !== 200) return exit(`Unable to get topics of category (status ${categoryResponse.status}): ${requestError(categoryResponse)}`);
402
+
403
+ const allTopics = categoryResponse.body.response.topics;
404
+ console.log(`[notify] Got ${allTopics.length} topics in category. Titles: ${allTopics.map(t => t.title).join(', ')}`);
405
+
406
+ const topic = allTopics.find(t => t.title.toLowerCase().includes('package updates'));
401
407
  if (!topic) return exit('Could not find the Package Update topic');
402
408
  const topicId = topic.tid;
409
+ console.log(`[notify] Found "Package Updates" topic tid: ${topicId}`);
403
410
 
404
411
  const pageCountResponse = await superagent.get(`https://forum.cloudron.io/api/topic/pagination/${topicId}`).set('Authorization', `Bearer ${apiToken}`).ok(() => true);
405
- if (pageCountResponse.status !== 200) return exit(`Unable to get page count of topic: ${requestError(pageCountResponse)}`);
412
+ if (pageCountResponse.status !== 200) return exit(`Unable to get page count of topic (status ${pageCountResponse.status}): ${requestError(pageCountResponse)}`);
406
413
  const pageCount = pageCountResponse.body.pagination.pageCount;
414
+ console.log(`[notify] Topic has ${pageCount} pages to scan for duplicates`);
407
415
 
408
416
  for (let page = 1; page <= pageCount; page++) {
409
417
  const pageResponse = await superagent.get(`https://forum.cloudron.io/api/topic/${topicId}?page=${page}`).set('Authorization', `Bearer ${apiToken}`).ok(() => true);
410
- if (pageResponse.status !== 200) return exit(`Unable to get topics of category: ${requestError(pageResponse)}`);
411
- for (const post of pageResponse.body.posts) { // post.content is html!
418
+ if (pageResponse.status !== 200) return exit(`Unable to get posts of topic page ${page} (status ${pageResponse.status}): ${requestError(pageResponse)}`);
419
+ console.log(`[notify] Page ${page}/${pageCount}: ${pageResponse.body.posts.length} posts`);
420
+ for (const post of pageResponse.body.posts) {
412
421
  if (post.content.includes(`[${manifest.version}]`)) return exit(`Version ${manifest.version} is already on the forum.\n${post.content}`);
413
422
  }
414
423
  }
415
424
 
425
+ console.log(`[notify] No duplicate found, posting version ${manifest.version}`);
426
+
416
427
  // https://docs.nodebb.org/api/write/#tag/topics/paths/~1topics~1%7Btid%7D/post
417
428
  const postData = {
418
429
  content: postContent,
419
430
  toPid: 0 // which post is this post a reply to
420
431
  };
421
432
  const postResponse = await superagent.post(`https://forum.cloudron.io/api/v3/topics/${topicId}`).set('Authorization', `Bearer ${apiToken}`).send(postData).ok(() => true);
422
- if (postResponse.status !== 200) return exit(`Unable to create changelog post: ${requestError(postResponse)}`);
433
+ if (postResponse.status !== 200) return exit(`Unable to create changelog post (status ${postResponse.status}): ${requestError(postResponse)}`);
423
434
  console.log('Posted to forum');
424
435
  }
425
436
 
437
+ async function login() {
438
+ const origin = config.appStoreOrigin();
439
+ const consoleFqdn = origin.replace('https://', '').replace('api.', 'console.') + '/openid';
440
+
441
+ const token = await performOidcLogin(consoleFqdn);
442
+ if (!token) process.exit(1);
443
+
444
+ config.setAppStoreToken(token);
445
+ console.log('Login successful.');
446
+ }
447
+
448
+ function logout() {
449
+ config.setAppStoreToken(null);
450
+ console.log('Logged out.');
451
+ }
452
+
426
453
  export default {
454
+ login,
455
+ logout,
427
456
  info,
428
457
  listVersions,
429
458
  submit,
@@ -31,51 +31,6 @@ async function writeVersions(versionsFilePath, versionsRoot) {
31
31
  if (error) return exit(`Unable to write to ${path.relative(process.cwd(), versionsFilePath)}: ${error.message}`);
32
32
  }
33
33
 
34
- async function init(/*localOptions, cmd*/) {
35
- const manifestFilePath = locateManifest();
36
- if (!manifestFilePath) return exit(`${NO_MANIFEST_FOUND_ERROR_STRING}. You must run this command in the package dir.`);
37
-
38
- const baseDir = path.dirname(manifestFilePath);
39
- const versionsFilePath = `${baseDir}/CloudronVersions.json`;
40
-
41
- if (fs.existsSync(versionsFilePath)) return exit(`${path.relative(process.cwd(), versionsFilePath)} already exists.`);
42
-
43
- await writeVersions(versionsFilePath, { stable: true, versions: {} });
44
- console.log(`Created ${path.relative(process.cwd(), versionsFilePath)}.`);
45
-
46
- const result = manifestFormat.parseFile(manifestFilePath);
47
- if (!result.error) {
48
- ensurePublishFields(result.manifest, manifestFilePath);
49
- }
50
-
51
- console.log('\nUse "cloudron versions add" to add a version.');
52
- }
53
-
54
- async function resolveManifest(manifest, baseDir) {
55
- if (manifest.description.slice(0, 7) === 'file://') {
56
- let descriptionFilePath = manifest.description.slice(7);
57
- descriptionFilePath = path.isAbsolute(descriptionFilePath) ? descriptionFilePath : path.join(baseDir, descriptionFilePath);
58
- manifest.description = safe.fs.readFileSync(descriptionFilePath, 'utf8');
59
- if (!manifest.description && safe.error) throw(new Error('Could not read/parse description ' + safe.error.message));
60
- if (!manifest.description) throw new Error('Description cannot be empty');
61
- }
62
-
63
- if (manifest.postInstallMessage && manifest.postInstallMessage.slice(0, 7) === 'file://') {
64
- let postInstallFilePath = manifest.postInstallMessage.slice(7);
65
- postInstallFilePath = path.isAbsolute(postInstallFilePath) ? postInstallFilePath : path.join(baseDir, postInstallFilePath);
66
- manifest.postInstallMessage = safe.fs.readFileSync(postInstallFilePath, 'utf8');
67
- if (!manifest.postInstallMessage && safe.error) throw(new Error('Could not read/parse postInstall ' + safe.error.message));
68
- if (!manifest.postInstallMessage) throw new Error('PostInstall file specified but it is empty');
69
- }
70
-
71
- if (manifest.changelog.slice(0, 7) === 'file://') {
72
- let changelogPath = manifest.changelog.slice(7);
73
- changelogPath = path.isAbsolute(changelogPath) ? changelogPath : path.join(baseDir, changelogPath);
74
- manifest.changelog = parseChangelog(changelogPath, manifest.version);
75
- if (!manifest.changelog) throw new Error('Bad changelog format or missing changelog for this version');
76
- }
77
- }
78
-
79
34
  function createStubFile(filePath, content) {
80
35
  if (fs.existsSync(filePath)) return false;
81
36
  fs.writeFileSync(filePath, content, 'utf8');
@@ -162,6 +117,55 @@ function ensurePublishFields(manifest, manifestFilePath) {
162
117
  console.log(' tags, mediaLinks, DESCRIPTION.md, CHANGELOG, POSTINSTALL.md');
163
118
  }
164
119
 
120
+ async function init(/*localOptions, cmd*/) {
121
+ const manifestFilePath = locateManifest();
122
+ if (!manifestFilePath) return exit(`${NO_MANIFEST_FOUND_ERROR_STRING}. You must run this command in the package dir.`);
123
+
124
+ const baseDir = path.dirname(manifestFilePath);
125
+ const versionsFilePath = `${baseDir}/CloudronVersions.json`;
126
+
127
+ if (fs.existsSync(versionsFilePath)) return exit(`${path.relative(process.cwd(), versionsFilePath)} already exists.`);
128
+
129
+ await writeVersions(versionsFilePath, { stable: true, versions: {} });
130
+ console.log(`Created ${path.relative(process.cwd(), versionsFilePath)}.`);
131
+
132
+ const result = manifestFormat.parseFile(manifestFilePath);
133
+ if (!result.error) {
134
+ ensurePublishFields(result.manifest, manifestFilePath);
135
+ }
136
+
137
+ console.log('\nUse "cloudron versions add" to add a version.');
138
+ }
139
+
140
+ async function resolveManifest(manifest, baseDir) {
141
+ const resolved = structuredClone(manifest);
142
+
143
+ if (resolved.description.slice(0, 7) === 'file://') {
144
+ let descriptionFilePath = resolved.description.slice(7);
145
+ descriptionFilePath = path.isAbsolute(descriptionFilePath) ? descriptionFilePath : path.join(baseDir, descriptionFilePath);
146
+ resolved.description = safe.fs.readFileSync(descriptionFilePath, 'utf8');
147
+ if (!resolved.description && safe.error) throw(new Error('Could not read/parse description ' + safe.error.message));
148
+ if (!resolved.description) throw new Error('Description cannot be empty');
149
+ }
150
+
151
+ if (resolved.postInstallMessage && resolved.postInstallMessage.slice(0, 7) === 'file://') {
152
+ let postInstallFilePath = resolved.postInstallMessage.slice(7);
153
+ postInstallFilePath = path.isAbsolute(postInstallFilePath) ? postInstallFilePath : path.join(baseDir, postInstallFilePath);
154
+ resolved.postInstallMessage = safe.fs.readFileSync(postInstallFilePath, 'utf8');
155
+ if (!resolved.postInstallMessage && safe.error) throw(new Error('Could not read/parse postInstall ' + safe.error.message));
156
+ if (!resolved.postInstallMessage) throw new Error('PostInstall file specified but it is empty');
157
+ }
158
+
159
+ if (resolved.changelog.slice(0, 7) === 'file://') {
160
+ let changelogPath = resolved.changelog.slice(7);
161
+ changelogPath = path.isAbsolute(changelogPath) ? changelogPath : path.join(baseDir, changelogPath);
162
+ resolved.changelog = parseChangelog(changelogPath, resolved.version);
163
+ if (!resolved.changelog) throw new Error('Bad changelog format or missing changelog for this version');
164
+ }
165
+
166
+ return resolved;
167
+ }
168
+
165
169
  async function addOrUpdate(localOptions, cmd) {
166
170
  const isUpdate = cmd.parent.args[0] === 'update';
167
171
  const versionsFilePath = await locateVersions();
@@ -194,7 +198,7 @@ async function addOrUpdate(localOptions, cmd) {
194
198
  const versionsRoot = await readVersions(versionsFilePath);
195
199
  const versions = versionsRoot.versions;
196
200
 
197
- await resolveManifest(manifest, path.dirname(manifestFilePath));
201
+ const resolved = await resolveManifest(manifest, path.dirname(manifestFilePath));
198
202
 
199
203
  if (options.state && options.state !== PUBLISH_STATE_PUBLISHED && options.state !== PUBLISH_STATE_TESTING) {
200
204
  return exit(`Invalid state "${options.state}". Must be "published" or "testing".`);
@@ -204,13 +208,13 @@ async function addOrUpdate(localOptions, cmd) {
204
208
  const targetVersion = options.version || manifest.version;
205
209
  if (!(targetVersion in versions)) exit(`${targetVersion} does not exist in ${path.relative(process.cwd(), versionsFilePath)}.`);
206
210
 
207
- versions[targetVersion].manifest = manifest;
211
+ versions[targetVersion].manifest = resolved;
208
212
  versions[targetVersion].ts = (new Date()).toUTCString();
209
213
  if (options.state) versions[targetVersion].publishState = options.state;
210
214
  } else {
211
215
  if (manifest.version in versions) exit(`${manifest.version} already exists in ${path.relative(process.cwd(), versionsFilePath)}.`);
212
216
  versions[manifest.version] = {
213
- manifest,
217
+ manifest: resolved,
214
218
  creationDate: (new Date()).toUTCString(),
215
219
  ts: (new Date()).toUTCString(),
216
220
  publishState: options.state || PUBLISH_STATE_PUBLISHED
@@ -278,9 +282,38 @@ async function revoke(localOptions, cmd) {
278
282
  console.log(`Marked ${targetVersion} as revoked in ${path.relative(process.cwd(), versionsFilePath)}`);
279
283
  }
280
284
 
285
+ async function verify(/* localOptions, cmd */) {
286
+ const versionsFilePath = await locateVersions();
287
+ if (!versionsFilePath) return exit(NO_VERSIONS_FOUND_ERROR_STRING);
288
+
289
+ const versionsRoot = await readVersions(versionsFilePath);
290
+ const versions = versionsRoot.versions;
291
+
292
+ const sortedVersions = Object.keys(versions).sort(manifestFormat.packageVersionCompare);
293
+ if (sortedVersions.length === 0) return exit('No versions found in versions file');
294
+
295
+ for (const version of sortedVersions) {
296
+ const manifest = versions[version].manifest;
297
+
298
+ if (!manifest.dockerImage) return exit(`Version ${version}: missing dockerImage`);
299
+
300
+ const error = manifestFormat.checkVersionsRequirements(manifest);
301
+ if (error) return exit(`Version ${version}: ${error}`);
302
+
303
+ try {
304
+ await resolveManifest(manifest, path.dirname(versionsFilePath));
305
+ } catch (e) {
306
+ return exit(`Version ${version}: ${e.message}`);
307
+ }
308
+ }
309
+
310
+ console.log(`${path.relative(process.cwd(), versionsFilePath)} is valid (${sortedVersions.length} version(s))`);
311
+ }
312
+
281
313
  export default {
282
314
  init,
283
315
  addOrUpdate,
284
316
  list,
285
317
  revoke,
318
+ verify,
286
319
  };