cloudron 6.0.0 → 7.0.1

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/src/config.js CHANGED
@@ -1,74 +1,41 @@
1
- /* jshint node:true */
1
+ import fs from 'fs';
2
+ import path from 'path';
3
+ import safe from '@cloudron/safetydance';
2
4
 
3
- 'use strict';
5
+ const HOME = process.env.HOME || process.env.HOMEPATH || process.env.USERPROFILE;
4
6
 
5
- var fs = require('fs'),
6
- path = require('path'),
7
- safe = require('safetydance');
7
+ const _configFilePath = path.join(HOME, '.cloudron.json');
8
8
 
9
- var HOME = process.env.HOME || process.env.HOMEPATH || process.env.USERPROFILE;
10
-
11
- exports = module.exports = {
12
- setActive: setActive,
13
-
14
- set: set,
15
- get: get,
16
-
17
- cloudronGet: cloudronGet,
18
- cloudronSet: cloudronSet,
19
-
20
- // appstore
21
- appStoreToken,
22
- setAppStoreToken,
23
- appStoreOrigin,
24
-
25
- // per app
26
- getAppConfig: (path) => get(['apps', path]) || {},
27
- setAppConfig: (path, c) => set(['apps', path], c),
28
-
29
- // build service
30
- getBuildServiceConfig: () => get('buildService') || {},
31
- setBuildServiceConfig: (c) => set('buildService', c),
32
-
33
- // current cloudron
34
- token: cloudronGet.bind(null, 'token'),
35
- setToken: cloudronSet.bind(null, 'token'),
36
-
37
- cloudron: cloudronGet.bind(null, 'cloudron'),
38
- setCloudron: cloudronSet.bind(null, 'cloudron'),
39
-
40
- apiEndpoint: cloudronGet.bind(null, 'apiEndpoint'),
41
- setApiEndpoint: cloudronSet.bind(null, 'apiEndpoint'),
42
-
43
- allowSelfsigned: cloudronGet.bind(null, 'allowSelfsigned'),
44
- setAllowSelfsigned: cloudronSet.bind(null, 'allowSelfsigned'),
45
-
46
- // for testing
47
- _configFilePath: path.join(HOME, '.cloudron.json')
48
- };
49
-
50
- var gConfig = safe.JSON.parse(safe.fs.readFileSync(exports._configFilePath)) || {};
9
+ var gConfig = safe.JSON.parse(safe.fs.readFileSync(_configFilePath)) || {};
51
10
 
52
11
  // per default we fetch the last cloudron which a user performed an explicit login
53
12
  var gCurrent = safe.query(gConfig, 'cloudrons.default') || null;
54
13
 
55
14
  // This is mostly called from helper.detectCloudronApiEndpoint() which will select the correct config section then
