cloudron 7.0.5 → 7.1.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
@@ -49,3 +49,48 @@
49
49
  * Various changes to accomodate Cloudron 9 release API changes
50
50
  * add --siteId to cloudron backup to specify site id explicitly
51
51
 
52
+ [7.0.0]
53
+ * Port from CommonJS to ESM
54
+ * Add initial OIDC login support
55
+ * Add `cloudron versions` subcommand
56
+ * `versions`: init with publisher info, add `--version` flag, add publishes by default
57
+ * Add `cloudron build info` subcommand
58
+ * Add `cloudron build clear` subcommand
59
+ * Add `cloudron build logout` subcommand
60
+ * Rework the build command
61
+ * Support source tarball upload with install and update subcommands
62
+ * Remove calling the /repair route in update subcommand
63
+ * Use POST /api/v1/apps for installations
64
+ * Remove unused superagent module
65
+ * Do not print stack if app isn't found
66
+
67
+ [7.0.1]
68
+ * Fix `--no-wait` flag
69
+
70
+ [7.0.2]
71
+ * Rename `--set-repository` to `--repository`
72
+ * Fix broken build command
73
+
74
+ [7.0.3]
75
+ * Implement `versionsUrl` for testing
76
+ * Fix app detection to be based on appId
77
+ * Save appId on update
78
+
79
+ [7.0.4]
80
+ * Rename `build clear` to `build reset`
81
+ * `versions`: add fields in init
82
+ * Set minBoxVersion
83
+ * Print the build type
84
+
85
+ [7.0.5]
86
+ * Gzip the source tarball for upload
87
+ * Print where the image is coming from
88
+
89
+ [7.0.6]
90
+ * Remove duplicate build info output
91
+ * Do not error on EROFS
92
+
93
+ [7.1.0]
94
+ * Add `cloudron sync push` and `cloudron sync pull` subcommands
95
+ * Merge all binaries into single `cloudron` command
96
+
package/bin/cloudron CHANGED
@@ -1,10 +1,13 @@
1
1
  #!/usr/bin/env node
2
2
 
3
3
  import actions from '../src/actions.js';
4
+ import appstoreActions from '../src/appstore-actions.js';
4
5
  import backupTools from '../src/backup-tools.js';
6
+ import buildActions from '../src/build-actions.js';
5
7
  import completion from '../src/completion.js';
6
8
  import { Command } from 'commander';
7
9
  import semver from 'semver';
10
+ import versionsActions from '../src/versions-actions.js';
8
11
  import pkg from '../package.json' with { type: 'json' };
9
12
 
10
13
  // ensure node version
@@ -23,13 +26,116 @@ program.option('--server <server>', 'Cloudron domain')
23
26
  .option('--accept-selfsigned', 'Accept self signed SSL certificate')
24
27
  .option('--no-wait', 'Do not wait for the operation to finish');
25
28
 
26
- // these are separate binaries since global options are not applicable
27
- program.command('appstore', 'Cloudron appstore commands');
28
- program.command('build', 'Cloudron build commands');
29
- program.command('versions', 'Cloudron versions commands');
29
+ const appstoreCommand = program.command('appstore').description('Commands for publishing to the Appstore')
30
+ .option('--appstore-token <token>', 'AppStore token');
31
+
32
+ appstoreCommand.command('login')
33
+ .description('Login to the appstore')
34
+ .option('-e, --email <email>', 'Email address')
35
+ .option('-p, --password <password>', 'Password (unsafe)')
36
+ .action(appstoreActions.login);
37
+
38
+ appstoreCommand.command('logout')
39
+ .description('Logout from the appstore')
40
+ .action(appstoreActions.logout);
41
+
42
+ appstoreCommand.command('info')
43
+ .description('List info of published app')
44
+ .option('--appstore-id <appid@version>', 'Appstore id and version')
45
+ .action(appstoreActions.info);
46
+
47
+ appstoreCommand.command('approve')
48
+ .description('Approve a submitted app version')
49
+ .option('--appstore-id <appid@version>', 'Appstore id and version')
50
+ .option('--no-git-push', 'Do not attempt to push to git repo')
51
+ .action(appstoreActions.approve);
52
+
53
+ appstoreCommand.command('notify')
54
+ .description('Notify forum about successful app submission')
55
+ .action(appstoreActions.notify);
56
+
57
+ appstoreCommand.command('revoke')
58
+ .description('Revoke a published app version')
59
+ .option('--appstore-id <appid@version>', 'Appstore id and version')
60
+ .action(appstoreActions.revoke);
61
+
62
+ appstoreCommand.command('submit')
63
+ .description('Submit app to the store for review')
64
+ .action(appstoreActions.submit);
65
+
66
+ appstoreCommand.command('upload')
67
+ .description('Upload app to the store for testing')
68
+ .option('-i, --image <image>', 'Docker image')
69
+ .option('-f, --force', 'Update existing version')
70
+ .action(appstoreActions.upload);
71
+
72
+ appstoreCommand.command('verify-manifest')
73
+ .description('Verify if manifest is ready to be upload/published')
74
+ .action(appstoreActions.verifyManifest);
75
+
76
+ appstoreCommand.command('versions')
77
+ .alias('list')
78
+ .description('List published versions')
79
+ .option('--appstore-id <id>', 'Appstore id')
80
+ .option('--raw', 'Dump versions as json')
81
+ .action(appstoreActions.listVersions);
82
+
83
+ const backupCommand = program.command('backup').description('App backup commands');
84
+
85
+ function collectBuildArgs(value, collected) {
86
+ collected.push(value);
87
+ return collected;
88
+ }
30
89
 
