@shopify/shop-minis-react 0.1.6 → 0.1.8
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/dist/_virtual/index4.js +2 -2
- package/dist/_virtual/index5.js +3 -2
- package/dist/_virtual/index5.js.map +1 -1
- package/dist/_virtual/index6.js +2 -2
- package/dist/_virtual/index7.js +2 -2
- package/dist/_virtual/index8.js +2 -3
- package/dist/_virtual/index8.js.map +1 -1
- package/dist/hooks/navigation/useDeeplink.js +9 -9
- package/dist/hooks/navigation/useDeeplink.js.map +1 -1
- package/dist/mocks.js +1 -0
- package/dist/mocks.js.map +1 -1
- package/dist/shop-minis-react/node_modules/.pnpm/@radix-ui_react-use-is-hydrated@0.1.0_@types_react@19.1.6_react@19.1.0/node_modules/@radix-ui/react-use-is-hydrated/dist/index.js +1 -1
- package/dist/shop-minis-react/node_modules/.pnpm/@videojs_xhr@2.7.0/node_modules/@videojs/xhr/lib/index.js +1 -1
- package/dist/shop-minis-react/node_modules/.pnpm/color-string@1.9.1/node_modules/color-string/index.js +1 -1
- package/dist/shop-minis-react/node_modules/.pnpm/mpd-parser@1.3.1/node_modules/mpd-parser/dist/mpd-parser.es.js +1 -1
- package/dist/shop-minis-react/node_modules/.pnpm/querystringify@2.2.0/node_modules/querystringify/index.js +1 -1
- package/dist/shop-minis-react/node_modules/.pnpm/simple-swizzle@0.2.2/node_modules/simple-swizzle/index.js +1 -1
- package/dist/shop-minis-react/node_modules/.pnpm/use-sync-external-store@1.5.0_react@19.1.0/node_modules/use-sync-external-store/shim/index.js +1 -1
- package/eslint/README.md +201 -0
- package/eslint/config.cjs +32 -0
- package/eslint/index.cjs +17 -0
- package/eslint/rules/no-internal-imports.cjs +43 -0
- package/eslint/rules/prefer-sdk-components.cjs +153 -0
- package/eslint/rules/validate-manifest.cjs +607 -0
- package/generated-hook-maps/hook-actions-map.json +130 -0
- package/generated-hook-maps/hook-scopes-map.json +48 -0
- package/package.json +10 -4
- package/src/components/commerce/product-link.test.tsx +1 -0
- package/src/hooks/navigation/useDeeplink.test.ts +429 -0
- package/src/hooks/navigation/useDeeplink.ts +6 -3
- package/src/mocks.ts +1 -0
- package/src/test-utils.tsx +1 -0
|
@@ -0,0 +1,607 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ESLint rule to validate manifest.json configuration
|
|
3
|
+
* @fileoverview Validates that manifest.json contains required scopes and permissions
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
const fs = require('fs')
|
|
7
|
+
const path = require('path')
|
|
8
|
+
|
|
9
|
+
// Load the hook-scopes map
|
|
10
|
+
const hookScopesMap = require('../../generated-hook-maps/hook-scopes-map.json')
|
|
11
|
+
|
|
12
|
+
// Module-level cache for manifest.json to avoid repeated file I/O
|
|
13
|
+
// ESLint reuses the same rule module across all files, so this cache
|
|
14
|
+
// persists for the entire lint run, dramatically improving performance
|
|
15
|
+
// Cache is invalidated if manifest.json modification time changes (for IDE support)
|
|
16
|
+
let manifestCache = null
|
|
17
|
+
let manifestPathCache = null
|
|
18
|
+
let manifestParseErrorCache = null
|
|
19
|
+
let manifestMtimeCache = null
|
|
20
|
+
|
|
21
|
+
// Hook to permission mappings
|
|
22
|
+
const hookPermissionsMap = {
|
|
23
|
+
useImagePicker: ['CAMERA'],
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// Extract domain from URL (returns domain in CSP format)
|
|
27
|
+
function extractDomain(url) {
|
|
28
|
+
if (!url || typeof url !== 'string') return null
|
|
29
|
+
|
|
30
|
+
try {
|
|
31
|
+
// Handle relative URLs
|
|
32
|
+
if (url.startsWith('/') || url.startsWith('./') || url.startsWith('../')) {
|
|
33
|
+
return null
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// Handle data: and blob: URLs
|
|
37
|
+
if (url.startsWith('data:') || url.startsWith('blob:')) {
|
|
38
|
+
return null
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// Parse URL
|
|
42
|
+
const urlObj = new URL(url)
|
|
43
|
+
return urlObj.hostname
|
|
44
|
+
} catch (err) {
|
|
45
|
+
// If URL parsing fails, might be a relative path
|
|
46
|
+
return null
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// Check if domain matches a pattern (supports wildcards)
|
|
51
|
+
function domainMatchesPattern(domain, pattern) {
|
|
52
|
+
if (pattern === '*') return true
|
|
53
|
+
if (pattern === domain) return true
|
|
54
|
+
|
|
55
|
+
// Handle wildcard subdomains (*.example.com)
|
|
56
|
+
if (pattern.startsWith('*.')) {
|
|
57
|
+
const basePattern = pattern.slice(2)
|
|
58
|
+
return domain === basePattern || domain.endsWith(`.${basePattern}`)
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
return false
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
module.exports = {
|
|
65
|
+
meta: {
|
|
66
|
+
type: 'problem',
|
|
67
|
+
docs: {
|
|
68
|
+
description:
|
|
69
|
+
'Ensure manifest.json includes required scopes and permissions for Shop Minis features',
|
|
70
|
+
category: 'Possible Errors',
|
|
71
|
+
recommended: true,
|
|
72
|
+
},
|
|
73
|
+
fixable: 'code',
|
|
74
|
+
messages: {
|
|
75
|
+
missingScope:
|
|
76
|
+
'Hook "{{hookName}}" requires scope "{{scope}}" in src/manifest.json. Add "{{scope}}" to the "scopes" array.',
|
|
77
|
+
missingPermission:
|
|
78
|
+
'{{reason}} requires permission "{{permission}}" in src/manifest.json. Add "{{permission}}" to the "permissions" array.',
|
|
79
|
+
missingTrustedDomain:
|
|
80
|
+
'{{reason}} loads from "{{domain}}" which is not in trusted_domains. Add "{{domain}}" to the "trusted_domains" array in src/manifest.json.',
|
|
81
|
+
manifestNotFound:
|
|
82
|
+
'Cannot find src/manifest.json. Create a manifest file with required scopes/permissions/trusted_domains.',
|
|
83
|
+
manifestInvalidJson:
|
|
84
|
+
'Failed to parse src/manifest.json: {{error}}. Please fix the JSON syntax (common issues: trailing commas, missing quotes).',
|
|
85
|
+
},
|
|
86
|
+
schema: [],
|
|
87
|
+
},
|
|
88
|
+
|
|
89
|
+
create(context) {
|
|
90
|
+
// eslint-disable-next-line @shopify/prefer-module-scope-constants
|
|
91
|
+
const SDK_PACKAGE = '@shopify/shop-minis-react'
|
|
92
|
+
let manifestPath = null
|
|
93
|
+
let manifest = null
|
|
94
|
+
let manifestParseError = null
|
|
95
|
+
const usedHooks = new Set()
|
|
96
|
+
const requiredPermissions = new Set()
|
|
97
|
+
const requiredDomains = new Set()
|
|
98
|
+
const fixedIssues = new Set()
|
|
99
|
+
|
|
100
|
+
// Check module-level cache first to avoid repeated file I/O
|
|
101
|
+
if (manifestPathCache && fs.existsSync(manifestPathCache)) {
|
|
102
|
+
// Check if manifest.json has been modified (for IDE integration)
|
|
103
|
+
const currentMtime = fs.statSync(manifestPathCache).mtimeMs
|
|
104
|
+
|
|
105
|
+
if (manifestMtimeCache === currentMtime) {
|
|
106
|
+
// Cache is still valid - use it
|
|
107
|
+
manifestPath = manifestPathCache
|
|
108
|
+
manifest = manifestCache
|
|
109
|
+
manifestParseError = manifestParseErrorCache
|
|
110
|
+
} else {
|
|
111
|
+
// File was modified - invalidate cache and reload
|
|
112
|
+
manifestCache = null
|
|
113
|
+
manifestParseErrorCache = null
|
|
114
|
+
manifestMtimeCache = null
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// Load manifest if not cached or cache was invalidated
|
|
119
|
+
if (!manifestPath) {
|
|
120
|
+
// Cache miss - find and load manifest.json
|
|
121
|
+
const filename = context.getFilename()
|
|
122
|
+
if (filename && filename !== '<input>') {
|
|
123
|
+
const dir = path.dirname(filename)
|
|
124
|
+
// Look for src/manifest.json
|
|
125
|
+
let currentDir = dir
|
|
126
|
+
for (let i = 0; i < 5; i++) {
|
|
127
|
+
const testPath = path.join(currentDir, 'src', 'manifest.json')
|
|
128
|
+
if (fs.existsSync(testPath)) {
|
|
129
|
+
manifestPath = testPath
|
|
130
|
+
try {
|
|
131
|
+
manifest = JSON.parse(fs.readFileSync(testPath, 'utf8'))
|
|
132
|
+
// Store modification time for cache invalidation
|
|
133
|
+
manifestMtimeCache = fs.statSync(testPath).mtimeMs
|
|
134
|
+
} catch (err) {
|
|
135
|
+
// Invalid JSON - save error for reporting
|
|
136
|
+
manifestParseError = err.message
|
|
137
|
+
}
|
|
138
|
+
break
|
|
139
|
+
}
|
|
140
|
+
currentDir = path.join(currentDir, '..')
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// Update cache for subsequent files
|
|
145
|
+
manifestPathCache = manifestPath
|
|
146
|
+
manifestCache = manifest
|
|
147
|
+
manifestParseErrorCache = manifestParseError
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
return {
|
|
151
|
+
// Track imports from the SDK
|
|
152
|
+
ImportDeclaration(node) {
|
|
153
|
+
if (node.source.value === SDK_PACKAGE) {
|
|
154
|
+
// eslint-disable-next-line @shopify/prefer-early-return
|
|
155
|
+
node.specifiers.forEach(spec => {
|
|
156
|
+
if (spec.type === 'ImportSpecifier') {
|
|
157
|
+
const importedName = spec.imported.name
|
|
158
|
+
|
|
159
|
+
// Check if it's a hook that requires scopes
|
|
160
|
+
if (hookScopesMap[importedName]) {
|
|
161
|
+
usedHooks.add(importedName)
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// Check if it's a hook that requires permissions
|
|
165
|
+
if (hookPermissionsMap[importedName]) {
|
|
166
|
+
hookPermissionsMap[importedName].forEach(permission => {
|
|
167
|
+
requiredPermissions.add({
|
|
168
|
+
permission,
|
|
169
|
+
reason: `Hook "${importedName}"`,
|
|
170
|
+
node,
|
|
171
|
+
})
|
|
172
|
+
})
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
})
|
|
176
|
+
}
|
|
177
|
+
},
|
|
178
|
+
|
|
179
|
+
// Detect browser API usage for permissions
|
|
180
|
+
MemberExpression(node) {
|
|
181
|
+
const sourceCode = context.getSourceCode()
|
|
182
|
+
const code = sourceCode.getText(node)
|
|
183
|
+
|
|
184
|
+
// Check for camera/microphone API
|
|
185
|
+
if (
|
|
186
|
+
code.includes('navigator.mediaDevices.getUserMedia') ||
|
|
187
|
+
code.includes('navigator.getUserMedia')
|
|
188
|
+
) {
|
|
189
|
+
// Need to check the constraints to determine if it's camera or microphone
|
|
190
|
+
// For now, we'll flag both as potentially needed
|
|
191
|
+
requiredPermissions.add({
|
|
192
|
+
permission: 'CAMERA',
|
|
193
|
+
reason: 'getUserMedia API usage',
|
|
194
|
+
node,
|
|
195
|
+
})
|
|
196
|
+
requiredPermissions.add({
|
|
197
|
+
permission: 'MICROPHONE',
|
|
198
|
+
reason: 'getUserMedia API usage',
|
|
199
|
+
node,
|
|
200
|
+
})
|
|
201
|
+
}
|
|
202
|
+
},
|
|
203
|
+
|
|
204
|
+
// Detect network requests and event listeners
|
|
205
|
+
CallExpression(node) {
|
|
206
|
+
if (!node.arguments[0]) {
|
|
207
|
+
return
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
const firstArg = node.arguments[0]
|
|
211
|
+
|
|
212
|
+
// Check for fetch() calls
|
|
213
|
+
if (node.callee.name === 'fetch' && firstArg.type === 'Literal') {
|
|
214
|
+
const url = firstArg.value
|
|
215
|
+
const domain = extractDomain(url)
|
|
216
|
+
if (domain) {
|
|
217
|
+
requiredDomains.add({
|
|
218
|
+
domain,
|
|
219
|
+
reason: 'fetch() call',
|
|
220
|
+
node,
|
|
221
|
+
})
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
// Check for XMLHttpRequest.open()
|
|
226
|
+
if (
|
|
227
|
+
node.callee.type === 'MemberExpression' &&
|
|
228
|
+
node.callee.property.name === 'open' &&
|
|
229
|
+
node.arguments[1] &&
|
|
230
|
+
node.arguments[1].type === 'Literal'
|
|
231
|
+
) {
|
|
232
|
+
const url = node.arguments[1].value
|
|
233
|
+
const domain = extractDomain(url)
|
|
234
|
+
if (domain) {
|
|
235
|
+
requiredDomains.add({
|
|
236
|
+
domain,
|
|
237
|
+
reason: 'XMLHttpRequest.open() call',
|
|
238
|
+
node,
|
|
239
|
+
})
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
// Check for WebSocket
|
|
244
|
+
if (node.callee.name === 'WebSocket' && firstArg.type === 'Literal') {
|
|
245
|
+
const url = firstArg.value
|
|
246
|
+
// WebSocket URLs use ws:// or wss://
|
|
247
|
+
const domain = url.replace(/^wss?:\/\//, 'https://')
|
|
248
|
+
const extracted = extractDomain(domain)
|
|
249
|
+
if (extracted) {
|
|
250
|
+
requiredDomains.add({
|
|
251
|
+
domain: extracted,
|
|
252
|
+
reason: 'WebSocket connection',
|
|
253
|
+
node,
|
|
254
|
+
})
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
// Check for EventSource
|
|
259
|
+
if (node.callee.name === 'EventSource' && firstArg.type === 'Literal') {
|
|
260
|
+
const url = firstArg.value
|
|
261
|
+
const domain = extractDomain(url)
|
|
262
|
+
if (domain) {
|
|
263
|
+
requiredDomains.add({
|
|
264
|
+
domain,
|
|
265
|
+
reason: 'EventSource connection',
|
|
266
|
+
node,
|
|
267
|
+
})
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
// Check for navigator.sendBeacon()
|
|
272
|
+
if (
|
|
273
|
+
node.callee.type === 'MemberExpression' &&
|
|
274
|
+
node.callee.property.name === 'sendBeacon' &&
|
|
275
|
+
firstArg.type === 'Literal'
|
|
276
|
+
) {
|
|
277
|
+
const url = firstArg.value
|
|
278
|
+
const domain = extractDomain(url)
|
|
279
|
+
if (domain) {
|
|
280
|
+
requiredDomains.add({
|
|
281
|
+
domain,
|
|
282
|
+
reason: 'navigator.sendBeacon() call',
|
|
283
|
+
node,
|
|
284
|
+
})
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
// Check for window.open()
|
|
289
|
+
if (
|
|
290
|
+
node.callee.type === 'MemberExpression' &&
|
|
291
|
+
node.callee.property.name === 'open' &&
|
|
292
|
+
node.callee.object.name === 'window' &&
|
|
293
|
+
firstArg.type === 'Literal'
|
|
294
|
+
) {
|
|
295
|
+
const url = firstArg.value
|
|
296
|
+
const domain = extractDomain(url)
|
|
297
|
+
if (domain) {
|
|
298
|
+
requiredDomains.add({
|
|
299
|
+
domain,
|
|
300
|
+
reason: 'window.open() call',
|
|
301
|
+
node,
|
|
302
|
+
})
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
// Check addEventListener for device motion/orientation
|
|
307
|
+
if (
|
|
308
|
+
node.callee.type === 'MemberExpression' &&
|
|
309
|
+
node.callee.property.name === 'addEventListener' &&
|
|
310
|
+
firstArg.type === 'Literal'
|
|
311
|
+
) {
|
|
312
|
+
const eventName = firstArg.value
|
|
313
|
+
if (
|
|
314
|
+
eventName === 'deviceorientation' ||
|
|
315
|
+
eventName === 'devicemotion'
|
|
316
|
+
) {
|
|
317
|
+
requiredPermissions.add({
|
|
318
|
+
permission: 'MOTION',
|
|
319
|
+
reason: `${eventName} event listener`,
|
|
320
|
+
node,
|
|
321
|
+
})
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
},
|
|
325
|
+
|
|
326
|
+
// Check JSX attributes for external URLs
|
|
327
|
+
JSXAttribute(node) {
|
|
328
|
+
if (!node.value || node.value.type !== 'Literal') {
|
|
329
|
+
return
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
const attrName = node.name.name
|
|
333
|
+
const url = node.value.value
|
|
334
|
+
const domain = extractDomain(url)
|
|
335
|
+
|
|
336
|
+
if (!domain) {
|
|
337
|
+
return
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
const elementName = node.parent.name.name
|
|
341
|
+
|
|
342
|
+
// src attributes (img, video, audio, source, track, embed)
|
|
343
|
+
// Exclude: script, iframe (not supported)
|
|
344
|
+
if (attrName === 'src') {
|
|
345
|
+
const supportedElements = [
|
|
346
|
+
'img',
|
|
347
|
+
'video',
|
|
348
|
+
'audio',
|
|
349
|
+
'source',
|
|
350
|
+
'track',
|
|
351
|
+
'embed',
|
|
352
|
+
'Image',
|
|
353
|
+
'VideoPlayer',
|
|
354
|
+
]
|
|
355
|
+
if (supportedElements.includes(elementName)) {
|
|
356
|
+
requiredDomains.add({
|
|
357
|
+
domain,
|
|
358
|
+
reason: `<${elementName}> src attribute`,
|
|
359
|
+
node,
|
|
360
|
+
})
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
// poster attribute (video)
|
|
364
|
+
else if (attrName === 'poster') {
|
|
365
|
+
requiredDomains.add({
|
|
366
|
+
domain,
|
|
367
|
+
reason: `<${elementName}> poster attribute`,
|
|
368
|
+
node,
|
|
369
|
+
})
|
|
370
|
+
}
|
|
371
|
+
// data attribute (object)
|
|
372
|
+
else if (attrName === 'data') {
|
|
373
|
+
requiredDomains.add({
|
|
374
|
+
domain,
|
|
375
|
+
reason: `<${elementName}> data attribute`,
|
|
376
|
+
node,
|
|
377
|
+
})
|
|
378
|
+
}
|
|
379
|
+
// action attribute (form)
|
|
380
|
+
else if (attrName === 'action') {
|
|
381
|
+
requiredDomains.add({
|
|
382
|
+
domain,
|
|
383
|
+
reason: `<${elementName}> action attribute`,
|
|
384
|
+
node,
|
|
385
|
+
})
|
|
386
|
+
}
|
|
387
|
+
},
|
|
388
|
+
|
|
389
|
+
// Detect DeviceOrientation/Motion events
|
|
390
|
+
Identifier(node) {
|
|
391
|
+
if (
|
|
392
|
+
node.name === 'DeviceOrientationEvent' ||
|
|
393
|
+
node.name === 'DeviceMotionEvent'
|
|
394
|
+
) {
|
|
395
|
+
requiredPermissions.add({
|
|
396
|
+
permission: 'MOTION',
|
|
397
|
+
reason: `${node.name} usage`,
|
|
398
|
+
node,
|
|
399
|
+
})
|
|
400
|
+
}
|
|
401
|
+
},
|
|
402
|
+
|
|
403
|
+
// Check everything at the end
|
|
404
|
+
'Program:exit': function () {
|
|
405
|
+
const issues = []
|
|
406
|
+
|
|
407
|
+
// If manifest exists but has parse error, report it once
|
|
408
|
+
if (
|
|
409
|
+
manifestParseError &&
|
|
410
|
+
(usedHooks.size > 0 ||
|
|
411
|
+
requiredPermissions.size > 0 ||
|
|
412
|
+
requiredDomains.size > 0)
|
|
413
|
+
) {
|
|
414
|
+
context.report({
|
|
415
|
+
loc: {line: 1, column: 0},
|
|
416
|
+
messageId: 'manifestInvalidJson',
|
|
417
|
+
data: {error: manifestParseError},
|
|
418
|
+
})
|
|
419
|
+
return
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
// Check scopes for hooks
|
|
423
|
+
usedHooks.forEach(hookName => {
|
|
424
|
+
const requiredScopes = hookScopesMap[hookName]
|
|
425
|
+
if (!requiredScopes || requiredScopes.length === 0) {
|
|
426
|
+
return
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
if (!manifest) {
|
|
430
|
+
issues.push({
|
|
431
|
+
messageId: 'manifestNotFound',
|
|
432
|
+
data: {hookName, scopes: requiredScopes.join(', ')},
|
|
433
|
+
})
|
|
434
|
+
return
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
const manifestScopes = manifest.scopes || []
|
|
438
|
+
|
|
439
|
+
requiredScopes.forEach(requiredScope => {
|
|
440
|
+
if (!manifestScopes.includes(requiredScope)) {
|
|
441
|
+
issues.push({
|
|
442
|
+
type: 'scope',
|
|
443
|
+
scope: requiredScope,
|
|
444
|
+
hookName,
|
|
445
|
+
})
|
|
446
|
+
}
|
|
447
|
+
})
|
|
448
|
+
})
|
|
449
|
+
|
|
450
|
+
// Check permissions
|
|
451
|
+
requiredPermissions.forEach(({permission, reason, node}) => {
|
|
452
|
+
if (!manifest) {
|
|
453
|
+
issues.push({
|
|
454
|
+
messageId: 'manifestNotFound',
|
|
455
|
+
data: {reason, permission},
|
|
456
|
+
})
|
|
457
|
+
return
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
const manifestPermissions = manifest.permissions || []
|
|
461
|
+
|
|
462
|
+
if (!manifestPermissions.includes(permission)) {
|
|
463
|
+
issues.push({
|
|
464
|
+
type: 'permission',
|
|
465
|
+
permission,
|
|
466
|
+
reason,
|
|
467
|
+
node,
|
|
468
|
+
})
|
|
469
|
+
}
|
|
470
|
+
})
|
|
471
|
+
|
|
472
|
+
// Check trusted domains
|
|
473
|
+
requiredDomains.forEach(({domain, reason, node}) => {
|
|
474
|
+
if (!manifest) {
|
|
475
|
+
issues.push({
|
|
476
|
+
messageId: 'manifestNotFound',
|
|
477
|
+
data: {reason, domain},
|
|
478
|
+
})
|
|
479
|
+
return
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
const trustedDomains = manifest.trusted_domains || []
|
|
483
|
+
|
|
484
|
+
// Check if domain is trusted (supports wildcards)
|
|
485
|
+
const isTrusted = trustedDomains.some(pattern =>
|
|
486
|
+
domainMatchesPattern(domain, pattern)
|
|
487
|
+
)
|
|
488
|
+
|
|
489
|
+
if (!isTrusted) {
|
|
490
|
+
issues.push({
|
|
491
|
+
type: 'domain',
|
|
492
|
+
domain,
|
|
493
|
+
reason,
|
|
494
|
+
node,
|
|
495
|
+
})
|
|
496
|
+
}
|
|
497
|
+
})
|
|
498
|
+
|
|
499
|
+
// Report all issues
|
|
500
|
+
issues.forEach(issue => {
|
|
501
|
+
if (issue.messageId) {
|
|
502
|
+
context.report({
|
|
503
|
+
loc: {line: 1, column: 0},
|
|
504
|
+
messageId: issue.messageId,
|
|
505
|
+
data: issue.data,
|
|
506
|
+
})
|
|
507
|
+
} else if (issue.type === 'scope') {
|
|
508
|
+
const issueKey = `scopes:${issue.scope}`
|
|
509
|
+
const fixer = createManifestFixer('scopes', issue.scope)
|
|
510
|
+
|
|
511
|
+
// Call fixer immediately to check if we can fix
|
|
512
|
+
fixer()
|
|
513
|
+
|
|
514
|
+
// Only report if not fixed
|
|
515
|
+
if (!fixedIssues.has(issueKey)) {
|
|
516
|
+
context.report({
|
|
517
|
+
loc: {line: 1, column: 0},
|
|
518
|
+
messageId: 'missingScope',
|
|
519
|
+
data: {
|
|
520
|
+
hookName: issue.hookName,
|
|
521
|
+
scope: issue.scope,
|
|
522
|
+
},
|
|
523
|
+
})
|
|
524
|
+
}
|
|
525
|
+
} else if (issue.type === 'permission') {
|
|
526
|
+
const issueKey = `permissions:${issue.permission}`
|
|
527
|
+
const fixer = createManifestFixer('permissions', issue.permission)
|
|
528
|
+
|
|
529
|
+
fixer()
|
|
530
|
+
|
|
531
|
+
if (!fixedIssues.has(issueKey)) {
|
|
532
|
+
context.report({
|
|
533
|
+
node: issue.node || {loc: {line: 1, column: 0}},
|
|
534
|
+
messageId: 'missingPermission',
|
|
535
|
+
data: {
|
|
536
|
+
reason: issue.reason,
|
|
537
|
+
permission: issue.permission,
|
|
538
|
+
},
|
|
539
|
+
})
|
|
540
|
+
}
|
|
541
|
+
} else if (issue.type === 'domain') {
|
|
542
|
+
const issueKey = `trusted_domains:${issue.domain}`
|
|
543
|
+
const fixer = createManifestFixer('trusted_domains', issue.domain)
|
|
544
|
+
|
|
545
|
+
fixer()
|
|
546
|
+
|
|
547
|
+
if (!fixedIssues.has(issueKey)) {
|
|
548
|
+
context.report({
|
|
549
|
+
node: issue.node || {loc: {line: 1, column: 0}},
|
|
550
|
+
messageId: 'missingTrustedDomain',
|
|
551
|
+
data: {
|
|
552
|
+
reason: issue.reason,
|
|
553
|
+
domain: issue.domain,
|
|
554
|
+
},
|
|
555
|
+
})
|
|
556
|
+
}
|
|
557
|
+
}
|
|
558
|
+
})
|
|
559
|
+
},
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
function createManifestFixer(field, value) {
|
|
563
|
+
return function () {
|
|
564
|
+
// Explicit check: Only run if --fix flag was passed
|
|
565
|
+
const hasFixFlag = process.argv.includes('--fix')
|
|
566
|
+
if (!hasFixFlag) {
|
|
567
|
+
return null
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
if (!manifestPath || manifestParseError) {
|
|
571
|
+
return null
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
try {
|
|
575
|
+
const currentManifest = JSON.parse(
|
|
576
|
+
fs.readFileSync(manifestPath, 'utf8')
|
|
577
|
+
)
|
|
578
|
+
|
|
579
|
+
if (!currentManifest[field]) {
|
|
580
|
+
currentManifest[field] = []
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
if (!currentManifest[field].includes(value)) {
|
|
584
|
+
currentManifest[field].push(value)
|
|
585
|
+
currentManifest[field].sort()
|
|
586
|
+
|
|
587
|
+
const updatedContent = JSON.stringify(currentManifest, null, 2)
|
|
588
|
+
fs.writeFileSync(manifestPath, `${updatedContent}\n`, 'utf8')
|
|
589
|
+
|
|
590
|
+
// Update cache with the modified manifest and new mtime
|
|
591
|
+
manifestCache = currentManifest
|
|
592
|
+
manifest = currentManifest
|
|
593
|
+
manifestMtimeCache = fs.statSync(manifestPath).mtimeMs
|
|
594
|
+
|
|
595
|
+
// Mark this issue as fixed
|
|
596
|
+
fixedIssues.add(`${field}:${value}`)
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
// Return empty array to signal we handled it
|
|
600
|
+
return []
|
|
601
|
+
} catch (err) {
|
|
602
|
+
return null
|
|
603
|
+
}
|
|
604
|
+
}
|
|
605
|
+
}
|
|
606
|
+
},
|
|
607
|
+
}
|