diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
new file mode 100644
index 0000000..4ad5050
--- /dev/null
+++ b/.github/workflows/ci.yml
@@ -0,0 +1,18 @@
+name: CI
+on:
+ pull_request:
+ push:
+ branches: [main]
+permissions:
+ contents: read
+jobs:
+ test:
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v5
+ - uses: actions/setup-node@v5
+ with:
+ node-version: 24
+ - run: npm install
+ - run: npm test
+ - run: npm run lint
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 0118f44..d6bf11e 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,3 +1,7 @@
+## 1.11.0
+
+This version adds support for Metadata and iOS deep links, and expands Tags support when updating or ending Live Activities.
+
## 1.10.0
### New Features
diff --git a/README.md b/README.md
index 2c64da5..e7add21 100644
--- a/README.md
+++ b/README.md
@@ -1,31 +1,10 @@
# ActivitySmith CLI
-CLI wrapper for the ActivitySmith API using the official Node SDK.
-
-## Table of Contents
-
-- [Install](#install)
-- [Agent Skill](#agent-skill)
-- [Auth](#auth)
-- [Push Notifications](#push-notifications)
- - [Send Push Notification](#send-push-notification)
- - [Rich Push Notifications with Media](#rich-push-notifications-with-media)
- - [Actionable Push Notifications](#actionable-push-notifications)
-- [Live Activities](#live-activities)
- - [Start & Update Live Activity](#start--update-live-activity)
- - [End Live Activity](#end-live-activity)
- - [Live Activity Action](#live-activity-action)
- - [Icons and Badges](#icons-and-badges)
- - [Live Activity Colors](#live-activity-colors)
-- [Widgets](#widgets)
-- [App Icon Badge Count](#app-icon-badge-count)
-- [Channels](#channels)
-- [Tags](#tags)
-- [Aliases](#aliases)
-- [Content State Options](#content-state-options)
-- [Output](#output)
-
-## Install
+[Documentation](https://activitysmith.com/docs/sdks/cli)
+
+## Installation
+
+Install the ActivitySmith CLI globally with npm:
```bash
npm install -g activitysmith-cli
@@ -33,79 +12,65 @@ npm install -g activitysmith-cli
## Agent Skill
-
-
-
+Install the ActivitySmith skill when you want Codex, Claude, Cursor, or another skills-compatible agent to decide which ActivitySmith CLI command to run.
-The ActivitySmith skill helps coding agents decide when and how to notify you.
+```bash
+npx -y skills@latest add ActivitySmithHQ/activitysmith-cli --skill activitysmith
+```
-Use it for prompts like:
+Use the skill when an agent should notify you with Push Notifications, include a notification tap or action that can open a URL or run a specific iOS Shortcut, or keep task progress visible with Live Activities.
-- "Notify me when you're done."
-- "Send me a push notification if you get blocked."
-- "When the task finishes, the notification tap should run my Test Shortcut."
-- "Show progress on my Lock Screen while you work."
+For example, a Codex agent can work on your computer, send a Push Notification when it needs your attention, and include a Shortcut action that runs an `OpenChatGPT` Shortcut on your iPhone so you can continue the conversation in the ChatGPT app.
-The skill maps those requests to the CLI:
+## Quickstart
-- Push Notifications for completion, blockers, and review requests
-- `shortcuts://` redirection for a specific iPhone Shortcut
-- action buttons for follow-up links or Shortcut buttons
-- Live Activities for long-running progress
-- widget metrics for values that should stay visible
-- App Icon Badge Counts for a number that should stay on the app icon
+1. [Create an API key](https://activitysmith.com/app/keys)
+2. Authenticate with `ACTIVITYSMITH_API_KEY` or pass `--api-key` per command.
+3. Run `activitysmith --help` to inspect available commands.
-Install the public skill from this repo:
+Use the environment variable when you want the cleanest shell scripts:
```bash
-npx -y skills@latest add ActivitySmithHQ/activitysmith-cli --skill activitysmith
-```
-
-Skill path in this repo:
+export ACTIVITYSMITH_API_KEY="YOUR-API-KEY"
-```text
-skills/activitysmith
+activitysmith --help
```
-The skill is agent-neutral and recipe-driven. It uses `ACTIVITYSMITH_API_KEY` auth plus the same CLI commands shown below.
-
-## Auth
+Or pass the key directly:
-Set `ACTIVITYSMITH_API_KEY` or pass `--api-key`.
-
-For the skill scripts, you can also copy `skills/activitysmith/.env.example` to `skills/activitysmith/.env`.
+```bash
+activitysmith --api-key "YOUR-API-KEY" push --title "Hello"
+```
## Push Notifications
-Run `activitysmith --help` to inspect available commands.
+### Send a Push Notification
-### Send Push Notification
+Send an immediate notification for a completed task or event.
+
+
```bash
activitysmith push \
--title "Build Failed 🚨" \
- --message "CI pipeline failed on main branch"
+ --message "CI pipeline failed on main branch" \
+ --subtitle "main"
```
### Rich Push Notifications with Media
-
-
-
+
```bash
activitysmith push \
--title "Homepage ready" \
--message "Your agent finished the redesign." \
- --media "https://cdn.example.com/output/homepage-v2.png" \
- --redirection "https://github.com/acme/web/pull/482"
+ --media "https://cdn.example.com/output/homepage-v2.png"
```
-Send images, videos, or audio with your push notifications, press and hold to preview media directly from the notification, then tap through to open the linked content.
+Attach images, videos, or audio to your Push Notifications. Press and hold the notification to preview the media.
-
-
-
+
What will work:
@@ -114,25 +79,50 @@ What will work:
- direct video file URL: `.mp4`, `.mov`, etc.
- URL that responds with a proper media `Content-Type`, even if the path has no extension
-`--media` can be combined with `--redirection`, but not with `--actions` or `--actions-file`.
+`--media` cannot be combined with `--actions`.
+
+### Push Notifications with Redirection
+
+Open a web page, run an iOS Shortcut, or open an app when someone taps the notification. `--redirection` supports:
+
+- **HTTP/HTTPS:** Web pages, e.g. `https://example.com`
+- **Shortcuts:** Run Jarvis with `shortcuts://run-shortcut?name=Jarvis`
+- **App deep links:** Installed apps or specific content within them
+ - **Spotify:** A track, e.g. `spotify:track:6rqhFgbbKwnb9MLmUQDhG6`
+ - **Termius:** `termius://` to open the app
+ - **Claude:** `claude://code` to open the Code tab
+ - **ChatGPT:** `chatgpt://` to open the app
+
+```bash
+activitysmith push \
+ --title "Homepage ready" \
+ --message "Your agent finished the redesign." \
+ --redirection "https://github.com/acme/web/pull/482"
+```
### Actionable Push Notifications
-
-
-
+
+
+`open_url` actions open a web page, run an iOS Shortcut, or open an app when someone taps the button. Supported links:
-Push notification `--redirection` and `--actions` are optional. Use them to open HTTPS URLs, run a specific iPhone Shortcut with a `shortcuts://run-shortcut?name=...` URL, or trigger backend webhook workflows.
-Webhooks are executed by the ActivitySmith backend.
+- **HTTP/HTTPS:** Web pages, e.g. `https://example.com`
+- **Shortcuts:** Run Jarvis with `shortcuts://run-shortcut?name=Jarvis`
+- **App deep links:** Installed apps or specific content within them
+ - **Spotify:** A track, e.g. `spotify:track:6rqhFgbbKwnb9MLmUQDhG6`
+ - **Termius:** `termius://` to open the app
+ - **Claude:** `claude://code` to open the Code tab
+ - **ChatGPT:** `chatgpt://` to open the app
+
+Webhooks are executed by the ActivitySmith backend and must use HTTPS.
```bash
activitysmith push \
--title "Build Failed 🚨" \
--message "CI pipeline failed on main branch" \
- --redirection "https://github.com/org/repo/actions/runs/123456789" \
--actions '[
{
- "title": "Open Failing Run",
+ "title": "Open Build",
"type": "open_url",
"url": "https://github.com/org/repo/actions/runs/123456789"
},
@@ -155,7 +145,7 @@ activitysmith push \
]'
```
-You can also load actions from a file:
+You can also save the JSON array above as `actions.json` and load it from a file:
```bash
activitysmith push \
@@ -166,14 +156,31 @@ activitysmith push \
## Live Activities
-There are six types of Live Activities:
+Choose the Live Activity type that matches what you want to show:
+
+
+
+**Stats**: Show up to 8 labeled values on your Lock Screen, from revenue and orders to uptime and conversion.
+
+
+
+**Metrics**: Track two related values with segmented bars, such as CPU and memory.
+
+
+
+**Segmented Progress**: Show progress through a known set of steps, like build, test, deploy, and verify.
+
+
-- `stats`: best for showing business numbers side by side, such as revenue, sales, new users, conversion, refunds, or any other value you want visible at a glance
-- `metrics`: best for live percentage values that change often, like server CPU, memory usage, disk usage, or error rate
-- `segmented_progress`: best for anything that moves through clear stages, like deployments, onboarding flows, backups, ETL pipelines, migrations, and AI agent runs
-- `progress`: best for tracking real-time progress with percentage, like tasks, backups, migrations, syncs, or uploads
-- `alert`: best for status updates, such as feature adoption, reactivation, onboarding blockers, incidents, escalations, and other operational states
-- `timer`: best for countdowns and elapsed runtime, like benchmark runs, uploads, backups, transcodes, and long-running jobs
+**Progress**: Show percentage progress for jobs that move continuously toward completion.
+
+
+
+**Alert**: Show status updates with a clear message, badge, and icon. When you add an action button, `color` controls the button tint.
+
+
+
+**Timer**: Count down from a duration, or count up from 00:00 while a job runs.
### Start & Update Live Activity
@@ -181,13 +188,7 @@ Use a stable `stream_key` to identify the metric, job, deployment, or system you
#### Stats
-
-
-
+
```bash
activitysmith activity stream sales-hourly \
@@ -208,13 +209,7 @@ activitysmith activity stream sales-hourly \
#### Metrics
-
-
-
+
```bash
activitysmith activity stream prod-web-1 \
@@ -231,13 +226,7 @@ activitysmith activity stream prod-web-1 \
#### Segmented Progress
-
-
-
+
```bash
activitysmith activity stream nightly-backup \
@@ -252,13 +241,7 @@ activitysmith activity stream nightly-backup \
#### Progress
-
-
-
+
```bash
activitysmith activity stream search-reindex \
@@ -272,13 +255,7 @@ activitysmith activity stream search-reindex \
#### Alert
-
-
-
+
```bash
activitysmith activity stream customer-ops \
@@ -299,13 +276,7 @@ activitysmith activity stream customer-ops \
#### Timer
-
-
-
+
```bash
activitysmith activity stream benchmark-run \
@@ -318,16 +289,17 @@ activitysmith activity stream benchmark-run \
}'
```
-For a countdown, send `duration_seconds`. You can update `title`, `subtitle`, `color`, or any other visible field as the work changes. Leave `duration_seconds` out unless you want to change the timer.
+For a countdown, send `durationSeconds`. Leave it out on later stream updates to preserve the running timer. Supplying a new duration restarts the countdown.
-To start at 00:00 and count up, set `counts_down: false` and leave out `duration_seconds`.
+To start at 00:00 and count up, set `countsDown` to `false` and leave out `durationSeconds`.
### End Live Activity
-Call `activity end-stream` with the same `stream_key` to dismiss the Live Activity. You can include final values before it is removed. By default, iOS removes the Live Activity after two minutes. Set `autoDismissMinutes` to choose a different dismissal time, including `0` for immediate dismissal.
+Call `activity end-stream` with the same `stream_key` to dismiss the Live Activity. You can include final values before it is removed. Use `--auto-dismiss-seconds` or `--auto-dismiss-minutes` to delay dismissal. Use `0` for immediate dismissal. Seconds take precedence if both are set. JSON content state also accepts `autoDismissSeconds` or `auto_dismiss_seconds`.
```bash
activitysmith activity end-stream prod-web-1 \
+ --auto-dismiss-seconds 30 \
--content-state '{
"title": "Server Health",
"subtitle": "prod-web-1",
@@ -335,29 +307,66 @@ activitysmith activity end-stream prod-web-1 \
"metrics": [
{ "label": "CPU", "value": 7, "unit": "%" },
{ "label": "MEM", "value": 38, "unit": "%" }
+ ]
+ }'
+```
+
+### Icons and Badges
+
+Add more context to Live Activities with icons and badges.
+
+#### Icon
+
+```bash
+activitysmith activity stream prod-web-1 \
+ --content-state '{
+ "title": "Server Health",
+ "type": "metrics",
+ "metrics": [
+ { "label": "CPU", "value": 18, "unit": "%" },
+ { "label": "MEM", "value": 42, "unit": "%" }
],
- "autoDismissMinutes": 2
+ "icon": { "symbol": "server.rack", "color": "blue" }
+ }'
+```
+
+The `icon.symbol` value is an Apple SF Symbol name. Browse the catalog in the ActivitySmith iOS app under Settings > SF Symbols.
+
+#### Badge
+
+```bash
+activitysmith activity stream nightly-backup \
+ --content-state '{
+ "title": "Nightly Database Backup",
+ "type": "segmented_progress",
+ "numberOfSteps": 3,
+ "currentStep": 2,
+ "badge": { "title": "S3", "color": "cyan" }
}'
```
+### Live Activity Colors
+
+Choose from these colors for the Live Activity accent, including progress bars and action buttons, or apply them to an individual icon or badge:
+
+`lime`, `green`, `cyan`, `blue`, `purple`, `magenta`, `red`, `orange`, `yellow`, `gray`
+
### Live Activity Action
-Live Activities can include an action button.
+
-- `open_url`: open an HTTPS URL.
-- `open_url` with a `shortcuts://` URL: run an Apple Shortcut, for example to open an app.
-- `webhook`: trigger a backend GET/POST workflow.
+Live Activities can include an action button.
-
-
-
+- `open_url`: Open a web page or run an iOS Shortcut
+- `webhook`: Trigger a backend GET/POST workflow
#### Open URL action
+Open a web page or run an iOS Shortcut when someone taps the button. Supported links:
+
+- **HTTP/HTTPS:** Web pages, e.g. `https://example.com`
+- **Shortcuts:** Run Jarvis with `shortcuts://run-shortcut?name=Jarvis`
+
```bash
activitysmith activity stream prod-web-1 \
--content-state '{
@@ -372,20 +381,22 @@ activitysmith activity stream prod-web-1 \
--action '{
"title": "Dashboard",
"type": "open_url",
- "url": "https://ops.example.com/servers/prod-web-1"
+ "url": "https://status.example.com/servers/prod-web-1"
}'
```
#### Apple Shortcut action
```bash
-activitysmith activity stream deploy-payments-api \
+activitysmith activity stream prod-web-1 \
--content-state '{
- "title": "Deploying payments-api",
- "subtitle": "Running database migrations",
- "type": "segmented_progress",
- "numberOfSteps": 5,
- "currentStep": 3
+ "title": "Server Health",
+ "subtitle": "prod-web-1",
+ "type": "metrics",
+ "metrics": [
+ { "label": "CPU", "value": 76, "unit": "%" },
+ { "label": "MEM", "value": 52, "unit": "%" }
+ ]
}' \
--action '{
"title": "Chat with Jarvis",
@@ -419,13 +430,7 @@ activitysmith activity stream search-reindex \
#### Secondary action
-
-
-
+
Use `--secondary-action` when you want a second button beside the primary `--action`.
@@ -444,95 +449,32 @@ activitysmith activity stream agent-approval \
--action '{
"title": "Send",
"type": "webhook",
- "url": "https://hooks.example.com/agent/approval",
+ "url": "https://agent.example.com/live-activity/approve",
"method": "POST",
- "body": { "decision": "send" }
+ "body": {
+ "approval_id": "approval_01JY3J7Q9S0P8M1V5PZK7DR4M2",
+ "decision": "send"
+ }
}' \
--secondary-action '{
"title": "Deny",
"type": "webhook",
- "url": "https://hooks.example.com/agent/approval",
+ "url": "https://agent.example.com/live-activity/deny",
"method": "POST",
- "body": { "decision": "deny" }
- }'
-```
-
-### Icons and Badges
-
-Add more context to Live Activities with icons and badges.
-
-#### Icon
-
-Supported Live Activity types: `stats`, `metrics`, `progress`, `segmented_progress`, and `alert`.
-
-
-
-
-
-```bash
-activitysmith activity stream prod-web-1 \
- --content-state '{
- "title": "Server Health",
- "subtitle": "prod-web-1",
- "type": "metrics",
- "icon": { "symbol": "server.rack", "color": "blue" },
- "metrics": [
- { "label": "CPU", "value": 18, "unit": "%" },
- { "label": "MEM", "value": 42, "unit": "%" }
- ]
- }'
-```
-
-The `icon.symbol` value is an Apple SF Symbol name. Browse the catalog with one of these tools:
-
-- [ActivitySmith app](https://apps.apple.com/us/app/activitysmith/id6752254835) - Open Settings -> SF Symbols to browse 45 hand-picked icons ready to use
-- [SF Symbols](https://developer.apple.com/sf-symbols/) - Apple's official macOS app
-- [Interactful](https://apps.apple.com/app/interactful/id1528095640) - free third-party iOS app listing all SF Symbols under Foundations -> Iconography
-
-#### Badge
-
-Badges are supported by `alert`, `progress`, and `segmented_progress` Live Activities.
-
-
-
-
-
-```bash
-activitysmith activity stream nightly-database-backup \
- --content-state '{
- "title": "Nightly Database Backup",
- "subtitle": "verify restore",
- "type": "progress",
- "badge": { "title": "S3", "color": "cyan" },
- "percentage": 62
+ "body": {
+ "approval_id": "approval_01JY3J7Q9S0P8M1V5PZK7DR4M2",
+ "decision": "deny"
+ }
}'
```
-### Live Activity Colors
+## Lock Screen Widgets
-Choose from these colors for the Live Activity accent, including progress bars and action buttons, or apply them to an individual icon or badge:
+
-`lime`, `green`, `cyan`, `blue`, `purple`, `magenta`, `red`, `orange`, `yellow`, `gray`
+ActivitySmith lets you display any value on your Lock Screen with widgets - SaaS metrics, revenue, signups, uptime, habits, or anything else you want to track. Create a metric in the [web app](https://activitysmith.com/app/widgets), then update the metric value using our API, add a widget to your lock screen and it will fetch the latest update automatically.
-## Widgets
-
-
-
-
-
-ActivitySmith lets you display any value on your Lock Screen with widgets - SaaS metrics, revenue, signups, uptime, habits, or anything else you want to track. Create a metric in the web app, then update the metric value using our API, add a widget to your lock screen and it will fetch the latest update automatically.
-
-
-
-
+
Use the metric key to update its value.
@@ -548,53 +490,50 @@ activitysmith metrics update prod.status healthy
## App Icon Badge Count
-
-
-
+
Show the number you care about on your ActivitySmith app icon. Track MRR, a customer count, a stock price, or any other value you want to keep in view.
-Set or update the badge value.
+### Set or update the badge value
```bash
activitysmith badge 8333
```
-To clear the badge, set its value to 0.
+### Clear the badge
+
+Pass `0` to clear the badge.
```bash
activitysmith badge 0
```
-## Channels
-
-Use `--channels` to target specific team members or devices
+## Metadata
-### Push Notifications
+Metadata adds extra information to Push Notification and Live Activity details in ActivitySmith. It does not appear in the notification or Live Activity on your device.
```bash
activitysmith push \
--title "New subscription 💸" \
--message "Customer upgraded to Pro plan" \
- --channels "sales,customer-success"
-```
-
-### Live Activities
+ --metadata '{
+ "customer_id": "382",
+ "plan": "Pro",
+ "amount": 29,
+ "trial": false
+ }'
-```bash
-activitysmith activity start \
- --title "Nightly Database Backup" \
- --subtitle "verify restore" \
+activitysmith activity stream customer-import \
+ --title "Customer Import" \
--type progress \
- --percentage 62 \
- --channels "sales,customer-success"
+ --percentage 60 \
+ --metadata '{
+ "job_id": "import-382",
+ "records": 1200
+ }'
```
-### App Icon Badge Count
-
-```bash
-activitysmith badge 3 --channels "sales,customer-success"
-```
+Values can be strings, numbers, or booleans. Metadata supports up to 50 entries and 16 KB of JSON, with keys up to 100 characters and strings up to 4,000 characters. Nested objects, arrays, and null values are not supported.
## Tags
@@ -607,75 +546,58 @@ activitysmith push \
--tags "user:382,billing"
```
-## Aliases
-
-The CLI installs two bin names:
+For `activity stream`, `activity update`, and `activity end`, omit `--tags` to keep existing Tags, pass `--tags` to replace them, or use `--clear-tags` to remove them. `--tags` and `--clear-tags` cannot be used together.
-- `activitysmith` (recommended)
-- `activitysmith-cli` (alias)
-
-## Content State Options
-
-For `activity stream|start|update|end|end-stream`, you can pass content state via JSON:
-
-- `--content-state `
-- `--content-state-file `
-
-For `metrics` and `stats`, you can also pass the metrics array directly:
-
-- `--metrics `
-- `--metrics-file `
-
-Or use flags to build the rest of the payload:
-
-- `--title `
-- `--subtitle `
-- `--type `
-- `--number-of-steps `
-- `--current-step `
-- `--percentage `
-- `--value `
-- `--upper-limit `
-- `--duration-seconds `
-- `--counts-down `
-- `--color `
-- `--step-color `
-- `--auto-dismiss-minutes `
+```bash
+activitysmith activity stream customer-import \
+ --title "Customer Import" \
+ --type progress \
+ --percentage 60 \
+ --clear-tags
+```
-For `timer`, use `--duration-seconds` for a countdown. To start at 00:00 and count up, use `--counts-down false` and leave out `--duration-seconds`.
+`activity end-stream` also accepts `--tags` or `--clear-tags` to replace or clear Tags in the final history entry. Omit both flags to preserve them.
-Live Activity action options:
+## Channels
-- `--action `
-- `--action-file `
-- `--secondary-action `
-- `--secondary-action-file `
+Use `--channels` to target specific team members or devices when sending Push Notifications, Live Activities, or App Icon Badge Count updates. Omit it for account-wide delivery.
-Targeting options:
+```bash
+activitysmith push \
+ --title "Build Failed 🚨" \
+ --message "CI pipeline failed on main branch" \
+ --channels "devs,ops"
+```
-- `--channels ` (for `push`, `badge`, `activity stream`, and `activity start`)
+```bash
+activitysmith activity stream nightly-backup \
+ --content-state '{
+ "title": "Nightly database backup",
+ "type": "segmented_progress",
+ "numberOfSteps": 4,
+ "currentStep": 1
+ }' \
+ --channels "devs,ops"
+```
-Organization options:
+```bash
+activitysmith badge 3 --channels "sales,customer-success"
+```
-- `--tags ` (for `push`, `activity stream`, and `activity start`; repeat the option to add more tags)
+## Output
-Widget metric options:
+Use `--json` for machine-readable output:
-- `activitysmith metrics update `
-- `activitysmith metric update ` (alias)
+```bash
+activitysmith push --title "Hello" --json
+```
-Required fields:
+## Error Handling
-- `activity stream`: `--title`, `--type`, plus `--metrics`, `--number-of-steps` and `--current-step`, `--percentage`, `--value` with `--upper-limit`, or timer fields
-- `activity start`: `--title`, `--type`, plus `--metrics`, `--number-of-steps` and `--current-step`, `--percentage`, `--value` with `--upper-limit`, or timer fields
-- `activity update`: `--title`, plus `--metrics`, `--current-step`, `--percentage`, `--value` with `--upper-limit`, or timer fields
-- `activity end`: `--title`, plus `--metrics`, `--current-step`, `--percentage`, `--value` with `--upper-limit`, or timer fields
-- `activity end-stream`: no content state is required, but if you provide one it follows the same rules as `activity end`
+The CLI exits non-zero on non-2xx responses and prints the API error body. That includes validation failures, rate limits, and Live Activity limit errors.
-## Output
+## Additional Resources
-Use `--json` for machine-readable output.
+### [NPM Package](https://www.npmjs.com/package/activitysmith-cli)
-```bash
-activitysmith push --title "Hello" --json
-```
+Install the ActivitySmith CLI from npm
diff --git a/package.json b/package.json
index 8299de3..12c216a 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "activitysmith-cli",
- "version": "1.10.0",
+ "version": "1.11.0",
"description": "Command-line interface for ActivitySmith. Send Push Notifications, start, update, and end Live Activities, and set App Icon Badge Counts from your terminal.",
"keywords": [
"activitysmith",
@@ -42,7 +42,7 @@
"test": "node --test"
},
"dependencies": {
- "activitysmith": "^1.10.0",
+ "activitysmith": "^1.11.0",
"commander": "^12.1.0"
}
}
diff --git a/src/cli.js b/src/cli.js
index 0e246d7..b8e54f0 100755
--- a/src/cli.js
+++ b/src/cli.js
@@ -1,6 +1,7 @@
#!/usr/bin/env node
import { Command, InvalidArgumentError } from "commander";
+import { loadMetadata } from "./metadata.js";
import ActivitySmith from "activitysmith";
import { createRequire } from "module";
import { readFile } from "fs/promises";
@@ -160,18 +161,30 @@ const normalizeUrlWithSchemes = (value, label, schemes) => {
return parsed.toString();
};
-const normalizeActionUrl = (value, label, type) => {
+const normalizeActionUrl = (value, label, type, push = false) => {
if (type === "open_url") {
- return normalizeUrlWithSchemes(value, label, ["https", "shortcuts"]);
+ if (push) return normalizeOpenUrl(value, label);
+ const normalized = typeof value === "string" ? value.trim().replace(/^x-safari-https:\/\//i, "https://") : value;
+ return normalizeUrlWithSchemes(normalized, label, ["http", "https", "shortcuts"]);
}
return normalizeHttpsUrl(value, label);
};
-const normalizeOpenUrl = (value, label) =>
- normalizeUrlWithSchemes(value, label, ["https", "shortcuts"]);
+const normalizeOpenUrl = (value, label) => {
+ if (typeof value !== "string") throw new Error(`${label} must be a string URL`);
+ const trimmed = value.trim();
+ if (!trimmed || trimmed.length > 2048 || /[\u0000-\u001f\u007f]/.test(trimmed)) {
+ throw new Error(`${label} must be a valid external URL of at most 2048 characters`);
+ }
+ let parsed;
+ try { parsed = new URL(trimmed); } catch { throw new Error(`${label} must be a valid external URL`); }
+ const blocked = ["about:", "activitysmith:", "app-prefs:", "blob:", "data:", "file:", "itms-services:", "javascript:", "prefs:"];
+ if (blocked.includes(parsed.protocol.toLowerCase())) throw new Error(`${label} uses a blocked URL scheme`);
+ return ["http:", "https:"].includes(parsed.protocol) ? parsed.toString() : trimmed;
+};
-const addContentStateOptions = (command, { includeAutoDismiss } = {}) => {
+const addContentStateOptions = (command, { includeAutoDismiss, includeAutoDismissSeconds } = {}) => {
command
.option("--content-state ", "Content state as JSON string")
.option("--content-state-file ", "Content state JSON file path")
@@ -231,6 +244,14 @@ const addContentStateOptions = (command, { includeAutoDismiss } = {}) => {
);
}
+ if (includeAutoDismissSeconds) {
+ command.option(
+ "--auto-dismiss-seconds ",
+ "Auto dismiss seconds for ended stream (takes precedence over minutes)",
+ parseIntegerOption("auto-dismiss-seconds")
+ );
+ }
+
return command;
};
@@ -388,6 +409,13 @@ const validateContentState = (contentState, mode) => {
const hasCountsDown = hasOwn(contentState, "countsDown");
const hasTimerFields = hasDurationSeconds || hasCountsDown;
+ for (const key of ["autoDismissSeconds", "auto_dismiss_seconds"]) {
+ if (hasOwn(contentState, key) &&
+ (!Number.isInteger(contentState[key]) || contentState[key] < 0)) {
+ throw new Error(`contentState.${key} must be a non-negative integer`);
+ }
+ }
+
if (hasValue !== hasUpperLimit) {
throw new Error(
"contentState.value and contentState.upperLimit must be provided together"
@@ -472,7 +500,7 @@ const validateContentState = (contentState, mode) => {
}
if (
- hasAlertFields &&
+ hasMessage &&
(hasMetrics || hasSegmentedFields || hasProgressFields || hasStepColor)
) {
throw new Error(
@@ -552,7 +580,7 @@ const validateContentState = (contentState, mode) => {
}
if (effectiveType === "timer") {
- if (!hasDurationSeconds && contentState.countsDown !== false) {
+ if (mode === "start" && !hasDurationSeconds && contentState.countsDown !== false) {
throw new Error(
`timer ${mode} requires contentState.durationSeconds, or contentState.countsDown=false`
);
@@ -637,7 +665,7 @@ const validateContentState = (contentState, mode) => {
}
};
-const parseAction = (value, label) => {
+const parseAction = (value, label, push = false) => {
assertPlainObject(value, label);
if (typeof value.title !== "string" || value.title.trim().length === 0) {
@@ -656,7 +684,7 @@ const parseAction = (value, label) => {
const action = {
title: value.title.trim(),
type: normalizedType,
- url: normalizeActionUrl(value.url, `${label}.url`, normalizedType),
+ url: normalizeActionUrl(value.url, `${label}.url`, normalizedType, push),
};
if (value.method !== undefined) {
@@ -682,7 +710,7 @@ const parseAction = (value, label) => {
return action;
};
-const parsePushAction = (value, index) => parseAction(value, `actions[${index}]`);
+const parsePushAction = (value, index) => parseAction(value, `actions[${index}]`, true);
const loadPushActions = async (options) => {
if (options.actions && options.actionsFile) {
@@ -845,6 +873,10 @@ const buildContentStateFromOptions = (options) => {
contentState.stepColor = options.stepColor;
}
+ if (options.autoDismissSeconds !== undefined) {
+ contentState.autoDismissSeconds = options.autoDismissSeconds;
+ }
+
if (options.autoDismissMinutes !== undefined) {
contentState.autoDismissMinutes = options.autoDismissMinutes;
}
@@ -875,6 +907,7 @@ const toApiContentState = (contentState) => {
upperLimit: "upper_limit",
stepColor: "step_color",
autoDismissMinutes: "auto_dismiss_minutes",
+ autoDismissSeconds: "auto_dismiss_seconds",
durationSeconds: "duration_seconds",
countsDown: "counts_down",
};
@@ -926,8 +959,9 @@ const withTargetChannels = (request, channels) => {
};
};
-const withTags = (request, tags) => {
- if (!tags || tags.length === 0) {
+const withTags = (request, tags, metadata) => {
+ if (metadata !== undefined) request = { ...request, metadata };
+ if (tags === undefined) {
return request;
}
@@ -1232,7 +1266,9 @@ program
"Comma-separated tags for organizing history (repeatable)",
parseTagsOption
)
- .action(async (options) => {
+ .option("--metadata ", "Metadata JSON object shown in ActivitySmith details")
+ .option("--metadata-file ", "Path to a Metadata JSON object file")
+ .action(async (options) => {
const globalOptions = program.opts();
try {
@@ -1259,6 +1295,7 @@ program
: undefined,
actions,
tags: options.tags,
+ metadata: await loadMetadata(options),
},
options.channels
);
@@ -1330,7 +1367,9 @@ metricsCommand
.description("Update a widget metric value")
.argument("", "Metric key")
.argument("", "Metric value")
- .action(async (metricKey, rawValue) => {
+ .option("--metadata ", "Metadata JSON object shown in ActivitySmith details")
+ .option("--metadata-file ", "Path to a Metadata JSON object file")
+ .action(async (metricKey, rawValue) => {
const globalOptions = program.opts();
try {
@@ -1364,10 +1403,16 @@ addLiveActivityActionOptions(addContentStateOptions(
"Comma-separated tags for organizing history (repeatable)",
parseTagsOption
)
+ .option("--clear-tags", "Remove all Tags from this stream")
+ .option("--metadata ", "Metadata JSON object shown in ActivitySmith details")
+ .option("--metadata-file ", "Path to a Metadata JSON object file")
.action(async (streamKey, options) => {
const globalOptions = program.opts();
try {
+ if (options.clearTags && options.tags !== undefined) {
+ throw new Error("Use either --tags or --clear-tags, not both.");
+ }
const apiKey = requireApiKey(globalOptions);
const client = createClient(apiKey);
const contentState = await loadContentState(options, "stream");
@@ -1385,7 +1430,8 @@ addLiveActivityActionOptions(addContentStateOptions(
),
options.channels
),
- options.tags
+ options.clearTags ? [] : options.tags,
+ await loadMetadata(options)
)
);
@@ -1417,6 +1463,8 @@ addLiveActivityActionOptions(addContentStateOptions(
"Comma-separated tags for organizing history (repeatable)",
parseTagsOption
)
+ .option("--metadata ", "Metadata JSON object shown in ActivitySmith details")
+ .option("--metadata-file ", "Path to a Metadata JSON object file")
.action(async (options) => {
const globalOptions = program.opts();
@@ -1437,7 +1485,8 @@ addLiveActivityActionOptions(addContentStateOptions(
),
options.channels
),
- options.tags
+ options.tags,
+ await loadMetadata(options)
),
});
@@ -1457,10 +1506,17 @@ addLiveActivityActionOptions(addContentStateOptions(
.command("update")
.description("Update a Live Activity")
.requiredOption("--activity-id ", "Live Activity ID")
+ .option("--tags ", "Replace Tags for this Live Activity (repeatable)", parseTagsOption)
+ .option("--clear-tags", "Remove all Tags from this Live Activity")
+ .option("--metadata ", "Metadata JSON object shown in ActivitySmith details")
+ .option("--metadata-file ", "Path to a Metadata JSON object file")
.action(async (options) => {
const globalOptions = program.opts();
try {
+ if (options.clearTags && options.tags !== undefined) {
+ throw new Error("Use either --tags or --clear-tags, not both.");
+ }
const apiKey = requireApiKey(globalOptions);
const client = createClient(apiKey);
const contentState = await loadContentState(options, "update");
@@ -1468,12 +1524,12 @@ addLiveActivityActionOptions(addContentStateOptions(
const secondaryAction = await loadLiveActivitySecondaryAction(options);
const response = await client.liveActivities.updateLiveActivity({
- liveActivityUpdateRequest: toApiLiveActivityUpdateRequest(
+ liveActivityUpdateRequest: withTags(toApiLiveActivityUpdateRequest(
options.activityId,
contentState,
action,
secondaryAction
- ),
+ ), options.clearTags ? [] : options.tags, await loadMetadata(options)),
});
outputResult(response, globalOptions, [
@@ -1491,10 +1547,17 @@ addLiveActivityActionOptions(addContentStateOptions(
.command("end")
.description("End a Live Activity")
.requiredOption("--activity-id ", "Live Activity ID")
+ .option("--tags ", "Replace Tags for this Live Activity (repeatable)", parseTagsOption)
+ .option("--clear-tags", "Remove all Tags from this Live Activity")
+ .option("--metadata ", "Metadata JSON object shown in ActivitySmith details")
+ .option("--metadata-file ", "Path to a Metadata JSON object file")
.action(async (options) => {
const globalOptions = program.opts();
try {
+ if (options.clearTags && options.tags !== undefined) {
+ throw new Error("Use either --tags or --clear-tags, not both.");
+ }
const apiKey = requireApiKey(globalOptions);
const client = createClient(apiKey);
const contentState = await loadContentState(options, "end");
@@ -1502,12 +1565,12 @@ addLiveActivityActionOptions(addContentStateOptions(
const secondaryAction = await loadLiveActivitySecondaryAction(options);
const response = await client.liveActivities.endLiveActivity({
- liveActivityEndRequest: toApiLiveActivityEndRequest(
+ liveActivityEndRequest: withTags(toApiLiveActivityEndRequest(
options.activityId,
contentState,
action,
secondaryAction
- ),
+ ), options.clearTags ? [] : options.tags, await loadMetadata(options)),
});
outputResult(response, globalOptions, [
@@ -1526,10 +1589,17 @@ addLiveActivityActionOptions(addContentStateOptions(
.command("end-stream")
.description("End a stateless Live Activity stream")
.argument("", "Stable stream key")
+ .option("--tags ", "Comma-separated Tags", parseTagsOption)
+ .option("--clear-tags", "Clear existing Tags")
+ .option("--metadata ", "Metadata as a JSON object")
+ .option("--metadata-file ", "Metadata JSON file path")
.action(async (streamKey, options) => {
const globalOptions = program.opts();
try {
+ if (options.clearTags && options.tags !== undefined) throw new Error("Provide either --tags or --clear-tags, not both.");
+ const tags = options.clearTags ? [] : options.tags;
+ const metadata = await loadMetadata(options);
const apiKey = requireApiKey(globalOptions);
const client = createClient(apiKey);
const contentState = await loadOptionalContentState(options, "end");
@@ -1539,7 +1609,7 @@ addLiveActivityActionOptions(addContentStateOptions(
const request =
contentState !== undefined ||
action !== undefined ||
- secondaryAction !== undefined
+ secondaryAction !== undefined || tags !== undefined || metadata !== undefined
? toApiLiveActivityStreamDeleteRequest(
contentState,
action,
@@ -1547,6 +1617,8 @@ addLiveActivityActionOptions(addContentStateOptions(
)
: undefined;
+ if (tags !== undefined) request.tags = tags;
+ if (metadata !== undefined) request.metadata = metadata;
const response = await client.liveActivities.endStream(streamKey, request);
const activityId = response?.activityId ?? response?.activity_id;
@@ -1561,7 +1633,7 @@ addLiveActivityActionOptions(addContentStateOptions(
await handleError(error, globalOptions);
}
}),
- { includeAutoDismiss: true }
+ { includeAutoDismiss: true, includeAutoDismissSeconds: true }
));
program.showHelpAfterError(true);
diff --git a/src/metadata.js b/src/metadata.js
new file mode 100644
index 0000000..ffaae7c
--- /dev/null
+++ b/src/metadata.js
@@ -0,0 +1,27 @@
+import { readFile } from "node:fs/promises";
+
+export async function loadMetadata(options) {
+ if (options.metadata !== undefined && options.metadataFile !== undefined) {
+ throw new Error("Use either --metadata or --metadata-file, not both.");
+ }
+ const raw = options.metadataFile !== undefined
+ ? await readFile(options.metadataFile, "utf8") : options.metadata;
+ if (raw === undefined) return undefined;
+ const value = JSON.parse(raw);
+ if (value === null || Array.isArray(value) || typeof value !== "object") {
+ throw new Error("Metadata must be a JSON object.");
+ }
+ if (Object.keys(value).length > 50 || Buffer.byteLength(JSON.stringify(value), "utf8") > 16384) {
+ throw new Error("Metadata supports at most 50 entries and 16 KB of JSON.");
+ }
+ for (const [key, item] of Object.entries(value)) {
+ if (!key.trim() || key.length > 100 || key === "__proto__") {
+ throw new Error("Metadata keys must contain 1-100 characters and cannot be __proto__.");
+ }
+ if (!(typeof item === "string" && item.length <= 4000) &&
+ !(typeof item === "number" && Number.isFinite(item)) && typeof item !== "boolean") {
+ throw new Error("Metadata values must be strings (up to 4000 characters), finite numbers, or booleans.");
+ }
+ }
+ return value;
+}
diff --git a/test/cli.test.js b/test/cli.test.js
index 555385b..90465bc 100644
--- a/test/cli.test.js
+++ b/test/cli.test.js
@@ -8,7 +8,7 @@ const runCli = (args) =>
globalThis.fetch = async (url, init) => {
process.stdout.write("CAPTURE:" + JSON.stringify({
url,
- body: JSON.parse(init.body)
+ body: init.body ? JSON.parse(init.body) : null
}) + "\\n");
return new Response(JSON.stringify({
success: true,
@@ -56,6 +56,7 @@ const runCli = (args) =>
resolve({
code,
+ stdout,
stderr,
request: capture ? JSON.parse(capture.slice("CAPTURE:".length)) : null,
});
@@ -131,3 +132,179 @@ test("tags rejects an empty list", async () => {
assert.match(result.stderr, /tags must contain at least one tag/);
assert.equal(result.request, null);
});
+
+for (const [type, fields] of Object.entries({
+ metrics: { metrics: [{ label: "CPU", value: 20 }] },
+ stats: { metrics: [{ label: "Status", value: "Healthy" }] },
+ progress: { percentage: 20 },
+ segmented_progress: { numberOfSteps: 3, currentStep: 1 },
+ timer: { durationSeconds: 60 },
+ alert: { message: "Recovered" },
+})) {
+ test(`${type} accepts icons and badges`, async () => {
+ const state = { title: "Status", type, ...fields,
+ icon: { symbol: "server.rack", color: "blue" },
+ badge: { title: "Production", color: "green" } };
+ const result = await runCli(["activity", "stream", "status", "--content-state", JSON.stringify(state)]);
+ assert.equal(result.code, 0, result.stderr);
+ assert.deepEqual(result.request.body.content_state.icon, state.icon);
+ assert.deepEqual(result.request.body.content_state.badge, state.badge);
+ });
+}
+
+for (const seconds of [0, 30]) {
+ for (const form of ["flag", "camel", "snake"]) {
+ test(`stream dismissal seconds ${seconds} via ${form}`, async () => {
+ const state = { title: "Finished", type: "timer" };
+ const args = ["activity", "end-stream", "job"];
+ if (form === "flag") args.push("--auto-dismiss-seconds", String(seconds));
+ else state[form === "camel" ? "autoDismissSeconds" : "auto_dismiss_seconds"] = seconds;
+ state.autoDismissMinutes = 5;
+ args.push("--content-state", JSON.stringify(state));
+ const result = await runCli(args);
+ assert.equal(result.code, 0, result.stderr);
+ assert.equal(result.request.body.content_state.auto_dismiss_seconds, seconds);
+ assert.equal(result.request.body.content_state.auto_dismiss_minutes, 5);
+ assert.equal(result.request.body.content_state.autoDismissSeconds, undefined);
+ });
+ }
+}
+
+for (const mode of ["stream", "update"]) {
+ test(`timer ${mode} preserves duration when omitted`, async () => {
+ const args = ["activity", mode];
+ if (mode === "stream") args.push("job");
+ else args.push("--activity-id", "activity-1");
+ args.push("--title", "Still working", "--type", "timer");
+ const result = await runCli(args);
+ assert.equal(result.code, 0, result.stderr);
+ assert.equal(result.request.body.content_state.duration_seconds, undefined);
+ assert.equal(result.request.body.content_state.counts_down, undefined);
+ });
+}
+
+test("new countdown still requires a duration", async () => {
+ const result = await runCli(["activity", "start", "--title", "Job", "--type", "timer"]);
+ assert.notEqual(result.code, 0);
+ assert.equal(result.request, null);
+});
+
+test("rejects negative dismissal seconds before sending", async () => {
+ const result = await runCli(["activity", "end-stream", "job", "--title", "Done",
+ "--type", "timer", "--auto-dismiss-seconds", "-1"]);
+ assert.notEqual(result.code, 0);
+ assert.equal(result.request, null);
+});
+
+test("icons still validate their symbol", async () => {
+ const result = await runCli(["activity", "stream", "job", "--content-state",
+ JSON.stringify({title: "Job", type: "progress", percentage: 20, icon: {color: "blue"}})]);
+ assert.notEqual(result.code, 0);
+ assert.equal(result.request, null);
+});
+
+const streamTagArgs = ["activity", "stream", "job", "--title", "Job", "--type", "progress", "--percentage", "50"];
+
+test("clear-tags sends an explicit empty array", async () => {
+ const result = await runCli([...streamTagArgs, "--clear-tags"]);
+ assert.equal(result.code, 0, result.stderr);
+ assert.deepEqual(result.request.body.tags, []);
+});
+
+test("omitting tag options preserves existing stream tags", async () => {
+ const result = await runCli(streamTagArgs);
+ assert.equal(result.code, 0, result.stderr);
+ assert.equal(Object.hasOwn(result.request.body, "tags"), false);
+});
+
+for (const options of [["--tags", "billing", "--clear-tags"], ["--clear-tags", "--tags", "billing"]]) {
+ test(`conflicting tag options fail before sending: ${options.join(" ")}`, async () => {
+ const result = await runCli([...streamTagArgs, ...options]);
+ assert.notEqual(result.code, 0);
+ assert.equal(result.request, null);
+ assert.match(result.stdout + result.stderr, /Use either --tags or --clear-tags/);
+ });
+}
+
+test("clear-tags is unavailable for new Push Notifications", async () => {
+ const result = await runCli(["push", "--title", "Done", "--clear-tags"]);
+ assert.notEqual(result.code, 0);
+ assert.equal(result.request, null);
+});
+
+for (const operation of ["update", "end"]) {
+ const args = ["activity", operation, "--activity-id", "test-id", "--title", "Job", "--type", "progress", "--percentage", "50"];
+ for (const [flags, tags] of [[[], undefined], [["--tags", "billing,production"], ["billing", "production"]], [["--clear-tags"], []]]) {
+ test(`${operation} preserves, replaces or clears Tags: ${flags.join(" ")}`, async () => {
+ const result = await runCli([...args, ...flags]);
+ assert.equal(result.code, 0, result.stdout + result.stderr);
+ assert.deepEqual(result.request.body.tags, tags);
+ assert.equal(Object.hasOwn(result.request.body, "tags"), tags !== undefined);
+ });
+ }
+ test(`${operation} rejects conflicting Tags flags before sending`, async () => {
+ const result = await runCli([...args, "--tags", "billing", "--clear-tags"]);
+ assert.notEqual(result.code, 0);
+ assert.equal(result.request, null);
+ });
+}
+
+
+for (const args of [
+ ["push", "--title", "Job"],
+ ["activity", "start", "--title", "Job", "--type", "progress", "--percentage", "50"],
+ ["activity", "stream", "job", "--title", "Job", "--type", "progress", "--percentage", "50"],
+ ...["update", "end"].map(op => ["activity", op, "--activity-id", "a", "--title", "Job", "--percentage", "50"]),
+]) {
+ for (const metadata of [{}, {order: "382", ready: false, count: 0, empty: "", ratio: 1.25}]) {
+ test(`${args.slice(0, 2).join(" ")} serializes Metadata ${JSON.stringify(metadata)}`, async () => {
+ const result = await runCli([...args, "--metadata", JSON.stringify(metadata)]);
+ assert.equal(result.code, 0, result.stdout + result.stderr);
+ assert.deepEqual(result.request.body.metadata, metadata);
+ assert.equal(result.request.body.content_state?.metadata, undefined);
+ });
+ }
+}
+for (const metadata of ["null", "[]", '{"nested":{}}', '{"value":null}', '{"__proto__":"bad"}']) {
+ test(`invalid Metadata rejected before sending: ${metadata}`, async () => {
+ const result = await runCli(["push", "--title", "Job", "--metadata", metadata]);
+ assert.notEqual(result.code, 0);
+ assert.equal(result.request, null);
+ });
+}
+
+
+for (const url of ["http://example.com", "https://example.com", "shortcuts://run-shortcut?name=Test", "spotify://", "spotify:track:123", "custom-app://item/42?q=a%20b", "x-safari-https://example.com"]) {
+ test(`Push Notification accepts external destination ${url}`, async () => {
+ const action = {title:"Open", type:"open_url", url};
+ const result = await runCli(["push", "--title", "Job", "--redirection", url, "--actions", JSON.stringify([action])]);
+ assert.equal(result.code, 0, result.stdout + result.stderr);
+ const expected = /^https?:/.test(url) ? new URL(url).toString() : url;
+ assert.equal(result.request.body.redirection, expected);
+ assert.equal(result.request.body.actions[0].url, expected);
+ });
+}
+for (const url of ["javascript:alert(1)", "file:///tmp/file", "activitysmith://internal", "data:text/plain,test", "spotify://a\nb"]) {
+ test(`Push Notification rejects blocked destination ${url}`, async () => {
+ const result = await runCli(["push", "--title", "Job", "--redirection", url]);
+ assert.notEqual(result.code, 0); assert.equal(result.request, null);
+ });
+}
+for (const [type,url,valid] of [["open_url","http://example.com",true], ["open_url","x-safari-https://example.com",true], ["open_url","spotify://",false], ["webhook","http://example.com",false], ["webhook","spotify://",false]]) {
+ test(`Live Activity ${type} URL policy ${url}`, async () => {
+ const result = await runCli([...streamTagArgs, "--action", JSON.stringify({title:"Open",type,url})]);
+ assert.equal(result.code === 0, valid, result.stdout + result.stderr);
+ });
+}
+for (const flags of [[], ["--tags","finished", "--metadata",'{"ready":false,"count":0}'], ["--clear-tags","--metadata","{}"]]) {
+ test(`end-stream history fields ${flags.join(" ")}`, async () => {
+ const result = await runCli(["activity","end-stream","job",...flags]);
+ assert.equal(result.code, 0, result.stdout + result.stderr);
+ if (flags[0] === "--clear-tags") { assert.deepEqual(result.request.body.tags, []); assert.deepEqual(result.request.body.metadata, {}); }
+ if (flags[0] === "--tags") { assert.deepEqual(result.request.body.tags, ["finished"]); assert.deepEqual(result.request.body.metadata, {ready:false,count:0}); }
+ });
+}
+test("end-stream rejects conflicting Tags flags", async () => {
+ const result = await runCli(["activity","end-stream","job","--tags","finished","--clear-tags"]);
+ assert.notEqual(result.code,0); assert.equal(result.request,null);
+});
diff --git a/test/metadata.test.js b/test/metadata.test.js
new file mode 100644
index 0000000..941d989
--- /dev/null
+++ b/test/metadata.test.js
@@ -0,0 +1,27 @@
+import assert from 'node:assert/strict';
+import test from 'node:test';
+import { mkdtemp, writeFile, rm } from 'node:fs/promises';
+import { tmpdir } from 'node:os';
+import { join } from 'node:path';
+import { loadMetadata } from '../src/metadata.js';
+
+test('Metadata file supports objects and clearing, and rejects conflicting sources', async t => {
+ const directory = await mkdtemp(join(tmpdir(), 'activitysmith-metadata-'));
+ t.after(() => rm(directory, {recursive: true, force: true}));
+ const file = join(directory, 'metadata.json');
+ for (const value of [{ready:false, count:0, empty:''}, {}]) {
+ await writeFile(file, JSON.stringify(value));
+ assert.deepEqual(await loadMetadata({metadataFile:file}), value);
+ }
+ await assert.rejects(loadMetadata({metadata:'{}', metadataFile:file}), /either/);
+ await assert.rejects(loadMetadata({metadataFile:join(directory, 'missing.json')}), /ENOENT/);
+ assert.equal(await loadMetadata({}), undefined);
+});
+
+test('Metadata limits match the API', async () => {
+ for (const value of [
+ Object.fromEntries(Array.from({length:51}, (_,i) => [`key${i}`, i])),
+ {['x'.repeat(101)]: 'value'}, {field:'x'.repeat(4001)},
+ {a:'x'.repeat(4000), b:'x'.repeat(4000), c:'x'.repeat(4000), d:'x'.repeat(4000), e:'x'.repeat(1000)},
+ ]) await assert.rejects(loadMetadata({metadata:JSON.stringify(value)}));
+});