31
- const backupCommand = program.command('backup')
32
- .description('App backup commands');
90
+ const buildCommand = program.command('build').description('Build using the build service')
91
+ .option('--build-service-url <url>', 'Build service URL')
92
+ .option('--build-service-token <token>', 'Build service token');
93
+
94
+ buildCommand.command('build', { isDefault: true })
95
+ .description('Build an app')
96
+ .option('--build-arg <namevalue>', 'Build arg passed to docker. Can be used multiple times', collectBuildArgs, [])
97
+ .option('-f, --file <dockerfile>', 'Name of the Dockerfile')
98
+ .option('--repository [repository url]', 'Change the repository. This url is stored for future builds for this project. e.g registry/username/projectname')
99
+ .option('--no-cache', 'Do not use cache')
100
+ .option('--no-push', 'Do not push built image to registry')
101
+ .option('--raw', 'Raw output build log')
102
+ .option('--tag <docker image tag>', 'Docker image tag. Note that this does not include the repository name')
103
+ .action(buildActions.build);
104
+
105
+ buildCommand.command('reset')
106
+ .description('Reset build configuration for this directory')
107
+ .action(buildActions.reset);
108
+
109
+ buildCommand.command('info')
110
+ .description('Print build information')
111
+ .action(buildActions.info);
112
+
113
+ buildCommand.command('login')
114
+ .description('Login to the build service')
115
+ .action(buildActions.login);
116
+
117
+ buildCommand.command('logout')
118
+ .description('Logout from the build service')
119
+ .action(buildActions.logout);
120
+
121
+ buildCommand.command('logs')
122
+ .description('Build logs. This works only when using the Build Service')
123
+ .option('--id <buildid>', 'Build ID')
124
+ .option('--raw', 'Raw output build log')
125
+ .action(buildActions.logs);
126
+
127
+ buildCommand.command('push')
128
+ .description('Push the build image')
129
+ .option('--id <buildid>', 'Build ID')
130
+ .option('--repository [repository url]', 'Set repository to push to. e.g registry/username/projectname')
131
+ .option('--tag <docker image tag>', 'Docker image tag. Note that this does not include the repository name')
132
+ .option('--image <docker image>', 'Docker image of the form registry/repo:tag')
133
+ .action(buildActions.push);
134
+
135
+ buildCommand.command('status')
136
+ .description('Build status. This works only when using the Build Service')
137
+ .option('--id <buildid>', 'Build ID')
138
+ .action(buildActions.status);
33
139
 
34
140
  backupCommand.command('create')
35
141
  .description('Create new app backup')
@@ -223,6 +329,23 @@ program.command('push <local> <remote>')
223
329
  console.log();
224
330
  });
225
331
 
332
+ const sync = program.command('sync')
333
+ .description('Sync files between local and remote (only transfers changed files)');
334
+
335
+ sync.command('push <local> <remote>')
336
+ .description('Upload changed files to remote directory. A trailing slash on <local> syncs its contents; without it, the directory itself is placed inside <remote>.')
337
+ .option('--app <id/location>', 'App id or location')
338
+ .option('--delete', 'Delete remote files not present locally')
339
+ .option('--force', 'Remove files blocking directory creation at destination')
340
+ .action(actions.syncPush);
341
+
342
+ sync.command('pull <remote> <local>')
343
+ .description('Download changed files from remote directory. A trailing slash on <remote> syncs its contents; without it, the directory itself is placed inside <local>.')
344
+ .option('--app <id/location>', 'App id or location')
345
+ .option('--delete', 'Delete local files not present on remote')
346
+ .option('--force', 'Remove files blocking directory creation at destination')
347
+ .action(actions.syncPull);
348
+
226
349
  program.command('repair')
227
350
  .description('Repair an installed application (re-configure)')
228
351
  .option('--app <id/location>', 'App id or location')
@@ -279,4 +402,29 @@ program.command('update')
279
402
  .option('--no-force', 'Match appstore id and manifest id before updating', true)
280
403
  .action(actions.update);
281
404
 
405
+ const versionsCommand = program.command('versions').description('Commands for publishing community packages');
406
+
407
+ versionsCommand.command('add')
408
+ .description('Add the current build to version file')
409
+ .option('--state <state>', 'Publish state (published or testing)')
410
+ .action(versionsActions.addOrUpdate);
411
+
412
+ versionsCommand.command('init')
413
+ .description('Create versions file')
414
+ .action(versionsActions.init);
415
+
416
+ versionsCommand.command('list')
417
+ .description('List existing versions')
418
+ .action(versionsActions.list);
419
+
420
+ versionsCommand.command('revoke')
421
+ .description('Revoke the latest version')
422
+ .action(versionsActions.revoke);
423
+
424
+ versionsCommand.command('update')
425
+ .description('Update existing version')
426
+ .option('--version <version>', 'Version to update')
427
+ .option('--state <state>', 'Publish state (published or testing)')
428
+ .action(versionsActions.addOrUpdate);
429
+
282
430
  program.parse(process.argv);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cloudron",
3
- "version": "7.0.5",
3
+ "version": "7.1.0",
4
4
  "license": "MIT",
5
5
  "description": "Cloudron Commandline Tool",
6
6
  "type": "module",
@@ -17,26 +17,26 @@
17
17
  },
18
18
  "author": "Cloudron Developers <support@cloudron.io>",
19
19
  "dependencies": {
20
- "@cloudron/manifest-format": "^6.0.2",
20
+ "@cloudron/manifest-format": "^6.1.0",
21
21
  "@cloudron/safetydance": "^3.0.1",
22
22
  "@cloudron/superagent": "^2.1.1",
23
23
  "commander": "^14.0.3",
24
24
  "debug": "^4.4.3",
25
25
  "easy-table": "^1.2.0",
26
- "ejs": "^4.0.1",
26
+ "ejs": "^5.0.1",
27
27
  "eventsource": "^4.1.0",
28
28
  "micromatch": "^4.0.8",
29
29
  "open": "^11.0.0",
30
30
  "semver": "^7.7.4",
31
- "tar-fs": "^3.1.1"
31
+ "tar-fs": "^3.1.2"
32
32
  },
33
33
  "engines": {
34
34
  "node": ">= 20.11.0"
35
35
  },
36
36
  "devDependencies": {
37
37
  "@eslint/js": "^10.0.1",
38
- "eslint": "^10.0.0",
39
- "globals": "^17.3.0",
38
+ "eslint": "^10.0.3",
39
+ "globals": "^17.4.0",
40
40
  "mocha": "^11.7.5"
41
41
  }
42
42
  }
package/src/actions.js CHANGED
@@ -13,6 +13,7 @@ import * as readline from './readline.js';
13
13
  import safe from '@cloudron/safetydance';
14
14
  import { spawn } from 'child_process';
