@servicetitan/hammer-token 2.5.2 → 3.0.1

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 (146) hide show
  1. package/CHANGELOG.md +52 -2
  2. package/README.md +332 -0
  3. package/build/web/core/component-variables.scss +1088 -131
  4. package/build/web/core/component.d.ts +558 -0
  5. package/build/web/core/component.js +6685 -249
  6. package/build/web/core/component.scss +557 -69
  7. package/build/web/core/css-utils/a2-border.css +23 -51
  8. package/build/web/core/css-utils/a2-color.css +221 -233
  9. package/build/web/core/css-utils/a2-font.css +1 -29
  10. package/build/web/core/css-utils/a2-spacing.css +238 -483
  11. package/build/web/core/css-utils/a2-utils.css +496 -781
  12. package/build/web/core/css-utils/border.css +23 -51
  13. package/build/web/core/css-utils/color.css +221 -233
  14. package/build/web/core/css-utils/font.css +1 -29
  15. package/build/web/core/css-utils/spacing.css +238 -483
  16. package/build/web/core/css-utils/utils.css +496 -781
  17. package/build/web/core/index.d.ts +6 -0
  18. package/build/web/core/index.js +1 -1
  19. package/build/web/core/primitive-variables.scss +148 -65
  20. package/build/web/core/primitive.d.ts +209 -0
  21. package/build/web/core/primitive.js +779 -61
  22. package/build/web/core/primitive.scss +207 -124
  23. package/build/web/core/semantic-variables.scss +363 -245
  24. package/build/web/core/semantic.d.ts +221 -0
  25. package/build/web/core/semantic.js +1592 -347
  26. package/build/web/core/semantic.scss +219 -140
  27. package/build/web/index.d.ts +3 -4
  28. package/build/web/types.d.ts +17 -0
  29. package/config.js +121 -496
  30. package/eslint.config.mjs +11 -1
  31. package/package.json +15 -5
  32. package/src/global/primitive/breakpoint.tokens.json +54 -0
  33. package/src/global/primitive/color.tokens.json +1092 -0
  34. package/src/global/primitive/duration.tokens.json +44 -0
  35. package/src/global/primitive/font.tokens.json +151 -0
  36. package/src/global/primitive/radius.tokens.json +94 -0
  37. package/src/global/primitive/size.tokens.json +174 -0
  38. package/src/global/primitive/transition.tokens.json +32 -0
  39. package/src/theme/core/background.tokens.json +1312 -0
  40. package/src/theme/core/border.tokens.json +192 -0
  41. package/src/theme/core/chart.tokens.json +982 -0
  42. package/src/theme/core/component/ai-mark.tokens.json +20 -0
  43. package/src/theme/core/component/alert.tokens.json +261 -0
  44. package/src/theme/core/component/announcement.tokens.json +460 -0
  45. package/src/theme/core/component/avatar.tokens.json +137 -0
  46. package/src/theme/core/component/badge.tokens.json +42 -0
  47. package/src/theme/core/component/breadcrumb.tokens.json +42 -0
  48. package/src/theme/core/component/button-toggle.tokens.json +428 -0
  49. package/src/theme/core/component/button.tokens.json +941 -0
  50. package/src/theme/core/component/calendar.tokens.json +391 -0
  51. package/src/theme/core/component/card.tokens.json +107 -0
  52. package/src/theme/core/component/checkbox.tokens.json +631 -0
  53. package/src/theme/core/component/chip.tokens.json +169 -0
  54. package/src/theme/core/component/combobox.tokens.json +269 -0
  55. package/src/theme/core/component/details.tokens.json +152 -0
  56. package/src/theme/core/component/dialog.tokens.json +87 -0
  57. package/src/theme/core/component/divider.tokens.json +23 -0
  58. package/src/theme/core/component/dnd.tokens.json +208 -0
  59. package/src/theme/core/component/drawer.tokens.json +61 -0
  60. package/src/theme/core/component/drilldown.tokens.json +61 -0
  61. package/src/theme/core/component/edit-card.tokens.json +381 -0
  62. package/src/theme/core/component/field-label.tokens.json +42 -0
  63. package/src/theme/core/component/field-message.tokens.json +65 -0
  64. package/src/theme/core/component/icon.tokens.json +42 -0
  65. package/src/theme/core/component/link.tokens.json +108 -0
  66. package/src/theme/core/component/list-view.tokens.json +82 -0
  67. package/src/theme/core/component/listbox.tokens.json +283 -0
  68. package/src/theme/core/component/menu.tokens.json +230 -0
  69. package/src/theme/core/component/overflow.tokens.json +84 -0
  70. package/src/theme/core/component/page.tokens.json +377 -0
  71. package/src/theme/core/component/pagination.tokens.json +63 -0
  72. package/src/theme/core/component/popover.tokens.json +122 -0
  73. package/src/theme/core/component/progress-bar.tokens.json +133 -0
  74. package/src/theme/core/component/radio.tokens.json +631 -0
  75. package/src/theme/core/component/segmented-control.tokens.json +175 -0
  76. package/src/theme/core/component/select-card.tokens.json +943 -0
  77. package/src/theme/core/component/side-nav.tokens.json +349 -0
  78. package/src/theme/core/component/skeleton.tokens.json +42 -0
  79. package/src/theme/core/component/spinner.tokens.json +96 -0
  80. package/src/theme/core/component/status-icon.tokens.json +164 -0
  81. package/src/theme/core/component/stepper.tokens.json +484 -0
  82. package/src/theme/core/component/switch.tokens.json +285 -0
  83. package/src/theme/core/component/tab.tokens.json +192 -0
  84. package/src/theme/core/component/text-field.tokens.json +160 -0
  85. package/src/theme/core/component/text.tokens.json +59 -0
  86. package/src/theme/core/component/toast.tokens.json +343 -0
  87. package/src/theme/core/component/toolbar.tokens.json +114 -0
  88. package/src/theme/core/component/tooltip.tokens.json +61 -0
  89. package/src/theme/core/focus.tokens.json +56 -0
  90. package/src/theme/core/foreground.tokens.json +416 -0
  91. package/src/theme/core/gradient.tokens.json +41 -0
  92. package/src/theme/core/opacity.tokens.json +25 -0
  93. package/src/theme/core/shadow.tokens.json +81 -0
  94. package/src/theme/core/status.tokens.json +74 -0
  95. package/src/theme/core/typography.tokens.json +163 -0
  96. package/src/utils/__tests__/css-utils-format-utils.test.js +312 -0
  97. package/src/utils/__tests__/sd-build-configs.test.js +306 -0
  98. package/src/utils/__tests__/sd-formats.test.js +942 -0
  99. package/src/utils/__tests__/sd-transforms.test.js +336 -0
  100. package/src/utils/__tests__/token-helpers.test.js +1160 -0
  101. package/src/utils/copy-css-utils-cli.js +13 -1
  102. package/src/utils/css-utils-format-utils.js +105 -176
  103. package/src/utils/figma/__tests__/sync-gradient.test.js +561 -0
  104. package/src/utils/figma/__tests__/token-conversion.test.js +117 -0
  105. package/src/utils/figma/__tests__/token-resolution.test.js +231 -0
  106. package/src/utils/figma/auth.js +355 -0
  107. package/src/utils/figma/constants.js +22 -0
  108. package/src/utils/figma/errors.js +80 -0
  109. package/src/utils/figma/figma-api.js +1069 -0
  110. package/src/utils/figma/get-token.js +348 -0
  111. package/src/utils/figma/sync-components.js +909 -0
  112. package/src/utils/figma/sync-main.js +692 -0
  113. package/src/utils/figma/sync-orchestration.js +683 -0
  114. package/src/utils/figma/sync-primitives.js +230 -0
  115. package/src/utils/figma/sync-semantic.js +1056 -0
  116. package/src/utils/figma/token-conversion.js +340 -0
  117. package/src/utils/figma/token-parsing.js +186 -0
  118. package/src/utils/figma/token-resolution.js +569 -0
  119. package/src/utils/figma/utils.js +199 -0
  120. package/src/utils/sd-build-configs.js +305 -0
  121. package/src/utils/sd-formats.js +948 -0
  122. package/src/utils/sd-transforms.js +165 -0
  123. package/src/utils/token-helpers.js +848 -0
  124. package/tsconfig.json +18 -0
  125. package/vitest.config.js +17 -0
  126. package/.turbo/turbo-build.log +0 -37
  127. package/build/web/core/raw.js +0 -234
  128. package/src/global/primitive/breakpoint.js +0 -19
  129. package/src/global/primitive/color.js +0 -231
  130. package/src/global/primitive/duration.js +0 -16
  131. package/src/global/primitive/font.js +0 -60
  132. package/src/global/primitive/radius.js +0 -31
  133. package/src/global/primitive/size.js +0 -55
  134. package/src/global/primitive/transition.js +0 -16
  135. package/src/theme/core/background.js +0 -170
  136. package/src/theme/core/border.js +0 -103
  137. package/src/theme/core/charts.js +0 -464
  138. package/src/theme/core/component/button.js +0 -708
  139. package/src/theme/core/component/checkbox.js +0 -405
  140. package/src/theme/core/focus.js +0 -35
  141. package/src/theme/core/foreground.js +0 -148
  142. package/src/theme/core/overlay.js +0 -137
  143. package/src/theme/core/shadow.js +0 -29
  144. package/src/theme/core/status.js +0 -49
  145. package/src/theme/core/typography.js +0 -82
  146. package/type/types.ts +0 -344
@@ -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
+ };