@servicetitan/hammer-token 2.5.0 → 3.0.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/CHANGELOG.md +56 -0
- package/README.md +332 -0
- package/build/web/core/component-variables.scss +1088 -131
- package/build/web/core/component.d.ts +558 -0
- package/build/web/core/component.js +6685 -249
- package/build/web/core/component.scss +557 -69
- package/build/web/core/css-utils/a2-border.css +47 -45
- package/build/web/core/css-utils/a2-color.css +443 -227
- package/build/web/core/css-utils/a2-font.css +0 -2
- package/build/web/core/css-utils/a2-spacing.css +476 -478
- package/build/web/core/css-utils/a2-utils.css +992 -772
- package/build/web/core/css-utils/border.css +47 -45
- package/build/web/core/css-utils/color.css +443 -227
- package/build/web/core/css-utils/font.css +0 -2
- package/build/web/core/css-utils/spacing.css +476 -478
- package/build/web/core/css-utils/utils.css +992 -772
- package/build/web/core/index.d.ts +6 -0
- package/build/web/core/index.js +1 -1
- package/build/web/core/primitive-variables.scss +148 -65
- package/build/web/core/primitive.d.ts +209 -0
- package/build/web/core/primitive.js +779 -61
- package/build/web/core/primitive.scss +207 -124
- package/build/web/core/semantic-variables.scss +363 -239
- package/build/web/core/semantic.d.ts +221 -0
- package/build/web/core/semantic.js +1613 -347
- package/build/web/core/semantic.scss +219 -137
- package/build/web/index.d.ts +3 -4
- package/build/web/index.js +0 -1
- package/build/web/types.d.ts +17 -0
- package/config.js +121 -497
- package/eslint.config.mjs +11 -1
- package/package.json +15 -5
- package/src/global/primitive/breakpoint.tokens.json +54 -0
- package/src/global/primitive/color.tokens.json +1092 -0
- package/src/global/primitive/duration.tokens.json +44 -0
- package/src/global/primitive/font.tokens.json +151 -0
- package/src/global/primitive/radius.tokens.json +94 -0
- package/src/global/primitive/size.tokens.json +174 -0
- package/src/global/primitive/transition.tokens.json +32 -0
- package/src/theme/core/background.tokens.json +1312 -0
- package/src/theme/core/border.tokens.json +192 -0
- package/src/theme/core/chart.tokens.json +982 -0
- package/src/theme/core/component/ai-mark.tokens.json +20 -0
- package/src/theme/core/component/alert.tokens.json +261 -0
- package/src/theme/core/component/announcement.tokens.json +460 -0
- package/src/theme/core/component/avatar.tokens.json +137 -0
- package/src/theme/core/component/badge.tokens.json +42 -0
- package/src/theme/core/component/breadcrumb.tokens.json +42 -0
- package/src/theme/core/component/button-toggle.tokens.json +428 -0
- package/src/theme/core/component/button.tokens.json +941 -0
- package/src/theme/core/component/calendar.tokens.json +391 -0
- package/src/theme/core/component/card.tokens.json +107 -0
- package/src/theme/core/component/checkbox.tokens.json +631 -0
- package/src/theme/core/component/chip.tokens.json +169 -0
- package/src/theme/core/component/combobox.tokens.json +269 -0
- package/src/theme/core/component/details.tokens.json +152 -0
- package/src/theme/core/component/dialog.tokens.json +87 -0
- package/src/theme/core/component/divider.tokens.json +23 -0
- package/src/theme/core/component/dnd.tokens.json +208 -0
- package/src/theme/core/component/drawer.tokens.json +61 -0
- package/src/theme/core/component/drilldown.tokens.json +61 -0
- package/src/theme/core/component/edit-card.tokens.json +381 -0
- package/src/theme/core/component/field-label.tokens.json +42 -0
- package/src/theme/core/component/field-message.tokens.json +74 -0
- package/src/theme/core/component/icon.tokens.json +42 -0
- package/src/theme/core/component/link.tokens.json +108 -0
- package/src/theme/core/component/list-view.tokens.json +82 -0
- package/src/theme/core/component/listbox.tokens.json +283 -0
- package/src/theme/core/component/menu.tokens.json +230 -0
- package/src/theme/core/component/overflow.tokens.json +84 -0
- package/src/theme/core/component/page.tokens.json +377 -0
- package/src/theme/core/component/pagination.tokens.json +63 -0
- package/src/theme/core/component/popover.tokens.json +122 -0
- package/src/theme/core/component/progress-bar.tokens.json +133 -0
- package/src/theme/core/component/radio.tokens.json +631 -0
- package/src/theme/core/component/segmented-control.tokens.json +175 -0
- package/src/theme/core/component/select-card.tokens.json +943 -0
- package/src/theme/core/component/side-nav.tokens.json +349 -0
- package/src/theme/core/component/skeleton.tokens.json +42 -0
- package/src/theme/core/component/spinner.tokens.json +96 -0
- package/src/theme/core/component/status-icon.tokens.json +164 -0
- package/src/theme/core/component/stepper.tokens.json +484 -0
- package/src/theme/core/component/switch.tokens.json +285 -0
- package/src/theme/core/component/tab.tokens.json +192 -0
- package/src/theme/core/component/text-field.tokens.json +160 -0
- package/src/theme/core/component/text.tokens.json +59 -0
- package/src/theme/core/component/toast.tokens.json +343 -0
- package/src/theme/core/component/toolbar.tokens.json +114 -0
- package/src/theme/core/component/tooltip.tokens.json +61 -0
- package/src/theme/core/focus.tokens.json +56 -0
- package/src/theme/core/foreground.tokens.json +416 -0
- package/src/theme/core/gradient.tokens.json +41 -0
- package/src/theme/core/opacity.tokens.json +25 -0
- package/src/theme/core/shadow.tokens.json +81 -0
- package/src/theme/core/status.tokens.json +74 -0
- package/src/theme/core/typography.tokens.json +163 -0
- package/src/utils/__tests__/css-utils-format-utils.test.js +312 -0
- package/src/utils/__tests__/sd-build-configs.test.js +306 -0
- package/src/utils/__tests__/sd-formats.test.js +950 -0
- package/src/utils/__tests__/sd-transforms.test.js +336 -0
- package/src/utils/__tests__/token-helpers.test.js +1160 -0
- package/src/utils/copy-css-utils-cli.js +13 -1
- package/src/utils/css-utils-format-utils.js +105 -176
- package/src/utils/figma/__tests__/sync-gradient.test.js +561 -0
- package/src/utils/figma/__tests__/token-conversion.test.js +117 -0
- package/src/utils/figma/__tests__/token-resolution.test.js +231 -0
- package/src/utils/figma/auth.js +355 -0
- package/src/utils/figma/constants.js +22 -0
- package/src/utils/figma/errors.js +80 -0
- package/src/utils/figma/figma-api.js +1069 -0
- package/src/utils/figma/get-token.js +348 -0
- package/src/utils/figma/sync-components.js +909 -0
- package/src/utils/figma/sync-main.js +692 -0
- package/src/utils/figma/sync-orchestration.js +683 -0
- package/src/utils/figma/sync-primitives.js +230 -0
- package/src/utils/figma/sync-semantic.js +1056 -0
- package/src/utils/figma/token-conversion.js +340 -0
- package/src/utils/figma/token-parsing.js +186 -0
- package/src/utils/figma/token-resolution.js +569 -0
- package/src/utils/figma/utils.js +199 -0
- package/src/utils/sd-build-configs.js +305 -0
- package/src/utils/sd-formats.js +965 -0
- package/src/utils/sd-transforms.js +165 -0
- package/src/utils/token-helpers.js +848 -0
- package/tsconfig.json +18 -0
- package/vitest.config.js +17 -0
- package/.turbo/turbo-build.log +0 -37
- package/build/web/core/raw.js +0 -229
- package/src/global/primitive/breakpoint.js +0 -19
- package/src/global/primitive/color.js +0 -231
- package/src/global/primitive/duration.js +0 -16
- package/src/global/primitive/font.js +0 -60
- package/src/global/primitive/radius.js +0 -31
- package/src/global/primitive/size.js +0 -55
- package/src/global/primitive/transition.js +0 -16
- package/src/theme/core/background.js +0 -170
- package/src/theme/core/border.js +0 -103
- package/src/theme/core/charts.js +0 -439
- package/src/theme/core/component/button.js +0 -708
- package/src/theme/core/component/checkbox.js +0 -405
- package/src/theme/core/focus.js +0 -35
- package/src/theme/core/foreground.js +0 -148
- package/src/theme/core/overlay.js +0 -137
- package/src/theme/core/shadow.js +0 -29
- package/src/theme/core/status.js +0 -49
- package/src/theme/core/typography.js +0 -82
- package/type/types.ts +0 -341
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest";
|
|
2
|
+
import {
|
|
3
|
+
buildReferenceChain,
|
|
4
|
+
getDefaultDescription,
|
|
5
|
+
getTokenDescription,
|
|
6
|
+
} from "../token-resolution.js";
|
|
7
|
+
|
|
8
|
+
// ---------------------------------------------------------------------------
|
|
9
|
+
// Helpers
|
|
10
|
+
// ---------------------------------------------------------------------------
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Build a minimal token registry Map.
|
|
14
|
+
* Each entry: { token: { $value }, resolvedLight }
|
|
15
|
+
*/
|
|
16
|
+
const makeRegistry = (entries) => {
|
|
17
|
+
const map = new Map();
|
|
18
|
+
for (const [path, data] of entries) {
|
|
19
|
+
map.set(path, data);
|
|
20
|
+
}
|
|
21
|
+
return map;
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
// ---------------------------------------------------------------------------
|
|
25
|
+
// buildReferenceChain
|
|
26
|
+
// ---------------------------------------------------------------------------
|
|
27
|
+
|
|
28
|
+
describe("buildReferenceChain", () => {
|
|
29
|
+
it("returns null for non-reference strings", () => {
|
|
30
|
+
expect(buildReferenceChain("#ffffff", new Map())).toBeNull();
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it("returns path when token not in registry", () => {
|
|
34
|
+
const result = buildReferenceChain("{color.primary}", new Map());
|
|
35
|
+
expect(result).toBe("color/primary");
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it("follows a single-hop reference to a terminal hex value", () => {
|
|
39
|
+
const registry = makeRegistry([
|
|
40
|
+
[
|
|
41
|
+
"color/primary",
|
|
42
|
+
{ token: { $value: "#0066cc" }, resolvedLight: "#0066cc" },
|
|
43
|
+
],
|
|
44
|
+
]);
|
|
45
|
+
const result = buildReferenceChain("{color.primary}", registry);
|
|
46
|
+
expect(result).toBe("color/primary → #0066cc");
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it("follows a multi-hop reference chain", () => {
|
|
50
|
+
const registry = makeRegistry([
|
|
51
|
+
[
|
|
52
|
+
"semantic/color/primary",
|
|
53
|
+
{ token: { $value: "{color.primary}" }, resolvedLight: null },
|
|
54
|
+
],
|
|
55
|
+
[
|
|
56
|
+
"color/primary",
|
|
57
|
+
{ token: { $value: "#0066cc" }, resolvedLight: "#0066cc" },
|
|
58
|
+
],
|
|
59
|
+
]);
|
|
60
|
+
const result = buildReferenceChain("{semantic.color.primary}", registry);
|
|
61
|
+
expect(result).toBe("semantic/color/primary → color/primary → #0066cc");
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it("handles dimension terminal with rem unit", () => {
|
|
65
|
+
const registry = makeRegistry([
|
|
66
|
+
[
|
|
67
|
+
"size/2",
|
|
68
|
+
{
|
|
69
|
+
token: { $value: { value: 0.5, unit: "rem" } },
|
|
70
|
+
resolvedLight: { value: 0.5, unit: "rem" },
|
|
71
|
+
},
|
|
72
|
+
],
|
|
73
|
+
]);
|
|
74
|
+
const result = buildReferenceChain("{size.2}", registry);
|
|
75
|
+
expect(result).toBe("size/2 → 0.5rem (8px)");
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
it("handles dimension terminal with px unit", () => {
|
|
79
|
+
const registry = makeRegistry([
|
|
80
|
+
[
|
|
81
|
+
"size/0",
|
|
82
|
+
{
|
|
83
|
+
token: { $value: { value: 0, unit: "rem" } },
|
|
84
|
+
resolvedLight: { value: 0, unit: "rem" },
|
|
85
|
+
},
|
|
86
|
+
],
|
|
87
|
+
]);
|
|
88
|
+
const result = buildReferenceChain("{size.0}", registry);
|
|
89
|
+
expect(result).toBe("size/0 → 0rem");
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
it("handles composite color terminal with hex resolution", () => {
|
|
93
|
+
const registry = makeRegistry([
|
|
94
|
+
[
|
|
95
|
+
"shadow/color/default",
|
|
96
|
+
{
|
|
97
|
+
token: { $value: { color: "{color.neutral.900}", alpha: 0.08 } },
|
|
98
|
+
resolvedLight: null,
|
|
99
|
+
},
|
|
100
|
+
],
|
|
101
|
+
[
|
|
102
|
+
"color/neutral/900",
|
|
103
|
+
{ token: { $value: "#1a1a1a" }, resolvedLight: "#1a1a1a" },
|
|
104
|
+
],
|
|
105
|
+
]);
|
|
106
|
+
const result = buildReferenceChain("{shadow.color.default}", registry);
|
|
107
|
+
expect(result).toBe(
|
|
108
|
+
"shadow/color/default → color/neutral/900 at 8% opacity → #1A1A1A at 8% opacity",
|
|
109
|
+
);
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
it("handles composite color terminal without resolving base hex", () => {
|
|
113
|
+
const registry = makeRegistry([
|
|
114
|
+
[
|
|
115
|
+
"shadow/color/default",
|
|
116
|
+
{
|
|
117
|
+
token: { $value: { color: "{color.neutral.900}", alpha: 0.08 } },
|
|
118
|
+
resolvedLight: null,
|
|
119
|
+
},
|
|
120
|
+
],
|
|
121
|
+
// color/neutral/900 not in registry
|
|
122
|
+
]);
|
|
123
|
+
const result = buildReferenceChain("{shadow.color.default}", registry);
|
|
124
|
+
expect(result).toBe(
|
|
125
|
+
"shadow/color/default → color/neutral/900 at 8% opacity",
|
|
126
|
+
);
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
it("stops at cycle and returns path without infinite recursion", () => {
|
|
130
|
+
const registry = makeRegistry([
|
|
131
|
+
["a/b", { token: { $value: "{a.b}" }, resolvedLight: null }],
|
|
132
|
+
]);
|
|
133
|
+
// Should not throw or infinite-loop; cycle terminates at the repeated path
|
|
134
|
+
const result = buildReferenceChain("{a.b}", registry);
|
|
135
|
+
expect(result).toBe("a/b → a/b");
|
|
136
|
+
});
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
// ---------------------------------------------------------------------------
|
|
140
|
+
// getDefaultDescription
|
|
141
|
+
// ---------------------------------------------------------------------------
|
|
142
|
+
|
|
143
|
+
describe("getDefaultDescription", () => {
|
|
144
|
+
it("returns empty string when token has no $value", () => {
|
|
145
|
+
expect(getDefaultDescription({}, new Map())).toBe("");
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
it("returns empty string for non-reference literal values", () => {
|
|
149
|
+
expect(getDefaultDescription({ $value: "#ffffff" }, new Map())).toBe("");
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
it("returns chain for reference value", () => {
|
|
153
|
+
const registry = makeRegistry([
|
|
154
|
+
[
|
|
155
|
+
"color/primary",
|
|
156
|
+
{ token: { $value: "#0066cc" }, resolvedLight: "#0066cc" },
|
|
157
|
+
],
|
|
158
|
+
]);
|
|
159
|
+
const token = { $value: "{color.primary}" };
|
|
160
|
+
expect(getDefaultDescription(token, registry)).toBe(
|
|
161
|
+
"color/primary → #0066cc",
|
|
162
|
+
);
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
it("returns composite color description for composite value with reference", () => {
|
|
166
|
+
const registry = makeRegistry([
|
|
167
|
+
[
|
|
168
|
+
"color/neutral/900",
|
|
169
|
+
{ token: { $value: "#1a1a1a" }, resolvedLight: "#1a1a1a" },
|
|
170
|
+
],
|
|
171
|
+
]);
|
|
172
|
+
const token = { $value: { color: "{color.neutral.900}", alpha: 0.08 } };
|
|
173
|
+
expect(getDefaultDescription(token, registry)).toBe(
|
|
174
|
+
"color/neutral/900 at 8% opacity → #1A1A1A at 8% opacity",
|
|
175
|
+
);
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
it("returns composite color without hex when base not in registry", () => {
|
|
179
|
+
const token = { $value: { color: "{color.neutral.900}", alpha: 0.5 } };
|
|
180
|
+
expect(getDefaultDescription(token, new Map())).toBe(
|
|
181
|
+
"color/neutral/900 at 50% opacity",
|
|
182
|
+
);
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
it("returns empty string for composite color with no color ref", () => {
|
|
186
|
+
const token = { $value: { color: "#ffffff", alpha: 0.5 } };
|
|
187
|
+
expect(getDefaultDescription(token, new Map())).toBe("");
|
|
188
|
+
});
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
// ---------------------------------------------------------------------------
|
|
192
|
+
// getTokenDescription
|
|
193
|
+
// ---------------------------------------------------------------------------
|
|
194
|
+
|
|
195
|
+
describe("getTokenDescription", () => {
|
|
196
|
+
it("returns only explicit $description when no chain available", () => {
|
|
197
|
+
const token = { $description: "My description", $value: "#ffffff" };
|
|
198
|
+
expect(getTokenDescription(token, new Map())).toBe("My description");
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
it("returns chain when no $description and value is a reference", () => {
|
|
202
|
+
const registry = makeRegistry([
|
|
203
|
+
[
|
|
204
|
+
"color/primary",
|
|
205
|
+
{ token: { $value: "#0066cc" }, resolvedLight: "#0066cc" },
|
|
206
|
+
],
|
|
207
|
+
]);
|
|
208
|
+
const token = { $value: "{color.primary}" };
|
|
209
|
+
expect(getTokenDescription(token, registry)).toBe(
|
|
210
|
+
"color/primary → #0066cc",
|
|
211
|
+
);
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
it("combines $description and chain with newline when both exist", () => {
|
|
215
|
+
const registry = makeRegistry([
|
|
216
|
+
[
|
|
217
|
+
"color/primary",
|
|
218
|
+
{ token: { $value: "#0066cc" }, resolvedLight: "#0066cc" },
|
|
219
|
+
],
|
|
220
|
+
]);
|
|
221
|
+
const token = { $description: "Primary color", $value: "{color.primary}" };
|
|
222
|
+
expect(getTokenDescription(token, registry)).toBe(
|
|
223
|
+
"Primary color\ncolor/primary → #0066cc",
|
|
224
|
+
);
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
it("returns empty string when no $description and no chain", () => {
|
|
228
|
+
const token = { $value: "#ffffff" };
|
|
229
|
+
expect(getTokenDescription(token, new Map())).toBe("");
|
|
230
|
+
});
|
|
231
|
+
});
|
|
@@ -0,0 +1,355 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Figma Authentication Module
|
|
5
|
+
*
|
|
6
|
+
* Handles loading configuration and OAuth2 refresh token authentication for Figma API access.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
const fs = require("fs");
|
|
10
|
+
const path = require("path");
|
|
11
|
+
const {
|
|
12
|
+
FIGMA_PROD_FILE_KEY,
|
|
13
|
+
COLLECTION_NAME,
|
|
14
|
+
LIGHT_MODE_NAME,
|
|
15
|
+
DARK_MODE_NAME,
|
|
16
|
+
} = require("./constants");
|
|
17
|
+
const { ConfigurationError } = require("./errors");
|
|
18
|
+
const { validateFileKey } = require("./utils");
|
|
19
|
+
|
|
20
|
+
// .env.local file path (repo root)
|
|
21
|
+
const ENV_FILE = path.join(__dirname, "../../../../../.env.local");
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Load environment variables from .env.local file if it exists
|
|
25
|
+
*/
|
|
26
|
+
function loadEnvFile() {
|
|
27
|
+
if (fs.existsSync(ENV_FILE)) {
|
|
28
|
+
try {
|
|
29
|
+
const envContent = fs.readFileSync(ENV_FILE, "utf8");
|
|
30
|
+
const lines = envContent.split("\n");
|
|
31
|
+
|
|
32
|
+
for (const line of lines) {
|
|
33
|
+
// Skip comments and empty lines
|
|
34
|
+
const trimmed = line.trim();
|
|
35
|
+
if (!trimmed || trimmed.startsWith("#")) {
|
|
36
|
+
continue;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// Parse KEY=VALUE format
|
|
40
|
+
const match = trimmed.match(/^([^=]+)=(.*)$/);
|
|
41
|
+
if (match) {
|
|
42
|
+
const key = match[1].trim();
|
|
43
|
+
const value = match[2].trim().replace(/^["']|["']$/g, ""); // Remove quotes
|
|
44
|
+
// Only set if not already in process.env (env vars take precedence)
|
|
45
|
+
if (!process.env[key]) {
|
|
46
|
+
process.env[key] = value;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
} catch (error) {
|
|
51
|
+
// Silently fail if .env.local file can't be read
|
|
52
|
+
if (process.env.DEBUG) {
|
|
53
|
+
console.warn(
|
|
54
|
+
`Warning: Could not read .env.local file: ${error.message}`,
|
|
55
|
+
);
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// Load .env.local file on module load
|
|
62
|
+
loadEnvFile();
|
|
63
|
+
|
|
64
|
+
// Default configuration (uses production file key)
|
|
65
|
+
const DEFAULT_CONFIG = {
|
|
66
|
+
fileKey: FIGMA_PROD_FILE_KEY,
|
|
67
|
+
collectionName: COLLECTION_NAME,
|
|
68
|
+
lightModeName: LIGHT_MODE_NAME,
|
|
69
|
+
darkModeName: DARK_MODE_NAME,
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
// OAuth2 token cache (in-memory)
|
|
73
|
+
let tokenCache = {
|
|
74
|
+
accessToken: null,
|
|
75
|
+
expiresAt: null,
|
|
76
|
+
refreshToken: null,
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
// Figma OAuth2 endpoints
|
|
80
|
+
const FIGMA_OAUTH_BASE = "https://api.figma.com/v1";
|
|
81
|
+
const TOKEN_EXPIRY_BUFFER_MS = 60000; // Refresh 1 minute before expiry
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Validate configuration
|
|
85
|
+
* @param {Object} config - Configuration object to validate
|
|
86
|
+
* @throws {ConfigurationError} If configuration is invalid
|
|
87
|
+
*/
|
|
88
|
+
function validateConfig(config) {
|
|
89
|
+
const errors = [];
|
|
90
|
+
|
|
91
|
+
if (!config.fileKey) {
|
|
92
|
+
errors.push(
|
|
93
|
+
"FIGMA_FILE_KEY is required (set via env var, config file, or --file-key flag)",
|
|
94
|
+
);
|
|
95
|
+
} else {
|
|
96
|
+
try {
|
|
97
|
+
validateFileKey(config.fileKey);
|
|
98
|
+
} catch (error) {
|
|
99
|
+
errors.push(`Invalid file key: ${error.message}`);
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
if (!isOAuth2Configured(config)) {
|
|
104
|
+
errors.push(
|
|
105
|
+
"OAuth2 credentials are required. Set FIGMA_CLIENT_ID, FIGMA_CLIENT_SECRET, and FIGMA_REFRESH_TOKEN",
|
|
106
|
+
);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
if (errors.length > 0) {
|
|
110
|
+
throw new ConfigurationError("Configuration validation failed", { errors });
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Load configuration from environment variables
|
|
116
|
+
* @param {string} [overrideFileKey] - Optional fileKey to override the default or env var
|
|
117
|
+
* @param {Object} [options] - Options
|
|
118
|
+
* @param {boolean} [options.skipValidation=false] - Skip validation (for testing)
|
|
119
|
+
* @returns {Object} Configuration object with fileKey, collectionName, mode names, and OAuth2 credentials
|
|
120
|
+
*/
|
|
121
|
+
function loadConfig(overrideFileKey = null, options = {}) {
|
|
122
|
+
const { skipValidation = false } = options;
|
|
123
|
+
const config = { ...DEFAULT_CONFIG };
|
|
124
|
+
|
|
125
|
+
// Load from environment variables
|
|
126
|
+
if (process.env.FIGMA_CLIENT_ID) {
|
|
127
|
+
config.clientId = process.env.FIGMA_CLIENT_ID;
|
|
128
|
+
}
|
|
129
|
+
if (process.env.FIGMA_CLIENT_SECRET) {
|
|
130
|
+
config.clientSecret = process.env.FIGMA_CLIENT_SECRET;
|
|
131
|
+
}
|
|
132
|
+
if (process.env.FIGMA_REFRESH_TOKEN) {
|
|
133
|
+
config.refreshToken = process.env.FIGMA_REFRESH_TOKEN;
|
|
134
|
+
}
|
|
135
|
+
if (process.env.FIGMA_FILE_KEY) {
|
|
136
|
+
config.fileKey = process.env.FIGMA_FILE_KEY;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// Override fileKey if provided as parameter (takes precedence)
|
|
140
|
+
if (overrideFileKey) {
|
|
141
|
+
config.fileKey = overrideFileKey;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// Validate configuration unless explicitly skipped
|
|
145
|
+
if (!skipValidation) {
|
|
146
|
+
validateConfig(config);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
return config;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* Check if OAuth2 credentials are configured
|
|
154
|
+
* @param {Object} config - Configuration object
|
|
155
|
+
* @returns {boolean} True if OAuth2 is configured
|
|
156
|
+
*/
|
|
157
|
+
function isOAuth2Configured(config) {
|
|
158
|
+
return !!(config.clientId && config.clientSecret && config.refreshToken);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* Check if a cached access token is still valid
|
|
163
|
+
* @returns {boolean} True if token is valid and not expired
|
|
164
|
+
*/
|
|
165
|
+
function isTokenValid() {
|
|
166
|
+
if (!tokenCache.accessToken || !tokenCache.expiresAt) {
|
|
167
|
+
return false;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// Check if token expires within the buffer time
|
|
171
|
+
const now = Date.now();
|
|
172
|
+
return tokenCache.expiresAt > now + TOKEN_EXPIRY_BUFFER_MS;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* Refresh OAuth2 access token using refresh token
|
|
177
|
+
* @param {Object} config - Configuration object with OAuth2 credentials
|
|
178
|
+
* @returns {Promise<string>} New access token
|
|
179
|
+
* @throws {Error} If refresh fails
|
|
180
|
+
*/
|
|
181
|
+
async function refreshAccessToken(config) {
|
|
182
|
+
const { clientId, clientSecret, refreshToken } = config;
|
|
183
|
+
|
|
184
|
+
if (!clientId || !clientSecret || !refreshToken) {
|
|
185
|
+
throw new Error(
|
|
186
|
+
"OAuth2 credentials not configured. Required environment variables:\n" +
|
|
187
|
+
" - FIGMA_CLIENT_ID\n" +
|
|
188
|
+
" - FIGMA_CLIENT_SECRET\n" +
|
|
189
|
+
" - FIGMA_REFRESH_TOKEN",
|
|
190
|
+
);
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
try {
|
|
194
|
+
// Figma requires HTTP Basic Authentication with Base64 encoded client_id:client_secret
|
|
195
|
+
const credentials = Buffer.from(`${clientId}:${clientSecret}`).toString(
|
|
196
|
+
"base64",
|
|
197
|
+
);
|
|
198
|
+
|
|
199
|
+
// Figma requires form-encoded body for refresh token endpoint
|
|
200
|
+
const formData = new URLSearchParams({
|
|
201
|
+
refresh_token: refreshToken,
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
const response = await fetch(`${FIGMA_OAUTH_BASE}/oauth/refresh`, {
|
|
205
|
+
method: "POST",
|
|
206
|
+
headers: {
|
|
207
|
+
"Content-Type": "application/x-www-form-urlencoded",
|
|
208
|
+
Authorization: `Basic ${credentials}`,
|
|
209
|
+
},
|
|
210
|
+
body: formData.toString(),
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
if (!response.ok) {
|
|
214
|
+
const errorText = await response.text();
|
|
215
|
+
let errorMessage = `Token refresh failed (${response.status}): ${errorText || response.statusText}`;
|
|
216
|
+
|
|
217
|
+
// Parse error details if available
|
|
218
|
+
try {
|
|
219
|
+
const errorJson = JSON.parse(errorText);
|
|
220
|
+
if (errorJson.error) {
|
|
221
|
+
errorMessage = `Token refresh failed: ${errorJson.error}`;
|
|
222
|
+
if (errorJson.error_description) {
|
|
223
|
+
errorMessage += ` - ${errorJson.error_description}`;
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
} catch {
|
|
227
|
+
// Not JSON, use raw text
|
|
228
|
+
if (errorText && errorText.length < 200) {
|
|
229
|
+
errorMessage += ` (${errorText})`;
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
// Handle specific error cases
|
|
234
|
+
if (response.status === 401 || errorMessage.includes("invalid_grant")) {
|
|
235
|
+
throw new Error(
|
|
236
|
+
"Authentication Failed - Please Re-Login\n\n" +
|
|
237
|
+
"The refresh token has expired or been revoked. You need to:\n" +
|
|
238
|
+
" 1. Re-authenticate with Figma OAuth2\n" +
|
|
239
|
+
" 2. Obtain a new refresh token\n" +
|
|
240
|
+
" 3. Update FIGMA_REFRESH_TOKEN in your configuration",
|
|
241
|
+
);
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
throw new Error(errorMessage);
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
const data = await response.json();
|
|
248
|
+
|
|
249
|
+
if (!data.access_token) {
|
|
250
|
+
throw new Error("Token refresh response missing access_token");
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
// Update token cache
|
|
254
|
+
tokenCache.accessToken = data.access_token;
|
|
255
|
+
|
|
256
|
+
// Figma access tokens typically expire in 1 hour (3600 seconds)
|
|
257
|
+
// Use expires_in if provided, otherwise default to 1 hour
|
|
258
|
+
const expiresIn = data.expires_in || 3600;
|
|
259
|
+
tokenCache.expiresAt = Date.now() + expiresIn * 1000;
|
|
260
|
+
|
|
261
|
+
// Handle token rotation - Figma may return a new refresh token
|
|
262
|
+
if (data.refresh_token && data.refresh_token !== refreshToken) {
|
|
263
|
+
tokenCache.refreshToken = data.refresh_token;
|
|
264
|
+
|
|
265
|
+
// Log warning about token rotation
|
|
266
|
+
console.warn("\n⚠️ WARNING: Refresh Token has rotated!");
|
|
267
|
+
console.warn(" A new refresh token was returned by Figma.");
|
|
268
|
+
console.warn(
|
|
269
|
+
" Please update your configuration with the new refresh token:\n",
|
|
270
|
+
);
|
|
271
|
+
console.warn(` New Refresh Token: ${data.refresh_token}\n`);
|
|
272
|
+
console.warn(" Update the following:");
|
|
273
|
+
console.warn(" - FIGMA_REFRESH_TOKEN environment variable");
|
|
274
|
+
console.warn(" - GitHub secret (if running in CI)\n");
|
|
275
|
+
|
|
276
|
+
// In CI, also log to stdout for easy capture
|
|
277
|
+
if (process.env.CI || process.env.GITHUB_ACTIONS) {
|
|
278
|
+
console.log("::warning::Refresh token rotated - update your secrets");
|
|
279
|
+
console.log(`FIGMA_REFRESH_TOKEN=${data.refresh_token}`);
|
|
280
|
+
}
|
|
281
|
+
} else {
|
|
282
|
+
// Keep the existing refresh token
|
|
283
|
+
tokenCache.refreshToken = refreshToken;
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
return data.access_token;
|
|
287
|
+
} catch (error) {
|
|
288
|
+
// Re-throw with more context if it's not already a formatted error
|
|
289
|
+
if (error.message && !error.message.includes("Authentication Failed")) {
|
|
290
|
+
throw new Error(`Failed to refresh access token: ${error.message}`);
|
|
291
|
+
}
|
|
292
|
+
throw error;
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
/**
|
|
297
|
+
* Get a valid access token using OAuth2 refresh token
|
|
298
|
+
* @param {Object} config - Configuration object
|
|
299
|
+
* @returns {Promise<string>} Access token
|
|
300
|
+
* @throws {Error} If OAuth2 is not configured or refresh fails
|
|
301
|
+
*/
|
|
302
|
+
async function getAccessToken(config) {
|
|
303
|
+
if (!isOAuth2Configured(config)) {
|
|
304
|
+
throw new Error(
|
|
305
|
+
"OAuth2 credentials not configured.\n\n" +
|
|
306
|
+
"Please provide OAuth2 credentials as environment variables:\n" +
|
|
307
|
+
" - FIGMA_CLIENT_ID\n" +
|
|
308
|
+
" - FIGMA_CLIENT_SECRET\n" +
|
|
309
|
+
" - FIGMA_REFRESH_TOKEN\n\n" +
|
|
310
|
+
"Get OAuth2 credentials from: https://www.figma.com/developers/apps",
|
|
311
|
+
);
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
// Check if we have a valid cached token
|
|
315
|
+
if (isTokenValid()) {
|
|
316
|
+
return tokenCache.accessToken;
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
// Refresh the token
|
|
320
|
+
return await refreshAccessToken(config);
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
/**
|
|
324
|
+
* Get authentication method being used
|
|
325
|
+
* @param {Object} config - Configuration object
|
|
326
|
+
* @returns {Promise<string>} Authentication method (always 'OAuth2' if configured)
|
|
327
|
+
*/
|
|
328
|
+
async function getAuthMethod(config) {
|
|
329
|
+
if (isOAuth2Configured(config)) {
|
|
330
|
+
return "OAuth2";
|
|
331
|
+
}
|
|
332
|
+
return "None";
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
/**
|
|
336
|
+
* Clear the token cache (useful for testing or forced refresh)
|
|
337
|
+
*/
|
|
338
|
+
function clearTokenCache() {
|
|
339
|
+
tokenCache = {
|
|
340
|
+
accessToken: null,
|
|
341
|
+
expiresAt: null,
|
|
342
|
+
refreshToken: null,
|
|
343
|
+
};
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
module.exports = {
|
|
347
|
+
loadConfig,
|
|
348
|
+
validateConfig,
|
|
349
|
+
getAccessToken,
|
|
350
|
+
getAuthMethod,
|
|
351
|
+
refreshAccessToken,
|
|
352
|
+
clearTokenCache,
|
|
353
|
+
isOAuth2Configured,
|
|
354
|
+
DEFAULT_CONFIG,
|
|
355
|
+
};
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Figma Constants
|
|
5
|
+
*
|
|
6
|
+
* File-specific constants for Figma integration.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
// Figma file key - specific to the target file
|
|
10
|
+
const FIGMA_PROD_FILE_KEY = "YiDC0EjGiMSxgXU61X6Qpx";
|
|
11
|
+
|
|
12
|
+
// Variable collection configuration
|
|
13
|
+
const COLLECTION_NAME = "Anvil2 Theme";
|
|
14
|
+
const LIGHT_MODE_NAME = "Light";
|
|
15
|
+
const DARK_MODE_NAME = "Dark";
|
|
16
|
+
|
|
17
|
+
module.exports = {
|
|
18
|
+
FIGMA_PROD_FILE_KEY,
|
|
19
|
+
COLLECTION_NAME,
|
|
20
|
+
LIGHT_MODE_NAME,
|
|
21
|
+
DARK_MODE_NAME,
|
|
22
|
+
};
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Custom Error Classes for Figma Sync
|
|
5
|
+
*
|
|
6
|
+
* Provides structured error handling with error codes and details.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Base error class for all Figma sync errors
|
|
11
|
+
*/
|
|
12
|
+
class FigmaSyncError extends Error {
|
|
13
|
+
constructor(message, code, details = {}) {
|
|
14
|
+
super(message);
|
|
15
|
+
this.name = "FigmaSyncError";
|
|
16
|
+
this.code = code;
|
|
17
|
+
this.details = details;
|
|
18
|
+
Error.captureStackTrace(this, this.constructor);
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Authentication-related errors
|
|
24
|
+
*/
|
|
25
|
+
class AuthenticationError extends FigmaSyncError {
|
|
26
|
+
constructor(message, details = {}) {
|
|
27
|
+
super(message, "AUTH_ERROR", details);
|
|
28
|
+
this.name = "AuthenticationError";
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* File access-related errors
|
|
34
|
+
*/
|
|
35
|
+
class FileAccessError extends FigmaSyncError {
|
|
36
|
+
constructor(message, details = {}) {
|
|
37
|
+
super(message, "FILE_ACCESS_ERROR", details);
|
|
38
|
+
this.name = "FileAccessError";
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Configuration validation errors
|
|
44
|
+
*/
|
|
45
|
+
class ConfigurationError extends FigmaSyncError {
|
|
46
|
+
constructor(message, details = {}) {
|
|
47
|
+
super(message, "CONFIG_ERROR", details);
|
|
48
|
+
this.name = "ConfigurationError";
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Token validation errors
|
|
54
|
+
*/
|
|
55
|
+
class TokenValidationError extends FigmaSyncError {
|
|
56
|
+
constructor(message, details = {}) {
|
|
57
|
+
super(message, "TOKEN_VALIDATION_ERROR", details);
|
|
58
|
+
this.name = "TokenValidationError";
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* API request errors
|
|
64
|
+
*/
|
|
65
|
+
class ApiError extends FigmaSyncError {
|
|
66
|
+
constructor(message, statusCode, details = {}) {
|
|
67
|
+
super(message, "API_ERROR", { ...details, statusCode });
|
|
68
|
+
this.name = "ApiError";
|
|
69
|
+
this.statusCode = statusCode;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
module.exports = {
|
|
74
|
+
FigmaSyncError,
|
|
75
|
+
AuthenticationError,
|
|
76
|
+
FileAccessError,
|
|
77
|
+
ConfigurationError,
|
|
78
|
+
TokenValidationError,
|
|
79
|
+
ApiError,
|
|
80
|
+
};
|