15
15
  import semver from 'semver';
16
+ import { PassThrough } from 'stream';
16
17
  import stream from 'stream/promises';
17
18
  import superagent from '@cloudron/superagent';
18
19
  import Table from 'easy-table';
@@ -106,10 +107,14 @@ async function stopActiveTask(app, options) {
106
107
  function saveCwdAppId(appId, manifestFilePath) {
107
108
  if (!manifestFilePath) return;
108
109
 
109
- const sourceDir = path.dirname(manifestFilePath);
110
- const cwdConfig = config.getCwdConfig(sourceDir);
111
- cwdConfig.appId = appId;
112
- config.setCwdConfig(sourceDir, cwdConfig);
110
+ try {
111
+ const sourceDir = path.dirname(manifestFilePath);
112
+ const cwdConfig = config.getCwdConfig(sourceDir);
113
+ cwdConfig.appId = appId;
114
+ config.setCwdConfig(sourceDir, cwdConfig);
115
+ } catch (e) { // will happen when run on EROFS (set HOME to avoid)
116
+ console.log(`Warning: Could not save app id to config: ${e.message} . Try setting $HOME`);
117
+ }
113
118
  }
114
119
 
115
120
  // appId may be the appId or the location
@@ -1422,6 +1427,187 @@ function demuxStream(inputStream, stdout, stderr) {
1422
1427
  });
1423
1428
  }
1424
1429
 
1430
+ async function createExecSession(appId, args, { tty, lang }, options) {
1431
+ const request = createRequest('POST', `/api/v1/apps/${appId}/exec`, options);
1432
+ const response = await request.send({ cmd: args, tty, lang });
1433
+ if (response.status !== 200) exit(`Failed to create exec: ${requestError(response)}`);
1434
+ return response.body.id;
1435
+ }
1436
+
1437
+ async function getExecSession(appId, execId, options) {
1438
+ const response = await createRequest('GET', `/api/v1/apps/${appId}/exec/${execId}`, options);
1439
+ if (response.status !== 200) exit(`Failed to get exec session: ${requestError(response)}`);
1440
+ return response.body;
1441
+ }
1442
+
1443
+ function runExecSession(appId, execId, { stdin, stdout, stderr, tty }, options) {
1444
+ return new Promise((resolve, reject) => {
1445
+ const { adminFqdn, token: reqToken, rejectUnauthorized } = requestOptions(options);
1446
+
1447
+ const searchParams = new URLSearchParams({
1448
+ rows: stdout.rows || 24,
1449
+ columns: stdout.columns || 80,
1450
+ access_token: reqToken,
1451
+ tty
1452
+ });
1453
+
1454
+ const req = https.request({
1455
+ hostname: adminFqdn,
1456
+ path: `/api/v1/apps/${appId}/exec/${execId}/start?${searchParams.toString()}`,
1457
+ method: 'GET',
1458
+ headers: {
1459
+ 'Connection': 'Upgrade',
1460
+ 'Upgrade': 'tcp'
1461
+ },
1462
+ rejectUnauthorized
1463
+ }, function handler(res) {
1464
+ if (res.statusCode === 403) return reject(new Error('Unauthorized.'));
1465
+ reject(new Error(`Could not upgrade connection to tcp. http status: ${res.statusCode}`));
1466
+ });
1467
+
1468
+ req.on('upgrade', function (resThatShouldNotBeUsed, socket) {
1469
+ socket.on('error', reject);
1470
+
1471
+ socket.setNoDelay(true);
1472
+ socket.setKeepAlive(true);
1473
+
1474
+ if (tty) {
1475
+ stdin.setRawMode(true);
1476
+ stdin.pipe(socket, { end: false });
1477
+ socket.pipe(stdout);
1478
+ socket.on('end', () => resolve());
1479
+ } else {
1480
+ if (typeof stdin === 'function') stdin = stdin();
1481
+ let socketEnded = false;
1482
+
1483
+ stdin.on('data', function (d) {
1484
+ if (socketEnded) return;
1485
+ var buf = Buffer.alloc(4);
1486
+ buf.writeUInt32BE(d.length, 0);
1487
+ socket.write(buf);
1488
+ socket.write(d);
1489
+ });
1490
+ stdin.on('end', function () {
1491
+ if (socketEnded) return;
1492
+ var buf = Buffer.alloc(4);
1493
+ buf.writeUInt32BE(0, 0);
1494
+ socket.write(buf);
1495
+ });
1496
+
1497
+ demuxStream(socket, stdout, stderr);
1498
+ socket.on('end', function () {
1499
+ socketEnded = true;
1500
+ if (typeof stdin.end === 'function') stdin.end();
1501
+ socket.end();
1502
+
1503
+ if (stdout !== process.stdout && typeof stdout.end === 'function') {
1504
+ stdout.on('close', () => resolve());
1505
+ stdout.end();
1506
+ stdout.resume(); // drain readable side of Duplex/Transform streams so autoDestroy emits 'close'
1507
+ } else {
1508
+ setImmediate(resolve);
1509
+ }
1510
+ });
1511
+ }
1512
+ });
1513
+
1514
+ req.on('error', reject);
1515
+ req.end();
1516
+ });
1517
+ }
1518
+
1519
+ async function runExecCommand(appId, args, options) {
1520
+ const stdoutChunks = [];
1521
+ const stderrChunks = [];
1522
+
1523
+ const stdoutStream = new PassThrough();
1524
+ const stderrStream = new PassThrough();
1525
+
1526
+ stdoutStream.on('data', (chunk) => stdoutChunks.push(chunk));
1527
+ stderrStream.on('data', (chunk) => stderrChunks.push(chunk));
1528
+
1529
+ const execId = await createExecSession(appId, args, { tty: false }, options);
1530
+
1531
+ await runExecSession(appId, execId, {
1532
+ stdin: () => new PassThrough(),
1533
+ stdout: stdoutStream,
1534
+ stderr: stderrStream,
1535
+ tty: false
1536
+ }, options);
1537
+
1538
+ const session = await getExecSession(appId, execId, options);
1539
+
1540
+ return {
1541
+ stdout: Buffer.concat(stdoutChunks),
1542
+ stderr: Buffer.concat(stderrChunks),
1543
+ exitCode: session.exitCode
1544
+ };
1545
+ }
1546
+
1547
+ function getLocalFileList(localPath) {
1548
+ const resolved = path.resolve(localPath);
1549
+ const files = new Map();
1550
+
1551
+ if (fs.lstatSync(resolved).isFile()) {
1552
+ const stat = fs.statSync(resolved);
1553
+ files.set(path.basename(resolved), { size: stat.size, mtimeSec: Math.floor(stat.mtimeMs / 1000) });
1554
+ return { baseDir: path.dirname(resolved), files };
1555
+ }
1556
+
1557
+ const entries = fs.readdirSync(resolved, { withFileTypes: true, recursive: true });
1558
+ for (const entry of entries) {
1559
+ if (!entry.isFile()) continue;
1560
+ const relativePath = path.relative(resolved, path.join(entry.parentPath || entry.path, entry.name));
1561
+ const stat = fs.statSync(path.join(resolved, relativePath));
1562
+ files.set(relativePath, { size: stat.size, mtimeSec: Math.floor(stat.mtimeMs / 1000) });
1563
+ }
1564
+
1565
+ return { baseDir: resolved, files };
1566
+ }
1567
+
1568
+ async function getRemoteFileList(appId, dir, options) {
1569
+ const files = new Map();
1570
+
1571
+ let result;
1572
+ try {
1573
+ result = await runExecCommand(appId, ['find', dir, '-type', 'f', '-printf', '%s\\t%T@\\t%P\\n'], options);
1574
+ } catch (e) {
1575
+ console.error(`Warning: could not list remote files (${e.message}), syncing all files`);
1576
+ return files;
1577
+ }
1578
+
1579
+ if (result.exitCode !== 0) return files;
1580
+
1581
+ const output = result.stdout.toString().trim();
1582
+ if (!output) return files;
1583
+
1584
+ for (const line of output.split('\n')) {
1585
+ const [size, mtime, ...pathParts] = line.split('\t');
1586
+ const relativePath = pathParts.join('\t');
1587
+ files.set(relativePath, { size: parseInt(size, 10), mtimeSec: Math.floor(parseFloat(mtime)) });
1588
+ }
1589
+
1590
+ return files;
1591
+ }
1592
+
1593
+ function diffFileLists(srcList, destList) {
1594
+ const changed = [];
1595
+ const extraneous = [];
1596
+
1597
+ for (const [filePath, src] of srcList) {
1598
+ const dest = destList.get(filePath);
1599
+ if (!dest || dest.size !== src.size || src.mtimeSec > dest.mtimeSec) {
1600
+ changed.push(filePath);
1601
+ }
1602
+ }
1603
+
1604
+ for (const filePath of destList.keys()) {
1605
+ if (!srcList.has(filePath)) extraneous.push(filePath);
1606
+ }
1607
+
1608
+ return { changed, extraneous };
1609
+ }
1610
+
1425
1611
  // cloudron exec - must work interactively. needs tty.
