cloudron 5.11.7 → 5.11.9

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.
@@ -32,11 +32,12 @@ program.command('info')
32
32
  program.command('approve')
33
33
  .description('Approve a submitted app version')
34
34
  .option('--appstore-id <appid@version>', 'Appstore id and version')
35
+ .option('--no-git-push', 'Do not attempt to push to git repo')
35
36
  .action(appstoreActions.approve);
36
37
 
37
- program.command('tag <version>')
38
- .description('Tag the repo')
39
- .action(appstoreActions.tag);
38
+ program.command('notify')
39
+ .description('Notify forum about successful app submission')
40
+ .action(appstoreActions.notify);
40
41
 
41
42
  program.command('revoke')
42
43
  .description('Revoke a published app version')
@@ -45,6 +45,7 @@ program.command('push')
45
45
  .option('--id <buildid>', 'Build ID')
46
46
  .option('--repository [repository url]', 'Set repository to push to. e.g registry/username/projectname')
47
47
  .option('--tag <docker image tag>', 'Docker image tag. Note that this does not include the repository name')
48
+ .option('--image <docker image>', 'Docker image of the form registry/repo:tag')
48
49
  .action(buildActions.push);
49
50
 
50
51
  program.command('status')
package/package.json CHANGED
@@ -1,13 +1,13 @@
1
1
  {
2
2
  "name": "cloudron",
3
- "version": "5.11.7",
3
+ "version": "5.11.9",
4
4
  "license": "MIT",
5
5
  "description": "Cloudron Commandline Tool",
6
6
  "main": "main.js",
7
- "homepage": "https://git.cloudron.io/cloudron/cloudron-cli",
7
+ "homepage": "https://git.cloudron.io/platform/cloudron-cli",
8
8
  "repository": {
9
9
  "type": "git",
10
- "url": "https://git.cloudron.io/cloudron/cloudron-cli.git"
10
+ "url": "https://git.cloudron.io/platform/cloudron-cli.git"
11
11
  },
12
12
  "scripts": {
13
13
  "test": "mocha test/test.js"
@@ -18,7 +18,7 @@
18
18
  "author": "Cloudron Developers <support@cloudron.io>",
19
19
  "dependencies": {
20
20
  "async": "^3.2.5",
21
- "cloudron-manifestformat": "^5.24.0",
21
+ "cloudron-manifestformat": "^5.26.2",
22
22
  "commander": "^12.1.0",
23
23
  "debug": "^4.3.5",
24
24
  "easy-table": "^1.2.0",
@@ -25,7 +25,7 @@ exports = module.exports = {
25
25
  revoke,
26
26
  approve,
27
27
 
28
- tag
28
+ notify
29
29
  };
30
30
 
31
31
  const NO_MANIFEST_FOUND_ERROR_STRING = 'No CloudronManifest.json found';
@@ -280,8 +280,19 @@ async function upload(localOptions, cmd) {
280
280
  const appConfig = config.getAppConfig(sourceDir);
281
281
 
282
282
  // image can be passed in options for buildbot
283
- const dockerImage = options.image ? options.image : appConfig.dockerImage;
284
- manifest.dockerImage = dockerImage;
283
+ if (options.image) {
284
+ manifest.dockerImage = options.image;
285
+ } else {
286
+ const gitCommit = execSync('git rev-parse HEAD', { encoding: 'utf8' }).trim();
287
+ if (appConfig.gitCommit !== gitCommit) {
288
+ console.log(`This build ${appConfig.dockerImage} was made from git hash ${appConfig.gitCommit} but you are now at ${gitCommit}`);
289
+ if (!appConfig.gitCommit) return exit('The build is stale');
290
+ const output = execSync(`git diff ${appConfig.gitCommit}..HEAD --name-only`, { encoding: 'utf8'});
291
+ const changedFiles = output.trim().split('\n').filter(filepath => !filepath.match(/(^CHANGELOG|README|CloudronManifest|test\|.git|screenshots|renovate|LICENSE|POSTINSTALL|.docker|logo)/));
292
+ if (changedFiles.length) return exit(`The build is stale. Changed files: ${changedFiles.join(',')}`);
293
+ }
294
+ manifest.dockerImage = appConfig.dockerImage;
295
+ }
285
296
 
286
297
  if (!manifest.dockerImage) exit('No docker image found, run `cloudron build` first');
287
298
 
@@ -291,6 +302,10 @@ async function upload(localOptions, cmd) {
291
302
  const error = manifestFormat.checkAppstoreRequirements(manifest);
292
303
  if (error) return exit(error);
293
304
 
305
+ const [repo, tag] = manifest.dockerImage.split(':');
306
+ const [tagError, tagResponse] = await safe(superagent.get(`https://hub.docker.com/v2/repositories/${repo}/tags/${tag}`).ok(() => true));
307
+ if (tagError || tagResponse.statusCode !== 200) return exit(`Failed to find docker image in dockerhub. check https://hub.docker.com/r/${repo}/tags : ${tagError || requestError(tagResponse)}`);
308
+
294
309
  // ensure the app is known on the appstore side
295
310
  const baseDir = path.dirname(manifestFilePath);
296
311
 
@@ -350,9 +365,32 @@ async function approve(localOptions, cmd) {
350
365
 
351
366
  console.log(`Approving ${appstoreId}@${version}`);
352
367
 
368
+ let defaultBranch, latestTag;
369
+ if (options.gitPush) {
370
+ // Git repo pre-flight checks: checking if latest tag matches latest commit
371
+ defaultBranch = safe.child_process.execSync(`git symbolic-ref refs/remotes/origin/HEAD | sed 's@^refs/remotes/origin/@@'`, { encoding: 'utf8' });
372
+ if (safe.error) return exit(`Failed to get default branch: ${safe.error.message}`);
373
+
374
+ const defaultBranchSha = safe.child_process.execSync(`git rev-parse ${defaultBranch}`, { encoding: 'utf8' });
375
+ if (safe.error) return exit(`Failed to get head of ${defaultBranch}: ${safe.error.message}`);
376
+
377
+ latestTag = safe.child_process.execSync('git describe --tags --abbrev=0', { encoding: 'utf8' });
378
+ if (safe.error) return exit(`Failed to get latest tag: ${safe.error.message}`);
379
+
380
+ const latestTagSha = safe.child_process.execSync(`git rev-list -n 1 ${latestTag}`, { encoding: 'utf8' });
381
+ if (safe.error) return exit(`Failed to get head of ${defaultBranch}: ${safe.error.message}`);
382
+
383
+ if (defaultBranchSha !== latestTagSha) return exit(`Latest tag ${latestTag} does not match HEAD of ${defaultBranch}`);
384
+ }
385
+
353
386
  const response = await createRequest('POST', `/api/v1/developers/apps/${appstoreId}/versions/${version}/approve`, options);
354
387
  if (response.statusCode !== 200) return exit(`Failed to approve version: ${requestError(response)}`);
355
388
 
389
+ if (options.gitPush) {
390
+ safe.child_process.execSync(`git push --atomic origin ${defaultBranch} ${latestTag}`, { encoding: 'utf8' });
391
+ if (safe.error) return exit(`Failed to get last release tag: ${safe.error.message}`);
392
+ }
393
+
356
394
  console.log('Approved.');
357
395
  console.log('');
358
396
 
@@ -366,14 +404,11 @@ async function approve(localOptions, cmd) {
366
404
  console.log('');
367
405
  }
368
406
 
369
- async function tag(version) {
370
- const basename = `${path.basename(process.cwd())}`;
371
- if (!basename.endsWith('-app')) return exit('Does not look like a app repo. Has to end with -app');
372
-
373
- if (!semver.valid(version)) return exit(`${version} is not a valid semver`);
374
-
375
- const latestTag = safe.child_process.execSync('git describe --tags --abbrev=0', { encoding: 'utf8' });
376
- if (safe.error) return exit(`Failed to get last release tag: ${safe.error.message}`);
407
+ // https://docs.nodebb.org/api/read/
408
+ // https://docs.nodebb.org/api/write/
409
+ async function notify() {
410
+ if (!process.env.NODEBB_API_TOKEN) return exit('NODEBB_API_TOKEN env var has to be set');
411
+ const apiToken = process.env.NODEBB_API_TOKEN;
377
412
 
378
413
  const manifestFilePath = locateManifest();
379
414
  if (!manifestFilePath) return exit('Could not locate CloudronManifest.json');
@@ -382,48 +417,47 @@ async function tag(version) {
382
417
  if (result.error) return exit(new Error(`Invalid CloudronManifest.json: ${result.error.message}`));
383
418
  const { manifest } = result;
384
419
 
385
- const latestVersion = latestTag.match(/v(.*)/)[1];
420
+ let postContent = null;
421
+ if (manifest.changelog.slice(0, 7) === 'file://') {
422
+ const baseDir = path.dirname(manifestFilePath);
423
+ let changelogPath = manifest.changelog.slice(7);
424
+ changelogPath = path.isAbsolute(changelogPath) ? changelogPath : path.join(baseDir, changelogPath);
425
+ const changelog = parseChangelog(changelogPath, manifest.version);
426
+ if (!changelog) return exit('Bad changelog format or missing changelog for this version');
427
+ postContent = `[${manifest.version}]\n${changelog}\n`;
428
+ } else {
429
+ postContent = `[${manifest.version}]\n${manifest.changelog}\n`;
430
+ }
386
431
 
387
- if (semver.lte(version, latestVersion)) return exit(`${version} is less than or equal to last repo tag ${latestVersion}`);
388
- if (semver.inc(latestVersion, 'major') !== version
389
- && semver.inc(latestVersion, 'minor') !== version
390
- && semver.inc(latestVersion, 'patch') !== version) {
391
- return exit(`${version} is not the next major/minor/patch of last published version ${latestVersion}`);
432
+ if (!manifest.forumUrl) return exit(new Error('CloudronManifest.json does not have a forumUrl'));
433
+ const categoryMatch = manifest.forumUrl.match(/category\/(.*)\//);
434
+ if (!categoryMatch) return exit('Unable to detect category id');
435
+ const categoryId = categoryMatch[1];
436
+
437
+ const categoryResponse = await superagent.get(`https://forum.cloudron.io/api/v3/categories/${categoryId}/topics`).set('Authorization', `Bearer ${apiToken}`).ok(() => true);
438
+ if (categoryResponse.statusCode !== 200) return exit(`Unable to get topics of category: ${requestError(categoryResponse)}`);
439
+ const topic = categoryResponse.body.response.topics.find(t => t.title.includes('Package Updates'));
440
+ if (!topic) return exit('Could not find the Package Update topic');
441
+ const topicId = topic.tid;
442
+
443
+ const pageCountResponse = await superagent.get(`https://forum.cloudron.io/api/topic/pagination/${topicId}`).set('Authorization', `Bearer ${apiToken}`).ok(() => true);
444
+ if (pageCountResponse.statusCode !== 200) return exit(`Unable to get page count of topic: ${requestError(pageCountResponse)}`);
445
+ const pageCount = pageCountResponse.body.pagination.pageCount;
446
+
447
+ for (let page = 1; page <= pageCount; page++) {
448
+ const pageResponse = await superagent.get(`https://forum.cloudron.io/api/topic/${topicId}?page=${page}`).set('Authorization', `Bearer ${apiToken}`).ok(() => true);
449
+ if (pageResponse.statusCode !== 200) return exit(`Unable to get topics of category: ${requestError(pageResponse)}`);
450
+ for (const post of pageResponse.body.posts) { // post.content is html!
451
+ if (post.content.includes(`[${manifest.version}]`)) return exit(`Version ${manifest.version} is already on the forum.\n${post.content}`);
452
+ }
392
453
  }
393
454
 
394
- const latestRenovateCommit = safe.child_process.execSync('git log -n 1 --committer=renovatebot@cloudron.io --pretty="format:%h,%aI,%s"', { encoding: 'utf8' });
395
- if (!latestRenovateCommit) return exit('Could not find a commit from renovate bot');
396
-
397
- const [ , , commitMessage ] = latestRenovateCommit.split(',');
398
- const repoDir = path.dirname(manifestFilePath);
399
- const upstreamVersion = commitMessage.match(/update dependency .* to (.*)/)[1];
400
-
401
- console.log(`Enter the changelog for ${upstreamVersion}: (press ctrl+D to finish)`);
402
- const rawChangelog = fs.readFileSync(0, 'utf-8');
403
- const mdChangelog = rawChangelog.split('\n').map(line => {
404
- line = line.trim();
405
- line = line.replace(/[\u{0080}-\u{FFFF}]/gu, ''); // only ascii
406
- line = line.replace(/^\* /, ''); // replace any "* " in the front
407
- return line ? `* ${line}` : '';
408
- }).join('\n');
409
- const newChangelog = `\n[${version}]\n* Update ${manifest.title} to ${upstreamVersion}\n${mdChangelog}\n`;
410
- const changelogFile = `${repoDir}/${manifest.changelog.replace('file://', '')}`; // sometimes CHANGELOG, sometimes CHANGELOG.md
411
- fs.appendFileSync(changelogFile, newChangelog);
412
-
413
- manifest.version = version;
414
- manifest.upstreamVersion = upstreamVersion;
415
- fs.writeFileSync('CloudronManifest.json', JSON.stringify(manifest, null, 2));
416
-
417
- // git branch --show-current does not work in CI :/
418
- const mainOrMaster = safe.child_process.execSync('git branch -r --list origin/master origin/main', { encoding: 'utf8' });
419
- if (safe.error) return exit('Could not determine branch name');
420
- const branch = mainOrMaster.includes('master') ? 'master' : 'main';
421
-
422
- execSync(`git commit -a -m 'Version ${version}'`, { encoding: 'utf8' });
423
- execSync(`git tag v${version} -a -m 'Version ${version}'`, { encoding: 'utf8' });
424
- console.log(`git push --atomic origin ${branch} v${version}`);
425
- execSync(`git push --atomic origin HEAD:${branch} v${version}`, { encoding: 'utf8' }); // push this tag only. in CI, we might have a git cache
426
- if (safe.error) return exit(`Failed to push tag v${version} and branch ${branch}: ${safe.error.message}`, { encoding: 'utf8' });
427
-
428
- console.log(`Created tag v${version} and pushed branch ${branch}`);
455
+ // https://docs.nodebb.org/api/write/#tag/topics/paths/~1topics~1%7Btid%7D/post
456
+ const postData = {
457
+ content: postContent,
458
+ toPid: 0 // which post is this post a reply to
459
+ };
460
+ const postResponse = await superagent.post(`https://forum.cloudron.io/api/v3/topics/${topicId}`).set('Authorization', `Bearer ${apiToken}`).send(postData).ok(() => true);
461
+ if (postResponse.statusCode !== 200) return exit(`Unable to create changelog post: ${requestError(postResponse)}`);
462
+ console.log('Posted to forum');
429
463
  }
@@ -331,6 +331,7 @@ async function build(localOptions, cmd) {
331
331
  config.setAppConfig(sourceDir, appConfig);
332
332
  }
333
333
 
334
+ appConfig.gitCommit = execSync('git rev-parse HEAD', { encoding: 'utf8' }).trim(); // when the build gets saved, save the gitCommit also
334
335
  if (buildServiceConfig.type === 'local') {
335
336
  await buildLocal(manifest, sourceDir, appConfig, options);
336
337
  } else if (buildServiceConfig.type === 'remote' && buildServiceConfig.url) {
@@ -363,14 +364,21 @@ async function push(localOptions, cmd) {
363
364
  const options = cmd.optsWithGlobals();
364
365
 
365
366
  if (!options.id) return exit('buildId is required');
366
- if (!options.repository) return exit('repository is required');
367
- if (!options.tag) return exit('tag is required');
367
+ let repository, tag;
368
+ if (options.image) {
369
+ [repository, tag] = options.image.split(':');
370
+ } else {
371
+ if (!options.repository) return exit('repository is required');
372
+ if (!options.tag) return exit('tag is required');
373
+ repository = options.repository;
374
+ tag = options.tag;
375
+ }
368
376
 
369
377
  const buildServiceConfig = getBuildServiceConfig(options);
370
378
 
371
379
  const response = await superagent.post(`${buildServiceConfig.url}/api/v1/builds/${options.id}/push`)
372
380
  .query({ accessToken: buildServiceConfig.token })
373
- .send({ dockerImageRepo: options.repository, dockerImageTag: options.tag })
381
+ .send({ dockerImageRepo: repository, dockerImageTag: tag })
374
382
  .ok(() => true);
375
383
  if (response.statusCode !== 201) return exit(`Failed to push: ${requestError(response)}`);
376
384