56
- function setActive(apiEndpoint) {
57
- gCurrent = apiEndpoint;
15
+ function setActive(endpoint) {
16
+ gCurrent = endpoint;
58
17
  }
59
18
 
60
19
  function save() {
61
- fs.writeFileSync(exports._configFilePath, JSON.stringify(gConfig, null, 4));
20
+ fs.writeFileSync(_configFilePath, JSON.stringify(gConfig, null, 4));
62
21
  }
63
22
 
64
23
  function set(key, value) {
65
24
  // reload config from disk and set. A parallel cli process could have adjusted values
66
- gConfig = safe.JSON.parse(safe.fs.readFileSync(exports._configFilePath)) || {};
25
+ gConfig = safe.JSON.parse(safe.fs.readFileSync(_configFilePath)) || {};
67
26
 
68
27
  safe.set(gConfig, key, value);
69
28
  save();
70
29
  }
71
30
 
31
+ function unset(key) {
32
+ // reload config from disk and set. A parallel cli process could have adjusted values
33
+ gConfig = safe.JSON.parse(safe.fs.readFileSync(_configFilePath)) || {};
34
+
35
+ safe.unset(gConfig, key);
36
+ save();
37
+ }
38
+
72
39
  function get(key) {
73
40
  return safe.query(gConfig, key);
74
41
  }
@@ -89,7 +56,65 @@ function appStoreToken() {
89
56
  return get(['appStore', appStoreOrigin().replace('https://', ''), 'token']);
90
57
  }
91
58
 
92
- function setAppStoreToken(token) {
93
- set(['appStore', appStoreOrigin().replace('https://', ''), 'token'], token);
59
+ function setAppStoreToken(value) {
60
+ set(['appStore', appStoreOrigin().replace('https://', ''), 'token'], value);
94
61
  }
95
62
 
63
+ const getAppBuildConfig = (p) => get(['apps', p]) || {};
64
+ const setAppBuildConfig = (p, c) => set(['apps', p], c);
65
+ const unsetAppBuildConfig = (p) => unset(['apps', p]);
66
+
67
+ const getBuildServiceConfig = () => get('buildService') || {};
68
+ const setBuildServiceConfig = (c) => set('buildService', c);
69
+
70
+ const token = cloudronGet.bind(null, 'token');
71
+ const setToken = cloudronSet.bind(null, 'token');
72
+
73
+ const cloudron = cloudronGet.bind(null, 'cloudron');
74
+ const setCloudron = cloudronSet.bind(null, 'cloudron');
75
+
76
+ const apiEndpoint = cloudronGet.bind(null, 'apiEndpoint');
77
+ const setApiEndpoint = cloudronSet.bind(null, 'apiEndpoint');
78
+
79
+ const allowSelfsigned = cloudronGet.bind(null, 'allowSelfsigned');
80
+ const setAllowSelfsigned = cloudronSet.bind(null, 'allowSelfsigned');
81
+
82
+ export {
83
+ setActive,
84
+
85
+ set,
86
+ get,
87
+
88
+ cloudronGet,
89
+ cloudronSet,
90
+
91
+ // appstore
92
+ appStoreToken,
93
+ setAppStoreToken,
94
+ appStoreOrigin,
95
+
96
+ // per app
97
+ getAppBuildConfig,
98
+ setAppBuildConfig,
99
+ unsetAppBuildConfig,
100
+
101
+ // build service
102
+ getBuildServiceConfig,
103
+ setBuildServiceConfig,
104
+
105
+ // current cloudron
106
+ token,
107
+ setToken,
108
+
109
+ cloudron,
110
+ setCloudron,
111
+
112
+ apiEndpoint,
113
+ setApiEndpoint,
114
+
115
+ allowSelfsigned,
116
+ setAllowSelfsigned,
117
+
118
+ // for testing
119
+ _configFilePath
120
+ };
package/src/helper.js CHANGED
@@ -1,16 +1,11 @@
1
- /* jshint node:true */
2
-
3
- 'use strict';
4
-
5
- const fs = require('fs'),
6
- path = require('path'),
7
- util = require('util');
8
-
9
- exports = module.exports = {
10
- exit,
11
-
12
- locateManifest,
13
- };
1
+ import crypto from 'crypto';
2
+ import fs from 'fs';
3
+ import http from 'http';
4
+ import open from 'open';
5
+ import path from 'path';
6
+ import safe from '@cloudron/safetydance';
7
+ import superagent from '@cloudron/superagent';
8
+ import util from 'util';
14
9
 
15
10
  function exit(error) {
16
11
  if (error instanceof Error) console.log(error.message);
@@ -34,3 +29,150 @@ function locateManifest() {
34
29
 
35
30
  return null;
36
31
  }
32
+
33
+ async function locateVersions() {
34
+ let curdir = process.cwd();
35
+ do {
36
+ const candidate = path.join(curdir, 'CloudronVersions.json');
37
+ if (fs.existsSync(candidate)) return candidate;
38
+
39
+ // check if we can't go further up (the previous check for '/' breaks on windows)
40
+ if (curdir === path.resolve(curdir, '..')) break;
41
+
42
+ curdir = path.resolve(curdir, '..');
43
+ // eslint-disable-next-line no-constant-condition
44
+ } while (true);
45
+
46
+ return null;
47
+ }
48
+
49
+ function parseChangelog(file, version) {
50
+ let changelog = '';
51
+ const data = safe.fs.readFileSync(file, 'utf8');
52
+ if (!data) return null;
53
+ const lines = data.split('\n');
54
+
55
+ version = version.replace(/-.*/, ''); // remove any prerelease
56
+
57
+ let i;
58
+ for (i = 0; i < lines.length; i++) {
59
+ if (lines[i] === '[' + version + ']') break;
60
+ }
61
+
62
+ for (i = i + 1; i < lines.length; i++) {
63
+ if (lines[i] === '') continue;
64
+ if (lines[i][0] === '[') break;
65
+
66
+ changelog += lines[i] + '\n';
67
+ }
68
+
69
+ return changelog;
70
+ }
71
+
72
+ async function performOidcLogin(adminFqdn, { rejectUnauthorized = true } = {}) {
73
+ const OIDC_CLIENT_ID = 'cid-cli';
74
+ const OIDC_CLIENT_SECRET = 'notused';
75
+ const OIDC_PROVIDER = `https://${adminFqdn}`;
76
+ const OIDC_CALLBACK_URL = 'http://localhost:1312/callback';
77
+
78
+ // Discover OIDC endpoints
79
+ const discoveryRequest = superagent.get(`${OIDC_PROVIDER}/.well-known/openid-configuration`).timeout(60000);
80
+ if (!rejectUnauthorized) discoveryRequest.disableTLSCerts();
81
+ const discoveryResponse = await discoveryRequest;
82
+ const { authorization_endpoint, token_endpoint } = discoveryResponse.body;
83
+
84
+ // Generate PKCE code_verifier and code_challenge (S256)
85
+ const codeVerifier = crypto.randomBytes(32).toString('base64url');
86
+ const codeChallenge = crypto.createHash('sha256').update(codeVerifier).digest('base64url');
87
+
88
+ // Generate state for CSRF protection
89
+ const state = crypto.randomBytes(16).toString('hex');
90
+
91
+ // Build authorization URL
92
+ const authUrl = new URL(authorization_endpoint);
93
+ authUrl.searchParams.set('response_type', 'code');
94
+ authUrl.searchParams.set('client_id', OIDC_CLIENT_ID);
95
+ authUrl.searchParams.set('redirect_uri', OIDC_CALLBACK_URL);
96
+ authUrl.searchParams.set('state', state);
97
+ authUrl.searchParams.set('scope', 'openid');
98
+ authUrl.searchParams.set('code_challenge', codeChallenge);
99
+ authUrl.searchParams.set('code_challenge_method', 'S256');
100
+
101
+ // Start local HTTP server and wait for the OIDC callback
102
+ const code = await new Promise((resolve, reject) => {
103
+ const server = http.createServer((req, res) => {
104
+ const url = new URL(req.url, 'http://localhost:1312');
105
+ if (url.pathname !== '/callback') {
106
+ res.writeHead(404);
107
+ res.end('Not found');
108
+ return;
109
+ }
110
+
111
+ const error = url.searchParams.get('error');
112
+ if (error) {
113
+ const description = url.searchParams.get('error_description') || error;
114
+ res.writeHead(200, { 'Content-Type': 'text/html' });
115
+ res.end('<html><body><h1>Login failed</h1><p>' + description + '</p><p>You can close this window.</p></body></html>');
116
+ server.close();
117
+ reject(new Error(`OIDC login failed: ${description}`));
118
+ return;
119
+ }
120
+
121
+ const receivedState = url.searchParams.get('state');
122
+ if (receivedState !== state) {
123
+ res.writeHead(400, { 'Content-Type': 'text/html' });
124
+ res.end('<html><body><h1>Login failed</h1><p>State mismatch.</p></body></html>');
125
+ server.close();
126
+ reject(new Error('OIDC state mismatch'));
127
+ return;
128
+ }
129
+
130
+ const receivedCode = url.searchParams.get('code');
131
+ res.writeHead(200, { 'Content-Type': 'text/html' });
132
+ res.end('<html><body><h1>Login successful!</h1><p>You can close this window and return to the terminal.</p></body></html>');
133
+ server.close();
134
+ resolve(receivedCode);
135
+ });
136
+
137
+ server.listen(1312, () => {
138
+ console.log('Opening browser for authentication...');
139
+ open(authUrl.toString());
140
+ });
141
+
142
+ server.on('error', (err) => {
143
+ reject(new Error(`Failed to start local server: ${err.message}`));
144
+ });
145
+
146
+ // Timeout after 2 minutes so the CLI doesn't hang indefinitely
147
+ setTimeout(() => {
148
+ server.close();
149
+ reject(new Error('Login timed out after 2 minutes'));
150
+ }, 120000);
151
+ });
152
+
153
+ // Exchange authorization code for access token
154
+ const tokenBody = new URLSearchParams({
155
+ grant_type: 'authorization_code',
156
+ code,
157
+ redirect_uri: OIDC_CALLBACK_URL,
158
+ client_id: OIDC_CLIENT_ID,
159
+ client_secret: OIDC_CLIENT_SECRET,
160
+ code_verifier: codeVerifier,
161
+ }).toString();
162
+ const tokenRequest = superagent.post(token_endpoint)
163
+ .timeout(60000)
164
+ .set('Content-Type', 'application/x-www-form-urlencoded')
165
+ .send(tokenBody);
166
+ if (!rejectUnauthorized) tokenRequest.disableTLSCerts();
167
+ const tokenResponse = await tokenRequest;
168
+
169
+ return tokenResponse.body.access_token;
170
+ }
171
+
172
+ export {
173
+ exit,
174
+ locateManifest,
175
+ parseChangelog,
176
+ locateVersions,
177
+ performOidcLogin,
178
+ };
package/src/readline.js CHANGED
@@ -1,21 +1,19 @@
1
- 'use strict';
2
-
3
- exports = module.exports = {
4
- question
5
- };
6
-
7
- const readline = require('node:readline/promises'),
8
- { Writable } = require('node:stream');
1
+ import readline from 'node:readline/promises';
2
+ import safe from '@cloudron/safetydance';
3
+ import { Writable } from 'node:stream';
9
4
 
10
5
  async function question(query, options) {
11
6
  const output = options.noEchoBack
12
7
  ? new Writable({ write: function (chunk, encoding, callback) { callback(); } })
13
8
  : process.stdout;
14
- process.stdin.setRawMode(options.noEchoBack); // raw mode gives each keypress as opposd to line based cooked mode
9
+ process.stdin.setRawMode(options.noEchoBack); // raw mode gives each keypress as opposed to line based cooked mode
15
10
  const rl = readline.createInterface({ input: process.stdin, output });
16
11
  if (options.noEchoBack) process.stdout.write(query);
17
- const answer = await rl.question(query, options);
12
+ const [error, answer] = await safe(rl.question(query, options));
13
+ if (error) return process.exit(1); // like AbortError for ^C
18
14
  rl.close();
19
15
  if (options.noEchoBack) process.stdout.write('\n');
20
16
  return answer;
21
17
  }
18
+
19
+ export { question };
@@ -2,14 +2,14 @@
2
2
  "id": "",
3
3
  "version": "<%- version %>",
4
4
  "upstreamVersion": "",
5
- "minBoxVersion": "7.1.0",
5
+ "minBoxVersion": "9.0.0",
6
6
  "title": "",
7
7
  "author": "",
8
8
  "description": "file://DESCRIPTION.md",
9
9
  "tagline": "",
10
10
  "website": "",
11
11
  "contactEmail": "",
12
- "icon": "file://logo.png",
12
+ "iconUrl": "https-logo.png",
13
13
  "healthCheckPath": "/",
14
14
  "mediaLinks": [],
15
15
  "httpPort": <%- httpPort %>,
@@ -0,0 +1,184 @@
1
+ import { exit, locateManifest, locateVersions, parseChangelog } from './helper.js';
2
+ import * as config from './config.js';
3
+ import fs from 'fs';
4
+ import fsPromises from 'fs/promises';
5
+ import manifestFormat from '@cloudron/manifest-format';
6
+ import path from 'path';
7
+ import safe from '@cloudron/safetydance';
8
+ import Table from 'easy-table';
9
+
10
+ const NO_MANIFEST_FOUND_ERROR_STRING = 'No CloudronManifest.json found';
11
+ const NO_VERSIONS_FOUND_ERROR_STRING = 'No CloudronVersions.json found. Use "cloudron versions init" to create one.';
12
+
13
+ const PUBLISH_STATE_TESTING = 'testing';
14
+ const PUBLISH_STATE_PUBLISHED = 'published';
15
+ const PUBLISH_STATE_REVOKED = 'revoked';
16
+
17
+ async function readVersions(versionsFilePath) {
18
+ if (!fs.existsSync(versionsFilePath)) exit('No CloudronVersions.json found, create it first with "cloudron versions init"');
19
+
20
+ const data = fs.readFileSync(versionsFilePath, 'utf8');
21
+ const versionsRoot = safe.JSON.parse(data);
22
+
23
+ const error = manifestFormat.parseVersions(versionsRoot);
24
+ if (error) throw new Error(`${path.relative(process.cwd(), versionsFilePath)} is corrupt: ${error.message}`);
25
+
26
+ return versionsRoot;
27
+ }
28
+
29
+ async function writeVersions(versionsFilePath, versionsRoot) {
30
+ const [error] = await safe(fsPromises.writeFile(versionsFilePath, JSON.stringify(versionsRoot, null, 4), 'utf8'));
31
+ if (error) return exit(`Unable to write to ${path.relative(process.cwd(), versionsFilePath)}: ${error.message}`);
32
+ }
33
+
34
+ async function init(/*localOptions, cmd*/) {
35
+ const manifestFilePath = locateManifest();
36
+ if (!manifestFilePath) return exit(`${NO_MANIFEST_FOUND_ERROR_STRING}. You must run this command in the package dir.`);
37
+
38
+ const baseDir = path.dirname(manifestFilePath);
39
+ const versionsFilePath = `${baseDir}/CloudronVersions.json`;
40
+
41
+ if (fs.existsSync(versionsFilePath)) return exit(`${path.relative(process.cwd(), versionsFilePath)} already exists.`);
42
+
43
+ await writeVersions(versionsFilePath, { stable: true, versions: {} });
44
+ console.log(`Created ${path.relative(process.cwd(), versionsFilePath)}. Use "cloudron versions add" to add a version.`);
45
+ }
46
+
47
+ async function resolveManifest(manifest, baseDir) {
48
+ if (manifest.description.slice(0, 7) === 'file://') {
49
+ let descriptionFilePath = manifest.description.slice(7);
50
+ descriptionFilePath = path.isAbsolute(descriptionFilePath) ? descriptionFilePath : path.join(baseDir, descriptionFilePath);
51
+ manifest.description = safe.fs.readFileSync(descriptionFilePath, 'utf8');
52
+ if (!manifest.description && safe.error) throw(new Error('Could not read/parse description ' + safe.error.message));
53
+ if (!manifest.description) throw new Error('Description cannot be empty');
54
+ }
55
+
56
+ if (manifest.postInstallMessage && manifest.postInstallMessage.slice(0, 7) === 'file://') {
57
+ let postInstallFilePath = manifest.postInstallMessage.slice(7);
58
+ postInstallFilePath = path.isAbsolute(postInstallFilePath) ? postInstallFilePath : path.join(baseDir, postInstallFilePath);
59
+ manifest.postInstallMessage = safe.fs.readFileSync(postInstallFilePath, 'utf8');
60
+ if (!manifest.postInstallMessage && safe.error) throw(new Error('Could not read/parse postInstall ' + safe.error.message));
61
+ if (!manifest.postInstallMessage) throw new Error('PostInstall file specified but it is empty');
62
+ }
63
+
64
+ if (manifest.changelog.slice(0, 7) === 'file://') {
65
+ let changelogPath = manifest.changelog.slice(7);
66
+ changelogPath = path.isAbsolute(changelogPath) ? changelogPath : path.join(baseDir, changelogPath);
67
+ manifest.changelog = parseChangelog(changelogPath, manifest.version);
68
+ if (!manifest.changelog) throw new Error('Bad changelog format or missing changelog for this version');
69
+ }
70
+ }
71
+
72
+ async function addOrUpdate(localOptions, cmd) {
73
+ const isUpdate = cmd.parent.args[0] === 'update';
74
+ const versionsFilePath = await locateVersions();
75
+ if (!versionsFilePath) return exit(NO_VERSIONS_FOUND_ERROR_STRING);
76
+
77
+ const options = cmd.optsWithGlobals();
78
+ // try to find the manifest of this project
79
+ const manifestFilePath = locateManifest();
80
+ if (!manifestFilePath) return exit(NO_MANIFEST_FOUND_ERROR_STRING);
81
+
82
+ const result = manifestFormat.parseFile(manifestFilePath);
83
+ if (result.error) return exit(result.error.message);
84
+
85
+ const manifest = result.manifest;
86
+
87
+ const sourceDir = path.dirname(manifestFilePath);
88
+ const appConfig = config.getAppBuildConfig(sourceDir);
89
+
90
+ if (options.image) {
91
+ manifest.dockerImage = options.image;
92
+ } else {
93
+ manifest.dockerImage = appConfig.dockerImage;
94
+ }
95
+
96
+ if (!manifest.dockerImage) exit('No docker image found, run `cloudron build` first');
97
+
98
+ const error = manifestFormat.checkVersionsRequirements(manifest);
99
+ if (error) return exit(error);
100
+
101
+ const versionsRoot = await readVersions(versionsFilePath);
102
+ const versions = versionsRoot.versions;
103
+
104
+ await resolveManifest(manifest, path.dirname(manifestFilePath));
105
+
106
+ if (options.state && options.state !== PUBLISH_STATE_PUBLISHED && options.state !== PUBLISH_STATE_TESTING) {
107
+ return exit(`Invalid state "${options.state}". Must be "published" or "testing".`);
108
+ }
109
+
110
+ if (isUpdate) {
111
+ const targetVersion = options.version || manifest.version;
112
+ if (!(targetVersion in versions)) exit(`${targetVersion} does not exist in ${path.relative(process.cwd(), versionsFilePath)}.`);
113
+
114
+ versions[targetVersion].manifest = manifest;
115
+ versions[targetVersion].ts = (new Date()).toUTCString();
116
+ if (options.state) versions[targetVersion].publishState = options.state;
117
+ } else {
118
+ if (manifest.version in versions) exit(`${manifest.version} already exists in ${path.relative(process.cwd(), versionsFilePath)}.`);
119
+ versions[manifest.version] = {
120
+ manifest,
121
+ creationDate: (new Date()).toUTCString(),
122
+ ts: (new Date()).toUTCString(),
123
+ publishState: options.state || PUBLISH_STATE_PUBLISHED
124
+ };
125
+ }
126
+
127
+ await writeVersions(versionsFilePath, versionsRoot);
128
+
129
+ if (isUpdate) {
130
+ const targetVersion = options.version || manifest.version;
131
+ console.log(`Updated ${targetVersion} in ${path.relative(process.cwd(), versionsFilePath)}`);
132
+ } else {
133
+ console.log(`Added ${manifest.version} in ${path.relative(process.cwd(), versionsFilePath)}`);
134
+ }
135
+ }
136
+
137
+ async function list(/*localOptions, cmd*/) {
138
+ const versionsFilePath = await locateVersions();
139
+ if (!versionsFilePath) return exit(NO_VERSIONS_FOUND_ERROR_STRING);
140
+
141
+ const versionsRoot = await readVersions(versionsFilePath);
142
+ const versions = versionsRoot.versions;
143
+ const sortedVersions = Object.keys(versions).sort(manifestFormat.packageVersionCompare);
144
+ if (sortedVersions.length === 0) {
145
+ console.log('No versions');
146
+ return;
147
+ }
148
+
149
+ const t = new Table();
150
+
151
+ for (const version of sortedVersions) {
152
+ t.cell('Version', versions[version].manifest.version);
153
+ t.cell('Creation Date', versions[version].creationDate);
154
+ t.cell('Image', versions[version].manifest.dockerImage);
155
+ t.cell('Publish state', versions[version].publishState);
156
+ t.newRow();
157
+ }
158
+
159
+ console.log(t.toString());
160
+ }
161
+
162
+ async function revoke() {
163
+ const versionsFilePath = await locateVersions();
164
+ if (!versionsFilePath) return exit(NO_VERSIONS_FOUND_ERROR_STRING);
165
+
166
+ const versionsRoot = await readVersions(versionsFilePath);
167
+ const versions = versionsRoot.versions;
168
+ const sortedVersions = Object.keys(versions).sort(manifestFormat.packageVersionCompare);
169
+ const latestVersion = sortedVersions.at(-1);
170
+ if (versions[latestVersion].publishState !== PUBLISH_STATE_PUBLISHED) {
171
+ return exit(`Only versions in "${PUBLISH_STATE_PUBLISHED}" can be revoked. ${latestVersion} is currently marked as "${versions[latestVersion].publishState}"`);
172
+ }
173
+
174
+ versions[latestVersion].publishState = PUBLISH_STATE_REVOKED;
175
+ await writeVersions(versionsFilePath, versionsRoot);
176
+ console.log(`Marked ${latestVersion} as revoked in ${path.relative(process.cwd(), versionsFilePath)}`);
177
+ }
178
+
179
+ export default {
180
+ init,
181
+ addOrUpdate,
182
+ list,
183
+ revoke,
184
+ };