1426
1612
  // cloudron exec -- ls asdf - must work
1427
1613
  // cloudron exec -- cat /home/cloudron/start.sh > /tmp/start.sh - must work (test with binary files). should disable tty
@@ -1429,12 +1615,9 @@ function demuxStream(inputStream, stdout, stderr) {
1429
1615
  // cat ~/tmp/fantome.tar.gz | cloudron exec -- bash -c "tar xvf - -C /tmp" - must show an error
1430
1616
  // cat ~/tmp/fantome.tar.gz | cloudron exec -- bash -c "tar zxf - -C /tmp" - must extrack ok
1431
1617
  async function execApp(args, localOptions, cmd) {
1432
- let stdin = localOptions._stdin || process.stdin; // hack for 'push', 'pull' to reuse this function
1433
- const stdout = localOptions._stdout || process.stdout; // hack for 'push', 'pull' to reuse this function
1434
-
1435
1618
  const options = cmd.optsWithGlobals();
1436
1619
  let tty = !!options.tty;
1437
- let lang; // used as body param later
1620
+ let lang;
1438
1621
 
1439
1622
  const [error, app] = await safe(getApp(options));
1440
1623
  if (error) return exit(error);
@@ -1443,150 +1626,288 @@ async function execApp(args, localOptions, cmd) {
1443
1626
  if (app.installationState !== 'installed') exit('App is not yet running. Try again later.');
1444
1627
 
1445
1628
  if (args.length === 0) {
1446
- args = [ '/bin/bash' ];
1447
- tty = true; // override
1629
+ args = ['/bin/bash'];
1630
+ tty = true;
1448
1631
  lang = 'C.UTF-8';
1449
1632
  }
1450
1633
 
1451
- if (tty && !stdin.isTTY) exit('stdin is not tty');
1452
-
1453
- const request = createRequest('POST', `/api/v1/apps/${app.id}/exec`, options);
1454
- const response = await request.send({ cmd: args, tty, lang });
1455
- if (response.status !== 200) return exit(`Failed to create exec: ${requestError(response)}`);
1456
- const execId = response.body.id;
1457
-
1458
- async function exitWithCode() {
1459
- const response2 = await createRequest('GET', `/api/v1/apps/${app.id}/exec/${execId}`, options);
1460
- if (response2.status !== 200) return exit(`Failed to get exec code: ${requestError(response2)}`);
1461
-
1462
- process.exit(response2.body.exitCode);
1463
- }
1634
+ if (tty && !process.stdin.isTTY) exit('stdin is not tty');
1464
1635
 
1465
- const { adminFqdn, token: reqToken, rejectUnauthorized } = requestOptions(cmd.optsWithGlobals());
1636
+ const execId = await createExecSession(app.id, args, { tty, lang }, options);
1466
1637
 
1467
- const searchParams = new URLSearchParams({
1468
- rows: stdout.rows || 24,
1469
- columns: stdout.columns || 80,
1470
- access_token: reqToken,
1638
+ await runExecSession(app.id, execId, {
1639
+ stdin: process.stdin,
1640
+ stdout: process.stdout,
1641
+ stderr: process.stderr,
1471
1642
  tty
1472
- });
1473
-
1474
- const req = https.request({
1475
- hostname: adminFqdn,
1476
- path: `/api/v1/apps/${app.id}/exec/${execId}/start?${searchParams.toString()}`,
1477
- method: 'GET',
1478
- headers: {
1479
- 'Connection': 'Upgrade',
1480
- 'Upgrade': 'tcp'
1481
- },
1482
- rejectUnauthorized
1483
- }, function handler(res) {
1484
- if (res.statusCode === 403) exit('Unauthorized.'); // only admin or only owner (for docker addon)
1485
-
1486
- exit('Could not upgrade connection to tcp. http status:', res.statusCode);
1487
- });
1488
-
1489
- req.on('upgrade', function (resThatShouldNotBeUsed, socket /*, upgradeHead*/) {
1490
- // do not use res here! it's all socket from here on
1491
- socket.on('error', exit);
1492
-
1493
- socket.setNoDelay(true);
1494
- socket.setKeepAlive(true);
1495
-
1496
- if (tty) {
1497
- stdin.setRawMode(true);
1498
- stdin.pipe(socket, { end: false }); // the remote will close the connection
1499
- socket.pipe(stdout); // in tty mode, stdout/stderr is merged
1500
- socket.on('end', exitWithCode); // server closed the socket
1501
- } else { // create stdin process on demand
1502
- if (typeof stdin === 'function') stdin = stdin();
1503
-
1504
- stdin.on('data', function (d) {
1505
- var buf = Buffer.alloc(4);
1506
- buf.writeUInt32BE(d.length, 0 /* offset */);
1507
- socket.write(buf);
1508
- socket.write(d);
1509
- });
1510
- stdin.on('end', function () {
1511
- var buf = Buffer.alloc(4);
1512
- buf.writeUInt32BE(0, 0 /* offset */);
1513
- socket.write(buf);
1514
- });
1515
-
1516
- stdout.on('close', exitWithCode); // this is only emitted when stdout is a file and not the terminal
1643
+ }, options);
1517
1644
 
1518
- demuxStream(socket, stdout, process.stderr); // can get separate streams in non-tty mode
1519
- socket.on('end', function () { // server closed the socket
1520
- if (typeof stdin.end === 'function') stdin.end(); // required for this process to 'exit' cleanly. do not call exit() because writes may not have finished . the type check is required for when stdin: 'ignore' in execSync, not sure why
1521
- if (stdout !== process.stdout) stdout.end(); // for push stream
1645
+ const execSession = await getExecSession(app.id, execId, options);
1646
+ process.exit(execSession.exitCode);
1647
+ }
1522
1648
 
1523
- socket.end();
1649
+ async function pushApp(localDir, remote, localOptions, cmd) {
1650
+ const options = cmd.optsWithGlobals();
1524
1651
 
1525
- // process._getActiveHandles(); process._getActiveRequests();
1526
- if (stdout === process.stdout) setImmediate(exitWithCode); // otherwise, we rely on the 'close' event above
1527
- });
1528
- }
1529
- });
1652
+ const [error, app] = await safe(getApp(options));
1653
+ if (error) return exit(error);
1654
+ if (!app) return exit(NO_APP_FOUND_ERROR_STRING);
1530
1655
 
1531
- req.on('error', exit); // could not make a request
1532
- req.end(); // this makes the request
1533
- }
1656
+ if (app.installationState !== 'installed') exit('App is not yet running. Try again later.');
1534
1657
 
1535
- function pushApp(localDir, remote, localOptions, cmd) {
1536
- // deal with paths prefixed with ~
1537
1658
  const local = localDir.replace(/^~(?=$|\/|\\)/, os.homedir());
1538
1659
  const stat = fs.existsSync(path.resolve(local)) ? fs.lstatSync(local) : null;
1539
1660
 
1540
- if (stat && stat.isDirectory()) {
1661
+ let args, stdin;
1662
+
1663
+ if (stat && stat.isDirectory()) {
1541
1664
  // Create a functor for stdin. If no data event handlers are attached, and there are no stream.pipe() destinations, and the stream is
1542
1665
  // switched into flowing mode, then data will be lost. So, we have to start the tarzip only when exec is ready to attach event handlers.
1543
- localOptions._stdin = function () {
1666
+ stdin = function () {
1544
1667
  var tarzip = spawn('tar', ['zcf', '-', '-C', path.dirname(local), path.basename(local)], { stdio: 'pipe' });
1545
1668
  return tarzip.stdout;
1546
1669
  };
1547
-
1548
- execApp(['tar', 'zxvf', '-', '-C', remote], localOptions, cmd);
1670
+ args = ['tar', 'zxvf', '-', '-C', remote];
1549
1671
  } else {
1550
1672
  if (local === '-') {
1551
- localOptions._stdin = process.stdin;
1673
+ stdin = process.stdin;
1552
1674
  } else if (stat) {
1553
- localOptions._stdin = fs.createReadStream(local);
1675
+ stdin = fs.createReadStream(local);
1554
1676
  } else {
1555
1677
  exit('local file ' + local + ' does not exist');
1556
1678
  }
1557
1679
 
1558
- localOptions._stdin.on('error', function (error) { exit('Error pushing', error); });
1680
+ stdin.on('error', function (e) { exit('Error pushing', e); });
1559
1681
 
1560
- if (remote.endsWith('/')) { // dir
1561
- remote = remote + '/' + path.basename(local); // do not use path.join as we want this to be a UNIX path
1682
+ if (remote.endsWith('/')) {
1683
+ remote = remote + '/' + path.basename(local);
1562
1684
  }
1563
1685
 
1564
- execApp(['bash', '-c', `cat - > "${remote}"`], localOptions, cmd);
1686
+ args = ['bash', '-c', `cat - > "${remote}"`];
1565
1687
  }
1688
+
1689
+ const execId = await createExecSession(app.id, args, { tty: false }, options);
1690
+
1691
+ await runExecSession(app.id, execId, {
1692
+ stdin,
1693
+ stdout: process.stdout,
1694
+ stderr: process.stderr,
1695
+ tty: false
1696
+ }, options);
1697
+
1698
+ const execSession = await getExecSession(app.id, execId, options);
1699
+ process.exit(execSession.exitCode);
1566
1700
  }
1567
1701
 
1568
- function pull(remote, local, localOptions, cmd) {
1569
- if (remote.endsWith('/')) { // dir
1570
- var untar = tar.extract(local); // local directory is created if it doesn't exist!
1702
+ async function pull(remote, local, localOptions, cmd) {
1703
+ const options = cmd.optsWithGlobals();
1704
+
1705
+ const [error, app] = await safe(getApp(options));
1706
+ if (error) return exit(error);
1707
+ if (!app) return exit(NO_APP_FOUND_ERROR_STRING);
1708
+
1709
+ if (app.installationState !== 'installed') exit('App is not yet running. Try again later.');
1710
+
1711
+ let args, stdout;
1712
+
1713
+ if (remote.endsWith('/')) {
1714
+ var untar = tar.extract(local);
1571
1715
  var unzip = zlib.createGunzip();
1572
1716
 
1573
1717
  unzip.pipe(untar);
1574
- localOptions._stdout = unzip;
1575
-
1576
- execApp(['tar', 'zcf', '-', '-C', remote, '.'], localOptions, cmd);
1718
+ stdout = unzip;
1719
+ args = ['tar', 'zcf', '-', '-C', remote, '.'];
1577
1720
  } else {
1578
1721
  if (fs.existsSync(local) && fs.lstatSync(local).isDirectory()) {
1579
1722
  local = path.join(local, path.basename(remote));
1580
- localOptions._stdout = fs.createWriteStream(local);
1723
+ stdout = fs.createWriteStream(local);
1581
1724
  } else if (local === '-') {
1582
- localOptions._stdout = process.stdout;
1725
+ stdout = process.stdout;
1583
1726
  } else {
1584
- localOptions._stdout = fs.createWriteStream(local);
1727
+ stdout = fs.createWriteStream(local);
1585
1728
  }
1586
1729
 
1587
- localOptions._stdout.on('error', function (error) { exit('Error pulling', error); });
1730
+ stdout.on('error', function (e) { exit('Error pulling', e); });
1588
1731
 
1589
- execApp(['cat', remote], localOptions, cmd);
1732
+ args = ['cat', remote];
1733
+ }
1734
+
1735
+ const execId = await createExecSession(app.id, args, { tty: false }, options);
1736
+
1737
+ await runExecSession(app.id, execId, {
1738
+ stdin: process.stdin,
1739
+ stdout,
1740
+ stderr: process.stderr,
1741
+ tty: false
1742
+ }, options);
1743
+
1744
+ const execSession = await getExecSession(app.id, execId, options);
1745
+ process.exit(execSession.exitCode);
1746
+ }
1747
+
1748
+ async function syncPush(localDir, remoteDir, localOptions, cmd) {
1749
+ try {
1750
+ const options = cmd.optsWithGlobals();
1751
+
1752
+ const [error, app] = await safe(getApp(options));
1753
+ if (error) return exit(error);
1754
+ if (!app) return exit(NO_APP_FOUND_ERROR_STRING);
1755
+
1756
+ if (app.installationState !== 'installed') exit('App is not yet running. Try again later.');
1757
+
1758
+ const resolvedLocal = path.resolve(localDir);
1759
+ if (!fs.existsSync(resolvedLocal)) exit('local path must be an existing file or directory');
1760
+
1761
+ const localStat = fs.lstatSync(resolvedLocal);
1762
+ if (!localStat.isFile() && !localStat.isDirectory()) exit('local path must be an existing file or directory');
1763
+
1764
+ const { baseDir: tarBaseDir, files: localList } = getLocalFileList(resolvedLocal);
1765
+
1766
+ // rsync convention: no trailing slash on source dir appends the directory name to dest
1767
+ if (localStat.isDirectory() && !localDir.endsWith('/') && !localDir.endsWith('/.')) {
1768
+ remoteDir = remoteDir.replace(/\/+$/, '') + '/' + path.basename(resolvedLocal);
1769
+ } else if (localStat.isFile()) {
1770
+ remoteDir = remoteDir.replace(/\/+$/, '');
1771
+ }
1772
+
1773
+ const remoteList = await getRemoteFileList(app.id, remoteDir, options);
1774
+ const { changed: filesToSync, extraneous: filesToDelete } = diffFileLists(localList, remoteList);
1775
+ const doDelete = options.delete && filesToDelete.length > 0;
1776
+
1777
+ if (filesToSync.length === 0 && !doDelete) {
1778
+ console.log('Already up to date.');
1779
+ return;
1780
+ }
1781
+
1782
+ for (const f of filesToSync) console.log(f);
1783
+ if (doDelete) for (const f of filesToDelete) console.log(`delete: ${f}`);
1784
+ console.log(`Syncing ${filesToSync.length} file(s)${doDelete ? `, deleting ${filesToDelete.length} file(s)` : ''}...`);
1785
+
1786
+ if (filesToSync.length > 0) {
1787
+ const localFileList = path.join(os.tmpdir(), `.cloudron-sync-${process.pid}`);
1788
+ fs.writeFileSync(localFileList, filesToSync.join('\n') + '\n');
1789
+
1790
+ const stdin = function () {
1791
+ const tarzip = spawn('tar', ['zcf', '-', '-C', tarBaseDir, '-T', localFileList], { stdio: 'pipe' });
1792
+ tarzip.on('close', () => fs.unlinkSync(localFileList));
1793
+ return tarzip.stdout;
1794
+ };
1795
+
1796
+ const mkdirCmd = options.force
1797
+ ? `test -f ${remoteDir} && rm -f ${remoteDir}; mkdir -p ${remoteDir}`
1798
+ : `mkdir -p ${remoteDir}`;
1799
+ const execId = await createExecSession(app.id, ['bash', '-c', `${mkdirCmd} && tar zxf - -C ${remoteDir}`], { tty: false }, options);
1800
+
1801
+ await runExecSession(app.id, execId, {
1802
+ stdin,
1803
+ stdout: process.stdout,
1804
+ stderr: process.stderr,
1805
+ tty: false
1806
+ }, options);
1807
+
1808
+ const execSession = await getExecSession(app.id, execId, options);
1809
+ if (execSession.exitCode !== 0) exit(`Sync failed with exit code ${execSession.exitCode}`);
1810
+ }
1811
+
1812
+ if (doDelete) {
1813
+ const CHUNK_SIZE = 500;
1814
+ for (let i = 0; i < filesToDelete.length; i += CHUNK_SIZE) {
1815
+ const chunk = filesToDelete.slice(i, i + CHUNK_SIZE).map(f => `${remoteDir}/${f}`);
1816
+ const result = await runExecCommand(app.id, ['rm', '-f', ...chunk], options);
1817
+ if (result.exitCode !== 0) exit(`Failed to delete remote files: ${result.stderr.toString().trim()}`);
1818
+ }
1819
+ }
1820
+
1821
+ console.log('Sync complete.');
1822
+ } catch (e) {
1823
+ exit(`Sync push failed: ${e.message}`);
1824
+ }
1825
+ }
1826
+
1827
+ async function syncPull(remoteDir, localDir, localOptions, cmd) {
1828
+ try {
1829
+ const options = cmd.optsWithGlobals();
1830
+
1831
+ const [error, app] = await safe(getApp(options));
1832
+ if (error) return exit(error);
1833
+ if (!app) return exit(NO_APP_FOUND_ERROR_STRING);
1834
+
1835
+ if (app.installationState !== 'installed') exit('App is not yet running. Try again later.');
1836
+
1837
+ // rsync convention: no trailing slash on source appends the directory name to dest
1838
+ if (!remoteDir.endsWith('/')) {
1839
+ localDir = path.join(localDir, path.basename(remoteDir));
1840
+ }
1841
+
1842
+ const resolvedLocalDir = path.resolve(localDir);
1843
+ if (fs.existsSync(resolvedLocalDir) && !fs.lstatSync(resolvedLocalDir).isDirectory()) {
1844
+ if (options.force) fs.unlinkSync(resolvedLocalDir);
1845
+ else exit(`local path '${localDir}' exists and is not a directory (use --force to remove)`);
1846
+ }
1847
+ if (!fs.existsSync(resolvedLocalDir)) fs.mkdirSync(resolvedLocalDir, { recursive: true });
1848
+
1849
+ const remoteList = await getRemoteFileList(app.id, remoteDir, options);
1850
+ const { files: localList } = getLocalFileList(path.resolve(localDir));
1851
+ const { changed: filesToSync, extraneous: filesToDelete } = diffFileLists(remoteList, localList);
1852
+ const doDelete = options.delete && filesToDelete.length > 0;
1853
+
1854
+ if (filesToSync.length === 0 && !doDelete) {
1855
+ console.log('Already up to date.');
1856
+ return;
1857
+ }
1858
+
1859
+ for (const f of filesToSync) console.log(f);
1860
+ if (doDelete) for (const f of filesToDelete) console.log(`delete: ${f}`);
1861
+ console.log(`Syncing ${filesToSync.length} file(s)${doDelete ? `, deleting ${filesToDelete.length} file(s)` : ''}...`);
1862
+
1863
+ if (filesToSync.length > 0) {
1864
+ const remoteFileList = '/tmp/.cloudron-sync-filelist';
1865
+ const fileListData = filesToSync.join('\n') + '\n';
1866
+
1867
+ const writeExecId = await createExecSession(app.id, ['bash', '-c', `cat > ${remoteFileList}`], { tty: false }, options);
1868
+ await runExecSession(app.id, writeExecId, {
1869
+ stdin: () => {
1870
+ const s = new PassThrough();
1871
+ s.end(fileListData);
1872
+ return s;
1873
+ },
1874
+ stdout: new PassThrough(),
1875
+ stderr: process.stderr,
1876
+ tty: false
1877
+ }, options);
1878
+ const writeSession = await getExecSession(app.id, writeExecId, options);
1879
+ if (writeSession.exitCode !== 0) exit('Failed to write remote file list');
1880
+
1881
+ const untar = tar.extract(path.resolve(localDir));
1882
+ const unzip = zlib.createGunzip();
1883
+ unzip.pipe(untar);
1884
+
1885
+ const execId = await createExecSession(app.id, ['bash', '-c', `tar zcf - -C ${remoteDir} -T ${remoteFileList}; rm -f ${remoteFileList}`], { tty: false }, options);
1886
+
1887
+ await runExecSession(app.id, execId, {
1888
+ stdin: () => {
1889
+ const s = new PassThrough();
1890
+ s.end();
1891
+ return s;
1892
+ },
1893
+ stdout: unzip,
1894
+ stderr: process.stderr,
1895
+ tty: false
1896
+ }, options);
1897
+
1898
+ const execSession = await getExecSession(app.id, execId, options);
1899
+ if (execSession.exitCode !== 0) exit(`Sync failed with exit code ${execSession.exitCode}`);
1900
+ }
1901
+
1902
+ if (doDelete) {
1903
+ for (const f of filesToDelete) {
1904
+ fs.unlinkSync(path.join(path.resolve(localDir), f));
1905
+ }
1906
+ }
1907
+
1908
+ console.log('Sync complete.');
1909
+ } catch (e) {
1910
+ exit(`Sync pull failed: ${e.message}`);
1590
1911
  }
1591
1912
  }
1592
1913
 
@@ -1775,5 +2096,8 @@ export default {
1775
2096
  envGet,
1776
2097
  envSet,
1777
2098
  envList,
1778
- envUnset
2099
+ envUnset,
2100
+
2101
+ syncPush,
2102
+ syncPull
1779
2103
  };
@@ -295,11 +295,6 @@ async function build(localOptions, cmd) {
295
295
 
296
296
  const appConfig = config.getCwdConfig(sourceDir);
297
297
  const buildServiceConfig = getEffectiveBuildServiceConfig(options);
298
- if (buildServiceConfig.type === 'remote' && buildServiceConfig.url) {
299
- console.log('Building using remote build service at %s', buildServiceConfig.url);
300
- } else {
301
- console.log('Building locally with Docker.');
302
- }
303
298
 
304
299
  let repository = appConfig.repository;
305
300
  if (!repository || options.repository) {
@@ -1,65 +0,0 @@
1
- #!/usr/bin/env node
2
-
3
- import appstoreActions from '../src/appstore-actions.js';
4
- import { Command } from 'commander';
5
- import pkg from '../package.json' with { type: 'json' };
6
-
7
- const program = new Command();
8
- program.version(pkg.packageVersion);
9
-
10
- // global options. IMPORTANT: These cannot conflict with global options!
11
- program
12
- .option('--appstore-token <token>', 'AppStore token');
13
-
14
- program.command('login')
15
- .description('Login to the appstore')
16
- .option('-e, --email <email>', 'Email address')
17
- .option('-p, --password <password>', 'Password (unsafe)')
18
- .action(appstoreActions.login);
19
-
20
- program.command('logout')
21
- .description('Logout from the appstore')
22
- .action(appstoreActions.logout);
23
-
24
- program.command('info')
25
- .description('List info of published app')
26
- .option('--appstore-id <appid@version>', 'Appstore id and version')
27
- .action(appstoreActions.info);
28
-
29
- program.command('approve')
30
- .description('Approve a submitted app version')
31
- .option('--appstore-id <appid@version>', 'Appstore id and version')
32
- .option('--no-git-push', 'Do not attempt to push to git repo')
33
- .action(appstoreActions.approve);
34
-
35
- program.command('notify')
36
- .description('Notify forum about successful app submission')
37
- .action(appstoreActions.notify);
38
-
39
- program.command('revoke')
40
- .description('Revoke a published app version')
41
- .option('--appstore-id <appid@version>', 'Appstore id and version')
42
- .action(appstoreActions.revoke);
43
-
44
- program.command('submit')
45
- .description('Submit app to the store for review')
46
- .action(appstoreActions.submit);
47
-
48
- program.command('upload')
49
- .description('Upload app to the store for testing')
50
- .option('-i, --image <image>', 'Docker image')
51
- .option('-f, --force', 'Update existing version')
52
- .action(appstoreActions.upload);
53
-
54
- program.command('verify-manifest')
55
- .description('Verify if manifest is ready to be upload/published')
56
- .action(appstoreActions.verifyManifest);
57
-
58
- program.command('versions')
59
- .alias('list')
60
- .description('List published versions')
61
- .option('--appstore-id <id>', 'Appstore id')
62
- .option('--raw', 'Dump versions as json')
63
- .action(appstoreActions.listVersions);
64
-
65
- program.parse(process.argv);
@@ -1,71 +0,0 @@
1
- #!/usr/bin/env node
2
-
3
- import { program } from 'commander';
4
- import buildActions from '../src/build-actions.js';
5
- import pkg from '../package.json' with { type: 'json' };
6
-
7
- program.version(pkg.version);
8
-
9
- program.addHelpText('after', `
10
-
11
- Show help for default subcommand:
12
- $ cloudron build build --help`);
13
-
14
-
15
- function collectArgs(value, collected) {
16
- collected.push(value);
17
- return collected;
18
- }
19
-
20
- // global options. IMPORTANT: These cannot conflict with global options!
21
- program.option('--server <server>', 'Cloudron domain')
22
- .option('--build-service-url <url>', 'Build service URL')
23
- .option('--build-service-token <token>', 'Build service token');
24
-
25
- program.command('build', { isDefault: true })
26
- .description('Build an app. This is the default subcommand')
27
- .option('--build-arg <namevalue>', 'Build arg passed to docker. Can be used multiple times', collectArgs, [])
28
- .option('-f, --file <dockerfile>', 'Name of the Dockerfile')
29
- .option('--repository [repository url]', 'Change the repository. This url is stored for future builds for this project. e.g registry/username/projectname')
30
- .option('--no-cache', 'Do not use cache')
31
- .option('--no-push', 'Do not push built image to registry')
32
- .option('--raw', 'Raw output build log')
33
- .option('--tag <docker image tag>', 'Docker image tag. Note that this does not include the repository name')
34
- .action(buildActions.build);
35
-
36
- program.command('reset')
37
- .description('Reset build configuration for this directory')
38
- .action(buildActions.reset);
39
-
40
- program.command('info')
41
- .description('Print build information')
42
- .action(buildActions.info);
43
-
44
- program.command('login')
45
- .description('Login to the build service')
46
- .action(buildActions.login);
47
-
48
- program.command('logout')
49
- .description('Logout from the build service')
50
- .action(buildActions.logout);
51
-
52
- program.command('logs')
53
- .description('Build logs. This works only when using the Build Service')
54
- .option('--id <buildid>', 'Build ID')
55
- .option('--raw', 'Raw output build log')
56
- .action(buildActions.logs);
57
-
58
- program.command('push')
59
- .description('Push the build image')
60
- .option('--id <buildid>', 'Build ID')
61
- .option('--repository [repository url]', 'Set repository to push to. e.g registry/username/projectname')
62
- .option('--tag <docker image tag>', 'Docker image tag. Note that this does not include the repository name')
63
- .option('--image <docker image>', 'Docker image of the form registry/repo:tag')
64
- .action(buildActions.push);
65
-
66
- program.command('status')
67
- .description('Build status. This works only when using the Build Service')
68
- .option('--id <buildid>', 'Build ID')
69
- .action(buildActions.status);
70
-
71
- program.parse(process.argv);
@@ -1,33 +0,0 @@
1
- #!/usr/bin/env node
2
-
3
- import versionsActions from '../src/versions-actions.js';
4
- import { Command } from 'commander';
5
- import pkg from '../package.json' with { type: 'json' };
6
-
7
- const program = new Command();
8
- program.version(pkg.version);
9
-
10
- program.command('add')
11
- .description('Add the current build to version file')
12
- .option('--state <state>', 'Publish state (published or testing)')
13
- .action(versionsActions.addOrUpdate);
14
-
15
- program.command('init')
16
- .description('Create versions file')
17
- .action(versionsActions.init);
18
-
19
- program.command('list')
20
- .description('List existing versions')
21
- .action(versionsActions.list);
22
-
23
- program.command('revoke')
24
- .description('Revoke the latest version')
25
- .action(versionsActions.revoke);
26
-
27
- program.command('update')
28
- .description('Update existing version')
29
- .option('--version <version>', 'Version to update')
30
- .option('--state <state>', 'Publish state (published or testing)')
31
- .action(versionsActions.addOrUpdate);
32
-
33
- program.parse(process.argv);