cloudron 7.0.6 → 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.6",
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';
@@ -1426,6 +1427,187 @@ function demuxStream(inputStream, stdout, stderr) {
1426
1427
  });
1427
1428
  }
1428
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
+
1429
1611
  // cloudron exec - must work interactively. needs tty.
1430
1612
  // cloudron exec -- ls asdf - must work
1431
1613
  // cloudron exec -- cat /home/cloudron/start.sh > /tmp/start.sh - must work (test with binary files). should disable tty
@@ -1433,12 +1615,9 @@ function demuxStream(inputStream, stdout, stderr) {
1433
1615
  // cat ~/tmp/fantome.tar.gz | cloudron exec -- bash -c "tar xvf - -C /tmp" - must show an error
1434
1616
  // cat ~/tmp/fantome.tar.gz | cloudron exec -- bash -c "tar zxf - -C /tmp" - must extrack ok
1435
1617
  async function execApp(args, localOptions, cmd) {
1436
- let stdin = localOptions._stdin || process.stdin; // hack for 'push', 'pull' to reuse this function
1437
- const stdout = localOptions._stdout || process.stdout; // hack for 'push', 'pull' to reuse this function
1438
-
1439
1618
  const options = cmd.optsWithGlobals();
1440
1619
  let tty = !!options.tty;
1441
- let lang; // used as body param later
1620
+ let lang;
1442
1621
 
1443
1622
  const [error, app] = await safe(getApp(options));
1444
1623
  if (error) return exit(error);
@@ -1447,150 +1626,288 @@ async function execApp(args, localOptions, cmd) {
1447
1626
  if (app.installationState !== 'installed') exit('App is not yet running. Try again later.');
1448
1627
 
1449
1628
  if (args.length === 0) {
1450
- args = [ '/bin/bash' ];
1451
- tty = true; // override
1629
+ args = ['/bin/bash'];
1630
+ tty = true;
1452
1631
  lang = 'C.UTF-8';
1453
1632
  }
1454
1633
 
1455
- if (tty && !stdin.isTTY) exit('stdin is not tty');
1634
+ if (tty && !process.stdin.isTTY) exit('stdin is not tty');
1456
1635
 
1457
- const request = createRequest('POST', `/api/v1/apps/${app.id}/exec`, options);
1458
- const response = await request.send({ cmd: args, tty, lang });
1459
- if (response.status !== 200) return exit(`Failed to create exec: ${requestError(response)}`);
1460
- const execId = response.body.id;
1461
-
1462
- async function exitWithCode() {
1463
- const response2 = await createRequest('GET', `/api/v1/apps/${app.id}/exec/${execId}`, options);
1464
- if (response2.status !== 200) return exit(`Failed to get exec code: ${requestError(response2)}`);
1465
-
1466
- process.exit(response2.body.exitCode);
1467
- }
1468
-
1469
- const { adminFqdn, token: reqToken, rejectUnauthorized } = requestOptions(cmd.optsWithGlobals());
1636
+ const execId = await createExecSession(app.id, args, { tty, lang }, options);
1470
1637
 
1471
- const searchParams = new URLSearchParams({
1472
- rows: stdout.rows || 24,
1473
- columns: stdout.columns || 80,
1474
- access_token: reqToken,
1638
+ await runExecSession(app.id, execId, {
1639
+ stdin: process.stdin,
1640
+ stdout: process.stdout,
1641
+ stderr: process.stderr,
1475
1642
  tty
1476
- });
1477
-
1478
- const req = https.request({
1479
- hostname: adminFqdn,
1480
- path: `/api/v1/apps/${app.id}/exec/${execId}/start?${searchParams.toString()}`,
1481
- method: 'GET',
1482
- headers: {
1483
- 'Connection': 'Upgrade',
1484
- 'Upgrade': 'tcp'
1485
- },
1486
- rejectUnauthorized
1487
- }, function handler(res) {
1488
- if (res.statusCode === 403) exit('Unauthorized.'); // only admin or only owner (for docker addon)
1489
-
1490
- exit('Could not upgrade connection to tcp. http status:', res.statusCode);
1491
- });
1643
+ }, options);
1492
1644
 
1493
- req.on('upgrade', function (resThatShouldNotBeUsed, socket /*, upgradeHead*/) {
1494
- // do not use res here! it's all socket from here on
1495
- socket.on('error', exit);
1496
-
1497
- socket.setNoDelay(true);
1498
- socket.setKeepAlive(true);
1499
-
1500
- if (tty) {
1501
- stdin.setRawMode(true);
1502
- stdin.pipe(socket, { end: false }); // the remote will close the connection
1503
- socket.pipe(stdout); // in tty mode, stdout/stderr is merged
1504
- socket.on('end', exitWithCode); // server closed the socket
1505
- } else { // create stdin process on demand
1506
- if (typeof stdin === 'function') stdin = stdin();
1507
-
1508
- stdin.on('data', function (d) {
1509
- var buf = Buffer.alloc(4);
1510
- buf.writeUInt32BE(d.length, 0 /* offset */);
1511
- socket.write(buf);
1512
- socket.write(d);
1513
- });
1514
- stdin.on('end', function () {
1515
- var buf = Buffer.alloc(4);
1516
- buf.writeUInt32BE(0, 0 /* offset */);
1517
- socket.write(buf);
1518
- });
1519
-
1520
- stdout.on('close', exitWithCode); // this is only emitted when stdout is a file and not the terminal
1521
-
1522
- demuxStream(socket, stdout, process.stderr); // can get separate streams in non-tty mode
1523
- socket.on('end', function () { // server closed the socket
1524
- 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
1525
- if (stdout !== process.stdout) stdout.end(); // for push stream
1645
+ const execSession = await getExecSession(app.id, execId, options);
1646
+ process.exit(execSession.exitCode);
1647
+ }
1526
1648
 
1527
- socket.end();
1649
+ async function pushApp(localDir, remote, localOptions, cmd) {
1650
+ const options = cmd.optsWithGlobals();
1528
1651
 
1529
- // process._getActiveHandles(); process._getActiveRequests();
1530
- if (stdout === process.stdout) setImmediate(exitWithCode); // otherwise, we rely on the 'close' event above
1531
- });
1532
- }
1533
- });
1652
+ const [error, app] = await safe(getApp(options));
1653
+ if (error) return exit(error);
1654
+ if (!app) return exit(NO_APP_FOUND_ERROR_STRING);
1534
1655
 
1535
- req.on('error', exit); // could not make a request
1536
- req.end(); // this makes the request
1537
- }
1656
+ if (app.installationState !== 'installed') exit('App is not yet running. Try again later.');
1538
1657
 
1539
- function pushApp(localDir, remote, localOptions, cmd) {
1540
- // deal with paths prefixed with ~
1541
1658
  const local = localDir.replace(/^~(?=$|\/|\\)/, os.homedir());
1542
1659
  const stat = fs.existsSync(path.resolve(local)) ? fs.lstatSync(local) : null;
1543
1660
 
1544
- if (stat && stat.isDirectory()) {
1661
+ let args, stdin;
1662
+
1663
+ if (stat && stat.isDirectory()) {
1545
1664
  // Create a functor for stdin. If no data event handlers are attached, and there are no stream.pipe() destinations, and the stream is
1546
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.
1547
- localOptions._stdin = function () {
1666
+ stdin = function () {
1548
1667
  var tarzip = spawn('tar', ['zcf', '-', '-C', path.dirname(local), path.basename(local)], { stdio: 'pipe' });
1549
1668
  return tarzip.stdout;
1550
1669
  };
1551
-
1552
- execApp(['tar', 'zxvf', '-', '-C', remote], localOptions, cmd);
1670
+ args = ['tar', 'zxvf', '-', '-C', remote];
1553
1671
  } else {
1554
1672
  if (local === '-') {
1555
- localOptions._stdin = process.stdin;
1673
+ stdin = process.stdin;
1556
1674
  } else if (stat) {
1557
- localOptions._stdin = fs.createReadStream(local);
1675
+ stdin = fs.createReadStream(local);
1558
1676
  } else {
1559
1677
  exit('local file ' + local + ' does not exist');
1560
1678
  }
1561
1679
 
1562
- localOptions._stdin.on('error', function (error) { exit('Error pushing', error); });
1680
+ stdin.on('error', function (e) { exit('Error pushing', e); });
1563
1681
 
1564
- if (remote.endsWith('/')) { // dir
1565
- 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);
1566
1684
  }
1567
1685
 
1568
- execApp(['bash', '-c', `cat - > "${remote}"`], localOptions, cmd);
1686
+ args = ['bash', '-c', `cat - > "${remote}"`];
1569
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);
1570
1700
  }
1571
1701
 
1572
- function pull(remote, local, localOptions, cmd) {
1573
- if (remote.endsWith('/')) { // dir
1574
- 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);
1575
1715
  var unzip = zlib.createGunzip();
1576
1716
 
1577
1717
  unzip.pipe(untar);
1578
- localOptions._stdout = unzip;
1579
-
1580
- execApp(['tar', 'zcf', '-', '-C', remote, '.'], localOptions, cmd);
1718
+ stdout = unzip;
1719
+ args = ['tar', 'zcf', '-', '-C', remote, '.'];
1581
1720
  } else {
1582
1721
  if (fs.existsSync(local) && fs.lstatSync(local).isDirectory()) {
1583
1722
  local = path.join(local, path.basename(remote));
1584
- localOptions._stdout = fs.createWriteStream(local);
1723
+ stdout = fs.createWriteStream(local);
1585
1724
  } else if (local === '-') {
1586
- localOptions._stdout = process.stdout;
1725
+ stdout = process.stdout;
1587
1726
  } else {
1588
- localOptions._stdout = fs.createWriteStream(local);
1727
+ stdout = fs.createWriteStream(local);
1589
1728
  }
1590
1729
 
1591
- localOptions._stdout.on('error', function (error) { exit('Error pulling', error); });
1730
+ stdout.on('error', function (e) { exit('Error pulling', e); });
1592
1731
 
1593
- 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}`);
1594
1911
  }
1595
1912
  }
1596
1913
 
@@ -1779,5 +2096,8 @@ export default {
1779
2096
  envGet,
1780
2097
  envSet,
1781
2098
  envList,
1782
- envUnset
2099
+ envUnset,
2100
+
2101
+ syncPush,
2102
+ syncPull
1783
2103
  };
@@ -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);