Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 0 additions & 17 deletions .eslintrc.json

This file was deleted.

40 changes: 38 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,8 @@ To build it from source instead:

Before upgrading a CI pipeline to 1.2.0, note two changes: an unknown flag is now an error rather than silently ignored, and a rejected command exits 1 rather than 0. A pipeline that passed on a misspelled flag will now fail.

Before upgrading an Expo pipeline to 1.2.2, note two changes. `release-expo` now sends your app config with every release and stops, releasing nothing, when it can't read it, so a job that releases an `--exportDir` from outside the project folder needs `--appConfig <file>` (see [App config parameters](#app-config-parameters)). And `promote` or `rollback` on an Expo app now exits 1 when any target failed or was busy with another change; it used to exit 0. New in 1.2.2: commands that ask "Are you sure?" take `--yes`, so a pipeline can run `dpctl rollback MyApp Production --yes`.

## Getting started

1. Create a [DeployPulse account](https://deploypulse.io/register).
Expand Down Expand Up @@ -278,7 +280,9 @@ dpctl app transfer <appName> <newOwnerEmail>

_NOTE: Just like with the `dpctl collaborator add` command, this expects that the new owner has already registered with DeployPulse using the specified e-mail address._

Once confirmed, the specified developer becomes the app's owner and immediately receives the permissions associated with that role. Besides the transfer of ownership, nothing else about the app is modified (e.g. deployments, release history, collaborators). This means that you will still be a collaborator of the app, and therefore, if you want to remove yourself, you simply need to run the `dpctl collaborator rm` command after successfully transferring ownership.
This sends a transfer invite to that e-mail address; nothing changes yet. The transfer completes only once the new owner accepts the invite from the e-mail, and the invite expires after 7 days. Until then you remain the owner, and you can send a new invite if it lapses.

Once accepted, the new owner receives the owner's permissions and you are removed from the app. Deployments, deployment keys, release history and the other collaborators are not modified. If you still need access afterwards, ask the new owner to add you back with `dpctl collaborator add`.

### Deployment Management

Expand Down Expand Up @@ -674,6 +678,8 @@ dpctl release-expo <appName>
[--runtimeVersion <runtimeVersion>]
[--exportDir <exportDir>]
[--metadata <json>]
[--appConfig <file>]
[--skipAppConfig]
```

`release-expo` is for apps that use `expo-updates` rather than the CodePush SDK. The app must have been created with `--platform expo-v1`. Run it from your project folder:
Expand All @@ -686,10 +692,13 @@ It does what `eas update` does before uploading:

1. Runs `npx expo export` for iOS and Android, using the Expo CLI installed in your project.
2. Resolves each platform's runtime version from your app config with `npx expo-updates runtimeversion:resolve`, so every runtime version policy works, `fingerprint` included.
3. Uploads each platform as its own release, then removes its temporary files.
3. Reads your public app config with `npx expo config --type public --json` and sends it with the release. Devices read it as `Constants.expoConfig` (and `Constants.easConfig`) once they run the update.
4. Uploads each platform as its own release, then removes its temporary files.

Both platforms are exported and packaged before either is uploaded, so a problem with one can't leave the other released on its own. If you run `release-expo` on a CodePush app, dpctl stops before exporting and tells you to use `release-react` instead.

If the server is busy, dpctl prints "Server busy, retrying…" and tries the upload again, waiting as long as the server asks, for up to five minutes. After that it stops with "Server busy, try again later." A busy answer means nothing was released, so running the command again is safe.

#### Deployment name parameter

This is the same parameter as the one described in the [above section](#deployment-name-parameter).
Expand Down Expand Up @@ -723,6 +732,22 @@ Extra metadata to attach to the release, as a JSON string. It is included in the
dpctl release-expo MyExpoApp --metadata '{"channel":"beta"}'
```

#### App config parameters

Every release carries your app's public config, the same config `eas update` sends. Without it, `Constants.expoConfig` and `Constants.easConfig` are `null` on devices running the update, so anything that reads them breaks: values in `extra`, or the EAS project id `getExpoPushTokenAsync` falls back to. If dpctl can't read the config, it releases nothing.

`--appConfig <file>` sends a config saved earlier instead of running `npx expo config`. Use it with `--exportDir` when releasing from outside the project folder:

```shell
npx expo export --output-dir dist
npx expo config --type public --json > app-config.json
dpctl release-expo MyExpoApp -d Production --exportDir dist --runtimeVersion 1.0.2 --appConfig app-config.json
```

`--skipAppConfig` releases without the config, with a warning. Only use it for an app that never reads `Constants.expoConfig` or `Constants.easConfig`.

Releases made with dpctl 1.2.1 or older have no app config. Release again with 1.2.2 or later to fix devices running them.

## Debugging DeployPulse Integration

Once you've released an update, React Native plugin has been integrated into your app, it can be helpful to diagnose how the plugin is behaving, especially if you run into an issue and want to understand why. In order to debug the DeployPulse update discovery experience, you can run the following command in order to easily view the diagnostic logs produced by the CodePush plugin within your app:
Expand Down Expand Up @@ -871,6 +896,12 @@ dpctl rollback <appName> <deploymentName>
dpctl rollback MyApp-iOS Production
```

`rollback` asks "Are you sure?" first. In a script or CI job, where nothing can answer, pass `--yes` (or `-y`); without it the prompt answers no and nothing is rolled back. The same flag works on every command that asks: `app rm`, `app transfer`, `deployment rm`, `deployment clear`, `access-key rm`, `session rm` and `collaborator rm`.

```
dpctl rollback MyApp-iOS Production --yes
```

This has the effect of creating a new release for the deployment that includes the **exact same code and metadata** as the version prior to the latest one. For example, imagine that you released the following updates to your app:

| Release | Description | Mandatory |
Expand Down Expand Up @@ -925,6 +956,11 @@ dpctl rollback MyExpoApp production --toEmbedded
That issues the Expo `rollBackToEmbedded` directive. Devices return to the bundle they shipped with until the
channel gets a new release, which supersedes the rollback.

A target that is already rolled back is skipped, so running the same rollback again (after a timeout, say) is
safe and exits 0. `rollback` and `promote` exit 1 when any target failed, or was skipped because another
change to the channel was in progress; everything else they did stands, and running the command again retries
the rest.

## Auto-Rollback

Auto-rollback automatically reverts a deployment to its previous release when the error rate on the current release exceeds a configured threshold. This protects your users from bad releases without requiring manual intervention.
Expand Down
27 changes: 27 additions & 0 deletions eslint.config.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
// ESLint 9 reads only this flat config; it replaces .eslintrc.json, which it ignores.
import tsParser from "@typescript-eslint/parser";
import tsPlugin from "@typescript-eslint/eslint-plugin";

export default [
{
files: ["script/**/*.ts"],
plugins: { "@typescript-eslint": tsPlugin },
languageOptions: {
parser: tsParser,
parserOptions: {
ecmaVersion: 2022,
sourceType: "module",
project: "./tsconfig.json",
},
},
rules: {
"no-var": "error",
"prefer-const": "error",
// The core rule does not understand TypeScript: it flags enum members and interface fields.
// Unused callback parameters (reject, notify, argv) and catch bindings are this codebase's style.
"@typescript-eslint/no-unused-vars": ["error", { args: "none", caughtErrors: "none", ignoreRestSiblings: true }],
eqeqeq: "error",
"no-eval": "error",
},
},
];
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@deploypulseio/dpctl",
"version": "1.2.1",
"version": "1.2.2",
"description": "Management CLI for the DeployPulse CodePush service",
"keywords": [
"deploypulse",
Expand Down
45 changes: 42 additions & 3 deletions script/api-errors.ts
Original file line number Diff line number Diff line change
@@ -1,15 +1,54 @@
/**
* The API answers errors with `{"message": "..."}`. Printing the envelope makes every refusal, an
* expired key or a read-only key among them, read like a bug in the CLI rather than an answer from
* the server. Anything that is not that shape is passed through untouched.
* the server. Anything that is not that shape is passed through untouched, apart from HTML entities.
*/
export function messageFromResponseText(text: string): string {
if (!text) return text;
try {
const parsed = JSON.parse(text);
if (parsed && typeof parsed.message === "string" && parsed.message) return parsed.message;
if (parsed && typeof parsed.message === "string" && parsed.message) return decodeHtmlEntities(parsed.message);
const fieldErrors = describeFieldErrors(parsed);
if (fieldErrors) return decodeHtmlEntities(fieldErrors);
} catch {
/* not JSON */
}
return text;
return decodeHtmlEntities(text);
}

/**
* The API's request validation answers with a list instead of the envelope:
* `[{"field":"name","message":"Field is invalid"}]`. Read as "The name field is invalid." Null for anything
* that is not such a list, so it is passed through as before.
*/
function describeFieldErrors(parsed: unknown): string | null {
if (!Array.isArray(parsed) || parsed.length === 0) return null;
const sentences: string[] = [];
for (const entry of parsed) {
if (!entry || typeof entry.message !== "string" || !entry.message.trim()) return null;
const message = entry.message.trim();
const field = typeof entry.field === "string" && entry.field.trim() ? entry.field.trim() : null;
const rest = /^field\s+/i.test(message) ? message.replace(/^field\s+/i, "") : null;
const sentence = field ? (rest ? `The ${field} field ${rest}` : `${field}: ${message}`) : message;
sentences.push(/[.!?]$/.test(sentence) ? sentence : `${sentence}.`);
}
return sentences.join(" ");
}

const NAMED_ENTITIES: { [name: string]: string } = { amp: "&", lt: "<", gt: ">", quot: '"', apos: "'" };

/**
* The API escapes user-supplied names it echoes into error text (the Expo routes answer in plain text),
* so `a&b` arrives as `a&amp;b`. A terminal is not HTML: print what the user typed. One pass, so
* `&amp;lt;` becomes `&lt;` and not `<`.
*/
export function decodeHtmlEntities(text: string): string {
return text.replace(/&(#x[0-9a-f]+|#[0-9]+|[a-z]+);/gi, (entity: string, body: string): string => {
if (body[0] === "#") {
const code = body[1] === "x" || body[1] === "X" ? parseInt(body.slice(2), 16) : parseInt(body.slice(1), 10);
return code > 0 && code <= 0x10ffff ? String.fromCodePoint(code) : entity;
}
const named = NAMED_ENTITIES[body.toLowerCase()];
return named === undefined ? entity : named;
});
}
Loading
Loading