@novacraft-engineering/pulsar-cli 1.2.0 → 1.2.2
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/README.md +32 -6
- package/package.json +13 -9
- package/pulsar.js +126 -38
package/README.md
CHANGED
|
@@ -1,19 +1,27 @@
|
|
|
1
1
|
# @novacraft-engineering/pulsar-cli
|
|
2
2
|
|
|
3
|
-
Release CLI for [Pulsar](https://
|
|
3
|
+
Release CLI for [Pulsar](https://usepulsar.dev) — the free, self-hostable **CodePush alternative** for React Native. Bundle your JavaScript, hash it exactly the way the on-device client does, and ship it over-the-air to live apps in seconds. No app-store review.
|
|
4
|
+
|
|
5
|
+
Microsoft App Center CodePush was retired in March 2025. Pulsar speaks the same CodePush protocol, so migrating is a deployment key and a server URL — not a rewrite. See [usepulsar.dev/codepush-alternative](https://usepulsar.dev/codepush-alternative).
|
|
4
6
|
|
|
5
7
|
## Install
|
|
6
8
|
|
|
7
9
|
```bash
|
|
8
10
|
npm i -g @novacraft-engineering/pulsar-cli
|
|
9
11
|
# or run without installing:
|
|
10
|
-
npx @novacraft-engineering/pulsar-cli login https://api.
|
|
12
|
+
npx @novacraft-engineering/pulsar-cli login https://api.usepulsar.dev
|
|
11
13
|
```
|
|
12
14
|
|
|
15
|
+
`<apiUrl>` is whatever Pulsar server you release against. `api.usepulsar.dev`
|
|
16
|
+
is the hosted one; point it at your own if you self-host. The CLI talks to the
|
|
17
|
+
Pulsar API and nothing else, so it has no host compiled into it.
|
|
18
|
+
|
|
13
19
|
## Commands
|
|
14
20
|
|
|
15
21
|
```bash
|
|
16
|
-
pulsar login <apiUrl> # authenticate, saved to ~/.pulsar/config.json
|
|
22
|
+
pulsar login <apiUrl> # authenticate (API key or browser), saved to ~/.pulsar/config.json
|
|
23
|
+
# --api-key paste a key from Settings → API keys
|
|
24
|
+
# --browser sign in via the dashboard, no key to manage
|
|
17
25
|
pulsar apps # list your apps and deployment keys
|
|
18
26
|
|
|
19
27
|
pulsar release <app> <deployment> --platform ios|android --target <ver> \
|
|
@@ -35,9 +43,6 @@ pulsar patch <app> <deployment> <label> [--rollout N] [--mandatory] [--disable]
|
|
|
35
43
|
- `--disabled` — upload the bundle but don't serve it (flip on later with `pulsar patch … --enable`).
|
|
36
44
|
- `--target <range>` — semver range of native binary versions the release applies to.
|
|
37
45
|
|
|
38
|
-
```bash
|
|
39
|
-
```
|
|
40
|
-
|
|
41
46
|
## How a release works
|
|
42
47
|
|
|
43
48
|
`release` runs the React Native bundler, computes the **CodePush package hash** (the exact
|
|
@@ -47,6 +52,27 @@ then downloads it in the background and swaps it in on next launch.
|
|
|
47
52
|
|
|
48
53
|
Auth is read from `PULSAR_API_URL` / `PULSAR_TOKEN` env, or `~/.pulsar/config.json` (written by `pulsar login`). Nothing is committed.
|
|
49
54
|
|
|
55
|
+
## Use it from CI
|
|
56
|
+
|
|
57
|
+
Both env vars are all a GitHub Actions / GitLab CI job needs:
|
|
58
|
+
|
|
59
|
+
```yaml
|
|
60
|
+
- run: npm i -g @novacraft-engineering/pulsar-cli
|
|
61
|
+
- run: pulsar release MyApp Production --platform ios --target 1.4.0 --rollout 25
|
|
62
|
+
env:
|
|
63
|
+
PULSAR_API_URL: https://api.usepulsar.dev
|
|
64
|
+
PULSAR_TOKEN: ${{ secrets.PULSAR_TOKEN }}
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
Or connect the repo in the dashboard and fire a release for any commit without leaving the browser.
|
|
68
|
+
|
|
69
|
+
## Related
|
|
70
|
+
|
|
71
|
+
- [`@novacraft-engineering/pulsar-code-push`](https://github.com/Novacraft-Africa/pulsar-code-push) — the React Native client this CLI ships to
|
|
72
|
+
- [Pulsar vs CodePush vs RevoPush](https://usepulsar.dev/blog/codepush-vs-revopush-vs-pulsar)
|
|
73
|
+
- [How React Native OTA updates actually work](https://usepulsar.dev/blog/how-react-native-ota-updates-work)
|
|
74
|
+
- [Staged rollouts and instant rollback](https://usepulsar.dev/blog/staged-rollouts-instant-rollback-react-native)
|
|
75
|
+
|
|
50
76
|
## License
|
|
51
77
|
|
|
52
78
|
MIT © Novacraft Engineering
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@novacraft-engineering/pulsar-cli",
|
|
3
|
-
"version": "1.2.
|
|
4
|
-
"description": "Pulsar OTA release CLI
|
|
3
|
+
"version": "1.2.2",
|
|
4
|
+
"description": "Pulsar OTA release CLI \u2014 bundle, hash, and ship React Native updates over-the-air.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
7
7
|
"pulsar": "./pulsar.js"
|
|
@@ -16,7 +16,7 @@
|
|
|
16
16
|
"type": "git",
|
|
17
17
|
"url": "git+https://github.com/Novacraft-Africa/pulsar-cli.git"
|
|
18
18
|
},
|
|
19
|
-
"homepage": "https://
|
|
19
|
+
"homepage": "https://usepulsar.dev",
|
|
20
20
|
"license": "MIT",
|
|
21
21
|
"author": "Novacraft Engineering",
|
|
22
22
|
"publishConfig": {
|
|
@@ -26,15 +26,19 @@
|
|
|
26
26
|
"node": ">=18"
|
|
27
27
|
},
|
|
28
28
|
"scripts": {
|
|
29
|
-
"test": "node test/hash.test.js"
|
|
29
|
+
"test": "node test/hash.test.js && node test/security.test.js"
|
|
30
30
|
},
|
|
31
31
|
"keywords": [
|
|
32
|
-
"
|
|
33
|
-
"ota",
|
|
32
|
+
"cli",
|
|
34
33
|
"code-push",
|
|
34
|
+
"codepush-alternative",
|
|
35
|
+
"hot-update",
|
|
36
|
+
"ota",
|
|
37
|
+
"ota-updates",
|
|
38
|
+
"over-the-air",
|
|
35
39
|
"pulsar",
|
|
36
|
-
"
|
|
37
|
-
"
|
|
38
|
-
"
|
|
40
|
+
"react-native",
|
|
41
|
+
"react-native-ota",
|
|
42
|
+
"release"
|
|
39
43
|
]
|
|
40
44
|
}
|
package/pulsar.js
CHANGED
|
@@ -23,7 +23,7 @@ function cmdKeygen() {
|
|
|
23
23
|
publicKeyEncoding: {type: 'spki', format: 'pem'},
|
|
24
24
|
privateKeyEncoding: {type: 'pkcs8', format: 'pem'},
|
|
25
25
|
});
|
|
26
|
-
fs.writeFileSync('pulsar-private.pem', privateKey);
|
|
26
|
+
fs.writeFileSync('pulsar-private.pem', privateKey, {mode: 0o600});
|
|
27
27
|
fs.writeFileSync('pulsar-public.pem', publicKey);
|
|
28
28
|
console.log('✓ wrote pulsar-private.pem (keep secret, use with --private-key) and pulsar-public.pem');
|
|
29
29
|
console.log(' Put the public key in Info.plist <key>CodePushPublicKey</key> to require signed updates.');
|
|
@@ -41,8 +41,11 @@ function loadConfig() {
|
|
|
41
41
|
}
|
|
42
42
|
|
|
43
43
|
function saveConfig(config) {
|
|
44
|
-
|
|
45
|
-
|
|
44
|
+
// The config holds a bearer token — keep the dir and file owner-only so other local users on a
|
|
45
|
+
// shared host can't read the session.
|
|
46
|
+
fs.mkdirSync(CONFIG_DIR, {recursive: true, mode: 0o700});
|
|
47
|
+
fs.writeFileSync(CONFIG_FILE, JSON.stringify(config, null, 2), {mode: 0o600});
|
|
48
|
+
try { fs.chmodSync(CONFIG_FILE, 0o600); } catch { /* best-effort on pre-existing files */ }
|
|
46
49
|
}
|
|
47
50
|
|
|
48
51
|
async function api(pathname, {method = 'GET', body, headers = {}, raw, allowAnonymous, onUploadProgress} = {}) {
|
|
@@ -153,19 +156,26 @@ function dashboardUrl(apiUrl) {
|
|
|
153
156
|
// Browser login: spin up a throwaway localhost server, open the dashboard's /cli-login page which
|
|
154
157
|
// (once you're signed in) redirects back here with a token. No password ever touches the terminal.
|
|
155
158
|
function browserLogin(apiUrl) {
|
|
159
|
+
// A random nonce ties the callback to THIS login: the dashboard echoes it back and we reject any
|
|
160
|
+
// callback whose state doesn't match, so a stray page spraying the loopback port range can't inject
|
|
161
|
+
// its own token into our config (login fixation).
|
|
162
|
+
const state = crypto.randomUUID();
|
|
156
163
|
return new Promise((resolve, reject) => {
|
|
157
164
|
const server = http.createServer((req, res) => {
|
|
158
165
|
const requestUrl = new URL(req.url, 'http://127.0.0.1');
|
|
166
|
+
if (requestUrl.pathname !== '/callback') { res.writeHead(204); res.end(); return; }
|
|
159
167
|
const token = requestUrl.searchParams.get('token');
|
|
160
|
-
|
|
161
|
-
res.
|
|
168
|
+
const ok = token && requestUrl.searchParams.get('state') === state;
|
|
169
|
+
res.writeHead(ok ? 200 : 400, {'content-type': 'text/html'});
|
|
170
|
+
res.end(`<!doctype html><meta charset="utf-8"><body style="font-family:system-ui;background:#080a0f;color:#eef2f7;display:grid;place-items:center;height:100vh;margin:0"><div style="text-align:center"><h2 style="font-weight:600">${ok ? 'Pulsar CLI authorized ✓' : 'Authorization failed'}</h2><p style="color:#8f98a6">You can close this tab and return to your terminal.</p></div></body>`);
|
|
171
|
+
if (!ok) return;
|
|
162
172
|
setTimeout(() => { try { server.close(); } catch {} }, 100);
|
|
163
|
-
|
|
173
|
+
resolve(token);
|
|
164
174
|
});
|
|
165
175
|
server.on('error', reject);
|
|
166
176
|
server.listen(0, '127.0.0.1', () => {
|
|
167
177
|
const {port} = server.address();
|
|
168
|
-
const authUrl = `${dashboardUrl(apiUrl)}/cli-login?port=${port}`;
|
|
178
|
+
const authUrl = `${dashboardUrl(apiUrl)}/cli-login?port=${port}&state=${state}`;
|
|
169
179
|
console.log(`\n Authorize the CLI in your browser:\n ${authUrl}\n`);
|
|
170
180
|
openBrowser(authUrl);
|
|
171
181
|
console.log(' Waiting for authorization…');
|
|
@@ -247,6 +257,35 @@ async function fetchBlob(apiUrl, appId, fileName) {
|
|
|
247
257
|
return Buffer.from(await response.arrayBuffer());
|
|
248
258
|
}
|
|
249
259
|
|
|
260
|
+
const splitList = (value, separator) => String(value || '').split(separator).map((entry) => entry.trim()).filter(Boolean);
|
|
261
|
+
|
|
262
|
+
// --schedule takes either an absolute instant ("2026-09-20T09:00:00Z", "2026-09-20 09:00") or a
|
|
263
|
+
// relative offset from now ("+90m", "+6h", "+3d"). Resolved here so the API only sees ISO.
|
|
264
|
+
function parseSchedule(value) {
|
|
265
|
+
if (value === undefined) return null;
|
|
266
|
+
const raw = String(value).trim();
|
|
267
|
+
const relative = raw.match(/^\+(\d+(?:\.\d+)?)\s*(m|h|d)$/i);
|
|
268
|
+
if (relative) {
|
|
269
|
+
const unit = {m: 60000, h: 3600000, d: 86400000}[relative[2].toLowerCase()];
|
|
270
|
+
return new Date(Date.now() + Number(relative[1]) * unit).toISOString();
|
|
271
|
+
}
|
|
272
|
+
const at = Date.parse(raw.includes('T') || !raw.includes(' ') ? raw : raw.replace(' ', 'T'));
|
|
273
|
+
if (!Number.isFinite(at)) die(`Invalid --schedule "${raw}". Use an ISO instant or a relative offset like +6h.`);
|
|
274
|
+
if (at <= Date.now()) die(`--schedule "${raw}" is in the past.`);
|
|
275
|
+
return new Date(at).toISOString();
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
// --ramp takes "<to>@<hours>" ("100@48") or just "<hours>" (ramps to 100).
|
|
279
|
+
function parseRamp(value) {
|
|
280
|
+
if (value === undefined) return null;
|
|
281
|
+
const raw = String(value).trim();
|
|
282
|
+
const match = raw.match(/^(?:(\d{1,3})@)?(\d+(?:\.\d+)?)$/);
|
|
283
|
+
if (!match) die(`Invalid --ramp "${raw}". Use <toPercent>@<hours>, e.g. 100@48.`);
|
|
284
|
+
const to = match[1] === undefined ? 100 : Number(match[1]);
|
|
285
|
+
if (to > 100) die('--ramp target cannot exceed 100.');
|
|
286
|
+
return {to, hours: Number(match[2])};
|
|
287
|
+
}
|
|
288
|
+
|
|
250
289
|
function getFlag(args, name, fallback) {
|
|
251
290
|
const index = args.indexOf(`--${name}`);
|
|
252
291
|
return index >= 0 ? args[index + 1] : fallback;
|
|
@@ -258,17 +297,31 @@ async function cmdLogin(args) {
|
|
|
258
297
|
|
|
259
298
|
let mode;
|
|
260
299
|
if (args.includes('--browser')) mode = 'browser';
|
|
300
|
+
else if (args.includes('--api-key') || args.includes('--apikey')) mode = 'apikey';
|
|
261
301
|
else if (args.includes('--terminal')) mode = 'terminal';
|
|
262
302
|
else {
|
|
263
|
-
const answer = (await prompt('Sign in with [
|
|
264
|
-
mode = answer.startsWith('
|
|
303
|
+
const answer = (await prompt('Sign in with [a]pi key or [b]rowser? (a/b): ')).toLowerCase();
|
|
304
|
+
mode = answer.startsWith('a') ? 'apikey' : 'browser';
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
if (mode === 'apikey') {
|
|
308
|
+
const token = (await prompt('Paste your Pulsar API key: ', true)).trim();
|
|
309
|
+
if (!token) die('No API key provided.');
|
|
310
|
+
saveConfig({apiUrl, token});
|
|
311
|
+
const me = await api('/auth/me').catch(() => null);
|
|
312
|
+
if (!me?.user?.email) {
|
|
313
|
+
saveConfig({apiUrl, token: undefined});
|
|
314
|
+
die('That API key was rejected. Create one in the dashboard → Settings → API keys.');
|
|
315
|
+
}
|
|
316
|
+
console.log(`✓ logged in as ${me.user.email} (api key)`);
|
|
317
|
+
return;
|
|
265
318
|
}
|
|
266
319
|
|
|
267
320
|
if (mode === 'browser') {
|
|
268
321
|
const token = await browserLogin(apiUrl);
|
|
269
322
|
saveConfig({apiUrl, token});
|
|
270
323
|
const me = await api('/auth/me').catch(() => null);
|
|
271
|
-
console.log(`✓ logged in${me?.email ? ` as ${me.email}` : ''} (browser)`);
|
|
324
|
+
console.log(`✓ logged in${me?.user?.email ? ` as ${me.user.email}` : ''} (browser)`);
|
|
272
325
|
return;
|
|
273
326
|
}
|
|
274
327
|
|
|
@@ -301,6 +354,8 @@ async function pushBuiltRelease(app, deployment, args, contentsDir, packageHash,
|
|
|
301
354
|
} catch {}
|
|
302
355
|
}
|
|
303
356
|
|
|
357
|
+
const startsAt = parseSchedule(getFlag(args, 'schedule'));
|
|
358
|
+
const ramp = parseRamp(getFlag(args, 'ramp'));
|
|
304
359
|
const release = await api(`/apps/${app.id}/deployments/${deployment}/release`, {
|
|
305
360
|
method: 'POST',
|
|
306
361
|
raw: true,
|
|
@@ -314,11 +369,25 @@ async function pushBuiltRelease(app, deployment, args, contentsDir, packageHash,
|
|
|
314
369
|
'x-disabled': String(args.includes('--disabled')),
|
|
315
370
|
'x-rollout': String(getFlag(args, 'rollout', '100')),
|
|
316
371
|
'x-client-ids': getFlag(args, 'devices', ''),
|
|
372
|
+
// JSON-encoded: iOS model ids contain a comma ("iPhone14,3"), so separate models with ';'.
|
|
373
|
+
'x-target-devices': JSON.stringify(splitList(getFlag(args, 'device-models', ''), ';')),
|
|
374
|
+
'x-target-users': JSON.stringify(splitList(getFlag(args, 'users', ''), ',')),
|
|
375
|
+
'x-target-from': JSON.stringify(splitList(getFlag(args, 'from', ''), ',')),
|
|
317
376
|
'x-description': encodeURIComponent(getFlag(args, 'description', '')),
|
|
377
|
+
...(startsAt ? {'x-starts-at': startsAt} : {}),
|
|
378
|
+
...(ramp ? {'x-rollout-to': String(ramp.to), 'x-rollout-hours': String(ramp.hours)} : {}),
|
|
318
379
|
},
|
|
319
380
|
});
|
|
320
|
-
const
|
|
381
|
+
const scopes = [
|
|
382
|
+
release.clientIds?.length ? `${release.clientIds.length} device id(s)` : null,
|
|
383
|
+
release.targetDevices?.length ? `${release.targetDevices.length} device model(s)` : null,
|
|
384
|
+
release.targetUserIds?.length ? `${release.targetUserIds.length} user(s)` : null,
|
|
385
|
+
release.targetFromLabels?.length ? `devices on ${release.targetFromLabels.join('/')}` : null,
|
|
386
|
+
].filter(Boolean);
|
|
387
|
+
const reach = scopes.length ? scopes.join(' + ') : `${release.rollout}% of devices`;
|
|
321
388
|
console.log(`✓ released ${release.label} to ${app.name}/${deployment} — ${release.isDisabled ? 'DISABLED' : reach}`);
|
|
389
|
+
if (release.startsAt) console.log(` scheduled — goes live ${new Date(release.startsAt).toLocaleString()}`);
|
|
390
|
+
if (release.rolloutRamp) console.log(` gradual rollout — ${release.rollout}% to ${release.rolloutRamp.to}% over ${release.rolloutRamp.hours}h`);
|
|
322
391
|
|
|
323
392
|
if (prevLatest && prevLatest.packageHash && prevLatest.packageHash !== packageHash) {
|
|
324
393
|
try {
|
|
@@ -360,7 +429,7 @@ function buildOnce(args) {
|
|
|
360
429
|
|
|
361
430
|
async function cmdRelease(args) {
|
|
362
431
|
const [nameOrId, deployment = 'Staging'] = args;
|
|
363
|
-
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 "..."]');
|
|
432
|
+
if (!nameOrId) die('Usage: pulsar release <app> <Staging|Production> --platform ios|android --target <ver> [--mandatory] [--disabled] [--rollout N] [--schedule +6h|ISO] [--ramp 100@48] [--from v6,ota,binary] [--devices id1,id2,...] [--device-models model1;model2] [--users u1,u2] [--private-key key.pem] [--no-diff] [--description "..."]');
|
|
364
433
|
if (!getFlag(args, 'target')) die('--target <binary version> is required (e.g. --target 1.0.0)');
|
|
365
434
|
const app = await resolveApp(nameOrId, getFlag(args, 'platform', 'ios'));
|
|
366
435
|
if (app.os !== getFlag(args, 'platform', 'ios')) {
|
|
@@ -444,13 +513,17 @@ async function cmdHistory(args) {
|
|
|
444
513
|
if (!releases.length) return console.log('(no releases)');
|
|
445
514
|
for (const release of [...releases].reverse()) {
|
|
446
515
|
const flags = `${release.isMandatory ? ' [mandatory]' : ''}${release.isDisabled ? ' [disabled]' : ''}`;
|
|
447
|
-
|
|
516
|
+
const rollout = release.currentRollout ?? release.rollout;
|
|
517
|
+
const state = release.state && release.state !== 'live' ? ` [${release.state}]` : '';
|
|
518
|
+
const when = release.startsAt ? ` starts ${new Date(release.startsAt).toLocaleString()}` : '';
|
|
519
|
+
const ramping = release.rolloutRamp ? ` ->${release.rolloutRamp.to}%/${release.rolloutRamp.hours}h` : '';
|
|
520
|
+
console.log(` ${release.label.padEnd(5)} target ${String(release.appVersion).padEnd(10)} rollout ${String(rollout).padStart(3)}%${ramping}${flags}${state}${when} ${release.description || ''}`);
|
|
448
521
|
}
|
|
449
522
|
}
|
|
450
523
|
|
|
451
524
|
async function cmdPatch(args) {
|
|
452
525
|
const [nameOrId, deployment, label] = args;
|
|
453
|
-
if (!label) die('Usage: pulsar patch <app> <deployment> <label> [--rollout N] [--mandatory] [--disable] [--enable] [--devices id1,id2,...]');
|
|
526
|
+
if (!label) die('Usage: pulsar patch <app> <deployment> <label> [--rollout N] [--schedule +6h|ISO|none] [--ramp 100@48|none] [--from v6,ota,binary] [--mandatory] [--disable] [--enable] [--devices id1,id2,...] [--device-models model1;model2] [--users u1,u2]');
|
|
454
527
|
const app = await resolveApp(nameOrId, getFlag(args, 'platform'));
|
|
455
528
|
const body = {};
|
|
456
529
|
const rollout = getFlag(args, 'rollout');
|
|
@@ -459,9 +532,19 @@ async function cmdPatch(args) {
|
|
|
459
532
|
if (args.includes('--disable')) body.isDisabled = true;
|
|
460
533
|
if (args.includes('--enable')) body.isDisabled = false;
|
|
461
534
|
const devices = getFlag(args, 'devices');
|
|
462
|
-
if (devices !== undefined) body.clientIds = devices
|
|
535
|
+
if (devices !== undefined) body.clientIds = splitList(devices, ',');
|
|
536
|
+
const deviceModels = getFlag(args, 'device-models');
|
|
537
|
+
if (deviceModels !== undefined) body.targetDevices = splitList(deviceModels, ';');
|
|
538
|
+
const users = getFlag(args, 'users');
|
|
539
|
+
if (users !== undefined) body.targetUserIds = splitList(users, ',');
|
|
540
|
+
const from = getFlag(args, 'from');
|
|
541
|
+
if (from !== undefined) body.targetFromLabels = from === 'none' ? [] : splitList(from, ',');
|
|
542
|
+
const schedule = getFlag(args, 'schedule');
|
|
543
|
+
if (schedule !== undefined) body.startsAt = schedule === 'none' ? null : parseSchedule(schedule);
|
|
544
|
+
const ramp = getFlag(args, 'ramp');
|
|
545
|
+
if (ramp !== undefined) body.rolloutRamp = ramp === 'none' ? null : parseRamp(ramp);
|
|
463
546
|
const release = await api(`/apps/${app.id}/deployments/${deployment}/releases/${label}`, {method: 'PATCH', body});
|
|
464
|
-
console.log(`✓ patched ${label}: rollout ${release.rollout}%${release.isMandatory ? ' mandatory' : ''}${release.isDisabled ? ' disabled' : ''}`);
|
|
547
|
+
console.log(`✓ patched ${label}: rollout ${release.rollout}%${release.rolloutRamp ? ` to ${release.rolloutRamp.to}% over ${release.rolloutRamp.hours}h` : ''}${release.startsAt ? ` starting ${new Date(release.startsAt).toLocaleString()}` : ''}${release.isMandatory ? ' mandatory' : ''}${release.isDisabled ? ' disabled' : ''}`);
|
|
465
548
|
}
|
|
466
549
|
|
|
467
550
|
// Deployment keys for a whole React Native project (its -Android and -iOS apps), shaped as the
|
|
@@ -511,28 +594,33 @@ async function cmdKeys(args) {
|
|
|
511
594
|
console.log(`✓ wrote ${Object.keys(vars).length} key(s) to ${target} (${written.size} updated, ${missing.length} added)`);
|
|
512
595
|
}
|
|
513
596
|
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
597
|
+
export {saveConfig, browserLogin};
|
|
598
|
+
|
|
599
|
+
// Only dispatch when run as the CLI — importing the module (e.g. from tests) must not execute a command.
|
|
600
|
+
if (import.meta.url === `file://${process.argv[1]}`) {
|
|
601
|
+
const [command, ...rest] = process.argv.slice(2);
|
|
602
|
+
const commands = {
|
|
603
|
+
login: cmdLogin,
|
|
604
|
+
apps: cmdApps,
|
|
605
|
+
release: cmdRelease,
|
|
606
|
+
'release-train': cmdReleaseTrain,
|
|
607
|
+
promote: cmdPromote,
|
|
608
|
+
rollback: cmdRollback,
|
|
609
|
+
deployment: cmdDeployment,
|
|
610
|
+
history: cmdHistory,
|
|
611
|
+
patch: cmdPatch,
|
|
612
|
+
keygen: cmdKeygen,
|
|
613
|
+
keys: cmdKeys,
|
|
614
|
+
};
|
|
528
615
|
|
|
529
|
-
const ANONYMOUS_COMMANDS = new Set(['login', 'keygen']);
|
|
616
|
+
const ANONYMOUS_COMMANDS = new Set(['login', 'keygen']);
|
|
530
617
|
|
|
531
|
-
if (commands[command] && !ANONYMOUS_COMMANDS.has(command) && !loadConfig().token) {
|
|
532
|
-
|
|
533
|
-
}
|
|
618
|
+
if (commands[command] && !ANONYMOUS_COMMANDS.has(command) && !loadConfig().token) {
|
|
619
|
+
die('Not authenticated. Run: pulsar login <apiUrl>');
|
|
620
|
+
}
|
|
534
621
|
|
|
535
|
-
(commands[command] || (() => {
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
}))(rest);
|
|
622
|
+
(commands[command] || (() => {
|
|
623
|
+
console.log('pulsar <login|apps|keys|release|release-train|promote|rollback|deployment|history|patch|keygen>');
|
|
624
|
+
process.exit(command ? 1 : 0);
|
|
625
|
+
}))(rest);
|
|
626
|
+
}
|