stoaken 0.1.0 → 0.3.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.
- package/README.md +85 -39
- package/dist/index.js +347 -13
- package/package.json +51 -51
package/README.md
CHANGED
|
@@ -1,39 +1,85 @@
|
|
|
1
|
-
# stoaken
|
|
2
|
-
|
|
3
|
-
The official CLI for [Stoaken](https://stoaken.com) -- a design token management platform (Brands, Token Sets, Themes, DTCG-conformant token storage, versioned Releases with an approval gate) that AI coding agents and MCP-capable tools can read and propose changes to directly.
|
|
4
|
-
|
|
5
|
-
## Quickstart
|
|
6
|
-
|
|
7
|
-
```
|
|
8
|
-
npx stoaken init
|
|
9
|
-
```
|
|
10
|
-
|
|
11
|
-
Run it in the root of the project you want connected to a Stoaken Brand. It will:
|
|
12
|
-
|
|
13
|
-
1. Ask how you'd like to sign in -- open your browser for a real OAuth 2.1 login, or let you paste an API key you've already created at `/settings/api-keys`.
|
|
14
|
-
2. List the real Brands your account can see, and let you pick one.
|
|
15
|
-
3. Write `stoaken.config.json` (which Brand, which server -- safe to commit, holds no secret) and `.stoaken/credentials.json` (your bearer credential, `chmod 600`, automatically added to `.gitignore`).
|
|
16
|
-
4. Generate `.stoaken/SKILL.md` -- a short brief for AI coding agents (Claude Code, Cursor) explaining how to connect to this project's Stoaken MCP server and what its tools do.
|
|
17
|
-
|
|
18
|
-
Run `stoaken init` again in an already-configured directory to continue with the same Brand, switch to a different one, or re-authenticate.
|
|
19
|
-
|
|
20
|
-
## Authentication
|
|
21
|
-
|
|
22
|
-
Two paths, both landing on the same bearer credential:
|
|
23
|
-
|
|
24
|
-
- **Log in with browser** -- a real OAuth 2.1 Authorization Code + PKCE flow. The CLI discovers Stoaken's Authorization Server automatically (via its `/.well-known/oauth-protected-resource` metadata), registers itself as an OAuth client on first run, and opens your browser to sign in.
|
|
25
|
-
- **Paste an API key** -- for CI, scripts, or anywhere a browser isn't available. Create one at `/settings/api-keys` in the Stoaken app.
|
|
26
|
-
|
|
27
|
-
##
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
1
|
+
# stoaken
|
|
2
|
+
|
|
3
|
+
The official CLI for [Stoaken](https://www.stoaken.com) -- a design token management platform (Brands, Token Sets, Themes, DTCG-conformant token storage, versioned Releases with an approval gate) that AI coding agents and MCP-capable tools can read and propose changes to directly.
|
|
4
|
+
|
|
5
|
+
## Quickstart
|
|
6
|
+
|
|
7
|
+
```
|
|
8
|
+
npx stoaken init
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
Run it in the root of the project you want connected to a Stoaken Brand. It will:
|
|
12
|
+
|
|
13
|
+
1. Ask how you'd like to sign in -- open your browser for a real OAuth 2.1 login, or let you paste an API key you've already created at `/settings/api-keys`.
|
|
14
|
+
2. List the real Brands your account can see, and let you pick one.
|
|
15
|
+
3. Write `stoaken.config.json` (which Brand, which server -- safe to commit, holds no secret) and `.stoaken/credentials.json` (your bearer credential, `chmod 600`, automatically added to `.gitignore`).
|
|
16
|
+
4. Generate `.stoaken/SKILL.md` -- a short brief for AI coding agents (Claude Code, Cursor) explaining how to connect to this project's Stoaken MCP server and what its tools do.
|
|
17
|
+
|
|
18
|
+
Run `stoaken init` again in an already-configured directory to continue with the same Brand, switch to a different one, or re-authenticate.
|
|
19
|
+
|
|
20
|
+
## Authentication
|
|
21
|
+
|
|
22
|
+
Two paths, both landing on the same bearer credential:
|
|
23
|
+
|
|
24
|
+
- **Log in with browser** -- a real OAuth 2.1 Authorization Code + PKCE flow. The CLI discovers Stoaken's Authorization Server automatically (via its `/.well-known/oauth-protected-resource` metadata), registers itself as an OAuth client on first run, and opens your browser to sign in.
|
|
25
|
+
- **Paste an API key** -- for CI, scripts, or anywhere a browser isn't available. Create one at `/settings/api-keys` in the Stoaken app.
|
|
26
|
+
|
|
27
|
+
## Export your tokens as code
|
|
28
|
+
|
|
29
|
+
`stoaken export` writes a theme's live release as a file: CSS variables, W3C JSON, Style
|
|
30
|
+
Dictionary v4 JSON, or a JS/TS module. There are no timestamps in the output, so committed files
|
|
31
|
+
give clean diffs.
|
|
32
|
+
|
|
33
|
+
```sh
|
|
34
|
+
npx stoaken export --theme Default --format css --out src/styles/tokens.css
|
|
35
|
+
npx stoaken export --theme Default --format json-dtcg --mode color-scheme:dark --out tokens/dark.json
|
|
36
|
+
npx stoaken export --theme Default --format ts --out src/tokens.ts
|
|
37
|
+
npx stoaken export --theme Default --out - # to stdout
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
| Option | |
|
|
41
|
+
| --- | --- |
|
|
42
|
+
| `--theme <name\|id>` | Which theme (needed when the Brand has more than one) |
|
|
43
|
+
| `--format` | `css` (default), `json-dtcg`, `json-sd4`, `js`, `ts` |
|
|
44
|
+
| `--mode <key>` | A mode combination for the one-mode formats; CSS includes every mode |
|
|
45
|
+
| `--prefix <prefix>` | CSS only: a prefix for every variable name |
|
|
46
|
+
| `--release <id>` | One release (e.g. one still in review) instead of the live one |
|
|
47
|
+
| `--out <file\|->` | Where to write; default is the server's file name |
|
|
48
|
+
| `--check` | Write nothing; exit 1 if the file on disk is out of date |
|
|
49
|
+
|
|
50
|
+
Save your targets in `stoaken.config.json` and a plain `npx stoaken export` runs them all
|
|
51
|
+
(`npx stoaken export --check` checks them all):
|
|
52
|
+
|
|
53
|
+
```json
|
|
54
|
+
{
|
|
55
|
+
"brandId": "…",
|
|
56
|
+
"brandName": "Acme",
|
|
57
|
+
"serverUrl": "https://www.stoaken.com",
|
|
58
|
+
"exports": [
|
|
59
|
+
{ "theme": "Default", "format": "css", "out": "src/styles/tokens.css" },
|
|
60
|
+
{ "theme": "Default", "format": "ts", "out": "src/tokens.ts" }
|
|
61
|
+
]
|
|
62
|
+
}
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
**In CI**, set `STOAKEN_TOKEN` to an API key (Settings > API keys); it's used instead of the
|
|
66
|
+
sign-in saved by `stoaken init`. For example, to fail a build when the committed tokens are behind
|
|
67
|
+
the live release:
|
|
68
|
+
|
|
69
|
+
```sh
|
|
70
|
+
STOAKEN_TOKEN=${{ secrets.STOAKEN_TOKEN }} npx stoaken export --check
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
## What this connects to
|
|
74
|
+
|
|
75
|
+
Once set up, any MCP-capable client (Claude Code, Cursor, Claude Desktop) or a direct HTTP client can use the credential in `.stoaken/credentials.json` as a Bearer token against Stoaken's MCP server, which exposes four tools:
|
|
76
|
+
|
|
77
|
+
- `list_brands` -- every Brand you hold at least Viewer on.
|
|
78
|
+
- `get_theme_tokens(themeId)` -- a Theme's cascade-merged token tree.
|
|
79
|
+
- `list_releases(themeId)` -- a Theme's Releases with version, status, and change summary.
|
|
80
|
+
- `propose_token_change(themeId, tokenSetId, document, version, versionType)` -- proposes a token change and submits it for approval. **This never publishes.** A human reviewer always approves through the Stoaken Command Center before it goes live.
|
|
81
|
+
|
|
82
|
+
## Links
|
|
83
|
+
|
|
84
|
+
- [Stoaken on GitHub](https://github.com/craig-astravale/stoaken)
|
|
85
|
+
- [Report an issue](https://github.com/craig-astravale/stoaken/issues)
|
package/dist/index.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
|
|
3
3
|
// src/index.ts
|
|
4
|
-
import * as
|
|
4
|
+
import * as p4 from "@clack/prompts";
|
|
5
5
|
|
|
6
6
|
// src/commands/init.ts
|
|
7
7
|
import * as p2 from "@clack/prompts";
|
|
@@ -44,6 +44,14 @@ async function writeCredentials(cwd, credentials) {
|
|
|
44
44
|
await writeFile(path, `${JSON.stringify(credentials, null, 2)}
|
|
45
45
|
`, { encoding: "utf8", mode: 384 });
|
|
46
46
|
}
|
|
47
|
+
async function readCredentials(cwd) {
|
|
48
|
+
try {
|
|
49
|
+
const raw = await readFile(join(cwd, CREDENTIALS_FILE), "utf8");
|
|
50
|
+
return JSON.parse(raw);
|
|
51
|
+
} catch {
|
|
52
|
+
return null;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
47
55
|
async function readOAuthClient(cwd) {
|
|
48
56
|
try {
|
|
49
57
|
const raw = await readFile(join(cwd, OAUTH_CLIENT_FILE), "utf8");
|
|
@@ -121,7 +129,7 @@ function generatePkce() {
|
|
|
121
129
|
return { verifier, challenge };
|
|
122
130
|
}
|
|
123
131
|
function waitForCallback() {
|
|
124
|
-
return new Promise((
|
|
132
|
+
return new Promise((resolve2, reject) => {
|
|
125
133
|
const server = createServer((req, res) => {
|
|
126
134
|
const url = new URL(req.url ?? "/", REDIRECT_URI);
|
|
127
135
|
if (url.pathname !== "/callback") {
|
|
@@ -140,7 +148,7 @@ function waitForCallback() {
|
|
|
140
148
|
} else if (!code) {
|
|
141
149
|
reject(new Error("No authorization code in the callback"));
|
|
142
150
|
} else {
|
|
143
|
-
|
|
151
|
+
resolve2(code);
|
|
144
152
|
}
|
|
145
153
|
});
|
|
146
154
|
server.on("error", reject);
|
|
@@ -189,8 +197,8 @@ async function promptForApiKey() {
|
|
|
189
197
|
const key = await p.password({
|
|
190
198
|
message: "Paste your Stoaken API key (from /settings/api-keys):",
|
|
191
199
|
validate: (value) => {
|
|
192
|
-
if (!value?.startsWith("twk_")) {
|
|
193
|
-
return "That doesn't look like a Stoaken API key (should start with
|
|
200
|
+
if (!value?.startsWith("stk_") && !value?.startsWith("twk_")) {
|
|
201
|
+
return "That doesn't look like a Stoaken API key (should start with stk_)";
|
|
194
202
|
}
|
|
195
203
|
}
|
|
196
204
|
});
|
|
@@ -207,7 +215,7 @@ async function listBrands(serverUrl, credential) {
|
|
|
207
215
|
const transport = new StreamableHTTPClientTransport(new URL("/mcp", serverUrl), {
|
|
208
216
|
requestInit: { headers: { Authorization: `Bearer ${credential}` } }
|
|
209
217
|
});
|
|
210
|
-
const client = new Client({ name: "stoaken-cli", version: "0.
|
|
218
|
+
const client = new Client({ name: "stoaken-cli", version: "0.3.0" });
|
|
211
219
|
await client.connect(transport);
|
|
212
220
|
try {
|
|
213
221
|
const result = await client.callTool({ name: "list_brands", arguments: {} });
|
|
@@ -227,14 +235,19 @@ function generateSkillFile(config) {
|
|
|
227
235
|
|
|
228
236
|
This project is connected to the **${config.brandName}** Brand on Stoaken (\`${config.serverUrl}\`).
|
|
229
237
|
|
|
230
|
-
Stoaken's MCP server exposes
|
|
238
|
+
Stoaken's MCP server exposes the tools below. Connect to it at \`${config.serverUrl}/mcp\` using
|
|
231
239
|
the credential in \`.stoaken/credentials.json\` as a Bearer token (never commit that file --
|
|
232
240
|
it's already in \`.gitignore\`).
|
|
233
241
|
|
|
234
242
|
## Tools
|
|
235
243
|
|
|
236
244
|
- **list_brands** -- every Brand you hold at least Viewer on.
|
|
237
|
-
- **get_theme_tokens(themeId)** -- a Theme's cascade-merged token tree.
|
|
245
|
+
- **get_theme_tokens(themeId)** -- a Theme's cascade-merged token tree (aliases left raw).
|
|
246
|
+
- **get_resolved_theme_tokens(themeId, mode?)** -- a Theme's fully resolved tokens (aliases followed,
|
|
247
|
+
inheritance applied) for every mode-combination, or just \`mode\`.
|
|
248
|
+
- **list_deprecated_tokens(themeId, mode?)** -- deprecated tokens, each with its notice, its named
|
|
249
|
+
replacement, and the tokens still referencing it. **Don't use a deprecated token in new code --
|
|
250
|
+
use its replacement.** A resolved token carrying \`$deprecated\` is deprecated.
|
|
238
251
|
- **list_releases(themeId)** -- a Theme's Releases with version, status, and change summary.
|
|
239
252
|
- **propose_token_change(themeId, tokenSetId, document, version, versionType)** -- updates a Token
|
|
240
253
|
Set's document and submits a new Release for approval. **This never publishes.** The Release
|
|
@@ -242,6 +255,16 @@ it's already in \`.gitignore\`).
|
|
|
242
255
|
Command Center before it goes live -- exactly the same gate a person editing through the UI
|
|
243
256
|
goes through. There is no tool that publishes a Release; propose changes, then ask a human to
|
|
244
257
|
review them.
|
|
258
|
+
- **list_webhooks / create_webhook / update_webhook / rotate_webhook_secret / ping_webhook** -- manage a Brand's webhooks (Admin on the Brand; Team plan). A created or rotated secret is returned once; after a rotation the old secret keeps signing for 24 hours.
|
|
259
|
+
- **create_theme / update_theme / delete_theme** -- manage a Brand's themes: which token sets they stack and in what order (Editor on the Brand; Team plan). create_theme can copy another theme's modes. Settings, not token changes: no release needed. Only never-released themes can be deleted.
|
|
260
|
+
- **export_tokens(themeId | releaseId, format, mode?, prefix?)** -- read-only: a live release as a file (css, css-auto, scss, tailwind, tailwind4, swift, android, compose, json-dtcg, json-sd4, js, ts). Use it to update a project's committed token file.
|
|
261
|
+
- **propose_token_edits(themeId, tokenSetId, operations, version, versionType, baseHash?)** -- the
|
|
262
|
+
same review gate, but with targeted edits instead of a whole document: \`set\`, \`add\`,
|
|
263
|
+
\`rename\` (references follow), \`delete\`, \`rename_group\` and \`describe\`, applied all or
|
|
264
|
+
nothing. Prefer it to propose_token_change for small changes.
|
|
265
|
+
- **propose_token_deprecation(themeId, tokenPath, action, replacement?, note?, version)** --
|
|
266
|
+
deprecates (or un-deprecates) one token, naming its replacement, and submits a Release for review
|
|
267
|
+
the same way. Deprecation is a reviewed change, never a silent edit.
|
|
245
268
|
|
|
246
269
|
## This Brand
|
|
247
270
|
|
|
@@ -249,12 +272,18 @@ it's already in \`.gitignore\`).
|
|
|
249
272
|
- Brand name: ${config.brandName}
|
|
250
273
|
- Server: ${config.serverUrl}
|
|
251
274
|
|
|
275
|
+
## Tokens as code
|
|
276
|
+
|
|
277
|
+
\`npx stoaken export --theme <name> --format css|css-auto|scss|tailwind|tailwind4|swift|android|compose|json-dtcg|json-sd4|js|ts --out <file>\` writes a
|
|
278
|
+
theme's live release as a file (no timestamps, so diffs stay clean). \`--check\` fails when the
|
|
279
|
+
committed file is out of date. In CI, set \`STOAKEN_TOKEN\` to an API key.
|
|
280
|
+
|
|
252
281
|
Re-run \`stoaken init\` if you need to switch Brands or re-authenticate.
|
|
253
282
|
`;
|
|
254
283
|
}
|
|
255
284
|
|
|
256
285
|
// src/commands/init.ts
|
|
257
|
-
var DEFAULT_SERVER_URL = "https://stoaken.com";
|
|
286
|
+
var DEFAULT_SERVER_URL = "https://www.stoaken.com";
|
|
258
287
|
async function runInit(options) {
|
|
259
288
|
const cwd = process.cwd();
|
|
260
289
|
const serverUrl = options.server ?? DEFAULT_SERVER_URL;
|
|
@@ -340,13 +369,314 @@ async function runInit(options) {
|
|
|
340
369
|
await writeCredentials(cwd, { kind: credentialKind, token: credential });
|
|
341
370
|
skipToWrite = { brandId: brand.id, brandName: brand.name };
|
|
342
371
|
}
|
|
343
|
-
const
|
|
372
|
+
const keepExports = existing?.brandId === skipToWrite.brandId && existing.exports !== void 0;
|
|
373
|
+
const config = {
|
|
374
|
+
brandId: skipToWrite.brandId,
|
|
375
|
+
brandName: skipToWrite.brandName,
|
|
376
|
+
serverUrl,
|
|
377
|
+
...keepExports ? { exports: existing.exports } : {}
|
|
378
|
+
};
|
|
344
379
|
await writeConfig(cwd, config);
|
|
345
380
|
await ensureGitignored(cwd);
|
|
346
381
|
await writeSkillFile(cwd, generateSkillFile(config));
|
|
347
382
|
p2.outro(`Done -- this project is set up for ${config.brandName}.`);
|
|
348
383
|
}
|
|
349
384
|
|
|
385
|
+
// src/commands/export.ts
|
|
386
|
+
import { mkdir as mkdir2, readFile as readFile2, writeFile as writeFile2 } from "fs/promises";
|
|
387
|
+
import { dirname, relative, resolve } from "path";
|
|
388
|
+
import * as p3 from "@clack/prompts";
|
|
389
|
+
|
|
390
|
+
// src/api.ts
|
|
391
|
+
var ApiProblem = class extends Error {
|
|
392
|
+
constructor(problem) {
|
|
393
|
+
super(problem.detail ?? problem.title ?? `HTTP ${problem.status}`);
|
|
394
|
+
this.problem = problem;
|
|
395
|
+
this.name = "ApiProblem";
|
|
396
|
+
}
|
|
397
|
+
problem;
|
|
398
|
+
};
|
|
399
|
+
async function call(serverUrl, token, path) {
|
|
400
|
+
let response;
|
|
401
|
+
try {
|
|
402
|
+
response = await fetch(new URL(path, serverUrl), {
|
|
403
|
+
headers: { Authorization: `Bearer ${token}`, "User-Agent": "stoaken-cli/0.3.0" }
|
|
404
|
+
});
|
|
405
|
+
} catch (error) {
|
|
406
|
+
throw new Error(`Couldn't reach ${serverUrl}: ${error instanceof Error ? error.message : String(error)}`);
|
|
407
|
+
}
|
|
408
|
+
if (!response.ok) {
|
|
409
|
+
let body = {};
|
|
410
|
+
try {
|
|
411
|
+
body = await response.json();
|
|
412
|
+
} catch {
|
|
413
|
+
}
|
|
414
|
+
throw new ApiProblem({ ...body, status: response.status });
|
|
415
|
+
}
|
|
416
|
+
return response;
|
|
417
|
+
}
|
|
418
|
+
async function listThemes(serverUrl, token, brandId) {
|
|
419
|
+
const themes = [];
|
|
420
|
+
let cursor = null;
|
|
421
|
+
for (let page = 0; page < 50; page++) {
|
|
422
|
+
const query = new URLSearchParams({ limit: "100" });
|
|
423
|
+
if (cursor) query.set("cursor", cursor);
|
|
424
|
+
const response = await call(serverUrl, token, `/api/brands/${encodeURIComponent(brandId)}/themes?${query}`);
|
|
425
|
+
const body = await response.json();
|
|
426
|
+
themes.push(...body.data.map((t) => ({ id: t.id, name: t.name })));
|
|
427
|
+
cursor = body.next_cursor;
|
|
428
|
+
if (!cursor) break;
|
|
429
|
+
}
|
|
430
|
+
return themes;
|
|
431
|
+
}
|
|
432
|
+
async function fetchExport(serverUrl, token, target) {
|
|
433
|
+
const query = new URLSearchParams({ format: target.format });
|
|
434
|
+
if (target.mode) query.set("mode", target.mode);
|
|
435
|
+
if (target.prefix) query.set("prefix", target.prefix);
|
|
436
|
+
const path = target.releaseId ? `/api/releases/${encodeURIComponent(target.releaseId)}/export?${query}` : `/api/themes/${encodeURIComponent(target.themeId ?? "")}/export?${query}`;
|
|
437
|
+
const response = await call(serverUrl, token, path);
|
|
438
|
+
return {
|
|
439
|
+
text: await response.text(),
|
|
440
|
+
filename: response.headers.get("content-disposition"),
|
|
441
|
+
warnings: Number(response.headers.get("stoaken-export-warnings") ?? 0) || 0
|
|
442
|
+
};
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
// src/export-plan.ts
|
|
446
|
+
var EXPORT_FORMATS = ["css", "css-auto", "json-dtcg", "json-sd4", "js", "ts", "scss", "tailwind", "swift", "android", "tailwind4", "compose"];
|
|
447
|
+
var ExportUsageError = class extends Error {
|
|
448
|
+
constructor(message) {
|
|
449
|
+
super(message);
|
|
450
|
+
this.name = "ExportUsageError";
|
|
451
|
+
}
|
|
452
|
+
};
|
|
453
|
+
var VALUE_FLAGS = ["theme", "format", "mode", "prefix", "release", "out", "server"];
|
|
454
|
+
function isExportFormat(value) {
|
|
455
|
+
return typeof value === "string" && EXPORT_FORMATS.includes(value);
|
|
456
|
+
}
|
|
457
|
+
function parseExportArgs(argv) {
|
|
458
|
+
const values = {};
|
|
459
|
+
let check = false;
|
|
460
|
+
for (let i = 0; i < argv.length; i++) {
|
|
461
|
+
const arg = argv[i];
|
|
462
|
+
if (arg === "--check") {
|
|
463
|
+
check = true;
|
|
464
|
+
continue;
|
|
465
|
+
}
|
|
466
|
+
const match = /^--([a-z]+)(?:=(.*))?$/.exec(arg);
|
|
467
|
+
const name = match?.[1];
|
|
468
|
+
if (!match || !name || !VALUE_FLAGS.includes(name)) {
|
|
469
|
+
throw new ExportUsageError(`Unknown option: ${arg}. Run "stoaken export --help" for the options.`);
|
|
470
|
+
}
|
|
471
|
+
let value = match[2];
|
|
472
|
+
if (value === void 0) {
|
|
473
|
+
value = argv[i + 1];
|
|
474
|
+
i++;
|
|
475
|
+
}
|
|
476
|
+
if (value === void 0 || value.startsWith("--") && value !== "-") {
|
|
477
|
+
throw new ExportUsageError(`--${name} needs a value.`);
|
|
478
|
+
}
|
|
479
|
+
values[name] = value;
|
|
480
|
+
}
|
|
481
|
+
if (values.format !== void 0 && !isExportFormat(values.format)) {
|
|
482
|
+
throw new ExportUsageError(`Unknown format "${values.format}". Use one of: ${EXPORT_FORMATS.join(", ")}.`);
|
|
483
|
+
}
|
|
484
|
+
const hasTarget = ["theme", "format", "mode", "prefix", "release", "out"].some((k) => values[k] !== void 0);
|
|
485
|
+
return {
|
|
486
|
+
check,
|
|
487
|
+
server: values.server,
|
|
488
|
+
target: hasTarget ? {
|
|
489
|
+
theme: values.theme,
|
|
490
|
+
format: values.format ?? "css",
|
|
491
|
+
mode: values.mode,
|
|
492
|
+
prefix: values.prefix,
|
|
493
|
+
release: values.release,
|
|
494
|
+
out: values.out
|
|
495
|
+
} : null
|
|
496
|
+
};
|
|
497
|
+
}
|
|
498
|
+
function readSavedTargets(exportsValue) {
|
|
499
|
+
if (exportsValue === void 0 || exportsValue === null) return [];
|
|
500
|
+
if (!Array.isArray(exportsValue)) throw new ExportUsageError('"exports" in stoaken.config.json must be a list.');
|
|
501
|
+
return exportsValue.map((raw, i) => {
|
|
502
|
+
const where = `"exports" item ${i + 1} in stoaken.config.json`;
|
|
503
|
+
if (!raw || typeof raw !== "object" || Array.isArray(raw)) throw new ExportUsageError(`${where} must be an object.`);
|
|
504
|
+
const item = raw;
|
|
505
|
+
for (const key of Object.keys(item)) {
|
|
506
|
+
if (!["theme", "format", "mode", "prefix", "release", "out"].includes(key)) {
|
|
507
|
+
throw new ExportUsageError(`${where} has an unknown field "${key}".`);
|
|
508
|
+
}
|
|
509
|
+
if (typeof item[key] !== "string" || item[key] === "") throw new ExportUsageError(`${where}: "${key}" must be text.`);
|
|
510
|
+
}
|
|
511
|
+
const format = item.format ?? "css";
|
|
512
|
+
if (!isExportFormat(format)) throw new ExportUsageError(`${where}: unknown format "${String(format)}". Use one of: ${EXPORT_FORMATS.join(", ")}.`);
|
|
513
|
+
return {
|
|
514
|
+
theme: item.theme,
|
|
515
|
+
format,
|
|
516
|
+
mode: item.mode,
|
|
517
|
+
prefix: item.prefix,
|
|
518
|
+
release: item.release,
|
|
519
|
+
out: item.out
|
|
520
|
+
};
|
|
521
|
+
});
|
|
522
|
+
}
|
|
523
|
+
function chooseTheme(themes, wanted, brandName, interactive) {
|
|
524
|
+
const names = themes.map((t) => t.name).join(", ");
|
|
525
|
+
if (themes.length === 0) throw new ExportUsageError(`${brandName} has no themes yet.`);
|
|
526
|
+
if (wanted !== void 0) {
|
|
527
|
+
const byId = themes.find((t) => t.id === wanted);
|
|
528
|
+
if (byId) return { kind: "theme", theme: byId };
|
|
529
|
+
const byName = themes.filter((t) => t.name.trim().toLowerCase() === wanted.trim().toLowerCase());
|
|
530
|
+
if (byName.length === 1) return { kind: "theme", theme: byName[0] };
|
|
531
|
+
if (byName.length > 1) throw new ExportUsageError(`More than one theme is called "${wanted}". Use its id instead.`);
|
|
532
|
+
throw new ExportUsageError(`${brandName} has no theme called "${wanted}". Themes: ${names}.`);
|
|
533
|
+
}
|
|
534
|
+
if (themes.length === 1) return { kind: "theme", theme: themes[0] };
|
|
535
|
+
if (interactive) return { kind: "ask", themes };
|
|
536
|
+
throw new ExportUsageError(`${brandName} has ${themes.length} themes, so name one with --theme. Themes: ${names}.`);
|
|
537
|
+
}
|
|
538
|
+
function filenameFrom(disposition, fallback) {
|
|
539
|
+
const match = disposition ? /filename="?([^";]+)"?/i.exec(disposition) : null;
|
|
540
|
+
const name = (match?.[1] ?? fallback).replace(/[/\\]/g, "-").replace(/^\.+/, "");
|
|
541
|
+
return name || fallback;
|
|
542
|
+
}
|
|
543
|
+
function problemMessage(problem, context) {
|
|
544
|
+
const what = context.themeName ? `"${context.themeName}"` : "this theme";
|
|
545
|
+
switch (problem.status) {
|
|
546
|
+
case 401:
|
|
547
|
+
return context.usingEnvToken ? "Stoaken didn't accept STOAKEN_TOKEN. Check it's a current API key (Settings > API keys)." : `Stoaken didn't accept your saved sign-in. Run "stoaken init" again, or set STOAKEN_TOKEN to an API key.`;
|
|
548
|
+
case 403:
|
|
549
|
+
case 404:
|
|
550
|
+
return `You can't see ${what}, or it doesn't exist. Check the Brand in stoaken.config.json and your access.`;
|
|
551
|
+
case 409:
|
|
552
|
+
return problem.detail ? `${context.themeName ? `"${context.themeName}": ` : ""}${problem.detail}` : `${context.themeName ? `"${context.themeName}" has` : "This has"} nothing to export yet: approve a release first.`;
|
|
553
|
+
case 400: {
|
|
554
|
+
const modes = problem.available_modes?.filter((m) => m !== "");
|
|
555
|
+
if (problem.available_modes && modes?.length === 0) return "This theme has no modes, so leave out --mode.";
|
|
556
|
+
const extra = modes?.length ? ` Modes: ${modes.join(", ")}.` : problem.available_formats?.length ? ` Formats: ${problem.available_formats.join(", ")}.` : "";
|
|
557
|
+
return `${problem.detail ?? problem.title ?? "Stoaken refused the request."}${extra}`;
|
|
558
|
+
}
|
|
559
|
+
case 429:
|
|
560
|
+
return "Too many requests. Wait a minute and try again.";
|
|
561
|
+
default:
|
|
562
|
+
return `Stoaken answered ${problem.status}${problem.detail ? `: ${problem.detail}` : problem.title ? `: ${problem.title}` : "."}`;
|
|
563
|
+
}
|
|
564
|
+
}
|
|
565
|
+
function formatBytes(n) {
|
|
566
|
+
return n < 1024 ? `${n} B` : `${(n / 1024).toFixed(1)} KB`;
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
// src/commands/export.ts
|
|
570
|
+
var EXPORT_HELP = [
|
|
571
|
+
"stoaken export -- write your tokens as code",
|
|
572
|
+
"",
|
|
573
|
+
"Usage:",
|
|
574
|
+
" stoaken export [options]",
|
|
575
|
+
"",
|
|
576
|
+
"Options:",
|
|
577
|
+
" --theme <name|id> Which theme (needed when the Brand has more than one)",
|
|
578
|
+
` --format <format> ${EXPORT_FORMATS.join(", ")} (default css)`,
|
|
579
|
+
" --mode <key> A mode combination for the one-mode formats, e.g. color-scheme:dark",
|
|
580
|
+
" --prefix <prefix> CSS only: a prefix for every variable name",
|
|
581
|
+
" --release <id> Export one release instead of the theme's live one",
|
|
582
|
+
" --out <file|-> Where to write it (- for stdout); default: the server's file name",
|
|
583
|
+
" --check Write nothing; fail if the file on disk is out of date",
|
|
584
|
+
" --server <url> Override the server in stoaken.config.json",
|
|
585
|
+
"",
|
|
586
|
+
`With no options, every target in stoaken.config.json's "exports" list runs.`,
|
|
587
|
+
'Credentials: STOAKEN_TOKEN (an API key, for CI), else the sign-in from "stoaken init".'
|
|
588
|
+
].join("\n");
|
|
589
|
+
var say = (text) => process.stderr.write(`${text}
|
|
590
|
+
`);
|
|
591
|
+
async function runExport(argv) {
|
|
592
|
+
if (argv.includes("--help") || argv.includes("-h")) {
|
|
593
|
+
say(EXPORT_HELP);
|
|
594
|
+
return 0;
|
|
595
|
+
}
|
|
596
|
+
const cwd = process.cwd();
|
|
597
|
+
try {
|
|
598
|
+
const args = parseExportArgs(argv);
|
|
599
|
+
const config = await readConfig(cwd);
|
|
600
|
+
if (!config) throw new ExportUsageError('No stoaken.config.json here. Run "stoaken init" in your project first.');
|
|
601
|
+
const serverUrl = args.server ?? config.serverUrl;
|
|
602
|
+
const envToken = process.env.STOAKEN_TOKEN?.trim();
|
|
603
|
+
const token = envToken || (await readCredentials(cwd))?.token;
|
|
604
|
+
if (!token) throw new ExportUsageError('No credentials. Run "stoaken init", or set STOAKEN_TOKEN to an API key.');
|
|
605
|
+
const targets = args.target ? [args.target] : readSavedTargets(config.exports);
|
|
606
|
+
if (targets.length === 0) targets.push({ format: "css" });
|
|
607
|
+
let themes = null;
|
|
608
|
+
let failed = 0;
|
|
609
|
+
for (const target of targets) {
|
|
610
|
+
let themeName;
|
|
611
|
+
try {
|
|
612
|
+
let themeId;
|
|
613
|
+
if (!target.release) {
|
|
614
|
+
themes ??= await listThemes(serverUrl, token, config.brandId);
|
|
615
|
+
const choice = chooseTheme(themes, target.theme, config.brandName, Boolean(process.stdin.isTTY && process.stderr.isTTY));
|
|
616
|
+
const theme = choice.kind === "theme" ? choice.theme : await askForTheme(choice.themes);
|
|
617
|
+
if (!theme) return 1;
|
|
618
|
+
themeId = theme.id;
|
|
619
|
+
themeName = theme.name;
|
|
620
|
+
}
|
|
621
|
+
await exportOne(serverUrl, token, target, { themeId, themeName }, args.check, cwd);
|
|
622
|
+
} catch (error) {
|
|
623
|
+
failed++;
|
|
624
|
+
say(`\u2716 ${describe(error, themeName, Boolean(envToken))}`);
|
|
625
|
+
}
|
|
626
|
+
}
|
|
627
|
+
return failed > 0 ? 1 : 0;
|
|
628
|
+
} catch (error) {
|
|
629
|
+
say(`\u2716 ${describe(error, void 0, Boolean(process.env.STOAKEN_TOKEN?.trim()))}`);
|
|
630
|
+
return 1;
|
|
631
|
+
}
|
|
632
|
+
}
|
|
633
|
+
async function askForTheme(themes) {
|
|
634
|
+
const choice = await p3.select({ message: "Which theme?", options: themes.map((t) => ({ value: t.id, label: t.name })) });
|
|
635
|
+
if (p3.isCancel(choice)) {
|
|
636
|
+
p3.cancel("Cancelled.");
|
|
637
|
+
return null;
|
|
638
|
+
}
|
|
639
|
+
return themes.find((t) => t.id === choice) ?? null;
|
|
640
|
+
}
|
|
641
|
+
async function exportOne(serverUrl, token, target, theme, check, cwd) {
|
|
642
|
+
const file = await fetchExport(serverUrl, token, {
|
|
643
|
+
themeId: theme.themeId,
|
|
644
|
+
releaseId: target.release,
|
|
645
|
+
format: target.format,
|
|
646
|
+
mode: target.mode,
|
|
647
|
+
prefix: target.prefix
|
|
648
|
+
});
|
|
649
|
+
const label = theme.themeName ?? `release ${target.release}`;
|
|
650
|
+
const warnings = file.warnings > 0 ? ` (${file.warnings} warning${file.warnings === 1 ? "" : "s"}: some values can't be written in this format)` : "";
|
|
651
|
+
if (target.out === "-") {
|
|
652
|
+
if (check) throw new ExportUsageError("--check needs a file to compare with, not --out -.");
|
|
653
|
+
process.stdout.write(file.text);
|
|
654
|
+
if (warnings) say(`! ${label}${warnings}`);
|
|
655
|
+
return;
|
|
656
|
+
}
|
|
657
|
+
const path = resolve(cwd, target.out ?? filenameFrom(file.filename, `tokens.${target.format}`));
|
|
658
|
+
const shown = relative(cwd, path) || path;
|
|
659
|
+
if (check) {
|
|
660
|
+
let current = null;
|
|
661
|
+
try {
|
|
662
|
+
current = await readFile2(path, "utf8");
|
|
663
|
+
} catch {
|
|
664
|
+
}
|
|
665
|
+
if (current !== file.text) {
|
|
666
|
+
throw new ExportUsageError(`${shown} is out of date with ${label}${current === null ? " (it doesn't exist)" : ""}. Run "stoaken export" and commit the result.`);
|
|
667
|
+
}
|
|
668
|
+
say(`\u2714 ${shown} is up to date with ${label}`);
|
|
669
|
+
return;
|
|
670
|
+
}
|
|
671
|
+
await mkdir2(dirname(path), { recursive: true });
|
|
672
|
+
await writeFile2(path, file.text, "utf8");
|
|
673
|
+
say(`\u2714 ${label} \u2192 ${shown} (${target.format}, ${formatBytes(Buffer.byteLength(file.text))})${warnings}`);
|
|
674
|
+
}
|
|
675
|
+
function describe(error, themeName, usingEnvToken) {
|
|
676
|
+
if (error instanceof ApiProblem) return problemMessage(error.problem, { themeName, usingEnvToken });
|
|
677
|
+
return error instanceof Error ? error.message : String(error);
|
|
678
|
+
}
|
|
679
|
+
|
|
350
680
|
// src/index.ts
|
|
351
681
|
var [command, ...rest] = process.argv.slice(2);
|
|
352
682
|
function flagValue(args, name) {
|
|
@@ -357,19 +687,23 @@ switch (command) {
|
|
|
357
687
|
case "init":
|
|
358
688
|
await runInit({ server: flagValue(rest, "server") });
|
|
359
689
|
break;
|
|
690
|
+
case "export":
|
|
691
|
+
process.exitCode = await runExport(rest);
|
|
692
|
+
break;
|
|
360
693
|
case void 0:
|
|
361
694
|
case "--help":
|
|
362
695
|
case "-h":
|
|
363
|
-
|
|
696
|
+
p4.log.message(
|
|
364
697
|
[
|
|
365
698
|
"stoaken -- Stoaken CLI",
|
|
366
699
|
"",
|
|
367
700
|
"Usage:",
|
|
368
|
-
" stoaken init [--server <url>] Authenticate, pick a Brand, and set up this project"
|
|
701
|
+
" stoaken init [--server <url>] Authenticate, pick a Brand, and set up this project",
|
|
702
|
+
" stoaken export [options] Write your tokens as code (stoaken export --help)"
|
|
369
703
|
].join("\n")
|
|
370
704
|
);
|
|
371
705
|
break;
|
|
372
706
|
default:
|
|
373
|
-
|
|
707
|
+
p4.log.error(`Unknown command: ${command}`);
|
|
374
708
|
process.exitCode = 1;
|
|
375
709
|
}
|
package/package.json
CHANGED
|
@@ -1,51 +1,51 @@
|
|
|
1
|
-
{
|
|
2
|
-
"name": "stoaken",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "Stoaken CLI --
|
|
5
|
-
"license": "UNLICENSED",
|
|
6
|
-
"type": "module",
|
|
7
|
-
"bin": {
|
|
8
|
-
"stoaken": "dist/index.js"
|
|
9
|
-
},
|
|
10
|
-
"files": [
|
|
11
|
-
"dist"
|
|
12
|
-
],
|
|
13
|
-
"repository": {
|
|
14
|
-
"type": "git",
|
|
15
|
-
"url": "git+https://github.com/craig-astravale/stoaken.git",
|
|
16
|
-
"directory": "cli"
|
|
17
|
-
},
|
|
18
|
-
"bugs": {
|
|
19
|
-
"url": "https://github.com/craig-astravale/stoaken/issues"
|
|
20
|
-
},
|
|
21
|
-
"homepage": "https://github.com/craig-astravale/stoaken/tree/main/cli#readme",
|
|
22
|
-
"keywords": [
|
|
23
|
-
"design-tokens",
|
|
24
|
-
"dtcg",
|
|
25
|
-
"mcp",
|
|
26
|
-
"model-context-protocol",
|
|
27
|
-
"cli",
|
|
28
|
-
"design-system"
|
|
29
|
-
],
|
|
30
|
-
"author": "Stoaken",
|
|
31
|
-
"engines": {
|
|
32
|
-
"node": ">=18"
|
|
33
|
-
},
|
|
34
|
-
"scripts": {
|
|
35
|
-
"build": "tsup src/index.ts --format esm --clean --shims",
|
|
36
|
-
"dev": "tsx src/index.ts",
|
|
37
|
-
"typecheck": "tsc --noEmit",
|
|
38
|
-
"prepublishOnly": "npm run build"
|
|
39
|
-
},
|
|
40
|
-
"dependencies": {
|
|
41
|
-
"@clack/prompts": "^1.8.0",
|
|
42
|
-
"@modelcontextprotocol/sdk": "^1.30.0",
|
|
43
|
-
"open": "^11.0.2"
|
|
44
|
-
},
|
|
45
|
-
"devDependencies": {
|
|
46
|
-
"@types/node": "^26.5.0",
|
|
47
|
-
"tsup": "^8.5.1",
|
|
48
|
-
"tsx": "^4.23.13",
|
|
49
|
-
"typescript": "^7.0.2"
|
|
50
|
-
}
|
|
51
|
-
}
|
|
1
|
+
{
|
|
2
|
+
"name": "stoaken",
|
|
3
|
+
"version": "0.3.0",
|
|
4
|
+
"description": "Stoaken CLI -- set up a project for MCP/API access, and export your design tokens as code.",
|
|
5
|
+
"license": "UNLICENSED",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"bin": {
|
|
8
|
+
"stoaken": "dist/index.js"
|
|
9
|
+
},
|
|
10
|
+
"files": [
|
|
11
|
+
"dist"
|
|
12
|
+
],
|
|
13
|
+
"repository": {
|
|
14
|
+
"type": "git",
|
|
15
|
+
"url": "git+https://github.com/craig-astravale/stoaken.git",
|
|
16
|
+
"directory": "cli"
|
|
17
|
+
},
|
|
18
|
+
"bugs": {
|
|
19
|
+
"url": "https://github.com/craig-astravale/stoaken/issues"
|
|
20
|
+
},
|
|
21
|
+
"homepage": "https://github.com/craig-astravale/stoaken/tree/main/cli#readme",
|
|
22
|
+
"keywords": [
|
|
23
|
+
"design-tokens",
|
|
24
|
+
"dtcg",
|
|
25
|
+
"mcp",
|
|
26
|
+
"model-context-protocol",
|
|
27
|
+
"cli",
|
|
28
|
+
"design-system"
|
|
29
|
+
],
|
|
30
|
+
"author": "Stoaken",
|
|
31
|
+
"engines": {
|
|
32
|
+
"node": ">=18"
|
|
33
|
+
},
|
|
34
|
+
"scripts": {
|
|
35
|
+
"build": "tsup src/index.ts --format esm --clean --shims",
|
|
36
|
+
"dev": "tsx src/index.ts",
|
|
37
|
+
"typecheck": "tsc --noEmit",
|
|
38
|
+
"prepublishOnly": "npm run build"
|
|
39
|
+
},
|
|
40
|
+
"dependencies": {
|
|
41
|
+
"@clack/prompts": "^1.8.0",
|
|
42
|
+
"@modelcontextprotocol/sdk": "^1.30.0",
|
|
43
|
+
"open": "^11.0.2"
|
|
44
|
+
},
|
|
45
|
+
"devDependencies": {
|
|
46
|
+
"@types/node": "^26.5.0",
|
|
47
|
+
"tsup": "^8.5.1",
|
|
48
|
+
"tsx": "^4.23.13",
|
|
49
|
+
"typescript": "^7.0.2"
|
|
50
|
+
}
|
|
51
|
+
}
|