Skip to content

Commit 2066265

Browse files
committed
harden CLI reliability and releases
1 parent 3df71f1 commit 2066265

14 files changed

Lines changed: 393 additions & 16 deletions

File tree

‎.github/workflows/release.yml‎

Lines changed: 4 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,8 @@ jobs:
1313
publish:
1414
runs-on: ubuntu-latest
1515
environment: npm
16+
env:
17+
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
1618
steps:
1719
- uses: actions/checkout@v4
1820
- uses: actions/setup-node@v4
@@ -22,9 +24,5 @@ jobs:
2224
registry-url: https://registry.npmjs.org
2325
- run: npm ci
2426
- run: npm run verify
25-
- run: npm publish --workspace beatapi-client
26-
env:
27-
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
28-
- run: npm publish --workspace beatapi
29-
env:
30-
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
27+
- run: node scripts/publish-workspace-if-needed.mjs beatapi-client
28+
- run: node scripts/publish-workspace-if-needed.mjs beatapi

‎README.md‎

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -92,6 +92,11 @@ beatapi webhooks delete WEBHOOK
9292
Use `beatapi --help` for the installed command summary. `--json` remains an
9393
alias for `--file` on JSON-input commands.
9494

95+
Webhook creation stores the one-time signing secret in the user's BeatAPI
96+
configuration directory with file mode `0600`. The JSON result contains
97+
`secret_file` instead of the secret itself. Set `BEATAPI_CONFIG_DIR` when a
98+
container or automation environment needs a custom secure location.
99+
95100
## TypeScript client
96101

