@novacraft-engineering/pulsar-cli 1.0.0 → 1.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/hash.js +11 -0
- package/package.json +28 -7
- package/pulsar.js +211 -24
package/hash.js
CHANGED
|
@@ -23,6 +23,17 @@ function walk(dir) {
|
|
|
23
23
|
return files;
|
|
24
24
|
}
|
|
25
25
|
|
|
26
|
+
// Per-file sha256 map (relativePath -> hash), used to compute differential packages.
|
|
27
|
+
export function fileHashes(contentsDir) {
|
|
28
|
+
const map = {};
|
|
29
|
+
for (const file of walk(contentsDir)) {
|
|
30
|
+
const relativePath = path.relative(contentsDir, file).split(path.sep).join('/');
|
|
31
|
+
if (isIgnored(relativePath)) continue;
|
|
32
|
+
map[relativePath] = sha256(fs.readFileSync(file));
|
|
33
|
+
}
|
|
34
|
+
return map;
|
|
35
|
+
}
|
|
36
|
+
|
|
26
37
|
// Matches @revopush/react-native-code-push CodePushUpdateUtils.computeFinalHashFromManifest:
|
|
27
38
|
// build "relPath:sha256(file)" entries, sort, JSON.stringify the array, sha256 that string.
|
|
28
39
|
export function computePackageHash(contentsDir) {
|
package/package.json
CHANGED
|
@@ -1,10 +1,17 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@novacraft-engineering/pulsar-cli",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.1.0",
|
|
4
4
|
"description": "Pulsar OTA release CLI — bundle, hash, and ship React Native updates over-the-air.",
|
|
5
5
|
"type": "module",
|
|
6
|
-
"bin": {
|
|
7
|
-
|
|
6
|
+
"bin": {
|
|
7
|
+
"pulsar": "./pulsar.js"
|
|
8
|
+
},
|
|
9
|
+
"files": [
|
|
10
|
+
"pulsar.js",
|
|
11
|
+
"hash.js",
|
|
12
|
+
"README.md",
|
|
13
|
+
"LICENSE"
|
|
14
|
+
],
|
|
8
15
|
"repository": {
|
|
9
16
|
"type": "git",
|
|
10
17
|
"url": "git+https://github.com/Novacraft-Africa/pulsar-cli.git"
|
|
@@ -12,8 +19,22 @@
|
|
|
12
19
|
"homepage": "https://pulsar.novacraft.africa",
|
|
13
20
|
"license": "MIT",
|
|
14
21
|
"author": "Novacraft Engineering",
|
|
15
|
-
"publishConfig": {
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
"
|
|
22
|
+
"publishConfig": {
|
|
23
|
+
"access": "public"
|
|
24
|
+
},
|
|
25
|
+
"engines": {
|
|
26
|
+
"node": ">=18"
|
|
27
|
+
},
|
|
28
|
+
"scripts": {
|
|
29
|
+
"test": "node test/hash.test.js"
|
|
30
|
+
},
|
|
31
|
+
"keywords": [
|
|
32
|
+
"react-native",
|
|
33
|
+
"ota",
|
|
34
|
+
"code-push",
|
|
35
|
+
"pulsar",
|
|
36
|
+
"cli",
|
|
37
|
+
"release",
|
|
38
|
+
"hot-update"
|
|
39
|
+
]
|
|
19
40
|
}
|
package/pulsar.js
CHANGED
|
@@ -4,7 +4,29 @@ import os from 'node:os';
|
|
|
4
4
|
import path from 'node:path';
|
|
5
5
|
import readline from 'node:readline';
|
|
6
6
|
import {execFileSync} from 'node:child_process';
|
|
7
|
-
import
|
|
7
|
+
import crypto from 'node:crypto';
|
|
8
|
+
import {computePackageHash, fileHashes} from './hash.js';
|
|
9
|
+
|
|
10
|
+
// Code signing: sign the package hash into an RS256 JWT the client verifies against its
|
|
11
|
+
// CodePushPublicKey (Info.plist). Written to `.codepushrelease` inside the bundle.
|
|
12
|
+
function signCodePushJWT(privateKeyPem, contentHash) {
|
|
13
|
+
const encode = (value) => Buffer.from(JSON.stringify(value)).toString('base64url');
|
|
14
|
+
const signingInput = `${encode({alg: 'RS256', typ: 'JWT'})}.${encode({contentHash})}`;
|
|
15
|
+
const signature = crypto.createSign('RSA-SHA256').update(signingInput).sign(privateKeyPem).toString('base64url');
|
|
16
|
+
return `${signingInput}.${signature}`;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function cmdKeygen() {
|
|
20
|
+
const {publicKey, privateKey} = crypto.generateKeyPairSync('rsa', {
|
|
21
|
+
modulusLength: 2048,
|
|
22
|
+
publicKeyEncoding: {type: 'spki', format: 'pem'},
|
|
23
|
+
privateKeyEncoding: {type: 'pkcs8', format: 'pem'},
|
|
24
|
+
});
|
|
25
|
+
fs.writeFileSync('pulsar-private.pem', privateKey);
|
|
26
|
+
fs.writeFileSync('pulsar-public.pem', publicKey);
|
|
27
|
+
console.log('✓ wrote pulsar-private.pem (keep secret, use with --private-key) and pulsar-public.pem');
|
|
28
|
+
console.log(' Put the public key in Info.plist <key>CodePushPublicKey</key> to require signed updates.');
|
|
29
|
+
}
|
|
8
30
|
|
|
9
31
|
const CONFIG_DIR = path.join(os.homedir(), '.pulsar');
|
|
10
32
|
const CONFIG_FILE = path.join(CONFIG_DIR, 'config.json');
|
|
@@ -22,9 +44,12 @@ function saveConfig(config) {
|
|
|
22
44
|
fs.writeFileSync(CONFIG_FILE, JSON.stringify(config, null, 2));
|
|
23
45
|
}
|
|
24
46
|
|
|
25
|
-
async function api(pathname, {method = 'GET', body, headers = {}, raw} = {}) {
|
|
47
|
+
async function api(pathname, {method = 'GET', body, headers = {}, raw, allowAnonymous} = {}) {
|
|
26
48
|
const {apiUrl, token} = loadConfig();
|
|
27
49
|
if (!apiUrl) die('No API url. Run: pulsar login <apiUrl>');
|
|
50
|
+
// Every call except the login handshake must carry a token, so an expired or
|
|
51
|
+
// cleared session fails here rather than silently issuing anonymous requests.
|
|
52
|
+
if (!token && !allowAnonymous) die('Not authenticated. Run: pulsar login <apiUrl>');
|
|
28
53
|
const response = await fetch(apiUrl.replace(/\/$/, '') + pathname, {
|
|
29
54
|
method,
|
|
30
55
|
headers: {
|
|
@@ -92,6 +117,41 @@ function zipContents(contentsDir) {
|
|
|
92
117
|
return zipPath;
|
|
93
118
|
}
|
|
94
119
|
|
|
120
|
+
// Build a differential package: only the files that changed vs the base, plus a hotcodepush.json
|
|
121
|
+
// manifest listing deleted files. The client reconstructs the full bundle from its current copy +
|
|
122
|
+
// this diff, then verifies the result against the target hash. Returns the diff dir, or null if the
|
|
123
|
+
// base couldn't be fetched.
|
|
124
|
+
async function buildDiffPackage(baseZip, newDir) {
|
|
125
|
+
const baseDir = fs.mkdtempSync(path.join(os.tmpdir(), 'pulsar-base-'));
|
|
126
|
+
const basePath = path.join(os.tmpdir(), `pulsar-base-${Date.now()}.zip`);
|
|
127
|
+
fs.writeFileSync(basePath, baseZip);
|
|
128
|
+
execFileSync('unzip', ['-qq', '-o', basePath, '-d', baseDir]);
|
|
129
|
+
|
|
130
|
+
const baseHashes = fileHashes(baseDir);
|
|
131
|
+
const newHashes = fileHashes(newDir);
|
|
132
|
+
const diffDir = fs.mkdtempSync(path.join(os.tmpdir(), 'pulsar-diff-'));
|
|
133
|
+
|
|
134
|
+
for (const [relativePath, hash] of Object.entries(newHashes)) {
|
|
135
|
+
if (baseHashes[relativePath] === hash) continue;
|
|
136
|
+
const dest = path.join(diffDir, relativePath);
|
|
137
|
+
fs.mkdirSync(path.dirname(dest), {recursive: true});
|
|
138
|
+
fs.copyFileSync(path.join(newDir, relativePath), dest);
|
|
139
|
+
}
|
|
140
|
+
const deletedFiles = Object.keys(baseHashes).filter((relativePath) => !(relativePath in newHashes));
|
|
141
|
+
fs.writeFileSync(path.join(diffDir, 'hotcodepush.json'), JSON.stringify({deletedFiles}));
|
|
142
|
+
// The signature isn't part of the content hash, so it's excluded above — but the patched bundle
|
|
143
|
+
// still needs it to verify, so carry it into the diff when the release is signed.
|
|
144
|
+
const signature = path.join(newDir, '.codepushrelease');
|
|
145
|
+
if (fs.existsSync(signature)) fs.copyFileSync(signature, path.join(diffDir, '.codepushrelease'));
|
|
146
|
+
return diffDir;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
async function fetchBlob(apiUrl, appId, fileName) {
|
|
150
|
+
const response = await fetch(`${apiUrl.replace(/\/$/, '')}/download/${appId}/${fileName}`);
|
|
151
|
+
if (!response.ok) throw new Error(`download ${response.status}`);
|
|
152
|
+
return Buffer.from(await response.arrayBuffer());
|
|
153
|
+
}
|
|
154
|
+
|
|
95
155
|
function getFlag(args, name, fallback) {
|
|
96
156
|
const index = args.indexOf(`--${name}`);
|
|
97
157
|
return index >= 0 ? args[index + 1] : fallback;
|
|
@@ -102,7 +162,7 @@ async function cmdLogin(args) {
|
|
|
102
162
|
const email = await prompt('Email: ');
|
|
103
163
|
const password = await prompt('Password: ', true);
|
|
104
164
|
saveConfig({apiUrl, token: undefined});
|
|
105
|
-
const {token, user} = await api('/auth/login', {method: 'POST', body: {email, password}});
|
|
165
|
+
const {token, user} = await api('/auth/login', {method: 'POST', body: {email, password}, allowAnonymous: true});
|
|
106
166
|
saveConfig({apiUrl, token});
|
|
107
167
|
console.log(`✓ logged in as ${user.email} (${user.platformRole})`);
|
|
108
168
|
}
|
|
@@ -118,21 +178,16 @@ async function cmdApps() {
|
|
|
118
178
|
}
|
|
119
179
|
}
|
|
120
180
|
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
const packageHash = computePackageHash(contentsDir);
|
|
133
|
-
const zipPath = zipContents(contentsDir);
|
|
134
|
-
const zip = fs.readFileSync(zipPath);
|
|
135
|
-
console.log(`• hash ${packageHash.slice(0, 12)}… size ${(zip.length / 1e6).toFixed(2)} MB`);
|
|
181
|
+
// Push an already-built (and optionally signed) bundle to one app's deployment, then generate and
|
|
182
|
+
// upload a differential patch against whatever that deployment was serving.
|
|
183
|
+
async function pushBuiltRelease(app, deployment, args, contentsDir, packageHash, zip) {
|
|
184
|
+
let prevLatest = null;
|
|
185
|
+
if (!args.includes('--no-diff')) {
|
|
186
|
+
try {
|
|
187
|
+
const existing = await api(`/apps/${app.id}/deployments/${deployment}/releases`);
|
|
188
|
+
prevLatest = [...existing].reverse().find((entry) => !entry.isDisabled) || existing[existing.length - 1] || null;
|
|
189
|
+
} catch {}
|
|
190
|
+
}
|
|
136
191
|
|
|
137
192
|
const release = await api(`/apps/${app.id}/deployments/${deployment}/release`, {
|
|
138
193
|
method: 'POST',
|
|
@@ -140,19 +195,95 @@ async function cmdRelease(args) {
|
|
|
140
195
|
body: zip,
|
|
141
196
|
headers: {
|
|
142
197
|
'content-type': 'application/zip',
|
|
143
|
-
'x-app-version': target,
|
|
198
|
+
'x-app-version': getFlag(args, 'target'),
|
|
144
199
|
'x-package-hash': packageHash,
|
|
145
200
|
'x-is-mandatory': String(args.includes('--mandatory')),
|
|
146
201
|
'x-disabled': String(args.includes('--disabled')),
|
|
147
202
|
'x-rollout': String(getFlag(args, 'rollout', '100')),
|
|
148
203
|
'x-client-ids': getFlag(args, 'devices', ''),
|
|
149
|
-
'x-description': getFlag(args, 'description', ''),
|
|
204
|
+
'x-description': encodeURIComponent(getFlag(args, 'description', '')),
|
|
150
205
|
},
|
|
151
206
|
});
|
|
152
|
-
const reach = release.clientIds?.length
|
|
153
|
-
? `${release.clientIds.length} device(s)`
|
|
154
|
-
: `${release.rollout}% of devices`;
|
|
207
|
+
const reach = release.clientIds?.length ? `${release.clientIds.length} device(s)` : `${release.rollout}% of devices`;
|
|
155
208
|
console.log(`✓ released ${release.label} to ${app.name}/${deployment} — ${release.isDisabled ? 'DISABLED' : reach}`);
|
|
209
|
+
|
|
210
|
+
if (prevLatest && prevLatest.packageHash && prevLatest.packageHash !== packageHash) {
|
|
211
|
+
try {
|
|
212
|
+
const {apiUrl} = loadConfig();
|
|
213
|
+
const baseZip = await fetchBlob(apiUrl, app.id, `${prevLatest.packageHash}.zip`);
|
|
214
|
+
const diffDir = await buildDiffPackage(baseZip, contentsDir);
|
|
215
|
+
const diffZip = fs.readFileSync(zipContents(diffDir));
|
|
216
|
+
await api(`/apps/${app.id}/deployments/${deployment}/releases/${release.label}/diff`, {
|
|
217
|
+
method: 'POST',
|
|
218
|
+
raw: true,
|
|
219
|
+
body: diffZip,
|
|
220
|
+
headers: {'content-type': 'application/zip', 'x-base-hash': prevLatest.packageHash},
|
|
221
|
+
});
|
|
222
|
+
const saved = Math.round((1 - diffZip.length / zip.length) * 100);
|
|
223
|
+
console.log(`• diff vs ${prevLatest.label}: ${(diffZip.length / 1e3).toFixed(0)} KB (${saved}% smaller)`);
|
|
224
|
+
} catch (err) {
|
|
225
|
+
console.log(`• diff skipped (full bundle still serves): ${err.message}`);
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
return release;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
// Build the JS bundle once, sign it if requested, and return the reusable artifacts.
|
|
232
|
+
function buildOnce(args) {
|
|
233
|
+
const platform = getFlag(args, 'platform', 'ios');
|
|
234
|
+
const contentsDir = fs.mkdtempSync(path.join(os.tmpdir(), 'pulsar-build-'));
|
|
235
|
+
bundle(platform, contentsDir);
|
|
236
|
+
const packageHash = computePackageHash(contentsDir);
|
|
237
|
+
const privateKeyPath = getFlag(args, 'private-key');
|
|
238
|
+
if (privateKeyPath) {
|
|
239
|
+
fs.writeFileSync(path.join(contentsDir, '.codepushrelease'), signCodePushJWT(fs.readFileSync(privateKeyPath, 'utf8'), packageHash));
|
|
240
|
+
console.log('• signed release — devices with CodePushPublicKey set will verify it');
|
|
241
|
+
}
|
|
242
|
+
const zip = fs.readFileSync(zipContents(contentsDir));
|
|
243
|
+
console.log(`• hash ${packageHash.slice(0, 12)}… size ${(zip.length / 1e6).toFixed(2)} MB`);
|
|
244
|
+
return {contentsDir, packageHash, zip};
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
async function cmdRelease(args) {
|
|
248
|
+
const [nameOrId, deployment = 'Staging'] = args;
|
|
249
|
+
if (!nameOrId) die('Usage: pulsar release <app> <Staging|Production> --platform ios|android --target <ver> [--mandatory] [--disabled] [--rollout N] [--devices id1,id2,...] [--private-key key.pem] [--no-diff] [--description "..."]');
|
|
250
|
+
if (!getFlag(args, 'target')) die('--target <binary version> is required (e.g. --target 1.0.0)');
|
|
251
|
+
const app = await resolveApp(nameOrId);
|
|
252
|
+
const {contentsDir, packageHash, zip} = buildOnce(args);
|
|
253
|
+
await pushBuiltRelease(app, deployment, args, contentsDir, packageHash, zip);
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
// Release the same built bundle to several apps at once (shared-codebase / white-label fleets).
|
|
257
|
+
async function cmdReleaseTrain(args) {
|
|
258
|
+
const platform = getFlag(args, 'platform', 'ios');
|
|
259
|
+
if (!getFlag(args, 'target')) die('--target <binary version> is required');
|
|
260
|
+
const orgName = getFlag(args, 'org');
|
|
261
|
+
const all = await api('/apps');
|
|
262
|
+
let apps;
|
|
263
|
+
let deployment;
|
|
264
|
+
if (orgName) {
|
|
265
|
+
deployment = args[0] && !args[0].startsWith('--') ? args[0] : 'Staging';
|
|
266
|
+
const org = (await api('/orgs')).find((entry) => entry.name.toLowerCase() === orgName.toLowerCase());
|
|
267
|
+
if (!org) die(`Organization not found: ${orgName}`);
|
|
268
|
+
apps = all.filter((app) => app.orgId === org.id);
|
|
269
|
+
} else {
|
|
270
|
+
if (!args[0] || args[0].startsWith('--')) die('Usage: pulsar release-train <App1,App2,…|--org Name> <deployment> --platform ios|android --target <ver> [...release flags]');
|
|
271
|
+
deployment = args[1] && !args[1].startsWith('--') ? args[1] : 'Staging';
|
|
272
|
+
const names = args[0].split(',').map((name) => name.trim().toLowerCase());
|
|
273
|
+
apps = all.filter((app) => names.includes(app.name.toLowerCase()) || names.includes(app.id));
|
|
274
|
+
}
|
|
275
|
+
apps = apps.filter((app) => app.os === platform);
|
|
276
|
+
if (!apps.length) die(`No ${platform} apps matched.`);
|
|
277
|
+
|
|
278
|
+
const {contentsDir, packageHash, zip} = buildOnce(args);
|
|
279
|
+
console.log(`• release train → ${apps.length} app(s): ${apps.map((app) => app.name).join(', ')}`);
|
|
280
|
+
for (const app of apps) {
|
|
281
|
+
try {
|
|
282
|
+
await pushBuiltRelease(app, deployment, args, contentsDir, packageHash, zip);
|
|
283
|
+
} catch (err) {
|
|
284
|
+
console.log(`✗ ${app.name}: ${err.message}`);
|
|
285
|
+
}
|
|
286
|
+
}
|
|
156
287
|
}
|
|
157
288
|
|
|
158
289
|
async function cmdPromote(args) {
|
|
@@ -216,19 +347,75 @@ async function cmdPatch(args) {
|
|
|
216
347
|
console.log(`✓ patched ${label}: rollout ${release.rollout}%${release.isMandatory ? ' mandatory' : ''}${release.isDisabled ? ' disabled' : ''}`);
|
|
217
348
|
}
|
|
218
349
|
|
|
350
|
+
// Deployment keys for a whole React Native project (its -Android and -iOS apps), shaped as the
|
|
351
|
+
// PULSAR_* env vars the mobile clients read. DEV maps to Staging, LIVE maps to Production.
|
|
352
|
+
const KEY_VAR = (platform, deployment) => `PULSAR_${platform.toUpperCase()}_${deployment === 'Production' ? 'LIVE' : 'DEV'}_KEY`;
|
|
353
|
+
|
|
354
|
+
async function cmdKeys(args) {
|
|
355
|
+
const project = args.find((arg) => !arg.startsWith('--'));
|
|
356
|
+
if (!project) die('Usage: pulsar keys <project> [--write <path/to/.env>] [--json]');
|
|
357
|
+
const apps = await api('/apps');
|
|
358
|
+
const matches = apps.filter((app) => app.name.toLowerCase().startsWith(`${project.toLowerCase()}-`));
|
|
359
|
+
if (!matches.length) die(`No apps for project "${project}". Known: ${apps.map((app) => app.name).join(', ')}`);
|
|
360
|
+
|
|
361
|
+
const vars = {};
|
|
362
|
+
for (const app of matches) {
|
|
363
|
+
for (const deployment of ['Staging', 'Production']) {
|
|
364
|
+
const key = app.deployments?.[deployment]?.key;
|
|
365
|
+
if (key) vars[KEY_VAR(app.os, deployment)] = key;
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
const target = getFlag(args, 'write');
|
|
370
|
+
if (!target) {
|
|
371
|
+
if (args.includes('--json')) return console.log(JSON.stringify(vars, null, 2));
|
|
372
|
+
for (const [name, value] of Object.entries(vars)) console.log(`${name}=${value}`);
|
|
373
|
+
return;
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
// Merge into the project's .env: update the PULSAR_* lines in place, append any that are
|
|
377
|
+
// missing, and leave every other line (and every other secret) untouched.
|
|
378
|
+
const existing = fs.existsSync(target) ? fs.readFileSync(target, 'utf8') : '';
|
|
379
|
+
const lines = existing.split('\n');
|
|
380
|
+
const written = new Set();
|
|
381
|
+
const merged = lines.map((line) => {
|
|
382
|
+
const match = line.match(/^([A-Z0-9_]+)(\s*=\s*)(.*)$/);
|
|
383
|
+
if (!match || !(match[1] in vars)) return line;
|
|
384
|
+
written.add(match[1]);
|
|
385
|
+
return `${match[1]}${match[2]}${vars[match[1]]}`;
|
|
386
|
+
});
|
|
387
|
+
const missing = Object.keys(vars).filter((name) => !written.has(name));
|
|
388
|
+
if (missing.length) {
|
|
389
|
+
if (merged[merged.length - 1] !== '') merged.push('');
|
|
390
|
+
merged.push('# Pulsar OTA keys (DEV = Staging deployment, LIVE = Production)');
|
|
391
|
+
for (const name of missing) merged.push(`${name}=${vars[name]}`);
|
|
392
|
+
}
|
|
393
|
+
fs.writeFileSync(target, merged.join('\n'));
|
|
394
|
+
console.log(`✓ wrote ${Object.keys(vars).length} key(s) to ${target} (${written.size} updated, ${missing.length} added)`);
|
|
395
|
+
}
|
|
396
|
+
|
|
219
397
|
const [command, ...rest] = process.argv.slice(2);
|
|
220
398
|
const commands = {
|
|
221
399
|
login: cmdLogin,
|
|
222
400
|
apps: cmdApps,
|
|
223
401
|
release: cmdRelease,
|
|
402
|
+
'release-train': cmdReleaseTrain,
|
|
224
403
|
promote: cmdPromote,
|
|
225
404
|
rollback: cmdRollback,
|
|
226
405
|
deployment: cmdDeployment,
|
|
227
406
|
history: cmdHistory,
|
|
228
407
|
patch: cmdPatch,
|
|
408
|
+
keygen: cmdKeygen,
|
|
409
|
+
keys: cmdKeys,
|
|
229
410
|
};
|
|
230
411
|
|
|
412
|
+
const ANONYMOUS_COMMANDS = new Set(['login', 'keygen']);
|
|
413
|
+
|
|
414
|
+
if (commands[command] && !ANONYMOUS_COMMANDS.has(command) && !loadConfig().token) {
|
|
415
|
+
die('Not authenticated. Run: pulsar login <apiUrl>');
|
|
416
|
+
}
|
|
417
|
+
|
|
231
418
|
(commands[command] || (() => {
|
|
232
|
-
console.log('pulsar <login|apps|release|promote|rollback|deployment|history|patch>');
|
|
419
|
+
console.log('pulsar <login|apps|keys|release|release-train|promote|rollback|deployment|history|patch|keygen>');
|
|
233
420
|
process.exit(command ? 1 : 0);
|
|
234
421
|
}))(rest);
|