@dastyaresocial/ds-cs 0.0.0-stage → 0.1.0

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.
Files changed (39) hide show
  1. package/README.md +134 -2
  2. package/bin/ds-cs.mjs +14 -0
  3. package/package.json +33 -4
  4. package/skills/README.md +71 -0
  5. package/skills/dev/add-component/SKILL.md +166 -0
  6. package/skills/dev/add-orpc-endpoint/SKILL.md +143 -0
  7. package/skills/dev/db-migration/SKILL.md +104 -0
  8. package/skills/dev/release/SKILL.md +106 -0
  9. package/skills/product/brand-config/SKILL.md +98 -0
  10. package/skills/product/manage-stories/SKILL.md +119 -0
  11. package/skills/product/mcp-connect/SKILL.md +116 -0
  12. package/skills/product/post-with-media/SKILL.md +197 -0
  13. package/skills/product/self-host-deploy/SKILL.md +112 -0
  14. package/skills/product/webhooks/SKILL.md +160 -0
  15. package/src/agents.mjs +130 -0
  16. package/src/args.mjs +111 -0
  17. package/src/auth.mjs +82 -0
  18. package/src/commands/add.mjs +118 -0
  19. package/src/commands/auth.mjs +87 -0
  20. package/src/commands/init.mjs +106 -0
  21. package/src/commands/list.mjs +89 -0
  22. package/src/commands/mcp.mjs +101 -0
  23. package/src/commands/remove.mjs +80 -0
  24. package/src/commands/search.mjs +51 -0
  25. package/src/commands/target.mjs +83 -0
  26. package/src/commands/update.mjs +94 -0
  27. package/src/index.mjs +176 -0
  28. package/src/install.mjs +174 -0
  29. package/src/lockfile.mjs +74 -0
  30. package/src/mcp-config.mjs +39 -0
  31. package/src/registry/archive.mjs +209 -0
  32. package/src/registry/fetch.mjs +114 -0
  33. package/src/registry/sources.mjs +395 -0
  34. package/src/skills/discover.mjs +170 -0
  35. package/src/skills/frontmatter.mjs +85 -0
  36. package/src/utils/errors.mjs +13 -0
  37. package/src/utils/fs.mjs +106 -0
  38. package/src/utils/log.mjs +85 -0
  39. package/src/utils/prompt.mjs +69 -0
