@novacraft-engineering/pulsar-cli 1.0.0 → 1.1.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/hash.js +11 -0
- package/package.json +28 -7
- package/pulsar.js +228 -27
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.1",
|
|
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: {
|
|
@@ -35,9 +60,23 @@ async function api(pathname, {method = 'GET', body, headers = {}, raw} = {}) {
|
|
|
35
60
|
body: raw ? body : body ? JSON.stringify(body) : undefined,
|
|
36
61
|
});
|
|
37
62
|
const text = await response.text();
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
63
|
+
// Check the status BEFORE parsing: an edge/proxy error (413, 502, 524) returns
|
|
64
|
+
// an HTML page, and parsing that first turned every such failure into an
|
|
65
|
+
// opaque "Unexpected token '<'" SyntaxError instead of the real status.
|
|
66
|
+
if (!response.ok) {
|
|
67
|
+
let detail = text;
|
|
68
|
+
try {
|
|
69
|
+
detail = JSON.parse(text).error || text;
|
|
70
|
+
} catch {
|
|
71
|
+
detail = text.replace(/<[^>]*>/g, ' ').replace(/\s+/g, ' ').trim().slice(0, 300);
|
|
72
|
+
}
|
|
73
|
+
die(`${response.status} ${response.statusText}: ${detail}`);
|
|
74
|
+
}
|
|
75
|
+
try {
|
|
76
|
+
return text ? JSON.parse(text) : {};
|
|
77
|
+
} catch {
|
|
78
|
+
die(`${response.status}: expected JSON, got ${text.slice(0, 200)}`);
|
|
79
|
+
}
|
|
41
80
|
}
|
|
42
81
|
|
|
43
82
|
function die(message) {
|
|
@@ -92,6 +131,41 @@ function zipContents(contentsDir) {
|
|
|
92
131
|
return zipPath;
|
|
93
132
|
}
|
|
94
133
|
|
|
134
|
+
// Build a differential package: only the files that changed vs the base, plus a hotcodepush.json
|
|
135
|
+
// manifest listing deleted files. The client reconstructs the full bundle from its current copy +
|
|
136
|
+
// this diff, then verifies the result against the target hash. Returns the diff dir, or null if the
|
|
137
|
+
// base couldn't be fetched.
|
|
138
|
+
async function buildDiffPackage(baseZip, newDir) {
|
|
139
|
+
const baseDir = fs.mkdtempSync(path.join(os.tmpdir(), 'pulsar-base-'));
|
|
140
|
+
const basePath = path.join(os.tmpdir(), `pulsar-base-${Date.now()}.zip`);
|
|
141
|
+
fs.writeFileSync(basePath, baseZip);
|
|
142
|
+
execFileSync('unzip', ['-qq', '-o', basePath, '-d', baseDir]);
|
|
143
|
+
|
|
144
|
+
const baseHashes = fileHashes(baseDir);
|
|
145
|
+
const newHashes = fileHashes(newDir);
|
|
146
|
+
const diffDir = fs.mkdtempSync(path.join(os.tmpdir(), 'pulsar-diff-'));
|
|
147
|
+
|
|
148
|
+
for (const [relativePath, hash] of Object.entries(newHashes)) {
|
|
149
|
+
if (baseHashes[relativePath] === hash) continue;
|
|
150
|
+
const dest = path.join(diffDir, relativePath);
|
|
151
|
+
fs.mkdirSync(path.dirname(dest), {recursive: true});
|
|
152
|
+
fs.copyFileSync(path.join(newDir, relativePath), dest);
|
|
153
|
+
}
|
|
154
|
+
const deletedFiles = Object.keys(baseHashes).filter((relativePath) => !(relativePath in newHashes));
|
|
155
|
+
fs.writeFileSync(path.join(diffDir, 'hotcodepush.json'), JSON.stringify({deletedFiles}));
|
|
156
|
+
// The signature isn't part of the content hash, so it's excluded above — but the patched bundle
|
|
157
|
+
// still needs it to verify, so carry it into the diff when the release is signed.
|
|
158
|
+
const signature = path.join(newDir, '.codepushrelease');
|
|
159
|
+
if (fs.existsSync(signature)) fs.copyFileSync(signature, path.join(diffDir, '.codepushrelease'));
|
|
160
|
+
return diffDir;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
async function fetchBlob(apiUrl, appId, fileName) {
|
|
164
|
+
const response = await fetch(`${apiUrl.replace(/\/$/, '')}/download/${appId}/${fileName}`);
|
|
165
|
+
if (!response.ok) throw new Error(`download ${response.status}`);
|
|
166
|
+
return Buffer.from(await response.arrayBuffer());
|
|
167
|
+
}
|
|
168
|
+
|
|
95
169
|
function getFlag(args, name, fallback) {
|
|
96
170
|
const index = args.indexOf(`--${name}`);
|
|
97
171
|
return index >= 0 ? args[index + 1] : fallback;
|
|
@@ -102,7 +176,7 @@ async function cmdLogin(args) {
|
|
|
102
176
|
const email = await prompt('Email: ');
|
|
103
177
|
const password = await prompt('Password: ', true);
|
|
104
178
|
saveConfig({apiUrl, token: undefined});
|
|
105
|
-
const {token, user} = await api('/auth/login', {method: 'POST', body: {email, password}});
|
|
179
|
+
const {token, user} = await api('/auth/login', {method: 'POST', body: {email, password}, allowAnonymous: true});
|
|
106
180
|
saveConfig({apiUrl, token});
|
|
107
181
|
console.log(`✓ logged in as ${user.email} (${user.platformRole})`);
|
|
108
182
|
}
|
|
@@ -118,21 +192,16 @@ async function cmdApps() {
|
|
|
118
192
|
}
|
|
119
193
|
}
|
|
120
194
|
|
|
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`);
|
|
195
|
+
// Push an already-built (and optionally signed) bundle to one app's deployment, then generate and
|
|
196
|
+
// upload a differential patch against whatever that deployment was serving.
|
|
197
|
+
async function pushBuiltRelease(app, deployment, args, contentsDir, packageHash, zip) {
|
|
198
|
+
let prevLatest = null;
|
|
199
|
+
if (!args.includes('--no-diff')) {
|
|
200
|
+
try {
|
|
201
|
+
const existing = await api(`/apps/${app.id}/deployments/${deployment}/releases`);
|
|
202
|
+
prevLatest = [...existing].reverse().find((entry) => !entry.isDisabled) || existing[existing.length - 1] || null;
|
|
203
|
+
} catch {}
|
|
204
|
+
}
|
|
136
205
|
|
|
137
206
|
const release = await api(`/apps/${app.id}/deployments/${deployment}/release`, {
|
|
138
207
|
method: 'POST',
|
|
@@ -140,19 +209,95 @@ async function cmdRelease(args) {
|
|
|
140
209
|
body: zip,
|
|
141
210
|
headers: {
|
|
142
211
|
'content-type': 'application/zip',
|
|
143
|
-
'x-app-version': target,
|
|
212
|
+
'x-app-version': getFlag(args, 'target'),
|
|
144
213
|
'x-package-hash': packageHash,
|
|
145
214
|
'x-is-mandatory': String(args.includes('--mandatory')),
|
|
146
215
|
'x-disabled': String(args.includes('--disabled')),
|
|
147
216
|
'x-rollout': String(getFlag(args, 'rollout', '100')),
|
|
148
217
|
'x-client-ids': getFlag(args, 'devices', ''),
|
|
149
|
-
'x-description': getFlag(args, 'description', ''),
|
|
218
|
+
'x-description': encodeURIComponent(getFlag(args, 'description', '')),
|
|
150
219
|
},
|
|
151
220
|
});
|
|
152
|
-
const reach = release.clientIds?.length
|
|
153
|
-
? `${release.clientIds.length} device(s)`
|
|
154
|
-
: `${release.rollout}% of devices`;
|
|
221
|
+
const reach = release.clientIds?.length ? `${release.clientIds.length} device(s)` : `${release.rollout}% of devices`;
|
|
155
222
|
console.log(`✓ released ${release.label} to ${app.name}/${deployment} — ${release.isDisabled ? 'DISABLED' : reach}`);
|
|
223
|
+
|
|
224
|
+
if (prevLatest && prevLatest.packageHash && prevLatest.packageHash !== packageHash) {
|
|
225
|
+
try {
|
|
226
|
+
const {apiUrl} = loadConfig();
|
|
227
|
+
const baseZip = await fetchBlob(apiUrl, app.id, `${prevLatest.packageHash}.zip`);
|
|
228
|
+
const diffDir = await buildDiffPackage(baseZip, contentsDir);
|
|
229
|
+
const diffZip = fs.readFileSync(zipContents(diffDir));
|
|
230
|
+
await api(`/apps/${app.id}/deployments/${deployment}/releases/${release.label}/diff`, {
|
|
231
|
+
method: 'POST',
|
|
232
|
+
raw: true,
|
|
233
|
+
body: diffZip,
|
|
234
|
+
headers: {'content-type': 'application/zip', 'x-base-hash': prevLatest.packageHash},
|
|
235
|
+
});
|
|
236
|
+
const saved = Math.round((1 - diffZip.length / zip.length) * 100);
|
|
237
|
+
console.log(`• diff vs ${prevLatest.label}: ${(diffZip.length / 1e3).toFixed(0)} KB (${saved}% smaller)`);
|
|
238
|
+
} catch (err) {
|
|
239
|
+
console.log(`• diff skipped (full bundle still serves): ${err.message}`);
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
return release;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
// Build the JS bundle once, sign it if requested, and return the reusable artifacts.
|
|
246
|
+
function buildOnce(args) {
|
|
247
|
+
const platform = getFlag(args, 'platform', 'ios');
|
|
248
|
+
const contentsDir = fs.mkdtempSync(path.join(os.tmpdir(), 'pulsar-build-'));
|
|
249
|
+
bundle(platform, contentsDir);
|
|
250
|
+
const packageHash = computePackageHash(contentsDir);
|
|
251
|
+
const privateKeyPath = getFlag(args, 'private-key');
|
|
252
|
+
if (privateKeyPath) {
|
|
253
|
+
fs.writeFileSync(path.join(contentsDir, '.codepushrelease'), signCodePushJWT(fs.readFileSync(privateKeyPath, 'utf8'), packageHash));
|
|
254
|
+
console.log('• signed release — devices with CodePushPublicKey set will verify it');
|
|
255
|
+
}
|
|
256
|
+
const zip = fs.readFileSync(zipContents(contentsDir));
|
|
257
|
+
console.log(`• hash ${packageHash.slice(0, 12)}… size ${(zip.length / 1e6).toFixed(2)} MB`);
|
|
258
|
+
return {contentsDir, packageHash, zip};
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
async function cmdRelease(args) {
|
|
262
|
+
const [nameOrId, deployment = 'Staging'] = args;
|
|
263
|
+
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 "..."]');
|
|
264
|
+
if (!getFlag(args, 'target')) die('--target <binary version> is required (e.g. --target 1.0.0)');
|
|
265
|
+
const app = await resolveApp(nameOrId);
|
|
266
|
+
const {contentsDir, packageHash, zip} = buildOnce(args);
|
|
267
|
+
await pushBuiltRelease(app, deployment, args, contentsDir, packageHash, zip);
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
// Release the same built bundle to several apps at once (shared-codebase / white-label fleets).
|
|
271
|
+
async function cmdReleaseTrain(args) {
|
|
272
|
+
const platform = getFlag(args, 'platform', 'ios');
|
|
273
|
+
if (!getFlag(args, 'target')) die('--target <binary version> is required');
|
|
274
|
+
const orgName = getFlag(args, 'org');
|
|
275
|
+
const all = await api('/apps');
|
|
276
|
+
let apps;
|
|
277
|
+
let deployment;
|
|
278
|
+
if (orgName) {
|
|
279
|
+
deployment = args[0] && !args[0].startsWith('--') ? args[0] : 'Staging';
|
|
280
|
+
const org = (await api('/orgs')).find((entry) => entry.name.toLowerCase() === orgName.toLowerCase());
|
|
281
|
+
if (!org) die(`Organization not found: ${orgName}`);
|
|
282
|
+
apps = all.filter((app) => app.orgId === org.id);
|
|
283
|
+
} else {
|
|
284
|
+
if (!args[0] || args[0].startsWith('--')) die('Usage: pulsar release-train <App1,App2,…|--org Name> <deployment> --platform ios|android --target <ver> [...release flags]');
|
|
285
|
+
deployment = args[1] && !args[1].startsWith('--') ? args[1] : 'Staging';
|
|
286
|
+
const names = args[0].split(',').map((name) => name.trim().toLowerCase());
|
|
287
|
+
apps = all.filter((app) => names.includes(app.name.toLowerCase()) || names.includes(app.id));
|
|
288
|
+
}
|
|
289
|
+
apps = apps.filter((app) => app.os === platform);
|
|
290
|
+
if (!apps.length) die(`No ${platform} apps matched.`);
|
|
291
|
+
|
|
292
|
+
const {contentsDir, packageHash, zip} = buildOnce(args);
|
|
293
|
+
console.log(`• release train → ${apps.length} app(s): ${apps.map((app) => app.name).join(', ')}`);
|
|
294
|
+
for (const app of apps) {
|
|
295
|
+
try {
|
|
296
|
+
await pushBuiltRelease(app, deployment, args, contentsDir, packageHash, zip);
|
|
297
|
+
} catch (err) {
|
|
298
|
+
console.log(`✗ ${app.name}: ${err.message}`);
|
|
299
|
+
}
|
|
300
|
+
}
|
|
156
301
|
}
|
|
157
302
|
|
|
158
303
|
async function cmdPromote(args) {
|
|
@@ -216,19 +361,75 @@ async function cmdPatch(args) {
|
|
|
216
361
|
console.log(`✓ patched ${label}: rollout ${release.rollout}%${release.isMandatory ? ' mandatory' : ''}${release.isDisabled ? ' disabled' : ''}`);
|
|
217
362
|
}
|
|
218
363
|
|
|
364
|
+
// Deployment keys for a whole React Native project (its -Android and -iOS apps), shaped as the
|
|
365
|
+
// PULSAR_* env vars the mobile clients read. DEV maps to Staging, LIVE maps to Production.
|
|
366
|
+
const KEY_VAR = (platform, deployment) => `PULSAR_${platform.toUpperCase()}_${deployment === 'Production' ? 'LIVE' : 'DEV'}_KEY`;
|
|
367
|
+
|
|
368
|
+
async function cmdKeys(args) {
|
|
369
|
+
const project = args.find((arg) => !arg.startsWith('--'));
|
|
370
|
+
if (!project) die('Usage: pulsar keys <project> [--write <path/to/.env>] [--json]');
|
|
371
|
+
const apps = await api('/apps');
|
|
372
|
+
const matches = apps.filter((app) => app.name.toLowerCase().startsWith(`${project.toLowerCase()}-`));
|
|
373
|
+
if (!matches.length) die(`No apps for project "${project}". Known: ${apps.map((app) => app.name).join(', ')}`);
|
|
374
|
+
|
|
375
|
+
const vars = {};
|
|
376
|
+
for (const app of matches) {
|
|
377
|
+
for (const deployment of ['Staging', 'Production']) {
|
|
378
|
+
const key = app.deployments?.[deployment]?.key;
|
|
379
|
+
if (key) vars[KEY_VAR(app.os, deployment)] = key;
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
const target = getFlag(args, 'write');
|
|
384
|
+
if (!target) {
|
|
385
|
+
if (args.includes('--json')) return console.log(JSON.stringify(vars, null, 2));
|
|
386
|
+
for (const [name, value] of Object.entries(vars)) console.log(`${name}=${value}`);
|
|
387
|
+
return;
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
// Merge into the project's .env: update the PULSAR_* lines in place, append any that are
|
|
391
|
+
// missing, and leave every other line (and every other secret) untouched.
|
|
392
|
+
const existing = fs.existsSync(target) ? fs.readFileSync(target, 'utf8') : '';
|
|
393
|
+
const lines = existing.split('\n');
|
|
394
|
+
const written = new Set();
|
|
395
|
+
const merged = lines.map((line) => {
|
|
396
|
+
const match = line.match(/^([A-Z0-9_]+)(\s*=\s*)(.*)$/);
|
|
397
|
+
if (!match || !(match[1] in vars)) return line;
|
|
398
|
+
written.add(match[1]);
|
|
399
|
+
return `${match[1]}${match[2]}${vars[match[1]]}`;
|
|
400
|
+
});
|
|
401
|
+
const missing = Object.keys(vars).filter((name) => !written.has(name));
|
|
402
|
+
if (missing.length) {
|
|
403
|
+
if (merged[merged.length - 1] !== '') merged.push('');
|
|
404
|
+
merged.push('# Pulsar OTA keys (DEV = Staging deployment, LIVE = Production)');
|
|
405
|
+
for (const name of missing) merged.push(`${name}=${vars[name]}`);
|
|
406
|
+
}
|
|
407
|
+
fs.writeFileSync(target, merged.join('\n'));
|
|
408
|
+
console.log(`✓ wrote ${Object.keys(vars).length} key(s) to ${target} (${written.size} updated, ${missing.length} added)`);
|
|
409
|
+
}
|
|
410
|
+
|
|
219
411
|
const [command, ...rest] = process.argv.slice(2);
|
|
220
412
|
const commands = {
|
|
221
413
|
login: cmdLogin,
|
|
222
414
|
apps: cmdApps,
|
|
223
415
|
release: cmdRelease,
|
|
416
|
+
'release-train': cmdReleaseTrain,
|
|
224
417
|
promote: cmdPromote,
|
|
225
418
|
rollback: cmdRollback,
|
|
226
419
|
deployment: cmdDeployment,
|
|
227
420
|
history: cmdHistory,
|
|
228
421
|
patch: cmdPatch,
|
|
422
|
+
keygen: cmdKeygen,
|
|
423
|
+
keys: cmdKeys,
|
|
229
424
|
};
|
|
230
425
|
|
|
426
|
+
const ANONYMOUS_COMMANDS = new Set(['login', 'keygen']);
|
|
427
|
+
|
|
428
|
+
if (commands[command] && !ANONYMOUS_COMMANDS.has(command) && !loadConfig().token) {
|
|
429
|
+
die('Not authenticated. Run: pulsar login <apiUrl>');
|
|
430
|
+
}
|
|
431
|
+
|
|
231
432
|
(commands[command] || (() => {
|
|
232
|
-
console.log('pulsar <login|apps|release|promote|rollback|deployment|history|patch>');
|
|
433
|
+
console.log('pulsar <login|apps|keys|release|release-train|promote|rollback|deployment|history|patch|keygen>');
|
|
233
434
|
process.exit(command ? 1 : 0);
|
|
234
435
|
}))(rest);
|