Skip to content
Merged
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
8 changes: 7 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,13 @@ This is a fork of the code-push-standalone CLI tool open-sourced by Microsoft, m

## Installation

To install and run the DeployPulse CLI, follow these steps:
Install the DeployPulse CLI from npm. It requires Node.js 20.19 or later, or 22.12 or later.

```shell
npm install -g @deploypulseio/dpctl
```

To build it from source instead:

1. Clone this repository.
2. Install the necessary dependencies by running `npm install`.
Expand Down
19 changes: 16 additions & 3 deletions package.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,20 @@
{
"name": "@deploypulseio/dpctl",
"version": "1.1.1",
"version": "1.2.0",
"description": "Management CLI for the DeployPulse CodePush service",
"keywords": [
"deploypulse",
"codepush",
"code-push",
"ota",
"over-the-air",
"react-native",
"expo",
"expo-updates",
"hot-update",
"mobile",
"cli"
],
"main": "./bin/script/cli.js",
"scripts": {
"start": "node ./bin/script/cli.js",
Expand All @@ -11,7 +24,7 @@
"prettier": "prettier --write \"./**/*.ts\"",
"lint": "npx eslint ./script/**/*.ts",
"lint:fix": "npx eslint ./script/**/*.ts --fix",
"test": "mocha --require ts-node/register ./test/cli.ts ./test/hash-utils.ts ./test/acquisition-sdk.ts ./test/management-sdk.ts ./test/command-parser.ts --reporter mochawesome --exit"
"test": "mocha --require ts-node/register ./test/cli.ts ./test/hash-utils.ts ./test/acquisition-sdk.ts ./test/management-sdk.ts ./test/command-parser.ts ./test/config-file.ts --reporter mochawesome --exit"
},
"bin": {
"dpctl": "./bin/script/cli.js"
Expand All @@ -26,7 +39,7 @@
"email": "support@deploypulse.io"
},
"files": [
"bin/**",
"bin/script/**",
"README.md",
"LICENSE*",
"NOTICE"
Expand Down
30 changes: 29 additions & 1 deletion script/command-executor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -861,6 +861,10 @@ export const deserializeConnectionInfo = (): ILoginConnectionInfo => {
const savedConnection: string = fs.readFileSync(configFilePath, {
encoding: "utf8",
});

// Repaired on read, not only on write: someone who was already logged in before this change never
// takes a write path again, so their key would stay world-readable forever.
tightenConfigFilePermissions();
let connectionInfo: ILegacyLoginConnectionInfo | ILoginConnectionInfo = JSON.parse(savedConnection);

// If the connection info is in the legacy format, convert it to the modern format
Expand Down Expand Up @@ -2244,9 +2248,33 @@ function orgClear(command: cli.ICommand): Promise<void> {
// `export const`, not `export function`: TypeScript compiles calls to an exported const through the
// exports object, which is what lets the tests stub this and keep the real session file untouched.
export const writeConnectionInfo = (connectionInfo: ILoginConnectionInfo): void => {
fs.writeFileSync(configFilePath, JSON.stringify(connectionInfo), { encoding: "utf8" });
// Written to a temp file and renamed so an interrupted write cannot leave a half-written session
// file behind. The mode is set on creation rather than fixed afterwards, because the contents are a
// live access key and there should be no moment where the file exists and anyone can read it.
const tempPath = `${configFilePath}.${process.pid}.tmp`;
try {
fs.writeFileSync(tempPath, JSON.stringify(connectionInfo), { encoding: "utf8", mode: 0o600 });
fs.renameSync(tempPath, configFilePath);
} catch (error) {
try {
fs.unlinkSync(tempPath);
} catch {
/* the temp file may never have been created */
}
throw error;
}
};

// Best effort: Windows and some network filesystems do not support the mode, and a session that
// cannot be locked down is still better than no session.
function tightenConfigFilePermissions(): void {
try {
fs.chmodSync(configFilePath, 0o600);
} catch {
/* nothing to tighten, or the filesystem will not say */
}
}

function serializeConnectionInfo(accessKey: string, preserveAccessKeyOnLogout: boolean, org?: OrgContext): void {
const connectionInfo: ILoginConnectionInfo = {
accessKey: accessKey,
Expand Down
3 changes: 3 additions & 0 deletions script/management-sdk.ts
Original file line number Diff line number Diff line change
Expand Up @@ -850,6 +850,9 @@ class AccountManager {
request.set("Accept", `application/vnd.code-push.v${AccountManager.API_VERSION}+json`);
request.set("Authorization", `Bearer ${this._accessKey}`);
request.set("X-CodePush-SDK-Version", packageJson.version);
// Node's superagent sends no User-Agent of its own. Without this the server has only the version
// header to go on, and a login shows up as a nameless CLI in Recent Logins.
request.set("User-Agent", `dpctl/${packageJson.version}`);
if (this._orgId) {
request.set("x-org-id", this._orgId);
}
Expand Down
84 changes: 84 additions & 0 deletions test/config-file.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
// The session file holds a live access key, so these drive the real read and write paths rather than
// the stub the CLI tests use. `configFilePath` is resolved at import time from HOME, so each test
// points HOME at a temp directory and loads a fresh copy of the module.

import * as assert from "assert";
import * as fs from "fs";
import * as os from "os";
import * as path from "path";

function withTempHome<T>(body: (home: string, cmdexec: any) => T): T {
const home = fs.mkdtempSync(path.join(os.tmpdir(), "dpctl-test-home-"));
const previous = { HOME: process.env.HOME, LOCALAPPDATA: process.env.LOCALAPPDATA };
process.env.HOME = home;
process.env.LOCALAPPDATA = home;
const modulePath = require.resolve("../script/command-executor");
delete require.cache[modulePath];
try {
return body(home, require("../script/command-executor"));
} finally {
delete require.cache[modulePath];
process.env.HOME = previous.HOME;
process.env.LOCALAPPDATA = previous.LOCALAPPDATA;
fs.rmSync(home, { recursive: true, force: true });
}
}

function mode(file: string): number {
return fs.statSync(file).mode & 0o777;
}

describe("the session file", () => {
it("is written so that only its owner can read it", () => {
withTempHome((home: string, cmdexec: any) => {
cmdexec.writeConnectionInfo({ accessKey: "secret-key", preserveAccessKeyOnLogout: false });
const file = path.join(home, ".dpctl.config");
assert.strictEqual(mode(file), 0o600, `mode was ${mode(file).toString(8)}`);
assert.deepStrictEqual(JSON.parse(fs.readFileSync(file, "utf8")).accessKey, "secret-key");
});
});

it("is tightened on read, for anyone who logged in before it was written this way", () => {
withTempHome((home: string, cmdexec: any) => {
const file = path.join(home, ".dpctl.config");
fs.writeFileSync(file, JSON.stringify({ accessKey: "secret-key" }), { mode: 0o644 });
assert.strictEqual(mode(file), 0o644, "precondition: the file starts world-readable");

assert.strictEqual(cmdexec.deserializeConnectionInfo().accessKey, "secret-key");
assert.strictEqual(mode(file), 0o600, `mode was ${mode(file).toString(8)}`);
});
});

it("leaves no temp file behind", () => {
withTempHome((home: string, cmdexec: any) => {
cmdexec.writeConnectionInfo({ accessKey: "secret-key", preserveAccessKeyOnLogout: false });
const strays = fs.readdirSync(home).filter((name: string) => name.indexOf(".tmp") >= 0);
assert.deepStrictEqual(strays, []);
});
});

it("leaves no temp file behind when the rename fails", () => {
withTempHome((home: string, cmdexec: any) => {
// A directory in the session file's place: the temp file is written, then the rename fails.
fs.mkdirSync(path.join(home, ".dpctl.config"));
assert.throws(() => cmdexec.writeConnectionInfo({ accessKey: "secret-key", preserveAccessKeyOnLogout: false }));

const strays = fs.readdirSync(home).filter((name: string) => name.indexOf(".tmp") >= 0);
assert.deepStrictEqual(strays, []);
});
});

it("does not replace the old session when the write fails", () => {
withTempHome((home: string, cmdexec: any) => {
const file = path.join(home, ".dpctl.config");
cmdexec.writeConnectionInfo({ accessKey: "first-key", preserveAccessKeyOnLogout: false });

// A directory where the temp file wants to go: writeFileSync fails, and the live file must survive.
fs.mkdirSync(`${file}.${process.pid}.tmp`);
assert.throws(() => cmdexec.writeConnectionInfo({ accessKey: "second-key", preserveAccessKeyOnLogout: false }));

assert.strictEqual(JSON.parse(fs.readFileSync(file, "utf8")).accessKey, "first-key");
assert.strictEqual(mode(file), 0o600);
});
});
});
9 changes: 9 additions & 0 deletions test/management-sdk.ts
Original file line number Diff line number Diff line change
Expand Up @@ -420,6 +420,15 @@ describe("Management SDK", () => {
}, rejectHandler);
});

it("identifies itself as dpctl, so a login is not a nameless CLI row", (done: Mocha.Done) => {
mockReturn(JSON.stringify({ apps: [] }), 200);
manager.getApps().done(() => {
const version = require("../package.json").version;
assert.strictEqual(lastRequestHeaders["User-Agent"], `dpctl/${version}`);
done();
}, rejectHandler);
});

it("addAccessKey sends scopes and appIds when they are set", (done: Mocha.Done) => {
mockReturn(JSON.stringify({ accessKey: { name: "k", friendlyName: "CI key", createdTime: 0, expires: 1 } }), 201);
manager.addAccessKey("CI key", undefined, ["read"], ["app-a-id"]).done(() => {
Expand Down
Loading