@@ -0,0 +1,112 @@
1
+ ---
2
+ name: self-host-deploy
3
+ description: Install, run, update and back up a self-hosted DS-CS channel with Docker Compose or Vercel. Use for the one-command installer, env var setup, reverse proxying, port layout, backing up the database, or moving a channel to a new version. For writing content use post-with-media.
4
+ ---
5
+
6
+ # Self-hosting DS-CS
7
+
8
+ ## What you need
9
+
10
+ - Docker and curl
11
+ - A domain with HTTPS (required for browser push notifications)
12
+ - Ports: `8729` (app), `9000` / `9001` (S3 console)
13
+
14
+ The stack ships Postgres 16 and rustfs (S3-compatible storage) alongside the app. You do not need to provision either.
15
+
16
+ ## Option A — one command
17
+
18
+ ```bash
19
+ curl -fsSL https://raw.githubusercontent.com/dastyare-social/DS-CS/main/scripts/install.sh | bash
20
+ ```
21
+
22
+ Into a specific directory:
23
+
24
+ ```bash
25
+ curl -fsSL https://raw.githubusercontent.com/dastyare-social/DS-CS/main/scripts/install.sh -o install.sh
26
+ bash install.sh /opt/ds-cs
27
+ ```
28
+
29
+ The script downloads `docker-compose.yml`, drops in the three editable brand files, creates `.env` with generated secrets if none exists, and starts the stack. It is non-destructive: existing files are left intact rather than overwritten. Compose runs under the pinned project name `ds-cs`, so containers and volumes are prefixed `ds-cs-` regardless of the install directory — two installs cannot collide on volume names.
30
+
31
+ **Review the generated `.env` before using this in production.** The secrets it writes are locally safe, not production-safe.
32
+
33
+ ## Option B — compose by hand
34
+
35
+ ```bash
36
+ curl -fsSL https://raw.githubusercontent.com/dastyare-social/DS-CS/main/docker-compose.yml -o docker-compose.yml
37
+ cp .env.example .env
38
+ $EDITOR .env
39
+ docker compose up -d
40
+ ```
41
+
42
+ The app container runs migrations and bootstraps the admin user automatically on boot. That is why `db:migrate` runs before `next build` in the build script — schema changes ship as files, never as live database edits.
43
+
44
+ ## Environment variables
45
+
46
+ | Variable | Purpose |
47
+ |---|---|
48
+ | `DATABASE_URL` | Postgres connection |
49
+ | `API_KEY` | Shared key for REST writes and MCP write tools |
50
+ | `MCP_API_KEY` | Optional separate key for the stdio MCP server |
51
+ | `BETTER_AUTH_URL`, `BETTER_AUTH_SECRET` | Auth |
52
+ | `ADMIN_EMAIL`, `ADMIN_PASSWORD` | Bootstrap admin, used once |
53
+ | `S3_ENDPOINT`, `S3_REGION`, `S3_ACCESS_KEY_ID`, `S3_SECRET_ACCESS_KEY`, `S3_BUCKET_NAME`, `S3_FORCE_PATH_STYLE` | Media storage |
54
+ | `S3_PUBLIC_BASE_URL` | Browser-reachable base for media URLs — keep in sync with the published rustfs port |
55
+ | `NEXT_PUBLIC_APP_URL` | Public URL; drives sitemap, canonical URLs and OG images |
56
+ | `NEXT_PUBLIC_WEBPUSH_PUBLIC_KEY`, `WEBPUSH_PRIVATE_KEY`, `WEBPUSH_SUBJECT` | Web Push (VAPID) |
57
+ | `NEXT_PUBLIC_ALLOW_INDEXING` | Unblocks search engines; default blocks indexing |
58
+ | `MEDIA_MAX_*_SIZE_MB`, `MEDIA_ALLOWED_MIME_TYPES` | Upload limits |
59
+ | `NEXT_PUBLIC_POSTHOG_PROJECT_TOKEN`, `POSTHOG_API_KEY` | Analytics |
60
+
61
+ `NEXT_PUBLIC_APP_URL` being wrong is the most common self-hosting mistake: the sitemap, canonical URLs and OG images are all built from it, so they silently point at localhost.
62
+
63
+ ## Reverse proxy
64
+
65
+ Terminate TLS at Caddy, nginx or Traefik and proxy to port `8729`. Push notifications require HTTPS — the Service Worker does not register on plain HTTP outside `localhost`.
66
+
67
+ Also proxy or expose the S3 port if media URLs should be served from your own domain; then set `S3_PUBLIC_BASE_URL` to match. Media served from an unconfigured domain falls back to unoptimized images, which is slower but not broken.
68
+
69
+ ## Vercel
70
+
71
+ The `Dockerfile.vercel` in the install is a pull-only blueprint (FROM `dastyaresocial/ds-cs:latest` with a PORT-aware CMD) so an installed folder can deploy to Vercel Fluid compute without the app source. Configure the same env vars there. Remember the database and S3 are external services — Vercel does not run your Postgres or rustfs.
72
+
73
+ ## Updating an existing install
74
+
75
+ ```bash
76
+ cd /opt/ds-cs
77
+ docker compose pull
78
+ docker compose up -d
79
+ ```
80
+
81
+ Migrations run on boot. Config files are bind-mounted and read per request, so a config edit does not need an update at all.
82
+
83
+ ## Backups
84
+
85
+ Two things matter and they live in different places:
86
+
87
+ ```bash
88
+ # database
89
+ docker compose exec -T db pg_dump -U postgres ds-cs > backup.sql
90
+
91
+ # media
92
+ docker run --rm -v ds-cs_rustfs_data:/data -v "$PWD:/backup" alpine \
93
+ tar czf /backup/media.tar.gz -C /data .
94
+ ```
95
+
96
+ The media backup volume name follows the pinned project name — confirm it with `docker volume ls | grep rustfs` before assuming. Losing media is not recoverable by re-running the installer; it is the one backup that actually matters.
97
+
98
+ Restore:
99
+
100
+ ```bash
101
+ docker compose exec -T db psql -U postgres ds-cs < backup.sql
102
+ ```
103
+
104
+ ## Verify
105
+
106
+ ```bash
107
+ docker compose ps
108
+ curl -s http://localhost:8729/api/posts?type=count
109
+ curl -s https://your-domain.com/.well-known/mcp
110
+ ```
111
+
112
+ Open the channel, confirm the feed loads, then confirm a media upload round-trips — that exercises the app, Postgres and S3 together.
@@ -0,0 +1,160 @@
1
+ ---
2
+ name: webhooks
3
+ description: Register, inspect and verify signed webhook deliveries from a DS-CS channel. Use for reacting to post and story events, debugging a failing endpoint, or verifying the x-ds-webhook-signature HMAC. For posting content use post-with-media.
4
+ ---
5
+
6
+ # Webhooks
7
+
8
+ ## What it is for
9
+
10
+ A channel POSTs a signed JSON body to a URL you register whenever something happens. Use it to mirror content into a newsletter, a Discord channel, a cache, or another service — anything that should react to activity without polling.
11
+
12
+ ## Register an endpoint
13
+
14
+ ```bash
15
+ curl -X POST https://cs.example.com/api/webhooks \
16
+ -H "Authorization: Bearer $API_KEY" \
17
+ -H "Content-Type: application/json" \
18
+ -d '{
19
+ "url": "https://my-app.com/hooks/ds-cs",
20
+ "events": ["post.created", "post.updated", "post.deleted"]
21
+ }'
22
+ ```
23
+
24
+ The response contains a **per-endpoint HMAC secret, returned only once**. Store it immediately — it cannot be read back in plaintext later.
25
+
26
+ ```json
27
+ {
28
+ "id": "…",
29
+ "url": "https://my-app.com/hooks/ds-cs",
30
+ "events": ["post.created"],
31
+ "secret": "whsec_…"
32
+ }
33
+ ```
34
+
35
+ ## Events
36
+
37
+ | Group | Events |
38
+ |---|---|
39
+ | Posts | `post.created`, `post.updated`, `post.deleted`, `post.reacted`, `post.viewed` |
40
+ | Stories | `story.created`, `story.updated`, `story.deleted`, `story.viewed`, `story.liked` |
41
+
42
+ Subscribe only to what you use — every event is a delivery, and each one is an outbound HTTP request from your channel.
43
+
44
+ ## Delivery payload
45
+
46
+ ```json
47
+ {
48
+ "id": "<delivery id>",
49
+ "event": "post.created",
50
+ "timestamp": "2026-08-23T10:15:00.000Z",
51
+ "data": { "…": "the post or story record" }
52
+ }
53
+ ```
54
+
55
+ `data` carries the affected record. Verify the signature **before** parsing it.
56
+
57
+ ## Verify the signature — do not skip this
58
+
59
+ Every delivery has:
60
+
61
+ ```
62
+ x-ds-webhook-signature: t=<unix>,v1=<hex>
63
+ ```
64
+
65
+ `t` is a unix timestamp, `v1` is a lowercase hex HMAC-SHA256. The signed message is the timestamp, a dot, and the **raw request body** — not the parsed JSON:
66
+
67
+ ```js
68
+ import { createHmac, timingSafeEqual } from "node:crypto";
69
+
70
+ export async function POST(request) {
71
+ const header = request.headers.get("x-ds-webhook-signature") ?? "";
72
+ const match = /^t=(\d+),v1=([0-9a-f]{64})$/.exec(header);
73
+ if (!match) return new Response("bad signature header", { status: 401 });
74
+
75
+ const [, timestamp, provided] = match;
76
+
77
+ // Reject replays: anything older than five minutes is not a fresh delivery.
78
+ const age = Math.abs(Date.now() / 1000 - Number(timestamp));
79
+ if (age > 300) return new Response("stale delivery", { status: 401 });
80
+
81
+ // The raw body — read as text BEFORE any JSON.parse.
82
+ const raw = await request.text();
83
+
84
+ const expected = createHmac("sha256", process.env.DS_CS_WEBHOOK_SECRET)
85
+ .update(`${timestamp}.${raw}`)
86
+ .digest("hex");
87
+
88
+ const a = Buffer.from(expected, "hex");
89
+ const b = Buffer.from(provided, "hex");
90
+ if (a.length !== b.length || !timingSafeEqual(a, b)) {
91
+ return new Response("invalid signature", { status: 401 });
92
+ }
93
+
94
+ const payload = JSON.parse(raw);
95
+ // … handle it
96
+ return new Response("ok", { status: 200 });
97
+ }
98
+ ```
99
+
100
+ Four things that break naive verification:
101
+
102
+ 1. **Sign the raw body.** Re-serializing with `JSON.stringify` changes key order and whitespace, so the HMAC will not match.
103
+ 2. **Use the raw request body**, not a framework-parsed object. In Next.js, call `await request.text()` — once. Calling `request.json()` first consumes the stream.
104
+ 3. **Compare with `timingSafeEqual`.** A plain `===` leaks the signature byte by byte.
105
+ 4. **Check the timestamp.** Without a freshness check, a captured delivery can be replayed forever.
106
+
107
+ The secret is **per endpoint**, not the channel `API_KEY`. The two are unrelated and never interchangeable.
108
+
109
+ ## Retries and delivery status
110
+
111
+ Failed deliveries are retried up to three times with backoff. The final failure is recorded.
112
+
113
+ ```bash
114
+ GET /api/webhooks # list, with lastStatus / lastAttemptAt / failureCount
115
+ GET /api/webhooks/{webhook_id} # one endpoint, with delivery status
116
+ ```
117
+
118
+ `failureCount` climbing is the signal to check: is your endpoint returning 2xx fast, or timing out?
119
+
120
+ ## Manage
121
+
122
+ ```bash
123
+ # change URL, events, or pause
124
+ curl -X PATCH https://cs.example.com/api/webhooks/$ID \
125
+ -H "Authorization: Bearer $API_KEY" \
126
+ -H "Content-Type: application/json" \
127
+ -d '{"events":["post.created","story.created"],"active":true}'
128
+
129
+ # pause without deleting
130
+ curl -X PATCH https://cs.example.com/api/webhooks/$ID \
131
+ -H "Authorization: Bearer $API_KEY" \
132
+ -H "Content-Type: application/json" \
133
+ -d '{"active":false}'
134
+
135
+ # delete permanently
136
+ curl -X DELETE https://cs.example.com/api/webhooks/$ID \
137
+ -H "Authorization: Bearer $API_KEY"
138
+ ```
139
+
140
+ Pausing (`active: false`) stops deliveries but keeps the endpoint and its secret. Deleting removes it and the secret with it.
141
+
142
+ ## Verify locally before going live
143
+
144
+ ```bash
145
+ ngrok http 3000 # or any tunnel
146
+ # register the https URL
147
+ curl -X POST https://cs.example.com/api/webhooks/$ID \
148
+ -H "Authorization: Bearer $API_KEY" -d '{"events":["post.created"]}'
149
+ # then create a post and watch the tunnel log
150
+ ```
151
+
152
+ Confirm your handler returns 2xx quickly. Slow handlers exhaust the retry budget.
153
+
154
+ ## Mistakes that cost time
155
+
156
+ - Verifying against the channel `API_KEY` instead of the endpoint secret.
157
+ - `JSON.parse` before `request.text()`, leaving nothing to verify.
158
+ - Skipping the timestamp check and leaving the endpoint replayable.
159
+ - Registering `*.*` and drowning in `post.viewed` deliveries.
160
+ - Treating a 4xx as retryable noise. A 4xx means your handler rejected the event; it will not succeed on retry. Return 2xx for anything you have durably queued, and process asynchronously.
package/src/agents.mjs ADDED
@@ -0,0 +1,130 @@
1
+ import fs from "node:fs/promises";
2
+ import os from "node:os";
3
+ import path from "node:path";
4
+
5
+ import { CliError } from "./utils/errors.mjs";
6
+ import { exists, isDirectory } from "./utils/fs.mjs";
7
+
8
+ /**
9
+ * Where each supported agent looks for project-scoped skills.
10
+ *
11
+ * Many agents share `.agents/skills/`, so they are listed as separate names but
12
+ * resolve to the same directory — installing to one installs to all of them.
13
+ * This mirrors `npx skills`; entries not listed here can still be targeted with
14
+ * `--agent-dir <path>`.
15
+ */
16
+ export const AGENTS = [
17
+ { name: "opencode", project: ".agents/skills", global: ".config/opencode/skills", detect: ".opencode" },
18
+ { name: "cursor", project: ".agents/skills", global: ".cursor/skills", detect: ".cursor" },
19
+ { name: "codex", project: ".agents/skills", global: ".codex/skills", detect: ".codex" },
20
+ { name: "claude-code", project: ".claude/skills", global: ".claude/skills", detect: ".claude" },
21
+ { name: "windsurf", project: ".windsurf/skills", global: ".codeium/windsurf/skills", detect: ".windsurf" },
22
+ { name: "amp", project: ".agents/skills", global: ".config/agents/skills", detect: ".amp" },
23
+ { name: "gemini-cli", project: ".agents/skills", global: ".gemini/skills", detect: ".gemini" },
24
+ { name: "github-copilot", project: ".agents/skills", global: ".copilot/skills", detect: ".github" },
25
+ { name: "goose", project: ".goose/skills", global: ".config/goose/skills", detect: ".goose" },
26
+ { name: "devin", project: ".devin/skills", global: ".config/devin/skills", detect: ".devin" },
27
+ { name: "kilo-code", project: ".agents/skills", global: ".kilocode/skills", detect: ".kilocode" },
28
+ { name: "cline", project: ".agents/skills", global: ".cline/skills", detect: ".cline" },
29
+ { name: "roo", project: ".roo/skills", global: ".roo/skills", detect: ".roo" },
30
+ { name: "crush", project: ".crush/skills", global: ".config/crush/skills", detect: ".crush" },
31
+ { name: "aider-desk", project: ".aider-desk/skills", global: ".aider-desk/skills", detect: ".aider-desk" },
32
+ ];
33
+
34
+ /**
35
+ * The canonical install location. Everything else either is this directory or
36
+ * points at it, which keeps a skill's files in exactly one place per project.
37
+ */
38
+ export const CANONICAL_AGENT = "opencode";
39
+
40
+ export function getAgent(name) {
41
+ const agent = AGENTS.find((candidate) => candidate.name === name);
42
+ if (!agent) {
43
+ throw new CliError(`Unknown agent "${name}"`, {
44
+ hint: `Known agents: ${AGENTS.map((candidate) => candidate.name).join(", ")}. Or use --agent-dir <path>.`,
45
+ });
46
+ }
47
+ return agent;
48
+ }
49
+
50
+ export function skillsDirFor(agentName, { projectRoot, global }) {
51
+ const agent = getAgent(agentName);
52
+ const relative = global ? agent.global : agent.project;
53
+ return global
54
+ ? path.join(os.homedir(), relative)
55
+ : path.join(projectRoot, relative);
56
+ }
57
+
58
+ /**
59
+ * Agents that look installed in this project (or in the user's home directory).
60
+ * `ds-cs add` uses this to pre-select targets in interactive mode; with no
61
+ * detection at all it falls back to the canonical directory.
62
+ */
63
+ export async function detectAgents({ projectRoot, global }) {
64
+ const detected = [];
65
+ for (const agent of AGENTS) {
66
+ const marker = agent.detect;
67
+ if (!marker) continue;
68
+ const projectMarker = path.join(projectRoot, marker);
69
+ const globalMarker = path.join(os.homedir(), marker);
70
+ if ((await exists(projectMarker)) || (await exists(globalMarker))) {
71
+ detected.push(agent.name);
72
+ }
73
+ }
74
+
75
+ if (detected.length === 0 && !global) {
76
+ return [CANONICAL_AGENT];
77
+ }
78
+ return [...new Set(detected)];
79
+ }
80
+
81
+ export function dedupeDirectories(directories) {
82
+ const seen = new Map();
83
+ for (const directory of directories) {
84
+ if (!seen.has(directory)) seen.set(directory, directory);
85
+ }
86
+ return [...seen.values()];
87
+ }
88
+
89
+ /**
90
+ * Install one skill directory into an agent's skills folder.
91
+ *
92
+ * Symlink by default: every agent points at the canonical copy, so an update
93
+ * lands everywhere at once and there is one source of truth. `--copy` is the
94
+ * escape hatch for filesystems or setups where symlinks do not work.
95
+ */
96
+ export async function linkSkill(skillDir, skillsDirectory, { copy }) {
97
+ const target = path.join(skillsDirectory, path.basename(skillDir));
98
+
99
+ if (await exists(target)) {
100
+ await fs.rm(target, { recursive: true, force: true });
101
+ }
102
+ await fs.mkdir(skillsDirectory, { recursive: true });
103
+
104
+ if (copy) {
105
+ await fs.cp(skillDir, target, { recursive: true });
106
+ } else {
107
+ try {
108
+ await fs.symlink(path.resolve(skillDir), target, "dir");
109
+ } catch (error) {
110
+ // Windows without developer mode, some container mounts, and certain
111
+ // network filesystems reject symlinks. Copying still produces a correct
112
+ // install, so degrade instead of failing the whole command.
113
+ await fs.cp(skillDir, target, { recursive: true });
114
+ return { target, linked: false, fallbackReason: error.code ?? "symlink unsupported" };
115
+ }
116
+ }
117
+
118
+ return { target, linked: !copy };
119
+ }
120
+
121
+ export async function unlinkSkill(name, skillsDirectory) {
122
+ const target = path.join(skillsDirectory, name);
123
+ if (!(await exists(target))) return false;
124
+ await fs.rm(target, { recursive: true, force: true });
125
+ return true;
126
+ }
127
+
128
+ export async function skillsDirectoryExists(skillsDirectory) {
129
+ return isDirectory(skillsDirectory);
130
+ }
package/src/args.mjs ADDED
@@ -0,0 +1,111 @@
1
+ /**
2
+ * Tiny argument parser. Enough for a CLI of this size and nothing more: no
3
+ * dependency, and unknown flags fail loudly instead of being silently dropped
4
+ * (a silently-ignored `--global` is worse than an error).
5
+ */
6
+
7
+ const ALIASES = {
8
+ g: "global",
9
+ a: "agent",
10
+ s: "skill",
11
+ y: "yes",
12
+ l: "list",
13
+ h: "help",
14
+ v: "version",
15
+ p: "project",
16
+ };
17
+
18
+ /** Flags that take a value. Everything else is treated as boolean. */
19
+ const VALUE_FLAGS = new Set([
20
+ "agent",
21
+ "skill",
22
+ "url",
23
+ "api-key",
24
+ "agent-dir",
25
+ "cwd",
26
+ "scope",
27
+ ]);
28
+
29
+ export function parseArgs(argv) {
30
+ const flags = {};
31
+ const positional = [];
32
+
33
+ for (let index = 0; index < argv.length; index += 1) {
34
+ const token = argv[index];
35
+
36
+ if (token === "--") {
37
+ positional.push(...argv.slice(index + 1));
38
+ break;
39
+ }
40
+
41
+ if (token.startsWith("--")) {
42
+ let [name, inlineValue] = splitFlag(token.slice(2));
43
+ name = ALIASES[name] ?? name;
44
+
45
+ if (inlineValue !== undefined) {
46
+ setFlag(flags, name, inlineValue);
47
+ continue;
48
+ }
49
+
50
+ const next = argv[index + 1];
51
+ if (VALUE_FLAGS.has(name)) {
52
+ if (next === undefined || next.startsWith("-")) {
53
+ throw new Error(`--${name} needs a value`);
54
+ }
55
+ setFlag(flags, name, next);
56
+ index += 1;
57
+ } else {
58
+ flags[name] = true;
59
+ }
60
+ continue;
61
+ }
62
+
63
+ if (token.startsWith("-") && token.length > 1) {
64
+ for (const letter of token.slice(1)) {
65
+ const name = ALIASES[letter] ?? letter;
66
+ if (VALUE_FLAGS.has(name)) {
67
+ const next = argv[index + 1];
68
+ if (next === undefined || next.startsWith("-")) {
69
+ throw new Error(`-${letter} needs a value`);
70
+ }
71
+ setFlag(flags, name, next);
72
+ index += 1;
73
+ } else {
74
+ flags[name] = true;
75
+ }
76
+ }
77
+ continue;
78
+ }
79
+
80
+ positional.push(token);
81
+ }
82
+
83
+ return { flags, positional };
84
+ }
85
+
86
+ function splitFlag(text) {
87
+ const equals = text.indexOf("=");
88
+ if (equals === -1) return [text, undefined];
89
+ return [text.slice(0, equals), text.slice(equals + 1)];
90
+ }
91
+
92
+ function setFlag(flags, name, value) {
93
+ const key = camelCase(name);
94
+ if (flags[key] === undefined) {
95
+ flags[key] = value;
96
+ } else if (Array.isArray(flags[key])) {
97
+ flags[key].push(value);
98
+ } else {
99
+ flags[key] = [flags[key], value];
100
+ }
101
+ }
102
+
103
+ export function camelCase(value) {
104
+ return value.replace(/-([a-z])/g, (_, letter) => letter.toUpperCase());
105
+ }
106
+
107
+ /** Normalise a repeatable flag into an array; `'*'` means "every agent". */
108
+ export function toArray(value) {
109
+ if (value === undefined) return [];
110
+ return Array.isArray(value) ? value : [value];
111
+ }
package/src/auth.mjs ADDED
@@ -0,0 +1,82 @@
1
+ import fs from "node:fs/promises";
2
+ import os from "node:os";
3
+ import path from "node:path";
4
+
5
+ import { CliError } from "./utils/errors.mjs";
6
+ import { exists } from "./utils/fs.mjs";
7
+
8
+ /**
9
+ * Channel credentials live in one JSON file outside the project, with owner-only
10
+ * permissions.
11
+ *
12
+ * Keeping them out of the repo is the point: `ds-cs login` stores a key that can
13
+ * create and delete content, and a project-local config is one `git add -A` away
14
+ * from being published.
15
+ */
16
+ export function configPath() {
17
+ const base =
18
+ process.env.DS_CS_CONFIG_DIR ??
19
+ (process.env.XDG_CONFIG_HOME
20
+ ? path.join(process.env.XDG_CONFIG_HOME, "ds-cs")
21
+ : path.join(os.homedir(), ".config", "ds-cs"));
22
+ return path.join(base, "auth.json");
23
+ }
24
+
25
+ export async function readAuth() {
26
+ const file = configPath();
27
+ if (!(await exists(file))) return { baseUrl: null, apiKey: null, loggedInAt: null };
28
+ try {
29
+ const parsed = JSON.parse(await fs.readFile(file, "utf8"));
30
+ return {
31
+ baseUrl: parsed.baseUrl ?? null,
32
+ apiKey: parsed.apiKey ?? null,
33
+ loggedInAt: parsed.loggedInAt ?? null,
34
+ };
35
+ } catch (error) {
36
+ throw new CliError(`Auth config at ${file} is not valid JSON`, {
37
+ hint: "Run `ds-cs logout` to clear it.",
38
+ cause: error,
39
+ });
40
+ }
41
+ }
42
+
43
+ export async function writeAuth({ baseUrl, apiKey }) {
44
+ const file = configPath();
45
+ await fs.mkdir(path.dirname(file), { recursive: true });
46
+ await fs.writeFile(
47
+ file,
48
+ `${JSON.stringify({ baseUrl, apiKey, loggedInAt: new Date().toISOString() }, null, 2)}\n`,
49
+ { encoding: "utf8", mode: 0o600 }
50
+ );
51
+ // Write with the mode, but also force it: an existing file keeps its old bits
52
+ // across a rewrite on some platforms.
53
+ await fs.chmod(file, 0o600).catch(() => {});
54
+ return file;
55
+ }
56
+
57
+ export async function clearAuth() {
58
+ const file = configPath();
59
+ if (!(await exists(file))) return false;
60
+ await fs.rm(file);
61
+ return true;
62
+ }
63
+
64
+ /** Resolve the channel URL for a command: flag, then stored login, then env. */
65
+ export async function resolveTarget({ url: urlFlag } = {}) {
66
+ const stored = await readAuth();
67
+ const baseUrl = (urlFlag ?? stored.baseUrl ?? process.env.DS_CS_URL ?? "").trim();
68
+ const apiKey = (stored.apiKey ?? process.env.DS_CS_API_KEY ?? "").trim();
69
+
70
+ if (!baseUrl) {
71
+ throw new CliError("No channel URL configured", {
72
+ hint: "Run `ds-cs login --url https://your-channel.com --api-key <key>`, or pass --url.",
73
+ });
74
+ }
75
+
76
+ return { baseUrl: baseUrl.replace(/\/+$/, ""), apiKey, stored };
77
+ }
78
+
79
+ export function redact(apiKey) {
80
+ if (!apiKey) return "";
81
+ return apiKey.length <= 8 ? "***" : `${apiKey.slice(0, 4)}…${apiKey.slice(-4)}`;
82
+ }