97102
```bash
@@ -131,8 +136,9 @@ The client exposes every launch-contract operation: workflows, usage, file
131136
upload, music-video automatic and manual composition, ecommerce-video tasks,
132137
task polling, and webhook CRUD.
133138

134-
Retries are bounded and opt-in through method retry options. The client
135-
preserves BeatAPI error code, HTTP status, request ID, details, and
139+
Retries are bounded and opt-in through method retry options. Task waiting uses
140+
bounded retries for transient network and retryable server failures. The client
141+
preserves BeatAPI error code, HTTP status, request ID, details, and honors
136142
`Retry-After` information.
137143

138144
## Security model
@@ -174,7 +180,8 @@ See [CONTRIBUTING.md](./CONTRIBUTING.md) before changing behavior.
174180

175181
GitHub Actions verifies every pull request. Publishing is triggered by a GitHub
176182
release or manually through the release workflow after the repository
177-
environment contains an `NPM_TOKEN` secret.
183+
environment contains an `NPM_TOKEN` secret. The workflow skips package
184+
versions that already exist, so a partial release can be rerun safely.
178185

179186
Release steps and ownership prerequisites are documented in
180187
[`docs/releasing.md`](./docs/releasing.md).

‎docs/releasing.md‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,8 @@ release.
2323
6. Merge to `main` only after CI passes.
2424
7. Create a GitHub release tagged `vX.Y.Z`.
2525
8. Approve the protected `npm` environment deployment.
26+
The workflow checks each exact package version before publishing, so it is
27+
safe to rerun after one package succeeds and the other fails.
2628
9. Confirm both npm package pages and install the CLI in a clean temporary
2729
directory.
2830
10. Run `beatapi --version`, `beatapi --help`, and an authenticated

‎package.json‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@
1515
"build": "npm run generate:types && tsc -b",
1616
"clean": "tsc -b --clean",
1717
"typecheck": "tsc -b --pretty false",
18-
"test": "tsx --test packages/*/test/*.test.ts",
18+
"test": "tsx --test packages/*/test/*.test.ts test/*.test.mjs",
1919
"lint": "tsc -b --pretty false",
2020
"verify": "npm run contract:check && npm run check:generated && npm run clean && npm run typecheck && npm test && npm run build && npm run pack:check && npm run package:smoke",
2121
"pack:check": "npm pack --dry-run --workspace beatapi-client && npm pack --dry-run --workspace beatapi",

‎packages/cli/README.md‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,5 +20,8 @@ beatapi tasks wait task_123 --interval 7000
2020
Results are JSON on stdout. Progress and errors use stderr so the CLI composes
2121
cleanly with shell scripts and automation tools.
2222

23+
Webhook creation writes the one-time signing secret to a mode-`0600` file and
24+
returns its path as `secret_file`; it does not print the secret.
25+
2326
See the [repository](https://github.com/erickkkyt/beatapi-cli) for the complete
2427
command reference and security model.

‎packages/cli/package.json‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,7 @@
3737
"bugs": {
3838
"url": "https://github.com/erickkkyt/beatapi-cli/issues"
3939
},
40-
"homepage": "https://beatapi.io/docs",
40+
"homepage": "https://docs.beatapi.io/",
4141
"keywords": [
4242
"beatapi",
4343
"cli",

‎packages/cli/src/cli.ts‎

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import {
1616
type CredentialStore,
1717
} from "./credentials.js";
1818
import { promptSecret as defaultPromptSecret } from "./prompt.js";
19+
import { persistWebhookSecret } from "./webhook-secrets.js";
1920

2021
export const VERSION = "0.1.0";
2122

@@ -382,7 +383,23 @@ export async function run(
382383
}
383384
if (resource === "webhooks" && action === "create") {
384385
const input = await readJson<CreateWebhookInput>(inputFile(args));
385-
printJson(await client.createWebhook(input), stdout);
386+
const endpoint = (await client.createWebhook(input)) as Record<
387+
string,
388+
unknown
389+
>;
390+
try {
391+
printJson(await persistWebhookSecret(endpoint, env), stdout);
392+
} catch (error) {
393+
const endpointId =
394+
typeof endpoint.id === "string" ? endpoint.id : undefined;
395+
if (endpointId) {
396+
await client.deleteWebhook(endpointId).catch(() => undefined);
397+
}
398+
throw new Error(
399+
"Unable to store the one-time webhook secret; the webhook was rolled back.",
400+
{ cause: error },
401+
);
402+
}
386403
return 0;
387404
}
388405
if (resource === "webhooks" && action === "get") {
Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
import { chmod, mkdir, writeFile } from "node:fs/promises";
2+
import { homedir } from "node:os";
3+
import { resolve } from "node:path";
4+
5+
type Environment = Record<string, string | undefined>;
6+
7+
function secretDirectory(env: Environment): string {
8+
if (env.BEATAPI_CONFIG_DIR?.trim()) {
9+
return resolve(env.BEATAPI_CONFIG_DIR.trim(), "secrets");
10+
}
11+
if (process.platform === "win32" && env.APPDATA?.trim()) {
12+
return resolve(env.APPDATA.trim(), "BeatAPI", "secrets");
13+
}
14+
if (env.XDG_CONFIG_HOME?.trim()) {
15+
return resolve(env.XDG_CONFIG_HOME.trim(), "beatapi", "secrets");
16+
}
17+
return resolve(homedir(), ".config", "beatapi", "secrets");
18+
}
19+
20+
function secretFilename(endpoint: Record<string, unknown>): string {
21+
const id =
22+
typeof endpoint.id === "string"
23+
? endpoint.id.replace(/[^A-Za-z0-9._-]/g, "-")
24+
: "";
25+
return `${id || `webhook-${Date.now()}`}.secret`;
26+
}
27+
28+
export async function persistWebhookSecret(
29+
endpoint: Record<string, unknown>,
30+
env: Environment = process.env,
31+
): Promise<Record<string, unknown>> {
32+
const secret = endpoint.secret;
33+
if (typeof secret !== "string" || !secret || secret.includes("masked")) {
34+
throw new Error("BeatAPI did not return a usable one-time webhook secret.");
35+
}
36+
37+
const directory = secretDirectory(env);
38+
await mkdir(directory, { recursive: true, mode: 0o700 });
39+
await chmod(directory, 0o700);
40+
const path = resolve(directory, secretFilename(endpoint));
41+
await writeFile(path, `${secret}\n`, { mode: 0o600, flag: "wx" });
42+
await chmod(path, 0o600);
43+
44+
const { secret: _secret, ...safeEndpoint } = endpoint;
45+
return { ...safeEndpoint, secret_file: path };
46+
}

‎packages/cli/test/cli.test.ts‎

Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,14 @@
11
import assert from "node:assert/strict";
2+
import {
3+
mkdir,
4+
mkdtemp,
5+
readFile,
6+
rm,
7+
stat,
8+
writeFile,
9+
} from "node:fs/promises";
10+
import { tmpdir } from "node:os";
11+
import { resolve } from "node:path";
212
import test from "node:test";
313

414
import { run } from "../src/cli.js";
@@ -172,3 +182,84 @@ test("supports music-video manual workflow and webhook commands", async () => {
172182

173183
assert.deepEqual(calls, ["edit", "media", "compose", "webhooks-list"]);
174184
});
185+
186+
test("webhook creation stores the one-time secret instead of printing it", async () => {
187+
const output = outputCollector();
188+
const directory = await mkdtemp(resolve(tmpdir(), "beatapi-cli-webhook-"));
189+
const inputPath = resolve(directory, "webhook.json");
190+
await writeFile(
191+
inputPath,
192+
JSON.stringify({ url: "https://example.com/webhooks/beatapi" }),
193+
);
194+
195+
try {
196+
const exitCode = await run(
197+
["webhooks", "create", "--file", inputPath],
198+
{
199+
...output.io,
200+
apiKey: "sk_test",
201+
env: { BEATAPI_CONFIG_DIR: directory },
202+
createClient: () => ({
203+
createWebhook: async () => ({
204+
id: "wh_test",
205+
url: "https://example.com/webhooks/beatapi",
206+
secret: "whsec_one_time_secret",
207+
}),
208+
deleteWebhook: async () => ({ deleted: true }),
209+
}),
210+
},
211+
);
212+
213+
assert.equal(exitCode, 0);
214+
assert.doesNotMatch(output.stdout(), /whsec_one_time_secret/);
215+
const result = JSON.parse(output.stdout()) as {
216+
secret_file: string;
217+
};
218+
assert.equal(
219+
(await readFile(result.secret_file, "utf8")).trim(),
220+
"whsec_one_time_secret",
221+
);
222+
assert.equal((await stat(result.secret_file)).mode & 0o777, 0o600);
223+
} finally {
224+
await rm(directory, { recursive: true, force: true });
225+
}
226+
});
227+
228+
test("webhook creation rolls back when secure secret storage fails", async () => {
229+
const output = outputCollector();
230+
const directory = await mkdtemp(resolve(tmpdir(), "beatapi-cli-webhook-"));
231+
const inputPath = resolve(directory, "webhook.json");
232+
const secretsDirectory = resolve(directory, "secrets");
233+
await writeFile(
234+
inputPath,
235+
JSON.stringify({ url: "https://example.com/webhooks/beatapi" }),
236+
);
237+
await mkdir(secretsDirectory, { recursive: true });
238+
await writeFile(resolve(secretsDirectory, "wh_test.secret"), "existing\n");
239+
let deletedWebhookId = "";
240+
241+
try {
242+
await assert.rejects(
243+
run(["webhooks", "create", "--file", inputPath], {
244+
...output.io,
245+
apiKey: "sk_test",
246+
env: { BEATAPI_CONFIG_DIR: directory },
247+
createClient: () => ({
248+
createWebhook: async () => ({
249+
id: "wh_test",
250+
url: "https://example.com/webhooks/beatapi",
251+
secret: "whsec_one_time_secret",
252+
}),
253+
deleteWebhook: async (id: string) => {
254+
deletedWebhookId = id;
255+
return { deleted: true };
256+
},
257+
}),
258+
}),
259+
/rolled back/,
260+
);
261+
assert.equal(deletedWebhookId, "wh_test");
262+
} finally {
263+
await rm(directory, { recursive: true, force: true });
264+
}
265+
});

‎packages/client/package.json‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,7 @@
3636
"bugs": {
3737
"url": "https://github.com/erickkkyt/beatapi-cli/issues"
3838
},
39-
"homepage": "https://beatapi.io/docs",
39+
"homepage": "https://docs.beatapi.io/",
4040
"keywords": [
4141
"beatapi",
4242
"ai-video",

0 commit comments

Comments
 (0)